Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/formats/replay-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Replay bundle format v1

A replay bundle is a directory containing `manifest.json` and independent scene files. It contains presentation data; it cannot restart a model or execute simulation steps. `microsimulator export-replay CHECKPOINT... --output DIRECTORY` exports an explicitly ordered sequence using CPU checkpoint deserialization. It never imports the original model, invokes controller callbacks, or requires the source run's GPU.

## Manifest

The manifest envelope has exactly `format`, `version`, `integrity`, and `recording` fields. Format is `microsimulator-replay`, version is `1`, and `integrity` has `algorithm: "sha256"` and `recording`, the lowercase SHA-256 digest of the RFC 8785 canonical representation of the complete recording object. This detects corruption; it is not proof of publisher authenticity.

`recording` contains exactly:

- `export_backend`: backend identity of the CPU used to deserialize the portable checkpoints. This has the same `kind`, `name`, `device`, `device_index`, and `native` fields as a scene backend.
- `frames`: a nonempty ordered array of frame entries.

Each entry contains exactly:

| Field | Meaning |
| --- | --- |
| `ordinal` | Zero-based contiguous ordinal equal to the entry's array index |
| `time` | Finite nonnegative recorded simulation time |
| `file` | Safe relative path to a `.scene.json` document |
| `bytes` | Exact positive UTF-8 file byte length |
| `sha256` | Lowercase SHA-256 of the exact scene file bytes, including whitespace |
| `checkpoint_sha256` | Lowercase SHA-256 of the exact source checkpoint bytes consumed |
| `source_backend` | Backend identity recorded by that checkpoint's producer |

The entry order is authoritative. Paths and checkpoint names are never sorted. Times must be nondecreasing. Equal times remain distinct frames and receive distinct ordinals; they are useful for topology events or separate observations at the same physical time. The exporter does not interpolate, merge, or drop snapshots. Supply checkpoints from the same run when stable cell identity across frames is required; the exporter cannot infer common ancestry from arbitrary checkpoint provenance.

Paths may use ASCII letters, digits, underscores, hyphens and dots within nonempty segments. Segments start with a letter, digit, underscore or hyphen. Absolute paths, dot segments, backslashes, URI schemes and percent-encoded escapes are rejected. References must be unique. References identify files selected from the bundle folder; the browser never fetches URLs from a manifest.

Readers verify the recording digest, strict schema, ordinals, timestamps and references before opening a frame. On demand, they verify the file size and exact-file digest, then use the shared scene reader to verify the scene's own digest/schema. The scene time and source backend must match the entry. Scene version 2 or 3 is supported through that reader; current exports use version 3 and preserve channel metadata.

The scene's backend describes the source run, so the viewer does not present the exporter's CPU as the simulation device. The exporter identity is retained separately. Checkpoint source-backend values are provenance, not a request to allocate that device. Source paths are omitted; `checkpoint_sha256` identifies the source bytes without leaking machine-local paths. Each checkpoint is copied into a temporary snapshot before parsing, so a producer replacing its original path cannot make the parsed bytes differ from the recorded digest.

## Limits and failure behavior

Manifests are limited to 16 MiB and 100,000 entries. Each scene is bounded by the scene format's 1 GiB encoded limit. The exporter requires a new destination; it refuses existing files, folders and symlinks. It builds a temporary sibling and publishes the completed bundle only after all frames pass validation. Failures identify the zero-based ordinal and source checkpoint, remove the temporary export, and leave source checkpoints untouched.

The viewer retains file handles and manifest metadata, but reads scene payloads only on demand. Its LRU cache holds at most three decoded frames and at most 64 MiB of conservative decoded-size accounting units. Objects too large for that budget are displayed without entering the cache. This is not a 64 MiB process-heap limit: the current displayed frame, renderer/GPU buffers, manifest, file handles and one active load/parse can exist outside the cache. Cache accounting includes cell arrays, species, signal levels, boundary values, constraints and labels.

A single worker performs frame loading/decoding; repeated seeks replace one pending ordinal rather than creating a queue of decodes. Superseded reads are canceled where possible. Both successful and failed stale requests are ignored. Opening a different dataset cancels the previous reader and prevents its completion from changing the view. Failures leave the last successfully displayed frame in place, identify the affected ordinal/file, pause playback and allow seeking to another frame.

## Playback and presentation

