diet-guard/diet_guard/sync_merge/_schedule.py
Krzysztof kuhy Rudnicki 24dbffc432 Sync the meal schedule as sched: fields on the budget record
Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.

The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.

Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
  {"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.

Two guards worth naming:

- A device with no history contributes no `sched:` fields at all, so an unset
  default can never outrank a peer's real schedule; and an empty merge result
  is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
  edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
  Log and would otherwise drop the schedule fields too. Its budget value
  carries an epoch stamp, so it loses every LWW race against a real edit.

sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.

772 Python tests at 100% branch coverage, 619 Dart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 14:49:08 +02:00

112 lines
4.4 KiB
Python

"""Meal-schedule history as extra fields on the shared ``budget`` record.
Split out of :mod:`._budget` to keep both modules under the repo's 250-line
limit; the two halves are otherwise the same idea and share one record.
The schedule rides as one ``sched:<YYYY-MM-DD>`` field per history entry,
exactly like the budget's ``hist:`` fields. ``crdt_sync``'s ``merge_record``
is per-field last-writer-wins over the *union* of field names, and both
devices push the *merged* record rather than their own, so a device that
predates meal schedules neither clobbers those fields nor blocks them -- it
relays them untouched. That is what makes this shippable without a
coordinated release.
The value is a small map (``{"f": 8, "l": 20, "n": 5}``) rather than a scalar.
``Record.to_dict``/``from_dict`` round-trip nested values unchanged, and a
malformed one is skipped exactly as a non-int ``hist:`` value is.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from crdt_sync import Hlc
from diet_guard._device import device_id
from diet_guard._meal_schedule import MealSchedule
from diet_guard._meal_schedule_store import ScheduleEntry
from diet_guard.sync_merge._clock import _EPOCH
if TYPE_CHECKING:
from crdt_sync import Log
_BUDGET_RECORD_ID = "budget"
# Field-name prefix for the effective-from meal-schedule history, one field
# per date. A separate *field* rather than a separate document, for the
# reason spelled out in this module's docstring.
SCHEDULE_FIELD_PREFIX = "sched:"
def schedule_hlc(entry: ScheduleEntry) -> Hlc:
"""Derive a deterministic Hlc for one schedule entry from its edit time.
Same trick as the budget's ``_history_hlc``: identical inputs always yield
the same clock, so re-syncing an unchanged history is a no-op. Derived
from the parsed timestamp rather than the raw string, so the two languages
agree even though they format the epoch fallback differently.
"""
try:
moment = datetime.fromisoformat(entry.edited_at)
except ValueError:
moment = _EPOCH
return Hlc.new_tick(device_id(), wall_time_ms=int(moment.timestamp() * 1000))
def schedule_fields(
entries: tuple[ScheduleEntry, ...],
) -> dict[str, tuple[object, Hlc]]:
"""Return the ``sched:`` fields contributed by ``entries``.
Empty when this device has no history, so a device that has never edited
a schedule contributes nothing to the merge rather than pushing the unset
default over a peer's real value.
"""
return {
f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": (
{
"f": entry.schedule.first,
"l": entry.schedule.last,
"n": entry.schedule.count,
},
schedule_hlc(entry),
)
for entry in entries
}
def log_to_schedule_history(log: Log) -> tuple[ScheduleEntry, ...]:
"""Extract the meal-schedule history from a merged budget ``Log``.
Each entry's ``edited_at`` is reconstructed from its field Hlc rather than
carried separately, so the stored timestamp and the clock the merge
compared can never drift apart. Malformed values are skipped, so one bad
field from a peer cannot take out the whole history.
"""
record = log.get(_BUDGET_RECORD_ID)
if record is None:
return ()
entries = []
for name, (value, hlc) in record.fields.items():
if not name.startswith(SCHEDULE_FIELD_PREFIX):
continue
if not isinstance(value, dict):
continue
first, last, count = value.get("f"), value.get("l"), value.get("n")
if not (
isinstance(first, int) and isinstance(last, int) and isinstance(count, int)
) or any(isinstance(part, bool) for part in (first, last, count)):
continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
entries.append(
ScheduleEntry(
effective_from=name[len(SCHEDULE_FIELD_PREFIX) :],
# Normalised on the way in, so a peer running a future version
# with a wider range cannot hand us a schedule we would derive
# slots differently from.
schedule=MealSchedule(first, last, count).normalized(),
edited_at=edited.astimezone().isoformat(timespec="seconds"),
),
)
return tuple(sorted(entries, key=lambda entry: entry.effective_from))