Merge pull request #648 from AssetOverflow/feat/r3-2-be

feat(rate): R3.2b–e — time-unit conversion integration (model/solver/reader/gold) [merge LAST]
This commit is contained in:
Shay 2026-06-08 06:03:27 -07:00 committed by GitHub
commit 801f0e23dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 173 additions and 63 deletions

View file

@ -0,0 +1,48 @@
# R3.2 — explicit time-unit conversion (single-rate)
**As of:** R3.2, on `main` + `feat/r3-2-conversion`.
R3.2 adds exactly one capability to the single-rate organ: a duration whose unit **converts** to the
rate's denominator (`minute` ↔ `hour`) is now **solved** instead of refused. It closes the most
common rate gap — `60 miles per hour for 30 minutes → 30 miles` — which the R3.1 loop surfaced as a
proposal.
## Lane state
- R3 gold **13 / 13 valid** (7 solved / 2 solver_refuses / 4 reader_refuses)
- R3 reader **9 setup_correct / 0 setup_wrong / 4 refused** → answers **7 / 0 / 6**
- R1 **7/0/3** · R2 **10/0/0** · serving unchanged · router-hygiene invariant green · off-serving
## Design — Option A (text-faithful model, Fraction in the solver)
- **Model** (`RateProblem`) gains `time_unit` — the duration's **original** unit from the text; `time`
stays the original `int`. `rate_unit.denominator` is the target unit for composition. The setup
remembers what the text said ("30 minutes"), not a normalized internal.
- **Conversion** (`conversion.py`): `convert_time(value, from, to) -> Fraction` — exact rational
(`fractions.Fraction`), identity for the same unit (incl. non-time like `box`), `minute ↔ hour`
otherwise, refuse else. **No floats.**
- **Solver** confines `Fraction`: `rate × convert(time)` (or the inverses), then exact-int-or-refuse
(`non_integer_solution`). A non-convertible duration raises `ConversionError``rate_unit_mismatch`.
- **Reader** accepts a convertible mismatch (keeps the original `time_unit`; the solver converts);
a non-convertible mismatch still refuses `rate_unit_mismatch`.
- **Signature** includes `time_unit` — "30 minutes" and "30 hours" are different setups at the same rate.
## Gold change
- `r3-09` flips **reader_refuses → solved**: "60 mph for 30 minutes" → 30 minute = `Fraction(1,2)` hour
`60 × 1/2 = 30` mile (the never-convert distractor `1800` is rejected).
- New `r3-13` (non-convertible): "…per hour for 3 gallons" → `rate_unit_mismatch` (gallon is not a
time unit). Stays a proposal surface.
## Failure-family / hygiene (confirmed, no code change)
- `rate_unit_mismatch` now fires **only for non-convertible** durations → still maps to the
`unsupported_rate_duration` growth surface (propose adding *that* unit pair). Convertible mismatches
now **solve** (no longer a failure). `temporal_state` / `not_rate_shaped` unchanged.
- The **router-organ-hygiene invariant** stays green — R3 still refuses foreign R1/R2 text as
`input_shape`, never a substantive boundary.
## Acceptance (met)
R3 `setup_correct` **8 → 9** · `answer_wrong = 0` · `r3-09` refused/proposal → solved ·
non-convertible mismatch still refused/proposal · R1/R2 unchanged · **no float path** (Fraction →
int-or-refuse).
## Deferred (named for later)
length (`mile ↔ km`), currency (`dollar ↔ cent`), compound conversions, combined/multi-rate,
clock-time intervals. The dimensional substrate is now proven for exact rational time conversion.

View file

