feat(#88): IMP-88 u1~u7 Step 17 retry chain — layout_adjust + image_fit + frame_internal_fit_candidate executors + dispatcher + entry
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:
kmlee1andClaude Opus 4.7 committed 2026-05-24 15:01:55 +09:00
1 parent e0c39f1bc1
commit 2e3747c5ab
13 files changed
+3007 -15

No files matched your search

+374
View File
@@ -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}"