diff --git a/.changeset/smooth-lizards-run.md b/.changeset/smooth-lizards-run.md new file mode 100644 index 00000000000..66e8d809ddf --- /dev/null +++ b/.changeset/smooth-lizards-run.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add basic MonteCarlo-based experiments diff --git a/libs/.gitignore b/libs/.gitignore index 03314f77b5a..435ac05c0c1 100644 --- a/libs/.gitignore +++ b/libs/.gitignore @@ -1 +1,2 @@ Cargo.lock +analysis diff --git a/libs/@hashintel/petrinaut/panda.config.shared.ts b/libs/@hashintel/petrinaut/panda.config.shared.ts index 7384aed1396..8d44ac50ac2 100644 --- a/libs/@hashintel/petrinaut/panda.config.shared.ts +++ b/libs/@hashintel/petrinaut/panda.config.shared.ts @@ -48,6 +48,10 @@ export const createPetrinautPandaConfig = (dsComponentsBuildInfoPath: string) => from: { opacity: "1", transform: "translateY(0)" }, to: { opacity: "0", transform: "translateY(-10px)" }, }, + spin: { + from: { transform: "rotate(0deg)" }, + to: { transform: "rotate(360deg)" }, + }, expand: { from: { height: "0", opacity: "0" }, to: { height: "var(--height)", opacity: "1" }, diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/01-motivation.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/01-motivation.md deleted file mode 100644 index 05581b87714..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/01-motivation.md +++ /dev/null @@ -1,25 +0,0 @@ -# 01 — Motivation - -## Goals - -Restructure `@hashintel/petrinaut` into three import paths so that: - -- **`@hashintel/petrinaut/core`** can be consumed without React, the DOM, or Monaco. A Node script, a CLI, a server-side simulation runner, or an alternative-framework binding can all instantiate a Petrinaut, mutate it, and run simulations against it. -- **`@hashintel/petrinaut/react`** carries the React-specific glue (hooks, contexts, bridge providers) that turns a Core instance into something React components can subscribe to. No visual widgets — anyone wanting to build a different UI on top of Core uses this layer. -- **`@hashintel/petrinaut/ui`** is the opinionated, polished editor we ship today. Most consumers continue to use just `` and never see `/core` or `/react`. - -The split should make the boundary between *what the system does* (core) and *how it is rendered* (ui) explicit, with `/react` as the bridge. - -## Non-goals - -- **Collaborative editing as a first-class core concern.** Automerge / Yjs integration stays the host's responsibility; the RFC only commits to keeping that integration possible (likely via an "external document" mode — see Q1 in `07-open-questions.md`). -- **Framework-specific bindings beyond React.** A future `petrinaut/vue` or `petrinaut/solid` would slot into the same shape, but they're out of scope here. -- **Worker pool / multi-instance optimisation.** Each Petrinaut instance gets its own worker; sharing across instances is a future RFC if it becomes necessary. -- **A new public docs / migration guide.** Will follow once the RFC is accepted and merged. -- **Reworking the SDCPN type itself.** Existing types stay; only their location and wrappers change. - -## Why now - -- The provider stack in `petrinaut.tsx` has grown to ten layers. Each is a mix of Core-shaped logic and React glue, which makes it hard to reason about, hard to test in isolation, and impossible to reuse outside the editor. -- Headless simulation (CI lint of an SDCPN, server-side runs, snapshot-based regression tests) is increasingly desired but currently requires booting a React tree. -- The simulation engine and LSP worker are already pure / headless internally; only their wrappers prevent non-React reuse. The cost of doing this split now is low. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/02-current-state.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/02-current-state.md deleted file mode 100644 index d7b0396a1a2..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/02-current-state.md +++ /dev/null @@ -1,66 +0,0 @@ -# 02 — Current state - -## Provider stack - -A single `` component (`src/petrinaut.tsx`) composes a stack of providers: - -```text -NotificationsProvider -└─ UndoRedoContext - └─ SDCPNProvider ← holds petriNetDefinition + mutate callback (from props) - └─ LanguageClientProvider ← LSP worker - └─ MonacoProvider ← editor framework - └─ SimulationProvider ← wraps the simulation web worker - └─ PlaybackProvider ← rAF loop + speed/frame index - └─ UserSettingsProvider - └─ EditorProvider ← UI mode/selection/panels - └─ MutationProvider ← typed mutation DSL - └─ EditorView (UI) -``` - -## Public surface - -`src/main.ts` re-exports: - -- `` component + `PetrinautProps` -- Domain types: `SDCPN`, `Place`, `Transition`, `Color`, `Parameter`, `DifferentialEquation`, `MinimalNetMetadata`, `MutateSDCPN` -- `isSDCPNEqual` deep-equal helper -- `ErrorTrackerContext` for error reporting - -## Document ownership today - -The host owns the SDCPN. `petriNetDefinition` and `mutatePetriNetDefinition` are passed in as props; the host wraps the mutator in `immer.produce`, `automerge.changeDoc`, etc. **Petrinaut does not own the document.** - -That model is what enables collaborative editing without Petrinaut having to know about CRDTs. Any future split must preserve this affordance — see Q1 in [07-open-questions.md](./07-open-questions.md). - -## What's already core-shaped - -Several modules are already pure logic with no React dependencies. The split largely consists of moving them and removing their React wrappers, not rewriting them: - -- `core/types/sdcpn.ts`, `core/schemas/`, `core/errors.ts` -- `simulation/simulator/*`, `simulation/compile-scenario.ts` -- `simulation/worker/*` (already a `postMessage`-based protocol) -- `lsp/worker/*` (already runs in a worker) -- `validation/*` -- `lib/deep-equal.ts` -- `examples/*` - -## What's React-wrapped state that needs extracting - -These hold real state, but the state itself isn't inherently React — it's the wrapping that is: - -- `state/sdcpn-provider.tsx` — derives `getItemType()` from the definition. -- `state/mutation-provider.tsx` — typed mutation DSL over `mutatePetriNetDefinition`. -- `simulation/provider.tsx` — worker lifecycle + status mapping. -- `playback/provider.tsx` — rAF frame loop + speed/frame index. -- `lsp/provider.tsx` — language client glue (diagnostics push, completion/hover RPCs). -- `notifications/*` — emits notification events that the toaster renders. - -## What's genuinely UI-only - -- `views/`, `components/` -- `monaco/*` (editor framework) -- The toaster part of `notifications/*` -- `resize/`, `state/portal-container-context.tsx` -- `state/editor-context.ts`, `state/user-settings-*.ts` (panel widths, modes, selection — UI concerns with no Core counterpart) -- DOM-touching parts of `file-format/export-sdcpn.ts` and `clipboard/clipboard.ts` diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/03-layering.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/03-layering.md deleted file mode 100644 index f7296d59e60..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/03-layering.md +++ /dev/null @@ -1,43 +0,0 @@ -# 03 — Layering - -## 3.1 What goes in `core/` (no React, no DOM) - -- **Domain types** — `core/types/sdcpn.ts`, `core/schemas/`, `core/errors.ts`. Already pure. Move as-is. -- **Simulation engine** — `simulation/simulator/*`, `simulation/compile-scenario.ts`. Already pure. -- **Simulation worker** — `simulation/worker/*` (worker code + typed message protocol). Headless; uses `postMessage`. Bundling solved via caller-provided factory — see [05-simulation.md](./05-simulation.md). -- **Validation** — `validation/*`. Pure regex/naming rules. -- **File format** — `file-format/import-sdcpn.ts`, `file-format/types.ts`. Pure deserialization. Browser-download trigger lives in `/ui` (it touches the DOM). -- **Clipboard (logic)** — `clipboard/serialize.ts`, `clipboard/paste.ts`. Pure marshaling. `navigator.clipboard` wrapper stays in `/ui`. -- **Examples** — `examples/*`. Static SDCPN data; useful for tests and demos in any layer. -- **Selection types** — `state/selection.ts`. Pure data; the *concept* of a selection map. (Whether the selection itself is owned by core or by the UI is an open question — see [07-open-questions.md](./07-open-questions.md).) -- **Layout** — `lib/layout/*` (elkjs auto-layout, if pure). Verify no DOM deps. -- **Deep-equal** — `lib/deep-equal.ts`. Pure. -- **The Core instance** — new: `core/instance.ts`. Top-level factory `createPetrinaut(...)` returning a stateful object with input/output streams. See [04-core-instance.md](./04-core-instance.md). - -## 3.2 What goes in `react/` (React bindings, no visual widgets) - -The `/react` layer is everything you'd need to build a different UI on top of Core: hooks, contexts, and the bridge providers that turn Core's streams into React state. **No JSX that renders to the screen.** - -- **Instance context + factory hook** — new: `react/instance-context.ts`, `react/use-petrinaut-instance.ts`. Holds the Core instance; `usePetrinaut*()` hooks read from it via `useSyncExternalStore`. -- **Bridge providers** — `state/sdcpn-provider.tsx`, `state/mutation-provider.tsx`, `simulation/provider.tsx`, `playback/provider.tsx`, `lsp/provider.tsx`, `notifications/provider.tsx`. Re-shaped to subscribe to a Core instance and republish via React Context. -- **Editor UI state (no Core counterpart)** — `state/editor-context.ts`, `state/editor-provider.tsx`, `state/user-settings-context.ts`, `state/user-settings-provider.tsx`, `state/use-selection.ts`, etc. React-only state for panel widths, modes, sidebar, selection. Lives in `/react` so alternative UIs can reuse it. -- **Undo/redo context** — `state/undo-redo-context.ts`. Pure React glue for the host-provided undo/redo interface. -- **Error tracker context** — `error-tracker/error-tracker.context.ts`. Stays as a React context. -- **Convenience hooks** — `state/use-is-read-only.ts`, `state/use-sync-editor-to-settings.ts`, etc. Move with their providers. - -## 3.3 What goes in `ui/` (the editor itself) - -- **Top-level component** — `petrinaut.tsx` rebuilt as a thin wrapper. Creates a Core instance, mounts the `/react` providers, then renders `EditorView`. -- **Editor view + components** — `views/`, `components/`. All visual UI. -- **Monaco integration** — `monaco/*`. Loads + configures the editor; subscribes to LSP via `/react` hooks. -- **Notifications UI** — `notifications/*` (rendering side). Toast components. The *event stream* lives in core; the *toast UI* lives here. -- **Resize / portal helpers** — `resize/`, `state/portal-container-context.tsx`. UI infra. -- **File-format download** — `file-format/export-sdcpn.ts` (DOM bits only). The *serialization* moves to core; the *trigger-a-browser-download* part stays here. -- **Clipboard browser wrapper** — `clipboard/clipboard.ts` (`navigator.clipboard` calls). Pure marshaling moves to core; the browser API call stays here. - -## 3.4 Ambiguous items (pending decisions in [07-open-questions.md](./07-open-questions.md)) - -- **Editor state** (selection, current mode, panel layout, user settings): leaning toward **react-only**, because nothing in core needs to know which node is highlighted. But if a non-React consumer wants "what is the user pointing at" semantics, we may want a thin selection slice in core. -- **Undo/redo:** today it's a pass-through interface — host implements it. Should core own a default in-memory history (with the host able to replace it), or stay pass-through? Probably pass-through stays. -- **LSP**: the worker logic is pure and headless; the React provider is just glue. The worker itself fits in core, but Monaco's binding to it stays in `/ui` (via `/react` hooks). The diagnostics stream is a clean core output. -- **Notifications**: today these are toasts. Notifications are *outputs*; core should emit them as events, and `/ui` can render them as toasts. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/04-core-instance.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/04-core-instance.md deleted file mode 100644 index 6ee81cedc1c..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/04-core-instance.md +++ /dev/null @@ -1,358 +0,0 @@ -# 04 — Core instance API - -The Core instance owns "the live document": - -- **Inputs** (commands flowing in): mutate definition, paste from clipboard, undo/redo via the handle, etc. -- **Outputs** (events/state flowing out): current SDCPN, patches, future LSP diagnostics, future notifications. - -**Simulation does not live on the instance.** A simulation operates on a frozen SDCPN snapshot and has no need for the live document. It is constructed by `createSimulation` standalone — see [05-simulation.md](./05-simulation.md). Playback state belongs to whoever's driving the visualisation (today the React layer; future Core surface). - -## 4.1 Construction (locked) - -Core never owns the document. It is given a **handle** to one — produced by the host from a plain JSON store, an Automerge `DocHandle`, or anything else that can satisfy the `PetrinautDocHandle` interface. - -```ts -import { createPetrinaut } from "@hashintel/petrinaut/core"; -import type { PetrinautDocHandle } from "@hashintel/petrinaut/core"; - -const instance = createPetrinaut({ - document: handle, // PetrinautDocHandle - readonly: false, // optional, defaults to false -}); -``` - -The instance config is deliberately minimal. **Simulation is not configured here** — it's built standalone via `createSimulation`. **LSP is also separate** — `createLanguageClient` is its own factory. **Error tracking** lives on the `/react` side (`` from `@hashintel/petrinaut/react`); Core doesn't take an `errorTracker` because it has typed error channels (`simulation.events`, `lsp.diagnostics`, `handle.state`) for everything legitimately observable. See [05-simulation.md](./05-simulation.md). - -### Handle interface - -```ts -export type DocumentId = string; -export type DocHandleState = "loading" | "ready" | "deleted" | "unavailable"; - -/** - * Minimal RFC 6902-shaped patch. Modeled on Immer's `produceWithPatches` output: - * array path, `op` field, three operations only. - * - * Petrinaut-defined to avoid a runtime dependency. Adapters from Immer (no - * conversion needed) and Automerge (small mapper) are documented at the - * adapter-construction site. - */ -export type PetrinautPatch = { - op: "add" | "remove" | "replace"; - path: (string | number)[]; - value?: unknown; -}; - -export type DocChangeEvent = { - /** Post-mutation snapshot. */ - next: SDCPN; - /** Optional. Emitted by handles that can produce them (e.g. Immer-backed, Automerge-backed). */ - patches?: PetrinautPatch[]; - /** Optional. "local" if produced by `change()`, "remote" if delivered via a sync channel. */ - source?: "local" | "remote"; -}; - -export type HistoryEntry = { - timestamp: string; -}; - -export interface PetrinautHistory { - /** Apply the most recent inverse patches. Returns true if anything was undone. */ - undo(): boolean; - /** Re-apply the most recently undone patches. Returns true if anything was redone. */ - redo(): boolean; - /** Jump to an arbitrary point in history. Returns true if the index changed. */ - goToIndex(index: number): boolean; - /** Drop the entire history (state stays at the current value). */ - clear(): void; - - readonly canUndo: ReadableStore; - readonly canRedo: ReadableStore; - - /** - * Ordered timestamps of the history checkpoints. Index 0 is the initial - * state; index N is the state after the Nth retained mutation. - */ - readonly entries: ReadableStore; - - /** Position of the current state within {@link entries}. */ - readonly currentIndex: ReadableStore; -} - -export interface PetrinautDocHandle { - /** Stable id. Used for LSP document URIs, error reports, simulation-recording keys. */ - readonly id: DocumentId; - - /** Lifecycle state, observable. */ - readonly state: ReadableStore; - - /** Resolves when state reaches "ready"; rejects on "unavailable" / "deleted". */ - whenReady(): Promise; - - /** Synchronous current value. `undefined` while not ready. */ - doc(): SDCPN | undefined; - - /** Apply a mutation. Implementation decides how (Immer.produce, Automerge.change, plain assignment). */ - change(fn: (draft: SDCPN) => void): void; - - /** Subscribe to changes (local + remote). */ - subscribe(listener: (event: DocChangeEvent) => void): () => void; - - /** - * Optional. Present on handles that track local history (Immer-backed, - * Automerge-backed, …). Read-only mirror handles omit it. See §4.1 "History". - */ - readonly history?: PetrinautHistory; -} -``` - -### History (locked) - -Undo/redo lives on the handle, not as a separate host-implemented interface. Reasons: - -- An Immer-backed handle already has the data (`produceWithPatches` returns `[next, forward, inverse]`); the previous design was throwing the inverse patches away. -- Automerge handles can implement `history` against their own time-travel API (`Doc.heads`). -- Read-only / mirror handles simply omit `history`. -- This removes today's pass-through `UndoRedoContextValue`. The host's job becomes "build/choose a handle"; it no longer wires history separately. - -`createJsonDocHandle` ships a default implementation: - -- Bounded stack (`historyLimit`, default 50) — older entries dropped to cap memory. -- New mutation truncates the redo stack. -- `goToIndex(n)` jumps to an arbitrary point (used by today's version-history dropdown). -- `clear()` empties the stack but leaves the current state alone. -- Each undo / redo / `goToIndex` emits a `DocChangeEvent` with the patches actually applied. -- Pass `historyLimit: 0` to opt out — `handle.history` becomes `undefined`. - -### Coalescing — deferred - -Single-character edits in Monaco produce one `change` per keystroke, so naïve history would mean one undo per character. The current website's `useUndoRedo` debounces at 500 ms; the spike's handle does not. Two future shapes: - -- `handle.transaction(fn)` — group multiple `change` calls into one history entry. -- `handle.change(fn, { coalesceWith: "monaco:transition-t1" })` — tag-based coalescing: merge with the previous entry if tags match. - -Most editors do both. Locking the API is a Phase 3 concern; for now the limit is "one change = one history entry." - -### Why a handle, not a document or repo - -| Level | Why not | -| ----- | ------- | -| **Document** (raw value + 3 callbacks — today's prop shape) | Three loose primitives, no unifying type, no lifecycle (loading / ready / deleted). | -| **Repo** (multi-document, load-by-id, storage / network adapters) | Persistence and sync are host concerns. Core handles one document at a time. | -| **Handle** ✓ | Right scope. One document, observable lifecycle, mutate + subscribe in one type. Matches Automerge `DocHandle` so collaboration plugs in cleanly. | - -### Adapters - -Core ships exactly one helper for the common case: - -```ts -export function createJsonDocHandle(opts: { - id?: DocumentId; - initial: SDCPN; -}): PetrinautDocHandle; -``` - -Internally uses Immer's `produceWithPatches` so plain-JSON consumers get patches for free. Adds `immer` (~14 KB) as a `/core` dep. - -Implementation notes (locked by the Phase 0 spike): - -- `enablePatches()` must be called once at `/core` module load — `handle.ts` does this at import time before any `produceWithPatches` call. -- **No-op mutations do not emit.** `produceWithPatches` returns an empty patch array when the draft was not actually changed; in that case the handle skips notifying subscribers. Subscribers can rely on "every event corresponds to a real change." - -For **Automerge**, no adapter is shipped (would require `@automerge/automerge-repo` as a peer dep). The docs include a 5-line wrapper consumers paste in; Automerge's `Patch` shape maps to `PetrinautPatch` via a small switch (`put` → `replace`, `del` → `remove`, `splice`/`insert` → multiple `add`). - -### Where patch conversion happens - -Conversion lives **at the handle adapter boundary, inbound only.** Core itself never converts; it only consumes `PetrinautPatch[]`. - -| Adapter | Direction | Where it lives | What it does | -| ------- | --------- | -------------- | ------------ | -| `createJsonDocHandle` | Immer → `PetrinautPatch` | `/core` (shipped) | Near-identity. Immer's `produceWithPatches` already emits `{ op, path, value }`; the adapter passes them through (or coerces the type). | -| `fromAutomergeHandle` | Automerge → `PetrinautPatch` | Consumer code (documented snippet) | Switch on `action`: `put` → `replace`, `del` → `remove`, `splice`/`insert` → fan out into per-element `add`. Drops `inc`/`mark`/`unmark` (not used by SDCPN). | - -There is **no outbound direction in Core**. Core never needs to turn a `PetrinautPatch` back into an Immer or Automerge patch — the handle is the only thing that applies mutations, and it does so via `change(fn)`, not via patches. If a downstream consumer ever wants to re-apply patches against a separate Immer or Automerge doc (e.g. mirror the document somewhere else), that conversion is their problem and lives in their code. - -### `PetrinautPatch` is not a persistence or wire format - -`PetrinautPatch` is an **in-memory event payload only**. Do not: - -- write it to disk, -- serialize it into telemetry / analytics with a versioned schema, -- send it across a network boundary as a Petrinaut-defined protocol. - -Persistence and sync go through the **underlying handle**: `createJsonDocHandle` persists SDCPN snapshots; an Automerge handle persists Automerge's binary change log; future network sync uses the CRDT engine's native protocol (Automerge / Yjs sync messages), not raw `PetrinautPatch[]`. - -This is what lets us evolve the patch shape — including the eventual splice-on-string addition for text-range edits (Q1.c) — as a TypeScript-level breaking change with **zero on-disk migration**. Adding a new variant to the `op` union is caught by exhaustiveness checking on every consumer; consumers update; nothing on disk has to. - -### Why the handle has its own subscribe shape - -`PetrinautDocHandle.subscribe` is **not** a `ReadableStore`. The event carries optional `patches` and `source` fields that don't belong in a generic state-store interface. Internally Core constructs a `ReadableStore` (`instance.definition`) on top of the handle, dropping `patches`/`source` for consumers that just want the value. - -### Known limitation: text-range edits - -`PetrinautPatch` (Immer-shaped) treats strings as atomic. A single-character edit inside a long code block (transition guard, kernel, equation) emits a `replace` carrying the **entire new string** — there is no `splice` op for sub-string ranges. - -This is acceptable for the current single-user editor, but it has two drawbacks worth flagging: - -1. **Bandwidth.** Every keystroke produces a patch sized like the whole field. For 10 KB code blocks, that's a lot of duplication. -2. **Collaboration.** Character-level CRDT merging (Automerge `Text`, Yjs) requires sub-string operations. Whole-string `replace` ops can't be merged without conflict and would defeat collaborative code editing if it's ever introduced. - -**Decision:** keep Immer-shape now; address later. When/if Petrinaut needs collaborative code editing — or when patch volume becomes a problem — a follow-up RFC will introduce either: - -- a richer op (`{ op: "splice"; path; index; remove; insert }`) added to `PetrinautPatch`, or -- a separate text-edit event channel on `PetrinautDocHandle` (e.g. `subscribeText(path, listener)`) that operates alongside the structural patch stream. - -Tracked as a known follow-up in [07-open-questions.md](./07-open-questions.md) and [09-risks.md](./09-risks.md). - -## 4.2 Stream primitive (locked) - -Two primitives. State uses `ReadableStore`; one-shot events use `EventStream`. - -```ts -type ReadableStore = { - /** Synchronous read of the current value. */ - get(): T; - /** Subscribe to changes. The listener receives the new value on every call. Returns an unsubscribe function. */ - subscribe(listener: (value: T) => void): () => void; -}; - -type EventStream = { - /** Subscribe to discrete events. Returns an unsubscribe function. */ - subscribe(listener: (event: T) => void): () => void; -}; -``` - -### Why this shape - -- **Zero dependencies.** Two interfaces, no library. -- **Listener receives the value.** Avoids forcing every consumer to call `get()` after a ping. Slight allocation cost per emission is acceptable for the rates Petrinaut runs at. -- **Easy to wrap.** A consumer who wants Observable / RxJS / AsyncIterable / Signals can build any of them as a thin adapter on top. -- **React adaptation is one wrapper line.** See [06-react-bindings.md](./06-react-bindings.md) §6.3. - -### Alternatives considered - -| Option | Why rejected | -| ------ | ------------ | -| Standard `Observable` (TC39 / zen-observable) | Adds a base class for little gain; `error`/`complete` channels rarely apply to state slices. | -| RxJS | Heavy for `/core`; conflicts with consumers' own RxJS versions. | -| `AsyncIterable` | No native "current value" semantics; awkward to multicast. | -| Signals (`@preact/signals-core`) | Push-pull model; less idiomatic outside React-likes. | -| Listener-as-ping (React `useSyncExternalStore` shape) | Slightly cheaper for React, but worse ergonomics for non-React consumers who'd then have to chase a `get()` after every notification. | - -## 4.3 The surface - -```ts -type Petrinaut = { - // --- Document (derived from the handle) --- - readonly handle: PetrinautDocHandle; // the handle Core was constructed with - readonly definition: ReadableStore; // current snapshot store, sourced from `handle` - readonly patches: EventStream; // only fires for handles that produce them - - // --- Mutation --- - mutate(fn: (draft: SDCPN) => void): void; // delegates to handle.change; no-op if readonly - - // --- Config echo --- - readonly readonly: boolean; - - // --- Lifecycle --- - dispose(): void; -}; -``` - -That's it. Earlier drafts of this RFC sketched a much wider surface (`setTitle`, an `lsp` block, a `notifications` stream); each ended up belonging elsewhere — see "Not on the instance" below. - -`mutate` and `dispose` carry `this: void` so consumers can pass them as method references without `unbound-method` complaints. Same retrofit was applied to `Simulation` and `LanguageClient`. - -### Not on the instance - -These are intentionally outside the `Petrinaut` type: - -- **Simulation.** Operates on a frozen SDCPN snapshot; no need for the live document. Built via `createSimulation({ sdcpn, ... })`. The host owns the resulting `Simulation` handle and its lifecycle. Multiple simulations can coexist against one document. See [05-simulation.md](./05-simulation.md). -- **LSP.** Built standalone via `createLanguageClient({ createWorker })` from `@hashintel/petrinaut/core/lsp`. Returns a `LanguageClient` handle with `diagnostics: ReadableStore`, `notifyDocumentChanged`, `requestCompletion` / `requestHover` / `requestSignatureHelp`, scenario / metric session methods, and `dispose`. The React side wires it up through ``; the host can also call it directly for headless use. -- **Notifications.** Folded into `simulation.events` (one-shot `complete` / `error` events the React layer surfaces as toasts). There is no separate notifications channel on the instance. -- **Title.** Net title is a host-management concern, not a document field. Lives in `NetManagement` (`{ title, setTitle, existingNets, createNewNet, loadPetriNet }`) provided by the host through ``. See [06-react-bindings.md](./06-react-bindings.md) §6.1. -- **Playback.** Frame-loop timing belongs to whoever drives the visualisation. Today the React layer (`PlaybackProvider`); a future Core helper might package the rAF loop, but it would still take a `Simulation`, not an instance. -- **Editor / UI state.** Selection, panels, modes — purely React. -- **Undo/redo.** Lives on the handle (see §4.1 "History"), not on the instance. -- **Error tracking.** `ErrorTrackerContext` lives in `/react` (host plugs Sentry / Datadog in via the provider). Core has typed error channels (`simulation.events`, `lsp.diagnostics`, `handle.state`); a generic capture callback would duplicate them. - -## 4.4 Instantiation patterns - -The host produces a `PetrinautDocHandle` and passes it to Core. The four shapes: - -### A. Plain JSON (in-memory, headless or React) - -```ts -import { createPetrinaut, createJsonDocHandle } from "@hashintel/petrinaut/core"; - -const handle = createJsonDocHandle({ id: "net-1", initial: emptyNet() }); -const instance = createPetrinaut({ document: handle }); - -instance.mutate((draft) => { - draft.places.push({ id: "p1", /* … */ }); -}); -``` - -`createJsonDocHandle` uses Immer internally, so `subscribe` events carry patches. - -### B. Automerge - -```ts -import { createPetrinaut } from "@hashintel/petrinaut/core"; -import { Repo } from "@automerge/automerge-repo"; - -const repo = new Repo({ /* storage, network, … */ }); -const automergeHandle = repo.find("automerge:abc123"); - -const handle = fromAutomergeHandle(automergeHandle); // 5-line adapter, see docs -const instance = createPetrinaut({ document: handle }); -``` - -Petrinaut never sees the `Repo`. Storage, sync, and lifecycle stay in the host. - -### C. From the React component (what most consumers see) - -```tsx -import { Petrinaut } from "@hashintel/petrinaut/ui"; - -; -``` - -`` calls `createPetrinaut` internally and disposes the instance on unmount. The handle's identity is the React `key` — replacing the handle re-creates the instance. - -### D. Headless simulation (the new use case the split unlocks) - -The simulation is constructed standalone — no need for a `Petrinaut` instance if all you want to do is run an SDCPN. - -```ts -import { createSimulation } from "@hashintel/petrinaut/core"; - -const sim = await createSimulation({ - sdcpn: net, - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 100, - createWorker: () => new Worker(/* … */), -}); - -const off = sim.frames.subscribe(({ latest }) => recordFrame(latest)); -sim.run(); -``` - -If you already have an instance (for live editing), the SDCPN is a `handle.doc()` away: - -```ts -const sim = await createSimulation({ - sdcpn: instance.handle.doc()!, - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 100, - createWorker: () => new Worker(/* … */), -}); -``` - -The simulation is independent of the instance and outlives it. Disposing the instance does **not** dispose the simulation; the host owns the simulation's lifecycle. Multiple simulations can coexist against one document (parameter sweeps, scenario comparison). diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/05-simulation.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/05-simulation.md deleted file mode 100644 index f75436ae158..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/05-simulation.md +++ /dev/null @@ -1,198 +0,0 @@ -# 05 — Simulation patterns (locked) - -The simulator and the simulation worker live in `/core`. Simulations are **standalone** — they don't live on a `Petrinaut` instance because they only need a frozen SDCPN snapshot to run. The patterns below isolate the engine from how it runs, so non-browser consumers can use it and bundling concerns stay out of `/core`. - -## 5.1 Standalone, not instance-owned - -A simulation operates on a frozen SDCPN snapshot taken at start time. After that, it has no relationship with the live document — mutations don't affect a running simulation, and the simulation can outlive any `Petrinaut` instance. - -So the entry point is a top-level function, **not** a method on the instance: - -```ts -import { createSimulation } from "@hashintel/petrinaut/core"; - -const sim = await createSimulation({ - sdcpn: someSDCPN, // frozen snapshot - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, - createWorker: () => new Worker(/* … */), -}); -``` - -If you have a `Petrinaut` instance (for live editing), pass `instance.handle.doc()`. If you don't, pass any SDCPN value — it works the same. Multiple simulations can coexist against one document (parameter sweeps, scenario comparison) because each owns its own snapshot and worker. - -The host owns the resulting `Simulation` handle and its lifecycle; disposing the source `Petrinaut` instance does **not** dispose its simulations. - -## 5.2 Pluggable transport - -The pure engine (`simulation/simulator/*`) is one thing; the worker that runs it off-thread is another. A `SimulationTransport` interface decouples them: - -```ts -interface SimulationTransport { - send(message: ToWorkerMessage): void; - onMessage(listener: (m: ToMainMessage) => void): () => void; - terminate(): void; -} - -// shipped in /core -createWorkerTransport(createWorker: () => Worker | Promise): SimulationTransport; -createInlineTransport(): SimulationTransport; // 🟡 planned — runs the engine on the calling thread -``` - -`createSimulation` accepts either a `createWorker` factory (it builds the transport) or a pre-built `transport`: - -```ts -type CreateSimulationConfig = SimulationConfig & - ( - | { createWorker: WorkerFactory; transport?: never } - | { transport: SimulationTransport; createWorker?: never } - ); -``` - -When you pass a `transport`, ownership transfers to the simulation — `simulation.dispose()` calls `transport.terminate()`. Build a fresh transport per simulation. - -### Transport matrix - -| Environment | Off-thread? | Recommended path | Status | -| ----------- | :---------: | ---------------- | :----: | -| Browser, fast | ✅ | `createWorkerTransport(() => new Worker(...))` | 🟢 | -| Browser, simple / tests | ❌ | `createInlineTransport()` | 🟡 | -| Node `worker_threads` | ✅ | Custom `SimulationTransport` (see snippet below) — *or* `web-worker` polyfill + `createWorkerTransport` | 🟢 (DIY) | -| Node, simple / tests | ❌ | `createInlineTransport()` | 🟡 | -| Bun / Deno workers | ✅ | Custom `SimulationTransport`, or use the runtime's built-in browser-`Worker`-shim with `createWorkerTransport` | 🟢 (DIY) | -| Edge / process pools / IPC | ✅ | Custom `SimulationTransport` over whatever message-passing primitive you have | 🟢 (DIY) | - -`/core` ships only `createWorkerTransport` (browser-shaped) and the planned `createInlineTransport`. Other environments are DIY because the message-passing API differs between runtimes (browser `addEventListener` vs Node `EventEmitter`, etc.) and importing `node:worker_threads` from `/core` would compromise the browser bundle. Writing a transport is ~10 lines. - -### Example: Node `worker_threads` - -```ts -import { Worker } from "node:worker_threads"; -import type { - SimulationTransport, - ToMainMessage, - ToWorkerMessage, -} from "@hashintel/petrinaut/core"; - -export function createNodeWorkerTransport( - scriptPath: string | URL, -): SimulationTransport { - const worker = new Worker(scriptPath); - return { - send: (msg: ToWorkerMessage) => worker.postMessage(msg), - onMessage: (listener: (msg: ToMainMessage) => void) => { - worker.on("message", listener); - return () => worker.off("message", listener); - }, - terminate: () => { - void worker.terminate(); - }, - }; -} - -// usage -const sim = await createSimulation({ - transport: createNodeWorkerTransport(new URL("./sim.worker.mjs", import.meta.url)), - sdcpn: net, - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, -}); -``` - -The same pattern adapts to Bun (`new globalThis.Worker(...)`), Deno (`new Worker(...)`), or any other message-passing primitive. The `SimulationTransport` interface is small enough that it's easier to wrap than to abstract over. - -### Why we don't ship a Node helper - -`createNodeWorkerTransport` would need either a sub-entry like `@hashintel/petrinaut/core/node` (to keep `node:worker_threads` out of the browser bundle) or conditional bundler logic. Both add weight for code that's ten lines for a consumer to write. We'll revisit if a real internal consumer needs Node off-thread sim — until then, document the snippet. - -## 5.3 Worker bundling — caller-provided factory (Option A) - -`/core` does **not** import the worker via Vite-specific syntax. Consumers pass a `Worker` factory: - -```ts -import { createSimulation } from "@hashintel/petrinaut/core"; - -const sim = await createSimulation({ - sdcpn: net, - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, - createWorker: () => - new Worker( - new URL("@hashintel/petrinaut/core/simulation.worker", import.meta.url), - { type: "module" }, - ), -}); -``` - -The worker source is exposed as a sub-entry of the package (`./core/simulation.worker`) so the consumer's bundler resolves it via `new URL(..., import.meta.url)`. This works in Vite, Webpack, Rolldown, esbuild, and Bun without `/core` itself reaching for any bundler-specific syntax. - -`/ui`'s `` will provide a sensible default factory pointing at the same sub-entry, so most consumers don't have to think about this. Advanced consumers (Node `worker_threads`, custom worker pools, polyfills) override it. - -## 5.4 Stream outputs, imperative inputs - -```ts -type Simulation = { - // outputs (subscribe-and-read) - status: ReadableStore; - frames: ReadableStore<{ count: number; latest: SimulationFrame | null }>; - events: EventStream<{ type: "complete" | "error"; reason?: string; itemId?: string | null }>; - - // inputs (imperative) - run(): void; - pause(): void; - reset(): void; - getFrame(index: number): SimulationFrame | null; - ack(frameNumber: number): void; - setBackpressure(cfg: BackpressureConfig): void; - - // lifecycle - dispose(): void; -}; -``` - -`frames` deliberately exposes only `{count, latest}` so subscribers don't re-render per frame; individual frames are pulled via `getFrame(i)`. Same approach `PlaybackProvider` already uses today. - -## 5.5 Disposal & cancellation - -- `createSimulation({ ..., signal })` aborts worker init and frame streaming if the signal fires before init completes; the partial transport is torn down. -- `sim.dispose()` is the synchronous variant; idempotent. -- The host owns the lifecycle — Core does not chain disposal from a `Petrinaut` instance. - -## 5.6 Recording / replay falls out for free - -Because frames are a stream, persisting a run is `sim.frames.subscribe(({latest}) => store.push(latest))`. Replay is `createInlineTransport()` (or a `createRecordedTransport(frames)`) fed pre-recorded frames — useful for deterministic tests, snapshots, time-travel debugging, no extra API. - -## 5.7 `/react` shape - -`SimulationProvider` becomes a ~30-line bridge: hold a `Simulation | null` in React state, expose its stores via `useSyncExternalStore`, and republish through the existing `SimulationContext` so `/ui` doesn't change. `useSimulationStatus()` / `useSimulationFrames()` hooks live here. - -The provider also owns "setup state" (parameter values, initial marking, scenarios, etc.) as plain React state — these are inputs to `createSimulation`, not Core concerns. When the user clicks "run," the provider: - -1. Disposes the previous simulation, if any. -2. Calls `createSimulation({ sdcpn, ...setupState, createWorker })`. -3. Stores the resulting handle and forwards control calls to it. - -## 5.8 Phase 2a spike — landed - -A first cut of the patterns above ships in `src/core/simulation/`: - -- `transport.ts` — `SimulationTransport` interface and `createWorkerTransport(createWorker)`. Accepts a sync or async `Worker` factory; messages sent before the worker is ready are queued and flushed on boot. -- `simulation.ts` — `createSimulation(config)` returning a `Simulation` handle (`status` + `frames` + `events` stores; `run`/`pause`/`reset`/`ack`/`setBackpressure`/`getFrame`/`dispose` actions). Accepts either `createWorker` or `transport` per call. Resolves on `ready`; rejects on init `error` or `AbortSignal` abort. `dispose()` is idempotent and tears down the transport. -- `index.ts` — barrel for `/core/simulation`. - -Decoupling: `createPetrinaut` does **not** take a simulation config. The instance has no `simulation` field. Consumers who want to run a simulation call `createSimulation` directly with whatever SDCPN they have. This keeps the instance surface narrow, supports multiple simulations per document, and makes pure-headless simulation a one-import operation. - -`createInlineTransport()` is **not yet implemented** — Phase 3 / follow-up. The `SimulationTransport` interface is designed to admit it without the rest of the API changing. - -**`` swap landed (Phase 2b).** `src/simulation/provider.tsx` now calls `createSimulation` instead of `useSimulationWorker`. The provider holds a `Simulation | null` in React state, subscribes to its `status` and `frames` stores via `useStore`, and forwards `run` / `pause` / `reset` / `ack` / `setBackpressure` to the active handle. Setup state (parameter values, initial marking, scenarios, dt, maxTime) stays React-side as before — these are inputs to `createSimulation`, not Core concerns. The old `useSimulationWorker` hook + its test (`src/simulation/worker/use-simulation-worker.{ts,test.ts}`) are deleted. - -8 unit tests cover both flavours: ready/init via mock transport, init error, single + batch frame appends, complete event, control message round-trip, idempotent dispose, abort during init, plus a fake-`Worker` test for the `createWorker` factory route. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/06-react-bindings.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/06-react-bindings.md deleted file mode 100644 index e7d78623986..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/06-react-bindings.md +++ /dev/null @@ -1,261 +0,0 @@ -# 06 — React bindings (`/react`) and how `/ui` consumes them - -The `/react` layer exposes a `` that takes a Core instance and mounts every bridge provider. The `/ui` layer's `` is then a thin wrapper that creates the instance and renders the editor. - -## 6.1 `` - -```tsx -// @hashintel/petrinaut/react -export const PetrinautProvider: FC<{ instance: Petrinaut; children: ReactNode }> = ({ instance, children }) => ( - - - - - - - - {children} - - - - - - - -); -``` - -Each existing provider is rewritten as a **thin bridge**: it reads from a Core stream via `useSyncExternalStore` and republishes through the existing React Context shape. That keeps the consumer-facing context API stable, so `/ui` files don't change. - -## 6.2 Hook surface - -### 6.2.1 Rules every hook must follow - -1. **One concern per hook.** A hook returns a single value, a single coherent action bundle, or subscribes to a single event stream. No "return everything" hooks (the only exception is `usePetrinautInstance` as the explicit escape hatch). -2. **Read hooks return plain values.** Never return a `ReadableStore` — do the `useStore` bridge inside (§6.3). Consumers should not see Core's stream primitive. -3. **Action hooks return stable function references.** Either expose the instance method directly (already stable) or wrap once. Never allocate new closures per render. -4. **Selector hooks let consumers narrow re-renders.** When a value is large (the whole `SDCPN`), expose a `*Selector(selector)` variant alongside the full hook so consumers can subscribe to only their slice. -5. **Subscription hooks for events take a callback and return `void`.** Events have no current value — there's nothing to read between renders. Pattern: `useNotifications(handler)`, `usePetrinautPatches(handler)`. Callback subscribes in `useEffect`, unsubscribes on cleanup. -6. **All hooks require `` as an ancestor.** Throw a clear error when missing — never silently return defaults. -7. **No mutations during render.** Hooks read; mutations happen in handlers and effects. -8. **Async actions return promises.** No fire-and-poll patterns. `startSimulation` returns `Promise`. -9. **Naming.** `use[]` for reads; `useActions` for action bundles; `use` for event subscriptions. No abbreviations, no Hungarian prefixes. -10. **No transitive re-renders.** Changing one slice (e.g. simulation frame count) must not re-render consumers of an unrelated slice (e.g. LSP diagnostics). Each hook subscribes only to what it returns. -11. **Hooks live in `/react` only.** `/ui` consumes them; never re-implements them. `/ui` files don't call `useSyncExternalStore` or import from `/core` directly. - -### 6.2.2 The full hook surface - -```ts -// ─── Instance access (escape hatch) ─────────────────────────────────────── -usePetrinautInstance(): Petrinaut; - -// ─── Document handle ────────────────────────────────────────────────────── -useDocumentId(): DocumentId; -useDocumentState(): DocHandleState; // "loading" | "ready" | "deleted" | "unavailable" -useIsDocumentReady(): boolean; // sugar over state === "ready" - -// ─── Document content ───────────────────────────────────────────────────── -usePetrinautDefinition(): SDCPN; -usePetrinautDefinitionSelector(selector: (sdcpn: SDCPN) => T): T; -useMutate(): (fn: (draft: SDCPN) => void) => void; -useSetTitle(): (title: string) => void; -usePetrinautPatches(handler: (patches: PetrinautPatch[]) => void): void; - -// ─── Simulation ─────────────────────────────────────────────────────────── -// Simulations are NOT on the Petrinaut instance — see 05-simulation.md §5.1. -// The bridge provider holds the active `Simulation | null` in React state, -// exposes its stores via these hooks, and forwards control calls. -useSimulation(): Simulation | null; // current handle, or null if no run started -useSimulationStatus(): SimulationState; // "Initializing" | "Ready" | "Running" | "Paused" | "Complete" | "Error" -useSimulationFrameCount(): number; -useSimulationLatestFrame(): SimulationFrame | null; -/** - * Returns a stable wrapper that builds the SimulationConfig from the - * provider's setup state (parameterValues, initialMarking, …) and calls - * `createSimulation` with the configured `createWorker`. - */ -useStartSimulation(): (overrides?: Partial) => Promise; -useSimulationActions(): { // null-safe wrappers over the active sim - run: () => void; - pause: () => void; - reset: () => void; - ack: (frameNumber: number) => void; - setBackpressure: (cfg: BackpressureConfig) => void; -}; - -// ─── Playback ───────────────────────────────────────────────────────────── -usePlaybackState(): PlaybackState; // { playState, frameIndex, speed, mode } -usePlaybackFrameIndex(): number; // selector hook for the hot path -usePlaybackActions(): { - play: () => void; - pause: () => void; - stop: () => void; - setSpeed: (s: number) => void; - setFrameIndex: (i: number) => void; - setMode: (m: PlayMode) => void; -}; - -// ─── LSP ────────────────────────────────────────────────────────────────── -useDiagnostics(): { byUri: Map; total: number }; -useDiagnosticsForUri(uri: DocumentUri): Diagnostic[]; -useTotalDiagnosticsCount(): number; -useLspActions(): { - notifyDocumentChanged: (uri: DocumentUri, text: string) => void; - requestCompletion: (uri: DocumentUri, position: Position) => Promise; - requestHover: (uri: DocumentUri, position: Position) => Promise; - requestSignatureHelp: (uri: DocumentUri, position: Position) => Promise; - initializeScenarioSession: (params: ScenarioSessionParams) => void; - updateScenarioSession: (params: ScenarioSessionParams) => void; - killScenarioSession: (sessionId: string) => void; -}; - -// ─── Notifications ──────────────────────────────────────────────────────── -useNotifications(handler: (n: Notification) => void): void; - -// ─── Settings / mode ────────────────────────────────────────────────────── -useIsReadOnly(): boolean; -``` - -### 6.2.3 Why split read hooks from action bundles - -State and actions live on different timescales. Read hooks change the rendered tree on every value change; action bundles never do, because they return stable function references. Bundling them together (the "useState-style" tuple) means consumers re-render on state change even when they only call actions, which fights React's render model. - -The split also makes the dependency surface clear: a component that only mutates uses `useMutate()` and never subscribes to the definition. - -### 6.2.4 Pitfalls and conventions - -- **Selectors must be stable across renders.** React Compiler memoizes inline functions in this codebase, so `usePetrinautDefinitionSelector(s => s.transitions[0])` is fine. Outside the compiler, consumers would need `useCallback`. -- **Subscription hooks accept unstable callbacks.** Inside, the callback is captured in a ref and re-read on each event, so consumers can pass inline arrows freely. -- **`useSimulationActions()` is null-safe.** When `instance.simulation.get()` returns `null`, calling `actions.run()` is a no-op (with a dev warning), not a throw. Callers that need the active handle directly use `useSimulation()` and check. -- **Don't compose hooks that subscribe to the same store twice.** A `useDiagnosticsForUri(uri)` and a `useDiagnostics()` in the same component share a single subscription internally — but two separate `useStore(instance.lsp.diagnostics)` calls would each register a subscription. Acceptable, but worth a perf note. -- **`usePetrinautPatches(handler)` does not include the local snapshot.** If the consumer needs the resulting state, they should also subscribe to `usePetrinautDefinition` (or use the snapshot in scope). - -## 6.3 Bridging `ReadableStore` to `useSyncExternalStore` - -Core's `ReadableStore.subscribe` passes the new value to its listener; React's `useSyncExternalStore` passes nothing. The adapter is one function: - -```ts -// @hashintel/petrinaut/react/lib/use-store.ts -import { useSyncExternalStore } from "react"; -import type { ReadableStore } from "@hashintel/petrinaut/core"; - -export function useStore(store: ReadableStore): T { - return useSyncExternalStore( - (onStoreChange) => store.subscribe(() => onStoreChange()), - store.get, - ); -} - -export function useStoreSelector( - store: ReadableStore, - selector: (value: T) => U, -): U { - return useSyncExternalStore( - (onStoreChange) => store.subscribe(() => onStoreChange()), - () => selector(store.get()), - ); -} -``` - -Every hook in `/react` uses these two helpers. Consumers never call `useSyncExternalStore` directly. - -### Note: oxlint `unbound-method` and method typing - -Passing `store.get` (or any Core method) as a function reference triggers the `typescript-eslint(unbound-method)` rule under oxlint, even though our methods don't use `this`. Two fixes: - -1. **Inline-wrap at the call site** — `() => store.get()`. Done in the Phase 0 spike. -2. **Type the method with `this: void`** — preferred long-term: declare `get(this: void): T;` in `ReadableStore` (and similarly on `Petrinaut.mutate`, etc.). Lets consumers and bridges pass the method reference directly without wrapping. - -Phase 2 should adopt approach (2) when finalising the Core types, so `/react` and downstream consumers don't have to wrap. Spike code uses (1) as the temporary form. - -### LSP example - -The existing `LanguageClientProvider` becomes a ~10-line bridge: - -```tsx -const instance = usePetrinautInstance(); -const diagnostics = useStore(instance.lsp.diagnostics); - -const value: LanguageClientContextValue = { - diagnosticsByUri: diagnostics.byUri, - totalDiagnosticsCount: diagnostics.total, - notifyDocumentChanged: instance.lsp.notifyDocumentChanged, - requestCompletion: instance.lsp.requestCompletion, - requestHover: instance.lsp.requestHover, - requestSignatureHelp: instance.lsp.requestSignatureHelp, - initializeScenarioSession: instance.lsp.initializeScenarioSession, - updateScenarioSession: instance.lsp.updateScenarioSession, - killScenarioSession: instance.lsp.killScenarioSession, -}; -``` - -Monaco bindings (`monaco/sync/*` adapters that today call into `LanguageClientContext`) keep consuming the React context — they don't touch Core directly. This satisfies the rule from the README: `ui` → `react` → `core`, never skip a layer. - -## 6.4 `/ui`'s top-level component - -```tsx -// @hashintel/petrinaut/ui -export type PetrinautProps = { - handle: PetrinautDocHandle; // replaces today's petriNetDefinition + mutatePetriNetDefinition - readonly?: boolean; - hideNetManagementControls?: boolean; - viewportActions?: ViewportAction[]; - - // Optional host-supplied worker factories. When provided, they replace the - // bundled inlined-blob defaults — see "Host-controlled workers" below. - simulationWorkerFactory?: WorkerFactory; - lspWorkerFactory?: LspWorkerFactory; - - // …other UI props… -}; - -export const Petrinaut: FC = (props) => { - const instance = useMemo(() => createPetrinaut({ document: props.handle, readonly: props.readonly }), [props.handle, props.readonly]); - useEffect(() => () => instance.dispose(), [instance]); - - return ( - - - - - - - ); -}; -``` - -The existing context API to child components stays roughly the same — only the *source* of context values changes (from local React state to the Core instance). That keeps the diff inside `views/` and `components/` close to zero. - -### 6.4.1 Host-controlled workers (locked) - -Both the simulation and the language-server workers ship inlined as blob URLs out of the box (via Vite's `?worker&inline` directive against the worker source files). That works for source-built consumers — Storybook, the dev server, anyone vendoring the package. It can fail for consumers that pull the published dist through a host bundler that mishandles the inlined worker (esbuild pre-bundling rewriting dynamic imports, CSPs that block `blob:` URLs, etc.). - -For those cases, `` exposes optional factory props: - -```tsx -type WorkerFactory = () => Worker | Promise; -type LspWorkerFactory = WorkerFactory; - - -``` - -The factories are plumbed through `` to `` / ``. When omitted, the providers fall back to `createSimulationWorker` / `createLanguageServerWorker` (the bundled inlined-blob defaults). - -`` creates the language client inside a `useEffect` (with cleanup that calls `client.dispose()`), not in `useState`'s lazy initializer. This is required for React StrictMode dev: a lazy init would run twice — leaving one of the two clients orphaned (with a leaked worker) and the other one without an `initialize` message ever delivered. The effect-driven pattern lets each StrictMode mount cycle's client be cleaned up individually; only the survivor receives `initialize`. - -## 6.5 React Compiler interaction - -The library uses React Compiler with `panicThreshold: "critical_errors"`. Two things to watch: - -- **Instance handles.** `createPetrinaut()` returns a stable object. Compiler memoization of derived values built off `instance.foo` is fine because `instance` is stable for the component's lifetime. -- **`useSyncExternalStore` selectors.** These are read in render but already understood by the compiler. No `"use no memo"` should be needed. - -**Confirmed by the Phase 0 spike.** The `` wrapper builds and runs under React Compiler with no opt-outs and no panic-threshold violations. Both bullets above held up in practice. Phase 3 should retain this property — opt out only where the compiler genuinely can't reason about a hook. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/07-open-questions.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/07-open-questions.md deleted file mode 100644 index 3c0668949b1..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/07-open-questions.md +++ /dev/null @@ -1,76 +0,0 @@ -# 07 — Open questions - -The hot file while the RFC is in flight. Each question lists its current status. Decided questions are struck through with a pointer to where the conclusion lives. - -## Q1. ~~Document ownership~~ - -**Decided.** Core never owns the document. It is given a `PetrinautDocHandle` — a single interface that adapts plain JSON, Immer-backed state, Automerge `DocHandle`, or anything else. The handle exposes `doc()`, `change(fn)`, `subscribe(listener)`, plus `id`, `state`, `whenReady()`. Subscriptions deliver `{ next, patches?, source? }` events. Repos, storage, and sync are host concerns — Petrinaut never sees a Repo. See [04-core-instance.md](./04-core-instance.md) §4.1 / §4.4. - -### Q1.b. ~~Patch type~~ - -**Decided.** Petrinaut defines its own minimal `PetrinautPatch` type modeled on Immer's `produceWithPatches` shape (array path, `op: "add" | "remove" | "replace"`). Immer-backed handles (including `createJsonDocHandle`) emit it natively. Automerge consumers convert via a small switch in their adapter. No runtime dependency. See [04-core-instance.md](./04-core-instance.md) §4.1. - -### Q1.c. Text-range edits — deferred - -`PetrinautPatch` cannot represent sub-string operations; a one-character edit inside a long code block emits a `replace` with the entire new string. Acceptable for single-user editing today; will need addressing if/when: - -- patch volume becomes a real problem on large code blocks, or -- collaborative code editing is introduced (character-level CRDTs require splice-on-string ops). - -**Status:** deferred. Likely follow-up RFC; sketches in [04-core-instance.md](./04-core-instance.md) §4.1 ("Known limitation: text-range edits"). - -## Q2. ~~Stream primitive~~ - -**Decided.** `ReadableStore` (`get()` + `subscribe(listener: (value: T) => void)`) for state slices; `EventStream` (`subscribe(listener: (event: T) => void)`) for one-shot events. The listener receives the value on every call. React adapts via a `useStore(store)` helper that wraps `subscribe` to drop the value, so `useSyncExternalStore`'s ping shape is satisfied. See [04-core-instance.md](./04-core-instance.md) §4.2 and [06-react-bindings.md](./06-react-bindings.md) §6.3. - -## Q3. Editor / UI state in core? - -Selection, current mode, panel layout, user settings — does any of this belong in core? - -- **Default:** no. Core doesn't need to know which node the user is highlighting. -- **Caveat:** collaborative cursors / multiplayer presence might want a thin selection slice in core. -- **Status:** open. Decision affects [03-layering.md](./03-layering.md) §3.4. - -## Q4. ~~Undo/redo~~ - -**Decided.** Undo/redo lives on the handle as an optional `history` field. `createJsonDocHandle` ships a default Immer-based implementation (bounded stack, default 50 entries, truncate-on-mutate, `goToIndex` for version-history-style navigation). Other handles (Automerge, custom) implement their own. The host no longer wires history separately — the `UndoRedoContextValue` pass-through goes away. See [04-core-instance.md](./04-core-instance.md) §4.1 "History (locked)". - -**Coalescing** (typing-burst → single undo entry) is a known follow-up — sketched in §4.1 "Coalescing — deferred", scheduled for Phase 3. - -## Q5. ~~Worker bundling~~ - -**Decided.** `/core` accepts a caller-provided `createWorker` factory; the worker source is exposed as a `./core/simulation.worker` sub-entry that consumers resolve via `new URL(..., import.meta.url)`. `/ui` supplies a default factory so most consumers don't see this. See [05-simulation.md](./05-simulation.md) §5.2. - -## Q6. ~~Lazy subsystems~~ - -**Decided / superseded.** Simulation is now decoupled from the `Petrinaut` instance entirely — the question of "eager vs lazy spin-up on the instance" no longer applies. `createSimulation` is its own top-level function; consumers who don't need simulation never call it. See [05-simulation.md](./05-simulation.md) §5.1. - -## Q7. ~~Notifications~~ - -**Decided — folded into `simulation.events`.** The standalone `NotificationsProvider` / `useNotifications` system has been removed. Its only producer was "Simulation complete" — a single toast triggered when the simulation finished. That signal is already on the Core simulation handle's `events: EventStream` stream; the React provider now subscribes to it directly and renders toasts via `` inline. No parallel notification infrastructure needed today. - -If a future use case introduces non-simulation notifications (LSP type-check failed, scenario imported, etc.), the right shape is to surface those via the same pattern — domain handle exposes an event stream, React provider renders a toaster — not to reinstate a generic notifications system. - -## Q8. ~~Error tracker~~ - -**Removed for now.** The `ErrorTrackerContext` was published by the demo site but nothing inside the package consumed it — dead scaffolding. Left in place pending a real consumer (e.g. Core surfacing simulation/LSP worker exceptions through a host-provided tracker). When reintroduced, the interface lives in `/core` (pure) and the React Context lives in `/react`. See conversation thread in commit history for the rationale. - -## Q9. ~~Package shape~~ - -**Decided.** One package with an `exports` map: `./core`, `./react`, `./ui`, plus the worker sub-entry `./core/simulation.worker`. Per-entry externals declared via the build's `external` config so `/core` consumers don't transitively pull React, Monaco, or `@xyflow/react`. To be implemented in Phase 5. - -## Q10. ~~Examples & file-format purity~~ - -**Decided.** Verification task — not a design question. Will be performed during Phase 5 when the `/core` bundle is split out: any DOM / React import inside `/core/` files will fail the build's externals check. `examples/`, `validation/`, `lib/deep-equal.ts`, `clipboard/serialize.ts`, `clipboard/paste.ts`, `file-format/import-sdcpn.ts` are the candidates to grep for `react`, `document`, `window` imports. - ---- - -## Iteration discipline - -When a question is resolved: - -1. Move the conclusion into the relevant chapter. -2. Strike through the question here with `~~Q?. …~~` and add **Decided.** + a pointer to the new home. -3. If the conclusion changes a chapter's content, update that chapter in the same edit. - -This keeps `07-open-questions.md` honest — at any point, the un-struck questions are exactly the things still up for debate. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/08-migration.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/08-migration.md deleted file mode 100644 index da5dbb9a5f0..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/08-migration.md +++ /dev/null @@ -1,337 +0,0 @@ -# 08 — Migration plan - -High-level. Will refine after the open questions in [07-open-questions.md](./07-open-questions.md) are settled. - -## Phase 0 — Proof-of-concept spike (done) - -A thin slice landed alongside the existing code path to validate the core concepts before the full reorganisation. Files added (no existing files modified): - -- `src/core/handle.ts` — `PetrinautDocHandle`, `PetrinautPatch`, `DocChangeEvent`, `ReadableStore`, `PetrinautHistory`, `HistoryEntry`, and `createJsonDocHandle` (Immer-backed) with optional bounded history stack. -- `src/core/instance.ts` — `Petrinaut` type, `createPetrinaut`, `EventStream`, `definition` store, `patches` event stream, `mutate`, `dispose`. -- `src/core/handle.test.ts` — 15 smoke tests covering handle lifecycle, mutations, patches, no-op skipping, readonly-mode, and the history sub-API (undo/redo/goToIndex/clear, limit enforcement, redo-stack truncation, change events on undo/redo). -- `src/react/use-store.ts` — `useStore` / `useStoreSelector` adapters over `useSyncExternalStore`. -- `src/react/instance-context.ts` — `PetrinautInstanceContext`. -- `src/react/use-petrinaut-instance.ts` — escape-hatch hook that throws if no `` is mounted. -- `src/ui/petrinaut-next.tsx` — `` that creates a Core instance and bridges to the existing prop-shaped ``. Also bridges `handle.history` (when present) into the existing `UndoRedoContextValue` so the editor's top-bar undo/redo button, version-history dropdown, and Cmd/Ctrl+Z keyboard shortcut all work without consumer wiring. -- `src/petrinaut.stories.tsx` — two stories (`HandleSpike`, `HandleSpikeWithSir`) with an on-screen patch-log overlay. -- `src/main.ts` — re-exports `createJsonDocHandle`, `createPetrinaut`, ``, and the new types so consumers of `@hashintel/petrinaut` can use the handle-driven path today. -- `package.json` — `immer: 10.1.3` added to `dependencies`. - -**Downstream consumer updated in the same pass:** - -- `apps/petrinaut-website/src/main/app.tsx` — switched from `` (prop-shaped) to `` (handle-driven). Maintains a per-net `PetrinautDocHandle` cache; mirrors handle changes to localStorage via `handle.subscribe`. -- `apps/petrinaut-website/src/main/app/use-undo-redo.ts` — **deleted.** Each handle owns its own history; the website-level history hook is no longer needed. Per-net history is preserved across net switches automatically. - -**Validated:** - -- `useSyncExternalStore` works under React Compiler with zero `"use no memo"` opt-outs. -- `createJsonDocHandle` round-trips: mutations → Immer patches → `PetrinautPatch[]` → subscribers in the editor + a story-level patch log. -- The full editor (`EditorView`, all existing providers) renders unchanged when fed via the bridge. -- Build, type-check, lint, and 462 unit tests all pass. - -**Issues surfaced (queued for later phases):** - -- `oxlint(unbound-method)` flags `store.get` and `instance.mutate` when passed as references. Spike used arrow-wrapping; Phase 2 should switch to `this: void` typing on Core methods to remove the wrapping. See [06-react-bindings.md](./06-react-bindings.md) §6.3. -- `enablePatches()` from Immer must be called at `/core` module load — `handle.ts` does this; documented in [04-core-instance.md](./04-core-instance.md) §4.1. -- No-op mutations don't emit (Immer returns an empty patch array). Subscribers may rely on "every event is a real change." Documented as a contract. - -## Phase 1 — Reorganise without behaviour change - -Done incrementally, alongside subsystem-by-subsystem work: - -1. ✅ `src/core/`, `src/react/`, `src/ui/` directories created (Phase 0). -2. **Simulation subsystem moved (done):** - - `src/simulation/simulator/` → `src/core/simulation/simulator/` - - `src/simulation/worker/` → `src/core/simulation/worker/` - - `src/simulation/compile-scenario.{ts,test.ts}` → `src/core/simulation/` - - `src/simulation/compile-metric.{ts,test.ts}` → `src/core/simulation/` - - `src/simulation/metric-state.ts` → `src/core/simulation/` - - `src/simulation/sandbox.ts` → `src/core/simulation/` - - `src/simulation/README.md` → `src/core/simulation/` - - `src/simulation/context.ts` split: pure types (`SimulationFrame`, `InitialMarking`, `SimulationFrameState_*`, `SimulationFrameState`) extracted to `src/core/simulation/types.ts`; React glue (`SimulationContext`, `SimulationContextValue`, legacy `SimulationState` enum) → `src/react/simulation/context.ts`. - - `src/simulation/provider.tsx` → `src/react/simulation/provider.tsx`. - - All ~22 consumer files updated to the new paths. `src/simulation/` directory removed. -3. **Pending:** validation, file-format/import, clipboard/serialize, examples, lib/deep-equal, lsp/worker still in old locations. To be moved when their subsystems get worked on. -4. **Pending:** `views/`, `components/`, `monaco/`, `notifications/` (rendering parts), `resize/` → `src/ui/`. Mechanical move; deferred until `/ui` becomes a separate bundle. -5. **Pending:** providers, contexts, and hooks → `src/react/`. Same — moves alongside subsystem work. - -## Phase 2 — Build the Core instance - -1. ~~Implement `createPetrinaut()` returning a thin façade over existing logic.~~ Done in Phase 0. -2. ~~Implement `subscribe + getSnapshot` stores backed by current state holders.~~ Done in Phase 0. -3. ~~Define `SimulationTransport` + `createWorkerTransport(createWorker)`~~ ([05-simulation.md](./05-simulation.md) §5.1) — done. `createInlineTransport()` deferred — interface is shape-compatible, can ship later without API change. -4. Add the `./core/simulation.worker` sub-entry in `package.json` `exports` ([05-simulation.md](./05-simulation.md) §5.2). **Pending** — only relevant once `/core` is its own bundle (Phase 5). -5. ~~Implement `instance.startSimulation(cfg)` returning a `Simulation` handle whose stores wrap the transport messages~~ ([05-simulation.md](./05-simulation.md) §5.3 / §5.4) — done. -6. ~~Wire `signal` / `dispose()` cancellation paths~~ ([05-simulation.md](./05-simulation.md) §5.5) — done. -7. Move playback frame loop out of `PlaybackProvider` into `instance.playback`. (`requestAnimationFrame` is browser-only — for non-browser consumers we expose a `tick()` method or accept a custom scheduler.) -8. Move LSP wrapping out of `LanguageClientProvider` into `instance.lsp`. - -### Phase 2a — Simulation transport (done) - -Files added: - -- `src/core/simulation/transport.ts` — `SimulationTransport` interface, `createWorkerTransport(createWorker)`, `WorkerFactory`. Async-factory friendly: messages sent before worker boot are queued and flushed. -- `src/core/simulation/simulation.ts` — `Simulation` interface, **`createSimulation(config)`** factory. Accepts either a `createWorker: WorkerFactory` or a pre-built `transport: SimulationTransport` (discriminated union). Promise resolves on `ready`; rejects on init `error` or `AbortSignal` abort. `dispose()` is idempotent and tears down the transport. -- `src/core/simulation/index.ts` — barrel re-export. -- `src/core/simulation/simulation.test.ts` — 8 unit tests covering both flavours (mock transport + fake-`Worker` factory route). - -**Decoupled from `createPetrinaut`.** Simulations operate on a frozen SDCPN snapshot — they don't need the live document. So `createPetrinaut` does **not** take a simulation config, and the `Petrinaut` instance has no `simulation` field. To run a simulation, call `createSimulation({ sdcpn, ... })` directly (passing `instance.handle.doc()` if you have an instance, or any other SDCPN value). Multiple simulations can coexist against one document. - -Public exports added in `main.ts`: `createSimulation`, `createWorkerTransport`, plus the `Simulation*` types, `CreateSimulationConfig`, `SimulationTransport`, and `WorkerFactory`. - -### Phase 4 — UI relocation (done) - -The visual editor and its supporting subsystems moved from flat `src/` into `src/ui/`: - -| From | To | Files | -| ---- | -- | ----- | -| `src/monaco/` | `src/ui/monaco/` | 9 | -| `src/views/` | `src/ui/views/` | 88 | -| `src/components/` | `src/ui/components/` | 30 | -| `src/resize/` | `src/ui/resize/` | 1 | -| `src/constants/` | `src/ui/constants/` | 4 | -| `src/petrinaut.tsx` | `src/ui/petrinaut.tsx` | 1 | -| `src/petrinaut.stories.tsx` | `src/ui/petrinaut.stories.tsx` | 1 | -| `src/petrinaut-story-provider.tsx` | `src/ui/petrinaut-story-provider.tsx` | 1 | -| `src/index.css` | `src/ui/index.css` | 1 | -| `src/fontsource.d.ts` | `src/ui/fontsource.d.ts` | 1 | - -All 137 files moved via `git mv` (history preserved). - -**Import-path fix-ups** applied via two perl passes: - -1. Inside files now under `src/ui/`, paths to non-moved dirs (`core`, `react`, `state`, `clipboard`, `lib`, `examples`, `validation`, `file-format`, `hooks`, `error-tracker`, `types`) got one extra `../` because they're now one level deeper. Two false positives — `views/SDCPN/hooks/` and `views/Editor/lib/` are nested directories with the same name as top-level dirs — manually reverted in three transition-node files. -2. Files outside `/ui/` referencing the moved dirs (e.g. `state/mutation-provider.tsx` referring to `views/SDCPN/styles/styling`) had `ui/` inserted into the path. -3. Top-level config files (`panda.config.shared.ts`) updated by hand. -4. Two pre-existing files in `src/ui/` (`petrinaut-next.tsx`, `index.ts`) had their `../core/...` etc. paths un-deepened — they were already at the correct depth and shouldn't have been touched by the bulk pass. - -**Verified**: yarn lint:tsc + yarn lint:eslint clean; yarn build succeeds; 485 unit tests pass. - -**Layer rule status**: spot-checked that `/ui` files don't import `/core` *values* directly. They do import `/core` *types* (e.g. `SDCPN`, `PetrinautDocHandle`, `Diagnostic`), which is fine — type-only imports don't create runtime dependencies. The "no `/ui` → `/core` value imports" rule is honored. A formal audit (e.g. an eslint plugin enforcing layer direction) is a future cleanup, not blocking. - -**Pending Phase 1 moves** (no forcing function — done as a single tidiness commit later if desired): - -- ~~`src/state/` → `src/react/state/`~~ — done (post-Phase 3b). All 13 files (`*-context.ts`, `*-provider.tsx`, `selection.ts`, the `use-*` hooks) moved via `git mv`. Imports rewritten across 67 consumer files via perl. The two pre-existing `react/state → ui/constants/ui` value imports were resolved by extracting `panel-defaults.ts` into `react/state/` (and `node-dimensions.ts` into `ui/views/SDCPN/`); the layer-direction lint rule now passes cleanly. -- ~~`src/lib/` → split between `src/core/lib/` and `src/ui/lib/`~~ — done. `deep-equal` (and its test) moved to `src/core/lib/`; `calculate-graph-layout`, `hsl-color`, `snap-position-to-grid`, `split-pascal-case`, `viewport` (and tests) moved to `src/ui/lib/`. `src/lib/` is gone. The layer-direction lint rule now covers these files automatically (`/core/lib/**` is part of `/core/**`, `/ui/lib/**` is part of `/ui/**`). -- ~~`get-connections` to `/core/lib/`~~ — done in a follow-up. The function is pure graph traversal but pulled in `generateArcId` and `SelectionMap` from `react/state/`, which initially forced it to land in `react/state/`. Those two pure pieces were extracted: the ARC ID conventions (`ARC_ID_PREFIX`, `ARC_ID_SEPARATOR`, `generateArcId`, `ArcIdPrefix`) live in `src/core/arc-id.ts`, and the SDCPN-shaped selection types (`SelectionItemType`, `SelectionItem`, `SelectionMap`, `PanelTarget`, `parseArcId`) live in `src/core/types/selection.ts`. With those upstream, `get-connections` itself moved to `src/core/lib/get-connections.ts`. `src/react/state/selection.ts` was deleted; `src/react/state/sdcpn-context.ts` shrank to just its React-context shape. ~30 consumer imports across `/ui`, `/react`, and `/clipboard` repointed at the new `/core` paths. -- ~~`src/hooks/` → `src/react/hooks/`~~ — done. The 4 files (`use-default-parameter-values`, `use-element-size`, `use-latest`, `use-stable-callback`) are all React hooks; they fold into the existing `/react/hooks/` directory next to the public hook surface from Phase 3a. The pure pieces of `use-default-parameter-values` were already extracted to `core/parameter-values.ts` during Phase 5 — the React-aware re-exports stay alongside the hook for back-compat. -- ~~`src/clipboard/` → split between `src/core/clipboard/` (pure) and `src/ui/clipboard/` (DOM)~~ — done. `serialize` / `paste` / `deduplicate-name` / `types` (and tests) moved to `/core/clipboard/`. `clipboard.ts` (the `navigator.clipboard.readText/writeText` wrappers) moved to `/ui/clipboard/`. `MutationContext.pasteEntities` removed (was a `/react→/ui` leak through `pasteFromClipboard`); the only consumer (`use-keyboard-shortcuts.ts`) now composes `pasteFromClipboard(instance.mutate)` inline alongside the `isReadonly` guard. -- ~~`src/file-format/` → split between `/core/file-format/` (pure) and `/ui/file-io/` (DOM)~~ — done. Pure conversion (`serialize-sdcpn`, `parse-sdcpn-file`, `sdcpn-to-tikz`, `types`, `remove-visual-info`) lives in `/core/file-format/`; browser-bound import/export wrappers live in `/ui/file-io/` (deliberately renamed from `file-format` — the `/ui` side does file I/O, not format definition). New `/ui/lib/download-blob.ts` exposes a generic `downloadBlob({content, mimeType, filename})` + `timestampedFilename(title, ext)` so future format exporters don't duplicate the DOM plumbing. -- `src/examples/` — **stay at root for now** (decision: 2026-05-05). The 6 SDCPN samples will eventually move out of the editor's "Load example" submenu, ship as a separate `@hashintel/petrinaut/examples` subpath export, and surface in the demo site via per-example read-only routes (e.g. `/examples/sir-model`). Skip during the Phase 1 sweep; revisit when that flow lands. -- ~~`src/validation/` → `src/core/validation/`~~ — done. The 3 `validate*` functions (display-name / entity-name / variable-name) + tests + README moved as a unit; pure zod-based, no internal cross-layer imports. The 3 /ui consumers (Properties Panel subviews) repointed at `/core/validation/...`. -- ~~`src/error-tracker/` → `src/react/error-tracker-context.ts`~~ — done. `ErrorTracker` is a React context (host plugs Sentry / Datadog in via ``), so the layer-correct home is `/react`. Renamed from `error-tracker.context.ts` to `error-tracker-context.ts` to match the existing `/react/instance-context.ts` and `/react/net-management-context.ts` naming. Currently nothing inside petrinaut reads it — the wiring fix (worker init failures call `errorTracker?.captureException`, error boundaries report caught React errors) is bundled with [FE-694](https://linear.app/hash/issue/FE-694/petrinaut-surface-simulation-init-errors-to-the-user). Considered placing it in `/core` so the simulation worker / handle could call it directly, but rejected: `/core` already has typed error channels (`simulation.events`, `lsp.diagnostics`, `handle.state`) for everything observable, and a generic capture callback would duplicate them. ErrorTracker is observability/host-integration — a `/react` concern. - -### Phase 5 — Public entry points (done) - -The headline deliverable: `@hashintel/petrinaut/core`, `/react`, `/ui` are now real subpath imports backed by separate bundles. - -**Per-entry barrels** in `src/`: - -- `src/core/index.ts` — re-exports the headless surface: handle / instance / simulation / lsp / playback factories + types, `parameter-values` utilities, `SDCPNItemError`, domain types. -- `src/react/index.ts` — re-exports the React bindings: ``, `usePetrinautInstance`, `useStore`, `useStoreSelector`, plus the full hook surface from `react/hooks/`. -- `src/ui/index.ts` — re-exports `` and (via re-export from `src/petrinaut.tsx`) the existing `` editor + `isSDCPNEqual`. -- `src/main.ts` — back-compat barrel; left unchanged so existing consumers (the website) keep working without import-path edits. - -**`package.json` `exports` map** with four entries: `.`, `./core`, `./react`, `./ui`, plus `./package.json` for tooling. - -**Multi-entry build config** in `vite.config.ts`: - -- `lib.entry` is now an object mapping each alias (`main`, `core`, `react`, `ui`) to its entry file. -- `lib.fileName` template emits `${entryName}.js` for each. -- The `dts` plugin emits per-entry `.d.ts` bundles (current quirk: filenames come out as `core.d.d.ts` instead of `core.d.ts` — cosmetic; the `exports` map points at the actual paths). - -**Externals tightened**: `vscode-languageserver-types` added to the `external` list. This is what unblocks the multi-entry build — when the upstream namespace-merged types are externalised, [sxzz/rolldown-plugin-dts#209](https://github.com/sxzz/rolldown-plugin-dts/issues/209) "Duplicated export" errors disappear because the plugin no longer tries to inline the upstream `.d.ts` into multiple bundles. LSP types now ship as `import type { … } from "vscode-languageserver-types"` in the consumer's resolved type tree, which matches how every other LSP-using library handles it. - -**Phase 5 prep audit** (the only forced cleanup): - -- `core/simulation/simulator/build-simulation.ts` was importing `deriveDefaultParameterValues` and `mergeParameterValues` from `hooks/use-default-parameter-values.ts` — a layer leak (`/core` reaching into `/hooks`). Extracted the pure functions into `src/core/parameter-values.ts`. The hook file now re-exports from there for back-compat. - -**Bundle sanity check**: - -- `dist/core.js` (4 KB stub + chunks): no `import "react"`. Confirms `/core` is genuinely React-free. -- `dist/react.js`: imports React; expected. -- `dist/ui.js`: imports React, Monaco, `@xyflow/react`; expected. - -**Not yet done** (deferred to Phase 4 or later): - -- The website (`apps/petrinaut-website`) still imports from `@hashintel/petrinaut` (the default `.` entry). It works via back-compat. Switching it to `/ui` is a one-line change but not required. -- The legacy `` editor still lives at `src/petrinaut.tsx`. Phase 4 moves it (and `views/`, `components/`, `monaco/`, `resize/`) into `src/ui/` properly. -- The remaining "no-forcing-function" Phase 1 moves (`examples/`, `validation/`, `clipboard/` split, `file-format/` split, `lib/` split, `constants/`, `state/`) — still pending. - -### Phase 3a — Public hook surface (done) - -The 25-hook surface from [06-react-bindings.md](./06-react-bindings.md) §6.2, implemented as thin wrappers over the existing React contexts: - -- `src/react/hooks/use-document.ts` — `usePetrinautDefinition`, `usePetrinautDefinitionSelector`, `useMutate`, `useSetTitle`, `useTitle`, `useDocumentId`, `useDocumentState`, `useIsDocumentReady`, `usePetrinautPatches`. -- `src/react/hooks/use-simulation.ts` — `useSimulationStatus`, `useSimulationFrameCount`, `useGetSimulationFrame`, `useSimulationActions`, `useSimulationParameters`, `useSimulationError`. -- `src/react/hooks/use-playback.ts` — `usePlaybackState`, `usePlaybackFrameIndex`, `usePlaybackSpeed`, `usePlaybackMode`, `useCurrentFrame`, `useCurrentViewedFrame`, `usePlaybackActions`, `useIsViewOnlyAvailable`, `useIsComputeAvailable`. -- `src/react/hooks/use-lsp.ts` — `useDiagnostics`, `useDiagnosticsForUri`, `useTotalDiagnosticsCount`, `useLspActions`. -- `src/react/hooks/index.ts` — barrel; re-exports `useIsReadOnly`, `useNotifications`, `usePetrinautInstance`, `useStore`, `useStoreSelector`. - -Most hooks read from the existing React contexts (`SDCPNContext`, `SimulationContext`, `PlaybackContext`, `LanguageClientContext`). A few read from the Petrinaut instance directly (`useMutate`, `useDocumentState`, `useDocumentId` via instance, `usePetrinautPatches`). - -`this: void` retrofit applied to `Petrinaut` (`mutate`, `dispose`) and `Simulation` (`run`, `pause`, `reset`, `ack`, `setBackpressure`, `getFrame`, `dispose`) — same treatment LSP already had. Removes the `unbound-method` complaint when consumers pass methods as references. - -**What's deferred to Phase 3b**: replacing the existing per-feature provider stack (``, ``, ``, ``, ``) with a single `` that mounts all the bridges. Today's hooks work inside the existing prop-shaped `` (whether mounted directly or via ``). - -### Phase 3b — Provider unification (done) - -`` is the single React entry point for mounting every bridge against a Core instance, and the legacy prop-shaped `` is now a thin adapter on top of it. - -- `src/react/petrinaut-provider.tsx` — ``. Composes (top-down): - - `PetrinautInstanceContext` (host for `usePetrinautInstance`) - - `NetManagementContext` (host-owned: `title / setTitle / existingNets / createNewNet / loadPetriNet`) - - `UndoRedoContext` — **only mounted when `instance.handle.history` is defined**, fed via `useHandleHistoryAsUndoRedo`. When absent, the outer `UndoRedoContext` (e.g. one wrapped by the legacy `` adapter) shows through unchanged. - - `SDCPNProvider` (bridge) - - `LanguageClientProvider` (bridge; keyed by `instance.handle.id` so a net switch fully resets the LSP worker) - - `SimulationProvider`, `PlaybackProvider` (bridges) - - `UserSettingsProvider`, `EditorProvider` (UI-state, unchanged) - - `MutationProvider` (bridge) -- `src/react/sdcpn-provider.tsx` — new bridge. Reads `usePetrinautInstance()` + `use(NetManagementContext)`, subscribes to `instance.definition` via `useStore`, republishes through the existing `SDCPNContext` shape (no signature changes for `/ui` consumers). -- `src/react/mutation-provider.tsx` — new bridge. Delegates writes to `instance.mutate`. No `mutatePetriNetDefinition` prop; everything is read from the instance. -- `src/react/net-management-context.ts` — new context for host-owned net actions / metadata. Single shape (`NetManagement`) so the SDCPN bridge can compose it with Core-derived values without splitting the consumer-facing context. -- `src/react/use-handle-history-as-undo-redo.ts` — extracted from `petrinaut-next.tsx`. -- `src/react/use-ephemeral-handle.ts` — adapter that turns prop-driven `{ petriNetId, petriNetDefinition, mutatePetriNetDefinition }` into a stable `PetrinautDocHandle`. The handle has no `history`; the legacy `` wraps `` in `` so the prop-supplied `undoRedo` shows through. -- `src/react/mutation-provider.test.tsx` — moved from `src/state/`, ported to mount `PetrinautInstanceContext` over a stub instance whose `mutate` is the spied function. 14 tests covering not-readonly mutations, readonly enforcement, and cascading deletes. - -`` was rewritten to skip the legacy prop-shaped `` entirely: it creates the Core instance and renders `` + `` + `` directly. Side-effect imports (fonts, `@xyflow/react/dist/style.css`, `index.css`) moved with it. - -The legacy `` (`src/ui/petrinaut.tsx`) was briefly kept as a thin adapter — building an ephemeral handle from its props and wrapping `` in `` — to preserve back-compat. **It has since been retired entirely** along with the supporting `src/react/use-ephemeral-handle.ts`. The only internal caller (`petrinaut-story-provider.tsx`) was migrated to `` + `createJsonDocHandle` (one handle per net id, kept in a ref map so per-net history survives switching). The demo site (`apps/petrinaut-website`) was already on ``. SDCPN domain types and `isSDCPNEqual` that the legacy file used to re-export (`Color`, `MinimalNetMetadata`, `MutateSDCPN`, `Place`, `SDCPN`, `Transition`, `ViewportAction`, …) are now re-exported explicitly from `main.ts`, so external consumers are unaffected. - -The duplicated prop-driven providers in `src/state/` are gone: - -- `src/state/sdcpn-provider.tsx` — **deleted**. -- `src/state/mutation-provider.tsx` — **deleted**. -- `src/state/mutation-provider.test.tsx` — **deleted** (replaced by the ported version under `src/react/`). -- `src/ui/petrinaut.tsx`, `src/react/use-ephemeral-handle.ts` — **deleted** (post-Phase 3b). - -``, `NetManagement`, and `NetManagementContext` are exported from the `/react` public surface. - -Verified: `yarn lint:tsc` clean, `yarn lint:eslint` clean, 485 unit tests pass, library build succeeds. - -**Post-retirement rename.** With the legacy editor gone, the "Next" suffix on the surviving entry was misleading — it implied an old-vs-new split that no longer exists. The component and its props were renamed `PetrinautNext → Petrinaut` and `PetrinautNextProps → PetrinautProps`, and the file `src/ui/petrinaut-next.tsx → src/ui/petrinaut.tsx`. All internal call sites (`/main.ts`, `/ui/index.ts`, story provider, stories, `usePetrinautInstance` error message, `useMutate` JSDoc) and the demo site (`apps/petrinaut-website/src/main/app.tsx`) were updated in the same change. Earlier sections of this document reference the old name historically; that's intentional — the chronology stays accurate. - -### Phase 3b polish — host-controlled workers + StrictMode fix (done) - -Two issues turned up while migrating the demo site to subpath imports: - -- The demo (production-style consumer of `@hashintel/petrinaut`'s dist) couldn't load the simulation / LSP workers reliably. The `?worker&inline` blob URLs that ship in dist load in some host bundler setups but not others, so the package needs to let hosts plug in their own worker construction. -- React StrictMode's dev-only re-invocation of `useState` lazy initializers was creating two LSP clients per mount: one orphan (leaks a worker) and one live one whose `initialize` message had been queued on the orphan's transport. Result: no diagnostics and no error surfaced. - -Both addressed: - -- `` and `` now accept an optional `workerFactory` prop. When provided, it replaces the bundled `createSimulationWorker` / `createLanguageServerWorker` defaults. The prop is plumbed through `` (`simulationWorkerFactory`, `lspWorkerFactory`) and `` (`/ui`) so hosts can supply factories at the editor entry. Storybook's source-built path keeps using the defaults. -- `` was rewritten to construct the `LanguageClient` inside a `useEffect` (with cleanup) rather than in `useState`'s lazy initializer. Each StrictMode cycle's client is now disposed individually; only the survivor receives `initialize`. Diagnostics flow correctly even with React's dev double-invocation. - -Side fixes captured in the same change set: - -- `vite.config.ts` adds `cssFileName: "main"` so the bundled CSS lands at `dist/main.css` (matching the `style` field), instead of vite's package-name default `dist/petrinaut.css`. -- `package.json` `exports` adds `./styles.css` (preferred) and `./dist/main.css` (back-compat for hash-frontend's existing import). -- `/ui` re-exports `ViewportAction`; `/react` re-exports `ErrorTrackerContext` + `ErrorTracker`. Both were previously only on the `main.ts` back-compat barrel; making them available on the per-layer surfaces is what unblocked the demo's switch to subpath imports. -- Diagnostic instrumentation: `[sim]` / `[sim:worker]` / `[sim:provider]` / `[playback]` / `[lsp]` console logs added at the worker boundary, the simulation handle, and the React provider edges. Useful for tracing init/start/pause flows in dev; safe to leave in for now (filterable in DevTools, no production impact beyond the existing ESLint `no-console` rule, which is suppressed at each call). - -**Consumer migration.** `apps/petrinaut-website` is fully on subpath imports (`@hashintel/petrinaut/core` for the handle / domain types, `/react` for `ErrorTrackerContext`, `/ui` for `Petrinaut` + `ViewportAction`). `apps/hash-frontend` stayed on the `@hashintel/petrinaut` back-compat barrel because its `tsconfig.moduleResolution` doesn't support subpath imports — a tsconfig change there would cascade to the rest of the app. Its editor wrapper migrated from the legacy prop-shaped API to the handle-based one (creates a `PetrinautDocHandle` per loaded net, mirrors `handle.doc()` into a snapshot for the existing save/load logic). - -`apps/petrinaut-website/src/main/app.tsx` (`DevApp`) gained a `"use no memo"` directive — same intentional ref-during-render pattern as ``. The React Compiler was treating `setStoredSDCPNsRef.current = setStoredSDCPNs` as a critical error. - -### Phase 2d — Playback timing model + provider rewire (done) - -Mirrors 2a/2b/2c — pure timing model lives in `/core`; React provider drives ticks and coordinates simulation lifecycle. - -New in `/core`: - -- `src/core/playback/playback.ts` — `createPlayback(initial?)` returning a `Playback` handle: - - `state: ReadableStore<{ playState, frameIndex, speed, mode }>` - - actions: `play / pause / stop / setFrameIndex / setSpeed / setMode / resetTiming / dispose` (all `this: void`-typed) - - `tick({ currentTime, dt, totalFrames, simulationDone })` — caller drives the loop. Returns `{ frameIndex, advanced, reachedEnd }`. Auto-pauses on reaching the end when `simulationDone` or `mode === "viewOnly"`. -- Pure helpers: `getPlayModeBackpressure(mode)`, `formatPlaybackSpeed(speed)`, the `PLAYBACK_SPEEDS` constant, and the `PlaybackState` / `PlayMode` / `PlaybackSpeed` enums — all moved out of `react/playback/context.ts`. -- `src/core/playback/index.ts` — barrel. -- `src/core/playback/playback.test.ts` — 18 unit tests covering speed/mode/state transitions, tick advancement, max-speed jump, auto-pause behaviour, no-op-when-not-Playing, dispose idempotency, resetTiming. - -Phase 1 reorg of playback files: - -- `src/playback/{provider.tsx, provider.test.tsx, context.ts, README.md}` → `src/react/playback/` -- `src/playback/` directory removed. - -`` rewritten as a bridge: - -- Holds the core `Playback` via `useState` lazy-init. -- `useStore(playback.state)` for snapshot subscription. -- Drives the `requestAnimationFrame` loop, calling `playback.tick(...)` with the current dt / totalFrames / simulationDone. -- Coordinates simulation lifecycle: `play()` initialises the sim if NotRun, runs it; `pause()` / `stop()` pause it; `setPlayMode()` runs/pauses appropriately; backpressure ack logic stays here. -- `currentFrame` (the actual frame data) is fetched in React from `getFrame(frameIndex)` — Core doesn't hold frame data. -- Republishes through the existing `PlaybackContext` shape so `/ui` consumers don't change. - -The 11 external consumers (`views/SDCPN/**`, `views/Editor/**`, `petrinaut.tsx`) updated to the new `react/playback/` path. The `react/playback/context.ts` re-exports `PlaybackState`, `PlayMode`, `PlaybackSpeed`, `PLAYBACK_SPEEDS`, `formatPlaybackSpeed` from `core/playback` for back-compat. - -### Phase 2c — LSP transport + LanguageClient (done) - -Same shape as 2a/2b, applied to the language-server worker: - -- `src/core/lsp/transport.ts` — `LspTransport` interface + `createWorkerLspTransport(createWorker)`. Async-factory friendly with message queueing. -- `src/core/lsp/language-client.ts` — `createLanguageClient(config)` returning a `LanguageClient` handle: `diagnostics: ReadableStore<{ byUri, total }>`, fire-and-forget notifications (`initialize`, `notifySDCPNChanged`, `notifyDocumentChanged`, scenario / metric session methods), promise-returning RPCs (`requestCompletion`, `requestHover`, `requestSignatureHelp`), `dispose()`. -- `src/core/lsp/index.ts` — barrel. - -`LanguageClient` methods are typed with `this: void` so consumers can pass them as references without `unbound-method` complaints. Worth retrofitting onto `Petrinaut` and `Simulation` in a follow-up; see [06-react-bindings.md](./06-react-bindings.md) §6.3 "Note". - -Phase 1 simulation pattern repeated for LSP: - -- `src/lsp/lib/` → `src/core/lsp/lib/` (checker, language-service host, virtual-files, document-URIs, position-utils, ts-to-lsp, helper/) -- `src/lsp/worker/language-server.worker.ts` → `src/core/lsp/worker/` -- `src/lsp/worker/protocol.ts` → `src/core/lsp/worker/` -- `src/lsp/provider.tsx` → `src/react/lsp/` (rewritten to call `createLanguageClient`) -- `src/lsp/context.ts` → `src/react/lsp/` -- `src/lsp/worker/use-language-client.ts` — **deleted.** Replaced by `createLanguageClient`. -- `src/lsp/` directory removed. - -The 17 external consumers (`monaco/*`, `views/Editor/**`, `petrinaut.tsx`) updated to the new paths via sed. - -`` rewritten to use `useState` lazy-init for the client (React Compiler rejects ref writes during render). It still owns: - -- creating the worker via the existing `?worker&inline` import, -- calling `client.initialize(sdcpn)` on first mount, -- calling `client.notifySDCPNChanged(sdcpn)` on every subsequent SDCPN change, -- subscribing to diagnostics via `useStore(client.diagnostics)`, -- republishing through the existing `LanguageClientContext` shape so `/ui` and `monaco/` consumers don't change. - -**Public exports — known issue.** The dts bundler (`rolldown-plugin-dts`) emits "Duplicated export" errors for `vscode-languageserver-types` symbols (`DocumentUri`, `Position`, …) whenever those types are reachable through more than one path in the dependency graph. Tracked upstream as [sxzz/rolldown-plugin-dts#209](https://github.com/sxzz/rolldown-plugin-dts/issues/209) — open since 2026-03-19, no fix shipped. - -We removed `core/lsp/worker/protocol.ts`'s re-exports of upstream types (consumers now import directly from `vscode-languageserver-types`) — that's a clean-up regardless, but it is **not enough on its own** to fix the dts duplication, because the upstream types are still imported by both `core/lsp/language-client.ts` and `react/lsp/context.ts`, and both sit in `main.ts`'s dependency graph. - -LSP exports therefore remain absent from `main.ts`. Phase 5's per-entry bundling resolves this naturally: when `/core` becomes its own bundle, the upstream types only show up once in its dts. - -### Phase 2b — `` swap (done) - -`src/simulation/provider.tsx` rewritten to call `createSimulation` instead of `useSimulationWorker`. The provider keeps its existing public `SimulationContextValue` shape — `/ui` files (the simulation panel, scenarios UI, etc.) are unchanged. Internally: - -- A `Simulation | null` is held in React state. Disposed on unmount, on `petriNetId` change, on `reset()`, and before each new `initialize()`. -- `status` and `frames.count` come from `useStore(simulation.status)` / `useStore(simulation.frames)` with stable empty-store fallbacks when no simulation is active. -- `error` / `errorItemId` are captured from `simulation.events` (the core handle no longer republishes the message via stores; it fires once on transition). -- The legacy `SimulationState` shape ("NotRun" | "Paused" | "Running" | "Complete" | "Error") is reconstructed from the new `CoreSimulationState` ("Initializing" | "Ready" | "Running" | "Paused" | "Complete" | "Error") + the presence of a handle. -- `getFrame` / `getAllFrames` / `getFramesInRange` read from `simulation.getFrame(i)` and the `frames.count` store. Promise-wrapped to preserve the existing async signature. - -`useSimulationWorker` hook + its test file deleted (`src/simulation/worker/use-simulation-worker.{ts,test.ts}`). The hook's `WorkerStatus` type and the React glue around it are no longer needed — the engine talks to the main thread purely through the `Simulation` handle. - -`create-simulation-worker.ts` is kept as the default `WorkerFactory` used by the provider; its `?worker&inline` import is the existing browser-side bundling path. - -## Phase 3 — React bindings - -1. Rewrite each bridge provider in `/react` to subscribe to a Core instance instead of holding local state. Keep their existing public context shapes so `/ui` consumers don't change. -2. Add `` that mounts the bridge stack ([06-react-bindings.md](./06-react-bindings.md) §6.1). -3. Add `usePetrinautInstance`, `usePetrinautDefinition`, `useSimulationStatus`, `usePlaybackState`, `useDiagnostics`, etc. as the public hook surface ([06-react-bindings.md](./06-react-bindings.md) §6.2). - -## Phase 4 — UI - -1. Top-level `` (in `/ui`) creates a Core instance, mounts ``, then renders `MonacoProvider` + `EditorView` + toaster. -2. Verify no `/ui` file imports from `core/` directly — everything goes via `/react` hooks. - -## Phase 5 — Public entry points - -1. Add `exports` map: `"."` → `/ui`, `"./core"`, `"./react"`, `"./ui"`. Migrate `main.ts` to thin re-exports for back-compat. -2. Update build (Rolldown) to emit three entry bundles with appropriate `external`s (no React in `/core`'s bundle; no Monaco/xyflow in `/react`'s). -3. Update consumers in this monorepo (find call sites, switch to `/ui` or `/core` as appropriate). -4. Bump package version, write CHANGELOG entry. - -## Phase 6 — (optional) trim deps further - -Investigate moving `react`, `@xyflow/react`, `monaco-editor`, panda CSS out of the `dependencies` block where they're not needed by every entry point. Most likely candidates: split `peerDependencies` per entry, or rely on bundlers' tree-shaking + a clean `external` list. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/09-risks.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/09-risks.md deleted file mode 100644 index 5e85fa81080..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/09-risks.md +++ /dev/null @@ -1,64 +0,0 @@ -# 09 — Risks and likely surprises - -Things that are easy to underestimate or that have bitten similar refactors before. - -## Worker bundling under non-Vite consumers - -The current worker setup uses Vite's `?worker` import, which is bundler-specific. The decision in [05-simulation.md](./05-simulation.md) §5.2 (caller-provided factory + `./core/simulation.worker` sub-entry) covers the common bundlers, but verifying it actually works in: - -- Webpack 5 -- esbuild (raw) -- Bun -- Node `worker_threads` (with the `web-worker` polyfill) - -…is a Phase-2 task that can't be skipped. If the sub-entry approach turns out to need bundler-specific shims, fallback is shipping the worker as a string blob URL (the `web-worker` dep already supports this). - -## React Compiler + external store - -`useSyncExternalStore` is supported by React Compiler, but a few things to sanity-check: - -- The compiler doesn't over-cache instance handles such that hooks read stale state. -- Subscribe callbacks aren't memoised in a way that drops subscriptions. -- The compiler doesn't try to memoise `instance.foo.subscribe` (which would break re-subscription on instance change). - -Opt out with `"use no memo"` only where genuinely needed. - -## Whole-string `replace` for text edits - -`PetrinautPatch` is Immer-shaped, so any character change inside a long code field (guards, kernels, equations) emits a `replace` carrying the entire new string. Drawbacks: - -- **Patch volume.** Keystrokes in a 10 KB code block produce 10 KB-per-stroke patches. -- **No collaborative text merging.** Character-level CRDTs need sub-string `splice` ops; whole-string replace can't merge concurrent edits without conflict. - -Mitigation: deferred. See [Q1.c in 07-open-questions.md](./07-open-questions.md) and the "Known limitation: text-range edits" section in [04-core-instance.md](./04-core-instance.md) §4.1. Will be addressed by a follow-up RFC if/when collaboration or patch-volume becomes a real concern. - -## Handle adapter drift - -Core never owns the document — it receives a `PetrinautDocHandle`. For Automerge, consumers paste a small adapter that wraps `DocHandle`. Two risks: - -- **Automerge API drift.** If `automerge-repo` changes its `DocHandle` surface (e.g. `docSync` returning a different shape), the published adapter snippet goes stale. Mitigation: the adapter is short, version-tagged, and lives in a docs page rather than in Petrinaut's own code. -- **Patch-format mismatch.** Automerge patches don't map 1:1 to `PetrinautPatch` (e.g. `splice` becomes multiple `add` ops). If a consumer relies on Automerge-flavoured patches inside Petrinaut, they'll be confused. Mitigation: document the conversion explicitly in the adapter section. - -## Layout (`elkjs`) - -Confirm `lib/layout/*` runs without DOM. `elkjs` ships a Web Worker variant that may have implicit assumptions about the environment; pure JS variant should be fine but verify in Phase 1. - -## Render-time `mutate` calls - -The React mutation provider sometimes triggers a mutation as part of a render cycle (e.g. auto-layout reacting to a new node). Need to verify the new boundary doesn't introduce a cycle — specifically, that an async `instance.mutate` → `instance.definition.subscribe` → React re-render → `instance.mutate` chain can't infinite-loop. Likely solution: queue mutations through a microtask, but that may break ordering guarantees the current code relies on. - -## Monaco + LSP timing - -Monaco initialisation and LSP worker readiness have a current race that's masked by the React provider order. Once both move out of providers and into the Core instance, the sequencing has to be explicit (LSP ready before the editor opens a document). Phase 2 should document this contract before Phase 3. - -## Notifications duplication - -If notifications become core-output (Q7) and `/ui` renders them as toasts, ensure there's no path where both `/core` and `/ui` independently emit a toast for the same event. - -## `@xyflow/react` boundary - -`@xyflow/react` is heavily entangled with the editor view and is the second-largest dep (after Monaco). It must end up in `/ui` only. Phase 1 should grep for `@xyflow/react` imports in moved files and reject any in `/core` or `/react`. - -## Storybook / demo site - -The current Storybook setup imports the editor directly. After the split it should import from `/ui` and continue to work with no story changes — but the build wiring needs updating. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/10-public-api.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/10-public-api.md deleted file mode 100644 index abc2cb961ac..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/10-public-api.md +++ /dev/null @@ -1,51 +0,0 @@ -# 10 — Public API summary - -## Imports after the split - -```ts -// Headless engine — live document -import { createPetrinaut, createJsonDocHandle } from "@hashintel/petrinaut/core"; -import type { - SDCPN, Place, Transition, - PetrinautDocHandle, PetrinautPatch, DocChangeEvent, DocumentId, - /* … */ -} from "@hashintel/petrinaut/core"; - -// Headless engine — simulation (standalone; not tied to an instance) -import { createSimulation, createWorkerTransport } from "@hashintel/petrinaut/core"; -import type { - Simulation, CreateSimulationConfig, SimulationConfig, - SimulationTransport, WorkerFactory, - /* … */ -} from "@hashintel/petrinaut/core"; - -// React bindings (build your own UI) -import { - PetrinautProvider, - usePetrinautInstance, - usePetrinautDefinition, - useSimulationStatus, - /* … */ -} from "@hashintel/petrinaut/react"; - -// Opinionated editor (default for most consumers) -import { Petrinaut } from "@hashintel/petrinaut/ui"; -``` - -## Layer dependency direction - -**`ui` → `react` → `core`**, never reversed, never skipping a layer. - -- `/ui` files **must not** import from `/core` directly. They go through `/react` hooks. This keeps the abstraction honest: if a UI file imports from `/core`, it bypasses the React bindings layer and re-creates the coupling we're trying to remove. -- `/react` files **may** import types from `/core` and call into a Core instance, but **must not** know about visual components or DOM APIs. -- `/core` files **must not** import from `/react` or `/ui`. Enforced by file-system layout and (post-merge) by an ESLint rule. - -## What the default top-level entry exports - -`@hashintel/petrinaut` (no sub-path) re-exports `/ui` for back-compat with today's consumers — they keep working without changes. - -Domain types previously exposed at the top level (`SDCPN`, `Place`, etc.) are also re-exported there, but the canonical home is `/core`. - -## Worker sub-entry - -`@hashintel/petrinaut/core/simulation.worker` is a fourth entry that exists **only as a worker source URL**. It is not meant to be imported as a module — only resolved via `new URL(..., import.meta.url)` and passed to `new Worker(...)`. See [05-simulation.md](./05-simulation.md) §5.2. diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/11-headless-usage.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/11-headless-usage.md deleted file mode 100644 index 0333cc89853..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/11-headless-usage.md +++ /dev/null @@ -1,566 +0,0 @@ -# 11 — Using Petrinaut without any UI - -This chapter shows how to use `@hashintel/petrinaut/core` as a headless engine — no React, no DOM, no Monaco. CLI tools, server-side simulation runners, snapshot tests, and alternative-framework bindings all consume Core through this surface. - -Status callouts: - -- 🟢 **Shipped** — works today against the `cf/fe-628` branch. -- 🟡 **Planned** — described in the RFC, not yet implemented. Consumers shouldn't rely on the exact shape until it lands. - ---- - -## 11.1 At a glance - -```ts -import { - createJsonDocHandle, - createPetrinaut, - createSimulation, -} from "@hashintel/petrinaut/core"; // (today: re-exported from "@hashintel/petrinaut") - -const handle = createJsonDocHandle({ initial: emptySDCPN }); -const instance = createPetrinaut({ document: handle }); - -// mutate -instance.mutate((draft) => { - draft.places.push({ id: "p1", name: "Place 1", /* … */ }); -}); - -// observe -const off = instance.definition.subscribe((sdcpn) => { - console.log(`Now ${sdcpn.places.length} place(s)`); -}); - -// run a simulation — standalone, not on the instance -const sim = await createSimulation({ - sdcpn: instance.handle.doc()!, // or any other SDCPN value - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, - createWorker: () => new Worker(/* … */), -}); -sim.run(); - -// later — host owns each lifecycle independently -sim.dispose(); -instance.dispose(); -off(); -``` - ---- - -## 11.2 `createPetrinaut` - -```ts -function createPetrinaut(config: { - document: PetrinautDocHandle; - readonly?: boolean; -}): Petrinaut; -``` - -🟢 **Shipped.** Returns a stateful `Petrinaut` instance bound to a single document handle. Core never owns the document — it's the host's responsibility to provide a handle via one of the patterns below. - -> **Simulation is not configured here.** A simulation is built standalone via `createSimulation` (§11.4). The instance only manages live-document concerns: definition, patches, mutate, future LSP. - -### 11.2.1 With `createJsonDocHandle` (in-memory, the common case) - -🟢 **Shipped.** - -```ts -import { createJsonDocHandle } from "@hashintel/petrinaut/core"; - -const handle = createJsonDocHandle({ - id: "my-net", // optional; auto-generated if omitted - initial: someSDCPN, // required initial document - historyLimit: 50, // optional, default 50; pass 0 to disable history -}); -``` - -Backed by Immer — `produceWithPatches` runs on every `change()` so the handle gets forward and inverse patches for free. Patches are emitted with each `DocChangeEvent` and feed both the optional history stack and any consumer subscribed to `instance.patches`. - -### 11.2.2 With an Automerge handle (for collaborative editing) - -🟡 **Planned.** No adapter is shipped — adding `@automerge/automerge-repo` as a peer dep just for the type isn't worth it. The docs include a 5-line wrapper consumers paste in: - -```ts -import type { DocHandle as AutomergeDocHandle } from "@automerge/automerge-repo"; -import type { - PetrinautDocHandle, - PetrinautPatch, -} from "@hashintel/petrinaut/core"; -import type { SDCPN } from "@hashintel/petrinaut/core"; - -function fromAutomergeHandle(h: AutomergeDocHandle): PetrinautDocHandle { - return { - id: h.documentId, - state: /* map h.state() to the Petrinaut DocHandleState shape */, - whenReady: () => h.whenReady().then(() => {}), - doc: () => h.docSync(), - change: (fn) => h.change(fn), - subscribe: (listener) => { - const handler = ({ doc, patches }) => listener({ - next: doc, - patches: patches.map(automergePatchToPetrinaut), // small switch - source: "remote", // or detect local based on payload origin - }); - h.on("change", handler); - return () => h.off("change", handler); - }, - // history could optionally be implemented against Automerge's heads. - }; -} - -const repo = new Repo({ /* storage, network, … */ }); -const automergeHandle = repo.find("automerge:abc123"); -const handle = fromAutomergeHandle(automergeHandle); -const instance = createPetrinaut({ document: handle }); -``` - -Patch conversion is a small switch (`put` → `replace`, `del` → `remove`, `splice`/`insert` → multiple `add`). Atomicity loss on splice is documented in [04-core-instance.md](./04-core-instance.md) §4.1. - -### 11.2.3 Building your own handle - -🟢 **Shipped** (the interface; build it however you like). - -The contract is in [04-core-instance.md](./04-core-instance.md) §4.1. Minimum: - -```ts -import type { - DocChangeEvent, - DocHandleState, - DocumentId, - PetrinautDocHandle, - ReadableStore, -} from "@hashintel/petrinaut/core"; -import type { SDCPN } from "@hashintel/petrinaut/core"; - -function createMyHandle(initial: SDCPN, id: DocumentId): PetrinautDocHandle { - let current = initial; - const subs = new Set<(e: DocChangeEvent) => void>(); - const stateListeners = new Set<(s: DocHandleState) => void>(); - - const state: ReadableStore = { - get: () => "ready", - subscribe: (l) => (stateListeners.add(l), () => stateListeners.delete(l)), - }; - - return { - id, - state, - whenReady: () => Promise.resolve(), - doc: () => current, - change(fn) { - const next = structuredClone(current); // or Immer; or your CRDT API - fn(next); - current = next; - for (const sub of subs) { - sub({ next: current, source: "local" }); // omit `patches` if you can't produce them - } - }, - subscribe(listener) { - subs.add(listener); - return () => subs.delete(listener); - }, - // history: omit if not supported - }; -} -``` - -Things to consider when writing your own handle: - -- **`patches` are optional** in the `DocChangeEvent`. Omit when your underlying store can't produce them; consumers fall back to deep-equal where it matters. -- **`source: "local" | "remote"`** lets subscribers distinguish locally-applied mutations from remote-sync deliveries. Useful for Automerge adapters; harmless to omit otherwise. -- **`history?` is optional**. Omit on read-only / mirror handles. If you implement it, the contract is in [04-core-instance.md](./04-core-instance.md) §4.1 "History (locked)". -- **No-op mutations should not emit.** Subscribers rely on "every event is a real change" (Phase 0 contract). - -### 11.2.4 History - -🟢 **Shipped** when `handle.history` is present (it is for `createJsonDocHandle`): - -```ts -const h = createJsonDocHandle({ initial }); -h.change(addPlace); // history entry 1 -h.change(addTransition); // history entry 2 - -h.history?.undo(); // back to state after entry 1 -h.history?.canRedo.get(); // → true -h.history?.redo(); // forward again - -// Subscribe to canUndo/canRedo for UI gating without a render framework: -const offCanUndo = h.history?.canUndo.subscribe((can) => { - console.log("can undo:", can); -}); - -// Jump arbitrarily (used by version-history dropdowns): -h.history?.goToIndex(0); // back to initial state -``` - -Each `undo` / `redo` / `goToIndex` emits a normal `DocChangeEvent` with the patches applied (so simulation, validators, etc. react the same way as for fresh mutations). - -Coalescing of typing-bursts is **deferred** (Q1.c in [07-open-questions.md](./07-open-questions.md)). Today every `change()` is a fresh history entry. - ---- - -## 11.3 Mutating the document - -🟢 **Shipped.** - -Two ways, equivalent except for the readonly check: - -```ts -// Through the instance — respects `readonly: true` -instance.mutate((draft) => { - draft.transitions.push({ id: "t1", /* … */ }); -}); - -// Directly through the handle — bypasses the instance's readonly flag -handle.change((draft) => { - draft.transitions.push({ id: "t1", /* … */ }); -}); -``` - -The `draft` is an Immer draft when using `createJsonDocHandle`. You can mutate it directly; Immer produces the immutable next state plus patches. - -For multi-step changes, just call `change` once with all of them — they're a single history entry and a single patch event. - ---- - -## 11.4 Running a simulation - -A simulation is **standalone** — it doesn't live on a `Petrinaut` instance. It runs against a frozen SDCPN snapshot and outlives any instance you happen to have. Multiple simulations can coexist against one document. - -### 11.4.1 With a `Worker` factory (the common case) - -🟢 **Shipped.** Browser-side, the function builds a transport for you: - -```ts -import { createSimulation } from "@hashintel/petrinaut/core"; - -const sim = await createSimulation({ - sdcpn: someSDCPN, // any SDCPN value; from a handle, file, fixture, … - initialMarking: new Map(), // empty = all places start with zero tokens - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, - backpressure: { maxFramesAhead: 40, batchSize: 10 }, - signal: abortController.signal, // optional cancellation - createWorker: () => - new Worker( - // Today: use the bundled worker URL via your bundler's resolution. - // Once Phase 5 lands, this becomes a `./core/simulation.worker` sub-entry. - new URL("./worker.js", import.meta.url), - { type: "module" }, - ), -}); - -sim.run(); - -// Watch the latest frame: -const off = sim.frames.subscribe(({ count, latest }) => { - if (latest) { - console.log(`Frame ${count}, t=${latest.time}`); - } -}); - -// Listen for completion: -sim.events.subscribe((e) => { - if (e.type === "complete") { - console.log(`Done at frame ${e.frameNumber} (${e.reason})`); - } -}); - -off(); -sim.dispose(); -``` - -If you have a `Petrinaut` instance: - -```ts -const sim = await createSimulation({ - sdcpn: instance.handle.doc()!, - /* …rest of config… */ -}); -``` - -The simulation captures the SDCPN snapshot once. Mutations to the source document after that don't affect the running simulation. - -### 11.4.2 With a pre-built transport - -🟢 **Shipped.** When you want explicit control over the transport (custom worker pool, polyfill, recorded replay), pass it directly: - -```ts -import { - createSimulation, - createWorkerTransport, -} from "@hashintel/petrinaut/core"; - -const transport = createWorkerTransport(() => new Worker(/* … */)); - -const sim = await createSimulation({ - transport, - sdcpn: someSDCPN, - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, -}); - -sim.run(); -``` - -**Ownership transfers to the simulation:** `sim.dispose()` calls `transport.terminate()`. Build a fresh transport per simulation. This is the path the unit tests in `src/core/simulation/simulation.test.ts` use with a manual transport for full control. - -### 11.4.3 Frame consumption - -The `frames` store deliberately exposes only `{ count, latest }` so subscribers don't re-render per frame for nothing. Pull individual frames with `sim.getFrame(index)` when you need them: - -```ts -const offFrames = sim.frames.subscribe(({ count }) => { - // Only react every 10 frames, say - if (count % 10 === 0) { - const frame = sim.getFrame(count - 1); - persist(frame); - } -}); -``` - -### 11.4.4 Recording / replay - -Because frames are a stream, persisting a run is just subscribing: - -```ts -const recorded: SimulationFrame[] = []; -sim.frames.subscribe(({ latest }) => { - if (latest) recorded.push(latest); -}); -``` - -🟡 **Planned:** `createInlineTransport()` (synchronous, no worker, no DOM) and `createRecordedTransport(frames)` (replay against a saved tape). The `SimulationTransport` interface is shape-compatible with both; they ship later without API change. - -### 11.4.5 Off-thread on the server (Node / Bun / Deno) - -🟢 **Shipped (DIY transport).** `createWorkerTransport` is shaped for the browser Web Worker API; Node's `worker_threads.Worker` is `EventEmitter`-shaped. Different API, same idea — and the `SimulationTransport` interface is small enough that wrapping the runtime's worker is ~10 lines: - -```ts -import { Worker } from "node:worker_threads"; -import { createSimulation } from "@hashintel/petrinaut/core"; -import type { - SimulationTransport, - ToMainMessage, - ToWorkerMessage, -} from "@hashintel/petrinaut/core"; - -const worker = new Worker(new URL("./sim.worker.mjs", import.meta.url)); -const transport: SimulationTransport = { - send: (msg: ToWorkerMessage) => worker.postMessage(msg), - onMessage: (listener: (msg: ToMainMessage) => void) => { - worker.on("message", listener); - return () => worker.off("message", listener); - }, - terminate: () => { - void worker.terminate(); - }, -}; - -const sim = await createSimulation({ - transport, - sdcpn: net, - initialMarking: new Map(), - parameterValues: {}, - seed: 42, - dt: 0.01, - maxTime: 10, -}); -``` - -The same shape works for Bun, Deno, edge runtimes, or process pools over IPC — wrap whatever message-passing primitive you have in the three-method `SimulationTransport` interface. See [05-simulation.md](./05-simulation.md) §5.2 "Transport matrix" for the full breakdown. - -Alternative path: the [`web-worker`](https://www.npmjs.com/package/web-worker) package is already a `petrinaut` dep and exposes the browser `Worker` API on top of `node:worker_threads`. With it, you can use `createWorkerTransport` directly in Node: - -```ts -import Worker from "web-worker"; -const sim = await createSimulation({ - createWorker: () => - new Worker(new URL("./sim.worker.mjs", import.meta.url), { type: "module" }), - /* … */ -}); -``` - -### 11.4.6 Headless-headless (no off-thread at all) - -🟡 **Planned:** `createInlineTransport()` runs the simulator on the calling thread — no worker involved. Useful for tests, small SDCPNs, deterministic snapshots. Once it ships, the recommended pattern is `createSimulation({ sdcpn, transport: createInlineTransport(), … })`. - ---- - -## 11.5 Type-checking user code (LSP) - -🟡 **Planned.** Today the LSP worker (`src/lsp/worker/`) is consumed only through `` in `/react`. Headless consumers can't easily check that a transition guard or kernel function compiles without booting React. - -Planned surface (RFC [04-core-instance.md](./04-core-instance.md) §4.3): - -```ts -const instance = createPetrinaut({ document: handle }); - -// One-shot: ask LSP to lint the current document -instance.lsp.notifyDocumentChanged(uri, code); - -const off = instance.lsp.diagnostics.subscribe(({ total, byUri }) => { - if (total > 0) { - for (const [docUri, diags] of byUri) { - console.error(`${docUri}: ${diags.length} diagnostic(s)`); - } - } -}); - -// Or ask explicitly: -const completion = await instance.lsp.requestCompletion(uri, position); -const hover = await instance.lsp.requestHover(uri, position); -``` - -The LSP worker is already headless — it runs in a Web Worker over a typed message protocol. Phase 2 task: wrap it in `instance.lsp.*` the same way Phase 2a wrapped the simulation worker. Until then, headless type-checking requires reaching into `src/lsp/worker/` directly, which isn't a stable API. - ---- - -## 11.6 Subscribing — the two patterns - -Core uses exactly two stream primitives ([04-core-instance.md](./04-core-instance.md) §4.2): - -### `ReadableStore` — for state ("there is always a current value") - -```ts -type ReadableStore = { - get(): T; - subscribe(listener: (value: T) => void): () => void; -}; -``` - -Examples in Core: `instance.definition`, `handle.state`, `handle.history.canUndo`, `sim.status`, `sim.frames`. - -```ts -const current = instance.definition.get(); -const off = instance.definition.subscribe((next) => render(next)); -// later: -off(); -``` - -### `EventStream` — for discrete events ("things that happen") - -```ts -type EventStream = { - subscribe(listener: (event: T) => void): () => void; -}; -``` - -Examples: `instance.patches`, `sim.events`. No `get()` — events are gone after they fire. - -```ts -const off = sim.events.subscribe((e) => { - if (e.type === "error") report(e.message); -}); -``` - -### When to use which - -- "What is the current X?" → `ReadableStore`. -- "What happened?" / "Tell me when X happens" → `EventStream`. -- For React, both are bridged via `useStore` / `useStoreSelector` (see [06-react-bindings.md](./06-react-bindings.md) §6.3). -- For non-React consumers, just call `subscribe` directly and store the unsubscribe function. - -### Cleanup - -Always call the unsubscribe function returned by `subscribe`. Long-lived consumers that forget will leak. In Node: - -```ts -const off = sim.frames.subscribe(handler); -process.on("beforeExit", off); -``` - ---- - -## 11.7 Disposal and lifecycle - -```ts -const instance = createPetrinaut({ document: handle }); -const sim = await createSimulation({ sdcpn: handle.doc()!, /* … */ }); - -// Tear down the simulation: -sim.dispose(); - -// Tear down the instance: -instance.dispose(); -``` - -The instance and the simulation are **independent lifecycles**. Disposing the instance does *not* dispose any simulations you spawned — they're standalone (§5.1). Disposing the simulation does not affect the instance. The host owns both. - -Disposal is **idempotent** — safe to call twice. The handle itself is owned by the host; Core does not call any teardown on it (e.g. it doesn't close localStorage adapters or detach Automerge listeners). - -🟡 **Planned:** lifecycle ordering when both LSP and simulation are active — ensure LSP is initialized before the editor opens a document. See [09-risks.md](./09-risks.md) "Monaco + LSP timing". - ---- - -## 11.8 End-to-end example: lint an SDCPN file - -Putting it together — a future CI script that fails when a saved net has type errors. (🟡 LSP currently still goes through `/react`; sketched here as it will look once Phase 2c lands.) - -```ts -import { readFile } from "node:fs/promises"; -import { - createJsonDocHandle, - createPetrinaut, -} from "@hashintel/petrinaut/core"; -import { importSDCPN } from "@hashintel/petrinaut/core/file-format"; // 🟡 path TBD in Phase 5 - -const json = await readFile(process.argv[2]!, "utf8"); -const sdcpn = importSDCPN(json); - -const instance = createPetrinaut({ - document: createJsonDocHandle({ initial: sdcpn, historyLimit: 0 }), -}); - -await new Promise((resolve, reject) => { - const off = instance.lsp.diagnostics.subscribe(({ total, byUri }) => { - if (total > 0) { - console.error(`Found ${total} diagnostic(s)`); - for (const [uri, diags] of byUri) { - for (const d of diags) console.error(` ${uri} :: ${d.message}`); - } - off(); - reject(new Error("type-check failed")); - return; - } - off(); - resolve(); - }); - - // Trigger LSP to lint every URI in the SDCPN - for (const t of sdcpn.transitions) { - instance.lsp.notifyDocumentChanged(`net://transition/${t.id}/guard`, t.guard ?? ""); - } -}); - -instance.dispose(); -console.log("OK"); -``` - -This script is the canonical motivator for the headless surface — every part of it should remain a single import away from a `node` script when Phase 2c finishes. - ---- - -## 11.9 Status summary - -| Capability | Status | -| ---------- | :----: | -| `createJsonDocHandle` (with history) | 🟢 | -| `createPetrinaut` (document + readonly) | 🟢 | -| `instance.mutate` / `instance.definition` / `instance.patches` | 🟢 | -| `createSimulation` (standalone, with `createWorker` or `transport`) | 🟢 | -| `createWorkerTransport` (build a transport for explicit reuse) | 🟢 | -| Automerge handle adapter | 🟡 (pasted snippet, not shipped) | -| `createInlineTransport` / `createRecordedTransport` | 🟡 | -| `instance.lsp.*` (headless type-checking) | 🟡 | -| `instance.playback.*` (frame loop in Core) | 🟡 | -| `./core/simulation.worker` `package.json` sub-entry | 🟡 (Phase 5) | diff --git a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/README.md b/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/README.md deleted file mode 100644 index 04c1b79ba5a..00000000000 --- a/libs/@hashintel/petrinaut/rfc/0001-core-react-ui-split/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# RFC 0001 — Petrinaut: Core / React / UI Split - -**Status:** Draft (iterating) — Phase 0 + 2a/2b/2c/2d + 3a + 3b + 4 + 5 landed; post-3b polish (legacy retirement + rename, layer-direction lint, host-controlled workers) landed; Phase 1 layout sweep complete (every top-level dir now lives under `/core/`, `/react/`, or `/ui/` apart from the intentional `/examples/`) -**Authors:** @cf -**Created:** 2026-04-28 -**Last updated:** 2026-05-05 -**Tracking issue:** FE-628 - ---- - -## Summary - -Restructure `@hashintel/petrinaut` into three import paths: - -- **`@hashintel/petrinaut/core`** — stateful, headless instance. No React, no DOM, no Monaco. Inputs and outputs flow through streams. -- **`@hashintel/petrinaut/react`** — React bindings: hooks, contexts, and bridge providers that synchronize a Core instance with React. No visual widgets. -- **`@hashintel/petrinaut/ui`** — the opinionated visual editor: ``, all views, panels, and components. Built on top of `/react`. - -Layer dependency direction: **`ui` → `react` → `core`**, never the reverse, and `/ui` should not reach into `/core` directly — always through `/react` hooks. - -## Why - -- Enable non-React consumers (CLI tools, headless automation, server-side simulation). -- Let advanced consumers build alternative UIs against `/react` without forking the editor. -- Keep the polished editor available out of the box via `/ui`. -- Make the boundary between *what the system does* and *how it is rendered* explicit. - -## Reading order - -| # | File | Purpose | -| - | ---- | ------- | -| 01 | [01-motivation.md](./01-motivation.md) | Goals + non-goals | -| 02 | [02-current-state.md](./02-current-state.md) | The provider stack today and the SDCPN ownership model | -| 03 | [03-layering.md](./03-layering.md) | What goes in `core` / `react` / `ui` | -| 04 | [04-core-instance.md](./04-core-instance.md) | `createPetrinaut()` surface, streams, instantiation | -| 05 | [05-simulation.md](./05-simulation.md) | Simulation patterns (locked) | -| 06 | [06-react-bindings.md](./06-react-bindings.md) | `` + hooks; how `/ui` consumes them | -| 07 | [07-open-questions.md](./07-open-questions.md) | The hot file while the RFC is in flight | -| 08 | [08-migration.md](./08-migration.md) | Phased migration plan | -| 09 | [09-risks.md](./09-risks.md) | Risks and likely surprises | -| 10 | [10-public-api.md](./10-public-api.md) | Final import surface summary | -| 11 | [11-headless-usage.md](./11-headless-usage.md) | Using Petrinaut without any UI — handle setup, simulation, type-checking, subscriptions | - -## Decisions locked so far - -- Three entry points: `core`, `react`, `ui` (not two; not `hooks`). -- Single package with an `exports` map, not three separate packages. -- Simulation worker lives in `/core`; bundling via caller-provided `createWorker` factory + `./core/simulation.worker` sub-entry. See [05-simulation.md](./05-simulation.md). -- Stream primitive: `ReadableStore` (`get` + value-passing `subscribe`) for state, `EventStream` for one-shot events. React adapts via a `useStore` helper. See [04-core-instance.md](./04-core-instance.md) §4.2. -- Document never owned by Core — Core takes a `PetrinautDocHandle` (adapts plain JSON, Immer, Automerge, …). `createJsonDocHandle` shipped for the common case. See [04-core-instance.md](./04-core-instance.md) §4.1. -- Patches: Petrinaut-defined minimal `PetrinautPatch` type (Immer-shaped: array path, `op: add | remove | replace`). Adds `immer` (~14 KB) as a `/core` dep. Patches are in-memory only, never persisted. See [04-core-instance.md](./04-core-instance.md) §4.1. -- Undo/redo lives on the handle as an optional `history` field. `createJsonDocHandle` ships a default Immer-based implementation; the host's `UndoRedoContextValue` pass-through goes away. Coalescing of typing-bursts is a deferred follow-up. See [04-core-instance.md](./04-core-instance.md) §4.1 "History (locked)". -- Phase 0 spike landed: `createJsonDocHandle` (with history), `createPetrinaut`, `useStore`, ``, two Storybook stories, 15 smoke tests. Demo site (`apps/petrinaut-website`) migrated to the handle-driven path; per-net history preserved across switches. See [08-migration.md](./08-migration.md) Phase 0. -- Phase 2a landed: `SimulationTransport` interface, `createWorkerTransport(createWorker)`, **`createSimulation(config)`** standalone factory returning a `Simulation` handle (status / frames / events stores, run/pause/reset/ack/setBackpressure/getFrame/dispose actions). Simulation is **decoupled** from the `Petrinaut` instance — it operates on a frozen SDCPN snapshot and the host owns its lifecycle. See [05-simulation.md](./05-simulation.md) §5.1, §5.8. -- Phase 2b landed: `` swapped to call `createSimulation` directly. Old `useSimulationWorker` hook deleted. The legacy `SimulationContextValue` shape is preserved so `/ui` files don't change. See [08-migration.md](./08-migration.md) "Phase 2b". -- Phase 2c landed: `LspTransport` interface, `createWorkerLspTransport(createWorker)`, **`createLanguageClient(config)`** standalone factory returning a `LanguageClient` handle (diagnostics store, fire-and-forget notifications, promise-returning RPCs, `dispose`). `` swapped to use it; old `useLanguageClient` hook deleted. `LanguageClient` methods carry `this: void` — same retrofit recommended for `Petrinaut` and `Simulation`. LSP files moved into `core/lsp/` + `react/lsp/`. See [08-migration.md](./08-migration.md) "Phase 2c". -- Phase 2d landed: pure playback timing model in `/core` — **`createPlayback(initial?)`** returning a `Playback` handle (state store + caller-driven `tick(currentTime, dt, totalFrames, simulationDone)`). Auto-pauses on reaching end when `simulationDone` or `mode === "viewOnly"`. `` rewritten as a thin bridge driving rAF + simulation coordination. Playback files moved into `core/playback/` + `react/playback/`. 18 new unit tests. See [08-migration.md](./08-migration.md) "Phase 2d". -- Phase 3a landed: public hook surface in `src/react/hooks/` — `usePetrinautDefinition`, `useMutate`, `useDocumentState`, `useSimulationStatus`, `usePlaybackState`, `useDiagnostics`, `useLspActions`, etc. (~25 hooks across `use-document.ts`, `use-simulation.ts`, `use-playback.ts`, `use-lsp.ts`). `this: void` retrofit applied to `Petrinaut` and `Simulation` for clean method-reference passing. See [08-migration.md](./08-migration.md) "Phase 3a". -- Phase 3b landed: `` in `/react` mounts every bridge over a Core instance. New bridges: `react/sdcpn-provider.tsx`, `react/mutation-provider.tsx`. `NetManagementContext` (new) holds host-owned title/switching actions. `useHandleHistoryAsUndoRedo` extracted from ``. `` rewritten to use `` directly, skipping the legacy prop-shaped ``. Legacy `` is now a thin adapter on top of `` (ephemeral handle synthesised from props, `undoRedo` prop wrapped as outer `UndoRedoContext`); the duplicated `state/sdcpn-provider.tsx` + `state/mutation-provider.tsx` are deleted. See [08-migration.md](./08-migration.md) "Phase 3b". -- Post-3b polish landed (compounding the work above into a single coherent shape): - - **Legacy `` adapter retired** along with `useEphemeralHandle`. `` was renamed to `` (file `petrinaut-next.tsx → petrinaut.tsx`) and is now the sole editor entry. SDCPN domain types and `isSDCPNEqual` re-exported explicitly from `main.ts` so external consumers are unaffected. - - **Layer-direction lint rule** in `.oxlintrc.json`: `/core/**` may not import from `/react` or `/ui`; `/react/**` may not import from `/ui`. Probed with deliberate violations to confirm both directions trigger. - - **`MutationContext.layoutGraph` and `pasteEntities` removed** — both were `/react→/ui` leaks (one needed visual node dimensions, the other called `pasteFromClipboard`). Composing the right primitives in `/ui` (`runAutoLayout` for the layout button; inline `pasteFromClipboard(instance.mutate)` in the keyboard-shortcuts handler) keeps `/react` clean. - - **Host-controlled workers**: `simulationWorkerFactory` + `lspWorkerFactory` props on `` (plumbed through `` to the bridges). Hosts can replace the bundled inlined-blob defaults when their bundler can't handle them. `` now creates the `LanguageClient` in a `useEffect` rather than `useState`'s lazy initializer — fixes a StrictMode dev-time leak where two LSP clients were created and `initialize` went to the orphaned one. See [08-migration.md](./08-migration.md) "Phase 3b polish" + [06-react-bindings.md](./06-react-bindings.md) §6.4.1. - - **CSS exports**: `vite.config.ts` `cssFileName: "main"` so the bundle lands at `dist/main.css`; `package.json` `exports` exposes `./styles.css` and `./dist/main.css`. - - **`/validation` export surface trimmed** (schemas + result-type aliases unexported per knip). - - **Consumer migration**: `apps/petrinaut-website` fully on subpath imports (`/core`, `/react`, `/ui`); `apps/hash-frontend` stays on the back-compat barrel (older `moduleResolution`) but its editor wrapper migrated from the legacy prop-shape to the handle-based API. - -- **Phase 1 layout sweep** landed across a series of `git mv` commits. Every top-level dir except `/core/`, `/react/`, `/ui/`, and `/examples/` is gone: - - `src/state/` → `src/react/state/` (+ pure pieces extracted to `/core`: `arc-id.ts`, `types/selection.ts`, `lib/get-connections.ts`). - - `src/lib/` split — pure (`deep-equal`) into `/core/lib/`; UI-bound (`calculate-graph-layout`, `hsl-color`, `snap-position-to-grid`, `split-pascal-case`, `viewport`) into `/ui/lib/`. - - `src/file-format/` split — pure conversion (`serialize-sdcpn`, `parse-sdcpn-file`, `sdcpn-to-tikz`, `types`, `remove-visual-info`) into `/core/file-format/`; browser-bound import/export wrappers into `/ui/file-io/` (deliberately renamed — the `/ui` side does file I/O, not format definition). New `/ui/lib/download-blob.ts` exposes a generic `downloadBlob` + `timestampedFilename` so future format exporters don't duplicate the DOM plumbing. - - `src/clipboard/` split — pure (`serialize`, `paste`, `deduplicate-name`, `types`) into `/core/clipboard/`; the `navigator.clipboard.readText/writeText` wrappers into `/ui/clipboard/`. - - `src/hooks/` → `src/react/hooks/` — folds the 4 utility hooks (`use-default-parameter-values`, `use-element-size`, `use-latest`, `use-stable-callback`) next to the public hook surface from Phase 3a. - - `src/validation/` → `src/core/validation/` — pure zod-based validators. - - `src/types/viewport-action.ts` → `src/ui/types/viewport-action.ts` — UI-shaped type carrying `React.ReactNode`. - - `src/examples/` stays at root (decision: 2026-05-05 — will become its own `@hashintel/petrinaut/examples` subpath export with per-example demo-site routes; revisit when that flow lands). - - `src/error-tracker/error-tracker.context.ts` → `src/react/error-tracker-context.ts` — `ErrorTracker` is a React context (host plugs Sentry / Datadog in via the provider), so `/react` is the layer-correct home. Considered placing it in `/core` so the simulation worker / handle could call it directly, but rejected: `/core` already has typed error channels (`simulation.events`, `lsp.diagnostics`, `handle.state`) for everything observable, and a generic capture callback would duplicate them. The wiring-fix (actually using it from worker error paths and React error boundaries) is folded into FE-694. - -## What this RFC does *not* cover - -- Collaborative editing (Automerge / Yjs) wire-up — only the document-ownership decision that affects it. -- Worker pool strategies (one worker per instance vs shared pool). -- Public docs / migration guide for external consumers — to be written once the RFC is accepted. - -## Iteration protocol - -While this RFC is in flight, most edits land in [07-open-questions.md](./07-open-questions.md). Once a question is resolved, its conclusion migrates into the relevant chapter, and the question is struck through with a pointer to where the decision now lives. diff --git a/libs/@hashintel/petrinaut/src/core/index.ts b/libs/@hashintel/petrinaut/src/core/index.ts index 0083cc24aac..5fadcc8fd9c 100644 --- a/libs/@hashintel/petrinaut/src/core/index.ts +++ b/libs/@hashintel/petrinaut/src/core/index.ts @@ -2,8 +2,6 @@ // // No React, no DOM, no Monaco. Stateful handles, streams, and pure logic for // SDCPN documents, simulation, LSP, and playback. -// -// See `rfc/0001-core-react-ui-split/` chapters 04, 05, 11 for design context. // --- Document --- export { @@ -29,29 +27,51 @@ export type { // --- Simulation --- export { + createMonteCarloExperiment, + createMonteCarloSimulator, + createMonteCarloWorker, + createPlaceTokenCountDistributionMetric, createSimulation, createWorkerTransport, } from "./simulation"; export type { BackpressureConfig, + CreateMonteCarloExperimentConfig, CreateSimulationConfig, Simulation, SimulationCompleteEvent, SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameReader, + SimulationFrameState, SimulationFrameSummary, + SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, -} from "./simulation"; -export type { InitialMarking, - SimulationFrame, - SimulationFrameState, - SimulationFrameState_Place, - SimulationFrameState_Transition, -} from "./simulation/types"; + MonteCarloAdvanceResult, + MonteCarloActiveRunPlaceCountsVisitor, + MonteCarloExperiment, + MonteCarloExperimentDistributions, + MonteCarloExperimentEvent, + MonteCarloExperimentState, + MonteCarloFrameMetric, + MonteCarloFrameMetricContext, + MonteCarloRunConfig, + MonteCarloRunSnapshot, + MonteCarloRunStatus, + MonteCarloRunSummary, + MonteCarloRunUntilCompleteOptions, + MonteCarloSimulator, + MonteCarloSimulatorConfig, + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, + PlaceTokenCountDistributionPlace, + MonteCarloWorkerProgress, +} from "./simulation"; // --- LSP --- export { @@ -75,6 +95,7 @@ export { } from "./playback"; export type { Playback, + ComputePlayMode, PlaybackSnapshot, PlaybackSpeed, PlaybackState, diff --git a/libs/@hashintel/petrinaut/src/core/playback/index.ts b/libs/@hashintel/petrinaut/src/core/playback/index.ts index eb8add474da..b382d70a22b 100644 --- a/libs/@hashintel/petrinaut/src/core/playback/index.ts +++ b/libs/@hashintel/petrinaut/src/core/playback/index.ts @@ -3,6 +3,7 @@ export { formatPlaybackSpeed, getPlayModeBackpressure, PLAYBACK_SPEEDS, + type ComputePlayMode, type Playback, type PlaybackSnapshot, type PlaybackSpeed, diff --git a/libs/@hashintel/petrinaut/src/core/playback/playback.test.ts b/libs/@hashintel/petrinaut/src/core/playback/playback.test.ts index eb5c66dbb3c..45aa049e44f 100644 --- a/libs/@hashintel/petrinaut/src/core/playback/playback.test.ts +++ b/libs/@hashintel/petrinaut/src/core/playback/playback.test.ts @@ -3,13 +3,6 @@ import { describe, expect, it } from "vitest"; import { createPlayback, getPlayModeBackpressure } from "./playback"; describe("getPlayModeBackpressure", () => { - it("returns zeros for viewOnly", () => { - expect(getPlayModeBackpressure("viewOnly")).toEqual({ - maxFramesAhead: 0, - batchSize: 0, - }); - }); - it("returns a small buffer for computeBuffer", () => { const cfg = getPlayModeBackpressure("computeBuffer"); expect(cfg.maxFramesAhead).toBeGreaterThan(0); diff --git a/libs/@hashintel/petrinaut/src/core/playback/playback.ts b/libs/@hashintel/petrinaut/src/core/playback/playback.ts index 97dc4073c49..97b082ad68e 100644 --- a/libs/@hashintel/petrinaut/src/core/playback/playback.ts +++ b/libs/@hashintel/petrinaut/src/core/playback/playback.ts @@ -10,6 +10,8 @@ export type PlaybackState = "Stopped" | "Playing" | "Paused"; */ export type PlayMode = "viewOnly" | "computeBuffer" | "computeMax"; +export type ComputePlayMode = Exclude; + export const PLAYBACK_SPEEDS = [ 1, 2, @@ -28,23 +30,23 @@ export function formatPlaybackSpeed(speed: PlaybackSpeed): string { } /** - * Backpressure configuration for a given play mode. Used to tell the + * Backpressure configuration for a compute play mode. Used to tell the * simulation worker how aggressively to compute new frames. + * + * `viewOnly` intentionally has no backpressure shape: it is a frame viewing + * mode, not a worker computation mode. */ export type PlayModeBackpressure = { maxFramesAhead: number; batchSize: number; }; -export function getPlayModeBackpressure(mode: PlayMode): PlayModeBackpressure { - switch (mode) { - case "viewOnly": - return { maxFramesAhead: 0, batchSize: 0 }; - case "computeBuffer": - return { maxFramesAhead: 40, batchSize: 10 }; - case "computeMax": - return { maxFramesAhead: 10000, batchSize: 500 }; - } +export function getPlayModeBackpressure( + mode: ComputePlayMode, +): PlayModeBackpressure { + return mode === "computeBuffer" + ? { maxFramesAhead: 40, batchSize: 10 } + : { maxFramesAhead: 10000, batchSize: 500 }; } export type PlaybackSnapshot = { 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..65079fe3937 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/ARCHITECTURE.md @@ -0,0 +1,51 @@ +# Simulation Architecture + +The simulation module is split into five boundaries: + +- `api.ts` defines the public Core contract. Consumers receive + `SimulationFrameReader` and summary state, not engine storage objects. +- `authoring/metric/`, `authoring/scenario/`, and `authoring/user-code/` + compile user-authored inputs. Shared same-realm hardening helpers live in + `authoring/sandbox.ts`. +- `engine/` builds SDCPN definitions into runnable state and advances internal + `EngineFrame` state. `EngineFrame` is an `ArrayBuffer`; the + SDCPN-specialized `EngineFrameLayout` lives on the `SimulationInstance`. +- `worker/` owns the transport protocol between the engine worker and runtime. + Worker frame payloads carry binary frame buffers plus orchestration metadata + such as time. +- `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 is currently a +binary `ArrayBuffer` with typed sections for place token counts, place value +offsets, transition timers/counts/flags, and packed token values. It can only be +read with the matching SDCPN-derived layout. + +The public frame path is: + +```ts +const createReader = compileSimulationFrameReader(sdcpn); +const reader = createReader(engineFrame, frameNumber, frameTime); +``` + +Simulation time is owned by the run controller and kept outside `EngineFrame`. +Worker payloads carry it as frame metadata, and `SimulationFrameReader` exposes +it to consumers. Future storage 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/README.md b/libs/@hashintel/petrinaut/src/core/simulation/README.md index 5b851794c39..c621ec1074e 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/README.md @@ -1,79 +1,155 @@ # 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. + +## 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/metric/`: user-authored metric compilation. +- `authoring/scenario/`: user-authored scenario compilation. +- `authoring/user-code/`: engine user-code compilation helpers. +- `authoring/sandbox.ts`: shared same-realm hardening helpers. +- `engine/`: internal SDCPN build/step execution engine. +- `worker/`: worker protocol and runtime entrypoint. ## 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` | JSON-serializable 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 opaque `SimulationTransport` for tests or custom + worker adapters. + +`initialMarking` is keyed by place ID. Uncolored places use a token count +number, while colored places use one record per token: + +```ts +{ + susceptible: 100, + infected: [{ age: 42, viralLoad: 0.8 }] +} +``` ## 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 }`, 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 as a + `SimulationFrameReader`. +- `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"; + +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)), +}); -// In component: -const simulation = use(SimulationContext); -await simulation.initialize({ seed: 42, dt: 0.01, maxFramesAhead: 100, batchSize: 50 }); +const unsubscribe = simulation.frames.subscribe(({ count, latest }) => { + if (latest) { + console.log( + `Computed ${count} frames; place p1 has ${latest.getPlaceTokenCount("p1")} tokens`, + ); + } +}); + +simulation.ack(0); simulation.run(); + +// Later: +unsubscribe(); +simulation.dispose(); ``` 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..fad8a642661 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/api.ts @@ -0,0 +1,161 @@ +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. + * + * This is intentionally JSON-serializable. The simulator is responsible for + * converting it into its internal packed frame representation. + * + * - Uncolored places use a token count. + * - Colored places use one record per token, keyed by color element name. + */ +export type InitialPlaceMarking = number | Record[]; +export type InitialMarking = Record; + +/** + * 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 } + ); + +/** + * Simplified view of a simulation frame for higher-level consumers. + * Provides easy access to place states without internal details. + */ +export type SimulationFrameState = { + /** Frame index in the simulation history */ + number: 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; + }; +}; + +export type SimulationPlaceTokenValues = { + values: Float64Array; + count: number; +}; + +export interface SimulationFrameReader { + /** Frame index in the simulation history. */ + readonly number: number; + /** Simulation time for this frame, in seconds. */ + readonly time: number; + + getPlaceTokenCount(placeId: string): number; + getPlaceTokenValues(placeId: string): SimulationPlaceTokenValues | null; + getPlaceTokens( + place: Place, + color: Color | null | undefined, + ): Record[]; + getTransitionState(transitionId: string): { + /** + * 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; + } | 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/metric/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/metric/compile-metric.test.ts index 018cb98423a..5b7a40aa21d 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/metric/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/metric/compile-metric.ts similarity index 96% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/metric/compile-metric.ts index 85f276fa770..2829ae5f843 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/metric/compile-metric.ts @@ -1,5 +1,5 @@ -import type { Metric } from "../types/sdcpn"; -import { runSandboxed, SHADOWED_GLOBALS } from "./sandbox"; +import type { Metric } from "../../../types/sdcpn"; +import { runSandboxed, SHADOWED_GLOBALS } from "../sandbox"; // -- Public 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/compile-scenario.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/scenario/compile-scenario.test.ts similarity index 63% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/scenario/compile-scenario.test.ts index 0416c0dd501..954f6dfb28b 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/scenario/compile-scenario.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Parameter, Scenario } from "../types/sdcpn"; +import type { Color, Parameter, Place, Scenario } from "../../../types/sdcpn"; import { compileScenario } from "./compile-scenario"; // -- Helpers ------------------------------------------------------------------ @@ -26,6 +26,27 @@ const scenario = (overrides: Partial = {}): Scenario => ({ ...overrides, }); +const place = (id: string, name: string, colorId: string | null): Place => ({ + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); + +const color = (id: string): Color => ({ + id, + name: "Type 1", + iconSlug: "circle", + displayColor: "#000000", + elements: [ + { elementId: "x", name: "x", type: "real" }, + { elementId: "y", name: "y", type: "real" }, + ], +}); + // -- Tests -------------------------------------------------------------------- describe("compileScenario", () => { @@ -65,7 +86,7 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1?.count).toBe(100); + expect(result.result.initialState.place1).toBe(100); } }); @@ -81,7 +102,7 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.result.parameterValues).toEqual({ x: "5", y: "7" }); - expect(result.result.initialState.place1?.count).toBe(0); + expect(result.result.initialState.place1).toBe(0); } }); }); @@ -120,7 +141,57 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1?.count).toBe(50); + expect(result.result.initialState.place1).toBe(50); + } + }); + + it("uses supplied scenario parameter values over defaults", () => { + const result = compileScenario( + scenario({ + scenarioParameters: [ + { type: "integer", identifier: "count", default: 50 }, + ], + parameterOverrides: { p1: "scenario.count * 2" }, + initialState: { + type: "per_place", + content: { place1: "scenario.count" }, + }, + }), + [param("p1", "x", "0")], + [], + [], + { scenarioParameterValues: { count: 75 } }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.parameterValues.x).toBe("150"); + expect(result.result.initialState.place1).toBe(75); + } + }); + + it("rejects non-finite supplied scenario parameter values", () => { + const result = compileScenario( + scenario({ + scenarioParameters: [ + { type: "real", identifier: "rate", default: 1 }, + ], + }), + [], + [], + [], + { scenarioParameterValues: { rate: Number.NaN } }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual([ + { + source: "scenarioParameter", + itemId: "rate", + message: 'Scenario parameter "rate" must be a finite number.', + }, + ]); } }); }); @@ -175,7 +246,7 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1?.count).toBe(1000); + expect(result.result.initialState.place1).toBe(1000); } }); @@ -189,7 +260,7 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1?.count).toBe(4); + expect(result.result.initialState.place1).toBe(4); } }); @@ -203,34 +274,57 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1?.count).toBe(0); + expect(result.result.initialState.place1).toBe(0); } }); }); describe("colored places (number[][] data)", () => { - it("passes through number[][] as flattened values", () => { + it("converts number[][] to token records", () => { const result = compileScenario( scenario({ initialState: { type: "per_place", content: { place1: [ - [1, 2, 3], - [4, 5, 6], + [1, 2], + [4, 5], ], }, }, }), [], + [ + { + id: "place1", + name: "Place 1", + colorId: "type1", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + [ + { + id: "type1", + name: "Type 1", + iconSlug: "circle", + displayColor: "#000000", + elements: [ + { elementId: "x", name: "x", type: "real" }, + { elementId: "y", name: "y", type: "real" }, + ], + }, + ], ); expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1).toEqual({ - values: [1, 2, 3, 4, 5, 6], - count: 2, - }); + expect(result.result.initialState.place1).toEqual([ + { x: 1, y: 2 }, + { x: 4, y: 5 }, + ]); } }); @@ -247,10 +341,29 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.place1).toEqual({ - values: [], - count: 0, - }); + expect(result.result.initialState.place1).toEqual([]); + } + }); + + it("converts empty colored token rows to zero-valued token records", () => { + const result = compileScenario( + scenario({ + initialState: { + type: "per_place", + content: { place1: [[], []] }, + }, + }), + [], + [place("place1", "Place 1", "type1")], + [color("type1")], + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.initialState.place1).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ]); } }); @@ -269,18 +382,126 @@ describe("compileScenario", () => { }, }), [], + [ + place("uncolored", "Uncolored", null), + place("colored", "Colored", "type1"), + ], + [color("type1")], ); expect(result.ok).toBe(true); if (result.ok) { - expect(result.result.initialState.uncolored).toEqual({ - values: [], - count: 42, - }); - expect(result.result.initialState.colored).toEqual({ - values: [10, 20, 30, 40], - count: 2, - }); + expect(result.result.initialState.uncolored).toBe(42); + expect(result.result.initialState.colored).toEqual([ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ]); + } + }); + + it("reports colored token rows when place metadata is missing", () => { + const result = compileScenario( + scenario({ + initialState: { + type: "per_place", + content: { + colored: [[10, 20]], + }, + }, + }), + [], + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual([ + { + source: "initialState", + itemId: "colored", + message: + 'Initial state for place "colored" uses colored token rows, but the place does not exist.', + }, + ]); + } + }); + + it("reports empty colored token rows when place metadata is missing", () => { + const result = compileScenario( + scenario({ + initialState: { + type: "per_place", + content: { + colored: [[], []], + }, + }, + }), + [], + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual([ + { + source: "initialState", + itemId: "colored", + message: + 'Initial state for place "colored" uses colored token rows, but the place does not exist.', + }, + ]); + } + }); + + it("reports colored token rows when color elements are missing", () => { + const result = compileScenario( + scenario({ + initialState: { + type: "per_place", + content: { + colored: [[10, 20]], + }, + }, + }), + [], + [place("colored", "Colored", "missing-type")], + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual([ + { + source: "initialState", + itemId: "colored", + message: + 'Initial state for place "colored" uses colored token rows, but the place has no color elements.', + }, + ]); + } + }); + + it("reports empty colored token rows when color elements are missing", () => { + const result = compileScenario( + scenario({ + initialState: { + type: "per_place", + content: { + colored: [[], []], + }, + }, + }), + [], + [place("colored", "Colored", "missing-type")], + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual([ + { + source: "initialState", + itemId: "colored", + message: + 'Initial state for place "colored" uses colored token rows, but the place has no color elements.', + }, + ]); } }); }); @@ -301,7 +522,7 @@ describe("compileScenario", () => { expect(result.ok).toBe(true); if (result.ok) { // parameters.x should be the overridden 99, not the default 1 - expect(result.result.initialState.place1?.count).toBe(99); + expect(result.result.initialState.place1).toBe(99); } }); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/scenario/compile-scenario.ts similarity index 66% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/scenario/compile-scenario.ts index 495f497ca4d..68ce3eeebbb 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/scenario/compile-scenario.ts @@ -1,29 +1,26 @@ -import type { Color, Parameter, Place, Scenario } from "../types/sdcpn"; -import { runSandboxed, SHADOWED_GLOBALS } from "./sandbox"; +import type { Color, Parameter, Place, Scenario } from "../../../types/sdcpn"; +import type { InitialMarking, InitialPlaceMarking } from "../../api"; +import { runSandboxed, SHADOWED_GLOBALS } from "../sandbox"; // -- Result types ------------------------------------------------------------- /** * Compiled initial state entry for a single place. - * - Uncolored places: `values` is empty, `count` is the token count. - * - Colored places: `values` is the flattened element data, `count` is the - * number of tokens (rows). + * - Uncolored places: token count number. + * - Colored places: array of token records keyed by color element name. */ -export interface CompiledPlaceMarking { - values: number[]; - count: number; -} +export type CompiledPlaceMarking = InitialPlaceMarking; 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; /** * Resolved initial marking keyed by place ID. */ - initialState: Record; + initialState: InitialMarking; } export interface ScenarioCompilationError { @@ -39,6 +36,16 @@ export type CompileScenarioOutcome = | { ok: true; result: CompiledScenarioResult } | { ok: false; errors: ScenarioCompilationError[] }; +export type ScenarioParameterValues = Record; + +export interface CompileScenarioOptions { + /** + * Concrete scenario parameter values keyed by scenario parameter identifier. + * When omitted, the scenario's own default values are used. + */ + scenarioParameterValues?: ScenarioParameterValues; +} + // -- Hardened expression evaluator -------------------------------------------- /** @@ -77,6 +84,48 @@ function evaluateExpression( ); } +function tokenRecordsFromRows( + rows: number[][], + elements: Color["elements"], +): Record[] { + return rows.map((row) => { + const token: Record = {}; + for (let i = 0; i < elements.length; i++) { + token[elements[i]!.name] = row[i] ?? 0; + } + return token; + }); +} + +function normalizeTokenRecords( + tokens: unknown[], + elements: Color["elements"], +): Record[] { + return tokens.flatMap((rawToken) => { + if ( + typeof rawToken !== "object" || + rawToken === null || + Array.isArray(rawToken) + ) { + return []; + } + + const source = rawToken as Record; + const token: Record = {}; + const entries = + elements.length > 0 + ? elements.map( + (element) => [element.name, source[element.name]] as const, + ) + : Object.entries(source); + + for (const [name, value] of entries) { + token[name] = Number(value ?? 0); + } + return [token]; + }); +} + // -- Compiler ----------------------------------------------------------------- /** @@ -99,6 +148,7 @@ export function compileScenario( netParameters: Parameter[], places: Place[] = [], types: Color[] = [], + options: CompileScenarioOptions = {}, ): CompileScenarioOutcome { const errors: ScenarioCompilationError[] = []; @@ -109,7 +159,20 @@ export function compileScenario( if (sp.identifier.trim() === "") { continue; } - scenarioObj[sp.identifier] = sp.default; + + const value = + options.scenarioParameterValues?.[sp.identifier] ?? sp.default; + if (!Number.isFinite(value)) { + errors.push({ + source: "scenarioParameter", + itemId: sp.identifier, + message: `Scenario parameter "${sp.identifier}" must be a finite number.`, + }); + scenarioObj[sp.identifier] = sp.default; + continue; + } + + scenarioObj[sp.identifier] = value; } // ── Step 2: Evaluate parameter overrides ── @@ -159,7 +222,10 @@ export function compileScenario( // ── Step 3: Evaluate initial state ── - const initialState: Record = {}; + const initialState: InitialMarking = {}; + const placeById = new Map(places.map((p) => [p.id, p])); + const placeByName = new Map(places.map((p) => [p.name, p])); + const typeById = new Map(types.map((t) => [t.id, t])); if (scenario.initialState.type === "code") { // Code mode: evaluate the full code block as a function body. @@ -184,10 +250,6 @@ export function compileScenario( message: `Initial state code must return an object, got ${typeof result}.`, }); } else { - // Build lookups: placeName → placeId, placeId → color elements - const placeByName = new Map(places.map((p) => [p.name, p])); - const typeById = new Map(types.map((t) => [t.id, t])); - for (const [placeName, tokens] of Object.entries(result)) { const place = placeByName.get(placeName); if (!place) { @@ -196,30 +258,14 @@ export function compileScenario( if (typeof tokens === "number") { // Uncolored place: just a token count - initialState[place.id] = { - values: [], - count: Math.max(0, Math.round(tokens)), - }; + initialState[place.id] = Math.max(0, Math.round(tokens)); } else if (Array.isArray(tokens)) { - // Colored place: array of token objects → flatten + // Colored place: array of token objects. const color = place.colorId ? typeById.get(place.colorId) : undefined; const elements = color?.elements ?? []; - const flat: number[] = []; - for (const token of tokens) { - if (typeof token === "object" && token !== null) { - for (const el of elements) { - flat.push( - Number((token as Record)[el.name] ?? 0), - ); - } - } - } - initialState[place.id] = { - values: flat, - count: tokens.length, - }; + initialState[place.id] = normalizeTokenRecords(tokens, elements); } } } @@ -236,22 +282,52 @@ export function compileScenario( for (const [placeId, value] of Object.entries( scenario.initialState.content, )) { - // Colored places: number[][] stored directly — flatten to values + count + // Colored places: number[][] stored directly by the UI. if (Array.isArray(value)) { - const flat: number[] = []; - for (const row of value) { - for (const v of row) { - flat.push(v); - } + const place = placeById.get(placeId); + const color = place?.colorId ? typeById.get(place.colorId) : undefined; + const hasTokenRows = value.length > 0; + + if (hasTokenRows && !place) { + errors.push({ + source: "initialState", + itemId: placeId, + message: `Initial state for place "${placeId}" uses colored token rows, but the place does not exist.`, + }); + continue; + } + + if (hasTokenRows && (!color || color.elements.length === 0)) { + errors.push({ + source: "initialState", + itemId: placeId, + message: `Initial state for place "${placeId}" uses colored token rows, but the place has no color elements.`, + }); + continue; } - initialState[placeId] = { values: flat, count: value.length }; + + const elementCount = color?.elements.length ?? 0; + const tooWideRow = value.find((row) => row.length > elementCount); + if (tooWideRow) { + errors.push({ + source: "initialState", + itemId: placeId, + message: `Initial state for place "${placeId}" has ${tooWideRow.length} values per token, but the color type has ${elementCount} elements.`, + }); + continue; + } + + initialState[placeId] = tokenRecordsFromRows( + value, + color?.elements ?? [], + ); continue; } // Uncolored places: expression string → evaluate to token count const trimmed = value.trim(); if (trimmed === "") { - initialState[placeId] = { values: [], count: 0 }; + initialState[placeId] = 0; continue; } try { @@ -264,10 +340,7 @@ export function compileScenario( }); continue; } - initialState[placeId] = { - values: [], - count: Math.max(0, Math.round(result)), - }; + initialState[placeId] = Math.max(0, Math.round(result)); } catch (err) { errors.push({ source: "initialState", @@ -282,7 +355,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/compile-user-code.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/user-code/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/authoring/user-code/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/authoring/user-code/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/authoring/user-code/compile-user-code.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/user-code/distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/user-code/distribution.ts new file mode 100644 index 00000000000..c7f881cde23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/user-code/distribution.ts @@ -0,0 +1,66 @@ +type DistributionBase = { + __brand: "distribution"; + /** Cached sampled value. Set after first sample so that multiple + * `.map()` calls on the same distribution share one draw. */ + sampledValue?: number; +}; + +/** + * Runtime representation of a probability distribution. + * Created by user code via Distribution.Gaussian() or Distribution.Uniform(), + * then sampled during transition kernel output resolution. + */ +export type RuntimeDistribution = + | (DistributionBase & { + type: "gaussian"; + mean: number; + deviation: number; + }) + | (DistributionBase & { type: "uniform"; min: number; max: number }) + | (DistributionBase & { + type: "lognormal"; + mu: number; + sigma: number; + }) + | (DistributionBase & { + type: "mapped"; + inner: RuntimeDistribution; + fn: (value: number) => number; + }); + +/** + * Checks if a value is a RuntimeDistribution object. + */ +export function isDistribution(value: unknown): value is RuntimeDistribution { + return ( + typeof value === "object" && + value !== null && + "__brand" in value && + (value as Record).__brand === "distribution" + ); +} + +/** + * JavaScript source code that defines the Distribution namespace at runtime. + * Injected into the compiled user code execution context so that + * Distribution.Gaussian() and Distribution.Uniform() are available. + */ +export const distributionRuntimeCode = ` + function __addMap(dist) { + dist.map = function(fn) { + return __addMap({ __brand: "distribution", type: "mapped", inner: dist, fn: fn }); + }; + return dist; + } + var Distribution = { + Gaussian: function(mean, deviation) { + return __addMap({ __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation }); + }, + Uniform: function(min, max) { + return __addMap({ __brand: "distribution", type: "uniform", min: min, max: max }); + }, + Lognormal: function(mu, sigma) { + return __addMap({ __brand: "distribution", type: "lognormal", mu: mu, sigma: sigma }); + } + }; +`; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md b/libs/@hashintel/petrinaut/src/core/simulation/engine/README.md similarity index 50% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md rename to libs/@hashintel/petrinaut/src/core/simulation/engine/README.md index 977acff8d5e..3d8b892ecba 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/README.md @@ -1,10 +1,13 @@ -# 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 builds an SDCPN definition into a runnable `SimulationInstance` and +computes frames by evaluating transitions and differential equations. It imports +user-code compilation helpers from `authoring/user-code/`, but owns the +runtime stepping state and frame layout. ## Core Functions @@ -27,26 +30,29 @@ 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 ``` -## SimulationFrame +## Internal EngineFrame -A snapshot of simulation state at a point in time. +A snapshot of simulation state. The run controller owns frame number and +simulation time; `EngineFrame` only stores the state needed to advance the +simulation. Public callers should read frames through `SimulationFrameReader`. ```typescript -type SimulationFrame = { - time: number; - places: Record; - transitions: Record; - buffer: Float64Array; // Token values storage -}; +type EngineFrame = ArrayBuffer; ``` +The frame does not contain place or transition IDs. `SimulationInstance` owns an +`EngineFrameLayout` derived from the SDCPN and passes it to internal readers and +writers. + ## Token Value Storage -Token values are stored in a flat `Float64Array` buffer for performance. +Token values are stored in a packed `Float64Array` section inside the frame +buffer. Place counts and value offsets are stored in separate `Uint32Array` +sections, so a place can be accessed in O(1) when the SDCPN layout is known. ```text Place p1: offset=0, count=2, dimensions=3 @@ -59,6 +65,7 @@ buffer: [v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.a, v3.b] Access: ```typescript -const startIdx = place.offset + tokenIdx * place.dimensions; -const values = buffer.slice(startIdx, startIdx + place.dimensions); +const frameView = readEngineFrame(simulation.frameLayout, frame); +const place = frameView.getPlaceState("p1"); +const values = frameView.getPlaceTokenValues("p1"); ``` 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 84% 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 index 37d15adaed0..7d161a38e20 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { materializeEngineFrame } from "../frames/internal-frame"; import { buildSimulation } from "./build-simulation"; import type { SimulationInput } from "./types"; @@ -41,15 +42,12 @@ describe("buildSimulation", () => { ], transitions: [], }, - initialMarking: new Map([ - [ - "p1", - { - values: new Float64Array([1.0, 2.0, 3.0, 4.0]), - count: 2, // 2 tokens with 2 dimensions each - }, + initialMarking: { + p1: [ + { x: 1.0, y: 2.0 }, + { x: 3.0, y: 4.0 }, ], - ]), + }, parameterValues: {}, seed: 42, dt: 0.1, @@ -57,17 +55,21 @@ describe("buildSimulation", () => { }; const simulationInstance = buildSimulation(input); - const frame = simulationInstance.frames[0]!; + const engineFrame = simulationInstance.frames[0]!; + const frame = materializeEngineFrame( + simulationInstance.frameLayout, + engineFrame, + ); // Verify simulation instance properties expect(simulationInstance.dt).toBe(0.1); expect(simulationInstance.rngState).toBe(42); expect(simulationInstance.currentFrameNumber).toBe(0); expect(simulationInstance.frames).toHaveLength(1); - expect(simulationInstance.frames[0]).toBe(frame); + expect(simulationInstance.frames[0]).toBe(engineFrame); // Verify initial frame properties - expect(frame.time).toBe(0); + expect(simulationInstance.currentTime).toBe(0); expect(Object.keys(frame.places).length).toBe(1); expect(Object.keys(frame.transitions).length).toBe(0); @@ -181,23 +183,11 @@ describe("buildSimulation", () => { }, ], }, - initialMarking: new Map([ - [ - "p1", - { - values: new Float64Array([10.0, 20.0, 30.0]), - count: 3, // 3 tokens with 1 dimension each - }, - ], - [ - "p2", - { - values: new Float64Array([1.0, 2.0]), - count: 1, // 1 token with 2 dimensions - }, - ], + initialMarking: { + p1: [{ x: 10.0 }, { x: 20.0 }, { x: 30.0 }], + p2: [{ x: 1.0, y: 2.0 }], // p3 has no initial tokens - ]), + }, parameterValues: {}, seed: 123, dt: 0.05, @@ -205,7 +195,10 @@ describe("buildSimulation", () => { }; const simulationInstance = buildSimulation(input); - const frame = simulationInstance.frames[0]!; + const frame = materializeEngineFrame( + simulationInstance.frameLayout, + simulationInstance.frames[0]!, + ); // Verify simulation instance properties expect(simulationInstance.dt).toBe(0.05); @@ -250,20 +243,21 @@ 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); - expect(simulationInstance.lambdaFns.size).toBe(2); - expect(simulationInstance.transitionKernelFns.size).toBe(2); + expect(simulationInstance.compiledTransitions.size).toBe(2); // Verify compiled functions are callable - const lambdaFn = simulationInstance.lambdaFns.get("t1"); - expect(lambdaFn).toBeDefined(); - expect(typeof lambdaFn).toBe("function"); + const compiledTransition = simulationInstance.compiledTransitions.get("t1"); + expect(compiledTransition).toBeDefined(); + expect(typeof compiledTransition?.lambdaFn).toBe("function"); - const kernelFn = simulationInstance.transitionKernelFns.get("t2"); - expect(kernelFn).toBeDefined(); - expect(typeof kernelFn).toBe("function"); + const kernelTransition = simulationInstance.compiledTransitions.get("t2"); + expect(kernelTransition).toBeDefined(); + expect(typeof kernelTransition?.transitionKernelFn).toBe("function"); }); it("throws error when initialMarking references non-existent place", () => { @@ -300,15 +294,9 @@ describe("buildSimulation", () => { ], transitions: [], }, - initialMarking: new Map([ - [ - "p_nonexistent", - { - values: new Float64Array([1.0]), - count: 1, - }, - ], - ]), + initialMarking: { + p_nonexistent: [{ x: 1.0 }], + }, parameterValues: {}, seed: 42, dt: 0.1, @@ -320,7 +308,7 @@ describe("buildSimulation", () => { ); }); - it("throws error when token dimensions don't match place dimensions", () => { + it("throws error when colored initial marking is not token records", () => { const input: SimulationInput = { sdcpn: { types: [ @@ -357,15 +345,9 @@ describe("buildSimulation", () => { ], transitions: [], }, - initialMarking: new Map([ - [ - "p1", - { - values: new Float64Array([1.0, 2.0, 3.0]), // 3 values for 2 tokens = wrong - count: 2, // 2 tokens × 2 dimensions = 4 values expected - }, - ], - ]), + initialMarking: { + p1: 2, + }, parameterValues: {}, seed: 42, dt: 0.1, @@ -373,7 +355,7 @@ describe("buildSimulation", () => { }; expect(() => buildSimulation(input)).toThrow( - "Token dimension mismatch for place p1. Expected 4 values (2 dimensions × 2 tokens), got 3", + "Initial marking for colored place p1 must be an array of token records", ); }); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts new file mode 100644 index 00000000000..f1714908f1a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/build-simulation.ts @@ -0,0 +1,521 @@ +import { SDCPNItemError } from "../../errors"; +import { + deriveDefaultParameterValues, + mergeParameterValues, +} from "../../parameter-values"; +import { compileUserCode } from "../authoring/user-code/compile-user-code"; +import { + createEngineFrame, + createEngineFrameLayout, + type EngineFrameSnapshot, +} from "../frames/internal-frame"; +import type { + CompiledTransition, + DifferentialEquationFn, + LambdaFn, + ParameterValues, + SimulationInput, + SimulationInstance, + TransitionKernelOutput, + TransitionKernelFn, + TransitionTokenValues, +} from "./types"; + +type PackedInitialPlaceMarking = { + values: number[]; + count: number; +}; + +type UserDifferentialEquationFn = ( + tokens: Record[], + parameters: ParameterValues, +) => Record[]; + +type UserLambdaFn = ( + tokenValues: TransitionTokenValues, + parameters: ParameterValues, +) => number | boolean; + +type UserTransitionKernelFn = ( + tokenValues: TransitionTokenValues, + parameters: ParameterValues, +) => TransitionKernelOutput; + +function getInitialMarkingValue( + initialMarking: SimulationInput["initialMarking"], + placeId: string, +): SimulationInput["initialMarking"][string] | undefined { + return Object.prototype.hasOwnProperty.call(initialMarking, placeId) + ? initialMarking[placeId] + : undefined; +} + +/** + * Get the dimensions (number of elements) for a place based on its type. + * If the place has no type, returns 0. + */ +function getPlaceDimensions( + place: SimulationInput["sdcpn"]["places"][0], + sdcpn: SimulationInput["sdcpn"], +): number { + if (!place.colorId) { + return 0; + } + const type = sdcpn.types.find((tp) => tp.id === place.colorId); + if (!type) { + throw new Error( + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + return type.elements.length; +} + +function packInitialPlaceMarking( + place: SimulationInput["sdcpn"]["places"][0], + sdcpn: SimulationInput["sdcpn"], + value: SimulationInput["initialMarking"][string] | undefined, +): PackedInitialPlaceMarking { + const dimensions = getPlaceDimensions(place, sdcpn); + + if (value === undefined) { + return { values: [], count: 0 }; + } + + if (dimensions === 0) { + if (typeof value !== "number") { + throw new Error( + `Initial marking for uncolored place ${place.id} must be a token count number`, + ); + } + return { values: [], count: Math.max(0, Math.round(value)) }; + } + + if (!Array.isArray(value)) { + throw new Error( + `Initial marking for colored place ${place.id} must be an array of token records`, + ); + } + + const type = sdcpn.types.find((tp) => tp.id === place.colorId); + if (!type) { + throw new Error( + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + + const tokenRecords: unknown[] = value; + const values: number[] = []; + for (const token of tokenRecords) { + if (typeof token !== "object" || token === null || Array.isArray(token)) { + throw new Error( + `Initial marking token for place ${place.id} must be a record`, + ); + } + const tokenRecord = token as Record; + for (const element of type.elements) { + values.push(Number(tokenRecord[element.name] ?? 0)); + } + } + + return { values, count: value.length }; +} + +function createDifferentialEquationFn({ + placeId, + elementNames, + parameterValues, + userFn, +}: { + placeId: string; + elementNames: string[]; + parameterValues: ParameterValues; + userFn: UserDifferentialEquationFn; +}): DifferentialEquationFn { + const expectedDimensions = elementNames.length; + + return (currentState, dimensions, numberOfTokens) => { + if (dimensions !== expectedDimensions) { + throw new Error( + `Place ${placeId} has ${dimensions} dimensions in frame, expected ${expectedDimensions}`, + ); + } + + const inputTokens: Record[] = []; + for (let tokenIndex = 0; tokenIndex < numberOfTokens; tokenIndex++) { + const tokenStart = tokenIndex * dimensions; + const token: Record = {}; + for ( + let dimensionIndex = 0; + dimensionIndex < dimensions; + dimensionIndex++ + ) { + token[elementNames[dimensionIndex]!] = + currentState[tokenStart + dimensionIndex]!; + } + inputTokens.push(token); + } + + const resultTokens = userFn(inputTokens, parameterValues); + const result = new Float64Array(numberOfTokens * dimensions); + const tokenCount = Math.min(resultTokens.length, numberOfTokens); + + for (let tokenIndex = 0; tokenIndex < tokenCount; tokenIndex++) { + const token = resultTokens[tokenIndex]!; + for ( + let dimensionIndex = 0; + dimensionIndex < dimensions; + dimensionIndex++ + ) { + const dimensionName = elementNames[dimensionIndex]!; + result[tokenIndex * dimensions + dimensionIndex] = + token[dimensionName]!; + } + } + + return result; + }; +} + +function getPlaceElementNames( + placeId: string, + placesMap: ReadonlyMap, + typesMap: ReadonlyMap, +): readonly string[] | null { + const place = placesMap.get(placeId); + if (!place) { + throw new Error( + `Place with ID ${placeId} referenced by transition does not exist in SDCPN`, + ); + } + + if (!place.colorId) { + return null; + } + + const type = typesMap.get(place.colorId); + if (!type) { + throw new Error( + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + + return type.elements.map((element) => element.name); +} + +function createLambdaFn( + transition: SimulationInput["sdcpn"]["transitions"][number], + parameterValues: ParameterValues, +): LambdaFn { + try { + const userFn = compileUserCode<[TransitionTokenValues, ParameterValues]>( + transition.lambdaCode, + "Lambda", + ) as UserLambdaFn; + + return (tokenValues) => userFn(tokenValues, parameterValues); + } catch (error) { + throw new SDCPNItemError( + `Failed to compile Lambda function for transition \`${ + transition.name + }\`:\n\n${error instanceof Error ? error.message : String(error)}`, + transition.id, + ); + } +} + +function createTransitionKernelFn({ + transition, + placesMap, + parameterValues, +}: { + transition: SimulationInput["sdcpn"]["transitions"][number]; + placesMap: ReadonlyMap; + parameterValues: ParameterValues; +}): TransitionKernelFn { + const hasTypedOutputPlace = transition.outputArcs.some((arc) => { + const place = placesMap.get(arc.placeId); + return Boolean(place?.colorId); + }); + + if (!hasTypedOutputPlace) { + return () => ({}); + } + + try { + const userFn = compileUserCode<[TransitionTokenValues, ParameterValues]>( + transition.transitionKernelCode, + "TransitionKernel", + ) as UserTransitionKernelFn; + + return (tokenValues) => userFn(tokenValues, parameterValues); + } catch (error) { + throw new SDCPNItemError( + `Failed to compile transition kernel for transition \`${ + transition.name + }\`:\n\n${error instanceof Error ? error.message : String(error)}`, + transition.id, + ); + } +} + +function createCompiledTransition({ + transition, + placesMap, + typesMap, + lambdaFn, + transitionKernelFn, +}: { + transition: SimulationInput["sdcpn"]["transitions"][number]; + placesMap: ReadonlyMap; + typesMap: ReadonlyMap; + lambdaFn: LambdaFn; + transitionKernelFn: TransitionKernelFn; +}): CompiledTransition { + return { + id: transition.id, + name: transition.name, + inputPlaces: transition.inputArcs.map((arc) => { + const place = placesMap.get(arc.placeId); + if (!place) { + throw new Error( + `Input place with ID ${arc.placeId} referenced by transition ${transition.id} does not exist in SDCPN`, + ); + } + + return { + placeId: arc.placeId, + placeName: place.name, + weight: arc.weight, + arcType: arc.type, + elementNames: getPlaceElementNames(arc.placeId, placesMap, typesMap), + }; + }), + outputPlaces: transition.outputArcs.map((arc) => { + const place = placesMap.get(arc.placeId); + if (!place) { + throw new Error( + `Output place with ID ${arc.placeId} referenced by transition ${transition.id} does not exist in SDCPN`, + ); + } + + return { + placeId: arc.placeId, + placeName: place.name, + weight: arc.weight, + elementNames: getPlaceElementNames(arc.placeId, placesMap, typesMap), + }; + }), + lambdaFn, + transitionKernelFn, + }; +} + +/** + * Builds a simulation instance and its initial frame from simulation input. + * + * Takes a SimulationInput containing: + * - SDCPN definition (places, transitions, and their code) + * - Initial marking (JSON-serializable token distribution across places) + * - Random seed + * - Time step (dt) + * + * 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 + * + * @param input - The simulation input configuration + * @returns The initial simulation frame ready for execution + * @throws {Error} if place IDs in initialMarking don't match places in SDCPN + * @throws {Error} if a place marking does not match the place color shape + * @throws {Error} if user code fails to compile + */ +export function buildSimulation(input: SimulationInput): SimulationInstance { + const { + sdcpn, + initialMarking, + parameterValues: inputParameterValues, + seed, + dt, + maxTime, + } = input; + + // Build maps for quick lookup + const placesMap = new Map(sdcpn.places.map((place) => [place.id, place])); + const transitionsMap = new Map( + sdcpn.transitions.map((transition) => [transition.id, transition]), + ); + const typesMap = new Map(sdcpn.types.map((type) => [type.id, type])); + + // Build parameter values: merge input values with SDCPN defaults + // Input values (from simulation store) take precedence over defaults + const defaultParameterValues = deriveDefaultParameterValues(sdcpn.parameters); + const parameterValues = mergeParameterValues( + inputParameterValues, + defaultParameterValues, + ); + + // Validate that all places in initialMarking exist in SDCPN + for (const placeId of Object.keys(initialMarking)) { + if (!placesMap.has(placeId)) { + throw new Error( + `Place with ID ${placeId} in initialMarking does not exist in SDCPN`, + ); + } + } + + const packedInitialMarking = new Map(); + for (const place of sdcpn.places) { + packedInitialMarking.set( + place.id, + packInitialPlaceMarking( + place, + sdcpn, + getInitialMarkingValue(initialMarking, place.id), + ), + ); + } + + // Compile all differential equation functions + const differentialEquationFns = new Map(); + for (const place of sdcpn.places) { + // Skip places without dynamics enabled or without differential equation code + if (!place.dynamicsEnabled || !place.differentialEquationId) { + continue; + } + + const differentialEquation = sdcpn.differentialEquations.find( + (de) => de.id === place.differentialEquationId, + ); + if (!differentialEquation) { + throw new Error( + `Differential equation with ID ${place.differentialEquationId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + const { code } = differentialEquation; + + try { + if (!place.colorId) { + continue; + } + + const type = typesMap.get(place.colorId); + if (!type) { + throw new Error( + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + + const userFn = compileUserCode< + [Record[], ParameterValues] + >(code, "Dynamics") as UserDifferentialEquationFn; + differentialEquationFns.set( + place.id, + createDifferentialEquationFn({ + placeId: place.id, + elementNames: type.elements.map((element) => element.name), + parameterValues, + userFn, + }), + ); + } catch (error) { + throw new SDCPNItemError( + `Failed to compile differential equation for place \`${ + place.name + }\`:\n\n${error instanceof Error ? error.message : String(error)}`, + place.id, + ); + } + } + + // Compile transitions into the shape used by the execution hot path. + const compiledTransitions = new Map(); + for (const transition of sdcpn.transitions) { + compiledTransitions.set( + transition.id, + createCompiledTransition({ + transition, + placesMap, + typesMap, + lambdaFn: createLambdaFn(transition, parameterValues), + transitionKernelFn: createTransitionKernelFn({ + transition, + placesMap, + parameterValues, + }), + }), + ); + } + + // Calculate buffer size and build place states + let bufferSize = 0; + const frameLayout = createEngineFrameLayout(sdcpn); + const placeStates: EngineFrameSnapshot["places"] = {}; + + for (const placeId of frameLayout.placeIds) { + const place = placesMap.get(placeId)!; + const marking = packedInitialMarking.get(placeId); + const count = marking?.count ?? 0; + const dimensions = getPlaceDimensions(place, sdcpn); + + placeStates[placeId] = { + offset: bufferSize, + count, + dimensions, + }; + + bufferSize += dimensions * count; + } + + // Build the initial buffer with token values + const buffer = new Float64Array(bufferSize); + let bufferIndex = 0; + + for (const placeId of frameLayout.placeIds) { + const marking = packedInitialMarking.get(placeId); + if (marking && marking.count > 0) { + for (let i = 0; i < marking.values.length; i++) { + buffer[bufferIndex++] = marking.values[i]!; + } + } + } + + // Initialize transition states + const transitionStates: EngineFrameSnapshot["transitions"] = {}; + for (const transition of sdcpn.transitions) { + transitionStates[transition.id] = { + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, + }; + } + + // Create the simulation instance (without frames initially) + const simulationInstance: SimulationInstance = { + places: placesMap, + transitions: transitionsMap, + types: typesMap, + differentialEquationFns, + compiledTransitions, + parameterValues, + dt, + maxTime, + currentTime: 0, + rngState: seed, + frameLayout, + frames: [], // Will be populated with the initial frame + currentFrameNumber: 0, + }; + + // Create the initial frame + const initialFrame = createEngineFrame(frameLayout, { + places: placeStates, + transitions: transitionStates, + buffer, + }); + + // Add the initial frame to the simulation instance + simulationInstance.frames.push(initialFrame); + + return simulationInstance; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts new file mode 100644 index 00000000000..1e7428a2b0a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from "vitest"; + +import type { InputArc, OutputArc, Place, Transition } from "../../types/sdcpn"; +import { + createEngineFrame, + createEngineFrameLayout, + type EngineFrameLayout, + type EngineFrameSnapshot, +} from "../frames/internal-frame"; +import { + checkTransitionEnablement, + isTransitionStructurallyEnabled, +} from "./check-transition-enablement"; +import type { EngineFrame } 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])); +} + +type TestFrame = EngineFrame & { layout: EngineFrameLayout }; + +function makePlace(id: string): Place { + return { + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeFrame({ + places, + transitions, +}: { + places: EngineFrameSnapshot["places"]; + transitions: Transition[]; +}): TestFrame { + const layout = createEngineFrameLayout({ + places: Object.keys(places).map(makePlace), + transitions, + types: [], + }); + const frame = createEngineFrame(layout, { + places, + transitions: Object.fromEntries( + transitions.map((transition) => [transition.id, transitionState]), + ), + buffer: new Float64Array([]), + }) as TestFrame; + Object.defineProperty(frame, "layout", { value: layout }); + return frame; +} + +describe("isTransitionStructurallyEnabled", () => { + it("returns true when input place has sufficient tokens", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(true); + }); + + it("returns false when input place has insufficient tokens", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + + it("respects arc weights when checking enablement", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + + it("checks all input places for enablement", () => { + 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, dimensions: 0 }, + }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + + it("returns true for inhibitor arc when place has fewer tokens than weight", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(true); + }); + + it("returns false for inhibitor arc when place has enough tokens", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + + it("returns false for inhibitor arc when place has exactly the weight in tokens", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + + it("returns true for inhibitor arc when place is empty", () => { + 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]), + frame.layout, + "t1", + ), + ).toBe(true); + }); + + it("checks mixed standard and inhibitor arcs together", () => { + 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: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + frame.layout, + "t1", + ), + ).toBe(true); + }); + + it("returns false when standard arc is satisfied but inhibitor arc is not", () => { + 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: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + + it("returns true for transitions with no input arcs", () => { + const transition = makeTransition({ + inputArcs: [], + outputArcs: [{ placeId: "p1", weight: 1 }], + }); + const frame = makeFrame({ places: {}, transitions: [transition] }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + frame.layout, + "t1", + ), + ).toBe(true); + }); +}); + +describe("checkTransitionEnablement", () => { + it("returns hasEnabledTransition=true when at least one transition is 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: 1, dimensions: 0 }, + p2: { offset: 0, count: 0, dimensions: 0 }, + }, + transitions, + }); + + const result = checkTransitionEnablement( + frame, + makeTransitionMap(transitions), + frame.layout, + ); + + 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", () => { + 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, + }); + + const result = checkTransitionEnablement( + frame, + makeTransitionMap(transitions), + frame.layout, + ); + + expect(result.hasEnabledTransition).toBe(false); + expect(result.transitionStatus.get("t1")).toBe(false); + expect(result.transitionStatus.get("t2")).toBe(false); + }); + + it("returns hasEnabledTransition=false when there are no transitions", () => { + const frame = makeFrame({ places: {}, transitions: [] }); + + const result = checkTransitionEnablement( + frame, + makeTransitionMap([]), + frame.layout, + ); + + expect(result.hasEnabledTransition).toBe(false); + expect(result.transitionStatus.size).toBe(0); + }); + + it("returns all transitions enabled when all have sufficient tokens", () => { + 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), + frame.layout, + ); + + expect(result.hasEnabledTransition).toBe(true); + expect(result.transitionStatus.get("t1")).toBe(true); + expect(result.transitionStatus.get("t2")).toBe(true); + expect(result.transitionStatus.get("t3")).toBe(true); + }); +}); 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 65% 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 index 94ef6532ce8..6f0b5316790 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/check-transition-enablement.ts @@ -1,4 +1,10 @@ -import type { SimulationFrame } from "./types"; +import type { Transition } from "../../types/sdcpn"; +import { materializeEngineFrame } from "../frames/internal-frame"; +import type { + EngineFrame, + EngineFrameLayout, + EngineFrameSnapshot, +} from "./types"; /** * Result of checking transition enablement for a simulation frame. @@ -26,21 +32,29 @@ 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, +function isTransitionStructurallyEnabledSnapshot( + snapshot: EngineFrameSnapshot, + transitions: ReadonlyMap, transitionId: string, -): boolean => { - const transition = frame.transitions[transitionId]; - if (!transition) { +): boolean { + if (!snapshot.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) => { - const placeState = frame.places[arc.placeId]; + return transition.inputArcs.every((arc) => { + const placeState = snapshot.places[arc.placeId]; if (!placeState) { throw new Error( `Place with ID ${arc.placeId} not found in current marking.`, @@ -51,6 +65,19 @@ export const isTransitionStructurallyEnabled = ( ? placeState.count < arc.weight : placeState.count >= arc.weight; }); +} + +export const isTransitionStructurallyEnabled = ( + frame: EngineFrame, + transitions: ReadonlyMap, + layout: EngineFrameLayout, + transitionId: string, +): boolean => { + return isTransitionStructurallyEnabledSnapshot( + materializeEngineFrame(layout, frame), + transitions, + transitionId, + ); }; /** @@ -71,20 +98,31 @@ export const isTransitionStructurallyEnabled = ( * * @example * ```ts - * const result = checkTransitionEnablement(currentFrame); + * const result = checkTransitionEnablement( + * currentFrame, + * simulation.transitions, + * simulation.frameLayout, + * ); * if (!result.hasEnabledTransition) { * console.log("Simulation reached a terminal state (deadlock)"); * } * ``` */ export const checkTransitionEnablement = ( - frame: SimulationFrame, + frame: EngineFrame, + transitions: ReadonlyMap, + layout: EngineFrameLayout, ): TransitionEnablementResult => { + const snapshot = materializeEngineFrame(layout, frame); const transitionStatus = new Map(); let hasEnabledTransition = false; - for (const transitionId of Object.keys(frame.transitions)) { - const isEnabled = isTransitionStructurallyEnabled(frame, transitionId); + for (const transitionId of Object.keys(snapshot.transitions)) { + const isEnabled = isTransitionStructurallyEnabledSnapshot( + snapshot, + transitions, + transitionId, + ); transitionStatus.set(transitionId, isEnabled); if (isEnabled) { 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 89% 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 index 633669877f3..f636fc8d8fb 100644 --- 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 @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { SDCPN } from "../../types/sdcpn"; +import { materializeEngineFrame } from "../frames/internal-frame"; import { buildSimulation } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; @@ -57,9 +58,7 @@ describe("computeNextFrame", () => { ], }; - const initialMarking = new Map([ - ["p1", { values: new Float64Array([10.0, 20.0]), count: 1 }], - ]); + const initialMarking = { p1: [{ x: 10.0, y: 20.0 }] }; // Build the simulation const simulation = buildSimulation({ @@ -80,9 +79,12 @@ describe("computeNextFrame", () => { // No transition should have fired (low probability) expect(result.transitionFired).toBe(false); - // The new frame should have time = dt - const nextFrame = result.simulation.frames[1]!; - expect(nextFrame.time).toBe(0.1); + // The run controller should advance time by dt. + const nextFrame = materializeEngineFrame( + result.simulation.frameLayout, + result.simulation.frames[1]!, + ); + expect(result.simulation.currentTime).toBe(0.1); // The buffer should reflect dynamics (values should have increased by derivative * dt) // Initial: [10, 20], derivative: [1, 1], dt: 0.1 @@ -111,9 +113,7 @@ describe("computeNextFrame", () => { transitions: [], }; - const initialMarking = new Map([ - ["p1", { values: new Float64Array([]), count: 0 }], - ]); + const initialMarking = { p1: 0 }; const simulation = buildSimulation({ sdcpn, @@ -167,9 +167,7 @@ describe("computeNextFrame", () => { transitions: [], }; - const initialMarking = new Map([ - ["p1", { values: new Float64Array([10.0]), count: 1 }], - ]); + const initialMarking = { p1: [{ x: 10.0 }] }; const simulation = buildSimulation({ sdcpn, @@ -184,7 +182,10 @@ describe("computeNextFrame", () => { const result = computeNextFrame(simulation); // THEN the buffer should be unchanged (no dynamics applied) - const nextFrame = result.simulation.frames[1]!; + const nextFrame = materializeEngineFrame( + result.simulation.frameLayout, + result.simulation.frames[1]!, + ); expect(nextFrame.buffer[0]).toBe(10.0); expect(result.transitionFired).toBe(false); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts new file mode 100644 index 00000000000..4ba67fc49ca --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-next-frame.ts @@ -0,0 +1,182 @@ +import { checkTransitionEnablement } from "./check-transition-enablement"; +import { computePlaceNextState } from "./compute-place-next-state"; +import { executeTransitions } from "./execute-transitions"; +import { + createEngineFrame, + materializeEngineFrame, +} from "../frames/internal-frame"; +import type { SimulationInstance } from "./types"; + +/** + * Reason why the simulation completed. + */ +export type SimulationCompletionReason = "maxTime" | "deadlock"; + +/** + * Result of computing the next frame. + */ +export type ComputeNextFrameResult = { + /** + * The updated simulation instance with the new frame appended. + */ + simulation: SimulationInstance; + + /** + * Whether any transition fired during this frame. + * When false, the token distribution did not change due to discrete events. + */ + transitionFired: boolean; + + /** + * If set, the simulation has completed and should not continue. + * - "maxTime": The simulation reached the configured maximum time. + * - "deadlock": No transitions are enabled and no further progress is possible. + */ + completionReason: SimulationCompletionReason | null; +}; + +/** + * Computes the next frame of the simulation by: + * 1. Applying differential equations to all places with dynamics enabled (that have a type) + * 2. Executing all possible transitions + * + * This integrates continuous dynamics (ODEs) and discrete transitions into a single step. + * + * @param simulation - The simulation instance containing the current state + * @returns An object containing the updated SimulationInstance and whether any transition fired + */ +export function computeNextFrame( + simulation: SimulationInstance, +): ComputeNextFrameResult { + // Get the current frame + const currentFrame = simulation.frames[simulation.currentFrameNumber]!; + + // Check if maxTime has been reached before computing + if ( + simulation.maxTime !== null && + simulation.currentTime >= simulation.maxTime + ) { + return { + simulation, + transitionFired: false, + completionReason: "maxTime", + }; + } + + const currentSnapshot = materializeEngineFrame( + simulation.frameLayout, + currentFrame, + ); + + // Step 1: Apply differential equations to places with dynamics enabled + let frameAfterDynamics = currentFrame; + + // Only apply dynamics if there are places with differential equations + if (simulation.differentialEquationFns.size > 0) { + const newBuffer = new Float64Array(currentSnapshot.buffer); + + for (const [ + placeId, + differentialEquation, + ] of simulation.differentialEquationFns) { + const placeState = currentSnapshot.places[placeId]; + if (!placeState) { + throw new Error(`Place with ID ${placeId} not found in frame`); + } + const { offset, count, dimensions } = placeState; + const placeSize = count * dimensions; + const placeBuffer = currentSnapshot.buffer.slice( + offset, + offset + placeSize, + ); + + const nextPlaceBuffer = computePlaceNextState( + placeBuffer, + dimensions, + count, + differentialEquation, + "euler", // Currently only Euler method is implemented + simulation.dt, + ); + + // Copy the updated values back into the new buffer + newBuffer.set(nextPlaceBuffer, offset); + } + + // Create frame with updated buffer after applying dynamics + frameAfterDynamics = createEngineFrame(simulation.frameLayout, { + ...currentSnapshot, + buffer: newBuffer, + }); + } + + // Step 2: Execute all transitions on the frame with updated dynamics + const transitionsResult = executeTransitions( + frameAfterDynamics, + simulation, + simulation.dt, + simulation.rngState, + ); + const frameAfterTransitions = transitionsResult.frame; + const transitionFired = transitionsResult.transitionFired; + const nextTime = simulation.currentTime + simulation.dt; + + // Step 3: Ensure transition timers advance when no transition fired. + let finalFrame = frameAfterTransitions; + if (!transitionFired) { + const frameAfterTransitionsSnapshot = materializeEngineFrame( + simulation.frameLayout, + frameAfterTransitions, + ); + finalFrame = createEngineFrame(simulation.frameLayout, { + ...frameAfterTransitionsSnapshot, + transitions: Object.fromEntries( + Object.entries(frameAfterTransitionsSnapshot.transitions).map( + ([id, state]) => [ + id, + { + ...state, + timeSinceLastFiringMs: + state.timeSinceLastFiringMs + simulation.dt, + firedInThisFrame: false, + }, + ], + ), + ), + }); + } + + // Step 4: Build updated simulation instance with new frame added + const updatedSimulation: SimulationInstance = { + ...simulation, + frames: [...simulation.frames, finalFrame], + currentFrameNumber: simulation.currentFrameNumber + 1, + currentTime: nextTime, + rngState: transitionsResult.rngState, + }; + + // Step 5: Check for completion conditions + let completionReason: SimulationCompletionReason | null = null; + + // Check if maxTime was reached with this new frame + if (simulation.maxTime !== null && nextTime >= simulation.maxTime) { + completionReason = "maxTime"; + } + // Check for deadlock if no transition fired + else if (!transitionFired) { + const enablementResult = checkTransitionEnablement( + finalFrame, + simulation.transitions, + simulation.frameLayout, + ); + if (!enablementResult.hasEnabledTransition) { + completionReason = "deadlock"; + } + } + + return { + simulation: updatedSimulation, + transitionFired, + completionReason, + }; +} 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/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.test.ts new file mode 100644 index 00000000000..2aa7c845e43 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place, Transition } from "../../types/sdcpn"; +import { + createEngineFrame, + createEngineFrameLayout, + type EngineFrameLayout, + type EngineFrameSnapshot, +} from "../frames/internal-frame"; +import { computePossibleTransition as computePossibleTransitionImpl } from "./compute-possible-transition"; +import type { + CompiledTransition, + EngineFrame, + LambdaFn, + 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, +}); + +type TestFrame = EngineFrame & { layout: EngineFrameLayout }; + +function makePlace(id: string, name: string, colorId: string | null): Place { + return { + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeColor(dimensions: number): Color { + return { + id: `frame-type-${dimensions}`, + name: `Frame Type ${dimensions}`, + iconSlug: "circle", + displayColor: "#000000", + elements: Array.from({ length: dimensions }, (_, index) => ({ + elementId: `d${index}`, + name: `d${index}`, + type: "real", + })), + }; +} + +function makeTransition( + transition: Pick & + Partial>, +): Transition { + return { + name: "Transition 1", + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + ...transition, + }; +} + +function makeCompiledTransitions({ + places, + transitions, + types, + lambdaFns, + transitionKernelFns, +}: { + places: Place[]; + transitions: Transition[]; + types: Color[]; + lambdaFns: ReadonlyMap; + transitionKernelFns: ReadonlyMap; +}): Map { + const placesMap = new Map(places.map((place) => [place.id, place])); + const typesMap = new Map(types.map((type) => [type.id, type])); + const getElementNames = (placeId: string) => { + const place = placesMap.get(placeId); + if (!place?.colorId) { + return null; + } + + return ( + typesMap.get(place.colorId)?.elements.map((element) => element.name) ?? + null + ); + }; + + return new Map( + transitions.map((transition) => { + const lambdaFn = lambdaFns.get(transition.id); + const transitionKernelFn = transitionKernelFns.get(transition.id); + if (!lambdaFn || !transitionKernelFn) { + throw new Error(`Missing compiled functions for ${transition.id}`); + } + + return [ + transition.id, + { + id: transition.id, + name: transition.name, + inputPlaces: transition.inputArcs.map((arc) => ({ + placeId: arc.placeId, + placeName: placesMap.get(arc.placeId)?.name ?? arc.placeId, + weight: arc.weight, + arcType: arc.type, + elementNames: getElementNames(arc.placeId), + })), + outputPlaces: transition.outputArcs.map((arc) => ({ + placeId: arc.placeId, + placeName: placesMap.get(arc.placeId)?.name ?? arc.placeId, + weight: arc.weight, + elementNames: getElementNames(arc.placeId), + })), + lambdaFn, + transitionKernelFn, + }, + ]; + }), + ); +} + +function makeSimulation({ + places = [], + transitions, + types = [], + lambdaFns, + transitionKernelFns, +}: { + places?: Place[]; + transitions: Transition[]; + types?: Color[]; + lambdaFns: ReadonlyMap; + transitionKernelFns: ReadonlyMap; +}): SimulationInstance { + const frameLayout = createEngineFrameLayout({ + places, + transitions, + types, + }); + + 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(), + compiledTransitions: makeCompiledTransitions({ + places, + transitions, + types, + lambdaFns, + transitionKernelFns, + }), + parameterValues: {}, + dt: 0.1, + maxTime: null, + currentTime: 0, + rngState: 42, + frameLayout, + frames: [], + currentFrameNumber: 0, + }; +} + +function makeFrame(snapshot: EngineFrameSnapshot): TestFrame { + const dimensions = new Set( + Object.values(snapshot.places).map((place) => place.dimensions), + ); + const layout = createEngineFrameLayout({ + places: Object.entries(snapshot.places).map(([id, place]) => + makePlace( + id, + id, + place.dimensions === 0 ? null : `frame-type-${place.dimensions}`, + ), + ), + transitions: Object.keys(snapshot.transitions).map((id) => + makeTransition({ id, inputArcs: [], outputArcs: [] }), + ), + types: [...dimensions] + .filter((dimension) => dimension > 0) + .map((dimension) => makeColor(dimension)), + }); + const frame = createEngineFrame(layout, snapshot) as TestFrame; + Object.defineProperty(frame, "layout", { value: layout }); + return frame; +} + +function computePossibleTransition( + frame: TestFrame, + simulation: SimulationInstance, + transitionId: string, + rngState: number, +) { + return computePossibleTransitionImpl( + frame, + { ...simulation, frameLayout: frame.layout }, + transitionId, + rngState, + ); +} + +describe("computePossibleTransition", () => { + it("returns null when transition is not enabled due to insufficient tokens", () => { + 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 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 1, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([1.0]), + }); + + expect(computePossibleTransition(frame, simulation, "t1", 42)).toBeNull(); + }); + + 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", () => ({})], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 2, dimensions: 0 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([]), + }); + + expect(computePossibleTransition(frame, simulation, "t1", 42)).toBeNull(); + }); + + it("does not consume tokens from inhibitor arc when transition fires", () => { + 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 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 1, dimensions: 1 }, + p2: { offset: 1, count: 0, dimensions: 0 }, + p3: { offset: 1, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([3.0]), + }); + + const result = computePossibleTransition(frame, simulation, "t1", 42); + + expect(result).not.toBeNull(); + expect(result!.remove).toHaveProperty("p1"); + expect(result!.remove).not.toHaveProperty("p2"); + expect(result!.add).toMatchObject({ p3: [[5.0]] }); + }); + + it("returns token combinations when transition is enabled and 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]]), + transitionKernelFns: new Map([ + ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 2, dimensions: 1 }, + p2: { offset: 2, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([1.0, 1.5]), + }); + + const result = computePossibleTransition(frame, simulation, "t1", 42); + + expect(result).not.toBeNull(); + expect(result).toMatchObject({ + remove: { p1: new Set([0]) }, + add: { p2: [[2.0]] }, + }); + expect(result?.newRngState).toBeTypeOf("number"); + }); +}); 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 63% 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 index a44d08494b4..9a3f62629c6 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/compute-possible-transition.ts @@ -1,14 +1,20 @@ import { SDCPNItemError } from "../../errors"; import type { ID } from "../../types/sdcpn"; -import { isDistribution, sampleDistribution } from "./distribution"; +import { isDistribution } from "../authoring/user-code/distribution"; +import { materializeEngineFrame } from "../frames/internal-frame"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; +import { sampleDistribution } from "./sample-distribution"; import { nextRandom } from "./seeded-rng"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { + EngineFrame, + SimulationInstance, + TransitionTokenValues, +} 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 +22,7 @@ type PlaceID = ID; * - newRngState: Updated RNG seed after consuming randomness */ export function computePossibleTransition( - frame: SimulationFrame, + frame: EngineFrame, simulation: SimulationInstance, transitionId: string, rngState: number, @@ -25,32 +31,37 @@ export function computePossibleTransition( add: Record; newRngState: number; } { - // Get the transition from the simulation instance - const transition = frame.transitions[transitionId]; - if (!transition) { + const snapshot = materializeEngineFrame(simulation.frameLayout, frame); + const transitionState = snapshot.transitions[transitionId]; + if (!transitionState) { throw new Error(`Transition with ID ${transitionId} not found.`); } + const transition = simulation.compiledTransitions.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 placeState = frame.places[arc.placeId]; + const inputPlaces = transition.inputPlaces.map((inputPlace) => { + const placeState = snapshot.places[inputPlace.placeId]; if (!placeState) { throw new Error( - `Place with ID ${arc.placeId} not found in current marking.`, + `Place with ID ${inputPlace.placeId} not found in current marking.`, ); } return { ...placeState, - placeId: arc.placeId, - weight: arc.weight, - type: arc.type, + ...inputPlace, }; }); // Transition is enabled if all input places have more tokens than the arc weight. const isTransitionEnabled = inputPlaces.every((inputPlace) => - inputPlace.type === "inhibitor" + inputPlace.arcType === "inhibitor" ? inputPlace.count < inputPlace.weight : inputPlace.count >= inputPlace.weight, ); @@ -60,38 +71,22 @@ export function computePossibleTransition( return null; } - // Get lambda function - const lambdaFn = simulation.lambdaFns.get(transitionId); - if (!lambdaFn) { - throw new Error( - `Lambda function for transition ${transitionId} not found.`, - ); - } - - // Get transition kernel function - const transitionKernelFn = simulation.transitionKernelFns.get(transitionId); - if (!transitionKernelFn) { - throw new Error( - `Transition kernel fn for transition ${transitionId} not found.`, - ); - } - // // Transition computation logic // // 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) const inputPlacesWithAtLeastOneDimension = inputPlaces.filter( - (place) => place.dimensions > 0 && place.type !== "inhibitor", + (place) => place.dimensions > 0 && place.arcType !== "inhibitor", ); const inputPlacesWithZeroDimensions = inputPlaces.filter( - (place) => place.dimensions === 0 && place.type !== "inhibitor", + (place) => place.dimensions === 0 && place.arcType !== "inhibitor", ); // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. @@ -104,7 +99,7 @@ export function computePossibleTransition( // Expensive: get token values from global buffer // And transform them for lambda function input. // Convert to object format with place names as keys - const tokenCombinationValues: Record[]> = {}; + const tokenCombinationValues: TransitionTokenValues = {}; for (const [ placeIndex, @@ -114,31 +109,13 @@ export function computePossibleTransition( const placeOffsetInBuffer = inputPlace.offset; const dimensions = inputPlace.dimensions; - // Look up the place instance from simulation - const place = simulation.places.get(inputPlace.placeId); - if (!place) { - throw new Error( - `Place with ID ${inputPlace.placeId} not found in simulation`, - ); - } - - const placeName = place.name; - - // Get the type definition to access dimension names - const typeId = place.colorId; - if (!typeId) { + if (!inputPlace.elementNames) { throw new SDCPNItemError( - `Place \`${place.name}\` has no type defined`, - place.id, - ); - } - - const type = simulation.types.get(typeId); - if (!type) { - throw new Error( - `Type with ID ${typeId} referenced by place ${place.id} does not exist in simulation`, + `Place \`${inputPlace.placeName}\` has no type defined`, + inputPlace.placeId, ); } + const elementNames = inputPlace.elementNames; // Convert tokens for this place to objects with named dimensions const placeTokens: Record[] = placeTokenIndices.map( @@ -150,32 +127,29 @@ export function computePossibleTransition( // Create token object with named dimensions const token: Record = {}; for (let dimIdx = 0; dimIdx < dimensions; dimIdx++) { - const dimensionName = type.elements[dimIdx]!.name; - token[dimensionName] = frame.buffer[globalIndex + dimIdx]!; + const dimensionName = elementNames[dimIdx]!; + token[dimensionName] = snapshot.buffer[globalIndex + dimIdx]!; } return token; }, ); - tokenCombinationValues[placeName] = placeTokens; + tokenCombinationValues[inputPlace.placeName] = placeTokens; } // Approximate by just multiplying by elapsed time since last transition, // not a real accumulation over time with lambda varying as the paper suggests. // But prevent having to handle a big buffer of varying lambda values over time, // which should be reordered in case of new tokens arriving. - let lambdaResult: ReturnType; + let lambdaResult: ReturnType; try { - lambdaResult = lambdaFn( - tokenCombinationValues, - simulation.parameterValues, - ); + lambdaResult = transition.lambdaFn(tokenCombinationValues); } 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, ); } @@ -192,20 +166,21 @@ export function computePossibleTransition( // Find the first combination of tokens where e^(-lambda) < U1 // We should normally find the minimum for all possibilities, but we try to reduce as much as we can here. if (Math.exp(-lambdaValue) <= U1) { - let transitionKernelOutput: ReturnType; + let transitionKernelOutput: ReturnType< + typeof transition.transitionKernelFn + >; try { // Transition fires! // Return result of the transition kernel as is (no stochasticity for now, only one result) - transitionKernelOutput = transitionKernelFn( + transitionKernelOutput = transition.transitionKernelFn( tokenCombinationValues, - simulation.parameterValues, ); } 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,48 +191,30 @@ export function computePossibleTransition( const addMap: Record = {}; let currentRngState = newRngState; - for (const outputArc of transition.instance.outputArcs) { - const outputPlaceState = frame.places[outputArc.placeId]; + for (const outputPlace of transition.outputPlaces) { + const outputPlaceState = snapshot.places[outputPlace.placeId]; if (!outputPlaceState) { throw new Error( - `Output place with ID ${outputArc.placeId} not found in frame`, + `Output place with ID ${outputPlace.placeId} not found in frame`, ); } - // Look up the output place instance from simulation - const outputPlace = simulation.places.get(outputArc.placeId); - if (!outputPlace) { - throw new Error( - `Output place with ID ${outputArc.placeId} not found in simulation`, - ); - } - - const placeName = outputPlace.name; - const typeId = outputPlace.colorId; - // If place has no type, create n empty tuples where n is the arc weight - if (!typeId) { + if (!outputPlace.elementNames) { const emptyTokens: number[][] = Array.from( - { length: outputArc.weight }, + { length: outputPlace.weight }, () => [], ); - addMap[outputArc.placeId] = emptyTokens; + addMap[outputPlace.placeId] = emptyTokens; continue; } - const outputTokens = transitionKernelOutput[placeName]; + const outputTokens = transitionKernelOutput[outputPlace.placeName]; if (!outputTokens) { throw new SDCPNItemError( - `Transition kernel for transition \`${transition.instance.name}\` did not return tokens for place "${placeName}"`, - transition.instance.id, - ); - } - - const type = simulation.types.get(typeId); - if (!type) { - throw new Error( - `Type with ID ${typeId} referenced by place ${outputPlace.id} does not exist in simulation`, + `Transition kernel for transition \`${transition.name}\` did not return tokens for place "${outputPlace.placeName}"`, + transition.id, ); } @@ -266,8 +223,8 @@ export function computePossibleTransition( const tokenArrays: number[][] = []; for (const token of outputTokens) { const values: number[] = []; - for (const element of type.elements) { - const raw = token[element.name]!; + for (const elementName of outputPlace.elementNames) { + const raw = token[elementName]!; if (isDistribution(raw)) { const [sampled, nextRng] = sampleDistribution( raw, @@ -282,7 +239,7 @@ export function computePossibleTransition( tokenArrays.push(values); } - addMap[outputArc.placeId] = tokenArrays; + addMap[outputPlace.placeId] = tokenArrays; } return { 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/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.test.ts new file mode 100644 index 00000000000..b8ae5f221a3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.test.ts @@ -0,0 +1,480 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place, Transition } from "../../types/sdcpn"; +import { + createEngineFrame, + createEngineFrameLayout, + materializeEngineFrame, + type EngineFrameLayout, + type EngineFrameSnapshot, +} from "../frames/internal-frame"; +import { executeTransitions as executeEngineTransitions } from "./execute-transitions"; +import type { + CompiledTransition, + EngineFrame, + LambdaFn, + 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, +}); + +type TestFrame = EngineFrame & { layout: EngineFrameLayout }; + +function makePlace(id: string, name: string, colorId: string | null): Place { + return { + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeColor(dimensions: number): Color { + return { + id: `frame-type-${dimensions}`, + name: `Frame Type ${dimensions}`, + iconSlug: "circle", + displayColor: "#000000", + elements: Array.from({ length: dimensions }, (_, index) => ({ + elementId: `d${index}`, + name: `d${index}`, + type: "real", + })), + }; +} + +function makeTransition( + transition: Pick & + Partial>, +): Transition { + return { + name: "Transition", + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + ...transition, + }; +} + +function makeCompiledTransitions({ + places, + transitions, + types, + lambdaFns, + transitionKernelFns, +}: { + places: Place[]; + transitions: Transition[]; + types: Color[]; + lambdaFns: ReadonlyMap; + transitionKernelFns: ReadonlyMap; +}): Map { + const placesMap = new Map(places.map((place) => [place.id, place])); + const typesMap = new Map(types.map((type) => [type.id, type])); + const getElementNames = (placeId: string) => { + const place = placesMap.get(placeId); + if (!place?.colorId) { + return null; + } + + return ( + typesMap.get(place.colorId)?.elements.map((element) => element.name) ?? + null + ); + }; + + return new Map( + transitions.map((transition) => { + const lambdaFn = lambdaFns.get(transition.id); + const transitionKernelFn = transitionKernelFns.get(transition.id); + if (!lambdaFn || !transitionKernelFn) { + throw new Error(`Missing compiled functions for ${transition.id}`); + } + + return [ + transition.id, + { + id: transition.id, + name: transition.name, + inputPlaces: transition.inputArcs.map((arc) => ({ + placeId: arc.placeId, + placeName: placesMap.get(arc.placeId)?.name ?? arc.placeId, + weight: arc.weight, + arcType: arc.type, + elementNames: getElementNames(arc.placeId), + })), + outputPlaces: transition.outputArcs.map((arc) => ({ + placeId: arc.placeId, + placeName: placesMap.get(arc.placeId)?.name ?? arc.placeId, + weight: arc.weight, + elementNames: getElementNames(arc.placeId), + })), + lambdaFn, + transitionKernelFn, + }, + ]; + }), + ); +} + +function makeSimulation({ + places = [], + transitions, + types = [], + lambdaFns, + transitionKernelFns, +}: { + places?: Place[]; + transitions: Transition[]; + types?: Color[]; + lambdaFns: ReadonlyMap; + transitionKernelFns: ReadonlyMap; +}): SimulationInstance { + const frameLayout = createEngineFrameLayout({ + places, + transitions, + types, + }); + + 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(), + compiledTransitions: makeCompiledTransitions({ + places, + transitions, + types, + lambdaFns, + transitionKernelFns, + }), + parameterValues: {}, + dt: 0.1, + maxTime: null, + currentTime: 0, + rngState: 42, + frameLayout, + frames: [], + currentFrameNumber: 0, + }; +} + +function makeFrame(snapshot: EngineFrameSnapshot): TestFrame { + const dimensions = new Set( + Object.values(snapshot.places).map((place) => place.dimensions), + ); + const layout = createEngineFrameLayout({ + places: Object.entries(snapshot.places).map(([id, place]) => + makePlace( + id, + id, + place.dimensions === 0 ? null : `frame-type-${place.dimensions}`, + ), + ), + transitions: Object.keys(snapshot.transitions).map((id) => + makeTransition({ id, inputArcs: [], outputArcs: [] }), + ), + types: [...dimensions] + .filter((dimension) => dimension > 0) + .map((dimension) => makeColor(dimension)), + }); + const frame = createEngineFrame(layout, snapshot) as TestFrame; + Object.defineProperty(frame, "layout", { value: layout }); + return frame; +} + +function executeTransitions( + frame: TestFrame, + simulation: SimulationInstance, + dt: number, + rngState: number, +) { + const result = executeEngineTransitions( + frame, + { ...simulation, frameLayout: frame.layout }, + dt, + rngState, + ); + + return { + ...result, + frame: + result.frame === frame + ? (frame as unknown as EngineFrameSnapshot) + : materializeEngineFrame(frame.layout, result.frame), + }; +} + +describe("executeTransitions", () => { + it("returns the original frame when no transitions can fire", () => { + 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 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([]), + }); + + const result = executeTransitions( + frame, + simulation, + simulation.dt, + simulation.rngState, + ); + + expect(result.frame).toBe(frame); + expect(result.transitionFired).toBe(false); + }); + + it("removes tokens and adds new tokens when a single 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]]), + transitionKernelFns: new Map([ + ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 2, dimensions: 1 }, + p2: { offset: 2, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([1.0, 1.5]), + }); + + const result = executeTransitions( + frame, + simulation, + simulation.dt, + simulation.rngState, + ); + + expect(result.frame.places.p1?.count).toBe(1); + expect(result.frame.buffer[0]).toBe(1.5); + expect(result.frame.places.p2?.count).toBe(1); + expect(result.frame.buffer[1]).toBe(2.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 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([ + ["t1", () => ({ "Place 2": [{ x: 5.0 }] })], + ["t2", () => ({ "Place 3": [{ x: 10.0 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 3, dimensions: 1 }, + p2: { offset: 3, count: 0, dimensions: 1 }, + p3: { offset: 3, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + t2: transitionState(), + }, + buffer: new Float64Array([1.0, 2.0, 3.0]), + }); + + const result = executeTransitions( + frame, + simulation, + simulation.dt, + simulation.rngState, + ); + + expect(result.frame.places.p1?.count).toBe(1); + expect(result.frame.places.p2?.count).toBe(1); + expect(result.frame.places.p3?.count).toBe(1); + expect(result.frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); + expect(result.frame.transitions.t2?.timeSinceLastFiringMs).toBe(0); + }); + + it("handles transitions with multi-dimensional tokens", () => { + 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", () => ({ "Place 2": [{ x: 3.0, y: 4.0 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 1, dimensions: 2 }, + p2: { offset: 2, count: 0, dimensions: 2 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([1.0, 2.0]), + }); + + const result = executeTransitions( + frame, + simulation, + simulation.dt, + simulation.rngState, + ); + + expect(result.frame.places.p1?.count).toBe(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 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], + ["t2", () => 0.001], + ]), + transitionKernelFns: new Map([ + ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], + ["t2", () => ({ "Place 2": [{ x: 3.0 }] })], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 2, dimensions: 1 }, + p2: { offset: 2, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(0.5), + t2: transitionState(0.3), + }, + buffer: new Float64Array([1.0, 1.5]), + }); + + const result = executeTransitions( + frame, + simulation, + simulation.dt, + simulation.rngState, + ); + + expect(result.frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); + expect(result.frame.transitions.t2?.timeSinceLastFiringMs).toBe(0.4); + }); +}); 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 81% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.ts rename to libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts index 43b77571392..a429f6436ef 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/execute-transitions.ts @@ -1,36 +1,48 @@ import type { ID } from "../../types/sdcpn"; +import { + createEngineFrame, + materializeEngineFrame, +} from "../frames/internal-frame"; import { computePossibleTransition } from "./compute-possible-transition"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { + EngineFrame, + EngineFrameLayout, + EngineFrameSnapshot, + 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 { + layout: EngineFrameLayout, +): EngineFrame { // If no tokens to add, return frame as-is if (tokensToAdd.size === 0) { return frame; } + const snapshot = materializeEngineFrame(layout, frame); + // Validate all places and token dimensions first for (const [placeId, tokens] of tokensToAdd) { - const placeState = frame.places[placeId]; + const placeState = snapshot.places[placeId]; if (!placeState) { throw new Error( `Place with ID ${placeId} not found in simulation frame.`, @@ -51,20 +63,22 @@ function addTokensToSimulationFrame( // Calculate total size increase needed in buffer let totalSizeIncrease = 0; for (const [placeId, tokens] of tokensToAdd) { - const placeState = frame.places[placeId]!; + const placeState = snapshot.places[placeId]!; const tokenSize = placeState.dimensions; totalSizeIncrease += tokens.length * tokenSize; } // Create a new buffer with increased size - const newBuffer = new Float64Array(frame.buffer.length + totalSizeIncrease); + const newBuffer = new Float64Array( + snapshot.buffer.length + totalSizeIncrease, + ); // Process places in order of their offsets to build the new buffer - const placesByOffset = Object.entries(frame.places).sort( + const placesByOffset = Object.entries(snapshot.places).sort( (a, b) => a[1].offset - b[1].offset, ); - const newPlaces: SimulationFrame["places"] = { ...frame.places }; + const newPlaces: EngineFrameSnapshot["places"] = { ...snapshot.places }; let sourceIndex = 0; let targetIndex = 0; @@ -75,7 +89,7 @@ function addTokensToSimulationFrame( // Copy existing tokens from this place for (let i = 0; i < placeSize; i++) { - newBuffer[targetIndex++] = frame.buffer[sourceIndex++]!; + newBuffer[targetIndex++] = snapshot.buffer[sourceIndex++]!; } // Add new tokens for this place if any @@ -110,11 +124,11 @@ function addTokensToSimulationFrame( currentOffset += placeSize; } - return { - ...frame, + return createEngineFrame(layout, { + ...snapshot, buffer: newBuffer, places: newPlaces, - }; + }); } /** @@ -122,7 +136,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 +160,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, @@ -164,7 +178,7 @@ export function executeTransitions( let currentRngState = rngState; // Iterate through all transitions in the frame - for (const transitionId of Object.keys(currentFrame.transitions)) { + for (const transitionId of simulation.frameLayout.transitionIds) { // Compute if this transition can fire based on the current state const result = computePossibleTransition( currentFrame, @@ -188,6 +202,7 @@ export function executeTransitions( currentFrame = removeTokensFromSimulationFrame( currentFrame, tokensToRemove, + simulation.frameLayout, ); // Accumulate tokens to add @@ -207,14 +222,22 @@ export function executeTransitions( } // Add all new tokens at once - const newFrame = addTokensToSimulationFrame(currentFrame, tokensToAdd); + const newFrame = addTokensToSimulationFrame( + currentFrame, + tokensToAdd, + simulation.frameLayout, + ); + const newFrameSnapshot = materializeEngineFrame( + simulation.frameLayout, + newFrame, + ); // Update transition timeSinceLastFiringMs, firedInThisFrame, and firingCount - const newTransitions: SimulationFrame["transitions"] = { - ...newFrame.transitions, + const newTransitions: EngineFrameSnapshot["transitions"] = { + ...newFrameSnapshot.transitions, }; for (const [transitionId, transitionState] of Object.entries( - newFrame.transitions, + newFrameSnapshot.transitions, )) { if (transitionsFired.has(transitionId)) { // Reset time since last firing and increment firing count for transitions that fired @@ -235,11 +258,10 @@ export function executeTransitions( } return { - frame: { - ...newFrame, + frame: createEngineFrame(simulation.frameLayout, { + ...newFrameSnapshot, transitions: newTransitions, - time: frame.time + dt, - }, + }), rngState: currentRngState, transitionFired: true, }; 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 75% 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 index 91e1bd192c4..e637dde95ee 100644 --- 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 @@ -1,16 +1,84 @@ import { describe, expect, it } from "vitest"; -import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; -import type { SimulationFrame } from "./types"; +import type { Color, Place } from "../../types/sdcpn"; +import { + createEngineFrame, + createEngineFrameLayout, + materializeEngineFrame, + type EngineFrameLayout, + type EngineFrameSnapshot, +} from "../frames/internal-frame"; +import { removeTokensFromSimulationFrame as removeTokensFromEngineFrame } from "./remove-tokens-from-simulation-frame"; +import type { EngineFrame } from "./types"; + +type TestFrame = EngineFrame & { layout: EngineFrameLayout }; + +function makeColor(dimensions: number): Color { + return { + id: `type-${dimensions}`, + name: `Type ${dimensions}`, + iconSlug: "circle", + displayColor: "#000000", + elements: Array.from({ length: dimensions }, (_, index) => ({ + elementId: `d${index}`, + name: `d${index}`, + type: "real", + })), + }; +} + +function makePlace(id: string, dimensions: number): Place { + return { + id, + name: id, + colorId: dimensions === 0 ? null : `type-${dimensions}`, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeFrame(snapshot: EngineFrameSnapshot): TestFrame { + const dimensions = new Set( + Object.values(snapshot.places).map((place) => place.dimensions), + ); + const layout = createEngineFrameLayout({ + places: Object.entries(snapshot.places).map(([id, place]) => + makePlace(id, place.dimensions), + ), + transitions: [], + types: [...dimensions] + .filter((dimension) => dimension > 0) + .map((dimension) => makeColor(dimension)), + }); + const frame = createEngineFrame(layout, snapshot) as TestFrame; + Object.defineProperty(frame, "layout", { value: layout }); + return frame; +} + +function removeTokensFromSimulationFrame( + frame: TestFrame, + tokensToRemove: Map | number>, +): EngineFrameSnapshot { + const result = removeTokensFromEngineFrame( + frame, + tokensToRemove, + frame.layout, + ); + if (result === frame) { + return frame as unknown as EngineFrameSnapshot; + } + return materializeEngineFrame(frame.layout, result); +} describe("removeTokensFromSimulationFrame", () => { it("throws error when place ID is not found", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: {}, transitions: {}, buffer: new Float64Array([]), - }; + }); expect(() => { removeTokensFromSimulationFrame( @@ -21,8 +89,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when tokens map is empty", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -32,7 +99,7 @@ describe("removeTokensFromSimulationFrame", () => { }, transitions: {}, buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]), - }; + }); const result = removeTokensFromSimulationFrame(frame, new Map()); @@ -40,8 +107,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("throws error when token index is out of bounds", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -51,7 +117,7 @@ describe("removeTokensFromSimulationFrame", () => { }, transitions: {}, buffer: new Float64Array([1.0, 2.0]), - }; + }); expect(() => { removeTokensFromSimulationFrame(frame, new Map([["p1", new Set([3])]])); @@ -59,8 +125,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when place has empty set of indices", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -70,7 +135,7 @@ describe("removeTokensFromSimulationFrame", () => { }, transitions: {}, buffer: new Float64Array([1.0, 2.0, 3.0]), - }; + }); const result = removeTokensFromSimulationFrame( frame, @@ -82,8 +147,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes a single token from a place with 1D tokens", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -93,7 +157,7 @@ describe("removeTokensFromSimulationFrame", () => { }, transitions: {}, buffer: new Float64Array([1.0, 2.0, 3.0]), - }; + }); const result = removeTokensFromSimulationFrame( frame, @@ -106,8 +170,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes multiple tokens from a place with 1D tokens", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -117,7 +180,7 @@ describe("removeTokensFromSimulationFrame", () => { }, transitions: {}, buffer: new Float64Array([1.0, 2.0, 3.0, 4.0]), - }; + }); const result = removeTokensFromSimulationFrame( frame, @@ -130,8 +193,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes tokens from a place with multi-dimensional tokens", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -142,7 +204,7 @@ describe("removeTokensFromSimulationFrame", () => { transitions: {}, // 3 tokens with 3 dimensions each: [1,2,3], [4,5,6], [7,8,9] buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]), - }; + }); // Remove token at index 1 (middle token: [4,5,6]) const result = removeTokensFromSimulationFrame( @@ -158,8 +220,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("adjusts offsets for subsequent places after removal", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -175,7 +236,7 @@ describe("removeTokensFromSimulationFrame", () => { transitions: {}, // p1: [1,2], [3,4] | p2: [5], [6], [7] buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]), - }; + }); // Remove one token from p1 const result = removeTokensFromSimulationFrame( @@ -193,8 +254,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes all tokens from a place", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -209,7 +269,7 @@ describe("removeTokensFromSimulationFrame", () => { }, transitions: {}, buffer: new Float64Array([1.0, 2.0, 3.0, 4.0]), - }; + }); const result = removeTokensFromSimulationFrame( frame, @@ -224,8 +284,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("handles removal from middle place with three places", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -246,7 +305,7 @@ describe("removeTokensFromSimulationFrame", () => { transitions: {}, // p1: [1, 2] | p2: [3, 4, 5] | p3: [6, 7] buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]), - }; + }); // Remove one token from p2 (middle place) const result = removeTokensFromSimulationFrame( @@ -268,8 +327,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes tokens from multiple places simultaneously", () => { - const frame: SimulationFrame = { - time: 0, + const frame = makeFrame({ places: { p1: { offset: 0, @@ -290,7 +348,7 @@ describe("removeTokensFromSimulationFrame", () => { transitions: {}, // p1: [1], [2], [3] | p2: [4,5], [6,7] | p3: [8], [9] buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]), - }; + }); // Remove tokens from multiple places: token 1 from p1, token 0 from p2, token 1 from p3 const result = removeTokensFromSimulationFrame( 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 80% 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 index 54bb2d0cd74..9a8711bfa48 100644 --- 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 @@ -1,10 +1,18 @@ -import type { SimulationFrame } from "./types"; +import { + createEngineFrame, + materializeEngineFrame, +} from "../frames/internal-frame"; +import type { + EngineFrame, + EngineFrameLayout, + EngineFrameSnapshot, +} 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,21 +21,24 @@ 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 { + layout: EngineFrameLayout, +): EngineFrame { // If no tokens to remove, return frame as-is if (tokensToRemove.size === 0) { return frame; } + const snapshot = materializeEngineFrame(layout, frame); + // Validate all places and indices first for (const [placeId, indices] of tokensToRemove) { - const placeState = frame.places[placeId]; + const placeState = snapshot.places[placeId]; if (!placeState) { throw new Error( `Place with ID ${placeId} not found in simulation frame.`, @@ -64,7 +75,7 @@ export function removeTokensFromSimulationFrame( const globalIndicesToRemove = new Set(); for (const [placeId, indices] of tokensToRemove) { - const placeState = frame.places[placeId]!; + const placeState = snapshot.places[placeId]!; const { offset, dimensions } = placeState; const tokenSize = dimensions; @@ -89,24 +100,24 @@ export function removeTokensFromSimulationFrame( } // Create a new buffer without the removed tokens - const newBufferSize = frame.buffer.length - globalIndicesToRemove.size; + const newBufferSize = snapshot.buffer.length - globalIndicesToRemove.size; const newBuffer = new Float64Array(newBufferSize); // Copy buffer excluding removed indices let newBufferIndex = 0; - for (let i = 0; i < frame.buffer.length; i++) { + for (let i = 0; i < snapshot.buffer.length; i++) { if (!globalIndicesToRemove.has(i)) { - newBuffer[newBufferIndex++] = frame.buffer[i]!; + newBuffer[newBufferIndex++] = snapshot.buffer[i]!; } } // Calculate offset adjustments for each place // We need to track cumulative size removed before each place's offset - const placesByOffset = Object.entries(frame.places).sort( + const placesByOffset = Object.entries(snapshot.places).sort( (a, b) => a[1].offset - b[1].offset, ); - const newPlaces: SimulationFrame["places"] = { ...frame.places }; + const newPlaces: EngineFrameSnapshot["places"] = { ...snapshot.places }; let cumulativeRemoved = 0; for (const [placeId, placeState] of placesByOffset) { @@ -131,9 +142,9 @@ export function removeTokensFromSimulationFrame( } // Return new frame with updated buffer and places - return { - ...frame, + return createEngineFrame(layout, { + ...snapshot, buffer: newBuffer, places: newPlaces, - }; + }); } diff --git a/libs/@hashintel/petrinaut/src/core/simulation/engine/sample-distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/sample-distribution.ts new file mode 100644 index 00000000000..4696b403f41 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/sample-distribution.ts @@ -0,0 +1,61 @@ +import type { RuntimeDistribution } from "../authoring/user-code/distribution"; +import { nextRandom } from "./seeded-rng"; + +/** + * Samples a single numeric value from a distribution using the seeded RNG. + * Caches the result on the distribution object so that sibling `.map()` calls + * sharing the same inner distribution get a coherent sample. + * + * @returns A tuple of [sampledValue, newRngState] + */ +export function sampleDistribution( + distribution: RuntimeDistribution, + rngState: number, +): [number, number] { + if (distribution.sampledValue !== undefined) { + return [distribution.sampledValue, rngState]; + } + + let value: number; + let nextRng: number; + + switch (distribution.type) { + case "gaussian": { + // Box-Muller transform: converts two uniform random values to a standard normal. + const [u1, rng1] = nextRandom(rngState); + const [u2, rng2] = nextRandom(rng1); + const z = Math.sqrt(-2 * Math.log(1 - u1)) * Math.cos(2 * Math.PI * u2); + value = distribution.mean + z * distribution.deviation; + nextRng = rng2; + break; + } + case "uniform": { + const [sample, newRng] = nextRandom(rngState); + value = distribution.min + sample * (distribution.max - distribution.min); + nextRng = newRng; + break; + } + case "lognormal": { + // Lognormal(μ, σ): if X ~ Normal(μ, σ), then e^X ~ Lognormal(μ, σ). + const [u1, rng1] = nextRandom(rngState); + const [u2, rng2] = nextRandom(rng1); + const z = Math.sqrt(-2 * Math.log(1 - u1)) * Math.cos(2 * Math.PI * u2); + value = Math.exp(distribution.mu + z * distribution.sigma); + nextRng = rng2; + break; + } + case "mapped": { + const [innerValue, newRng] = sampleDistribution( + distribution.inner, + rngState, + ); + value = distribution.fn(innerValue); + nextRng = newRng; + break; + } + } + + // eslint-disable-next-line no-param-reassign -- intentional: cache sampled value for coherent .map() siblings + distribution.sampledValue = value; + return [value, nextRng]; +} 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/engine/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts new file mode 100644 index 00000000000..b92656c9e77 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/engine/types.ts @@ -0,0 +1,135 @@ +/** + * Internal types for the simulation engine. + * + * These types are used by the simulator and worker modules but are not + * part of the public simulation API. + */ + +import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; +import type { RuntimeDistribution } from "../authoring/user-code/distribution"; +import type { InitialMarking } from "../api"; +import type { EngineFrame, EngineFrameLayout } from "../frames/internal-frame"; + +/** + * Runtime parameter values used during simulation execution. + * Maps parameter names to their resolved numeric or boolean values. + */ +export type ParameterValues = Record; + +/** + * Engine-facing differential equation for one place's continuous dynamics. + * + * Today this wraps the user-authored object API and adapts it to/from the + * engine's packed numeric buffers. Later this can be replaced by an + * IR-compiled buffer-native function without changing the stepping loop. + */ +export type DifferentialEquationFn = ( + currentState: Float64Array, + dimensions: number, + numberOfTokens: number, +) => Float64Array; + +export type TransitionTokenValues = Record[]>; +export type TransitionKernelOutput = Record< + string, + Record[] +>; + +/** + * Engine-facing lambda function for transition firing probability. + * + * Runtime parameter values are already bound by `buildSimulation`. + * + * Returns a rate (number) for stochastic transitions or a boolean for predicate transitions. + */ +export type LambdaFn = (tokenValues: TransitionTokenValues) => number | boolean; + +/** + * Engine-facing transition kernel function for token generation. + * + * Runtime parameter values are already bound by `buildSimulation`. + * + * Computes the output tokens to create when a transition fires. + */ +export type TransitionKernelFn = ( + tokenValues: TransitionTokenValues, +) => TransitionKernelOutput; + +export type CompiledTransitionPlace = { + placeId: string; + placeName: string; + weight: number; + elementNames: readonly string[] | null; +}; + +export type CompiledTransitionInputPlace = CompiledTransitionPlace & { + arcType: "standard" | "inhibitor"; +}; + +export type CompiledTransition = { + id: string; + name: string; + inputPlaces: readonly CompiledTransitionInputPlace[]; + outputPlaces: readonly CompiledTransitionPlace[]; + lambdaFn: LambdaFn; + transitionKernelFn: TransitionKernelFn; +}; + +/** + * Input configuration for building a new simulation instance. + */ +export type SimulationInput = { + /** The SDCPN definition to simulate */ + sdcpn: SDCPN; + /** Initial token distribution across places */ + initialMarking: InitialMarking; + /** Parameter values from the simulation store (overrides SDCPN defaults) */ + parameterValues: Record; + /** Random seed for deterministic stochastic behavior */ + seed: number; + /** Time step for simulation advancement */ + dt: number; + /** Maximum simulation time (immutable once set). Null means no limit. */ + maxTime: number | null; +}; + +/** + * A running simulation instance with compiled functions and frame history. + * Contains all state needed to execute and advance the simulation. + */ +export type SimulationInstance = { + /** Place definitions indexed by ID */ + places: Map; + /** Transition definitions indexed by ID */ + transitions: Map; + /** Color type definitions indexed by ID */ + types: Map; + /** Compiled differential equation functions indexed by place ID */ + differentialEquationFns: Map; + /** Transition definitions specialized for runtime execution. */ + compiledTransitions: Map; + /** Resolved parameter values for this simulation run */ + parameterValues: ParameterValues; + /** Time step for simulation advancement */ + dt: number; + /** Maximum simulation time (immutable). Null means no limit. */ + maxTime: number | null; + /** Simulation time for the current frame, owned by the run controller. */ + currentTime: number; + /** Current state of the seeded random number generator */ + rngState: number; + /** SDCPN-specialized binary frame layout for this simulation run. */ + frameLayout: EngineFrameLayout; + /** History of all computed frames */ + 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 { + EngineFrame, + EngineFrameLayout, + EngineFramePlaceState, + EngineFrameSnapshot, +} from "../frames/internal-frame"; 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..b623a6a10fe --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; +import { compileSimulationFrameReader } from "./frame-reader"; +import { + createEngineFrame, + createEngineFrameLayout, + type EngineFrame, +} 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: "stochastic", + lambdaCode: "return 0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, +}; + +const sdcpn: Pick = { + places: [place], + transitions: [transition], + types: [color], +}; + +function makeFrame(): EngineFrame { + return createEngineFrame(createEngineFrameLayout(sdcpn), { + places: { + [place.id]: { offset: 2, count: 2, dimensions: 2 }, + }, + transitions: { + "transition-1": { + timeSinceLastFiringMs: 10, + firedInThisFrame: true, + firingCount: 3, + }, + }, + buffer: new Float64Array([99, 99, 1, 2, 3, 4]), + }); +} + +describe("SimulationFrameReader", () => { + it("reads place and transition state without exposing raw frame layout", () => { + const reader = compileSimulationFrameReader(sdcpn)(makeFrame(), 7, 1.25); + + expect(reader.number).toBe(7); + expect(reader.time).toBe(1.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-1"); + expect(transitionState).toEqual({ + timeSinceLastFiringMs: 10, + firedInThisFrame: true, + firingCount: 3, + }); + expect(transitionState).not.toHaveProperty("instance"); + + expect(reader.toFrameState()).toEqual({ + number: 7, + places: { + [place.id]: { tokenCount: 2 }, + }, + }); + }); + + it("returns a copied token value buffer", () => { + const reader = compileSimulationFrameReader(sdcpn)(makeFrame(), 7, 1.25); + 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..58d26abbb77 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts @@ -0,0 +1,97 @@ +import type { SDCPN } from "../../types/sdcpn"; +import type { + SimulationFrameReader, + SimulationFrameState, + SimulationPlaceTokenValues, +} from "../api"; +import { + createEngineFrameLayout, + readEngineFrame, + type EngineFrame, + type EngineFrameLayout, +} from "./internal-frame"; + +function createSimulationFrameReader( + layout: EngineFrameLayout, + frame: EngineFrame, + number: number, + time: number, +): SimulationFrameReader { + const frameView = readEngineFrame(layout, frame); + + const getPlaceTokenCount = (placeId: string): number => + frameView.getPlaceState(placeId)?.count ?? 0; + + const getPlaceTokenValues = ( + placeId: string, + ): SimulationPlaceTokenValues | null => { + const placeState = frameView.getPlaceState(placeId); + if (!placeState) { + return null; + } + + const tokenValues = frameView.getPlaceTokenValues(placeId)!; + return { + values: tokenValues.slice(), + count: placeState.count, + }; + }; + + return { + number, + time, + getPlaceTokenCount, + getPlaceTokenValues, + getPlaceTokens(place, color) { + const placeState = frameView.getPlaceState(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] = + frameView.tokenValues[base + dimensionIndex] ?? 0; + } + tokens.push(token); + } + + return tokens; + }, + getTransitionState: (transitionId) => + frameView.getTransitionState(transitionId), + toFrameState() { + const places: SimulationFrameState["places"] = {}; + for (const [placeId, placeData] of frameView.getPlaceEntries()) { + places[placeId] = { tokenCount: placeData.count }; + } + + return { + number, + places, + }; + }, + }; +} + +export function compileSimulationFrameReader( + sdcpn: Pick, +): (frame: EngineFrame, number: number, time: number) => SimulationFrameReader { + const layout = createEngineFrameLayout(sdcpn); + + return (frame, number, time) => + createSimulationFrameReader(layout, frame, number, time); +} 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..242d2363784 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts @@ -0,0 +1,485 @@ +import type { ID, SDCPN } from "../../types/sdcpn"; +import type { SimulationTransitionState } from "./transition-state"; + +/** + * Internal place layout within an engine frame. + */ +export type EngineFramePlaceState = { + offset: number; + count: number; + dimensions: number; +}; + +export type EngineFrameSnapshot = { + places: Record; + transitions: Record; + buffer: Float64Array; +}; + +export type EngineFrameLayout = { + placeIds: readonly ID[]; + placeIndexById: ReadonlyMap; + placeDimensions: Uint32Array; + transitionIds: readonly ID[]; + transitionIndexById: ReadonlyMap; +}; + +/** + * Internal frame storage layout used by the stepping engine and worker payload. + * + * This is intentionally only an `ArrayBuffer`. The SDCPN-specific layout is + * kept outside each frame and must be supplied to read or write the buffer. + * Public callers should read engine output through `SimulationFrameReader`. + */ +export type EngineFrame = ArrayBuffer; + +type EngineFrameHeader = { + placeCount: number; + transitionCount: number; + tokenValueCount: number; + placeCountsOffset: number; + placeValueOffsetsOffset: number; + transitionElapsedOffset: number; + transitionFiringCountsOffset: number; + transitionFiredFlagsOffset: number; + tokenValuesOffset: number; + byteLength: number; +}; + +export type EngineFrameView = { + tokenValues: Float64Array; + getPlaceState(placeId: ID): EngineFramePlaceState | null; + getPlaceEntries(): [ID, EngineFramePlaceState][]; + getPlaceTokenValues(placeId: ID): Float64Array | null; + getTransitionState(transitionId: ID): SimulationTransitionState | null; + getTransitionEntries(): [ID, SimulationTransitionState][]; + toSnapshot(): EngineFrameSnapshot; +}; + +const FRAME_MAGIC = 0x5046524d; // "PFRM" +const FRAME_VERSION = 1; +const HEADER_BYTES = 64; + +const enum HeaderOffset { + Magic = 0, + Version = 4, + HeaderBytes = 6, + PlaceCount = 8, + TransitionCount = 12, + TokenValueCount = 16, + PlaceCountsOffset = 20, + PlaceValueOffsetsOffset = 24, + TransitionElapsedOffset = 28, + TransitionFiringCountsOffset = 32, + TransitionFiredFlagsOffset = 36, + TokenValuesOffset = 40, + ByteLength = 44, +} + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; + +function getPlaceDimensions( + sdcpn: Pick, + place: Pick, +): number { + if (!place.colorId) { + return 0; + } + + const color = sdcpn.types.find((type) => type.id === place.colorId); + if (!color) { + throw new Error( + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + + return color.elements.length; +} + +export function createEngineFrameLayout( + sdcpn: Pick, +): EngineFrameLayout { + const placeIds = sdcpn.places.map((place) => place.id); + const placeIndexById = new Map(); + const placeDimensions = new Uint32Array(placeIds.length); + + for (let index = 0; index < sdcpn.places.length; index++) { + const place = sdcpn.places[index]!; + if (place.id === "__proto__") { + throw new Error("Cannot add place with id '__proto__'"); + } + if (placeIndexById.has(place.id)) { + throw new Error(`Duplicate place id in SDCPN: ${place.id}`); + } + placeIndexById.set(place.id, index); + placeDimensions[index] = getPlaceDimensions(sdcpn, place); + } + + const transitionIds = sdcpn.transitions.map((transition) => transition.id); + const transitionIndexById = new Map(); + for (let index = 0; index < sdcpn.transitions.length; index++) { + const transition = sdcpn.transitions[index]!; + if (transition.id === "__proto__") { + throw new Error("Cannot add transition with id '__proto__'"); + } + if (transitionIndexById.has(transition.id)) { + throw new Error(`Duplicate transition id in SDCPN: ${transition.id}`); + } + transitionIndexById.set(transition.id, index); + } + + return { + placeIds, + placeIndexById, + placeDimensions, + transitionIds, + transitionIndexById, + }; +} + +function readHeader(frame: EngineFrame): EngineFrameHeader { + if (frame.byteLength < HEADER_BYTES) { + throw new Error("Invalid EngineFrame: frame is shorter than its header"); + } + + const view = new DataView(frame); + const magic = view.getUint32(HeaderOffset.Magic, true); + if (magic !== FRAME_MAGIC) { + throw new Error("Invalid EngineFrame: unexpected frame magic"); + } + + const version = view.getUint16(HeaderOffset.Version, true); + if (version !== FRAME_VERSION) { + throw new Error(`Unsupported EngineFrame version: ${version}`); + } + + const headerBytes = view.getUint16(HeaderOffset.HeaderBytes, true); + if (headerBytes !== HEADER_BYTES) { + throw new Error(`Unsupported EngineFrame header size: ${headerBytes}`); + } + + const byteLength = view.getUint32(HeaderOffset.ByteLength, true); + if (byteLength !== frame.byteLength) { + throw new Error("Invalid EngineFrame: byte length mismatch"); + } + + return { + placeCount: view.getUint32(HeaderOffset.PlaceCount, true), + transitionCount: view.getUint32(HeaderOffset.TransitionCount, true), + tokenValueCount: view.getUint32(HeaderOffset.TokenValueCount, true), + placeCountsOffset: view.getUint32(HeaderOffset.PlaceCountsOffset, true), + placeValueOffsetsOffset: view.getUint32( + HeaderOffset.PlaceValueOffsetsOffset, + true, + ), + transitionElapsedOffset: view.getUint32( + HeaderOffset.TransitionElapsedOffset, + true, + ), + transitionFiringCountsOffset: view.getUint32( + HeaderOffset.TransitionFiringCountsOffset, + true, + ), + transitionFiredFlagsOffset: view.getUint32( + HeaderOffset.TransitionFiredFlagsOffset, + true, + ), + tokenValuesOffset: view.getUint32(HeaderOffset.TokenValuesOffset, true), + byteLength, + }; +} + +function assertLayoutMatchesFrame( + layout: EngineFrameLayout, + header: EngineFrameHeader, +): void { + if (layout.placeIds.length !== header.placeCount) { + throw new Error( + `EngineFrame place count mismatch: layout has ${layout.placeIds.length}, frame has ${header.placeCount}`, + ); + } + if (layout.transitionIds.length !== header.transitionCount) { + throw new Error( + `EngineFrame transition count mismatch: layout has ${layout.transitionIds.length}, frame has ${header.transitionCount}`, + ); + } +} + +function writeHeader(buffer: ArrayBuffer, header: EngineFrameHeader): void { + const view = new DataView(buffer); + view.setUint32(HeaderOffset.Magic, FRAME_MAGIC, true); + view.setUint16(HeaderOffset.Version, FRAME_VERSION, true); + view.setUint16(HeaderOffset.HeaderBytes, HEADER_BYTES, true); + view.setUint32(HeaderOffset.PlaceCount, header.placeCount, true); + view.setUint32(HeaderOffset.TransitionCount, header.transitionCount, true); + view.setUint32(HeaderOffset.TokenValueCount, header.tokenValueCount, true); + view.setUint32( + HeaderOffset.PlaceCountsOffset, + header.placeCountsOffset, + true, + ); + view.setUint32( + HeaderOffset.PlaceValueOffsetsOffset, + header.placeValueOffsetsOffset, + true, + ); + view.setUint32( + HeaderOffset.TransitionElapsedOffset, + header.transitionElapsedOffset, + true, + ); + view.setUint32( + HeaderOffset.TransitionFiringCountsOffset, + header.transitionFiringCountsOffset, + true, + ); + view.setUint32( + HeaderOffset.TransitionFiredFlagsOffset, + header.transitionFiredFlagsOffset, + true, + ); + view.setUint32( + HeaderOffset.TokenValuesOffset, + header.tokenValuesOffset, + true, + ); + view.setUint32(HeaderOffset.ByteLength, header.byteLength, true); +} + +export function createEngineFrame( + layout: EngineFrameLayout, + snapshot: EngineFrameSnapshot, +): EngineFrame { + const placeCount = layout.placeIds.length; + const transitionCount = layout.transitionIds.length; + const packedPlaceCounts = new Uint32Array(placeCount); + const packedPlaceOffsets = new Uint32Array(placeCount); + + let tokenValueCount = 0; + for (let index = 0; index < placeCount; index++) { + const placeId = layout.placeIds[index]!; + const dimensions = layout.placeDimensions[index] ?? 0; + const placeState = snapshot.places[placeId] ?? { + offset: tokenValueCount, + count: 0, + dimensions, + }; + + if (placeState.dimensions !== dimensions) { + throw new Error( + `Place ${placeId} has ${placeState.dimensions} dimensions in snapshot, expected ${dimensions}`, + ); + } + + packedPlaceCounts[index] = placeState.count; + packedPlaceOffsets[index] = tokenValueCount; + tokenValueCount += placeState.count * dimensions; + } + + const placeCountsOffset = HEADER_BYTES; + const placeValueOffsetsOffset = + placeCountsOffset + packedPlaceCounts.byteLength; + const transitionElapsedOffset = alignTo( + placeValueOffsetsOffset + packedPlaceOffsets.byteLength, + 8, + ); + const transitionFiringCountsOffset = + transitionElapsedOffset + transitionCount * Float64Array.BYTES_PER_ELEMENT; + const transitionFiredFlagsOffset = + transitionFiringCountsOffset + + transitionCount * Uint32Array.BYTES_PER_ELEMENT; + const tokenValuesOffset = alignTo( + transitionFiredFlagsOffset + transitionCount * Uint8Array.BYTES_PER_ELEMENT, + 8, + ); + const byteLength = + tokenValuesOffset + tokenValueCount * Float64Array.BYTES_PER_ELEMENT; + + const frame = new ArrayBuffer(byteLength); + writeHeader(frame, { + placeCount, + transitionCount, + tokenValueCount, + placeCountsOffset, + placeValueOffsetsOffset, + transitionElapsedOffset, + transitionFiringCountsOffset, + transitionFiredFlagsOffset, + tokenValuesOffset, + byteLength, + }); + + new Uint32Array(frame, placeCountsOffset, placeCount).set(packedPlaceCounts); + new Uint32Array(frame, placeValueOffsetsOffset, placeCount).set( + packedPlaceOffsets, + ); + + const transitionElapsed = new Float64Array( + frame, + transitionElapsedOffset, + transitionCount, + ); + const transitionFiringCounts = new Uint32Array( + frame, + transitionFiringCountsOffset, + transitionCount, + ); + const transitionFiredFlags = new Uint8Array( + frame, + transitionFiredFlagsOffset, + transitionCount, + ); + + for (let index = 0; index < transitionCount; index++) { + const transitionId = layout.transitionIds[index]!; + const transitionState = snapshot.transitions[transitionId] ?? { + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, + }; + transitionElapsed[index] = transitionState.timeSinceLastFiringMs; + transitionFiringCounts[index] = transitionState.firingCount; + transitionFiredFlags[index] = transitionState.firedInThisFrame ? 1 : 0; + } + + const tokenValues = new Float64Array( + frame, + tokenValuesOffset, + tokenValueCount, + ); + for (let index = 0; index < placeCount; index++) { + const placeId = layout.placeIds[index]!; + const dimensions = layout.placeDimensions[index] ?? 0; + const count = packedPlaceCounts[index] ?? 0; + const targetOffset = packedPlaceOffsets[index] ?? 0; + const size = count * dimensions; + if (size === 0) { + continue; + } + + const sourceState = snapshot.places[placeId]!; + tokenValues.set( + snapshot.buffer.subarray(sourceState.offset, sourceState.offset + size), + targetOffset, + ); + } + + return frame; +} + +export function readEngineFrame( + layout: EngineFrameLayout, + frame: EngineFrame, +): EngineFrameView { + const header = readHeader(frame); + assertLayoutMatchesFrame(layout, header); + + const placeCounts = new Uint32Array( + frame, + header.placeCountsOffset, + header.placeCount, + ); + const placeValueOffsets = new Uint32Array( + frame, + header.placeValueOffsetsOffset, + header.placeCount, + ); + const transitionElapsed = new Float64Array( + frame, + header.transitionElapsedOffset, + header.transitionCount, + ); + const transitionFiringCounts = new Uint32Array( + frame, + header.transitionFiringCountsOffset, + header.transitionCount, + ); + const transitionFiredFlags = new Uint8Array( + frame, + header.transitionFiredFlagsOffset, + header.transitionCount, + ); + const tokenValues = new Float64Array( + frame, + header.tokenValuesOffset, + header.tokenValueCount, + ); + + const getPlaceState = (placeId: ID): EngineFramePlaceState | null => { + const index = layout.placeIndexById.get(placeId); + if (index === undefined) { + return null; + } + + return { + offset: placeValueOffsets[index] ?? 0, + count: placeCounts[index] ?? 0, + dimensions: layout.placeDimensions[index] ?? 0, + }; + }; + + const getTransitionState = ( + transitionId: ID, + ): SimulationTransitionState | null => { + const index = layout.transitionIndexById.get(transitionId); + if (index === undefined) { + return null; + } + + return { + timeSinceLastFiringMs: transitionElapsed[index] ?? 0, + firedInThisFrame: (transitionFiredFlags[index] ?? 0) !== 0, + firingCount: transitionFiringCounts[index] ?? 0, + }; + }; + + const getPlaceEntries = (): [ID, EngineFramePlaceState][] => + layout.placeIds.map((placeId) => [placeId, getPlaceState(placeId)!]); + + const getTransitionEntries = (): [ID, SimulationTransitionState][] => + layout.transitionIds.map((transitionId) => [ + transitionId, + getTransitionState(transitionId)!, + ]); + + return { + tokenValues, + getPlaceState, + getPlaceEntries, + getPlaceTokenValues(placeId) { + const placeState = getPlaceState(placeId); + if (!placeState) { + return null; + } + const size = placeState.count * placeState.dimensions; + return tokenValues.subarray(placeState.offset, placeState.offset + size); + }, + getTransitionState, + getTransitionEntries, + toSnapshot() { + const places: EngineFrameSnapshot["places"] = {}; + for (const [placeId, placeState] of getPlaceEntries()) { + places[placeId] = placeState; + } + + const transitions: EngineFrameSnapshot["transitions"] = {}; + for (const [transitionId, transitionState] of getTransitionEntries()) { + transitions[transitionId] = transitionState; + } + + return { + places, + transitions, + buffer: tokenValues.slice(), + }; + }, + }; +} + +export function materializeEngineFrame( + layout: EngineFrameLayout, + frame: EngineFrame, +): EngineFrameSnapshot { + return readEngineFrame(layout, frame).toSnapshot(); +} 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..781421cf505 --- /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/metric/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/frames/transition-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/transition-state.ts new file mode 100644 index 00000000000..ad8936c1e5f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/transition-state.ts @@ -0,0 +1,24 @@ +/** + * Internal transition state stored in engine and worker frames. + * + * Public callers should access this shape through + * `SimulationFrameReader.getTransitionState()`, not through a separately + * exported type. + */ +export type SimulationTransitionState = { + /** + * 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; +}; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/index.ts b/libs/@hashintel/petrinaut/src/core/simulation/index.ts index f39f72f550f..39c1623ef1d 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/index.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/index.ts @@ -1,17 +1,49 @@ +export type { + BackpressureConfig, + CreateSimulationConfig, + InitialMarking, + InitialPlaceMarking, + Simulation, + SimulationCompleteEvent, + SimulationConfig, + SimulationErrorEvent, + SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameSummary, + SimulationPlaceTokenValues, + SimulationTransport, + SimulationState, + WorkerFactory, +} from "./api"; 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"; + createMonteCarloExperiment, + createMonteCarloSimulator, + createMonteCarloWorker, + createPlaceTokenCountDistributionMetric, +} from "./monte-carlo"; +export type { + CreateMonteCarloExperimentConfig, + MonteCarloAdvanceResult, + MonteCarloActiveRunPlaceCountsVisitor, + MonteCarloExperiment, + MonteCarloExperimentDistributions, + MonteCarloExperimentEvent, + MonteCarloExperimentState, + MonteCarloFrameMetric, + MonteCarloFrameMetricContext, + MonteCarloRunConfig, + MonteCarloRunSnapshot, + MonteCarloRunStatus, + MonteCarloRunSummary, + MonteCarloRunUntilCompleteOptions, + MonteCarloSimulator, + MonteCarloSimulatorConfig, + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, + PlaceTokenCountDistributionPlace, + MonteCarloWorkerProgress, +} from "./monte-carlo"; +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/monte-carlo/README.md b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/README.md new file mode 100644 index 00000000000..185109986cb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/README.md @@ -0,0 +1,66 @@ +# Monte Carlo Simulator + +## Goal + +`MonteCarloSimulator` runs many independent SDCPN simulations with bounded +frame memory. It is separate from the interactive simulator: it does not retain +frame history, does not expose engine frame storage, and is designed for batch +statistics work. + +## Concepts + +| Name | Meaning | +| --- | --- | +| `MonteCarloSimulator` | Orchestrates a group of independent runs. | +| `MonteCarloRun` | One logical simulation run with its own seed, parameters, initial marking, status, time, and frame buffers. | +| `MonteCarloFrameBuffer` | Internal reusable binary storage for one frame. Each run owns a current buffer and next buffer. | +| `advanceAll()` | Deterministic round-robin scheduler. Advances every active run by one frame. | +| `runUntilComplete()` | Repeats `advanceAll()` until all runs are complete/errored or a guard limit is reached. | + +## Memory Model + +Each run owns two reusable `ArrayBuffer`s: + +- `currentFrame`: the current frame. +- `nextFrame`: the write target for the next frame. + +Each step writes into `nextFrame`, then swaps the two pointers. The simulator +does not append frames to a history array. + +The frame buffer stores: + +- per-place token counts +- per-place token value offsets +- per-transition elapsed time +- per-transition firing counts +- per-transition fired flags +- contiguous `Float64` token values + +The token value section has a capacity. During transition execution the +simulator calculates the next token value count from removals and additions. +If the target buffer cannot hold it, that run reallocates a larger buffer and +continues. Reallocation is per run and tracked in run summaries. + +The current growth policy doubles token value capacity on reallocation: + +```text +nextCapacity = max(requiredTokenValueCount, currentCapacity * 2, 8) +``` + +Future versions should make this smarter. Static analysis could estimate the +maximum expansion ratio from arc weights and output token dimensions. Dynamic +analysis could adjust the growth ratio per run based on observed growth rate. + +## Scheduling + +The current strategy is single-threaded deterministic round-robin. This keeps +all runs moving together and avoids one long run starving the others. Future +parallelization can shard `MonteCarloRun`s across Web Workers without changing +the run state model. + +## Current Limits + +This first implementation focuses on bounded frame retention and a testable API. +It still reuses the existing user-code object API for dynamics, lambda, and +transition kernels. Future IR compilation can make those functions operate +directly on numeric buffers. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/advance-run.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/advance-run.ts new file mode 100644 index 00000000000..8e00572f971 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/advance-run.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-param-reassign -- Monte Carlo run state is mutable by design. */ +import type { SimulationCompletionReason } from "../engine/compute-next-frame"; +import { + applyTokenAdditions, + applyTokenRemovals, + hasStructurallyEnabledTransition, + mergeTokenAdditions, + updateTransitionTimers, + writeFrameAfterDynamics, +} from "./frame-operations"; +import type { MonteCarloRunState, PlaceID } from "./internal-types"; +import { computeTransitionEffect } from "./transition-effect"; + +/** + * Marks a run as complete and records why no more frames will be advanced. + */ +function completeRun( + run: MonteCarloRunState, + reason: SimulationCompletionReason, +): void { + run.status = "complete"; + run.completionReason = reason; +} + +/** + * Marks a run as errored while preserving a serializable error message. + */ +function failRun(run: MonteCarloRunState, error: unknown): void { + run.status = "error"; + run.error = error instanceof Error ? error.message : String(error); +} + +/** + * Advances one Monte Carlo run by one simulation step. + * + * The step applies continuous dynamics, evaluates transition effects, mutates + * the reusable frame buffers, swaps current/next frame pointers, advances time, + * and updates completion status. It returns `true` only when a new frame was + * produced. + */ +export function advanceRun(run: MonteCarloRunState): boolean { + if (run.status === "complete" || run.status === "error") { + return false; + } + + try { + if (run.frameNumber >= run.maxFrameNumber) { + completeRun(run, "maxTime"); + return false; + } + + run.status = "running"; + let workingFrame = writeFrameAfterDynamics(run); + const tokensToAdd = new Map(); + const firedTransitions = new Set(); + + for (const transitionId of run.simulation.frameLayout.transitionIds) { + const transition = run.simulation.compiledTransitions.get(transitionId); + if (!transition) { + throw new Error(`Compiled transition ${transitionId} not found`); + } + + const effect = computeTransitionEffect(run, workingFrame, transition); + if (!effect) { + continue; + } + + firedTransitions.add(transitionId); + run.rngState = effect.newRngState; + applyTokenRemovals( + run.simulation.frameLayout, + workingFrame, + effect.remove, + ); + mergeTokenAdditions(tokensToAdd, effect.add); + } + + workingFrame = applyTokenAdditions(run, workingFrame, tokensToAdd); + updateTransitionTimers(workingFrame, firedTransitions, run.simulation); + + run.nextFrame = run.currentFrame; + run.currentFrame = workingFrame; + run.frameNumber++; + + if (run.frameNumber >= run.maxFrameNumber) { + completeRun(run, "maxTime"); + } else if ( + firedTransitions.size === 0 && + !hasStructurallyEnabledTransition(run) + ) { + completeRun(run, "deadlock"); + } + + return true; + } catch (error) { + failRun(run, error); + return false; + } +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/frame-buffer.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/frame-buffer.ts new file mode 100644 index 00000000000..e8d61130996 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/frame-buffer.ts @@ -0,0 +1,249 @@ +/* eslint-disable no-param-reassign -- Monte Carlo frame buffers are mutable by design. */ +import type { + EngineFrameLayout, + EngineFrameView, +} from "../frames/internal-frame"; + +export type MonteCarloFrameBuffer = { + buffer: ArrayBuffer; + tokenValueCapacity: number; + tokenValueCount: number; + placeCounts: Uint32Array; + placeOffsets: Uint32Array; + transitionElapsedFrames: Float64Array; + transitionElapsed: Float64Array; + transitionFiringCounts: Uint32Array; + transitionFiredFlags: Uint8Array; + tokenValues: Float64Array; +}; + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; + +/** + * Creates typed-array views over one Monte Carlo frame buffer. + * + * The buffer contains fixed-size place and transition metadata followed by the + * variable-capacity token value region. Keeping all views over one ArrayBuffer + * makes frame swapping cheap and keeps ownership explicit. + */ +function createViews( + layout: EngineFrameLayout, + buffer: ArrayBuffer, + tokenValueCapacity: number, +): Omit< + MonteCarloFrameBuffer, + "buffer" | "tokenValueCapacity" | "tokenValueCount" +> { + const placeCount = layout.placeIds.length; + const transitionCount = layout.transitionIds.length; + + const placeCountsOffset = 0; + const placeOffsetsOffset = + placeCountsOffset + placeCount * Uint32Array.BYTES_PER_ELEMENT; + const transitionElapsedOffset = alignTo( + placeOffsetsOffset + placeCount * Uint32Array.BYTES_PER_ELEMENT, + Float64Array.BYTES_PER_ELEMENT, + ); + const transitionElapsedFramesOffset = + transitionElapsedOffset + transitionCount * Float64Array.BYTES_PER_ELEMENT; + const transitionFiringCountsOffset = + transitionElapsedFramesOffset + + transitionCount * Float64Array.BYTES_PER_ELEMENT; + const transitionFiredFlagsOffset = + transitionFiringCountsOffset + + transitionCount * Uint32Array.BYTES_PER_ELEMENT; + const tokenValuesOffset = alignTo( + transitionFiredFlagsOffset + transitionCount * Uint8Array.BYTES_PER_ELEMENT, + Float64Array.BYTES_PER_ELEMENT, + ); + + return { + placeCounts: new Uint32Array(buffer, placeCountsOffset, placeCount), + placeOffsets: new Uint32Array(buffer, placeOffsetsOffset, placeCount), + transitionElapsed: new Float64Array( + buffer, + transitionElapsedOffset, + transitionCount, + ), + transitionElapsedFrames: new Float64Array( + buffer, + transitionElapsedFramesOffset, + transitionCount, + ), + transitionFiringCounts: new Uint32Array( + buffer, + transitionFiringCountsOffset, + transitionCount, + ), + transitionFiredFlags: new Uint8Array( + buffer, + transitionFiredFlagsOffset, + transitionCount, + ), + tokenValues: new Float64Array( + buffer, + tokenValuesOffset, + tokenValueCapacity, + ), + }; +} + +/** + * Computes the ArrayBuffer byte length required for a frame with this layout + * and token value capacity. + * + * `tokenValueCapacity` is measured in Float64 values, not token count, because + * colored places can have different dimensionality. + */ +export function getMonteCarloFrameBufferByteLength( + layout: EngineFrameLayout, + tokenValueCapacity: number, +): number { + const placeCount = layout.placeIds.length; + const transitionCount = layout.transitionIds.length; + const placeBytes = placeCount * Uint32Array.BYTES_PER_ELEMENT * 2; + const transitionElapsedOffset = alignTo( + placeBytes, + Float64Array.BYTES_PER_ELEMENT, + ); + const transitionBytes = + transitionCount * Float64Array.BYTES_PER_ELEMENT + + transitionCount * Float64Array.BYTES_PER_ELEMENT + + transitionCount * Uint32Array.BYTES_PER_ELEMENT + + transitionCount * Uint8Array.BYTES_PER_ELEMENT; + const tokenValuesOffset = alignTo( + transitionElapsedOffset + transitionBytes, + Float64Array.BYTES_PER_ELEMENT, + ); + + return ( + tokenValuesOffset + tokenValueCapacity * Float64Array.BYTES_PER_ELEMENT + ); +} + +/** + * Allocates an empty Monte Carlo frame buffer and attaches its typed-array + * views. + * + * The returned frame owns one ArrayBuffer and starts with zero used token + * values, even if a larger capacity was allocated. + */ +export function createMonteCarloFrameBuffer( + layout: EngineFrameLayout, + tokenValueCapacity: number, +): MonteCarloFrameBuffer { + const normalizedCapacity = Math.max(0, Math.ceil(tokenValueCapacity)); + const buffer = new ArrayBuffer( + getMonteCarloFrameBufferByteLength(layout, normalizedCapacity), + ); + + return { + buffer, + tokenValueCapacity: normalizedCapacity, + tokenValueCount: 0, + ...createViews(layout, buffer, normalizedCapacity), + }; +} + +/** + * Copies the used portion of one Monte Carlo frame into another existing frame + * buffer. + * + * The target must already have enough token value capacity. This is used for + * current/next frame swapping without allocating on every simulation step. + */ +export function copyMonteCarloFrameBuffer( + source: MonteCarloFrameBuffer, + target: MonteCarloFrameBuffer, +): void { + if (target.tokenValueCapacity < source.tokenValueCount) { + throw new Error( + `Target MonteCarloFrameBuffer capacity ${target.tokenValueCapacity} cannot hold ${source.tokenValueCount} token values`, + ); + } + + target.placeCounts.set(source.placeCounts); + target.placeOffsets.set(source.placeOffsets); + target.transitionElapsedFrames.set(source.transitionElapsedFrames); + target.transitionElapsed.set(source.transitionElapsed); + target.transitionFiringCounts.set(source.transitionFiringCounts); + target.transitionFiredFlags.set(source.transitionFiredFlags); + target.tokenValues.set( + source.tokenValues.subarray(0, source.tokenValueCount), + ); + target.tokenValueCount = source.tokenValueCount; +} + +/** + * Allocates a new Monte Carlo frame buffer and copies an existing frame into + * it. + * + * This is the resize path used when a run outgrows its current token value + * capacity. + */ +export function cloneMonteCarloFrameBuffer( + layout: EngineFrameLayout, + source: MonteCarloFrameBuffer, + tokenValueCapacity: number, +): MonteCarloFrameBuffer { + const target = createMonteCarloFrameBuffer(layout, tokenValueCapacity); + copyMonteCarloFrameBuffer(source, target); + return target; +} + +/** + * Converts the regular engine frame reader output into a mutable Monte Carlo + * frame buffer. + * + * Monte Carlo runs only retain their current and next frames, so initialization + * copies the engine-produced initial frame into the reusable buffer format and + * then discards the retained engine frame history. + */ +export function copyEngineFrameViewToMonteCarloFrameBuffer( + layout: EngineFrameLayout, + source: EngineFrameView, + target: MonteCarloFrameBuffer, + dt: number, +): void { + const tokenValueCount = source.tokenValues.length; + if (target.tokenValueCapacity < tokenValueCount) { + throw new Error( + `Target MonteCarloFrameBuffer capacity ${target.tokenValueCapacity} cannot hold ${tokenValueCount} token values`, + ); + } + + for (let index = 0; index < layout.placeIds.length; index++) { + const placeId = layout.placeIds[index]!; + const placeState = source.getPlaceState(placeId); + if (!placeState) { + throw new Error(`Place ${placeId} not found in source frame`); + } + + target.placeCounts[index] = placeState.count; + target.placeOffsets[index] = placeState.offset; + } + + for (let index = 0; index < layout.transitionIds.length; index++) { + const transitionId = layout.transitionIds[index]!; + const transitionState = source.getTransitionState(transitionId); + if (!transitionState) { + throw new Error(`Transition ${transitionId} not found in source frame`); + } + + const elapsedFrames = Math.max( + 0, + Math.round(transitionState.timeSinceLastFiringMs / dt), + ); + + target.transitionElapsedFrames[index] = elapsedFrames; + target.transitionElapsed[index] = elapsedFrames * dt; + target.transitionFiringCounts[index] = transitionState.firingCount; + target.transitionFiredFlags[index] = transitionState.firedInThisFrame + ? 1 + : 0; + } + + target.tokenValues.set(source.tokenValues); + target.tokenValueCount = tokenValueCount; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/frame-operations.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/frame-operations.ts new file mode 100644 index 00000000000..02268a561f8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/frame-operations.ts @@ -0,0 +1,325 @@ +/* eslint-disable no-param-reassign -- Monte Carlo frame buffers are mutable by design. */ +import { computePlaceNextState } from "../engine/compute-place-next-state"; +import type { EngineFrameLayout, SimulationInstance } from "../engine/types"; +import { + copyMonteCarloFrameBuffer, + type MonteCarloFrameBuffer, +} from "./frame-buffer"; +import type { MonteCarloRunState, PlaceID } from "./internal-types"; +import { getPlaceIndex } from "./layout"; +import { ensureFrameCapacity } from "./run-state"; + +/** + * Copies the current frame into the next-frame buffer and applies continuous + * place dynamics. + * + * This is the first phase of a Monte Carlo step: it preserves token structure + * while updating colored token values according to each compiled differential + * equation. + */ +export function writeFrameAfterDynamics( + run: MonteCarloRunState, +): MonteCarloFrameBuffer { + const { simulation } = run; + const { frameLayout } = simulation; + const source = run.currentFrame; + const target = ensureFrameCapacity( + run, + run.nextFrame, + source.tokenValueCount, + ); + + copyMonteCarloFrameBuffer(source, target); + + for (const [ + placeId, + differentialEquation, + ] of simulation.differentialEquationFns) { + const placeIndex = getPlaceIndex(frameLayout, placeId); + const count = source.placeCounts[placeIndex] ?? 0; + const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const placeSize = count * dimensions; + if (placeSize === 0) { + continue; + } + + const offset = source.placeOffsets[placeIndex] ?? 0; + const currentState = source.tokenValues.slice(offset, offset + placeSize); + const nextState = computePlaceNextState( + currentState, + dimensions, + count, + differentialEquation, + "euler", + simulation.dt, + ); + + target.tokenValues.set(nextState, offset); + } + + return target; +} + +/** + * Removes fired transition input tokens from a frame in place. + * + * Colored places remove explicit token indices and compact the token value + * region. Uncolored places remove only a count because they have no token value + * storage. + */ +export function applyTokenRemovals( + frameLayout: EngineFrameLayout, + frame: MonteCarloFrameBuffer, + tokensToRemove: Record | number>, +): void { + for (const [placeId, tokenSelection] of Object.entries(tokensToRemove)) { + const placeIndex = getPlaceIndex(frameLayout, placeId); + const count = frame.placeCounts[placeIndex] ?? 0; + const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + + if (dimensions === 0) { + if (typeof tokenSelection !== "number") { + throw new Error( + `Expected token count removal for uncolored place ${placeId}`, + ); + } + if (tokenSelection > count) { + throw new Error( + `Cannot remove ${tokenSelection} tokens from place ${placeId}; it only has ${count}`, + ); + } + continue; + } + + if (typeof tokenSelection === "number") { + throw new Error(`Expected token index removal set for place ${placeId}`); + } + + for (const tokenIndex of tokenSelection) { + if (tokenIndex < 0 || tokenIndex >= count) { + throw new Error( + `Invalid token index ${tokenIndex} for place ${placeId}; it has ${count} tokens`, + ); + } + } + } + + let writeOffset = 0; + for ( + let placeIndex = 0; + placeIndex < frameLayout.placeIds.length; + placeIndex++ + ) { + const placeId = frameLayout.placeIds[placeIndex]!; + const count = frame.placeCounts[placeIndex] ?? 0; + const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const oldOffset = frame.placeOffsets[placeIndex] ?? 0; + const tokenSelection = tokensToRemove[placeId]; + + frame.placeOffsets[placeIndex] = writeOffset; + + if (dimensions === 0) { + const removedCount = + typeof tokenSelection === "number" ? tokenSelection : 0; + frame.placeCounts[placeIndex] = count - removedCount; + continue; + } + + const removedIndices = + tokenSelection instanceof Set ? tokenSelection : new Set(); + let nextCount = 0; + for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + if (removedIndices.has(tokenIndex)) { + continue; + } + + const sourceOffset = oldOffset + tokenIndex * dimensions; + if (writeOffset !== sourceOffset) { + frame.tokenValues.copyWithin( + writeOffset, + sourceOffset, + sourceOffset + dimensions, + ); + } + writeOffset += dimensions; + nextCount++; + } + + frame.placeCounts[placeIndex] = nextCount; + } + + frame.tokenValueCount = writeOffset; +} + +/** + * Accumulates token additions from multiple fired transitions by output place. + * + * Deferring additions until all removals are applied lets one step handle + * multiple firings without repeatedly repacking the frame. + */ +export function mergeTokenAdditions( + target: Map, + additions: Record, +): void { + for (const [placeId, tokens] of Object.entries(additions)) { + const existingTokens = target.get(placeId); + if (existingTokens) { + existingTokens.push(...tokens); + } else { + target.set(placeId, [...tokens]); + } + } +} + +/** + * Appends pending output tokens into the frame, resizing if needed. + * + * The function computes the required Float64 value count, repacks all places + * into their new contiguous offsets, and writes added colored token values at + * the end of each place segment. + */ +export function applyTokenAdditions( + run: MonteCarloRunState, + frame: MonteCarloFrameBuffer, + tokensToAdd: ReadonlyMap, +): MonteCarloFrameBuffer { + if (tokensToAdd.size === 0) { + return frame; + } + + const { frameLayout } = run.simulation; + const additionalTokenCounts = new Uint32Array(frameLayout.placeIds.length); + let addedTokenValueCount = 0; + + for (const [placeId, tokens] of tokensToAdd) { + const placeIndex = getPlaceIndex(frameLayout, placeId); + const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + for (const token of tokens) { + if (token.length !== dimensions) { + throw new Error( + `Token dimension mismatch for place ${placeId}. Expected ${dimensions}, got ${token.length}.`, + ); + } + } + + additionalTokenCounts[placeIndex] = + (additionalTokenCounts[placeIndex] ?? 0) + tokens.length; + addedTokenValueCount += tokens.length * dimensions; + } + + const requiredTokenValueCount = frame.tokenValueCount + addedTokenValueCount; + const target = ensureFrameCapacity(run, frame, requiredTokenValueCount); + const newPlaceOffsets = new Uint32Array(frameLayout.placeIds.length); + const newPlaceCounts = new Uint32Array(frameLayout.placeIds.length); + + let offset = 0; + for ( + let placeIndex = 0; + placeIndex < frameLayout.placeIds.length; + placeIndex++ + ) { + const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const count = target.placeCounts[placeIndex] ?? 0; + const addedCount = additionalTokenCounts[placeIndex] ?? 0; + const newCount = count + addedCount; + + newPlaceOffsets[placeIndex] = offset; + newPlaceCounts[placeIndex] = newCount; + offset += newCount * dimensions; + } + + for ( + let placeIndex = frameLayout.placeIds.length - 1; + placeIndex >= 0; + placeIndex-- + ) { + const placeId = frameLayout.placeIds[placeIndex]!; + const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const oldCount = target.placeCounts[placeIndex] ?? 0; + const oldOffset = target.placeOffsets[placeIndex] ?? 0; + const oldSize = oldCount * dimensions; + const newOffset = newPlaceOffsets[placeIndex] ?? 0; + + if (oldSize > 0 && oldOffset !== newOffset) { + target.tokenValues.copyWithin(newOffset, oldOffset, oldOffset + oldSize); + } + + const addedTokens = tokensToAdd.get(placeId); + if (addedTokens && dimensions > 0) { + let writeOffset = newOffset + oldSize; + for (const token of addedTokens) { + target.tokenValues.set(token, writeOffset); + writeOffset += dimensions; + } + } + } + + target.placeOffsets.set(newPlaceOffsets); + target.placeCounts.set(newPlaceCounts); + target.tokenValueCount = requiredTokenValueCount; + + return target; +} + +/** + * Updates transition elapsed-time and firing-count metadata after a step. + * + * Fired transitions reset their elapsed timer and increment their firing count; + * non-fired transitions advance by `dt`. + */ +export function updateTransitionTimers( + frame: MonteCarloFrameBuffer, + firedTransitions: ReadonlySet, + simulation: SimulationInstance, +): void { + for ( + let index = 0; + index < simulation.frameLayout.transitionIds.length; + index++ + ) { + const transitionId = simulation.frameLayout.transitionIds[index]!; + if (firedTransitions.has(transitionId)) { + frame.transitionElapsedFrames[index] = 0; + frame.transitionElapsed[index] = 0; + frame.transitionFiredFlags[index] = 1; + frame.transitionFiringCounts[index] = + (frame.transitionFiringCounts[index] ?? 0) + 1; + } else { + const elapsedFrames = (frame.transitionElapsedFrames[index] ?? 0) + 1; + frame.transitionElapsedFrames[index] = elapsedFrames; + frame.transitionElapsed[index] = elapsedFrames * simulation.dt; + frame.transitionFiredFlags[index] = 0; + } + } +} + +/** + * Checks whether any transition is structurally enabled in the current frame. + * + * This is used for deadlock detection after a step where no transition fired. + * It intentionally ignores lambda probability and only checks input-place + * token availability and inhibitor conditions. + */ +export function hasStructurallyEnabledTransition( + run: MonteCarloRunState, +): boolean { + const { frameLayout } = run.simulation; + const frame = run.currentFrame; + + for (const transition of run.simulation.compiledTransitions.values()) { + const enabled = transition.inputPlaces.every((inputPlace) => { + const placeIndex = getPlaceIndex(frameLayout, inputPlace.placeId); + const count = frame.placeCounts[placeIndex] ?? 0; + + return inputPlace.arcType === "inhibitor" + ? count < inputPlace.weight + : count >= inputPlace.weight; + }); + + if (enabled) { + return true; + } + } + + return false; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/index.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/index.ts new file mode 100644 index 00000000000..f8df6b5405f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/index.ts @@ -0,0 +1,31 @@ +export { createMonteCarloSimulator } from "./monte-carlo-simulator"; +export { createPlaceTokenCountDistributionMetric } from "./metrics"; +export { createMonteCarloExperiment } from "./runtime/experiment"; +export { createMonteCarloWorker } from "./worker/create-monte-carlo-worker"; +export type { + MonteCarloAdvanceResult, + MonteCarloRunConfig, + MonteCarloRunSnapshot, + MonteCarloRunStatus, + MonteCarloRunSummary, + MonteCarloRunUntilCompleteOptions, + MonteCarloSimulator, + MonteCarloSimulatorConfig, +} from "./types"; +export type { + MonteCarloActiveRunPlaceCountsVisitor, + MonteCarloFrameMetric, + MonteCarloFrameMetricContext, + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, + PlaceTokenCountDistributionPlace, +} from "./metrics"; +export type { + CreateMonteCarloExperimentConfig, + MonteCarloExperiment, + MonteCarloExperimentDistributions, + MonteCarloExperimentEvent, + MonteCarloExperimentState, +} from "./runtime/experiment"; +export type { MonteCarloWorkerProgress } from "./worker/messages"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/internal-types.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/internal-types.ts new file mode 100644 index 00000000000..405d2d4a743 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/internal-types.ts @@ -0,0 +1,30 @@ +import type { InitialMarking } from "../api"; +import type { SimulationCompletionReason } from "../engine/compute-next-frame"; +import type { ParameterValues, SimulationInstance } from "../engine/types"; +import type { MonteCarloFrameBuffer } from "./frame-buffer"; +import type { MonteCarloRunStatus } from "./types"; + +export type PlaceID = string; + +export type TransitionEffect = { + remove: Record | number>; + add: Record; + newRngState: number; +}; + +export type MonteCarloRunState = { + index: number; + status: MonteCarloRunStatus; + seed: number; + simulation: SimulationInstance; + currentFrame: MonteCarloFrameBuffer; + nextFrame: MonteCarloFrameBuffer; + initialMarking: InitialMarking; + parameterValues: ParameterValues; + frameNumber: number; + maxFrameNumber: number; + rngState: number; + completionReason: SimulationCompletionReason | null; + error: string | null; + reallocations: number; +}; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/layout.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/layout.ts new file mode 100644 index 00000000000..526a6594a0d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/layout.ts @@ -0,0 +1,40 @@ +import type { EngineFrameLayout } from "../frames/internal-frame"; + +/** + * Resolves a place ID to its dense frame-layout index. + * + * Monte Carlo frame buffers store place metadata in parallel typed arrays, so + * all hot-path frame operations need this numeric index before reading counts, + * offsets, or dimensions. + */ +export function getPlaceIndex( + layout: EngineFrameLayout, + placeId: string, +): number { + const placeIndex = layout.placeIndexById.get(placeId); + if (placeIndex === undefined) { + throw new Error(`Place ${placeId} not found in Monte Carlo frame layout`); + } + + return placeIndex; +} + +/** + * Resolves a transition ID to its dense frame-layout index. + * + * Transition timer and firing metadata are stored in parallel typed arrays, + * indexed by the SDCPN-specialized frame layout. + */ +export function getTransitionIndex( + layout: EngineFrameLayout, + transitionId: string, +): number { + const transitionIndex = layout.transitionIndexById.get(transitionId); + if (transitionIndex === undefined) { + throw new Error( + `Transition ${transitionId} not found in Monte Carlo frame layout`, + ); + } + + return transitionIndex; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/index.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/index.ts new file mode 100644 index 00000000000..a6be1ee7540 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/index.ts @@ -0,0 +1,10 @@ +export { createPlaceTokenCountDistributionMetric } from "./place-token-count-distribution"; +export type { + MonteCarloActiveRunPlaceCountsVisitor, + MonteCarloFrameMetric, + MonteCarloFrameMetricContext, + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, + PlaceTokenCountDistributionPlace, +} from "./types"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/place-token-count-distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/place-token-count-distribution.ts new file mode 100644 index 00000000000..5b29d2af66f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/place-token-count-distribution.ts @@ -0,0 +1,87 @@ +import type { + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, +} from "./types"; + +function growHistogram( + histogram: Uint32Array, + tokenCount: number, +): Uint32Array { + const nextLength = Math.max(tokenCount + 1, histogram.length * 2, 8); + const nextHistogram = new Uint32Array(nextLength); + nextHistogram.set(histogram); + return nextHistogram; +} + +function toSparseBins( + histogram: Uint32Array, +): PlaceTokenCountDistributionBin[] { + const bins: PlaceTokenCountDistributionBin[] = []; + + for (let tokenCount = 0; tokenCount < histogram.length; tokenCount++) { + const frequency = histogram[tokenCount] ?? 0; + if (frequency > 0) { + bins.push([tokenCount, frequency]); + } + } + + return bins; +} + +/** + * Creates an active-only streaming distribution metric for place token counts. + * + * Each observed frame stores one exact integer histogram per place. Completed + * and errored runs do not contribute to the frame sample set. + */ +export function createPlaceTokenCountDistributionMetric(): PlaceTokenCountDistributionMetric { + const frames: PlaceTokenCountDistributionFrame[] = []; + + return { + get frames() { + return frames; + }, + getLatestFrame: () => frames.at(-1) ?? null, + clear: () => { + frames.length = 0; + }, + observeFrame: (context) => { + const histograms: Uint32Array[] = context.placeIds.map( + () => new Uint32Array(1), + ); + + context.forEachActiveRunPlaceCounts((_runIndex, placeCounts) => { + for ( + let placeIndex = 0; + placeIndex < context.placeIds.length; + placeIndex++ + ) { + const tokenCount = placeCounts[placeIndex] ?? 0; + let histogram = histograms[placeIndex]!; + if (tokenCount >= histogram.length) { + histogram = growHistogram(histogram, tokenCount); + histograms[placeIndex] = histogram; + } + + histogram[tokenCount] = (histogram[tokenCount] ?? 0) + 1; + } + }); + + frames.push({ + frameNumber: context.frameNumber, + time: context.time, + runCount: context.runCount, + activeRunCount: context.activeRunCount, + completedRunCount: context.completedRunCount, + erroredRunCount: context.erroredRunCount, + places: context.placeIds.map((placeId, placeIndex) => ({ + placeId, + placeName: context.placeNames[placeIndex] ?? placeId, + sampleCount: context.activeRunCount, + bins: toSparseBins(histograms[placeIndex]!), + })), + }); + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/types.ts new file mode 100644 index 00000000000..252c835f651 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/metrics/types.ts @@ -0,0 +1,55 @@ +export type MonteCarloActiveRunPlaceCountsVisitor = ( + runIndex: number, + /** + * Dense place-count view indexed by `placeIds`. + * + * Metric implementations must treat this as read-only. + */ + placeCounts: Uint32Array, +) => void; + +export type MonteCarloFrameMetricContext = { + frameNumber: number; + time: number; + runCount: number; + activeRunCount: number; + completedRunCount: number; + erroredRunCount: number; + placeIds: readonly string[]; + placeNames: readonly string[]; + forEachActiveRunPlaceCounts: ( + visitor: MonteCarloActiveRunPlaceCountsVisitor, + ) => void; +}; + +export type MonteCarloFrameMetric = { + observeFrame: (context: MonteCarloFrameMetricContext) => void; +}; + +export type PlaceTokenCountDistributionBin = readonly [ + tokenCount: number, + frequency: number, +]; + +export type PlaceTokenCountDistributionPlace = { + placeId: string; + placeName: string; + sampleCount: number; + bins: readonly PlaceTokenCountDistributionBin[]; +}; + +export type PlaceTokenCountDistributionFrame = { + frameNumber: number; + time: number; + runCount: number; + activeRunCount: number; + completedRunCount: number; + erroredRunCount: number; + places: readonly PlaceTokenCountDistributionPlace[]; +}; + +export type PlaceTokenCountDistributionMetric = MonteCarloFrameMetric & { + readonly frames: readonly PlaceTokenCountDistributionFrame[]; + getLatestFrame: () => PlaceTokenCountDistributionFrame | null; + clear: () => void; +}; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/monte-carlo-simulator.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/monte-carlo-simulator.test.ts new file mode 100644 index 00000000000..98aa5475793 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/monte-carlo-simulator.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +import type { SDCPN } from "../../types/sdcpn"; +import { createMonteCarloSimulator } from "./monte-carlo-simulator"; +import { createPlaceTokenCountDistributionMetric } from "./metrics"; +import type { PlaceTokenCountDistributionFrame } from "./metrics"; + +const sdcpn: SDCPN = { + types: [ + { + id: "type-product", + name: "Product", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [{ elementId: "quality", name: "quality", type: "real" }], + }, + ], + places: [ + { + id: "source", + name: "Source", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "product", + name: "Product", + colorId: "type-product", + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "make-product", + name: "Make Product", + inputArcs: [{ placeId: "source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "product", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: + "export default TransitionKernel(() => ({ Product: [{ quality: 1 }] }));", + x: 50, + y: 0, + }, + ], + differentialEquations: [], + parameters: [ + { + id: "param-quality", + name: "Quality", + variableName: "quality", + type: "real", + defaultValue: "1", + }, + ], +}; + +const selfLoopSdcpn: SDCPN = { + types: [], + places: [ + { + id: "source", + name: "Source", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "loop", + name: "Loop", + inputArcs: [{ placeId: "source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "source", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "export default TransitionKernel(() => ({}));", + x: 50, + y: 0, + }, + ], + differentialEquations: [], + parameters: [], +}; + +function getPlaceDistributionFrame( + frame: PlaceTokenCountDistributionFrame, + placeId: string, +) { + const place = frame.places.find((entry) => entry.placeId === placeId); + if (!place) { + throw new Error(`Expected distribution for place ${placeId}`); + } + + return place; +} + +describe("MonteCarloSimulator", () => { + it("runs multiple independent simulations without retaining frame history", () => { + const simulator = createMonteCarloSimulator({ + sdcpn, + runCount: 2, + initialMarking: { source: 1 }, + runs: [ + { seed: 10, initialMarking: { source: 1 } }, + { seed: 20, initialMarking: { source: 2 } }, + ], + dt: 1, + maxTime: 20, + initialTokenValueCapacity: 0, + }); + + const result = simulator.runUntilComplete({ maxBatches: 20 }); + + expect(result.allFinished).toBe(true); + expect(result.completedRuns).toBe(2); + expect(result.erroredRuns).toBe(0); + + const firstRun = simulator.getRunSnapshot(0); + const secondRun = simulator.getRunSnapshot(1); + + expect(firstRun.status).toBe("complete"); + expect(firstRun.completionReason).toBe("deadlock"); + expect(firstRun.seed).toBe(10); + expect(firstRun.placeTokenCounts).toMatchObject({ + source: 0, + product: 1, + }); + expect(firstRun.tokenValueCount).toBe(1); + expect(firstRun.tokenValueCapacity).toBeGreaterThan( + firstRun.tokenValueCount, + ); + expect(firstRun.reallocations).toBeGreaterThan(0); + + expect(secondRun.status).toBe("complete"); + expect(secondRun.completionReason).toBe("deadlock"); + expect(secondRun.seed).toBe(20); + expect(secondRun.placeTokenCounts).toMatchObject({ + source: 0, + product: 2, + }); + expect(secondRun.tokenValueCount).toBe(2); + }); + + it("advances active runs in deterministic round-robin batches", () => { + const simulator = createMonteCarloSimulator({ + sdcpn, + runCount: 3, + initialMarking: { source: 1 }, + seed: 100, + dt: 1, + maxTime: 10, + }); + + const result = simulator.advanceAll(); + + expect(result.advancedRuns).toBe(3); + expect(result.activeRuns).toBe(3); + expect(simulator.getSummaries().map((run) => run.frameNumber)).toEqual([ + 1, 1, 1, + ]); + }); + + it("streams active-only place token count distributions", () => { + const distributionMetric = createPlaceTokenCountDistributionMetric(); + const simulator = createMonteCarloSimulator({ + sdcpn, + runCount: 2, + initialMarking: { source: 1 }, + runs: [ + { seed: 10, initialMarking: { source: 1 } }, + { seed: 20, initialMarking: { source: 2 } }, + ], + dt: 1, + maxTime: 20, + metrics: [distributionMetric], + }); + + expect(distributionMetric.frames).toHaveLength(1); + expect(distributionMetric.frames[0]).toMatchObject({ + frameNumber: 0, + time: 0, + activeRunCount: 2, + completedRunCount: 0, + erroredRunCount: 0, + }); + expect( + getPlaceDistributionFrame(distributionMetric.frames[0]!, "source").bins, + ).toEqual([ + [1, 1], + [2, 1], + ]); + expect( + getPlaceDistributionFrame(distributionMetric.frames[0]!, "product").bins, + ).toEqual([[0, 2]]); + + simulator.advanceAll(); + expect(distributionMetric.frames).toHaveLength(2); + expect( + getPlaceDistributionFrame(distributionMetric.frames[1]!, "source").bins, + ).toEqual([ + [0, 1], + [1, 1], + ]); + expect( + getPlaceDistributionFrame(distributionMetric.frames[1]!, "product").bins, + ).toEqual([[1, 2]]); + + simulator.advanceAll(); + expect(distributionMetric.frames).toHaveLength(3); + expect(distributionMetric.frames[2]).toMatchObject({ + frameNumber: 2, + time: 2, + activeRunCount: 1, + completedRunCount: 1, + erroredRunCount: 0, + }); + expect( + getPlaceDistributionFrame(distributionMetric.frames[2]!, "source").bins, + ).toEqual([[0, 1]]); + expect( + getPlaceDistributionFrame(distributionMetric.frames[2]!, "product").bins, + ).toEqual([[2, 1]]); + }); + + it("derives completion and metric time from frame numbers", () => { + const distributionMetric = createPlaceTokenCountDistributionMetric(); + const simulator = createMonteCarloSimulator({ + sdcpn: selfLoopSdcpn, + runCount: 1, + initialMarking: { source: 1 }, + seed: 100, + dt: 0.1, + maxTime: 1, + metrics: [distributionMetric], + }); + + const result = simulator.runUntilComplete(); + const summary = simulator.getRunSummary(0); + + expect(result.allFinished).toBe(true); + expect(summary.status).toBe("complete"); + expect(summary.completionReason).toBe("maxTime"); + expect(summary.frameNumber).toBe(10); + expect(summary.currentTime).toBe(1); + expect(distributionMetric.frames).toHaveLength(11); + expect(distributionMetric.frames.at(-1)).toMatchObject({ + frameNumber: 10, + time: 1, + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/monte-carlo-simulator.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/monte-carlo-simulator.ts new file mode 100644 index 00000000000..621940a7005 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/monte-carlo-simulator.ts @@ -0,0 +1,197 @@ +import { advanceRun } from "./advance-run"; +import type { MonteCarloRunState } from "./internal-types"; +import { + createRunState, + getRunSnapshot, + summarizeRun, + summarizeRuns, +} from "./run-state"; +import { getFrameTime } from "./time"; +import type { + MonteCarloAdvanceResult, + MonteCarloRunSnapshot, + MonteCarloRunSummary, + MonteCarloRunUntilCompleteOptions, + MonteCarloSimulator, + MonteCarloSimulatorConfig, +} from "./types"; +import type { MonteCarloFrameMetric } from "./metrics/types"; + +/** + * Coordinates a fixed set of independent Monte Carlo runs. + * + * The implementation keeps one mutable run state per run and advances them in + * batches. It exposes only summaries/snapshots so frame buffer representation + * stays internal to the core simulation layer. + */ +class MonteCarloSimulatorImpl implements MonteCarloSimulator { + readonly #runs: MonteCarloRunState[]; + readonly #metrics: readonly MonteCarloFrameMetric[]; + readonly #placeIds: readonly string[]; + readonly #placeNames: readonly string[]; + #frameNumber = 0; + + /** + * Validates simulator-level configuration and creates all run states. + */ + constructor(config: MonteCarloSimulatorConfig) { + if (!Number.isInteger(config.runCount) || config.runCount <= 0) { + throw new Error( + "MonteCarloSimulator requires a positive integer runCount", + ); + } + if (!Number.isFinite(config.dt) || config.dt <= 0) { + throw new Error("MonteCarloSimulator requires a positive dt"); + } + if (!Number.isFinite(config.maxTime) || config.maxTime < 0) { + throw new Error("MonteCarloSimulator requires a finite maxTime >= 0"); + } + if (config.runs && config.runs.length > config.runCount) { + throw new Error( + "MonteCarloSimulator received more run configs than runCount", + ); + } + + this.#runs = Array.from({ length: config.runCount }, (_, index) => + createRunState(config, config.runs?.[index], index), + ); + const firstRun = this.#runs[0]!; + this.#metrics = config.metrics ?? []; + this.#placeIds = firstRun.simulation.frameLayout.placeIds; + this.#placeNames = this.#placeIds.map( + (placeId) => firstRun.simulation.places.get(placeId)?.name ?? placeId, + ); + this.observeMetricFrame(); + } + + /** + * Returns the number of configured Monte Carlo runs. + */ + get runCount(): number { + return this.#runs.length; + } + + /** + * Advances every active run by at most one frame. + * + * Complete and errored runs are skipped, so repeated calls can drive the + * simulator until all runs finish without reallocating orchestration state. + */ + advanceAll(): MonteCarloAdvanceResult { + let advancedRuns = 0; + for (const run of this.#runs) { + if (advanceRun(run)) { + advancedRuns++; + } + } + + if (advancedRuns > 0) { + this.#frameNumber++; + this.observeMetricFrame(); + } + + return summarizeRuns(this.#runs, advancedRuns); + } + + /** + * Advances batches until all runs finish or the optional batch cap is hit. + */ + runUntilComplete( + options: MonteCarloRunUntilCompleteOptions = {}, + ): MonteCarloAdvanceResult { + const maxBatches = + options.maxBatches ?? Math.max(1, this.#runs[0]!.maxFrameNumber + 1); + let result = summarizeRuns(this.#runs, 0); + + for (let batch = 0; batch < maxBatches && !result.allFinished; batch++) { + result = this.advanceAll(); + } + + return result; + } + + /** + * Returns a stable summary for one run without exposing its frame buffer. + */ + getRunSummary(index: number): MonteCarloRunSummary { + return summarizeRun(this.getRun(index)); + } + + /** + * Returns a run summary plus current place token counts. + */ + getRunSnapshot(index: number): MonteCarloRunSnapshot { + return getRunSnapshot(this.getRun(index)); + } + + /** + * Returns stable summaries for all runs. + */ + getSummaries(): MonteCarloRunSummary[] { + return this.#runs.map((run) => summarizeRun(run)); + } + + /** + * Streams the current frame to configured metrics. + */ + private observeMetricFrame(): void { + if (this.#metrics.length === 0) { + return; + } + + let activeRunCount = 0; + let completedRunCount = 0; + let erroredRunCount = 0; + + for (const run of this.#runs) { + if (run.status === "complete") { + completedRunCount++; + } else if (run.status === "error") { + erroredRunCount++; + } else { + activeRunCount++; + } + } + + for (const metric of this.#metrics) { + metric.observeFrame({ + frameNumber: this.#frameNumber, + time: getFrameTime(this.#frameNumber, this.#runs[0]!.simulation.dt), + runCount: this.#runs.length, + activeRunCount, + completedRunCount, + erroredRunCount, + placeIds: this.#placeIds, + placeNames: this.#placeNames, + forEachActiveRunPlaceCounts: (visitor) => { + for (const run of this.#runs) { + if (run.status !== "complete" && run.status !== "error") { + visitor(run.index, run.currentFrame.placeCounts); + } + } + }, + }); + } + } + + /** + * Looks up an internal run state and validates the requested index. + */ + private getRun(index: number): MonteCarloRunState { + const run = this.#runs[index]; + if (!run) { + throw new Error(`Monte Carlo run ${index} does not exist`); + } + + return run; + } +} + +/** + * Creates a Monte Carlo simulator from an SDCPN and run configuration. + */ +export function createMonteCarloSimulator( + config: MonteCarloSimulatorConfig, +): MonteCarloSimulator { + return new MonteCarloSimulatorImpl(config); +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/run-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/run-state.ts new file mode 100644 index 00000000000..4c7248f9d2b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/run-state.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-param-reassign -- Monte Carlo run state owns mutable frame buffers. */ +import { buildSimulation } from "../engine/build-simulation"; +import { readEngineFrame } from "../frames/internal-frame"; +import { + cloneMonteCarloFrameBuffer, + copyEngineFrameViewToMonteCarloFrameBuffer, + createMonteCarloFrameBuffer, + type MonteCarloFrameBuffer, +} from "./frame-buffer"; +import type { MonteCarloRunState } from "./internal-types"; +import { getFrameTime, getMaxFrameNumber } from "./time"; +import type { + MonteCarloAdvanceResult, + MonteCarloRunConfig, + MonteCarloRunSnapshot, + MonteCarloRunSummary, + MonteCarloSimulatorConfig, +} from "./types"; + +/** + * Derives a deterministic seed for a run when the caller did not provide an + * explicit per-run seed. + * + * This keeps runs reproducible while avoiding identical RNG streams across the + * default run set. + */ +function deriveRunSeed(baseSeed: number, runIndex: number): number { + return ( + Math.abs(Math.trunc(baseSeed + (runIndex + 1) * 2_654_435_761)) % + 2_147_483_648 + ); +} + +/** + * Ensures a frame has enough Float64 token value capacity. + * + * If the frame is too small, this allocates a replacement with 2x growth, + * copies existing state, rewires the owning run's current/next frame pointer, + * and records the reallocation. + */ +export function ensureFrameCapacity( + run: MonteCarloRunState, + frame: MonteCarloFrameBuffer, + requiredTokenValueCount: number, +): MonteCarloFrameBuffer { + if (frame.tokenValueCapacity >= requiredTokenValueCount) { + return frame; + } + + const nextCapacity = Math.max( + requiredTokenValueCount, + frame.tokenValueCapacity * 2, + 8, + ); + const resizedFrame = cloneMonteCarloFrameBuffer( + run.simulation.frameLayout, + frame, + nextCapacity, + ); + + if (run.currentFrame === frame) { + run.currentFrame = resizedFrame; + } else if (run.nextFrame === frame) { + run.nextFrame = resizedFrame; + } + + run.reallocations++; + return resizedFrame; +} + +/** + * Builds one independent Monte Carlo run. + * + * Each run gets its own compiled simulation instance, RNG state, current frame, + * and next frame. The engine's retained frame history is cleared after copying + * the initial frame into the reusable Monte Carlo buffer format. + */ +export function createRunState( + config: MonteCarloSimulatorConfig, + runConfig: MonteCarloRunConfig | undefined, + index: number, +): MonteCarloRunState { + const seed = runConfig?.seed ?? deriveRunSeed(config.seed ?? 1, index); + const initialMarking = runConfig?.initialMarking ?? config.initialMarking; + const inputParameterValues = { + ...config.parameterValues, + ...runConfig?.parameterValues, + }; + const simulation = buildSimulation({ + sdcpn: config.sdcpn, + initialMarking, + parameterValues: inputParameterValues, + seed, + dt: config.dt, + maxTime: config.maxTime, + }); + const initialFrame = simulation.frames[0]; + if (!initialFrame) { + throw new Error("Monte Carlo simulation initialization produced no frame"); + } + + const initialView = readEngineFrame(simulation.frameLayout, initialFrame); + const initialTokenValueCount = initialView.tokenValues.length; + const initialCapacity = Math.max( + config.initialTokenValueCapacity ?? initialTokenValueCount, + initialTokenValueCount, + ); + const currentFrame = createMonteCarloFrameBuffer( + simulation.frameLayout, + initialCapacity, + ); + copyEngineFrameViewToMonteCarloFrameBuffer( + simulation.frameLayout, + initialView, + currentFrame, + simulation.dt, + ); + + return { + index, + status: "ready", + seed, + simulation: { + ...simulation, + frames: [], + currentFrameNumber: 0, + }, + currentFrame, + nextFrame: createMonteCarloFrameBuffer( + simulation.frameLayout, + initialCapacity, + ), + initialMarking, + parameterValues: simulation.parameterValues, + frameNumber: 0, + maxFrameNumber: getMaxFrameNumber(config.maxTime, config.dt), + rngState: seed, + completionReason: null, + error: null, + reallocations: 0, + }; +} + +/** + * Creates a lightweight summary of one run without exposing its frame buffer. + * + * This is the stable public read model for progress, completion, capacity, and + * error reporting. + */ +export function summarizeRun(run: MonteCarloRunState): MonteCarloRunSummary { + return { + index: run.index, + status: run.status, + seed: run.seed, + frameNumber: run.frameNumber, + currentTime: getFrameTime(run.frameNumber, run.simulation.dt), + rngState: run.rngState, + parameterValues: run.parameterValues, + completionReason: run.completionReason, + error: run.error, + tokenValueCount: run.currentFrame.tokenValueCount, + tokenValueCapacity: run.currentFrame.tokenValueCapacity, + reallocations: run.reallocations, + }; +} + +/** + * Aggregates run statuses after advancing a batch of runs. + * + * `advancedRuns` is supplied by the caller because only the stepping loop knows + * how many runs actually produced a new frame in that batch. + */ +export function summarizeRuns( + simulationRuns: readonly MonteCarloRunState[], + advancedRuns: number, +): MonteCarloAdvanceResult { + let completedRuns = 0; + let erroredRuns = 0; + let activeRuns = 0; + + for (const run of simulationRuns) { + if (run.status === "complete") { + completedRuns++; + } else if (run.status === "error") { + erroredRuns++; + } else { + activeRuns++; + } + } + + return { + advancedRuns, + completedRuns, + erroredRuns, + activeRuns, + allFinished: activeRuns === 0, + }; +} + +/** + * Creates an inspection snapshot for one run. + * + * The snapshot exposes place token counts but still keeps the underlying + * current frame buffer private to the core simulation layer. + */ +export function getRunSnapshot(run: MonteCarloRunState): MonteCarloRunSnapshot { + const placeTokenCounts: Record = {}; + for ( + let placeIndex = 0; + placeIndex < run.simulation.frameLayout.placeIds.length; + placeIndex++ + ) { + placeTokenCounts[run.simulation.frameLayout.placeIds[placeIndex]!] = + run.currentFrame.placeCounts[placeIndex] ?? 0; + } + + return { + ...summarizeRun(run), + placeTokenCounts, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/runtime/experiment.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/runtime/experiment.test.ts new file mode 100644 index 00000000000..1a1b030356a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/runtime/experiment.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SimulationTransport } from "../../api"; +import type { SDCPN } from "../../../types/sdcpn"; +import type { PlaceTokenCountDistributionFrame } from "../metrics"; +import type { + MonteCarloToMainMessage, + MonteCarloToWorkerMessage, + MonteCarloWorkerProgress, +} from "../worker/messages"; +import { createMonteCarloExperiment } from "./experiment"; + +const empty = (): SDCPN => ({ + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}); + +function makeProgress( + overrides: Partial = {}, +): MonteCarloWorkerProgress { + return { + activeRuns: 1, + advancedRuns: 1, + allFinished: false, + completedRuns: 0, + erroredRuns: 0, + frameNumber: 1, + runCount: 1, + time: 1, + ...overrides, + }; +} + +function makeDistributionFrame( + frameNumber: number, +): PlaceTokenCountDistributionFrame { + return { + frameNumber, + time: frameNumber, + runCount: 1, + activeRunCount: 1, + completedRunCount: 0, + erroredRunCount: 0, + places: [ + { + placeId: "place-a", + placeName: "Place A", + sampleCount: 1, + bins: [[frameNumber, 1]], + }, + ], + }; +} + +function makeMockTransport() { + const sent: MonteCarloToWorkerMessage[] = []; + const listeners = new Set<(message: unknown) => void>(); + let terminated = false; + + const transport: SimulationTransport = { + send(message) { + sent.push(message as MonteCarloToWorkerMessage); + }, + onMessage(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + terminate() { + terminated = true; + listeners.clear(); + }, + }; + + return { + transport, + sent, + isTerminated: () => terminated, + simulate(message: MonteCarloToMainMessage) { + for (const listener of listeners) { + listener(message); + } + }, + }; +} + +function createExperimentWithMockTransport(mock: { + transport: SimulationTransport; +}) { + return createMonteCarloExperiment({ + transport: mock.transport, + sdcpn: empty(), + initialMarking: {}, + parameterValues: {}, + seed: 1, + dt: 1, + maxTime: 10, + runCount: 1, + }); +} + +describe("createMonteCarloExperiment", () => { + it("sends init and resolves when the worker reports ready", async () => { + const mock = makeMockTransport(); + const promise = createExperimentWithMockTransport(mock); + + expect(mock.sent[0]).toMatchObject({ + type: "init", + seed: 1, + dt: 1, + maxTime: 10, + runCount: 1, + }); + + mock.simulate({ type: "ready" }); + + const experiment = await promise; + expect(experiment.status.get()).toBe("Ready"); + + experiment.dispose(); + }); + + it("updates progress, appends distribution frames, and emits completion", async () => { + const mock = makeMockTransport(); + const promise = createExperimentWithMockTransport(mock); + mock.simulate({ type: "ready" }); + const experiment = await promise; + + const statusUpdates: string[] = []; + const events = vi.fn(); + experiment.status.subscribe((status) => statusUpdates.push(status)); + experiment.events.subscribe(events); + + const firstFrame = makeDistributionFrame(1); + const secondFrame = makeDistributionFrame(2); + mock.simulate({ + type: "distributionFrames", + frames: [firstFrame, secondFrame], + }); + mock.simulate({ type: "progress", progress: makeProgress() }); + + expect(experiment.distributions.get()).toEqual({ + frames: [firstFrame, secondFrame], + latest: secondFrame, + }); + expect(experiment.progress.get()).toMatchObject({ + frameNumber: 1, + time: 1, + }); + + const completeProgress = makeProgress({ + activeRuns: 0, + allFinished: true, + completedRuns: 1, + frameNumber: 10, + time: 10, + }); + mock.simulate({ type: "complete", progress: completeProgress }); + + expect(experiment.status.get()).toBe("Complete"); + expect(statusUpdates).toContain("Complete"); + expect(events).toHaveBeenCalledWith({ + type: "complete", + progress: completeProgress, + }); + + experiment.dispose(); + }); + + it("forwards start and cancel messages over the transport", async () => { + const mock = makeMockTransport(); + const promise = createExperimentWithMockTransport(mock); + mock.simulate({ type: "ready" }); + const experiment = await promise; + + experiment.start(); + experiment.cancel(); + + expect(mock.sent.map((message) => message.type)).toEqual([ + "init", + "start", + "cancel", + ]); + + experiment.dispose(); + }); + + it("emits cancelled and tears down idempotently", async () => { + const mock = makeMockTransport(); + const promise = createExperimentWithMockTransport(mock); + mock.simulate({ type: "ready" }); + const experiment = await promise; + + const events = vi.fn(); + experiment.events.subscribe(events); + const progress = makeProgress({ advancedRuns: 0 }); + + mock.simulate({ type: "cancelled", progress }); + expect(experiment.status.get()).toBe("Cancelled"); + expect(events).toHaveBeenCalledWith({ type: "cancelled", progress }); + + experiment.dispose(); + experiment.dispose(); + expect(mock.isTerminated()).toBe(true); + }); + + it("rejects when the worker reports an initialization error", async () => { + const mock = makeMockTransport(); + const promise = createExperimentWithMockTransport(mock); + + mock.simulate({ type: "error", message: "boom", itemId: "transition-a" }); + + await expect(promise).rejects.toThrow("boom"); + expect(mock.isTerminated()).toBe(true); + }); + + it("emits errors reported after initialization", async () => { + const mock = makeMockTransport(); + const promise = createExperimentWithMockTransport(mock); + mock.simulate({ type: "ready" }); + const experiment = await promise; + + const events = vi.fn(); + experiment.events.subscribe(events); + + mock.simulate({ type: "error", message: "late boom", itemId: null }); + + expect(experiment.status.get()).toBe("Error"); + expect(events).toHaveBeenCalledWith({ + type: "error", + message: "late boom", + itemId: null, + }); + + experiment.dispose(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/runtime/experiment.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/runtime/experiment.ts new file mode 100644 index 00000000000..46653df66bd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/runtime/experiment.ts @@ -0,0 +1,268 @@ +import type { ReadableStore } from "../../../handle"; +import type { EventStream } from "../../../instance"; +import type { + InitialMarking, + SimulationTransport, + WorkerFactory, +} from "../../api"; +import { createWorkerTransport } from "../../runtime/transport"; +import type { SDCPN } from "../../../types/sdcpn"; +import type { PlaceTokenCountDistributionFrame } from "../metrics"; +import type { + MonteCarloToMainMessage, + MonteCarloWorkerProgress, +} from "../worker/messages"; + +export type MonteCarloExperimentState = + | "Initializing" + | "Ready" + | "Running" + | "Complete" + | "Error" + | "Cancelled"; + +export type MonteCarloExperimentDistributions = { + frames: readonly PlaceTokenCountDistributionFrame[]; + latest: PlaceTokenCountDistributionFrame | null; +}; + +export type MonteCarloExperimentEvent = + | { type: "complete"; progress: MonteCarloWorkerProgress } + | { type: "cancelled"; progress: MonteCarloWorkerProgress | null } + | { type: "error"; message: string; itemId: string | null }; + +export type CreateMonteCarloExperimentConfig = { + sdcpn: SDCPN; + initialMarking: InitialMarking; + parameterValues: Record; + seed: number; + dt: number; + maxTime: number; + runCount: number; + batchSize?: number; + signal?: AbortSignal; +} & ( + | { createWorker: WorkerFactory; transport?: never } + | { transport: SimulationTransport; createWorker?: never } +); + +export interface MonteCarloExperiment { + readonly status: ReadableStore; + readonly progress: ReadableStore; + readonly distributions: ReadableStore; + readonly events: EventStream; + + start(this: void): void; + cancel(this: void): void; + dispose(this: void): void; +} + +function createReadableStore(initial: T): ReadableStore & { + set(next: T): void; +} { + let current = initial; + const listeners = new Set<(value: T) => void>(); + + return { + get: () => current, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set(next) { + if (Object.is(next, current)) { + return; + } + current = next; + for (const listener of listeners) { + listener(current); + } + }, + }; +} + +function createEventStream(): EventStream & { emit(event: T): void } { + const listeners = new Set<(event: T) => void>(); + + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit(event) { + for (const listener of listeners) { + listener(event); + } + }, + }; +} + +export function createMonteCarloExperiment( + config: CreateMonteCarloExperimentConfig, +): Promise { + const transport = + "transport" in config && config.transport !== undefined + ? config.transport + : createWorkerTransport(config.createWorker); + const status = createReadableStore("Initializing"); + const progress = createReadableStore(null); + const distributions = createReadableStore({ + frames: [], + latest: null, + }); + const events = createEventStream(); + let disposed = false; + + return new Promise((resolve, reject) => { + let settled = false; + let off: (() => void) | null = null; + let abortListener: (() => void) | null = null; + + const cleanupTransport = ({ sendCancel }: { sendCancel: boolean }) => { + if (disposed) { + return; + } + + disposed = true; + if (abortListener) { + config.signal?.removeEventListener("abort", abortListener); + abortListener = null; + } + off?.(); + off = null; + + if (sendCancel) { + try { + transport.send({ type: "cancel" }); + } catch { + // Transport may already be torn down. + } + } + + transport.terminate(); + }; + + const rejectBeforeReady = (error: Error) => { + if (settled) { + return; + } + + settled = true; + cleanupTransport({ sendCancel: false }); + reject(error); + }; + + const onAbort = () => { + if (!settled) { + settled = true; + reject( + new DOMException( + "Monte Carlo experiment start aborted", + "AbortError", + ), + ); + } + cleanupTransport({ sendCancel: true }); + }; + + abortListener = onAbort; + + const handle: MonteCarloExperiment = { + status, + progress, + distributions, + events, + start() { + if (disposed) { + return; + } + status.set("Running"); + transport.send({ type: "start" }); + }, + cancel() { + if (disposed) { + return; + } + transport.send({ type: "cancel" }); + }, + dispose() { + cleanupTransport({ sendCancel: true }); + }, + }; + + off = transport.onMessage((rawMessage) => { + const message = rawMessage as MonteCarloToMainMessage; + + switch (message.type) { + case "ready": { + status.set("Ready"); + if (!settled) { + settled = true; + resolve(handle); + } + break; + } + case "distributionFrames": { + const frames = [...distributions.get().frames, ...message.frames]; + distributions.set({ + frames, + latest: frames.at(-1) ?? null, + }); + break; + } + case "progress": + progress.set(message.progress); + break; + case "complete": + progress.set(message.progress); + status.set("Complete"); + events.emit({ type: "complete", progress: message.progress }); + break; + case "cancelled": + progress.set(message.progress); + status.set("Cancelled"); + events.emit({ type: "cancelled", progress: message.progress }); + break; + case "error": + status.set("Error"); + events.emit({ + type: "error", + message: message.message, + itemId: message.itemId, + }); + if (!settled) { + rejectBeforeReady(new Error(message.message)); + } + break; + } + }); + + if (config.signal) { + if (config.signal.aborted) { + onAbort(); + return; + } + config.signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + transport.send({ + type: "init", + sdcpn: config.sdcpn, + initialMarking: config.initialMarking, + parameterValues: config.parameterValues, + seed: config.seed, + dt: config.dt, + maxTime: config.maxTime, + runCount: config.runCount, + batchSize: config.batchSize, + }); + } catch (error) { + rejectBeforeReady( + error instanceof Error + ? error + : new Error("Failed to initialize Monte Carlo experiment"), + ); + } + }); +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/time.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/time.ts new file mode 100644 index 00000000000..98c7e7dbc32 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/time.ts @@ -0,0 +1,26 @@ +/** + * Derives simulation time from frame counts instead of incrementally adding + * `dt`, which keeps long Monte Carlo runs from accumulating floating-point + * rounding drift. + */ +export function getFrameTime(frameNumber: number, dt: number): number { + return frameNumber * dt; +} + +/** + * Computes the first frame number at or beyond maxTime. + * + * If maxTime is an integer multiple of dt, small binary floating-point division + * errors should not push the limit one frame later. + */ +export function getMaxFrameNumber(maxTime: number, dt: number): number { + const frameCount = maxTime / dt; + const roundedFrameCount = Math.round(frameCount); + const tolerance = Number.EPSILON * Math.max(1, Math.abs(frameCount)) * 16; + + if (Math.abs(frameCount - roundedFrameCount) <= tolerance) { + return roundedFrameCount; + } + + return Math.ceil(frameCount); +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/transition-effect.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/transition-effect.ts new file mode 100644 index 00000000000..64e5b19a889 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/transition-effect.ts @@ -0,0 +1,198 @@ +import { SDCPNItemError } from "../../errors"; +import { isDistribution } from "../authoring/user-code/distribution"; +import { enumerateWeightedMarkingIndicesGenerator } from "../engine/enumerate-weighted-markings"; +import { sampleDistribution } from "../engine/sample-distribution"; +import { nextRandom } from "../engine/seeded-rng"; +import type { + CompiledTransition, + TransitionTokenValues, +} from "../engine/types"; +import type { MonteCarloFrameBuffer } from "./frame-buffer"; +import type { + MonteCarloRunState, + PlaceID, + TransitionEffect, +} from "./internal-types"; +import { getPlaceIndex, getTransitionIndex } from "./layout"; + +/** + * Computes the effect of one transition against a candidate frame. + * + * The function checks structural enablement, samples the transition firing + * probability from the run RNG state, evaluates user-authored lambda/kernel + * functions, samples distribution-valued outputs, and returns the token + * removals/additions that the caller should apply to the frame. + */ +export function computeTransitionEffect( + run: MonteCarloRunState, + frame: MonteCarloFrameBuffer, + transition: CompiledTransition, +): TransitionEffect | null { + const { frameLayout } = run.simulation; + const transitionIndex = getTransitionIndex(frameLayout, transition.id); + + const inputPlaces = transition.inputPlaces.map((inputPlace) => { + const placeIndex = getPlaceIndex(frameLayout, inputPlace.placeId); + + return { + ...inputPlace, + placeIndex, + count: frame.placeCounts[placeIndex] ?? 0, + offset: frame.placeOffsets[placeIndex] ?? 0, + dimensions: frameLayout.placeDimensions[placeIndex] ?? 0, + }; + }); + + const enabled = inputPlaces.every((inputPlace) => + inputPlace.arcType === "inhibitor" + ? inputPlace.count < inputPlace.weight + : inputPlace.count >= inputPlace.weight, + ); + if (!enabled) { + return null; + } + + const [u1, candidateRngState] = nextRandom(run.rngState); + const timeSinceLastFiring = + (frame.transitionElapsedFrames[transitionIndex] ?? 0) * run.simulation.dt; + const inputPlacesWithValues = inputPlaces.filter( + (place) => place.dimensions > 0 && place.arcType !== "inhibitor", + ); + const inputPlacesWithoutValues = inputPlaces.filter( + (place) => place.dimensions === 0 && place.arcType !== "inhibitor", + ); + + const tokenCombinations = enumerateWeightedMarkingIndicesGenerator( + inputPlacesWithValues, + ); + + for (const tokenCombinationIndices of tokenCombinations) { + const tokenValues: TransitionTokenValues = {}; + + for (const [ + placeIndex, + tokenIndices, + ] of tokenCombinationIndices.entries()) { + const inputPlace = inputPlacesWithValues[placeIndex]!; + const { dimensions, offset } = inputPlace; + if (!inputPlace.elementNames) { + throw new SDCPNItemError( + `Place \`${inputPlace.placeName}\` has no type defined`, + inputPlace.placeId, + ); + } + const elementNames = inputPlace.elementNames; + + tokenValues[inputPlace.placeName] = tokenIndices.map((tokenIndex) => { + const tokenOffset = offset + tokenIndex * dimensions; + const token: Record = {}; + for (let dimension = 0; dimension < dimensions; dimension++) { + token[elementNames[dimension]!] = + frame.tokenValues[tokenOffset + dimension]!; + } + return token; + }); + } + + let lambdaResult: ReturnType; + try { + lambdaResult = transition.lambdaFn(tokenValues); + } catch (error) { + throw new SDCPNItemError( + `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ + (error as Error).message + }\n\nInput:\n${JSON.stringify(tokenValues, null, 2)}`, + transition.id, + ); + } + + const lambdaNumeric = + typeof lambdaResult === "boolean" + ? lambdaResult + ? Number.POSITIVE_INFINITY + : 0 + : lambdaResult; + const lambdaValue = lambdaNumeric * timeSinceLastFiring; + if (Math.exp(-lambdaValue) > u1) { + continue; + } + + let kernelOutput: ReturnType; + try { + kernelOutput = transition.transitionKernelFn(tokenValues); + } catch (error) { + throw new SDCPNItemError( + `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ + (error as Error).message + }\n\nInput:\n${JSON.stringify(tokenValues, null, 2)}`, + transition.id, + ); + } + + const add: Record = {}; + let currentRngState = candidateRngState; + for (const outputPlace of transition.outputPlaces) { + const outputPlaceIndex = getPlaceIndex(frameLayout, outputPlace.placeId); + const dimensions = frameLayout.placeDimensions[outputPlaceIndex] ?? 0; + + if (!outputPlace.elementNames) { + add[outputPlace.placeId] = Array.from( + { length: outputPlace.weight }, + () => [], + ); + continue; + } + + const outputTokens = kernelOutput[outputPlace.placeName]; + if (!outputTokens) { + throw new SDCPNItemError( + `Transition kernel for transition \`${transition.name}\` did not return tokens for place "${outputPlace.placeName}"`, + transition.id, + ); + } + + const tokenArrays: number[][] = []; + for (const token of outputTokens) { + const values: number[] = []; + for (const elementName of outputPlace.elementNames) { + const rawValue = token[elementName]!; + if (isDistribution(rawValue)) { + const [sampled, nextRngState] = sampleDistribution( + rawValue, + currentRngState, + ); + currentRngState = nextRngState; + values.push(sampled); + } else { + values.push(rawValue); + } + } + + if (values.length !== dimensions) { + throw new Error( + `Transition ${transition.id} produced ${values.length} values for place ${outputPlace.placeId}, expected ${dimensions}`, + ); + } + tokenArrays.push(values); + } + add[outputPlace.placeId] = tokenArrays; + } + + const remove: TransitionEffect["remove"] = {}; + for (const inputPlace of inputPlacesWithoutValues) { + remove[inputPlace.placeId] = inputPlace.weight; + } + for (const [index, tokenIndices] of tokenCombinationIndices.entries()) { + const inputPlace = inputPlacesWithValues[index]!; + remove[inputPlace.placeId] = new Set(tokenIndices); + } + + return { + remove, + add, + newRngState: currentRngState, + }; + } + + return null; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/types.ts new file mode 100644 index 00000000000..9dd337d63d6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/types.ts @@ -0,0 +1,90 @@ +import type { InitialMarking } from "../api"; +import type { SimulationCompletionReason } from "../engine/compute-next-frame"; +import type { ParameterValues } from "../engine/types"; +import type { SDCPN } from "../../types/sdcpn"; +import type { MonteCarloFrameMetric } from "./metrics/types"; + +export type MonteCarloRunStatus = "ready" | "running" | "complete" | "error"; + +export type MonteCarloRunConfig = { + seed?: number; + parameterValues?: Record; + initialMarking?: InitialMarking; +}; + +export type MonteCarloSimulatorConfig = { + sdcpn: SDCPN; + runCount: number; + initialMarking: InitialMarking; + parameterValues?: Record; + seed?: number; + dt: number; + maxTime: number; + runs?: readonly MonteCarloRunConfig[]; + initialTokenValueCapacity?: number; + metrics?: readonly MonteCarloFrameMetric[]; +}; + +export type MonteCarloRunSummary = { + index: number; + status: MonteCarloRunStatus; + seed: number; + frameNumber: number; + currentTime: number; + rngState: number; + parameterValues: ParameterValues; + completionReason: SimulationCompletionReason | null; + error: string | null; + tokenValueCount: number; + tokenValueCapacity: number; + reallocations: number; +}; + +export type MonteCarloRunSnapshot = MonteCarloRunSummary & { + placeTokenCounts: Record; +}; + +export type MonteCarloAdvanceResult = { + advancedRuns: number; + completedRuns: number; + erroredRuns: number; + activeRuns: number; + allFinished: boolean; +}; + +export type MonteCarloRunUntilCompleteOptions = { + maxBatches?: number; +}; + +/** + * Public controller for a memory-bounded Monte Carlo simulation. + * + * Implementations should keep binary frame buffers internal and expose only + * serializable summaries or snapshots to callers. + */ +export interface MonteCarloSimulator { + readonly runCount: number; + /** + * Advance every active run by at most one simulation frame. + */ + advanceAll(): MonteCarloAdvanceResult; + /** + * Keep advancing runs until every run is complete/errored or a batch cap is + * reached. + */ + runUntilComplete( + options?: MonteCarloRunUntilCompleteOptions, + ): MonteCarloAdvanceResult; + /** + * Read progress and capacity metadata for one run. + */ + getRunSummary(index: number): MonteCarloRunSummary; + /** + * Read one run summary plus current place token counts. + */ + getRunSnapshot(index: number): MonteCarloRunSnapshot; + /** + * Read progress and capacity metadata for every run. + */ + getSummaries(): MonteCarloRunSummary[]; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/create-monte-carlo-worker.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/create-monte-carlo-worker.ts new file mode 100644 index 00000000000..4341b6c51fb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/create-monte-carlo-worker.ts @@ -0,0 +1,8 @@ +/** Dynamically import and instantiate the Monte Carlo worker. */ +export async function createMonteCarloWorker(): Promise { + const MonteCarloWorker = await import( + "./monte-carlo.worker.ts?worker&inline" + ); + // eslint-disable-next-line new-cap + return new MonteCarloWorker.default(); +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/messages.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/messages.ts new file mode 100644 index 00000000000..cb4e446c3e7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/messages.ts @@ -0,0 +1,73 @@ +import type { InitialMarking } from "../../api"; +import type { SDCPN } from "../../../types/sdcpn"; +import type { PlaceTokenCountDistributionFrame } from "../metrics"; +import type { MonteCarloAdvanceResult } from "../types"; + +export type MonteCarloInitMessage = { + type: "init"; + sdcpn: SDCPN; + initialMarking: InitialMarking; + parameterValues: Record; + seed: number; + dt: number; + maxTime: number; + runCount: number; + batchSize?: number; +}; + +export type MonteCarloStartMessage = { + type: "start"; +}; + +export type MonteCarloCancelMessage = { + type: "cancel"; +}; + +export type MonteCarloToWorkerMessage = + | MonteCarloInitMessage + | MonteCarloStartMessage + | MonteCarloCancelMessage; + +export type MonteCarloProgressMessage = { + type: "progress"; + progress: MonteCarloWorkerProgress; +}; + +export type MonteCarloDistributionFramesMessage = { + type: "distributionFrames"; + frames: PlaceTokenCountDistributionFrame[]; +}; + +export type MonteCarloReadyMessage = { + type: "ready"; +}; + +export type MonteCarloCompleteMessage = { + type: "complete"; + progress: MonteCarloWorkerProgress; +}; + +export type MonteCarloCancelledMessage = { + type: "cancelled"; + progress: MonteCarloWorkerProgress | null; +}; + +export type MonteCarloErrorMessage = { + type: "error"; + message: string; + itemId: string | null; +}; + +export type MonteCarloWorkerProgress = MonteCarloAdvanceResult & { + frameNumber: number; + time: number; + runCount: number; +}; + +export type MonteCarloToMainMessage = + | MonteCarloReadyMessage + | MonteCarloProgressMessage + | MonteCarloDistributionFramesMessage + | MonteCarloCompleteMessage + | MonteCarloCancelledMessage + | MonteCarloErrorMessage; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/monte-carlo.worker.ts b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/monte-carlo.worker.ts new file mode 100644 index 00000000000..e7b0e027bdd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/monte-carlo/worker/monte-carlo.worker.ts @@ -0,0 +1,189 @@ +import { SDCPNItemError } from "../../../errors"; +import { createMonteCarloSimulator } from "../monte-carlo-simulator"; +import { createPlaceTokenCountDistributionMetric } from "../metrics"; +import type { PlaceTokenCountDistributionMetric } from "../metrics"; +import type { MonteCarloAdvanceResult, MonteCarloSimulator } from "../types"; +import type { + MonteCarloInitMessage, + MonteCarloToMainMessage, + MonteCarloToWorkerMessage, + MonteCarloWorkerProgress, +} from "./messages"; + +const DEFAULT_BATCH_SIZE = 4; + +let simulator: MonteCarloSimulator | null = null; +let distributionMetric: PlaceTokenCountDistributionMetric | null = null; +let isRunning = false; +let isInitialized = false; +let batchSize = DEFAULT_BATCH_SIZE; +let lastSentDistributionFrameCount = 0; +let latestProgress: MonteCarloWorkerProgress | null = null; + +function postTypedMessage(message: MonteCarloToMainMessage): void { + self.postMessage(message); +} + +function progressFromResult( + result: MonteCarloAdvanceResult, +): MonteCarloWorkerProgress { + const latestFrame = distributionMetric?.getLatestFrame(); + + return { + ...result, + frameNumber: latestFrame?.frameNumber ?? 0, + time: latestFrame?.time ?? 0, + runCount: latestFrame?.runCount ?? simulator?.runCount ?? 0, + }; +} + +function initialProgress(runCount: number): MonteCarloWorkerProgress { + const latestFrame = distributionMetric?.getLatestFrame(); + + return { + activeRuns: latestFrame?.activeRunCount ?? runCount, + advancedRuns: 0, + allFinished: false, + completedRuns: latestFrame?.completedRunCount ?? 0, + erroredRuns: latestFrame?.erroredRunCount ?? 0, + frameNumber: latestFrame?.frameNumber ?? 0, + runCount, + time: latestFrame?.time ?? 0, + }; +} + +function postPendingDistributionFrames(): void { + if (!distributionMetric) { + return; + } + + const frames = distributionMetric.frames.slice( + lastSentDistributionFrameCount, + ); + lastSentDistributionFrameCount = distributionMetric.frames.length; + + if (frames.length > 0) { + postTypedMessage({ type: "distributionFrames", frames }); + } +} + +function initialize(message: MonteCarloInitMessage): void { + distributionMetric = createPlaceTokenCountDistributionMetric(); + simulator = createMonteCarloSimulator({ + sdcpn: message.sdcpn, + initialMarking: message.initialMarking, + parameterValues: message.parameterValues, + seed: message.seed, + dt: message.dt, + maxTime: message.maxTime, + runCount: message.runCount, + metrics: [distributionMetric], + }); + batchSize = message.batchSize ?? DEFAULT_BATCH_SIZE; + isInitialized = true; + isRunning = false; + lastSentDistributionFrameCount = 0; + latestProgress = initialProgress(message.runCount); + + postTypedMessage({ type: "ready" }); + postPendingDistributionFrames(); + postTypedMessage({ type: "progress", progress: latestProgress }); +} + +async function computeLoop(): Promise { + while (isRunning) { + const currentSimulator = simulator; + if (!currentSimulator) { + return; + } + + let result: MonteCarloAdvanceResult | null = null; + + for (let i = 0; i < batchSize; i++) { + result = currentSimulator.advanceAll(); + if (result.allFinished) { + break; + } + } + + if (result) { + latestProgress = progressFromResult(result); + postPendingDistributionFrames(); + postTypedMessage({ type: "progress", progress: latestProgress }); + + if (result.allFinished) { + isRunning = false; + postTypedMessage({ type: "complete", progress: latestProgress }); + return; + } + } + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case "init": { + try { + initialize(message); + } catch (error) { + isInitialized = false; + isRunning = false; + simulator = null; + distributionMetric = null; + postTypedMessage({ + type: "error", + message: + error instanceof Error + ? error.message + : "Failed to initialize Monte Carlo experiment", + itemId: error instanceof SDCPNItemError ? error.itemId : null, + }); + } + break; + } + + case "start": { + if (!isInitialized || !simulator) { + postTypedMessage({ + type: "error", + message: "Cannot start: Monte Carlo experiment is not initialized", + itemId: null, + }); + return; + } + + if (isRunning) { + return; + } + + isRunning = true; + void computeLoop().catch((error: unknown) => { + isRunning = false; + postTypedMessage({ + type: "error", + message: + error instanceof Error + ? error.message + : "Unknown error during Monte Carlo computation", + itemId: error instanceof SDCPNItemError ? error.itemId : null, + }); + }); + break; + } + + case "cancel": { + isRunning = false; + simulator = null; + distributionMetric = null; + isInitialized = false; + postTypedMessage({ type: "cancelled", progress: latestProgress }); + break; + } + } +}; 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..7e9b68f6fc6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts @@ -0,0 +1,48 @@ +import type { SDCPN } from "../../types/sdcpn"; +import type { SimulationFrameReader } from "../api"; +import { compileSimulationFrameReader } 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( + sdcpn: Pick, +): SimulationFrameStore { + const frames: SimulationFramePayload[] = []; + const createFrameReader = compileSimulationFrameReader(sdcpn); + + 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 ? createFrameReader(frame.frame, index, frame.time) : null; + }, + get(index) { + const frame = frames[index]; + return frame ? createFrameReader(frame.frame, index, frame.time) : null; + }, + }; +} 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 87% 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..e1b42395f64 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,14 @@ 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 { 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 { + createEngineFrame, + createEngineFrameLayout, +} from "../frames/internal-frame"; +import { createSimulation } from "./simulation"; const empty = (): SDCPN => ({ places: [], @@ -14,12 +18,16 @@ const empty = (): SDCPN => ({ differentialEquations: [], }); -function makeFrame(time: number): SimulationFrame { - return { - time, +function makeFrame(time: number): SimulationFramePayload { + const frame = createEngineFrame(createEngineFrameLayout(empty()), { places: {}, transitions: {}, buffer: new Float64Array(), + }); + + return { + time, + frame, }; } @@ -29,12 +37,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); @@ -64,7 +72,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -85,7 +93,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -102,7 +110,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -121,7 +129,9 @@ describe("createSimulation (transport flavour)", () => { }); expect(sim.frames.get().count).toBe(4); + expect(sim.frames.get().latest?.number).toBe(3); expect(sim.frames.get().latest?.time).toBe(0.03); + expect(sim.getFrame(2)?.number).toBe(2); expect(sim.getFrame(2)?.time).toBe(0.02); expect(seen).toEqual([1, 4]); @@ -133,7 +143,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -162,7 +172,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -187,7 +197,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -208,7 +218,7 @@ describe("createSimulation (transport flavour)", () => { const promise = createSimulation({ transport: mock.transport, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, @@ -250,7 +260,7 @@ describe("createSimulation (createWorker flavour)", () => { const promise = createSimulation({ createWorker: () => fakeWorker, sdcpn: empty(), - initialMarking: new Map(), + initialMarking: {}, parameterValues: {}, seed: 1, dt: 0.01, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts similarity index 62% rename from libs/@hashintel/petrinaut/src/core/simulation/simulation.ts rename to libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts index bf80240bccc..ef3e85dcbc5 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 type { SimulationFramePayload } from "../worker/frame-payload"; +import { createInMemorySimulationFrameStore } from "./frame-store"; function createReadableStore(initial: T): ReadableStore & { set(next: T): void; @@ -153,27 +79,26 @@ export function createSimulation( latest: null, }); const events = createEventStream(); - const frames: SimulationFrame[] = []; let disposed = false; - function pushFrames(newFrames: SimulationFrame[]): void { - if (newFrames.length === 0) { - return; - } - for (const frame of newFrames) { - frames.push(frame); - } - frameSummary.set({ - count: frames.length, - latest: frames[frames.length - 1] ?? null, - }); - } - return new Promise((resolve, reject) => { + const frameStore = createInMemorySimulationFrameStore(config.sdcpn); let settled = false; let handle: Simulation; - const off = transport.onMessage((message) => { + function pushFrames(newFrames: SimulationFramePayload[]): void { + if (newFrames.length === 0) { + return; + } + frameStore.appendBatch(newFrames); + frameSummary.set({ + count: frameStore.count(), + latest: frameStore.latest(), + }); + } + + const off = transport.onMessage((rawMessage) => { + const message = rawMessage as ToMainMessage; switch (message.type) { case "ready": { status.set("Ready"); @@ -257,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"); }, @@ -278,7 +203,7 @@ export function createSimulation( }); }, getFrame(index) { - return frames[index] ?? null; + return frameStore.get(index); }, dispose() { if (disposed) { @@ -299,7 +224,7 @@ export function createSimulation( transport.send({ type: "init", sdcpn: config.sdcpn, - initialMarking: Array.from(config.initialMarking.entries()), + initialMarking: config.initialMarking, parameterValues: config.parameterValues, seed: config.seed, dt: config.dt, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/transport.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/transport.ts similarity index 55% rename from libs/@hashintel/petrinaut/src/core/simulation/transport.ts rename to libs/@hashintel/petrinaut/src/core/simulation/runtime/transport.ts index 754c8f2fed7..dc2ab3dcc18 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/transport.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/transport.ts @@ -1,22 +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. - * - * See [05-simulation.md](../../../rfc/0001-core-react-ui-split/05-simulation.md) §5.1. - */ -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 @@ -25,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/simulator/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts deleted file mode 100644 index fa89e4a7eab..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { SDCPNItemError } from "../../errors"; -import { - deriveDefaultParameterValues, - mergeParameterValues, -} from "../../parameter-values"; -import { compileUserCode } from "./compile-user-code"; -import type { - DifferentialEquationFn, - LambdaFn, - ParameterValues, - SimulationFrame, - SimulationInput, - SimulationInstance, - TransitionKernelFn, -} from "./types"; - -/** - * Get the dimensions (number of elements) for a place based on its type. - * If the place has no type, returns 0. - */ -function getPlaceDimensions( - place: SimulationInput["sdcpn"]["places"][0], - sdcpn: SimulationInput["sdcpn"], -): number { - if (!place.colorId) { - return 0; - } - const type = sdcpn.types.find((tp) => tp.id === place.colorId); - if (!type) { - throw new Error( - `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, - ); - } - return type.elements.length; -} - -/** - * Builds a simulation instance and its initial frame from simulation input. - * - * Takes a SimulationInput containing: - * - SDCPN definition (places, transitions, and their code) - * - Initial marking (token distribution across places) - * - Random seed - * - Time step (dt) - * - * Returns a SimulationFrame with: - * - A SimulationInstance containing compiled user code functions - * - Initial token distribution in a contiguous buffer - * - All places and transitions initialized with proper state - * - * @param input - The simulation input configuration - * @returns The initial simulation frame ready for execution - * @throws {Error} if place IDs in initialMarking don't match places in SDCPN - * @throws {Error} if token dimensions don't match place dimensions - * @throws {Error} if user code fails to compile - */ -export function buildSimulation(input: SimulationInput): SimulationInstance { - const { - sdcpn, - initialMarking, - parameterValues: inputParameterValues, - seed, - dt, - maxTime, - } = input; - - // Build maps for quick lookup - const placesMap = new Map(sdcpn.places.map((place) => [place.id, place])); - const transitionsMap = new Map( - sdcpn.transitions.map((transition) => [transition.id, transition]), - ); - const typesMap = new Map(sdcpn.types.map((type) => [type.id, type])); - - // Build parameter values: merge input values with SDCPN defaults - // Input values (from simulation store) take precedence over defaults - const defaultParameterValues = deriveDefaultParameterValues(sdcpn.parameters); - const parameterValues = mergeParameterValues( - inputParameterValues, - defaultParameterValues, - ); - - // Validate that all places in initialMarking exist in SDCPN - for (const placeId of initialMarking.keys()) { - if (!placesMap.has(placeId)) { - throw new Error( - `Place with ID ${placeId} in initialMarking does not exist in SDCPN`, - ); - } - } - - // Validate token dimensions match place dimensions - for (const [placeId, marking] of initialMarking) { - const place = placesMap.get(placeId)!; - const dimensions = getPlaceDimensions(place, sdcpn); - const expectedSize = dimensions * marking.count; - if (marking.values.length !== expectedSize) { - throw new Error( - `Token dimension mismatch for place ${placeId}. Expected ${expectedSize} values (${dimensions} dimensions × ${marking.count} tokens), got ${marking.values.length}`, - ); - } - } - - // Compile all differential equation functions - const differentialEquationFns = new Map(); - for (const place of sdcpn.places) { - // Skip places without dynamics enabled or without differential equation code - if (!place.dynamicsEnabled || !place.differentialEquationId) { - continue; - } - - const differentialEquation = sdcpn.differentialEquations.find( - (de) => de.id === place.differentialEquationId, - ); - if (!differentialEquation) { - throw new Error( - `Differential equation with ID ${place.differentialEquationId} referenced by place ${place.id} does not exist in SDCPN`, - ); - } - const { code } = differentialEquation; - - try { - const fn = compileUserCode<[Record[], ParameterValues]>( - code, - "Dynamics", - ); - differentialEquationFns.set(place.id, fn as DifferentialEquationFn); - } catch (error) { - throw new SDCPNItemError( - `Failed to compile differential equation for place \`${ - place.name - }\`:\n\n${error instanceof Error ? error.message : String(error)}`, - place.id, - ); - } - } - - // Compile all lambda functions - const lambdaFns = new Map(); - for (const transition of sdcpn.transitions) { - try { - const fn = compileUserCode< - [Record[]>, ParameterValues] - >(transition.lambdaCode, "Lambda"); - lambdaFns.set(transition.id, fn as LambdaFn); - } catch (error) { - throw new SDCPNItemError( - `Failed to compile Lambda function for transition \`${ - transition.name - }\`:\n\n${error instanceof Error ? error.message : String(error)}`, - transition.id, - ); - } - } - - // Compile all transition kernel functions - const transitionKernelFns = new Map(); - for (const transition of sdcpn.transitions) { - // Skip transitions without output places that have types - // (they won't need to generate token data) - const hasTypedOutputPlace = transition.outputArcs.some((arc) => { - const place = placesMap.get(arc.placeId); - return place && place.colorId; - }); - - if (!hasTypedOutputPlace) { - // Set a dummy function that returns an empty object for transitions - // without typed output places (they don't need to generate token data) - transitionKernelFns.set( - transition.id, - (() => ({})) as TransitionKernelFn, - ); - continue; - } - - try { - const fn = compileUserCode< - [Record[]>, ParameterValues] - >(transition.transitionKernelCode, "TransitionKernel"); - transitionKernelFns.set(transition.id, fn as TransitionKernelFn); - } catch (error) { - throw new SDCPNItemError( - `Failed to compile transition kernel for transition \`${ - transition.name - }\`:\n\n${error instanceof Error ? error.message : String(error)}`, - transition.id, - ); - } - } - - // Calculate buffer size and build place states - let bufferSize = 0; - const placeStates: SimulationFrame["places"] = {}; - - // Process places in a consistent order (sorted by ID) - const sortedPlaceIds = Array.from(placesMap.keys()).sort(); - - for (const placeId of sortedPlaceIds) { - const place = placesMap.get(placeId)!; - const marking = initialMarking.get(placeId); - const count = marking?.count ?? 0; - const dimensions = getPlaceDimensions(place, sdcpn); - - if (placeId === "__proto__") { - throw new Error("Cannot add place with id '__proto__'"); - } - - placeStates[placeId] = { - offset: bufferSize, - count, - dimensions, - }; - - bufferSize += dimensions * count; - } - - // Build the initial buffer with token values - const buffer = new Float64Array(bufferSize); - let bufferIndex = 0; - - for (const placeId of sortedPlaceIds) { - const marking = initialMarking.get(placeId); - if (marking && marking.count > 0) { - for (let i = 0; i < marking.values.length; i++) { - buffer[bufferIndex++] = marking.values[i]!; - } - } - } - - // Initialize transition states - const transitionStates: SimulationFrame["transitions"] = {}; - for (const transition of sdcpn.transitions) { - if (transition.id === "__proto__") { - throw new Error("Cannot add transition with id '__proto__'"); - } - - transitionStates[transition.id] = { - instance: transition, - timeSinceLastFiringMs: 0, - firedInThisFrame: false, - firingCount: 0, - }; - } - - // Create the simulation instance (without frames initially) - const simulationInstance: SimulationInstance = { - places: placesMap, - transitions: transitionsMap, - types: typesMap, - differentialEquationFns, - lambdaFns, - transitionKernelFns, - parameterValues, - dt, - maxTime, - rngState: seed, - frames: [], // Will be populated with the initial frame - currentFrameNumber: 0, - }; - - // Create the initial frame - const initialFrame: SimulationFrame = { - time: 0, - places: placeStates, - transitions: transitionStates, - buffer, - }; - - // Add the initial frame to the simulation instance - simulationInstance.frames.push(initialFrame); - - return simulationInstance; -} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.test.ts deleted file mode 100644 index 19848eec179..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.test.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - checkTransitionEnablement, - isTransitionStructurallyEnabled, -} from "./check-transition-enablement"; -import type { SimulationFrame } from "./types"; - -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); - }); - - 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); - }); - - 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); - }); - - it("checks all input places for enablement", () => { - const frame: SimulationFrame = { - time: 0, - 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, - }, - }, - buffer: new Float64Array([]), - }; - - // p1 has tokens, but p2 doesn't - expect(isTransitionStructurallyEnabled(frame, "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); - }); - - 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); - }); - - 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); - }); - - 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); - }); - - it("checks mixed standard and inhibitor arcs together", () => { - const frame: SimulationFrame = { - time: 0, - 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, - }, - }, - buffer: new Float64Array([]), - }; - - // p1 has 2 >= 1 (standard satisfied), p2 has 0 < 1 (inhibitor satisfied) - expect(isTransitionStructurallyEnabled(frame, "t1")).toBe(true); - }); - - it("returns false when standard arc is satisfied but inhibitor arc is not", () => { - const frame: SimulationFrame = { - time: 0, - 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, - }, - }, - buffer: new Float64Array([]), - }; - - // p1 has 2 >= 1 (standard satisfied), but p2 has 3 >= 1 (inhibitor NOT satisfied) - expect(isTransitionStructurallyEnabled(frame, "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); - }); -}); - -describe("checkTransitionEnablement", () => { - it("returns hasEnabledTransition=true when at least one transition is enabled", () => { - const frame: SimulationFrame = { - time: 0, - places: { - 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([]), - }; - - const result = checkTransitionEnablement(frame); - - 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, - 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, - }, - }, - buffer: new Float64Array([]), - }; - - const result = checkTransitionEnablement(frame); - - expect(result.hasEnabledTransition).toBe(false); - expect(result.transitionStatus.get("t1")).toBe(false); - expect(result.transitionStatus.get("t2")).toBe(false); - }); - - 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 - 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); - - expect(result.hasEnabledTransition).toBe(true); - expect(result.transitionStatus.get("t1")).toBe(true); - expect(result.transitionStatus.get("t2")).toBe(true); - expect(result.transitionStatus.get("t3")).toBe(true); - }); -}); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts deleted file mode 100644 index dd4596e2a3b..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { checkTransitionEnablement } from "./check-transition-enablement"; -import { computePlaceNextState } from "./compute-place-next-state"; -import { executeTransitions } from "./execute-transitions"; -import type { SimulationInstance } from "./types"; - -/** - * Reason why the simulation completed. - */ -export type SimulationCompletionReason = "maxTime" | "deadlock"; - -/** - * Result of computing the next frame. - */ -export type ComputeNextFrameResult = { - /** - * The updated simulation instance with the new frame appended. - */ - simulation: SimulationInstance; - - /** - * Whether any transition fired during this frame. - * When false, the token distribution did not change due to discrete events. - */ - transitionFired: boolean; - - /** - * If set, the simulation has completed and should not continue. - * - "maxTime": The simulation reached the configured maximum time. - * - "deadlock": No transitions are enabled and no further progress is possible. - */ - completionReason: SimulationCompletionReason | null; -}; - -/** - * Computes the next frame of the simulation by: - * 1. Applying differential equations to all places with dynamics enabled (that have a type) - * 2. Executing all possible transitions - * - * This integrates continuous dynamics (ODEs) and discrete transitions into a single step. - * - * @param simulation - The simulation instance containing the current state - * @returns An object containing the updated SimulationInstance and whether any transition fired - */ -export function computeNextFrame( - simulation: SimulationInstance, -): ComputeNextFrameResult { - // Get the current frame - const currentFrame = simulation.frames[simulation.currentFrameNumber]!; - - // Check if maxTime has been reached before computing - if (simulation.maxTime !== null && currentFrame.time >= simulation.maxTime) { - return { - simulation, - transitionFired: false, - completionReason: "maxTime", - }; - } - - // Step 1: Apply differential equations to places with dynamics enabled - let frameAfterDynamics = currentFrame; - - // Only apply dynamics if there are places with differential equations - if (simulation.differentialEquationFns.size > 0) { - const newBuffer = new Float64Array(currentFrame.buffer); - - // Apply differential equations to each place that has dynamics enabled - for (const [placeId, placeState] of Object.entries(currentFrame.places)) { - // Get the place instance from the simulation - const place = simulation.places.get(placeId); - if (!place) { - throw new Error(`Place with ID ${placeId} not found in simulation`); - } - - // Skip places without dynamics enabled - if (!place.dynamicsEnabled) { - continue; - } - - // Skip places without a type (no dimensions to work with) - if (!place.colorId) { - continue; - } - - // Get the differential equation function for this place - const diffEqFn = simulation.differentialEquationFns.get(placeId); - if (!diffEqFn) { - // No differential equation defined for this place, skip - continue; - } - - const { offset, count, dimensions } = placeState; - const placeSize = count * dimensions; - - // Extract the current state for this place from the buffer - const placeBuffer = currentFrame.buffer.slice(offset, offset + placeSize); - - // Get the type definition to access dimension names - const typeId = place.colorId; - if (!typeId) { - continue; // This shouldn't happen due to earlier check, but be safe - } - - const type = simulation.types.get(typeId); - if (!type) { - throw new Error( - `Type with ID ${typeId} referenced by place ${placeId} does not exist in simulation`, - ); - } - - // ADAPTER - // This could also allow for different modes, like: - // - Buffer mode: passing Float64Array directly - // - Object mode: passing array of objects - // Right now, we pass objects with named dimensions - - // Convert buffer to token array with named dimensions (Record[]) - const tokens: Record[] = []; - for (let tokenIdx = 0; tokenIdx < count; tokenIdx++) { - const tokenStart = tokenIdx * dimensions; - const token: Record = {}; - for (let dimIdx = 0; dimIdx < dimensions; dimIdx++) { - const dimensionName = type.elements[dimIdx]!.name; - token[dimensionName] = placeBuffer[tokenStart + dimIdx]!; - } - tokens.push(token); - } - - // Compute the next state using the differential equation - // The DifferentialEquationFn now expects tokens as Record[] - const wrappedDiffEq = ( - currentState: Float64Array, - _dimensions: number, - _numberOfTokens: number, - ): Float64Array => { - // Convert Float64Array to token array for the user function - const inputTokens: Record[] = []; - for (let tokenIdx = 0; tokenIdx < count; tokenIdx++) { - const tokenStart = tokenIdx * dimensions; - const token: Record = {}; - for (let dimIdx = 0; dimIdx < dimensions; dimIdx++) { - const dimensionName = type.elements[dimIdx]!.name; - token[dimensionName] = currentState[tokenStart + dimIdx]!; - } - inputTokens.push(token); - } - - // Call the user's differential equation function with token array - const resultTokens = diffEqFn(inputTokens, simulation.parameterValues); - - // Convert result back to Float64Array - const result = new Float64Array(count * dimensions); - for (let tokenIdx = 0; tokenIdx < resultTokens.length; tokenIdx++) { - const token = resultTokens[tokenIdx]!; - for (let dimIdx = 0; dimIdx < dimensions; dimIdx++) { - const dimensionName = type.elements[dimIdx]!.name; - result[tokenIdx * dimensions + dimIdx] = token[dimensionName]!; - } - } - return result; - }; - - const nextPlaceBuffer = computePlaceNextState( - placeBuffer, - dimensions, - count, - wrappedDiffEq, - "euler", // Currently only Euler method is implemented - simulation.dt, - ); - - // Copy the updated values back into the new buffer - newBuffer.set(nextPlaceBuffer, offset); - } - - // Create frame with updated buffer after applying dynamics - frameAfterDynamics = { - ...currentFrame, - buffer: newBuffer, - }; - } - - // Step 2: Execute all transitions on the frame with updated dynamics - const transitionsResult = executeTransitions( - frameAfterDynamics, - simulation, - simulation.dt, - simulation.rngState, - ); - const frameAfterTransitions = transitionsResult.frame; - const transitionFired = transitionsResult.transitionFired; - - // Step 3: Ensure time is always incremented (executeTransitions only increments if transitions fire) - const finalFrame = transitionFired - ? frameAfterTransitions - : { - ...frameAfterTransitions, - time: currentFrame.time + simulation.dt, - // Also update transition timeSinceLastFiringMs and firedInThisFrame since time advanced - transitions: Object.fromEntries( - Object.entries(frameAfterTransitions.transitions).map( - ([id, state]) => [ - id, - { - ...state, - timeSinceLastFiringMs: - state.timeSinceLastFiringMs + simulation.dt, - firedInThisFrame: false, - }, - ], - ), - ), - }; - - // Step 4: Build updated simulation instance with new frame added - const updatedSimulation: SimulationInstance = { - ...simulation, - frames: [...simulation.frames, finalFrame], - currentFrameNumber: simulation.currentFrameNumber + 1, - rngState: transitionsResult.rngState, - }; - - // Step 5: Check for completion conditions - let completionReason: SimulationCompletionReason | null = null; - - // Check if maxTime was reached with this new frame - if (simulation.maxTime !== null && finalFrame.time >= simulation.maxTime) { - completionReason = "maxTime"; - } - // Check for deadlock if no transition fired - else if (!transitionFired) { - const enablementResult = checkTransitionEnablement(finalFrame); - if (!enablementResult.hasEnabledTransition) { - completionReason = "deadlock"; - } - } - - return { - simulation: updatedSimulation, - transitionFired, - completionReason, - }; -} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.test.ts deleted file mode 100644 index a6ed2af6b54..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { computePossibleTransition } from "./compute-possible-transition"; -import type { SimulationFrame, SimulationInstance } from "./types"; - -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(), - 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, - }; - - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 1, // Only 1 token available - 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, - }, - }, - 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(); - }); - - 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(), - lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([["t1", () => ({})]]), - parameterValues: {}, - dt: 0.1, - maxTime: null, - rngState: 42, - frames: [], - currentFrameNumber: 0, - }; - - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 2, // 2 tokens present - 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, - }, - }, - 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(); - }); - - 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(), - 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, - }; - - 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, - }, - }, - 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, - }, - }, - 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 - 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 }] }; - }, - ], - ]), - 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, - }, - }, - 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, - }, - }, - 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/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/distribution.ts deleted file mode 100644 index 80a3b889131..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/distribution.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { nextRandom } from "./seeded-rng"; - -type DistributionBase = { - __brand: "distribution"; - /** Cached sampled value. Set after first sample so that multiple - * `.map()` calls on the same distribution share one draw. */ - sampledValue?: number; -}; - -/** - * Runtime representation of a probability distribution. - * Created by user code via Distribution.Gaussian() or Distribution.Uniform(), - * then sampled during transition kernel output resolution. - */ -export type RuntimeDistribution = - | (DistributionBase & { - type: "gaussian"; - mean: number; - deviation: number; - }) - | (DistributionBase & { type: "uniform"; min: number; max: number }) - | (DistributionBase & { - type: "lognormal"; - mu: number; - sigma: number; - }) - | (DistributionBase & { - type: "mapped"; - inner: RuntimeDistribution; - fn: (value: number) => number; - }); - -/** - * Checks if a value is a RuntimeDistribution object. - */ -export function isDistribution(value: unknown): value is RuntimeDistribution { - return ( - typeof value === "object" && - value !== null && - "__brand" in value && - (value as Record).__brand === "distribution" - ); -} - -/** - * JavaScript source code that defines the Distribution namespace at runtime. - * Injected into the compiled user code execution context so that - * Distribution.Gaussian() and Distribution.Uniform() are available. - */ -export const distributionRuntimeCode = ` - function __addMap(dist) { - dist.map = function(fn) { - return __addMap({ __brand: "distribution", type: "mapped", inner: dist, fn: fn }); - }; - return dist; - } - var Distribution = { - Gaussian: function(mean, deviation) { - return __addMap({ __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation }); - }, - Uniform: function(min, max) { - return __addMap({ __brand: "distribution", type: "uniform", min: min, max: max }); - }, - Lognormal: function(mu, sigma) { - return __addMap({ __brand: "distribution", type: "lognormal", mu: mu, sigma: sigma }); - } - }; -`; - -/** - * Samples a single numeric value from a distribution using the seeded RNG. - * Caches the result on the distribution object so that sibling `.map()` calls - * sharing the same inner distribution get a coherent sample. - * - * @returns A tuple of [sampledValue, newRngState] - */ -export function sampleDistribution( - distribution: RuntimeDistribution, - rngState: number, -): [number, number] { - if (distribution.sampledValue !== undefined) { - return [distribution.sampledValue, rngState]; - } - - let value: number; - let nextRng: number; - - switch (distribution.type) { - case "gaussian": { - // Box-Muller transform: converts two uniform random values to a standard normal - // Use (1 - u1) to avoid Math.log(0) since nextRandom returns [0, 1) - const [u1, rng1] = nextRandom(rngState); - const [u2, rng2] = nextRandom(rng1); - const z = Math.sqrt(-2 * Math.log(1 - u1)) * Math.cos(2 * Math.PI * u2); - value = distribution.mean + z * distribution.deviation; - nextRng = rng2; - break; - } - case "uniform": { - const [sample, newRng] = nextRandom(rngState); - value = distribution.min + sample * (distribution.max - distribution.min); - nextRng = newRng; - break; - } - case "lognormal": { - // Lognormal(μ, σ): if X ~ Normal(μ, σ), then e^X ~ Lognormal(μ, σ) - // Use (1 - u1) to avoid Math.log(0) since nextRandom returns [0, 1) - const [u1, rng1] = nextRandom(rngState); - const [u2, rng2] = nextRandom(rng1); - const z = Math.sqrt(-2 * Math.log(1 - u1)) * Math.cos(2 * Math.PI * u2); - value = Math.exp(distribution.mu + z * distribution.sigma); - nextRng = rng2; - break; - } - case "mapped": { - const [innerValue, newRng] = sampleDistribution( - distribution.inner, - rngState, - ); - value = distribution.fn(innerValue); - nextRng = newRng; - break; - } - } - - // eslint-disable-next-line no-param-reassign -- intentional: cache sampled value for coherent .map() siblings - distribution.sampledValue = value; - return [value, nextRng]; -} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.test.ts deleted file mode 100644 index 34876fb24ac..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.test.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { executeTransitions } from "./execute-transitions"; -import type { SimulationFrame, SimulationInstance } from "./types"; - -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(), - 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, - }; - - const frame: SimulationFrame = { - time: 0, - places: { - p1: { - offset: 0, - count: 0, // No tokens - 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, - }, - }, - buffer: new Float64Array([]), - }; - - const result = executeTransitions( - frame, - simulation, - simulation.dt, - simulation.rngState, - ); - - expect(result.frame).toBe(frame); - expect(result.transitionFired).toBe(false); - }); - - 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(), - lambdaFns: new Map([["t1", () => 10.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, - 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, - }, - }, - buffer: new Float64Array([1.0, 1.5]), - }; - - const result = executeTransitions( - frame, - simulation, - simulation.dt, - 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.places.p2?.count).toBe(1); - expect(result.frame.buffer[1]).toBe(2.0); // New token in p2 - - // Time should be incremented - 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(), - lambdaFns: new Map([ - ["t1", () => 10.0], - ["t2", () => 10.0], - ]), - transitionKernelFns: new Map< - string, - () => Record[]> - >([ - ["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, - }, - }, - 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, - }, - }, - buffer: new Float64Array([1.0, 2.0, 3.0]), - }; - - const result = executeTransitions( - frame, - simulation, - simulation.dt, - 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(), - 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 }] }; - }, - ], - ]), - 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, - }, - }, - 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, - }, - }, - buffer: new Float64Array([1.0, 2.0]), - }; - - const result = executeTransitions( - frame, - simulation, - simulation.dt, - 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(), - lambdaFns: new Map([ - ["t1", () => 10.0], // High lambda, will fire - ["t2", () => 0.001], // Low lambda, won't fire - ]), - transitionKernelFns: new Map< - string, - () => Record[]> - >([ - ["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, - }, - }, - 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, - }, - }, - buffer: new Float64Array([1.0, 1.5]), - }; - - const result = executeTransitions( - frame, - simulation, - simulation.dt, - 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/simulator/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts deleted file mode 100644 index a558a41f63f..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 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. - */ - -import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; -import type { SimulationFrame } from "../types"; -import type { RuntimeDistribution } from "./distribution"; - -/** - * Runtime parameter values used during simulation execution. - * Maps parameter names to their resolved numeric or boolean values. - */ -export type ParameterValues = Record; - -/** - * Compiled differential equation function for continuous dynamics. - * Computes the rate of change for tokens in a place with dynamics enabled. - */ -export type DifferentialEquationFn = ( - tokens: Record[], - parameters: ParameterValues, -) => Record[]; - -/** - * Compiled lambda function for transition firing probability. - * Returns a rate (number) for stochastic transitions or a boolean for predicate transitions. - */ -export type LambdaFn = ( - tokenValues: Record[]>, - parameters: ParameterValues, -) => number | boolean; - -/** - * Compiled transition kernel function for token generation. - * Computes the output tokens to create when a transition fires. - */ -export type TransitionKernelFn = ( - tokenValues: Record[]>, - parameters: ParameterValues, -) => Record[]>; - -/** - * Input configuration for building a new simulation instance. - */ -export type SimulationInput = { - /** The SDCPN definition to simulate */ - sdcpn: SDCPN; - /** Initial token distribution across places */ - initialMarking: Map; - /** Parameter values from the simulation store (overrides SDCPN defaults) */ - parameterValues: Record; - /** Random seed for deterministic stochastic behavior */ - seed: number; - /** Time step for simulation advancement */ - dt: number; - /** Maximum simulation time (immutable once set). Null means no limit. */ - maxTime: number | null; -}; - -/** - * A running simulation instance with compiled functions and frame history. - * Contains all state needed to execute and advance the simulation. - */ -export type SimulationInstance = { - /** Place definitions indexed by ID */ - places: Map; - /** Transition definitions indexed by ID */ - transitions: Map; - /** Color type definitions indexed by ID */ - types: Map; - /** Compiled differential equation functions indexed by place ID */ - differentialEquationFns: Map; - /** Compiled lambda functions indexed by transition ID */ - lambdaFns: Map; - /** Compiled transition kernel functions indexed by transition ID */ - transitionKernelFns: Map; - /** Resolved parameter values for this simulation run */ - parameterValues: ParameterValues; - /** Time step for simulation advancement */ - dt: number; - /** Maximum simulation time (immutable). Null means no limit. */ - maxTime: number | null; - /** Current state of the seeded random number generator */ - rngState: number; - /** History of all computed frames */ - frames: SimulationFrame[]; - /** Index of the current frame in the frames array */ - currentFrameNumber: number; -}; - -// Re-export frame types from context for convenient access within simulator -export type { - SimulationFrame, - SimulationFrameState_Place, - SimulationFrameState_Transition, -} from "../types"; 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 a8b2cc45d25..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 UI consumption. - * 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/README.md b/libs/@hashintel/petrinaut/src/core/simulation/worker/README.md index 3afff14f611..c74be954209 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,27 +21,35 @@ 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 | | ---------- | -------------------------------------------------- | ----------------------- | | `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 | +`SimulationFramePayload.frame` is a binary `ArrayBuffer`. Host code should not +read it directly; `runtime/frame-store.ts` specializes a `SimulationFrameReader` +from the SDCPN snapshot and exposes that reader through the public simulation +API. + ## 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,16 +59,19 @@ 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, - initialMarking, + initialMarking: { + queue: 10, + customer: [{ waitTime: 0 }], + }, parameterValues, seed, dt, @@ -69,4 +82,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/worker/frame-payload.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts new file mode 100644 index 00000000000..fd9c67128f9 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts @@ -0,0 +1,21 @@ +import type { EngineFrame } from "../frames/internal-frame"; + +/** + * Worker protocol representation for a full frame payload. + * + * Time is attached by the run controller, not stored in `EngineFrame`. + */ +export type SimulationFramePayload = { + time: number; + frame: EngineFrame; +}; + +export function framePayloadFromEngineFrame( + frame: EngineFrame, + time: number, +): SimulationFramePayload { + return { + time, + frame, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts index 9bc08d21515..b0384b51109 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts @@ -5,7 +5,8 @@ */ import type { SDCPN } from "../../types/sdcpn"; -import type { SimulationFrame } from "../types"; +import type { InitialMarking } from "../api"; +import type { SimulationFramePayload } from "./frame-payload"; // // Main Thread → Worker Messages @@ -19,8 +20,8 @@ export type InitMessage = { type: "init"; /** The SDCPN definition to simulate */ sdcpn: SDCPN; - /** Initial token distribution (serialized from Map) */ - initialMarking: Array<[string, { values: Float64Array; count: number }]>; + /** Initial token distribution. JSON-serializable. */ + initialMarking: InitialMarking; /** Parameter values (overrides SDCPN defaults) */ parameterValues: Record; /** Random seed for deterministic stochastic behavior */ @@ -106,7 +107,7 @@ export type ReadyMessage = { */ export type FrameMessage = { type: "frame"; - frame: SimulationFrame; + frame: SimulationFramePayload; }; /** @@ -114,7 +115,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.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.test.ts index 5ae9aa65c7d..d03eca00b07 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.test.ts @@ -101,11 +101,9 @@ describe("simulation.worker", () => { }); describe("initialization", () => { - it("posts ready message on load", () => { - // Worker posts ready on load + it("does not post ready before init", () => { const readyMessages = getMessages("ready"); - expect(readyMessages).toHaveLength(1); - expect(readyMessages[0]?.initialFrameCount).toBe(0); + expect(readyMessages).toHaveLength(0); }); it("initializes simulation with valid SDCPN", () => { @@ -115,7 +113,7 @@ describe("simulation.worker", () => { sendToWorker({ type: "init", sdcpn, - initialMarking: [["p1", { values: new Float64Array([1.0]), count: 1 }]], + initialMarking: { p1: [{ x: 1.0 }] }, parameterValues: {}, seed: 42, dt: 0.1, @@ -140,9 +138,7 @@ describe("simulation.worker", () => { sendToWorker({ type: "init", sdcpn, - initialMarking: [ - ["nonexistent", { values: new Float64Array([1.0]), count: 1 }], - ], + initialMarking: { nonexistent: [{ x: 1.0 }] }, parameterValues: {}, seed: 42, dt: 0.1, @@ -174,7 +170,7 @@ describe("simulation.worker", () => { sendToWorker({ type: "init", sdcpn, - initialMarking: [["p1", { values: new Float64Array([1.0]), count: 1 }]], + initialMarking: { p1: [{ x: 1.0 }] }, parameterValues: {}, seed: 42, dt: 0.1, @@ -198,7 +194,7 @@ describe("simulation.worker", () => { sendToWorker({ type: "init", sdcpn, - initialMarking: [["p1", { values: new Float64Array([1.0]), count: 1 }]], + initialMarking: { p1: [{ x: 1.0 }] }, parameterValues: {}, seed: 42, dt: 0.1, @@ -227,7 +223,7 @@ describe("simulation.worker", () => { sendToWorker({ type: "init", sdcpn, - initialMarking: [["p1", { values: new Float64Array([1.0]), count: 1 }]], + initialMarking: { p1: [{ x: 1.0 }] }, parameterValues: {}, seed: 42, dt: 0.1, @@ -256,7 +252,7 @@ describe("simulation.worker", () => { sendToWorker({ type: "init", sdcpn, - initialMarking: [["p1", { values: new Float64Array([1.0]), count: 1 }]], + initialMarking: { p1: [{ x: 1.0 }] }, parameterValues: {}, seed: 42, dt: 0.1, 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..9057f8bb508 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,13 @@ */ 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 { + framePayloadFromEngineFrame, + type SimulationFramePayload, +} from "./frame-payload"; import type { ToMainMessage, ToWorkerMessage } from "./messages"; // @@ -81,7 +85,7 @@ async function computeLoop(): Promise { } // Compute a batch of frames - const framesToSend: typeof simulation.frames = []; + const framesToSend: SimulationFramePayload[] = []; for (let i = 0; i < batchSize; i++) { try { @@ -90,7 +94,9 @@ async function computeLoop(): Promise { simulation = updatedSimulation; const newFrame = simulation.frames[simulation.currentFrameNumber]!; - framesToSend.push(newFrame); + framesToSend.push( + framePayloadFromEngineFrame(newFrame, simulation.currentTime), + ); // Check if simulation completed if (completionReason !== null) { @@ -121,9 +127,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: framesToSend[0]!, + }); } else { - postTypedMessage({ type: "frames", frames: framesToSend }); + postTypedMessage({ + type: "frames", + frames: framesToSend, + }); } } @@ -143,13 +155,10 @@ self.onmessage = (event: MessageEvent) => { switch (message.type) { case "init": { try { - // Convert serialized initialMarking back to Map - const initialMarking = new Map(message.initialMarking); - // Build simulation (compiles user code) simulation = buildSimulation({ sdcpn: message.sdcpn, - initialMarking, + initialMarking: message.initialMarking, parameterValues: message.parameterValues, seed: message.seed, dt: message.dt, @@ -168,7 +177,13 @@ 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, + simulation.currentTime, + ), + }); } postTypedMessage({ @@ -250,14 +265,8 @@ self.onmessage = (event: MessageEvent) => { } case "ack": { - lastAckedFrame = message.frameNumber; + lastAckedFrame = Math.max(lastAckedFrame, message.frameNumber); break; } } }; - -// Signal that worker is ready -postTypedMessage({ - type: "ready", - initialFrameCount: 0, -}); diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 5be8888a443..2267f881a2c 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -20,7 +20,6 @@ export type { ViewportAction } from "./ui/types/viewport-action"; // SDCPN deep-equality helper (also exported from `/ui`). export { isSDCPNEqual } from "./core/lib/deep-equal"; -// Phase 0 spike: handle-driven entry path. See rfc/0001-core-react-ui-split. export { createJsonDocHandle } from "./core/handle"; export type { CreateJsonDocHandleOptions, @@ -46,12 +45,16 @@ export { export type { BackpressureConfig, CreateSimulationConfig, + InitialMarking, Simulation, SimulationCompleteEvent, SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameReader, + SimulationFrameState, SimulationFrameSummary, + SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.ts new file mode 100644 index 00000000000..8457a316c44 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/experiments/context.ts @@ -0,0 +1,63 @@ +import { createContext } from "react"; + +import type { + MonteCarloWorkerProgress, + PlaceTokenCountDistributionFrame, +} from "../../core/simulation"; + +export type ExperimentStatus = + | "initializing" + | "running" + | "complete" + | "error" + | "cancelled"; + +export type CreateExperimentInput = { + name: string; + scenarioId: string | null; + scenarioParameterValues: Record; + runCount: number; + seed: number; + dt: number; + maxTime: number; +}; + +export type ExperimentRecord = { + id: string; + name: string; + createdAt: number; + scenarioId: string | null; + scenarioName: string | null; + runCount: number; + seed: number; + dt: number; + maxTime: number; + status: ExperimentStatus; + error: string | null; + progress: MonteCarloWorkerProgress | null; + distributionFrames: readonly PlaceTokenCountDistributionFrame[]; +}; + +export type ExperimentsContextValue = { + experiments: readonly ExperimentRecord[]; + selectedExperimentId: string | null; + selectedExperiment: ExperimentRecord | null; + setSelectedExperimentId: (experimentId: string | null) => void; + createExperiment: (input: CreateExperimentInput) => Promise; + cancelExperiment: (experimentId: string) => void; + removeExperiment: (experimentId: string) => void; +}; + +const DEFAULT_CONTEXT_VALUE: ExperimentsContextValue = { + experiments: [], + selectedExperimentId: null, + selectedExperiment: null, + setSelectedExperimentId: () => {}, + createExperiment: () => Promise.resolve(""), + cancelExperiment: () => {}, + removeExperiment: () => {}, +}; + +export const ExperimentsContext = createContext( + DEFAULT_CONTEXT_VALUE, +); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx new file mode 100644 index 00000000000..6c18259c106 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -0,0 +1,245 @@ +/** + * @vitest-environment jsdom + */ +import { act, render, type RenderResult } from "@testing-library/react"; +import { use } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { PlaceTokenCountDistributionFrame } from "../../core/simulation"; +import type { SDCPN } from "../../core/types/sdcpn"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { ExperimentsContext, type ExperimentsContextValue } from "./context"; +import { ExperimentsProvider } from "./provider"; +import type { + MonteCarloToMainMessage, + MonteCarloToWorkerMessage, + MonteCarloWorkerProgress, +} from "../../core/simulation/monte-carlo/worker/messages"; + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; + +function makeProgress( + overrides: Partial = {}, +): MonteCarloWorkerProgress { + return { + activeRuns: 1, + advancedRuns: 1, + allFinished: false, + completedRuns: 0, + erroredRuns: 0, + frameNumber: 1, + runCount: 1, + time: 1, + ...overrides, + }; +} + +function makeDistributionFrame(): PlaceTokenCountDistributionFrame { + return { + frameNumber: 1, + time: 1, + runCount: 1, + activeRunCount: 1, + completedRunCount: 0, + erroredRunCount: 0, + places: [ + { + placeId: "place-a", + placeName: "Place A", + sampleCount: 1, + bins: [[1, 1]], + }, + ], + }; +} + +class FakeMonteCarloWorker { + readonly sent: MonteCarloToWorkerMessage[] = []; + readonly postMessage = vi.fn((message: MonteCarloToWorkerMessage) => { + this.sent.push(message); + }); + readonly terminate = vi.fn(() => { + this.terminated = true; + this.listeners.clear(); + }); + + terminated = false; + #listeners = new Set< + (event: MessageEvent) => void + >(); + + private get listeners() { + return this.#listeners; + } + + addEventListener( + type: string, + listener: (event: MessageEvent) => void, + ) { + if (type === "message") { + this.#listeners.add(listener); + } + } + + removeEventListener( + type: string, + listener: (event: MessageEvent) => void, + ) { + if (type === "message") { + this.#listeners.delete(listener); + } + } + + emit(message: MonteCarloToMainMessage) { + for (const listener of this.#listeners) { + listener( + new MessageEvent("message", { + data: message, + }), + ); + } + } +} + +const flushWorkerSetup = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: EMPTY_SDCPN, + readonly: false, + setTitle: () => {}, + title: "Test", + getItemType: () => null, +}; + +const ExperimentsContextConsumer = ({ + onContextValue, +}: { + onContextValue: (value: ExperimentsContextValue) => void; +}) => { + const contextValue = use(ExperimentsContext); + onContextValue(contextValue); + return null; +}; + +const TestWrapper = ({ + worker, + onContextValue, +}: { + worker: FakeMonteCarloWorker; + onContextValue: (value: ExperimentsContextValue) => void; +}) => ( + + worker as unknown as Worker}> + + + +); + +function renderExperimentsProvider(worker: FakeMonteCarloWorker): { + getValue: () => ExperimentsContextValue; + renderResult: RenderResult; +} { + const valueHolder = { current: null as ExperimentsContextValue | null }; + const captureValue = (value: ExperimentsContextValue) => { + valueHolder.current = value; + }; + + const renderResult = render( + , + ); + + return { + getValue: () => valueHolder.current!, + renderResult, + }; +} + +describe("ExperimentsProvider", () => { + it("creates, streams, cancels, and removes a Monte Carlo experiment", async () => { + const worker = new FakeMonteCarloWorker(); + const { getValue, renderResult } = renderExperimentsProvider(worker); + + let experimentId = ""; + await act(async () => { + const createPromise = getValue().createExperiment({ + name: "Test experiment", + scenarioId: null, + scenarioParameterValues: {}, + runCount: 1, + seed: 42, + dt: 1, + maxTime: 10, + }); + + await flushWorkerSetup(); + expect(worker.sent[0]).toMatchObject({ + type: "init", + runCount: 1, + seed: 42, + dt: 1, + maxTime: 10, + }); + + worker.emit({ type: "ready" }); + experimentId = await createPromise; + }); + + expect(worker.sent.map((message) => message.type)).toEqual([ + "init", + "start", + ]); + expect(getValue().experiments).toHaveLength(1); + expect(getValue().selectedExperimentId).toBe(experimentId); + expect(getValue().selectedExperiment?.status).toBe("running"); + + const frame = makeDistributionFrame(); + const progress = makeProgress(); + await act(async () => { + worker.emit({ type: "distributionFrames", frames: [frame] }); + worker.emit({ type: "progress", progress }); + }); + + expect(getValue().selectedExperiment?.distributionFrames).toEqual([frame]); + expect(getValue().selectedExperiment?.progress).toEqual(progress); + + await act(async () => { + getValue().cancelExperiment(experimentId); + }); + expect(worker.sent.map((message) => message.type)).toContain("cancel"); + + const cancelledProgress = makeProgress({ + activeRuns: 0, + advancedRuns: 0, + completedRuns: 1, + }); + await act(async () => { + worker.emit({ type: "cancelled", progress: cancelledProgress }); + }); + + expect(getValue().selectedExperiment?.status).toBe("cancelled"); + expect(getValue().selectedExperiment?.progress).toEqual(cancelledProgress); + expect(worker.terminated).toBe(true); + + await act(async () => { + getValue().removeExperiment(experimentId); + }); + + expect(getValue().experiments).toEqual([]); + expect(getValue().selectedExperimentId).toBeNull(); + + renderResult.unmount(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx new file mode 100644 index 00000000000..bb3e9176184 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -0,0 +1,346 @@ +import { use, useEffect, useRef, useState } from "react"; +import { v4 as generateUuid } from "uuid"; + +import { + createMonteCarloExperiment, + type InitialMarking, + type MonteCarloExperiment, + type MonteCarloExperimentState, + type WorkerFactory, +} from "../../core/simulation"; +import { compileScenario } from "../../core/simulation/authoring/scenario/compile-scenario"; +import { createMonteCarloWorker } from "../../core/simulation/monte-carlo/worker/create-monte-carlo-worker"; +import type { Scenario, ScenarioParameter } from "../../core/types/sdcpn"; +import { useLatest } from "../hooks/use-latest"; +import { useStableCallback } from "../hooks/use-stable-callback"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { + type CreateExperimentInput, + type ExperimentRecord, + type ExperimentStatus, + ExperimentsContext, + type ExperimentsContextValue, +} from "./context"; + +type ExperimentsProviderProps = React.PropsWithChildren<{ + workerFactory?: WorkerFactory; +}>; + +type ExperimentHandleRegistration = { + handle: MonteCarloExperiment; + off: () => void; +}; + +function mapExperimentStatus( + status: MonteCarloExperimentState, +): ExperimentStatus { + switch (status) { + case "Initializing": + case "Ready": + return "initializing"; + case "Running": + return "running"; + case "Complete": + return "complete"; + case "Error": + return "error"; + case "Cancelled": + return "cancelled"; + } +} + +function parseScenarioParameterValue( + parameter: ScenarioParameter, + rawValue: string | undefined, +): number | string { + const value = + rawValue === undefined || rawValue.trim() === "" + ? String(parameter.default) + : rawValue.trim(); + + if (parameter.type === "boolean") { + const normalizedValue = value.toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalizedValue)) { + return 1; + } + if (["0", "false", "no", "off"].includes(normalizedValue)) { + return 0; + } + return `${parameter.identifier} must be true or false`; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return `${parameter.identifier} must be a finite number`; + } + if (parameter.type === "integer" && !Number.isInteger(parsed)) { + return `${parameter.identifier} must be an integer`; + } + if (parameter.type === "ratio" && (parsed < 0 || parsed > 1)) { + return `${parameter.identifier} must be between 0 and 1`; + } + + return parsed; +} + +function parseScenarioParameterValues( + scenario: Scenario, + rawValues: Record, +): { values: Record; errors: string[] } { + const values: Record = {}; + const errors: string[] = []; + + for (const parameter of scenario.scenarioParameters) { + const parsed = parseScenarioParameterValue( + parameter, + rawValues[parameter.identifier], + ); + + if (typeof parsed === "string") { + errors.push(parsed); + } else { + values[parameter.identifier] = parsed; + } + } + + return { values, errors }; +} + +function assertExperimentInput(input: CreateExperimentInput): void { + if (input.name.trim() === "") { + throw new Error("Experiment name is required"); + } + if (!Number.isInteger(input.runCount) || input.runCount <= 0) { + throw new Error("Runs must be a positive integer"); + } + if (!Number.isInteger(input.seed)) { + throw new Error("Seed must be an integer"); + } + if (!Number.isFinite(input.dt) || input.dt <= 0) { + throw new Error("Time step must be a positive number"); + } + if (!Number.isFinite(input.maxTime) || input.maxTime <= 0) { + throw new Error("Max time must be a positive number"); + } +} + +export const ExperimentsProvider: React.FC = ({ + children, + workerFactory, +}) => { + const { petriNetDefinition } = use(SDCPNContext); + const petriNetDefinitionRef = useLatest(petriNetDefinition); + const workerFactoryRef = useLatest(workerFactory ?? createMonteCarloWorker); + const registrationsRef = useRef( + new Map(), + ); + const [experiments, setExperiments] = useState([]); + const [selectedExperimentId, setSelectedExperimentId] = useState< + string | null + >(null); + + useEffect(() => { + const registrations = registrationsRef.current; + return () => { + for (const registration of registrations.values()) { + registration.off(); + registration.handle.dispose(); + } + registrations.clear(); + }; + }, []); + + const patchExperiment = ( + experimentId: string, + patch: Partial, + ) => { + setExperiments((prev) => + prev.map((experiment) => + experiment.id === experimentId + ? { ...experiment, ...patch } + : experiment, + ), + ); + }; + + const disposeExperimentHandle = (experimentId: string) => { + const registration = registrationsRef.current.get(experimentId); + if (!registration) { + return; + } + + registration.off(); + registration.handle.dispose(); + registrationsRef.current.delete(experimentId); + }; + + const registerExperimentHandle = ( + experimentId: string, + handle: MonteCarloExperiment, + ) => { + const sync = () => { + patchExperiment(experimentId, { + distributionFrames: handle.distributions.get().frames, + progress: handle.progress.get(), + status: mapExperimentStatus(handle.status.get()), + }); + }; + + const unsubscribeStatus = handle.status.subscribe(sync); + const unsubscribeProgress = handle.progress.subscribe(sync); + const unsubscribeDistributions = handle.distributions.subscribe(sync); + const unsubscribeEvents = handle.events.subscribe((event) => { + if (event.type === "error") { + patchExperiment(experimentId, { + error: event.message, + status: "error", + }); + } else { + sync(); + } + + if (event.type === "complete" || event.type === "cancelled") { + disposeExperimentHandle(experimentId); + } + }); + + registrationsRef.current.set(experimentId, { + handle, + off: () => { + unsubscribeStatus(); + unsubscribeProgress(); + unsubscribeDistributions(); + unsubscribeEvents(); + }, + }); + sync(); + }; + + const createExperiment: ExperimentsContextValue["createExperiment"] = async ( + input, + ) => { + assertExperimentInput(input); + + const sdcpn = petriNetDefinitionRef.current; + const selectedScenario = input.scenarioId + ? (sdcpn.scenarios ?? []).find( + (scenario) => scenario.id === input.scenarioId, + ) + : null; + if (input.scenarioId && !selectedScenario) { + throw new Error("Selected scenario does not exist"); + } + + let parameterValues: Record = {}; + let initialMarking: InitialMarking = {}; + + if (selectedScenario) { + const parsedScenarioValues = parseScenarioParameterValues( + selectedScenario, + input.scenarioParameterValues, + ); + if (parsedScenarioValues.errors.length > 0) { + throw new Error(parsedScenarioValues.errors.join("\n")); + } + + const compiledScenario = compileScenario( + selectedScenario, + sdcpn.parameters, + sdcpn.places, + sdcpn.types, + { scenarioParameterValues: parsedScenarioValues.values }, + ); + if (!compiledScenario.ok) { + throw new Error( + compiledScenario.errors + .map((error) => `${error.source}:${error.itemId} ${error.message}`) + .join("\n"), + ); + } + + parameterValues = compiledScenario.result.parameterValues; + initialMarking = compiledScenario.result.initialState; + } + + const experimentId = generateUuid(); + const experiment: ExperimentRecord = { + id: experimentId, + name: input.name.trim(), + createdAt: Date.now(), + scenarioId: input.scenarioId, + scenarioName: selectedScenario?.name ?? null, + runCount: input.runCount, + seed: input.seed, + dt: input.dt, + maxTime: input.maxTime, + status: "initializing", + error: null, + progress: null, + distributionFrames: [], + }; + + setExperiments((prev) => [experiment, ...prev]); + setSelectedExperimentId(experimentId); + + try { + const handle = await createMonteCarloExperiment({ + sdcpn, + initialMarking, + parameterValues, + seed: input.seed, + dt: input.dt, + maxTime: input.maxTime, + runCount: input.runCount, + createWorker: workerFactoryRef.current, + }); + registerExperimentHandle(experimentId, handle); + handle.start(); + } catch (error) { + patchExperiment(experimentId, { + error: error instanceof Error ? error.message : String(error), + status: "error", + }); + throw error; + } + + return experimentId; + }; + + const cancelExperiment: ExperimentsContextValue["cancelExperiment"] = ( + experimentId, + ) => { + registrationsRef.current.get(experimentId)?.handle.cancel(); + }; + + const removeExperiment: ExperimentsContextValue["removeExperiment"] = ( + experimentId, + ) => { + disposeExperimentHandle(experimentId); + setExperiments((prev) => + prev.filter((experiment) => experiment.id !== experimentId), + ); + setSelectedExperimentId((current) => + current === experimentId ? null : current, + ); + }; + + const selectedExperiment = + experiments.find((experiment) => experiment.id === selectedExperimentId) ?? + null; + + const contextValue: ExperimentsContextValue = { + experiments, + selectedExperimentId, + selectedExperiment, + setSelectedExperimentId, + createExperiment: useStableCallback(createExperiment), + cancelExperiment: useStableCallback(cancelExperiment), + removeExperiment: useStableCallback(removeExperiment), + }; + + return ( + + {children} + + ); +}; 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/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index d2a0415db19..a3a8455aa15 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -15,6 +15,13 @@ export { NetManagementContext, type NetManagement, } from "./net-management-context"; +export { ExperimentsContext } from "./experiments/context"; +export type { + CreateExperimentInput, + ExperimentRecord, + ExperimentStatus, + ExperimentsContextValue, +} from "./experiments/context"; // --- Error tracker DI --- export { ErrorTrackerContext } from "./error-tracker-context"; diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx index c286b3ca385..802e3fa7e31 100644 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx @@ -47,7 +47,7 @@ const DEFAULT_SIMULATION: SimulationContextValue = { error: null, errorItemId: null, parameterValues: {}, - initialMarking: new Map(), + initialMarking: {}, selectedScenarioId: null, scenarioParameterValues: {}, compiledScenarioResult: null, diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index 122e629f377..7b6fde24085 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -11,6 +11,7 @@ import { type NetManagement, } from "./net-management-context"; import { PlaybackProvider } from "./playback/provider"; +import { ExperimentsProvider } from "./experiments/provider"; import { SDCPNProvider } from "./sdcpn-provider"; import { SimulationProvider } from "./simulation/provider"; import { EditorProvider } from "./state/editor-provider"; @@ -31,6 +32,7 @@ export type PetrinautProviderProps = { * issues with the inlined worker. */ simulationWorkerFactory?: WorkerFactory; + monteCarloWorkerFactory?: WorkerFactory; /** * Optional language-server worker factory. Same shape as * `simulationWorkerFactory` — provided when the host needs to bundle the @@ -50,6 +52,7 @@ export const PetrinautProvider: React.FC = ({ instance, netManagement, simulationWorkerFactory, + monteCarloWorkerFactory, lspWorkerFactory, children, }) => { @@ -57,22 +60,26 @@ export const PetrinautProvider: React.FC = ({ instance.handle.history, ); - // Keyed by handle id so a net switch fully resets the LSP worker - // and its in-flight diagnostics. + // Keyed by handle id so a net switch fully resets net-scoped worker state. const inner = ( - - - - - {children} - - - + + + + + + {children} + + + + 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..b16459087e9 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,15 +32,14 @@ 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). - * Provides easy access to token counts and transition states. + * Provides easy access to place token counts. * Null when no simulation is running or no frames exist. */ currentViewedFrame: SimulationFrameState | null; @@ -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..80960676fc2 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SimulationContext, type SimulationContextValue, - type SimulationFrame, + type SimulationFrameReader, } from "../simulation/context"; import { PlaybackContext, type PlaybackContextValue } from "./context"; import { PlaybackProvider } from "./provider"; @@ -19,25 +19,28 @@ import { PlaybackProvider } from "./provider"; type MockSimulationContextOverrides = Partial; -/** - * Creates a minimal SimulationFrame for testing. - */ -function createMockFrame(time: number): SimulationFrame { +function createMockFrameReader(number: number): SimulationFrameReader { return { - time, - places: {}, - transitions: {}, - buffer: new Float64Array(), + number, + time: number * 0.01, + getPlaceTokenCount: () => 0, + getPlaceTokenValues: () => null, + getPlaceTokens: () => [], + getTransitionState: () => null, + toFrameState: () => ({ + number, + places: {}, + }), }; } /** - * 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(createMockFrameReader(i)); } return frames; } @@ -45,7 +48,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 +67,7 @@ function createMockSimulationContext( overrides: MockSimulationContextOverrides = {}, frameCount = 0, ): SimulationContextValue { - const frames = createMockFrames(frameCount); + const frames = createMockFrameReaders(frameCount); const frameAccessors = createMockFrameAccessors(frames); return { @@ -72,7 +75,7 @@ function createMockSimulationContext( error: null, errorItemId: null, parameterValues: {}, - initialMarking: new Map(), + initialMarking: {}, selectedScenarioId: null, scenarioParameterValues: {}, compiledScenarioResult: null, @@ -262,8 +265,8 @@ describe("PlaybackProvider", () => { }); }); - describe("auto-switch play mode", () => { - it("should switch to viewOnly when simulation completes", () => { + describe("effective play mode", () => { + it("should expose viewOnly when simulation completes", () => { const simulationContext = createMockSimulationContext( { state: "Running", @@ -286,7 +289,8 @@ describe("PlaybackProvider", () => { ), ); - // Should auto-switch to viewOnly + // The exposed mode is derived from simulation state. The stored + // requested mode remains computeMax so a later reset can run again. expect(getPlaybackValue().playMode).toBe("viewOnly"); }); }); @@ -435,7 +439,7 @@ describe("PlaybackProvider", () => { ); const { getPlaybackValue } = renderPlaybackProvider(simulationContext); - // Should auto-switch to viewOnly due to Complete state + // Should expose viewOnly due to Complete state expect(getPlaybackValue().playMode).toBe("viewOnly"); // Try to switch to compute mode @@ -467,15 +471,27 @@ describe("PlaybackProvider", () => { }); describe("play action", () => { - it("should do nothing when no simulation exists", () => { - const simulationContext = createMockSimulationContext(); + it("should initialize and start the simulation when no run exists", async () => { + const initializeFn = vi.fn().mockResolvedValue(undefined); + const runFn = vi.fn(); + const simulationContext = createMockSimulationContext({ + initialize: initializeFn, + run: runFn, + }); const { getPlaybackValue } = renderPlaybackProvider(simulationContext); - act(() => { - void getPlaybackValue().play(); + await act(async () => { + await getPlaybackValue().play(); }); - expect(getPlaybackValue().playbackState).toBe("Stopped"); + expect(initializeFn).toHaveBeenCalledWith( + expect.objectContaining({ + maxFramesAhead: 10000, + batchSize: 500, + }), + ); + expect(runFn).toHaveBeenCalled(); + expect(getPlaybackValue().playbackState).toBe("Playing"); }); it("should do nothing when simulation has no frames", () => { @@ -579,6 +595,65 @@ describe("PlaybackProvider", () => { // run should not have been called expect(runFn).not.toHaveBeenCalled(); }); + + it("should use compute backpressure when playing after complete/reset", async () => { + const initializeFn = vi.fn().mockResolvedValue(undefined); + const runFn = vi.fn(); + const { getPlaybackValue, rerender } = renderPlaybackProvider( + createMockSimulationContext( + { + state: "Running", + initialize: initializeFn, + run: runFn, + }, + 10, + ), + ); + + expect(getPlaybackValue().playMode).toBe("computeMax"); + + await act(async () => { + rerender( + createMockSimulationContext( + { + state: "Complete", + initialize: initializeFn, + run: runFn, + }, + 10, + ), + ); + await Promise.resolve(); + }); + + expect(getPlaybackValue().playMode).toBe("viewOnly"); + + await act(async () => { + rerender( + createMockSimulationContext({ + state: "NotRun", + initialize: initializeFn, + run: runFn, + }), + ); + await Promise.resolve(); + }); + + expect(getPlaybackValue().playMode).toBe("computeMax"); + + await act(async () => { + await getPlaybackValue().play(); + }); + + expect(initializeFn).toHaveBeenCalledWith( + expect.objectContaining({ + maxFramesAhead: 10000, + batchSize: 500, + }), + ); + expect(runFn).toHaveBeenCalled(); + expect(getPlaybackValue().playMode).toBe("computeMax"); + }); }); describe("pause action", () => { @@ -741,7 +816,6 @@ describe("PlaybackProvider", () => { expect(getPlaybackValue().currentViewedFrame).not.toBeNull(); expect(getPlaybackValue().currentViewedFrame!.number).toBe(0); - expect(getPlaybackValue().currentViewedFrame!.time).toBe(0); // Move to frame 5 await act(async () => { @@ -750,12 +824,11 @@ describe("PlaybackProvider", () => { }); expect(getPlaybackValue().currentViewedFrame!.number).toBe(5); - expect(getPlaybackValue().currentViewedFrame!.time).toBeCloseTo(0.05); }); }); describe("auto-start playback", () => { - it("should auto-start playback when simulation transitions to Running", () => { + it("should not auto-start playback when simulation transitions to Running", () => { const simulationContext = createMockSimulationContext( { state: "NotRun", @@ -777,7 +850,7 @@ describe("PlaybackProvider", () => { ), ); - expect(getPlaybackValue().playbackState).toBe("Playing"); + expect(getPlaybackValue().playbackState).toBe("Stopped"); }); }); }); diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.tsx index fba29723216..511105973bd 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.tsx @@ -1,11 +1,10 @@ import { use, useEffect, useRef, useState } from "react"; -import type { ReadableStore } from "../../core/handle"; import { createPlayback, getPlayModeBackpressure, + type ComputePlayMode, type Playback, - type PlaybackSnapshot, type PlaybackSpeed, type PlayMode, } from "../../core/playback"; @@ -13,64 +12,45 @@ import { useLatest } from "../hooks/use-latest"; import { useStableCallback } from "../hooks/use-stable-callback"; import { SimulationContext, - type SimulationFrame, + type SimulationContextValue, + type SimulationFrameReader, type SimulationFrameState, } from "../simulation/context"; import { useStore } from "../use-store"; import { PlaybackContext, type PlaybackContextValue } from "./context"; /** - * Stable fallback snapshot used while the real playback handle is being - * created in the mount effect. Sharing the same reference across `get()` calls - * keeps `useSyncExternalStore` from looping (a fresh object each read would - * trigger an infinite render cycle). - */ -const EMPTY_PLAYBACK_SNAPSHOT: PlaybackSnapshot = { - playState: "Stopped", - frameIndex: 0, - speed: 1, - mode: "computeMax", -}; - -const EMPTY_PLAYBACK_STORE: ReadableStore = { - get: () => EMPTY_PLAYBACK_SNAPSHOT, - subscribe: () => () => {}, -}; - -/** - * 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; - } + return frame?.toFrameState() ?? null; +} - const places: SimulationFrameState["places"] = {}; - for (const [placeId, placeData] of Object.entries(frame.places)) { - places[placeId] = { tokenCount: placeData.count }; - } +function isSimulationComputeAvailable( + simulationState: SimulationContextValue["state"], +): boolean { + return simulationState !== "Complete" && simulationState !== "Error"; +} - const transitions: SimulationFrameState["transitions"] = {}; - for (const [transitionId, transitionData] of Object.entries( - frame.transitions, - )) { - transitions[transitionId] = { - timeSinceLastFiringMs: transitionData.timeSinceLastFiringMs, - firedInThisFrame: transitionData.firedInThisFrame, - firingCount: transitionData.firingCount, - }; +function getEffectivePlayMode( + requestedMode: PlayMode, + simulationState: SimulationContextValue["state"], + totalFrames: number, +): PlayMode { + if (!isSimulationComputeAvailable(simulationState)) { + return "viewOnly"; + } + if (requestedMode === "viewOnly" && totalFrames === 0) { + return "computeMax"; } + return requestedMode; +} - return { - number: frameIndex, - time: frame.time, - places, - transitions, - }; +function toComputePlayMode(mode: PlayMode): ComputePlayMode { + return mode === "computeBuffer" ? "computeBuffer" : "computeMax"; } type PlaybackProviderProps = React.PropsWithChildren; @@ -92,29 +72,18 @@ export const PlaybackProvider: React.FC = ({ // Pure timing model lives in /core. The provider drives ticks via rAF and // coordinates simulation lifecycle (init / run / pause / ack / backpressure). - // - // Created inside an effect (not via `useState`'s lazy initializer) so React - // StrictMode's simulated unmount/remount doesn't leave us holding a disposed - // handle. The cleanup disposes whichever handle was created here; the next - // mount creates a fresh one. Same pattern as . - const [playback, setPlayback] = useState(null); + const [playback] = useState(() => createPlayback()); useEffect(() => { - const pb = createPlayback(); - setPlayback(pb); - return () => { - pb.dispose(); - setPlayback((current) => (current === pb ? null : current)); - }; - }, []); + return playback.dispose; + }, [playback]); - const snapshot = useStore(playback?.state ?? EMPTY_PLAYBACK_STORE); - const { playState, frameIndex, speed, mode } = snapshot; + const snapshot = useStore(playback.state); + const { playState, frameIndex, speed, mode: requestedMode } = snapshot; // 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); @@ -126,15 +95,32 @@ export const PlaybackProvider: React.FC = ({ const isViewOnlyAvailable = totalFrames > 0; // Compute modes are available when simulation can still compute more frames. - const isComputeAvailable = - simulationState !== "Complete" && simulationState !== "Error"; + const isComputeAvailable = isSimulationComputeAvailable(simulationState); + const mode = getEffectivePlayMode( + requestedMode, + simulationState, + totalFrames, + ); + + const getCurrentMode = () => + getEffectivePlayMode( + snapshotRef.current.mode, + simulationStateRef.current, + totalFramesRef.current, + ); + const getCurrentComputeMode = () => toComputePlayMode(getCurrentMode()); + const pauseSimulationIfComputing = () => { + if (getCurrentMode() !== "viewOnly") { + pauseSimulation(); + } + }; // Fetch frame whenever the index changes. useEffect(() => { let cancelled = false; void getFrame(frameIndex).then((frame) => { if (!cancelled) { - setCurrentFrame(frame); + setCurrentFrameReader(frame); } }); return () => { @@ -142,47 +128,19 @@ export const PlaybackProvider: React.FC = ({ }; }, [frameIndex, getFrame, totalFrames]); - // Auto-switch to viewOnly when the simulation can no longer compute. - useEffect(() => { - if (!playback) { - return; - } - if (!isComputeAvailable && mode !== "viewOnly") { - playback.setMode("viewOnly"); - } - }, [isComputeAvailable, mode, playback]); - - // Push backpressure config to the simulation worker on mode changes. - useEffect(() => { - const cfg = getPlayModeBackpressure(mode); - setBackpressure(cfg); - }, [mode, setBackpressure]); - - // Reset playback state when the simulation is reset / not yet run. - useEffect(() => { - if (!playback) { - return; - } - if (simulationState === "NotRun") { - playback.stop(); - } - }, [simulationState, playback]); - - // Safety net: if the simulation transitions into Running without going - // through `play()` (e.g. an external caller invoked `simulation.run()` - // directly), make sure playback follows. The user-driven play path calls - // `playback.play()` itself so this effect is normally a no-op. + // Reset playback state when the simulation transitions back to NotRun. const prevSimulationStateRef = useRef(simulationState); useEffect(() => { const prevState = prevSimulationStateRef.current; prevSimulationStateRef.current = simulationState; - if (!playback) { - return; - } - if (simulationState === "Running" && prevState !== "Running") { - playback.play(); + if ( + simulationState === "NotRun" && + prevState !== "NotRun" && + (playState !== "Stopped" || frameIndex !== 0) + ) { + playback.stop(); } - }, [simulationState, playback]); + }, [simulationState, playState, frameIndex, playback]); // Backpressure ack — based on play mode. const prevTotalFramesRef = useRef(totalFrames); @@ -216,7 +174,7 @@ export const PlaybackProvider: React.FC = ({ // rAF loop — drive playback ticks while Playing. useEffect(() => { - if (!playback || playState !== "Playing") { + if (playState !== "Playing") { return; } @@ -253,31 +211,22 @@ export const PlaybackProvider: React.FC = ({ snapshotRef, ]); - // - // Actions - // - - // Simulation control is gated only on `mode` (not on the React-mirrored - // simulation state). The simulation handle's `pause`/`run` are idempotent at - // the worker level, and the React-mirrored state lags behind worker reality - // — gating on it caused the "first pause doesn't pause sim generation" - // class of bug where simState was momentarily out of sync with the worker. - const setCurrentViewedFrame: PlaybackContextValue["setCurrentViewedFrame"] = ( index, ) => { - playback?.setFrameIndex(index, totalFramesRef.current); + playback.setFrameIndex(index, totalFramesRef.current); }; const play: PlaybackContextValue["play"] = async () => { - if (!playback) { - return; - } const simState = simulationStateRef.current; - const currentMode = snapshotRef.current.mode; - const cfg = getPlayModeBackpressure(currentMode); + const currentMode = getCurrentMode(); + const computeMode = getCurrentComputeMode(); + const cfg = getPlayModeBackpressure(computeMode); if (simState === "NotRun") { + if (snapshotRef.current.mode !== computeMode) { + playback.setMode(computeMode); + } await initialize({ seed: Date.now(), dt: dtRef.current, @@ -300,6 +249,7 @@ export const PlaybackProvider: React.FC = ({ // a no-op if it's already running, so it's safe to call regardless of // the React-mirrored simulation state. if (currentMode !== "viewOnly") { + setBackpressure(cfg); runSimulation(); } @@ -312,37 +262,24 @@ export const PlaybackProvider: React.FC = ({ }; const pause: PlaybackContextValue["pause"] = () => { - if (!playback) { - return; - } - if (snapshotRef.current.mode !== "viewOnly") { - pauseSimulation(); - } + pauseSimulationIfComputing(); playback.pause(); }; const stop: PlaybackContextValue["stop"] = () => { - if (!playback) { - return; - } - if (snapshotRef.current.mode !== "viewOnly") { - pauseSimulation(); - } + pauseSimulationIfComputing(); playback.stop(); }; const setPlaybackSpeed: PlaybackContextValue["setPlaybackSpeed"] = ( nextSpeed: PlaybackSpeed, ) => { - playback?.setSpeed(nextSpeed); + playback.setSpeed(nextSpeed); }; const setPlayMode: PlaybackContextValue["setPlayMode"] = ( nextMode: PlayMode, ) => { - if (!playback) { - return; - } if (nextMode === "viewOnly" && !isViewOnlyAvailable) { return; } @@ -352,20 +289,22 @@ export const PlaybackProvider: React.FC = ({ const isPlaying = snapshotRef.current.playState === "Playing"; - if (nextMode !== "viewOnly" && isPlaying) { - runSimulation(); - } if (nextMode === "viewOnly") { pauseSimulation(); + } else { + setBackpressure(getPlayModeBackpressure(nextMode)); + if (isPlaying) { + runSimulation(); + } } 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..6f7c9a2ba20 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/context.ts +++ b/libs/@hashintel/petrinaut/src/react/simulation/context.ts @@ -1,22 +1,20 @@ import { createContext } from "react"; -import type { CompiledScenarioResult } from "../../core/simulation/compile-scenario"; +import type { CompiledScenarioResult } from "../../core/simulation/authoring/scenario/compile-scenario"; import type { InitialMarking, - SimulationFrame, + InitialPlaceMarking, + 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, + InitialPlaceMarking, + 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. @@ -139,10 +137,7 @@ export type SimulationContextValue = { // Actions setSelectedScenarioId: (scenarioId: string | null) => void; setScenarioParameterValue: (identifier: string, value: string) => void; - setInitialMarking: ( - placeId: string, - marking: { values: Float64Array; count: number }, - ) => void; + setInitialMarking: (placeId: string, marking: InitialPlaceMarking) => void; setParameterValue: (parameterId: string, value: string) => void; setDt: (dt: number) => void; /** @@ -190,7 +185,7 @@ const DEFAULT_CONTEXT_VALUE: SimulationContextValue = { error: null, errorItemId: null, parameterValues: {}, - initialMarking: new Map(), + initialMarking: {}, selectedScenarioId: null, scenarioParameterValues: {}, compiledScenarioResult: null, diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index 840968ea70c..38a8d43399e 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/scenario/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"; @@ -42,7 +42,7 @@ type SimulationStateValues = { const INITIAL_STATE_VALUES: SimulationStateValues = { parameterValues: {}, - initialMarking: new Map(), + initialMarking: {}, selectedScenarioId: null, scenarioParameterValues: {}, dt: 0.01, @@ -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 = { @@ -117,7 +117,7 @@ export const SimulationProvider: React.FC = ({ workerFactory, }) => { const sdcpnContext = use(SDCPNContext); - const { petriNetId, petriNetDefinition } = sdcpnContext; + const { petriNetDefinition } = sdcpnContext; const petriNetDefinitionRef = useLatest(petriNetDefinition); const workerFactoryRef = useLatest(workerFactory ?? createSimulationWorker); @@ -148,14 +148,12 @@ export const SimulationProvider: React.FC = ({ const coreStatus = useStore(simulation?.status ?? EMPTY_STATUS_STORE); const frameSummary = useStore(simulation?.frames ?? EMPTY_FRAMES_STORE); - // When the simulation changes, wire up its events stream for error - // surfacing and clear stale error state. + // When the simulation changes, wire up its events stream for errors and + // completion notifications. useEffect(() => { if (!simulation) { return; } - setError(null); - setErrorItemId(null); const off = simulation.events.subscribe((event) => { if (event.type === "error") { setError(event.message); @@ -183,18 +181,6 @@ export const SimulationProvider: React.FC = ({ return off; }, [simulation]); - // Reinitialize when petriNetId changes — drop any active simulation and - // reset configuration to defaults. - useEffect(() => { - setSimulation((prev) => { - prev?.dispose(); - return null; - }); - setStateValues(INITIAL_STATE_VALUES); - setError(null); - setErrorItemId(null); - }, [petriNetId]); - // Dispose on unmount. useEffect(() => { return () => { @@ -206,8 +192,25 @@ export const SimulationProvider: React.FC = ({ // Actions // + const invalidateSimulationForConfigurationChange = (): void => { + const current = simulationRef.current; + if (!current) { + return; + } + + current.dispose(); + simulationRef.current = null; + setSimulation(null); + setError(null); + setErrorItemId(null); + }; + const setSelectedScenarioId: SimulationContextValue["setSelectedScenarioId"] = (scenarioId) => { + if (stateValuesRef.current.selectedScenarioId !== scenarioId) { + invalidateSimulationForConfigurationChange(); + } + setStateValues((prev) => { // Initialize scenario parameter values from the scenario's defaults const scenarioParameterValues: Record = {}; @@ -231,6 +234,12 @@ export const SimulationProvider: React.FC = ({ const setScenarioParameterValue: SimulationContextValue["setScenarioParameterValue"] = (identifier, value) => { + if ( + stateValuesRef.current.scenarioParameterValues[identifier] !== value + ) { + invalidateSimulationForConfigurationChange(); + } + setStateValues((prev) => ({ ...prev, scenarioParameterValues: { @@ -244,10 +253,16 @@ export const SimulationProvider: React.FC = ({ placeId, marking, ) => { + invalidateSimulationForConfigurationChange(); + setStateValues((prev) => { - const newMarking = new Map(prev.initialMarking); - newMarking.set(placeId, marking); - return { ...prev, initialMarking: newMarking }; + return { + ...prev, + initialMarking: { + ...prev.initialMarking, + [placeId]: marking, + }, + }; }); }; @@ -255,6 +270,10 @@ export const SimulationProvider: React.FC = ({ parameterId, value, ) => { + if (stateValuesRef.current.parameterValues[parameterId] !== value) { + invalidateSimulationForConfigurationChange(); + } + setStateValues((prev) => ({ ...prev, parameterValues: { @@ -265,10 +284,18 @@ export const SimulationProvider: React.FC = ({ }; const setDt: SimulationContextValue["setDt"] = (dt) => { + if (stateValuesRef.current.dt !== dt) { + invalidateSimulationForConfigurationChange(); + } + setStateValues((prev) => ({ ...prev, dt })); }; const setMaxTime: SimulationContextValue["setMaxTime"] = (maxTime) => { + if (stateValuesRef.current.maxTime !== maxTime) { + invalidateSimulationForConfigurationChange(); + } + setStateValues((prev) => ({ ...prev, maxTime })); }; @@ -296,21 +323,32 @@ export const SimulationProvider: React.FC = ({ // Update local dt setStateValues((prev) => ({ ...prev, dt })); - const sim = await createSimulation({ - sdcpn, - // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render - initialMarking: effectiveInitialMarkingRef.current, - // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render - parameterValues: effectiveParameterValuesRef.current, - seed, - dt, - maxTime: currentState.maxTime, - backpressure: - maxFramesAhead !== undefined || batchSize !== undefined - ? { maxFramesAhead, batchSize } - : undefined, - createWorker: workerFactoryRef.current, - }); + let sim: Simulation; + try { + sim = await createSimulation({ + sdcpn, + // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render + initialMarking: effectiveInitialMarkingRef.current, + // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render + parameterValues: effectiveParameterValuesRef.current, + seed, + dt, + maxTime: currentState.maxTime, + backpressure: + maxFramesAhead !== undefined || batchSize !== undefined + ? { maxFramesAhead, batchSize } + : undefined, + createWorker: workerFactoryRef.current, + }); + } catch (caught) { + const message = + caught instanceof Error + ? caught.message + : "Failed to initialize simulation"; + setError(message); + setErrorItemId(null); + throw caught; + } // Write the ref synchronously *before* setSimulation so a same-tick // caller (e.g. PlaybackProvider's `play()` chains `runSimulation()` @@ -338,10 +376,9 @@ export const SimulationProvider: React.FC = ({ parameterValues[key] = String(value); } - setSimulation((prev) => { - prev?.dispose(); - return null; - }); + simulationRef.current?.dispose(); + simulationRef.current = null; + setSimulation(null); setError(null); setErrorItemId(null); @@ -365,7 +402,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 +415,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 +437,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) { @@ -452,16 +489,10 @@ export const SimulationProvider: React.FC = ({ effectiveParameterValues = compiledScenarioResult.parameterValues; // Merge compiled scenario initial state on top of manual markings. - const mergedMarking: InitialMarking = new Map(stateValues.initialMarking); - for (const [placeId, marking] of Object.entries( - compiledScenarioResult.initialState, - )) { - mergedMarking.set(placeId, { - values: new Float64Array(marking.values), - count: marking.count, - }); - } - effectiveInitialMarking = mergedMarking; + effectiveInitialMarking = { + ...stateValues.initialMarking, + ...compiledScenarioResult.initialState, + }; } // Keep refs to effective values so `initialize` uses scenario-overridden diff --git a/libs/@hashintel/petrinaut/src/ui/components/panel-primitives.tsx b/libs/@hashintel/petrinaut/src/ui/components/panel-primitives.tsx index e2509ae07fa..c71bef8625c 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/panel-primitives.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/panel-primitives.tsx @@ -122,6 +122,7 @@ export const Header = ({ const bodyStyle = css({ padding: "5", + paddingTop: "0", overflowY: "auto", flex: "[1]", }); 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/monaco/code-editor.tsx b/libs/@hashintel/petrinaut/src/ui/monaco/code-editor.tsx index 8ea235542cd..5df3ca8cf55 100644 --- a/libs/@hashintel/petrinaut/src/ui/monaco/code-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/monaco/code-editor.tsx @@ -9,7 +9,7 @@ import { MonacoContext } from "./context"; // -- Single-line constants ---------------------------------------------------- -const SINGLE_LINE_HEIGHT = 18; +const SINGLE_LINE_HEIGHT = 16; const SINGLE_LINE_PADDING_Y = 6; const SINGLE_LINE_TOTAL_HEIGHT = SINGLE_LINE_HEIGHT + SINGLE_LINE_PADDING_Y * 2; @@ -214,6 +214,7 @@ const CodeEditorInner: React.FC = ({ contextmenu: false, suggest: { showStatusBar: false }, ...options, + tabFocusMode: true, } : { minimap: { enabled: false }, diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index 6a62fefbc75..2af26cfacd4 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -1,19 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useEffect, useMemo, useState } from "react"; -import { createJsonDocHandle } from "../core/handle"; -import type { SDCPN } from "../core/types/sdcpn"; import { sirModel } from "../examples/sir-model"; import { PetrinautStoryProvider } from "./petrinaut-story-provider"; -import { Petrinaut } from "../ui/petrinaut"; - -const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], -}; const meta = { title: "Petrinaut", @@ -51,74 +39,3 @@ export const HiddenNetManagement: Story = { ), }; - -const HandleSpikeRender = ({ - initial, - initialTitle, -}: { - initial: SDCPN; - initialTitle: string; -}) => { - const handle = useMemo( - () => createJsonDocHandle({ id: "spike-net", initial }), - [initial], - ); - - const [patchLog, setPatchLog] = useState([]); - const [title, setTitle] = useState(initialTitle); - - useEffect(() => { - return handle.subscribe((event) => { - const summary = (event.patches ?? []).map( - (p) => `${p.op} /${p.path.join("/")}`, - ); - setPatchLog((prev) => [...summary, ...prev].slice(0, 12)); - }); - }, [handle]); - - return ( -
- -
-        {`Last ${patchLog.length} patches (newest first):\n` +
-          (patchLog.length === 0 ? "(no mutations yet)" : patchLog.join("\n"))}
-      
-
- ); -}; - -export const HandleSpike: Story = { - render: () => ( - - ), -}; - -export const HandleSpikeWithSir: Story = { - render: () => ( - - ), -}; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 61575ecae6d..afa6eb9bbe1 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -37,6 +37,11 @@ export type PetrinautProps = { * dist consumers. */ simulationWorkerFactory?: WorkerFactory; + /** + * Optional Monte Carlo worker factory. Hosts can provide this when they need + * to own worker bundling for the Experiments tab. + */ + monteCarloWorkerFactory?: WorkerFactory; /** * Optional language-server worker factory. Same intent as * `simulationWorkerFactory` — host-supplied LSP worker, typically via @@ -66,6 +71,7 @@ export const Petrinaut: FunctionComponent = ({ loadPetriNet = noop, viewportActions, simulationWorkerFactory, + monteCarloWorkerFactory, lspWorkerFactory, }) => { const instance = useMemo( @@ -88,6 +94,7 @@ export const Petrinaut: FunctionComponent = ({ instance={instance} netManagement={netManagement} simulationWorkerFactory={simulationWorkerFactory} + monteCarloWorkerFactory={monteCarloWorkerFactory} lspWorkerFactory={lspWorkerFactory} > 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..ccd0240d94f 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 @@ -50,7 +50,7 @@ const PlaybackSettingsMenuStory = ({ error: null, errorItemId: null, parameterValues: {}, - initialMarking: new Map(), + initialMarking: {}, selectedScenarioId: null, scenarioParameterValues: {}, compiledScenarioResult: null, @@ -76,7 +76,7 @@ const PlaybackSettingsMenuStory = ({ > = ({ disabled = false, }) => { - const { state: simulationState, reset } = use(SimulationContext); + const { dt, state: simulationState, reset } = use(SimulationContext); const { currentViewedFrame, @@ -99,7 +99,7 @@ export const SimulationControls: React.FC = ({ const isSimulationErrored = simulationState === "Error"; const isPlaybackPlaying = playbackState === "Playing"; const frameIndex = currentFrameIndex; - const elapsedTime = currentViewedFrame?.time ?? 0; + const elapsedTime = currentViewedFrame ? frameIndex * dt : 0; // Disable play button when at the last frame and simulation is complete or errored const isAtLastFrame = totalFrames > 0 && frameIndex >= totalFrames - 1; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 1bcad5720dc..056bda3c438 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -14,12 +14,12 @@ import { PlaybackContext } from "../../../../../../react/playback/context"; import { type CompiledMetric, compileMetric, -} from "../../../../../../core/simulation/compile-metric"; +} from "../../../../../../core/simulation/authoring/metric/compile-metric"; import { SimulationContext, - type SimulationFrame, + type SimulationFrameReader, } from "../../../../../../react/simulation/context"; -import { buildMetricState } from "../../../../../../core/simulation/metric-state"; +import { buildMetricState } from "../../../../../../core/simulation/frames/metric-state"; import { EditorContext, type TimelineChartType, @@ -318,7 +318,10 @@ function createEmptyStore(places: PlaceMeta[]): StreamingStore { * A single extractor returns the value for series `seriesIdx` at the given * frame. Returning NaN leaves a gap on the chart. */ -type SeriesExtractor = (frame: SimulationFrame, seriesIdx: number) => 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/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..b6952bdc802 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx @@ -0,0 +1,173 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { use, useState } from "react"; +import { Button } from "../../../../../../components/button"; +import { SegmentGroup } from "../../../../../../components/segment-group"; +import { Select } from "../../../../../../components/select"; + +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/create-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx index a37ada5309e..eb26dcc1a24 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 @@ -1,4 +1,3 @@ -import { css } from "@hashintel/ds-helpers/css"; import { useStore } from "@tanstack/react-form"; import { use } from "react"; @@ -6,7 +5,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/metric/compile-metric"; import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { @@ -20,13 +19,6 @@ import { summarizeMetricLspErrors } from "./metric-lsp"; import { buildMetricFromFormState } from "./metric-mapping"; import { ScenarioErrorDisplay } from "./scenario-error-display"; -const bodyStyle = css({ - overflowY: "auto", - paddingX: "5", - paddingY: "[0]", - flex: "1", -}); - // -- Footer ------------------------------------------------------------------- const CreateMetricFooter = ({ @@ -145,7 +137,7 @@ export const CreateMetricDrawer = ({ Create a metric - + { } return ( - + = 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 +