From 8a9b51af9ef2d970bbc388ef44b8a9b8a5ca40e9 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 27 May 2026 16:27:37 -0700 Subject: [PATCH 1/2] feat(matcher-extension/ME-2): cross-sentence subject binding for composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admits case 0019's composition sentence via prior_subject resolved from upstream sentences. Stacks on PR #400 (ME-1). Modules ------- - generate/recognizer_match.py: - _CROSS_SENTENCE_COMPOSITION_RE — regex for "requires N noun, which cost(s) $X each" (no subject prefix) - try_extract_cross_sentence_composition_anchor(statement, spec, prior_subject) — refuses on None / empty / pronoun prior_subject; publishes the same composition_shape + composed_initial payload as ME-1, sourced via prior_subject - extract_proper_noun_subject(statement) — head proper-noun extractor used by callers to track running prior_subject; rejects determiners, sentence-initial connectors (After/How/Every/...), and pronouns - match() dispatcher gains keyword-only prior_subject parameter; when a per-category matcher returns None for a RATE_WITH_CURRENCY recognizer with currency_per_unit_composition anchor_kind AND prior_subject is supplied, the cross-sentence helper is tried as a fallback - generate/math_candidate_graph.py: - tracks _prior_subject across statement_sentences iteration - passes prior_subject to recognizer_match.match() - updates _prior_subject from each sentence's head proper-noun Tests (19 new, all green) ------------------------- - test_me2_cross_sentence_subject.py (15 tests) - subject extraction narrowness (proper noun / determiner / connector / pronoun / non-string) - cross-sentence helper happy path + refusals (None, empty, pronoun, unobserved currency / per_unit, wrong anchor_kind, zero count, multi-match) - source_span substring invariant - kind label "currency_per_unit_composition_cross_sentence" - test_me2_case_0019_admits.py (4 tests) - case_0019_admits_with_prior_subject_john — the truth test - case_0019_refuses_without_prior_subject — ME-1 Option A still holds - case_0019_refuses_with_pronoun_prior — refusal-preferring - maria_same_sentence_unaffected_by_prior_subject — ME-1 path intact Registered in core/cli.py "packs" suite. Suite results ------------- core test --suite packs -q → 91 passed (existing + ME-1's 21 + 19 new) core test --suite runtime -q → 20 passed core eval gsm8k_math --split public → 150/150, wrong=0 Scope boundary -------------- The wiring is load-bearing AND tested end-to-end via synthetic recognizer registry (test_case_0019_admits_with_prior_subject_john proves the full chain match → inject → admit). For the LIVE train_sample case 0019 admission, two ratifications must also be seeded (operator workflow outside this PR's code scope): 1. A RatifiedRecognizer in the proposal log with shape_category= RATE_WITH_CURRENCY and canonical_pattern carrying anchor_kind="currency_per_unit_composition" 2. A composition_registry entry for "bound(count) × bound(unit_cost)" under multiplicative_composition with polarity=affirms With both ratifications in place, case 0019 admits via the wiring this PR ships. Without them, the live train_sample run remains at the 3/47 baseline (preserved; no regression). Anti-regression invariants preserved ------------------------------------ - wrong == 0 on gsm8k_math public - Case 0050 hazard pin holds (no _COMPOSITION_SUBJECT_BUY_RE or _CROSS_SENTENCE_COMPOSITION_RE match on case 0050's sentences) - ADR-0166 — no new eval lanes - ADR-0167 partition — no cognition imports - ME-1 Maria same-sentence path byte-identical (test pins) - Existing currency_per_unit_rate path unaffected (test pins) - prior_subject is keyword-only on match() (additive; old callers unaffected) - engine_state/* not committed Stacks on PR #400 (base: feat/matcher-extension-currency-per-unit-composition). --- core/cli.py | 2 + generate/math_candidate_graph.py | 26 ++- generate/recognizer_match.py | 257 ++++++++++++++++++++++- tests/test_me2_case_0019_admits.py | 154 ++++++++++++++ tests/test_me2_cross_sentence_subject.py | 156 ++++++++++++++ 5 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 tests/test_me2_case_0019_admits.py create mode 100644 tests/test_me2_cross_sentence_subject.py diff --git a/core/cli.py b/core/cli.py index 519c2503..6f5f7ec9 100644 --- a/core/cli.py +++ b/core/cli.py @@ -82,6 +82,8 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = { "tests/test_matcher_extension_currency_per_unit.py", "tests/test_matcher_extension_case_0050_hazard_pin.py", "tests/test_matcher_extension_end_to_end_admission.py", + "tests/test_me2_cross_sentence_subject.py", + "tests/test_me2_case_0019_admits.py", ), "algebra": ( "tests/test_versor_closure.py", diff --git a/generate/math_candidate_graph.py b/generate/math_candidate_graph.py index 0e5b6a7d..7dc9259d 100644 --- a/generate/math_candidate_graph.py +++ b/generate/math_candidate_graph.py @@ -701,12 +701,24 @@ def parse_and_solve( # surfaces into solver state) is Phase E follow-up work. _ratified_registry = _load_ratified_registry_or_empty() per_sentence_choices: list[list[SentenceChoice]] = [] + # ME-2 — track a running proper-noun subject across sentences so the + # recognizer matcher can resolve cross-sentence composition shapes + # (e.g. case 0019: "John adopts a dog... 3 vet appointments at + # $400 each"). Update AFTER each statement is processed (the current + # statement's subject is not yet trusted when matching that same + # statement; only prior sentences contribute). + _prior_subject: str | None = None for s in statement_sentences: choices = _filtered_statement_choices(s) if not choices: if _ratified_registry: - from generate.recognizer_match import match as _recognizer_match - recognizer_match = _recognizer_match(s, _ratified_registry) + from generate.recognizer_match import ( + extract_proper_noun_subject as _extract_subj, + match as _recognizer_match, + ) + recognizer_match = _recognizer_match( + s, _ratified_registry, prior_subject=_prior_subject + ) if recognizer_match is not None: # ADR-0163.D.2 — per-category anchor injection. # The matcher may carry populated parsed_anchors that @@ -783,6 +795,16 @@ def parse_and_solve( branches_enumerated=0, branches_admissible=0, ) per_sentence_choices.append(_collapse_per_sentence_ties(choices)) + # ME-2 — update prior_subject AFTER this sentence is processed. + # The current sentence's head proper-noun is now eligible to be + # the cross-sentence subject for the next sentence's composition + # match. + from generate.recognizer_match import ( + extract_proper_noun_subject as _extract_subj_for_update, + ) + _head = _extract_subj_for_update(s) + if _head is not None: + _prior_subject = _head # ADR-0164 Phase 1 — comprehension reader hybrid dispatch. # When comprehension_reader_questions is True, try the reader FIRST. diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 6622adfe..5b28c79f 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -524,6 +524,162 @@ def _try_extract_currency_per_unit_composition_anchor( return ((anchor,), "rate") +# --------------------------------------------------------------------------- +# ME-2 — cross-sentence subject binding (admits case 0019). +# +# Case 0019: "John adopts a dog from a shelter. The dog ends up having +# health problems and this requires 3 vet appointments, which cost +# $400 each." +# +# The composition sentence has no same-sentence proper-noun subject — +# "John" lives in sentence 0. ME-1 (Option A) refuses; ME-2 admits +# when the caller supplies a ``prior_subject`` resolved from the +# upstream sentence trace. +# +# Discipline: +# - The cross-sentence regex requires NO subject prefix; instead it +# keys on a discourse-anaphoric introduction like "which cost $X each" +# or "and this requires N noun" + "$X each" in the same sentence. +# - Caller is responsible for providing a confidence-pinned prior +# subject (most-recent proper-noun subject from prior sentences). +# - The matcher refuses if prior_subject is None / empty / refused. +# --------------------------------------------------------------------------- + +# Shape: `... which cost(s)? $ each` plus a preceding count token. +# Constructed so the count + noun are pulled from the same statement, but +# the subject is supplied externally. +_CROSS_SENTENCE_COMPOSITION_RE: Final[re.Pattern[str]] = re.compile( + r"""(?ix) + \b + (?:requires|require|needs|need|costs|cost) + \s+ + (?P\d+(?:\.\d+)?) # outer count token + \s+ + (?P[a-z][a-z\s]+?) # counted noun phrase + ,?\s+ + (?:which\s+)? + (?:cost|costs|costing) + \s+ + (?P[\$£€¥]) + (?P\d+(?:\.\d+)?) + \s+ + (?Peach|apiece) + \b + """, +) + + +def try_extract_cross_sentence_composition_anchor( + statement: str, + spec: Mapping[str, Any], + prior_subject: str | None, +) -> tuple[tuple[Mapping[str, Any], ...], Literal["rate"]] | None: + """Cross-sentence composition extraction. + + Like :func:`_try_extract_currency_per_unit_composition_anchor` but + sources the subject from ``prior_subject`` instead of a + same-sentence head proper-noun. + + Refuses when: + + - ``prior_subject`` is None / empty / in :data:`_REFUSED_SUBJECT_TOKENS` + - the cross-sentence regex matches zero or multiple times + - currency / per-unit / count narrowness fail (mirrors ME-1) + + The same composition_shape + composed_initial payload as ME-1 is + published. The consumer (composition_registry) gates admission. + """ + if spec.get("anchor_kind") != "currency_per_unit_composition": + return None + if not prior_subject or not isinstance(prior_subject, str): + return None + if prior_subject.lower() in _REFUSED_SUBJECT_TOKENS: + return None + + observed_symbols = set(spec.get("observed_currency_symbols") or ()) + observed_per_units = set(spec.get("observed_per_units") or ()) + if not observed_symbols or not observed_per_units: + return None + + matches = list(_CROSS_SENTENCE_COMPOSITION_RE.finditer(statement)) + if len(matches) != 1: + return None + + m = matches[0] + symbol = m.group("symbol") + if symbol not in observed_symbols: + return None + per_unit_lc = m.group("per_unit").lower() + if per_unit_lc not in observed_per_units: + return None + if per_unit_lc not in _PER_ITEM_TOKENS: + return None + + count_token = m.group("count") + amount_token = m.group("amount") + try: + outer_count: float = float(count_token) + unit_cost: float = float(amount_token) + except ValueError: + return None + if outer_count <= 0 or unit_cost <= 0: + return None + + composed_value_f = outer_count * unit_cost + 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_token + and "." not in amount_token + ): + composed_value = int(composed_value_f) + else: + composed_value = composed_value_f + + unit = _CURRENCY_SYMBOL_TO_UNIT.get(symbol) + if unit is None: + return None + + from generate.math_candidate_parser import CandidateInitial + from generate.math_problem_graph import InitialPossession, Quantity + + # Validate prior_subject can satisfy CandidateInitial.entity. + entity = prior_subject.strip() + if not entity: + return None + + composed_initial = CandidateInitial( + initial=InitialPossession( + entity=entity, + quantity=Quantity(value=composed_value, unit=unit), + ), + source_span=m.group(0), + matched_anchor="bought", # canonical buy-anchor for the whitelist + matched_value_token=str(composed_value), + matched_unit_token=unit, + matched_entity_token=entity, + ) + + anchor: Mapping[str, Any] = { + "kind": "currency_per_unit_composition_cross_sentence", + "composition_shape": _COMPOSITION_SHAPE_MULTIPLICATIVE, + "composed_initial": composed_initial, + "currency_symbol": symbol, + "amount": amount_token, + "per_unit": per_unit_lc, + "outer_count": count_token, + "subject": entity, + "subject_source": "prior_sentence", + } + return ((anchor,), "rate") + + +# Refused subjects mirrors the constant defined later in this module +# (used by both the same-sentence and cross-sentence extractors). + + # --------------------------------------------------------------------------- # ADR-0163.B.2 round-2 matchers. Detection-only (return empty # parsed_anchors) — consistent with Phase D's skip-only wiring. Real @@ -908,13 +1064,29 @@ _MATCHERS: Final[dict[ShapeCategory, Any]] = { def match( statement: str, registry: tuple[RatifiedRecognizer, ...], + *, + prior_subject: str | None = None, ) -> RecognizerMatch | None: """First-match-wins over *registry*. - Pure: same ``(statement, registry)`` → same result, byte-identical. - Order is registry order (the projection step in + Pure: same ``(statement, registry, prior_subject)`` → same result, + byte-identical. Order is registry order (the projection step in :mod:`generate.recognizer_registry` sorts by ``(review_date, proposal_id)``). + + ME-2 (cross-sentence subject binding) — when the per-category + matcher returns ``None`` for a ``RATE_WITH_CURRENCY`` recognizer + AND ``prior_subject`` is supplied, this dispatcher additionally + tries + :func:`try_extract_cross_sentence_composition_anchor`. Admitting + the case 0019 sentence shape requires both: + + - a ratified recognizer carrying + ``anchor_kind = "currency_per_unit_composition"`` + - a non-empty ``prior_subject`` resolved from upstream sentences + + Refusal-preferring discipline is preserved: ``prior_subject=None`` + + same-sentence Option A regex miss → returns ``None``. """ if not isinstance(statement, str) or not statement.strip(): return None @@ -924,6 +1096,25 @@ def match( continue result = matcher(statement, recognizer.canonical_pattern) if result is None: + if ( + recognizer.shape_category is ShapeCategory.RATE_WITH_CURRENCY + and recognizer.canonical_pattern.get("anchor_kind") + == "currency_per_unit_composition" + and prior_subject is not None + ): + cross_result = try_extract_cross_sentence_composition_anchor( + statement, recognizer.canonical_pattern, prior_subject + ) + if cross_result is None: + continue + parsed_anchors, graph_intent = cross_result + return RecognizerMatch( + recognizer=recognizer, + category=recognizer.shape_category, + outcome="admissible", + graph_intent=graph_intent, + parsed_anchors=parsed_anchors, + ) continue parsed_anchors, graph_intent = result outcome: Literal["admissible", "inadmissible_by_design"] = ( @@ -941,7 +1132,69 @@ def match( return None +# --------------------------------------------------------------------------- +# Cross-sentence subject resolution helper (ME-2). +# --------------------------------------------------------------------------- + +_PROPER_NOUN_SUBJECT_RE: Final[re.Pattern[str]] = re.compile( + r"^\s*([A-Z][a-zA-Z]+)\b" +) + + +_COMMON_DETERMINERS_AT_HEAD: Final[frozenset[str]] = frozenset( + { + # Articles + demonstratives + "the", "a", "an", "this", "that", "these", "those", + # Possessives + "his", "her", "their", "its", "my", "your", "our", + # Sentence-initial connectors / prepositions that get capitalized + "after", "before", "when", "while", "if", "then", "so", "but", + "and", "or", "during", "since", "until", "though", "although", + "however", "moreover", "additionally", "first", "next", "later", + "finally", "now", "soon", "today", "tomorrow", "yesterday", + "every", "all", "some", "many", "each", "another", "other", + "in", "on", "at", "by", "for", "from", "with", "without", + "how", "why", "what", "where", "who", "when", + } +) + + +def extract_proper_noun_subject(statement: str) -> str | None: + """Return the head proper-noun subject of *statement*, or None. + + Used by callers (e.g. ``generate.math_candidate_graph``) to track a + running ``prior_subject`` across sentences for cross-sentence + composition binding (ME-2). + + Heuristic narrowness: + + - The head token must match ``[A-Z][a-zA-Z]+``. + - The lowercased head must NOT be in :data:`_REFUSED_SUBJECT_TOKENS` + (existing pronoun set) or + :data:`_COMMON_DETERMINERS_AT_HEAD` (articles + demonstratives + + possessives that get capitalized at sentence start but are not + proper nouns). + + Refuses on any ambiguity. The caller is expected to update the + running ``prior_subject`` only when this returns a non-None value. + """ + if not isinstance(statement, str): + return None + m = _PROPER_NOUN_SUBJECT_RE.match(statement) + if m is None: + return None + cand = m.group(1) + lc = cand.lower() + if lc in _REFUSED_SUBJECT_TOKENS: + return None + if lc in _COMMON_DETERMINERS_AT_HEAD: + return None + return cand + + __all__ = [ "RecognizerMatch", "match", + "extract_proper_noun_subject", + "try_extract_cross_sentence_composition_anchor", ] diff --git a/tests/test_me2_case_0019_admits.py b/tests/test_me2_case_0019_admits.py new file mode 100644 index 00000000..8c4e191c --- /dev/null +++ b/tests/test_me2_case_0019_admits.py @@ -0,0 +1,154 @@ +"""ME-2 — the load-bearing truth test: case 0019 admits via cross-sentence subject. + +End-to-end: ratify ``bound(count) × bound(unit_cost)`` under +``multiplicative_composition``, then run the match dispatcher with +``prior_subject="John"`` on case 0019's composition sentence, then +feed the recognizer match into ``inject_from_match``. The composition +registry consult must produce a ``CandidateInitial`` with +``entity="John"`` and ``value=1200``. + +This is the real-world canary that was deferred by ME-1's Option A. +""" + +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 +from generate.recognizer_registry import RatifiedRecognizer +from language_packs.compile_compositions import compile_compositions + + +_SHAPE = "bound(count) × bound(unit_cost)" + +_CASE_0019_COMPOSITION_SENTENCE = ( + "The dog ends up having health problems and this requires " + "3 vet appointments, which cost $400 each." +) + + +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 / "multiplicative_composition.jsonl").write_text( + json.dumps( + { + "surface_pattern": _SHAPE, + "composition_category": "multiplicative_composition", + "polarity": "affirms", + "provenance": "test_me2_case_0019", + "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 _synthetic_registry() -> tuple[RatifiedRecognizer, ...]: + """Build a one-entry registry with a currency_per_unit_composition spec.""" + canonical_pattern: Mapping[str, Any] = { + "anchor_kind": "currency_per_unit_composition", + "observed_currency_symbols": ["$"], + "observed_per_units": ["each", "apiece"], + } + rec = RatifiedRecognizer( + proposal_id="test_me2_proposal_id", + shape_category=ShapeCategory.RATE_WITH_CURRENCY, + canonical_pattern=canonical_pattern, + spec_digest="0" * 64, + review_date="2026-05-27", + ratified_at_revision="test", + ) + return (rec,) + + +def setup_function(_): + clear_composition_cache() + + +def test_case_0019_admits_with_prior_subject_john(monkeypatch, tmp_path): + pack = _stage_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) + + registry = _synthetic_registry() + # ME-2: dispatcher with prior_subject "John" (resolved from sentence 0). + m = match(_CASE_0019_COMPOSITION_SENTENCE, registry, prior_subject="John") + assert m is not None + assert isinstance(m, RecognizerMatch) + anchor = m.parsed_anchors[0] + assert anchor["composition_shape"] == _SHAPE + assert anchor["subject"] == "John" + + emissions = inject_from_match(m, _CASE_0019_COMPOSITION_SENTENCE) + assert len(emissions) == 1 + composed = emissions[0] + assert isinstance(composed, CandidateInitial) + assert composed.initial.entity == "John" + assert composed.initial.quantity.value == 1200 + assert composed.initial.quantity.unit == "dollars" + + +def test_case_0019_refuses_without_prior_subject(monkeypatch, tmp_path): + """Same sentence, no prior_subject → ME-1 Option A still refuses.""" + pack = _stage_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) + + registry = _synthetic_registry() + m = match(_CASE_0019_COMPOSITION_SENTENCE, registry, prior_subject=None) + assert m is None + + +def test_case_0019_refuses_with_pronoun_prior(monkeypatch, tmp_path): + pack = _stage_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) + + registry = _synthetic_registry() + m = match(_CASE_0019_COMPOSITION_SENTENCE, registry, prior_subject="He") + # Pronouns are rejected in cross-sentence binding (refusal-preferring) + assert m is None + + +def test_maria_same_sentence_unaffected_by_prior_subject(monkeypatch, tmp_path): + """ME-1's same-sentence path still works; prior_subject is irrelevant.""" + pack = _stage_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) + + registry = _synthetic_registry() + # Same-sentence subject "Maria" wins; prior_subject argument is ignored + # because the regular matcher hits first. + statement = "Maria bought 3 vet appointments at $400 each." + m = match(statement, registry, prior_subject="John") + assert m is not None + composed = inject_from_match(m, statement)[0] + assert isinstance(composed, CandidateInitial) + # Same-sentence subject wins; the head Maria is the entity. + assert composed.initial.entity == "Maria" diff --git a/tests/test_me2_cross_sentence_subject.py b/tests/test_me2_cross_sentence_subject.py new file mode 100644 index 00000000..9429203f --- /dev/null +++ b/tests/test_me2_cross_sentence_subject.py @@ -0,0 +1,156 @@ +"""ME-2 — cross-sentence subject binding tests. + +Covers: +- ``extract_proper_noun_subject`` narrowness (proper noun vs determiner + vs sentence-initial connector vs pronoun) +- ``try_extract_cross_sentence_composition_anchor`` happy path + refusals +- ``match`` dispatcher cross-sentence fallback (prior_subject supplied + AND same-sentence Option A miss → cross-sentence path fires) +- case 0019 sentence shape admits when prior_subject="John" +- refusal-preferring on None / empty / pronoun prior_subject +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from generate.math_candidate_parser import CandidateInitial +from generate.recognizer_match import ( + extract_proper_noun_subject, + try_extract_cross_sentence_composition_anchor, +) + + +_SPEC: Mapping[str, Any] = { + "anchor_kind": "currency_per_unit_composition", + "observed_currency_symbols": ["$"], + "observed_per_units": ["each", "apiece"], +} + +_CASE_0019 = ( + "The dog ends up having health problems and this requires " + "3 vet appointments, which cost $400 each." +) + + +def test_proper_noun_head_extracted(): + assert extract_proper_noun_subject("John adopts a dog from a shelter.") == "John" + assert extract_proper_noun_subject("Maria bought 3 things.") == "Maria" + assert extract_proper_noun_subject("Sam Saves 5 dollars.") == "Sam" + + +def test_determiner_head_refused(): + assert extract_proper_noun_subject("The dog ends up sick.") is None + assert extract_proper_noun_subject("A boy walks home.") is None + assert extract_proper_noun_subject("Their car is red.") is None + + +def test_sentence_initial_connector_refused(): + assert extract_proper_noun_subject("After 2 years, John retires.") is None + assert extract_proper_noun_subject("How much did Marco spend?") is None + assert extract_proper_noun_subject("In May, sales doubled.") is None + assert extract_proper_noun_subject("Every Tuesday Maria buys milk.") is None + + +def test_pronoun_head_refused(): + assert extract_proper_noun_subject("He walks home.") is None + assert extract_proper_noun_subject("They are happy.") is None + assert extract_proper_noun_subject("It costs $5.") is None + + +def test_non_string_returns_none(): + assert extract_proper_noun_subject(None) is None # type: ignore[arg-type] + assert extract_proper_noun_subject(123) is None # type: ignore[arg-type] + + +def test_cross_sentence_happy_path(): + result = try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, "John") + assert result is not None + anchor = result[0][0] + assert anchor["composition_shape"] == "bound(count) × bound(unit_cost)" + assert anchor["subject"] == "John" + assert anchor["subject_source"] == "prior_sentence" + composed = anchor["composed_initial"] + assert isinstance(composed, CandidateInitial) + assert composed.initial.entity == "John" + assert composed.initial.quantity.value == 1200 + assert composed.initial.quantity.unit == "dollars" + + +def test_cross_sentence_no_prior_subject_refuses(): + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, None) is None + ) + assert try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, "") is None + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, " ") is None + ) + + +def test_cross_sentence_pronoun_prior_refused(): + """Prior subject in refused-pronoun set → refuse.""" + for pronoun in ("he", "She", "They", "it"): + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, pronoun) + is None + ) + + +def test_cross_sentence_unobserved_currency_refuses(): + spec = dict(_SPEC) + spec["observed_currency_symbols"] = ["£"] + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, "John") is not None + ) + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, spec, "John") is None + ) + + +def test_cross_sentence_per_unit_outside_observed_refuses(): + spec = dict(_SPEC) + spec["observed_per_units"] = ["hour"] + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, spec, "John") is None + ) + + +def test_cross_sentence_wrong_anchor_kind_refuses(): + spec = dict(_SPEC) + spec["anchor_kind"] = "currency_per_unit_rate" + assert ( + try_extract_cross_sentence_composition_anchor(_CASE_0019, spec, "John") is None + ) + + +def test_cross_sentence_zero_count_refuses(): + statement = ( + "The dog ends up having health problems and this requires " + "0 vet appointments, which cost $400 each." + ) + assert ( + try_extract_cross_sentence_composition_anchor(statement, _SPEC, "John") is None + ) + + +def test_cross_sentence_multi_match_refuses(): + """Two composition shapes in one statement → ambiguity refuses.""" + statement = ( + "this requires 3 vet appointments, which cost $400 each " + "and also requires 2 items, which cost $50 each" + ) + result = try_extract_cross_sentence_composition_anchor(statement, _SPEC, "John") + assert result is None + + +def test_cross_sentence_source_span_is_substring(): + result = try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, "John") + assert result is not None + span = result[0][0]["composed_initial"].source_span + assert span in _CASE_0019 + + +def test_cross_sentence_kind_label(): + result = try_extract_cross_sentence_composition_anchor(_CASE_0019, _SPEC, "John") + assert result is not None + assert result[0][0]["kind"] == "currency_per_unit_composition_cross_sentence" From a2c39282c56f3d1bcc852b8affea0537b43f67dc Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 27 May 2026 17:01:51 -0700 Subject: [PATCH 2/2] ci: trigger smoke + lane-shas