Files
C.E.L_Slide_test2/.orchestrator/tmp/issue9_claude2_round2.md
T

23 KiB
Raw Blame History

[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 singlesingle 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 singlefield 가 아니라 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 0avail_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 조항) :

  1. Consistency : 모든 zone position 이 override_zone_geometries 에 있어야 함. partial override (일부 zone 만) → strict error.
  2. 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.
  3. Track contribution : 각 grid track 의 ratio = 그 track 에 포함된 non-span zone 의 ratio (있을 때만). 모든 zone 이 그 track 에서 span 이면 → spanning zone 의 ratio 를 N 등분하여 적용 (Codex Gap 2 의 "aggregated zone rectangles" 옵션).
  4. Track sum normalization : 각 axis 의 track ratio 합 = 1.0 (오차 0.01 허용, 마지막 track 흡수).
  5. 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 surfaceselect_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_tracksPR 배치 결정 :

구성요소 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_gridcompute_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.yamlmin_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_geometry 1-D path PR 1, 2-D enable PR 2. _override_to_grid_tracks 일반 룰 PR 2.
  • 잔여 open 결정 0. Stage 2 의 7 결정 분기 + round 1 Q-r2-15 + 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