Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Snapshots and Determinism

elevator-core is designed for reproducible simulations. This chapter covers the determinism guarantees, the snapshot save/load API, and the patterns for replay, regression testing, and research comparisons.

Determinism guarantee

The simulation is deterministic given:

  1. The same initial SimConfig (same stops, elevators, groups, lines, dispatch strategy).
  2. The same sequence of API calls (spawn_rider, despawn_rider, tag_entity, hook mutations, etc.).
  3. A deterministic dispatch strategy (the four built-ins — ScanDispatch, LookDispatch, NearestCarDispatch, EtdDispatch — are deterministic).

Under those conditions two runs produce byte-identical snapshots and event streams.

Sources of non-determinism to watch for:

  • PoissonSource and similar traffic generators use a thread-local RNG. See Traffic Generation → Determinism and seeding.
  • Custom dispatch strategies or hooks that read wall-clock time, thread IDs, or unseeded RNGs.
  • HashMap iteration order in your own hook code (the sim itself uses stable iteration).

Snapshots

A WorldSnapshot captures the full simulation state — all entities, components, groups, lines, metrics, tagged metrics, tick counter — in a serializable struct. Extension components are captured by type name and need a matching registration on restore. Resources and hooks are not captured.

Saving

use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
    .stop(StopId(0), "Ground", 0.0)
    .stop(StopId(1), "Top", 10.0)
    .elevator(ElevatorConfig::default())
    .build()?;
for _ in 0..1000 { sim.step(); }

let snapshot = sim.snapshot();
let bytes = ron::to_string(&snapshot).unwrap();
std::fs::write("save.ron", bytes).unwrap();
Ok(())
}

The snapshot struct is Serialize + Deserialize — choose any serde format (RON, JSON, bincode, postcard).

Loading

use elevator_core::prelude::*;
use elevator_core::snapshot::WorldSnapshot;
fn main() -> Result<(), SimError> {
let bytes = std::fs::read_to_string("save.ron").unwrap();
let snapshot: WorldSnapshot = ron::from_str(&bytes).unwrap();

// `None` means "only built-in dispatch strategies"; pass a closure to
// resurrect custom strategies registered by name.
let sim = snapshot.restore(None);
Ok(())
}

Custom dispatch across restore

Built-in strategies (Scan, Look, NearestCar, Etd) are auto-restored by name. Custom strategies need a factory:

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use elevator_core::world::World;
struct HighestFirstDispatch;
impl DispatchStrategy for HighestFirstDispatch {
  fn decide(&mut self, _: EntityId, _: f64, _: &elevator_core::dispatch::ElevatorGroup, _: &DispatchManifest, _: &World) -> DispatchDecision { DispatchDecision::Idle }
}
fn run(snapshot: WorldSnapshot) {
let sim = snapshot.restore(Some(&|name: &str| match name {
    "HighestFirst" => Some(Box::new(HighestFirstDispatch)),
    _ => None,
}));
}
}

Custom strategies are registered with BuiltinStrategy::Custom("name") via sim.set_dispatch(group, Box::new(HighestFirstDispatch), BuiltinStrategy::Custom("HighestFirst".into())). That registered name is what the snapshot stores and what the factory closure receives on restore — make sure the two match.

Extensions across restore

Extensions are serialized by their registered name. To restore them, re-register on the restored simulation’s world and then call load_extensions:

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use serde::{Serialize, Deserialize};
#[derive(Clone, Serialize, Deserialize)] struct VipTag;
fn run(snapshot: WorldSnapshot) {
let mut sim = snapshot.restore(None);
sim.world_mut().register_ext::<VipTag>("vip_tag");
sim.load_extensions();
}
}

Use the register_extensions! macro to register many types in one line.

Patterns

Replay

  1. Serialize the initial config.
  2. Log every external mutation (spawn_rider, despawn_rider, tag changes) with its tick.
  3. To replay: rebuild the sim from config, then step while replaying logged mutations at the right ticks.

Snapshots are a stronger alternative — you can start replay from any tick by restoring a snapshot taken at that tick.

Regression testing

Run a seeded scenario for N ticks, snapshot, and diff against a golden snapshot:

let snap = sim.snapshot();
let actual = ron::to_string(&snap).unwrap();
let expected = include_str!("../golden/scenario_a.ron");
assert_eq!(actual, expected);

This catches unintended behavior changes anywhere in the tick pipeline.

Research comparisons

To compare dispatch strategies fairly, use identical seeded traffic across runs:

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
use elevator_core::dispatch::scan::ScanDispatch;
use elevator_core::dispatch::etd::EtdDispatch;
fn build_sim(dispatch: impl DispatchStrategy + 'static) -> Simulation {
  SimulationBuilder::new()
      .stop(StopId(0), "Ground", 0.0)
      .stop(StopId(1), "Top", 10.0)
      .elevator(ElevatorConfig::default())
      .dispatch(dispatch)
      .build()
      .unwrap()
}
fn run_with(sim: &mut Simulation) {}
let mut scan_sim = build_sim(ScanDispatch::new());
let mut etd_sim  = build_sim(EtdDispatch::new());
run_with(&mut scan_sim);  // same seed, same traffic source construction
run_with(&mut etd_sim);
// Compare metrics side-by-side.
}

Next steps

See Performance for scaling guidance and benchmark interpretation.