diff --git a/core/cli.py b/core/cli.py index 6f5f7ec9..6be521e8 100644 --- a/core/cli.py +++ b/core/cli.py @@ -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", diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 5b28c79f..15895be9 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.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 " and " 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[A-Z][a-zA-Z]+) + \s+ + (?Plost|gained|earned|saved|made|paid|spent|bought|sold|added|removed|received) + \s+ + (?P\d+(?:\.\d+)?) + \s+ + (?P[a-z]+) + (?:\s+[a-z]+\s+[a-z]+)? # optional time/location phrase like "in March" + \s+and\s+ + (?P\d+(?:\.\d+)?) + \s+ + (?P[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: diff --git a/tests/test_me3_additive_composition.py b/tests/test_me3_additive_composition.py new file mode 100644 index 00000000..58ca2dcf --- /dev/null +++ b/tests/test_me3_additive_composition.py @@ -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 == ()