Merge pull request #402 from AssetOverflow/feat/matcher-extension-multi-quantity
feat(matcher-extension/ME-3): additive composition matcher
This commit is contained in:
commit
da1a791d8c
3 changed files with 449 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
280
tests/test_me3_additive_composition.py
Normal file
280
tests/test_me3_additive_composition.py
Normal 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 == ()
|
||||
Loading…
Reference in a new issue