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:
2026-05-18 02:07:22 +09:00
parent a79bd8bc43
commit 56619a0239
10 changed files with 1246 additions and 39 deletions

View File

@@ -7,20 +7,29 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
**잠근 매핑** (사용자 잠금 — 2026-04-29) :
**잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) :
| failure_type | next_proposed_action |
| failure_type | next_proposed_action |
|---|---|
| donor_slack_insufficient | layout_adjust |
| no_donor_candidates | layout_adjust |
| rerender_still_fails | frame_reselect |
| not_attempted | none |
| donor_slack_insufficient | cross_zone_redistribute |
| no_donor_candidates | cross_zone_redistribute |
| cross_zone_redistribute_insufficient | glue_compression |
| glue_absorption_insufficient | font_step_compression |
| font_step_insufficient | layout_adjust |
| rerender_still_fails | frame_reselect |
| not_attempted | none |
**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) :
**escalation 단계 hierarchy** (Step 17 deterministic salvage cascade → layout/frame) :
```
layout_adjust (가장 가벼움 — zone 배치만 변경)
cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment)
↓ 그래도 안 되면
frame_reselect (중간 — frame 자체 변경)
glue_compression (SPACING_GLUE envelope, frame-scoped)
↓ 그래도 안 되면
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
↓ 그래도 안 되면
layout_adjust (zone topology 변경)
↓ 그래도 안 되면
frame_reselect (V4 top-k 의 다른 frame)
↓ 그래도 안 되면
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
```
@@ -53,26 +62,65 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
"현재 frame/zone 조합이 content 와 맞지 않음"
),
"cross_zone_redistribute_insufficient": (
"cross_zone_redistribute salvage step failed — fit_verifier.redistribute "
"could not find a feasible role-height adjustment within the frame envelope"
),
"glue_absorption_insufficient": (
"glue_compression salvage step failed — frame envelope cannot absorb "
"remaining overflow via SPACING_GLUE overrides (no global spacing shrink)"
),
"font_step_insufficient": (
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
"down to the floor without resolving overflow (or text_metrics missing)"
),
}
# ─── §A4-1b salvage_steps[-1].action → failure_type table ──────────
# u2 (IMP-12): _attempt_salvage_chain (u8) writes per-step records into
# retry_trace["salvage_steps"] with {action, passed, failure_reason}. classifier
# inspects salvage_steps[-1] so u3 can route 3 new types onto the cascade.
SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
}
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
"donor_slack_insufficient": "layout_adjust",
"no_donor_candidates": "layout_adjust",
"rerender_still_fails": "frame_reselect",
"not_attempted": "none",
"donor_slack_insufficient": "cross_zone_redistribute",
"no_donor_candidates": "cross_zone_redistribute",
"cross_zone_redistribute_insufficient": "glue_compression",
"glue_absorption_insufficient": "font_step_compression",
"font_step_insufficient": "layout_adjust",
"rerender_still_fails": "frame_reselect",
"not_attempted": "none",
}
NEXT_ACTION_RATIONALE: dict[str, str] = {
"donor_slack_insufficient": (
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 "
"(layout_adjust). frame 자체는 아직 의심 대상 X"
"primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
"전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
),
"no_donor_candidates": (
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 "
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음"
"단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
"재할당 시도 (fit_verifier.redistribute). layout 변경은 cascade 끝"
),
"cross_zone_redistribute_insufficient": (
"role-height 재분배도 frame envelope 못 맞춤 → glue_compression "
"(SPACING_GLUE frame-scoped) 으로 frame 내부 여백 축소"
),
"glue_absorption_insufficient": (
"frame 여백 envelope 도 부족 → font_step_compression "
"(FONT_SIZE_STEPS zone-scoped) 으로 폰트 한 단계 축소"
),
"font_step_insufficient": (
"deterministic salvage cascade 모두 소진 → layout_adjust 로 zone "
"topology 부터 재구성. frame_reselect 는 그 다음 단계"
),
"rerender_still_fails": (
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
@@ -85,10 +133,19 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
}
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
# IMP-12 u7 (2026-05-18): 3 cascade salvage actions registered as IMPLEMENTED.
# plan/apply pairs live in phase_z2_retry (u4/u5/u6); pipeline orchestrator wiring
# (_attempt_salvage_chain) lands in u8/u9. router-level mapping is decoupled from
# orchestrator wiring on purpose so route_retry_failure → impl_status reflects
# the deterministic surface availability, not whether a given pipeline run has
# already invoked it.
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"layout_adjust": "MISSING",
"frame_reselect": "MISSING",
"none": "n/a",
"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",
"frame_reselect": "MISSING",
"none": "n/a",
}
@@ -106,6 +163,29 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
if retry_trace.get("retry_passed"):
return None
# case 0.5 : salvage chain 자체 성공 — failure 없음 (u8/u9 wiring)
if retry_trace.get("salvage_passed"):
return None
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
salvage_steps = retry_trace.get("salvage_steps") or []
if salvage_steps:
last = salvage_steps[-1] or {}
if not last.get("passed"):
action = (last.get("action") or "").lower()
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False. raw failure_reason: {reason!r}"
),
}
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
if not retry_trace.get("retry_attempted"):
return {
@@ -204,7 +284,7 @@ def route_retry_failure(failure_type: str) -> dict:
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
next_action, "unknown"
),
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)",
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)",
}

