diff --git a/src/phase_z2_failure_router.py b/src/phase_z2_failure_router.py index f7e1f34..0885007 100644 --- a/src/phase_z2_failure_router.py +++ b/src/phase_z2_failure_router.py @@ -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)", } diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 56983ee..b663c89 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -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"" + candidate_html = base.replace("", f"{style}\n", 1) if "" 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) ─── diff --git a/src/phase_z2_retry.py b/src/phase_z2_retry.py index 4155d0e..d467de9 100644 --- a/src/phase_z2_retry.py +++ b/src/phase_z2_retry.py @@ -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=""] 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=""] +# (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=""] 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=""] (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=""] 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}}") diff --git a/src/phase_z2_router.py b/src/phase_z2_router.py index 52beca7..810459a 100644 --- a/src/phase_z2_router.py +++ b/src/phase_z2_router.py @@ -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 } diff --git a/tests/phase_z2/test_phase_z2_cross_zone_redistribute.py b/tests/phase_z2/test_phase_z2_cross_zone_redistribute.py new file mode 100644 index 0000000..a9144f2 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_cross_zone_redistribute.py @@ -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) == "" diff --git a/tests/phase_z2/test_phase_z2_failure_router_cascade.py b/tests/phase_z2/test_phase_z2_failure_router_cascade.py new file mode 100644 index 0000000..d45d7fe --- /dev/null +++ b/tests/phase_z2/test_phase_z2_failure_router_cascade.py @@ -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" diff --git a/tests/phase_z2/test_phase_z2_font_step_compression.py b/tests/phase_z2/test_phase_z2_font_step_compression.py new file mode 100644 index 0000000..e2c6b5e --- /dev/null +++ b/tests/phase_z2/test_phase_z2_font_step_compression.py @@ -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=""] 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) == "" diff --git a/tests/phase_z2/test_phase_z2_glue_compression.py b/tests/phase_z2/test_phase_z2_glue_compression.py new file mode 100644 index 0000000..85e3560 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_glue_compression.py @@ -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=""] 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) == "" diff --git a/tests/phase_z2/test_phase_z2_retry_multi_donor.py b/tests/phase_z2/test_phase_z2_retry_multi_donor.py new file mode 100644 index 0000000..0b649f6 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_retry_multi_donor.py @@ -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 diff --git a/tests/phase_z2/test_phase_z2_step17_salvage_chain.py b/tests/phase_z2/test_phase_z2_step17_salvage_chain.py new file mode 100644 index 0000000..7600b0f --- /dev/null +++ b/tests/phase_z2/test_phase_z2_step17_salvage_chain.py @@ -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"" + f"
" + ) + + 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"