diet-guard/diet_guard/_meal_schedule.py
Krzysztof kuhy Rudnicki 8bc1812147 slots: minute-of-day contract (phase 1 of 3: pure model + shared fixture)
Meal-slot checkpoints move from whole hours to minute-of-day ints
(0..1439) in the pure model and slot arithmetic of both languages:
MealSchedule(first_minute, last_minute, count) / (firstMinute:,
lastMinute:, count:), interior slots snapped to the 15-minute grid,
endpoints exact, integer-only. New: nearest_slot, satisfied_slots,
slot_fields / entry_slot_minute (additive "slot_min" wire field) and
schedule_to_wire / schedule_from_wire ("fm"/"lm" only when off the hour,
so whole-hour data stays byte-identical), parse_hhmm.

tests/fixtures/meal_schedule_vectors.json (scripts/
build_meal_schedule_fixture.py) is read by both suites; kuchnia_day.json
slot values regenerated as minutes.

Consumers (CLI, gate, storage, sync, app screens, kuchnia, notifications)
are intentionally broken until phase 2. patch-targets skipped: it fails
only because diet_guard._gatelock no longer imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:22:43 +02:00

219 lines
9.9 KiB
Python

"""The user's eating window and meal count, and the slot minutes they imply.
A schedule is three numbers -- the first meal's minute of day, the last meal's
minute of day, and how many meals fall between them inclusive -- from which the
intermediate checkpoints are derived by even division and rounded to the
15-minute grid. ``MealSchedule(480, 1200, 5)`` (08:00-20:00, five meals)
yields ``(480, 660, 840, 1020, 1200)``: 08:00, 11:00, 14:00, 17:00, 20:00.
Every time here is a *minute of day*, an int in ``0..1439``. The fields are
deliberately named ``first_minute``/``last_minute`` rather than reusing the old
hour-valued ``first``/``last``: renaming them makes mypy enumerate every call
site that still thinks in hours, instead of one quietly passing ``8`` and
getting a 00:08 breakfast.
This module is pure: no clock, no filesystem, no configuration lookup. Storage
lives in :mod:`diet_guard._meal_schedule_store`, the slot arithmetic that
consumes a schedule in :mod:`diet_guard._slots`, and a log entry's slot
encoding in :mod:`diet_guard._slot_wire`. The schedule's own wire encoding
(:func:`schedule_to_wire`/:func:`schedule_from_wire`) lives here.
KEEP IN SYNC WITH ``app/lib/models/meal_schedule.dart``. The two must agree on
every input, because a device that derives different slots than its peer nags
for checkpoints the other never offers -- and a slot that can never be
satisfied is a permanent lock. ``tests/fixtures/meal_schedule_vectors.json``
(regenerated by ``scripts/build_meal_schedule_fixture.py``) is read by both
test suites; three rules make that agreement checkable:
* **Integer arithmetic only.** No floats and no ``round()`` anywhere in the
derivation. Python's ``round`` is banker's rounding (``round(2.5) == 2``)
while Dart's is half-away-from-zero (``2.5.round() == 3``), so any float path
is a latent cross-language split brain. ``//`` here, ``~/`` there.
* **Non-negative operands to every division.** Python's ``//`` floors while
Dart's ``~/`` truncates, so ``-7 // 15 == -1`` but ``-7 ~/ 15 == 0``. Every
division below runs on a value :meth:`MealSchedule.normalized` has already
pulled into the day, which is what keeps the two operators equivalent.
* **Clamp, don't reject.** Every out-of-range input is normalised to the
nearest legal schedule rather than raising, so the two languages cannot
disagree about which inputs are errors.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeGuard
# A day's meals must fit inside a day, and the pills that render them have to
# stay legible on a phone at the widest (all-logged) size -- see
# ``app/test/widgets/slot_selector_row_test.dart``.
MIN_MEAL_COUNT = 2
MAX_MEAL_COUNT = 6
MINUTES_PER_DAY = 1440
# Both the minimum spacing between two checkpoints and the grid the interior
# checkpoints are rounded onto. One constant for both is what makes the
# strict-ascent proof in ``slots()`` work: points at least one grid step apart
# cannot round onto the same grid mark.
SLOT_GRID_MINUTES = 15
# Grace period after the final checkpoint, before the gate stops firing for
# the day. Deliberately a constant rather than the slot spacing: it is how
# long you have to log a late dinner, which has nothing to do with how many
# meals you eat. Tying it to the spacing would stretch the lockout window to
# midnight at four meals, contradicting the "don't trap me overnight" intent
# this cutoff exists to serve.
ENFORCEMENT_TAIL_MINUTES = 120
_LAST_MINUTE = MINUTES_PER_DAY - 1
def _clamp(value: int, low: int, high: int) -> int:
"""Return ``value`` confined to ``[low, high]``."""
return max(low, min(high, value))
@dataclass(frozen=True)
class MealSchedule:
"""An eating window and the number of meals inside it.
Attributes:
first_minute: Minute of day of the first meal, 0-1424.
last_minute: Minute of day of the last meal, at least one grid step
(15 minutes) after ``first_minute`` and at most 1439.
count: Total meals including both endpoints.
"""
first_minute: int
last_minute: int
count: int
def normalized(self) -> MealSchedule:
"""Return an equivalent schedule guaranteed to satisfy the invariants.
Ordering matters: ``first_minute`` is clamped into the day leaving room
for one grid step, then ``last_minute`` is clamped to at least one grid
step after it, then ``count`` is clamped to how many grid-spaced
checkpoints the window holds. That last clamp is the load-bearing one
-- see :meth:`slots`.
Returns:
A schedule whose ``slots()`` is strictly ascending.
"""
first = _clamp(self.first_minute, 0, _LAST_MINUTE - SLOT_GRID_MINUTES)
last = _clamp(self.last_minute, first + SLOT_GRID_MINUTES, _LAST_MINUTE)
# A window of W minutes holds at most W//15 + 1 checkpoints spaced a
# grid step apart; asking for more would round two onto one mark.
capacity = (last - first) // SLOT_GRID_MINUTES + 1
count = _clamp(self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, capacity))
return MealSchedule(first, last, count)
def slots(self) -> tuple[int, ...]:
"""Return the meal-slot minutes, ascending, endpoints exact.
With ``span = last - first`` and ``d = count - 1``, interior slot *i*
is ``raw = first + (i*span + d//2) // d`` (even division, the ``d//2``
term a round-half-up bias that keeps it float-free), then snapped to
the nearest absolute quarter hour by ``(raw + 7) // 15 * 15``. The
endpoints are ``first`` and ``last`` exactly -- never snapped -- so the
eating window the user typed is always honoured, even at 07:23.
Strictly ascending, provably: :meth:`normalized` caps ``count`` so that
``span / d >= 15``, hence consecutive ``raw`` values differ by at least
15, and two integers at least 15 apart never snap onto the same grid
mark. Snapping moves a value by at most 7, so the first interior slot
is at least 8 past ``first`` and the last at least 8 before ``last``.
That matters because slot minutes are set members, dict keys *and*
notification ids: a repeat silently drops a checkpoint.
Returns:
The slot minutes, ascending, of length ``normalized().count``.
"""
schedule = self.normalized()
first, last = schedule.first_minute, schedule.last_minute
span = last - first
divisions = schedule.count - 1
interior = (
(first + (index * span + divisions // 2) // divisions + 7)
// SLOT_GRID_MINUTES
* SLOT_GRID_MINUTES
for index in range(1, divisions)
)
return (first, *interior, last)
@property
def enforcement_end_minute(self) -> int:
"""Return the minute at which slot enforcement stops for the day.
Derived from the *normalised* last meal, so it always agrees with the
last entry of :meth:`slots`. Clamped to the end of the day: a 23:00
last meal would otherwise put the cutoff at 1500, making
``minute < cutoff`` vacuously true so the enforcement window never
closes and the gate can never stop firing.
"""
last = self.normalized().last_minute
return min(last + ENFORCEMENT_TAIL_MINUTES, MINUTES_PER_DAY)
# The historical hardcoded schedule: 08:00, 12:00, 16:00, 20:00, with the
# enforcement window closing at 22:00. Still what a device uses before the
# user has ever chosen anything, so upgrading changes no behaviour.
DEFAULT_SCHEDULE = MealSchedule(first_minute=8 * 60, last_minute=20 * 60, count=4)
def is_wire_int(value: object) -> TypeGuard[int]:
"""Return True for a real int; ``bool`` is excluded, as Dart's ``is int`` is.
Python's ``bool`` subclasses ``int``, so a bare ``isinstance`` would accept
``true`` from a peer's JSON that the Dart side rejects -- two devices
disagreeing about which values are usable.
"""
return isinstance(value, int) and not isinstance(value, bool)
def schedule_to_wire(schedule: MealSchedule) -> dict[str, int]:
"""Return the synced ``sched:<date>`` value for ``schedule``.
Backward compatible by construction: ``f``/``l`` carry whole hours exactly
as before the move to minutes, and ``fm``/``lm`` appear only when an
endpoint is off the hour. So a whole-hour schedule encodes byte-identically
to the old ``{"f", "l", "n"}`` form. The schedule is normalised first, so
every ``//`` runs on a non-negative value (see the module docstring).
Returns:
``{"f", "l", "n"}`` plus ``"fm"``/``"lm"`` for off-hour endpoints.
"""
norm = schedule.normalized()
first, last = norm.first_minute, norm.last_minute
wire = {"f": first // 60, "l": last // 60, "n": norm.count}
if first % 60:
wire["fm"] = first
if last % 60:
wire["lm"] = last
return wire
def schedule_from_wire(raw: object) -> MealSchedule | None:
"""Return the normalised schedule a ``sched:<date>`` value describes.
``fm``/``lm`` win when they are ints; otherwise ``f``/``l`` are hours.
Never raises: anything without int ``f``, ``l`` and ``n`` yields None, so
one bad field from a peer cannot take out the whole history. Normalising
on the way in means a peer running a future version with a wider range
cannot hand us a schedule we would derive differently.
Returns:
The decoded, normalised schedule, or None when ``raw`` is unusable.
"""
if not isinstance(raw, dict):
return None
first, last, count = raw.get("f"), raw.get("l"), raw.get("n")
if not (is_wire_int(first) and is_wire_int(last) and is_wire_int(count)):
return None
first_minute, last_minute = raw.get("fm"), raw.get("lm")
return MealSchedule(
first_minute if is_wire_int(first_minute) else first * 60,
last_minute if is_wire_int(last_minute) else last * 60,
count,
).normalized()