Playback displays every recorded frame without interpolation. Configurable 1–120 frames/s defines a maximum presentation cadence; slow loading reduces achieved speed instead of skipping frames. Recorded simulation time remains visible independently of playback speed, including in the transport bar at narrow supported window widths. Playback stops at the last frame; pressing Play there restarts from the first when no seek is pending. Pressing Play during a pending seek waits for that requested frame, then starts timed playback from it. After a load failure, Play retries the failed requested frame and resumes only after it loads successfully; another failure leaves playback paused and preserves the last valid view. Manual seeking and previous/next stepping pause automatic playback.

Opening a recording begins one viewer dataset. Subsequent frames, backward steps and seeks use the shared presentation-update path. Camera pose, reference-grid geometry and index-based channel preferences persist. Selected cells follow stable IDs across slot changes; selection clears if the ID is absent. Missing signal grids temporarily hide the controls, and smaller grids clamp displayed indices while retaining preferences for later compatible frames. Opening another recording or a static scene starts a new dataset.
43 changes: 43 additions & 0 deletions examples/replay_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Short deterministic growth/division/removal recording for the replay tutorial."""

from microsimulator import (
CellInit,
ChannelMetadata,
CheckpointBundle,
ControllerStep,
DivisionRequest,
ModelContext,
NativeController,
StepPlan,
)


def regulate(step: ControllerStep) -> StepPlan:
if step.completed_steps == 1:
return StepPlan(divisions=(DivisionRequest(step.cells[0].id),))
if step.completed_steps == 2:
return StepPlan(removals=(step.cells[0].id,))
return StepPlan()


def build(context: ModelContext) -> NativeController:
simulation = context.simulation(species_count=1)
cell = CellInit()
cell.length = 4.0
cell.growth_rate = 0.5
cell.species = [0.25]
simulation.add_cell(cell)
return NativeController(
simulation,
model_id="replay-demo",
model_version=1,
rng=context.rng,
regulate=regulate,
channel_metadata=ChannelMetadata(species=("Reporter",)),
)


def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController:
return NativeController.from_checkpoint(
checkpoint, model_id="replay-demo", model_version=1, regulate=regulate
)
17 changes: 15 additions & 2 deletions python/src/microsimulator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ def _parser() -> argparse.ArgumentParser:
)
analysis.add_argument("--overwrite", action="store_true")

replay = commands.add_parser(
"export-replay", help="export explicitly ordered checkpoints for offline replay"
)
replay.add_argument("checkpoints", nargs="+", type=Path)
replay.add_argument("--output", type=Path, required=True, help="new replay bundle directory")

manifest = commands.add_parser(
"run-manifest", help="execute one named job from a data-only run manifest"
)
Expand Down Expand Up @@ -459,8 +465,7 @@ def _export_analysis(arguments: argparse.Namespace) -> int:
if not backend_available(backend, device_index):
count = backend_device_count(backend)
raise BatchError(
f"backend {backend_name} device {device_index} is unavailable "
f"({count} device(s) found)"
f"backend {backend_name} device {device_index} is unavailable ({count} device(s) found)"
)
summary = export_dataset(
cast(list[Path], arguments.checkpoints),
Expand Down Expand Up @@ -511,6 +516,14 @@ def main(argv: Sequence[str] | None = None) -> int:
return _view(arguments)
if arguments.command == "export-analysis":
return _export_analysis(arguments)
if arguments.command == "export-replay":
from .replay import export_replay

summary = export_replay(
cast(list[Path], arguments.checkpoints), cast(Path, arguments.output)
)
print(f"wrote {summary.output} frames={summary.frame_count}")
return 0
if arguments.command == "run-manifest":
return _run_manifest(arguments)
return _run(arguments)
Expand Down
146 changes: 146 additions & 0 deletions python/src/microsimulator/replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Data-only replay export from an explicitly ordered checkpoint sequence."""

from __future__ import annotations

import hashlib
import json
import os
import tempfile
from collections.abc import Sequence
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import cast

import rfc8785

from ._core import BackendKind # pyright: ignore[reportMissingModuleSource]
from .checkpoint import MAX_CHECKPOINT_BYTES, CheckpointError, JSONValue, load_checkpoint_bundle
from .scene import MAX_SCENE_BYTES, SceneBackend, SceneBackendKind, capture_scene, dumps_scene

REPLAY_FORMAT = "microsimulator-replay"
REPLAY_VERSION = 1
MAX_REPLAY_FRAMES = 100_000
MAX_REPLAY_MANIFEST_BYTES = 16 * 1024 * 1024


class ReplayExportError(ValueError):
"""Raised when an ordered recording cannot be exported without loss."""