View File

@@ -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) ───

View File

@@ -162,35 +162,55 @@ def plan_zone_ratio_retry(
),
}
# A3 minimal : single primary donor (multi-donor 는 future)
primary_donor = donor_candidates[0]
if primary_donor["slack"] < target_added_px:
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
if aggregate_slack_available < target_added_px:
return {
**base_plan,
"feasible": False,
"donor_zone_position": primary_donor["position"],
"donor_max_slack": primary_donor["slack"],
"donor_zone_position": donor_candidates[0]["position"],
"donor_max_slack": donor_candidates[0]["slack"],
"donor_reduced_px": 0,
"donors_used": [],
"aggregate_slack_used": 0,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": dict(zones_before),
"failure_reason": (
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
f"primary donor '{donor_candidates[0]['position']}' slack "
f"{donor_candidates[0]['slack']}px (aggregate "
f"{aggregate_slack_available}px across {len(donor_candidates)} "
f"candidate(s)) < target_added_px {target_added_px}px "
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
),
}
# feasible
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
zones_after = dict(zones_before)
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
zones_after[primary_donor["position"]] = (
zones_before[primary_donor["position"]] - target_added_px
)
donors_used: list[dict] = []
remaining = target_added_px
for donor in donor_candidates:
if remaining <= 0:
break
take = min(donor["slack"], remaining)
zones_after[donor["position"]] = zones_before[donor["position"]] - take
donors_used.append({
"position": donor["position"],
"reduced_px": take,
"slack_before": donor["slack"],
"slack_after": donor["slack"] - take,
})
remaining -= take
primary_donor = donors_used[0]
return {
**base_plan,
"feasible": True,
"donor_zone_position": primary_donor["position"],
"donor_reduced_px": target_added_px,
"donor_reduced_px": primary_donor["reduced_px"],
"donors_used": donors_used,
"aggregate_slack_used": target_added_px,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": zones_after,
}
@@ -213,3 +233,177 @@ def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dic
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
new_layout_css["raw_zone_layout"]["retry_applied"] = True
return new_layout_css
# ──────────────────────────────────────
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
# can drive it deterministically. Plan-only — no rerender / no final.html
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
# ──────────────────────────────────────
def plan_cross_zone_redistribute(
*,
fit_analysis,
containers: dict,
min_margin_px: float | None = None,
) -> dict:
"""Cross-zone (intra-zone role-to-role) redistribute plan.
Plan-only — no rerender / no final.html mutation. Side-effect-free
(operates on deepcopy of fit_analysis).
"""
from copy import deepcopy
from src.fit_verifier import redistribute as _fv_redistribute
role_heights_before = {
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
}
base_plan = {
"action": "cross_zone_redistribute",
"role_heights_before": role_heights_before,
}
if not role_heights_before:
return {**base_plan, "feasible": False, "role_heights_after": {},
"can_redistribute": False,
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
redistribution = dict(result.redistribution or {})
can_redistribute = bool(result.can_redistribute)
if not can_redistribute or not redistribution:
return {
**base_plan,
"feasible": False,
"role_heights_after": redistribution or dict(role_heights_before),
"can_redistribute": can_redistribute,
"failure_reason": (
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
"or surplus insufficient to cover deficit within envelope."
),
}
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
"can_redistribute": True}
def apply_cross_zone_redistribute_css(plan: dict) -> str:
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
"""
if not plan.get("feasible"):
return ""
role_heights_after = plan.get("role_heights_after") or {}
role_heights_before = plan.get("role_heights_before") or {}
rules: list[str] = []
for role, new_height in role_heights_after.items():
before = role_heights_before.get(role)
if before is None or abs(float(before) - float(new_height)) < 0.5:
continue
new_h_int = int(round(float(new_height)))
rules.append(
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
)
return "\n".join(rules)
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
def plan_glue_compression(
*, excess_px: float, block_count: int, zone_position: str,
) -> dict:
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
from src.space_allocator import (
calculate_glue_absorption, compute_glue_css_overrides,
)
base = {"action": "glue_compression", "zone_position": zone_position,
"excess_px": float(excess_px), "block_count": int(block_count)}
if excess_px <= 0:
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
"failure_reason": "excess_px <= 0 — no compression needed."}
absorption_max = float(calculate_glue_absorption(block_count))
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
if excess_px > absorption_max:
return {**base, "feasible": False, "overrides": overrides,
"absorption_max_px": absorption_max,
"failure_reason": (
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
f"max absorption {absorption_max:.1f}px "
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
)}
return {**base, "feasible": True, "overrides": overrides,
"absorption_max_px": absorption_max}
def apply_glue_compression_css(plan: dict) -> str:
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
overrides = plan.get("overrides") or {}
if not zone_position or not overrides:
return ""
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
def plan_font_step_compression(
*, current_font_px: float, excess_after_glue_px: float,
available_lines: int, chars_per_line: int, zone_position: str,
) -> dict:
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
contains a size whose line-height savings cover excess_after_glue_px. Missing
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
floor = float(FONT_SIZE_STEPS[-1])
base = {"action": "font_step_compression", "zone_position": zone_position,
"current_font_px": float(current_font_px),
"excess_after_glue_px": float(excess_after_glue_px),
"available_lines": int(available_lines or 0),
"chars_per_line": int(chars_per_line or 0),
"font_floor_px": floor}
if excess_after_glue_px <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
if current_font_px <= floor:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
target = find_fitting_font_size(
current_font_px=float(current_font_px),
excess_after_glue_px=float(excess_after_glue_px),
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
if target is None:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"font_step floor — {floor:.1f}px cannot absorb "
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
return {**base, "feasible": True, "target_font_px": float(target)}
def apply_font_step_compression_css(plan: dict) -> str:
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
target_font_px = plan.get("target_font_px")
if not zone_position or target_font_px is None:
return ""
return (f'[data-zone-position="{zone_position}"] {{\n'
f" font-size: {float(target_font_px):.1f}px;\n}}")

View File

@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
}
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
# 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
@@ -65,6 +65,12 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
# IMP-12 u7 (2026-05-18): cascade-only salvage actions (no ACTION_BY_CATEGORY row;
# surfaced via NEXT_ACTION_BY_FAILURE in phase_z2_failure_router). plan/apply pairs
# implemented in phase_z2_retry; pipeline orchestrator wiring lands in u8/u9.
"cross_zone_redistribute": "IMPLEMENTED", # u4 phase_z2_retry.plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 phase_z2_retry.plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 phase_z2_retry.plan_font_step_compression + apply_font_step_compression_css
}