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

@@ -368,6 +368,15 @@ class CompositionUnit:
# 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input). # 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input).
v4_candidates: list = field(default_factory=list) v4_candidates: list = field(default_factory=list)
# IMP-30 u2 — provisional first-render flag. True when the V4Match
# backing this unit was synthesized via lookup_v4_match_with_fallback
# (allow_provisional=True) after chain_exhausted, or when u3 inserts
# a last-resort provisional fill for an uncovered section. Carried as
# data (not re-derived from label/selection_path downstream) so the
# render path / status / zone template can surface "needs adaptation"
# uniformly. Default False keeps non-provisional units byte-identical.
provisional: bool = False
# ─── Heading Tree ────────────────────────────────────────────── # ─── Heading Tree ──────────────────────────────────────────────
@@ -490,6 +499,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
raw_content=s.raw_content, raw_content=s.raw_content,
title=s.title, title=s.title,
v4_candidates=_v4_cands(s.section_id), v4_candidates=_v4_cands(s.section_id),
provisional=getattr(match, "provisional", False),
) )
_apply_capacity_fit(c, capacity_fit_fn) _apply_capacity_fit(c, capacity_fit_fn)
candidates.append(c) candidates.append(c)
@@ -524,6 +534,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
raw_content=merged_raw, raw_content=merged_raw,
title=pid, title=pid,
v4_candidates=_v4_cands(pid), v4_candidates=_v4_cands(pid),
provisional=getattr(parent_match, "provisional", False),
) )
_apply_capacity_fit(c_pm, capacity_fit_fn) _apply_capacity_fit(c_pm, capacity_fit_fn)
candidates.append(c_pm) candidates.append(c_pm)
@@ -624,6 +635,10 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
notes=notes, notes=notes,
# rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관). # rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관).
v4_candidates=_v4_cands(rep_child.section_id), v4_candidates=_v4_cands(rep_child.section_id),
# IMP-30 u2 — rep_match drives frame selection so its provisional
# flag flows here. If a non-rep child match is provisional but the
# rep is not, this unit is not provisional (the rep frame is real).
provisional=getattr(rep_match, "provisional", False),
) )
_apply_capacity_fit(c_inf, capacity_fit_fn) _apply_capacity_fit(c_inf, capacity_fit_fn)
candidates.append(c_inf) candidates.append(c_inf)
@@ -670,7 +685,13 @@ def score_candidate(c: CompositionUnit) -> CompositionUnit:
# ─── Selection ───────────────────────────────────────────────── # ─── Selection ─────────────────────────────────────────────────
def select_composition_units(candidates, allowed_statuses: set[str]) -> list[CompositionUnit]: def select_composition_units(
candidates,
allowed_statuses: set[str],
*,
all_section_ids: Optional[list[str]] = None,
allow_provisional_fill: bool = False,
) -> list[CompositionUnit]:
"""Greedy non-overlapping selection by score, with coverage tiebreak. """Greedy non-overlapping selection by score, with coverage tiebreak.
1. 모든 candidate 점수 매김 1. 모든 candidate 점수 매김
@@ -685,6 +706,27 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음. auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음.
UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X). UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X).
IMP-30 u3 — last-resort provisional fill (opt-in via allow_provisional_fill):
After the normal greedy pass, sections in ``all_section_ids`` that are
still uncovered are filled with the highest-score *provisional*
candidate (``c.provisional == True``) that includes at least one
uncovered section and does not collide with already-covered ones. A
provisional candidate's backing V4Match was synthesized via
``lookup_v4_match_with_fallback(allow_provisional=True)`` (IMP-30 u1)
after chain_exhausted; its ``phase_z_status`` is therefore typically
*outside* ``allowed_statuses`` (extract_matched_zone / fallback_candidate),
which is why it gets filtered out of the normal greedy pass. The fill
preserves first-render invariant for sections whose rank-1~3 are all
restructure/reject. Default ``allow_provisional_fill=False`` keeps
pre-u3 behavior byte-identical (IMP-05 regression guard).
Args:
candidates: full candidate pool from collect_candidates().
allowed_statuses: phase_z_status set considered auto-renderable.
all_section_ids: ordered section id list (only consulted when
allow_provisional_fill=True; required for coverage check).
allow_provisional_fill: opt-in for last-resort provisional fill.
""" """
scored = [score_candidate(c) for c in candidates] scored = [score_candidate(c) for c in candidates]
viable = [ viable = [
@@ -701,6 +743,28 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
selected.append(c) selected.append(c)
covered.update(c.source_section_ids) covered.update(c.source_section_ids)
# IMP-30 u3 — last-resort provisional fill (opt-in, default off).
# Honors first-render invariant by surfacing chain_exhausted sections as
# provisional zones instead of dropping them. Skip reasons on
# non-provisional filtered candidates are preserved (not mutated here).
if allow_provisional_fill and all_section_ids:
uncovered = {sid for sid in all_section_ids if sid not in covered}
if uncovered:
provisional_pool = [
c for c in scored
if c.provisional
and any(sid in uncovered for sid in c.source_section_ids)
]
provisional_pool.sort(
key=lambda c: (c.score, len(c.source_section_ids)),
reverse=True,
)
for c in provisional_pool:
if any(sid in covered for sid in c.source_section_ids):
continue
selected.append(c)
covered.update(c.source_section_ids)
return selected return selected
@@ -740,7 +804,9 @@ def select_layout_preset(units: list[CompositionUnit]) -> Optional[str]:
def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
allowed_statuses: set[str], allowed_statuses: set[str],
capacity_fit_fn=None, capacity_fit_fn=None,
v4_candidates_lookup_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]: v4_candidates_lookup_fn=None,
*,
allow_provisional_fill: bool = False) -> tuple[list[CompositionUnit], Optional[str], dict]:
"""Composition planner v0.2 entry. """Composition planner v0.2 entry.
v0.2 변경 : v0.2 변경 :
@@ -753,6 +819,14 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로. logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로.
runtime 결과 무변. Step 9 application_plan input 위한 schema 확장. runtime 결과 무변. Step 9 application_plan input 위한 schema 확장.
IMP-30 u3 — last-resort provisional fill (opt-in, default off):
``allow_provisional_fill`` is plumbed to select_composition_units().
When True, uncovered sections receive a provisional fill from candidates
whose backing V4Match was synthesized via ``allow_provisional=True``
(IMP-30 u1). ``_candidate_state`` returns ``selected_provisional`` for
those filled units so the debug summary distinguishes greedy selections
from provisional fills. Default False keeps IMP-05 behavior identical.
v0.1 / v0.1.1 동작 (유지) : v0.1 / v0.1.1 동작 (유지) :
- parent_merged_inferred candidate 생성 (parent V4 없어도) - parent_merged_inferred candidate 생성 (parent V4 없어도)
- review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정 - review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정
@@ -771,11 +845,22 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
) )
scored_all = [score_candidate(c) for c in candidates] scored_all = [score_candidate(c) for c in candidates]
units = select_composition_units(candidates, allowed_statuses) units = select_composition_units(
candidates,
allowed_statuses,
all_section_ids=[s.section_id for s in sections] if allow_provisional_fill else None,
allow_provisional_fill=allow_provisional_fill,
)
preset = select_layout_preset(units) preset = select_layout_preset(units)
def _candidate_state(c: CompositionUnit) -> str: def _candidate_state(c: CompositionUnit) -> str:
if c in units: if c in units:
# IMP-30 u3 — provisional-fill units surface as a distinct state so
# downstream debug consumers can tell greedy selection apart from
# last-resort fill. unit.provisional flows from u1 (V4Match
# synthesis) → u2 (CompositionUnit propagation).
if c.provisional:
return "selected_provisional"
return "selected" return "selected"
if c.phase_z_status not in allowed_statuses: if c.phase_z_status not in allowed_statuses:
return "filtered_status" # V4 label → status not auto-renderable return "filtered_status" # V4 label → status not auto-renderable

