Skip to content

Simulate

Deterministic apply is what makes recovery possible; it also makes replay a test oracle. This chapter is the seeded harness built on that: one seed and one script, virtual time instead of sleeping, scripted service faults, and two standing laws: that the history a run produced is enough to rebuild the state it reached, and that every inverse it derived restored what its change replaced. A failing seed becomes a reproducible test case rather than a hint.

Deterministic apply made recovery possible. It also gives tests a stronger question than “did this one assertion pass?”: after any scripted run, does folding the history again produce the exact state the live run reached?

The sim feature turns that question into a tier-one harness over the whole runtime — commits, records, streams, Jobs, Services, stop, and reboot — while keeping infrastructure failures out. Storage corruption and network partitions belong to adapter tests; this harness controls application-visible scheduling, time, service exits, and entropy.

The harness is behind an explicit feature, so enable it where your tests are:

[dev-dependencies]
harmos = { workspace = true, features = ["sim"] }

Then assemble the same nouns a live runtime uses:

#[harmos::lanes]
enum Lanes {
/// Everything that names no specific lane.
#[default]
Open,
/// One analysis at a time, in the script exactly as live.
#[lane(serial)]
Analysis,
}
let mut sim = app::builder(Schematic::default(), ())
.register(Lanes::catalog())
.simulated_service("instrument", instrument_loop)
.entropy_seed(42)
.simulate()?;

The seed is selected before simulate(), not after it: simulate is the verb that acts, and everything in front of it is a declaration.

The harness owns a current-thread executor, Tokio virtual time, an in-memory recovery source, and a seeded entropy stream. Owning the executor is what makes the script verbs synchronous: the harness drives it between steps rather than handing you futures to await. That is also why it is feature-gated and confined to SimulatedRuntime — a live runtime runs on whatever Tokio runtime you started, and harmos installs none of its own.

app::builder is the same invariant declaration used for a live runtime (chapter 3). Catalog registration is captured internally so reboot can freeze the same catalog again; there is no second public builder and no registrations to repeat. Deterministic service doubles attach with simulated_service_with(name, policy, resident), making restart credit and backoff part of virtual time. Live-only storage or services are refused by simulate() rather than silently left out.

When a build has simulated services or resources of its own, that whole composition is worth naming once beside the other two entry verbs, so a test asks for a seed and nothing else:

#[cfg(feature = "sim")]
pub fn simulate(origin: impl Into<Origin>, seed: u64) -> Result<Simulated, Incompatible> {
builder(origin, ()).entropy_seed(seed).simulate()
}

The schematic editor does exactly that, and its test calls app::simulate(Schematic::default(), 42).

The verbs are synchronous because the harness owns and drives its executor. The schematic example uses the same state and transactions as its live runtime, runs the script twice, and then applies both standing laws (mise run test:minischematic:simulation):

#[test]
fn the_same_editor_script_replays_and_restores_its_inverses() {
let first = scripted(42);
let second = scripted(42);
first.assert_same(&second);
assert_eq!(first.trace(), second.trace());
first.assert_replay_equivalent();
first.assert_inverses_restore();
assert_eq!(
first
.state()
.expect("the rebooted schematic is readable")
.value
.net(POWER)
.name,
NetName::parse("VCC")
);
}

The scripted function immediately below that test calls app::simulate(Schematic::default(), seed) and then runs the ordinary commit, stop, and reboot sequence. The test lives in the application crate's own tests/, beside the verb it calls, which is what makes simulation a mode of the editor rather than a second toy application.

Facts and finite work take the same shape. record appends one, and a job is submitted, advanced past its deadline, and settled without anything sleeping:

sim.record(alice(), ExportCompleted { .. })?;
let analysis = sim.submit(Analyze { .. });
sim.advance_ms(500);
let report = sim.settle(analysis)?;

advance_ms moves virtual time. Job deadlines, retry backoff, and service backoff elapse without sleeping on wall time. fault delivers the exact exit regime to the current resident attempt. Jobs and Services draw entropy through harmos::sim::entropy() inside the same executor.

The trace contains canonical history identity, stream cursors, service exits, and entropy draws. Two independent runs from the same seed and script must be byte-identical:

let first = scripted(42);
let second = scripted(42);
first.assert_same(&second);
assert_eq!(first.trace(), second.trace());

Changing the seed may change scheduling and entropy. Reusing it must never do so. A failing seed is therefore a reproducible test case, not a probabilistic hint.

Simulation's in-memory storage uses ordinary recovery:

let stopped = sim.stop();
sim.reboot()?;
sim.assert_replay_equivalent();
let state = sim.state()?;
assert_eq!(state.position, stopped);

assert_replay_equivalent folds the stored order and compares it with the live state. Call it after meaningful steps, not only at the end: when an application accidentally reads a clock or random source inside apply, the assertion localizes the first divergent step.

Reboot also rebuilds derived undo state. A script may commit, stop, reboot, then call sim.undo(principal, scope) to prove recovery reconstructed the same personal undo path as the live runtime.

The compiler holds every rule about which change takes another back (chapter 7); it cannot hold the one about the values an inverse carries, because that is arithmetic in your own domain. sim.assert_inverses_restore() is the standing law for it: for every change the scripted run applied that declared an inverse, apply the change and then the inverse derived from the state before it, and land back on that state exactly. A change declared irreversible derives nothing and is skipped, which is what a barrier honestly means.

sim.assert_inverses_restore();

It reads the whole state, so it holds applications to a strict standard: an undo that leaves an emptied collection behind, or a counter one higher than it started, is a divergence the assertion names — with the position of the change that broke it and both states side by side.

The promise is deliberately bounded:

  • same seed + same script -> same history, cursors, service exits, entropy;
  • virtual work timing advances only when the script advances it;
  • replay of that history -> the same application state.

It does not simulate filesystem errors, corrupted JSONL, network partitions, or Wasm guest scheduling. Those cross infrastructure boundaries and belong to a later fault-injection tier. Keeping tier one small is what makes it fast enough to sit in every application test suite.

The default harmos build compiles none of this module. Production code pays no dependency or vocabulary cost for a test harness it did not enable.

1. A service retry test sleeps for 20 ms and usually passes. What should the simulation do instead?

Deliver the environmental fault, assert no restart yet, advance virtual time through the backoff, and assert the restart count.

2. Two runs share a seed but produce different entropy draws. What class of defect does that reveal?

Some work path bypassed the simulation entropy seam or scheduling escaped the controlled executor. The differing trace is a harness failure.

3. Why is storage corruption not a SimulatedRuntime::fault variant?

It is infrastructure behavior, already owned by adapter tests. Mixing it into tier one would make the fast application harness own a different boundary.

Stokker Technologies markDesigned and built by Stokker Technologies