Merge pull request #403 from AssetOverflow/feat/matcher-extension-subtractive

feat(matcher-extension/ME-4): subtractive composition matcher
This commit is contained in:
Shay 2026-05-27 17:35:05 -07:00 committed by GitHub
commit 1be7e0200f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 418 additions and 0 deletions

View file

@ -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",

View file

@ -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 "<Subject> <init-verb> <N> <unit>(,| then|; etc.) <sub-verb>
# <M> <unit>" (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<subject>[A-Z][a-zA-Z]+)
\s+
(?P<verb_a>had|has|got|owns|owned|earned|saved|made|received|bought)
\s+
(?P<count_a>\d+(?:\.\d+)?)
\s+
(?P<unit_a>[a-z]+)
\s*
(?:,|\sthen\s|;|\s+and\s+then\s+|\s+then\s+|\s+and\s+)
\s*
(?:then\s+)?
(?P<verb_b>lost|spent|gave|donated|paid|removed|sold|used|consumed)
(?:\s+away)?
\s+
(?P<count_b>\d+(?:\.\d+)?)
\s+
(?P<unit_b>[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:

View file

@ -0,0 +1,257 @@
"""ME-4 — subtractive composition matcher tests.
Covers the ``subtractive_quantity_composition`` extension to
``_match_multiplicative_aggregation``. Pattern: ``<Subject> <init-verb>
<N> <unit>(,| then| ;| and then| and) <sub-verb> <M> <unit>`` 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 == ()