Merge pull request #647 from AssetOverflow/feat/r3-2-a

feat(rate): R3.2a — exact-rational time-unit conversion primitive [merge after #646]
This commit is contained in:
Shay 2026-06-08 06:03:23 -07:00 committed by GitHub
commit 7e39357e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 91 additions and 0 deletions

View file

@ -0,0 +1,49 @@
"""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 (milekm), no currency
(dollarcent), 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 *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
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):
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"]

View file

@ -0,0 +1,42 @@
"""Tests for the R3.2 exact-rational time-unit conversion primitive."""
from __future__ import annotations
from fractions import Fraction
import pytest
from generate.rate_comprehension.conversion import ConversionError, convert_time, is_convertible
def test_minutes_to_hours_is_exact_rational() -> None:
assert convert_time(30, "minute", "hour") == Fraction(1, 2)
assert convert_time(90, "minute", "hour") == Fraction(3, 2)
assert convert_time(60, "minute", "hour") == Fraction(1) # exact whole
def test_hours_to_minutes() -> None:
assert convert_time(2, "hour", "minute") == Fraction(120)
def test_identity_conversion() -> None:
assert convert_time(5, "hour", "hour") == Fraction(5)
def test_non_time_units_refuse() -> None:
with pytest.raises(ConversionError):
convert_time(30, "minute", "mile")
with pytest.raises(ConversionError):
convert_time(5, "dollar", "hour") # currency deferred
def test_is_convertible() -> None:
assert is_convertible("minute", "hour") and is_convertible("hour", "minute")
assert not is_convertible("minute", "mile")
assert not is_convertible("kilometer", "mile") # length deferred
def test_result_is_never_a_float() -> None:
result = convert_time(45, "minute", "hour")
assert isinstance(result, Fraction) and not isinstance(result, float)
assert result == Fraction(3, 4)