feat(#85): IMP catalog builder invariant + VP runtime gate (u1~u7)
- u1: BuilderMissingError(FitError) — narrow exception aligned with pipeline catch - u2: load_frame_contracts catalog invariant + VP skip + CatalogInvariantError - u3a: audit CLI I1~I3 (partial existence / declared builder / registry membership) - u3b: audit CLI I4 (slot_payload refs vs declared/generated payload keys) - u4: lookup_v4_candidates VP filter (lookup_v4_all_judgments raw telemetry untouched) - u5: catalog invariant regression coverage + temp non-VP failure fixtures - u6: mdx04 VP routing fixture tests (sw_dependency_four_problems excluded from live) - u7: tests/conftest.py env isolation + mdx03/mdx04/mdx05 subprocess smoke Targeted 74 PASS (12.31s). Full regression 1063 PASS (87.70s). Audit CLI clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,22 @@ class FitError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class BuilderMissingError(FitError):
|
||||
"""Contract.payload.builder ↔ PAYLOAD_BUILDERS registry mismatch.
|
||||
|
||||
FitError subclass — pipeline 의 기존 `except FitError` 경로가 그대로
|
||||
adapter_needed 로 라우팅 (mdx04 hard crash 차단, IMP-#85 u1).
|
||||
"""
|
||||
|
||||
|
||||
class CatalogInvariantError(Exception):
|
||||
"""Catalog ↔ runtime registry drift detected at load time.
|
||||
|
||||
Boot-time invariant violation (IMP-#85 u2). Distinct from FitError:
|
||||
runtime fallback 대상이 아니라 catalog wiring 결함 (fail-fast).
|
||||
"""
|
||||
|
||||
|
||||
# ─── Catalog loading ──────────────────────────────────────────────
|
||||
|
||||
_CATALOG_CACHE: dict | None = None
|
||||
@@ -50,7 +66,9 @@ _CATALOG_CACHE: dict | None = None
|
||||
def load_frame_contracts() -> dict:
|
||||
global _CATALOG_CACHE
|
||||
if _CATALOG_CACHE is None:
|
||||
_CATALOG_CACHE = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
catalog = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
_CATALOG_CACHE = catalog
|
||||
return _CATALOG_CACHE
|
||||
|
||||
|
||||
@@ -686,6 +704,50 @@ PAYLOAD_BUILDERS: dict[str, Callable] = {
|
||||
}
|
||||
|
||||
|
||||
# ─── Catalog builder invariant (IMP-#85 u2) ──────────────────────
|
||||
|
||||
def _check_catalog_builder_invariant(catalog: dict) -> None:
|
||||
"""Every non-`visual_pending` contract must declare a registered builder.
|
||||
|
||||
`visual_pending: true` contracts are scaffolding records whose builders
|
||||
are tracked as VP backlog (별 axis IMP-04b / #42) — skipped here so the
|
||||
catalog can keep declaring them without breaking boot.
|
||||
|
||||
Violations are aggregated and raised together so first-fix iteration sees
|
||||
the full drift surface, not just the first row.
|
||||
|
||||
Raises:
|
||||
CatalogInvariantError — when one or more live (non-VP) contracts
|
||||
either omit `payload.builder` or reference a name absent from
|
||||
`PAYLOAD_BUILDERS`.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if contract.get("visual_pending") is True:
|
||||
continue
|
||||
payload = contract.get("payload") or {}
|
||||
builder_name = payload.get("builder") if isinstance(payload, dict) else None
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) missing payload.builder."
|
||||
)
|
||||
continue
|
||||
if builder_name not in PAYLOAD_BUILDERS:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) references payload.builder="
|
||||
f"'{builder_name}' not in PAYLOAD_BUILDERS registry."
|
||||
)
|
||||
if violations:
|
||||
raise CatalogInvariantError(
|
||||
f"Catalog builder invariant violated "
|
||||
f"({len(violations)} non-VP contract(s)):\n - "
|
||||
+ "\n - ".join(violations)
|
||||
+ f"\nRegistered builders: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Generic mapper (single dispatch via builder) ────────────────
|
||||
|
||||
def _check_cardinality(contract: dict, units: list, section) -> None:
|
||||
@@ -843,13 +905,13 @@ def map_with_contract(section, contract: dict) -> dict:
|
||||
payload_spec = contract["payload"]
|
||||
builder_name = payload_spec.get("builder")
|
||||
if not builder_name:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' missing payload.builder. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
builder = PAYLOAD_BUILDERS.get(builder_name)
|
||||
if builder is None:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' references payload.builder="
|
||||
f"'{builder_name}' but PAYLOAD_BUILDERS has no such entry. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
|
||||
@@ -1099,6 +1099,20 @@ def lookup_v4_all_judgments(
|
||||
return out
|
||||
|
||||
|
||||
def _is_visual_pending(template_id: str) -> bool:
|
||||
"""IMP-#85 u4 — return True iff catalog marks contract as ``visual_pending``.
|
||||
|
||||
Data-driven from ``frame_contracts.yaml`` (no hard-coded frame allow-list).
|
||||
Used by ``lookup_v4_candidates`` to exclude VP frames from the live
|
||||
candidate set; ``lookup_v4_all_judgments`` raw telemetry stays untouched
|
||||
(Step 7-A axis preserves full 32-frame evidence for the frontend).
|
||||
"""
|
||||
contract = get_contract(template_id)
|
||||
if not isinstance(contract, dict):
|
||||
return False
|
||||
return contract.get("visual_pending") is True
|
||||
|
||||
|
||||
def lookup_v4_candidates(
|
||||
v4: dict,
|
||||
section_id: str,
|
||||
@@ -1112,6 +1126,7 @@ def lookup_v4_candidates(
|
||||
v4_candidates = [
|
||||
c for c in judgments_full32
|
||||
if c["label"] != "reject"
|
||||
and not visual_pending(c.template_id) # IMP-#85 u4
|
||||
][:max_n]
|
||||
|
||||
Returns:
|
||||
@@ -1123,6 +1138,11 @@ def lookup_v4_candidates(
|
||||
lookup_v4_match() (rank-1) 는 그대로. Step 6 의 plan_composition()
|
||||
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
|
||||
위한 새 entry point.
|
||||
|
||||
IMP-#85 u4 — visual_pending frames are excluded from the live candidate
|
||||
set (catalog scaffolding without registered builder would crash the
|
||||
mapper). lookup_v4_all_judgments raw telemetry is intentionally NOT
|
||||
gated here.
|
||||
"""
|
||||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||
@@ -1133,6 +1153,9 @@ def lookup_v4_candidates(
|
||||
for j in judgments:
|
||||
if j.get("label") == "reject":
|
||||
continue
|
||||
tid = j.get("template_id")
|
||||
if tid and _is_visual_pending(tid):
|
||||
continue
|
||||
candidates.append(_v4_match_from_judgment(section_id, j))
|
||||
if len(candidates) >= max_n:
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user