# 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 `ArrayBuffer`s 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

- Frames are opaque outside the engine. `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`.
- Simulation time lives outside the frame. The run controller owns frame number and time; payloads carry time as metadata.
- Runs are snapshots. A simulation never re-reads the live document; extension sanitization + component flattening happen once at init on both sides of the boundary (they must agree on the place list, or the layout check throws).
- Determinism. One seeded RNG (`seeded-rng.ts`) threaded through lambda sampling and distribution draws; same seed → same run.