@ -1,12 +1,13 @@
{"id": "r3-01-distance", "expect": "solved", "text": "A car travels 60 miles per hour for 3 hours. How many miles does it travel?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": 60, "time": 3, "quantity": null, "query": "quantity", "gold": 180, "options": {"A": 120, "B": 150, "C": 180, "D": 200}, "answer": "C", "notes": "Forward: quantity = rate × time = 60 mile/hour × 3 hour = 180 mile. Unit composition exact."}
{"id": "r3-02-earnings", "expect": "solved", "text": "A worker earns 15 dollars per hour for 8 hours. How many dollars does she earn?", "rate_unit": {"numerator": "dollar", "denominator": "hour"}, "rate": 15, "time": 8, "quantity": null, "query": "quantity", "gold": 120, "options": {"A": 120, "B": 23, "C": 105, "D": 135}, "answer": "A", "notes": "Forward: 15 dollar/hour × 8 hour = 120 dollar."}
{"id": "r3-03-widgets", "expect": "solved", "text": "A machine makes 12 widgets per minute. It runs for 5 minutes. How many widgets does it make?", "rate_unit": {"numerator": "widget", "denominator": "minute"}, "rate": 12, "time": 5, "quantity": null, "query": "quantity", "gold": 60, "options": {"A": 17, "B": 60, "C": 7, "D": 72}, "answer": "B", "notes": "Forward: 12 widget/minute × 5 minute = 60 widget."}
{"id": "r3-04-items", "expect": "solved", "text": "A packer fills 8 items per box for 6 boxes. How many items does the packer pack?", "rate_unit": {"numerator": "item", "denominator": "box"}, "rate": 8, "time": 6, "quantity": null, "query": "quantity", "gold": 48, "options": {"A": 14, "B": 48, "C": 42, "D": 54}, "answer": "B", "notes": "Forward with a non-temporal denominator (per box): 8 item/box × 6 box = 48 item. The 'time' slot is the rate's denominator, not necessarily a clock unit."}
{"id": "r3-05-speed", "expect": "solved", "text": "A car travels 180 miles in 3 hours. What is its speed in miles per hour?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": null, "time": 3, "quantity": 180, "query": "rate", "gold": 60, "options": {"A": 540, "B": 60, "C": 90, "D": 30}, "answer": "B", "notes": "Inverse (find rate): rate = quantity ÷ time = 180 mile ÷ 3 hour = 60 mile/hour. Exact division."}
{"id": "r3-06-runtime", "expect": "solved", "text": "A machine makes 60 widgets at 12 widgets per minute. How many minutes did it run?", "rate_unit": {"numerator": "widget", "denominator": "minute"}, "rate": 12, "time": null, "quantity": 60, "query": "time", "gold": 5, "options": {"A": 5, "B": 720, "C": 48, "D": 6}, "answer": "A", "notes": "Inverse (find time): time = quantity ÷ rate = 60 widget ÷ 12 widget/minute = 5 minute. Exact division."}
{"id": "r3-07-non-integer-rate", "expect": "solver_refuses", "solver_reason": "non_integer_solution", "text": "A car travels 100 miles in 3 hours. What is its speed in miles per hour?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": null, "time": 3, "quantity": 100, "query": "rate", "gold": null, "notes": "Inverse with a non-exact division: 100 ÷ 3 is not an integer. The solver REFUSES (non_integer_solution), never rounds to 33."}
{"id": "r3-08-non-integer-time", "expect": "solver_refuses", "solver_reason": "non_integer_solution", "text": "A machine makes 50 widgets at 12 widgets per minute. How many minutes did it run?", "rate_unit": {"numerator": "widget", "denominator": "minute"}, "rate": 12, "time": null, "quantity": 50, "query": "time", "gold": null, "notes": "Inverse with a non-exact division: 50 ÷ 12 is not an integer. The solver REFUSES (non_integer_solution)."}
{"id": "r3-09-unit-mismatch", "expect": "reader_refuses", "reader_reason": "rate_unit_mismatch", "text": "A car travels 60 miles per hour for 30 minutes. How many miles does it travel?", "gold": null, "notes": "The rate is per HOUR but the duration is in MINUTES: mile/hour × minute does not compose. v1 does NOT silently convert minutes to hours (that would introduce rational quantities + unit conversion). The reader must REFUSE (rate_unit_mismatch)."}
{"id": "r3-10-missing-time", "expect": "reader_refuses", "reader_reason": "missing_time", "text": "A car travels 60 miles per hour. How many miles does it travel?", "gold": null, "notes": "Only the rate is given — no duration. With one known the equation is underdetermined; the reader must REFUSE (missing_time), never invent a time."}
{"id": "r3-11-combined-rates", "expect": "reader_refuses", "reader_reason": "combined_rates", "text": "A car travels 60 miles per hour for 2 hours and then 40 miles per hour for 3 hours. How many miles does it travel?", "gold": null, "notes": "Two rates over two segments — combined/multi-rate is R3.2, not single-rate v1. The reader must REFUSE (combined_rates) rather than read one segment."}
{"id": "r3-12-temporal-state", "expect": "reader_refuses", "reader_reason": "temporal_state", "text": "A car left at 2 pm and arrived at 5 pm traveling 60 miles per hour. How many miles did it travel?", "gold": null, "notes": "Elapsed clock-time (2 pm to 5 pm) is a temporal-state computation, not an explicit duration. v1 does NOT compute elapsed intervals; the reader must REFUSE (temporal_state)."}
{"id": "r3-01-distance", "expect": "solved", "text": "A car travels 60 miles per hour for 3 hours. How many miles does it travel?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": 60, "time": 3, "time_unit": "hour", "quantity": null, "query": "quantity", "gold": 180, "options": {"A": 120, "B": 150, "C": 180, "D": 200}, "answer": "C", "notes": "Forward, no conversion: 60 mile/hour × 3 hour = 180 mile."}
{"id": "r3-02-earnings", "expect": "solved", "text": "A worker earns 15 dollars per hour for 8 hours. How many dollars does she earn?", "rate_unit": {"numerator": "dollar", "denominator": "hour"}, "rate": 15, "time": 8, "time_unit": "hour", "quantity": null, "query": "quantity", "gold": 120, "options": {"A": 120, "B": 23, "C": 105, "D": 135}, "answer": "A", "notes": "15 dollar/hour × 8 hour = 120 dollar."}
{"id": "r3-03-widgets", "expect": "solved", "text": "A machine makes 12 widgets per minute. It runs for 5 minutes. How many widgets does it make?", "rate_unit": {"numerator": "widget", "denominator": "minute"}, "rate": 12, "time": 5, "time_unit": "minute", "quantity": null, "query": "quantity", "gold": 60, "options": {"A": 17, "B": 60, "C": 7, "D": 72}, "answer": "B", "notes": "12 widget/minute × 5 minute = 60 widget."}
{"id": "r3-04-items", "expect": "solved", "text": "A packer fills 8 items per box for 6 boxes. How many items does the packer pack?", "rate_unit": {"numerator": "item", "denominator": "box"}, "rate": 8, "time": 6, "time_unit": "box", "quantity": null, "query": "quantity", "gold": 48, "options": {"A": 14, "B": 48, "C": 42, "D": 54}, "answer": "B", "notes": "Non-temporal denominator (per box): 8 item/box × 6 box = 48 item. The denominator is not a time unit, so conversion is the identity."}
{"id": "r3-05-speed", "expect": "solved", "text": "A car travels 180 miles in 3 hours. What is its speed in miles per hour?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": null, "time": 3, "time_unit": "hour", "quantity": 180, "query": "rate", "gold": 60, "options": {"A": 540, "B": 60, "C": 90, "D": 30}, "answer": "B", "notes": "Inverse (rate): 180 mile ÷ 3 hour = 60 mile/hour."}
{"id": "r3-06-runtime", "expect": "solved", "text": "A machine makes 60 widgets at 12 widgets per minute. How many minutes did it run?", "rate_unit": {"numerator": "widget", "denominator": "minute"}, "rate": 12, "time": null, "time_unit": "minute", "quantity": 60, "query": "time", "gold": 5, "options": {"A": 5, "B": 720, "C": 48, "D": 6}, "answer": "A", "notes": "Inverse (time, answered in the rate denominator unit): 60 widget ÷ 12 widget/minute = 5 minute."}
{"id": "r3-09-convertible", "expect": "solved", "text": "A car travels 60 miles per hour for 30 minutes. How many miles does it travel?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": 60, "time": 30, "time_unit": "minute", "quantity": null, "query": "quantity", "gold": 30, "options": {"A": 30, "B": 1800, "C": 15, "D": 90}, "answer": "A", "notes": "R3.2 conversion: the rate is per HOUR but the duration is 30 MINUTES, which converts EXACTLY (30 minute = 1/2 hour). 60 mile/hour × 1/2 hour = 30 mile. The conversion is exact rational (Fraction), confined to the solver; the never-convert distractor 1800 (60×30) is rejected."}
{"id": "r3-07-non-integer-rate", "expect": "solver_refuses", "solver_reason": "non_integer_solution", "text": "A car travels 100 miles in 3 hours. What is its speed in miles per hour?", "rate_unit": {"numerator": "mile", "denominator": "hour"}, "rate": null, "time": 3, "time_unit": "hour", "quantity": 100, "query": "rate", "gold": null, "notes": "100 ÷ 3 is not an integer. REFUSE (non_integer_solution), never round."}
{"id": "r3-08-non-integer-time", "expect": "solver_refuses", "solver_reason": "non_integer_solution", "text": "A machine makes 50 widgets at 12 widgets per minute. How many minutes did it run?", "rate_unit": {"numerator": "widget", "denominator": "minute"}, "rate": 12, "time": null, "time_unit": "minute", "quantity": 50, "query": "time", "gold": null, "notes": "50 ÷ 12 is not an integer. REFUSE (non_integer_solution)."}
{"id": "r3-10-missing-time", "expect": "reader_refuses", "reader_reason": "missing_time", "text": "A car travels 60 miles per hour. How many miles does it travel?", "gold": null, "notes": "Rate but no duration — underdetermined. REFUSE (missing_time)."}
{"id": "r3-11-combined-rates", "expect": "reader_refuses", "reader_reason": "combined_rates", "text": "A car travels 60 miles per hour for 2 hours and then 40 miles per hour for 3 hours. How many miles does it travel?", "gold": null, "notes": "Two rates / segments — multi-rate is R3.x. REFUSE (combined_rates)."}
{"id": "r3-12-temporal-state", "expect": "reader_refuses", "reader_reason": "temporal_state", "text": "A car left at 2 pm and arrived at 5 pm traveling 60 miles per hour. How many miles did it travel?", "gold": null, "notes": "Elapsed clock time, not an explicit duration. REFUSE (temporal_state)."}
{"id": "r3-13-non-convertible", "expect": "reader_refuses", "reader_reason": "rate_unit_mismatch", "text": "A car travels 60 miles per hour for 3 gallons. How many miles does it travel?", "gold": null, "notes": "The rate is per HOUR but the duration is in GALLONS — gallon is not a time unit and does not convert to hour. v1 REFUSES (rate_unit_mismatch); this stays a proposal surface (rate-like but non-composing), distinct from the convertible minute↔hour case which now solves."}

