Files
NestQuest/custom_components/nestquest/core/materialize.py
T

536 lines
22 KiB
Python

"""The materialization walk (Feature 07 core).
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence patterns and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date (or every assignee, when the definition's
``skip_on_away`` is off) and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
Regeneration: a config or presence change must rebuild future instances
so they track the new state. :func:`regenerate_for_definition` covers
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
covers a single child's presence change (Feature 09). Both delete the
affected open instances at or after today — never a completed instance,
never the past — and re-run the walk over the rolling horizon
``[today, today + horizon_days]`` (``horizon_days`` defaults to
``DEFAULT_HORIZON_DAYS``).
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
creating, updating and deleting a presence pattern
(:mod:`.presence_management`'s ``create_presence_pattern`` /
``update_presence_pattern`` / ``delete_presence_pattern``),
``create_presence_override``, and ``delete_presence_override``. A
pattern delete changes presence state too, so it must regenerate just
like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
"""
from __future__ import annotations
import datetime
from typing import NamedTuple
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
_resolve_today,
)
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresencePattern
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _resolve_horizon_days(horizon_days: int | None) -> int:
"""Validate a regeneration horizon and return the resolved day count.
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
or a value below 1 raises ValueError naming the field — the validation
runs BEFORE any future-instance delete so a malformed horizon can never
delete rows it then fails to rebuild. A negative horizon would
otherwise delete first and only then be rejected by
:func:`materialize`'s inverted-range check; ``0``/``False`` would
silently shrink the window to a single day.
"""
if horizon_days is None:
return DEFAULT_HORIZON_DAYS
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < 1
):
raise ValueError(
f"horizon_days must be a positive integer, got {horizon_days!r}"
)
return horizon_days
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
patterns_records,
overrides_records,
*,
extra_overrides: tuple[PresenceOverride, ...] = (),
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine.
``extra_overrides`` layers already-validated, NOT-yet-stored
overrides on top of the stored ones — the consequence preview's
proposed override (:func:`count_removed_by_override`).
"""
patterns: dict[int, list[PresencePattern]] = {}
for child_id, records in patterns_records.items():
patterns[child_id] = [
PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(patterns, overrides)
class GeneratedTuple(NamedTuple):
"""One instance the walk WOULD write: its upsert key plus due time."""
definition_id: int
child_id: int
due_date: str
window: str
due_time: str | None
def _generation_preview(
snapshots,
engine: PresenceEngine,
start: datetime.date,
end: datetime.date,
*,
child_filter: set[int] | None = None,
) -> list[GeneratedTuple]:
"""Return the tuples the walk would write over ``[start, end]``.
The pure, read-only half of :func:`materialize`: for each date, each
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
each assignee in ``child_filter`` (every assignee when None) who is
``is_present`` on it — or every assignee when the definition's
``skip_on_away`` is off — and each declared window, one tuple, in
the walk's write order. :func:`materialize` writes exactly these
tuples and :func:`count_removed_by_override` compares two of these
previews, so the write and the consequence preview can never
diverge. No database access.
"""
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows,
snapshot.definition.skip_on_away)
for snapshot in snapshots
]
generated: list[GeneratedTuple] = []
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for (
definition_id, rule, assignees, windows, skip_on_away
) in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if skip_on_away and not engine.is_present(child.id, cursor):
continue
for window in windows:
generated.append(
GeneratedTuple(
definition_id,
child.id,
iso,
window.window,
window.due_time,
)
)
cursor += datetime.timedelta(days=1)
return generated
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
today: datetime.date | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved household-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in the
household's application time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, patterns_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(patterns_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the household-local anchor when the
# caller pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
for generated in _generation_preview(
snapshots, engine, start, end, child_filter=child_filter
):
written = await instances.upsert_if_valid(
generated.definition_id,
generated.child_id,
generated.due_date,
generated_at,
window=generated.window,
due_time=generated.due_time,
today=today,
)
if written is not None:
count += 1
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved household-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=horizon)
).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(
definition_id, start_date, today=today
)
return await materialize(database, start_date, end_date, today=today)
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: create_presence_pattern,
# update_presence_pattern, delete_presence_pattern,
# create_presence_override, and delete_presence_override. A pattern
# delete changes presence state too, so it must regenerate just like
# the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — create_presence_pattern,
update_presence_pattern, delete_presence_pattern,
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions
(never a completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved household-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-household time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
async def count_removed_by_override(
database: NestQuestDatabase,
override: PresenceOverride,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Count the upcoming instances saving ``override`` would remove.
Read-only: nothing is written. The window is the part of the
override's range inside the rolling horizon ``[today, today +
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
override is saved (outside the range the two presence answers agree,
so nothing there can change). The materialization input is read
ONCE (:func:`~.dao_rules.load_materialization_input`) and
:func:`_generation_preview` runs twice over it for the override's
child — with the stored presence, and with ``override`` layered on
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
tuples the first preview generates and the second does not, MINUS
every (definition, date, window) whose stored instance already has a
completion event (D-005: regeneration never deletes those). A
definition with ``skip_on_away`` off generates regardless of
presence, so it contributes nothing; a present override only ever
ADDS tuples, so it counts 0.
An override overlapping a stored one (which the create path refuses)
resolves through the engine's latest-start-wins rule. ``today`` and
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
resolves them; a malformed horizon raises ValueError.
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start = max(today_date, override.start_date)
end = min(
today_date + datetime.timedelta(days=horizon), override.end_date
)
if end < start:
return 0
start_date, end_date = start.isoformat(), end.isoformat()
child_id = override.child_id
snapshots, patterns_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
current = _generation_preview(
snapshots,
_build_engine(patterns_records, overrides_records),
start,
end,
child_filter={child_id},
)
proposed = set(
_generation_preview(
snapshots,
_build_engine(
patterns_records,
overrides_records,
extra_overrides=(override,),
),
start,
end,
child_filter={child_id},
)
)
removed = [generated for generated in current if generated not in proposed]
if not removed:
return 0
stored = await QuestInstancesDao(database).list_by_date_range(
child_id, start_date, end_date
)
touched = {
event.instance_id
for event in await CompletionEventsDao(
database
).list_by_child_and_date_range(child_id, start_date, end_date)
}
completed = {
(instance.definition_id, instance.due_date, instance.window)
for instance in stored
if instance.id in touched
}
return sum(
1
for generated in removed
if (generated.definition_id, generated.due_date, generated.window)
not in completed
)