engine/ + frames/ — builds an SDCPN into a
runnable SimulationInstance and advances it one immutable
binary frame at a time.
buildSimulation(input) ·
computeNextFrame(simulation)
InitialMarking, parameter values, seed, dt,
maxTime (number | null; null = no limit), extensions
EngineFrame per step +
completionReason: "maxTime" | "deadlock" | null
frames: EngineFrame[] (history, frame 0 included) ·
frameLayout · compiledTransitions ·
differentialEquationFns · parameterValues ·
dt · maxTime · currentTime · currentFrameNumber · rngState
maxTime !== null and
currentTime >= maxTime, return
"maxTime" without computing.
Dynamics fn, apply Euler
x += dx·dt to real slots (discrete derivatives
are forced to 0). Builds an intermediate frame.
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.
timeSinceLastFiringMs by dt.
maxTime reached, or deadlock (nothing fired and no
transition is enabled).
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.
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.
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.
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) |
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.
| 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.
|
real and
integer elements are f64 fields;
boolean elements are single u8 bytes, placed by
computeTokenSlotLayout.
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.
readTokenRecord): user code and the UI always see
number | boolean values.
realFieldF64Offsets exclusively, discrete bytes are copied
through untouched, and colours without real elements skip dynamics
compilation entirely.
Distribution.Gaussian…) are allowed for
real/integer kernel outputs and rejected for boolean.
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.
|
DataView). Places are byte-addressed:
{ byteOffset, count, strideBytes }.
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.
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.
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.