mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:47:39 +01:00
First step of the configurable meal schedule: the pure arithmetic, in both languages, with nothing wired to it yet. MealSchedule(8, 20, 5) gives 08, 11, 14, 17, 20; the default MealSchedule(8, 20, 4) reproduces the hours that are currently hardcoded, so nothing changes behaviour until a user picks something else. Two properties are load-bearing and both are tested rather than asserted in prose: Integer arithmetic only, no floats and no round(). Python's round() is banker's (round(2.5) == 2) and Dart's is half-away-from-zero (2.5.round() == 3), so a floating-point derivation would silently disagree across devices -- and a slot one device offers but the other does not is a checkpoint that can never be satisfied, i.e. a permanent lock. The shared vector table plus an identical 6912-case sweep in both suites is what catches that class of bug before it reaches a device. Counts are clamped to the window width rather than rejected. A narrow window (08-12 across 6 meals) would otherwise repeat an hour -- 8, 9, 10, 10, 11, 12 -- and slot hours are used as set members, map keys and notification ids, so a repeat silently drops a checkpoint. Clamping also means the two languages cannot disagree about which inputs are errors. The enforcement cutoff is now derived as last + 2h (clamped to midnight) rather than sharing the constant that also bounded the slot range. The clamp matters: 23:00 + 2 = 25 would make `hour < cutoff` vacuously true and the window would never close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
135 lines
5.5 KiB
Python
135 lines
5.5 KiB
Python
"""The user's eating window and meal count, and the slot hours they imply.
|
|
|
|
A schedule is three numbers -- the first meal hour, the last meal hour, and how
|
|
many meals fall between them inclusive -- from which the intermediate
|
|
checkpoints are derived by even division. ``MealSchedule(8, 20, 5)`` yields
|
|
``(8, 11, 14, 17, 20)``.
|
|
|
|
This module is pure: no clock, no filesystem, no configuration lookup. Storage
|
|
lives in :mod:`diet_guard._meal_schedule_store`, and the slot arithmetic that
|
|
consumes a schedule lives in :mod:`diet_guard._slots`.
|
|
|
|
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. Two 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.
|
|
* **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
|
|
|
|
# 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
|
|
|
|
FIRST_HOUR = 0
|
|
LAST_HOUR = 23
|
|
|
|
# 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_HOURS = 2
|
|
|
|
_HOURS_PER_DAY = 24
|
|
|
|
|
|
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: Hour of the first meal, 0-23.
|
|
last: Hour of the last meal, strictly after ``first``.
|
|
count: Total meals including both endpoints.
|
|
"""
|
|
|
|
first: int
|
|
last: int
|
|
count: int
|
|
|
|
def normalized(self) -> MealSchedule:
|
|
"""Return an equivalent schedule guaranteed to satisfy the invariants.
|
|
|
|
Ordering matters: ``first`` is clamped into the day, then ``last`` is
|
|
clamped to leave at least one hour of window, then ``count`` is clamped
|
|
to the window's width. That last clamp is the load-bearing one -- see
|
|
:meth:`slots`.
|
|
|
|
Returns:
|
|
A schedule whose ``slots()`` is strictly ascending.
|
|
"""
|
|
first = _clamp(self.first, FIRST_HOUR, LAST_HOUR - 1)
|
|
last = _clamp(self.last, first + 1, LAST_HOUR)
|
|
# A window of N hours holds at most N+1 whole-hour checkpoints; asking
|
|
# for more would repeat an hour (see slots()).
|
|
count = _clamp(
|
|
self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, last - first + 1)
|
|
)
|
|
return MealSchedule(first, last, count)
|
|
|
|
def slots(self) -> tuple[int, ...]:
|
|
"""Return the meal-slot hours, ascending, endpoints exact.
|
|
|
|
Meals are spread evenly across the window and rounded to whole hours by
|
|
integer arithmetic: slot *i* is ``first + (i*span + d//2) // d`` where
|
|
``span = last - first`` and ``d = count - 1``. The ``d//2`` term is a
|
|
round-half-up bias applied before the division, which is what keeps
|
|
this float-free.
|
|
|
|
Both endpoints land exactly on ``first`` and ``last`` by construction,
|
|
so the eating window is always honoured even when the interior spacing
|
|
has to round.
|
|
|
|
The result is strictly ascending because :meth:`normalized` caps
|
|
``count`` at ``last - first + 1``. Without that cap a narrow window
|
|
repeats an hour (``08-12`` with 6 meals would give
|
|
``8, 9, 10, 10, 11, 12``), and since slot hours are used as set
|
|
members, dict keys *and* notification ids, a repeat silently drops a
|
|
checkpoint.
|
|
|
|
Returns:
|
|
The slot hours, ascending, of length ``normalized().count``.
|
|
"""
|
|
schedule = self.normalized()
|
|
span = schedule.last - schedule.first
|
|
divisions = schedule.count - 1
|
|
return tuple(
|
|
schedule.first + (index * span + divisions // 2) // divisions
|
|
for index in range(schedule.count)
|
|
)
|
|
|
|
@property
|
|
def enforcement_end_hour(self) -> int:
|
|
"""Return the hour at which slot enforcement stops for the day.
|
|
|
|
Clamped to the end of the day: a 23:00 last meal would otherwise put
|
|
the cutoff at 25, making ``hour < cutoff`` vacuously true so the
|
|
enforcement window never closes and the gate can never stop firing.
|
|
"""
|
|
return min(self.last + ENFORCEMENT_TAIL_HOURS, _HOURS_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=8, last=20, count=4)
|