From 6560300101ad242b8152d633b8c59bd1bf8aa810 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 7 Jun 2026 23:03:30 -0700 Subject: [PATCH] feat(rate): exact integer single-rate solver (R3c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate/rate_comprehension/solver.py: solve_rate computes the one unknown — quantity=rate×time (always integer), rate=quantity÷time, time=quantity÷rate (exact division or REFUSE non_integer_solution, never rounds). Confirms compound-unit composition via the R3a algebra (defensive; a RateProblem's units compose by construction). answer_unit gives the answer's unit. No floats. Ties to the gold: solves all 6 solved fixtures to gold; refuses both solver_refuses with non_integer_solution. Meaningful-fail: 100÷3 refuses (not 33), 99÷3=33. Off-serving. 5 tests. --- generate/rate_comprehension/__init__.py | 3 ++ generate/rate_comprehension/solver.py | 65 +++++++++++++++++++++++++ tests/test_rate_solver.py | 43 ++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 generate/rate_comprehension/solver.py create mode 100644 tests/test_rate_solver.py diff --git a/generate/rate_comprehension/__init__.py b/generate/rate_comprehension/__init__.py index 2988fec6..112aa471 100644 --- a/generate/rate_comprehension/__init__.py +++ b/generate/rate_comprehension/__init__.py @@ -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", ] diff --git a/generate/rate_comprehension/solver.py b/generate/rate_comprehension/solver.py new file mode 100644 index 00000000..97989607 --- /dev/null +++ b/generate/rate_comprehension/solver.py @@ -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"] diff --git a/tests/test_rate_solver.py b/tests/test_rate_solver.py new file mode 100644 index 00000000..8b3e95b9 --- /dev/null +++ b/tests/test_rate_solver.py @@ -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")