From 11d7e0b607d360cd8cbecc736e3409c0ef5e2448 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 27 May 2026 16:35:32 -0700 Subject: [PATCH 1/2] feat(matcher-extension/ME-4): subtractive composition matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends _match_multiplicative_aggregation with a new branch keyed on anchor_kind="subtractive_quantity_composition". Pattern: (,| then| ;| and then| and) Same-unit only. Emits a pre-composed CandidateInitial(N - M, unit) + composition_shape="bound(initial) − bound(removed)". Verb whitelists: initial: had/has/got/owns/owned/earned/saved/made/received/bought removal: lost/spent/gave/donated/paid/removed/sold/used/consumed Removal verbs accept an optional " away" suffix ("gave away 20 apples"). Refusal-preferring discipline: - count_b >= count_a → refuse (non-negative remainder; wrong>0 hazard) - Pronoun / determiner subject → refuse - Cross-unit → refuse (no v1 conversion table) - Unobserved unit → refuse - Unknown initial/removal verb → refuse Tests (17 new, all green): - canonical subtractive ("Sam had 50 apples, gave 20" → 30) - then/and connectives - gave away variant - negative + equal remainder refused (hazard pin) - pronoun + determiner subject refused - cross-unit refused - unobserved unit refused - unknown initial/removal verbs refused - additive (ME-3) path unaffected - multiplicative_aggregate detection unaffected - anchor audit fields complete - end-to-end via composition_registry: affirms admits, falsifies suppresses Registered in core/cli.py "packs" suite. core test --suite packs -q → 123 passed (106 + 17 new) core eval gsm8k_math --split public → 150/150, wrong=0 Anti-regression invariants preserved across ME-1..ME-4 stack: - 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 - All prior matcher paths unaffected (test pins) - engine_state/* not committed - All three SAFE_COMPOSITION_CATEGORIES (multiplicative / additive / subtractive) now have matcher extensions wired Stacks on PR #402 (base: feat/matcher-extension-multi-quantity). --- core/cli.py | 1 + generate/recognizer_match.py | 160 ++++++++++++++ tests/test_me4_subtractive_composition.py | 257 ++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 tests/test_me4_subtractive_composition.py diff --git a/core/cli.py b/core/cli.py index 6be521e8..fa18c800 100644 --- a/core/cli.py +++ b/core/cli.py @@ -85,6 +85,7 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = { "tests/test_me2_cross_sentence_subject.py", "tests/test_me2_case_0019_admits.py", "tests/test_me3_additive_composition.py", + "tests/test_me4_subtractive_composition.py", ), "algebra": ( "tests/test_versor_closure.py", diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 15895be9..6df46e39 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -1012,6 +1012,16 @@ def _match_multiplicative_aggregation( # ME-3 dispatch — same Literal narrowing keeps the return type # consistent ('aggregate' is reused). return _try_extract_additive_composition_anchor(statement, spec) + if anchor_kind == "subtractive_quantity_composition": + # ME-4 dispatch — subtractive shape returns ("amount" intent). + # Cast the Literal return: the matcher signature widens at the + # type level to include any 'aggregate'/'amount' but the caller + # in match() reads graph_intent verbatim. + sub_result = _try_extract_subtractive_composition_anchor(statement, spec) + if sub_result is None: + return None + anchors, _ = sub_result + return (anchors, "aggregate") # reuse 'aggregate' label for subtractive too if anchor_kind != "multiplicative_aggregate": return None padded = _padded_lower(statement) @@ -1184,6 +1194,156 @@ _ADDITIVE_COMPOSITION_VERBS: Final[frozenset[str]] = frozenset({ }) +# --------------------------------------------------------------------------- +# ME-4 — subtractive composition matcher. +# +# Admits " (,| then|; etc.) +# " (same unit; positive initial verb followed by removal +# verb) and emits a pre-composed CandidateInitial(N - M, unit). +# +# Refusal-preferring discipline: count_b >= count_a → refuse +# (non-negative remainder; subtractive composition that goes below +# zero is a wrong>0 hazard). +# --------------------------------------------------------------------------- + +_SUBTRACTIVE_TWO_QUANTITY_RE: Final[re.Pattern[str]] = re.compile( + r"""(?ix) + ^\s* + (?P[A-Z][a-zA-Z]+) + \s+ + (?Phad|has|got|owns|owned|earned|saved|made|received|bought) + \s+ + (?P\d+(?:\.\d+)?) + \s+ + (?P[a-z]+) + \s* + (?:,|\sthen\s|;|\s+and\s+then\s+|\s+then\s+|\s+and\s+) + \s* + (?:then\s+)? + (?Plost|spent|gave|donated|paid|removed|sold|used|consumed) + (?:\s+away)? + \s+ + (?P\d+(?:\.\d+)?) + \s+ + (?P[a-z]+) + \b + """, +) + +_SUBTRACTIVE_COMPOSITION_SHAPE: Final[str] = "bound(initial) − bound(removed)" + + +_SUBTRACTIVE_INITIAL_VERBS: Final[frozenset[str]] = frozenset({ + "had", "has", "got", "owns", "owned", "earned", "saved", + "made", "received", "bought", +}) + +_SUBTRACTIVE_REMOVAL_VERBS: Final[frozenset[str]] = frozenset({ + "lost", "spent", "gave", "donated", "paid", "removed", + "sold", "used", "consumed", +}) + + +def _try_extract_subtractive_composition_anchor( + statement: str, spec: Mapping[str, Any] +) -> tuple[tuple[Mapping[str, Any], ...], Literal["amount"]] | None: + """Extract a pre-composed CandidateInitial for subtractive composition. + + See module docstring above for narrowness layers. ``count_b >= + count_a`` refuses (non-negative remainder discipline; wrong>0 + hazard). + """ + if spec.get("anchor_kind") != "subtractive_quantity_composition": + return None + observed_units = set(spec.get("observed_units") or ()) + if not observed_units: + return None + + matches = list(_SUBTRACTIVE_TWO_QUANTITY_RE.finditer(statement)) + if len(matches) != 1: + return None + + m = matches[0] + subject = m.group("subject") + if subject.lower() in _REFUSED_SUBJECT_TOKENS: + return None + if subject.lower() in _COMMON_DETERMINERS_AT_HEAD: + return None + + verb_a = m.group("verb_a").lower() + verb_b = m.group("verb_b").lower() + if verb_a not in _SUBTRACTIVE_INITIAL_VERBS: + return None + if verb_b not in _SUBTRACTIVE_REMOVAL_VERBS: + return None + + unit_a = m.group("unit_a").lower() + unit_b = m.group("unit_b").lower() + if unit_a.rstrip("s") != unit_b.rstrip("s"): + return None + canonical_unit = unit_a + if ( + canonical_unit not in observed_units + and canonical_unit.rstrip("s") not in observed_units + ): + return None + + count_a_token = m.group("count_a") + count_b_token = m.group("count_b") + try: + count_a = float(count_a_token) + count_b = float(count_b_token) + except ValueError: + return None + if count_a <= 0 or count_b <= 0: + return None + if count_b >= count_a: + return None # Non-negative remainder; wrong>0 hazard. + + composed_value_f = count_a - count_b + composed_value: int | float + if ( + composed_value_f.is_integer() + and "." not in count_a_token + and "." not in count_b_token + ): + composed_value = int(composed_value_f) + else: + composed_value = composed_value_f + + from generate.math_candidate_parser import CandidateInitial + from generate.math_problem_graph import InitialPossession, Quantity + + matched_anchor = verb_a if verb_a in { + "has", "had", "saved", "earned", "got", "received", "bought", "made", "paid", + } else "had" + + composed_initial = CandidateInitial( + initial=InitialPossession( + entity=subject, + quantity=Quantity(value=composed_value, unit=canonical_unit), + ), + source_span=m.group(0), + matched_anchor=matched_anchor, + matched_value_token=str(composed_value), + matched_unit_token=canonical_unit, + matched_entity_token=subject, + ) + + anchor: Mapping[str, Any] = { + "kind": "subtractive_quantity_composition", + "composition_shape": _SUBTRACTIVE_COMPOSITION_SHAPE, + "composed_initial": composed_initial, + "count_a": count_a_token, + "count_b": count_b_token, + "unit": canonical_unit, + "subject": subject, + "initial_verb": verb_a, + "removal_verb": verb_b, + } + return ((anchor,), "amount") + + def _match_currency_amount( statement: str, spec: Mapping[str, Any] ) -> tuple[tuple[Mapping[str, Any], ...], Literal["amount"]] | None: diff --git a/tests/test_me4_subtractive_composition.py b/tests/test_me4_subtractive_composition.py new file mode 100644 index 00000000..7a13f551 --- /dev/null +++ b/tests/test_me4_subtractive_composition.py @@ -0,0 +1,257 @@ +"""ME-4 — subtractive composition matcher tests. + +Covers the ``subtractive_quantity_composition`` extension to +``_match_multiplicative_aggregation``. Pattern: `` + (,| then| ;| and then| and) `` with +same unit and ``M < N`` (non-negative remainder discipline). +""" + +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 language_packs.compile_compositions import compile_compositions + + +_SPEC: Mapping[str, Any] = { + "anchor_kind": "subtractive_quantity_composition", + "observed_units": ["apples", "apple", "dollars", "pounds", "pound", "books"], +} + +_SHAPE = "bound(initial) − bound(removed)" + + +def setup_function(_): + clear_composition_cache() + + +def test_canonical_subtractive_admits(): + result = _match_multiplicative_aggregation( + "Sam had 50 apples, gave 20 apples.", _SPEC + ) + assert result is not None + a = result[0][0] + assert a["composition_shape"] == _SHAPE + assert a["subject"] == "Sam" + composed = a["composed_initial"] + assert isinstance(composed, CandidateInitial) + assert composed.initial.entity == "Sam" + assert composed.initial.quantity.value == 30 + assert composed.initial.quantity.unit == "apples" + + +def test_then_connective_admits(): + result = _match_multiplicative_aggregation( + "Mark had 100 dollars then spent 30 dollars.", _SPEC + ) + assert result is not None + assert result[0][0]["composed_initial"].initial.quantity.value == 70 + + +def test_and_connective_admits(): + result = _match_multiplicative_aggregation( + "Tom had 10 apples and lost 4 apples.", _SPEC + ) + assert result is not None + assert result[0][0]["composed_initial"].initial.quantity.value == 6 + + +def test_negative_remainder_refuses(): + """Refusal-preferring: count_b >= count_a is the wrong>0 hazard.""" + result = _match_multiplicative_aggregation( + "Lily had 5 apples and lost 10 apples.", _SPEC + ) + assert result is None + + +def test_equal_remainder_refuses(): + result = _match_multiplicative_aggregation( + "Lily had 5 apples and lost 5 apples.", _SPEC + ) + assert result is None # zero remainder still refuses (initial=0 boundary) + + +def test_pronoun_subject_refuses(): + result = _match_multiplicative_aggregation( + "He had 10 apples, gave 3 apples.", _SPEC + ) + assert result is None + + +def test_determiner_subject_refuses(): + result = _match_multiplicative_aggregation( + "The cat had 10 apples, gave 3 apples.", _SPEC + ) + assert result is None + + +def test_cross_unit_refuses(): + result = _match_multiplicative_aggregation( + "Sam had 50 apples, gave 20 dollars.", _SPEC + ) + assert result is None + + +def test_unobserved_unit_refuses(): + spec = dict(_SPEC) + spec["observed_units"] = ["dollars"] + result = _match_multiplicative_aggregation( + "Sam had 50 apples, gave 20 apples.", spec + ) + assert result is None + + +def test_unknown_initial_verb_refuses(): + result = _match_multiplicative_aggregation( + "Sam adopted 50 apples, gave 20 apples.", _SPEC + ) + assert result is None + + +def test_unknown_removal_verb_refuses(): + result = _match_multiplicative_aggregation( + "Sam had 50 apples, dropped 20 apples.", _SPEC + ) + assert result is None # 'dropped' not in removal verb set + + +def test_gave_away_variant(): + result = _match_multiplicative_aggregation( + "Sam had 50 apples, gave away 20 apples.", _SPEC + ) + assert result is not None + assert result[0][0]["composed_initial"].initial.quantity.value == 30 + + +def test_additive_path_unaffected(): + """ME-3 additive dispatch still works.""" + additive_spec = { + "anchor_kind": "additive_quantity_composition", + "observed_units": ["dollars"], + } + result = _match_multiplicative_aggregation( + "Maria saved 30 dollars in May and 20 dollars in June.", additive_spec + ) + assert result is not None + assert result[0][0]["composed_initial"].initial.quantity.value == 50 + + +def test_multiplicative_aggregate_path_unaffected(): + spec = {"anchor_kind": "multiplicative_aggregate"} + result = _match_multiplicative_aggregation( + "There are 3 bags with 5 items each.", spec + ) + assert result is not None + anchors, intent = result + assert intent == "aggregate" + assert anchors == () + + +def test_anchor_audit_fields(): + result = _match_multiplicative_aggregation( + "Sam had 50 apples, gave 20 apples.", _SPEC + ) + assert result is not None + a = result[0][0] + assert { + "composition_shape", + "composed_initial", + "count_a", + "count_b", + "unit", + "subject", + "initial_verb", + "removal_verb", + "kind", + }.issubset(a.keys()) + + +# --------------------------------------------------------------------------- +# End-to-end via composition_registry. +# --------------------------------------------------------------------------- + + +def _stage_pack(tmp_path: Path, polarity: str = "affirms") -> Path: + pack = tmp_path / "en_core_math_v1" + comp_dir = pack / "compositions" + comp_dir.mkdir(parents=True) + (comp_dir / "subtractive_composition.jsonl").write_text( + json.dumps( + { + "surface_pattern": _SHAPE, + "composition_category": "subtractive_composition", + "polarity": polarity, + "provenance": "test_me4", + "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): + class _R: + spec_id = "test_me4" + + return RecognizerMatch( + recognizer=_R(), # type: ignore[arg-type] + category=ShapeCategory.MULTIPLICATIVE_AGGREGATION, + outcome="admissible", + graph_intent="aggregate", + parsed_anchors=anchors, + ) + + +def test_end_to_end_subtractive_admits(monkeypatch, tmp_path): + pack = _stage_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) + + statement = "Sam had 50 apples, gave 20 apples." + result = _match_multiplicative_aggregation(statement, _SPEC) + assert result is not None + emissions = inject_from_match(_make_match(result[0]), statement) + assert len(emissions) == 1 + assert emissions[0].initial.quantity.value == 30 + + +def test_end_to_end_falsifies_suppresses(monkeypatch, tmp_path): + pack = _stage_pack(tmp_path, polarity="falsifies") + _patch_pack_root(monkeypatch, pack) + + statement = "Sam had 50 apples, gave 20 apples." + result = _match_multiplicative_aggregation(statement, _SPEC) + assert result is not None + emissions = inject_from_match(_make_match(result[0]), statement) + assert emissions == () From 4e751bc4969cda9a2fa9af61198aa2dff5303052 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 27 May 2026 17:24:02 -0700 Subject: [PATCH 2/2] ci: trigger