← All posts Source on GitHub jacob@stephens.page
Notes · Software Factory

The Animation Is a Replay

July 16, 2026 · ~3,000 words · 6 figures and a quiz · reading this as an AI agent? agents.md is for you

Here is the rule this page exists to prove: run the simulation flat out, keep the event log, and treat every animation as a replay of that log. Get that separation right and the engine stops mattering - which I can demonstrate, because this page runs the same factory on four engines across six execution paths: a TypeScript kernel, the same Rust kernel in WebAssembly and native x86-64, SimScript, and SimPy on native CPython and CPython compiled to WebAssembly. Four paths execute in your browser, two behind this site's API. One schema out of all six runs, one scrubber replaying all six, one throughput they all agree on.

The idea is the one from last time, carried one layer further. Last time: the diagram is a projection of the model. This time, applied to motion: the animation is a projection of the event log. A simulation's clock is not your clock. It jumps event to event as fast as the CPU allows - 14 days in about 12 milliseconds - and if you chain the engine to the animation you get those sad simulations that trickle along in real time because someone wanted to watch. Keep the layers apart - model → engine → event log → presentation, nobody reaching past their neighbor - and one run replays at any speed, scrubs, diffs, and renders in any viewer that reads the schema.

Fig. 1 · The log is the seam
Producer clock14 simulated days finish in milliseconds.
Viewer clockThe same run can play in seconds, minutes, or not at all.

The engine can disappear when the run ends. Everything downstream reads the log.

The event log is not debugging exhaust; it is the interface. The producer owns virtual time, the viewer owns presentation time, and neither needs a reference to the other.

I said there was no library. Here are four engines.

The whole bench hangs off one small contract. Every engine emits this; the stats, the hashes, and the replayer read nothing else:

{ "header": { "engine": "simpy-4.1.2", "seed": 42, "schema": 1, "params": { ... } },
  "events": [ { "t": 12.4, "type": "arrive", "id": 3 },
              { "t": 13.1, "type": "code_start", "id": 3 }, ... ] }

The model is the factory from last post, unchanged: arrivals every 25 minutes on average, three coding agents at 45 minutes a pass, CI at 10 minutes failing a quarter of the time, failures looping back as rework, one reviewer at 30 minutes. I can do the first answer on paper: review caps the system at 48 deploys/day. Fig. 2 makes that arithmetic - and the exact point where it stops being true - visible.

Fig. 2 · Rework moves the bottleneck
Factory capacity as the CI failure rate changes Arrivals stay at 57.6 per day and review capacity stays at 48. Coding capacity falls from 96 per day at zero failures to 24 at a 75 percent failure rate, crossing review capacity at 50 percent.
25%
1.33×
coding passes
per deploy
72.0/day
coding
capacity
Review
active constraint
48.0/day ceiling
Rework multiplies coding effort by 1/(1-p), so coding capacity is 96(1-p) deploys/day. At the default p = 0.25, coding can supply 72/day and review caps throughput at 48/day. At p = 0.50 the capacities are tied; only above 0.50 is coding strictly the constraint.

Two hundred SimPy replications put 14-day runs at 47.3 ± 1.9 deploys/day - mean plus or minus run-to-run standard deviation, with a 3σ band from 41.6 to 53.0. Paper prediction, replicated distribution - now run the engines into them.

Fig. 3 · One model, four engines, six execution paths
EngineClient payloadDeploys/dayReview utilQueue@14dWall msLog hash

Closed form predicts 48.0/day, review-constrained. Conformance band from 200 SimPy replications: 41.6 – 53.0. Rows marked reference were generated at build time with seed 42; ran here rows executed in your browser; ran on server came back from a native engine through the live API.

Four real engines, six execution paths, one schema, nothing mocked. The same Rust crate runs as 47 KB of WebAssembly (~53 KB with glue) in your browser and as a persistent native x86-64 shared library behind this site's FastAPI adapter, returning about 36 KB compressed for seed 42. SimScript (~34 KB) comes from esm.sh. SimPy runs on native CPython 3.12 through the same API, returning about 39 KB compressed, and on CPython-via-Pyodide in your browser, strictly opt-in behind the button that quotes its ~5 MB first-run cost. Different engines produce different hashes. Each runtime pair repeats its engine's hash exactly.

