Extends frame_contracts.yaml from 11 to 32 contracts to match V4 evidence (tests/matching/v4_full32_result.yaml unique template_ids), closing the IMP-04b gap surfaced in IMP-04 (#4) Track A milestone. Scope (Stage 2 24-unit plan): - u3/u4: WIP partial absorb — app_sw_package_vs_solution (F23), pre_construction_model_info_stacked (F9). Both promoted from _WIP_FILES.md to frame_contracts.yaml. WIP allowlist now empty. - u5~u11: Track A 7 frames (index.html present, contract missing). - u12~u23: Track B 12 frames (visual_pending: true; family partial authoring deferred — contract-first per Stage 2 plan). - u24: BT closure gate. Adds test_imp04b_closure_gate_v4_coverage_and_wip_empty (catalog ↔ V4 set-equal + WIP==0) and test_vp_exempt_keys_are_contracted_and_disk_absent (vp ∩ disk == ∅). Relaxes test_contracts_set_equals_disk_families_minus_wip to (disk - wip) ∪ vp. 32 derived from V4 evidence YAML (no hardcoding). Closure facts (locked): contracts = 32, v4_unique = 32, missing = [], extra = [], wip_count = 0, vp_count = 19, vp ∩ disk = []. Guardrails honored: - No calculate_fit migration. - No AI/Kei API call in per-frame work. - No 1-2 sample hardcoding (Codex #7 generalization guardrail). - No production refactor for tests (IMP-32 owns helper extract). - figma_to_html / V4 / Phase Z 3-layer separation preserved. - 1 commit = 1 IMP-04b decision unit (bundled u1~u24 per Stage 2 plan; CAT+WIP atomicity for u3/u4 preserved). Tests: tests/test_family_contract_baseline.py 4/4 PASS. Cross-ref: IMP-04 (#4), IMP-29 (#38), IMP-30 (#39), IMP-31 (#40), IMP-32 (#41), IMP-33 (#61), IMP-47A (#75). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
6.3 KiB
Python
166 lines
6.3 KiB
Python
"""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)}."
|
||
)
|