On main: IMP-09 PR2 sketch (Stage 2 design reference; re-derive per unit)
This commit is contained in:
+261
-11
@@ -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_<axis_label> 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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user