"""IMP-47B u12 — Initial plan_composition allow_provisional_fill for mixed direct+reject. Scope (this slice): The u12 glue inserted in ``run_phase_z2_mvp1`` (src/phase_z2_pipeline.py, right after the initial plan_composition + telemetry build, before the Step 7-A layout override block) detects the mixed direct+reject case (initial plan_composition returns a viable layout but some sections remain uncovered) and re-runs plan_composition with: * a lookup_fn that passes ``allow_provisional=True`` (so chain_exhausted sections synthesize a provisional rank-1 V4Match), and * ``allow_provisional_fill=True`` (so uncovered sections receive a last-resort provisional candidate fill in select_composition_units). This admits the mixed direct+reject case to the AI repair path (IMP-47B u4/u5) on first render — the reject section becomes a provisional unit (``provisional=True`` + ``label="reject"``) which Step 12's reject route gather (u4) routes to AI fallback. Gate predicates (mirrored from src/phase_z2_pipeline.py u12 block): * units non-empty (all-reject case is handled by IMP-30 u4 retry below) * layout_preset is not None * not override_section_assignments (operator override bypasses the gate) * at least one section_id is uncovered after initial pass Guardrails proven by these tests: * MDX 원문 100% 보존 — every section_id covered after mixed admission (no silent drop). * 자동 frame swap 금지 — mixed admission only re-runs plan_composition with provisional flags; rank-1 reject judgment is preserved as the provisional V4Match (no template_id swap to a different rank). * Normal-path AI=0 — the mixed admission still emits the reject label; AI activation is gated separately in router (config.py:19 default OFF). * All-direct slides are a no-op — gate skips when no uncovered sections. This test file exercises ``plan_composition`` directly with synthetic stub V4 matches + a stub lookup_fn that mirrors the u12 retry seam. Stub naming follows the IMP-30 u3 convention (MOCK_ prefix mandatory, no real catalog template_id / frame_id leakage). """ from __future__ import annotations from dataclasses import dataclass from typing import Optional from src.phase_z2_composition import plan_composition # ─── Synthetic V4Match duck-type (mirrors IMP-30 _StubV4Match) ─────────── @dataclass class _StubV4Match: template_id: str frame_id: str frame_number: int confidence: float label: str v4_rank: Optional[int] = None selection_path: str = "rank_1" fallback_reason: Optional[str] = None provisional: bool = False @dataclass class _StubSection: section_id: str title: str = "" raw_content: str = "" _LABEL_TO_STATUS = { "use_as_is": "matched_zone", "light_edit": "adapt_matched_zone", "restructure": "extract_matched_zone", "reject": "fallback_candidate", } _ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"} def _make_normal_lookup(matches_by_section: dict[str, _StubV4Match]): """Lookup_fn that returns the synthetic rank-1 match (no provisional path). Mirrors the pipeline initial ``lookup_fn`` at src/phase_z2_pipeline.py:3456-3465 (no ``allow_provisional`` kwarg). """ def _fn(section_id: str): return matches_by_section.get(section_id) return _fn def _make_provisional_lookup(matches_by_section: dict[str, _StubV4Match]): """Lookup_fn that flags reject rank-1 matches provisional. Mirrors the pipeline u12 retry ``_lookup_fn_mixed_admission`` at the inserted block — for reject judgments, returns a provisional=True rank-1 V4Match-shaped stub so plan_composition's last-resort fill pool can see it (provisional candidates are otherwise filtered out of the normal greedy pass). """ def _fn(section_id: str): m = matches_by_section.get(section_id) if m is not None and m.label == "reject": # Synthesize the provisional shape that # lookup_v4_match_with_fallback returns when allow_provisional # is True: provisional=True + selection_path="provisional_rank_1". return _StubV4Match( template_id=m.template_id, frame_id=m.frame_id, frame_number=m.frame_number, confidence=m.confidence, label=m.label, v4_rank=1, selection_path="provisional_rank_1", provisional=True, ) return m return _fn def _make_candidates_lookup_empty(): def _fn(section_id: str): return [] return _fn # ─── u12 case 1 : mechanic — mixed admission via provisional lookup + fill ──── def test_u12_mechanic_mixed_admission_covers_reject_section_via_provisional_fill(): """Positive proof. Mixed direct+reject (S1=use_as_is, S2=reject). Without u12 (initial path: normal lookup + allow_provisional_fill=False), plan_composition returns only the S1 unit and S2 is silently dropped. With u12 (retry: provisional lookup + allow_provisional_fill=True), plan_composition returns both units; S2 is a provisional unit with label="reject" — ready to be picked up by Step 12's reject route gather (IMP-47B u4). """ sections = [_StubSection("S1"), _StubSection("S2")] matches = { "S1": _StubV4Match( template_id="MOCK_template_direct_a", frame_id="MOCK_frame_001", frame_number=1, confidence=0.92, label="use_as_is", v4_rank=1, ), "S2": _StubV4Match( template_id="MOCK_template_reject_a", frame_id="MOCK_frame_002", frame_number=2, confidence=0.30, label="reject", v4_rank=1, ), } # Pre-u12 baseline — normal lookup, no provisional fill. units_pre, preset_pre, _ = plan_composition( sections, _make_normal_lookup(matches), _LABEL_TO_STATUS, _ALLOWED_STATUSES, v4_candidates_lookup_fn=_make_candidates_lookup_empty(), ) covered_pre = {sid for u in units_pre for sid in u.source_section_ids} assert "S1" in covered_pre, "S1 (use_as_is) must cover pre-u12" assert "S2" not in covered_pre, ( "Pre-u12 baseline regression: reject S2 should be uncovered (no provisional fill)" ) # u12 mixed-admission retry — provisional lookup + allow_provisional_fill=True. units_post, preset_post, _ = plan_composition( sections, _make_provisional_lookup(matches), _LABEL_TO_STATUS, _ALLOWED_STATUSES, v4_candidates_lookup_fn=_make_candidates_lookup_empty(), allow_provisional_fill=True, ) covered_post = {sid for u in units_post for sid in u.source_section_ids} assert covered_post == {"S1", "S2"}, ( "u12 mixed admission must cover every section (no text loss)" ) assert preset_post is not None # The S2 unit must be marked provisional so the reject route gather # (src/phase_z2_ai_fallback/step12.py:133-136) admits it. s2_unit = next(u for u in units_post if "S2" in u.source_section_ids) assert s2_unit.provisional is True, ( "Reject S2 unit must be provisional so Step 12 reject route admits it" ) assert s2_unit.label == "reject" # Frame template id is preserved — no auto frame swap. assert s2_unit.frame_template_id == "MOCK_template_reject_a" # ─── u12 case 2 : gate — all-direct slides are a no-op ────────────────────── def test_u12_gate_all_direct_yields_no_uncovered_sections(): """No-op proof. When every section is auto-renderable (use_as_is or light_edit), the initial plan_composition covers everything — the u12 mixed-admission gate's ``_u12_uncovered_ids`` list is empty and the retry is skipped. """ sections = [_StubSection("S1"), _StubSection("S2")] matches = { "S1": _StubV4Match( template_id="MOCK_template_direct_a", frame_id="MOCK_frame_001", frame_number=1, confidence=0.92, label="use_as_is", v4_rank=1, ), "S2": _StubV4Match( template_id="MOCK_template_direct_b", frame_id="MOCK_frame_002", frame_number=2, confidence=0.81, label="light_edit", v4_rank=1, ), } units, preset, _ = plan_composition( sections, _make_normal_lookup(matches), _LABEL_TO_STATUS, _ALLOWED_STATUSES, v4_candidates_lookup_fn=_make_candidates_lookup_empty(), ) covered = {sid for u in units for sid in u.source_section_ids} assert covered == {"S1", "S2"}, "All-direct must cover every section pre-u12" # Predicate from src/phase_z2_pipeline.py u12 block: uncovered = [s.section_id for s in sections if s.section_id not in covered] assert uncovered == [], ( "u12 gate must classify all-direct as no-op (uncovered list empty)" ) assert preset is not None # ─── u12 case 3 : gate — initial empty units bypass u12 (IMP-30 retry owns it) ── def test_u12_gate_skips_when_initial_units_empty(): """All-reject case is owned by IMP-30 u4 retry (units=[] guard at src/phase_z2_pipeline.py:3646). u12 mixed-admission must NOT compete with that path; the gate ``units and layout_preset is not None`` short-circuits when the initial plan_composition returns nothing. """ sections = [_StubSection("S1")] matches = { "S1": _StubV4Match( template_id="MOCK_template_reject_a", frame_id="MOCK_frame_002", frame_number=2, confidence=0.30, label="reject", v4_rank=1, ), } units, preset, _ = plan_composition( sections, _make_normal_lookup(matches), _LABEL_TO_STATUS, _ALLOWED_STATUSES, v4_candidates_lookup_fn=_make_candidates_lookup_empty(), ) # All-reject initial pass: no auto-renderable units, no layout preset. assert units == [] and preset is None # u12 gate predicate would short-circuit on `units` truthiness: gate_active = bool(units) and preset is not None assert gate_active is False, ( "u12 mixed-admission gate must skip the all-reject case (IMP-30 u4 owns it)" ) # ─── u12 case 4 : code-path anchor — pipeline source contains u12 marker ──── def test_u12_pipeline_source_contains_mixed_admission_marker(): """Anchor test. Ensures the inserted u12 block in src/phase_z2_pipeline.py is reachable (not silently removed by a future refactor). Asserts on the marker comment + ``imp47b_u12_mixed_admission`` debug key + ``allow_provisional_fill=True`` invocation co-located in the file. Cheap structural guard — does not run the heavy pipeline. """ from pathlib import Path src_path = Path(__file__).resolve().parent.parent / "src" / "phase_z2_pipeline.py" text = src_path.read_text(encoding="utf-8") assert "IMP-47B u12 — mixed direct+reject first-render admission" in text, ( "u12 marker comment missing from pipeline — block may have been removed" ) assert "imp47b_u12_mixed_admission" in text, ( "u12 comp_debug telemetry key missing" ) # The mixed-admission retry must pass allow_provisional_fill=True. # Anchor against the helper function name + the kwarg co-occurrence. assert "_lookup_fn_mixed_admission" in text assert "allow_provisional_fill=True" in text