diff --git a/core/cli.py b/core/cli.py index a978dcfa..cb292cd1 100644 --- a/core/cli.py +++ b/core/cli.py @@ -73,6 +73,12 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = { "packs": ( "tests/test_core_semantic_seed_pack.py", "tests/test_adr_0127_pack_ratification.py", + "tests/test_frame_registry_load.py", + "tests/test_composition_registry_load.py", + "tests/test_composition_consult_in_injector.py", + "tests/test_consumption_case_0050_hazard_pin.py", + "tests/test_consumption_empty_registry_no_op.py", + "tests/test_consumption_partition.py", ), "algebra": ( "tests/test_versor_closure.py", diff --git a/generate/comprehension/composition_registry.py b/generate/comprehension/composition_registry.py new file mode 100644 index 00000000..7b06414a --- /dev/null +++ b/generate/comprehension/composition_registry.py @@ -0,0 +1,274 @@ +"""CW-2 — runtime composition registry loader. + +Reads ``{pack}/compositions.jsonl`` (compiled by +:mod:`language_packs.compile_compositions`) and exposes a frozen lookup +surface for the recognizer/injector path. + +Structural twin of :mod:`generate.comprehension.frame_registry` plus +two extra guards required by ADR-0169 / ADR-0169.1: + +1. **Allowlist enforced at load** — any entry whose + ``composition_category`` falls outside + :data:`teaching.math_composition_proposal.SAFE_COMPOSITION_CATEGORIES` + raises :class:`WrongCompositionCategory`. Defense in depth: protects + against pack edits that bypass the handler's own enforcement. + +2. **Polarity respected** — ``polarity: "falsifies"`` entries are + loaded and exposed; consumers MUST suppress an injection that would + have fired without the entry. Silently treating ``falsifies`` as + ``affirms`` is a discipline violation. + +Trust boundary identical to FrameRegistry: read-only over reviewed pack; +no fallback to unsigned paths. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import types +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + +from teaching.math_composition_ratification import SAFE_COMPOSITION_CATEGORIES + + +class CompositionRegistryLoadError(ValueError): + """Raised on any load-time failure (checksum mismatch, malformed entry).""" + + +class WrongCompositionCategory(CompositionRegistryLoadError): + """Raised when a compiled entry carries a category outside the allowlist.""" + + +Polarity = Literal["affirms", "falsifies"] + + +@dataclass(frozen=True, slots=True) +class CompositionRegistryEntry: + surface_pattern: str + composition_category: str + polarity: Polarity + provenance: str + + +@dataclass(frozen=True, slots=True) +class CompositionRegistry: + by_pattern: Mapping[str, CompositionRegistryEntry] + by_category: Mapping[str, tuple[CompositionRegistryEntry, ...]] + pack_manifest_sha256: str + source_pack_id: str + + def is_empty(self) -> bool: + return not self.by_pattern + + def __hash__(self) -> int: + return hash((self.pack_manifest_sha256, self.source_pack_id)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CompositionRegistry): + return NotImplemented + return ( + dict(self.by_pattern) == dict(other.by_pattern) + and self.pack_manifest_sha256 == other.pack_manifest_sha256 + and self.source_pack_id == other.source_pack_id + ) + + +_CACHE: dict[tuple[str, int, str], CompositionRegistry] = {} + +_DEFAULT_PACK_RELPATH = Path("language_packs") / "data" / "en_core_math_v1" + +_VALID_POLARITIES: frozenset[str] = frozenset({"affirms", "falsifies"}) + + +def _repo_root() -> Path: + here = Path(__file__).resolve() + candidate = here + for _ in range(10): + candidate = candidate.parent + if (candidate / "pyproject.toml").exists() or (candidate / "setup.cfg").exists(): + return candidate + return here.parent.parent.parent + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _empty_registry(pack_id: str, manifest_sha256: str) -> CompositionRegistry: + return CompositionRegistry( + by_pattern=types.MappingProxyType({}), + by_category=types.MappingProxyType({}), + pack_manifest_sha256=manifest_sha256, + source_pack_id=pack_id, + ) + + +def load_composition_registry(pack_path: Path | None = None) -> CompositionRegistry: + """Load and cache the math pack's composition registry. + + When ``{pack}/compositions.jsonl`` is absent or empty, returns an + empty registry (no-op semantics). + + When the manifest declares ``composition_checksum`` it MUST match + the on-disk compiled bytes; mismatch raises + :class:`CompositionRegistryLoadError`. + + Any entry whose ``composition_category`` is outside + :data:`SAFE_COMPOSITION_CATEGORIES` raises + :class:`WrongCompositionCategory` (defense in depth). + + Any entry whose ``polarity`` is not in + ``{"affirms", "falsifies"}`` raises + :class:`CompositionRegistryLoadError`. + + Caches per (resolved_path, mtime_ns, sha256). + """ + resolved = ( + (pack_path if isinstance(pack_path, Path) else Path(pack_path)).resolve() + if pack_path is not None + else (_repo_root() / _DEFAULT_PACK_RELPATH).resolve() + ) + + manifest_path = resolved / "manifest.json" + compiled_path = resolved / "compositions.jsonl" + if not manifest_path.exists(): + raise CompositionRegistryLoadError(f"Pack manifest missing: {manifest_path}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + pack_id: str = manifest.get("pack_id", resolved.name) + declared_sha256 = manifest.get("composition_checksum") + + if not compiled_path.exists(): + if declared_sha256: + raise CompositionRegistryLoadError( + f"Manifest declares composition_checksum={declared_sha256!r} but " + f"compositions.jsonl is missing at {compiled_path}" + ) + return _empty_registry(pack_id, manifest.get("checksum", "")) + + compiled_bytes = compiled_path.read_bytes() + actual_sha256 = _sha256_bytes(compiled_bytes) + mtime_ns = os.stat(compiled_path).st_mtime_ns + cache_key = (str(resolved), mtime_ns, actual_sha256) + + cached = _CACHE.get(cache_key) + if cached is not None: + return cached + + if declared_sha256 and declared_sha256 != actual_sha256: + raise CompositionRegistryLoadError( + f"Composition checksum mismatch for {resolved!r}: " + f"declared={declared_sha256!r}, actual={actual_sha256!r}" + ) + + entries: list[CompositionRegistryEntry] = [] + if compiled_bytes: + for line in compiled_bytes.decode("utf-8").splitlines(): + line = line.strip() + if not line: + continue + rec = json.loads(line) + + category = rec.get("composition_category") + if category not in SAFE_COMPOSITION_CATEGORIES: + raise WrongCompositionCategory( + f"Composition entry has category {category!r} outside " + f"SAFE_COMPOSITION_CATEGORIES=" + f"{sorted(SAFE_COMPOSITION_CATEGORIES)!r}; " + "load-time allowlist enforced per ADR-0169 defense in depth" + ) + + polarity = rec.get("polarity") + if polarity not in _VALID_POLARITIES: + raise CompositionRegistryLoadError( + f"Composition entry has invalid polarity {polarity!r}; " + f"must be one of {sorted(_VALID_POLARITIES)!r}" + ) + + entries.append( + CompositionRegistryEntry( + surface_pattern=str(rec["surface_pattern"]), + composition_category=str(category), + polarity=polarity, + provenance=str(rec.get("provenance", "")), + ) + ) + + by_pattern_mut: dict[str, CompositionRegistryEntry] = {} + by_cat_mut: dict[str, list[CompositionRegistryEntry]] = {} + for entry in entries: + # Pattern identity is the case-sensitive surface pattern — these + # are structural shape strings, not natural-language surfaces. + key = entry.surface_pattern + existing = by_pattern_mut.get(key) + if existing is not None and ( + existing.composition_category != entry.composition_category + or existing.polarity != entry.polarity + ): + raise CompositionRegistryLoadError( + f"Composition pattern {key!r} carries conflicting entries: " + f"({existing.composition_category!r}, {existing.polarity!r}) vs " + f"({entry.composition_category!r}, {entry.polarity!r})" + ) + by_pattern_mut[key] = entry + by_cat_mut.setdefault(entry.composition_category, []).append(entry) + + by_category_frozen: dict[str, tuple[CompositionRegistryEntry, ...]] = { + cat: tuple(sorted(lst, key=lambda e: e.surface_pattern)) + for cat, lst in by_cat_mut.items() + } + + registry = CompositionRegistry( + by_pattern=types.MappingProxyType(by_pattern_mut), + by_category=types.MappingProxyType(by_category_frozen), + pack_manifest_sha256=actual_sha256, + source_pack_id=pack_id, + ) + _CACHE[cache_key] = registry + return registry + + +def lookup( + registry: CompositionRegistry, surface_pattern: str +) -> CompositionRegistryEntry | None: + """Exact-match lookup by surface pattern (case-sensitive, structural).""" + return registry.by_pattern.get(surface_pattern) + + +def is_affirmed(registry: CompositionRegistry, surface_pattern: str) -> bool: + """Return True only when an entry exists AND its polarity is ``affirms``. + + Convenience for consumer code: a falsifying entry returns False (the + consumer must suppress injection that would have fired anyway). + Absence also returns False (no opinion). + """ + entry = registry.by_pattern.get(surface_pattern) + return entry is not None and entry.polarity == "affirms" + + +def is_falsified(registry: CompositionRegistry, surface_pattern: str) -> bool: + """Return True only when an entry exists AND its polarity is ``falsifies``.""" + entry = registry.by_pattern.get(surface_pattern) + return entry is not None and entry.polarity == "falsifies" + + +def clear_cache() -> None: + """Test hook — drop the per-process cache.""" + _CACHE.clear() + + +__all__ = [ + "CompositionRegistry", + "CompositionRegistryEntry", + "CompositionRegistryLoadError", + "WrongCompositionCategory", + "Polarity", + "load_composition_registry", + "lookup", + "is_affirmed", + "is_falsified", + "clear_cache", +] diff --git a/generate/comprehension/frame_registry.py b/generate/comprehension/frame_registry.py new file mode 100644 index 00000000..b87c78da --- /dev/null +++ b/generate/comprehension/frame_registry.py @@ -0,0 +1,225 @@ +"""CW-1 — runtime frame registry loader. + +Reads ``{pack}/frames.jsonl`` (compiled by :mod:`language_packs.compile_frames`) +and exposes a frozen lookup surface for the comprehension reader's +frame-opener decision path. + +Mirrors :mod:`generate.comprehension.lexicon` structurally: + +- per-process cache keyed on ``(resolved_path, mtime_ns, sha256)`` +- manifest checksum verification when the manifest declares + ``frame_checksum``; backward-compatible when the field is absent +- empty / missing compiled artifact yields an empty registry rather + than raising — preserves the no-op invariant when ``frames/`` carries + zero reviewed entries + +Trust boundary: read-only over the reviewed math pack; no engine_state +writes, no corpus mutation, no fallback to unsigned paths. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import types +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + + +class FrameRegistryLoadError(ValueError): + """Raised on any load-time failure (checksum mismatch, malformed entry).""" + + +Polarity = Literal["affirms", "falsifies"] + + +@dataclass(frozen=True, slots=True) +class FrameRegistryEntry: + surface_form: str + frame_category: str + polarity: Polarity + provenance: str + + +@dataclass(frozen=True, slots=True) +class FrameRegistry: + by_surface: Mapping[str, FrameRegistryEntry] + by_category: Mapping[str, tuple[FrameRegistryEntry, ...]] + pack_manifest_sha256: str + source_pack_id: str + + def is_empty(self) -> bool: + return not self.by_surface + + def __hash__(self) -> int: + return hash((self.pack_manifest_sha256, self.source_pack_id)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FrameRegistry): + return NotImplemented + return ( + dict(self.by_surface) == dict(other.by_surface) + and self.pack_manifest_sha256 == other.pack_manifest_sha256 + and self.source_pack_id == other.source_pack_id + ) + + +_CACHE: dict[tuple[str, int, str], FrameRegistry] = {} + +_DEFAULT_PACK_RELPATH = Path("language_packs") / "data" / "en_core_math_v1" + +_VALID_POLARITIES: frozenset[str] = frozenset({"affirms", "falsifies"}) + + +def _repo_root() -> Path: + here = Path(__file__).resolve() + candidate = here + for _ in range(10): + candidate = candidate.parent + if (candidate / "pyproject.toml").exists() or (candidate / "setup.cfg").exists(): + return candidate + return here.parent.parent.parent + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _empty_registry(pack_id: str, manifest_sha256: str) -> FrameRegistry: + return FrameRegistry( + by_surface=types.MappingProxyType({}), + by_category=types.MappingProxyType({}), + pack_manifest_sha256=manifest_sha256, + source_pack_id=pack_id, + ) + + +def load_frame_registry(pack_path: Path | None = None) -> FrameRegistry: + """Load and cache the math pack's frame registry. + + When ``{pack}/frames.jsonl`` is absent or empty, returns an empty + registry (no-op semantics). When the manifest declares a + ``frame_checksum`` it MUST match the on-disk compiled bytes; a + mismatch raises :class:`FrameRegistryLoadError`. + + Caches per (resolved_path, mtime_ns, sha256) — invalidated on any + byte-level edit to the compiled artifact. + """ + resolved = ( + (pack_path if isinstance(pack_path, Path) else Path(pack_path)).resolve() + if pack_path is not None + else (_repo_root() / _DEFAULT_PACK_RELPATH).resolve() + ) + + manifest_path = resolved / "manifest.json" + compiled_path = resolved / "frames.jsonl" + if not manifest_path.exists(): + raise FrameRegistryLoadError(f"Pack manifest missing: {manifest_path}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + pack_id: str = manifest.get("pack_id", resolved.name) + declared_sha256 = manifest.get("frame_checksum") + + if not compiled_path.exists(): + # Backward-compat: no compiled frames + no declared checksum is a + # legitimate empty registry. A declared checksum with no compiled + # file is a discipline violation. + if declared_sha256: + raise FrameRegistryLoadError( + f"Manifest declares frame_checksum={declared_sha256!r} but " + f"frames.jsonl is missing at {compiled_path}" + ) + return _empty_registry(pack_id, manifest.get("checksum", "")) + + compiled_bytes = compiled_path.read_bytes() + actual_sha256 = _sha256_bytes(compiled_bytes) + mtime_ns = os.stat(compiled_path).st_mtime_ns + cache_key = (str(resolved), mtime_ns, actual_sha256) + + cached = _CACHE.get(cache_key) + if cached is not None: + return cached + + if declared_sha256 and declared_sha256 != actual_sha256: + raise FrameRegistryLoadError( + f"Frame checksum mismatch for {resolved!r}: " + f"declared={declared_sha256!r}, actual={actual_sha256!r}" + ) + + entries: list[FrameRegistryEntry] = [] + if compiled_bytes: + for line in compiled_bytes.decode("utf-8").splitlines(): + line = line.strip() + if not line: + continue + rec = json.loads(line) + polarity = rec.get("polarity") + if polarity not in _VALID_POLARITIES: + raise FrameRegistryLoadError( + f"Frame entry has invalid polarity {polarity!r}; " + f"must be one of {sorted(_VALID_POLARITIES)!r}" + ) + entries.append( + FrameRegistryEntry( + surface_form=str(rec["surface_form"]), + frame_category=str(rec["frame_category"]), + polarity=polarity, + provenance=str(rec.get("provenance", "")), + ) + ) + + by_surface_mut: dict[str, FrameRegistryEntry] = {} + by_cat_mut: dict[str, list[FrameRegistryEntry]] = {} + for entry in entries: + key = entry.surface_form.lower() + # Conflict policy: the FIRST entry per (surface, category, polarity) + # wins; later identical entries are deduped. Conflicting polarities + # for the same surface form raise — the operator must resolve. + existing = by_surface_mut.get(key) + if existing is not None and ( + existing.frame_category != entry.frame_category + or existing.polarity != entry.polarity + ): + raise FrameRegistryLoadError( + f"Frame surface {key!r} carries conflicting entries: " + f"({existing.frame_category!r}, {existing.polarity!r}) vs " + f"({entry.frame_category!r}, {entry.polarity!r})" + ) + by_surface_mut[key] = entry + by_cat_mut.setdefault(entry.frame_category, []).append(entry) + + by_category_frozen: dict[str, tuple[FrameRegistryEntry, ...]] = { + cat: tuple(sorted(lst, key=lambda e: e.surface_form)) + for cat, lst in by_cat_mut.items() + } + + registry = FrameRegistry( + by_surface=types.MappingProxyType(by_surface_mut), + by_category=types.MappingProxyType(by_category_frozen), + pack_manifest_sha256=actual_sha256, + source_pack_id=pack_id, + ) + _CACHE[cache_key] = registry + return registry + + +def lookup(registry: FrameRegistry, surface: str) -> FrameRegistryEntry | None: + """Case-fold surface and return its entry, or None if unknown.""" + return registry.by_surface.get(surface.lower()) + + +def clear_cache() -> None: + """Test hook — drop the per-process cache.""" + _CACHE.clear() + + +__all__ = [ + "FrameRegistry", + "FrameRegistryEntry", + "FrameRegistryLoadError", + "Polarity", + "load_frame_registry", + "lookup", + "clear_cache", +] diff --git a/generate/recognizer_anchor_inject.py b/generate/recognizer_anchor_inject.py index b9d8e937..0dc7bb48 100644 --- a/generate/recognizer_anchor_inject.py +++ b/generate/recognizer_anchor_inject.py @@ -82,11 +82,105 @@ def inject_from_match( so per-category injectors can emit operations as well as initials. The v1 ``discrete_count_statement`` injector continues to emit only ``CandidateInitial`` — the widening is type-level only in this PR. + + CW-2 (ADR-0169 consumption) — when the per-category injector + returns empty AND the matcher published a ``composition_shape`` key + in ``parsed_anchors``, the composition registry is consulted: an + ``affirms`` entry under :data:`SAFE_COMPOSITION_CATEGORIES` admits + the composition; a ``falsifies`` entry continues to refuse; + absence continues to refuse. The composition path is read-only + over the reviewed math pack — it cannot weaken any existing + admission gate. See :mod:`generate.comprehension.composition_registry`. """ injector = _INJECTORS.get(match.category) - if injector is None: + if injector is not None: + emitted = injector(match, sentence) + if emitted: + return emitted + return _consult_composition_registry(match, sentence) + + +# --------------------------------------------------------------------------- +# CW-2 — composition registry consultation (ADR-0169 consumption) +# --------------------------------------------------------------------------- + + +def _consult_composition_registry( + match: RecognizerMatch, + sentence: str, +) -> tuple[InjectorEmission, ...]: + """Composition-registry consultation fallback for ``inject_from_match``. + + Contract (the contract a matcher extension must honor to enable + composition admission via this path): + + - ``match.parsed_anchors`` carries at least one anchor mapping with a + key ``"composition_shape"`` whose value is the surface pattern + string used by ratified composition registry entries (e.g. + ``"bound(count) × bound(unit_cost)"``). + - The same anchor carries a pre-composed payload the registry only + gates: either ``"composed_initial"`` (a fully-constructed + :class:`CandidateInitial`) or ``"composed_operation"`` (a + :class:`CandidateOperation`). This module does NOT perform + arithmetic — the matcher / matcher-extension owns the math; the + registry owns the admissibility decision. + + Semantics: + + - registry empty OR no entry for shape → return ``()`` (refusal-preferring) + - entry exists, polarity ``"affirms"`` → admit the pre-composed payload + - entry exists, polarity ``"falsifies"`` → return ``()`` (suppressed) + + This is a registry-driven *gate*, not a registry-driven arithmetic + primitive. Per ADR-0169 §"Mutation boundary" the registry never + rewrites solver / arithmetic semantics; it ratifies whether a + given structural shape may admit. + + No matcher currently publishes ``composition_shape`` — at land time + this path is dormant infrastructure. The case-0019 truth-test will + fire only after a matcher extension binds quantity-shape composition + anchors (out of scope for this PR; see follow-up brief). + """ + if not match.parsed_anchors: return () - return injector(match, sentence) + + # Lazy import — composition_registry import chain pulls + # SAFE_COMPOSITION_CATEGORIES from teaching/, and the load path may + # not be needed on every recognizer call. Module-level loader cache + # keeps the repeat-call cost at one dict hit after the first load. + from generate.comprehension.composition_registry import ( + is_affirmed, + is_falsified, + load_composition_registry, + ) + + registry = load_composition_registry() + if registry.is_empty(): + return () + + out: list[InjectorEmission] = [] + for anchor in match.parsed_anchors: + shape = anchor.get("composition_shape") if isinstance(anchor, Mapping) else None + if not isinstance(shape, str): + continue + if is_falsified(registry, shape): + # Falsifying entry — suppress any admission that would have + # fired from this anchor; refusal-preferring discipline. + return () + if not is_affirmed(registry, shape): + continue + composed_initial = anchor.get("composed_initial") + composed_operation = anchor.get("composed_operation") + if isinstance(composed_initial, CandidateInitial): + out.append(composed_initial) + elif isinstance(composed_operation, CandidateOperation): + out.append(composed_operation) + else: + # The registry affirms the shape but no pre-composed payload + # is attached — under-admit. The matcher owns producing the + # payload; we never invent arithmetic here. + return () + return tuple(out) # --------------------------------------------------------------------------- diff --git a/language_packs/compile_compositions.py b/language_packs/compile_compositions.py new file mode 100644 index 00000000..7f04f640 --- /dev/null +++ b/language_packs/compile_compositions.py @@ -0,0 +1,78 @@ +"""CW-2 — compile compositions/*.jsonl into a deterministic compositions.jsonl artifact. + +Mirrors :mod:`language_packs.compile_frames` for the composition surface. +Reads per-category source files under ``{pack}/compositions/*.jsonl`` and +writes ``{pack}/compositions.jsonl`` with entries sorted by +``(composition_category, surface_pattern)``. + +Trust boundary: read-only over the reviewed pack. The compile step does +**not** enforce the SAFE_COMPOSITION_CATEGORIES allowlist — that lives +at write time (handler) and re-fires at load time (registry). Compile +is byte-deterministic projection only. + +Empty source directory produces an empty (zero-byte) compiled artifact. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + + +def _canonical_entry(rec: dict[str, Any]) -> dict[str, Any]: + """Project an entry to its canonical-bytes shape. + + Required keys: surface_pattern, composition_category, polarity, + provenance, evidence_hashes. + """ + return { + "surface_pattern": str(rec["surface_pattern"]), + "composition_category": str(rec["composition_category"]), + "polarity": str(rec["polarity"]), + "provenance": str(rec.get("provenance", "")), + "evidence_hashes": [str(h) for h in rec.get("evidence_hashes", [])], + } + + +def compile_compositions(pack_path: Path) -> tuple[bytes, str]: + """Compile ``{pack_path}/compositions/*.jsonl`` → bytes + sha256. + + Writes ``{pack_path}/compositions.jsonl`` if the bytes differ. + Returns ``(compiled_bytes, sha256)`` so the caller may update the + pack manifest's ``composition_checksum``. + """ + source_dir = pack_path / "compositions" + entries: list[dict[str, Any]] = [] + if source_dir.is_dir(): + for src in sorted(source_dir.glob("*.jsonl")): + for line in src.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + entries.append(_canonical_entry(json.loads(line))) + + entries.sort(key=lambda e: (e["composition_category"], e["surface_pattern"])) + + if not entries: + compiled_bytes = b"" + else: + compiled_bytes = ( + "\n".join( + json.dumps(e, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + for e in entries + ) + + "\n" + ).encode("utf-8") + + out_path = pack_path / "compositions.jsonl" + existing = out_path.read_bytes() if out_path.exists() else None + if existing != compiled_bytes: + out_path.write_bytes(compiled_bytes) + + sha256 = hashlib.sha256(compiled_bytes).hexdigest() + return compiled_bytes, sha256 + + +__all__ = ["compile_compositions"] diff --git a/language_packs/compile_frames.py b/language_packs/compile_frames.py new file mode 100644 index 00000000..906fc5fb --- /dev/null +++ b/language_packs/compile_frames.py @@ -0,0 +1,86 @@ +"""CW-1 — compile frames/*.jsonl into a deterministic frames.jsonl artifact. + +Mirrors the lexicon compile pattern (see ``language_packs/compiler.py``). +Reads per-category source files under ``{pack}/frames/*.jsonl``, normalizes +ordering, and writes the compiled artifact ``{pack}/frames.jsonl`` with +entries sorted by ``(frame_category, surface_form)``. + +Returns the sha256 of the compiled bytes so callers (typically the +manifest writer) can pin ``frame_checksum`` per CLAUDE.md +"Semantic Pack Discipline". + +Trust boundary: read-only over the reviewed pack; no engine_state writes, +no corpus mutation. Empty source directory produces an empty (zero-byte) +compiled artifact — the loader treats this as a no-op. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + + +def _canonical_entry(rec: dict[str, Any]) -> dict[str, Any]: + """Project the entry to its canonical-bytes shape. + + Drops unknown keys to keep the compiled artifact byte-stable across + upstream schema additions. Required keys: surface_form, frame_category, + polarity, provenance, evidence_hashes. + """ + return { + "surface_form": str(rec["surface_form"]), + "frame_category": str(rec["frame_category"]), + "polarity": str(rec["polarity"]), + "provenance": str(rec.get("provenance", "")), + "evidence_hashes": [str(h) for h in rec.get("evidence_hashes", [])], + } + + +def compile_frames(pack_path: Path) -> tuple[bytes, str]: + """Compile ``{pack_path}/frames/*.jsonl`` → bytes + sha256. + + The compiled bytes are written to ``{pack_path}/frames.jsonl`` if + they differ from the existing file (or it does not exist). The + sha256 is returned so the caller may update the pack manifest. + + Deterministic ordering: source files iterated alphabetically; entries + within each file are re-sorted globally by + ``(frame_category, surface_form)`` before write. + + Empty source directory (or missing) → zero-byte artifact. + """ + source_dir = pack_path / "frames" + entries: list[dict[str, Any]] = [] + if source_dir.is_dir(): + for src in sorted(source_dir.glob("*.jsonl")): + for line in src.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + entries.append(_canonical_entry(json.loads(line))) + + entries.sort(key=lambda e: (e["frame_category"], e["surface_form"])) + + if not entries: + compiled_bytes = b"" + else: + compiled_bytes = ( + "\n".join( + json.dumps(e, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + for e in entries + ) + + "\n" + ).encode("utf-8") + + out_path = pack_path / "frames.jsonl" + existing = out_path.read_bytes() if out_path.exists() else None + if existing != compiled_bytes: + out_path.write_bytes(compiled_bytes) + + sha256 = hashlib.sha256(compiled_bytes).hexdigest() + return compiled_bytes, sha256 + + +__all__ = ["compile_frames"] diff --git a/language_packs/compiler.py b/language_packs/compiler.py index ea1dd237..2e3a3278 100644 --- a/language_packs/compiler.py +++ b/language_packs/compiler.py @@ -462,6 +462,29 @@ def _load_pack_cached(pack_id: str) -> tuple[LanguagePackManifest, VocabManifold f"{actual} != {expected_glosses_checksum}" ) + # ADR-0168 + ADR-0169 consumption — frame_checksum / composition_checksum. + # Same backward-compatible pattern as glosses_checksum: missing field + + # missing compiled file is the empty-registry no-op path. Declared + # checksum with no compiled file is a discipline violation. + for kind, manifest_key in ( + ("frames", "frame_checksum"), + ("compositions", "composition_checksum"), + ): + expected = manifest_payload.get(manifest_key) + compiled_path = pack_dir / f"{kind}.jsonl" + if expected is not None: + if not compiled_path.exists(): + raise ValueError( + f"Manifest for {pack_id} declares {manifest_key} but " + f"{kind}.jsonl is missing at {compiled_path}" + ) + actual = hashlib.sha256(compiled_path.read_bytes()).hexdigest() + if actual != expected: + raise ValueError( + f"{kind.capitalize()} checksum mismatch for {pack_id}: " + f"{actual} != {expected}" + ) + entries = load_pack_entries(pack_id) morphology_registry = None if any(entry.morphology_id for entry in entries): diff --git a/tests/test_composition_consult_in_injector.py b/tests/test_composition_consult_in_injector.py new file mode 100644 index 00000000..fe61846e --- /dev/null +++ b/tests/test_composition_consult_in_injector.py @@ -0,0 +1,195 @@ +"""CW-2 — inject_from_match composition-registry consultation tests. + +Verifies the contract of ``_consult_composition_registry``: + +- empty registry → fallback is a no-op (no admission) +- matcher publishes ``composition_shape`` + ``composed_initial``, + registry affirms → inject_from_match emits the composed payload +- matcher publishes the shape, registry falsifies → suppressed +- matcher publishes the shape, registry absent → refusal-preferring +- matcher publishes the shape AND registry affirms BUT no + ``composed_initial`` payload → under-admit (registry never invents + arithmetic; ADR-0169 §"Mutation boundary") + +These tests are wiring-level — they verify the consumer is reachable +from the production code path. They do not exercise the full +candidate-graph pipeline (covered by the eval-delta truth test once a +matcher extension publishes ``composition_shape``). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from evals.refusal_taxonomy.shape_categories import ShapeCategory +from generate.comprehension.composition_registry import ( + clear_cache as clear_composition_cache, +) +from generate.math_candidate_parser import CandidateInitial +from generate.math_problem_graph import InitialPossession, Quantity +from generate.recognizer_anchor_inject import inject_from_match +from generate.recognizer_match import RecognizerMatch +from language_packs.compile_compositions import compile_compositions + + +_SHAPE = "bound(count) × bound(unit_cost)" + + +def _build_candidate_initial() -> CandidateInitial: + return CandidateInitial( + initial=InitialPossession( + entity="John", + quantity=Quantity(value=1200, unit="dollars"), + ), + source_span="3 vet appointments cost $400 each", + matched_anchor="has", + matched_value_token="1200", + matched_unit_token="dollars", + matched_entity_token="John", + ) + + +class _FakeRecognizer: + """Test double — RecognizerMatch only stores the recognizer reference.""" + + spec_id = "test_spec" + + +def _build_match(anchor: dict[str, Any]) -> RecognizerMatch: + return RecognizerMatch( + recognizer=_FakeRecognizer(), # type: ignore[arg-type] + category=ShapeCategory.CURRENCY_AMOUNT, + outcome="admissible", + graph_intent="amount", + parsed_anchors=(anchor,), + ) + + +def _write_registry( + pack_path: Path, + surface_pattern: str, + polarity: str, + category: str = "multiplicative_composition", +) -> None: + comp_dir = pack_path / "compositions" + comp_dir.mkdir(parents=True, exist_ok=True) + (comp_dir / f"{category}.jsonl").write_text( + json.dumps( + { + "surface_pattern": surface_pattern, + "composition_category": category, + "polarity": polarity, + "provenance": "test_provenance", + "evidence_hashes": [], + } + ) + + "\n", + encoding="utf-8", + ) + _, sha = compile_compositions(pack_path) + (pack_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "en_core_math_v1", # use canonical pack id + "checksum": "deadbeef", + "composition_checksum": sha, + } + ), + encoding="utf-8", + ) + + +def setup_function(_): + clear_composition_cache() + + +def _patch_pack_root(monkeypatch: pytest.MonkeyPatch, pack_path: Path) -> None: + """Redirect the composition registry's default pack-root to our fixture.""" + from generate.comprehension import composition_registry as cr + + monkeypatch.setattr(cr, "_DEFAULT_PACK_RELPATH", pack_path) + monkeypatch.setattr(cr, "_repo_root", lambda: Path("/")) + + +def test_no_composition_shape_returns_empty(monkeypatch, tmp_path): + _patch_pack_root(monkeypatch, tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "en_core_math_v1", "checksum": "x"}), + encoding="utf-8", + ) + match = _build_match({"kind": "currency_amount"}) # no composition_shape + result = inject_from_match(match, "sentence") + assert result == () + + +def test_affirms_admits_pre_composed_initial(monkeypatch, tmp_path): + _write_registry(tmp_path, _SHAPE, "affirms") + _patch_pack_root(monkeypatch, tmp_path) + composed = _build_candidate_initial() + match = _build_match( + { + "composition_shape": _SHAPE, + "composed_initial": composed, + } + ) + result = inject_from_match(match, "sentence") + assert result == (composed,) + + +def test_falsifies_suppresses(monkeypatch, tmp_path): + _write_registry(tmp_path, _SHAPE, "falsifies") + _patch_pack_root(monkeypatch, tmp_path) + composed = _build_candidate_initial() + match = _build_match( + { + "composition_shape": _SHAPE, + "composed_initial": composed, + } + ) + result = inject_from_match(match, "sentence") + assert result == () + + +def test_absent_in_registry_refuses(monkeypatch, tmp_path): + _write_registry(tmp_path, "different_shape", "affirms") + _patch_pack_root(monkeypatch, tmp_path) + composed = _build_candidate_initial() + match = _build_match( + { + "composition_shape": _SHAPE, # not in registry + "composed_initial": composed, + } + ) + result = inject_from_match(match, "sentence") + assert result == () + + +def test_affirms_without_composed_payload_under_admits(monkeypatch, tmp_path): + """Registry never invents arithmetic — affirms + no payload = ().""" + _write_registry(tmp_path, _SHAPE, "affirms") + _patch_pack_root(monkeypatch, tmp_path) + match = _build_match({"composition_shape": _SHAPE}) # no composed_initial + result = inject_from_match(match, "sentence") + assert result == () + + +def test_empty_registry_is_noop(monkeypatch, tmp_path): + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "en_core_math_v1", "checksum": "x"}), + encoding="utf-8", + ) + _patch_pack_root(monkeypatch, tmp_path) + composed = _build_candidate_initial() + match = _build_match( + { + "composition_shape": _SHAPE, + "composed_initial": composed, + } + ) + # Empty registry — even with a fully-populated anchor, no admission. + result = inject_from_match(match, "sentence") + assert result == () diff --git a/tests/test_composition_registry_load.py b/tests/test_composition_registry_load.py new file mode 100644 index 00000000..5a178dc2 --- /dev/null +++ b/tests/test_composition_registry_load.py @@ -0,0 +1,266 @@ +"""CW-2 — composition_registry loader tests. + +Covers: empty no-op, round-trip, deterministic order, manifest mismatch, +declared-without-file, cache, byte-stability, allowlist enforcement +(defense-in-depth), polarity validation, conflict detection, +``is_affirmed`` / ``is_falsified`` semantics. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from generate.comprehension.composition_registry import ( + CompositionRegistry, + CompositionRegistryLoadError, + WrongCompositionCategory, + clear_cache, + is_affirmed, + is_falsified, + load_composition_registry, + lookup, +) +from language_packs.compile_compositions import compile_compositions +from teaching.math_composition_ratification import SAFE_COMPOSITION_CATEGORIES + + +def _write_entry( + path: Path, + surface_pattern: str, + composition_category: str, + polarity: str = "affirms", + provenance: str = "test_provenance", +) -> None: + with path.open("a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "surface_pattern": surface_pattern, + "composition_category": composition_category, + "polarity": polarity, + "provenance": provenance, + "evidence_hashes": [], + } + ) + + "\n" + ) + + +@pytest.fixture +def empty_pack(tmp_path: Path) -> Path: + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "test_empty", "checksum": "deadbeef"}), + encoding="utf-8", + ) + return tmp_path + + +@pytest.fixture +def populated_pack(tmp_path: Path) -> Path: + comp_dir = tmp_path / "compositions" + comp_dir.mkdir() + _write_entry( + comp_dir / "multiplicative.jsonl", + "bound(count) × bound(unit_cost)", + "multiplicative_composition", + polarity="affirms", + ) + _write_entry( + comp_dir / "additive.jsonl", + "bound(qty_a) + bound(qty_b)", + "additive_composition", + polarity="affirms", + ) + _, sha = compile_compositions(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "test_populated", + "checksum": "deadbeef", + "composition_checksum": sha, + } + ), + encoding="utf-8", + ) + return tmp_path + + +def setup_function(_): + clear_cache() + + +def test_empty_pack_returns_empty_registry(empty_pack: Path): + reg = load_composition_registry(empty_pack) + assert isinstance(reg, CompositionRegistry) + assert reg.is_empty() + + +def test_populated_round_trip(populated_pack: Path): + reg = load_composition_registry(populated_pack) + assert not reg.is_empty() + entry = lookup(reg, "bound(count) × bound(unit_cost)") + assert entry is not None + assert entry.composition_category == "multiplicative_composition" + assert entry.polarity == "affirms" + + +def test_safe_categories_pinned(): + # Defense-in-depth: the load-time allowlist matches what the handler + # enforces at write time. If the handler's set ever drifts, this + # test fails — operator must update both surfaces consciously. + assert SAFE_COMPOSITION_CATEGORIES == frozenset( + { + "multiplicative_composition", + "additive_composition", + "subtractive_composition", + } + ) + + +def test_unsafe_category_at_load_raises(tmp_path: Path): + comp_dir = tmp_path / "compositions" + comp_dir.mkdir() + _write_entry( + comp_dir / "bad.jsonl", + "bound(a) ratio bound(b)", + "ratio_composition", # explicitly deferred per ADR-0169 + ) + _, sha = compile_compositions(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "test", + "checksum": "x", + "composition_checksum": sha, + } + ), + encoding="utf-8", + ) + with pytest.raises(WrongCompositionCategory, match="ratio_composition"): + load_composition_registry(tmp_path) + + +def test_invalid_polarity_raises(tmp_path: Path): + comp_dir = tmp_path / "compositions" + comp_dir.mkdir() + _write_entry( + comp_dir / "bad.jsonl", + "x", + "multiplicative_composition", + polarity="maybe", + ) + _, sha = compile_compositions(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "test", + "checksum": "x", + "composition_checksum": sha, + } + ), + encoding="utf-8", + ) + with pytest.raises(CompositionRegistryLoadError, match="invalid polarity"): + load_composition_registry(tmp_path) + + +def test_is_affirmed_and_is_falsified(tmp_path: Path): + comp_dir = tmp_path / "compositions" + comp_dir.mkdir() + _write_entry( + comp_dir / "ok.jsonl", + "shape_a", + "multiplicative_composition", + polarity="affirms", + ) + _write_entry( + comp_dir / "ok.jsonl", + "shape_b", + "multiplicative_composition", + polarity="falsifies", + ) + _, sha = compile_compositions(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "t", "checksum": "x", "composition_checksum": sha}), + encoding="utf-8", + ) + reg = load_composition_registry(tmp_path) + assert is_affirmed(reg, "shape_a") is True + assert is_falsified(reg, "shape_a") is False + assert is_affirmed(reg, "shape_b") is False + assert is_falsified(reg, "shape_b") is True + # Absence + assert is_affirmed(reg, "shape_unknown") is False + assert is_falsified(reg, "shape_unknown") is False + + +def test_manifest_mismatch_raises(populated_pack: Path): + manifest_path = populated_pack / "manifest.json" + payload = json.loads(manifest_path.read_text()) + payload["composition_checksum"] = "f" * 64 + manifest_path.write_text(json.dumps(payload), encoding="utf-8") + clear_cache() + with pytest.raises(CompositionRegistryLoadError, match="checksum mismatch"): + load_composition_registry(populated_pack) + + +def test_declared_without_file_raises(tmp_path: Path): + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "test", + "checksum": "x", + "composition_checksum": "f" * 64, + } + ), + encoding="utf-8", + ) + with pytest.raises(CompositionRegistryLoadError, match="missing"): + load_composition_registry(tmp_path) + + +def test_compile_byte_stable(tmp_path: Path): + comp_dir = tmp_path / "compositions" + comp_dir.mkdir() + _write_entry( + comp_dir / "a.jsonl", + "bound(count) × bound(unit_cost)", + "multiplicative_composition", + ) + b1, s1 = compile_compositions(tmp_path) + b2, s2 = compile_compositions(tmp_path) + assert b1 == b2 + assert s1 == s2 + + +def test_cache_returns_same_instance(populated_pack: Path): + reg1 = load_composition_registry(populated_pack) + reg2 = load_composition_registry(populated_pack) + assert reg1 is reg2 + + +def test_conflicting_pattern_polarities_raise(tmp_path: Path): + comp_dir = tmp_path / "compositions" + comp_dir.mkdir() + _write_entry( + comp_dir / "a.jsonl", + "same_pattern", + "multiplicative_composition", + polarity="affirms", + ) + _write_entry( + comp_dir / "b.jsonl", + "same_pattern", + "multiplicative_composition", + polarity="falsifies", + ) + _, sha = compile_compositions(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "t", "checksum": "x", "composition_checksum": sha}), + encoding="utf-8", + ) + with pytest.raises(CompositionRegistryLoadError, match="conflicting entries"): + load_composition_registry(tmp_path) diff --git a/tests/test_consumption_case_0050_hazard_pin.py b/tests/test_consumption_case_0050_hazard_pin.py new file mode 100644 index 00000000..4899c0ec --- /dev/null +++ b/tests/test_consumption_case_0050_hazard_pin.py @@ -0,0 +1,123 @@ +"""Mandatory case 0050 hazard pin for the consumption wiring. + +ADR-0169 §"Acceptance gates": ratifying any synthetic CompositionClaim +under the SAFE allowlist must NOT cause case 0050 to admit. + +This test ratifies a synthetic CompositionClaim under each of the three +allowlist categories, in turn, and confirms that case 0050 (the canary +preventing pre_frame_filler "fixes" from drifting into wrong>0) remains +refused under each. + +Because the composition wiring goes through ``inject_from_match`` and +case 0050's refusal happens upstream (the regex parser already declines +to admit), this is a structural hazard pin: ratifying compositions +through the registry can never *enable* case 0050 because the +recognizer doesn't bind ``composition_shape`` for that sentence shape. +The pin verifies that invariant holds after this PR — i.e. the wiring +is dormant for case 0050 specifically. + +See [[feedback-wrong-zero-hazard-case-0050]]. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from generate.comprehension.composition_registry import ( + clear_cache as clear_composition_cache, +) +from language_packs.compile_compositions import compile_compositions +from teaching.math_composition_ratification import SAFE_COMPOSITION_CATEGORIES + + +def setup_function(_): + clear_composition_cache() + + +def _stage_pack(tmp_path: Path) -> Path: + """Materialize a clean copy of the en_core_math_v1 pack into tmp_path.""" + here = Path(__file__).resolve() + repo = here + while repo.parent != repo and not (repo / "pyproject.toml").exists(): + repo = repo.parent + src = repo / "language_packs" / "data" / "en_core_math_v1" + dst = tmp_path / "en_core_math_v1" + shutil.copytree(src, dst) + # Strip any pre-existing composition entries — start clean. + comp_dir = dst / "compositions" + if comp_dir.exists(): + for f in comp_dir.glob("*.jsonl"): + f.unlink() + if (dst / "compositions.jsonl").exists(): + (dst / "compositions.jsonl").unlink() + return dst + + +@pytest.mark.parametrize("category", sorted(SAFE_COMPOSITION_CATEGORIES)) +def test_case_0050_stays_refused_under_each_allowlist_category( + monkeypatch, tmp_path: Path, category: str +): + """Ratifying any allowlisted CompositionClaim must not enable case 0050.""" + pack = _stage_pack(tmp_path) + comp_dir = pack / "compositions" + comp_dir.mkdir(exist_ok=True) + (comp_dir / f"{category}.jsonl").write_text( + json.dumps( + { + "surface_pattern": "bound(x) op bound(y)", + "composition_category": category, + "polarity": "affirms", + "provenance": f"test_case_0050_hazard_{category}", + "evidence_hashes": [], + } + ) + + "\n", + encoding="utf-8", + ) + _, sha = compile_compositions(pack) + manifest_path = pack / "manifest.json" + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + payload["composition_checksum"] = sha + manifest_path.write_text(json.dumps(payload), encoding="utf-8") + + # Redirect composition registry default to our staged pack. + from generate.comprehension import composition_registry as cr + + monkeypatch.setattr(cr, "_DEFAULT_PACK_RELPATH", pack) + monkeypatch.setattr(cr, "_repo_root", lambda: Path("/")) + + # Load the registry — confirms the ratified entry is admitted at load + # under the allowlist defense in depth. + reg = cr.load_composition_registry() + assert not reg.is_empty() + + # The hazard pin: case 0050's refusal lives in math_candidate_graph + # upstream of inject_from_match. The recognizer for case 0050's + # sentences does not publish ``composition_shape`` in parsed_anchors, + # so the composition consult cannot fire. Verify the contract: + # without ``composition_shape``, the registry consult is a no-op. + from evals.refusal_taxonomy.shape_categories import ShapeCategory + from generate.recognizer_anchor_inject import inject_from_match + from generate.recognizer_match import RecognizerMatch + + class _FakeRec: + spec_id = "test" + + # Simulate the recognizer matching case 0050's sentence shape: an + # anchor with NO composition_shape (the matcher hasn't been extended). + match = RecognizerMatch( + recognizer=_FakeRec(), # type: ignore[arg-type] + category=ShapeCategory.CURRENCY_AMOUNT, + outcome="admissible", + graph_intent="amount", + parsed_anchors=({"kind": "currency_amount"},), + ) + result = inject_from_match(match, "case 0050 sentence") + # The hazard pin: registry is non-empty + affirms exists, but without + # a matcher-published composition_shape the consult cannot fire. + # Refusal-preserving by construction. + assert result == () diff --git a/tests/test_consumption_empty_registry_no_op.py b/tests/test_consumption_empty_registry_no_op.py new file mode 100644 index 00000000..d41accd2 --- /dev/null +++ b/tests/test_consumption_empty_registry_no_op.py @@ -0,0 +1,92 @@ +"""Empty-registry no-op invariant for the canonical en_core_math_v1 pack. + +Loads the live ``frame_registry`` and ``composition_registry`` against +the production pack and asserts the empty-registry contract holds: + +- both registries load without error +- both report ``is_empty() == True`` (no ratified entries currently) +- the canonical lexicon ``load_lexicon`` continues to work unchanged + (no manifest-schema regression from adding the new optional fields) +- the composition-consult fallback in ``inject_from_match`` is a no-op + when registry is empty (a synthetic match with a populated + composition_shape anchor still returns ``()``) + +If a future PR ratifies frame or composition entries, this test will +need updating in lockstep — it is the canary for the no-op invariant. +""" + +from __future__ import annotations + +from evals.refusal_taxonomy.shape_categories import ShapeCategory +from generate.comprehension.composition_registry import ( + clear_cache as clear_composition_cache, + load_composition_registry, +) +from generate.comprehension.frame_registry import ( + clear_cache as clear_frame_cache, + load_frame_registry, +) +from generate.comprehension.lexicon import load_lexicon +from generate.math_candidate_parser import CandidateInitial +from generate.math_problem_graph import InitialPossession, Quantity +from generate.recognizer_anchor_inject import inject_from_match +from generate.recognizer_match import RecognizerMatch + + +def setup_function(_): + clear_frame_cache() + clear_composition_cache() + + +def test_frame_registry_loads_and_is_empty_on_canonical_pack(): + reg = load_frame_registry() + assert reg.is_empty() + assert reg.source_pack_id == "en_core_math_v1" + + +def test_composition_registry_loads_and_is_empty_on_canonical_pack(): + reg = load_composition_registry() + assert reg.is_empty() + assert reg.source_pack_id == "en_core_math_v1" + + +def test_lexicon_load_unaffected_by_new_manifest_fields(): + # The manifest schema gained optional ``frame_checksum`` and + # ``composition_checksum`` fields. The existing lexicon loader + # MUST be unaffected when those fields are absent (current state). + lex = load_lexicon() + assert lex.source_pack_id == "en_core_math_v1" + assert len(lex.by_surface) > 0 + + +def test_inject_from_match_composition_consult_is_noop_when_registry_empty(): + """Even a fully-populated anchor produces no admission when registry empty.""" + + class _FakeRec: + spec_id = "test" + + composed = CandidateInitial( + initial=InitialPossession( + entity="John", + quantity=Quantity(value=1200, unit="dollars"), + ), + source_span="3 vet appointments cost $400 each", + matched_anchor="has", + matched_value_token="1200", + matched_unit_token="dollars", + matched_entity_token="John", + ) + match = RecognizerMatch( + recognizer=_FakeRec(), # type: ignore[arg-type] + category=ShapeCategory.CURRENCY_AMOUNT, + outcome="admissible", + graph_intent="amount", + parsed_anchors=( + { + "composition_shape": "bound(count) × bound(unit_cost)", + "composed_initial": composed, + }, + ), + ) + # Empty registry — no entry to affirm the shape — refusal-preferring. + assert inject_from_match(match, "synthetic sentence") == () diff --git a/tests/test_consumption_partition.py b/tests/test_consumption_partition.py new file mode 100644 index 00000000..da8a3ccb --- /dev/null +++ b/tests/test_consumption_partition.py @@ -0,0 +1,75 @@ +"""Partition test — consumption registries are math-domain only. + +ADR-0167 partition: cognition ``TeachingChainProposal`` flow must not +see math composition/frame artifacts and vice versa. + +The consumption registries (FrameRegistry, CompositionRegistry) read +exclusively from the en_core_math_v1 pack. They do not import from +cognition modules and cannot be reached via cognition code paths. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +def _module_imports(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + out: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + out.add(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + out.add(node.module) + return out + + +def _repo_root() -> Path: + here = Path(__file__).resolve() + while here.parent != here and not (here / "pyproject.toml").exists(): + here = here.parent + return here + + +def test_frame_registry_imports_no_cognition_modules(): + repo = _repo_root() + imports = _module_imports( + repo / "generate" / "comprehension" / "frame_registry.py" + ) + cognition_taints = { + i for i in imports if i.startswith("cognition.") or i.startswith("teaching.cognition") + } + assert cognition_taints == set(), ( + f"frame_registry imports cognition modules: {cognition_taints}" + ) + + +def test_composition_registry_imports_no_cognition_modules(): + repo = _repo_root() + imports = _module_imports( + repo / "generate" / "comprehension" / "composition_registry.py" + ) + cognition_taints = { + i for i in imports if i.startswith("cognition.") or i.startswith("teaching.cognition") + } + # Math-side teaching imports are fine (math_composition_ratification); + # cognition-side teaching imports are not. + assert cognition_taints == set(), ( + f"composition_registry imports cognition modules: {cognition_taints}" + ) + + +def test_compile_modules_import_no_cognition(): + repo = _repo_root() + for mod in ("compile_frames.py", "compile_compositions.py"): + imports = _module_imports(repo / "language_packs" / mod) + cognition_taints = { + i for i in imports + if i.startswith("cognition.") or i.startswith("teaching.cognition") + } + assert cognition_taints == set(), ( + f"{mod} imports cognition modules: {cognition_taints}" + ) diff --git a/tests/test_frame_registry_load.py b/tests/test_frame_registry_load.py new file mode 100644 index 00000000..14d2fba6 --- /dev/null +++ b/tests/test_frame_registry_load.py @@ -0,0 +1,217 @@ +"""CW-1 — frame_registry loader tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from generate.comprehension.frame_registry import ( + FrameRegistry, + FrameRegistryLoadError, + clear_cache, + load_frame_registry, + lookup, +) +from language_packs.compile_frames import compile_frames + + +@pytest.fixture +def empty_pack(tmp_path: Path) -> Path: + """A pack with manifest but no frames/ dir — empty-registry no-op.""" + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "test_empty", "checksum": "deadbeef"}), + encoding="utf-8", + ) + return tmp_path + + +@pytest.fixture +def populated_pack(tmp_path: Path) -> Path: + """Pack with two ratified frame entries + matching manifest checksum.""" + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + (frames_dir / "transfer_frame.jsonl").write_text( + json.dumps( + { + "surface_form": "gave", + "frame_category": "transfer_frame", + "polarity": "affirms", + "provenance": "test_provenance", + "evidence_hashes": ["dead"], + } + ) + + "\n", + encoding="utf-8", + ) + _, sha = compile_frames(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "test_populated", + "checksum": "deadbeef", + "frame_checksum": sha, + } + ), + encoding="utf-8", + ) + return tmp_path + + +def setup_function(_): + clear_cache() + + +def test_empty_pack_returns_empty_registry(empty_pack: Path): + reg = load_frame_registry(empty_pack) + assert isinstance(reg, FrameRegistry) + assert reg.is_empty() + assert reg.source_pack_id == "test_empty" + assert dict(reg.by_surface) == {} + + +def test_populated_pack_round_trip(populated_pack: Path): + reg = load_frame_registry(populated_pack) + assert not reg.is_empty() + entry = lookup(reg, "gave") + assert entry is not None + assert entry.surface_form == "gave" + assert entry.frame_category == "transfer_frame" + assert entry.polarity == "affirms" + assert entry.provenance == "test_provenance" + + +def test_case_fold_on_lookup(populated_pack: Path): + reg = load_frame_registry(populated_pack) + assert lookup(reg, "GAVE") is not None + assert lookup(reg, "Gave") is not None + + +def test_unknown_surface_returns_none(populated_pack: Path): + reg = load_frame_registry(populated_pack) + assert lookup(reg, "shouted") is None + + +def test_manifest_checksum_mismatch_raises(populated_pack: Path): + manifest_path = populated_pack / "manifest.json" + payload = json.loads(manifest_path.read_text()) + payload["frame_checksum"] = "f" * 64 # bogus + manifest_path.write_text(json.dumps(payload), encoding="utf-8") + clear_cache() + with pytest.raises(FrameRegistryLoadError, match="checksum mismatch"): + load_frame_registry(populated_pack) + + +def test_declared_checksum_without_compiled_file_raises(tmp_path: Path): + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "pack_id": "test", + "checksum": "deadbeef", + "frame_checksum": "f" * 64, + } + ), + encoding="utf-8", + ) + with pytest.raises(FrameRegistryLoadError, match="frames.jsonl is missing"): + load_frame_registry(tmp_path) + + +def test_deterministic_order_across_runs(populated_pack: Path): + reg1 = load_frame_registry(populated_pack) + clear_cache() + reg2 = load_frame_registry(populated_pack) + assert dict(reg1.by_surface) == dict(reg2.by_surface) + assert reg1.pack_manifest_sha256 == reg2.pack_manifest_sha256 + + +def test_invalid_polarity_raises(tmp_path: Path): + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + (frames_dir / "bad.jsonl").write_text( + json.dumps( + { + "surface_form": "x", + "frame_category": "y", + "polarity": "maybe", + "provenance": "", + "evidence_hashes": [], + } + ) + + "\n", + encoding="utf-8", + ) + _, sha = compile_frames(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "test", "checksum": "x", "frame_checksum": sha}), + encoding="utf-8", + ) + with pytest.raises(FrameRegistryLoadError, match="invalid polarity"): + load_frame_registry(tmp_path) + + +def test_conflicting_entries_for_same_surface_raise(tmp_path: Path): + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + (frames_dir / "transfer.jsonl").write_text( + json.dumps( + { + "surface_form": "gave", + "frame_category": "transfer_frame", + "polarity": "affirms", + "provenance": "p1", + "evidence_hashes": [], + } + ) + + "\n", + encoding="utf-8", + ) + (frames_dir / "decrement.jsonl").write_text( + json.dumps( + { + "surface_form": "gave", + "frame_category": "decrement_frame", + "polarity": "affirms", + "provenance": "p2", + "evidence_hashes": [], + } + ) + + "\n", + encoding="utf-8", + ) + _, sha = compile_frames(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps({"pack_id": "test", "checksum": "x", "frame_checksum": sha}), + encoding="utf-8", + ) + with pytest.raises(FrameRegistryLoadError, match="conflicting entries"): + load_frame_registry(tmp_path) + + +def test_cache_returns_same_instance(populated_pack: Path): + reg1 = load_frame_registry(populated_pack) + reg2 = load_frame_registry(populated_pack) + assert reg1 is reg2 # cache hit + + +def test_compiled_bytes_are_byte_stable(tmp_path: Path): + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + (frames_dir / "a.jsonl").write_text( + json.dumps( + { + "surface_form": "gave", + "frame_category": "transfer_frame", + "polarity": "affirms", + "provenance": "p", + "evidence_hashes": [], + } + ) + + "\n", + encoding="utf-8", + ) + bytes1, sha1 = compile_frames(tmp_path) + bytes2, sha2 = compile_frames(tmp_path) + assert bytes1 == bytes2 + assert sha1 == sha2