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:
parent
65405f1128
commit
d18c0b6be3
9 changed files with 623 additions and 21 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
113
tests/test_math_candidate_graph_fraction_portion.py
Normal file
113
tests/test_math_candidate_graph_fraction_portion.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue