feat(#39): IMP-30 first-render invariant + abort bypass (2 paths)

Restore first-render invariant: final.html + Step 20 slide_status MUST be
written for every input where Step 0~5 succeed. Two abort paths replaced
with provisional/empty-shell synthesis; MDX content preserved, AI-free.

- u1 V4Match.provisional + lookup_v4_match_with_fallback(allow_provisional)
  chain_exhausted -> synthesize rank-1 provisional (opt-in, default-off)
- u2 CompositionUnit.provisional propagation (single / parent_merged /
  parent_merged_inferred constructors)
- u3 select_composition_units(allow_provisional_fill=True) last-resort
  fill + _candidate_state="selected_provisional"
- u4 pipeline.py path-(a) abort guard replaced with provisional retry +
  terminal __empty__ shell (no sys.exit(1))
- u5 zones_data.provisional -> slide_base.html zone--provisional class +
  data-provisional + needs-adaptation badge (template-only)
- u6 compute_slide_status additive provisional_first_render_count/_units
  (overall enum unchanged per IMP-05 Codex #10 D4)
- u7 regression: tests/test_phase_z2_imp30_first_render.py (28 tests) +
  tests/test_phase_z2_v4_fallback.py (+5 cases)

Guardrails verified: MVP1_ALLOWED_STATUSES unchanged, no calculate_fit,
no LLM in fallback path, no MDX 03/04/05 hardcoding.

Anchor sync (Rule 13): tests/orchestrator_unit/test_imp17_comment_anchor.py
re-pinned 564/565 -> 570/571 to track V4Match.provisional shift at
src/phase_z2_pipeline.py:179-184.

Cross-ref: IMP-05 (#5) §5 defer + Codex #2 first-render invariant.
This commit is contained in:
2026-05-21 00:40:58 +09:00
parent b4872ba6ce
commit 1efbf672bd
6 changed files with 2105 additions and 33 deletions

View File

@@ -176,6 +176,12 @@ class V4Match:
v4_rank: Optional[int] = None
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
# IMP-30 u1 — provisional first-render flag. True when the selector
# synthesizes a rank-1 V4 candidate after chain_exhausted because the
# opt-in allow_provisional kwarg was set. Default False keeps IMP-05
# behavior byte-identical; downstream surfaces this for zone-level
# "needs adaptation" marking without altering V4 evidence.
provisional: bool = False
def to_phase_z_status(match: V4Match) -> str:
@@ -585,11 +591,26 @@ def lookup_v4_match_with_fallback(
raw_content: Optional[str] = None,
max_rank: int = 3,
alias_keys: Optional[list] = None,
allow_provisional: bool = False,
) -> 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.
IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..max_rank chain
is exhausted (no candidate passes MVP1 filter + contract + capacity), the
selector synthesizes a *provisional* V4Match from the rank-1 judgment so
the first-render invariant can be satisfied downstream. The synthesized
match carries ``provisional=True``, ``selection_path="provisional_rank_1"``,
and ``fallback_reason`` mirrors the existing chain-exhaust reason. The
candidate trace shape is unchanged (synthetic injection only updates the
top-level ``selection_path`` + ``selected_*`` mirrors). When the rank-1
judgment itself is missing (``empty_v4_judgments`` / ``no_v4_section``),
no provisional is synthesized — the caller (u3 / u4) handles those cases
with a placeholder zone or empty-shell.
Default ``allow_provisional=False`` keeps the IMP-05 behavior byte-identical.
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
@@ -692,6 +713,32 @@ def lookup_v4_match_with_fallback(
trace["selection_path"] = "chain_exhausted"
trace["fallback_reason"] = first_skip_reason or "no_auto_renderable_rank_1_to_3"
# IMP-30 u1 — opt-in provisional first-render synthesis. When the caller
# signals allow_provisional, promote rank-1 judgment as a provisional
# match so downstream composition can satisfy the first-render invariant.
# Top-level mirrors (selection_path / selected_*) are updated; candidate
# trace entries are left intact (their skip reasons remain accurate).
# Default-off keeps IMP-05 behavior byte-identical.
if allow_provisional:
rank_1_judgment = judgments[0]
provisional_match = _v4_match_from_judgment(
section_id, rank_1_judgment, rank=1
)
provisional_match.selection_path = "provisional_rank_1"
provisional_match.fallback_reason = trace["fallback_reason"]
provisional_match.provisional = True
trace.update({
"selection_path": "provisional_rank_1",
"selected_rank": 1,
"selected_template_id": provisional_match.template_id,
"selected_frame_id": provisional_match.frame_id,
"selected_label": provisional_match.label,
"fallback_used": True,
"provisional": True,
})
return provisional_match, trace
return None, trace
@@ -2437,6 +2484,9 @@ def compute_slide_status(sections: list[MdxSection],
- full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered
- adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고)
- content_truncated_count : builder 가 truncate 한 zone 수 (informational)
- provisional_first_render_count : IMP-30 first-render invariant 로 합성된 unit 수
(u1 V4Match synthesis / u3 last-resort fill /
u4 empty-shell — needs user/AI adaptation 신호)
overall enum :
PASS — visual OK + full coverage + adapter_needed=0
@@ -2444,6 +2494,8 @@ def compute_slide_status(sections: list[MdxSection],
PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK
PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다
(adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용)
(IMP-30 u6 : provisional_first_render_count 도 qualifier 일 뿐, overall enum 변경 X.
Stage 1 Q3 + Codex #10 D4 lock.)
"""
aligned_ids = [s.section_id for s in sections]
covered = set()
@@ -2555,6 +2607,29 @@ def compute_slide_status(sections: list[MdxSection],
_fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0)
_selection_paths = _v4_fb_summary.get("selection_paths", [])
# IMP-30 u6 — Step 20 additive qualifier fields for the first-render invariant.
# provisional_first_render_count = number of selected units whose .provisional
# flag is True (set by u1 V4Match synthesis → u2 CompositionUnit propagation,
# u3 last-resort fill, or u4 empty-shell synthesis). The list mirrors the shape
# of fallback_selections / adapter_needed_units for symmetry. Top-level overall
# enum stays unchanged per IMP-05 Codex #10 D4 + Stage 1 Q3 decision: this
# signal is a qualifier, not a new failure class. Defensive getattr keeps the
# function safe when units come from legacy code paths predating u2.
provisional_first_render_units: list[dict] = []
for u in units:
if not getattr(u, "provisional", False):
continue
provisional_first_render_units.append({
"source_section_ids": list(getattr(u, "source_section_ids", []) or []),
"phase_z_status": getattr(u, "phase_z_status", None),
"frame_template_id": getattr(u, "frame_template_id", None),
"frame_id": getattr(u, "frame_id", None),
"label": getattr(u, "label", None),
"selection_path": getattr(u, "selection_path", None),
"fallback_reason": getattr(u, "fallback_reason", None),
"v4_rank": getattr(u, "v4_rank", None),
})
return {
"rendered": True,
"visual_check_passed": visual_passed,
@@ -2574,12 +2649,17 @@ def compute_slide_status(sections: list[MdxSection],
"adapter_needed_units": adapter_needed_units,
"content_truncated_count": len(content_truncated),
"content_truncated_units": content_truncated,
# IMP-30 u6 — additive provisional qualifiers (overall enum unchanged).
"provisional_first_render_count": len(provisional_first_render_units),
"provisional_first_render_units": provisional_first_render_units,
"overall": overall,
"note": (
"자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. "
"overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. "
"adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. "
"content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)."
"content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). "
"provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 "
"(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)."
),
}
@@ -3154,25 +3234,145 @@ def run_phase_z2_mvp1(
units = plan_units
if not units or layout_preset is None:
# composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는
# status filter 통과 못 함. error.json 기록 후 abort.
run_dir.mkdir(parents=True, exist_ok=True)
error_data = {
"stage": "composition_planner",
"reason": (
"Composition planner v0 selected 0 viable units. "
f"Either no V4 entries for any section, or all candidates filtered out by "
f"allowed_statuses={sorted(MVP1_ALLOWED_STATUSES)}."
),
"aligned_section_ids": [s.section_id for s in sections],
"composition_debug": comp_debug,
}
err_path = run_dir / "error.json"
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ composition_planner", file=sys.stderr)
print(f" reason : 0 viable units after composition v0", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
# IMP-30 u4 — first-render invariant. The pre-u4 path here was
# `sys.exit(1)` after writing error.json. That violated the invariant
# ("final.html + Step 20 slide_status MUST be written for every input
# where Step 0~5 succeed") whenever V4 evidence for any section was
# restructure/reject (chain_exhausted) or missing (no_v4_section /
# empty_v4_judgments).
#
# Recovery has two phases:
# Phase A — provisional retry (u1 + u3 opt-in). Re-run plan_composition
# with allow_provisional=True (in lookup_fn) and allow_provisional_fill
# =True. Synthesizes rank-1 provisional V4Match on chain_exhausted
# (u1) and last-resort-fills uncovered sections with provisional
# candidates (u3). Skipped when the CLI override path was used —
# re-running plan_composition there would discard the override.
# Phase B — terminal empty-shell. If retry still yields zero units
# (true "no rank-1 V4 anywhere" case, or override path with no
# resolvable assignments), synthesize a single placeholder
# CompositionUnit with frame_template_id="__empty__", layout_preset
# ="single". The per-unit loop's __empty__ guard emits a placeholder
# zones_data / debug_zones record; final.html renders the slide
# base shell (title + footer + empty zone) so the first-render
# invariant holds. Provisional flag = True surfaces the "needs
# adaptation" signal (u5 zone class + u6 status qualifier).
provisional_recovered = False
if section_assignment_plan is None:
def _lookup_fn_provisional(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
max_rank=3,
alias_keys=section_alias_by_id.get(sid),
allow_provisional=True,
)
v4_fallback_traces[sid] = trace
return match
units_retry, layout_preset_retry, comp_debug_retry = plan_composition(
sections,
_lookup_fn_provisional,
V4_LABEL_TO_PHASE_Z_STATUS,
MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
allow_provisional_fill=True,
)
comp_debug["imp30_u4_provisional_retry"] = {
"applied": True,
"result_unit_count": len(units_retry),
"result_layout_preset": layout_preset_retry,
"candidates_summary": comp_debug_retry.get("candidates_summary"),
}
if units_retry and layout_preset_retry is not None:
units = units_retry
layout_preset = layout_preset_retry
provisional_recovered = True
# v4_fallback_traces was overwritten by _lookup_fn_provisional;
# refresh the IMP-05 selection_paths telemetry so Step 20 reflects
# the actual selection (provisional_rank_1) rather than the stale
# chain_exhausted state from the first attempt.
_imp05_selection_paths_retry = [
{
"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_selections"] = list(v4_fallback_traces.values())
if "v4_fallback_summary" in comp_debug:
comp_debug["v4_fallback_summary"]["selection_paths"] = (
_imp05_selection_paths_retry
)
print(
f" [IMP-30 u4] provisional retry recovered {len(units)} unit(s) "
f"— first-render invariant preserved.",
file=sys.stderr,
)
if not provisional_recovered:
# Phase B — terminal empty-shell. No rank-1 V4 evidence for any
# section, or override path produced no renderable assignments.
from src.phase_z2_composition import CompositionUnit as _CompositionUnit
run_dir.mkdir(parents=True, exist_ok=True)
empty_shell_unit = _CompositionUnit(
source_section_ids=[s.section_id for s in sections],
merge_type="empty_shell",
frame_template_id="__empty__",
frame_id="__empty__",
frame_number=0,
confidence=0.0,
label="empty_shell",
phase_z_status="empty_shell",
raw_content="\n\n".join((s.raw_content or "") for s in sections),
title=" / ".join((s.title or "") for s in sections),
v4_rank=None,
selection_path="empty_shell",
fallback_reason="no_v4_rank_1_for_any_section",
score=0.0,
rationale={
"imp30_u4": "terminal_first_render_empty_shell",
"reason": (
"no_rank_1_V4_evidence_in_any_section"
if section_assignment_plan is None
else "section_assignment_override_yielded_no_renderable_units"
),
"aligned_section_ids": [s.section_id for s in sections],
},
provisional=True,
)
units = [empty_shell_unit]
layout_preset = "single"
comp_debug["imp30_u4_empty_shell"] = {
"applied": True,
"reason": (
"no_rank_1_V4_for_any_section"
if section_assignment_plan is None
else "section_assignment_override_yielded_no_renderable_units"
),
"aligned_section_ids": [s.section_id for s in sections],
}
print(
f"\n[Phase Z-2 IMP-30 u4] EMPTY-SHELL @ composition_planner",
file=sys.stderr,
)
print(
f" reason : "
f"{'no rank-1 V4 evidence for any section' if section_assignment_plan is None else 'override produced no renderable units'}",
file=sys.stderr,
)
print(
f" shell : 1 placeholder unit, preset='single' "
f"(sections={[s.section_id for s in sections]})",
file=sys.stderr,
)
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
for u in units:
@@ -3345,6 +3545,63 @@ def run_phase_z2_mvp1(
# and is byte-identical to plan_record.position in the normal case.
if plan_record is not None and plan_record.get("position"):
position = plan_record["position"]
# IMP-30 u4 — empty-shell synthesized unit. frame_template_id="__empty__"
# has no catalog contract by design; bypass mapper/contract path and emit
# a placeholder zone record so render_slide() short-circuits to empty
# partial_html (existing `__empty__` branch at render_slide:2106). The
# slide_base still renders title + footer + empty grid cell so the
# first-render invariant holds; u5 will surface the provisional flag as
# a zone class + needs-adaptation badge.
if unit.frame_template_id == "__empty__":
zones_data.append({
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 0,
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"provisional": bool(getattr(unit, "provisional", False)),
})
debug_zones.append({
"position": position,
"source_section_ids": list(unit.source_section_ids),
"merge_type": unit.merge_type,
"title": unit.title,
"v4_rank1_frame_id": unit.frame_id,
"v4_rank1_frame_number": unit.frame_number,
"v4_template_id": "__empty__",
"v4_label": unit.label,
"v4_confidence": float(unit.confidence or 0.0),
"v4_selected_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"fallback_used": False,
"phase_z_status": unit.phase_z_status,
"composition_score": float(unit.score or 0.0),
"composition_rationale": dict(unit.rationale or {}),
"composition_notes": list(unit.notes),
"mapper_type": "empty_shell",
"contract_id": "__empty__",
"contract_frame_id": None,
"builder": None,
"min_height_px": 0,
"slot_payload_keys": [],
"content_truncated_count": None,
"assets_dir": None,
"content_weight": {"score": 0},
"placement_trace": None,
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "imp30_u4_empty_shell_no_v4_evidence",
"provisional": bool(getattr(unit, "provisional", False)),
})
continue
synth_section = MdxSection(
section_id="+".join(unit.source_section_ids),
section_num=0,
@@ -3470,6 +3727,12 @@ def run_phase_z2_mvp1(
plan_record.get("skipped_reason") if plan_record else None
)
# IMP-30 u5 — `provisional` flag flows as data through V4Match (u1) →
# CompositionUnit (u2) → zones_data here. slide_base.html reads
# zone.provisional to apply the `zone--provisional` class + inline
# needs-adaptation badge. Default False keeps non-provisional zones
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
# empty-shell synthesize provisional=True units.
zones_data.append({
"position": position,
"template_id": unit.frame_template_id,
@@ -3478,6 +3741,7 @@ def run_phase_z2_mvp1(
"min_height_px": min_height_px,
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"provisional": bool(getattr(unit, "provisional", False)),
})
debug_zones.append({
"position": position,
@@ -3515,6 +3779,8 @@ def run_phase_z2_mvp1(
"skipped_collided_auto_units": plan_skipped_collided,
"uncovered_section_ids": plan_uncovered,
"skipped_reason": plan_skipped_reason,
# IMP-30 u5 — provisional signal mirror for debug.json consumers.
"provisional": bool(getattr(unit, "provisional", False)),
})
# IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records