- Add Rev.2 authoritative plan detailing root causes and 2xK decision table - Add Phase 1 & 2 review reports from Claude (planner/reviewer) and Cline (reviewer)
19 KiB
🧭 Layout Engine 개선 계획 (Rev.2) — 결정론적 2×K 그리드
- Planner:
planner-reviewer-claude-01 - Job:
8722045f(Rev.1 =29924fd4) - 개정 사유:
creator-grok-01이의제기(b907f997) 수용 - 검증: BSP 시뮬레이터 + 격리 워크스페이스 실측(
w16/w17, 정리 완료) + 코드 실측
0. Rev.1 대비 변경 요약
| # | 항목 | Rev.1 | Rev.2 |
|---|---|---|---|
| C-1 | 헤드리스 결정 규칙 | "홀짝 반전" (§6.1) | 홀짝 폐기. GUI 와 동일 결정표 공유 (§4.2) |
| C-2 | max_columns 배선 |
시그니처 기본값 2 | _env_int(default=2) + lib.sh --max-cols 동시 적용 (§4.5) |
| C-3 | GUI 채우기 규칙 | singleton 휴리스틱 | 열 페인 수 기반(max_rows 일반화) (§4.1) |
| C-4 | 궤적 표 | (3,2)→(3,3) (3행) |
max_rows=2 와 모순 해소 — 4페인에서 정지 (§4.1) |
| C-5 | .mam.env.example |
미언급 | MAM_MAX_PANE_COLS 문서 기본값 갱신 대상에 추가 (§4.5) |
이의제기 3건 모두 인용(SUSTAINED)합니다. C-3·C-4·C-5 는 이의제기가 드러낸 제 계획서의 추가 결함으로, 자진 정정합니다.
§1~§3(근본 원인 분석·실측 증거)은 Rev.1 그대로 유효하므로 §11 에 요약만 남깁니다.
1. C-1 인용 — 홀짝 반전은 n=3 에서 GUI 와 갈라진다
이의제기 검증
현행 헤드리스(layout.py:113-125)는 n % 2 로 기하를 흉내 냅니다. Rev.1 §6.1 이 "반전"이라고만 적었으므로, 구현자가 문자 그대로 뒤집으면:
| n | 홀짝 반전 결과 | GUI(Rev.2 §4.1) | 판정 |
|---|---|---|---|
| 1 | right |
right |
✅ |
| 2 | down |
down |
✅ |
| 3 | right |
down |
🔴 갈라짐 — 3열을 염 |
| 4 | down |
overflow |
🔴 |
0×0 에서는 열 그룹핑이 불가능하므로 잘못된 right 가 GUI 보다 먼저, 더 조용히 3열을 만듭니다. 이의제기가 정확합니다.
근본 원인
열 채우기는 n % 2 로 표현되지 않습니다. 패리티는 "직전에 무엇을 했는가"를 인코딩할 뿐, "각 열이 얼마나 찼는가"를 모릅니다. Rev.1 은 이를 "반전"이라는 한 단어로 넘겨 구현자에게 잘못된 자유도를 남겼습니다.
기존 테스트가 고정 중인 옛 계약
tests/test_layout.py:380-414 test_headless_max_columns_growth_guard 는 현행 홀짝을 명시적으로 고정합니다:
d2 = compute_2xk_layout(headless(2), max_columns=2) # right
d3 = compute_2xk_layout(headless(3), max_columns=2) # down
d5 = compute_2xk_layout(headless(5), max_columns=2) # down, despite cap
assert compute_2xk_layout(headless(4)).direction == "right" # 무제한일 때
n=2 → right 와 n=4 → right 단언은 Rev.2 계약과 정면 충돌하므로 W2 커밋에서 함께 갱신해야 합니다(§7.2).
2. 핵심 설계 — 단일 결정표 (Single Decision Table)
GUI 분기와 헤드리스 분기는 같은 결정표를 쓴다. 차이는 상태를 어떻게 관측하는가뿐이며, 무엇을 결정하는가는 동일합니다.
상태: cols = 열별 페인 수 리스트, capacity = max_columns × max_rows
관측: GUI → x 좌표 그룹핑으로 cols 산출
헤드리스 → 생성 순서로 cols 추론 (§4.2)
결정표 (공통):
① len(cols) < max_columns 그리고 전고 페인 존재 → RIGHT (새 열)
② 가장 적은 열의 페인 수 < max_rows → DOWN (그 열 채우기)
③ 그 외 → OVERFLOW
이 표가 유일한 진실 원천이며, 두 분기는 이를 호출만 합니다.
3. 궤적 (C-4 정정)
max_columns=2, max_rows=2 (capacity 4) 기준. n = 현재 페인 수, 결정은 다음 페인용입니다.
| n | 상태 (a,b) |
규칙 | 결정 | 결과 |
|---|---|---|---|---|
| 1 | (1,-) |
① | right on p1 |
(1,1) |
| 2 | (1,1) |
② | down on 열1 최하단 |
(2,1) |
| 3 | (2,1) |
② | down on 열2 최하단 |
(2,2) ← 2×2 완성 |
| 4 | (2,2) |
③ | overflow |
새 워크스페이스 |
Rev.1 정정: Rev.1 §4.1 은 궤적을
(3,2) → (3,3)까지 적었으나, 같은 문서 §4.4 가max_rows=2를 제안하여 자기모순이었습니다. Rev.2 는max_rows=2기준으로 4페인에서 정지합니다.max_rows=3을 열면 궤적이(3,2) → (3,3)으로 자연히 연장되며, 그때는 §5 행 균등화가 선행되어야 합니다.
4. 알고리즘 명세
4.1 GUI 분기 (C-3 — singleton 휴리스틱 폐기)
현행 fill_singleton_column(layout.py:147-159)은 len(col) == 1 만 봅니다. 이는 max_rows=2 에서만 우연히 맞고, max_rows=3 에서는 (2,2) 상태에 singleton 이 없어 새 열을 시도하다 캡에 걸려 조기 overflow 합니다. 열 페인 수 기반으로 일반화합니다.
def _decide(cols, area_h, max_columns, max_rows, min_cols, min_rows):
# ① 새 열: 전고 페인이 있을 때만 (R-2)
if len(cols) < max_columns:
fh = _full_height_pane(cols[-1], area_h)
if fh is not None:
if fh.width > 0 and fh.width // 2 < min_cols:
return OVERFLOW("column_width_overflow", fh)
return RIGHT("new_column_right", fh)
# 전고 페인이 없으면 새 열을 열 수 없다 → ②로 폴백
# ② 가장 적은 열을 채운다 (동률이면 좌측 우선 — 결정론)
shortest = min(cols, key=lambda c: (len(c), c[0].x))
if len(shortest) < max_rows:
bottom = shortest[-1] # y 정렬 후 최하단
if min_rows > 0 and bottom.height > 0 and bottom.height // 2 < min_rows:
return OVERFLOW("row_height_overflow", bottom)
return DOWN("fill_column", bottom)
# ③
return OVERFLOW("grid_capacity_reached", cols[-1][0])
_full_height_pane (R-2 처방):
def _full_height_pane(col, area_h, tol=2):
"""열 전체 높이를 점유하는 단일 페인. 없으면 None."""
if len(col) != 1:
return None
return col[0] if (area_h <= 0 or abs(col[0].height - area_h) <= tol) else None
동률 시 좌측 우선((len(c), c[0].x))은 결정론 보장을 위한 필수 타이브레이커입니다. min() 은 첫 최소값을 반환하지만 cols 정렬이 바뀌면 결과가 흔들리므로 명시합니다.
4.2 헤드리스 분기 (C-1 처방 — 생성 순서로 열 추론)
0×0 에서도 생성 순서가 열을 결정합니다. right 후 p1=열1, p2=열2 이고, 이후 down 채우기는 열을 번갈아 갑니다. 따라서 인덱스 i(0-based)의 페인은 열 i % max_columns 에 속합니다.
def _decide_headless(panes, max_columns, max_rows):
n = len(panes)
if n >= max_columns * max_rows:
return OVERFLOW("grid_capacity_reached", panes[-1])
if n < max_columns:
return RIGHT("new_column_right", panes[n - 1])
return DOWN("fill_column", panes[n - max_columns])
타깃 선택 근거: panes[n - max_columns] 는 다음에 채울 열의 최하단 페인입니다.
| n | n - max_columns |
타깃 | 들어가는 열 |
|---|---|---|---|
| 2 | 0 | p1 |
열1 → (2,1) ✅ |
| 3 | 1 | p2 |
열2 → (2,2) ✅ |
| 4 | 2 | p3 |
열1 (max_rows=3 일 때) ✅ |
| 5 | 3 | p4 |
열2 ✅ |
주의: 헤드리스에서 default_anchor_id(lib.sh 의 --sample-pane)를 타깃으로 쓰면 안 됩니다. lib.sh 는 워크스페이스의 첫 페인을 넘기므로, 그것을 계속 타깃하면 한 열만 깊어집니다. 현행 코드의 anchor = default_anchor_id or panes[-1] 는 이 경로에서 제거해야 하며, default_anchor_id 는 페인이 0개일 때의 폴백으로만 남깁니다.
4.3 결정표 공유 강제 (구조적 보증)
두 분기가 갈라지지 않도록 compute_2xk_layout 은 관측 → 공통 결정 2단으로 재구성합니다:
def compute_2xk_layout(data, min_cols=15, min_rows=0, max_columns=2, max_rows=2, default_anchor_id=None):
panes = extract_panes(data)
if not panes:
return RIGHT("no_panes_default", default_anchor_id or "")
if all(p.width <= 0 or p.height <= 0 for p in panes):
return _decide_headless(panes, max_columns, max_rows) # ← 같은 표
cols = _group_columns(panes)
return _decide(cols, _area_height(data, panes), max_columns, max_rows, min_cols, min_rows)
§7.2 의 parity 테스트가 이 공유를 계약으로 고정합니다.
4.4 파라미터 요약
| 변수 | 현재 | Rev.2 | 근거 |
|---|---|---|---|
MAM_MAX_PANE_COLS |
unset(무제한) | 2 | "2×K" 의 2를 실제로 강제 |
MAM_MAX_PANE_ROWS |
없음 | 2 (신규) | §5 균등화 전까지 보장 구간 |
MAM_MIN_PANE_COLS |
15 | 유지 | 2열 상한 하에서 역할 축소 |
MAM_MIN_PANE_ROWS |
0 | 유지 | 상동 |
4.5 C-2 인용 — max_columns 배선 (실측 확인)
이의제기의 부수 지적을 코드로 확인했습니다.
# layout.py:203 ← default= 없음 → env 미설정 시 None
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
# layout.py:217-223 ← 항상 전달
decision = compute_2xk_layout(..., max_columns=args.max_cols, ...)
None 이 무조건 전달되므로 시그니처 기본값 2 는 CLI 경로에서 절대 적용되지 않습니다. 그리고 lib.sh:432 는 --max-cols 를 아예 넘기지 않습니다(grep 결과 lib.sh 내 0건).
즉 lib.sh → layout.py 경로가 유일한 생산 경로인데, 거기서 캡이 영원히 None 입니다. Rev.1 의 W4 는 프로덕션에 무효였습니다.
필수 3중 조치 (같은 커밋):
# 1) layout.py:203
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS", default=2))
parser.add_argument("--max-rows", type=int, default=_env_int("MAM_MAX_ROWS", "MAM_MAX_PANE_ROWS", default=2))
# 2) lib.sh:432
python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" \
--max-cols "${MAM_MAX_PANE_COLS:-2}" --max-rows "${MAM_MAX_PANE_ROWS:-2}" --sample-pane "$sample_pane"
# 3) .mam.env.example:146-148 (C-5)
#default: 2 ← 현재 "(unset -> no column cap)" 이고 예시가 =3 이라 이중으로 어긋남
# MAM_MAX_PANE_COLS=2
# (신규 블록) MAM_MAX_PANE_ROWS=2
C-5 추가 발견:
.mam.env.example:147은 기본값을 "(unset → no column cap)" 로,:148예시는=3으로 적어 문서 자체가 이미 불일치합니다. Rev.2 값으로 양쪽을 함께 정정하십시오.
W4 회귀 가드 (§7.2에 포함):
def test_max_cols_default_reaches_cli_path(): ... # env 없이 --json 실행 시 4페인에서 overflow
def test_lib_sh_passes_max_cols_and_rows(): ... # lib.sh 소스 문자열 가드
5. 행 균등화 — max_rows ≥ 3 의 전제조건
BSP 단일 분할은 한 페인만 이등분하므로 형제 높이가 안 바뀝니다. 높이 78, 2페인(각 39)인 열에 1개를 더하면 {39, 19, 20} 이 되고 --ratio 를 써도 형제는 그대로입니다. 3행 균등은 분할만으로 불가능하며 herdr pane resize --pane <id> --direction up|down --amount <f> 정규화 패스가 필요합니다.
따라서 max_rows 기본값 2 는 임의 선택이 아니라 "균등을 보장할 수 있는 최대치" 입니다. §6 W6 완료 전에는 3행을 열지 마십시오(D2).
6. WBS
| 단계 | 작업 | 파일 | 비고 |
|---|---|---|---|
| W1 | _full_height_pane, _group_columns 추출 |
layout.py |
|
| W2 | 공통 결정표 + GUI/헤드리스 양 분기 동시 전환 | layout.py |
C-1. "1줄" 아님 |
| W3 | 헤드리스 타깃 panes[n - max_columns], anchor 오용 제거 |
layout.py |
C-1 |
| W4 | max_cols/max_rows 3중 배선 |
layout.py, lib.sh:432, .mam.env.example |
C-2·C-5 |
| W5 | grid_health() + --health |
layout.py |
진단 |
| W6 | ratio 정규화 리컨사일러 | 신규 lib_py/layout_repair.py |
§5 |
| W7 | 리컨사일러 배선 | create/resume/reconcile |
|
| W8 | 테스트 (§7) | tests/test_layout.py |
6.1 W2 범위 정정 (C-1)
Rev.1 은 W2 를 "핵심 1줄" 이라 적었습니다. 이 표현을 철회합니다. W2 는 최소한 다음을 하나의 커밋에 포함해야 합니다:
- GUI 분기를 §4.1 결정표로 교체
- 헤드리스 분기를 §4.2 결정표로 교체 (홀짝 로직 삭제)
- 두 분기가 같은
_decide*계층을 호출하도록 구조 정리 (§4.3) test_headless_max_columns_growth_guard등 옛 계약 테스트 갱신
분리 커밋 금지: GUI 만 바꾸고 헤드리스를 남기면 §1 표의 n=3 갈라짐이 그대로 생산에 들어갑니다.
7. 테스트 계획
7.1 누적 시퀀스 가드 (Rev.1 유지 — 최중요)
단일 결정만 단언하는 현행 방식은 R-1 을 통과시켰습니다. BSP 시뮬레이터를 테스트 헬퍼로 승격합니다.
def test_four_panes_form_clean_2x2():
panes = [{"id": "p1", "x": 0, "y": 0, "w": 277, "h": 78}]
for _ in range(3):
d = compute_2xk_layout(_payload(panes))
assert not d.is_overflow
panes = _bsp_split(panes, d.target_pane_id, d.direction)
assert len(panes) == 4
assert max(p["w"] for p in panes) - min(p["w"] for p in panes) <= 2
assert max(p["h"] for p in panes) - min(p["h"] for p in panes) <= 2
def test_fifth_pane_overflows():
... # capacity 4 → 4페인 상태에서 overflow
7.2 GUI ↔ 헤드리스 parity (C-1 처방 — 확장)
Rev.1 은 n=1 만 단언했습니다. 이의제기대로 전 구간을 단언합니다:
@pytest.mark.parametrize("n,expected", [(1,"right"), (2,"down"), (3,"down"), (4,"overflow")])
def test_headless_matches_gui_decision(n, expected):
hl = compute_2xk_layout(_headless(n), max_columns=2, max_rows=2)
assert hl.direction == expected, f"headless n={n}"
def test_headless_gui_direction_parity_full_sequence():
"""같은 n 에서 두 분기의 direction 이 항상 일치한다."""
panes = [{"id": "p1", "x": 0, "y": 0, "w": 277, "h": 78}]
for n in range(1, 5):
gui = compute_2xk_layout(_payload(panes), max_columns=2, max_rows=2)
hl = compute_2xk_layout(_headless(n), max_columns=2, max_rows=2)
assert gui.direction == hl.direction, f"divergence at n={n}"
if gui.is_overflow:
break
panes = _bsp_split(panes, gui.target_pane_id, gui.direction)
def test_headless_fills_alternating_columns():
"""C-1: n=2 는 p1, n=3 은 p2 를 타깃해야 한 열만 깊어지지 않는다."""
assert compute_2xk_layout(_headless(2), max_columns=2, max_rows=2).target_pane_id == "p1"
assert compute_2xk_layout(_headless(3), max_columns=2, max_rows=2).target_pane_id == "p2"
def test_headless_ignores_sample_pane_anchor():
"""§4.2: --sample-pane 이 채우기 타깃을 오염시키지 않는다."""
d = compute_2xk_layout(_headless(3), max_columns=2, max_rows=2, default_anchor_id="p1")
assert d.target_pane_id == "p2"
7.3 갱신 대상 기존 테스트
| 테스트 | 충돌 단언 | 조치 |
|---|---|---|
test_headless_max_columns_growth_guard:399 |
n=2 → right |
→ down |
〃 :409 |
n=5 → down (캡 무시) |
capacity 규칙으로 재작성 |
〃 :414 |
n=4 무제한 → right |
기본 캡 2 하에서 재정의 |
test_headless_0x0_transitions:142 |
홀짝 전제 | 전면 재작성 |
test_j1* (min_cols/rows) |
영향 없음(명시 전달) | 유지 |
7.4 W4 배선 가드
§4.5 의 두 테스트. env 미설정 상태에서 CLI 경로가 실제로 캡을 적용하는지 확인하는 것이 핵심입니다.
8. 리스크
| 리스크 | 영향 | 완화 |
|---|---|---|
| GUI/헤드리스 분리 커밋 | n=3 갈라짐이 조용히 생산 진입 | §6.1 단일 커밋 강제 + §7.2 parity 테스트 |
| W4 배선 누락 | 캡이 None 으로 남아 Rev.2 전체가 무효 |
§4.5 3중 조치 + §7.4 가드 |
| 헤드리스 anchor 오용 | 한 열만 깊어짐 | §4.2 주의 + test_headless_ignores_sample_pane_anchor |
| 기존 테스트 대량 실패 | 계약 변경이라 불가피 | §7.3 목록대로 갱신 |
| capacity 4 로 워크스페이스 증가 | 5+ 에이전트에서 워크스페이스 수↑ | 의도된 트레이드오프(D1) |
9. 결정 필요 사항
| ID | 항목 | 권장 |
|---|---|---|
| D1 | max_columns=2, max_rows=2 (capacity 4) 수용 |
수용 — 4 에이전트 2×2 목표와 일치 |
| D2 | 3행 개방 시점 | W6 정규화 완료 후 |
| D3 | 리컨사일러 자동 재배치 범위 | ratio 정규화까지만 자동 |
| D4 | 기존 왜곡 워크스페이스 | 진단만, 복구 수동 |
10. 이의제기 대응 정리
| 이의 | 판정 | 반영 |
|---|---|---|
| 홀짝 반전 시 n=3 갈라짐 | ✅ 인용 | §1, §4.2, §6.1, §7.2 |
max_columns 배선 누락 |
✅ 인용 (실측 확인) | §4.5, §7.4 |
| parity 테스트가 n=1 만 단언 | ✅ 인용 | §7.2 전 구간 파라미터화 |
| (자진 정정) singleton 휴리스틱 비일반성 | — | §4.1 |
(자진 정정) 궤적 표 ↔ max_rows=2 모순 |
— | §3 |
(자진 발견) .mam.env.example 자체 불일치 |
— | §4.5 C-5 |
11. Rev.1 근거 요약 (변경 없음)
- herdr = 엄격 BSP:
split은 대상 페인 rect 만 이등분(격리w16실측). - R-1:
down우선 시 하단 페인이 전폭으로 남아 2×2 도달 불가. 시뮬레이션상 N=4 에서 widths{138,139,277}, heights{19,20,39}. - R-2: 전고 페인이 없으면
right는 반쪽 열만 생성. - 해법 실증:
right우선 →down×2 → widths[138,139], heights[39]완전 균등(격리w17실측). 트리 구조가 살아있는w15와 동일.
12. 결론
이의제기 3건을 모두 인용하며, 그 과정에서 제 계획서의 추가 결함 3건(C-3·C-4·C-5)을 자진 정정했습니다.
Rev.1 의 가장 위험한 표현은 "핵심 1줄" 이었습니다. 근본 원인 진단은 옳았으나, 처방의 범위를 과소 표기하여 구현자가 GUI 만 고치고 헤드리스를 홀짝 반전으로 처리할 여지를 남겼습니다. Rev.2 는 이를 단일 결정표 공유로 구조적으로 차단하고(§4.3), parity 테스트로 계약을 고정합니다(§7.2).
max_columns 배선 지적은 특히 중요합니다 — 이것이 없으면 Rev.2 전체가 프로덕션에서 무효입니다. lib.sh 가 --max-cols 를 넘기지 않고 _env_int 가 None 을 반환하는 이중 누락이라, 시그니처 기본값만 바꾸는 수정은 테스트만 통과하고 실사용에서는 아무 효과가 없었을 것입니다.