Skip to content

symfonic.capabilities.memory.schedule

schedule

When consolidation runs, and what it runs — three cadences, one cursor.

The shipped system schedules exactly one cadence in-process (quick_nap, every N turns) and leaves the full roster to whatever cron the adopter owns. The consequence is documented in the shipped code itself: a phase that lived only in the full roster was dead configuration for every adopter without a scheduler, and had to be added to the quick roster after the fact.

So the cadence is a value here, not a call site:

  • quick — the every-N-turns nap. Cheap enough to run inside a turn. Transcribes legacy's quick_nap.
  • nightly — a profile this capability defines: the full roster without entity extraction, for a quiet window that should not pay for it. Nothing in the shipped system runs this one.
  • deep — Deep Sleep: roster parity with the shipped consolidator plus scope_promotion. The widest, including every phase that costs a model call, on a much longer period.

Legacy has two rosters and not three: nightly_nap calls run, so "nightly" and "Deep Sleep" are the same sixteen phases on two schedules. The middle cadence here is new, and saying so is what keeps DEEP the one that answers for parity.

Two rules make the cursor safe to persist and safe to resume:

  • One cycle per tick, the widest that is due. Running quick and nightly in the same tick would run the quick phases twice — every counter doubled, every idempotency claim tested for no reason.
  • A wider cycle satisfies the narrower ones. Deep Sleep did the quick phases; leaving the turn counter untouched would fire a quick nap on the very next turn.

ConsolidationCycle

Bases: StrEnum

The three consolidation cadences, narrowest first.

ConsolidationSchedule dataclass

ConsolidationSchedule(quick_every_turns: int = 5, nightly_after_seconds: float = 24 * 60 * 60, deep_after_seconds: float = 7 * 24 * 60 * 60)

The cadences, and the rule that picks one.

due

due(cursor: ScheduleCursor, *, now: datetime | None = None, among: Sequence[ConsolidationCycle] | None = None) -> ConsolidationCycle | None

The widest cycle due at now, or None.

among narrows the answer to cadences the caller can actually run. It is not a convenience: a cadence that has never run reads as infinitely overdue, so on a fresh deployment every cadence is due on turn one — and a turn-driven nap asked for the widest would be told to run Deep Sleep, which is the roster it was never composed for and the cost nobody scheduled. A caller that runs one cadence asks about one.

Source code in src/symfonic/capabilities/memory/schedule.py
def due(
    self,
    cursor: ScheduleCursor,
    *,
    now: datetime | None = None,
    among: Sequence[ConsolidationCycle] | None = None,
) -> ConsolidationCycle | None:
    """The widest cycle due at ``now``, or ``None``.

    ``among`` narrows the answer to cadences the caller can actually run.
    It is not a convenience: a cadence that has never run reads as
    infinitely overdue, so on a fresh deployment *every* cadence is due on
    turn one — and a turn-driven nap asked for the widest would be told to
    run Deep Sleep, which is the roster it was never composed for and the
    cost nobody scheduled. A caller that runs one cadence asks about one.
    """
    moment = now if now is not None else datetime.now(UTC)
    allowed = _WIDEST_FIRST if among is None else tuple(among)
    for cycle in _WIDEST_FIRST:
        if cycle in allowed and self._is_due(cycle, cursor, moment):
            return cycle
    return None

validate

validate() -> None

Refuse a schedule under which a cadence can never fire.

Source code in src/symfonic/capabilities/memory/schedule.py
def validate(self) -> None:
    """Refuse a schedule under which a cadence can never fire."""
    if self.quick_every_turns < 1:
        raise MemoryContractError(
            f"quick_every_turns is {self.quick_every_turns}; a cadence of zero turns "
            "either never fires or fires on every turn, and the two read identically "
            "from the call site. Disable a cadence by not scheduling it."
        )
    if self.nightly_after_seconds <= 0 or self.deep_after_seconds <= 0:
        raise MemoryContractError(
            "nightly and deep cadences are periods in seconds and must be positive."
        )
    if self.deep_after_seconds <= self.nightly_after_seconds:
        raise MemoryContractError(
            f"deep_after_seconds ({self.deep_after_seconds}) is not wider than "
            f"nightly_after_seconds ({self.nightly_after_seconds}). Deep Sleep is the "
            "wider roster; at this configuration every due nightly run is also a due "
            "deep run, so the nightly cadence never fires on its own."
        )

ScheduleCursor dataclass

ScheduleCursor(turns_since_quick: int = 0, last_quick_at: datetime | None = None, last_nightly_at: datetime | None = None, last_deep_at: datetime | None = None)

What has run so far. The persisted half of the schedule.

completed

completed(cycle: ConsolidationCycle, *, at: datetime) -> ScheduleCursor

Record cycle as run, satisfying every narrower cadence too.

Source code in src/symfonic/capabilities/memory/schedule.py
def completed(self, cycle: ConsolidationCycle, *, at: datetime) -> ScheduleCursor:
    """Record ``cycle`` as run, satisfying every narrower cadence too."""
    updates: dict[str, Any] = {"turns_since_quick": 0, "last_quick_at": at}
    if cycle in (ConsolidationCycle.NIGHTLY, ConsolidationCycle.DEEP):
        updates["last_nightly_at"] = at
    if cycle is ConsolidationCycle.DEEP:
        updates["last_deep_at"] = at
    return replace(self, **updates)

from_state classmethod

from_state(state: dict[str, Any]) -> ScheduleCursor

Read a persisted cursor, tolerating one written by an older build.

Missing keys default rather than raise: a cursor written before Deep Sleep existed is a valid cursor with no deep run behind it, and refusing it would make a rollback forward-incompatible.

Source code in src/symfonic/capabilities/memory/schedule.py
@classmethod
def from_state(cls, state: dict[str, Any]) -> ScheduleCursor:
    """Read a persisted cursor, tolerating one written by an older build.

    Missing keys default rather than raise: a cursor written before Deep
    Sleep existed is a valid cursor with no deep run behind it, and
    refusing it would make a rollback forward-incompatible.
    """
    return cls(
        turns_since_quick=int(state.get("turns_since_quick", 0) or 0),
        last_quick_at=_parse(state.get("last_quick_at")),
        last_nightly_at=_parse(state.get("last_nightly_at")),
        last_deep_at=_parse(state.get("last_deep_at")),
    )

to_state

to_state() -> dict[str, Any]

The persisted form: ISO-8601 timestamps, like every other record.

Source code in src/symfonic/capabilities/memory/schedule.py
def to_state(self) -> dict[str, Any]:
    """The persisted form: ISO-8601 timestamps, like every other record."""
    return {
        "turns_since_quick": self.turns_since_quick,
        "last_quick_at": _iso(self.last_quick_at),
        "last_nightly_at": _iso(self.last_nightly_at),
        "last_deep_at": _iso(self.last_deep_at),
    }

turn

turn() -> ScheduleCursor

Advance by one completed turn.

Source code in src/symfonic/capabilities/memory/schedule.py
def turn(self) -> ScheduleCursor:
    """Advance by one completed turn."""
    return replace(self, turns_since_quick=self.turns_since_quick + 1)