core/tests/test_composition_registry_load.py
Shay 78ddab79b4
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
2026-05-27 16:17:03 -07:00

266 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)