Skip to content

symfonic.capabilities.memory.cadence

cadence

When a scope is next due, counted per scope and decided without awaiting.

Split from :mod:symfonic.capabilities.memory.napping at the 300-line budget, and the two halves are two questions: this one answers whether a scope consolidates, and the module it left owns the work once it does.

Worth its own module for a second reason. The shipped engine counted turns on the agent, so two tenants served by one process shared a cadence and either consolidated on the other's turns. The counter being keyed by scope is the fix, and keeping it in one small object is what makes that visible.

Cadence

Cadence(schedule: ConsolidationSchedule, cycles: Sequence[ConsolidationCycle], cursors: MutableMapping[str, ScheduleCursor] | None = None)

One counter per scope, and the rule that reads it.

Source code in src/symfonic/capabilities/memory/cadence.py
def __init__(
    self,
    schedule: ConsolidationSchedule,
    cycles: Sequence[ConsolidationCycle],
    cursors: MutableMapping[str, ScheduleCursor] | None = None,
) -> None:
    self._schedule = schedule
    self._cycles = tuple(cycles)
    self._cursors: MutableMapping[str, ScheduleCursor] = (
        cursors if cursors is not None else {}
    )

after_turn

after_turn(scope: MemoryScope) -> ConsolidationCycle | None

Record a successful turn and answer which cycle is now due.

No await anywhere in this method, deliberately: the read, the arithmetic and the write are one step, so two turns completing at once cannot both be told the same turn count is due.

Source code in src/symfonic/capabilities/memory/cadence.py
def after_turn(self, scope: MemoryScope) -> ConsolidationCycle | None:
    """Record a successful turn and answer which cycle is now due.

    No ``await`` anywhere in this method, deliberately: the read, the
    arithmetic and the write are one step, so two turns completing at once
    cannot both be told the same turn count is due.
    """
    cursor = self.cursor(scope).turn()
    cycle = self._schedule.due(cursor, among=self._cycles)
    if cycle is not None:
        # Reset *before* the cycle runs, not after. A cycle that took a
        # second while three turns finished would otherwise have all three
        # read the pre-reset counter and queue three naps for one batch.
        cursor = cursor.completed(cycle, at=datetime.now(UTC))
    self._cursors[scope.path] = cursor
    return cycle

cursor

cursor(scope: MemoryScope) -> ScheduleCursor

This scope's counter. A scope nobody has served yet has a fresh one.

Source code in src/symfonic/capabilities/memory/cadence.py
def cursor(self, scope: MemoryScope) -> ScheduleCursor:
    """This scope's counter. A scope nobody has served yet has a fresh one."""
    return self._cursors.get(scope.path, ScheduleCursor())

uncomplete

uncomplete(scope: MemoryScope) -> None

Put the cursor back on the threshold after a cancelled cycle.

after_turn reset it to zero on the way in, so leaving it there would cost this scope a whole cadence for a cycle that never ran. Back to the threshold, not back by one: the turns that earned this nap already happened.

Source code in src/symfonic/capabilities/memory/cadence.py
def uncomplete(self, scope: MemoryScope) -> None:
    """Put the cursor back on the threshold after a cancelled cycle.

    ``after_turn`` reset it to zero on the way in, so leaving it there
    would cost this scope a whole cadence for a cycle that never ran. Back
    to the threshold, not back by one: the turns that earned this nap
    already happened.
    """
    self._cursors[scope.path] = replace(
        self.cursor(scope),
        turns_since_quick=self._schedule.quick_every_turns,
    )