core/generate/math_problem_graph.py
Shay 5a0423cb36
feat(derivation): Gate A2b case 0002 fractional rest composition (#810)
* 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".

* test(derivation): add A2b sibling and confuser anti-overfit cases

Cover Bob/Alice partition+fraction chains, slash-without-referent refusal,
partition-less "of that", and multi-actor pronoun ambiguity.

* chore(derivation): normalize A2b fraction test EOF
2026-06-17 19:08:24 -07:00

630 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ADR-0115 — Typed proposition graph for grade-school math word problems.
This module defines the structural target of the parser added under ADR-0115.
Parsing a natural-language problem produces a :class:`MathProblemGraph`; the
solver (ADR-0116) and verifier (ADR-0117) consume the same structure.
Determinism guarantees:
- Every dataclass is ``frozen=True, slots=True`` and hashes by value.
- :meth:`MathProblemGraph.canonical_bytes` is sorted-keys, compact-separators
JSON — same graph object → byte-identical SHA-256.
- Field order on ``entities``, ``initial_state``, ``operations`` is
**order-of-introduction** in the source text. Two graphs that disagree on
introduction order are NOT equal; this matches CORE's general "preserve
source-text ordering" doctrine.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Final, Literal, Mapping
# Operation kinds correspond to math-pack lemma vocabulary (en_mathematics_logic_v1).
# A future solver under ADR-0116 dispatches on this string.
VALID_OPERATION_KINDS: Final[frozenset[str]] = frozenset(
{
"add",
"subtract",
"transfer",
"multiply",
"divide",
"apply_rate",
"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"}
)
class MathGraphError(ValueError):
"""Raised on schema violations in math-problem-graph construction."""
@dataclass(frozen=True, slots=True)
class Quantity:
"""A numeric value paired with a textual unit.
The unit is the canonical noun (lowercase). Equality is exact:
``Quantity(5, 'apples')`` != ``Quantity(5, 'apple')``. Authors and
parsers must canonicalize units before constructing.
"""
value: int | float
unit: str
def __post_init__(self) -> None:
if not isinstance(self.value, (int, float)) or isinstance(self.value, bool):
raise MathGraphError(
f"Quantity.value must be int or float, got "
f"{type(self.value).__name__}"
)
if not isinstance(self.unit, str) or not self.unit:
raise MathGraphError(
f"Quantity.unit must be a non-empty string, got {self.unit!r}"
)
def as_json(self) -> dict[str, Any]:
return {"unit": self.unit, "value": self.value}
@dataclass(frozen=True, slots=True)
class Rate:
"""A per-unit rate connecting two units (ADR-0122).
``Rate(2.0, "dollars", "apple")`` means "2 dollars per apple". The
rate consumes a quantity in ``denominator_unit`` and produces a
quantity in ``numerator_unit`` via scalar multiplication. ``value``
must be strictly positive — zero or negative rates are refused at
construction (illegal states made hard to represent).
"""
value: int | float
numerator_unit: str
denominator_unit: str
def __post_init__(self) -> None:
if not isinstance(self.value, (int, float)) or isinstance(self.value, bool):
raise MathGraphError(
f"Rate.value must be int or float, got "
f"{type(self.value).__name__}"
)
if self.value <= 0:
raise MathGraphError(
f"Rate.value must be strictly positive; got {self.value!r}"
)
if not isinstance(self.numerator_unit, str) or not self.numerator_unit:
raise MathGraphError(
f"Rate.numerator_unit must be a non-empty string, got "
f"{self.numerator_unit!r}"
)
if not isinstance(self.denominator_unit, str) or not self.denominator_unit:
raise MathGraphError(
f"Rate.denominator_unit must be a non-empty string, got "
f"{self.denominator_unit!r}"
)
if self.numerator_unit == self.denominator_unit:
raise MathGraphError(
f"Rate.numerator_unit and Rate.denominator_unit must differ; "
f"got {self.numerator_unit!r} for both"
)
def as_json(self) -> dict[str, Any]:
return {
"denominator_unit": self.denominator_unit,
"numerator_unit": self.numerator_unit,
"value": self.value,
}
@dataclass(frozen=True, slots=True)
class PartitionChunk:
"""Fixed-size chunk measure for unit_partition (Gate A2a).
``PartitionChunk(25, "feet", "sections")`` means "split the actor's
total in ``unit`` into chunks of size 25, writing the integer chunk
count under ``result_unit``". ``value`` is the chunk size (divisor);
``unit`` is the measure unit shared with the prior total; ``result_unit``
is the count noun for the quotient (not the dividend unit).
"""
value: int | float
unit: str
result_unit: str
def __post_init__(self) -> None:
if not isinstance(self.value, (int, float)) or isinstance(self.value, bool):
raise MathGraphError(
f"PartitionChunk.value must be int or float, got "
f"{type(self.value).__name__}"
)
if self.value <= 0:
raise MathGraphError(
f"PartitionChunk.value must be strictly positive; got {self.value!r}"
)
if not isinstance(self.unit, str) or not self.unit:
raise MathGraphError(
f"PartitionChunk.unit must be a non-empty string, got {self.unit!r}"
)
if not isinstance(self.result_unit, str) or not self.result_unit:
raise MathGraphError(
f"PartitionChunk.result_unit must be a non-empty string, got "
f"{self.result_unit!r}"
)
if self.unit == self.result_unit:
raise MathGraphError(
f"PartitionChunk.unit and PartitionChunk.result_unit must differ; "
f"got {self.unit!r} for both"
)
def as_json(self) -> dict[str, Any]:
return {
"result_unit": self.result_unit,
"unit": self.unit,
"value": self.value,
}
@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).
Two modes, discriminated by ``direction``:
- ``direction='more'`` / ``direction='fewer'``: additive — actor's
quantity is ``reference_actor``'s quantity ± ``delta`` (Quantity).
``factor`` must be ``None``.
- ``direction='times'`` / ``direction='fraction'``: multiplicative —
actor's quantity is ``factor`` × ``reference_actor``'s quantity.
``delta`` must be ``None``. ``factor`` must be strictly positive.
Self-reference is refused at the Operation boundary, not here.
"""
reference_actor: str
delta: "Quantity | None"
factor: float | None
direction: Literal["more", "fewer", "times", "fraction"]
def __post_init__(self) -> None:
if not isinstance(self.reference_actor, str) or not self.reference_actor:
raise MathGraphError(
"Comparison.reference_actor must be a non-empty string"
)
if self.direction not in VALID_COMPARISON_DIRECTIONS:
raise MathGraphError(
f"Comparison.direction must be one of "
f"{sorted(VALID_COMPARISON_DIRECTIONS)}; got {self.direction!r}"
)
if self.direction in ("more", "fewer"):
if not isinstance(self.delta, Quantity):
raise MathGraphError(
"Comparison.delta must be a Quantity when "
f"direction={self.direction!r}; got "
f"{type(self.delta).__name__}"
)
if self.factor is not None:
raise MathGraphError(
"Comparison.factor must be None when "
f"direction={self.direction!r}; got {self.factor!r}"
)
else:
if self.delta is not None:
raise MathGraphError(
"Comparison.delta must be None when "
f"direction={self.direction!r}; got {self.delta!r}"
)
if not isinstance(self.factor, (int, float)) or isinstance(
self.factor, bool
):
raise MathGraphError(
"Comparison.factor must be int or float when "
f"direction={self.direction!r}; got "
f"{type(self.factor).__name__}"
)
if self.factor <= 0:
raise MathGraphError(
f"Comparison.factor must be strictly positive; "
f"got {self.factor!r}"
)
def as_json(self) -> dict[str, Any]:
d: dict[str, Any] = {
"direction": self.direction,
"reference_actor": self.reference_actor,
}
if self.delta is not None:
d["delta"] = self.delta.as_json()
if self.factor is not None:
d["factor"] = self.factor
return d
@dataclass(frozen=True, slots=True)
class InitialPossession:
"""Some entity holds some quantity at the start of the problem."""
entity: str
quantity: Quantity
def __post_init__(self) -> None:
if not isinstance(self.entity, str) or not self.entity:
raise MathGraphError(
"InitialPossession.entity must be a non-empty string"
)
def as_json(self) -> dict[str, Any]:
return {"entity": self.entity, "quantity": self.quantity.as_json()}
@dataclass(frozen=True, slots=True)
class Operation:
"""A state-mutating event applied in story order.
``transfer`` denotes ``actor → target`` movement of ``operand``. The
solver (ADR-0116) decomposes ``transfer`` into ``subtract`` from actor
plus ``add`` to target; the parser emits ``transfer`` to stay close to
natural-language surface ("gives X to Y").
For ``multiply`` / ``divide`` the ``operand`` is the scalar (e.g. a
factor of 3). Unit handling for these kinds is delegated to the solver.
"""
actor: str
kind: str
operand: "Quantity | Rate | Comparison | PartitionChunk | FractionPortion"
target: str | None = None
def __post_init__(self) -> None:
if not isinstance(self.actor, str) or not self.actor:
raise MathGraphError("Operation.actor must be a non-empty string")
if self.kind not in VALID_OPERATION_KINDS:
raise MathGraphError(
f"Operation.kind must be one of {sorted(VALID_OPERATION_KINDS)}, "
f"got {self.kind!r}"
)
if self.kind == "apply_rate":
if not isinstance(self.operand, Rate):
raise MathGraphError(
"Operation.operand must be a Rate when kind='apply_rate'; "
f"got {type(self.operand).__name__}"
)
elif self.kind == "unit_partition":
if not isinstance(self.operand, PartitionChunk):
raise MathGraphError(
"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(
"Operation.operand must be a Comparison when "
f"kind={self.kind!r}; got {type(self.operand).__name__}"
)
if self.kind == "compare_additive" and self.operand.direction not in (
"more",
"fewer",
):
raise MathGraphError(
"Operation.kind='compare_additive' requires "
"Comparison.direction in {'more','fewer'}; got "
f"{self.operand.direction!r}"
)
if self.kind == "compare_multiplicative" and self.operand.direction not in (
"times",
"fraction",
):
raise MathGraphError(
"Operation.kind='compare_multiplicative' requires "
"Comparison.direction in {'times','fraction'}; got "
f"{self.operand.direction!r}"
)
if self.operand.reference_actor == self.actor:
raise MathGraphError(
"Operation.operand.reference_actor must differ from "
f"Operation.actor; both are {self.actor!r}"
)
else:
if not isinstance(self.operand, Quantity):
raise MathGraphError(
"Operation.operand must be a Quantity when "
f"kind={self.kind!r}; got {type(self.operand).__name__}"
)
if self.kind == "transfer":
if not self.target:
raise MathGraphError(
"Operation.target required when kind='transfer'"
)
if self.target == self.actor:
raise MathGraphError(
"Operation.target must differ from Operation.actor for "
"kind='transfer'"
)
elif self.target is not None:
raise MathGraphError(
f"Operation.target only valid for kind='transfer'; got "
f"kind={self.kind!r}"
)
def as_json(self) -> dict[str, Any]:
d: dict[str, Any] = {
"actor": self.actor,
"kind": self.kind,
"operand": self.operand.as_json(),
}
if self.target is not None:
d["target"] = self.target
return d
@dataclass(frozen=True, slots=True)
class Unknown:
"""The quantity the question is asking for.
``entity=None`` means "total across every entity holding ``unit``"
(e.g. "How many apples do they have in total?"). For a single-entity
question ("How many apples does Sam have?") set ``entity='Sam'``.
"""
entity: str | None
unit: str
def __post_init__(self) -> None:
if not isinstance(self.unit, str) or not self.unit:
raise MathGraphError("Unknown.unit must be a non-empty string")
if self.entity is not None and (
not isinstance(self.entity, str) or not self.entity
):
raise MathGraphError(
"Unknown.entity must be a non-empty string or None"
)
def as_json(self) -> dict[str, Any]:
return {"entity": self.entity, "unit": self.unit}
@dataclass(frozen=True, slots=True)
class MathProblemGraph:
"""Typed graph produced by the ADR-0115 parser.
Field order on tuples is **order of introduction in the source text**,
not alphabetical. ``MathProblemGraph`` equality is element-wise tuple
equality; reordering changes the graph identity.
"""
entities: tuple[str, ...]
initial_state: tuple[InitialPossession, ...]
operations: tuple[Operation, ...]
unknown: Unknown
def __post_init__(self) -> None:
if not self.entities:
raise MathGraphError(
"MathProblemGraph.entities must contain at least one entity"
)
seen: set[str] = set()
for e in self.entities:
if not isinstance(e, str) or not e:
raise MathGraphError(
"MathProblemGraph.entities must be non-empty strings"
)
if e in seen:
raise MathGraphError(
f"MathProblemGraph.entities contains duplicate {e!r}"
)
seen.add(e)
entity_set = set(self.entities)
# ADR-0174 Phase 3 diagnostic — refuse contradictory initial
# possessions for the same (entity, unit). Surfaced 2026-05-28
# by post-merge diagnostic: prior behavior silently overwrote
# earlier with later in math_solver.solve()'s state dict, so
# 'Sam has 5 marbles. Sam has 3 marbles.' returned 3.0 — a
# wrong=0 violation (definite answer from contradictory input).
# Refuse at construction; admit duplicates only when the value
# matches (redundant but not contradictory).
seen_initial: dict[tuple[str, str], int | float] = {}
for p in self.initial_state:
if p.entity not in entity_set:
raise MathGraphError(
f"initial_state references unknown entity {p.entity!r}"
)
key = (p.entity, p.quantity.unit)
if key in seen_initial and seen_initial[key] != p.quantity.value:
raise MathGraphError(
f"initial_state contains contradictory possessions for "
f"({p.entity!r}, {p.quantity.unit!r}): "
f"{seen_initial[key]} vs {p.quantity.value}"
)
seen_initial[key] = p.quantity.value
for op in self.operations:
if op.actor not in entity_set:
raise MathGraphError(
f"operation references unknown actor {op.actor!r}"
)
if op.target is not None and op.target not in entity_set:
raise MathGraphError(
f"operation references unknown target {op.target!r}"
)
if isinstance(op.operand, Comparison):
if op.operand.reference_actor not in entity_set:
raise MathGraphError(
"operation Comparison references unknown "
f"reference_actor {op.operand.reference_actor!r}"
)
if self.unknown.entity is not None and self.unknown.entity not in entity_set:
raise MathGraphError(
f"unknown references unknown entity {self.unknown.entity!r}"
)
def as_json(self) -> dict[str, Any]:
return {
"entities": list(self.entities),
"initial_state": [p.as_json() for p in self.initial_state],
"operations": [o.as_json() for o in self.operations],
"unknown": self.unknown.as_json(),
}
def canonical_bytes(self) -> bytes:
"""Deterministic JSON for hashing/byte-equality comparison."""
return json.dumps(
self.as_json(), sort_keys=True, separators=(",", ":")
).encode("utf-8")
def graph_from_dict(d: Mapping[str, Any]) -> MathProblemGraph:
"""Deserialize a graph from its canonical JSON dict.
The reverse of :meth:`MathProblemGraph.as_json`. Raises
:class:`MathGraphError` on any schema violation surfaced by the
dataclass constructors.
"""
if not isinstance(d, Mapping):
raise MathGraphError(f"graph payload must be a mapping; got {type(d).__name__}")
for required in ("entities", "initial_state", "operations", "unknown"):
if required not in d:
raise MathGraphError(f"graph payload missing required field {required!r}")
entities = tuple(d["entities"])
initial_state = tuple(
InitialPossession(
entity=p["entity"],
quantity=Quantity(value=p["quantity"]["value"], unit=p["quantity"]["unit"]),
)
for p in d["initial_state"]
)
operations = tuple(
Operation(
actor=o["actor"],
kind=o["kind"],
operand=_operand_from_dict(o["kind"], o["operand"]),
target=o.get("target"),
)
for o in d["operations"]
)
unk = d["unknown"]
unknown = Unknown(entity=unk.get("entity"), unit=unk["unit"])
return MathProblemGraph(
entities=entities,
initial_state=initial_state,
operations=operations,
unknown=unknown,
)
def _operand_from_dict(
kind: str, operand: Mapping[str, Any]
) -> "Quantity | Rate | Comparison | PartitionChunk | FractionPortion":
"""Reconstruct an Operation.operand from its canonical JSON form.
Dispatches on ``kind``:
- ``apply_rate`` → ``Rate`` (ADR-0122)
- ``compare_additive`` / ``compare_multiplicative`` → ``Comparison`` (ADR-0123)
- ``unit_partition`` → ``PartitionChunk`` (Gate A2a)
- every other kind → ``Quantity``
Payload shapes are structurally distinct (``Rate`` has
``numerator_unit``/``denominator_unit``; ``Comparison`` has
``reference_actor``/``direction``; ``Quantity`` has ``unit``) but
we dispatch on ``kind`` rather than sniffing keys so a mismatch
between ``kind`` and operand shape raises loudly in the dataclass
constructor.
"""
if not isinstance(operand, Mapping):
raise MathGraphError(
f"Operation.operand must be a mapping; got {type(operand).__name__}"
)
if kind == "apply_rate":
return Rate(
value=operand["value"],
numerator_unit=operand["numerator_unit"],
denominator_unit=operand["denominator_unit"],
)
if kind in ("compare_additive", "compare_multiplicative"):
delta_payload = operand.get("delta")
delta = (
Quantity(value=delta_payload["value"], unit=delta_payload["unit"])
if delta_payload is not None
else None
)
return Comparison(
reference_actor=operand["reference_actor"],
delta=delta,
factor=operand.get("factor"),
direction=operand["direction"],
)
if kind == "unit_partition":
return PartitionChunk(
value=operand["value"],
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"])