Merge pull request #642 from AssetOverflow/feat/r3-c

feat(rate): R3c — exact integer single-rate solver [merge after b]
This commit is contained in:
Shay 2026-06-08 01:56:30 -07:00 committed by GitHub
commit 54c9f60442
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 111 additions and 0 deletions

View file

@ -13,6 +13,7 @@ conversion, no multi-equation systems. Those are later R3 slices.
from __future__ import annotations
from generate.rate_comprehension.model import RateProblem, RateRole
from generate.rate_comprehension.solver import answer_unit, solve_rate
from generate.rate_comprehension.units import (
BaseUnit,
RateUnit,
@ -28,7 +29,9 @@ __all__ = [
"RateRole",
"RateUnit",
"UnitError",
"answer_unit",
"rate_from_quantity_over_time",
"rate_times_time",
"solve_rate",
"time_from_quantity_over_rate",
]

View file

@ -0,0 +1,65 @@
"""Exact integer single-rate solver (R3c).
Solves the one unknown of a ``RateProblem`` ``quantity = rate × time`` and its two inverses
with **exact integer arithmetic** and a **unit-composition** confirmation:
```text
query quantity: quantity = rate × time (always integer)
query rate: rate = quantity ÷ time (exact division or REFUSE)
query time: time = quantity ÷ rate (exact division or REFUSE)
```
No floats, no rounding. A non-exact inverse REFUSES (``non_integer_solution``) rather than
flooring to a wrong integer the wrong=0 boundary. The compound-unit composition is confirmed via
the unit algebra (R3a); since a ``RateProblem``'s units are consistent by construction, that check
is defensive (a non-composing unit is caught earlier, at the reader). Off-serving; deterministic.
"""
from __future__ import annotations
from generate.meaning_graph.reader import Refusal
from generate.rate_comprehension.model import RateProblem
from generate.rate_comprehension.units import (
BaseUnit,
RateUnit,
UnitError,
rate_from_quantity_over_time,
rate_times_time,
time_from_quantity_over_rate,
)
def solve_rate(problem: RateProblem) -> int | Refusal:
"""Solve the unknown slot exactly, or refuse a non-integer inverse."""
ru = problem.rate_unit
try:
if problem.query == "quantity":
rate_times_time(ru, BaseUnit(problem.time_unit)) # confirm mile/hour × hour = mile
assert problem.rate is not None and problem.time is not None
return problem.rate * problem.time
if problem.query == "rate":
rate_from_quantity_over_time(BaseUnit(problem.quantity_unit), BaseUnit(problem.time_unit))
assert problem.quantity is not None and problem.time is not None
if problem.quantity % problem.time != 0:
return Refusal("non_integer_solution", f"{problem.quantity} ÷ {problem.time} (rate)")
return problem.quantity // problem.time
# query == "time"
time_from_quantity_over_rate(BaseUnit(problem.quantity_unit), ru)
assert problem.quantity is not None and problem.rate is not None
if problem.quantity % problem.rate != 0:
return Refusal("non_integer_solution", f"{problem.quantity} ÷ {problem.rate} (time)")
return problem.quantity // problem.rate
except UnitError as exc: # pragma: no cover - a RateProblem's units compose by construction
return Refusal("rate_unit_mismatch", str(exc))
def answer_unit(problem: RateProblem) -> str | RateUnit:
"""The unit of the answer to *problem* — a base unit string, or a ``RateUnit`` when asking rate."""
if problem.query == "quantity":
return problem.quantity_unit
if problem.query == "time":
return problem.time_unit
return problem.rate_unit
__all__ = ["answer_unit", "solve_rate"]

43
tests/test_rate_solver.py Normal file
View file

@ -0,0 +1,43 @@
"""Tests for the exact integer single-rate solver (R3c).
Ties the solver to the R3 gold: every ``solved`` fixture computes its ``gold``; every
``solver_refuses`` fixture refuses with the gold-claimed reason. Exact-or-refuse, never rounds.
"""
from __future__ import annotations
from evals.rate_oracle.runner import _load_rate_gold, gold_to_problem
from generate.meaning_graph.reader import Refusal
from generate.rate_comprehension.model import RateProblem
from generate.rate_comprehension.solver import answer_unit, solve_rate
from generate.rate_comprehension.units import RateUnit
def test_solver_solves_every_solved_fixture() -> None:
for fx in (f for f in _load_rate_gold() if f["expect"] == "solved"):
assert solve_rate(gold_to_problem(fx)) == fx["gold"], fx["id"]
def test_solver_refuses_every_solver_refuse_fixture_with_reason() -> None:
for fx in (f for f in _load_rate_gold() if f["expect"] == "solver_refuses"):
out = solve_rate(gold_to_problem(fx))
assert isinstance(out, Refusal) and out.reason == fx["solver_reason"], fx["id"]
def test_forward_product_is_always_integer() -> None:
assert solve_rate(RateProblem(RateUnit("mile", "hour"), 7, 9, None, "quantity")) == 63
def test_non_exact_inverse_refuses_never_rounds() -> None:
# 100 mile ÷ 3 hour = 33.3… → refuse (not 33).
out = solve_rate(RateProblem(RateUnit("mile", "hour"), None, 3, 100, "rate"))
assert isinstance(out, Refusal) and out.reason == "non_integer_solution"
# one less mile is exact → 99 ÷ 3 = 33.
assert solve_rate(RateProblem(RateUnit("mile", "hour"), None, 3, 99, "rate")) == 33
def test_answer_unit_by_query() -> None:
rp = lambda q, **kw: RateProblem(RateUnit("mile", "hour"), kw.get("rate"), kw.get("time"), kw.get("quantity"), q) # noqa: E731
assert answer_unit(rp("quantity", rate=60, time=3)) == "mile"
assert answer_unit(rp("time", rate=60, quantity=180)) == "hour"
assert answer_unit(rp("rate", time=3, quantity=180)) == RateUnit("mile", "hour")