@dataclass(frozen=True, slots=True)
class ReplayExportSummary:
output: Path
frame_count: int


def export_replay(
checkpoints: Sequence[str | os.PathLike[str]], output: str | os.PathLike[str]
) -> ReplayExportSummary:
"""Export exact snapshots on CPU, never loading model source or stepping biology.

Input order is authoritative. Equal times remain distinct frames; decreasing
time is rejected. The destination must not exist, including an empty folder.
"""

if not 1 <= len(checkpoints) <= MAX_REPLAY_FRAMES:
raise ReplayExportError(f"expected 1 to {MAX_REPLAY_FRAMES} ordered checkpoints")
destination = Path(output).absolute()
if destination.exists() or destination.is_symlink():
raise ReplayExportError(f"output already exists: {destination}")
try:
destination.parent.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise ReplayExportError(
f"could not prepare replay destination {destination}: {error}"
) from error
entries: list[JSONValue] = []
previous_time = -1.0
export_backend: JSONValue = None
try:
with tempfile.TemporaryDirectory(
prefix=f".{destination.name}.", dir=destination.parent
) as temporary:
stage = Path(temporary)
frames_dir = stage / "frames"
frames_dir.mkdir()
snapshot = stage / ".checkpoint.json"
for ordinal, checkpoint_path in enumerate(checkpoints):
try:
# Parse exactly the bytes whose digest is recorded, even if a
# running producer replaces the original checkpoint concurrently.
digest = hashlib.sha256()
size = 0
with Path(checkpoint_path).open("rb") as source, snapshot.open("wb") as target:
while chunk := source.read(1024 * 1024):
size += len(chunk)
if size > MAX_CHECKPOINT_BYTES:
raise ReplayExportError("checkpoint exceeds its byte limit")
target.write(chunk)
digest.update(chunk)
bundle = load_checkpoint_bundle(snapshot, backend=BackendKind.CPU)
captured = capture_scene(
bundle.simulation, channel_metadata=bundle.channel_metadata
)
if captured.time < previous_time:
raise ReplayExportError(
f"time {captured.time} precedes previous frame time {previous_time}"
)
previous_time = captured.time
if export_backend is None:
export_backend = cast(JSONValue, asdict(captured.backend))
source_backend = bundle.source_backend
# The displayed scene describes its source run, not the CPU
# used solely to deserialize portable state during export.
frame = replace(
captured,
backend=SceneBackend(
kind=cast(SceneBackendKind, source_backend.kind),
name=source_backend.name,
device=source_backend.device,
device_index=source_backend.device_index,
native=source_backend.native,
),
)
encoded = dumps_scene(frame).encode("utf-8")
if len(encoded) > MAX_SCENE_BYTES:
raise ReplayExportError("scene exceeds its byte limit")
relative = f"frames/{ordinal:08d}.scene.json"
(stage / relative).write_bytes(encoded)
entries.append(
{
"ordinal": ordinal,
"time": frame.time,
"file": relative,
"bytes": len(encoded),
"sha256": hashlib.sha256(encoded).hexdigest(),
"checkpoint_sha256": digest.hexdigest(),
"source_backend": cast(JSONValue, asdict(source_backend)),
}
)
except (OSError, ValueError, RuntimeError) as error:
raise ReplayExportError(
f"frame {ordinal} ({checkpoint_path}): {error}"
) from error
snapshot.unlink()
recording: dict[str, JSONValue] = {"export_backend": export_backend, "frames": entries}
document: dict[str, JSONValue] = {
"format": REPLAY_FORMAT,
"version": REPLAY_VERSION,
"integrity": {
"algorithm": "sha256",
"recording": hashlib.sha256(rfc8785.dumps(recording)).hexdigest(),
},
"recording": recording,
}
encoded_manifest = (
json.dumps(document, allow_nan=False, ensure_ascii=False, indent=2) + "\n"
).encode("utf-8")
if len(encoded_manifest) > MAX_REPLAY_MANIFEST_BYTES:
raise ReplayExportError("replay manifest exceeds the 16 MiB limit")
(stage / "manifest.json").write_bytes(encoded_manifest)
if destination.exists() or destination.is_symlink():
raise ReplayExportError(f"output already exists: {destination}")
stage.rename(destination)
except (OSError, CheckpointError) as error:
raise ReplayExportError(f"could not export replay to {destination}: {error}") from error
return ReplayExportSummary(destination, len(entries))
Loading