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:
commit
801f0e23dd
12 changed files with 173 additions and 63 deletions
48
docs/analysis/r3-2-unit-conversion-2026-06-08.md
Normal file
48
docs/analysis/r3-2-unit-conversion-2026-06-08.md
Normal 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.
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
49
tests/test_rate_conversion_solving.py
Normal file
49
tests/test_rate_conversion_solving.py
Normal 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))
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue