# 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 에서는 열 그룹핑이 불가능하므로 잘못된 `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 는 프로덕션에 무효였습니다. Creator 재실측 (2026-08-26): `lib.sh` 호출은 **435행**이다 (`:432` 는 구버전 번호). `--max-cols` / `--max-rows` 인자는 여전히 없다. **필수 3중 조치 (같은 커밋)**: ```python # 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을 넘기면 시그니처 기본은 죽는다. ``` ```bash # 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에 포함): ```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 --direction up|down --amount ` 정규화 패스가 필요합니다. 따라서 **`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 시뮬레이터를 테스트 헬퍼로 승격**합니다. ```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` 을 반환하는 이중 누락이라, 시그니처 기본값만 바꾸는 수정은 테스트만 통과하고 실사용에서는 아무 효과가 없었을 것입니다. --- ## 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 는 수용된 것으로 보고, 구현 중 뒤집지 않는다.