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_currency_per_unit.py",
|
||||||
"tests/test_matcher_extension_case_0050_hazard_pin.py",
|
"tests/test_matcher_extension_case_0050_hazard_pin.py",
|
||||||
"tests/test_matcher_extension_end_to_end_admission.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": (
|
"algebra": (
|
||||||
"tests/test_versor_closure.py",
|
"tests/test_versor_closure.py",
|
||||||
|
|
|
||||||
|
|
@ -701,12 +701,24 @@ def parse_and_solve(
|
||||||
# surfaces into solver state) is Phase E follow-up work.
|
# surfaces into solver state) is Phase E follow-up work.
|
||||||
_ratified_registry = _load_ratified_registry_or_empty()
|
_ratified_registry = _load_ratified_registry_or_empty()
|
||||||
per_sentence_choices: list[list[SentenceChoice]] = []
|
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:
|
for s in statement_sentences:
|
||||||
choices = _filtered_statement_choices(s)
|
choices = _filtered_statement_choices(s)
|
||||||
if not choices:
|
if not choices:
|
||||||
if _ratified_registry:
|
if _ratified_registry:
|
||||||
from generate.recognizer_match import match as _recognizer_match
|
from generate.recognizer_match import (
|
||||||
recognizer_match = _recognizer_match(s, _ratified_registry)
|
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:
|
if recognizer_match is not None:
|
||||||
# ADR-0163.D.2 — per-category anchor injection.
|
# ADR-0163.D.2 — per-category anchor injection.
|
||||||
# The matcher may carry populated parsed_anchors that
|
# The matcher may carry populated parsed_anchors that
|
||||||
|
|
@ -783,6 +795,16 @@ def parse_and_solve(
|
||||||
branches_enumerated=0, branches_admissible=0,
|
branches_enumerated=0, branches_admissible=0,
|
||||||
)
|
)
|
||||||
per_sentence_choices.append(_collapse_per_sentence_ties(choices))
|
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.
|
# ADR-0164 Phase 1 — comprehension reader hybrid dispatch.
|
||||||
# When comprehension_reader_questions is True, try the reader FIRST.
|
# 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")
|
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
|
# ADR-0163.B.2 round-2 matchers. Detection-only (return empty
|
||||||
# parsed_anchors) — consistent with Phase D's skip-only wiring. Real
|
# parsed_anchors) — consistent with Phase D's skip-only wiring. Real
|
||||||
|
|
@ -908,13 +1064,29 @@ _MATCHERS: Final[dict[ShapeCategory, Any]] = {
|
||||||
def match(
|
def match(
|
||||||
statement: str,
|
statement: str,
|
||||||
registry: tuple[RatifiedRecognizer, ...],
|
registry: tuple[RatifiedRecognizer, ...],
|
||||||
|
*,
|
||||||
|
prior_subject: str | None = None,
|
||||||
) -> RecognizerMatch | None:
|
) -> RecognizerMatch | None:
|
||||||
"""First-match-wins over *registry*.
|
"""First-match-wins over *registry*.
|
||||||
|
|
||||||
Pure: same ``(statement, registry)`` → same result, byte-identical.
|
Pure: same ``(statement, registry, prior_subject)`` → same result,
|
||||||
Order is registry order (the projection step in
|
byte-identical. Order is registry order (the projection step in
|
||||||
:mod:`generate.recognizer_registry` sorts by ``(review_date,
|
:mod:`generate.recognizer_registry` sorts by ``(review_date,
|
||||||
proposal_id)``).
|
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():
|
if not isinstance(statement, str) or not statement.strip():
|
||||||
return None
|
return None
|
||||||
|
|
@ -924,6 +1096,25 @@ def match(
|
||||||
continue
|
continue
|
||||||
result = matcher(statement, recognizer.canonical_pattern)
|
result = matcher(statement, recognizer.canonical_pattern)
|
||||||
if result is None:
|
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
|
continue
|
||||||
parsed_anchors, graph_intent = result
|
parsed_anchors, graph_intent = result
|
||||||
outcome: Literal["admissible", "inadmissible_by_design"] = (
|
outcome: Literal["admissible", "inadmissible_by_design"] = (
|
||||||
|
|
@ -941,7 +1132,69 @@ def match(
|
||||||
return None
|
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__ = [
|
__all__ = [
|
||||||
"RecognizerMatch",
|
"RecognizerMatch",
|
||||||
"match",
|
"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