Source code for modelly.timetravel.recorder

"""Record a run as a ring of checkpoints and rewind it to any step.

A :class:`Recorder` checkpoints the model when recording starts and then every
``every`` time units, holding the checkpoints in a ring
(:mod:`modelly.timetravel.ring`). ``at(step)`` restores the nearest checkpoint
at or before ``step`` and replays forward to it. Checkpoints and replay are
the 1.2 checkpoints (:meth:`modelly.Model.save`), so the model returned is the
one a straight run reaches at that step, bit for bit.
"""

from __future__ import annotations

import math
import types
from pathlib import Path
from typing import TYPE_CHECKING, Any

import numpy as np

from modelly.core.checkpoint import checkpoint_bytes, model_from_bytes
from modelly.timetravel.ring import DiskRing, MemoryRing

if TYPE_CHECKING:
    from modelly.core.model import Model

DEFAULT_EVERY = 10
MEBIBYTE = 2**20
DEFAULT_BUDGET = 256 * MEBIBYTE
# The attribute the recorder hooks on the model instance.
HOOK = "_advance_time"


def _plain_advance(model: Model) -> types.MethodType:
    """The model's own ``_advance_time``, for a model pickled while recorded."""
    return types.MethodType(type(model)._advance_time, model)


class _Hook:
    """Advances time for the recorded model, stopping at each checkpoint."""

    def __init__(self, recorder: Recorder) -> None:
        """Advance on behalf of ``recorder``."""
        self.recorder = recorder

    def __call__(self, until: float) -> None:
        """Advance the model to ``until``, checkpointing on the way."""
        self.recorder._advance(until)

    def __reduce__(self) -> tuple[Any, ...]:
        """Pickle (``model.save`` while recording) as the plain method."""
        return _plain_advance, (self.recorder.model,)


[docs] def thaw(model: Model) -> Model: """Let ``model.scenario`` be changed, as on a model returned by ``at``.""" object.__setattr__(model.scenario, "_frozen", False) return model
[docs] def reseed_model(model: Model, seed: np.random.SeedSequence) -> None: """Reseed both generators of ``model`` in place from ``seed``. ``model.rng`` gets a bit generator of its own class seeded with ``seed`` and ``model.random`` the stdlib seed ``Model`` derives from a seed sequence, so spaces and columns holding the generators follow. """ bit_generator = model.rng.bit_generator bit_generator.state = type(bit_generator)(seed).state model.random.seed(int(seed.generate_state(1, dtype=np.uint32)[0]))
[docs] class Recorder: """Checkpoint a model as it runs, to rewind, replay and branch it. Use it as a context manager, or call ``start()`` and ``stop()``:: with Recorder(model, every=10) as rec: model.run_for(1000) past = rec.at(312) Args: model: The model to record. every: Time units between checkpoints; one is also taken at start. memory_budget: Bytes the checkpoints may take in all, in memory or, with ``directory``, on disk. The oldest are evicted beyond it, except the first and the newest. directory: Write the checkpoints to this directory instead of holding them in memory. compress: Gzip each checkpoint (smaller, slower). """ def __init__( self, model: Model, every: float = DEFAULT_EVERY, *, memory_budget: int = DEFAULT_BUDGET, directory: str | Path | None = None, compress: bool = False, ) -> None: """Prepare to record ``model``; recording starts with ``start()``.""" if every <= 0: raise ValueError(f"Checkpoint interval must be > 0, got {every}") self.model = model self.every = every self.compress = compress if directory is None: self._ring: MemoryRing | DiskRing = MemoryRing(memory_budget) else: self._ring = DiskRing(directory, memory_budget) self._last: float | None = None def __enter__(self) -> Recorder: """Start recording.""" self.start() return self def __exit__(self, *exc: object) -> None: """Stop recording; the checkpoints stay available.""" self.stop() @property def recording(self) -> bool: """Whether the model is being recorded now.""" hook = vars(self.model).get(HOOK) return isinstance(hook, _Hook) and hook.recorder is self @property def steps(self) -> list[float]: """The steps that hold a checkpoint, oldest first.""" return self._ring.steps @property def span(self) -> tuple[float, float]: """The first and last step ``at`` can return.""" if self._last is None: raise ValueError("Nothing recorded yet: call start() first.") return self.steps[0], self._last @property def nbytes(self) -> int: """The bytes the checkpoints take, in memory or on disk.""" return self._ring.nbytes
[docs] def start(self) -> None: """Checkpoint the model now and every ``every`` time units from here.""" hook = vars(self.model).get(HOOK) if isinstance(hook, _Hook): raise RuntimeError("The model is already being recorded.") vars(self.model)[HOOK] = _Hook(self) self._capture()
[docs] def stop(self) -> None: """Stop recording; ``at`` and ``branch`` keep working.""" if self.recording: del vars(self.model)[HOOK]
[docs] def checkpoint(self) -> None: """Checkpoint the model now, after changing it from outside the run. Replay reruns the model's own dynamics only: a change made between two ``run_for`` calls, such as setting an agent attribute, reaches ``at`` for the steps after the next checkpoint. Call this right after such a change so that every later step includes it. """ self._capture()
[docs] def at(self, step: float) -> Model: """A new model in the state of the recorded run at ``step``. The nearest checkpoint at or before ``step`` is restored and run on to ``step``. The model is independent of the recorded one and of the checkpoints, and its scenario can be changed (see the time travel guide for what that means). Raises: ValueError: If ``step`` is outside ``span``. """ first, last = self.span if not first <= step <= last: raise ValueError(f"Step {step} is outside the recorded span {self.span}.") base = max(s for s in self.steps if s <= step) model = model_from_bytes(self._ring.get(base), f"checkpoint at {base}") if step > base: model._advance_time(step) return thaw(model)
[docs] def branch(self, step: float, n: int, *, reseed: bool = False) -> list[Model]: """``n`` independent models, all in the state of the run at ``step``. Without ``reseed`` every branch continues the same random streams, so identical runs stay identical until you change one. With ``reseed``, branch ``i`` is reseeded from ``scenario.seed_sequence.spawn(n)[i]``: the child seeds ``Scenario.spawn_replications(n)`` uses. """ state = checkpoint_bytes(self.at(step)) models = [thaw(model_from_bytes(state)) for _ in range(n)] if reseed and models: children = models[0].scenario.seed_sequence.spawn(n) for model, child in zip(models, children): reseed_model(model, child) return models
def _advance(self, until: float) -> None: """Advance the model to ``until`` in pieces ending at checkpoints.""" model = self.model plain = _plain_advance(model) if until <= model.time: plain(until) # warns, as it does without a recorder return index = math.floor(model.time / self.every) + 1 while index * self.every <= until: plain(float(index * self.every)) self._capture() index += 1 if model.time < until: plain(until) self._last = model.time def _capture(self) -> None: """Add a checkpoint of the model now, without the recorder's hook.""" model = self.model hook = vars(model).pop(HOOK, None) try: data = checkpoint_bytes(model, compress=self.compress) finally: if hook is not None: vars(model)[HOOK] = hook self._ring.add(model.time, data) self._last = model.time