diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 5bc031b..331c8ac 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -1113,6 +1113,224 @@ def _build_cols_dynamic(preset: dict, zones_data: list[dict], } +# ─── IMP-09 PR 2 helpers (2-D dynamic dispatch) ────────────────────── +# 5 in-scope presets (top-1-bottom-2, top-2-bottom-1, left-1-right-2, +# left-2-right-1, grid-2x2) are promoted from _build_fr_default to +# dynamic computation on BOTH axes by aggregating zone positions onto +# each row/col track. Each preset's R x C grid is mapped to two +# 1-D allocations consumed by the PR 1 solvers (compute_zone_layout +# for rows, compute_zone_layout_cols for cols). Spanning zones +# contribute to one axis only (the axis they span exclusively +# defines); they are excluded from the perpendicular axis aggregate +# because their position does not bias either track on that axis. + +TOPOLOGY_AXIS_MAP: dict[str, dict[str, list[list[str]]]] = { + "T": { + # "top top" / "bottom-left bottom-right" + # top spans both cols — excluded from col aggregates. + "rows": [["top"], ["bottom-left", "bottom-right"]], + "cols": [["bottom-left"], ["bottom-right"]], + }, + "inverted-T": { + # "top-left top-right" / "bottom bottom" + # bottom spans both cols — excluded from col aggregates. + "rows": [["top-left", "top-right"], ["bottom"]], + "cols": [["top-left"], ["top-right"]], + }, + "side-T-left": { + # "left right-top" / "left right-bottom" + # left spans both rows — excluded from row aggregates. + "rows": [["right-top"], ["right-bottom"]], + "cols": [["left"], ["right-top", "right-bottom"]], + }, + "side-T-right": { + # "left-top right" / "left-bottom right" + # right spans both rows — excluded from row aggregates. + "rows": [["left-top"], ["left-bottom"]], + "cols": [["left-top", "left-bottom"], ["right"]], + }, + "2x2": { + # "top-left top-right" / "bottom-left bottom-right" + # No spanning — every zone participates in both axes. + "rows": [["top-left", "top-right"], ["bottom-left", "bottom-right"]], + "cols": [["top-left", "bottom-left"], ["top-right", "bottom-right"]], + }, +} + + +_TWO_D_TOPOLOGIES = frozenset(TOPOLOGY_AXIS_MAP) + + +def _aggregate_axis_zone(positions: list[str], zones_data: list[dict], + axis_label: str) -> dict: + """Combine zones occupying the same row/col track into a synthetic + zone consumable by the PR 1 row/col solvers. + + - 1 zone: returned as-is (no allocation overhead). + - N zones: synthetic zone with + min_height_px = max of constituents (both must clear the bound) + content_weight.score = mean (each zone contributes equally to track) + template_id / position = _AXIS_ marker + """ + src = [z for z in zones_data if z["position"] in positions] + seen = {z["position"] for z in src} + missing = [p for p in positions if p not in seen] + if missing: + raise ValueError( + f"_aggregate_axis_zone: zones_data missing positions {missing} " + f"required for axis {axis_label!r} (got {sorted(seen)!r})" + ) + if len(src) == 1: + return src[0] + min_h = max( + z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX) for z in src + ) + score = sum(z["content_weight"]["score"] for z in src) / len(src) + return { + "position": f"_AXIS_{axis_label}", + "template_id": f"_AXIS_{axis_label}", + "content_weight": {"score": score}, + "min_height_px": min_h, + } + + +def _build_2d_dynamic(preset: dict, zones_data: list[dict], + gap: int = GRID_GAP) -> dict: + """2-D dynamic path — both row heights and col widths computed + from per-axis aggregates of zones_data. + + Topology-keyed aggregation lives in TOPOLOGY_AXIS_MAP. Spanning + zones (top in T, bottom in inverted-T, left in side-T-left, + right in side-T-right) appear only on the axis they span + exclusively; the perpendicular axis is driven by the non-spanning + zones to avoid double-biasing. + """ + topology = preset["topology"] + if topology not in TOPOLOGY_AXIS_MAP: + raise ValueError( + f"_build_2d_dynamic: topology {topology!r} not registered " + f"in TOPOLOGY_AXIS_MAP {sorted(TOPOLOGY_AXIS_MAP)!r}" + ) + mapping = TOPOLOGY_AXIS_MAP[topology] + + row_axis_zones = [ + _aggregate_axis_zone(group, zones_data, f"R{i}") + for i, group in enumerate(mapping["rows"]) + ] + col_axis_zones = [ + _aggregate_axis_zone(group, zones_data, f"C{i}") + for i, group in enumerate(mapping["cols"]) + ] + + row_zl = compute_zone_layout(row_axis_zones, gap=gap) + col_zl = compute_zone_layout_cols(col_axis_zones, gap=gap) + + rows_str = " ".join(f"{h}px" for h in row_zl["heights_px"]) + cols_str = " ".join(f"{w}px" for w in col_zl["widths_px"]) + return { + "areas": preset["css_areas"], + "cols": cols_str, + "rows": rows_str, + "heights_px": row_zl["heights_px"], + "widths_px": col_zl["widths_px"], + "ratios": row_zl["ratios"], + "width_ratios": col_zl["width_ratios"], + "computation": "2d_dynamic_aggregated", + "dynamic_rows": True, + "dynamic_cols": True, + "raw_zone_layout": { + "topology": topology, + "rows": row_zl, + "cols": col_zl, + }, + } + + +def _override_to_grid_tracks(preset: dict, override: dict[str, dict], + zones_data: list[dict], + gap: int = GRID_GAP) -> dict: + """Derive row/col track sizes from override_zone_geometries for + the 5 in-scope 2-D presets. + + Per-axis reconciliation: + - Row track size = max(h) of zones in that row (zones sharing + a row are constrained by the taller request). + - Col track size = max(w) of zones in that column (same + reasoning, w-axis). + - Spanning zones are mapped only to the axis they exclusively + define (TOPOLOGY_AXIS_MAP rows/cols). + - Missing or zero total on an axis → that axis falls back to + the dynamic normal-path solver (compute_zone_layout / + compute_zone_layout_cols) on aggregated axis zones. + """ + topology = preset["topology"] + mapping = TOPOLOGY_AXIS_MAP[topology] + rows_grid, _ = _parse_css_areas(preset["css_areas"]) + R = len(rows_grid) + C = len(rows_grid[0]) + avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1) + avail_w = SLIDE_BODY_WIDTH - gap * (C - 1) + + # ── Row axis ── + row_raw = [] + for group in mapping["rows"]: + hs = [ + float(override.get(pos, {}).get("h", 0) or 0) + for pos in group + ] + row_raw.append(max(hs) if hs else 0.0) + + if sum(row_raw) > 0: + total = sum(row_raw) + row_ratios = [r / total for r in row_raw] + heights_px = [int(round(r * avail_h)) for r in row_ratios] + heights_px[-1] += avail_h - sum(heights_px) + row_source = "override" + else: + row_axis_zones = [ + _aggregate_axis_zone(g, zones_data, f"R{i}") + for i, g in enumerate(mapping["rows"]) + ] + row_zl = compute_zone_layout(row_axis_zones, gap=gap) + heights_px = row_zl["heights_px"] + row_ratios = [h / SLIDE_BODY_HEIGHT for h in heights_px] + row_source = "dynamic_fallback" + + # ── Col axis ── + col_raw = [] + for group in mapping["cols"]: + ws = [ + float(override.get(pos, {}).get("w", 0) or 0) + for pos in group + ] + col_raw.append(max(ws) if ws else 0.0) + + if sum(col_raw) > 0: + total = sum(col_raw) + col_ratios = [c / total for c in col_raw] + widths_px = [int(round(c * avail_w)) for c in col_ratios] + widths_px[-1] += avail_w - sum(widths_px) + col_source = "override" + else: + col_axis_zones = [ + _aggregate_axis_zone(g, zones_data, f"C{i}") + for i, g in enumerate(mapping["cols"]) + ] + col_zl = compute_zone_layout_cols(col_axis_zones, gap=gap) + widths_px = col_zl["widths_px"] + col_ratios = [w / SLIDE_BODY_WIDTH for w in widths_px] + col_source = "dynamic_fallback" + + return { + "heights_px": heights_px, + "widths_px": widths_px, + "row_ratios": row_ratios, + "col_ratios": col_ratios, + "row_source": row_source, + "col_source": col_source, + } + + # Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용. # 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29). @@ -1131,15 +1349,19 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], Dynamic dispatch: - topology="rows" -> _build_rows_dynamic (horizontal-2: row heights) - topology="cols" -> _build_cols_dynamic (vertical-2: col widths) - - other topologies (single / T / inverted-T / side-T / 2x2) fall - through to _build_fr_default in PR 1; PR 2 enables the 2-D - dispatcher. + - topology in _TWO_D_TOPOLOGIES (T / inverted-T / side-T-left / + side-T-right / 2x2) -> _build_2d_dynamic (both axes dynamic + via aggregated row/col solver inputs). + - topology="single" falls through to _build_fr_default (PR 3 + promotion pending). Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id -> {x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve). - 다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks - 로 promote). + PR 2 lock: 5 in-scope 2-D presets (T / inverted-T / side-T-left / + side-T-right / 2x2) routed through _override_to_grid_tracks; the + `single` preset is the only remaining warn-and-fallthrough surface + (PR 3 will promote it). """ preset = LAYOUT_PRESETS[layout_preset] positions = preset["positions"] @@ -1202,22 +1424,50 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], "dynamic_cols": True, "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, } + elif topology in _TWO_D_TOPOLOGIES: + # PR 2 — 2-D override via topology-keyed track derivation. + tracks = _override_to_grid_tracks( + preset, override_zone_geometries, zones_data, gap=gap + ) + rows_str = " ".join(f"{h}px" for h in tracks["heights_px"]) + cols_str = " ".join(f"{w}px" for w in tracks["widths_px"]) + return { + "areas": preset["css_areas"], + "cols": cols_str, + "rows": rows_str, + "heights_px": tracks["heights_px"], + "widths_px": tracks["widths_px"], + "ratios": [round(r, 3) for r in tracks["row_ratios"]], + "width_ratios": [round(c, 3) for c in tracks["col_ratios"]], + "computation": "user_override_geometry", + "dynamic_rows": True, + "dynamic_cols": True, + "raw_zone_layout": { + "override_applied": True, + "source": override_zone_geometries, + "topology": topology, + "row_source": tracks["row_source"], + "col_source": tracks["col_source"], + }, + } else: - # PR 1 lock — warn-and-fallthrough preserved. - # PR 2 promotes this to strict ValueError via _override_to_grid_tracks. + # PR 2 lock — only `single` remains as warn-and-fallthrough. + # PR 3 will promote single via dedicated override handling. print( f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 " - f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.", + f"(PR 2 후 single 만 fallthrough). default layout_css 사용.", file=sys.stderr, ) - # ── Dynamic branch — topology dispatch (PR 1: rows / cols only) ── + # ── Dynamic branch — topology dispatch ── if topology == "rows": return _build_rows_dynamic(preset, zones_data, gap) if topology == "cols": return _build_cols_dynamic(preset, zones_data, gap) - # PR 2 will dispatch T / inverted-T / side-T-{left,right} / 2x2 here. - # PR 3 will dispatch single here. + if topology in _TWO_D_TOPOLOGIES: + return _build_2d_dynamic(preset, zones_data, gap) + # PR 3 will dispatch `single` here; until then it falls through to + # fr_default_from_preset (length-locked sink). return _build_fr_default(preset) diff --git a/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml b/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml index bcf838c..14b65e1 100644 --- a/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml +++ b/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml @@ -1,9 +1,12 @@ case_id: single_fr_default description: | - Any layout that fell through to fr_default_from_preset (single, - T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols. - Row-axis retry is a no-op and must be skipped by the IMP-09 gate - with a fr_default_from_preset skip reason. + Post-IMP-09 PR 2 the only preset that still falls through to + fr_default_from_preset is `single` (top-1-bottom-2 / top-2-bottom-1 / + left-1-right-2 / left-2-right-1 / grid-2x2 were promoted to 2-D + dynamic). This fixture exercises the IMP-09 retry-gate fr_default + skip path using a layout_css with dynamic_rows=False AND + dynamic_cols=False (the surviving fr_default signature). Row-axis + retry must be skipped with a fr_default_from_preset skip reason. input_layout_css: areas: '"top top" "bottom-left bottom-right"' cols: 1fr 1fr diff --git a/tests/phase_z2/test_build_layout_css_pr1.py b/tests/phase_z2/test_build_layout_css_pr1.py index 079d110..048ae92 100644 --- a/tests/phase_z2/test_build_layout_css_pr1.py +++ b/tests/phase_z2/test_build_layout_css_pr1.py @@ -138,21 +138,109 @@ def test_vertical_2_override_keeps_fr_cols_legacy(): assert result["width_ratios"] == [0.4, 0.6] -# ────────────────────── fr_default sink (PR 1) ────────────────────── +# ───────────────── PR 2: 5 in-scope 2-D presets dynamic ───────────────── -def test_top_1_bottom_2_fr_default_populates_geometry(): - """T-shape (top-1-bottom-2) falls through to fr_default in PR 1 - but heights_px / widths_px must be populated (length-locked to - grid R=2, C=2).""" - zones = [ - _zone("top", 0.5), - _zone("bottom-left", 0.25), - _zone("bottom-right", 0.25), - ] - result = build_layout_css("top-1-bottom-2", zones) +_TWO_D_PRESETS = [ + "top-1-bottom-2", + "top-2-bottom-1", + "left-1-right-2", + "left-2-right-1", + "grid-2x2", +] + + +def _zones_for(preset: str) -> list[dict]: + """Default zone fixtures (positions per LAYOUT_PRESETS, equal score).""" + if preset == "top-1-bottom-2": + return [_zone("top", 0.5), _zone("bottom-left", 0.25), + _zone("bottom-right", 0.25)] + if preset == "top-2-bottom-1": + return [_zone("top-left", 0.3), _zone("top-right", 0.2), + _zone("bottom", 0.5)] + if preset == "left-1-right-2": + return [_zone("left", 0.5), _zone("right-top", 0.3), + _zone("right-bottom", 0.2)] + if preset == "left-2-right-1": + return [_zone("left-top", 0.3), _zone("left-bottom", 0.2), + _zone("right", 0.5)] + if preset == "grid-2x2": + return [_zone("top-left", 0.25), _zone("top-right", 0.25), + _zone("bottom-left", 0.25), _zone("bottom-right", 0.25)] + raise ValueError(f"no _zones_for({preset!r})") + + +@pytest.mark.parametrize("preset", _TWO_D_PRESETS) +def test_two_d_preset_promoted_to_dynamic(preset): + """PR 2 — T / inverted-T / side-T-{left,right} / 2x2 must dispatch + to _build_2d_dynamic (computation=='2d_dynamic_aggregated') with + dynamic_rows=True AND dynamic_cols=True, and grid-template strings + in pixels.""" + result = build_layout_css(preset, _zones_for(preset)) + assert result["computation"] == "2d_dynamic_aggregated" + assert result["dynamic_rows"] is True + assert result["dynamic_cols"] is True + # Both axes pixel-based. + assert "fr" not in result["rows"] + assert "fr" not in result["cols"] + assert result["rows"].count("px") == 2 + assert result["cols"].count("px") == 2 + # Length contract — R=2 rows, C=2 cols for all 5 in-scope presets. + assert len(result["heights_px"]) == 2 + assert len(result["widths_px"]) == 2 + + +@pytest.mark.parametrize("preset", _TWO_D_PRESETS) +def test_two_d_preset_override_dispatches_via_helper(preset): + """Override on a 2-D preset must route through + _override_to_grid_tracks (computation=='user_override_geometry') + with both axes marked dynamic.""" + zones = _zones_for(preset) + positions = [z["position"] for z in zones] + override = {pos: {"x": 0, "y": 0, "w": 0.5, "h": 0.5} for pos in positions} + result = build_layout_css(preset, zones, override_zone_geometries=override) + assert result["computation"] == "user_override_geometry" + assert result["dynamic_rows"] is True + assert result["dynamic_cols"] is True + assert len(result["heights_px"]) == 2 + assert len(result["widths_px"]) == 2 + # Total of axis cell sums equals body minus inter-track gap. + assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP + assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP + + +@pytest.mark.parametrize("preset", _TWO_D_PRESETS) +def test_two_d_preset_override_invalid_falls_back_to_dynamic(preset): + """Zero-only override on a 2-D preset must fall back to dynamic + normal-path solvers on both axes; computation still labeled + user_override_geometry (override invocation context preserved) + and raw_zone_layout.{row_source,col_source}=='dynamic_fallback'.""" + zones = _zones_for(preset) + positions = [z["position"] for z in zones] + # Zero-only override — must trigger per-axis fallback to solvers. + override = {pos: {"x": 0, "y": 0, "w": 0.0, "h": 0.0} for pos in positions} + result = build_layout_css(preset, zones, override_zone_geometries=override) + assert result["computation"] == "user_override_geometry" + rzl = result["raw_zone_layout"] + assert rzl["row_source"] == "dynamic_fallback" + assert rzl["col_source"] == "dynamic_fallback" + # Result must match what dynamic normal path would produce. + normal = build_layout_css(preset, zones) + assert result["heights_px"] == normal["heights_px"] + assert result["widths_px"] == normal["widths_px"] + + +# ────────────────────── fr_default sink (PR 2 — single only) ────────────────────── + + +def test_single_remains_fr_default_sink(): + """After PR 2 the only preset that still falls through to + _build_fr_default is `single` (PR 3 will promote it). Length + contract (R=1, C=1) must still hold.""" + zones = [_zone("primary", 1.0)] + result = build_layout_css("single", zones) assert result["computation"] == "fr_default_from_preset" assert result["dynamic_rows"] is False assert result["dynamic_cols"] is False - assert len(result["heights_px"]) == 2 # R rows - assert len(result["widths_px"]) == 2 # C cols + assert len(result["heights_px"]) == 1 + assert len(result["widths_px"]) == 1