Files
C.E.L_Slide_test2/src/phase_z2_failure_router.py
T
KyeongminandClaude Opus 4.7 f3ef4d917c feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin
Land the production + test surface for the Step 17 cascade POPUP terminal
(DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2.
u11 (baseline-red invariance gate) was already landed in 7c93031 ahead of
this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up
anchor re-pin for test_imp17_comment_anchor.py.

Implementation units (Stage 2 R2 contract):
  u1  frame_reselect_insufficient failure_type + post-frame remeasure (q4)
        - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py
  u2  NEXT_ACTION_BY_FAILURE row + impl_status flip
        - src/phase_z2_failure_router.py
  u3  Router details_popup_escalation MISSING->IMPLEMENTED + executor stub
        - src/phase_z2_router.py
  u4  step17.py AI split-decision contract (POPUP cascade_stage +
      route_for_label + skip_reason); API gated
        - src/phase_z2_ai_fallback/step17.py
  u5  Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker
        - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py
  u6  Composition popup binding -- yaml strategy -> zone payload
        - src/phase_z2_composition.py
  u7  Pipeline composer -> render_slide wiring
      (popup_html / preview_text / has_popup)
        - src/phase_z2_pipeline.py
  u8  slide_base.html <details>/<summary> popup wrapper
        - templates/phase_z2/slide_base.html
  u9  display_strategies.yaml inline_preview + popup metadata
        - templates/phase_z2/regions/display_strategies.yaml
  u10 MDX preservation invariant: popup=full source / body=summary or subset
        (asserted by tests/phase_z2/test_popup_mdx_preservation.py)
  u11 (already in 7c93031) -- baseline-red invariance gate

Stage 3 R7 follow-up (anchor re-pin, test-only):
  - tests/orchestrator_unit/test_imp17_comment_anchor.py
    Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted
    the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned
    the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 ->
    586). Production code untouched.

Verification (Stage 4 R1):
  pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
    -> 2 passed / 0.02s
  pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback>
    -> 136 passed / 15.94s
  Baseline-red invariance gate
    (tests/test_imp47b_step12_ai_wiring.py +
     tests/test_phase_z2_ai_fallback_config.py)
    -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS
    (frozen registry from 7c93031). Contract holds.
  Codex Stage 4 R1 = YES (independent verify).

Guardrails honored:
  - MDX content preservation: popup carries full source, body holds
    summary or subset only (CLAUDE.md 자세히보기 원칙;
    feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink).
  - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook
    surface is split-decision contract only, API call gated.
  - No hardcoding: escalation thresholds derived from existing overflow
    detector outputs; preview_chars deterministic from container px.
  - 1 commit = 1 decision unit: u1~u10 land together as the planned
    production surface; u11 was deliberately split into 7c93031 as Stage 3
    R7 carve-out, and the R7 anchor re-pin rides with this commit because
    it is the direct shift consequence of the u1/u5/u7 pre-anchor additions.
  - Scope-locked: .claude/settings.json explicitly excluded
    (Stage 4 exit report contract).

Out of scope (per Stage 1 + Stage 2):
  - AI_REPAIR API activation (post IMP-35 axis).
  - IMP-34 zone resize, IMP-36 responsive fit (chain partners,
    separate issues).
  - Print-time auto-expand JavaScript for <details>.
  - Popup escalation in stages other than Step 17.
  - Baseline-red body repair (4 frozen failures) -- separate follow-up
    issue; u11 only guards the count.
  - frame_reselect algorithm changes (entry point only).
  - templates/phase_z2/slide_base.html path rename.

source_comment_ids:
  Stage 1: claude_stage1_problem_review_imp35, codex_stage1_verification_imp35_yes
  Stage 2: Claude #4 R2 plan, Codex #5 R2 YES
  Stage 3: Claude #86 (R7 anchor re-pin), Codex #87 YES
  Stage 4: Claude #88 R1, Codex #89 R1 YES

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:36:57 +09:00

374 lines
17 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)
```
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
(post-frame remeasure failure, classifier path locked in u1) now routes onto
`details_popup_escalation`. The status table records the popup action as
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
terminal without claiming an implementation it does not own.
"""
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)"
),
"frame_reselect_insufficient": (
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
"Routes to details_popup_escalation in u2 (cascade terminal)."
),
}
# ─── §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",
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
# writes a salvage_steps entry with action='frame_reselect', passed=False,
# and post_salvage_overflow populated by run_overflow_check on the swapped
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
# that routes this onto details_popup_escalation.
"frame_reselect": "frame_reselect_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",
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
# (post-frame remeasure failed; classifier path gated on
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
# Popup body holds full MDX source; preview shows summary/subset
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
# only.
"frame_reselect_insufficient": "details_popup_escalation",
"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 가 아닌 한)"
),
"frame_reselect_insufficient": (
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
"AI repair 진입 전 deterministic 마지막 단계."
),
"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",
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
# router executor stub + MISSING→IMPLEMENTED flip lives in
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
# lands prevents premature "popup ready" claims from the failure-router
# surface.
"details_popup_escalation": "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.
#
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
# branch is gated on post_salvage_overflow being present on the salvage
# step. A bare passed=False flag with no remeasure payload is *not*
# sufficient to emit frame_reselect_insufficient (which routes to
# details_popup_escalation in u2). When the gate fails, the classifier
# falls through to lower-priority cases so the salvage trace surfaces as
# an unmatched defensive fallback instead of a spurious popup escalation.
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()
frame_reselect_blocked = (
action == "frame_reselect"
and not last.get("post_salvage_overflow")
)
if not frame_reselect_blocked:
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
rule_suffix = (
" AND post_salvage_overflow present"
if action == "frame_reselect"
else ""
)
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False{rule_suffix}. "
f"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