Files
C.E.L_Slide_test2/tests/test_imp47b_mixed_reject_fill.py
kyeongmin 1186ad8ae2 feat(#76): IMP-47B reject-as-AI-adaptation activation (u1~u13 backend + tests)
- 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>
2026-05-22 00:19:10 +09:00

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