Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Step 17 salvage dispatcher previously only ran the 3 actions in
_SALVAGE_FAIL_BY_ACTION (cross_zone_redistribute / glue_compression /
font_step_compression). Any next_proposed_action outside that set hit
salvage_terminal_action and dropped through, so visual_check aborted on
layout_adjust / image_fit / frame_internal_fit_candidate cascades.
u1 — router data surface (src/phase_z2_router.py)
- ACTION_BY_CATEGORY: image_aspect_mismatch -> image_fit (new row),
frame_capacity_mismatch -> frame_internal_fit_candidate (was
frame_reselect).
- ACTION_IMPLEMENTATION_STATUS: layout_adjust / image_fit /
frame_internal_fit_candidate flipped MISSING -> IMPLEMENTED with
inline IMP-88 rationale.
u2 — failure_router cascade surface (src/phase_z2_failure_router.py)
- FAILURE_TYPE_DESCRIPTIONS + SALVAGE_FAILURE_TYPE_BY_ACTION extended
with layout_adjust_insufficient / image_fit_insufficient /
frame_internal_fit_insufficient producers.
- NEXT_ACTION_BY_FAILURE + NEXT_ACTION_RATIONALE +
NEXT_ACTION_IMPLEMENTATION_STATUS rows added; cascade chain becomes
font_step_compression -> layout_adjust -> frame_internal_fit_candidate
-> frame_reselect -> details_popup_escalation (#64 terminal).
u3~u5 — planners + apply helpers (src/phase_z2_retry.py)
- plan_layout_adjust / apply_layout_adjust_layout_css with
_layout_swap_priority across 8-preset LAYOUT_PRESETS (preset switch,
no shared-margin shrink per Phase Z spacing direction).
- plan_image_fit / apply_image_fit_css scoped to frame slot using
existing classifier image_event payload (object-fit + max-w/h
derivation).
- plan_frame_internal_fit_candidate / apply_frame_internal_fit_candidate_css
stays inside declared frame contract envelope; emits infeasible path
when envelope is absent.
u6~u7 — pipeline wiring (src/phase_z2_pipeline.py)
- _SALVAGE_FAIL_BY_ACTION extended; _attempt_salvage_chain gains
layout_adjust distinct-render branch + frame_internal_fit_candidate
CSS-overlay branch + loop cap.
- _attempt_step17_image_fit_single_pass added for image_fit entry.
- §11.7.1 / §11.7.2 entry triggers wired; Step 17/18/19 artifact
refresh + note logging closes the salvage_terminal_action fall-through
for the 3 IMP-88 actions.
Tests
- New: test_router_actions_imp88.py (12),
test_failure_router_imp88_cascade.py (12),
test_phase_z2_retry_layout_adjust.py (10),
test_phase_z2_retry_image_fit.py (13),
test_phase_z2_retry_frame_internal_fit.py (13),
test_phase_z2_pipeline_salvage_imp88.py (8),
test_phase_z2_pipeline_step17_entry_imp88.py.
- Regression-aligned: test_phase_z2_failure_router_cascade.py,
test_phase_z2_step17_salvage_chain.py — pre-existing cascade +
salvage-chain assertions updated to the IMPLEMENTED surface.
Out of scope (separate axes / issues)
- details_popup_escalation terminal body (#64).
- frame_reselect MISSING flip (different axis).
- Step 14/16 detection refinement.
- Stage 0 mdx_normalizer integration (locked 2026-05-08).
- AI fallback activation.
Guardrails respected
- Phase Z spacing direction: layout_adjust switches preset; no shared
margin shrink.
- AI isolation contract: planners + dispatcher are deterministic; zero
AI calls in u1~u7.
- No hardcoding: routing + cascade live in router/failure_router data
rows, not inline conditionals.
- IMP-46 (#62) cache carve-out: untouched.
- 1 commit = 1 decision unit: u1~u7 grouped as a single IMP-88 unit.
Stage 4 verification: 7 IMP-88 test files + 2 modified regression files
PASS (Claude #12 + Codex #12 consensus YES). Full-suite sweep deferred to
a separate step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
342 lines
17 KiB
Python
342 lines
17 KiB
Python
"""Phase Z-2 overflow_router v0 (A2 — 정책 매핑 layer 만).
|
|
|
|
fit_classifier 의 출력 (category) 를 spec §4 의 *proposed_action* 으로 매핑하는 layer.
|
|
|
|
본 module 은 ***매핑까지만***. 실제 action 실행은 별도 step (A3+).
|
|
출력 = 각 classification 에 proposed_action 추가 + router 전체 summary.
|
|
|
|
원칙 :
|
|
- classifier = 사실 분류 (category 결정)
|
|
- router = 정책 결정 (그 category 면 무엇을 *제안* 할 것인가)
|
|
- 본 단계는 *제안 trace* 만. pipeline behavior / abort 정책 / rerender 변경 X
|
|
- 실행 안 됨 → 현재 코드는 여전히 visual_check_passed=False 시 sys.exit(1)
|
|
그러나 debug.json 에 *어떤 action 이 제안됐는지* 가 기록됨
|
|
|
|
다음 step (별도 — A3) :
|
|
zone_ratio_retry action 의 *실제 구현* — 지금 spec §4 mapping 의 가장 자주
|
|
트리거되는 action.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
|
|
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
|
|
|
|
# category → proposed_action (primary)
|
|
# IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the
|
|
# primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17
|
|
# spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321):
|
|
# 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped
|
|
# classifier emission (phase_z2_classifier.py:434-447) that previously
|
|
# returned proposed_action=None and stalled visual_check on overflow
|
|
# runs carrying image_event payloads.
|
|
# 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
|
|
# (previously frame_reselect) — OVERVIEW.md Step 17 locks
|
|
# frame_internal_fit_candidate as the per-zone first-pass salvage
|
|
# *inside* the declared frame envelope; frame_reselect (V4 top-k
|
|
# alternate frame swap) stays available downstream via the
|
|
# failure_router cascade (rerender_still_fails → frame_reselect).
|
|
ACTION_BY_CATEGORY: dict[str, str] = {
|
|
"minor_overflow": "zone_ratio_retry",
|
|
"moderate_overflow": "layout_adjust",
|
|
"structural_minor_overflow": "zone_ratio_retry",
|
|
"structural_major_overflow": "details_popup_escalation",
|
|
"tabular_overflow": "details_popup_escalation",
|
|
"image_aspect_mismatch": "image_fit",
|
|
"frame_capacity_mismatch": "frame_internal_fit_candidate",
|
|
"layout_zone_mismatch": "layout_adjust",
|
|
"hard_visual_fail": "abort",
|
|
}
|
|
|
|
# 매핑 근거 — *왜 이 category 면 이 action 인가* trace 용
|
|
ACTION_RATIONALE: dict[str, str] = {
|
|
"minor_overflow":
|
|
"1.5 줄 미만 text/label flow → zone 양보 / spacing 재계산으로 fit 가능",
|
|
"moderate_overflow":
|
|
"1.5~4 줄 text/label → layout/zone ratio 재분배 필요",
|
|
"structural_minor_overflow":
|
|
"structural unit boundary spill (<1 unit drop) → zone 양보로 fit, 단위 자르기 X",
|
|
"structural_major_overflow":
|
|
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
|
|
"tabular_overflow":
|
|
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
|
|
"image_aspect_mismatch":
|
|
"image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / "
|
|
"max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).",
|
|
"frame_capacity_mismatch":
|
|
"composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope "
|
|
"안 internal fit 변형 (density / line rhythm / row 배치) 우선. "
|
|
"frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).",
|
|
"layout_zone_mismatch":
|
|
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
|
|
"hard_visual_fail":
|
|
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
|
}
|
|
|
|
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18;
|
|
# IMP-35 u3 popup-stub 2026-05-23)
|
|
# 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
|
|
# IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered
|
|
# here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5
|
|
# landed the deterministic planners in src/phase_z2_retry.py. u6 wired the
|
|
# salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17
|
|
# entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2).
|
|
# Status flips MISSING→IMPLEMENTED land here on u7 completion — once the
|
|
# end-to-end path (planner + apply + dispatcher + entry) is wired the
|
|
# action is IMPLEMENTED on the deterministic surface. (Same convention as
|
|
# IMP-12 u7 cascade rows below: planner-surface availability + orchestrator
|
|
# wiring together constitute IMPLEMENTED; route_action's
|
|
# implementation_status field reflects surface availability, not whether a
|
|
# given pipeline run has invoked the action.)
|
|
"layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry
|
|
"image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry
|
|
"frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry
|
|
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
|
|
# `plan_details_popup_escalation` (below) provides the deterministic stub
|
|
# that downstream units consume: u4 binds the AI split-decision contract
|
|
# in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
|
# gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is
|
|
# decoupled from orchestrator wiring (same precedent as the IMP-12 u7
|
|
# cascade actions below): IMPLEMENTED here reflects deterministic
|
|
# *surface availability* (importable stub), not whether a given pipeline
|
|
# run has invoked it. The failure_router companion surface
|
|
# (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps
|
|
# `details_popup_escalation` as MISSING until u5 lands the pipeline gate.
|
|
"details_popup_escalation": "IMPLEMENTED",
|
|
"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
|
|
}
|
|
|
|
|
|
# ─── 단일 분류 → routing 결과 ─────────────────────────────────────
|
|
|
|
|
|
def route_action(category: str) -> dict:
|
|
"""category → proposed_action mapping 결과 (단일).
|
|
|
|
Returns:
|
|
dict :
|
|
proposed_action : action 이름 (또는 None)
|
|
rationale : *왜* 이 action 인가
|
|
implementation_status : implemented / partial / missing / unknown
|
|
mapping_source : "spec §4 ACTION_BY_CATEGORY" 또는 "no mapping"
|
|
"""
|
|
action = ACTION_BY_CATEGORY.get(category)
|
|
if action is None:
|
|
return {
|
|
"proposed_action": None,
|
|
"rationale": f"category '{category}' has no mapping in ACTION_BY_CATEGORY",
|
|
"implementation_status": "unknown",
|
|
"mapping_source": "no mapping (unknown category)",
|
|
}
|
|
return {
|
|
"proposed_action": action,
|
|
"rationale": ACTION_RATIONALE.get(category, ""),
|
|
"implementation_status": ACTION_IMPLEMENTATION_STATUS.get(action, "unknown"),
|
|
"mapping_source": "spec §4 ACTION_BY_CATEGORY",
|
|
}
|
|
|
|
|
|
# ─── fit_classification 전체 → router decision ──────────────────
|
|
|
|
|
|
def route_fit_classification(fit_classification: dict) -> dict:
|
|
"""fit_classification 의 모든 classifications 에 proposed_action 추가 + summary.
|
|
|
|
각 classification 에 다음 필드를 *추가* (기존 필드 보존) :
|
|
- proposed_action
|
|
- proposed_action_rationale
|
|
- proposed_action_implementation_status
|
|
- proposed_action_mapping_source
|
|
|
|
Returns:
|
|
router decision summary dict :
|
|
router_active : True/False (visual_check_passed=False 일 때만 True)
|
|
proposed_actions_summary : unique action 들 sorted list
|
|
implementation_status_summary : {status: count} dict
|
|
routed_count : 처리된 classification 수
|
|
routed_details : per-classification routing trace
|
|
missing_actions_pending_impl : 본 routing 에서 *현재 미구현* 인 action 모음
|
|
note : 사용자 안내 텍스트
|
|
"""
|
|
if fit_classification.get("visual_check_passed", True):
|
|
return {
|
|
"router_active": False,
|
|
"proposed_actions_summary": [],
|
|
"implementation_status_summary": {},
|
|
"routed_count": 0,
|
|
"routed_details": [],
|
|
"missing_actions_pending_impl": [],
|
|
"note": "visual check passed — no overflow to route",
|
|
}
|
|
|
|
classifications = fit_classification.get("classifications", []) or []
|
|
routed_details = []
|
|
|
|
for cls in classifications:
|
|
category = cls.get("category", "hard_visual_fail")
|
|
routing = route_action(category)
|
|
|
|
# classification entry 에 proposed_action 정보 *추가* (기존 필드 보존)
|
|
cls["proposed_action"] = routing["proposed_action"]
|
|
cls["proposed_action_rationale"] = routing["rationale"]
|
|
cls["proposed_action_implementation_status"] = routing["implementation_status"]
|
|
cls["proposed_action_mapping_source"] = routing["mapping_source"]
|
|
|
|
routed_details.append({
|
|
"source": cls.get("source"),
|
|
"zone_position": cls.get("zone_position"),
|
|
"category": category,
|
|
"proposed_action": routing["proposed_action"],
|
|
"implementation_status": routing["implementation_status"],
|
|
})
|
|
|
|
# summary
|
|
actions_seen = sorted({
|
|
r["proposed_action"] for r in routed_details
|
|
if r["proposed_action"] is not None
|
|
})
|
|
status_breakdown: dict[str, int] = {}
|
|
missing_actions: list[str] = []
|
|
for r in routed_details:
|
|
s = r["implementation_status"]
|
|
status_breakdown[s] = status_breakdown.get(s, 0) + 1
|
|
if s == "MISSING" and r["proposed_action"] not in missing_actions:
|
|
missing_actions.append(r["proposed_action"])
|
|
|
|
return {
|
|
"router_active": True,
|
|
"proposed_actions_summary": actions_seen,
|
|
"implementation_status_summary": status_breakdown,
|
|
"routed_count": len(routed_details),
|
|
"routed_details": routed_details,
|
|
"missing_actions_pending_impl": sorted(missing_actions),
|
|
"note": (
|
|
"router 는 category → proposed_action 매핑까지 담당. 실제 action 실행은 "
|
|
"pipeline 의 별도 orchestrator 가 처리 (예: zone_ratio_retry 는 "
|
|
"_attempt_zone_ratio_retry 에서 실행). proposed_action 의 implementation_status "
|
|
"가 IMPLEMENTED 이면 pipeline 이 시도하고 결과는 retry_trace 에 기록, "
|
|
"MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료."
|
|
),
|
|
}
|
|
|
|
|
|
# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─
|
|
# Surface contract for the cascade-terminal popup escalation. This stub
|
|
# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan
|
|
# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook
|
|
# (u4) consume. Keeping the executor surface here (next to the primary
|
|
# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for
|
|
# `details_popup_escalation` while u4/u5 are still landing.
|
|
#
|
|
# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3):
|
|
# - Inputs: classification dict (a single fit_classifier output row).
|
|
# The category MUST be one of the two ACTION_BY_CATEGORY
|
|
# rows that map onto `details_popup_escalation` —
|
|
# `structural_major_overflow` or `tabular_overflow`.
|
|
# Other categories raise the stub's defensive guard (so
|
|
# callers do not silently popup-escalate the wrong category).
|
|
# - Output: popup_escalation_plan dict with `feasible=True`,
|
|
# `stub=True`, the source category, the canonical
|
|
# ACTION_RATIONALE entry, and `needs_split_decision=True`
|
|
# to flag that u4 (AI hook) must run before u5 renders.
|
|
# - No side effects (no AI call, no MDX read, no HTML mutation).
|
|
#
|
|
# Guardrails honored:
|
|
# - feedback_ai_isolation_contract: stub is deterministic-with-data;
|
|
# no AI call inside the router surface.
|
|
# - Phase Z spacing 방향: stub does not shrink common margins; it
|
|
# expands capacity by routing content to popup downstream.
|
|
# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses
|
|
# to put MDX 원문 in popup body and a summary/subset in preview.
|
|
# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the
|
|
# downstream wiring on their respective files.
|
|
|
|
|
|
# Categories that legitimately escalate onto details_popup_escalation
|
|
# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant
|
|
# so the router cannot drift away from the single source of truth.
|
|
POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset(
|
|
category
|
|
for category, action in ACTION_BY_CATEGORY.items()
|
|
if action == "details_popup_escalation"
|
|
)
|
|
|
|
|
|
def plan_details_popup_escalation(classification: dict) -> dict:
|
|
"""Cascade-terminal popup escalation plan stub (IMP-35 u3).
|
|
|
|
Returns a deterministic popup_escalation_plan marker. The actual
|
|
content split (popup_html / preview_text / has_popup payload) is
|
|
composed downstream: u4 binds the AI split-decision contract on
|
|
`src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
|
gate executor on `src/phase_z2_pipeline.py`.
|
|
|
|
Args:
|
|
classification: a single fit_classifier classification dict.
|
|
Must contain a `category` key. Only the categories that
|
|
map onto `details_popup_escalation` in ACTION_BY_CATEGORY
|
|
(currently `structural_major_overflow` and `tabular_overflow`)
|
|
are accepted; any other category produces an
|
|
`feasible=False` plan with `failure_reason` so the caller
|
|
never silently popup-escalates the wrong overflow shape.
|
|
|
|
Returns:
|
|
popup_escalation_plan dict with at least:
|
|
action : "details_popup_escalation"
|
|
feasible : True/False (True for accepted categories)
|
|
stub : True (marks u3 surface; u4/u5 fill in)
|
|
category : echoed from input
|
|
rationale : canonical ACTION_RATIONALE entry
|
|
needs_split_decision : True (u4 AI hook must run before u5 renders)
|
|
mapping_source : "IMP-35 u3 plan_details_popup_escalation stub"
|
|
note : downstream-wiring pointer text
|
|
"""
|
|
category = (classification or {}).get("category")
|
|
base = {
|
|
"action": "details_popup_escalation",
|
|
"stub": True,
|
|
"category": category,
|
|
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
|
}
|
|
if category not in POPUP_ESCALATION_CATEGORIES:
|
|
return {
|
|
**base,
|
|
"feasible": False,
|
|
"needs_split_decision": False,
|
|
"rationale": "",
|
|
"failure_reason": (
|
|
f"category {category!r} does not map onto details_popup_escalation "
|
|
f"in ACTION_BY_CATEGORY. Accepted categories: "
|
|
f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — "
|
|
f"router must not silently popup-escalate the wrong overflow shape."
|
|
),
|
|
"note": (
|
|
"u3 stub — caller passed a category that should not popup-escalate. "
|
|
"Honour the ACTION_BY_CATEGORY mapping at the router entry point."
|
|
),
|
|
}
|
|
return {
|
|
**base,
|
|
"feasible": True,
|
|
"needs_split_decision": True,
|
|
"rationale": ACTION_RATIONALE.get(category, ""),
|
|
"note": (
|
|
"u3 stub — actual content split planning lands in u4 "
|
|
"(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) "
|
|
"and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). "
|
|
"popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)."
|
|
),
|
|
}
|