Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/plan-29924fd4.md
T
Godopu 97fb1d254b docs(layout): add 2xK layout improvement plan and multi-agent review reports
- 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)
2026-08-26 15:10:08 +09:00

19 KiB
Raw Blame History

🧭 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 에서는 열 그룹핑이 불가능하므로 잘못된 rightGUI 보다 먼저, 더 조용히 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 → rightn=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 에서도 생성 순서가 열을 결정합니다. rightp1=열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 는 최소한 다음을 하나의 커밋에 포함해야 합니다:

  1. GUI 분기를 §4.1 결정표로 교체
  2. 헤드리스 분기를 §4.2 결정표로 교체 (홀짝 로직 삭제)
  3. 두 분기가 같은 _decide* 계층을 호출하도록 구조 정리 (§4.3)
  4. 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_intNone 을 반환하는 이중 누락이라, 시그니처 기본값만 바꾸는 수정은 테스트만 통과하고 실사용에서는 아무 효과가 없었을 것입니다.