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.
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.
InitialMarking, parameter values, seed/dt/maxTime
(null = unbounded); calls createSimulation()
playback module — picks the viewed frame
(getFrame(i)), drives ack/backpressure per play
mode
MonteCarloExperiment handle per experiment,
subscribed to its stores
SimulationFrameReader / metric frames
SimulationFramePayload (ArrayBuffer + time)
buildSimulation(); loop →
computeNextFrame(); streams frames under ack
backpressure
MonteCarloSimulator — round-robin
advanceAll(), metrics observed in-worker
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. |
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.
frame(0) + ready.
{count, latest} through
the frames store.
compileSimulationFrameReader(sdcpn) specialises the
layout once; readers are created per frame on demand (latest()
/ getFrame(i)).
frameIndex; the host renders
from getTransitionState(), getPlaceTokens(),
and getPlaceTokenCount(); metric code reads the raw
packed frame through a HIR evaluator.
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 simworker/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 Carlomonte-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.
|
| 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 |
EngineFrame is “not a public API or stable storage format”;
it can only be decoded with the SDCPN-derived
EngineFrameLayout. All consumers go through
SimulationFrameReader.
seeded-rng.ts) threaded through lambda sampling and
distribution draws; same seed → same run.