From 1c59f331f4053055c0a18260c276268705be1f76 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 8 Jun 2026 05:43:51 -0700 Subject: [PATCH] feat(rate): exact time-unit conversion for single-rate (R3.2b-e) Option A (text-faithful): RateProblem gains time_unit (the duration's ORIGINAL unit; time stays int; defaults to the rate denominator). The SOLVER converts via convert_time -> exact Fraction -> int-or-refuse(non_integer); Fraction never leaves the solver, no floats. The reader accepts a convertible (minute<->hour) duration mismatch (keeps original time_unit); a non-convertible one still refuses rate_unit_mismatch. Signature includes time_unit ('30 minutes' != '30 hours'). Gold: r3-09 flips reader_refuses -> SOLVED (60 mph for 30 min = 30 mile, exact 1/2 hour; distractor 1800 rejected); new r3-13 non-convertible (3 gallons) stays rate_unit_mismatch. R3 gold 13/13; reader 9/0/0 -> answers 7/0/6 (setup_correct 8->9). Failure-family: rate_unit_mismatch now non-convertible-only -> still unsupported_rate_duration proposal surface; router-hygiene invariant stays green; R1 7/0/3, R2 10/0/0 unchanged. 119-test smoke green incl. invariants. Off-serving, no float path. --- .../r3-2-unit-conversion-2026-06-08.md | 48 ++++++++++++++ evals/rate_oracle/rate_gold.jsonl | 25 ++++---- evals/rate_oracle/runner.py | 1 + evals/rate_oracle/signature.py | 3 + generate/rate_comprehension/conversion.py | 11 ++-- generate/rate_comprehension/model.py | 10 +-- generate/rate_comprehension/reader.py | 18 +++--- generate/rate_comprehension/solver.py | 63 +++++++++---------- tests/test_r3_router_contemplation.py | 2 +- tests/test_rate_conversion_solving.py | 49 +++++++++++++++ tests/test_rate_oracle.py | 4 +- tests/test_rate_reader.py | 2 +- 12 files changed, 173 insertions(+), 63 deletions(-) create mode 100644 docs/analysis/r3-2-unit-conversion-2026-06-08.md create mode 100644 tests/test_rate_conversion_solving.py diff --git a/docs/analysis/r3-2-unit-conversion-2026-06-08.md b/docs/analysis/r3-2-unit-conversion-2026-06-08.md new file mode 100644 index 00000000..02676f4d --- /dev/null +++ b/docs/analysis/r3-2-unit-conversion-2026-06-08.md @@ -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. diff --git a/evals/rate_oracle/rate_gold.jsonl b/evals/rate_oracle/rate_gold.jsonl index 3a9709b9..40a2dad9 100644 --- a/evals/rate_oracle/rate_gold.jsonl +++ b/evals/rate_oracle/rate_gold.jsonl @@ -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."} diff --git a/evals/rate_oracle/runner.py b/evals/rate_oracle/runner.py index 1499bffe..f4a55b19 100644 --- a/evals/rate_oracle/runner.py +++ b/evals/rate_oracle/runner.py @@ -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"), ) diff --git a/evals/rate_oracle/signature.py b/evals/rate_oracle/signature.py index 8a9f0fa1..c6538578 100644 --- a/evals/rate_oracle/signature.py +++ b/evals/rate_oracle/signature.py @@ -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, } diff --git a/generate/rate_comprehension/conversion.py b/generate/rate_comprehension/conversion.py index cba2aaa0..866dd158 100644 --- a/generate/rate_comprehension/conversion.py +++ b/generate/rate_comprehension/conversion.py @@ -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] diff --git a/generate/rate_comprehension/model.py b/generate/rate_comprehension/model.py index d2ddc3fd..d0d023b3 100644 --- a/generate/rate_comprehension/model.py +++ b/generate/rate_comprehension/model.py @@ -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"] diff --git a/generate/rate_comprehension/reader.py b/generate/rate_comprehension/reader.py index 455be34f..da7b8ce1 100644 --- a/generate/rate_comprehension/reader.py +++ b/generate/rate_comprehension/reader.py @@ -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"] diff --git a/generate/rate_comprehension/solver.py b/generate/rate_comprehension/solver.py index 97989607..91f6c9b1 100644 --- a/generate/rate_comprehension/solver.py +++ b/generate/rate_comprehension/solver.py @@ -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 diff --git a/tests/test_r3_router_contemplation.py b/tests/test_r3_router_contemplation.py index 7dfd93e7..82a01cea 100644 --- a/tests/test_r3_router_contemplation.py +++ b/tests/test_r3_router_contemplation.py @@ -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"]) diff --git a/tests/test_rate_conversion_solving.py b/tests/test_rate_conversion_solving.py new file mode 100644 index 00000000..f3ea8528 --- /dev/null +++ b/tests/test_rate_conversion_solving.py @@ -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)) diff --git a/tests/test_rate_oracle.py b/tests/test_rate_oracle.py index 98644069..03de8ce5 100644 --- a/tests/test_rate_oracle.py +++ b/tests/test_rate_oracle.py @@ -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: diff --git a/tests/test_rate_reader.py b/tests/test_rate_reader.py index e2327780..97eb9665 100644 --- a/tests/test_rate_reader.py +++ b/tests/test_rate_reader.py @@ -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