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
274 lines
9.6 KiB
Python
274 lines
9.6 KiB
Python
"""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",
|
|
]
|