Six rows, four hashes - two exact runtime pairs. The four engines draw randomness four different ways (mulberry32, PCG32, SimScript's seeded streams, the Mersenne Twister), so different engines disagree event by event while their statistics agree. Rust/native repeats Rust/WASM's 406362c5… hash across all 5,014 events. SimPy/server repeats SimPy/Pyodide's 3dab9d60… across all 5,276. That is the deployment boundary behaving twice. I expected Rust's native ln() to be the likelier place for a last-bit divergence; seed 42 did not give me one, so the honest claim is empirical and workload-specific, not a promise about every libm.

Two clocks

Look at the wall-ms column again: 14 simulated days, 10 to 200 milliseconds. The virtual clock owes your wristwatch nothing. That asymmetry is the entire case for the event log - you never slow a simulation down to watch it; you run it flat out, keep the log, and pick a replay speed after the fact. The first half of Fig. 4 will replay any row from Fig. 3. The second half lets salabim 26.0.8 run and draw a six-hour version of the factory with its own animation engine. Same process, two presentation architectures; the distinction is visible instead of theoretical.

Fig. 4 · Replay outside the engine, animation inside it log: ts-kernel · seed 42
speed
sim clock: day 0, 00:00 wall clock this replay: 0.0 s ratio:
backlog awaiting review Coding agents CI 10 min · fails 25% Review Deployed 0 total fail → rework
0
waiting for review
at this instant
0
deployed
so far
0
events replayed
of 0
salabim's built-in renderer
Nothing here is a JavaScript imitation. Click once to load Pyodide, Pillow, and salabim. The Python model will return its own animated WebP.

Opt-in: no salabim or Pillow bytes have loaded.

Top: hand-built SVG folds any Fig. 3 event log into queues, busy slots, and a deploy count; it has never heard of an engine. Bottom: genuine salabim 26.0.8 and Pillow run inside Pyodide, execute this six-hour factory model, and render 61 WebP frames at 10 fps. JavaScript displays the returned bytes and nothing else. The first path makes the renderer swappable; the second buys salabim's animation and monitoring vocabulary. Both are real, but they put the seam in different places.

You could write the kernel in an afternoon

The kernel under all of this is almost insultingly small: a priority queue ordered by (time, sequence), and a loop that pops. Here it is twice - the TypeScript running Fig. 3's inline row and the Rust that became the 47 KB wasm row. Press either toy button: five events, two at the same instant, popped in order - b1 beats b2 because of the sequence number, not luck. I have chased enough flaky behavior to promise you that tie-break is where deterministic engines are won and lost.

Fig. 5 · The kernel, in both languages
TypeScript - the kernel behind Fig. 3's inline row
function makeKernel() {
  const heap = [];
  let seq = 0;
  const less = (a, b) =>
    a.t < b.t ||
    (a.t === b.t && a.seq < b.seq);
  return {
    schedule(t, event) {
      heap.push({ t, seq: seq++, event });
      // sift up
    },
    next() {
      // pop min by (t, seq), sift down
    }
  };
}
// The loop:
// while ((n = k.next()) && n.t < horizon)
//   handle(n.event) may k.schedule(...)

Rust - the same kernel behind the wasm row
struct Scheduled<E> { t: f64, seq: u64, event: E }

impl<E> Ord for Scheduled<E> {
  fn cmp(&self, o: &Self) -> Ordering {
    o.t.total_cmp(&self.t)          // earliest first
     .then_with(|| o.seq.cmp(&self.seq))
  }
}

struct Kernel<E> {
  heap: BinaryHeap<Scheduled<E>>,
  seq: u64,
}
impl<E> Kernel<E> {
  fn schedule(&mut self, t: f64, event: E) {
    self.heap.push(Scheduled {
      t, seq: self.seq, event });
    self.seq += 1;
  }
  fn next(&mut self) -> Option<(f64, E)> {
    self.heap.pop().map(|s| (s.t, s.event))
  }
}

Full sources: the Rust crate ships with the post; the TypeScript is in this page. The code is parsed in your browser by highlight.js 11.11.1 with only its TypeScript and Rust grammars loaded; if esm.sh is unavailable, the untouched plain source remains. The kernel is the easy 10%, and the afternoon does not buy the rest: resource queues, cancellation, seeded streams, distributions, warm-up, replications, confidence intervals, serialization. That list is why SimPy exists. Adopt a library for semantics; keep the hand-rolled kernel for the day you need to own the core.

Which tool, when

My research notes rank the whole field, but the version worth keeping fits in a tree. And per last post's own rule, the picture is generated: a JSON decision model, serialized to DOT, drawn by the real dot compiled to WebAssembly. The generated source sits under the figure, where it belongs.

Fig. 6 · Choosing a DES tool, generated from a model

Loading viz-js (WASM)…

Generated DOT source
Leaves from the research pass: SimPy as the code-first default (richest ecosystem: NumPy, pandas, Jupyter, and agents know it); salabim when you want built-in animation and monitoring; Ciw for pure queueing networks; SimScript when the browser is the runtime; a hand-rolled Rust core compiled to native and WASM when you need to own and embed the engine; AnyLogic and JaamSim for visual multimethod modeling; the industrial suites (FlexSim, Simio, Arena) as concepts to borrow, not platforms to adopt. Ecosystem table in agents.md.

What I'll build

I built all four and ran both Rust and SimPy on both sides of HTTP so this choice could run on data instead of vibes. What the bench told me: my TypeScript kernel took the longest to trust, because the queue-and-rework logic is exactly where the subtle bugs live and every line of it was mine to get wrong. The Rust port was mechanical once the semantics existed - 47 KB in WASM, a persistent shared library on the server, fastest rows on the board. SimScript needed the least adapter code and brought the most opinions. SimPy read closest to the problem statement - the factory is 40 lines of Python that sound like the prose that specified it - and drags the whole scientific ecosystem behind it. So:

  1. SimPy owns simulation semantics on the server for the software factory, behind a narrow SimulationRuntime interface (dispatch / step / runUntil / reset(seed)) so no framework becomes the domain model. Pyodide keeps the demo self-contained; the native API is the production-shaped path.
  2. The model stays declarative - the same canonical JSON/YAML discipline as the last post; engines consume it, none of them owns it.
  3. The event-log schema is the contract. Every run ships with seed and params in the header; every viewer, stat, and diff reads logs, never engine state.
  4. The Rust core is the embedded engine, gated by conformance. The same crate now runs in the browser and behind the API; both paths must reproduce SimPy's statistics against these reference logs before either is trusted - the same conformance-corpus pattern that guards my CrMS charting engine across languages. Fig. 3 is that corpus's first six execution paths.
  5. Replications before beliefs. Single runs demo; replicated distributions decide. The 200-run band in Fig. 3 came from SimPy in about 20 seconds of CPU.

The quiz at the door

Same rule as last time, which is Geoffrey Litt's rule for his agents before it was mine: no merge until you can pass the quiz. Five questions.

Quiz · Pass before you merge

1. Why not stream the engine's events to the browser live, as the simulation produces them?

A DES clock jumps event-to-event at CPU speed. Record the log, then replay it at a human speed - 1 min/s or 1 day/s, the log doesn't care.

2. Two different engines, same scenario, same seed. Should their event-log hashes match? Should their deploys/day?

Determinism is a per-engine property. Across different engines the invariant is statistical agreement, so TypeScript, Rust, SimScript, and SimPy produce four hashes. Fig. 3 runs both Rust and SimPy twice; each browser/server pair repeats its engine's hash because the model, RNG, seed, and serializer are the same.

3. Which layer owns simulation state during a replay?

The engine finished milliseconds after it started, and the renderer is stateless by design - Fig. 4 rebuilds the world from the log on every scrub. The log is the artifact.

4. When does running SimPy in the browser (Pyodide) beat running it behind an API?

Pyodide is real CPython in WebAssembly: perfect for a static, self-contained page like this one, wrong for big runs, private models, or shared storage - that's when the FastAPI-plus-workers shape wins.

5. All four engines land near the predicted 48/day. What result would have falsified the model instead?

Hashes must differ and wall clocks may differ - neither is evidence. A throughput outside the 3σ band would mean the semantics of some engine's model diverged: a real bug, findable precisely because the band exists.

0 of 5 answered.

What's still open

Three things I have not solved. The number people will quote from this page is still a single row, even though the honest evidence is the replicated distribution behind it - replications-by-default is the fix, and none of the six buttons does it yet. Fig. 2 draws the analytical sweep, but the four-engine bench proves agreement only at p = 0.25; a conformance corpus worth the name runs every adapter across that sweep. And the native API is production-shaped, not production-finished: one 14-day run fits comfortably inside one HTTP request, while 200 replications should become a bounded background job that returns an artifact rather than holding a connection open.

Sources

Ideas and research

  • Research notes: "Are there any tools or libraries for running discrete-event simulations in web apps?" (Perplexity, 2026-07-15; three-part conversation) - the browser-DES landscape, the ecosystem map, and the SimPy-in-a-web-app patterns this post demonstrates.
  • The Diagram Is Not the Model - the previous post; its Fig. 6 simulation is the model all four engines run here, and its closed-form arithmetic is the reference row. stephens.page/blog/the-diagram-is-not-the-model
  • Geoffrey Litt, "Understanding: the New Bottleneck" (talk I attended) - the quiz rule. youtube.com/watch?v=WkBPX-oDMnA

Engines demonstrated

  • SimPy 4.1.2 (pure Python, via native CPython 3.12 behind this site's API and Pyodide v0.28 in the browser) - simpy.readthedocs.io, pyodide.org
  • SimScript 1.0.37 by Bernardo Castilho - github.com/Bernardo-Castilho/SimScript
  • salabim 26.0.8 by Ruud van der Ham (Fig. 4's built-in animation path, executed in Pyodide with Pillow 11.2.1) - salabim animation documentation, this post's model
  • replay-kernel 0.1.0 (this post's Rust crate, compiled to WebAssembly with wasm-bindgen/wasm-pack and to a native x86-64 shared library loaded through a narrow C ABI) - source
  • The TypeScript kernel - inline in this page; also the previous post's simulation, refactored to emit the log schema.
  • highlight.js 11.11.1 (Fig. 5 TypeScript and Rust syntax parsing, loaded from esm.sh) - highlightjs.org

The wider field (researched, not demonstrated)

  • Browser-capable: discrete-sim (discrete-sim.dev), OESjs (sim4edu.com/oesjs), SIM.JS (2011-era API, ideas worth reading).
  • Python: Ciw (queueing networks, ciw.readthedocs.io).
  • Visual platforms: AnyLogic, JaamSim (open-source GUI); industrial: FlexSim, Simio, Arena, SIMUL8, WITNESS.
  • Elsewhere: simmer (R), ConcurrentSim.jl (Julia), desru/desim (Rust); domain-specific: ns-3, SUMO, gem5, SystemC.

For AI agents: agents.md carries the event-log schema, verified numbers, adapter rules, native API contract, checklist, and self-test without requiring a browser.

I built the engines, the bench, and their verification with Claude from my own Perplexity research notes on browser discrete-event simulation (2026-07-15). Everything in Fig. 3 is real: the TypeScript kernel inline, the same Rust crate as browser WebAssembly and a native shared library, SimScript 1.0.37 from esm.sh, SimPy 4.1.2 on native CPython behind this site's FastAPI service, and genuine CPython-via-Pyodide behind the opt-in button. Fig. 4's second panel is real too: salabim 26.0.8 runs the six-hour factory inside that Pyodide worker, Pillow 11.2.1 records 61 frames, and the page displays the returned WebP bytes. The browser derives every statistic and hash from returned event logs; it does not trust server summaries. The architecture map, capacity chart, event-log replayer, and quiz are hand-built SVG, HTML, and vanilla JavaScript; highlight.js 11.11.1 parses the two Fig. 5 code blocks, and Graphviz draws the decision tree. Reference logs, the 200-run replication distribution, and the cross-engine checks were generated at build time and ship with the page. The decisions, the factory, and the words are mine; Claude was the technical collaborator.