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>
318 lines
14 KiB
Python
318 lines
14 KiB
Python
"""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
|