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
# 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

View file

@ -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

View file

@ -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<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<count>\d+|[A-Za-z\-]+)" # integer or word/hyphenated cardinal
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<count>\d+|[A-Za-z\-]+)" # integer or word/hyphenated cardinal
r"\s+(?P<noun>" + 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<noun>{closed_alt}|{open_noun})" if closed_alt
rf"(?P<noun>{closed_alt}|{open_noun})"
if closed_alt
else rf"(?P<noun>{open_noun})"
)
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"\s+(?P<verb>[A-Za-z]+)"
r"\s+(?P<count>\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",
}
)

View file

@ -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}")

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).
- 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
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,
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"

View file

@ -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