feat(IMP-09): PR 1 — col-axis solver + per-zone geometry mapper + retry gate

Stage 3 round 4 lock implementation: extend build_layout_css beyond
the horizontal-2-only dynamic path. Every layout_css return now
carries length-locked col-axis keys (widths_px, width_ratios,
dynamic_cols) matching the parsed css_areas grid (R rows, C cols),
so 2-D layouts (T / 2x2 in PR 2) and the unified
_compute_per_zone_geometry mapper can plug in without further
contract churn.

PR 1 scope:
  - _parse_css_areas + _parse_fr_string + _compute_per_zone_geometry
    (unified — 1-D and 2-D from the same code path)
  - compute_zone_layout_cols (vertical-2 weight-only solver)
  - _build_fr_default / _build_rows_dynamic / _build_cols_dynamic
    (populate widths_px/heights_px on every return path)
  - build_layout_css override branch keeps the warn-and-fallthrough
    legacy for unsupported presets (PR 2 promotes to strict raise)
  - retry gate in _attempt_zone_ratio_retry skips when dynamic_cols=True
    or dynamic_rows=False, with explicit retry_skipped_reason
  - Step 8 artifact gains zone_widths_px_planned /
    zone_col_ratios_planned (top-level) + zone_width_px_planned /
    zone_col_ratio_planned (per-zone)
  - debug_zones width injection via _compute_per_zone_geometry
    (replaces the legacy row-only zip)

Tests: tests/phase_z2/ — 47 new cases (parse / fr-string / cols solver /
per-zone geometry / build_layout_css contract / retry gate +
6 build_layout_css YAML fixtures + 3 retry_gate fixtures).

Verification: python -m pytest -q tests = 89 passed (was 42).
horizontal-2 grid CSS strings (areas/cols/rows) byte-identical to
legacy; only additive col-axis keys are introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 12:03:23 +09:00
parent 8f6cffc2a7
commit 201099e53b
18 changed files with 1318 additions and 35 deletions

View File

