feat(consumption-wiring): CW-1 + CW-2 — Frame + Composition registry loaders (#398)
Closes the consumption-half of the math teaching loop for two of three sub-types per docs/handoff/CONSUMPTION-WIRING-DISPATCH-PACK.md (PR #397). Companion to the doctrinal brief in PR #396. Modules ------- - language_packs/compile_frames.py — byte-deterministic compile of frames/*.jsonl → frames.jsonl (sorted by (frame_category, surface_form)) - language_packs/compile_compositions.py — same shape for compositions/*.jsonl → compositions.jsonl - generate/comprehension/frame_registry.py — load_frame_registry() mirroring load_lexicon: cache by (path, mtime, sha256), manifest checksum verification (optional frame_checksum field), polarity validation, conflict detection, empty-registry no-op - generate/comprehension/composition_registry.py — same shape PLUS: * SAFE_COMPOSITION_CATEGORIES enforced at LOAD (defense in depth; raises WrongCompositionCategory on any unsafe category — protects against pack edits that bypass the handler) * polarity "falsifies" exposed via is_falsified() (consumer must suppress; not silently treated as affirms) - language_packs/compiler.py — manifest verification extended for frame_checksum + composition_checksum, mirroring the proven glosses_checksum pattern (optional fields; backward-compatible) - generate/recognizer_anchor_inject.py — inject_from_match consults composition_registry when the per-category injector returns empty AND the matcher publishes ``composition_shape`` in parsed_anchors. Registry is a gate (admissibility) not an arithmetic primitive (ADR-0169 §"Mutation boundary"). Tests (38 new, all green) ------------------------- tests/test_frame_registry_load.py (11 tests) tests/test_composition_registry_load.py (11 tests) tests/test_composition_consult_in_injector.py ( 6 tests) tests/test_consumption_case_0050_hazard_pin.py( 3 tests, parametrized over allowlist) tests/test_consumption_empty_registry_no_op.py( 4 tests) tests/test_consumption_partition.py ( 3 tests) Registered in core/cli.py "packs" suite. Suite results ------------- core test --suite teaching -q → 93 passed core test --suite runtime -q → 20 passed core test --suite packs -q → 51 passed core eval gsm8k_math --split public → 150/150, wrong=0 Truth-test rows (6-row binding table in dispatch pack): #1 Case 0019 admits ............. PARTIAL — see Scope Boundary below #2 Case 0050 stays refused ....... PASS #3 train_sample 3/47 → ≥4/46 ..... PARTIAL — same as #1 #4 wrong == 0 preserved .......... PASS #5 public split 150/150 .......... PASS #6 Empty-registry no-op .......... PASS Scope Boundary (honest finding) ------------------------------- Rows #1 and #3 (case 0019 admission) require a matcher extension that publishes ``composition_shape`` + a pre-composed CandidateInitial in parsed_anchors. The existing currency_amount / multiplicative_aggregation matchers in generate/recognizer_match.py are detection-only (return empty parsed_anchors). This PR ships the consumption infrastructure correctly but the runtime path remains dormant until a follow-up PR extends the matcher. The dispatch pack's truth test #1/#3 cannot fire without that extension. The wiring is positioned correctly: inject_from_match → consult composition_registry → admit on affirms-with-payload, suppress on falsifies, refuse on absence. A synthetic recognizer match with populated composition_shape + composed_initial DOES admit through the new path (covered by 6 tests in test_composition_consult_in_injector.py). A follow-up brief naming the matcher-extension work is the recommended next step. Anti-regression invariants verified ----------------------------------- - wrong == 0 on core eval gsm8k_math (public 150/150) - case 0050 stays refused (parametrized over allowlist categories) - ADR-0166 — no new eval lanes - ADR-0167 partition — no cognition imports in any new module - Empty-registry runtime byte-identical to today (no-op test) - SAFE_COMPOSITION_CATEGORIES enforced at write AND load - polarity semantics (affirms vs falsifies) honored - engine_state/* never committed
This commit is contained in:
parent
ffa04f775b
commit
78ddab79b4
13 changed files with 1756 additions and 2 deletions
|
|
@ -73,6 +73,12 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = {
|
||||||
"packs": (
|
"packs": (
|
||||||
"tests/test_core_semantic_seed_pack.py",
|
"tests/test_core_semantic_seed_pack.py",
|
||||||
"tests/test_adr_0127_pack_ratification.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": (
|
"algebra": (
|
||||||
"tests/test_versor_closure.py",
|
"tests/test_versor_closure.py",
|
||||||
|
|
|
||||||
274
generate/comprehension/composition_registry.py
Normal file
274
generate/comprehension/composition_registry.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
225
generate/comprehension/frame_registry.py
Normal file
225
generate/comprehension/frame_registry.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -82,11 +82,105 @@ def inject_from_match(
|
||||||
so per-category injectors can emit operations as well as initials.
|
so per-category injectors can emit operations as well as initials.
|
||||||
The v1 ``discrete_count_statement`` injector continues to emit only
|
The v1 ``discrete_count_statement`` injector continues to emit only
|
||||||
``CandidateInitial`` — the widening is type-level only in this PR.
|
``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)
|
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 ()
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
78
language_packs/compile_compositions.py
Normal file
78
language_packs/compile_compositions.py
Normal file
|
|
@ -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"]
|
||||||
86
language_packs/compile_frames.py
Normal file
86
language_packs/compile_frames.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -462,6 +462,29 @@ def _load_pack_cached(pack_id: str) -> tuple[LanguagePackManifest, VocabManifold
|
||||||
f"{actual} != {expected_glosses_checksum}"
|
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)
|
entries = load_pack_entries(pack_id)
|
||||||
morphology_registry = None
|
morphology_registry = None
|
||||||
if any(entry.morphology_id for entry in entries):
|
if any(entry.morphology_id for entry in entries):
|
||||||
|
|
|
||||||
195
tests/test_composition_consult_in_injector.py
Normal file
195
tests/test_composition_consult_in_injector.py
Normal file
|
|
@ -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 == ()
|
||||||
266
tests/test_composition_registry_load.py
Normal file
266
tests/test_composition_registry_load.py
Normal file
|
|
@ -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)
|
||||||
123
tests/test_consumption_case_0050_hazard_pin.py
Normal file
123
tests/test_consumption_case_0050_hazard_pin.py
Normal file
|
|
@ -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 == ()
|
||||||
92
tests/test_consumption_empty_registry_no_op.py
Normal file
92
tests/test_consumption_empty_registry_no_op.py
Normal file
|
|
@ -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") == ()
|
||||||
75
tests/test_consumption_partition.py
Normal file
75
tests/test_consumption_partition.py
Normal file
|
|
@ -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}"
|
||||||
|
)
|
||||||
217
tests/test_frame_registry_load.py
Normal file
217
tests/test_frame_registry_load.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue