Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0b0b282f | ||
|
|
1264e92e75 |
@@ -701,6 +701,12 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
|
|||||||
selected.append(c)
|
selected.append(c)
|
||||||
covered.update(c.source_section_ids)
|
covered.update(c.source_section_ids)
|
||||||
|
|
||||||
|
# 2026-05-14 — MDX 자연 순서 (section_id 오름차순) 로 재정렬.
|
||||||
|
# 사용자 룰 (CLAUDE.md "정보 계층: 위 → 아래") + 04-1/04-2 score 차이로 인한
|
||||||
|
# zone 거꾸로 배치 catch. score 는 viable selection 에만, position 은 MDX 순서.
|
||||||
|
# source_section_ids 첫 element 기준 lexicographic sort — "04-1" < "04-2" < "04-2.1" < "04-2.2".
|
||||||
|
selected.sort(key=lambda c: c.source_section_ids[0] if c.source_section_ids else "")
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+101
-616
@@ -86,6 +86,19 @@ V4_LABEL_TO_PHASE_Z_STATUS = {
|
|||||||
}
|
}
|
||||||
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
|
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
|
||||||
|
|
||||||
|
# Env toggle PHASE_Z_ALLOW_RESTRUCTURE (default OFF) — when "1/true/yes" 도 restructure
|
||||||
|
# (= extract_matched_zone) 통과시킴. AI fallback 대행 (사용자가 콘텐츠를 frame 구조에 맞게
|
||||||
|
# 재정리한 mdx 를 제공) 시나리오용. MVP1 정책 자체는 무변 — env 켜진 세션만 영향.
|
||||||
|
if os.environ.get("PHASE_Z_ALLOW_RESTRUCTURE", "").strip().lower() in {"1", "true", "yes"}:
|
||||||
|
MVP1_ALLOWED_STATUSES = MVP1_ALLOWED_STATUSES | {"extract_matched_zone"}
|
||||||
|
|
||||||
|
# Env toggle PHASE_Z_ALLOW_REJECT (default OFF) — when "1/true/yes" 도 reject
|
||||||
|
# (= fallback_candidate) 통과시킴. 사용자 룰 : "매칭점수 가장 높은 frame 의 구조,
|
||||||
|
# 요소, 색상 활용" — V4 가 의미적으로 reject 해도 structure 매칭 충분하면 강제 사용.
|
||||||
|
# 04-1 같은 all-reject section + 신규 등록 frame 강제 매핑 시나리오용.
|
||||||
|
if os.environ.get("PHASE_Z_ALLOW_REJECT", "").strip().lower() in {"1", "true", "yes"}:
|
||||||
|
MVP1_ALLOWED_STATUSES = MVP1_ALLOWED_STATUSES | {"fallback_candidate"}
|
||||||
|
|
||||||
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
|
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
|
||||||
# tuple = (application_mode, auto_applicable, delegated_to).
|
# tuple = (application_mode, auto_applicable, delegated_to).
|
||||||
# status.md §2 Q3 / Q7 lock 따라.
|
# status.md §2 Q3 / Q7 lock 따라.
|
||||||
@@ -185,7 +198,15 @@ def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]:
|
|||||||
if footer_match:
|
if footer_match:
|
||||||
body = footer_match.group(1)
|
body = footer_match.group(1)
|
||||||
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
|
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
|
||||||
footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip())
|
if bullet_match:
|
||||||
|
footer_text = bullet_match.group(1).strip()
|
||||||
|
else:
|
||||||
|
# 2026-05-14 Q5 — markdown bullet marker `*` 시작 시 제거 (사용자 lock).
|
||||||
|
# `* 검증 없는 정책...` → `검증 없는 정책...`
|
||||||
|
plain = body.strip()
|
||||||
|
if plain.startswith("*"):
|
||||||
|
plain = plain[1:].lstrip()
|
||||||
|
footer_text = plain
|
||||||
text = text[:footer_match.start()] + text[footer_match.end():]
|
text = text[:footer_match.start()] + text[footer_match.end():]
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
@@ -564,7 +585,7 @@ def lookup_v4_match_with_fallback(
|
|||||||
section_id: str,
|
section_id: str,
|
||||||
*,
|
*,
|
||||||
raw_content: Optional[str] = None,
|
raw_content: Optional[str] = None,
|
||||||
max_rank: int = 3,
|
max_rank: Optional[int] = None,
|
||||||
alias_keys: Optional[list] = None,
|
alias_keys: Optional[list] = None,
|
||||||
) -> tuple[Optional[V4Match], dict]:
|
) -> tuple[Optional[V4Match], dict]:
|
||||||
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
|
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
|
||||||
@@ -572,6 +593,13 @@ def lookup_v4_match_with_fallback(
|
|||||||
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
|
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
|
||||||
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
|
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
|
||||||
"""
|
"""
|
||||||
|
# 2026-05-14 — max_rank env toggle PHASE_Z_MAX_RANK (default 3).
|
||||||
|
# 보고용 : 등록 frame rank 가 4+ 인 경우 (예: mdx05-2 의 rank 10) 도 통과시킴.
|
||||||
|
if max_rank is None:
|
||||||
|
try:
|
||||||
|
max_rank = int(os.environ.get("PHASE_Z_MAX_RANK", "3"))
|
||||||
|
except ValueError:
|
||||||
|
max_rank = 3
|
||||||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||||
trace = {
|
trace = {
|
||||||
@@ -590,7 +618,20 @@ def lookup_v4_match_with_fallback(
|
|||||||
trace["fallback_reason"] = "no_v4_section"
|
trace["fallback_reason"] = "no_v4_section"
|
||||||
return None, trace
|
return None, trace
|
||||||
|
|
||||||
judgments = (sec.get("judgments_full32") or [])[:max_rank]
|
# 2026-05-14 — fallback chain sort = label priority + confidence (frontend 와 동일).
|
||||||
|
# 사용자 룰 : "reject 외 다른 label 있으면 reject 는 ranking 상단 X".
|
||||||
|
# judgments_full32 는 confidence desc only. 그대로 iterate 시 reject (conf 높은) 가
|
||||||
|
# light_edit (conf 낮은) 보다 먼저 선택될 수 있음. label priority 우선 정렬.
|
||||||
|
_LABEL_PRIORITY = {"use_as_is": 0, "light_edit": 1, "restructure": 2, "reject": 3}
|
||||||
|
all_judgments = sec.get("judgments_full32") or []
|
||||||
|
judgments_sorted = sorted(
|
||||||
|
all_judgments,
|
||||||
|
key=lambda j: (
|
||||||
|
_LABEL_PRIORITY.get(j.get("label"), 99),
|
||||||
|
-float(j.get("confidence") or 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
judgments = judgments_sorted[:max_rank]
|
||||||
if not judgments:
|
if not judgments:
|
||||||
trace["fallback_reason"] = "empty_v4_judgments"
|
trace["fallback_reason"] = "empty_v4_judgments"
|
||||||
return None, trace
|
return None, trace
|
||||||
@@ -834,503 +875,6 @@ 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── 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"] 직접 사용.
|
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
|
||||||
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
|
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
|
||||||
|
|
||||||
@@ -1340,62 +884,45 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
|
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
|
||||||
"""Composition v0 layout preset → CSS grid 문자열.
|
"""Composition v0 layout preset → CSS grid 문자열.
|
||||||
|
|
||||||
IMP-09 PR 1 contract — every layout_css return path carries
|
horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지
|
||||||
matching-length heights_px (= grid rows R) and widths_px (= grid cols C),
|
(MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default.
|
||||||
plus ratios / width_ratios / dynamic_rows / dynamic_cols. The
|
|
||||||
horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical
|
|
||||||
to the legacy path.
|
|
||||||
|
|
||||||
Dynamic dispatch:
|
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id → {x,y,w,h}
|
||||||
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
|
slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. horizontal-2 / vertical-2
|
||||||
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
|
만 처리. 다른 preset 은 일단 무시 + warning. 비율 합 != 1 이면 normalize.
|
||||||
- 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).
|
|
||||||
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]
|
preset = LAYOUT_PRESETS[layout_preset]
|
||||||
positions = preset["positions"]
|
positions = preset["positions"]
|
||||||
topology = preset.get("topology")
|
|
||||||
|
|
||||||
# ── Step D-ext : user override 처리 ──
|
# ── Step D-ext : user override 처리 ──
|
||||||
if override_zone_geometries:
|
if override_zone_geometries:
|
||||||
if layout_preset == "horizontal-2":
|
if layout_preset == "horizontal-2":
|
||||||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
# heights_px override — zone 의 h 비율로 (SLIDE_BODY_HEIGHT - gap) 분배.
|
||||||
|
# 2026-05-14 BUGFIX (Axis A) — 원래 SLIDE_BODY_HEIGHT 만 사용해서 zone
|
||||||
|
# heights 합 + gap 이 slide-body 보다 +gap px overflow. gap 빼고
|
||||||
|
# 분배 (compute_zone_layout 의 normal path 와 동일 logic).
|
||||||
ratios = []
|
ratios = []
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
geom = override_zone_geometries.get(pos)
|
geom = override_zone_geometries.get(pos)
|
||||||
ratios.append(float(geom["h"]) if geom else 0.0)
|
ratios.append(float(geom["h"]) if geom else 0.0)
|
||||||
total = sum(ratios)
|
total = sum(ratios)
|
||||||
if total > 0:
|
if total > 0:
|
||||||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
n = len(ratios)
|
||||||
|
available = SLIDE_BODY_HEIGHT - gap * (n - 1)
|
||||||
|
heights_px = [int(round(r / total * available)) for r in ratios]
|
||||||
rows = " ".join(f"{h}px" for h in heights_px)
|
rows = " ".join(f"{h}px" for h in heights_px)
|
||||||
return {
|
return {
|
||||||
"areas": preset["css_areas"],
|
"areas": preset["css_areas"],
|
||||||
"cols": preset["css_cols"],
|
"cols": preset["css_cols"],
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"heights_px": heights_px,
|
"heights_px": heights_px,
|
||||||
"widths_px": [SLIDE_BODY_WIDTH],
|
|
||||||
"ratios": [round(r / total, 3) for r in ratios],
|
"ratios": [round(r / total, 3) for r in ratios],
|
||||||
"width_ratios": [1.0],
|
|
||||||
"computation": "user_override_geometry",
|
"computation": "user_override_geometry",
|
||||||
"dynamic_rows": True,
|
"dynamic_rows": True,
|
||||||
"dynamic_cols": False,
|
|
||||||
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
||||||
}
|
}
|
||||||
elif layout_preset == "vertical-2":
|
elif layout_preset == "vertical-2":
|
||||||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
# cols override — zone 의 w 비율로 fr 분배.
|
||||||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
|
||||||
# populated in pixels for _compute_per_zone_geometry length contract.
|
|
||||||
ratios = []
|
ratios = []
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
geom = override_zone_geometries.get(pos)
|
geom = override_zone_geometries.get(pos)
|
||||||
@@ -1403,72 +930,47 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
total = sum(ratios)
|
total = sum(ratios)
|
||||||
if total > 0:
|
if total > 0:
|
||||||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
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 {
|
return {
|
||||||
"areas": preset["css_areas"],
|
"areas": preset["css_areas"],
|
||||||
"cols": cols,
|
"cols": cols,
|
||||||
"rows": preset["css_rows"],
|
"rows": preset["css_rows"],
|
||||||
"heights_px": [SLIDE_BODY_HEIGHT],
|
"heights_px": [],
|
||||||
"widths_px": widths_px,
|
"ratios": [round(r / total, 3) for r in ratios],
|
||||||
"ratios": [1.0],
|
|
||||||
"width_ratios": [round(rr, 3) for rr in normalized],
|
|
||||||
"computation": "user_override_geometry",
|
"computation": "user_override_geometry",
|
||||||
"dynamic_rows": False,
|
"dynamic_rows": False,
|
||||||
"dynamic_cols": True,
|
|
||||||
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
"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:
|
else:
|
||||||
# PR 2 lock — only `single` remains as warn-and-fallthrough.
|
|
||||||
# PR 3 will promote single via dedicated override handling.
|
|
||||||
print(
|
print(
|
||||||
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
|
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
|
||||||
f"(PR 2 후 single 만 fallthrough). default layout_css 사용.",
|
f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Dynamic branch — topology dispatch ──
|
if layout_preset == "horizontal-2":
|
||||||
if topology == "rows":
|
zl = compute_zone_layout(zones_data, gap=gap)
|
||||||
return _build_rows_dynamic(preset, zones_data, gap)
|
rows = " ".join(f"{h}px" for h in zl["heights_px"])
|
||||||
if topology == "cols":
|
return {
|
||||||
return _build_cols_dynamic(preset, zones_data, gap)
|
"areas": preset["css_areas"],
|
||||||
if topology in _TWO_D_TOPOLOGIES:
|
"cols": preset["css_cols"],
|
||||||
return _build_2d_dynamic(preset, zones_data, gap)
|
"rows": rows,
|
||||||
# PR 3 will dispatch `single` here; until then it falls through to
|
"heights_px": zl["heights_px"],
|
||||||
# fr_default_from_preset (length-locked sink).
|
"ratios": zl["ratios"],
|
||||||
return _build_fr_default(preset)
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─── Abort ──────────────────────────────────────────────────────
|
# ─── Abort ──────────────────────────────────────────────────────
|
||||||
@@ -1865,21 +1367,6 @@ def _attempt_zone_ratio_retry(
|
|||||||
)
|
)
|
||||||
return base_trace
|
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
|
# 2. plan
|
||||||
base_trace["retry_attempted"] = True
|
base_trace["retry_attempted"] = True
|
||||||
base_trace["retry_action"] = "zone_ratio_retry"
|
base_trace["retry_action"] = "zone_ratio_retry"
|
||||||
@@ -1983,7 +1470,13 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
|
|||||||
zone["partial_html"] = ""
|
zone["partial_html"] = ""
|
||||||
continue
|
continue
|
||||||
partial = env.get_template(f"families/{zone['template_id']}.html")
|
partial = env.get_template(f"families/{zone['template_id']}.html")
|
||||||
zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"])
|
# 2026-05-14 — partial 에 assets_dir 전달. figma asset PNG/SVG 참조 가능.
|
||||||
|
# assets_dir = "assets/<template_id>" (run-relative). final.html 의 <img src>
|
||||||
|
# 상대 path 와 matching — frontend `/data/runs/.../assets/...` serve.
|
||||||
|
zone["partial_html"] = partial.render(
|
||||||
|
slot_payload=zone["slot_payload"],
|
||||||
|
assets_dir=zone.get("assets_dir") or "",
|
||||||
|
)
|
||||||
|
|
||||||
base = env.get_template("slide_base.html")
|
base = env.get_template("slide_base.html")
|
||||||
return base.render(
|
return base.render(
|
||||||
@@ -2737,11 +2230,12 @@ def run_phase_z2_mvp1(
|
|||||||
v4_fallback_traces: dict[str, dict] = {}
|
v4_fallback_traces: dict[str, dict] = {}
|
||||||
|
|
||||||
def lookup_fn(sid: str) -> Optional[V4Match]:
|
def lookup_fn(sid: str) -> Optional[V4Match]:
|
||||||
|
# max_rank None → lookup_v4_match_with_fallback 가 PHASE_Z_MAX_RANK env (default 3) 사용.
|
||||||
match, trace = lookup_v4_match_with_fallback(
|
match, trace = lookup_v4_match_with_fallback(
|
||||||
v4,
|
v4,
|
||||||
sid,
|
sid,
|
||||||
raw_content=section_content_by_id.get(sid),
|
raw_content=section_content_by_id.get(sid),
|
||||||
max_rank=3,
|
max_rank=None,
|
||||||
alias_keys=section_alias_by_id.get(sid),
|
alias_keys=section_alias_by_id.get(sid),
|
||||||
)
|
)
|
||||||
v4_fallback_traces[sid] = trace
|
v4_fallback_traces[sid] = trace
|
||||||
@@ -3249,6 +2743,10 @@ def run_phase_z2_mvp1(
|
|||||||
"slot_payload": slot_payload,
|
"slot_payload": slot_payload,
|
||||||
"content_weight": content_weight,
|
"content_weight": content_weight,
|
||||||
"min_height_px": min_height_px,
|
"min_height_px": min_height_px,
|
||||||
|
# 2026-05-14 — partial.render() 가 assets_dir 을 zone.get() 으로 읽으므로
|
||||||
|
# zones_data 에도 포함해야 figma asset PNG 경로가 final.html 에 박힘.
|
||||||
|
# as_posix() — Windows 에서 str(Path) 는 backslash 라 url() 에서 404.
|
||||||
|
"assets_dir": assets_dir.relative_to(run_dir).as_posix() if assets_dir else None,
|
||||||
"assignment_source": plan_assignment_source,
|
"assignment_source": plan_assignment_source,
|
||||||
"section_assignment_override": plan_section_override,
|
"section_assignment_override": plan_section_override,
|
||||||
})
|
})
|
||||||
@@ -3277,7 +2775,7 @@ def run_phase_z2_mvp1(
|
|||||||
"min_height_px": min_height_px,
|
"min_height_px": min_height_px,
|
||||||
"slot_payload_keys": sorted(slot_payload.keys()),
|
"slot_payload_keys": sorted(slot_payload.keys()),
|
||||||
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
|
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
|
||||||
"assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None,
|
"assets_dir": assets_dir.relative_to(run_dir).as_posix() if assets_dir else None,
|
||||||
"content_weight": content_weight,
|
"content_weight": content_weight,
|
||||||
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
|
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
|
||||||
"placement_trace": placement_trace,
|
"placement_trace": placement_trace,
|
||||||
@@ -3623,20 +3121,11 @@ def run_phase_z2_mvp1(
|
|||||||
layout_css = build_layout_css(
|
layout_css = build_layout_css(
|
||||||
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
|
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"]:
|
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']}")
|
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:
|
else:
|
||||||
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
|
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
|
||||||
|
|
||||||
@@ -3797,8 +3286,6 @@ def run_phase_z2_mvp1(
|
|||||||
"position": dz["position"],
|
"position": dz["position"],
|
||||||
"zone_height_px_planned": dz.get("height_px"),
|
"zone_height_px_planned": dz.get("height_px"),
|
||||||
"zone_ratio_planned": dz.get("ratio"),
|
"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"),
|
"min_height_px": visual_hints.get("min_height_px"),
|
||||||
"frame_cardinality_strict": cardinality.get("strict"),
|
"frame_cardinality_strict": cardinality.get("strict"),
|
||||||
"sub_zones_planned": [
|
"sub_zones_planned": [
|
||||||
@@ -3823,9 +3310,7 @@ def run_phase_z2_mvp1(
|
|||||||
run_dir, 8, "zone_region_ratios",
|
run_dir, 8, "zone_region_ratios",
|
||||||
data={
|
data={
|
||||||
"zone_heights_px_planned": layout_css.get("heights_px"),
|
"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_ratios_planned": layout_css.get("ratios"),
|
||||||
"zone_col_ratios_planned": layout_css.get("width_ratios"),
|
|
||||||
"per_zone_plan": zone_region_plans,
|
"per_zone_plan": zone_region_plans,
|
||||||
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
|
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
|
||||||
"step8_conn_placeholder_signals": _step8_placeholder_signals,
|
"step8_conn_placeholder_signals": _step8_placeholder_signals,
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
input:
|
|
||||||
layout_preset: horizontal-2
|
|
||||||
zones_data:
|
|
||||||
- position: top
|
|
||||||
template_id: MOCK_top
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
- position: bottom
|
|
||||||
template_id: MOCK_bottom
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
override_zone_geometries: null
|
|
||||||
expected_layout_css:
|
|
||||||
areas: '"top" "bottom"'
|
|
||||||
cols: 1fr
|
|
||||||
rows: 286px 285px
|
|
||||||
heights_px:
|
|
||||||
- 286
|
|
||||||
- 285
|
|
||||||
widths_px:
|
|
||||||
- 1180
|
|
||||||
ratios:
|
|
||||||
- 0.489
|
|
||||||
- 0.487
|
|
||||||
width_ratios:
|
|
||||||
- 1.0
|
|
||||||
computation: min_height_first + content_weight_distribution
|
|
||||||
dynamic_rows: true
|
|
||||||
dynamic_cols: false
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
input:
|
|
||||||
layout_preset: horizontal-2
|
|
||||||
zones_data:
|
|
||||||
- position: top
|
|
||||||
template_id: MOCK_top
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
- position: bottom
|
|
||||||
template_id: MOCK_bottom
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
override_zone_geometries:
|
|
||||||
top:
|
|
||||||
x: 0
|
|
||||||
y: 0
|
|
||||||
w: 1.0
|
|
||||||
h: 0.3
|
|
||||||
bottom:
|
|
||||||
x: 0
|
|
||||||
y: 0.3
|
|
||||||
w: 1.0
|
|
||||||
h: 0.7
|
|
||||||
expected_layout_css:
|
|
||||||
areas: '"top" "bottom"'
|
|
||||||
cols: 1fr
|
|
||||||
rows: 176px 410px
|
|
||||||
heights_px:
|
|
||||||
- 176
|
|
||||||
- 410
|
|
||||||
widths_px:
|
|
||||||
- 1180
|
|
||||||
ratios:
|
|
||||||
- 0.3
|
|
||||||
- 0.7
|
|
||||||
width_ratios:
|
|
||||||
- 1.0
|
|
||||||
computation: user_override_geometry
|
|
||||||
dynamic_rows: true
|
|
||||||
dynamic_cols: false
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
input:
|
|
||||||
layout_preset: horizontal-2
|
|
||||||
zones_data:
|
|
||||||
- position: top
|
|
||||||
template_id: MOCK_top
|
|
||||||
content_weight:
|
|
||||||
score: 0.8
|
|
||||||
min_height_px: 200
|
|
||||||
- position: bottom
|
|
||||||
template_id: MOCK_bottom
|
|
||||||
content_weight:
|
|
||||||
score: 0.2
|
|
||||||
min_height_px: 150
|
|
||||||
override_zone_geometries: null
|
|
||||||
expected_layout_css:
|
|
||||||
areas: '"top" "bottom"'
|
|
||||||
cols: 1fr
|
|
||||||
rows: 377px 194px
|
|
||||||
heights_px:
|
|
||||||
- 377
|
|
||||||
- 194
|
|
||||||
widths_px:
|
|
||||||
- 1180
|
|
||||||
ratios:
|
|
||||||
- 0.644
|
|
||||||
- 0.332
|
|
||||||
width_ratios:
|
|
||||||
- 1.0
|
|
||||||
computation: min_height_first + content_weight_distribution
|
|
||||||
dynamic_rows: true
|
|
||||||
dynamic_cols: false
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
input:
|
|
||||||
layout_preset: vertical-2
|
|
||||||
zones_data:
|
|
||||||
- position: left
|
|
||||||
template_id: MOCK_left
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
- position: right
|
|
||||||
template_id: MOCK_right
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
override_zone_geometries: null
|
|
||||||
expected_layout_css:
|
|
||||||
areas: '"left right"'
|
|
||||||
cols: 583px 583px
|
|
||||||
rows: 1fr
|
|
||||||
heights_px:
|
|
||||||
- 585
|
|
||||||
widths_px:
|
|
||||||
- 583
|
|
||||||
- 583
|
|
||||||
ratios:
|
|
||||||
- 1.0
|
|
||||||
width_ratios:
|
|
||||||
- 0.494
|
|
||||||
- 0.494
|
|
||||||
computation: content_weight_distribution_cols
|
|
||||||
dynamic_rows: false
|
|
||||||
dynamic_cols: true
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
input:
|
|
||||||
layout_preset: vertical-2
|
|
||||||
zones_data:
|
|
||||||
- position: left
|
|
||||||
template_id: MOCK_left
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
- position: right
|
|
||||||
template_id: MOCK_right
|
|
||||||
content_weight:
|
|
||||||
score: 0.5
|
|
||||||
min_height_px: 200
|
|
||||||
override_zone_geometries:
|
|
||||||
left:
|
|
||||||
x: 0
|
|
||||||
y: 0
|
|
||||||
w: 0.4
|
|
||||||
h: 1.0
|
|
||||||
right:
|
|
||||||
x: 0.4
|
|
||||||
y: 0
|
|
||||||
w: 0.6
|
|
||||||
h: 1.0
|
|
||||||
expected_layout_css:
|
|
||||||
areas: '"left right"'
|
|
||||||
cols: 40.0fr 60.0fr
|
|
||||||
rows: 1fr
|
|
||||||
heights_px:
|
|
||||||
- 585
|
|
||||||
widths_px:
|
|
||||||
- 466
|
|
||||||
- 700
|
|
||||||
ratios:
|
|
||||||
- 1.0
|
|
||||||
width_ratios:
|
|
||||||
- 0.4
|
|
||||||
- 0.6
|
|
||||||
computation: user_override_geometry
|
|
||||||
dynamic_rows: false
|
|
||||||
dynamic_cols: true
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
input:
|
|
||||||
layout_preset: vertical-2
|
|
||||||
zones_data:
|
|
||||||
- position: left
|
|
||||||
template_id: MOCK_left
|
|
||||||
content_weight:
|
|
||||||
score: 0.7
|
|
||||||
min_height_px: 200
|
|
||||||
- position: right
|
|
||||||
template_id: MOCK_right
|
|
||||||
content_weight:
|
|
||||||
score: 0.3
|
|
||||||
min_height_px: 200
|
|
||||||
override_zone_geometries: null
|
|
||||||
expected_layout_css:
|
|
||||||
areas: '"left right"'
|
|
||||||
cols: 816px 350px
|
|
||||||
rows: 1fr
|
|
||||||
heights_px:
|
|
||||||
- 585
|
|
||||||
widths_px:
|
|
||||||
- 816
|
|
||||||
- 350
|
|
||||||
ratios:
|
|
||||||
- 1.0
|
|
||||||
width_ratios:
|
|
||||||
- 0.692
|
|
||||||
- 0.297
|
|
||||||
computation: content_weight_distribution_cols
|
|
||||||
dynamic_rows: false
|
|
||||||
dynamic_cols: true
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
case_id: horizontal2_dynamic_rows
|
|
||||||
description: |
|
|
||||||
horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry
|
|
||||||
gate. The base trace should record retry_attempted=True (legacy
|
|
||||||
plan/rerender path continues). retry_skipped_reason MUST NOT contain
|
|
||||||
either of the IMP-09 gate skip strings.
|
|
||||||
input_layout_css:
|
|
||||||
areas: '"top" "bottom"'
|
|
||||||
cols: 1fr
|
|
||||||
rows: 333px 238px
|
|
||||||
heights_px: [333, 238]
|
|
||||||
widths_px: [1180]
|
|
||||||
ratios: [0.569, 0.407]
|
|
||||||
width_ratios: [1.0]
|
|
||||||
dynamic_rows: true
|
|
||||||
dynamic_cols: false
|
|
||||||
router_decision:
|
|
||||||
router_active: true
|
|
||||||
proposed_actions_summary: [zone_ratio_retry]
|
|
||||||
expected_gate:
|
|
||||||
retry_attempted: true
|
|
||||||
retry_skipped_reason_excludes:
|
|
||||||
- "dynamic_cols"
|
|
||||||
- "fr_default_from_preset"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
case_id: single_fr_default
|
|
||||||
description: |
|
|
||||||
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
|
|
||||||
rows: 1fr 1fr
|
|
||||||
heights_px: [285, 286]
|
|
||||||
widths_px: [583, 583]
|
|
||||||
ratios: [0.487, 0.489]
|
|
||||||
width_ratios: [0.494, 0.494]
|
|
||||||
dynamic_rows: false
|
|
||||||
dynamic_cols: false
|
|
||||||
router_decision:
|
|
||||||
router_active: true
|
|
||||||
proposed_actions_summary: [zone_ratio_retry]
|
|
||||||
expected_gate:
|
|
||||||
retry_attempted: false
|
|
||||||
retry_skipped_reason_contains:
|
|
||||||
- "fr_default_from_preset"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
case_id: vertical2_dynamic_cols
|
|
||||||
description: |
|
|
||||||
vertical-2 layout with dynamic_cols=True must be skipped by the
|
|
||||||
IMP-09 retry gate before plan/rerender, because the existing
|
|
||||||
apply_retry_to_layout_css mutates only row-axis fields and would
|
|
||||||
produce a misleading trace if it ran on a column-dynamic layout.
|
|
||||||
input_layout_css:
|
|
||||||
areas: '"left right"'
|
|
||||||
cols: 583px 583px
|
|
||||||
rows: 1fr
|
|
||||||
heights_px: [585]
|
|
||||||
widths_px: [583, 583]
|
|
||||||
ratios: [1.0]
|
|
||||||
width_ratios: [0.494, 0.494]
|
|
||||||
dynamic_rows: false
|
|
||||||
dynamic_cols: true
|
|
||||||
router_decision:
|
|
||||||
router_active: true
|
|
||||||
proposed_actions_summary: [zone_ratio_retry]
|
|
||||||
expected_gate:
|
|
||||||
retry_attempted: false
|
|
||||||
retry_skipped_reason_contains:
|
|
||||||
- "dynamic_cols"
|
|
||||||
- "IMP-09"
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — build_layout_css contract tests.
|
|
||||||
|
|
||||||
Verifies horizontal-2 byte-identity for the legacy grid strings
|
|
||||||
(areas / cols / rows) and that every return path now carries the new
|
|
||||||
length-locked col-axis keys (widths_px / width_ratios / dynamic_cols).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import (
|
|
||||||
GRID_GAP,
|
|
||||||
SLIDE_BODY_HEIGHT,
|
|
||||||
SLIDE_BODY_WIDTH,
|
|
||||||
build_layout_css,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _zone(position: str, score: float, min_h: int = 100) -> dict:
|
|
||||||
return {
|
|
||||||
"position": position,
|
|
||||||
"template_id": f"MOCK_{position}",
|
|
||||||
"content_weight": {"score": score},
|
|
||||||
"min_height_px": min_h,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── new-key contract ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_presets_carry_new_col_axis_keys():
|
|
||||||
"""Every PR 1 return path must include widths_px / width_ratios /
|
|
||||||
dynamic_cols, and heights_px / widths_px must be length-locked to
|
|
||||||
the catalog grid (R rows, C cols)."""
|
|
||||||
cases = [
|
|
||||||
("single", [_zone("primary", 1.0)]),
|
|
||||||
("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]),
|
|
||||||
("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]),
|
|
||||||
("top-1-bottom-2", [
|
|
||||||
_zone("top", 0.5),
|
|
||||||
_zone("bottom-left", 0.25),
|
|
||||||
_zone("bottom-right", 0.25),
|
|
||||||
]),
|
|
||||||
("grid-2x2", [
|
|
||||||
_zone("top-left", 0.25),
|
|
||||||
_zone("top-right", 0.25),
|
|
||||||
_zone("bottom-left", 0.25),
|
|
||||||
_zone("bottom-right", 0.25),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
for preset, zones in cases:
|
|
||||||
result = build_layout_css(preset, zones)
|
|
||||||
missing = NEW_KEYS - set(result)
|
|
||||||
assert not missing, f"{preset} missing new keys: {missing}"
|
|
||||||
# heights_px / widths_px never empty in PR 1 (length-locked).
|
|
||||||
assert len(result["heights_px"]) > 0, f"{preset} empty heights_px"
|
|
||||||
assert len(result["widths_px"]) > 0, f"{preset} empty widths_px"
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── horizontal-2 byte-identity ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_horizontal_2_grid_strings_match_legacy():
|
|
||||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
|
||||||
result = build_layout_css("horizontal-2", zones)
|
|
||||||
|
|
||||||
# Legacy contract: areas / cols / rows strings preserved.
|
|
||||||
assert result["areas"] == '"top" "bottom"'
|
|
||||||
assert result["cols"] == "1fr"
|
|
||||||
assert result["rows"].count("px") == 2
|
|
||||||
|
|
||||||
# heights_px sum to body height; ratios consistent.
|
|
||||||
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
|
|
||||||
assert result["dynamic_rows"] is True
|
|
||||||
assert result["dynamic_cols"] is False
|
|
||||||
|
|
||||||
# New col-axis defaults: full body width, ratio 1.0.
|
|
||||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
|
||||||
assert result["width_ratios"] == [1.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_horizontal_2_override_preserves_rows():
|
|
||||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
|
||||||
override = {
|
|
||||||
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
|
|
||||||
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
|
|
||||||
}
|
|
||||||
result = build_layout_css(
|
|
||||||
"horizontal-2", zones, override_zone_geometries=override
|
|
||||||
)
|
|
||||||
assert result["computation"] == "user_override_geometry"
|
|
||||||
assert result["dynamic_rows"] is True
|
|
||||||
assert result["dynamic_cols"] is False
|
|
||||||
assert result["heights_px"][0] < result["heights_px"][1]
|
|
||||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
|
||||||
# Override ratio target.
|
|
||||||
assert result["ratios"] == [0.3, 0.7]
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── vertical-2 new dynamic ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_vertical_2_normal_produces_dynamic_cols():
|
|
||||||
zones = [_zone("left", 0.7), _zone("right", 0.3)]
|
|
||||||
result = build_layout_css("vertical-2", zones)
|
|
||||||
assert result["dynamic_rows"] is False
|
|
||||||
assert result["dynamic_cols"] is True
|
|
||||||
# cols string is px-based (no fr).
|
|
||||||
assert "fr" not in result["cols"]
|
|
||||||
assert result["cols"].count("px") == 2
|
|
||||||
# Heights span full body in a single row.
|
|
||||||
assert result["heights_px"] == [SLIDE_BODY_HEIGHT]
|
|
||||||
# Widths reflect 70/30 weight split.
|
|
||||||
assert result["widths_px"][0] > result["widths_px"][1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_vertical_2_override_keeps_fr_cols_legacy():
|
|
||||||
"""PR 1 v-2 override path keeps legacy fr-string cols but now
|
|
||||||
populates widths_px in pixels for downstream consumers."""
|
|
||||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
|
||||||
override = {
|
|
||||||
"left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0},
|
|
||||||
"right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0},
|
|
||||||
}
|
|
||||||
result = build_layout_css(
|
|
||||||
"vertical-2", zones, override_zone_geometries=override
|
|
||||||
)
|
|
||||||
assert result["computation"] == "user_override_geometry"
|
|
||||||
assert "fr" in result["cols"]
|
|
||||||
assert result["dynamic_cols"] is True
|
|
||||||
assert result["dynamic_rows"] is False
|
|
||||||
# widths_px now populated.
|
|
||||||
assert len(result["widths_px"]) == 2
|
|
||||||
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
|
|
||||||
assert result["width_ratios"] == [0.4, 0.6]
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────── PR 2: 5 in-scope 2-D presets dynamic ─────────────────
|
|
||||||
|
|
||||||
|
|
||||||
_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"]) == 1
|
|
||||||
assert len(result["widths_px"]) == 1
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths).
|
|
||||||
|
|
||||||
Verifies the unified per-zone geometry aggregator on horizontal-2 and
|
|
||||||
vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone
|
|
||||||
cases (T / 2x2) are exercised in PR 2.
|
|
||||||
|
|
||||||
The helper aggregates grid-track sizes into per-zone dimensions and
|
|
||||||
must produce length-locked outputs:
|
|
||||||
- layout_css["heights_px"] length == R (parsed css_areas rows)
|
|
||||||
- layout_css["widths_px"] length == C (parsed css_areas cols)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import (
|
|
||||||
GRID_GAP,
|
|
||||||
SLIDE_BODY_HEIGHT,
|
|
||||||
SLIDE_BODY_WIDTH,
|
|
||||||
_compute_per_zone_geometry,
|
|
||||||
build_layout_css,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _zone(position: str, score: float) -> dict:
|
|
||||||
return {
|
|
||||||
"position": position,
|
|
||||||
"template_id": f"MOCK_{position}",
|
|
||||||
"content_weight": {"score": score},
|
|
||||||
"min_height_px": 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_horizontal_2_per_zone_widths_match_slide_body():
|
|
||||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
|
||||||
layout_css = build_layout_css("horizontal-2", zones)
|
|
||||||
debug_zones = [{"position": "top"}, {"position": "bottom"}]
|
|
||||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
|
||||||
|
|
||||||
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
|
|
||||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
|
||||||
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
|
|
||||||
# Heights mirror layout_css.heights_px.
|
|
||||||
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
|
|
||||||
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_vertical_2_per_zone_heights_match_slide_body():
|
|
||||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
|
||||||
layout_css = build_layout_css("vertical-2", zones)
|
|
||||||
debug_zones = [{"position": "left"}, {"position": "right"}]
|
|
||||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
|
||||||
|
|
||||||
# Both zones share the single row => height == SLIDE_BODY_HEIGHT.
|
|
||||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
|
||||||
assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
|
||||||
# Widths mirror layout_css.widths_px.
|
|
||||||
assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0]
|
|
||||||
assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_heights_px_length_mismatch_raises():
|
|
||||||
layout_css = {
|
|
||||||
"areas": '"top" "bottom"',
|
|
||||||
"heights_px": [300], # wrong length, expected 2
|
|
||||||
"widths_px": [SLIDE_BODY_WIDTH],
|
|
||||||
}
|
|
||||||
with pytest.raises(ValueError, match="heights_px length"):
|
|
||||||
_compute_per_zone_geometry(
|
|
||||||
layout_css, [{"position": "top"}], GRID_GAP
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_widths_px_length_mismatch_raises():
|
|
||||||
layout_css = {
|
|
||||||
"areas": '"left right"',
|
|
||||||
"heights_px": [SLIDE_BODY_HEIGHT],
|
|
||||||
"widths_px": [600], # wrong length, expected 2
|
|
||||||
}
|
|
||||||
with pytest.raises(ValueError, match="widths_px length"):
|
|
||||||
_compute_per_zone_geometry(
|
|
||||||
layout_css, [{"position": "left"}], GRID_GAP
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_position_raises():
|
|
||||||
zones = [_zone("top", 0.5), _zone("bottom", 0.5)]
|
|
||||||
layout_css = build_layout_css("horizontal-2", zones)
|
|
||||||
debug_zones = [{"position": "ghost"}]
|
|
||||||
with pytest.raises(ValueError, match="not present in css_areas"):
|
|
||||||
_compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fr_default_single_returns_full_body():
|
|
||||||
# 'single' is the fr_default sink in PR 1; widths_px / heights_px
|
|
||||||
# must still be populated (length 1 each).
|
|
||||||
layout_css = build_layout_css("single", [_zone("primary", 1.0)])
|
|
||||||
debug_zones = [{"position": "primary"}]
|
|
||||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
|
||||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
|
||||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — compute_zone_layout_cols tests.
|
|
||||||
|
|
||||||
Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
|
|
||||||
No min_width_px contract exists in frame_contracts.yaml (verified
|
|
||||||
during Stage 2), so column distribution is purely content_weight.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import (
|
|
||||||
GRID_GAP,
|
|
||||||
SLIDE_BODY_WIDTH,
|
|
||||||
compute_zone_layout_cols,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _zone(position: str, score: float) -> dict:
|
|
||||||
return {
|
|
||||||
"position": position,
|
|
||||||
"template_id": f"MOCK_{position}",
|
|
||||||
"content_weight": {"score": score},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_zones_returns_empty_result():
|
|
||||||
result = compute_zone_layout_cols([])
|
|
||||||
assert result["widths_px"] == []
|
|
||||||
assert result["width_ratios"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_two_equal_zones_split_evenly():
|
|
||||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
|
||||||
result = compute_zone_layout_cols(zones)
|
|
||||||
available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones
|
|
||||||
assert sum(result["widths_px"]) == available
|
|
||||||
assert result["widths_px"][0] == result["widths_px"][1]
|
|
||||||
assert result["computation"] == "content_weight_distribution_cols"
|
|
||||||
|
|
||||||
|
|
||||||
def test_asymmetric_weights_distribute_by_ratio():
|
|
||||||
zones = [_zone("left", 0.8), _zone("right", 0.2)]
|
|
||||||
result = compute_zone_layout_cols(zones)
|
|
||||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
|
||||||
assert sum(result["widths_px"]) == available
|
|
||||||
# left should be ~4x right
|
|
||||||
assert result["widths_px"][0] > result["widths_px"][1] * 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_weight_guard_equal_split():
|
|
||||||
zones = [_zone("left", 0.0), _zone("right", 0.0)]
|
|
||||||
result = compute_zone_layout_cols(zones)
|
|
||||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
|
||||||
assert sum(result["widths_px"]) == available
|
|
||||||
assert result["widths_px"][0] == result["widths_px"][1]
|
|
||||||
# weight_shares fallback to equal share.
|
|
||||||
assert result["weight_shares"] == [0.5, 0.5]
|
|
||||||
|
|
||||||
|
|
||||||
def test_integer_rounding_absorbed_by_last_zone():
|
|
||||||
# Three zones with weights that don't divide evenly.
|
|
||||||
zones = [
|
|
||||||
_zone("a", 0.333333),
|
|
||||||
_zone("b", 0.333333),
|
|
||||||
_zone("c", 0.333334),
|
|
||||||
]
|
|
||||||
result = compute_zone_layout_cols(zones)
|
|
||||||
available = SLIDE_BODY_WIDTH - 2 * GRID_GAP
|
|
||||||
assert sum(result["widths_px"]) == available
|
|
||||||
|
|
||||||
|
|
||||||
def test_width_ratios_match_total_width():
|
|
||||||
zones = [_zone("left", 0.6), _zone("right", 0.4)]
|
|
||||||
result = compute_zone_layout_cols(zones)
|
|
||||||
# width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available)
|
|
||||||
assert abs(
|
|
||||||
result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH
|
|
||||||
) < 1e-3
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — fixture-driven regression checks.
|
|
||||||
|
|
||||||
Loads the YAML snapshots under tests/phase_z2/fixtures/ and exercises
|
|
||||||
build_layout_css + _attempt_zone_ratio_retry against them. Any drift
|
|
||||||
in IMP-09 output forces a fixture refresh, which is the lock surface
|
|
||||||
called out in Stage 3 round 4 §5.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import _attempt_zone_ratio_retry, build_layout_css
|
|
||||||
|
|
||||||
|
|
||||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_yaml(path: Path) -> dict:
|
|
||||||
with path.open(encoding="utf-8") as f:
|
|
||||||
return yaml.safe_load(f)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────── build_layout_css fixtures ────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
_BUILD_DIR = FIXTURES_DIR / "build_layout_css"
|
|
||||||
_BUILD_FIXTURES = sorted(_BUILD_DIR.glob("*.yaml")) if _BUILD_DIR.exists() else []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"fixture_path",
|
|
||||||
_BUILD_FIXTURES,
|
|
||||||
ids=[p.stem for p in _BUILD_FIXTURES],
|
|
||||||
)
|
|
||||||
def test_build_layout_css_matches_fixture(fixture_path: Path):
|
|
||||||
payload = _load_yaml(fixture_path)
|
|
||||||
inp = payload["input"]
|
|
||||||
expected = payload["expected_layout_css"]
|
|
||||||
|
|
||||||
result = build_layout_css(
|
|
||||||
inp["layout_preset"],
|
|
||||||
inp["zones_data"],
|
|
||||||
override_zone_geometries=inp.get("override_zone_geometries"),
|
|
||||||
)
|
|
||||||
# raw_zone_layout is intentionally not snapshotted (contains
|
|
||||||
# solver internals); compare the rest.
|
|
||||||
actual = {k: v for k, v in result.items() if k != "raw_zone_layout"}
|
|
||||||
assert actual == expected, (
|
|
||||||
f"layout_css drift in fixture {fixture_path.name}:\n"
|
|
||||||
f" expected={expected}\n actual={actual}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────── retry_gate fixtures ──────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
_RETRY_DIR = FIXTURES_DIR / "retry_gate"
|
|
||||||
_RETRY_FIXTURES = sorted(_RETRY_DIR.glob("*.yaml")) if _RETRY_DIR.exists() else []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"fixture_path",
|
|
||||||
_RETRY_FIXTURES,
|
|
||||||
ids=[p.stem for p in _RETRY_FIXTURES],
|
|
||||||
)
|
|
||||||
def test_retry_gate_matches_fixture(fixture_path: Path, tmp_path: Path):
|
|
||||||
payload = _load_yaml(fixture_path)
|
|
||||||
layout_css = payload["input_layout_css"]
|
|
||||||
router_decision = payload["router_decision"]
|
|
||||||
expected = payload["expected_gate"]
|
|
||||||
|
|
||||||
trace = _attempt_zone_ratio_retry(
|
|
||||||
run_dir=tmp_path,
|
|
||||||
out_path=tmp_path / "final.html",
|
|
||||||
slide_title="fixture",
|
|
||||||
slide_footer=None,
|
|
||||||
zones_data=[],
|
|
||||||
debug_zones=[],
|
|
||||||
layout_preset="fixture",
|
|
||||||
layout_css=layout_css,
|
|
||||||
overflow={},
|
|
||||||
fit_classification={},
|
|
||||||
router_decision=router_decision,
|
|
||||||
gap_px=14,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert trace["retry_attempted"] == expected["retry_attempted"]
|
|
||||||
skip_reason = trace.get("retry_skipped_reason")
|
|
||||||
for needle in expected.get("retry_skipped_reason_contains", []):
|
|
||||||
assert skip_reason is not None and needle in skip_reason, (
|
|
||||||
f"expected {needle!r} in retry_skipped_reason, got {skip_reason!r}"
|
|
||||||
)
|
|
||||||
for forbidden in expected.get("retry_skipped_reason_excludes", []):
|
|
||||||
if skip_reason is not None:
|
|
||||||
assert forbidden not in skip_reason, (
|
|
||||||
f"forbidden {forbidden!r} found in retry_skipped_reason {skip_reason!r}"
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — _parse_css_areas strict validation tests.
|
|
||||||
|
|
||||||
Covers the four ValueError cases declared in the Stage 3 round 4 lock
|
|
||||||
(plan §2-D): empty input, no quoted rows, empty row tokens, and
|
|
||||||
non-rectangular grids. Also exercises positive parsing on all 8
|
|
||||||
catalog presets so any future catalog drift in row/col counts is
|
|
||||||
caught here.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import _parse_css_areas
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_empty_string_raises():
|
|
||||||
with pytest.raises(ValueError, match="no quoted row strings"):
|
|
||||||
_parse_css_areas("")
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_no_quotes_raises():
|
|
||||||
with pytest.raises(ValueError, match="no quoted row strings"):
|
|
||||||
_parse_css_areas("top top bottom-left bottom-right")
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_empty_row_raises():
|
|
||||||
# Whitespace-only quoted row -> tokens list is empty.
|
|
||||||
with pytest.raises(ValueError, match="empty row"):
|
|
||||||
_parse_css_areas('" "')
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_non_rectangular_raises():
|
|
||||||
# First row has 1 token, second row has 2 tokens.
|
|
||||||
with pytest.raises(ValueError, match="non-rectangular"):
|
|
||||||
_parse_css_areas('"top" "bottom-left bottom-right"')
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_single_zone():
|
|
||||||
rows, seen = _parse_css_areas('"primary"')
|
|
||||||
assert rows == [["primary"]]
|
|
||||||
assert seen == ["primary"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_horizontal_2():
|
|
||||||
rows, seen = _parse_css_areas('"top" "bottom"')
|
|
||||||
assert rows == [["top"], ["bottom"]]
|
|
||||||
assert seen == ["top", "bottom"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_vertical_2():
|
|
||||||
rows, seen = _parse_css_areas('"left right"')
|
|
||||||
assert rows == [["left", "right"]]
|
|
||||||
assert seen == ["left", "right"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_top_1_bottom_2_span():
|
|
||||||
rows, seen = _parse_css_areas('"top top" "bottom-left bottom-right"')
|
|
||||||
assert rows == [["top", "top"], ["bottom-left", "bottom-right"]]
|
|
||||||
# 'top' should appear once in seen even though it occupies two cells.
|
|
||||||
assert seen == ["top", "bottom-left", "bottom-right"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_grid_2x2_four_zones():
|
|
||||||
rows, seen = _parse_css_areas(
|
|
||||||
'"top-left top-right" "bottom-left bottom-right"'
|
|
||||||
)
|
|
||||||
assert rows == [
|
|
||||||
["top-left", "top-right"],
|
|
||||||
["bottom-left", "bottom-right"],
|
|
||||||
]
|
|
||||||
assert seen == ["top-left", "top-right", "bottom-left", "bottom-right"]
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — _parse_fr_string tests.
|
|
||||||
|
|
||||||
Catalog presets only use `1fr` / `1fr 1fr` specs (verified
|
|
||||||
templates/phase_z2/layouts/layouts.yaml). The helper must reject
|
|
||||||
non-fr tokens and round to integer pixel sizes summing to `total`.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import _parse_fr_string
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_fr_returns_full_total():
|
|
||||||
assert _parse_fr_string("1fr", 585) == [585]
|
|
||||||
|
|
||||||
|
|
||||||
def test_two_equal_fr_splits_evenly():
|
|
||||||
result = _parse_fr_string("1fr 1fr", 1180)
|
|
||||||
assert result == [590, 590]
|
|
||||||
assert sum(result) == 1180
|
|
||||||
|
|
||||||
|
|
||||||
def test_unequal_fr_distributes_by_ratio():
|
|
||||||
result = _parse_fr_string("2fr 1fr", 300)
|
|
||||||
assert sum(result) == 300
|
|
||||||
assert result[0] > result[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rounding_absorbed_by_last_track():
|
|
||||||
# 1fr 1fr 1fr / total=100 -> 33,33,33 + diff 1 absorbed by last.
|
|
||||||
result = _parse_fr_string("1fr 1fr 1fr", 100)
|
|
||||||
assert sum(result) == 100
|
|
||||||
assert result == [33, 33, 34]
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_fr_token_raises():
|
|
||||||
with pytest.raises(ValueError, match="non-fr token"):
|
|
||||||
_parse_fr_string("200px 1fr", 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_spec_raises():
|
|
||||||
with pytest.raises(ValueError, match="empty spec"):
|
|
||||||
_parse_fr_string("", 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_fr_raises():
|
|
||||||
with pytest.raises(ValueError, match="total fr"):
|
|
||||||
_parse_fr_string("0fr 0fr", 1000)
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""IMP-09 PR 1 — retry gate tests (_attempt_zone_ratio_retry early exit).
|
|
||||||
|
|
||||||
Stage 3 round 4 lock §2-A: row-axis retry must skip when layout has
|
|
||||||
dynamic_cols=True (2-D topology) OR dynamic_rows=False (fr_default
|
|
||||||
sink). The horizontal-2 path (dynamic_rows=True, dynamic_cols=False)
|
|
||||||
must still proceed through the gate.
|
|
||||||
|
|
||||||
These tests exercise the gate by routing the request through
|
|
||||||
_attempt_zone_ratio_retry with router_active=True + proposed
|
|
||||||
zone_ratio_retry — but with layout_css fields that should trip the
|
|
||||||
gate. We confirm the early skip by asserting retry_attempted==False
|
|
||||||
and retry_skipped_reason content.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.phase_z2_pipeline import _attempt_zone_ratio_retry
|
|
||||||
|
|
||||||
|
|
||||||
_ROUTER_ACTIVE = {
|
|
||||||
"router_active": True,
|
|
||||||
"proposed_actions_summary": ["zone_ratio_retry"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dummy_kwargs(layout_css: dict, tmp_path: Path) -> dict:
|
|
||||||
"""All params required by _attempt_zone_ratio_retry. Only
|
|
||||||
`layout_css` and `router_decision` matter pre-gate."""
|
|
||||||
return {
|
|
||||||
"run_dir": tmp_path,
|
|
||||||
"out_path": tmp_path / "final.html",
|
|
||||||
"slide_title": "test",
|
|
||||||
"slide_footer": None,
|
|
||||||
"zones_data": [],
|
|
||||||
"debug_zones": [],
|
|
||||||
"layout_preset": "horizontal-2",
|
|
||||||
"layout_css": layout_css,
|
|
||||||
"overflow": {},
|
|
||||||
"fit_classification": {},
|
|
||||||
"router_decision": _ROUTER_ACTIVE,
|
|
||||||
"gap_px": 14,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_vertical_2_dynamic_cols_skips_retry(tmp_path):
|
|
||||||
layout_css = {
|
|
||||||
"areas": '"left right"',
|
|
||||||
"cols": "583px 583px",
|
|
||||||
"rows": "1fr",
|
|
||||||
"heights_px": [585],
|
|
||||||
"widths_px": [583, 583],
|
|
||||||
"ratios": [1.0],
|
|
||||||
"width_ratios": [0.494, 0.494],
|
|
||||||
"dynamic_rows": False,
|
|
||||||
"dynamic_cols": True,
|
|
||||||
}
|
|
||||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
|
||||||
assert trace["retry_attempted"] is False
|
|
||||||
assert "dynamic_cols" in trace["retry_skipped_reason"]
|
|
||||||
assert "IMP-09" in trace["retry_skipped_reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fr_default_sink_skips_retry(tmp_path):
|
|
||||||
# PR 1 single / T-shape / 2x2 fall through to fr_default and must
|
|
||||||
# not enter row-only retry plan.
|
|
||||||
layout_css = {
|
|
||||||
"areas": '"top top" "bottom-left bottom-right"',
|
|
||||||
"cols": "1fr 1fr",
|
|
||||||
"rows": "1fr 1fr",
|
|
||||||
"heights_px": [285, 286],
|
|
||||||
"widths_px": [583, 583],
|
|
||||||
"ratios": [0.487, 0.489],
|
|
||||||
"width_ratios": [0.494, 0.494],
|
|
||||||
"dynamic_rows": False,
|
|
||||||
"dynamic_cols": False,
|
|
||||||
}
|
|
||||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
|
||||||
assert trace["retry_attempted"] is False
|
|
||||||
assert "fr_default_from_preset" in trace["retry_skipped_reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_horizontal_2_dynamic_rows_passes_gate(tmp_path):
|
|
||||||
"""horizontal-2 with dynamic_rows=True must pass the gate. The
|
|
||||||
test does not need plan_zone_ratio_retry to succeed; it only
|
|
||||||
asserts the gate did not early-skip with one of the new
|
|
||||||
skip reasons."""
|
|
||||||
layout_css = {
|
|
||||||
"areas": '"top" "bottom"',
|
|
||||||
"cols": "1fr",
|
|
||||||
"rows": "333px 238px",
|
|
||||||
"heights_px": [333, 238],
|
|
||||||
"widths_px": [1180],
|
|
||||||
"ratios": [0.569, 0.407],
|
|
||||||
"width_ratios": [1.0],
|
|
||||||
"dynamic_rows": True,
|
|
||||||
"dynamic_cols": False,
|
|
||||||
}
|
|
||||||
# plan_zone_ratio_retry will return None because debug_zones is
|
|
||||||
# empty, so retry_attempted=True but plan==None.
|
|
||||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
|
||||||
assert trace["retry_attempted"] is True
|
|
||||||
# The gate was passed; skip reason (if any) is the legacy
|
|
||||||
# plan-failure reason, not the new gate reasons.
|
|
||||||
skip_reason = trace.get("retry_skipped_reason")
|
|
||||||
if skip_reason is not None:
|
|
||||||
assert "dynamic_cols" not in skip_reason
|
|
||||||
assert "fr_default_from_preset" not in skip_reason
|
|
||||||
|
|
||||||
|
|
||||||
def test_router_inactive_skips_before_gate(tmp_path):
|
|
||||||
"""When router_active=False, the early skip happens before the
|
|
||||||
new IMP-09 gate. Verify the existing behavior is unchanged."""
|
|
||||||
layout_css = {
|
|
||||||
"areas": '"left right"',
|
|
||||||
"dynamic_rows": False,
|
|
||||||
"dynamic_cols": True,
|
|
||||||
"heights_px": [585],
|
|
||||||
"widths_px": [583, 583],
|
|
||||||
"ratios": [1.0],
|
|
||||||
"width_ratios": [0.5, 0.5],
|
|
||||||
}
|
|
||||||
kwargs = _dummy_kwargs(layout_css, tmp_path)
|
|
||||||
kwargs["router_decision"] = {"router_active": False}
|
|
||||||
trace = _attempt_zone_ratio_retry(**kwargs)
|
|
||||||
assert trace["retry_attempted"] is False
|
|
||||||
assert "router_active=False" in trace["retry_skipped_reason"]
|
|
||||||
@@ -111,9 +111,9 @@ def test_mdx_section_default_construction_preserves_4_positional_callers():
|
|||||||
|
|
||||||
|
|
||||||
def test_align_passthrough_when_v4_key_exact_match():
|
def test_align_passthrough_when_v4_key_exact_match():
|
||||||
# Section already aligned to V4 key (no override target): aligner
|
# Section already aligned to V4 key (no H3 sub-sections, no override
|
||||||
# keeps it untouched. Parent-level V4 evidence flows via exact-match
|
# target): aligner keeps it untouched. Parent-level V4 evidence
|
||||||
# lookup.
|
# flows via exact-match lookup.
|
||||||
sections = [_section("04-1", 1, "1. Top", "body")]
|
sections = [_section("04-1", 1, "1. Top", "body")]
|
||||||
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
|
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
|
||||||
out = align_sections_to_v4_granularity(sections, v4)
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
|||||||
Reference in New Issue
Block a user