From 3fc81f66a4ec76dfca2f46a3b85f5912f1fe195b Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 17 Jun 2026 09:43:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(derivation):=20Workstream=20A=20inc=203=20?= =?UTF-8?q?=E2=80=94=20support=20'one'=20connector=20in=20rate=5Fwith=5Fcu?= =?UTF-8?q?rrency=20injector=20(post=20docs=20ratification)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generate/math_roundtrip.py | 342 +++++++++---- generate/recognizer_anchor_inject.py | 56 ++- generate/recognizer_match.py | 465 +++++++++++++----- .../test_candidate_graph_recognizer_wiring.py | 84 ++-- tests/test_gsm8k_frontier_report.py | 68 ++- ...est_math_candidate_graph_rate_injection.py | 40 +- tests/test_recognizer_anchor_inject.py | 59 ++- 7 files changed, 830 insertions(+), 284 deletions(-) diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index 10c97968..92eda2ec 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -57,91 +57,214 @@ from generate.math_problem_graph import Comparison, Operation, Quantity, Rate # Surface verbs that grammatically place the actor as the *gainer* of the # operand quantity. Past tense and present tense both registered. -ADD_VERBS: Final[frozenset[str]] = frozenset({ - # acquisition - "buy", "buys", "bought", - "get", "gets", "got", - "find", "finds", "found", - "receive", "receives", "received", - "earn", "earns", "earned", - "add", "adds", "added", - "pick", "picks", "picked", # "picks up N" - "collect", "collects", "collected", - "gather", "gathers", "gathered", - "catch", "catches", "caught", - "save", "saves", "saved", - # production (actor creates instances of the unit) - "bake", "bakes", "baked", - "make", "makes", "made", - "cook", "cooks", "cooked", - "slice", "slices", "sliced", - "pack", "packs", "packed", - "build", "builds", "built", - "grow", "grows", "grew", -}) +ADD_VERBS: Final[frozenset[str]] = frozenset( + { + # acquisition + "buy", + "buys", + "bought", + "get", + "gets", + "got", + "find", + "finds", + "found", + "receive", + "receives", + "received", + "earn", + "earns", + "earned", + "add", + "adds", + "added", + "pick", + "picks", + "picked", # "picks up N" + "collect", + "collects", + "collected", + "gather", + "gathers", + "gathered", + "catch", + "catches", + "caught", + "save", + "saves", + "saved", + # production (actor creates instances of the unit) + "bake", + "bakes", + "baked", + "make", + "makes", + "made", + "cook", + "cooks", + "cooked", + "slice", + "slices", + "sliced", + "pack", + "packs", + "packed", + "build", + "builds", + "built", + "grow", + "grows", + "grew", + } +) # Surface verbs that grammatically place the actor as the *loser* of the # operand quantity. -SUBTRACT_VERBS: Final[frozenset[str]] = frozenset({ - "eat", "eats", "ate", - "lose", "loses", "lost", - "sell", "sells", "sold", - "donate", "donates", "donated", - "use", "uses", "used", - "spend", "spends", "spent", - "drop", "drops", "dropped", - "remove", "removes", "removed", - "break", "breaks", "broke", - "destroy", "destroys", "destroyed", - "throw", "throws", "threw", # "throws out N" - "discard", "discards", "discarded", - "return", "returns", "returned", # ambiguous — see TRANSFER_VERBS - "consume", "consumes", "consumed", - "give", "gives", "gave", # ambiguous — see TRANSFER_VERBS - "send", "sends", "sent", # ambiguous — see TRANSFER_VERBS -}) +SUBTRACT_VERBS: Final[frozenset[str]] = frozenset( + { + "eat", + "eats", + "ate", + "lose", + "loses", + "lost", + "sell", + "sells", + "sold", + "donate", + "donates", + "donated", + "use", + "uses", + "used", + "spend", + "spends", + "spent", + "drop", + "drops", + "dropped", + "remove", + "removes", + "removed", + "break", + "breaks", + "broke", + "destroy", + "destroys", + "destroyed", + "throw", + "throws", + "threw", # "throws out N" + "discard", + "discards", + "discarded", + "return", + "returns", + "returned", # ambiguous — see TRANSFER_VERBS + "consume", + "consumes", + "consumed", + "give", + "gives", + "gave", # ambiguous — see TRANSFER_VERBS + "send", + "sends", + "sent", # ambiguous — see TRANSFER_VERBS + } +) # Surface verbs that grammatically place the actor as the *sender* and a # named target as the *receiver*. These verbs ALSO appear in SUBTRACT_VERBS # because the same surface token can take a transfer reading (with target) # or a subtract reading (without target) — both candidates fire and the # decision rule picks based on whether a target slot was grounded. -TRANSFER_VERBS: Final[frozenset[str]] = frozenset({ - "give", "gives", "gave", - "send", "sends", "sent", - "hand", "hands", "handed", - "pass", "passes", "passed", - "mail", "mails", "mailed", - "deliver", "delivers", "delivered", - "return", "returns", "returned", -}) +TRANSFER_VERBS: Final[frozenset[str]] = frozenset( + { + "give", + "gives", + "gave", + "send", + "sends", + "sent", + "hand", + "hands", + "handed", + "pass", + "passes", + "passed", + "mail", + "mails", + "mailed", + "deliver", + "delivers", + "delivered", + "return", + "returns", + "returned", + } +) -MULTIPLY_VERBS: Final[frozenset[str]] = frozenset({ - "double", "doubles", "doubled", - "triple", "triples", "tripled", - "quadruple", "quadruples", "quadrupled", - "multiply", "multiplies", "multiplied", -}) +MULTIPLY_VERBS: Final[frozenset[str]] = frozenset( + { + "double", + "doubles", + "doubled", + "triple", + "triples", + "tripled", + "quadruple", + "quadruples", + "quadrupled", + "multiply", + "multiplies", + "multiplied", + } +) -DIVIDE_VERBS: Final[frozenset[str]] = frozenset({ - "halve", "halves", "halved", - "split", "splits", "split", - "divide", "divides", "divided", - "share", "shares", "shared", -}) +DIVIDE_VERBS: Final[frozenset[str]] = frozenset( + { + "halve", + "halves", + "halved", + "split", + "splits", + "split", + "divide", + "divides", + "divided", + "share", + "shares", + "shared", + } +) # Comparison "verbs" — the surface anchor for compare_additive / # compare_multiplicative is usually 'has'/'have' + comparator phrase # ('N more than', 'twice as many as', etc.). The matched_verb slot for # comparison candidates carries the comparator phrase head ('more', # 'fewer', 'twice', 'times', 'half'). -COMPARE_ADDITIVE_ANCHORS: Final[frozenset[str]] = frozenset({ - "more", "fewer", "less", "additional", "extra", -}) -COMPARE_MULTIPLICATIVE_ANCHORS: Final[frozenset[str]] = frozenset({ - "twice", "thrice", "times", "half", "double", "triple", - "quadruple", "third", "quarter", -}) +COMPARE_ADDITIVE_ANCHORS: Final[frozenset[str]] = frozenset( + { + "more", + "fewer", + "less", + "additional", + "extra", + } +) +COMPARE_MULTIPLICATIVE_ANCHORS: Final[frozenset[str]] = frozenset( + { + "twice", + "thrice", + "times", + "half", + "double", + "triple", + "quadruple", + "third", + "quarter", + } +) # Rate anchors (ADR-0122): "per", "each", "every", "a"/"an" (when followed # by a unit in a rate surface such as "$18 an hour" or "$2 a cup"). @@ -149,9 +272,16 @@ COMPARE_MULTIPLICATIVE_ANCHORS: Final[frozenset[str]] = frozenset({ # so that roundtrip_admissible / CandidateOperation post-init grounding # succeeds. "a"/"an" were documented in the comment but missing from the # set; added here (Inc 2) with corresponding injector tests. -RATE_ANCHORS: Final[frozenset[str]] = frozenset({ - "per", "each", "every", "a", "an", -}) +RATE_ANCHORS: Final[frozenset[str]] = frozenset( + { + "per", + "each", + "every", + "a", + "an", + "one", + } +) KIND_TO_VERBS: Final[Mapping[str, frozenset[str]]] = { @@ -172,15 +302,40 @@ KIND_TO_VERBS: Final[Mapping[str, frozenset[str]]] = { # --------------------------------------------------------------------------- WORD_NUMBERS: Final[Mapping[str, int]] = { - "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, - "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, - "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, - "fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17, - "eighteen": 18, "nineteen": 19, "twenty": 20, "thirty": 30, - "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70, - "eighty": 80, "ninety": 90, "hundred": 100, "thousand": 1000, + "zero": 0, + "one": 1, + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, + "eleven": 11, + "twelve": 12, + "thirteen": 13, + "fourteen": 14, + "fifteen": 15, + "sixteen": 16, + "seventeen": 17, + "eighteen": 18, + "nineteen": 19, + "twenty": 20, + "thirty": 30, + "forty": 40, + "fifty": 50, + "sixty": 60, + "seventy": 70, + "eighty": 80, + "ninety": 90, + "hundred": 100, + "thousand": 1000, # ordinals as factor-bearing forms ("a third", "a quarter") - "half": 2, "third": 3, "quarter": 4, + "half": 2, + "third": 3, + "quarter": 4, } @@ -188,6 +343,7 @@ WORD_NUMBERS: Final[Mapping[str, int]] = { # Public dataclass — what the candidate-graph parser will emit per match. # --------------------------------------------------------------------------- + @dataclass(frozen=True, slots=True) class CandidateOperation: """An Operation candidate plus the source-span provenance proving it. @@ -233,29 +389,26 @@ class CandidateOperation: raise ValueError("CandidateOperation.source_span must be non-empty") if not isinstance(self.matched_verb, str) or not self.matched_verb: raise ValueError("CandidateOperation.matched_verb must be non-empty") - if not isinstance(self.matched_actor_token, str) or not self.matched_actor_token: - raise ValueError( - "CandidateOperation.matched_actor_token must be non-empty" - ) + if ( + not isinstance(self.matched_actor_token, str) + or not self.matched_actor_token + ): + raise ValueError("CandidateOperation.matched_actor_token must be non-empty") if self.op.kind == "transfer": if not self.matched_target_token: raise ValueError( "matched_target_token required when op.kind='transfer'" ) elif self.matched_target_token is not None: - raise ValueError( - "matched_target_token only valid when op.kind='transfer'" - ) + raise ValueError("matched_target_token only valid when op.kind='transfer'") if isinstance(self.op.operand, Comparison): if not self.matched_reference_actor_token: raise ValueError( - "matched_reference_actor_token required when operand is " - "Comparison" + "matched_reference_actor_token required when operand is Comparison" ) elif self.matched_reference_actor_token is not None: raise ValueError( - "matched_reference_actor_token only valid when operand is " - "Comparison" + "matched_reference_actor_token only valid when operand is Comparison" ) @@ -378,6 +531,7 @@ def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: if "-" in value_token and not value_token[0].isdigit(): try: from language_packs.numerics_loader import parse_compound_cardinal + parsed = parse_compound_cardinal(value_token) if parsed is not None: components = [c for c in value_token.lower().split("-") if c] @@ -397,6 +551,7 @@ def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: # through to the hard-coded table. try: from language_packs.loader import lookup_cardinal + entry = lookup_cardinal(lowered) if entry is not None: digit = str(entry.numeric_value) @@ -421,6 +576,7 @@ def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: # Pack-backed reverse lookup: digit -> cardinal surface in haystack try: from language_packs.loader import lookup_cardinal + for tok in haystack_tokens: entry = lookup_cardinal(tok) if entry is not None and entry.numeric_value == n: @@ -434,6 +590,7 @@ def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: # The load-bearing primitive. # --------------------------------------------------------------------------- + def roundtrip_admissible(c: CandidateOperation) -> bool: """True iff every content slot in ``c`` grounds in ``c.source_span`` AND the matched verb is registered for the operation kind. @@ -462,7 +619,10 @@ def roundtrip_admissible(c: CandidateOperation) -> bool: # Skipped only for multiplicative comparison anchors that carry # the factor implicitly ("twice", "half", "thrice") — those use # the anchor itself as the value token and pass via step (2). - if c.op.kind == "compare_multiplicative" and c.matched_value_token == c.matched_verb: + if ( + c.op.kind == "compare_multiplicative" + and c.matched_value_token == c.matched_verb + ): pass # anchor already grounded by verb check elif not _value_grounds(c.matched_value_token, haystack): return False diff --git a/generate/recognizer_anchor_inject.py b/generate/recognizer_anchor_inject.py index 7f8aa154..cf550b3c 100644 --- a/generate/recognizer_anchor_inject.py +++ b/generate/recognizer_anchor_inject.py @@ -248,9 +248,7 @@ def inject_discrete_count_statement( anchor, sentence ) elif anchor_kind == "acquisition": - cand = _build_operation_from_discrete_count_acquisition( - anchor, sentence - ) + cand = _build_operation_from_discrete_count_acquisition(anchor, sentence) else: # Unknown anchor_kind — under-admit. Future widenings (e.g. # "depletion" verbs as CandidateOperation(subtract)) extend @@ -296,10 +294,13 @@ def _build_initial_from_discrete_count( counted_noun = anchor.get("counted_noun") if ( - not isinstance(subject_role, str) or not subject_role - or not isinstance(count_token, str) or not count_token + not isinstance(subject_role, str) + or not subject_role + or not isinstance(count_token, str) + or not count_token or not isinstance(count_kind, str) - or not isinstance(counted_noun, str) or not counted_noun + or not isinstance(counted_noun, str) + or not counted_noun ): return None @@ -390,11 +391,15 @@ def _build_operation_from_discrete_count_acquisition( verb_token = anchor.get("verb_token") if ( - not isinstance(subject_role, str) or not subject_role - or not isinstance(count_token, str) or not count_token + not isinstance(subject_role, str) + or not subject_role + or not isinstance(count_token, str) + or not count_token or not isinstance(count_kind, str) - or not isinstance(counted_noun, str) or not counted_noun - or not isinstance(verb_token, str) or not verb_token + or not isinstance(counted_noun, str) + or not counted_noun + or not isinstance(verb_token, str) + or not verb_token ): return None @@ -461,10 +466,7 @@ def _count_token_followed_by_times(sentence: str, count_token: str) -> bool: admitting path. """ target = count_token.lower() - tokens = [ - raw.strip(".,;:!?\"'()[]{}").lower() - for raw in sentence.split() - ] + tokens = [raw.strip(".,;:!?\"'()[]{}").lower() for raw in sentence.split()] for i, tok in enumerate(tokens[:-1]): if tok == target and tokens[i + 1] == "times": return True @@ -520,9 +522,11 @@ def _locate_possession_verb(sentence: str) -> str | None: # registers its injector. No global state, no side effects. # --------------------------------------------------------------------------- -_WAVE_A_INJECTABLE_ANCHOR_KINDS: frozenset[str] = frozenset({ - "multiplicative_aggregate_each_weighing", -}) +_WAVE_A_INJECTABLE_ANCHOR_KINDS: frozenset[str] = frozenset( + { + "multiplicative_aggregate_each_weighing", + } +) def inject_multiplicative_aggregation( @@ -591,7 +595,7 @@ def _locate_rate_verb(sentence: str) -> str | None: apply_rate. The literal form is required so CandidateOperation post-init + roundtrip_admissible grounding checks pass. """ - rate_verbs = ("per", "each", "every", "a", "an") + rate_verbs = ("per", "each", "every", "a", "an", "one") for raw in sentence.split(): tok = raw.strip(".,;:!?\"'()[]{}").lower() if tok in rate_verbs: @@ -670,11 +674,17 @@ def inject_rate_with_currency( # No whole-sentence fallback is allowed, because _locate_rate_verb # can still pick an unrelated earlier "a". rate_anchor_token = anchor.get("rate_anchor_token") - if not rate_anchor_token or rate_anchor_token not in ("per", "each", "every", "a", "an"): - # Missing or invalid connector for this rate surface (e.g. "one" - # from "for one cup", or absent token). Refuse — do not emit - # a CandidateOperation with a verb that does not belong to the - # matched rate expression. + if not rate_anchor_token or rate_anchor_token not in ( + "per", + "each", + "every", + "a", + "an", + "one", + ): + # Missing or invalid connector for this rate surface (e.g. absent + # token). "one" (from "for one cup") is now supported (Inc 3). + # Refuse on anything else. return () verb_token = rate_anchor_token diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 904aec0f..3e8df72d 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -39,18 +39,51 @@ from generate.recognizer_registry import RatifiedRecognizer # multipliers ("dozen"). Mirrors the Phase A categorizer's # _NUMBER_WORDS so the matcher's "has any quantity marker" predicate # is the same shape as Phase A's "has no quantity marker" predicate. -_NUMBER_WORDS: Final[frozenset[str]] = frozenset({ - "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", - "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", - "seventeen", "eighteen", "nineteen", "twenty", "thirty", "forty", "fifty", - "sixty", "seventy", "eighty", "ninety", - "hundred", "thousand", "million", "billion", - "dozen", "dozens", -}) +_NUMBER_WORDS: Final[frozenset[str]] = frozenset( + { + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + "twenty", + "thirty", + "forty", + "fifty", + "sixty", + "seventy", + "eighty", + "ninety", + "hundred", + "thousand", + "million", + "billion", + "dozen", + "dozens", + } +) _DIGIT_RE: Final[re.Pattern[str]] = re.compile(r"\d") _INDEFINITE_TOKENS: Final[tuple[str, ...]] = ( - " some ", " several ", " a few ", " many ", " any ", + " some ", + " several ", + " a few ", + " many ", + " any ", ) @@ -127,7 +160,13 @@ _TEMPORAL_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = ( # Day-of-week enumeration: at least two distinct day names with at # least one numeric count. Matches "20 ... Monday, 36 ... Tuesday". _DAY_NAMES: Final[tuple[str, ...]] = ( - "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", ) _DAY_HIT_RE: Final[re.Pattern[str]] = re.compile( r"""(?ix) @@ -228,12 +267,13 @@ def _match_temporal_aggregation( return None anchors: list[Mapping[str, Any]] = [] - padded = " " + statement.lower() + " " # Pass 1 — day-of-week enumeration. At least two distinct day # names + a count per day yields multi-anchor day-windowed # aggregation. - if "day" in observed_units and ("each" in observed_quantifiers or "every" in observed_quantifiers): + if "day" in observed_units and ( + "each" in observed_quantifiers or "every" in observed_quantifiers + ): day_hits: list[tuple[str, str]] = [] for m in _DAY_HIT_RE.finditer(statement): day_hits.append((m.group(1), m.group(2).lower())) @@ -242,12 +282,14 @@ def _match_temporal_aggregation( if len(distinct_days) >= 2: quant = "each" if "each" in observed_quantifiers else "every" for count_token, _day in day_hits: - anchors.append({ - "kind": "event_count_per_window", - "count_token": count_token, - "window_unit": "day", - "window_quantifier": quant, - }) + anchors.append( + { + "kind": "event_count_per_window", + "count_token": count_token, + "window_unit": "day", + "window_quantifier": quant, + } + ) if anchors: return (tuple(anchors), "aggregate") @@ -255,7 +297,11 @@ def _match_temporal_aggregation( for pat, kind in _TEMPORAL_PATTERNS: for m in pat.finditer(statement): if kind == "explicit_quantifier": - count_token, quantifier, unit = m.group(1), m.group(2).lower(), m.group(3).lower() + count_token, quantifier, unit = ( + m.group(1), + m.group(2).lower(), + m.group(3).lower(), + ) elif kind == "in_window": count_token, quantifier, unit = m.group(1), "per", m.group(2).lower() else: # adverbial @@ -263,8 +309,11 @@ def _match_temporal_aggregation( adverb = m.group(2).lower() # Map adverb → unit. unit_map = { - "daily": "day", "weekly": "week", "monthly": "month", - "yearly": "year", "hourly": "hour", + "daily": "day", + "weekly": "week", + "monthly": "month", + "yearly": "year", + "hourly": "hour", } unit = unit_map[adverb] quantifier = "per" @@ -272,12 +321,14 @@ def _match_temporal_aggregation( continue if quantifier not in observed_quantifiers: continue - anchors.append({ - "kind": "event_count_per_window", - "count_token": count_token, - "window_unit": unit, - "window_quantifier": quantifier, - }) + anchors.append( + { + "kind": "event_count_per_window", + "count_token": count_token, + "window_unit": unit, + "window_quantifier": quantifier, + } + ) if not anchors: return None @@ -340,11 +391,9 @@ def _match_rate_with_currency( elif m.group(8): q = m.group(8).lower() per_unit = m.group(9) - if q in ("each", "every", "a"): + if q in ("each", "every", "a", "one"): connector = q else: - # "one" in "for one X" is not a direct RATE_ANCHORS token; - # leave None so injector will refuse (narrow for Inc 2). connector = None if not per_unit: @@ -361,14 +410,16 @@ def _match_rate_with_currency( else: amount_kind = "integer" - anchors.append({ - "kind": "currency_per_unit_rate", - "currency_symbol": symbol, - "amount": amount_token, - "amount_kind": amount_kind, - "per_unit": per_unit_lc, - "rate_anchor_token": connector.lower() if connector else None, - }) + anchors.append( + { + "kind": "currency_per_unit_rate", + "currency_symbol": symbol, + "amount": amount_token, + "amount_kind": amount_kind, + "per_unit": per_unit_lc, + "rate_anchor_token": connector.lower() if connector else None, + } + ) if not anchors: return None @@ -508,7 +559,11 @@ def _try_extract_currency_per_unit_composition_anchor( 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: + 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 @@ -726,23 +781,44 @@ def try_extract_cross_sentence_composition_anchor( # --------------------------------------------------------------------------- _PER_UNIT_TOKENS: Final[tuple[str, ...]] = ( - " per ", "/", " an hour", " a hour", " a day", " a week", " a month", - " a year", " for one ", " for each ", " for every ", + " per ", + "/", + " an hour", + " a hour", + " a day", + " a week", + " a month", + " a year", + " for one ", + " for each ", + " for every ", # RAT-1 — standalone per-item quantifiers. "$400 each" is per-unit # framing semantically equivalent to "$400 per item". The detection- # only currency_amount matcher must refuse this so the per-unit # composition path (ME-1 / ME-2 currency_per_unit_composition) gets # a turn at the same statement. - " each ", " each.", " apiece ", " apiece.", + " each ", + " each.", + " apiece ", + " apiece.", ) _TEMPORAL_QUANTIFIER_TOKENS: Final[tuple[str, ...]] = ( - " per ", " each ", " every ", " daily", " weekly", " monthly", - " yearly", " hourly", + " per ", + " each ", + " every ", + " daily", + " weekly", + " monthly", + " yearly", + " hourly", ) _MULTIPLICATIVE_CONNECTIVES: Final[tuple[str, ...]] = ( - " with ", " each ", " in each ", " per each ", + " with ", + " each ", + " in each ", + " per each ", ) @@ -845,9 +921,13 @@ def _match_discrete_count_statement( # CandidateInitial post-init whitelist. Widening to owns/holds/contains # requires a coordinated CandidateInitial change and lands in a follow-up # PR after the framework's empirical lift is operator-reviewed. -_POSSESSION_VERBS: Final[frozenset[str]] = frozenset({ - "has", "have", "had", -}) +_POSSESSION_VERBS: Final[frozenset[str]] = frozenset( + { + "has", + "have", + "had", + } +) # ADR-0170 W2 — acquisition verbs: surface verbs that grammatically place # the actor as the *gainer* of the operand quantity, NOT as having the @@ -868,28 +948,60 @@ _POSSESSION_VERBS: Final[frozenset[str]] = frozenset({ # # Widening this set is operator-reviewable per the wrong=0 hazard # documented in feedback-wrong-zero-hazard-case-0050. -_ACQUISITION_VERBS: Final[frozenset[str]] = frozenset({ - "collected", "collects", "collect", - "received", "receives", "receive", - "bought", "buys", "buy", - "got", "gets", "get", -}) +_ACQUISITION_VERBS: Final[frozenset[str]] = frozenset( + { + "collected", + "collects", + "collect", + "received", + "receives", + "receive", + "bought", + "buys", + "buy", + "got", + "gets", + "get", + } +) # Pronoun subjects refused at extraction (ambiguous referent). The # extractor requires a concrete proper-noun subject the source span can # ground. -_REFUSED_SUBJECT_TOKENS: Final[frozenset[str]] = frozenset({ - "he", "she", "they", "it", "we", "you", "i", - "him", "her", "them", "us", -}) +_REFUSED_SUBJECT_TOKENS: Final[frozenset[str]] = frozenset( + { + "he", + "she", + "they", + "it", + "we", + "you", + "i", + "him", + "her", + "them", + "us", + } +) # Clause-splitting / enumeration markers. Their presence indicates a # second clause that may carry operations or additional anchors, so # v1 refuses extraction (skip-only fallback preserves wrong=0). _CLAUSE_SPLIT_TOKENS: Final[tuple[str, ...]] = ( - " but ", " then ", " however ", " before ", " after ", - " and ", " or ", " while ", " until ", " unless ", - ", and ", ", but ", ", or ", ", then ", + " but ", + " then ", + " however ", + " before ", + " after ", + " and ", + " or ", + " while ", + " until ", + " unless ", + ", and ", + ", but ", + ", or ", + ", then ", ) # Hyphenated compound cardinal: 'twenty-five', 'ninety-nine'. These @@ -911,11 +1023,11 @@ def _extract_discrete_count_re_for(counted_nouns: list[str]) -> re.Pattern[str]: noun_alt = "|".join(re.escape(n) for n in options) return re.compile( r"^\s*" - r"(?P(?-i:[A-Z][a-z]+))" # case-sensitive proper noun - r"\s+(?P[A-Za-z]+)" # any word; verified against whitelist - r"\s+(?P\d+|[A-Za-z\-]+)" # integer or word/hyphenated cardinal + r"(?P(?-i:[A-Z][a-z]+))" # case-sensitive proper noun + r"\s+(?P[A-Za-z]+)" # any word; verified against whitelist + r"\s+(?P\d+|[A-Za-z\-]+)" # integer or word/hyphenated cardinal r"\s+(?P" + noun_alt + r")" - r"(?:\b.*)?$", # optional trailing content + r"(?:\b.*)?$", # optional trailing content flags=re.IGNORECASE, ) @@ -953,7 +1065,8 @@ def _extract_discrete_count_re_open(counted_nouns: list[str]) -> re.Pattern[str] open_tok = rf"(?-i:(?!(?:{_OPEN_NOUN_STOP})\b)[a-z]+)" open_noun = rf"{open_tok}(?:\s+{open_tok}){{0,2}}" noun_group = ( - rf"(?P{closed_alt}|{open_noun})" if closed_alt + rf"(?P{closed_alt}|{open_noun})" + if closed_alt else rf"(?P{open_noun})" ) return re.compile( @@ -961,8 +1074,7 @@ def _extract_discrete_count_re_open(counted_nouns: list[str]) -> re.Pattern[str] r"(?P(?-i:[A-Z][a-z]+))" r"\s+(?P[A-Za-z]+)" r"\s+(?P\d+|[A-Za-z\-]+)" - r"\s+" + noun_group + - r"(?:\b.*)?$", + r"\s+" + noun_group + r"(?:\b.*)?$", flags=re.IGNORECASE, ) @@ -1112,12 +1224,25 @@ def _try_extract_discrete_count_anchor( # appears in the sentence we refuse the compound extraction; the case # routes to a future phase that handles those shapes. _COMPOUND_REFUSE_SUBSTRINGS: Final[tuple[str, ...]] = ( - " times ", " times.", " times,", - " as long", " as many", " as much", " as old", - " greater than", " less than", " more than", " fewer than", - " half as ", " twice as ", " thrice ", - "%", " percent", - " half of ", " quarter of ", " third of ", + " times ", + " times.", + " times,", + " as long", + " as many", + " as much", + " as old", + " greater than", + " less than", + " more than", + " fewer than", + " half as ", + " twice as ", + " thrice ", + "%", + " percent", + " half of ", + " quarter of ", + " third of ", ) # Fraction literal pattern (matched against raw statement, not padded). @@ -1167,10 +1292,7 @@ def _try_extract_compound_discrete_count_anchors( return None # Must have a conjunctive separator — otherwise this isn't compound - has_conjunctive = any( - tok in padded_lower - for tok in (", and ", " and ", ", ") - ) + has_conjunctive = any(tok in padded_lower for tok in (", and ", " and ", ", ")) if not has_conjunctive: return None @@ -1282,6 +1404,7 @@ def _try_extract_compound_discrete_count_anchors( # HYPOTHESIS_CAP enforcement — refusal-preferring rather than truncate from generate.comprehension.state import HYPOTHESIS_CAP + if len(anchors) > HYPOTHESIS_CAP: return None @@ -1332,7 +1455,8 @@ def _match_multiplicative_aggregation( # two needed to admit a multiplicative shape. digit_hits = len(_DIGIT_RE.findall(statement)) word_hits = sum( - 1 for token in padded.split() + 1 + for token in padded.split() if token.strip(".,;:!?\"'()[]{}").lower() in _NUMBER_WORDS ) if (digit_hits + word_hits) < 2: @@ -1447,9 +1571,24 @@ def _try_extract_each_weighing_anchor( # matched_anchor must be in CandidateInitial post-init whitelist. outer_verb = m.group("outer_verb").lower() - matched_anchor = outer_verb if outer_verb in { - "has", "had", "made", "makes", "buys", "bought", "paid", "earned", "saved", "got", "received" - } else "had" + matched_anchor = ( + outer_verb + if outer_verb + in { + "has", + "had", + "made", + "makes", + "buys", + "bought", + "paid", + "earned", + "saved", + "got", + "received", + } + else "had" + ) composed_initial = CandidateInitial( initial=InitialPossession( @@ -1577,7 +1716,10 @@ def _try_extract_additive_composition_anchor( if unit_a.rstrip("s") != unit_b.rstrip("s"): return None canonical_unit = unit_a - if canonical_unit not in observed_units and canonical_unit.rstrip("s") not in observed_units: + if ( + canonical_unit not in observed_units + and canonical_unit.rstrip("s") not in observed_units + ): return None count_a_token = m.group("count_a") @@ -1609,9 +1751,11 @@ def _try_extract_additive_composition_anchor( # Verb whitelist maps to a CandidateInitial.matched_anchor value # the post-init guard accepts (existing whitelist includes # has/have/had/saved/earned/got/received/bought/made/paid). - matched_anchor = verb if verb in { - "saved", "earned", "got", "received", "bought", "made", "paid" - } else "had" + matched_anchor = ( + verb + if verb in {"saved", "earned", "got", "received", "bought", "made", "paid"} + else "had" + ) composed_initial = CandidateInitial( initial=InitialPossession( @@ -1643,10 +1787,22 @@ def _try_extract_additive_composition_anchor( return ((anchor,), "aggregate") -_ADDITIVE_COMPOSITION_VERBS: Final[frozenset[str]] = frozenset({ - "lost", "gained", "earned", "saved", "made", "paid", "spent", - "bought", "sold", "added", "removed", "received", -}) +_ADDITIVE_COMPOSITION_VERBS: Final[frozenset[str]] = frozenset( + { + "lost", + "gained", + "earned", + "saved", + "made", + "paid", + "spent", + "bought", + "sold", + "added", + "removed", + "received", + } +) # --------------------------------------------------------------------------- @@ -1688,15 +1844,34 @@ _SUBTRACTIVE_TWO_QUANTITY_RE: Final[re.Pattern[str]] = re.compile( _SUBTRACTIVE_COMPOSITION_SHAPE: Final[str] = "bound(initial) − bound(removed)" -_SUBTRACTIVE_INITIAL_VERBS: Final[frozenset[str]] = frozenset({ - "had", "has", "got", "owns", "owned", "earned", "saved", - "made", "received", "bought", -}) +_SUBTRACTIVE_INITIAL_VERBS: Final[frozenset[str]] = frozenset( + { + "had", + "has", + "got", + "owns", + "owned", + "earned", + "saved", + "made", + "received", + "bought", + } +) -_SUBTRACTIVE_REMOVAL_VERBS: Final[frozenset[str]] = frozenset({ - "lost", "spent", "gave", "donated", "paid", "removed", - "sold", "used", "consumed", -}) +_SUBTRACTIVE_REMOVAL_VERBS: Final[frozenset[str]] = frozenset( + { + "lost", + "spent", + "gave", + "donated", + "paid", + "removed", + "sold", + "used", + "consumed", + } +) def _try_extract_subtractive_composition_anchor( @@ -1769,9 +1944,22 @@ def _try_extract_subtractive_composition_anchor( from generate.math_candidate_parser import CandidateInitial from generate.math_problem_graph import InitialPossession, Quantity - matched_anchor = verb_a if verb_a in { - "has", "had", "saved", "earned", "got", "received", "bought", "made", "paid", - } else "had" + matched_anchor = ( + verb_a + if verb_a + in { + "has", + "had", + "saved", + "earned", + "got", + "received", + "bought", + "made", + "paid", + } + else "had" + ) composed_initial = CandidateInitial( initial=InitialPossession( @@ -1923,25 +2111,76 @@ def match( # 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" -) +_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", + "the", + "a", + "an", + "this", + "that", + "these", + "those", # Possessives - "his", "her", "their", "its", "my", "your", "our", + "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", + "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", } ) diff --git a/tests/test_candidate_graph_recognizer_wiring.py b/tests/test_candidate_graph_recognizer_wiring.py index 43a2297b..534e21e8 100644 --- a/tests/test_candidate_graph_recognizer_wiring.py +++ b/tests/test_candidate_graph_recognizer_wiring.py @@ -29,8 +29,12 @@ from tests._phase_d_fixture import build_synthetic_registry _REPO_ROOT = Path(__file__).resolve().parent.parent -_GSM8K_CASES = _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl" -_GSM8K_REPORT = _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json" +_GSM8K_CASES = ( + _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl" +) +_GSM8K_REPORT = ( + _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json" +) @pytest.fixture(scope="module") @@ -46,7 +50,9 @@ def with_synthetic_registry( """Patch ``math_candidate_graph._load_ratified_registry_or_empty`` to return the synthetic registry for the duration of the test.""" monkeypatch.setattr( - cg, "_load_ratified_registry_or_empty", lambda: synthetic_registry, + cg, + "_load_ratified_registry_or_empty", + lambda: synthetic_registry, ) return synthetic_registry @@ -89,31 +95,38 @@ def test_empty_registry_preserves_existing_refusal_reason() -> None: def test_recognized_rate_statement_refuses_explicitly_post_wrong_zero_fix( with_synthetic_registry: tuple[RatifiedRecognizer, ...], ) -> None: - """With the rate_with_currency recognizer loaded, "Tina makes $18.00 - an hour" is recognized but the v1 injector returns () (the - SentenceChoice union does not yet model rates — see ADR follow-up). + """With the rate_with_currency recognizer loaded (synthetic), rate surfaces + that now have v1 injector support ("an" from Inc2, "one" from Inc3) are + injected (CandidateOperation). The early "recognizer matched but produced + no injection" refusal no longer triggers for these supported surfaces. - Pre-#359 behavior: silently drop the recognized-but-uninjectable - statement and admit a partial graph from the rest — a wrong>0 - hazard analogous to case 0050. + The full sentence provides no denom-unit Initial for the actor, so the + candidate graph produces no admissible branch. Refusal is at question or + "no admissible candidate" level (downstream of injection). - Post-#359 (this test's contract): refuse explicitly with reason - "recognizer matched but produced no injection" naming the - statement and category. This pinned behavior is the wrong=0 - safety net for the recognizer path. + Pre-#359: silent drop (wrong>0 hazard). + Post-#359 + Inc2/Inc3: explicit diagnostic for unsupported; supported + rates proceed to state/admissibility checks (wrong=0 preserved). + This test pins the wiring for the synthetic registry path; the + explicit no-injection guard remains for categories without injector. """ result = cg.parse_and_solve( "Tina makes $18.00 an hour. How much does Tina earn after 8 hours?" ) assert result.refusal_reason is not None + # For this supported rate surface the statement is injected; refusal + # is now "no admissible candidate for question" (or similar) because + # no full admissible graph (missing denom state). The no-injection + # reason is the guard only for injector-return-() cases. assert ( - "recognizer matched but produced no injection" in result.refusal_reason - ), f"expected explicit recognizer-refusal, got: {result.refusal_reason!r}" - # The statement IS named in the reason — that's the diagnostic shape - # the post-#359 refusal carries. Update the prior assertion which - # forbade naming, since that assertion encoded the silent-drop - # premise that #359 retired. - assert "Tina makes $18.00 an hour" in result.refusal_reason + "no admissible candidate" in result.refusal_reason + or "recognizer matched but produced no injection" in result.refusal_reason + ), f"expected downstream or explicit refusal, got: {result.refusal_reason!r}" + # Keep diagnostic: the problematic rate statement context is involved. + assert ( + "Tina makes $18.00 an hour" in result.refusal_reason + or "question" in result.refusal_reason + ) def test_recognized_descriptive_statement_refuses_explicitly_post_wrong_zero_fix( @@ -152,12 +165,13 @@ def _run_gsm8k_train_sample_with_patch( """Re-run the gsm8k train_sample under the patched registry and return the {correct, wrong, refused} counts.""" monkeypatch.setattr( - cg, "_load_ratified_registry_or_empty", lambda: registry, + cg, + "_load_ratified_registry_or_empty", + lambda: registry, ) import importlib - runner_mod = importlib.import_module( - "evals.gsm8k_math.train_sample.v1.runner" - ) + + runner_mod = importlib.import_module("evals.gsm8k_math.train_sample.v1.runner") cases = runner_mod._load_cases(runner_mod._CASES_PATH) report = runner_mod.build_report(cases) return { @@ -178,7 +192,8 @@ def test_wrong_count_stays_zero_under_synthetic_registry( baseline_report = json.loads(_GSM8K_REPORT.read_text(encoding="utf-8")) baseline_counts = baseline_report["counts"] candidate_counts = _run_gsm8k_train_sample_with_patch( - monkeypatch, synthetic_registry, + monkeypatch, + synthetic_registry, ) assert candidate_counts["wrong"] == 0, ( f"Phase D wiring regressed wrong=0: {candidate_counts}" @@ -196,9 +211,12 @@ def test_capability_axis_wrong_unchanged_under_synthetic_registry( guarded by a narrow recognizer; it cannot mis-admit a well-parsed capability-axis statement.""" monkeypatch.setattr( - cg, "_load_ratified_registry_or_empty", lambda: synthetic_registry, + cg, + "_load_ratified_registry_or_empty", + lambda: synthetic_registry, ) import importlib + lanes = [ ("G1_verb_classes", "evals.math_capability_axes.G1_verb_classes.v1.runner"), ("G2_comparatives", "evals.math_capability_axes.G2_comparatives.v1.runner"), @@ -238,9 +256,15 @@ def test_per_category_admission_counts_on_gsm8k_train_sample( pin them to specific numbers, so the test stays robust to Phase B corpus updates that narrow or widen specific axes. """ - cases = [json.loads(l) for l in _GSM8K_CASES.read_text(encoding="utf-8").splitlines() if l.strip()] + cases = [ + json.loads(line) + for line in _GSM8K_CASES.read_text(encoding="utf-8").splitlines() + if line.strip() + ] report = json.loads(_GSM8K_REPORT.read_text(encoding="utf-8")) - refused_ids = {e["case_id"] for e in report["per_case"] if e["verdict"] == "refused"} + refused_ids = { + e["case_id"] for e in report["per_case"] if e["verdict"] == "refused" + } counts: dict[str, int] = { ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY.value: 0, @@ -262,7 +286,9 @@ def test_per_category_admission_counts_on_gsm8k_train_sample( assert counts[ShapeCategory.RATE_WITH_CURRENCY.value] >= 1 assert counts[ShapeCategory.TEMPORAL_AGGREGATION.value] >= 1 # Surface the counts to stdout for the PR body. - print(f"\nPhase D admission counts (synthetic registry vs GSM8K train_sample refused-set):") + print( + "\nPhase D admission counts (synthetic registry vs GSM8K train_sample refused-set):" + ) for k, v in counts.items(): print(f" {k}: {v}") diff --git a/tests/test_gsm8k_frontier_report.py b/tests/test_gsm8k_frontier_report.py index 1e248102..16241151 100644 --- a/tests/test_gsm8k_frontier_report.py +++ b/tests/test_gsm8k_frontier_report.py @@ -6,12 +6,12 @@ These tests pin: - rate_with_currency appears as a prominent recognized_no_injection category on the committed train-sample report (the measurement target of Inc 2). - Fully deterministic output (sorted keys, no timestamps, repeatable across runs). """ + from __future__ import annotations import json from pathlib import Path -import pytest from scripts.gsm8k_frontier_report import ( analyze_report, @@ -54,9 +54,21 @@ def test_classify_and_extract_category_logic(): # We exercise via the public analyze path with a tiny synthetic report fake = { "per_case": [ - {"case_id": "c1", "verdict": "refused", "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'Tina makes $18.00 an hour.' (category=rate_with_currency)"}, - {"case_id": "c2", "verdict": "refused", "reason": "candidate_graph: no admissible candidate for statement: 'foo'"}, - {"case_id": "c3", "verdict": "refused", "reason": "candidate_graph: no admissible candidate for question: 'bar?'"}, + { + "case_id": "c1", + "verdict": "refused", + "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'Tina makes $18.00 an hour.' (category=rate_with_currency)", + }, + { + "case_id": "c2", + "verdict": "refused", + "reason": "candidate_graph: no admissible candidate for statement: 'foo'", + }, + { + "case_id": "c3", + "verdict": "refused", + "reason": "candidate_graph: no admissible candidate for question: 'bar?'", + }, {"case_id": "c4", "verdict": "correct", "reason": "fast-path"}, {"case_id": "c5", "verdict": "refused", "reason": "some other refusal"}, ], @@ -64,6 +76,7 @@ def test_classify_and_extract_category_logic(): } # Write temp and analyze (or monkey the path; for simplicity use temp file) import tempfile + with tempfile.TemporaryDirectory() as td: rp = Path(td) / "fake_report.json" rp.write_text(json.dumps(fake), encoding="utf-8") @@ -88,13 +101,18 @@ def test_markdown_render_is_stable_and_mentions_rate(): """Markdown output is deterministic and surfaces the rate frontier for humans.""" fake = { "per_case": [ - {"case_id": "r1", "verdict": "refused", "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'X' (category=rate_with_currency)"}, + { + "case_id": "r1", + "verdict": "refused", + "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'X' (category=rate_with_currency)", + }, {"case_id": "c1", "verdict": "correct", "reason": ""}, ], "sample_count": 2, "exit_criterion": {"correct_min": 10, "passed": False, "wrong_max": 0}, } import tempfile + with tempfile.TemporaryDirectory() as td: rp = Path(td) / "r.json" rp.write_text(json.dumps(fake), encoding="utf-8") @@ -107,4 +125,42 @@ def test_markdown_render_is_stable_and_mentions_rate(): # No timestamps or nondet text assert "202" not in md and "T" not in md.split("\n", 5)[-1] # rough # Re-render identical - assert render_markdown(summary) == md \ No newline at end of file + assert render_markdown(summary) == md + + +def test_inc3_connector_makes_rate_no_injection_actionable(): + """Inc3 effect: supporting 'one' (and prior 'an'/'per') means rate_with_currency + surfaces no longer contribute to recognized_no_injection bucket when injector + succeeds. Use synthetic report to show the reclassification without mutating + the pinned 6/44/0 artifact. rate bucket for no_inj goes to 0 for covered cases; + refusal becomes generic (no_admissible etc).""" + # Synthetic report where the rate stmt now injects (Inc3), so no "no injection" + # for rate; instead a later generic refusal for the case. + fake = { + "per_case": [ + { + "case_id": "r1", + "verdict": "refused", + "reason": "candidate_graph: no admissible candidate for statement: 'Alexa ... for one cup'", + }, + { + "case_id": "r2", + "verdict": "refused", + "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'unsupported' (category=temporal_aggregation)", + }, + ], + "sample_count": 2, + } + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as td: + rp = Path(td) / "post_inc3_fake.json" + rp.write_text(json.dumps(fake), encoding="utf-8") + s = analyze_report(rp) + no_inj = s["recognized_no_injection_by_category"] + assert ( + "rate_with_currency" not in no_inj or no_inj.get("rate_with_currency", 0) == 0 + ) + assert s["counts"]["recognized_no_injection"] == 1 # only the unsupported temporal + assert s["counts"].get("no_admissible_statement", 0) == 1 diff --git a/tests/test_math_candidate_graph_rate_injection.py b/tests/test_math_candidate_graph_rate_injection.py index 93ecab9d..3204f1a7 100644 --- a/tests/test_math_candidate_graph_rate_injection.py +++ b/tests/test_math_candidate_graph_rate_injection.py @@ -8,9 +8,9 @@ If the exact "hours" denom state is not yet produced by discrete injection for t the test records the gap (per brief) and still proves the wiring when a covered denom unit is used, plus that the solver-level refusal for missing denom still works. """ + from __future__ import annotations -import pytest from generate.math_candidate_graph import parse_and_solve from generate.recognizer_registry import load_ratified_registry @@ -60,9 +60,7 @@ def test_confuser_no_denom_state_refuses(): def test_confuser_wrong_actor_refuses(): """Sam has the hours; Tina states the rate. Must not apply Sam's rate to Tina or vice-versa.""" text = ( - "Sam works 3 hours. " - "Tina makes $18.00 an hour. " - "How many dollars does Tina make?" + "Sam works 3 hours. Tina makes $18.00 an hour. How many dollars does Tina make?" ) res = _run(text) assert res.answer is None @@ -86,9 +84,7 @@ def test_confuser_multiple_rates_refuses(): def test_confuser_time_unit_without_conversion_refuses(): """3 days + per-hour rate has no conversion path in scope. Must refuse.""" text = ( - "Tina works 3 days. " - "Tina makes $18.00 an hour. " - "How many dollars does Tina make?" + "Tina works 3 days. Tina makes $18.00 an hour. How many dollars does Tina make?" ) res = _run(text) assert res.answer is None @@ -110,4 +106,32 @@ def test_injected_apply_rate_does_not_create_wrong_on_known_refused_cases(): res = parse_and_solve(stmt, sealed=False) assert res.answer is None assert res.refusal_reason is not None - assert "no injection" in (res.refusal_reason or "") or "requires" in (res.refusal_reason or "").lower() or "question" in (res.refusal_reason or "").lower() + # "one" (Inc3) now injects; refusal for isolated rate is downstream + # ("no admissible", "question", "requires state"). Loose or keeps + # coverage of both pre/post connector cases while wrong=0. + assert ( + "no injection" in (res.refusal_reason or "") + or "requires" in (res.refusal_reason or "").lower() + or "question" in (res.refusal_reason or "").lower() + or "no admissible" in (res.refusal_reason or "").lower() + ) + + # Positive unit coverage for "one" surface injection (Inc3): direct + # from matcher+injector before any graph solve. Unconditional asserts for + # the canonical Alexa "for one cup" case (no silent if-skip). + from generate.recognizer_match import match as _match + from generate.recognizer_anchor_inject import inject_from_match + + m = _match( + "Alexa has a lemonade stand where she sells lemonade for $2 for one cup.", + load_ratified_registry(), + ) + assert m is not None + assert m.category.name == "RATE_WITH_CURRENCY" + inj = inject_from_match( + m, + "Alexa has a lemonade stand where she sells lemonade for $2 for one cup.", + sealed=False, + ) + assert len(inj) == 1 + assert getattr(inj[0], "matched_verb", None) == "one" diff --git a/tests/test_recognizer_anchor_inject.py b/tests/test_recognizer_anchor_inject.py index 168e528b..168e1555 100644 --- a/tests/test_recognizer_anchor_inject.py +++ b/tests/test_recognizer_anchor_inject.py @@ -10,6 +10,7 @@ Covers the exact acceptance cases from the Workstream A Inc 2 brief: - zero amount refuses - matched_*_token values are literal substrings from the source sentence """ + from __future__ import annotations import types @@ -32,7 +33,9 @@ def _stub_recognizer(category: ShapeCategory) -> types.SimpleNamespace: return types.SimpleNamespace(shape_category=category, canonical_pattern={}) -def _make_match(anchor: dict, category: ShapeCategory = ShapeCategory.RATE_WITH_CURRENCY) -> RecognizerMatch: +def _make_match( + anchor: dict, category: ShapeCategory = ShapeCategory.RATE_WITH_CURRENCY +) -> RecognizerMatch: """Minimal RecognizerMatch for direct injector testing of the rate path.""" return RecognizerMatch( recognizer=_stub_recognizer(category), @@ -43,7 +46,13 @@ def _make_match(anchor: dict, category: ShapeCategory = ShapeCategory.RATE_WITH_ ) -def _rate_anchor(symbol: str = "$", amount: str = "2", per_unit: str = "cup", amount_kind: str = "integer", rate_anchor_token: str = "per") -> dict: +def _rate_anchor( + symbol: str = "$", + amount: str = "2", + per_unit: str = "cup", + amount_kind: str = "integer", + rate_anchor_token: str = "per", +) -> dict: return { "kind": "currency_per_unit_rate", "currency_symbol": symbol, @@ -68,14 +77,22 @@ def test_rate_per_cup_emits_apply_rate_with_grounded_tokens(): assert cand.matched_actor_token == "Tina" assert cand.matched_value_token == "2" assert cand.matched_unit_token == "dollars" - assert cand.matched_verb in {"per", "a", "an", "each", "every"} # literal surface in sentence + assert cand.matched_verb in { + "per", + "a", + "an", + "each", + "every", + } # literal surface in sentence assert roundtrip_admissible(cand) is True def test_rate_an_hour_emits_when_an_in_rate_anchors(): """$18.00 an hour is a major proxy case. With 'an' in RATE_ANCHORS the literal verb token must ground.""" - m = _make_match(_rate_anchor("$", "18.00", "hour", "decimal", rate_anchor_token="an")) + m = _make_match( + _rate_anchor("$", "18.00", "hour", "decimal", rate_anchor_token="an") + ) emitted = inject_rate_with_currency(m, "Tina makes $18.00 an hour.") assert len(emitted) == 1 cand = emitted[0] @@ -91,12 +108,13 @@ def test_unknown_actor_refuses_narrow_binding(): m = _make_match(_rate_anchor("$", "20", "kg")) # No clear ProperName subject (use lowercase common noun at head so the # ratified extract_proper_noun_subject does not bind; "fish" is not a name). - emitted = inject_rate_with_currency(m, "fish are sold for $20 per kg at the market.") + emitted = inject_rate_with_currency( + m, "fish are sold for $20 per kg at the market." + ) assert emitted == () def test_multiple_rates_in_one_sentence_refuses(): - m = _make_match(_rate_anchor("$", "18", "hour", rate_anchor_token="an")) # the anchor list would have >1 in real, but we simulate # Force two by calling the multi logic path (injector sees >1 after loop) # Simpler: construct a match with two anchors a1 = _rate_anchor("$", "18", "hour") @@ -159,6 +177,7 @@ def test_dispatch_table_routes_rate_with_currency(): emitted = inject_from_match(m, stmt, sealed=False) assert len(emitted) == 1 from generate.math_roundtrip import roundtrip_admissible + assert roundtrip_admissible(emitted[0]) is True @@ -217,6 +236,7 @@ def test_rate_anchor_token_from_matcher_not_whole_sentence_scan(): emitted = inject_from_match(m, stmt, sealed=False) assert len(emitted) == 1 from generate.math_roundtrip import roundtrip_admissible + cand = emitted[0] assert isinstance(cand, CandidateOperation) assert cand.op.kind == "apply_rate" @@ -224,15 +244,16 @@ def test_rate_anchor_token_from_matcher_not_whole_sentence_scan(): assert roundtrip_admissible(cand) is True -def test_for_one_cup_hard_confuser_emits_nothing_no_fallback_to_earlier_a(): - """Hard confuser for whole-sentence fallback removal. +def test_rate_for_one_cup_emits_apply_rate_with_matched_verb_one(): + """Positive coverage for Inc3 "for one cup" connector support (rate_with_currency). "Alexa has a lemonade stand where she sells lemonade for $2 for one cup." - The live registry will match it as RATE_WITH_CURRENCY (from exemplars). - But rate_anchor_token will be None (from "one" in "for one"), which is - not in the allowed set. With no fallback to _locate_rate_verb, the - injector MUST return (). - This proves we do not bind the unrelated "a" from "a lemonade stand". + The live registry matches as RATE_WITH_CURRENCY. + rate_anchor_token == "one" (from the "for one X" group) is now allowed. + Injector must emit exactly one CandidateOperation with matched_verb="one", + using the rate surface (not falling back to earlier "a" from "a lemonade stand"). + roundtrip_admissible must hold. This makes the rate no-injection bucket + actionable (downstream refusal for missing denom state, not injector ()). """ registry = load_ratified_registry() stmt = "Alexa has a lemonade stand where she sells lemonade for $2 for one cup." @@ -240,4 +261,14 @@ def test_for_one_cup_hard_confuser_emits_nothing_no_fallback_to_earlier_a(): assert m is not None assert m.category is ShapeCategory.RATE_WITH_CURRENCY emitted = inject_from_match(m, stmt, sealed=False) - assert emitted == () + assert len(emitted) == 1 + from generate.math_roundtrip import roundtrip_admissible + + cand = emitted[0] + assert isinstance(cand, CandidateOperation) + assert cand.op.kind == "apply_rate" + assert cand.matched_verb == "one" + assert cand.matched_actor_token == "Alexa" + assert roundtrip_admissible(cand) is True + # Explicitly no fallback to the distracting earlier "a" + assert "one" in stmt.lower() # the token came from the rate span