From 86d4e98d5c93d6fbf8efeece5790ac199469725d Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 28 May 2026 07:49:24 -0700 Subject: [PATCH] fix(roundtrip): multi-word units ground when every component appears in source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _unit_grounds() previously refused multi-word units like 'Pokemon cards' even when both component words appeared as tokens in the source span. The function checked unit_token against the haystack as a single key, but the tokenizer splits source into per-word tokens — 'Pokemon cards' was never going to match. Fix is conjunctive by design: every component word must appear in the haystack. A missing component refuses, preserving wrong=0. Truth-test: case 0023 (Nicole/Pokemon cards) previously refused with 'recognizer matched but produced no injection' on its first sentence. After this fix, sentence 1 passes injection cleanly; the case now refuses on sentence 2 (Cindy/Rex compositional clause) — a more honest refusal reason that reflects the actual remaining gap. Score unchanged at 3/47/0 (no overall lift; correctness win). smoke 67/67, packs 141/141, lanes 8/8 all green. --- generate/math_roundtrip.py | 7 ++++++ tests/test_math_roundtrip.py | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index a03985f3..5ec361f3 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -293,6 +293,13 @@ def _unit_grounds( if _token_in(unit_token, haystack_tokens): return True lower = unit_token.lower() + # Multi-word units (e.g. "Pokemon cards", "stop signs") ground when + # every component appears as a word token in source. Conjunctive by + # design — a missing component means the unit cannot be reconstructed + # from the source, which preserves wrong=0. + parts = lower.split() + if len(parts) > 1 and all(p in haystack_tokens for p in parts): + return True if lower in ("cent", "cents"): if "$" in source_span or "¢" in source_span: return True diff --git a/tests/test_math_roundtrip.py b/tests/test_math_roundtrip.py index 92b9bfb8..df1da6fd 100644 --- a/tests/test_math_roundtrip.py +++ b/tests/test_math_roundtrip.py @@ -338,6 +338,48 @@ class TestNumberGrounding: assert roundtrip_admissible(c) +class TestMultiWordUnitGrounding: + """Multi-word units (e.g. 'Pokemon cards') ground when every + component word appears in source. Conjunctive — missing one + component refuses, preserving wrong=0.""" + + def test_two_word_unit_grounds_when_both_components_present(self) -> None: + c = CandidateOperation( + op=Operation(actor="Nicole", kind="add", + operand=Quantity(value=400, unit="Pokemon cards")), + source_span="Nicole collected 400 Pokemon cards.", + matched_verb="collected", + matched_value_token="400", + matched_unit_token="Pokemon cards", + matched_actor_token="Nicole", + ) + assert roundtrip_admissible(c) + + def test_two_word_unit_refuses_when_one_component_missing(self) -> None: + c = CandidateOperation( + op=Operation(actor="Nicole", kind="add", + operand=Quantity(value=400, unit="Pokemon cards")), + source_span="Nicole collected 400 cards.", # 'Pokemon' missing + matched_verb="collected", + matched_value_token="400", + matched_unit_token="Pokemon cards", + matched_actor_token="Nicole", + ) + assert not roundtrip_admissible(c) + + def test_single_word_unit_unaffected(self) -> None: + c = CandidateOperation( + op=Operation(actor="Sam", kind="add", + operand=Quantity(value=3, unit="apples")), + source_span="Sam buys 3 apples.", + matched_verb="buys", + matched_value_token="3", + matched_unit_token="apples", + matched_actor_token="Sam", + ) + assert roundtrip_admissible(c) + + # --------------------------------------------------------------------------- # Constructor validation — illegal CandidateOperation states are # refused at construction (not at filter time).