"""Phase Z family-template ↔ frame_contracts.yaml baseline invariant. #52 F-2 option (c) lock (2026-05-19) — locks active contracted family count at 11/11 with a WIP allowlist for the 2 untracked WIP family templates documented in `templates/phase_z2/families/_WIP_FILES.md`. Any drift after this point (new family file on disk without a contract entry, or a contract entry pointing to a missing file) fails fast in CI. IMP-04b (#42) extends the rule with a second exemption axis: catalog entries flagged `visual_pending: true` are contracted but have no family partial on disk yet (Track A/B VP frames). The invariant becomes: contracts == (disk - wip) ∪ vp where `vp ∩ disk == ∅` (VP entry with disk file = promotion overdue). References: - docs/architecture/INTEGRATION-AUDIT-01-REPORT.md §10.2 F-2 - docs/architecture/IMP-18-SVG-GAP-REPORT.md L28/L30/L51 - templates/phase_z2/families/_WIP_FILES.md (WIP allowlist source) - Gitea #52 (this reconciliation), #42 (promote/remove gate + VP axis) Pattern mirrors `tests/test_catalog_invariant.py` — fail fast with explicit diff message if family ↔ contract surfaces drift. """ from __future__ import annotations import re from pathlib import Path import yaml PROJECT_ROOT = Path(__file__).parent.parent FAMILIES_DIR = PROJECT_ROOT / "templates" / "phase_z2" / "families" CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml" WIP_DOC_PATH = FAMILIES_DIR / "_WIP_FILES.md" V4_EVIDENCE_PATH = PROJECT_ROOT / "tests" / "matching" / "v4_full32_result.yaml" def _load_contract_keys() -> set[str]: with CATALOG_PATH.open(encoding="utf-8") as f: catalog = yaml.safe_load(f) return {k for k, v in catalog.items() if isinstance(v, dict)} def _load_disk_family_stems() -> set[str]: return {p.stem for p in FAMILIES_DIR.glob("*.html")} def _load_wip_allowlist() -> set[str]: text = WIP_DOC_PATH.read_text(encoding="utf-8") return {m.group(1) for m in re.finditer(r"`([A-Za-z0-9_\-]+)\.html`", text)} def _load_v4_evidence_template_ids() -> set[str]: """Unique `template_id` values across all V4 full32 judgments. IMP-04b (#42) closure gate derives its 32-frame target from V4 evidence rather than a hardcoded count, so future Track C additions extend both surfaces in lockstep. """ with V4_EVIDENCE_PATH.open(encoding="utf-8") as f: v4 = yaml.safe_load(f) return { j["template_id"] for sec in v4["mdx_sections"].values() for j in sec.get("judgments_full32", []) if "template_id" in j } def _load_vp_exempt_keys() -> set[str]: """Catalog entries flagged `visual_pending: true` (IMP-04b / #42). VP = contracted but no family partial on disk yet (Track A/B VP frames). Exempt from the disk-family existence check until the partial is authored. """ with CATALOG_PATH.open(encoding="utf-8") as f: catalog = yaml.safe_load(f) return { k for k, v in catalog.items() if isinstance(v, dict) and v.get("visual_pending") is True } def test_contracts_set_equals_disk_families_minus_wip(): """`frame_contracts.yaml` keys ↔ (disk family stems − WIP) ∪ VP-exempt.""" contracts = _load_contract_keys() disk = _load_disk_family_stems() wip = _load_wip_allowlist() vp = _load_vp_exempt_keys() expected = disk - wip missing = expected - contracts extra = (contracts - vp) - expected assert not missing, ( f"Family files on disk without frame_contracts.yaml entry " f"(and not in _WIP_FILES.md): {sorted(missing)}. " "Add a contract entry, or list the file in " "templates/phase_z2/families/_WIP_FILES.md as WIP." ) assert not extra, ( f"frame_contracts.yaml has entries with no matching family file " f"(and not flagged `visual_pending: true`): {sorted(extra)}." ) def test_wip_allowlist_is_disk_only_and_uncontracted(): """WIP allowlist names must exist on disk AND have no contract entry.""" contracts = _load_contract_keys() disk = _load_disk_family_stems() wip = _load_wip_allowlist() missing_on_disk = wip - disk leaked_into_contracts = wip & contracts assert not missing_on_disk, ( f"_WIP_FILES.md names files not on disk: {sorted(missing_on_disk)}." ) assert not leaked_into_contracts, ( f"_WIP_FILES.md names files that already have a contract entry: " f"{sorted(leaked_into_contracts)}. Promote via #42 instead — " "WIP allowlist must be disk-only / uncontracted." ) def test_vp_exempt_keys_are_contracted_and_disk_absent(): """VP-exempt keys: must be in catalog (by construction) AND must not have a family partial on disk (VP = pending partial authoring per #42).""" contracts = _load_contract_keys() disk = _load_disk_family_stems() vp = _load_vp_exempt_keys() leaked_outside_contracts = vp - contracts has_disk_partial = vp & disk assert not leaked_outside_contracts, ( f"VP-exempt keys not in catalog: {sorted(leaked_outside_contracts)}." ) assert not has_disk_partial, ( f"VP-exempt entries have a family partial on disk: " f"{sorted(has_disk_partial)}. Drop `visual_pending: true` from the " "catalog entry (authoring complete) instead of carrying the flag." ) def test_imp04b_closure_gate_v4_coverage_and_wip_empty(): """IMP-04b (#42) u24 closure gate: catalog set-equals V4 evidence + WIP==0. Locks the 32/32 frame coverage by comparing catalog top-level keys to `tests/matching/v4_full32_result.yaml` unique `template_id` values, and asserts the WIP allowlist is empty (both partials absorbed in u3/u4). """ contracts = _load_contract_keys() wip = _load_wip_allowlist() v4_template_ids = _load_v4_evidence_template_ids() missing = v4_template_ids - contracts extra = contracts - v4_template_ids assert not missing, ( f"IMP-04b closure gate: V4 template_ids not in frame_contracts.yaml: " f"{sorted(missing)}." ) assert not extra, ( f"IMP-04b closure gate: frame_contracts.yaml has entries outside " f"V4 evidence: {sorted(extra)}. Extend V4 evidence first or scope " "the addition under a follow-up IMP." ) assert not wip, ( f"IMP-04b closure gate: WIP allowlist must be empty: {sorted(wip)}." )