diff --git a/src/phase_z2_composition.py b/src/phase_z2_composition.py
index e0e417b..d5c4909 100644
--- a/src/phase_z2_composition.py
+++ b/src/phase_z2_composition.py
@@ -343,6 +343,9 @@ class CompositionUnit:
phase_z_status: str
raw_content: str
title: str
+ v4_rank: Optional[int] = None
+ selection_path: str = "rank_1"
+ fallback_reason: Optional[str] = None
score: float = 0.0
rationale: dict = field(default_factory=dict)
@@ -473,6 +476,9 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
confidence=match.confidence,
label=match.label,
phase_z_status=v4_label_to_status.get(match.label, "unknown"),
+ v4_rank=getattr(match, "v4_rank", None),
+ selection_path=getattr(match, "selection_path", "rank_1"),
+ fallback_reason=getattr(match, "fallback_reason", None),
raw_content=s.raw_content,
title=s.title,
v4_candidates=_v4_cands(s.section_id),
@@ -504,6 +510,9 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
confidence=parent_match.confidence,
label=parent_match.label,
phase_z_status=v4_label_to_status.get(parent_match.label, "unknown"),
+ v4_rank=getattr(parent_match, "v4_rank", None),
+ selection_path=getattr(parent_match, "selection_path", "rank_1"),
+ fallback_reason=getattr(parent_match, "fallback_reason", None),
raw_content=merged_raw,
title=pid,
v4_candidates=_v4_cands(pid),
@@ -597,6 +606,9 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
confidence=rep_match.confidence,
label=rep_match.label,
phase_z_status=rep_status,
+ v4_rank=getattr(rep_match, "v4_rank", None),
+ selection_path=getattr(rep_match, "selection_path", "rank_1"),
+ fallback_reason=getattr(rep_match, "fallback_reason", None),
raw_content=merged_raw,
title=pid,
auto_selectable=auto_selectable,
@@ -773,6 +785,9 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
"template_id": c.frame_template_id,
"label": c.label,
"phase_z_status": c.phase_z_status,
+ "v4_rank": c.v4_rank,
+ "selection_path": c.selection_path,
+ "fallback_reason": c.fallback_reason,
"score": c.score,
"selection_state": _candidate_state(c),
"auto_selectable": c.auto_selectable,
diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py
index 898f5e8..c49e430 100644
--- a/src/phase_z2_pipeline.py
+++ b/src/phase_z2_pipeline.py
@@ -146,6 +146,9 @@ class V4Match:
template_id: str
confidence: float
label: str
+ v4_rank: Optional[int] = None
+ selection_path: str = "rank_1"
+ fallback_reason: Optional[str] = None
def to_phase_z_status(match: V4Match) -> str:
@@ -408,6 +411,19 @@ def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> li
return aligned
+def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int] = None) -> V4Match:
+ resolved_rank = rank if rank is not None else judgment.get("v4_full_rank")
+ return V4Match(
+ section_id=section_id,
+ frame_id=str(judgment["frame_id"]),
+ frame_number=int(judgment["frame_number"]),
+ template_id=judgment["template_id"],
+ confidence=float(judgment["confidence"]),
+ label=judgment["label"],
+ v4_rank=int(resolved_rank) if resolved_rank is not None else None,
+ )
+
+
def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]:
sec = v4.get("mdx_sections", {}).get(section_id)
if not sec:
@@ -416,14 +432,128 @@ def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]:
if not judgments:
return None
top = judgments[0]
- return V4Match(
- section_id=section_id,
- frame_id=str(top["frame_id"]),
- frame_number=int(top["frame_number"]),
- template_id=top["template_id"],
- confidence=float(top["confidence"]),
- label=top["label"],
- )
+ return _v4_match_from_judgment(section_id, top, rank=1)
+
+
+# IMP-05 L2/L5 route hint — V4 label → execution route guidance for future consumers
+# (frontend zone-level override / AI-assisted adaptation). Codex #2 conceptual model :
+# use_as_is → Phase Z direct render
+# light_edit → deterministic minor adjustment
+# restructure → AI-assisted frame-aware adaptation (deferred to IMP-31)
+# reject → design reference only (deferred to IMP-29 frontend override)
+_IMP05_ROUTE_HINTS: dict[str, str] = {
+ "use_as_is": "direct_render",
+ "light_edit": "deterministic_minor_adjustment",
+ "restructure": "ai_adaptation_required",
+ "reject": "design_reference_only",
+}
+
+
+def _imp05_route_hint(label: Optional[str]) -> Optional[str]:
+ """Map V4 label to execution route hint. Returns None for unknown labels."""
+ if label is None:
+ return None
+ return _IMP05_ROUTE_HINTS.get(label)
+
+
+def lookup_v4_match_with_fallback(
+ v4: dict,
+ section_id: str,
+ *,
+ raw_content: Optional[str] = None,
+ max_rank: int = 3,
+) -> tuple[Optional[V4Match], dict]:
+ """Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
+
+ This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
+ presence, and the Phase Z capacity precheck; it does not call calculate_fit.
+ """
+ sec = v4.get("mdx_sections", {}).get(section_id)
+ trace = {
+ "section_id": section_id,
+ "max_rank": max_rank,
+ "selection_path": "no_v4_candidate",
+ "selected_rank": None,
+ "selected_template_id": None,
+ "selected_frame_id": None,
+ "selected_label": None,
+ "fallback_used": False,
+ "fallback_reason": None,
+ "candidates": [],
+ }
+ if not sec:
+ trace["fallback_reason"] = "no_v4_section"
+ return None, trace
+
+ judgments = (sec.get("judgments_full32") or [])[:max_rank]
+ if not judgments:
+ trace["fallback_reason"] = "empty_v4_judgments"
+ return None, trace
+
+ first_skip_reason: Optional[str] = None
+ for i, judgment in enumerate(judgments, start=1):
+ match = _v4_match_from_judgment(section_id, judgment, rank=i)
+ status = to_phase_z_status(match)
+ # IMP-05 L2 (Codex #10 E4) — informative candidate_evidence schema.
+ # `v4_label` naming matches Codex schema (Claude #13 §1 lock).
+ # `filtered_for_direct_execution` + `route_hint` = L5 restructure/reject trace 보존
+ # 단일 source (frontend/AI future consumer guidance).
+ is_direct_eligible = status in MVP1_ALLOWED_STATUSES
+ candidate_trace = {
+ "rank": i,
+ "template_id": match.template_id,
+ "frame_id": match.frame_id,
+ "frame_number": match.frame_number,
+ "confidence": match.confidence,
+ "label": match.label, # existing — kept for backward compat
+ "v4_label": match.label, # IMP-05 L2 alias (Codex schema)
+ "phase_z_status": status,
+ "catalog_registered": get_contract(match.template_id) is not None,
+ "filtered_for_direct_execution": not is_direct_eligible, # IMP-05 L2/L5
+ "route_hint": _imp05_route_hint(match.label), # IMP-05 L2/L5
+ "decision": "skipped",
+ "reason": None,
+ }
+
+ if status not in MVP1_ALLOWED_STATUSES:
+ candidate_trace["reason"] = f"phase_z_status_not_allowed:{status}"
+ elif get_contract(match.template_id) is None:
+ candidate_trace["reason"] = "skipped_no_contract"
+ else:
+ capacity_fit = None
+ if raw_content is not None:
+ capacity_fit = compute_capacity_fit(match.template_id, raw_content)
+ candidate_trace["capacity_fit"] = capacity_fit
+ if capacity_fit and capacity_fit.get("fit_status") not in {
+ "ok", "no_contract", "unknown_source_shape",
+ }:
+ candidate_trace["reason"] = f"capacity_mismatch:{capacity_fit.get('fit_status')}"
+ else:
+ fallback_used = i > 1
+ fallback_reason = first_skip_reason if fallback_used else None
+ match.selection_path = f"rank_{i}" if not fallback_used else f"rank_{i}_fallback"
+ match.fallback_reason = fallback_reason
+ candidate_trace["decision"] = "selected"
+ candidate_trace["reason"] = "primary_selected" if i == 1 else "fallback_selected"
+ trace["candidates"].append(candidate_trace)
+ trace.update({
+ "selection_path": match.selection_path,
+ "selected_rank": i,
+ "selected_template_id": match.template_id,
+ "selected_frame_id": match.frame_id,
+ "selected_label": match.label,
+ "fallback_used": fallback_used,
+ "fallback_reason": fallback_reason,
+ })
+ return match, trace
+
+ if i == 1:
+ first_skip_reason = candidate_trace["reason"]
+ trace["candidates"].append(candidate_trace)
+
+ trace["selection_path"] = "chain_exhausted"
+ trace["fallback_reason"] = first_skip_reason or "no_auto_renderable_rank_1_to_3"
+ return None, trace
def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
@@ -442,14 +572,7 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
judgments = sec.get("judgments_full32", [])
out: list[V4Match] = []
for j in judgments:
- out.append(V4Match(
- section_id=section_id,
- frame_id=str(j["frame_id"]),
- frame_number=int(j["frame_number"]),
- template_id=j["template_id"],
- confidence=float(j["confidence"]),
- label=j["label"],
- ))
+ out.append(_v4_match_from_judgment(section_id, j))
return out
@@ -482,14 +605,7 @@ def lookup_v4_candidates(
for j in judgments:
if j.get("label") == "reject":
continue
- candidates.append(V4Match(
- section_id=section_id,
- frame_id=str(j["frame_id"]),
- frame_number=int(j["frame_number"]),
- template_id=j["template_id"],
- confidence=float(j["confidence"]),
- label=j["label"],
- ))
+ candidates.append(_v4_match_from_judgment(section_id, j))
if len(candidates) >= max_n:
break
return candidates
@@ -1187,7 +1303,17 @@ def compute_slide_status(sections: list[MdxSection],
adapter_needed_units = list(adapter_needed_units or [])
content_truncated = []
+ fallback_selections = []
for z in (debug_zones or []):
+ if z.get("fallback_used"):
+ fallback_selections.append({
+ "position": z["position"],
+ "source_section_ids": z["source_section_ids"],
+ "template_id": z["v4_template_id"],
+ "selected_v4_rank": z.get("v4_selected_rank"),
+ "selection_path": z.get("selection_path"),
+ "fallback_reason": z.get("fallback_reason"),
+ })
tc = z.get("content_truncated_count")
if tc:
content_truncated.append({
@@ -1232,6 +1358,9 @@ def compute_slide_status(sections: list[MdxSection],
"covered_section_ids": sorted(covered),
"filtered_section_ids": filtered_ids,
"filtered_section_reasons": filtered_section_reasons,
+ "selection_path": "fallback_used" if fallback_selections else "rank_1",
+ "fallback_used": bool(fallback_selections),
+ "fallback_selections": fallback_selections,
"visual_fail_reasons": list(overflow.get("fail_reasons") or []),
"adapter_needed_count": len(adapter_needed_units),
"adapter_needed_units": adapter_needed_units,
@@ -1601,8 +1730,18 @@ def run_phase_z2_mvp1(
# 4. Composition planner v0 — replaces per-section + select_layout_preset.
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
# layout preset (count-based v0).
+ section_content_by_id = {s.section_id: s.raw_content for s in sections}
+ v4_fallback_traces: dict[str, dict] = {}
+
def lookup_fn(sid: str) -> Optional[V4Match]:
- return lookup_v4_match(v4, sid)
+ match, trace = lookup_v4_match_with_fallback(
+ v4,
+ sid,
+ raw_content=section_content_by_id.get(sid),
+ max_rank=3,
+ )
+ v4_fallback_traces[sid] = trace
+ return match
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
@@ -1614,6 +1753,35 @@ def run_phase_z2_mvp1(
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
)
+ comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
+ # IMP-05 L3 (Codex #10 D4) — Step 20 qualifier fields (additive only, no top-level enum change).
+ # `fallback_selection_count` = number of sections where rank-2/3 was promoted.
+ # `selection_paths` = per-section selection_path summary (rank_1 / rank_N_fallback / chain_exhausted).
+ # Top-level slide status enum (PASS / PARTIAL_COVERAGE / ...) remains stable.
+ _imp05_selection_paths = [
+ {
+ "section_id": sid,
+ "selection_path": t.get("selection_path"),
+ "selected_rank": t.get("selected_rank"),
+ "selected_template_id": t.get("selected_template_id"),
+ "fallback_trigger": t.get("fallback_reason") if t.get("fallback_used") else None,
+ }
+ for sid, t in v4_fallback_traces.items()
+ ]
+ comp_debug["v4_fallback_summary"] = {
+ "fallback_used_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
+ "fallback_selection_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
+ "chain_exhausted_count": sum(
+ 1 for t in v4_fallback_traces.values()
+ if t.get("selection_path") == "chain_exhausted"
+ ),
+ "selection_paths": _imp05_selection_paths,
+ "policy": (
+ "IMP-05: rank-1 is kept when usable; rank-2/3 may be promoted only when "
+ "the earlier rank is not auto-renderable, has no catalog contract, or fails "
+ "capacity precheck. calculate_fit is not used."
+ ),
+ }
# ── Step 7-A axis : layout override ──
# 사용자가 LayoutPanel 에서 다른 preset 을 선택했을 때 자동 결정값을 강제 변경.
@@ -1678,6 +1846,9 @@ def run_phase_z2_mvp1(
"frame_number": u.frame_number,
"frame_template_id": u.frame_template_id,
"label": u.label,
+ "v4_rank": u.v4_rank,
+ "selection_path": u.selection_path,
+ "fallback_reason": u.fallback_reason,
"score": u.score,
"phase_z_status": u.phase_z_status,
"rationale": u.rationale,
@@ -1896,6 +2067,10 @@ def run_phase_z2_mvp1(
"v4_template_id": unit.frame_template_id,
"v4_label": unit.label,
"v4_confidence": unit.confidence,
+ "v4_selected_rank": unit.v4_rank,
+ "selection_path": unit.selection_path,
+ "fallback_reason": unit.fallback_reason,
+ "fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
"phase_z_status": unit.phase_z_status,
"composition_score": unit.score,
"composition_rationale": unit.rationale,
@@ -2019,9 +2194,12 @@ def run_phase_z2_mvp1(
{
"position": dz["position"],
"v4_rank1_frame_number": dz.get("v4_rank1_frame_number"),
+ "v4_selected_rank": dz.get("v4_selected_rank"),
"v4_template_id": dz.get("v4_template_id"),
"v4_confidence": dz.get("v4_confidence"),
"v4_label": dz.get("v4_label"),
+ "selection_path": dz.get("selection_path"),
+ "fallback_reason": dz.get("fallback_reason"),
"phase_z_status": dz.get("phase_z_status"),
"selected_template_id": dz.get("contract_id"),
"mapper_type": dz.get("mapper_type"),
@@ -2490,9 +2668,8 @@ def run_phase_z2_mvp1(
has_v4 = bool(unit.v4_candidates)
candidate_status = "ok" if has_v4 else "no_non_reject_v4_candidate"
application_status = "ok" if has_v4 else "no_v4_candidate"
- current_default = (
- unit.v4_candidates[0].template_id if has_v4 else None
- )
+ current_default = unit.frame_template_id if has_v4 else None
+ selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {})
# Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가
# 모든 frame 의 png 를 카드로 보여주기 위함).
@@ -2525,11 +2702,17 @@ def run_phase_z2_mvp1(
"candidate_status": candidate_status,
"application_status": application_status,
"current_default_candidate": current_default,
+ "selected_v4_rank": unit.v4_rank,
+ "selection_path": unit.selection_path,
+ "fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
+ "fallback_reason": unit.fallback_reason,
+ "fallback_chain": selection_trace.get("candidates", []),
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
+ "v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
}
@@ -2546,6 +2729,7 @@ def run_phase_z2_mvp1(
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
+ "v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": get_contract(c.template_id) is not None,
@@ -2566,7 +2750,11 @@ def run_phase_z2_mvp1(
"units": application_plan_units,
"candidate_status_summary": {
"units_with_no_v4_candidate": units_with_no_v4,
+ "units_with_fallback": [
+ u["unit_id"] for u in application_plan_units if u.get("fallback_used")
+ ],
},
+ "fallback_policy": comp_debug.get("v4_fallback_summary"),
# Step 7-A axis : user override trace
"frame_overrides_applied": frame_overrides_applied,
"frame_overrides_skipped": frame_overrides_skipped,
@@ -2617,6 +2805,13 @@ def run_phase_z2_mvp1(
f'{u["current_default_candidate"]}'
if u["current_default_candidate"] else 'null'
)
+ _fallback_html = (
+ f' | selection_path: {u.get("selection_path")}'
+ f' | selected_v4_rank: {u.get("selected_v4_rank")}'
+ f' | fallback_reason: {u.get("fallback_reason")}'
+ if u.get("fallback_used") else
+ f' | selection_path: {u.get("selection_path")}'
+ )
_layout_pills = " ".join(
f'{lc}{" ★" if k == 0 else ""}'
@@ -2635,7 +2830,7 @@ def run_phase_z2_mvp1(
_app_rows = ""
for k, ac in enumerate(u["application_candidates"]):
_bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569"))
- _is_default = (k == 0)
+ _is_default = (ac["template_id"] == u["current_default_candidate"])
_default_mark = (
' current_default'
@@ -2661,7 +2856,7 @@ def run_phase_z2_mvp1(
f'
{u["unit_id"]} {_status_badge}'
f'layout_preset (default): {u["layout_preset"]} | '
- f'current_default_candidate: {_default_html}
layout_candidates (★ default): {_layout_pills}
' f'region_layout_candidates (★ default, placeholder): {_region_pills}
' f'display_strategy_candidates (★ default, placeholder): {_display_pills}
' @@ -2775,6 +2970,12 @@ def run_phase_z2_mvp1( # *매핑까지만*. 실행 / rerender / behavior 변경 X. # classifications 각 entry 에 proposed_action 추가, router_decision summary 반환. 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" + ) # ─── Step 16: Overflow Router ─── _write_step_artifact( @@ -2812,6 +3013,12 @@ def run_phase_z2_mvp1( # post-retry classifier / router 재실행 — 새 overflow 가 통과면 router_active=False 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" + ) # 11.6 retry_failure_classifier + next_action_router (A4 — 분류/매핑만, 실행 X) # retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보). diff --git a/src/phase_z2_router.py b/src/phase_z2_router.py index e81dc0f..52beca7 100644 --- a/src/phase_z2_router.py +++ b/src/phase_z2_router.py @@ -62,7 +62,7 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { "zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration "layout_adjust": "MISSING", "details_popup_escalation": "MISSING", # CLAUDE.md 의