feat(derivation): Workstream A inc 3 — support 'one' connector in rate_with_currency injector (post docs ratification)

This commit is contained in:
Shay 2026-06-17 09:43:31 -07:00
parent e9ece64ddd
commit 3fc81f66a4
7 changed files with 830 additions and 284 deletions

View file

@ -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 # Surface verbs that grammatically place the actor as the *gainer* of the
# operand quantity. Past tense and present tense both registered. # operand quantity. Past tense and present tense both registered.
ADD_VERBS: Final[frozenset[str]] = frozenset({ ADD_VERBS: Final[frozenset[str]] = frozenset(
# acquisition {
"buy", "buys", "bought", # acquisition
"get", "gets", "got", "buy",
"find", "finds", "found", "buys",
"receive", "receives", "received", "bought",
"earn", "earns", "earned", "get",
"add", "adds", "added", "gets",
"pick", "picks", "picked", # "picks up N" "got",
"collect", "collects", "collected", "find",
"gather", "gathers", "gathered", "finds",
"catch", "catches", "caught", "found",
"save", "saves", "saved", "receive",
# production (actor creates instances of the unit) "receives",
"bake", "bakes", "baked", "received",
"make", "makes", "made", "earn",
"cook", "cooks", "cooked", "earns",
"slice", "slices", "sliced", "earned",
"pack", "packs", "packed", "add",
"build", "builds", "built", "adds",
"grow", "grows", "grew", "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 # Surface verbs that grammatically place the actor as the *loser* of the
# operand quantity. # operand quantity.
SUBTRACT_VERBS: Final[frozenset[str]] = frozenset({ SUBTRACT_VERBS: Final[frozenset[str]] = frozenset(
"eat", "eats", "ate", {
"lose", "loses", "lost", "eat",
"sell", "sells", "sold", "eats",
"donate", "donates", "donated", "ate",
"use", "uses", "used", "lose",
"spend", "spends", "spent", "loses",
"drop", "drops", "dropped", "lost",
"remove", "removes", "removed", "sell",
"break", "breaks", "broke", "sells",
"destroy", "destroys", "destroyed", "sold",
"throw", "throws", "threw", # "throws out N" "donate",
"discard", "discards", "discarded", "donates",
"return", "returns", "returned", # ambiguous — see TRANSFER_VERBS "donated",
"consume", "consumes", "consumed", "use",
"give", "gives", "gave", # ambiguous — see TRANSFER_VERBS "uses",
"send", "sends", "sent", # ambiguous — see TRANSFER_VERBS "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 # Surface verbs that grammatically place the actor as the *sender* and a
# named target as the *receiver*. These verbs ALSO appear in SUBTRACT_VERBS # named target as the *receiver*. These verbs ALSO appear in SUBTRACT_VERBS
# because the same surface token can take a transfer reading (with target) # because the same surface token can take a transfer reading (with target)
# or a subtract reading (without target) — both candidates fire and the # or a subtract reading (without target) — both candidates fire and the
# decision rule picks based on whether a target slot was grounded. # decision rule picks based on whether a target slot was grounded.
TRANSFER_VERBS: Final[frozenset[str]] = frozenset({ TRANSFER_VERBS: Final[frozenset[str]] = frozenset(
"give", "gives", "gave", {
"send", "sends", "sent", "give",
"hand", "hands", "handed", "gives",
"pass", "passes", "passed", "gave",
"mail", "mails", "mailed", "send",
"deliver", "delivers", "delivered", "sends",
"return", "returns", "returned", "sent",
}) "hand",
"hands",
"handed",
"pass",
"passes",
"passed",
"mail",
"mails",
"mailed",
"deliver",
"delivers",
"delivered",
"return",
"returns",
"returned",
}
)
MULTIPLY_VERBS: Final[frozenset[str]] = frozenset({ MULTIPLY_VERBS: Final[frozenset[str]] = frozenset(
"double", "doubles", "doubled", {
"triple", "triples", "tripled", "double",
"quadruple", "quadruples", "quadrupled", "doubles",
"multiply", "multiplies", "multiplied", "doubled",
}) "triple",
"triples",
"tripled",
"quadruple",
"quadruples",
"quadrupled",
"multiply",
"multiplies",
"multiplied",
}
)
DIVIDE_VERBS: Final[frozenset[str]] = frozenset({ DIVIDE_VERBS: Final[frozenset[str]] = frozenset(
"halve", "halves", "halved", {
"split", "splits", "split", "halve",
"divide", "divides", "divided", "halves",
"share", "shares", "shared", "halved",
}) "split",
"splits",
"split",
"divide",
"divides",
"divided",
"share",
"shares",
"shared",
}
)
# Comparison "verbs" — the surface anchor for compare_additive / # Comparison "verbs" — the surface anchor for compare_additive /
# compare_multiplicative is usually 'has'/'have' + comparator phrase # compare_multiplicative is usually 'has'/'have' + comparator phrase
# ('N more than', 'twice as many as', etc.). The matched_verb slot for # ('N more than', 'twice as many as', etc.). The matched_verb slot for
# comparison candidates carries the comparator phrase head ('more', # comparison candidates carries the comparator phrase head ('more',
# 'fewer', 'twice', 'times', 'half'). # 'fewer', 'twice', 'times', 'half').
COMPARE_ADDITIVE_ANCHORS: Final[frozenset[str]] = frozenset({ COMPARE_ADDITIVE_ANCHORS: Final[frozenset[str]] = frozenset(
"more", "fewer", "less", "additional", "extra", {
}) "more",
COMPARE_MULTIPLICATIVE_ANCHORS: Final[frozenset[str]] = frozenset({ "fewer",
"twice", "thrice", "times", "half", "double", "triple", "less",
"quadruple", "third", "quarter", "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 # 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"). # 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 # so that roundtrip_admissible / CandidateOperation post-init grounding
# succeeds. "a"/"an" were documented in the comment but missing from the # succeeds. "a"/"an" were documented in the comment but missing from the
# set; added here (Inc 2) with corresponding injector tests. # set; added here (Inc 2) with corresponding injector tests.
RATE_ANCHORS: Final[frozenset[str]] = frozenset({ RATE_ANCHORS: Final[frozenset[str]] = frozenset(
"per", "each", "every", "a", "an", {
}) "per",
"each",
"every",
"a",
"an",
"one",
}
)
KIND_TO_VERBS: Final[Mapping[str, frozenset[str]]] = { 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]] = { WORD_NUMBERS: Final[Mapping[str, int]] = {
"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "zero": 0,
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "one": 1,
"ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "two": 2,
"fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17, "three": 3,
"eighteen": 18, "nineteen": 19, "twenty": 20, "thirty": 30, "four": 4,
"forty": 40, "fifty": 50, "sixty": 60, "seventy": 70, "five": 5,
"eighty": 80, "ninety": 90, "hundred": 100, "thousand": 1000, "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") # 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. # Public dataclass — what the candidate-graph parser will emit per match.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CandidateOperation: class CandidateOperation:
"""An Operation candidate plus the source-span provenance proving it. """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") raise ValueError("CandidateOperation.source_span must be non-empty")
if not isinstance(self.matched_verb, str) or not self.matched_verb: if not isinstance(self.matched_verb, str) or not self.matched_verb:
raise ValueError("CandidateOperation.matched_verb must be non-empty") raise ValueError("CandidateOperation.matched_verb must be non-empty")
if not isinstance(self.matched_actor_token, str) or not self.matched_actor_token: if (
raise ValueError( not isinstance(self.matched_actor_token, str)
"CandidateOperation.matched_actor_token must be non-empty" or not self.matched_actor_token
) ):
raise ValueError("CandidateOperation.matched_actor_token must be non-empty")
if self.op.kind == "transfer": if self.op.kind == "transfer":
if not self.matched_target_token: if not self.matched_target_token:
raise ValueError( raise ValueError(
"matched_target_token required when op.kind='transfer'" "matched_target_token required when op.kind='transfer'"
) )
elif self.matched_target_token is not None: elif self.matched_target_token is not None:
raise ValueError( raise ValueError("matched_target_token only valid when op.kind='transfer'")
"matched_target_token only valid when op.kind='transfer'"
)
if isinstance(self.op.operand, Comparison): if isinstance(self.op.operand, Comparison):
if not self.matched_reference_actor_token: if not self.matched_reference_actor_token:
raise ValueError( raise ValueError(
"matched_reference_actor_token required when operand is " "matched_reference_actor_token required when operand is Comparison"
"Comparison"
) )
elif self.matched_reference_actor_token is not None: elif self.matched_reference_actor_token is not None:
raise ValueError( raise ValueError(
"matched_reference_actor_token only valid when operand is " "matched_reference_actor_token only valid when operand is Comparison"
"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(): if "-" in value_token and not value_token[0].isdigit():
try: try:
from language_packs.numerics_loader import parse_compound_cardinal from language_packs.numerics_loader import parse_compound_cardinal
parsed = parse_compound_cardinal(value_token) parsed = parse_compound_cardinal(value_token)
if parsed is not None: if parsed is not None:
components = [c for c in value_token.lower().split("-") if c] 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. # through to the hard-coded table.
try: try:
from language_packs.loader import lookup_cardinal from language_packs.loader import lookup_cardinal
entry = lookup_cardinal(lowered) entry = lookup_cardinal(lowered)
if entry is not None: if entry is not None:
digit = str(entry.numeric_value) 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 # Pack-backed reverse lookup: digit -> cardinal surface in haystack
try: try:
from language_packs.loader import lookup_cardinal from language_packs.loader import lookup_cardinal
for tok in haystack_tokens: for tok in haystack_tokens:
entry = lookup_cardinal(tok) entry = lookup_cardinal(tok)
if entry is not None and entry.numeric_value == n: 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. # The load-bearing primitive.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def roundtrip_admissible(c: CandidateOperation) -> bool: def roundtrip_admissible(c: CandidateOperation) -> bool:
"""True iff every content slot in ``c`` grounds in ``c.source_span`` """True iff every content slot in ``c`` grounds in ``c.source_span``
AND the matched verb is registered for the operation kind. 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 # Skipped only for multiplicative comparison anchors that carry
# the factor implicitly ("twice", "half", "thrice") — those use # the factor implicitly ("twice", "half", "thrice") — those use
# the anchor itself as the value token and pass via step (2). # 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 pass # anchor already grounded by verb check
elif not _value_grounds(c.matched_value_token, haystack): elif not _value_grounds(c.matched_value_token, haystack):
return False return False

View file

@ -248,9 +248,7 @@ def inject_discrete_count_statement(
anchor, sentence anchor, sentence
) )
elif anchor_kind == "acquisition": elif anchor_kind == "acquisition":
cand = _build_operation_from_discrete_count_acquisition( cand = _build_operation_from_discrete_count_acquisition(anchor, sentence)
anchor, sentence
)
else: else:
# Unknown anchor_kind — under-admit. Future widenings (e.g. # Unknown anchor_kind — under-admit. Future widenings (e.g.
# "depletion" verbs as CandidateOperation(subtract)) extend # "depletion" verbs as CandidateOperation(subtract)) extend
@ -296,10 +294,13 @@ def _build_initial_from_discrete_count(
counted_noun = anchor.get("counted_noun") counted_noun = anchor.get("counted_noun")
if ( if (
not isinstance(subject_role, str) or not subject_role not isinstance(subject_role, str)
or not isinstance(count_token, str) or not count_token or not subject_role
or not isinstance(count_token, str)
or not count_token
or not isinstance(count_kind, str) 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 return None
@ -390,11 +391,15 @@ def _build_operation_from_discrete_count_acquisition(
verb_token = anchor.get("verb_token") verb_token = anchor.get("verb_token")
if ( if (
not isinstance(subject_role, str) or not subject_role not isinstance(subject_role, str)
or not isinstance(count_token, str) or not count_token or not subject_role
or not isinstance(count_token, str)
or not count_token
or not isinstance(count_kind, str) or not isinstance(count_kind, str)
or not isinstance(counted_noun, str) or not counted_noun or not isinstance(counted_noun, str)
or not isinstance(verb_token, str) or not verb_token or not counted_noun
or not isinstance(verb_token, str)
or not verb_token
): ):
return None return None
@ -461,10 +466,7 @@ def _count_token_followed_by_times(sentence: str, count_token: str) -> bool:
admitting path. admitting path.
""" """
target = count_token.lower() target = count_token.lower()
tokens = [ tokens = [raw.strip(".,;:!?\"'()[]{}").lower() for raw in sentence.split()]
raw.strip(".,;:!?\"'()[]{}").lower()
for raw in sentence.split()
]
for i, tok in enumerate(tokens[:-1]): for i, tok in enumerate(tokens[:-1]):
if tok == target and tokens[i + 1] == "times": if tok == target and tokens[i + 1] == "times":
return True return True
@ -520,9 +522,11 @@ def _locate_possession_verb(sentence: str) -> str | None:
# registers its injector. No global state, no side effects. # registers its injector. No global state, no side effects.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_WAVE_A_INJECTABLE_ANCHOR_KINDS: frozenset[str] = frozenset({ _WAVE_A_INJECTABLE_ANCHOR_KINDS: frozenset[str] = frozenset(
"multiplicative_aggregate_each_weighing", {
}) "multiplicative_aggregate_each_weighing",
}
)
def inject_multiplicative_aggregation( 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 apply_rate. The literal form is required so CandidateOperation
post-init + roundtrip_admissible grounding checks pass. 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(): for raw in sentence.split():
tok = raw.strip(".,;:!?\"'()[]{}").lower() tok = raw.strip(".,;:!?\"'()[]{}").lower()
if tok in rate_verbs: if tok in rate_verbs:
@ -670,11 +674,17 @@ def inject_rate_with_currency(
# No whole-sentence fallback is allowed, because _locate_rate_verb # No whole-sentence fallback is allowed, because _locate_rate_verb
# can still pick an unrelated earlier "a". # can still pick an unrelated earlier "a".
rate_anchor_token = anchor.get("rate_anchor_token") rate_anchor_token = anchor.get("rate_anchor_token")
if not rate_anchor_token or rate_anchor_token not in ("per", "each", "every", "a", "an"): if not rate_anchor_token or rate_anchor_token not in (
# Missing or invalid connector for this rate surface (e.g. "one" "per",
# from "for one cup", or absent token). Refuse — do not emit "each",
# a CandidateOperation with a verb that does not belong to the "every",
# matched rate expression. "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 () return ()
verb_token = rate_anchor_token verb_token = rate_anchor_token

View file

@ -39,18 +39,51 @@ from generate.recognizer_registry import RatifiedRecognizer
# multipliers ("dozen"). Mirrors the Phase A categorizer's # multipliers ("dozen"). Mirrors the Phase A categorizer's
# _NUMBER_WORDS so the matcher's "has any quantity marker" predicate # _NUMBER_WORDS so the matcher's "has any quantity marker" predicate
# is the same shape as Phase A's "has no quantity marker" predicate. # is the same shape as Phase A's "has no quantity marker" predicate.
_NUMBER_WORDS: Final[frozenset[str]] = frozenset({ _NUMBER_WORDS: Final[frozenset[str]] = frozenset(
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", {
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "one",
"seventeen", "eighteen", "nineteen", "twenty", "thirty", "forty", "fifty", "two",
"sixty", "seventy", "eighty", "ninety", "three",
"hundred", "thousand", "million", "billion", "four",
"dozen", "dozens", "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") _DIGIT_RE: Final[re.Pattern[str]] = re.compile(r"\d")
_INDEFINITE_TOKENS: Final[tuple[str, ...]] = ( _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 # Day-of-week enumeration: at least two distinct day names with at
# least one numeric count. Matches "20 ... Monday, 36 ... Tuesday". # least one numeric count. Matches "20 ... Monday, 36 ... Tuesday".
_DAY_NAMES: Final[tuple[str, ...]] = ( _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( _DAY_HIT_RE: Final[re.Pattern[str]] = re.compile(
r"""(?ix) r"""(?ix)
@ -228,12 +267,13 @@ def _match_temporal_aggregation(
return None return None
anchors: list[Mapping[str, Any]] = [] anchors: list[Mapping[str, Any]] = []
padded = " " + statement.lower() + " "
# Pass 1 — day-of-week enumeration. At least two distinct day # Pass 1 — day-of-week enumeration. At least two distinct day
# names + a count per day yields multi-anchor day-windowed # names + a count per day yields multi-anchor day-windowed
# aggregation. # 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]] = [] day_hits: list[tuple[str, str]] = []
for m in _DAY_HIT_RE.finditer(statement): for m in _DAY_HIT_RE.finditer(statement):
day_hits.append((m.group(1), m.group(2).lower())) day_hits.append((m.group(1), m.group(2).lower()))
@ -242,12 +282,14 @@ def _match_temporal_aggregation(
if len(distinct_days) >= 2: if len(distinct_days) >= 2:
quant = "each" if "each" in observed_quantifiers else "every" quant = "each" if "each" in observed_quantifiers else "every"
for count_token, _day in day_hits: for count_token, _day in day_hits:
anchors.append({ anchors.append(
"kind": "event_count_per_window", {
"count_token": count_token, "kind": "event_count_per_window",
"window_unit": "day", "count_token": count_token,
"window_quantifier": quant, "window_unit": "day",
}) "window_quantifier": quant,
}
)
if anchors: if anchors:
return (tuple(anchors), "aggregate") return (tuple(anchors), "aggregate")
@ -255,7 +297,11 @@ def _match_temporal_aggregation(
for pat, kind in _TEMPORAL_PATTERNS: for pat, kind in _TEMPORAL_PATTERNS:
for m in pat.finditer(statement): for m in pat.finditer(statement):
if kind == "explicit_quantifier": 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": elif kind == "in_window":
count_token, quantifier, unit = m.group(1), "per", m.group(2).lower() count_token, quantifier, unit = m.group(1), "per", m.group(2).lower()
else: # adverbial else: # adverbial
@ -263,8 +309,11 @@ def _match_temporal_aggregation(
adverb = m.group(2).lower() adverb = m.group(2).lower()
# Map adverb → unit. # Map adverb → unit.
unit_map = { unit_map = {
"daily": "day", "weekly": "week", "monthly": "month", "daily": "day",
"yearly": "year", "hourly": "hour", "weekly": "week",
"monthly": "month",
"yearly": "year",
"hourly": "hour",
} }
unit = unit_map[adverb] unit = unit_map[adverb]
quantifier = "per" quantifier = "per"
@ -272,12 +321,14 @@ def _match_temporal_aggregation(
continue continue
if quantifier not in observed_quantifiers: if quantifier not in observed_quantifiers:
continue continue
anchors.append({ anchors.append(
"kind": "event_count_per_window", {
"count_token": count_token, "kind": "event_count_per_window",
"window_unit": unit, "count_token": count_token,
"window_quantifier": quantifier, "window_unit": unit,
}) "window_quantifier": quantifier,
}
)
if not anchors: if not anchors:
return None return None
@ -340,11 +391,9 @@ def _match_rate_with_currency(
elif m.group(8): elif m.group(8):
q = m.group(8).lower() q = m.group(8).lower()
per_unit = m.group(9) per_unit = m.group(9)
if q in ("each", "every", "a"): if q in ("each", "every", "a", "one"):
connector = q connector = q
else: 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 connector = None
if not per_unit: if not per_unit:
@ -361,14 +410,16 @@ def _match_rate_with_currency(
else: else:
amount_kind = "integer" amount_kind = "integer"
anchors.append({ anchors.append(
"kind": "currency_per_unit_rate", {
"currency_symbol": symbol, "kind": "currency_per_unit_rate",
"amount": amount_token, "currency_symbol": symbol,
"amount_kind": amount_kind, "amount": amount_token,
"per_unit": per_unit_lc, "amount_kind": amount_kind,
"rate_anchor_token": connector.lower() if connector else None, "per_unit": per_unit_lc,
}) "rate_anchor_token": connector.lower() if connector else None,
}
)
if not anchors: if not anchors:
return None return None
@ -508,7 +559,11 @@ def _try_extract_currency_per_unit_composition_anchor(
if composed_value_f != composed_value_f: # NaN guard if composed_value_f != composed_value_f: # NaN guard
return None return None
composed_value: int | float 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) composed_value = int(composed_value_f)
else: else:
composed_value = composed_value_f composed_value = composed_value_f
@ -726,23 +781,44 @@ def try_extract_cross_sentence_composition_anchor(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_PER_UNIT_TOKENS: Final[tuple[str, ...]] = ( _PER_UNIT_TOKENS: Final[tuple[str, ...]] = (
" per ", "/", " an hour", " a hour", " a day", " a week", " a month", " per ",
" a year", " for one ", " for each ", " for every ", "/",
" 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 # RAT-1 — standalone per-item quantifiers. "$400 each" is per-unit
# framing semantically equivalent to "$400 per item". The detection- # framing semantically equivalent to "$400 per item". The detection-
# only currency_amount matcher must refuse this so the per-unit # only currency_amount matcher must refuse this so the per-unit
# composition path (ME-1 / ME-2 currency_per_unit_composition) gets # composition path (ME-1 / ME-2 currency_per_unit_composition) gets
# a turn at the same statement. # a turn at the same statement.
" each ", " each.", " apiece ", " apiece.", " each ",
" each.",
" apiece ",
" apiece.",
) )
_TEMPORAL_QUANTIFIER_TOKENS: Final[tuple[str, ...]] = ( _TEMPORAL_QUANTIFIER_TOKENS: Final[tuple[str, ...]] = (
" per ", " each ", " every ", " daily", " weekly", " monthly", " per ",
" yearly", " hourly", " each ",
" every ",
" daily",
" weekly",
" monthly",
" yearly",
" hourly",
) )
_MULTIPLICATIVE_CONNECTIVES: Final[tuple[str, ...]] = ( _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 # CandidateInitial post-init whitelist. Widening to owns/holds/contains
# requires a coordinated CandidateInitial change and lands in a follow-up # requires a coordinated CandidateInitial change and lands in a follow-up
# PR after the framework's empirical lift is operator-reviewed. # PR after the framework's empirical lift is operator-reviewed.
_POSSESSION_VERBS: Final[frozenset[str]] = frozenset({ _POSSESSION_VERBS: Final[frozenset[str]] = frozenset(
"has", "have", "had", {
}) "has",
"have",
"had",
}
)
# ADR-0170 W2 — acquisition verbs: surface verbs that grammatically place # ADR-0170 W2 — acquisition verbs: surface verbs that grammatically place
# the actor as the *gainer* of the operand quantity, NOT as having the # 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 # Widening this set is operator-reviewable per the wrong=0 hazard
# documented in feedback-wrong-zero-hazard-case-0050. # documented in feedback-wrong-zero-hazard-case-0050.
_ACQUISITION_VERBS: Final[frozenset[str]] = frozenset({ _ACQUISITION_VERBS: Final[frozenset[str]] = frozenset(
"collected", "collects", "collect", {
"received", "receives", "receive", "collected",
"bought", "buys", "buy", "collects",
"got", "gets", "get", "collect",
}) "received",
"receives",
"receive",
"bought",
"buys",
"buy",
"got",
"gets",
"get",
}
)
# Pronoun subjects refused at extraction (ambiguous referent). The # Pronoun subjects refused at extraction (ambiguous referent). The
# extractor requires a concrete proper-noun subject the source span can # extractor requires a concrete proper-noun subject the source span can
# ground. # ground.
_REFUSED_SUBJECT_TOKENS: Final[frozenset[str]] = frozenset({ _REFUSED_SUBJECT_TOKENS: Final[frozenset[str]] = frozenset(
"he", "she", "they", "it", "we", "you", "i", {
"him", "her", "them", "us", "he",
}) "she",
"they",
"it",
"we",
"you",
"i",
"him",
"her",
"them",
"us",
}
)
# Clause-splitting / enumeration markers. Their presence indicates a # Clause-splitting / enumeration markers. Their presence indicates a
# second clause that may carry operations or additional anchors, so # second clause that may carry operations or additional anchors, so
# v1 refuses extraction (skip-only fallback preserves wrong=0). # v1 refuses extraction (skip-only fallback preserves wrong=0).
_CLAUSE_SPLIT_TOKENS: Final[tuple[str, ...]] = ( _CLAUSE_SPLIT_TOKENS: Final[tuple[str, ...]] = (
" but ", " then ", " however ", " before ", " after ", " but ",
" and ", " or ", " while ", " until ", " unless ", " then ",
", and ", ", but ", ", or ", ", then ", " however ",
" before ",
" after ",
" and ",
" or ",
" while ",
" until ",
" unless ",
", and ",
", but ",
", or ",
", then ",
) )
# Hyphenated compound cardinal: 'twenty-five', 'ninety-nine'. These # 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) noun_alt = "|".join(re.escape(n) for n in options)
return re.compile( return re.compile(
r"^\s*" r"^\s*"
r"(?P<subject>(?-i:[A-Z][a-z]+))" # case-sensitive proper noun r"(?P<subject>(?-i:[A-Z][a-z]+))" # case-sensitive proper noun
r"\s+(?P<verb>[A-Za-z]+)" # any word; verified against whitelist r"\s+(?P<verb>[A-Za-z]+)" # any word; verified against whitelist
r"\s+(?P<count>\d+|[A-Za-z\-]+)" # integer or word/hyphenated cardinal r"\s+(?P<count>\d+|[A-Za-z\-]+)" # integer or word/hyphenated cardinal
r"\s+(?P<noun>" + noun_alt + r")" r"\s+(?P<noun>" + noun_alt + r")"
r"(?:\b.*)?$", # optional trailing content r"(?:\b.*)?$", # optional trailing content
flags=re.IGNORECASE, 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_tok = rf"(?-i:(?!(?:{_OPEN_NOUN_STOP})\b)[a-z]+)"
open_noun = rf"{open_tok}(?:\s+{open_tok}){{0,2}}" open_noun = rf"{open_tok}(?:\s+{open_tok}){{0,2}}"
noun_group = ( noun_group = (
rf"(?P<noun>{closed_alt}|{open_noun})" if closed_alt rf"(?P<noun>{closed_alt}|{open_noun})"
if closed_alt
else rf"(?P<noun>{open_noun})" else rf"(?P<noun>{open_noun})"
) )
return re.compile( return re.compile(
@ -961,8 +1074,7 @@ def _extract_discrete_count_re_open(counted_nouns: list[str]) -> re.Pattern[str]
r"(?P<subject>(?-i:[A-Z][a-z]+))" r"(?P<subject>(?-i:[A-Z][a-z]+))"
r"\s+(?P<verb>[A-Za-z]+)" r"\s+(?P<verb>[A-Za-z]+)"
r"\s+(?P<count>\d+|[A-Za-z\-]+)" r"\s+(?P<count>\d+|[A-Za-z\-]+)"
r"\s+" + noun_group + r"\s+" + noun_group + r"(?:\b.*)?$",
r"(?:\b.*)?$",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
@ -1112,12 +1224,25 @@ def _try_extract_discrete_count_anchor(
# appears in the sentence we refuse the compound extraction; the case # appears in the sentence we refuse the compound extraction; the case
# routes to a future phase that handles those shapes. # routes to a future phase that handles those shapes.
_COMPOUND_REFUSE_SUBSTRINGS: Final[tuple[str, ...]] = ( _COMPOUND_REFUSE_SUBSTRINGS: Final[tuple[str, ...]] = (
" times ", " times.", " times,", " times ",
" as long", " as many", " as much", " as old", " times.",
" greater than", " less than", " more than", " fewer than", " times,",
" half as ", " twice as ", " thrice ", " as long",
"%", " percent", " as many",
" half of ", " quarter of ", " third of ", " 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). # Fraction literal pattern (matched against raw statement, not padded).
@ -1167,10 +1292,7 @@ def _try_extract_compound_discrete_count_anchors(
return None return None
# Must have a conjunctive separator — otherwise this isn't compound # Must have a conjunctive separator — otherwise this isn't compound
has_conjunctive = any( has_conjunctive = any(tok in padded_lower for tok in (", and ", " and ", ", "))
tok in padded_lower
for tok in (", and ", " and ", ", ")
)
if not has_conjunctive: if not has_conjunctive:
return None return None
@ -1282,6 +1404,7 @@ def _try_extract_compound_discrete_count_anchors(
# HYPOTHESIS_CAP enforcement — refusal-preferring rather than truncate # HYPOTHESIS_CAP enforcement — refusal-preferring rather than truncate
from generate.comprehension.state import HYPOTHESIS_CAP from generate.comprehension.state import HYPOTHESIS_CAP
if len(anchors) > HYPOTHESIS_CAP: if len(anchors) > HYPOTHESIS_CAP:
return None return None
@ -1332,7 +1455,8 @@ def _match_multiplicative_aggregation(
# two needed to admit a multiplicative shape. # two needed to admit a multiplicative shape.
digit_hits = len(_DIGIT_RE.findall(statement)) digit_hits = len(_DIGIT_RE.findall(statement))
word_hits = sum( word_hits = sum(
1 for token in padded.split() 1
for token in padded.split()
if token.strip(".,;:!?\"'()[]{}").lower() in _NUMBER_WORDS if token.strip(".,;:!?\"'()[]{}").lower() in _NUMBER_WORDS
) )
if (digit_hits + word_hits) < 2: 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. # matched_anchor must be in CandidateInitial post-init whitelist.
outer_verb = m.group("outer_verb").lower() outer_verb = m.group("outer_verb").lower()
matched_anchor = outer_verb if outer_verb in { matched_anchor = (
"has", "had", "made", "makes", "buys", "bought", "paid", "earned", "saved", "got", "received" outer_verb
} else "had" if outer_verb
in {
"has",
"had",
"made",
"makes",
"buys",
"bought",
"paid",
"earned",
"saved",
"got",
"received",
}
else "had"
)
composed_initial = CandidateInitial( composed_initial = CandidateInitial(
initial=InitialPossession( initial=InitialPossession(
@ -1577,7 +1716,10 @@ def _try_extract_additive_composition_anchor(
if unit_a.rstrip("s") != unit_b.rstrip("s"): if unit_a.rstrip("s") != unit_b.rstrip("s"):
return None return None
canonical_unit = unit_a 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 return None
count_a_token = m.group("count_a") 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 # Verb whitelist maps to a CandidateInitial.matched_anchor value
# the post-init guard accepts (existing whitelist includes # the post-init guard accepts (existing whitelist includes
# has/have/had/saved/earned/got/received/bought/made/paid). # has/have/had/saved/earned/got/received/bought/made/paid).
matched_anchor = verb if verb in { matched_anchor = (
"saved", "earned", "got", "received", "bought", "made", "paid" verb
} else "had" if verb in {"saved", "earned", "got", "received", "bought", "made", "paid"}
else "had"
)
composed_initial = CandidateInitial( composed_initial = CandidateInitial(
initial=InitialPossession( initial=InitialPossession(
@ -1643,10 +1787,22 @@ def _try_extract_additive_composition_anchor(
return ((anchor,), "aggregate") return ((anchor,), "aggregate")
_ADDITIVE_COMPOSITION_VERBS: Final[frozenset[str]] = frozenset({ _ADDITIVE_COMPOSITION_VERBS: Final[frozenset[str]] = frozenset(
"lost", "gained", "earned", "saved", "made", "paid", "spent", {
"bought", "sold", "added", "removed", "received", "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_COMPOSITION_SHAPE: Final[str] = "bound(initial) bound(removed)"
_SUBTRACTIVE_INITIAL_VERBS: Final[frozenset[str]] = frozenset({ _SUBTRACTIVE_INITIAL_VERBS: Final[frozenset[str]] = frozenset(
"had", "has", "got", "owns", "owned", "earned", "saved", {
"made", "received", "bought", "had",
}) "has",
"got",
"owns",
"owned",
"earned",
"saved",
"made",
"received",
"bought",
}
)
_SUBTRACTIVE_REMOVAL_VERBS: Final[frozenset[str]] = frozenset({ _SUBTRACTIVE_REMOVAL_VERBS: Final[frozenset[str]] = frozenset(
"lost", "spent", "gave", "donated", "paid", "removed", {
"sold", "used", "consumed", "lost",
}) "spent",
"gave",
"donated",
"paid",
"removed",
"sold",
"used",
"consumed",
}
)
def _try_extract_subtractive_composition_anchor( 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_candidate_parser import CandidateInitial
from generate.math_problem_graph import InitialPossession, Quantity from generate.math_problem_graph import InitialPossession, Quantity
matched_anchor = verb_a if verb_a in { matched_anchor = (
"has", "had", "saved", "earned", "got", "received", "bought", "made", "paid", verb_a
} else "had" if verb_a
in {
"has",
"had",
"saved",
"earned",
"got",
"received",
"bought",
"made",
"paid",
}
else "had"
)
composed_initial = CandidateInitial( composed_initial = CandidateInitial(
initial=InitialPossession( initial=InitialPossession(
@ -1923,25 +2111,76 @@ def match(
# Cross-sentence subject resolution helper (ME-2). # Cross-sentence subject resolution helper (ME-2).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_PROPER_NOUN_SUBJECT_RE: Final[re.Pattern[str]] = re.compile( _PROPER_NOUN_SUBJECT_RE: Final[re.Pattern[str]] = re.compile(r"^\s*([A-Z][a-zA-Z]+)\b")
r"^\s*([A-Z][a-zA-Z]+)\b"
)
_COMMON_DETERMINERS_AT_HEAD: Final[frozenset[str]] = frozenset( _COMMON_DETERMINERS_AT_HEAD: Final[frozenset[str]] = frozenset(
{ {
# Articles + demonstratives # Articles + demonstratives
"the", "a", "an", "this", "that", "these", "those", "the",
"a",
"an",
"this",
"that",
"these",
"those",
# Possessives # Possessives
"his", "her", "their", "its", "my", "your", "our", "his",
"her",
"their",
"its",
"my",
"your",
"our",
# Sentence-initial connectors / prepositions that get capitalized # Sentence-initial connectors / prepositions that get capitalized
"after", "before", "when", "while", "if", "then", "so", "but", "after",
"and", "or", "during", "since", "until", "though", "although", "before",
"however", "moreover", "additionally", "first", "next", "later", "when",
"finally", "now", "soon", "today", "tomorrow", "yesterday", "while",
"every", "all", "some", "many", "each", "another", "other", "if",
"in", "on", "at", "by", "for", "from", "with", "without", "then",
"how", "why", "what", "where", "who", "when", "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",
} }
) )

View file

@ -29,8 +29,12 @@ from tests._phase_d_fixture import build_synthetic_registry
_REPO_ROOT = Path(__file__).resolve().parent.parent _REPO_ROOT = Path(__file__).resolve().parent.parent
_GSM8K_CASES = _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl" _GSM8K_CASES = (
_GSM8K_REPORT = _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json" _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") @pytest.fixture(scope="module")
@ -46,7 +50,9 @@ def with_synthetic_registry(
"""Patch ``math_candidate_graph._load_ratified_registry_or_empty`` to """Patch ``math_candidate_graph._load_ratified_registry_or_empty`` to
return the synthetic registry for the duration of the test.""" return the synthetic registry for the duration of the test."""
monkeypatch.setattr( monkeypatch.setattr(
cg, "_load_ratified_registry_or_empty", lambda: synthetic_registry, cg,
"_load_ratified_registry_or_empty",
lambda: synthetic_registry,
) )
return 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( def test_recognized_rate_statement_refuses_explicitly_post_wrong_zero_fix(
with_synthetic_registry: tuple[RatifiedRecognizer, ...], with_synthetic_registry: tuple[RatifiedRecognizer, ...],
) -> None: ) -> None:
"""With the rate_with_currency recognizer loaded, "Tina makes $18.00 """With the rate_with_currency recognizer loaded (synthetic), rate surfaces
an hour" is recognized but the v1 injector returns () (the that now have v1 injector support ("an" from Inc2, "one" from Inc3) are
SentenceChoice union does not yet model rates see ADR follow-up). 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 The full sentence provides no denom-unit Initial for the actor, so the
statement and admit a partial graph from the rest a wrong>0 candidate graph produces no admissible branch. Refusal is at question or
hazard analogous to case 0050. "no admissible candidate" level (downstream of injection).
Post-#359 (this test's contract): refuse explicitly with reason Pre-#359: silent drop (wrong>0 hazard).
"recognizer matched but produced no injection" naming the Post-#359 + Inc2/Inc3: explicit diagnostic for unsupported; supported
statement and category. This pinned behavior is the wrong=0 rates proceed to state/admissibility checks (wrong=0 preserved).
safety net for the recognizer path. 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( result = cg.parse_and_solve(
"Tina makes $18.00 an hour. How much does Tina earn after 8 hours?" "Tina makes $18.00 an hour. How much does Tina earn after 8 hours?"
) )
assert result.refusal_reason is not None 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 ( assert (
"recognizer matched but produced no injection" in result.refusal_reason "no admissible candidate" in result.refusal_reason
), f"expected explicit recognizer-refusal, got: {result.refusal_reason!r}" or "recognizer matched but produced no injection" in result.refusal_reason
# The statement IS named in the reason — that's the diagnostic shape ), f"expected downstream or explicit refusal, got: {result.refusal_reason!r}"
# the post-#359 refusal carries. Update the prior assertion which # Keep diagnostic: the problematic rate statement context is involved.
# forbade naming, since that assertion encoded the silent-drop assert (
# premise that #359 retired. "Tina makes $18.00 an hour" in result.refusal_reason
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( 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 """Re-run the gsm8k train_sample under the patched registry and
return the {correct, wrong, refused} counts.""" return the {correct, wrong, refused} counts."""
monkeypatch.setattr( monkeypatch.setattr(
cg, "_load_ratified_registry_or_empty", lambda: registry, cg,
"_load_ratified_registry_or_empty",
lambda: registry,
) )
import importlib 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) cases = runner_mod._load_cases(runner_mod._CASES_PATH)
report = runner_mod.build_report(cases) report = runner_mod.build_report(cases)
return { 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_report = json.loads(_GSM8K_REPORT.read_text(encoding="utf-8"))
baseline_counts = baseline_report["counts"] baseline_counts = baseline_report["counts"]
candidate_counts = _run_gsm8k_train_sample_with_patch( candidate_counts = _run_gsm8k_train_sample_with_patch(
monkeypatch, synthetic_registry, monkeypatch,
synthetic_registry,
) )
assert candidate_counts["wrong"] == 0, ( assert candidate_counts["wrong"] == 0, (
f"Phase D wiring regressed wrong=0: {candidate_counts}" 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 guarded by a narrow recognizer; it cannot mis-admit a
well-parsed capability-axis statement.""" well-parsed capability-axis statement."""
monkeypatch.setattr( monkeypatch.setattr(
cg, "_load_ratified_registry_or_empty", lambda: synthetic_registry, cg,
"_load_ratified_registry_or_empty",
lambda: synthetic_registry,
) )
import importlib import importlib
lanes = [ lanes = [
("G1_verb_classes", "evals.math_capability_axes.G1_verb_classes.v1.runner"), ("G1_verb_classes", "evals.math_capability_axes.G1_verb_classes.v1.runner"),
("G2_comparatives", "evals.math_capability_axes.G2_comparatives.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 pin them to specific numbers, so the test stays robust to
Phase B corpus updates that narrow or widen specific axes. 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")) 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] = { counts: dict[str, int] = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY.value: 0, 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.RATE_WITH_CURRENCY.value] >= 1
assert counts[ShapeCategory.TEMPORAL_AGGREGATION.value] >= 1 assert counts[ShapeCategory.TEMPORAL_AGGREGATION.value] >= 1
# Surface the counts to stdout for the PR body. # 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(): for k, v in counts.items():
print(f" {k}: {v}") print(f" {k}: {v}")

View file

@ -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). - 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). - Fully deterministic output (sorted keys, no timestamps, repeatable across runs).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import pytest
from scripts.gsm8k_frontier_report import ( from scripts.gsm8k_frontier_report import (
analyze_report, 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 # We exercise via the public analyze path with a tiny synthetic report
fake = { fake = {
"per_case": [ "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": "c1",
{"case_id": "c3", "verdict": "refused", "reason": "candidate_graph: no admissible candidate for question: 'bar?'"}, "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": "c4", "verdict": "correct", "reason": "fast-path"},
{"case_id": "c5", "verdict": "refused", "reason": "some other refusal"}, {"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) # Write temp and analyze (or monkey the path; for simplicity use temp file)
import tempfile import tempfile
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
rp = Path(td) / "fake_report.json" rp = Path(td) / "fake_report.json"
rp.write_text(json.dumps(fake), encoding="utf-8") 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.""" """Markdown output is deterministic and surfaces the rate frontier for humans."""
fake = { fake = {
"per_case": [ "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": ""}, {"case_id": "c1", "verdict": "correct", "reason": ""},
], ],
"sample_count": 2, "sample_count": 2,
"exit_criterion": {"correct_min": 10, "passed": False, "wrong_max": 0}, "exit_criterion": {"correct_min": 10, "passed": False, "wrong_max": 0},
} }
import tempfile import tempfile
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
rp = Path(td) / "r.json" rp = Path(td) / "r.json"
rp.write_text(json.dumps(fake), encoding="utf-8") 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 # No timestamps or nondet text
assert "202" not in md and "T" not in md.split("\n", 5)[-1] # rough assert "202" not in md and "T" not in md.split("\n", 5)[-1] # rough
# Re-render identical # Re-render identical
assert render_markdown(summary) == md 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

View file

@ -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, 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. plus that the solver-level refusal for missing denom still works.
""" """
from __future__ import annotations from __future__ import annotations
import pytest
from generate.math_candidate_graph import parse_and_solve from generate.math_candidate_graph import parse_and_solve
from generate.recognizer_registry import load_ratified_registry 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(): 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.""" """Sam has the hours; Tina states the rate. Must not apply Sam's rate to Tina or vice-versa."""
text = ( text = (
"Sam works 3 hours. " "Sam works 3 hours. Tina makes $18.00 an hour. How many dollars does Tina make?"
"Tina makes $18.00 an hour. "
"How many dollars does Tina make?"
) )
res = _run(text) res = _run(text)
assert res.answer is None assert res.answer is None
@ -86,9 +84,7 @@ def test_confuser_multiple_rates_refuses():
def test_confuser_time_unit_without_conversion_refuses(): def test_confuser_time_unit_without_conversion_refuses():
"""3 days + per-hour rate has no conversion path in scope. Must refuse.""" """3 days + per-hour rate has no conversion path in scope. Must refuse."""
text = ( text = (
"Tina works 3 days. " "Tina works 3 days. Tina makes $18.00 an hour. How many dollars does Tina make?"
"Tina makes $18.00 an hour. "
"How many dollars does Tina make?"
) )
res = _run(text) res = _run(text)
assert res.answer is None 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) res = parse_and_solve(stmt, sealed=False)
assert res.answer is None assert res.answer is None
assert res.refusal_reason is not 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"

View file

@ -10,6 +10,7 @@ Covers the exact acceptance cases from the Workstream A Inc 2 brief:
- zero amount refuses - zero amount refuses
- matched_*_token values are literal substrings from the source sentence - matched_*_token values are literal substrings from the source sentence
""" """
from __future__ import annotations from __future__ import annotations
import types import types
@ -32,7 +33,9 @@ def _stub_recognizer(category: ShapeCategory) -> types.SimpleNamespace:
return types.SimpleNamespace(shape_category=category, canonical_pattern={}) 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.""" """Minimal RecognizerMatch for direct injector testing of the rate path."""
return RecognizerMatch( return RecognizerMatch(
recognizer=_stub_recognizer(category), 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 { return {
"kind": "currency_per_unit_rate", "kind": "currency_per_unit_rate",
"currency_symbol": symbol, "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_actor_token == "Tina"
assert cand.matched_value_token == "2" assert cand.matched_value_token == "2"
assert cand.matched_unit_token == "dollars" 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 assert roundtrip_admissible(cand) is True
def test_rate_an_hour_emits_when_an_in_rate_anchors(): 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 """$18.00 an hour is a major proxy case. With 'an' in RATE_ANCHORS the
literal verb token must ground.""" 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.") emitted = inject_rate_with_currency(m, "Tina makes $18.00 an hour.")
assert len(emitted) == 1 assert len(emitted) == 1
cand = emitted[0] cand = emitted[0]
@ -91,12 +108,13 @@ def test_unknown_actor_refuses_narrow_binding():
m = _make_match(_rate_anchor("$", "20", "kg")) m = _make_match(_rate_anchor("$", "20", "kg"))
# No clear ProperName subject (use lowercase common noun at head so the # 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). # 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 == () assert emitted == ()
def test_multiple_rates_in_one_sentence_refuses(): 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) # Force two by calling the multi logic path (injector sees >1 after loop)
# Simpler: construct a match with two anchors # Simpler: construct a match with two anchors
a1 = _rate_anchor("$", "18", "hour") 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) emitted = inject_from_match(m, stmt, sealed=False)
assert len(emitted) == 1 assert len(emitted) == 1
from generate.math_roundtrip import roundtrip_admissible from generate.math_roundtrip import roundtrip_admissible
assert roundtrip_admissible(emitted[0]) is True 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) emitted = inject_from_match(m, stmt, sealed=False)
assert len(emitted) == 1 assert len(emitted) == 1
from generate.math_roundtrip import roundtrip_admissible from generate.math_roundtrip import roundtrip_admissible
cand = emitted[0] cand = emitted[0]
assert isinstance(cand, CandidateOperation) assert isinstance(cand, CandidateOperation)
assert cand.op.kind == "apply_rate" 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 assert roundtrip_admissible(cand) is True
def test_for_one_cup_hard_confuser_emits_nothing_no_fallback_to_earlier_a(): def test_rate_for_one_cup_emits_apply_rate_with_matched_verb_one():
"""Hard confuser for whole-sentence fallback removal. """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." "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). The live registry matches as RATE_WITH_CURRENCY.
But rate_anchor_token will be None (from "one" in "for one"), which is rate_anchor_token == "one" (from the "for one X" group) is now allowed.
not in the allowed set. With no fallback to _locate_rate_verb, the Injector must emit exactly one CandidateOperation with matched_verb="one",
injector MUST return (). using the rate surface (not falling back to earlier "a" from "a lemonade stand").
This proves we do not bind the unrelated "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() registry = load_ratified_registry()
stmt = "Alexa has a lemonade stand where she sells lemonade for $2 for one cup." 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 is not None
assert m.category is ShapeCategory.RATE_WITH_CURRENCY assert m.category is ShapeCategory.RATE_WITH_CURRENCY
emitted = inject_from_match(m, stmt, sealed=False) 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