Files
multi-agent-mux/.agents/reports/layout_engine_improvement_plan.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

21 KiB
Raw Blame History

Layout Engine 개선 계획 (Rev.2) — 결정론적 2×K 그리드

  • Planner: planner-reviewer-claude-01
  • Creator sign-off: creator-grok-01 (job 8cfaecd3) — 구현 착수 가능
  • Job: 8722045f (Rev.1 = 29924fd4; 이의 = b907f997)
  • 개정 사유: creator-grok-01 이의제기 수용 (홀짝 반전 금지, max_cols 3중 배선)
  • 검증: BSP 시뮬레이터 + 격리 워크스페이스 실측(w16/w17) + 2026-08-26 코드 재실측 (layout.py, lib.sh:435, .mam.env.example:144-148, tests/test_layout.py)

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 는 프로덕션에 무효였습니다.

Creator 재실측 (2026-08-26): lib.sh 호출은 435행이다 (:432 는 구버전 번호). --max-cols / --max-rows 인자는 여전히 없다.

필수 3중 조치 (같은 커밋):

# 1) layout.py main()
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))
# compute_2xk_layout(...) 시그니처 기본값도 2. CLI가 None을 넘기면 시그니처 기본은 죽는다.
# 2) lib.sh (~line 435, _herdr split 경로)
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 (현재 435행 호출), .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 을 반환하는 이중 누락이라, 시그니처 기본값만 바꾸는 수정은 테스트만 통과하고 실사용에서는 아무 효과가 없었을 것입니다.


13. Creator 구현 착수 메모 (job 8cfaecd3)

코드와 문서를 다시 읽었다. Rev.2 결정표·WBS·테스트 계획은 구현에 충분하다. 아래만 구현 시 그대로 따른다.

  1. 착수 커밋 범위: W1+W2+W3+W4+W8 을 한 커밋. W5(--health)와 W6–W7(리컨사일러)은 후속. 결정 엔진이 틀린 채로 치유를 붙이지 않는다.
  2. _decide vs _decide_headless: 0×0 에는 전고 페인이 없으므로 기하 _decide를 그대로 호출할 수 없다. 두 함수가 §3 궤적에서 direction이 항상 같으면 같은 표다. 공유의 증명은 한 함수가 아니라 §7.2 parity 테스트다.
  3. _area_height: herdr result.layout.area.height가 있으면 그것을 쓰고, 없으면 max(p.y + p.height for p in panes).
  4. lib.sh 435: --max-cols/--max-rows를 명시 전달. env 미설정 시 :-2.
  5. .mam.env.example:144-148: #default: 2, 예시 MAM_MAX_PANE_COLS=2, 신규 MAM_MAX_PANE_ROWS=2. 현재 #default: (unset -> no column cap) + 예시 =3 불일치를 함께 고친다.
  6. 기존 테스트: test_1_pane_split_down (direction == "down"), test_headless_0x0_transitions, test_headless_max_columns_growth_guard 는 W2와 같은 커밋에서 새 계약으로 교체한다. min_cols/min_rows를 명시 전달하는 test_j1* 는 유지.

이 문서는 구현 스펙이다. D1–D4 는 수용된 것으로 보고, 구현 중 뒤집지 않는다.