diff --git a/.changeset/webgpu-experiment-backend.md b/.changeset/webgpu-experiment-backend.md
new file mode 100644
index 00000000000..d1a39291b3d
--- /dev/null
+++ b/.changeset/webgpu-experiment-backend.md
@@ -0,0 +1,14 @@
+---
+"@hashintel/petrinaut-core": patch
+"@hashintel/petrinaut": patch
+---
+
+Add an experimental WebGPU compute backend for experiments.
+
+An experiment's runs are independent, so the GPU runs one invocation per run and steps the whole net on the device. WGSL is generated from the net's lowered HIR — the same HIR the CPU engine compiles to buffer programs — so dynamics, firing rates and transition kernels execute on the device rather than being interpreted per frame. Metrics reduce on-GPU into per-frame histograms, and only a compact per-run summary is read back, so run state never leaves the device.
+
+It is a **subset** engine, asked rather than told: it reports whether it can run a net before the experiment starts, and a net it cannot take falls back to the CPU with the reason recorded. It needs every place holding typed tokens to declare a token capacity, arcs consuming at most two typed tokens per place, no `string` or `uuid` attributes, and metrics that measure place token counts without a time aggregation. A weight-2 pairwise condition is scanned over every pair by combinatorial unranking, which preserves the CPU's lexicographic firing order.
+
+Two backends can be loaded at once and the choice is per experiment, via a toggle in the create-experiment drawer. Results are not seed-identical to the CPU — WebGPU cannot reproduce the CPU generator, so trajectories differ while distributions agree — and continuous dynamics integrate with RK4, so which backend ran an experiment is recorded alongside its results.
+
+A **Compilation** panel, behind a user setting, reports what the compiler made of each condition, kernel and differential equation, and what stops a net running on the GPU.
diff --git a/libs/@hashintel/petrinaut-core/benchmarks/webgpu-vs-cpu.html b/libs/@hashintel/petrinaut-core/benchmarks/webgpu-vs-cpu.html
new file mode 100644
index 00000000000..e511a5c3d1f
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/benchmarks/webgpu-vs-cpu.html
@@ -0,0 +1,208 @@
+
+
+
+
+ Petrinaut WebGPU backend — validation
+
+
+
+
WebGPU backend validation
+
+ Runs the same net through the CPU Monte Carlo engine and the WebGPU
+ backend, then compares the per-frame token-count distributions. The two
+ backends use different random generators by necessity, so this checks
+ statistical agreement, not identical trajectories.
+
+
running…
+
+
+
diff --git a/libs/@hashintel/petrinaut-core/docs/simulation-performance.md b/libs/@hashintel/petrinaut-core/docs/simulation-performance.md
index 3e00b2e0815..911b8709042 100644
--- a/libs/@hashintel/petrinaut-core/docs/simulation-performance.md
+++ b/libs/@hashintel/petrinaut-core/docs/simulation-performance.md
@@ -1,7 +1,8 @@
# Simulation performance: threads, WASM, and GPU
-Status: §3 (worker sharding) and §5 (place capacity) are implemented. §2 and
-§4 measurements still stand and are unaddressed. §6–§9 remain proposals.
+Status: §3 (worker sharding), §5 (place capacity) and §8 (the WebGPU backend)
+are implemented. §2 and §4 measurements still stand and are unaddressed. §6, §7
+and §9 remain proposals, and §8a records a defect found along the way.
Goal: make **Experiments** (Monte Carlo batches) as fast as possible, with
per-seed parallelism across threads/workers, and decide whether WASM (browser),
@@ -35,14 +36,19 @@ laptop, Node 25.6, against the built `dist` of this package.
5. **WASM is worth doing, but as a second codegen backend behind whole-loop
codegen, not as a rewrite.** Expect ~1.5–3× over _good_ JS, not over
today's engine.
-6. **WebGPU: discrete transitions are possible but only for a restricted
- subset, and WGSL has no `f64`** — so a GPU path is a numerically-different
- fork, not an acceleration of the existing one. Offloading _only_ ODEs is
- architecturally worse than doing nothing (per-frame round-trip).
-
-Done: **§3 worker sharding**, **§5 capacities**.
+6. **WebGPU: implemented, ~1780× on the SIR net** (§8.4). Discrete transitions
+ do work, for a restricted subset — uncoloured nets are the sweet spot, where a
+ run's whole state is 36 bytes of integers living in registers for a whole
+ dispatch. It is a numerically-different fork rather than an acceleration:
+ different generator, so agreement is statistical (within 0.5% measured), and
+ `f32` throughout. Two predictions here were wrong — f32 is _not_ the limiting
+ factor for ODEs, and RK4 on the GPU is far more accurate than the CPU's Euler.
+ The one that held is that partial per-frame offload is worse than nothing,
+ which is why the backend owns whole experiments rather than frames.
+
+Done: **§3 worker sharding**, **§5 capacities**, **§8 WebGPU backend**.
Remaining, in order: **§4 hot-path fixes → §6 whole-loop codegen → §7
-WASM/native → §8 GPU (spike only)**.
+WASM/native**, plus **§8a** (the CPU generator defect) as its own change.
§4 item 1 (the quadratic enumeration blow-up) is the single highest-value change
left and is independent of everything else.
@@ -625,23 +631,288 @@ The rule: **GPU pays off only if the entire stepping loop lives on the GPU for
many frames without readback.** Partial offload of any per-frame stage is
strictly worse than not doing it.
-### 8.4 Verdict
+### 8.4 Verdict — spike run, and it lands far above the bar
+
+The spike below was scoped to answer one question: is a GPU path ≥10× the CPU,
+or not worth having? It is **~1780×** on the SIR net, so it now exists as
+`src/webgpu/`, selected by a **Compute backend** setting and wired into the
+experiments provider: an experiment asking for the GPU gets it when the net
+qualifies, and otherwise runs on the CPU with the reason recorded on the
+experiment and shown to the user. `ExperimentRecord.computeBackend` records which
+backend actually ran, because the two are not numerically interchangeable.
+
+Two predictions in §8.2 and §8.3 above were wrong, and the corrections are the
+interesting part.
+
+**`f32` is not the limiting factor for ODEs.** §8.2 treats f32 as a fidelity
+problem. Measured on logistic growth integrated to _t_ = 10 with a closed-form
+reference:
+
+| `dt` | GPU Euler f32 | GPU RK2 f32 | GPU RK4 f32 | CPU Euler f64 (incumbent) |
+| ---- | ------------- | ----------- | ------------ | ------------------------- |
+| 0.5 | 0.15% | 0.128% | **0.00155%** | 0.15% |
+| 0.1 | 0.02% | 0.00422% | **5.3e-7%** | 0.02% |
+| 0.01 | 0.00179% | 0.0000235% | 0.0000082% | 0.00181% |
+
+f32 Euler tracks f64 Euler to three significant figures, because integrator
+truncation error dwarfs rounding error at these step counts. And because a
+token's derivative depends only on that token (`emit-buffer-js.ts`'s dynamics
+loop reads only `__b`, its own token base), RK4's four stages fit in **one
+invocation with no extra dispatch** — measured at 2.5× Euler's cost for four
+orders of magnitude less error. So the GPU path is not a fidelity compromise: at
+`dt` 0.1 it is ~38,000× more accurate than the CPU's Euler while still being
+vastly faster. RK4 is the default for that reason.
+
+**The "no partial offload" rule holds, and it dictates the architecture.** §8.3
+is right that a per-frame round-trip is fatal — which is exactly why the GPU
+path does **not** implement `MonteCarloSimulator`. That interface's synchronous
+per-frame `advanceAll()` would force one `mapAsync` per frame. Instead
+`webgpu/runner.ts` dispatches 300 frames at a time with the whole stepping loop
+inside the shader, and `webgpu/gpu-experiment.ts` implements the
+_whole-experiment_ handle only.
+
+Measured, SIR, 4096 runs × 600 frames, one distribution metric, Apple metal-3:
+
+| | Wall clock | ns / run-frame |
+| -------------- | ---------- | -------------- |
+| CPU engine | 6060 ms | 2466 |
+| WebGPU backend | 3.4 ms | 1.87 |
+
+Per-frame cross-run distributions are reduced **on the device** into histograms
+in workgroup-shared memory, flushed once per frame — measured 2× faster than
+global atomics, and necessary because shipping raw samples back would be ~2.4 GB
+for 600 frames × 1M runs against under a megabyte for histograms.
+
+**Correctness.** The two backends use different generators by necessity (§8.2's
+determinism point stands, and see the RNG note below), so agreement is
+statistical. Verified against the CPU engine on identical configuration:
+
+| Frame | CPU mean infected | GPU | Diff |
+| ----- | ----------------- | ------ | ---- |
+| 60 | 9.950 | 9.960 | 0.1% |
+| 240 | 24.039 | 23.997 | 0.2% |
+| 480 | 42.760 | 42.675 | 0.2% |
+| 599 | 52.027 | 51.873 | 0.3% |
+
+Getting there caught a real semantic subtlety worth recording. A first version
+redrew the acceptance variate every frame and diverged by **21% by frame 599** —
+systematically, not as noise. The CPU engine only commits its generator state
+when a transition _fires_ (`advance-run.ts` skips the assignment via
+`if (!effect) continue`), which holds `u` fixed until it is consumed and makes
+the transition fire at the first frame where `elapsed >= -ln(u)/rate` — that is
+inverse-transform sampling of an exponential waiting time. Redrawing turns it
+into a per-frame Bernoulli trial, which fires far too eagerly. The shader now
+threads a candidate state the same way.
+
+**What the backend does not do.** Uncoloured discrete nets are the sweet spot: a
+run's whole state is 36 bytes of integers that live in registers for a whole
+dispatch. Beyond that it is a subset engine, and `webgpu/eligibility.ts` refuses
+rather than approximates — typed places need a declared capacity, `string` and
+`uuid` attributes are impossible in 32-bit WGSL, typed input arcs above weight 2
+are refused, and only place-token-count metrics are served. Everything else falls
+back to the CPU with a reason.
+
+### 8.6 Weighted typed arcs: the objection was wrong
+
+§8.2 refuses weighted arcs on the grounds that choosing among `C(n, w)`
+combinations "does not vectorise". That reasoning does not hold up, and the
+correction is worth recording because it moves weight-2 from research to
+engineering.
+
+The stated problem was divergence: a nested loop over candidate combinations has
+data-dependent trip counts, and under SIMT those cost the maximum across the
+subgroup. But the nesting is avoidable. Unranking through the **combinatorial
+number system** maps a flat index `x` to the `x`-th lexicographic combination in
+closed form, so the search becomes a single loop over a statically-shaped index
+space. This is standard practice for triangular thread domains, where collision
+detection is one of the named applications — see
+[A Non-linear GPU Thread Map for Triangular Domains](https://arxiv.org/abs/1609.01490).
+
+Two things had to be checked rather than assumed:
+
+**f32 is sufficient.** The closed form takes a square root and WGSL has no `f64`.
+Simulating f32 rounding at every step and round-tripping every pair: exact through
+`n = 4096`, first mismatch at `n = 5793`. The metric histogram already caps a
+measured place at 256 tokens, so there is roughly a 16× margin.
+
+**The real constraint is ordering, not throughput.** The CPU draws its acceptance
+uniform `u` **once** per transition per frame and reuses it for every combination
+(`monte-carlo/transition-effect.ts`), firing on the first combination whose
+`exp(-λ·Δt) ≤ u`. Because `exp` is monotone decreasing, that single threshold means
+"does this transition fire at all" is a plain OR over combinations — equivalently
+`max(λ) ≥ -ln(u)/Δt`. But _which_ combination fires is the **lowest-indexed**
+passing one, not the largest `λ`. With several pairs passing — routine for a
+collision model — choosing by `λ` consumes different tokens and the trajectory
+diverges structurally rather than by noise.
+
+So the GPU needs a flat scan in the CPU's own pair order, stopping at the first
+hit. `webgpu/pair-selection.ts` implements that ordering and emits the scan; its
+tests check the order against the engine's own
+`enumerateWeightedMarkingIndicesGenerator` rather than against themselves.
+
+Both prerequisites are now in place. Lambdas read token attributes through the
+accessor `emitDynamics` already used, and firing compacts the token array stably,
+mirroring `monte-carlo/frame-operations.ts`. `eligibility.ts` accepts arc weight up
+to 2 on a typed place; wider arcs still refuse, because only the pair case has a
+closed-form unranking that preserves the engine's ordering.
+
+So `Collision` — `const [a, b] = tokens.Space` over a weight-2 arc — now compiles
+to WGSL, and so does the whole satellites net once its places declare capacities:
+all seven items (three lambdas, one dynamics, three kernels) emit, in 333 lines of
+WGSL over 552 bytes of state per run.
+
+Transition kernels emit too, which is what closes the "produces zero-attribute
+tokens" gap. One ordering trap is worth recording, because the generated shader
+looked correct and was not: a kernel reads the attributes of the tokens the firing
+**consumes**, and compaction overwrites exactly those slots. Emitting the writes
+in source order therefore read a survivor that compaction had just moved into the
+consumed token's place — silently the wrong token's attributes, with no error
+anywhere. Every produced value is now hoisted into a `let kout_N` **before**
+compaction, and `compile-net-shader.test.ts` pins that relative order.
+
+Produced tokens are written above `counts[p] + pending[p]`, mirroring the CPU's
+deferral of additions to the end of the frame, so a token produced this frame is
+not a candidate for a later transition in the same frame.
+
+What still keeps the satellites net's _own_ experiment off the GPU is unrelated:
+two of its four metrics reduce over token attributes, which only an expression
+metric can express, and the GPU refuses those.
+
+Still open from §8.2: WGSL builtin accuracy is implementation-defined
+(`sin`/`cos` carry ~2⁻¹¹ absolute error), so GPU results are not reproducible
+across _devices_ either, only across runs on one adapter.
+
+A cheaper GPU idea still worth noting separately: use WebGPU for **rendering**
+large token populations and distribution charts, which has no numerical-fidelity
+question at all.
+
+### 8.5 How the HIR reaches the shader generator
+
+The shader is generated from HIR, so the GPU backend needs HIR trees in the
+browser. The obvious route — call `lowerNetHir` on the main thread — does not
+work and is worth writing down, because it broke the `@apps/hash-frontend` build
+twice.
+
+Lowering needs the TypeScript compiler, whose `require("module")` webpack cannot
+resolve for a browser target. The import chain is perfectly valid TypeScript, so
+`lint:tsc`, `lint:eslint` and `test:unit` are all blind to it; only bundling a
+consumer surfaces it. Guarding the import site is the wrong fix: any module the
+`webgpu` entry reaches transitively reintroduces it.
+
+What works is to carry the HIR on the compiled artifacts, which are already
+produced in the language worker where the compiler legitimately lives, and have
+`webgpu/hir-from-artifacts.ts` read them back. That keeps the browser-facing
+entries compiler-free.
+
+HIR roughly triples artifact size (15.3 KB → 45.5 KB on
+`supplyChainWithDisruption`), and artifacts are structured-cloned to every shard
+worker, so it is opt-in: `compileHirArtifacts(sdcpn, extensions, { includeHir })`,
+requested by the experiments provider only when the chosen backend is `webgpu`.
+
+`scripts/check-browser-safe-entries.mjs` walks the built entry graphs for
+Node-only specifiers and runs as part of `yarn build`. It takes about a second,
+against roughly nine minutes for a frontend build.
+
+### 8.7 Device limits: ask the adapter, not the spec
+
+`adapter.requestDevice()` without `requiredLimits` returns a device on the
+WebGPU **default** limits, which are the floor every conformant implementation
+must clear — not what the hardware can do. The two that bind here are
+`maxStorageBufferBindingSize` (128 MiB) and `maxBufferSize` (256 MiB). Measured
+on an Apple metal-3 adapter, both are reported as **4096 MiB**, so the default
+cost a factor of 32 and refused run counts the GPU could hold comfortably —
+"Run state needs 311 MB but this device caps a storage buffer at 134 MB", where
+134 is just `128 MiB / 1e6` rounded.
+
+The device now requests exactly what the adapter reports. Asking for the
+adapter's own value is always valid — the spec only rejects asking for _more_ —
+and raising a limit allocates nothing by itself, so there is no reason to ask
+for less than the ceiling and size buffers to the experiment instead. Both
+limits must be raised: raising only the binding size moves the wall from 128 MiB
+to 256 MiB rather than removing it.
+
+### 8.8 Read back a summary, not the state
+
+Raising the limits moved the wall rather than removing it, and the next two walls
+were both the host, not the GPU.
+
+**The host mirror.** Seeding built the whole initial state as one `Uint32Array`.
+At 3112 bytes per run a million runs is 2.90 GiB in one contiguous ArrayBuffer,
+which the browser refuses outright — "Array buffer allocation failed", before
+frame zero. Runs are independent and laid out contiguously, so seeding now stages
+4 MiB at a time and uploads with `writeBuffer` at increasing offsets. The run
+index is absolute, not chunk-relative, or every chunk would repeat the first
+chunk's RNG streams and silently correlate runs.
+
+**The readback.** Then a million runs failed differently: `mapAsync` reporting
+"[Invalid Buffer] is invalid due to a previous error", _after_ the whole
+simulation had run. The real error was three operations upstream — Dawn signals
+an out-of-memory `createBuffer` by returning an _error buffer_ rather than
+throwing, so allocation looks like it succeeded and every later use reports being
+poisoned. Buffer creation is now wrapped in an out-of-memory error scope so the
+actual message ("Failed to allocate memory for buffer mapping") is what the user
+sees.
+
+The allocation that failed was the mappable copy of the run state. Host-visible
+memory is scarcer than device memory and gives out well below `maxBufferSize`:
+measured, a 1.94 GiB mappable buffer allocates and a 2.90 GiB one does not, on an
+adapter reporting 4 GiB. No limit predicts it, so the code attempts the
+allocation and explains the failure rather than guessing a threshold and refusing
+experiments that would have worked.
+
+But the readback should never have been that large. Of a run's state the host
+decodes exactly two things — the place counts and the status — and discards the
+token array, which is nearly all of it. The shader now writes those into a
+compact `summary` buffer (binding 3) from the registers it already holds them in,
+at `placeCount + 1` words per run. A second entry point would have re-read them
+from memory for nothing.
+
+Measured consequence for a 3112-byte-per-run net at a million runs: readback
+falls from 2.90 GiB to 15 MiB, run state stops leaving the device at all, and the
+experiment that failed now allocates, maps and decodes. The ceiling is now the
+state storage buffer itself — `maxStorageBufferBindingSize / bytesPerRun`, about
+1.38 M runs at that size — and `describeBufferOverflow` reports how many runs
+would fit, which is exact because run state is linear in the run count.
+
+**Still open: dispatch chunking.** Run state is still one buffer, so the run
+count is bounded by that 4 GB. Removing the bound means splitting runs across
+several dispatches, each sized to fit, and merging the metric histograms across
+them — tractable because runs are independent and the histograms are a
+commutative monoid, but a real change to the dispatch loop, and not done.
+
+---
+
+## 8a. A defect found while investigating the GPU RNG
+
+Not a GPU issue — it is in the shipped CPU engine, and it deserves its own fix.
-Sequence this **after** §4–§7, and scope it as a spike, not a roadmap item.
-Realistic upside for a suitable net — bounded capacities, weight-1 arcs,
-`f32`-tolerant, thousands of runs — is large (plausibly 10–100× over a
-multi-threaded CPU implementation, dominated by how much divergence the net
-causes). But it is a second engine with different numerics, a restricted
-feature subset, and a hard dependency on §5, and it only helps at run counts
-where a well-optimised threaded CPU engine may already be fast enough.
+`simulation/engine/seeded-rng.ts` is an LCG computing `(1103515245 * seed + 12345) % 2^31`
+in f64. For any seed above `2^53 / 1103515245 ≈ 8,162,278` — **99.6% of its own
+2^31 seed space** — the multiply exceeds f64's exact-integer range, so the low
+bits the modulo reads are rounding artefacts. `engine/uuid.ts` already documents
+this and works around it by using only the top 16 bits of each draw.
-Reasonable spike, ~1 week, answering one question: for the SIR net with 10 000
-runs, `f32`, capacities set, what is the end-to-end wall clock versus the
-threaded CPU path? If it is not ≥10×, drop it.
+Measured consequences:
-A cheaper GPU idea worth noting: keep simulation on the CPU and use WebGPU only
-for **rendering** large token populations and distribution charts, which has no
-numerical-fidelity problem at all.
+- the generator's cycle is **10,466 states**, against ~2^31 for a sound LCG;
+- every seed tested enters that same cycle, after a median of 3,864 draws
+ (min 1, p90 6,975);
+- so the harm depends on draws per run: at ~1,200 draws (SIR at `maxTime` 60)
+ only 17% of runs reach the cycle and SIR's summary statistics match a sound
+ generator closely; at 3,600 draws it is 47%; at **9,000 draws every run is on
+ the shared 10,466-state cycle**, spending a median 5,092 draws there and
+ therefore overlapping other runs heavily.
+
+9,000 draws is reachable at the product's default `maxTime` of 180 — 1,800 frames
+× 5 transitions. So this is latent at today's typical experiment sizes and
+becomes real for longer or larger nets, where it would understate variance and
+distort the tails that Experiments exist to show. Means hold up better than
+quantiles.
+
+The GPU backend sidesteps it by using PCG (`webgpu/wgsl-prelude.ts`), which is
+part of why the two backends cannot agree seed for seed. Fixing the CPU
+generator is a separate change: it alters every existing seed's trajectory, which
+§10's cross-version decision permits but which still deserves its own review.
---
@@ -662,26 +933,31 @@ step faster", and it deserves evaluation alongside the engineering work.
---
-## 10. Questions for you
-
-Blocking design decisions:
-
-1. **Capacity semantics** — when a firing would exceed a place's capacity: is
- the transition not enabled (classical, cheap, composes with inhibitor arcs),
- or does it error and fail the run? Do capacities affect _existing_ nets at
- all, or only where explicitly set?
-2. **Reproducibility contract** — must the same seed give the same trajectory
- across (a) shard counts, (b) engine versions, (c) CPU vs WASM vs GPU
- backends? (a) is preserved by the §3 design. (b) is broken by hot-path item 7.
- (c) is essentially impossible for GPU (`f32`) and hard for WASM (`libm`).
- Knowing which of these you are willing to give up decides §7 and §8.
-3. **Worker budget** — one shared pool across concurrent experiments, or per
- experiment? What is the acceptable core count while the user keeps editing?
-
-Sequencing:
-
-4. Should I start with §4 items 1–6 (measurable, behaviour-preserving, no new
- concepts), or do you want the §3 sharding landed first because it is the
- visible feature?
-5. Is the §9 event-driven mode in scope at all, or is fixed-`dt` a fixed
- product decision?
+## 10. Decisions taken
+
+The three questions this section originally asked have been answered:
+
+1. **Capacity semantics** — a firing that would exceed a place's capacity leaves
+ the transition _not enabled_, so a full place blocks its producers. It does
+ not error. Capacity is opt-in per place; nets that set none behave exactly as
+ before. Implemented as described in §5.3.
+2. **Reproducibility contract** — the same seed need **not** give the same
+ trajectory across engine versions. That frees §4 item 7 (RNG reuse), and it
+ softens the `libm` bit-exactness problem for §7: a WASM backend may diverge
+ from the JS one as long as each is internally deterministic. Sharding
+ preserves determinism outright (§3), so it needed no trade-off.
+3. **Worker budget** — full parallelism per experiment: one worker per core
+ minus one, capped at the run count. Concurrent experiments therefore
+ oversubscribe rather than share a pool, and the user-facing docs say to run
+ them one at a time for maximum speed. A shared pool remains a possible
+ refinement (§3.3).
+
+Still open:
+
+1. Does the GPU path's `f32` divergence from the CPU path's `f64` count as
+ acceptable for the same seed _and the same engine version_? Cross-version
+ divergence is now permitted, but that does not settle the cross-backend case
+ within one version. This is the remaining gate on §8.
+2. Is the §9 event-driven mode (Gillespie / next-reaction) in scope, or is
+ fixed-`dt` a settled product decision? It is a larger lever than any
+ remaining engineering item here.
diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json
index 9afaa7c2864..71504cd02e0 100644
--- a/libs/@hashintel/petrinaut-core/package.json
+++ b/libs/@hashintel/petrinaut-core/package.json
@@ -66,13 +66,18 @@
"types": "./dist/workers/simulation.d.d.ts",
"import": "./dist/workers/simulation.js"
},
+ "./webgpu": {
+ "types": "./dist/webgpu.d.d.ts",
+ "import": "./dist/webgpu.js"
+ },
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
- "build": "vite build",
+ "build": "vite build && yarn check:browser-safe-entries",
+ "check:browser-safe-entries": "node scripts/check-browser-safe-entries.mjs",
"fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .",
"lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .",
"lint:tsc": "tsgo --noEmit",
@@ -89,6 +94,7 @@
"devDependencies": {
"@types/node": "22.18.13",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
+ "@webgpu/types": "0.1.71",
"oxlint": "1.63.0",
"oxlint-tsgolint": "0.22.1",
"rolldown": "1.1.2",
diff --git a/libs/@hashintel/petrinaut-core/scripts/check-browser-safe-entries.mjs b/libs/@hashintel/petrinaut-core/scripts/check-browser-safe-entries.mjs
new file mode 100644
index 00000000000..51342b08901
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/scripts/check-browser-safe-entries.mjs
@@ -0,0 +1,141 @@
+/**
+ * Fails if a browser-facing entry point reaches Node-only code.
+ *
+ * This exists because that mistake broke the frontend build twice in a row, and
+ * neither `lint:tsc`, `lint:eslint` nor `test:unit` can see it — the import is
+ * perfectly valid TypeScript. Only bundling the consumer catches it, and a full
+ * `@apps/hash-frontend` build takes ~9 minutes, which is too slow to run before
+ * every push.
+ *
+ * The failure mode it guards: an entry that transitively imports the HIR
+ * frontend pulls in the TypeScript compiler, whose `require("module")` webpack
+ * cannot resolve for the browser, so the consuming app fails with
+ * `Module not found: Can't resolve 'module'`.
+ *
+ * node scripts/check-browser-safe-entries.mjs
+ *
+ * Run after `yarn build`, since it inspects `dist`.
+ */
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+
+const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), "..");
+
+/**
+ * Entries a browser bundle may import, and what they must never reach.
+ *
+ * `hir` and `compiled-model` are deliberately absent: they are Node/worker
+ * entries and are *expected* to bundle the compiler.
+ */
+const BROWSER_SAFE_ENTRIES = ["index.js", "webgpu.js", "hir-runtime.js"];
+
+/** Bare specifiers that mean "this cannot run in a browser". */
+const NODE_ONLY = new Set([
+ "module",
+ "fs",
+ "fs/promises",
+ "path",
+ "os",
+ "crypto",
+ "child_process",
+ "worker_threads",
+ "url",
+ "util",
+ "typescript",
+]);
+
+/**
+ * Follows relative imports from `entry` and returns every bare specifier
+ * reached, mapped to the first file that imported it.
+ *
+ * @param {string} entry
+ * @returns {Map}
+ */
+function collectBareImports(entry) {
+ /** @type {Set} */
+ const seen = new Set();
+ /** @type {Map} */
+ const bare = new Map();
+ /** @type {string[]} */
+ const queue = [entry];
+
+ for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
+ if (seen.has(file)) {
+ continue;
+ }
+ seen.add(file);
+
+ let source;
+ try {
+ source = readFileSync(file, "utf8");
+ } catch {
+ continue;
+ }
+
+ // Static and dynamic import specifiers, plus CJS requires, as they appear in
+ // the built output.
+ /** @type {string[]} */
+ const specifiers = [];
+ for (const pattern of [
+ /(?:from|import)\s*\(?\s*["']([^"']+)["']/gu,
+ /require\(\s*["']([^"']+)["']\s*\)/gu,
+ ]) {
+ for (const [, specifier] of source.matchAll(pattern)) {
+ specifiers.push(specifier);
+ }
+ }
+
+ for (const specifier of specifiers) {
+ if (specifier.startsWith(".")) {
+ const target = resolve(dirname(file), specifier);
+ queue.push(target, `${target}.js`);
+ } else {
+ const bareName = specifier.startsWith("node:")
+ ? specifier.slice("node:".length)
+ : specifier;
+ if (!bare.has(bareName)) {
+ bare.set(bareName, file);
+ }
+ }
+ }
+ }
+
+ return bare;
+}
+
+let failed = false;
+
+for (const entry of BROWSER_SAFE_ENTRIES) {
+ const entryPath = resolve(packageRoot, "dist", entry);
+ const bare = collectBareImports(entryPath);
+ const offenders = [...bare].filter(([name]) => NODE_ONLY.has(name));
+
+ if (offenders.length === 0) {
+ const external = [...bare.keys()];
+ process.stdout.write(
+ ` ok ${entry}${external.length > 0 ? ` (external: ${external.join(", ")})` : " (no external imports)"}\n`,
+ );
+ continue;
+ }
+
+ failed = true;
+ process.stdout.write(` FAIL ${entry}\n`);
+ for (const [name, importer] of offenders) {
+ process.stdout.write(
+ ` reaches "${name}" via ${importer.replace(`${packageRoot}/`, "")}\n`,
+ );
+ }
+}
+
+if (failed) {
+ process.stdout.write(
+ "\nA browser-facing entry reaches Node-only code. Consuming apps will fail to\n" +
+ "bundle with `Module not found`. Move the Node-only dependency behind a\n" +
+ "separate entry point rather than guarding the import site.\n",
+ );
+ process.exit(1);
+}
+
+process.stdout.write(
+ "\nAll browser-facing entries are free of Node-only imports.\n",
+);
diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts
index 486c278aa80..6acdacd014a 100644
--- a/libs/@hashintel/petrinaut-core/src/ai.ts
+++ b/libs/@hashintel/petrinaut-core/src/ai.ts
@@ -96,6 +96,7 @@ export const petrinautDocNames = [
"actual-mode",
"ai-assistant",
"visual-settings",
+ "compilation-output",
"examples",
] as const;
@@ -121,7 +122,9 @@ export const petrinautDocSummaries: Record = {
"ai-assistant":
"In-app AI assistant: opening the panel, conversation surface, prompt chips, tool cards, read-only/simulate-mode rules, host configuration.",
"visual-settings":
- "Animations, keep-panels-mounted, minimap, snap-to-grid, compact vs classic nodes, partial selection, tree view, arc rendering style.",
+ "Animations, keep-panels-mounted, minimap, snap-to-grid, compact vs classic nodes, partial selection, tree view, arc rendering style, compute backend, compilation output.",
+ "compilation-output":
+ "The Compilation bottom-panel tab: enabling it, the GPU verdict line, structural blockers, shader emission failures, per-item GPU/CPU/untested/no-HIR/unused status, and HIR node counts.",
examples:
"Walkthroughs of the built-in examples and the scenarios/metrics each ships with: SIR, Supply Chain, Deployment Pipeline, Production Machines, Satellites in Orbit, Probabilistic Satellites Launcher.",
};
diff --git a/libs/@hashintel/petrinaut-core/src/hir.ts b/libs/@hashintel/petrinaut-core/src/hir.ts
index c44a5694e89..0babb2f96d8 100644
--- a/libs/@hashintel/petrinaut-core/src/hir.ts
+++ b/libs/@hashintel/petrinaut-core/src/hir.ts
@@ -21,6 +21,7 @@ export {
} from "./hir/analyze";
export {
compileHirArtifacts,
+ type CompileHirArtifactsOptions,
type HirCompileFailure,
type HirCompileResult,
} from "./hir/compile";
diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.ts
index f808deccb2f..ab52488fa94 100644
--- a/libs/@hashintel/petrinaut-core/src/hir/compile.ts
+++ b/libs/@hashintel/petrinaut-core/src/hir/compile.ts
@@ -100,9 +100,23 @@ function lowerAndCheck(
* not scalarize to a buffer program are reported in `failures` (mirrored by
* the LSP as error diagnostics); such items cannot simulate.
*/
+export type CompileHirArtifactsOptions = {
+ /**
+ * Also carry the lowered HIR tree on each artifact.
+ *
+ * Off by default: the tree roughly triples artifact size (measured +197% on
+ * the supply-chain example), and artifacts are posted to every Monte Carlo
+ * shard worker, none of which need it. Only the WebGPU backend does, because
+ * it generates a shader from the HIR and cannot lower the net itself — that
+ * would pull the TypeScript frontend into the browser bundle.
+ */
+ includeHir?: boolean;
+};
+
export function compileHirArtifacts(
sdcpn: SDCPN,
extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS,
+ options: CompileHirArtifactsOptions = {},
): HirCompileResult {
const sanitized = sanitizeSDCPNForExtensions(sdcpn, extensions);
const artifacts: HirArtifacts = {
@@ -162,7 +176,10 @@ export function compileHirArtifacts(
});
continue;
}
- artifacts.dynamics[de.id] = { source };
+ artifacts.dynamics[de.id] = {
+ source,
+ ...(options.includeHir ? { hir: item.fn } : {}),
+ };
}
for (const transition of transitions) {
@@ -197,6 +214,7 @@ export function compileHirArtifacts(
});
} else {
artifacts.lambdas[transition.id] = {
+ ...(options.includeHir ? { hir: item.fn } : {}),
source: program.source,
inputSlotCount: program.inputSlotCount,
};
@@ -235,6 +253,7 @@ export function compileHirArtifacts(
source: program.source,
inputSlotCount: program.inputSlotCount,
outputByteCount: program.outputByteCount,
+ ...(options.includeHir ? { hir: item.fn } : {}),
};
}
}
diff --git a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts
index 5c115bff987..26a017c7324 100644
--- a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts
+++ b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts
@@ -8,6 +8,7 @@
* packed-struct buffer access with compile-time-constant offsets/strides).
*/
import type { RuntimeDistribution } from "../simulation/authoring/user-code/distribution";
+import type { HirFunction } from "./hir";
export type HirParameterValues = Record;
@@ -86,6 +87,16 @@ export type HirLambdaArtifact = {
source: string;
/** Expected `indices.length` — engine-side sanity check. */
inputSlotCount: number;
+ /**
+ * The lowered HIR the program was emitted from.
+ *
+ * Carried so a second backend can compile the same code without re-running the
+ * TypeScript frontend. That matters for the WebGPU backend specifically: it
+ * runs in the browser, and lowering would pull the TypeScript compiler (and
+ * its Node builtins) into the browser bundle. HIR is a JSON-serializable tree,
+ * so it crosses the worker boundary with the rest of the artifact.
+ */
+ hir?: HirFunction;
};
export type HirKernelArtifact = {
@@ -93,10 +104,14 @@ export type HirKernelArtifact = {
inputSlotCount: number;
/** Expected staging byte length — engine-side sanity check. */
outputByteCount: number;
+ /** The lowered HIR the program was emitted from — see `HirLambdaArtifact.hir`. */
+ hir?: HirFunction;
};
export type HirDynamicsArtifact = {
source: string;
+ /** The lowered HIR the program was emitted from — see `HirLambdaArtifact.hir`. */
+ hir?: HirFunction;
};
export type HirMetricArtifact = {
diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts
index 3fcc2841218..27a52f6d9da 100644
--- a/libs/@hashintel/petrinaut-core/src/index.ts
+++ b/libs/@hashintel/petrinaut-core/src/index.ts
@@ -210,6 +210,9 @@ export {
getDefaultMonteCarloShardCount,
planMonteCarloShards,
} from "./simulation";
+// Dependency-free WebGPU capability check. The backend itself lives behind the
+// `./webgpu` entry point, which bundles the HIR frontend and must not reach UI.
+export { isWebGpuAvailable } from "./webgpu/support";
export type {
BackpressureConfig,
CreateMonteCarloExperimentConfig,
@@ -301,6 +304,7 @@ export type {
// --- HIR (type-only from the main entry; the compiler itself stays in the
// LSP worker, runtime instantiation in ./hir-runtime) ---
export type {
+ CompileHirArtifactsOptions,
HirArtifacts,
HirCompileFailure,
HirCompileResult,
diff --git a/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts b/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts
index b939b384a29..7126f4d3491 100644
--- a/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts
+++ b/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts
@@ -9,6 +9,7 @@ import {
import type { PetrinautExtensionSettings } from "../extensions";
// Type-only: must not pull the compiler (`typescript`) into client bundles.
import type { HirCompileResult } from "../hir";
+import type { CompileHirArtifactsOptions } from "../hir/compile";
import type { ReadableStore } from "../store";
import type { SDCPN } from "../types/sdcpn";
import type {
@@ -93,6 +94,12 @@ export interface LanguageClient {
this: void,
sdcpn: SDCPN,
extensions?: PetrinautExtensionSettings,
+ /**
+ * Pass `{ includeHir: true }` when the caller needs the HIR tree — only the
+ * WebGPU backend does. It roughly triples artifact size, so it is off by
+ * default.
+ */
+ options?: CompileHirArtifactsOptions,
): Promise;
/**
@@ -314,10 +321,11 @@ export function createLanguageClient(
position,
});
},
- requestHirArtifacts(sdcpn, extensions) {
+ requestHirArtifacts(sdcpn, extensions, options) {
return sendRequest("sdcpn/compileHirArtifacts", {
sdcpn,
extensions,
+ options,
});
},
diff --git a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts
index 0c352b578fa..a24dfd70ebb 100644
--- a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts
+++ b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts
@@ -387,7 +387,11 @@ workerRuntime.onMessage((data) => {
const { id } = data;
respond(
id,
- compileHirArtifacts(data.params.sdcpn, data.params.extensions),
+ compileHirArtifacts(
+ data.params.sdcpn,
+ data.params.extensions,
+ data.params.options,
+ ),
);
break;
}
diff --git a/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts b/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts
index 9df7c2aee59..9dfd432cc49 100644
--- a/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts
+++ b/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts
@@ -13,6 +13,7 @@
* upstream package directly.
*/
import type { PetrinautExtensionSettings } from "../../extensions";
+import type { CompileHirArtifactsOptions } from "../../hir/compile";
import type { SDCPN, ScenarioParameter } from "../../types/sdcpn";
import type {
Diagnostic,
@@ -143,6 +144,7 @@ type ClientRequest =
params: {
sdcpn: SDCPN;
extensions?: PetrinautExtensionSettings;
+ options?: CompileHirArtifactsOptions;
};
};
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment-stores.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment-stores.ts
new file mode 100644
index 00000000000..a60b17a3356
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment-stores.ts
@@ -0,0 +1,91 @@
+/**
+ * Store and event plumbing shared by every experiment backend.
+ *
+ * Extracted from `experiment.ts` when the WebGPU backend arrived: a second
+ * backend has to present the identical `MonteCarloExperiment` handle, and
+ * duplicating the stores would let the two drift in ways the UI would notice
+ * (a missed `Object.is` guard, say, causing render loops in one but not the
+ * other).
+ */
+import type { EventStream } from "../../../instance";
+import type { ReadableStore } from "../../../store";
+import type { MonteCarloUserDefinedMetricFrame } from "../metrics";
+
+export type WritableStore = ReadableStore & { set(next: T): void };
+
+export type EmittableEventStream = EventStream & { emit(event: T): void };
+
+export type MonteCarloExperimentMetrics = {
+ frames: readonly MonteCarloUserDefinedMetricFrame[];
+ latestByMetricId: Readonly>;
+};
+
+/**
+ * A minimal observable value.
+ *
+ * Skips notification when the value is unchanged, because consumers subscribe
+ * every store to one `sync` callback that patches React state.
+ */
+export function createReadableStore(initial: T): WritableStore {
+ let current = initial;
+ const listeners = new Set<(value: T) => void>();
+
+ return {
+ get: () => current,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ set(next) {
+ if (Object.is(next, current)) {
+ return;
+ }
+ current = next;
+ for (const listener of listeners) {
+ listener(current);
+ }
+ },
+ };
+}
+
+export function createEventStream(): EmittableEventStream {
+ const listeners = new Set<(event: T) => void>();
+
+ return {
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ emit(event) {
+ for (const listener of listeners) {
+ listener(event);
+ }
+ },
+ };
+}
+
+export function createEmptyMetricsState(): MonteCarloExperimentMetrics {
+ return { frames: [], latestByMetricId: {} };
+}
+
+/**
+ * Appends frames and refreshes the per-metric latest pointer.
+ *
+ * `latestByMetricId` is what the UI reads for current values, so it is kept
+ * alongside the flat timeline rather than derived on every render.
+ */
+export function appendMetricFrames(
+ state: MonteCarloExperimentMetrics,
+ nextFrames: readonly MonteCarloUserDefinedMetricFrame[],
+): MonteCarloExperimentMetrics {
+ const latestByMetricId = { ...state.latestByMetricId };
+
+ for (const frame of nextFrames) {
+ latestByMetricId[frame.metricId] = frame;
+ }
+
+ return {
+ frames: [...state.frames, ...nextFrames],
+ latestByMetricId,
+ };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts
index 7e5cc65f299..f4656c9ca61 100644
--- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts
+++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts
@@ -7,6 +7,12 @@ import {
createMonteCarloUserDefinedMetric,
} from "../metrics";
import { createMonteCarloSimulator } from "../monte-carlo-simulator";
+import {
+ appendMetricFrames,
+ createEmptyMetricsState,
+ createEventStream,
+ createReadableStore,
+} from "./experiment-stores";
import {
getDefaultMonteCarloShardCount,
planMonteCarloShards,
@@ -34,6 +40,7 @@ import type {
MonteCarloToMainMessage,
MonteCarloWorkerProgress,
} from "../worker/messages";
+import type { MonteCarloExperimentMetrics } from "./experiment-stores";
import type { MonteCarloShardPlanEntry } from "./shard-plan";
export type MonteCarloExperimentState =
@@ -44,10 +51,7 @@ export type MonteCarloExperimentState =
| "Error"
| "Cancelled";
-export type MonteCarloExperimentMetrics = {
- frames: readonly MonteCarloUserDefinedMetricFrame[];
- latestByMetricId: Readonly>;
-};
+export type { MonteCarloExperimentMetrics } from "./experiment-stores";
export type MonteCarloExperimentEvent =
| { type: "complete"; progress: MonteCarloWorkerProgress }
@@ -120,46 +124,9 @@ export interface MonteCarloExperiment {
dispose(this: void): void;
}
-function createReadableStore(initial: T): ReadableStore & {
- set(next: T): void;
-} {
- let current = initial;
- const listeners = new Set<(value: T) => void>();
-
- return {
- get: () => current,
- subscribe(listener) {
- listeners.add(listener);
- return () => listeners.delete(listener);
- },
- set(next) {
- if (Object.is(next, current)) {
- return;
- }
- current = next;
- for (const listener of listeners) {
- listener(current);
- }
- },
- };
-}
-
-function createEventStream(): EventStream & { emit(event: T): void } {
- const listeners = new Set<(event: T) => void>();
-
- return {
- subscribe(listener) {
- listeners.add(listener);
- return () => listeners.delete(listener);
- },
- emit(event) {
- for (const listener of listeners) {
- listener(event);
- }
- },
- };
-}
-
+/**
+ * Yields to the host between compute batches so the worker stays responsive.
+ */
function delay(): Promise {
const runtime = globalThis as {
setTimeout?: (handler: () => void, timeout?: number) => unknown;
@@ -172,29 +139,6 @@ function delay(): Promise {
: Promise.resolve();
}
-function createEmptyMetricsState(): MonteCarloExperimentMetrics {
- return {
- frames: [],
- latestByMetricId: {},
- };
-}
-
-function appendMetricFrames(
- state: MonteCarloExperimentMetrics,
- nextFrames: readonly MonteCarloUserDefinedMetricFrame[],
-): MonteCarloExperimentMetrics {
- const latestByMetricId = { ...state.latestByMetricId };
-
- for (const frame of nextFrames) {
- latestByMetricId[frame.metricId] = frame;
- }
-
- return {
- frames: [...state.frames, ...nextFrames],
- latestByMetricId,
- };
-}
-
function takePendingMetricFrames(
metrics: readonly MonteCarloUserDefinedMetric[],
lastFrameCounts: Map,
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu.ts b/libs/@hashintel/petrinaut-core/src/webgpu.ts
new file mode 100644
index 00000000000..885ab048561
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu.ts
@@ -0,0 +1,101 @@
+/**
+ * WebGPU compute backend for Monte Carlo experiments (experimental).
+ *
+ * A separate entry point, not part of the main bundle, because it depends on the
+ * HIR frontend to re-lower user code and because it is opt-in: the CPU path
+ * remains the default and the only one that runs every net.
+ *
+ * See `../docs/simulation-performance.md` §8 for why this shape (whole-experiment
+ * dispatch, on-GPU metric reduction) is the only GPU design that is faster than
+ * the CPU rather than slower.
+ */
+export {
+ createWebGpuExperimentBackend,
+ WEBGPU_BACKEND_ID,
+ type WebGpuExperimentBackendOptions,
+} from "./webgpu/webgpu-experiment-backend";
+
+export {
+ GPU_HISTOGRAM_BINS,
+ GPU_WORKGROUP_SIZE,
+ compileNetShader,
+} from "./webgpu/compile-net-shader";
+export type {
+ CompileNetShaderInput,
+ CompileNetShaderResult,
+ CompiledNetShader,
+ GpuMetricSpec,
+ GpuOdeMethod,
+} from "./webgpu/compile-net-shader";
+export {
+ analyzeCompilation,
+ summarizeGpuUnavailability,
+} from "./webgpu/compilation-report";
+export type {
+ AnalyzeCompilationInput,
+ CompilationItemKind,
+ CompilationItemReport,
+ CompilationItemStatus,
+ CompilationReport,
+} from "./webgpu/compilation-report";
+export {
+ assessGpuEligibility,
+ formatGpuIneligibility,
+} from "./webgpu/eligibility";
+export type {
+ GpuEligibility,
+ GpuIneligibilityReason,
+ GpuNetProfile,
+} from "./webgpu/eligibility";
+export {
+ describeMathFnSupport,
+ emitF32Literal,
+ isWgslRepresentableType,
+ WgslBailError,
+ WgslEmitter,
+} from "./webgpu/emit-wgsl";
+export type { WgslEmitterOptions, WgslValue } from "./webgpu/emit-wgsl";
+export { createGpuMonteCarloExperiment } from "./webgpu/gpu-experiment-handle";
+export type {
+ CreateGpuMonteCarloExperimentConfig,
+ CreateGpuMonteCarloExperimentResult,
+} from "./webgpu/gpu-experiment-handle";
+export { runGpuMonteCarloExperiment } from "./webgpu/gpu-experiment";
+export {
+ toGpuMetricFrames,
+ toGpuMetricSpecs,
+} from "./webgpu/gpu-metric-frames";
+export type {
+ GpuExperimentConfig,
+ GpuExperimentOutcome,
+} from "./webgpu/gpu-experiment";
+export { hirFromArtifacts } from "./webgpu/hir-from-artifacts";
+export type { NetHir } from "./webgpu/hir-from-artifacts";
+export {
+ DEFAULT_GPU_FRAMES_PER_DISPATCH,
+ requestGpuExperimentBackend,
+} from "./webgpu/backend";
+export type {
+ GpuBackend,
+ GpuBackendRequest,
+ GpuBackendUnavailable,
+} from "./webgpu/backend";
+export {
+ deriveGpuRunSeed,
+ requestGpuDevice,
+ runGpuExperiment,
+} from "./webgpu/runner";
+export { isWebGpuAvailable } from "./webgpu/support";
+export { tryTranslateKernel } from "./webgpu/try-translate-kernel";
+export type { KernelTranslationResult } from "./webgpu/try-translate-kernel";
+export type {
+ GpuDeviceHandle,
+ GpuExperimentRequest,
+ GpuExperimentResult,
+ GpuHistogramFrame,
+} from "./webgpu/runner";
+export {
+ isReservedWgslIdentifier,
+ mangleWgslIdentifier,
+} from "./webgpu/wgsl-identifiers";
+export { wgslPrelude } from "./webgpu/wgsl-prelude";
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts b/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts
new file mode 100644
index 00000000000..ded6211f3b4
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts
@@ -0,0 +1,207 @@
+/**
+ * One call that answers "can this net run on the GPU, and if so how".
+ *
+ * Consumers should not have to sequence eligibility, HIR lowering, shader
+ * generation and device acquisition themselves, nor decide which failures are
+ * fatal. Everything here degrades to a `supported: false` with a reason, so the
+ * caller's only job is to fall back to the CPU and show the reason.
+ *
+ * @layerRoot core.webgpu
+ * @role Generates a WGSL compute shader from a net's HIR and runs its experiment runs on the GPU
+ */
+import { resolveNetParameterValues } from "../parameter-values";
+import { compileNetShader, GPU_HISTOGRAM_BINS } from "./compile-net-shader";
+import { assessGpuEligibility, formatGpuIneligibility } from "./eligibility";
+import { hirFromArtifacts } from "./hir-from-artifacts";
+import { requestGpuDevice } from "./runner";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirArtifacts } from "../hir-runtime";
+import type { InitialMarking } from "../simulation/api";
+import type { SDCPN } from "../types/sdcpn";
+import type {
+ CompiledNetShader,
+ GpuMetricSpec,
+ GpuOdeMethod,
+} from "./compile-net-shader";
+import type { GpuNetProfile } from "./eligibility";
+import type { GpuDeviceHandle } from "./runner";
+
+/**
+ * Frames advanced per dispatch.
+ *
+ * Long dispatches risk the platform's GPU watchdog resetting the device, and a
+ * chunk boundary is also the only place progress can be reported. 300 frames
+ * measured well under any watchdog threshold while keeping dispatch overhead
+ * (~0.2 ms) negligible against the work.
+ */
+export const DEFAULT_GPU_FRAMES_PER_DISPATCH = 300;
+
+export type GpuBackendRequest = {
+ sdcpn: SDCPN;
+ /**
+ * Compiled artifacts for this net, carrying the HIR the shader is generated
+ * from.
+ *
+ * Required rather than lowered here: lowering runs the TypeScript frontend,
+ * and this module is imported by browser code, where that would drag the
+ * compiler and its Node builtins into the bundle. The artifacts are produced in
+ * the language worker, which already has the compiler.
+ */
+ hirArtifacts: HirArtifacts;
+ extensions?: PetrinautExtensionSettings;
+ parameterValues?: Record;
+ dt: number;
+ metrics: readonly GpuMetricSpec[];
+ /**
+ * Initial marking, keyed by place id.
+ *
+ * Used to refuse a net whose sampled places already exceed the histogram's
+ * range. Keyed rather than ordered because the caller cannot know
+ * `profile.places` order until this call returns. Optional, so callers that only
+ * want a shader need not supply it.
+ */
+ initialMarking?: InitialMarking;
+ /**
+ * Integrator for continuous dynamics.
+ *
+ * Defaults to RK4. A token's derivative depends only on that token, so all
+ * four stages fit in one invocation with no extra dispatch — measured at 2.5x
+ * Euler's cost for roughly four orders of magnitude less truncation error, so
+ * Euler is rarely the right choice here even though it is what the CPU uses.
+ */
+ odeMethod?: GpuOdeMethod;
+ framesPerDispatch?: number;
+};
+
+export type GpuBackend = {
+ supported: true;
+ handle: GpuDeviceHandle;
+ shader: CompiledNetShader;
+ profile: GpuNetProfile;
+ framesPerDispatch: number;
+ /** Notes that did not prevent use, e.g. user code that fell back to a default. */
+ warnings: string[];
+};
+
+export type GpuBackendUnavailable = {
+ supported: false;
+ /** Why the GPU path cannot be used, phrased for a user. */
+ reason: string;
+ /** Whether the net itself is the problem, as opposed to the environment. */
+ cause: "no-device" | "net-unsupported" | "shader-generation";
+};
+
+/**
+ * Prepares the GPU backend for one net, or explains why it is unavailable.
+ */
+/**
+ * Token count a place's initial marking represents: uncoloured places carry a
+ * plain number, typed places an array of token records.
+ */
+function initialTokenCount(
+ marking: InitialMarking[string] | undefined,
+): number {
+ if (typeof marking === "number") {
+ return marking;
+ }
+ return Array.isArray(marking) ? marking.length : 0;
+}
+
+export async function requestGpuExperimentBackend(
+ request: GpuBackendRequest,
+): Promise {
+ const {
+ sdcpn,
+ hirArtifacts,
+ extensions,
+ parameterValues = {},
+ dt,
+ metrics,
+ odeMethod = "rk4",
+ framesPerDispatch = DEFAULT_GPU_FRAMES_PER_DISPATCH,
+ } = request;
+
+ // Net eligibility is checked before touching the GPU: it is the most likely
+ // failure and the cheapest to determine.
+ const eligibility = assessGpuEligibility(sdcpn);
+ if (!eligibility.eligible) {
+ return {
+ supported: false,
+ cause: "net-unsupported",
+ reason: formatGpuIneligibility(eligibility.reasons),
+ };
+ }
+
+ const lowered = hirFromArtifacts(sdcpn, hirArtifacts, extensions);
+ const resolvedParameters = resolveNetParameterValues(
+ sdcpn.parameters,
+ parameterValues,
+ extensions?.parameters ?? true,
+ );
+
+ const compiled = compileNetShader({
+ sdcpn,
+ profile: eligibility.profile,
+ parameterValues: resolvedParameters,
+ lambdaHir: lowered.lambdas,
+ dynamicsHir: lowered.dynamics,
+ kernelHir: lowered.kernels,
+ dt,
+ framesPerDispatch,
+ metrics,
+ odeMethod,
+ extensions,
+ });
+ if (!compiled.ok) {
+ return {
+ supported: false,
+ cause: "shader-generation",
+ reason: `This net's user code cannot be compiled to a GPU shader: ${compiled.reason}`,
+ };
+ }
+
+ // Metrics are reduced on the device into a histogram with one bin per integer
+ // token count, and the shader clamps that index to the top bin. A sampled place
+ // that already starts at or above the ceiling reports the ceiling from frame 0 —
+ // a flat line rather than a trajectory — so refuse instead of producing it.
+ // Counts that climb past the ceiling mid-run cannot be caught here;
+ // `saturatedSamples` reports those after the run.
+ if (request.initialMarking !== undefined) {
+ for (const metric of metrics) {
+ const initialCount = initialTokenCount(
+ request.initialMarking[metric.placeId],
+ );
+ if (initialCount >= GPU_HISTOGRAM_BINS) {
+ const placeName =
+ eligibility.profile.places.find(
+ (place) => place.id === metric.placeId,
+ )?.name ?? metric.placeId;
+ return {
+ supported: false,
+ cause: "net-unsupported",
+ reason: `Place \`${placeName}\` starts with ${initialCount} tokens, and the GPU backend reduces metrics into a histogram of ${GPU_HISTOGRAM_BINS} bins — one per token count — so counts of ${GPU_HISTOGRAM_BINS} or more cannot be told apart.`,
+ };
+ }
+ }
+ }
+
+ const device = await requestGpuDevice();
+ if (!device.ok) {
+ return { supported: false, cause: "no-device", reason: device.reason };
+ }
+
+ const warnings = lowered.skipped.map(
+ (entry) =>
+ `\`${entry.itemId}\` could not be lowered (${entry.reason}); it will use the always-enabled default.`,
+ );
+
+ return {
+ supported: true,
+ handle: device.handle,
+ shader: compiled.shader,
+ profile: eligibility.profile,
+ framesPerDispatch,
+ warnings,
+ };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts
new file mode 100644
index 00000000000..ba0b45611f3
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts
@@ -0,0 +1,298 @@
+import { describe, expect, it } from "vitest";
+
+import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher";
+import { sirModel } from "../examples/sir-model";
+import { compileHirArtifacts } from "../hir";
+import {
+ analyzeCompilation,
+ summarizeGpuUnavailability,
+} from "./compilation-report";
+
+import type { SDCPN } from "../types/sdcpn";
+
+function analyze(sdcpn: SDCPN) {
+ const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
+ includeHir: true,
+ });
+ // Parameter values are deliberately not passed: the net's own defaults are the
+ // documented fallback, and every caller in the app relies on that.
+ return analyzeCompilation({ sdcpn, artifacts });
+}
+
+const satellites = probabilisticSatellitesSDCPN.petriNetDefinition;
+
+describe("analyzeCompilation", () => {
+ it("reports an uncoloured net as GPU-ready and keeps the WGSL", () => {
+ const report = analyze(sirModel.petriNetDefinition);
+
+ expect(report.gpuReady).toBe(true);
+ expect(report.eligibilityReasons).toStrictEqual([]);
+ expect(report.shaderFailure).toBeNull();
+ expect(report.wgsl).toContain("@compute");
+ expect(report.bytesPerRun).toBeGreaterThan(0);
+ // Both transitions' conditions lowered and emitted.
+ expect(
+ report.items.filter(
+ (item) => item.kind === "lambda" && item.status === "gpu-ready",
+ ),
+ ).toHaveLength(2);
+ });
+
+ it("does not claim an item is GPU-ready when emission never ran", () => {
+ // The satellites net is refused for missing capacities, so nothing was
+ // emitted. Saying "GPU" here would assert something untested — and this net
+ // in fact fails emission once capacities are added.
+ const report = analyze(satellites);
+
+ const dynamics = report.items.filter((item) => item.kind === "dynamics");
+ expect(dynamics.length).toBeGreaterThan(0);
+ for (const item of dynamics) {
+ expect(item.status).toBe("not-attempted");
+ expect(item.detail).toMatch(/refused before shader emission/i);
+ }
+ expect(report.items.some((item) => item.status === "gpu-ready")).toBe(
+ false,
+ );
+ });
+
+ it("reports the structural blockers for the satellites example", () => {
+ const report = analyze(satellites);
+
+ expect(report.gpuReady).toBe(false);
+ expect(
+ report.eligibilityReasons.map((reason) => reason.code),
+ ).toStrictEqual([
+ "colored-place-without-capacity",
+ "colored-place-without-capacity",
+ ]);
+ // Eligibility failed, so emission never ran — there is nothing to report.
+ expect(report.shaderFailure).toBeNull();
+ expect(report.wgsl).toBeNull();
+ });
+
+ it("compiles a weight-1 typed condition once capacities let the net through", () => {
+ // `Crash` reads `tokens.Space[0].x` and `.y`. That used to bail — the shader
+ // bound `tokens` to an empty tuple — and now emits a scan over candidate
+ // tokens with the same slot arithmetic the dynamics loop uses.
+ const capped: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 32 })),
+ transitions: satellites.transitions.filter(
+ (transition) => transition.name === "Crash",
+ ),
+ };
+
+ const report = analyze(capped);
+
+ expect(report.eligibilityReasons).toStrictEqual([]);
+ expect(report.shaderFailure).toBeNull();
+ expect(report.items.find((item) => item.kind === "lambda")?.status).toBe(
+ "gpu-ready",
+ );
+ // And GPU-ready overall now that its kernel writes the debris attributes.
+ expect(report.gpuReady).toBe(true);
+ });
+
+ it("reports a kernel as GPU-ready once the net compiles", () => {
+ // Every satellites kernel translates, distributions included, and the shader
+ // now writes the attributes they produce.
+ const capped: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 32 })),
+ };
+ const kernels = analyze(capped).items.filter(
+ (item) => item.kind === "kernel",
+ );
+
+ expect(kernels.length).toBeGreaterThan(0);
+ for (const kernel of kernels) {
+ expect(kernel.status).toBe("gpu-ready");
+ expect(kernel.hirNodeCount).toBeGreaterThan(0);
+ }
+ });
+
+ it("names the code that stops a kernel translating", () => {
+ // No built-in example has an untranslatable kernel, so this adds a `string`
+ // attribute — 64-bit string-pool ids, which WGSL has no room for. The point
+ // is that the report says *that*, rather than the generic "the GPU does not
+ // run kernels" it says when the backend is what is missing.
+ const labelled: SDCPN = {
+ ...satellites,
+ types: satellites.types.map((type, index) =>
+ index === 0
+ ? {
+ ...type,
+ elements: [
+ ...type.elements,
+ { elementId: "el__tag", name: "tag", type: "string" as const },
+ ],
+ }
+ : type,
+ ),
+ transitions: satellites.transitions.map((transition) =>
+ transition.name === "LaunchSatellite"
+ ? {
+ ...transition,
+ transitionKernelCode: `export default TransitionKernel(() => ({
+ Space: [{ x: 0, y: 0, direction: 0, velocity: 1, tag: "sat" }],
+}))`,
+ }
+ : transition,
+ ),
+ };
+
+ const launch = analyze(labelled).items.find(
+ (item) => item.kind === "kernel" && item.itemName === "LaunchSatellite",
+ );
+
+ expect(launch?.status).toBe("cpu-only");
+ expect(launch?.detail).toMatch(/Cannot be translated to WGSL: .*string/);
+ });
+
+ it("does not blame the GPU for a kernel neither engine uses", () => {
+ // A kernel is only compiled when the transition has a typed output place
+ // (`isTransitionKernelAvailable`). SIR has kernel *code* but no typed places,
+ // so the engine ignores it — reporting that as a GPU limitation was wrong.
+ const report = analyze(sirModel.petriNetDefinition);
+ const kernels = report.items.filter((item) => item.kind === "kernel");
+
+ expect(kernels.length).toBeGreaterThan(0);
+ for (const kernel of kernels) {
+ expect(kernel.status).toBe("disabled");
+ expect(kernel.detail).toMatch(/no typed output place/);
+ }
+ });
+
+ it("counts HIR nodes so the panel can show expression size", () => {
+ // SIR's condition is exactly `parameters.infection_rate` — one node. Pinning
+ // it at 1 keeps the count honest at the bottom end.
+ const sir = analyze(sirModel.petriNetDefinition);
+ expect(sir.items.find((item) => item.kind === "lambda")?.hirNodeCount).toBe(
+ 1,
+ );
+
+ // Satellites' `Crash` condition is a comparison over a hypot of two token
+ // fields against a sum of three parameters, so the walk has to recurse.
+ const capped: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 32 })),
+ };
+ const crash = analyze(capped).items.find(
+ (item) => item.kind === "lambda" && item.itemName === "Crash",
+ );
+ expect(crash?.hirNodeCount).toBeGreaterThan(8);
+ });
+
+ it("reports metric shapes the GPU histogram cannot serve", () => {
+ const sdcpn = sirModel.petriNetDefinition;
+ const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
+ includeHir: true,
+ });
+
+ const withPlaceCount = analyzeCompilation({
+ sdcpn,
+ artifacts,
+ metricSpecs: [
+ {
+ id: "count",
+ label: "Susceptible",
+ kind: "placeTokenCountMean",
+ placeId: sdcpn.places[0]!.id,
+ },
+ ],
+ });
+ expect(withPlaceCount.metricFailure).toBeNull();
+
+ const withFiringCount = analyzeCompilation({
+ sdcpn,
+ artifacts,
+ metricSpecs: [
+ {
+ id: "firings",
+ label: "Infections",
+ kind: "transitionFiringCount",
+ transitionId: sdcpn.transitions[0]!.id,
+ },
+ ],
+ });
+ expect(withFiringCount.metricFailure).not.toBeNull();
+ expect(withFiringCount.gpuReady).toBe(false);
+ });
+
+ it("does not run the metric gate when no metrics are given", () => {
+ // The panel analyses a net being edited, which has no experiment metrics yet.
+ const report = analyze(sirModel.petriNetDefinition);
+
+ expect(report.metricFailure).toBeNull();
+ });
+});
+
+describe("summarizeGpuUnavailability", () => {
+ it("says nothing when the net is GPU-ready", () => {
+ expect(
+ summarizeGpuUnavailability(analyze(sirModel.petriNetDefinition)),
+ ).toBeNull();
+ });
+
+ it("leads with a structural reason, which is the actionable one", () => {
+ const summary = summarizeGpuUnavailability(analyze(satellites));
+
+ expect(summary).toMatch(/holds typed tokens but has no token capacity/);
+ // Two places are uncapped; the tooltip says so without listing both.
+ expect(summary).toMatch(/\(\+1 more\)$/);
+ });
+
+ it("falls through to the emitter's message only when nothing better exists", () => {
+ // A `string` attribute is refused by eligibility now, with a better message,
+ // so the case that actually reaches the emitter is a limitation of the token
+ // scan: consuming typed tokens from two places would be a Cartesian product
+ // across arcs, which is a nested scan and is not supported.
+ const debrisPlace = satellites.places.find(
+ (place) => place.name === "Debris",
+ )!;
+ const twoTypedInputs: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 32 })),
+ transitions: satellites.transitions
+ .filter((transition) => transition.name === "Crash")
+ .map((transition) => ({
+ ...transition,
+ outputArcs: [],
+ inputArcs: [
+ ...transition.inputArcs,
+ { placeId: debrisPlace.id, weight: 1, type: "standard" as const },
+ ],
+ })),
+ };
+ const report = analyze(twoTypedInputs);
+
+ expect(report.eligibilityReasons).toStrictEqual([]);
+ expect(report.shaderFailure).toMatch(/only one is supported/);
+ expect(summarizeGpuUnavailability(report)).toMatch(
+ /cannot be compiled to a GPU shader/,
+ );
+ });
+
+ it("reports a metric refusal ahead of the shader message", () => {
+ const sdcpn = sirModel.petriNetDefinition;
+ const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
+ includeHir: true,
+ });
+ const report = analyzeCompilation({
+ sdcpn,
+ artifacts,
+ metricSpecs: [
+ {
+ id: "firings",
+ label: "Infections",
+ kind: "transitionFiringCount",
+ transitionId: sdcpn.transitions[0]!.id,
+ },
+ ],
+ });
+
+ // The net itself compiles fine, so only the metric stands in the way.
+ expect(report.shaderFailure).toBeNull();
+ expect(summarizeGpuUnavailability(report)).toBe(report.metricFailure);
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts
new file mode 100644
index 00000000000..95432295efc
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts
@@ -0,0 +1,343 @@
+/**
+ * Explains, per net and per item, what the compilation pipeline made of a net:
+ * which user code lowered to HIR, and what the GPU backend can and cannot take.
+ *
+ * This exists because the pipeline has three independent gates that fail in
+ * different places, and only the first one produces a good message:
+ *
+ * 1. `assessGpuEligibility` — structural, checked up front, reports named reasons.
+ * 2. `compileNetShader` — bails while emitting WGSL, with a message written for
+ * whoever wrote the emitter (`field access on a array, which has no fields`)
+ * rather than for whoever wrote the net.
+ * 3. `toGpuMetricSpecs` — refuses metric shapes the on-GPU histogram cannot serve.
+ *
+ * A user hitting gate 2 or 3 currently sees a single fallback sentence and has no
+ * way to find out which transition caused it. This report attributes each failure
+ * to an item so the UI can point at it.
+ *
+ * It is deliberately read-only and device-free: it answers "would this compile"
+ * without acquiring a GPU, so it can run while editing.
+ */
+import { resolveNetParameterValues } from "../parameter-values";
+import { compileNetShader } from "./compile-net-shader";
+import { assessGpuEligibility } from "./eligibility";
+import { toGpuMetricSpecs } from "./gpu-metric-frames";
+import { hirFromArtifacts } from "./hir-from-artifacts";
+import { tryTranslateKernel } from "./try-translate-kernel";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirArtifacts } from "../hir-runtime";
+import type { MonteCarloMetricSpec } from "../simulation/monte-carlo/metrics/types";
+import type { SDCPN } from "../types/sdcpn";
+import type { GpuIneligibilityReason } from "./eligibility";
+
+/** What kind of user code an item carries. */
+export type CompilationItemKind = "lambda" | "kernel" | "dynamics";
+
+export type CompilationItemStatus =
+ /** Lowered to HIR and emittable as WGSL. */
+ | "gpu-ready"
+ /** Lowered to HIR, but the GPU emitter cannot take it. */
+ | "cpu-only"
+ /**
+ * Lowered to HIR, but the net was refused before emission ran, so whether this
+ * item would emit is genuinely unknown. Reporting it as GPU-ready would claim
+ * something that was never tested.
+ */
+ | "not-attempted"
+ /** No HIR — either it never compiled, or artifacts were built without it. */
+ | "no-hir"
+ /** The relevant extension is off, so the engine does not use this code. */
+ | "disabled";
+
+export type CompilationItemReport = {
+ /** Place, transition or differential-equation id, for selecting the item. */
+ itemId: string;
+ itemName: string;
+ kind: CompilationItemKind;
+ status: CompilationItemStatus;
+ /** Why it is not `gpu-ready`, phrased for the net's author. */
+ detail: string | null;
+ /** Node count of the lowered HIR body, when there is one. */
+ hirNodeCount: number | null;
+};
+
+export type CompilationReport = {
+ /** True when the whole net would run on the GPU as configured. */
+ gpuReady: boolean;
+ /** Structural reasons the net was refused before any code was emitted. */
+ eligibilityReasons: GpuIneligibilityReason[];
+ /** Set when the net was structurally eligible but shader emission failed. */
+ shaderFailure: string | null;
+ /** Bytes of GPU state one run needs, when known. */
+ bytesPerRun: number | null;
+ /** Generated WGSL, when emission succeeded. Shown verbatim in the UI. */
+ wgsl: string | null;
+ /** Why the configured metrics cannot be served on the GPU, if they cannot. */
+ metricFailure: string | null;
+ items: CompilationItemReport[];
+};
+
+function countHirNodes(node: unknown): number {
+ if (node === null || typeof node !== "object") {
+ return 0;
+ }
+ if (Array.isArray(node)) {
+ let total = 0;
+ for (const entry of node) {
+ total += countHirNodes(entry);
+ }
+ return total;
+ }
+
+ // Spans are position data, not expression structure, so they are not counted.
+ let total = "kind" in node ? 1 : 0;
+ for (const [key, value] of Object.entries(node)) {
+ if (key === "span") {
+ continue;
+ }
+ total += countHirNodes(value);
+ }
+ return total;
+}
+
+export type AnalyzeCompilationInput = {
+ sdcpn: SDCPN;
+ /** Must come from `compileHirArtifacts(..., { includeHir: true })`. */
+ artifacts: HirArtifacts;
+ extensions?: PetrinautExtensionSettings;
+ /**
+ * Resolved net parameter values. The shader inlines parameters as literals, so
+ * omitting one makes emission fail with `unknown parameter ...` — which reads
+ * as a defect in the net rather than a missing argument. Defaults to each
+ * parameter's own declared default, which is what the net means on its own.
+ */
+ parameterValues?: Readonly>;
+ /** Metric specs an experiment would run. Omit to skip the metric gate. */
+ metricSpecs?: readonly MonteCarloMetricSpec[];
+ dt?: number;
+};
+
+export function analyzeCompilation({
+ sdcpn,
+ artifacts,
+ extensions,
+ parameterValues,
+ metricSpecs,
+ dt = 0.1,
+}: AnalyzeCompilationInput): CompilationReport {
+ // The canonical resolver, so the report inlines the same literals a real run
+ // would. Absent values fail emission with `unknown parameter ...`, which reads
+ // as a defect in the net rather than a missing argument.
+ const resolvedParameterValues =
+ parameterValues ??
+ resolveNetParameterValues(
+ sdcpn.parameters,
+ {},
+ extensions?.parameters ?? true,
+ );
+ const netHir = hirFromArtifacts(sdcpn, artifacts, extensions);
+ const eligibility = assessGpuEligibility(sdcpn);
+
+ let shaderFailure: string | null = null;
+ let wgsl: string | null = null;
+ let bytesPerRun: number | null = null;
+
+ if (eligibility.eligible) {
+ bytesPerRun = eligibility.profile.bytesPerRun;
+ const compiled = compileNetShader({
+ sdcpn,
+ profile: eligibility.profile,
+ parameterValues: resolvedParameterValues,
+ lambdaHir: netHir.lambdas,
+ dynamicsHir: netHir.dynamics,
+ kernelHir: netHir.kernels,
+ extensions,
+ dt,
+ // Only affects the emitted loop bound, not whether emission succeeds.
+ framesPerDispatch: 64,
+ metrics: [],
+ odeMethod: "rk4",
+ });
+ if (compiled.ok) {
+ wgsl = compiled.shader.wgsl;
+ } else {
+ shaderFailure = compiled.reason;
+ }
+ }
+
+ let metricFailure: string | null = null;
+ if (metricSpecs !== undefined && metricSpecs.length > 0) {
+ const gpuMetrics = toGpuMetricSpecs(metricSpecs);
+ if (!gpuMetrics.ok) {
+ metricFailure = gpuMetrics.reason;
+ }
+ }
+
+ // Attributing a shader bail to one item would mean re-emitting each in
+ // isolation, which can succeed where the whole net fails. Instead, mark every
+ // item that could have caused it and say so once, in `shaderFailure`.
+ const items: CompilationItemReport[] = [];
+
+ /** Status for an item whose HIR exists, given how far the pipeline got. */
+ const emittedStatus: CompilationItemStatus = !eligibility.eligible
+ ? "not-attempted"
+ : shaderFailure === null
+ ? "gpu-ready"
+ : "cpu-only";
+ const emittedDetail: string | null = !eligibility.eligible
+ ? "The net was refused before shader emission, so this was never tried."
+ : shaderFailure;
+
+ const skippedReasonByItemId = new Map(
+ netHir.skipped.map((entry) => [entry.itemId, entry.reason]),
+ );
+
+ for (const transition of sdcpn.transitions) {
+ if (transition.lambdaCode.trim() === "") {
+ continue;
+ }
+ const hir = netHir.lambdas.get(transition.id);
+ const skipped = skippedReasonByItemId.get(transition.id);
+ items.push({
+ itemId: transition.id,
+ itemName: transition.name,
+ kind: "lambda",
+ status:
+ hir !== undefined
+ ? emittedStatus
+ : skipped !== undefined
+ ? "no-hir"
+ : "disabled",
+ detail:
+ hir !== undefined
+ ? emittedDetail
+ : (skipped ?? "Stochasticity is off, so this condition is not used."),
+ hirNodeCount: hir ? countHirNodes(hir.body) : null,
+ });
+ }
+
+ for (const place of sdcpn.places) {
+ if (place.dynamicsEnabled !== true) {
+ continue;
+ }
+ const hir = netHir.dynamics.get(place.id);
+ items.push({
+ itemId: place.id,
+ itemName: place.name,
+ kind: "dynamics",
+ status: hir !== undefined ? emittedStatus : "no-hir",
+ detail: hir !== undefined ? emittedDetail : "No HIR for this place.",
+ hirNodeCount: hir ? countHirNodes(hir.body) : null,
+ });
+ }
+
+ for (const transition of sdcpn.transitions) {
+ if (transition.transitionKernelCode.trim() === "") {
+ continue;
+ }
+ // A kernel is only compiled when the transition has a typed output place
+ // (`isTransitionKernelAvailable`). Without one, neither engine uses the code
+ // at all — reporting it as "the GPU cannot run kernels" blamed the backend
+ // for something no backend does here.
+ if (artifacts.kernels[transition.id] === undefined) {
+ items.push({
+ itemId: transition.id,
+ itemName: transition.name,
+ kind: "kernel",
+ status: "disabled",
+ detail:
+ "This transition has no typed output place, so neither engine uses its kernel.",
+ hirNodeCount: null,
+ });
+ continue;
+ }
+
+ const hir = netHir.kernels.get(transition.id);
+ // Always `cpu-only` when there is a kernel to run: nothing runs kernels on
+ // the GPU yet. The detail says which kind of blocked it is, because "the
+ // backend cannot write output tokens yet" and "this kernel uses a string
+ // attribute" are the same outcome and completely different work.
+ const translation =
+ hir === undefined
+ ? null
+ : tryTranslateKernel({
+ sdcpn,
+ transition,
+ hir,
+ extensions,
+ parameterValues: resolvedParameterValues,
+ });
+ items.push({
+ itemId: transition.id,
+ itemName: transition.name,
+ kind: "kernel",
+ status:
+ hir === undefined
+ ? "no-hir"
+ : // A failed translation is a *tested* negative, so it stays `cpu-only`
+ // even when the net was refused before emission ran. Only a successful
+ // translation defers to how far the pipeline got.
+ translation?.translatable === false
+ ? "cpu-only"
+ : emittedStatus,
+ detail:
+ translation === null
+ ? "Its compiled artifact carries no HIR, so it cannot be translated."
+ : translation.translatable
+ ? emittedDetail
+ : `Cannot be translated to WGSL: ${translation.reason}`,
+ hirNodeCount: hir ? countHirNodes(hir.body) : null,
+ });
+ }
+
+ return {
+ gpuReady:
+ eligibility.eligible &&
+ shaderFailure === null &&
+ metricFailure === null,
+ eligibilityReasons: eligibility.eligible ? [] : eligibility.reasons,
+ shaderFailure,
+ bytesPerRun,
+ wgsl,
+ metricFailure,
+ items,
+ };
+}
+
+/**
+ * One sentence explaining why the GPU cannot run this net, or `null` when it can.
+ *
+ * For a disabled control's tooltip, where there is room for one reason rather
+ * than a list. Ordered by how actionable each kind is, not by where the pipeline
+ * happened to stop: a named structural reason tells the author what to change,
+ * whereas the emitter's own message describes an expression tree. The Compilation
+ * panel shows the full picture.
+ */
+export function summarizeGpuUnavailability(
+ report: CompilationReport,
+): string | null {
+ if (report.gpuReady) {
+ return null;
+ }
+
+ const others = (count: number) => (count > 1 ? ` (+${count - 1} more)` : "");
+
+ const [firstReason] = report.eligibilityReasons;
+ if (firstReason !== undefined) {
+ return `${firstReason.message}${others(report.eligibilityReasons.length)}`;
+ }
+
+ if (report.metricFailure !== null) {
+ return report.metricFailure;
+ }
+
+ if (report.shaderFailure !== null) {
+ return `This net's code cannot be compiled to a GPU shader: ${report.shaderFailure}`;
+ }
+
+ // `gpuReady` is false only when one of the above is set, so this is
+ // unreachable — but returning a vague sentence beats returning null and
+ // silently enabling a control that will fall back.
+ return "The GPU backend cannot run this net.";
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts
new file mode 100644
index 00000000000..f641dc76d2d
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts
@@ -0,0 +1,704 @@
+import { describe, expect, it } from "vitest";
+
+import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher";
+import { sirModel } from "../examples/sir-model";
+import { resolveNetParameterValues } from "../parameter-values";
+import { compileNetShader } from "./compile-net-shader";
+import { assessGpuEligibility } from "./eligibility";
+import { lowerNetHir } from "./lower-net-hir";
+
+import type { SDCPN } from "../types/sdcpn";
+import type { GpuOdeMethod } from "./compile-net-shader";
+
+function compileFor(
+ sdcpn: SDCPN,
+ {
+ odeMethod = "rk4",
+ metrics = [] as { id: string; placeId: string }[],
+ dt = 0.1,
+ framesPerDispatch = 300,
+ }: {
+ odeMethod?: GpuOdeMethod;
+ metrics?: { id: string; placeId: string }[];
+ dt?: number;
+ framesPerDispatch?: number;
+ } = {},
+) {
+ const eligibility = assessGpuEligibility(sdcpn);
+ if (!eligibility.eligible) {
+ throw new Error(
+ `net not eligible: ${eligibility.reasons.map((r) => r.code).join(", ")}`,
+ );
+ }
+ const lowered = lowerNetHir(sdcpn);
+ return compileNetShader({
+ sdcpn,
+ profile: eligibility.profile,
+ parameterValues: resolveNetParameterValues(sdcpn.parameters, {}, true),
+ lambdaHir: lowered.lambdas,
+ dynamicsHir: lowered.dynamics,
+ kernelHir: lowered.kernels,
+ dt,
+ framesPerDispatch,
+ metrics,
+ odeMethod,
+ });
+}
+
+const sir = sirModel.petriNetDefinition;
+const satellites = probabilisticSatellitesSDCPN.petriNetDefinition;
+
+describe("compileNetShader", () => {
+ it("compiles the uncoloured SIR net", () => {
+ const result = compileFor(sir);
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ // 3 counts + 2 x (elapsed + firings) + rng + status.
+ expect(result.shader.stateWordsPerRun).toBe(9);
+ expect(result.shader.compiledLambdas).toStrictEqual([
+ "transition__infection",
+ "transition__recovery",
+ ]);
+ });
+
+ it("declares one invocation-per-run entry point", () => {
+ const result = compileFor(sir);
+ if (!result.ok) throw new Error(result.reason);
+
+ expect(result.shader.wgsl).toContain("@compute @workgroup_size(256)");
+ expect(result.shader.wgsl).toContain("fn step_runs(");
+ // The frame loop must be inside the shader; a host-driven per-frame dispatch
+ // would cost more in readback than the work itself.
+ expect(result.shader.wgsl).toContain(
+ "for (var frame: u32 = 0u; frame < 300u;",
+ );
+ });
+
+ it("commits the generator state only when a transition fires", () => {
+ // This mirrors the CPU engine, where `advance-run.ts` skips the `rngState`
+ // assignment for a transition that does not fire. Holding `u` fixed until it
+ // is consumed makes firing an exponential waiting time; redrawing every frame
+ // would be a Bernoulli trial and fire measurably sooner. Verified against
+ // the CPU engine: redrawing produced a 21% divergence by frame 599.
+ const result = compileFor(sir);
+ if (!result.ok) throw new Error(result.reason);
+
+ expect(result.shader.wgsl).toContain("var rng_candidate = rng_state;");
+ expect(result.shader.wgsl).toContain(
+ "let u = rng_next_f32(&rng_candidate);",
+ );
+ expect(result.shader.wgsl).toContain(
+ "if (fires) { rng_state = rng_candidate; }",
+ );
+ });
+
+ it("applies removals immediately and additions at end of frame", () => {
+ const result = compileFor(sir);
+ if (!result.ok) throw new Error(result.reason);
+ const wgsl = result.shader.wgsl;
+
+ // Infection consumes one Susceptible immediately...
+ expect(wgsl).toContain("counts[0u] = counts[0u] - 1u;");
+ // ...but its two Infected outputs are deferred, so a later transition in the
+ // same frame cannot consume them.
+ expect(wgsl).toContain("pending[1u] = pending[1u] + 2;");
+ expect(wgsl).toContain(
+ "counts[1u] = u32(max(0, i32(counts[1u]) + pending[1u]));",
+ );
+ });
+
+ it("marks a run deadlocked only when nothing fired and nothing is enabled", () => {
+ const result = compileFor(sir);
+ if (!result.ok) throw new Error(result.reason);
+
+ expect(result.shader.wgsl).toContain(
+ "if (!any_fired && !any_enabled) { status = 1u; }",
+ );
+ });
+
+ it.each([
+ ["euler", 1],
+ ["rk2", 2],
+ ["rk4", 4],
+ ] as const)("emits %s with %i derivative stages", (odeMethod, stages) => {
+ // A place whose token carries a real attribute with a differential equation.
+ const net: SDCPN = {
+ types: [
+ {
+ id: "c",
+ name: "Item",
+ iconSlug: "circle",
+ displayColor: "#0f0",
+ elements: [{ elementId: "v", name: "v", type: "real" }],
+ },
+ ],
+ places: [
+ {
+ id: "pool",
+ name: "Pool",
+ colorId: "c",
+ capacity: 4,
+ dynamicsEnabled: true,
+ differentialEquationId: "eq",
+ x: 0,
+ y: 0,
+ },
+ ],
+ transitions: [
+ {
+ id: "t",
+ name: "T",
+ inputArcs: [],
+ outputArcs: [],
+ lambdaType: "predicate",
+ lambdaCode: "export default Lambda(() => false);",
+ transitionKernelCode: "export default TransitionKernel(() => ({}));",
+ x: 0,
+ y: 0,
+ },
+ ],
+ differentialEquations: [
+ {
+ id: "eq",
+ name: "decay",
+ colorId: "c",
+ code: "export default Dynamics((tokens) => tokens.map((token) => ({ v: -token.v })));",
+ },
+ ],
+ parameters: [],
+ };
+
+ const result = compileFor(net, { odeMethod });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ const wgsl = result.shader.wgsl;
+ for (let stage = 1; stage <= stages; stage++) {
+ expect(wgsl).toContain(`k${stage}_0`);
+ }
+ expect(wgsl).not.toContain(`k${stages + 1}_0`);
+ // RK4's four stages live in one invocation because a token's derivative
+ // depends only on that token — no extra dispatch, no shared memory.
+ if (odeMethod === "rk4") {
+ expect(wgsl).toContain(
+ "(DT / 6.0) * (k1_0 + 2.0 * k2_0 + 2.0 * k3_0 + k4_0)",
+ );
+ }
+ });
+
+ it("reduces metrics in workgroup memory rather than global atomics", () => {
+ const result = compileFor(sir, {
+ metrics: [{ id: "infected", placeId: "place__infected" }],
+ });
+ if (!result.ok) throw new Error(result.reason);
+ const wgsl = result.shader.wgsl;
+
+ // Measured 2x faster than hitting global atomics directly, because runs in a
+ // workgroup collide on the same bin constantly.
+ expect(wgsl).toContain("var local_hist");
+ expect(wgsl).toContain("atomicAdd(&local_hist[");
+ expect(wgsl).toContain("workgroupBarrier();");
+ expect(result.shader.metricIds).toStrictEqual(["infected"]);
+ });
+
+ it("emits no histogram machinery when there are no metrics", () => {
+ const result = compileFor(sir);
+ if (!result.ok) throw new Error(result.reason);
+
+ expect(result.shader.wgsl).not.toContain("local_hist");
+ });
+
+ it("reports a reason rather than throwing when a metric names an unknown place", () => {
+ const result = compileFor(sir, {
+ metrics: [{ id: "m", placeId: "does-not-exist" }],
+ });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toMatch(/unknown place/);
+ });
+
+ it("inlines dt as the f32 the device will hold", () => {
+ const result = compileFor(sir, { dt: 0.1 });
+ if (!result.ok) throw new Error(result.reason);
+
+ expect(result.shader.wgsl).toContain(
+ "const DT: f32 = 0.10000000149011612;",
+ );
+ });
+});
+
+/**
+ * The host reads a run's RNG state and status out of the state buffer by word
+ * offset. Those offsets used to be derived by counting back from
+ * `stateWordsPerRun`, which is only correct for a net with no token attributes:
+ * the layout is `counts | elapsed | firings | rng | status | tokens`, so on a
+ * typed net the seed landed in a token attribute and the status came out of the
+ * token array — leaving every run sharing one RNG stream while reporting
+ * confidently. These pin the offsets against the shader's own writes.
+ */
+describe("state layout offsets", () => {
+ /** A typed place with one real attribute, so the layout has token words. */
+ const typedNet = (): SDCPN => ({
+ ...sir,
+ types: [
+ {
+ id: "type__tank",
+ name: "Tank",
+ iconSlug: "circle",
+ displayColor: "#3366ff",
+ elements: [{ elementId: "el__level", name: "level", type: "real" }],
+ },
+ ],
+ places: sir.places.map((place, index) =>
+ index === 0 ? { ...place, colorId: "type__tank", capacity: 4 } : place,
+ ),
+ });
+
+ const statusWriteOffset = (wgsl: string): number =>
+ Number(/state\[base \+ (\d+)u\] = status;/.exec(wgsl)![1]);
+ const rngWriteOffset = (wgsl: string): number =>
+ Number(/state\[base \+ (\d+)u\] = rng_state;/.exec(wgsl)![1]);
+
+ it("matches where the shader writes them, for an uncoloured net", () => {
+ const compiled = compileFor(sir);
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+
+ expect(compiled.shader.statusOffset).toBe(
+ statusWriteOffset(compiled.shader.wgsl),
+ );
+ expect(compiled.shader.rngOffset).toBe(
+ rngWriteOffset(compiled.shader.wgsl),
+ );
+ });
+
+ it("matches where the shader writes them once token attributes exist", () => {
+ const compiled = compileFor(typedNet());
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ const { rngOffset, statusOffset, stateWordsPerRun } = compiled.shader;
+
+ expect(statusOffset).toBe(statusWriteOffset(compiled.shader.wgsl));
+ expect(rngOffset).toBe(rngWriteOffset(compiled.shader.wgsl));
+
+ // And the old derivation would have been wrong here, which is the whole
+ // point: token words sit after the status word.
+ expect(statusOffset).not.toBe(stateWordsPerRun - 1);
+ expect(rngOffset).not.toBe(stateWordsPerRun - 2);
+ });
+});
+
+/**
+ * A weight-1 typed input arc means the transition *chooses* a token, and the CPU
+ * chooses by walking `indexCombinations(n, 1)` and firing on the first passing
+ * candidate. These pin the two halves of that: reading the candidate's attributes,
+ * and removing exactly the chosen token afterwards.
+ */
+describe("typed token consumption", () => {
+ const crashNet = (): SDCPN => ({
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 16 })),
+ transitions: satellites.transitions.filter(
+ (transition) => transition.name === "Crash",
+ ),
+ });
+
+ const crashWgsl = (): string => {
+ const compiled = compileFor(crashNet());
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ return compiled.shader.wgsl;
+ };
+
+ it("reads the candidate token's attributes from its own slot", () => {
+ const wgsl = crashWgsl();
+
+ // Same slot arithmetic the dynamics loop uses, indexed by the candidate
+ // rather than by a full sweep.
+ expect(wgsl).toMatch(
+ /bitcast\(state\[\(base \+ \d+u \+ cand_0 \* \d+u\) \+ 0u\]\)/,
+ );
+ });
+
+ it("stops at the first passing candidate, as the CPU does", () => {
+ const wgsl = crashWgsl();
+
+ expect(wgsl).toMatch(/for \(var cand_0: u32 = 0u; cand_0 < counts\[\d+u\]/);
+ expect(wgsl).toMatch(/if \(fires\) \{ sel_0 = cand_0; break; \}/);
+ });
+
+ it("draws the acceptance uniform once, outside the candidate scan", () => {
+ // `Crash` is a predicate, so it never draws. A *stochastic* typed lambda does,
+ // and the CPU draws once per transition per frame and reuses it for every
+ // candidate — drawing inside the scan would give a place holding more tokens
+ // more chances to fire, so it would fire measurably sooner.
+ const net = crashNet();
+ const stochastic: SDCPN = {
+ ...net,
+ transitions: net.transitions.map((transition) => ({
+ ...transition,
+ lambdaType: "stochastic" as const,
+ lambdaCode:
+ "export default Lambda((tokens) => 1.0 / (1.0 + tokens.Space[0].velocity))",
+ })),
+ };
+
+ const compiled = compileFor(stochastic);
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ const { wgsl } = compiled.shader;
+ const drawIndex = wgsl.indexOf("let u = rng_next_f32(&rng_candidate);");
+ const scanIndex = wgsl.indexOf("for (var cand_0:");
+
+ expect(drawIndex).toBeGreaterThan(-1);
+ expect(scanIndex).toBeGreaterThan(drawIndex);
+ });
+
+ it("compacts stably from the chosen slot, not by swapping the last token in", () => {
+ // `monte-carlo/frame-operations.ts` shifts survivors down and preserves their
+ // order. A swap-remove would reorder the array, so later frames would
+ // enumerate candidates differently and consume different tokens.
+ const wgsl = crashWgsl();
+
+ expect(wgsl).toMatch(/for \(var m: u32 = sel_0 \+ 1u; m < counts\[\d+u\]/);
+ expect(wgsl).toMatch(/var write_slot: u32 = sel_0;/);
+ expect(wgsl).toMatch(/let dst = base \+ \d+u \+ write_slot \* \d+u;/);
+ // No swap-in-from-the-end anywhere.
+ expect(wgsl).not.toMatch(
+ /counts\[\d+u\] - 1u\) \* \d+u;\s*\n\s*for \(var w/,
+ );
+ });
+
+ it("declares the chosen slot even when the transition has no lambda", () => {
+ // Without a lambda the CPU takes combination 0, so the compaction still runs
+ // — and it references `sel_0`, which must therefore exist.
+ const net = crashNet();
+ const withoutLambda: SDCPN = {
+ ...net,
+ transitions: net.transitions.map((transition) => ({
+ ...transition,
+ lambdaCode: "",
+ })),
+ };
+
+ const compiled = compileFor(withoutLambda);
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ expect(compiled.shader.wgsl).toMatch(/var sel_0: u32 = 0u;/);
+ expect(compiled.shader.wgsl).toMatch(/var m: u32 = sel_0 \+ 1u/);
+ });
+});
+
+/**
+ * A weight-2 typed arc consumes a *pair*, and the CPU chooses it by walking
+ * `indexCombinations(n, 2)` and firing on the first passing one. The shader scans
+ * the same order by unranking a flat index — see `pair-selection.ts`.
+ */
+describe("weight-2 typed token consumption", () => {
+ const collisionWgsl = (): string => {
+ const net: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 16 })),
+ transitions: satellites.transitions.filter(
+ (transition) => transition.name === "Collision",
+ ),
+ };
+ const compiled = compileFor(net);
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ return compiled.shader.wgsl;
+ };
+
+ it("compiles a condition over both tokens of the pair", () => {
+ const wgsl = collisionWgsl();
+
+ // `const [a, b] = tokens.Space` binds a to the first leg and b to the second,
+ // so both candidate variables must appear in the distance computation.
+ expect(wgsl).toMatch(/cand_i \* \d+u/);
+ expect(wgsl).toMatch(/cand_j \* \d+u/);
+ });
+
+ it("scans pairs by unranking a flat index, in the engine's order", () => {
+ const wgsl = collisionWgsl();
+
+ expect(wgsl).toMatch(
+ /let pair_total = select\(0u, pair_n \* \(pair_n - 1u\)/,
+ );
+ expect(wgsl).toMatch(
+ /let cand_j = x - \(cand_i \* \(pair_a_u - cand_i\)\)/,
+ );
+ expect(wgsl).toMatch(/sel_0 = cand_i;/);
+ expect(wgsl).toMatch(/sel_1 = cand_j;/);
+ });
+
+ it("binds tokens[0] to the lower leg of the pair", () => {
+ // `const [a, b] = tokens.Space` must put `a` on cand_i. A symmetric condition
+ // like distance(a, b) would hide a swap, so this reads only index 0.
+ const net: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 16 })),
+ transitions: satellites.transitions
+ .filter((transition) => transition.name === "Collision")
+ .map((transition) => ({
+ ...transition,
+ lambdaType: "predicate" as const,
+ lambdaCode:
+ "export default Lambda((tokens) => tokens.Space[0].x < 1)",
+ })),
+ };
+ const compiled = compileFor(net);
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+
+ expect(compiled.shader.wgsl).toMatch(/cand_i \* \d+u\) \+ 0u\]/);
+ expect(compiled.shader.wgsl).not.toMatch(/cand_j \* \d+u\) \+ 0u\]/);
+ });
+
+ it("defaults to the pair (0, 1), which is combination zero", () => {
+ // With no condition to fail the CPU consumes combination 0. `sel_1` therefore
+ // cannot be left at its zero initialiser, which would pair a token with itself.
+ expect(collisionWgsl()).toMatch(/sel_1 = 1u;/);
+ });
+
+ it("compacts both consumed slots, skipping only the higher one", () => {
+ const wgsl = collisionWgsl();
+
+ expect(wgsl).toMatch(/if \(m == sel_1\) \{ continue; \}/);
+ // The sweep already starts past sel_0, so re-testing it would be dead code.
+ expect(wgsl).not.toMatch(/m == sel_0 \|\|/);
+ expect(wgsl).toMatch(/counts\[0u\] = counts\[0u\] - 2u;/);
+ });
+});
+
+/**
+ * A transition kernel writes the attributes of the tokens a firing produces. The
+ * ordering is the subtle part: a kernel reads the tokens the firing *consumes*,
+ * and compaction overwrites those slots.
+ */
+describe("transition kernels", () => {
+ const crashNet = (): SDCPN => ({
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 16 })),
+ transitions: satellites.transitions.filter(
+ (transition) => transition.name === "Crash",
+ ),
+ });
+
+ const crashWgsl = (): string => {
+ const compiled = compileFor(crashNet());
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ return compiled.shader.wgsl;
+ };
+
+ it("reads the consumed token before compaction overwrites its slot", () => {
+ // The emitter hoists only the subexpressions it names, so `x: tokens.Space[0].x`
+ // would otherwise stay inline in the write and execute after compaction had
+ // moved a survivor into that slot — silently the wrong token's attributes.
+ const wgsl = crashWgsl();
+ const hoistIndex = wgsl.indexOf("let kout_0: u32 =");
+ const compactIndex = wgsl.indexOf("var write_slot: u32 = sel_0;");
+ const writeIndex = wgsl.indexOf("state[out + 0u] = kout_0;");
+
+ expect(hoistIndex).toBeGreaterThan(-1);
+ expect(compactIndex).toBeGreaterThan(hoistIndex);
+ expect(writeIndex).toBeGreaterThan(compactIndex);
+ });
+
+ it("writes produced tokens above the live count, so nothing consumes them this frame", () => {
+ // Mirrors the CPU, which defers additions to after its transition loop while
+ // tracking the count in `pendingOutputCounts`.
+ expect(crashWgsl()).toMatch(
+ /let out = base \+ \d+u \+ \(counts\[\d+u\] \+ u32\(max\(0, pending\[\d+u\]\)\)/,
+ );
+ });
+
+ it("refuses a typed output whose kernel has no HIR rather than zeroing it", () => {
+ // Writing nothing would leave every attribute at zero and report that as a
+ // result, which is the failure mode this replaced.
+ const eligibility = assessGpuEligibility(crashNet());
+ if (!eligibility.eligible) {
+ throw new Error("fixture should be eligible");
+ }
+ const lowered = lowerNetHir(crashNet());
+ const compiled = compileNetShader({
+ sdcpn: crashNet(),
+ profile: eligibility.profile,
+ parameterValues: resolveNetParameterValues(
+ crashNet().parameters,
+ {},
+ true,
+ ),
+ lambdaHir: lowered.lambdas,
+ dynamicsHir: lowered.dynamics,
+ // Deliberately omitted.
+ kernelHir: new Map(),
+ dt: 0.1,
+ framesPerDispatch: 8,
+ metrics: [],
+ odeMethod: "rk4",
+ });
+
+ expect(compiled.ok).toBe(false);
+ if (compiled.ok) return;
+ expect(compiled.reason).toMatch(/carried no HIR/);
+ });
+});
+
+/**
+ * Same-scope `let`/`var` redeclarations, which is what naga reports and what a
+ * text assertion cannot see. Shadowing an outer scope is legal WGSL, so only the
+ * innermost scope is checked.
+ *
+ * Calibrated against a real validator: on the emitter as it stood before the
+ * per-stage identifier scope, this reported exactly the twelve findings naga did
+ * for RK4 (`u_0_mu`, `u_1_r`, `u_2_ax`, `u_3_ay`, three times over), and none of
+ * the `structurally_enabled` or `kout_N` repeats, which live in sibling blocks.
+ */
+function sameScopeRedeclarations(wgsl: string): string[] {
+ const found: string[] = [];
+ const stack: Set[] = [new Set()];
+ for (const [index, line] of wgsl.split("\n").entries()) {
+ const declaration = /(?:^|\s)(?:let|var)\s+(\w+)/u.exec(line);
+ if (declaration) {
+ const scope = stack.at(-1)!;
+ const name = declaration[1]!;
+ if (scope.has(name)) {
+ found.push(`line ${index + 1}: redeclaration of '${name}'`);
+ }
+ scope.add(name);
+ }
+ for (const character of line) {
+ if (character === "{") {
+ stack.push(new Set());
+ } else if (character === "}" && stack.length > 1) {
+ stack.pop();
+ }
+ }
+ }
+ return found;
+}
+
+describe("generated WGSL validity", () => {
+ const cappedSatellites = (): SDCPN => ({
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 16 })),
+ });
+
+ // Every RK stage re-emits the same derivative HIR, and each emitter counts its
+ // hoisted temporaries from zero. All of those statements land in one scope, so
+ // the stage name has to reach the identifiers — the shader is otherwise
+ // well-formed text that fails at `createShaderModule` with nothing upstream
+ // noticing. `euler` has one stage and so never collided.
+ it.each(["euler", "rk2", "rk4"] as const)(
+ "declares each hoisted temporary once per scope with %s",
+ (odeMethod) => {
+ const compiled = compileFor(cappedSatellites(), { odeMethod });
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+
+ expect(sameScopeRedeclarations(compiled.shader.wgsl)).toStrictEqual([]);
+ },
+ );
+
+ it("keeps every stage's derivatives distinct rather than merging them", () => {
+ // A scope prefix would also silence the redeclaration by making all four
+ // stages write one name, which would compile and integrate the wrong
+ // trajectory. Each stage must still contribute its own value.
+ const compiled = compileFor(cappedSatellites(), { odeMethod: "rk4" });
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+
+ for (const stage of ["k1", "k2", "k3", "k4"]) {
+ expect(compiled.shader.wgsl).toContain(`let ${stage}_u_0_mu: f32`);
+ }
+ });
+});
+
+/**
+ * The host reads a compact per-run summary instead of the run state. Run state is
+ * dominated by the token array, which the host never decodes, and copying it into
+ * a mappable buffer needs host-visible memory equal to the state — measured, that
+ * capped a 3112-byte-per-run net at ~689k runs on hardware reporting a 4 GiB
+ * `maxBufferSize`, and the failure surfaced three operations later as
+ * "[Invalid Buffer] is invalid due to a previous error" from `mapAsync`.
+ *
+ * These offsets are an ABI between the generated WGSL and the host decoder, and
+ * nothing else checks that the two agree.
+ */
+describe("run summary ABI", () => {
+ const summaryFor = (sdcpn: SDCPN) => {
+ const compiled = compileFor(sdcpn);
+ if (!compiled.ok) {
+ throw new Error(compiled.reason);
+ }
+ return compiled.shader;
+ };
+
+ it("writes one word per place plus the status", () => {
+ const shader = summaryFor(sir);
+
+ expect(shader.summaryWordsPerRun).toBe(
+ shader.placeCountOffsets.length + 1,
+ );
+ // Far smaller than the state it replaces, which is the entire point.
+ expect(shader.summaryWordsPerRun).toBeLessThan(shader.stateWordsPerRun);
+ });
+
+ it("writes each place count at the index the host reads it from", () => {
+ // The host indexes counts by place order, not by their offsets in run state.
+ const shader = summaryFor(sir);
+
+ for (let placeIndex = 0; placeIndex < 3; placeIndex++) {
+ expect(shader.wgsl).toContain(
+ `summary[summary_base + ${placeIndex}u] = counts[${placeIndex}u];`,
+ );
+ }
+ });
+
+ it("writes the status at the offset the type advertises", () => {
+ // `summaryStatusOffset` is what the host adds to a run's base. If the shader
+ // wrote it anywhere else the host would decode a place count as a status and
+ // silently report every run as still running.
+ const shader = summaryFor(sir);
+
+ expect(shader.wgsl).toContain(
+ `summary[summary_base + ${shader.summaryStatusOffset}u] = status;`,
+ );
+ expect(shader.summaryStatusOffset).toBe(shader.placeCountOffsets.length);
+ });
+
+ it("strides the summary by its own width, not the run state's", () => {
+ const shader = summaryFor(sir);
+
+ expect(shader.wgsl).toContain(
+ `let summary_base = run_index * ${shader.summaryWordsPerRun}u;`,
+ );
+ });
+
+ it("keeps the summary tiny on a typed net, where state is large", () => {
+ // The satellites net at capacity 16 is 552 bytes of state per run; its
+ // summary is 4 words. That ratio is what moves the run ceiling.
+ const capped: SDCPN = {
+ ...satellites,
+ places: satellites.places.map((place) => ({ ...place, capacity: 16 })),
+ };
+ const shader = summaryFor(capped);
+
+ expect(shader.summaryWordsPerRun * 20).toBeLessThan(
+ shader.stateWordsPerRun,
+ );
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts
new file mode 100644
index 00000000000..07da5e27265
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts
@@ -0,0 +1,1159 @@
+/**
+ * Compiles a whole net into one WGSL compute shader.
+ *
+ * The unit of work is an *experiment*, not a frame. One invocation owns one run
+ * and advances it through many frames inside the shader, so per-run state stays
+ * in registers and the host is not involved until a chunk finishes. This is the
+ * only shape that pays: `../../docs/simulation-performance.md` §8.3 measures a
+ * per-frame host round-trip at hundreds of microseconds against ~1 µs of
+ * per-frame work, so any design that reads back each frame is slower than doing
+ * nothing. That is also why this deliberately does **not** implement
+ * `MonteCarloSimulator`, whose synchronous per-frame `advanceAll()` would force
+ * exactly that round-trip.
+ *
+ * Metrics are reduced on the GPU into per-frame histograms, because shipping raw
+ * per-run samples back would be gigabytes for a large experiment
+ * (600 frames × 1M runs × 4 B ≈ 2.4 GB) while a histogram is under a megabyte.
+ */
+import { getArcEndpointPlaceId } from "../arc-endpoints";
+import {
+ buildKernelContext,
+ buildLambdaContext,
+} from "../hir/surface-context";
+import { computeTransitionCapacityConstraints } from "../simulation/engine/capacity";
+import { WgslBailError, WgslEmitter, emitF32Literal } from "./emit-wgsl";
+import { emitPairScanWgsl } from "./pair-selection";
+import { wgslPrelude } from "./wgsl-prelude";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirExpr, HirFunction } from "../hir/hir";
+import type { SDCPN } from "../types/sdcpn";
+import type { GpuNetProfile } from "./eligibility";
+import type { WgslValue } from "./emit-wgsl";
+
+/** Invocations per workgroup. 256 is the guaranteed WebGPU maximum. */
+export const GPU_WORKGROUP_SIZE = 256;
+
+/** Histogram bins per metric per frame. */
+export const GPU_HISTOGRAM_BINS = 256;
+
+export type GpuOdeMethod = "euler" | "rk2" | "rk4";
+
+export type GpuMetricSpec = {
+ id: string;
+ /** Place whose token count is sampled. */
+ placeId: string;
+};
+
+export type CompileNetShaderInput = {
+ sdcpn: SDCPN;
+ profile: GpuNetProfile;
+ /** Resolved net parameter values, inlined into the shader as literals. */
+ parameterValues: Readonly>;
+ /** Lowered HIR per transition id, when the transition has a lambda. */
+ lambdaHir: ReadonlyMap;
+ /** Lowered HIR per place id, for places with dynamics. */
+ dynamicsHir: ReadonlyMap;
+ /**
+ * Lowered HIR per transition id, for transitions with a compiled kernel.
+ *
+ * Omitted leaves output tokens unwritten, which is only correct for a net whose
+ * output places are all uncoloured — a typed place would receive tokens with
+ * every attribute at zero.
+ */
+ kernelHir?: ReadonlyMap;
+ dt: number;
+ /** Frames advanced per dispatch. Bounded to keep the GPU watchdog happy. */
+ framesPerDispatch: number;
+ metrics: readonly GpuMetricSpec[];
+ odeMethod: GpuOdeMethod;
+ /**
+ * Extension settings, so input slot names come from `buildLambdaContext` —
+ * the same source the HIR was type-checked against. Re-deriving them here
+ * would risk drifting from its component-port scoping and its
+ * last-arc-with-a-name-wins rule.
+ */
+ extensions?: PetrinautExtensionSettings;
+};
+
+export type CompiledNetShader = {
+ wgsl: string;
+ /** u32 words of state per run. */
+ stateWordsPerRun: number;
+ /**
+ * u32 words of *result* per run — the place counts and the status.
+ *
+ * The host reads this back instead of the run state. Run state is dominated by
+ * the token array, which the host never decodes, and copying it into a mappable
+ * buffer needs host-visible memory equal to the state itself: measured, that
+ * capped a 3112-byte-per-run net at ~689k runs on hardware whose
+ * `maxBufferSize` reports 4 GiB. A few words per run moves that ceiling out by
+ * more than two orders of magnitude.
+ */
+ summaryWordsPerRun: number;
+ /** Word offset of each place's token count within a run's state. */
+ placeCountOffsets: number[];
+ /** Word offset of the status within a run's *summary*. */
+ summaryStatusOffset: number;
+ /**
+ * Word offset of the run's RNG state and status.
+ *
+ * Exposed rather than derived by the host: the layout is
+ * `counts | elapsed | firings | rng | status | tokens`, so counting back from
+ * `stateWordsPerRun` only finds them when a net has no token attributes at all.
+ * A typed net seeded a token attribute and read its status out of the token
+ * array, which left every run sharing one RNG stream.
+ */
+ rngOffset: number;
+ statusOffset: number;
+ /** Metric ids in histogram order. */
+ metricIds: string[];
+ /** Which transitions got a compiled lambda; the rest are always-enabled. */
+ compiledLambdas: string[];
+};
+
+export type CompileNetShaderResult =
+ | { ok: true; shader: CompiledNetShader }
+ | { ok: false; reason: string };
+
+/**
+ * Per-token attribute accessor: field name to a WGSL value reading that field of
+ * one candidate token.
+ */
+type TokenReader = (fieldName: string) => WgslValue;
+
+/**
+ * Builds a reader for one token slot of a place.
+ *
+ * A token's words are its `real` attributes as f32, then its `integer`/`boolean`
+ * attributes as u32, matching `eligibility.ts`'s `wordsPerToken`. `slotExpr` is
+ * WGSL for the token's first word, so the caller decides which token — a loop
+ * variable, or one leg of a pair scan.
+ */
+function makeTokenReader(
+ place: GpuNetProfile["places"][number],
+ discreteTypeByName: ReadonlyMap,
+ slotExpr: string,
+): TokenReader {
+ return (fieldName) => {
+ const realOrdinal = place.realFields.indexOf(fieldName);
+ if (realOrdinal !== -1) {
+ return {
+ kind: "f32",
+ code: `bitcast(state[${slotExpr} + ${realOrdinal}u])`,
+ };
+ }
+
+ const discreteOrdinal = place.discreteFields.indexOf(fieldName);
+ if (discreteOrdinal === -1) {
+ throw new WgslBailError(
+ `place \`${place.name}\` has no attribute \`${fieldName}\``,
+ );
+ }
+ const word = `state[${slotExpr} + ${place.realFields.length + discreteOrdinal}u]`;
+ // Booleans arrive as a WGSL `bool` rather than a 0/1 float, so a condition
+ // reading one composes without an explicit comparison — the HIR's type
+ // checker has already established which it is.
+ return discreteTypeByName.get(fieldName) === "boolean"
+ ? { kind: "bool", code: `(${word} != 0u)` }
+ : { kind: "f32", code: `f32(${word})` };
+ };
+}
+
+/**
+ * The name a lambda's `tokens` record uses for one input arc's slot.
+ *
+ * Taken from `buildLambdaContext` rather than derived from the place name, so
+ * component-port scoping and the engine's last-arc-with-a-name-wins rule are
+ * whatever the HIR was type-checked against.
+ */
+function lambdaSlotName(
+ transition: SDCPN["transitions"][number],
+ arc: SDCPN["transitions"][number]["inputArcs"][number],
+ sdcpn: SDCPN,
+ extensions: PetrinautExtensionSettings | undefined,
+): string {
+ const context = buildLambdaContext(sdcpn, transition, extensions);
+ // Only one typed input arc is supported, and `inputSlots` holds exactly the
+ // typed non-inhibitor arcs, so that arc is the sole slot. Deriving an index
+ // from the arc's position among *all* input arcs would be shifted by any
+ // uncoloured arc declared before it.
+ void arc;
+ const slot = context.inputSlots[0];
+ if (slot === undefined) {
+ throw new WgslBailError(
+ `transition \`${transition.name}\` has no lambda input slot for its typed arc`,
+ );
+ }
+ return slot.name;
+}
+
+/** One output token's attribute writes, as WGSL words relative to its slot. */
+type KernelTokenWrite = { wordOffset: number; valueExpr: string };
+
+/** Where one output arc's tokens go, and what to write into them. */
+type KernelOutputWrite = {
+ placeIndex: number;
+ tokens: KernelTokenWrite[][];
+};
+
+/**
+ * Reads a transition kernel as the words it writes for each produced token.
+ *
+ * A kernel body is a record keyed by output slot name, holding one array of
+ * `arc.weight` token records each — a plain `recordLit` of `arrayLit` of
+ * `recordLit`, with no kernel-specific HIR node. The emitter already turns those
+ * into `record`/`array` values, so this walks the emitted structure the same way
+ * `hir/emit-buffer-js.ts` does: look each slot up by name, and bail rather than
+ * guess if it is missing or the wrong length.
+ *
+ * The values are returned as expressions rather than written directly, because the
+ * caller must evaluate them *before* compacting the input place — a kernel reads
+ * the very tokens the firing consumes.
+ */
+function emitKernel(
+ fn: HirFunction,
+ parameterValues: Readonly>,
+ tokenSlots: ReadonlyMap,
+ outputs: readonly {
+ slotName: string;
+ placeIndex: number;
+ tokenCount: number;
+ place: GpuNetProfile["places"][number];
+ discreteTypes: ReadonlyMap;
+ }[],
+): { statements: string[]; writes: KernelOutputWrite[] } {
+ const emitter = new WgslEmitter({
+ parameterValues,
+ rngStateVar: "rng_state",
+ });
+ const env = new Map();
+ const tokensParam = fn.params[0];
+ if (tokensParam) {
+ env.set(tokensParam.name, {
+ kind: "record",
+ fields: new Map(
+ [...tokenSlots].map(([slotName, readers]) => [
+ slotName,
+ {
+ kind: "array" as const,
+ elements: readers.map(
+ (read): WgslValue => ({ kind: "token", read }),
+ ),
+ },
+ ]),
+ ),
+ });
+ }
+
+ const result = emitter.emit(fn.body, env);
+ if (result.kind !== "record") {
+ throw new WgslBailError(
+ "a transition kernel must return a record of output places to token arrays",
+ );
+ }
+
+ const writes: KernelOutputWrite[] = [];
+ for (const output of outputs) {
+ const entry = result.fields.get(output.slotName);
+ if (entry === undefined || entry.kind !== "array") {
+ throw new WgslBailError(
+ `the kernel returns no token array for output place \`${output.slotName}\``,
+ );
+ }
+ if (entry.elements.length !== output.tokenCount) {
+ throw new WgslBailError(
+ `the kernel returns ${entry.elements.length} token(s) for \`${output.slotName}\`, but its arc weight is ${output.tokenCount}`,
+ );
+ }
+
+ const tokens = entry.elements.map((element) => {
+ if (element.kind !== "record") {
+ throw new WgslBailError(
+ `the kernel's tokens for \`${output.slotName}\` must be records of attributes`,
+ );
+ }
+ const tokenWrites: KernelTokenWrite[] = [];
+ // Reals first, then discretes, matching `eligibility.ts`'s `wordsPerToken`
+ // and the reader above.
+ for (const [ordinal, field] of output.place.realFields.entries()) {
+ const value = element.fields.get(field);
+ if (value === undefined) {
+ throw new WgslBailError(
+ `the kernel does not set \`${field}\` on a token for \`${output.slotName}\``,
+ );
+ }
+ tokenWrites.push({
+ wordOffset: ordinal,
+ valueExpr: `bitcast(${emitter.f32(value)})`,
+ });
+ }
+ for (const [ordinal, field] of output.place.discreteFields.entries()) {
+ const value = element.fields.get(field);
+ if (value === undefined) {
+ throw new WgslBailError(
+ `the kernel does not set \`${field}\` on a token for \`${output.slotName}\``,
+ );
+ }
+ tokenWrites.push({
+ wordOffset: output.place.realFields.length + ordinal,
+ valueExpr:
+ output.discreteTypes.get(field) === "boolean"
+ ? `select(0u, 1u, ${emitter.bool(value)})`
+ : `u32(${emitter.f32(value)})`,
+ });
+ }
+ return tokenWrites;
+ });
+
+ writes.push({ placeIndex: output.placeIndex, tokens });
+ }
+
+ return { statements: [...emitter.statements], writes };
+}
+
+/**
+ * Reads a transition's lambda as a WGSL boolean-or-rate expression.
+ *
+ * Lambda HIR takes `(tokens, parameters)`. `tokenSlots` binds `tokens`: one entry
+ * per input slot, holding one reader per token the arc consumes. An empty map
+ * leaves every slot an empty tuple, which is right for an uncoloured net and
+ * makes any lambda that reads attributes bail.
+ */
+function emitLambda(
+ fn: HirFunction,
+ parameterValues: Readonly>,
+ tokenSlots: ReadonlyMap = new Map(),
+): { statements: string[]; expression: string; isPredicate: boolean } {
+ const emitter = new WgslEmitter({
+ parameterValues,
+ randomCall: "rng_next_f32(&rng_state)",
+ });
+ const env = new Map();
+ const tokensParam = fn.params[0];
+ if (tokensParam) {
+ // A slot with no readers stays an empty tuple, which is what an uncoloured
+ // place has: no attributes, so nothing to index into.
+ env.set(tokensParam.name, {
+ kind: "record",
+ fields: new Map(
+ [...tokenSlots].map(([slotName, readers]) => [
+ slotName,
+ {
+ kind: "array" as const,
+ elements: readers.map(
+ (read): WgslValue => ({ kind: "token", read }),
+ ),
+ },
+ ]),
+ ),
+ });
+ }
+
+ const value = emitter.emit(fn.body, env);
+ if (value.kind === "bool") {
+ return {
+ statements: [...emitter.statements],
+ expression: value.code,
+ isPredicate: true,
+ };
+ }
+ return {
+ statements: [...emitter.statements],
+ expression: emitter.f32(value),
+ isPredicate: false,
+ };
+}
+
+/**
+ * Extracts a place's per-token derivative expressions from dynamics HIR.
+ *
+ * Dynamics HIR is `tokens.map(token => ({ field: expr }))`. Because a token's
+ * derivative reads only that token's own attributes, integration is entirely
+ * local to one invocation — which is what lets RK4 run without extra dispatches.
+ */
+/**
+ * Strips line terminators from a user-authored name spliced into a WGSL line
+ * comment.
+ *
+ * A line comment runs to the next line break, so a name containing one ends the
+ * comment early and drops the rest of the name into the shader as code. Every
+ * other channel from user data goes through `mangleWgslIdentifier`, which strips
+ * anything outside `[A-Za-z0-9_]`; comments are the one place raw text reaches
+ * the shader. Names are unconstrained strings in a loaded document even though
+ * the editor's own inputs are single-line.
+ */
+function commentSafe(name: string): string {
+ return name.replaceAll(/[\r\n\u2028\u2029]/gu, " ");
+}
+
+function emitDynamics(
+ fn: HirFunction,
+ realFields: readonly string[],
+ parameterValues: Readonly>,
+ fieldExpression: (fieldName: string) => string,
+ /**
+ * Distinguishes this stage's hoisted temporaries from the other stages'. Every
+ * stage's statements are spliced into the same WGSL scope, so without it each
+ * stage would redeclare the previous stage's names.
+ */
+ identifierScope: string,
+): { statements: string[]; derivatives: Map } {
+ let body: HirExpr = fn.body;
+ const outerBindings = body.kind === "let" ? body.bindings : [];
+ if (body.kind === "let") {
+ body = body.body;
+ }
+
+ const tokensParam = fn.params[0];
+ if (
+ !tokensParam ||
+ body.kind !== "arrayMap" ||
+ body.target.kind !== "localRef" ||
+ body.target.name !== tokensParam.name
+ ) {
+ throw new WgslBailError(
+ "dynamics must be a direct `tokens.map(...)` over the place's tokens",
+ );
+ }
+
+ const emitter = new WgslEmitter({ parameterValues, identifierScope });
+ const env = new Map();
+ for (const binding of outerBindings) {
+ env.set(
+ binding.name,
+ emitter.hoist(binding.name, emitter.emit(binding.value, env)),
+ );
+ }
+
+ // The token binding resolves attribute reads to whatever accessor the caller
+ // supplies, so the same HIR serves each RK stage at a different trial state.
+ env.set(body.param.name, {
+ kind: "token",
+ read: (fieldName) => ({ kind: "f32", code: fieldExpression(fieldName) }),
+ });
+
+ let mapBody: HirExpr = body.body;
+ if (mapBody.kind === "let") {
+ for (const binding of mapBody.bindings) {
+ env.set(
+ binding.name,
+ emitter.hoist(binding.name, emitter.emit(binding.value, env)),
+ );
+ }
+ mapBody = mapBody.body;
+ }
+ if (mapBody.kind !== "recordLit") {
+ throw new WgslBailError("dynamics must return a record of derivatives");
+ }
+
+ const derivatives = new Map();
+ for (const field of realFields) {
+ const entry = mapBody.entries.find((candidate) => candidate.key === field);
+ // A field with no entry has zero derivative, matching the CPU emitter.
+ derivatives.set(
+ field,
+ entry ? emitter.f32(emitter.emit(entry.value, env)) : "0.0",
+ );
+ }
+
+ return { statements: [...emitter.statements], derivatives };
+}
+
+/**
+ * Generates the shader, or explains why it cannot be generated.
+ */
+export function compileNetShader(
+ input: CompileNetShaderInput,
+): CompileNetShaderResult {
+ const {
+ sdcpn,
+ profile,
+ parameterValues,
+ lambdaHir,
+ dynamicsHir,
+ dt,
+ framesPerDispatch,
+ metrics,
+ odeMethod,
+ extensions,
+ kernelHir = new Map(),
+ } = input;
+
+ try {
+ // Attribute types for the discrete (non-`real`) fields, so a lambda reading a
+ // boolean gets a WGSL `bool` rather than a 0/1 float. `eligibility.ts` has
+ // already refused anything wider than 32 bits.
+ const colorById = new Map(sdcpn.types.map((type) => [type.id, type]));
+ const discreteTypesByPlaceId = new Map<
+ string,
+ Map
+ >();
+ for (const place of sdcpn.places) {
+ const color =
+ place.colorId === null ? undefined : colorById.get(place.colorId);
+ const types = new Map();
+ for (const element of color?.elements ?? []) {
+ if (element.type === "integer" || element.type === "boolean") {
+ types.set(element.name, element.type);
+ }
+ }
+ discreteTypesByPlaceId.set(place.id, types);
+ }
+
+ const placeIndexById = new Map(
+ profile.places.map((place, index) => [place.id, index]),
+ );
+
+ // --- State layout -------------------------------------------------------
+ // counts | elapsed frames | firing counts | rng | status | token values
+ const placeCount = profile.places.length;
+ const transitionCount = sdcpn.transitions.length;
+ const countsOffset = 0;
+ const elapsedOffset = countsOffset + placeCount;
+ const firingsOffset = elapsedOffset + transitionCount;
+ const rngOffset = firingsOffset + transitionCount;
+ const statusOffset = rngOffset + 1;
+ const tokensOffset = statusOffset + 1;
+
+ let tokenWords = 0;
+ const placeTokenOffsets: number[] = [];
+ const placeTokenStride: number[] = [];
+ for (const place of profile.places) {
+ placeTokenOffsets.push(tokensOffset + tokenWords);
+ const stride = place.realFields.length + place.discreteFields.length;
+ placeTokenStride.push(stride);
+ tokenWords += place.capacity * stride;
+ }
+ const stateWordsPerRun = tokensOffset + tokenWords;
+ // One word per place count, plus the status. Deliberately not the whole run
+ // header: `elapsed`, `firings` and the RNG word are device-side bookkeeping
+ // the host never decodes.
+ const summaryWordsPerRun = placeCount + 1;
+
+ const lines: string[] = [];
+ const push = (line: string) => lines.push(line);
+
+ push(`// Generated by compile-net-shader.ts — do not edit.`);
+ push(
+ `// One invocation per run; ${framesPerDispatch} frames per dispatch.`,
+ );
+ push(`const STATE_WORDS: u32 = ${stateWordsPerRun}u;`);
+ push(`const HIST_BINS: u32 = ${GPU_HISTOGRAM_BINS}u;`);
+ push(`const DT: f32 = ${emitF32Literal(dt)};`);
+ push("");
+ push(`struct Config {`);
+ push(` run_count: u32,`);
+ push(` base_frame: u32,`);
+ push(` frame_limit: u32,`);
+ push(` seed: u32,`);
+ push(`};`);
+ push(`@group(0) @binding(0) var state: array;`);
+ push(
+ `@group(0) @binding(1) var hist: array>;`,
+ );
+ push(`@group(0) @binding(2) var config: Config;`);
+ // Compact per-run results, gathered on the device so the host never reads the
+ // token array back. Of a run's state the host only needs its place counts and
+ // its status — a handful of words against hundreds — and the mappable buffer
+ // a readback needs is the scarcest memory in the system.
+ push(
+ `@group(0) @binding(3) var summary: array;`,
+ );
+ push("");
+ push(wgslPrelude());
+ push("");
+
+ // Per-frame histograms are built in workgroup memory and flushed once per
+ // frame. Measured at 2x the throughput of hitting global atomics directly,
+ // because runs in a workgroup collide on the same bin constantly.
+ if (metrics.length > 0) {
+ push(
+ `var local_hist: array, ${GPU_HISTOGRAM_BINS * metrics.length}>;`,
+ );
+ push("");
+ }
+
+ push(`@compute @workgroup_size(${GPU_WORKGROUP_SIZE})`);
+ push(`fn step_runs(@builtin(global_invocation_id) gid: vec3,`);
+ push(` @builtin(local_invocation_index) lid: u32) {`);
+ push(` let run_index = gid.x;`);
+ push(` let in_range = run_index < config.run_count;`);
+ push(` let base = run_index * STATE_WORDS;`);
+ push("");
+
+ // Load state into registers. Out-of-range invocations still execute so they
+ // reach the workgroup barriers the histogram flush needs.
+ push(` var counts: array;`);
+ push(` var elapsed: array;`);
+ push(` var firings: array;`);
+ push(` var rng_state: u32 = 0u;`);
+ push(` var status: u32 = 0u;`);
+ push(` if (in_range) {`);
+ for (let index = 0; index < placeCount; index++) {
+ push(` counts[${index}u] = state[base + ${countsOffset + index}u];`);
+ }
+ for (let index = 0; index < transitionCount; index++) {
+ push(` elapsed[${index}u] = state[base + ${elapsedOffset + index}u];`);
+ push(` firings[${index}u] = state[base + ${firingsOffset + index}u];`);
+ }
+ push(` rng_state = state[base + ${rngOffset}u];`);
+ push(` status = state[base + ${statusOffset}u];`);
+ push(` }`);
+ push("");
+
+ push(
+ ` for (var frame: u32 = 0u; frame < ${framesPerDispatch}u; frame = frame + 1u) {`,
+ );
+ push(` let absolute_frame = config.base_frame + frame;`);
+ push(
+ ` let running = in_range && status == 0u && absolute_frame < config.frame_limit;`,
+ );
+ push("");
+
+ // --- Continuous dynamics ------------------------------------------------
+ const dynamicsPlaces = profile.places
+ .map((place, index) => ({ place, index }))
+ .filter(
+ ({ place }) => dynamicsHir.has(place.id) && place.realFields.length > 0,
+ );
+
+ for (const { place, index } of dynamicsPlaces) {
+ const stride = placeTokenStride[index]!;
+ const tokenBase = placeTokenOffsets[index]!;
+ const fieldIndex = (field: string) => place.realFields.indexOf(field);
+
+ push(` // dynamics: ${commentSafe(place.name)} (${odeMethod})`);
+ push(` if (running) {`);
+ push(` for (var t: u32 = 0u; t < counts[${index}u]; t = t + 1u) {`);
+ push(` let slot = base + ${tokenBase}u + t * ${stride}u;`);
+ for (const [ordinal] of place.realFields.entries()) {
+ push(
+ ` let y${ordinal} = bitcast(state[slot + ${ordinal}u]);`,
+ );
+ }
+
+ // Each RK stage re-emits the same derivative HIR against a trial state,
+ // which is sound because a token's derivative depends only on that token.
+ const stageNames =
+ odeMethod === "euler"
+ ? ["k1"]
+ : odeMethod === "rk2"
+ ? ["k1", "k2"]
+ : ["k1", "k2", "k3", "k4"];
+ const trialFor = (stage: number, ordinal: number): string => {
+ if (stage === 0) return `y${ordinal}`;
+ if (odeMethod === "rk2")
+ return `(y${ordinal} + 0.5 * DT * k1_${ordinal})`;
+ if (stage === 1) return `(y${ordinal} + 0.5 * DT * k1_${ordinal})`;
+ if (stage === 2) return `(y${ordinal} + 0.5 * DT * k2_${ordinal})`;
+ return `(y${ordinal} + DT * k3_${ordinal})`;
+ };
+
+ for (const [stage, stageName] of stageNames.entries()) {
+ const { statements, derivatives } = emitDynamics(
+ dynamicsHir.get(place.id)!,
+ place.realFields,
+ parameterValues,
+ (fieldName) => {
+ const ordinal = fieldIndex(fieldName);
+ if (ordinal < 0) {
+ throw new WgslBailError(
+ `dynamics for \`${place.name}\` reads \`${fieldName}\`, which is not a real attribute`,
+ );
+ }
+ return trialFor(stage, ordinal);
+ },
+ `${stageName}_`,
+ );
+ for (const statement of statements) {
+ push(` ${statement}`);
+ }
+ for (const [ordinal, field] of place.realFields.entries()) {
+ push(
+ ` let ${stageName}_${ordinal}: f32 = ${derivatives.get(field) ?? "0.0"};`,
+ );
+ }
+ }
+
+ for (const [ordinal] of place.realFields.entries()) {
+ const combined =
+ odeMethod === "euler"
+ ? `y${ordinal} + DT * k1_${ordinal}`
+ : odeMethod === "rk2"
+ ? `y${ordinal} + DT * k2_${ordinal}`
+ : `y${ordinal} + (DT / 6.0) * (k1_${ordinal} + 2.0 * k2_${ordinal} + 2.0 * k3_${ordinal} + k4_${ordinal})`;
+ push(` state[slot + ${ordinal}u] = bitcast(${combined});`);
+ }
+ push(` }`);
+ push(` }`);
+ push("");
+ }
+
+ // --- Discrete transitions ----------------------------------------------
+ // Removals apply immediately so later transitions see them, matching the CPU
+ // engine; additions are held until the end of the frame, so capacity checks
+ // fold in what earlier transitions already produced this frame.
+ push(` var pending: array;`);
+ for (let index = 0; index < placeCount; index++) {
+ push(` pending[${index}u] = 0;`);
+ }
+ push(` var any_fired = false;`);
+ push(` var any_enabled = false;`);
+ push("");
+
+ const compiledLambdas: string[] = [];
+
+ for (const [transitionIndex, transition] of sdcpn.transitions.entries()) {
+ const inputs = transition.inputArcs
+ .map((arc) => ({ arc, placeId: getArcEndpointPlaceId(arc) }))
+ .filter(
+ (entry): entry is { arc: typeof entry.arc; placeId: string } =>
+ entry.placeId !== null,
+ );
+ const outputs = transition.outputArcs
+ .map((arc) => ({ arc, placeId: getArcEndpointPlaceId(arc) }))
+ .filter(
+ (entry): entry is { arc: typeof entry.arc; placeId: string } =>
+ entry.placeId !== null,
+ );
+
+ const capacityConstraints = computeTransitionCapacityConstraints({
+ transition,
+ placeIndexById,
+ placeCapacities: Uint32Array.from(
+ profile.places.map((place) =>
+ place.colored ? place.capacity : 0xffffffff,
+ ),
+ ),
+ });
+
+ const guards: string[] = [];
+ for (const { arc, placeId } of inputs) {
+ const index = placeIndexById.get(placeId);
+ if (index === undefined) {
+ throw new WgslBailError(
+ `transition references unknown place ${placeId}`,
+ );
+ }
+ guards.push(
+ arc.type === "inhibitor"
+ ? `counts[${index}u] < ${arc.weight}u`
+ : `counts[${index}u] >= ${arc.weight}u`,
+ );
+ }
+ for (const constraint of capacityConstraints) {
+ // `pending` is signed so a place that both gained and lost tokens this
+ // frame nets out correctly before the comparison.
+ guards.push(
+ `(i32(counts[${constraint.placeIndex}u]) + pending[${constraint.placeIndex}u] + ${constraint.delta}) <= ${constraint.capacity}`,
+ );
+ }
+
+ const enabled = guards.length > 0 ? guards.join(" && ") : "true";
+ push(` // transition: ${commentSafe(transition.name)}`);
+ push(` {`);
+ push(` let structurally_enabled = running && (${enabled});`);
+ push(` any_enabled = any_enabled || structurally_enabled;`);
+
+ // Typed standard inputs need a *choice* of which tokens to consume, and the
+ // CPU makes it by walking `indexCombinations` and firing on the first
+ // passing combination. Exactly one such arc is supported: more than one
+ // means a Cartesian product across arcs, which is a nested scan.
+ const typedInputs = inputs.filter(
+ ({ arc, placeId }) =>
+ arc.type === "standard" &&
+ (profile.places[placeIndexById.get(placeId)!]?.colored ?? false),
+ );
+ if (typedInputs.length > 1) {
+ throw new WgslBailError(
+ `transition \`${transition.name}\` consumes typed tokens from ${typedInputs.length} places; only one is supported`,
+ );
+ }
+ const typedInput = typedInputs[0];
+ if (typedInput !== undefined && typedInput.arc.weight > 2) {
+ throw new WgslBailError(
+ `transition \`${transition.name}\` consumes ${typedInput.arc.weight} tokens from \`${typedInput.placeId}\`; at most two per place are supported`,
+ );
+ }
+ const typedWeight = typedInput?.arc.weight ?? 0;
+
+ const tokenSlots = new Map();
+ const selectionTokenSlots = new Map();
+ let scanPlaceIndex: number | null = null;
+ if (typedInput !== undefined) {
+ // A local const, so the reader closures below capture a `number` rather
+ // than the wider `number | null` of the outer binding.
+ const placeIndex = placeIndexById.get(typedInput.placeId)!;
+ scanPlaceIndex = placeIndex;
+ const place = profile.places[placeIndex]!;
+ const slotName = lambdaSlotName(
+ transition,
+ typedInput.arc,
+ sdcpn,
+ extensions,
+ );
+ const discreteTypes = discreteTypesByPlaceId.get(place.id) ?? new Map();
+ const slotExprFor = (candidateVar: string) =>
+ `(base + ${placeTokenOffsets[placeIndex]!}u + ${candidateVar} * ${placeTokenStride[placeIndex]!}u)`;
+ // One reader per token the arc consumes, in the order the lambda
+ // destructures them: `const [a, b] = tokens.Space`.
+ const candidateVars =
+ typedWeight === 2 ? ["cand_i", "cand_j"] : ["cand_0"];
+ tokenSlots.set(
+ slotName,
+ candidateVars.map((candidateVar) =>
+ makeTokenReader(place, discreteTypes, slotExprFor(candidateVar)),
+ ),
+ );
+ // The same readers against the *chosen* slots, for the kernel: it runs in
+ // the fire block, after the scan has settled on `sel_*`.
+ const selectionVarsForSlot =
+ typedWeight === 2 ? ["sel_0", "sel_1"] : ["sel_0"];
+ selectionTokenSlots.set(
+ slotName,
+ selectionVarsForSlot.map((selectionVar) =>
+ makeTokenReader(place, discreteTypes, slotExprFor(selectionVar)),
+ ),
+ );
+ }
+
+ // Declared outside the lambda branch: a typed-input transition with no
+ // lambda still consumes tokens, and the CPU takes combination 0 in that
+ // case, so the compaction below needs these either way.
+ const selectionVars =
+ typedWeight === 2
+ ? ["sel_0", "sel_1"]
+ : typedWeight === 1
+ ? ["sel_0"]
+ : [];
+ for (const selectionVar of selectionVars) {
+ push(` var ${selectionVar}: u32 = 0u;`);
+ }
+ if (typedWeight === 2) {
+ // Combination 0 of `indexCombinations(n, 2)` is the pair (0, 1), which is
+ // what the CPU consumes when there is no condition to fail.
+ push(` sel_1 = 1u;`);
+ }
+
+ const lambda = lambdaHir.get(transition.id);
+ let fireCondition: string;
+ if (lambda) {
+ const emitted = emitLambda(lambda, parameterValues, tokenSlots);
+ compiledLambdas.push(transition.id);
+ push(` var fires = false;`);
+ push(` if (structurally_enabled) {`);
+
+ // The CPU draws its acceptance uniform once per transition per frame,
+ // before walking combinations, and reuses it for every one
+ // (`monte-carlo/transition-effect.ts`). Drawing inside the scan would give
+ // a place holding more tokens more chances to clear the threshold, so it
+ // would fire measurably sooner. It also only commits the generator state
+ // when the transition fires, which makes the wait an exponential rather
+ // than a per-frame Bernoulli trial — hence the candidate state.
+ const isStochastic = !emitted.isPredicate;
+ if (isStochastic) {
+ push(` var rng_candidate = rng_state;`);
+ push(` let u = rng_next_f32(&rng_candidate);`);
+ }
+ const acceptance = isStochastic
+ ? `accepts_firing(${emitted.expression}, f32(elapsed[${transitionIndex}u]) * DT, u)`
+ : emitted.expression;
+
+ if (typedWeight === 2) {
+ // The readers bound above already name `cand_i` and `cand_j`, which is
+ // what the scan declares, so the statements need no rewriting.
+ for (const line of emitPairScanWgsl({
+ tokenCountExpr: `counts[${scanPlaceIndex!}u]`,
+ emitAccepts: () => ({
+ statements: emitted.statements,
+ expression: acceptance,
+ }),
+ firedVar: "fires",
+ firstVar: "sel_0",
+ secondVar: "sel_1",
+ indent: " ",
+ })) {
+ push(line);
+ }
+ } else if (typedWeight === 1) {
+ push(
+ ` for (var cand_0: u32 = 0u; cand_0 < counts[${scanPlaceIndex!}u]; cand_0 = cand_0 + 1u) {`,
+ );
+ for (const statement of emitted.statements) {
+ push(` ${statement}`);
+ }
+ push(` fires = ${acceptance};`);
+ push(` if (fires) { sel_0 = cand_0; break; }`);
+ push(` }`);
+ } else {
+ for (const statement of emitted.statements) {
+ push(` ${statement}`);
+ }
+ push(` fires = ${acceptance};`);
+ }
+
+ if (isStochastic) {
+ push(` if (fires) { rng_state = rng_candidate; }`);
+ }
+ push(` }`);
+ fireCondition = "fires";
+ } else {
+ // No lambda compiled: always enabled once structure permits, matching
+ // the CPU engine's default.
+ fireCondition = "structurally_enabled";
+ }
+
+ push(` if (${fireCondition}) {`);
+
+ // A kernel reads the tokens this firing consumes, so its values are
+ // evaluated into `let`s *before* compaction destroys them, and written
+ // after. The CPU does the same by computing `effect.add` from the frame it
+ // then removes from (`monte-carlo/advance-run.ts`).
+ const kernel = kernelHir.get(transition.id);
+ const typedOutputs = outputs
+ .map(({ arc, placeId }) => {
+ const index = placeIndexById.get(placeId)!;
+ const place = profile.places[index]!;
+ return { arc, placeId, index, place };
+ })
+ .filter(({ place }) => place.colored);
+ let kernelWrites: KernelOutputWrite[] = [];
+ if (typedOutputs.length > 0) {
+ if (kernel === undefined) {
+ throw new WgslBailError(
+ `transition \`${transition.name}\` produces typed tokens but its kernel carried no HIR, so their attributes cannot be written`,
+ );
+ }
+ const kernelContext = buildKernelContext(sdcpn, transition, extensions);
+ const emittedKernel = emitKernel(
+ kernel,
+ parameterValues,
+ // The kernel runs in the fire block, where the chosen tokens are named
+ // `sel_*` rather than the scan's `cand_*`.
+ selectionTokenSlots,
+ typedOutputs.map(({ index, place }, ordinal) => ({
+ slotName:
+ kernelContext.outputSlots[ordinal]?.name ??
+ (() => {
+ throw new WgslBailError(
+ `transition \`${transition.name}\` has no kernel output slot for \`${place.name}\``,
+ );
+ })(),
+ placeIndex: index,
+ tokenCount: typedOutputs[ordinal]!.arc.weight,
+ place,
+ discreteTypes: discreteTypesByPlaceId.get(place.id) ?? new Map(),
+ })),
+ );
+ for (const statement of emittedKernel.statements) {
+ push(` ${statement}`);
+ }
+ // Every produced value is forced into a `let` here, before compaction.
+ // The emitter hoists only the subexpressions it names, so a direct read
+ // like `x: tokens.Space[0].x` would otherwise stay inline in the write
+ // below and execute *after* compaction had overwritten that slot with a
+ // survivor — reading the wrong token's attributes.
+ let hoistOrdinal = 0;
+ kernelWrites = emittedKernel.writes.map((write) => ({
+ placeIndex: write.placeIndex,
+ tokens: write.tokens.map((tokenWrites) =>
+ tokenWrites.map(({ wordOffset, valueExpr }) => {
+ const name = `kout_${hoistOrdinal}`;
+ hoistOrdinal += 1;
+ push(` let ${name}: u32 = ${valueExpr};`);
+ return { wordOffset, valueExpr: name };
+ }),
+ ),
+ }));
+ }
+
+ if (scanPlaceIndex !== null) {
+ const stride = placeTokenStride[scanPlaceIndex]!;
+ const tokenBase = placeTokenOffsets[scanPlaceIndex]!;
+ // Stable compaction, matching `monte-carlo/frame-operations.ts`: survivors
+ // keep their relative order and shift down into the gaps. A swap-remove
+ // would reorder the array, so later frames would enumerate candidates in a
+ // different order and consume different tokens — divergence, not noise.
+ // Nothing below the lowest removed slot moves, so the sweep starts there.
+ // The sweep starts past `sel_0`, so only the higher slots need skipping —
+ // comparing against `sel_0` again would be dead code in the shader.
+ const skipped = selectionVars
+ .slice(1)
+ .map((selectionVar) => `m == ${selectionVar}`)
+ .join(" || ");
+ push(
+ ` // consume ${typedWeight} token(s) from ${commentSafe(profile.places[scanPlaceIndex]!.name)}`,
+ );
+ push(` var write_slot: u32 = sel_0;`);
+ push(
+ ` for (var m: u32 = sel_0 + 1u; m < counts[${scanPlaceIndex}u]; m = m + 1u) {`,
+ );
+ if (skipped !== "") {
+ push(` if (${skipped}) { continue; }`);
+ }
+ push(` let src = base + ${tokenBase}u + m * ${stride}u;`);
+ push(
+ ` let dst = base + ${tokenBase}u + write_slot * ${stride}u;`,
+ );
+ push(` if (dst != src) {`);
+ push(` for (var w: u32 = 0u; w < ${stride}u; w = w + 1u) {`);
+ push(` state[dst + w] = state[src + w];`);
+ push(` }`);
+ push(` }`);
+ push(` write_slot = write_slot + 1u;`);
+ push(` }`);
+ }
+ for (const { arc, placeId } of inputs) {
+ if (arc.type !== "standard") {
+ continue;
+ }
+ const index = placeIndexById.get(placeId)!;
+ push(` counts[${index}u] = counts[${index}u] - ${arc.weight}u;`);
+ }
+ for (const { arc, placeId } of outputs) {
+ const index = placeIndexById.get(placeId)!;
+ // Produced tokens are written above the live count and revealed only when
+ // `pending` folds into `counts` at end of frame, so nothing later in this
+ // frame can consume them — matching the CPU, which defers additions to
+ // after its transition loop while tracking the count in
+ // `pendingOutputCounts`.
+ const write = kernelWrites.find((entry) => entry.placeIndex === index);
+ if (write !== undefined) {
+ const stride = placeTokenStride[index]!;
+ const tokenBase = placeTokenOffsets[index]!;
+ for (const [tokenOrdinal, tokenWrites] of write.tokens.entries()) {
+ push(
+ ` {`,
+ );
+ push(
+ ` let out = base + ${tokenBase}u + (counts[${index}u] + u32(max(0, pending[${index}u])) + ${tokenOrdinal}u) * ${stride}u;`,
+ );
+ for (const { wordOffset, valueExpr } of tokenWrites) {
+ push(` state[out + ${wordOffset}u] = ${valueExpr};`);
+ }
+ push(` }`);
+ }
+ }
+ push(
+ ` pending[${index}u] = pending[${index}u] + ${arc.weight};`,
+ );
+ }
+ push(` elapsed[${transitionIndex}u] = 0u;`);
+ push(
+ ` firings[${transitionIndex}u] = firings[${transitionIndex}u] + 1u;`,
+ );
+ push(` any_fired = true;`);
+ push(` } else if (running) {`);
+ push(
+ ` elapsed[${transitionIndex}u] = elapsed[${transitionIndex}u] + 1u;`,
+ );
+ push(` }`);
+ push(` }`);
+ }
+
+ push("");
+ push(` if (running) {`);
+ for (let index = 0; index < placeCount; index++) {
+ push(
+ ` counts[${index}u] = u32(max(0, i32(counts[${index}u]) + pending[${index}u]));`,
+ );
+ }
+ // A run with nothing enabled and nothing fired is deadlocked; one that
+ // reaches the frame limit is complete. Both stop consuming work.
+ push(` if (!any_fired && !any_enabled) { status = 1u; }`);
+ push(
+ ` if (absolute_frame + 1u >= config.frame_limit) { status = 2u; }`,
+ );
+ push(` }`);
+ push("");
+
+ // --- Metrics ------------------------------------------------------------
+ if (metrics.length > 0) {
+ push(` // per-frame histograms, reduced in workgroup memory`);
+ push(
+ ` for (var b: u32 = lid; b < ${GPU_HISTOGRAM_BINS * metrics.length}u; b = b + ${GPU_WORKGROUP_SIZE}u) {`,
+ );
+ push(` atomicStore(&local_hist[b], 0u);`);
+ push(` }`);
+ push(` workgroupBarrier();`);
+ for (const [metricIndex, metric] of metrics.entries()) {
+ const placeIndex = placeIndexById.get(metric.placeId);
+ if (placeIndex === undefined) {
+ throw new WgslBailError(
+ `metric \`${metric.id}\` references unknown place ${metric.placeId}`,
+ );
+ }
+ // Samples only active runs, matching the CPU metric default.
+ push(` if (running) {`);
+ push(
+ ` atomicAdd(&local_hist[${metricIndex * GPU_HISTOGRAM_BINS}u + min(counts[${placeIndex}u], HIST_BINS - 1u)], 1u);`,
+ );
+ push(` }`);
+ }
+ push(` workgroupBarrier();`);
+ push(
+ ` for (var b: u32 = lid; b < ${GPU_HISTOGRAM_BINS * metrics.length}u; b = b + ${GPU_WORKGROUP_SIZE}u) {`,
+ );
+ push(` let v = atomicLoad(&local_hist[b]);`);
+ push(` if (v > 0u) {`);
+ push(
+ ` atomicAdd(&hist[absolute_frame * ${GPU_HISTOGRAM_BINS * metrics.length}u + b], v);`,
+ );
+ push(` }`);
+ push(` }`);
+ push(` workgroupBarrier();`);
+ }
+
+ push(` }`);
+ push("");
+
+ // Store state back for the next chunk.
+ push(` if (in_range) {`);
+ for (let index = 0; index < placeCount; index++) {
+ push(` state[base + ${countsOffset + index}u] = counts[${index}u];`);
+ }
+ for (let index = 0; index < transitionCount; index++) {
+ push(` state[base + ${elapsedOffset + index}u] = elapsed[${index}u];`);
+ push(` state[base + ${firingsOffset + index}u] = firings[${index}u];`);
+ }
+ push(` state[base + ${rngOffset}u] = rng_state;`);
+ push(` state[base + ${statusOffset}u] = status;`);
+ push("");
+
+ // The compact result the host reads back, written from the same registers
+ // rather than gathered by a second pass: the values are already here, so a
+ // separate entry point would re-read them from memory for nothing. Written
+ // every dispatch and overwritten by the next, so after the final dispatch it
+ // holds the final state — which is all the host ever wanted.
+ push(` let summary_base = run_index * ${summaryWordsPerRun}u;`);
+ for (let index = 0; index < placeCount; index++) {
+ push(` summary[summary_base + ${index}u] = counts[${index}u];`);
+ }
+ push(` summary[summary_base + ${placeCount}u] = status;`);
+ push(` }`);
+ push(`}`);
+
+ return {
+ ok: true,
+ shader: {
+ wgsl: lines.join("\n"),
+ stateWordsPerRun,
+ summaryWordsPerRun,
+ placeCountOffsets: profile.places.map(
+ (_, index) => countsOffset + index,
+ ),
+ summaryStatusOffset: placeCount,
+ rngOffset,
+ statusOffset,
+ metricIds: metrics.map((metric) => metric.id),
+ compiledLambdas,
+ },
+ };
+ } catch (error) {
+ if (error instanceof WgslBailError) {
+ return { ok: false, reason: error.message };
+ }
+ throw error;
+ }
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/eligibility.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/eligibility.test.ts
new file mode 100644
index 00000000000..bd768c85c56
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/eligibility.test.ts
@@ -0,0 +1,239 @@
+import { describe, expect, it } from "vitest";
+
+import { sirModel } from "../examples/sir-model";
+import { supplyChainWithDisruption } from "../examples/supply-chain-with-disruption";
+import { assessGpuEligibility, formatGpuIneligibility } from "./eligibility";
+
+import type { Color, Place, SDCPN, Transition } from "../types/sdcpn";
+
+function place(id: string, overrides: Partial = {}): Place {
+ return {
+ id,
+ name: id,
+ colorId: null,
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ ...overrides,
+ };
+}
+
+function transition(
+ id: string,
+ inputArcs: Transition["inputArcs"] = [],
+ outputArcs: Transition["outputArcs"] = [],
+): Transition {
+ return {
+ id,
+ name: id,
+ inputArcs,
+ outputArcs,
+ lambdaType: "predicate",
+ lambdaCode: "export default Lambda(() => true);",
+ transitionKernelCode: "export default TransitionKernel(() => ({}));",
+ x: 0,
+ y: 0,
+ };
+}
+
+function color(id: string, elements: Color["elements"]): Color {
+ return {
+ id,
+ name: id,
+ iconSlug: "circle",
+ displayColor: "#00FF00",
+ elements,
+ };
+}
+
+function net(overrides: Partial): SDCPN {
+ return {
+ types: [],
+ places: [],
+ transitions: [],
+ differentialEquations: [],
+ parameters: [],
+ ...overrides,
+ };
+}
+
+describe("assessGpuEligibility", () => {
+ it("accepts an uncoloured net, the case the backend handles best", () => {
+ const result = assessGpuEligibility(sirModel.petriNetDefinition);
+
+ expect(result.eligible).toBe(true);
+ if (!result.eligible) return;
+ expect(result.profile.uncolouredOnly).toBe(true);
+ // Three counts, two transitions x (elapsed + firings), rng, status = 9 words.
+ expect(result.profile.bytesPerRun).toBe(36);
+ });
+
+ it("rejects a typed place with no capacity, since buffers must be fixed", () => {
+ const result = assessGpuEligibility(
+ net({
+ types: [color("c", [{ elementId: "x", name: "x", type: "real" }])],
+ places: [place("p", { colorId: "c" })],
+ transitions: [transition("t", [], [{ placeId: "p", weight: 1 }])],
+ }),
+ );
+
+ expect(result.eligible).toBe(false);
+ if (result.eligible) return;
+ expect(result.reasons.map((reason) => reason.code)).toContain(
+ "colored-place-without-capacity",
+ );
+ });
+
+ it("accepts a typed place once it declares a capacity", () => {
+ const result = assessGpuEligibility(
+ net({
+ types: [
+ color("c", [
+ { elementId: "x", name: "x", type: "real" },
+ { elementId: "n", name: "n", type: "integer" },
+ ]),
+ ],
+ places: [place("p", { colorId: "c", capacity: 4 })],
+ transitions: [transition("t", [], [{ placeId: "p", weight: 1 }])],
+ }),
+ );
+
+ expect(result.eligible).toBe(true);
+ if (!result.eligible) return;
+ const [profilePlace] = result.profile.places;
+ expect(profilePlace?.realFields).toStrictEqual(["x"]);
+ expect(profilePlace?.discreteFields).toStrictEqual(["n"]);
+ expect(result.profile.uncolouredOnly).toBe(false);
+ });
+
+ it.each(["string", "uuid"] as const)(
+ "rejects a %s attribute, which needs more than 32 bits",
+ (type) => {
+ const result = assessGpuEligibility(
+ net({
+ types: [color("c", [{ elementId: "s", name: "s", type }])],
+ places: [place("p", { colorId: "c", capacity: 2 })],
+ transitions: [transition("t", [], [{ placeId: "p", weight: 1 }])],
+ }),
+ );
+
+ expect(result.eligible).toBe(false);
+ if (result.eligible) return;
+ expect(result.reasons.map((reason) => reason.code)).toContain(
+ "unsupported-attribute-type",
+ );
+ },
+ );
+
+ it("allows a pair arc on a typed place, which has a closed-form unranking", () => {
+ // Weight 2 maps a flat index to the k-th pair in the engine's own order
+ // (`pair-selection.ts`), so the scan keeps the CPU's first-passing choice.
+ const result = assessGpuEligibility(
+ net({
+ types: [color("c", [{ elementId: "x", name: "x", type: "real" }])],
+ places: [place("p", { colorId: "c", capacity: 8 })],
+ transitions: [
+ transition("t", [{ placeId: "p", weight: 2, type: "standard" }], []),
+ ],
+ }),
+ );
+
+ expect(result.eligible).toBe(true);
+ });
+
+ it("rejects an input arc wider than a pair on a typed place", () => {
+ // Beyond pairs there is no closed-form unranking in use, so the shader has no
+ // way to walk combinations in the engine's order.
+ const result = assessGpuEligibility(
+ net({
+ types: [color("c", [{ elementId: "x", name: "x", type: "real" }])],
+ places: [place("p", { colorId: "c", capacity: 8 })],
+ transitions: [
+ transition("t", [{ placeId: "p", weight: 3, type: "standard" }], []),
+ ],
+ }),
+ );
+
+ expect(result.eligible).toBe(false);
+ if (result.eligible) return;
+ expect(result.reasons.map((reason) => reason.code)).toContain(
+ "colored-input-arc-weight",
+ );
+ });
+
+ it("allows a weighted arc on an untyped place, which needs no enumeration", () => {
+ const result = assessGpuEligibility(
+ net({
+ places: [place("p"), place("q")],
+ transitions: [
+ transition(
+ "t",
+ [{ placeId: "p", weight: 3, type: "standard" }],
+ [{ placeId: "q", weight: 1 }],
+ ),
+ ],
+ }),
+ );
+
+ expect(result.eligible).toBe(true);
+ });
+
+ it("allows a weighted inhibitor arc on a typed place", () => {
+ // An inhibitor consumes nothing, so there is no combination to choose.
+ const result = assessGpuEligibility(
+ net({
+ types: [color("c", [{ elementId: "x", name: "x", type: "real" }])],
+ places: [place("p", { colorId: "c", capacity: 8 }), place("q")],
+ transitions: [
+ transition(
+ "t",
+ [{ placeId: "p", weight: 4, type: "inhibitor" }],
+ [{ placeId: "q", weight: 1 }],
+ ),
+ ],
+ }),
+ );
+
+ expect(result.eligible).toBe(true);
+ });
+
+ it("rejects state too large to schedule usefully", () => {
+ const result = assessGpuEligibility(
+ net({
+ types: [color("c", [{ elementId: "x", name: "x", type: "real" }])],
+ places: [place("p", { colorId: "c", capacity: 100_000 })],
+ transitions: [transition("t", [], [{ placeId: "p", weight: 1 }])],
+ }),
+ );
+
+ expect(result.eligible).toBe(false);
+ if (result.eligible) return;
+ expect(result.reasons.map((reason) => reason.code)).toContain(
+ "state-too-large",
+ );
+ });
+
+ it("rejects a net with nothing to simulate", () => {
+ const result = assessGpuEligibility(net({ places: [place("p")] }));
+
+ expect(result.eligible).toBe(false);
+ if (result.eligible) return;
+ expect(result.reasons.map((reason) => reason.code)).toContain(
+ "no-transitions",
+ );
+ });
+
+ it("reports every reason a real net is ineligible, not just the first", () => {
+ // This example has typed places with dynamics and no capacities set.
+ const result = assessGpuEligibility(
+ supplyChainWithDisruption.petriNetDefinition,
+ );
+
+ expect(result.eligible).toBe(false);
+ if (result.eligible) return;
+ expect(result.reasons.length).toBeGreaterThan(1);
+ // The message has to name the place, or a user cannot act on it.
+ expect(formatGpuIneligibility(result.reasons)).toMatch(/capacity/i);
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/eligibility.ts b/libs/@hashintel/petrinaut-core/src/webgpu/eligibility.ts
new file mode 100644
index 00000000000..ba89c2da7a0
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/eligibility.ts
@@ -0,0 +1,220 @@
+/**
+ * Whether a net can run on the WebGPU backend.
+ *
+ * The GPU backend is deliberately a *subset* engine. It exists because a
+ * restricted shape — bounded state, 32-bit numbers, no per-frame host
+ * round-trip — is thousands of times faster than the general CPU path, and that
+ * restriction is only safe if it is checked up front and reported honestly.
+ *
+ * Eligibility is computed before any shader is generated, so an ineligible net
+ * falls back to the CPU with a reason the UI can show, rather than failing at
+ * shader-compile time where the cause would be opaque.
+ */
+import { getArcEndpointPlaceId } from "../arc-endpoints";
+import { PLACE_CAPACITY_UNBOUNDED } from "../simulation/engine/capacity";
+
+import type { SDCPN } from "../types/sdcpn";
+
+/**
+ * Most typed tokens one arc may consume from a place.
+ *
+ * Two, because the shader picks the combination by unranking — mapping a flat
+ * index to the k-th combination in the engine's own order, which the pair case
+ * has in closed form (`pair-selection.ts`). Wider arcs would need the general
+ * greedy unranking and a deeper scan.
+ */
+const MAX_COLORED_INPUT_ARC_WEIGHT = 2;
+
+/** Why a net cannot use the GPU backend. */
+export type GpuIneligibilityReason = {
+ /** Stable code, for tests and for grouping in the UI. */
+ code:
+ | "colored-place-without-capacity"
+ | "unsupported-attribute-type"
+ | "colored-input-arc-weight"
+ | "no-transitions"
+ | "state-too-large";
+ message: string;
+ /** The net item responsible, when one can be identified. */
+ itemId?: string;
+};
+
+export type GpuEligibility =
+ | { eligible: true; profile: GpuNetProfile }
+ | { eligible: false; reasons: GpuIneligibilityReason[] };
+
+/**
+ * Static facts about an eligible net that the shader generator needs.
+ */
+export type GpuNetProfile = {
+ /** Places in frame order, with their GPU storage shape. */
+ places: {
+ id: string;
+ name: string;
+ /** Maximum tokens; equals 0 for uncoloured places, which store only a count. */
+ capacity: number;
+ /** Names of `real` attributes, in declaration order. These are integrated. */
+ realFields: string[];
+ /** Names of `integer`/`boolean` attributes, carried but not integrated. */
+ discreteFields: string[];
+ colored: boolean;
+ }[];
+ /** Whether every place is uncoloured, so per-run state is just counts. */
+ uncolouredOnly: boolean;
+ /** Per-run state size in bytes, which bounds how many runs fit in a buffer. */
+ bytesPerRun: number;
+};
+
+/**
+ * Attribute types the GPU backend can hold.
+ *
+ * `string` is a 64-bit pool id and `uuid` is 128-bit; WGSL integers are 32-bit,
+ * so neither can be represented. See `emit-wgsl.ts`.
+ */
+const SUPPORTED_ATTRIBUTE_TYPES = new Set(["real", "integer", "boolean"]);
+
+/** Storage words per token: one f32 per real field, one u32 per discrete field. */
+function wordsPerToken(realCount: number, discreteCount: number): number {
+ return realCount + discreteCount;
+}
+
+/**
+ * Decides whether `sdcpn` can run on the GPU backend.
+ *
+ * `maxBytesPerRun` guards against a net whose bounded state is technically
+ * finite but too large to hold for a useful number of runs — a place with a
+ * capacity of ten million is expressible but not schedulable.
+ */
+export function assessGpuEligibility(
+ sdcpn: SDCPN,
+ { maxBytesPerRun = 4096 }: { maxBytesPerRun?: number } = {},
+): GpuEligibility {
+ const reasons: GpuIneligibilityReason[] = [];
+ const typeById = new Map(sdcpn.types.map((type) => [type.id, type]));
+ const places: GpuNetProfile["places"] = [];
+
+ // Per-run state always carries: one u32 count per place, one u32 elapsed-frame
+ // counter and one u32 firing count per transition, plus an RNG word and status.
+ let stateWords = sdcpn.places.length + sdcpn.transitions.length * 2 + 2;
+
+ for (const place of sdcpn.places) {
+ const colored = place.colorId !== null;
+ const realFields: string[] = [];
+ const discreteFields: string[] = [];
+
+ if (colored) {
+ const color = typeById.get(place.colorId!);
+ for (const element of color?.elements ?? []) {
+ if (!SUPPORTED_ATTRIBUTE_TYPES.has(element.type)) {
+ reasons.push({
+ code: "unsupported-attribute-type",
+ itemId: place.id,
+ message: `Place \`${place.name}\` carries a \`${element.type}\` attribute (\`${element.name}\`). WebGPU integers are 32-bit, so string and uuid attributes cannot be represented.`,
+ });
+ continue;
+ }
+ if (element.type === "real") {
+ realFields.push(element.name);
+ } else {
+ discreteFields.push(element.name);
+ }
+ }
+
+ // A coloured place needs a fixed token slot count to live in a buffer.
+ const capacity = place.capacity;
+ if (capacity === undefined || capacity === null) {
+ reasons.push({
+ code: "colored-place-without-capacity",
+ itemId: place.id,
+ message: `Place \`${place.name}\` holds typed tokens but has no token capacity. The GPU backend needs a fixed upper bound to size its buffers — set a capacity on this place.`,
+ });
+ continue;
+ }
+
+ stateWords +=
+ capacity * wordsPerToken(realFields.length, discreteFields.length);
+ places.push({
+ id: place.id,
+ name: place.name,
+ capacity,
+ realFields,
+ discreteFields,
+ colored: true,
+ });
+ } else {
+ places.push({
+ id: place.id,
+ name: place.name,
+ capacity: 0,
+ realFields: [],
+ discreteFields: [],
+ colored: false,
+ });
+ }
+ }
+
+ if (sdcpn.transitions.length === 0) {
+ reasons.push({
+ code: "no-transitions",
+ message: "The net has no transitions, so there is nothing to simulate.",
+ });
+ }
+
+ // Enumerating token combinations for a weighted arc over typed tokens is
+ // combinatorial (a product of binomials) with a data-dependent trip count,
+ // which is exactly what a SIMT execution model handles worst. Weight-1 arcs
+ // need no enumeration at all.
+ const coloredPlaceIds = new Set(
+ places.filter((place) => place.colored).map((place) => place.id),
+ );
+ for (const transition of sdcpn.transitions) {
+ for (const arc of transition.inputArcs) {
+ const placeId = getArcEndpointPlaceId(arc);
+ if (
+ arc.type !== "inhibitor" &&
+ arc.weight > MAX_COLORED_INPUT_ARC_WEIGHT &&
+ placeId !== null &&
+ coloredPlaceIds.has(placeId)
+ ) {
+ reasons.push({
+ code: "colored-input-arc-weight",
+ itemId: transition.id,
+ message: `Transition \`${transition.name}\` consumes ${arc.weight} typed tokens from one place. The GPU backend supports at most ${MAX_COLORED_INPUT_ARC_WEIGHT} per place: a wider arc means choosing among \`C(n, w)\` combinations, and only the pair case has an unranking that keeps the engine's ordering.`,
+ });
+ }
+ }
+ }
+
+ const bytesPerRun = stateWords * 4;
+ if (bytesPerRun > maxBytesPerRun) {
+ reasons.push({
+ code: "state-too-large",
+ message: `One run needs ${bytesPerRun} bytes of GPU state, above the ${maxBytesPerRun}-byte limit. Lower the token capacities to fit more runs on the device.`,
+ });
+ }
+
+ if (reasons.length > 0) {
+ return { eligible: false, reasons };
+ }
+
+ return {
+ eligible: true,
+ profile: {
+ places,
+ uncolouredOnly: places.every((place) => !place.colored),
+ bytesPerRun,
+ },
+ };
+}
+
+/** Human-readable one-liner for why the GPU backend was not used. */
+export function formatGpuIneligibility(
+ reasons: readonly GpuIneligibilityReason[],
+): string {
+ if (reasons.length === 1) {
+ return reasons[0]!.message;
+ }
+ return `${reasons.length} reasons: ${reasons.map((reason) => reason.message).join(" ")}`;
+}
+
+export { PLACE_CAPACITY_UNBOUNDED };
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts
new file mode 100644
index 00000000000..d2f508bae5f
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts
@@ -0,0 +1,387 @@
+import { describe, expect, it } from "vitest";
+
+import { HIR_MATH_FNS } from "../hir/hir";
+import { lowerTypeScriptToHir } from "../hir/lower-typescript";
+import {
+ describeMathFnSupport,
+ emitF32Literal,
+ isWgslRepresentableType,
+ WgslBailError,
+ WgslEmitter,
+} from "./emit-wgsl";
+
+import type { HirFunction } from "../hir/hir";
+import type { WgslValue } from "./emit-wgsl";
+
+/** Lowers a lambda body so tests exercise real HIR rather than hand-built trees. */
+function lowerLambda(code: string): HirFunction {
+ const result = lowerTypeScriptToHir(code, "lambda");
+ if (!result.ok) {
+ throw new Error(
+ `test lambda did not lower: ${result.diagnostics.map((d) => d.message).join("; ")}`,
+ );
+ }
+ return result.fn;
+}
+
+function emit(
+ code: string,
+ {
+ parameterValues = {},
+ tokens = [],
+ }: {
+ parameterValues?: Record;
+ tokens?: WgslValue[];
+ } = {},
+): { statements: string[]; code: string } {
+ const fn = lowerLambda(code);
+ const emitter = new WgslEmitter({
+ parameterValues,
+ randomCall: "rng_next_f32(&rng)",
+ });
+ const env = new Map();
+ const tokensParam = fn.params[0];
+ if (tokensParam) {
+ env.set(tokensParam.name, { kind: "array", elements: tokens });
+ }
+ const value = emitter.emit(fn.body, env);
+ return { statements: emitter.statements, code: emitter.f32(value) };
+}
+
+describe("emitF32Literal", () => {
+ it("always emits something WGSL parses as floating point", () => {
+ expect(emitF32Literal(1)).toBe("1.0");
+ expect(emitF32Literal(0)).toBe("0.0");
+ expect(emitF32Literal(-3)).toBe("-3.0");
+ expect(emitF32Literal(0.5)).toBe("0.5");
+ });
+
+ it("narrows to the value the GPU will actually hold", () => {
+ // 0.1 is not representable in f32; the literal must be the f32 neighbour so
+ // host and device agree on the constant.
+ expect(emitF32Literal(0.1)).toBe("0.10000000149011612");
+ });
+
+ it("refuses non-finite values rather than dividing by zero to build them", () => {
+ // This used to emit `(0.0 / 0.0)` and `(±1.0 / 0.0)`. Both are
+ // shader-creation errors, not values: the operands are AbstractFloat
+ // literals, so the quotient is a const-expression WGSL must evaluate when
+ // the module is created, and AbstractFloat cannot hold NaN or an infinity.
+ // `Infinity` is the spelling the product's own AI guidance suggests for a
+ // rate that always fires, so this was reachable from ordinary authoring.
+ for (const value of [
+ Number.NaN,
+ Number.POSITIVE_INFINITY,
+ Number.NEGATIVE_INFINITY,
+ ]) {
+ expect(() => emitF32Literal(value)).toThrow(/no WGSL representation/);
+ }
+ });
+
+ it("refuses a finite value that overflows f32 instead of emitting `Infinity`", () => {
+ // The non-finite guard runs on the input, but `Math.fround` overflows
+ // anything past ~3.4e38 to an infinity, and `String(Infinity)` is the
+ // JavaScript spelling — which WGSL reads as an undeclared identifier. So the
+ // check has to happen after narrowing too.
+ expect(() => emitF32Literal(1e300)).toThrow(/overflows f32/);
+ expect(() => emitF32Literal(-3.5e38)).toThrow(/overflows f32/);
+ // Just inside the range still emits a literal.
+ // Narrowed to its f32 neighbour, as every in-range literal is.
+ expect(emitF32Literal(3.4e38)).toBe("3.3999999521443642e+38");
+ });
+});
+
+describe("WgslEmitter", () => {
+ it("inlines parameters as literals rather than buffer reads", () => {
+ const result = emit(
+ "export default Lambda((tokens, parameters) => parameters.rate);",
+ {
+ parameterValues: { rate: 2.5 },
+ },
+ );
+
+ expect(result.code).toBe("2.5");
+ });
+
+ it("emits arithmetic and comparison operators", () => {
+ const result = emit(
+ "export default Lambda((tokens, parameters) => parameters.a * 2 + parameters.b / 4);",
+ { parameterValues: { a: 3, b: 8 } },
+ );
+
+ expect(result.code).toBe("((3.0 * 2.0) + (8.0 / 4.0))");
+ });
+
+ it("maps `**` to pow, which WGSL has no operator for", () => {
+ const result = emit(
+ "export default Lambda((tokens, parameters) => parameters.a ** 3);",
+ { parameterValues: { a: 2 } },
+ );
+
+ expect(result.code).toBe("pow(2.0, 3.0)");
+ });
+
+ it("emits conditionals as `select`, which is branchless", () => {
+ const result = emit(
+ "export default Lambda((tokens, parameters) => parameters.a > 1 ? 10 : 20);",
+ { parameterValues: { a: 2 } },
+ );
+
+ expect(result.code).toBe("select(20.0, 10.0, (2.0 > 1.0))");
+ });
+
+ it("hoists `const` bindings so each is evaluated once", () => {
+ const result = emit(
+ `export default Lambda((tokens, parameters) => {
+ const base = parameters.a * 2;
+ return base + base;
+ });`,
+ { parameterValues: { a: 4 } },
+ );
+
+ expect(result.statements).toHaveLength(1);
+ expect(result.statements[0]).toMatch(
+ /^let u_0_base: f32 = \(4\.0 \* 2\.0\);$/,
+ );
+ // Both uses reference the temporary, not a duplicated expression.
+ expect(result.code).toBe("(u_0_base + u_0_base)");
+ });
+
+ it("routes `Math.random` through the caller's generator", () => {
+ const result = emit(
+ "export default Lambda((tokens, parameters) => Math.random() * parameters.a);",
+ { parameterValues: { a: 1 } },
+ );
+
+ expect(result.code).toContain("rng_next_f32(&rng)");
+ });
+
+ it("polyfills the math builtins WGSL lacks, exactly", () => {
+ expect(
+ emit(
+ "export default Lambda((tokens, parameters) => Math.log10(parameters.a));",
+ {
+ parameterValues: { a: 100 },
+ },
+ ).code,
+ ).toBe("(log(100.0) * 0.43429448190325176)");
+
+ // JS Math.round rounds half up; WGSL's `round` breaks ties to even.
+ expect(
+ emit(
+ "export default Lambda((tokens, parameters) => Math.round(parameters.a));",
+ {
+ parameterValues: { a: 1.5 },
+ },
+ ).code,
+ ).toBe("floor(1.5 + 0.5)");
+
+ // pow() alone returns NaN for a negative base, so the sign is carried out.
+ expect(
+ emit(
+ "export default Lambda((tokens, parameters) => Math.cbrt(parameters.a));",
+ {
+ parameterValues: { a: -8 },
+ },
+ ).code,
+ ).toContain("sign(-8.0)");
+ });
+
+ it("folds variadic min/max into WGSL's binary form", () => {
+ const result = emit(
+ "export default Lambda((tokens, parameters) => Math.max(parameters.a, parameters.b, 7));",
+ { parameterValues: { a: 1, b: 2 } },
+ );
+
+ expect(result.code).toBe("max(max(1.0, 2.0), 7.0)");
+ });
+
+ it("refuses string values, which need a 64-bit pool id", () => {
+ expect(() =>
+ emit(
+ 'export default Lambda((tokens, parameters) => "x" === "y" ? 1 : 0);',
+ ),
+ ).toThrow(WgslBailError);
+ });
+
+ it("refuses a distribution outside a kernel", () => {
+ expect(() =>
+ emit(
+ "export default Lambda((tokens, parameters) => Distribution.Gaussian(0, 1));",
+ ),
+ ).toThrow(WgslBailError);
+ });
+
+ it("covers every HIR math builtin with a stated strategy", () => {
+ const support = describeMathFnSupport();
+
+ expect(support).toHaveLength(HIR_MATH_FNS.length);
+ expect(
+ support.filter((entry) => entry.support === "builtin").length,
+ ).toBeGreaterThan(15);
+ // `random` is the only one needing generator state.
+ expect(support.filter((entry) => entry.support === "rng")).toStrictEqual([
+ { fn: "random", support: "rng" },
+ ]);
+ });
+});
+
+describe("isWgslRepresentableType", () => {
+ it("accepts the numeric and boolean types", () => {
+ expect(isWgslRepresentableType({ kind: "real" })).toBe(true);
+ expect(isWgslRepresentableType({ kind: "int" })).toBe(true);
+ expect(isWgslRepresentableType({ kind: "bool" })).toBe(true);
+ });
+
+ it("rejects what 32-bit WGSL cannot hold", () => {
+ expect(isWgslRepresentableType({ kind: "string" })).toBe(false);
+ expect(isWgslRepresentableType({ kind: "uuid" })).toBe(false);
+ expect(isWgslRepresentableType({ kind: "distribution" })).toBe(false);
+ expect(isWgslRepresentableType({ kind: "unknown" })).toBe(false);
+ });
+
+ it("rejects an array with no statically-known length", () => {
+ expect(
+ isWgslRepresentableType({ kind: "array", element: { kind: "real" } }),
+ ).toBe(false);
+ expect(
+ isWgslRepresentableType({
+ kind: "array",
+ element: { kind: "real" },
+ length: 3,
+ }),
+ ).toBe(true);
+ });
+});
+
+/**
+ * Distributions are kernel-only — `typecheck.ts` reports
+ * `hir:distribution-outside-kernel` anywhere else — so these lower a kernel and
+ * pass `rngStateVar`, which is what tells the emitter it is in a kernel.
+ */
+describe("WgslEmitter distributions", () => {
+ function emitKernel(
+ code: string,
+ { rngStateVar = "rng_state" }: { rngStateVar?: string } = {},
+ ): { statements: string[]; code: string } {
+ const result = lowerTypeScriptToHir(code, "kernel");
+ if (!result.ok) {
+ throw new Error(
+ `test kernel did not lower: ${result.diagnostics
+ .map((diagnostic) => diagnostic.message)
+ .join("; ")}`,
+ );
+ }
+ const emitter = new WgslEmitter({ parameterValues: {}, rngStateVar });
+ const value = emitter.emit(result.fn.body, new Map());
+ return { statements: emitter.statements, code: emitter.f32(value) };
+ }
+
+ it("samples each family through the prelude's helpers", () => {
+ for (const [source, expected] of [
+ ["Distribution.Gaussian(1, 2)", "sample_gaussian(&rng_state, 1.0, 2.0)"],
+ ["Distribution.Uniform(0, 10)", "sample_uniform(&rng_state, 0.0, 10.0)"],
+ [
+ "Distribution.Lognormal(0, 1)",
+ "sample_lognormal(&rng_state, 0.0, 1.0)",
+ ],
+ ] as const) {
+ const { statements } = emitKernel(
+ `export default TransitionKernel(() => ${source}.map((v) => v))`,
+ );
+ expect(statements.join("\n")).toContain(expected);
+ }
+ });
+
+ it("draws once per distribution, so sibling maps stay coherent", () => {
+ // The CPU caches the draw on the distribution object precisely so that two
+ // `.map()`s over the same distribution see one sample.
+ //
+ // What guarantees it here is the `let` binding, which the emitter already
+ // hoists — a distribution can only be reached twice by being bound, since two
+ // inline `Distribution.Gaussian(...)` calls are two HIR nodes and should draw
+ // twice. Hoisting the sample as well is belt-and-braces, and keeps one named
+ // `let` per draw in the generated WGSL.
+ const { statements, code } = emitKernel(`
+ export default TransitionKernel(() => {
+ const shared = Distribution.Gaussian(0, 1);
+ return shared.map((v) => v) + shared.map((v) => v * 2);
+ })
+ `);
+
+ const draws = statements.filter((statement) =>
+ statement.includes("sample_gaussian("),
+ );
+ expect(draws).toHaveLength(1);
+ // Both uses read the hoisted name rather than re-sampling.
+ expect(code).not.toContain("sample_gaussian(");
+ });
+
+ it("applies the map body to the sampled value", () => {
+ const { code, statements } = emitKernel(
+ "export default TransitionKernel(() => Distribution.Uniform(0, 1).map((v) => v * 100))",
+ );
+
+ expect(statements.join("\n")).toContain("sample_uniform(&rng_state");
+ expect(code).toMatch(/\* 100\.0/);
+ });
+
+ it("still refuses distributions when no generator is in scope", () => {
+ // Which is every surface except a kernel — matching where the CPU allows
+ // them. Built directly rather than through the helper, whose default would
+ // put `rngStateVar` back.
+ const lowered = lowerTypeScriptToHir(
+ "export default TransitionKernel(() => Distribution.Gaussian(0, 1).map((v) => v))",
+ "kernel",
+ );
+ if (!lowered.ok) {
+ throw new Error("test kernel did not lower");
+ }
+ const emitter = new WgslEmitter({ parameterValues: {} });
+
+ expect(() =>
+ emitter.emit(lowered.fn.body, new Map()),
+ ).toThrow(WgslBailError);
+ });
+});
+
+describe("Math.hypot arity", () => {
+ const hypotOf = (expression: string) =>
+ emit(`export default Lambda((tokens, parameters) => ${expression});`, {
+ parameterValues: { rate: 0.5 },
+ }).code;
+
+ it("emits the absolute value for one argument", () => {
+ // `hypot: { min: 1, max: Infinity }` in the typechecker, but the emitter
+ // destructured exactly two arguments — so one argument left the second as
+ // JavaScript `undefined` and emitted the text `undefined` into the shader.
+ expect(hypotOf("Math.hypot(parameters.rate)")).toBe("abs(0.5)");
+ });
+
+ it("widens the vector rather than dropping arguments past the second", () => {
+ // Three arguments used to compute a 2-D distance while the CPU computed a
+ // 3-D one: valid WGSL, no error, and agreement with the CPU on nothing.
+ // `Math.hypot(dx, dy, dz)` is the natural spelling in the orbital models
+ // this backend exists for.
+ expect(hypotOf("Math.hypot(parameters.rate, parameters.rate, 3)")).toBe(
+ "length(vec3(0.5, 0.5, 3.0))",
+ );
+ expect(
+ hypotOf("Math.hypot(parameters.rate, 1, 2, 3)"),
+ ).toBe("length(vec4(0.5, 1.0, 2.0, 3.0))");
+ });
+
+ it("folds past four arguments, since WGSL vectors stop at four components", () => {
+ // `vec5` does not exist. hypot is associative, so a running length
+ // gives the same answer with the same overflow behaviour.
+ expect(hypotOf("Math.hypot(parameters.rate, 1, 2, 3, 4)")).toBe(
+ "length(vec2(length(vec2(length(vec2(length(vec2(0.5, 1.0)), 2.0)), 3.0)), 4.0))",
+ );
+ });
+
+ it("still emits the plain two-argument form", () => {
+ expect(hypotOf("Math.hypot(parameters.rate, 1)")).toBe(
+ "length(vec2(0.5, 1.0))",
+ );
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts
new file mode 100644
index 00000000000..01b1e839de7
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts
@@ -0,0 +1,696 @@
+/**
+ * HIR → WGSL expression emitter.
+ *
+ * A second backend for the same HIR the JavaScript emitter consumes
+ * (`../hir/emit-buffer-js.ts`), targeting WebGPU compute shaders. The HIR was
+ * built for this: it is pure, has no recursion and no unbounded loops, and its
+ * `arrayMap` lengths are statically known — all of which a shader requires.
+ *
+ * Two differences from the JS backend drive most of the design:
+ *
+ * **Everything is f32.** WGSL has no `f64` (the proposal is open but unshipped:
+ * gpuweb/gpuweb#2805). HIR `real` and `integer` both live in f64 lanes on the
+ * CPU, so numbers narrow here. For `real` this is usually invisible — measured
+ * on logistic growth, f32 Euler tracks f64 Euler to three significant figures,
+ * because integrator truncation error dwarfs rounding error. For `integer` it
+ * imposes a hard exactness ceiling of 2^24 (16,777,216), above which counting
+ * silently loses precision.
+ *
+ * **No 64-bit integers.** WGSL integers are 32-bit, so HIR `string` (a u64
+ * string-pool id) and `uuid` (128-bit) have no representation. Programs touching
+ * them are rejected rather than approximated.
+ *
+ * Unsupported shapes bail by returning `null`, matching the JS emitter's
+ * contract, so the caller falls back to the CPU backend instead of failing.
+ */
+import { HIR_MATH_FNS } from "../hir/hir";
+import { mangleWgslIdentifier } from "./wgsl-identifiers";
+
+import type { HirExpr, HirMathFn, HirType } from "../hir/hir";
+
+/** Thrown internally when a program cannot be expressed in WGSL. */
+export class WgslBailError extends Error {
+ constructor(reason: string) {
+ super(reason);
+ this.name = "WgslBailError";
+ }
+}
+
+/**
+ * A WGSL value produced by emitting one HIR node.
+ *
+ * Mirrors the JS emitter's `Value` union, minus the cases WGSL cannot hold.
+ * `record` and `array` are compile-time-only groupings that never become WGSL
+ * values — they are destructured before use, exactly as the JS emitter does.
+ */
+export type WgslValue =
+ | { kind: "f32"; code: string }
+ | { kind: "bool"; code: string }
+ | { kind: "record"; fields: Map }
+ | { kind: "array"; elements: WgslValue[] }
+ /** One token's field accessors, resolved lazily on field access. */
+ | { kind: "token"; read: (fieldName: string) => WgslValue };
+
+/**
+ * How each HIR math builtin reaches WGSL.
+ *
+ * Most are WGSL builtins outright. The rest are expressed exactly rather than
+ * approximated, except `random`, which needs generator state and so is handled
+ * by the caller.
+ */
+const MATH_FN_WGSL: Record<
+ HirMathFn,
+ | { kind: "builtin" }
+ | { kind: "expr"; emit: (args: string[]) => string }
+ | { kind: "rng" }
+> = {
+ abs: { kind: "builtin" },
+ acos: { kind: "builtin" },
+ asin: { kind: "builtin" },
+ atan: { kind: "builtin" },
+ atan2: { kind: "builtin" },
+ ceil: { kind: "builtin" },
+ cos: { kind: "builtin" },
+ cosh: { kind: "builtin" },
+ exp: { kind: "builtin" },
+ floor: { kind: "builtin" },
+ log: { kind: "builtin" },
+ log2: { kind: "builtin" },
+ max: { kind: "builtin" },
+ min: { kind: "builtin" },
+ pow: { kind: "builtin" },
+ sign: { kind: "builtin" },
+ sin: { kind: "builtin" },
+ sinh: { kind: "builtin" },
+ sqrt: { kind: "builtin" },
+ tan: { kind: "builtin" },
+ tanh: { kind: "builtin" },
+ trunc: { kind: "builtin" },
+ // WGSL has no cbrt. Sign is carried out of the power so negative inputs work,
+ // which `pow` alone would return NaN for.
+ cbrt: {
+ kind: "expr",
+ emit: ([x]) => `(sign(${x}) * pow(abs(${x}), 0.33333333333333331))`,
+ },
+ // WGSL has no hypot; vector length is the same computation with the same
+ // overflow characteristics as a naive sqrt(x*x + y*y + ...).
+ //
+ // Every arity the typechecker allows (`hypot: { min: 1, max: Infinity }`) has
+ // to be handled. Destructuring two arguments was wrong in both directions: one
+ // argument left the second as JavaScript `undefined` and emitted the text
+ // `undefined` into the shader, and three or more silently dropped the rest,
+ // computing a 2-D distance where the CPU computed a 3-D one.
+ hypot: {
+ kind: "expr",
+ emit: (args) => {
+ const [first] = args;
+ if (first === undefined) {
+ throw new WgslBailError("`Math.hypot` needs at least one argument");
+ }
+ if (args.length === 1) {
+ return `abs(${first})`;
+ }
+ if (args.length <= 4) {
+ return `length(vec${args.length}(${args.join(", ")}))`;
+ }
+ // WGSL vectors stop at four components, so wider calls fold. hypot is
+ // associative, and combining a running length with the next component
+ // keeps the overflow behaviour of the narrow case rather than summing
+ // squares directly.
+ return args.reduce(
+ (accumulated, argument) =>
+ `length(vec2(${accumulated}, ${argument}))`,
+ );
+ },
+ },
+ // WGSL has log and log2 but not log10.
+ log10: { kind: "expr", emit: ([x]) => `(log(${x}) * 0.43429448190325176)` },
+ // WGSL's `round` breaks ties to even; JavaScript's Math.round rounds half
+ // upward. Emitting floor(x + 0.5) keeps the JS semantics the HIR documents.
+ round: { kind: "expr", emit: ([x]) => `floor(${x} + 0.5)` },
+ random: { kind: "rng" },
+};
+
+/**
+ * Formats an f32 literal that WGSL will parse as floating point.
+ *
+ * @throws WgslBailError when the value has no f32 literal form, which sends the
+ * net to the CPU rather than emitting a shader that cannot be created.
+ */
+export function emitF32Literal(value: number): string {
+ if (!Number.isFinite(value)) {
+ // This used to construct the value arithmetically — `(0.0 / 0.0)` for NaN,
+ // `(1.0 / 0.0)` for an infinity. Both are shader-creation errors: the
+ // operands are AbstractFloat literals, so the quotient is a const-expression
+ // that must be evaluated at creation time, and AbstractFloat's value set
+ // excludes NaN and the infinities. WGSL has no literal for either, and it
+ // separately permits implementations to assume they never arise, so there is
+ // nothing to emit here that would mean what the author wrote.
+ throw new WgslBailError(
+ `\`${value}\` has no WGSL representation, so this expression can only run on the CPU`,
+ );
+ }
+ // f32 has ~9 significant decimal digits; round-tripping through Math.fround
+ // makes the emitted literal exactly the value the GPU will hold.
+ const narrowed = Math.fround(value);
+ if (!Number.isFinite(narrowed)) {
+ // Checked after narrowing as well as before: `Math.fround` overflows anything
+ // beyond ~3.4e38 to an infinity, and `String(Infinity)` is the JavaScript
+ // spelling `Infinity`, which WGSL would read as an undeclared identifier.
+ throw new WgslBailError(
+ `\`${value}\` overflows f32, the widest float WGSL has`,
+ );
+ }
+ return Number.isInteger(narrowed) && Math.abs(narrowed) < 1e21
+ ? `${narrowed}.0`
+ : String(narrowed);
+}
+
+export type WgslEmitterOptions = {
+ /**
+ * Model parameter values, inlined as literals.
+ *
+ * Parameters are fixed for a run, so binding them as uniforms would cost a
+ * buffer read per access for no flexibility.
+ */
+ parameterValues: Readonly>;
+ /**
+ * WGSL expression yielding the next uniform random f32 in [0, 1).
+ *
+ * Supplied by the caller because generator state lives in the shader's
+ * stepping loop, not in the expression tree.
+ */
+ randomCall?: string;
+ /**
+ * Name of the in-scope `var u32` holding the generator state, e.g.
+ * `"rng_state"`.
+ *
+ * Supplied only when emitting a transition kernel: `typecheck.ts` reports
+ * `hir:distribution-outside-kernel` anywhere else, so without this the emitter
+ * keeps refusing distributions rather than silently sampling somewhere the CPU
+ * would not.
+ */
+ rngStateVar?: string;
+ /**
+ * Prefix for hoisted identifiers, distinguishing this emitter's temporaries
+ * from another's.
+ *
+ * Required when two emitters' statements are spliced into one WGSL scope,
+ * because each counts its temporaries from zero and would otherwise name them
+ * identically — a redeclaration the generated shader only fails on at
+ * `createShaderModule`. The RK stages of a dynamics loop are the case that
+ * needs it.
+ */
+ identifierScope?: string;
+};
+
+/**
+ * Emits WGSL expressions for HIR nodes.
+ *
+ * Statement-shaped HIR (`let`) becomes hoisted `let` declarations in
+ * `statements`, which the caller splices above the expression that uses them.
+ */
+export class WgslEmitter {
+ readonly statements: string[] = [];
+ #temporaries = 0;
+
+ constructor(private readonly options: WgslEmitterOptions) {}
+
+ /** Emits `value` into a named temporary so it is evaluated exactly once. */
+ hoist(name: string, value: WgslValue): WgslValue {
+ if (
+ value.kind === "record" ||
+ value.kind === "array" ||
+ value.kind === "token"
+ ) {
+ // Compile-time groupings need no temporary; they are destructured later.
+ return value;
+ }
+ const identifier = mangleWgslIdentifier(
+ name,
+ this.#temporaries++,
+ this.options.identifierScope,
+ );
+ const type = value.kind === "bool" ? "bool" : "f32";
+ this.statements.push(`let ${identifier}: ${type} = ${value.code};`);
+ return { kind: value.kind, code: identifier };
+ }
+
+ /** Narrows a value to a numeric WGSL expression, or bails. */
+ f32(value: WgslValue): string {
+ if (value.kind === "f32") {
+ return value.code;
+ }
+ if (value.kind === "bool") {
+ return `select(0.0, 1.0, ${value.code})`;
+ }
+ throw new WgslBailError(
+ `expected a numeric value but got a ${value.kind}, which has no WGSL representation`,
+ );
+ }
+
+ /** Narrows a value to a boolean WGSL expression, or bails. */
+ bool(value: WgslValue): string {
+ if (value.kind === "bool") {
+ return value.code;
+ }
+ if (value.kind === "f32") {
+ return `(${value.code} != 0.0)`;
+ }
+ throw new WgslBailError(`expected a boolean value but got a ${value.kind}`);
+ }
+
+ /**
+ * Emits one HIR node.
+ *
+ * @throws WgslBailError when the node has no WGSL representation.
+ */
+ emit(expr: HirExpr, env: ReadonlyMap): WgslValue {
+ const kind = expr.kind;
+ switch (expr.kind) {
+ case "numberLit":
+ return { kind: "f32", code: emitF32Literal(expr.value) };
+
+ case "boolLit":
+ return { kind: "bool", code: expr.value ? "true" : "false" };
+
+ case "constant":
+ switch (expr.name) {
+ case "PI":
+ return { kind: "f32", code: emitF32Literal(Math.PI) };
+ case "E":
+ return { kind: "f32", code: emitF32Literal(Math.E) };
+ case "Infinity":
+ return { kind: "f32", code: "(1.0 / 0.0)" };
+ case "NaN":
+ return { kind: "f32", code: "(0.0 / 0.0)" };
+ }
+ break;
+
+ case "localRef": {
+ const bound = env.get(expr.name);
+ if (!bound) {
+ throw new WgslBailError(`unbound local \`${expr.name}\``);
+ }
+ return bound;
+ }
+
+ case "paramRef": {
+ const value = this.options.parameterValues[expr.name];
+ if (value === undefined) {
+ throw new WgslBailError(`unknown parameter \`${expr.name}\``);
+ }
+ return typeof value === "boolean"
+ ? { kind: "bool", code: value ? "true" : "false" }
+ : { kind: "f32", code: emitF32Literal(value) };
+ }
+
+ case "fieldAccess": {
+ const target = this.emit(expr.target, env);
+ if (target.kind === "token") {
+ return target.read(expr.field);
+ }
+ if (target.kind === "record") {
+ const field = target.fields.get(expr.field);
+ if (!field) {
+ throw new WgslBailError(`unknown field \`${expr.field}\``);
+ }
+ return field;
+ }
+ throw new WgslBailError(
+ `field access on a ${target.kind}, which has no fields`,
+ );
+ }
+
+ case "indexAccess": {
+ const target = this.emit(expr.target, env);
+ if (target.kind !== "array") {
+ throw new WgslBailError("index access on a non-array");
+ }
+ // Only statically-known indices work: a shader cannot index a
+ // compile-time tuple dynamically.
+ const index = this.#constantIndex(expr.index, env);
+ const element = target.elements[index];
+ if (!element) {
+ throw new WgslBailError(`array index ${index} out of range`);
+ }
+ return element;
+ }
+
+ case "length": {
+ const target = this.emit(expr.target, env);
+ if (target.kind !== "array") {
+ throw new WgslBailError("`.length` on a non-array");
+ }
+ return { kind: "f32", code: emitF32Literal(target.elements.length) };
+ }
+
+ case "unary": {
+ const operand = this.emit(expr.operand, env);
+ switch (expr.op) {
+ case "-":
+ return { kind: "f32", code: `(-${this.f32(operand)})` };
+ case "+":
+ return { kind: "f32", code: this.f32(operand) };
+ case "!":
+ return { kind: "bool", code: `(!${this.bool(operand)})` };
+ }
+ break;
+ }
+
+ case "binary":
+ return this.#emitBinary(expr, env);
+
+ case "cond": {
+ const condition = this.bool(this.emit(expr.condition, env));
+ const thenValue = this.emit(expr.thenBranch, env);
+ const elseValue = this.emit(expr.elseBranch, env);
+ // WGSL `select` evaluates both arms, which is safe because the HIR is
+ // pure — no side effects, and the only divergence risk is arithmetic
+ // that would produce NaN/Inf in the untaken branch and then be
+ // discarded, which `select` handles correctly.
+ if (thenValue.kind === "bool" && elseValue.kind === "bool") {
+ return {
+ kind: "bool",
+ code: `select(${elseValue.code}, ${thenValue.code}, ${condition})`,
+ };
+ }
+ return {
+ kind: "f32",
+ code: `select(${this.f32(elseValue)}, ${this.f32(thenValue)}, ${condition})`,
+ };
+ }
+
+ case "let": {
+ const scope = new Map(env);
+ for (const binding of expr.bindings) {
+ scope.set(
+ binding.name,
+ this.hoist(binding.name, this.emit(binding.value, scope)),
+ );
+ }
+ return this.emit(expr.body, scope);
+ }
+
+ case "mathCall":
+ return this.#emitMathCall(expr, env);
+
+ case "recordLit": {
+ const fields = new Map();
+ for (const entry of expr.entries) {
+ fields.set(entry.key, this.emit(entry.value, env));
+ }
+ return { kind: "record", fields };
+ }
+
+ case "arrayLit":
+ return {
+ kind: "array",
+ elements: expr.elements.map((element) => this.emit(element, env)),
+ };
+
+ case "arrayMap": {
+ const target = this.emit(expr.target, env);
+ if (target.kind !== "array") {
+ throw new WgslBailError(
+ "`.map` over a value with no statically-known length",
+ );
+ }
+ // Unrolled, exactly as the JS buffer emitter does for token tuples.
+ return {
+ kind: "array",
+ elements: target.elements.map((element, index) => {
+ const scope = new Map(env);
+ scope.set(expr.param.name, element);
+ if (expr.indexParam) {
+ scope.set(expr.indexParam.name, {
+ kind: "f32",
+ code: emitF32Literal(index),
+ });
+ }
+ return this.emit(expr.body, scope);
+ }),
+ };
+ }
+
+ case "arrayConcat": {
+ const left = this.emit(expr.left, env);
+ const right = this.emit(expr.right, env);
+ if (left.kind !== "array" || right.kind !== "array") {
+ throw new WgslBailError("`.concat` on a non-array");
+ }
+ return {
+ kind: "array",
+ elements: [...left.elements, ...right.elements],
+ };
+ }
+
+ case "arrayReduce": {
+ const target = this.emit(expr.target, env);
+ if (target.kind !== "array") {
+ // Metric reduces run over runtime token counts, which cannot be
+ // unrolled. Those stay on the CPU.
+ throw new WgslBailError(
+ "`.reduce` over a value with no statically-known length",
+ );
+ }
+ let accumulator = this.emit(expr.initial, env);
+ for (const [index, element] of target.elements.entries()) {
+ const scope = new Map(env);
+ scope.set(expr.accParam.name, accumulator);
+ scope.set(expr.param.name, element);
+ if (expr.indexParam) {
+ scope.set(expr.indexParam.name, {
+ kind: "f32",
+ code: emitF32Literal(index),
+ });
+ }
+ accumulator = this.hoist("acc", this.emit(expr.body, scope));
+ }
+ return accumulator;
+ }
+
+ case "stringLit":
+ case "stringCall":
+ throw new WgslBailError(
+ "string values need a 64-bit string-pool id, and WGSL integers are 32-bit",
+ );
+
+ case "uuidGenerate":
+ case "uuidFrom":
+ throw new WgslBailError(
+ "uuid values are 128-bit, which WGSL cannot represent",
+ );
+
+ case "distribution": {
+ const rngStateVar = this.options.rngStateVar;
+ if (rngStateVar === undefined) {
+ throw new WgslBailError(
+ "probability distributions are only supported in transition kernels",
+ );
+ }
+ if (expr.args.length !== 2) {
+ // Typecheck already reports arity, so this only guards malformed HIR.
+ throw new WgslBailError(
+ `${expr.dist} needs exactly two arguments, got ${expr.args.length}`,
+ );
+ }
+ const args = expr.args.map((argument) =>
+ this.f32(this.emit(argument, env)),
+ );
+ // Hoisted, not inlined: the CPU caches a distribution's draw on the
+ // object so sibling `.map()` calls over the same distribution see one
+ // coherent sample. A `let` gives the same sharing, because a `let`-bound
+ // distribution resolves to this same name at every use.
+ return this.hoist(`dist_${expr.dist}`, {
+ kind: "f32",
+ code: `sample_${expr.dist}(&${rngStateVar}, ${args.join(", ")})`,
+ });
+ }
+ case "distributionMap": {
+ // `.map` is eager here where the CPU is lazy, so a distribution built and
+ // never used still advances the generator. The two backends' streams
+ // already differ by design, and the sampled *distribution* is unchanged.
+ const base = this.emit(expr.base, env);
+ const inner = new Map(env);
+ inner.set(expr.param.name, base);
+ return this.emit(expr.body, inner);
+ }
+ }
+
+ // Reached only when an inner switch falls through (`constant`, `unary`),
+ // which the outer switch's exhaustiveness hides from narrowing.
+ throw new WgslBailError(`unsupported HIR node \`${kind}\``);
+ }
+
+ #emitBinary(
+ expr: Extract,
+ env: ReadonlyMap,
+ ): WgslValue {
+ const left = this.emit(expr.left, env);
+ const right = this.emit(expr.right, env);
+
+ switch (expr.op) {
+ case "+":
+ case "-":
+ case "*":
+ return {
+ kind: "f32",
+ code: `(${this.f32(left)} ${expr.op} ${this.f32(right)})`,
+ };
+ case "/":
+ // WGSL division by zero is implementation-defined rather than the
+ // Infinity JavaScript produces, so it is not special-cased here; models
+ // relying on division by zero are outside what this backend reproduces.
+ return {
+ kind: "f32",
+ code: `(${this.f32(left)} / ${this.f32(right)})`,
+ };
+ case "%":
+ // JS `%` keeps the sign of the dividend, and so does WGSL's `%` on
+ // floats, so this maps directly.
+ return {
+ kind: "f32",
+ code: `(${this.f32(left)} % ${this.f32(right)})`,
+ };
+ case "**":
+ return {
+ kind: "f32",
+ code: `pow(${this.f32(left)}, ${this.f32(right)})`,
+ };
+ case "<":
+ case "<=":
+ case ">":
+ case ">=":
+ return {
+ kind: "bool",
+ code: `(${this.f32(left)} ${expr.op} ${this.f32(right)})`,
+ };
+ case "==":
+ case "!=": {
+ const operator = expr.op === "==" ? "==" : "!=";
+ if (left.kind === "bool" && right.kind === "bool") {
+ return {
+ kind: "bool",
+ code: `(${left.code} ${operator} ${right.code})`,
+ };
+ }
+ return {
+ kind: "bool",
+ code: `(${this.f32(left)} ${operator} ${this.f32(right)})`,
+ };
+ }
+ case "&&":
+ return {
+ kind: "bool",
+ code: `(${this.bool(left)} && ${this.bool(right)})`,
+ };
+ case "||":
+ return {
+ kind: "bool",
+ code: `(${this.bool(left)} || ${this.bool(right)})`,
+ };
+ }
+ }
+
+ #emitMathCall(
+ expr: Extract,
+ env: ReadonlyMap,
+ ): WgslValue {
+ const mapping = MATH_FN_WGSL[expr.fn];
+
+ if (mapping.kind === "rng") {
+ if (!this.options.randomCall) {
+ throw new WgslBailError(
+ "`Math.random` is not available in this shader surface",
+ );
+ }
+ return { kind: "f32", code: this.options.randomCall };
+ }
+
+ const args = expr.args.map((argument) =>
+ this.f32(this.emit(argument, env)),
+ );
+
+ if (mapping.kind === "builtin") {
+ // `min`/`max` are variadic in JavaScript but binary in WGSL, so they are
+ // folded left-to-right.
+ if ((expr.fn === "min" || expr.fn === "max") && args.length !== 2) {
+ if (args.length === 0) {
+ // Math.min() is Infinity, Math.max() is -Infinity.
+ return {
+ kind: "f32",
+ code: expr.fn === "min" ? "(1.0 / 0.0)" : "(-1.0 / 0.0)",
+ };
+ }
+ return {
+ kind: "f32",
+ code: args.reduce(
+ (accumulator, argument) =>
+ `${expr.fn}(${accumulator}, ${argument})`,
+ ),
+ };
+ }
+ return { kind: "f32", code: `${expr.fn}(${args.join(", ")})` };
+ }
+
+ return { kind: "f32", code: mapping.emit(args) };
+ }
+
+ /** Resolves a statically-known array index, or bails. */
+ #constantIndex(expr: HirExpr, env: ReadonlyMap): number {
+ if (expr.kind === "numberLit") {
+ return expr.value;
+ }
+ // The JS emitter folds constants before emitting; anything still dynamic
+ // here cannot index a compile-time tuple.
+ const value = this.emit(expr, env);
+ const literal = /^-?\d+(?:\.0)?$/u.exec(
+ value.kind === "f32" ? value.code : "",
+ );
+ if (literal) {
+ return Number.parseFloat(literal[0]);
+ }
+ throw new WgslBailError(
+ "dynamic index into transition input tokens is not supported",
+ );
+ }
+}
+
+/** Every HIR math builtin, with how it reaches WGSL. Exposed for tests/docs. */
+export function describeMathFnSupport(): {
+ fn: HirMathFn;
+ support: "builtin" | "polyfill" | "rng";
+}[] {
+ return HIR_MATH_FNS.map((fn) => {
+ const mapping = MATH_FN_WGSL[fn];
+ return {
+ fn,
+ support:
+ mapping.kind === "builtin"
+ ? "builtin"
+ : mapping.kind === "rng"
+ ? "rng"
+ : "polyfill",
+ };
+ });
+}
+
+/** HIR types this backend can represent. */
+export function isWgslRepresentableType(type: HirType): boolean {
+ switch (type.kind) {
+ case "real":
+ case "int":
+ case "bool":
+ return true;
+ case "record":
+ return type.fields.every((field) => isWgslRepresentableType(field.type));
+ case "array":
+ return type.length !== undefined && isWgslRepresentableType(type.element);
+ case "string":
+ case "uuid":
+ case "distribution":
+ case "unknown":
+ return false;
+ }
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts
new file mode 100644
index 00000000000..baa8e1e0715
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts
@@ -0,0 +1,296 @@
+/**
+ * Presents a GPU run as a `MonteCarloExperiment`, so callers need no branch.
+ *
+ * The provider subscribes to `status`/`progress`/`metrics`/`events` and does not
+ * care which backend produced them. Keeping that contract identical is what lets
+ * the GPU path be a setting rather than a parallel UI.
+ *
+ * Supportability is resolved *before* the handle exists — eligibility, HIR
+ * lowering and shader generation all happen in `create...`, which returns a
+ * reason instead of a handle when the net cannot run. A handle that could fail
+ * on `start()` would leave the caller unable to fall back cleanly, because by
+ * then the experiment is already registered and showing as running.
+ */
+import {
+ appendMetricFrames,
+ createEmptyMetricsState,
+ createEventStream,
+ createReadableStore,
+} from "../simulation/monte-carlo/runtime/experiment-stores";
+import { requestGpuExperimentBackend } from "./backend";
+import { GPU_HISTOGRAM_BINS } from "./compile-net-shader";
+import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames";
+import { runGpuExperiment } from "./runner";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirArtifacts } from "../hir-runtime";
+import type { InitialMarking } from "../simulation/api";
+import type { MonteCarloMetricSpec } from "../simulation/monte-carlo/metrics";
+import type {
+ MonteCarloExperiment,
+ MonteCarloExperimentEvent,
+ MonteCarloExperimentState,
+} from "../simulation/monte-carlo/runtime/experiment";
+import type { MonteCarloWorkerProgress } from "../simulation/monte-carlo/worker/messages";
+import type { SDCPN } from "../types/sdcpn";
+import type { GpuOdeMethod } from "./compile-net-shader";
+
+export type CreateGpuMonteCarloExperimentConfig = {
+ sdcpn: SDCPN;
+ /** Compiled artifacts for this net, carrying the HIR the shader is built from. */
+ hirArtifacts: HirArtifacts;
+ extensions?: PetrinautExtensionSettings;
+ initialMarking: InitialMarking;
+ parameterValues: Record;
+ seed: number;
+ dt: number;
+ maxTime: number;
+ runCount: number;
+ metricSpecs: readonly MonteCarloMetricSpec[];
+ /** Defaults to RK4 — see `backend.ts` for why that is not Euler. */
+ odeMethod?: GpuOdeMethod;
+ /**
+ * Called with problems only detectable once the run has finished — today, a
+ * histogram whose top bin saturated. The `warnings` returned at creation are
+ * assembled before the run and cannot carry these, and without a channel the
+ * results would be presented as fact.
+ */
+ onWarning?: (warning: string) => void;
+};
+
+export type CreateGpuMonteCarloExperimentResult =
+ | {
+ supported: true;
+ handle: MonteCarloExperiment;
+ /** Adapter description, for recording which device ran the experiment. */
+ deviceInfo: string;
+ /** Notes that did not prevent the run, surfaced to the user. */
+ warnings: string[];
+ }
+ | {
+ supported: false;
+ reason: string;
+ /**
+ * Which gate refused, so a caller can classify without parsing `reason`.
+ *
+ * `requestGpuExperimentBackend` already determines this and used to discard
+ * it here; `metrics-unsupported` is the one case it cannot see, because the
+ * metric gate runs before the backend is asked.
+ */
+ cause:
+ | "no-device"
+ | "net-unsupported"
+ | "shader-generation"
+ | "metrics-unsupported";
+ };
+
+/** Progress with no runs advanced yet. */
+function initialProgress(runCount: number): MonteCarloWorkerProgress {
+ return {
+ activeRuns: runCount,
+ advancedRuns: 0,
+ allFinished: false,
+ completedRuns: 0,
+ erroredRuns: 0,
+ frameNumber: 0,
+ runCount,
+ time: 0,
+ };
+}
+
+/**
+ * Prepares a GPU-backed experiment, or explains why it is not possible.
+ */
+export async function createGpuMonteCarloExperiment(
+ config: CreateGpuMonteCarloExperimentConfig,
+): Promise {
+ const gpuMetrics = toGpuMetricSpecs(config.metricSpecs);
+ if (!gpuMetrics.ok) {
+ return {
+ supported: false,
+ cause: "metrics-unsupported",
+ reason: gpuMetrics.reason,
+ };
+ }
+
+ const backend = await requestGpuExperimentBackend({
+ sdcpn: config.sdcpn,
+ hirArtifacts: config.hirArtifacts,
+ extensions: config.extensions,
+ parameterValues: config.parameterValues,
+ dt: config.dt,
+ metrics: gpuMetrics.metrics,
+ odeMethod: config.odeMethod ?? "rk4",
+ initialMarking: config.initialMarking,
+ });
+ if (!backend.supported) {
+ return {
+ supported: false,
+ cause: backend.cause,
+ reason: backend.reason,
+ };
+ }
+
+ const frameLimit = Math.max(1, Math.round(config.maxTime / config.dt));
+ const status = createReadableStore("Initializing");
+ const progress = createReadableStore(null);
+ const metrics = createReadableStore(createEmptyMetricsState());
+ const events = createEventStream();
+
+ let disposed = false;
+ let running = false;
+ let aborted = false;
+ // A minimal `AbortSignalLike`: the runner only reads `aborted`, and building
+ // a real AbortController would pull a DOM global into this package.
+ const signal = {
+ get aborted() {
+ return aborted;
+ },
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ };
+
+ // Uncoloured places carry a plain count; a typed place's initial marking is an
+ // array of token records whose length is the count the shader needs.
+ const placeCounts = backend.profile.places.map((place) => {
+ const marking = config.initialMarking[place.id];
+ if (typeof marking === "number") {
+ return marking;
+ }
+ return Array.isArray(marking) ? marking.length : 0;
+ });
+
+ progress.set(initialProgress(config.runCount));
+ status.set("Ready");
+
+ const finish = (outcome: "complete" | "cancelled") => {
+ running = false;
+ const finalProgress = progress.get() ?? initialProgress(config.runCount);
+ if (outcome === "complete") {
+ status.set("Complete");
+ events.emit({ type: "complete", progress: finalProgress });
+ } else {
+ status.set("Cancelled");
+ events.emit({ type: "cancelled", progress: finalProgress });
+ }
+ };
+
+ const fail = (message: string) => {
+ running = false;
+ status.set("Error");
+ events.emit({ type: "error", message, itemId: null });
+ };
+
+ const run = async () => {
+ const outcome = await runGpuExperiment(backend.handle, backend.shader, {
+ runCount: config.runCount,
+ frameLimit,
+ framesPerDispatch: backend.framesPerDispatch,
+ seed: config.seed,
+ initial: { placeCounts },
+ signal,
+ onChunk: ({ framesDone }) => {
+ if (disposed) {
+ return;
+ }
+ progress.set({
+ activeRuns: config.runCount,
+ advancedRuns: config.runCount,
+ allFinished: false,
+ completedRuns: 0,
+ erroredRuns: 0,
+ frameNumber: framesDone,
+ runCount: config.runCount,
+ time: framesDone * config.dt,
+ });
+ },
+ });
+
+ if (disposed) {
+ return;
+ }
+ if (!outcome.ok) {
+ fail(outcome.reason);
+ return;
+ }
+
+ // Metric frames arrive in one batch rather than streaming: the histogram is
+ // read back once, after the dispatches. Streaming would add a readback per
+ // chunk to shave milliseconds off a run that takes single-digit
+ // milliseconds in total.
+ if (outcome.result.saturatedSamples > 0) {
+ // The top bin saturates, so the distribution is wrong above it. Saying so
+ // beats presenting a clipped distribution as a result.
+ config.onWarning?.(
+ `${outcome.result.saturatedSamples} samples reached the histogram's largest bin (${GPU_HISTOGRAM_BINS - 1} tokens) and were clamped there, so values above it are not accurate. Run on the CPU for an exact distribution.`,
+ );
+ }
+
+ metrics.set(
+ appendMetricFrames(
+ metrics.get(),
+ toGpuMetricFrames(outcome.result.frames, config.metricSpecs, config.dt),
+ ),
+ );
+ progress.set({
+ activeRuns: 0,
+ advancedRuns: config.runCount,
+ allFinished: !outcome.result.cancelled,
+ completedRuns: outcome.result.completedRuns,
+ erroredRuns: 0,
+ frameNumber: frameLimit,
+ runCount: config.runCount,
+ time: frameLimit * config.dt,
+ });
+
+ finish(outcome.result.cancelled ? "cancelled" : "complete");
+ };
+
+ const handle: MonteCarloExperiment = {
+ status,
+ progress,
+ metrics,
+ events,
+ start() {
+ if (disposed || running) {
+ return;
+ }
+ running = true;
+ status.set("Running");
+ void run().catch((error: unknown) => {
+ if (disposed) {
+ return;
+ }
+ fail(
+ error instanceof Error
+ ? error.message
+ : "Unknown error during GPU computation",
+ );
+ });
+ },
+ cancel() {
+ if (disposed || !running) {
+ return;
+ }
+ aborted = true;
+ // The in-flight chunk still completes; `run` reports the cancellation once
+ // the runner returns, so status is not set optimistically here.
+ },
+ dispose() {
+ if (disposed) {
+ return;
+ }
+ aborted = true;
+ disposed = true;
+ running = false;
+ backend.handle.device.destroy();
+ },
+ };
+
+ return {
+ supported: true,
+ handle,
+ deviceInfo: backend.handle.info,
+ warnings: backend.warnings,
+ };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment.test.ts
new file mode 100644
index 00000000000..3a7a3ea7ede
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment.test.ts
@@ -0,0 +1,178 @@
+import { describe, expect, it } from "vitest";
+
+import { sirModel } from "../examples/sir-model";
+import { compileHirArtifacts } from "../hir";
+import { runGpuMonteCarloExperiment } from "./gpu-experiment";
+
+import type { MonteCarloMetricSpec } from "../simulation/monte-carlo/metrics";
+
+const sir = sirModel.petriNetDefinition;
+
+const baseConfig = {
+ sdcpn: sir,
+ hirArtifacts: compileHirArtifacts(sir, undefined, { includeHir: true })
+ .artifacts,
+ initialMarking: {
+ place__susceptible: 100,
+ place__infected: 5,
+ place__recovered: 0,
+ },
+ parameterValues: {},
+ seed: 1,
+ dt: 0.1,
+ maxTime: 10,
+ runCount: 64,
+};
+
+/**
+ * These cover the refusals that happen before a device is touched, so they run
+ * without a GPU. The paths that need one are exercised by
+ * `benchmarks/webgpu-vs-cpu.html` against a real adapter.
+ */
+describe("runGpuMonteCarloExperiment gating", () => {
+ it("refuses a metric kind it cannot measure rather than substituting one", async () => {
+ const metricSpecs: MonteCarloMetricSpec[] = [
+ {
+ kind: "transitionFiringCount",
+ id: "f",
+ label: "Firings",
+ transitionId: "transition__infection",
+ },
+ ];
+
+ const outcome = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ metricSpecs,
+ });
+
+ expect(outcome.ran).toBe(false);
+ if (outcome.ran) return;
+ expect(outcome.reason).toContain("can only measure place token counts");
+ });
+
+ it("refuses time aggregation rather than reporting it as absent", async () => {
+ // Silently returning `aggregateTime: "none"` would make the chart look
+ // right while showing per-frame values where the user asked for a running
+ // aggregate.
+ const metricSpecs: MonteCarloMetricSpec[] = [
+ {
+ kind: "placeTokenCountMean",
+ id: "i",
+ label: "I",
+ placeId: "place__infected",
+ aggregateTime: "mean",
+ },
+ ];
+
+ const outcome = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ metricSpecs,
+ });
+
+ expect(outcome.ran).toBe(false);
+ if (outcome.ran) return;
+ expect(outcome.reason).toContain("does not aggregate metrics over time");
+ });
+
+ it("names the offending place when a typed place has no capacity", async () => {
+ const typed = {
+ ...sir,
+ types: [
+ {
+ id: "c",
+ name: "Item",
+ iconSlug: "circle",
+ displayColor: "#0f0",
+ elements: [{ elementId: "v", name: "v", type: "real" as const }],
+ },
+ ],
+ places: sir.places.map((place, index) =>
+ index === 0 ? { ...place, colorId: "c" } : place,
+ ),
+ };
+
+ const outcome = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ sdcpn: typed,
+ hirArtifacts: compileHirArtifacts(typed, undefined, { includeHir: true })
+ .artifacts,
+ metricSpecs: [],
+ });
+
+ expect(outcome.ran).toBe(false);
+ if (outcome.ran) return;
+ // A reason that does not name the place is not actionable.
+ expect(outcome.reason).toContain("Susceptible");
+ expect(outcome.reason).toMatch(/capacity/i);
+ });
+});
+
+/**
+ * Metrics are reduced on the device into a histogram with one bin per integer
+ * token count, and the shader clamps that index into range. A place holding more
+ * tokens than there are bins therefore reads as the ceiling — a flat line where
+ * the CPU shows a declining trajectory. Refusing beats reporting that.
+ */
+describe("histogram range gating", () => {
+ const susceptibleMean: MonteCarloMetricSpec[] = [
+ {
+ kind: "placeTokenCountMean",
+ id: "s",
+ label: "Susceptible tokens",
+ placeId: "place__susceptible",
+ },
+ ];
+
+ it("refuses a net whose sampled place starts beyond the histogram's range", async () => {
+ const outcome = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ // 1000 tokens against 256 bins: every sample lands in the top bin until the
+ // count falls below it, which is exactly the flat-then-cliff artefact.
+ initialMarking: {
+ ...baseConfig.initialMarking,
+ place__susceptible: 1000,
+ },
+ metricSpecs: susceptibleMean,
+ });
+
+ expect(outcome.ran).toBe(false);
+ expect(outcome.ran ? "" : outcome.reason).toMatch(
+ /starts with 1000 tokens.*256 bins/s,
+ );
+ });
+
+ it("refuses at the ceiling, not one past it", async () => {
+ // Bin indices run 0..255, so 256 tokens is already unrepresentable. Asserting
+ // on the reason rather than on `ran`: with no GPU in this environment every
+ // path ends up refusing, so `ran === false` would pass either way.
+ const atCeiling = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ initialMarking: { ...baseConfig.initialMarking, place__susceptible: 256 },
+ metricSpecs: susceptibleMean,
+ });
+ expect(atCeiling.ran ? "" : atCeiling.reason).toMatch(/histogram/);
+
+ // 255 fits, so this must get past the range check — it then stops for want of
+ // a GPU device, which is a different refusal.
+ const belowCeiling = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ initialMarking: { ...baseConfig.initialMarking, place__susceptible: 255 },
+ metricSpecs: susceptibleMean,
+ });
+ expect(belowCeiling.ran ? "" : belowCeiling.reason).not.toMatch(
+ /histogram/,
+ );
+ });
+
+ it("ignores places no metric samples", async () => {
+ // `Recovered` accumulates well past the ceiling, but nothing measures it, so
+ // it has no bearing on whether the histogram can represent the results.
+ const outcome = await runGpuMonteCarloExperiment({
+ ...baseConfig,
+ initialMarking: { ...baseConfig.initialMarking, place__recovered: 5000 },
+ metricSpecs: susceptibleMean,
+ });
+
+ expect(outcome.ran ? "" : outcome.reason).not.toMatch(/histogram/);
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment.ts
new file mode 100644
index 00000000000..fd79c66b569
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment.ts
@@ -0,0 +1,115 @@
+/**
+ * One-shot GPU experiment: run it, get the frames back.
+ *
+ * The React provider uses `gpu-experiment-handle.ts` instead, which wraps the
+ * same machinery in the observable `MonteCarloExperiment` contract. This flatter
+ * API exists for benchmarks, scripts and tests that want the result without a
+ * store to subscribe to.
+ */
+import { requestGpuExperimentBackend } from "./backend";
+import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames";
+import { runGpuExperiment } from "./runner";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirArtifacts } from "../hir-runtime";
+import type { InitialMarking } from "../simulation/api";
+import type {
+ MonteCarloMetricSpec,
+ MonteCarloUserDefinedMetricFrame,
+} from "../simulation/monte-carlo/metrics";
+import type { SDCPN } from "../types/sdcpn";
+
+export type GpuExperimentConfig = {
+ sdcpn: SDCPN;
+ /** Compiled artifacts for this net, carrying the HIR the shader is built from. */
+ hirArtifacts: HirArtifacts;
+ extensions?: PetrinautExtensionSettings;
+ initialMarking: InitialMarking;
+ parameterValues: Record;
+ seed: number;
+ dt: number;
+ maxTime: number;
+ runCount: number;
+ metricSpecs: readonly MonteCarloMetricSpec[];
+};
+
+export type GpuExperimentOutcome =
+ | {
+ ran: true;
+ frames: MonteCarloUserDefinedMetricFrame[];
+ dispatchMs: number;
+ deviceInfo: string;
+ /** Non-fatal notes, e.g. histogram saturation. */
+ warnings: string[];
+ }
+ | { ran: false; reason: string };
+
+/**
+ * Runs one experiment on the GPU, or reports why it could not.
+ *
+ * Never throws for an unsupported net or a missing device — the caller is
+ * expected to fall back to the CPU and surface `reason`.
+ */
+export async function runGpuMonteCarloExperiment(
+ config: GpuExperimentConfig,
+ onProgress?: (progress: { framesDone: number; frameLimit: number }) => void,
+): Promise {
+ const metrics = toGpuMetricSpecs(config.metricSpecs);
+ if (!metrics.ok) {
+ return { ran: false, reason: metrics.reason };
+ }
+
+ const backend = await requestGpuExperimentBackend({
+ sdcpn: config.sdcpn,
+ hirArtifacts: config.hirArtifacts,
+ extensions: config.extensions,
+ parameterValues: config.parameterValues,
+ dt: config.dt,
+ metrics: metrics.metrics,
+ odeMethod: "rk4",
+ initialMarking: config.initialMarking,
+ });
+ if (!backend.supported) {
+ return { ran: false, reason: backend.reason };
+ }
+
+ // Uncoloured places take a plain count; a typed place's initial marking is an
+ // array of token records, whose length is the count the shader needs.
+ const placeCounts = backend.profile.places.map((place) => {
+ const marking = config.initialMarking[place.id];
+ if (typeof marking === "number") {
+ return marking;
+ }
+ return Array.isArray(marking) ? marking.length : 0;
+ });
+
+ const frameLimit = Math.max(1, Math.round(config.maxTime / config.dt));
+ const run = await runGpuExperiment(backend.handle, backend.shader, {
+ runCount: config.runCount,
+ frameLimit,
+ framesPerDispatch: backend.framesPerDispatch,
+ seed: config.seed,
+ initial: { placeCounts },
+ onChunk: onProgress,
+ });
+ if (!run.ok) {
+ return { ran: false, reason: run.reason };
+ }
+
+ const warnings = [...backend.warnings];
+ if (run.result.saturatedSamples > 0) {
+ // The top histogram bin saturates, so the distribution's tail is wrong past
+ // it. Saying so beats presenting a clipped distribution as fact.
+ warnings.push(
+ `${run.result.saturatedSamples} samples exceeded the histogram's largest bin and were clamped, so the upper tail of this distribution is not accurate.`,
+ );
+ }
+
+ return {
+ ran: true,
+ frames: toGpuMetricFrames(run.result.frames, config.metricSpecs, config.dt),
+ dispatchMs: run.result.dispatchMs,
+ deviceInfo: backend.handle.info,
+ warnings,
+ };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts
new file mode 100644
index 00000000000..926839a55b6
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts
@@ -0,0 +1,159 @@
+/**
+ * Translating between metric specs and the GPU's on-device histograms.
+ *
+ * Shared by the one-shot API (`gpu-experiment.ts`) and the handle
+ * (`gpu-experiment-handle.ts`) so a spec accepted by one is accepted by the
+ * other, and both produce byte-identical frames.
+ */
+import type {
+ MonteCarloMetricSpec,
+ MonteCarloUserDefinedMetricFrame,
+} from "../simulation/monte-carlo/metrics";
+import type { GpuMetricSpec } from "./compile-net-shader";
+import type { GpuHistogramFrame } from "./runner";
+
+export type GpuMetricSpecsResult =
+ | { ok: true; metrics: GpuMetricSpec[] }
+ | { ok: false; reason: string };
+
+/**
+ * Validates metric specs against what the shader can measure.
+ *
+ * Only place-token-count metrics are served: the shader samples a place's count
+ * into a histogram. Expression metrics would need the metric HIR surface
+ * compiled to WGSL too, and transition-firing metrics need a different sample
+ * source. Both are follow-on work, and a spec asking for them is refused so the
+ * caller falls back to the CPU rather than being shown a different measurement
+ * than it asked for.
+ */
+export function toGpuMetricSpecs(
+ specs: readonly MonteCarloMetricSpec[],
+): GpuMetricSpecsResult {
+ const metrics: GpuMetricSpec[] = [];
+
+ for (const spec of specs) {
+ if (spec.kind !== "placeTokenCountMean") {
+ return {
+ ok: false,
+ reason: `The GPU backend can only measure place token counts; metric "${spec.label}" is a ${spec.kind} metric instead.`,
+ };
+ }
+ if (spec.aggregateTime !== undefined && spec.aggregateTime !== "none") {
+ // Returning `aggregateTime: "none"` would make the chart look right while
+ // plotting per-frame values where a running aggregate was asked for.
+ return {
+ ok: false,
+ reason: `The GPU backend does not aggregate metrics over time yet; metric "${spec.label}" uses a time aggregation.`,
+ };
+ }
+ metrics.push({ id: spec.id, placeId: spec.placeId });
+ }
+
+ return { ok: true, metrics };
+}
+
+/**
+ * Rebuilds one metric frame from a GPU histogram.
+ *
+ * Distribution metrics use the bins directly. Scalar metrics reduce from the
+ * histogram, which is exact for mean/sum/min/max because a histogram of integer
+ * counts loses nothing the run-axis aggregation would have used.
+ */
+function toMetricFrame(
+ histogram: GpuHistogramFrame,
+ spec: Extract,
+ dt: number,
+): MonteCarloUserDefinedMetricFrame {
+ const time = histogram.frameNumber * dt;
+
+ if (spec.runOutput?.type === "distribution") {
+ return {
+ metricId: spec.id,
+ label: spec.label,
+ outputType: "distribution",
+ frameNumber: histogram.frameNumber,
+ time,
+ value: null,
+ frameValue: null,
+ timeValue: null,
+ bins: histogram.bins,
+ runSampleCount: histogram.sampleCount,
+ timeSampleCount: histogram.sampleCount,
+ };
+ }
+
+ let count = 0;
+ let sum = 0;
+ let min: number | null = null;
+ let max: number | null = null;
+ let last: number | null = null;
+ for (const [value, frequency] of histogram.bins) {
+ count += frequency;
+ sum += value * frequency;
+ min = min === null ? value : Math.min(min, value);
+ max = max === null ? value : Math.max(max, value);
+ last = value;
+ }
+
+ // `aggregateRuns` only exists on the scalar variant of `runOutput`.
+ const aggregateRuns =
+ (spec.runOutput?.type === "scalar"
+ ? spec.runOutput.aggregateRuns
+ : undefined) ??
+ spec.aggregateRuns ??
+ "mean";
+ const frameValue =
+ count === 0
+ ? null
+ : aggregateRuns === "mean"
+ ? sum / count
+ : aggregateRuns === "sum"
+ ? sum
+ : aggregateRuns === "min"
+ ? min
+ : aggregateRuns === "max"
+ ? max
+ : last;
+
+ return {
+ metricId: spec.id,
+ label: spec.label,
+ outputType: "scalar",
+ frameNumber: histogram.frameNumber,
+ time,
+ value: frameValue,
+ frameValue,
+ // Time aggregation is refused by `toGpuMetricSpecs`, so it is always absent.
+ timeValue: null,
+ runSampleCount: count,
+ timeSampleCount: count,
+ // Carried so a GPU frame merges through the same monoid as a CPU frame.
+ runAggregate: { count, sum, min, max, last },
+ aggregateRuns,
+ aggregateTime: "none",
+ };
+}
+
+/**
+ * Converts every histogram frame, in frame order.
+ *
+ * Histograms whose metric id is not in `specs` are dropped rather than guessed
+ * at; that can only happen if the shader and the spec list disagree, which
+ * would be a bug worth failing quietly over rather than mislabelling.
+ */
+export function toGpuMetricFrames(
+ histograms: readonly GpuHistogramFrame[],
+ specs: readonly MonteCarloMetricSpec[],
+ dt: number,
+): MonteCarloUserDefinedMetricFrame[] {
+ const specById = new Map(
+ specs.flatMap((spec) =>
+ spec.kind === "placeTokenCountMean" ? [[spec.id, spec] as const] : [],
+ ),
+ );
+
+ return histograms.flatMap((histogram) => {
+ const spec = specById.get(histogram.metricId);
+ return spec ? [toMetricFrame(histogram, spec, dt)] : [];
+ });
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.test.ts
new file mode 100644
index 00000000000..4c128d866ca
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from "vitest";
+
+import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher";
+import { sirModel } from "../examples/sir-model";
+import { supplyChainWithDisruption } from "../examples/supply-chain-with-disruption";
+import { compileHirArtifacts } from "../hir";
+import { hirFromArtifacts } from "./hir-from-artifacts";
+
+import type { HirArtifacts } from "../hir-runtime";
+
+const sir = sirModel.petriNetDefinition;
+
+describe("hirFromArtifacts", () => {
+ it("reads lambda HIR straight from compiled artifacts", () => {
+ // The point of carrying HIR on the artifact: the GPU backend gets it without
+ // running the TypeScript frontend, which cannot be bundled for the browser.
+ const { artifacts } = compileHirArtifacts(sir, undefined, {
+ includeHir: true,
+ });
+ const result = hirFromArtifacts(sir, artifacts);
+
+ expect([...result.lambdas.keys()].sort()).toStrictEqual([
+ "transition__infection",
+ "transition__recovery",
+ ]);
+ expect(result.skipped).toStrictEqual([]);
+ // The HIR must be the real tree, not a placeholder.
+ expect(result.lambdas.get("transition__infection")?.surface).toBe("lambda");
+ });
+
+ it("keys dynamics by place, though artifacts key them by equation", () => {
+ const net = supplyChainWithDisruption.petriNetDefinition;
+ const { artifacts } = compileHirArtifacts(net, undefined, {
+ includeHir: true,
+ });
+ const result = hirFromArtifacts(net, artifacts);
+
+ expect(result.dynamics.size).toBeGreaterThan(0);
+ for (const placeId of result.dynamics.keys()) {
+ const place = net.places.find((candidate) => candidate.id === placeId);
+ // Every key is a place id, and that place really does have dynamics.
+ expect(place?.dynamicsEnabled).toBe(true);
+ }
+ expect(result.dynamics.get([...result.dynamics.keys()][0]!)?.surface).toBe(
+ "dynamics",
+ );
+ });
+
+ it("reports an artifact carrying no HIR rather than silently omitting it", () => {
+ const { artifacts } = compileHirArtifacts(sir, undefined, {
+ includeHir: true,
+ });
+ // Simulate an artifact produced before HIR was carried.
+ const stripped: HirArtifacts = {
+ ...artifacts,
+ lambdas: Object.fromEntries(
+ Object.entries(artifacts.lambdas).map(([id, artifact]) => [
+ id,
+ { source: artifact.source, inputSlotCount: artifact.inputSlotCount },
+ ]),
+ ),
+ };
+
+ const result = hirFromArtifacts(sir, stripped);
+
+ expect(result.lambdas.size).toBe(0);
+ expect(result.skipped.map((entry) => entry.itemId).sort()).toStrictEqual([
+ "transition__infection",
+ "transition__recovery",
+ ]);
+ expect(result.skipped[0]?.reason).toMatch(/no HIR/i);
+ });
+
+ it("skips lambdas when stochasticity is disabled, matching the CPU engine", () => {
+ // With stochasticity off the CPU engine installs the always-enabled default
+ // instead of compiling lambdas; the GPU path has to agree.
+ const { artifacts } = compileHirArtifacts(sir, undefined, {
+ includeHir: true,
+ });
+ const result = hirFromArtifacts(sir, artifacts, {
+ colors: true,
+ stochasticity: false,
+ dynamics: true,
+ parameters: true,
+ subnets: true,
+ });
+
+ expect(result.lambdas.size).toBe(0);
+ // Not a problem to report — it is the configured behaviour.
+ expect(result.skipped).toStrictEqual([]);
+ });
+
+ it("skips dynamics when the dynamics extension is disabled", () => {
+ const net = supplyChainWithDisruption.petriNetDefinition;
+ const { artifacts } = compileHirArtifacts(net, undefined, {
+ includeHir: true,
+ });
+ const result = hirFromArtifacts(net, artifacts, {
+ colors: true,
+ stochasticity: true,
+ dynamics: false,
+ parameters: true,
+ subnets: true,
+ });
+
+ expect(result.dynamics.size).toBe(0);
+ expect(result.skipped).toStrictEqual([]);
+ });
+
+ it("ignores a transition whose lambda never compiled", () => {
+ // A lambda that fails to compile has no artifact at all. That failure is
+ // already reported through the compile-failure path, so it is not repeated
+ // here as a GPU-specific warning.
+ const { artifacts } = compileHirArtifacts(sir, undefined, {
+ includeHir: true,
+ });
+ const withoutOne: HirArtifacts = {
+ ...artifacts,
+ lambdas: Object.fromEntries(
+ Object.entries(artifacts.lambdas).filter(
+ ([id]) => id !== "transition__recovery",
+ ),
+ ),
+ };
+
+ const result = hirFromArtifacts(sir, withoutOne);
+
+ expect([...result.lambdas.keys()]).toStrictEqual(["transition__infection"]);
+ expect(result.skipped).toStrictEqual([]);
+ });
+});
+
+/**
+ * Kernels only exist where a transition has a *typed* output place —
+ * `isTransitionKernelAvailable` in extensions.ts gates on exactly that — so the
+ * uncoloured SIR net has none, and these use a coloured net instead.
+ */
+describe("hirFromArtifacts kernels", () => {
+ const coloured = probabilisticSatellitesSDCPN.petriNetDefinition;
+
+ it("collects kernel HIR keyed by transition", () => {
+ const { artifacts } = compileHirArtifacts(coloured, undefined, {
+ includeHir: true,
+ });
+ const result = hirFromArtifacts(coloured, artifacts);
+
+ expect(result.kernels.size).toBeGreaterThan(0);
+ for (const [transitionId, hir] of result.kernels) {
+ expect(
+ coloured.transitions.some(
+ (transition) => transition.id === transitionId,
+ ),
+ ).toBe(true);
+ // The real lowered tree, and it knows which surface it came from.
+ expect(hir.surface).toBe("kernel");
+ }
+ });
+
+ it("reports a kernel artifact carrying no HIR", () => {
+ const { artifacts } = compileHirArtifacts(coloured, undefined, {
+ includeHir: true,
+ });
+ const stripped: HirArtifacts = {
+ ...artifacts,
+ kernels: Object.fromEntries(
+ Object.entries(artifacts.kernels).map(([id, artifact]) => [
+ id,
+ {
+ source: artifact.source,
+ inputSlotCount: artifact.inputSlotCount,
+ outputByteCount: artifact.outputByteCount,
+ },
+ ]),
+ ),
+ };
+
+ const result = hirFromArtifacts(coloured, stripped);
+
+ expect(result.kernels.size).toBe(0);
+ expect(result.skipped.map((entry) => entry.reason)).toContain(
+ "its compiled kernel artifact carries no HIR, so it cannot be translated to a shader",
+ );
+ });
+
+ it("carries kernel HIR only when asked, since it is not free", () => {
+ // Artifacts are structured-cloned to every shard worker, so the default must
+ // stay lean — the same reason lambdas and dynamics gate on this flag.
+ const withoutHir = compileHirArtifacts(coloured).artifacts;
+
+ for (const artifact of Object.values(withoutHir.kernels)) {
+ expect(artifact.hir).toBeUndefined();
+ }
+ expect(Object.keys(withoutHir.kernels).length).toBeGreaterThan(0);
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts b/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts
new file mode 100644
index 00000000000..da6a502fdbb
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts
@@ -0,0 +1,124 @@
+/**
+ * Reads the HIR the GPU backend needs out of already-compiled artifacts.
+ *
+ * The alternative — lowering the net's TypeScript here — is what
+ * `lower-net-hir.ts` does, and it cannot be used from the browser: it pulls in
+ * the TypeScript frontend, whose Node builtins break the frontend bundle
+ * outright (`Module not found: Can't resolve 'module'`). Artifacts are produced
+ * in the language worker, which has the compiler, and carry the HIR alongside the
+ * emitted JavaScript, so the browser only reads.
+ *
+ * Deliberately dependency-free beyond types, for the same reason.
+ */
+import { DEFAULT_PETRINAUT_EXTENSIONS } from "../extensions";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirArtifacts } from "../hir-runtime";
+import type { HirFunction } from "../hir/hir";
+import type { SDCPN } from "../types/sdcpn";
+
+export type NetHir = {
+ /** Transition id → lambda HIR, for transitions with a compiled lambda. */
+ lambdas: Map;
+ /** Place id → dynamics HIR, for places with dynamics enabled. */
+ dynamics: Map;
+ /**
+ * Transition id → kernel HIR, for transitions with a compiled kernel.
+ *
+ * Collected but not yet emitted: the shader's fire block only adjusts token
+ * counts, so a kernel's output attributes are not written. Carrying the HIR is
+ * the prerequisite for that, and lets the compilation report say whether a
+ * kernel *could* be translated rather than only that it is unsupported.
+ */
+ kernels: Map;
+ /** Items whose artifact carried no HIR, with why it matters. Non-fatal. */
+ skipped: { itemId: string; reason: string }[];
+};
+
+/**
+ * Collects per-item HIR for `sdcpn` from `artifacts`.
+ *
+ * An artifact without HIR is skipped rather than fatal: the shader generator
+ * treats a missing lambda as always-enabled, which is what the CPU engine does
+ * for a transition with no compiled lambda. Extensions are honoured so a net run
+ * with stochasticity or dynamics disabled compiles the same surfaces the CPU
+ * engine would.
+ */
+export function hirFromArtifacts(
+ sdcpn: SDCPN,
+ artifacts: HirArtifacts,
+ extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS,
+): NetHir {
+ const lambdas = new Map();
+ const dynamics = new Map();
+ const kernels = new Map();
+ const skipped: { itemId: string; reason: string }[] = [];
+
+ for (const transition of sdcpn.transitions) {
+ if (!extensions.stochasticity || transition.lambdaCode.trim() === "") {
+ continue;
+ }
+ const artifact = artifacts.lambdas[transition.id];
+ if (!artifact) {
+ // The lambda did not compile for the CPU either, so its absence is
+ // already reported through the usual compile-failure path.
+ continue;
+ }
+ if (!artifact.hir) {
+ skipped.push({
+ itemId: transition.id,
+ reason:
+ "its compiled artifact carries no HIR, so it cannot be translated to a shader",
+ });
+ continue;
+ }
+ lambdas.set(transition.id, artifact.hir);
+ }
+
+ for (const transition of sdcpn.transitions) {
+ if (transition.transitionKernelCode.trim() === "") {
+ continue;
+ }
+ const artifact = artifacts.kernels[transition.id];
+ if (!artifact) {
+ // Did not compile for the CPU either; reported through that path.
+ continue;
+ }
+ if (!artifact.hir) {
+ skipped.push({
+ itemId: transition.id,
+ reason:
+ "its compiled kernel artifact carries no HIR, so it cannot be translated to a shader",
+ });
+ continue;
+ }
+ kernels.set(transition.id, artifact.hir);
+ }
+
+ for (const place of sdcpn.places) {
+ if (
+ !extensions.dynamics ||
+ !place.dynamicsEnabled ||
+ !place.differentialEquationId ||
+ !place.colorId
+ ) {
+ continue;
+ }
+ // Dynamics artifacts are keyed by differential equation, not by place.
+ const artifact = artifacts.dynamics[place.differentialEquationId];
+ if (!artifact) {
+ continue;
+ }
+ if (!artifact.hir) {
+ skipped.push({
+ itemId: place.id,
+ reason:
+ "its differential equation's artifact carries no HIR, so it cannot be translated to a shader",
+ });
+ continue;
+ }
+ dynamics.set(place.id, artifact.hir);
+ }
+
+ return { lambdas, dynamics, kernels, skipped };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/lower-net-hir.ts b/libs/@hashintel/petrinaut-core/src/webgpu/lower-net-hir.ts
new file mode 100644
index 00000000000..cfa526b2caf
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/lower-net-hir.ts
@@ -0,0 +1,116 @@
+/**
+ * Re-lowers a net's user code to HIR for the WGSL backend.
+ *
+ * `HirArtifacts` stores *emitted JavaScript source*, not the HIR tree
+ * (`../hir/instantiate.ts`), so the WGSL backend cannot reuse them — it needs the
+ * tree. Rather than widen the artifact format and make every consumer carry a
+ * second program, the GPU path lowers again from the net's own code. Lowering is
+ * cheap relative to a dispatch and happens once per experiment.
+ */
+import { DEFAULT_PETRINAUT_EXTENSIONS } from "../extensions";
+import { lowerTypeScriptToHir } from "../hir/lower-typescript";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirFunction } from "../hir/hir";
+import type { SDCPN } from "../types/sdcpn";
+
+export type LoweredNetHir = {
+ /** Transition id → lambda HIR, for transitions with lambda code. */
+ lambdas: Map;
+ /** Place id → dynamics HIR, for places with dynamics enabled. */
+ dynamics: Map;
+ /** Transition id → kernel HIR, for transitions with kernel code. */
+ kernels: Map;
+ /** Items whose code would not lower, with the reason. Non-fatal. */
+ skipped: { itemId: string; reason: string }[];
+};
+
+/**
+ * Lowers every lambda and differential equation in `sdcpn`.
+ *
+ * A lambda that fails to lower is omitted rather than throwing: the shader
+ * generator treats a missing lambda as always-enabled, which matches the CPU
+ * engine's default for transitions without compiled lambdas.
+ */
+export function lowerNetHir(
+ sdcpn: SDCPN,
+ extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS,
+): LoweredNetHir {
+ const lambdas = new Map();
+ const dynamics = new Map();
+ const kernels = new Map();
+ const skipped: { itemId: string; reason: string }[] = [];
+
+ for (const transition of sdcpn.transitions) {
+ // Stochasticity off means the CPU engine installs the always-enabled
+ // default instead of compiling lambdas; match that.
+ if (!extensions.stochasticity || transition.lambdaCode.trim() === "") {
+ continue;
+ }
+ const lowered = lowerTypeScriptToHir(transition.lambdaCode, "lambda");
+ if (lowered.ok) {
+ lambdas.set(transition.id, lowered.fn);
+ } else {
+ skipped.push({
+ itemId: transition.id,
+ reason:
+ lowered.diagnostics[0]?.message ??
+ "lambda could not be lowered to HIR",
+ });
+ }
+ }
+
+ for (const transition of sdcpn.transitions) {
+ if (
+ !extensions.colors ||
+ transition.transitionKernelCode.trim() === ""
+ ) {
+ continue;
+ }
+ const lowered = lowerTypeScriptToHir(
+ transition.transitionKernelCode,
+ "kernel",
+ );
+ if (lowered.ok) {
+ kernels.set(transition.id, lowered.fn);
+ } else {
+ skipped.push({
+ itemId: transition.id,
+ reason:
+ lowered.diagnostics[0]?.message ??
+ "transition kernel could not be lowered to HIR",
+ });
+ }
+ }
+
+ const equationById = new Map(
+ sdcpn.differentialEquations.map((equation) => [equation.id, equation]),
+ );
+ for (const place of sdcpn.places) {
+ if (
+ !extensions.dynamics ||
+ !place.dynamicsEnabled ||
+ !place.differentialEquationId ||
+ !place.colorId
+ ) {
+ continue;
+ }
+ const equation = equationById.get(place.differentialEquationId);
+ if (!equation) {
+ continue;
+ }
+ const lowered = lowerTypeScriptToHir(equation.code, "dynamics");
+ if (lowered.ok) {
+ dynamics.set(place.id, lowered.fn);
+ } else {
+ skipped.push({
+ itemId: place.id,
+ reason:
+ lowered.diagnostics[0]?.message ??
+ "differential equation could not be lowered to HIR",
+ });
+ }
+ }
+
+ return { lambdas, dynamics, kernels, skipped };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts
new file mode 100644
index 00000000000..ee278dac64d
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from "vitest";
+
+import { enumerateWeightedMarkingIndicesGenerator } from "../simulation/engine/enumerate-weighted-markings";
+import {
+ emitPairScanWgsl,
+ pairCount,
+ rankPair,
+ selectFiringPair,
+ unrankPair,
+} from "./pair-selection";
+
+/** The engine's own pair order for one place, as the CPU would walk it. */
+function cpuPairs(tokenCount: number): [number, number][] {
+ return [
+ ...enumerateWeightedMarkingIndicesGenerator([
+ { count: tokenCount, weight: 2 },
+ ]),
+ ].map((combination) => {
+ const [pair] = combination;
+ return [pair![0]!, pair![1]!] as [number, number];
+ });
+}
+
+/**
+ * Deterministic pseudo-random predicate, so a failure is reproducible.
+ *
+ * Modular arithmetic rather than the usual xor-shift mixing, because the lint
+ * bans bitwise operators; the quality only has to be enough to scatter passes
+ * across the pair space.
+ */
+function makePredicate(seed: number): (i: number, j: number) => boolean {
+ return (i, j) => {
+ const mixed =
+ (seed * 1_103_515_245 + i * 12_347 + j * 6_781) % 2_147_483_647;
+ return mixed % 5 === 0;
+ };
+}
+
+describe("unrankPair", () => {
+ it("reproduces the engine's pair order exactly", () => {
+ // The whole scheme rests on this: the CPU fires on the *first* passing
+ // combination, so an ordering that merely covers the same pairs is not
+ // enough — index x must be the CPU's x-th pair.
+ for (const n of [2, 3, 4, 5, 8, 17, 64]) {
+ const expected = cpuPairs(n);
+ expect(pairCount(n)).toBe(expected.length);
+
+ const actual = Array.from({ length: pairCount(n) }, (_, x) =>
+ unrankPair(x, n),
+ );
+ expect(actual).toStrictEqual(expected);
+ }
+ });
+
+ it("has no pairs below two tokens", () => {
+ expect(pairCount(0)).toBe(0);
+ expect(pairCount(1)).toBe(0);
+ expect(cpuPairs(1)).toStrictEqual([]);
+ });
+
+ it("round-trips against rankPair", () => {
+ for (const n of [2, 7, 32, 256]) {
+ for (let x = 0; x < pairCount(n); x++) {
+ const [i, j] = unrankPair(x, n);
+ expect(rankPair(i, j, n)).toBe(x);
+ }
+ }
+ });
+
+ it("stays exact in f32, which is all WGSL has", () => {
+ // The closed form takes a square root. Simulating f32 rounding at every step
+ // shows it is exact well past the 256-token ceiling the metric histogram
+ // imposes; it first breaks at n = 5793.
+ const f32 = Math.fround;
+ const unrankF32 = (x: number, n: number): [number, number] => {
+ const a = f32(2 * n - 1);
+ const disc = f32(f32(a * a) - f32(8 * x));
+ const i = Math.floor(f32(f32(a - f32(Math.sqrt(disc))) / 2));
+ return [i, x - Math.floor((i * (2 * n - 1 - i)) / 2) + i + 1];
+ };
+
+ // Half a million `expect` calls cost seconds of harness time and made this
+ // test flake on the shared 5s timeout, so mismatches are collected and
+ // asserted once. Capped at a few so a systematic break stays readable.
+ const mismatches: string[] = [];
+ for (const n of [64, 256, 1024]) {
+ for (let x = 0; x < pairCount(n) && mismatches.length < 4; x++) {
+ const [i, j] = unrankF32(x, n);
+ const [wantI, wantJ] = unrankPair(x, n);
+ if (i !== wantI || j !== wantJ) {
+ mismatches.push(`n=${n} x=${x}: f32 (${i},${j}) != (${wantI},${wantJ})`);
+ }
+ }
+ }
+
+ expect(mismatches).toStrictEqual([]);
+ });
+});
+
+describe("selectFiringPair", () => {
+ it("picks the same pair the engine's loop would", () => {
+ // Against the engine's enumerator directly: walk it in order, take the first
+ // passing combination, and require the closed-form scan to agree.
+ for (const n of [2, 3, 5, 9, 16, 33]) {
+ for (let seed = 1; seed <= 40; seed++) {
+ const passes = makePredicate(seed * 7 + n);
+ const expected = cpuPairs(n).find(([i, j]) => passes(i, j)) ?? null;
+ const actual = selectFiringPair(n, passes);
+
+ if (expected === null) {
+ expect(actual).toBeNull();
+ } else {
+ expect([actual?.i, actual?.j]).toStrictEqual(expected);
+ }
+ }
+ }
+ });
+
+ it("takes the lowest index, not the largest lambda", () => {
+ // The distinction that matters. Pair 0 is (0,1) and pair 5 is (1,4) for n=5;
+ // a max-lambda rule would choose the later one, and consume different tokens.
+ const passing = new Set(["0,1", "1,4"]);
+ const chosen = selectFiringPair(5, (i, j) => passing.has(`${i},${j}`));
+
+ expect(chosen).toStrictEqual({ index: 0, i: 0, j: 1 });
+ });
+
+ it("reports nothing firing rather than a fallback pair", () => {
+ expect(selectFiringPair(8, () => false)).toBeNull();
+ // And a place that cannot supply two tokens has nothing to scan.
+ expect(selectFiringPair(1, () => true)).toBeNull();
+ });
+});
+
+describe("emitPairScanWgsl", () => {
+ const wgsl = (): string =>
+ emitPairScanWgsl({
+ tokenCountExpr: "counts[0u]",
+ emitAccepts: (first, second) => ({
+ statements: [`let d = distance(${first}, ${second});`],
+ expression: "d < 1.0",
+ }),
+ firedVar: "fires",
+ firstVar: "slot_a",
+ secondVar: "slot_b",
+ }).join("\n");
+
+ it("stops at the first passing pair rather than reducing over all of them", () => {
+ // A full min-reduction would visit every pair even when the first one fires,
+ // and the CPU stops — so the emitted loop must break.
+ expect(wgsl()).toMatch(/break;/);
+ });
+
+ it("places the acceptance statements inside the loop, per candidate", () => {
+ // A compiled lambda hoists its subexpressions into `let`s that read the
+ // candidate's attributes, so they must be re-evaluated for each pair rather
+ // than lifted out of the loop.
+ const emitted = wgsl();
+ const loopIndex = emitted.indexOf("for (var x: u32 = 0u;");
+ const statementIndex = emitted.indexOf("let d = distance(cand_i, cand_j);");
+
+ expect(loopIndex).toBeGreaterThan(-1);
+ expect(statementIndex).toBeGreaterThan(loopIndex);
+ });
+
+ it("records the chosen slots so the caller can consume them", () => {
+ const emitted = wgsl();
+
+ expect(emitted).toMatch(/slot_a = cand_i;/);
+ expect(emitted).toMatch(/slot_b = cand_j;/);
+ });
+
+ it("guards the square root and the pair count against fewer than two tokens", () => {
+ const emitted = wgsl();
+
+ // `2n - 1` underflows u32 at n = 0, and the discriminant can go slightly
+ // negative from f32 rounding at the last pair.
+ expect(emitted).toMatch(
+ /select\(0u, pair_n \* \(pair_n - 1u\) \/ 2u, pair_n >= 2u\)/,
+ );
+ expect(emitted).toMatch(/sqrt\(max\(disc, 0\.0\)\)/);
+ });
+
+ it("computes j in integer arithmetic, not through the f32 discriminant", () => {
+ // Rounding `2n - 1` through f32 and back would be exact at our sizes but is
+ // needless; j is exact integer work.
+ const emitted = wgsl();
+
+ expect(emitted).toMatch(/let pair_a_u = 2u \* pair_n - 1u;/);
+ expect(emitted).toMatch(/pair_a_u - cand_i/);
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.ts b/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.ts
new file mode 100644
index 00000000000..1ed2dbe8064
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.ts
@@ -0,0 +1,157 @@
+/**
+ * Choosing which pair of tokens a weight-2 typed arc consumes, on the GPU.
+ *
+ * The CPU walks `indexCombinations(n, 2)` — lexicographic `i < j` — and fires on
+ * the **first** combination whose lambda clears the frame's acceptance test
+ * (`monte-carlo/transition-effect.ts`). Two properties of that loop matter, and
+ * both are easy to get wrong:
+ *
+ * 1. The uniform `u` is drawn **once**, before the loop, and reused for every
+ * combination. So the test is a single threshold applied to all pairs, and
+ * "does the transition fire at all" is a plain OR over pairs.
+ * 2. Which pair fires is the **lowest-indexed** passing one, not the one with the
+ * largest lambda. With several pairs passing — routine for a collision model —
+ * picking the wrong one consumes different tokens and the trajectory diverges
+ * structurally, not by noise.
+ *
+ * So the GPU needs a flat scan over pairs with a min-index reduction, and its
+ * pair ordering must be the CPU's exactly. Unranking through the combinatorial
+ * number system gives that: pair index `x` maps to the `x`-th lexicographic
+ * `i < j`, so scanning `x` ascending *is* the CPU's order.
+ *
+ * See `docs/simulation-performance.md` §8 for why this is a flat scan rather than
+ * a nested loop: dynamic trip counts cost the maximum across a SIMT subgroup, and
+ * unranking removes the nesting entirely.
+ */
+
+/** Number of unordered pairs over `n` tokens. */
+export function pairCount(n: number): number {
+ return n < 2 ? 0 : (n * (n - 1)) / 2;
+}
+
+/**
+ * The `x`-th unordered pair `(i, j)`, `i < j`, in lexicographic order.
+ *
+ * Closed form rather than a search, so a GPU invocation derives its own pair from
+ * a flat index with no loop. Verified exact in f32 for every pair up to n = 4096
+ * (first mismatch at n = 5793), which is far above the 256-token ceiling the
+ * metric histogram imposes — see `pair-selection.test.ts`.
+ */
+export function unrankPair(x: number, n: number): [number, number] {
+ const a = 2 * n - 1;
+ const i = Math.floor((a - Math.sqrt(a * a - 8 * x)) / 2);
+ const j = x - (i * (a - i)) / 2 + i + 1;
+ return [i, j];
+}
+
+/** The flat index of pair `(i, j)`, inverse of {@link unrankPair}. */
+export function rankPair(i: number, j: number, n: number): number {
+ return (i * (2 * n - 1 - i)) / 2 + (j - i - 1);
+}
+
+/**
+ * The pair the CPU would fire on: the lowest-indexed one that passes.
+ *
+ * `null` when none passes, which is the transition not firing this frame. The
+ * reference implementation of what the emitted WGSL must compute — the tests
+ * check it against the engine's own enumerator rather than against itself.
+ */
+export function selectFiringPair(
+ tokenCount: number,
+ passes: (i: number, j: number) => boolean,
+): { index: number; i: number; j: number } | null {
+ const total = pairCount(tokenCount);
+ for (let x = 0; x < total; x++) {
+ const [i, j] = unrankPair(x, tokenCount);
+ if (passes(i, j)) {
+ return { index: x, i, j };
+ }
+ }
+ return null;
+}
+
+export type EmitPairScanOptions = {
+ /** WGSL expression for the live token count of the place being paired over. */
+ tokenCountExpr: string;
+ /**
+ * Emits the acceptance test for one candidate pair, given the WGSL variable
+ * names holding its two token slot indices.
+ *
+ * Returns statements *and* an expression rather than just an expression,
+ * because a compiled lambda hoists its subexpressions into `let` bindings that
+ * read the candidate's attributes — those have to land inside the loop body.
+ */
+ emitAccepts: (
+ firstVar: string,
+ secondVar: string,
+ ) => { statements: readonly string[]; expression: string };
+ /** Existing `bool` set to true when a pair passes. */
+ firedVar: string;
+ /** Existing `u32`s the chosen slot indices are written to. */
+ firstVar: string;
+ secondVar: string;
+ /** Indentation prefix for the emitted lines. */
+ indent?: string;
+};
+
+/**
+ * WGSL for a flat scan over pairs that stops at the lowest passing index.
+ *
+ * A serial scan inside one invocation rather than a parallel reduction across
+ * invocations, because a run's whole state lives in one invocation's registers —
+ * spreading a single transition's pair search across the workgroup would mean
+ * sharing that state through memory, which is the round-trip the whole design
+ * exists to avoid. Breaking at the first hit also makes the common case (an early
+ * pair passes) cheap, where a full reduction would always pay for every pair.
+ *
+ * The caller declares `firedVar`, `firstVar` and `secondVar`: the chosen slots
+ * are needed again by the compaction that follows, which sits outside the block
+ * this emits.
+ */
+export function emitPairScanWgsl({
+ tokenCountExpr,
+ emitAccepts,
+ firedVar,
+ firstVar,
+ secondVar,
+ indent = " ",
+}: EmitPairScanOptions): string[] {
+ const lines: string[] = [];
+ const push = (line: string) => lines.push(`${indent}${line}`);
+
+ push(`{`);
+ push(` let pair_n = ${tokenCountExpr};`);
+ push(
+ ` let pair_total = select(0u, pair_n * (pair_n - 1u) / 2u, pair_n >= 2u);`,
+ );
+ // `2n - 1` in both forms: f32 for the discriminant, u32 for the exact integer
+ // arithmetic of `j`, so neither has to round-trip through the other.
+ push(` let pair_a_u = 2u * pair_n - 1u;`);
+ push(` let pair_a = f32(pair_a_u);`);
+ push(` for (var x: u32 = 0u; x < pair_total; x = x + 1u) {`);
+ // Unranked per iteration rather than kept as a running (i, j): the closed form
+ // is a handful of ALU ops, and carrying state would make an early `break`
+ // leave the pair variables inconsistent.
+ push(` let disc = pair_a * pair_a - 8.0 * f32(x);`);
+ push(` let cand_i = u32(floor((pair_a - sqrt(max(disc, 0.0))) * 0.5));`);
+ push(
+ ` let cand_j = x - (cand_i * (pair_a_u - cand_i)) / 2u + cand_i + 1u;`,
+ );
+
+ const accepts = emitAccepts("cand_i", "cand_j");
+ for (const statement of accepts.statements) {
+ push(` ${statement}`);
+ }
+ push(` ${firedVar} = ${accepts.expression};`);
+ // The CPU takes the first passing combination, so stop here rather than
+ // continuing and keeping a minimum.
+ push(` if (${firedVar}) {`);
+ push(` ${firstVar} = cand_i;`);
+ push(` ${secondVar} = cand_j;`);
+ push(` break;`);
+ push(` }`);
+ push(` }`);
+ push(`}`);
+
+ return lines;
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner.test.ts
new file mode 100644
index 00000000000..ac67e3b2c99
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner.test.ts
@@ -0,0 +1,323 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ deriveGpuRunSeed,
+ describeAllocationFailure,
+ describeBufferOverflow,
+ fillSeedChunk,
+ requestGpuDevice,
+ seedRunsPerChunk,
+} from "./runner";
+
+/**
+ * The user-facing wall on run count. A device made without `requiredLimits` gets
+ * the WebGPU defaults — 128 MiB per binding, 256 MiB per buffer — which is the
+ * floor every implementation must support, not the hardware's capability. An
+ * Apple metal-3 adapter reports 4096 MiB for both.
+ */
+describe("describeBufferOverflow", () => {
+ const DEFAULT_LIMITS = {
+ maxStorageBufferBindingSize: 128 * 1024 * 1024,
+ maxBufferSize: 256 * 1024 * 1024,
+ };
+ const ADAPTER_LIMITS = {
+ maxStorageBufferBindingSize: 4096 * 1024 * 1024,
+ maxBufferSize: 4096 * 1024 * 1024,
+ };
+
+ const overflowFor = (
+ limits: typeof DEFAULT_LIMITS,
+ { bytesPerRun = 1024, runCount = 304_000 } = {},
+ ) =>
+ describeBufferOverflow({
+ stateBytes: bytesPerRun * runCount,
+ histBytes: 1024,
+ bytesPerRun,
+ runCount,
+ limits,
+ });
+
+ it("refuses on the default limits and fits on the adapter's", () => {
+ // ~311 MB of run state: over the 128 MiB default, far under 4096 MiB.
+ expect(overflowFor(DEFAULT_LIMITS)).toMatch(/Run state needs 311 MB/);
+ expect(overflowFor(ADAPTER_LIMITS)).toBeNull();
+ });
+
+ it("takes the smaller of the two limits, not just the binding size", () => {
+ // Checking only `maxStorageBufferBindingSize` would pass this, and the
+ // allocation would then fail as a raw WebGPU validation error instead.
+ const cappedAllocation = {
+ maxStorageBufferBindingSize: 4096 * 1024 * 1024,
+ maxBufferSize: 256 * 1024 * 1024,
+ };
+
+ expect(overflowFor(cappedAllocation)).toMatch(/caps a buffer at 268 MB/);
+ });
+
+ it("says how many runs would fit instead of `use fewer runs`", () => {
+ const reason = overflowFor(DEFAULT_LIMITS, {
+ bytesPerRun: 1024,
+ runCount: 304_000,
+ });
+
+ // floor(134217728 / 1024) = 131072.
+ expect(reason).toMatch(/that is 131072 runs; this experiment asked for 304000/);
+ });
+
+ it("reports the histogram separately, since fewer runs would not help", () => {
+ expect(
+ describeBufferOverflow({
+ stateBytes: 1024,
+ histBytes: 300 * 1e6,
+ bytesPerRun: 1024,
+ runCount: 1,
+ limits: DEFAULT_LIMITS,
+ }),
+ ).toMatch(/Metric histograms need 300 MB/);
+ });
+
+ it("does not divide by zero when a run holds no state", () => {
+ expect(
+ describeBufferOverflow({
+ stateBytes: 300 * 1e6,
+ histBytes: 0,
+ bytesPerRun: 0,
+ runCount: 1,
+ limits: DEFAULT_LIMITS,
+ }),
+ ).toMatch(/that is 0 runs/);
+ });
+});
+
+describe("requestGpuDevice", () => {
+ const original = Reflect.getOwnPropertyDescriptor(globalThis, "navigator");
+
+ afterEach(() => {
+ if (original) {
+ Object.defineProperty(globalThis, "navigator", original);
+ } else {
+ Reflect.deleteProperty(globalThis, "navigator");
+ }
+ });
+
+ /** Records what the caller asked for, and honours it the way a real device does. */
+ function stubAdapter(adapterLimits: Record) {
+ const requested: GPUDeviceDescriptor[] = [];
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: {
+ gpu: {
+ requestAdapter: () =>
+ Promise.resolve({
+ info: { vendor: "apple", architecture: "metal-3" },
+ limits: adapterLimits,
+ requestDevice: (descriptor: GPUDeviceDescriptor = {}) => {
+ requested.push(descriptor);
+ return Promise.resolve({
+ limits: {
+ // A device gets the WebGPU defaults for anything it does not
+ // ask for, regardless of what the adapter supports.
+ maxStorageBufferBindingSize: 128 * 1024 * 1024,
+ maxBufferSize: 256 * 1024 * 1024,
+ ...(descriptor.requiredLimits ?? {}),
+ },
+ });
+ },
+ }),
+ },
+ },
+ });
+ return requested;
+ }
+
+ it("asks for the adapter's limits, not the WebGPU defaults", async () => {
+ // Without `requiredLimits` the device is capped at 128 MiB per binding on
+ // hardware that offers 4096 MiB — a factor of 32 — and run counts the GPU
+ // could hold were refused. Requesting exactly what the adapter reports is
+ // always valid; only asking for more is rejected.
+ const requested = stubAdapter({
+ maxStorageBufferBindingSize: 4096 * 1024 * 1024,
+ maxBufferSize: 4096 * 1024 * 1024,
+ });
+
+ const result = await requestGpuDevice();
+
+ expect(requested[0]?.requiredLimits).toStrictEqual({
+ maxStorageBufferBindingSize: 4096 * 1024 * 1024,
+ maxBufferSize: 4096 * 1024 * 1024,
+ });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.handle.device.limits.maxStorageBufferBindingSize).toBe(
+ 4096 * 1024 * 1024,
+ );
+ });
+
+ it("does not ask for more than the adapter reports, which would be rejected", async () => {
+ // A modest adapter must still get a device: requesting a hard-coded ceiling
+ // would make `requestDevice` reject and lose the GPU entirely.
+ const requested = stubAdapter({
+ maxStorageBufferBindingSize: 128 * 1024 * 1024,
+ maxBufferSize: 256 * 1024 * 1024,
+ });
+
+ const result = await requestGpuDevice();
+
+ expect(requested[0]?.requiredLimits).toStrictEqual({
+ maxStorageBufferBindingSize: 128 * 1024 * 1024,
+ maxBufferSize: 256 * 1024 * 1024,
+ });
+ expect(result.ok).toBe(true);
+ });
+});
+
+/**
+ * Seeding used to build the whole run state as one `Uint32Array`. At ~2 KB per
+ * run a million runs is ~2 GiB in one contiguous ArrayBuffer, which the browser
+ * refuses outright — "Array buffer allocation failed", before frame zero. It is
+ * staged in chunks now, which puts an absolute-vs-relative run index in the
+ * middle of the hot path.
+ */
+describe("fillSeedChunk", () => {
+ const LAYOUT = {
+ stateWordsPerRun: 8,
+ placeCountOffsets: [0, 1],
+ rngOffset: 3,
+ placeCounts: [5, 7],
+ seed: 12345,
+ };
+
+ /** Seeds `runCount` runs through the chunked path, returning the whole buffer. */
+ function seedChunked(runCount: number, runsPerChunk: number): Uint32Array {
+ const whole = new Uint32Array(runCount * LAYOUT.stateWordsPerRun);
+ const staging = new Uint32Array(runsPerChunk * LAYOUT.stateWordsPerRun);
+ for (let firstRun = 0; firstRun < runCount; firstRun += runsPerChunk) {
+ const runsInChunk = Math.min(runsPerChunk, runCount - firstRun);
+ fillSeedChunk(staging, { ...LAYOUT, firstRun, runsInChunk });
+ whole.set(
+ staging.subarray(0, runsInChunk * LAYOUT.stateWordsPerRun),
+ firstRun * LAYOUT.stateWordsPerRun,
+ );
+ }
+ return whole;
+ }
+
+ it("produces the same bytes whatever the chunk size", () => {
+ // Including a chunk size that does not divide the run count, and one run
+ // per chunk — the degenerate case a very large per-run state produces.
+ const reference = seedChunked(10, 10);
+
+ for (const runsPerChunk of [1, 2, 3, 4, 7, 9, 10]) {
+ expect(seedChunked(10, runsPerChunk)).toStrictEqual(reference);
+ }
+ });
+
+ it("seeds each run from its absolute index, not its index within the chunk", () => {
+ // The bug chunking invites: run 7 in chunk 2 getting run 1's stream, which
+ // would silently correlate runs that must be independent.
+ const state = seedChunked(10, 3);
+
+ for (let run = 0; run < 10; run++) {
+ expect(state[run * LAYOUT.stateWordsPerRun + LAYOUT.rngOffset]).toBe(
+ deriveGpuRunSeed(LAYOUT.seed, run),
+ );
+ }
+ });
+
+ it("leaves every word it does not set as zero, which is what the shader expects", () => {
+ // The staging array is reused across chunks. That is safe only because every
+ // word this writes is written for every run at the same offsets, so nothing
+ // from a previous chunk can survive inside the uploaded region. Asserting
+ // the invariant directly rather than the `fill` that guards it: removing the
+ // `fill` is genuinely unobservable today, and a test claiming otherwise
+ // would be vacuous.
+ const staging = new Uint32Array(4 * LAYOUT.stateWordsPerRun);
+ fillSeedChunk(staging, { ...LAYOUT, firstRun: 0, runsInChunk: 4 });
+ fillSeedChunk(staging, { ...LAYOUT, firstRun: 4, runsInChunk: 1 });
+
+ const written = new Set([
+ ...LAYOUT.placeCountOffsets,
+ LAYOUT.rngOffset,
+ ]);
+ for (let word = 0; word < LAYOUT.stateWordsPerRun; word++) {
+ if (!written.has(word)) {
+ expect(staging[word]).toBe(0);
+ }
+ }
+ // And the one run in the short chunk is entirely its own.
+ expect(staging[LAYOUT.rngOffset]).toBe(deriveGpuRunSeed(LAYOUT.seed, 4));
+ expect(staging[0]).toBe(5);
+ expect(staging[1]).toBe(7);
+ });
+
+ it("writes place counts at the offsets the shader reads them from", () => {
+ const state = seedChunked(2, 1);
+
+ expect(state[0]).toBe(5);
+ expect(state[1]).toBe(7);
+ expect(state[LAYOUT.stateWordsPerRun + 0]).toBe(5);
+ expect(state[LAYOUT.stateWordsPerRun + 1]).toBe(7);
+ });
+});
+
+describe("seedRunsPerChunk", () => {
+ it("stages at least one run however large a single run's state is", () => {
+ // A 4 MiB budget over a run needing more than that must not round to zero,
+ // which would loop forever.
+ expect(seedRunsPerChunk(50_000_000, 10)).toBe(1);
+ });
+
+ it("never stages more runs than exist", () => {
+ expect(seedRunsPerChunk(8, 3)).toBe(3);
+ });
+
+ it("keeps the staging array bounded for a realistic net", () => {
+ // 2088 bytes per run = 522 words; the staging array stays a few MiB rather
+ // than scaling with the run count.
+ const runsPerChunk = seedRunsPerChunk(522, 1_000_000);
+
+ expect(runsPerChunk * 522 * 4).toBeLessThanOrEqual(4 * 1024 * 1024);
+ expect(runsPerChunk).toBeGreaterThan(1000);
+ });
+});
+
+/**
+ * Dawn reports an out-of-memory `createBuffer` by returning an error buffer
+ * rather than throwing, so allocation looks successful and the first thing the
+ * user sees is `mapAsync` failing with "[Invalid Buffer] is invalid due to a
+ * previous error" — three operations downstream, after the whole simulation has
+ * run. The real message lives only inside an error scope.
+ */
+describe("describeAllocationFailure", () => {
+ const DAWN_OOM =
+ "Failed to allocate memory for buffer mapping\n at APICreateErrorBuffer (../../third_party/dawn/src/dawn/native/Device.cpp:1573)\n";
+
+ it("leads with the run arithmetic, not Dawn's internals", () => {
+ const reason = describeAllocationFailure({
+ message: DAWN_OOM,
+ stateBytes: 3112 * 1_000_000,
+ bytesPerRun: 3112,
+ runCount: 1_000_000,
+ });
+
+ // Measured: 3112 B/run x 1e6 runs = 2.90 GiB, which fails to allocate as a
+ // mappable buffer on an adapter reporting maxBufferSize = 4 GiB.
+ expect(reason).toMatch(/^The GPU could not allocate memory for 1000000 runs/);
+ expect(reason).toContain("3112 bytes per run");
+ expect(reason).toContain("2.90 GiB");
+ // The two things the author can actually change.
+ expect(reason).toMatch(/fewer runs/);
+ expect(reason).toMatch(/token capacities/);
+ });
+
+ it("keeps the underlying message, which separates OOM from a validation bug", () => {
+ const reason = describeAllocationFailure({
+ message: DAWN_OOM,
+ stateBytes: 1,
+ bytesPerRun: 1,
+ runCount: 1,
+ });
+
+ expect(reason).toContain("Failed to allocate memory for buffer mapping");
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts
new file mode 100644
index 00000000000..20d0f1a9566
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts
@@ -0,0 +1,666 @@
+/**
+ * Runs a compiled net shader on a WebGPU device.
+ *
+ * The API is deliberately whole-experiment rather than per-frame. Implementing
+ * `MonteCarloSimulator.advanceAll()` — synchronous, one frame at a time — would
+ * force a `mapAsync` readback per frame, and
+ * `../../docs/simulation-performance.md` §8.3 measures that round-trip at
+ * hundreds of microseconds against roughly a microsecond of per-frame work. A
+ * GPU path shaped like the CPU interface would therefore be slower than the CPU.
+ *
+ * Instead the host dispatches a chunk of frames, and results come back as
+ * per-frame histograms accumulated on the device.
+ */
+import { GPU_HISTOGRAM_BINS, GPU_WORKGROUP_SIZE } from "./compile-net-shader";
+import { isWebGpuAvailable } from "./support";
+
+import type { AbortSignalLike } from "../environment";
+import type { CompiledNetShader } from "./compile-net-shader";
+
+/** Words in the uniform config block: run_count, base_frame, frame_limit, seed. */
+const CONFIG_WORDS = 4;
+
+/**
+ * Words of run state staged on the host per `writeBuffer` call, 4 MiB worth.
+ *
+ * Large enough that the per-call overhead is irrelevant next to the copy, small
+ * enough to allocate on any device. The seeding array used to be the whole run
+ * state at once, which is where a million-run experiment died.
+ */
+const SEED_CHUNK_WORDS = 1024 * 1024;
+
+/**
+ * Host globals this module needs, reached structurally.
+ *
+ * This package is headless by design and pins `types: []` plus `lib: ["ESNext"]`
+ * so it never depends on DOM typings (see `../environment.ts`). `@webgpu/types`
+ * supplies the `GPU*` shapes, but `navigator` and `performance` are DOM globals,
+ * so they are read through a narrow structural view instead of widening `lib`.
+ */
+const host = globalThis as unknown as {
+ navigator?: { gpu?: GPU };
+ performance?: { now: () => number };
+};
+
+/** Monotonic milliseconds, falling back to 0 where unavailable. */
+function now(): number {
+ return host.performance?.now() ?? 0;
+}
+
+export type GpuRunnerInitialState = {
+ /** Initial token count per place, in profile order. */
+ placeCounts: readonly number[];
+};
+
+export type GpuExperimentRequest = {
+ runCount: number;
+ /** Total frames to advance, across however many dispatches that takes. */
+ frameLimit: number;
+ /** Frames per dispatch. Smaller chunks keep the GPU watchdog satisfied. */
+ framesPerDispatch: number;
+ seed: number;
+ initial: GpuRunnerInitialState;
+ /** Invoked after each chunk so callers can report progress. */
+ onChunk?: (progress: { framesDone: number; frameLimit: number }) => void;
+ /**
+ * Stops the run at the next chunk boundary.
+ *
+ * A dispatch cannot be interrupted once submitted, so cancellation is only
+ * observed between chunks — which is also why `framesPerDispatch` is bounded
+ * rather than running the whole experiment in one dispatch.
+ */
+ signal?: AbortSignalLike;
+};
+
+export type GpuHistogramFrame = {
+ frameNumber: number;
+ metricId: string;
+ /** `[value, frequency]` pairs, ascending, zero bins omitted. */
+ bins: [number, number][];
+ /** Runs that contributed a sample; equals the active run count. */
+ sampleCount: number;
+};
+
+export type GpuExperimentResult = {
+ /** True when the run stopped early because the signal aborted. */
+ cancelled: boolean;
+ frames: GpuHistogramFrame[];
+ /** Final token counts per run per place, for inspection and tests. */
+ finalPlaceCounts: Uint32Array;
+ /** Runs that ended deadlocked (status 1) and completed (status 2). */
+ deadlockedRuns: number;
+ completedRuns: number;
+ /** Wall-clock time spent inside dispatches, excluding setup. */
+ dispatchMs: number;
+ /**
+ * Values that landed in the histogram's final bin.
+ *
+ * The top bin is saturating, so a non-zero count here means some samples were
+ * clamped and the distribution's tail is not trustworthy.
+ */
+ saturatedSamples: number;
+};
+
+/**
+ * Derives a per-run RNG seed on the host.
+ *
+ * Kept in TypeScript rather than the shader so it is unit-testable and so the
+ * same derivation can be reused if the CPU backend ever adopts this generator.
+ * One PCG advance after mixing decorrelates adjacent run indices, which plain
+ * sequential seeding leaves visibly correlated in the first few draws.
+ */
+/* eslint-disable no-bitwise -- a 32-bit PRNG is bit manipulation by definition */
+export function deriveGpuRunSeed(
+ baseSeed: number,
+ globalRunIndex: number,
+): number {
+ const mixed = (baseSeed ^ Math.imul(globalRunIndex, 2654435761)) >>> 0;
+ return (Math.imul(mixed, 747796405) + 2891336453) >>> 0;
+}
+/* eslint-enable no-bitwise */
+
+export type GpuDeviceHandle = {
+ device: GPUDevice;
+ /** Adapter description, for reporting which device ran an experiment. */
+ info: string;
+};
+
+/**
+ * Acquires a WebGPU device, or explains why one is unavailable.
+ *
+ * Returns a reason rather than throwing so callers can fall back to the CPU and
+ * show the user why, which a thrown error at this layer would turn into an
+ * opaque failure.
+ */
+export async function requestGpuDevice(): Promise<
+ { ok: true; handle: GpuDeviceHandle } | { ok: false; reason: string }
+> {
+ if (!isWebGpuAvailable()) {
+ return {
+ ok: false,
+ reason:
+ "This browser does not expose WebGPU. Chrome, Edge and Safari 26+ support it; Firefox needs it enabled.",
+ };
+ }
+ try {
+ const adapter = await host.navigator!.gpu!.requestAdapter();
+ if (!adapter) {
+ return {
+ ok: false,
+ reason:
+ "No WebGPU adapter is available — the browser exposes the API but no usable GPU was found.",
+ };
+ }
+ // A device created without `requiredLimits` gets the WebGPU *default*
+ // limits, not the adapter's — 128 MiB per storage binding and 256 MiB per
+ // buffer, the floor every conformant implementation must support. That is
+ // unrelated to what the hardware can do: an Apple metal-3 adapter reports
+ // 4096 MiB for both, so the default costs a factor of 32 and refused run
+ // counts the GPU could hold comfortably.
+ //
+ // Asking for exactly what the adapter reports is always valid — the spec
+ // only rejects asking for more — and raising a limit allocates nothing on
+ // its own, so there is no cost to requesting the ceiling and then sizing
+ // buffers to what the experiment actually needs.
+ const device = await adapter.requestDevice({
+ requiredLimits: {
+ maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
+ maxBufferSize: adapter.limits.maxBufferSize,
+ },
+ });
+ const vendor = adapter.info.vendor || "unknown vendor";
+ const architecture = adapter.info.architecture || "unknown architecture";
+ return {
+ ok: true,
+ handle: { device, info: `${vendor} / ${architecture}` },
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ reason: `Requesting a WebGPU device failed: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ };
+ }
+}
+
+/**
+ * Compiles the shader and reports WGSL diagnostics.
+ *
+ * Shader compilation errors are the failure mode a generated-code backend hits
+ * most, and WebGPU surfaces them as console warnings by default rather than as
+ * exceptions — so they are read explicitly here and turned into a real error.
+ */
+async function createPipeline(
+ device: GPUDevice,
+ wgsl: string,
+): Promise<
+ { ok: true; pipeline: GPUComputePipeline } | { ok: false; reason: string }
+> {
+ const module = device.createShaderModule({ code: wgsl });
+ const info = await module.getCompilationInfo();
+ const errors = info.messages.filter((message) => message.type === "error");
+ if (errors.length > 0) {
+ return {
+ ok: false,
+ reason: `Generated WGSL did not compile: ${errors
+ .map((message) => `line ${message.lineNum}: ${message.message}`)
+ .join("; ")}`,
+ };
+ }
+
+ device.pushErrorScope("validation");
+ const pipeline = device.createComputePipeline({
+ layout: "auto",
+ compute: { module, entryPoint: "step_runs" },
+ });
+ const validationError = await device.popErrorScope();
+ if (validationError) {
+ return {
+ ok: false,
+ reason: `Pipeline validation failed: ${validationError.message}`,
+ };
+ }
+
+ return { ok: true, pipeline };
+}
+
+/**
+ * Turns a WebGPU allocation failure into something the author can act on.
+ *
+ * The raw message is a Dawn internal — "Failed to allocate memory for buffer
+ * mapping at APICreateErrorBuffer (Device.cpp:1573)" — which says nothing about
+ * runs. It is kept, because it distinguishes running out of memory from a
+ * validation mistake, but the run arithmetic goes first.
+ *
+ * No limit predicts this: host-visible memory for mappable readback is scarcer
+ * than device memory and gives out well below `maxBufferSize`, so the honest
+ * approach is to attempt the allocation and explain the failure rather than to
+ * guess a threshold and refuse experiments that would have worked.
+ */
+export function describeAllocationFailure({
+ message,
+ stateBytes,
+ bytesPerRun,
+ runCount,
+}: {
+ message: string;
+ stateBytes: number;
+ bytesPerRun: number;
+ runCount: number;
+}): string {
+ const gib = (bytes: number) => (bytes / 1024 ** 3).toFixed(2);
+ // Readback doubles it: the state lives on the device and again in a mappable
+ // buffer, and the mappable one is what runs out.
+ return `The GPU could not allocate memory for ${runCount} runs: ${bytesPerRun} bytes per run is ${gib(
+ stateBytes,
+ )} GiB of run state, and reading it back needs that much again in host-visible memory. Try fewer runs, or lower the token capacities that set the per-run size. (${message.trim()})`;
+}
+
+/** How many runs to stage per `writeBuffer`, at least one however large a run is. */
+export function seedRunsPerChunk(
+ stateWordsPerRun: number,
+ runCount: number,
+): number {
+ return Math.max(
+ 1,
+ Math.min(runCount, Math.floor(SEED_CHUNK_WORDS / stateWordsPerRun)),
+ );
+}
+
+/**
+ * Writes one chunk of initial run state into a reused staging array.
+ *
+ * Separate and pure because the index arithmetic is the part that silently
+ * corrupts: a run's RNG seed comes from its **absolute** index, so chunking must
+ * not renumber it, and the staging array carries the previous chunk's contents
+ * and has to be cleared before it is filled again.
+ */
+/* eslint-disable no-param-reassign -- filling the caller's reusable staging
+ array is this function's purpose; returning a fresh one per chunk would
+ reintroduce the allocation the chunking exists to avoid */
+export function fillSeedChunk(
+ staging: Uint32Array,
+ {
+ firstRun,
+ runsInChunk,
+ stateWordsPerRun,
+ placeCountOffsets,
+ rngOffset,
+ placeCounts,
+ seed,
+ }: {
+ firstRun: number;
+ runsInChunk: number;
+ stateWordsPerRun: number;
+ placeCountOffsets: readonly number[];
+ rngOffset: number;
+ placeCounts: readonly number[];
+ seed: number;
+ },
+): void {
+ // Defensive, and deliberately not load-bearing: every word this writes, it
+ // writes for every run at the same offsets, and words it never writes are
+ // zero from allocation — so reuse cannot leak a previous chunk today, and no
+ // test can observe this line. It stays because the moment any field becomes
+ // conditional, stale run state would be uploaded to the GPU silently.
+ staging.fill(0, 0, runsInChunk * stateWordsPerRun);
+ for (let run = 0; run < runsInChunk; run++) {
+ const base = run * stateWordsPerRun;
+ for (const [placeIndex, offset] of placeCountOffsets.entries()) {
+ staging[base + offset] = placeCounts[placeIndex] ?? 0;
+ }
+ // The RNG word sits immediately before the status word, which is last in
+ // the fixed header the shader lays out.
+ staging[base + rngOffset] = deriveGpuRunSeed(seed, firstRun + run);
+ }
+}
+/* eslint-enable no-param-reassign */
+
+/**
+ * Why this experiment's buffers will not fit on this device, or `null` if they
+ * will.
+ *
+ * Pure, and separate from `runGpuExperiment`, because the arithmetic is the part
+ * users actually hit and a real `GPUDevice` cannot be had in a unit test.
+ *
+ * Two device limits bind independently. `maxStorageBufferBindingSize` caps a
+ * single binding and `maxBufferSize` caps the allocation; the defaults are
+ * 128 MiB and 256 MiB, so checking only the first moves the wall instead of
+ * finding it and the allocation fails later as a raw WebGPU validation error.
+ * Neither default reflects the hardware — see `requestGpuDevice`, which now asks
+ * for the adapter's own limits.
+ */
+export function describeBufferOverflow({
+ stateBytes,
+ histBytes,
+ bytesPerRun,
+ runCount,
+ limits,
+}: {
+ stateBytes: number;
+ histBytes: number;
+ bytesPerRun: number;
+ runCount: number;
+ limits: Pick<
+ GPUSupportedLimits,
+ "maxStorageBufferBindingSize" | "maxBufferSize"
+ >;
+}): string | null {
+ const ceiling = Math.min(
+ limits.maxStorageBufferBindingSize,
+ limits.maxBufferSize,
+ );
+ const mb = (bytes: number) => Math.round(bytes / 1e6);
+
+ if (stateBytes > ceiling) {
+ // Say what would fit rather than "use fewer runs", which left the author to
+ // bisect. Run state is exactly linear in the run count, so the number is
+ // both computable and correct.
+ const runsThatFit = bytesPerRun > 0 ? Math.floor(ceiling / bytesPerRun) : 0;
+ return `Run state needs ${mb(stateBytes)} MB but this device caps a buffer at ${mb(
+ ceiling,
+ )} MB. At ${bytesPerRun} bytes per run that is ${runsThatFit} runs; this experiment asked for ${runCount}.`;
+ }
+ if (histBytes > ceiling) {
+ return `Metric histograms need ${mb(histBytes)} MB but this device caps a buffer at ${mb(
+ ceiling,
+ )} MB. Use fewer frames or fewer metrics.`;
+ }
+ return null;
+}
+
+export async function runGpuExperiment(
+ handle: GpuDeviceHandle,
+ shader: CompiledNetShader,
+ request: GpuExperimentRequest,
+): Promise<
+ { ok: true; result: GpuExperimentResult } | { ok: false; reason: string }
+> {
+ const { device } = handle;
+ const { runCount, frameLimit, framesPerDispatch, seed, initial } = request;
+ const metricCount = shader.metricIds.length;
+
+ const compiled = await createPipeline(device, shader.wgsl);
+ if (!compiled.ok) {
+ return compiled;
+ }
+
+ const stateWords = shader.stateWordsPerRun * runCount;
+ const stateBytes = stateWords * 4;
+ const histWords = Math.max(1, frameLimit * GPU_HISTOGRAM_BINS * metricCount);
+ const histBytes = histWords * 4;
+ const summaryWords = Math.max(1, shader.summaryWordsPerRun * runCount);
+ const summaryBytes = summaryWords * 4;
+
+ const tooLarge = describeBufferOverflow({
+ stateBytes,
+ histBytes,
+ bytesPerRun: shader.stateWordsPerRun * 4,
+ runCount,
+ limits: device.limits,
+ });
+ if (tooLarge !== null) {
+ return { ok: false, reason: tooLarge };
+ }
+
+ // Buffer allocation is the failure nobody was told about. Dawn reports an
+ // out-of-memory `createBuffer` by returning an *error buffer* rather than
+ // throwing, so allocation appears to succeed and the first visible symptom is
+ // three operations downstream — "[Invalid Buffer] is invalid due to a previous
+ // error" from `mapAsync`, long after the dispatch has run. The real message
+ // ("Failed to allocate memory for buffer mapping") only exists inside an error
+ // scope, and none was pushed here.
+ //
+ // Mappable readback is the buffer that actually runs out: host-visible memory
+ // is scarcer than device memory, and it fails well below `maxBufferSize`, so
+ // no limit check can predict it. Measured on an Apple metal-3 adapter, a
+ // 1.94 GiB readback allocates and a 2.90 GiB one does not, with
+ // `maxBufferSize` reporting 4 GiB in both cases.
+ device.pushErrorScope("out-of-memory");
+
+ /* eslint-disable no-bitwise -- GPUBufferUsage flags are a bit field */
+ // No COPY_SRC: run state never leaves the device now.
+ const stateBuffer = device.createBuffer({
+ size: stateBytes,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ });
+ const summaryBuffer = device.createBuffer({
+ size: summaryBytes,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
+ });
+ const histBuffer = device.createBuffer({
+ size: histBytes,
+ usage:
+ GPUBufferUsage.STORAGE |
+ GPUBufferUsage.COPY_SRC |
+ GPUBufferUsage.COPY_DST,
+ });
+ const configBuffer = device.createBuffer({
+ size: CONFIG_WORDS * 4,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+ const summaryReadback = device.createBuffer({
+ size: summaryBytes,
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
+ });
+ const histReadback = device.createBuffer({
+ size: histBytes,
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
+ });
+ /* eslint-enable no-bitwise */
+
+ const destroyAll = () => {
+ for (const buffer of [
+ stateBuffer,
+ summaryBuffer,
+ histBuffer,
+ configBuffer,
+ summaryReadback,
+ histReadback,
+ ]) {
+ buffer.destroy();
+ }
+ };
+
+ const allocationError = await device.popErrorScope();
+ if (allocationError) {
+ destroyAll();
+ return {
+ ok: false,
+ reason: describeAllocationFailure({
+ message: allocationError.message,
+ stateBytes,
+ bytesPerRun: shader.stateWordsPerRun * 4,
+ runCount,
+ }),
+ };
+ }
+
+ try {
+ // Seed initial state on the host: counts from the initial marking, a
+ // per-run RNG stream, everything else zero.
+ //
+ // Written in chunks rather than as one array. A single `Uint32Array` of the
+ // whole state is the largest allocation the host makes — at 2 KB per run,
+ // a million runs is ~2 GiB in one contiguous ArrayBuffer, which the browser
+ // refuses with "Array buffer allocation failed" before a single frame runs.
+ // The GPU itself is fine with that size; only the host mirror was the
+ // problem. Runs are independent and laid out contiguously, so the staging
+ // array can be small and reused.
+ const runsPerChunk = seedRunsPerChunk(shader.stateWordsPerRun, runCount);
+ const staging = new Uint32Array(runsPerChunk * shader.stateWordsPerRun);
+ for (let firstRun = 0; firstRun < runCount; firstRun += runsPerChunk) {
+ const runsInChunk = Math.min(runsPerChunk, runCount - firstRun);
+ fillSeedChunk(staging, {
+ firstRun,
+ runsInChunk,
+ stateWordsPerRun: shader.stateWordsPerRun,
+ placeCountOffsets: shader.placeCountOffsets,
+ rngOffset: shader.rngOffset,
+ placeCounts: initial.placeCounts,
+ seed,
+ });
+ device.queue.writeBuffer(
+ stateBuffer,
+ firstRun * shader.stateWordsPerRun * 4,
+ staging,
+ 0,
+ runsInChunk * shader.stateWordsPerRun,
+ );
+ }
+
+ const bindGroup = device.createBindGroup({
+ layout: compiled.pipeline.getBindGroupLayout(0),
+ entries: [
+ { binding: 0, resource: { buffer: stateBuffer } },
+ { binding: 1, resource: { buffer: histBuffer } },
+ { binding: 2, resource: { buffer: configBuffer } },
+ { binding: 3, resource: { buffer: summaryBuffer } },
+ ],
+ });
+
+ const workgroups = Math.ceil(runCount / GPU_WORKGROUP_SIZE);
+ if (workgroups > device.limits.maxComputeWorkgroupsPerDimension) {
+ return {
+ ok: false,
+ reason: `${runCount} runs needs ${workgroups} workgroups, above this device's per-dispatch limit of ${device.limits.maxComputeWorkgroupsPerDimension}.`,
+ };
+ }
+
+ const start = now();
+ device.pushErrorScope("validation");
+
+ let cancelled = false;
+ for (
+ let baseFrame = 0;
+ baseFrame < frameLimit;
+ baseFrame += framesPerDispatch
+ ) {
+ // A submitted dispatch cannot be interrupted, so cancellation is observed
+ // between chunks. Frames already advanced stay in the histogram, and the
+ // caller is told the run was cut short.
+ if (request.signal?.aborted) {
+ cancelled = true;
+ break;
+ }
+ device.queue.writeBuffer(
+ configBuffer,
+ 0,
+ new Uint32Array([runCount, baseFrame, frameLimit, seed]),
+ );
+ const encoder = device.createCommandEncoder();
+ const pass = encoder.beginComputePass();
+ pass.setPipeline(compiled.pipeline);
+ pass.setBindGroup(0, bindGroup);
+ pass.dispatchWorkgroups(workgroups);
+ pass.end();
+ device.queue.submit([encoder.finish()]);
+ // Awaiting per chunk (not per frame) keeps the browser responsive and
+ // bounds how far ahead the queue runs, at negligible cost.
+ await device.queue.onSubmittedWorkDone();
+ request.onChunk?.({
+ framesDone: Math.min(baseFrame + framesPerDispatch, frameLimit),
+ frameLimit,
+ });
+ }
+
+ const dispatchError = await device.popErrorScope();
+ if (dispatchError) {
+ return {
+ ok: false,
+ reason: `GPU dispatch failed: ${dispatchError.message}`,
+ };
+ }
+ const dispatchMs = now() - start;
+
+ const encoder = device.createCommandEncoder();
+ encoder.copyBufferToBuffer(
+ summaryBuffer,
+ 0,
+ summaryReadback,
+ 0,
+ summaryBytes,
+ );
+ encoder.copyBufferToBuffer(histBuffer, 0, histReadback, 0, histBytes);
+ device.queue.submit([encoder.finish()]);
+
+ await Promise.all([
+ summaryReadback.mapAsync(GPUMapMode.READ),
+ histReadback.mapAsync(GPUMapMode.READ),
+ ]);
+ // Read through the mapped ranges rather than copying them: `.slice(0)`
+ // doubled the host cost of every experiment for a copy thrown away
+ // immediately. Both stay mapped until the decode loops below are done.
+ const summary = new Uint32Array(summaryReadback.getMappedRange());
+ const histogram = new Uint32Array(histReadback.getMappedRange());
+
+ // Decode: per-run status, per-run final counts, per-frame histograms. The
+ // shader gathered the first two into `summary`, so this walks a few words per
+ // run rather than the whole run state.
+ let deadlockedRuns = 0;
+ let completedRuns = 0;
+ const placeCount = shader.placeCountOffsets.length;
+ const finalPlaceCounts = new Uint32Array(runCount * placeCount);
+ for (let run = 0; run < runCount; run++) {
+ const base = run * shader.summaryWordsPerRun;
+ const status = summary[base + shader.summaryStatusOffset] ?? 0;
+ // Both 1 (deadlocked) and 2 (reached the frame limit) are finished runs,
+ // and the CPU engine reports either as `complete` — a run that deadlocks
+ // has completed, it just stopped early. Counting only 2 made a net where
+ // every run deadlocks report "0 complete" while its status said Complete.
+ // `deadlockedRuns` stays separate for diagnostics.
+ if (status === 1) {
+ deadlockedRuns++;
+ completedRuns++;
+ } else if (status === 2) {
+ completedRuns++;
+ }
+ // Counts lead the summary in place order, so the place index is the offset.
+ for (let placeIndex = 0; placeIndex < placeCount; placeIndex++) {
+ finalPlaceCounts[run * placeCount + placeIndex] =
+ summary[base + placeIndex] ?? 0;
+ }
+ }
+
+ const frames: GpuHistogramFrame[] = [];
+ let saturatedSamples = 0;
+ for (let frame = 0; frame < frameLimit; frame++) {
+ for (const [metricIndex, metricId] of shader.metricIds.entries()) {
+ const offset =
+ frame * GPU_HISTOGRAM_BINS * metricCount +
+ metricIndex * GPU_HISTOGRAM_BINS;
+ const bins: [number, number][] = [];
+ let sampleCount = 0;
+ for (let bin = 0; bin < GPU_HISTOGRAM_BINS; bin++) {
+ const frequency = histogram[offset + bin] ?? 0;
+ if (frequency > 0) {
+ bins.push([bin, frequency]);
+ sampleCount += frequency;
+ }
+ }
+ saturatedSamples += histogram[offset + GPU_HISTOGRAM_BINS - 1] ?? 0;
+ frames.push({ frameNumber: frame, metricId, bins, sampleCount });
+ }
+ }
+
+ // Last use of both views; everything returned below is host-owned.
+ summaryReadback.unmap();
+ histReadback.unmap();
+
+ return {
+ ok: true,
+ result: {
+ cancelled,
+ frames,
+ finalPlaceCounts,
+ deadlockedRuns,
+ completedRuns,
+ dispatchMs,
+ saturatedSamples,
+ },
+ };
+ } finally {
+ destroyAll();
+ }
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/support.ts b/libs/@hashintel/petrinaut-core/src/webgpu/support.ts
new file mode 100644
index 00000000000..655a1eeb6ef
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/support.ts
@@ -0,0 +1,31 @@
+/**
+ * WebGPU capability detection, deliberately dependency-free.
+ *
+ * This lives apart from the rest of `webgpu/` because UI needs to ask "is a GPU
+ * available?" in order to enable a control, and it must be able to do that
+ * without pulling in the backend. The `./webgpu` entry point re-lowers user code
+ * to HIR, which bundles the TypeScript compiler and its Node builtins — importing
+ * that from an editor component breaks the browser build outright.
+ *
+ * Keep this module free of imports.
+ */
+
+/**
+ * Host globals, reached structurally.
+ *
+ * The package is headless by design and pins `types: []` plus `lib: ["ESNext"]`,
+ * so `navigator` is not a declared global here (see `../environment.ts`).
+ */
+const host = globalThis as unknown as {
+ navigator?: { gpu?: unknown };
+};
+
+/**
+ * Whether this environment exposes the WebGPU API.
+ *
+ * Only checks for the API's presence — an adapter can still be unavailable even
+ * where `navigator.gpu` exists, which `requestGpuDevice` reports separately.
+ */
+export function isWebGpuAvailable(): boolean {
+ return host.navigator?.gpu !== undefined;
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-kernel.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-kernel.test.ts
new file mode 100644
index 00000000000..06611455ffc
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-kernel.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+
+import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher";
+import { compileHirArtifacts } from "../hir";
+import { lowerTypeScriptToHir } from "../hir/lower-typescript";
+import { tryTranslateKernel } from "./try-translate-kernel";
+
+import type { HirFunction } from "../hir/hir";
+
+const sdcpn = probabilisticSatellitesSDCPN.petriNetDefinition;
+/** Any transition with a typed output place, so a kernel context can be built. */
+const transition = sdcpn.transitions.find(
+ (candidate) => candidate.name === "LaunchSatellite",
+)!;
+
+function lowerKernel(code: string): HirFunction {
+ const result = lowerTypeScriptToHir(code, "kernel");
+ if (!result.ok) {
+ throw new Error(
+ `test kernel did not lower: ${result.diagnostics
+ .map((diagnostic) => diagnostic.message)
+ .join("; ")}`,
+ );
+ }
+ return result.fn;
+}
+
+describe("tryTranslateKernel", () => {
+ it("accepts the example's own kernels, distributions included", () => {
+ // Through the artifact, which is the path the report uses: lowering the raw
+ // source without a kernel context yields different HIR, because parameter
+ // references are only resolved to `paramRef` when the context supplies them.
+ const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
+ includeHir: true,
+ });
+
+ let checked = 0;
+ for (const candidate of sdcpn.transitions) {
+ const hir = artifacts.kernels[candidate.id]?.hir;
+ if (!hir) {
+ continue;
+ }
+ checked += 1;
+ expect(
+ tryTranslateKernel({ sdcpn, transition: candidate, hir }),
+ ).toStrictEqual({ translatable: true });
+ }
+ expect(checked).toBeGreaterThan(0);
+ });
+
+ it("accepts a kernel that samples every distribution family", () => {
+ // These are the nodes the emitter refused until the samplers were wired up,
+ // and they are what kernels use most.
+ const hir = lowerKernel(`
+ export default TransitionKernel(() => {
+ const a = Distribution.Gaussian(0, 1);
+ const b = Distribution.Uniform(0, 1);
+ const c = Distribution.Lognormal(0, 1);
+ return a.map((v) => v) + b.map((v) => v) + c.map((v) => v);
+ })
+ `);
+
+ expect(tryTranslateKernel({ sdcpn, transition, hir }).translatable).toBe(
+ true,
+ );
+ });
+
+ it("refuses a kernel that generates a uuid, and says why", () => {
+ // 128 bits, which WGSL cannot represent — a limit of the code, not of the
+ // backend's missing slot support, and the report needs to tell them apart.
+ const hir = lowerKernel(
+ "export default TransitionKernel(() => Uuid.generate())",
+ );
+ const result = tryTranslateKernel({ sdcpn, transition, hir });
+
+ expect(result.translatable).toBe(false);
+ expect(result.translatable ? "" : result.reason).toMatch(/uuid/i);
+ });
+
+ it("refuses a kernel that builds a string", () => {
+ const hir = lowerKernel(
+ 'export default TransitionKernel(() => "launched")',
+ );
+ const result = tryTranslateKernel({ sdcpn, transition, hir });
+
+ expect(result.translatable).toBe(false);
+ expect(result.translatable ? "" : result.reason).toMatch(/string/i);
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-kernel.ts b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-kernel.ts
new file mode 100644
index 00000000000..bd8a4abb554
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-kernel.ts
@@ -0,0 +1,99 @@
+/**
+ * Asks whether a transition kernel's expressions could be translated to WGSL.
+ *
+ * This is **not** a kernel emitter. The generated shader has nowhere to write
+ * output tokens — its fire block only adjusts counts — so no kernel runs on the
+ * GPU today whatever this returns.
+ *
+ * It answers the narrower question the Compilation panel needs: is a kernel held
+ * up by the *backend* (slot allocation, still to be built) or by its own *code*
+ * (a `string` attribute, a generated `uuid`)? Those are the same "runs on the
+ * CPU" outcome but completely different work, and reporting them identically
+ * told the author nothing about which.
+ *
+ * Input tokens are bound to a placeholder accessor rather than real state
+ * offsets, because whether `tokens.Space[0].x` resolves to a load is a property
+ * of the future slot support, not of the expression.
+ */
+import { buildKernelContext } from "../hir/surface-context";
+import { resolveNetParameterValues } from "../parameter-values";
+import { WgslBailError, WgslEmitter } from "./emit-wgsl";
+
+import type { PetrinautExtensionSettings } from "../extensions";
+import type { HirFunction } from "../hir/hir";
+import type { SDCPN, Transition } from "../types/sdcpn";
+import type { WgslValue } from "./emit-wgsl";
+
+/** Name the probe uses for the generator; the real one is chosen when emitting. */
+const PROBE_RNG_STATE_VAR = "rng_state";
+
+export type KernelTranslationResult =
+ | { translatable: true }
+ | { translatable: false; reason: string };
+
+export function tryTranslateKernel({
+ sdcpn,
+ transition,
+ hir,
+ extensions,
+ parameterValues,
+}: {
+ sdcpn: SDCPN;
+ transition: Transition;
+ hir: HirFunction;
+ extensions?: PetrinautExtensionSettings;
+ /**
+ * Resolved parameter values. Defaults to the net's own declared defaults —
+ * the shader inlines parameters as literals, so an absent one fails emission
+ * with `unknown parameter ...`, which would read as the kernel's fault.
+ */
+ parameterValues?: Readonly>;
+}): KernelTranslationResult {
+ try {
+ const context = buildKernelContext(sdcpn, transition, extensions);
+ const emitter = new WgslEmitter({
+ parameterValues:
+ parameterValues ??
+ resolveNetParameterValues(
+ sdcpn.parameters,
+ {},
+ extensions?.parameters ?? true,
+ ),
+ rngStateVar: PROBE_RNG_STATE_VAR,
+ });
+
+ const env = new Map();
+ const tokensParam = hir.params[0];
+ if (tokensParam) {
+ // One entry per input slot, each a tuple of the arc's weight. Attribute
+ // reads resolve to a constant: the probe is about translatability, and a
+ // misspelled attribute is already a typecheck error upstream.
+ env.set(tokensParam.name, {
+ kind: "record",
+ fields: new Map(
+ context.inputSlots.map((slot) => [
+ slot.name,
+ {
+ kind: "array" as const,
+ elements: Array.from(
+ { length: slot.tokenCount },
+ (): WgslValue => ({
+ kind: "token",
+ read: () => ({ kind: "f32", code: "0.0" }),
+ }),
+ ),
+ },
+ ]),
+ ),
+ });
+ }
+
+ emitter.emit(hir.body, env);
+ return { translatable: true };
+ } catch (error) {
+ if (error instanceof WgslBailError) {
+ return { translatable: false, reason: error.message };
+ }
+ throw error;
+ }
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/webgpu-experiment-backend.ts b/libs/@hashintel/petrinaut-core/src/webgpu/webgpu-experiment-backend.ts
new file mode 100644
index 00000000000..130cc10112e
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/webgpu-experiment-backend.ts
@@ -0,0 +1,158 @@
+/**
+ * The WebGPU experiment backend, behind the shared `ExperimentBackend` contract.
+ *
+ * Lives in the `webgpu/` subtree, and is reached through the `./webgpu` entry
+ * point, so registering it does not pull the shader generator into a bundle that
+ * never runs a GPU experiment. `selectExperimentBackend` loads a registration
+ * only when it reaches it.
+ *
+ * Assessment here is genuinely cheap relative to a run and deliberately
+ * device-free: `createGpuMonteCarloExperiment` sequences eligibility, HIR
+ * lowering, shader generation and only then device acquisition, so everything
+ * about the *net* is settled before any scarce resource is touched. This adapter
+ * currently performs both phases inside `instantiate`, because that function is
+ * the seam that exists today; splitting it so `assess` stops before
+ * `requestGpuDevice` is a further step, and worth taking only if a caller needs
+ * to assess without acquiring.
+ */
+import { createGpuMonteCarloExperiment } from "./gpu-experiment-handle";
+import { isWebGpuAvailable } from "./support";
+
+import type {
+ ExperimentAssessment,
+ ExperimentBlockerOrigin,
+ ExperimentBlockers,
+ ExperimentNote,
+} from "../experiments/experiment-assessment";
+import type { ExperimentBackend } from "../experiments/experiment-backend";
+import type { ExperimentRequest } from "../experiments/experiment-request";
+import type { GpuOdeMethod } from "./compile-net-shader";
+import type { CreateGpuMonteCarloExperimentResult } from "./gpu-experiment-handle";
+
+export const WEBGPU_BACKEND_ID = "webgpu";
+
+export type WebGpuExperimentBackendOptions = {
+ /**
+ * Integration method for continuous dynamics.
+ *
+ * Bound at construction rather than carried on the request: it is a property of
+ * how this backend computes, and no other backend has an opinion about it.
+ * Defaults to RK4 — see `backend.ts` for why that is not Euler.
+ */
+ odeMethod?: GpuOdeMethod;
+};
+
+/**
+ * Maps a refusal to who can act on it.
+ *
+ * `shader-generation` counts as `model` rather than `environment`: the emitter
+ * failed on *this net's* expressions, so editing the net is what changes the
+ * answer. `no-device` is the only one the author cannot do anything about.
+ */
+function originFor(
+ cause: Extract["cause"],
+): ExperimentBlockerOrigin {
+ switch (cause) {
+ case "no-device":
+ return "environment";
+ case "metrics-unsupported":
+ return "configuration";
+ case "net-unsupported":
+ case "shader-generation":
+ return "model";
+ }
+}
+
+function assess(
+ request: ExperimentRequest,
+ options: WebGpuExperimentBackendOptions,
+): ExperimentAssessment {
+ if (request.hirArtifacts === undefined) {
+ const blockers: ExperimentBlockers = [
+ {
+ code: "missing-hir-trees",
+ message:
+ "The GPU backend generates a shader from the net's lowered code, which was not supplied. Compile with `includeHir` to run on the GPU.",
+ origin: "configuration",
+ },
+ ];
+ return { eligible: false, blockers };
+ }
+ const hirArtifacts = request.hirArtifacts;
+
+ return {
+ eligible: true,
+ // Assembled by `createGpuMonteCarloExperiment`, so they are not known until
+ // instantiation; anything discovered then is delivered through `onNote`.
+ notes: [],
+ instantiate: async (instantiateOptions) => {
+ const created = await createGpuMonteCarloExperiment({
+ sdcpn: request.sdcpn,
+ hirArtifacts,
+ ...(request.extensions === undefined
+ ? {}
+ : { extensions: request.extensions }),
+ initialMarking: request.initialMarking,
+ parameterValues: { ...request.parameterValues },
+ seed: request.seed,
+ dt: request.dt,
+ maxTime: request.maxTime,
+ runCount: request.runCount,
+ metricSpecs: request.metricSpecs,
+ ...(options.odeMethod === undefined
+ ? {}
+ : { odeMethod: options.odeMethod }),
+ ...(instantiateOptions?.onNote === undefined
+ ? {}
+ : {
+ onWarning: (warning: string) => {
+ instantiateOptions.onNote?.({
+ code: "gpu-runtime-warning",
+ message: warning,
+ });
+ },
+ }),
+ });
+
+ if (!created.supported) {
+ const blockers: ExperimentBlockers = [
+ {
+ code: created.cause,
+ message: created.reason,
+ origin: originFor(created.cause),
+ },
+ ];
+ return { ok: false, blockers };
+ }
+
+ for (const warning of created.warnings) {
+ const note: ExperimentNote = {
+ code: "gpu-setup-warning",
+ message: warning,
+ };
+ instantiateOptions?.onNote?.(note);
+ }
+
+ return {
+ ok: true,
+ handle: created.handle,
+ runtimeInfo: created.deviceInfo,
+ };
+ },
+ };
+}
+
+export function createWebGpuExperimentBackend(
+ options: WebGpuExperimentBackendOptions = {},
+): ExperimentBackend {
+ return {
+ id: WEBGPU_BACKEND_ID,
+ label: "GPU (WebGPU)",
+ // The shader is generated from the HIR trees, and this backend cannot lower
+ // the net itself: that needs the TypeScript frontend, which must not reach a
+ // browser bundle.
+ needsHirTrees: true,
+ isAvailable: isWebGpuAvailable,
+ assess: (request) => Promise.resolve(assess(request, options)),
+ };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/wgsl-identifiers.ts b/libs/@hashintel/petrinaut-core/src/webgpu/wgsl-identifiers.ts
new file mode 100644
index 00000000000..807821f2fde
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/wgsl-identifiers.ts
@@ -0,0 +1,267 @@
+/**
+ * WGSL identifier hygiene.
+ *
+ * User code names bindings freely, but WGSL reserves a large vocabulary — far
+ * more than JavaScript does, and including innocuous words like `active`,
+ * `sample`, `filter` and `buffer` that real model code plausibly uses. A
+ * collision is a shader compile error, which surfaces as a broken simulation
+ * rather than a diagnostic, so every emitted name is mangled rather than
+ * checked against the list case by case.
+ */
+
+/**
+ * Reserved and predeclared words that may not be used as WGSL identifiers.
+ *
+ * From the WGSL spec's reserved-words list plus the predeclared type and
+ * builtin-function names an emitted program could otherwise shadow.
+ */
+const WGSL_RESERVED = new Set([
+ // Keywords
+ "alias",
+ "break",
+ "case",
+ "const",
+ "const_assert",
+ "continue",
+ "continuing",
+ "default",
+ "diagnostic",
+ "discard",
+ "else",
+ "enable",
+ "false",
+ "fn",
+ "for",
+ "if",
+ "let",
+ "loop",
+ "override",
+ "requires",
+ "return",
+ "struct",
+ "switch",
+ "true",
+ "var",
+ "while",
+ // Reserved for future use — the spec forbids these outright.
+ "NULL",
+ "Self",
+ "abstract",
+ "active",
+ "alignas",
+ "alignof",
+ "as",
+ "asm",
+ "asm_fragment",
+ "async",
+ "attribute",
+ "auto",
+ "await",
+ "become",
+ "cast",
+ "catch",
+ "class",
+ "co_await",
+ "co_return",
+ "co_yield",
+ "coherent",
+ "column_major",
+ "common",
+ "compile",
+ "compile_fragment",
+ "concept",
+ "const_cast",
+ "consteval",
+ "constinit",
+ "constexpr",
+ "crate",
+ "debugger",
+ "decltype",
+ "delete",
+ "demote",
+ "demote_to_helper",
+ "do",
+ "dynamic_cast",
+ "enum",
+ "explicit",
+ "export",
+ "extends",
+ "extern",
+ "external",
+ "fallthrough",
+ "filter",
+ "final",
+ "finally",
+ "friend",
+ "from",
+ "fxgroup",
+ "get",
+ "goto",
+ "groupuniform",
+ "highp",
+ "impl",
+ "implements",
+ "import",
+ "inline",
+ "instanceof",
+ "interface",
+ "layout",
+ "lowp",
+ "macro",
+ "macro_rules",
+ "match",
+ "mediump",
+ "meta",
+ "mod",
+ "module",
+ "move",
+ "mut",
+ "mutable",
+ "namespace",
+ "new",
+ "nil",
+ "noexcept",
+ "noinline",
+ "nointerpolation",
+ "non_coherent",
+ "noncoherent",
+ "noperspective",
+ "null",
+ "nullptr",
+ "of",
+ "operator",
+ "package",
+ "packoffset",
+ "partition",
+ "pass",
+ "patch",
+ "pixelfragment",
+ "precise",
+ "precision",
+ "premerge",
+ "priv",
+ "protected",
+ "pub",
+ "public",
+ "readonly",
+ "ref",
+ "regardless",
+ "register",
+ "reinterpret_cast",
+ "require",
+ "resource",
+ "restrict",
+ "self",
+ "set",
+ "shared",
+ "sizeof",
+ "smooth",
+ "snorm",
+ "static",
+ "static_assert",
+ "static_cast",
+ "std",
+ "subroutine",
+ "super",
+ "target",
+ "template",
+ "this",
+ "thread_local",
+ "throw",
+ "trait",
+ "try",
+ "type",
+ "typedef",
+ "typeid",
+ "typename",
+ "typeof",
+ "union",
+ "unless",
+ "unorm",
+ "unsafe",
+ "unsized",
+ "use",
+ "using",
+ "varying",
+ "virtual",
+ "volatile",
+ "wgsl",
+ "where",
+ "with",
+ "writeonly",
+ "yield",
+ // Predeclared types and common builtins worth not shadowing.
+ "array",
+ "atomic",
+ "bool",
+ "f16",
+ "f32",
+ "i32",
+ "mat2x2",
+ "mat3x3",
+ "mat4x4",
+ "ptr",
+ "sampler",
+ "texture_1d",
+ "texture_2d",
+ "texture_3d",
+ "u32",
+ "vec2",
+ "vec3",
+ "vec4",
+ "abs",
+ "all",
+ "any",
+ "ceil",
+ "clamp",
+ "cos",
+ "cross",
+ "dot",
+ "exp",
+ "floor",
+ "fract",
+ "length",
+ "log",
+ "max",
+ "min",
+ "mix",
+ "modf",
+ "normalize",
+ "pow",
+ "round",
+ "select",
+ "sign",
+ "sin",
+ "smoothstep",
+ "sqrt",
+ "step",
+ "tan",
+ "trunc",
+]);
+
+/** Whether `name` cannot be used verbatim as a WGSL identifier. */
+export function isReservedWgslIdentifier(name: string): boolean {
+ return WGSL_RESERVED.has(name);
+}
+
+/**
+ * Mangles a user-authored name into a WGSL identifier.
+ *
+ * Every name is prefixed rather than only the reserved ones, so a model that
+ * happens to use a future reserved word does not start failing when the WGSL
+ * vocabulary grows. Characters WGSL does not accept are replaced, and a
+ * disambiguating ordinal keeps two names that sanitize alike apart.
+ *
+ * The ordinal only disambiguates names from **one** emitter. Two emitters both
+ * counting from zero produce the same identifiers, which is an error if their
+ * statements land in one WGSL scope — as the RK stages of a dynamics loop do.
+ * Such callers must pass a `scope` that differs per emitter.
+ */
+export function mangleWgslIdentifier(
+ name: string,
+ ordinal: number,
+ scope = "",
+): string {
+ const sanitized = name.replaceAll(/[^A-Za-z0-9_]/gu, "_");
+ return `${scope}u_${ordinal}_${sanitized}`;
+}
diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/wgsl-prelude.ts b/libs/@hashintel/petrinaut-core/src/webgpu/wgsl-prelude.ts
new file mode 100644
index 00000000000..f2bc4c866eb
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/webgpu/wgsl-prelude.ts
@@ -0,0 +1,91 @@
+/**
+ * Fixed WGSL support code shared by every generated shader.
+ *
+ * Kept separate from the generated body so it can be snapshot-tested once and
+ * read as ordinary WGSL rather than as template-string fragments.
+ */
+
+/**
+ * Counter-based RNG.
+ *
+ * The CPU engine's generator (`../simulation/engine/seeded-rng.ts`) cannot be
+ * reproduced here: it computes `1103515245 * seed` in f64, which exceeds 2^53
+ * for all but 0.4% of its seed space, so its stream is V8's rounding behaviour
+ * rather than a mathematically defined LCG. Emulating that in WGSL's 32-bit
+ * integers is not practical, so this backend uses PCG instead — which is also
+ * a far better generator: full 2^32 period per stream against the CPU
+ * generator's measured 10,466-step cycle.
+ *
+ * The consequence is deliberate and must be stated wherever backends are
+ * compared: the GPU backend does not reproduce CPU trajectories seed for seed.
+ */
+export const WGSL_RNG = `
+// PCG-RXS-M-XS, 32-bit state and output.
+fn rng_next_u32(rng: ptr) -> u32 {
+ let previous = *rng;
+ *rng = previous * 747796405u + 2891336453u;
+ let word = ((previous >> ((previous >> 28u) + 4u)) ^ previous) * 277803737u;
+ return (word >> 22u) ^ word;
+}
+
+// Uniform in [0, 1). Dividing a 24-bit mantissa keeps every result exactly
+// representable in f32, which a full 32-bit divide would not.
+fn rng_next_f32(rng: ptr) -> f32 {
+ return f32(rng_next_u32(rng) >> 8u) * 5.9604645e-8;
+}
+
+// Seeds one stream per (run, replicate) pair. Mixing with a large odd constant
+// and one PCG advance decorrelates adjacent run indices, which sequential
+// seeding alone would leave visibly correlated in the first few draws.
+fn rng_seed(base: u32, run_index: u32) -> u32 {
+ var seeded = base ^ (run_index * 2654435761u);
+ seeded = seeded * 747796405u + 2891336453u;
+ return seeded;
+}
+`;
+
+/**
+ * Gaussian sampling.
+ *
+ * Box–Muller rather than the Ziggurat method: it needs no lookup tables (which
+ * would cost a storage binding and a memory round-trip per sample) and its two
+ * transcendental calls are cheap on a GPU. One of the two normals it produces is
+ * discarded, which is the usual trade for not carrying spare state per token.
+ */
+export const WGSL_DISTRIBUTIONS = `
+fn sample_gaussian(rng: ptr, mean: f32, deviation: f32) -> f32 {
+ // Guard the log against exactly zero, which rng_next_f32 can return.
+ let u1 = max(rng_next_f32(rng), 1.0e-7);
+ let u2 = rng_next_f32(rng);
+ return mean + deviation * sqrt(-2.0 * log(u1)) * cos(6.2831853071795862 * u2);
+}
+
+fn sample_uniform(rng: ptr, low: f32, high: f32) -> f32 {
+ return low + (high - low) * rng_next_f32(rng);
+}
+
+fn sample_lognormal(rng: ptr, mean: f32, deviation: f32) -> f32 {
+ return exp(sample_gaussian(rng, mean, deviation));
+}
+`;
+
+/**
+ * Firing acceptance test, matching the CPU engine's rule.
+ *
+ * The CPU path compares `exp(-lambda * elapsed) > u` and skips when true
+ * (`../simulation/monte-carlo/transition-effect.ts`), so firing happens when
+ * `exp(-lambda * elapsed) <= u`. A predicate lambda arrives as a boolean and
+ * bypasses this entirely rather than going through the CPU's `Infinity`
+ * sentinel, which would be a NaN hazard in f32.
+ */
+export const WGSL_FIRING = `
+fn accepts_firing(lambda_value: f32, elapsed_seconds: f32, u: f32) -> bool {
+ let lambda_total = lambda_value * elapsed_seconds;
+ return exp(-lambda_total) <= u;
+}
+`;
+
+/** Every prelude section, in dependency order. */
+export function wgslPrelude(): string {
+ return [WGSL_RNG, WGSL_DISTRIBUTIONS, WGSL_FIRING].join("\n");
+}
diff --git a/libs/@hashintel/petrinaut-core/tsconfig.json b/libs/@hashintel/petrinaut-core/tsconfig.json
index 141f53018c5..d3ef95495e3 100644
--- a/libs/@hashintel/petrinaut-core/tsconfig.json
+++ b/libs/@hashintel/petrinaut-core/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2024",
"lib": ["ESNext"],
- "types": [],
+ "types": ["@webgpu/types"],
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
diff --git a/libs/@hashintel/petrinaut-core/vite.config.ts b/libs/@hashintel/petrinaut-core/vite.config.ts
index 336a63db279..2c1f6000e0f 100644
--- a/libs/@hashintel/petrinaut-core/vite.config.ts
+++ b/libs/@hashintel/petrinaut-core/vite.config.ts
@@ -23,9 +23,13 @@ export default defineConfig(({ command }) => ({
// Dependency-free instantiation of compiled HIR artifacts.
"hir-runtime": resolve(packageRoot, "src/hir-runtime.ts"),
optimization: resolve(packageRoot, "src/optimization.ts"),
- // Backend contract and selection. A separate entry so a heavy backend
- // can be registered without dragging its implementation in with it.
+ // Backend contract and selection. Separate entry so registering the
+ // WebGPU backend does not drag the shader generator in with it: this
+ // holds the contract and the worker-pool backend only.
experiments: resolve(packageRoot, "src/experiments.ts"),
+ // Experimental WebGPU compute backend. Separate entry: it pulls in the
+ // HIR frontend and is opt-in, so it must stay out of the main bundle.
+ webgpu: resolve(packageRoot, "src/webgpu.ts"),
"examples/index": resolve(packageRoot, "src/examples/index.ts"),
"workers/lsp": resolve(packageRoot, "src/workers/lsp.ts"),
"workers/monte-carlo": resolve(
diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md
index 976a7d728c6..48581377ccd 100644
--- a/libs/@hashintel/petrinaut/docs/README.md
+++ b/libs/@hashintel/petrinaut/docs/README.md
@@ -40,4 +40,5 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl
- [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch.
- [AI Assistant](ai-assistant.md) -- Build, review, and revise nets using natural language.
- [Visual Settings](visual-settings.md) -- Configure the editor appearance and behavior.
+- [Compilation Output](compilation-output.md) -- Inspect how your net's code compiled, and what stops it running on the GPU.
- [Examples](examples.md) -- Walkthrough of the built-in example nets.
diff --git a/libs/@hashintel/petrinaut/docs/compilation-output.md b/libs/@hashintel/petrinaut/docs/compilation-output.md
new file mode 100644
index 00000000000..070ceae8354
--- /dev/null
+++ b/libs/@hashintel/petrinaut/docs/compilation-output.md
@@ -0,0 +1,47 @@
+# Compilation Output
+
+The **Compilation** tab explains what Petrinaut's compiler made of your net's code: which conditions, kernels and differential equations were understood, and what stops the net running on the [GPU backend](experiments.md#compute-backend-experimental).
+
+It is a diagnostic view about the compiler, not about your model — for errors in your code, use [Diagnostics](petri-net-extensions.md#diagnostics) instead.
+
+## Turning it on
+
+Under **Settings → Simulation**, switch on **Compilation output**. A **Compilation** tab appears in the bottom panel. It is off by default.
+
+## What it shows
+
+### The verdict line
+
+A pill reads **Runs on GPU** or **CPU only**, followed by:
+
+- **B/run** -- bytes of GPU state one simulation run needs. The backend refuses nets above 4096 bytes, so this is the number to watch when raising [token capacities](drawing-a-net.md#token-capacity).
+- **lines of WGSL** -- size of the generated shader, when one was generated.
+- **compiled items** -- how many pieces of user code the net contains.
+
+### Blocks GPU compilation
+
+Structural reasons the net was refused before any code was generated — a typed place without a capacity, an unsupported attribute type, an arc consuming more than one typed token. Each reason names the item; click it to select that item on the canvas.
+
+### Shader emission failed
+
+The net passed the structural checks, but the generator could not turn some expression into GPU code. The message is the generator's own, so it describes the expression rather than your model. A common cause is an arc that consumes three or more typed tokens at once: conditions reading token attributes are supported at weight 1 and 2, but not beyond.
+
+When a transition kernel reads as **CPU**, the detail names what WGSL cannot express — a `string` attribute, a generated `uuid`. A net with such a kernel is refused rather than run: a produced token whose attributes were never written would report zeros as results.
+
+### Compiled code
+
+One row per piece of user code — transition conditions, transition kernels, and per-place dynamics — with the size of its compiled expression and where it can run:
+
+| Label | Meaning |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **GPU** | Compiled, and the GPU backend can run it. |
+| **CPU** | Compiled, but only the CPU engine can run it. Select the item to see whether that is because of the backend or because of the code. |
+| **untested** | Compiled, but the net was refused before generation, so this was never tried either way. |
+| **no HIR** | Did not compile. Check [Diagnostics](petri-net-extensions.md#diagnostics) for why. |
+| **unused** | Neither engine uses this code — the relevant [extension](petri-net-extensions.md) is off, or a transition kernel has no typed output place to write to. |
+
+Select a node on the canvas and the list narrows to that node's code and shows its detail. With nothing selected you get the whole net.
+
+## Node counts
+
+The node count is the size of the compiled expression tree, not of your source text. Comments, formatting and intermediate variables do not affect it. `parameters.infection_rate` is one node; a comparison between two computed distances is a dozen. It is a rough measure of how much work a condition does per firing check.
diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md
index 54a947c8c41..8da2baac84a 100644
--- a/libs/@hashintel/petrinaut/docs/experiments.md
+++ b/libs/@hashintel/petrinaut/docs/experiments.md
@@ -21,6 +21,7 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo
| **Runs** | `1000` | Positive integer; how many independent simulations to run. |
| **Time step (dt)** | `0.1` | Same meaning as in single-run simulations (see [Simulation](simulation.md#time-step-dt)). |
| **Max time (seconds)** | `180` | Each run advances until simulation time reaches this value, then completes. |
+| **Run on GPU** | off | Only shown when **WebGPU** is on under **Settings → Simulation**. Greyed out with the reason on hover when this model cannot run on the GPU. See [Compute backend](#compute-backend-experimental). |
The model used is a snapshot of the current net at the time you press **Run**. Editing the net afterwards does not change runs that have already started.
@@ -51,6 +52,51 @@ Two consequences worth knowing:
- Progress reports the slowest worker's position, so the progress bar never runs ahead of the results behind it.
- Several experiments running at once each use the same number of workers, so they compete for cores and all of them slow down. Run them one at a time if you want any single one to finish as fast as possible.
+### Compute backend (experimental)
+
+Experiments run on the CPU unless you ask for the GPU. Switch on **WebGPU** under **Settings → Simulation**, and the Create Experiment drawer gains a **Run on GPU** switch. Running on your graphics hardware is dramatically faster — a 4000-run experiment that takes six seconds on the CPU finishes in a few milliseconds.
+
+The choice is per experiment, not global, so a GPU experiment and a CPU experiment can run side by side — useful for comparing the two on the same model. Each gets its own GPU device, so nothing is shared between them.
+
+The switch is greyed out when the current model cannot run on the GPU; hover it for the reason. The setting is only offered where your browser exposes WebGPU. Chrome, Edge and Safari 26+ do; Firefox needs it enabled.
+
+The GPU backend handles a **subset** of nets, and it tells you when it cannot take one rather than guessing. It needs:
+
+- **fewer than 256 tokens in any place a metric measures.** Metrics are reduced on the device into a histogram with one bin per token count, so counts of 256 or more cannot be told apart. A net whose measured place already starts above that is refused; one that grows past it mid-run warns you that values above 255 are clamped;
+- every place that holds typed tokens to declare a [token capacity](drawing-a-net.md#token-capacity), so buffer sizes are known up front;
+- no `string` or `uuid` token attributes, which need more than the 32 bits WebGPU offers;
+- **arcs consuming at most two typed tokens per place.** A condition that reads token attributes runs on the GPU at weight 1 and at weight 2 — a pairwise condition like a collision test is scanned over every pair — but not beyond;
+- typed tokens consumed from at most one place per transition, since two would be a product across arcs;
+- metrics that measure place token counts, without a time aggregation.
+
+When an experiment does not qualify, it runs on the CPU instead and a message explains which requirement was not met. Nothing fails, and you do not need to check in advance. To see the full picture for the net you are editing — including which individual conditions and equations compiled — turn on [Compilation Output](compilation-output.md).
+
+There is also a ceiling on **run count**, because every run's state lives in one GPU buffer. How many runs fit depends on your hardware and on how much state a run needs, and Petrinaut asks your GPU for its own limit rather than the minimum every GPU must support — on an Apple M-series machine that is 4 GB rather than 128 MB. If an experiment still exceeds it, the message says how many runs would fit, and that experiment runs on the CPU.
+
+Two things to know before comparing results:
+
+- **The same seed gives different numbers on the two backends.** They use different random number generators — WebGPU cannot reproduce the CPU one — so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's summary records which backend ran it, so results stay attributable after the fact.
+- Continuous dynamics are integrated with a **more accurate method** (Runge-Kutta 4) than the CPU's, so a model with differential equations may show slightly different — better — values, not just different noise.
+- The GPU steps every run to the configured max time, while the CPU stops a run as soon as it can no longer fire anything. So a net that finishes early reports a **higher frame count and simulated time** on the GPU for the same results. Nothing is wrong with either; they just stop counting at different points.
+
+### Reading the summary
+
+Open an experiment's drawer and its **Summary** section reports:
+
+| Field | Meaning |
+| ------------ | -------------------------------------------------------------------------------------------------------------------- |
+| **Status** | One of the five statuses above. |
+| **Scenario** | The scenario the experiment runs, or `Default`. |
+| **Runs** | How many runs are in flight, and how many have finished. |
+| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. |
+| **Frame** | The frame number reached — the slowest worker's position, so it never runs ahead of the results. |
+| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. |
+| **Elapsed** | Clock time the experiment has been simulating. Once it stops, this becomes **Duration** and holds the total it took. |
+
+A badge beside the **Summary** heading shows whether the run used the **CPU** or the **GPU**, and stays visible when the section is collapsed. Hover it for detail — on a CPU-backed experiment that asked for the GPU, the badge explains which requirement the net did not meet.
+
+**Elapsed** and **Duration** measure simulating only. Compiling the net's user code and starting the workers (or acquiring the GPU device and compiling the shader) happens before the clock starts, so the number is comparable between the two backends. An experiment that fails before it starts simulating shows `—` rather than a duration.
+
### Actions
In the experiment's view drawer (open it by clicking a row in the list, or any experiment in the top-bar **Active experiments** popover):
diff --git a/libs/@hashintel/petrinaut/src/panda-preset.ts b/libs/@hashintel/petrinaut/src/panda-preset.ts
index 30eeb376ede..dcdfa130026 100644
--- a/libs/@hashintel/petrinaut/src/panda-preset.ts
+++ b/libs/@hashintel/petrinaut/src/panda-preset.ts
@@ -66,6 +66,22 @@ export const petrinautPandaPreset = {
"0 2px 6px rgba(0, 220, 255, 0.03), 0 -2px 6px rgba(255, 0, 128, 0.045)",
},
},
+ /**
+ * Breathing purple glow for the GPU side of the experiment backend
+ * toggle. Both steps repeat the control's inset shadows, because
+ * animating `box-shadow` replaces the whole property and the depth
+ * would otherwise vanish for the duration.
+ */
+ petrinautGpuGlow: {
+ "0%, 100%": {
+ boxShadow:
+ "inset 0 2px 4px rgba(0, 0, 0, 0.05), inset 0 0 0 1px var(--colors-black-a10), 0 0 5px var(--colors-purple-a30), 0 0 11px var(--colors-purple-a15)",
+ },
+ "50%": {
+ boxShadow:
+ "inset 0 2px 4px rgba(0, 0, 0, 0.05), inset 0 0 0 1px var(--colors-black-a10), 0 0 9px var(--colors-purple-a50), 0 0 20px var(--colors-purple-a30)",
+ },
+ },
petrinautExpand: {
from: { height: "0", opacity: "0" },
to: { height: "var(--height)", opacity: "1" },
diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts
new file mode 100644
index 00000000000..f4848034ea9
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ type ExperimentRecord,
+ type ExperimentStatus,
+ getExperimentElapsedMs,
+ isExperimentActive,
+ isTerminalExperimentStatus,
+} from "./context";
+
+function makeRecord(overrides: Partial): ExperimentRecord {
+ return {
+ id: "experiment",
+ name: "Experiment",
+ createdAt: 1_000,
+ scenarioId: null,
+ scenarioName: null,
+ runCount: 1,
+ seed: 1,
+ dt: 1,
+ maxTime: 10,
+ status: "running",
+ error: null,
+ metricSpecs: [],
+ computeBackend: "cpu",
+ computeBackendFallbackReason: null,
+ startedAt: null,
+ finishedAt: null,
+ progress: null,
+ metricFrames: [],
+ latestMetricFramesById: {},
+ ...overrides,
+ };
+}
+
+const ALL_STATUSES: ExperimentStatus[] = [
+ "initializing",
+ "running",
+ "complete",
+ "error",
+ "cancelled",
+];
+
+describe("isTerminalExperimentStatus", () => {
+ it("partitions every status into exactly active or terminal", () => {
+ // The two must stay exact complements: `isExperimentActive` is defined as the
+ // negation, and the provider stamps `finishedAt` off the terminal side.
+ const terminal = ALL_STATUSES.filter(isTerminalExperimentStatus);
+ const active = ALL_STATUSES.filter(
+ (status) => !isTerminalExperimentStatus(status),
+ );
+
+ expect(terminal).toStrictEqual(["complete", "error", "cancelled"]);
+ expect(active).toStrictEqual(["initializing", "running"]);
+
+ for (const status of ALL_STATUSES) {
+ expect(isExperimentActive(makeRecord({ status }))).toBe(
+ !isTerminalExperimentStatus(status),
+ );
+ }
+ });
+});
+
+describe("getExperimentElapsedMs", () => {
+ it("measures against the live clock while still running", () => {
+ const experiment = makeRecord({ startedAt: 5_000 });
+
+ expect(getExperimentElapsedMs(experiment, 8_500)).toBe(3_500);
+ });
+
+ it("freezes at the finish time once finished", () => {
+ const experiment = makeRecord({
+ status: "complete",
+ startedAt: 5_000,
+ finishedAt: 6_250,
+ });
+
+ // The clock has moved a long way past the finish; the duration must not.
+ expect(getExperimentElapsedMs(experiment, 900_000)).toBe(1_250);
+ });
+
+ it("reports null when stepping never began", () => {
+ // An experiment that failed while compiling has no runtime to report, which
+ // is different from a runtime of zero.
+ const experiment = makeRecord({
+ status: "error",
+ error: "did not compile",
+ startedAt: null,
+ finishedAt: 6_000,
+ });
+
+ expect(getExperimentElapsedMs(experiment, 9_000)).toBeNull();
+ });
+
+ it("clamps rather than going negative if the clock moves backwards", () => {
+ const experiment = makeRecord({ startedAt: 5_000 });
+
+ expect(getExperimentElapsedMs(experiment, 4_000)).toBe(0);
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.ts
index 46fea6e6099..864ce25a83b 100644
--- a/libs/@hashintel/petrinaut/src/react/experiments/context.ts
+++ b/libs/@hashintel/petrinaut/src/react/experiments/context.ts
@@ -23,6 +23,16 @@ export type ExperimentMetricSpecInput =
| Exclude
| Omit;
+/**
+ * Engine an experiment should try to use.
+ *
+ * Passed per experiment rather than read from user settings inside the
+ * provider, because `UserSettingsProvider` is mounted *inside*
+ * `ExperimentsProvider` (see `petrinaut-provider.tsx`) and so is not visible
+ * there. The create-experiment surface reads the setting and passes it here.
+ */
+export type ExperimentComputeBackend = "cpu" | "webgpu";
+
export type CreateExperimentInput = {
name: string;
scenarioId: string | null;
@@ -32,6 +42,14 @@ export type CreateExperimentInput = {
dt: number;
maxTime: number;
metricSpecs: readonly ExperimentMetricSpecInput[];
+ /**
+ * Backend to attempt. Defaults to `cpu`.
+ *
+ * `webgpu` is a request, not a guarantee: a net the GPU backend cannot run
+ * falls back to the CPU, and `ExperimentRecord.computeBackend` records which
+ * one actually ran.
+ */
+ computeBackend?: ExperimentComputeBackend;
};
export type ExperimentRecord = {
@@ -47,6 +65,31 @@ export type ExperimentRecord = {
status: ExperimentStatus;
error: string | null;
metricSpecs: readonly ExperimentMetricSpecInput[];
+ /**
+ * Backend that actually ran this experiment.
+ *
+ * Recorded because the two are not numerically interchangeable — the GPU
+ * backend uses a different random generator, so the same seed gives different
+ * (statistically equivalent) trajectories.
+ */
+ computeBackend: ExperimentComputeBackend;
+ /** Why the GPU backend was not used, when `webgpu` was requested but declined. */
+ computeBackendFallbackReason: string | null;
+ /**
+ * When stepping began — i.e. when the engine handle was started, after user
+ * code compiled and the workers (or the GPU device and shader) were ready.
+ *
+ * Deliberately later than `createdAt`: setup cost differs between backends,
+ * so including it would make the two look different for reasons that have
+ * nothing to do with how fast they simulate. `null` until stepping starts,
+ * which is also the case for an experiment that fails during setup.
+ */
+ startedAt: number | null;
+ /**
+ * When the experiment reached a terminal status, whether that was completion,
+ * an error or cancellation. `null` while it is still active.
+ */
+ finishedAt: number | null;
progress: MonteCarloWorkerProgress | null;
metricFrames: readonly MonteCarloUserDefinedMetricFrame[];
latestMetricFramesById: Readonly<
@@ -54,10 +97,32 @@ export type ExperimentRecord = {
>;
};
+/** Whether a status is one an experiment can never leave. */
+export function isTerminalExperimentStatus(status: ExperimentStatus): boolean {
+ return status === "complete" || status === "error" || status === "cancelled";
+}
+
export function isExperimentActive(experiment: ExperimentRecord): boolean {
- return (
- experiment.status === "initializing" || experiment.status === "running"
- );
+ return !isTerminalExperimentStatus(experiment.status);
+}
+
+/**
+ * Wall-clock milliseconds the experiment has been stepping: up to `now` while it
+ * is still running, and up to the moment it finished once it is not.
+ *
+ * `null` when stepping never began, so callers can distinguish "no time yet"
+ * from "zero time" — an experiment that failed while compiling has no runtime to
+ * report, rather than a runtime of 0.
+ */
+export function getExperimentElapsedMs(
+ experiment: ExperimentRecord,
+ now: number,
+): number | null {
+ if (experiment.startedAt === null) {
+ return null;
+ }
+
+ return Math.max(0, (experiment.finishedAt ?? now) - experiment.startedAt);
}
export type ExperimentsContextValue = {
diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx
index ab46f3aea9c..efff7f8ff10 100644
--- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx
+++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx
@@ -1,13 +1,20 @@
/**
* @vitest-environment jsdom
*/
-import { act, render, type RenderResult } from "@testing-library/react";
+import {
+ act,
+ render,
+ waitFor,
+ type RenderResult,
+} from "@testing-library/react";
import { use } from "react";
import { describe, expect, it, vi } from "vitest";
import {
+ type CompileHirArtifactsOptions,
type MonteCarloUserDefinedMetricFrame,
DEFAULT_PETRINAUT_EXTENSIONS,
+ type PetrinautExtensionSettings,
type SDCPN,
type WorkerLike,
} from "@hashintel/petrinaut-core";
@@ -138,8 +145,10 @@ class FakeMonteCarloWorker {
*
* A macrotask boundary drains the entire microtask queue, so this holds however
* many awaits setup takes. It used to await exactly two microtasks, which was the
- * count at the time and broke the moment a step was added — selecting a backend
- * inserts several.
+ * count at the time and broke the moment a step was added — selection moving
+ * behind `selectExperimentBackend` inserted three more (loading the backend,
+ * building the request, assessing it), and every test that waits for a worker
+ * message failed at once.
*/
const flushWorkerSetup = async () => {
await new Promise((resolve) => {
@@ -147,6 +156,31 @@ const flushWorkerSetup = async () => {
});
};
+/**
+ * Makes the WebGPU backend report itself available for the duration of `body`.
+ *
+ * `isWebGpuAvailable()` only checks that `navigator.gpu` exists, and jsdom has no
+ * such property — so without this the backend is skipped before it is ever asked
+ * about a net, and every refusal reads "not available in this environment".
+ * Acquiring a device still fails, which is what makes the CPU fallback happen.
+ */
+const withWebGpuAvailable = async (body: () => Promise) => {
+ const descriptor = Reflect.getOwnPropertyDescriptor(globalThis, "navigator");
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: { ...globalThis.navigator, gpu: {} },
+ });
+ try {
+ await body();
+ } finally {
+ if (descriptor) {
+ Object.defineProperty(globalThis, "navigator", descriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, "navigator");
+ }
+ }
+};
+
const sdcpnContextValue: SDCPNContextValue = {
createNewNet: () => {},
existingNets: [],
@@ -177,8 +211,8 @@ const LanguageClientOverride = ({
...value,
requestHirArtifacts:
requestHirArtifacts ??
- ((sdcpn, extensions) =>
- Promise.resolve(compileHirArtifacts(sdcpn, extensions))),
+ ((sdcpn, extensions, options) =>
+ Promise.resolve(compileHirArtifacts(sdcpn, extensions, options))),
}}
>
{children}
@@ -680,6 +714,402 @@ describe("ExperimentsProvider", () => {
}
});
+ it("asks for HIR trees only when the GPU backend is requested", async () => {
+ // Only the GPU backend reads the HIR, and carrying it roughly triples the
+ // artifact payload structured-cloned to every shard worker. A CPU
+ // experiment must not pay for it.
+ const requestedOptions: (CompileHirArtifactsOptions | undefined)[] = [];
+ const requestHirArtifacts = vi.fn(
+ (
+ sdcpn: SDCPN,
+ extensions?: PetrinautExtensionSettings,
+ options?: CompileHirArtifactsOptions,
+ ) => {
+ requestedOptions.push(options);
+ return Promise.resolve(compileHirArtifacts(sdcpn, extensions, options));
+ },
+ );
+
+ for (const computeBackend of ["cpu", "webgpu"] as const) {
+ const worker = new FakeMonteCarloWorker();
+ const { getValue, renderResult } = renderExperimentsProvider(worker, {
+ requestHirArtifacts,
+ });
+
+ try {
+ await act(async () => {
+ const createPromise = getValue().createExperiment({
+ name: `${computeBackend} experiment`,
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ computeBackend,
+ });
+ // Probing the GPU adds await points before the CPU worker exists, so
+ // wait for `init` rather than assuming a single flush reaches it.
+ await waitFor(() => {
+ expect(worker.sent.map((message) => message.type)).toContain(
+ "init",
+ );
+ });
+ worker.emit({ type: "ready" });
+ await createPromise;
+ });
+ } finally {
+ renderResult.unmount();
+ }
+ }
+
+ // Both `false`, including the run that *asked* for the GPU: jsdom exposes no
+ // `navigator.gpu`, so the GPU backend reports itself unavailable and is
+ // skipped before it is ever assessed. Nothing then needs the HIR trees, and
+ // they are not compiled — a real improvement over asking for them whenever
+ // the preference was `webgpu`, since carrying them roughly triples the
+ // artifact payload cloned to every shard worker.
+ expect(requestedOptions).toStrictEqual([
+ { includeHir: false },
+ { includeHir: false },
+ ]);
+ });
+
+ it("asks for HIR trees when the GPU backend is available to try", async () => {
+ // The counterpart to the case above: with an adapter present the GPU backend
+ // is a real candidate, so the trees it needs are compiled. It still declines
+ // here — assessment succeeds and instantiation cannot get a device — which is
+ // why a second, tree-free request follows for the CPU fallback.
+ const requestedOptions: (CompileHirArtifactsOptions | undefined)[] = [];
+ const requestHirArtifacts = vi.fn(
+ (
+ sdcpn: SDCPN,
+ extensions?: PetrinautExtensionSettings,
+ options?: CompileHirArtifactsOptions,
+ ) => {
+ requestedOptions.push(options);
+ return Promise.resolve(compileHirArtifacts(sdcpn, extensions, options));
+ },
+ );
+
+ const worker = new FakeMonteCarloWorker();
+ const { getValue, renderResult } = renderExperimentsProvider(worker, {
+ requestHirArtifacts,
+ });
+
+ try {
+ await withWebGpuAvailable(async () => {
+ await act(async () => {
+ const createPromise = getValue().createExperiment({
+ name: "gpu experiment",
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ computeBackend: "webgpu",
+ });
+ await waitFor(() => {
+ expect(worker.sent.map((message) => message.type)).toContain(
+ "init",
+ );
+ });
+ worker.emit({ type: "ready" });
+ await createPromise;
+ });
+
+ expect(requestedOptions).toStrictEqual([
+ { includeHir: true },
+ { includeHir: false },
+ ]);
+ // And it ran on the CPU, with the GPU's reason recorded.
+ expect(getValue().selectedExperiment?.computeBackend).toBe("cpu");
+ expect(
+ getValue().selectedExperiment?.computeBackendFallbackReason,
+ ).not.toBeNull();
+ });
+ } finally {
+ renderResult.unmount();
+ }
+ });
+
+ it("times stepping, not setup, and freezes the clock on completion", async () => {
+ // The clock is stubbed so that "the finish time does not move" is a real
+ // assertion. Against the real clock the whole test runs inside a single
+ // millisecond, and a re-stamped timestamp is indistinguishable from a frozen
+ // one — the test passes whether or not the provider guards the stamp.
+ const createdTime = 1_700_000_000_000;
+ let currentTime = createdTime;
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => currentTime);
+ const worker = new FakeMonteCarloWorker();
+ const { getValue, renderResult } = renderExperimentsProvider(worker);
+
+ try {
+ let createPromise!: Promise;
+
+ await act(async () => {
+ createPromise = getValue().createExperiment({
+ name: "Timed experiment",
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ });
+ await flushWorkerSetup();
+ });
+
+ // Before `ready`, the experiment is still compiling and spinning up its
+ // worker. That is setup, not simulation, so the clock has not started.
+ expect(getValue().selectedExperiment).toMatchObject({
+ createdAt: createdTime,
+ startedAt: null,
+ finishedAt: null,
+ });
+
+ // Setup took five seconds. Stepping begins after it, so that time is not
+ // charged to either backend.
+ currentTime = createdTime + 5_000;
+ await act(async () => {
+ worker.emit({ type: "ready" });
+ await createPromise;
+ });
+
+ expect(getValue().selectedExperiment).toMatchObject({
+ startedAt: createdTime + 5_000,
+ finishedAt: null,
+ });
+
+ currentTime = createdTime + 6_250;
+ await act(async () => {
+ worker.emit({
+ type: "complete",
+ progress: makeProgress({ allFinished: true, completedRuns: 1 }),
+ });
+ });
+
+ expect(getValue().selectedExperiment).toMatchObject({
+ status: "complete",
+ startedAt: createdTime + 5_000,
+ finishedAt: createdTime + 6_250,
+ });
+
+ // Completing disposes the handle, so a late worker message must not revive
+ // the record or move its timestamps.
+ currentTime = createdTime + 40_000;
+ await act(async () => {
+ worker.emit({ type: "progress", progress: makeProgress() });
+ });
+
+ expect(getValue().selectedExperiment).toMatchObject({
+ status: "complete",
+ startedAt: createdTime + 5_000,
+ finishedAt: createdTime + 6_250,
+ });
+ } finally {
+ nowSpy.mockRestore();
+ renderResult.unmount();
+ }
+ });
+
+ it("records when an errored experiment stopped", async () => {
+ // Failure is a third path to a terminal status, separate from completion and
+ // cancellation. It disposes the handle like the other two — which is what
+ // releases the backend's resources — so late worker chatter reaches nobody
+ // and cannot disturb the finish time.
+ const createdTime = 1_700_000_000_000;
+ let currentTime = createdTime;
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => currentTime);
+ const worker = new FakeMonteCarloWorker();
+ const { getValue, renderResult } = renderExperimentsProvider(worker, {
+ addNotification: () => "",
+ });
+
+ try {
+ let createPromise!: Promise;
+ await act(async () => {
+ createPromise = getValue().createExperiment({
+ name: "Erroring experiment",
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ });
+ await flushWorkerSetup();
+ worker.emit({ type: "ready" });
+ await createPromise;
+ });
+
+ currentTime = createdTime + 1_000;
+ await act(async () => {
+ worker.emit({
+ type: "error",
+ message: "Worker failed",
+ itemId: null,
+ });
+ });
+
+ expect(getValue().selectedExperiment).toMatchObject({
+ status: "error",
+ finishedAt: createdTime + 1_000,
+ });
+
+ currentTime = createdTime + 9_000;
+ await act(async () => {
+ worker.emit({ type: "progress", progress: makeProgress() });
+ });
+
+ expect(getValue().selectedExperiment?.finishedAt).toBe(
+ createdTime + 1_000,
+ );
+ } finally {
+ nowSpy.mockRestore();
+ renderResult.unmount();
+ }
+ });
+
+ it("records when a cancelled experiment stopped", async () => {
+ // Cancellation is a separate code path from completion, and an experiment
+ // cancelled during setup never started at all.
+ const worker = new FakeMonteCarloWorker();
+ const { getValue, renderResult } = renderExperimentsProvider(worker);
+
+ try {
+ await act(async () => {
+ void getValue().createExperiment({
+ name: "Cancelled during setup",
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ });
+ await flushWorkerSetup();
+ });
+
+ const experimentId = getValue().selectedExperimentId!;
+ await act(async () => {
+ getValue().cancelExperiment(experimentId);
+ });
+
+ expect(getValue().selectedExperiment).toMatchObject({
+ status: "cancelled",
+ startedAt: null,
+ });
+ expect(getValue().selectedExperiment?.finishedAt).toBeGreaterThan(0);
+ } finally {
+ renderResult.unmount();
+ }
+ });
+
+ it("records the CPU backend when no GPU backend is requested", async () => {
+ const worker = new FakeMonteCarloWorker();
+ const { getValue, renderResult } = renderExperimentsProvider(worker);
+
+ try {
+ await act(async () => {
+ const createPromise = getValue().createExperiment({
+ name: "CPU experiment",
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ });
+ await flushWorkerSetup();
+ // The handle only resolves once every shard reports ready.
+ worker.emit({ type: "ready" });
+ await createPromise;
+ });
+
+ expect(getValue().selectedExperiment).toMatchObject({
+ computeBackend: "cpu",
+ computeBackendFallbackReason: null,
+ status: "running",
+ });
+ expect(worker.sent.map((message) => message.type)).toEqual([
+ "init",
+ "start",
+ ]);
+ } finally {
+ renderResult.unmount();
+ }
+ });
+
+ it("falls back to the CPU and records why when the GPU declines the net", async () => {
+ // The GPU backend cannot serve expression metrics, so requesting it for this
+ // experiment is declined. It must still run — silently switching backends is
+ // wrong, and failing outright is worse.
+ const worker = new FakeMonteCarloWorker();
+ const notifications: AddNotificationInput[] = [];
+ const { getValue, renderResult } = renderExperimentsProvider(worker, {
+ addNotification: (notification) => {
+ notifications.push(notification);
+ return "";
+ },
+ });
+
+ try {
+ await withWebGpuAvailable(async () => {
+ await act(async () => {
+ const createPromise = getValue().createExperiment({
+ name: "GPU experiment",
+ scenarioId: null,
+ scenarioParameterValues: {},
+ runCount: 1,
+ seed: 42,
+ dt: 1,
+ maxTime: 10,
+ metricSpecs: CONSTANT_METRIC_SPEC,
+ computeBackend: "webgpu",
+ });
+ // Probing the GPU adds await points before the CPU worker is created, so
+ // `init` has not necessarily been sent after a single flush.
+ await waitFor(() => {
+ expect(worker.sent.map((message) => message.type)).toContain(
+ "init",
+ );
+ });
+ worker.emit({ type: "ready" });
+ await createPromise;
+ });
+
+ const experiment = getValue().selectedExperiment;
+ expect(experiment?.computeBackend).toBe("cpu");
+ // The metric shape, not "no GPU here": with an adapter present the backend
+ // is genuinely asked about the net, and this is the reason it gives.
+ expect(experiment?.computeBackendFallbackReason).toMatch(
+ /place token counts/i,
+ );
+ // It still ran, on the CPU worker.
+ expect(worker.sent.map((message) => message.type)).toEqual([
+ "init",
+ "start",
+ ]);
+ // And the reason reached the user rather than being swallowed.
+ expect(
+ notifications.some((notification) =>
+ /running on the CPU/i.test(notification.message),
+ ),
+ ).toBe(true);
+ });
+ } finally {
+ renderResult.unmount();
+ }
+ });
+
it("notifies when a Monte Carlo experiment errors", async () => {
const addNotification = vi.fn(() => "notification-id");
const worker = new FakeMonteCarloWorker();
diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx
index c354b7dff9e..f279c8d6ca3 100644
--- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx
+++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx
@@ -31,11 +31,13 @@ import { NotificationsContext } from "../notifications/context";
import { SDCPNContext } from "../state/sdcpn-context";
import {
type CreateExperimentInput,
+ type ExperimentComputeBackend,
type ExperimentRecord,
type ExperimentStatus,
ExperimentsContext,
type ExperimentsContextValue,
isExperimentActive,
+ isTerminalExperimentStatus,
} from "./context";
type ExperimentsProviderProps = React.PropsWithChildren<{
@@ -218,12 +220,35 @@ export const ExperimentsProvider: React.FC = ({
experimentId: string,
patch: Partial,
) => {
+ // A patch that moves the experiment to a terminal status is stamped with the
+ // arrival time here rather than at each call site, so that no path — normal
+ // completion, a worker error, or cancellation — can finish an experiment
+ // without recording when it stopped.
+ const finishedAt =
+ patch.status !== undefined && isTerminalExperimentStatus(patch.status)
+ ? Date.now()
+ : null;
+
setExperiments((prev) =>
- prev.map((experiment) =>
- experiment.id === experimentId
- ? { ...experiment, ...patch }
- : experiment,
- ),
+ prev.map((experiment) => {
+ if (experiment.id !== experimentId) {
+ return experiment;
+ }
+
+ return {
+ ...experiment,
+ ...patch,
+ // The status, progress and event subscriptions can each sync the same
+ // terminal status, so only the first stamp counts. Their timestamps
+ // coincide today — the engine tears its transports down on reaching a
+ // terminal state, so nothing arrives later — which is why no test can
+ // tell this apart from re-stamping. It is here so that the recorded
+ // time stays the moment the run stopped if that ever changes.
+ ...(finishedAt !== null && experiment.finishedAt === null
+ ? { finishedAt }
+ : {}),
+ };
+ }),
);
};
@@ -284,9 +309,12 @@ export const ExperimentsProvider: React.FC = ({
});
}
- if (event.type === "complete" || event.type === "cancelled") {
- disposeExperimentHandle(experimentId);
- }
+ // Every member of this event union is terminal — `complete`, `cancelled`,
+ // `error` — so any event means the handle is finished with. Disposal is
+ // what releases the backend's resources; for the GPU path that is
+ // `device.destroy()`, and skipping it on `error` left a live GPUDevice
+ // held until the record was removed.
+ disposeExperimentHandle(experimentId);
});
registrationsRef.current.set(experimentId, {
@@ -367,6 +395,10 @@ export const ExperimentsProvider: React.FC = ({
status: "initializing",
error: null,
metricSpecs: input.metricSpecs,
+ computeBackend: "cpu",
+ computeBackendFallbackReason: null,
+ startedAt: null,
+ finishedAt: null,
progress: null,
latestMetricFramesById: {},
metricFrames: [],
@@ -396,84 +428,112 @@ export const ExperimentsProvider: React.FC = ({
code: spec.code,
})),
};
- const { artifacts, failures } = await requestHirArtifacts(
- compiledExperimentSdcpn,
- experimentExtensions,
- );
- // Compilation cannot currently be aborted. A cancelled or removed
- // experiment must stop here rather than turning a late compile result
- // (or failure below) into a worker or an error notification.
- if (!pendingRegistrationsRef.current.has(experimentId)) {
- return;
- }
+ // Built per backend rather than once, because the HIR trees roughly
+ // triple the artifact payload structured-cloned to every shard worker,
+ // and only a shader-generating backend reads them. `needsHirTrees` comes
+ // from the backend itself, so a new backend cannot be forgotten here.
+ const buildRequest = async ({
+ needsHirTrees,
+ }: {
+ needsHirTrees: boolean;
+ }): Promise => {
+ const { artifacts, failures } = await requestHirArtifacts(
+ compiledExperimentSdcpn,
+ experimentExtensions,
+ { includeHir: needsHirTrees },
+ );
- const metricSpecs = input.metricSpecs.map((spec) => {
- if (spec.kind !== "expression") {
- return spec;
- }
- const artifact = artifacts.metrics[spec.id];
- if (!artifact) {
- const diagnostics = failures
- .filter(
- (failure) =>
- failure.itemType === "metric" && failure.itemId === spec.id,
- )
- .flatMap((failure) =>
- failure.diagnostics.map((diagnostic) => diagnostic.message),
+ const metricSpecs = input.metricSpecs.map((spec) => {
+ if (spec.kind !== "expression") {
+ return spec;
+ }
+ const artifact = artifacts.metrics[spec.id];
+ if (!artifact) {
+ const diagnostics = failures
+ .filter(
+ (failure) =>
+ failure.itemType === "metric" && failure.itemId === spec.id,
+ )
+ .flatMap((failure) =>
+ failure.diagnostics.map((diagnostic) => diagnostic.message),
+ );
+ throw new Error(
+ `Metric "${spec.label}" did not compile${
+ diagnostics.length > 0 ? `: ${diagnostics.join("; ")}` : ""
+ }`,
);
- throw new Error(
- `Metric "${spec.label}" did not compile${
- diagnostics.length > 0 ? `: ${diagnostics.join("; ")}` : ""
- }`,
- );
- }
- return { ...spec, artifact };
- });
-
- const request: ExperimentRequest = {
- // Artifact fingerprints cover the complete sanitized SDCPN, including
- // its metric definitions. Run the worker against the exact snapshot
- // used above rather than the pre-substitution model.
- sdcpn: compiledExperimentSdcpn,
- extensions: experimentExtensions,
- initialMarking,
- parameterValues,
- seed: input.seed,
- dt: input.dt,
- maxTime: input.maxTime,
- runCount: input.runCount,
- metricSpecs,
- hirArtifacts: artifacts,
+ }
+ return { ...spec, artifact };
+ });
+
+ return {
+ // Artifact fingerprints cover the complete sanitized SDCPN,
+ // including its metric definitions. Run against the exact snapshot
+ // compiled above rather than the pre-substitution model.
+ sdcpn: compiledExperimentSdcpn,
+ extensions: experimentExtensions,
+ initialMarking,
+ parameterValues,
+ seed: input.seed,
+ dt: input.dt,
+ maxTime: input.maxTime,
+ runCount: input.runCount,
+ metricSpecs,
+ hirArtifacts: artifacts,
+ };
};
- // Preference order, best first. Only one backend today; the point of
- // going through the registry is that adding another is a registration
- // rather than an edit to this branch.
- const registrations: ExperimentBackendRegistration[] = [
- {
- id: "cpu",
- label: "CPU (Web Workers)",
- load: () =>
- Promise.resolve(
- createWorkerPoolExperimentBackend({
- createWorker: workerFactoryRef.current,
- ...(shardCountRef.current === undefined
- ? {}
- : { shardCount: shardCountRef.current }),
- }),
- ),
- },
- ];
+ // Preference order, best first. The GPU backend is only a candidate when
+ // it was asked for; the worker-pool backend is always last because it
+ // accepts everything, which is what makes it the fallback.
+ const registrations: ExperimentBackendRegistration[] = [];
+ if (input.computeBackend === "webgpu") {
+ registrations.push({
+ id: "webgpu",
+ label: "GPU (WebGPU)",
+ // Imported here, not at module scope, so a session that never asks
+ // for the GPU never loads the shader generator.
+ load: async () => {
+ const { createWebGpuExperimentBackend } =
+ await import("@hashintel/petrinaut-core/webgpu");
+ return createWebGpuExperimentBackend();
+ },
+ });
+ }
+ registrations.push({
+ id: "cpu",
+ label: "CPU (Web Workers)",
+ load: () =>
+ Promise.resolve(
+ createWorkerPoolExperimentBackend({
+ createWorker: workerFactoryRef.current,
+ ...(shardCountRef.current === undefined
+ ? {}
+ : { shardCount: shardCountRef.current }),
+ }),
+ ),
+ });
const selection = await selectExperimentBackend({
registrations,
- buildRequest: () => Promise.resolve(request),
- instantiateOptions: { signal: abortController.signal },
+ buildRequest,
+ instantiateOptions: {
+ signal: abortController.signal,
+ // Problems only detectable once running — a saturated histogram —
+ // arrive too late for the notes returned at selection.
+ onNote: (note) => {
+ addNotification({
+ message: `${experiment.name}: ${note.message}`,
+ tone: "error",
+ });
+ },
+ },
});
- // Setup cannot be aborted mid-flight. A cancelled or removed experiment
- // must stop here rather than turning a late result into a running handle.
+ // Compilation and device acquisition cannot be aborted mid-flight. A
+ // cancelled or removed experiment must stop here rather than turning a
+ // late result into a running handle.
if (!pendingRegistrationsRef.current.has(experimentId)) {
if (selection.ok) {
selection.handle.dispose();
@@ -490,8 +550,34 @@ export const ExperimentsProvider: React.FC = ({
}
const { handle } = selection;
+ const usedBackend = selection.backendId as ExperimentComputeBackend;
+ // Only the backends the user chose *against* are worth reporting, and
+ // only when something was declined — otherwise this is the happy path.
+ const [firstDeclined] = selection.declined;
+ const fallbackReason = firstDeclined?.reason ?? null;
+ if (firstDeclined) {
+ addNotification({
+ message: `${experiment.name} is running on the CPU: ${firstDeclined.reason}`,
+ tone: "neutral",
+ });
+ }
+ for (const note of selection.notes) {
+ addNotification({
+ message: `${experiment.name}: ${note.message}`,
+ tone: "neutral",
+ });
+ }
pendingRegistrationsRef.current.delete(experimentId);
+ patchExperiment(experimentId, {
+ computeBackend: usedBackend,
+ computeBackendFallbackReason: fallbackReason,
+ // Stepping starts on the next line. Setup — compiling user code,
+ // spinning up workers, acquiring the GPU device — is deliberately
+ // outside the measurement, so the two backends are compared on the
+ // work they actually differ in.
+ startedAt: Date.now(),
+ });
registerExperimentHandle(experiment, handle);
handle.start();
} catch (error) {
diff --git a/libs/@hashintel/petrinaut/src/react/lsp/context.ts b/libs/@hashintel/petrinaut/src/react/lsp/context.ts
index f62b301d489..a77f98690c3 100644
--- a/libs/@hashintel/petrinaut/src/react/lsp/context.ts
+++ b/libs/@hashintel/petrinaut/src/react/lsp/context.ts
@@ -1,6 +1,7 @@
import { createContext } from "react";
import type {
+ CompileHirArtifactsOptions,
CompletionList,
Diagnostic,
DocumentUri,
@@ -49,6 +50,7 @@ export interface LanguageClientContextValue {
requestHirArtifacts: (
sdcpn: SDCPN,
extensions?: PetrinautExtensionSettings,
+ options?: CompileHirArtifactsOptions,
) => Promise;
/** Initialize a temporary scenario editing session. */
initializeScenarioSession: (params: ScenarioSessionParams) => void;
diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts
index 7778c21bab4..6c250c439b2 100644
--- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts
+++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts
@@ -21,6 +21,7 @@ type EditorEditionMode =
| "add-component";
export type CursorMode = "select" | "pan";
export type BottomPanelTab =
+ | "compilation"
| "diagnostics"
| "simulation-settings"
| "actual-events"
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
index 64c97de3035..f4e20ed36ef 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
@@ -51,6 +51,26 @@ export type UserSettings = {
* only takes effect at the next init — not the current session.
*/
showWalkthroughOnInit: boolean;
+ /**
+ * Whether the WebGPU backend is offered at all.
+ *
+ * A master switch, not a choice of engine: with it on, each experiment picks
+ * its own backend as it is created, so a GPU and a CPU experiment can run side
+ * by side. Off means the per-experiment control is not shown.
+ *
+ * The backend is a restricted subset engine — bounded state, 32-bit numbers —
+ * and uses a different random generator, so it does not reproduce CPU
+ * trajectories seed for seed (it agrees statistically).
+ */
+ webGpuEnabled: boolean;
+ /**
+ * Shows the Compilation tab in the bottom panel, which reports how the net's
+ * user code lowered to HIR and what the GPU backend can take.
+ *
+ * Off by default: it explains the compiler rather than the model, so it is
+ * only useful when you are debugging why something did not compile.
+ */
+ showCompilationOutput: boolean;
subViewPanels: SubViewPanelsSettings;
};
@@ -73,6 +93,8 @@ export type UserSettingsActions = {
setUseEntitiesTreeView: (value: boolean) => void;
setEnableNetComponents: (value: boolean) => void;
setShowWalkthroughOnInit: (value: boolean) => void;
+ setWebGpuEnabled: (value: boolean) => void;
+ setShowCompilationOutput: (value: boolean) => void;
updateSubViewSection: (
containerName: string,
sectionId: string,
@@ -101,6 +123,8 @@ export const defaultUserSettings: UserSettings = {
useEntitiesTreeView: false,
enableNetComponents: false,
showWalkthroughOnInit: true,
+ webGpuEnabled: false,
+ showCompilationOutput: false,
subViewPanels: {},
};
@@ -124,6 +148,8 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = {
setUseEntitiesTreeView: () => {},
setEnableNetComponents: () => {},
setShowWalkthroughOnInit: () => {},
+ setWebGpuEnabled: () => {},
+ setShowCompilationOutput: () => {},
updateSubViewSection: () => {},
};
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
index 4cd78390f29..4080e5772bf 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
@@ -18,12 +18,30 @@ import type {
const STORAGE_KEY = "petrinaut:user-settings";
+/** The persisted blob, including keys no longer part of `UserSettings`. */
+type PersistedUserSettings = Partial & {
+ /**
+ * Replaced by `webGpuEnabled` when the backend became a per-experiment choice.
+ * Still present in blobs written before that.
+ */
+ computeBackend?: "cpu" | "webgpu";
+};
+
const loadSettings = (): UserSettings => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
- const parsed = JSON.parse(raw) as Partial;
- return { ...defaultUserSettings, ...parsed };
+ // Destructured rather than read through the spread, so the dead key is
+ // dropped from storage on the next write instead of persisting forever.
+ const { computeBackend, ...parsed } = JSON.parse(
+ raw,
+ ) as PersistedUserSettings;
+ return {
+ ...defaultUserSettings,
+ ...parsed,
+ // Someone who had selected the GPU globally keeps it available.
+ webGpuEnabled: parsed.webGpuEnabled ?? computeBackend === "webgpu",
+ };
}
} catch {
// Ignore corrupted or unavailable localStorage
@@ -82,6 +100,10 @@ export const UserSettingsProvider: React.FC = ({
setState((prev) => ({ ...prev, enableNetComponents: value })),
setShowWalkthroughOnInit: (value: boolean) =>
setState((prev) => ({ ...prev, showWalkthroughOnInit: value })),
+ setWebGpuEnabled: (value: boolean) =>
+ setState((prev) => ({ ...prev, webGpuEnabled: value })),
+ setShowCompilationOutput: (value: boolean) =>
+ setState((prev) => ({ ...prev, showCompilationOutput: value })),
updateSubViewSection: (
containerName: string,
sectionId: string,
diff --git a/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts b/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts
index dc752c9b7f4..9d11dffe9e7 100644
--- a/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts
+++ b/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts
@@ -6,6 +6,7 @@
*/
import { actualEventsSubView } from "../views/Editor/panels/BottomPanel/subviews/actual-events";
+import { compilationSubView } from "../views/Editor/panels/BottomPanel/subviews/compilation";
import { diagnosticsSubView } from "../views/Editor/panels/BottomPanel/subviews/diagnostics";
import { simulationSettingsSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-settings";
import { actualTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline/actual";
@@ -48,3 +49,8 @@ export const ACTUAL_BOTTOM_PANEL_SUBVIEWS = [
export const SIMULATION_ONLY_SUBVIEWS = [
simulationTimelineSubView,
] as const satisfies SubView[];
+
+// Subviews gated behind a user setting rather than editor state.
+export const COMPILATION_SUBVIEWS = [
+ compilationSubView,
+] as const satisfies SubView[];
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/panel.tsx
index 6a692b79db1..092229a772c 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/panel.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/panel.tsx
@@ -24,6 +24,7 @@ import {
import {
ACTUAL_BOTTOM_PANEL_SUBVIEWS,
BOTTOM_PANEL_SUBVIEWS,
+ COMPILATION_SUBVIEWS,
SIMULATION_ONLY_SUBVIEWS,
} from "../../../../constants/ui-subviews";
@@ -91,15 +92,18 @@ const headerRightStyle = css({
const getBottomPanelSubViews = ({
isActualMode,
isSimulationActive,
+ showCompilationOutput,
}: {
isActualMode: boolean;
isSimulationActive: boolean;
+ showCompilationOutput: boolean;
}) =>
isActualMode
? ACTUAL_BOTTOM_PANEL_SUBVIEWS
: [
...BOTTOM_PANEL_SUBVIEWS,
...(isSimulationActive ? SIMULATION_ONLY_SUBVIEWS : []),
+ ...(showCompilationOutput ? COMPILATION_SUBVIEWS : []),
];
/**
@@ -124,6 +128,8 @@ export const BottomPanel: React.FC = () => {
globalMode,
} = use(EditorContext);
+ const { keepPanelsMounted, showCompilationOutput } = use(UserSettingsContext);
+
// Simulation state for conditional subviews
const { state: simulationState } = use(SimulationContext);
const actualMode = use(ActualModeContext);
@@ -147,6 +153,7 @@ export const BottomPanel: React.FC = () => {
const subViews = getBottomPanelSubViews({
isActualMode,
isSimulationActive,
+ showCompilationOutput,
});
// Automatically open bottom panel and switch to the relevant timeline when a
@@ -196,6 +203,7 @@ export const BottomPanel: React.FC = () => {
const availableSubViews = getBottomPanelSubViews({
isActualMode,
isSimulationActive,
+ showCompilationOutput,
});
if (!availableSubViews.some((subView) => subView.id === activeTab)) {
@@ -207,7 +215,13 @@ export const BottomPanel: React.FC = () => {
setActiveTab(fallbackTab);
}
}
- }, [activeTab, isActualMode, isSimulationActive, setActiveTab]);
+ }, [
+ activeTab,
+ isActualMode,
+ isSimulationActive,
+ setActiveTab,
+ showCompilationOutput,
+ ]);
const renderedActiveTab =
subViews.find((subView) => subView.id === activeTab)?.id ??
@@ -225,8 +239,6 @@ export const BottomPanel: React.FC = () => {
? leftSidebarWidth + PANEL_MARGIN * 2
: PANEL_MARGIN;
- const { keepPanelsMounted } = use(UserSettingsContext);
-
if (!isOpen && !isPanelAnimating && !keepPanelsMounted) {
return null;
}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/compilation.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/compilation.tsx
new file mode 100644
index 00000000000..752f1ac21f6
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/compilation.tsx
@@ -0,0 +1,385 @@
+import { use, useEffect, useState } from "react";
+
+import { Icon } from "@hashintel/ds-components";
+import { css, cx } from "@hashintel/ds-helpers/css";
+import { analyzeCompilation } from "@hashintel/petrinaut-core/webgpu";
+
+import { LanguageClientContext } from "../../../../../../react/lsp/context";
+import { EditorContext } from "../../../../../../react/state/editor-context";
+import { SDCPNContext } from "../../../../../../react/state/sdcpn-context";
+
+import type { SubView } from "../../../../../components/sub-view/types";
+import type {
+ CompilationItemReport,
+ CompilationReport,
+} from "@hashintel/petrinaut-core/webgpu";
+
+const rootStyle = css({
+ display: "flex",
+ flexDirection: "column",
+ gap: "3",
+ fontSize: "xs",
+});
+
+const mutedStyle = css({
+ color: "neutral.s100",
+ fontStyle: "italic",
+});
+
+const verdictRowStyle = css({
+ display: "flex",
+ alignItems: "center",
+ gap: "2",
+ flexWrap: "wrap",
+});
+
+const pillStyle = css({
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "1",
+ paddingX: "1.5",
+ paddingY: "0.5",
+ borderRadius: "md",
+ fontWeight: "medium",
+ whiteSpace: "nowrap",
+});
+
+const pillReadyStyle = css({
+ backgroundColor: "green.s30",
+ color: "green.s110",
+});
+
+const pillBlockedStyle = css({
+ backgroundColor: "neutral.s30",
+ color: "neutral.s120",
+});
+
+const pillWarnStyle = css({
+ backgroundColor: "orange.s30",
+ color: "orange.s110",
+});
+
+const factStyle = css({
+ color: "neutral.s100",
+});
+
+const groupTitleStyle = css({
+ fontSize: "[11px]",
+ fontWeight: "semibold",
+ letterSpacing: "wide",
+ textTransform: "uppercase",
+ color: "neutral.s105",
+});
+
+const listStyle = css({
+ display: "flex",
+ flexDirection: "column",
+ gap: "1",
+ margin: "[0]",
+ padding: "[0]",
+ listStyle: "none",
+});
+
+const reasonButtonStyle = css({
+ display: "flex",
+ alignItems: "baseline",
+ gap: "1.5",
+ width: "[100%]",
+ textAlign: "left",
+ padding: "[2px 4px]",
+ border: "none",
+ borderRadius: "sm",
+ background: "[transparent]",
+ color: "neutral.s115",
+ cursor: "pointer",
+ _hover: { backgroundColor: "neutral.a10" },
+});
+
+const reasonStaticStyle = css({
+ display: "flex",
+ alignItems: "baseline",
+ gap: "1.5",
+ padding: "[2px 4px]",
+ color: "neutral.s115",
+});
+
+const codeStyle = css({
+ fontFamily: "mono",
+ fontSize: "[11px]",
+ color: "neutral.s100",
+ flexShrink: "[0]",
+});
+
+const itemRowStyle = css({
+ display: "grid",
+ gridTemplateColumns: "[minmax(0, 1fr) auto auto]",
+ alignItems: "center",
+ gap: "2",
+ padding: "[2px 4px]",
+ borderRadius: "sm",
+ width: "[100%]",
+ border: "none",
+ background: "[transparent]",
+ textAlign: "left",
+ cursor: "pointer",
+ color: "neutral.s115",
+ _hover: { backgroundColor: "neutral.a10" },
+});
+
+const itemRowSelectedStyle = css({
+ backgroundColor: "blue.s20",
+});
+
+const itemNameStyle = css({
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+});
+
+const detailStyle = css({
+ paddingLeft: "[4px]",
+ color: "neutral.s100",
+ fontFamily: "mono",
+ fontSize: "[11px]",
+ wordBreak: "break-word",
+ userSelect: "text",
+ cursor: "text",
+});
+
+const KIND_LABEL = {
+ lambda: "condition",
+ kernel: "kernel",
+ dynamics: "dynamics",
+} as const;
+
+const STATUS_LABEL = {
+ "gpu-ready": "GPU",
+ "cpu-only": "CPU",
+ // The net was refused before emission, so this was never tested either way.
+ "not-attempted": "untested",
+ "no-hir": "no HIR",
+ disabled: "unused",
+} as const;
+
+function statusPillStyle(status: CompilationItemReport["status"]): string {
+ if (status === "gpu-ready") {
+ return pillReadyStyle;
+ }
+ return status === "no-hir" ? pillWarnStyle : pillBlockedStyle;
+}
+
+/** Shown when nothing is selected, to explain how to see per-item detail. */
+const SELECT_HINT = "Select a node to see its detail.";
+
+const Group = ({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) => (
+
+
{title}
+
{children}
+
+);
+
+const CompilationContent: React.FC = () => {
+ const { petriNetDefinition, extensions, getItemType } = use(SDCPNContext);
+ const { requestHirArtifacts } = use(LanguageClientContext);
+ const { selection, selectItem } = use(EditorContext);
+
+ const [report, setReport] = useState(null);
+ const [error, setError] = useState(null);
+
+ // Compiling in the language worker is asynchronous, and the net changes as the
+ // user edits, so a stale result must never overwrite a newer one.
+ useEffect(() => {
+ let cancelled = false;
+
+ const analyze = async () => {
+ try {
+ const { artifacts } = await requestHirArtifacts(
+ petriNetDefinition,
+ extensions,
+ // The report reads the HIR trees themselves, so they have to be asked
+ // for — they are not carried by default.
+ { includeHir: true },
+ );
+ if (cancelled) {
+ return;
+ }
+ const parameterValues: Record = {};
+ for (const parameter of petriNetDefinition.parameters) {
+ const value = Number(parameter.defaultValue);
+ if (Number.isFinite(value)) {
+ parameterValues[parameter.variableName] = value;
+ }
+ }
+ setReport(
+ analyzeCompilation({
+ sdcpn: petriNetDefinition,
+ artifacts,
+ extensions,
+ parameterValues,
+ }),
+ );
+ setError(null);
+ } catch (caught) {
+ if (cancelled) {
+ return;
+ }
+ setError(caught instanceof Error ? caught.message : String(caught));
+ }
+ };
+
+ void analyze();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [petriNetDefinition, extensions, requestHirArtifacts]);
+
+ if (error !== null) {
+ return
Could not analyse the net: {error}
;
+ }
+ if (report === null) {
+ return
Compiling…
;
+ }
+
+ const selectedIds = new Set(selection.keys());
+ // With something selected, narrow to it — that is the question being asked.
+ const shownItems =
+ selectedIds.size > 0
+ ? report.items.filter((item) => selectedIds.has(item.itemId))
+ : report.items;
+ const isNarrowed = selectedIds.size > 0 && shownItems.length > 0;
+
+ const select = (itemId: string) => {
+ const itemType = getItemType(itemId);
+ if (itemType) {
+ selectItem({ type: itemType, id: itemId });
+ }
+ };
+
+ return (
+