core/tests/test_me3_additive_composition.py
Shay 1215944a20 feat(matcher-extension/ME-3): additive composition matcher
Extends _match_multiplicative_aggregation with a new branch keyed on
anchor_kind="additive_quantity_composition". When a statement carries
"<Subject> <verb> <N> <unit> and <M> <unit>" (same unit) shape, emits
a pre-composed CandidateInitial(N+M, unit) and publishes
composition_shape="bound(qty_a) + bound(qty_b)".

Subject binding under Option A (refuse on pronoun / determiner / no
proper-noun head). Cross-sentence subject support (mirroring ME-2)
is deferred — not needed for the v1 ME-3 canaries.

Verb whitelist: lost / gained / earned / saved / made / paid / spent /
bought / sold / added / removed / received. Verbs that route through
CandidateInitial.matched_anchor's existing post-init whitelist;
unmapped verbs fall back to "had".

Unit normalization: rstrip 's' for plural matching (pounds vs pound).
Cross-unit composition refused — no conversion table in v1.

Tests (15 new, all green):
- same-unit admission with sum
- pronoun subject refuses
- determiner subject refuses
- cross-unit refuses
- unobserved unit refuses
- zero count refuses
- plural normalization
- unknown verb refuses
- multiplicative_aggregate detection path unaffected
- wrong anchor_kind refuses
- anchor audit fields complete
- source_span substring invariant
- no match returns None
- end-to-end admission via composition_registry
- end-to-end falsifies suppresses

Registered in core/cli.py "packs" suite. core test --suite packs -q →
106 passed (91 existing + 15 new).

