# 🧭 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` 는 현행 홀짝을 명시적으로 고정합니다:
```python
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** 합니다. 열 페인 수 기반으로 일반화합니다.

```python
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 처방):
```python
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` 에 속합니다.

```python
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단으로 재구성합니다:

```python
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` 배선 (실측 확인)

이의제기의 부수 지적을 코드로 확인했습니다.

```python
# 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중 조치 (같은 커밋)**:
```python
# 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))
```
```bash
# 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에 포함):
```python
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 시뮬레이터를 테스트 헬퍼로 승격**합니다.
```python
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` 만 단언했습니다. 이의제기대로 **전 구간**을 단언합니다:
```python
@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` 을 반환하는 이중 누락이라, 시그니처 기본값만 바꾸는 수정은 테스트만 통과하고 실사용에서는 아무 효과가 없었을 것입니다.
