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"