feat(#88): IMP-88 u1~u7 Step 17 retry chain — layout_adjust + image_fit + frame_internal_fit_candidate executors + dispatcher + entry
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Step 17 salvage dispatcher previously only ran the 3 actions in
_SALVAGE_FAIL_BY_ACTION (cross_zone_redistribute / glue_compression /
font_step_compression). Any next_proposed_action outside that set hit
salvage_terminal_action and dropped through, so visual_check aborted on
layout_adjust / image_fit / frame_internal_fit_candidate cascades.
u1 — router data surface (src/phase_z2_router.py)
- ACTION_BY_CATEGORY: image_aspect_mismatch -> image_fit (new row),
frame_capacity_mismatch -> frame_internal_fit_candidate (was
frame_reselect).
- ACTION_IMPLEMENTATION_STATUS: layout_adjust / image_fit /
frame_internal_fit_candidate flipped MISSING -> IMPLEMENTED with
inline IMP-88 rationale.
u2 — failure_router cascade surface (src/phase_z2_failure_router.py)
- FAILURE_TYPE_DESCRIPTIONS + SALVAGE_FAILURE_TYPE_BY_ACTION extended
with layout_adjust_insufficient / image_fit_insufficient /
frame_internal_fit_insufficient producers.
- NEXT_ACTION_BY_FAILURE + NEXT_ACTION_RATIONALE +
NEXT_ACTION_IMPLEMENTATION_STATUS rows added; cascade chain becomes
font_step_compression -> layout_adjust -> frame_internal_fit_candidate
-> frame_reselect -> details_popup_escalation (#64 terminal).
u3~u5 — planners + apply helpers (src/phase_z2_retry.py)
- plan_layout_adjust / apply_layout_adjust_layout_css with
_layout_swap_priority across 8-preset LAYOUT_PRESETS (preset switch,
no shared-margin shrink per Phase Z spacing direction).
- plan_image_fit / apply_image_fit_css scoped to frame slot using
existing classifier image_event payload (object-fit + max-w/h
derivation).
- plan_frame_internal_fit_candidate / apply_frame_internal_fit_candidate_css
stays inside declared frame contract envelope; emits infeasible path
when envelope is absent.
u6~u7 — pipeline wiring (src/phase_z2_pipeline.py)
- _SALVAGE_FAIL_BY_ACTION extended; _attempt_salvage_chain gains
layout_adjust distinct-render branch + frame_internal_fit_candidate
CSS-overlay branch + loop cap.
- _attempt_step17_image_fit_single_pass added for image_fit entry.
- §11.7.1 / §11.7.2 entry triggers wired; Step 17/18/19 artifact
refresh + note logging closes the salvage_terminal_action fall-through
for the 3 IMP-88 actions.
Tests
- New: test_router_actions_imp88.py (12),
test_failure_router_imp88_cascade.py (12),
test_phase_z2_retry_layout_adjust.py (10),
test_phase_z2_retry_image_fit.py (13),
test_phase_z2_retry_frame_internal_fit.py (13),
test_phase_z2_pipeline_salvage_imp88.py (8),
test_phase_z2_pipeline_step17_entry_imp88.py.
- Regression-aligned: test_phase_z2_failure_router_cascade.py,
test_phase_z2_step17_salvage_chain.py — pre-existing cascade +
salvage-chain assertions updated to the IMPLEMENTED surface.
Out of scope (separate axes / issues)
- details_popup_escalation terminal body (#64).
- frame_reselect MISSING flip (different axis).
- Step 14/16 detection refinement.
- Stage 0 mdx_normalizer integration (locked 2026-05-08).
- AI fallback activation.
Guardrails respected
- Phase Z spacing direction: layout_adjust switches preset; no shared
margin shrink.
- AI isolation contract: planners + dispatcher are deterministic; zero
AI calls in u1~u7.
- No hardcoding: routing + cascade live in router/failure_router data
rows, not inline conditionals.
- IMP-46 (#62) cache carve-out: untouched.
- 1 commit = 1 decision unit: u1~u7 grouped as a single IMP-88 unit.
Stage 4 verification: 7 IMP-88 test files + 2 modified regression files
PASS (Claude #12 + Codex #12 consensus YES). Full-suite sweep deferred to
a separate step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,9 @@ glue_compression (SPACING_GLUE envelope, frame-scoped)
|
|||||||
↓ 그래도 안 되면
|
↓ 그래도 안 되면
|
||||||
font_step_compression (FONT_SIZE_STEPS, zone-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)
|
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
|
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
|
||||||
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
||||||
terminal without claiming an implementation it does not own.
|
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
|
from __future__ import annotations
|
||||||
@@ -85,6 +114,28 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
"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
|
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
|
||||||
# that routes this onto details_popup_escalation.
|
# that routes this onto details_popup_escalation.
|
||||||
"frame_reselect": "frame_reselect_insufficient",
|
"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.
|
# only.
|
||||||
"frame_reselect_insufficient": "details_popup_escalation",
|
"frame_reselect_insufficient": "details_popup_escalation",
|
||||||
"not_attempted": "none",
|
"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] = {
|
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||||
@@ -161,6 +232,22 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
|||||||
"not_attempted": (
|
"not_attempted": (
|
||||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
"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 들의 *현재 코드* 구현 상태
|
# 본 매핑이 가리키는 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
|
"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
|
"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
|
"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",
|
"frame_reselect": "MISSING",
|
||||||
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
||||||
# router executor stub + MISSING→IMPLEMENTED flip lives in
|
# 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
|
# lands prevents premature "popup ready" claims from the failure-router
|
||||||
# surface.
|
# surface.
|
||||||
"details_popup_escalation": "MISSING",
|
"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",
|
"none": "n/a",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,17 @@ from phase_z2_retry import (
|
|||||||
DEFAULT_SAFETY_MARGIN_PX,
|
DEFAULT_SAFETY_MARGIN_PX,
|
||||||
apply_cross_zone_redistribute_css,
|
apply_cross_zone_redistribute_css,
|
||||||
apply_font_step_compression_css,
|
apply_font_step_compression_css,
|
||||||
|
apply_frame_internal_fit_candidate_css,
|
||||||
apply_glue_compression_css,
|
apply_glue_compression_css,
|
||||||
|
apply_image_fit_css,
|
||||||
|
apply_layout_adjust_layout_css,
|
||||||
apply_retry_to_layout_css,
|
apply_retry_to_layout_css,
|
||||||
plan_cross_zone_redistribute,
|
plan_cross_zone_redistribute,
|
||||||
plan_font_step_compression,
|
plan_font_step_compression,
|
||||||
|
plan_frame_internal_fit_candidate,
|
||||||
plan_glue_compression,
|
plan_glue_compression,
|
||||||
|
plan_image_fit,
|
||||||
|
plan_layout_adjust,
|
||||||
plan_zone_ratio_retry,
|
plan_zone_ratio_retry,
|
||||||
)
|
)
|
||||||
from phase_z2_failure_router import (
|
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).
|
# 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).
|
# 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.
|
# 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 = {
|
_SALVAGE_FAIL_BY_ACTION = {
|
||||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||||
"glue_compression": "glue_absorption_insufficient",
|
"glue_compression": "glue_absorption_insufficient",
|
||||||
"font_step_compression": "font_step_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_action"] = next_action
|
||||||
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
||||||
return trace
|
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 next_action == "cross_zone_redistribute":
|
||||||
if ci.get("fit_analysis") is None:
|
if ci.get("fit_analysis") is None:
|
||||||
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
||||||
@@ -2617,6 +2675,21 @@ def _attempt_salvage_chain(
|
|||||||
block_count=int(ci.get("block_count") or 0),
|
block_count=int(ci.get("block_count") or 0),
|
||||||
zone_position=str(ci.get("zone_position") or ""))
|
zone_position=str(ci.get("zone_position") or ""))
|
||||||
apply_fn = apply_glue_compression_css
|
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:
|
else:
|
||||||
plan = plan_font_step_compression(
|
plan = plan_font_step_compression(
|
||||||
current_font_px=float(ci.get("current_font_px") or 0.0),
|
current_font_px=float(ci.get("current_font_px") or 0.0),
|
||||||
@@ -2653,6 +2726,89 @@ def _attempt_salvage_chain(
|
|||||||
return trace
|
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 = "<style>\n" + "\n".join(css_chunks) + "\n</style>"
|
||||||
|
candidate_html = (
|
||||||
|
base.replace("</head>", f"{style}\n</head>", 1)
|
||||||
|
if "</head>" 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(
|
def _remeasure_after_frame_reselect(
|
||||||
*, candidate_path: Path, plan: Optional[dict] = None,
|
*, candidate_path: Path, plan: Optional[dict] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -6370,6 +6526,144 @@ def run_phase_z2_mvp1(
|
|||||||
# fields become None (no failure to classify, no escalation pending).
|
# fields become None (no failure to classify, no escalation pending).
|
||||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
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.
|
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
|
||||||
# Runs after the salvage cascade exits at cascade-terminal action
|
# Runs after the salvage cascade exits at cascade-terminal action
|
||||||
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
|
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
|
||||||
@@ -6433,7 +6727,9 @@ def run_phase_z2_mvp1(
|
|||||||
note=(
|
note=(
|
||||||
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
||||||
"(cross_zone_redistribute → glue_compression → font_step_compression). "
|
"(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)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -428,3 +428,377 @@ def apply_font_step_compression_css(plan: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
return (f'[data-zone-position="{zone_position}"] {{\n'
|
return (f'[data-zone-position="{zone_position}"] {{\n'
|
||||||
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
# IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap).
|
||||||
|
# Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body
|
||||||
|
# shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust;
|
||||||
|
# downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate.
|
||||||
|
# Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and
|
||||||
|
# rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px).
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int:
|
||||||
|
"""Lower = preferred swap target. Honors topology-axis mirroring first."""
|
||||||
|
pair = frozenset({current_topology, candidate_topology})
|
||||||
|
if pair == frozenset({"rows", "cols"}):
|
||||||
|
return 0
|
||||||
|
if pair == frozenset({"T", "inverted-T"}):
|
||||||
|
return 1
|
||||||
|
if pair == frozenset({"side-T-left", "side-T-right"}):
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
def plan_layout_adjust(
|
||||||
|
*, current_layout_preset: str, zones_data: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
"""Layout-preset switch plan (Step 17 retry chain — IMP-88 u3).
|
||||||
|
|
||||||
|
Finds a render-ready sibling preset (same candidate_when.unit_count) and
|
||||||
|
remaps zone positions in catalog order. No common spacing shrink —
|
||||||
|
feedback_phase_z_spacing_direction lock: escalate via layout topology only.
|
||||||
|
"""
|
||||||
|
from src.phase_z2_composition import LAYOUT_PRESETS
|
||||||
|
base = {
|
||||||
|
"action": "layout_adjust",
|
||||||
|
"current_layout_preset": current_layout_preset,
|
||||||
|
}
|
||||||
|
current_spec = LAYOUT_PRESETS.get(current_layout_preset)
|
||||||
|
if current_spec is None:
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "new_layout_preset": None,
|
||||||
|
"candidates_considered": [],
|
||||||
|
"failure_reason": (
|
||||||
|
f"current_layout_preset '{current_layout_preset}' not in "
|
||||||
|
f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
current_positions = list(current_spec.get("positions") or [])
|
||||||
|
if len(zones_data) != len(current_positions):
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "new_layout_preset": None,
|
||||||
|
"candidates_considered": [],
|
||||||
|
"failure_reason": (
|
||||||
|
f"zones_data length {len(zones_data)} != current preset "
|
||||||
|
f"'{current_layout_preset}' positions {current_positions} — "
|
||||||
|
f"cannot remap to a sibling preset."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
unit_count = (current_spec.get("candidate_when") or {}).get("unit_count")
|
||||||
|
candidates = [
|
||||||
|
pid for pid, spec in LAYOUT_PRESETS.items()
|
||||||
|
if pid != current_layout_preset
|
||||||
|
and spec.get("render_ready", False)
|
||||||
|
and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count)
|
||||||
|
]
|
||||||
|
base = {**base, "unit_count": unit_count,
|
||||||
|
"candidates_considered": list(candidates)}
|
||||||
|
if not candidates:
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "new_layout_preset": None,
|
||||||
|
"failure_reason": (
|
||||||
|
f"no render-ready 8-preset sibling for unit_count {unit_count} "
|
||||||
|
f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) "
|
||||||
|
f"have no swap target by catalog design."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
catalog_order = list(LAYOUT_PRESETS.keys())
|
||||||
|
current_topo = current_spec.get("topology")
|
||||||
|
candidates.sort(key=lambda pid: (
|
||||||
|
_layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")),
|
||||||
|
catalog_order.index(pid),
|
||||||
|
))
|
||||||
|
new_preset = candidates[0]
|
||||||
|
new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or [])
|
||||||
|
new_zones_data = [
|
||||||
|
{**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"feasible": True,
|
||||||
|
"new_layout_preset": new_preset,
|
||||||
|
"swap_topology_from": current_topo,
|
||||||
|
"swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"),
|
||||||
|
"position_remap": dict(zip(current_positions, new_positions)),
|
||||||
|
"new_zones_data": new_zones_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]:
|
||||||
|
"""Build a fresh layout_css dict for the swapped preset.
|
||||||
|
|
||||||
|
Returns None when plan is infeasible. Dispatcher (u6) re-renders with
|
||||||
|
render_slide(zones_data=plan['new_zones_data'], layout_preset=plan
|
||||||
|
['new_layout_preset'], layout_css=<this return>, gap_px=gap_px).
|
||||||
|
"""
|
||||||
|
if not plan.get("feasible"):
|
||||||
|
return None
|
||||||
|
new_preset = plan.get("new_layout_preset")
|
||||||
|
new_zones_data = plan.get("new_zones_data") or []
|
||||||
|
if not new_preset or not new_zones_data:
|
||||||
|
return None
|
||||||
|
from src.phase_z2_pipeline import build_layout_css
|
||||||
|
new_layout_css = dict(build_layout_css(new_preset, new_zones_data, gap=gap_px))
|
||||||
|
raw = dict(new_layout_css.get("raw_zone_layout") or {})
|
||||||
|
raw["layout_adjust_applied"] = True
|
||||||
|
raw["layout_adjust_from"] = plan.get("current_layout_preset")
|
||||||
|
raw["layout_adjust_to"] = new_preset
|
||||||
|
new_layout_css["raw_zone_layout"] = raw
|
||||||
|
return new_layout_css
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
# IMP-88 u4 : image_fit — Step 17 single-pass image-scoped CSS override.
|
||||||
|
# Consumes overflow_metrics.image_events directly (natural_w/h, rendered_w/h,
|
||||||
|
# natural_ratio, rendered_ratio, delta). Honors feedback_phase_z_spacing
|
||||||
|
# _direction — image-scoped CSS only, no shared margin / frame envelope shrink.
|
||||||
|
# Plan-only — Step 17 entry (u7) is the runtime caller.
|
||||||
|
# Default delta_tol mirrors src.phase_z2_pipeline.IMAGE_ASPECT_DELTA_TOL = 0.05;
|
||||||
|
# overridable arg keeps tests free of the pipeline import cycle.
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def plan_image_fit(
|
||||||
|
*, image_event: dict, delta_tol: float = 0.05,
|
||||||
|
) -> dict:
|
||||||
|
"""Image_fit planner (Step 17 retry chain — IMP-88 u4).
|
||||||
|
|
||||||
|
Emits frame-scoped object-fit + max-width/height from a single
|
||||||
|
image_event (overflow_metrics.image_events). Image-scoped only.
|
||||||
|
"""
|
||||||
|
base = {
|
||||||
|
"action": "image_fit",
|
||||||
|
"src": image_event.get("src"),
|
||||||
|
"zone_position": image_event.get("zone_position"),
|
||||||
|
"zone_template_id": image_event.get("zone_template_id"),
|
||||||
|
}
|
||||||
|
delta = image_event.get("delta")
|
||||||
|
if delta is None:
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "css_overrides": None,
|
||||||
|
"failure_reason": (
|
||||||
|
"image_event delta is None — image not loaded; no aspect "
|
||||||
|
"mismatch can be measured."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if abs(float(delta)) <= float(delta_tol):
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "css_overrides": None,
|
||||||
|
"delta": float(delta),
|
||||||
|
"failure_reason": (
|
||||||
|
f"|delta|={abs(float(delta)):.4f} <= delta_tol={delta_tol} — "
|
||||||
|
f"no image_aspect_mismatch to correct (planner no-op)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
rendered_w = image_event.get("rendered_w")
|
||||||
|
rendered_h = image_event.get("rendered_h")
|
||||||
|
if not (isinstance(rendered_w, (int, float)) and rendered_w > 0
|
||||||
|
and isinstance(rendered_h, (int, float)) and rendered_h > 0):
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "css_overrides": None,
|
||||||
|
"delta": float(delta),
|
||||||
|
"failure_reason": (
|
||||||
|
"image_event missing positive rendered_w / rendered_h — "
|
||||||
|
"cannot bound max-width / max-height for image-scoped CSS."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"feasible": True,
|
||||||
|
"delta": float(delta),
|
||||||
|
"natural_ratio": image_event.get("natural_ratio"),
|
||||||
|
"rendered_ratio": image_event.get("rendered_ratio"),
|
||||||
|
"natural_w": image_event.get("natural_w"),
|
||||||
|
"natural_h": image_event.get("natural_h"),
|
||||||
|
"rendered_w": int(rendered_w),
|
||||||
|
"rendered_h": int(rendered_h),
|
||||||
|
# delta > 0 ⇒ rendered_ratio > natural_ratio ⇒ rendered too wide ⇒
|
||||||
|
# width axis correction; delta < 0 ⇒ height axis correction.
|
||||||
|
"correction_axis": "width" if float(delta) > 0 else "height",
|
||||||
|
"css_overrides": {
|
||||||
|
"object_fit": "contain",
|
||||||
|
"max_width_px": int(rendered_w),
|
||||||
|
"max_height_px": int(rendered_h),
|
||||||
|
"width": "auto",
|
||||||
|
"height": "auto",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_image_fit_css(plan: dict) -> Optional[str]:
|
||||||
|
"""Build a frame-scoped CSS snippet from a feasible image_fit plan.
|
||||||
|
|
||||||
|
Returns None when plan is infeasible. u7 (Step 17 entry) injects the
|
||||||
|
snippet into the per-slide style override and re-renders.
|
||||||
|
"""
|
||||||
|
if not plan.get("feasible"):
|
||||||
|
return None
|
||||||
|
overrides = plan.get("css_overrides") or {}
|
||||||
|
if not overrides:
|
||||||
|
return None
|
||||||
|
src = plan.get("src") or ""
|
||||||
|
zone_position = plan.get("zone_position") or ""
|
||||||
|
if src:
|
||||||
|
selector = (
|
||||||
|
f".zone[data-zone-position=\"{zone_position}\"] "
|
||||||
|
f"img[src=\"{src}\"]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
selector = f".zone[data-zone-position=\"{zone_position}\"] img"
|
||||||
|
return (
|
||||||
|
f"{selector} {{\n"
|
||||||
|
f" object-fit: {overrides.get('object_fit', 'contain')};\n"
|
||||||
|
f" max-width: {int(overrides.get('max_width_px') or 0)}px;\n"
|
||||||
|
f" max-height: {int(overrides.get('max_height_px') or 0)}px;\n"
|
||||||
|
f" width: {overrides.get('width', 'auto')};\n"
|
||||||
|
f" height: {overrides.get('height', 'auto')};\n"
|
||||||
|
f"}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
# IMP-88 u5 : frame_internal_fit_candidate — Step 17 retry chain.
|
||||||
|
# Operates ONLY inside the frame contract's declared `internal_envelope`
|
||||||
|
# (PHASE-Z-PIPELINE-OVERVIEW.md:333 lock). Sub-mechanism names allowed by
|
||||||
|
# the OVERVIEW: density envelope / line rhythm / internal grid row / text
|
||||||
|
# block allocation — all unified under the single action label
|
||||||
|
# `frame_internal_fit_candidate` so common-CSS/padding shrink antipatterns
|
||||||
|
# stay quarantined ([[feedback_phase_z_spacing_direction]]).
|
||||||
|
#
|
||||||
|
# Envelope shape (dormant — catalog adds when contracts declare it):
|
||||||
|
# frame_contract["internal_envelope"] = {
|
||||||
|
# "variants": [
|
||||||
|
# {"name": "<sub_mechanism_name>",
|
||||||
|
# "excess_budget_px": <int — px of vertical excess this variant can absorb>,
|
||||||
|
# "css_overrides": {<css-property>: <value>, ...}},
|
||||||
|
# ...
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
# Selection = walk variants in catalog order, pick first whose excess_budget_px
|
||||||
|
# >= effective excess_y (greedy). Catalog order = catalog author's priority.
|
||||||
|
#
|
||||||
|
# No frame contract currently declares `internal_envelope`, so the planner
|
||||||
|
# returns infeasible(envelope_present=False) for every live frame today.
|
||||||
|
# Cascade hand-off: NEXT_ACTION_BY_FAILURE['frame_internal_fit_candidate_
|
||||||
|
# insufficient'] = 'frame_reselect' (set in u2). Plan-only — u6 (salvage
|
||||||
|
# dispatcher) and u7 (Step 17 entry) own the runtime call site.
|
||||||
|
#
|
||||||
|
# frame_contract is an overridable kwarg so tests don't pay the mapper
|
||||||
|
# catalog cache cost / pipeline import cycle (mirrors u4's delta_tol).
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def plan_frame_internal_fit_candidate(
|
||||||
|
*, frame_template_id: str,
|
||||||
|
frame_contract: Optional[dict] = None,
|
||||||
|
overflow_zone: Optional[dict] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""frame_internal_fit_candidate planner (Step 17 retry chain — IMP-88 u5).
|
||||||
|
|
||||||
|
Walks `frame_contract['internal_envelope']['variants']` in catalog order
|
||||||
|
and picks the first variant whose excess_budget_px covers `overflow_zone
|
||||||
|
['excess_y']`. Returns infeasible when no contract / no envelope / no
|
||||||
|
variant fits. No common-margin shrink — sub-mechanism CSS is frame-scoped.
|
||||||
|
"""
|
||||||
|
base = {
|
||||||
|
"action": "frame_internal_fit_candidate",
|
||||||
|
"frame_template_id": frame_template_id,
|
||||||
|
}
|
||||||
|
if frame_contract is None:
|
||||||
|
from src.phase_z2_mapper import get_contract
|
||||||
|
frame_contract = get_contract(frame_template_id)
|
||||||
|
if frame_contract is None:
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "envelope_present": False,
|
||||||
|
"candidates_considered": [], "selected_variant": None,
|
||||||
|
"css_overrides": None,
|
||||||
|
"failure_reason": (
|
||||||
|
f"no frame contract registered for template_id "
|
||||||
|
f"'{frame_template_id}' — cannot enumerate internal_envelope."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
envelope = frame_contract.get("internal_envelope")
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "envelope_present": False,
|
||||||
|
"candidates_considered": [], "selected_variant": None,
|
||||||
|
"css_overrides": None,
|
||||||
|
"failure_reason": (
|
||||||
|
f"frame contract '{frame_template_id}' does not declare "
|
||||||
|
f"internal_envelope — cascade should escalate to frame_reselect."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
variants = list(envelope.get("variants") or [])
|
||||||
|
candidates_considered = [v.get("name") for v in variants if isinstance(v, dict)]
|
||||||
|
if not variants:
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "envelope_present": True,
|
||||||
|
"envelope_keys": sorted(envelope.keys()),
|
||||||
|
"candidates_considered": candidates_considered,
|
||||||
|
"selected_variant": None, "css_overrides": None,
|
||||||
|
"failure_reason": (
|
||||||
|
f"frame contract '{frame_template_id}' internal_envelope "
|
||||||
|
f"declares no variants — no sub-mechanism available."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
excess_y = 0
|
||||||
|
if isinstance(overflow_zone, dict):
|
||||||
|
ey = overflow_zone.get("excess_y")
|
||||||
|
if isinstance(ey, (int, float)) and ey > 0:
|
||||||
|
excess_y = int(math.ceil(float(ey)))
|
||||||
|
selected: Optional[dict] = None
|
||||||
|
for variant in variants:
|
||||||
|
if not isinstance(variant, dict):
|
||||||
|
continue
|
||||||
|
budget = variant.get("excess_budget_px")
|
||||||
|
if not isinstance(budget, (int, float)):
|
||||||
|
continue
|
||||||
|
if int(budget) >= excess_y:
|
||||||
|
selected = variant
|
||||||
|
break
|
||||||
|
if selected is None:
|
||||||
|
return {
|
||||||
|
**base, "feasible": False, "envelope_present": True,
|
||||||
|
"envelope_keys": sorted(envelope.keys()),
|
||||||
|
"candidates_considered": candidates_considered,
|
||||||
|
"selected_variant": None, "css_overrides": None,
|
||||||
|
"excess_y": excess_y,
|
||||||
|
"failure_reason": (
|
||||||
|
f"all {len(variants)} internal_envelope variant(s) for "
|
||||||
|
f"'{frame_template_id}' have excess_budget_px below excess_y="
|
||||||
|
f"{excess_y}px — internal fit cannot absorb overflow."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
overrides = selected.get("css_overrides") or {}
|
||||||
|
return {
|
||||||
|
**base, "feasible": True, "envelope_present": True,
|
||||||
|
"envelope_keys": sorted(envelope.keys()),
|
||||||
|
"candidates_considered": candidates_considered,
|
||||||
|
"selected_variant": selected.get("name"),
|
||||||
|
"selected_variant_budget_px": int(selected.get("excess_budget_px") or 0),
|
||||||
|
"excess_y": excess_y,
|
||||||
|
"css_overrides": dict(overrides),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_frame_internal_fit_candidate_css(plan: dict) -> Optional[str]:
|
||||||
|
"""Build a frame-scoped CSS snippet from a feasible frame_internal_fit
|
||||||
|
plan. Returns None when plan is infeasible. u6 / u7 inject the snippet
|
||||||
|
into the per-slide style override and re-render.
|
||||||
|
"""
|
||||||
|
if not plan.get("feasible"):
|
||||||
|
return None
|
||||||
|
overrides = plan.get("css_overrides") or {}
|
||||||
|
if not overrides:
|
||||||
|
return None
|
||||||
|
template_id = plan.get("frame_template_id") or ""
|
||||||
|
if not template_id:
|
||||||
|
return None
|
||||||
|
selector = f".zone[data-template-id=\"{template_id}\"]"
|
||||||
|
body_lines = [
|
||||||
|
f" {prop}: {value};" for prop, value in overrides.items()
|
||||||
|
]
|
||||||
|
return f"{selector} {{\n" + "\n".join(body_lines) + "\n}"
|
||||||
|
|||||||
@@ -25,13 +25,27 @@ from typing import Optional
|
|||||||
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
|
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
|
||||||
|
|
||||||
# category → proposed_action (primary)
|
# 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] = {
|
ACTION_BY_CATEGORY: dict[str, str] = {
|
||||||
"minor_overflow": "zone_ratio_retry",
|
"minor_overflow": "zone_ratio_retry",
|
||||||
"moderate_overflow": "layout_adjust",
|
"moderate_overflow": "layout_adjust",
|
||||||
"structural_minor_overflow": "zone_ratio_retry",
|
"structural_minor_overflow": "zone_ratio_retry",
|
||||||
"structural_major_overflow": "details_popup_escalation",
|
"structural_major_overflow": "details_popup_escalation",
|
||||||
"tabular_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",
|
"layout_zone_mismatch": "layout_adjust",
|
||||||
"hard_visual_fail": "abort",
|
"hard_visual_fail": "abort",
|
||||||
}
|
}
|
||||||
@@ -48,8 +62,13 @@ ACTION_RATIONALE: dict[str, str] = {
|
|||||||
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
|
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
|
||||||
"tabular_overflow":
|
"tabular_overflow":
|
||||||
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
|
"표는 행 단위로 잘리면 의미 손실 → 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":
|
"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":
|
"layout_zone_mismatch":
|
||||||
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
|
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
|
||||||
"hard_visual_fail":
|
"hard_visual_fail":
|
||||||
@@ -61,7 +80,21 @@ ACTION_RATIONALE: dict[str, str] = {
|
|||||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
"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.
|
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
|
||||||
# `plan_details_popup_escalation` (below) provides the deterministic stub
|
# `plan_details_popup_escalation` (below) provides the deterministic stub
|
||||||
# that downstream units consume: u4 binds the AI split-decision contract
|
# that downstream units consume: u4 binds the AI split-decision contract
|
||||||
|
|||||||
299
tests/phase_z2/test_failure_router_imp88_cascade.py
Normal file
299
tests/phase_z2/test_failure_router_imp88_cascade.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
@@ -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["glue_absorption_insufficient"] == "font_step_compression"
|
||||||
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
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["glue_compression"] == "IMPLEMENTED"
|
||||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_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
|
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||||
cases = [
|
cases = [
|
||||||
|
|||||||
483
tests/phase_z2/test_phase_z2_pipeline_salvage_imp88.py
Normal file
483
tests/phase_z2/test_phase_z2_pipeline_salvage_imp88.py
Normal file
@@ -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"<html><head><meta charset='utf-8'></head>"
|
||||||
|
f"<body><div data-slide-title='{slide_title}' "
|
||||||
|
f"data-rendered-preset='{layout_preset}'></div></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
542
tests/phase_z2/test_phase_z2_pipeline_step17_entry_imp88.py
Normal file
542
tests/phase_z2/test_phase_z2_pipeline_step17_entry_imp88.py
Normal file
@@ -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"<html><head><meta charset='utf-8'></head>"
|
||||||
|
f"<body><div data-rendered-preset='{layout_preset}'>"
|
||||||
|
f"<img src='zoneA.png'/></div></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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>" in promoted
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. helper FAIL — out_path NOT promoted, failure_reason recorded ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_fail_leaves_out_path_untouched(project_tmp, monkeypatch):
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||||
|
_stub_render_capture(monkeypatch)
|
||||||
|
fail_payload = {
|
||||||
|
"passed": False,
|
||||||
|
"fail_reasons": ["zone--top (t-top) overflowed by 30px (vert) / 0px (horiz)"],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check", lambda p: fail_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-fail", 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 False
|
||||||
|
assert res["step"]["passed"] is False
|
||||||
|
assert "post_salvage_overflow" not in res["step"]
|
||||||
|
assert res["step"]["failure_reason"] == fail_payload["fail_reasons"]
|
||||||
|
# out_path stays at the pre-helper value — strict PASS-only promotion.
|
||||||
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. helper aggregates multi-event CSS into one re-render ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_aggregates_multi_event_css_into_single_render(project_tmp, monkeypatch):
|
||||||
|
"""Three feasible image events → CSS chunks concatenated → ONE render_slide
|
||||||
|
call (not three). The aggregated style block is injected before </head>."""
|
||||||
|
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": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
events = [
|
||||||
|
_image_event(src="zoneA.png", zone_position="top", delta=0.20),
|
||||||
|
_image_event(src="zoneB.png", zone_position="top", delta=0.25),
|
||||||
|
_image_event(src="zoneC.png", zone_position="bottom",
|
||||||
|
zone_template_id="t-bottom", delta=0.18),
|
||||||
|
]
|
||||||
|
res = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-multi", 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 True
|
||||||
|
assert res["passed"] is True
|
||||||
|
assert counter["n"] == 1
|
||||||
|
promoted = out_path.read_text(encoding="utf-8")
|
||||||
|
# All three image src selectors should appear in the merged overlay.
|
||||||
|
assert "zoneA.png" in promoted
|
||||||
|
assert "zoneB.png" in promoted
|
||||||
|
assert "zoneC.png" in promoted
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. candidate_path is project-relative ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_candidate_path_is_project_root_relative(project_tmp, monkeypatch):
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||||
|
_stub_render_capture(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": False, "fail_reasons": ["nope"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
res = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-relpath", 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.20)],
|
||||||
|
gap_px=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["triggered"] is True
|
||||||
|
cp = res["step"]["candidate_path"]
|
||||||
|
# project-relative path ending with the salvage candidate filename.
|
||||||
|
assert cp.endswith("salvage_image_fit_candidate.html")
|
||||||
|
assert not Path(cp).is_absolute()
|
||||||
|
# Concrete file actually exists on disk.
|
||||||
|
assert (project_tmp / "salvage_image_fit_candidate.html").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 8. delta_tol override threads through ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_passes_delta_tol_through_to_plan_image_fit(project_tmp, monkeypatch):
|
||||||
|
"""An event with |delta|=0.10 is OVER default tol (0.05) but UNDER an
|
||||||
|
override tol of 0.20. With override the helper should see infeasible
|
||||||
|
plans → triggered=False; with default it should see feasible plans."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||||
|
_stub_render_capture(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": True, "fail_reasons": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
ev = _image_event(src="zoneA.png", delta=0.10)
|
||||||
|
# Default tol = IMAGE_ASPECT_DELTA_TOL = 0.05 → plan feasible, triggered.
|
||||||
|
res_default = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-tol", slide_footer=None,
|
||||||
|
zones_data=_zones(), layout_preset="vertical-2",
|
||||||
|
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[ev], gap_px=14,
|
||||||
|
)
|
||||||
|
assert res_default["triggered"] is True
|
||||||
|
assert res_default["event_plans"][0].get("feasible") is True
|
||||||
|
|
||||||
|
# Override tol = 0.20 → plan infeasible (delta 0.10 within tol).
|
||||||
|
out_path.write_text("ORIGINAL", encoding="utf-8") # reset
|
||||||
|
res_override = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-tol", slide_footer=None,
|
||||||
|
zones_data=_zones(), layout_preset="vertical-2",
|
||||||
|
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[ev], gap_px=14,
|
||||||
|
delta_tol=0.20,
|
||||||
|
)
|
||||||
|
assert res_override["triggered"] is False
|
||||||
|
assert res_override["event_plans"][0].get("feasible") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 9. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_fit_stays_out_of_salvage_fail_map():
|
||||||
|
"""u7 entry single-pass is NOT a cascade salvage stage. The u6 guard
|
||||||
|
asserted this; re-assert here so u7's wiring does not accidentally
|
||||||
|
register image_fit into the cascade map."""
|
||||||
|
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
|
||||||
|
# Cascade stages stay as u6 left them: 5 entries, image_fit absent.
|
||||||
|
assert len(_SALVAGE_FAIL_BY_ACTION) == 5
|
||||||
|
assert set(_SALVAGE_FAIL_BY_ACTION) == {
|
||||||
|
"cross_zone_redistribute",
|
||||||
|
"glue_compression",
|
||||||
|
"font_step_compression",
|
||||||
|
"layout_adjust",
|
||||||
|
"frame_internal_fit_candidate",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 10. PASS-only out_path mutation (strict gate) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_strictly_promotes_only_on_pass(project_tmp, monkeypatch):
|
||||||
|
"""Defensive — assert the helper does NOT write out_path on FAIL even
|
||||||
|
after rendering a candidate. Mutation must be strictly gated on
|
||||||
|
passed=True from run_overflow_check."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
canary = "STRICT_PROMOTION_CANARY_DO_NOT_OVERWRITE"
|
||||||
|
out_path.write_text(canary, encoding="utf-8")
|
||||||
|
_stub_render_capture(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": False, "fail_reasons": ["persists"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
res = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-strict", 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 False
|
||||||
|
# The candidate file IS written (telemetry continuity); out_path is NOT.
|
||||||
|
assert (project_tmp / "salvage_image_fit_candidate.html").exists()
|
||||||
|
assert out_path.read_text(encoding="utf-8") == canary
|
||||||
|
|
||||||
|
|
||||||
|
# ── 11. event_plans telemetry continuity even when no CSS emitted ───
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_event_plans_recorded_even_when_not_triggered(project_tmp, monkeypatch):
|
||||||
|
"""Step 17 telemetry must surface every plan_image_fit result via
|
||||||
|
event_plans so Step 18 / Step 19 can read planner-side decisions
|
||||||
|
even when the single-pass did not render a candidate."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||||
|
_stub_render_capture(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: pytest.fail("not triggered → overflow_check must not run"),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = [
|
||||||
|
_image_event(src="zoneA.png", delta=None), # not loaded
|
||||||
|
_image_event(src="zoneB.png", delta=0.02), # under default tol
|
||||||
|
]
|
||||||
|
res = _attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-telemetry", 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 res["step"] is None
|
||||||
|
# Every event still surfaces a plan — telemetry continuity invariant.
|
||||||
|
assert len(res["event_plans"]) == 2
|
||||||
|
failure_reasons = [p.get("failure_reason") for p in res["event_plans"]]
|
||||||
|
assert any("not loaded" in (r or "") for r in failure_reasons)
|
||||||
|
assert any("delta_tol" in (r or "") for r in failure_reasons)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 12. Emitted CSS is img-scoped (Phase Z spacing guardrail) ────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_emits_img_scoped_css_only(project_tmp, monkeypatch):
|
||||||
|
"""[[feedback_phase_z_spacing_direction]] — image_fit must NOT shrink
|
||||||
|
common margins / slide-body / zone gap. The emitted CSS overlay must
|
||||||
|
target img selectors only."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||||
|
_stub_render_capture(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": True, "fail_reasons": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
_attempt_step17_image_fit_single_pass(
|
||||||
|
run_dir=project_tmp, out_path=out_path,
|
||||||
|
slide_title="u7-scope", 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.20)],
|
||||||
|
gap_px=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate = (project_tmp / "salvage_image_fit_candidate.html").read_text(
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
style_block_start = candidate.find("<style>")
|
||||||
|
style_block_end = candidate.find("</style>", style_block_start)
|
||||||
|
assert style_block_start >= 0 and style_block_end > style_block_start
|
||||||
|
style_body = candidate[style_block_start + len("<style>"):style_block_end]
|
||||||
|
# img-scoped selector present.
|
||||||
|
assert "img" in style_body
|
||||||
|
# Phase Z spacing guardrail — none of these shared-spacing tokens
|
||||||
|
# may appear in the image_fit overlay.
|
||||||
|
assert ".slide-body" not in style_body
|
||||||
|
assert "slide-base" not in style_body
|
||||||
|
assert "--spacing-page" not in style_body
|
||||||
|
assert "--spacing-block" not in style_body
|
||||||
|
assert "grid-gap" not in style_body
|
||||||
|
assert "padding-page" not in style_body
|
||||||
252
tests/phase_z2/test_phase_z2_retry_frame_internal_fit.py
Normal file
252
tests/phase_z2/test_phase_z2_retry_frame_internal_fit.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""IMP-88 u5 — plan_frame_internal_fit_candidate / apply tests (Step 17).
|
||||||
|
|
||||||
|
Stage 2 contract (unit u5):
|
||||||
|
- plan_frame_internal_fit_candidate operates ONLY inside the frame
|
||||||
|
contract's declared `internal_envelope` (PHASE-Z-PIPELINE-OVERVIEW.md
|
||||||
|
:333 lock). No internal_envelope → infeasible(envelope_present=False).
|
||||||
|
Envelope present + variant.excess_budget_px >= overflow_zone.excess_y
|
||||||
|
→ feasible with selected_variant + frame-scoped css_overrides.
|
||||||
|
- apply_frame_internal_fit_candidate_css(plan) emits a frame-scoped CSS
|
||||||
|
rule (`.zone[data-template-id="<template_id>"]` selector) from the
|
||||||
|
selected variant's css_overrides. None on infeasible.
|
||||||
|
- Honors feedback_phase_z_spacing_direction — frame-scoped only, no
|
||||||
|
common margin / slide-body / zone gap shrink.
|
||||||
|
- Default contract loader (mapper.get_contract) overridable as kwarg so
|
||||||
|
tests stay free of the catalog cache / pipeline import cycle.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_frame_internal_fit_candidate_css,
|
||||||
|
plan_frame_internal_fit_candidate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _contract(*, variants=None, with_envelope=True) -> dict:
|
||||||
|
"""Build a synthetic frame contract for u5 planner tests."""
|
||||||
|
c: dict = {
|
||||||
|
"template_id": "frame_internal_fit_test",
|
||||||
|
"source_shape": "top_bullets",
|
||||||
|
"cardinality": {"strict": 3},
|
||||||
|
}
|
||||||
|
if with_envelope:
|
||||||
|
c["internal_envelope"] = {
|
||||||
|
"variants": list(variants or []),
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _variant(*, name: str, budget_px: int, css: dict | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"excess_budget_px": budget_px,
|
||||||
|
"css_overrides": dict(css or {"--frame-density": "compact"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── planner: no-envelope infeasible paths ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_contract_infeasible_with_clear_reason():
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="non_existent_frame",
|
||||||
|
frame_contract={}, # caller passed empty dict — explicit no-contract
|
||||||
|
)
|
||||||
|
# Empty dict counts as "contract present but no internal_envelope" → present=False.
|
||||||
|
assert plan["action"] == "frame_internal_fit_candidate"
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["envelope_present"] is False
|
||||||
|
assert plan["selected_variant"] is None
|
||||||
|
assert plan["css_overrides"] is None
|
||||||
|
assert "internal_envelope" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_lookup_none_returns_no_contract_failure():
|
||||||
|
# Simulate mapper.get_contract returning None: caller passes None explicitly
|
||||||
|
# via overriding kwarg path — planner falls back to mapper path then returns
|
||||||
|
# the dedicated "no frame contract registered" failure.
|
||||||
|
# We exercise this directly by passing a sentinel template_id that has no
|
||||||
|
# entry; the planner default-loads via mapper.get_contract.
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="__sentinel_unregistered_template__",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["envelope_present"] is False
|
||||||
|
assert "no frame contract registered" in plan["failure_reason"]
|
||||||
|
assert "__sentinel_unregistered_template__" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_without_internal_envelope_infeasible():
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test",
|
||||||
|
frame_contract=_contract(with_envelope=False),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["envelope_present"] is False
|
||||||
|
assert "does not declare internal_envelope" in plan["failure_reason"]
|
||||||
|
assert "frame_reselect" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_present_but_empty_variants_infeasible():
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test",
|
||||||
|
frame_contract=_contract(variants=[]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["envelope_present"] is True
|
||||||
|
assert plan["candidates_considered"] == []
|
||||||
|
assert "no variants" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── planner: feasible paths ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_variant_within_budget_is_selected():
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="density_compact", budget_px=40,
|
||||||
|
css={"--frame-density": "compact", "font-size": "0.95em"}),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test",
|
||||||
|
frame_contract=contract,
|
||||||
|
overflow_zone={"excess_y": 24.0},
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["envelope_present"] is True
|
||||||
|
assert plan["selected_variant"] == "density_compact"
|
||||||
|
assert plan["selected_variant_budget_px"] == 40
|
||||||
|
assert plan["excess_y"] == 24
|
||||||
|
assert plan["css_overrides"]["font-size"] == "0.95em"
|
||||||
|
assert plan["css_overrides"]["--frame-density"] == "compact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_greedy_walk_picks_first_variant_that_fits_in_catalog_order():
|
||||||
|
# density_compact only absorbs 10px; line_rhythm 60px; grid_row 200px.
|
||||||
|
# excess_y=45 → density_compact rejected, line_rhythm selected (first fit).
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="density_compact", budget_px=10),
|
||||||
|
_variant(name="line_rhythm_tight", budget_px=60),
|
||||||
|
_variant(name="grid_row_collapse", budget_px=200),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test",
|
||||||
|
frame_contract=contract,
|
||||||
|
overflow_zone={"excess_y": 45.0},
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["selected_variant"] == "line_rhythm_tight"
|
||||||
|
assert plan["selected_variant_budget_px"] == 60
|
||||||
|
assert plan["candidates_considered"] == [
|
||||||
|
"density_compact", "line_rhythm_tight", "grid_row_collapse",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_overflow_zone_picks_first_variant():
|
||||||
|
# excess_y default = 0 → every variant qualifies; first catalog entry wins.
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="first", budget_px=5),
|
||||||
|
_variant(name="second", budget_px=100),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test", frame_contract=contract,
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["selected_variant"] == "first"
|
||||||
|
assert plan["excess_y"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_variants_below_budget_returns_infeasible_with_excess():
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="small", budget_px=10),
|
||||||
|
_variant(name="medium", budget_px=25),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test", frame_contract=contract,
|
||||||
|
overflow_zone={"excess_y": 80.0},
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["envelope_present"] is True
|
||||||
|
assert plan["excess_y"] == 80
|
||||||
|
assert plan["selected_variant"] is None
|
||||||
|
assert "excess_y=80px" in plan["failure_reason"]
|
||||||
|
assert plan["candidates_considered"] == ["small", "medium"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_excess_y_is_ceil_rounded():
|
||||||
|
# Sub-pixel overflow rounds up so a budget exactly matching the integer
|
||||||
|
# excess covers the case. 23.4 → 24 → variant(budget=24) is selected.
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="exact_fit", budget_px=24),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test", frame_contract=contract,
|
||||||
|
overflow_zone={"excess_y": 23.4},
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["excess_y"] == 24
|
||||||
|
assert plan["selected_variant"] == "exact_fit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_keys_recorded_for_telemetry():
|
||||||
|
contract = _contract(variants=[_variant(name="v1", budget_px=100)])
|
||||||
|
# Inject an extra envelope key to ensure planner surfaces them.
|
||||||
|
contract["internal_envelope"]["envelope_kind"] = "density_grid"
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test", frame_contract=contract,
|
||||||
|
)
|
||||||
|
assert "envelope_kind" in plan["envelope_keys"]
|
||||||
|
assert "variants" in plan["envelope_keys"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── apply: frame-scoped CSS snippet ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_emits_frame_template_scoped_selector():
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="density_compact", budget_px=100, css={
|
||||||
|
"--frame-density": "compact",
|
||||||
|
"font-size": "0.92em",
|
||||||
|
"line-height": "1.35",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="three_parallel_requirements",
|
||||||
|
frame_contract=contract,
|
||||||
|
)
|
||||||
|
css = apply_frame_internal_fit_candidate_css(plan)
|
||||||
|
assert css is not None
|
||||||
|
assert ".zone[data-template-id=\"three_parallel_requirements\"]" in css
|
||||||
|
assert "--frame-density: compact;" in css
|
||||||
|
assert "font-size: 0.92em;" in css
|
||||||
|
assert "line-height: 1.35;" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_infeasible_returns_none():
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test",
|
||||||
|
frame_contract=_contract(with_envelope=False),
|
||||||
|
)
|
||||||
|
assert apply_frame_internal_fit_candidate_css(plan) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_does_not_shrink_shared_spacing():
|
||||||
|
# feedback_phase_z_spacing_direction: emitted CSS must scope to the frame
|
||||||
|
# only and MUST NOT touch slide-body / outer zone / gap / common margin /
|
||||||
|
# padding tokens. The selector is frame-scoped; body comes from author-
|
||||||
|
# declared envelope css_overrides. We sanity-check that nothing in the
|
||||||
|
# apply helper introduces shared-spacing properties of its own.
|
||||||
|
contract = _contract(variants=[
|
||||||
|
_variant(name="density_compact", budget_px=100,
|
||||||
|
css={"--frame-density": "compact"}),
|
||||||
|
])
|
||||||
|
plan = plan_frame_internal_fit_candidate(
|
||||||
|
frame_template_id="f_test", frame_contract=contract,
|
||||||
|
)
|
||||||
|
css = apply_frame_internal_fit_candidate_css(plan)
|
||||||
|
assert css is not None
|
||||||
|
for forbidden in (".slide-body", ".zone-container", "grid-gap", "gap:",
|
||||||
|
"padding:", "margin:"):
|
||||||
|
assert forbidden not in css, (
|
||||||
|
f"frame_internal_fit CSS leaked shared-spacing token "
|
||||||
|
f"'{forbidden}' — see feedback_phase_z_spacing_direction."
|
||||||
|
)
|
||||||
198
tests/phase_z2/test_phase_z2_retry_image_fit.py
Normal file
198
tests/phase_z2/test_phase_z2_retry_image_fit.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""IMP-88 u4 — plan_image_fit / apply_image_fit_css tests (Step 17 entry).
|
||||||
|
|
||||||
|
Stage 2 contract (unit u4):
|
||||||
|
- plan_image_fit consumes a single image_event (overflow_metrics.image_
|
||||||
|
events shape: natural_w/h, rendered_w/h, natural_ratio, rendered_ratio,
|
||||||
|
delta, src, zone_position, zone_template_id) and returns:
|
||||||
|
success : {feasible=True, css_overrides={object_fit, max_width_px,
|
||||||
|
max_height_px, width, height}, delta, correction_axis,
|
||||||
|
natural_*, rendered_*}
|
||||||
|
no-op : {feasible=False, failure_reason} when |delta| <= tol or
|
||||||
|
delta is None
|
||||||
|
infeas : {feasible=False, failure_reason} when rendered_w/h missing
|
||||||
|
or non-positive
|
||||||
|
- apply_image_fit_css(plan) returns a frame-scoped CSS rule string for
|
||||||
|
feasible plans (object-fit + max-w/h constraints scoped to the zone +
|
||||||
|
src image), None for infeasible plans.
|
||||||
|
- Honors feedback_phase_z_spacing_direction — image-scoped CSS only, no
|
||||||
|
common margin / frame envelope shrink.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_retry import apply_image_fit_css, plan_image_fit
|
||||||
|
|
||||||
|
|
||||||
|
def _image_event(
|
||||||
|
*, delta: float | None = 0.20, src: str = "/images/diagram.png",
|
||||||
|
zone_position: str = "top", zone_template_id: str = "frame_07",
|
||||||
|
natural_w: int = 1200, natural_h: int = 800,
|
||||||
|
rendered_w: int = 600, rendered_h: int = 300,
|
||||||
|
) -> dict:
|
||||||
|
"""image_event shape mirroring runtime overflow_metrics.image_events[i]."""
|
||||||
|
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,
|
||||||
|
"bbox": {"x": 0, "y": 0, "w": rendered_w, "h": rendered_h},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── planner: success paths ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_positive_delta_emits_width_correction_axis():
|
||||||
|
# natural 1200x800 (1.5), rendered 600x300 (2.0) → delta = +0.5
|
||||||
|
ev = _image_event(
|
||||||
|
delta=0.5,
|
||||||
|
natural_w=1200, natural_h=800,
|
||||||
|
rendered_w=600, rendered_h=300,
|
||||||
|
)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["action"] == "image_fit"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["correction_axis"] == "width"
|
||||||
|
assert plan["delta"] == 0.5
|
||||||
|
assert plan["natural_w"] == 1200
|
||||||
|
assert plan["rendered_w"] == 600
|
||||||
|
overrides = plan["css_overrides"]
|
||||||
|
assert overrides["object_fit"] == "contain"
|
||||||
|
assert overrides["max_width_px"] == 600
|
||||||
|
assert overrides["max_height_px"] == 300
|
||||||
|
assert overrides["width"] == "auto"
|
||||||
|
assert overrides["height"] == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_delta_emits_height_correction_axis():
|
||||||
|
# natural 800x1200 (~0.667), rendered 600x600 (1.0) → delta = +0.333 not -;
|
||||||
|
# use rendered taller than natural for negative delta.
|
||||||
|
ev = _image_event(
|
||||||
|
delta=-0.40,
|
||||||
|
natural_w=1600, natural_h=800,
|
||||||
|
rendered_w=400, rendered_h=400,
|
||||||
|
)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["correction_axis"] == "height"
|
||||||
|
assert plan["css_overrides"]["max_width_px"] == 400
|
||||||
|
assert plan["css_overrides"]["max_height_px"] == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_passes_through_zone_and_template_metadata():
|
||||||
|
ev = _image_event(
|
||||||
|
delta=0.20,
|
||||||
|
src="/img/policy.png",
|
||||||
|
zone_position="bottom-right",
|
||||||
|
zone_template_id="f23",
|
||||||
|
)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["src"] == "/img/policy.png"
|
||||||
|
assert plan["zone_position"] == "bottom-right"
|
||||||
|
assert plan["zone_template_id"] == "f23"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── planner: infeasible / no-op paths ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_delta_none_infeasible_with_clear_reason():
|
||||||
|
ev = _image_event(delta=None)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["css_overrides"] is None
|
||||||
|
assert "delta is None" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delta_within_tolerance_returns_planner_noop():
|
||||||
|
ev = _image_event(delta=0.02)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert "no image_aspect_mismatch to correct" in plan["failure_reason"]
|
||||||
|
# No-op path still records delta + action for telemetry continuity.
|
||||||
|
assert plan["delta"] == 0.02
|
||||||
|
assert plan["action"] == "image_fit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delta_at_boundary_is_planner_noop():
|
||||||
|
# |delta| == delta_tol is treated as no-op (strict greater-than is the
|
||||||
|
# emission threshold in the classifier).
|
||||||
|
ev = _image_event(delta=0.05)
|
||||||
|
plan = plan_image_fit(image_event=ev, delta_tol=0.05)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rendered_w_zero_infeasible():
|
||||||
|
ev = _image_event(rendered_w=0)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert "rendered_w / rendered_h" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rendered_h_missing_infeasible():
|
||||||
|
ev = _image_event()
|
||||||
|
ev.pop("rendered_h", None)
|
||||||
|
plan = plan_image_fit(image_event=ev)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["css_overrides"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_delta_tol_widens_noop_band():
|
||||||
|
ev = _image_event(delta=0.10)
|
||||||
|
# default tol=0.05 → feasible, but caller-supplied tol=0.20 → no-op.
|
||||||
|
assert plan_image_fit(image_event=ev)["feasible"] is True
|
||||||
|
assert plan_image_fit(image_event=ev, delta_tol=0.20)["feasible"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── apply: frame-scoped CSS snippet ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_image_fit_css_emits_zone_and_src_scoped_selector():
|
||||||
|
plan = plan_image_fit(image_event=_image_event(
|
||||||
|
delta=0.30,
|
||||||
|
src="/images/process.png",
|
||||||
|
zone_position="top",
|
||||||
|
rendered_w=520, rendered_h=240,
|
||||||
|
))
|
||||||
|
css = apply_image_fit_css(plan)
|
||||||
|
assert css is not None
|
||||||
|
assert ".zone[data-zone-position=\"top\"]" in css
|
||||||
|
assert "img[src=\"/images/process.png\"]" in css
|
||||||
|
assert "object-fit: contain;" in css
|
||||||
|
assert "max-width: 520px;" in css
|
||||||
|
assert "max-height: 240px;" in css
|
||||||
|
assert "width: auto;" in css
|
||||||
|
assert "height: auto;" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_image_fit_css_without_src_falls_back_to_zone_only_selector():
|
||||||
|
ev = _image_event(delta=0.30, src="")
|
||||||
|
css = apply_image_fit_css(plan_image_fit(image_event=ev))
|
||||||
|
assert css is not None
|
||||||
|
assert ".zone[data-zone-position=\"top\"] img {" in css
|
||||||
|
assert "img[src=" not in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_image_fit_css_infeasible_returns_none():
|
||||||
|
plan = plan_image_fit(image_event=_image_event(delta=None))
|
||||||
|
assert apply_image_fit_css(plan) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_image_fit_css_does_not_shrink_shared_spacing():
|
||||||
|
# feedback_phase_z_spacing_direction: CSS must scope to image only and
|
||||||
|
# MUST NOT touch slide-body / zone / frame / gap / margin / padding.
|
||||||
|
plan = plan_image_fit(image_event=_image_event(delta=0.30))
|
||||||
|
css = apply_image_fit_css(plan)
|
||||||
|
assert css is not None
|
||||||
|
for forbidden in (".slide-body", ".zone-container", "padding:", "margin:",
|
||||||
|
"gap:", "grid-gap"):
|
||||||
|
assert forbidden not in css, (
|
||||||
|
f"image_fit CSS leaked shared-spacing token '{forbidden}'"
|
||||||
|
)
|
||||||
168
tests/phase_z2/test_phase_z2_retry_layout_adjust.py
Normal file
168
tests/phase_z2/test_phase_z2_retry_layout_adjust.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""IMP-88 u3 — plan_layout_adjust tests (Step 17 retry chain).
|
||||||
|
|
||||||
|
Stage 2 contract (unit u3):
|
||||||
|
- planner returns {feasible, new_layout_preset, new_zones_data,
|
||||||
|
position_remap, candidates_considered, swap_topology_from/to} on success;
|
||||||
|
{feasible=False, failure_reason} on infeasible (no sibling, unknown
|
||||||
|
preset, zone-count mismatch).
|
||||||
|
- apply_layout_adjust_layout_css(plan, gap_px) builds a fresh layout_css
|
||||||
|
via build_layout_css with the swapped preset + remapped zones_data; raw_
|
||||||
|
zone_layout records layout_adjust_applied/from/to provenance. Infeasible
|
||||||
|
plan -> None (dispatcher u6 skips re-render).
|
||||||
|
- Honors feedback_phase_z_spacing_direction — preset swap only, no shared
|
||||||
|
spacing shrink claim.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_composition import LAYOUT_PRESETS
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_layout_adjust_layout_css,
|
||||||
|
plan_layout_adjust,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _zones(positions: list[str]) -> list[dict]:
|
||||||
|
"""Minimal zones_data shape for planner consumption."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"position": pos,
|
||||||
|
"template_id": f"frame_{i}",
|
||||||
|
"min_height_px": 120,
|
||||||
|
"content_weight": {"score": 1.0},
|
||||||
|
"slot_payload": {"title": f"zone_{i}"},
|
||||||
|
}
|
||||||
|
for i, pos in enumerate(positions)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── planner: success paths ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_horizontal_2_swaps_to_vertical_2_orientation_axis():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="horizontal-2",
|
||||||
|
zones_data=_zones(["top", "bottom"]),
|
||||||
|
)
|
||||||
|
assert plan["action"] == "layout_adjust"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["new_layout_preset"] == "vertical-2"
|
||||||
|
assert plan["unit_count"] == 2
|
||||||
|
assert plan["swap_topology_from"] == "rows"
|
||||||
|
assert plan["swap_topology_to"] == "cols"
|
||||||
|
assert plan["position_remap"] == {"top": "left", "bottom": "right"}
|
||||||
|
new_zd = plan["new_zones_data"]
|
||||||
|
assert [z["position"] for z in new_zd] == ["left", "right"]
|
||||||
|
# Non-position payload preserved through remap.
|
||||||
|
assert new_zd[0]["template_id"] == "frame_0"
|
||||||
|
assert new_zd[1]["slot_payload"] == {"title": "zone_1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_vertical_2_swaps_back_to_horizontal_2():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="vertical-2",
|
||||||
|
zones_data=_zones(["left", "right"]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["new_layout_preset"] == "horizontal-2"
|
||||||
|
assert plan["position_remap"] == {"left": "top", "right": "bottom"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_T_swaps_to_inverted_T_first_by_topology_priority():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="top-1-bottom-2",
|
||||||
|
zones_data=_zones(["top", "bottom-left", "bottom-right"]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
# 3-unit siblings: top-2-bottom-1, left-1-right-2, left-2-right-1.
|
||||||
|
# _layout_swap_priority puts T<->inverted-T at priority 1 (before side-T).
|
||||||
|
assert plan["new_layout_preset"] == "top-2-bottom-1"
|
||||||
|
assert plan["candidates_considered"] == [
|
||||||
|
"top-2-bottom-1", "left-1-right-2", "left-2-right-1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── planner: infeasible paths ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_preset_infeasible_no_sibling():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="single",
|
||||||
|
zones_data=_zones(["primary"]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["new_layout_preset"] is None
|
||||||
|
assert plan["unit_count"] == 1
|
||||||
|
assert plan["candidates_considered"] == []
|
||||||
|
assert "no render-ready 8-preset sibling" in plan["failure_reason"]
|
||||||
|
assert "single (1)" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_2x2_preset_infeasible_no_sibling():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="grid-2x2",
|
||||||
|
zones_data=_zones(["top-left", "top-right", "bottom-left", "bottom-right"]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["candidates_considered"] == []
|
||||||
|
assert "grid-2x2 (4)" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_preset_infeasible_with_clear_reason():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="not-a-real-preset",
|
||||||
|
zones_data=_zones(["top", "bottom"]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["new_layout_preset"] is None
|
||||||
|
assert "not in LAYOUT_PRESETS catalog" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_count_mismatch_infeasible():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="horizontal-2",
|
||||||
|
zones_data=_zones(["top", "bottom", "extra"]),
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert "length 3" in plan["failure_reason"]
|
||||||
|
assert "horizontal-2" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── apply: layout_css construction + provenance ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_layout_adjust_builds_new_layout_css_with_provenance():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="horizontal-2",
|
||||||
|
zones_data=_zones(["top", "bottom"]),
|
||||||
|
)
|
||||||
|
layout_css = apply_layout_adjust_layout_css(plan, gap_px=20)
|
||||||
|
assert layout_css is not None
|
||||||
|
# Mirrors build_layout_css(vertical-2, ...) output shape.
|
||||||
|
assert layout_css["areas"] == LAYOUT_PRESETS["vertical-2"]["css_areas"]
|
||||||
|
assert "heights_px" in layout_css and "widths_px" in layout_css
|
||||||
|
raw = layout_css["raw_zone_layout"]
|
||||||
|
assert raw["layout_adjust_applied"] is True
|
||||||
|
assert raw["layout_adjust_from"] == "horizontal-2"
|
||||||
|
assert raw["layout_adjust_to"] == "vertical-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_layout_adjust_infeasible_returns_none():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="single",
|
||||||
|
zones_data=_zones(["primary"]),
|
||||||
|
)
|
||||||
|
assert apply_layout_adjust_layout_css(plan, gap_px=20) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_layout_adjust_T_swap_produces_2d_dynamic_css():
|
||||||
|
plan = plan_layout_adjust(
|
||||||
|
current_layout_preset="top-1-bottom-2",
|
||||||
|
zones_data=_zones(["top", "bottom-left", "bottom-right"]),
|
||||||
|
)
|
||||||
|
layout_css = apply_layout_adjust_layout_css(plan, gap_px=20)
|
||||||
|
assert layout_css is not None
|
||||||
|
# top-2-bottom-1 is a 2-D dynamic preset.
|
||||||
|
assert layout_css["dynamic_rows"] is True
|
||||||
|
assert layout_css["dynamic_cols"] is True
|
||||||
|
assert layout_css["areas"] == LAYOUT_PRESETS["top-2-bottom-1"]["css_areas"]
|
||||||
|
assert layout_css["raw_zone_layout"]["layout_adjust_to"] == "top-2-bottom-1"
|
||||||
@@ -198,10 +198,19 @@ def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkey
|
|||||||
|
|
||||||
|
|
||||||
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||||
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
"""(c) All cascade actions are infeasible (no CSS / no candidate emitted
|
||||||
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
by any planner) → run_overflow_check is never invoked, salvage_passed=
|
||||||
salvage_steps has three failed entries, and out_path is unchanged
|
False, out_path is unchanged (original final.html intact — (b)-revert
|
||||||
(original final.html intact — (b)-revert preserved)."""
|
preserved).
|
||||||
|
|
||||||
|
IMP-88 u6 extends the cascade depth from 3 to 5 stages (layout_adjust +
|
||||||
|
frame_internal_fit_candidate added). When all stages are infeasible the
|
||||||
|
cascade now runs through all five — the empty zones_data carried by
|
||||||
|
_kwargs() makes plan_layout_adjust infeasible (length mismatch) and the
|
||||||
|
empty resulting template_id makes plan_frame_internal_fit_candidate
|
||||||
|
infeasible (no contract). The (b)-revert contract this test locks
|
||||||
|
(out_path untouched + salvage_passed=False) is unchanged.
|
||||||
|
"""
|
||||||
out_path = project_tmp / "final.html"
|
out_path = project_tmp / "final.html"
|
||||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
@@ -231,18 +240,24 @@ def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
|||||||
|
|
||||||
assert trace["salvage_attempted"] is True
|
assert trace["salvage_attempted"] is True
|
||||||
assert trace["salvage_passed"] is False
|
assert trace["salvage_passed"] is False
|
||||||
assert len(trace["salvage_steps"]) == 3
|
# IMP-88 u6 — cascade depth extended from 3 to 5; see _SALVAGE_FAIL_BY_ACTION.
|
||||||
|
assert len(trace["salvage_steps"]) == 5
|
||||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||||
assert actions == [
|
assert actions == [
|
||||||
"cross_zone_redistribute",
|
"cross_zone_redistribute",
|
||||||
"glue_compression",
|
"glue_compression",
|
||||||
"font_step_compression",
|
"font_step_compression",
|
||||||
|
"layout_adjust",
|
||||||
|
"frame_internal_fit_candidate",
|
||||||
]
|
]
|
||||||
for step in trace["salvage_steps"]:
|
for step in trace["salvage_steps"]:
|
||||||
assert step["passed"] is False
|
assert step["passed"] is False
|
||||||
assert step["css_override"] is None
|
# layout_adjust uses a distinct render path → its step dict has no
|
||||||
|
# css_override key (new_layout_preset is the observability field
|
||||||
|
# instead). All other branches use the shared CSS-overlay path.
|
||||||
|
assert step.get("css_override") is None
|
||||||
assert step["failure_reason"]
|
assert step["failure_reason"]
|
||||||
# No CSS emitted anywhere → no render_slide calls either.
|
# No CSS / candidate emitted anywhere → no render_slide calls either.
|
||||||
assert render_counter["n"] == 0
|
assert render_counter["n"] == 0
|
||||||
# (b) revert: out_path is untouched.
|
# (b) revert: out_path is untouched.
|
||||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||||
|
|||||||
223
tests/phase_z2/test_router_actions_imp88.py
Normal file
223
tests/phase_z2/test_router_actions_imp88.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""IMP-88 (#88) u1 — Step 17 retry chain router rows + status surface.
|
||||||
|
|
||||||
|
Stage 2 binding contract (unit u1, data-surface only):
|
||||||
|
- NEW row `image_aspect_mismatch → image_fit` in ACTION_BY_CATEGORY.
|
||||||
|
Closes the unmapped classifier emission gap at
|
||||||
|
src/phase_z2_classifier.py:434-447 where image_aspect_mismatch was
|
||||||
|
emitted with proposed_action=None (verified Stage 1).
|
||||||
|
- REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
|
||||||
|
(previously frame_reselect) per PHASE-Z-PIPELINE-OVERVIEW.md:321.
|
||||||
|
frame_reselect remains a valid downstream action via the
|
||||||
|
failure_router cascade (rerender_still_fails → frame_reselect).
|
||||||
|
- NEW ACTION_RATIONALE rows for image_aspect_mismatch +
|
||||||
|
frame_internal_fit_candidate (rationale text for trace surface).
|
||||||
|
- NEW ACTION_IMPLEMENTATION_STATUS rows for image_fit +
|
||||||
|
frame_internal_fit_candidate. layout_adjust is also registered.
|
||||||
|
u1 initial state was MISSING for all three. u7 completion flips the
|
||||||
|
rows to IMPLEMENTED once the end-to-end path (u3/u4/u5 planners +
|
||||||
|
u6 dispatcher + u7 Step 17 entry) is wired (same convention as
|
||||||
|
IMP-12 u7 cascade rows + IMP-35 u3 details_popup_escalation flip).
|
||||||
|
|
||||||
|
Out of scope for u1 (locked in Stage 2 exit report):
|
||||||
|
- failure_router cascade rows for the three actions → u2.
|
||||||
|
- planner stubs (plan_layout_adjust / plan_image_fit /
|
||||||
|
plan_frame_internal_fit_candidate) → u3 / u4 / u5.
|
||||||
|
- salvage dispatcher branches + Step 17 entry triggers → u6 / u7.
|
||||||
|
|
||||||
|
Post u7 completion (2026-05-24): status assertions in this file reflect
|
||||||
|
the IMPLEMENTED end-state. Test names that previously referenced "_missing"
|
||||||
|
are renamed to "_implemented_after_u7" so the surface contract is honest
|
||||||
|
about the post-u7 state.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_router import (
|
||||||
|
ACTION_BY_CATEGORY,
|
||||||
|
ACTION_IMPLEMENTATION_STATUS,
|
||||||
|
ACTION_RATIONALE,
|
||||||
|
route_action,
|
||||||
|
route_fit_classification,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ACTION_BY_CATEGORY rows ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_aspect_mismatch_maps_to_image_fit():
|
||||||
|
"""u1 — NEW row closes the classifier→router gap.
|
||||||
|
|
||||||
|
Stage 1 verified that route_action('image_aspect_mismatch') returned
|
||||||
|
proposed_action=None with implementation_status='unknown'. u1 must
|
||||||
|
register the row so the classifier emission is routable.
|
||||||
|
"""
|
||||||
|
assert ACTION_BY_CATEGORY["image_aspect_mismatch"] == "image_fit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_capacity_mismatch_remaps_to_frame_internal_fit_candidate():
|
||||||
|
"""u1 — REMAP per PHASE-Z-PIPELINE-OVERVIEW.md:321.
|
||||||
|
|
||||||
|
Spec lock: frame_internal_fit_candidate is the per-zone first-pass
|
||||||
|
salvage inside the declared frame envelope. frame_reselect (V4 top-k
|
||||||
|
alternate frame swap) remains downstream via the failure_router
|
||||||
|
cascade (rerender_still_fails → frame_reselect).
|
||||||
|
"""
|
||||||
|
assert ACTION_BY_CATEGORY["frame_capacity_mismatch"] == "frame_internal_fit_candidate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_action_by_category_rows_unchanged():
|
||||||
|
"""u1 — non-IMP-88 rows must NOT be touched (regression guard).
|
||||||
|
|
||||||
|
Only two edits are allowed in u1: NEW image_aspect_mismatch row and
|
||||||
|
REMAP frame_capacity_mismatch row. Everything else is locked.
|
||||||
|
"""
|
||||||
|
assert ACTION_BY_CATEGORY["minor_overflow"] == "zone_ratio_retry"
|
||||||
|
assert ACTION_BY_CATEGORY["moderate_overflow"] == "layout_adjust"
|
||||||
|
assert ACTION_BY_CATEGORY["structural_minor_overflow"] == "zone_ratio_retry"
|
||||||
|
assert ACTION_BY_CATEGORY["structural_major_overflow"] == "details_popup_escalation"
|
||||||
|
assert ACTION_BY_CATEGORY["tabular_overflow"] == "details_popup_escalation"
|
||||||
|
assert ACTION_BY_CATEGORY["layout_zone_mismatch"] == "layout_adjust"
|
||||||
|
assert ACTION_BY_CATEGORY["hard_visual_fail"] == "abort"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ACTION_RATIONALE rows ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_aspect_mismatch_rationale_present():
|
||||||
|
"""u1 — trace surface must explain *why* image_aspect_mismatch routes
|
||||||
|
onto image_fit (frame-scoped, no global image CSS shrink — honors
|
||||||
|
feedback_phase_z_spacing_direction)."""
|
||||||
|
rationale = ACTION_RATIONALE.get("image_aspect_mismatch", "")
|
||||||
|
assert rationale.strip(), "image_aspect_mismatch rationale must be non-empty"
|
||||||
|
assert "image" in rationale.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_capacity_mismatch_rationale_updated_for_internal_fit():
|
||||||
|
"""u1 — rationale text must reflect the new internal-fit-first
|
||||||
|
routing. The text must no longer claim frame_reselect as the primary
|
||||||
|
action for this category (it's now the downstream cascade step)."""
|
||||||
|
rationale = ACTION_RATIONALE.get("frame_capacity_mismatch", "")
|
||||||
|
assert rationale.strip(), "frame_capacity_mismatch rationale must be non-empty"
|
||||||
|
# Mentions the new internal-fit direction.
|
||||||
|
assert "internal" in rationale.lower() or "envelope" in rationale.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ACTION_IMPLEMENTATION_STATUS rows ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_adjust_status_implemented_after_u7():
|
||||||
|
"""u1 registered layout_adjust row (initial MISSING). After u3
|
||||||
|
(plan_layout_adjust + apply_layout_adjust_layout_css) + u6 (salvage
|
||||||
|
dispatcher branch) + u7 (cascade entry trigger) land the end-to-end
|
||||||
|
deterministic path, the status flips to IMPLEMENTED."""
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_fit_status_implemented_after_u7():
|
||||||
|
"""u1 registered image_fit row (initial MISSING). After u4 (plan_image_fit
|
||||||
|
+ apply_image_fit_css) + u7 (_attempt_step17_image_fit_single_pass entry)
|
||||||
|
land the end-to-end deterministic path, the status flips to IMPLEMENTED."""
|
||||||
|
assert "image_fit" in ACTION_IMPLEMENTATION_STATUS
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["image_fit"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_internal_fit_candidate_status_implemented_after_u7():
|
||||||
|
"""u1 registered frame_internal_fit_candidate row (initial MISSING). After
|
||||||
|
u5 (plan_frame_internal_fit_candidate + apply) + u6 (salvage dispatcher
|
||||||
|
branch) + u7 (cascade entry trigger) land the end-to-end deterministic
|
||||||
|
path, the status flips to IMPLEMENTED."""
|
||||||
|
assert "frame_internal_fit_candidate" in ACTION_IMPLEMENTATION_STATUS
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["frame_internal_fit_candidate"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_action_implementation_status_rows_unchanged():
|
||||||
|
"""u1 — non-IMP-88 status rows must NOT regress. zone_ratio_retry,
|
||||||
|
cascade-only salvage actions, details_popup_escalation (IMP-35 u3),
|
||||||
|
and frame_reselect must keep their current statuses."""
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["zone_ratio_retry"] == "IMPLEMENTED"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "IMPLEMENTED"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["frame_reselect"] == "PARTIAL"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["adapter_needed"] == "PARTIAL"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["abort"] == "IMPLEMENTED"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["cross_zone_redistribute"] == "IMPLEMENTED"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||||
|
assert ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── route_action + route_fit_classification integration ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_action_image_aspect_mismatch_returns_image_fit_implemented():
|
||||||
|
"""u1 — route_action surface composes the new mapping correctly.
|
||||||
|
|
||||||
|
Stage 1 evidence: previously this call returned proposed_action=None
|
||||||
|
and implementation_status='unknown'. After u1 + u4 + u7, the call must
|
||||||
|
return image_fit with status IMPLEMENTED (end-to-end deterministic path
|
||||||
|
via plan_image_fit + apply_image_fit_css + Step 17 single-pass entry)."""
|
||||||
|
routing = route_action("image_aspect_mismatch")
|
||||||
|
assert routing["proposed_action"] == "image_fit"
|
||||||
|
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||||
|
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
|
||||||
|
assert routing["rationale"], "rationale must be carried through route_action"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_action_frame_capacity_mismatch_returns_frame_internal_fit_candidate_implemented():
|
||||||
|
"""u1 — route_action surface reflects the REMAP. After u1 + u5 + u6 + u7
|
||||||
|
the status is IMPLEMENTED (end-to-end deterministic path via
|
||||||
|
plan_frame_internal_fit_candidate + apply + salvage dispatcher branch +
|
||||||
|
cascade entry trigger)."""
|
||||||
|
routing = route_action("frame_capacity_mismatch")
|
||||||
|
assert routing["proposed_action"] == "frame_internal_fit_candidate"
|
||||||
|
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||||
|
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_fit_classification_surfaces_imp88_actions_as_implemented():
|
||||||
|
"""End-to-end: when classifier emits the two IMP-88 categories alongside
|
||||||
|
an already-implemented one, route_fit_classification:
|
||||||
|
- attaches proposed_action onto each row
|
||||||
|
- lists all three actions in proposed_actions_summary
|
||||||
|
- reports an empty missing_actions_pending_impl for the IMP-88 actions
|
||||||
|
(u7 completion flipped image_fit + frame_internal_fit_candidate to
|
||||||
|
IMPLEMENTED alongside layout_adjust)
|
||||||
|
- all three rows count as IMPLEMENTED in the status summary."""
|
||||||
|
fit_classification = {
|
||||||
|
"visual_check_passed": False,
|
||||||
|
"classifications": [
|
||||||
|
{
|
||||||
|
"source": "image_event",
|
||||||
|
"zone_position": "bottom",
|
||||||
|
"category": "image_aspect_mismatch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "composition",
|
||||||
|
"zone_position": "top",
|
||||||
|
"category": "frame_capacity_mismatch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "clipped_inner",
|
||||||
|
"zone_position": "bottom",
|
||||||
|
"category": "minor_overflow",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
summary = route_fit_classification(fit_classification)
|
||||||
|
assert summary["router_active"] is True
|
||||||
|
assert summary["routed_count"] == 3
|
||||||
|
assert "image_fit" in summary["proposed_actions_summary"]
|
||||||
|
assert "frame_internal_fit_candidate" in summary["proposed_actions_summary"]
|
||||||
|
assert "zone_ratio_retry" in summary["proposed_actions_summary"]
|
||||||
|
# After u7 completion, both new IMP-88 actions are IMPLEMENTED on the
|
||||||
|
# router-surface — they no longer surface as missing pending impl.
|
||||||
|
assert "image_fit" not in summary["missing_actions_pending_impl"]
|
||||||
|
assert "frame_internal_fit_candidate" not in summary["missing_actions_pending_impl"]
|
||||||
|
# All three (zone_ratio_retry IMPLEMENTED + 2 IMP-88 IMPLEMENTED) count
|
||||||
|
# together. zone_ratio_retry was IMPLEMENTED since A3 cascade.
|
||||||
|
assert summary["implementation_status_summary"].get("IMPLEMENTED", 0) == 3
|
||||||
|
assert summary["implementation_status_summary"].get("MISSING", 0) == 0
|
||||||
|
# Per-row enrichment carries the new proposed actions onto entries.
|
||||||
|
cats = {c["category"]: c for c in fit_classification["classifications"]}
|
||||||
|
assert cats["image_aspect_mismatch"]["proposed_action"] == "image_fit"
|
||||||
|
assert (
|
||||||
|
cats["frame_capacity_mismatch"]["proposed_action"]
|
||||||
|
== "frame_internal_fit_candidate"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user