diff --git a/generate/rate_comprehension/__init__.py b/generate/rate_comprehension/__init__.py new file mode 100644 index 00000000..cd7d6021 --- /dev/null +++ b/generate/rate_comprehension/__init__.py @@ -0,0 +1,31 @@ +"""R3 single-rate comprehension organ (off-serving). + +A fresh, isolated organ for the R3 capability family: explicit single-rate integer problems with +exact compound-unit composition — ``quantity = rate × time`` and its two inverses. Its new +substrate is the compound-unit algebra (``units``); the gold/oracle/solver/reader build on it the +same disciplined way R2 did. Disjoint from the GSM8K serving path (imports no +``generate.derivation`` / ``core.reliability_gate``), so it cannot regress the serving metric. + +R3 v1 supports ONLY single-rate problems — no combined rates, no temporal state, no unit +conversion, no multi-equation systems. Those are later R3 slices. +""" + +from __future__ import annotations + +from generate.rate_comprehension.units import ( + BaseUnit, + RateUnit, + UnitError, + rate_from_quantity_over_time, + rate_times_time, + time_from_quantity_over_rate, +) + +__all__ = [ + "BaseUnit", + "RateUnit", + "UnitError", + "rate_from_quantity_over_time", + "rate_times_time", + "time_from_quantity_over_rate", +] diff --git a/generate/rate_comprehension/units.py b/generate/rate_comprehension/units.py new file mode 100644 index 00000000..9791e241 --- /dev/null +++ b/generate/rate_comprehension/units.py @@ -0,0 +1,90 @@ +"""Compound-unit algebra for the R3 single-rate organ (R3a). + +The genuinely new substrate of R3: a **rate carries a compound unit** — ``mile/hour`` is +``quantity / time`` — and the three single-rate operations must verify that units *compose*: + +```text +rate × time -> quantity (mile/hour × hour = mile) [time must be the rate's denominator] +quantity ÷ time -> rate (mile ÷ hour = mile/hour) +quantity ÷ rate -> time (mile ÷ mile/hour = hour) [quantity must be the rate's numerator] +``` + +A non-composing operation REFUSES (``UnitError``) rather than producing a nonsense unit — +``mile/hour × minute``, ``students/hour × mile``, ``dollar ÷ mile/hour``. This is the wrong=0 +boundary for R3: the dimensional check refuses before any arithmetic runs. + +Deliberately tiny. Units are bare names; there is **no** generic dimensional-analysis universe +and **no** semantic unit-class typing (v1 cannot itself know "dollar is not a time" — the gold and +reader only admit sensible single-rate problems). Compound-of-compound and rate/rate are simply +not representable (``RateUnit`` is one numerator over one denominator). No unit conversion (v1 +never turns minutes into hours). Deterministic; no clock, no randomness. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class UnitError(ValueError): + """A non-composing unit operation — refuse, never fabricate a unit.""" + + +@dataclass(frozen=True, slots=True) +class BaseUnit: + """A simple unit of quantity or time — ``mile``, ``hour``, ``dollar``, ``widget``.""" + + name: str + + def __post_init__(self) -> None: + if not self.name or not isinstance(self.name, str): + raise UnitError(f"BaseUnit needs a non-empty name; got {self.name!r}") + + +@dataclass(frozen=True, slots=True) +class RateUnit: + """A compound rate unit ``numerator / denominator`` — ``mile/hour``, ``dollar/hour``.""" + + numerator: str + denominator: str + + def __post_init__(self) -> None: + if not self.numerator or not self.denominator: + raise UnitError(f"RateUnit needs non-empty units; got {self!r}") + if self.numerator == self.denominator: + raise UnitError(f"a rate's numerator and denominator must differ; got {self}") + + def __str__(self) -> str: + return f"{self.numerator}/{self.denominator}" + + +def rate_times_time(rate: RateUnit, time: BaseUnit) -> BaseUnit: + """``rate × time -> quantity`` (``mile/hour × hour = mile``). The time unit MUST be the rate's + denominator, else REFUSE (``mile/hour × minute``).""" + if time.name != rate.denominator: + raise UnitError(f"{rate} × {time.name}: the time unit must be {rate.denominator!r}") + return BaseUnit(rate.numerator) + + +def rate_from_quantity_over_time(quantity: BaseUnit, time: BaseUnit) -> RateUnit: + """``quantity ÷ time -> rate`` (``mile ÷ hour = mile/hour``).""" + if quantity.name == time.name: + raise UnitError(f"{quantity.name} ÷ {time.name}: quantity and time units must differ") + return RateUnit(quantity.name, time.name) + + +def time_from_quantity_over_rate(quantity: BaseUnit, rate: RateUnit) -> BaseUnit: + """``quantity ÷ rate -> time`` (``mile ÷ mile/hour = hour``). The quantity unit MUST be the + rate's numerator, else REFUSE (``dollar ÷ mile/hour``).""" + if quantity.name != rate.numerator: + raise UnitError(f"{quantity.name} ÷ {rate}: the quantity unit must be {rate.numerator!r}") + return BaseUnit(rate.denominator) + + +__all__ = [ + "BaseUnit", + "RateUnit", + "UnitError", + "rate_from_quantity_over_time", + "rate_times_time", + "time_from_quantity_over_rate", +] diff --git a/tests/test_rate_units.py b/tests/test_rate_units.py new file mode 100644 index 00000000..dd6c13a6 --- /dev/null +++ b/tests/test_rate_units.py @@ -0,0 +1,76 @@ +"""Tests for the R3 compound-unit algebra (R3a). + +Pins the three single-rate compositions and — load-bearing — that a non-composing operation +REFUSES (``UnitError``) rather than fabricating a unit. The dimensional check is the wrong=0 gate. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from generate.rate_comprehension.units import ( + BaseUnit, + RateUnit, + UnitError, + rate_from_quantity_over_time, + rate_times_time, + time_from_quantity_over_rate, +) + +_MPH = RateUnit("mile", "hour") + + +def test_rate_times_time_composes() -> None: + assert rate_times_time(_MPH, BaseUnit("hour")) == BaseUnit("mile") + + +def test_rate_times_wrong_time_refuses() -> None: + # mile/hour × minute — the time unit is not the rate's denominator. + with pytest.raises(UnitError): + rate_times_time(_MPH, BaseUnit("minute")) + # students/hour × mile. + with pytest.raises(UnitError): + rate_times_time(RateUnit("student", "hour"), BaseUnit("mile")) + + +def test_quantity_over_time_makes_rate() -> None: + assert rate_from_quantity_over_time(BaseUnit("mile"), BaseUnit("hour")) == _MPH + + +def test_quantity_over_same_unit_refuses() -> None: + with pytest.raises(UnitError): + rate_from_quantity_over_time(BaseUnit("mile"), BaseUnit("mile")) + + +def test_quantity_over_rate_makes_time() -> None: + assert time_from_quantity_over_rate(BaseUnit("mile"), _MPH) == BaseUnit("hour") + + +def test_quantity_over_rate_wrong_numerator_refuses() -> None: + # dollar ÷ mile/hour — the quantity unit is not the rate's numerator. + with pytest.raises(UnitError): + time_from_quantity_over_rate(BaseUnit("dollar"), _MPH) + + +def test_rate_unit_numerator_equals_denominator_refuses() -> None: + with pytest.raises(UnitError): + RateUnit("mile", "mile") + + +def test_three_operations_compose_consistently() -> None: + # mile/hour × hour = mile; mile ÷ hour = mile/hour; mile ÷ mile/hour = hour. + quantity = rate_times_time(_MPH, BaseUnit("hour")) + assert rate_from_quantity_over_time(quantity, BaseUnit("hour")) == _MPH + assert time_from_quantity_over_rate(quantity, _MPH) == BaseUnit("hour") + + +def test_units_are_frozen() -> None: + with pytest.raises(dataclasses.FrozenInstanceError): + _MPH.numerator = "kilometer" # type: ignore[misc] + + +def test_empty_unit_name_refuses() -> None: + with pytest.raises(UnitError): + BaseUnit("")