"""ADR-0126 — Candidate-emitting sentence parser. Sibling to ``generate/math_parser.py``. Same regex spirit, different topology: instead of first-match-wins with a single mutable state and ``ParseError`` on miss, each per-sentence extractor returns a *list of candidates* (possibly empty) carrying full source-span provenance. The wrong-answer firewall is :func:`generate.math_roundtrip.roundtrip_admissible`, applied downstream in P3 (graph assembly). This module's job is purely to *enumerate* the parses the grammar admits — telling truth from falsehood is not its concern. Determinism: candidate lists are returned in deterministic order (canonical pattern key); the same input always produces the same ordered output. Scope of P2 (this module): - Initial-possession candidate extraction. - Operation candidate extraction for add / subtract / transfer via the canonical " [to ]" shape. - Permissive verb tables imported from :data:`generate.math_roundtrip.KIND_TO_VERBS` — much wider than ``math_parser._ADD_VERBS`` / ``_SUBTRACT_VERBS`` / ``_TRANSFER_VERBS`` because the round-trip filter rejects wrong candidates downstream. Out of scope for P2 (added in later phases): - Pronoun resolution (needs per-branch state — P3). - Unit inheritance from ``last_unit`` (needs per-branch state — P3). - Multiply / divide / rate / comparison candidates (later phases of ADR-0126; the candidate-emission machinery is identical, just more pattern matchers). """ from __future__ import annotations import re from dataclasses import dataclass from typing import Final, Literal, Mapping, cast from generate.math_problem_graph import ( Comparison, InitialPossession, Operation, Quantity, Unknown, ) from generate.math_roundtrip import ( ADD_VERBS, SUBTRACT_VERBS, TRANSFER_VERBS, WORD_NUMBERS, CandidateOperation, ) # Locally re-typed alias mirroring Comparison.direction's literal slot — # used only to satisfy pyright when narrowing surface-direction strings. _CompDirection = Literal["more", "fewer", "times", "fraction"] # --------------------------------------------------------------------------- # Initial-possession candidate # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class CandidateInitial: """Initial-possession candidate with source-span provenance. Mirrors :class:`CandidateOperation` but for ``InitialPossession``. The round-trip filter for initials is the same shape: every claimed content slot (entity, value, unit, anchor verb 'has'/'have') must ground in the source sentence. """ initial: InitialPossession source_span: str matched_anchor: str # 'has' or 'have' matched_value_token: str # '3' or 'three' matched_unit_token: str matched_entity_token: str # RAT-1 — composed-candidate evidence. When non-None this candidate # was produced by a registry-gated composition (ADR-0169) rather # than a literal extraction; the value/unit/entity are DERIVED, so # the admissibility gate checks each composition INPUT grounds in # source_span instead of the derived value. Schema keys: # count_token, amount_token, currency_symbol, composition_shape, # entity_source. composition_evidence: Mapping[str, str] | None = None def __post_init__(self) -> None: # ADR-0127 widens the anchor set to include 'there are/were/is/was' # for the implicit-subject initial-possession shape. # # ADR-0131.G.1: _INITIAL_HAS_RE itself only emits has/have/had/started # — acquisition verbs (buys, bought, sells, collected, saved, makes) # live exclusively in ADD_VERBS / SUBTRACT_VERBS so a sentence like # "Sam buys 3 apples" parses as an add-operation only, avoiding # branch-disagreement when a canonical "has" initial precedes it. # # ADR-0131.G.4 introduces a separate conjoined-subject-each extractor # that legitimately emits CandidateInitial with a wider set of # state-introducing verbs (saved/earned/got/received/bought/made/paid + # inflections) for the closed shape "A and B each N ". # That extractor is the only path into these wider anchors. The # whitelist below is the runtime safety net for both paths. if self.matched_anchor.lower() not in ( "has", "have", "had", "started", "are", "were", "is", "was", "save", "saved", "earn", "earned", "get", "got", "gets", "receive", "received", "receives", "buy", "bought", "buys", "make", "made", "makes", "pay", "paid", "pays", ): raise ValueError( f"CandidateInitial.matched_anchor must be a registered initial-" f"state anchor; got {self.matched_anchor!r}" ) # --------------------------------------------------------------------------- # Shared regex building blocks # --------------------------------------------------------------------------- # Title-cased proper noun OR "the " collective. Same widening as # math_parser._INITIAL_HAS_RE's ADR-0123a entity slot. _ENTITY: Final[str] = r"(?:[A-Z]\w+|[Tt]he\s+\w+)" # Numeric value alternation. Listed longest-form-first so the regex # engine doesn't truncate on a shorter prefix: # - Money symbol literal: ``$N`` / ``$N.NN`` (1-2 decimal places) plus # multi-currency symbols ``¢N`` ``€N`` ``£N`` ``¥N`` ``₱N``. # ADR-0131.G.3.1. ``$N.NNN`` (3+ decimals) deliberately not matched # — refused as out-of-scope so wrong == 0 is preserved. # - Slash fraction literal: ``N/M``. Denominator-zero refused at # resolve time, not regex. # - Hyphenated multi-word cardinal: ``twenty-five``, ``ninety-nine``. # Resolved via :func:`language_packs.numerics_loader.parse_compound_cardinal`. # - Digit run. # - Single-word cardinal (legacy ``WORD_NUMBERS`` set). # ADR-0131.G.3.1: multi-currency symbol group. ¢ and $ are the only # non-decimal currencies (sub-unit is the unit itself for ¢; $ converts # to cents). €, £, ₱ admit 1-2 decimal places; ¥ is integer-only. _MONEY_SYMBOL: Final[str] = ( r"(?:\$\d+(?:\.\d{1,2})?|¢\d+|€\d+(?:\.\d{1,2})?|£\d+(?:\.\d{1,2})?|¥\d+|₱\d+(?:\.\d{1,2})?)" ) _SLASH_FRACTION: Final[str] = r"\d+/\d+" _HYPHENATED_CARDINAL: Final[str] = r"[A-Za-z]+-[A-Za-z]+" _WORD_NUM_OPTIONS: Final[str] = "|".join( re.escape(w) for w in sorted(WORD_NUMBERS.keys(), key=len, reverse=True) ) _VALUE: Final[str] = ( rf"(?:{_MONEY_SYMBOL}|{_SLASH_FRACTION}|" rf"{_HYPHENATED_CARDINAL}|" rf"\d+|{_WORD_NUM_OPTIONS})" ) # Verb alternation built from the permissive registry. Pre-compute one # pattern per kind so we can attribute matched verbs to candidates. def _verbs_pattern(verbs: frozenset[str]) -> str: # Longest-first so "passes" matches before "pass" inside the alternation. options = sorted(verbs, key=len, reverse=True) return r"(?:" + "|".join(re.escape(v) for v in options) + r")" _ADD_VERBS_PATTERN: Final[str] = _verbs_pattern(ADD_VERBS) _SUBTRACT_VERBS_PATTERN: Final[str] = _verbs_pattern(SUBTRACT_VERBS) _TRANSFER_VERBS_PATTERN: Final[str] = _verbs_pattern(TRANSFER_VERBS) # --------------------------------------------------------------------------- # Initial-possession extractor # --------------------------------------------------------------------------- # ADR-0131.G1 note: acquisition/action verbs (buys, bought, sells, # collected, saved, makes) were removed from the anchor alternation here. # They live exclusively in ADD_VERBS / SUBTRACT_VERBS so that sentences # like "Sam buys 3 apples" are parsed as add-operations only, avoiding # branch-disagreement when a canonical "has" initial precedes them. # The solver defaults-from-zero for operations, so single-statement # acquisition sentences ("Sam buys 5 apples. How many does Sam have?") # still resolve correctly as 0 + 5 = 5. _INITIAL_HAS_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+" # ADR-0131.G.1: pure-possession anchors only (with optional particle # for "had started with N", etc.). Acquisition verbs live in # ADD_VERBS / SUBTRACT_VERBS — see CandidateInitial.__post_init__. rf"(?Phas|have|had|started)(?:\s+(?:up|with))?\s+" rf"(?P{_VALUE})" # ADR-0131.G.3: unit slot is optional. Money-symbol value literals # (``$40``) carry their unit implicitly (``cent``); a missing unit # slot is admissible IFF the value resolves with a unit override. # Non-money values without a unit slot are refused at resolve time. # ADR-0131.G.3.1 axis 4: optional adjective between value and unit # ("five full boxes" — adjective 'full' is consumed and discarded; # the unit head noun 'boxes' becomes the unit slot). r"(?:\s+(?:full|loose|empty|whole|broken|new|old|small|large|fresh|raw|flat))?" r"(?:\s+(?P\w+))?" # ADR-0127 substance qualifier: "Sam has 5 feet of rope" — the # 'of ' tail is grammatically real but arithmetically inert. # ADR-0131.G.3: 'in ' is also discardable # ("Bob has $40 in savings"; "Bob has $40 in his wallet"). r"(?:\s+(?:of|in|for|with)\s+.+)?" r"\s*\.?$" ) # ADR-0127 "There are/were N [in ]" initial-possession shape. # The implicit-subject anchor 'there are' is the only initial-possession # shape that doesn't name an entity in the source; we treat the # place phrase (when present) as the entity and treat the unit as the # count noun. When no place is named, the entity is the unit itself # (collective). Indefinite quantifiers ('some', 'few', 'many') in the # value slot are refused upstream by extract_initial_candidates via # the quantifier-driven refusal helper (ADR-0128.4). _INITIAL_THERE_ARE_RE: Final[re.Pattern[str]] = re.compile( r"^There\s+(?Pare|were|is|was)\s+" rf"(?P{_VALUE})\s+" r"(?P\w+)" r"(?:\s+in\s+(?P[A-Za-z]\w*(?:\s+\w+)?))?" r"\s*\.?$", flags=re.IGNORECASE, ) # ADR-0136.S.4 — Shape A: indefinite-article subject. # "A school has 100 students." / "A box has 12 apples of various colors." # Sibling to _INITIAL_HAS_RE; uses _ENTITY_INDEF (not _ENTITY) so the # widening is localised — _ENTITY itself is unchanged for all other paths. # Restricted to [Aa]\s+ (not "An") to avoid colliding with money-amount # or other shapes that may follow "an" as a numeral article. # Anchor: 'has' only (singular third-person; "A school have" is not # grammatical English). _INITIAL_HAS_INDEF_RE: Final[re.Pattern[str]] = re.compile( r"^[Aa]\s+(?P\w+)\s+" r"(?Phas)\s+" rf"(?P{_VALUE})" r"(?:\s+(?:full|loose|empty|whole|broken|new|old|small|large|fresh|raw|flat))?" r"(?:\s+(?P\w+))?" r"(?:\s+(?:of|in|for|with)\s+.+)?" r"\s*\.?$" ) # ADR-0136.S.4 — Shape B: prepositional-prefix existential. # "In a building, there are a hundred ladies on the first-floor studying." # Sibling to _INITIAL_THERE_ARE_RE; prefix is "In a " (not bare # "There are"). The optional article "a" before the value handles the # "a hundred" construction (article consumed, not captured). The # ordinal-floor qualifier and participial phrase are both optional and # discarded. _INITIAL_THERE_ARE_PREFIX_RE: Final[re.Pattern[str]] = re.compile( r"^In\s+[Aa]\s+(?P\w+),?\s+" r"there\s+(?Pare|were|is|was)\s+" r"(?:a\s+)?" rf"(?P{_VALUE})\s+" r"(?P\w+)" r"(?:\s+on\s+the\s+\w+(?:-floor)?)?" r"(?:\s+\w+ing)?" r"\s*\.?$", flags=re.IGNORECASE, ) # ADR-0131.G.3.1 — Axis 1: fraction-of-unit initial possession. # "Bob has 3/4 of a cup." — the fraction is the value; "of a/an " # carries the unit. The main _INITIAL_HAS_RE treats "of " as a # discardable substance qualifier and emits no candidate (unit slot absent # and no unit_override); this separate pattern extracts the unit from # the "of" phrase explicitly. _INITIAL_FRACTION_OF_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+" rf"(?Phas|have)\s+" rf"(?P{_SLASH_FRACTION})\s+" r"of\s+(?:a\s+|an\s+)?(?P\w+)" r"(?:\s+of\s+.+)?" # optional further substance qualifier r"\s*\.?$" ) # ADR-0131.G.3.1 — Axis 3: multi-token space-separated cardinal. # "Bob has one hundred apples." — parse_compound_cardinal already handles # the value; this pattern captures it before the unit-slot boundary. # Approach (a) chosen over (b) (_VALUE widening) because greedy cardinal- # word matching inside _VALUE would span the unit slot and require # look-ahead unwinding; a separate dedicated extractor is narrower and # leaves _VALUE unchanged for all other paths. # Build cardinal-word alternation from the WORD_NUMBERS table. _CARDINAL_WORD_OPTIONS: Final[str] = "|".join( re.escape(w) for w in sorted(WORD_NUMBERS.keys(), key=len, reverse=True) ) # At least two cardinal words (single-word is handled by _VALUE/_resolve_value). _MULTI_WORD_CARDINAL_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+" rf"(?Phas|have)\s+" rf"(?P(?:{_CARDINAL_WORD_OPTIONS})(?:\s+(?:{_CARDINAL_WORD_OPTIONS}))+)" # Optional adjective (axis 4 compound) between cardinal and unit. r"(?:\s+(?:full|loose|empty|whole|broken|new|old|small|large|fresh|raw|flat))?" r"\s+(?P\w+)" r"(?:\s+(?:of|in|for|with)\s+.+)?" r"\s*\.?$", flags=re.IGNORECASE, ) def _normalize_entity(raw: str) -> str: """Collapse whitespace + lowercase article. Mirrors math_parser canonicalization so candidate entity names hash-equal to legacy.""" e = re.sub(r"\s+", " ", raw.strip()) if e.lower().startswith("the "): return "the " + e[4:] return e @dataclass(frozen=True, slots=True) class _ResolvedValue: """Resolved value-slot reading. ADR-0131.G.3 widens the value slot beyond integer + single-word cardinal to include money literals (``$N`` / ``$N.NN``), slash fractions (``N/M``), and hyphenated multi-word cardinals (``twenty-five``). Money literals carry an implicit canonical unit (``cent``); when set, ``unit_override`` replaces the unit slot the regex captured (or fills it when the unit slot is absent). """ value: int | float unit_override: str | None # Money: canonical normalization to integer cents (en_units_v1 # ``canonical_unit`` for the ``money`` dimension is ``cent``). _MONEY_UNIT: Final[str] = "cents" # ADR-0131.G.3.1: multi-currency symbol → (unit_surface, factor_to_unit). # ``factor_to_unit`` is the multiplier applied to the face value to # produce the canonical unit. For USD ($): face is dollars → *100 cents. # For ¢: face is already cents → *1. For all others the pack has no # sub-unit defined, so face == canonical (factor=1) and the unit is the # pack's plural surface form. _CURRENCY_SYMBOLS: Final[dict[str, tuple[str, float]]] = { "$": ("cents", 100.0), # dollar → 100 cents "¢": ("cents", 1.0), # cent already canonical "€": ("euros", 1.0), "£": ("pounds sterling", 1.0), "¥": ("yen", 1.0), "₱": ("pesos", 1.0), } def _resolve_currency(t: str) -> _ResolvedValue | None: """Resolve a currency-symbol value token (``$N``, ``¢N``, ``€N.NN``, …). Returns ``None`` when the format is out-of-scope (e.g. 3+ decimal places). Yen (``¥``) is integer-only (no sub-unit in en_units_v1). """ for sym, (unit_surface, factor) in _CURRENCY_SYMBOLS.items(): if not t.startswith(sym): continue body = t[len(sym):] if re.fullmatch(r"\d+", body): raw_val = int(body) final = int(raw_val * factor) if factor == int(factor) else raw_val * factor return _ResolvedValue(final, unit_surface) # ¥ is integer-only. if sym == "¥": return None if re.fullmatch(r"\d+\.\d{1,2}", body): raw_val = float(body) result = raw_val * factor return _ResolvedValue(int(round(result)) if factor != 1.0 else raw_val, unit_surface) return None # 3+ decimals refused for all currency symbols return None def _resolve_value(value_token: str) -> _ResolvedValue | None: """Resolve a value-slot token into a numeric value + optional unit override. Returns ``None`` on refusal (indefinite quantifier, division-by-zero in slash fraction, unrecognized hyphenated form, unparseable money). Refusal at this layer is first-class: a ``None`` upstream means the candidate is not emitted, which preserves ``wrong == 0`` per ADR-0114a Obligation #4. """ if not value_token: return None t = value_token.strip() # Multi-currency symbols (ADR-0131.G.3.1): $, ¢, €, £, ¥, ₱. if t and t[0] in _CURRENCY_SYMBOLS: return _resolve_currency(t) # Slash fraction literal: N/M with M > 0. if "/" in t: m = re.fullmatch(r"(\d+)/(\d+)", t) if m is None: return None num, den = int(m.group(1)), int(m.group(2)) if den == 0: return None # division-by-zero refused. if num % den == 0: return _ResolvedValue(num // den, None) return _ResolvedValue(num / den, None) # Digit run. if t.isdigit(): return _ResolvedValue(int(t), None) # Indefinite quantifier (ADR-0128.4) — refuse, never guess. if _is_indefinite_quantifier(t): return None # Hyphenated multi-word cardinal: twenty-five, ninety-nine, etc. if "-" in t: from language_packs.numerics_loader import parse_compound_cardinal parsed = parse_compound_cardinal(t) if parsed is None: return None # Unrecognized hyphenated form refused. return _ResolvedValue(parsed, None) # Single-word cardinal (legacy WORD_NUMBERS table). lower = t.lower() if lower in WORD_NUMBERS: return _ResolvedValue(WORD_NUMBERS[lower], None) return None def _is_indefinite_quantifier(token: str) -> bool: """ADR-0128.4 — quantifier-driven refusal helper. Returns True when ``token`` resolves (via en_numerics_v1 lookup) to an indefinite quantifier (``some``, ``many``, ``few``, ``several``, etc.). Indefinite quantifiers in value-slot positions are refused rather than guessed — preserves wrong == 0. """ try: from language_packs.loader import lookup_quantifier entry = lookup_quantifier(token.lower()) if entry is not None and entry.semantic_type == "indefinite": return True except Exception: pass return False def _money_unit_normalization( value: int | float, unit: str | None ) -> tuple[int | float, str | None]: """ADR-0131.G.3 — normalize money word-form surface units to pack canonical. ``en_units_v1`` pins ``cent`` as ``canonical_unit`` for the ``money`` dimension. ``dollar``/``dollars`` → 100 cents each. Other currencies (ADR-0131.G.3.1) are already in canonical form when they arrive via ``_resolve_currency``; this helper normalizes the word-form paths. """ if unit is None: return value, unit lower = unit.lower() if lower in ("dollar", "dollars"): return value * 100, _MONEY_UNIT # Euro/pound-sterling/yen/peso word forms: already canonical (factor=1). # These enter via unit slot (word form) rather than symbol — pass through. if lower in ("euro", "euros"): return value, "euros" if lower in ("pound sterling", "pounds sterling"): return value, "pounds sterling" if lower == "yen": return value, "yen" if lower in ("peso", "pesos"): return value, "pesos" return value, unit def extract_initial_candidates(sentence: str) -> list[CandidateInitial]: """Return all admissible initial-possession candidates for ``sentence``. Recognized shapes: 1. " has [of ]" — canonical. 2. "There are [in ]" — implicit-subject shape. Value-slot widenings (ADR-0131.G.3) apply to both shapes via :func:`_resolve_value`: money literals (``$N`` / ``$N.NN``), slash fractions (``N/M``), hyphenated multi-word cardinals (``twenty-five``). Refusal-first: indefinite quantifiers, division-by-zero fractions, unrecognized compound forms, and money literals with >2 decimals all return ``None`` from :func:`_resolve_value` and emit no candidate (preserves ``wrong == 0`` per ADR-0114a Obligation #4). """ s = sentence.strip().rstrip(".") out: list[CandidateInitial] = [] m = _INITIAL_HAS_RE.match(s) if m is not None: value_raw = m.group("value") rv = _resolve_value(value_raw) if rv is not None: entity = _normalize_entity(m.group("entity")) unit_raw = m.group("unit") # may be None when value is money symbol # Unit precedence: explicit override from value (money symbol) # wins over the regex's unit slot. The unit slot is required # for non-money values; if both are absent the candidate # cannot be constructed. resolved_unit: str | None if rv.unit_override is not None: resolved_unit = rv.unit_override elif unit_raw is not None: resolved_unit = _canonicalize_unit(unit_raw) else: resolved_unit = None if resolved_unit is not None: value, final_unit = _money_unit_normalization(rv.value, resolved_unit) assert final_unit is not None out.append( CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=value, unit=final_unit), ), source_span=sentence, matched_anchor=m.group("anchor"), matched_value_token=value_raw, matched_unit_token=unit_raw if unit_raw is not None else final_unit, matched_entity_token=m.group("entity"), ) ) # ADR-0131.G.3.1 — Axis 1: fraction-of-unit shape. # "Bob has 3/4 of a cup." — separate regex extracts unit from "of" phrase. out.extend(_fraction_of_candidates(sentence)) # ADR-0131.G.3.1 — Axis 3: multi-token space-separated cardinals. # "Bob has one hundred apples." — separate extractor; _VALUE is unchanged. out.extend(_multi_word_cardinal_candidates(sentence)) # ADR-0131.G.4 — multi-clause initial-state extractors. # Each may emit ≥1 candidates; deterministic order: conjoined-subject-each, # conjoined-object, embedded-quantifier, conjoined-embedded-quantifier. # See module-bottom for shape definitions and closed-set discipline. out.extend(_conj_subject_each_candidates(sentence)) out.extend(_conj_object_candidates(sentence)) out.extend(_embedded_quantifier_candidates(sentence)) # ADR-0136.S.3 — compound initial-mutation: "Entity had N unit, but then verb M" out.extend(_init_mutation_candidates(sentence)) # ADR-0136.S.4 — Shape A: "A has N " indefinite-article subject. out.extend(_init_has_indef_candidates(sentence)) m2 = _INITIAL_THERE_ARE_RE.match(s) if m2 is not None: value_raw = m2.group("value") rv = _resolve_value(value_raw) if rv is not None: unit_raw = m2.group("unit") assert unit_raw is not None # there-are regex always captures unit slot if rv.unit_override is not None: unit_str: str = rv.unit_override else: unit_str = _canonicalize_unit(unit_raw) v_norm, u_norm = _money_unit_normalization(rv.value, unit_str) assert u_norm is not None value: int | float = v_norm unit: str = u_norm place = m2.group("place") # When a 'in ' phrase is present, treat the place as # the implicit entity. Otherwise use the unit's plural as # the collective entity name (deterministic, derivable from # the source: "There are 5 kids" -> entity='kids'). if place is not None: entity = _normalize_entity(place) entity_token = place else: entity = unit entity_token = unit_raw out.append( CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=value, unit=unit), ), source_span=sentence, matched_anchor=m2.group("anchor"), matched_value_token=value_raw, matched_unit_token=unit_raw, matched_entity_token=entity_token, ) ) # ADR-0136.S.4 — Shape B: "In a , there are N " prefix existential. out.extend(_init_there_are_prefix_candidates(sentence)) return out # --------------------------------------------------------------------------- # Operation candidate extractor # --------------------------------------------------------------------------- # Per-kind operation patterns. Each captures: subject, verb, value, unit, # optional target. The verb alternation is the kind's permissive verb table. # # Note: optional unit (?P) is allowed because some constructions # rely on inherited unit ("Sam doubles his savings"); however for P2's # scope we only emit candidates when the unit token is explicit. Inherited- # unit candidates require per-branch state and are added in P3. def _op_pattern(verbs_pattern: str, *, requires_target: bool) -> re.Pattern[str]: """Build the per-kind operation regex. For ``requires_target=True`` (transfer): the trailing ``to `` clause is a captured slot. For ``requires_target=False`` (add/subtract): there is no target slot. A trailing ``to `` phrase, if present, is consumed as part of the discardable preposition tail so the regex still matches ambiguous sentences like "Sam gives 3 apples to Tom" (which we *do* want to match as a subtract candidate; the transfer-vs-subtract disambiguation happens at the candidate / filter / decision-rule layer, not by regex specificity). """ if requires_target: target_part = r"\s+to\s+(?P[A-Z]\w+)" trailing_prep = ( r"(?:\s+(?:on|from|at|in|onto|into|under|over|of|for|with)\s+.+)?" ) else: target_part = "" # 'to' is included in the discardable preposition set. # 'of' is included for ADR-0127 substance qualifiers ("1000 feet # of cable") — the substance NP is grammatically real but # arithmetically inert; the unit slot carries the dimensional info. trailing_prep = ( r"(?:\s+(?:on|from|at|in|onto|into|under|over|to|of|for|with)\s+.+)?" ) # Optional verb particle: handles "saved up N", "picked up N", # "threw out N", etc. The particle is grammatically real but # arithmetically inert — it does not affect the operation kind or # operand. ADR-0131.G1: this clause replaces the former approach # of listing particle-bearing verbs as initial-possession anchors. verb_particle = r"(?:\s+(?:up|down|out|back|off|in|away))?" return re.compile( r"^" rf"(?P{_ENTITY})\s+" rf"(?P{verbs_pattern})" rf"{verb_particle}" rf"\s+(?P{_VALUE})" r"(?:\s+more)?" r"(?:\s+(?!to\b)(?!more\b)(?!on\b)(?!from\b)(?!at\b)(?!in\b)" r"(?P\w+))?" rf"{target_part}" rf"{trailing_prep}" r"\s*\.?$", flags=re.IGNORECASE, ) _ADD_OP_RE: Final[re.Pattern[str]] = _op_pattern(_ADD_VERBS_PATTERN, requires_target=False) _SUBTRACT_OP_RE: Final[re.Pattern[str]] = _op_pattern(_SUBTRACT_VERBS_PATTERN, requires_target=False) _TRANSFER_OP_RE: Final[re.Pattern[str]] = _op_pattern(_TRANSFER_VERBS_PATTERN, requires_target=True) def _canonicalize_unit(unit_raw: str) -> str: """Canonicalize a unit surface token to its plural form. ADR-0127 integration: consult en_units_v1 first. If the token is a pack-recognized unit, use the pack's canonical plural form (handles irregular plurals like feet/feet, children, mice, etc. correctly). Otherwise fall back to the legacy '+s' rule for count nouns. """ lowered = unit_raw.lower() try: from language_packs.loader import lookup_unit entry = lookup_unit(lowered) if entry is not None: return entry.plural.lower() except Exception: pass if not lowered.endswith("s"): return lowered + "s" return lowered def _build_op_candidate( m: re.Match[str], kind: str, source: str ) -> CandidateOperation | None: """Build a CandidateOperation from a regex match. Returns None if the value cannot be resolved or if no unit can be determined (unit slot absent AND value carries no implicit unit override). """ value_raw = m.group("value") rv = _resolve_value(value_raw) if rv is None: return None unit_raw = m.group("unit") # ADR-0131.G.3: a money-symbol value carries its unit implicitly # (override 'cent'); for plain-numeric values, the unit slot must # be present. if rv.unit_override is not None: unit: str = rv.unit_override elif unit_raw is not None: unit = _canonicalize_unit(unit_raw) else: return None # P2 does not emit unit-inherited candidates. subject = _normalize_entity(m.group("subject")) verb = m.group("verb").lower() value, unit_normalized = _money_unit_normalization(rv.value, unit) assert unit_normalized is not None unit = unit_normalized target_raw = m.group("target") if "target" in m.groupdict() else None target = target_raw if target_raw is not None else None op_kwargs: dict[str, object] = { "actor": subject, "kind": kind, "operand": Quantity(value=value, unit=unit), } if kind == "transfer": if target is None: return None # transfer requires target op_kwargs["target"] = target else: if target is not None: return None # add/subtract don't take targets return CandidateOperation( op=Operation(**op_kwargs), # type: ignore[arg-type] source_span=source, matched_verb=verb, matched_value_token=m.group("value"), matched_unit_token=unit_raw if unit_raw is not None else unit, matched_actor_token=m.group("subject"), matched_target_token=target, ) # --------------------------------------------------------------------------- # Question candidate # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class CandidateUnknown: """Question-candidate with source-span provenance. Two question shapes in P3 scope: - ``How many does have [left|now|in total|altogether|combined|together]?`` → ``Unknown(entity=, unit=)`` - ``How many do they have [left|now|in total|altogether|combined|together]?`` → ``Unknown(entity=None, unit=)`` (total-across) Closed aggregate-cue vocabulary: ``in total``, ``altogether``, ``combined``, ``together``. All four map to ``entity=None`` on the total-across form. The round-trip filter for questions checks the unit token and (when present) the entity token both appear in the source span. """ unknown: Unknown source_span: str matched_unit_token: str matched_entity_token: str | None # None for total-across questions # ADR-0163.D.4 — Pattern B comparative marker ("how many more X"). # Default False keeps existing constructions byte-identical. comparative_marker: bool = False _Q_ENTITY_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+(?P\w+)\s+(?:does|do)\s+" rf"(?P{_ENTITY})" r"\s+have(?:\s+(?:left|now|in\s+total|altogether|combined|together|in\s+all)){0,2}\s*\??$", flags=re.IGNORECASE, ) _Q_TOTAL_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+(?P\w+)\s+do\s+they\s+have" r"(?:\s+(?:in\s+total|altogether|combined|together|in\s+all|left|now)){0,2}\s*\??$", flags=re.IGNORECASE, ) def extract_question_candidates( sentence: str, problem_text: str | None = None ) -> list[CandidateUnknown]: """Return all admissible question candidates for ``sentence``. Tries the total-across pattern FIRST (same specificity order as legacy math_parser). The entity-pattern's widened regex would otherwise capture "they" as an entity name. ADR-0163.D.4 — ``problem_text`` is the full problem text used by pronoun-entity resolution (Pattern C). When None, pronoun-entity branches refuse rather than guess. This keeps the function pure and deterministic: same (sentence, problem_text) → byte-identical output. Empty list if no shape matches. """ s = sentence.strip() out: list[CandidateUnknown] = [] m = _Q_TOTAL_RE.match(s) if m is not None: unit_raw = m.group("unit") unit = _canonicalize_unit(unit_raw) out.append( CandidateUnknown( unknown=Unknown(entity=None, unit=unit), source_span=sentence, matched_unit_token=unit_raw, matched_entity_token=None, ) ) return out # specificity order: don't also try entity pattern m = _Q_ENTITY_RE.match(s) if m is not None: unit_raw = m.group("unit") unit = _canonicalize_unit(unit_raw) entity = _normalize_entity(m.group("entity")) out.append( CandidateUnknown( unknown=Unknown(entity=entity, unit=unit), source_span=sentence, matched_unit_token=unit_raw, matched_entity_token=m.group("entity"), ) ) return out # ADR-0163.D.4 — Pattern A: mass-noun question out.extend(_pattern_a_mass_noun_candidates(sentence, problem_text)) if out: return out # ADR-0163.D.4 — Pattern B: comparative quantifier ("how many more") out.extend(_pattern_b_comparative_candidates(sentence, problem_text)) if out: return out # ADR-0163.D.4 — Pattern C: pronoun-entity in non-"have" verb position out.extend(_pattern_c_pronoun_verb_candidates(sentence, problem_text)) return out def extract_operation_candidates(sentence: str) -> list[CandidateOperation]: """Return all operation candidates for ``sentence``. Tries every verb-kind pattern independently. A sentence with an ambiguous verb (e.g. "Sam gives 3 apples to Tom" — "gives" appears in both SUBTRACT_VERBS and TRANSFER_VERBS) may emit multiple candidates. The round-trip filter (:func:`generate.math_roundtrip.roundtrip_admissible`) and the decision rule (P3) resolve which one becomes the chosen graph. Candidate emission order is canonical: add, subtract, transfer. Within each kind, the regex emits at most one candidate per sentence. """ s = sentence.strip() out: list[CandidateOperation] = [] for pattern, kind in ( (_ADD_OP_RE, "add"), (_SUBTRACT_OP_RE, "subtract"), (_TRANSFER_OP_RE, "transfer"), ): m = pattern.match(s) if m is None: continue candidate = _build_op_candidate(m, kind, source=sentence) if candidate is not None: out.append(candidate) # ADR-0131.G.2 — comparative operations. # Specificity order: nested > multiplicative > additive. Multiplicative # anchors that overlap with additive ("twice" vs "two more") are disjoint # at the lexical level (WORD_NUMBERS has 'two' not 'twice'); nesting # consumes a *trailing* "than N times " tail so it cannot be confused # with the bare additive pattern. See ADR-0131.G.2 for precedence # rationale. out.extend(_compare_nested_candidates(sentence)) out.extend(_compare_multiplicative_candidates(sentence)) out.extend(_compare_additive_candidates(sentence)) return out # --------------------------------------------------------------------------- # ADR-0131.G.2 — Comparative operation extractors # --------------------------------------------------------------------------- # # Closed-set anchor alternation, aligned 1:1 with the four # ``Comparison.direction`` literals registered in # :data:`generate.math_roundtrip.COMPARE_ADDITIVE_ANCHORS` / # :data:`COMPARE_MULTIPLICATIVE_ANCHORS`: # # additive — direction ∈ {more, fewer}; "less" admitted as a # surface synonym mapped to direction='fewer'. # ``matched_verb`` = the lowercased direction word # ('more' / 'fewer' / 'less'); these are members of # COMPARE_ADDITIVE_ANCHORS so the round-trip filter's # verb-registry check (math_roundtrip step 1) passes. # multiplicative — direction ∈ {times, fraction}; surface anchors are # 'twice' / 'thrice' / 'N times' / 'half'. The # anchor-as-value-token convention from math_roundtrip # step 4 lets word-form factor anchors skip # value-grounding (the anchor's own appearance in # the source already grounds the factor). # # Out of scope (refused by deliberate non-match): "as many … as" without # a direction anchor, "compared to …", "in comparison with …", # "the same … as". These have no entry in COMPARE_*_ANCHORS — admitting # them would breach the round-trip filter's verb-registry check anyway. # Comparative entity slot: proper-noun, "the X" collective, "the number/amount # of " mass-noun construction. Possessives ("Bob's"/"his") are deferred. _COMPARE_REF: Final[str] = ( r"(?:" r"the\s+(?:number|amount)\s+of\s+\w+" r"|[Tt]he\s+\w+" r"|[A-Z]\w+" r")" ) def _resolve_reference_token(raw: str) -> tuple[str, str]: """Return ``(canonical_entity, head_token_for_grounding)``. For "the number of chickens" the head token is "chickens"; the canonical entity uses the full noun-phrase so binding-graph referential-integrity isn't subverted by collapsing different references to the same noun. """ collapsed = re.sub(r"\s+", " ", raw.strip()) lowered = collapsed.lower() if lowered.startswith("the number of ") or lowered.startswith("the amount of "): head = collapsed.split()[-1] return collapsed, head if lowered.startswith("the "): head = collapsed[4:].split()[0] return "the " + collapsed[4:], head return collapsed, collapsed def _comparison_anchor_verb() -> str: # 'has' / 'have' carry the comparator phrase. We don't include 'had/gets' # etc. in P2 — past-tense + lemma-widening are deferred to a later axis # to keep the precedence story narrow. return r"(?:has|have)" _COMPARE_ADDITIVE_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+{_comparison_anchor_verb()}\s+" rf"(?P{_VALUE})\s+" r"(?Pmore|fewer|less)\s+" r"(?P\w+)\s+than\s+" rf"(?P{_COMPARE_REF})\s*\.?$" ) # Multiplicative: anchor-as-value form ("twice"/"thrice"/"half"/"quarter"/ # "third" carry the factor implicitly). "as many " required; unit # ellipsis ("twice as many as Bob") is deferred to keep wrong==0 strict. # "quarter" / "third" admit an optional article ("a quarter", "a third") — # the article is not a named group; matched_verb is the anchor word itself, # which is a substring of the source and registered in # COMPARE_MULTIPLICATIVE_ANCHORS, so round-trip checks pass. _COMPARE_MULT_ANCHOR_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+{_comparison_anchor_verb()}\s+" r"(?:a\s+)?(?Ptwice|thrice|half|quarter|third)\s+as\s+many\s+" r"(?P\w+)\s+as\s+" rf"(?P{_COMPARE_REF})\s*\.?$" ) # Multiplicative: explicit "N times as many as ". _COMPARE_MULT_NTIMES_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+{_comparison_anchor_verb()}\s+" rf"(?P{_VALUE})\s+times\s+as\s+many\s+" r"(?P\w+)\s+as\s+" rf"(?P{_COMPARE_REF})\s*\.?$" ) # Nested: additive over multiplicative — "A has N more than M times ". # Emits *two* flat candidates so the binding graph (ADR-0134) can decide which # admissible composition (if any) survives. The parser does not commit to a # nested operand shape (Comparison ∋ Comparison is not a supported operand # type today); composition admissibility is the round-trip layer's call. _COMPARE_NESTED_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+{_comparison_anchor_verb()}\s+" rf"(?P{_VALUE})\s+" r"(?Pmore|fewer|less)\s+" r"(?P\w+)\s+than\s+" rf"(?P{_VALUE})\s+times\s+" rf"(?P{_COMPARE_REF})\s*\.?$" ) _ANCHOR_TO_FACTOR: Final[dict[str, tuple[float, str]]] = { # surface anchor → (factor, direction-literal) # Registered in COMPARE_MULTIPLICATIVE_ANCHORS (math_roundtrip) and in the # round-trip factor-divisor table ("half":2, "third":3, "quarter":4). "twice": (2.0, "times"), "thrice": (3.0, "times"), "half": (0.5, "fraction"), "quarter": (0.25, "fraction"), "third": (1.0 / 3.0, "fraction"), } def _direction_to_anchor(direction_raw: str) -> tuple[str, str]: """Map surface direction word → (canonical Comparison.direction, matched_verb registered in COMPARE_ADDITIVE_ANCHORS). 'less' is a surface synonym of 'fewer'; the Comparison value uses direction='fewer', but matched_verb retains the source surface ('less') so the round-trip filter's "verb appears in source" check succeeds. 'less' is registered in COMPARE_ADDITIVE_ANCHORS, so the verb-registry check also succeeds. """ lowered = direction_raw.lower() if lowered in ("more", "fewer"): return lowered, lowered if lowered == "less": return "fewer", "less" raise ValueError(f"unknown comparative direction surface {direction_raw!r}") def _build_compare_additive( *, actor_raw: str, delta_value_raw: str, direction_raw: str, unit_raw: str, reference_raw: str, source: str, ) -> CandidateOperation | None: if _is_indefinite_quantifier(delta_value_raw): return None direction, matched_verb = _direction_to_anchor(direction_raw) actor = _normalize_entity(actor_raw) reference_canon, reference_head = _resolve_reference_token(reference_raw) if reference_canon == actor: return None # self-reference; constructor would refuse anyway rv = _resolve_value(delta_value_raw) if rv is None: return None delta_value = rv.value unit = rv.unit_override if rv.unit_override is not None else _canonicalize_unit(unit_raw) try: op = Operation( actor=actor, kind="compare_additive", operand=Comparison( reference_actor=reference_canon, delta=Quantity(value=delta_value, unit=unit), factor=None, direction=cast(_CompDirection, direction), ), ) except Exception: return None try: return CandidateOperation( op=op, source_span=source, matched_verb=matched_verb, matched_value_token=delta_value_raw, matched_unit_token=unit_raw, matched_actor_token=actor_raw, matched_reference_actor_token=reference_head, ) except Exception: return None def _build_compare_multiplicative( *, actor_raw: str, factor: float, matched_verb: str, matched_value_token: str, unit_raw: str, reference_raw: str, source: str, direction: str, ) -> CandidateOperation | None: actor = _normalize_entity(actor_raw) reference_canon, reference_head = _resolve_reference_token(reference_raw) if reference_canon == actor: return None _ = _canonicalize_unit(unit_raw) # validation only; multiplicative compares # carry the unit on the source-span side, not the operand try: op = Operation( actor=actor, kind="compare_multiplicative", operand=Comparison( reference_actor=reference_canon, delta=None, factor=factor, direction=cast(_CompDirection, direction), ), ) except Exception: return None try: return CandidateOperation( op=op, source_span=source, matched_verb=matched_verb, matched_value_token=matched_value_token, matched_unit_token=unit_raw, matched_actor_token=actor_raw, matched_reference_actor_token=reference_head, ) except Exception: return None def _compare_additive_candidates(sentence: str) -> list[CandidateOperation]: s = sentence.strip() m = _COMPARE_ADDITIVE_RE.match(s) if m is None: return [] cand = _build_compare_additive( actor_raw=m.group("actor"), delta_value_raw=m.group("value"), direction_raw=m.group("direction"), unit_raw=m.group("unit"), reference_raw=m.group("reference"), source=sentence, ) return [cand] if cand is not None else [] def _compare_multiplicative_candidates(sentence: str) -> list[CandidateOperation]: s = sentence.strip() out: list[CandidateOperation] = [] m = _COMPARE_MULT_ANCHOR_RE.match(s) if m is not None: anchor = m.group("anchor").lower() factor, direction = _ANCHOR_TO_FACTOR[anchor] cand = _build_compare_multiplicative( actor_raw=m.group("actor"), factor=factor, matched_verb=anchor, matched_value_token=anchor, # anchor-as-value (math_roundtrip step 4) unit_raw=m.group("unit"), reference_raw=m.group("reference"), source=sentence, direction=direction, ) if cand is not None: out.append(cand) return out # specificity — don't also try N-times pattern m = _COMPARE_MULT_NTIMES_RE.match(s) if m is not None: value_raw = m.group("value") if _is_indefinite_quantifier(value_raw): return out rv = _resolve_value(value_raw) if rv is None: return out factor = float(rv.value) cand = _build_compare_multiplicative( actor_raw=m.group("actor"), factor=factor, matched_verb="times", matched_value_token=value_raw, unit_raw=m.group("unit"), reference_raw=m.group("reference"), source=sentence, direction="times", ) if cand is not None: out.append(cand) return out def _compare_nested_candidates(sentence: str) -> list[CandidateOperation]: """Emit two flat candidates for nested 'N more than M times '. The parser does not commit to a composed Comparison-of-Comparison operand (operand type Comparison ∋ Comparison is not modelled today). Both flat candidates are forwarded; the binding-graph / round-trip layer (ADR-0134) picks an admissible composition or refuses. Refusal is the safe outcome — never a wrong answer. """ s = sentence.strip() m = _COMPARE_NESTED_RE.match(s) if m is None: return [] out: list[CandidateOperation] = [] actor_raw = m.group("actor") unit_raw = m.group("unit") reference_raw = m.group("reference") # Candidate 1: additive — "A has N more than " treating # as the comparison reference directly. The "M times" multiplier # is dropped on this candidate (the binding-graph composition is # what would re-introduce it). add_cand = _build_compare_additive( actor_raw=actor_raw, delta_value_raw=m.group("delta_value"), direction_raw=m.group("direction"), unit_raw=unit_raw, reference_raw=reference_raw, source=sentence, ) if add_cand is not None: out.append(add_cand) # Candidate 2: multiplicative — "A has M times as many as " # treating the multiplier M and the same as the multiplicative # comparison. The additive offset N is dropped on this candidate. factor_value_raw = m.group("factor_value") if not _is_indefinite_quantifier(factor_value_raw): rv = _resolve_value(factor_value_raw) factor = float(rv.value) if rv is not None else None if factor is not None: mult_cand = _build_compare_multiplicative( actor_raw=actor_raw, factor=factor, matched_verb="times", matched_value_token=factor_value_raw, unit_raw=unit_raw, reference_raw=reference_raw, source=sentence, direction="times", ) if mult_cand is not None: out.append(mult_cand) return out # --------------------------------------------------------------------------- # ADR-0131.G.3.1 — Axis 1 + Axis 3 extractor functions # --------------------------------------------------------------------------- def _fraction_of_candidates(sentence: str) -> list[CandidateInitial]: """Axis 1 (fractions): 'Bob has 3/4 of a cup.' → value=0.75, unit='cups'. The main _INITIAL_HAS_RE treats 'of ' as a discardable substance qualifier and cannot fill the unit slot from it. This extractor uses _INITIAL_FRACTION_OF_RE to explicitly capture the unit after 'of'. """ s = sentence.strip().rstrip(".") m = _INITIAL_FRACTION_OF_RE.match(s) if m is None: return [] value_raw = m.group("value") rv = _resolve_value(value_raw) if rv is None: return [] unit_raw = m.group("unit") unit = _canonicalize_unit(unit_raw) entity = _normalize_entity(m.group("entity")) try: return [ CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=rv.value, unit=unit), ), source_span=sentence, matched_anchor=m.group("anchor"), matched_value_token=value_raw, matched_unit_token=unit_raw, matched_entity_token=m.group("entity"), ) ] except Exception: return [] def _multi_word_cardinal_candidates(sentence: str) -> list[CandidateInitial]: """Axis 3 (multi-word cardinals): 'Bob has one hundred apples.' Approach (a): dedicated extractor leaving _VALUE unchanged. The value group captures the full space-separated cardinal sequence; the unit slot is the next word token after the cardinal sequence (and optional adjective). """ s = sentence.strip().rstrip(".") m = _MULTI_WORD_CARDINAL_RE.match(s) if m is None: return [] value_raw = m.group("value") from language_packs.numerics_loader import parse_compound_cardinal parsed = parse_compound_cardinal(value_raw) if parsed is None: return [] unit_raw = m.group("unit") value_n, unit_n = _money_unit_normalization(parsed, _canonicalize_unit(unit_raw)) if unit_n is None: return [] entity = _normalize_entity(m.group("entity")) try: return [ CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=value_n, unit=unit_n), ), source_span=sentence, matched_anchor=m.group("anchor"), # Provenance: use the first cardinal word as the value token # for grounding (all cardinal words are in the source span). matched_value_token=value_raw.split()[0], matched_unit_token=unit_raw, matched_entity_token=m.group("entity"), ) ] except Exception: return [] # --------------------------------------------------------------------------- # ADR-0131.G.4 — Multi-clause initial-state composition # --------------------------------------------------------------------------- # # Closed shape set. Every recognized multi-clause structure matches exactly # one of the four extractors below. Cross-sentence coreference, ellipsis, # three-way+ conjunctions, and collective `each` readings are deliberately # refused (no extractor matches them). # # Why initials, not operations: the GSM8K shapes targeted here introduce # starting state ('Aaron and Carson each saved $40', 'Francine has five # full boxes and 5 loose crayons', 'Ella has 4 bags with 20 apples in each # bag'). They are not state-mutating events. Emitting CandidateInitial # preserves the conventional initial-state-vs-operation split the solver # (math_solver.py) expects. # Anchor verbs allowed in conjoined-subject-each constructions. Surface # verb is mapped to a single canonical anchor token (e.g. 'saved up' → # matched_anchor='saved'). The CandidateInitial constructor whitelists # these via the ADR-0131.G.4 widening. _CONJ_SUBJECT_VERBS: Final[tuple[str, ...]] = ( "has", "have", "had", "saved", "earned", "got", "received", "bought", "made", "paid", ) _CONJ_SUBJECT_VERBS_PATTERN: Final[str] = ( r"(?:" + "|".join(_CONJ_SUBJECT_VERBS) + r")" ) # Optional "and his/her/their brother/sister/friend/cousin" appositive # between the two conjuncts. Captures the appositive's head noun as part # of the second entity; we still ground on the proper noun that follows. _CONJ_KIN_GLUE: Final[str] = ( r"(?:(?:his|her|their)\s+(?:brother|sister|friend|cousin)\s+)?" ) # Conjoined-subject "each" — distributive only. The trailing "to " # / "for " / "of " tail is consumed and discarded (arithmetically # inert; cf. ADR-0127 substance qualifier). _CONJ_SUBJECT_EACH_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+and\s+{_CONJ_KIN_GLUE}" rf"(?P{_ENTITY})\s+each\s+" rf"(?P{_CONJ_SUBJECT_VERBS_PATTERN})(?:\s+up)?\s+" rf"(?P{_VALUE})\s+" r"(?P\w+)" r"(?:\s+(?:of|in|for|to|from|with|on|at)\s+.+)?" r"\s*\.?$", flags=re.IGNORECASE, ) # Conjoined-object NPs sharing a verb. The two units may differ # ('5 boxes and 7 marbles') — the binding graph keeps the per-unit # states independent. Same-unit conjuncts (rare) collapse into a # single state slot via the solver's state[(entity,unit)] overwrite, # which is a known limitation — we refuse same-unit conjuncts to avoid # silently losing the first conjunct's value. _CONJ_OBJECT_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+(?Phas|have|had)\s+" rf"(?P{_VALUE})\s+(?P\w+)" r"(?:\s+(?:full|loose|empty|whole|broken|new|old|small|large))?" r"(?:\s+of\s+\w+)?" rf"\s+and\s+(?P{_VALUE})\s+(?P\w+)" r"(?:\s+(?:full|loose|empty|whole|broken|new|old|small|large))?" r"(?:\s+of\s+\w+)?" r"\s*\.?$", flags=re.IGNORECASE, ) # Embedded quantifier: "N with M in each []". # Optional second mention of the container after 'each' (the natural- # language redundancy in the brief's Ella example). _EMBEDDED_QUANTIFIER_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+(?Phas|have|had)\s+" rf"(?P{_VALUE})\s+(?P\w+)\s+with\s+" rf"(?P{_VALUE})\s+(?P\w+)\s+in\s+each" r"(?:\s+(?P\w+))?" r"\s*\.?$", flags=re.IGNORECASE, ) # Conjoined embedded quantifiers — both halves match the embedded shape. # Emits a single SUM candidate (value = N1*M1 + N2*M2) — emitting two # derived candidates with the same (entity, unit) is unsafe under the # solver's overwrite-on-collision semantics (math_solver.py:206; would # silently drop the first conjunct's value). Same-unit summation is the # admissible interpretation; mismatched units refuse. _CONJ_EMBEDDED_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+(?Phas|have|had)\s+" rf"(?P{_VALUE})\s+(?P\w+)\s+with\s+(?P{_VALUE})\s+(?P\w+)" r"\s+in\s+each(?:\s+\w+)?\s+and\s+" rf"(?P{_VALUE})\s+(?P\w+)\s+with\s+(?P{_VALUE})\s+(?P\w+)" r"\s+in\s+each(?:\s+\w+)?" r"\s*\.?$", flags=re.IGNORECASE, ) def _canon_verb_to_anchor(verb: str) -> str: """Map surface verb to its canonical CandidateInitial anchor token. The constructor whitelist is keyed on lowercase singular-or-past tokens; we lowercase + strip particle ('saved up' was already stripped of 'up' by the regex's separate slot).""" return verb.lower() def _conj_subject_each_candidates(sentence: str) -> list[CandidateInitial]: """Distributive `each` only. Collective readings refuse by not matching (no 'each' in the surface).""" s = sentence.strip().rstrip(".") m = _CONJ_SUBJECT_EACH_RE.match(s) if m is None: return [] value_raw = m.group("value") if _is_indefinite_quantifier(value_raw): return [] # Adversarial probe: 'each ... together' is a contradiction; refuse. # Captured in test_refuses_each_with_together. if re.search(r"\btogether\b|\bin total\b|\baltogether\b", s, re.IGNORECASE): return [] entity_a = _normalize_entity(m.group("a")) entity_b = _normalize_entity(m.group("b")) if entity_a == entity_b: return [] # 'Aaron and Aaron each ...' is degenerate rv = _resolve_value(value_raw) if rv is None: return [] value = rv.value unit_raw = m.group("unit") unit = rv.unit_override if rv.unit_override is not None else _canonicalize_unit(unit_raw) anchor = _canon_verb_to_anchor(m.group("verb")) out: list[CandidateInitial] = [] for entity, entity_raw in ((entity_a, m.group("a")), (entity_b, m.group("b"))): try: out.append( CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=value, unit=unit), ), source_span=sentence, matched_anchor=anchor, matched_value_token=value_raw, matched_unit_token=unit_raw, matched_entity_token=entity_raw, ) ) except Exception: return [] # all-or-nothing emission return out def _conj_object_candidates(sentence: str) -> list[CandidateInitial]: """Conjoined object NPs sharing a verb. Same-unit conjuncts refused (cannot safely compose under solver's overwrite-on-collision).""" s = sentence.strip().rstrip(".") m = _CONJ_OBJECT_RE.match(s) if m is None: return [] v1_raw, v2_raw = m.group("v1"), m.group("v2") if _is_indefinite_quantifier(v1_raw) or _is_indefinite_quantifier(v2_raw): return [] u1_raw, u2_raw = m.group("u1"), m.group("u2") u1 = _canonicalize_unit(u1_raw) u2 = _canonicalize_unit(u2_raw) if u1 == u2: # Same-unit conjuncts would silently collide under the solver's # state[(entity,unit)] overwrite. Refuse rather than guess. return [] entity = _normalize_entity(m.group("entity")) anchor = m.group("anchor").lower() out: list[CandidateInitial] = [] for value_raw, unit_raw, unit in ( (v1_raw, u1_raw, u1), (v2_raw, u2_raw, u2), ): rv = _resolve_value(value_raw) if rv is None: return [] final_unit = rv.unit_override if rv.unit_override is not None else unit try: out.append( CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=rv.value, unit=final_unit), ), source_span=sentence, matched_anchor=anchor, matched_value_token=value_raw, matched_unit_token=unit_raw, matched_entity_token=m.group("entity"), ) ) except Exception: return [] return out def _embedded_quantifier_candidates(sentence: str) -> list[CandidateInitial]: """Embedded quantifier 'N with M in each' → derived InitialPossession(value=N*M, unit=). Also handles the conjoined-embedded shape via _CONJ_EMBEDDED_RE (single SUM candidate; same-unit only).""" s = sentence.strip().rstrip(".") # Try conjoined-embedded first (most specific). m = _CONJ_EMBEDDED_RE.match(s) if m is not None: return _build_conj_embedded_sum(m, sentence) m = _EMBEDDED_QUANTIFIER_RE.match(s) if m is None: return [] n_raw, m_raw = m.group("n"), m.group("m") if _is_indefinite_quantifier(n_raw) or _is_indefinite_quantifier(m_raw): return [] container = m.group("container").lower() container2_raw = m.group("container2") if container2_raw is not None: # 'with M unit in each ' — container2 (if named) # must agree with the leading container; otherwise the scope of # 'each' is ambiguous and we refuse. c2 = container2_raw.lower() if c2 not in (container, container.rstrip("s"), container + "s"): return [] rv_n = _resolve_value(n_raw) rv_per = _resolve_value(m_raw) if rv_n is None or rv_per is None: return [] total = rv_n.value * rv_per.value entity = _normalize_entity(m.group("entity")) unit_raw = m.group("unit") unit = _canonicalize_unit(unit_raw) try: return [ CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=total, unit=unit), ), source_span=sentence, matched_anchor=m.group("anchor").lower(), # Provenance: anchor on the per-container value token (M). # The product N*M is a parser-committed derivation; the # source-token check passes on M's surface form. matched_value_token=m_raw, matched_unit_token=unit_raw, matched_entity_token=m.group("entity"), ) ] except Exception: return [] # --------------------------------------------------------------------------- # Per-shape admitted-only wrappers (used by the G4 runner). # Each filters its extractor's output through _initial_admissible from # math_candidate_graph so the runner sees only round-trip-admissible # candidates without re-implementing the check. # --------------------------------------------------------------------------- def _admit(cands: list[CandidateInitial]) -> list[CandidateInitial]: from generate.math_candidate_graph import _initial_admissible return [c for c in cands if _initial_admissible(c)] def _conj_subject_each_admitted(sentence: str) -> list[CandidateInitial]: return _admit(_conj_subject_each_candidates(sentence)) def _conj_object_admitted(sentence: str) -> list[CandidateInitial]: return _admit(_conj_object_candidates(sentence)) def _embedded_quantifier_admitted(sentence: str) -> list[CandidateInitial]: # _embedded_quantifier_candidates dispatches to _CONJ_EMBEDDED_RE # *first*, so this wrapper returns the single-embedded candidate # only when the conjoined shape doesn't match. To distinguish, # callers that care about the conjoined branch use # _conj_embedded_admitted below. s = sentence.strip().rstrip(".") if _CONJ_EMBEDDED_RE.match(s) is not None: return [] return _admit(_embedded_quantifier_candidates(sentence)) def _conj_embedded_admitted(sentence: str) -> list[CandidateInitial]: s = sentence.strip().rstrip(".") if _CONJ_EMBEDDED_RE.match(s) is None: return [] return _admit(_embedded_quantifier_candidates(sentence)) def _build_conj_embedded_sum( m: re.Match[str], sentence: str ) -> list[CandidateInitial]: """Single SUM candidate for conjoined-embedded 'N1 C with M1 U in each and N2 C with M2 U in each'.""" n1_raw, m1_raw = m.group("n1"), m.group("m1") n2_raw, m2_raw = m.group("n2"), m.group("m2") for raw in (n1_raw, m1_raw, n2_raw, m2_raw): if _is_indefinite_quantifier(raw): return [] u1 = _canonicalize_unit(m.group("u1")) u2 = _canonicalize_unit(m.group("u2")) if u1 != u2: # Mixed-unit sum is meaningless; refuse. return [] rv_n1 = _resolve_value(n1_raw) rv_m1 = _resolve_value(m1_raw) rv_n2 = _resolve_value(n2_raw) rv_m2 = _resolve_value(m2_raw) if any(rv is None for rv in (rv_n1, rv_m1, rv_n2, rv_m2)): return [] total = (rv_n1.value * rv_m1.value) + (rv_n2.value * rv_m2.value) # type: ignore[union-attr] entity = _normalize_entity(m.group("entity")) try: return [ CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=total, unit=u1), ), source_span=sentence, matched_anchor=m.group("anchor").lower(), matched_value_token=m1_raw, # provenance: first per-container M matched_unit_token=m.group("u1"), matched_entity_token=m.group("entity"), ) ] except Exception: return [] # --------------------------------------------------------------------------- # ADR-0136.S.3 — Compound initial-mutation extractor # --------------------------------------------------------------------------- _INIT_MUT_SUBTRACT_VERBS: Final[frozenset[str]] = frozenset({ "lost", "gave", "gave away", "used", "spent", "ate", "dropped", "sold", }) _INIT_MUT_ADD_VERBS: Final[frozenset[str]] = frozenset({ "gained", "got", "received", "found", "earned", "picked up", "bought", }) _INIT_MUT_VERB_PATTERN: Final[str] = ( r"(?:" + "|".join( re.escape(v) for v in sorted( _INIT_MUT_SUBTRACT_VERBS | _INIT_MUT_ADD_VERBS, key=len, reverse=True, ) ) + r")" ) _INIT_MUTATION_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+(?:has|have|had)\s+" rf"(?P{_VALUE})\s+(?P\w+)" r"(?:\s+initially)?" r",?\s+but(?:\s+then)?\s+" rf"(?P{_INIT_MUT_VERB_PATTERN})\s+" rf"(?P{_VALUE})" r"\s*\.?\s*$", flags=re.IGNORECASE, ) def _init_mutation_candidates(sentence: str) -> list[CandidateInitial]: s = sentence.strip().rstrip(".") m = _INIT_MUTATION_RE.match(s) if m is None: return [] n_raw = m.group("n") m_raw = m.group("m") if _is_indefinite_quantifier(n_raw) or _is_indefinite_quantifier(m_raw): return [] rv_n = _resolve_value(n_raw) rv_m = _resolve_value(m_raw) if rv_n is None or rv_m is None: return [] verb = m.group("verb").lower() if verb in _INIT_MUT_SUBTRACT_VERBS: derived = rv_n.value - rv_m.value elif verb in _INIT_MUT_ADD_VERBS: derived = rv_n.value + rv_m.value else: return [] if derived < 0: return [] unit_raw = m.group("unit") unit = rv_n.unit_override if rv_n.unit_override is not None else _canonicalize_unit(unit_raw) entity = _normalize_entity(m.group("entity")) try: return [ CandidateInitial( initial=InitialPossession( entity=entity, quantity=Quantity(value=derived, unit=unit), ), source_span=sentence, matched_anchor="had", matched_value_token=n_raw, matched_unit_token=unit_raw, matched_entity_token=m.group("entity"), ) ] except Exception: return [] def _init_mutation_admitted(sentence: str) -> list[CandidateInitial]: return _admit(_init_mutation_candidates(sentence)) # --------------------------------------------------------------------------- # ADR-0136.S.4 — Novel initial-form extractors (Shape A + Shape B) # --------------------------------------------------------------------------- def _init_has_indef_candidates(sentence: str) -> list[CandidateInitial]: """Shape A — indefinite-article subject: 'A school has 100 students.' Sibling to the _INITIAL_HAS_RE block in extract_initial_candidates. Entity is the bare noun (lowercased); the article 'A/a' is consumed and discarded. Same value/unit resolution and money normalization as the definite-subject path. """ s = sentence.strip().rstrip(".") m = _INITIAL_HAS_INDEF_RE.match(s) if m is None: return [] value_raw = m.group("value") rv = _resolve_value(value_raw) if rv is None: return [] noun = m.group("noun").lower() unit_raw = m.group("unit") if rv.unit_override is not None: resolved_unit: str = rv.unit_override elif unit_raw is not None: resolved_unit = _canonicalize_unit(unit_raw) else: return [] value, final_unit = _money_unit_normalization(rv.value, resolved_unit) assert final_unit is not None try: return [ CandidateInitial( initial=InitialPossession( entity=noun, quantity=Quantity(value=value, unit=final_unit), ), source_span=sentence, matched_anchor=m.group("anchor"), matched_value_token=value_raw, matched_unit_token=unit_raw if unit_raw is not None else final_unit, matched_entity_token=noun, ) ] except Exception: return [] def _init_there_are_prefix_candidates(sentence: str) -> list[CandidateInitial]: """Shape B — prepositional-prefix existential: 'In a building, there are 100 ladies.' Sibling to the _INITIAL_THERE_ARE_RE block in extract_initial_candidates. Entity is the bare place noun (lowercased). The optional 'a ' article (as in 'a hundred'), ordinal-floor qualifier, and participial phrase are consumed without being captured. Same value/unit resolution and money normalization as the standard there-are path. """ s = sentence.strip().rstrip(".") m = _INITIAL_THERE_ARE_PREFIX_RE.match(s) if m is None: return [] value_raw = m.group("value") rv = _resolve_value(value_raw) if rv is None: return [] unit_raw = m.group("unit") if rv.unit_override is not None: unit_str: str = rv.unit_override else: unit_str = _canonicalize_unit(unit_raw) v_norm, u_norm = _money_unit_normalization(rv.value, unit_str) assert u_norm is not None place = m.group("place").lower() try: return [ CandidateInitial( initial=InitialPossession( entity=place, quantity=Quantity(value=v_norm, unit=u_norm), ), source_span=sentence, matched_anchor=m.group("anchor"), matched_value_token=value_raw, matched_unit_token=unit_raw, matched_entity_token=place, ) ] except Exception: return [] def _init_has_indef_admitted(sentence: str) -> list[CandidateInitial]: return _admit(_init_has_indef_candidates(sentence)) def _init_there_are_prefix_admitted(sentence: str) -> list[CandidateInitial]: return _admit(_init_there_are_prefix_candidates(sentence)) # --------------------------------------------------------------------------- # ADR-0136.S.0 — Context-sentence classifier # --------------------------------------------------------------------------- _WORD_NUMBER_RE: Final[re.Pattern[str]] = re.compile( r"\b(?:" + "|".join(re.escape(w) for w in sorted(WORD_NUMBERS, key=len, reverse=True)) + r")\b", re.IGNORECASE, ) def has_numeric_token(sentence: str) -> bool: """Return True if *sentence* contains any digit or closed-set word-number. A sentence with no numeric token cannot introduce quantified initial state, so it is safe to classify as a context filler and skip it. """ if re.search(r"\d", sentence): return True return bool(_WORD_NUMBER_RE.search(sentence)) def classify_sentence(sentence: str) -> Literal["context", "numeric_state"]: """Classify a statement sentence as skippable context or numeric-state-bearing. Rule: if the sentence contains no digit and no word-number from the closed set, it cannot introduce any parseable numeric state — classify as ``"context"`` (safe to skip). All other sentences are ``"numeric_state"`` and must either parse successfully or cause a refusal. """ return "context" if not has_numeric_token(sentence) else "numeric_state" # --------------------------------------------------------------------------- # ADR-0136.S.1 — Rate/event statement extractors (capacity + earnings) # --------------------------------------------------------------------------- _TIME_UNITS_TO_SECONDS: Final[dict[str, float]] = { "second": 1.0, "seconds": 1.0, "minute": 60.0, "minutes": 60.0, "hour": 3600.0, "hours": 3600.0, "day": 86400.0, "days": 86400.0, } _TIME_UNIT_SET: Final[str] = ( r"(?:seconds?|minutes?|hours?|days?)" ) def _to_seconds(count: float, unit: str) -> float: return count * _TIME_UNITS_TO_SECONDS[unit.lower()] # --- Shape A: capacity-rate --- _CAPACITY_VERBS: Final[frozenset[str]] = frozenset({ "shuck", "shucks", "pick", "picks", "pack", "packs", "make", "makes", "produce", "produces", "type", "types", "read", "reads", "write", "writes", "paint", "paints", "run", "runs", "score", "scores", "answer", "answers", "complete", "completes", }) _CAPACITY_VERB_PATTERN: Final[str] = ( r"(?:" + "|".join( re.escape(v) for v in sorted(_CAPACITY_VERBS, key=len, reverse=True) ) + r")" ) @dataclass(frozen=True, slots=True) class CandidateCapacity: actor: str count: float unit: str per_count: float per_unit: str source_span: str # Shape A1 (canonical): " can N in M ." _CAPACITY_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+can\s+" rf"(?P{_CAPACITY_VERB_PATTERN})\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P\w+)\s+in\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P{_TIME_UNIT_SET})" r"(?:\s+on\s+average)?" r"\s*\.?\s*$", flags=re.IGNORECASE, ) # Shape A2 (inverted): "During M can N [on average]." _CAPACITY_INVERTED_RE: Final[re.Pattern[str]] = re.compile( rf"^[Dd]uring\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P{_TIME_UNIT_SET})\s+" rf"(?P{_ENTITY})\s+can\s+" rf"(?P{_CAPACITY_VERB_PATTERN})\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P\w+)" r"(?:\s+on\s+average)?" r"\s*\.?\s*$", flags=re.IGNORECASE, ) def _capacity_from_match(m: re.Match[str], sentence: str) -> list[CandidateCapacity]: verb = m.group("verb").lower() if verb not in _CAPACITY_VERBS: return [] count = float(m.group("count")) per_count = float(m.group("per_count")) if per_count <= 0 or count <= 0: return [] return [ CandidateCapacity( actor=m.group("actor"), count=count, unit=_canonicalize_unit(m.group("unit")), per_count=per_count, per_unit=m.group("per_unit").lower(), source_span=sentence, ) ] def extract_capacity_candidates(sentence: str) -> list[CandidateCapacity]: s = sentence.strip() m = _CAPACITY_RE.match(s) if m is not None: return _capacity_from_match(m, sentence) m2 = _CAPACITY_INVERTED_RE.match(s) if m2 is not None: return _capacity_from_match(m2, sentence) return [] @dataclass(frozen=True, slots=True) class CandidateCapacityQuestion: actor: str | None unit: str per_count: float per_unit: str source_span: str _PRONOUN_SET: Final[str] = r"(?:he|she|they|it)" # Q1 (canonical): "How many can in T ?" _CAPACITY_Q_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+(?P\w+)\s+can\s+" rf"(?P{_ENTITY}|{_PRONOUN_SET})\s+" rf"(?P{_CAPACITY_VERB_PATTERN})\s+in\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P{_TIME_UNIT_SET})" r"\s*\??\s*$", flags=re.IGNORECASE, ) # Q2 (able-form): "How many [on average] is able to , # when the [match/game/session] lasted for T ?" _CAPACITY_Q2_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+(?P\w+)(?:\s+on\s+average)?\s+is\s+" rf"(?P{_ENTITY}|{_PRONOUN_SET})\s+able\s+to\s+" rf"(?P{_CAPACITY_VERB_PATTERN}),?\s+" r"when\s+the\s+\w+\s+lasted\s+for\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P{_TIME_UNIT_SET})" r"\s*\??\s*$", flags=re.IGNORECASE, ) def _capacity_question_from_match( m: re.Match[str], sentence: str ) -> list[CandidateCapacityQuestion]: verb = m.group("verb").lower() if verb not in _CAPACITY_VERBS: return [] actor_raw = m.group("actor") actor: str | None = None if actor_raw.lower() in ( "he", "she", "they", "it", ) else actor_raw per_count = float(m.group("per_count")) if per_count <= 0: return [] return [ CandidateCapacityQuestion( actor=actor, unit=_canonicalize_unit(m.group("unit")), per_count=per_count, per_unit=m.group("per_unit").lower(), source_span=sentence, ) ] def extract_capacity_question_candidates( sentence: str, ) -> list[CandidateCapacityQuestion]: s = sentence.strip() m = _CAPACITY_Q_RE.match(s) if m is not None: return _capacity_question_from_match(m, sentence) m2 = _CAPACITY_Q2_RE.match(s) if m2 is not None: return _capacity_question_from_match(m2, sentence) return [] # --- Shape B: earnings rate --- _EARNINGS_VERBS: Final[frozenset[str]] = frozenset({ "make", "makes", "earn", "earns", "receive", "receives", "get", "gets", "charge", "charges", }) _EARNINGS_VERB_PATTERN: Final[str] = ( r"(?:" + "|".join( re.escape(v) for v in sorted(_EARNINGS_VERBS, key=len, reverse=True) ) + r")" ) _CURRENCY_AMOUNT: Final[str] = r"\$\d+(?:\.\d{1,2})?" _PER_TOKEN: Final[str] = ( rf"(?:per|an?|for\s+each|every)\s+(?P{_TIME_UNIT_SET}|\w+)" ) @dataclass(frozen=True, slots=True) class CandidateEarningsRate: actor: str amount: float unit: str per_unit: str source_span: str _EARNINGS_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+" rf"(?P{_EARNINGS_VERB_PATTERN})\s+" rf"(?P{_CURRENCY_AMOUNT})\s+" rf"{_PER_TOKEN}" r"\s*\.?\s*$", flags=re.IGNORECASE, ) def extract_earnings_candidates(sentence: str) -> list[CandidateEarningsRate]: s = sentence.strip() m = _EARNINGS_RE.match(s) if m is None: return [] verb = m.group("verb").lower() if verb not in _EARNINGS_VERBS: return [] amount_raw = m.group("amount") amount = float(amount_raw.replace("$", "")) if amount <= 0: return [] per_unit = m.group("per_unit").lower() return [ CandidateEarningsRate( actor=m.group("actor"), amount=amount, unit="dollar", per_unit=per_unit, source_span=sentence, ) ] @dataclass(frozen=True, slots=True) class CandidateEarningsQuestion: actor: str unit: str time_count: float time_unit: str source_span: str _EARNINGS_Q_VERBS: Final[str] = r"(?:make|earn|get|receive|charge)" _EARNINGS_Q_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+much\s+(?:money|dollars?)\s+does\s+" rf"(?P{_ENTITY})\s+" rf"{_EARNINGS_Q_VERBS}\s+in\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P{_TIME_UNIT_SET})" r"\s*\??\s*$", flags=re.IGNORECASE, ) def extract_earnings_question_candidates( sentence: str, ) -> list[CandidateEarningsQuestion]: s = sentence.strip() m = _EARNINGS_Q_RE.match(s) if m is None: return [] time_count = float(m.group("time_count")) if time_count <= 0: return [] return [ CandidateEarningsQuestion( actor=m.group("actor"), unit="dollar", time_count=time_count, time_unit=m.group("time_unit").lower(), source_span=sentence, ) ] # --------------------------------------------------------------------------- # ADR-0136.S.2 — Conditional-op question # --------------------------------------------------------------------------- # # Target shape (gsm8k-0042): # "If , how many does left?" # # Routes through the parse_and_solve short-circuit: given a single matching # initial-state candidate for (entity, unit), the answer is # initial_value ± operand depending on verb polarity. No graph built; # refuses on any ambiguity (unit mismatch, entity mismatch, multiple # matching ICs, negative answer). _COND_SUBTRACT_VERBS: Final[frozenset[str]] = frozenset({ "sell", "sells", "sold", "give", "gives", "gave", "eat", "eats", "ate", "use", "uses", "used", "lose", "loses", "lost", "spend", "spends", "spent", "donate", "donates", "donated", "remove", "removes", "removed", "take", "takes", "took", "send", "sends", "sent", "pay", "pays", "paid", "drop", "drops", "dropped", "throw", "throws", "threw", }) _COND_ADD_VERBS: Final[frozenset[str]] = frozenset({ "buy", "buys", "bought", "get", "gets", "got", "receive", "receives", "received", "find", "finds", "found", "add", "adds", "added", "collect", "collects", "collected", "pick", "picks", "picked", "earn", "earns", "earned", "gain", "gains", "gained", }) _COND_VERB_PATTERN: Final[str] = ( r"(?:" + "|".join( re.escape(v) for v in sorted(_COND_SUBTRACT_VERBS | _COND_ADD_VERBS, key=len, reverse=True) ) + r")" ) @dataclass(frozen=True, slots=True) class CandidateConditionalOpQuestion: entity: str op: Literal["add", "subtract"] operand: float unit: str source_span: str # "If , how many does [ ]?" _COND_OP_Q_RE: Final[re.Pattern[str]] = re.compile( rf"^If\s+(?P{_ENTITY})\s+" rf"(?P{_COND_VERB_PATTERN})\s+" r"(?P\d+(?:\.\d+)?)\s+(?P\w+),\s+" r"how\s+many\s+(?P\w+)\s+does\s+" rf"(?P{_ENTITY})\s+(?:has|have|had)" r"(?:\s+(?:left|now|remaining|away|in\s+total|altogether))?" r"\s*\??\s*$", flags=re.IGNORECASE, ) def extract_conditional_op_question_candidates( sentence: str, ) -> list[CandidateConditionalOpQuestion]: s = sentence.strip() m = _COND_OP_Q_RE.match(s) if m is None: return [] verb = m.group("verb").lower() if verb in _COND_SUBTRACT_VERBS: op: Literal["add", "subtract"] = "subtract" elif verb in _COND_ADD_VERBS: op = "add" else: return [] unit = _canonicalize_unit(m.group("unit")) unit2 = _canonicalize_unit(m.group("unit2")) if unit != unit2: return [] entity = _normalize_entity(m.group("entity")) entity2 = _normalize_entity(m.group("entity2")) if entity.lower() != entity2.lower(): return [] n = float(m.group("n")) if n <= 0: return [] return [ CandidateConditionalOpQuestion( entity=entity, op=op, operand=n, unit=unit, source_span=sentence, ) ] # --------------------------------------------------------------------------- # ADR-0163.D.4 — Question-grammar extensions # --------------------------------------------------------------------------- # # Three new question shapes extracted from the GSM8K train_sample # post-Phase-D refusal taxonomy. Each is structurally narrow and is # protected by three independent wrong=0 safety nets: # # 1. Regex narrowness — required mass-noun whitelist / comparative # "more" anchor / pronoun-or-named-entity slot + closed action-verb # set. Open-ended question shapes do not match. # 2. Pronoun resolver refuse-on-ambiguity — if a problem text has two # distinct female (or male) names, "she"/"he" cannot resolve to a # single entity and the extraction emits no candidate. # 3. Downstream multi-branch decision rule (math_candidate_graph) — # when multiple admissible parses produce different answers, the # case refuses rather than picks one. # # The _MASS_NOUNS whitelist is HARD: extending it requires a separate # ADR. The pronoun gender name lists are SMALL and DOCUMENTED, sourced # from GSM8K train_sample observation. # Pattern A — mass-noun question shape. # Narrow whitelist of high-signal mass nouns observed in GSM8K # train_sample. Extending this set is a separate ADR; this prevents # incremental drift into "anything that looks like a mass noun." _MASS_NOUNS: Final[frozenset[str]] = frozenset({ "money", "profit", "interest", "income", "savings", "cost", "amount", "total", }) _MASS_NOUN_PATTERN: Final[str] = ( r"(?:" + "|".join(sorted(_MASS_NOUNS, key=len, reverse=True)) + r")" ) # Subject pronouns for Pattern C resolution. _Q_SUBJECT_PRONOUN: Final[str] = r"(?:she|he|they|it)" # Entity slot widened to also admit a subject pronoun (resolved # downstream via _resolve_pronoun_entity). _Q_ENTITY_OR_PRONOUN: Final[str] = rf"(?:{_ENTITY}|{_Q_SUBJECT_PRONOUN})" # Pattern A verb set: what the entity DOES with the mass noun. _PATTERN_A_VERBS: Final[str] = ( r"(?:make|makes|made|earn|earns|earned|save|saved|saves|" r"spend|spends|spent|cost|costs|need|needs|have|has|had|" r"gain|gains|gained|pay|paid|pays)" ) # Pattern B verb set: comparative-need style verbs. _PATTERN_B_VERBS: Final[str] = ( r"(?:need|needs|needed|require|requires|required|" r"have|has|had|gain|gains|gained)" ) # Pattern C verb set: action verbs admitted in non-"have" pronoun # position. Closed whitelist of high-signal action verbs observed in # GSM8K train_sample. Conservative — extending this requires evidence # from refused cases. _PATTERN_C_VERBS: Final[str] = ( r"(?:need|needs|sell|sells|make|makes|pick|picks|" r"buy|buys|use|uses|want|wants)" ) # Optional "of " qualifier between unit and aux. Bounded NP: # bare noun, " ", or two-word compound. The qualifier # itself does not flow into the candidate; the unit token alone is # the canonical unit (matching the math_parser convention for # "cups of lemonade" / "boxes of crayons"). _Q_OF_NP_TAIL: Final[str] = r"(?:\s+of\s+\w+(?:\s+\w+)?)?" _Q_MASS_NOUN_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+much\s+" rf"(?P{_MASS_NOUN_PATTERN})" rf"{_Q_OF_NP_TAIL}\s+" r"(?:will|did|does|do|would)\s+" rf"(?P{_Q_ENTITY_OR_PRONOUN})\s+" rf"(?:have\s+earned\s+|be\s+able\s+to\s+)?{_PATTERN_A_VERBS}" r"(?:\s+.*?)?\??\s*$", flags=re.IGNORECASE, ) _Q_COMPARATIVE_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+more\s+" r"(?P\w+)" rf"{_Q_OF_NP_TAIL}\s+" r"(?:does|do|would|will|did)\s+" rf"(?P{_Q_ENTITY_OR_PRONOUN})\s+" rf"{_PATTERN_B_VERBS}" r"(?:\s+.*?)?\??\s*$", flags=re.IGNORECASE, ) # Pattern C — pronoun entity in non-"have" verb position. # The verb slot is a NARROW closed set of action verbs; an optional # "to " infinitive tail consumes constructions like "need to sell". _Q_PRONOUN_VERB_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+(?P\w+)" rf"{_Q_OF_NP_TAIL}\s+" r"(?:does|do|will|did|would)\s+" rf"(?P{_Q_ENTITY_OR_PRONOUN})\s+" rf"{_PATTERN_C_VERBS}" r"(?:\s+to\s+\w+)?" r"(?:\s+.*?)?\??\s*$", flags=re.IGNORECASE, ) # Pronoun → name lookup lists. Closed-set whitelists sourced from # GSM8K train_sample observation. Adding a name requires evidence # from a refused case. Lower-cased; names are matched case-insensitively # against problem-text mentions. _FEMALE_NAMES: Final[frozenset[str]] = frozenset({ "alexa", "alice", "amy", "ann", "anna", "barbara", "betty", "carol", "carolyn", "christine", "cindy", "claire", "cynthia", "deborah", "diana", "donna", "dorothy", "elizabeth", "ella", "emily", "emma", "erica", "francine", "helen", "jane", "janet", "jen", "jennifer", "jessica", "joyce", "judith", "julie", "karen", "kate", "kathleen", "kelly", "laura", "linda", "lisa", "lilibeth", "lori", "mandy", "marie", "martha", "marnie", "mary", "melissa", "nancy", "nicole", "pamela", "patricia", "rachel", "rebecca", "ruth", "sandra", "sarah", "sharon", "shirley", "stephanie", "susan", "tina", "virginia", }) _MALE_NAMES: Final[frozenset[str]] = frozenset({ "aaron", "adam", "alan", "albert", "andrew", "anthony", "arthur", "benjamin", "bob", "brian", "bruce", "carl", "carson", "charles", "christopher", "daniel", "david", "dennis", "donald", "douglas", "edward", "eric", "ethan", "eugene", "fabian", "frank", "gary", "george", "gerald", "gregory", "harold", "harry", "henry", "jack", "james", "jason", "jeffrey", "jeremy", "jerry", "jesse", "john", "jonathan", "joseph", "joshua", "keith", "kenneth", "kevin", "larry", "lawrence", "mark", "matthew", "michael", "nathan", "nicholas", "patrick", "paul", "peter", "philip", "ralph", "raymond", "richard", "robert", "roger", "ronald", "ryan", "samuel", "scott", "sean", "stephen", "steven", "thomas", "timothy", "tom", "walter", "wayne", "william", }) # Title-cased proper-noun mention extractor. Matches any sequence # starting with an upper-case letter followed by lower-case letters, # avoiding all-caps tokens (acronyms) which are unlikely to be names. _PROPER_NOUN_MENTION_RE: Final[re.Pattern[str]] = re.compile( r"\b([A-Z][a-z]+)\b" ) def _resolve_pronoun_entity( pronoun: str, problem_text: str | None ) -> str | None: """Resolve a subject pronoun to a single unambiguous named entity. Pure, deterministic, no global state. Returns the canonical Title-cased entity name when a SINGLE unambiguous match exists; returns ``None`` on ambiguity, no-match, or empty problem_text. Heuristic limits: - ``she``/``her`` match female names from :data:`_FEMALE_NAMES`. - ``he``/``his``/``him`` match male names from :data:`_MALE_NAMES`. - ``they`` is plural; refuses (no single-entity resolution). - ``it`` is neuter; refuses (not safely resolvable from name lists). - If the problem text contains >1 distinct female (or male) name, "she" (or "he") cannot resolve unambiguously → refuse. Refuse-on-ambiguity preserves wrong=0: better to refuse a question than admit one with the wrong entity. """ if not problem_text: return None p = pronoun.lower() if p in ("they", "it"): return None # Plural/neuter — outside scope. if p in ("she", "her"): whitelist = _FEMALE_NAMES elif p in ("he", "his", "him"): whitelist = _MALE_NAMES else: return None distinct: list[str] = [] for m in _PROPER_NOUN_MENTION_RE.finditer(problem_text): name = m.group(1) if name.lower() not in whitelist: continue if name not in distinct: distinct.append(name) if len(distinct) != 1: # Zero matches → no candidate; >1 distinct → ambiguous → refuse. return None return distinct[0] def _resolve_question_entity( raw_entity: str, problem_text: str | None ) -> tuple[str, str] | None: """Return ``(canonical_entity, matched_entity_token)`` or None. Wraps :func:`_resolve_pronoun_entity` for pronoun slots; passes proper-noun entities through unchanged. ``None`` means the candidate should not be emitted (pronoun unresolvable). For a resolved pronoun the ``matched_entity_token`` is the LITERAL surface pronoun ("she", "he", ...). The downstream question- admissibility check requires the token to appear in the source- span (the question sentence); the resolved canonical name does not. """ lower = raw_entity.strip().lower() if lower in ("she", "her", "he", "his", "him", "they", "it"): resolved = _resolve_pronoun_entity(lower, problem_text) if resolved is None: return None return _normalize_entity(resolved), raw_entity return _normalize_entity(raw_entity), raw_entity def _pattern_a_mass_noun_candidates( sentence: str, problem_text: str | None ) -> list[CandidateUnknown]: """Pattern A — "How much MASS_NOUN does ENTITY VERB ..." question.""" s = sentence.strip() m = _Q_MASS_NOUN_RE.match(s) if m is None: return [] unit_raw = m.group("unit") # Mass nouns are pre-whitelisted; preserve literal token for # downstream grounding (no hidden normalization). unit = unit_raw.lower() raw_entity = m.group("entity") resolved = _resolve_question_entity(raw_entity, problem_text) if resolved is None: return [] entity, entity_token = resolved return [ CandidateUnknown( unknown=Unknown(entity=entity, unit=unit), source_span=sentence, matched_unit_token=unit_raw, matched_entity_token=entity_token, ) ] def _pattern_b_comparative_candidates( sentence: str, problem_text: str | None ) -> list[CandidateUnknown]: """Pattern B — "How many more UNIT does ENTITY VERB ..." question. wrong=0 GATE: comparative quantification ("how many MORE X are needed beyond current state") is structurally distinct from the plain ``How many X does ENTITY have?`` shape the existing solver resolves. If the parser emits a CandidateUnknown for Pattern B, the downstream solver computes the entity's current total — which is the WRONG answer for a comparative question (off by the missing delta). Until the solver gains comparative semantics (D.5 follow-up), this extractor recognises the shape but emits NO candidate, forcing a clean refusal. The regex + marker field + detection helper are retained for D.5 wiring. """ s = sentence.strip() if _Q_COMPARATIVE_RE.match(s) is None: return [] # Detection-only: see docstring. D.5 will add solver semantics. return [] def _pattern_b_detects(sentence: str) -> bool: """ADR-0163.D.4 — pure-grammar Pattern B detector for tests. Returns True iff the comparative-quantifier shape matches. Has no side effects on candidate emission. """ return _Q_COMPARATIVE_RE.match(sentence.strip()) is not None def _pattern_c_pronoun_verb_candidates( sentence: str, problem_text: str | None ) -> list[CandidateUnknown]: """Pattern C — "How many UNIT does PRONOUN VERB [to VERB2] ..." question. Admits the entity slot to either a proper-noun ENTITY or a subject pronoun resolved via :func:`_resolve_pronoun_entity`. Narrow verb whitelist (:data:`_PATTERN_C_VERBS`) bounds the question shape. """ s = sentence.strip() m = _Q_PRONOUN_VERB_RE.match(s) if m is None: return [] unit_raw = m.group("unit") unit = _canonicalize_unit(unit_raw) raw_entity = m.group("entity") resolved = _resolve_question_entity(raw_entity, problem_text) if resolved is None: return [] entity, entity_token = resolved return [ CandidateUnknown( unknown=Unknown(entity=entity, unit=unit), source_span=sentence, matched_unit_token=unit_raw, matched_entity_token=entity_token, ) ]