Anti-regression invariants preserved:
- wrong == 0 on gsm8k_math public 150/150
- Case 0050 hazard pin holds
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports
- Original multiplicative_aggregate detection path byte-identical
- ME-1 currency-per-unit path unaffected
- ME-2 cross-sentence path unaffected
- engine_state/* not committed

Live train_sample admission requires the same operator workflow as
ME-2: a RatifiedRecognizer for the new anchor_kind + composition_registry
entry for "bound(qty_a) + bound(qty_b)" under additive_composition.
Without those, the wiring is correctly positioned but dormant — no
regression in the live eval.

Stacks on PR #401 (base: feat/matcher-extension-cross-sentence-subject).
2026-05-27 17:12:34 -07:00

280 lines
8.4 KiB
Python

"""ME-3 — additive composition matcher tests.
Covers the ``additive_quantity_composition`` extension to
``_match_multiplicative_aggregation``: extracts two same-unit
quantities connected by ``and`` and emits a pre-composed
``CandidateInitial`` whose value is the sum.
Subject binding: same-sentence Option A (refuse on missing /
pronoun / determiner). Cross-sentence subject for additive composition
is deferred (would mirror ME-2 but not needed for the v1 ME-3
canary).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Mapping
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.recognizer_anchor_inject import inject_from_match
from generate.recognizer_match import (
RecognizerMatch,
_match_multiplicative_aggregation,
)
from generate.recognizer_registry import RatifiedRecognizer
from language_packs.compile_compositions import compile_compositions
_SPEC: Mapping[str, Any] = {
"anchor_kind": "additive_quantity_composition",
"observed_units": ["pounds", "pound", "dollars", "apples", "books"],
}
_SHAPE = "bound(qty_a) + bound(qty_b)"
def setup_function(_):
clear_composition_cache()
def test_same_unit_admits_with_sum():
result = _match_multiplicative_aggregation(
"Maria saved 30 dollars in May and 20 dollars in June.", _SPEC
)
assert result is not None
a = result[0][0]
assert a["composition_shape"] == _SHAPE
assert a["subject"] == "Maria"
composed = a["composed_initial"]
assert isinstance(composed, CandidateInitial)
assert composed.initial.entity == "Maria"
assert composed.initial.quantity.value == 50
assert composed.initial.quantity.unit == "dollars"
def test_pronoun_subject_refuses():
"""Pronoun head → refuse (Option A); cross-sentence is a future brief."""
result = _match_multiplicative_aggregation(
"He lost 3 pounds in March and 4 pounds in April.", _SPEC
)
assert result is None
def test_determiner_subject_refuses():
result = _match_multiplicative_aggregation(
"The dog ate 3 pounds in March and 4 pounds in April.", _SPEC
)
assert result is None
def test_cross_unit_refuses():
"""Cross-unit composition has no canonical conversion in v1."""
result = _match_multiplicative_aggregation(
"Maria earned 30 dollars and 20 books.", _SPEC
)
assert result is None
def test_unobserved_unit_refuses():
spec = dict(_SPEC)
spec["observed_units"] = ["dollars"] # 'pounds' missing
result = _match_multiplicative_aggregation(
"Tom gained 5 pounds and 3 pounds.", spec
)
assert result is None
def test_zero_count_refuses():
result = _match_multiplicative_aggregation(
"Maria earned 0 dollars and 50 dollars.", _SPEC
)
assert result is None
def test_plural_normalization():
"""pound/pounds normalize to canonical singular for matching."""
spec = dict(_SPEC)
spec["observed_units"] = ["pound"]
result = _match_multiplicative_aggregation(
"Tom gained 5 pounds and 3 pounds.", spec
)
# observed_units has 'pound' singular; the matcher should still
# accept (rstrip normalization).
assert result is not None
def test_unknown_verb_refuses():
result = _match_multiplicative_aggregation(
"Maria adopted 3 pounds and 4 pounds.", _SPEC
)
assert result is None
def test_multiplicative_aggregate_path_unaffected():
"""The original detection-only aggregate path still works."""
spec = {"anchor_kind": "multiplicative_aggregate"}
result = _match_multiplicative_aggregation(
"There are 3 bags with 5 items each.", spec
)
# Detection-only — empty parsed_anchors.
assert result is not None
anchors, intent = result
assert intent == "aggregate"
assert anchors == ()
def test_wrong_anchor_kind_refuses():
spec = {"anchor_kind": "currency_per_unit_rate"}
result = _match_multiplicative_aggregation(
"Maria earned 30 dollars and 20 dollars.", spec
)
assert result is None
def test_anchor_audit_fields():
result = _match_multiplicative_aggregation(
"Tom gained 5 pounds and 3 pounds.", _SPEC
)
assert result is not None
a = result[0][0]
assert {
"composition_shape",
"composed_initial",
"count_a",
"count_b",
"unit",
"subject",
"verb",
"kind",
}.issubset(a.keys())
def test_source_span_substring():
statement = "Sam earned 100 dollars and 50 dollars."
result = _match_multiplicative_aggregation(statement, _SPEC)
assert result is not None
span = result[0][0]["composed_initial"].source_span
assert span in statement
def test_no_match_returns_none():
result = _match_multiplicative_aggregation(
"There is nothing here.", _SPEC
)
assert result is None
# ---------------------------------------------------------------------------
# End-to-end: ratified composition entry + matcher + inject_from_match
# ---------------------------------------------------------------------------
def _stage_pack(tmp_path: Path) -> Path:
pack = tmp_path / "en_core_math_v1"
comp_dir = pack / "compositions"
comp_dir.mkdir(parents=True)
(comp_dir / "additive_composition.jsonl").write_text(
json.dumps(
{
"surface_pattern": _SHAPE,
"composition_category": "additive_composition",
"polarity": "affirms",
"provenance": "test_me3",
"evidence_hashes": [],
}
)
+ "\n",
encoding="utf-8",
)
_, sha = compile_compositions(pack)
(pack / "manifest.json").write_text(
json.dumps(
{
"pack_id": "en_core_math_v1",
"checksum": "x",
"composition_checksum": sha,
}
),
encoding="utf-8",
)
return pack
def _patch_pack_root(monkeypatch, pack_path: Path) -> None:
from generate.comprehension import composition_registry as cr
monkeypatch.setattr(cr, "_DEFAULT_PACK_RELPATH", pack_path)
monkeypatch.setattr(cr, "_repo_root", lambda: Path("/"))
def _make_match(anchors: tuple[Mapping[str, Any], ...]) -> RecognizerMatch:
class _FakeRec:
spec_id = "test_me3"
return RecognizerMatch(
recognizer=_FakeRec(), # type: ignore[arg-type]
category=ShapeCategory.MULTIPLICATIVE_AGGREGATION,
outcome="admissible",
graph_intent="aggregate",
parsed_anchors=anchors,
)
def test_end_to_end_additive_admits(monkeypatch, tmp_path):
pack = _stage_pack(tmp_path)
_patch_pack_root(monkeypatch, pack)
statement = "Maria saved 30 dollars in May and 20 dollars in June."
result = _match_multiplicative_aggregation(statement, _SPEC)
assert result is not None
match = _make_match(result[0])
emissions = inject_from_match(match, statement)
assert len(emissions) == 1
composed = emissions[0]
assert composed.initial.entity == "Maria"
assert composed.initial.quantity.value == 50
assert composed.initial.quantity.unit == "dollars"
def test_end_to_end_falsifies_suppresses(monkeypatch, tmp_path):
pack = tmp_path / "en_core_math_v1"
comp_dir = pack / "compositions"
comp_dir.mkdir(parents=True)
(comp_dir / "additive_composition.jsonl").write_text(
json.dumps(
{
"surface_pattern": _SHAPE,
"composition_category": "additive_composition",
"polarity": "falsifies",
"provenance": "test_falsifies",
"evidence_hashes": [],
}
)
+ "\n",
encoding="utf-8",
)
_, sha = compile_compositions(pack)
(pack / "manifest.json").write_text(
json.dumps(
{
"pack_id": "en_core_math_v1",
"checksum": "x",
"composition_checksum": sha,
}
),
encoding="utf-8",
)
_patch_pack_root(monkeypatch, pack)
statement = "Maria saved 30 dollars in May and 20 dollars in June."
result = _match_multiplicative_aggregation(statement, _SPEC)
assert result is not None
match = _make_match(result[0])
emissions = inject_from_match(match, statement)
assert emissions == ()