feat(IMP-12): Step 16/17 retry refinement — multi-donor + 3-stage salvage cascade
Extend Step 17 deterministic action surface so donor_slack_insufficient no longer abort-terminates at zone_ratio_retry. AI is NOT invoked on the normal salvage path. Source changes (4 files, scope-locked): - src/phase_z2_retry.py — plan_zone_ratio_retry: single-primary-donor → multi-donor greedy aggregation (donors_used / aggregate_slack_used / aggregate_slack_available); new plan/apply pairs: cross_zone_redistribute (wraps fit_verifier.redistribute, data-role scoped CSS), glue_compression (wraps space_allocator.compute_glue_css_overrides, data-zone-position scoped), font_step_compression (wraps find_fitting_font_size, zone-scoped, defensive feasible=False on missing text_metrics). - src/phase_z2_failure_router.py — classifier inspects salvage_steps[-1] via SALVAGE_FAILURE_TYPE_BY_ACTION; NEXT_ACTION_BY_FAILURE rewired into donor_slack_insufficient/no_donor_candidates → cross_zone_redistribute → glue → font_step → layout_adjust; 3 IMPLEMENTED salvage status rows added. - src/phase_z2_router.py — ACTION_IMPLEMENTATION_STATUS registers 3 new salvage actions as IMPLEMENTED; ACTION_BY_CATEGORY untouched (cascade-only labels). - src/phase_z2_pipeline.py — new _attempt_salvage_chain() iterates router next_proposed_action with retry_budget=1 per action; honors IMP-09 dynamic_cols / fr_default gate; preserves (b)-revert on all-fail; wires Step 17 telemetry (salvage_steps / salvage_passed). Tests (6 new pytest modules): - test_phase_z2_retry_multi_donor.py — single sufficient (regression), 1st insufficient + 2nd sufficient (multi-donor PASS), aggregate insufficient FAIL. - test_phase_z2_cross_zone_redistribute.py — multi-role zone feasible, single-role zone short-circuits infeasible. - test_phase_z2_glue_compression.py — feasible asserts emitted CSS contains [data-zone-position=...] selector and NO global :root/body/.slide rule. - test_phase_z2_font_step_compression.py — 15.2 → 13 closes excess; 8px floor; missing text_metrics → defensive infeasible reason. - test_phase_z2_failure_router_cascade.py — donor_slack_insufficient → cross_zone (impl=IMPLEMENTED); 3 new failure types → expected next actions; rerender_still_fails preserves frame_reselect terminus. - test_phase_z2_step17_salvage_chain.py — end-to-end (a) cross_zone PASS promotes final.html, (b) cross_zone FAIL + glue PASS promotes 2nd candidate, (c) all-3 FAIL preserves original final.html (revert). Guardrails preserved: - AI calls: 0 on normal path (feedback_ai_isolation_contract) - Spacing direction: no shrink-common-margin; resolve via donor/glue/font-step within frame envelope (feedback_phase_z_spacing_direction) - All CSS overrides scoped to [data-role=...] or [data-zone-position=...] - IMP-09 dynamic_cols / fr_default gate honored in cascade - (b)-revert preserved if all 3 salvage actions fail Refs: gitea#12 IMP-12 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,10 +58,19 @@ from phase_z2_classifier import classify_visual_runtime_check
|
||||
from phase_z2_router import route_fit_classification
|
||||
from phase_z2_retry import (
|
||||
DEFAULT_SAFETY_MARGIN_PX,
|
||||
apply_cross_zone_redistribute_css,
|
||||
apply_font_step_compression_css,
|
||||
apply_glue_compression_css,
|
||||
apply_retry_to_layout_css,
|
||||
plan_cross_zone_redistribute,
|
||||
plan_font_step_compression,
|
||||
plan_glue_compression,
|
||||
plan_zone_ratio_retry,
|
||||
)
|
||||
from phase_z2_failure_router import enrich_retry_trace_with_failure_classification
|
||||
from phase_z2_failure_router import (
|
||||
enrich_retry_trace_with_failure_classification,
|
||||
route_retry_failure,
|
||||
)
|
||||
|
||||
# trace-only runtime 연결 v0 — B1 → B4 chain.
|
||||
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
||||
@@ -1925,6 +1934,91 @@ def _attempt_zone_ratio_retry(
|
||||
return base_trace
|
||||
|
||||
|
||||
# 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.
|
||||
_SALVAGE_FAIL_BY_ACTION = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
}
|
||||
|
||||
|
||||
def _attempt_salvage_chain(
|
||||
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str, layout_css: dict,
|
||||
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
|
||||
) -> dict:
|
||||
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
|
||||
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
|
||||
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
|
||||
Honors IMP-09 dynamic_cols / fr_default gate.
|
||||
"""
|
||||
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
|
||||
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
|
||||
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
|
||||
return trace
|
||||
trace["salvage_attempted"] = True
|
||||
failure_type = initial_failure_type
|
||||
ci = cascade_inputs
|
||||
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
|
||||
routing = route_retry_failure(failure_type) or {}
|
||||
next_action = routing.get("next_proposed_action")
|
||||
if next_action not in _SALVAGE_FAIL_BY_ACTION:
|
||||
trace["salvage_terminal_action"] = next_action
|
||||
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
||||
return trace
|
||||
if next_action == "cross_zone_redistribute":
|
||||
if ci.get("fit_analysis") is None:
|
||||
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
||||
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
|
||||
else:
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
|
||||
min_margin_px=ci.get("min_margin_px"))
|
||||
apply_fn = apply_cross_zone_redistribute_css
|
||||
elif next_action == "glue_compression":
|
||||
plan = plan_glue_compression(
|
||||
excess_px=float(ci.get("excess_px") or 0.0),
|
||||
block_count=int(ci.get("block_count") or 0),
|
||||
zone_position=str(ci.get("zone_position") or ""))
|
||||
apply_fn = apply_glue_compression_css
|
||||
else:
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=float(ci.get("current_font_px") or 0.0),
|
||||
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
|
||||
available_lines=int(ci.get("available_lines") or 0),
|
||||
chars_per_line=int(ci.get("chars_per_line") or 0),
|
||||
zone_position=str(ci.get("zone_position") or ""))
|
||||
apply_fn = apply_font_step_compression_css
|
||||
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
|
||||
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
|
||||
candidate_html, candidate_overflow, passed = None, None, False
|
||||
if css_override:
|
||||
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
|
||||
style = f"<style>\n{css_override}\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": next_action, "plan": plan, "passed": passed,
|
||||
"css_override": css_override or None,
|
||||
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override 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 no CSS emitted")
|
||||
trace["salvage_steps"].append(step)
|
||||
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
|
||||
return trace
|
||||
|
||||
|
||||
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str,
|
||||
layout_css: dict, gap_px: int = GRID_GAP) -> str:
|
||||
@@ -4326,19 +4420,85 @@ def run_phase_z2_mvp1(
|
||||
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
|
||||
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
|
||||
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
|
||||
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
|
||||
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
|
||||
_plan = retry_trace.get("plan") or {}
|
||||
_tpos = _plan.get("target_zone_position")
|
||||
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
|
||||
_excess = float(_plan.get("target_excess_y") or 0.0)
|
||||
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
|
||||
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
|
||||
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
|
||||
# Each position becomes a "role"; all share conceptual zone "slide_body" so
|
||||
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
|
||||
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
|
||||
_fa_roles, _fa_containers = {}, {}
|
||||
for _dz in debug_zones:
|
||||
_pos = _dz.get("position")
|
||||
if not _pos:
|
||||
continue
|
||||
_alloc = float(_dz.get("height_px") or 0.0)
|
||||
_zm = _zof.get(_pos) or {}
|
||||
_ch = float(_zm.get("clientHeight") or _alloc)
|
||||
_sh = float(_zm.get("scrollHeight") or _ch)
|
||||
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
|
||||
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
|
||||
_salvage_trace = _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=_fa_roles),
|
||||
"containers": _fa_containers,
|
||||
"min_margin_px": None,
|
||||
"excess_px": _excess, "excess_after_glue_px": _excess,
|
||||
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
|
||||
"zone_position": _tpos or "",
|
||||
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
|
||||
"available_lines": int(_tdz.get("available_lines") or 0),
|
||||
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
|
||||
},
|
||||
initial_failure_type=_ft, gap_px=GRID_GAP,
|
||||
)
|
||||
retry_trace.update(_salvage_trace)
|
||||
if _salvage_trace.get("salvage_passed"):
|
||||
overflow = (_salvage_trace["salvage_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"
|
||||
)
|
||||
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
|
||||
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
|
||||
# state. classify_retry_failure short-circuits on salvage_passed=True → both
|
||||
# fields become None (no failure to classify, no escalation pending).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# ─── Step 17: Implemented Action (retry) ───
|
||||
_write_step_artifact(
|
||||
run_dir, 17, "retry_trace",
|
||||
data=retry_trace,
|
||||
step_status=(
|
||||
"failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||||
else "done" if retry_trace.get("retry_passed")
|
||||
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
|
||||
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||||
else "skipped"
|
||||
),
|
||||
pipeline_path_connected=True,
|
||||
inputs=["step16_router_decision.json"],
|
||||
outputs=["step17_retry_trace.json"],
|
||||
note="A3 — zone_ratio_retry action only. 다른 actions (layout_adjust / frame_internal_fit 등) 미구현 — Step 17 ⚠ partial.",
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
# ─── Step 18: Failure Classification (A4-1) ───
|
||||
|
||||
Reference in New Issue
Block a user