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:
parent
0a682f065f
commit
8a9b51af9e
5 changed files with 591 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
154
tests/test_me2_case_0019_admits.py
Normal file
154
tests/test_me2_case_0019_admits.py
Normal 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"
|
||||
156
tests/test_me2_cross_sentence_subject.py
Normal file
156
tests/test_me2_cross_sentence_subject.py
Normal 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"
|
||||
Loading…
Reference in a new issue