feat(derivation): Gate A2b case 0002 fractional rest composition

Add fraction_portion operation for "gives N/M of that" and "half of the
rest" subtract semantics chained after unit_partition, plus keep-on-hand
question binding with partition-noun unit inference. Live train_sample
moves 6/44/0 to 7/43/0 with wrong=0 preserved; confuser-v1-0007 still
refuses without "of that".
This commit is contained in:
Shay 2026-06-17 18:45:36 -07:00
parent 65405f1128
commit d18c0b6be3
9 changed files with 623 additions and 21 deletions

View file

@ -58,6 +58,7 @@ from generate.math_candidate_parser import (
)
from generate.math_problem_graph import (
MathGraphError,
Operation,
MathProblemGraph,
)
from generate.math_completeness import uncovered_quantities
@ -191,6 +192,48 @@ def _split_sentences(text: str) -> list[str]:
# choice space: a list of CandidateUnknown.
SentenceChoice = Union[CandidateInitial, CandidateOperation]
_PARSER_PRONOUN_ACTORS: Final[frozenset[str]] = frozenset({
"she", "he", "they", "it", "her", "him",
})
def _bind_parser_pronoun_actor(
choice: SentenceChoice,
antecedent: str | None,
multi_actor_ambiguous: bool,
) -> SentenceChoice | None:
"""Bind parser-emitted pronoun actors to a discourse antecedent."""
if not isinstance(choice, CandidateOperation):
return choice
if choice.matched_actor_token.lower() not in _PARSER_PRONOUN_ACTORS:
return choice
if multi_actor_ambiguous or not antecedent:
return None
from generate.math_candidate_parser import _normalize_entity
bound_actor = _normalize_entity(antecedent)
if bound_actor == choice.op.actor:
return choice
try:
rebound = Operation(
actor=bound_actor,
kind=choice.op.kind,
operand=choice.op.operand,
target=choice.op.target,
)
except MathGraphError:
return None
return CandidateOperation(
op=rebound,
source_span=choice.source_span,
matched_verb=choice.matched_verb,
matched_value_token=choice.matched_value_token,
matched_unit_token=choice.matched_unit_token,
matched_actor_token=choice.matched_actor_token,
matched_target_token=choice.matched_target_token,
matched_reference_actor_token=choice.matched_reference_actor_token,
)
def _filtered_statement_choices(sentence: str) -> list[SentenceChoice]:
"""Return all admissible (initial | operation) candidates for a
@ -233,13 +276,13 @@ def _filtered_question_choices(
"""
out: list[CandidateUnknown] = []
for qc in extract_question_candidates(sentence, problem_text):
if _question_admissible(qc):
if _question_admissible(qc, problem_text):
out.append(qc)
if not out:
stripped = _strip_conditional_prefix(sentence)
if stripped is not None and stripped != sentence:
for qc in extract_question_candidates(stripped, problem_text):
if _question_admissible(qc):
if _question_admissible(qc, problem_text):
out.append(qc)
return out[:MAX_CANDIDATES_PER_SENTENCE]
@ -347,12 +390,21 @@ def _composed_initial_admissible(ic: CandidateInitial) -> bool:
return True
def _question_admissible(qc: CandidateUnknown) -> bool:
def _question_admissible(
qc: CandidateUnknown,
problem_text: str | None = None,
) -> bool:
"""Light structural ground-check for question candidates."""
from generate.math_roundtrip import _tokens, _token_in, _unit_grounds
haystack = _tokens(qc.source_span)
if not _unit_grounds(qc.matched_unit_token, qc.source_span, haystack):
return False
if problem_text is None:
return False
problem_haystack = _tokens(problem_text)
if not _unit_grounds(
qc.matched_unit_token, problem_text, problem_haystack
):
return False
if qc.matched_entity_token is not None:
for tok in qc.matched_entity_token.split():
if not _token_in(tok, haystack):
@ -698,6 +750,21 @@ def parse_and_solve(text: str, *, sealed: bool = False) -> CandidateGraphResult:
# the in-loop running subject when discourse map has no entry.
_effective_prior = _discourse_prior_subjects.get(s, _prior_subject) or _prior_subject
choices = _filtered_statement_choices(s)
_distinct_priors_for_bind = {
v for v in _discourse_prior_subjects.values() if v is not None
}
if _prior_subject is not None:
_distinct_priors_for_bind.add(_prior_subject)
_multi_actor_for_bind = len(_distinct_priors_for_bind) > 1
if choices:
_bound_choices: list[SentenceChoice] = []
for _c in choices:
_bc = _bind_parser_pronoun_actor(
_c, _effective_prior, _multi_actor_for_bind
)
if _bc is not None:
_bound_choices.append(_bc)
choices = _bound_choices
if not choices:
if _ratified_registry:
from generate.recognizer_match import (

View file

@ -40,6 +40,7 @@ from typing import Final, Literal, Mapping, cast
from generate.math_problem_graph import (
Comparison,
FractionPortion,
InitialPossession,
Operation,
PartitionChunk,
@ -143,6 +144,11 @@ class CandidateInitial:
# math_parser._INITIAL_HAS_RE's ADR-0123a entity slot.
_ENTITY: Final[str] = r"(?:[A-Z]\w+|[Tt]he\s+\w+)"
# Gate A2b — actor slot admitting subject pronouns for fraction surfaces.
_ACTOR_OR_PRONOUN: Final[str] = (
r"(?:[A-Z]\w+|[Tt]he\s+\w+|She|He|They|It)"
)
# Numeric value alternation. Listed longest-form-first so the regex
# engine doesn't truncate on a shorter prefix:
# - Money symbol literal: ``$N`` / ``$N.NN`` (1-2 decimal places) plus
@ -936,6 +942,12 @@ def extract_question_candidates(
if out:
return out
# Gate A2b — "How much does <entity> keep on hand?" with partition noun
# unit inferred from the enclosing problem text.
out.extend(_pattern_d_keep_on_hand_candidates(sentence, problem_text))
if out:
return out
# ADR-0163.D.4 — Pattern B: comparative quantifier ("how many more")
out.extend(_pattern_b_comparative_candidates(sentence, problem_text))
if out:
@ -947,6 +959,111 @@ def extract_question_candidates(
return out
_FRACTION_GIVE_RE: Final[re.Pattern[str]] = re.compile(
rf"^(?P<actor>{_ACTOR_OR_PRONOUN})\s+"
r"(?P<verb>give|gives|gave)\s+"
rf"(?P<value>{_SLASH_FRACTION})\s+"
r"of\s+(?P<referent>that|it|them)\s+"
r"(?:to\s+(?:\w+\s+)*\w+)?"
r"\s*\.?$",
flags=re.IGNORECASE,
)
_HALF_REST_RE: Final[re.Pattern[str]] = re.compile(
rf"^(?P<actor>{_ACTOR_OR_PRONOUN})\s+"
r"(?:then\s+)?"
r"(?P<verb>put|puts|place|places)\s+"
r"half\s+of\s+the\s+rest\s+"
r"(?:in\s+(?:\w+\s+)*\w+)?"
r"\s*\.?$",
flags=re.IGNORECASE,
)
def _build_fraction_portion_candidate(
*,
actor_raw: str,
numerator: int,
denominator: int,
referent: Literal["that", "it", "them", "rest", "the_rest"],
matched_verb: str,
matched_value_token: str,
matched_unit_token: str,
source: str,
) -> CandidateOperation | None:
actor = _normalize_entity(actor_raw)
try:
op = Operation(
actor=actor,
kind="fraction_portion",
operand=FractionPortion(
numerator=numerator,
denominator=denominator,
referent=referent,
),
)
except Exception:
return None
return CandidateOperation(
op=op,
source_span=source,
matched_verb=matched_verb,
matched_value_token=matched_value_token,
matched_unit_token=matched_unit_token,
matched_actor_token=actor_raw,
)
def _fraction_give_candidates(sentence: str) -> list[CandidateOperation]:
"""Gate A2b — subtract N/M of a prior partition count (not transfer)."""
s = sentence.strip()
m = _FRACTION_GIVE_RE.match(s)
if m is None:
return []
value_raw = m.group("value")
frac = re.fullmatch(r"(\d+)/(\d+)", value_raw)
if frac is None:
return []
numerator = int(frac.group(1))
denominator = int(frac.group(2))
if denominator == 0:
return []
referent = m.group("referent").lower()
cand = _build_fraction_portion_candidate(
actor_raw=m.group("actor"),
numerator=numerator,
denominator=denominator,
referent=cast(
Literal["that", "it", "them", "rest", "the_rest"],
referent,
),
matched_verb=m.group("verb").lower(),
matched_value_token=value_raw,
matched_unit_token=referent,
source=sentence,
)
return [cand] if cand is not None else []
def _half_rest_candidates(sentence: str) -> list[CandidateOperation]:
"""Gate A2b — subtract half of the actor's remaining partition count."""
s = sentence.strip()
m = _HALF_REST_RE.match(s)
if m is None:
return []
cand = _build_fraction_portion_candidate(
actor_raw=m.group("actor"),
numerator=1,
denominator=2,
referent="rest",
matched_verb=m.group("verb").lower(),
matched_value_token="half",
matched_unit_token="rest",
source=sentence,
)
return [cand] if cand is not None else []
def extract_operation_candidates(sentence: str) -> list[CandidateOperation]:
"""Return all operation candidates for ``sentence``.
@ -983,6 +1100,8 @@ def extract_operation_candidates(sentence: str) -> list[CandidateOperation]:
# consumes a *trailing* "than N times <REF>" tail so it cannot be confused
# with the bare additive pattern. See ADR-0131.G.2 for precedence
# rationale.
out.extend(_fraction_give_candidates(sentence))
out.extend(_half_rest_candidates(sentence))
out.extend(_compare_nested_candidates(sentence))
out.extend(_compare_multiplicative_candidates(sentence))
out.extend(_compare_additive_candidates(sentence))
@ -2619,6 +2738,29 @@ _PATTERN_C_VERBS: Final[str] = (
_Q_OF_NP_TAIL: Final[str] = r"(?:\s+of\s+\w+(?:\s+\w+)?)?"
_Q_KEEP_ON_HAND_RE: Final[re.Pattern[str]] = re.compile(
r"^How\s+much\s+does\s+"
rf"(?P<entity>(?:{_ENTITY}|she|he|they|it))\s+"
r"keep\s+on\s+hand\??\s*$",
flags=re.IGNORECASE,
)
_INFER_PARTITION_COUNT_UNIT_RE: Final[re.Pattern[str]] = re.compile(
r"into\s+\d+\s*-\s*\w+\s+(sections?|pieces?|parts?)",
flags=re.IGNORECASE,
)
def _infer_partition_count_unit(problem_text: str | None) -> str | None:
"""Infer partition result_unit from a prior chunking stmt in the problem."""
if problem_text is None:
return None
m = _INFER_PARTITION_COUNT_UNIT_RE.search(problem_text)
if m is None:
return None
return _canonicalize_unit(m.group(1))
_Q_MASS_NOUN_RE: Final[re.Pattern[str]] = re.compile(
r"^How\s+much\s+"
rf"(?P<unit>{_MASS_NOUN_PATTERN})"
@ -2666,7 +2808,7 @@ _FEMALE_NAMES: Final[frozenset[str]] = frozenset({
"alexa", "alice", "amy", "ann", "anna", "barbara", "betty",
"carol", "carolyn", "christine", "cindy", "claire", "cynthia",
"deborah", "diana", "donna", "dorothy", "elizabeth", "ella",
"emily", "emma", "erica", "francine", "helen", "jane", "janet",
"emily", "emma", "erica", "francine", "helen", "jan", "jane", "janet",
"jen", "jennifer", "jessica", "joyce", "judith", "julie", "karen",
"kate", "kathleen", "kelly", "laura", "linda", "lisa", "lilibeth",
"lori", "mandy", "marie", "martha", "marnie", "mary", "melissa",
@ -2766,6 +2908,32 @@ def _resolve_question_entity(
return _normalize_entity(raw_entity), raw_entity
def _pattern_d_keep_on_hand_candidates(
sentence: str, problem_text: str | None
) -> list[CandidateUnknown]:
"""Gate A2b — terminal possession after partition+fraction chain."""
s = sentence.strip()
m = _Q_KEEP_ON_HAND_RE.match(s)
if m is None:
return []
unit = _infer_partition_count_unit(problem_text)
if unit is None:
return []
raw_entity = m.group("entity")
resolved = _resolve_question_entity(raw_entity, problem_text)
if resolved is None:
return []
entity, entity_token = resolved
return [
CandidateUnknown(
unknown=Unknown(entity=entity, unit=unit),
source_span=sentence,
matched_unit_token=unit,
matched_entity_token=entity_token,
)
]
def _pattern_a_mass_noun_candidates(
sentence: str, problem_text: str | None
) -> list[CandidateUnknown]:

View file

@ -35,9 +35,14 @@ VALID_OPERATION_KINDS: Final[frozenset[str]] = frozenset(
"compare_additive",
"compare_multiplicative",
"unit_partition",
"fraction_portion",
}
)
VALID_FRACTION_REFERENTS: Final[frozenset[str]] = frozenset(
{"that", "it", "them", "rest", "the_rest"}
)
VALID_COMPARISON_DIRECTIONS: Final[frozenset[str]] = frozenset(
{"more", "fewer", "times", "fraction"}
@ -172,6 +177,50 @@ class PartitionChunk:
}
@dataclass(frozen=True, slots=True)
class FractionPortion:
"""Fraction of an actor's prior count state (Gate A2b).
``FractionPortion(1, 4, "that")`` on a give surface means subtract
one quarter of the actor's partition-derived count unit from the actor
(not a transfer to a recipient). ``referent="rest"`` means the same
count unit after prior fraction steps.
"""
numerator: int
denominator: int
referent: Literal["that", "it", "them", "rest", "the_rest"]
def __post_init__(self) -> None:
if not isinstance(self.numerator, int) or isinstance(self.numerator, bool):
raise MathGraphError(
f"FractionPortion.numerator must be int, got "
f"{type(self.numerator).__name__}"
)
if not isinstance(self.denominator, int) or isinstance(self.denominator, bool):
raise MathGraphError(
f"FractionPortion.denominator must be int, got "
f"{type(self.denominator).__name__}"
)
if self.numerator <= 0 or self.denominator <= 0:
raise MathGraphError(
f"FractionPortion requires positive numerator/denominator; "
f"got {self.numerator}/{self.denominator}"
)
if self.referent not in VALID_FRACTION_REFERENTS:
raise MathGraphError(
f"FractionPortion.referent must be one of "
f"{sorted(VALID_FRACTION_REFERENTS)}; got {self.referent!r}"
)
def as_json(self) -> dict[str, Any]:
return {
"denominator": self.denominator,
"numerator": self.numerator,
"referent": self.referent,
}
@dataclass(frozen=True, slots=True)
class Comparison:
"""A comparison between two actors' quantities (ADR-0123).
@ -279,7 +328,7 @@ class Operation:
actor: str
kind: str
operand: "Quantity | Rate | Comparison | PartitionChunk"
operand: "Quantity | Rate | Comparison | PartitionChunk | FractionPortion"
target: str | None = None
def __post_init__(self) -> None:
@ -302,6 +351,12 @@ class Operation:
"Operation.operand must be a PartitionChunk when "
f"kind='unit_partition'; got {type(self.operand).__name__}"
)
elif self.kind == "fraction_portion":
if not isinstance(self.operand, FractionPortion):
raise MathGraphError(
"Operation.operand must be a FractionPortion when "
f"kind='fraction_portion'; got {type(self.operand).__name__}"
)
elif self.kind in ("compare_additive", "compare_multiplicative"):
if not isinstance(self.operand, Comparison):
raise MathGraphError(
@ -520,7 +575,7 @@ def graph_from_dict(d: Mapping[str, Any]) -> MathProblemGraph:
def _operand_from_dict(
kind: str, operand: Mapping[str, Any]
) -> "Quantity | Rate | Comparison | PartitionChunk":
) -> "Quantity | Rate | Comparison | PartitionChunk | FractionPortion":
"""Reconstruct an Operation.operand from its canonical JSON form.
Dispatches on ``kind``:
@ -566,4 +621,10 @@ def _operand_from_dict(
unit=operand["unit"],
result_unit=operand["result_unit"],
)
if kind == "fraction_portion":
return FractionPortion(
numerator=operand["numerator"],
denominator=operand["denominator"],
referent=operand["referent"],
)
return Quantity(value=operand["value"], unit=operand["unit"])

View file

@ -194,6 +194,39 @@ def _step_sentence(
f"{_render_number(step.operand.value)} groups and keeps one "
f"group, leaving {_render_number(step.after_value)}."
)
if step.operation_kind == "unit_partition":
from generate.math_problem_graph import PartitionChunk
if not isinstance(step.operand, PartitionChunk):
raise RealizerError(
f"unit_partition step {step.step_index} requires "
f"PartitionChunk operand"
)
chunk = step.operand
return (
f"{step.actor} splits {_render_number(step.before_value)} "
f"{_unit_surface(chunk.unit, step.before_value)} into "
f"{_render_number(chunk.value)}-{_singular(chunk.unit)} "
f"{_unit_surface(chunk.result_unit, step.after_value)}, "
f"yielding {_render_number(step.after_value)} "
f"{_unit_surface(chunk.result_unit, step.after_value)}."
)
if step.operation_kind == "fraction_portion":
from generate.math_problem_graph import FractionPortion
if not isinstance(step.operand, FractionPortion):
raise RealizerError(
f"fraction_portion step {step.step_index} requires "
f"FractionPortion operand"
)
portion = step.operand
removed = step.before_value - step.after_value
return (
f"{step.actor} gives away {_render_number(removed)} "
f"({_render_number(portion.numerator)}/"
f"{_render_number(portion.denominator)} of the prior total), "
f"leaving {_render_number(step.after_value)}."
)
raise RealizerError(
f"step {step.step_index} has unknown operation_kind "
f"{step.operation_kind!r}"

View file

@ -166,6 +166,11 @@ KIND_TO_VERBS: Final[Mapping[str, frozenset[str]]] = {
"compare_additive": COMPARE_ADDITIVE_ANCHORS,
"compare_multiplicative": COMPARE_MULTIPLICATIVE_ANCHORS,
"unit_partition": DIVIDE_VERBS,
"fraction_portion": frozenset({
"give", "gives", "gave",
"put", "puts", "place", "places",
"half",
}),
}
@ -477,8 +482,10 @@ def roundtrip_admissible(c: CandidateOperation) -> bool:
if not _unit_grounds(c.matched_unit_token, c.source_span, haystack):
return False
else:
if not isinstance(c.op.operand, Comparison):
return False # only comparisons may have empty unit token
from generate.math_problem_graph import FractionPortion
if not isinstance(c.op.operand, (Comparison, FractionPortion)):
return False # comparisons and fraction_portion omit unit slot
# 6. Transfer target must appear.
if c.matched_target_token is not None:
@ -510,6 +517,15 @@ def roundtrip_admissible(c: CandidateOperation) -> bool:
return False
if not _token_in(c.op.operand.result_unit, haystack):
return False
elif c.op.kind == "fraction_portion":
from generate.math_problem_graph import FractionPortion
if not isinstance(c.op.operand, FractionPortion):
return False
if not _token_in(c.op.operand.referent, haystack):
return False
if c.matched_target_token is not None:
return False
else:
if not isinstance(c.op.operand, Quantity):
return False

View file

@ -34,6 +34,7 @@ from typing import Any, Mapping
from generate.math_problem_graph import (
Comparison,
FractionPortion,
MathProblemGraph,
Operation,
PartitionChunk,
@ -59,6 +60,7 @@ _OPERATION_REQUIRED_LEMMAS: dict[str, str] = {
"compare_additive": "compare_additive",
"compare_multiplicative": "compare_multiplicative",
"unit_partition": "divide",
"fraction_portion": "subtract",
}
@ -90,7 +92,7 @@ class SolutionStep:
operation_kind: str
pack_lemma_id: str
actor: str
operand: "Quantity | Rate | Comparison | PartitionChunk"
operand: "Quantity | Rate | Comparison | PartitionChunk | FractionPortion"
target: str | None
before_value: float
after_value: float
@ -209,8 +211,11 @@ def solve(graph: MathProblemGraph) -> SolutionTrace:
state[(p.entity, p.quantity.unit)] = float(p.quantity.value)
steps: list[SolutionStep] = []
last_count_unit: dict[str, str] = {}
for index, op in enumerate(graph.operations):
step = _apply(op, index, state, pack_bindings)
step = _apply(
op, index, state, pack_bindings, last_count_unit=last_count_unit
)
steps.append(step)
answer_value, answer_unit = _resolve_unknown(graph.unknown, state)
@ -230,6 +235,8 @@ def _apply(
index: int,
state: dict[tuple[str, str], float],
pack_bindings: Mapping[str, str],
*,
last_count_unit: dict[str, str],
) -> SolutionStep:
# Kind-discriminated early returns for operations carrying non-Quantity
# operands: apply_rate (ADR-0122) uses Rate; compare_* (ADR-0123) uses
@ -242,7 +249,13 @@ def _apply(
if op.kind == "compare_multiplicative":
return _apply_compare_multiplicative(op, index, state, pack_bindings)
if op.kind == "unit_partition":
return _apply_unit_partition(op, index, state, pack_bindings)
return _apply_unit_partition(
op, index, state, pack_bindings, last_count_unit=last_count_unit
)
if op.kind == "fraction_portion":
return _apply_fraction_portion(
op, index, state, pack_bindings, last_count_unit=last_count_unit
)
if not isinstance(op.operand, Quantity):
raise SolveError(
@ -428,6 +441,8 @@ def _apply_unit_partition(
index: int,
state: dict[tuple[str, str], float],
pack_bindings: Mapping[str, str],
*,
last_count_unit: dict[str, str],
) -> SolutionStep:
"""Apply a fixed-size unit partition (Gate A2a).
@ -469,6 +484,7 @@ def _apply_unit_partition(
f"silently redeclare"
)
state[result_key] = after
last_count_unit[op.actor] = chunk.result_unit
return SolutionStep(
step_index=index,
operation_kind=op.kind,
@ -483,6 +499,63 @@ def _apply_unit_partition(
)
def _apply_fraction_portion(
op: Operation,
index: int,
state: dict[tuple[str, str], float],
pack_bindings: Mapping[str, str],
*,
last_count_unit: dict[str, str],
) -> SolutionStep:
"""Subtract a fraction of the actor's partition-derived count unit."""
if not isinstance(op.operand, FractionPortion):
raise SolveError(
f"fraction_portion at step {index} requires a "
f"FractionPortion operand; got {type(op.operand).__name__}"
)
portion = op.operand
unit = last_count_unit.get(op.actor)
if unit is None:
raise SolveError(
f"fraction_portion at step {index} requires a prior "
f"partition-derived count unit for actor {op.actor!r}"
)
key = (op.actor, unit)
if key not in state:
raise SolveError(
f"fraction_portion at step {index} requires actor {op.actor!r} "
f"to hold a quantity in {unit!r}, but no such state exists"
)
before = state[key]
amount = before * float(portion.numerator) / float(portion.denominator)
if abs(amount - round(amount)) > 1e-9:
raise SolveError(
f"fraction_portion at step {index} requires an exact "
f"integer portion; got {amount!r} from {before!r} * "
f"{portion.numerator}/{portion.denominator}"
)
after = before - float(int(round(amount)))
if after < 0:
raise SolveError(
f"fraction_portion at step {index} would yield negative "
f"quantity {after!r}; refuse rather than emit nonsense"
)
state[key] = after
last_count_unit[op.actor] = unit
return SolutionStep(
step_index=index,
operation_kind=op.kind,
pack_lemma_id=pack_bindings[op.kind],
actor=op.actor,
operand=portion,
target=None,
before_value=before,
after_value=after,
target_before=None,
target_after=None,
)
def _apply_compare_multiplicative(
op: Operation,
index: int,

View file

@ -185,9 +185,10 @@ def verify(graph: MathProblemGraph, trace: SolutionTrace) -> VerifierVerdict:
replay_ok = True
replay_detail = ""
last_count_unit: dict[str, str] = {}
for step in trace.steps:
try:
_verify_step(step, state)
_verify_step(step, state, last_count_unit=last_count_unit)
except VerificationError as exc:
replay_ok = False
replay_detail = str(exc)
@ -238,7 +239,12 @@ def verify(graph: MathProblemGraph, trace: SolutionTrace) -> VerifierVerdict:
)
def _verify_step(step: SolutionStep, state: dict[tuple[str, str], float]) -> None:
def _verify_step(
step: SolutionStep,
state: dict[tuple[str, str], float],
*,
last_count_unit: dict[str, str],
) -> None:
# Kind-discriminated early returns for non-Quantity operands:
# apply_rate (ADR-0122) uses Rate; compare_* (ADR-0123) uses Comparison.
if step.operation_kind == "apply_rate":
@ -251,7 +257,10 @@ def _verify_step(step: SolutionStep, state: dict[tuple[str, str], float]) -> Non
_verify_compare_multiplicative_step(step, state)
return
if step.operation_kind == "unit_partition":
_verify_unit_partition_step(step, state)
_verify_unit_partition_step(step, state, last_count_unit=last_count_unit)
return
if step.operation_kind == "fraction_portion":
_verify_fraction_portion_step(step, state, last_count_unit=last_count_unit)
return
if not isinstance(step.operand, Quantity):
@ -431,7 +440,10 @@ def _verify_compare_additive_step(
def _verify_unit_partition_step(
step: SolutionStep, state: dict[tuple[str, str], float]
step: SolutionStep,
state: dict[tuple[str, str], float],
*,
last_count_unit: dict[str, str],
) -> None:
"""Verify a unit_partition step (Gate A2a).
@ -486,6 +498,61 @@ def _verify_unit_partition_step(
f"existing state for ({step.actor!r}, {chunk.result_unit!r})"
)
state[result_key] = fresh_after
last_count_unit[step.actor] = chunk.result_unit
def _verify_fraction_portion_step(
step: SolutionStep,
state: dict[tuple[str, str], float],
*,
last_count_unit: dict[str, str],
) -> None:
"""Verify a fraction_portion step (Gate A2b)."""
from generate.math_problem_graph import FractionPortion
if not isinstance(step.operand, FractionPortion):
raise VerificationError(
f"step {step.step_index} kind=fraction_portion requires "
f"FractionPortion operand; got {type(step.operand).__name__}"
)
portion = step.operand
unit = last_count_unit.get(step.actor)
if unit is None:
raise VerificationError(
f"step {step.step_index} kind=fraction_portion requires a "
f"prior partition-derived count unit for actor {step.actor!r}"
)
key = (step.actor, unit)
if key not in state:
raise VerificationError(
f"step {step.step_index} kind=fraction_portion references "
f"({step.actor!r}, {unit!r}) which is not in verifier state"
)
fresh_before = state[key]
if fresh_before != step.before_value:
raise VerificationError(
f"step {step.step_index} declares before_value="
f"{step.before_value}, verifier computed {fresh_before}"
)
amount = fresh_before * float(portion.numerator) / float(portion.denominator)
if abs(amount - round(amount)) > 1e-9:
raise VerificationError(
f"step {step.step_index} kind=fraction_portion requires an exact "
f"integer portion; got {amount!r}"
)
fresh_after = fresh_before - float(int(round(amount)))
if fresh_after != step.after_value:
raise VerificationError(
f"step {step.step_index} declares after_value="
f"{step.after_value}, verifier computed {fresh_after}"
)
if step.target is not None:
raise VerificationError(
f"step {step.step_index} kind=fraction_portion must not declare "
f"a target; got {step.target!r}"
)
state[key] = fresh_after
last_count_unit[step.actor] = unit
def _verify_compare_multiplicative_step(

View file

@ -0,0 +1,113 @@
"""Gate A2b — fraction_portion + keep-on-hand question composition."""
from __future__ import annotations
from generate.math_candidate_graph import parse_and_solve
from generate.math_candidate_parser import extract_operation_candidates
from generate.math_problem_graph import FractionPortion, Operation, PartitionChunk
from generate.math_roundtrip import roundtrip_admissible
from generate.math_solver import _apply_fraction_portion, _apply_unit_partition
def _run(text: str):
return parse_and_solve(text, sealed=False)
def test_fraction_give_extracts_and_roundtrips():
stmt = "She gives 1/4 of that to a friend."
cands = extract_operation_candidates(stmt)
assert len(cands) == 1
cand = cands[0]
assert cand.op.kind == "fraction_portion"
assert isinstance(cand.op.operand, FractionPortion)
assert cand.op.operand.numerator == 1
assert cand.op.operand.denominator == 4
assert roundtrip_admissible(cand)
def test_half_rest_extracts_and_roundtrips():
stmt = "She then puts half of the rest in storage."
cands = extract_operation_candidates(stmt)
assert len(cands) == 1
cand = cands[0]
assert cand.op.kind == "fraction_portion"
assert cand.op.operand.numerator == 1
assert cand.op.operand.denominator == 2
assert cand.op.operand.referent == "rest"
assert roundtrip_admissible(cand)
def test_fraction_give_without_of_that_does_not_extract():
stmt = "She gives 1/4 to a friend."
assert extract_operation_candidates(stmt) == []
def test_fraction_portion_solver_chain():
pack_bindings = {"unit_partition": "en_arithmetic_v1:divide", "fraction_portion": "en_arithmetic_v1:subtract"}
state = {("Jan", "feet"): 1000.0}
last_count_unit: dict[str, str] = {}
_apply_unit_partition(
Operation(
actor="Jan",
kind="unit_partition",
operand=PartitionChunk(value=25.0, unit="feet", result_unit="sections"),
),
index=0,
state=state,
pack_bindings=pack_bindings,
last_count_unit=last_count_unit,
)
assert state[("Jan", "sections")] == 40.0
_apply_fraction_portion(
Operation(
actor="Jan",
kind="fraction_portion",
operand=FractionPortion(1, 4, "that"),
),
index=1,
state=state,
pack_bindings=pack_bindings,
last_count_unit=last_count_unit,
)
assert state[("Jan", "sections")] == 30.0
_apply_fraction_portion(
Operation(
actor="Jan",
kind="fraction_portion",
operand=FractionPortion(1, 2, "rest"),
),
index=2,
state=state,
pack_bindings=pack_bindings,
last_count_unit=last_count_unit,
)
assert state[("Jan", "sections")] == 15.0
def test_train_sample_0002_end_to_end():
text = (
"Jan buys 1000 feet of cable. "
"She splits it up into 25-foot sections. "
"She gives 1/4 of that to a friend. "
"She then puts half of the rest in storage. "
"How much does she keep on hand?"
)
res = _run(text)
assert res.answer == 15.0
assert res.refusal_reason is None
def test_confuser_v1_0007_still_refuses():
text = (
"Jan buys 1000 feet of cable. "
"She splits it into 25-foot sections. "
"She gives 1/4 to a friend. "
"She puts half of the rest in storage. "
"How much does she keep on hand?"
)
res = _run(text)
assert res.answer is None
assert res.refusal_reason is not None

View file

@ -23,7 +23,9 @@ def test_unit_partition_solver_lower_level_integration():
state = {("Jan", "feet"): 1000.0}
pack_bindings = {"unit_partition": "en_arithmetic_v1:divide"}
step = _apply_unit_partition(op, index=0, state=state, pack_bindings=pack_bindings)
step = _apply_unit_partition(
op, index=0, state=state, pack_bindings=pack_bindings, last_count_unit={}
)
assert step.operation_kind == "unit_partition"
assert state[("Jan", "sections")] == 40.0
@ -71,7 +73,9 @@ def test_non_exact_quotient_refuses_at_solver():
pack_bindings = {"unit_partition": "en_arithmetic_v1:divide"}
with pytest.raises(SolveError):
_apply_unit_partition(op, index=0, state=state, pack_bindings=pack_bindings)
_apply_unit_partition(
op, index=0, state=state, pack_bindings=pack_bindings, last_count_unit={}
)
def test_unit_mismatch_surface_does_not_solve():
@ -80,7 +84,7 @@ def test_unit_mismatch_surface_does_not_solve():
assert res.answer is None
def test_full_0002_still_refuses_without_composition():
def test_full_0002_solves_with_fraction_rest_composition():
text = (
"Jan buys 1000 feet of cable. "
"She splits it up into 25-foot sections. "
@ -89,8 +93,8 @@ def test_full_0002_still_refuses_without_composition():
"How much does she keep on hand?"
)
res = _run(text)
assert res.answer is None
assert res.refusal_reason is not None
assert res.answer == 15.0
assert res.refusal_reason is None
def test_duration_confuser_does_not_inject_unit_partition():