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. Every built-in (see Dispatch Strategies) is deterministic, and each one round-trips its identity, tunable weights, and internal per-car state through WorldSnapshot so snapshot + restore produces an indistinguishable simulation.

Under those conditions two runs produce byte-identical snapshots and event streams. The cross-strategy invariant harness in crates/elevator-core/src/tests/invariants_tests.rs pins this tick-for-tick across all built-ins.

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 via BTreeMap).

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.

flowchart LR
    simA["Simulation A<br/>(at tick N)"] -->|sim.snapshot| ws1["WorldSnapshot"]
    ws1 -->|serde<br/>(RON / JSON /<br/>bincode / …)| bytes["bytes"]
    bytes -->|deserialize| ws2["WorldSnapshot"]
    ws2 -->|Simulation::from_snapshot| simB["Simulation B<br/>(at tick N)"]

    simA -.snapshot_checksum.-> chk["checksum"]
    simB -.snapshot_checksum.-> chk
    chk -.compared in.-> harness["elevator-contract<br/>vs. golden.txt"]

    classDef artifact fill:#1c1c1c,stroke:#666,color:#eee
    class ws1,ws2,bytes,chk artifact

Two simulations restored from the same bytes produce byte-identical snapshots forever after, given the same input sequence. The elevator-contract harness pins this across hosts: the core run and the wasm-bindgen run share a single golden.txt checksum.

Saving

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

// `RestoreOptions::default()` means "only built-in dispatch strategies";
// use `RestoreOptions::with_factory(...)` to resurrect custom strategies
// registered by name.
let sim = snapshot.restore(RestoreOptions::default())?;
Ok(())
}

Entity ID remapping

On restore, fresh EntityId values are generated (SlotMap keys are not stable across sessions). The snapshot stores entity data by index; restore() builds an old_id -> new_id mapping and remaps all cross-references (elevator riders, rider phases, route legs, group caches). This is transparent to callers.

Custom dispatch across restore

Built-in strategies (Scan, Look, NearestCar, Etd, Rsr, Destination) are auto-restored by name – BuiltinStrategy::instantiate() rebuilds each with default weights, and any tunable configuration applied via with_* builder methods is replayed from snapshot_config / restore_config immediately after. Custom strategies need a factory:

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::snapshot::WorldSnapshot;
struct HighestFirstDispatch;
impl DispatchStrategy for HighestFirstDispatch {
  fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> { Some(0.0) }
}
use elevator_core::snapshot::RestoreOptions;
fn run(snapshot: WorldSnapshot) {
let sim = snapshot.restore(RestoreOptions::with_factory(&|name: &str| match name {
    "HighestFirst" => Some(Box::new(HighestFirstDispatch) as Box<dyn DispatchStrategy>),
    _ => None,
}));
}
}

Custom strategies register their snapshot identity by overriding DispatchStrategy::builtin_id to return BuiltinStrategy::Custom("name"); that name is what the snapshot stores and what the factory closure receives on restore – make sure the two match. Overriding snapshot_config / restore_config gives the same tuning-survival guarantee the built-ins get. See Writing a Custom Dispatch – Step 4 for the full pattern.

Extensions across restore

Extensions are serialized by their registered name. Dispatch-internal extensions the sim itself owns (currently AssignedCar for DestinationDispatch) are auto-registered and auto-deserialized in Simulation::from_parts, so a DCS snapshot round-trip preserves sticky rider assignments without caller involvement. Game-owned extensions still need manual re-registration – re-register on the restored simulation’s world and call load_extensions:

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

Use the register_extensions! macro to register many types in one line. See Extensions – Snapshot integration for details.

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:

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, expected: &str) {
let snap = sim.snapshot();
let actual = ron::to_string(&snap).unwrap();
// Compare against a golden file checked into the repo:
// let expected = include_str!("../golden/scenario_a.ron");
assert_eq!(actual, expected);
}
}

This catches unintended behavior changes anywhere in the tick pipeline. See Testing Your Simulation for more patterns.

Research comparisons

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

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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.
}

Build both simulations from the same config and feed them the same seeded TrafficSource. After running for the same number of ticks, compare sim.metrics() to see which strategy performs better on wait time, throughput, or any other metric.

Next steps