feat(matcher-extension/ME-2): cross-sentence subject binding for composition

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).
This commit is contained in:
Shay 2026-05-27 16:27:37 -07:00
parent 0a682f065f
commit 8a9b51af9e
5 changed files with 591 additions and 4 deletions

View file

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

View file

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

View file

@ -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)? $<amount> 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<count>\d+(?:\.\d+)?) # outer count token
\s+
(?P<noun>[a-z][a-z\s]+?) # counted noun phrase
,?\s+
(?:which\s+)?
(?:cost|costs|costing)
\s+
(?P<symbol>[\$£¥])
(?P<amount>\d+(?:\.\d+)?)
\s+
(?P<per_unit>each|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",
]

View file

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

View file

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