diff --git a/.config/oxlint/react-compiler.json b/.config/oxlint/react-compiler.json new file mode 100644 index 00000000000..90b6d6a9dc4 --- /dev/null +++ b/.config/oxlint/react-compiler.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "jsPlugins": [ + { + "name": "react-hooks-js", + "specifier": "eslint-plugin-react-hooks" + } + ], + "rules": { + "react/rules-of-hooks": "off", + "react/exhaustive-deps": "off", + "react-hooks-js/rules-of-hooks": "error", + "react-hooks-js/exhaustive-deps": "error", + "react-hooks-js/static-components": "warn", + "react-hooks-js/use-memo": "warn", + "react-hooks-js/void-use-memo": "warn", + "react-hooks-js/component-hook-factories": "warn", + "react-hooks-js/preserve-manual-memoization": "warn", + "react-hooks-js/incompatible-library": "warn", + "react-hooks-js/immutability": "warn", + "react-hooks-js/globals": "warn", + "react-hooks-js/refs": "warn", + "react-hooks-js/set-state-in-effect": "warn", + "react-hooks-js/error-boundaries": "warn", + "react-hooks-js/purity": "warn", + "react-hooks-js/set-state-in-render": "warn", + "react-hooks-js/unsupported-syntax": "warn", + "react-hooks-js/config": "warn", + "react-hooks-js/gating": "warn" + } +} diff --git a/apps/petrinaut-website/.oxlintrc.json b/apps/petrinaut-website/.oxlintrc.json index 33073c8c929..56e080ee777 100644 --- a/apps/petrinaut-website/.oxlintrc.json +++ b/apps/petrinaut-website/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.config/oxlint/react-compiler.json"], "plugins": ["import", "react", "jsx-a11y", "unicorn", "typescript"], "categories": { "correctness": "error" @@ -82,9 +83,6 @@ { "button": true, "submit": true, "reset": false } ], - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "off", - "jsx-a11y/prefer-tag-over-role": "off", "jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }], "jsx-a11y/no-noninteractive-tabindex": [ diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 1fb5ab8dedb..a16dad7bb10 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -23,11 +23,11 @@ "@rolldown/plugin-babel": "0.2.1", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260315.1", + "@typescript/native-preview": "7.0.0-dev.20260507.1", "@vitejs/plugin-react": "6.0.1", "babel-plugin-react-compiler": "1.0.0", - "oxlint": "1.55.0", - "oxlint-tsgolint": "0.17.0", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", "vite": "8.0.10" } } diff --git a/libs/@hashintel/petrinaut/.oxlintrc.json b/libs/@hashintel/petrinaut/.oxlintrc.json index 504286a2d33..baaa6c1c834 100644 --- a/libs/@hashintel/petrinaut/.oxlintrc.json +++ b/libs/@hashintel/petrinaut/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../.config/oxlint/react-compiler.json"], "plugins": ["import", "react", "jsx-a11y", "unicorn", "typescript"], "categories": { "correctness": "error" @@ -94,9 +95,6 @@ { "button": true, "submit": true, "reset": false } ], - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "off", - "jsx-a11y/prefer-tag-over-role": "off", "jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }], "jsx-a11y/no-noninteractive-tabindex": [ diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index bb1ce3148f6..6cc2a7d75ba 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -91,18 +91,18 @@ "@types/lodash-es": "4.17.12", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260315.1", + "@typescript/native-preview": "7.0.0-dev.20260507.1", "@vitejs/plugin-react": "6.0.1", "babel-plugin-react-compiler": "1.0.0", "jsdom": "24.1.3", - "oxlint": "1.55.0", - "oxlint-tsgolint": "0.17.0", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", "react": "19.2.3", "react-dom": "19.2.3", - "rolldown": "1.0.0-rc.9", - "rolldown-plugin-dts": "0.22.5", - "storybook": "10.2.19", - "vite": "8.0.10", + "rolldown": "1.0.0", + "rolldown-plugin-dts": "0.25.0", + "storybook": "10.3.6", + "vite": "8.0.11", "vitest": "4.1.5" }, "peerDependencies": { diff --git a/libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md b/libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md new file mode 100644 index 00000000000..8f8a75363d9 --- /dev/null +++ b/libs/@hashintel/petrinaut/rfc/0002-simulation-engine-v2/README.md @@ -0,0 +1,305 @@ +# RFC 0002 — Petrinaut: Simulation Engine v2 + +**Status:** Draft +**Authors:** @cf +**Created:** 2026-05-08 +**Last updated:** 2026-05-08 +**Tracking issue:** FE-666 + +--- + +## Summary + +Rework Petrinaut simulation so the worker is fast, memory-efficient, stable to +talk to, and capable of running much longer simulations than the current +frame-history model allows. + +The current implementation is useful for playback, but it couples the simulator +to full-frame retention, exposes buffer layout through the public API, and makes +the worker protocol depend on streaming complete `SimulationFrame` objects. This +RFC proposes separating simulation stepping, storage, and presentation. + +## Objectives + +- Make simulation faster and reduce memory footprint. +- Keep the worker's internal simulation representation binary-first and compact. +- Avoid JSON/object wrappers inside hot simulation state. +- Stop exposing internal in-memory or storage representation through public + simulator interfaces. +- Make the communication protocol more stable and versionable. +- Allow many more frames to be computed by writing chunks to storage and keeping + only a current in-memory window. +- Keep all protocol messages JSON-communicable: no `Map`, class instances, or + non-serializable values in message shapes. +- Support stochastic simulation properly in future: deterministic seeding, + reproducible sampling, and efficient multi-run/ensemble modes. +- Allow a mode that runs every frame through a single reusable buffer when + frame history is not needed. + +## Non-Goals For This Draft + +- Final storage schema. +- Final browser persistence implementation. +- Full experiment UI design. +- Rewriting the SDCPN authoring model. +- Choosing every binary layout detail up front. + +## Current Problems + +### Full Frame History Is Hard-Coded + +The low-level `SimulationInstance` stores `frames: SimulationFrame[]`, and +`computeNextFrame` appends a new frame on every step. The public +`createSimulation` handle also stores all received frames. This duplicates +memory and makes memory use grow with simulation duration. + +This shape prevents efficient modes like: + +- compute and discard; +- keep latest frame only; +- keep a sliding playback window; +- stream aggregate chunks to storage; +- run many stochastic replications in parallel. + +### Public API Leaks Internal Layout + +`SimulationFrame` exposes place offsets, dimensions, and a shared +`Float64Array` buffer. That is an internal storage layout, but it is currently +part of the consumer contract. + +Consumers should ask for domain-level outputs: frame metadata, place token +counts, aggregate series, final marking, or a decoded token view. They should +not need to know the engine's buffer layout. + +### Static Model Data Is Repeated Per Frame + +Transition frame state currently includes the transition `instance`. Static +SDCPN model data should live outside frame payloads. Per-frame state should only +contain values that change over time. + +### Worker Protocol Is Too Playback-Shaped + +The protocol primarily sends `frame` and `frames` messages. It lacks concepts +for output mode, run identity, protocol version, aggregate chunks, storage +checkpoints, final-frame-only output, or multiple simulations running through +one coordinator. + +### `ready` Is Ambiguous + +The worker currently uses a `ready` message both for worker boot and simulation +initialization. These should be separate protocol events, preferably tied to a +run id. + +### Backpressure Is Too Low-Level + +`ack(frameNumber)` assumes frame playback. For persistence-heavy or +aggregate-only runs, the useful acknowledgement may be "chunk persisted" or +"window consumed." Backpressure should be expressed in terms of the chosen +output sink. + +### `reset()` Semantics Are Confusing + +The public `reset()` sends `stop`, clears local frames, and sets status to +`Ready`, but the worker has discarded the simulation. The API should distinguish +stop/dispose/reinitialize clearly. + +### Status And Error State Are Split + +`status` is a store, but error details and completion reason are one-shot +events. Late subscribers cannot recover the current error or completion +metadata. A single snapshot store would be easier to consume safely. + +## Proposed Direction + +### 1. Separate Engine State From Retention + +The simulator should advance from the current state to the next state without +owning a history array. + +Conceptually: + +```ts +type StepResult = { + frameNumber: number; + time: number; + completionReason: "deadlock" | "maxTime" | null; +}; + +step(engineState): StepResult; +``` + +The engine can keep current binary state internally. History retention becomes +a policy outside the hot stepping path. + +### 2. Add Explicit Output Policies + +Simulation creation should declare what outputs are produced and retained. + +Example shape: + +```ts +type SimulationOutputPolicy = + | { kind: "none" } + | { kind: "latestFrame" } + | { kind: "frameWindow"; maxFrames: number } + | { kind: "frameChunks"; chunkSize: number } + | { kind: "placeTokenCounts"; chunkSize: number } + | { kind: "placeTokenMeans"; chunkSize: number }; +``` + +This avoids pretending every consumer wants full frames forever. + +### 3. Make Protocol Messages Versioned And JSON-Communicable + +All protocol messages should be plain JSON-compatible objects plus transferable +binary payloads when needed. + +No `Map`, class instances, function values, or object graphs that depend on +prototype behavior. + +Example: + +```ts +type SimulationProtocolMessage = { + protocolVersion: 2; + runId: string; + type: string; +}; +``` + +Binary data should be carried in `ArrayBuffer`/typed-array payloads with a +documented layout. Message metadata should describe the layout rather than +requiring consumers to infer it from engine internals. + +### 4. Separate Public Domain Views From Internal Binary Layout + +The worker may use compact typed arrays internally, but public APIs should +expose stable domain-oriented views. + +Potential public surfaces: + +- `getLatestFrame()` +- `getFrameWindow()` +- `getFinalFrame()` +- `subscribeToSeries()` +- `readChunk(chunkId)` +- `getRunSnapshot()` + +These should not expose the internal memory layout used by the stepping engine. + +### 5. Support Storage Sinks + +Long simulations should be able to write chunks to storage while retaining only +a small window in memory. + +The likely browser shape: + +- IndexedDB for run metadata and chunk indexes. +- IndexedDB or OPFS for binary chunk payloads. +- Worker-owned chunk production. +- Host-owned persistence acknowledgement. + +The simulation should be able to continue after a chunk has been persisted and +acknowledged, without retaining the full history in memory. + +### 6. Prepare For Stochastic Experiments + +The v2 interface should make stochastic simulation a first-class future path. + +Needed foundations: + +- explicit base seed; +- deterministic per-run seed derivation; +- reproducible random sampling from engine state; +- output modes that aggregate across replications; +- single-buffer stepping for runs where only aggregates or final frame matter; +- enough metadata to replay or audit a run. + +This does not require implementing ensemble simulation immediately, but the +protocol and engine boundaries should not block it. + +## Sketch: Simulation v2 Handle + +```ts +type SimulationSnapshot = { + runId: string; + status: "initializing" | "ready" | "running" | "paused" | "complete" | "error"; + frameNumber: number; + time: number; + error: { message: string; itemId: string | null } | null; + completionReason: "deadlock" | "maxTime" | null; +}; + +interface SimulationV2 { + readonly snapshot: ReadableStore; + readonly events: EventStream; + + start(this: void): void; + pause(this: void): void; + stop(this: void): void; + dispose(this: void): void; + + acknowledge(this: void, ack: SimulationAck): void; +} +``` + +Open question: whether frame and chunk reads belong on the handle itself or on +a separate result store abstraction. + +## Sketch: Worker Protocol v2 + +Message families: + +- `worker.ready` +- `run.init` +- `run.ready` +- `run.start` +- `run.pause` +- `run.stop` +- `run.snapshot` +- `run.output.chunk` +- `run.output.finalFrame` +- `run.complete` +- `run.error` +- `run.ack` + +Every message should include: + +- `protocolVersion` +- `runId` +- `type` + +Output chunks should include: + +- frame range; +- time range; +- output kind; +- binary layout id; +- transferable buffers; +- chunk id for persistence acknowledgement. + +## Migration Notes + +- Keep the current playback UI working by implementing it as one output policy: + a bounded frame window or all-frames mode during the transition. +- Introduce v2 protocol alongside v1 if needed, rather than silently changing + message meanings. +- Move current full-frame history behavior behind an explicit compatibility + option. +- Treat `reset()` as a breaking API point: rename to `stop()` or make it truly + reinitialize. +- Avoid publishing internal engine types as public result types. + +## Open Questions + +- What is the default output policy for editor playback? +- Should storage be owned by the worker, the host, or an injected sink? +- Should the core package ship an IndexedDB/OPFS sink, or only define the sink + interface? +- How large should frame/chunk windows be by default? +- Do aggregate outputs use `Float32Array` or `Float64Array`? +- How do we represent places and transitions in binary layouts while preserving + stable IDs? +- Should ensemble/stochastic experiments be a separate API from single + simulation playback? +- What protocol compatibility guarantees do we want for external consumers? diff --git a/libs/@hashintel/petrinaut/src/core/index.ts b/libs/@hashintel/petrinaut/src/core/index.ts index 0083cc24aac..3f7e6ab99d2 100644 --- a/libs/@hashintel/petrinaut/src/core/index.ts +++ b/libs/@hashintel/petrinaut/src/core/index.ts @@ -40,18 +40,16 @@ export type { SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, SimulationFrameSummary, + SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, -} from "./simulation"; -export type { InitialMarking, - SimulationFrame, - SimulationFrameState, - SimulationFrameState_Place, - SimulationFrameState_Transition, -} from "./simulation/types"; +} from "./simulation"; // --- LSP --- export { @@ -75,6 +73,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..ede27e8ca3d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/ARCHITECTURE.md @@ -0,0 +1,35 @@ +# Simulation Architecture + +The simulation module is split into four boundaries: + +- `api.ts` defines the public Core contract. Consumers receive + `SimulationFrameReader` and summary state, not engine storage objects. +- `authoring/engine/` compiles SDCPN definitions and advances internal + `EngineFrame` state. This code may use mutable/compact structures optimized + for stepping. +- `worker/` owns the transport protocol between the engine worker and runtime. + Worker messages use protocol payloads such as `SimulationFramePayload`, not + engine types directly. +- `runtime/` owns lifecycle and retention. It stores protocol payloads through a + `SimulationFrameStore` and returns `SimulationFrameReader` instances. + +Current data flow: + +```text +SDCPN snapshot + -> buildSimulation() + -> EngineFrame + -> SimulationFramePayload + -> SimulationFrameStore + -> SimulationFrameReader +``` + +`EngineFrame` is not a public API or stable storage format. It currently uses +records keyed by IDs and a shared `Float64Array` for token values. Future binary +work should happen behind the worker payload and frame-store boundaries so UI +and React consumers keep using the same reader interface. + +Retention is intentionally isolated in `runtime/frame-store.ts`. The current +store keeps every full frame in memory for compatibility. Future stores can keep +only the latest frame, a sliding window, aggregate chunks, or persisted binary +payloads without changing the public simulation handle. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/README.md b/libs/@hashintel/petrinaut/src/core/simulation/README.md index 5b851794c39..d8a158fe235 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/README.md @@ -1,79 +1,142 @@ # 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/`: user-authored metric/scenario compilation and sandboxing. +- `worker/`: worker protocol and runtime entrypoint. +- `engine/`: internal SDCPN execution engine. ## Simulation State ```typescript -type SimulationState = 'NotRun' | 'Paused' | 'Running' | 'Complete' | 'Error'; +type SimulationState = + | "Initializing" + | "Ready" + | "Running" + | "Paused" + | "Complete" + | "Error"; ``` -| WorkerStatus | SimulationState | -| ------------------------ | --------------- | -| `idle`, `initializing` | `NotRun` | -| `ready`, `paused` | `Paused` | -| `running` | `Running` | -| `complete` | `Complete` | -| `error` | `Error` | +| State | Description | +| -------------- | ----------------------------------------------------- | +| `Initializing` | Worker or transport is booting and compiling the run. | +| `Ready` | Simulation is initialized and ready to run. | +| `Running` | Frames are being computed. | +| `Paused` | Computation is paused; frame history is retained. | +| `Complete` | Simulation ended because of deadlock or max time. | +| `Error` | Initialization or computation failed. | ## Configuration -| Property | Default | Description | -| ----------------- | ----------- | ------------------------------------------ | -| `parameterValues` | `{}` | User-defined parameters | -| `initialMarking` | `new Map()` | Initial token placement | -| `dt` | `0.01` | Time step in seconds | -| `maxTime` | `null` | Simulation end time (immutable after init) | +| Property | Description | +| ----------------- | -------------------------------------------------- | +| `sdcpn` | SDCPN snapshot to simulate. | +| `initialMarking` | Initial token placement. | +| `parameterValues` | Parameter values overriding SDCPN defaults. | +| `seed` | Seed for deterministic stochastic behavior. | +| `dt` | Time step in seconds. | +| `maxTime` | Maximum simulation time. `null` disables it. | +| `backpressure` | Optional worker frame-ahead and batch settings. | +| `signal` | Optional abort signal for initialization/teardown. | + +Provide exactly one execution transport: + +- `createWorker`: a factory returning a `Worker` or `Promise`. +- `transport`: a pre-built opaque `SimulationTransport` for tests or custom + worker adapters. ## 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..7015a70b9e8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/api.ts @@ -0,0 +1,174 @@ +import type { ReadableStore } from "../handle"; +import type { EventStream } from "../instance"; +import type { Color, Place, SDCPN } from "../types/sdcpn"; + +export type SimulationState = + | "Initializing" + | "Ready" + | "Running" + | "Paused" + | "Complete" + | "Error"; + +export type BackpressureConfig = { + /** Maximum frames the worker can compute ahead before waiting for ack. */ + maxFramesAhead?: number; + /** Number of frames to compute in each batch before checking for messages. */ + batchSize?: number; +}; + +export interface SimulationTransport { + /** Send a message to the engine. May queue if the transport is not yet ready. */ + send(message: unknown): void; + /** Subscribe to messages from the engine. Returns an unsubscribe function. */ + onMessage(listener: (message: unknown) => void): () => void; + /** Tear down the underlying worker / runtime. Idempotent. */ + terminate(): void; +} + +export type WorkerFactory = () => Worker | Promise; + +/** + * Initial token distribution for starting a simulation. + * Maps place IDs to their initial token values and counts. + */ +export type InitialMarking = Map< + string, + { values: Float64Array; count: number } +>; + +/** + * Common per-run config shared by both transport modes. The simulation runs + * against the {@link sdcpn} snapshot and never reads it again, so subsequent + * mutations to the source document don't affect a running simulation. + */ +export type SimulationConfig = { + sdcpn: SDCPN; + initialMarking: InitialMarking; + parameterValues: Record; + seed: number; + dt: number; + /** Maximum simulation time. Null = no limit. */ + maxTime: number | null; + backpressure?: BackpressureConfig; + /** Optional cancellation. Aborting tears down the simulation. */ + signal?: AbortSignal; +}; + +/** + * Top-level config for `createSimulation`. Provide exactly one of: + * + * - `createWorker`: a `Worker` factory; the function builds a transport for you. + * - `transport`: a pre-built {@link SimulationTransport}; ownership transfers + * to the simulation (it will be terminated on `simulation.dispose()`). + */ +export type CreateSimulationConfig = SimulationConfig & + ( + | { createWorker: WorkerFactory; transport?: never } + | { transport: SimulationTransport; createWorker?: never } + ); + +/** + * State of a transition within a simulation frame. + * + * Contains timing information and firing counts for tracking transition behavior + * during simulation execution. + */ +export type SimulationFrameState_Transition = { + /** + * Time elapsed since this transition last fired, in milliseconds. + * Resets to 0 when the transition fires. + */ + timeSinceLastFiringMs: number; + /** + * Whether this transition fired in this specific frame. + * True only during the frame when the firing occurred. + */ + firedInThisFrame: boolean; + /** + * Total cumulative count of times this transition has fired + * since the start of the simulation (frame 0). + */ + firingCount: number; +}; + +/** + * Simplified view of a simulation frame for higher-level consumers. + * Provides easy access to place and transition states without internal details. + */ +export type SimulationFrameState = { + /** Frame index in the simulation history */ + number: number; + /** Simulation time at this frame */ + time: number; + /** Place states indexed by place ID */ + places: { + [placeId: string]: + | { + /** Number of tokens in the place at the time of the frame. */ + tokenCount: number; + } + | undefined; + }; + /** Transition states indexed by transition ID */ + transitions: { + [transitionId: string]: SimulationFrameState_Transition | undefined; + }; +}; + +export type SimulationPlaceTokenValues = { + values: Float64Array; + count: number; +}; + +export interface SimulationFrameReader { + /** Frame index in the simulation history. */ + readonly number: number; + /** Simulation time at this frame. */ + readonly time: number; + + getPlaceTokenCount(placeId: string): number; + getPlaceTokenValues(placeId: string): SimulationPlaceTokenValues | null; + getPlaceTokens( + place: Place, + color: Color | null | undefined, + ): Record[]; + getTransitionState( + transitionId: string, + ): SimulationFrameState_Transition | null; + toFrameState(): SimulationFrameState; +} + +export type SimulationCompleteEvent = { + type: "complete"; + reason: "deadlock" | "maxTime"; + frameNumber: number; +}; + +export type SimulationErrorEvent = { + type: "error"; + message: string; + itemId: string | null; +}; + +export type SimulationEvent = SimulationCompleteEvent | SimulationErrorEvent; + +export type SimulationFrameSummary = { + count: number; + latest: SimulationFrameReader | null; +}; + +export interface Simulation { + readonly status: ReadableStore; + readonly frames: ReadableStore; + readonly events: EventStream; + + run(this: void): void; + pause(this: void): void; + reset(this: void): void; + ack(this: void, frameNumber: number): void; + setBackpressure(this: void, cfg: BackpressureConfig): void; + getFrame(this: void, index: number): SimulationFrameReader | null; + + dispose(this: void): void; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.test.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.test.ts index 018cb98423a..646b28fa986 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Metric } from "../types/sdcpn"; +import type { Metric } from "../../types/sdcpn"; import { compileMetric, type MetricState } from "./compile-metric"; const metric = (overrides: Partial = {}): Metric => ({ diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.ts index 85f276fa770..bc4d7ea8030 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-metric.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-metric.ts @@ -1,4 +1,4 @@ -import type { Metric } from "../types/sdcpn"; +import type { Metric } from "../../types/sdcpn"; import { runSandboxed, SHADOWED_GLOBALS } from "./sandbox"; // -- Public types ------------------------------------------------------------- diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.test.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.test.ts index 0416c0dd501..cd555e8b065 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Parameter, Scenario } from "../types/sdcpn"; +import type { Parameter, Scenario } from "../../types/sdcpn"; import { compileScenario } from "./compile-scenario"; // -- Helpers ------------------------------------------------------------------ diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.ts index 495f497ca4d..188f93f79cc 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compile-scenario.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/compile-scenario.ts @@ -1,4 +1,4 @@ -import type { Color, Parameter, Place, Scenario } from "../types/sdcpn"; +import type { Color, Parameter, Place, Scenario } from "../../types/sdcpn"; import { runSandboxed, SHADOWED_GLOBALS } from "./sandbox"; // -- Result types ------------------------------------------------------------- @@ -17,7 +17,7 @@ export interface CompiledPlaceMarking { export interface CompiledScenarioResult { /** * Resolved parameter values keyed by variableName (matches the format - * expected by the simulation worker and SimulationContext). + * expected by the simulation worker). */ parameterValues: Record; /** @@ -282,7 +282,7 @@ export function compileScenario( return { ok: false, errors }; } - // Convert parameters to string values (SimulationContext format) + // Convert parameters to string values (simulation worker input format) const parameterValues: Record = {}; for (const [key, value] of Object.entries(parametersObj)) { parameterValues[key] = String(value); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md similarity index 79% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md index 977acff8d5e..d68dfeca379 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/README.md +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/README.md @@ -1,10 +1,11 @@ -# Simulator +# Simulation Engine Core simulation logic for SDCPN Petri net execution. ## Overview -The simulator compiles an SDCPN definition into a runnable `SimulationInstance` and computes frames by evaluating transitions and differential equations. +The engine compiles an SDCPN definition into a runnable `SimulationInstance` +and computes frames by evaluating transitions and differential equations. ## Core Functions @@ -27,16 +28,18 @@ computeNextFrame(simulation) ├─► Check if maxTime reached → "maxTime" completion ├─► Apply differential equations ├─► For each transition: check enablement, sample firing, execute kernel - ├─► Build new SimulationFrame + ├─► Build new EngineFrame └─► Check deadlock → "deadlock" completion ``` -## SimulationFrame +## Internal EngineFrame -A snapshot of simulation state at a point in time. +A snapshot of simulation state at a point in time. This is the engine and +worker storage layout. Public callers should read frames through +`SimulationFrameReader`. ```typescript -type SimulationFrame = { +type EngineFrame = { time: number; places: Record; transitions: Record; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts index 37d15adaed0..0bf843af3be 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.test.ts @@ -250,6 +250,8 @@ describe("buildSimulation", () => { expect(Object.keys(frame.transitions).length).toBe(2); expect(frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); expect(frame.transitions.t2?.timeSinceLastFiringMs).toBe(0); + expect(frame.transitions.t1).not.toHaveProperty("instance"); + expect(simulationInstance.transitions.get("t1")?.name).toBe("Transition 1"); // Verify all compiled functions exist expect(simulationInstance.differentialEquationFns.size).toBe(3); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts similarity index 96% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts index fa89e4a7eab..0d6c67c5cfb 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/build-simulation.ts @@ -1,14 +1,14 @@ -import { SDCPNItemError } from "../../errors"; +import { SDCPNItemError } from "../../../errors"; import { deriveDefaultParameterValues, mergeParameterValues, -} from "../../parameter-values"; +} from "../../../parameter-values"; import { compileUserCode } from "./compile-user-code"; import type { DifferentialEquationFn, LambdaFn, ParameterValues, - SimulationFrame, + EngineFrame, SimulationInput, SimulationInstance, TransitionKernelFn, @@ -43,7 +43,7 @@ function getPlaceDimensions( * - Random seed * - Time step (dt) * - * Returns a SimulationFrame with: + * Returns an EngineFrame with: * - A SimulationInstance containing compiled user code functions * - Initial token distribution in a contiguous buffer * - All places and transitions initialized with proper state @@ -189,7 +189,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { // Calculate buffer size and build place states let bufferSize = 0; - const placeStates: SimulationFrame["places"] = {}; + const placeStates: EngineFrame["places"] = {}; // Process places in a consistent order (sorted by ID) const sortedPlaceIds = Array.from(placesMap.keys()).sort(); @@ -227,14 +227,13 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { } // Initialize transition states - const transitionStates: SimulationFrame["transitions"] = {}; + const transitionStates: EngineFrame["transitions"] = {}; for (const transition of sdcpn.transitions) { if (transition.id === "__proto__") { throw new Error("Cannot add transition with id '__proto__'"); } transitionStates[transition.id] = { - instance: transition, timeSinceLastFiringMs: 0, firedInThisFrame: false, firingCount: 0, @@ -258,7 +257,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { }; // Create the initial frame - const initialFrame: SimulationFrame = { + const initialFrame: EngineFrame = { time: 0, places: placeStates, transitions: transitionStates, diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts new file mode 100644 index 00000000000..d339d3fceea --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; + +import type { InputArc, OutputArc, Transition } from "../../../types/sdcpn"; +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])); +} + +function makeFrame({ + places, + transitions, +}: { + places: EngineFrame["places"]; + transitions: Transition[]; +}): EngineFrame { + return { + time: 0, + places, + transitions: Object.fromEntries( + transitions.map((transition) => [transition.id, transitionState]), + ), + buffer: new Float64Array([]), + }; +} + +describe("isTransitionStructurallyEnabled", () => { + it("returns true when input place has sufficient tokens", () => { + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(true); + }); + + it("returns false when input place has insufficient tokens", () => { + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 0, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); + }); + + it("respects arc weights when checking enablement", () => { + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 3, type: "standard" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + "t1", + ), + ).toBe(false); + }); + + it("checks all input places for enablement", () => { + const 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]), + "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]), + "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]), + "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]), + "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]), + "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]), + "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]), + "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]), + "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), + ); + + 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), + ); + + 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([])); + + 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), + ); + + 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/authoring/engine/check-transition-enablement.ts similarity index 79% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts index 94ef6532ce8..88d14f2d278 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/check-transition-enablement.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/check-transition-enablement.ts @@ -1,4 +1,5 @@ -import type { SimulationFrame } from "./types"; +import type { Transition } from "../../../types/sdcpn"; +import type { EngineFrame } from "./types"; /** * Result of checking transition enablement for a simulation frame. @@ -26,20 +27,28 @@ export type TransitionEnablementResult = { * be structurally enabled but still not fire due to lambda returning 0 or false. * * @param frame - The current simulation frame + * @param transitions - Static transition definitions for the simulation run * @param transitionId - The ID of the transition to check * @returns true if the transition has sufficient input tokens, false otherwise */ export const isTransitionStructurallyEnabled = ( - frame: SimulationFrame, + frame: EngineFrame, + transitions: ReadonlyMap, transitionId: string, ): boolean => { - const transition = frame.transitions[transitionId]; - if (!transition) { + if (!frame.transitions[transitionId]) { throw new Error(`Transition with ID ${transitionId} not found.`); } + const transition = transitions.get(transitionId); + if (!transition) { + throw new Error( + `Transition definition for transition ${transitionId} not found.`, + ); + } + // Check if all input places have enough tokens for the required arc weights - return transition.instance.inputArcs.every((arc) => { + return transition.inputArcs.every((arc) => { const placeState = frame.places[arc.placeId]; if (!placeState) { throw new Error( @@ -71,20 +80,25 @@ export const isTransitionStructurallyEnabled = ( * * @example * ```ts - * const result = checkTransitionEnablement(currentFrame); + * const result = checkTransitionEnablement(currentFrame, simulation.transitions); * if (!result.hasEnabledTransition) { * console.log("Simulation reached a terminal state (deadlock)"); * } * ``` */ export const checkTransitionEnablement = ( - frame: SimulationFrame, + frame: EngineFrame, + transitions: ReadonlyMap, ): TransitionEnablementResult => { const transitionStatus = new Map(); let hasEnabledTransition = false; for (const transitionId of Object.keys(frame.transitions)) { - const isEnabled = isTransitionStructurallyEnabled(frame, transitionId); + const isEnabled = isTransitionStructurallyEnabled( + frame, + transitions, + transitionId, + ); transitionStatus.set(transitionId, isEnabled); if (isEnabled) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-user-code.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compile-user-code.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.test.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.test.ts index 633669877f3..7467d8651e1 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { SDCPN } from "../../types/sdcpn"; +import type { SDCPN } from "../../../types/sdcpn"; import { buildSimulation } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts index dd4596e2a3b..d65a6475423 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-next-frame.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-next-frame.ts @@ -228,7 +228,10 @@ export function computeNextFrame( } // Check for deadlock if no transition fired else if (!transitionFired) { - const enablementResult = checkTransitionEnablement(finalFrame); + const enablementResult = checkTransitionEnablement( + finalFrame, + simulation.transitions, + ); if (!enablementResult.hasEnabledTransition) { completionReason = "deadlock"; } diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-place-next-state.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-place-next-state.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts new file mode 100644 index 00000000000..8efc62de134 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place, Transition } from "../../../types/sdcpn"; +import { computePossibleTransition } from "./compute-possible-transition"; +import type { + EngineFrame, + SimulationInstance, + TransitionKernelFn, +} from "./types"; + +const type1: Color = { + id: "type1", + name: "Type1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "e1", name: "x", type: "real" }], +}; + +const transitionState = (timeSinceLastFiringMs = 1.0) => ({ + timeSinceLastFiringMs, + firedInThisFrame: false, + firingCount: 0, +}); + +function makePlace(id: string, name: string, colorId: string | null): Place { + return { + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeTransition( + transition: Pick & + Partial>, +): Transition { + return { + name: "Transition 1", + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + ...transition, + }; +} + +function makeSimulation({ + places = [], + transitions, + types = [], + lambdaFns, + transitionKernelFns, +}: { + places?: Place[]; + transitions: Transition[]; + types?: Color[]; + lambdaFns: SimulationInstance["lambdaFns"]; + transitionKernelFns: SimulationInstance["transitionKernelFns"]; +}): SimulationInstance { + return { + places: new Map(places.map((place) => [place.id, place])), + transitions: new Map( + transitions.map((transition) => [transition.id, transition]), + ), + types: new Map(types.map((type) => [type.id, type])), + differentialEquationFns: new Map(), + lambdaFns, + transitionKernelFns, + parameterValues: {}, + dt: 0.1, + maxTime: null, + rngState: 42, + frames: [], + currentFrameNumber: 0, + }; +} + +describe("computePossibleTransition", () => { + it("returns null when transition is not enabled due to insufficient tokens", () => { + 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: EngineFrame = { + time: 0, + 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: EngineFrame = { + time: 0, + 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: EngineFrame = { + time: 0, + 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: EngineFrame = { + time: 0, + 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/authoring/engine/compute-possible-transition.ts similarity index 91% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts index a44d08494b4..c9db8cc98a0 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/compute-possible-transition.ts @@ -1,14 +1,14 @@ -import { SDCPNItemError } from "../../errors"; -import type { ID } from "../../types/sdcpn"; +import { SDCPNItemError } from "../../../errors"; +import type { ID } from "../../../types/sdcpn"; import { isDistribution, sampleDistribution } from "./distribution"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { EngineFrame, SimulationInstance } from "./types"; type PlaceID = ID; /** - * Takes a SimulationFrame, a SimulationInstance, a TransitionID, and computes the possible transition. + * Takes an EngineFrame, a SimulationInstance, a TransitionID, and computes the possible transition. * Returns null if no transition is possible. * Returns a record with: * - removed: Map from PlaceID to Set of token indices to remove. @@ -16,7 +16,7 @@ type PlaceID = ID; * - newRngState: Updated RNG seed after consuming randomness */ export function computePossibleTransition( - frame: SimulationFrame, + frame: EngineFrame, simulation: SimulationInstance, transitionId: string, rngState: number, @@ -25,14 +25,20 @@ export function computePossibleTransition( add: Record; newRngState: number; } { - // Get the transition from the simulation instance - const transition = frame.transitions[transitionId]; - if (!transition) { + const transitionState = frame.transitions[transitionId]; + if (!transitionState) { throw new Error(`Transition with ID ${transitionId} not found.`); } + const transition = simulation.transitions.get(transitionId); + if (!transition) { + throw new Error( + `Transition definition for transition ${transitionId} not found.`, + ); + } + // Gather input places with their weights relative to this transition. - const inputPlaces = transition.instance.inputArcs.map((arc) => { + const inputPlaces = transition.inputArcs.map((arc) => { const placeState = frame.places[arc.placeId]; if (!placeState) { throw new Error( @@ -82,7 +88,7 @@ export function computePossibleTransition( // Generate random number using seeded RNG and update state const [U1, newRngState] = nextRandom(rngState); - const { timeSinceLastFiringMs } = transition; + const { timeSinceLastFiringMs } = transitionState; // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. // (just multiply by time since last transition) @@ -172,10 +178,10 @@ export function computePossibleTransition( ); } catch (err) { throw new SDCPNItemError( - `Error while executing lambda function for transition \`${transition.instance.name}\`:\n\n${ + `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ (err as Error).message }\n\nInput:\n${JSON.stringify(tokenCombinationValues, null, 2)}`, - transition.instance.id, + transition.id, ); } @@ -202,10 +208,10 @@ export function computePossibleTransition( ); } catch (err) { throw new SDCPNItemError( - `Error while executing transition kernel for transition \`${transition.instance.name}\`:\n\n${ + `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ (err as Error).message }\n\nInput:\n${JSON.stringify(tokenCombinationValues, null, 2)}`, - transition.instance.id, + transition.id, ); } @@ -216,7 +222,7 @@ export function computePossibleTransition( const addMap: Record = {}; let currentRngState = newRngState; - for (const outputArc of transition.instance.outputArcs) { + for (const outputArc of transition.outputArcs) { const outputPlaceState = frame.places[outputArc.placeId]; if (!outputPlaceState) { throw new Error( @@ -249,8 +255,8 @@ export function computePossibleTransition( if (!outputTokens) { throw new SDCPNItemError( - `Transition kernel for transition \`${transition.instance.name}\` did not return tokens for place "${placeName}"`, - transition.instance.id, + `Transition kernel for transition \`${transition.name}\` did not return tokens for place "${placeName}"`, + transition.id, ); } diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/distribution.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/distribution.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/distribution.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/enumerate-weighted-markings.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/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/authoring/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/authoring/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/authoring/engine/enumerate-weighted-markings.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts new file mode 100644 index 00000000000..52e09bc0cc3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place, Transition } from "../../../types/sdcpn"; +import { executeTransitions } from "./execute-transitions"; +import type { + EngineFrame, + SimulationInstance, + TransitionKernelFn, +} from "./types"; + +const type1: Color = { + id: "type1", + name: "Type1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "e1", name: "x", type: "real" }], +}; + +const type2: Color = { + id: "type2", + name: "Type2", + iconSlug: "square", + displayColor: "#00FF00", + elements: [ + { elementId: "e1", name: "x", type: "real" }, + { elementId: "e2", name: "y", type: "real" }, + ], +}; + +const transitionState = (timeSinceLastFiringMs = 1.0) => ({ + timeSinceLastFiringMs, + firedInThisFrame: false, + firingCount: 0, +}); + +function makePlace(id: string, name: string, colorId: string | null): Place { + return { + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; +} + +function makeTransition( + transition: Pick & + Partial>, +): Transition { + return { + name: "Transition", + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, + ...transition, + }; +} + +function makeSimulation({ + places = [], + transitions, + types = [], + lambdaFns, + transitionKernelFns, +}: { + places?: Place[]; + transitions: Transition[]; + types?: Color[]; + lambdaFns: SimulationInstance["lambdaFns"]; + transitionKernelFns: SimulationInstance["transitionKernelFns"]; +}): SimulationInstance { + return { + places: new Map(places.map((place) => [place.id, place])), + transitions: new Map( + transitions.map((transition) => [transition.id, transition]), + ), + types: new Map(types.map((type) => [type.id, type])), + differentialEquationFns: new Map(), + lambdaFns, + transitionKernelFns, + parameterValues: {}, + dt: 0.1, + maxTime: null, + rngState: 42, + frames: [], + currentFrameNumber: 0, + }; +} + +describe("executeTransitions", () => { + it("returns the original frame when no transitions can fire", () => { + const 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: EngineFrame = { + time: 0, + 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: EngineFrame = { + time: 0, + 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.time).toBe(0.1); + 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: EngineFrame = { + time: 0, + 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: EngineFrame = { + time: 0, + 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: EngineFrame = { + time: 0, + 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/authoring/engine/execute-transitions.ts similarity index 93% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts index 43b77571392..92e79e35858 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/execute-transitions.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/execute-transitions.ts @@ -1,28 +1,28 @@ -import type { ID } from "../../types/sdcpn"; +import type { ID } from "../../../types/sdcpn"; import { computePossibleTransition } from "./compute-possible-transition"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; -import type { SimulationFrame, SimulationInstance } from "./types"; +import type { EngineFrame, SimulationInstance } from "./types"; type PlaceID = ID; /** * Adds tokens to multiple places in the simulation frame. * - * Takes a SimulationFrame and a Map of Place IDs to arrays of token values, - * and returns a new SimulationFrame with: + * Takes an EngineFrame and a Map of Place IDs to arrays of token values, + * and returns a new EngineFrame with: * - The specified tokens added to each place's section in the buffer * - Each place's count incremented by the number of added tokens * - All subsequent places' offsets adjusted accordingly * * @param frame - The simulation frame to modify * @param tokensToAdd - Map from Place ID to array of token values to add (each token is an array of numbers) - * @returns A new SimulationFrame with the tokens added + * @returns A new EngineFrame with the tokens added * @throws Error if a place is not found or token dimensions don't match */ function addTokensToSimulationFrame( - frame: SimulationFrame, + frame: EngineFrame, tokensToAdd: Map, -): SimulationFrame { +): EngineFrame { // If no tokens to add, return frame as-is if (tokensToAdd.size === 0) { return frame; @@ -64,7 +64,7 @@ function addTokensToSimulationFrame( (a, b) => a[1].offset - b[1].offset, ); - const newPlaces: SimulationFrame["places"] = { ...frame.places }; + const newPlaces: EngineFrame["places"] = { ...frame.places }; let sourceIndex = 0; let targetIndex = 0; @@ -122,7 +122,7 @@ function addTokensToSimulationFrame( */ export type ExecuteTransitionsResult = { /** The updated simulation frame */ - frame: SimulationFrame; + frame: EngineFrame; /** The updated RNG state after all transitions */ rngState: number; /** Whether any transition fired */ @@ -146,7 +146,7 @@ export type ExecuteTransitionsResult = { * @returns Result containing the updated frame, new RNG state, and whether any transition fired */ export function executeTransitions( - frame: SimulationFrame, + frame: EngineFrame, simulation: SimulationInstance, dt: number, rngState: number, @@ -210,7 +210,7 @@ export function executeTransitions( const newFrame = addTokensToSimulationFrame(currentFrame, tokensToAdd); // Update transition timeSinceLastFiringMs, firedInThisFrame, and firingCount - const newTransitions: SimulationFrame["transitions"] = { + const newTransitions: EngineFrame["transitions"] = { ...newFrame.transitions, }; for (const [transitionId, transitionState] of Object.entries( diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts similarity index 94% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.test.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts index 91e1bd192c4..a010085294e 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; -import type { SimulationFrame } from "./types"; +import type { EngineFrame } from "./types"; describe("removeTokensFromSimulationFrame", () => { it("throws error when place ID is not found", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: {}, transitions: {}, @@ -21,7 +21,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when tokens map is empty", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -40,7 +40,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("throws error when token index is out of bounds", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -59,7 +59,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when place has empty set of indices", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -82,7 +82,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes a single token from a place with 1D tokens", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -106,7 +106,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes multiple tokens from a place with 1D tokens", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -130,7 +130,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes tokens from a place with multi-dimensional tokens", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -158,7 +158,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("adjusts offsets for subsequent places after removal", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -193,7 +193,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes all tokens from a place", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -224,7 +224,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("handles removal from middle place with three places", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { @@ -268,7 +268,7 @@ describe("removeTokensFromSimulationFrame", () => { }); it("removes tokens from multiple places simultaneously", () => { - const frame: SimulationFrame = { + const frame: EngineFrame = { time: 0, places: { p1: { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts similarity index 92% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts index 54bb2d0cd74..54c58b05e63 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/remove-tokens-from-simulation-frame.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/remove-tokens-from-simulation-frame.ts @@ -1,10 +1,10 @@ -import type { SimulationFrame } from "./types"; +import type { EngineFrame } from "./types"; /** * Removes tokens from multiple places in the simulation frame. * - * Takes a SimulationFrame and a Map of Place IDs to Sets of token indices to remove, - * and returns a new SimulationFrame with: + * Takes an EngineFrame and a Map of Place IDs to Sets of token indices to remove, + * and returns a new EngineFrame with: * - The specified tokens removed from each place's section in the buffer * - Each place's count decremented by the number of removed tokens * - All places' offsets adjusted accordingly @@ -13,13 +13,13 @@ import type { SimulationFrame } from "./types"; * * @param frame - The simulation frame to modify * @param tokensToRemove - Map from Place ID to Set of token indices to remove from that place - * @returns A new SimulationFrame with the tokens removed + * @returns A new EngineFrame with the tokens removed * @throws Error if a place is not found or indices are invalid */ export function removeTokensFromSimulationFrame( - frame: SimulationFrame, + frame: EngineFrame, tokensToRemove: Map | number>, -): SimulationFrame { +): EngineFrame { // If no tokens to remove, return frame as-is if (tokensToRemove.size === 0) { return frame; @@ -106,7 +106,7 @@ export function removeTokensFromSimulationFrame( (a, b) => a[1].offset - b[1].offset, ); - const newPlaces: SimulationFrame["places"] = { ...frame.places }; + const newPlaces: EngineFrame["places"] = { ...frame.places }; let cumulativeRemoved = 0; for (const [placeId, placeState] of placesByOffset) { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/seeded-rng.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/seeded-rng.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/seeded-rng.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/seeded-rng.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts similarity index 88% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts rename to libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts index a558a41f63f..03786639d06 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulator/types.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/authoring/engine/types.ts @@ -2,11 +2,11 @@ * Internal types for the simulation engine. * * These types are used by the simulator and worker modules but are not - * part of the public SimulationContext API. + * part of the public simulation API. */ -import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; -import type { SimulationFrame } from "../types"; +import type { Color, Place, SDCPN, Transition } from "../../../types/sdcpn"; +import type { EngineFrame } from "../../frames/internal-frame"; import type { RuntimeDistribution } from "./distribution"; /** @@ -86,14 +86,14 @@ export type SimulationInstance = { /** Current state of the seeded random number generator */ rngState: number; /** History of all computed frames */ - frames: SimulationFrame[]; + frames: EngineFrame[]; /** Index of the current frame in the frames array */ currentFrameNumber: number; }; -// Re-export frame types from context for convenient access within simulator +// Re-export frame types for convenient access within simulator internals. export type { - SimulationFrame, - SimulationFrameState_Place, - SimulationFrameState_Transition, -} from "../types"; + EngineFrame, + EngineFramePlaceState, +} from "../../frames/internal-frame"; +export type { SimulationFrameState_Transition } from "../../api"; 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/frames/frame-reader.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts new file mode 100644 index 00000000000..cfde9b514fa --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import type { Color, Place } from "../../types/sdcpn"; +import { createSimulationFrameReader } from "./frame-reader"; +import 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, +}; + +function makeFrame(): EngineFrame { + return { + time: 0.25, + 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 = createSimulationFrameReader(makeFrame(), 7); + + expect(reader.number).toBe(7); + expect(reader.time).toBe(0.25); + expect(reader.getPlaceTokenCount(place.id)).toBe(2); + expect(reader.getPlaceTokenCount("missing")).toBe(0); + + expect(reader.getPlaceTokenValues(place.id)).toEqual({ + values: new Float64Array([1, 2, 3, 4]), + count: 2, + }); + expect(reader.getPlaceTokens(place, color)).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + const transitionState = reader.getTransitionState("transition-1"); + expect(transitionState).toEqual({ + timeSinceLastFiringMs: 10, + firedInThisFrame: true, + firingCount: 3, + }); + expect(transitionState).not.toHaveProperty("instance"); + + expect(reader.toFrameState()).toEqual({ + number: 7, + time: 0.25, + places: { + [place.id]: { tokenCount: 2 }, + }, + transitions: { + "transition-1": transitionState, + }, + }); + }); + + it("returns a copied token value buffer", () => { + const reader = createSimulationFrameReader(makeFrame(), 7); + const values = reader.getPlaceTokenValues(place.id); + + expect(values).not.toBeNull(); + values!.values[0] = 42; + + expect(reader.getPlaceTokenValues(place.id)?.values).toEqual( + new Float64Array([1, 2, 3, 4]), + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts new file mode 100644 index 00000000000..1df8c801777 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/frame-reader.ts @@ -0,0 +1,109 @@ +import type { + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, + SimulationPlaceTokenValues, +} from "../api"; +import type { EngineFramePlaceState } from "./internal-frame"; + +type SimulationFrameReaderData = { + time: number; + places: Record; + transitions: Record; + buffer: Float64Array; +}; + +export function createSimulationFrameReader( + frame: SimulationFrameReaderData, + number: number, +): SimulationFrameReader { + const getPlaceTokenCount = (placeId: string): number => + frame.places[placeId]?.count ?? 0; + + const getPlaceTokenValues = ( + placeId: string, + ): SimulationPlaceTokenValues | null => { + const placeState = frame.places[placeId]; + if (!placeState) { + return null; + } + + const { offset, count, dimensions } = placeState; + const size = count * dimensions; + return { + values: frame.buffer.slice(offset, offset + size), + count, + }; + }; + + const getTransitionState = ( + transitionId: string, + ): SimulationFrameState_Transition | null => { + const transitionState = frame.transitions[transitionId]; + if (!transitionState) { + return null; + } + + return { + timeSinceLastFiringMs: transitionState.timeSinceLastFiringMs, + firedInThisFrame: transitionState.firedInThisFrame, + firingCount: transitionState.firingCount, + }; + }; + + return { + number, + time: frame.time, + getPlaceTokenCount, + getPlaceTokenValues, + getPlaceTokens(place, color) { + const placeState = frame.places[place.id]; + if (!placeState) { + return []; + } + + const { offset, count, dimensions } = placeState; + const elements = color?.elements ?? []; + const tokens: Record[] = []; + if (elements.length === 0 || dimensions === 0 || count === 0) { + return tokens; + } + + for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + const token: Record = {}; + const base = offset + tokenIndex * dimensions; + for ( + let dimensionIndex = 0; + dimensionIndex < elements.length && dimensionIndex < dimensions; + dimensionIndex++ + ) { + token[elements[dimensionIndex]!.name] = + frame.buffer[base + dimensionIndex] ?? 0; + } + tokens.push(token); + } + + return tokens; + }, + getTransitionState, + toFrameState() { + const places: SimulationFrameState["places"] = {}; + for (const [placeId, placeData] of Object.entries(frame.places)) { + places[placeId] = { tokenCount: placeData.count }; + } + + const transitions: SimulationFrameState["transitions"] = {}; + for (const transitionId of Object.keys(frame.transitions)) { + transitions[transitionId] = + getTransitionState(transitionId) ?? undefined; + } + + return { + number, + time: frame.time, + places, + transitions, + }; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts new file mode 100644 index 00000000000..6952465c91c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/internal-frame.ts @@ -0,0 +1,34 @@ +import type { ID } from "../../types/sdcpn"; +import type { SimulationFrameState_Transition } from "../api"; + +/** + * Internal place layout within an engine frame. + */ +export type EngineFramePlaceState = { + offset: number; + count: number; + dimensions: number; +}; + +/** + * Internal frame storage layout used by the stepping engine. + * + * This is not a worker protocol or public API type. Public callers should read + * engine output through `SimulationFrameReader`. + */ +export type EngineFrame = { + /** 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; + /** + * Buffer containing all place values concatenated. + * + * Size: sum of (place.dimensions * place.count) for all places. + * + * Layout: For each place, its tokens are stored contiguously. + */ + buffer: Float64Array; +}; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts new file mode 100644 index 00000000000..b12b60691e9 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/frames/metric-state.ts @@ -0,0 +1,27 @@ +import type { Color, Place } from "../../types/sdcpn"; +import type { SimulationFrameReader } from "../api"; +import type { MetricState } from "../authoring/compile-metric"; + +/** + * Reshape a simulation frame reader into the `MetricState` shape exposed to + * compiled metric functions. Place state is keyed by place **name** so author + * code can read e.g. `state.places.Infected.count`. + */ +export function buildMetricState( + frame: SimulationFrameReader, + places: Place[], + types: Color[], +): MetricState { + const typeById = new Map(types.map((t) => [t.id, t])); + const placesByName: Record = {}; + + for (const place of places) { + const color = place.colorId ? typeById.get(place.colorId) : undefined; + placesByName[place.name] = { + count: frame.getPlaceTokenCount(place.id), + tokens: frame.getPlaceTokens(place, color), + }; + } + + return { places: placesByName }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/index.ts b/libs/@hashintel/petrinaut/src/core/simulation/index.ts index f39f72f550f..c4fcc6139ef 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/index.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/index.ts @@ -1,17 +1,20 @@ -export { - createSimulation, - type BackpressureConfig, - type CreateSimulationConfig, - type Simulation, - type SimulationCompleteEvent, - type SimulationConfig, - type SimulationErrorEvent, - type SimulationEvent, - type SimulationFrameSummary, - type SimulationState, -} from "./simulation"; -export { - createWorkerTransport, - type SimulationTransport, - type WorkerFactory, -} from "./transport"; +export type { + BackpressureConfig, + CreateSimulationConfig, + InitialMarking, + Simulation, + SimulationCompleteEvent, + SimulationConfig, + SimulationErrorEvent, + SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, + SimulationFrameSummary, + SimulationPlaceTokenValues, + SimulationTransport, + SimulationState, + WorkerFactory, +} from "./api"; +export { createSimulation } from "./runtime/simulation"; +export { createWorkerTransport } from "./runtime/transport"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts b/libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts deleted file mode 100644 index f025449069f..00000000000 --- a/libs/@hashintel/petrinaut/src/core/simulation/metric-state.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Color, Place } from "../types/sdcpn"; -import type { MetricState } from "./compile-metric"; -import type { SimulationFrame } from "./types"; - -/** - * Reshape a raw `SimulationFrame` into the `MetricState` shape exposed to - * compiled metric functions. Place state is keyed by place **name** so author - * code can read e.g. `state.places.Infected.count`. - * - * For colored places, each token is reconstructed as a `Record` - * by slicing the frame's flat `buffer` using `{ offset, count, dimensions }` - * and the place's color element names. - */ -export function buildMetricState( - frame: SimulationFrame, - places: Place[], - types: Color[], -): MetricState { - const typeById = new Map(types.map((t) => [t.id, t])); - const placesByName: Record = {}; - - for (const place of places) { - const placeFrame = frame.places[place.id]; - if (!placeFrame) { - placesByName[place.name] = { count: 0, tokens: [] }; - continue; - } - - const { offset, count, dimensions } = placeFrame; - const color = place.colorId ? typeById.get(place.colorId) : undefined; - const elements = color?.elements ?? []; - - const tokens: Record[] = []; - if (elements.length > 0 && dimensions > 0 && count > 0) { - for (let i = 0; i < count; i++) { - const token: Record = {}; - const base = offset + i * dimensions; - for (let d = 0; d < elements.length && d < dimensions; d++) { - token[elements[d]!.name] = frame.buffer[base + d] ?? 0; - } - tokens.push(token); - } - } - - placesByName[place.name] = { count, tokens }; - } - - return { places: placesByName }; -} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts new file mode 100644 index 00000000000..5fe1517a0de --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/frame-store.ts @@ -0,0 +1,44 @@ +import type { SimulationFrameReader } from "../api"; +import { createSimulationFrameReader } from "../frames/frame-reader"; +import type { SimulationFramePayload } from "../worker/frame-payload"; + +export interface SimulationFrameStore { + append(frame: SimulationFramePayload): void; + appendBatch(frames: SimulationFramePayload[]): void; + clear(): void; + count(): number; + latest(): SimulationFrameReader | null; + get(index: number): SimulationFrameReader | null; +} + +/** + * Compatibility store for the v1 worker protocol. It keeps all full frame + * payloads in memory, while hiding that retention policy from `Simulation`. + */ +export function createInMemorySimulationFrameStore(): SimulationFrameStore { + const frames: SimulationFramePayload[] = []; + + return { + append(frame) { + frames.push(frame); + }, + appendBatch(nextFrames) { + frames.push(...nextFrames); + }, + clear() { + frames.length = 0; + }, + count() { + return frames.length; + }, + latest() { + const index = frames.length - 1; + const frame = frames[index]; + return frame ? createSimulationFrameReader(frame, index) : null; + }, + get(index) { + const frame = frames[index]; + return frame ? createSimulationFrameReader(frame, index) : null; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts similarity index 93% 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..b4fa2e40fc5 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { SimulationFrame } from "./types"; -import type { ToMainMessage, ToWorkerMessage } from "./worker/messages"; -import type { SDCPN } from "../types/sdcpn"; -import { createSimulation, type SimulationFrameSummary } from "./simulation"; -import type { SimulationTransport } from "./transport"; +import type { ToMainMessage, ToWorkerMessage } from "../worker/messages"; +import type { SimulationFramePayload } from "../worker/frame-payload"; +import type { SDCPN } from "../../types/sdcpn"; +import type { SimulationFrameSummary, SimulationTransport } from "../api"; +import { createSimulation } from "./simulation"; const empty = (): SDCPN => ({ places: [], @@ -14,7 +14,7 @@ const empty = (): SDCPN => ({ differentialEquations: [], }); -function makeFrame(time: number): SimulationFrame { +function makeFrame(time: number): SimulationFramePayload { return { time, places: {}, @@ -29,12 +29,12 @@ function makeFrame(time: number): SimulationFrame { */ function makeMockTransport() { const sent: ToWorkerMessage[] = []; - const listeners = new Set<(m: ToMainMessage) => void>(); + const listeners = new Set<(m: unknown) => void>(); let terminated = false; const transport: SimulationTransport = { send(message) { - sent.push(message); + sent.push(message as ToWorkerMessage); }, onMessage(listener) { listeners.add(listener); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts similarity index 64% rename from libs/@hashintel/petrinaut/src/core/simulation/simulation.ts rename to libs/@hashintel/petrinaut/src/core/simulation/runtime/simulation.ts index bf80240bccc..e3c155506af 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,19 +79,17 @@ export function createSimulation( latest: null, }); const events = createEventStream(); - const frames: SimulationFrame[] = []; + const frameStore = createInMemorySimulationFrameStore(); let disposed = false; - function pushFrames(newFrames: SimulationFrame[]): void { + function pushFrames(newFrames: SimulationFramePayload[]): void { if (newFrames.length === 0) { return; } - for (const frame of newFrames) { - frames.push(frame); - } + frameStore.appendBatch(newFrames); frameSummary.set({ - count: frames.length, - latest: frames[frames.length - 1] ?? null, + count: frameStore.count(), + latest: frameStore.latest(), }); } @@ -173,7 +97,8 @@ export function createSimulation( let settled = false; let handle: Simulation; - const off = transport.onMessage((message) => { + const off = transport.onMessage((rawMessage) => { + const message = rawMessage as ToMainMessage; switch (message.type) { case "ready": { status.set("Ready"); @@ -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) { 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/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-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/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/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..d0455feecff 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,30 @@ 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 | ## Backpressure -The worker blocks computation until it receives an `ack` message, then computes up to `maxFramesAhead` frames beyond the acknowledged frame before waiting again. +The worker blocks computation until it receives an `ack` message, then computes +up to `maxFramesAhead` frames beyond the acknowledged frame before waiting +again. **Key behavior:** -- Worker starts with `lastAckedFrame = -1` (blocked until first ack) -- PlaybackProvider controls ack calls based on play mode -- If no ack is sent (viewOnly mode), no new frames are computed +- Worker starts with `lastAckedFrame = -1` and blocks until the first ack. +- Hosts should ack frames as they consume or persist them. +- If no ack is sent, no new frames are computed after initialization. -**Play mode configuration (set by PlaybackProvider):** +**Common backpressure profiles:** | Play Mode | maxFramesAhead | batchSize | Ack Behavior | | ---------------- | -------------- | --------- | -------------------------------- | @@ -49,12 +54,12 @@ The worker blocks computation until it receives an `ack` message, then computes --- -## Consuming this worker from main-thread code +## Consuming this worker from host code -The previous `useSimulationWorker` React hook has been removed. Main-thread code now uses the standalone `createSimulation` factory from `/core` (see [`../../../rfc/0001-core-react-ui-split/05-simulation.md`](../../../rfc/0001-core-react-ui-split/05-simulation.md)): +Host code should use the standalone `createSimulation` factory from `/core`: ```ts -import { createSimulation } from "@hashintel/petrinaut"; +import { createSimulation } from "@hashintel/petrinaut/core"; const sim = await createSimulation({ sdcpn, @@ -69,4 +74,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..ef6332ca935 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/frame-payload.ts @@ -0,0 +1,33 @@ +import type { ID } from "../../types/sdcpn"; +import type { SimulationFrameState_Transition } from "../api"; +import type { EngineFrame } from "../frames/internal-frame"; + +/** + * Worker protocol representation for a full frame payload. + * + * This is intentionally separate from `EngineFrame`: the current v1 payload is + * structurally similar, but the worker protocol is the compatibility boundary. + */ +export type SimulationFramePayloadPlaceState = { + offset: number; + count: number; + dimensions: number; +}; + +export type SimulationFramePayload = { + time: number; + places: Record; + transitions: Record; + buffer: Float64Array; +}; + +export function framePayloadFromEngineFrame( + frame: EngineFrame, +): SimulationFramePayload { + return { + time: frame.time, + places: frame.places, + transitions: frame.transitions, + buffer: frame.buffer, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts index 9bc08d21515..bedcdbe2566 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/worker/messages.ts @@ -5,7 +5,7 @@ */ import type { SDCPN } from "../../types/sdcpn"; -import type { SimulationFrame } from "../types"; +import type { SimulationFramePayload } from "./frame-payload"; // // Main Thread → Worker Messages @@ -106,7 +106,7 @@ export type ReadyMessage = { */ export type FrameMessage = { type: "frame"; - frame: SimulationFrame; + frame: SimulationFramePayload; }; /** @@ -114,7 +114,7 @@ export type FrameMessage = { */ export type FramesMessage = { type: "frames"; - frames: SimulationFrame[]; + frames: SimulationFramePayload[]; }; /** diff --git a/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts b/libs/@hashintel/petrinaut/src/core/simulation/worker/simulation.worker.ts index 9fa4ec37f3e..f5caafcf93e 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,10 @@ */ 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 "../authoring/engine/build-simulation"; +import { computeNextFrame } from "../authoring/engine/compute-next-frame"; +import type { SimulationInstance } from "../authoring/engine/types"; +import { framePayloadFromEngineFrame } from "./frame-payload"; import type { ToMainMessage, ToWorkerMessage } from "./messages"; // @@ -121,9 +122,15 @@ async function computeLoop(): Promise { // Send computed frames if (framesToSend.length > 0) { if (framesToSend.length === 1) { - postTypedMessage({ type: "frame", frame: framesToSend[0]! }); + postTypedMessage({ + type: "frame", + frame: framePayloadFromEngineFrame(framesToSend[0]!), + }); } else { - postTypedMessage({ type: "frames", frames: framesToSend }); + postTypedMessage({ + type: "frames", + frames: framesToSend.map(framePayloadFromEngineFrame), + }); } } @@ -168,7 +175,10 @@ self.onmessage = (event: MessageEvent) => { // Send initial frame const initialFrame = simulation.frames[0]; if (initialFrame) { - postTypedMessage({ type: "frame", frame: initialFrame }); + postTypedMessage({ + type: "frame", + frame: framePayloadFromEngineFrame(initialFrame), + }); } postTypedMessage({ diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 5be8888a443..1f0ac997494 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -46,12 +46,17 @@ export { export type { BackpressureConfig, CreateSimulationConfig, + InitialMarking, Simulation, SimulationCompleteEvent, SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameState_Transition, SimulationFrameSummary, + SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index 1466b535c17..dfbca552c62 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -19,19 +19,21 @@ export { export { useGetSimulationFrame, + useGetSimulationFrameReader, useSimulationActions, useSimulationError, useSimulationFrameCount, useSimulationParameters, useSimulationStatus, type SimulationActionsBundle, - type SimulationFrame, + type SimulationFrameReader, type SimulationFrameState, type SimulationState, } from "./use-simulation"; export { useCurrentFrame, + useCurrentFrameReader, useCurrentViewedFrame, useIsComputeAvailable, useIsViewOnlyAvailable, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts index 8f12fe50f76..bb45b38be65 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-playback.ts @@ -26,11 +26,13 @@ export function usePlaybackMode(): PlayMode { return use(PlaybackContext).playMode; } -/** Currently displayed frame data, or `null` if no simulation is running. */ -export function useCurrentFrame(): PlaybackContextValue["currentFrame"] { - return use(PlaybackContext).currentFrame; +/** Reader for the currently displayed frame, or `null` if no simulation is running. */ +export function useCurrentFrameReader(): PlaybackContextValue["currentFrameReader"] { + return use(PlaybackContext).currentFrameReader; } +export const useCurrentFrame = useCurrentFrameReader; + /** Simplified, UI-shaped view of the current frame. */ export function useCurrentViewedFrame(): PlaybackContextValue["currentViewedFrame"] { return use(PlaybackContext).currentViewedFrame; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts index 78803be6b17..8ea069fe29d 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-simulation.ts @@ -1,9 +1,9 @@ import { use } from "react"; import type { - SimulationFrame, + SimulationFrameReader, SimulationFrameState, -} from "../../core/simulation/types"; +} from "../../core/simulation"; import { SimulationContext, type SimulationContextValue, @@ -24,15 +24,17 @@ export function useSimulationFrameCount(): number { } /** - * Async access to a specific frame by index. Resolves to `null` when the index - * is out of range or no simulation exists. + * Async access to a specific frame reader by index. Resolves to `null` when + * the index is out of range or no simulation exists. */ -export function useGetSimulationFrame(): ( +export function useGetSimulationFrameReader(): ( index: number, -) => Promise { +) => Promise { return use(SimulationContext).getFrame; } +export const useGetSimulationFrame = useGetSimulationFrameReader; + export type SimulationActionsBundle = { initialize: SimulationContextValue["initialize"]; run: SimulationContextValue["run"]; @@ -87,4 +89,4 @@ export function useSimulationError(): { return { message: ctx.error, itemId: ctx.errorItemId }; } -export type { SimulationFrame, SimulationFrameState, SimulationState }; +export type { SimulationFrameReader, SimulationFrameState, SimulationState }; diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index 122e629f377..a05bd9003f7 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -57,15 +57,17 @@ 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 = ( - + diff --git a/libs/@hashintel/petrinaut/src/react/playback/README.md b/libs/@hashintel/petrinaut/src/react/playback/README.md index 06752e985f5..512ca272232 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/README.md +++ b/libs/@hashintel/petrinaut/src/react/playback/README.md @@ -4,7 +4,9 @@ React context for viewing simulation frames at controlled speeds. ## Overview -PlaybackProvider reads frames from SimulationContext and advances them using `requestAnimationFrame`. It controls both visualization playback and simulation computation via backpressure. +PlaybackProvider reads frame readers from SimulationContext and advances them +using `requestAnimationFrame`. It controls both visualization playback and +simulation computation via backpressure. ## Play Mode @@ -52,7 +54,7 @@ Playback auto-pauses when reaching the end of available frames (if simulation is **Reading:** -- `getFrame()`: Access frame data for current index +- `getFrame()`: Access a `SimulationFrameReader` for the current index - `dt`: Calculate real-time playback timing - `totalFrames`: Know when new frames are available - `state`: Determine available play modes diff --git a/libs/@hashintel/petrinaut/src/react/playback/context.ts b/libs/@hashintel/petrinaut/src/react/playback/context.ts index 703cf32cf8d..c76706f35c0 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/context.ts +++ b/libs/@hashintel/petrinaut/src/react/playback/context.ts @@ -8,7 +8,7 @@ import { type PlayMode, } from "../../core/playback"; import type { - SimulationFrame, + SimulationFrameReader, SimulationFrameState, } from "../simulation/context"; @@ -32,11 +32,10 @@ export { export type PlaybackContextValue = { // State values /** - * The raw simulation frame data for the currently viewed frame. - * Contains buffer data for accessing token values directly. + * Reader for the currently viewed frame. * Null when no simulation is running or no frames exist. */ - currentFrame: SimulationFrame | null; + currentFrameReader: SimulationFrameReader | null; /** * The currently viewed simulation frame state (simplified view). @@ -120,7 +119,7 @@ export type PlaybackContextValue = { }; const DEFAULT_CONTEXT_VALUE: PlaybackContextValue = { - currentFrame: null, + currentFrameReader: null, currentViewedFrame: null, playbackState: "Stopped", currentFrameIndex: 0, diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx index 79ebaee5eec..a2ac31e62bb 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,31 @@ import { PlaybackProvider } from "./provider"; type MockSimulationContextOverrides = Partial; -/** - * Creates a minimal SimulationFrame for testing. - */ -function createMockFrame(time: number): SimulationFrame { +function createMockFrameReader(number: number): SimulationFrameReader { + const time = number * 0.01; return { + number, time, - places: {}, - transitions: {}, - buffer: new Float64Array(), + getPlaceTokenCount: () => 0, + getPlaceTokenValues: () => null, + getPlaceTokens: () => [], + getTransitionState: () => null, + toFrameState: () => ({ + number, + time, + places: {}, + transitions: {}, + }), }; } /** - * 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 +51,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 +70,7 @@ function createMockSimulationContext( overrides: MockSimulationContextOverrides = {}, frameCount = 0, ): SimulationContextValue { - const frames = createMockFrames(frameCount); + const frames = createMockFrameReaders(frameCount); const frameAccessors = createMockFrameAccessors(frames); return { @@ -262,8 +268,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 +292,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 +442,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 +474,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 +598,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", () => { @@ -755,7 +833,7 @@ describe("PlaybackProvider", () => { }); 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 +855,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..efde64c89e4 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/context.ts +++ b/libs/@hashintel/petrinaut/src/react/simulation/context.ts @@ -1,21 +1,19 @@ import { createContext } from "react"; -import type { CompiledScenarioResult } from "../../core/simulation/compile-scenario"; +import type { CompiledScenarioResult } from "../../core/simulation/authoring/compile-scenario"; import type { InitialMarking, - SimulationFrame, + SimulationFrameReader, SimulationFrameState, - SimulationFrameState_Place, SimulationFrameState_Transition, -} from "../../core/simulation/types"; +} from "../../core/simulation"; // Re-export for back-compat with existing consumers that import these from // the simulation context module. export type { InitialMarking, - SimulationFrame, + SimulationFrameReader, SimulationFrameState, - SimulationFrameState_Place, SimulationFrameState_Transition, }; @@ -37,9 +35,9 @@ export type SimulationState = /** * The combined simulation context containing both state and actions. * - * Note: The full SimulationInstance is not exposed. Instead, use `getFrame()` - * to access individual frame data. This encapsulation supports the WebWorker - * architecture where frames are computed off the main thread. + * Note: The full SimulationInstance and raw frame storage are not exposed. + * Instead, use `getFrame()` to access individual frames through a + * `SimulationFrameReader`. */ export type SimulationContextValue = { // State values @@ -87,9 +85,9 @@ export type SimulationContextValue = { * is kept internal to the provider for memory management. * * @param frameIndex - The index of the frame to retrieve (0-based) - * @returns Promise resolving to the frame data or null + * @returns Promise resolving to the frame reader or null */ - getFrame: (frameIndex: number) => Promise; + getFrame: (frameIndex: number) => Promise; /** * Get all computed frames. @@ -98,9 +96,9 @@ export type SimulationContextValue = { * Note: For large simulations, this may return a large array. * Consider using getFrame() for single-frame access when possible. * - * @returns Promise resolving to array of all frames + * @returns Promise resolving to array of all frame readers */ - getAllFrames: () => Promise; + getAllFrames: () => Promise; /** * Get frames in a specified range. @@ -112,12 +110,12 @@ export type SimulationContextValue = { * * @param startIndex - The starting frame index (inclusive, 0-based) * @param endIndex - The ending frame index (exclusive). If omitted, returns to the end. - * @returns Promise resolving to array of frames in the range + * @returns Promise resolving to array of frame readers in the range */ getFramesInRange: ( startIndex: number, endIndex?: number, - ) => Promise; + ) => Promise; /** * ID of the currently selected scenario, or `null` for no scenario. diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index 840968ea70c..c3e1389a3f1 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -10,7 +10,7 @@ import { import { compileScenario, type CompiledScenarioResult, -} from "../../core/simulation/compile-scenario"; +} from "../../core/simulation/authoring/compile-scenario"; import { createSimulationWorker } from "../../core/simulation/worker/create-simulation-worker"; import { deriveDefaultParameterValues } from "../hooks/use-default-parameter-values"; import { useLatest } from "../hooks/use-latest"; @@ -22,7 +22,7 @@ import { type InitialMarking, SimulationContext, type SimulationContextValue, - type SimulationFrame, + type SimulationFrameReader, type SimulationState, } from "./context"; @@ -61,7 +61,7 @@ const EMPTY_STATUS_STORE: ReadableStore = { */ const EMPTY_FRAME_SUMMARY: { count: number; - latest: SimulationFrame | null; + latest: SimulationFrameReader | null; } = { count: 0, latest: null }; const EMPTY_FRAMES_STORE: ReadableStore = { @@ -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,6 +253,8 @@ export const SimulationProvider: React.FC = ({ placeId, marking, ) => { + invalidateSimulationForConfigurationChange(); + setStateValues((prev) => { const newMarking = new Map(prev.initialMarking); newMarking.set(placeId, marking); @@ -255,6 +266,10 @@ export const SimulationProvider: React.FC = ({ parameterId, value, ) => { + if (stateValuesRef.current.parameterValues[parameterId] !== value) { + invalidateSimulationForConfigurationChange(); + } + setStateValues((prev) => ({ ...prev, parameterValues: { @@ -265,10 +280,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 +319,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 +372,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 +398,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 +411,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 +433,7 @@ export const SimulationProvider: React.FC = ({ const total = sim.frames.get().count; const start = Math.max(0, startIndex); const end = endIndex === undefined ? total : Math.min(endIndex, total); - const slice: SimulationFrame[] = []; + const slice: SimulationFrameReader[] = []; for (let i = start; i < end; i++) { const frame = sim.getFrame(i); if (frame) { diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx index 86d01c92b76..69128c2e2a3 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -284,11 +284,11 @@ const ScrollableContent: React.FC<{ children: React.ReactNode }> = ({ const [canScrollUp, setCanScrollUp] = useState(false); const [canScrollDown, setCanScrollDown] = useState(false); - const updateShadows = () => { - const el = scrollRef.current; + const setShadowState = (el: HTMLDivElement | null) => { if (!el) { return; } + setCanScrollUp(el.scrollTop > 0); setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1); }; @@ -299,16 +299,20 @@ const ScrollableContent: React.FC<{ children: React.ReactNode }> = ({ return; } - updateShadows(); + const updateObservedShadows = () => { + setShadowState(el); + }; + + updateObservedShadows(); - const observer = new ResizeObserver(updateShadows); + const observer = new ResizeObserver(updateObservedShadows); observer.observe(el); for (const child of el.children) { observer.observe(child); } return () => observer.disconnect(); - }, [updateShadows]); + }); return (
@@ -321,7 +325,9 @@ const ScrollableContent: React.FC<{ children: React.ReactNode }> = ({
{ + setShadowState(scrollRef.current); + }} > {children}
diff --git a/libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-visualizer.ts b/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/simulator/compile-visualizer.ts rename to libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx index 338e915c9c0..ee22e9f9fe5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.stories.tsx @@ -76,7 +76,7 @@ const PlaybackSettingsMenuStory = ({ > { window.removeEventListener("keydown", handleKeyDown); }; - }, [handleKeyDown]); + }, []); } 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 deleted file mode 100644 index 8fbcdd92837..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ /dev/null @@ -1,1416 +0,0 @@ -import { css } from "@hashintel/ds-helpers/css"; -import { use, useEffect, useMemo, useRef, useState } from "react"; -import { TbList, TbPencil, TbPlus } from "react-icons/tb"; -import uPlot from "uplot"; -import "uplot/dist/uPlot.min.css"; - -import { IconButton } from "../../../../../components/icon-button"; -import { SegmentGroup } from "../../../../../components/segment-group"; -import { Select } from "../../../../../components/select"; -import type { SubView } from "../../../../../components/sub-view/types"; -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 CompiledMetric, - compileMetric, -} from "../../../../../../core/simulation/compile-metric"; -import { - SimulationContext, - type SimulationFrame, -} from "../../../../../../react/simulation/context"; -import { buildMetricState } from "../../../../../../core/simulation/metric-state"; -import { - EditorContext, - type TimelineChartType, - type TimelineView, -} from "../../../../../../react/state/editor-context"; -import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; -import { CreateMetricDrawer } from "../../SimulateView/create-metric-drawer"; -import { ViewMetricDrawer } from "../../SimulateView/view-metric-drawer"; - -// -- Styles ------------------------------------------------------------------- - -const containerStyle = css({ - display: "flex", - flexDirection: "column", - height: "[100%]", - paddingTop: "[4px]", -}); - -const chartAreaStyle = css({ - position: "relative", - flex: "[1]", - minHeight: "[0]", -}); - -const legendContainerStyle = css({ - display: "flex", - flexWrap: "wrap", - gap: "[12px]", - fontSize: "[11px]", - color: "[#666]", - paddingY: "3", - paddingX: "3", -}); - -const legendItemStyle = css({ - display: "flex", - alignItems: "center", - gap: "[4px]", - cursor: "pointer", - userSelect: "none", - transition: "[opacity 0.15s ease]", - _hover: { - opacity: 1, - }, -}); - -const legendColorStyle = css({ - width: "[10px]", - height: "[10px]", - borderRadius: "[2px]", -}); - -const tooltipStyle = css({ - position: "absolute", - pointerEvents: "none", - backgroundColor: "[rgba(0, 0, 0, 0.85)]", - color: "neutral.s00", - padding: "[6px 10px]", - borderRadius: "md", - fontSize: "[11px]", - lineHeight: "[1.4]", - zIndex: "[1000]", - whiteSpace: "nowrap", - boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", - display: "none", -}); - -const tooltipLabelStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", -}); - -const tooltipDotStyle = css({ - width: "[8px]", - height: "[8px]", - borderRadius: "[50%]", - flexShrink: "[0]", -}); - -const tooltipValueStyle = css({ - fontWeight: "semibold", - marginLeft: "[4px]", -}); - -// -- Constants ---------------------------------------------------------------- - -const DEFAULT_COLORS = [ - "#3b82f6", // blue - "#ef4444", // red - "#22c55e", // green - "#f59e0b", // amber - "#8b5cf6", // violet - "#06b6d4", // cyan - "#ec4899", // pink - "#84cc16", // lime -]; - -const CHART_TYPE_OPTIONS = [ - { value: "run", label: "Run" }, - { value: "stacked", label: "Stacked" }, -]; - -// -- Types -------------------------------------------------------------------- - -/** Metadata for each place (stable across streaming updates). */ -interface PlaceMeta { - placeId: string; - placeName: string; - color: string; -} - -// -- Header action ------------------------------------------------------------ - -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 -
- setTimelineView(selectValueToView(value))} + /> +
+
+ {selectedMetric && ( + setIsViewOpen(true)} + > + + + )} + setIsCreateOpen(true)} + > + + + { + setSimulateViewMode("metrics"); + setGlobalMode("simulate"); + }} + > + + +
+ setIsCreateOpen(false)} + /> + setIsViewOpen(false)} + metric={selectedMetric} + /> + + ); +}; + +export const TimelineHeaderActions: React.FC = () => ( +
+ + +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/index.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/index.ts new file mode 100644 index 00000000000..70048642ea7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/index.ts @@ -0,0 +1 @@ +export { simulationTimelineSubView } from "./main"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/legend.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/legend.tsx new file mode 100644 index 00000000000..d0404b52d2b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/legend.tsx @@ -0,0 +1,49 @@ +import type { FC } from "react"; + +import { + legendColorStyle, + legendContainerStyle, + legendItemStyle, +} from "./styles"; +import type { TimelineSeriesMeta } from "./types"; + +export const TimelineLegend: FC<{ + series: TimelineSeriesMeta[]; + hiddenSeries: Set; + onToggleVisibility: (seriesId: string) => void; +}> = ({ series, hiddenSeries, onToggleVisibility }) => ( +
+ {series.map((item) => { + const isHidden = hiddenSeries.has(item.seriesId); + + return ( +
onToggleVisibility(item.seriesId)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleVisibility(item.seriesId); + } + }} + style={{ + opacity: isHidden ? 0.4 : 1, + textDecoration: isHidden ? "line-through" : "none", + }} + > +
+ {item.seriesName} +
+ ); + })} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx new file mode 100644 index 00000000000..58b366840a1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx @@ -0,0 +1,80 @@ +import { use, useState } from "react"; + +import { PlaybackContext } from "../../../../../../../react/playback/context"; +import { SimulationContext } from "../../../../../../../react/simulation/context"; +import { EditorContext } from "../../../../../../../react/state/editor-context"; +import type { SubView } from "../../../../../../components/sub-view/types"; +import { UPlotChart } from "./chart"; +import { TimelineHeaderActions } from "./header"; +import { TimelineLegend } from "./legend"; +import { chartAreaStyle, containerStyle } from "./styles"; +import { useStreamingData } from "./use-streaming-data"; + +const SimulationTimelineContent: React.FC = () => { + const { timelineChartType: chartType } = use(EditorContext); + const { totalFrames } = use(SimulationContext); + const { currentFrameIndex } = use(PlaybackContext); + const { store, metricError } = useStreamingData(); + + const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + + const toggleSeriesVisibility = (seriesId: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev); + if (next.has(seriesId)) { + next.delete(seriesId); + } else { + next.add(seriesId); + } + return next; + }); + }; + + if (metricError) { + return ( +
+ {metricError} +
+ ); + } + + if (store.length === 0 || totalFrames === 0) { + return ( +
+ + No simulation data available + +
+ ); + } + + return ( +
+ + {store.series.length > 1 && ( + + )} +
+ ); +}; + +export const simulationTimelineSubView: SubView = { + id: "simulation-timeline", + title: "Timeline", + tooltip: + "View the simulation timeline with compartment time-series. Click/drag to scrub through frames.", + component: SimulationTimelineContent, + renderHeaderAction: () => , + noPadding: true, +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts new file mode 100644 index 00000000000..bf06963fd25 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts @@ -0,0 +1,53 @@ +import type { CompiledMetric } from "../../../../../../../../core/simulation/authoring/compile-metric"; +import type { + Color, + Metric, + Place, + Transition, +} from "../../../../../../../../core/types/sdcpn"; +import type { TimelineView } from "../../../../../../../../react/state/editor-context"; +import type { TimelineSeriesConfig } from "../types"; +import { buildMetricSeriesConfig } from "./metric"; +import { buildPerPlaceSeriesConfig } from "./per-place"; +import { buildPerTransitionSeriesConfig } from "./per-transition"; +import { buildPerTypeSeriesConfig } from "./per-type"; + +/** + * Selects the timeline series builder for the active timeline view. + * + * The streaming hook calls this once per view/net/metric change, then uses the + * returned series metadata and frame extractor while appending simulation data. + */ +export function buildTimelineSeriesConfig(args: { + timelineView: TimelineView; + places: Place[]; + types: Color[]; + transitions: Transition[]; + selectedMetric: Metric | null; + compiledMetric: CompiledMetric | null; +}): TimelineSeriesConfig { + const { + timelineView, + places, + types, + transitions, + selectedMetric, + compiledMetric, + } = args; + + switch (timelineView.kind) { + case "metric": + return buildMetricSeriesConfig({ + metric: selectedMetric, + compiledMetric, + places, + types, + }); + case "per-transition": + return buildPerTransitionSeriesConfig({ transitions }); + case "per-type": + return buildPerTypeSeriesConfig({ places, types }); + case "per-place": + return buildPerPlaceSeriesConfig({ places, types }); + } +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts new file mode 100644 index 00000000000..cf955fe219e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts @@ -0,0 +1,49 @@ +import type { CompiledMetric } from "../../../../../../../../core/simulation/authoring/compile-metric"; +import { buildMetricState } from "../../../../../../../../core/simulation/frames/metric-state"; +import type { + Color, + Metric, + Place, +} from "../../../../../../../../core/types/sdcpn"; +import { DEFAULT_COLORS } from "../default-colors"; +import type { TimelineSeriesConfig } from "../types"; + +/** + * Builds the timeline series for a user-authored metric. + * + * Metric views expose one plotted series and evaluate the compiled metric + * against each incoming frame. Runtime metric errors become NaN so uPlot draws + * a gap instead of crashing the timeline. + */ +export function buildMetricSeriesConfig(args: { + metric: Metric | null; + compiledMetric: CompiledMetric | null; + places: Place[]; + types: Color[]; +}): TimelineSeriesConfig { + const { metric, compiledMetric, places, types } = args; + + if (!metric || !compiledMetric) { + return { + series: [], + extract: () => Number.NaN, + }; + } + + return { + series: [ + { + seriesId: metric.id, + seriesName: metric.name, + color: DEFAULT_COLORS[0]!, + }, + ], + extract: (frame) => { + try { + return compiledMetric(buildMetricState(frame, places, types)); + } catch { + return Number.NaN; + } + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-place.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-place.ts new file mode 100644 index 00000000000..bfa87ae4527 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-place.ts @@ -0,0 +1,35 @@ +import type { Color, Place } from "../../../../../../../../core/types/sdcpn"; +import { DEFAULT_COLORS } from "../default-colors"; +import type { TimelineSeriesConfig } from "../types"; + +/** + * Builds the default per-place timeline view. + * + * Each place becomes one series, colored from its token type when available, + * and each frame contributes the current token count for that place. + */ +export function buildPerPlaceSeriesConfig(args: { + places: Place[]; + types: Color[]; +}): TimelineSeriesConfig { + const { places, types } = args; + const placeIds = places.map((place) => place.id); + + return { + series: places.map((place, index) => { + const tokenType = types.find((type) => type.id === place.colorId); + + return { + seriesId: place.id, + seriesName: place.name, + color: + tokenType?.displayColor ?? + DEFAULT_COLORS[index % DEFAULT_COLORS.length]!, + }; + }), + extract: (frame, seriesIdx) => { + const id = placeIds[seriesIdx]; + return id ? frame.getPlaceTokenCount(id) : 0; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-transition.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-transition.ts new file mode 100644 index 00000000000..89a3a84c3b5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-transition.ts @@ -0,0 +1,103 @@ +import type { Transition } from "../../../../../../../../core/types/sdcpn"; +import { DEFAULT_COLORS } from "../default-colors"; +import type { TimelineSeriesConfig } from "../types"; + +const PER_TRANSITION_WINDOW_SEC = 4; +const OUTPUT_EWMA_ALPHA = 0.15; + +/** + * Builds the per-transition firing timeline view. + * + * Each transition becomes one series. The extractor tracks cumulative firing + * counts over time and returns a smoothed trailing-window firing delta so the + * chart is readable even when transition firings are bursty. + */ +export function buildPerTransitionSeriesConfig(args: { + transitions: Transition[]; +}): TimelineSeriesConfig { + const { transitions } = args; + const transitionIds = transitions.map((transition) => transition.id); + + const timeHistory: number[] = []; + const cumulativeHistories: number[][] = transitions.map(() => []); + const lastFiringTimes: (number | null)[] = transitions.map(() => null); + const lastIntervals: (number | null)[] = transitions.map(() => null); + const prevFiringCounts: number[] = transitions.map(() => 0); + const smoothedOutputs: number[] = transitions.map(() => 0); + + const resetState = () => { + timeHistory.length = 0; + for (const history of cumulativeHistories) { + history.length = 0; + } + for (let i = 0; i < transitions.length; i++) { + lastFiringTimes[i] = null; + lastIntervals[i] = null; + prevFiringCounts[i] = 0; + smoothedOutputs[i] = 0; + } + }; + + return { + series: transitions.map((transition, index) => ({ + seriesId: `transition__${transition.id}`, + seriesName: transition.name, + color: DEFAULT_COLORS[index % DEFAULT_COLORS.length]!, + })), + extract: (frame, seriesIdx) => { + const id = transitionIds[seriesIdx]; + if (!id) { + return 0; + } + + if (seriesIdx === 0) { + const last = timeHistory[timeHistory.length - 1]; + if (last !== undefined && frame.time < last) { + resetState(); + } + timeHistory.push(frame.time); + } + + const transitionState = frame.getTransitionState(id); + const firingCount = transitionState?.firingCount ?? 0; + const tslSec = (transitionState?.timeSinceLastFiringMs ?? 0) / 1000; + + if (firingCount > prevFiringCounts[seriesIdx]!) { + const prevFiringTime = lastFiringTimes[seriesIdx] ?? null; + if (prevFiringTime !== null) { + const interval = frame.time - prevFiringTime; + if (interval > 0) { + lastIntervals[seriesIdx] = interval; + } + } + lastFiringTimes[seriesIdx] = frame.time; + } + prevFiringCounts[seriesIdx] = firingCount; + + const interval = lastIntervals[seriesIdx] ?? null; + const interpolated = + interval !== null && interval > 0 + ? firingCount + Math.min(1, tslSec / interval) + : firingCount; + + cumulativeHistories[seriesIdx]!.push(interpolated); + + const targetTime = frame.time - PER_TRANSITION_WINDOW_SEC; + const history = cumulativeHistories[seriesIdx]!; + let prev = 0; + for (let i = timeHistory.length - 2; i >= 0; i--) { + if (timeHistory[i]! <= targetTime) { + prev = history[i]!; + break; + } + } + + const rawDelta = interpolated - prev; + const smoothed = + OUTPUT_EWMA_ALPHA * rawDelta + + (1 - OUTPUT_EWMA_ALPHA) * smoothedOutputs[seriesIdx]!; + smoothedOutputs[seriesIdx] = smoothed; + return smoothed; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-type.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-type.ts new file mode 100644 index 00000000000..d10e6627b69 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/per-type.ts @@ -0,0 +1,72 @@ +import type { Color, Place } from "../../../../../../../../core/types/sdcpn"; +import { DEFAULT_COLORS } from "../default-colors"; +import type { TimelineSeriesConfig, TimelineSeriesMeta } from "../types"; + +const UNTYPED_COLOR = "#94a3b8"; // slate-400 + +/** + * Builds the per-type timeline view. + * + * Places are grouped by token color/type, then each frame sums token counts + * across the places in that group. Places without a type are grouped under + * "Untyped". + */ +export function buildPerTypeSeriesConfig(args: { + places: Place[]; + types: Color[]; +}): TimelineSeriesConfig { + const { places, types } = args; + const groups: { series: TimelineSeriesMeta; placeIds: string[] }[] = []; + + for (const type of types) { + const placeIds = places + .filter((place) => place.colorId === type.id) + .map((place) => place.id); + + if (placeIds.length === 0) { + continue; + } + + groups.push({ + series: { + seriesId: `type__${type.id}`, + seriesName: type.name, + color: type.displayColor || DEFAULT_COLORS[0]!, + }, + placeIds, + }); + } + + const untypedIds = places + .filter((place) => place.colorId === null) + .map((place) => place.id); + + if (untypedIds.length > 0) { + groups.push({ + series: { + seriesId: "type__untyped", + seriesName: "Untyped", + color: UNTYPED_COLOR, + }, + placeIds: untypedIds, + }); + } + + const groupPlaceIds = groups.map((group) => group.placeIds); + + return { + series: groups.map((group) => group.series), + extract: (frame, seriesIdx) => { + const placeIds = groupPlaceIds[seriesIdx]; + if (!placeIds) { + return 0; + } + + let sum = 0; + for (const id of placeIds) { + sum += frame.getPlaceTokenCount(id); + } + return sum; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/styles.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/styles.ts new file mode 100644 index 00000000000..6988a824930 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/styles.ts @@ -0,0 +1,75 @@ +import { css } from "@hashintel/ds-helpers/css"; + +export const containerStyle = css({ + display: "flex", + flexDirection: "column", + height: "[100%]", + paddingTop: "[4px]", +}); + +export const chartAreaStyle = css({ + position: "relative", + flex: "[1]", + minHeight: "[0]", +}); + +export const legendContainerStyle = css({ + display: "flex", + flexWrap: "wrap", + gap: "[12px]", + fontSize: "[11px]", + color: "[#666]", + paddingY: "3", + paddingX: "3", +}); + +export const legendItemStyle = css({ + display: "flex", + alignItems: "center", + gap: "[4px]", + cursor: "pointer", + userSelect: "none", + transition: "[opacity 0.15s ease]", + _hover: { + opacity: 1, + }, +}); + +export const legendColorStyle = css({ + width: "[10px]", + height: "[10px]", + borderRadius: "[2px]", +}); + +export const tooltipStyle = css({ + position: "absolute", + pointerEvents: "none", + backgroundColor: "[rgba(0, 0, 0, 0.85)]", + color: "neutral.s00", + padding: "[6px 10px]", + borderRadius: "md", + fontSize: "[11px]", + lineHeight: "[1.4]", + zIndex: "[1000]", + whiteSpace: "nowrap", + boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", + display: "none", +}); + +export const tooltipLabelStyle = css({ + display: "flex", + alignItems: "center", + gap: "[6px]", +}); + +export const tooltipDotStyle = css({ + width: "[8px]", + height: "[8px]", + borderRadius: "[50%]", + flexShrink: "[0]", +}); + +export const tooltipValueStyle = css({ + fontWeight: "semibold", + marginLeft: "[4px]", +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/types.ts new file mode 100644 index 00000000000..9b07a9771c3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/types.ts @@ -0,0 +1,41 @@ +import type { SimulationFrameReader } from "../../../../../../../react/simulation/context"; + +/** Frame reader consumed by timeline series extractors. */ +export type TimelineFrame = SimulationFrameReader; + +/** Metadata for each plotted series, stable across streaming updates. */ +export interface TimelineSeriesMeta { + seriesId: string; + seriesName: string; + color: string; +} + +/** + * Returns the value for series `seriesIdx` at the given frame. + * Returning NaN leaves a gap on the chart. + */ +export type TimelineSeriesExtractor = ( + frame: TimelineFrame, + seriesIdx: number, +) => number; + +/** Series metadata and extractor for the active timeline view. */ +export interface TimelineSeriesConfig { + series: TimelineSeriesMeta[]; + extract: TimelineSeriesExtractor; +} + +/** + * Streaming data store that builds uPlot columnar arrays directly. + * New frames are pushed in O(k) where k = new frames, no full-array copies. + */ +export interface StreamingStore { + /** Series metadata (stable) */ + series: TimelineSeriesMeta[]; + /** Columnar arrays: [times, ...seriesValues], mutated in place */ + columns: number[][]; + /** Current frame count in the columns */ + length: number; + /** Revision counter, incremented on every append to trigger React updates */ + revision: number; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/use-streaming-data.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/use-streaming-data.ts new file mode 100644 index 00000000000..8d5d04aa03c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/use-streaming-data.ts @@ -0,0 +1,225 @@ +import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; +import { use, useEffect, useRef, useSyncExternalStore } from "react"; +import { + type CompiledMetric, + compileMetric, +} from "../../../../../../../core/simulation/authoring/compile-metric"; +import type { Metric } from "../../../../../../../core/types/sdcpn"; +import { SimulationContext } from "../../../../../../../react/simulation/context"; +import { EditorContext } from "../../../../../../../react/state/editor-context"; +import { buildTimelineSeriesConfig } from "./series-config"; +import type { + StreamingStore, + TimelineFrame, + TimelineSeriesExtractor, + TimelineSeriesMeta, +} from "./types"; + +function createEmptyStore(series: TimelineSeriesMeta[]): StreamingStore { + return { + series, + columns: [[], ...series.map(() => [])], + length: 0, + revision: 0, + }; +} + +function resetStore(store: StreamingStore, series: TimelineSeriesMeta[]): void { + Object.assign(store, { + series, + columns: [[], ...series.map(() => [])], + length: 0, + revision: store.revision + 1, + }); +} + +interface StreamingStoreSnapshot { + store: StreamingStore; + revision: number; +} + +interface StreamingStoreController { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => StreamingStoreSnapshot; + getLength: () => number; + reset: (series: TimelineSeriesMeta[]) => void; + resetCurrentSeries: () => void; + appendFrames: ( + frames: TimelineFrame[], + extract: TimelineSeriesExtractor, + ) => void; +} + +function createStreamingStoreController( + series: TimelineSeriesMeta[], +): StreamingStoreController { + const listeners = new Set<() => void>(); + const store = createEmptyStore(series); + let snapshot: StreamingStoreSnapshot = { + store, + revision: store.revision, + }; + + const notify = () => { + snapshot = { + store, + revision: store.revision, + }; + for (const listener of listeners) { + listener(); + } + }; + + return { + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot: () => snapshot, + getLength: () => store.length, + reset: (nextPlaces) => { + resetStore(store, nextPlaces); + notify(); + }, + resetCurrentSeries: () => { + resetStore(store, store.series); + notify(); + }, + appendFrames: (frames, extract) => { + const cols = store.columns; + const timeCol = cols[0]!; + const seriesCount = store.series.length; + + for (const frame of frames) { + timeCol.push(frame.time); + for (let s = 0; s < seriesCount; s++) { + cols[s + 1]!.push(extract(frame, s)); + } + } + + store.length = timeCol.length; + store.revision++; + notify(); + }, + }; +} + +function compileTimelineMetric(metric: Metric | null): { + fn: CompiledMetric | null; + error: string | null; +} { + if (!metric) { + return { fn: null, error: null }; + } + + const outcome = compileMetric(metric); + if (outcome.ok) { + return { fn: outcome.fn, error: null }; + } + return { fn: null, error: outcome.error }; +} + +/** + * Hook that streams simulation frames directly into uPlot columnar arrays. + * + * uPlot data is columnar: the first array contains x-values, then each + * following array contains y-values for one plotted series. + * See: https://github.com/leeoniya/uPlot/blob/master/docs/README.md#data-format + * + * Returns the current streaming store snapshot for the active timeline view. + * + * Handles three view modes driven by `timelineView`: + * - `per-place`: one series per place, values are token counts. + * - `per-type`: one series per color type (plus "Untyped" for uncolored + * places), values are the sum of token counts across places of that type. + * - `metric`: a single series computed by the compiled user metric. + */ +export function useStreamingData(): { + store: StreamingStore; + metricError: string | null; +} { + const { getFramesInRange, totalFrames } = use(SimulationContext); + const { + petriNetDefinition: { places, types, transitions, metrics }, + } = use(SDCPNContext); + const { timelineView } = use(EditorContext); + + const selectedMetric = + timelineView.kind === "metric" + ? (metrics?.find((metric) => metric.id === timelineView.metricId) ?? null) + : null; + + const compiledMetric = compileTimelineMetric(selectedMetric); + + // Computes the active timeline view mode described above into concrete uPlot + // series metadata and the per-frame value extractor used while streaming. + const seriesConfig = buildTimelineSeriesConfig({ + timelineView, + places, + types, + transitions, + selectedMetric, + compiledMetric: compiledMetric.fn, + }); + + const storeController = createStreamingStoreController([]); + const { store } = useSyncExternalStore( + storeController.subscribe, + storeController.getSnapshot, + storeController.getSnapshot, + ); + + // Imperative cursor for streaming: this is the next frame index that has not + // yet been appended to the uPlot columns. Updating it should not re-render. + const processedRef = useRef(0); + + // Reset store when the series structure changes (view switch or net edits). + useEffect(() => { + storeController.reset(seriesConfig.series); + processedRef.current = 0; + }, [seriesConfig.series, storeController]); + + // Stream new frames into the store + useEffect(() => { + let cancelled = false; + + const fetchData = async () => { + if (totalFrames === 0) { + if (storeController.getLength() > 0) { + storeController.resetCurrentSeries(); + processedRef.current = 0; + } + return; + } + + // Handle simulation restart + if (totalFrames < processedRef.current) { + storeController.resetCurrentSeries(); + processedRef.current = 0; + } + + const startIndex = processedRef.current; + if (startIndex >= totalFrames) { + return; + } + + const newFrames = await getFramesInRange(startIndex); + if (cancelled || newFrames.length === 0) { + return; + } + + storeController.appendFrames(newFrames, seriesConfig.extract); + processedRef.current = totalFrames; + }; + + void fetchData(); + return () => { + cancelled = true; + }; + }, [getFramesInRange, totalFrames, seriesConfig, storeController]); + + return { + store, + metricError: compiledMetric.error, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 8f0fdbb74a2..ccde5e0dd9d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -551,6 +551,7 @@ const FilterableListContent = ({ } }} role="option" + tabIndex={-1} aria-selected={selected} className={listItemRowStyle({ selectable: true, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx index 1b6eb4ca735..07c643ac4a7 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx @@ -23,7 +23,7 @@ export const InitialStateEditor: React.FC = ({ readOnly = false, }) => { const { initialMarking, setInitialMarking } = use(SimulationContext); - const { currentFrame, totalFrames } = use(PlaybackContext); + const { currentFrameReader, totalFrames } = use(PlaybackContext); const hasSimulation = totalFrames > 0; @@ -39,21 +39,12 @@ export const InitialStateEditor: React.FC = ({ // Get current marking for this place - either from simulation frame or initial marking const currentMarking = useMemo(() => { - if (hasSimulation && currentFrame) { - const placeState = currentFrame.places[placeId]; - if (!placeState) { - return null; - } - - const { offset, count, dimensions } = placeState; - const placeSize = count * dimensions; - const values = currentFrame.buffer.slice(offset, offset + placeSize); - - return { values, count }; + if (hasSimulation && currentFrameReader) { + return currentFrameReader.getPlaceTokenValues(placeId); } return initialMarking.get(placeId) ?? null; - }, [hasSimulation, currentFrame, initialMarking, placeId]); + }, [hasSimulation, currentFrameReader, initialMarking, placeId]); // Convert Float64Array marking data to number[][] for the Spreadsheet const data: number[][] = useMemo(() => { @@ -92,7 +83,7 @@ export const InitialStateEditor: React.FC = ({ setInitialMarking(placeId, { values, count }); }; - }, [hasSimulation, columns.length, setInitialMarking, placeId]); + }, [hasSimulation, readOnly, columns.length, setInitialMarking, placeId]); return ; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx index d5308e48110..d7ab8ea296f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx @@ -102,7 +102,7 @@ const PlaceInitialStateContent: React.FC = () => { const { initialMarking, setInitialMarking, selectedScenarioId } = use(SimulationContext); - const { currentFrame, totalFrames } = use(PlaybackContext); + const { currentFrameReader, totalFrames } = use(PlaybackContext); // Determine if simulation is running (has frames) const hasSimulationFrames = totalFrames > 0; @@ -124,9 +124,8 @@ const PlaceInitialStateContent: React.FC = () => { // Uncolored places: show token count let tokenCount = 0; - if (hasSimulationFrames && currentFrame) { - const placeState = currentFrame.places[place.id]; - tokenCount = placeState?.count ?? 0; + if (hasSimulationFrames && currentFrameReader) { + tokenCount = currentFrameReader.getPlaceTokenCount(place.id); } else { const marking = initialMarking.get(place.id); tokenCount = marking?.count ?? 0; @@ -151,9 +150,8 @@ const PlaceInitialStateContent: React.FC = () => { if (!placeType || placeType.elements.length === 0) { // Get token count from simulation frame or initial marking let currentTokenCount = 0; - if (hasSimulationFrames && currentFrame) { - const placeState = currentFrame.places[place.id]; - currentTokenCount = placeState?.count ?? 0; + if (hasSimulationFrames && currentFrameReader) { + currentTokenCount = currentFrameReader.getPlaceTokenCount(place.id); } else { const currentMarking = initialMarking.get(place.id); currentTokenCount = currentMarking?.count ?? 0; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index aa90453ecd8..dfb6c08772b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -20,7 +20,7 @@ import { import { CodeEditor } from "../../../../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../../../../../react/playback/context"; import { SimulationContext } from "../../../../../../../../react/simulation/context"; -import { compileVisualizer } from "../../../../../../../../core/simulation/simulator/compile-visualizer"; +import { compileVisualizer } from "../../../../../../../lib/compile-visualizer"; import { EditorContext } from "../../../../../../../../react/state/editor-context"; import { usePlacePropertiesContext } from "../../context"; import { VisualizerErrorBoundary } from "./visualizer-error-boundary"; @@ -83,7 +83,7 @@ const VisualizerPreview: React.FC = () => { const { place, placeType } = usePlacePropertiesContext(); const { initialMarking, parameterValues } = use(SimulationContext); - const { currentFrame, totalFrames } = use(PlaybackContext); + const { currentFrameReader, totalFrames } = use(PlaybackContext); const defaultParameterValues = useDefaultParameterValues(); @@ -112,19 +112,19 @@ const VisualizerPreview: React.FC = () => { const tokens: Record[] = []; let parameters: Record = {}; - if (totalFrames > 0 && currentFrame) { - const placeState = currentFrame.places[place.id]; - if (!placeState) { + if (totalFrames > 0 && currentFrameReader) { + const placeTokenValues = currentFrameReader.getPlaceTokenValues(place.id); + if (!placeTokenValues) { return
Place not found in frame
; } - const { offset, count } = placeState; - const placeSize = count * dimensions; - const tokenValues = Array.from( - currentFrame.buffer.slice(offset, offset + placeSize), - ); + const tokenValues = Array.from(placeTokenValues.values); - for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + for ( + let tokenIndex = 0; + tokenIndex < placeTokenValues.count; + tokenIndex++ + ) { const token: Record = {}; for (let colIndex = 0; colIndex < dimensions; colIndex++) { const dimensionName = placeType.elements[colIndex]!.name; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx index f4224763a81..974c6b20db0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/create-metric-drawer.tsx @@ -6,7 +6,7 @@ import { Button } from "../../../../components/button"; import { Drawer } from "../../../../components/drawer"; import { metricSchema } from "../../../../../core/schemas/metric-schema"; import { LanguageClientContext } from "../../../../../react/lsp/context"; -import { compileMetric } from "../../../../../core/simulation/compile-metric"; +import { compileMetric } from "../../../../../core/simulation/authoring/compile-metric"; import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metric-form.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metric-form.tsx index c183773a0df..56b2aacf366 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metric-form.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metric-form.tsx @@ -154,16 +154,16 @@ export function useMetricLspSession(code: string): string { const [sessionId] = useState(() => crypto.randomUUID()); const initializedRef = useRef(false); - const sessionData = { sessionId, code }; - useEffect(() => { + const sessionData = { sessionId, code }; + if (!initializedRef.current) { initializeMetricSession(sessionData); initializedRef.current = true; } else { updateMetricSession(sessionData); } - }, [sessionData, initializeMetricSession, updateMetricSession]); + }, [code, initializeMetricSession, sessionId, updateMetricSession]); useEffect(() => { return () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenario-form.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenario-form.tsx index db917281edb..63588390658 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenario-form.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenario-form.tsx @@ -460,39 +460,50 @@ function useScenarioLspSession({ const [sessionId] = useState(() => crypto.randomUUID()); const initializedRef = useRef(false); - // Build session data for all parameters and places that use code editors. - // Computed during render — React Compiler handles memoization. - const allOverrides: Record = {}; - for (const param of parameters) { - allOverrides[param.id] = parameterOverrides[param.id] ?? ""; - } + useEffect(() => { + const allOverrides: Record = {}; + for (const param of parameters) { + allOverrides[param.id] = parameterOverrides[param.id] ?? ""; + } - // Include entries for places that use code editors (not spreadsheets) - const allInitialState: Record = {}; - for (const place of places) { - const placeType = place.colorId ? typesById.get(place.colorId) : undefined; - if (!placeType || placeType.elements.length === 0) { - allInitialState[place.id] = initialTokenCounts[place.id] ?? ""; + const allInitialState: Record = {}; + for (const place of places) { + const placeType = place.colorId + ? typesById.get(place.colorId) + : undefined; + if (!placeType || placeType.elements.length === 0) { + allInitialState[place.id] = initialTokenCounts[place.id] ?? ""; + } } - } - const sessionData = { - sessionId, - scenarioParameters: scenarioParams.map(({ _key: _, ...rest }) => rest), - parameterOverrides: allOverrides, - initialState: allInitialState, - initialStateCode, - initialStateAsCode, - }; + const sessionData = { + sessionId, + scenarioParameters: scenarioParams.map(({ _key: _, ...rest }) => rest), + parameterOverrides: allOverrides, + initialState: allInitialState, + initialStateCode, + initialStateAsCode, + }; - useEffect(() => { if (!initializedRef.current) { initializeScenarioSession(sessionData); initializedRef.current = true; } else { updateScenarioSession(sessionData); } - }, [sessionData, initializeScenarioSession, updateScenarioSession]); + }, [ + initialStateAsCode, + initialStateCode, + initialTokenCounts, + initializeScenarioSession, + parameterOverrides, + parameters, + places, + scenarioParams, + sessionId, + typesById, + updateScenarioSession, + ]); useEffect(() => { return () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx index 1ba03f34a4e..2f69513f732 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx @@ -7,7 +7,7 @@ import { Drawer } from "../../../../components/drawer"; import { metricSchema } from "../../../../../core/schemas/metric-schema"; import type { Metric } from "../../../../../core/types/sdcpn"; import { LanguageClientContext } from "../../../../../react/lsp/context"; -import { compileMetric } from "../../../../../core/simulation/compile-metric"; +import { compileMetric } from "../../../../../core/simulation/authoring/compile-metric"; import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts index ad49b7fd179..e5d6d6435bb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts @@ -97,6 +97,7 @@ export function useRecenterOnPanelOpen( .catch(() => {}); } }, [ + canvasRef, isBottomPanelOpen, bottomPanelHeight, leftSidebarWidth, diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx index 17a15ef2920..6ddd01a7961 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx @@ -14,7 +14,7 @@ export function useDebounceCallback< return () => { debounced.flush(); }; - }, [func, delay]); + }, [debounced]); return debounced; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 7efd1a5da56..259241bf86e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -116,7 +116,7 @@ export const SDCPNView: React.FC<{ minZoom: minZoom, maxZoom: 1.1, }); - }, [reactFlowInstance, petriNetId]); + }, [minZoom, reactFlowInstance, petriNetId]); // This sets the min zoom (ie the max you can zoom out to) to be slightly larger than the total size of the current net. // We also avoid shrinking the zoom to be lower than the current zoom level to avoid changing the zoom without user input diff --git a/libs/@hashintel/refractive/.oxlintrc.json b/libs/@hashintel/refractive/.oxlintrc.json index 1f31575fedf..8e1c768b058 100644 --- a/libs/@hashintel/refractive/.oxlintrc.json +++ b/libs/@hashintel/refractive/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../.config/oxlint/react-compiler.json"], "plugins": ["import", "react", "jsx-a11y", "unicorn", "typescript"], "categories": { "correctness": "error" @@ -94,9 +95,6 @@ { "button": true, "submit": true, "reset": false } ], - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "off", - "jsx-a11y/prefer-tag-over-role": "off", "jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }], "jsx-a11y/no-noninteractive-tabindex": [ diff --git a/libs/@hashintel/refractive/package.json b/libs/@hashintel/refractive/package.json index d05e1dbfa60..9246e1feb25 100644 --- a/libs/@hashintel/refractive/package.json +++ b/libs/@hashintel/refractive/package.json @@ -35,12 +35,12 @@ "@storybook/react-vite": "10.2.19", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260315.1", + "@typescript/native-preview": "7.0.0-dev.20260507.1", "@vitejs/plugin-react": "6.0.1", "babel-plugin-react-compiler": "1.0.0", - "oxlint": "1.55.0", - "oxlint-tsgolint": "0.17.0", - "rolldown-plugin-dts": "0.22.5", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "rolldown-plugin-dts": "0.25.0", "storybook": "10.2.19", "vite": "8.0.10" }, diff --git a/yarn.lock b/yarn.lock index ef251fe031b..fd3a24b71fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -778,12 +778,12 @@ __metadata: "@sentry/react": "npm:10.22.0" "@types/react": "npm:19.2.7" "@types/react-dom": "npm:19.2.3" - "@typescript/native-preview": "npm:7.0.0-dev.20260315.1" + "@typescript/native-preview": "npm:7.0.0-dev.20260507.1" "@vitejs/plugin-react": "npm:6.0.1" babel-plugin-react-compiler: "npm:1.0.0" immer: "npm:10.1.3" - oxlint: "npm:1.55.0" - oxlint-tsgolint: "npm:0.17.0" + oxlint: "npm:1.63.0" + oxlint-tsgolint: "npm:0.22.1" react: "npm:19.2.3" react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" @@ -3344,17 +3344,17 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:8.0.0-rc.2": - version: 8.0.0-rc.2 - resolution: "@babel/generator@npm:8.0.0-rc.2" +"@babel/generator@npm:8.0.0-rc.4": + version: 8.0.0-rc.4 + resolution: "@babel/generator@npm:8.0.0-rc.4" dependencies: - "@babel/parser": "npm:^8.0.0-rc.2" - "@babel/types": "npm:^8.0.0-rc.2" + "@babel/parser": "npm:^8.0.0-rc.4" + "@babel/types": "npm:^8.0.0-rc.4" "@jridgewell/gen-mapping": "npm:^0.3.12" "@jridgewell/trace-mapping": "npm:^0.3.28" "@types/jsesc": "npm:^2.5.0" jsesc: "npm:^3.0.2" - checksum: 10c0/ffaf6d16c0b60968f25823d006177e5b0021a2fb2d463f9b8ae70b34a48fe530f1395dce6301989424024fffcdd2251f7357cf95dfebd96d3c637daddc5eb1aa + checksum: 10c0/5315aa22671658646bf1b5e2ac827b65a83964f19f5d8ddb851f5daac86c9dee7b8b8119ca7fd6bc682bfbae07dfb37ca95f521193431ed2702ab3665d319a2e languageName: node linkType: hard @@ -3537,17 +3537,17 @@ __metadata: languageName: node linkType: hard -"@babel/helper-string-parser@npm:^8.0.0-rc.2": - version: 8.0.0-rc.2 - resolution: "@babel/helper-string-parser@npm:8.0.0-rc.2" - checksum: 10c0/74b5107ed84c99948651a52ebd880d91f0c307fcf0273b75c79552b2c5469c27573640a5187c87df636e96c247a43c57be70bdac1ce06c29a30aefd499952121 +"@babel/helper-string-parser@npm:^8.0.0-rc.4": + version: 8.0.0-rc.4 + resolution: "@babel/helper-string-parser@npm:8.0.0-rc.4" + checksum: 10c0/8e9950d36b8cd5fb5d03c56cd2cce91cf98efa093110d519e160a948a9e77eade71f292204d7395212c5e763af23b2b6687550fbcaa281a1efd3494a46bd99ca languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:8.0.0-rc.2, @babel/helper-validator-identifier@npm:^8.0.0-rc.2": - version: 8.0.0-rc.2 - resolution: "@babel/helper-validator-identifier@npm:8.0.0-rc.2" - checksum: 10c0/9a1687e18bfb50728ae38b1dac889c1a3bc2c53bdf4c1632533b1b0672cc272c087507a2a7c60c3af20d58bd645e169dd685f892d2a62d580e759e26d67e8788 +"@babel/helper-validator-identifier@npm:8.0.0-rc.4, @babel/helper-validator-identifier@npm:^8.0.0-rc.4": + version: 8.0.0-rc.4 + resolution: "@babel/helper-validator-identifier@npm:8.0.0-rc.4" + checksum: 10c0/dc523bdc206cff0b2a4db97459e7cf31d315d60fa7dff1fdcc8101568dc9e6a929c2126eb96a078b1e8eb26b6b4978526bbf8b5f50d91d809c77956a3b84064f languageName: node linkType: hard @@ -3586,14 +3586,14 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:8.0.0-rc.2, @babel/parser@npm:^8.0.0-beta.4, @babel/parser@npm:^8.0.0-rc.2": - version: 8.0.0-rc.2 - resolution: "@babel/parser@npm:8.0.0-rc.2" +"@babel/parser@npm:8.0.0-rc.4, @babel/parser@npm:^8.0.0-beta.4, @babel/parser@npm:^8.0.0-rc.4": + version: 8.0.0-rc.4 + resolution: "@babel/parser@npm:8.0.0-rc.4" dependencies: - "@babel/types": "npm:^8.0.0-rc.2" + "@babel/types": "npm:^8.0.0-rc.4" bin: parser: ./bin/babel-parser.js - checksum: 10c0/704ddbc1fce338e5b8df6327c1a7eeb1a554d136f89738135a8be5f5e2e854bd3f05eb3946b9d7b6814491bcd677175496076657696674eaecbed0e582749b2e + checksum: 10c0/3f3c6c7b62e2c8256237f39d78b7449c316797390a3ac8af1698995f1c3a7375082033d50cbfc18109b07cb8ef9c4e11e8bba394ec13165ba92a22622244bb1f languageName: node linkType: hard @@ -4810,16 +4810,6 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:8.0.0-rc.2, @babel/types@npm:^8.0.0-rc.2": - version: 8.0.0-rc.2 - resolution: "@babel/types@npm:8.0.0-rc.2" - dependencies: - "@babel/helper-string-parser": "npm:^8.0.0-rc.2" - "@babel/helper-validator-identifier": "npm:^8.0.0-rc.2" - checksum: 10c0/8b372115aa4ee3f55541e19887683655e32b78b64579d2402119920af3512594a2be820e05db4a3100cb38db3da3a7bdcc1beb7d031a4ab0129f28d858f129bd - languageName: node - linkType: hard - "@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.5, @babel/types@npm:^7.25.4, @babel/types@npm:^7.25.6, @babel/types@npm:^7.26.0, @babel/types@npm:^7.26.10, @babel/types@npm:^7.26.3, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.4.4": version: 7.29.0 resolution: "@babel/types@npm:7.29.0" @@ -4830,6 +4820,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^8.0.0-rc.4": + version: 8.0.0-rc.4 + resolution: "@babel/types@npm:8.0.0-rc.4" + dependencies: + "@babel/helper-string-parser": "npm:^8.0.0-rc.4" + "@babel/helper-validator-identifier": "npm:^8.0.0-rc.4" + checksum: 10c0/fd6ba6dcebe79c3f79273da371ec7a5f479a310342f046126f54839b951b00816e1cad8ccefd326ef7cbd62b96ca49f7805c4351904b83e7388f122ff0bb7512 + languageName: node + linkType: hard + "@bcherny/json-schema-ref-parser@npm:10.0.5-fork": version: 10.0.5-fork resolution: "@bcherny/json-schema-ref-parser@npm:10.0.5-fork" @@ -7694,7 +7694,7 @@ __metadata: "@types/lodash-es": "npm:4.17.12" "@types/react": "npm:19.2.7" "@types/react-dom": "npm:19.2.3" - "@typescript/native-preview": "npm:7.0.0-dev.20260315.1" + "@typescript/native-preview": "npm:7.0.0-dev.20260507.1" "@vitejs/plugin-react": "npm:6.0.1" "@xyflow/react": "npm:12.10.1" babel-plugin-react-compiler: "npm:1.0.0" @@ -7704,19 +7704,19 @@ __metadata: jsdom: "npm:24.1.3" lodash-es: "npm:4.18.1" monaco-editor: "npm:0.55.1" - oxlint: "npm:1.55.0" - oxlint-tsgolint: "npm:0.17.0" + oxlint: "npm:1.63.0" + oxlint-tsgolint: "npm:0.22.1" react: "npm:19.2.3" react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" react-resizable-panels: "npm:4.6.5" - rolldown: "npm:1.0.0-rc.9" - rolldown-plugin-dts: "npm:0.22.5" - storybook: "npm:10.2.19" + rolldown: "npm:1.0.0" + rolldown-plugin-dts: "npm:0.25.0" + storybook: "npm:10.3.6" typescript: "npm:5.9.3" uplot: "npm:1.6.32" uuid: "npm:14.0.0" - vite: "npm:8.0.10" + vite: "npm:8.0.11" vitest: "npm:4.1.5" vscode-languageserver-types: "npm:3.17.5" web-worker: "npm:1.4.1" @@ -7766,12 +7766,12 @@ __metadata: "@storybook/react-vite": "npm:10.2.19" "@types/react": "npm:19.2.7" "@types/react-dom": "npm:19.2.3" - "@typescript/native-preview": "npm:7.0.0-dev.20260315.1" + "@typescript/native-preview": "npm:7.0.0-dev.20260507.1" "@vitejs/plugin-react": "npm:6.0.1" babel-plugin-react-compiler: "npm:1.0.0" - oxlint: "npm:1.55.0" - oxlint-tsgolint: "npm:0.17.0" - rolldown-plugin-dts: "npm:0.22.5" + oxlint: "npm:1.63.0" + oxlint-tsgolint: "npm:0.22.1" + rolldown-plugin-dts: "npm:0.25.0" storybook: "npm:10.2.19" vite: "npm:8.0.10" peerDependencies: @@ -10007,7 +10007,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.1, @napi-rs/wasm-runtime@npm:^1.1.4": +"@napi-rs/wasm-runtime@npm:^1.1.4": version: 1.1.4 resolution: "@napi-rs/wasm-runtime@npm:1.1.4" dependencies: @@ -12080,13 +12080,6 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.115.0": - version: 0.115.0 - resolution: "@oxc-project/types@npm:0.115.0" - checksum: 10c0/47fc31eb3fb3fcf4119955339f92ba2003f9b445834c1a28ed945cd6b9cd833c7ba66fca88aa5277336c2c55df300a593bc67970e544691eceaa486ebe12cb58 - languageName: node - linkType: hard - "@oxc-project/types@npm:=0.127.0": version: 0.127.0 resolution: "@oxc-project/types@npm:0.127.0" @@ -12094,177 +12087,191 @@ __metadata: languageName: node linkType: hard -"@oxlint-tsgolint/darwin-arm64@npm:0.17.0": - version: 0.17.0 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.17.0" +"@oxc-project/types@npm:=0.128.0": + version: 0.128.0 + resolution: "@oxc-project/types@npm:0.128.0" + checksum: 10c0/b6999b1b6b012d979364231a2c0c9204bca814a73f8417234edd39bf352a081779dad72aaf18ac60a676fb904c1408b63553e4e1230d7408a4f885002d66c809 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.129.0": + version: 0.129.0 + resolution: "@oxc-project/types@npm:0.129.0" + checksum: 10c0/3714ba117af387992c2e5e779eedc1ccaf5a92c4d5c9b014dcc65d5a53012f8daae7aeb28930fef9eae7516bcdc500a0e689480eb1cb44a2e02830201fce7f1a + languageName: node + linkType: hard + +"@oxlint-tsgolint/darwin-arm64@npm:0.22.1": + version: 0.22.1 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.22.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/darwin-x64@npm:0.17.0": - version: 0.17.0 - resolution: "@oxlint-tsgolint/darwin-x64@npm:0.17.0" +"@oxlint-tsgolint/darwin-x64@npm:0.22.1": + version: 0.22.1 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.22.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-arm64@npm:0.17.0": - version: 0.17.0 - resolution: "@oxlint-tsgolint/linux-arm64@npm:0.17.0" +"@oxlint-tsgolint/linux-arm64@npm:0.22.1": + version: 0.22.1 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.22.1" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-x64@npm:0.17.0": - version: 0.17.0 - resolution: "@oxlint-tsgolint/linux-x64@npm:0.17.0" +"@oxlint-tsgolint/linux-x64@npm:0.22.1": + version: 0.22.1 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.22.1" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-arm64@npm:0.17.0": - version: 0.17.0 - resolution: "@oxlint-tsgolint/win32-arm64@npm:0.17.0" +"@oxlint-tsgolint/win32-arm64@npm:0.22.1": + version: 0.22.1 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.22.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-x64@npm:0.17.0": - version: 0.17.0 - resolution: "@oxlint-tsgolint/win32-x64@npm:0.17.0" +"@oxlint-tsgolint/win32-x64@npm:0.22.1": + version: 0.22.1 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.22.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-android-arm-eabi@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-android-arm-eabi@npm:1.55.0" +"@oxlint/binding-android-arm-eabi@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-android-arm-eabi@npm:1.63.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@oxlint/binding-android-arm64@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-android-arm64@npm:1.55.0" +"@oxlint/binding-android-arm64@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-android-arm64@npm:1.63.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-darwin-arm64@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-darwin-arm64@npm:1.55.0" +"@oxlint/binding-darwin-arm64@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-darwin-arm64@npm:1.63.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-darwin-x64@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-darwin-x64@npm:1.55.0" +"@oxlint/binding-darwin-x64@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-darwin-x64@npm:1.63.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-freebsd-x64@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-freebsd-x64@npm:1.55.0" +"@oxlint/binding-freebsd-x64@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-freebsd-x64@npm:1.63.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-linux-arm-gnueabihf@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.55.0" +"@oxlint/binding-linux-arm-gnueabihf@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.63.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxlint/binding-linux-arm-musleabihf@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.55.0" +"@oxlint/binding-linux-arm-musleabihf@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.63.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxlint/binding-linux-arm64-gnu@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.55.0" +"@oxlint/binding-linux-arm64-gnu@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.63.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-arm64-musl@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-arm64-musl@npm:1.55.0" +"@oxlint/binding-linux-arm64-musl@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-arm64-musl@npm:1.63.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-linux-ppc64-gnu@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.55.0" +"@oxlint/binding-linux-ppc64-gnu@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.63.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-riscv64-gnu@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.55.0" +"@oxlint/binding-linux-riscv64-gnu@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.63.0" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-riscv64-musl@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.55.0" +"@oxlint/binding-linux-riscv64-musl@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.63.0" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-linux-s390x-gnu@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.55.0" +"@oxlint/binding-linux-s390x-gnu@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.63.0" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-x64-gnu@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-x64-gnu@npm:1.55.0" +"@oxlint/binding-linux-x64-gnu@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-x64-gnu@npm:1.63.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-x64-musl@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-linux-x64-musl@npm:1.55.0" +"@oxlint/binding-linux-x64-musl@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-linux-x64-musl@npm:1.63.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-openharmony-arm64@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-openharmony-arm64@npm:1.55.0" +"@oxlint/binding-openharmony-arm64@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-openharmony-arm64@npm:1.63.0" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-win32-arm64-msvc@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.55.0" +"@oxlint/binding-win32-arm64-msvc@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.63.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-win32-ia32-msvc@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.55.0" +"@oxlint/binding-win32-ia32-msvc@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.63.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@oxlint/binding-win32-x64-msvc@npm:1.55.0": - version: 1.55.0 - resolution: "@oxlint/binding-win32-x64-msvc@npm:1.55.0" +"@oxlint/binding-win32-x64-msvc@npm:1.63.0": + version: 1.63.0 + resolution: "@oxlint/binding-win32-x64-msvc@npm:1.63.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -14334,6 +14341,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-android-arm64@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-android-arm64@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.17" @@ -14341,13 +14355,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.9" +"@rolldown/binding-android-arm64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.18" conditions: os=android & cpu=arm64 languageName: node linkType: hard +"@rolldown/binding-darwin-arm64@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17" @@ -14355,13 +14376,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.9" +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.18" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard +"@rolldown/binding-darwin-x64@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-darwin-x64@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.17" @@ -14369,13 +14397,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.9" +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.18" conditions: os=darwin & cpu=x64 languageName: node linkType: hard +"@rolldown/binding-freebsd-x64@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17" @@ -14383,13 +14418,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.9" +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.18" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17" @@ -14397,13 +14439,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.9" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.18" conditions: os=linux & cpu=arm languageName: node linkType: hard +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17" @@ -14411,13 +14460,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.9" +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard +"@rolldown/binding-linux-arm64-musl@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17" @@ -14425,13 +14481,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.9" +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.18" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17" @@ -14439,13 +14502,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.9" +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17" @@ -14453,13 +14523,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.9" +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard +"@rolldown/binding-linux-x64-gnu@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17" @@ -14467,13 +14544,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.9" +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard +"@rolldown/binding-linux-x64-musl@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17" @@ -14481,13 +14565,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.9" +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.18" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard +"@rolldown/binding-openharmony-arm64@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17" @@ -14495,13 +14586,24 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.9" +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.18" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard +"@rolldown/binding-wasm32-wasi@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0" + dependencies: + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17" @@ -14513,15 +14615,24 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.9" +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.18" dependencies: - "@napi-rs/wasm-runtime": "npm:^1.1.1" + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" conditions: cpu=wasm32 languageName: node linkType: hard +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17" @@ -14529,13 +14640,20 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.9" +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.18" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard +"@rolldown/binding-win32-x64-msvc@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17" @@ -14543,9 +14661,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.9" +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.18" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -14572,6 +14690,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0": + version: 1.0.0 + resolution: "@rolldown/pluginutils@npm:1.0.0" + checksum: 10c0/44aba363862f6f4defb60a6045fe236769a2307fbe8233b21ef91b728c31033e1167b5209ba7ac7c2f3b7d7738776bfd71913b42876afafab9ac406d03c6c178 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -14593,6 +14718,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.18" + checksum: 10c0/c09f2ebe53762df23b725f452a3f7ee45968824b062a38ec06054e368551e8c5e1874b0ef28143ff3b1b9d6d5ca60177a34378bdd672e899c3646fb8d0bd5aff + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-rc.7": version: 1.0.0-rc.7 resolution: "@rolldown/pluginutils@npm:1.0.0-rc.7" @@ -14600,13 +14732,6 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "@rolldown/pluginutils@npm:1.0.0-rc.9" - checksum: 10c0/fca488fb96b134ccf95b42632b6112b4abb8b3a9688f166fbd627410def2538ee201953717d234ddecbff62dfe4edc4e72c657b01a9d0750134608d767eea5fd - languageName: node - linkType: hard - "@rollup/plugin-commonjs@npm:28.0.1": version: 28.0.1 resolution: "@rollup/plugin-commonjs@npm:28.0.1" @@ -17990,9 +18115,9 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^6.6.3": - version: 6.8.0 - resolution: "@testing-library/jest-dom@npm:6.8.0" +"@testing-library/jest-dom@npm:^6.6.3, @testing-library/jest-dom@npm:^6.9.1": + version: 6.9.1 + resolution: "@testing-library/jest-dom@npm:6.9.1" dependencies: "@adobe/css-tools": "npm:^4.4.0" aria-query: "npm:^5.0.0" @@ -18000,7 +18125,7 @@ __metadata: dom-accessibility-api: "npm:^0.6.3" picocolors: "npm:^1.1.1" redent: "npm:^3.0.0" - checksum: 10c0/4c5b8b433e0339e0399b940ae901a99ae00f1d5ffb7cbb295460b2c44aaad0bc7befcca7b06ceed7aa68a524970077468046c9fe52836ee26f45b807c80a7ff1 + checksum: 10c0/4291ebd2f0f38d14cefac142c56c337941775a5807e2a3d6f1a14c2fbd6be76a18e498ed189e95bedc97d9e8cf1738049bc76c85b5bc5e23fae7c9e10f7b3a12 languageName: node linkType: hard @@ -20184,66 +20309,66 @@ __metadata: languageName: node linkType: hard -"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260507.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260507.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260507.1" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260507.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260507.1" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260507.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260507.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@typescript/native-preview@npm:7.0.0-dev.20260315.1": - version: 7.0.0-dev.20260315.1 - resolution: "@typescript/native-preview@npm:7.0.0-dev.20260315.1" +"@typescript/native-preview@npm:7.0.0-dev.20260507.1": + version: 7.0.0-dev.20260507.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20260507.1" dependencies: - "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260315.1" - "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260315.1" - "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260315.1" - "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260315.1" - "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260315.1" - "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260315.1" - "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260315.1" + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260507.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260507.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260507.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260507.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260507.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260507.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260507.1" dependenciesMeta: "@typescript/native-preview-darwin-arm64": optional: true @@ -20261,7 +20386,7 @@ __metadata: optional: true bin: tsgo: bin/tsgo.js - checksum: 10c0/d017002bd72e0993d007f661fee11ccdf56a2067edd5f9f81ed4497e652d4e6aada5e6d9cbb480bd3277226fd385355f0269f2c03093cd11e956768a59cd5318 + checksum: 10c0/539f4da0df37110f33f51be77d8c72d0c206127dbc9f1f86e12ccad39a31e26905edb243c0b54997bd0b679e128a646b7c77b7e38ae1320db7637134cfda033b languageName: node linkType: hard @@ -21071,6 +21196,13 @@ __metadata: languageName: node linkType: hard +"@webcontainer/env@npm:^1.1.1": + version: 1.1.1 + resolution: "@webcontainer/env@npm:1.1.1" + checksum: 10c0/bc64114ffa7ee92f4985cc2bdd5e27f6f31d892b9aa5cde68eaf93df02d13ee6edf13faeebdd701464183b6f8f9c47c14975958cdd6fc20e7356ad32f6ee39e7 + languageName: node + linkType: hard + "@webpack-cli/configtest@npm:^3.0.1": version: 3.0.1 resolution: "@webpack-cli/configtest@npm:3.0.1" @@ -26599,15 +26731,15 @@ __metadata: languageName: node linkType: hard -"dts-resolver@npm:^2.1.3": - version: 2.1.3 - resolution: "dts-resolver@npm:2.1.3" +"dts-resolver@npm:^3.0.0": + version: 3.0.0 + resolution: "dts-resolver@npm:3.0.0" peerDependencies: oxc-resolver: ">=11.0.0" peerDependenciesMeta: oxc-resolver: optional: true - checksum: 10c0/bf589ba9bfacdb23ff9c075948175f5a21ae0bccb2ca36f8315bff2729358902256ee7aca972f5b259641f08a4b5973034e082a730113d5af76e64062e45fe3a + checksum: 10c0/ae52c4e09b43def702b02d7edbfa04798522907f22879809f8890e5c61fc762403f46a1c415c2d1a6440b01960bbaf98c72def1771b0962d3b15fec40983a4ab languageName: node linkType: hard @@ -29930,7 +30062,16 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.13.6, get-tsconfig@npm:^4.7.5": +"get-tsconfig@npm:5.0.0-beta.5": + version: 5.0.0-beta.5 + resolution: "get-tsconfig@npm:5.0.0-beta.5" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/fd96e2a7d872703d8c5d7ed5d5d884a4e33f6cd9f012aa68a3c7aae700b84b502e41c06641a3d0667b1e8c8f7d497857dd6d3bba3987d2e5f59d95e49fb6e3b9 + languageName: node + linkType: hard + +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5": version: 4.13.6 resolution: "get-tsconfig@npm:4.13.6" dependencies: @@ -37557,16 +37698,16 @@ __metadata: languageName: node linkType: hard -"oxlint-tsgolint@npm:0.17.0": - version: 0.17.0 - resolution: "oxlint-tsgolint@npm:0.17.0" - dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:0.17.0" - "@oxlint-tsgolint/darwin-x64": "npm:0.17.0" - "@oxlint-tsgolint/linux-arm64": "npm:0.17.0" - "@oxlint-tsgolint/linux-x64": "npm:0.17.0" - "@oxlint-tsgolint/win32-arm64": "npm:0.17.0" - "@oxlint-tsgolint/win32-x64": "npm:0.17.0" +"oxlint-tsgolint@npm:0.22.1": + version: 0.22.1 + resolution: "oxlint-tsgolint@npm:0.22.1" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.22.1" + "@oxlint-tsgolint/darwin-x64": "npm:0.22.1" + "@oxlint-tsgolint/linux-arm64": "npm:0.22.1" + "@oxlint-tsgolint/linux-x64": "npm:0.22.1" + "@oxlint-tsgolint/win32-arm64": "npm:0.22.1" + "@oxlint-tsgolint/win32-x64": "npm:0.22.1" dependenciesMeta: "@oxlint-tsgolint/darwin-arm64": optional: true @@ -37582,35 +37723,35 @@ __metadata: optional: true bin: tsgolint: bin/tsgolint.js - checksum: 10c0/8a01a5bfad70a66fea3ca94d38aad8af1fdcc8ed7a2ca7b24d1a975de906c3d2bc7b4616c83411141429da4b13d72b26b02a3526d8d34933a0b2839d8aa7b66a - languageName: node - linkType: hard - -"oxlint@npm:1.55.0": - version: 1.55.0 - resolution: "oxlint@npm:1.55.0" - dependencies: - "@oxlint/binding-android-arm-eabi": "npm:1.55.0" - "@oxlint/binding-android-arm64": "npm:1.55.0" - "@oxlint/binding-darwin-arm64": "npm:1.55.0" - "@oxlint/binding-darwin-x64": "npm:1.55.0" - "@oxlint/binding-freebsd-x64": "npm:1.55.0" - "@oxlint/binding-linux-arm-gnueabihf": "npm:1.55.0" - "@oxlint/binding-linux-arm-musleabihf": "npm:1.55.0" - "@oxlint/binding-linux-arm64-gnu": "npm:1.55.0" - "@oxlint/binding-linux-arm64-musl": "npm:1.55.0" - "@oxlint/binding-linux-ppc64-gnu": "npm:1.55.0" - "@oxlint/binding-linux-riscv64-gnu": "npm:1.55.0" - "@oxlint/binding-linux-riscv64-musl": "npm:1.55.0" - "@oxlint/binding-linux-s390x-gnu": "npm:1.55.0" - "@oxlint/binding-linux-x64-gnu": "npm:1.55.0" - "@oxlint/binding-linux-x64-musl": "npm:1.55.0" - "@oxlint/binding-openharmony-arm64": "npm:1.55.0" - "@oxlint/binding-win32-arm64-msvc": "npm:1.55.0" - "@oxlint/binding-win32-ia32-msvc": "npm:1.55.0" - "@oxlint/binding-win32-x64-msvc": "npm:1.55.0" - peerDependencies: - oxlint-tsgolint: ">=0.15.0" + checksum: 10c0/1f2e3840993e85ebe73f394e7e7441c1482aa4c7da38408c5887e2635be64a577edc887688c8a5232e9f3c8b963fbcecec93036e5fbf2fb863784175e1336aa4 + languageName: node + linkType: hard + +"oxlint@npm:1.63.0": + version: 1.63.0 + resolution: "oxlint@npm:1.63.0" + dependencies: + "@oxlint/binding-android-arm-eabi": "npm:1.63.0" + "@oxlint/binding-android-arm64": "npm:1.63.0" + "@oxlint/binding-darwin-arm64": "npm:1.63.0" + "@oxlint/binding-darwin-x64": "npm:1.63.0" + "@oxlint/binding-freebsd-x64": "npm:1.63.0" + "@oxlint/binding-linux-arm-gnueabihf": "npm:1.63.0" + "@oxlint/binding-linux-arm-musleabihf": "npm:1.63.0" + "@oxlint/binding-linux-arm64-gnu": "npm:1.63.0" + "@oxlint/binding-linux-arm64-musl": "npm:1.63.0" + "@oxlint/binding-linux-ppc64-gnu": "npm:1.63.0" + "@oxlint/binding-linux-riscv64-gnu": "npm:1.63.0" + "@oxlint/binding-linux-riscv64-musl": "npm:1.63.0" + "@oxlint/binding-linux-s390x-gnu": "npm:1.63.0" + "@oxlint/binding-linux-x64-gnu": "npm:1.63.0" + "@oxlint/binding-linux-x64-musl": "npm:1.63.0" + "@oxlint/binding-openharmony-arm64": "npm:1.63.0" + "@oxlint/binding-win32-arm64-msvc": "npm:1.63.0" + "@oxlint/binding-win32-ia32-msvc": "npm:1.63.0" + "@oxlint/binding-win32-x64-msvc": "npm:1.63.0" + peerDependencies: + oxlint-tsgolint: ">=0.22.1" dependenciesMeta: "@oxlint/binding-android-arm-eabi": optional: true @@ -37655,7 +37796,7 @@ __metadata: optional: true bin: oxlint: bin/oxlint - checksum: 10c0/ea0a16c2c70c2756ff30ade960e98892b6687c13defdfb4b7a92f8e5396d636ce35815c71f878eb28822cf26f0be9b5600e80313a0d03a37822c21a533b10571 + checksum: 10c0/ca3e0c36eb02be68494b900cf34242a4d5917a2f63e0bf7833aa017223f9768fa053228483dcb0b86eb077641e60b7c2e5960a3f5d0fa03145b4ee29748b44c6 languageName: node linkType: hard @@ -38910,14 +39051,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.3.11, postcss@npm:^8.4.14, postcss@npm:^8.4.33, postcss@npm:^8.5.1, postcss@npm:^8.5.10, postcss@npm:^8.5.3, postcss@npm:^8.5.6": - version: 8.5.10 - resolution: "postcss@npm:8.5.10" +"postcss@npm:^8.3.11, postcss@npm:^8.4.14, postcss@npm:^8.4.33, postcss@npm:^8.5.1, postcss@npm:^8.5.10, postcss@npm:^8.5.14, postcss@npm:^8.5.3, postcss@npm:^8.5.6": + version: 8.5.14 + resolution: "postcss@npm:8.5.14" dependencies: nanoid: "npm:^3.3.11" picocolors: "npm:^1.1.1" source-map-js: "npm:^1.2.1" - checksum: 10c0/c592dffa0c4873b401f01955b265538d9942f425040df5e2b8f0ad34c83773a792ea0fa5859ccc99cfb5b955b4ebff118ab7056315388dc83b107b0fa8313576 + checksum: 10c0/48138207cf5ef5581be1bfe2cb65ccfe0ac75e43888ba045afc8ed6043d7b56aeb3b9a9fe5b353ff554be943cd0cc15d826ccb991525159175971e5ee8ab0237 languageName: node linkType: hard @@ -41141,24 +41282,23 @@ __metadata: languageName: node linkType: hard -"rolldown-plugin-dts@npm:0.22.5": - version: 0.22.5 - resolution: "rolldown-plugin-dts@npm:0.22.5" +"rolldown-plugin-dts@npm:0.25.0": + version: 0.25.0 + resolution: "rolldown-plugin-dts@npm:0.25.0" dependencies: - "@babel/generator": "npm:8.0.0-rc.2" - "@babel/helper-validator-identifier": "npm:8.0.0-rc.2" - "@babel/parser": "npm:8.0.0-rc.2" - "@babel/types": "npm:8.0.0-rc.2" + "@babel/generator": "npm:8.0.0-rc.4" + "@babel/helper-validator-identifier": "npm:8.0.0-rc.4" + "@babel/parser": "npm:8.0.0-rc.4" ast-kit: "npm:^3.0.0-beta.1" birpc: "npm:^4.0.0" - dts-resolver: "npm:^2.1.3" - get-tsconfig: "npm:^4.13.6" + dts-resolver: "npm:^3.0.0" + get-tsconfig: "npm:5.0.0-beta.5" obug: "npm:^2.1.1" peerDependencies: "@ts-macro/tsc": ^0.3.6 - "@typescript/native-preview": ">=7.0.0-dev.20250601.1" - rolldown: ^1.0.0-rc.3 - typescript: ^5.0.0 || ^6.0.0-beta + "@typescript/native-preview": ">=7.0.0-dev.20260325.1" + rolldown: ^1.0.0 + typescript: ^6.0.0 vue-tsc: ~3.2.0 peerDependenciesMeta: "@ts-macro/tsc": @@ -41169,7 +41309,65 @@ __metadata: optional: true vue-tsc: optional: true - checksum: 10c0/43940457abc0576833a50da2fe90d4993f5b4171910875da1d540954ba14aac9b41e72920caa11c1ffee0e439c0bd5b950706036f56c7220c5ad7cf178559236 + checksum: 10c0/0277e644fe28b0d98a53ecf9949caf0e88a745d6281d72e2af1369aeb65d9a54e921cd14376c65fe951fd36356436f83b29e9583e3b299289af2a3ed07a0b57f + languageName: node + linkType: hard + +"rolldown@npm:1.0.0": + version: 1.0.0 + resolution: "rolldown@npm:1.0.0" + dependencies: + "@oxc-project/types": "npm:=0.129.0" + "@rolldown/binding-android-arm64": "npm:1.0.0" + "@rolldown/binding-darwin-arm64": "npm:1.0.0" + "@rolldown/binding-darwin-x64": "npm:1.0.0" + "@rolldown/binding-freebsd-x64": "npm:1.0.0" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0" + "@rolldown/pluginutils": "npm:1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10c0/8e8c4ebcd80cd6fc051e1e58ad2ffb6578431f6828522788d6e5da6ba6d6e3e92f20e47df5e30034aba5a5af296f497a2b2ff26a21d5ade3c125b620ea958256 languageName: node linkType: hard @@ -41231,27 +41429,27 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.0-rc.9": - version: 1.0.0-rc.9 - resolution: "rolldown@npm:1.0.0-rc.9" - dependencies: - "@oxc-project/types": "npm:=0.115.0" - "@rolldown/binding-android-arm64": "npm:1.0.0-rc.9" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.9" - "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.9" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.9" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.9" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.9" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.9" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.9" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.9" - "@rolldown/pluginutils": "npm:1.0.0-rc.9" +"rolldown@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "rolldown@npm:1.0.0-rc.18" + dependencies: + "@oxc-project/types": "npm:=0.128.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.18" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.18" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.18" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.18" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.18" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.18" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.18" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.18" + "@rolldown/pluginutils": "npm:1.0.0-rc.18" dependenciesMeta: "@rolldown/binding-android-arm64": optional: true @@ -41285,7 +41483,7 @@ __metadata: optional: true bin: rolldown: bin/cli.mjs - checksum: 10c0/d19af14dccf569dc25c0c3c2f1142b7a6f7cec291d55bba80cea71099f89c6d634145bb1b6487626ddd41d578f183f7065ed68067e49d2b964ad6242693b0f79 + checksum: 10c0/699b8545a9a8b85ed4c639122163a6f46f84404fd88262bafa9549b01546744db625fd4425fceb4658c888de1671323170de1f837f6f6bb93e243e6e1d48c114 languageName: node linkType: hard @@ -42876,6 +43074,37 @@ __metadata: languageName: node linkType: hard +"storybook@npm:10.3.6": + version: 10.3.6 + resolution: "storybook@npm:10.3.6" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@storybook/icons": "npm:^2.0.1" + "@testing-library/jest-dom": "npm:^6.9.1" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/expect": "npm:3.2.4" + "@vitest/spy": "npm:3.2.4" + "@webcontainer/env": "npm:^1.1.1" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" + open: "npm:^10.2.0" + recast: "npm:^0.23.5" + semver: "npm:^7.7.3" + use-sync-external-store: "npm:^1.5.0" + ws: "npm:^8.18.0" + peerDependencies: + prettier: ^2 || ^3 + vite-plus: ^0.1.15 + peerDependenciesMeta: + prettier: + optional: true + vite-plus: + optional: true + bin: + storybook: ./dist/bin/dispatcher.js + checksum: 10c0/ee6702667459ba2d49269ddd63a7281f17816fcdd4bacd338ed47a4a8e7ad65760c90d99f6b2dfdfd0a564bcfcab3c1ea05b50bf3aafc6b5b19a039a5da77870 + languageName: node + linkType: hard + "storybook@npm:9.1.19": version: 9.1.19 resolution: "storybook@npm:9.1.19" @@ -45730,7 +45959,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:8.0.10, vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": +"vite@npm:8.0.10": version: 8.0.10 resolution: "vite@npm:8.0.10" dependencies: @@ -45787,6 +46016,63 @@ __metadata: languageName: node linkType: hard +"vite@npm:8.0.11, vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.0.11 + resolution: "vite@npm:8.0.11" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.14" + rolldown: "npm:1.0.0-rc.18" + tinyglobby: "npm:^0.2.16" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/504ec6064761239e7063426dd123ea68cd540cb2d475bf72f5b1062313b9c79984831f56a20891ed5e08b2753d34171ee7a75cbadf9365e975d1f68634f0a10f + languageName: node + linkType: hard + "vite@npm:^6.0.5": version: 6.4.2 resolution: "vite@npm:6.4.2"