# 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.ts` — `createEngineFrame()` 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.

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 `TokenRecord`s 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)

- Storage is schema-driven: `real` and `integer` elements are f64 fields; `boolean` elements are single u8 bytes, placed by `computeTokenSlotLayout`.
- Coercion at the write boundary (`coerceTokenRecord`, `encodeTokenAttributeValue`): integers round, booleans become 0/1; initial markings, scenario rows, and kernel outputs all pass through it before `writeTokenValue` / `encodeTokenToBytes` place the bytes.
- Decode at the read boundary (`readTokenRecord`): user code and the UI always see `number | boolean` values.
- Only transition kernels write discrete values. Dynamics apply to real elements only — Euler integrates `realFieldF64Offsets` exclusively, discrete bytes are copied through untouched, and colours without real elements skip dynamics compilation entirely.
- Distributions (`Distribution.Gaussian…`) are allowed for real/integer kernel outputs and rejected for boolean.

## 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.

- Layout rule: per colour, fields are ordered by decreasing alignment (stable) and the stride is rounded up to 8 B — every f64 field stays 8-aligned so hot paths use plain typed-array views (no `DataView`). Places are byte-addressed: `{ byteOffset, count, strideBytes }`.
- Access rule: `token-layout.ts` (`readTokenRecord` / `writeTokenValue` / `encodeTokenToBytes`) is the only code that indexes token bytes; all whole-token moves are byte-range copies (`Uint8Array.set`). The raw `getPlaceTokenValues` reader was removed from the public API — raw f64 access is meaningless under mixed widths. Because the string pool never crosses the worker boundary with the frames, each frame payload ships an append-only `newStrings` delta that the main-thread frame store accumulates and hands to the frame reader for decoding.

## 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.