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.
55 lines
2.2 KiB
Python
55 lines
2.2 KiB
Python
"""Typed model for an R3 single-rate problem (R3b).
|
||
|
||
A single-rate problem is the fixed equation ``quantity = rate × time`` with **exactly one**
|
||
unknown (the query). ``rate_unit`` (e.g. ``mile/hour``) is the single source of unit truth: the
|
||
quantity is measured in ``rate_unit.numerator`` and the time in ``rate_unit.denominator``, so the
|
||
three units are consistent by construction. The two non-query slots carry integer values; the
|
||
query slot is ``None``.
|
||
|
||
Pure data with a structural guard: exactly the query slot is unknown (illegal states — zero or two
|
||
unknowns — cannot be represented). Off-serving; deterministic.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Literal
|
||
|
||
from generate.rate_comprehension.units import RateUnit
|
||
|
||
RateRole = Literal["rate", "time", "quantity"]
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class RateProblem:
|
||
"""``quantity = rate × time`` with one unknown. ``rate_unit`` fixes all three units."""
|
||
|
||
rate_unit: RateUnit
|
||
rate: int | None
|
||
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]:
|
||
raise ValueError(
|
||
f"exactly the query slot must be the unknown; query={self.query!r}, unknown={unknown}"
|
||
)
|
||
for role, value in slots.items():
|
||
if value is not None and (not isinstance(value, int) or isinstance(value, bool)):
|
||
raise ValueError(f"{role} value must be int; got {value!r}")
|
||
|
||
@property
|
||
def quantity_unit(self) -> str:
|
||
return self.rate_unit.numerator
|
||
|
||
|
||
__all__ = ["RateProblem", "RateRole"]
|