wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷
- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규 - Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가 - templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신 - tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분) - tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가 - docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서 - scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸 - .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외 미완성 작업의 보존용 스냅샷 커밋 (2026-07-02) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""IMP-95 u1 — PHASE_Z_B4_V4_EVIDENCE flag reader unit tests.
|
||||
|
||||
Stage 2 plan (u1): locks the env flag reader helper and trace key
|
||||
constants (default OFF). u2 wires the selector under
|
||||
``accepted_content_types`` constraint; u3 extends ``plan_placement`` to
|
||||
emit additive trace fields; u4 exposes them in placement_trace at
|
||||
Step 11; u5 adds the gatekeeper short-circuit; u6 layers the
|
||||
``partial_exists`` precheck.
|
||||
|
||||
Truthy contract mirrors ``_b4_mapper_source_enabled`` at
|
||||
``src/phase_z2_pipeline.py:675-688``: case-insensitive + leading/trailing
|
||||
whitespace stripped; truthy set = {'1', 'true', 'yes'}. Everything else
|
||||
(including '0', '', 'no', 'false', missing env var) is OFF.
|
||||
|
||||
Flag independence (u1 scope-lock + Stage 2 A8): the new flag must NOT be
|
||||
flipped by ``PHASE_Z_B4_MAPPER_SOURCE`` (IMP-89 89-a) or
|
||||
``PHASE_Z_B4_GATEKEEPER``. Default OFF preserves final.html SHA parity
|
||||
(Stage 2 A1 + A10).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_placement_planner import (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
PHASE_Z_B4_V4_EVIDENCE_ENV,
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
_b4_v4_evidence_enabled,
|
||||
)
|
||||
|
||||
FLAG = "PHASE_Z_B4_V4_EVIDENCE"
|
||||
|
||||
|
||||
def test_env_constant_matches_flag_name() -> None:
|
||||
"""PHASE_Z_B4_V4_EVIDENCE_ENV is the literal env var the reader inspects."""
|
||||
assert PHASE_Z_B4_V4_EVIDENCE_ENV == FLAG
|
||||
|
||||
|
||||
def test_default_off_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_v4_evidence_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE", "Yes", " true ", " 1\t"])
|
||||
def test_truthy_values_enable_flag(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
monkeypatch.setenv(FLAG, value)
|
||||
assert _b4_v4_evidence_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "no", "false", "off", "2", "on", "y"])
|
||||
def test_non_truthy_values_keep_flag_off(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
monkeypatch.setenv(FLAG, value)
|
||||
assert _b4_v4_evidence_enabled() is False
|
||||
|
||||
|
||||
def test_flag_distinct_from_mapper_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PHASE_Z_B4_MAPPER_SOURCE ON must not flip the V4 evidence flag.
|
||||
|
||||
Locks Stage 2 A8 (flag independence): IMP-89 89-a mapper-source flag
|
||||
governs slot_payload source-of-truth; IMP-95 V4 evidence flag governs
|
||||
_select_frame ranking. They must be independently toggleable.
|
||||
"""
|
||||
monkeypatch.setenv("PHASE_Z_B4_MAPPER_SOURCE", "1")
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_v4_evidence_enabled() is False
|
||||
|
||||
|
||||
def test_flag_distinct_from_gatekeeper(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PHASE_Z_B4_GATEKEEPER ON must not flip the V4 evidence flag.
|
||||
|
||||
Locks Stage 2 A8 (flag independence): gatekeeper retains its
|
||||
mismatch render-skip semantics; V4 evidence flag is orthogonal.
|
||||
"""
|
||||
monkeypatch.setenv("PHASE_Z_B4_GATEKEEPER", "1")
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_v4_evidence_enabled() is False
|
||||
|
||||
|
||||
def test_trace_key_constants_are_unique_strings() -> None:
|
||||
"""u1 locks trace key NAMES so u3~u6 cannot drift them silently."""
|
||||
keys = {
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP,
|
||||
}
|
||||
assert len(keys) == 6
|
||||
assert TRACE_KEY_FRAME_SELECTION_BASIS == "frame_selection_basis"
|
||||
assert TRACE_KEY_V4_EVIDENCE_CONSUMED == "v4_evidence_consumed"
|
||||
assert TRACE_KEY_B4_V0_FALLBACK_REASON == "b4_v0_fallback_reason"
|
||||
assert TRACE_KEY_V4_RANK_USED == "v4_rank_used"
|
||||
assert TRACE_KEY_V4_B4_FRAME_MATCH == "v4_b4_frame_match"
|
||||
assert TRACE_KEY_B4_PARTIAL_MISSING_SKIP == "b4_partial_missing_skip"
|
||||
|
||||
|
||||
def test_frame_selection_basis_enum_values() -> None:
|
||||
"""u1 locks enum values so u2 selector tests can rely on them."""
|
||||
assert FRAME_SELECTION_BASIS_DECLARATION_ORDER == "declaration_order"
|
||||
assert FRAME_SELECTION_BASIS_V4_RANKED == "v4_ranked"
|
||||
assert FRAME_SELECTION_BASIS_DECLARATION_ORDER != FRAME_SELECTION_BASIS_V4_RANKED
|
||||
@@ -0,0 +1,436 @@
|
||||
"""IMP-95 u5 — Gatekeeper short-circuit (``v4_short_circuit``) telemetry.
|
||||
|
||||
Stage 2 plan (u5 / axes A7 + A8): the Step 11 ``placement_trace`` assembly
|
||||
must carry a derived ``v4_short_circuit`` boolean that is True iff V4
|
||||
evidence was consumed (``PHASE_Z_B4_V4_EVIDENCE`` flag ON + a V4 rank
|
||||
resolved during selection) AND the resulting B4 selection matches the
|
||||
mapper's V4 rank-1 ``template_id``. The gatekeeper logic itself (the
|
||||
``PHASE_Z_B4_GATEKEEPER`` branch that appends to ``adapter_needed_units``)
|
||||
must remain unchanged — gatekeeper triggers purely on ``not matches_mapper``
|
||||
regardless of V4 path. PHASE_Z_B4_V4_EVIDENCE and PHASE_Z_B4_GATEKEEPER
|
||||
stay independent flags.
|
||||
|
||||
Coverage axes :
|
||||
* Derived-field contract — ``v4_short_circuit = v4_evidence_consumed AND
|
||||
matches_mapper`` (Stage 2 A7).
|
||||
* Flag-OFF parity — ``v4_short_circuit=False`` on every code path when
|
||||
``PHASE_Z_B4_V4_EVIDENCE`` is OFF, regardless of mapper match (Stage 2
|
||||
A1 + A10 — final.html SHA parity precondition).
|
||||
* Flag-ON happy path — V4 rank-1 produces a mapper-matching selection,
|
||||
``v4_short_circuit=True``.
|
||||
* Flag-ON deliberate-mismatch — V4 evidence resolved but mapper template
|
||||
differs from the V4-selected contract; ``v4_short_circuit=False`` and
|
||||
the gatekeeper still triggers on ``not matches_mapper`` (Stage 2 A8 —
|
||||
flag independence; V4 evidence consumption alone does NOT suppress the
|
||||
gatekeeper).
|
||||
* Flag-ON empty evidence — V4 evidence empty / None falls back to
|
||||
declaration order; ``v4_short_circuit=False`` because evidence was not
|
||||
consumed even when the fallback selection happens to match the mapper.
|
||||
* Adapter-record contract — when the gatekeeper triggers (mismatch),
|
||||
``adapter_record`` carries ``v4_short_circuit`` (always False on this
|
||||
branch) so downstream consumers can read the V4-path outcome on the
|
||||
rejected branch as well.
|
||||
* Flag-independence structural assertion — the gatekeeper trigger
|
||||
condition in ``src/phase_z2_pipeline.py`` references neither
|
||||
``PHASE_Z_B4_V4_EVIDENCE`` nor ``v4_short_circuit``; only the
|
||||
placement_trace assembly + adapter_record do.
|
||||
|
||||
Out of scope for u5 (Stage 2 scope-lock) :
|
||||
* Partial-exists precheck (u6).
|
||||
* Full-pipeline SHA / adapter_needed / trace-field regressions
|
||||
(u8 / u9 / u10).
|
||||
* Status-board markers (u11).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_content_extractor import ContentObject
|
||||
from src.phase_z2_placement_planner import (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
PHASE_Z_B4_V4_EVIDENCE_ENV,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
plan_placement,
|
||||
)
|
||||
|
||||
PIPELINE_PATH = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "src"
|
||||
/ "phase_z2_pipeline.py"
|
||||
)
|
||||
|
||||
|
||||
# ─── Duck-typed CompositionUnit V4 candidate (composition.py:678-684) ─
|
||||
|
||||
|
||||
@dataclass
|
||||
class _V4Cand:
|
||||
template_id: str
|
||||
frame_id: str = ""
|
||||
frame_number: int = 0
|
||||
confidence: float = 0.0
|
||||
label: str = "use_as_is"
|
||||
|
||||
|
||||
def _text_obj() -> ContentObject:
|
||||
return ContentObject(
|
||||
id="u5.text-1",
|
||||
type="text_block",
|
||||
role="summary",
|
||||
raw_payload="* bullet",
|
||||
size_estimate={"line_count": 3},
|
||||
type_specific={
|
||||
"format": "bullet_list",
|
||||
"bullet_count": 1,
|
||||
"max_indent_level": 0,
|
||||
"has_emphasis": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _contract(template_id: str, frame_id: str, accepted: list[str]) -> dict:
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"frame_id": frame_id,
|
||||
"accepted_content_types": accepted,
|
||||
"sub_zones": [],
|
||||
}
|
||||
|
||||
|
||||
def _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id: str
|
||||
) -> dict:
|
||||
"""Replay the exact ``placement_trace`` assembly + the IMP-95 u5
|
||||
``v4_short_circuit`` derivation from ``src/phase_z2_pipeline.py``
|
||||
(Step 11). Binding mirror of the code under test — any drift in the
|
||||
short-circuit formula MUST be reflected here so the test fails loudly.
|
||||
"""
|
||||
matches_mapper = plan.selected_template_id == mapper_frame_template_id
|
||||
match_note = None
|
||||
if not matches_mapper:
|
||||
if plan.selected_template_id is None:
|
||||
match_note = "no_frame_covers_content_types"
|
||||
else:
|
||||
match_note = (
|
||||
f"B4 selected '{plan.selected_template_id}' but "
|
||||
f"mapper uses '{mapper_frame_template_id}' (composition V4 rank-1)"
|
||||
)
|
||||
selection_trace = getattr(plan, "selection_trace", None) or {}
|
||||
placement_trace = {
|
||||
**asdict(plan),
|
||||
"mapper_frame_template_id": mapper_frame_template_id,
|
||||
"frame_selection_matches_mapper": matches_mapper,
|
||||
"frame_selection_match_note": match_note,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS: selection_trace.get(
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS
|
||||
),
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED: selection_trace.get(
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED, False
|
||||
),
|
||||
TRACE_KEY_V4_RANK_USED: selection_trace.get(TRACE_KEY_V4_RANK_USED),
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH: selection_trace.get(
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH, False
|
||||
),
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON: selection_trace.get(
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON
|
||||
),
|
||||
}
|
||||
# IMP-95 u5 — derived field replay (binding mirror).
|
||||
placement_trace["v4_short_circuit"] = bool(
|
||||
placement_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] and matches_mapper
|
||||
)
|
||||
return placement_trace, matches_mapper
|
||||
|
||||
|
||||
# ─── Structural — pipeline.py wiring assertions ───────────────────
|
||||
|
||||
|
||||
def test_pipeline_emits_v4_short_circuit_field() -> None:
|
||||
"""``src/phase_z2_pipeline.py`` Step 11 must add ``v4_short_circuit``
|
||||
to the assembled ``placement_trace`` (Stage 2 A7)."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
assert 'placement_trace["v4_short_circuit"]' in source, (
|
||||
"u5 wiring missing — placement_trace must expose v4_short_circuit "
|
||||
"(derived from TRACE_KEY_V4_EVIDENCE_CONSUMED + matches_mapper)."
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_v4_short_circuit_formula_is_consumed_and_matches() -> None:
|
||||
"""The derivation must combine ``TRACE_KEY_V4_EVIDENCE_CONSUMED`` with
|
||||
``matches_mapper`` (Stage 2 A7). A pure structural assertion guards
|
||||
against drift to a looser formula (e.g. only checking the env flag,
|
||||
which would mis-record fallback paths as ``v4_short_circuit=True``)."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
# Match the multi-line ``bool(... and ...)`` block tolerant of whitespace.
|
||||
pattern = re.compile(
|
||||
r"v4_short_circuit\s*=\s*bool\(\s*"
|
||||
r"placement_trace\[TRACE_KEY_V4_EVIDENCE_CONSUMED\]\s*"
|
||||
r"and\s+matches_mapper\s*\)",
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(source), (
|
||||
"u5 derivation must be exactly "
|
||||
"bool(placement_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] and matches_mapper) — "
|
||||
"any other formula (env flag alone, v4_b4_frame_match alone, etc.) "
|
||||
"breaks the Stage 2 A7 contract."
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_gatekeeper_trigger_remains_flag_independent() -> None:
|
||||
"""The ``PHASE_Z_B4_GATEKEEPER`` branch must continue to trigger purely
|
||||
on ``not matches_mapper`` (Stage 2 A8 — mapper/gatekeeper flag
|
||||
independence). u5 must NOT add ``v4_short_circuit`` or
|
||||
``PHASE_Z_B4_V4_EVIDENCE`` to the trigger condition."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
# Locate the gatekeeper trigger `if (...)` block and extract its body
|
||||
# up to the closing `):` so we can assert what is / is not inside.
|
||||
gate_open = source.index('os.environ.get("PHASE_Z_B4_GATEKEEPER"')
|
||||
# Walk back to the opening `if (` of this conditional.
|
||||
if_idx = source.rfind("if (", 0, gate_open)
|
||||
assert if_idx != -1, "could not locate gatekeeper `if (` opener"
|
||||
# Walk forward from `if (` to balance parentheses up to the matching `)`.
|
||||
depth = 0
|
||||
i = if_idx + len("if ")
|
||||
assert source[i] == "(", "gatekeeper opener malformed"
|
||||
start = i
|
||||
while i < len(source):
|
||||
c = source[i]
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
trigger_body = source[start: i + 1]
|
||||
assert "PHASE_Z_B4_GATEKEEPER" in trigger_body
|
||||
assert "not matches_mapper" in trigger_body
|
||||
assert "PHASE_Z_B4_V4_EVIDENCE" not in trigger_body, (
|
||||
"u5 must not couple gatekeeper trigger to PHASE_Z_B4_V4_EVIDENCE — "
|
||||
"flag independence (Stage 2 A8) requires the two flags to be "
|
||||
"evaluable independently."
|
||||
)
|
||||
assert "v4_short_circuit" not in trigger_body, (
|
||||
"u5 must not couple gatekeeper trigger to v4_short_circuit — "
|
||||
"the short-circuit is telemetry only; gatekeeper trigger remains "
|
||||
"purely structural (not matches_mapper)."
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_adapter_record_includes_v4_short_circuit() -> None:
|
||||
"""When the gatekeeper triggers (mismatch path), the appended
|
||||
``adapter_record`` must carry ``v4_short_circuit`` so downstream
|
||||
consumers can read the V4-path outcome on the rejected branch."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
# Find the adapter_record dict literal and assert it contains the field.
|
||||
adapter_open = source.index("adapter_record = {")
|
||||
# Balance braces to capture the dict body.
|
||||
depth = 0
|
||||
i = adapter_open + len("adapter_record = ")
|
||||
assert source[i] == "{"
|
||||
start = i
|
||||
while i < len(source):
|
||||
c = source[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
adapter_body = source[start: i + 1]
|
||||
assert '"v4_short_circuit": v4_short_circuit' in adapter_body, (
|
||||
"u5 wiring missing — adapter_record must include "
|
||||
'"v4_short_circuit": v4_short_circuit so the rejected branch '
|
||||
"carries the same V4-path telemetry as the accepted branch."
|
||||
)
|
||||
|
||||
|
||||
# ─── Behavioral — derivation replay via plan_placement ────────────
|
||||
|
||||
|
||||
def test_flag_off_v4_short_circuit_is_false_on_match(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag OFF: even when ``v4_candidates`` is supplied AND declaration
|
||||
order happens to match the mapper, ``v4_short_circuit=False`` because
|
||||
V4 evidence was not consumed (Stage 2 A1 + A10 — flag-OFF parity)."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_off_match",
|
||||
v4_candidates=[_V4Cand("F_DECL_FIRST")],
|
||||
)
|
||||
trace, matches_mapper = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_DECL_FIRST"
|
||||
)
|
||||
assert matches_mapper is True
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace["v4_short_circuit"] is False
|
||||
|
||||
|
||||
def test_flag_off_v4_short_circuit_is_false_on_mismatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag OFF + declaration-order mismatch: ``v4_short_circuit=False``."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_off_mismatch",
|
||||
v4_candidates=[_V4Cand("F_RANK1")],
|
||||
)
|
||||
trace, matches_mapper = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_RANK1"
|
||||
)
|
||||
assert matches_mapper is False
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace["v4_short_circuit"] is False
|
||||
|
||||
|
||||
def test_flag_on_v4_short_circuit_true_when_v4_rank1_matches_mapper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + V4 rank-1 eligible and matches mapper: ``v4_short_circuit=True``
|
||||
(Stage 2 A7 happy path — V4 directly drove the mapper-matching selection)."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_on_match",
|
||||
v4_candidates=[_V4Cand("F_RANK1")],
|
||||
)
|
||||
trace, matches_mapper = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_RANK1"
|
||||
)
|
||||
assert matches_mapper is True
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is True
|
||||
assert (
|
||||
trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
)
|
||||
assert trace["v4_short_circuit"] is True
|
||||
|
||||
|
||||
def test_flag_on_v4_short_circuit_false_when_v4_picks_non_mapper_contract(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + V4 evidence consumed but the V4-selected contract differs
|
||||
from the mapper's frame_template_id: ``v4_short_circuit=False``. This is
|
||||
the "V4 disagreed with mapper" path — the gatekeeper would still trigger
|
||||
on ``not matches_mapper`` regardless of V4 consumption (Stage 2 A8
|
||||
flag-independence)."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [
|
||||
_contract("F_MAPPER", "1", ["text_block"]),
|
||||
_contract("F_V4_PICK", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_on_deliberate_mismatch",
|
||||
v4_candidates=[_V4Cand("F_V4_PICK")],
|
||||
)
|
||||
trace, matches_mapper = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_MAPPER"
|
||||
)
|
||||
assert matches_mapper is False
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is True
|
||||
assert (
|
||||
trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
)
|
||||
assert trace["v4_short_circuit"] is False
|
||||
|
||||
|
||||
def test_flag_on_v4_short_circuit_false_when_evidence_empty_even_if_decl_order_matches(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON but ``v4_candidates`` empty/None: planner falls back to
|
||||
declaration order. Even when that fallback happens to match the mapper,
|
||||
``v4_short_circuit=False`` because V4 evidence was NOT consumed
|
||||
(``v4_evidence_consumed=False`` under the ``v4_evidence_empty`` fallback
|
||||
reason). Distinguishes "V4 affirmatively chose" from "declaration order
|
||||
coincidence" — the whole point of the short-circuit telemetry."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_on_empty_evidence",
|
||||
v4_candidates=None,
|
||||
)
|
||||
trace, matches_mapper = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_DECL_FIRST"
|
||||
)
|
||||
assert matches_mapper is True
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
assert (
|
||||
trace[TRACE_KEY_FRAME_SELECTION_BASIS]
|
||||
== FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert trace["v4_short_circuit"] is False
|
||||
|
||||
|
||||
def test_flag_on_v4_short_circuit_false_when_no_rank_eligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + V4 candidates supplied but none resolve to an eligible
|
||||
contract: planner records ``no_v4_rank_eligible`` and falls back to
|
||||
declaration order. ``v4_evidence_consumed=False`` → ``v4_short_circuit=False``
|
||||
even when the declaration-order fallback matches the mapper."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_on_no_rank_eligible",
|
||||
v4_candidates=[_V4Cand("F_UNKNOWN_TEMPLATE")],
|
||||
)
|
||||
trace, matches_mapper = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_DECL_FIRST"
|
||||
)
|
||||
assert matches_mapper is True
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "no_v4_rank_eligible"
|
||||
assert trace["v4_short_circuit"] is False
|
||||
|
||||
|
||||
def test_v4_short_circuit_is_boolean_type_always(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The derived field must be ``bool`` (not ``None``, not a truthy
|
||||
non-bool like ``1``). Guards against ``and``-chain returning the LHS /
|
||||
RHS object instead of a normalized boolean — important for downstream
|
||||
consumers (u10 trace-field type regression)."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [_contract("F_RANK1", "1", ["text_block"])]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u5_type_check",
|
||||
v4_candidates=[_V4Cand("F_RANK1")],
|
||||
)
|
||||
trace, _ = _replay_trace_assembly_with_short_circuit(
|
||||
plan, mapper_frame_template_id="F_RANK1"
|
||||
)
|
||||
assert isinstance(trace["v4_short_circuit"], bool)
|
||||
assert trace["v4_short_circuit"] is True
|
||||
@@ -0,0 +1,362 @@
|
||||
"""IMP-95 u6 — partial_exists precheck regression.
|
||||
|
||||
Covers the V4-aware selector's contract-only / no-partial precheck:
|
||||
|
||||
* ``_select_frame_v4_aware(..., partial_exists=...)`` skips V4 ranks whose
|
||||
resolved ``template_id`` returns False from the callable, recording the
|
||||
skip in ``b4_partial_missing_skip`` and continuing to the next rank.
|
||||
* ``partial_exists=None`` (default) preserves pre-u6 selector behavior —
|
||||
no precheck performed, ``b4_partial_missing_skip`` stays empty.
|
||||
* Declaration-order legacy fallback (``_select_frame``) is NEVER consulted
|
||||
for partial existence — SHA parity guarantee under flag OFF.
|
||||
* ``_declaration_order_selection_trace()`` carries the key with an empty
|
||||
list so trace-field regression (u10) can read presence on every path.
|
||||
* ``plan_placement(..., partial_exists=...)`` forwards the callable when
|
||||
``PHASE_Z_B4_V4_EVIDENCE`` is ON; flag OFF ignores the kwarg verbatim.
|
||||
* Pipeline-side ``_b4_partial_exists`` helper mirrors the partial filename
|
||||
convention (``templates/phase_z2/families/{template_id}.html``) so the
|
||||
V4 precheck and ``_load_frame_partial_html`` cannot drift.
|
||||
|
||||
These are unit tests; multi-mdx fixture regression lives in u8/u9/u10.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC_DIR = REPO_ROOT / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from phase_z2_content_extractor import ContentObject # noqa: E402
|
||||
from phase_z2_placement_planner import ( # noqa: E402
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
PHASE_Z_B4_V4_EVIDENCE_ENV,
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
_declaration_order_selection_trace,
|
||||
_select_frame_v4_aware,
|
||||
plan_placement,
|
||||
)
|
||||
|
||||
|
||||
# ─── Test fixtures ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class _V4Cand:
|
||||
"""Duck-typed V4 candidate mirror (composition.py:678-684 contract)."""
|
||||
|
||||
__slots__ = ("template_id", "frame_id")
|
||||
|
||||
def __init__(self, template_id=None, frame_id=None):
|
||||
self.template_id = template_id
|
||||
self.frame_id = frame_id
|
||||
|
||||
|
||||
def _text_obj(obj_id: str = "t1") -> ContentObject:
|
||||
return ContentObject(
|
||||
id=obj_id,
|
||||
type="text_block",
|
||||
role="summary",
|
||||
raw_payload="* body",
|
||||
size_estimate={"line_count": 1},
|
||||
type_specific={"format": "bullet_list", "bullet_count": 1},
|
||||
)
|
||||
|
||||
|
||||
def _contract(template_id: str, frame_id: str = None, accepts=("text_block",)) -> dict:
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"frame_id": frame_id or template_id,
|
||||
"accepted_content_types": list(accepts),
|
||||
"sub_zones": [
|
||||
{
|
||||
"id": "slot_1",
|
||||
"accepts": list(accepts),
|
||||
"partial_target_path": "[data-slot='slot_1']",
|
||||
"cardinality": {"strict": 1},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scrub_env(monkeypatch):
|
||||
"""Force PHASE_Z_B4_V4_EVIDENCE OFF unless a test sets it explicitly."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
# ─── _declaration_order_selection_trace contract ────────────────
|
||||
|
||||
|
||||
def test_declaration_order_trace_contains_partial_missing_skip_key():
|
||||
"""Default trace MUST carry ``b4_partial_missing_skip`` with empty list.
|
||||
|
||||
u10 trace-field regression reads the top-level hoist on every code path;
|
||||
the declaration-order default has to expose the key so a flag-OFF run
|
||||
does not produce ``KeyError`` downstream.
|
||||
"""
|
||||
trace = _declaration_order_selection_trace()
|
||||
assert TRACE_KEY_B4_PARTIAL_MISSING_SKIP in trace
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == []
|
||||
assert isinstance(trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP], list)
|
||||
|
||||
|
||||
# ─── _select_frame_v4_aware precheck behavior ───────────────────
|
||||
|
||||
|
||||
def test_selector_partial_exists_none_preserves_pre_u6_behavior():
|
||||
"""When ``partial_exists`` is omitted/None, no precheck runs and rank-1 wins."""
|
||||
contracts = [_contract("RANK1"), _contract("RANK2")]
|
||||
candidates = [_V4Cand(template_id="RANK1"), _V4Cand(template_id="RANK2")]
|
||||
|
||||
matched, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts, v4_candidates=candidates,
|
||||
)
|
||||
|
||||
assert matched is contracts[0]
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == []
|
||||
|
||||
|
||||
def test_selector_skips_rank1_when_partial_missing_falls_through_to_rank2():
|
||||
"""Rank-1 with no partial → skip + record; rank-2 with partial → win."""
|
||||
contracts = [_contract("CONTRACT_ONLY"), _contract("HAS_PARTIAL")]
|
||||
candidates = [
|
||||
_V4Cand(template_id="CONTRACT_ONLY"),
|
||||
_V4Cand(template_id="HAS_PARTIAL"),
|
||||
]
|
||||
|
||||
def partial_exists(tid: str) -> bool:
|
||||
return tid == "HAS_PARTIAL"
|
||||
|
||||
matched, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
v4_candidates=candidates,
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
|
||||
assert matched is contracts[1]
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is True
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
# The skipped rank-1 must be recorded in order.
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == [
|
||||
{"rank": 0, "template_id": "CONTRACT_ONLY"},
|
||||
]
|
||||
|
||||
|
||||
def test_selector_all_ranks_missing_partial_falls_back_to_declaration_order():
|
||||
"""When every V4 rank lacks a partial, fallback to declaration order.
|
||||
|
||||
The fallback reason must be ``no_v4_rank_eligible`` (ranks were tried but
|
||||
none satisfied the precheck) and every skipped rank must be traced.
|
||||
"""
|
||||
contracts = [_contract("DECL_WINNER"), _contract("RANK1"), _contract("RANK2")]
|
||||
candidates = [_V4Cand(template_id="RANK1"), _V4Cand(template_id="RANK2")]
|
||||
|
||||
def partial_exists(tid: str) -> bool:
|
||||
return tid not in {"RANK1", "RANK2"}
|
||||
|
||||
matched, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
v4_candidates=candidates,
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
|
||||
assert matched is contracts[0] # declaration order fallback
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "no_v4_rank_eligible"
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == [
|
||||
{"rank": 0, "template_id": "RANK1"},
|
||||
{"rank": 1, "template_id": "RANK2"},
|
||||
]
|
||||
|
||||
|
||||
def test_selector_partial_exists_not_called_for_unmatched_ranks():
|
||||
"""Ranks that fail template_id/frame_id lookup MUST NOT consult partial_exists.
|
||||
|
||||
The precheck applies only after a contract is matched; otherwise we would
|
||||
record a ``b4_partial_missing_skip`` for an unmatched rank, which is a
|
||||
different failure mode.
|
||||
"""
|
||||
contracts = [_contract("ONLY_CONTRACT")]
|
||||
candidates = [
|
||||
_V4Cand(template_id="NOT_IN_CATALOG"), # unmatched — no precheck
|
||||
_V4Cand(template_id="ONLY_CONTRACT"),
|
||||
]
|
||||
consulted: list[str] = []
|
||||
|
||||
def partial_exists(tid: str) -> bool:
|
||||
consulted.append(tid)
|
||||
return True
|
||||
|
||||
matched, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
v4_candidates=candidates,
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
|
||||
assert matched is contracts[0]
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
# Only the matched candidate consulted partial_exists; unmatched is skipped earlier.
|
||||
assert consulted == ["ONLY_CONTRACT"]
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == []
|
||||
|
||||
|
||||
def test_selector_partial_exists_runs_before_accepted_content_types_check():
|
||||
"""Precheck order — partial_exists BEFORE accepted_content_types ⊇ check.
|
||||
|
||||
Stage 2 A9 + u6 docstring contract: a contract-only template must be
|
||||
recorded as ``b4_partial_missing_skip`` even when the ⊇ check would also
|
||||
have failed. The ordering keeps the skip reason unambiguous.
|
||||
"""
|
||||
# Contract present but does NOT cover the content type — would normally
|
||||
# be ineligible. partial_exists=False means we record partial_missing
|
||||
# before reaching the ⊇ check.
|
||||
contracts = [_contract("MISSING_PARTIAL", accepts=("image",))]
|
||||
candidates = [_V4Cand(template_id="MISSING_PARTIAL")]
|
||||
|
||||
seen_acceptable_check = []
|
||||
|
||||
def partial_exists(tid: str) -> bool:
|
||||
seen_acceptable_check.append(tid)
|
||||
return False
|
||||
|
||||
matched, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
v4_candidates=candidates,
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
|
||||
assert matched is None # no declaration-order winner either
|
||||
assert seen_acceptable_check == ["MISSING_PARTIAL"]
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == [
|
||||
{"rank": 0, "template_id": "MISSING_PARTIAL"},
|
||||
]
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "no_v4_rank_eligible"
|
||||
|
||||
|
||||
def test_selector_empty_evidence_keeps_partial_missing_skip_empty():
|
||||
"""No V4 evidence → precheck is never reached → empty skip list.
|
||||
|
||||
Fallback reason is the empty-evidence variant, distinct from
|
||||
``no_v4_rank_eligible``.
|
||||
"""
|
||||
contracts = [_contract("DECL_WINNER")]
|
||||
matched, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
v4_candidates=None,
|
||||
partial_exists=lambda tid: True,
|
||||
)
|
||||
assert matched is contracts[0]
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
assert trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == []
|
||||
|
||||
|
||||
# ─── plan_placement wiring ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_plan_placement_signature_accepts_partial_exists_kwarg():
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(plan_placement)
|
||||
assert "partial_exists" in sig.parameters
|
||||
param = sig.parameters["partial_exists"]
|
||||
assert param.default is None
|
||||
# kwarg-style, not positional-only.
|
||||
assert param.kind in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
|
||||
|
||||
def test_plan_placement_flag_off_ignores_partial_exists(monkeypatch):
|
||||
"""Flag OFF (default) → selector never consulted, partial_exists ignored.
|
||||
|
||||
Stage 2 A10 SHA parity precondition.
|
||||
"""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [_contract("DECL_FIRST"), _contract("DECL_SECOND")]
|
||||
called: list[str] = []
|
||||
|
||||
def partial_exists(tid: str) -> bool:
|
||||
called.append(tid)
|
||||
return False # would-be skip everything if consulted
|
||||
|
||||
plan = plan_placement(
|
||||
[_text_obj()], contracts, section_id="zone-1",
|
||||
v4_candidates=[_V4Cand(template_id="DECL_SECOND")],
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
|
||||
# Declaration-order winner unchanged; partial_exists never called.
|
||||
assert plan.selected_template_id == "DECL_FIRST"
|
||||
assert called == []
|
||||
# Trace carries the declaration-order default with empty skip list.
|
||||
assert plan.selection_trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == []
|
||||
|
||||
|
||||
def test_plan_placement_flag_on_forwards_partial_exists(monkeypatch):
|
||||
"""Flag ON → plan_placement forwards partial_exists to the V4-aware selector."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [_contract("RANK1"), _contract("RANK2")]
|
||||
candidates = [_V4Cand(template_id="RANK1"), _V4Cand(template_id="RANK2")]
|
||||
|
||||
def partial_exists(tid: str) -> bool:
|
||||
return tid == "RANK2"
|
||||
|
||||
plan = plan_placement(
|
||||
[_text_obj()], contracts, section_id="zone-1",
|
||||
v4_candidates=candidates,
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
|
||||
assert plan.selected_template_id == "RANK2"
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert plan.selection_trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
assert plan.selection_trace[TRACE_KEY_B4_PARTIAL_MISSING_SKIP] == [
|
||||
{"rank": 0, "template_id": "RANK1"},
|
||||
]
|
||||
|
||||
|
||||
# ─── pipeline helper contract ───────────────────────────────────
|
||||
|
||||
|
||||
def test_pipeline_b4_partial_exists_uses_families_path(tmp_path, monkeypatch):
|
||||
"""``_b4_partial_exists`` checks ``templates/phase_z2/families/{id}.html``.
|
||||
|
||||
Same convention as ``_load_frame_partial_html`` — drift would break the
|
||||
V4 precheck and the AI fallback loader independently.
|
||||
"""
|
||||
import phase_z2_pipeline as pipeline_mod
|
||||
|
||||
fake_template_dir = tmp_path / "templates" / "phase_z2"
|
||||
families_dir = fake_template_dir / "families"
|
||||
families_dir.mkdir(parents=True)
|
||||
(families_dir / "PRESENT.html").write_text("<div/>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(pipeline_mod, "TEMPLATE_DIR", fake_template_dir)
|
||||
|
||||
assert pipeline_mod._b4_partial_exists("PRESENT") is True
|
||||
assert pipeline_mod._b4_partial_exists("ABSENT") is False
|
||||
# Defensive: empty / falsy template_id never resolves to True.
|
||||
assert pipeline_mod._b4_partial_exists("") is False
|
||||
@@ -0,0 +1,330 @@
|
||||
"""IMP-95 u4 — Step 11 ``unit.v4_candidates`` wiring + placement_trace
|
||||
top-level exposure.
|
||||
|
||||
Stage 2 plan (u4 / axes A4 + A5 + A6): the Step 11 renderable-zones loop at
|
||||
``src/phase_z2_pipeline.py`` must (1) forward ``unit.v4_candidates`` into
|
||||
``plan_placement`` as the new ``v4_candidates=`` kwarg added by u3 and
|
||||
(2) hoist the u1 ``TRACE_KEY_*`` keys from ``PlacementPlan.selection_trace``
|
||||
to the top level of ``placement_trace`` so downstream consumers
|
||||
(u5 gatekeeper short-circuit, u10 trace-field regression) can read them
|
||||
without traversing the nested ``selection_trace`` dict.
|
||||
|
||||
Coverage axes :
|
||||
* Wiring contract — the call site uses ``v4_candidates=`` from the unit.
|
||||
* Hoist contract — ``placement_trace`` carries the five u1 trace keys at
|
||||
the top level, in addition to the nested ``selection_trace`` produced
|
||||
by ``asdict(placement_plan)``.
|
||||
* Flag-OFF parity — top-level keys exist with declaration-order defaults
|
||||
(Stage 2 A1 + A10).
|
||||
* Flag-ON V4-ranked — top-level keys reflect the V4-aware selector path
|
||||
(basis = v4_ranked, rank_used = 0, evidence_consumed = True,
|
||||
frame_match = True, fallback_reason = None).
|
||||
* Flag-ON empty evidence fallback — top-level fallback_reason =
|
||||
'v4_evidence_empty'.
|
||||
* ``unit.v4_candidates = []`` (default-factory empty list) is coerced to
|
||||
``None`` so the planner treats it as no-evidence (Stage 2 A4 contract
|
||||
mirrored by u3 tests; explicit-empty is semantically "no evidence").
|
||||
* Defensive ``getattr`` on a non-CompositionUnit shape keeps Step 11 alive.
|
||||
|
||||
Out of scope for u4 (Stage 2 scope-lock) :
|
||||
* Gatekeeper short-circuit on V4=mapper match (u5).
|
||||
* Partial-exists precheck (u6).
|
||||
* Full-pipeline SHA / adapter_needed regressions (u8 / u9 / u10).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_content_extractor import ContentObject
|
||||
from src.phase_z2_placement_planner import (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
PHASE_Z_B4_V4_EVIDENCE_ENV,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
plan_placement,
|
||||
)
|
||||
|
||||
PIPELINE_PATH = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "src"
|
||||
/ "phase_z2_pipeline.py"
|
||||
)
|
||||
|
||||
|
||||
# ─── Duck-typed CompositionUnit for evidence wiring (u4 / A4) ─────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _V4Cand:
|
||||
"""Duck-typed V4Match mirror (composition.py:678-684)."""
|
||||
|
||||
template_id: str
|
||||
frame_id: str = ""
|
||||
frame_number: int = 0
|
||||
confidence: float = 0.0
|
||||
label: str = "use_as_is"
|
||||
|
||||
|
||||
def _text_obj() -> ContentObject:
|
||||
return ContentObject(
|
||||
id="u4.text-1",
|
||||
type="text_block",
|
||||
role="summary",
|
||||
raw_payload="* bullet",
|
||||
size_estimate={"line_count": 3},
|
||||
type_specific={
|
||||
"format": "bullet_list",
|
||||
"bullet_count": 1,
|
||||
"max_indent_level": 0,
|
||||
"has_emphasis": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _contract(template_id: str, frame_id: str, accepted: list[str]) -> dict:
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"frame_id": frame_id,
|
||||
"accepted_content_types": accepted,
|
||||
"sub_zones": [],
|
||||
}
|
||||
|
||||
|
||||
def _replay_trace_assembly(plan, mapper_frame_template_id: str) -> dict:
|
||||
"""Replay the exact ``placement_trace`` assembly from
|
||||
``src/phase_z2_pipeline.py`` (Step 11). This is a binding mirror of the
|
||||
code under test — the hoist contract (u4) lives at that call site.
|
||||
|
||||
Any drift in field names or default semantics MUST be reflected here so
|
||||
the test fails loudly. The body intentionally mirrors the production
|
||||
assembly verbatim (including the ``frame_selection_match_note`` string
|
||||
template) so a future edit to that block requires this mirror to
|
||||
update — which is the unit's binding contract.
|
||||
"""
|
||||
matches_mapper = plan.selected_template_id == mapper_frame_template_id
|
||||
match_note = None
|
||||
if not matches_mapper:
|
||||
if plan.selected_template_id is None:
|
||||
match_note = "no_frame_covers_content_types"
|
||||
else:
|
||||
match_note = (
|
||||
f"B4 selected '{plan.selected_template_id}' but "
|
||||
f"mapper uses '{mapper_frame_template_id}' (composition V4 rank-1)"
|
||||
)
|
||||
selection_trace = getattr(plan, "selection_trace", None) or {}
|
||||
return {
|
||||
**asdict(plan),
|
||||
"mapper_frame_template_id": mapper_frame_template_id,
|
||||
"frame_selection_matches_mapper": matches_mapper,
|
||||
"frame_selection_match_note": match_note,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS: selection_trace.get(
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS
|
||||
),
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED: selection_trace.get(
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED, False
|
||||
),
|
||||
TRACE_KEY_V4_RANK_USED: selection_trace.get(TRACE_KEY_V4_RANK_USED),
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH: selection_trace.get(
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH, False
|
||||
),
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON: selection_trace.get(
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── Structural — pipeline.py wiring assertion ────────────────────
|
||||
|
||||
|
||||
def test_pipeline_imports_u1_trace_key_constants() -> None:
|
||||
"""``src/phase_z2_pipeline.py`` imports the u1 ``TRACE_KEY_*`` constants
|
||||
from the placement planner so the Step 11 trace assembly cannot inline
|
||||
or drift the key names (Stage 2 A5)."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
for name in (
|
||||
"TRACE_KEY_FRAME_SELECTION_BASIS",
|
||||
"TRACE_KEY_V4_EVIDENCE_CONSUMED",
|
||||
"TRACE_KEY_V4_RANK_USED",
|
||||
"TRACE_KEY_V4_B4_FRAME_MATCH",
|
||||
"TRACE_KEY_B4_V0_FALLBACK_REASON",
|
||||
):
|
||||
assert name in source, (
|
||||
f"u4 wiring missing — pipeline.py must import {name} so the Step 11 "
|
||||
f"placement_trace assembly uses the u1 constant (avoids string drift)."
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_step11_passes_v4_candidates_kwarg() -> None:
|
||||
"""The Step 11 ``plan_placement(...)`` call site forwards
|
||||
``v4_candidates=`` using ``unit.v4_candidates`` (Stage 2 A4)."""
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
# Locate every ``plan_placement(`` opener and balance parentheses
|
||||
# manually so nested calls (``load_frame_contracts()``) do not truncate
|
||||
# the captured body.
|
||||
found_kwarg = False
|
||||
for opener in re.finditer(r"plan_placement\s*\(", source):
|
||||
depth = 1
|
||||
i = opener.end()
|
||||
while i < len(source) and depth > 0:
|
||||
c = source[i]
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
i += 1
|
||||
body = source[opener.end(): i - 1]
|
||||
if "v4_candidates=" in body:
|
||||
found_kwarg = True
|
||||
break
|
||||
|
||||
assert found_kwarg, (
|
||||
"u4 wiring missing — Step 11 plan_placement(...) call must pass "
|
||||
"v4_candidates= (the kwarg added by u3)."
|
||||
)
|
||||
assert (
|
||||
"unit.v4_candidates" in source
|
||||
or 'getattr(unit, "v4_candidates"' in source
|
||||
), (
|
||||
"u4 wiring missing — Step 11 must source v4_candidates from "
|
||||
"CompositionUnit.v4_candidates (composition.py:678-684)."
|
||||
)
|
||||
|
||||
|
||||
# ─── Behavioral — trace hoist via assembly replay ─────────────────
|
||||
|
||||
|
||||
def test_flag_off_trace_assembly_hoists_declaration_order_defaults(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag OFF: even when ``v4_candidates`` is supplied by the unit, the
|
||||
hoisted top-level keys must carry declaration-order defaults (Stage 2
|
||||
A1 + A10 — final.html SHA parity precondition for u8)."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1_WOULD_WIN_IF_ON", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u4_off",
|
||||
v4_candidates=[_V4Cand("F_RANK1_WOULD_WIN_IF_ON")],
|
||||
)
|
||||
trace = _replay_trace_assembly(plan, mapper_frame_template_id="F_DECL_FIRST")
|
||||
|
||||
# u4 hoist contract — five top-level keys present with declaration-order defaults.
|
||||
assert (
|
||||
trace[TRACE_KEY_FRAME_SELECTION_BASIS]
|
||||
== FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
# nested selection_trace from asdict(plan) survives — additive guarantee.
|
||||
assert "selection_trace" in trace
|
||||
assert (
|
||||
trace["selection_trace"][TRACE_KEY_FRAME_SELECTION_BASIS]
|
||||
== FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
|
||||
# mapper-comparison fields untouched.
|
||||
assert trace["mapper_frame_template_id"] == "F_DECL_FIRST"
|
||||
assert trace["frame_selection_matches_mapper"] is True
|
||||
assert trace["frame_selection_match_note"] is None
|
||||
|
||||
|
||||
def test_flag_on_trace_assembly_hoists_v4_ranked_evidence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + rank-1 candidate eligible: hoisted top-level keys reflect
|
||||
the V4-aware selector path (Stage 2 A6 — v4_rank_used / v4_b4_frame_match
|
||||
visibility)."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u4_on_rank1",
|
||||
v4_candidates=[_V4Cand("F_RANK1")],
|
||||
)
|
||||
trace = _replay_trace_assembly(plan, mapper_frame_template_id="F_RANK1")
|
||||
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is True
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
# mapper now matches the V4-selected frame.
|
||||
assert trace["frame_selection_matches_mapper"] is True
|
||||
|
||||
|
||||
def test_flag_on_empty_evidence_hoists_v4_evidence_empty_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON but ``unit.v4_candidates`` empty → planner falls back to
|
||||
declaration order with ``b4_v0_fallback_reason='v4_evidence_empty'``.
|
||||
Top-level hoist surfaces the reason without traversing ``selection_trace``."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="u4_on_empty",
|
||||
v4_candidates=None,
|
||||
)
|
||||
trace = _replay_trace_assembly(plan, mapper_frame_template_id="F_DECL_FIRST")
|
||||
|
||||
assert (
|
||||
trace[TRACE_KEY_FRAME_SELECTION_BASIS]
|
||||
== FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
|
||||
|
||||
def test_step11_empty_v4_candidates_list_coerced_to_none() -> None:
|
||||
"""``unit.v4_candidates`` defaults to ``[]`` (CompositionUnit
|
||||
default-factory). The Step 11 wiring coerces empty-list → ``None`` so
|
||||
the planner emits ``v4_evidence_empty`` instead of
|
||||
``no_v4_rank_eligible`` — these reasons are semantically distinct
|
||||
(Stage 2 A5 enum)."""
|
||||
# This mirrors the exact coercion at the call site :
|
||||
# unit_v4_candidates = getattr(unit, "v4_candidates", None) or None
|
||||
source = PIPELINE_PATH.read_text(encoding="utf-8")
|
||||
assert 'getattr(unit, "v4_candidates", None) or None' in source, (
|
||||
"u4 wiring must coerce empty / missing unit.v4_candidates to None "
|
||||
"so the planner reports the v4_evidence_empty fallback reason."
|
||||
)
|
||||
|
||||
|
||||
def test_step11_non_composition_unit_shape_does_not_crash() -> None:
|
||||
"""Defensive ``getattr(unit, 'v4_candidates', None)`` keeps Step 11
|
||||
alive when ``unit`` lacks the attribute (e.g. legacy fixture shapes /
|
||||
future unit subclasses). The unit-under-test is the coercion line at
|
||||
the call site — verified by structural assertion in
|
||||
:func:`test_step11_empty_v4_candidates_list_coerced_to_none`. Here
|
||||
we assert the defensive pattern survives ``getattr`` semantics on a
|
||||
bare ``object()``."""
|
||||
|
||||
class _BareUnit:
|
||||
pass
|
||||
|
||||
bare = _BareUnit()
|
||||
coerced = getattr(bare, "v4_candidates", None) or None
|
||||
assert coerced is None
|
||||
@@ -0,0 +1,271 @@
|
||||
"""IMP-95 u3 — ``plan_placement`` V4 evidence wiring unit tests.
|
||||
|
||||
Stage 2 plan (u3 / axes A4, A5): ``plan_placement`` gains an optional
|
||||
``v4_candidates`` kwarg and surfaces an additive ``selection_trace`` dict
|
||||
on ``PlacementPlan``. Default OFF preserves declaration-order behavior
|
||||
(Stage 2 A1); ON delegates to ``_select_frame_v4_aware`` (u2).
|
||||
|
||||
Coverage :
|
||||
* Flag OFF: legacy declaration-order frame chosen; ``v4_candidates``
|
||||
kwarg ignored even when supplied (Stage 2 A1 + A10 SHA parity).
|
||||
* Flag OFF: ``selection_trace`` populated with declaration_order defaults.
|
||||
* Flag ON + V4 rank-1 eligible: V4-ranked contract wins.
|
||||
* Flag ON + empty V4 evidence: declaration-order fallback,
|
||||
``b4_v0_fallback_reason = 'v4_evidence_empty'``.
|
||||
* Flag ON + None V4 evidence: same fallback semantics.
|
||||
* Empty ``content_objects`` short-circuit still emits ``selection_trace``.
|
||||
* ``v4_candidates`` is keyword-only-by-convention (kwarg presence check).
|
||||
|
||||
Out of scope for u3 (per Stage 2 scope-lock) :
|
||||
* Step 11 wiring of ``unit.v4_candidates`` (u4).
|
||||
* Gatekeeper short-circuit (u5).
|
||||
* Partial-exists precheck (u6).
|
||||
* Pipeline regression / SHA parity (u8~u10).
|
||||
|
||||
Trace keys reuse the u1 ``TRACE_KEY_*`` constants so u4 cannot drift names.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_content_extractor import ContentObject
|
||||
from src.phase_z2_placement_planner import (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
PHASE_Z_B4_V4_EVIDENCE_ENV,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
PlacementPlan,
|
||||
plan_placement,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cand:
|
||||
"""Duck-typed V4Match (mirrors ``src/phase_z2_composition.py:678-684``)."""
|
||||
|
||||
template_id: str
|
||||
frame_id: str = ""
|
||||
frame_number: int = 0
|
||||
confidence: float = 0.0
|
||||
label: str = "use_as_is"
|
||||
|
||||
|
||||
def _text_obj(oid: str = "u3.text-1") -> ContentObject:
|
||||
return ContentObject(
|
||||
id=oid,
|
||||
type="text_block",
|
||||
role="summary",
|
||||
raw_payload="* bullet",
|
||||
size_estimate={"line_count": 3},
|
||||
type_specific={
|
||||
"format": "bullet_list",
|
||||
"bullet_count": 1,
|
||||
"max_indent_level": 0,
|
||||
"has_emphasis": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _contract(template_id: str, frame_id: str, accepted: list[str]) -> dict:
|
||||
"""Minimal frame_contract — empty ``sub_zones`` means Stage B picks no slot
|
||||
(rejection), but selection_trace is still produced (the unit under test)."""
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"frame_id": frame_id,
|
||||
"accepted_content_types": accepted,
|
||||
"sub_zones": [],
|
||||
}
|
||||
|
||||
|
||||
# ─── Signature contract (Stage 2 A4) ──────────────────────────────
|
||||
|
||||
|
||||
def test_plan_placement_accepts_v4_candidates_kwarg() -> None:
|
||||
"""``plan_placement`` exposes ``v4_candidates`` as an optional kwarg
|
||||
with default ``None``. Callers that omit it must keep working (legacy
|
||||
Step 11 wiring path until u4 lands)."""
|
||||
sig = inspect.signature(plan_placement)
|
||||
assert "v4_candidates" in sig.parameters
|
||||
param = sig.parameters["v4_candidates"]
|
||||
assert param.default is None
|
||||
|
||||
|
||||
# ─── Empty content_objects short-circuit ──────────────────────────
|
||||
|
||||
|
||||
def test_empty_content_objects_returns_default_trace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Empty ``content_objects`` → no Stage A/B, but ``selection_trace`` is
|
||||
populated so u4 reading from the field never sees a missing key."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
plan = plan_placement([], [_contract("F", "1", ["text_block"])], section_id="empty")
|
||||
assert isinstance(plan, PlacementPlan)
|
||||
assert plan.selected_frame_id is None
|
||||
assert plan.selected_template_id is None
|
||||
assert plan.internal_regions == []
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert plan.selection_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert plan.selection_trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert plan.selection_trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
assert plan.selection_trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
|
||||
# ─── Flag OFF — declaration-order parity (Stage 2 A1 + A10) ──────
|
||||
|
||||
|
||||
def test_flag_off_ignores_v4_candidates_and_keeps_declaration_order(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Even when ``v4_candidates`` is supplied, flag OFF must keep the
|
||||
legacy declaration-order frame selection. This is the load-bearing
|
||||
SHA-parity guarantee for u8."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1_WOULD_WIN_IF_ON", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="off_with_evidence",
|
||||
v4_candidates=[_Cand("F_RANK1_WOULD_WIN_IF_ON")],
|
||||
)
|
||||
assert plan.selected_template_id == "F_DECL_FIRST"
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert plan.selection_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert plan.selection_trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert plan.selection_trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
# Flag OFF never enters the V4 path → no fallback reason is recorded.
|
||||
assert plan.selection_trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
|
||||
def test_flag_off_no_v4_kwarg_keeps_legacy_signature(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Existing callers (e.g. self-test, IMP-89/IMP-94) call without
|
||||
``v4_candidates`` — confirm the default ``None`` path is wired to
|
||||
declaration_order."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [_contract("F_FIRST", "1", ["text_block"])]
|
||||
plan = plan_placement([_text_obj()], contracts, section_id="off_default")
|
||||
assert plan.selected_template_id == "F_FIRST"
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
|
||||
|
||||
# ─── Flag ON — V4 rank wins ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_flag_on_with_v4_rank1_match_consumes_evidence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + rank-1 candidate eligible → V4-ranked contract chosen."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="on_rank1",
|
||||
v4_candidates=[_Cand("F_RANK1")],
|
||||
)
|
||||
assert plan.selected_template_id == "F_RANK1"
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_V4_RANKED
|
||||
)
|
||||
assert plan.selection_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is True
|
||||
assert plan.selection_trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
assert plan.selection_trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
assert plan.selection_trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
|
||||
# ─── Flag ON — empty / None evidence falls back ───────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evidence", [None, []])
|
||||
def test_flag_on_without_evidence_falls_back_to_declaration_order(
|
||||
monkeypatch: pytest.MonkeyPatch, evidence: object
|
||||
) -> None:
|
||||
"""Flag ON but no V4 evidence → declaration-order winner; fallback
|
||||
reason = ``v4_evidence_empty``. The frame outcome stays identical to
|
||||
the OFF path, which is the load-bearing invariant for u9/u10 (trace-
|
||||
only by default)."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="on_empty_evidence",
|
||||
v4_candidates=evidence, # type: ignore[arg-type]
|
||||
)
|
||||
assert plan.selected_template_id == "F_DECL_FIRST"
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert plan.selection_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert plan.selection_trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert plan.selection_trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
assert plan.selection_trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
|
||||
|
||||
# ─── Flag ON — every rank ineligible falls back ───────────────────
|
||||
|
||||
|
||||
def test_flag_on_all_ranks_ineligible_records_fallback_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + every V4 rank ineligible by accepted_content_types →
|
||||
declaration-order winner; fallback reason = ``no_v4_rank_eligible``."""
|
||||
monkeypatch.setenv(PHASE_Z_B4_V4_EVIDENCE_ENV, "1")
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["transform_table"]),
|
||||
]
|
||||
plan = plan_placement(
|
||||
[_text_obj()],
|
||||
contracts,
|
||||
section_id="on_no_eligible",
|
||||
v4_candidates=[_Cand("F_RANK1")],
|
||||
)
|
||||
assert plan.selected_template_id == "F_DECL_FIRST"
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert plan.selection_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert plan.selection_trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "no_v4_rank_eligible"
|
||||
|
||||
|
||||
# ─── No-frame-covers path still emits trace (rejection branch) ────
|
||||
|
||||
|
||||
def test_no_frame_covers_records_trace_and_rejection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When no frame covers the content_types, ``selection_trace`` is
|
||||
still populated (declaration-order default) so u4 can read it
|
||||
uniformly on every code path."""
|
||||
monkeypatch.delenv(PHASE_Z_B4_V4_EVIDENCE_ENV, raising=False)
|
||||
contracts = [_contract("F_ONLY", "1", ["transform_table"])]
|
||||
plan = plan_placement([_text_obj()], contracts, section_id="reject")
|
||||
assert plan.selected_frame_id is None
|
||||
assert plan.selected_template_id is None
|
||||
assert any(r["reason"] == "no_frame_covers_content_types" for r in plan.rejection)
|
||||
assert plan.selection_trace[TRACE_KEY_FRAME_SELECTION_BASIS] == (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
)
|
||||
assert plan.selection_trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
@@ -0,0 +1,477 @@
|
||||
"""IMP-95 u2 — V4 evidence-aware ``_select_frame_v4_aware`` unit tests.
|
||||
|
||||
Stage 2 plan (u2 / axes A2, A3, A13): the V4-aware selector ranks
|
||||
eligible frame_contracts under the existing
|
||||
``accepted_content_types ⊇ content_type_set`` constraint, then falls
|
||||
back to declaration order via ``_select_frame``.
|
||||
|
||||
Coverage (Stage 2 A13) :
|
||||
* V4 rank-1 eligible → wins over declaration-order first contract.
|
||||
* V4 rank-1 ineligible → fall-through to next eligible rank.
|
||||
* All V4 ranks ineligible / unmatched → declaration-order fallback,
|
||||
``b4_v0_fallback_reason = 'no_v4_rank_eligible'``.
|
||||
* Empty / None V4 evidence → declaration-order fallback,
|
||||
``b4_v0_fallback_reason = 'v4_evidence_empty'``.
|
||||
* V4 candidate matches by ``frame_id`` when ``template_id`` empty.
|
||||
* No contract covers content_types → returns ``None``.
|
||||
* Legacy ``_select_frame`` signature/behavior preserved (Stage 2 A1).
|
||||
|
||||
Trace keys reuse the u1 constants so u3 (plan_placement wiring) cannot
|
||||
silently drift the names.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.phase_z2_content_extractor import ContentObject
|
||||
from src.phase_z2_placement_planner import (
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
_select_frame,
|
||||
_select_frame_v4_aware,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cand:
|
||||
"""Duck-typed V4Match for tests.
|
||||
|
||||
Mirrors the contract documented at
|
||||
``src/phase_z2_composition.py:678-684`` :
|
||||
``template_id / frame_id / frame_number / confidence / label``.
|
||||
"""
|
||||
|
||||
template_id: str
|
||||
frame_id: str = ""
|
||||
frame_number: int = 0
|
||||
confidence: float = 0.0
|
||||
label: str = "use_as_is"
|
||||
|
||||
|
||||
def _text_obj(oid: str = "o1") -> ContentObject:
|
||||
return ContentObject(
|
||||
id=oid,
|
||||
type="text_block",
|
||||
role="summary",
|
||||
raw_payload="* bullet",
|
||||
size_estimate={"line_count": 3},
|
||||
type_specific={
|
||||
"format": "bullet_list",
|
||||
"bullet_count": 1,
|
||||
"max_indent_level": 0,
|
||||
"has_emphasis": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _contract(template_id: str, frame_id: str, accepted: list[str]) -> dict:
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"frame_id": frame_id,
|
||||
"accepted_content_types": accepted,
|
||||
"sub_zones": [],
|
||||
}
|
||||
|
||||
|
||||
# ─── Happy path: V4 rank-1 wins ───────────────────────────────────
|
||||
|
||||
|
||||
def test_v4_rank1_match_returns_rank1_contract() -> None:
|
||||
"""Eligible V4 rank-1 wins over declaration-order first contract."""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts, [_Cand("F_RANK1")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK1"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is True
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
|
||||
# ─── Rank fall-through ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rank2_fallthrough_when_rank1_ineligible() -> None:
|
||||
"""V4 rank-1 ineligible (does not accept text_block) → rank-2 wins."""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["transform_table"]),
|
||||
_contract("F_RANK2", "3", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand("F_RANK1"), _Cand("F_RANK2")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK2"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
|
||||
def test_unmatched_v4_candidate_skipped_to_next_rank() -> None:
|
||||
"""V4 rank-1 template_id absent from frame_contracts → skip to next rank."""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK2", "3", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand("F_NOT_IN_CONTRACTS"), _Cand("F_RANK2")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK2"
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
|
||||
|
||||
# ─── Declaration-order fallback ───────────────────────────────────
|
||||
|
||||
|
||||
def test_all_ranks_ineligible_falls_back_to_declaration_order() -> None:
|
||||
"""Every V4 rank ineligible → declaration-order first eligible contract wins."""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["transform_table"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts, [_Cand("F_RANK1")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_DECL_FIRST"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is False
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "no_v4_rank_eligible"
|
||||
|
||||
|
||||
def test_empty_v4_evidence_falls_back_to_declaration_order() -> None:
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
chosen, trace = _select_frame_v4_aware([_text_obj()], contracts, [])
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_DECL_FIRST"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] is None
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
|
||||
|
||||
def test_none_v4_evidence_falls_back_to_declaration_order() -> None:
|
||||
contracts = [_contract("F_DECL_FIRST", "1", ["text_block"])]
|
||||
chosen, trace = _select_frame_v4_aware([_text_obj()], contracts, None)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_DECL_FIRST"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
|
||||
|
||||
# ─── Frame_id matching ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_v4_match_by_frame_id_when_template_id_empty() -> None:
|
||||
"""V4 candidate without template_id but with frame_id matches by str-coerced frame_id."""
|
||||
contracts = [_contract("F_RANK1", "1171281190", ["text_block"])]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand(template_id="", frame_id="1171281190")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK1"
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
|
||||
|
||||
# ─── template_id-first precedence (Codex r1 regression) ──────────
|
||||
|
||||
|
||||
def test_template_id_match_wins_over_frame_id_match_on_earlier_contract() -> None:
|
||||
"""V4 candidate with both ``template_id`` and ``frame_id`` set —
|
||||
a later contract whose ``template_id`` matches must win over an
|
||||
earlier contract whose ``frame_id`` matches.
|
||||
|
||||
Regression for Codex r1 verification (#95 u2):
|
||||
a single-pass-per-contract loop would return the earlier
|
||||
``frame_id`` match because that contract is encountered first.
|
||||
The two-pass implementation (template_id across ALL contracts,
|
||||
then frame_id) keeps the documented precedence.
|
||||
"""
|
||||
contracts = [
|
||||
_contract("FRAME_ID_MATCH_EARLY", "FRAME_LOSE", ["text_block"]),
|
||||
_contract("TEMPLATE_WIN", "OTHER_FID", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand(template_id="TEMPLATE_WIN", frame_id="FRAME_LOSE")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "TEMPLATE_WIN"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
|
||||
|
||||
def test_frame_id_match_used_only_when_template_id_unmatched() -> None:
|
||||
"""When the candidate's ``template_id`` is not present in any
|
||||
contract, the second pass falls through to ``frame_id`` match."""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_OTHER", "1171281190", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand(template_id="TEMPLATE_NOT_DEFINED", frame_id="1171281190")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_OTHER"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
|
||||
|
||||
# ─── Hard "no eligible contract" path ─────────────────────────────
|
||||
|
||||
|
||||
def test_no_contract_covers_content_types_returns_none() -> None:
|
||||
"""Declaration-order fallback finds no eligible contract → returns None."""
|
||||
contracts = [_contract("F_ONLY", "1", ["transform_table"])]
|
||||
chosen, trace = _select_frame_v4_aware([_text_obj()], contracts, [])
|
||||
assert chosen is None
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "v4_evidence_empty"
|
||||
|
||||
|
||||
# ─── Legacy selector untouched ────────────────────────────────────
|
||||
|
||||
|
||||
def test_legacy_select_frame_signature_preserved() -> None:
|
||||
"""``_select_frame`` keeps the original (objects, contracts) → Optional[dict] contract."""
|
||||
contracts = [
|
||||
_contract("F_FIRST", "1", ["text_block"]),
|
||||
_contract("F_SECOND", "2", ["text_block"]),
|
||||
]
|
||||
legacy_choice = _select_frame([_text_obj()], contracts)
|
||||
assert legacy_choice is not None
|
||||
assert legacy_choice["template_id"] == "F_FIRST"
|
||||
|
||||
|
||||
# ─── IMP-95 u7 — Selector coverage expansion ─────────────────────
|
||||
#
|
||||
# u7 scope-lock : additive tests ONLY against the existing
|
||||
# ``_select_frame_v4_aware`` selector (planner u2 implementation,
|
||||
# augmented by u6's ``partial_exists`` kwarg). u7 does NOT touch
|
||||
# plan_placement, Step 11 wiring, gatekeeper, partial precheck, or
|
||||
# any regression fixture — those belong to u3/u4/u5/u6/u8/u9/u10/u11.
|
||||
#
|
||||
# Strengthens the four Stage 2 A13 axes (rank-1, rank-2 fallthrough,
|
||||
# all-ineligible, empty evidence) plus structural invariants the
|
||||
# pipeline-trace tests (u4) and gatekeeper tests (u5) rely on :
|
||||
#
|
||||
# 1. Multi-rank (rank-3) fallthrough — u2 only proved rank-2.
|
||||
# 2. First-eligible-rank stops iteration — V4 must not "search" past
|
||||
# the first hit (a future regression here would silently re-order
|
||||
# rank ties).
|
||||
# 3. Trace-shape invariant — every selector return path (V4 hit,
|
||||
# V4 fallthrough, empty evidence, no-eligible-contract) MUST emit
|
||||
# all six u1 TRACE_KEY_* keys. u4 hoists these onto placement_trace
|
||||
# verbatim; a drift would silently strip top-level keys.
|
||||
# 4. Multi-content-type ⊇ preserved — V4 must not relax the
|
||||
# legacy semantics for text+transform unions.
|
||||
# 5. Duck-type defense — candidates missing both ``template_id`` and
|
||||
# ``frame_id`` are silently skipped (matches u2 implementation,
|
||||
# composition.py:678-684 contract).
|
||||
# 6. Empty frame_contracts edge — V4 evidence supplied but no
|
||||
# contracts → ``no_v4_rank_eligible`` fallback, not crash.
|
||||
# 7. Selector path independent of env flag — the helper itself does
|
||||
# not consult ``PHASE_Z_B4_V4_EVIDENCE``; gating belongs to
|
||||
# ``plan_placement`` (Stage 2 A8 flag-independence).
|
||||
|
||||
|
||||
def _transform_obj(oid: str = "tr1") -> ContentObject:
|
||||
"""transform_table ContentObject for multi-content-type ⊇ tests."""
|
||||
return ContentObject(
|
||||
id=oid,
|
||||
type="transform_table",
|
||||
role="summary",
|
||||
raw_payload="| AS-IS | ➜ | TO-BE |",
|
||||
size_estimate={"rows": 1},
|
||||
type_specific={
|
||||
"pair_count": 1,
|
||||
"arrow_glyph": "➜",
|
||||
"rows": [{"from": "a", "arrow": "➜", "to": "b"}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_TRACE_KEYS_EXPECTED = frozenset({
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED,
|
||||
TRACE_KEY_V4_RANK_USED,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON,
|
||||
"b4_partial_missing_skip",
|
||||
})
|
||||
|
||||
|
||||
def test_rank3_wins_when_ranks_1_and_2_ineligible() -> None:
|
||||
"""Multi-rank fallthrough — rank-1 + rank-2 both ineligible, rank-3 wins.
|
||||
|
||||
u2 only covered rank-2 fallthrough; the loop must continue past
|
||||
arbitrarily many ineligible ranks until the first eligible match.
|
||||
"""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["transform_table"]),
|
||||
_contract("F_RANK2", "3", ["transform_table"]),
|
||||
_contract("F_RANK3", "4", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand("F_RANK1"), _Cand("F_RANK2"), _Cand("F_RANK3")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK3"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 2
|
||||
assert trace[TRACE_KEY_V4_B4_FRAME_MATCH] is True
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] is None
|
||||
|
||||
|
||||
def test_first_eligible_rank_stops_iteration() -> None:
|
||||
"""V4 must stop at the FIRST eligible rank — subsequent ranks ignored.
|
||||
|
||||
If a future change accidentally iterated past the first match
|
||||
(e.g. to "score" all ranks), rank-2 here would silently override
|
||||
rank-1 and final.html SHA parity would drift.
|
||||
"""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
_contract("F_RANK2", "3", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand("F_RANK1"), _Cand("F_RANK2")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK1"
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 0
|
||||
|
||||
|
||||
def test_trace_shape_invariant_all_six_keys_present_on_every_return_path() -> None:
|
||||
"""Every selector return path emits all six u1 trace keys.
|
||||
|
||||
u4 hoists these keys onto top-level placement_trace; a drift here
|
||||
would silently strip pipeline-trace fields. Cover the four
|
||||
structurally distinct return paths:
|
||||
(a) V4 rank-1 match,
|
||||
(b) V4 fallthrough → declaration-order fallback,
|
||||
(c) empty V4 evidence → declaration-order fallback,
|
||||
(d) no contract covers content_types → (None, trace).
|
||||
"""
|
||||
text_objs = [_text_obj()]
|
||||
eligible_contracts = [_contract("F_OK", "1", ["text_block"])]
|
||||
ineligible_contracts = [_contract("F_NOPE", "1", ["transform_table"])]
|
||||
|
||||
paths: list[tuple[object, dict]] = [
|
||||
_select_frame_v4_aware(text_objs, eligible_contracts, [_Cand("F_OK")]),
|
||||
_select_frame_v4_aware(text_objs, eligible_contracts, [_Cand("F_MISS")]),
|
||||
_select_frame_v4_aware(text_objs, eligible_contracts, []),
|
||||
_select_frame_v4_aware(text_objs, ineligible_contracts, []),
|
||||
]
|
||||
|
||||
for _, trace in paths:
|
||||
assert isinstance(trace, dict)
|
||||
assert set(trace.keys()) >= _TRACE_KEYS_EXPECTED, (
|
||||
f"trace missing keys: {_TRACE_KEYS_EXPECTED - set(trace.keys())}"
|
||||
)
|
||||
assert isinstance(trace[TRACE_KEY_V4_EVIDENCE_CONSUMED], bool)
|
||||
assert isinstance(trace[TRACE_KEY_V4_B4_FRAME_MATCH], bool)
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] is None or isinstance(
|
||||
trace[TRACE_KEY_V4_RANK_USED], int
|
||||
)
|
||||
assert isinstance(trace["b4_partial_missing_skip"], list)
|
||||
|
||||
|
||||
def test_multi_content_type_supseteq_preserved_under_v4() -> None:
|
||||
"""text+transform union must still satisfy ⊇ on the V4-selected contract.
|
||||
|
||||
A contract accepting ``text_block`` only must NOT be returned for
|
||||
a content_type_set = {text_block, transform_table}, regardless of
|
||||
V4 rank. The selector falls through to the next rank.
|
||||
"""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block", "transform_table"]),
|
||||
_contract("F_RANK1_TEXT_ONLY", "2", ["text_block"]),
|
||||
_contract("F_RANK2_BOTH", "3", ["text_block", "transform_table"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj(), _transform_obj()], contracts,
|
||||
[_Cand("F_RANK1_TEXT_ONLY"), _Cand("F_RANK2_BOTH")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK2_BOTH"
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
|
||||
|
||||
def test_candidate_without_template_or_frame_id_skipped_to_next_rank() -> None:
|
||||
"""Duck-type defense — a candidate with neither identifier is silently
|
||||
skipped per the composition.py:678-684 contract."""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK2", "2", ["text_block"]),
|
||||
]
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts,
|
||||
[_Cand(template_id="", frame_id=""), _Cand("F_RANK2")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK2"
|
||||
assert trace[TRACE_KEY_V4_RANK_USED] == 1
|
||||
|
||||
|
||||
def test_empty_frame_contracts_with_v4_evidence_falls_back_no_v4_rank_eligible() -> None:
|
||||
"""V4 evidence supplied but no contracts → ``no_v4_rank_eligible`` fallback
|
||||
(NOT ``v4_evidence_empty``) and selector returns None without crashing."""
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], [], [_Cand("F_RANK1")],
|
||||
)
|
||||
assert chosen is None
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_DECLARATION_ORDER
|
||||
assert trace[TRACE_KEY_B4_V0_FALLBACK_REASON] == "no_v4_rank_eligible"
|
||||
assert trace[TRACE_KEY_V4_EVIDENCE_CONSUMED] is False
|
||||
|
||||
|
||||
def test_selector_path_independent_of_env_flag(monkeypatch) -> None:
|
||||
"""Selector helper must NOT consult ``PHASE_Z_B4_V4_EVIDENCE``.
|
||||
|
||||
Flag-gating belongs to ``plan_placement`` (Stage 2 A8 flag
|
||||
independence). Calling the selector directly with V4 evidence must
|
||||
use the V4 path regardless of the env flag — otherwise u4's
|
||||
pipeline-trace tests would silently flip behavior when the test
|
||||
environment leaks the flag.
|
||||
"""
|
||||
contracts = [
|
||||
_contract("F_DECL_FIRST", "1", ["text_block"]),
|
||||
_contract("F_RANK1", "2", ["text_block"]),
|
||||
]
|
||||
# Both flag OFF and ON must yield the same V4-driven outcome.
|
||||
for flag_value in ("", "1"):
|
||||
monkeypatch.setenv("PHASE_Z_B4_V4_EVIDENCE", flag_value)
|
||||
chosen, trace = _select_frame_v4_aware(
|
||||
[_text_obj()], contracts, [_Cand("F_RANK1")],
|
||||
)
|
||||
assert chosen is not None
|
||||
assert chosen["template_id"] == "F_RANK1"
|
||||
assert trace[TRACE_KEY_FRAME_SELECTION_BASIS] == FRAME_SELECTION_BASIS_V4_RANKED
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import plan_zone_ratio_retry
|
||||
|
||||
|
||||
_ROUTER_ACTIVE = {"router_active": True}
|
||||
|
||||
|
||||
def _classification(target_pos: str, excess_y: float) -> dict:
|
||||
return {
|
||||
"classifications": [
|
||||
{
|
||||
"proposed_action": "zone_ratio_retry",
|
||||
"zone_position": target_pos,
|
||||
"inputs": {"excess_y": excess_y},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _zone(position: str, height_px: int, min_height_px: int) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"height_px": height_px,
|
||||
"min_height_px": min_height_px,
|
||||
"composition_rationale": {"capacity_fit": {"fit_status": "ok"}},
|
||||
}
|
||||
|
||||
|
||||
def _overflow(*positions: str) -> dict:
|
||||
return {
|
||||
"zones": [
|
||||
{"position": p, "overflowed": False, "clipped_inner": False}
|
||||
for p in positions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_t28_5b_manual_target_is_blocked_and_geometry_unchanged():
|
||||
debug_zones = [
|
||||
_zone("left", height_px=300, min_height_px=180),
|
||||
_zone("right", height_px=420, min_height_px=200),
|
||||
]
|
||||
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow("right"),
|
||||
fit_classification=_classification("left", excess_y=40),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
override_zone_geometries={"left": {"x": 0, "y": 0, "w": 0.45, "h": 1}},
|
||||
)
|
||||
|
||||
assert plan["feasible"] is False
|
||||
assert plan["manual_target_blocked"] is True
|
||||
assert plan["manual_zone_positions"] == ["left"]
|
||||
assert plan["donor_candidates_considered"] == []
|
||||
assert plan["zones_after"] == {"left": 300, "right": 420}
|
||||
assert "manual target zone" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_t28_5b_manual_donor_is_excluded_but_auto_donor_can_resolve():
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=260, min_height_px=200),
|
||||
_zone("bottom", height_px=360, min_height_px=200),
|
||||
]
|
||||
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow("middle", "bottom"),
|
||||
fit_classification=_classification("top", excess_y=40),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
override_zone_geometries={"bottom": {"x": 0, "y": 0.5, "w": 1, "h": 0.5}},
|
||||
)
|
||||
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_added_px"] == 44
|
||||
assert plan["donor_zone_position"] == "middle"
|
||||
assert plan["donors_used"] == [
|
||||
{"position": "middle", "reduced_px": 44, "slack_before": 60, "slack_after": 16}
|
||||
]
|
||||
assert plan["zones_after"]["top"] == 344
|
||||
assert plan["zones_after"]["middle"] == 216
|
||||
assert plan["zones_after"]["bottom"] == 360
|
||||
|
||||
|
||||
def test_t28_5b_no_automatic_donor_escalates_without_using_manual_slack():
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=200, min_height_px=200),
|
||||
_zone("bottom", height_px=420, min_height_px=200),
|
||||
]
|
||||
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow("middle", "bottom"),
|
||||
fit_classification=_classification("top", excess_y=40),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
override_zone_geometries={"bottom": {"x": 0, "y": 0.5, "w": 1, "h": 0.5}},
|
||||
)
|
||||
|
||||
assert plan["feasible"] is False
|
||||
assert plan["donor_candidates_considered"] == []
|
||||
assert plan["zones_after"] == {"top": 300, "middle": 200, "bottom": 420}
|
||||
assert "no donor candidates eligible" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_t28_5b_same_input_same_decision():
|
||||
kwargs = {
|
||||
"debug_zones": [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=260, min_height_px=200),
|
||||
_zone("bottom", height_px=360, min_height_px=200),
|
||||
],
|
||||
"overflow": _overflow("middle", "bottom"),
|
||||
"fit_classification": _classification("top", excess_y=40),
|
||||
"router_decision": _ROUTER_ACTIVE,
|
||||
"override_zone_geometries": {"bottom": {"x": 0, "y": 0.5, "w": 1, "h": 0.5}},
|
||||
}
|
||||
|
||||
assert plan_zone_ratio_retry(**kwargs) == plan_zone_ratio_retry(**kwargs)
|
||||
@@ -59,6 +59,13 @@ def test_standalone_mode_explicit():
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_debug_marker_hidden_by_default():
|
||||
html = _render("auto")
|
||||
body = html.split("<body>", 1)[1]
|
||||
assert "phase-z2-marker" not in body
|
||||
assert "phase_z2 / mvp-1.5b" not in body
|
||||
|
||||
|
||||
def test_deterministic():
|
||||
assert _render("embedded") == _render("embedded")
|
||||
assert _render("auto") == _render("auto")
|
||||
|
||||
Reference in New Issue
Block a user