From 2e3747c5abee18fc0208554db74d295d88cbe0e1 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Sun, 24 May 2026 15:01:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(#88):=20IMP-88=20u1~u7=20Step=2017=20retry?= =?UTF-8?q?=20chain=20=E2=80=94=20layout=5Fadjust=20+=20image=5Ffit=20+=20?= =?UTF-8?q?frame=5Finternal=5Ffit=5Fcandidate=20executors=20+=20dispatcher?= =?UTF-8?q?=20+=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/phase_z2_failure_router.py | 109 +++- src/phase_z2_pipeline.py | 298 +++++++++- src/phase_z2_retry.py | 374 ++++++++++++ src/phase_z2_router.py | 39 +- .../test_failure_router_imp88_cascade.py | 299 ++++++++++ .../test_phase_z2_failure_router_cascade.py | 8 +- .../test_phase_z2_pipeline_salvage_imp88.py | 483 ++++++++++++++++ ...st_phase_z2_pipeline_step17_entry_imp88.py | 542 ++++++++++++++++++ .../test_phase_z2_retry_frame_internal_fit.py | 252 ++++++++ .../phase_z2/test_phase_z2_retry_image_fit.py | 198 +++++++ .../test_phase_z2_retry_layout_adjust.py | 168 ++++++ .../test_phase_z2_step17_salvage_chain.py | 29 +- tests/phase_z2/test_router_actions_imp88.py | 223 +++++++ 13 files changed, 3007 insertions(+), 15 deletions(-) create mode 100644 tests/phase_z2/test_failure_router_imp88_cascade.py create mode 100644 tests/phase_z2/test_phase_z2_pipeline_salvage_imp88.py create mode 100644 tests/phase_z2/test_phase_z2_pipeline_step17_entry_imp88.py create mode 100644 tests/phase_z2/test_phase_z2_retry_frame_internal_fit.py create mode 100644 tests/phase_z2/test_phase_z2_retry_image_fit.py create mode 100644 tests/phase_z2/test_phase_z2_retry_layout_adjust.py create mode 100644 tests/phase_z2/test_router_actions_imp88.py 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("