Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 9m49s
- 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>
550 lines
22 KiB
Python
550 lines
22 KiB
Python
"""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"
|