395 lines
23 KiB
Plaintext
395 lines
23 KiB
Plaintext
[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
|
||
|