Merge pull request #402 from AssetOverflow/feat/matcher-extension-multi-quantity

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

View file

@ -84,6 +84,7 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = {
"tests/test_matcher_extension_end_to_end_admission.py",
"tests/test_me2_cross_sentence_subject.py",
"tests/test_me2_case_0019_admits.py",
"tests/test_me3_additive_composition.py",
),
"algebra": (
"tests/test_versor_closure.py",

View file

@ -997,8 +997,22 @@ def _match_multiplicative_aggregation(
- statement does NOT carry currency-per-unit framing
Returns ``(empty parsed_anchors, "aggregate")`` on a hit.
ME-3 (ADR-0169 additive composition) when
``spec["anchor_kind"] == "additive_quantity_composition"`` this
matcher dispatches to :func:`_try_extract_additive_composition_anchor`
which publishes ``composition_shape`` + a pre-composed
:class:`CandidateInitial` in ``parsed_anchors`` for two same-unit
quantities connected by ``and``. The graph_intent is widened from
``"aggregate"`` to also include ``"additive"`` so the dispatcher in
:func:`match` can recognize composition emissions.
"""
if spec.get("anchor_kind") != "multiplicative_aggregate":
anchor_kind = spec.get("anchor_kind")
if anchor_kind == "additive_quantity_composition":
# 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 != "multiplicative_aggregate":
return None
padded = _padded_lower(statement)
if not any(c in padded for c in _MULTIPLICATIVE_CONNECTIVES):
@ -1017,6 +1031,159 @@ def _match_multiplicative_aggregation(
return (tuple(), "aggregate")
# ---------------------------------------------------------------------------
# ME-3 — additive composition matcher.
#
# Admits "<count_a> <unit> and <count_b> <unit>" shape (same unit) and
# emits a pre-composed CandidateInitial whose value is the sum.
#
# Subject-binding discipline:
# - SAME-SENTENCE proper-noun subject preferred (Option A from ME-1).
# - When absent, the caller MAY supply ``prior_subject`` via the match()
# dispatcher (ME-2 path); the ME-3 helper does NOT itself consult
# ``prior_subject`` — that path is reserved for the cross-sentence
# composition extension (a future ME-3b if needed). v1 ME-3 narrowness
# matches the dispatch pack: refuse on subject-absent.
# - Pronoun subject refused (mirrors existing _REFUSED_SUBJECT_TOKENS).
# ---------------------------------------------------------------------------
_ADDITIVE_TWO_QUANTITY_RE: Final[re.Pattern[str]] = re.compile(
r"""(?ix)
^\s*
(?P<subject>[A-Z][a-zA-Z]+)
\s+
(?P<verb>lost|gained|earned|saved|made|paid|spent|bought|sold|added|removed|received)
\s+
(?P<count_a>\d+(?:\.\d+)?)
\s+
(?P<unit_a>[a-z]+)
(?:\s+[a-z]+\s+[a-z]+)? # optional time/location phrase like "in March"
\s+and\s+
(?P<count_b>\d+(?:\.\d+)?)
\s+
(?P<unit_b>[a-z]+)
(?:\s+[a-z]+\s+[a-z]+)? # optional second time/location phrase
\b
""",
)
_ADDITIVE_COMPOSITION_SHAPE: Final[str] = "bound(qty_a) + bound(qty_b)"
def _try_extract_additive_composition_anchor(
statement: str, spec: Mapping[str, Any]
) -> tuple[tuple[Mapping[str, Any], ...], Literal["aggregate"]] | None:
"""Extract a pre-composed CandidateInitial for additive composition.
Narrowness layers (all required):
1. ``spec["anchor_kind"] == "additive_quantity_composition"`` (caller)
2. ``spec["observed_units"]`` is non-empty
3. Exactly one match of :data:`_ADDITIVE_TWO_QUANTITY_RE`
4. ``unit_a == unit_b`` (same-unit composition only; cross-unit
addition is ill-defined without a conversion table refuse)
5. Both unit tokens in ``observed_units``
6. Both counts are positive
7. Subject is a proper noun not in :data:`_REFUSED_SUBJECT_TOKENS`
8. Verb in :data:`_ADDITIVE_COMPOSITION_VERBS`
Refuses on any failure; refusal-preferring discipline.
"""
if spec.get("anchor_kind") != "additive_quantity_composition":
return None
observed_units = set(spec.get("observed_units") or ())
if not observed_units:
return None
matches = list(_ADDITIVE_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 = m.group("verb").lower()
if verb not in _ADDITIVE_COMPOSITION_VERBS:
return None
unit_a = m.group("unit_a").lower()
unit_b = m.group("unit_b").lower()
# Strip trailing 's' for plural normalization on the comparison
# (apples vs apple). Refuse on stem mismatch.
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
composed_value_f = count_a + count_b
if composed_value_f != composed_value_f: # NaN guard
return None
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
# Verb whitelist maps to a CandidateInitial.matched_anchor value
# the post-init guard accepts (existing whitelist includes
# has/have/had/saved/earned/got/received/bought/made/paid).
matched_anchor = verb if verb in {
"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": "additive_quantity_composition",
"composition_shape": _ADDITIVE_COMPOSITION_SHAPE,
"composed_initial": composed_initial,
"count_a": count_a_token,
"count_b": count_b_token,
"unit": canonical_unit,
"subject": subject,
"verb": verb,
}
return ((anchor,), "aggregate")
_ADDITIVE_COMPOSITION_VERBS: Final[frozenset[str]] = frozenset({
"lost", "gained", "earned", "saved", "made", "paid", "spent",
"bought", "sold", "added", "removed", "received",
})
def _match_currency_amount(
statement: str, spec: Mapping[str, Any]
) -> tuple[tuple[Mapping[str, Any], ...], Literal["amount"]] | None:

View file

@ -0,0 +1,280 @@
"""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 == ()