mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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
219 lines
9.9 KiB
Python
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()
|