diff --git a/src/phase_z2_failure_router.py b/src/phase_z2_failure_router.py
index 24761cf..3e1b3db 100644
--- a/src/phase_z2_failure_router.py
+++ b/src/phase_z2_failure_router.py
@@ -27,7 +27,9 @@ glue_compression (SPACING_GLUE envelope, frame-scoped)
↓ 그래도 안 되면
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
↓ 그래도 안 되면
-layout_adjust (zone topology 변경)
+layout_adjust (zone topology 변경 — 8-preset switch)
+ ↓ 그래도 안 되면
+frame_internal_fit_candidate (frame contract envelope 안 internal fit 변형)
↓ 그래도 안 되면
frame_reselect (V4 top-k 의 다른 frame)
↓ 그래도 안 되면
@@ -40,6 +42,33 @@ IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
terminal without claiming an implementation it does not own.
+
+IMP-88 (#88) u2 — Step 17 retry chain extension. Three new failure_type
+producers + cascade rows wire the three issue-body axes onto the deterministic
+chain WITHOUT activating any AI path or shared-margin shrink:
+
+ | failure_type | next_proposed_action |
+ |---|---|
+ | layout_adjust_insufficient | frame_internal_fit_candidate |
+ | frame_internal_fit_candidate_insufficient | frame_reselect |
+ | image_fit_insufficient | layout_adjust |
+
+`layout_adjust_insufficient` is the cascade extension between
+`font_step_insufficient → layout_adjust` (existing) and the legacy
+`rerender_still_fails → frame_reselect` rejoin point — closing the open
+cascade tail that previously terminated salvage at `layout_adjust` with no
+next-step record. `frame_internal_fit_candidate_insufficient` rejoins the
+existing `frame_reselect` mid-cascade, so V4 top-k swap remains reachable
+after the in-envelope salvage exhausts. `image_fit_insufficient` (Step 17
+single-pass entry per u7) escalates onto the main cascade at `layout_adjust`
+so an image-driven overflow that cannot be fit inside the frame envelope
+benefits from layout topology change instead of any margin shrink
+(feedback_phase_z_spacing_direction guardrail).
+
+The three new `next_action` destinations (`layout_adjust`,
+`frame_internal_fit_candidate`, `image_fit`) are advertised as MISSING here.
+The MISSING → IMPLEMENTED flip lives on the deterministic planner units
+(u3/u4/u5) in `src/phase_z2_retry.py`; this module owns mapping only.
"""
from __future__ import annotations
@@ -85,6 +114,28 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
"Routes to details_popup_escalation in u2 (cascade terminal)."
),
+ # IMP-88 (#88) u2 — three new salvage failure producers wired onto the
+ # deterministic cascade. Classifier reuses the salvage_steps[-1] path
+ # introduced in IMP-12 u2 (SALVAGE_FAILURE_TYPE_BY_ACTION).
+ "layout_adjust_insufficient": (
+ "layout_adjust salvage step failed — 8-preset layout switch executed "
+ "but overflow persists post-rerender. Cascade exits onto "
+ "frame_internal_fit_candidate (frame envelope internal fit variant) "
+ "before V4 top-k frame_reselect."
+ ),
+ "frame_internal_fit_candidate_insufficient": (
+ "frame_internal_fit_candidate salvage step failed — variant adjustments "
+ "inside the declared frame contract envelope could not absorb the "
+ "remaining overflow. Cascade exits onto frame_reselect (V4 top-k "
+ "alternate frame swap)."
+ ),
+ "image_fit_insufficient": (
+ "image_fit salvage step failed — Step 17 single-pass image fit "
+ "(object-fit + max-w/h scoped to the offending frame) did not resolve "
+ "image_aspect_mismatch. Escalates onto the main cascade at "
+ "layout_adjust so a different layout topology can host the image "
+ "natural ratio (no shared margin shrink — Phase Z spacing direction)."
+ ),
}
@@ -103,6 +154,17 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
# that routes this onto details_popup_escalation.
"frame_reselect": "frame_reselect_insufficient",
+ # IMP-88 (#88) u2: producers for the three Step 17 retry chain actions
+ # (layout_adjust / image_fit / frame_internal_fit_candidate). The u6
+ # dispatcher (src/phase_z2_pipeline.py) appends salvage_steps entries with
+ # these action names when their planner-driven executor (u3/u4/u5) emits
+ # passed=False. The classifier path below already inspects
+ # salvage_steps[-1].action so no classifier change is required; u3 just
+ # registers the producer rows so the cascade keeps flowing instead of
+ # falling through to the defensive "not_attempted" fallback.
+ "layout_adjust": "layout_adjust_insufficient",
+ "image_fit": "image_fit_insufficient",
+ "frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
}
@@ -124,6 +186,15 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
# only.
"frame_reselect_insufficient": "details_popup_escalation",
"not_attempted": "none",
+ # IMP-88 (#88) u2 — Step 17 retry chain cascade extension. Closes the
+ # previously open tail at layout_adjust + adds the frame_internal_fit
+ # mid-cascade rejoin onto frame_reselect. image_fit (single-pass entry,
+ # u7) escalates onto layout_adjust when its single-pass transform cannot
+ # resolve image_aspect_mismatch — Phase Z spacing direction guardrail
+ # routes through layout/frame instead of shrinking shared margins.
+ "layout_adjust_insufficient": "frame_internal_fit_candidate",
+ "frame_internal_fit_candidate_insufficient": "frame_reselect",
+ "image_fit_insufficient": "layout_adjust",
}
NEXT_ACTION_RATIONALE: dict[str, str] = {
@@ -161,6 +232,22 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
"not_attempted": (
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
),
+ # IMP-88 (#88) u2 — Step 17 retry chain cascade rationale entries
+ "layout_adjust_insufficient": (
+ "layout_adjust salvage (8-preset switch) 후에도 overflow 잔존 → "
+ "frame_internal_fit_candidate 로 frame contract envelope 안 internal "
+ "fit 변형 시도. frame_reselect (V4 top-k 다른 frame) 는 cascade 다음 단계."
+ ),
+ "frame_internal_fit_candidate_insufficient": (
+ "frame contract envelope 안 internal fit 변형 (density / line rhythm) "
+ "도 overflow 못 흡수 → frame_reselect (V4 top-k 다른 frame) 로 escalate. "
+ "popup 직행은 frame_reselect 까지 소진 후 (cascade terminal)."
+ ),
+ "image_fit_insufficient": (
+ "image_fit Step 17 single-pass (object-fit / max-w/h frame-scoped) 가 "
+ "image_aspect_mismatch 못 해결 → layout_adjust 로 main cascade 진입. "
+ "공통 image CSS / 공통 spacing 축소 X (Phase Z spacing direction)."
+ ),
}
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
@@ -174,7 +261,13 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
- "layout_adjust": "MISSING",
+ # IMP-88 (#88) u1→u7 (2026-05-24): layout_adjust flips here on the
+ # failure-router surface alongside the primary router surface. The
+ # cascade entry chains font_step_insufficient → layout_adjust and
+ # image_fit_insufficient → layout_adjust both reach this destination,
+ # which is now wired end-to-end via u3 (plan_layout_adjust) + u6
+ # (salvage dispatcher branch) + u7 (cascade entry trigger).
+ "layout_adjust": "IMPLEMENTED",
"frame_reselect": "MISSING",
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
# router executor stub + MISSING→IMPLEMENTED flip lives in
@@ -182,6 +275,18 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
# lands prevents premature "popup ready" claims from the failure-router
# surface.
"details_popup_escalation": "MISSING",
+ # IMP-88 (#88) u1→u7 (2026-05-24): Step 17 retry chain destinations
+ # flipped to IMPLEMENTED. frame_internal_fit_candidate is a cascade
+ # destination (layout_adjust_insufficient → frame_internal_fit_candidate)
+ # wired via u5 planner + u6 dispatcher branch + u7 cascade entry.
+ # image_fit is a Step 17 single-pass entry wired via u4 planner +
+ # u7 _attempt_step17_image_fit_single_pass; it also surfaces here so
+ # route_retry_failure never returns 'unknown' when image_fit_insufficient
+ # cascades onto layout_adjust. (Same precedent as IMP-12 u7 cascade
+ # actions above — planner-surface availability + orchestrator wiring
+ # together constitute IMPLEMENTED on the deterministic surface.)
+ "frame_internal_fit_candidate": "IMPLEMENTED",
+ "image_fit": "IMPLEMENTED",
"none": "n/a",
}
diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py
index daa2f1e..9495c6f 100644
--- a/src/phase_z2_pipeline.py
+++ b/src/phase_z2_pipeline.py
@@ -63,11 +63,17 @@ from phase_z2_retry import (
DEFAULT_SAFETY_MARGIN_PX,
apply_cross_zone_redistribute_css,
apply_font_step_compression_css,
+ apply_frame_internal_fit_candidate_css,
apply_glue_compression_css,
+ apply_image_fit_css,
+ apply_layout_adjust_layout_css,
apply_retry_to_layout_css,
plan_cross_zone_redistribute,
plan_font_step_compression,
+ plan_frame_internal_fit_candidate,
plan_glue_compression,
+ plan_image_fit,
+ plan_layout_adjust,
plan_zone_ratio_retry,
)
from phase_z2_failure_router import (
@@ -2571,10 +2577,16 @@ def _attempt_zone_ratio_retry(
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
+# IMP-88 u6 — extended with layout_adjust + frame_internal_fit_candidate dispatch.
+# Mirror of failure_router.SALVAGE_FAILURE_TYPE_BY_ACTION (single source-of-truth lives
+# there; this local map gates which actions the salvage loop can execute and feeds the
+# loop-cap range(len(...)) so cascade depth scales with implemented executors).
_SALVAGE_FAIL_BY_ACTION = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
+ "layout_adjust": "layout_adjust_insufficient",
+ "frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
}
@@ -2602,6 +2614,52 @@ def _attempt_salvage_chain(
trace["salvage_terminal_action"] = next_action
trace["salvage_terminal_rationale"] = routing.get("rationale")
return trace
+ # IMP-88 u6 — layout_adjust takes a distinct render path (fresh
+ # render_slide with the new preset + remapped zones_data + new
+ # layout_css), so it is dispatched BEFORE the shared CSS-overlay
+ # planner cluster below. No common margin / slide-body shrink
+ # ([[feedback_phase_z_spacing_direction]]) — topology swap only.
+ if next_action == "layout_adjust":
+ plan = plan_layout_adjust(
+ current_layout_preset=layout_preset, zones_data=zones_data)
+ new_layout_css = (
+ apply_layout_adjust_layout_css(plan, gap_px=gap_px)
+ if (plan and plan.get("feasible")) else None
+ )
+ candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
+ candidate_html, candidate_overflow, passed = None, None, False
+ if new_layout_css:
+ candidate_html = render_slide(
+ slide_title, slide_footer,
+ plan["new_zones_data"], plan["new_layout_preset"],
+ new_layout_css, gap_px=gap_px,
+ )
+ candidate_path.write_text(candidate_html, encoding="utf-8")
+ candidate_overflow = run_overflow_check(candidate_path)
+ passed = bool(candidate_overflow.get("passed", False))
+ step = {
+ "action": next_action, "plan": plan, "passed": passed,
+ "new_layout_preset": (
+ plan.get("new_layout_preset") if isinstance(plan, dict) else None
+ ),
+ "candidate_path": (
+ str(candidate_path.relative_to(PROJECT_ROOT))
+ if new_layout_css else None
+ ),
+ }
+ if passed:
+ out_path.write_text(candidate_html, encoding="utf-8")
+ step["post_salvage_overflow"] = candidate_overflow
+ trace["salvage_steps"].append(step)
+ trace["salvage_passed"] = True
+ return trace
+ step["failure_reason"] = (
+ (plan.get("failure_reason") if isinstance(plan, dict) else None)
+ or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
+ or "infeasible or render emitted no candidate")
+ trace["salvage_steps"].append(step)
+ failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
+ continue
if next_action == "cross_zone_redistribute":
if ci.get("fit_analysis") is None:
plan = {"action": "cross_zone_redistribute", "feasible": False,
@@ -2617,6 +2675,21 @@ def _attempt_salvage_chain(
block_count=int(ci.get("block_count") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_glue_compression_css
+ elif next_action == "frame_internal_fit_candidate":
+ # IMP-88 u6 — per-zone frame-scoped envelope variant. Resolves
+ # frame_template_id from zones_data via cascade_inputs.zone_position
+ # so the planner stays within the frame contract envelope
+ # (no shared margin shrink per [[feedback_phase_z_spacing_direction]]).
+ _target_pos = str(ci.get("zone_position") or "")
+ _target_zone = next(
+ (z for z in zones_data if z.get("position") == _target_pos), {},
+ ) or {}
+ _frame_tid = str(_target_zone.get("template_id") or "")
+ plan = plan_frame_internal_fit_candidate(
+ frame_template_id=_frame_tid,
+ overflow_zone={"excess_y": float(ci.get("excess_px") or 0.0)},
+ )
+ apply_fn = apply_frame_internal_fit_candidate_css
else:
plan = plan_font_step_compression(
current_font_px=float(ci.get("current_font_px") or 0.0),
@@ -2653,6 +2726,89 @@ def _attempt_salvage_chain(
return trace
+# IMP-88 (#88) u7 — Step 17 image_fit single-pass entry executor.
+# image_fit is NOT a salvage cascade stage (deliberately OUT of
+# _SALVAGE_FAIL_BY_ACTION per u6 guard). Instead it is a Step 17 ENTRY
+# single-pass: aggregate per-image plan/apply via plan_image_fit (u4) +
+# apply_image_fit_css (u4), emit one CSS overlay, re-render once,
+# run_overflow_check. PASS promotes final.html; FAIL records `image_fit`
+# step with failure_reason so failure_router (u2)
+# SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as `image_fit_insufficient`
+# and the cascade entry block routes onto layout_adjust.
+# Honors [[feedback_phase_z_spacing_direction]] — frame-scoped img CSS only,
+# no common margin / slide-body / zone gap shrink. AI isolation contract
+# (PZ-1) — deterministic data-surface, no AI call.
+def _attempt_step17_image_fit_single_pass(
+ *, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
+ zones_data: list[dict], layout_preset: str, layout_css: dict,
+ image_events: list[dict], gap_px: int,
+ delta_tol: float = IMAGE_ASPECT_DELTA_TOL,
+) -> dict:
+ """IMP-88 u7 — Step 17 image_fit single-pass executor.
+
+ Returns a result dict with shape:
+ - triggered : bool — True iff any feasible image_fit plan emitted CSS.
+ - passed : bool — True iff post-rerender overflow check passed.
+ - step : dict|None — salvage_steps[] entry shape (action="image_fit",
+ image_fit_event_plans, candidate_path, post_salvage_overflow
+ on pass / failure_reason on fail).
+ - candidate_html : str|None — rendered HTML when triggered (None otherwise).
+ - candidate_overflow : dict|None — run_overflow_check result when triggered.
+ - event_plans : list[dict] — every plan_image_fit result (feasible + no-op),
+ surfaced for telemetry even when none emit CSS.
+ Side effect on PASS only: writes candidate_html to out_path.
+ """
+ event_plans = []
+ css_chunks = []
+ for ev in (image_events or []):
+ plan = plan_image_fit(image_event=ev, delta_tol=delta_tol)
+ event_plans.append(plan)
+ if plan.get("feasible"):
+ css = apply_image_fit_css(plan)
+ if css:
+ css_chunks.append(css)
+ if not css_chunks:
+ return {
+ "triggered": False, "passed": False, "step": None,
+ "candidate_html": None, "candidate_overflow": None,
+ "event_plans": event_plans,
+ }
+ candidate_path = run_dir / "salvage_image_fit_candidate.html"
+ base = render_slide(
+ slide_title, slide_footer, zones_data, layout_preset, layout_css,
+ gap_px=gap_px,
+ )
+ style = ""
+ candidate_html = (
+ base.replace("", f"{style}\n", 1)
+ if "" in base else style + base
+ )
+ candidate_path.write_text(candidate_html, encoding="utf-8")
+ candidate_overflow = run_overflow_check(candidate_path)
+ passed = bool(candidate_overflow.get("passed", False))
+ step = {
+ "action": "image_fit", "passed": passed,
+ "image_fit_event_plans": event_plans,
+ "candidate_path": (
+ str(candidate_path.relative_to(PROJECT_ROOT))
+ if candidate_path.is_absolute() else str(candidate_path)
+ ),
+ }
+ if passed:
+ step["post_salvage_overflow"] = candidate_overflow
+ out_path.write_text(candidate_html, encoding="utf-8")
+ else:
+ step["failure_reason"] = (
+ candidate_overflow.get("fail_reasons")
+ or "image_fit single-pass: overflow persists"
+ )
+ return {
+ "triggered": True, "passed": passed, "step": step,
+ "candidate_html": candidate_html, "candidate_overflow": candidate_overflow,
+ "event_plans": event_plans,
+ }
+
+
def _remeasure_after_frame_reselect(
*, candidate_path: Path, plan: Optional[dict] = None,
) -> dict:
@@ -6370,6 +6526,144 @@ def run_phase_z2_mvp1(
# fields become None (no failure to classify, no escalation pending).
enrich_retry_trace_with_failure_classification(retry_trace)
+ # 11.7.1 IMP-88 (#88) u7 — Step 17 image_fit single-pass entry trigger.
+ # Activates when router proposes `image_fit` (image_aspect_mismatch
+ # classifications, ACTION_BY_CATEGORY row added in u1). Single-pass executor
+ # (`_attempt_step17_image_fit_single_pass`): per-image plan/apply via
+ # plan_image_fit + apply_image_fit_css → aggregated CSS overlay → single
+ # re-render → run_overflow_check. PASS promotes final.html and refreshes
+ # Step 17/18/19 state; FAIL records `image_fit` step with failure_reason
+ # so failure_router (u2) SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as
+ # `image_fit_insufficient` and the §11.7.2 cascade entry block routes
+ # onto layout_adjust. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION
+ # (u6 guard) — Step 17 ENTRY single-pass, not a salvage cascade stage.
+ if (
+ not retry_trace.get("retry_passed")
+ and not retry_trace.get("salvage_passed")
+ and "image_fit" in set(router_decision.get("proposed_actions_summary") or [])
+ ):
+ _img_result = _attempt_step17_image_fit_single_pass(
+ run_dir=run_dir, out_path=out_path,
+ slide_title=slide_title, slide_footer=slide_footer,
+ zones_data=zones_data, layout_preset=layout_preset,
+ layout_css=layout_css,
+ image_events=overflow.get("image_events") or [],
+ gap_px=GRID_GAP,
+ )
+ if _img_result["triggered"]:
+ retry_trace.setdefault("salvage_steps", []).append(_img_result["step"])
+ retry_trace["salvage_attempted"] = True
+ if _img_result["passed"]:
+ retry_trace["salvage_passed"] = True
+ overflow = _img_result["candidate_overflow"]
+ fit_classification = classify_visual_runtime_check(overflow, debug_zones)
+ router_decision = route_fit_classification(fit_classification)
+ router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
+ router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
+ router_decision["frame_reselect_fallback_status"] = (
+ "pre_render_rank_2_3_fallback_implemented; "
+ "post_render visual-fail rerender remains routed through existing action trace"
+ )
+ enrich_retry_trace_with_failure_classification(retry_trace)
+
+ # 11.7.2 IMP-88 (#88) u7 — direct entry triggers for layout_adjust /
+ # frame_internal_fit_candidate when the donor_slack pathway above did not
+ # engage but the router proposes one of these actions (e.g. moderate_overflow
+ # / layout_zone_mismatch → layout_adjust; frame_capacity_mismatch →
+ # frame_internal_fit_candidate per u1 ACTION_BY_CATEGORY). Re-uses
+ # _attempt_salvage_chain with a synthetic initial_failure_type that
+ # routes (via failure_router NEXT_ACTION_BY_FAILURE / u2) onto the proposed
+ # action, so the cascade tail (frame_reselect → details_popup_escalation)
+ # stays consistent with the donor_slack pathway. Also handles the
+ # image_fit single-pass FAIL → image_fit_insufficient escalation onto
+ # the cascade (u2 row routes image_fit_insufficient → layout_adjust).
+ if not retry_trace.get("retry_passed") and not retry_trace.get("salvage_passed"):
+ _u7_proposed = set(router_decision.get("proposed_actions_summary") or [])
+ _u7_last_step = (retry_trace.get("salvage_steps") or [])
+ _u7_image_fit_failed = bool(
+ _u7_last_step
+ and _u7_last_step[-1].get("action") == "image_fit"
+ and _u7_last_step[-1].get("passed") is False
+ )
+ _u7_initial = None
+ if _u7_image_fit_failed:
+ _u7_initial = "image_fit_insufficient"
+ elif "layout_adjust" in _u7_proposed:
+ _u7_initial = "font_step_insufficient"
+ elif "frame_internal_fit_candidate" in _u7_proposed:
+ _u7_initial = "layout_adjust_insufficient"
+ if _u7_initial:
+ _u7_cls_list = fit_classification.get("classifications") or []
+ _u7_entry_cls = next(
+ (c for c in _u7_cls_list
+ if c.get("proposed_action") in {
+ "layout_adjust", "frame_internal_fit_candidate", "image_fit",
+ }),
+ {},
+ )
+ _u7_tpos = _u7_entry_cls.get("zone_position") or ""
+ _u7_tdz = next(
+ (dz for dz in debug_zones if dz.get("position") == _u7_tpos), {},
+ ) or {}
+ _u7_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
+ _u7_zm = _u7_zof.get(_u7_tpos) or {}
+ _u7_excess = max(
+ 0.0,
+ float(_u7_zm.get("scrollHeight") or 0.0)
+ - float(_u7_zm.get("clientHeight") or 0.0),
+ )
+ from src.fit_verifier import FitAnalysis, RoleFit
+ _u7_fa_roles, _u7_fa_containers = {}, {}
+ for _u7_dz in debug_zones:
+ _u7_pos = _u7_dz.get("position")
+ if not _u7_pos:
+ continue
+ _u7_alloc = float(_u7_dz.get("height_px") or 0.0)
+ _u7_zmi = _u7_zof.get(_u7_pos) or {}
+ _u7_ch = float(_u7_zmi.get("clientHeight") or _u7_alloc)
+ _u7_sh = float(_u7_zmi.get("scrollHeight") or _u7_ch)
+ _u7_fa_roles[_u7_pos] = RoleFit(
+ role=_u7_pos, allocated_px=_u7_alloc,
+ shortfall_px=_u7_sh - _u7_ch,
+ )
+ _u7_fa_containers[_u7_pos] = {
+ "zone": "slide_body", "height_px": int(_u7_alloc),
+ }
+ _u7_salvage = _attempt_salvage_chain(
+ run_dir=run_dir, out_path=out_path,
+ slide_title=slide_title, slide_footer=slide_footer,
+ zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
+ cascade_inputs={
+ "fit_analysis": FitAnalysis(roles=_u7_fa_roles),
+ "containers": _u7_fa_containers,
+ "min_margin_px": None,
+ "excess_px": _u7_excess, "excess_after_glue_px": _u7_excess,
+ "block_count": len(
+ (_u7_tdz.get("placement_trace") or {}).get("internal_regions") or []
+ ) or 1,
+ "zone_position": _u7_tpos,
+ "current_font_px": float(_u7_tdz.get("font_size_px") or 0.0),
+ "available_lines": int(_u7_tdz.get("available_lines") or 0),
+ "chars_per_line": int(_u7_tdz.get("chars_per_line") or 0),
+ },
+ initial_failure_type=_u7_initial, gap_px=GRID_GAP,
+ )
+ _u7_prior_steps = retry_trace.get("salvage_steps") or []
+ _u7_new_steps = _u7_salvage.get("salvage_steps") or []
+ retry_trace.update(_u7_salvage)
+ retry_trace["salvage_steps"] = _u7_prior_steps + _u7_new_steps
+ if _u7_salvage.get("salvage_passed"):
+ overflow = (_u7_new_steps[-1].get("post_salvage_overflow")) or overflow
+ fit_classification = classify_visual_runtime_check(overflow, debug_zones)
+ router_decision = route_fit_classification(fit_classification)
+ router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
+ router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
+ router_decision["frame_reselect_fallback_status"] = (
+ "pre_render_rank_2_3_fallback_implemented; "
+ "post_render visual-fail rerender remains routed through existing action trace"
+ )
+ enrich_retry_trace_with_failure_classification(retry_trace)
+
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
# Runs after the salvage cascade exits at cascade-terminal action
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
@@ -6433,7 +6727,9 @@ def run_phase_z2_mvp1(
note=(
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
"(cross_zone_redistribute → glue_compression → font_step_compression). "
- "Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
+ "IMP-88 u6/u7 — salvage cascade extended with layout_adjust + "
+ "frame_internal_fit_candidate; image_fit single-pass entry triggered "
+ "from router (cascade tail frame_reselect remains PARTIAL pre-render only)."
),
)
diff --git a/src/phase_z2_retry.py b/src/phase_z2_retry.py
index f1d36ee..c5b2071 100644
--- a/src/phase_z2_retry.py
+++ b/src/phase_z2_retry.py
@@ -428,3 +428,377 @@ def apply_font_step_compression_css(plan: dict) -> str:
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=, 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": "",
+# "excess_budget_px": ,
+# "css_overrides": {: , ...}},
+# ...
+# ]
+# }
+# 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}"
diff --git a/src/phase_z2_router.py b/src/phase_z2_router.py
index bbb7a08..9d5336e 100644
--- a/src/phase_z2_router.py
+++ b/src/phase_z2_router.py
@@ -25,13 +25,27 @@ from typing import Optional
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
# category → proposed_action (primary)
+# IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the
+# primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17
+# spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321):
+# 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped
+# classifier emission (phase_z2_classifier.py:434-447) that previously
+# returned proposed_action=None and stalled visual_check on overflow
+# runs carrying image_event payloads.
+# 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
+# (previously frame_reselect) — OVERVIEW.md Step 17 locks
+# frame_internal_fit_candidate as the per-zone first-pass salvage
+# *inside* the declared frame envelope; frame_reselect (V4 top-k
+# alternate frame swap) stays available downstream via the
+# failure_router cascade (rerender_still_fails → frame_reselect).
ACTION_BY_CATEGORY: dict[str, str] = {
"minor_overflow": "zone_ratio_retry",
"moderate_overflow": "layout_adjust",
"structural_minor_overflow": "zone_ratio_retry",
"structural_major_overflow": "details_popup_escalation",
"tabular_overflow": "details_popup_escalation",
- "frame_capacity_mismatch": "frame_reselect",
+ "image_aspect_mismatch": "image_fit",
+ "frame_capacity_mismatch": "frame_internal_fit_candidate",
"layout_zone_mismatch": "layout_adjust",
"hard_visual_fail": "abort",
}
@@ -48,8 +62,13 @@ ACTION_RATIONALE: dict[str, str] = {
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
"tabular_overflow":
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
+ "image_aspect_mismatch":
+ "image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / "
+ "max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).",
"frame_capacity_mismatch":
- "composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가",
+ "composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope "
+ "안 internal fit 변형 (density / line rhythm / row 배치) 우선. "
+ "frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).",
"layout_zone_mismatch":
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
"hard_visual_fail":
@@ -61,7 +80,21 @@ ACTION_RATIONALE: dict[str, str] = {
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
- "layout_adjust": "MISSING",
+ # IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered
+ # here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5
+ # landed the deterministic planners in src/phase_z2_retry.py. u6 wired the
+ # salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17
+ # entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2).
+ # Status flips MISSING→IMPLEMENTED land here on u7 completion — once the
+ # end-to-end path (planner + apply + dispatcher + entry) is wired the
+ # action is IMPLEMENTED on the deterministic surface. (Same convention as
+ # IMP-12 u7 cascade rows below: planner-surface availability + orchestrator
+ # wiring together constitute IMPLEMENTED; route_action's
+ # implementation_status field reflects surface availability, not whether a
+ # given pipeline run has invoked the action.)
+ "layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry
+ "image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry
+ "frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
# `plan_details_popup_escalation` (below) provides the deterministic stub
# that downstream units consume: u4 binds the AI split-decision contract
diff --git a/tests/phase_z2/test_failure_router_imp88_cascade.py b/tests/phase_z2/test_failure_router_imp88_cascade.py
new file mode 100644
index 0000000..6f6c300
--- /dev/null
+++ b/tests/phase_z2/test_failure_router_imp88_cascade.py
@@ -0,0 +1,299 @@
+"""IMP-88 (#88) u2 — failure_router cascade extension tests.
+
+Stage 2 binding contract (unit u2): the failure_router data-surface is
+extended so the three Step 17 retry chain actions (layout_adjust, image_fit,
+frame_internal_fit_candidate) participate in the deterministic cascade
+WITHOUT activating any AI path or shrinking shared margins.
+
+Producer surface (`SALVAGE_FAILURE_TYPE_BY_ACTION`):
+ - layout_adjust → layout_adjust_insufficient
+ - image_fit → image_fit_insufficient
+ - frame_internal_fit_candidate → frame_internal_fit_candidate_insufficient
+
+Cascade extension (`NEXT_ACTION_BY_FAILURE`):
+ - layout_adjust_insufficient → frame_internal_fit_candidate
+ - frame_internal_fit_candidate_insufficient → frame_reselect
+ - image_fit_insufficient → layout_adjust
+
+Implementation status surface (`NEXT_ACTION_IMPLEMENTATION_STATUS`):
+ - layout_adjust = IMPLEMENTED (u3 planner + u6 dispatcher + u7 entry)
+ - image_fit = IMPLEMENTED (u4 planner + u7 Step 17 single-pass entry)
+ - frame_internal_fit_candidate = IMPLEMENTED (u5 planner + u6 dispatcher + u7 entry)
+ - frame_reselect = MISSING (separate axis, out of IMP-88 scope)
+ - details_popup_escalation = MISSING here; flipped on the router surface
+ (src/phase_z2_router.py) by IMP-35 u3.
+
+Existing rows from IMP-12 / IMP-35 (#62 / #64) are guarded against regression.
+
+Post u7 completion (2026-05-24): status assertions in this file reflect the
+IMPLEMENTED end-state. The `_registered_as_missing` test name is renamed to
+`_registered_as_implemented_after_u7` so the surface contract is honest
+about the post-u7 state.
+"""
+from __future__ import annotations
+
+from src.phase_z2_failure_router import (
+ FAILURE_TYPE_DESCRIPTIONS,
+ NEXT_ACTION_BY_FAILURE,
+ NEXT_ACTION_IMPLEMENTATION_STATUS,
+ NEXT_ACTION_RATIONALE,
+ SALVAGE_FAILURE_TYPE_BY_ACTION,
+ classify_retry_failure,
+ enrich_retry_trace_with_failure_classification,
+ route_retry_failure,
+)
+
+
+# ─── FAILURE_TYPE_DESCRIPTIONS registry ──────────────────────────
+
+
+def test_imp88_three_new_failure_type_descriptions_registered():
+ """u2 registers three new failure_type descriptions for the Step 17
+ retry chain actions. Each entry is non-empty so trace consumers can
+ surface a human-readable failure reason."""
+ for ftype in (
+ "layout_adjust_insufficient",
+ "image_fit_insufficient",
+ "frame_internal_fit_candidate_insufficient",
+ ):
+ assert ftype in FAILURE_TYPE_DESCRIPTIONS, (
+ f"u2 must register {ftype} in FAILURE_TYPE_DESCRIPTIONS"
+ )
+ assert FAILURE_TYPE_DESCRIPTIONS[ftype].strip(), (
+ f"FAILURE_TYPE_DESCRIPTIONS[{ftype!r}] must be non-empty"
+ )
+
+
+# ─── SALVAGE_FAILURE_TYPE_BY_ACTION producers ─────────────────────
+
+
+def test_imp88_three_new_salvage_failure_producers_registered():
+ """u2 wires three new producers so when the u6 dispatcher emits a
+ salvage_steps[-1] entry whose action is one of the IMP-88 actions, the
+ classifier route lands on the correct failure_type instead of falling
+ through to the defensive not_attempted fallback."""
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["layout_adjust"] == (
+ "layout_adjust_insufficient"
+ )
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["image_fit"] == "image_fit_insufficient"
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_internal_fit_candidate"] == (
+ "frame_internal_fit_candidate_insufficient"
+ )
+
+
+def test_imp88_existing_salvage_producers_preserved():
+ """Regression guard — IMP-12 / IMP-35 producers stay intact after u2."""
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["cross_zone_redistribute"] == (
+ "cross_zone_redistribute_insufficient"
+ )
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["glue_compression"] == (
+ "glue_absorption_insufficient"
+ )
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["font_step_compression"] == (
+ "font_step_insufficient"
+ )
+ assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_reselect"] == (
+ "frame_reselect_insufficient"
+ )
+
+
+# ─── NEXT_ACTION_BY_FAILURE cascade ───────────────────────────────
+
+
+def test_imp88_layout_adjust_insufficient_routes_to_frame_internal_fit():
+ """Cascade extension: layout_adjust_insufficient → frame_internal_fit_candidate.
+
+ Closes the previously open cascade tail at layout_adjust (font_step_insufficient
+ → layout_adjust was the last existing row that could land on layout_adjust;
+ after layout_adjust executed and failed there was no NEXT_ACTION row, so the
+ dispatcher would terminate). u2 adds the frame envelope internal-fit step
+ before frame_reselect.
+ """
+ assert NEXT_ACTION_BY_FAILURE["layout_adjust_insufficient"] == (
+ "frame_internal_fit_candidate"
+ )
+
+ nr = route_retry_failure("layout_adjust_insufficient")
+ assert nr["next_proposed_action"] == "frame_internal_fit_candidate"
+ # frame_internal_fit_candidate is IMPLEMENTED after u5 planner + u6
+ # dispatcher branch + u7 cascade entry.
+ assert nr["next_action_implementation_status"] == "IMPLEMENTED"
+ assert "frame_internal_fit_candidate" in (nr["next_action_rationale"] or "")
+
+
+def test_imp88_frame_internal_fit_insufficient_routes_to_frame_reselect():
+ """frame_internal_fit_candidate_insufficient → frame_reselect (V4 top-k swap).
+
+ Rejoins the existing rerender_still_fails → frame_reselect path mid-cascade.
+ """
+ assert NEXT_ACTION_BY_FAILURE["frame_internal_fit_candidate_insufficient"] == (
+ "frame_reselect"
+ )
+
+ nr = route_retry_failure("frame_internal_fit_candidate_insufficient")
+ assert nr["next_proposed_action"] == "frame_reselect"
+ # frame_reselect is OUT of IMP-88 scope and stays MISSING (separate axis).
+ assert nr["next_action_implementation_status"] == "MISSING"
+
+
+def test_imp88_image_fit_insufficient_routes_to_layout_adjust():
+ """image_fit (Step 17 single-pass entry per u7) escalates onto the main
+ cascade at layout_adjust when the single-pass image fit transform cannot
+ resolve image_aspect_mismatch. Phase Z spacing direction guardrail —
+ no shared margin shrink, escalate through layout topology change instead.
+ """
+ assert NEXT_ACTION_BY_FAILURE["image_fit_insufficient"] == "layout_adjust"
+
+ nr = route_retry_failure("image_fit_insufficient")
+ assert nr["next_proposed_action"] == "layout_adjust"
+ # layout_adjust is IMPLEMENTED after u3 planner + u6 dispatcher branch +
+ # u7 cascade entry. Cascade now flows end-to-end on the deterministic path.
+ assert nr["next_action_implementation_status"] == "IMPLEMENTED"
+ # Rationale must not claim a margin shrink (Phase Z spacing direction
+ # guardrail anchored at feedback_phase_z_spacing_direction).
+ rationale = (nr["next_action_rationale"] or "").lower()
+ assert "shrink" not in rationale
+ assert "축소 x" in rationale or "축소x" in rationale or "spacing direction" in rationale
+
+
+def test_imp88_existing_cascade_rows_preserved():
+ """Regression guard — the IMP-12 / IMP-35 cascade rows stay intact after u2."""
+ assert NEXT_ACTION_BY_FAILURE["donor_slack_insufficient"] == "cross_zone_redistribute"
+ assert NEXT_ACTION_BY_FAILURE["no_donor_candidates"] == "cross_zone_redistribute"
+ assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
+ assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
+ assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
+ assert NEXT_ACTION_BY_FAILURE["rerender_still_fails"] == "frame_reselect"
+ assert NEXT_ACTION_BY_FAILURE["frame_reselect_insufficient"] == "details_popup_escalation"
+ assert NEXT_ACTION_BY_FAILURE["not_attempted"] == "none"
+
+
+# ─── NEXT_ACTION_IMPLEMENTATION_STATUS surface ────────────────────
+
+
+def test_imp88_new_next_action_destinations_registered_as_implemented_after_u7():
+ """u2 registered the two new cascade destinations (initial MISSING). After
+ u7 completion the rows flip to IMPLEMENTED on the failure-router surface:
+ frame_internal_fit_candidate via u5 planner + u6 dispatcher + u7 cascade
+ entry; image_fit via u4 planner + u7 Step 17 single-pass entry. (Same
+ precedent as IMP-12 u7 cascade actions — planner-surface + orchestrator
+ wiring together constitute IMPLEMENTED on the deterministic surface.)"""
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["frame_internal_fit_candidate"] == "IMPLEMENTED"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["image_fit"] == "IMPLEMENTED"
+
+
+def test_imp88_existing_implementation_status_preserved():
+ """Regression guard — IMP-12 u7 + IMP-35 u3 status rows stay intact.
+ Post u7 completion, layout_adjust on the failure-router surface flips
+ to IMPLEMENTED alongside the primary router surface (u3 planner + u6
+ dispatcher + u7 cascade entry)."""
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["cross_zone_redistribute"] == "IMPLEMENTED"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["frame_reselect"] == "MISSING"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "MISSING"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["none"] == "n/a"
+
+
+# ─── End-to-end classifier + router path ──────────────────────────
+
+
+def test_imp88_three_new_salvage_failures_classifier_path_end_to_end():
+ """End-to-end: a salvage_steps[-1] entry with one of the three IMP-88
+ actions (layout_adjust / image_fit / frame_internal_fit_candidate) and
+ passed=False routes through classifier → router and lands on the
+ expected cascade next action. Confirms u2 producer + cascade rows are
+ wired through `classify_retry_failure` + `route_retry_failure` together.
+ """
+ cases = [
+ (
+ "layout_adjust",
+ "layout_adjust_insufficient",
+ "frame_internal_fit_candidate",
+ ),
+ (
+ "frame_internal_fit_candidate",
+ "frame_internal_fit_candidate_insufficient",
+ "frame_reselect",
+ ),
+ (
+ "image_fit",
+ "image_fit_insufficient",
+ "layout_adjust",
+ ),
+ ]
+ for action, expected_ftype, expected_next in cases:
+ trace = {
+ "retry_attempted": True,
+ "retry_passed": False,
+ "salvage_passed": False,
+ "salvage_steps": [
+ {
+ "action": action,
+ "passed": False,
+ "failure_reason": f"{action} salvage failed (test)",
+ }
+ ],
+ }
+ fc = classify_retry_failure(trace)
+ assert fc is not None, f"classifier returned None for action={action}"
+ assert fc["failure_type"] == expected_ftype, (
+ f"classifier emitted {fc['failure_type']!r} for action={action}, "
+ f"expected {expected_ftype!r}"
+ )
+ nr = route_retry_failure(fc["failure_type"])
+ assert nr["next_proposed_action"] == expected_next, (
+ f"router routed {fc['failure_type']!r} → {nr['next_proposed_action']!r}, "
+ f"expected {expected_next!r}"
+ )
+
+
+def test_imp88_enrichment_composes_layout_adjust_insufficient_proposal():
+ """End-to-end via enrich_retry_trace_with_failure_classification — the
+ deterministic Step 17 dispatcher will read these fields off the trace,
+ so verify the public wrapper attaches both failure_classification and
+ next_action_proposal correctly for the layout_adjust salvage path."""
+ trace = {
+ "retry_attempted": True,
+ "retry_passed": False,
+ "salvage_passed": False,
+ "salvage_steps": [
+ {
+ "action": "layout_adjust",
+ "passed": False,
+ "failure_reason": "layout_adjust preset switch did not fit",
+ }
+ ],
+ }
+ enrich_retry_trace_with_failure_classification(trace)
+ assert trace["failure_classification"]["failure_type"] == (
+ "layout_adjust_insufficient"
+ )
+ assert trace["next_action_proposal"]["next_proposed_action"] == (
+ "frame_internal_fit_candidate"
+ )
+ # frame_internal_fit_candidate is IMPLEMENTED after u5 planner + u6
+ # dispatcher branch + u7 cascade entry.
+ assert trace["next_action_proposal"]["next_action_implementation_status"] == (
+ "IMPLEMENTED"
+ )
+
+
+# ─── Rationale registry coverage ──────────────────────────────────
+
+
+def test_imp88_three_new_failure_rationales_registered():
+ """u2 registers a rationale entry for each new failure_type so the
+ enrichment wrapper emits a non-empty rationale (debug-trace usability)."""
+ for ftype in (
+ "layout_adjust_insufficient",
+ "image_fit_insufficient",
+ "frame_internal_fit_candidate_insufficient",
+ ):
+ assert ftype in NEXT_ACTION_RATIONALE, (
+ f"u2 must register {ftype} in NEXT_ACTION_RATIONALE"
+ )
+ assert NEXT_ACTION_RATIONALE[ftype].strip(), (
+ f"NEXT_ACTION_RATIONALE[{ftype!r}] must be non-empty"
+ )
diff --git a/tests/phase_z2/test_phase_z2_failure_router_cascade.py b/tests/phase_z2/test_phase_z2_failure_router_cascade.py
index 771a106..f431478 100644
--- a/tests/phase_z2/test_phase_z2_failure_router_cascade.py
+++ b/tests/phase_z2/test_phase_z2_failure_router_cascade.py
@@ -77,10 +77,14 @@ def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
- # Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
+ # Implementation status: 3 cascade entries IMPLEMENTED.
+ # layout_adjust was MISSING pre-IMP-88; IMP-88 u7 (2026-05-24) flipped it
+ # to IMPLEMENTED on the failure-router surface alongside the primary
+ # router surface (u3 plan_layout_adjust + u6 dispatcher branch + u7
+ # cascade entry trigger).
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
- assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
+ assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
# Classifier path via salvage_steps[-1].action → failure_type → next action
cases = [
diff --git a/tests/phase_z2/test_phase_z2_pipeline_salvage_imp88.py b/tests/phase_z2/test_phase_z2_pipeline_salvage_imp88.py
new file mode 100644
index 0000000..fff7117
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_pipeline_salvage_imp88.py
@@ -0,0 +1,483 @@
+"""IMP-88 u6 — Step 17 salvage cascade dispatcher tests for the two
+new branches (`layout_adjust` + `frame_internal_fit_candidate`).
+
+Stage 2 binding contract (u6):
+ - Extend `_SALVAGE_FAIL_BY_ACTION` to include the two IMP-88 actions so
+ the salvage loop range adapts and the cascade does not exit early at
+ `layout_adjust` / `frame_internal_fit_candidate` (previously terminal
+ in the 3-entry map, now executable).
+ - `layout_adjust` takes a distinct render path: it calls `render_slide`
+ with the NEW preset + remapped zones_data + new layout_css (built via
+ `apply_layout_adjust_layout_css`). No CSS overlay — topology swap only
+ (honors `[[feedback_phase_z_spacing_direction]]`: no common margin
+ shrink, no slide-body shrink).
+ - `frame_internal_fit_candidate` uses the shared CSS-overlay path
+ (same as font_step_compression / glue_compression) because the
+ planner emits a frame-scoped CSS rule via
+ `apply_frame_internal_fit_candidate_css`.
+
+Test surfaces (8 tests):
+ 1. `_SALVAGE_FAIL_BY_ACTION` map registers the two new actions with
+ the failure_type names the failure_router (u2) cascade rows expect.
+ 2. `layout_adjust` PASS — out_path promoted with the swapped render,
+ step records new_layout_preset, cascade exits.
+ 3. `layout_adjust` infeasible (no sibling for `single` preset) — step
+ records failure_reason without invoking render_slide; cascade
+ advances to frame_internal_fit_candidate.
+ 4. `layout_adjust` rerender FAIL — cascade advances to
+ frame_internal_fit_candidate (which then PASSes via patched
+ envelope).
+ 5. `frame_internal_fit_candidate` PASS via patched envelope — out_path
+ promoted with CSS-overlay candidate.
+ 6. `frame_internal_fit_candidate` no-envelope — step records
+ envelope_present=False; cascade exits via frame_reselect terminal.
+ 7. Full 5-step cascade all fail — loop cap (range(len(map))=5)
+ respected; exactly 5 steps recorded; out_path preserved.
+ 8. `layout_adjust` uses no CSS-overlay path (css_override field is
+ absent from the layout_adjust step; new_layout_preset is present).
+"""
+from __future__ import annotations
+
+import shutil
+import tempfile
+from pathlib import Path
+
+import pytest
+
+import src.phase_z2_mapper as _pz_mapper
+import src.phase_z2_pipeline as _pz_pipeline
+from src.phase_z2_pipeline import _SALVAGE_FAIL_BY_ACTION, _attempt_salvage_chain
+
+
+_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
+
+
+@pytest.fixture
+def project_tmp(tmp_path_factory):
+ """Temp dir under PROJECT_ROOT so `_attempt_salvage_chain` can call
+ `candidate_path.relative_to(PROJECT_ROOT)` without ValueError on a
+ cross-drive Windows tmp path (default pytest tmp_path lives under
+ %LOCALAPPDATA% which is on a different drive from the project root).
+ Mirrors the fixture pattern in test_phase_z2_step17_salvage_chain.py.
+ """
+ base = _PROJECT_ROOT / ".orchestrator" / "tmp"
+ base.mkdir(parents=True, exist_ok=True)
+ d = Path(tempfile.mkdtemp(prefix="imp88_u6_", dir=str(base)))
+ try:
+ yield d
+ finally:
+ shutil.rmtree(d, ignore_errors=True)
+
+
+# IMP-09 gate-passing layout_css envelope. _attempt_salvage_chain skips
+# the cascade when dynamic_cols=True or dynamic_rows=False. Mirror of the
+# fixture in test_phase_z2_step17_salvage_chain.py so this test file
+# stays consistent with the existing u15 cascade surface.
+_LAYOUT_CSS_GATE_PASS = {
+ "areas": '"top" "bottom"',
+ "cols": "1fr",
+ "rows": "1fr 1fr",
+ "heights_px": [300, 290],
+ "widths_px": [1180],
+ "ratios": [0.508, 0.491],
+ "width_ratios": [1.0],
+ "dynamic_rows": True,
+ "dynamic_cols": False,
+}
+
+
+def _patch_render(monkeypatch):
+ """Stub render_slide → deterministic HTML envelope. Counter exposes
+ invocation count so tests can assert render_slide was (or was not)
+ called per branch."""
+ counter = {"n": 0}
+
+ def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
+ counter["n"] += 1
+ return (
+ f""
+ f""
+ )
+
+ monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
+ return counter
+
+
+def _horizontal_zones() -> list[dict]:
+ """horizontal-2 zones with content_weight.score so the vertical-2
+ swap path can call _build_cols_dynamic → compute_zone_layout_cols
+ inside apply_layout_adjust_layout_css → build_layout_css."""
+ return [
+ {"position": "top", "template_id": "t-top",
+ "content_weight": {"score": 1.0}},
+ {"position": "bottom", "template_id": "t-bottom",
+ "content_weight": {"score": 1.0}},
+ ]
+
+
+def _ci_image() -> dict:
+ """Minimal cascade_inputs for the cascade chain — covers the keys
+ every branch reads (fit_analysis is None to deliberately keep the
+ cross_zone branch infeasible when the cascade enters there, since
+ full FitAnalysis assembly is the u15 cascade test's domain not u6's)."""
+ return {
+ "fit_analysis": None, "containers": {}, "min_margin_px": 10,
+ "excess_px": 40.0, "excess_after_glue_px": 40.0,
+ "block_count": 3, "zone_position": "top",
+ "current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
+ }
+
+
+# ── 1. _SALVAGE_FAIL_BY_ACTION map registration ─────────────────────
+
+
+def test_salvage_fail_map_registers_imp88_actions():
+ """u6 extends the salvage-action → failure_type map from 3 to 5
+ entries so the loop cap (range(len(map))) covers the IMP-88
+ cascade depth. failure_type names mirror failure_router u2's
+ SALVAGE_FAILURE_TYPE_BY_ACTION rows."""
+ assert _SALVAGE_FAIL_BY_ACTION["layout_adjust"] == "layout_adjust_insufficient"
+ assert _SALVAGE_FAIL_BY_ACTION["frame_internal_fit_candidate"] == "frame_internal_fit_candidate_insufficient"
+ assert len(_SALVAGE_FAIL_BY_ACTION) == 5
+ # u4 image_fit stays OUT of the salvage chain map — u7 handles it as a
+ # Step 17 entry single-pass (not a cascade salvage stage). Guard against
+ # accidental future registration that would change cascade semantics.
+ assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
+
+
+# ── 2. layout_adjust PASS branch ────────────────────────────────────
+
+
+def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch):
+ """initial_failure_type=font_step_insufficient routes to layout_adjust;
+ plan_layout_adjust swaps horizontal-2 → vertical-2 (rows ↔ cols, swap
+ priority 0). render_slide is invoked with the NEW preset; overflow
+ passes → out_path promoted; step records new_layout_preset; cascade
+ exits with salvage_passed=True. No CSS overlay path was used."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ counter = _patch_render(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: {"passed": True, "fail_reasons": []},
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ zones_data=_horizontal_zones(),
+ layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs=_ci_image(),
+ initial_failure_type="font_step_insufficient", gap_px=14,
+ )
+
+ assert trace["salvage_attempted"] is True
+ assert trace["salvage_passed"] is True
+ assert len(trace["salvage_steps"]) == 1
+ step0 = trace["salvage_steps"][0]
+ assert step0["action"] == "layout_adjust"
+ assert step0["passed"] is True
+ assert step0["new_layout_preset"] == "vertical-2"
+ assert step0["plan"]["feasible"] is True
+ # layout_adjust uses the distinct render path — NOT the CSS-overlay path.
+ assert "css_override" not in step0
+ # render_slide was invoked exactly once with the NEW preset.
+ assert counter["n"] == 1
+ promoted = out_path.read_text(encoding="utf-8")
+ assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
+ assert "data-rendered-preset='vertical-2'" in promoted
+
+
+# ── 3. layout_adjust infeasible (no sibling) ────────────────────────
+
+
+def test_layout_adjust_infeasible_no_sibling_cascade_advances(project_tmp, monkeypatch):
+ """`single` preset has no render-ready unit_count=1 sibling (catalog
+ design — single and grid-2x2 have no swap target). layout_adjust
+ returns feasible=False; render_slide is NOT invoked; cascade
+ advances to frame_internal_fit_candidate. Patched get_contract
+ returns no envelope → that branch also infeasible → cascade exits
+ at the frame_reselect terminal action."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ counter = _patch_render(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: pytest.fail(
+ "run_overflow_check must not run when no candidate is emitted"
+ ),
+ )
+ monkeypatch.setattr(
+ _pz_mapper, "get_contract", lambda _tid: None,
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ zones_data=[{"position": "primary", "template_id": "t-only",
+ "content_weight": {"score": 1.0}}],
+ layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs={**_ci_image(), "zone_position": "primary"},
+ initial_failure_type="font_step_insufficient", gap_px=14,
+ )
+
+ assert trace["salvage_attempted"] is True
+ assert trace["salvage_passed"] is False
+ actions = [s["action"] for s in trace["salvage_steps"]]
+ assert actions == ["layout_adjust", "frame_internal_fit_candidate"]
+ s0, s1 = trace["salvage_steps"]
+ assert s0["plan"]["feasible"] is False
+ assert "no render-ready" in (s0["plan"]["failure_reason"] or "")
+ assert s0["new_layout_preset"] is None
+ assert s1["plan"]["feasible"] is False
+ assert s1["plan"]["envelope_present"] is False
+ # No candidate ever rendered (layout_adjust infeasible → no render call;
+ # frame_internal_fit_candidate infeasible → no render call).
+ assert counter["n"] == 0
+ # frame_reselect is the next routing target after
+ # frame_internal_fit_candidate_insufficient — not in salvage map → terminal.
+ assert trace.get("salvage_terminal_action") == "frame_reselect"
+ # Original final.html unchanged.
+ assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
+
+
+# ── 4. layout_adjust rerender FAIL → frame_internal_fit_candidate PASS ──
+
+
+def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monkeypatch):
+ """layout_adjust feasible but post-swap overflow persists →
+ failure_type=layout_adjust_insufficient → routes to
+ frame_internal_fit_candidate. Patched contract provides an envelope
+ variant that absorbs the excess; that branch PASSes → out_path
+ promoted with the CSS-overlay candidate."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ counter = _patch_render(monkeypatch)
+
+ # First overflow check (after layout_adjust render) FAILS,
+ # second (after frame_internal_fit CSS overlay) PASSes.
+ overflow_results = iter([
+ {"passed": False, "fail_reasons": ["zone overflow persists"]},
+ {"passed": True, "fail_reasons": []},
+ ])
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: next(overflow_results),
+ )
+
+ # Stub contract with a feasible internal_envelope variant covering 40px excess.
+ stub_contract = {
+ "internal_envelope": {
+ "variants": [
+ {"name": "internal_grid_row",
+ "excess_budget_px": 60,
+ "css_overrides": {"padding-top": "0px"}},
+ ],
+ },
+ }
+ monkeypatch.setattr(
+ _pz_mapper, "get_contract", lambda _tid: stub_contract,
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ zones_data=_horizontal_zones(),
+ layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs=_ci_image(),
+ initial_failure_type="font_step_insufficient", gap_px=14,
+ )
+
+ assert trace["salvage_passed"] is True
+ assert len(trace["salvage_steps"]) == 2
+ s0, s1 = trace["salvage_steps"]
+ assert s0["action"] == "layout_adjust"
+ assert s0["passed"] is False
+ assert s0["plan"]["feasible"] is True
+ assert s1["action"] == "frame_internal_fit_candidate"
+ assert s1["passed"] is True
+ assert s1["plan"]["selected_variant"] == "internal_grid_row"
+ assert s1["css_override"]
+ assert 'data-template-id="t-top"' in s1["css_override"]
+ # Two render_slide calls (one per dispatched branch).
+ assert counter["n"] == 2
+ assert "ORIGINAL_BEFORE_SALVAGE" not in out_path.read_text(encoding="utf-8")
+
+
+# ── 5. frame_internal_fit_candidate PASS (direct entry) ─────────────
+
+
+def test_frame_internal_fit_candidate_pass_promotes_final_html(project_tmp, monkeypatch):
+ """initial_failure_type=layout_adjust_insufficient routes directly to
+ frame_internal_fit_candidate. Patched contract envelope variant
+ covers excess; CSS-overlay candidate PASSes → out_path promoted."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ counter = _patch_render(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: {"passed": True, "fail_reasons": []},
+ )
+ monkeypatch.setattr(
+ _pz_mapper, "get_contract",
+ lambda _tid: {
+ "internal_envelope": {
+ "variants": [
+ {"name": "density_envelope",
+ "excess_budget_px": 80,
+ "css_overrides": {"line-height": "1.4"}},
+ ],
+ },
+ },
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ zones_data=_horizontal_zones(),
+ layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs=_ci_image(),
+ initial_failure_type="layout_adjust_insufficient", gap_px=14,
+ )
+
+ assert trace["salvage_passed"] is True
+ assert len(trace["salvage_steps"]) == 1
+ step0 = trace["salvage_steps"][0]
+ assert step0["action"] == "frame_internal_fit_candidate"
+ assert step0["passed"] is True
+ assert step0["plan"]["selected_variant"] == "density_envelope"
+ assert step0["css_override"] and "line-height: 1.4" in step0["css_override"]
+ assert counter["n"] == 1
+
+
+# ── 6. frame_internal_fit_candidate no envelope → terminal exit ─────
+
+
+def test_frame_internal_fit_candidate_no_envelope_cascade_terminal(project_tmp, monkeypatch):
+ """initial=layout_adjust_insufficient routes to
+ frame_internal_fit_candidate. Patched contract has NO
+ internal_envelope → planner returns feasible=False with
+ envelope_present=False; failure_type=
+ frame_internal_fit_candidate_insufficient → routes to
+ frame_reselect (not in salvage map) → terminal exit recorded."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ counter = _patch_render(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: pytest.fail("no candidate emitted — overflow_check must not run"),
+ )
+ # Contract present but no internal_envelope → envelope_present=False branch.
+ monkeypatch.setattr(
+ _pz_mapper, "get_contract", lambda _tid: {"some_other_key": "value"},
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ zones_data=_horizontal_zones(),
+ layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs=_ci_image(),
+ initial_failure_type="layout_adjust_insufficient", gap_px=14,
+ )
+
+ assert trace["salvage_passed"] is False
+ assert len(trace["salvage_steps"]) == 1
+ step0 = trace["salvage_steps"][0]
+ assert step0["action"] == "frame_internal_fit_candidate"
+ assert step0["plan"]["feasible"] is False
+ assert step0["plan"]["envelope_present"] is False
+ assert trace["salvage_terminal_action"] == "frame_reselect"
+ assert counter["n"] == 0
+ assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
+
+
+# ── 7. Loop cap respected — 5 stages all fail ───────────────────────
+
+
+def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatch):
+ """Start at donor_slack_insufficient and force every cascade stage to
+ fail: cross_zone (no fit_analysis), glue (excess > envelope), font_step
+ (no headroom), layout_adjust (single → no sibling), frame_internal_fit
+ (no contract). Loop iterates exactly len(_SALVAGE_FAIL_BY_ACTION)=5
+ times → salvage_steps has 5 entries in the exact cascade order."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ counter = _patch_render(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: pytest.fail("no CSS emitted in any branch — overflow_check must not run"),
+ )
+ monkeypatch.setattr(
+ _pz_mapper, "get_contract", lambda _tid: None,
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ # single preset → layout_adjust will be infeasible (no sibling)
+ zones_data=[{"position": "primary", "template_id": "t-only",
+ "content_weight": {"score": 1.0}}],
+ layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs={
+ "fit_analysis": None, "containers": {}, "min_margin_px": 10,
+ # excess_px=200 > glue envelope at block_count=1 (max ~28) → infeasible
+ "excess_px": 200.0, "excess_after_glue_px": 200.0,
+ "block_count": 1, "zone_position": "primary",
+ # current_font_px cannot absorb 200px even at 8px floor → infeasible
+ "current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
+ },
+ initial_failure_type="donor_slack_insufficient", gap_px=14,
+ )
+
+ assert trace["salvage_passed"] is False
+ assert len(trace["salvage_steps"]) == 5
+ actions = [s["action"] for s in trace["salvage_steps"]]
+ assert actions == [
+ "cross_zone_redistribute",
+ "glue_compression",
+ "font_step_compression",
+ "layout_adjust",
+ "frame_internal_fit_candidate",
+ ]
+ # All 5 are infeasible — no candidate rendering anywhere.
+ assert counter["n"] == 0
+ # Loop exhausted at cap (no mid-cascade terminal_action since each
+ # next_action stayed in _SALVAGE_FAIL_BY_ACTION through 5 stages).
+ assert "salvage_terminal_action" not in trace
+ assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
+
+
+# ── 8. layout_adjust uses the distinct render path (no CSS overlay) ──
+
+
+def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch):
+ """layout_adjust's render path is qualitatively different from the
+ CSS-overlay planners (glue / font_step / cross_zone / frame_internal_fit):
+ it calls render_slide with the NEW preset + remapped zones_data + new
+ layout_css. The step dict therefore omits `css_override` and surfaces
+ `new_layout_preset` instead — observability for downstream classifiers."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
+ _patch_render(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: {"passed": True, "fail_reasons": []},
+ )
+
+ trace = _attempt_salvage_chain(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="imp88-u6", slide_footer=None,
+ zones_data=_horizontal_zones(),
+ layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
+ cascade_inputs=_ci_image(),
+ initial_failure_type="font_step_insufficient", gap_px=14,
+ )
+
+ step0 = trace["salvage_steps"][0]
+ assert step0["action"] == "layout_adjust"
+ # Distinct render path observability contract:
+ assert "css_override" not in step0
+ assert "new_layout_preset" in step0
+ assert "candidate_path" in step0
diff --git a/tests/phase_z2/test_phase_z2_pipeline_step17_entry_imp88.py b/tests/phase_z2/test_phase_z2_pipeline_step17_entry_imp88.py
new file mode 100644
index 0000000..a975ef6
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_pipeline_step17_entry_imp88.py
@@ -0,0 +1,542 @@
+"""IMP-88 u7 — Step 17 entry runtime caller tests.
+
+Stage 2 binding contract (u7):
+ - `_attempt_step17_image_fit_single_pass` executes the image_fit Step 17
+ entry single-pass: per-event plan_image_fit → apply_image_fit_css →
+ aggregated CSS overlay → single re-render → run_overflow_check.
+ PASS promotes final.html and returns a salvage_steps-shaped entry with
+ post_salvage_overflow; FAIL returns the same shape with failure_reason
+ (NO out_path mutation on FAIL).
+ - image_fit stays OUT of `_SALVAGE_FAIL_BY_ACTION` (u6 guard). The Step 17
+ entry single-pass is NOT a cascade stage — it runs BEFORE the cascade
+ direct-entry block in pipeline §11.7.2.
+ - Honors `[[feedback_phase_z_spacing_direction]]` — img-scoped CSS only,
+ no common margin / slide-body shrink. Honors AI isolation contract
+ (PZ-1) — deterministic data-surface, no AI call.
+ - Step 17/18/19 artifact refresh: the pipeline §11.7.1 wrapper around the
+ helper re-runs classify_visual_runtime_check + route_fit_classification +
+ enrich_retry_trace_with_failure_classification on PASS so Step 18
+ failure_classification + Step 19 next_action_proposal reflect the
+ post-image_fit state (not the stale pre-image_fit state).
+ - direct entry triggers (§11.7.2) for layout_adjust /
+ frame_internal_fit_candidate / image_fit_insufficient route into
+ `_attempt_salvage_chain` with a synthetic initial_failure_type that
+ failure_router u2 NEXT_ACTION_BY_FAILURE maps onto the proposed action.
+
+Test surfaces (12 tests):
+ 1. helper returns triggered=False when no image_events.
+ 2. helper returns triggered=False when every image_event is below tol
+ (delta=None or |delta|<=tol).
+ 3. helper returns triggered=False when plan_image_fit emits no CSS for
+ any feasible event (rendered_w/h missing — apply returns None).
+ 4. helper PASS — out_path promoted, step shape correct (action=image_fit,
+ passed=True, image_fit_event_plans recorded, post_salvage_overflow).
+ 5. helper FAIL — out_path NOT promoted, step records failure_reason +
+ no post_salvage_overflow key.
+ 6. helper aggregates CSS chunks from multiple events into ONE candidate
+ re-render (render_slide called exactly once).
+ 7. helper writes candidate to run_dir as `salvage_image_fit_candidate.html`
+ and step.candidate_path is the project-root-relative form.
+ 8. helper passes delta_tol through to plan_image_fit (override threshold
+ filters which events get planned/applied).
+ 9. image_fit stays OUT of `_SALVAGE_FAIL_BY_ACTION` (u6 guard re-asserted
+ so u7 does not accidentally register image_fit as a cascade stage).
+ 10. helper guards out_path mutation strictly under PASS (FAIL = no write).
+ 11. helper exposes every plan_image_fit result through `event_plans`
+ even when no CSS was emitted (telemetry continuity for Step 17/18/19).
+ 12. helper honors frame-scoped img CSS only — emitted CSS contains an
+ img selector and does NOT touch shared margins / slide-body / zone gap.
+"""
+from __future__ import annotations
+
+import shutil
+import tempfile
+from pathlib import Path
+
+import pytest
+
+import src.phase_z2_pipeline as _pz_pipeline
+from src.phase_z2_pipeline import (
+ _SALVAGE_FAIL_BY_ACTION,
+ _attempt_step17_image_fit_single_pass,
+)
+
+
+_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
+
+
+@pytest.fixture
+def project_tmp(tmp_path_factory):
+ """Temp dir under PROJECT_ROOT so candidate_path.relative_to(PROJECT_ROOT)
+ does not raise ValueError on a cross-drive Windows tmp path. Mirrors the
+ fixture in test_phase_z2_pipeline_salvage_imp88.py."""
+ base = _PROJECT_ROOT / ".orchestrator" / "tmp"
+ base.mkdir(parents=True, exist_ok=True)
+ d = Path(tempfile.mkdtemp(prefix="imp88_u7_", dir=str(base)))
+ try:
+ yield d
+ finally:
+ shutil.rmtree(d, ignore_errors=True)
+
+
+# IMP-09 gate-passing layout_css envelope — kept for parity even though the
+# image_fit single-pass helper does not consult the gate (gate is internal
+# to _attempt_salvage_chain). Reserved for §11.7.2 direct-entry tests.
+_LAYOUT_CSS_GATE_PASS = {
+ "areas": '"top" "bottom"',
+ "cols": "1fr",
+ "rows": "1fr 1fr",
+ "heights_px": [300, 290],
+ "widths_px": [1180],
+ "ratios": [0.508, 0.491],
+ "width_ratios": [1.0],
+ "dynamic_rows": True,
+ "dynamic_cols": False,
+}
+
+
+def _stub_render_capture(monkeypatch):
+ """Stub render_slide → deterministic HTML envelope; counter exposes
+ invocation count + a recorder of the (preset, css overlay observed via
+ candidate_html) tuple per call."""
+ state = {"n": 0, "calls": []}
+
+ def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
+ state["n"] += 1
+ state["calls"].append({
+ "preset": layout_preset, "zones_count": len(zones_data),
+ "gap_px": gap_px,
+ })
+ return (
+ f""
+ f""
+ f"

"
+ )
+
+ monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
+ return state
+
+
+def _zones() -> list[dict]:
+ return [
+ {"position": "top", "template_id": "t-top",
+ "content_weight": {"score": 1.0}},
+ {"position": "bottom", "template_id": "t-bottom",
+ "content_weight": {"score": 1.0}},
+ ]
+
+
+def _image_event(*, src: str, zone_position: str = "top",
+ zone_template_id: str = "t-top",
+ natural_w: int = 1600, natural_h: int = 900,
+ rendered_w: int = 800, rendered_h: int = 600,
+ delta: float | None = 0.20) -> dict:
+ """Mirror the shape pipeline JS injection emits at lines 3019-3060."""
+ natural_ratio = natural_w / natural_h if natural_h else None
+ rendered_ratio = rendered_w / rendered_h if rendered_h else None
+ return {
+ "src": src,
+ "zone_position": zone_position,
+ "zone_template_id": zone_template_id,
+ "natural_w": natural_w, "natural_h": natural_h,
+ "rendered_w": rendered_w, "rendered_h": rendered_h,
+ "natural_ratio": natural_ratio,
+ "rendered_ratio": rendered_ratio,
+ "delta": delta,
+ }
+
+
+# ── 1. No image_events → not triggered ──────────────────────────────
+
+
+def test_helper_not_triggered_when_no_image_events(project_tmp, monkeypatch):
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
+ counter = _stub_render_capture(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: pytest.fail("must not run overflow_check when not triggered"),
+ )
+
+ res = _attempt_step17_image_fit_single_pass(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="u7-empty", slide_footer=None,
+ zones_data=_zones(), layout_preset="vertical-2",
+ layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[], gap_px=14,
+ )
+
+ assert res["triggered"] is False
+ assert res["passed"] is False
+ assert res["step"] is None
+ assert res["candidate_html"] is None
+ assert res["candidate_overflow"] is None
+ assert res["event_plans"] == []
+ assert counter["n"] == 0
+ assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
+
+
+# ── 2. All events sub-tolerance → not triggered ─────────────────────
+
+
+def test_helper_not_triggered_when_all_events_under_tolerance(project_tmp, monkeypatch):
+ """delta=None (image not loaded) + |delta|<=tol (no aspect mismatch
+ above threshold) both should produce feasible=False plans, no CSS,
+ and triggered=False at the helper level."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
+ counter = _stub_render_capture(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: pytest.fail("must not run overflow_check when not triggered"),
+ )
+
+ events = [
+ _image_event(src="zoneA.png", delta=None), # not loaded
+ _image_event(src="zoneB.png", delta=0.03), # under tol
+ ]
+ res = _attempt_step17_image_fit_single_pass(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="u7-undertol", slide_footer=None,
+ zones_data=_zones(), layout_preset="vertical-2",
+ layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
+ )
+
+ assert res["triggered"] is False
+ assert len(res["event_plans"]) == 2
+ assert all(p.get("feasible") is False for p in res["event_plans"])
+ assert counter["n"] == 0
+ assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
+
+
+# ── 3. Feasible but apply_image_fit_css returns None ─────────────────
+
+
+def test_helper_not_triggered_when_apply_returns_none(project_tmp, monkeypatch):
+ """rendered_w/h missing on a feasible-shaped event means apply_image_fit_css
+ would emit empty CSS — but plan_image_fit treats missing rendered dims as
+ infeasible (per u4 contract), so this stays triggered=False."""
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
+ counter = _stub_render_capture(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: pytest.fail("must not run overflow_check when not triggered"),
+ )
+
+ bad_event = _image_event(src="zoneA.png", delta=0.20)
+ bad_event["rendered_w"] = 0 # invalidates apply path → plan infeasible
+ res = _attempt_step17_image_fit_single_pass(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="u7-noapply", slide_footer=None,
+ zones_data=_zones(), layout_preset="vertical-2",
+ layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[bad_event], gap_px=14,
+ )
+
+ assert res["triggered"] is False
+ assert res["event_plans"][0].get("feasible") is False
+ assert counter["n"] == 0
+
+
+# ── 4. helper PASS — out_path promoted, step shape correct ──────────
+
+
+def test_helper_pass_promotes_final_html(project_tmp, monkeypatch):
+ out_path = project_tmp / "final.html"
+ out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
+ counter = _stub_render_capture(monkeypatch)
+ monkeypatch.setattr(
+ _pz_pipeline, "run_overflow_check",
+ lambda p: {"passed": True, "fail_reasons": []},
+ )
+
+ res = _attempt_step17_image_fit_single_pass(
+ run_dir=project_tmp, out_path=out_path,
+ slide_title="u7-pass", slide_footer=None,
+ zones_data=_zones(), layout_preset="vertical-2",
+ layout_css=_LAYOUT_CSS_GATE_PASS,
+ image_events=[_image_event(src="zoneA.png", delta=0.30)],
+ gap_px=14,
+ )
+
+ assert res["triggered"] is True
+ assert res["passed"] is True
+ assert res["step"]["action"] == "image_fit"
+ assert res["step"]["passed"] is True
+ assert res["step"]["post_salvage_overflow"] == {"passed": True, "fail_reasons": []}
+ assert "failure_reason" not in res["step"]
+ assert res["step"]["image_fit_event_plans"]
+ assert res["step"]["image_fit_event_plans"][0]["feasible"] is True
+ assert counter["n"] == 1
+ # out_path promoted with the candidate HTML (style overlay injected).
+ promoted = out_path.read_text(encoding="utf-8")
+ assert "ORIGINAL_BEFORE_U7" not in promoted
+ assert "data-rendered-preset='vertical-2'" in promoted
+ assert "", style_block_start)
+ assert style_block_start >= 0 and style_block_end > style_block_start
+ style_body = candidate[style_block_start + len("