Engine

engine/ + frames/ — builds an SDCPN into a runnable SimulationInstance and advances it one immutable binary frame at a time.

Entry points
buildSimulation(input) · computeNextFrame(simulation)
Inputs
SDCPN snapshot, InitialMarking, parameter values, seed, dt, maxTime (number | null; null = no limit), extensions
Outputs
New EngineFrame per step + completionReason: "maxTime" | "deadlock" | null
Events
None — pure return values; the worker turns them into protocol messages
Runs on
Worker thread (quick sim). The same engine functions are reused by Monte Carlo with different frame storage

Lifecycle

buildSimulation(input) Flattens component instances, compiles user code (authoring): lambdas only where available, kernels only for coloured outputs, dynamics only for colours with at least one real element. Packs the initial marking into frame 0 (coercing token values by element type).
SimulationInstance frames: EngineFrame[] (history, frame 0 included) · frameLayout · compiledTransitions · differentialEquationFns · parameterValues · dt · maxTime · currentTime · currentFrameNumber · rngState
computeNextFrame(simulation) Returns a new instance value with one more frame appended — the previous frames are never mutated.

One step, in order

1 · maxTime? If maxTime !== null and currentTime >= maxTime, return "maxTime" without computing.
2 · Dynamics Per place with a differential equation: slice its token region, decode tokens, call the user Dynamics fn, apply Euler x += dx·dt to real slots (discrete derivatives are forced to 0). Builds an intermediate frame.
3 · Transitions executeTransitions: for each transition — enablement (arc weights, inhibitor/read arcs), token-combination enumeration, lambda (predicate / stochastic rate vs seeded RNG), kernel for coloured outputs. Removals compact the buffer; additions append.
4 · Timers If nothing fired, advance every transition’s timeSinceLastFiringMs by dt.
5 · Complete? maxTime reached, or deadlock (nothing fired and no transition is enabled).
Every stage that changes state allocates a new ArrayBuffer via createEngineFrame() — dynamics, token removal, and token insertion each rebuild the frame. Immutability makes history trivially correct at the cost of allocation churn per step.

EngineFrame — the binary format

One ArrayBuffer, read through section-typed views. The frame stores no IDs and no time — decoding requires the EngineFrameLayout (place/transition order, per-place strideBytes and TokenSlotLayout) derived from the SDCPN, and time travels as payload metadata.

Source of truth: libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.tscreateEngineFrame() computes every offset below and is the only frame constructor; readEngineFrame() recreates the same views for reading; the header constants live in the HeaderOffset enum.

Memory map — example instance byte offsets on the left · one typed-array view per section on the right header — 64 B fixed magic "PFRM" · version · P · T · section offsets · byteLength DataView, little-endian place token counts u32 × P = 8 B Uint32Array(buf, 64, P) tokens currently in each place place value offsets u32 × P = 8 B Uint32Array(buf, 72, P) each place’s token run, relative to the token region start transition elapsed f64 × T = 16 B Float64Array(buf, 80, T) ms since each transition last fired transition firing counts u32 × T = 8 B Uint32Array(buf, 96, T) cumulative firings since frame 0 fired flags — u8 × T = 2 B Uint8Array(buf, 104, T) padding — 6 B alignTo(…, 8) so f64 views stay aligned tokenBytes / tokenF64 views (b112…) packed token structs — place-major, token-major; this all-real example is pure f64 (booleans = u8) slot 0 · b112 · p1 · tok0 · x slot 1 · b120 · p1 · tok0 · y slot 2 · b128 · p1 · tok0 · z slot 3 · b136 · p1 · tok1 · x slot 4 · b144 · p1 · tok1 · y slot 5 · b152 · p1 · tok1 · z slot 6 · b160 · p2 · tok0 · a slot 7 · b168 · p2 · tok0 · b p1 — byteOffset 0 2 tokens × stride 24 B (x,y,z — all real) p2 — byteOffset 48 1 token × stride 16 B (a,b) → byte 112 + 48 0 64 72 80 96 104 106 112 176 byteLength = 176 (recorded in the header, checked on read)
Exact offsets for a concrete instance: P = 2 places (p1 = 2 tokens × 24-byte stride, p2 = 1 token × 16-byte stride — all dimensions real here, so every field is one f64; a boolean dimension would appear as a single u8 byte inside its token’s stride), T = 2 transitions → 176-byte frame. Sections are laid out by createEngineFrame(); sections and strides are padded to 8-byte boundaries by alignTo(). Block heights are not to scale.

