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.
Replay Gives You an Oracle
Section titled “Replay Gives You an Oracle”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.
Start From a Seed
Section titled “Start From a Seed”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).
Script the Whole Runtime
Section titled “Script the Whole Runtime”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.
Stop, Reboot, Compare
Section titled “Stop, Reboot, Compare”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 Second Oracle: Inverses Restore
Section titled “The Second Oracle: Inverses Restore”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.
What Determinism Does and Does Not Mean
Section titled “What Determinism Does and Does Not Mean”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.
Test Your Knowledge
Section titled “Test Your Knowledge”
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?
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.