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:
- The same initial
SimConfig(same stops, elevators, groups, lines, dispatch strategy). - The same sequence of API calls (
spawn_rider,despawn_rider,tag_entity, hook mutations, etc.). - 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:
PoissonSourceand 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
- Serialize the initial config.
- Log every external mutation (
spawn_rider,despawn_rider, tag changes) with its tick. - 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.