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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user