core/generate/rate_comprehension/conversion.py
Shay 1c59f331f4 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.
2026-06-08 05:43:51 -07:00

52 lines
2.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Exact rational time-unit conversion for R3.2 (single-rate only).
The single new capability of R3.2: a duration whose unit differs from the rate's denominator may be
**converted** when both are known time units (``minute`` ↔ ``hour``), instead of refusing. The
conversion is **exact rational** (``fractions.Fraction``) — never a float, never a decimal:
```text
30 minutes -> Fraction(1, 2) hour
90 minutes -> Fraction(3, 2) hour
2 hours -> Fraction(120) minute
```
So ``60 mile/hour × 30 minute`` becomes ``60 × 1/2 = 30 mile`` (exact). A non-time / non-convertible
pair (``minute`` ↔ ``mile``) raises ``ConversionError`` and the caller still refuses
``rate_unit_mismatch`` — convertibility is the only thing R3.2 adds.
Deliberately tiny: ONLY ``minute`` ↔ ``hour`` in v1. No length (mile↔km), no currency
(dollar↔cent), no compound conversions, no clock-time intervals. Deterministic.
"""
from __future__ import annotations
from fractions import Fraction
class ConversionError(ValueError):
"""The units are not a known convertible pair — the caller must refuse."""
#: Each known time unit as an exact rational number of the base unit (one hour).
_TIME_IN_HOURS: dict[str, Fraction] = {
"hour": Fraction(1),
"minute": Fraction(1, 60),
}
def is_convertible(from_unit: str, to_unit: str) -> bool:
"""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*. 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]
__all__ = ["ConversionError", "convert_time", "is_convertible"]