"""Phase Z-2 retry_failure_classifier + next_action_router (A4 — 분류 / 매핑만). A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 : 1. **retry_failure_classifier** : 실패 type 을 4 종 중 하나로 분류 2. **next_action_router** : failure_type → next_proposed_action 매핑 본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup 실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가. **잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) : | failure_type | next_proposed_action | |---|---| | 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** (Step 17 deterministic salvage cascade → layout/frame) : ``` cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment) ↓ 그래도 안 되면 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` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow / frame_reselect 실패 이후 단계에서 다룸 (별 step). """ from __future__ import annotations from typing import Optional # ─── §A4-1 failure_type registry ────────────────────────────────── FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = { "not_attempted": ( "retry was not attempted (router_active=False or zone_ratio_retry " "not in proposed actions). 정상 path 의 일부 — 실패 X" ), "donor_slack_insufficient": ( "primary donor 의 slack 이 target_added_px 보다 작음. 현재 layout 안 " "redistribution 한도 도달" ), "no_donor_candidates": ( "donor 후보 자체 없음 — single layout / sibling visual fail / capacity " "mismatch / slack 0 등의 이유로 zone redistribution 불가" ), "rerender_still_fails": ( "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": "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": ( "primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone " "전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝" ), "no_donor_candidates": ( "단일 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 → 현재 " "frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). " "popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)" ), "not_attempted": ( "retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요" ), } # 본 매핑이 가리키는 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] = { "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", } # ─── classifier ────────────────────────────────────────────────── def classify_retry_failure(retry_trace: dict) -> Optional[dict]: """retry_trace → failure classification. Returns: None : retry 가 *성공* 한 case (retry_passed=True). 분류할 failure 없음. dict : {failure_type, classification_rule} """ # case 0 : retry 성공 — failure 없음 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 { "failure_type": "not_attempted", "classification_rule": ( "retry_attempted=False — router_active=False or zone_ratio_retry " "not in proposed_actions" ), } # case 2 : plan 단계 실패 (rerender 안 일어남) plan = retry_trace.get("plan") or {} if plan and not plan.get("feasible"): reason = (plan.get("failure_reason") or "") reason_lower = reason.lower() # donor slack insufficient — primary donor 가 있으나 slack 부족 if ( "primary donor" in reason_lower and "slack" in reason_lower and "target_added_px" in reason_lower ): return { "failure_type": "donor_slack_insufficient", "classification_rule": ( "plan.feasible=False AND failure_reason matches " "'primary donor ... slack ... target_added_px ...'" ), } # no donor candidates — sibling 자체 없거나 모두 자격 미달 if "no donor candidates" in reason_lower: return { "failure_type": "no_donor_candidates", "classification_rule": ( "plan.feasible=False AND failure_reason matches " "'no donor candidates'" ), } # 위 두 패턴 미매칭 — 보수적으로 no_donor_candidates 로 분류 # (donor 가 거의 모두 막힌 경우 와 구조적으로 비슷) return { "failure_type": "no_donor_candidates", "classification_rule": ( f"plan.feasible=False, failure_reason did not match known patterns. " f"defaulting to 'no_donor_candidates'. raw failure_reason: {reason!r}" ), } # case 3 : plan feasible AND rerender 했는데 visual fail if retry_trace.get("rerender_attempted") and not retry_trace.get("retry_passed"): return { "failure_type": "rerender_still_fails", "classification_rule": ( "plan.feasible=True AND rerender_attempted=True AND retry_passed=False" ), } # case 4 (defensive) : 어떤 case 에도 안 잡힘 — 보수적 fallback return { "failure_type": "not_attempted", "classification_rule": ( "no failure pattern matched (defensive fallback). retry_trace 구조 " "예상과 다름 — 검토 필요" ), } # ─── router ────────────────────────────────────────────────────── def route_retry_failure(failure_type: str) -> dict: """failure_type → next_proposed_action mapping. Returns: dict : next_proposed_action next_action_rationale next_action_implementation_status mapping_source """ next_action = NEXT_ACTION_BY_FAILURE.get(failure_type) if next_action is None: return { "next_proposed_action": None, "next_action_rationale": ( f"failure_type '{failure_type}' has no mapping in NEXT_ACTION_BY_FAILURE" ), "next_action_implementation_status": "unknown", "mapping_source": "no mapping (unknown failure_type)", } return { "next_proposed_action": next_action, "next_action_rationale": NEXT_ACTION_RATIONALE.get(failure_type, ""), "next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get( next_action, "unknown" ), "mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)", } # ─── enrichment wrapper ────────────────────────────────────────── def enrich_retry_trace_with_failure_classification(retry_trace: dict) -> dict: """retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가. Mutates retry_trace in place AND returns it. retry_passed=True 인 경우 → 두 필드 모두 None (failure 없음, escalation 없음). """ fc = classify_retry_failure(retry_trace) if fc is None: # retry succeeded — no failure to classify retry_trace["failure_classification"] = None retry_trace["next_action_proposal"] = None return retry_trace failure_type = fc["failure_type"] nr = route_retry_failure(failure_type) retry_trace["failure_classification"] = { "failure_type": failure_type, "failure_type_description": FAILURE_TYPE_DESCRIPTIONS.get(failure_type, ""), "classification_rule": fc["classification_rule"], } retry_trace["next_action_proposal"] = nr return retry_trace