feat(derivation): Workstream A inc 3 — support 'one' connector in rate_with_currency injector (post docs ratification)
This commit is contained in:
parent
e9ece64ddd
commit
3fc81f66a4
7 changed files with 830 additions and 284 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue