[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