View File

@@ -176,6 +176,12 @@ class V4Match:
v4_rank: Optional[int] = None v4_rank: Optional[int] = None
selection_path: str = "rank_1" selection_path: str = "rank_1"
fallback_reason: Optional[str] = None 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: def to_phase_z_status(match: V4Match) -> str:
@@ -585,11 +591,26 @@ def lookup_v4_match_with_fallback(
raw_content: Optional[str] = None, raw_content: Optional[str] = None,
max_rank: int = 3, max_rank: int = 3,
alias_keys: Optional[list] = None, alias_keys: Optional[list] = None,
allow_provisional: bool = False,
) -> tuple[Optional[V4Match], dict]: ) -> tuple[Optional[V4Match], dict]:
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable. """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 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. 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) resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None 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["selection_path"] = "chain_exhausted"
trace["fallback_reason"] = first_skip_reason or "no_auto_renderable_rank_1_to_3" 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 return None, trace
@@ -2437,6 +2484,9 @@ def compute_slide_status(sections: list[MdxSection],
- full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered - full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered
- adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고) - adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고)
- content_truncated_count : builder 가 truncate 한 zone 수 (informational) - 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 : overall enum :
PASS — visual OK + full coverage + adapter_needed=0 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 — 일부 section 필터됨, 렌더된 부분만 visual OK
PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다 PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다
(adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용) (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] aligned_ids = [s.section_id for s in sections]
covered = set() covered = set()
@@ -2555,6 +2607,29 @@ def compute_slide_status(sections: list[MdxSection],
_fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0) _fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0)
_selection_paths = _v4_fb_summary.get("selection_paths", []) _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 { return {
"rendered": True, "rendered": True,
"visual_check_passed": visual_passed, "visual_check_passed": visual_passed,
@@ -2574,12 +2649,17 @@ def compute_slide_status(sections: list[MdxSection],
"adapter_needed_units": adapter_needed_units, "adapter_needed_units": adapter_needed_units,
"content_truncated_count": len(content_truncated), "content_truncated_count": len(content_truncated),
"content_truncated_units": 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, "overall": overall,
"note": ( "note": (
"자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. " "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. "
"overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. " "overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. "
"adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. " "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 units = plan_units
if not units or layout_preset is None: if not units or layout_preset is None:
# composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는 # IMP-30 u4 — first-render invariant. The pre-u4 path here was
# status filter 통과 못 함. error.json 기록 후 abort. # `sys.exit(1)` after writing error.json. That violated the invariant
run_dir.mkdir(parents=True, exist_ok=True) # ("final.html + Step 20 slide_status MUST be written for every input
error_data = { # where Step 0~5 succeed") whenever V4 evidence for any section was
"stage": "composition_planner", # restructure/reject (chain_exhausted) or missing (no_v4_section /
"reason": ( # empty_v4_judgments).
"Composition planner v0 selected 0 viable units. " #
f"Either no V4 entries for any section, or all candidates filtered out by " # Recovery has two phases:
f"allowed_statuses={sorted(MVP1_ALLOWED_STATUSES)}." # Phase A — provisional retry (u1 + u3 opt-in). Re-run plan_composition
), # with allow_provisional=True (in lookup_fn) and allow_provisional_fill
"aligned_section_ids": [s.section_id for s in sections], # =True. Synthesizes rank-1 provisional V4Match on chain_exhausted
"composition_debug": comp_debug, # (u1) and last-resort-fills uncovered sections with provisional
} # candidates (u3). Skipped when the CLI override path was used —
err_path = run_dir / "error.json" # re-running plan_composition there would discard the override.
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") # Phase B — terminal empty-shell. If retry still yields zero units
print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ composition_planner", file=sys.stderr) # (true "no rank-1 V4 anywhere" case, or override path with no
print(f" reason : 0 viable units after composition v0", file=sys.stderr) # resolvable assignments), synthesize a single placeholder
print(f" error : {err_path}", file=sys.stderr) # CompositionUnit with frame_template_id="__empty__", layout_preset
sys.exit(1) # ="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)") print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
for u in units: 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. # and is byte-identical to plan_record.position in the normal case.
if plan_record is not None and plan_record.get("position"): if plan_record is not None and plan_record.get("position"):
position = plan_record["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( synth_section = MdxSection(
section_id="+".join(unit.source_section_ids), section_id="+".join(unit.source_section_ids),
section_num=0, section_num=0,
@@ -3470,6 +3727,12 @@ def run_phase_z2_mvp1(
plan_record.get("skipped_reason") if plan_record else None 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({ zones_data.append({
"position": position, "position": position,
"template_id": unit.frame_template_id, "template_id": unit.frame_template_id,
@@ -3478,6 +3741,7 @@ def run_phase_z2_mvp1(
"min_height_px": min_height_px, "min_height_px": min_height_px,
"assignment_source": plan_assignment_source, "assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override, "section_assignment_override": plan_section_override,
"provisional": bool(getattr(unit, "provisional", False)),
}) })
debug_zones.append({ debug_zones.append({
"position": position, "position": position,
@@ -3515,6 +3779,8 @@ def run_phase_z2_mvp1(
"skipped_collided_auto_units": plan_skipped_collided, "skipped_collided_auto_units": plan_skipped_collided,
"uncovered_section_ids": plan_uncovered, "uncovered_section_ids": plan_uncovered,
"skipped_reason": plan_skipped_reason, "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 # IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records

View File

@@ -114,6 +114,43 @@
min-height: 0; min-height: 0;
} }
/* ── IMP-30 u5 : provisional zone marker (first-render invariant) ──
When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted)
the pipeline still renders the rank-1 frame so the first-render invariant
holds, but the zone is tagged `provisional` so the user/AI can adapt later
(IMP-31). Visual contract:
- dashed amber border + striped wash → "needs adaptation" at a glance
- inline badge top-right → text label for non-color-perceiving readers
MDX content is preserved as-is; no shrink, no rewrite. */
.zone--provisional {
outline: 2px dashed #b8860b;
outline-offset: -2px;
background-image: repeating-linear-gradient(
45deg,
rgba(184, 134, 11, 0.04) 0,
rgba(184, 134, 11, 0.04) 8px,
transparent 8px,
transparent 16px
);
}
.zone--provisional .zone__needs-adaptation-badge {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
padding: 2px 6px;
background: #b8860b;
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
border-radius: 2px;
text-transform: uppercase;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
/* ── Frame-family text layout contract (shared, reusable) ── /* ── Frame-family text layout contract (shared, reusable) ──
feedback-1 (mvp1.5b_test7): visible improvement 강화. feedback-1 (mvp1.5b_test7): visible improvement 강화.
Stronger hanging indent + breathing line spacing + visible hierarchy. */ Stronger hanging indent + breathing line spacing + visible hierarchy. */
@@ -264,7 +301,8 @@
<div class="slide-body"> <div class="slide-body">
<div class="layout-{{ layout_preset }}"> <div class="layout-{{ layout_preset }}">
{% for zone in zones %} {% for zone in zones %}
<div class="zone" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}" style="grid-area: {{ zone.position }};"> <div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% endif %}
{{ zone.partial_html | safe }} {{ zone.partial_html | safe }}
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -4,6 +4,10 @@ Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖). The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
Line 565 (IMP-29 frontend zone-level override) must remain untouched. Line 565 (IMP-29 frontend zone-level override) must remain untouched.
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
""" """
from pathlib import Path from pathlib import Path
@@ -16,14 +20,14 @@ def _lines() -> list[str]:
return PIPELINE.read_text(encoding="utf-8").splitlines() return PIPELINE.read_text(encoding="utf-8").splitlines()
def test_line_564_references_imp17_not_imp31(): def test_line_570_references_imp17_not_imp31():
line = _lines()[563] # 1-indexed line 564 line = _lines()[569] # 1-indexed line 570
assert "restructure" in line, f"line 564 anchor drifted: {line!r}" assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
assert "IMP-17" in line, f"line 564 must reference IMP-17 (carve-out): {line!r}" assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
assert "IMP-31" not in line, f"line 564 must not reference non-existent IMP-31: {line!r}" assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
def test_line_565_still_references_imp29(): def test_line_571_still_references_imp29():
line = _lines()[564] # 1-indexed line 565 line = _lines()[570] # 1-indexed line 571
assert "reject" in line, f"line 565 anchor drifted: {line!r}" assert "reject" in line, f"line 571 anchor drifted: {line!r}"
assert "IMP-29" in line, f"line 565 must still reference IMP-29 frontend override: {line!r}" assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"

File diff suppressed because it is too large Load Diff

View File

@@ -380,3 +380,125 @@ def test_step20_slide_status_qualifier_fields_present_with_defensive_defaults():
# Defensive defaults — 0 + [] when summary present but empty # Defensive defaults — 0 + [] when summary present but empty
assert status_c["fallback_selection_count"] == 0 assert status_c["fallback_selection_count"] == 0
assert status_c["selection_paths"] == [] assert status_c["selection_paths"] == []
# ─── Case 9 : IMP-30 u1 — opt-in provisional synthesis on chain_exhausted ───
def test_allow_provisional_default_off_preserves_imp05_behavior(patch_selector_deps):
"""IMP-30 u1 — default ``allow_provisional=False`` keeps chain_exhausted
returning ``(None, trace)`` exactly as IMP-05 specified. Regression guard
for IMP-05 close commit 23d1b25.
"""
v4 = _make_v4([
_j(1, "MOCK_template_restructure_a", "MOCK_frame_001", "restructure"),
_j(2, "MOCK_template_reject_a", "MOCK_frame_002", "reject"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n"
)
assert match is None
assert trace["selection_path"] == "chain_exhausted"
assert trace.get("provisional") is None
assert trace["selected_rank"] is None
assert trace["selected_template_id"] is None
def test_allow_provisional_synthesizes_rank_1_on_chain_exhausted(patch_selector_deps):
"""IMP-30 u1 — opt-in ``allow_provisional=True`` synthesizes a provisional
rank-1 match when the rank-1..3 chain is exhausted (all restructure/reject).
Downstream first-render invariant uses this to render a "needs adaptation"
zone instead of aborting.
"""
v4 = _make_v4([
_j(1, "MOCK_template_restructure_a", "MOCK_frame_001", "restructure"),
_j(2, "MOCK_template_reject_a", "MOCK_frame_002", "reject"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n",
allow_provisional=True,
)
# Provisional rank-1 synthesized from the rank-1 judgment
assert match is not None
assert match.provisional is True
assert match.template_id == "MOCK_template_restructure_a"
assert match.frame_id == "MOCK_frame_001"
assert match.label == "restructure"
assert match.v4_rank == 1
assert match.selection_path == "provisional_rank_1"
# fallback_reason mirrors the chain-exhaust reason
assert match.fallback_reason is not None
assert "phase_z_status_not_allowed" in match.fallback_reason
# Top-level trace mirrors reflect provisional selection
assert trace["selection_path"] == "provisional_rank_1"
assert trace["selected_rank"] == 1
assert trace["selected_template_id"] == "MOCK_template_restructure_a"
assert trace["selected_frame_id"] == "MOCK_frame_001"
assert trace["selected_label"] == "restructure"
assert trace["fallback_used"] is True
assert trace["provisional"] is True
# Original candidate skip reasons are preserved (not rewritten by synthesis)
by_rank = {c["rank"]: c for c in trace["candidates"]}
assert by_rank[1]["decision"] == "skipped"
assert by_rank[1]["reason"] == "phase_z_status_not_allowed:extract_matched_zone"
assert by_rank[2]["decision"] == "skipped"
assert by_rank[2]["reason"] == "phase_z_status_not_allowed:fallback_candidate"
def test_allow_provisional_no_op_when_normal_selection_succeeds(patch_selector_deps):
"""IMP-30 u1 — ``allow_provisional=True`` is a no-op when normal selection
succeeds. The rank-1 (or rank-N fallback) result MUST be non-provisional.
"""
v4 = _make_v4([
_j(1, "MOCK_template_direct_a", "MOCK_frame_001", "use_as_is"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n",
allow_provisional=True,
)
assert match is not None
assert match.provisional is False
assert match.selection_path == "rank_1"
assert trace["selection_path"] == "rank_1"
assert trace.get("provisional") is None
def test_allow_provisional_no_op_when_no_v4_section(patch_selector_deps):
"""IMP-30 u1 — when no V4 section is resolved (no rank-1 judgment to
synthesize from), ``allow_provisional=True`` MUST still return
``(None, trace)``. u3/u4 handle this case with a placeholder zone or
empty-shell terminal slide.
"""
v4 = {"mdx_sections": {}} # no section at all
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n",
allow_provisional=True,
)
assert match is None
assert trace["fallback_reason"] == "no_v4_section"
def test_allow_provisional_no_op_when_empty_judgments(patch_selector_deps):
"""IMP-30 u1 — when the V4 section exists but ``judgments_full32`` is
empty, ``allow_provisional=True`` MUST still return ``(None, trace)``.
No synthetic rank-1 can be fabricated from nothing.
"""
v4 = {"mdx_sections": {"S1": {"judgments_full32": []}}}
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n",
allow_provisional=True,
)
assert match is None
assert trace["fallback_reason"] == "empty_v4_judgments"