- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook - u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage) - u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks) - u12: coverage_invariant guard - u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""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
|