Merge pull request #403 from AssetOverflow/feat/matcher-extension-subtractive
feat(matcher-extension/ME-4): subtractive composition matcher
This commit is contained in:
commit
1be7e0200f
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_cross_sentence_subject.py",
|
||||||
"tests/test_me2_case_0019_admits.py",
|
"tests/test_me2_case_0019_admits.py",
|
||||||
"tests/test_me3_additive_composition.py",
|
"tests/test_me3_additive_composition.py",
|
||||||
|
"tests/test_me4_subtractive_composition.py",
|
||||||
),
|
),
|
||||||
"algebra": (
|
"algebra": (
|
||||||
"tests/test_versor_closure.py",
|
"tests/test_versor_closure.py",
|
||||||
|
|
|
||||||
|
|
@ -1012,6 +1012,16 @@ def _match_multiplicative_aggregation(
|
||||||
# ME-3 dispatch — same Literal narrowing keeps the return type
|
# ME-3 dispatch — same Literal narrowing keeps the return type
|
||||||
# consistent ('aggregate' is reused).
|
# consistent ('aggregate' is reused).
|
||||||
return _try_extract_additive_composition_anchor(statement, spec)
|
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":
|
if anchor_kind != "multiplicative_aggregate":
|
||||||
return None
|
return None
|
||||||
padded = _padded_lower(statement)
|
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(
|
def _match_currency_amount(
|
||||||
statement: str, spec: Mapping[str, Any]
|
statement: str, spec: Mapping[str, Any]
|
||||||
) -> tuple[tuple[Mapping[str, Any], ...], Literal["amount"]] | None:
|
) -> 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