diff --git a/generate/rate_comprehension/conversion.py b/generate/rate_comprehension/conversion.py new file mode 100644 index 00000000..cba2aaa0 --- /dev/null +++ b/generate/rate_comprehension/conversion.py @@ -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 (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 *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"] diff --git a/tests/test_rate_conversion.py b/tests/test_rate_conversion.py new file mode 100644 index 00000000..49d4d2be --- /dev/null +++ b/tests/test_rate_conversion.py @@ -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)