Simulation Architecture — Overview

How Petrinaut runs SDCPN simulations: who owns the memory, what crosses thread boundaries, and how results are read back. Code lives in petrinaut-core/src/simulation/ (engine, authoring, worker, runtime, monte-carlo). These pages describe the headless core only — consumers appear as "the host application" using the public API; the React integration is documented separately in @hashintel/petrinaut/ARCHITECTURE.md. Generated import maps spanning core, React, and UI modules are in the dependency diagrams.

Engine (stepping & frames) Compilation (user code) Worker & protocol Monte Carlo Memory / buffers Host application

The two execution paths

There are two independent ways to execute a net. Quick Simulation runs one interactive simulation and keeps every frame so the host can scrub. Experiments run many Monte Carlo simulations with bounded memory (two reusable buffers per run) and only ship metric aggregates to the host — frame buffers never leave the worker.

Main thread — host application

Run configuration owns InitialMarking, parameter values, seed/dt/maxTime (null = unbounded); calls createSimulation()
Playback driver core playback module — picks the viewed frame (getFrame(i)), drives ack/backpressure per play mode
Experiment consumer one MonteCarloExperiment handle per experiment, subscribed to its stores
Rendering & metrics read via SimulationFrameReader / metric frames

Main thread — core runtime

createSimulation() runtime/simulation.ts — sanitizes SDCPN for extensions, flattens component instances, owns lifecycle stores + events
SimulationFrameStore runtime/frame-store.ts — retains every SimulationFramePayload (ArrayBuffer + time)
SimulationFrameReader frames/frame-reader.ts — typed-array views over a stored frame, zero-copy reads
createMonteCarloExperiment() monte-carlo/runtime/experiment.ts — status / progress / metrics stores

Worker threads

simulation.worker init → buildSimulation(); loop → computeNextFrame(); streams frames under ack backpressure
SimulationInstance.frames[] full EngineFrame history — the compute source of truth
monte-carlo.worker MonteCarloSimulator — round-robin advanceAll(), metrics observed in-worker
2 × ArrayBuffer per run current / next, swapped each step; no history

Where the memory actually lives

Everything the simulation computes is stored in raw ArrayBuffers read through typed-array views. There is no object graph of tokens — tokens are packed structs (real/integer as f64 fields, boolean as one u8 byte, stride rounded to 8 B), decoded to JS objects only at the read boundary.

Memory Structure Thread Lifetime / role
SDCPN document Plain JS objects (editor state) Main Snapshot is structured-cloned into the worker at init; later edits don’t affect a running simulation.
Initial marking JSON (InitialMarking: count or TokenRecord[] per place) Main (host state) Packed into frame 0 by buildSimulation → packInitialPlaceMarking.
EngineFrame (quick sim) ArrayBuffer: 64-byte header + Uint32/Float64/Uint8 sections + packed token structs (byte-addressed places) Worker Immutable snapshot per step. The worker appends every frame to SimulationInstance.frames[] — compute source of truth.
SimulationFramePayload { time, frame: ArrayBuffer } Worker → Main Structured-clone copy per frame (no transfer list). Retained forever by the in-memory frame store for scrubbing.
SimulationFrameReader Uint32Array/Float64Array views over the stored buffer Main Zero-copy views. getPlaceTokens() materializes typed TokenRecord objects via the place’s TokenSlotLayout.
Monte Carlo run buffers 2 × ArrayBuffer per run (current / next), token-value region has capacity + growth policy MC worker Swapped each step. Never sent to the main thread — only progress counters and metric frames (small JSON) cross the boundary.
Compiled user code Versioned HIR buffer programs; scenarios remain plain JS Workers (HIR programs) · Main (scenarios, timeline metrics) Produced by authoring at init time.
Refactoring seam — triple retention. Each quick-sim frame currently exists three times: in the worker history (SimulationInstance.frames[]), as a structured-clone copy in the main-thread frame store, and transiently in the postMessage queue. Frames are never transferred (Transferable) and the worker history is only read by the stepping loop’s latest entry. Retention policy is deliberately isolated behind runtime/frame-store.ts so this can change without touching consumers.

Quick Simulation — end-to-end data flow

SDCPN snapshot + InitialMarking + parameters + seed/dt/maxTime Assembled by the host (main thread). Scenario compilation may have produced the marking and parameter overrides first.
simulation.worker — buildSimulation() Sanitize by extensions → compile lambdas / kernels / dynamics → pack frame 0. Replies frame(0) + ready.
computeNextFrame() × batch Dynamics (Euler) → transitions (enablement, lambda, kernel) → new immutable EngineFrame appended; time += dt.
SimulationFrameStore (main) Appends every payload; publishes {count, latest} through the frames store.
SimulationFrameReader compileSimulationFrameReader(sdcpn) specialises the layout once; readers are created per frame on demand (latest() / getFrame(i)).
Host reads The playback driver picks frameIndex; the host renders from getTransitionState(), getPlaceTokens(), and getPlaceTokenCount(); metric code reads the raw packed frame through a HIR evaluator.

Protocols at a glance

Both workers speak a small typed message protocol over postMessage, wrapped in a SimulationTransport (queues messages until the worker boots). Full payloads and sequence diagrams are on the Worker and Monte Carlo pages.

Host → Worker Worker → Host Flow control
Quick sim
worker/messages.ts
init · start · pause · stop · setBackpressure · ack ready · frame · frames · paused · complete · error Ack-based backpressure: worker computes at most maxFramesAhead past the last acked frame.
Monte Carlo
monte-carlo/worker/messages.ts
init · start · cancel ready · progress · metricFrames · complete · cancelled · error Fire-and-forget batches (advanceAll() × batchSize per loop tick); cancellation checked between batches.

Module map

Area Path Owns Details
Engine engine/, frames/ Build + stepping, EngineFrame binary format, frame readers engine.html
Compilation authoring/ User code → JS functions (lambda, kernel, dynamics, metric, scenario), sandbox hardening authoring.html
Worker + runtime worker/, runtime/ Transport protocol, backpressure, lifecycle stores, frame retention worker.html
Monte Carlo monte-carlo/ Batch runs, bounded buffers, metric pipeline, experiment handle monte-carlo.html

Design invariants worth keeping