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
217 lines
6.3 KiB
Python
217 lines
6.3 KiB
Python
"""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
|