From d18c0b6be31d44a271dd09a839cac32aec4d5386 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 17 Jun 2026 18:45:36 -0700 Subject: [PATCH] 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". --- generate/math_candidate_graph.py | 75 +++++++- generate/math_candidate_parser.py | 170 +++++++++++++++++- generate/math_problem_graph.py | 65 ++++++- generate/math_realizer.py | 33 ++++ generate/math_roundtrip.py | 20 ++- generate/math_solver.py | 79 +++++++- generate/math_verifier.py | 75 +++++++- ...t_math_candidate_graph_fraction_portion.py | 113 ++++++++++++ ...andidate_graph_unit_partition_injection.py | 14 +- 9 files changed, 623 insertions(+), 21 deletions(-) create mode 100644 tests/test_math_candidate_graph_fraction_portion.py diff --git a/generate/math_candidate_graph.py b/generate/math_candidate_graph.py index 5210d807..5f5eecb0 100644 --- a/generate/math_candidate_graph.py +++ b/generate/math_candidate_graph.py @@ -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 ( diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index 72af818a..53d73ea8 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -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 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_OR_PRONOUN})\s+" + r"(?Pgive|gives|gave)\s+" + rf"(?P{_SLASH_FRACTION})\s+" + r"of\s+(?Pthat|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_OR_PRONOUN})\s+" + r"(?:then\s+)?" + r"(?Pput|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 " 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}|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{_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]: diff --git a/generate/math_problem_graph.py b/generate/math_problem_graph.py index 231e61d4..b873c7ea 100644 --- a/generate/math_problem_graph.py +++ b/generate/math_problem_graph.py @@ -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"]) diff --git a/generate/math_realizer.py b/generate/math_realizer.py index 8fbf4e39..d26cb381 100644 --- a/generate/math_realizer.py +++ b/generate/math_realizer.py @@ -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}" diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index a0eae203..460b84e6 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -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 diff --git a/generate/math_solver.py b/generate/math_solver.py index c12fbee1..a1ec42ba 100644 --- a/generate/math_solver.py +++ b/generate/math_solver.py @@ -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, diff --git a/generate/math_verifier.py b/generate/math_verifier.py index 4e7e4688..6c72e039 100644 --- a/generate/math_verifier.py +++ b/generate/math_verifier.py @@ -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( diff --git a/tests/test_math_candidate_graph_fraction_portion.py b/tests/test_math_candidate_graph_fraction_portion.py new file mode 100644 index 00000000..22d0e1be --- /dev/null +++ b/tests/test_math_candidate_graph_fraction_portion.py @@ -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 \ No newline at end of file diff --git a/tests/test_math_candidate_graph_unit_partition_injection.py b/tests/test_math_candidate_graph_unit_partition_injection.py index 99ec1859..d5a1f556 100644 --- a/tests/test_math_candidate_graph_unit_partition_injection.py +++ b/tests/test_math_candidate_graph_unit_partition_injection.py @@ -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():