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:
@@ -368,6 +368,15 @@ class CompositionUnit:
|
||||
# 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input).
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -490,6 +499,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
raw_content=s.raw_content,
|
||||
title=s.title,
|
||||
v4_candidates=_v4_cands(s.section_id),
|
||||
provisional=getattr(match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(c, capacity_fit_fn)
|
||||
candidates.append(c)
|
||||
@@ -524,6 +534,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
raw_content=merged_raw,
|
||||
title=pid,
|
||||
v4_candidates=_v4_cands(pid),
|
||||
provisional=getattr(parent_match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(c_pm, capacity_fit_fn)
|
||||
candidates.append(c_pm)
|
||||
@@ -624,6 +635,10 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
notes=notes,
|
||||
# rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관).
|
||||
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)
|
||||
candidates.append(c_inf)
|
||||
@@ -670,7 +685,13 @@ def score_candidate(c: CompositionUnit) -> CompositionUnit:
|
||||
|
||||
# ─── 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.
|
||||
|
||||
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 에는 남음.
|
||||
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]
|
||||
viable = [
|
||||
@@ -701,6 +743,28 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
|
||||
selected.append(c)
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
allowed_statuses: set[str],
|
||||
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.
|
||||
|
||||
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 는 그대로.
|
||||
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 동작 (유지) :
|
||||
- parent_merged_inferred candidate 생성 (parent V4 없어도)
|
||||
- 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]
|
||||
|
||||
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)
|
||||
|
||||
def _candidate_state(c: CompositionUnit) -> str:
|
||||
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"
|
||||
if c.phase_z_status not in allowed_statuses:
|
||||
return "filtered_status" # V4 label → status not auto-renderable
|
||||
|
||||
Reference in New Issue
Block a user