Source code for modelly.timetravel.ring

"""Checkpoint rings: the recorded checkpoints of a run, within a byte budget.

A ring maps a step (the model's ``time`` when the checkpoint was taken) to the
bytes of a checkpoint file. When the ring holds more bytes than its budget it
drops its oldest checkpoints, except the first one, so a recorded run can
always be rewound to where recording started.

:class:`MemoryRing` keeps the bytes in memory. :class:`DiskRing` writes each
checkpoint to its own file through a temporary file and ``os.replace``, which
is atomic on Windows, macOS and Linux: a file in the ring is always whole.
"""

from __future__ import annotations

import os
from pathlib import Path

# The first checkpoint and the newest one are never evicted.
KEEP = 2
SUFFIX = ".ckpt"
PARTIAL = ".partial"


class _Ring:
    """Steps and sizes of the checkpoints held, with eviction to the budget."""

    def __init__(self, budget: int) -> None:
        """Hold checkpoints up to ``budget`` bytes in all."""
        if budget <= 0:
            raise ValueError(f"The checkpoint budget must be > 0, got {budget}")
        self.budget = budget
        self._sizes: dict[float, int] = {}

    @property
    def steps(self) -> list[float]:
        """The steps held, oldest first."""
        return list(self._sizes)

    @property
    def nbytes(self) -> int:
        """The bytes held in all."""
        return sum(self._sizes.values())

    def add(self, step: float, data: bytes) -> None:
        """Hold ``data`` as the checkpoint of ``step``, then keep to the budget."""
        self._store(step, data)
        self._sizes[step] = len(data)
        steps = self.steps
        while self.nbytes > self.budget and len(steps) > KEEP:
            self.discard(steps.pop(1))

    def discard(self, step: float) -> None:
        """Drop the checkpoint of ``step``."""
        self._sizes.pop(step)

    def get(self, step: float) -> bytes:
        """The checkpoint bytes of ``step``."""
        raise NotImplementedError

    def _store(self, step: float, data: bytes) -> None:
        """Keep ``data`` for ``step``."""
        raise NotImplementedError


[docs] class MemoryRing(_Ring): """Checkpoints held in memory as bytes.""" def __init__(self, budget: int) -> None: """Hold checkpoints up to ``budget`` bytes of memory.""" super().__init__(budget) self._data: dict[float, bytes] = {}
[docs] def get(self, step: float) -> bytes: """The checkpoint bytes of ``step``.""" return self._data[step]
[docs] def discard(self, step: float) -> None: """Drop the checkpoint of ``step``.""" super().discard(step) del self._data[step]
def _store(self, step: float, data: bytes) -> None: """Keep ``data`` for ``step`` in memory.""" self._data[step] = data
[docs] class DiskRing(_Ring): """Checkpoints held as files ``step-<time>.ckpt`` in one directory. Each file is an ordinary checkpoint that ``Model.load`` reads. Files left when the ring is dropped stay on disk; evicted ones are deleted. """ def __init__(self, directory: str | Path, budget: int) -> None: """Hold checkpoints up to ``budget`` bytes of disk in ``directory``.""" super().__init__(budget) self.directory = Path(directory) self.directory.mkdir(parents=True, exist_ok=True)
[docs] def path(self, step: float) -> Path: """The file that holds the checkpoint of ``step``.""" return self.directory / f"step-{step!r}{SUFFIX}"
[docs] def get(self, step: float) -> bytes: """The checkpoint bytes of ``step``, read from its file.""" return self.path(step).read_bytes()
[docs] def discard(self, step: float) -> None: """Drop the checkpoint of ``step`` and delete its file.""" super().discard(step) self.path(step).unlink(missing_ok=True)
def _store(self, step: float, data: bytes) -> None: """Write ``data`` to a temporary file, then move it into place.""" final = self.path(step) partial = final.with_name(final.name + PARTIAL) partial.write_bytes(data) os.replace(partial, final)