Header (offsets in bytes, little-endian, via DataView)

Offset Field Type Value / meaning
0 magic u32 0x5046524d (“PFRM”) — corrupt-frame guard
4 version u16 2 — the current (only) format; readers assert this on decode
6 headerBytes u16 64
8 / 12 placeCount / transitionCount u32 must match the layout (checked on read)
16 tokenByteLength u32 token region length in bytes
20–40 section offsets u32 × 6 byte offsets of each section above
44 byteLength u32 whole-frame length (checked on read)

Token region

Each coloured place owns a contiguous run of count × strideBytes bytes starting at its byte offset (uncoloured places have stride 0 and only a count), giving O(1) access to any place via the layout. Within a token, each element sits at its layout-computed byte offset: real/integer as f64, boolean as one u8 byte. Value coercion (integers rounded, booleans 0/1) lives in engine/token-values.ts; byte placement in engine/token-layout.ts.

Who reads and writes the buffer

Path Direction View used Notes
createEngineFrame(layout, snapshot) write (allocate) DataView header + section views, bulk Uint8Array.set Only constructor of frames; recomputes byte offsets from counts × strides.
readEngineFrame(layout, frame)EngineFrameView read Uint32Array sections + tokenBytes (u8) / tokenF64 views Zero-copy; toSnapshot() copies the token region into a fresh Uint8Array.
Dynamics (computePlaceNextState) read + write f64 view over a fresh byte copy Euler touches only realFieldF64Offsets; discrete bytes copied through untouched.
Transition firing (compute-possible-transition, execute-transitions, remove-tokens…) read + write readTokenRecord / encodeTokenValuesToBytes + byte-range copies Input tokens decoded to TokenRecords for user lambdas/kernels; kernel outputs packed into per-token Uint8Array blocks.
SimulationFrameReader (main thread) read Same section views over the cloned buffer getPlaceTokens() = decoded records via the place’s TokenSlotLayout; getTransitionState() = timers/flags.

Token attribute typing (discrete types)

Token memory layout — packed structs

Since FRAME_VERSION = 2, tokens are stored as schema-driven packed structs (array-of-structs; column layout was considered and rejected because the workload — kernels, copies, UI reads — is token-oriented). engine/token-layout.ts (computeTokenSlotLayout) is the single source of truth:

Logical type Physical type Notes
real f64 — 8 B unchanged
integer f64 — 8 B, rounded on read/write Exact only within ±2^53 (documented in the schema). i32 rejected (silent wraparound at ±2^31); i64 rejected (bigint is contagious into user code: fortune * 1.05 would throw). If >2^53 is ever needed, add a separate opt-in int64 element type instead of changing integer.
boolean u8 — 1 B Not bit-packed (would break byte-granular copies for marginal savings).
uuid (128-bit, FE-1121) u64 × 2 — 16 B Two little-endian lanes read via the shared BigUint64Array view, combined to one bigint at the boundary (~28 ns; lane-compare without combining for equality, ~4× faster). Never routed through number (NaN-payload hazard). Kernel outputs are optional — omitted values auto-generate from the seeded RNG.
string (FE-769) u64 pool reference — 8 B The frame stores an ID into an append-only per-run string intern pool (engine/string-pool.ts); the pool lives on SimulationInstance, not on the frame, so frames stay fixed-stride and byte-copyable. Equal strings share one ID; id 0 is the pre-seeded "", so zeroed buffers decode cleanly.

Determinism

seeded-rng.ts provides a pure nextRandom(state) → [value, nextState]. The RNG state lives on SimulationInstance.rngState and is threaded through stochastic lambda sampling and distribution draws, so a given (SDCPN, marking, parameters, seed, dt) always reproduces the same frame sequence.

Refactoring seams. (1) Per-step allocation: each stage rebuilds the whole buffer — a double-buffer strategy (as Monte Carlo already does) would remove most churn. (2) 128-bit types (UUID, FE-1121) slot into the layout as u64×2 fields (align 8, read via BigUint64Array) — the layout machinery is ready; the value plumbing (bigint boundary, v5 coercion, seeded generation) is not. (3) The worker keeps the full frames[] history although only the latest frame is needed to advance — history retention belongs to the main-thread store.