core/generate/rate_comprehension/model.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

55 lines
2.2 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.

"""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"]