@@ -834,6 +834,285 @@ def compute_zone_layout(zones_data: list[dict],
}
# ─── IMP-09 PR 1 helpers (8-preset layout vocabulary) ────────────────
# Catalog css_areas / css_cols / css_rows parsing + per-zone aggregation
# + col-axis solver. Symmetric counterparts to compute_zone_layout (row-axis).
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.
Input : '"top top" "bottom-left bottom-right"'
Output : (
[["top", "top"], ["bottom-left", "bottom-right"]],
["top", "bottom-left", "bottom-right"],
)
Raises ValueError on empty input, missing quotes, empty row, or
non-rectangular layout (rows with mismatched column counts).
"""
rows: list[list[str]] = []
seen: list[str] = []
quoted = re.findall(r'"([^"]+)"', css_areas)
if not quoted:
raise ValueError(
f"_parse_css_areas: no quoted row strings found in {css_areas!r}"
)
for row_str in quoted:
tokens = row_str.split()
if not tokens:
raise ValueError(
f"_parse_css_areas: empty row in {css_areas!r}"
)
rows.append(tokens)
for token in tokens:
if token not in seen:
seen.append(token)
col_counts = {len(r) for r in rows}
if len(col_counts) > 1:
raise ValueError(
f"_parse_css_areas: non-rectangular grid, row column counts = "
f"{col_counts} in {css_areas!r}"
)
return rows, seen
def _parse_fr_string(spec: str, total: int) -> list[int]:
"""Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' into integer px lengths.
Catalog presets (templates/phase_z2/layouts/layouts.yaml) only use
1fr-only specs; mixed px/fr is out of scope. Raises ValueError on
non-fr tokens or zero total.
"""
fractions: list[float] = []
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)))
if not fractions:
raise ValueError(f"_parse_fr_string: empty spec {spec!r}")
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
def compute_zone_layout_cols(zones_data: list[dict],
total_width: int = SLIDE_BODY_WIDTH,
gap: int = GRID_GAP) -> dict:
"""Per-zone column width allocation — weight-only distribution.
Symmetric counterpart of compute_zone_layout for the column axis.
No min_width_px contract exists in frame_contracts.yaml (verified
empty as of IMP-09), so column allocation is purely content_weight
score based.
"""
n = len(zones_data)
if n == 0:
return {"widths_px": [], "width_ratios": [], "zones": []}
available = total_width - gap * (n - 1)
weights = [z["content_weight"]["score"] for z in zones_data]
total_w = sum(weights)
if total_w <= 0:
# Zero-weight guard (override-empty zone where score=0).
widths_px = [available // n] * n
widths_px[-1] += available - sum(widths_px)
weight_shares = [round(1.0 / n, 3)] * n
else:
widths_px = [
int(round(available * (w / total_w))) for w in weights
]
diff = available - sum(widths_px)
if diff != 0:
widths_px[-1] += diff
weight_shares = [round(w / total_w, 3) for w in weights]
width_ratios = [round(w / total_width, 3) for w in widths_px]
return {
"computation": "content_weight_distribution_cols",
"slide_body_width": total_width,
"gap": gap,
"available_after_gap": available,
"content_weights": [
{"position": z["position"],
"template_id": z["template_id"],
"score": w}
for z, w in zip(zones_data, weights)
],
"weight_shares": weight_shares,
"widths_px": widths_px,
"width_ratios": width_ratios,
}
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.
Parses layout_css["areas"] (catalog css_areas) into an R x C cell
grid, then for each zone in debug_zones sums the heights_px of its
occupied rows and widths_px of its occupied columns, including the
inter-track gap absorbed by a spanning zone.
Length contract: layout_css["heights_px"] MUST have length R, and
layout_css["widths_px"] MUST have length C. Mismatch raises
ValueError because that indicates a broken build path, not a
runtime input issue.
"""
rows_grid, _ = _parse_css_areas(layout_css["areas"])
R = len(rows_grid)
C = 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 "
f"{len(heights_px)} != grid rows R={R} "
f"(css_areas={layout_css.get('areas')!r})"
)
if len(widths_px) != C:
raise ValueError(
f"_compute_per_zone_geometry: widths_px length "
f"{len(widths_px)} != grid cols C={C} "
f"(css_areas={layout_css.get('areas')!r})"
)
per_zone: list[dict] = []
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"_compute_per_zone_geometry: zone position {pos!r} "
f"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
def _build_fr_default(preset: dict) -> dict:
"""fr-default sink — populate widths_px / heights_px from catalog fr ratios.
Replaces the legacy empty-array sink so that downstream consumers
(Step 7/8 trace, _compute_per_zone_geometry) always receive
length-locked arrays matching the catalog grid dimensions.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = 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(preset["css_rows"], avail_h)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": preset["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,
}
def _build_rows_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""horizontal-2 path — dynamic row heights, static fr column widths.
Preserves the legacy compute_zone_layout output (heights_px / ratios /
computation / raw_zone_layout) byte-for-byte; only adds the new
col-axis keys (widths_px from css_cols fr, width_ratios, dynamic_cols=False).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
C = len(rows_grid[0])
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
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: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""vertical-2 path — dynamic column widths, static fr row heights.
Mirror of _build_rows_dynamic. Returns a pixel grid-template-columns
string. PR 2 promotes vertical-2 override to dynamic_rows=True; in
PR 1 dynamic_rows stays False (legacy).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
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,
}
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
@@ -843,15 +1122,28 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
"""Composition v0 layout preset → CSS grid 문자열.
horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지
(MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default.
IMP-09 PR 1 contract — every layout_css return path carries
matching-length heights_px (= grid rows R) and widths_px (= grid cols C),
plus ratios / width_ratios / dynamic_rows / dynamic_cols. The
horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical
to the legacy path.
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id → {x,y,w,h}
slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. horizontal-2 / vertical-2
만 처리. 다른 preset 은 일단 무시 + warning. 비율 합 != 1 이면 normalize.
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.
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).
"""
preset = LAYOUT_PRESETS[layout_preset]
positions = preset["positions"]
topology = preset.get("topology")
# ── Step D-ext : user override 처리 ──
if override_zone_geometries:
@@ -870,13 +1162,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
"cols": preset["css_cols"],
"rows": rows,
"heights_px": heights_px,
"widths_px": [SLIDE_BODY_WIDTH],
"ratios": [round(r / total, 3) for r in ratios],
"width_ratios": [1.0],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
elif layout_preset == "vertical-2":
# cols override — zone 의 w 비율로 fr 분배.
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
# populated in pixels for _compute_per_zone_geometry length contract.
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
@@ -884,47 +1181,44 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
total = sum(ratios)
if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
normalized = [r / total for r in ratios]
widths_px = [
int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1))))
for rr in normalized
]
diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px)
if diff != 0 and widths_px:
widths_px[-1] += diff
return {
"areas": preset["css_areas"],
"cols": cols,
"rows": preset["css_rows"],
"heights_px": [],
"ratios": [round(r / total, 3) for r in ratios],
"heights_px": [SLIDE_BODY_HEIGHT],
"widths_px": widths_px,
"ratios": [1.0],
"width_ratios": [round(rr, 3) for rr in normalized],
"computation": "user_override_geometry",
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
else:
# PR 1 lock — warn-and-fallthrough preserved.
# PR 2 promotes this to strict ValueError via _override_to_grid_tracks.
print(
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.",
file=sys.stderr,
)
if layout_preset == "horizontal-2":
zl = compute_zone_layout(zones_data, gap=gap)
rows = " ".join(f"{h}px" for h in zl["heights_px"])
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows,
"heights_px": zl["heights_px"],
"ratios": zl["ratios"],
"computation": zl["computation"],
"dynamic_rows": True,
"raw_zone_layout": zl,
}
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": preset["css_rows"],
"heights_px": [],
"ratios": [],
"computation": "fr_default_from_preset",
"dynamic_rows": False,
"raw_zone_layout": None,
}
# ── Dynamic branch — topology dispatch (PR 1: rows / cols only) ──
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.
return _build_fr_default(preset)
# ─── Abort ──────────────────────────────────────────────────────
@@ -1321,6 +1615,21 @@ def _attempt_zone_ratio_retry(
)
return base_trace
# IMP-09 PR 1 retry gate — row-axis retry is only valid for layouts whose
# row geometry is dynamic. 2-D / dynamic_cols layouts and fr_default sinks
# would either misapply row-only redistribution or produce a no-op trace.
if layout_css.get("dynamic_cols", False):
base_trace["retry_skipped_reason"] = (
"layout has dynamic_cols (2-D topology) — "
"row-axis retry not applicable to 2-D layouts (IMP-09 lock)"
)
return base_trace
if not layout_css.get("dynamic_rows", False):
base_trace["retry_skipped_reason"] = (
"layout is fr_default_from_preset (no dynamic geometry) — retry no-op"
)
return base_trace
# 2. plan
base_trace["retry_attempted"] = True
base_trace["retry_action"] = "zone_ratio_retry"
@@ -3064,11 +3373,20 @@ def run_phase_z2_mvp1(
layout_css = build_layout_css(
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
)
# IMP-09 PR 1 — unified per-zone geometry aggregation across all
# layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward)
# are handled by _compute_per_zone_geometry; in PR 1 the helper
# operates on row/col-static or row-dynamic / col-dynamic outputs.
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"]
if layout_css["dynamic_rows"]:
for dz, h, r in zip(debug_zones, layout_css["heights_px"], layout_css["ratios"]):
dz["height_px"] = h
dz["ratio"] = r
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
elif layout_css.get("dynamic_cols"):
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
else:
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
@@ -3229,6 +3547,8 @@ def run_phase_z2_mvp1(
"position": dz["position"],
"zone_height_px_planned": dz.get("height_px"),
"zone_ratio_planned": dz.get("ratio"),
"zone_width_px_planned": dz.get("width_px"),
"zone_col_ratio_planned": dz.get("width_ratio"),
"min_height_px": visual_hints.get("min_height_px"),
"frame_cardinality_strict": cardinality.get("strict"),
"sub_zones_planned": [
@@ -3253,7 +3573,9 @@ def run_phase_z2_mvp1(
run_dir, 8, "zone_region_ratios",
data={
"zone_heights_px_planned": layout_css.get("heights_px"),
"zone_widths_px_planned": layout_css.get("widths_px"),
"zone_ratios_planned": layout_css.get("ratios"),
"zone_col_ratios_planned": layout_css.get("width_ratios"),
"per_zone_plan": zone_region_plans,
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
"step8_conn_placeholder_signals": _step8_placeholder_signals,