Files
C.E.L_Slide_test2/.orchestrator/tmp/issue9_latest_claude.txt
T

395 lines
23 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[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 == "<h1>px <h2>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 <br>(b) horizontal-2 render output (`cols`/`rows`/`areas` 문자열) byte-diff <br>(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) <br>(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 <br>(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 <br>(c) `step14_visual_check.json` 에서 T-shape / 2x2 / inv-T / side-T-L/R 슬라이드 한 frame 이라도 zone clipping <br>(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` <br>(b) Step 8 `step_status` 값 `"partial"` 외 (region-level partial 별 axis 유지) <br>(c) `test_all_8_presets_dynamic.py` 의 `result["computation"] != "fr_default_from_preset"` assertion 중 하나라도 fail <br>(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