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)
|
||||
↓ 그래도 안 되면
|
||||
layout_adjust (zone topology 변경)
|
||||
layout_adjust (zone topology 변경 — 8-preset switch)
|
||||
↓ 그래도 안 되면
|
||||
frame_internal_fit_candidate (frame contract envelope 안 internal fit 변형)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (V4 top-k 의 다른 frame)
|
||||
↓ 그래도 안 되면
|
||||
@@ -40,6 +42,33 @@ IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
||||
terminal without claiming an implementation it does not own.
|
||||
|
||||
IMP-88 (#88) u2 — Step 17 retry chain extension. Three new failure_type
|
||||
producers + cascade rows wire the three issue-body axes onto the deterministic
|
||||
chain WITHOUT activating any AI path or shared-margin shrink:
|
||||
|
||||
| failure_type | next_proposed_action |
|
||||
|---|---|
|
||||
| layout_adjust_insufficient | frame_internal_fit_candidate |
|
||||
| frame_internal_fit_candidate_insufficient | frame_reselect |
|
||||
| image_fit_insufficient | layout_adjust |
|
||||
|
||||
`layout_adjust_insufficient` is the cascade extension between
|
||||
`font_step_insufficient → layout_adjust` (existing) and the legacy
|
||||
`rerender_still_fails → frame_reselect` rejoin point — closing the open
|
||||
cascade tail that previously terminated salvage at `layout_adjust` with no
|
||||
next-step record. `frame_internal_fit_candidate_insufficient` rejoins the
|
||||
existing `frame_reselect` mid-cascade, so V4 top-k swap remains reachable
|
||||
after the in-envelope salvage exhausts. `image_fit_insufficient` (Step 17
|
||||
single-pass entry per u7) escalates onto the main cascade at `layout_adjust`
|
||||
so an image-driven overflow that cannot be fit inside the frame envelope
|
||||
benefits from layout topology change instead of any margin shrink
|
||||
(feedback_phase_z_spacing_direction guardrail).
|
||||
|
||||
The three new `next_action` destinations (`layout_adjust`,
|
||||
`frame_internal_fit_candidate`, `image_fit`) are advertised as MISSING here.
|
||||
The MISSING → IMPLEMENTED flip lives on the deterministic planner units
|
||||
(u3/u4/u5) in `src/phase_z2_retry.py`; this module owns mapping only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -85,6 +114,28 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
||||
),
|
||||
# IMP-88 (#88) u2 — three new salvage failure producers wired onto the
|
||||
# deterministic cascade. Classifier reuses the salvage_steps[-1] path
|
||||
# introduced in IMP-12 u2 (SALVAGE_FAILURE_TYPE_BY_ACTION).
|
||||
"layout_adjust_insufficient": (
|
||||
"layout_adjust salvage step failed — 8-preset layout switch executed "
|
||||
"but overflow persists post-rerender. Cascade exits onto "
|
||||
"frame_internal_fit_candidate (frame envelope internal fit variant) "
|
||||
"before V4 top-k frame_reselect."
|
||||
),
|
||||
"frame_internal_fit_candidate_insufficient": (
|
||||
"frame_internal_fit_candidate salvage step failed — variant adjustments "
|
||||
"inside the declared frame contract envelope could not absorb the "
|
||||
"remaining overflow. Cascade exits onto frame_reselect (V4 top-k "
|
||||
"alternate frame swap)."
|
||||
),
|
||||
"image_fit_insufficient": (
|
||||
"image_fit salvage step failed — Step 17 single-pass image fit "
|
||||
"(object-fit + max-w/h scoped to the offending frame) did not resolve "
|
||||
"image_aspect_mismatch. Escalates onto the main cascade at "
|
||||
"layout_adjust so a different layout topology can host the image "
|
||||
"natural ratio (no shared margin shrink — Phase Z spacing direction)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +154,17 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
|
||||
# that routes this onto details_popup_escalation.
|
||||
"frame_reselect": "frame_reselect_insufficient",
|
||||
# IMP-88 (#88) u2: producers for the three Step 17 retry chain actions
|
||||
# (layout_adjust / image_fit / frame_internal_fit_candidate). The u6
|
||||
# dispatcher (src/phase_z2_pipeline.py) appends salvage_steps entries with
|
||||
# these action names when their planner-driven executor (u3/u4/u5) emits
|
||||
# passed=False. The classifier path below already inspects
|
||||
# salvage_steps[-1].action so no classifier change is required; u3 just
|
||||
# registers the producer rows so the cascade keeps flowing instead of
|
||||
# falling through to the defensive "not_attempted" fallback.
|
||||
"layout_adjust": "layout_adjust_insufficient",
|
||||
"image_fit": "image_fit_insufficient",
|
||||
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +186,15 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
# only.
|
||||
"frame_reselect_insufficient": "details_popup_escalation",
|
||||
"not_attempted": "none",
|
||||
# IMP-88 (#88) u2 — Step 17 retry chain cascade extension. Closes the
|
||||
# previously open tail at layout_adjust + adds the frame_internal_fit
|
||||
# mid-cascade rejoin onto frame_reselect. image_fit (single-pass entry,
|
||||
# u7) escalates onto layout_adjust when its single-pass transform cannot
|
||||
# resolve image_aspect_mismatch — Phase Z spacing direction guardrail
|
||||
# routes through layout/frame instead of shrinking shared margins.
|
||||
"layout_adjust_insufficient": "frame_internal_fit_candidate",
|
||||
"frame_internal_fit_candidate_insufficient": "frame_reselect",
|
||||
"image_fit_insufficient": "layout_adjust",
|
||||
}
|
||||
|
||||
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
@@ -161,6 +232,22 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"not_attempted": (
|
||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
||||
),
|
||||
# IMP-88 (#88) u2 — Step 17 retry chain cascade rationale entries
|
||||
"layout_adjust_insufficient": (
|
||||
"layout_adjust salvage (8-preset switch) 후에도 overflow 잔존 → "
|
||||
"frame_internal_fit_candidate 로 frame contract envelope 안 internal "
|
||||
"fit 변형 시도. frame_reselect (V4 top-k 다른 frame) 는 cascade 다음 단계."
|
||||
),
|
||||
"frame_internal_fit_candidate_insufficient": (
|
||||
"frame contract envelope 안 internal fit 변형 (density / line rhythm) "
|
||||
"도 overflow 못 흡수 → frame_reselect (V4 top-k 다른 frame) 로 escalate. "
|
||||
"popup 직행은 frame_reselect 까지 소진 후 (cascade terminal)."
|
||||
),
|
||||
"image_fit_insufficient": (
|
||||
"image_fit Step 17 single-pass (object-fit / max-w/h frame-scoped) 가 "
|
||||
"image_aspect_mismatch 못 해결 → layout_adjust 로 main cascade 진입. "
|
||||
"공통 image CSS / 공통 spacing 축소 X (Phase Z spacing direction)."
|
||||
),
|
||||
}
|
||||
|
||||
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
||||
@@ -174,7 +261,13 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
|
||||
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||
"layout_adjust": "MISSING",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): layout_adjust flips here on the
|
||||
# failure-router surface alongside the primary router surface. The
|
||||
# cascade entry chains font_step_insufficient → layout_adjust and
|
||||
# image_fit_insufficient → layout_adjust both reach this destination,
|
||||
# which is now wired end-to-end via u3 (plan_layout_adjust) + u6
|
||||
# (salvage dispatcher branch) + u7 (cascade entry trigger).
|
||||
"layout_adjust": "IMPLEMENTED",
|
||||
"frame_reselect": "MISSING",
|
||||
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
||||
# router executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
@@ -182,6 +275,18 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
# lands prevents premature "popup ready" claims from the failure-router
|
||||
# surface.
|
||||
"details_popup_escalation": "MISSING",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): Step 17 retry chain destinations
|
||||
# flipped to IMPLEMENTED. frame_internal_fit_candidate is a cascade
|
||||
# destination (layout_adjust_insufficient → frame_internal_fit_candidate)
|
||||
# wired via u5 planner + u6 dispatcher branch + u7 cascade entry.
|
||||
# image_fit is a Step 17 single-pass entry wired via u4 planner +
|
||||
# u7 _attempt_step17_image_fit_single_pass; it also surfaces here so
|
||||
# route_retry_failure never returns 'unknown' when image_fit_insufficient
|
||||
# cascades onto layout_adjust. (Same precedent as IMP-12 u7 cascade
|
||||
# actions above — planner-surface availability + orchestrator wiring
|
||||
# together constitute IMPLEMENTED on the deterministic surface.)
|
||||
"frame_internal_fit_candidate": "IMPLEMENTED",
|
||||
"image_fit": "IMPLEMENTED",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,17 @@ from phase_z2_retry import (
|
||||
DEFAULT_SAFETY_MARGIN_PX,
|
||||
apply_cross_zone_redistribute_css,
|
||||
apply_font_step_compression_css,
|
||||
apply_frame_internal_fit_candidate_css,
|
||||
apply_glue_compression_css,
|
||||
apply_image_fit_css,
|
||||
apply_layout_adjust_layout_css,
|
||||
apply_retry_to_layout_css,
|
||||
plan_cross_zone_redistribute,
|
||||
plan_font_step_compression,
|
||||
plan_frame_internal_fit_candidate,
|
||||
plan_glue_compression,
|
||||
plan_image_fit,
|
||||
plan_layout_adjust,
|
||||
plan_zone_ratio_retry,
|
||||
)
|
||||
from phase_z2_failure_router import (
|
||||
@@ -2571,10 +2577,16 @@ def _attempt_zone_ratio_retry(
|
||||
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
|
||||
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
|
||||
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
|
||||
# IMP-88 u6 — extended with layout_adjust + frame_internal_fit_candidate dispatch.
|
||||
# Mirror of failure_router.SALVAGE_FAILURE_TYPE_BY_ACTION (single source-of-truth lives
|
||||
# there; this local map gates which actions the salvage loop can execute and feeds the
|
||||
# loop-cap range(len(...)) so cascade depth scales with implemented executors).
|
||||
_SALVAGE_FAIL_BY_ACTION = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
"layout_adjust": "layout_adjust_insufficient",
|
||||
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
|
||||
}
|
||||
|
||||
|
||||
@@ -2602,6 +2614,52 @@ def _attempt_salvage_chain(
|
||||
trace["salvage_terminal_action"] = next_action
|
||||
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
||||
return trace
|
||||
# IMP-88 u6 — layout_adjust takes a distinct render path (fresh
|
||||
# render_slide with the new preset + remapped zones_data + new
|
||||
# layout_css), so it is dispatched BEFORE the shared CSS-overlay
|
||||
# planner cluster below. No common margin / slide-body shrink
|
||||
# ([[feedback_phase_z_spacing_direction]]) — topology swap only.
|
||||
if next_action == "layout_adjust":
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset=layout_preset, zones_data=zones_data)
|
||||
new_layout_css = (
|
||||
apply_layout_adjust_layout_css(plan, gap_px=gap_px)
|
||||
if (plan and plan.get("feasible")) else None
|
||||
)
|
||||
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
|
||||
candidate_html, candidate_overflow, passed = None, None, False
|
||||
if new_layout_css:
|
||||
candidate_html = render_slide(
|
||||
slide_title, slide_footer,
|
||||
plan["new_zones_data"], plan["new_layout_preset"],
|
||||
new_layout_css, gap_px=gap_px,
|
||||
)
|
||||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||||
candidate_overflow = run_overflow_check(candidate_path)
|
||||
passed = bool(candidate_overflow.get("passed", False))
|
||||
step = {
|
||||
"action": next_action, "plan": plan, "passed": passed,
|
||||
"new_layout_preset": (
|
||||
plan.get("new_layout_preset") if isinstance(plan, dict) else None
|
||||
),
|
||||
"candidate_path": (
|
||||
str(candidate_path.relative_to(PROJECT_ROOT))
|
||||
if new_layout_css else None
|
||||
),
|
||||
}
|
||||
if passed:
|
||||
out_path.write_text(candidate_html, encoding="utf-8")
|
||||
step["post_salvage_overflow"] = candidate_overflow
|
||||
trace["salvage_steps"].append(step)
|
||||
trace["salvage_passed"] = True
|
||||
return trace
|
||||
step["failure_reason"] = (
|
||||
(plan.get("failure_reason") if isinstance(plan, dict) else None)
|
||||
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
|
||||
or "infeasible or render emitted no candidate")
|
||||
trace["salvage_steps"].append(step)
|
||||
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
|
||||
continue
|
||||
if next_action == "cross_zone_redistribute":
|
||||
if ci.get("fit_analysis") is None:
|
||||
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
||||
@@ -2617,6 +2675,21 @@ def _attempt_salvage_chain(
|
||||
block_count=int(ci.get("block_count") or 0),
|
||||
zone_position=str(ci.get("zone_position") or ""))
|
||||
apply_fn = apply_glue_compression_css
|
||||
elif next_action == "frame_internal_fit_candidate":
|
||||
# IMP-88 u6 — per-zone frame-scoped envelope variant. Resolves
|
||||
# frame_template_id from zones_data via cascade_inputs.zone_position
|
||||
# so the planner stays within the frame contract envelope
|
||||
# (no shared margin shrink per [[feedback_phase_z_spacing_direction]]).
|
||||
_target_pos = str(ci.get("zone_position") or "")
|
||||
_target_zone = next(
|
||||
(z for z in zones_data if z.get("position") == _target_pos), {},
|
||||
) or {}
|
||||
_frame_tid = str(_target_zone.get("template_id") or "")
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id=_frame_tid,
|
||||
overflow_zone={"excess_y": float(ci.get("excess_px") or 0.0)},
|
||||
)
|
||||
apply_fn = apply_frame_internal_fit_candidate_css
|
||||
else:
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=float(ci.get("current_font_px") or 0.0),
|
||||
@@ -2653,6 +2726,89 @@ def _attempt_salvage_chain(
|
||||
return trace
|
||||
|
||||
|
||||
# IMP-88 (#88) u7 — Step 17 image_fit single-pass entry executor.
|
||||
# image_fit is NOT a salvage cascade stage (deliberately OUT of
|
||||
# _SALVAGE_FAIL_BY_ACTION per u6 guard). Instead it is a Step 17 ENTRY
|
||||
# single-pass: aggregate per-image plan/apply via plan_image_fit (u4) +
|
||||
# apply_image_fit_css (u4), emit one CSS overlay, re-render once,
|
||||
# run_overflow_check. PASS promotes final.html; FAIL records `image_fit`
|
||||
# step with failure_reason so failure_router (u2)
|
||||
# SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as `image_fit_insufficient`
|
||||
# and the cascade entry block routes onto layout_adjust.
|
||||
# Honors [[feedback_phase_z_spacing_direction]] — frame-scoped img CSS only,
|
||||
# no common margin / slide-body / zone gap shrink. AI isolation contract
|
||||
# (PZ-1) — deterministic data-surface, no AI call.
|
||||
def _attempt_step17_image_fit_single_pass(
|
||||
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str, layout_css: dict,
|
||||
image_events: list[dict], gap_px: int,
|
||||
delta_tol: float = IMAGE_ASPECT_DELTA_TOL,
|
||||
) -> dict:
|
||||
"""IMP-88 u7 — Step 17 image_fit single-pass executor.
|
||||
|
||||
Returns a result dict with shape:
|
||||
- triggered : bool — True iff any feasible image_fit plan emitted CSS.
|
||||
- passed : bool — True iff post-rerender overflow check passed.
|
||||
- step : dict|None — salvage_steps[] entry shape (action="image_fit",
|
||||
image_fit_event_plans, candidate_path, post_salvage_overflow
|
||||
on pass / failure_reason on fail).
|
||||
- candidate_html : str|None — rendered HTML when triggered (None otherwise).
|
||||
- candidate_overflow : dict|None — run_overflow_check result when triggered.
|
||||
- event_plans : list[dict] — every plan_image_fit result (feasible + no-op),
|
||||
surfaced for telemetry even when none emit CSS.
|
||||
Side effect on PASS only: writes candidate_html to out_path.
|
||||
"""
|
||||
event_plans = []
|
||||
css_chunks = []
|
||||
for ev in (image_events or []):
|
||||
plan = plan_image_fit(image_event=ev, delta_tol=delta_tol)
|
||||
event_plans.append(plan)
|
||||
if plan.get("feasible"):
|
||||
css = apply_image_fit_css(plan)
|
||||
if css:
|
||||
css_chunks.append(css)
|
||||
if not css_chunks:
|
||||
return {
|
||||
"triggered": False, "passed": False, "step": None,
|
||||
"candidate_html": None, "candidate_overflow": None,
|
||||
"event_plans": event_plans,
|
||||
}
|
||||
candidate_path = run_dir / "salvage_image_fit_candidate.html"
|
||||
base = render_slide(
|
||||
slide_title, slide_footer, zones_data, layout_preset, layout_css,
|
||||
gap_px=gap_px,
|
||||
)
|
||||
style = "<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(
|
||||
*, candidate_path: Path, plan: Optional[dict] = None,
|
||||
) -> dict:
|
||||
@@ -6370,6 +6526,144 @@ def run_phase_z2_mvp1(
|
||||
# fields become None (no failure to classify, no escalation pending).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.7.1 IMP-88 (#88) u7 — Step 17 image_fit single-pass entry trigger.
|
||||
# Activates when router proposes `image_fit` (image_aspect_mismatch
|
||||
# classifications, ACTION_BY_CATEGORY row added in u1). Single-pass executor
|
||||
# (`_attempt_step17_image_fit_single_pass`): per-image plan/apply via
|
||||
# plan_image_fit + apply_image_fit_css → aggregated CSS overlay → single
|
||||
# re-render → run_overflow_check. PASS promotes final.html and refreshes
|
||||
# Step 17/18/19 state; FAIL records `image_fit` step with failure_reason
|
||||
# so failure_router (u2) SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as
|
||||
# `image_fit_insufficient` and the §11.7.2 cascade entry block routes
|
||||
# onto layout_adjust. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION
|
||||
# (u6 guard) — Step 17 ENTRY single-pass, not a salvage cascade stage.
|
||||
if (
|
||||
not retry_trace.get("retry_passed")
|
||||
and not retry_trace.get("salvage_passed")
|
||||
and "image_fit" in set(router_decision.get("proposed_actions_summary") or [])
|
||||
):
|
||||
_img_result = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=run_dir, out_path=out_path,
|
||||
slide_title=slide_title, slide_footer=slide_footer,
|
||||
zones_data=zones_data, layout_preset=layout_preset,
|
||||
layout_css=layout_css,
|
||||
image_events=overflow.get("image_events") or [],
|
||||
gap_px=GRID_GAP,
|
||||
)
|
||||
if _img_result["triggered"]:
|
||||
retry_trace.setdefault("salvage_steps", []).append(_img_result["step"])
|
||||
retry_trace["salvage_attempted"] = True
|
||||
if _img_result["passed"]:
|
||||
retry_trace["salvage_passed"] = True
|
||||
overflow = _img_result["candidate_overflow"]
|
||||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||||
router_decision = route_fit_classification(fit_classification)
|
||||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||||
router_decision["frame_reselect_fallback_status"] = (
|
||||
"pre_render_rank_2_3_fallback_implemented; "
|
||||
"post_render visual-fail rerender remains routed through existing action trace"
|
||||
)
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.7.2 IMP-88 (#88) u7 — direct entry triggers for layout_adjust /
|
||||
# frame_internal_fit_candidate when the donor_slack pathway above did not
|
||||
# engage but the router proposes one of these actions (e.g. moderate_overflow
|
||||
# / layout_zone_mismatch → layout_adjust; frame_capacity_mismatch →
|
||||
# frame_internal_fit_candidate per u1 ACTION_BY_CATEGORY). Re-uses
|
||||
# _attempt_salvage_chain with a synthetic initial_failure_type that
|
||||
# routes (via failure_router NEXT_ACTION_BY_FAILURE / u2) onto the proposed
|
||||
# action, so the cascade tail (frame_reselect → details_popup_escalation)
|
||||
# stays consistent with the donor_slack pathway. Also handles the
|
||||
# image_fit single-pass FAIL → image_fit_insufficient escalation onto
|
||||
# the cascade (u2 row routes image_fit_insufficient → layout_adjust).
|
||||
if not retry_trace.get("retry_passed") and not retry_trace.get("salvage_passed"):
|
||||
_u7_proposed = set(router_decision.get("proposed_actions_summary") or [])
|
||||
_u7_last_step = (retry_trace.get("salvage_steps") or [])
|
||||
_u7_image_fit_failed = bool(
|
||||
_u7_last_step
|
||||
and _u7_last_step[-1].get("action") == "image_fit"
|
||||
and _u7_last_step[-1].get("passed") is False
|
||||
)
|
||||
_u7_initial = None
|
||||
if _u7_image_fit_failed:
|
||||
_u7_initial = "image_fit_insufficient"
|
||||
elif "layout_adjust" in _u7_proposed:
|
||||
_u7_initial = "font_step_insufficient"
|
||||
elif "frame_internal_fit_candidate" in _u7_proposed:
|
||||
_u7_initial = "layout_adjust_insufficient"
|
||||
if _u7_initial:
|
||||
_u7_cls_list = fit_classification.get("classifications") or []
|
||||
_u7_entry_cls = next(
|
||||
(c for c in _u7_cls_list
|
||||
if c.get("proposed_action") in {
|
||||
"layout_adjust", "frame_internal_fit_candidate", "image_fit",
|
||||
}),
|
||||
{},
|
||||
)
|
||||
_u7_tpos = _u7_entry_cls.get("zone_position") or ""
|
||||
_u7_tdz = next(
|
||||
(dz for dz in debug_zones if dz.get("position") == _u7_tpos), {},
|
||||
) or {}
|
||||
_u7_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
|
||||
_u7_zm = _u7_zof.get(_u7_tpos) or {}
|
||||
_u7_excess = max(
|
||||
0.0,
|
||||
float(_u7_zm.get("scrollHeight") or 0.0)
|
||||
- float(_u7_zm.get("clientHeight") or 0.0),
|
||||
)
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
_u7_fa_roles, _u7_fa_containers = {}, {}
|
||||
for _u7_dz in debug_zones:
|
||||
_u7_pos = _u7_dz.get("position")
|
||||
if not _u7_pos:
|
||||
continue
|
||||
_u7_alloc = float(_u7_dz.get("height_px") or 0.0)
|
||||
_u7_zmi = _u7_zof.get(_u7_pos) or {}
|
||||
_u7_ch = float(_u7_zmi.get("clientHeight") or _u7_alloc)
|
||||
_u7_sh = float(_u7_zmi.get("scrollHeight") or _u7_ch)
|
||||
_u7_fa_roles[_u7_pos] = RoleFit(
|
||||
role=_u7_pos, allocated_px=_u7_alloc,
|
||||
shortfall_px=_u7_sh - _u7_ch,
|
||||
)
|
||||
_u7_fa_containers[_u7_pos] = {
|
||||
"zone": "slide_body", "height_px": int(_u7_alloc),
|
||||
}
|
||||
_u7_salvage = _attempt_salvage_chain(
|
||||
run_dir=run_dir, out_path=out_path,
|
||||
slide_title=slide_title, slide_footer=slide_footer,
|
||||
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
|
||||
cascade_inputs={
|
||||
"fit_analysis": FitAnalysis(roles=_u7_fa_roles),
|
||||
"containers": _u7_fa_containers,
|
||||
"min_margin_px": None,
|
||||
"excess_px": _u7_excess, "excess_after_glue_px": _u7_excess,
|
||||
"block_count": len(
|
||||
(_u7_tdz.get("placement_trace") or {}).get("internal_regions") or []
|
||||
) or 1,
|
||||
"zone_position": _u7_tpos,
|
||||
"current_font_px": float(_u7_tdz.get("font_size_px") or 0.0),
|
||||
"available_lines": int(_u7_tdz.get("available_lines") or 0),
|
||||
"chars_per_line": int(_u7_tdz.get("chars_per_line") or 0),
|
||||
},
|
||||
initial_failure_type=_u7_initial, gap_px=GRID_GAP,
|
||||
)
|
||||
_u7_prior_steps = retry_trace.get("salvage_steps") or []
|
||||
_u7_new_steps = _u7_salvage.get("salvage_steps") or []
|
||||
retry_trace.update(_u7_salvage)
|
||||
retry_trace["salvage_steps"] = _u7_prior_steps + _u7_new_steps
|
||||
if _u7_salvage.get("salvage_passed"):
|
||||
overflow = (_u7_new_steps[-1].get("post_salvage_overflow")) or overflow
|
||||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||||
router_decision = route_fit_classification(fit_classification)
|
||||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||||
router_decision["frame_reselect_fallback_status"] = (
|
||||
"pre_render_rank_2_3_fallback_implemented; "
|
||||
"post_render visual-fail rerender remains routed through existing action trace"
|
||||
)
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
|
||||
# Runs after the salvage cascade exits at cascade-terminal action
|
||||
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
|
||||
@@ -6433,7 +6727,9 @@ def run_phase_z2_mvp1(
|
||||
note=(
|
||||
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
||||
"(cross_zone_redistribute → glue_compression → font_step_compression). "
|
||||
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
|
||||
"IMP-88 u6/u7 — salvage cascade extended with layout_adjust + "
|
||||
"frame_internal_fit_candidate; image_fit single-pass entry triggered "
|
||||
"from router (cascade tail frame_reselect remains PARTIAL pre-render only)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -428,3 +428,377 @@ def apply_font_step_compression_css(plan: dict) -> str:
|
||||
return ""
|
||||
return (f'[data-zone-position="{zone_position}"] {{\n'
|
||||
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap).
|
||||
# Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body
|
||||
# shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust;
|
||||
# downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate.
|
||||
# Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and
|
||||
# rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int:
|
||||
"""Lower = preferred swap target. Honors topology-axis mirroring first."""
|
||||
pair = frozenset({current_topology, candidate_topology})
|
||||
if pair == frozenset({"rows", "cols"}):
|
||||
return 0
|
||||
if pair == frozenset({"T", "inverted-T"}):
|
||||
return 1
|
||||
if pair == frozenset({"side-T-left", "side-T-right"}):
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
def plan_layout_adjust(
|
||||
*, current_layout_preset: str, zones_data: list[dict],
|
||||
) -> dict:
|
||||
"""Layout-preset switch plan (Step 17 retry chain — IMP-88 u3).
|
||||
|
||||
Finds a render-ready sibling preset (same candidate_when.unit_count) and
|
||||
remaps zone positions in catalog order. No common spacing shrink —
|
||||
feedback_phase_z_spacing_direction lock: escalate via layout topology only.
|
||||
"""
|
||||
from src.phase_z2_composition import LAYOUT_PRESETS
|
||||
base = {
|
||||
"action": "layout_adjust",
|
||||
"current_layout_preset": current_layout_preset,
|
||||
}
|
||||
current_spec = LAYOUT_PRESETS.get(current_layout_preset)
|
||||
if current_spec is None:
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"candidates_considered": [],
|
||||
"failure_reason": (
|
||||
f"current_layout_preset '{current_layout_preset}' not in "
|
||||
f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings."
|
||||
),
|
||||
}
|
||||
current_positions = list(current_spec.get("positions") or [])
|
||||
if len(zones_data) != len(current_positions):
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"candidates_considered": [],
|
||||
"failure_reason": (
|
||||
f"zones_data length {len(zones_data)} != current preset "
|
||||
f"'{current_layout_preset}' positions {current_positions} — "
|
||||
f"cannot remap to a sibling preset."
|
||||
),
|
||||
}
|
||||
unit_count = (current_spec.get("candidate_when") or {}).get("unit_count")
|
||||
candidates = [
|
||||
pid for pid, spec in LAYOUT_PRESETS.items()
|
||||
if pid != current_layout_preset
|
||||
and spec.get("render_ready", False)
|
||||
and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count)
|
||||
]
|
||||
base = {**base, "unit_count": unit_count,
|
||||
"candidates_considered": list(candidates)}
|
||||
if not candidates:
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"failure_reason": (
|
||||
f"no render-ready 8-preset sibling for unit_count {unit_count} "
|
||||
f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) "
|
||||
f"have no swap target by catalog design."
|
||||
),
|
||||
}
|
||||
catalog_order = list(LAYOUT_PRESETS.keys())
|
||||
current_topo = current_spec.get("topology")
|
||||
candidates.sort(key=lambda pid: (
|
||||
_layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")),
|
||||
catalog_order.index(pid),
|
||||
))
|
||||
new_preset = candidates[0]
|
||||
new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or [])
|
||||
new_zones_data = [
|
||||
{**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data)
|
||||
]
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"new_layout_preset": new_preset,
|
||||
"swap_topology_from": current_topo,
|
||||
"swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"),
|
||||
"position_remap": dict(zip(current_positions, new_positions)),
|
||||
"new_zones_data": new_zones_data,
|
||||
}
|
||||
|
||||
|
||||
def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]:
|
||||
"""Build a fresh layout_css dict for the swapped preset.
|
||||
|
||||
Returns None when plan is infeasible. Dispatcher (u6) re-renders with
|
||||
render_slide(zones_data=plan['new_zones_data'], layout_preset=plan
|
||||
['new_layout_preset'], layout_css=<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) ──
|
||||
|
||||
# category → proposed_action (primary)
|
||||
# IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the
|
||||
# primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17
|
||||
# spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321):
|
||||
# 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped
|
||||
# classifier emission (phase_z2_classifier.py:434-447) that previously
|
||||
# returned proposed_action=None and stalled visual_check on overflow
|
||||
# runs carrying image_event payloads.
|
||||
# 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
|
||||
# (previously frame_reselect) — OVERVIEW.md Step 17 locks
|
||||
# frame_internal_fit_candidate as the per-zone first-pass salvage
|
||||
# *inside* the declared frame envelope; frame_reselect (V4 top-k
|
||||
# alternate frame swap) stays available downstream via the
|
||||
# failure_router cascade (rerender_still_fails → frame_reselect).
|
||||
ACTION_BY_CATEGORY: dict[str, str] = {
|
||||
"minor_overflow": "zone_ratio_retry",
|
||||
"moderate_overflow": "layout_adjust",
|
||||
"structural_minor_overflow": "zone_ratio_retry",
|
||||
"structural_major_overflow": "details_popup_escalation",
|
||||
"tabular_overflow": "details_popup_escalation",
|
||||
"frame_capacity_mismatch": "frame_reselect",
|
||||
"image_aspect_mismatch": "image_fit",
|
||||
"frame_capacity_mismatch": "frame_internal_fit_candidate",
|
||||
"layout_zone_mismatch": "layout_adjust",
|
||||
"hard_visual_fail": "abort",
|
||||
}
|
||||
@@ -48,8 +62,13 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
|
||||
"tabular_overflow":
|
||||
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
|
||||
"image_aspect_mismatch":
|
||||
"image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / "
|
||||
"max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).",
|
||||
"frame_capacity_mismatch":
|
||||
"composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가",
|
||||
"composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope "
|
||||
"안 internal fit 변형 (density / line rhythm / row 배치) 우선. "
|
||||
"frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).",
|
||||
"layout_zone_mismatch":
|
||||
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
|
||||
"hard_visual_fail":
|
||||
@@ -61,7 +80,21 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||
"layout_adjust": "MISSING",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered
|
||||
# here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5
|
||||
# landed the deterministic planners in src/phase_z2_retry.py. u6 wired the
|
||||
# salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17
|
||||
# entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2).
|
||||
# Status flips MISSING→IMPLEMENTED land here on u7 completion — once the
|
||||
# end-to-end path (planner + apply + dispatcher + entry) is wired the
|
||||
# action is IMPLEMENTED on the deterministic surface. (Same convention as
|
||||
# IMP-12 u7 cascade rows below: planner-surface availability + orchestrator
|
||||
# wiring together constitute IMPLEMENTED; route_action's
|
||||
# implementation_status field reflects surface availability, not whether a
|
||||
# given pipeline run has invoked the action.)
|
||||
"layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry
|
||||
"image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry
|
||||
"frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry
|
||||
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
|
||||
# `plan_details_popup_escalation` (below) provides the deterministic stub
|
||||
# that downstream units consume: u4 binds the AI split-decision contract
|
||||
|
||||
Reference in New Issue
Block a user