fix(roundtrip): multi-word units ground when every component appears in source

_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.
This commit is contained in:
Shay 2026-05-28 07:49:24 -07:00
parent 58294c7722
commit 86d4e98d5c
2 changed files with 49 additions and 0 deletions

View file

@ -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

View file

@ -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).