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:
@@ -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 밖).
|
||||
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
|
||||
"""
|
||||
from pathlib import Path
|
||||
@@ -16,14 +20,14 @@ def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_564_references_imp17_not_imp31():
|
||||
line = _lines()[563] # 1-indexed line 564
|
||||
assert "restructure" in line, f"line 564 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 564 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}"
|
||||
def test_line_570_references_imp17_not_imp31():
|
||||
line = _lines()[569] # 1-indexed line 570
|
||||
assert "restructure" in line, f"line 570 anchor drifted: {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 570 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_565_still_references_imp29():
|
||||
line = _lines()[564] # 1-indexed line 565
|
||||
assert "reject" in line, f"line 565 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 565 must still reference IMP-29 frontend override: {line!r}"
|
||||
def test_line_571_still_references_imp29():
|
||||
line = _lines()[570] # 1-indexed line 571
|
||||
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
|
||||
|
||||
1557
tests/test_phase_z2_imp30_first_render.py
Normal file
1557
tests/test_phase_z2_imp30_first_render.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -380,3 +380,125 @@ def test_step20_slide_status_qualifier_fields_present_with_defensive_defaults():
|
||||
# Defensive defaults — 0 + [] when summary present but empty
|
||||
assert status_c["fallback_selection_count"] == 0
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user