feat(matcher-extension/ME-4): subtractive composition matcher
Extends _match_multiplicative_aggregation with a new branch keyed on
anchor_kind="subtractive_quantity_composition". Pattern:
<Subject> <init-verb> <N> <unit>(,| then| ;| and then| and)
<sub-verb> <M> <unit>
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).
This commit is contained in:
parent
da1a791d8c
commit
11d7e0b607
3 changed files with 418 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
257
tests/test_me4_subtractive_composition.py
Normal file
257
tests/test_me4_subtractive_composition.py
Normal 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 == ()
|
||||
Loading…
Reference in a new issue