View file

@ -60,6 +60,7 @@ def gold_to_problem(fx: dict[str, Any]) -> RateProblem:
time=fx.get("time"),
quantity=fx.get("quantity"),
query=fx["query"],
time_unit=fx.get("time_unit"),
)

View file

@ -30,6 +30,9 @@ def rate_setup_signature(problem: RateProblem) -> dict[str, Any]:
"rate_unit": (problem.rate_unit.numerator, problem.rate_unit.denominator),
"knowns": knowns,
"query": problem.query,
# The duration's ORIGINAL unit is part of the setup: "30 minutes" and "30 hours" are
# different problems even at the same rate, so the signature must distinguish them (R3.2).
"time_unit": problem.time_unit,
}

View file

@ -35,13 +35,16 @@ _TIME_IN_HOURS: dict[str, Fraction] = {
def is_convertible(from_unit: str, to_unit: str) -> bool:
"""Whether *from_unit* and *to_unit* are both known time units (so a conversion exists)."""
return from_unit in _TIME_IN_HOURS and to_unit in _TIME_IN_HOURS
"""Whether a conversion exists — the identity (same unit, any kind) or a known time-unit pair."""
return from_unit == to_unit or (from_unit in _TIME_IN_HOURS and to_unit in _TIME_IN_HOURS)
def convert_time(value: int, from_unit: str, to_unit: str) -> Fraction:
"""Exact rational conversion of *value* ``from_unit`` into *to_unit*. Refuses unknown units."""
if not is_convertible(from_unit, to_unit):
"""Exact rational conversion of *value* ``from_unit`` into *to_unit*. Identity for the same unit
(any kind, e.g. ``box``); a known time-unit pair otherwise; refuses anything else."""
if from_unit == to_unit:
return Fraction(value)
if from_unit not in _TIME_IN_HOURS or to_unit not in _TIME_IN_HOURS:
raise ConversionError(f"no exact conversion {from_unit!r} -> {to_unit!r}")
return Fraction(value) * _TIME_IN_HOURS[from_unit] / _TIME_IN_HOURS[to_unit]

View file

@ -29,8 +29,14 @@ class RateProblem:
time: int | None
quantity: int | None
query: RateRole
#: The duration's ORIGINAL time unit from the text (R3.2). Defaults to the rate's denominator
#: (the non-converting case); a convertible duration (e.g. ``minute`` vs a ``/hour`` rate)
#: keeps its original unit here and the SOLVER converts it. ``time`` stays the original int.
time_unit: str | None = None
def __post_init__(self) -> None:
if self.time_unit is None:
object.__setattr__(self, "time_unit", self.rate_unit.denominator)
slots: dict[str, int | None] = {"rate": self.rate, "time": self.time, "quantity": self.quantity}
unknown = [role for role, value in slots.items() if value is None]
if unknown != [self.query]:
@ -45,9 +51,5 @@ class RateProblem:
def quantity_unit(self) -> str:
return self.rate_unit.numerator
@property
def time_unit(self) -> str:
return self.rate_unit.denominator
__all__ = ["RateProblem", "RateRole"]

View file

@ -21,6 +21,7 @@ from __future__ import annotations
import re
from generate.meaning_graph.reader import Refusal
from generate.rate_comprehension.conversion import is_convertible
from generate.rate_comprehension.model import RateProblem
from generate.rate_comprehension.units import RateUnit
@ -99,31 +100,34 @@ def read_rate_problem(text: str) -> RateProblem | Refusal:
else:
return Refusal("no_query")
# compound-unit consistency (the wrong=0 gate)
if time_value is not None and time_unit != rate_unit.denominator:
return Refusal("rate_unit_mismatch", f"duration {time_unit!r} ≠ rate denominator {rate_unit.denominator!r}")
# compound-unit consistency (the wrong=0 gate). R3.2: a duration whose unit CONVERTS to the
# rate denominator (minute↔hour) is accepted — the reader keeps the original time_unit and the
# SOLVER converts exactly. A non-convertible duration (e.g. gallons) still refuses.
if time_value is not None and not is_convertible(time_unit, rate_unit.denominator):
return Refusal("rate_unit_mismatch", f"duration {time_unit!r} does not convert to {rate_unit.denominator!r}")
if quantity_value is not None and quantity_unit != rate_unit.numerator:
return Refusal("rate_unit_mismatch", f"quantity {quantity_unit!r} ≠ rate numerator {rate_unit.numerator!r}")
# assemble by query (refusing an underdetermined setup)
# assemble by query (refusing an underdetermined setup); time_unit is the duration's ORIGINAL
# unit (the solver converts), or the rate denominator when the time is the unknown.
if query == "quantity":
if rate_value is None:
return Refusal("missing_rate")
if time_value is None:
return Refusal("missing_time")
return RateProblem(rate_unit, rate_value, time_value, None, "quantity")
return RateProblem(rate_unit, rate_value, time_value, None, "quantity", time_unit=time_unit)
if query == "rate":
if quantity_value is None:
return Refusal("missing_quantity")
if time_value is None:
return Refusal("missing_time")
return RateProblem(rate_unit, None, time_value, quantity_value, "rate")
return RateProblem(rate_unit, None, time_value, quantity_value, "rate", time_unit=time_unit)
# query == "time"
if rate_value is None:
return Refusal("missing_rate")
if quantity_value is None:
return Refusal("missing_quantity")
return RateProblem(rate_unit, rate_value, None, quantity_value, "time")
return RateProblem(rate_unit, rate_value, None, quantity_value, "time", time_unit=rate_unit.denominator)
__all__ = ["read_rate_problem"]

View file

@ -1,64 +1,63 @@
"""Exact integer single-rate solver (R3c).
"""Exact integer single-rate solver (R3c) with exact-rational unit conversion (R3.2c).
Solves the one unknown of a ``RateProblem`` ``quantity = rate × time`` and its two inverses
with **exact integer arithmetic** and a **unit-composition** confirmation:
normalizing the duration to the rate's denominator unit by **exact rational conversion** (R3.2):
```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)
query quantity: quantity = rate × convert(time) (exact int or REFUSE)
query rate: rate = quantity ÷ convert(time) (exact int or REFUSE)
query time: time = quantity ÷ rate (in the rate denominator unit) (exact int 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.
``Fraction`` is confined here: ``convert_time`` returns an exact rational, the arithmetic is
exact, and a non-whole result REFUSES (``non_integer_solution``) **no float, no rounding**. A
duration unit that does not convert to the rate denominator (``/hour`` for ``3 gallons``) raises
``ConversionError`` and REFUSES (``rate_unit_mismatch``). Off-serving; deterministic.
"""
from __future__ import annotations
from fractions import Fraction
from generate.meaning_graph.reader import Refusal
from generate.rate_comprehension.conversion import ConversionError, convert_time
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,
)
from generate.rate_comprehension.units import RateUnit
def _exact_int(value: Fraction) -> int | Refusal:
"""An exact integer, or a typed refusal — never a rounded/floored approximation."""
if value.denominator != 1:
return Refusal("non_integer_solution", str(value))
return int(value)
def solve_rate(problem: RateProblem) -> int | Refusal:
"""Solve the unknown slot exactly, or refuse a non-integer inverse."""
"""Solve the unknown slot exactly (converting the duration as needed), or refuse."""
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
time = convert_time(problem.time, problem.time_unit, ru.denominator)
return _exact_int(Fraction(problem.rate) * 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)
time = convert_time(problem.time, problem.time_unit, ru.denominator)
return _exact_int(Fraction(problem.quantity) / time)
# query == "time" — answered in the rate's denominator unit; the duration is the unknown.
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 _exact_int(Fraction(problem.quantity) / Fraction(problem.rate))
except ConversionError as exc:
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."""
"""The unit of the answer — the rate numerator (quantity), the rate denominator (time), or the
full ``RateUnit`` (rate)."""
if problem.query == "quantity":
return problem.quantity_unit
if problem.query == "time":
return problem.time_unit
return problem.rate_unit.denominator
return problem.rate_unit

View file

@ -34,7 +34,7 @@ def test_router_routes_rate_to_r3_and_stays_exclusive() -> None:
if r.selected is not None:
assert r.selected.organ == "r3_rate"
routed += 1
assert routed == 8 # 6 solved + 2 solver_refuses
assert routed == 9 # 7 solved (incl. convertible r3-09) + 2 solver_refuses
# adding R3 does not make any R1/R2 problem ambiguous, nor route it to r3
for fx in _load_r1_gold() + _load_r2_gold():
r = route_setup(fx["text"])

View file

@ -0,0 +1,49 @@
"""End-to-end conversion solving (R3.2c/d) — the extra cases pinned in the R3.2 plan."""
from __future__ import annotations
from fractions import Fraction
from generate.meaning_graph.reader import Refusal
from generate.rate_comprehension.model import RateProblem
from generate.rate_comprehension.reader import read_rate_problem
from generate.rate_comprehension.solver import solve_rate
from generate.rate_comprehension.units import RateUnit
_MPH = RateUnit("mile", "hour")
def test_30_minutes_at_60_mph_is_30_miles() -> None:
assert solve_rate(RateProblem(_MPH, 60, 30, None, "quantity", time_unit="minute")) == 30
def test_90_minutes_at_60_mph_is_90_miles() -> None:
assert solve_rate(RateProblem(_MPH, 60, 90, None, "quantity", time_unit="minute")) == 90
def test_45_minutes_at_10_mph_is_non_integer_refused() -> None:
# 10 mile/hour × 45/60 hour = 15/2 = 7.5 -> REFUSE (never 7 or 8).
out = solve_rate(RateProblem(_MPH, 10, 45, None, "quantity", time_unit="minute"))
assert isinstance(out, Refusal) and out.reason == "non_integer_solution"
def test_minutes_rate_needs_no_conversion() -> None:
# 60 mile/minute for 30 minutes -> identity, 60 × 30 = 1800 mile.
assert solve_rate(RateProblem(RateUnit("mile", "minute"), 60, 30, None, "quantity", time_unit="minute")) == 1800
def test_non_convertible_duration_refuses_at_reader() -> None:
out = read_rate_problem("A car travels 60 miles per hour for 3 gallons. How many miles does it travel?")
assert isinstance(out, Refusal) and out.reason == "rate_unit_mismatch"
def test_convertible_duration_reads_and_solves_end_to_end() -> None:
problem = read_rate_problem("A car travels 60 miles per hour for 30 minutes. How many miles does it travel?")
assert not isinstance(problem, Refusal)
assert problem.time == 30 and problem.time_unit == "minute" # text-faithful: stored as minutes
assert solve_rate(problem) == 30
def test_solver_answer_is_int_never_float() -> None:
result = solve_rate(RateProblem(_MPH, 60, 90, None, "quantity", time_unit="minute"))
assert isinstance(result, int) and not isinstance(result, (float, Fraction))

View file

@ -29,8 +29,8 @@ def _solved() -> dict:
def test_run_validates_all_gold() -> None:
r = run()
assert r["invalid"] == 0 and r["valid"] == r["total"] == 12
assert r["by_expect"] == {"solved": 6, "solver_refuses": 2, "reader_refuses": 4}
assert r["invalid"] == 0 and r["valid"] == r["total"] == 13
assert r["by_expect"] == {"solved": 7, "solver_refuses": 2, "reader_refuses": 4}
def test_gold_to_problem_roundtrips() -> None:

View file

@ -23,7 +23,7 @@ def test_reader_lane_is_wrong_zero_and_complete() -> None:
r = run_reader()
assert r["setup_wrong"] == 0 and r["reason_mismatch"] == 0
assert r["setup_refused"] == 0
assert r["setup_correct"] == 8 # 6 solved + 2 solver_refuses (both have valid setups)
assert r["setup_correct"] == 9 # 7 solved (incl. the convertible r3-09) + 2 solver_refuses
assert r["refused_correct"] == 4