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:
@@ -7,20 +7,29 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
|
|||||||
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
|
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
|
||||||
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
|
실행 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 |
|
| donor_slack_insufficient | cross_zone_redistribute |
|
||||||
| no_donor_candidates | layout_adjust |
|
| no_donor_candidates | cross_zone_redistribute |
|
||||||
| rerender_still_fails | frame_reselect |
|
| cross_zone_redistribute_insufficient | glue_compression |
|
||||||
| not_attempted | none |
|
| 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)
|
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||||
```
|
```
|
||||||
@@ -53,26 +62,65 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
|
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
|
||||||
"현재 frame/zone 조합이 content 와 맞지 않음"
|
"현재 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 (사용자 잠금) ──────────────────────
|
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
|
||||||
|
|
||||||
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||||
"donor_slack_insufficient": "layout_adjust",
|
"donor_slack_insufficient": "cross_zone_redistribute",
|
||||||
"no_donor_candidates": "layout_adjust",
|
"no_donor_candidates": "cross_zone_redistribute",
|
||||||
"rerender_still_fails": "frame_reselect",
|
"cross_zone_redistribute_insufficient": "glue_compression",
|
||||||
"not_attempted": "none",
|
"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] = {
|
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||||
"donor_slack_insufficient": (
|
"donor_slack_insufficient": (
|
||||||
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 "
|
"primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
|
||||||
"(layout_adjust). frame 자체는 아직 의심 대상 X"
|
"전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||||
),
|
),
|
||||||
"no_donor_candidates": (
|
"no_donor_candidates": (
|
||||||
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 "
|
"단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
|
||||||
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음"
|
"재할당 시도 (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": (
|
"rerender_still_fails": (
|
||||||
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
|
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
|
||||||
@@ -85,10 +133,19 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
# 본 매핑이 가리키는 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] = {
|
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||||
"layout_adjust": "MISSING",
|
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||||
"frame_reselect": "MISSING",
|
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
|
||||||
"none": "n/a",
|
"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"):
|
if retry_trace.get("retry_passed"):
|
||||||
return None
|
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)
|
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||||
if not retry_trace.get("retry_attempted"):
|
if not retry_trace.get("retry_attempted"):
|
||||||
return {
|
return {
|
||||||
@@ -204,7 +284,7 @@ def route_retry_failure(failure_type: str) -> dict:
|
|||||||
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
|
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
|
||||||
next_action, "unknown"
|
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)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,19 @@ from phase_z2_classifier import classify_visual_runtime_check
|
|||||||
from phase_z2_router import route_fit_classification
|
from phase_z2_router import route_fit_classification
|
||||||
from phase_z2_retry import (
|
from phase_z2_retry import (
|
||||||
DEFAULT_SAFETY_MARGIN_PX,
|
DEFAULT_SAFETY_MARGIN_PX,
|
||||||
|
apply_cross_zone_redistribute_css,
|
||||||
|
apply_font_step_compression_css,
|
||||||
|
apply_glue_compression_css,
|
||||||
apply_retry_to_layout_css,
|
apply_retry_to_layout_css,
|
||||||
|
plan_cross_zone_redistribute,
|
||||||
|
plan_font_step_compression,
|
||||||
|
plan_glue_compression,
|
||||||
plan_zone_ratio_retry,
|
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.
|
# trace-only runtime 연결 v0 — B1 → B4 chain.
|
||||||
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
||||||
@@ -1925,6 +1934,91 @@ def _attempt_zone_ratio_retry(
|
|||||||
return base_trace
|
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],
|
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||||
zones_data: list[dict], layout_preset: str,
|
zones_data: list[dict], layout_preset: str,
|
||||||
layout_css: dict, gap_px: int = GRID_GAP) -> 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 후보).
|
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
|
||||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
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) ───
|
# ─── Step 17: Implemented Action (retry) ───
|
||||||
_write_step_artifact(
|
_write_step_artifact(
|
||||||
run_dir, 17, "retry_trace",
|
run_dir, 17, "retry_trace",
|
||||||
data=retry_trace,
|
data=retry_trace,
|
||||||
step_status=(
|
step_status=(
|
||||||
"failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
|
||||||
else "done" if retry_trace.get("retry_passed")
|
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||||||
else "skipped"
|
else "skipped"
|
||||||
),
|
),
|
||||||
pipeline_path_connected=True,
|
pipeline_path_connected=True,
|
||||||
inputs=["step16_router_decision.json"],
|
inputs=["step16_router_decision.json"],
|
||||||
outputs=["step17_retry_trace.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) ───
|
# ─── Step 18: Failure Classification (A4-1) ───
|
||||||
|
|||||||
@@ -162,35 +162,55 @@ def plan_zone_ratio_retry(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# A3 minimal : single primary donor (multi-donor 는 future)
|
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
|
||||||
primary_donor = donor_candidates[0]
|
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
|
||||||
if primary_donor["slack"] < target_added_px:
|
if aggregate_slack_available < target_added_px:
|
||||||
return {
|
return {
|
||||||
**base_plan,
|
**base_plan,
|
||||||
"feasible": False,
|
"feasible": False,
|
||||||
"donor_zone_position": primary_donor["position"],
|
"donor_zone_position": donor_candidates[0]["position"],
|
||||||
"donor_max_slack": primary_donor["slack"],
|
"donor_max_slack": donor_candidates[0]["slack"],
|
||||||
"donor_reduced_px": 0,
|
"donor_reduced_px": 0,
|
||||||
|
"donors_used": [],
|
||||||
|
"aggregate_slack_used": 0,
|
||||||
|
"aggregate_slack_available": aggregate_slack_available,
|
||||||
"zones_after": dict(zones_before),
|
"zones_after": dict(zones_before),
|
||||||
"failure_reason": (
|
"failure_reason": (
|
||||||
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
|
f"primary donor '{donor_candidates[0]['position']}' slack "
|
||||||
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
|
f"{donor_candidates[0]['slack']}px (aggregate "
|
||||||
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
|
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 = dict(zones_before)
|
||||||
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
||||||
zones_after[primary_donor["position"]] = (
|
donors_used: list[dict] = []
|
||||||
zones_before[primary_donor["position"]] - target_added_px
|
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 {
|
return {
|
||||||
**base_plan,
|
**base_plan,
|
||||||
"feasible": True,
|
"feasible": True,
|
||||||
"donor_zone_position": primary_donor["position"],
|
"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,
|
"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"] = (layout_css.get("raw_zone_layout") or {}).copy()
|
||||||
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
||||||
return new_layout_css
|
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}}")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
|
|||||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
|
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||||
@@ -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
|
"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
|
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
89
tests/phase_z2/test_phase_z2_cross_zone_redistribute.py
Normal file
89
tests/phase_z2/test_phase_z2_cross_zone_redistribute.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u11):
|
||||||
|
- multi-role zone feasible (deficit role + surplus role in the same zone)
|
||||||
|
- single-role zone infeasible reason (no peer to donate surplus)
|
||||||
|
|
||||||
|
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
|
||||||
|
depends on whether deficit roles can be covered by surplus roles within the
|
||||||
|
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
|
||||||
|
role_heights_before / role_heights_after and surfaces the
|
||||||
|
'can_redistribute=False — single-role zone(s)' substring when redistribution
|
||||||
|
is impossible. The apply helper must scope output to [data-role=...] only
|
||||||
|
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.fit_verifier import FitAnalysis, RoleFit
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_cross_zone_redistribute_css,
|
||||||
|
plan_cross_zone_redistribute,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
|
||||||
|
"""roles dict = {role: (allocated_px, shortfall_px)}.
|
||||||
|
|
||||||
|
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
|
||||||
|
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
|
||||||
|
"""
|
||||||
|
return FitAnalysis(
|
||||||
|
roles={
|
||||||
|
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
|
||||||
|
for name, (alloc, short) in roles.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_role_zone_feasible():
|
||||||
|
"""Two roles in the same zone — deficit covered by surplus → feasible."""
|
||||||
|
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
|
||||||
|
containers = {
|
||||||
|
"top": {"zone": "slide_body", "height_px": 200},
|
||||||
|
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||||
|
}
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||||
|
)
|
||||||
|
assert plan["action"] == "cross_zone_redistribute"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["can_redistribute"] is True
|
||||||
|
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
|
||||||
|
after = plan["role_heights_after"]
|
||||||
|
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
|
||||||
|
assert after["top"] > 200.0
|
||||||
|
assert after["bottom_l"] < 300.0
|
||||||
|
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
|
||||||
|
css = apply_cross_zone_redistribute_css(plan)
|
||||||
|
assert '[data-role="top"]' in css
|
||||||
|
assert '[data-role="bottom_l"]' in css
|
||||||
|
# Scope lock — no global rules emitted.
|
||||||
|
for forbidden in (":root", "body", ".slide", ".zone"):
|
||||||
|
assert forbidden not in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_role_zone_infeasible_reason():
|
||||||
|
"""Lone role in a zone has no peer to donate surplus → infeasible."""
|
||||||
|
fit = _fit({"top": (200.0, 30.0)})
|
||||||
|
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["can_redistribute"] is False
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "single-role zone" in reason
|
||||||
|
assert "can_redistribute=False" in reason
|
||||||
|
# apply emits nothing when infeasible.
|
||||||
|
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_fit_analysis_infeasible():
|
||||||
|
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["role_heights_before"] == {}
|
||||||
|
assert "no roles" in plan["failure_reason"]
|
||||||
|
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||||
119
tests/phase_z2/test_phase_z2_failure_router_cascade.py
Normal file
119
tests/phase_z2/test_phase_z2_failure_router_cascade.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""IMP-12 u14 — failure_router cascade tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u14):
|
||||||
|
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
|
||||||
|
- 3 new failure types (cross_zone_redistribute_insufficient,
|
||||||
|
glue_absorption_insufficient, font_step_insufficient) all route to
|
||||||
|
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
|
||||||
|
- rerender_still_fails preserved → frame_reselect
|
||||||
|
|
||||||
|
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
|
||||||
|
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
|
||||||
|
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
|
||||||
|
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_failure_router import (
|
||||||
|
NEXT_ACTION_BY_FAILURE,
|
||||||
|
NEXT_ACTION_IMPLEMENTATION_STATUS,
|
||||||
|
classify_retry_failure,
|
||||||
|
enrich_retry_trace_with_failure_classification,
|
||||||
|
route_retry_failure,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
|
||||||
|
"""Stage 1 root cause — primary donor slack insufficient classifies as
|
||||||
|
donor_slack_insufficient and routes onto the deterministic salvage cascade
|
||||||
|
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"plan": {
|
||||||
|
"feasible": False,
|
||||||
|
"failure_reason": (
|
||||||
|
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
|
||||||
|
"< target_added_px 70px"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc is not None
|
||||||
|
assert fc["failure_type"] == "donor_slack_insufficient"
|
||||||
|
|
||||||
|
nr = route_retry_failure("donor_slack_insufficient")
|
||||||
|
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||||
|
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
# enrichment composes both fields onto the trace
|
||||||
|
enrich_retry_trace_with_failure_classification(trace)
|
||||||
|
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
|
||||||
|
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
|
||||||
|
"""no_donor_candidates is the second cascade entry — also onto
|
||||||
|
cross_zone_redistribute per the locked mapping."""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc["failure_type"] == "no_donor_candidates"
|
||||||
|
nr = route_retry_failure("no_donor_candidates")
|
||||||
|
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||||
|
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
|
||||||
|
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
|
||||||
|
types through the deterministic cascade: cross_zone → glue → font_step →
|
||||||
|
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
|
||||||
|
and via the classifier path."""
|
||||||
|
# Direct mapping (u3 lock)
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||||
|
|
||||||
|
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
|
||||||
|
|
||||||
|
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||||
|
cases = [
|
||||||
|
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
|
||||||
|
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
|
||||||
|
("font_step_compression", "font_step_insufficient", "layout_adjust"),
|
||||||
|
]
|
||||||
|
for action, expected_ftype, expected_next in cases:
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"salvage_passed": False,
|
||||||
|
"salvage_steps": [
|
||||||
|
{"action": action, "passed": False, "failure_reason": "salvage failed"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc is not None, f"classifier returned None for action={action}"
|
||||||
|
assert fc["failure_type"] == expected_ftype
|
||||||
|
nr = route_retry_failure(fc["failure_type"])
|
||||||
|
assert nr["next_proposed_action"] == expected_next
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
||||||
|
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
|
||||||
|
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"plan": {"feasible": True},
|
||||||
|
"rerender_attempted": True,
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc["failure_type"] == "rerender_still_fails"
|
||||||
|
nr = route_retry_failure("rerender_still_fails")
|
||||||
|
assert nr["next_proposed_action"] == "frame_reselect"
|
||||||
77
tests/phase_z2/test_phase_z2_font_step_compression.py
Normal file
77
tests/phase_z2/test_phase_z2_font_step_compression.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""IMP-12 u13 — plan_font_step_compression tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u13):
|
||||||
|
- feasible case (15.2 → 13 closes excess)
|
||||||
|
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
|
||||||
|
- text_metrics missing → defensive infeasible reason
|
||||||
|
|
||||||
|
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||||
|
Height savings per candidate font_size (Korean 1.6 line-height):
|
||||||
|
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
|
||||||
|
|
||||||
|
Scope lock per feedback_phase_z_spacing_direction:
|
||||||
|
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
|
||||||
|
- No :root / body / .slide / .zone selectors permitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_font_step_compression_css,
|
||||||
|
plan_font_step_compression,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feasible_15_2_to_13_closes_excess() -> None:
|
||||||
|
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
|
||||||
|
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=15.2, excess_after_glue_px=20.0,
|
||||||
|
available_lines=10, chars_per_line=40, zone_position="bottom_l",
|
||||||
|
)
|
||||||
|
assert plan["action"] == "font_step_compression"
|
||||||
|
assert plan["zone_position"] == "bottom_l"
|
||||||
|
assert plan["current_font_px"] == 15.2
|
||||||
|
assert plan["excess_after_glue_px"] == 20.0
|
||||||
|
assert plan["available_lines"] == 10
|
||||||
|
assert plan["chars_per_line"] == 40
|
||||||
|
assert plan["font_floor_px"] == 8.0
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["target_font_px"] == 13.0
|
||||||
|
assert "failure_reason" not in plan
|
||||||
|
|
||||||
|
css = apply_font_step_compression_css(plan)
|
||||||
|
assert '[data-zone-position="bottom_l"]' in css
|
||||||
|
assert "font-size: 13.0px" in css
|
||||||
|
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||||
|
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infeasible_font_floor_exhausted() -> None:
|
||||||
|
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
|
||||||
|
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=15.2, excess_after_glue_px=200.0,
|
||||||
|
available_lines=10, chars_per_line=40, zone_position="top",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["target_font_px"] is None
|
||||||
|
assert plan["font_floor_px"] == 8.0
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "font_step floor" in reason
|
||||||
|
assert "8.0px" in reason
|
||||||
|
assert "200.0px" in reason
|
||||||
|
assert "FONT_SIZE_STEPS exhausted" in reason
|
||||||
|
assert apply_font_step_compression_css(plan) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_metrics_missing_defensive_infeasible() -> None:
|
||||||
|
"""available_lines=0 → guard fires before find_fitting_font_size;
|
||||||
|
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=15.2, excess_after_glue_px=40.0,
|
||||||
|
available_lines=0, chars_per_line=40, zone_position="bottom_r",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["target_font_px"] is None
|
||||||
|
assert "text_metrics missing" in plan["failure_reason"]
|
||||||
|
assert "available_lines/chars_per_line required" in plan["failure_reason"]
|
||||||
|
assert apply_font_step_compression_css(plan) == ""
|
||||||
87
tests/phase_z2/test_phase_z2_glue_compression.py
Normal file
87
tests/phase_z2/test_phase_z2_glue_compression.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""IMP-12 u12 — plan_glue_compression tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u12):
|
||||||
|
- feasible case asserts emitted CSS contains [data-zone-position=...]
|
||||||
|
selector and NO global :root / body / .slide rule (scope lock)
|
||||||
|
- insufficient case feasible=False with envelope reason
|
||||||
|
|
||||||
|
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
|
||||||
|
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
|
||||||
|
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
|
||||||
|
+ inner_gap.shrink * block_count # 8 * n
|
||||||
|
+ title_gap.shrink * block_count # 4 * n
|
||||||
|
+ container_padding.shrink * 2 # 8 * 2
|
||||||
|
|
||||||
|
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
|
||||||
|
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
|
||||||
|
|
||||||
|
CSS must be wrapped under [data-zone-position="<pos>"] only
|
||||||
|
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_glue_compression_css,
|
||||||
|
plan_glue_compression,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feasible_case_emits_zone_scoped_css():
|
||||||
|
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
|
||||||
|
|
||||||
|
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
|
||||||
|
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
|
||||||
|
"""
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=40.0, block_count=3, zone_position="bottom_l",
|
||||||
|
)
|
||||||
|
assert plan["action"] == "glue_compression"
|
||||||
|
assert plan["zone_position"] == "bottom_l"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["excess_px"] == 40.0
|
||||||
|
assert plan["block_count"] == 3
|
||||||
|
assert plan["absorption_max_px"] == 76.0
|
||||||
|
overrides = plan["overrides"]
|
||||||
|
assert overrides, "feasible plan must return non-empty overrides"
|
||||||
|
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
|
||||||
|
assert key in overrides, f"missing override key {key}"
|
||||||
|
|
||||||
|
css = apply_glue_compression_css(plan)
|
||||||
|
assert '[data-zone-position="bottom_l"]' in css
|
||||||
|
assert "--spacing-block:" in css
|
||||||
|
assert "--spacing-inner:" in css
|
||||||
|
assert "--container-padding:" in css
|
||||||
|
# Scope lock — no global rules permitted.
|
||||||
|
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||||
|
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_envelope_feasible_false_with_reason():
|
||||||
|
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
|
||||||
|
|
||||||
|
failure_reason must surface the envelope shortage so the cascade router
|
||||||
|
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
|
||||||
|
"""
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=80.0, block_count=1, zone_position="top",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["absorption_max_px"] == 28.0
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "glue envelope insufficient" in reason
|
||||||
|
assert "excess_px 80" in reason
|
||||||
|
assert "max absorption 28" in reason
|
||||||
|
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
|
||||||
|
assert apply_glue_compression_css(plan) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_excess_non_positive_no_compression_needed():
|
||||||
|
"""excess_px <= 0 → defensive infeasible (no compression required)."""
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=0.0, block_count=3, zone_position="bottom_r",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["overrides"] == {}
|
||||||
|
assert plan["absorption_max_px"] == 0.0
|
||||||
|
assert "no compression needed" in plan["failure_reason"]
|
||||||
|
assert apply_glue_compression_css(plan) == ""
|
||||||
147
tests/phase_z2/test_phase_z2_retry_multi_donor.py
Normal file
147
tests/phase_z2/test_phase_z2_retry_multi_donor.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u10):
|
||||||
|
- single-donor sufficient (regression — backward compat preserved)
|
||||||
|
- single insufficient + 2nd sufficient (multi-donor PASS path)
|
||||||
|
- aggregate insufficient (multi-donor FAIL path)
|
||||||
|
|
||||||
|
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
|
||||||
|
slack-desc aggregation across all eligible sibling zones. The plan dict
|
||||||
|
now carries donors_used / aggregate_slack_used / aggregate_slack_available
|
||||||
|
while preserving donor_zone_position + donor_reduced_px for the failure
|
||||||
|
classifier substrings (router still keys off primary donor name).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_retry import plan_zone_ratio_retry
|
||||||
|
|
||||||
|
|
||||||
|
_ROUTER_ACTIVE = {"router_active": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _classification(target_pos: str, excess_y: float) -> dict:
|
||||||
|
return {
|
||||||
|
"classifications": [
|
||||||
|
{
|
||||||
|
"proposed_action": "zone_ratio_retry",
|
||||||
|
"zone_position": target_pos,
|
||||||
|
"inputs": {"excess_y": excess_y},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zone(position: str, height_px: int, min_height_px: int,
|
||||||
|
fit_status: str | None = "ok") -> dict:
|
||||||
|
return {
|
||||||
|
"position": position,
|
||||||
|
"height_px": height_px,
|
||||||
|
"min_height_px": min_height_px,
|
||||||
|
"composition_rationale": {
|
||||||
|
"capacity_fit": {"fit_status": fit_status},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _overflow_clean(donor_positions: list[str]) -> dict:
|
||||||
|
return {
|
||||||
|
"zones": [
|
||||||
|
{"position": p, "overflowed": False, "clipped_inner": False}
|
||||||
|
for p in donor_positions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_donor_sufficient_regression():
|
||||||
|
"""One donor with abundant slack. Plan must remain feasible and the
|
||||||
|
legacy donor_zone_position / donor_reduced_px fields must reflect the
|
||||||
|
primary donor (router classifier substring stability)."""
|
||||||
|
debug_zones = [
|
||||||
|
_zone("top", height_px=200, min_height_px=180),
|
||||||
|
_zone("bottom", height_px=400, min_height_px=200), # slack=200
|
||||||
|
]
|
||||||
|
plan = plan_zone_ratio_retry(
|
||||||
|
debug_zones=debug_zones,
|
||||||
|
overflow=_overflow_clean(["bottom"]),
|
||||||
|
fit_classification=_classification("top", excess_y=20.0),
|
||||||
|
router_decision=_ROUTER_ACTIVE,
|
||||||
|
)
|
||||||
|
assert plan is not None
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
|
||||||
|
assert plan["target_added_px"] == 24
|
||||||
|
assert plan["donor_zone_position"] == "bottom"
|
||||||
|
assert plan["donor_reduced_px"] == 24
|
||||||
|
assert plan["donors_used"] == [
|
||||||
|
{"position": "bottom", "reduced_px": 24,
|
||||||
|
"slack_before": 200, "slack_after": 176}
|
||||||
|
]
|
||||||
|
assert plan["aggregate_slack_used"] == 24
|
||||||
|
assert plan["aggregate_slack_available"] == 200
|
||||||
|
assert plan["zones_after"]["top"] == 224
|
||||||
|
assert plan["zones_after"]["bottom"] == 376
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_donor_pass_primary_insufficient_secondary_covers():
|
||||||
|
"""Primary donor alone has insufficient slack but primary + secondary
|
||||||
|
aggregate covers target_added_px. Multi-donor greedy aggregation must
|
||||||
|
split the deficit across both donors in slack-desc order."""
|
||||||
|
debug_zones = [
|
||||||
|
_zone("top", height_px=300, min_height_px=200),
|
||||||
|
_zone("middle", height_px=250, min_height_px=200), # slack=50
|
||||||
|
_zone("bottom", height_px=240, min_height_px=200), # slack=40
|
||||||
|
]
|
||||||
|
plan = plan_zone_ratio_retry(
|
||||||
|
debug_zones=debug_zones,
|
||||||
|
overflow=_overflow_clean(["middle", "bottom"]),
|
||||||
|
fit_classification=_classification("top", excess_y=66.0),
|
||||||
|
router_decision=_ROUTER_ACTIVE,
|
||||||
|
)
|
||||||
|
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
|
||||||
|
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["target_added_px"] == 70
|
||||||
|
assert plan["aggregate_slack_available"] == 90
|
||||||
|
assert plan["aggregate_slack_used"] == 70
|
||||||
|
assert plan["donor_zone_position"] == "middle" # primary
|
||||||
|
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
|
||||||
|
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
|
||||||
|
assert plan["donors_used"][0]["reduced_px"] == 50
|
||||||
|
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
|
||||||
|
assert plan["zones_after"]["top"] == 370
|
||||||
|
assert plan["zones_after"]["middle"] == 200
|
||||||
|
assert plan["zones_after"]["bottom"] == 220
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_donor_fail_aggregate_insufficient():
|
||||||
|
"""All donors combined still cannot cover target_added_px. Plan must
|
||||||
|
be feasible=False with primary-donor substring preserved so the
|
||||||
|
failure_router classifier still routes through donor_slack_insufficient."""
|
||||||
|
debug_zones = [
|
||||||
|
_zone("top", height_px=300, min_height_px=200),
|
||||||
|
_zone("middle", height_px=210, min_height_px=200), # slack=10
|
||||||
|
_zone("bottom", height_px=215, min_height_px=200), # slack=15
|
||||||
|
]
|
||||||
|
plan = plan_zone_ratio_retry(
|
||||||
|
debug_zones=debug_zones,
|
||||||
|
overflow=_overflow_clean(["middle", "bottom"]),
|
||||||
|
fit_classification=_classification("top", excess_y=66.0),
|
||||||
|
router_decision=_ROUTER_ACTIVE,
|
||||||
|
)
|
||||||
|
# target_added_px=70, aggregate=25 → fail
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["aggregate_slack_available"] == 25
|
||||||
|
assert plan["aggregate_slack_used"] == 0
|
||||||
|
assert plan["donors_used"] == []
|
||||||
|
# Primary = highest-slack donor = bottom (15)
|
||||||
|
assert plan["donor_zone_position"] == "bottom"
|
||||||
|
assert plan["donor_max_slack"] == 15
|
||||||
|
# Classifier substring stability: "donor", "slack", and "<" still present
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "donor" in reason
|
||||||
|
assert "slack" in reason
|
||||||
|
assert "<" in reason
|
||||||
|
# zones unchanged on fail (revert-friendly)
|
||||||
|
assert plan["zones_after"]["top"] == 300
|
||||||
|
assert plan["zones_after"]["middle"] == 210
|
||||||
|
assert plan["zones_after"]["bottom"] == 215
|
||||||
248
tests/phase_z2/test_phase_z2_step17_salvage_chain.py
Normal file
248
tests/phase_z2/test_phase_z2_step17_salvage_chain.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
|
||||||
|
|
||||||
|
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
|
||||||
|
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
|
||||||
|
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
|
||||||
|
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
|
||||||
|
|
||||||
|
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
|
||||||
|
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
|
||||||
|
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
|
||||||
|
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
|
||||||
|
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.phase_z2_pipeline as _pz_pipeline
|
||||||
|
from src.fit_verifier import FitAnalysis, RoleFit
|
||||||
|
from src.phase_z2_pipeline import _attempt_salvage_chain
|
||||||
|
|
||||||
|
|
||||||
|
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project_tmp(tmp_path_factory):
|
||||||
|
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
|
||||||
|
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
|
||||||
|
cross-drive system tmp path (pytest's default tmp_path is under
|
||||||
|
%LOCALAPPDATA% on Windows, which lives on a different drive from
|
||||||
|
the project root in this repo)."""
|
||||||
|
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
|
||||||
|
try:
|
||||||
|
yield d
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
_LAYOUT_CSS_GATE_PASS = {
|
||||||
|
"areas": '"top" "bottom"',
|
||||||
|
"cols": "1fr",
|
||||||
|
"rows": "1fr 1fr",
|
||||||
|
"heights_px": [300, 290],
|
||||||
|
"widths_px": [1180],
|
||||||
|
"ratios": [0.508, 0.491],
|
||||||
|
"width_ratios": [1.0],
|
||||||
|
"dynamic_rows": True,
|
||||||
|
"dynamic_cols": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_render(monkeypatch):
|
||||||
|
"""Stub render_slide → deterministic HTML envelope so the cascade does not
|
||||||
|
need real Jinja2 templates. Returns a counter so tests can assert how many
|
||||||
|
times it was invoked (one per CSS-feasible cascade step)."""
|
||||||
|
counter = {"n": 0}
|
||||||
|
|
||||||
|
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||||
|
counter["n"] += 1
|
||||||
|
return (
|
||||||
|
f"<html><head><meta charset='utf-8'></head>"
|
||||||
|
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||||
|
return counter
|
||||||
|
|
||||||
|
|
||||||
|
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
|
||||||
|
initial_failure_type: str = "donor_slack_insufficient") -> dict:
|
||||||
|
return {
|
||||||
|
"run_dir": run_dir,
|
||||||
|
"out_path": out_path,
|
||||||
|
"slide_title": "u15-test",
|
||||||
|
"slide_footer": None,
|
||||||
|
"zones_data": [],
|
||||||
|
"layout_preset": "horizontal-2",
|
||||||
|
"layout_css": _LAYOUT_CSS_GATE_PASS,
|
||||||
|
"cascade_inputs": cascade_inputs,
|
||||||
|
"initial_failure_type": initial_failure_type,
|
||||||
|
"gap_px": 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
|
||||||
|
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
|
||||||
|
passed=True → out_path overwritten with the cross_zone candidate HTML and
|
||||||
|
salvage_passed=True after the very first cascade iteration."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
|
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
|
||||||
|
fit_analysis = FitAnalysis(roles={
|
||||||
|
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||||
|
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
|
||||||
|
})
|
||||||
|
containers = {
|
||||||
|
"top": {"zone": "slide_body", "height_px": 200},
|
||||||
|
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||||
|
}
|
||||||
|
cascade_inputs = {
|
||||||
|
"fit_analysis": fit_analysis,
|
||||||
|
"containers": containers,
|
||||||
|
"min_margin_px": 10,
|
||||||
|
"excess_px": 30.0, "excess_after_glue_px": 30.0,
|
||||||
|
"block_count": 3, "zone_position": "top",
|
||||||
|
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
_patch_render(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": True, "fail_reasons": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = _attempt_salvage_chain(
|
||||||
|
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trace["salvage_attempted"] is True
|
||||||
|
assert trace["salvage_passed"] is True
|
||||||
|
assert len(trace["salvage_steps"]) == 1
|
||||||
|
step0 = trace["salvage_steps"][0]
|
||||||
|
assert step0["action"] == "cross_zone_redistribute"
|
||||||
|
assert step0["passed"] is True
|
||||||
|
assert step0["plan"]["feasible"] is True
|
||||||
|
assert step0["css_override"] and '[data-role=' in step0["css_override"]
|
||||||
|
# out_path was overwritten with the salvage candidate.
|
||||||
|
promoted = out_path.read_text(encoding="utf-8")
|
||||||
|
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||||
|
assert "u15-test" in promoted
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
|
||||||
|
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
|
||||||
|
emitted + run_overflow_check passes → out_path overwritten with the glue
|
||||||
|
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
|
||||||
|
records the infeasible cross_zone attempt."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
|
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
|
||||||
|
# (peer required, none present).
|
||||||
|
fit_analysis = FitAnalysis(roles={
|
||||||
|
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||||
|
})
|
||||||
|
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||||
|
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
|
||||||
|
cascade_inputs = {
|
||||||
|
"fit_analysis": fit_analysis,
|
||||||
|
"containers": containers,
|
||||||
|
"min_margin_px": 10,
|
||||||
|
"excess_px": 40.0, "excess_after_glue_px": 40.0,
|
||||||
|
"block_count": 3, "zone_position": "bottom_l",
|
||||||
|
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
render_counter = _patch_render(monkeypatch)
|
||||||
|
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
|
||||||
|
# feasible → exactly one rerender + overflow call → return passed=True.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": True, "fail_reasons": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = _attempt_salvage_chain(
|
||||||
|
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trace["salvage_attempted"] is True
|
||||||
|
assert trace["salvage_passed"] is True
|
||||||
|
assert len(trace["salvage_steps"]) == 2
|
||||||
|
|
||||||
|
s0 = trace["salvage_steps"][0]
|
||||||
|
assert s0["action"] == "cross_zone_redistribute"
|
||||||
|
assert s0["passed"] is False
|
||||||
|
assert s0["plan"]["feasible"] is False
|
||||||
|
assert s0["css_override"] is None
|
||||||
|
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
|
||||||
|
|
||||||
|
s1 = trace["salvage_steps"][1]
|
||||||
|
assert s1["action"] == "glue_compression"
|
||||||
|
assert s1["passed"] is True
|
||||||
|
assert s1["plan"]["feasible"] is True
|
||||||
|
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
|
||||||
|
# render_slide was invoked exactly once (only the glue branch emitted CSS).
|
||||||
|
assert render_counter["n"] == 1
|
||||||
|
# out_path was overwritten with the glue candidate.
|
||||||
|
promoted = out_path.read_text(encoding="utf-8")
|
||||||
|
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||||
|
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
||||||
|
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
||||||
|
salvage_steps has three failed entries, and out_path is unchanged
|
||||||
|
(original final.html intact — (b)-revert preserved)."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
|
cascade_inputs = {
|
||||||
|
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
|
||||||
|
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
|
||||||
|
"fit_analysis": None,
|
||||||
|
"containers": {},
|
||||||
|
"min_margin_px": 10,
|
||||||
|
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
|
||||||
|
"excess_px": 200.0, "excess_after_glue_px": 200.0,
|
||||||
|
"block_count": 1, "zone_position": "top",
|
||||||
|
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
|
||||||
|
# → find_fitting_font_size returns None → feasible=False.
|
||||||
|
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
render_counter = _patch_render(monkeypatch)
|
||||||
|
# Guard: if run_overflow_check is ever called, the test fails loudly.
|
||||||
|
def _must_not_call(_p): # pragma: no cover — intentional sentinel
|
||||||
|
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
|
||||||
|
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
|
||||||
|
|
||||||
|
trace = _attempt_salvage_chain(
|
||||||
|
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trace["salvage_attempted"] is True
|
||||||
|
assert trace["salvage_passed"] is False
|
||||||
|
assert len(trace["salvage_steps"]) == 3
|
||||||
|
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||||
|
assert actions == [
|
||||||
|
"cross_zone_redistribute",
|
||||||
|
"glue_compression",
|
||||||
|
"font_step_compression",
|
||||||
|
]
|
||||||
|
for step in trace["salvage_steps"]:
|
||||||
|
assert step["passed"] is False
|
||||||
|
assert step["css_override"] is None
|
||||||
|
assert step["failure_reason"]
|
||||||
|
# No CSS emitted anywhere → no render_slide calls either.
|
||||||
|
assert render_counter["n"] == 0
|
||||||
|
# (b) revert: out_path is untouched.
|
||||||
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||||
Reference in New Issue
Block a user