Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Step 17 salvage dispatcher previously only ran the 3 actions in
_SALVAGE_FAIL_BY_ACTION (cross_zone_redistribute / glue_compression /
font_step_compression). Any next_proposed_action outside that set hit
salvage_terminal_action and dropped through, so visual_check aborted on
layout_adjust / image_fit / frame_internal_fit_candidate cascades.
u1 — router data surface (src/phase_z2_router.py)
- ACTION_BY_CATEGORY: image_aspect_mismatch -> image_fit (new row),
frame_capacity_mismatch -> frame_internal_fit_candidate (was
frame_reselect).
- ACTION_IMPLEMENTATION_STATUS: layout_adjust / image_fit /
frame_internal_fit_candidate flipped MISSING -> IMPLEMENTED with
inline IMP-88 rationale.
u2 — failure_router cascade surface (src/phase_z2_failure_router.py)
- FAILURE_TYPE_DESCRIPTIONS + SALVAGE_FAILURE_TYPE_BY_ACTION extended
with layout_adjust_insufficient / image_fit_insufficient /
frame_internal_fit_insufficient producers.
- NEXT_ACTION_BY_FAILURE + NEXT_ACTION_RATIONALE +
NEXT_ACTION_IMPLEMENTATION_STATUS rows added; cascade chain becomes
font_step_compression -> layout_adjust -> frame_internal_fit_candidate
-> frame_reselect -> details_popup_escalation (#64 terminal).
u3~u5 — planners + apply helpers (src/phase_z2_retry.py)
- plan_layout_adjust / apply_layout_adjust_layout_css with
_layout_swap_priority across 8-preset LAYOUT_PRESETS (preset switch,
no shared-margin shrink per Phase Z spacing direction).
- plan_image_fit / apply_image_fit_css scoped to frame slot using
existing classifier image_event payload (object-fit + max-w/h
derivation).
- plan_frame_internal_fit_candidate / apply_frame_internal_fit_candidate_css
stays inside declared frame contract envelope; emits infeasible path
when envelope is absent.
u6~u7 — pipeline wiring (src/phase_z2_pipeline.py)
- _SALVAGE_FAIL_BY_ACTION extended; _attempt_salvage_chain gains
layout_adjust distinct-render branch + frame_internal_fit_candidate
CSS-overlay branch + loop cap.
- _attempt_step17_image_fit_single_pass added for image_fit entry.
- §11.7.1 / §11.7.2 entry triggers wired; Step 17/18/19 artifact
refresh + note logging closes the salvage_terminal_action fall-through
for the 3 IMP-88 actions.
Tests
- New: test_router_actions_imp88.py (12),
test_failure_router_imp88_cascade.py (12),
test_phase_z2_retry_layout_adjust.py (10),
test_phase_z2_retry_image_fit.py (13),
test_phase_z2_retry_frame_internal_fit.py (13),
test_phase_z2_pipeline_salvage_imp88.py (8),
test_phase_z2_pipeline_step17_entry_imp88.py.
- Regression-aligned: test_phase_z2_failure_router_cascade.py,
test_phase_z2_step17_salvage_chain.py — pre-existing cascade +
salvage-chain assertions updated to the IMPLEMENTED surface.
Out of scope (separate axes / issues)
- details_popup_escalation terminal body (#64).
- frame_reselect MISSING flip (different axis).
- Step 14/16 detection refinement.
- Stage 0 mdx_normalizer integration (locked 2026-05-08).
- AI fallback activation.
Guardrails respected
- Phase Z spacing direction: layout_adjust switches preset; no shared
margin shrink.
- AI isolation contract: planners + dispatcher are deterministic; zero
AI calls in u1~u7.
- No hardcoding: routing + cascade live in router/failure_router data
rows, not inline conditionals.
- IMP-46 (#62) cache carve-out: untouched.
- 1 commit = 1 decision unit: u1~u7 grouped as a single IMP-88 unit.
Stage 4 verification: 7 IMP-88 test files + 2 modified regression files
PASS (Claude #12 + Codex #12 consensus YES). Full-suite sweep deferred to
a separate step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
805 lines
34 KiB
Python
805 lines
34 KiB
Python
"""Phase Z-2 zone_ratio_retry action v0 (A3 — 실제 zone redistribution 구현).
|
|
|
|
router 가 *제안* 한 zone_ratio_retry action 의 **실행 layer**.
|
|
|
|
원칙 (A3 locked rules — 사용자 잠금 7+1) :
|
|
1. retry budget = 1 — 한 번만 시도
|
|
2. slide / slide-body / title / divider / footer / zone gap 모두 고정
|
|
(공통 spacing 깎기 금지)
|
|
3. 조정 대상 = router 가 지목한 *target zone* 만 height 증가
|
|
4. donor 선택 기준 :
|
|
- 같은 layout 의 sibling zone
|
|
- visual_check 통과 (이 zone 자체엔 overflow 없음)
|
|
- capacity_fit 가 ok
|
|
- 현재 height > min_height_px (slack > 0)
|
|
- donor min_height_px 아래로 줄지 X
|
|
- 여러 후보면 slack 가장 큰 것부터 (greedy)
|
|
- 부족 시 retry 실패
|
|
5. target_added_px = observed excess_y + safety_margin (small fixed)
|
|
— donor 가 min_height 아래로 가면 실패
|
|
6. retry 후 status :
|
|
- 성공 → PASS 가능
|
|
- 실패 → RENDERED_WITH_VISUAL_REGRESSION 유지 (CSS/padding/tolerance 보정 X)
|
|
7. debug trace 필수 (retry_attempted / target / donor / before/after / passed / reason)
|
|
8. revert 정책 ((b)) :
|
|
- redistribution check 실패 → rerender 안 함, original final.html 유지
|
|
- rerender 후 visual_check 실패 → original 로 revert (final.html 변경 X),
|
|
retried_candidate.html 은 *진단 artifact* 로만 별도 보관
|
|
- retry 성공 시에만 final.html = retried version
|
|
|
|
본 module 은 *plan + apply layer*. rerender / final.html 갱신 / revert 는 pipeline 이.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Optional
|
|
|
|
|
|
# 작은 고정 safety margin — 실험적 default. debug 에 기록.
|
|
DEFAULT_SAFETY_MARGIN_PX = 4
|
|
|
|
|
|
def plan_zone_ratio_retry(
|
|
*,
|
|
debug_zones: list[dict],
|
|
overflow: dict,
|
|
fit_classification: dict,
|
|
router_decision: dict,
|
|
safety_margin_px: int = DEFAULT_SAFETY_MARGIN_PX,
|
|
) -> Optional[dict]:
|
|
"""zone_ratio_retry 의 redistribution plan 을 산출.
|
|
|
|
*plan 만*. 실제 height 적용 / rerender X (caller 가 처리).
|
|
|
|
Returns:
|
|
None : retry 시도 자체가 불필요 (router 가 zone_ratio_retry 제안 X)
|
|
dict : retry attempt 정보 (feasible 여부 + 상세)
|
|
|
|
feasible=True 이면 caller 가 zones_after 로 layout_css 재구성 + rerender 시도.
|
|
feasible=False 이면 caller 는 retry 포기 (original final.html 유지).
|
|
"""
|
|
if not router_decision.get("router_active"):
|
|
return None
|
|
|
|
# zone_ratio_retry 가 router 제안에 포함된 첫 classification 을 target 으로
|
|
target_cls = None
|
|
for cls in fit_classification.get("classifications", []) or []:
|
|
if cls.get("proposed_action") == "zone_ratio_retry":
|
|
target_cls = cls
|
|
break
|
|
if target_cls is None:
|
|
return None # 다른 action (popup / reselect) — 본 retry 대상 아님
|
|
|
|
target_zone_position = target_cls.get("zone_position")
|
|
target_excess_y = float(target_cls.get("inputs", {}).get("excess_y", 0))
|
|
# round up to integer (subpixel 끼면 부족할 수 있음)
|
|
target_added_px = int(math.ceil(target_excess_y)) + int(safety_margin_px)
|
|
|
|
# zones_before — debug_zones 의 height_px 를 모음
|
|
zones_before: dict[str, int] = {}
|
|
zone_min_by_pos: dict[str, int] = {}
|
|
for dz in debug_zones:
|
|
pos = dz.get("position")
|
|
if pos is None:
|
|
continue
|
|
h = dz.get("height_px")
|
|
m = dz.get("min_height_px")
|
|
if h is None or m is None:
|
|
continue
|
|
zones_before[pos] = int(h)
|
|
zone_min_by_pos[pos] = int(m)
|
|
|
|
# overflow zone 별 visual fail 정보
|
|
overflow_zone_status: dict[str, dict] = {}
|
|
for z in overflow.get("zones", []) or []:
|
|
overflow_zone_status[z.get("position")] = z
|
|
|
|
# donor 후보 식별
|
|
donor_candidates: list[dict] = []
|
|
for dz in debug_zones:
|
|
pos = dz.get("position")
|
|
if pos is None or pos == target_zone_position:
|
|
continue
|
|
# rule 4-(a) sibling 확인은 layout 내 sibling = 같은 zones list 안에 있으면 OK
|
|
# (본 함수는 1 layout 내 zones 만 받음)
|
|
|
|
# rule 4-(b) visual_check 통과 — 이 zone 에 자체 overflow / clipped_inner 없음
|
|
zinfo = overflow_zone_status.get(pos, {})
|
|
zone_self_overflow = bool(zinfo.get("overflowed"))
|
|
zone_inner_clipped = bool(zinfo.get("clipped_inner"))
|
|
if zone_self_overflow or zone_inner_clipped:
|
|
continue
|
|
|
|
# rule 4-(c) capacity_fit 가 ok
|
|
cap_status = (
|
|
(dz.get("composition_rationale") or {}).get("capacity_fit", {}).get("fit_status")
|
|
)
|
|
# 'ok' 아니거나 missing/unknown 이면 보수적으로 제외 (no_contract 는 허용 — capacity_fit 자체 부재)
|
|
if cap_status not in {"ok", "no_contract", None}:
|
|
continue
|
|
|
|
# rule 4-(d) 현재 height > min_height
|
|
# IMP-34 u1: donor capacity bounded by measured empty space
|
|
# (clientHeight - scrollHeight from Step 14) when both fields are present,
|
|
# falling back to static contract slack when absent. Prevents the donor
|
|
# from being over-allocated when it is already full but not overflowing.
|
|
height = zones_before.get(pos)
|
|
min_h = zone_min_by_pos.get(pos)
|
|
if height is None or min_h is None:
|
|
continue
|
|
static_slack = height - min_h
|
|
client_h = zinfo.get("clientHeight")
|
|
scroll_h = zinfo.get("scrollHeight")
|
|
if (
|
|
isinstance(client_h, (int, float))
|
|
and isinstance(scroll_h, (int, float))
|
|
and not isinstance(client_h, bool)
|
|
and not isinstance(scroll_h, bool)
|
|
):
|
|
measured_empty_px = max(0, int(client_h) - int(scroll_h))
|
|
slack = min(static_slack, measured_empty_px)
|
|
slack_bound_source = "measured_bound"
|
|
else:
|
|
measured_empty_px = None
|
|
slack = static_slack
|
|
slack_bound_source = "static_fallback"
|
|
if slack <= 0:
|
|
continue
|
|
|
|
donor_candidates.append({
|
|
"position": pos,
|
|
"current_height": height,
|
|
"min_height": min_h,
|
|
"slack": slack,
|
|
"capacity_fit_status": cap_status,
|
|
"measured_empty_px": measured_empty_px,
|
|
"slack_bound_source": slack_bound_source,
|
|
})
|
|
|
|
# rule 4-(f) 여러 후보면 slack 가장 큰 것부터
|
|
donor_candidates.sort(key=lambda d: d["slack"], reverse=True)
|
|
|
|
# base plan dict (failure / success 공용)
|
|
base_plan = {
|
|
"target_zone_position": target_zone_position,
|
|
"target_excess_y": target_excess_y,
|
|
"target_added_px": target_added_px,
|
|
"safety_margin_px_used": int(safety_margin_px),
|
|
"donor_candidates_considered": donor_candidates,
|
|
"zones_before": dict(zones_before),
|
|
}
|
|
|
|
if not donor_candidates:
|
|
return {
|
|
**base_plan,
|
|
"feasible": False,
|
|
"donor_zone_position": None,
|
|
"donor_reduced_px": 0,
|
|
"zones_after": dict(zones_before),
|
|
"failure_reason": (
|
|
f"no donor candidates eligible (sibling visual_check OK + "
|
|
f"capacity_fit ok/no_contract + slack > 0)"
|
|
),
|
|
}
|
|
|
|
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
|
|
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
|
|
if aggregate_slack_available < target_added_px:
|
|
return {
|
|
**base_plan,
|
|
"feasible": False,
|
|
"donor_zone_position": donor_candidates[0]["position"],
|
|
"donor_max_slack": donor_candidates[0]["slack"],
|
|
"donor_reduced_px": 0,
|
|
"donors_used": [],
|
|
"aggregate_slack_used": 0,
|
|
"aggregate_slack_available": aggregate_slack_available,
|
|
"zones_after": dict(zones_before),
|
|
"failure_reason": (
|
|
f"primary donor '{donor_candidates[0]['position']}' slack "
|
|
f"{donor_candidates[0]['slack']}px (aggregate "
|
|
f"{aggregate_slack_available}px across {len(donor_candidates)} "
|
|
f"candidate(s)) < target_added_px {target_added_px}px "
|
|
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
|
|
),
|
|
}
|
|
|
|
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
|
|
zones_after = dict(zones_before)
|
|
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
|
donors_used: list[dict] = []
|
|
remaining = target_added_px
|
|
for donor in donor_candidates:
|
|
if remaining <= 0:
|
|
break
|
|
take = min(donor["slack"], remaining)
|
|
zones_after[donor["position"]] = zones_before[donor["position"]] - take
|
|
donors_used.append({
|
|
"position": donor["position"],
|
|
"reduced_px": take,
|
|
"slack_before": donor["slack"],
|
|
"slack_after": donor["slack"] - take,
|
|
})
|
|
remaining -= take
|
|
|
|
primary_donor = donors_used[0]
|
|
return {
|
|
**base_plan,
|
|
"feasible": True,
|
|
"donor_zone_position": primary_donor["position"],
|
|
"donor_reduced_px": primary_donor["reduced_px"],
|
|
"donors_used": donors_used,
|
|
"aggregate_slack_used": target_added_px,
|
|
"aggregate_slack_available": aggregate_slack_available,
|
|
"zones_after": zones_after,
|
|
}
|
|
|
|
|
|
def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dict],
|
|
total_height: int, gap_px: int) -> dict:
|
|
"""retry plan 의 zones_after 를 반영한 *새* layout_css 반환 (mutation X).
|
|
|
|
horizontal-2 같은 dynamic_rows 인 경우만 해당. fr-default layout 은 retry target 아님
|
|
(왜냐하면 dynamic heights 가 없으면 redistribution 의미 없음).
|
|
"""
|
|
new_layout_css = dict(layout_css)
|
|
# zone position 순서대로 height_px 추출
|
|
new_heights_px = [plan["zones_after"][zd["position"]] for zd in zones_data]
|
|
new_layout_css["heights_px"] = new_heights_px
|
|
new_layout_css["rows"] = " ".join(f"{h}px" for h in new_heights_px)
|
|
new_layout_css["ratios"] = [round(h / total_height, 3) for h in new_heights_px]
|
|
new_layout_css["computation"] = "zone_ratio_retry override (A3)"
|
|
new_layout_css["dynamic_rows"] = True
|
|
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
|
|
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
|
return new_layout_css
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
|
|
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
|
|
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
|
|
# can drive it deterministically. Plan-only — no rerender / no final.html
|
|
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
|
|
# ──────────────────────────────────────
|
|
|
|
|
|
def plan_cross_zone_redistribute(
|
|
*,
|
|
fit_analysis,
|
|
containers: dict,
|
|
min_margin_px: float | None = None,
|
|
) -> dict:
|
|
"""Cross-zone (intra-zone role-to-role) redistribute plan.
|
|
|
|
Plan-only — no rerender / no final.html mutation. Side-effect-free
|
|
(operates on deepcopy of fit_analysis).
|
|
"""
|
|
from copy import deepcopy
|
|
from src.fit_verifier import redistribute as _fv_redistribute
|
|
|
|
role_heights_before = {
|
|
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
|
|
}
|
|
base_plan = {
|
|
"action": "cross_zone_redistribute",
|
|
"role_heights_before": role_heights_before,
|
|
}
|
|
if not role_heights_before:
|
|
return {**base_plan, "feasible": False, "role_heights_after": {},
|
|
"can_redistribute": False,
|
|
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
|
|
|
|
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
|
|
redistribution = dict(result.redistribution or {})
|
|
can_redistribute = bool(result.can_redistribute)
|
|
|
|
if not can_redistribute or not redistribution:
|
|
return {
|
|
**base_plan,
|
|
"feasible": False,
|
|
"role_heights_after": redistribution or dict(role_heights_before),
|
|
"can_redistribute": can_redistribute,
|
|
"failure_reason": (
|
|
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
|
|
"or surplus insufficient to cover deficit within envelope."
|
|
),
|
|
}
|
|
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
|
|
"can_redistribute": True}
|
|
|
|
|
|
def apply_cross_zone_redistribute_css(plan: dict) -> str:
|
|
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
|
|
|
|
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
|
|
"""
|
|
if not plan.get("feasible"):
|
|
return ""
|
|
role_heights_after = plan.get("role_heights_after") or {}
|
|
role_heights_before = plan.get("role_heights_before") or {}
|
|
rules: list[str] = []
|
|
for role, new_height in role_heights_after.items():
|
|
before = role_heights_before.get(role)
|
|
if before is None or abs(float(before) - float(new_height)) < 0.5:
|
|
continue
|
|
new_h_int = int(round(float(new_height)))
|
|
rules.append(
|
|
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
|
|
)
|
|
return "\n".join(rules)
|
|
|
|
|
|
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
|
|
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
|
|
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
|
|
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
|
|
|
|
|
def plan_glue_compression(
|
|
*, excess_px: float, block_count: int, zone_position: str,
|
|
) -> dict:
|
|
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
|
|
from src.space_allocator import (
|
|
calculate_glue_absorption, compute_glue_css_overrides,
|
|
)
|
|
base = {"action": "glue_compression", "zone_position": zone_position,
|
|
"excess_px": float(excess_px), "block_count": int(block_count)}
|
|
if excess_px <= 0:
|
|
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
|
|
"failure_reason": "excess_px <= 0 — no compression needed."}
|
|
absorption_max = float(calculate_glue_absorption(block_count))
|
|
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
|
|
if excess_px > absorption_max:
|
|
return {**base, "feasible": False, "overrides": overrides,
|
|
"absorption_max_px": absorption_max,
|
|
"failure_reason": (
|
|
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
|
|
f"max absorption {absorption_max:.1f}px "
|
|
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
|
|
)}
|
|
return {**base, "feasible": True, "overrides": overrides,
|
|
"absorption_max_px": absorption_max}
|
|
|
|
|
|
def apply_glue_compression_css(plan: dict) -> str:
|
|
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
|
|
if not plan.get("feasible"):
|
|
return ""
|
|
zone_position = plan.get("zone_position")
|
|
overrides = plan.get("overrides") or {}
|
|
if not zone_position or not overrides:
|
|
return ""
|
|
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
|
|
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
|
|
|
|
|
|
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
|
|
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
|
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
|
|
|
|
|
|
def plan_font_step_compression(
|
|
*, current_font_px: float, excess_after_glue_px: float,
|
|
available_lines: int, chars_per_line: int, zone_position: str,
|
|
) -> dict:
|
|
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
|
|
contains a size whose line-height savings cover excess_after_glue_px. Missing
|
|
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
|
|
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
|
|
floor = float(FONT_SIZE_STEPS[-1])
|
|
base = {"action": "font_step_compression", "zone_position": zone_position,
|
|
"current_font_px": float(current_font_px),
|
|
"excess_after_glue_px": float(excess_after_glue_px),
|
|
"available_lines": int(available_lines or 0),
|
|
"chars_per_line": int(chars_per_line or 0),
|
|
"font_floor_px": floor}
|
|
if excess_after_glue_px <= 0:
|
|
return {**base, "feasible": False, "target_font_px": None,
|
|
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
|
|
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
|
|
return {**base, "feasible": False, "target_font_px": None,
|
|
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
|
|
if current_font_px <= floor:
|
|
return {**base, "feasible": False, "target_font_px": None,
|
|
"failure_reason": (
|
|
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
|
|
target = find_fitting_font_size(
|
|
current_font_px=float(current_font_px),
|
|
excess_after_glue_px=float(excess_after_glue_px),
|
|
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
|
|
if target is None:
|
|
return {**base, "feasible": False, "target_font_px": None,
|
|
"failure_reason": (
|
|
f"font_step floor — {floor:.1f}px cannot absorb "
|
|
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
|
|
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
|
|
return {**base, "feasible": True, "target_font_px": float(target)}
|
|
|
|
|
|
def apply_font_step_compression_css(plan: dict) -> str:
|
|
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
|
|
if not plan.get("feasible"):
|
|
return ""
|
|
zone_position = plan.get("zone_position")
|
|
target_font_px = plan.get("target_font_px")
|
|
if not zone_position or target_font_px is None:
|
|
return ""
|
|
return (f'[data-zone-position="{zone_position}"] {{\n'
|
|
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap).
|
|
# Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body
|
|
# shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust;
|
|
# downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate.
|
|
# Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and
|
|
# rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px).
|
|
# ──────────────────────────────────────
|
|
|
|
|
|
def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int:
|
|
"""Lower = preferred swap target. Honors topology-axis mirroring first."""
|
|
pair = frozenset({current_topology, candidate_topology})
|
|
if pair == frozenset({"rows", "cols"}):
|
|
return 0
|
|
if pair == frozenset({"T", "inverted-T"}):
|
|
return 1
|
|
if pair == frozenset({"side-T-left", "side-T-right"}):
|
|
return 2
|
|
return 3
|
|
|
|
|
|
def plan_layout_adjust(
|
|
*, current_layout_preset: str, zones_data: list[dict],
|
|
) -> dict:
|
|
"""Layout-preset switch plan (Step 17 retry chain — IMP-88 u3).
|
|
|
|
Finds a render-ready sibling preset (same candidate_when.unit_count) and
|
|
remaps zone positions in catalog order. No common spacing shrink —
|
|
feedback_phase_z_spacing_direction lock: escalate via layout topology only.
|
|
"""
|
|
from src.phase_z2_composition import LAYOUT_PRESETS
|
|
base = {
|
|
"action": "layout_adjust",
|
|
"current_layout_preset": current_layout_preset,
|
|
}
|
|
current_spec = LAYOUT_PRESETS.get(current_layout_preset)
|
|
if current_spec is None:
|
|
return {
|
|
**base, "feasible": False, "new_layout_preset": None,
|
|
"candidates_considered": [],
|
|
"failure_reason": (
|
|
f"current_layout_preset '{current_layout_preset}' not in "
|
|
f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings."
|
|
),
|
|
}
|
|
current_positions = list(current_spec.get("positions") or [])
|
|
if len(zones_data) != len(current_positions):
|
|
return {
|
|
**base, "feasible": False, "new_layout_preset": None,
|
|
"candidates_considered": [],
|
|
"failure_reason": (
|
|
f"zones_data length {len(zones_data)} != current preset "
|
|
f"'{current_layout_preset}' positions {current_positions} — "
|
|
f"cannot remap to a sibling preset."
|
|
),
|
|
}
|
|
unit_count = (current_spec.get("candidate_when") or {}).get("unit_count")
|
|
candidates = [
|
|
pid for pid, spec in LAYOUT_PRESETS.items()
|
|
if pid != current_layout_preset
|
|
and spec.get("render_ready", False)
|
|
and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count)
|
|
]
|
|
base = {**base, "unit_count": unit_count,
|
|
"candidates_considered": list(candidates)}
|
|
if not candidates:
|
|
return {
|
|
**base, "feasible": False, "new_layout_preset": None,
|
|
"failure_reason": (
|
|
f"no render-ready 8-preset sibling for unit_count {unit_count} "
|
|
f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) "
|
|
f"have no swap target by catalog design."
|
|
),
|
|
}
|
|
catalog_order = list(LAYOUT_PRESETS.keys())
|
|
current_topo = current_spec.get("topology")
|
|
candidates.sort(key=lambda pid: (
|
|
_layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")),
|
|
catalog_order.index(pid),
|
|
))
|
|
new_preset = candidates[0]
|
|
new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or [])
|
|
new_zones_data = [
|
|
{**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data)
|
|
]
|
|
return {
|
|
**base,
|
|
"feasible": True,
|
|
"new_layout_preset": new_preset,
|
|
"swap_topology_from": current_topo,
|
|
"swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"),
|
|
"position_remap": dict(zip(current_positions, new_positions)),
|
|
"new_zones_data": new_zones_data,
|
|
}
|
|
|
|
|
|
def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]:
|
|
"""Build a fresh layout_css dict for the swapped preset.
|
|
|
|
Returns None when plan is infeasible. Dispatcher (u6) re-renders with
|
|
render_slide(zones_data=plan['new_zones_data'], layout_preset=plan
|
|
['new_layout_preset'], layout_css=<this return>, gap_px=gap_px).
|
|
"""
|
|
if not plan.get("feasible"):
|
|
return None
|
|
new_preset = plan.get("new_layout_preset")
|
|
new_zones_data = plan.get("new_zones_data") or []
|
|
if not new_preset or not new_zones_data:
|
|
return None
|
|
from src.phase_z2_pipeline import build_layout_css
|
|
new_layout_css = dict(build_layout_css(new_preset, new_zones_data, gap=gap_px))
|
|
raw = dict(new_layout_css.get("raw_zone_layout") or {})
|
|
raw["layout_adjust_applied"] = True
|
|
raw["layout_adjust_from"] = plan.get("current_layout_preset")
|
|
raw["layout_adjust_to"] = new_preset
|
|
new_layout_css["raw_zone_layout"] = raw
|
|
return new_layout_css
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# IMP-88 u4 : image_fit — Step 17 single-pass image-scoped CSS override.
|
|
# Consumes overflow_metrics.image_events directly (natural_w/h, rendered_w/h,
|
|
# natural_ratio, rendered_ratio, delta). Honors feedback_phase_z_spacing
|
|
# _direction — image-scoped CSS only, no shared margin / frame envelope shrink.
|
|
# Plan-only — Step 17 entry (u7) is the runtime caller.
|
|
# Default delta_tol mirrors src.phase_z2_pipeline.IMAGE_ASPECT_DELTA_TOL = 0.05;
|
|
# overridable arg keeps tests free of the pipeline import cycle.
|
|
# ──────────────────────────────────────
|
|
|
|
|
|
def plan_image_fit(
|
|
*, image_event: dict, delta_tol: float = 0.05,
|
|
) -> dict:
|
|
"""Image_fit planner (Step 17 retry chain — IMP-88 u4).
|
|
|
|
Emits frame-scoped object-fit + max-width/height from a single
|
|
image_event (overflow_metrics.image_events). Image-scoped only.
|
|
"""
|
|
base = {
|
|
"action": "image_fit",
|
|
"src": image_event.get("src"),
|
|
"zone_position": image_event.get("zone_position"),
|
|
"zone_template_id": image_event.get("zone_template_id"),
|
|
}
|
|
delta = image_event.get("delta")
|
|
if delta is None:
|
|
return {
|
|
**base, "feasible": False, "css_overrides": None,
|
|
"failure_reason": (
|
|
"image_event delta is None — image not loaded; no aspect "
|
|
"mismatch can be measured."
|
|
),
|
|
}
|
|
if abs(float(delta)) <= float(delta_tol):
|
|
return {
|
|
**base, "feasible": False, "css_overrides": None,
|
|
"delta": float(delta),
|
|
"failure_reason": (
|
|
f"|delta|={abs(float(delta)):.4f} <= delta_tol={delta_tol} — "
|
|
f"no image_aspect_mismatch to correct (planner no-op)."
|
|
),
|
|
}
|
|
rendered_w = image_event.get("rendered_w")
|
|
rendered_h = image_event.get("rendered_h")
|
|
if not (isinstance(rendered_w, (int, float)) and rendered_w > 0
|
|
and isinstance(rendered_h, (int, float)) and rendered_h > 0):
|
|
return {
|
|
**base, "feasible": False, "css_overrides": None,
|
|
"delta": float(delta),
|
|
"failure_reason": (
|
|
"image_event missing positive rendered_w / rendered_h — "
|
|
"cannot bound max-width / max-height for image-scoped CSS."
|
|
),
|
|
}
|
|
return {
|
|
**base,
|
|
"feasible": True,
|
|
"delta": float(delta),
|
|
"natural_ratio": image_event.get("natural_ratio"),
|
|
"rendered_ratio": image_event.get("rendered_ratio"),
|
|
"natural_w": image_event.get("natural_w"),
|
|
"natural_h": image_event.get("natural_h"),
|
|
"rendered_w": int(rendered_w),
|
|
"rendered_h": int(rendered_h),
|
|
# delta > 0 ⇒ rendered_ratio > natural_ratio ⇒ rendered too wide ⇒
|
|
# width axis correction; delta < 0 ⇒ height axis correction.
|
|
"correction_axis": "width" if float(delta) > 0 else "height",
|
|
"css_overrides": {
|
|
"object_fit": "contain",
|
|
"max_width_px": int(rendered_w),
|
|
"max_height_px": int(rendered_h),
|
|
"width": "auto",
|
|
"height": "auto",
|
|
},
|
|
}
|
|
|
|
|
|
def apply_image_fit_css(plan: dict) -> Optional[str]:
|
|
"""Build a frame-scoped CSS snippet from a feasible image_fit plan.
|
|
|
|
Returns None when plan is infeasible. u7 (Step 17 entry) injects the
|
|
snippet into the per-slide style override and re-renders.
|
|
"""
|
|
if not plan.get("feasible"):
|
|
return None
|
|
overrides = plan.get("css_overrides") or {}
|
|
if not overrides:
|
|
return None
|
|
src = plan.get("src") or ""
|
|
zone_position = plan.get("zone_position") or ""
|
|
if src:
|
|
selector = (
|
|
f".zone[data-zone-position=\"{zone_position}\"] "
|
|
f"img[src=\"{src}\"]"
|
|
)
|
|
else:
|
|
selector = f".zone[data-zone-position=\"{zone_position}\"] img"
|
|
return (
|
|
f"{selector} {{\n"
|
|
f" object-fit: {overrides.get('object_fit', 'contain')};\n"
|
|
f" max-width: {int(overrides.get('max_width_px') or 0)}px;\n"
|
|
f" max-height: {int(overrides.get('max_height_px') or 0)}px;\n"
|
|
f" width: {overrides.get('width', 'auto')};\n"
|
|
f" height: {overrides.get('height', 'auto')};\n"
|
|
f"}}"
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# IMP-88 u5 : frame_internal_fit_candidate — Step 17 retry chain.
|
|
# Operates ONLY inside the frame contract's declared `internal_envelope`
|
|
# (PHASE-Z-PIPELINE-OVERVIEW.md:333 lock). Sub-mechanism names allowed by
|
|
# the OVERVIEW: density envelope / line rhythm / internal grid row / text
|
|
# block allocation — all unified under the single action label
|
|
# `frame_internal_fit_candidate` so common-CSS/padding shrink antipatterns
|
|
# stay quarantined ([[feedback_phase_z_spacing_direction]]).
|
|
#
|
|
# Envelope shape (dormant — catalog adds when contracts declare it):
|
|
# frame_contract["internal_envelope"] = {
|
|
# "variants": [
|
|
# {"name": "<sub_mechanism_name>",
|
|
# "excess_budget_px": <int — px of vertical excess this variant can absorb>,
|
|
# "css_overrides": {<css-property>: <value>, ...}},
|
|
# ...
|
|
# ]
|
|
# }
|
|
# Selection = walk variants in catalog order, pick first whose excess_budget_px
|
|
# >= effective excess_y (greedy). Catalog order = catalog author's priority.
|
|
#
|
|
# No frame contract currently declares `internal_envelope`, so the planner
|
|
# returns infeasible(envelope_present=False) for every live frame today.
|
|
# Cascade hand-off: NEXT_ACTION_BY_FAILURE['frame_internal_fit_candidate_
|
|
# insufficient'] = 'frame_reselect' (set in u2). Plan-only — u6 (salvage
|
|
# dispatcher) and u7 (Step 17 entry) own the runtime call site.
|
|
#
|
|
# frame_contract is an overridable kwarg so tests don't pay the mapper
|
|
# catalog cache cost / pipeline import cycle (mirrors u4's delta_tol).
|
|
# ──────────────────────────────────────
|
|
|
|
|
|
def plan_frame_internal_fit_candidate(
|
|
*, frame_template_id: str,
|
|
frame_contract: Optional[dict] = None,
|
|
overflow_zone: Optional[dict] = None,
|
|
) -> dict:
|
|
"""frame_internal_fit_candidate planner (Step 17 retry chain — IMP-88 u5).
|
|
|
|
Walks `frame_contract['internal_envelope']['variants']` in catalog order
|
|
and picks the first variant whose excess_budget_px covers `overflow_zone
|
|
['excess_y']`. Returns infeasible when no contract / no envelope / no
|
|
variant fits. No common-margin shrink — sub-mechanism CSS is frame-scoped.
|
|
"""
|
|
base = {
|
|
"action": "frame_internal_fit_candidate",
|
|
"frame_template_id": frame_template_id,
|
|
}
|
|
if frame_contract is None:
|
|
from src.phase_z2_mapper import get_contract
|
|
frame_contract = get_contract(frame_template_id)
|
|
if frame_contract is None:
|
|
return {
|
|
**base, "feasible": False, "envelope_present": False,
|
|
"candidates_considered": [], "selected_variant": None,
|
|
"css_overrides": None,
|
|
"failure_reason": (
|
|
f"no frame contract registered for template_id "
|
|
f"'{frame_template_id}' — cannot enumerate internal_envelope."
|
|
),
|
|
}
|
|
envelope = frame_contract.get("internal_envelope")
|
|
if not isinstance(envelope, dict):
|
|
return {
|
|
**base, "feasible": False, "envelope_present": False,
|
|
"candidates_considered": [], "selected_variant": None,
|
|
"css_overrides": None,
|
|
"failure_reason": (
|
|
f"frame contract '{frame_template_id}' does not declare "
|
|
f"internal_envelope — cascade should escalate to frame_reselect."
|
|
),
|
|
}
|
|
variants = list(envelope.get("variants") or [])
|
|
candidates_considered = [v.get("name") for v in variants if isinstance(v, dict)]
|
|
if not variants:
|
|
return {
|
|
**base, "feasible": False, "envelope_present": True,
|
|
"envelope_keys": sorted(envelope.keys()),
|
|
"candidates_considered": candidates_considered,
|
|
"selected_variant": None, "css_overrides": None,
|
|
"failure_reason": (
|
|
f"frame contract '{frame_template_id}' internal_envelope "
|
|
f"declares no variants — no sub-mechanism available."
|
|
),
|
|
}
|
|
excess_y = 0
|
|
if isinstance(overflow_zone, dict):
|
|
ey = overflow_zone.get("excess_y")
|
|
if isinstance(ey, (int, float)) and ey > 0:
|
|
excess_y = int(math.ceil(float(ey)))
|
|
selected: Optional[dict] = None
|
|
for variant in variants:
|
|
if not isinstance(variant, dict):
|
|
continue
|
|
budget = variant.get("excess_budget_px")
|
|
if not isinstance(budget, (int, float)):
|
|
continue
|
|
if int(budget) >= excess_y:
|
|
selected = variant
|
|
break
|
|
if selected is None:
|
|
return {
|
|
**base, "feasible": False, "envelope_present": True,
|
|
"envelope_keys": sorted(envelope.keys()),
|
|
"candidates_considered": candidates_considered,
|
|
"selected_variant": None, "css_overrides": None,
|
|
"excess_y": excess_y,
|
|
"failure_reason": (
|
|
f"all {len(variants)} internal_envelope variant(s) for "
|
|
f"'{frame_template_id}' have excess_budget_px below excess_y="
|
|
f"{excess_y}px — internal fit cannot absorb overflow."
|
|
),
|
|
}
|
|
overrides = selected.get("css_overrides") or {}
|
|
return {
|
|
**base, "feasible": True, "envelope_present": True,
|
|
"envelope_keys": sorted(envelope.keys()),
|
|
"candidates_considered": candidates_considered,
|
|
"selected_variant": selected.get("name"),
|
|
"selected_variant_budget_px": int(selected.get("excess_budget_px") or 0),
|
|
"excess_y": excess_y,
|
|
"css_overrides": dict(overrides),
|
|
}
|
|
|
|
|
|
def apply_frame_internal_fit_candidate_css(plan: dict) -> Optional[str]:
|
|
"""Build a frame-scoped CSS snippet from a feasible frame_internal_fit
|
|
plan. Returns None when plan is infeasible. u6 / u7 inject the snippet
|
|
into the per-slide style override and re-render.
|
|
"""
|
|
if not plan.get("feasible"):
|
|
return None
|
|
overrides = plan.get("css_overrides") or {}
|
|
if not overrides:
|
|
return None
|
|
template_id = plan.get("frame_template_id") or ""
|
|
if not template_id:
|
|
return None
|
|
selector = f".zone[data-template-id=\"{template_id}\"]"
|
|
body_lines = [
|
|
f" {prop}: {value};" for prop, value in overrides.items()
|
|
]
|
|
return f"{selector} {{\n" + "\n".join(body_lines) + "\n}"
|