From f6a378b8779bbb8790970a5b8c216aa98741cc5b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 8 May 2026 16:24:35 +0200 Subject: [PATCH 01/37] Clean up core simulation layering docs --- .../petrinaut/src/core/simulation/README.md | 145 ++++++++++++------ .../src/core/simulation/compile-scenario.ts | 4 +- .../src/core/simulation/simulator/types.ts | 2 +- .../src/core/simulation/transport.ts | 3 +- .../petrinaut/src/core/simulation/types.ts | 2 +- .../src/core/simulation/worker/README.md | 32 ++-- .../lib}/compile-visualizer.ts | 0 .../subviews/place-visualizer/subview.tsx | 2 +- 8 files changed, 123 insertions(+), 67 deletions(-) rename libs/@hashintel/petrinaut/src/{core/simulation/simulator => ui/lib}/compile-visualizer.ts (100%) diff --git a/libs/@hashintel/petrinaut/src/core/simulation/README.md b/libs/@hashintel/petrinaut/src/core/simulation/README.md index 5b851794c39..d54e5e4b42e 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/README.md @@ -1,79 +1,128 @@ # Simulation Module -React context and provider for SDCPN simulation management. +Headless SDCPN simulation runtime. ## Overview -SimulationProvider wraps the WebWorker-based simulation and exposes it through React Context. It handles configuration, lifecycle, and frame access while the actual computation runs off the main thread. +The simulation module exposes the core `createSimulation` factory and the +transport protocol used to run SDCPN simulations off the main thread. It has no +UI-framework dependency. + +`createSimulation` runs against an immutable SDCPN snapshot. After +initialization, later document mutations do not affect the active simulation. ## Simulation State ```typescript -type SimulationState = 'NotRun' | 'Paused' | 'Running' | 'Complete' | 'Error'; +type SimulationState = + | "Initializing" + | "Ready" + | "Running" + | "Paused" + | "Complete" + | "Error"; ``` -| WorkerStatus | SimulationState | -| ------------------------ | --------------- | -| `idle`, `initializing` | `NotRun` | -| `ready`, `paused` | `Paused` | -| `running` | `Running` | -| `complete` | `Complete` | -| `error` | `Error` | +| State | Description | +| -------------- | ----------------------------------------------------- | +| `Initializing` | Worker or transport is booting and compiling the run. | +| `Ready` | Simulation is initialized and ready to run. | +| `Running` | Frames are being computed. | +| `Paused` | Computation is paused; frame history is retained. | +| `Complete` | Simulation ended because of deadlock or max time. | +| `Error` | Initialization or computation failed. | ## Configuration -| Property | Default | Description | -| ----------------- | ----------- | ------------------------------------------ | -| `parameterValues` | `{}` | User-defined parameters | -| `initialMarking` | `new Map()` | Initial token placement | -| `dt` | `0.01` | Time step in seconds | -| `maxTime` | `null` | Simulation end time (immutable after init) | +| Property | Description | +| ----------------- | -------------------------------------------------- | +| `sdcpn` | SDCPN snapshot to simulate. | +| `initialMarking` | Initial token placement. | +| `parameterValues` | Parameter values overriding SDCPN defaults. | +| `seed` | Seed for deterministic stochastic behavior. | +| `dt` | Time step in seconds. | +| `maxTime` | Maximum simulation time. `null` disables it. | +| `backpressure` | Optional worker frame-ahead and batch settings. | +| `signal` | Optional abort signal for initialization/teardown. | + +Provide exactly one execution transport: + +- `createWorker`: a factory returning a `Worker` or `Promise`. +- `transport`: a pre-built `SimulationTransport`. ## Lifecycle ```text - ┌─────────────┐ - │ NotRun │◄──── reset() - └──────┬──────┘ - │ initialize() + ┌──────────────┐ + │ Initializing │ + └──────┬───────┘ + │ worker ready ▼ - ┌─────────────┐ - ┌─────►│ Paused │◄─────┐ - │ └──────┬──────┘ │ - │ │ run() │ pause() - │ ▼ │ - │ ┌─────────────┐ │ - │ │ Running │──────┘ - │ └──────┬──────┘ + ┌──────────────┐ + ┌─────►│ Ready │◄─────┐ + │ └──────┬───────┘ │ + │ │ run() │ pause() + │ ▼ │ + │ ┌──────────────┐ │ + │ │ Running │──────┘ + │ └──────┬───────┘ │ │ - │ deadlock/maxTime/error + │ deadlock / maxTime / error │ │ │ ▼ - │ ┌─────────────┐ - └──────│ Complete │ - │ or Error │ - └─────────────┘ + │ ┌──────────────┐ + └──────│ Complete or │ + │ Error │ + └──────────────┘ ``` -## Key Actions +## API -- `initialize()`: Returns Promise, resolves when worker is ready -- `run()` / `pause()`: Control simulation generation -- `getFrame(index)`: Access computed frames -- `ack(frameNumber)`: Backpressure control (called by PlaybackProvider) -- `setBackpressure()`: Configure worker backpressure parameters +- `createSimulation(config)`: initialize a simulation and resolve a live + `Simulation` handle once the worker reports ready. +- `simulation.status`: readable store containing the current + `SimulationState`. +- `simulation.frames`: readable store containing `{ count, latest }`. +- `simulation.events`: event stream for completion and runtime errors. +- `simulation.run()` / `simulation.pause()` / `simulation.reset()`: control + computation. +- `simulation.getFrame(index)`: read a computed frame by index. +- `simulation.ack(frameNumber)`: acknowledge consumed frames for worker + backpressure. +- `simulation.setBackpressure(config)`: update worker frame-ahead and batch + settings. +- `simulation.dispose()`: stop and terminate the underlying transport. ## Usage -```tsx - - - - - +```ts +import { createSimulation } from "@hashintel/petrinaut/core"; -// In component: -const simulation = use(SimulationContext); -await simulation.initialize({ seed: 42, dt: 0.01, maxFramesAhead: 100, batchSize: 50 }); +const simulation = await createSimulation({ + sdcpn, + initialMarking, + parameterValues, + seed: 42, + dt: 0.01, + maxTime: null, + backpressure: { + maxFramesAhead: 100, + batchSize: 50, + }, + createWorker: () => + new Worker(new URL("./simulation.worker.js", import.meta.url)), +}); + +const unsubscribe = simulation.frames.subscribe(({ count, latest }) => { + if (latest) { + console.log(`Computed ${count} frames; latest time is ${latest.time}`); + } +}); + +simulation.ack(0); simulation.run(); + +// Later: +unsubscribe(); +simulation.dispose(); ``` diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts b/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts index 495f497ca4d..3776342434c 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts @@ -17,7 +17,7 @@ export interface CompiledPlaceMarking { export interface CompiledScenarioResult { /** * Resolved parameter values keyed by variableName (matches the format - * expected by the simulation worker and SimulationContext). + * expected by the simulation worker). */ parameterValues: Record; /** @@ -282,7 +282,7 @@ export function compileScenario( return { ok: false, errors }; } - // Convert parameters to string values (SimulationContext format) + // Convert parameters to string values (simulation worker input format) const parameterValues: Record = {}; for (const [key, value] of Object.entries(parametersObj)) { parameterValues[key] = String(value); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts index a558a41f63f..a50b8dcd2c7 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts @@ -2,7 +2,7 @@ * Internal types for the simulation engine. * * These types are used by the simulator and worker modules but are not - * part of the public SimulationContext API. + * part of the public simulation API. */ import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/transport.ts b/libs/@hashintel/petrinaut/src/core/simulation/transport.ts index 754c8f2fed7..4e61ade0b8c 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/transport.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/transport.ts @@ -5,7 +5,8 @@ import type { ToMainMessage, ToWorkerMessage } from "./worker/messages"; * `Simulation` handle from how the engine is actually run — Worker, inline, * recorded replay, or a Node `worker_threads` polyfill all satisfy this shape. * - * See [05-simulation.md](../../../rfc/0001-core-react-ui-split/05-simulation.md) §5.1. + * This allows the same simulation handle to run against a browser worker, + * inline test transport, recorded replay transport, or Node worker adapter. */ export interface SimulationTransport { /** Send a message to the engine. May queue if the transport is not yet ready. */ diff --git a/libs/@hashintel/petrinaut/src/core/simulation/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/types.ts index a8b2cc45d25..b11371a23be 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/types.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/types.ts @@ -63,7 +63,7 @@ export type SimulationFrame = { }; /** - * Simplified view of a simulation frame for UI consumption. + * Simplified view of a simulation frame for higher-level consumers. * Provides easy access to place and transition states without internal details. */ export type SimulationFrameState = { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md b/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md index 3afff14f611..9143d69c272 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md @@ -1,14 +1,16 @@ # Simulation Worker -WebWorker for off-main-thread SDCPN simulation computation. +Worker runtime for off-main-thread SDCPN simulation computation. ## Overview -The worker computes simulation frames in batches, controlled by backpressure from the main thread. This keeps the UI responsive while allowing fast computation. +The worker computes simulation frames in batches, controlled by backpressure +from its host transport. This keeps the caller responsive while allowing fast +computation. ## Messages -**Main Thread → Worker:** +**Host → Worker:** | Type | Payload | Description | | ----------------- | -------------------------------------------------------------------------------------------- | ----------------------------------- | @@ -19,7 +21,7 @@ The worker computes simulation frames in batches, controlled by backpressure fro | `setBackpressure` | `{ maxFramesAhead?, batchSize? }` | Reconfigure backpressure at runtime | | `ack` | `{ frameNumber }` | Acknowledge frame receipt | -**Worker → Main Thread:** +**Worker → Host:** | Type | Payload | Description | | ---------- | -------------------------------------------------- | ----------------------- | @@ -31,15 +33,17 @@ The worker computes simulation frames in batches, controlled by backpressure fro ## Backpressure -The worker blocks computation until it receives an `ack` message, then computes up to `maxFramesAhead` frames beyond the acknowledged frame before waiting again. +The worker blocks computation until it receives an `ack` message, then computes +up to `maxFramesAhead` frames beyond the acknowledged frame before waiting +again. **Key behavior:** -- Worker starts with `lastAckedFrame = -1` (blocked until first ack) -- PlaybackProvider controls ack calls based on play mode -- If no ack is sent (viewOnly mode), no new frames are computed +- Worker starts with `lastAckedFrame = -1` and blocks until the first ack. +- Hosts should ack frames as they consume or persist them. +- If no ack is sent, no new frames are computed after initialization. -**Play mode configuration (set by PlaybackProvider):** +**Common backpressure profiles:** | Play Mode | maxFramesAhead | batchSize | Ack Behavior | | ---------------- | -------------- | --------- | -------------------------------- | @@ -49,12 +53,12 @@ The worker blocks computation until it receives an `ack` message, then computes --- -## Consuming this worker from main-thread code +## Consuming this worker from host code -The previous `useSimulationWorker` React hook has been removed. Main-thread code now uses the standalone `createSimulation` factory from `/core` (see [`../../../rfc/0001-core-react-ui-split/05-simulation.md`](../../../rfc/0001-core-react-ui-split/05-simulation.md)): +Host code should use the standalone `createSimulation` factory from `/core`: ```ts -import { createSimulation } from "@hashintel/petrinaut"; +import { createSimulation } from "@hashintel/petrinaut/core"; const sim = await createSimulation({ sdcpn, @@ -69,4 +73,6 @@ const sim = await createSimulation({ sim.run(); ``` -The default `createWorker` factory used inside `` lives in `./create-simulation-worker.ts`. It returns a `Promise` that imports the worker module via Vite's `?worker&inline` syntax. +The default browser worker factory lives in `./create-simulation-worker.ts`. +It returns a `Promise` that imports the worker module via Vite's +`?worker&inline` syntax. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-visualizer.ts b/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-visualizer.ts rename to libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index eb7cc0b2825..22f792999db 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -20,7 +20,7 @@ import { import { CodeEditor } from "../../../../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../../../../../react/playback/context"; import { SimulationContext } from "../../../../../../../../react/simulation/context"; -import { compileVisualizer } from "../../../../../../../../core/simulation/simulator/compile-visualizer"; +import { compileVisualizer } from "../../../../../../../lib/compile-visualizer"; import { EditorContext } from "../../../../../../../../react/state/editor-context"; import { usePlacePropertiesContext } from "../../context"; import { VisualizerErrorBoundary } from "./visualizer-error-boundary"; From b4afac5f4238196599e2bb587c9eb067f8766e91 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 8 May 2026 21:52:09 +0200 Subject: [PATCH 02/37] Refactor simulation module boundaries --- libs/@hashintel/petrinaut/src/core/index.ts | 12 +- .../petrinaut/src/core/simulation/README.md | 22 ++- .../petrinaut/src/core/simulation/api.ts | 174 ++++++++++++++++++ .../{ => authoring}/compile-metric.test.ts | 2 +- .../{ => authoring}/compile-metric.ts | 2 +- .../{ => authoring}/compile-scenario.test.ts | 2 +- .../{ => authoring}/compile-scenario.ts | 2 +- .../simulation/{ => authoring}/sandbox.ts | 0 .../{simulator => engine}/README.md | 11 +- .../build-simulation.test.ts | 0 .../{simulator => engine}/build-simulation.ts | 0 .../check-transition-enablement.test.ts | 0 .../check-transition-enablement.ts | 0 .../compile-user-code.test.ts | 0 .../compile-user-code.ts | 0 .../compute-next-frame.test.ts | 0 .../compute-next-frame.ts | 0 .../compute-place-next-state.test.ts | 0 .../compute-place-next-state.ts | 0 .../compute-possible-transition.test.ts | 0 .../compute-possible-transition.ts | 0 .../{simulator => engine}/distribution.ts | 0 .../enumerate-weighted-markings.test.ts | 0 .../enumerate-weighted-markings.ts | 0 .../execute-transitions.test.ts | 0 .../execute-transitions.ts | 0 ...emove-tokens-from-simulation-frame.test.ts | 0 .../remove-tokens-from-simulation-frame.ts | 0 .../{simulator => engine}/seeded-rng.ts | 0 .../simulation/{simulator => engine}/types.ts | 8 +- .../simulation/frames/frame-reader.test.ts | 107 +++++++++++ .../core/simulation/frames/frame-reader.ts | 102 ++++++++++ .../core/simulation/frames/internal-frame.ts | 36 ++++ .../core/simulation/frames/metric-state.ts | 27 +++ .../petrinaut/src/core/simulation/index.ts | 37 ++-- .../src/core/simulation/metric-state.ts | 49 ----- .../{ => runtime}/simulation.test.ts | 14 +- .../simulation/{ => runtime}/simulation.ts | 118 +++--------- .../simulation/{ => runtime}/transport.ts | 26 +-- .../petrinaut/src/core/simulation/types.ts | 96 ---------- .../src/core/simulation/worker/messages.ts | 2 +- .../simulation/worker/simulation.worker.ts | 6 +- libs/@hashintel/petrinaut/src/main.ts | 5 + .../petrinaut/src/react/hooks/index.ts | 4 +- .../petrinaut/src/react/hooks/use-playback.ts | 8 +- .../src/react/hooks/use-simulation.ts | 16 +- .../petrinaut/src/react/playback/README.md | 6 +- .../petrinaut/src/react/playback/context.ts | 9 +- .../src/react/playback/provider.test.tsx | 16 +- .../petrinaut/src/react/playback/provider.tsx | 45 +---- .../petrinaut/src/react/simulation/context.ts | 28 ++- .../src/react/simulation/provider.tsx | 12 +- .../playback-settings-menu.stories.tsx | 2 +- .../subviews/simulation-timeline.tsx | 17 +- .../initial-state-editor.tsx | 17 +- .../subviews/place-initial-state/subview.tsx | 12 +- .../subviews/place-visualizer/subview.tsx | 20 +- .../SimulateView/create-metric-drawer.tsx | 2 +- .../SimulateView/view-metric-drawer.tsx | 2 +- 59 files changed, 644 insertions(+), 432 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/api.ts rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/compile-metric.test.ts (98%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/compile-metric.ts (98%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/compile-scenario.test.ts (99%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/compile-scenario.ts (99%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/sandbox.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/README.md (82%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/build-simulation.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/build-simulation.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/check-transition-enablement.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/check-transition-enablement.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compile-user-code.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compile-user-code.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compute-next-frame.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compute-next-frame.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compute-place-next-state.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compute-place-next-state.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compute-possible-transition.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/compute-possible-transition.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/distribution.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/enumerate-weighted-markings.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/enumerate-weighted-markings.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/execute-transitions.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/execute-transitions.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/remove-tokens-from-simulation-frame.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/remove-tokens-from-simulation-frame.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/seeded-rng.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{simulator => engine}/types.ts (93%) create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts delete mode 100644 libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts rename libs/@hashintel/petrinaut/src/core/simulation/{ => runtime}/simulation.test.ts (94%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => runtime}/simulation.ts (66%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => runtime}/transport.ts (53%) delete mode 100644 libs/@hashintel/petrinaut/src/core/simulation/types.ts diff --git a/libs/@hashintel/petrinaut/src/core/index.ts b/libs/@hashintel/petrinaut/src/core/index.ts index 0083cc24aac..f98a7dcbe76 100644 --- a/libs/@hashintel/petrinaut/src/core/index.ts +++ b/libs/@hashintel/petrinaut/src/core/index.ts @@ -40,18 +40,16 @@ export type { SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, SimulationFrameSummary, + SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, -} from "./simulation"; -export type { InitialMarking, - SimulationFrame, - SimulationFrameState, - SimulationFrameState_Place, - SimulationFrameState_Transition, -} from "./simulation/types"; +} from "./simulation"; // --- LSP --- export { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/README.md b/libs/@hashintel/petrinaut/src/core/simulation/README.md index d54e5e4b42e..d8a158fe235 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/README.md @@ -11,6 +11,15 @@ UI-framework dependency. `createSimulation` runs against an immutable SDCPN snapshot. After initialization, later document mutations do not affect the active simulation. +## File Layout + +- `api.ts`: public simulation contract and exposed types. +- `runtime/`: `createSimulation` implementation and worker transport adapter. +- `frames/`: frame reader, metric projection, and internal frame storage. +- `authoring/`: user-authored metric/scenario compilation and sandboxing. +- `worker/`: worker protocol and runtime entrypoint. +- `engine/`: internal SDCPN execution engine. + ## Simulation State ```typescript @@ -48,7 +57,8 @@ type SimulationState = Provide exactly one execution transport: - `createWorker`: a factory returning a `Worker` or `Promise`. -- `transport`: a pre-built `SimulationTransport`. +- `transport`: a pre-built opaque `SimulationTransport` for tests or custom + worker adapters. ## Lifecycle @@ -82,11 +92,13 @@ Provide exactly one execution transport: `Simulation` handle once the worker reports ready. - `simulation.status`: readable store containing the current `SimulationState`. -- `simulation.frames`: readable store containing `{ count, latest }`. +- `simulation.frames`: readable store containing `{ count, latest }`, where + `latest` is a `SimulationFrameReader`. - `simulation.events`: event stream for completion and runtime errors. - `simulation.run()` / `simulation.pause()` / `simulation.reset()`: control computation. -- `simulation.getFrame(index)`: read a computed frame by index. +- `simulation.getFrame(index)`: read a computed frame by index as a + `SimulationFrameReader`. - `simulation.ack(frameNumber)`: acknowledge consumed frames for worker backpressure. - `simulation.setBackpressure(config)`: update worker frame-ahead and batch @@ -115,7 +127,9 @@ const simulation = await createSimulation({ const unsubscribe = simulation.frames.subscribe(({ count, latest }) => { if (latest) { - console.log(`Computed ${count} frames; latest time is ${latest.time}`); + console.log( + `Computed ${count} frames; place p1 has ${latest.getPlaceTokenCount("p1")} tokens`, + ); } }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/api.ts b/libs/@hashintel/petrinaut/src/core/simulation/api.ts new file mode 100644 index 00000000000..7015a70b9e8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/api.ts @@ -0,0 +1,174 @@ +import type { ReadableStore } from "../handle"; +import type { EventStream } from "../instance"; +import type { Color, Place, SDCPN } from "../types/sdcpn"; + +export type SimulationState = + | "Initializing" + | "Ready" + | "Running" + | "Paused" + | "Complete" + | "Error"; + +export type BackpressureConfig = { + /** Maximum frames the worker can compute ahead before waiting for ack. */ + maxFramesAhead?: number; + /** Number of frames to compute in each batch before checking for messages. */ + batchSize?: number; +}; + +export interface SimulationTransport { + /** Send a message to the engine. May queue if the transport is not yet ready. */ + send(message: unknown): void; + /** Subscribe to messages from the engine. Returns an unsubscribe function. */ + onMessage(listener: (message: unknown) => void): () => void; + /** Tear down the underlying worker / runtime. Idempotent. */ + terminate(): void; +} + +export type WorkerFactory = () => Worker | Promise; + +/** + * Initial token distribution for starting a simulation. + * Maps place IDs to their initial token values and counts. + */ +export type InitialMarking = Map< + string, + { values: Float64Array; count: number } +>; + +/** + * Common per-run config shared by both transport modes. The simulation runs + * against the {@link sdcpn} snapshot and never reads it again, so subsequent + * mutations to the source document don't affect a running simulation. + */ +export type SimulationConfig = { + sdcpn: SDCPN; + initialMarking: InitialMarking; + parameterValues: Record; + seed: number; + dt: number; + /** Maximum simulation time. Null = no limit. */ + maxTime: number | null; + backpressure?: BackpressureConfig; + /** Optional cancellation. Aborting tears down the simulation. */ + signal?: AbortSignal; +}; + +/** + * Top-level config for `createSimulation`. Provide exactly one of: + * + * - `createWorker`: a `Worker` factory; the function builds a transport for you. + * - `transport`: a pre-built {@link SimulationTransport}; ownership transfers + * to the simulation (it will be terminated on `simulation.dispose()`). + */ +export type CreateSimulationConfig = SimulationConfig & + ( + | { createWorker: WorkerFactory; transport?: never } + | { transport: SimulationTransport; createWorker?: never } + ); + +/** + * State of a transition within a simulation frame. + * + * Contains timing information and firing counts for tracking transition behavior + * during simulation execution. + */ +export type SimulationFrameState_Transition = { + /** + * Time elapsed since this transition last fired, in milliseconds. + * Resets to 0 when the transition fires. + */ + timeSinceLastFiringMs: number; + /** + * Whether this transition fired in this specific frame. + * True only during the frame when the firing occurred. + */ + firedInThisFrame: boolean; + /** + * Total cumulative count of times this transition has fired + * since the start of the simulation (frame 0). + */ + firingCount: number; +}; + +/** + * Simplified view of a simulation frame for higher-level consumers. + * Provides easy access to place and transition states without internal details. + */ +export type SimulationFrameState = { + /** Frame index in the simulation history */ + number: number; + /** Simulation time at this frame */ + time: number; + /** Place states indexed by place ID */ + places: { + [placeId: string]: + | { + /** Number of tokens in the place at the time of the frame. */ + tokenCount: number; + } + | undefined; + }; + /** Transition states indexed by transition ID */ + transitions: { + [transitionId: string]: SimulationFrameState_Transition | undefined; + }; +}; + +export type SimulationPlaceTokenValues = { + values: Float64Array; + count: number; +}; + +export interface SimulationFrameReader { + /** Frame index in the simulation history. */ + readonly number: number; + /** Simulation time at this frame. */ + readonly time: number; + + getPlaceTokenCount(placeId: string): number; + getPlaceTokenValues(placeId: string): SimulationPlaceTokenValues | null; + getPlaceTokens( + place: Place, + color: Color | null | undefined, + ): Record[]; + getTransitionState( + transitionId: string, + ): SimulationFrameState_Transition | null; + toFrameState(): SimulationFrameState; +} + +export type SimulationCompleteEvent = { + type: "complete"; + reason: "deadlock" | "maxTime"; + frameNumber: number; +}; + +export type SimulationErrorEvent = { + type: "error"; + message: string; + itemId: string | null; +}; + +export type SimulationEvent = SimulationCompleteEvent | SimulationErrorEvent; + +export type SimulationFrameSummary = { + count: number; + latest: SimulationFrameReader | null; +}; + +export interface Simulation { + readonly status: ReadableStore; + readonly frames: ReadableStore; + readonly events: EventStream; + + run(this: void): void; + pause(this: void): void; + reset(this: void): void; + ack(this: void, frameNumber: number): void; + setBackpressure(this: void, cfg: BackpressureConfig): void; + getFrame(this: void, index: number): SimulationFrameReader | null; + + dispose(this: void): void; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.test.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.test.ts index 018cb98423a..646b28fa986 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Metric } from "../types/sdcpn"; +import type { Metric } from "../../types/sdcpn"; import { compileMetric, type MetricState } from "./compile-metric"; const metric = (overrides: Partial = {}): Metric => ({ diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.ts index 85f276fa770..bc4d7ea8030 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.ts @@ -1,4 +1,4 @@ -import type { Metric } from "../types/sdcpn"; +import type { Metric } from "../../types/sdcpn"; import { runSandboxed, SHADOWED_GLOBALS } from "./sandbox"; // -- Public types ------------------------------------------------------------- diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.test.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.test.ts index 0416c0dd501..cd555e8b065 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Parameter, Scenario } from "../types/sdcpn"; +import type { Parameter, Scenario } from "../../types/sdcpn"; import { compileScenario } from "./compile-scenario"; // -- Helpers ------------------------------------------------------------------ diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.ts index 3776342434c..188f93f79cc 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.ts @@ -1,4 +1,4 @@ -import type { Color, Parameter, Place, Scenario } from "../types/sdcpn"; +import type { Color, Parameter, Place, Scenario } from "../../types/sdcpn"; import { runSandboxed, SHADOWED_GLOBALS } from "./sandbox"; // -- Result types ------------------------------------------------------------- diff --git a/libs/@hashintel/petrinaut/src/core/simulation/sandbox.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/sandbox.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/sandbox.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/sandbox.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md b/libs/@hashintel/petrinaut/src/core/simulation/engine/README.md similarity index 82% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md rename to libs/@hashintel/petrinaut/src/core/simulation/engine/README.md index 977acff8d5e..f66551d7d40 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/README.md @@ -1,10 +1,11 @@ -# Simulator +# Simulation Engine Core simulation logic for SDCPN Petri net execution. ## Overview -The simulator compiles an SDCPN definition into a runnable `SimulationInstance` and computes frames by evaluating transitions and differential equations. +The engine compiles an SDCPN definition into a runnable `SimulationInstance` +and computes frames by evaluating transitions and differential equations. ## Core Functions @@ -31,9 +32,11 @@ computeNextFrame(simulation) └─► Check deadlock → "deadlock" completion ``` -## SimulationFrame +## Internal SimulationFrame -A snapshot of simulation state at a point in time. +A snapshot of simulation state at a point in time. This is the engine and +worker storage layout. Public callers should read frames through +`SimulationFrameReader`. ```typescript type SimulationFrame = { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/distribution.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/distribution.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/distribution.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/enumerate-weighted-markings.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/enumerate-weighted-markings.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/enumerate-weighted-markings.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/enumerate-weighted-markings.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/seeded-rng.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/seeded-rng.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/seeded-rng.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/seeded-rng.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts similarity index 93% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts index a50b8dcd2c7..85fd81908aa 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts @@ -6,7 +6,7 @@ */ import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; -import type { SimulationFrame } from "../types"; +import type { SimulationFrame } from "../frames/internal-frame"; import type { RuntimeDistribution } from "./distribution"; /** @@ -91,9 +91,9 @@ export type SimulationInstance = { currentFrameNumber: number; }; -// Re-export frame types from context for convenient access within simulator +// Re-export frame types for convenient access within simulator internals. export type { SimulationFrame, SimulationFrameState_Place, - SimulationFrameState_Transition, -} from "../types"; +} from "../frames/internal-frame"; +export type { SimulationFrameState_Transition } from "../api"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts new file mode 100644 index 00000000000..984413e9edf --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place, Transition } from "../../types/sdcpn"; +import { createSimulationFrameReader } from "./frame-reader"; +import type { SimulationFrame } from "./internal-frame"; + +const color: Color = { + id: "color-1", + name: "Position", + iconSlug: "circle", + displayColor: "#000000", + elements: [ + { elementId: "x", name: "x", type: "real" }, + { elementId: "y", name: "y", type: "real" }, + ], +}; + +const place: Place = { + id: "place-1", + name: "Place 1", + colorId: color.id, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}; + +const transition: Transition = { + id: "transition-1", + name: "Transition 1", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, +}; + +function makeFrame(): SimulationFrame { + return { + time: 0.25, + places: { + [place.id]: { offset: 2, count: 2, dimensions: 2 }, + }, + transitions: { + [transition.id]: { + timeSinceLastFiringMs: 10, + firedInThisFrame: true, + firingCount: 3, + instance: transition, + }, + }, + buffer: new Float64Array([99, 99, 1, 2, 3, 4]), + }; +} + +describe("SimulationFrameReader", () => { + it("reads place and transition state without exposing raw frame layout", () => { + const reader = createSimulationFrameReader(makeFrame(), 7); + + expect(reader.number).toBe(7); + expect(reader.time).toBe(0.25); + expect(reader.getPlaceTokenCount(place.id)).toBe(2); + expect(reader.getPlaceTokenCount("missing")).toBe(0); + + expect(reader.getPlaceTokenValues(place.id)).toEqual({ + values: new Float64Array([1, 2, 3, 4]), + count: 2, + }); + expect(reader.getPlaceTokens(place, color)).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + const transitionState = reader.getTransitionState(transition.id); + expect(transitionState).toEqual({ + timeSinceLastFiringMs: 10, + firedInThisFrame: true, + firingCount: 3, + }); + expect(transitionState).not.toHaveProperty("instance"); + + expect(reader.toFrameState()).toEqual({ + number: 7, + time: 0.25, + places: { + [place.id]: { tokenCount: 2 }, + }, + transitions: { + [transition.id]: transitionState, + }, + }); + }); + + it("returns a copied token value buffer", () => { + const reader = createSimulationFrameReader(makeFrame(), 7); + const values = reader.getPlaceTokenValues(place.id); + + expect(values).not.toBeNull(); + values!.values[0] = 42; + + expect(reader.getPlaceTokenValues(place.id)?.values).toEqual( + new Float64Array([1, 2, 3, 4]), + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts new file mode 100644 index 00000000000..44aa08c9c40 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts @@ -0,0 +1,102 @@ +import type { + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, + SimulationPlaceTokenValues, +} from "../api"; +import type { SimulationFrame } from "./internal-frame"; + +export function createSimulationFrameReader( + frame: SimulationFrame, + number: number, +): SimulationFrameReader { + const getPlaceTokenCount = (placeId: string): number => + frame.places[placeId]?.count ?? 0; + + const getPlaceTokenValues = ( + placeId: string, + ): SimulationPlaceTokenValues | null => { + const placeState = frame.places[placeId]; + if (!placeState) { + return null; + } + + const { offset, count, dimensions } = placeState; + const size = count * dimensions; + return { + values: frame.buffer.slice(offset, offset + size), + count, + }; + }; + + const getTransitionState = ( + transitionId: string, + ): SimulationFrameState_Transition | null => { + const transitionState = frame.transitions[transitionId]; + if (!transitionState) { + return null; + } + + return { + timeSinceLastFiringMs: transitionState.timeSinceLastFiringMs, + firedInThisFrame: transitionState.firedInThisFrame, + firingCount: transitionState.firingCount, + }; + }; + + return { + number, + time: frame.time, + getPlaceTokenCount, + getPlaceTokenValues, + getPlaceTokens(place, color) { + const placeState = frame.places[place.id]; + if (!placeState) { + return []; + } + + const { offset, count, dimensions } = placeState; + const elements = color?.elements ?? []; + const tokens: Record[] = []; + if (elements.length === 0 || dimensions === 0 || count === 0) { + return tokens; + } + + for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + const token: Record = {}; + const base = offset + tokenIndex * dimensions; + for ( + let dimensionIndex = 0; + dimensionIndex < elements.length && dimensionIndex < dimensions; + dimensionIndex++ + ) { + token[elements[dimensionIndex]!.name] = + frame.buffer[base + dimensionIndex] ?? 0; + } + tokens.push(token); + } + + return tokens; + }, + getTransitionState, + toFrameState() { + const places: SimulationFrameState["places"] = {}; + for (const [placeId, placeData] of Object.entries(frame.places)) { + places[placeId] = { tokenCount: placeData.count }; + } + + const transitions: SimulationFrameState["transitions"] = {}; + for (const transitionId of Object.keys(frame.transitions)) { + transitions[transitionId] = + getTransitionState(transitionId) ?? undefined; + } + + return { + number, + time: frame.time, + places, + transitions, + }; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts new file mode 100644 index 00000000000..e84c0b95f42 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts @@ -0,0 +1,36 @@ +import type { ID, Transition } from "../../types/sdcpn"; +import type { SimulationFrameState_Transition } from "../api"; + +/** + * Internal place layout within a simulation frame. + */ +export type SimulationFrameState_Place = { + offset: number; + count: number; + dimensions: number; +}; + +/** + * Internal frame storage layout exchanged with the worker. + * + * Public callers should read this through `SimulationFrameReader`. + */ +export type SimulationFrame = { + /** Simulation time at this frame */ + time: number; + /** Place states with token buffer offsets, keyed by place ID */ + places: Record; + /** Transition states with firing information, keyed by transition ID */ + transitions: Record< + ID, + SimulationFrameState_Transition & { instance: Transition } + >; + /** + * Buffer containing all place values concatenated. + * + * Size: sum of (place.dimensions * place.count) for all places. + * + * Layout: For each place, its tokens are stored contiguously. + */ + buffer: Float64Array; +}; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts new file mode 100644 index 00000000000..b12b60691e9 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts @@ -0,0 +1,27 @@ +import type { Color, Place } from "../../types/sdcpn"; +import type { SimulationFrameReader } from "../api"; +import type { MetricState } from "../authoring/compile-metric"; + +/** + * Reshape a simulation frame reader into the `MetricState` shape exposed to + * compiled metric functions. Place state is keyed by place **name** so author + * code can read e.g. `state.places.Infected.count`. + */ +export function buildMetricState( + frame: SimulationFrameReader, + places: Place[], + types: Color[], +): MetricState { + const typeById = new Map(types.map((t) => [t.id, t])); + const placesByName: Record = {}; + + for (const place of places) { + const color = place.colorId ? typeById.get(place.colorId) : undefined; + placesByName[place.name] = { + count: frame.getPlaceTokenCount(place.id), + tokens: frame.getPlaceTokens(place, color), + }; + } + + return { places: placesByName }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/index.ts b/libs/@hashintel/petrinaut/src/core/simulation/index.ts index f39f72f550f..c4fcc6139ef 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/index.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/index.ts @@ -1,17 +1,20 @@ -export { - createSimulation, - type BackpressureConfig, - type CreateSimulationConfig, - type Simulation, - type SimulationCompleteEvent, - type SimulationConfig, - type SimulationErrorEvent, - type SimulationEvent, - type SimulationFrameSummary, - type SimulationState, -} from "./simulation"; -export { - createWorkerTransport, - type SimulationTransport, - type WorkerFactory, -} from "./transport"; +export type { + BackpressureConfig, + CreateSimulationConfig, + InitialMarking, + Simulation, + SimulationCompleteEvent, + SimulationConfig, + SimulationErrorEvent, + SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, + SimulationFrameSummary, + SimulationPlaceTokenValues, + SimulationTransport, + SimulationState, + WorkerFactory, +} from "./api"; +export { createSimulation } from "./runtime/simulation"; +export { createWorkerTransport } from "./runtime/transport"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts deleted file mode 100644 index f025449069f..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Color, Place } from "../types/sdcpn"; -import type { MetricState } from "./compile-metric"; -import type { SimulationFrame } from "./types"; - -/** - * Reshape a raw `SimulationFrame` into the `MetricState` shape exposed to - * compiled metric functions. Place state is keyed by place **name** so author - * code can read e.g. `state.places.Infected.count`. - * - * For colored places, each token is reconstructed as a `Record` - * by slicing the frame's flat `buffer` using `{ offset, count, dimensions }` - * and the place's color element names. - */ -export function buildMetricState( - frame: SimulationFrame, - places: Place[], - types: Color[], -): MetricState { - const typeById = new Map(types.map((t) => [t.id, t])); - const placesByName: Record = {}; - - for (const place of places) { - const placeFrame = frame.places[place.id]; - if (!placeFrame) { - placesByName[place.name] = { count: 0, tokens: [] }; - continue; - } - - const { offset, count, dimensions } = placeFrame; - const color = place.colorId ? typeById.get(place.colorId) : undefined; - const elements = color?.elements ?? []; - - const tokens: Record[] = []; - if (elements.length > 0 && dimensions > 0 && count > 0) { - for (let i = 0; i < count; i++) { - const token: Record = {}; - const base = offset + i * dimensions; - for (let d = 0; d < elements.length && d < dimensions; d++) { - token[elements[d]!.name] = frame.buffer[base + d] ?? 0; - } - tokens.push(token); - } - } - - placesByName[place.name] = { count, tokens }; - } - - return { places: placesByName }; -} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts similarity index 94% rename from libs/@hashintel/petrinaut/src/core/simulation/simulation.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts index 7c3a10bc6bc..68b855164f1 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { SimulationFrame } from "./types"; -import type { ToMainMessage, ToWorkerMessage } from "./worker/messages"; -import type { SDCPN } from "../types/sdcpn"; -import { createSimulation, type SimulationFrameSummary } from "./simulation"; -import type { SimulationTransport } from "./transport"; +import type { SimulationFrame } from "../frames/internal-frame"; +import type { ToMainMessage, ToWorkerMessage } from "../worker/messages"; +import type { SDCPN } from "../../types/sdcpn"; +import type { SimulationFrameSummary, SimulationTransport } from "../api"; +import { createSimulation } from "./simulation"; const empty = (): SDCPN => ({ places: [], @@ -29,12 +29,12 @@ function makeFrame(time: number): SimulationFrame { */ function makeMockTransport() { const sent: ToWorkerMessage[] = []; - const listeners = new Set<(m: ToMainMessage) => void>(); + const listeners = new Set<(m: unknown) => void>(); let terminated = false; const transport: SimulationTransport = { send(message) { - sent.push(message); + sent.push(message as ToWorkerMessage); }, onMessage(listener) { listeners.add(listener); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts similarity index 66% rename from libs/@hashintel/petrinaut/src/core/simulation/simulation.ts rename to libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts index bf80240bccc..e033d519d47 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts @@ -1,92 +1,18 @@ -import type { ReadableStore } from "../handle"; -import type { EventStream } from "../instance"; -import type { SDCPN } from "../types/sdcpn"; -import { - createWorkerTransport, - type SimulationTransport, - type WorkerFactory, -} from "./transport"; -import type { InitialMarking, SimulationFrame } from "./types"; - -export type SimulationState = - | "Initializing" - | "Ready" - | "Running" - | "Paused" - | "Complete" - | "Error"; - -export type BackpressureConfig = { - /** Maximum frames the worker can compute ahead before waiting for ack. */ - maxFramesAhead?: number; - /** Number of frames to compute in each batch before checking for messages. */ - batchSize?: number; -}; - -/** - * Common per-run config shared by both transport modes. The simulation runs - * against the {@link sdcpn} snapshot and never reads it again, so subsequent - * mutations to the source document don't affect a running simulation. - */ -export type SimulationConfig = { - sdcpn: SDCPN; - initialMarking: InitialMarking; - parameterValues: Record; - seed: number; - dt: number; - /** Maximum simulation time. Null = no limit. */ - maxTime: number | null; - backpressure?: BackpressureConfig; - /** Optional cancellation. Aborting tears down the simulation. */ - signal?: AbortSignal; -}; - -/** - * Top-level config for {@link createSimulation}. Provide exactly one of: - * - * - `createWorker`: a `Worker` factory; the function builds a transport for you. - * - `transport`: a pre-built {@link SimulationTransport}; ownership transfers - * to the simulation (it will be terminated on `simulation.dispose()`). - */ -export type CreateSimulationConfig = SimulationConfig & - ( - | { createWorker: WorkerFactory; transport?: never } - | { transport: SimulationTransport; createWorker?: never } - ); - -export type SimulationCompleteEvent = { - type: "complete"; - reason: "deadlock" | "maxTime"; - frameNumber: number; -}; - -export type SimulationErrorEvent = { - type: "error"; - message: string; - itemId: string | null; -}; - -export type SimulationEvent = SimulationCompleteEvent | SimulationErrorEvent; - -export type SimulationFrameSummary = { - count: number; - latest: SimulationFrame | null; -}; - -export interface Simulation { - readonly status: ReadableStore; - readonly frames: ReadableStore; - readonly events: EventStream; - - run(this: void): void; - pause(this: void): void; - reset(this: void): void; - ack(this: void, frameNumber: number): void; - setBackpressure(this: void, cfg: BackpressureConfig): void; - getFrame(this: void, index: number): SimulationFrame | null; - - dispose(this: void): void; -} +import type { ReadableStore } from "../../handle"; +import type { EventStream } from "../../instance"; +import type { + CreateSimulationConfig, + Simulation, + SimulationErrorEvent, + SimulationEvent, + SimulationFrameSummary, + SimulationState, + SimulationTransport, +} from "../api"; +import { createWorkerTransport } from "./transport"; +import type { ToMainMessage } from "../worker/messages"; +import { createSimulationFrameReader } from "../frames/frame-reader"; +import type { SimulationFrame } from "../frames/internal-frame"; function createReadableStore(initial: T): ReadableStore & { set(next: T): void; @@ -165,7 +91,13 @@ export function createSimulation( } frameSummary.set({ count: frames.length, - latest: frames[frames.length - 1] ?? null, + latest: + frames.length > 0 + ? createSimulationFrameReader( + frames[frames.length - 1]!, + frames.length - 1, + ) + : null, }); } @@ -173,7 +105,8 @@ export function createSimulation( let settled = false; let handle: Simulation; - const off = transport.onMessage((message) => { + const off = transport.onMessage((rawMessage) => { + const message = rawMessage as ToMainMessage; switch (message.type) { case "ready": { status.set("Ready"); @@ -278,7 +211,8 @@ export function createSimulation( }); }, getFrame(index) { - return frames[index] ?? null; + const frame = frames[index]; + return frame ? createSimulationFrameReader(frame, index) : null; }, dispose() { if (disposed) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/transport.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/transport.ts similarity index 53% rename from libs/@hashintel/petrinaut/src/core/simulation/transport.ts rename to libs/@hashintel/petrinaut/src/core/simulation/runtime/transport.ts index 4e61ade0b8c..dc2ab3dcc18 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/transport.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/transport.ts @@ -1,23 +1,5 @@ -import type { ToMainMessage, ToWorkerMessage } from "./worker/messages"; - -/** - * Protocol-level abstraction over the simulation worker. Decouples the - * `Simulation` handle from how the engine is actually run — Worker, inline, - * recorded replay, or a Node `worker_threads` polyfill all satisfy this shape. - * - * This allows the same simulation handle to run against a browser worker, - * inline test transport, recorded replay transport, or Node worker adapter. - */ -export interface SimulationTransport { - /** Send a message to the engine. May queue if the transport is not yet ready. */ - send(message: ToWorkerMessage): void; - /** Subscribe to messages from the engine. Returns an unsubscribe function. */ - onMessage(listener: (message: ToMainMessage) => void): () => void; - /** Tear down the underlying worker / runtime. Idempotent. */ - terminate(): void; -} - -export type WorkerFactory = () => Worker | Promise; +import type { ToMainMessage } from "../worker/messages"; +import type { SimulationTransport, WorkerFactory } from "../api"; /** * Wrap a `Worker` factory in a {@link SimulationTransport}. Messages sent @@ -26,10 +8,10 @@ export type WorkerFactory = () => Worker | Promise; export function createWorkerTransport( createWorker: WorkerFactory, ): SimulationTransport { - const listeners = new Set<(message: ToMainMessage) => void>(); + const listeners = new Set<(message: unknown) => void>(); let worker: Worker | null = null; let terminated = false; - const queued: ToWorkerMessage[] = []; + const queued: unknown[] = []; void Promise.resolve(createWorker()).then((w) => { if (terminated) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/types.ts deleted file mode 100644 index b11371a23be..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ID, Transition } from "../types/sdcpn"; - -/** - * State of a transition within a simulation frame. - * - * Contains timing information and firing counts for tracking transition behavior - * during simulation execution. - */ -export type SimulationFrameState_Transition = { - /** - * Time elapsed since this transition last fired, in milliseconds. - * Resets to 0 when the transition fires. - */ - timeSinceLastFiringMs: number; - /** - * Whether this transition fired in this specific frame. - * True only during the frame when the firing occurred. - */ - firedInThisFrame: boolean; - /** - * Total cumulative count of times this transition has fired - * since the start of the simulation (frame 0). - */ - firingCount: number; -}; - -/** - * State of a place within a simulation frame. - */ -export type SimulationFrameState_Place = { - offset: number; - count: number; - dimensions: number; -}; - -/** - * A single frame (snapshot) of the simulation state at a point in time. - * Contains the complete token distribution and transition states. - * - * All properties are serializable (no Map types) to support transfer - * between WebWorker and Main Thread via structured clone. - */ -export type SimulationFrame = { - /** Simulation time at this frame */ - time: number; - /** Place states with token buffer offsets, keyed by place ID */ - places: Record; - /** Transition states with firing information, keyed by transition ID */ - transitions: Record< - ID, - SimulationFrameState_Transition & { instance: Transition } - >; - /** - * Buffer containing all place values concatenated. - * - * Size: sum of (place.dimensions * place.count) for all places. - * - * Layout: For each place, its tokens are stored contiguously. - * - * Access to a place's token values can be done via the offset and count in the `places` record. - */ - buffer: Float64Array; -}; - -/** - * Simplified view of a simulation frame for higher-level consumers. - * Provides easy access to place and transition states without internal details. - */ -export type SimulationFrameState = { - /** Frame index in the simulation history */ - number: number; - /** Simulation time at this frame */ - time: number; - /** Place states indexed by place ID */ - places: { - [placeId: string]: - | { - /** Number of tokens in the place at the time of the frame. */ - tokenCount: number; - } - | undefined; - }; - /** Transition states indexed by transition ID */ - transitions: { - [transitionId: string]: SimulationFrameState_Transition | undefined; - }; -}; - -/** - * Initial token distribution for starting a simulation. - * Maps place IDs to their initial token values and counts. - */ -export type InitialMarking = Map< - string, - { values: Float64Array; count: number } ->; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts index 9bc08d21515..7fd908a6264 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts @@ -5,7 +5,7 @@ */ import type { SDCPN } from "../../types/sdcpn"; -import type { SimulationFrame } from "../types"; +import type { SimulationFrame } from "../frames/internal-frame"; // // Main Thread → Worker Messages diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts index 9fa4ec37f3e..2bb3d6f7da8 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts @@ -9,9 +9,9 @@ */ import { SDCPNItemError } from "../../errors"; -import { buildSimulation } from "../simulator/build-simulation"; -import { computeNextFrame } from "../simulator/compute-next-frame"; -import type { SimulationInstance } from "../simulator/types"; +import { buildSimulation } from "../engine/build-simulation"; +import { computeNextFrame } from "../engine/compute-next-frame"; +import type { SimulationInstance } from "../engine/types"; import type { ToMainMessage, ToWorkerMessage } from "./messages"; // diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 5be8888a443..1f0ac997494 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -46,12 +46,17 @@ export { export type { BackpressureConfig, CreateSimulationConfig, + InitialMarking, Simulation, SimulationCompleteEvent, SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, SimulationFrameSummary, + SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index 1466b535c17..dfbca552c62 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -19,19 +19,21 @@ export { export { useGetSimulationFrame, + useGetSimulationFrameReader, useSimulationActions, useSimulationError, useSimulationFrameCount, useSimulationParameters, useSimulationStatus, type SimulationActionsBundle, - type SimulationFrame, + type SimulationFrameReader, type SimulationFrameState, type SimulationState, } from "./use-simulation"; export { useCurrentFrame, + useCurrentFrameReader, useCurrentViewedFrame, useIsComputeAvailable, useIsViewOnlyAvailable, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts index 8f12fe50f76..bb45b38be65 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts @@ -26,11 +26,13 @@ export function usePlaybackMode(): PlayMode { return use(PlaybackContext).playMode; } -/** Currently displayed frame data, or `null` if no simulation is running. */ -export function useCurrentFrame(): PlaybackContextValue["currentFrame"] { - return use(PlaybackContext).currentFrame; +/** Reader for the currently displayed frame, or `null` if no simulation is running. */ +export function useCurrentFrameReader(): PlaybackContextValue["currentFrameReader"] { + return use(PlaybackContext).currentFrameReader; } +export const useCurrentFrame = useCurrentFrameReader; + /** Simplified, UI-shaped view of the current frame. */ export function useCurrentViewedFrame(): PlaybackContextValue["currentViewedFrame"] { return use(PlaybackContext).currentViewedFrame; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts index 78803be6b17..8ea069fe29d 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts @@ -1,9 +1,9 @@ import { use } from "react"; import type { - SimulationFrame, + SimulationFrameReader, SimulationFrameState, -} from "../../core/simulation/types"; +} from "../../core/simulation"; import { SimulationContext, type SimulationContextValue, @@ -24,15 +24,17 @@ export function useSimulationFrameCount(): number { } /** - * Async access to a specific frame by index. Resolves to `null` when the index - * is out of range or no simulation exists. + * Async access to a specific frame reader by index. Resolves to `null` when + * the index is out of range or no simulation exists. */ -export function useGetSimulationFrame(): ( +export function useGetSimulationFrameReader(): ( index: number, -) => Promise { +) => Promise { return use(SimulationContext).getFrame; } +export const useGetSimulationFrame = useGetSimulationFrameReader; + export type SimulationActionsBundle = { initialize: SimulationContextValue["initialize"]; run: SimulationContextValue["run"]; @@ -87,4 +89,4 @@ export function useSimulationError(): { return { message: ctx.error, itemId: ctx.errorItemId }; } -export type { SimulationFrame, SimulationFrameState, SimulationState }; +export type { SimulationFrameReader, SimulationFrameState, SimulationState }; diff --git a/libs/@hashintel/petrinaut/src/react/playback/README.md b/libs/@hashintel/petrinaut/src/react/playback/README.md index 06752e985f5..512ca272232 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/README.md +++ b/libs/@hashintel/petrinaut/src/react/playback/README.md @@ -4,7 +4,9 @@ React context for viewing simulation frames at controlled speeds. ## Overview -PlaybackProvider reads frames from SimulationContext and advances them using `requestAnimationFrame`. It controls both visualization playback and simulation computation via backpressure. +PlaybackProvider reads frame readers from SimulationContext and advances them +using `requestAnimationFrame`. It controls both visualization playback and +simulation computation via backpressure. ## Play Mode @@ -52,7 +54,7 @@ Playback auto-pauses when reaching the end of available frames (if simulation is **Reading:** -- `getFrame()`: Access frame data for current index +- `getFrame()`: Access a `SimulationFrameReader` for the current index - `dt`: Calculate real-time playback timing - `totalFrames`: Know when new frames are available - `state`: Determine available play modes diff --git a/libs/@hashintel/petrinaut/src/react/playback/context.ts b/libs/@hashintel/petrinaut/src/react/playback/context.ts index 703cf32cf8d..c76706f35c0 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/context.ts +++ b/libs/@hashintel/petrinaut/src/react/playback/context.ts @@ -8,7 +8,7 @@ import { type PlayMode, } from "../../core/playback"; import type { - SimulationFrame, + SimulationFrameReader, SimulationFrameState, } from "../simulation/context"; @@ -32,11 +32,10 @@ export { export type PlaybackContextValue = { // State values /** - * The raw simulation frame data for the currently viewed frame. - * Contains buffer data for accessing token values directly. + * Reader for the currently viewed frame. * Null when no simulation is running or no frames exist. */ - currentFrame: SimulationFrame | null; + currentFrameReader: SimulationFrameReader | null; /** * The currently viewed simulation frame state (simplified view). @@ -120,7 +119,7 @@ export type PlaybackContextValue = { }; const DEFAULT_CONTEXT_VALUE: PlaybackContextValue = { - currentFrame: null, + currentFrameReader: null, currentViewedFrame: null, playbackState: "Stopped", currentFrameIndex: 0, diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx index 79ebaee5eec..55291ea76e7 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx @@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SimulationContext, type SimulationContextValue, - type SimulationFrame, + type SimulationFrameReader, } from "../simulation/context"; +import { createSimulationFrameReader } from "../../core/simulation/frames/frame-reader"; +import type { SimulationFrame } from "../../core/simulation/frames/internal-frame"; import { PlaybackContext, type PlaybackContextValue } from "./context"; import { PlaybackProvider } from "./provider"; @@ -32,12 +34,12 @@ function createMockFrame(time: number): SimulationFrame { } /** - * Creates mock frames array for testing. + * Creates mock frame readers array for testing. */ -function createMockFrames(frameCount: number): SimulationFrame[] { - const frames: SimulationFrame[] = []; +function createMockFrameReaders(frameCount: number): SimulationFrameReader[] { + const frames: SimulationFrameReader[] = []; for (let i = 0; i < frameCount; i++) { - frames.push(createMockFrame(i * 0.01)); + frames.push(createSimulationFrameReader(createMockFrame(i * 0.01), i)); } return frames; } @@ -45,7 +47,7 @@ function createMockFrames(frameCount: number): SimulationFrame[] { /** * Creates mock getFrame, getAllFrames, and getFramesInRange functions for testing. */ -function createMockFrameAccessors(frames: SimulationFrame[]) { +function createMockFrameAccessors(frames: SimulationFrameReader[]) { return { getFrame: vi.fn((index: number) => Promise.resolve(frames[index] ?? null)), getAllFrames: vi.fn(() => Promise.resolve(frames)), @@ -64,7 +66,7 @@ function createMockSimulationContext( overrides: MockSimulationContextOverrides = {}, frameCount = 0, ): SimulationContextValue { - const frames = createMockFrames(frameCount); + const frames = createMockFrameReaders(frameCount); const frameAccessors = createMockFrameAccessors(frames); return { diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.tsx index fba29723216..e4c06b96adf 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.tsx @@ -13,7 +13,7 @@ import { useLatest } from "../hooks/use-latest"; import { useStableCallback } from "../hooks/use-stable-callback"; import { SimulationContext, - type SimulationFrame, + type SimulationFrameReader, type SimulationFrameState, } from "../simulation/context"; import { useStore } from "../use-store"; @@ -38,39 +38,13 @@ const EMPTY_PLAYBACK_STORE: ReadableStore = { }; /** - * Converts a {@link SimulationFrame} to the simplified {@link SimulationFrameState} + * Converts a {@link SimulationFrameReader} to the simplified {@link SimulationFrameState} * shape consumed by visualisations. */ function buildFrameState( - frame: SimulationFrame | null, - frameIndex: number, + frame: SimulationFrameReader | null, ): SimulationFrameState | null { - if (!frame) { - return null; - } - - const places: SimulationFrameState["places"] = {}; - for (const [placeId, placeData] of Object.entries(frame.places)) { - places[placeId] = { tokenCount: placeData.count }; - } - - const transitions: SimulationFrameState["transitions"] = {}; - for (const [transitionId, transitionData] of Object.entries( - frame.transitions, - )) { - transitions[transitionId] = { - timeSinceLastFiringMs: transitionData.timeSinceLastFiringMs, - firedInThisFrame: transitionData.firedInThisFrame, - firingCount: transitionData.firingCount, - }; - } - - return { - number: frameIndex, - time: frame.time, - places, - transitions, - }; + return frame?.toFrameState() ?? null; } type PlaybackProviderProps = React.PropsWithChildren; @@ -112,9 +86,8 @@ export const PlaybackProvider: React.FC = ({ // Currently displayed frame data, fetched from the simulation when the // index changes. - const [currentFrame, setCurrentFrame] = useState( - null, - ); + const [currentFrameReader, setCurrentFrameReader] = + useState(null); // Refs for stable identities inside the rAF loop / callbacks. const dtRef = useLatest(dt); @@ -134,7 +107,7 @@ export const PlaybackProvider: React.FC = ({ let cancelled = false; void getFrame(frameIndex).then((frame) => { if (!cancelled) { - setCurrentFrame(frame); + setCurrentFrameReader(frame); } }); return () => { @@ -362,10 +335,10 @@ export const PlaybackProvider: React.FC = ({ playback.setMode(nextMode); }; - const currentViewedFrame = buildFrameState(currentFrame, frameIndex); + const currentViewedFrame = buildFrameState(currentFrameReader); const contextValue: PlaybackContextValue = { - currentFrame, + currentFrameReader, currentViewedFrame, playbackState: playState, currentFrameIndex: frameIndex, diff --git a/libs/@hashintel/petrinaut/src/react/simulation/context.ts b/libs/@hashintel/petrinaut/src/react/simulation/context.ts index 6b5d7a26861..efde64c89e4 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/context.ts +++ b/libs/@hashintel/petrinaut/src/react/simulation/context.ts @@ -1,21 +1,19 @@ import { createContext } from "react"; -import type { CompiledScenarioResult } from "../../core/simulation/compile-scenario"; +import type { CompiledScenarioResult } from "../../core/simulation/authoring/compile-scenario"; import type { InitialMarking, - SimulationFrame, + SimulationFrameReader, SimulationFrameState, - SimulationFrameState_Place, SimulationFrameState_Transition, -} from "../../core/simulation/types"; +} from "../../core/simulation"; // Re-export for back-compat with existing consumers that import these from // the simulation context module. export type { InitialMarking, - SimulationFrame, + SimulationFrameReader, SimulationFrameState, - SimulationFrameState_Place, SimulationFrameState_Transition, }; @@ -37,9 +35,9 @@ export type SimulationState = /** * The combined simulation context containing both state and actions. * - * Note: The full SimulationInstance is not exposed. Instead, use `getFrame()` - * to access individual frame data. This encapsulation supports the WebWorker - * architecture where frames are computed off the main thread. + * Note: The full SimulationInstance and raw frame storage are not exposed. + * Instead, use `getFrame()` to access individual frames through a + * `SimulationFrameReader`. */ export type SimulationContextValue = { // State values @@ -87,9 +85,9 @@ export type SimulationContextValue = { * is kept internal to the provider for memory management. * * @param frameIndex - The index of the frame to retrieve (0-based) - * @returns Promise resolving to the frame data or null + * @returns Promise resolving to the frame reader or null */ - getFrame: (frameIndex: number) => Promise; + getFrame: (frameIndex: number) => Promise; /** * Get all computed frames. @@ -98,9 +96,9 @@ export type SimulationContextValue = { * Note: For large simulations, this may return a large array. * Consider using getFrame() for single-frame access when possible. * - * @returns Promise resolving to array of all frames + * @returns Promise resolving to array of all frame readers */ - getAllFrames: () => Promise; + getAllFrames: () => Promise; /** * Get frames in a specified range. @@ -112,12 +110,12 @@ export type SimulationContextValue = { * * @param startIndex - The starting frame index (inclusive, 0-based) * @param endIndex - The ending frame index (exclusive). If omitted, returns to the end. - * @returns Promise resolving to array of frames in the range + * @returns Promise resolving to array of frame readers in the range */ getFramesInRange: ( startIndex: number, endIndex?: number, - ) => Promise; + ) => Promise; /** * ID of the currently selected scenario, or `null` for no scenario. diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index 840968ea70c..64fbe9c8d70 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -10,7 +10,7 @@ import { import { compileScenario, type CompiledScenarioResult, -} from "../../core/simulation/compile-scenario"; +} from "../../core/simulation/authoring/compile-scenario"; import { createSimulationWorker } from "../../core/simulation/worker/create-simulation-worker"; import { deriveDefaultParameterValues } from "../hooks/use-default-parameter-values"; import { useLatest } from "../hooks/use-latest"; @@ -22,7 +22,7 @@ import { type InitialMarking, SimulationContext, type SimulationContextValue, - type SimulationFrame, + type SimulationFrameReader, type SimulationState, } from "./context"; @@ -61,7 +61,7 @@ const EMPTY_STATUS_STORE: ReadableStore = { */ const EMPTY_FRAME_SUMMARY: { count: number; - latest: SimulationFrame | null; + latest: SimulationFrameReader | null; } = { count: 0, latest: null }; const EMPTY_FRAMES_STORE: ReadableStore = { @@ -365,7 +365,7 @@ export const SimulationProvider: React.FC = ({ // Frame access — reads from the active simulation handle. const getFrame: SimulationContextValue["getFrame"] = ( frameIndex: number, - ): Promise => { + ): Promise => { const sim = simulationRef.current; if (!sim) { return Promise.resolve(null); @@ -378,7 +378,7 @@ export const SimulationProvider: React.FC = ({ if (!sim) { return Promise.resolve([]); } - const all: SimulationFrame[] = []; + const all: SimulationFrameReader[] = []; const total = sim.frames.get().count; for (let i = 0; i < total; i++) { const frame = sim.getFrame(i); @@ -400,7 +400,7 @@ export const SimulationProvider: React.FC = ({ const total = sim.frames.get().count; const start = Math.max(0, startIndex); const end = endIndex === undefined ? total : Math.min(endIndex, total); - const slice: SimulationFrame[] = []; + const slice: SimulationFrameReader[] = []; for (let i = start; i < end; i++) { const frame = sim.getFrame(i); if (frame) { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx index 338e915c9c0..ee22e9f9fe5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx @@ -76,7 +76,7 @@ const PlaybackSettingsMenuStory = ({ > number; +type SeriesExtractor = ( + frame: SimulationFrameReader, + seriesIdx: number, +) => number; const UNTYPED_COLOR = "#94a3b8"; // slate-400 @@ -469,7 +472,7 @@ function useStreamingData(): { timeHistory.push(frame.time); } - const transitionState = frame.transitions[id]; + const transitionState = frame.getTransitionState(id); const firingCount = transitionState?.firingCount ?? 0; const tslSec = (transitionState?.timeSinceLastFiringMs ?? 0) / 1000; @@ -563,7 +566,7 @@ function useStreamingData(): { } let sum = 0; for (const id of ids) { - sum += frame.places[id]?.count ?? 0; + sum += frame.getPlaceTokenCount(id); } return sum; }; @@ -584,7 +587,7 @@ function useStreamingData(): { const placeIds = places.map((p) => p.id); const extract: SeriesExtractor = (frame, seriesIdx) => { const id = placeIds[seriesIdx]; - return id ? (frame.places[id]?.count ?? 0) : 0; + return id ? frame.getPlaceTokenCount(id) : 0; }; return { series, extract }; }, [ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx index d75d8061733..07c643ac4a7 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx @@ -23,7 +23,7 @@ export const InitialStateEditor: React.FC = ({ readOnly = false, }) => { const { initialMarking, setInitialMarking } = use(SimulationContext); - const { currentFrame, totalFrames } = use(PlaybackContext); + const { currentFrameReader, totalFrames } = use(PlaybackContext); const hasSimulation = totalFrames > 0; @@ -39,21 +39,12 @@ export const InitialStateEditor: React.FC = ({ // Get current marking for this place - either from simulation frame or initial marking const currentMarking = useMemo(() => { - if (hasSimulation && currentFrame) { - const placeState = currentFrame.places[placeId]; - if (!placeState) { - return null; - } - - const { offset, count, dimensions } = placeState; - const placeSize = count * dimensions; - const values = currentFrame.buffer.slice(offset, offset + placeSize); - - return { values, count }; + if (hasSimulation && currentFrameReader) { + return currentFrameReader.getPlaceTokenValues(placeId); } return initialMarking.get(placeId) ?? null; - }, [hasSimulation, currentFrame, initialMarking, placeId]); + }, [hasSimulation, currentFrameReader, initialMarking, placeId]); // Convert Float64Array marking data to number[][] for the Spreadsheet const data: number[][] = useMemo(() => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx index b7eae102a18..497f9047aef 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx @@ -91,7 +91,7 @@ const PlaceInitialStateContent: React.FC = () => { const { initialMarking, setInitialMarking, selectedScenarioId } = use(SimulationContext); - const { currentFrame, totalFrames } = use(PlaybackContext); + const { currentFrameReader, totalFrames } = use(PlaybackContext); // Determine if simulation is running (has frames) const hasSimulationFrames = totalFrames > 0; @@ -113,9 +113,8 @@ const PlaceInitialStateContent: React.FC = () => { // Uncolored places: show token count let tokenCount = 0; - if (hasSimulationFrames && currentFrame) { - const placeState = currentFrame.places[place.id]; - tokenCount = placeState?.count ?? 0; + if (hasSimulationFrames && currentFrameReader) { + tokenCount = currentFrameReader.getPlaceTokenCount(place.id); } else { const marking = initialMarking.get(place.id); tokenCount = marking?.count ?? 0; @@ -140,9 +139,8 @@ const PlaceInitialStateContent: React.FC = () => { if (!placeType || placeType.elements.length === 0) { // Get token count from simulation frame or initial marking let currentTokenCount = 0; - if (hasSimulationFrames && currentFrame) { - const placeState = currentFrame.places[place.id]; - currentTokenCount = placeState?.count ?? 0; + if (hasSimulationFrames && currentFrameReader) { + currentTokenCount = currentFrameReader.getPlaceTokenCount(place.id); } else { const currentMarking = initialMarking.get(place.id); currentTokenCount = currentMarking?.count ?? 0; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index 22f792999db..78df1f71449 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -79,7 +79,7 @@ const VisualizerPreview: React.FC = () => { const { place, placeType } = usePlacePropertiesContext(); const { initialMarking, parameterValues } = use(SimulationContext); - const { currentFrame, totalFrames } = use(PlaybackContext); + const { currentFrameReader, totalFrames } = use(PlaybackContext); const defaultParameterValues = useDefaultParameterValues(); @@ -108,19 +108,19 @@ const VisualizerPreview: React.FC = () => { const tokens: Record[] = []; let parameters: Record = {}; - if (totalFrames > 0 && currentFrame) { - const placeState = currentFrame.places[place.id]; - if (!placeState) { + if (totalFrames > 0 && currentFrameReader) { + const placeTokenValues = currentFrameReader.getPlaceTokenValues(place.id); + if (!placeTokenValues) { return
Place not found in frame
; } - const { offset, count } = placeState; - const placeSize = count * dimensions; - const tokenValues = Array.from( - currentFrame.buffer.slice(offset, offset + placeSize), - ); + const tokenValues = Array.from(placeTokenValues.values); - for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + for ( + let tokenIndex = 0; + tokenIndex < placeTokenValues.count; + tokenIndex++ + ) { const token: Record = {}; for (let colIndex = 0; colIndex < dimensions; colIndex++) { const dimensionName = placeType.elements[colIndex]!.name; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx index a37ada5309e..af7b67d6711 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx @@ -6,7 +6,7 @@ import { Button } from "../../../../components/button"; import { Drawer } from "../../../../components/drawer"; import { metricSchema } from "../../../../../core/schemas/metric-schema"; import { LanguageClientContext } from "../../../../../react/lsp/context"; -import { compileMetric } from "../../../../../core/simulation/compile-metric"; +import { compileMetric } from "../../../../../core/simulation/authoring/compile-metric"; import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx index 5cfc4510fbc..b1619dee622 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx @@ -7,7 +7,7 @@ import { Drawer } from "../../../../components/drawer"; import { metricSchema } from "../../../../../core/schemas/metric-schema"; import type { Metric } from "../../../../../core/types/sdcpn"; import { LanguageClientContext } from "../../../../../react/lsp/context"; -import { compileMetric } from "../../../../../core/simulation/compile-metric"; +import { compileMetric } from "../../../../../core/simulation/authoring/compile-metric"; import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { From da9c05f8855aeb946bb954cfa2e8ec631a86ecbd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 9 May 2026 00:04:08 +0200 Subject: [PATCH 03/37] Add simulation engine v2 RFC --- .../rfc/0002-simulation-engine-v2/README.md | 305 ++++++++++++++++++ .../{ => authoring}/engine/README.md | 0 .../engine/build-simulation.test.ts | 0 .../engine/build-simulation.ts | 4 +- .../check-transition-enablement.test.ts | 0 .../engine/check-transition-enablement.ts | 0 .../engine/compile-user-code.test.ts | 0 .../engine/compile-user-code.ts | 0 .../engine/compute-next-frame.test.ts | 2 +- .../engine/compute-next-frame.ts | 0 .../engine/compute-place-next-state.test.ts | 0 .../engine/compute-place-next-state.ts | 0 .../compute-possible-transition.test.ts | 0 .../engine/compute-possible-transition.ts | 4 +- .../{ => authoring}/engine/distribution.ts | 0 .../enumerate-weighted-markings.test.ts | 0 .../engine/enumerate-weighted-markings.ts | 0 .../engine/execute-transitions.test.ts | 0 .../engine/execute-transitions.ts | 2 +- ...emove-tokens-from-simulation-frame.test.ts | 0 .../remove-tokens-from-simulation-frame.ts | 0 .../{ => authoring}/engine/seeded-rng.ts | 0 .../{ => authoring}/engine/types.ts | 8 +- .../simulation/worker/simulation.worker.ts | 6 +- 24 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/README.md (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/build-simulation.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/build-simulation.ts (99%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/check-transition-enablement.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/check-transition-enablement.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compile-user-code.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compile-user-code.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compute-next-frame.test.ts (99%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compute-next-frame.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compute-place-next-state.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compute-place-next-state.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compute-possible-transition.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/compute-possible-transition.ts (99%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/distribution.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/enumerate-weighted-markings.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/enumerate-weighted-markings.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/execute-transitions.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/execute-transitions.ts (99%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/remove-tokens-from-simulation-frame.test.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/remove-tokens-from-simulation-frame.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/seeded-rng.ts (100%) rename libs/@hashintel/petrinaut/src/core/simulation/{ => authoring}/engine/types.ts (93%) diff --git a/libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md b/libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md new file mode 100644 index 00000000000..8f8a75363d9 --- /dev/null +++ b/libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md @@ -0,0 +1,305 @@ +# RFC 0002 — Petrinaut: Simulation Engine v2 + +**Status:** Draft +**Authors:** @cf +**Created:** 2026-05-08 +**Last updated:** 2026-05-08 +**Tracking issue:** FE-666 + +--- + +## Summary + +Rework Petrinaut simulation so the worker is fast, memory-efficient, stable to +talk to, and capable of running much longer simulations than the current +frame-history model allows. + +The current implementation is useful for playback, but it couples the simulator +to full-frame retention, exposes buffer layout through the public API, and makes +the worker protocol depend on streaming complete `SimulationFrame` objects. This +RFC proposes separating simulation stepping, storage, and presentation. + +## Objectives + +- Make simulation faster and reduce memory footprint. +- Keep the worker's internal simulation representation binary-first and compact. +- Avoid JSON/object wrappers inside hot simulation state. +- Stop exposing internal in-memory or storage representation through public + simulator interfaces. +- Make the communication protocol more stable and versionable. +- Allow many more frames to be computed by writing chunks to storage and keeping + only a current in-memory window. +- Keep all protocol messages JSON-communicable: no `Map`, class instances, or + non-serializable values in message shapes. +- Support stochastic simulation properly in future: deterministic seeding, + reproducible sampling, and efficient multi-run/ensemble modes. +- Allow a mode that runs every frame through a single reusable buffer when + frame history is not needed. + +## Non-Goals For This Draft + +- Final storage schema. +- Final browser persistence implementation. +- Full experiment UI design. +- Rewriting the SDCPN authoring model. +- Choosing every binary layout detail up front. + +## Current Problems + +### Full Frame History Is Hard-Coded + +The low-level `SimulationInstance` stores `frames: SimulationFrame[]`, and +`computeNextFrame` appends a new frame on every step. The public +`createSimulation` handle also stores all received frames. This duplicates +memory and makes memory use grow with simulation duration. + +This shape prevents efficient modes like: + +- compute and discard; +- keep latest frame only; +- keep a sliding playback window; +- stream aggregate chunks to storage; +- run many stochastic replications in parallel. + +### Public API Leaks Internal Layout + +`SimulationFrame` exposes place offsets, dimensions, and a shared +`Float64Array` buffer. That is an internal storage layout, but it is currently +part of the consumer contract. + +Consumers should ask for domain-level outputs: frame metadata, place token +counts, aggregate series, final marking, or a decoded token view. They should +not need to know the engine's buffer layout. + +### Static Model Data Is Repeated Per Frame + +Transition frame state currently includes the transition `instance`. Static +SDCPN model data should live outside frame payloads. Per-frame state should only +contain values that change over time. + +### Worker Protocol Is Too Playback-Shaped + +The protocol primarily sends `frame` and `frames` messages. It lacks concepts +for output mode, run identity, protocol version, aggregate chunks, storage +checkpoints, final-frame-only output, or multiple simulations running through +one coordinator. + +### `ready` Is Ambiguous + +The worker currently uses a `ready` message both for worker boot and simulation +initialization. These should be separate protocol events, preferably tied to a +run id. + +### Backpressure Is Too Low-Level + +`ack(frameNumber)` assumes frame playback. For persistence-heavy or +aggregate-only runs, the useful acknowledgement may be "chunk persisted" or +"window consumed." Backpressure should be expressed in terms of the chosen +output sink. + +### `reset()` Semantics Are Confusing + +The public `reset()` sends `stop`, clears local frames, and sets status to +`Ready`, but the worker has discarded the simulation. The API should distinguish +stop/dispose/reinitialize clearly. + +### Status And Error State Are Split + +`status` is a store, but error details and completion reason are one-shot +events. Late subscribers cannot recover the current error or completion +metadata. A single snapshot store would be easier to consume safely. + +## Proposed Direction + +### 1. Separate Engine State From Retention + +The simulator should advance from the current state to the next state without +owning a history array. + +Conceptually: + +```ts +type StepResult = { + frameNumber: number; + time: number; + completionReason: "deadlock" | "maxTime" | null; +}; + +step(engineState): StepResult; +``` + +The engine can keep current binary state internally. History retention becomes +a policy outside the hot stepping path. + +### 2. Add Explicit Output Policies + +Simulation creation should declare what outputs are produced and retained. + +Example shape: + +```ts +type SimulationOutputPolicy = + | { kind: "none" } + | { kind: "latestFrame" } + | { kind: "frameWindow"; maxFrames: number } + | { kind: "frameChunks"; chunkSize: number } + | { kind: "placeTokenCounts"; chunkSize: number } + | { kind: "placeTokenMeans"; chunkSize: number }; +``` + +This avoids pretending every consumer wants full frames forever. + +### 3. Make Protocol Messages Versioned And JSON-Communicable + +All protocol messages should be plain JSON-compatible objects plus transferable +binary payloads when needed. + +No `Map`, class instances, function values, or object graphs that depend on +prototype behavior. + +Example: + +```ts +type SimulationProtocolMessage = { + protocolVersion: 2; + runId: string; + type: string; +}; +``` + +Binary data should be carried in `ArrayBuffer`/typed-array payloads with a +documented layout. Message metadata should describe the layout rather than +requiring consumers to infer it from engine internals. + +### 4. Separate Public Domain Views From Internal Binary Layout + +The worker may use compact typed arrays internally, but public APIs should +expose stable domain-oriented views. + +Potential public surfaces: + +- `getLatestFrame()` +- `getFrameWindow()` +- `getFinalFrame()` +- `subscribeToSeries()` +- `readChunk(chunkId)` +- `getRunSnapshot()` + +These should not expose the internal memory layout used by the stepping engine. + +### 5. Support Storage Sinks + +Long simulations should be able to write chunks to storage while retaining only +a small window in memory. + +The likely browser shape: + +- IndexedDB for run metadata and chunk indexes. +- IndexedDB or OPFS for binary chunk payloads. +- Worker-owned chunk production. +- Host-owned persistence acknowledgement. + +The simulation should be able to continue after a chunk has been persisted and +acknowledged, without retaining the full history in memory. + +### 6. Prepare For Stochastic Experiments + +The v2 interface should make stochastic simulation a first-class future path. + +Needed foundations: + +- explicit base seed; +- deterministic per-run seed derivation; +- reproducible random sampling from engine state; +- output modes that aggregate across replications; +- single-buffer stepping for runs where only aggregates or final frame matter; +- enough metadata to replay or audit a run. + +This does not require implementing ensemble simulation immediately, but the +protocol and engine boundaries should not block it. + +## Sketch: Simulation v2 Handle + +```ts +type SimulationSnapshot = { + runId: string; + status: "initializing" | "ready" | "running" | "paused" | "complete" | "error"; + frameNumber: number; + time: number; + error: { message: string; itemId: string | null } | null; + completionReason: "deadlock" | "maxTime" | null; +}; + +interface SimulationV2 { + readonly snapshot: ReadableStore; + readonly events: EventStream; + + start(this: void): void; + pause(this: void): void; + stop(this: void): void; + dispose(this: void): void; + + acknowledge(this: void, ack: SimulationAck): void; +} +``` + +Open question: whether frame and chunk reads belong on the handle itself or on +a separate result store abstraction. + +## Sketch: Worker Protocol v2 + +Message families: + +- `worker.ready` +- `run.init` +- `run.ready` +- `run.start` +- `run.pause` +- `run.stop` +- `run.snapshot` +- `run.output.chunk` +- `run.output.finalFrame` +- `run.complete` +- `run.error` +- `run.ack` + +Every message should include: + +- `protocolVersion` +- `runId` +- `type` + +Output chunks should include: + +- frame range; +- time range; +- output kind; +- binary layout id; +- transferable buffers; +- chunk id for persistence acknowledgement. + +## Migration Notes + +- Keep the current playback UI working by implementing it as one output policy: + a bounded frame window or all-frames mode during the transition. +- Introduce v2 protocol alongside v1 if needed, rather than silently changing + message meanings. +- Move current full-frame history behavior behind an explicit compatibility + option. +- Treat `reset()` as a breaking API point: rename to `stop()` or make it truly + reinitialize. +- Avoid publishing internal engine types as public result types. + +## Open Questions + +- What is the default output policy for editor playback? +- Should storage be owned by the worker, the host, or an injected sink? +- Should the core package ship an IndexedDB/OPFS sink, or only define the sink + interface? +- How large should frame/chunk windows be by default? +- Do aggregate outputs use `Float32Array` or `Float64Array`? +- How do we represent places and transitions in binary layouts while preserving + stable IDs? +- Should ensemble/stochastic experiments be a separate API from single + simulation playback? +- What protocol compatibility guarantees do we want for external consumers? diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/README.md b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/README.md rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts index fa89e4a7eab..93d71a49e93 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts @@ -1,8 +1,8 @@ -import { SDCPNItemError } from "../../errors"; +import { SDCPNItemError } from "../../../errors"; import { deriveDefaultParameterValues, mergeParameterValues, -} from "../../parameter-values"; +} from "../../../parameter-values"; import { compileUserCode } from "./compile-user-code"; import type { DifferentialEquationFn, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compile-user-code.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.test.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.test.ts index 633669877f3..7467d8651e1 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { SDCPN } from "../../types/sdcpn"; +import type { SDCPN } from "../../../types/sdcpn"; import { buildSimulation } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compute-place-next-state.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts index a44d08494b4..fc99d93dc96 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts @@ -1,5 +1,5 @@ -import { SDCPNItemError } from "../../errors"; -import type { ID } from "../../types/sdcpn"; +import { SDCPNItemError } from "../../../errors"; +import type { ID } from "../../../types/sdcpn"; import { isDistribution, sampleDistribution } from "./distribution"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/distribution.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/distribution.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/distribution.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/enumerate-weighted-markings.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/enumerate-weighted-markings.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/enumerate-weighted-markings.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/enumerate-weighted-markings.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/enumerate-weighted-markings.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts index 43b77571392..ea23a7404fc 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts @@ -1,4 +1,4 @@ -import type { ID } from "../../types/sdcpn"; +import type { ID } from "../../../types/sdcpn"; import { computePossibleTransition } from "./compute-possible-transition"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; import type { SimulationFrame, SimulationInstance } from "./types"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/remove-tokens-from-simulation-frame.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/seeded-rng.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/seeded-rng.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/seeded-rng.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/seeded-rng.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts similarity index 93% rename from libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts index 85fd81908aa..b4210f40ac7 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts @@ -5,8 +5,8 @@ * part of the public simulation API. */ -import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; -import type { SimulationFrame } from "../frames/internal-frame"; +import type { Color, Place, SDCPN, Transition } from "../../../types/sdcpn"; +import type { SimulationFrame } from "../../frames/internal-frame"; import type { RuntimeDistribution } from "./distribution"; /** @@ -95,5 +95,5 @@ export type SimulationInstance = { export type { SimulationFrame, SimulationFrameState_Place, -} from "../frames/internal-frame"; -export type { SimulationFrameState_Transition } from "../api"; +} from "../../frames/internal-frame"; +export type { SimulationFrameState_Transition } from "../../api"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts index 2bb3d6f7da8..10a37026bbe 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts @@ -9,9 +9,9 @@ */ import { SDCPNItemError } from "../../errors"; -import { buildSimulation } from "../engine/build-simulation"; -import { computeNextFrame } from "../engine/compute-next-frame"; -import type { SimulationInstance } from "../engine/types"; +import { buildSimulation } from "../authoring/engine/build-simulation"; +import { computeNextFrame } from "../authoring/engine/compute-next-frame"; +import type { SimulationInstance } from "../authoring/engine/types"; import type { ToMainMessage, ToWorkerMessage } from "./messages"; // From ed6f294238e8749030d63936d84c673a5a652389 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 9 May 2026 00:40:05 +0200 Subject: [PATCH 04/37] Remove transition instances from simulation frames --- .../authoring/engine/build-simulation.test.ts | 2 + .../authoring/engine/build-simulation.ts | 1 - .../check-transition-enablement.test.ts | 830 +++++++----------- .../engine/check-transition-enablement.ts | 24 +- .../authoring/engine/compute-next-frame.ts | 5 +- .../compute-possible-transition.test.ts | 428 ++++----- .../engine/compute-possible-transition.ts | 30 +- .../engine/execute-transitions.test.ts | 635 +++++--------- .../simulation/frames/frame-reader.test.ts | 21 +- .../core/simulation/frames/internal-frame.ts | 7 +- 10 files changed, 702 insertions(+), 1281 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts index 37d15adaed0..0bf843af3be 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts @@ -250,6 +250,8 @@ describe("buildSimulation", () => { expect(Object.keys(frame.transitions).length).toBe(2); expect(frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); expect(frame.transitions.t2?.timeSinceLastFiringMs).toBe(0); + expect(frame.transitions.t1).not.toHaveProperty("instance"); + expect(simulationInstance.transitions.get("t1")?.name).toBe("Transition 1"); // Verify all compiled functions exist expect(simulationInstance.differentialEquationFns.size).toBe(3); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts index 93d71a49e93..0a8df861c52 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts @@ -234,7 +234,6 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { } transitionStates[transition.id] = { - instance: transition, timeSinceLastFiringMs: 0, firedInThisFrame: false, firingCount: 0, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts index 19848eec179..cbde691a5bd 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts @@ -1,526 +1,334 @@ import { describe, expect, it } from "vitest"; +import type { InputArc, OutputArc, Transition } from "../../../types/sdcpn"; import { checkTransitionEnablement, isTransitionStructurallyEnabled, } from "./check-transition-enablement"; import type { SimulationFrame } from "./types"; +const transitionState = { + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, +}; + +function makeTransition({ + id = "t1", + name = "Transition", + inputArcs, + outputArcs = [], +}: { + id?: string; + name?: string; + inputArcs: InputArc[]; + outputArcs?: OutputArc[]; +}): Transition { + return { + id, + name, + inputArcs, + outputArcs, + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + }; +} + +function makeTransitionMap( + transitions: Transition[], +): ReadonlyMap { + return new Map(transitions.map((transition) => [transition.id, transition])); +} + +function makeFrame({ + places, + transitions, +}: { + places: SimulationFrame["places"]; + transitions: Transition[]; +}): SimulationFrame { + return { + time: 0, + places, + transitions: Object.fromEntries( + transitions.map((transition) => [transition.id, transitionState]), + ), + buffer: new Float64Array([]), + }; +} + describe("isTransitionStructurallyEnabled", () => { it("returns true when input place has sufficient tokens", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 2, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(true); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(true); }); it("returns false when input place has insufficient tokens", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 0, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(false); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 0, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); }); it("respects arc weights when checking enablement", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 2, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 3, type: "standard" }], // Requires 3 tokens - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - // Only 2 tokens, but 3 required - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(false); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 3, type: "standard" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); }); it("checks all input places for enablement", () => { - const frame: SimulationFrame = { - time: 0, + const transition = makeTransition({ + inputArcs: [ + { placeId: "p1", weight: 1, type: "standard" }, + { placeId: "p2", weight: 1, type: "standard" }, + ], + }); + const frame = makeFrame({ places: { - p1: { - offset: 0, - count: 2, - dimensions: 0, - }, - p2: { - offset: 0, - count: 0, // No tokens - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [ - { placeId: "p1", weight: 1, type: "standard" }, - { placeId: "p2", weight: 1, type: "standard" }, - ], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, + p1: { offset: 0, count: 2, dimensions: 0 }, + p2: { offset: 0, count: 0, dimensions: 0 }, }, - buffer: new Float64Array([]), - }; - - // p1 has tokens, but p2 doesn't - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(false); + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); }); it("returns true for inhibitor arc when place has fewer tokens than weight", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 1, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - // 1 token < weight 2, so inhibitor condition is satisfied - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(true); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 1, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(true); }); it("returns false for inhibitor arc when place has enough tokens", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 3, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - // 3 tokens >= weight 2, so inhibitor condition is NOT satisfied - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(false); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 3, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); }); it("returns false for inhibitor arc when place has exactly the weight in tokens", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 2, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - // 2 tokens is NOT < weight 2, so inhibitor condition is NOT satisfied - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(false); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); }); it("returns true for inhibitor arc when place is empty", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 0, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "inhibitor" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - // 0 tokens < weight 1, inhibitor condition satisfied - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(true); + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 1, type: "inhibitor" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 0, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(true); }); it("checks mixed standard and inhibitor arcs together", () => { - const frame: SimulationFrame = { - time: 0, + const transition = makeTransition({ + inputArcs: [ + { placeId: "p1", weight: 1, type: "standard" }, + { placeId: "p2", weight: 1, type: "inhibitor" }, + ], + }); + const frame = makeFrame({ places: { - p1: { - offset: 0, - count: 2, - dimensions: 0, - }, - p2: { - offset: 0, - count: 0, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [ - { placeId: "p1", weight: 1, type: "standard" }, - { placeId: "p2", weight: 1, type: "inhibitor" }, - ], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, + p1: { offset: 0, count: 2, dimensions: 0 }, + p2: { offset: 0, count: 0, dimensions: 0 }, }, - buffer: new Float64Array([]), - }; - - // p1 has 2 >= 1 (standard satisfied), p2 has 0 < 1 (inhibitor satisfied) - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(true); + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(true); }); it("returns false when standard arc is satisfied but inhibitor arc is not", () => { - const frame: SimulationFrame = { - time: 0, + const transition = makeTransition({ + inputArcs: [ + { placeId: "p1", weight: 1, type: "standard" }, + { placeId: "p2", weight: 1, type: "inhibitor" }, + ], + }); + const frame = makeFrame({ places: { - p1: { - offset: 0, - count: 2, - dimensions: 0, - }, - p2: { - offset: 0, - count: 3, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [ - { placeId: "p1", weight: 1, type: "standard" }, - { placeId: "p2", weight: 1, type: "inhibitor" }, - ], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, + p1: { offset: 0, count: 2, dimensions: 0 }, + p2: { offset: 0, count: 3, dimensions: 0 }, }, - buffer: new Float64Array([]), - }; - - // p1 has 2 >= 1 (standard satisfied), but p2 has 3 >= 1 (inhibitor NOT satisfied) - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(false); + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); }); it("returns true for transitions with no input arcs", () => { - const frame: SimulationFrame = { - time: 0, - places: {}, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [], // No input arcs - outputArcs: [{ placeId: "p1", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(true); + const transition = makeTransition({ + inputArcs: [], + outputArcs: [{ placeId: "p1", weight: 1 }], + }); + const frame = makeFrame({ places: {}, transitions: [transition] }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(true); }); }); describe("checkTransitionEnablement", () => { it("returns hasEnabledTransition=true when at least one transition is enabled", () => { - const frame: SimulationFrame = { - time: 0, + const transitions = [ + makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }), + makeTransition({ + id: "t2", + inputArcs: [{ placeId: "p2", weight: 1, type: "standard" }], + }), + ]; + const frame = makeFrame({ places: { - p1: { - offset: 0, - count: 1, - dimensions: 0, - }, - p2: { - offset: 0, - count: 0, - dimensions: 0, - }, + p1: { offset: 0, count: 1, dimensions: 0 }, + p2: { offset: 0, count: 0, dimensions: 0 }, }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - t2: { - instance: { - id: "t2", - name: "Transition 2", - inputArcs: [{ placeId: "p2", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; + transitions, + }); - const result = checkTransitionEnablement(frame); + const result = checkTransitionEnablement( + frame, + makeTransitionMap(transitions), + ); expect(result.hasEnabledTransition).toBe(true); expect(result.transitionStatus.get("t1")).toBe(true); expect(result.transitionStatus.get("t2")).toBe(false); }); - it("returns hasEnabledTransition=false when no transitions are enabled (deadlock)", () => { - const frame: SimulationFrame = { - time: 0, + it("returns hasEnabledTransition=false when no transitions are enabled", () => { + const transitions = [ + makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }), + makeTransition({ + id: "t2", + inputArcs: [{ placeId: "p2", weight: 1, type: "standard" }], + }), + ]; + const frame = makeFrame({ places: { - p1: { - offset: 0, - count: 0, - dimensions: 0, - }, - p2: { - offset: 0, - count: 0, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - t2: { - instance: { - id: "t2", - name: "Transition 2", - inputArcs: [{ placeId: "p2", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, + p1: { offset: 0, count: 0, dimensions: 0 }, + p2: { offset: 0, count: 0, dimensions: 0 }, }, - buffer: new Float64Array([]), - }; + transitions, + }); - const result = checkTransitionEnablement(frame); + const result = checkTransitionEnablement( + frame, + makeTransitionMap(transitions), + ); expect(result.hasEnabledTransition).toBe(false); expect(result.transitionStatus.get("t1")).toBe(false); @@ -528,86 +336,38 @@ describe("checkTransitionEnablement", () => { }); it("returns hasEnabledTransition=false when there are no transitions", () => { - const frame: SimulationFrame = { - time: 0, - places: {}, - transitions: {}, - buffer: new Float64Array([]), - }; - - const result = checkTransitionEnablement(frame); - - // No transitions means nothing is blocked - but also nothing can happen - // This is technically a terminal state, but we return false because - // no transition is enabled + const frame = makeFrame({ places: {}, transitions: [] }); + + const result = checkTransitionEnablement(frame, makeTransitionMap([])); + expect(result.hasEnabledTransition).toBe(false); expect(result.transitionStatus.size).toBe(0); }); it("returns all transitions enabled when all have sufficient tokens", () => { - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 5, - dimensions: 0, - }, - }, - transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - t2: { - instance: { - id: "t2", - name: "Transition 2", - inputArcs: [{ placeId: "p1", weight: 2, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - t3: { - instance: { - id: "t3", - name: "Transition 3", - inputArcs: [{ placeId: "p1", weight: 5, type: "standard" }], - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }, - }, - buffer: new Float64Array([]), - }; - - const result = checkTransitionEnablement(frame); + const transitions = [ + makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }), + makeTransition({ + id: "t2", + inputArcs: [{ placeId: "p1", weight: 2, type: "standard" }], + }), + makeTransition({ + id: "t3", + inputArcs: [{ placeId: "p1", weight: 5, type: "standard" }], + }), + ]; + const frame = makeFrame({ + places: { p1: { offset: 0, count: 5, dimensions: 0 } }, + transitions, + }); + + const result = checkTransitionEnablement( + frame, + makeTransitionMap(transitions), + ); expect(result.hasEnabledTransition).toBe(true); expect(result.transitionStatus.get("t1")).toBe(true); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts index 94ef6532ce8..019c15d59d3 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts @@ -1,3 +1,4 @@ +import type { Transition } from "../../../types/sdcpn"; import type { SimulationFrame } from "./types"; /** @@ -26,20 +27,28 @@ export type TransitionEnablementResult = { * be structurally enabled but still not fire due to lambda returning 0 or false. * * @param frame - The current simulation frame + * @param transitions - Static transition definitions for the simulation run * @param transitionId - The ID of the transition to check * @returns true if the transition has sufficient input tokens, false otherwise */ export const isTransitionStructurallyEnabled = ( frame: SimulationFrame, + transitions: ReadonlyMap, transitionId: string, ): boolean => { - const transition = frame.transitions[transitionId]; - if (!transition) { + if (!frame.transitions[transitionId]) { throw new Error(`Transition with ID ${transitionId} not found.`); } + const transition = transitions.get(transitionId); + if (!transition) { + throw new Error( + `Transition definition for transition ${transitionId} not found.`, + ); + } + // Check if all input places have enough tokens for the required arc weights - return transition.instance.inputArcs.every((arc) => { + return transition.inputArcs.every((arc) => { const placeState = frame.places[arc.placeId]; if (!placeState) { throw new Error( @@ -71,7 +80,7 @@ export const isTransitionStructurallyEnabled = ( * * @example * ```ts - * const result = checkTransitionEnablement(currentFrame); + * const result = checkTransitionEnablement(currentFrame, simulation.transitions); * if (!result.hasEnabledTransition) { * console.log("Simulation reached a terminal state (deadlock)"); * } @@ -79,12 +88,17 @@ export const isTransitionStructurallyEnabled = ( */ export const checkTransitionEnablement = ( frame: SimulationFrame, + transitions: ReadonlyMap, ): TransitionEnablementResult => { const transitionStatus = new Map(); let hasEnabledTransition = false; for (const transitionId of Object.keys(frame.transitions)) { - const isEnabled = isTransitionStructurallyEnabled(frame, transitionId); + const isEnabled = isTransitionStructurallyEnabled( + frame, + transitions, + transitionId, + ); transitionStatus.set(transitionId, isEnabled); if (isEnabled) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts index dd4596e2a3b..d65a6475423 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts @@ -228,7 +228,10 @@ export function computeNextFrame( } // Check for deadlock if no transition fired else if (!transitionFired) { - const enablementResult = checkTransitionEnablement(finalFrame); + const enablementResult = checkTransitionEnablement( + finalFrame, + simulation.transitions, + ); if (!enablementResult.hasEnabledTransition) { completionReason = "deadlock"; } diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts index a6ed2af6b54..1f0d389da7f 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts @@ -1,346 +1,224 @@ import { describe, expect, it } from "vitest"; +import type { Color, Place, Transition } from "../../../types/sdcpn"; import { computePossibleTransition } from "./compute-possible-transition"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { + SimulationFrame, + SimulationInstance, + TransitionKernelFn, +} from "./types"; + +const type1: Color = { + id: "type1", + name: "Type1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "e1", name: "x", type: "real" }], +}; + +const transitionState = (timeSinceLastFiringMs = 1.0) => ({ + timeSinceLastFiringMs, + firedInThisFrame: false, + firingCount: 0, +}); + +function makePlace(id: string, name: string, colorId: string | null): Place { + return { + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeTransition( + transition: Pick & + Partial>, +): Transition { + return { + name: "Transition 1", + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + ...transition, + }; +} + +function makeSimulation({ + places = [], + transitions, + types = [], + lambdaFns, + transitionKernelFns, +}: { + places?: Place[]; + transitions: Transition[]; + types?: Color[]; + lambdaFns: SimulationInstance["lambdaFns"]; + transitionKernelFns: SimulationInstance["transitionKernelFns"]; +}): SimulationInstance { + return { + places: new Map(places.map((place) => [place.id, place])), + transitions: new Map( + transitions.map((transition) => [transition.id, transition]), + ), + types: new Map(types.map((type) => [type.id, type])), + differentialEquationFns: new Map(), + lambdaFns, + transitionKernelFns, + parameterValues: {}, + dt: 0.1, + maxTime: null, + rngState: 42, + frames: [], + currentFrameNumber: 0, + }; +} describe("computePossibleTransition", () => { it("returns null when transition is not enabled due to insufficient tokens", () => { - // GIVEN a frame with a place that doesn't have enough tokens - const simulation: SimulationInstance = { - places: new Map(), - transitions: new Map(), - types: new Map(), - differentialEquationFns: new Map(), + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 2, type: "standard" }], + outputArcs: [], + }); + const simulation = makeSimulation({ + transitions: [transition], lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([["t1", () => ({ p2: [{ x: 1.0 }] })]]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + transitionKernelFns: new Map([ + ["t1", () => ({ p2: [{ x: 1.0 }] })], + ]), + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 1, // Only 1 token available - dimensions: 1, - }, + p1: { offset: 0, count: 1, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 2, type: "standard" }], // Requires 2 tokens - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return [[[1.0]]];", - x: 100, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([1.0]), }; - // WHEN computing possible transition - const result = computePossibleTransition(frame, simulation, "t1", 42); - - // THEN it should return null (transition not enabled) - expect(result).toBeNull(); + expect(computePossibleTransition(frame, simulation, "t1", 42)).toBeNull(); }); - it("returns null when inhibitor arc condition is not met (place has enough tokens)", () => { - // GIVEN a frame where the inhibitor place has enough tokens to block the transition - const simulation: SimulationInstance = { - places: new Map(), - transitions: new Map(), - types: new Map(), - differentialEquationFns: new Map(), + it("returns null when inhibitor arc condition is not met", () => { + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], + outputArcs: [], + }); + const simulation = makeSimulation({ + transitions: [transition], lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([["t1", () => ({})]]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + transitionKernelFns: new Map([ + ["t1", () => ({})], + ]), + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 2, // 2 tokens present - dimensions: 0, - }, + p1: { offset: 0, count: 2, dimensions: 0 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], // Inhibitor: needs count < 2 - outputArcs: [], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([]), }; - // WHEN computing possible transition - const result = computePossibleTransition(frame, simulation, "t1", 42); - - // THEN it should return null (inhibitor condition not met: 2 is not < 2) - expect(result).toBeNull(); + expect(computePossibleTransition(frame, simulation, "t1", 42)).toBeNull(); }); it("does not consume tokens from inhibitor arc when transition fires", () => { - // GIVEN a frame with a standard arc and an inhibitor arc, both conditions met - const simulation: SimulationInstance = { - places: new Map([ - [ - "p1", - { - id: "p1", - name: "Source", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p2", - { - id: "p2", - name: "Guard", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p3", - { - id: "p3", - name: "Target", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - ]), - transitions: new Map(), - types: new Map([ - [ - "type1", - { - id: "type1", - name: "Type1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [{ elementId: "e1", name: "x", type: "real" }], - }, - ], - ]), - differentialEquationFns: new Map(), + const transition = makeTransition({ + id: "t1", + inputArcs: [ + { placeId: "p1", weight: 1, type: "standard" }, + { placeId: "p2", weight: 1, type: "inhibitor" }, + ], + outputArcs: [{ placeId: "p3", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return { Target: [{ x: 5.0 }] };", + }); + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Source", "type1"), + makePlace("p2", "Guard", null), + makePlace("p3", "Target", "type1"), + ], + transitions: [transition], + types: [type1], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([["t1", () => ({ Target: [{ x: 5.0 }] })]]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + transitionKernelFns: new Map([ + ["t1", () => ({ Target: [{ x: 5.0 }] })], + ]), + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 1, - dimensions: 1, - }, - p2: { - offset: 1, - count: 0, // Empty — inhibitor condition satisfied (0 < 1) - dimensions: 0, - }, - p3: { - offset: 1, - count: 0, - dimensions: 1, - }, + p1: { offset: 0, count: 1, dimensions: 1 }, + p2: { offset: 1, count: 0, dimensions: 0 }, + p3: { offset: 1, count: 0, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [ - { placeId: "p1", weight: 1, type: "standard" }, - { placeId: "p2", weight: 1, type: "inhibitor" }, - ], - outputArcs: [{ placeId: "p3", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return { Target: [{ x: 5.0 }] };", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([3.0]), }; - // WHEN computing possible transition const result = computePossibleTransition(frame, simulation, "t1", 42); - // THEN it should fire expect(result).not.toBeNull(); - // Standard arc's place (p1) should have tokens removed expect(result!.remove).toHaveProperty("p1"); - // Inhibitor arc's place (p2) should NOT be in the remove map expect(result!.remove).not.toHaveProperty("p2"); - // Output tokens should be added to p3 expect(result!.add).toMatchObject({ p3: [[5.0]] }); }); it("returns token combinations when transition is enabled and fires", () => { - // GIVEN a frame with sufficient tokens and favorable random conditions - const simulation: SimulationInstance = { - places: new Map([ - [ - "p1", - { - id: "p1", - name: "Place 1", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p2", - { - id: "p2", - name: "Place 2", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - ]), - transitions: new Map(), - types: new Map([ - [ - "type1", - { - id: "type1", - name: "Type1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [{ elementId: "e1", name: "x", type: "real" }], - }, - ], - ]), - differentialEquationFns: new Map(), - // Lambda function that returns a high value to ensure transition fires + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return [[[2.0]]];", + }); + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Place 1", "type1"), + makePlace("p2", "Place 2", "type1"), + ], + transitions: [transition], + types: [type1], lambdaFns: new Map([["t1", () => 10.0]]), - // Kernel function that returns new token values - transitionKernelFns: new Map([ - [ - "t1", - (_tokenValues) => { - // Return the same structure with modified values - return { "Place 2": [{ x: 2.0 }] }; - }, - ], + transitionKernelFns: new Map([ + ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 2, // 2 tokens available - dimensions: 1, - }, - p2: { - offset: 2, - count: 0, - dimensions: 1, - }, + p1: { offset: 0, count: 2, dimensions: 1 }, + p2: { offset: 2, count: 0, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], // Requires 1 token - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[2.0]]];", - x: 100, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([1.0, 1.5]), }; - // WHEN computing possible transition const result = computePossibleTransition(frame, simulation, "t1", 42); - // THEN it should return the result from the transition kernel expect(result).not.toBeNull(); expect(result).toMatchObject({ remove: { p1: new Set([0]) }, add: { p2: [[2.0]] }, }); - // Also check that newRngState is present and is a number expect(result?.newRngState).toBeTypeOf("number"); }); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts index fc99d93dc96..75a8cffa209 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts @@ -25,14 +25,20 @@ export function computePossibleTransition( add: Record; newRngState: number; } { - // Get the transition from the simulation instance - const transition = frame.transitions[transitionId]; - if (!transition) { + const transitionState = frame.transitions[transitionId]; + if (!transitionState) { throw new Error(`Transition with ID ${transitionId} not found.`); } + const transition = simulation.transitions.get(transitionId); + if (!transition) { + throw new Error( + `Transition definition for transition ${transitionId} not found.`, + ); + } + // Gather input places with their weights relative to this transition. - const inputPlaces = transition.instance.inputArcs.map((arc) => { + const inputPlaces = transition.inputArcs.map((arc) => { const placeState = frame.places[arc.placeId]; if (!placeState) { throw new Error( @@ -82,7 +88,7 @@ export function computePossibleTransition( // Generate random number using seeded RNG and update state const [U1, newRngState] = nextRandom(rngState); - const { timeSinceLastFiringMs } = transition; + const { timeSinceLastFiringMs } = transitionState; // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. // (just multiply by time since last transition) @@ -172,10 +178,10 @@ export function computePossibleTransition( ); } catch (err) { throw new SDCPNItemError( - `Error while executing lambda function for transition \`${transition.instance.name}\`:\n\n${ + `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ (err as Error).message }\n\nInput:\n${JSON.stringify(tokenCombinationValues, null, 2)}`, - transition.instance.id, + transition.id, ); } @@ -202,10 +208,10 @@ export function computePossibleTransition( ); } catch (err) { throw new SDCPNItemError( - `Error while executing transition kernel for transition \`${transition.instance.name}\`:\n\n${ + `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ (err as Error).message }\n\nInput:\n${JSON.stringify(tokenCombinationValues, null, 2)}`, - transition.instance.id, + transition.id, ); } @@ -216,7 +222,7 @@ export function computePossibleTransition( const addMap: Record = {}; let currentRngState = newRngState; - for (const outputArc of transition.instance.outputArcs) { + for (const outputArc of transition.outputArcs) { const outputPlaceState = frame.places[outputArc.placeId]; if (!outputPlaceState) { throw new Error( @@ -249,8 +255,8 @@ export function computePossibleTransition( if (!outputTokens) { throw new SDCPNItemError( - `Transition kernel for transition \`${transition.instance.name}\` did not return tokens for place "${placeName}"`, - transition.instance.id, + `Transition kernel for transition \`${transition.name}\` did not return tokens for place "${placeName}"`, + transition.id, ); } diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts index 34876fb24ac..9439a912e99 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts @@ -1,51 +1,117 @@ import { describe, expect, it } from "vitest"; +import type { Color, Place, Transition } from "../../../types/sdcpn"; import { executeTransitions } from "./execute-transitions"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { + SimulationFrame, + SimulationInstance, + TransitionKernelFn, +} from "./types"; + +const type1: Color = { + id: "type1", + name: "Type1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "e1", name: "x", type: "real" }], +}; + +const type2: Color = { + id: "type2", + name: "Type2", + iconSlug: "square", + displayColor: "#00FF00", + elements: [ + { elementId: "e1", name: "x", type: "real" }, + { elementId: "e2", name: "y", type: "real" }, + ], +}; + +const transitionState = (timeSinceLastFiringMs = 1.0) => ({ + timeSinceLastFiringMs, + firedInThisFrame: false, + firingCount: 0, +}); + +function makePlace(id: string, name: string, colorId: string | null): Place { + return { + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeTransition( + transition: Pick & + Partial>, +): Transition { + return { + name: "Transition", + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + ...transition, + }; +} + +function makeSimulation({ + places = [], + transitions, + types = [], + lambdaFns, + transitionKernelFns, +}: { + places?: Place[]; + transitions: Transition[]; + types?: Color[]; + lambdaFns: SimulationInstance["lambdaFns"]; + transitionKernelFns: SimulationInstance["transitionKernelFns"]; +}): SimulationInstance { + return { + places: new Map(places.map((place) => [place.id, place])), + transitions: new Map( + transitions.map((transition) => [transition.id, transition]), + ), + types: new Map(types.map((type) => [type.id, type])), + differentialEquationFns: new Map(), + lambdaFns, + transitionKernelFns, + parameterValues: {}, + dt: 0.1, + maxTime: null, + rngState: 42, + frames: [], + currentFrameNumber: 0, + }; +} describe("executeTransitions", () => { it("returns the original frame when no transitions can fire", () => { - const simulation: SimulationInstance = { - places: new Map(), - transitions: new Map(), - types: new Map(), - differentialEquationFns: new Map(), + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + }); + const simulation = makeSimulation({ + transitions: [transition], lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([["t1", () => ({ p2: [{ x: 1.0 }] })]]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + transitionKernelFns: new Map([ + ["t1", () => ({ p2: [{ x: 1.0 }] })], + ]), + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 0, // No tokens - dimensions: 1, - }, + p1: { offset: 0, count: 0, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return [[[1.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([]), }; @@ -62,90 +128,33 @@ describe("executeTransitions", () => { }); it("removes tokens and adds new tokens when a single transition fires", () => { - const simulation: SimulationInstance = { - places: new Map([ - [ - "p1", - { - id: "p1", - name: "Place 1", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p2", - { - id: "p2", - name: "Place 2", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - ]), - transitions: new Map(), - types: new Map([ - [ - "type1", - { - id: "type1", - name: "Type1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [{ elementId: "e1", name: "x", type: "real" }], - }, - ], - ]), - differentialEquationFns: new Map(), + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return [[[2.0]]];", + }); + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Place 1", "type1"), + makePlace("p2", "Place 2", "type1"), + ], + transitions: [transition], + types: [type1], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([ + transitionKernelFns: new Map([ ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 2, - dimensions: 1, - }, - p2: { - offset: 2, - count: 0, - dimensions: 1, - }, + p1: { offset: 0, count: 2, dimensions: 1 }, + p2: { offset: 2, count: 0, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[2.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([1.0, 1.5]), }; @@ -157,147 +166,59 @@ describe("executeTransitions", () => { simulation.rngState, ); - // Token should be removed from p1 expect(result.frame.places.p1?.count).toBe(1); - expect(result.frame.buffer[0]).toBe(1.5); // Second token from p1 remains - - // Token should be added to p2 + expect(result.frame.buffer[0]).toBe(1.5); expect(result.frame.places.p2?.count).toBe(1); - expect(result.frame.buffer[1]).toBe(2.0); // New token in p2 - - // Time should be incremented + expect(result.frame.buffer[1]).toBe(2.0); expect(result.frame.time).toBe(0.1); - - // Transition that fired should have timeSinceLastFiringMs reset to 0 expect(result.frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); expect(result.transitionFired).toBe(true); }); it("executes multiple transitions sequentially with proper token removal between each", () => { - const simulation: SimulationInstance = { - places: new Map([ - [ - "p1", - { - id: "p1", - name: "Place 1", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p2", - { - id: "p2", - name: "Place 2", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p3", - { - id: "p3", - name: "Place 3", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - ]), - transitions: new Map(), - types: new Map([ - [ - "type1", - { - id: "type1", - name: "Type1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [{ elementId: "e1", name: "x", type: "real" }], - }, - ], - ]), - differentialEquationFns: new Map(), + const transitions = [ + makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return [[[5.0]]];", + }), + makeTransition({ + id: "t2", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p3", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return [[[10.0]]];", + }), + ]; + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Place 1", "type1"), + makePlace("p2", "Place 2", "type1"), + makePlace("p3", "Place 3", "type1"), + ], + transitions, + types: [type1], lambdaFns: new Map([ ["t1", () => 10.0], ["t2", () => 10.0], ]), - transitionKernelFns: new Map< - string, - () => Record[]> - >([ + transitionKernelFns: new Map([ ["t1", () => ({ "Place 2": [{ x: 5.0 }] })], ["t2", () => ({ "Place 3": [{ x: 10.0 }] })], ]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 3, // 3 tokens in p1 - dimensions: 1, - }, - p2: { - offset: 3, - count: 0, - dimensions: 1, - }, - p3: { - offset: 3, - count: 0, - dimensions: 1, - }, + p1: { offset: 0, count: 3, dimensions: 1 }, + p2: { offset: 3, count: 0, dimensions: 1 }, + p3: { offset: 3, count: 0, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[5.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, - t2: { - instance: { - id: "t2", - name: "Transition 2", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p3", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[10.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), + t2: transitionState(), }, buffer: new Float64Array([1.0, 2.0, 3.0]), }; @@ -309,115 +230,41 @@ describe("executeTransitions", () => { simulation.rngState, ); - // Both transitions should consume one token from p1 each - // So p1 should have 1 token remaining expect(result.frame.places.p1?.count).toBe(1); - - // p2 should have 1 token added by t1 expect(result.frame.places.p2?.count).toBe(1); - - // p3 should have 1 token added by t2 expect(result.frame.places.p3?.count).toBe(1); - - // Both transitions should have their timeSinceLastFiringMs reset expect(result.frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); expect(result.frame.transitions.t2?.timeSinceLastFiringMs).toBe(0); }); it("handles transitions with multi-dimensional tokens", () => { - const simulation: SimulationInstance = { - places: new Map([ - [ - "p1", - { - id: "p1", - name: "Place 1", - colorId: "type2", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p2", - { - id: "p2", - name: "Place 2", - colorId: "type2", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - ]), - transitions: new Map(), - types: new Map([ - [ - "type2", - { - id: "type2", - name: "Type2", - iconSlug: "square", - displayColor: "#00FF00", - elements: [ - { elementId: "e1", name: "x", type: "real" }, - { elementId: "e2", name: "y", type: "real" }, - ], - }, - ], - ]), - differentialEquationFns: new Map(), + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return [[[3.0, 4.0]]];", + }); + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Place 1", "type2"), + makePlace("p2", "Place 2", "type2"), + ], + transitions: [transition], + types: [type2], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([ - [ - "t1", - (_tokens) => { - // Transform input token [1.0, 2.0] to output [3.0, 4.0] - return { "Place 2": [{ x: 3.0, y: 4.0 }] }; - }, - ], + transitionKernelFns: new Map([ + ["t1", () => ({ "Place 2": [{ x: 3.0, y: 4.0 }] })], ]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 1, - dimensions: 2, - }, - p2: { - offset: 2, - count: 0, - dimensions: 2, - }, + p1: { offset: 0, count: 1, dimensions: 2 }, + p2: { offset: 2, count: 0, dimensions: 2 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[3.0, 4.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 1.0, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(), }, buffer: new Float64Array([1.0, 2.0]), }; @@ -429,123 +276,54 @@ describe("executeTransitions", () => { simulation.rngState, ); - // p1 should have no tokens expect(result.frame.places.p1?.count).toBe(0); - - // p2 should have 1 token with values [3.0, 4.0] expect(result.frame.places.p2?.count).toBe(1); expect(result.frame.buffer[0]).toBe(3.0); expect(result.frame.buffer[1]).toBe(4.0); }); it("updates timeSinceLastFiringMs for transitions that did not fire", () => { - const simulation: SimulationInstance = { - places: new Map([ - [ - "p1", - { - id: "p1", - name: "Place 1", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - [ - "p2", - { - id: "p2", - name: "Place 2", - colorId: "type1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - ]), - transitions: new Map(), - types: new Map([ - [ - "type1", - { - id: "type1", - name: "Type1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [{ elementId: "e1", name: "x", type: "real" }], - }, - ], - ]), - differentialEquationFns: new Map(), + const transitions = [ + makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: "return 10.0;", + transitionKernelCode: "return [[[2.0]]];", + }), + makeTransition({ + id: "t2", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: "return 0.001;", + transitionKernelCode: "return [[[3.0]]];", + }), + ]; + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Place 1", "type1"), + makePlace("p2", "Place 2", "type1"), + ], + transitions, + types: [type1], lambdaFns: new Map([ - ["t1", () => 10.0], // High lambda, will fire - ["t2", () => 0.001], // Low lambda, won't fire + ["t1", () => 10.0], + ["t2", () => 0.001], ]), - transitionKernelFns: new Map< - string, - () => Record[]> - >([ + transitionKernelFns: new Map([ ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ["t2", () => ({ "Place 2": [{ x: 3.0 }] })], ]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - + }); const frame: SimulationFrame = { time: 0, places: { - p1: { - offset: 0, - count: 2, - dimensions: 1, - }, - p2: { - offset: 2, - count: 0, - dimensions: 1, - }, + p1: { offset: 0, count: 2, dimensions: 1 }, + p2: { offset: 2, count: 0, dimensions: 1 }, }, transitions: { - t1: { - instance: { - id: "t1", - name: "Transition 1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[2.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0.5, - firedInThisFrame: false, - firingCount: 0, - }, - t2: { - instance: { - id: "t2", - name: "Transition 2", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "stochastic", - lambdaCode: "return 0.001;", - transitionKernelCode: "return [[[3.0]]];", - x: 0, - y: 0, - }, - timeSinceLastFiringMs: 0.3, - firedInThisFrame: false, - firingCount: 0, - }, + t1: transitionState(0.5), + t2: transitionState(0.3), }, buffer: new Float64Array([1.0, 1.5]), }; @@ -557,10 +335,7 @@ describe("executeTransitions", () => { simulation.rngState, ); - // t1 should have fired and timeSinceLastFiringMs reset expect(result.frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); - - // t2 should not have fired and timeSinceLastFiringMs incremented by dt expect(result.frame.transitions.t2?.timeSinceLastFiringMs).toBe(0.4); }); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts index 984413e9edf..79815d0e542 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Color, Place, Transition } from "../../types/sdcpn"; +import type { Color, Place } from "../../types/sdcpn"; import { createSimulationFrameReader } from "./frame-reader"; import type { SimulationFrame } from "./internal-frame"; @@ -25,18 +25,6 @@ const place: Place = { y: 0, }; -const transition: Transition = { - id: "transition-1", - name: "Transition 1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, -}; - function makeFrame(): SimulationFrame { return { time: 0.25, @@ -44,11 +32,10 @@ function makeFrame(): SimulationFrame { [place.id]: { offset: 2, count: 2, dimensions: 2 }, }, transitions: { - [transition.id]: { + "transition-1": { timeSinceLastFiringMs: 10, firedInThisFrame: true, firingCount: 3, - instance: transition, }, }, buffer: new Float64Array([99, 99, 1, 2, 3, 4]), @@ -73,7 +60,7 @@ describe("SimulationFrameReader", () => { { x: 3, y: 4 }, ]); - const transitionState = reader.getTransitionState(transition.id); + const transitionState = reader.getTransitionState("transition-1"); expect(transitionState).toEqual({ timeSinceLastFiringMs: 10, firedInThisFrame: true, @@ -88,7 +75,7 @@ describe("SimulationFrameReader", () => { [place.id]: { tokenCount: 2 }, }, transitions: { - [transition.id]: transitionState, + "transition-1": transitionState, }, }); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts index e84c0b95f42..e26d141e7c3 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts @@ -1,4 +1,4 @@ -import type { ID, Transition } from "../../types/sdcpn"; +import type { ID } from "../../types/sdcpn"; import type { SimulationFrameState_Transition } from "../api"; /** @@ -21,10 +21,7 @@ export type SimulationFrame = { /** Place states with token buffer offsets, keyed by place ID */ places: Record; /** Transition states with firing information, keyed by transition ID */ - transitions: Record< - ID, - SimulationFrameState_Transition & { instance: Transition } - >; + transitions: Record; /** * Buffer containing all place values concatenated. * From 1e57e2bb0373a615489abbab2f2f4b9c9405345d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 9 May 2026 02:56:39 +0200 Subject: [PATCH 05/37] Clarify simulation frame boundaries --- .../src/core/simulation/ARCHITECTURE.md | 35 +++++++++++++++ .../simulation/authoring/engine/README.md | 6 +-- .../authoring/engine/build-simulation.ts | 10 ++--- .../check-transition-enablement.test.ts | 6 +-- .../engine/check-transition-enablement.ts | 6 +-- .../compute-possible-transition.test.ts | 10 ++--- .../engine/compute-possible-transition.ts | 6 +-- .../engine/execute-transitions.test.ts | 12 ++--- .../authoring/engine/execute-transitions.ts | 20 ++++----- ...emove-tokens-from-simulation-frame.test.ts | 24 +++++----- .../remove-tokens-from-simulation-frame.ts | 14 +++--- .../core/simulation/authoring/engine/types.ts | 8 ++-- .../simulation/frames/frame-reader.test.ts | 4 +- .../core/simulation/frames/frame-reader.ts | 11 ++++- .../core/simulation/frames/internal-frame.ts | 13 +++--- .../core/simulation/runtime/frame-store.ts | 44 +++++++++++++++++++ .../simulation/runtime/simulation.test.ts | 4 +- .../src/core/simulation/runtime/simulation.ts | 27 ++++-------- .../src/core/simulation/worker/README.md | 3 +- .../core/simulation/worker/frame-payload.ts | 33 ++++++++++++++ .../src/core/simulation/worker/messages.ts | 6 +-- .../simulation/worker/simulation.worker.ts | 16 +++++-- .../src/react/playback/provider.test.tsx | 24 +++++----- 23 files changed, 234 insertions(+), 108 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/ARCHITECTURE.md create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts create mode 100644 libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/ARCHITECTURE.md b/libs/@hashintel/petrinaut/src/core/simulation/ARCHITECTURE.md new file mode 100644 index 00000000000..ede27e8ca3d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/ARCHITECTURE.md @@ -0,0 +1,35 @@ +# Simulation Architecture + +The simulation module is split into four boundaries: + +- `api.ts` defines the public Core contract. Consumers receive + `SimulationFrameReader` and summary state, not engine storage objects. +- `authoring/engine/` compiles SDCPN definitions and advances internal + `EngineFrame` state. This code may use mutable/compact structures optimized + for stepping. +- `worker/` owns the transport protocol between the engine worker and runtime. + Worker messages use protocol payloads such as `SimulationFramePayload`, not + engine types directly. +- `runtime/` owns lifecycle and retention. It stores protocol payloads through a + `SimulationFrameStore` and returns `SimulationFrameReader` instances. + +Current data flow: + +```text +SDCPN snapshot + -> buildSimulation() + -> EngineFrame + -> SimulationFramePayload + -> SimulationFrameStore + -> SimulationFrameReader +``` + +`EngineFrame` is not a public API or stable storage format. It currently uses +records keyed by IDs and a shared `Float64Array` for token values. Future binary +work should happen behind the worker payload and frame-store boundaries so UI +and React consumers keep using the same reader interface. + +Retention is intentionally isolated in `runtime/frame-store.ts`. The current +store keeps every full frame in memory for compatibility. Future stores can keep +only the latest frame, a sliding window, aggregate chunks, or persisted binary +payloads without changing the public simulation handle. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md index f66551d7d40..d68dfeca379 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md @@ -28,18 +28,18 @@ computeNextFrame(simulation) ├─► Check if maxTime reached → "maxTime" completion ├─► Apply differential equations ├─► For each transition: check enablement, sample firing, execute kernel - ├─► Build new SimulationFrame + ├─► Build new EngineFrame └─► Check deadlock → "deadlock" completion ``` -## Internal SimulationFrame +## Internal EngineFrame A snapshot of simulation state at a point in time. This is the engine and worker storage layout. Public callers should read frames through `SimulationFrameReader`. ```typescript -type SimulationFrame = { +type EngineFrame = { time: number; places: Record; transitions: Record; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts index 0a8df861c52..0d6c67c5cfb 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts @@ -8,7 +8,7 @@ import type { DifferentialEquationFn, LambdaFn, ParameterValues, - SimulationFrame, + EngineFrame, SimulationInput, SimulationInstance, TransitionKernelFn, @@ -43,7 +43,7 @@ function getPlaceDimensions( * - Random seed * - Time step (dt) * - * Returns a SimulationFrame with: + * Returns an EngineFrame with: * - A SimulationInstance containing compiled user code functions * - Initial token distribution in a contiguous buffer * - All places and transitions initialized with proper state @@ -189,7 +189,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { // Calculate buffer size and build place states let bufferSize = 0; - const placeStates: SimulationFrame["places"] = {}; + const placeStates: EngineFrame["places"] = {}; // Process places in a consistent order (sorted by ID) const sortedPlaceIds = Array.from(placesMap.keys()).sort(); @@ -227,7 +227,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { } // Initialize transition states - const transitionStates: SimulationFrame["transitions"] = {}; + const transitionStates: EngineFrame["transitions"] = {}; for (const transition of sdcpn.transitions) { if (transition.id === "__proto__") { throw new Error("Cannot add transition with id '__proto__'"); @@ -257,7 +257,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { }; // Create the initial frame - const initialFrame: SimulationFrame = { + const initialFrame: EngineFrame = { time: 0, places: placeStates, transitions: transitionStates, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts index cbde691a5bd..d339d3fceea 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts @@ -5,7 +5,7 @@ import { checkTransitionEnablement, isTransitionStructurallyEnabled, } from "./check-transition-enablement"; -import type { SimulationFrame } from "./types"; +import type { EngineFrame } from "./types"; const transitionState = { timeSinceLastFiringMs: 0, @@ -47,9 +47,9 @@ function makeFrame({ places, transitions, }: { - places: SimulationFrame["places"]; + places: EngineFrame["places"]; transitions: Transition[]; -}): SimulationFrame { +}): EngineFrame { return { time: 0, places, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts index 019c15d59d3..88d14f2d278 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts @@ -1,5 +1,5 @@ import type { Transition } from "../../../types/sdcpn"; -import type { SimulationFrame } from "./types"; +import type { EngineFrame } from "./types"; /** * Result of checking transition enablement for a simulation frame. @@ -32,7 +32,7 @@ export type TransitionEnablementResult = { * @returns true if the transition has sufficient input tokens, false otherwise */ export const isTransitionStructurallyEnabled = ( - frame: SimulationFrame, + frame: EngineFrame, transitions: ReadonlyMap, transitionId: string, ): boolean => { @@ -87,7 +87,7 @@ export const isTransitionStructurallyEnabled = ( * ``` */ export const checkTransitionEnablement = ( - frame: SimulationFrame, + frame: EngineFrame, transitions: ReadonlyMap, ): TransitionEnablementResult => { const transitionStatus = new Map(); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts index 1f0d389da7f..8efc62de134 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { Color, Place, Transition } from "../../../types/sdcpn"; import { computePossibleTransition } from "./compute-possible-transition"; import type { - SimulationFrame, + EngineFrame, SimulationInstance, TransitionKernelFn, } from "./types"; @@ -94,7 +94,7 @@ describe("computePossibleTransition", () => { ["t1", () => ({ p2: [{ x: 1.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 1, dimensions: 1 }, @@ -121,7 +121,7 @@ describe("computePossibleTransition", () => { ["t1", () => ({})], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 2, dimensions: 0 }, @@ -159,7 +159,7 @@ describe("computePossibleTransition", () => { ["t1", () => ({ Target: [{ x: 5.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 1, dimensions: 1 }, @@ -200,7 +200,7 @@ describe("computePossibleTransition", () => { ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 2, dimensions: 1 }, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts index 75a8cffa209..c9db8cc98a0 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts @@ -3,12 +3,12 @@ import type { ID } from "../../../types/sdcpn"; import { isDistribution, sampleDistribution } from "./distribution"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { EngineFrame, SimulationInstance } from "./types"; type PlaceID = ID; /** - * Takes a SimulationFrame, a SimulationInstance, a TransitionID, and computes the possible transition. + * Takes an EngineFrame, a SimulationInstance, a TransitionID, and computes the possible transition. * Returns null if no transition is possible. * Returns a record with: * - removed: Map from PlaceID to Set of token indices to remove. @@ -16,7 +16,7 @@ type PlaceID = ID; * - newRngState: Updated RNG seed after consuming randomness */ export function computePossibleTransition( - frame: SimulationFrame, + frame: EngineFrame, simulation: SimulationInstance, transitionId: string, rngState: number, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts index 9439a912e99..52e09bc0cc3 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { Color, Place, Transition } from "../../../types/sdcpn"; import { executeTransitions } from "./execute-transitions"; import type { - SimulationFrame, + EngineFrame, SimulationInstance, TransitionKernelFn, } from "./types"; @@ -105,7 +105,7 @@ describe("executeTransitions", () => { ["t1", () => ({ p2: [{ x: 1.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 0, dimensions: 1 }, @@ -147,7 +147,7 @@ describe("executeTransitions", () => { ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 2, dimensions: 1 }, @@ -209,7 +209,7 @@ describe("executeTransitions", () => { ["t2", () => ({ "Place 3": [{ x: 10.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 3, dimensions: 1 }, @@ -257,7 +257,7 @@ describe("executeTransitions", () => { ["t1", () => ({ "Place 2": [{ x: 3.0, y: 4.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 1, dimensions: 2 }, @@ -315,7 +315,7 @@ describe("executeTransitions", () => { ["t2", () => ({ "Place 2": [{ x: 3.0 }] })], ]), }); - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { offset: 0, count: 2, dimensions: 1 }, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts index ea23a7404fc..92e79e35858 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts @@ -1,28 +1,28 @@ import type { ID } from "../../../types/sdcpn"; import { computePossibleTransition } from "./compute-possible-transition"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { EngineFrame, SimulationInstance } from "./types"; type PlaceID = ID; /** * Adds tokens to multiple places in the simulation frame. * - * Takes a SimulationFrame and a Map of Place IDs to arrays of token values, - * and returns a new SimulationFrame with: + * Takes an EngineFrame and a Map of Place IDs to arrays of token values, + * and returns a new EngineFrame with: * - The specified tokens added to each place's section in the buffer * - Each place's count incremented by the number of added tokens * - All subsequent places' offsets adjusted accordingly * * @param frame - The simulation frame to modify * @param tokensToAdd - Map from Place ID to array of token values to add (each token is an array of numbers) - * @returns A new SimulationFrame with the tokens added + * @returns A new EngineFrame with the tokens added * @throws Error if a place is not found or token dimensions don't match */ function addTokensToSimulationFrame( - frame: SimulationFrame, + frame: EngineFrame, tokensToAdd: Map, -): SimulationFrame { +): EngineFrame { // If no tokens to add, return frame as-is if (tokensToAdd.size === 0) { return frame; @@ -64,7 +64,7 @@ function addTokensToSimulationFrame( (a, b) => a[1].offset - b[1].offset, ); - const newPlaces: SimulationFrame["places"] = { ...frame.places }; + const newPlaces: EngineFrame["places"] = { ...frame.places }; let sourceIndex = 0; let targetIndex = 0; @@ -122,7 +122,7 @@ function addTokensToSimulationFrame( */ export type ExecuteTransitionsResult = { /** The updated simulation frame */ - frame: SimulationFrame; + frame: EngineFrame; /** The updated RNG state after all transitions */ rngState: number; /** Whether any transition fired */ @@ -146,7 +146,7 @@ export type ExecuteTransitionsResult = { * @returns Result containing the updated frame, new RNG state, and whether any transition fired */ export function executeTransitions( - frame: SimulationFrame, + frame: EngineFrame, simulation: SimulationInstance, dt: number, rngState: number, @@ -210,7 +210,7 @@ export function executeTransitions( const newFrame = addTokensToSimulationFrame(currentFrame, tokensToAdd); // Update transition timeSinceLastFiringMs, firedInThisFrame, and firingCount - const newTransitions: SimulationFrame["transitions"] = { + const newTransitions: EngineFrame["transitions"] = { ...newFrame.transitions, }; for (const [transitionId, transitionState] of Object.entries( diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts index 91e1bd192c4..a010085294e 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; -import type { SimulationFrame } from "./types"; +import type { EngineFrame } from "./types"; describe("removeTokensFromSimulationFrame", () => { it("throws error when place ID is not found", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: {}, transitions: {}, @@ -21,7 +21,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when tokens map is empty", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -40,7 +40,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("throws error when token index is out of bounds", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -59,7 +59,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when place has empty set of indices", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -82,7 +82,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes a single token from a place with 1D tokens", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -106,7 +106,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes multiple tokens from a place with 1D tokens", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -130,7 +130,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes tokens from a place with multi-dimensional tokens", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -158,7 +158,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("adjusts offsets for subsequent places after removal", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -193,7 +193,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes all tokens from a place", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -224,7 +224,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("handles removal from middle place with three places", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -268,7 +268,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes tokens from multiple places simultaneously", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts index 54bb2d0cd74..54c58b05e63 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts @@ -1,10 +1,10 @@ -import type { SimulationFrame } from "./types"; +import type { EngineFrame } from "./types"; /** * Removes tokens from multiple places in the simulation frame. * - * Takes a SimulationFrame and a Map of Place IDs to Sets of token indices to remove, - * and returns a new SimulationFrame with: + * Takes an EngineFrame and a Map of Place IDs to Sets of token indices to remove, + * and returns a new EngineFrame with: * - The specified tokens removed from each place's section in the buffer * - Each place's count decremented by the number of removed tokens * - All places' offsets adjusted accordingly @@ -13,13 +13,13 @@ import type { SimulationFrame } from "./types"; * * @param frame - The simulation frame to modify * @param tokensToRemove - Map from Place ID to Set of token indices to remove from that place - * @returns A new SimulationFrame with the tokens removed + * @returns A new EngineFrame with the tokens removed * @throws Error if a place is not found or indices are invalid */ export function removeTokensFromSimulationFrame( - frame: SimulationFrame, + frame: EngineFrame, tokensToRemove: Map | number>, -): SimulationFrame { +): EngineFrame { // If no tokens to remove, return frame as-is if (tokensToRemove.size === 0) { return frame; @@ -106,7 +106,7 @@ export function removeTokensFromSimulationFrame( (a, b) => a[1].offset - b[1].offset, ); - const newPlaces: SimulationFrame["places"] = { ...frame.places }; + const newPlaces: EngineFrame["places"] = { ...frame.places }; let cumulativeRemoved = 0; for (const [placeId, placeState] of placesByOffset) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts index b4210f40ac7..03786639d06 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts @@ -6,7 +6,7 @@ */ import type { Color, Place, SDCPN, Transition } from "../../../types/sdcpn"; -import type { SimulationFrame } from "../../frames/internal-frame"; +import type { EngineFrame } from "../../frames/internal-frame"; import type { RuntimeDistribution } from "./distribution"; /** @@ -86,14 +86,14 @@ export type SimulationInstance = { /** Current state of the seeded random number generator */ rngState: number; /** History of all computed frames */ - frames: SimulationFrame[]; + frames: EngineFrame[]; /** Index of the current frame in the frames array */ currentFrameNumber: number; }; // Re-export frame types for convenient access within simulator internals. export type { - SimulationFrame, - SimulationFrameState_Place, + EngineFrame, + EngineFramePlaceState, } from "../../frames/internal-frame"; export type { SimulationFrameState_Transition } from "../../api"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts index 79815d0e542..cfde9b514fa 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Color, Place } from "../../types/sdcpn"; import { createSimulationFrameReader } from "./frame-reader"; -import type { SimulationFrame } from "./internal-frame"; +import type { EngineFrame } from "./internal-frame"; const color: Color = { id: "color-1", @@ -25,7 +25,7 @@ const place: Place = { y: 0, }; -function makeFrame(): SimulationFrame { +function makeFrame(): EngineFrame { return { time: 0.25, places: { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts index 44aa08c9c40..1df8c801777 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts @@ -4,10 +4,17 @@ import type { SimulationFrameState_Transition, SimulationPlaceTokenValues, } from "../api"; -import type { SimulationFrame } from "./internal-frame"; +import type { EngineFramePlaceState } from "./internal-frame"; + +type SimulationFrameReaderData = { + time: number; + places: Record; + transitions: Record; + buffer: Float64Array; +}; export function createSimulationFrameReader( - frame: SimulationFrame, + frame: SimulationFrameReaderData, number: number, ): SimulationFrameReader { const getPlaceTokenCount = (placeId: string): number => diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts index e26d141e7c3..6952465c91c 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts @@ -2,24 +2,25 @@ import type { ID } from "../../types/sdcpn"; import type { SimulationFrameState_Transition } from "../api"; /** - * Internal place layout within a simulation frame. + * Internal place layout within an engine frame. */ -export type SimulationFrameState_Place = { +export type EngineFramePlaceState = { offset: number; count: number; dimensions: number; }; /** - * Internal frame storage layout exchanged with the worker. + * Internal frame storage layout used by the stepping engine. * - * Public callers should read this through `SimulationFrameReader`. + * This is not a worker protocol or public API type. Public callers should read + * engine output through `SimulationFrameReader`. */ -export type SimulationFrame = { +export type EngineFrame = { /** Simulation time at this frame */ time: number; /** Place states with token buffer offsets, keyed by place ID */ - places: Record; + places: Record; /** Transition states with firing information, keyed by transition ID */ transitions: Record; /** diff --git a/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts new file mode 100644 index 00000000000..5fe1517a0de --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts @@ -0,0 +1,44 @@ +import type { SimulationFrameReader } from "../api"; +import { createSimulationFrameReader } from "../frames/frame-reader"; +import type { SimulationFramePayload } from "../worker/frame-payload"; + +export interface SimulationFrameStore { + append(frame: SimulationFramePayload): void; + appendBatch(frames: SimulationFramePayload[]): void; + clear(): void; + count(): number; + latest(): SimulationFrameReader | null; + get(index: number): SimulationFrameReader | null; +} + +/** + * Compatibility store for the v1 worker protocol. It keeps all full frame + * payloads in memory, while hiding that retention policy from `Simulation`. + */ +export function createInMemorySimulationFrameStore(): SimulationFrameStore { + const frames: SimulationFramePayload[] = []; + + return { + append(frame) { + frames.push(frame); + }, + appendBatch(nextFrames) { + frames.push(...nextFrames); + }, + clear() { + frames.length = 0; + }, + count() { + return frames.length; + }, + latest() { + const index = frames.length - 1; + const frame = frames[index]; + return frame ? createSimulationFrameReader(frame, index) : null; + }, + get(index) { + const frame = frames[index]; + return frame ? createSimulationFrameReader(frame, index) : null; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts index 68b855164f1..b4fa2e40fc5 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { SimulationFrame } from "../frames/internal-frame"; import type { ToMainMessage, ToWorkerMessage } from "../worker/messages"; +import type { SimulationFramePayload } from "../worker/frame-payload"; import type { SDCPN } from "../../types/sdcpn"; import type { SimulationFrameSummary, SimulationTransport } from "../api"; import { createSimulation } from "./simulation"; @@ -14,7 +14,7 @@ const empty = (): SDCPN => ({ differentialEquations: [], }); -function makeFrame(time: number): SimulationFrame { +function makeFrame(time: number): SimulationFramePayload { return { time, places: {}, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts index e033d519d47..e3c155506af 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts @@ -11,8 +11,8 @@ import type { } from "../api"; import { createWorkerTransport } from "./transport"; import type { ToMainMessage } from "../worker/messages"; -import { createSimulationFrameReader } from "../frames/frame-reader"; -import type { SimulationFrame } from "../frames/internal-frame"; +import type { SimulationFramePayload } from "../worker/frame-payload"; +import { createInMemorySimulationFrameStore } from "./frame-store"; function createReadableStore(initial: T): ReadableStore & { set(next: T): void; @@ -79,25 +79,17 @@ export function createSimulation( latest: null, }); const events = createEventStream(); - const frames: SimulationFrame[] = []; + const frameStore = createInMemorySimulationFrameStore(); let disposed = false; - function pushFrames(newFrames: SimulationFrame[]): void { + function pushFrames(newFrames: SimulationFramePayload[]): void { if (newFrames.length === 0) { return; } - for (const frame of newFrames) { - frames.push(frame); - } + frameStore.appendBatch(newFrames); frameSummary.set({ - count: frames.length, - latest: - frames.length > 0 - ? createSimulationFrameReader( - frames[frames.length - 1]!, - frames.length - 1, - ) - : null, + count: frameStore.count(), + latest: frameStore.latest(), }); } @@ -190,7 +182,7 @@ export function createSimulation( return; } transport.send({ type: "stop" }); - frames.length = 0; + frameStore.clear(); frameSummary.set({ count: 0, latest: null }); status.set("Ready"); }, @@ -211,8 +203,7 @@ export function createSimulation( }); }, getFrame(index) { - const frame = frames[index]; - return frame ? createSimulationFrameReader(frame, index) : null; + return frameStore.get(index); }, dispose() { if (disposed) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md b/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md index 9143d69c272..d0455feecff 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md @@ -26,7 +26,8 @@ computation. | Type | Payload | Description | | ---------- | -------------------------------------------------- | ----------------------- | | `ready` | `{ initialFrameCount }` | Initialization complete | -| `frames` | `{ frames: SimulationFrame[] }` | Batch of frames | +| `frame` | `{ frame: SimulationFramePayload }` | Single frame payload | +| `frames` | `{ frames: SimulationFramePayload[] }` | Batch of frame payloads | | `complete` | `{ reason: 'deadlock' \| 'maxTime', frameNumber }` | Simulation ended | | `paused` | `{ frameNumber }` | Worker has paused | | `error` | `{ message, itemId: string \| null }` | Error occurred | diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts new file mode 100644 index 00000000000..ef6332ca935 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts @@ -0,0 +1,33 @@ +import type { ID } from "../../types/sdcpn"; +import type { SimulationFrameState_Transition } from "../api"; +import type { EngineFrame } from "../frames/internal-frame"; + +/** + * Worker protocol representation for a full frame payload. + * + * This is intentionally separate from `EngineFrame`: the current v1 payload is + * structurally similar, but the worker protocol is the compatibility boundary. + */ +export type SimulationFramePayloadPlaceState = { + offset: number; + count: number; + dimensions: number; +}; + +export type SimulationFramePayload = { + time: number; + places: Record; + transitions: Record; + buffer: Float64Array; +}; + +export function framePayloadFromEngineFrame( + frame: EngineFrame, +): SimulationFramePayload { + return { + time: frame.time, + places: frame.places, + transitions: frame.transitions, + buffer: frame.buffer, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts index 7fd908a6264..bedcdbe2566 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts @@ -5,7 +5,7 @@ */ import type { SDCPN } from "../../types/sdcpn"; -import type { SimulationFrame } from "../frames/internal-frame"; +import type { SimulationFramePayload } from "./frame-payload"; // // Main Thread → Worker Messages @@ -106,7 +106,7 @@ export type ReadyMessage = { */ export type FrameMessage = { type: "frame"; - frame: SimulationFrame; + frame: SimulationFramePayload; }; /** @@ -114,7 +114,7 @@ export type FrameMessage = { */ export type FramesMessage = { type: "frames"; - frames: SimulationFrame[]; + frames: SimulationFramePayload[]; }; /** diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts index 10a37026bbe..f5caafcf93e 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts @@ -12,6 +12,7 @@ import { SDCPNItemError } from "../../errors"; import { buildSimulation } from "../authoring/engine/build-simulation"; import { computeNextFrame } from "../authoring/engine/compute-next-frame"; import type { SimulationInstance } from "../authoring/engine/types"; +import { framePayloadFromEngineFrame } from "./frame-payload"; import type { ToMainMessage, ToWorkerMessage } from "./messages"; // @@ -121,9 +122,15 @@ async function computeLoop(): Promise { // Send computed frames if (framesToSend.length > 0) { if (framesToSend.length === 1) { - postTypedMessage({ type: "frame", frame: framesToSend[0]! }); + postTypedMessage({ + type: "frame", + frame: framePayloadFromEngineFrame(framesToSend[0]!), + }); } else { - postTypedMessage({ type: "frames", frames: framesToSend }); + postTypedMessage({ + type: "frames", + frames: framesToSend.map(framePayloadFromEngineFrame), + }); } } @@ -168,7 +175,10 @@ self.onmessage = (event: MessageEvent) => { // Send initial frame const initialFrame = simulation.frames[0]; if (initialFrame) { - postTypedMessage({ type: "frame", frame: initialFrame }); + postTypedMessage({ + type: "frame", + frame: framePayloadFromEngineFrame(initialFrame), + }); } postTypedMessage({ diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx index 55291ea76e7..23c257ae80c 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx @@ -10,8 +10,6 @@ import { type SimulationContextValue, type SimulationFrameReader, } from "../simulation/context"; -import { createSimulationFrameReader } from "../../core/simulation/frames/frame-reader"; -import type { SimulationFrame } from "../../core/simulation/frames/internal-frame"; import { PlaybackContext, type PlaybackContextValue } from "./context"; import { PlaybackProvider } from "./provider"; @@ -21,15 +19,21 @@ import { PlaybackProvider } from "./provider"; type MockSimulationContextOverrides = Partial; -/** - * Creates a minimal SimulationFrame for testing. - */ -function createMockFrame(time: number): SimulationFrame { +function createMockFrameReader(number: number): SimulationFrameReader { + const time = number * 0.01; return { + number, time, - places: {}, - transitions: {}, - buffer: new Float64Array(), + getPlaceTokenCount: () => 0, + getPlaceTokenValues: () => null, + getPlaceTokens: () => [], + getTransitionState: () => null, + toFrameState: () => ({ + number, + time, + places: {}, + transitions: {}, + }), }; } @@ -39,7 +43,7 @@ function createMockFrame(time: number): SimulationFrame { function createMockFrameReaders(frameCount: number): SimulationFrameReader[] { const frames: SimulationFrameReader[] = []; for (let i = 0; i < frameCount; i++) { - frames.push(createSimulationFrameReader(createMockFrame(i * 0.01), i)); + frames.push(createMockFrameReader(i)); } return frames; } From 88feab233d095056c8c739e1f230f0b710477142 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 10 May 2026 01:26:56 +0200 Subject: [PATCH 06/37] Split simulation timeline view --- .../subviews/simulation-timeline/chart.tsx | 579 ++++++++++++++++++ .../simulation-timeline/default-colors.ts | 10 + .../subviews/simulation-timeline/header.tsx | 174 ++++++ .../subviews/simulation-timeline/index.ts | 1 + .../subviews/simulation-timeline/legend.tsx | 49 ++ .../subviews/simulation-timeline/main.tsx | 80 +++ .../subviews/simulation-timeline/styles.ts | 75 +++ .../subviews/simulation-timeline/types.ts | 21 + .../simulation-timeline/use-streaming-data.ts | 449 ++++++++++++++ 9 files changed, 1438 insertions(+) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/chart.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/default-colors.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/index.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/legend.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/styles.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/types.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/use-streaming-data.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/chart.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/chart.tsx new file mode 100644 index 00000000000..88ffd4a9990 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/chart.tsx @@ -0,0 +1,579 @@ +import { use, useEffect, useMemo, useRef } from "react"; +import type { FC, RefObject } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; + +import { useElementSize } from "../../../../../../../react/hooks/use-element-size"; +import { useLatest } from "../../../../../../../react/hooks/use-latest"; +import { useStableCallback } from "../../../../../../../react/hooks/use-stable-callback"; +import { PlaybackContext } from "../../../../../../../react/playback/context"; +import type { TimelineChartType } from "../../../../../../../react/state/editor-context"; +import { + tooltipDotStyle, + tooltipLabelStyle, + tooltipStyle, + tooltipValueStyle, +} from "./styles"; +import type { StreamingStore, TimelineSeriesMeta } from "./types"; + +function buildRunData( + store: StreamingStore, + hiddenSeries: Set, + length = store.length, +): uPlot.AlignedData { + const result: (number | null | undefined)[][] = [store.columns[0]!]; + for (let i = 0; i < store.series.length; i++) { + if (hiddenSeries.has(store.series[i]!.seriesId)) { + result.push(new Array(length).fill(null)); + } else { + result.push(store.columns[i + 1]!); + } + } + return result as uPlot.AlignedData; +} + +function buildStackedData( + store: StreamingStore, + hiddenSeries: Set, + length = store.length, +): uPlot.AlignedData { + const visible = store.series + .map((p, i) => ({ ...p, colIdx: i + 1 })) + .filter((p) => !hiddenSeries.has(p.seriesId)); + + const cumulative = new Float64Array(length); + const series: number[][] = []; + + for (const p of visible) { + const col = store.columns[p.colIdx]!; + const stacked = new Array(length); + for (let i = 0; i < length; i++) { + cumulative[i]! += col[i] ?? 0; + stacked[i] = cumulative[i]!; + } + series.push(stacked); + } + + series.reverse(); + + return [store.columns[0]!, ...series] as uPlot.AlignedData; +} + +interface TooltipNodes { + root: HTMLDivElement; + dot: HTMLDivElement; + name: HTMLSpanElement; + value: HTMLSpanElement; + time: HTMLDivElement; + frame: HTMLDivElement; +} + +function createTooltip(): TooltipNodes { + const root = document.createElement("div"); + root.className = tooltipStyle; + + const label = document.createElement("div"); + label.className = tooltipLabelStyle; + + const dot = document.createElement("div"); + dot.className = tooltipDotStyle; + + const name = document.createElement("span"); + + const value = document.createElement("span"); + value.className = tooltipValueStyle; + + label.append(dot, name, value); + + const time = document.createElement("div"); + time.style.cssText = "font-size:10px;opacity:0.8;margin-top:2px"; + + const frame = document.createElement("div"); + frame.style.cssText = "font-size:9px;opacity:0.6;margin-top:2px"; + + root.append(label, time, frame); + + return { root, dot, name, value, time, frame }; +} + +function hitTestStackedBand( + store: StreamingStore, + hiddenSeries: Set, + idx: number, + yVal: number, +): { seriesIdx: number; value: number } | null { + if (yVal < 0) { + return null; + } + let cumul = 0; + for (let i = 0; i < store.series.length; i++) { + if (hiddenSeries.has(store.series[i]!.seriesId)) { + continue; + } + const v = store.columns[i + 1]![idx] ?? 0; + cumul += v; + if (yVal <= cumul) { + return { seriesIdx: i, value: v }; + } + } + return null; +} + +interface HoverHit { + series: TimelineSeriesMeta; + value: number; + idx: number; + time: number; +} + +function resolveHoverTarget( + u: uPlot, + store: StreamingStore, + chartType: TimelineChartType, + hiddenSeries: Set, + focusedSeriesIdx: number, +): HoverHit | null { + const idx = u.cursor.idx; + if (idx == null || idx < 0 || store.length === 0) { + return null; + } + + let seriesIdx: number; + let value: number; + + if (chartType === "stacked") { + const top = u.cursor.top; + if (top == null || top < 0) { + return null; + } + const hit = hitTestStackedBand( + store, + hiddenSeries, + idx, + u.posToVal(top, "y"), + ); + if (!hit) { + return null; + } + seriesIdx = hit.seriesIdx; + value = hit.value; + } else { + if (focusedSeriesIdx < 1) { + return null; + } + seriesIdx = focusedSeriesIdx - 1; + if (hiddenSeries.has(store.series[seriesIdx]?.seriesId ?? "")) { + return null; + } + value = store.columns[focusedSeriesIdx]?.[idx] ?? 0; + } + + const series = store.series[seriesIdx]; + if (!series) { + return null; + } + + return { series, value, idx, time: store.columns[0]![idx] ?? 0 }; +} + +function positionTooltip(tooltip: TooltipNodes, u: uPlot, hit: HoverHit): void { + const t = tooltip; + t.dot.style.background = hit.series.color; + t.name.textContent = hit.series.seriesName; + t.value.textContent = String(hit.value); + t.time.textContent = `${hit.time.toFixed(3)}s`; + t.frame.textContent = `Frame ${hit.idx}`; + + t.root.style.display = "block"; + const cx = u.cursor.left ?? 0; + const cy = u.cursor.top ?? 0; + const ow = u.over.clientWidth; + const oh = u.over.clientHeight; + const tw = t.root.offsetWidth; + const th = t.root.offsetHeight; + const margin = 10; + + let left = cx - tw / 2; + if (left < 0) { + left = 0; + } else if (left + tw > ow) { + left = ow - tw; + } + + let top = cy - th - margin; + if (top < 0) { + top = Math.min(cy + margin, oh - th); + } + + t.root.style.left = `${left}px`; + t.root.style.top = `${top}px`; +} + +function drawPlayhead(u: uPlot, frameIdx: number): void { + const times = u.data[0]!; + if (times.length === 0) { + return; + } + + const dpr = devicePixelRatio; + const time = times[Math.min(frameIdx, times.length - 1)]!; + const cx = u.valToPos(time, "x", true); + const plotTop = u.bbox.top; + const plotHeight = u.bbox.height; + const ctx = u.ctx; + + const headW = 12 * dpr; + const rectH = 6 * dpr; + const tipH = 6 * dpr; + const radius = 3 * dpr; + const tipY = plotTop; + const baseY = tipY - tipH; + const topY = baseY - rectH; + const leftX = cx - headW / 2; + const rightX = cx + headW / 2; + + ctx.save(); + + ctx.fillStyle = "#1e293b"; + ctx.beginPath(); + ctx.moveTo(leftX, topY + radius); + ctx.arcTo(leftX, topY, leftX + radius, topY, radius); + ctx.lineTo(rightX - radius, topY); + ctx.arcTo(rightX, topY, rightX, topY + radius, radius); + ctx.lineTo(rightX, baseY); + ctx.lineTo(cx, tipY); + ctx.lineTo(leftX, baseY); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1 * dpr; + ctx.stroke(); + + ctx.strokeStyle = "#1e293b"; + ctx.lineWidth = 1.5 * dpr; + ctx.beginPath(); + ctx.moveTo(cx, tipY - 4 * dpr); + ctx.lineTo(cx, tipY + plotHeight); + ctx.stroke(); + + ctx.restore(); +} + +interface ChartOptions { + store: StreamingStore; + storeRef: RefObject; + chartType: TimelineChartType; + hiddenSeries: Set; + size: { width: number; height: number }; + onScrub: (frameIndex: number) => void; + getPlayheadFrame: () => number; + tooltip: TooltipNodes; +} + +function buildUPlotOptions(opts: ChartOptions): uPlot.Options { + const { + store, + storeRef, + chartType, + hiddenSeries, + size, + onScrub, + getPlayheadFrame, + tooltip: t, + } = opts; + + let focused = -1; + + const updateTooltip = (u: uPlot) => { + const hit = resolveHoverTarget( + u, + storeRef.current, + chartType, + hiddenSeries, + focused, + ); + if (!hit) { + t.root.style.display = "none"; + return; + } + positionTooltip(t, u, hit); + }; + + const series: uPlot.Series[] = [{ label: "Time" }]; + let bands: uPlot.Band[] | undefined; + + if (chartType === "stacked") { + const visible = store.series + .filter((p) => !hiddenSeries.has(p.seriesId)) + .reverse(); + for (const p of visible) { + series.push({ + label: p.seriesName, + stroke: p.color, + fill: `color-mix(in srgb, ${p.color} 53%, transparent)`, + width: 2, + }); + } + if (visible.length > 1) { + bands = []; + for (let i = 1; i < visible.length; i++) { + bands.push({ series: [i, i + 1] as [number, number] }); + } + } + } else { + for (const p of store.series) { + series.push({ + label: p.seriesName, + stroke: p.color, + width: 2, + show: !hiddenSeries.has(p.seriesId), + }); + } + } + + return { + width: size.width, + height: size.height, + series, + bands, + pxAlign: false, + padding: [0, 8, 4, null], + cursor: { + lock: false, + drag: { x: false, y: false, setScale: false }, + focus: { prox: 16 }, + bind: { + mousedown: (u, _targ, handler) => (e: MouseEvent) => { + handler(e); + if (u.cursor.left != null && u.cursor.left >= 0) { + onScrub(u.posToIdx(u.cursor.left)); + } + return null; + }, + mousemove: (u, _targ, handler) => (e: MouseEvent) => { + handler(e); + if (e.buttons === 1 && u.cursor.left != null && u.cursor.left >= 0) { + onScrub(u.posToIdx(u.cursor.left)); + } + return null; + }, + }, + }, + legend: { show: false }, + focus: { alpha: chartType === "stacked" ? 1 : 0.3 }, + axes: [ + { + show: true, + side: 0, + size: 26, + font: "10px system-ui", + stroke: "#475569", + grid: { stroke: "#f3f4f6", width: 1 }, + ticks: { stroke: "#cbd5e1", width: 1, size: 6 }, + values: (_u, vals) => vals.map((v) => `${v}s`), + }, + { + show: true, + size: 54, + font: "10px system-ui", + stroke: "#999", + grid: { stroke: "#f3f4f6", width: 1, dash: [4, 4] }, + ticks: { stroke: "#e5e7eb", width: 1 }, + }, + ], + scales: { + x: { time: false, range: (_u, min, max) => [min, max] }, + y: { + auto: true, + range: (_u, min, max) => [Math.min(0, min), Math.max(1, max * 1.05)], + }, + }, + hooks: { + drawClear: [ + (u) => { + const { ctx } = u; + const { left: bx, width: bw, top: by } = u.bbox; + const dpr = devicePixelRatio; + ctx.save(); + ctx.strokeStyle = "#cbd5e1"; + ctx.lineWidth = dpr; + ctx.beginPath(); + ctx.moveTo(bx, by - 0.5 * dpr); + ctx.lineTo(bx + bw, by - 0.5 * dpr); + ctx.stroke(); + ctx.restore(); + }, + ], + setSeries: [ + (u, sIdx) => { + focused = sIdx ?? -1; + updateTooltip(u); + }, + ], + setCursor: [(u) => updateTooltip(u)], + draw: [(u) => drawPlayhead(u, getPlayheadFrame())], + }, + }; +} + +function attachRulerScrubbing( + u: uPlot, + onScrub: (frameIndex: number) => void, +): () => void { + let dragging = false; + let overRect: DOMRect | null = null; + + const onDown = (e: PointerEvent) => { + overRect = u.over.getBoundingClientRect(); + if (e.clientY >= overRect.top) { + return; + } + if (e.clientX < overRect.left || e.clientX > overRect.right) { + return; + } + dragging = true; + u.root.setPointerCapture(e.pointerId); + const x = Math.max(0, Math.min(e.clientX - overRect.left, overRect.width)); + onScrub(u.posToIdx(x)); + }; + + const onMove = (e: PointerEvent) => { + if (dragging && overRect) { + const x = Math.max( + 0, + Math.min(e.clientX - overRect.left, overRect.width), + ); + onScrub(u.posToIdx(x)); + } + }; + + const onUp = (e: PointerEvent) => { + if (dragging) { + dragging = false; + u.root.releasePointerCapture(e.pointerId); + } + }; + + u.root.addEventListener("pointerdown", onDown); + u.root.addEventListener("pointermove", onMove); + u.root.addEventListener("pointerup", onUp); + u.root.addEventListener("pointercancel", onUp); + + return () => { + u.root.removeEventListener("pointerdown", onDown); + u.root.removeEventListener("pointermove", onMove); + u.root.removeEventListener("pointerup", onUp); + u.root.removeEventListener("pointercancel", onUp); + }; +} + +export const UPlotChart: FC<{ + store: StreamingStore; + chartType: TimelineChartType; + hiddenSeries: Set; + totalFrames: number; + currentFrameIndex: number; + className?: string; +}> = ({ + store, + chartType, + hiddenSeries, + totalFrames, + currentFrameIndex, + className, +}) => { + "use no memo"; + + const { setCurrentViewedFrame } = use(PlaybackContext); + const wrapperRef = useRef(null); + const chartRef = useRef(null); + const playheadFrameRef = useRef(currentFrameIndex); + const storeRef = useLatest(store); + + const size = useElementSize(wrapperRef); + const hasSize = size != null; + const dataLength = store.length; + + const onScrub = useStableCallback((idx: number) => { + setCurrentViewedFrame(Math.max(0, Math.min(idx, totalFrames - 1))); + }); + + const data = useMemo( + () => + chartType === "stacked" + ? buildStackedData(store, hiddenSeries, dataLength) + : buildRunData(store, hiddenSeries, dataLength), + [store, dataLength, chartType, hiddenSeries], + ); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper || !hasSize) { + return; + } + + const initialSize = { + width: wrapper.clientWidth, + height: wrapper.clientHeight, + }; + + const initialData = + chartType === "stacked" + ? buildStackedData(store, hiddenSeries) + : buildRunData(store, hiddenSeries); + + const tooltip = createTooltip(); + + const opts = buildUPlotOptions({ + store, + storeRef, + chartType, + hiddenSeries, + size: initialSize, + onScrub, + getPlayheadFrame: () => playheadFrameRef.current, + tooltip, + }); + + chartRef.current?.destroy(); + + // eslint-disable-next-line new-cap -- uPlot's constructor is lowercase by convention + const u = new uPlot(opts, initialData, wrapper); + chartRef.current = u; + + u.over.appendChild(tooltip.root); + + const cleanupRuler = attachRulerScrubbing(u, onScrub); + + return () => { + cleanupRuler(); + u.destroy(); + chartRef.current = null; + }; + }, [ + chartType, + hiddenSeries, + store, + store.series.length, + storeRef, + hasSize, + onScrub, + ]); + + useEffect(() => { + if (chartRef.current && size && size.width > 0 && size.height > 0) { + chartRef.current.setSize(size); + } + }, [size]); + + useEffect(() => { + chartRef.current?.setData(data); + }, [data]); + + useEffect(() => { + playheadFrameRef.current = currentFrameIndex; + chartRef.current?.redraw(false, false); + }, [currentFrameIndex]); + + return
; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/default-colors.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/default-colors.ts new file mode 100644 index 00000000000..5ee7a27c8a5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/default-colors.ts @@ -0,0 +1,10 @@ +export const DEFAULT_COLORS = [ + "#3b82f6", // blue + "#ef4444", // red + "#22c55e", // green + "#f59e0b", // amber + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#84cc16", // lime +]; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx new file mode 100644 index 00000000000..c9e7d09e6b8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx @@ -0,0 +1,174 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { use, useState } from "react"; +import { IconButton } from "../../../../../../components/icon-button"; +import { SegmentGroup } from "../../../../../../components/segment-group"; +import { Select } from "../../../../../../components/select"; +import { TbList, TbPencil, TbPlus } from "react-icons/tb"; + +import { CreateMetricDrawer } from "../../../SimulateView/create-metric-drawer"; +import { ViewMetricDrawer } from "../../../SimulateView/view-metric-drawer"; +import { + EditorContext, + type TimelineChartType, + type TimelineView, +} from "../../../../../../../react/state/editor-context"; +import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; + +const CHART_TYPE_OPTIONS = [ + { value: "run", label: "Run" }, + { value: "stacked", label: "Stacked" }, +]; + +const headerActionsStyle = css({ + display: "flex", + alignItems: "center", + gap: "[8px]", +}); + +const metricPickerLabelStyle = css({ + fontSize: "[10px]", + fontWeight: "semibold", + textTransform: "uppercase", + color: "neutral.a100", + letterSpacing: "[0.5px]", + flexShrink: 0, +}); + +const metricPickerWrapperStyle = css({ + width: "[200px]", +}); + +// Sentinel values for the native views in the picker. Metric ids are UUIDs +// (or `metric__*` in examples) so these cannot collide. +const PER_PLACE_VALUE = "__per_place__"; +const PER_TYPE_VALUE = "__per_type__"; +const PER_TRANSITION_VALUE = "__per_transition__"; + +function viewToSelectValue(view: TimelineView): string { + switch (view.kind) { + case "per-place": + return PER_PLACE_VALUE; + case "per-type": + return PER_TYPE_VALUE; + case "per-transition": + return PER_TRANSITION_VALUE; + case "metric": + return view.metricId; + } +} + +function selectValueToView(value: string): TimelineView { + if (value === PER_PLACE_VALUE) { + return { kind: "per-place" }; + } + if (value === PER_TYPE_VALUE) { + return { kind: "per-type" }; + } + if (value === PER_TRANSITION_VALUE) { + return { kind: "per-transition" }; + } + return { kind: "metric", metricId: value }; +} + +const TimelineChartTypeSelector: React.FC = () => { + const { timelineChartType: chartType, setTimelineChartType: setChartType } = + use(EditorContext); + + return ( + setChartType(value as TimelineChartType)} + size="sm" + /> + ); +}; + +const TimelineViewPicker: React.FC = () => { + const { timelineView, setTimelineView, setGlobalMode, setSimulateViewMode } = + use(EditorContext); + const { + petriNetDefinition: { metrics = [] }, + } = use(SDCPNContext); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isViewOpen, setIsViewOpen] = useState(false); + + const selectedMetric = + timelineView.kind === "metric" + ? metrics.find((m) => m.id === timelineView.metricId) + : undefined; + + const options = [ + { value: PER_PLACE_VALUE, label: "Tokens per place" }, + { value: PER_TYPE_VALUE, label: "Tokens per type" }, + { value: PER_TRANSITION_VALUE, label: "Transition firings" }, + ...metrics.map((m) => ({ value: m.id, label: m.name })), + ]; + + return ( + <> + Metric +
+ setName(event.currentTarget.value)} + /> +
+
+
+ Runs + setRunCount(event.currentTarget.value)} + /> +
+
+ Seed + setSeed(event.currentTarget.value)} + /> +
+
+ Time step + setDt(event.currentTarget.value)} + /> +
+
+ Max time + setMaxTime(event.currentTarget.value)} + /> +
+
+ +
- - {/* -- Predicates ------------------------------------------- */} -
-
- setPredicates(v ?? "")} - height="120px" - /> -
-
- diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiment-timeline.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiment-timeline.tsx new file mode 100644 index 00000000000..29234753930 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiment-timeline.tsx @@ -0,0 +1,314 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { use, useEffect, useRef } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; + +import type { PlaceTokenCountDistributionBin } from "../../../../../core/simulation"; +import { useElementSize } from "../../../../../react/hooks/use-element-size"; +import type { ExperimentRecord } from "../../../../../react/experiments/context"; +import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; +import { Select } from "../../../../components/select"; + +const UPlot = uPlot; + +const rootStyle = css({ + display: "flex", + flexDirection: "column", + gap: "3", + width: "full", + minHeight: "[0]", +}); + +const headerStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", +}); + +const labelStyle = css({ + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s100", +}); + +const selectStyle = css({ + width: "[220px]", +}); + +const chartStyle = css({ + height: "[320px]", + minHeight: "[320px]", + width: "full", + minWidth: "[0]", +}); + +const legendStyle = css({ + display: "flex", + flexWrap: "wrap", + gap: "3", + rowGap: "2", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s100", +}); + +const legendItemStyle = css({ + display: "flex", + alignItems: "center", + gap: "1.5", +}); + +const legendSwatchStyle = css({ + width: "[18px]", + height: "[0]", + borderTopWidth: "[2px]", + borderTopStyle: "solid", +}); + +const emptyStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "[220px]", + fontSize: "sm", + color: "neutral.s80", +}); + +function percentileFromBins( + bins: readonly PlaceTokenCountDistributionBin[], + sampleCount: number, + percentile: number, +): number | null { + if (sampleCount === 0) { + return null; + } + + const targetRank = Math.ceil(sampleCount * percentile); + let cumulative = 0; + + for (const [tokenCount, frequency] of bins) { + cumulative += frequency; + if (cumulative >= targetRank) { + return tokenCount; + } + } + + return null; +} + +function meanFromBins( + bins: readonly PlaceTokenCountDistributionBin[], + sampleCount: number, +): number | null { + if (sampleCount === 0) { + return null; + } + + return ( + bins.reduce( + (sum, [tokenCount, frequency]) => sum + tokenCount * frequency, + 0, + ) / sampleCount + ); +} + +function buildTimelineData( + experiment: ExperimentRecord, + placeId: string, +): uPlot.AlignedData { + const time: number[] = []; + const median: (number | null)[] = []; + const mean: (number | null)[] = []; + const p10: (number | null)[] = []; + const p90: (number | null)[] = []; + + for (const frame of experiment.distributionFrames) { + const place = frame.places.find( + (candidate) => candidate.placeId === placeId, + ); + if (!place) { + continue; + } + + time.push(frame.time); + median.push(percentileFromBins(place.bins, place.sampleCount, 0.5)); + mean.push(meanFromBins(place.bins, place.sampleCount)); + p10.push(percentileFromBins(place.bins, place.sampleCount, 0.1)); + p90.push(percentileFromBins(place.bins, place.sampleCount, 0.9)); + } + + return [time, median, mean, p10, p90] as uPlot.AlignedData; +} + +function createEmptyTimelineData(): uPlot.AlignedData { + return [[], [], [], [], []] as uPlot.AlignedData; +} + +function chartOptions(width: number, height: number): uPlot.Options { + return { + width, + height, + pxAlign: false, + padding: [0, 8, 4, null], + cursor: { + drag: { x: false, y: false, setScale: false }, + }, + legend: { + show: false, + }, + scales: { + x: { time: false }, + y: { range: (_u, min, max) => [Math.min(0, min), Math.max(1, max)] }, + }, + axes: [ + { + show: true, + side: 0, + size: 26, + font: "10px system-ui", + stroke: "#475569", + grid: { stroke: "#f3f4f6", width: 1 }, + ticks: { stroke: "#cbd5e1", width: 1, size: 6 }, + values: (_u, vals) => vals.map((v) => `${v}s`), + }, + { + show: true, + size: 54, + font: "10px system-ui", + stroke: "#999", + grid: { stroke: "#f3f4f6", width: 1, dash: [4, 4] }, + ticks: { stroke: "#e5e7eb", width: 1 }, + }, + ], + series: [ + {}, + { + label: "median", + stroke: "#111827", + width: 2, + }, + { + label: "mean", + stroke: "#d97706", + width: 2, + dash: [8, 6], + }, + { + label: "p10", + stroke: "#94a3b8", + width: 1, + }, + { + label: "p90", + stroke: "#94a3b8", + width: 1, + }, + ], + }; +} + +const legendItems = [ + { label: "median", color: "#111827", dash: "solid" }, + { label: "mean", color: "#d97706", dash: "dashed" }, + { label: "p10", color: "#94a3b8", dash: "solid" }, + { label: "p90", color: "#94a3b8", dash: "solid" }, +] as const; + +export const ExperimentTimeline = ({ + experiment, + placeId, + onPlaceIdChange, +}: { + experiment: ExperimentRecord; + placeId: string | null; + onPlaceIdChange: (placeId: string) => void; +}) => { + const { + petriNetDefinition: { places }, + } = use(SDCPNContext); + const rootRef = useRef(null); + const plotRef = useRef(null); + const size = useElementSize(rootRef, { debounce: 50 }); + const distributionPlaceOptions = + experiment.distributionFrames[0]?.places.map((place) => ({ + value: place.placeId, + label: + places.find((candidate) => candidate.id === place.placeId)?.name ?? + place.placeName, + })) ?? []; + const selectedPlaceId = + placeId && + distributionPlaceOptions.some((option) => option.value === placeId) + ? placeId + : (distributionPlaceOptions[0]?.value ?? null); + const data = selectedPlaceId + ? buildTimelineData(experiment, selectedPlaceId) + : null; + const hasData = data ? data[0]!.length > 0 : false; + + useEffect(() => { + const root = rootRef.current; + if (!root || !size || !selectedPlaceId || !hasData) { + plotRef.current?.destroy(); + plotRef.current = null; + root?.replaceChildren(); + return; + } + + plotRef.current?.destroy(); + root.replaceChildren(); + const plot = new UPlot( + chartOptions(size.width, Math.max(220, size.height)), + createEmptyTimelineData(), + root, + ); + plotRef.current = plot; + + return () => { + plotRef.current = null; + plot.destroy(); + }; + }, [hasData, selectedPlaceId, size]); + + useEffect(() => { + if (!data || !plotRef.current) { + return; + } + + plotRef.current.setData(data); + }, [data]); + + if (experiment.distributionFrames.length === 0 || !selectedPlaceId) { + return
Waiting for experiment data
; + } + + return ( +
+
+ Place +