Files
C.E.L_Slide_test2/tests/test_phase_z2_v4_fallback.py
T
KyeongminandClaude Fable 5 4d7c401779
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 9m49s
test: 통합 스냅샷 재생성 스크립트 추가 + 회귀 테스트 갱신 (#29 후속 작업분)
- tests/integration/scripts/regenerate_snapshots.py: 스냅샷 일괄 재생성 도구
- 통합 스냅샷 9종 갱신, 회귀/유닛 테스트 7건 보강
- ISSUE_DRAFTS_2026-07-02.md: 7/2 전수 검토 이슈 초안 보존

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 11:13:44 +09:00

550 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""IMP-05 V4 fallback selector behavior tests — fully synthetic per Codex #10 E1 + Claude #13.
Lock per round 65~73 + Claude #13 §3 L4' :
- 6 explicit behavior cases (Codex #10 E4)
- fully synthetic MOCK_ IDs (Codex #7 generalization guardrail + Codex #10 E1 naming)
- monkeypatch `get_contract` + `compute_capacity_fit` (Codex #10 E3 — selector has no DI)
- NO real catalog template_id / frame_id
- NO `v4_full32_result.yaml` dependency
Synthetic naming convention :
- `MOCK_` prefix mandatory
- `_a` / `_b` / `_c` suffixes = enumeration only (NOT ordering / priority)
- rank/order expressed by `v4_full_rank` field, NEVER by ID suffix
Real-catalog integrity is verified separately in `tests/test_catalog_invariant.py`.
"""
from __future__ import annotations
from typing import Optional
import pytest
import inspect
from src import phase_z2_pipeline
from src.phase_z2_pipeline import lookup_v4_match_with_fallback
# ─── Synthetic catalog stub ──────────────────────────────────────
# Tests control which synthetic templates are catalog-registered + capacity-OK.
_MOCK_CATALOG: dict[str, object] = {
"MOCK_template_direct_a": object(), # registered
"MOCK_template_direct_b": object(), # registered (used for dedup case)
"MOCK_template_reject_a": object(), # registered (but label=reject)
"MOCK_template_restructure_a": object(), # registered (but label=restructure)
# "MOCK_template_missing_contract" intentionally absent — get_contract returns None.
}
def _mock_get_contract(template_id: str):
"""Synthetic contract lookup — return catalog entry or None."""
return _MOCK_CATALOG.get(template_id)
def _mock_capacity_fit_ok(template_id: str, raw_content: str) -> dict:
"""Synthetic capacity precheck — always OK."""
return {"fit_status": "ok"}
@pytest.fixture
def patch_selector_deps(monkeypatch):
"""Monkeypatch module-level dependencies of `lookup_v4_match_with_fallback`.
Codex #10 E3 + Claude #12 verification — selector has no DI; module-level
`get_contract` / `compute_capacity_fit` must be monkeypatched.
"""
monkeypatch.setattr(
"src.phase_z2_pipeline.get_contract", _mock_get_contract
)
monkeypatch.setattr(
"src.phase_z2_pipeline.compute_capacity_fit", _mock_capacity_fit_ok
)
def _make_v4(judgments: list[dict], section_id: str = "S1") -> dict:
"""Wrap synthetic judgments into V4 input shape."""
return {"mdx_sections": {section_id: {"judgments_full32": judgments}}}
def _j(rank: int, template_id: str, frame_id: str, label: str,
confidence: float = 0.9) -> dict:
"""Synthetic V4 judgment record — shape matches real V4 evidence shape."""
return {
"frame_id": frame_id,
"frame_number": rank,
"template_id": template_id,
"confidence": confidence,
"label": label,
"v4_full_rank": rank,
}
# ─── Case 1 : rank-1 direct eligible retention (no fallback used) ───────────
def test_rank_1_direct_eligible_is_retained(patch_selector_deps):
"""Codex #10 E4 case 1 — rank-1 use_as_is + registered → keep rank-1, no fallback."""
v4 = _make_v4([
_j(1, "MOCK_template_direct_a", "MOCK_frame_001", "use_as_is"),
_j(2, "MOCK_template_direct_b", "MOCK_frame_002", "use_as_is"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n"
)
assert match is not None
assert match.template_id == "MOCK_template_direct_a"
assert match.v4_rank == 1
assert match.selection_path == "rank_1"
assert trace["fallback_used"] is False
assert trace["selection_path"] == "rank_1"
assert trace["selected_rank"] == 1
# ─── Case 2 : rank-1 non-direct → rank-2/3 direct selected (fallback used) ───
def test_rank_1_non_direct_promotes_direct_candidate(patch_selector_deps):
"""Codex #10 E4 case 2 — IMP-39(#68) label-priority sort 반영 갱신.
구 계약: rank 순 평가 → reject 스킵 후 rank_2_fallback 승격.
현 계약: 평가 전 label-priority 정렬 → use_as_is 가 정렬 1위로 즉시
선택 (fallback 아님). 선택 결과물(direct_a)은 동일 — 표기 의미만 변경.
"""
v4 = _make_v4([
_j(1, "MOCK_template_reject_a", "MOCK_frame_001", "reject"),
_j(2, "MOCK_template_direct_a", "MOCK_frame_002", "use_as_is"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n"
)
assert match is not None
assert match.template_id == "MOCK_template_direct_a"
assert match.selection_path == "rank_1"
assert trace["fallback_used"] is False
assert trace["selected_rank"] == 1
# ─── Case 3 : duplicate template_id is skipped / deduped ────────────────────
def test_duplicate_template_id_skipped_under_label_priority_sort(patch_selector_deps):
"""Codex #14 dedup — IMP-39(#68) label-priority sort 반영 갱신.
dedup 규칙 자체("첫 occurrence 가 template_id 를 claim, 이후 중복은
decision 무관 skip")는 존속하나, '첫 occurrence' 의 기준이 원 rank 순
→ 정렬 순으로 바뀌었다. 구 예시(reject 가 claim 해 use_as_is 중복이
져야 함)는 정렬 하에서 성립 불가 — use_as_is 가 정렬 선두로 오므로
같은 라벨의 중복 쌍으로 dedup 을 검증한다.
Fixture: A(미등록) use_as_is ×2 + B(등록) use_as_is.
정렬 1: A use_as_is → skipped_no_contract (첫 occurrence, A claim)
정렬 2: A use_as_is → duplicate_template_id (audit 필드 보존)
정렬 3: B use_as_is → selected
"""
v4 = _make_v4([
_j(1, "MOCK_template_dup_unreg", "MOCK_frame_dup_001", "use_as_is"),
_j(2, "MOCK_template_dup_unreg", "MOCK_frame_dup_001", "use_as_is"),
_j(3, "MOCK_template_direct_a", "MOCK_frame_003", "use_as_is"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n"
)
assert match is not None
assert match.template_id == "MOCK_template_direct_a"
assert trace["fallback_used"] is True
candidates = trace["candidates"]
by_rank = {c["rank"]: c for c in candidates}
assert set(by_rank.keys()) == {1, 2, 3}
# 정렬 1위: 첫 occurrence — no_contract skip 이면서 template claim
assert by_rank[1]["decision"] == "skipped"
assert by_rank[1]["reason"] == "skipped_no_contract"
assert by_rank[1]["template_id"] == "MOCK_template_dup_unreg"
# 정렬 2위: 중복 — decision 무관 skip + audit 필드 보존 (Codex #14 존속부)
assert by_rank[2]["decision"] == "skipped"
assert by_rank[2]["reason"] == "duplicate_template_id"
assert by_rank[2]["template_id"] == "MOCK_template_dup_unreg"
assert by_rank[2]["frame_id"] == "MOCK_frame_dup_001"
# 정렬 3위: distinct 등록 template — selected
assert by_rank[3]["decision"] == "selected"
assert by_rank[3]["template_id"] == "MOCK_template_direct_a"
# ─── Case 4 : missing contract → skipped / chain-exhausted trace ────────────
def test_missing_contract_yields_chain_exhausted_trace(patch_selector_deps):
"""Codex #10 E4 case 4 — all ranks missing catalog contract → chain exhausted."""
v4 = _make_v4([
_j(1, "MOCK_template_missing_contract", "MOCK_frame_001", "use_as_is"),
])
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"
candidates = trace["candidates"]
assert any(c.get("reason") == "skipped_no_contract" for c in candidates)
# ─── Case 5 : restructure / reject preserved as non-direct candidate evidence
def test_restructure_reject_preserved_as_non_direct_evidence(patch_selector_deps):
"""Codex #10 E4 case 5 + Claude #11 L5 — IMP-39(#68) sort 반영 갱신.
가시성 계약("restructure/reject 를 침묵 폐기하지 않는다")의 surface 가
이동했다: IMP-39 정렬로 use_as_is 가 즉시 선택되면 selector trace 는
선택 시점까지의 후보만 기록하므로, 비-direct 후보의 보존은 Emergency
P2 의 lookup_v4_candidates (전원 보존 + candidate_status) 가 담당한다.
"""
from src.phase_z2_pipeline import lookup_v4_candidates
v4 = _make_v4([
_j(1, "MOCK_template_reject_a", "MOCK_frame_001", "reject"),
_j(2, "MOCK_template_restructure_a", "MOCK_frame_002", "restructure"),
_j(3, "MOCK_template_direct_a", "MOCK_frame_003", "use_as_is"),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", raw_content="- a\n- b\n- c\n"
)
assert match is not None
assert match.template_id == "MOCK_template_direct_a"
# 정렬 1위 use_as_is 즉시 선택 — trace 는 선택 entry 를 포함
assert any(c["decision"] == "selected" for c in trace["candidates"])
# 보존 surface: lookup_v4_candidates 가 3 후보 전원 + status 를 노출
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
by_tid = {c.template_id: c for c in candidates}
assert set(by_tid) == {
"MOCK_template_reject_a",
"MOCK_template_restructure_a",
"MOCK_template_direct_a",
}
assert by_tid["MOCK_template_reject_a"].candidate_status == "ai_adaptation_required"
assert by_tid["MOCK_template_direct_a"].candidate_status == "auto_renderable"
# restructure 는 capacity 판정에 따라 auto/ai — 어느 쪽이든 보존이 계약
assert by_tid["MOCK_template_restructure_a"].candidate_status in {
"auto_renderable", "ai_adaptation_required",
}
# ─── Case 6 : additive fields do not regress existing trace shape ───────────
def test_existing_trace_shape_does_not_regress(patch_selector_deps):
"""Codex #10 E4 case 6 + Claude #11 L9 — additive L2/L3 fields must not break
existing trace consumers. Existing fields (`label`, `fallback_used`,
`selection_path`, `selected_rank`, etc.) must remain present and unchanged.
"""
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"
)
# Existing top-level trace fields preserved
expected_top_fields = {
"section_id", "max_rank", "selection_path", "selected_rank",
"selected_template_id", "selected_frame_id", "selected_label",
"fallback_used", "fallback_reason", "candidates",
}
assert expected_top_fields.issubset(trace.keys())
# Existing candidate fields preserved
candidate = trace["candidates"][0]
expected_candidate_fields = {
"rank", "template_id", "frame_id", "frame_number", "confidence",
"label", "phase_z_status", "catalog_registered", "decision", "reason",
}
assert expected_candidate_fields.issubset(candidate.keys())
# New L2 additive fields present (v4_label / filtered_for_direct_execution / route_hint)
assert candidate["v4_label"] == candidate["label"] # alias of label
assert "filtered_for_direct_execution" in candidate
assert "route_hint" in candidate
# rank-1 use_as_is path — no fallback used
assert trace["fallback_used"] is False
assert trace["selection_path"] == "rank_1"
# ─── Case 7 : Step 9 helper-call shape test (IMP-32 u5 — replaces source guard) ───
def test_build_application_plan_unit_emits_candidate_evidence_and_alias():
"""IMP-32 u5 — direct helper-call shape test for Step 9 evidence fields.
Replaces the IMP-05 Case 7 `inspect.getsource(phase_z2_pipeline)` literal
guard (introduced at commit `23d1b25` while Step 9 unit assembly was
inline) with a direct call to `_build_application_plan_unit`, the helper
extracted in IMP-32 u3. Verification axes preserved:
- candidate_evidence list identity sourced from `selection_trace["candidates"]`
- fallback_chain compat-alias identity (same list object as candidate_evidence)
- key order: candidate_evidence before fallback_chain
- compat-alias comment preserved on the helper's fallback_chain line
"""
from types import SimpleNamespace
from src.phase_z2_pipeline import _build_application_plan_unit
candidates_list = [
{"rank": 1, "template_id": "MOCK_template_direct_a", "label": "use_as_is"},
]
selection_trace = {"candidates": candidates_list}
# Synthetic CompositionUnit-shape duck-typed input — matches V4Match attrs
# used inside the helper (template_id / frame_id / frame_number / v4_rank /
# confidence / label per src/phase_z2_pipeline.py V4Match dataclass).
v4_candidate = SimpleNamespace(
template_id="MOCK_template_direct_a",
frame_id="MOCK_frame_001",
frame_number=1,
v4_rank=1,
confidence=0.9,
label="use_as_is",
)
unit = SimpleNamespace(
source_section_ids=["S1"],
v4_candidates=[v4_candidate],
v4_rank=1,
selection_path="rank_1",
fallback_reason=None,
frame_template_id="MOCK_template_direct_a",
)
result = _build_application_plan_unit(
unit=unit,
zone_plan={},
selection_trace=selection_trace,
plan_record=None,
v4_all_for_unit=[],
layout_preset="Type A",
layout_candidates_list=[],
)
# IMP-05 L2 — candidate_evidence is the primary field, identity-bound to
# selection_trace["candidates"] (not a copy).
assert "candidate_evidence" in result
assert result["candidate_evidence"] is candidates_list
# compat alias — fallback_chain references the SAME list object as
# candidate_evidence (verified by `is` identity, not equality).
assert "fallback_chain" in result
assert result["fallback_chain"] is candidates_list
# key order — candidate_evidence MUST precede fallback_chain in the
# returned dict to preserve documented L2 ordering.
keys = list(result.keys())
assert keys.index("candidate_evidence") < keys.index("fallback_chain")
# compat-alias comment preserved on the helper's fallback_chain line.
helper_source = inspect.getsource(_build_application_plan_unit)
assert "compat alias; prefer candidate_evidence" in helper_source
# ─── Case 8 : Step 20 slide-status qualifier fields presence + defensive default
def test_step20_slide_status_qualifier_fields_present_with_defensive_defaults():
"""Codex #10 D4 + Codex #17 idea F + Claude #21 idea J — Step 20 slide-status
must expose `fallback_selection_count` and `selection_paths[]` derived from
comp_debug["v4_fallback_summary"] with defensive defaults (0, []) when the
summary is missing or empty. Top-level `overall` enum must remain stable.
"""
from src.phase_z2_pipeline import compute_slide_status
from src.phase_z2_pipeline import MdxSection
# Case A — comp_debug with populated v4_fallback_summary
sections_empty: list[MdxSection] = []
units_empty: list = []
overflow_pass = {"passed": True, "fail_reasons": []}
comp_debug_with = {
"v4_fallback_summary": {
"fallback_used_count": 1,
"fallback_selection_count": 1,
"selection_paths": [
{"section_id": "S1", "selection_path": "rank_2_fallback",
"selected_rank": 2, "selected_template_id": "MOCK_T",
"fallback_trigger": "phase_z_status_not_allowed:fallback_candidate"},
],
},
"candidates_summary": [],
}
status_a = compute_slide_status(
sections_empty, units_empty, comp_debug_with, overflow_pass,
adapter_needed_units=None, debug_zones=None,
)
# Step 20 qualifier fields present near existing fallback fields (Codex F ordering)
assert "fallback_selection_count" in status_a
assert "selection_paths" in status_a
assert status_a["fallback_selection_count"] == 1
assert len(status_a["selection_paths"]) == 1
assert status_a["selection_paths"][0]["section_id"] == "S1"
# Existing fields preserved (no regression)
assert "fallback_used" in status_a
assert "fallback_selections" in status_a
assert "overall" in status_a
# Case B — comp_debug missing v4_fallback_summary (defensive defaults)
comp_debug_empty = {"candidates_summary": []}
status_b = compute_slide_status(
sections_empty, units_empty, comp_debug_empty, overflow_pass,
adapter_needed_units=None, debug_zones=None,
)
# Defensive defaults — 0 + [] when summary missing
assert status_b["fallback_selection_count"] == 0
assert status_b["selection_paths"] == []
# Top-level overall enum still stable
assert "overall" in status_b
# Case C — comp_debug with empty v4_fallback_summary dict
comp_debug_empty_summary = {"v4_fallback_summary": {}, "candidates_summary": []}
status_c = compute_slide_status(
sections_empty, units_empty, comp_debug_empty_summary, overflow_pass,
adapter_needed_units=None, debug_zones=None,
)
# 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"