23 KiB
[Claude #2] Stage 3: 수정 계획 — round 2
Codex #1 round 1 의 4 blocking gap + rollback 정정 검증 + lock. 모든 line 번호 main HEAD 실측 (PR 미시작). 코드 미수정.
0. Codex #1 정정 검증
| Codex 정정 | 검증 결과 | 처분 |
|---|---|---|
Gap 1 — Step 8 per-zone width/height 주입이 span zone 에서 깨짐 (:3068-3070 zip 이 per-zone 과 per-row/col 혼동) |
✅ 확인. debug_zones = per-zone (top-1-bottom-2 → 3 entry: top/bottom-left/bottom-right). 2-D heights_px = per-row (2 entry). zip 직접 적용 시 top 이 row[0] 만, bottom-left 가 row[1], bottom-right 가 idx-out-of-range or dropped. 현재 코드의 zip 이 1-D 에서만 의도된 path. |
수용 — area-aware mapper 신설 (§2-A) |
| Gap 2 — PR 2 override 가 T / 2x2 의 X,Y,W,H → grid track 변환 룰 미정의 | ✅ 확인. round 1 의 _build_layout_css_from_override 는 horizontal-2 (h) / vertical-2 (w) legacy 그대로. T / 2x2 의 span zone 이 grid row/col 양쪽에 영향 — 룰 부재. |
수용 — override 룰 5 조항 명시 (§2-B) |
Gap 3 — PR 3 V4 32-frame test 가 select_layout_preset(unit_count) 으로 signature 오기. assertion dynamic_rows or dynamic_cols or single 의 single field 부재 |
✅ 확인. src/phase_z2_composition.py:709 signature = select_layout_preset(units: list[CompositionUnit]). 또한 single 분기는 dynamic_rows=False, dynamic_cols=False (Stage 2 §1 #3 lock) — or single 은 field 가 아니라 preset 이름 의도였으나 assertion 으로 부정확. |
수용 — assertion 재작성 (§2-C). 단 invariant 자체도 surface 변경 (8-preset coverage assertion 으로 확장) |
Gap 4 — _parse_css_areas / compute_zone_layout_2d 가 malformed 입력에 div-by-zero / silent 빈 출력 |
✅ 확인. round 1 snippet 의 C = len(rows[0]) if R > 0 else 0 → avail_w = total_width - gap * (C - 1) 이 C=0 일 때 total_width + gap 으로 garbage. zone position 이 css_areas 에 없을 때도 silent 0 weight. |
수용 — strict validation (§2-D) |
Rollback 정정 a — pixel cols "583px 583px" 가 invalid CSS spec 이라 한 표현은 잘못. grid-template-columns 가 px 값 허용. |
✅ 확인. CSS Grid spec — grid-template-columns: <length> 허용. round 1 의 rollback trigger 표현 오류. |
수용 — rollback trigger 재작성 (§2-E) |
| Rollback 정정 b — per-zone planned geometry 와 CSS grid span mismatch 가 PR 2 의 주 failure surface, rollback trigger 로 추가 필요 | ✅ 동의. Gap 1 의 area-aware mapper 가 misfire 시 fixture 가 catch 해야 함. | 수용 — rollback trigger 추가 (§2-E) |
6 정정 모두 수용. round 1 plan 의 미정의 surface 가 본 round 에서 lock.
1. 정정의 root cause
round 1 plan 의 근본 결함 = 2-D layout 에서 "zone" 과 "grid track" 의 분리 모델 부재. 1-D 에서는 zone = row (또는 col) 라서 zip 으로 충분. 2-D 에서는 :
- grid track (row / col) = 물리 분할 단위 (catalog
css_areas의 토큰 위치) - zone = 의미 단위 (debug_zones 의 unique position)
- spanning zone = 1 zone 이 N track 차지
3 개념 분리 필요. round 2 가 area-aware mapper 로 분리 lock.
2. 정제된 contract (round 1 → round 2 변경)
2-A. Area-aware zone geometry mapper (Gap 1)
신규 helper _compute_per_zone_geometry :
def _compute_per_zone_geometry(
layout_css: dict,
debug_zones: list[dict],
gap: int = GRID_GAP,
) -> list[dict]:
"""Map grid-track heights/widths to per-zone aggregated dimensions.
Handles spanning zones in 2-D layouts (T, inverted-T, side-T, 2x2):
zone_height = sum(track_heights for rows occupied) + gap * (span_rows - 1)
zone_width = sum(track_widths for cols occupied) + gap * (span_cols - 1)
For 1-D layouts (horizontal-2, vertical-2):
degenerates to simple positional indexing (no spanning).
Returns list[{position, zone_height_px, zone_width_px,
zone_height_ratio, zone_width_ratio}].
"""
css_areas = layout_css.get("areas")
heights_px = layout_css.get("heights_px") or []
widths_px = layout_css.get("widths_px") or []
raw_zl = layout_css.get("raw_zone_layout") or {}
rows_grid = raw_zl.get("rows_grid") # set by compute_zone_layout_2d only
if rows_grid is None:
# 1-D path : positional zip (legacy horizontal-2 / new vertical-2)
per_zone = []
for i, dz in enumerate(debug_zones):
per_zone.append({
"position": dz["position"],
"zone_height_px": heights_px[i] if i < len(heights_px) else None,
"zone_width_px": widths_px[i] if i < len(widths_px) else None,
"zone_height_ratio": (
layout_css.get("ratios", [])[i]
if i < len(layout_css.get("ratios", [])) else None
),
"zone_width_ratio": (
layout_css.get("width_ratios", [])[i]
if i < len(layout_css.get("width_ratios", [])) else None
),
})
return per_zone
# 2-D path : aggregate via parsed grid
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:
# zone declared but not in css_areas — strict error (catalog corruption)
raise ValueError(
f"zone position '{pos}' not found in parsed 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
호출처 변경 :
:3067-3073(debug_zones 갱신) — zip 직접 사용 폐기. 대신 :per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP) for dz, geo in zip(debug_zones, per_zone_geo): dz["height_px"] = geo["zone_height_px"] dz["ratio"] = geo["zone_height_ratio"] dz["width_px"] = geo["zone_width_px"] dz["width_ratio"] = geo["zone_width_ratio"]:3228-3251(Step 8 per-zone) —dz.get("height_px")/dz.get("width_px")이 mapper 결과 (이미 위에서 주입) 그대로.
이 패턴은 1-D / 2-D 양쪽 모두 동일 API. spanning 처리는 mapper 안에 격리.
검증 fixture (PR 2 추가) :
top-1-bottom-2:top.zone_width_px = widths_px[0] + widths_px[1] + GRID_GAP,bottom-left.zone_width_px = widths_px[0].grid-2x2:top-left.zone_width_px = widths_px[0],top-left.zone_height_px = heights_px[0](span 없음).
2-B. Override branch 의 T / 2x2 룰 (Gap 2)
신규 _build_layout_css_from_override 의 일반 룰 (5 조항) :
- Consistency : 모든 zone position 이
override_zone_geometries에 있어야 함. partial override (일부 zone 만) → strict error. - Spanning consistency : zone 이 N track 을 span 할 때, 그 zone 의
w(또는h) 는 총 span 비율이어야 함. 예: top-1-bottom-2 의top=w=1.0(cols 0+1 전체) 필수.w=0.5와 같이 부분 값이면 strict error. - Track contribution : 각 grid track 의 ratio = 그 track 에 포함된 non-span zone 의 ratio (있을 때만). 모든 zone 이 그 track 에서 span 이면 → spanning zone 의 ratio 를 N 등분하여 적용 (Codex Gap 2 의 "aggregated zone rectangles" 옵션).
- Track sum normalization : 각 axis 의 track ratio 합 = 1.0 (오차 0.01 허용, 마지막 track 흡수).
- Missing geometry : zone 이 override 에 없으면 strict error. partial fallback (override + dynamic mix) 미지원 (별 axis).
구현 :
def _override_to_grid_tracks(
preset: dict, override_zone_geometries: dict,
css_areas: str, axis: str, # "row" or "col"
) -> list[float]:
"""Convert override X,Y,W,H per zone -> grid track ratios.
axis="row" -> returns row ratios derived from H values.
axis="col" -> returns col ratios derived from W values.
Spanning rule: a zone spanning N tracks on the axis MUST have
its H (or W) value equal the total span ratio. The function
distributes that span H/W equally across the spanned tracks if
no non-span zone fixes them; otherwise non-span zones win.
"""
rows_grid, unique_zones = _parse_css_areas(css_areas)
R, C = len(rows_grid), len(rows_grid[0]) if rows_grid else 0
# 5-#1 / #5 — consistency / missing
for pos in unique_zones:
if pos not in override_zone_geometries:
raise ValueError(
f"override missing zone '{pos}' (partial override not supported)"
)
if axis == "row":
track_count = R
track_values: list[Optional[float]] = [None] * R
for pos, geom in override_zone_geometries.items():
occupied = sorted({r for r, row in enumerate(rows_grid) if pos in row})
span = len(occupied)
if span == 0:
continue
if span == 1:
track_values[occupied[0]] = geom["h"] # non-span wins
else:
# 5-#3 — spanning zone, distribute equally if no non-span fixed
per_track = geom["h"] / span
for r in occupied:
if track_values[r] is None:
track_values[r] = per_track
else: # axis == "col"
track_count = C
track_values = [None] * C
for pos, geom in override_zone_geometries.items():
occupied = sorted({c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos})
span = len(occupied)
if span == 0:
continue
if span == 1:
track_values[occupied[0]] = geom["w"]
else:
per_track = geom["w"] / span
for c in occupied:
if track_values[c] is None:
track_values[c] = per_track
if any(v is None for v in track_values):
raise ValueError(
f"override produced unresolved {axis} tracks: {track_values} "
f"(catalog/override mismatch)"
)
total = sum(track_values)
if not 0.99 <= total <= 1.01:
raise ValueError(
f"override {axis} ratios sum to {total} (expected ~1.0)"
)
# 5-#4 — normalize last track to absorb rounding
track_values[-1] += 1.0 - total
return track_values
build_layout_css override branch (:856-902) 재구조화 :
if override_zone_geometries:
row_ratios = _override_to_grid_tracks(
preset, override_zone_geometries, preset["css_areas"], "row"
)
col_ratios = _override_to_grid_tracks(
preset, override_zone_geometries, preset["css_areas"], "col"
)
heights_px = [int(round(r * (SLIDE_BODY_HEIGHT - GRID_GAP * (len(row_ratios)-1))))
for r in row_ratios]
widths_px = [int(round(c * (SLIDE_BODY_WIDTH - GRID_GAP * (len(col_ratios)-1))))
for c in col_ratios]
return {
"areas": preset["css_areas"],
"cols": " ".join(f"{w}px" for w in widths_px),
"rows": " ".join(f"{h}px" for h in heights_px),
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": row_ratios,
"width_ratios": col_ratios,
"computation": "override_zone_geometries",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": None,
}
horizontal-2 / vertical-2 의 legacy override path (h-only / w-only) 는 삭제. 새 일반 룰이 horizontal-2 (R=2, C=1) / vertical-2 (R=1, C=2) 도 자연스럽게 처리. 단 기존 fixture (Stage 2 §2-A retry_gate horizontal2 fixture) 의 input override 형식 (h=0.6, h=0.4) 은 보존되어야 함 — 호출 시 w 값이 없으면 catalog css_cols=1fr 에서 col=1 이므로 each zone w=1.0 implied. 기존 input dict 호환성 :
# Default missing axis value : full extent (1.0) — preserves horizontal-2 legacy behavior
for pos, geom in override_zone_geometries.items():
geom.setdefault("h", 1.0)
geom.setdefault("w", 1.0)
geom.setdefault("x", 0.0)
geom.setdefault("y", 0.0)
이 default = legacy h-only override 가 vertical-2 의 R=1 일 때 R 의 row_ratio 합 = 1.0 (zone 1 개) 으로 자연 일관.
2-C. PR 3 V4 invariant test 재작성 (Gap 3)
round 1 의 select_layout_preset(unit_count) 잘못된 signature 폐기. 또한 V4 32-frame surface 는 select_layout_preset 의 4 preset (single / horizontal-2 / top-1-bottom-2 / grid-2x2) 만 커버 — 8 preset 전수 dynamic 확인은 별 axis (direct build_layout_css 호출).
분리된 2 test :
# tests/phase_z2/test_all_8_presets_dynamic.py
def test_all_8_presets_produce_dynamic_geometry():
"""Every layout preset in catalog must produce dynamic geometry,
not fr_default_from_preset sink. PR 3 closing invariant."""
expected_computation_substring = {
"single": "single_zone_full_body",
"horizontal-2": "content_weight_distribution",
"vertical-2": "content_weight_distribution_cols",
"top-1-bottom-2": "content_weight_distribution_2d",
"top-2-bottom-1": "content_weight_distribution_2d",
"left-1-right-2": "content_weight_distribution_2d",
"left-2-right-1": "content_weight_distribution_2d",
"grid-2x2": "content_weight_distribution_2d",
}
for preset_id, expected in expected_computation_substring.items():
zones_data = _synthesize_zones_for_preset(preset_id)
result = build_layout_css(preset_id, zones_data)
assert expected in result["computation"], (
f"preset={preset_id} computation={result['computation']!r}, "
f"expected substring {expected!r}"
)
assert result["computation"] != "fr_default_from_preset", (
f"preset={preset_id} fell through to fr_default sink"
)
# tests/phase_z2/test_v4_full32_layout_coverage.py
def test_v4_full32_default_preset_paths():
"""Passive cross-check: for the 4 presets reachable via
select_layout_preset(units), V4 32-frame catalog never triggers
fr_default sink. select_layout_preset accepts list[CompositionUnit]."""
from src.phase_z2_composition import CompositionUnit, select_layout_preset
# Build dummy units for 1..4 unit counts (preset coverage of v0 selector)
for unit_count in range(1, 5):
units = [_dummy_composition_unit() for _ in range(unit_count)]
preset_id = select_layout_preset(units)
assert preset_id is not None
zones_data = _synthesize_zones_for_preset(preset_id)
result = build_layout_css(preset_id, zones_data)
assert result["computation"] != "fr_default_from_preset", (
f"unit_count={unit_count} preset={preset_id} fell through"
)
_synthesize_zones_for_preset = helper. catalog 의 positions 를 읽고 각 zone 에 content_weight={score: 1.0} 부여. zero-weight 가드 (§2-A round 1) 와 별 axis (동등 분배 확인 따로).
2-D. Strict validation (Gap 4)
_parse_css_areas 보강 :
def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]:
"""Parse CSS grid-template-areas string into (row x col) cell grid.
Raises ValueError on:
- empty input
- non-rectangular (rows with different column counts)
- no quoted row strings found
"""
rows = []
seen = []
quoted = re.findall(r'"([^"]+)"', css_areas)
if not quoted:
raise ValueError(f"css_areas has no quoted row strings: {css_areas!r}")
for row_str in quoted:
tokens = row_str.split()
if not tokens:
raise ValueError(f"empty row in css_areas: {css_areas!r}")
rows.append(tokens)
for t in tokens:
if t not in seen:
seen.append(t)
col_counts = {len(r) for r in rows}
if len(col_counts) > 1:
raise ValueError(
f"non-rectangular css_areas: row column counts = {col_counts}"
)
return rows, seen
compute_zone_layout_2d 보강 :
def compute_zone_layout_2d(zones_data, css_areas, ...):
...
rows, _seen = _parse_css_areas(css_areas)
R, C = len(rows), len(rows[0])
if R == 0 or C == 0:
raise ValueError(
f"compute_zone_layout_2d: degenerate grid R={R} C={C}"
)
# Validate every zone in zones_data has a position in css_areas
zone_positions = {z["position"] for z in zones_data}
grid_positions = set(_seen)
extra = zone_positions - grid_positions
if extra:
raise ValueError(
f"zones_data has positions {extra} not in css_areas {grid_positions}"
)
missing = grid_positions - zone_positions
if missing:
raise ValueError(
f"css_areas has positions {missing} not in zones_data"
)
...
신규 negative test tests/phase_z2/test_parse_css_areas_validation.py :
def test_parse_css_areas_empty(): ... # raises ValueError
def test_parse_css_areas_no_quotes(): ... # raises
def test_parse_css_areas_non_rectangular(): ... # raises
def test_parse_css_areas_single_row(): ... # ([["top"]], ["top"])
def test_parse_css_areas_t_shape(): ... # rectangular T (span tokens)
def test_compute_zone_layout_2d_missing_zone(): ... # raises
def test_compute_zone_layout_2d_extra_zone(): ... # raises
def test_compute_zone_layout_2d_zero_weight_total(): ... # equal split
8 negative + edge case test. PR 2 land.
2-E. Rollback trigger 재작성
round 1 §6 정정 :
| PR | round 1 trigger | round 2 정정 |
|---|---|---|
| PR 1 | "vertical-2 가 cols 에 \"NNpx NNpx\" 출력 → grid-template-columns invalid" |
수정: "vertical-2 final.html render 후 visual_check 단계 (run_visual_check.py) 가 grid layout broken (zone 박스 위치 mismatch) 감지" |
| PR 2 | (없음) | 추가: "per-zone fixture 의 zone_width_px / zone_height_px 가 parsed css_areas span 과 mathematical inconsistent (= _compute_per_zone_geometry 가 spanning zone 을 잘못 집계)" |
| PR 2 | (없음) | 추가: "override 호출 시 _override_to_grid_tracks 가 strict error 를 던지지 않고 silent garbage 반환 — partial override / span 불일치 case 모두 explicit raise 필요" |
| 공통 | "기존 42 unit test 중 하나라도 fail" | 유지 |
3. PR scope 정합화 (round 1 → round 2)
§2-A _compute_per_zone_geometry 와 §2-B _override_to_grid_tracks 의 PR 배치 결정 :
| 구성요소 | PR 1 | PR 2 | PR 3 | 이유 |
|---|---|---|---|---|
_compute_per_zone_geometry (1-D path) |
✅ | vertical-2 의 per-zone width 주입 시 필요 (debug_zones 갱신) |
||
_compute_per_zone_geometry (2-D path, rows_grid 분기) |
✅ | rows_grid 는 compute_zone_layout_2d 출력에만 — 함수 자체는 PR 1 신설, 2-D 분기 활성화는 PR 2 |
||
_parse_css_areas + strict validation |
✅ | 2-D solver 전제. PR 1 의 vertical-2 는 css_areas parsing 불필요 | ||
_override_to_grid_tracks (일반 룰) |
✅ | T / 2x2 의 override 룰 = PR 2 land. PR 1 은 legacy h-only override (horizontal-2/vertical-2) preserve | ||
_override_to_grid_tracks (h/w default 1.0) |
✅ | PR 1 의 vertical-2 override 도 새 룰 적용 — 동시 land |
PR 1 의 override branch 처리 = vertical-2 만 (h-only horizontal-2 legacy preserve, w-only vertical-2 신설). 일반 룰 (_override_to_grid_tracks) 은 PR 2 에서 도입 + 4 preset (h-2/v-2/T/2x2) 전수 migrate. 즉 PR 1 의 vertical-2 override 는 round 1 의 임시 분기 유지 (PR 2 가 통합).
수정 PR 시퀀스 (Stage 2 §2-C 호환 + Gap 1/2 반영) :
| PR | 작업 (round 2 lock) |
|---|---|
| PR 1 | (a) compute_zone_layout_cols 신설 (§3-A round 1) (b) _compute_per_zone_geometry 신설 — 1-D path 만 활성화 (2-D rows_grid 분기는 PR 2 가 enable) (c) build_layout_css override branch — vertical-2 legacy 분기 추가 (h-only horizontal-2 + w-only vertical-2 둘 다 preserve) (d) layout_css 신규 key ( widths_px / width_ratios / dynamic_cols) — 모든 return path 일관 (e) Step 8 col-axis 4 신규 field (per-zone width via _compute_per_zone_geometry 1-D path) (f) Retry gate ( _attempt_zone_ratio_retry:1322 직후) (g) fixtures : horizontal-2 ×3 + vertical-2 ×3 + retry_gate ×3 (h) unit tests : test_compute_zone_layout_cols.py, test_build_layout_css.py (h-2/v-2), test_retry_gate.py, test_compute_per_zone_geometry.py (1-D path) |
| PR 2 | (a) _parse_css_areas + strict validation (§2-D) (b) compute_zone_layout_2d (Q-r2-2 row sum, Q-r2-3 2x2 row/col sum) (c) _compute_per_zone_geometry 2-D path enable (spanning zone aggregation) (d) _override_to_grid_tracks 일반 룰 (5 조항 §2-B) — h-2/v-2/T/2x2 4 preset 전수 migrate (legacy 분기 폐기) (e) build_layout_css dynamic branch — topology dispatcher (preset string fallback) (f) fixtures : top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2 ×3 = 15 YAML (g) unit tests : test_compute_zone_layout_2d.py, test_build_layout_css.py 8-preset extension, test_parse_css_areas_validation.py (8 negative case), test_override_to_grid_tracks.py (5 룰 case) |
| PR 3 | (a) single planned geometry 명시 (heights_px=[SLIDE_BODY_HEIGHT], widths_px=[SLIDE_BODY_WIDTH]) (b) Step 8 nested status ( zone_geometry_status="done", region_geometry_status="partial") (c) note 갱신 ( :3265-3273) (d) fixtures : single ×3 (e) PR 3 invariant tests (§2-C) : test_all_8_presets_dynamic.py + test_v4_full32_layout_coverage.py |
4. 잔여 axis (본 round 처리 X)
| 항목 | 상태 | 근거 |
|---|---|---|
select_layout_preset hardcoded → catalog-driven |
별 issue | Codex #3 Stage 1 정정 — 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 / Q-r2-4 — region-level partial 별 axis |
| Render template gap row/col 분리 | 미적용 | Stage 2 §4 #4 — shorthand 유지 lock |
5. Round 2 결론
- Codex #1 의 4 blocking gap (per-zone width injection, override 룰, V4 test signature, malformed validation) + 2 rollback 정정 모두 검증 + 수용.
- 신규 helper 2 (
_compute_per_zone_geometry,_override_to_grid_tracks) + strict validation + negative test 8 개 추가. - PR scope 재정합 —
_compute_per_zone_geometry1-D path PR 1, 2-D enable PR 2._override_to_grid_tracks일반 룰 PR 2. - 잔여 open 결정 0. Stage 2 의 7 결정 분기 + round 1 Q-r2-1
5 + round 2 Gap 14 모두 코드 변경 단위 lock. - 본 round 변경 = 0 (plan-only, code 미수정).
Codex 가 §2-A area-aware mapper / §2-B override 5 룰 / §2-C 재작성 test / §2-D strict validation / §2-E rollback trigger / §3 PR 재정합 6 axis 모두 ack 하면 Stage 3 종료 → Stage 4 implementation handoff.
FINAL_CONSENSUS: NO