First parser-expansion ADR after ADR-0121's deferral. Adds the rate
algebra substrate (Rate dataclass + apply_rate operation kind + parser
pattern + solver/verifier/realizer + en_arithmetic_v1 pack lemma)
mirroring the deferral pattern that ADR-0121 demonstrated for
capability promotion: substrate complete, gate refuses honestly.
Substrate
- Rate(value, numerator_unit, denominator_unit) frozen dataclass with
strict positive-value + non-empty-distinct-unit refusal at construction
- apply_rate operation kind admitted in VALID_OPERATION_KINDS;
Operation.operand widened to Quantity | Rate with kind-discriminated
type enforcement
- Parser: _RATE_COST_EACH_RE + _RATE_COST_EACH_TRAILING_RE +
_Q_RATE_AGGREGATE_RE patterns; actor_units state tracking;
first-declaration-wins on redeclaration (ParseError); orphan-rate
refusal at end of parse; three refusal paths in rate-aggregate question
- Solver: _apply_rate() reads denominator-unit state, multiplies by
rate.value, writes numerator-unit state (denom preserved)
- Verifier: _verify_apply_rate_step() byte-equal replay
- Realizer: 'At {N} {numer} per {denom_singular}, {actor} spends ...'
template containing required tokens
- Pack: en-arith-006 apply_rate lemma + gloss; SHA-256 checksums
refreshed; manifest version 1.0.0 -> 1.1.0; provenance tagged
adr-0122:rate_extension:2026-05-22
Measurement (honest)
- Sealed GSM8K correct_rate: 0/1319 (substrate matches zero real cases
alone). Multi-construction barrier documented in the ADR: all 14 sealed
cases matching 'each \w+ costs?' combine rate with at least one other
class (aggregation 6, comparison 3, unit conversion 2, multi-actor 2,
conditional 1)
- Sealed GSM8K wrong: 0 (load-bearing positive claim; grammar adds zero
misparses across 1,319 real test problems)
- Anti-overfit lanes unchanged: OOD ratio, perturbation invariance
preserving/breaking 1.0, adversarial wrong 0
- ADR-0121 invariants byte-equal preserved (6/6)
- 41 new ADR-0122 invariants pinned in tests/test_adr_0122_rate_per_unit.py
- 670 existing math + pack regression tests pass
Roadmap update
- Per-ADR lift expectation corrected: no single parser-expansion ADR
will move sealed correct_rate alone. First lift signal will come
from cumulative composition after 3rd or 4th class lands (rate +
comparison + aggregation foundational set)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
253 lines
9.4 KiB
Python
253 lines
9.4 KiB
Python
"""ADR-0118 — Stepped realizer: SolutionTrace → show-your-work prose.
|
|
|
|
Consumes a :class:`SolutionTrace` (ADR-0116) and emits a sequence of
|
|
one-sentence-per-step English explanations of the reasoning. The
|
|
realizer is **deterministic and pack-grounded**: every sentence
|
|
identifies the actor, the pack-resolved operation, and the operand,
|
|
ending with the answer sentence that names the resolved unknown.
|
|
|
|
Architectural commitments:
|
|
|
|
- **Deterministic.** Same trace → byte-identical prose.
|
|
- **Pack-grounded surface.** The verb in each step sentence is
|
|
drawn from a fixed table keyed to the operation kind; the kind
|
|
itself comes from the trace's ``pack_lemma_id``. Removing the
|
|
arithmetic pack breaks the trace upstream, which breaks the
|
|
realizer with a typed refusal.
|
|
- **Round-trippable** for add / subtract / transfer steps: the
|
|
rendered prose, when re-parsed by ``parse_problem``, yields a
|
|
graph whose solver-trace reproduces the same answer. ``multiply``
|
|
and ``divide`` step phrasings are deliberately one-way (the
|
|
parser's multiply pattern requires a possessed object phrase
|
|
that the realizer can simulate, but the divide phrasing requires
|
|
case-specific structure the parser does not yet recover). Round-
|
|
trippability is enforced on the operation kinds the parser fully
|
|
supports today; the divide / multiply cases produce inspectable
|
|
prose without the round-trip guarantee.
|
|
- **Typed refusal** on inconsistent traces (the realizer does not
|
|
re-validate the trace — :class:`ADR-0117 verifier`'s job — but
|
|
it does refuse unknown operation kinds).
|
|
|
|
The realizer is the ADR-0114a Obligation #5-compatible substrate
|
|
for ADR-0119's GSM8K eval lane: every "correct" answer in the lane
|
|
ships with a stepped explanation that traces to pack lemmas.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from generate.math_problem_graph import Rate
|
|
from generate.math_solver import SolutionStep, SolutionTrace
|
|
|
|
|
|
class RealizerError(ValueError):
|
|
"""Raised on unrecognized operation kind or empty trace."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RealizedTrace:
|
|
"""Stepped explanation surface for a :class:`SolutionTrace`.
|
|
|
|
``setup_sentences`` introduce the initial state (one sentence per
|
|
:class:`InitialPossession`). ``step_sentences`` walk the trace in
|
|
order. ``answer_sentence`` states the final resolved unknown.
|
|
|
|
``canonical_bytes()`` is byte-deterministic so two realizations of
|
|
the same trace produce the same SHA-256.
|
|
"""
|
|
|
|
setup_sentences: tuple[str, ...]
|
|
step_sentences: tuple[str, ...]
|
|
answer_sentence: str
|
|
pack_id: str
|
|
|
|
def as_json(self) -> dict[str, Any]:
|
|
return {
|
|
"setup_sentences": list(self.setup_sentences),
|
|
"step_sentences": list(self.step_sentences),
|
|
"answer_sentence": self.answer_sentence,
|
|
"pack_id": self.pack_id,
|
|
}
|
|
|
|
def canonical_bytes(self) -> bytes:
|
|
return json.dumps(
|
|
self.as_json(), sort_keys=True, separators=(",", ":")
|
|
).encode("utf-8")
|
|
|
|
def as_prose(self) -> str:
|
|
"""Join setup + step + answer sentences into one paragraph."""
|
|
sentences = list(self.setup_sentences) + list(self.step_sentences)
|
|
sentences.append(self.answer_sentence)
|
|
return " ".join(sentences)
|
|
|
|
|
|
def realize(graph_initial_state: tuple, trace: SolutionTrace) -> RealizedTrace:
|
|
"""Render a :class:`SolutionTrace` as show-your-work prose.
|
|
|
|
``graph_initial_state`` is the input graph's ``initial_state`` tuple
|
|
(used to introduce the entities and their starting quantities).
|
|
``trace`` provides the per-step records and the resolved answer.
|
|
|
|
Pure function; same inputs → byte-identical output. Raises
|
|
:class:`RealizerError` on empty traces or unrecognized step kinds.
|
|
"""
|
|
if not isinstance(trace, SolutionTrace):
|
|
raise RealizerError(
|
|
f"trace must be a SolutionTrace, got {type(trace).__name__}"
|
|
)
|
|
|
|
setup_sentences = tuple(
|
|
_setup_sentence(p.entity, p.quantity.value, p.quantity.unit)
|
|
for p in graph_initial_state
|
|
)
|
|
|
|
step_sentences: list[str] = []
|
|
for step in trace.steps:
|
|
step_sentences.append(_step_sentence(step))
|
|
|
|
answer_sentence = _answer_sentence(
|
|
trace.answer_entity, trace.answer_value, trace.answer_unit
|
|
)
|
|
|
|
return RealizedTrace(
|
|
setup_sentences=setup_sentences,
|
|
step_sentences=tuple(step_sentences),
|
|
answer_sentence=answer_sentence,
|
|
pack_id=trace.pack_id,
|
|
)
|
|
|
|
|
|
def _setup_sentence(entity: str, value: int | float, unit: str) -> str:
|
|
return f"{entity} has {_render_number(value)} {_unit_surface(unit, value)}."
|
|
|
|
|
|
def _step_sentence(step: SolutionStep) -> str:
|
|
if step.operation_kind == "apply_rate":
|
|
return _apply_rate_sentence(step)
|
|
if step.operation_kind == "add":
|
|
return (
|
|
f"{step.actor} buys {_render_number(step.operand.value)} more "
|
|
f"{_unit_surface(step.operand.unit, step.operand.value)}, "
|
|
f"raising the total to {_render_number(step.after_value)}."
|
|
)
|
|
if step.operation_kind == "subtract":
|
|
return (
|
|
f"{step.actor} loses {_render_number(step.operand.value)} "
|
|
f"{_unit_surface(step.operand.unit, step.operand.value)}, "
|
|
f"leaving {_render_number(step.after_value)}."
|
|
)
|
|
if step.operation_kind == "transfer":
|
|
if step.target is None:
|
|
raise RealizerError(
|
|
f"transfer step {step.step_index} missing target"
|
|
)
|
|
return (
|
|
f"{step.actor} gives {_render_number(step.operand.value)} "
|
|
f"{_unit_surface(step.operand.unit, step.operand.value)} to "
|
|
f"{step.target}, leaving {step.actor} with "
|
|
f"{_render_number(step.after_value)}."
|
|
)
|
|
if step.operation_kind == "multiply":
|
|
verb = "doubles" if step.operand.value == 2 else (
|
|
"triples" if step.operand.value == 3 else "multiplies"
|
|
)
|
|
if verb == "multiplies":
|
|
return (
|
|
f"{step.actor} multiplies their "
|
|
f"{_unit_surface(step.operand.unit, 2)} by "
|
|
f"{_render_number(step.operand.value)}, "
|
|
f"reaching {_render_number(step.after_value)}."
|
|
)
|
|
return (
|
|
f"{step.actor} {verb} their "
|
|
f"{_unit_surface(step.operand.unit, 2)}, "
|
|
f"reaching {_render_number(step.after_value)}."
|
|
)
|
|
if step.operation_kind == "divide":
|
|
return (
|
|
f"{step.actor} splits their "
|
|
f"{_unit_surface(step.operand.unit, 2)} evenly into "
|
|
f"{_render_number(step.operand.value)} groups and keeps one "
|
|
f"group, leaving {_render_number(step.after_value)}."
|
|
)
|
|
raise RealizerError(
|
|
f"step {step.step_index} has unknown operation_kind "
|
|
f"{step.operation_kind!r}"
|
|
)
|
|
|
|
|
|
def _apply_rate_sentence(step: SolutionStep) -> str:
|
|
"""Render an apply_rate step as show-your-work prose (ADR-0122).
|
|
|
|
The template intentionally contains both ``"<value> <numer> per
|
|
<denom-singular>"`` (the rate clause) and ``"<after> <numer>"``
|
|
(the computed total), which the test suite pins as a structural
|
|
invariant. The denominator phrase uses singular form (``per
|
|
apple``) regardless of count, matching natural English.
|
|
"""
|
|
if not isinstance(step.operand, Rate):
|
|
raise RealizerError(
|
|
f"apply_rate step {step.step_index} requires a Rate "
|
|
f"operand; got {type(step.operand).__name__}"
|
|
)
|
|
rate = step.operand
|
|
rate_n = _render_number(rate.value)
|
|
before_n = _render_number(step.before_value)
|
|
after_n = _render_number(step.after_value)
|
|
denom_singular = _singular(rate.denominator_unit)
|
|
denom_surface = _unit_surface(rate.denominator_unit, step.before_value)
|
|
return (
|
|
f"At {rate_n} {rate.numerator_unit} per {denom_singular}, "
|
|
f"{step.actor} spends {after_n} {rate.numerator_unit} on "
|
|
f"{before_n} {denom_surface}."
|
|
)
|
|
|
|
|
|
def _answer_sentence(
|
|
entity: str | None, value: int | float, unit: str
|
|
) -> str:
|
|
if entity is None:
|
|
return (
|
|
f"In total, they have {_render_number(value)} "
|
|
f"{_unit_surface(unit, value)}."
|
|
)
|
|
return (
|
|
f"{entity} has {_render_number(value)} "
|
|
f"{_unit_surface(unit, value)}."
|
|
)
|
|
|
|
|
|
def _render_number(value: int | float) -> str:
|
|
"""Render numeric value preferring integer form when exact."""
|
|
if isinstance(value, bool):
|
|
# bool is a subclass of int — refuse explicitly
|
|
raise RealizerError(f"cannot render boolean as number: {value!r}")
|
|
if isinstance(value, float) and value.is_integer():
|
|
return str(int(value))
|
|
return str(value)
|
|
|
|
|
|
def _unit_surface(unit: str, value: int | float) -> str:
|
|
"""Render a unit string in surface form.
|
|
|
|
Quantities of exactly 1 take the singular; all others keep the
|
|
canonical plural. This matches the parser's
|
|
``_canonical_unit`` round-trip — the parser maps singular surfaces
|
|
back to plural at graph time.
|
|
"""
|
|
if value == 1:
|
|
return _singular(unit)
|
|
return unit
|
|
|
|
|
|
def _singular(unit: str) -> str:
|
|
if unit.endswith("ies") and len(unit) > 3:
|
|
return unit[:-3] + "y"
|
|
if unit.endswith("es") and len(unit) > 2 and unit[-3:-2] in {"s", "x", "z"}:
|
|
return unit[:-2]
|
|
if unit.endswith("s") and not unit.endswith("ss"):
|
|
return unit[:-1]
|
|
return unit
|