diet-guard/diet_guard/_slots.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

234 lines
9.3 KiB
Python

"""Pure meal-slot arithmetic for the diet_guard gate.
This module is deliberately I/O-free and clock-free: every function is a total
function of its ``now`` and ``schedule`` arguments, so the fiddly time-of-day
edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight reset) are
exhaustively unit-testable without mocking the filesystem or the wall clock.
The stateful "which slots have I actually logged?" question lives in
:mod:`diet_guard._state`; the two are composed in :mod:`diet_guard._gate`.
A "slot" is the *minute of day* (``0..1439``) at which a meal checkpoint opens
(480, 720, 960, 1200 -- 08:00, 12:00, 16:00, 20:00 -- by default). A slot is
*elapsed* once its minute has arrived and we are still inside the daily
enforcement window; an elapsed slot with no logged meal is what makes the gate
fire. How a log entry records its slot on the wire, and how the old
hour-valued ``slot`` field is read back, lives in :mod:`diet_guard._slot_wire`.
A logged meal satisfies the slot *nearest* its recorded slot minute
(:func:`satisfied_slots`), not only an exact match. That is how a meal logged
under an old schedule (or an old hour-valued entry) still counts after the
schedule moves by a few minutes -- nothing on disk is ever rewritten.
``schedule`` is a required argument on every function here, deliberately: it
used to be read from module constants, and a default would let a call site that
was missed during a refactor keep deriving the old fixed hours on one device
only. That is the split brain this design exists to prevent -- a slot one
device offers and the other does not is a checkpoint that can never be
satisfied. Making it required lets mypy enumerate the call sites instead.
Callers resolve the value at the impure edge, mirroring how
:mod:`diet_guard._daystatus` takes an explicit budget schedule.
KEEP IN SYNC WITH ``app/lib/models/slot.dart``; the shared vectors in
``tests/fixtures/meal_schedule_vectors.json`` gate the two.
"""
from __future__ import annotations
from datetime import date, datetime, time
from typing import TYPE_CHECKING
from diet_guard._meal_schedule import MINUTES_PER_DAY
if TYPE_CHECKING:
from collections.abc import Iterable
from diet_guard._meal_schedule import MealSchedule
def minute_of_day(now: datetime) -> int:
"""Return ``now``'s minute of day, ``0..1439`` (seconds are ignored)."""
return now.hour * 60 + now.minute
def day_slots(schedule: MealSchedule) -> tuple[int, ...]:
"""Return the meal-slot minutes for a day, e.g. ``(480, 720, 960, 1200)``.
Args:
schedule: The eating window and meal count to derive slots from.
Returns:
The slot minutes in ascending order.
"""
return schedule.slots()
def within_enforcement_window(now: datetime, schedule: MealSchedule) -> bool:
"""Return True if ``now`` is inside the daily slot-enforcement window.
Outside ``[first_slot, enforcement_end)`` the gate never fires, so unlogged
slots lapse overnight instead of trapping you at 03:00.
Args:
now: Reference local time.
schedule: The schedule in force for that day.
Returns:
True if slot enforcement is active at ``now``.
"""
minute = minute_of_day(now)
return schedule.slots()[0] <= minute < schedule.enforcement_end_minute
def elapsed_slots(now: datetime, schedule: MealSchedule) -> tuple[int, ...]:
"""Return today's slots whose minute has arrived as of ``now``.
Empty outside the enforcement window (before the first slot, or after the
overnight cutoff), so the caller never has to special-case the night.
Args:
now: Reference local time.
schedule: The schedule in force for that day.
Returns:
The elapsed slot minutes, ascending (possibly empty).
"""
if not within_enforcement_window(now, schedule):
return ()
minute = minute_of_day(now)
return tuple(slot for slot in day_slots(schedule) if slot <= minute)
def missing_slots(
now: datetime, logged: set[int], schedule: MealSchedule
) -> tuple[int, ...]:
"""Return elapsed slots that have not been satisfied by a logged meal.
Args:
now: Reference local time.
logged: The slot minutes already covered by today's log -- normally
:func:`satisfied_slots` of the entries' slot minutes.
schedule: The schedule in force for that day.
Returns:
The unsatisfied elapsed slot minutes, ascending (empty == nothing due).
"""
return tuple(slot for slot in elapsed_slots(now, schedule) if slot not in logged)
def current_slot(now: datetime, schedule: MealSchedule) -> int | None:
"""Return the most recent elapsed slot as of ``now``, or None.
Reports the schedule position only. Tagging a *log* with a slot goes
through :func:`slot_for_log`, which additionally clamps off-hours meals
instead of returning None.
Args:
now: Reference local time.
schedule: The schedule in force for that day.
Returns:
The latest elapsed slot minute, or None when none have elapsed yet.
"""
elapsed = elapsed_slots(now, schedule)
return elapsed[-1] if elapsed else None
def slot_for_log(now: datetime, schedule: MealSchedule) -> int:
"""Return the slot a meal logged at ``now`` should be attributed to.
CLAMP RULE (keep byte-identical with ``slot.dart``'s ``slotForLog``): before
the first slot, clamp to the first slot; after the enforcement window ends,
clamp to the last slot; behaviour inside a window is unchanged. Both
languages must reach each answer by the *same* branch, not merely agree on
the value -- the shared fixture pins every minute edge for exactly that
reason.
Unlike :func:`current_slot` this never returns None, which is the point: an
off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
still left the gate firing for that checkpoint. Attribution is deliberately
separate from :func:`elapsed_slots`/:func:`missing_slots` -- widening *those*
would instead make every slot fall due at the end of the day.
Args:
now: Reference local time.
schedule: The schedule in force for that day.
Returns:
The slot minute to tag the log with.
"""
slots = day_slots(schedule)
if minute_of_day(now) < slots[0]:
return slots[0]
current = current_slot(now, schedule)
return current if current is not None else slots[-1]
def nearest_slot(minute: int, schedule: MealSchedule) -> int:
"""Return the slot closest to ``minute``; an exact tie goes to the earlier.
The tie rule is part of the cross-language contract: ``min`` keeps the
first minimal element of the ascending slots, and the Dart loop replaces
its best only on a strictly smaller distance.
"""
return min(day_slots(schedule), key=lambda slot: abs(slot - minute))
def satisfied_slots(entry_minutes: Iterable[int], schedule: MealSchedule) -> set[int]:
"""Return the slots covered by meals recorded at ``entry_minutes``.
Each recorded slot minute satisfies its :func:`nearest_slot`. Many-to-one
is intended: two meals snapping onto one slot satisfy only that slot, so a
double breakfast does not also clear lunch. This is what lets an entry
written under an older schedule count toward today's nearest checkpoint
without rewriting it on disk.
"""
return {nearest_slot(minute, schedule) for minute in entry_minutes}
def resolve_future_when(
date_str: str, slot_minute: int, schedule: MealSchedule, now: datetime
) -> datetime:
"""Build a tz-aware datetime for a meal pre-logged against a future slot.
Lets a caller (the CLI's ``--date`` flag, or the app's future-date picker)
turn a user-picked date+slot into the ``when`` that
:func:`diet_guard._state.log_meal` expects, with the same validation on
both platforms. Still clock-free per the module's own rule: ``now`` is
supplied by the caller rather than read here.
Args:
date_str: The chosen date as ``YYYY-MM-DD``.
slot_minute: The chosen slot; must be one of ``schedule``'s slots.
schedule: The schedule in force, used to validate ``slot_minute``.
now: Reference local time, used to reject a non-future date.
Returns:
A tz-aware datetime combining ``date_str`` and ``slot_minute``, in
``now``'s timezone.
Raises:
ValueError: ``date_str`` doesn't parse, ``slot_minute`` isn't a slot,
or the resulting date isn't strictly after ``now``'s date.
"""
try:
parsed_date = date.fromisoformat(date_str)
except ValueError as exc:
msg = f"invalid date {date_str!r}, expected YYYY-MM-DD"
raise ValueError(msg) from exc
slots = day_slots(schedule)
if slot_minute not in slots:
labels = ", ".join(slot_label(slot) for slot in slots)
msg = f"{slot_label(slot_minute)} is not a meal slot; choose one of {labels}"
raise ValueError(msg)
if parsed_date <= now.date():
msg = f"{date_str} is not a future date"
raise ValueError(msg)
clock = time(hour=slot_minute // 60, minute=slot_minute % 60)
return datetime.combine(parsed_date, clock, tzinfo=now.tzinfo)
def slot_label(minute: int) -> str:
"""Return a human ``HH:MM`` label for a slot minute, e.g. ``"07:15"``."""
wrapped = minute % MINUTES_PER_DAY
return f"{wrapped // 60:02d}:{wrapped % 60:02d}"