[Claude #3] Stage 3: 수정 계획 — round 3
Codex #2 round 2 의 4 blocking finding + 2 non-blocking 모두 검증. 4 모두 실제 코드 결함 수용. 본 round 에서 lock. 모든 line 번호 main HEAD 실측. 코드 미수정.
## 0. Codex #2 round 2 정정 검증
| Codex #2 finding | 검증 결과 | 처분 |
|---|---|---|
| Block 1 — round 2 `_compute_per_zone_geometry` 1-D fallback 이 `widths_px[i]` 로 zone 인덱싱 → h-2 (zone 2 개, widths_px length 1) 에서 zone[1] = IndexError/None | ✅ 확인. round 2 §2-A snippet `widths_px[i] if i < len(widths_px) else None` — h-2 의 bottom zone width = None. 실제로 silent broken | 수용 — unified 경로로 재작성 (§1) |
| Block 2 — round 2 `_override_to_grid_tracks` 가 5 룰 중 R-1 / R-2 / R-3 실제 구현 누락 (extra-position check 없음, span ratio = sum-of-tracks 검증 없음, non-span 충돌 시 last-write-wins) | ✅ 확인. round 2 snippet 의 `for pos, geom in override_zone_geometries.items()` loop = extra check 없이 unique_zones 외 key 도 무시 (silent ignore). non-span 분기 `track_values[occupied[0]] = geom["h"]` = previous value override 무검사 | 수용 — 2-pass + explicit raise (§2) |
| Block 3 — PR scope 자기모순. `_override_to_grid_tracks` 가 PR 1 (h/w default 1.0) 와 PR 2 (일반 룰) 동시 명시 + PR 1 legacy h-only/w-only 보존 명시 | ✅ 확인. round 2 §3 table : `_override_to_grid_tracks (h/w default 1.0)` row = PR 1 ✅, `_override_to_grid_tracks (일반 룰)` row = PR 2 ✅. 동시 land 불가 | 수용 — Option A lock (§3) |
| Block 4 — rollback trigger 의 `run_visual_check.py` 미존재. 실제 surface = Step 14 visual_check | ✅ 확인. `rg "run_visual_check"` empty. 실제 = `src/phase_z2_pipeline.py:3694` `_write_step_artifact(..., 14, "visual_check", data=overflow, ...)`. overflow source = `:3690 run_overflow_check(out_path)`. artifact = `step14_visual_check.json` | 수용 — 정확한 path (§4) |
| Non-block — Gap 1/3/4 원칙 ack | ✅ 확인 | 유지 |
| Non-block — `select_layout_preset()` 도달 가능 default 4 preset 만 vs 8 preset 직접 build_layout_css 호출 = 분리 test 옳음 | ✅ 확인 | 유지 |
| Non-block — Step 8 `step_status="complete"` out-of-scope 일관 | ✅ 확인 | 유지 |
4 block 모두 수용. round 2 의 미완 surface 4 axis 가 본 round 에서 lock.
## 1. Unified per-zone geometry — 1-D / 2-D 분기 폐기 (Block 1 fix)
round 2 의 결함 = "1-D layout 에는 css_areas 가 trivial 이라서 parse 불필요" 가정. 그러나 zone 갯수 ≠ track 갯수 (h-2 는 zone 2 / col-track 1) 면 인덱스 매핑 자체가 불가. **모든 preset 에서 css_areas 를 parse + spanning-aware 집계**.
신규 contract :
```
ALL layouts: layout_css 에 widths_px (length C) + heights_px (length R)
where (R, C) = parsed css_areas dimensions.
track 차원 = grid 차원, zone 차원 ≠ track 차원 (서로 분리).
```
8 preset 적용 :
| preset | R × C | heights_px (length R) | widths_px (length C) |
|---|---|---|---|
| `single` | 1 × 1 | `[SLIDE_BODY_HEIGHT]` | `[SLIDE_BODY_WIDTH]` |
| `horizontal-2` (rows dynamic) | 2 × 1 | `[h1, h2]` (compute_zone_layout) | `[SLIDE_BODY_WIDTH]` |
| `vertical-2` (cols dynamic) | 1 × 2 | `[SLIDE_BODY_HEIGHT]` | `[w1, w2]` (compute_zone_layout_cols) |
| `top-1-bottom-2` / inv-T / side-T 등 (2-D dynamic) | 2 × 2 | from `compute_zone_layout_2d` | from `compute_zone_layout_2d` |
| `grid-2x2` (2-D dynamic) | 2 × 2 | from `compute_zone_layout_2d` | from `compute_zone_layout_2d` |
| (PR 1+2 fr_default sink: T / 2x2 / single 일시적) | R × C | parse css_rows fr → split SLIDE_BODY_HEIGHT | parse css_cols fr → split SLIDE_BODY_WIDTH |
`_compute_per_zone_geometry` 재작성 (single path) :
```python
def _compute_per_zone_geometry(
layout_css: dict, debug_zones: list[dict], gap: int = GRID_GAP,
) -> list[dict]:
"""Aggregate grid-track sizes into per-zone dimensions for ALL layouts.
Unified path — no 1-D vs 2-D branch. Always parses css_areas, computes
occupied rows/cols per zone, sums track sizes + inter-track gaps.
"""
rows_grid, _ = _parse_css_areas(layout_css["areas"])
R, C = len(rows_grid), len(rows_grid[0])
heights_px = layout_css.get("heights_px") or []
widths_px = layout_css.get("widths_px") or []
if len(heights_px) != R:
raise ValueError(
f"_compute_per_zone_geometry: heights_px length {len(heights_px)} "
f"!= grid rows R={R} (css_areas={layout_css['areas']!r})"
)
if len(widths_px) != C:
raise ValueError(
f"_compute_per_zone_geometry: widths_px length {len(widths_px)} "
f"!= grid cols C={C} (css_areas={layout_css['areas']!r})"
)
per_zone = []
for dz in debug_zones:
pos = dz["position"]
occupied_rows = sorted({r for r, row in enumerate(rows_grid) if pos in row})
occupied_cols = sorted({c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos})
if not occupied_rows or not occupied_cols:
raise ValueError(
f"zone position {pos!r} not present in css_areas {rows_grid}"
)
zh = sum(heights_px[r] for r in occupied_rows) + gap * (len(occupied_rows) - 1)
zw = sum(widths_px[c] for c in occupied_cols) + gap * (len(occupied_cols) - 1)
per_zone.append({
"position": pos,
"zone_height_px": zh,
"zone_width_px": zw,
"zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3),
"zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3),
})
return per_zone
```
이 contract 의 함의 :
- **모든 `build_layout_css` return path** 가 `heights_px` (length R) + `widths_px` (length C) 보유 (round 2 의 `widths_px=[]` 폐기).
- `_parse_css_areas` 가 PR 1 으로 이동 (1-D 도 parse 필요).
- `_build_fr_default` (PR 1+2 단계의 single/T/2x2 sink) 가 catalog `css_cols` / `css_rows` 의 `1fr` / `1fr 1fr` 패턴을 parse 하여 widths_px / heights_px 채움. catalog 의 8 preset 모두 `1fr` / `1fr 1fr` (verified `templates/phase_z2/layouts/layouts.yaml:30-130`) — 신규 `_parse_fr_string` helper 자명 :
```python
def _parse_fr_string(spec: str, total: int) -> list[int]:
"""Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' -> equal-share px lengths.
Catalog presets (verified) use only 1fr-only specs; mixed px/fr
out of scope. Raises ValueError on non-fr tokens.
"""
fractions = []
for token in spec.split():
m = re.fullmatch(r"(\d+(?:\.\d+)?)fr", token)
if not m:
raise ValueError(f"_parse_fr_string: non-fr token {token!r} in {spec!r}")
fractions.append(float(m.group(1)))
total_fr = sum(fractions)
if total_fr <= 0:
raise ValueError(f"_parse_fr_string: total fr = 0 in {spec!r}")
sizes = [int(round(total * (f / total_fr))) for f in fractions]
sizes[-1] += total - sum(sizes)
return sizes
```
- **byte-identity scope 재정의** : horizontal-2 `cols=preset["css_cols"]` (= `"1fr"` 문자열) 은 **그대로 유지**. 변경되는 것은 *layout_css in-memory dict* 의 신규 key (widths_px/heights_px 의 길이 contract). 즉 render output (`cols`/`rows`/`areas` 문자열) 변경 0, in-memory dict shape 변경 +2 length-locked array. `slide_base.html` 가 in-memory `widths_px` / `heights_px` 직접 소비 X (verified `templates/phase_z2/slide_base.html` consumes `{layout_css.cols, layout_css.rows, layout_css.areas}` only) → render path 호환.
### 1-A. `_build_fr_default` 갱신
```python
def _build_fr_default(preset: dict) -> dict:
"""fr-default sink — populates widths_px/heights_px from catalog fr ratios."""
css_cols = preset["css_cols"]
css_rows = preset["css_rows"]
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R, C = len(rows_grid), len(rows_grid[0])
avail_h = SLIDE_BODY_HEIGHT - GRID_GAP * (R - 1)
avail_w = SLIDE_BODY_WIDTH - GRID_GAP * (C - 1)
heights_px = _parse_fr_string(css_rows, avail_h)
widths_px = _parse_fr_string(css_cols, avail_w)
return {
"areas": preset["css_areas"],
"cols": css_cols,
"rows": css_rows,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": "fr_default_from_preset",
"dynamic_rows": False,
"dynamic_cols": False,
"raw_zone_layout": None,
}
```
### 1-B. `_build_rows_dynamic` / `_build_cols_dynamic` 갱신
```python
def _build_rows_dynamic(preset, zones_data, gap):
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R, C = len(rows_grid), len(rows_grid[0])
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
widths_px = _parse_fr_string(preset["css_cols"], avail_w) # static col-axis
zl = compute_zone_layout(zones_data, gap=gap)
rows_str = " ".join(f"{h}px" for h in zl["heights_px"])
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows_str,
"heights_px": zl["heights_px"],
"widths_px": widths_px,
"ratios": zl["ratios"],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": zl["computation"],
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": zl,
}
def _build_cols_dynamic(preset, zones_data, gap):
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R, C = len(rows_grid), len(rows_grid[0])
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h) # static row-axis
zl = compute_zone_layout_cols(zones_data, gap=gap)
cols_str = " ".join(f"{w}px" for w in zl["widths_px"])
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": preset["css_rows"],
"heights_px": heights_px,
"widths_px": zl["widths_px"],
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": zl["width_ratios"],
"computation": zl["computation"],
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": zl,
}
```
h-2 의 byte-identity check : `cols == "1fr"` (preset 그대로), `rows == "
px px"` (변경 전 동일), 신규 in-memory key `widths_px=[1180]`, `width_ratios=[1.0]`, `dynamic_cols=False`. render output 변경 0.
## 2. `_override_to_grid_tracks` 5 룰 실제 구현 (Block 2 fix)
round 2 snippet 의 결함 :
- (R-1) extra position : `unique_zones` 외 key 가 `override_zone_geometries` 에 있어도 silent ignore.
- (R-2) span ratio = sum-of-tracks : 검증 부재. top-1-bottom-2 의 top.h=0.5 / bottom-left.h=0.6 / bottom-right.h=0.5 같은 입력도 통과.
- (R-3) non-span conflict : `track_values[occupied[0]] = geom["h"]` = previous 값 무검사 덮어쓰기. bottom-left.h=0.4 / bottom-right.h=0.6 둘 다 row 1 → last-write-wins.
2-pass 알고리즘으로 재작성 :
```python
def _override_to_grid_tracks(
css_areas: str,
override_zone_geometries: dict[str, dict],
axis: str, # "row" or "col"
) -> list[float]:
"""Convert per-zone override geometry into grid track ratios.
Enforces 5 rules (Stage 3 round 3 lock):
R-1 every position in css_areas MUST be present in override
R-2 every position in override MUST be in css_areas (extra raise)
R-3 spanning zone ratio MUST equal sum of spanned-track ratios
R-4 conflicting non-span zone ratios for the same track raise
R-5 total track ratio sum normalized to 1.0 (within 0.01 tolerance)
"""
assert axis in ("row", "col"), f"axis must be row/col, got {axis!r}"
rows_grid, unique_zones = _parse_css_areas(css_areas)
R, C = len(rows_grid), len(rows_grid[0])
track_count = R if axis == "row" else C
# R-1 / R-2 : position symmetry
grid_set = set(unique_zones)
ovr_set = set(override_zone_geometries)
if grid_set - ovr_set:
raise ValueError(
f"override missing positions {grid_set - ovr_set} "
f"(partial override not supported)"
)
if ovr_set - grid_set:
raise ValueError(
f"override has extra positions {ovr_set - grid_set} "
f"not in css_areas {grid_set}"
)
# Collect occupied tracks + ratio per zone (axis-aware).
def _occupied(pos):
if axis == "row":
return sorted({r for r, row in enumerate(rows_grid) if pos in row})
return sorted({c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos})
ratio_key = "h" if axis == "row" else "w"
zone_info = []
for pos in unique_zones:
occ = _occupied(pos)
ratio = override_zone_geometries[pos].get(ratio_key, 1.0)
zone_info.append((pos, occ, ratio))
# Pass 1 : non-span zones fix tracks (with R-4 conflict raise).
track_values: list[Optional[float]] = [None] * track_count
track_source: list[Optional[str]] = [None] * track_count
for pos, occ, ratio in zone_info:
if len(occ) == 1:
t = occ[0]
if track_values[t] is not None and abs(track_values[t] - ratio) > 0.01:
raise ValueError(
f"override {axis} track {t} conflict: "
f"{track_source[t]}={track_values[t]} vs {pos}={ratio}"
)
track_values[t] = ratio
track_source[t] = pos
# Pass 2 : spanning zones — validate vs already-fixed tracks (R-3), fill rest.
for pos, occ, ratio in zone_info:
if len(occ) <= 1:
continue
fixed = [t for t in occ if track_values[t] is not None]
unfixed = [t for t in occ if track_values[t] is None]
fixed_sum = sum(track_values[t] for t in fixed)
if not unfixed:
# all tracks already fixed; span ratio must equal sum
if abs(fixed_sum - ratio) > 0.01:
raise ValueError(
f"spanning zone {pos} ratio={ratio} != sum of fixed "
f"{axis} tracks {[(t, track_values[t]) for t in fixed]} "
f"= {fixed_sum:.3f}"
)
else:
remaining = ratio - fixed_sum
if remaining < -0.01:
raise ValueError(
f"spanning zone {pos} ratio={ratio} less than already-fixed "
f"sum {fixed_sum:.3f}"
)
per_track = remaining / len(unfixed)
for t in unfixed:
track_values[t] = per_track
track_source[t] = pos
if any(v is None for v in track_values):
raise ValueError(
f"override {axis} tracks unresolved: {track_values} "
f"(catalog/override mismatch)"
)
# R-5 : normalize total to 1.0
total = sum(track_values)
if not 0.99 <= total <= 1.01:
raise ValueError(
f"override {axis} track sum {total:.3f} (expected ~1.0)"
)
track_values[-1] += 1.0 - total
return track_values
```
negative test (추가, PR 2 land) :
- `test_override_extra_position` — `top-1-bottom-2` override 에 `foo: {h:0.3, w:1.0}` → R-1/2 raise
- `test_override_missing_position` — `top` 누락 → R-1 raise
- `test_override_span_sum_mismatch` — top-1-bottom-2 의 top.h=0.4 + bottom-left.h=0.3 + bottom-right.h=0.3 → span row sum 0.6 ≠ top 0.4 → R-3 raise
- `test_override_non_span_conflict` — top-1-bottom-2 의 bottom-left.h=0.4 + bottom-right.h=0.6 (둘 다 row 1) → R-4 raise
- `test_override_sum_out_of_range` — h 합 = 1.2 → R-5 raise
5 positive case (8 preset 모두 valid override) + 5 negative case = 10 test in `test_override_to_grid_tracks.py`.
## 3. PR scope — Option A lock (Block 3 fix)
자기모순 해소. round 2 의 "PR 1 legacy h-only/w-only 보존 + h/w default 1.0 helper" 를 폐기. Option A 로 단일화 :
**Option A** : PR 1 은 *legacy override path 그대로 유지* (h-only horizontal-2 분기 + w-only vertical-2 분기 신설 — 둘 다 single-axis 인라인 코드). 신규 helper `_override_to_grid_tracks` 도입은 PR 2 — 동시에 h-2/v-2/T/2x2 4 preset 전수 unified helper migrate.
| 함수 | PR 1 | PR 2 | PR 3 |
|---|---|---|---|
| `_parse_css_areas` (+ strict validation) | ✅ 신설 | | | (round 2 = PR 2 → round 3 = PR 1 이동, unified mapper 의 전제) |
| `_parse_fr_string` | ✅ 신설 | | | (`_build_fr_default` / `_build_rows_dynamic` / `_build_cols_dynamic` 가 사용) |
| `_compute_per_zone_geometry` (unified) | ✅ 신설 + 활성 | | | (1-D / 2-D 통합 — PR 1 부터 정합) |
| `_build_fr_default` 갱신 (widths_px/heights_px 채움) | ✅ | | |
| `_build_rows_dynamic` (h-2) | ✅ 신설 (round 2 의 round-axis only path 분리) | | |
| `_build_cols_dynamic` (v-2) + `compute_zone_layout_cols` | ✅ 신설 | | |
| `build_layout_css` override branch — h-2 inline | ✅ legacy 유지 | | |
| `build_layout_css` override branch — v-2 inline (w-only) | ✅ 신설 (legacy 형식) | | |
| Retry gate (`_attempt_zone_ratio_retry`) | ✅ 신설 | | |
| Step 8 col-axis 4 신규 field | ✅ 신설 | | |
| `compute_zone_layout_2d` | | ✅ 신설 | |
| `_override_to_grid_tracks` (5 룰) | | ✅ 신설 | |
| `build_layout_css` override branch — h-2/v-2/T/2x2 전수 unified migrate | | ✅ (PR 1 의 4 inline 분기 폐기) | |
| `build_layout_css` dynamic branch — T / inv-T / side-T-L/R / 2x2 dispatcher | | ✅ 신설 | |
| `_build_single` (`single` 명시 path) + Step 8 nested status | | | ✅ 신설 |
| 8-preset dynamic invariant test | | | ✅ |
| V4 32-frame coverage test | | | ✅ |
이 분리의 atomicity :
- **PR 1 만 ship** : single / T / 2x2 / 4개 side-T 가 `_build_fr_default` 로 떨어지지만, `widths_px` (length C) / `heights_px` (length R) 가 fr 분배로 채워져 있으므로 `_compute_per_zone_geometry` 통과. retry gate + col-axis Step 8 field 보유. v-2 의 width-dynamic 활성. T/2x2 override 는 round 2 plan 처럼 legacy fallthrough 없이 strict raise (override 가 들어와도 `_build_layout_css_from_override` 가 horizontal-2 / vertical-2 외엔 warn-and-fallthrough 그대로 → fallthrough 후 dynamic branch 가 fr_default 로 처리. round 2 의 lock 일치).
- **PR 2 추가 ship** : T / 2x2 override + dynamic 모두 정상. unified `_override_to_grid_tracks` 가 h-2/v-2 도 처리 (PR 1 inline 코드 폐기). 모든 8 preset 의 override 가 strict semantics.
- **PR 3 추가 ship** : single 명시 path + Step 8 nested status + invariant test 2 개. IMP-09 close.
각 PR 은 독립 verification 가능 (RULE 4 commit-scope 호환).
## 4. Rollback trigger 정확한 path (Block 4 fix)
round 2 의 `run_visual_check.py` (미존재) 폐기. 정확한 surface :
| PR | rollback trigger (round 3 정정) |
|---|---|
| PR 1 | (a) `python -m pytest -q tests` 신규 case 외 1 case 라도 fail
(b) horizontal-2 render output (`cols`/`rows`/`areas` 문자열) byte-diff
(c) `step14_visual_check.json` (`src/phase_z2_pipeline.py:3694` _write_step_artifact, source = `run_overflow_check`) 에서 `visual_check_passed=False` *AND* root cause = vertical-2 의 grid-template-columns spec invalid (Selenium 측정 per-zone clientWidth = 의도된 widths_px 와 mismatch)
(d) 기존 `data/runs/imp08_stage5_r2_backwardcompat/phase_z2/debug.json` 의 retry trace 가 새 gate 의 영향으로 `dynamic_rows=True` 인데 skip — fixture (`tests/phase_z2/fixtures/retry_gate/horizontal2_dynamic_rows.yaml`) 가 정확히 이 케이스 보호 |
| PR 2 | (a) `_compute_per_zone_geometry` 가 spanning zone 의 `zone_height_px` / `zone_width_px` 를 parsed css_areas span 과 mathematically inconsistent 반환 — fixture (`tests/phase_z2/fixtures/build_layout_css/top-1-bottom-2_default.yaml` 등) deep-equal fail
(b) `_override_to_grid_tracks` 가 5 negative case (extra / missing / span-sum / non-span-conflict / sum-out-of-range) 중 하나라도 explicit raise 안 함 → `test_override_to_grid_tracks.py` fail
(c) `step14_visual_check.json` 에서 T-shape / 2x2 / inv-T / side-T-L/R 슬라이드 한 frame 이라도 zone clipping
(d) topology dispatcher 가 8 preset 중 하나라도 `_build_fr_default` 로 fallthrough — `test_all_8_presets_dynamic.py` (PR 3 test 의 PR 2 partial preview) fail |
| PR 3 | (a) `_build_single` 의 `heights_px[0] != SLIDE_BODY_HEIGHT` 또는 `widths_px[0] != SLIDE_BODY_WIDTH`
(b) Step 8 `step_status` 값 `"partial"` 외 (region-level partial 별 axis 유지)
(c) `test_all_8_presets_dynamic.py` 의 `result["computation"] != "fr_default_from_preset"` assertion 중 하나라도 fail
(d) `test_v4_full32_layout_coverage.py` 의 4 preset (single / horizontal-2 / top-1-bottom-2 / grid-2x2) 도달 default 중 하나라도 fr_default sink |
공통 :
- `python -m py_compile src\phase_z2_pipeline.py src\phase_z2_retry.py src\phase_z2_composition.py` fail
- `templates/phase_z2/slide_base.html:74-81` 의 `layout_css.cols/rows/areas` consume contract 변경 (out-of-scope)
- `src/phase_z2_retry.py:198-215` `apply_retry_to_layout_css` 가 mutation 추가 (out-of-scope, Stage 2 §3-B lock)
## 5. 잔여 axis (본 plan 외)
| 항목 | 상태 | 근거 |
|---|---|---|
| `select_layout_preset` catalog-driven 전환 | 별 issue | Codex Stage 1 #3 정정 — IMP-09 out-of-scope |
| `frame_contracts.yaml` `min_width_px` 도입 | 별 issue | Stage 2 §3-C — col-axis weight-only invariant lock |
| IMP-19 `_group_blocks_by_area` 직접 통합 | 별 issue | Stage 2 §3-B — reference only |
| Step 8 `step_status="complete"` 승급 | 별 issue | Stage 2 §2-B — region-level partial 별 axis |
| render template gap row/col 분리 | 미적용 | Stage 2 §4 #4 — shorthand 유지 lock |
| 혼합 px/fr (e.g. `"200px 1fr"`) 지원 | 별 issue | 8 catalog preset 모두 1fr-only (verified `templates/phase_z2/layouts/layouts.yaml`) — `_parse_fr_string` 가 non-fr 토큰에 raise. 미래 catalog 확장 시 별 axis |
## 6. Round 3 결론
- Codex #2 round 2 의 4 blocking finding 모두 검증 + 수용.
- `_compute_per_zone_geometry` = unified path (1-D / 2-D 분기 폐기). 모든 layout_css 가 `widths_px` (length C) + `heights_px` (length R) 보유.
- `_override_to_grid_tracks` = 5 룰 실제 구현 (2-pass, extra-position raise, span-sum 검증, non-span 충돌 raise).
- PR scope = Option A (PR 1 legacy override inline 유지, PR 2 에서 unified helper 도입 + 4 preset 동시 migrate).
- Rollback trigger = `step14_visual_check.json` (`phase_z2_pipeline.py:3694`) 실제 surface.
- 본 round 변경 = 0 (plan only).
- Stage 2 의 7 결정 + Stage 3 round 1/2 의 Codex Q-r2-1~5 + Gap 1~4 + round 2 Block 1~4 모두 코드 변경 단위 lock.
Codex 가 §1 unified geometry / §2 5-rule override / §3 Option A scope / §4 rollback path 4 axis 모두 ack 하면 Stage 3 종료 → Stage 4 implementation handoff.
FINAL_CONSENSUS: NO