From a5c8afd0936d90847d4a7948206045033efa6954 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 00:58:12 +0200 Subject: [PATCH 01/21] Carry the lowered metric HIR on its artifact and key the GPU setup cache on the artifact fingerprint --- libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md | 4 ++++ libs/@hashintel/petrinaut-core/src/hir/README.md | 4 ++++ .../petrinaut-core/src/hir/artifacts.test.ts | 10 ++++++++++ libs/@hashintel/petrinaut-core/src/hir/compile.ts | 1 + libs/@hashintel/petrinaut-core/src/hir/instantiate.ts | 2 ++ .../src/webgpu/gpu-backend-cache.test.ts | 8 ++++++++ .../petrinaut-core/src/webgpu/gpu-backend-cache.ts | 8 ++++++-- .../petrinaut-core/src/webgpu/gpu-experiment-handle.ts | 2 +- .../petrinaut-core/src/webgpu/hir-from-artifacts.ts | 8 ++------ 9 files changed, 38 insertions(+), 9 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md b/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md index 089c3a1ad7b..4a945ec34d0 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md +++ b/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md @@ -113,6 +113,10 @@ instantiation, `__places[ordinal]` maps those names to frame place indexes. Metric token counts are dynamic, so metric `.reduce(...)` and `.concat(...)` compile to loops over `placeCounts` and `placeOffsets`. +Compiled with `includeHir`, `HirMetricArtifact.hir` carries the lowered tree +the program was emitted from, like the lambda, kernel and dynamics artifacts. +The buffer program never reads it; it is there for the WebGPU backend. + ## Artifact validation Artifacts are `version: 4` and carry a fingerprint of the sanitized SDCPN and diff --git a/libs/@hashintel/petrinaut-core/src/hir/README.md b/libs/@hashintel/petrinaut-core/src/hir/README.md index 5e9673291ab..7012ac2c7d3 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/README.md +++ b/libs/@hashintel/petrinaut-core/src/hir/README.md @@ -97,6 +97,10 @@ outside the supported HIR subset is a blocking diagnostic. } ``` +With `includeHir`, every artifact also carries the lowered `hir` tree it was +emitted from, so the WebGPU backend can generate a shader without lowering the +net in the browser. Metric artifacts carry it too, for the same consumer. + The engine validates the artifact version and compilation-input fingerprint before running, then checks per-program metadata. Missing or stale artifacts produce errors instead of falling back to runtime compilation. diff --git a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts index c8643501c7d..f63090bf76c 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts @@ -210,6 +210,16 @@ describe("compileHirArtifacts", () => { `); }); + it("carries the metric HIR only when includeHir is set", () => { + const withHir = compileHirArtifacts(sdcpn, undefined, { + includeHir: true, + }).artifacts.metrics["done-count"]!.hir; + expect(withHir?.surface).toBe("metric"); + expect(withHir?.params[0]?.name).toBe("state"); + + expect(compile().metrics["done-count"]!.hir).toBeUndefined(); + }); + it("matches the object reference emitter for a representative lambda", () => { const artifacts = compile(); const pool = new StringPool(); diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.ts index e4577d05af7..5d0849935db 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/compile.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.ts @@ -303,6 +303,7 @@ export function compileHirArtifacts( artifacts.metrics[metric.id] = { source: program.source, placeNames: program.placeNames, + ...(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 49cba5e473f..7785a466836 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts @@ -129,6 +129,8 @@ export type HirMetricArtifact = { source: string; /** Places referenced by the program, in `__places` ordinal order. */ placeNames: string[]; + /** The lowered HIR the program was emitted from — see `HirLambdaArtifact.hir`. */ + hir?: HirFunction; }; /** diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts index 9637d0df9dd..90429633344 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts @@ -235,6 +235,7 @@ describe("createGpuBackendCache", () => { describe("gpuBackendSetupKey", () => { const base = { sdcpn: { id: "net" }, + artifactFingerprint: "fingerprint-a", parameterValues: { rate: "1.5", size: "10" }, runParameterIds: ["rate"], metricIds: ["m"], @@ -265,4 +266,11 @@ describe("gpuBackendSetupKey", () => { gpuBackendSetupKey({ ...base, metricIds: ["m", "n"] }), ); }); + + it("keys on the artifact fingerprint", () => { + expect(gpuBackendSetupKey(base)).toBe(gpuBackendSetupKey({ ...base })); + expect(gpuBackendSetupKey(base)).not.toBe( + gpuBackendSetupKey({ ...base, artifactFingerprint: "fingerprint-b" }), + ); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.ts index cec883f86af..c4af0718bf2 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.ts @@ -187,7 +187,11 @@ function objectId(value: object | undefined): number { export function gpuBackendSetupKey(options: { sdcpn: object; extensions?: object | undefined; - hirArtifacts?: object | undefined; + /** + * `HirArtifacts.fingerprint`: hashes the sanitized net (its `metrics` + * included) and the extensions, so an edited metric body misses the cache. + */ + artifactFingerprint: string; parameterValues: Readonly>; runParameterIds: readonly string[]; metricIds: readonly string[]; @@ -204,7 +208,7 @@ export function gpuBackendSetupKey(options: { return [ objectId(options.sdcpn), objectId(options.extensions), - objectId(options.hirArtifacts), + options.artifactFingerprint, bakedValues, [...options.runParameterIds].sort().join(","), options.metricIds.join(","), diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 02a6807a7d5..4e31e2b4268 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -212,7 +212,7 @@ export async function createGpuMonteCarloExperiment( gpuBackendSetupKey({ sdcpn: config.sdcpn, extensions: config.extensions, - hirArtifacts: config.hirArtifacts, + artifactFingerprint: config.hirArtifacts.fingerprint, parameterValues: config.parameterValues, runParameterIds: runParameters.ids, metricIds, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts b/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts index 282baddb3c2..f98764ebacb 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/hir-from-artifacts.ts @@ -22,12 +22,8 @@ export type NetHir = { /** 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. + * Transition id → kernel HIR, for transitions with a compiled kernel. The + * fire block emits it to write the produced tokens' attributes. */ kernels: Map; /** Items whose artifact carried no HIR, with why it matters. Non-fatal. */ From c60eed14cfb4bdbda85439fa1cb78ddfb476188c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 01:06:28 +0200 Subject: [PATCH 02/21] Sample frame 0 on the device before the first step --- .../src/webgpu/compile-net-shader.test.ts | 79 +++++++++++++++++++ .../src/webgpu/compile-net-shader.ts | 16 ++-- .../webgpu/compile-net-shader/histograms.ts | 32 +++++--- .../src/webgpu/gpu-experiment-handle.ts | 33 +------- .../gpu-experiment-handle/frame-merge.test.ts | 2 +- .../petrinaut-core/src/webgpu/runner.ts | 4 +- .../webgpu/runner/histogram-frames.test.ts | 21 +++++ .../src/webgpu/runner/histogram-frames.ts | 6 +- 8 files changed, 138 insertions(+), 55 deletions(-) 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 index c2e6fa0bc11..06350260fe9 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts @@ -263,6 +263,49 @@ describe("compileNetShader", () => { expect(result.shader.metricIds).toStrictEqual(["infected"]); }); + it("samples each frame before stepping it, so row 0 holds the initial marking", () => { + const result = compileFor(sir, { + metrics: [{ id: "infected", placeId: "place__infected" }], + }); + if (!result.ok) throw new Error(result.reason); + const wgsl = result.shader.wgsl; + + // Row f holds frame f, the CPU's numbering: the sample reads the registers + // as the iteration starts, before `running` gates this frame's step. The + // guard is `in_range`, not `running`, because `running` is not yet bound. + const sampleAt = wgsl.indexOf("if (in_range && status == 0u) {"); + const runningAt = wgsl.indexOf("let running = in_range && status == 0u"); + expect(sampleAt).toBeGreaterThan(-1); + expect(runningAt).toBeGreaterThan(sampleAt); + expect(wgsl).toContain("atomicAdd(&hist[absolute_frame * "); + expect(wgsl).not.toContain("if (running && status == 0u) {"); + }); + + it("never writes the histogram's last row, so sampling first costs no buffer", () => { + // The buffer holds `frame_limit` rows. Sampling after the step left row + // `frame_limit - 1` empty: every run still running takes status 2 at the + // frame limit inside the end-of-frame fold, and a finished run is never + // sampled. Sampling first fills rows 0..frame_limit - 1 of the same + // buffer, as long as that status flip still follows the sample. + const result = compileFor(sir, { + metrics: [{ id: "infected", placeId: "place__infected" }], + }); + if (!result.ok) throw new Error(result.reason); + const wgsl = result.shader.wgsl; + + const sampleAt = wgsl.indexOf("if (in_range && status == 0u) {"); + const foldAt = wgsl.indexOf( + " if (running) {\n counts[0u] = u32(max(0, i32(counts[0u]) + pending[0u]));", + ); + const foldEnd = wgsl.indexOf("\n }\n", foldAt); + const completeAt = wgsl.indexOf( + "if (absolute_frame + 1u >= config.frame_limit) { status = 2u; }", + ); + expect(foldAt).toBeGreaterThan(sampleAt); + expect(completeAt).toBeGreaterThan(foldAt); + expect(completeAt).toBeLessThan(foldEnd); + }); + it("emits no histogram machinery when there are no metrics", () => { const result = compileFor(sir); if (!result.ok) throw new Error(result.reason); @@ -752,6 +795,19 @@ function sameScopeRedeclarations(wgsl: string): string[] { return found; } +/** Open braces minus close braces; anything but zero fails at `createShaderModule`. */ +function unbalancedBraces(wgsl: string): number { + let depth = 0; + for (const character of wgsl) { + if (character === "{") { + depth++; + } else if (character === "}") { + depth--; + } + } + return depth; +} + describe("generated WGSL validity", () => { const cappedSatellites = (): SDCPN => ({ ...satellites, @@ -775,6 +831,29 @@ describe("generated WGSL validity", () => { }, ); + it.each(["euler", "rk2", "rk4"] as const)( + "scans clean with a metric sampled at the top of the frame, with %s", + (odeMethod) => { + // The sampling block now precedes the dynamics and transition blocks in + // the same iteration, so its `let`/`var` declarations share the frame + // loop's scope tree with theirs. + const space = satellites.places.find((place) => place.name === "Space"); + if (space === undefined) { + throw new Error("the satellites example has no Space place"); + } + const compiled = compileFor(cappedSatellites(), { + odeMethod, + metrics: [{ id: "in_orbit", placeId: space.id }], + }); + if (!compiled.ok) { + throw new Error(compiled.reason); + } + + expect(sameScopeRedeclarations(compiled.shader.wgsl)).toStrictEqual([]); + expect(unbalancedBraces(compiled.shader.wgsl)).toBe(0); + }, + ); + 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 diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts index 05eb4e9ec5d..ab7c2f2584e 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts @@ -296,6 +296,14 @@ export function compileNetShader( ` for (var frame: u32 = 0u; frame < config.chunk_frames; frame = frame + 1u) {`, ); push(` let absolute_frame = config.base_frame + frame;`); + + emitFrameHistograms(push, { + metrics, + placeIndexById, + bins: histogramBins, + workgroupSize: GPU_WORKGROUP_SIZE, + }); + push( ` let running = in_range && status == 0u && absolute_frame < config.frame_limit;`, ); @@ -411,14 +419,6 @@ export function compileNetShader( ); } push(` }`); - push(""); - - emitFrameHistograms(push, { - metrics, - placeIndexById, - bins: histogramBins, - workgroupSize: GPU_WORKGROUP_SIZE, - }); push(` }`); push(""); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts index 9548d9a853c..a65b1e01f0d 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts @@ -122,8 +122,12 @@ export const workgroupHistogramLines = ( ]; /** - * Emits the end-of-frame sampling: zero the workgroup histogram, bin each - * live run's counts, then flush to the global histogram and range. + * Emits the start-of-frame sampling: zero the workgroup histogram, bin each + * live run's counts, then flush to the global histogram and range. Sampling + * precedes the step, so row `f` holds the state after `f` steps and row 0 is + * the initial marking; no row is spent on it, because the last row was never + * written when sampling followed the step (every run still running takes + * `status = 2u` at the frame limit). */ export const emitFrameHistograms = ( push: (line: string) => void, @@ -139,7 +143,16 @@ export const emitFrameHistograms = ( return; } const totalBins = bins * metrics.length; - push(` // per-frame histograms, reduced in workgroup memory`); + push( + ` // per-frame histograms, reduced in workgroup memory: the state after`, + ); + push( + ` // \`absolute_frame\` steps, so row f is frame f and row 0 is the initial`, + ); + push( + ` // marking. A run is sampled while active, the CPU metric default, which`, + ); + push(` // excludes a run in the frame it deadlocks or completes.`); push( ` for (var b: u32 = lid; b < ${totalBins}u; b = b + ${workgroupSize}u) {`, ); @@ -159,12 +172,13 @@ export const emitFrameHistograms = ( `metric \`${metric.id}\` references unknown place ${metric.placeId}`, ); } - // Samples only runs still active after this frame's step: the CPU metric - // default excludes a run in the frame it deadlocks or completes, because - // its status flips before the observation. A sample outside the window - // clamps into the edge bin and is counted as an escape, which triggers a - // recalibrated re-run — the clamped picture is only ever an intermediate. - push(` if (running && status == 0u) {`); + // Samples only runs still active at the top of the frame: the previous + // step set the status, so the CPU metric default's exclusion of a run in + // the frame it deadlocks or completes holds here too. A sample outside the + // window clamps into the edge bin and is counted as an escape, which + // triggers a recalibrated re-run — the clamped picture is only ever an + // intermediate. + push(` if (in_range && status == 0u) {`); push(` let c${metricIndex} = counts[${placeIndex}u];`); push(` atomicMin(&local_min[${metricIndex}u], c${metricIndex});`); push(` atomicMax(&local_max[${metricIndex}u], c${metricIndex});`); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 4e31e2b4268..8d829472932 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -331,36 +331,9 @@ export async function createGpuMonteCarloExperiment( encodeInitialTokenWords(place, config.initialMarking[place.id]), ); - // Frame 0 is the initial state, which the device never samples; the host - // knows it exactly (every run starts identical), so it is emitted here — - // matching the CPU simulator's observation of the initial marking before - // any step. const placeIndexById = new Map( backend.profile.places.map((place, index) => [place.id, index]), ); - const initialHistogramFrames = gpuMetrics.metrics.map((metric) => { - const count = placeCounts[placeIndexById.get(metric.placeId) ?? -1] ?? 0; - return { - frameNumber: 0, - metricId: metric.id, - bins: [[count, config.runCount]] as [number, number][], - // An exact count: the cell of one integer. - binExtent: { below: 0.5, above: 0.5 }, - sampleCount: config.runCount, - }; - }); - if (initialHistogramFrames.length > 0) { - metrics.set( - appendMetricFrames( - metrics.get(), - toGpuMetricFrames( - initialHistogramFrames, - config.metricSpecs, - config.dt, - ), - ), - ); - } const frameMerger = createFrameMerger(); // What window planning knows per metric: the sampled place's initial @@ -589,11 +562,7 @@ export async function createGpuMonteCarloExperiment( metrics.set( appendMetricFrames( createEmptyMetricsState(), - toGpuMetricFrames( - [...initialHistogramFrames, ...result.frames], - config.metricSpecs, - config.dt, - ), + toGpuMetricFrames(result.frames, config.metricSpecs, config.dt), ), ); progress.set({ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/frame-merge.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/frame-merge.test.ts index bfb36c4516d..522c5a11f54 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/frame-merge.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/frame-merge.test.ts @@ -95,7 +95,7 @@ describe("createFrameMerger", () => { expect(state.latestByMetricId.b?.value).toBe(41); }); - it("keeps frames the store already held, such as the host-built frame 0", () => { + it("keeps frames the store already held across a re-delivery", () => { const merger = createFrameMerger(); let state = createEmptyMetricsState(); state = merger.ingest(state, [frame("a", 0, 5)]); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts index e64c1bba517..0a1be660ea2 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts @@ -470,7 +470,7 @@ export async function runGpuExperiment( await chunkReadback.mapAsync(GPUMapMode.READ, 0, chunkBytes); const decoded = decodeHistogramFrames({ data: new Uint32Array(chunkReadback.getMappedRange(0, chunkBytes)), - firstFrame: baseFrame + 1, + firstFrame: baseFrame, frameCount: chunkFrames, metricIds: shader.metricIds, histogramBins: shader.histogramBins, @@ -591,7 +591,7 @@ export async function runGpuExperiment( const histogram = new Uint32Array(histReadback.getMappedRange()); const frames = decodeHistogramFrames({ data: histogram, - firstFrame: 1, + firstFrame: 0, frameCount: sampledFrameCount({ data: histogram, frameLimit, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts index 05b25e2a871..e7b184f682f 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts @@ -66,6 +66,27 @@ describe("decodeHistogramFrames", () => { ]); }); + it("decodes frame 0 from row 0, the initial marking sampled on the device", () => { + const frames = decodeHistogramFrames({ + data: Uint32Array.from([0, 0, 6, 0]), + firstFrame: 0, + frameCount: 1, + metricIds: ["a"], + histogramBins: 4, + windows: [{ lo: 0, stride: 1 }], + }); + + expect(frames).toEqual([ + { + frameNumber: 0, + metricId: "a", + bins: [[2, 6]], + binExtent: { below: 0.5, above: 0.5 }, + sampleCount: 6, + }, + ]); + }); + it("labels a wide bin by its middle count and reports its reach either side", () => { // Stride 4 over lo 8: bin 0 holds counts 8..11 and is labelled 9, reaching // 1.5 below (down to 7.5) and 2.5 above (up to 11.5). diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts index 20848af0728..4456eb87610 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts @@ -8,9 +8,9 @@ import type { MetricWindow } from "../metric-windows"; export type GpuHistogramFrame = { /** - * CPU-aligned frame number: frame 0 is the initial state (built by the - * host — the device never samples it), and the histogram's bin `f` holds - * the state after step `f`, published as frame `f + 1`. + * CPU-aligned frame number: row `f` holds the state after `f` steps, + * sampled before step `f`; frame 0 is the initial marking, sampled on the + * device like every other frame. */ frameNumber: number; metricId: string; From 1af00d6c5486713a22263059bed0f63a5cfa3847 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 01:20:21 +0200 Subject: [PATCH 03/21] Bin every GPU metric through one f32 window with integer and real domains --- .../src/webgpu/compile-net-shader.test.ts | 166 ++++++++++++++++-- .../src/webgpu/compile-net-shader.ts | 4 + .../webgpu/compile-net-shader/histograms.ts | 117 +++++++++--- .../src/webgpu/emit-wgsl.test.ts | 18 ++ .../petrinaut-core/src/webgpu/emit-wgsl.ts | 13 +- .../src/webgpu/gpu-experiment-handle.ts | 49 ++++-- .../gpu-experiment-handle/calibration.test.ts | 71 ++++++-- .../gpu-experiment-handle/calibration.ts | 10 +- .../src/webgpu/gpu-metric-frames.ts | 12 +- .../src/webgpu/metric-windows.test.ts | 158 ++++++++++++++--- .../src/webgpu/metric-windows.ts | 116 ++++++++---- .../petrinaut-core/src/webgpu/runner.ts | 40 +++-- .../webgpu/runner/histogram-frames.test.ts | 29 ++- .../src/webgpu/runner/histogram-frames.ts | 43 +++-- 14 files changed, 669 insertions(+), 177 deletions(-) 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 index 06350260fe9..80e2760e8c0 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts @@ -14,19 +14,26 @@ import { assessGpuEligibility } from "./eligibility"; import { hirFromArtifacts } from "./hir-from-artifacts"; import type { SDCPN } from "../types/sdcpn"; -import type { GpuOdeMethod } from "./compile-net-shader"; +import type { GpuMetricSpec, GpuOdeMethod } from "./compile-net-shader"; + +/** A metric sampling one place's token count. */ +const placeCount = (id: string, placeId: string): GpuMetricSpec => ({ + id, + integer: true, + sample: { kind: "placeCount", placeId }, +}); function compileFor( sdcpn: SDCPN, { odeMethod = "rk4", - metrics = [] as { id: string; placeId: string }[], + metrics = [] as GpuMetricSpec[], dt = 0.1, framesPerDispatch = 300, runParameters, }: { odeMethod?: GpuOdeMethod; - metrics?: { id: string; placeId: string }[]; + metrics?: GpuMetricSpec[]; dt?: number; framesPerDispatch?: number; runParameters?: readonly string[]; @@ -250,7 +257,7 @@ describe("compileNetShader", () => { it("reduces metrics in workgroup memory rather than global atomics", () => { const result = compileFor(sir, { - metrics: [{ id: "infected", placeId: "place__infected" }], + metrics: [placeCount("infected", "place__infected")], }); if (!result.ok) throw new Error(result.reason); const wgsl = result.shader.wgsl; @@ -265,7 +272,7 @@ describe("compileNetShader", () => { it("samples each frame before stepping it, so row 0 holds the initial marking", () => { const result = compileFor(sir, { - metrics: [{ id: "infected", placeId: "place__infected" }], + metrics: [placeCount("infected", "place__infected")], }); if (!result.ok) throw new Error(result.reason); const wgsl = result.shader.wgsl; @@ -288,7 +295,7 @@ describe("compileNetShader", () => { // sampled. Sampling first fills rows 0..frame_limit - 1 of the same // buffer, as long as that status flip still follows the sample. const result = compileFor(sir, { - metrics: [{ id: "infected", placeId: "place__infected" }], + metrics: [placeCount("infected", "place__infected")], }); if (!result.ok) throw new Error(result.reason); const wgsl = result.shader.wgsl; @@ -311,11 +318,13 @@ describe("compileNetShader", () => { if (!result.ok) throw new Error(result.reason); expect(result.shader.wgsl).not.toContain("local_hist"); + expect(result.shader.wgsl).not.toContain("fn f32_order_key("); + expect(result.shader.wgsl).not.toContain("fn window_bin("); }); 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" }], + metrics: [placeCount("m", "does-not-exist")], }); expect(result.ok).toBe(false); @@ -371,7 +380,7 @@ describe("histogram sizing", () => { it("gives an unbounded sampled place the full budget in a compiled shader", () => { const result = compileFor(sir, { - metrics: [{ id: "infected", placeId: "place__infected" }], + metrics: [placeCount("infected", "place__infected")], }); if (!result.ok) { throw new Error(result.reason); @@ -384,7 +393,7 @@ describe("histogram sizing", () => { it("sizes a typed sampled place's bins from its capacity", () => { const result = compileFor(dronePatrol.petriNetDefinition, { - metrics: [{ id: "airborne", placeId: "place__airborne" }], + metrics: [placeCount("airborne", "place__airborne")], }); if (!result.ok) { throw new Error(result.reason); @@ -393,24 +402,143 @@ describe("histogram sizing", () => { expect(result.shader.histogramBins).toBe(17); }); - it("bins through a per-metric window carried as uniforms", () => { + it("bins through a per-metric f32 window carried as uniforms", () => { const result = compileFor(sir, { - metrics: [{ id: "infected", placeId: "place__infected" }], + metrics: [placeCount("infected", "place__infected")], }); if (!result.ok) { throw new Error(result.reason); } - // The window lives in the config, so recalibration needs no recompile. - expect(result.shader.wgsl).toContain("m0_lo: u32,"); - expect(result.shader.wgsl).toContain("m0_stride: u32,"); - expect(result.shader.wgsl).toContain( - "(c0 - config.m0_lo) / config.m0_stride", + const { wgsl } = result.shader; + // The window lives in the config, so recalibration needs no recompile; + // it is f32 for every metric, so one sampling path serves counts and + // real-valued expressions alike. + expect(wgsl).toContain("m0_lo: f32,"); + expect(wgsl).toContain("m0_stride: f32,"); + expect(wgsl).toContain("fn f32_order_key("); + expect(wgsl).toContain("fn window_bin("); + // A place count is sampled as the f32 of its u32, exact below 2^24. + expect(wgsl).toContain("let v0: f32 = f32(counts[1u]);"); + // The observed range travels as order-preserving keys through the u32 + // atomics; the bin is settled against the window's exact edges. + expect(wgsl).toContain("atomicMin(&local_min[0u], k0);"); + expect(wgsl).toContain( + "let b0 = window_bin(v0, config.m0_lo, config.m0_stride);", ); + // A non-finite sample halts the run for the host to report. + expect(wgsl).toContain("status = 4u;"); // Observed range and escape counters, for the calibration loop. - expect(result.shader.wgsl).toContain( + expect(wgsl).toContain( "@group(0) @binding(5) var range: array>;", ); - expect(result.shader.wgsl).toContain("atomicMin(&local_min[0u], c0);"); + }); + + it("emits the sampling helpers once, after the prelude and before the entry point", () => { + const result = compileFor(sir, { + metrics: [placeCount("infected", "place__infected")], + }); + if (!result.ok) { + throw new Error(result.reason); + } + const { wgsl } = result.shader; + + expect(wgsl).toContain( + [ + "fn f32_order_key(v: f32) -> u32 {", + " let bits = bitcast(v);", + " return select(~bits, bits | 0x80000000u, (bits & 0x80000000u) == 0u);", + "}", + ].join("\n"), + ); + expect(wgsl).toContain( + [ + "fn window_bin(v: f32, lo: f32, stride: f32) -> i32 {", + " let t = (v - lo) / stride;", + " if (t < -1.0) { return -1; }", + " if (t >= f32(HIST_BINS) + 1.0) { return i32(HIST_BINS); }", + " var bin = i32(floor(t));", + " if (lo + f32(bin) * stride > v) {", + " bin = bin - 1;", + " } else if (lo + f32(bin + 1) * stride <= v) {", + " bin = bin + 1;", + " }", + " return clamp(bin, -1, i32(HIST_BINS));", + "}", + ].join("\n"), + ); + // `HIST_BINS` must already be declared where the helpers read it. + expect(wgsl.indexOf("const HIST_BINS: u32 =")).toBeLessThan( + wgsl.indexOf("fn window_bin("), + ); + expect(wgsl.indexOf("fn window_bin(")).toBeLessThan( + wgsl.indexOf("fn step_runs("), + ); + expect(wgsl.match(/fn window_bin\(/g)).toHaveLength(1); + }); + + it("samples a place count through the one f32 path, block for block", () => { + // SIR's Infected is profile index 1, and one metric gets the full 1024 + // bins, so this is the whole per-metric block the design specifies. + const result = compileFor(sir, { + metrics: [placeCount("infected", "place__infected")], + }); + if (!result.ok) { + throw new Error(result.reason); + } + + expect(result.shader.wgsl).toContain( + [ + " if (in_range && status == 0u) {", + " let v0: f32 = f32(counts[1u]);", + " if ((bitcast(v0) & 0x7f800000u) == 0x7f800000u) {", + " // NaN or an infinity: the CPU evaluator throws here, so the run halts", + " // and the host fails the experiment naming the metric.", + " status = 4u;", + " } else {", + " let k0 = f32_order_key(v0);", + " atomicMin(&local_min[0u], k0);", + " atomicMax(&local_max[0u], k0);", + " let b0 = window_bin(v0, config.m0_lo, config.m0_stride);", + " if (b0 < 0) {", + " atomicAdd(&range[2u], 1u);", + " atomicAdd(&local_hist[0u], 1u);", + " } else if (b0 >= i32(HIST_BINS)) {", + " atomicAdd(&range[3u], 1u);", + " atomicAdd(&local_hist[0u + HIST_BINS - 1u], 1u);", + " } else {", + " atomicAdd(&local_hist[0u + u32(b0)], 1u);", + " }", + " }", + " }", + ].join("\n"), + ); + }); + + it("gives the second metric its own status, range slots and histogram rows", () => { + const result = compileFor(sir, { + metrics: [ + placeCount("susceptible", "place__susceptible"), + placeCount("infected", "place__infected"), + ], + }); + if (!result.ok) { + throw new Error(result.reason); + } + const { wgsl, histogramBins } = result.shader; + + expect(wgsl).toContain("let v1: f32 = f32(counts[1u]);"); + expect(wgsl).toContain("status = 5u;"); + expect(wgsl).toContain("atomicMin(&local_min[1u], k1);"); + expect(wgsl).toContain( + "let b1 = window_bin(v1, config.m1_lo, config.m1_stride);", + ); + expect(wgsl).toContain("atomicAdd(&range[6u], 1u);"); + expect(wgsl).toContain("atomicAdd(&range[7u], 1u);"); + expect(wgsl).toContain( + `atomicAdd(&local_hist[${histogramBins}u + u32(b1)], 1u);`, + ); + expect(wgsl).toContain("m1_lo: f32,"); + expect(wgsl).toContain("m1_stride: f32,"); }); }); @@ -843,7 +971,7 @@ describe("generated WGSL validity", () => { } const compiled = compileFor(cappedSatellites(), { odeMethod, - metrics: [{ id: "in_orbit", placeId: space.id }], + metrics: [placeCount("in_orbit", space.id)], }); if (!compiled.ok) { throw new Error(compiled.reason); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts index ab7c2f2584e..bab81273152 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts @@ -22,6 +22,7 @@ import { emitDynamics } from "./compile-net-shader/dynamics"; import { emitFrameHistograms, histogramBinCount, + histogramHelperLines, histogramWindowUniformLines, observedRangeBindingLines, sampledCountCeiling, @@ -244,6 +245,9 @@ export function compileNetShader( push(""); push(wgslPrelude()); push(""); + for (const line of histogramHelperLines(metrics.length)) { + push(line); + } for (const line of workgroupHistogramLines(metrics.length, histogramBins)) { push(line); } diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts index a65b1e01f0d..7385a8a2d7d 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts @@ -28,8 +28,13 @@ export const GPU_BASELINE_WORKGROUP_STORAGE_BYTES = 16384; export type GpuMetricSpec = { id: string; - /** Place whose token count is sampled. */ - placeId: string; + /** Every sample is a whole number, so bins keep exact integer labels. */ + integer: boolean; + sample: { + /** A place's token count, read from `counts[]`. */ + kind: "placeCount"; + placeId: string; + }; }; /** @@ -74,7 +79,8 @@ export const sampledCountCeiling = ( ): number | null => { let ceiling = 0; for (const metric of metrics) { - const place = profile.places[placeIndexById.get(metric.placeId) ?? -1]; + const place = + profile.places[placeIndexById.get(metric.sample.placeId) ?? -1]; const placeCeiling = place === undefined ? null : placeCountCeiling(place); if (placeCeiling === null) { return null; @@ -85,16 +91,53 @@ export const sampledCountCeiling = ( }; /** - * Each metric's window as uniform fields: bin i covers counts + * Each metric's window as uniform fields: bin i covers values * [lo + i*stride, lo + (i+1)*stride). Uniforms, not constants, so the host - * recalibrates the window between attempts without recompiling. + * recalibrates the window between attempts without recompiling. Both are + * f32 for every metric: an integer window's `lo` and `stride` are whole + * numbers, exact in f32 below 2^24. */ export const histogramWindowUniformLines = (metricCount: number): string[] => Array.from({ length: metricCount }, (_, metricIndex) => [ - ` m${metricIndex}_lo: u32,`, - ` m${metricIndex}_stride: u32,`, + ` m${metricIndex}_lo: f32,`, + ` m${metricIndex}_stride: f32,`, ]).flat(); +/** + * The sampling helpers, emitted once after the prelude when the shader has + * metrics. `HIST_BINS` is the module-scope constant `compile-net-shader.ts` + * emits first, so the helpers are valid anywhere after it. + */ +export const histogramHelperLines = (metricCount: number): string[] => + metricCount === 0 + ? [] + : [ + `// f32 as u32 preserving order, so u32 atomicMin/atomicMax reduce a float range:`, + `// positives set the sign bit, negatives flip every bit.`, + `fn f32_order_key(v: f32) -> u32 {`, + ` let bits = bitcast(v);`, + ` return select(~bits, bits | 0x80000000u, (bits & 0x80000000u) == 0u);`, + `}`, + ``, + `// Bin of \`v\` in a window: -1 below it, HIST_BINS at or above its top edge.`, + `// f32 division carries up to 2.5 ULP, so the quotient is settled against the`, + `// edges the host labels by; both products are exact for integer windows below`, + `// 2^24, which keeps every place count in the bin the u32 path put it in.`, + `fn window_bin(v: f32, lo: f32, stride: f32) -> i32 {`, + ` let t = (v - lo) / stride;`, + ` if (t < -1.0) { return -1; }`, + ` if (t >= f32(HIST_BINS) + 1.0) { return i32(HIST_BINS); }`, + ` var bin = i32(floor(t));`, + ` if (lo + f32(bin) * stride > v) {`, + ` bin = bin - 1;`, + ` } else if (lo + f32(bin + 1) * stride <= v) {`, + ` bin = bin + 1;`, + ` }`, + ` return clamp(bin, -1, i32(HIST_BINS));`, + `}`, + ``, + ]; + /** * Per metric: [observed min, observed max, escapes below, escapes above]. * Min/max drive window recalibration; the escape counters say whether any @@ -122,12 +165,18 @@ export const workgroupHistogramLines = ( ]; /** - * Emits the start-of-frame sampling: zero the workgroup histogram, bin each - * live run's counts, then flush to the global histogram and range. Sampling - * precedes the step, so row `f` holds the state after `f` steps and row 0 is - * the initial marking; no row is spent on it, because the last row was never - * written when sampling followed the step (every run still running takes - * `status = 2u` at the frame limit). + * Emits the start-of-frame sampling: zero the workgroup histogram, sample each + * live run's metrics as f32 and bin them, then flush to the global histogram + * and range. Sampling precedes the step, so row `f` holds the state after `f` + * steps and row 0 is the initial marking; no row is spent on it, because the + * last row was never written when sampling followed the step (every run still + * running takes `status = 2u` at the frame limit). + * + * One path for every metric: the sample is an f32, its observed range travels + * as order-preserving u32 keys through the existing min/max atomics, and + * `window_bin` settles the bin against the window's exact edges. A non-finite + * sample halts the run with `status = 4u + metric`, so the host can fail the + * experiment naming the metric, as the CPU evaluator does when it throws. */ export const emitFrameHistograms = ( push: (line: string) => void, @@ -166,12 +215,15 @@ export const emitFrameHistograms = ( push(` }`); push(` workgroupBarrier();`); for (const [metricIndex, metric] of metrics.entries()) { - const placeIndex = placeIndexById.get(metric.placeId); + const placeIndex = placeIndexById.get(metric.sample.placeId); if (placeIndex === undefined) { throw new WgslBailError( - `metric \`${metric.id}\` references unknown place ${metric.placeId}`, + `metric \`${metric.id}\` references unknown place ${metric.sample.placeId}`, ); } + const value = `v${metricIndex}`; + const key = `k${metricIndex}`; + const bin = `b${metricIndex}`; // Samples only runs still active at the top of the frame: the previous // step set the status, so the CPU metric default's exclusion of a run in // the frame it deadlocks or completes holds here too. A sample outside the @@ -179,25 +231,34 @@ export const emitFrameHistograms = ( // triggers a recalibrated re-run — the clamped picture is only ever an // intermediate. push(` if (in_range && status == 0u) {`); - push(` let c${metricIndex} = counts[${placeIndex}u];`); - push(` atomicMin(&local_min[${metricIndex}u], c${metricIndex});`); - push(` atomicMax(&local_max[${metricIndex}u], c${metricIndex});`); - push(` var bin${metricIndex}: u32;`); - push(` if (c${metricIndex} < config.m${metricIndex}_lo) {`); - push(` atomicAdd(&range[${metricIndex * 4 + 2}u], 1u);`); - push(` bin${metricIndex} = 0u;`); + push(` let ${value}: f32 = f32(counts[${placeIndex}u]);`); + push(` if ((bitcast(${value}) & 0x7f800000u) == 0x7f800000u) {`); + push( + ` // NaN or an infinity: the CPU evaluator throws here, so the run halts`, + ); + push(` // and the host fails the experiment naming the metric.`); + push(` status = ${4 + metricIndex}u;`); push(` } else {`); + push(` let ${key} = f32_order_key(${value});`); + push(` atomicMin(&local_min[${metricIndex}u], ${key});`); + push(` atomicMax(&local_max[${metricIndex}u], ${key});`); push( - ` bin${metricIndex} = (c${metricIndex} - config.m${metricIndex}_lo) / config.m${metricIndex}_stride;`, + ` let ${bin} = window_bin(${value}, config.m${metricIndex}_lo, config.m${metricIndex}_stride);`, ); - push(` if (bin${metricIndex} >= HIST_BINS) {`); + push(` if (${bin} < 0) {`); + push(` atomicAdd(&range[${metricIndex * 4 + 2}u], 1u);`); + push(` atomicAdd(&local_hist[${metricIndex * bins}u], 1u);`); + push(` } else if (${bin} >= i32(HIST_BINS)) {`); push(` atomicAdd(&range[${metricIndex * 4 + 3}u], 1u);`); - push(` bin${metricIndex} = HIST_BINS - 1u;`); - push(` }`); - push(` }`); push( - ` atomicAdd(&local_hist[${metricIndex * bins}u + bin${metricIndex}], 1u);`, + ` atomicAdd(&local_hist[${metricIndex * bins}u + HIST_BINS - 1u], 1u);`, ); + push(` } else {`); + push( + ` atomicAdd(&local_hist[${metricIndex * bins}u + u32(${bin})], 1u);`, + ); + push(` }`); + push(` }`); push(` }`); } push(` workgroupBarrier();`); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts index 7be829f8c0c..62ae6afa68a 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts @@ -196,6 +196,24 @@ describe("WgslEmitter", () => { expect(result.code).toBe("max(max(1.0, 2.0), 7.0)"); }); + it("refuses the non-finite constants rather than dividing by zero to build them", () => { + // `emitF32Literal` already refuses a non-finite number; the named + // constants and the empty `Math.min()`/`Math.max()` (Infinity and + // -Infinity in JavaScript) used to bypass it with `(1.0 / 0.0)` forms, + // which fail at `createShaderModule` instead of at the probe. + for (const body of [ + "Infinity", + "NaN", + "-Infinity", + "Math.min()", + "Math.max()", + ]) { + expect(() => + emit(`export default Lambda((tokens, parameters) => ${body});`), + ).toThrow(/no WGSL representation/); + } + }); + it("refuses string values, which need a 64-bit pool id", () => { expect(() => emit( diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts index e7cddffba36..799b2d0727c 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts @@ -291,9 +291,12 @@ export class WgslEmitter { case "E": return { kind: "f32", code: emitF32Literal(Math.E) }; case "Infinity": - return { kind: "f32", code: "(1.0 / 0.0)" }; + return { + kind: "f32", + code: emitF32Literal(Number.POSITIVE_INFINITY), + }; case "NaN": - return { kind: "f32", code: "(0.0 / 0.0)" }; + return { kind: "f32", code: emitF32Literal(Number.NaN) }; } break; @@ -633,7 +636,11 @@ export class WgslEmitter { // Math.min() is Infinity, Math.max() is -Infinity. return { kind: "f32", - code: expr.fn === "min" ? "(1.0 / 0.0)" : "(-1.0 / 0.0)", + code: emitF32Literal( + expr.fn === "min" + ? Number.POSITIVE_INFINITY + : Number.NEGATIVE_INFINITY, + ), }; } return { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 8d829472932..e33febb8cfb 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -25,6 +25,7 @@ import { placeCountCeiling } from "./eligibility"; import { gpuBackendSetupKey } from "./gpu-backend-cache"; import { probeDerivedCapacities, + probeRunCount, probeWindows, rememberCalibration, RUN_POLICY, @@ -60,7 +61,7 @@ import type { CalibrationSession, ExecuteAttempt, } from "./gpu-experiment-handle/calibration"; -import type { MetricWindow } from "./metric-windows"; +import type { MetricWindow, MetricWindowInput } from "./metric-windows"; export type CreateGpuMonteCarloExperimentConfig = { sdcpn: SDCPN; @@ -336,16 +337,16 @@ export async function createGpuMonteCarloExperiment( ); const frameMerger = createFrameMerger(); - // What window planning knows per metric: the sampled place's initial - // count, and its hard ceiling when it has one (a ceiling makes the window - // exact by construction — no calibration needed). A derived probe slab is - // not a ceiling: its counts calibrate empirically. - const windowInputs = gpuMetrics.metrics.map((metric) => { - const placeIndex = placeIndexById.get(metric.placeId) ?? -1; - const place = backend.profile.places[placeIndex]; + // What window planning knows per metric: whether its samples are whole + // numbers, and a hard ceiling when a sampled place declares one (a ceiling + // makes the window exact by construction — no calibration needed). A + // derived probe slab is not a ceiling: its counts calibrate empirically. + const windowInputs: MetricWindowInput[] = gpuMetrics.metrics.map((metric) => { + const place = + backend.profile.places[placeIndexById.get(metric.sample.placeId) ?? -1]; return { - initialCount: placeCounts[placeIndex] ?? 0, - countCeiling: + integer: metric.integer, + ceiling: place === undefined || place.capacitySource === "derived" ? null : placeCountCeiling(place), @@ -484,19 +485,16 @@ export async function createGpuMonteCarloExperiment( } const run = async () => { - // Guessed windows (any sampled place without a ceiling) probe with a - // preview-sized prefix of the runs first, unless the capacity probe - // already calibrated them at creation. + // Blind windows (any metric without a ceiling) probe with a prefix of the + // runs first, unless the capacity probe already calibrated them at + // creation. let windows = calibratedWindows ?? planInitialWindows(windowInputs, session.shader.histogramBins); - const guessedWindows = windowInputs.some( - (input) => input.countCeiling === null, - ); + const blindWindows = windowInputs.some((input) => input.ceiling === null); if ( calibratedWindows === null && - guessedWindows && - config.runCount > GPU_PREVIEW_RUNS && + blindWindows && metricIds.length > 0 && !aborted ) { @@ -504,6 +502,7 @@ export async function createGpuMonteCarloExperiment( session, windows, execute: executeAttempt, + runCount: probeRunCount(session.shader, config.runCount), }); if (isDisposed()) { return; @@ -543,6 +542,20 @@ export async function createGpuMonteCarloExperiment( ); return; } + const erroredMetric = result.metricErrors.findIndex((runs) => runs > 0); + if (erroredMetric !== -1 && !result.cancelled) { + // The CPU evaluator throws on the first non-finite value and the + // experiment errors; the device halts the run instead, so the same + // failure is reported once the attempt returns. + const metricId = metricIds[erroredMetric]; + const label = + config.metricSpecs.find((spec) => spec.id === metricId)?.label ?? + metricId; + fail( + `Metric "${label}" returned a non-finite value in ${result.metricErrors[erroredMetric]} of ${config.runCount} runs, expected a finite number.`, + ); + return; + } if (!result.cancelled) { // The batch's final calibration — grown slabs, replanned windows — is // the best knowledge for the next batch on this marking. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts index 5c085a35693..9759cbfe5a6 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts @@ -5,6 +5,7 @@ import { probeDerivedCapacities, probeRunCount, PROBE_POLICY, + probeWindows, rememberCalibration, RUN_POLICY, runUntilCalibrated, @@ -90,6 +91,7 @@ const outcome = ( derivedPlaceMaxes: [], dispatchMs: 0, metricRanges: [{ min: 3, max: 9, below: 0, above: 0 }], + metricErrors: [], ...overrides, }); @@ -116,7 +118,7 @@ describe("runUntilCalibrated", () => { const run = await runUntilCalibrated({ session: current, runsFor: () => 1000, - windows: [{ lo: 0, stride: 1 }], + windows: [{ lo: 0, stride: 1, integer: true }], execute, policy: RUN_POLICY, stopped: () => false, @@ -200,7 +202,7 @@ describe("runUntilCalibrated", () => { const run = await runUntilCalibrated({ session: current, runsFor: () => 1000, - windows: [{ lo: 0, stride: 2 }], + windows: [{ lo: 0, stride: 2, integer: true }], execute, policy: RUN_POLICY, stopped: () => false, @@ -209,8 +211,12 @@ describe("runUntilCalibrated", () => { // 64 counts observed, margin max(2, ceil(64 / 64)) = 2 → [98, 165] over // 64 bins is a stride of 2. expect(attempts).toHaveLength(2); - expect(attempts[1]?.windows).toEqual([{ lo: 98, stride: 2 }]); - expect(run.ok && run.windows).toEqual([{ lo: 98, stride: 2 }]); + expect(attempts[1]?.windows).toEqual([ + { lo: 98, stride: 2, integer: true }, + ]); + expect(run.ok && run.windows).toEqual([ + { lo: 98, stride: 2, integer: true }, + ]); }); it("stops at a cancelled or abandoned attempt without retrying", async () => { @@ -320,7 +326,7 @@ describe("probeDerivedCapacities", () => { const probed = await probeDerivedCapacities({ session: current, runCount: 10_000, - windowInputs: [{ initialCount: 30, countCeiling: null }], + windowInputs: [{ integer: true, ceiling: null }], placeCounts: [3], execute, }); @@ -331,7 +337,10 @@ describe("probeDerivedCapacities", () => { expect(current.capacities).toEqual(new Map([["p", 19]])); expect(current.shader.stateWordsPerRun).toBe(4 + 19 * 2); // 21 counts observed, margin ceil(21 × 0.25) = 6 → [14, 46] over 64 bins. - expect(probed).toEqual({ ok: true, windows: [{ lo: 14, stride: 1 }] }); + expect(probed).toEqual({ + ok: true, + windows: [{ lo: 14, stride: 1, integer: true }], + }); }); it("hands back an abandoned probe without recompiling", async () => { @@ -346,7 +355,7 @@ describe("probeDerivedCapacities", () => { const probed = await probeDerivedCapacities({ session: current, runCount: 10_000, - windowInputs: [{ initialCount: 30, countCeiling: null }], + windowInputs: [{ integer: true, ceiling: null }], placeCounts: [3], execute, stopped: () => true, @@ -357,31 +366,67 @@ describe("probeDerivedCapacities", () => { }); }); +describe("probeWindows", () => { + it("runs the prefix it is given and replans the blind windows from what it saw", async () => { + const current = session({}); + const { execute, attempts } = scripted([ + { + ok: true, + result: outcome({ + metricRanges: [{ min: 20, max: 40, below: 0, above: 60 }], + }), + }, + ]); + + const probed = await probeWindows({ + session: current, + windows: [{ lo: 0, stride: 1, integer: true }], + execute, + runCount: 5, + }); + + // The caller sizes the prefix (`probeRunCount`), so an experiment of five + // runs probes five, never a preview tile's worth it does not have. + expect(attempts).toEqual([ + expect.objectContaining({ runCount: 5, preview: false }), + ]); + // 21 counts observed, margin ceil(21 × 0.25) = 6 → [14, 46] over 64 bins. + expect(probed).toMatchObject({ + ok: true, + windows: [{ lo: 14, stride: 1, integer: true }], + }); + }); +}); + describe("rememberCalibration", () => { const key = "marking|m0"; it("lets the latest writer win when no slab shrinks", () => { const calibrations = new Map(); rememberCalibration(calibrations, key, session({ p: 10 }), [ - { lo: 0, stride: 1 }, + { lo: 0, stride: 1, integer: true }, ]); rememberCalibration(calibrations, key, session({ p: 10 }), [ - { lo: 5, stride: 2 }, + { lo: 5, stride: 2, integer: true }, ]); - expect(calibrations.get(key)?.windows).toEqual([{ lo: 5, stride: 2 }]); + expect(calibrations.get(key)?.windows).toEqual([ + { lo: 5, stride: 2, integer: true }, + ]); }); it("keeps an entry whose slabs are larger than the late writer's", () => { const calibrations = new Map(); rememberCalibration(calibrations, key, session({ p: 40 }), [ - { lo: 0, stride: 1 }, + { lo: 0, stride: 1, integer: true }, ]); rememberCalibration(calibrations, key, session({ p: 10 }), [ - { lo: 5, stride: 2 }, + { lo: 5, stride: 2, integer: true }, ]); expect(calibrations.get(key)?.capacities.get("p")).toBe(40); - expect(calibrations.get(key)?.windows).toEqual([{ lo: 0, stride: 1 }]); + expect(calibrations.get(key)?.windows).toEqual([ + { lo: 0, stride: 1, integer: true }, + ]); }); }); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index e39a51fd2d4..9e3521aa968 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -353,18 +353,20 @@ export const probeDerivedCapacities = async (options: { }; /** - * Calibrates guessed windows from a preview-sized prefix of the runs before - * the full attempt, when no capacity probe already did. + * Calibrates blind windows from a prefix of the runs before the full attempt, + * when no capacity probe already did. */ export const probeWindows = async (options: { session: CalibrationSession; windows: readonly MetricWindow[]; execute: ExecuteAttempt; + /** `probeRunCount(session.shader, runCount)`: never more runs than the experiment has. */ + runCount: number; }): Promise => { - const { session, windows, execute } = options; + const { session, windows, execute, runCount } = options; const attempt = await execute({ shader: session.shader, - runCount: GPU_PREVIEW_RUNS, + runCount, windows, preview: false, }); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts index 32f2e4d7bd6..e661172d38d 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts @@ -46,7 +46,11 @@ export function toGpuMetricSpecs( 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 }); + metrics.push({ + id: spec.id, + integer: true, + sample: { kind: "placeCount", placeId: spec.placeId }, + }); } return { ok: true, metrics }; @@ -55,9 +59,9 @@ export function toGpuMetricSpecs( /** * 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. + * Distribution metrics use the bins directly. Scalar metrics reduce + * mean/sum/min/max from the bin labels, which is exact for integer metrics at + * stride 1 and otherwise quantised to the labels. */ function toMetricFrame( histogram: GpuHistogramFrame, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.test.ts index 96068a69b4c..07f50797515 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.test.ts @@ -2,68 +2,146 @@ import { describe, expect, it } from "vitest"; import { anyEscapes, + calibrationKey, + decodeF32OrderKey, + f32OrderKey, planInitialWindows, windowsFromObserved, - calibrationKey, } from "./metric-windows"; +import { decodeHistogramFrames } from "./runner/histogram-frames"; describe("planInitialWindows", () => { it("is exact for a ceiling that fits the bins", () => { - expect( - planInitialWindows([{ initialCount: 5, countCeiling: 16 }], 1024), - ).toEqual([{ lo: 0, stride: 1 }]); + expect(planInitialWindows([{ integer: true, ceiling: 16 }], 1024)).toEqual([ + { lo: 0, stride: 1, integer: true }, + ]); }); it("strides a ceiling wider than the bins", () => { // 0..4095 over 1024 bins: 4 counts per bin. expect( - planInitialWindows([{ initialCount: 5, countCeiling: 4095 }], 1024), - ).toEqual([{ lo: 0, stride: 4 }]); + planInitialWindows([{ integer: true, ceiling: 4095 }], 1024), + ).toEqual([{ lo: 0, stride: 4, integer: true }]); }); - it("anchors an unbounded metric's guess on its initial count", () => { - // 1030 initial tokens: the guess covers [0, 2060], where the old - // zero-anchored layout refused the model outright. - const [window] = planInitialWindows( - [{ initialCount: 1030, countCeiling: null }], - 1024, - ); - expect(window!.lo).toBe(0); - expect(window!.lo + 1024 * window!.stride).toBeGreaterThan(2060); + it("plans a blind unit window for a metric without a ceiling, in its domain", () => { + // The handle always probes a blind window, and range tracking is + // independent of the window, so the probe observes the exact range + // whatever the blind window clamps. + expect( + planInitialWindows( + [ + { integer: true, ceiling: null }, + { integer: false, ceiling: null }, + ], + 1024, + ), + ).toEqual([ + { lo: 0, stride: 1, integer: true }, + { lo: 0, stride: 1, integer: false }, + ]); }); }); describe("windowsFromObserved", () => { - it("fits the observed range with margin", () => { + it("fits an integer range with margin", () => { const [window] = windowsFromObserved( [{ min: 1000, max: 1200, below: 0, above: 5 }], - [{ lo: 0, stride: 4 }], + [{ lo: 0, stride: 4, integer: true }], 1024, 0.25, ); // Margin: ceil(201 × 0.25) = 51 → [949, 1251], span 303 ≤ 1024 → exact. - expect(window).toEqual({ lo: 949, stride: 1 }); + expect(window).toEqual({ lo: 949, stride: 1, integer: true }); }); it("keeps the previous window for a metric with no samples", () => { expect( windowsFromObserved( - [{ min: 0xffffffff, max: 0, below: 0, above: 0 }], - [{ lo: 7, stride: 3 }], + [ + { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + below: 0, + above: 0, + }, + ], + [{ lo: 7, stride: 3, integer: true }], 1024, 0.25, ), - ).toEqual([{ lo: 7, stride: 3 }]); + ).toEqual([{ lo: 7, stride: 3, integer: true }]); }); - it("never plans a negative lo", () => { - const [window] = windowsFromObserved( + it("reserves no bins below zero unless a negative value was observed", () => { + // A count never goes negative, so its margin is clamped at zero; a + // signed metric keeps its margin on both sides. + const [counts] = windowsFromObserved( [{ min: 1, max: 4, below: 0, above: 0 }], - [{ lo: 0, stride: 1 }], + [{ lo: 0, stride: 1, integer: true }], 1024, 0.25, ); - expect(window!.lo).toBe(0); + expect(counts!.lo).toBe(0); + + const [signed] = windowsFromObserved( + [{ min: -40, max: 10, below: 0, above: 0 }], + [{ lo: 0, stride: 1, integer: true }], + 1024, + 0.25, + ); + expect(signed!.lo).toBeLessThan(-40); + expect(signed!.integer).toBe(true); + }); + + it("fits a real range with margin and labels bin centres inside the span", () => { + const [window] = windowsFromObserved( + [{ min: 0, max: 1, below: 0, above: 0 }], + [{ lo: 0, stride: 1, integer: false }], + 1024, + 0.25, + ); + if (window === undefined) { + throw new Error("no window"); + } + + expect(window.lo).toBe(0); + expect(window.integer).toBe(false); + expect(window.stride).toBeCloseTo(1.25 / 1024, 9); + // The first and last bin centres sit within the padded span. + expect(window.lo + 0.5 * window.stride).toBeGreaterThan(0); + expect(window.lo + 1023.5 * window.stride).toBeLessThan(1.25); + expect(window.lo + 1023.5 * window.stride).toBeGreaterThan(1); + }); + + it("gives a constant real metric one bin centred on its value", () => { + const observed = Math.fround(0.3); + const [window] = windowsFromObserved( + [{ min: observed, max: observed, below: 0, above: 0 }], + [{ lo: 0, stride: 1, integer: false }], + 1024, + 0.25, + ); + if (window === undefined) { + throw new Error("no window"); + } + + // `observed - 0.5` is exact in f32 for any f32 in [0.25, 1), so the + // single bin's centre `lo + 0.5` is the constant itself. + expect(window).toEqual({ + lo: Math.fround(observed - 0.5), + stride: 1, + integer: false, + }); + const [frame] = decodeHistogramFrames({ + data: Uint32Array.from([9, 0]), + firstFrame: 0, + frameCount: 1, + metricIds: ["m"], + histogramBins: 2, + windows: [window], + }); + expect(frame?.bins).toEqual([[observed, 9]]); }); }); @@ -75,6 +153,36 @@ describe("anyEscapes", () => { }); }); +describe("f32OrderKey", () => { + const sorted = [-3.4e38, -1, -1e-30, 0, 1e-30, 1, 3.4e38]; + + it("orders keys as the floats they encode", () => { + const keys = sorted.map(f32OrderKey); + for (let index = 1; index < keys.length; index++) { + expect(keys[index]!).toBeGreaterThan(keys[index - 1]!); + } + // Every key is a u32, as the device's atomics hold it. + for (const key of keys) { + expect(key).toBeGreaterThanOrEqual(0); + expect(key).toBeLessThanOrEqual(0xffffffff); + expect(Number.isInteger(key)).toBe(true); + } + }); + + it("decodes back to the f32 the device held", () => { + for (const value of sorted) { + expect(decodeF32OrderKey(f32OrderKey(value))).toBe(Math.fround(value)); + } + }); + + it("leaves the runner's empty-range sentinels unused by any finite value", () => { + // The shader initialises min slots to the u32 maximum and max slots to + // zero; both must be keys no finite sample can produce. + expect(f32OrderKey(3.4e38)).toBeLessThan(0xffffffff); + expect(f32OrderKey(-3.4e38)).toBeGreaterThan(0); + }); +}); + describe("calibrationKey", () => { it("keys by marking and metric set", () => { const base = calibrationKey({ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.ts b/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.ts index 066a15d2f77..a57949d3b3b 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/metric-windows.ts @@ -1,19 +1,21 @@ /** - * Histogram windows: which count range each metric's bins cover. + * Histogram windows: which value range each metric's bins cover. * * Bins used to be zero-anchored — bin `i` meant count `i` — which coupled the * representable range to the bin budget: a place living in [1000, 1200] * wasted a thousand bins and a count past the budget clamped into the top * bin, surfacing as a warning the user could do nothing about. A window maps - * bin `i` to count `lo + i × stride` instead, and the window is a *uniform*, - * so moving it needs no shader recompile. + * bin `i` to the value `lo + i × stride` instead, and the window is a + * *uniform*, so moving it needs no shader recompile. * * The window is planned, observed, and replanned: * - * 1. The first attempt anchors on what is known exactly — a sampled place's - * initial count, and its capacity ceiling when it has one. - * 2. The shader tracks each metric's observed min/max on the device and - * counts values that escaped the window (clamped into an edge bin). + * 1. The first attempt is exact for a ceiling-bounded place count; any other + * metric gets a blind `lo 0, stride 1` window, which the handle always + * probes before the full run. + * 2. The shader tracks each metric's observed min/max on the device — as + * order-preserving u32 keys, so the u32 atomics reduce floats — and counts + * values that escaped the window (clamped into an edge bin). * 3. Any escape recalibrates: the handle replans from the observed range and * re-runs. Seeds derive from absolute run indices, so a re-run reproduces * the same trajectories and the observed range is exact — one re-run @@ -23,15 +25,26 @@ */ export type MetricWindow = { - /** Count the first bin represents. */ + /** Value at the first bin's lower edge. */ lo: number; - /** Counts per bin; 1 is exact, wider strides trade resolution for range. */ + /** + * Values per bin: an integer ≥ 1 for integer windows, any positive f32 + * otherwise. + */ stride: number; + /** + * Whether samples are whole numbers: integer windows label a bin by its + * middle integer, real ones by its centre. + */ + integer: boolean; }; /** What the device observed for one metric across every run and frame. */ export type ObservedMetricRange = { - /** Smallest and largest sampled count; `min > max` means no samples. */ + /** + * Smallest and largest sampled value, decoded from the device's order keys; + * `min > max` (±Infinity) means no samples. + */ min: number; max: number; /** Samples clamped into an edge bin because they fell outside the window. */ @@ -41,35 +54,33 @@ export type ObservedMetricRange = { /** What window planning knows about one metric before any run. */ export type MetricWindowInput = { - /** The sampled place's initial token count. */ - initialCount: number; + integer: boolean; /** - * Largest count the place can reach, or null when unbounded. A ceiling - * makes the window exact and escape-free by construction. + * Largest value the metric can reach, or null. Only a declared place + * capacity provides one; it makes the window exact and escape-free by + * construction. */ - countCeiling: number | null; + ceiling: number | null; }; const spanStride = (lo: number, hi: number, bins: number): number => Math.max(1, Math.ceil((hi - lo + 1) / bins)); /** - * First-attempt windows: exact for ceiling-bounded metrics, a generous - * anchored guess for unbounded ones. The guess trades resolution, not - * memory — a wider window is a larger stride over the same bins — so - * guessing large is cheap and the calibrated re-run restores resolution. + * First-attempt windows: exact for ceiling-bounded metrics, blind otherwise. + * A blind window is always probed, and the probe's observed range replans it + * whatever the blind window clamped — range tracking is independent of the + * window. */ export function planInitialWindows( inputs: readonly MetricWindowInput[], bins: number, ): MetricWindow[] { - return inputs.map(({ initialCount, countCeiling }) => { - if (countCeiling !== null) { - return { lo: 0, stride: spanStride(0, countCeiling, bins) }; - } - const hi = Math.max(2 * initialCount, initialCount + bins - 1, bins - 1); - return { lo: 0, stride: spanStride(0, hi, bins) }; - }); + return inputs.map(({ integer, ceiling }) => + ceiling === null + ? { lo: 0, stride: 1, integer } + : { lo: 0, stride: spanStride(0, ceiling, bins), integer: true }, + ); } /** @@ -78,7 +89,9 @@ export function planInitialWindows( * A probe's extremes understate a larger run's (more runs, wider tails), so * `marginFraction` widens the observed span on both sides; the escape * counters catch an undershoot and trigger one more calibration. A metric - * the run never sampled (`min > max`) keeps its previous window. + * the run never sampled (`min > max`) keeps its previous window. `lo` clamps + * at zero only when no negative value was observed, so a count metric never + * spends bins below zero and a signed metric keeps its margin. */ export function windowsFromObserved( observed: readonly ObservedMetricRange[], @@ -87,17 +100,28 @@ export function windowsFromObserved( marginFraction: number, ): MetricWindow[] { return observed.map((range, index) => { - const fallback = previous[index] ?? { lo: 0, stride: 1 }; + const fallback = previous[index] ?? { lo: 0, stride: 1, integer: true }; if (range.min > range.max) { return fallback; } - const margin = Math.max( - 2, - Math.ceil((range.max - range.min + 1) * marginFraction), - ); - const lo = Math.max(0, range.min - margin); - const hi = range.max + margin; - return { lo, stride: spanStride(lo, hi, bins) }; + if (fallback.integer) { + const margin = Math.max( + 2, + Math.ceil((range.max - range.min + 1) * marginFraction), + ); + const lo = + range.min >= 0 ? Math.max(0, range.min - margin) : range.min - margin; + const hi = range.max + margin; + return { lo, stride: spanStride(lo, hi, bins), integer: true }; + } + const pad = (range.max - range.min) * marginFraction; + const lo = range.min >= 0 ? Math.max(0, range.min - pad) : range.min - pad; + const stride = Math.fround((range.max + pad - lo) / bins); + if (stride > 0) { + return { lo: Math.fround(lo), stride, integer: false }; + } + // A constant metric: one bin, centred on the value. + return { lo: Math.fround(range.min - 0.5), stride: 1, integer: false }; }); } @@ -106,6 +130,28 @@ export function anyEscapes(observed: readonly ObservedMetricRange[]): boolean { return observed.some((range) => range.below > 0 || range.above > 0); } +const keyView = new DataView(new ArrayBuffer(4)); + +/* eslint-disable no-bitwise -- the order key is bit arithmetic */ +/** + * The shader's `f32_order_key`: the f32's bits as a u32 whose order matches + * the float's, so the device's u32 min/max atomics reduce a float range. + * Positives set the sign bit, negatives flip every bit. + */ +export const f32OrderKey = (value: number): number => { + keyView.setFloat32(0, value); + const bits = keyView.getUint32(0); + return ((bits & 0x80000000) === 0 ? bits | 0x80000000 : ~bits) >>> 0; +}; + +/** Inverse of `f32OrderKey`, for the device's range readback. */ +export const decodeF32OrderKey = (key: number): number => { + const bits = ((key & 0x80000000) === 0 ? ~key : key & 0x7fffffff) >>> 0; + keyView.setUint32(0, bits); + return keyView.getFloat32(0); +}; +/* eslint-enable no-bitwise */ + /** * The cache key for a batch's calibration (windows + derived capacities). * diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts index 0a1be660ea2..32b42c00e08 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts @@ -15,6 +15,7 @@ * histogram buffer — bins are sums, so the merge is free. */ import { GPU_WORKGROUP_SIZE } from "./compile-net-shader"; +import { decodeF32OrderKey } from "./metric-windows"; import { createPipeline, describeAllocationFailure, @@ -42,7 +43,7 @@ export type { GpuDeviceHandle, GpuHistogramFrame }; /** * Fixed words in the uniform config block: run_count, base_frame, * frame_limit, seed, chunk_frames. Each metric adds two more (its window's - * lo and stride). + * lo and stride, as f32). */ const CONFIG_FIXED_WORDS = 5; @@ -102,7 +103,8 @@ export type GpuExperimentRequest = { previewRuns: number | null; /** * Each metric's histogram window, in `shader.metricIds` order. Defaults to - * `{lo: 0, stride: 1}` per metric — the zero-anchored exact layout. + * `{lo: 0, stride: 1, integer: true}` per metric — the zero-anchored exact + * integer layout. */ metricWindows?: readonly MetricWindow[]; /** @@ -143,12 +145,18 @@ export type GpuExperimentResult = { /** Wall-clock time spent inside dispatches, excluding setup. */ dispatchMs: number; /** - * Per metric, what the device observed: the sampled min/max count and how + * Per metric, what the device observed: the sampled min/max value and how * many samples escaped the window (clamped into an edge bin). Any escape * means the frames are an intermediate picture and the caller should * recalibrate the windows and re-run. */ metricRanges: ObservedMetricRange[]; + /** + * Runs halted by a non-finite sample (status `4 + metric`), per metric in + * `metricIds` order. The CPU evaluator throws on the same value, so any + * count here fails the experiment. + */ + metricErrors: number[]; }; export async function runGpuExperiment( @@ -184,15 +192,16 @@ export async function runGpuExperiment( } const metricWindows: MetricWindow[] = shader.metricIds.map( - (_, index) => request.metricWindows?.[index] ?? { lo: 0, stride: 1 }, + (_, index) => + request.metricWindows?.[index] ?? { lo: 0, stride: 1, integer: true }, ); const configWords = new Uint32Array(CONFIG_FIXED_WORDS + 2 * metricCount); + // The window words are f32 in the shader's `Config`; the view writes them + // into the same buffer the u32 words occupy. + const configFloats = new Float32Array(configWords.buffer); for (const [index, window] of metricWindows.entries()) { - configWords[CONFIG_FIXED_WORDS + 2 * index] = window.lo; - configWords[CONFIG_FIXED_WORDS + 2 * index + 1] = Math.max( - 1, - window.stride, - ); + configFloats[CONFIG_FIXED_WORDS + 2 * index] = window.lo; + configFloats[CONFIG_FIXED_WORDS + 2 * index + 1] = window.stride; } const bytesPerRun = shader.stateWordsPerRun * 4; @@ -341,6 +350,7 @@ export async function runGpuExperiment( let deadlockedRuns = 0; let completedRuns = 0; let overflowRuns = 0; + const metricErrors = new Array(metricCount).fill(0); let cancelled = false; let dispatchMs = 0; @@ -532,6 +542,8 @@ export async function runGpuExperiment( completedRuns++; } else if (status === 3) { overflowRuns++; + } else if (status >= 4 && status - 4 < metricCount) { + metricErrors[status - 4] = metricErrors[status - 4]! + 1; } for (let slot = 0; slot < derivedCount; slot++) { const runMax = summary[base + placeCount + 1 + slot] ?? 0; @@ -574,9 +586,14 @@ export async function runGpuExperiment( await rangeReadback.mapAsync(GPUMapMode.READ); const rangeWords = new Uint32Array(rangeReadback.getMappedRange()); for (let metric = 0; metric < metricCount; metric++) { + const minKey = rangeWords[metric * 4]!; + const maxKey = rangeWords[metric * 4 + 1]!; + // The min slot's initial u32 maximum and the max slot's initial zero + // are no finite value's order key, so both untouched means no sample. + const sampled = !(minKey === 0xffffffff && maxKey === 0); metricRanges.push({ - min: rangeWords[metric * 4]!, - max: rangeWords[metric * 4 + 1]!, + min: sampled ? decodeF32OrderKey(minKey) : Number.POSITIVE_INFINITY, + max: sampled ? decodeF32OrderKey(maxKey) : Number.NEGATIVE_INFINITY, below: rangeWords[metric * 4 + 2]!, above: rangeWords[metric * 4 + 3]!, }); @@ -619,6 +636,7 @@ export async function runGpuExperiment( })), dispatchMs, metricRanges, + metricErrors, }, }; } finally { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts index e7b184f682f..bbc53e305d9 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.test.ts @@ -23,8 +23,8 @@ describe("decodeHistogramFrames", () => { metricIds: ["a", "b"], histogramBins: 4, windows: [ - { lo: 0, stride: 1 }, - { lo: 10, stride: 1 }, + { lo: 0, stride: 1, integer: true }, + { lo: 10, stride: 1, integer: true }, ], }); @@ -73,7 +73,7 @@ describe("decodeHistogramFrames", () => { frameCount: 1, metricIds: ["a"], histogramBins: 4, - windows: [{ lo: 0, stride: 1 }], + windows: [{ lo: 0, stride: 1, integer: true }], }); expect(frames).toEqual([ @@ -96,13 +96,34 @@ describe("decodeHistogramFrames", () => { frameCount: 1, metricIds: ["a"], histogramBins: 4, - windows: [{ lo: 8, stride: 4 }], + windows: [{ lo: 8, stride: 4, integer: true }], }); expect(frame?.bins).toEqual([[9, 5]]); expect(frame?.binExtent).toEqual({ below: 1.5, above: 2.5 }); }); + it("labels a real window's bins by their centres, reaching half a stride either side", () => { + // Real samples have no integer to label a bin by, so the centre stands + // for the bin and the extent is symmetric. + const stride = Math.fround(1.25 / 4); + const [frame] = decodeHistogramFrames({ + data: Uint32Array.from([0, 3, 0, 2]), + firstFrame: 0, + frameCount: 1, + metricIds: ["a"], + histogramBins: 4, + windows: [{ lo: 0, stride, integer: false }], + }); + + expect(frame?.bins).toEqual([ + [1.5 * stride, 3], + [3.5 * stride, 2], + ]); + expect(frame?.binExtent).toEqual({ below: stride / 2, above: stride / 2 }); + expect(frame?.sampleCount).toBe(5); + }); + it("defaults a missing window to the zero-anchored exact layout", () => { const [frame] = decodeHistogramFrames({ data: Uint32Array.from([0, 7]), diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts index 4456eb87610..187f09777d5 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts @@ -2,7 +2,9 @@ * Decoding the device's histogram buffer into per-frame metric frames. * * The buffer holds `frameLimit × metrics × bins` u32 counts, frame-major then - * metric-major; a bin's value is its window position, `lo + bin × stride`. + * metric-major; bin `b` covers the values `[lo + b × stride, lo + (b + 1) × + * stride)` of its metric's window. An integer window labels the bin by its + * middle integer, exact at stride 1; a real window labels it by its centre. */ import type { MetricWindow } from "../metric-windows"; @@ -17,10 +19,11 @@ export type GpuHistogramFrame = { /** `[value, frequency]` pairs, ascending, zero bins omitted. */ bins: [number, number][]; /** - * The counts a bin stands for, as a reach below and above its label: a - * stride-`s` window bin labelled `v` holds the integer counts in + * The values a bin stands for, as a reach below and above its label. An + * integer window's stride-`s` bin labelled `v` holds the integers in * `[v - below, v + above)`, half a count either side of its outermost - * integers. + * integers; a real window's bin reaches `stride / 2` either side of its + * centre. */ binExtent: { below: number; above: number }; /** Runs that contributed a sample; equals the active run count. */ @@ -47,15 +50,29 @@ export const decodeHistogramFrames = (options: { const frames: GpuHistogramFrame[] = []; for (let frame = 0; frame < frameCount; frame++) { for (const [metricIndex, metricId] of metricIds.entries()) { - const window = windows[metricIndex] ?? { lo: 0, stride: 1 }; - // A bin covers `stride` counts; labelling its middle keeps a wide - // window's means unbiased where the low edge skewed them down by - // (stride − 1) / 2. Exact (offset 0) at stride 1. - const binMidpoint = Math.floor((window.stride - 1) / 2); - const binExtent = { - below: binMidpoint + 0.5, - above: window.stride - binMidpoint - 0.5, + const window = windows[metricIndex] ?? { + lo: 0, + stride: 1, + integer: true, }; + // An integer bin covers `stride` counts; labelling its middle keeps a + // wide window's means unbiased where the low edge skewed them down by + // (stride − 1) / 2. Exact (offset 0) at stride 1. A real bin is + // labelled by its centre. + const binMidpoint = window.integer + ? Math.floor((window.stride - 1) / 2) + : null; + const binExtent = + binMidpoint === null + ? { below: window.stride / 2, above: window.stride / 2 } + : { + below: binMidpoint + 0.5, + above: window.stride - binMidpoint - 0.5, + }; + const label = (bin: number): number => + binMidpoint === null + ? window.lo + (bin + 0.5) * window.stride + : window.lo + bin * window.stride + binMidpoint; const offset = frame * histogramBins * metricCount + metricIndex * histogramBins; const bins: [number, number][] = []; @@ -63,7 +80,7 @@ export const decodeHistogramFrames = (options: { for (let bin = 0; bin < histogramBins; bin++) { const frequency = data[offset + bin] ?? 0; if (frequency > 0) { - bins.push([window.lo + bin * window.stride + binMidpoint, frequency]); + bins.push([label(bin), frequency]); sampleCount += frequency; } } From 51009ee108fba1466baf48dbe4b40cad715db926 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 01:39:02 +0200 Subject: [PATCH 04/21] Translate metric HIR to WGSL with token spans, loop reduces and the metric state binder --- .../src/webgpu/compile-net-shader.test.ts | 192 +++++++++++++ .../src/webgpu/compile-net-shader.ts | 22 +- .../src/webgpu/compile-net-shader/README.md | 4 +- .../webgpu/compile-net-shader/histograms.ts | 79 +++-- .../compile-net-shader/metric-sample.ts | 150 ++++++++++ .../src/webgpu/emit-wgsl.test.ts | 216 ++++++++++++++ .../petrinaut-core/src/webgpu/emit-wgsl.ts | 115 +++++++- .../src/webgpu/gpu-experiment-handle.ts | 6 +- .../src/webgpu/try-translate-metric.test.ts | 270 ++++++++++++++++++ .../src/webgpu/try-translate-metric.ts | 78 +++++ 10 files changed, 1094 insertions(+), 38 deletions(-) create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/metric-sample.ts create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.ts 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 index 80e2760e8c0..e2795153387 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import { dronePatrol } from "../examples/drone-patrol"; import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher"; import { sirModel } from "../examples/sir-model"; +import { vaccinationCampaign } from "../examples/vaccination-campaign"; import { compileHirArtifacts } from "../hir"; +import { lowerTypeScriptToHir } from "../hir/lower-typescript"; import { resolveNetParameterValues } from "../parameter-values"; import { compileNetShader, @@ -13,6 +15,7 @@ import { import { assessGpuEligibility } from "./eligibility"; import { hirFromArtifacts } from "./hir-from-artifacts"; +import type { HirFunction } from "../hir/hir"; import type { SDCPN } from "../types/sdcpn"; import type { GpuMetricSpec, GpuOdeMethod } from "./compile-net-shader"; @@ -23,6 +26,39 @@ const placeCount = (id: string, placeId: string): GpuMetricSpec => ({ sample: { kind: "placeCount", placeId }, }); +/** + * An expression metric over a lowered body. The shader does not read + * `integer` — it only labels bins on the host — so it is fixed here. + */ +const expression = (id: string, hir: HirFunction): GpuMetricSpec => ({ + id, + integer: false, + sample: { kind: "expression", hir }, +}); + +/** One of the net's own metrics, through the artifact path the gate uses. */ +const modelMetric = (sdcpn: SDCPN, metricId: string): GpuMetricSpec => { + const hir = compileHirArtifacts(sdcpn, undefined, { includeHir: true }) + .artifacts.metrics[metricId]?.hir; + if (hir === undefined) { + throw new Error(`metric ${metricId} compiled without HIR`); + } + return expression(metricId, hir); +}; + +/** A metric body lowered without a net context, for shapes no example has. */ +const loweredMetric = (id: string, code: string): GpuMetricSpec => { + const result = lowerTypeScriptToHir(code, "metric"); + if (!result.ok) { + throw new Error( + `test metric did not lower: ${result.diagnostics + .map((diagnostic) => diagnostic.message) + .join("; ")}`, + ); + } + return expression(id, result.fn); +}; + function compileFor( sdcpn: SDCPN, { @@ -66,6 +102,7 @@ function compileFor( const sir = sirModel.petriNetDefinition; const satellites = probabilisticSatellitesSDCPN.petriNetDefinition; +const vaccination = vaccinationCampaign.petriNetDefinition; describe("per-run parameters", () => { it("reads a swept parameter from the per-run buffer and keeps the rest inlined", () => { @@ -936,6 +973,161 @@ function unbalancedBraces(wgsl: string): number { return depth; } +/** + * Expression metrics are emitted from their HIR at the top of the frame, + * inside the same `if (in_range && status == 0u)` block a place count is + * sampled in. These pin the emitted text for the shapes the bundled examples + * use: count arithmetic with a conditional, a `tokens.reduce` loop, and a + * swept parameter. + */ +describe("expression metrics", () => { + const cappedSatellites = (): SDCPN => ({ + ...satellites, + places: satellites.places.map((place) => ({ ...place, capacity: 16 })), + }); + + it("emits SIR's Infected Fraction as hoisted counts and a select", () => { + // Susceptible, Infected, Recovered are profile indices 0, 1, 2; the + // metric's `const` bindings hoist under the `m0_` scope in order, and + // the `if (total === 0) return 0` becomes a `select` over both arms. + const result = compileFor(sir, { + metrics: [modelMetric(sir, "metric__infected_fraction")], + }); + if (!result.ok) throw new Error(result.reason); + + expect(result.shader.wgsl).toContain( + [ + " if (in_range && status == 0u) {", + " let m0_u_0_s: f32 = f32(counts[0u]);", + " let m0_u_1_i: f32 = f32(counts[1u]);", + " let m0_u_2_r: f32 = f32(counts[2u]);", + " let m0_u_3_total: f32 = ((m0_u_0_s + m0_u_1_i) + m0_u_2_r);", + " let v0: f32 = select((m0_u_1_i / m0_u_3_total), 0.0, (m0_u_3_total == 0.0));", + " if ((bitcast(v0) & 0x7f800000u) == 0x7f800000u) {", + ].join("\n"), + ); + expect(result.shader.metricIds).toStrictEqual([ + "metric__infected_fraction", + ]); + }); + + it("emits a `tokens.reduce` metric as a loop over the place's live slots", () => { + // Satellites' "Average orbital speed": `const sats = ...tokens` binds the + // span and hoists nothing, the reduce loops to the live count, and the + // token read is the slot arithmetic the dynamics loop uses. The Satellite + // colour has four real attributes, `velocity` last. + const spaceIndex = satellites.places.findIndex( + (place) => place.name === "Space", + ); + const result = compileFor(cappedSatellites(), { + metrics: [modelMetric(satellites, "metric__average_orbital_speed")], + }); + if (!result.ok) throw new Error(result.reason); + const { wgsl, placeTokenOffsets, placeTokenStrides } = result.shader; + const tokenBase = placeTokenOffsets[spaceIndex]; + const stride = placeTokenStrides[spaceIndex]; + + expect(spaceIndex).toBe(0); + expect(stride).toBe(4); + expect(wgsl).toContain( + [ + " if (in_range && status == 0u) {", + " var m0_u_0_sum: f32 = 0.0;", + " for (var m0_u_1_s: u32 = 0u; m0_u_1_s < counts[0u]; m0_u_1_s = m0_u_1_s + 1u) {", + ` m0_u_0_sum = (m0_u_0_sum + bitcast(state[(base + ${tokenBase}u + m0_u_1_s * ${stride}u) + 3u]));`, + " }", + " let v0: f32 = select((m0_u_0_sum / f32(counts[0u])), 0.0, (f32(counts[0u]) == 0.0));", + ].join("\n"), + ); + }); + + it("reads a swept parameter from its per-run local inside a metric", () => { + // Vaccination's "Total cost" reads `parameters.vaccination_coverage`; swept, + // it resolves to `run_param_0` exactly as it does inside a lambda, while + // the fixed parameters stay literals. + const result = compileFor(vaccination, { + metrics: [modelMetric(vaccination, "metric__total_cost")], + runParameters: ["vaccination_coverage"], + }); + if (!result.ok) throw new Error(result.reason); + + expect(result.shader.wgsl).toContain( + " let m0_u_2_coverage: f32 = run_param_0;", + ); + expect(result.shader.wgsl).toMatch( + / let m0_u_3_reduction: f32 = -?\d+(\.\d+)?(e[-+]?\d+)?;/, + ); + }); + + it("gives an expression metric the full bin budget, whatever the places' ceilings", () => { + // A count metric on Airborne (capacity 16) sizes to 17 bins; an + // expression over the same place has no ceiling the shader can know. + const drone = dronePatrol.petriNetDefinition; + const result = compileFor(drone, { + metrics: [ + loweredMetric("airborne_share", "return state.places.Airborne.count;"), + ], + }); + if (!result.ok) throw new Error(result.reason); + + expect(result.shader.histogramBins).toBe(GPU_HISTOGRAM_MAX_BINS); + }); + + it("reports a reason rather than throwing when a metric names an unknown place", () => { + const result = compileFor(sir, { + metrics: [loweredMetric("nowhere", "return state.places.Nowhere.count;")], + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toMatch(/unknown field `Nowhere`/); + }); + + it("scopes each metric's temporaries so two metrics may bind the same name", () => { + // Both bodies bind `total`; without the per-metric scope the second + // metric's `let` would redeclare the first's in the frame loop's scope. + const result = compileFor(sir, { + metrics: [ + modelMetric(sir, "metric__infected_fraction"), + loweredMetric( + "alive", + "const total = state.places.Susceptible.count + state.places.Infected.count;\nreturn total;", + ), + ], + }); + if (!result.ok) throw new Error(result.reason); + const { wgsl } = result.shader; + + expect(wgsl).toContain("let m0_u_3_total: f32 ="); + expect(wgsl).toContain("let m1_u_0_total: f32 ="); + expect(wgsl).toContain("let v1: f32 = m1_u_0_total;"); + expect(sameScopeRedeclarations(wgsl)).toStrictEqual([]); + expect(unbalancedBraces(wgsl)).toBe(0); + }); + + it("scans clean with every satellites model metric, two of them reduce loops", () => { + const net = cappedSatellites(); + const result = compileFor(net, { + metrics: (satellites.metrics ?? []).map((metric) => + modelMetric(satellites, metric.id), + ), + }); + if (!result.ok) throw new Error(result.reason); + const { wgsl, metricIds } = result.shader; + + expect(metricIds).toHaveLength(4); + // Both reduce metrics loop to the live count under their own scope. + expect(wgsl).toContain( + "for (var m2_u_1_s: u32 = 0u; m2_u_1_s < counts[0u];", + ); + expect(wgsl).toContain( + "for (var m3_u_1_s: u32 = 0u; m3_u_1_s < counts[0u];", + ); + expect(sameScopeRedeclarations(wgsl)).toStrictEqual([]); + expect(unbalancedBraces(wgsl)).toBe(0); + }); +}); + describe("generated WGSL validity", () => { const cappedSatellites = (): SDCPN => ({ ...satellites, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts index bab81273152..5cce747313c 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.ts @@ -15,7 +15,8 @@ * The concerns live in `compile-net-shader/`: `token-layout` (state and * attribute encoding), `transition-firing` (enabledness, token choice, * consumption), `output-emission` (kernel outputs), `dynamics` (ODE stages), - * `histograms` (on-device metrics) and `run-parameters` (per-run buffer). + * `histograms` (on-device metrics), `metric-sample` (metric bodies over the + * live state) and `run-parameters` (per-run buffer). */ import { getArcEndpointPlaceId } from "../arc-endpoints"; import { emitDynamics } from "./compile-net-shader/dynamics"; @@ -28,6 +29,10 @@ import { sampledCountCeiling, workgroupHistogramLines, } from "./compile-net-shader/histograms"; +import { + layoutPlaceBindings, + metricStateValue, +} from "./compile-net-shader/metric-sample"; import { emitKernelValues, emitOutputWrites, @@ -57,6 +62,7 @@ import type { HirFunction } from "../hir/hir"; import type { SDCPN } from "../types/sdcpn"; import type { GpuOdeMethod } from "./compile-net-shader/dynamics"; import type { GpuMetricSpec } from "./compile-net-shader/histograms"; +import type { MetricPlaceBinding } from "./compile-net-shader/metric-sample"; import type { GpuNetProfile } from "./eligibility"; export { @@ -64,8 +70,13 @@ export { GPU_HISTOGRAM_MAX_BINS, histogramBinCount, } from "./compile-net-shader/histograms"; +export { + emitMetricSample, + metricStateValue, + probePlaceBindings, +} from "./compile-net-shader/metric-sample"; export { encodeInitialTokenWords } from "./compile-net-shader/token-layout"; -export type { GpuMetricSpec, GpuOdeMethod }; +export type { GpuMetricSpec, GpuOdeMethod, MetricPlaceBinding }; /** Invocations per workgroup. 256 is the guaranteed WebGPU maximum. */ export const GPU_WORKGROUP_SIZE = 256; @@ -202,6 +213,11 @@ export function compileNetShader( const layout = planStateLayout(profile, sdcpn.transitions.length); const placeCount = profile.places.length; const transitionCount = sdcpn.transitions.length; + // `state` for expression metrics: every place by display name over the + // live registers and slots, built once and read at each frame's sample. + const metricState = metricStateValue( + layoutPlaceBindings(profile, layout, discreteTypes), + ); const lines: string[] = []; const push = (line: string) => lines.push(line); @@ -306,6 +322,8 @@ export function compileNetShader( placeIndexById, bins: histogramBins, workgroupSize: GPU_WORKGROUP_SIZE, + metricState, + parameterValues: emitterParameterValues, }); push( diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/README.md b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/README.md index 9d714ad1ea0..7c7b4807587 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/README.md +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/README.md @@ -1,6 +1,6 @@ --- layer: core.webgpu.shader -role: Generates the WGSL compute shader for a net, one module per concern (token layout, transition firing, output emission, dynamics, histograms, run parameters) +role: Generates the WGSL compute shader for a net, one module per concern (token layout, transition firing, output emission, dynamics, histograms, metric sampling, run parameters) --- -The shader generator's concerns, each in its own module so an extension changes one file: `token-layout.ts` owns how a token's attributes become words (a String or UUID attribute needs its word encoding here and its admission in `eligibility.ts`), `transition-firing.ts` owns enablement and the choice of tokens to consume, `output-emission.ts` writes kernel outputs, `dynamics.ts` emits the ODE stages, `histograms.ts` the per-metric windows and workgroup histograms, `run-parameters.ts` the per-run parameter buffer reads. `compile-net-shader.ts` in the parent folder assembles them into one shader. +The shader generator's concerns, each in its own module so an extension changes one file: `token-layout.ts` owns how a token's attributes become words (a String or UUID attribute needs its word encoding here and its admission in `eligibility.ts`), `transition-firing.ts` owns enablement and the choice of tokens to consume, `output-emission.ts` writes kernel outputs, `dynamics.ts` emits the ODE stages, `histograms.ts` the per-metric windows and workgroup histograms, `metric-sample.ts` binds `state.places` to the live registers and slots and emits a metric body as one f32 sample (the same binder serves the device-free probe with placeholder reads), `run-parameters.ts` the per-run parameter buffer reads. `compile-net-shader.ts` in the parent folder assembles them into one shader. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts index 7385a8a2d7d..737028e43db 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts @@ -9,8 +9,11 @@ */ import { placeCountCeiling } from "../eligibility"; import { WgslBailError } from "../emit-wgsl"; +import { emitMetricSample } from "./metric-sample"; +import type { HirFunction } from "../../hir/hir"; import type { GpuNetProfile } from "../eligibility"; +import type { WgslParameterValue, WgslValue } from "../emit-wgsl"; /** Most bins any shader allocates, however generous the budget. */ export const GPU_HISTOGRAM_MAX_BINS = 1024; @@ -30,26 +33,27 @@ export type GpuMetricSpec = { id: string; /** Every sample is a whole number, so bins keep exact integer labels. */ integer: boolean; - sample: { + sample: /** A place's token count, read from `counts[]`. */ - kind: "placeCount"; - placeId: string; - }; + | { kind: "placeCount"; placeId: string } + /** A metric body over `state.places`, emitted per run per frame. */ + | { kind: "expression"; hir: HirFunction }; }; /** * Histogram bins per metric per frame, for one compiled shader. * - * One bin per integer token count, so the bin count is the largest count the - * charts can distinguish plus one saturating top bin. Two inputs size it: + * Bins are the values the charts can distinguish plus one saturating top bin; + * an integer window spends one bin per whole number. Two inputs size it: * * - The workgroup-storage budget: `local_hist` holds `bins × metricCount` * u32 atomics, so more metrics mean fewer bins. Up to four metrics get the * full `GPU_HISTOGRAM_MAX_BINS`; a fixed 256 both wasted the budget below * five metrics and exceeded it (failing pipeline creation) above sixteen. - * - The sampled places' count ceiling, when every sampled place has one: - * counts past the ceiling cannot occur, so bins past it would only slow - * the per-frame zero/merge loops. + * - The sampled places' count ceiling, when every metric is a place count + * with one: counts past the ceiling cannot occur, so bins past it would + * only slow the per-frame zero/merge loops. An expression metric has no + * ceiling and takes the full budget. */ export function histogramBinCount( metricCount: number, @@ -70,7 +74,7 @@ export function histogramBinCount( /** * The largest count any sampled place can reach, or null when one is - * unbounded. + * unbounded or any metric is an expression. */ export const sampledCountCeiling = ( metrics: readonly GpuMetricSpec[], @@ -79,6 +83,9 @@ export const sampledCountCeiling = ( ): number | null => { let ceiling = 0; for (const metric of metrics) { + if (metric.sample.kind === "expression") { + return null; + } const place = profile.places[placeIndexById.get(metric.sample.placeId) ?? -1]; const placeCeiling = place === undefined ? null : placeCountCeiling(place); @@ -172,11 +179,13 @@ export const workgroupHistogramLines = ( * last row was never written when sampling followed the step (every run still * running takes `status = 2u` at the frame limit). * - * One path for every metric: the sample is an f32, its observed range travels - * as order-preserving u32 keys through the existing min/max atomics, and - * `window_bin` settles the bin against the window's exact edges. A non-finite - * sample halts the run with `status = 4u + metric`, so the host can fail the - * experiment naming the metric, as the CPU evaluator does when it throws. + * One path for every metric: the sample is an f32 — a place count cast from + * its register, or a metric body emitted over `metricState` — its observed + * range travels as order-preserving u32 keys through the existing min/max + * atomics, and `window_bin` settles the bin against the window's exact edges. + * A non-finite sample halts the run with `status = 4u + metric`, so the host + * can fail the experiment naming the metric, as the CPU evaluator does when + * it throws. */ export const emitFrameHistograms = ( push: (line: string) => void, @@ -185,9 +194,19 @@ export const emitFrameHistograms = ( placeIndexById: ReadonlyMap; bins: number; workgroupSize: number; + /** `state` for expression metrics, bound to the real layout. */ + metricState: WgslValue; + parameterValues: Readonly>; }, ): void => { - const { metrics, placeIndexById, bins, workgroupSize } = options; + const { + metrics, + placeIndexById, + bins, + workgroupSize, + metricState, + parameterValues, + } = options; if (metrics.length === 0) { return; } @@ -215,12 +234,6 @@ export const emitFrameHistograms = ( push(` }`); push(` workgroupBarrier();`); for (const [metricIndex, metric] of metrics.entries()) { - const placeIndex = placeIndexById.get(metric.sample.placeId); - if (placeIndex === undefined) { - throw new WgslBailError( - `metric \`${metric.id}\` references unknown place ${metric.sample.placeId}`, - ); - } const value = `v${metricIndex}`; const key = `k${metricIndex}`; const bin = `b${metricIndex}`; @@ -231,7 +244,27 @@ export const emitFrameHistograms = ( // triggers a recalibrated re-run — the clamped picture is only ever an // intermediate. push(` if (in_range && status == 0u) {`); - push(` let ${value}: f32 = f32(counts[${placeIndex}u]);`); + if (metric.sample.kind === "placeCount") { + const placeIndex = placeIndexById.get(metric.sample.placeId); + if (placeIndex === undefined) { + throw new WgslBailError( + `metric \`${metric.id}\` references unknown place ${metric.sample.placeId}`, + ); + } + push(` let ${value}: f32 = f32(counts[${placeIndex}u]);`); + } else { + // Each metric's temporaries carry their own scope, so two metrics + // binding the same `const` name declare distinct identifiers. + const sample = emitMetricSample(metric.sample.hir, { + state: metricState, + parameterValues, + identifierScope: `m${metricIndex}_`, + }); + for (const statement of sample.statements) { + push(` ${statement}`); + } + push(` let ${value}: f32 = ${sample.code};`); + } push(` if ((bitcast(${value}) & 0x7f800000u) == 0x7f800000u) {`); push( ` // NaN or an infinity: the CPU evaluator throws here, so the run halts`, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/metric-sample.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/metric-sample.ts new file mode 100644 index 00000000000..dba0c46be49 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/metric-sample.ts @@ -0,0 +1,150 @@ +/** + * Metric bodies sampled on the device. + * + * Metric code reads `state.places..count` and `.tokens`. The `state` + * record built here binds each root place to the shader's live count and a + * span over its token slots, and `emitMetricSample` emits the metric HIR as + * one f32 sample expression with the statements it hoists. The same binder + * serves the device-free probe (`try-translate-metric.ts`) with placeholder + * reads, so the eligibility gate and the shader agree on what translates. + */ +import { + WgslBailError, + WgslEmitter, + wgslStringBailReason, + wgslUuidBailReason, +} from "../emit-wgsl"; +import { makeTokenReader, tokenSlotExpr } from "./token-layout"; + +import type { HirFunction } from "../../hir/hir"; +import type { HirMetricContext } from "../../hir/surface-context"; +import type { GpuNetProfile } from "../eligibility"; +import type { + WgslParameterValue, + WgslTokenReader, + WgslValue, +} from "../emit-wgsl"; +import type { DiscreteType, StateLayout } from "./token-layout"; + +/** One root place as metric code sees it, resolved last-wins by display name. */ +export type MetricPlaceBinding = { + name: string; + /** WGSL u32 expression for the place's live token count. */ + count: string; + readAt: (indexVar: string) => WgslTokenReader; +}; + +/** + * `state` for a metric body: `{ places: { : { count, tokens } } }`. + * + * Bindings are set in order, so a duplicate display name resolves to the + * last place — the rule `buildMetricContext` and the CPU evaluator follow. + */ +export const metricStateValue = ( + places: readonly MetricPlaceBinding[], +): WgslValue => { + const placesByName = new Map(); + for (const place of places) { + placesByName.set(place.name, { + kind: "record", + fields: new Map([ + ["count", { kind: "f32", code: `f32(${place.count})` }], + [ + "tokens", + { kind: "tokenSpan", count: place.count, readAt: place.readAt }, + ], + ]), + }); + } + return { + kind: "record", + fields: new Map([ + ["places", { kind: "record", fields: placesByName }], + ]), + }; +}; + +/** + * Bindings over the real layout, in profile order: the count register and a + * token reader over the place's slots. + */ +export const layoutPlaceBindings = ( + profile: GpuNetProfile, + layout: StateLayout, + discreteTypesByPlaceId: ReadonlyMap< + string, + ReadonlyMap + >, +): MetricPlaceBinding[] => + profile.places.map((place, index) => ({ + name: place.name, + count: `counts[${index}u]`, + readAt: (indexVar) => + makeTokenReader( + place, + discreteTypesByPlaceId.get(place.id) ?? new Map(), + tokenSlotExpr(layout, index, indexVar), + ), + })); + +/** + * Placeholder bindings for the device-free probe: count `0u`; a `boolean` + * attribute reads `false`, a numeric one `0.0`, a `string` or `uuid` one + * bails as the emitter does for such a value, and an unknown attribute bails + * as the layout reader does. + */ +export const probePlaceBindings = ( + context: HirMetricContext, +): MetricPlaceBinding[] => + context.places.map((place) => ({ + name: place.name, + count: "0u", + readAt: () => (fieldName) => { + const element = place.elements.find( + (candidate) => candidate.name === fieldName, + ); + if (element === undefined) { + throw new WgslBailError( + `place \`${place.name}\` has no attribute \`${fieldName}\``, + ); + } + switch (element.type) { + case "boolean": + return { kind: "bool", code: "false" }; + case "real": + case "integer": + return { kind: "f32", code: "0.0" }; + case "string": + throw new WgslBailError(wgslStringBailReason); + case "uuid": + throw new WgslBailError(wgslUuidBailReason); + } + }, + })); + +/** + * Emits one metric body: the hoisted statements first, then the f32 sample + * expression. `hir.params[0]` is `state`; metrics are deterministic and the + * typechecker rejects distributions outside kernels, so the emitter gets no + * generator. + */ +export const emitMetricSample = ( + hir: HirFunction, + options: { + state: WgslValue; + parameterValues: Readonly>; + identifierScope: string; + }, +): { statements: string[]; code: string } => { + const emitter = new WgslEmitter({ + parameterValues: options.parameterValues, + identifierScope: options.identifierScope, + }); + const env = new Map(); + const stateParam = hir.params[0]; + if (stateParam) { + env.set(stateParam.name, options.state); + } + const value = emitter.emit(hir.body, env); + return { statements: emitter.statements, code: emitter.f32(value) }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts index 62ae6afa68a..9371491a9e4 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { HIR_MATH_FNS } from "../hir/hir"; import { lowerTypeScriptToHir } from "../hir/lower-typescript"; +import { metricStateValue, probePlaceBindings } from "./compile-net-shader"; import { describeMathFnSupport, emitF32Literal, @@ -11,6 +12,7 @@ import { } from "./emit-wgsl"; import type { HirFunction } from "../hir/hir"; +import type { HirMetricContext } from "../hir/surface-context"; import type { WgslValue } from "./emit-wgsl"; /** Lowers a lambda body so tests exercise real HIR rather than hand-built trees. */ @@ -363,6 +365,220 @@ describe("WgslEmitter distributions", () => { }); }); +/** + * A metric reads a place's live tokens through a `tokenSpan`: a runtime + * count and a per-index reader. `.length` is the count and `.reduce` becomes + * a loop, which is how the shader samples `tokens.reduce(...)` metrics. + */ +describe("WgslEmitter token spans", () => { + function lowerMetric(code: string): HirFunction { + const result = lowerTypeScriptToHir(code, "metric"); + if (!result.ok) { + throw new Error( + `test metric did not lower: ${result.diagnostics + .map((diagnostic) => diagnostic.message) + .join("; ")}`, + ); + } + return result.fn; + } + + /** One place `P` whose tokens read `tok_()`, a stand-in for the slot read. */ + const spanState = (): WgslValue => + metricStateValue([ + { + name: "P", + count: "counts[0u]", + readAt: (indexVar) => (fieldName) => ({ + kind: "f32", + code: `tok_${fieldName}(${indexVar})`, + }), + }, + ]); + + function emitMetric( + code: string, + state: WgslValue = spanState(), + ): { statements: string[]; code: string } { + const fn = lowerMetric(code); + const emitter = new WgslEmitter({ parameterValues: {} }); + const env = new Map(); + env.set(fn.params[0]!.name, state); + const value = emitter.emit(fn.body, env); + return { statements: emitter.statements, code: emitter.f32(value) }; + } + + it("reads a place's count as the f32 of its register", () => { + const result = emitMetric("return state.places.P.count;"); + + expect(result.statements).toStrictEqual([]); + expect(result.code).toBe("f32(counts[0u])"); + }); + + it("reads `tokens.length` as the live count, not a static length", () => { + const result = emitMetric("return state.places.P.tokens.length;"); + + expect(result.statements).toStrictEqual([]); + expect(result.code).toBe("f32(counts[0u])"); + }); + + it("emits `tokens.reduce` as a loop over the live slots", () => { + // The count is only known on the device, so the fold cannot unroll as a + // tuple reduce does: the accumulator is a `var` seeded from the initial + // value and assigned once per token. + const result = emitMetric( + "return state.places.P.tokens.reduce((sum, s) => sum + s.x, 0);", + ); + + expect(result.statements).toStrictEqual([ + "var u_0_sum: f32 = 0.0;", + "for (var u_1_s: u32 = 0u; u_1_s < counts[0u]; u_1_s = u_1_s + 1u) {", + " u_0_sum = (u_0_sum + tok_x(u_1_s));", + "}", + ]); + expect(result.code).toBe("u_0_sum"); + }); + + it("places a `const` bound inside the callback inside the loop", () => { + // The binding reads the current token, so it has to be evaluated per + // iteration; hoisting it above the loop would read an unbound index. + const result = emitMetric(`return state.places.P.tokens.reduce((sum, s) => { + const twice = s.x * 2; + return sum + twice; +}, 0);`); + + expect(result.statements).toStrictEqual([ + "var u_0_sum: f32 = 0.0;", + "for (var u_1_s: u32 = 0u; u_1_s < counts[0u]; u_1_s = u_1_s + 1u) {", + " let u_2_twice: f32 = (tok_x(u_1_s) * 2.0);", + " u_0_sum = (u_0_sum + u_2_twice);", + "}", + ]); + }); + + it("binds the index parameter to the loop variable as an f32", () => { + const result = emitMetric( + "return state.places.P.tokens.reduce((acc, t, i) => acc + i, 0);", + ); + + expect(result.statements).toContain(" u_0_acc = (u_0_acc + f32(u_1_t));"); + }); + + it("gives a boolean seed a `bool` accumulator", () => { + const result = emitMetric( + "return state.places.P.tokens.reduce((any, s) => any || s.x > 1, false) ? 1 : 0;", + ); + + expect(result.statements).toStrictEqual([ + "var u_0_any: bool = false;", + "for (var u_1_s: u32 = 0u; u_1_s < counts[0u]; u_1_s = u_1_s + 1u) {", + " u_0_any = (u_0_any || (tok_x(u_1_s) > 1.0));", + "}", + ]); + expect(result.code).toBe("select(0.0, 1.0, u_0_any)"); + }); + + it("nests a reduce inside a reduce, loop inside loop", () => { + const result = emitMetric(`return state.places.P.tokens.reduce( + (sum, s) => sum + state.places.P.tokens.reduce((inner, t) => inner + t.x * s.x, 0), + 0, +);`); + + expect(result.statements).toStrictEqual([ + "var u_0_sum: f32 = 0.0;", + "for (var u_1_s: u32 = 0u; u_1_s < counts[0u]; u_1_s = u_1_s + 1u) {", + " var u_2_inner: f32 = 0.0;", + " for (var u_3_t: u32 = 0u; u_3_t < counts[0u]; u_3_t = u_3_t + 1u) {", + " u_2_inner = (u_2_inner + (tok_x(u_3_t) * tok_x(u_1_s)));", + " }", + " u_0_sum = (u_0_sum + u_2_inner);", + "}", + ]); + expect(result.code).toBe("u_0_sum"); + }); + + it("refuses `.concat`, which would read two places at once", () => { + expect(() => + emitMetric( + "return state.places.P.tokens.concat(state.places.P.tokens).length;", + ), + ).toThrow(/joins the tokens of two places/); + }); + + it("refuses indexing a token by position, which needs the CPU's bounds check", () => { + expect(() => emitMetric("return state.places.P.tokens[0].x;")).toThrow( + /bounds check/, + ); + }); + + it("refuses a record seed, which has no WGSL accumulator", () => { + expect(() => + emitMetric( + "return state.places.P.tokens.reduce((acc, s) => acc, { n: 0 }).n;", + ), + ).toThrow(/expected a numeric value/); + }); + + describe("through the probe's placeholder reader", () => { + const probeState = ( + elements: HirMetricContext["places"][number]["elements"], + ): WgslValue => { + const context: HirMetricContext = { + surface: "metric", + parameters: [], + places: [{ name: "P", elements }], + }; + return metricStateValue(probePlaceBindings(context)); + }; + + it("reads numbers as 0.0 and booleans as false over an empty count", () => { + const result = emitMetric( + "return state.places.P.tokens.reduce((n, t) => t.active ? n + t.x : n, 0);", + probeState([ + { name: "x", type: "real" }, + { name: "active", type: "boolean" }, + ]), + ); + + expect(result.statements).toStrictEqual([ + "var u_0_n: f32 = 0.0;", + "for (var u_1_t: u32 = 0u; u_1_t < 0u; u_1_t = u_1_t + 1u) {", + " u_0_n = select(u_0_n, (u_0_n + 0.0), false);", + "}", + ]); + }); + + it("refuses a string attribute with the emitter's own reason", () => { + // The net's eligibility already refuses string attributes; the probe + // must not read GPU-ready for a metric the net would refuse. + expect(() => + emitMetric( + "return state.places.P.tokens.reduce((n, t) => n + t.status, 0);", + probeState([{ name: "status", type: "string" }]), + ), + ).toThrow(/32-bit/); + }); + + it("refuses a uuid attribute", () => { + expect(() => + emitMetric( + "return state.places.P.tokens.reduce((n, t) => n + t.id, 0);", + probeState([{ name: "id", type: "uuid" }]), + ), + ).toThrow(/128-bit/); + }); + + it("refuses an attribute the place does not declare", () => { + expect(() => + emitMetric( + "return state.places.P.tokens.reduce((n, t) => n + t.mass, 0);", + probeState([{ name: "x", type: "real" }]), + ), + ).toThrow(/has no attribute `mass`/); + }); + }); +}); + describe("Math.hypot arity", () => { const hypotOf = (expression: string) => emit(`export default Lambda((tokens, parameters) => ${expression});`, { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts index 799b2d0727c..8c65518671a 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/emit-wgsl.ts @@ -36,6 +36,9 @@ export class WgslBailError extends Error { } } +/** Field name to a WGSL value reading that field of one token. */ +export type WgslTokenReader = (fieldName: string) => WgslValue; + /** * A WGSL value produced by emitting one HIR node. * @@ -49,7 +52,27 @@ export type WgslValue = | { kind: "record"; fields: Map } | { kind: "array"; elements: WgslValue[] } /** One token's field accessors, resolved lazily on field access. */ - | { kind: "token"; read: (fieldName: string) => WgslValue }; + | { kind: "token"; read: WgslTokenReader } + /** + * A place's live tokens: a runtime-length span. `count` is the WGSL u32 + * count, `readAt` reads the token at a u32 index variable. `.length` reads + * the count and `.reduce` emits a loop; `.concat` and positional indexing + * bail, because the shader reads one place at a time and has no bounds + * check to throw. + */ + | { + kind: "tokenSpan"; + count: string; + readAt: (indexVar: string) => WgslTokenReader; + }; + +/** Why a `string` value has no WGSL form; the probe's placeholder token reader gives the same reason. */ +export const wgslStringBailReason = + "string values need a 64-bit string-pool id, and WGSL integers are 32-bit"; + +/** Why a `uuid` value has no WGSL form; the probe's placeholder token reader gives the same reason. */ +export const wgslUuidBailReason = + "uuid values are 128-bit, which WGSL cannot represent"; /** * How each HIR math builtin reaches WGSL. @@ -231,7 +254,8 @@ export class WgslEmitter { if ( value.kind === "record" || value.kind === "array" || - value.kind === "token" + value.kind === "token" || + value.kind === "tokenSpan" ) { // Compile-time groupings need no temporary; they are destructured later. return value; @@ -340,6 +364,11 @@ export class WgslEmitter { case "indexAccess": { const target = this.emit(expr.target, env); + if (target.kind === "tokenSpan") { + throw new WgslBailError( + "indexing a place's tokens by position needs the CPU's bounds check", + ); + } if (target.kind !== "array") { throw new WgslBailError("index access on a non-array"); } @@ -355,6 +384,9 @@ export class WgslEmitter { case "length": { const target = this.emit(expr.target, env); + if (target.kind === "tokenSpan") { + return { kind: "f32", code: `f32(${target.count})` }; + } if (target.kind !== "array") { throw new WgslBailError("`.length` on a non-array"); } @@ -452,6 +484,11 @@ export class WgslEmitter { case "arrayConcat": { const left = this.emit(expr.left, env); const right = this.emit(expr.right, env); + if (left.kind === "tokenSpan" || right.kind === "tokenSpan") { + throw new WgslBailError( + "`.concat` joins the tokens of two places, which the shader reads one place at a time", + ); + } if (left.kind !== "array" || right.kind !== "array") { throw new WgslBailError("`.concat` on a non-array"); } @@ -463,9 +500,10 @@ export class WgslEmitter { case "arrayReduce": { const target = this.emit(expr.target, env); + if (target.kind === "tokenSpan") { + return this.#emitSpanReduce(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", ); @@ -488,15 +526,11 @@ export class WgslEmitter { case "stringLit": case "stringCall": - throw new WgslBailError( - "string values need a 64-bit string-pool id, and WGSL integers are 32-bit", - ); + throw new WgslBailError(wgslStringBailReason); case "uuidGenerate": case "uuidFrom": - throw new WgslBailError( - "uuid values are 128-bit, which WGSL cannot represent", - ); + throw new WgslBailError(wgslUuidBailReason); case "distribution": { const rngStateVar = this.options.rngStateVar; @@ -539,6 +573,67 @@ export class WgslEmitter { throw new WgslBailError(`unsupported HIR node \`${kind}\``); } + /** + * `.reduce` over a place's live tokens, as a loop. + * + * The token count is only known on the device, so the fold cannot unroll + * as an array reduce does. The accumulator becomes a `var` seeded from the + * initial value and the body's assignment runs once per live slot — the + * loop shape `dynamics.ts` already emits. Statements the body hoists after + * the mark (its own `const` bindings, a nested reduce's loop) land inside + * the loop braces, so they are evaluated per token as the CPU does. + */ + #emitSpanReduce( + expr: Extract, + target: Extract, + env: ReadonlyMap, + ): WgslValue { + const initial = this.emit(expr.initial, env); + // The accumulator's WGSL type follows the seed; a record or array seed + // has no WGSL value and bails through `f32`. + const accumulatorKind = initial.kind === "bool" ? "bool" : "f32"; + const seed = + accumulatorKind === "bool" ? this.bool(initial) : this.f32(initial); + const accumulator = mangleWgslIdentifier( + expr.accParam.name, + this.#temporaries++, + this.options.identifierScope, + ); + const loopVariable = mangleWgslIdentifier( + expr.param.name, + this.#temporaries++, + this.options.identifierScope, + ); + + const scope = new Map(env); + scope.set(expr.accParam.name, { kind: accumulatorKind, code: accumulator }); + scope.set(expr.param.name, { + kind: "token", + read: target.readAt(loopVariable), + }); + if (expr.indexParam) { + scope.set(expr.indexParam.name, { + kind: "f32", + code: `f32(${loopVariable})`, + }); + } + + const mark = this.statements.length; + const bodyValue = this.emit(expr.body, scope); + const body = + accumulatorKind === "bool" ? this.bool(bodyValue) : this.f32(bodyValue); + const bodyStatements = this.statements.splice(mark); + + this.statements.push( + `var ${accumulator}: ${accumulatorKind} = ${seed};`, + `for (var ${loopVariable}: u32 = 0u; ${loopVariable} < ${target.count}; ${loopVariable} = ${loopVariable} + 1u) {`, + ...bodyStatements.map((statement) => ` ${statement}`), + ` ${accumulator} = ${body};`, + `}`, + ); + return { kind: accumulatorKind, code: accumulator }; + } + #emitBinary( expr: Extract, env: ReadonlyMap, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index e33febb8cfb..f188d4daf2b 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -343,7 +343,11 @@ export async function createGpuMonteCarloExperiment( // derived probe slab is not a ceiling: its counts calibrate empirically. const windowInputs: MetricWindowInput[] = gpuMetrics.metrics.map((metric) => { const place = - backend.profile.places[placeIndexById.get(metric.sample.placeId) ?? -1]; + metric.sample.kind === "placeCount" + ? backend.profile.places[ + placeIndexById.get(metric.sample.placeId) ?? -1 + ] + : undefined; return { integer: metric.integer, ceiling: diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.test.ts new file mode 100644 index 00000000000..d79ddb83d15 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; + +import { deploymentPipelineSDCPN } from "../examples/deployment-pipeline"; +import { productionMachines } from "../examples/production-with-machine-failure"; +import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher"; +import { sirModel } from "../examples/sir-model"; +import { supplyChainProfit } from "../examples/supply-chain-profit"; +import { supplyChainWithDisruption } from "../examples/supply-chain-with-disruption"; +import { vaccinationCampaign } from "../examples/vaccination-campaign"; +import { compileHirArtifacts } from "../hir"; +import { lowerTypeScriptToHir } from "../hir/lower-typescript"; +import { tryTranslateMetric } from "./try-translate-metric"; + +import type { HirFunction } from "../hir/hir"; +import type { SDCPN } from "../types/sdcpn"; + +function lowerMetric(code: string): HirFunction { + const result = lowerTypeScriptToHir(code, "metric"); + if (!result.ok) { + throw new Error( + `test metric did not lower: ${result.diagnostics + .map((diagnostic) => diagnostic.message) + .join("; ")}`, + ); + } + return result.fn; +} + +/** What the probe must say about one model metric. */ +type Expectation = { integer: boolean } | { refused: RegExp }; + +/** + * Every model metric of every bundled example, classified. Deliberately + * exhaustive: a metric added to an example fails this test until it is + * classified here, so the coverage the docs state stays true. + */ +const expectations: { + name: string; + sdcpn: SDCPN; + metrics: Record; +}[] = [ + { + name: "deployment pipeline", + sdcpn: deploymentPipelineSDCPN.petriNetDefinition, + metrics: { + metric__successful_deployments: { integer: true }, + metric__failed_deployments: { integer: true }, + metric__release_queue_length: { integer: true }, + metric__active_incidents: { integer: true }, + // `... ? 1 : 0` joins two integer literals. + metric__deployment_gate_blocked: { integer: true }, + // `failed / total` is a real. + metric__failure_share: { integer: false }, + }, + }, + { + name: "production with machine failure", + sdcpn: productionMachines.petriNetDefinition, + metrics: { + metric__good_products: { integer: true }, + metric__defective_products: { integer: true }, + metric__yield: { integer: false }, + metric__machines_down: { integer: true }, + // Joins two places' tokens; the shader reads one place at a time. + metric__average_machine_damage: { refused: /concat/ }, + }, + }, + { + name: "satellites", + sdcpn: probabilisticSatellitesSDCPN.petriNetDefinition, + metrics: { + metric__satellites_in_orbit: { integer: true }, + metric__debris: { integer: true }, + metric__average_orbital_radius: { integer: false }, + metric__average_orbital_speed: { integer: false }, + }, + }, + { + name: "SIR", + sdcpn: sirModel.petriNetDefinition, + metrics: { + metric__infected_fraction: { integer: false }, + }, + }, + { + name: "supply chain profit", + sdcpn: supplyChainProfit.petriNetDefinition, + metrics: { + metric_service_level: { integer: false }, + metric_profit: { integer: false }, + }, + }, + { + name: "supply chain with disruption", + sdcpn: supplyChainWithDisruption.petriNetDefinition, + metrics: { + metric_service_level: { integer: false }, + metric_customer_pressure: { integer: true }, + metric_stock_position: { integer: true }, + metric_inbound_pipeline: { integer: true }, + metric_average_inbound_risk: { integer: false }, + metric_factory_available: { integer: true }, + metric_scrap_rate: { integer: false }, + metric_supplier_outages: { integer: true }, + metric_average_order_age: { refused: /concat/ }, + }, + }, + { + name: "vaccination campaign", + sdcpn: vaccinationCampaign.petriNetDefinition, + metrics: { + metric__total_cost: { integer: false }, + metric__infected: { integer: true }, + metric__attack_rate: { integer: false }, + }, + }, +]; + +describe("tryTranslateMetric over the bundled examples", () => { + it.each(expectations)( + "classifies every model metric of $name", + ({ sdcpn, metrics }) => { + const { artifacts } = compileHirArtifacts(sdcpn, undefined, { + includeHir: true, + }); + const modelMetrics = sdcpn.metrics ?? []; + expect(modelMetrics.map((metric) => metric.id).sort()).toStrictEqual( + Object.keys(metrics).sort(), + ); + + for (const metric of modelMetrics) { + const hir = artifacts.metrics[metric.id]?.hir; + if (hir === undefined) { + throw new Error(`${metric.id} compiled without HIR`); + } + const expected = metrics[metric.id]!; + const result = tryTranslateMetric({ sdcpn, hir }); + if ("refused" in expected) { + expect(result, metric.id).toMatchObject({ translatable: false }); + expect(result.translatable ? "" : result.reason, metric.id).toMatch( + expected.refused, + ); + } else { + expect(result, metric.id).toStrictEqual({ + translatable: true, + integer: expected.integer, + }); + } + } + }, + ); + + it("translates 28 of the 30 model metrics", () => { + // The two refused bodies are the `.concat` averages; every count, + // parameter and single-place reduce body translates. + const all = expectations.flatMap((example) => + Object.values(example.metrics), + ); + + expect(all).toHaveLength(30); + expect(all.filter((expectation) => "integer" in expectation)).toHaveLength( + 28, + ); + }); +}); + +describe("tryTranslateMetric", () => { + const satellites = probabilisticSatellitesSDCPN.petriNetDefinition; + + it("classifies `tokens.length` and a count sum as integer", () => { + expect( + tryTranslateMetric({ + sdcpn: satellites, + hir: lowerMetric( + "return state.places.Space.tokens.length + state.places.Debris.count;", + ), + }), + ).toStrictEqual({ translatable: true, integer: true }); + }); + + it("classifies a reduce over a real attribute as real", () => { + // The reduce joins its integer seed with its real body. + expect( + tryTranslateMetric({ + sdcpn: satellites, + hir: lowerMetric( + "return state.places.Space.tokens.reduce((sum, s) => sum + s.velocity, 0);", + ), + }), + ).toStrictEqual({ translatable: true, integer: false }); + }); + + it("refuses a string attribute, so no metric reads GPU-ready on a net eligibility refuses", () => { + // The `artifacts.test.ts` fixture: a status string compared per token. + const sdcpn: SDCPN = { + types: [ + { + id: "order", + name: "Order", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [ + { elementId: "x", name: "x", type: "real" }, + { elementId: "status", name: "status", type: "string" }, + ], + }, + ], + places: [ + { + id: "target", + name: "Target", + colorId: "order", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + differentialEquations: [], + parameters: [], + }; + const result = tryTranslateMetric({ + sdcpn, + hir: lowerMetric(`return state.places.Target.tokens.reduce( + (count, token) => token.status === "done" ? count + 1 : count, + 0, +);`), + }); + + expect(result.translatable).toBe(false); + expect(result.translatable ? "" : result.reason).toMatch(/32-bit/); + }); + + it("refuses `Math.random`, which has no generator in a metric", () => { + const result = tryTranslateMetric({ + sdcpn: satellites, + hir: lowerMetric("return Math.random();"), + }); + + expect(result.translatable).toBe(false); + expect(result.translatable ? "" : result.reason).toMatch(/Math\.random/); + }); + + it("refuses a `.concat` over two places, and says why", () => { + const result = tryTranslateMetric({ + sdcpn: satellites, + hir: lowerMetric( + "return state.places.Space.tokens.concat(state.places.Debris.tokens).length;", + ), + }); + + expect(result.translatable).toBe(false); + expect(result.translatable ? "" : result.reason).toMatch( + /joins the tokens of two places/, + ); + }); + + it("refuses a metric over a place the net does not have", () => { + const result = tryTranslateMetric({ + sdcpn: satellites, + hir: lowerMetric("return state.places.Nowhere.count;"), + }); + + expect(result.translatable).toBe(false); + expect(result.translatable ? "" : result.reason).toMatch( + /unknown field `Nowhere`/, + ); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.ts b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.ts new file mode 100644 index 00000000000..2611b5eafe7 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/try-translate-metric.ts @@ -0,0 +1,78 @@ +/** + * Asks whether a metric body could be translated to WGSL, and whether its + * value is a whole number. + * + * This runs the same sample emitter `compile-net-shader.ts` uses, bound to + * placeholder places instead of the real layout, so it needs no device and + * no shader. The metric gate, the compilation report and the editor's GPU + * switch all read this one probe, which is why they give the same reason for + * the same metric. + * + * `integer` comes from the typechecker's static return type: `count`, + * `.length`, integer literals and `+ - * %` over them stay `int`, `/` and + * `**` make a `real`, a conditional joins its arms and a reduce joins its + * seed with its body. An integer metric keeps exact bin labels on the device. + */ +import { buildMetricContext } from "../hir/surface-context"; +import { typecheckHir } from "../hir/typecheck"; +import { resolveNetParameterValues } from "../parameter-values"; +import { + emitMetricSample, + metricStateValue, + probePlaceBindings, +} from "./compile-net-shader"; +import { WgslBailError } from "./emit-wgsl"; + +import type { PetrinautExtensionSettings } from "../extensions"; +import type { HirFunction } from "../hir/hir"; +import type { SDCPN } from "../types/sdcpn"; + +export type MetricTranslationResult = + | { + translatable: true; + /** The body's static return type is `int`. */ + integer: boolean; + } + | { translatable: false; reason: string }; + +export const tryTranslateMetric = ({ + sdcpn, + hir, + extensions, + parameterValues, +}: { + sdcpn: SDCPN; + hir: HirFunction; + extensions?: PetrinautExtensionSettings; + /** + * Resolved parameter values. Defaults to the net's own declared defaults, + * as the kernel probe does: the shader inlines parameters as literals, so + * an absent one fails emission with `unknown parameter ...`, which would + * read as the metric's fault. + */ + parameterValues?: Readonly>; +}): MetricTranslationResult => { + const context = buildMetricContext(sdcpn, extensions); + try { + emitMetricSample(hir, { + state: metricStateValue(probePlaceBindings(context)), + parameterValues: + parameterValues ?? + resolveNetParameterValues( + sdcpn.parameters, + {}, + extensions?.parameters ?? true, + ), + identifierScope: "probe_", + }); + } catch (error) { + if (error instanceof WgslBailError) { + return { translatable: false, reason: error.message }; + } + throw error; + } + return { + translatable: true, + integer: typecheckHir(hir, context).returnType.kind === "int", + }; +}; From 3093fb638e43d9ad1d76ae7e63b672ae110ffeab Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 01:52:56 +0200 Subject: [PATCH 05/21] Admit translatable expression metrics through the GPU gate and report them per metric --- .../src/examples/vaccination-campaign.test.ts | 34 +++ libs/@hashintel/petrinaut-core/src/webgpu.ts | 10 +- .../src/webgpu/compilation-report.test.ts | 164 ++++++++++- .../src/webgpu/compilation-report.ts | 84 ++++-- .../src/webgpu/gpu-experiment-handle.ts | 11 +- .../src/webgpu/gpu-metric-frames.test.ts | 262 ++++++++++++++++++ .../src/webgpu/gpu-metric-frames.ts | 93 ++++++- .../src/react/experiments/provider.test.tsx | 13 +- .../BottomPanel/subviews/compilation.tsx | 1 + .../shared/use-gpu-availability.ts | 2 +- 10 files changed, 631 insertions(+), 43 deletions(-) create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts diff --git a/libs/@hashintel/petrinaut-core/src/examples/vaccination-campaign.test.ts b/libs/@hashintel/petrinaut-core/src/examples/vaccination-campaign.test.ts index 6da04d132ad..e8b20267ee1 100644 --- a/libs/@hashintel/petrinaut-core/src/examples/vaccination-campaign.test.ts +++ b/libs/@hashintel/petrinaut-core/src/examples/vaccination-campaign.test.ts @@ -9,6 +9,7 @@ import { } from "../simulation/monte-carlo"; import { analyzeCompilation } from "../webgpu/compilation-report"; import { assessGpuEligibility } from "../webgpu/eligibility"; +import { tryTranslateMetric } from "../webgpu/try-translate-metric"; import { vaccinationCampaign } from "./vaccination-campaign"; import type { CompiledScenarioResult } from "../simulation/authoring/scenario/compile-scenario"; @@ -126,6 +127,39 @@ describe("Vaccination Campaign", () => { ).toStrictEqual(["gpu-ready", "gpu-ready"]); }); + it("compiles to a GPU shader with the Total cost expression objective", () => { + // The optimization stories minimise this metric; it prices counts by + // parameters, so its samples are real and the GPU bins them to a + // calibrated window. + const artifact = artifacts.metrics[totalCost.id]!; + expect( + tryTranslateMetric({ sdcpn: petriNetDefinition, hir: artifact.hir! }), + ).toStrictEqual({ translatable: true, integer: false }); + + const report = analyzeCompilation({ + sdcpn: petriNetDefinition, + artifacts, + metricSpecs: [ + { + kind: "expression", + id: totalCost.id, + label: totalCost.name, + code: totalCost.code, + artifact, + }, + ], + }); + + expect(report.gpuReady).toBe(true); + expect(report.metricFailure).toBeNull(); + expect(report.shaderFailure).toBeNull(); + expect( + report.items.find( + (item) => item.kind === "metric" && item.itemId === totalCost.id, + )?.status, + ).toBe("gpu-ready"); + }); + it("seeds the Winter wave from the coverage and the initial cases", () => { const result = compile(); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu.ts b/libs/@hashintel/petrinaut-core/src/webgpu.ts index 9cdb4caf0ca..940e87a5206 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu.ts @@ -8,9 +8,9 @@ * touches the TypeScript frontend. * * Only the surface the app consumes is exported: the backend factory, the - * compilation report the editor renders, and the metric-spec gate the - * experiment drawer applies. Everything else in `webgpu/` is internal; tests - * import it by relative path. + * compilation report the editor renders, the metric-spec gate the experiment + * drawer applies, and the per-metric translation probe behind both. Everything + * else in `webgpu/` is internal; tests import it by relative path. * * "The WebGPU backend" in * `libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx` covers @@ -37,3 +37,7 @@ export type { export { toGpuMetricSpecs } from "./webgpu/gpu-metric-frames"; export type { GpuMetricSpec } from "./webgpu/compile-net-shader"; +export { + tryTranslateMetric, + type MetricTranslationResult, +} from "./webgpu/try-translate-metric"; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts index 2a97266d8ab..59b987d7508 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts @@ -11,6 +11,7 @@ import { summarizeGpuUnavailability, } from "./compilation-report"; +import type { MonteCarloExpressionMetricSpec } from "../simulation/monte-carlo/metrics"; import type { SDCPN } from "../types/sdcpn"; function analyze(sdcpn: SDCPN) { @@ -22,6 +23,26 @@ function analyze(sdcpn: SDCPN) { return analyzeCompilation({ sdcpn, artifacts }); } +/** One of the net's model metrics as the expression spec an experiment sends. */ +function modelMetricSpec( + sdcpn: SDCPN, + metricId: string, +): MonteCarloExpressionMetricSpec { + const metric = sdcpn.metrics?.find((candidate) => candidate.id === metricId); + const artifact = compileHirArtifacts(sdcpn, undefined, { includeHir: true }) + .artifacts.metrics[metricId]; + if (metric === undefined || artifact === undefined) { + throw new Error(`metric ${metricId} is not on the net or did not compile`); + } + return { + kind: "expression", + id: metric.id, + label: metric.name, + code: metric.code, + artifact, + }; +} + const satellites = probabilisticSatellitesSDCPN.petriNetDefinition; describe("gpu-ready shipped examples", () => { @@ -254,6 +275,118 @@ describe("analyzeCompilation", () => { expect(crash?.hirNodeCount).toBeGreaterThan(8); }); + it("classifies every bundled example's model metrics", () => { + // One `metric` row per model metric, whether or not an experiment + // measures it. Deliberately exhaustive over the examples namespace, like + // the readiness matrix: a metric added to an example fails here until its + // GPU verdict is recorded. Every translatable metric on a GPU-ready net is + // `gpu-ready`; the two `.concat` averages are `cpu-only` with their own + // reason; Production Machines' other metrics are `cpu-only` because the + // net's shader fails, which is a different sentence. + const statuses = Object.fromEntries( + Object.entries(allExamples).map(([name, example]) => { + const definition = (example as { petriNetDefinition: SDCPN }) + .petriNetDefinition; + const rows = analyze(definition).items.filter( + (item) => item.kind === "metric", + ); + expect(rows.map((row) => row.itemId).sort(), name).toStrictEqual( + (definition.metrics ?? []).map((metric) => metric.id).sort(), + ); + return [ + name, + Object.fromEntries(rows.map((row) => [row.itemId, row.status])), + ]; + }), + ); + expect(statuses).toStrictEqual({ + productionMachines: { + metric__good_products: "cpu-only", + metric__defective_products: "cpu-only", + metric__yield: "cpu-only", + metric__machines_down: "cpu-only", + metric__average_machine_damage: "cpu-only", + }, + deploymentPipelineSDCPN: { + metric__successful_deployments: "gpu-ready", + metric__failed_deployments: "gpu-ready", + metric__release_queue_length: "gpu-ready", + metric__active_incidents: "gpu-ready", + metric__deployment_gate_blocked: "gpu-ready", + metric__failure_share: "gpu-ready", + }, + probabilisticSatellitesSDCPN: { + metric__satellites_in_orbit: "gpu-ready", + metric__debris: "gpu-ready", + metric__average_orbital_radius: "gpu-ready", + metric__average_orbital_speed: "gpu-ready", + }, + sirModel: { metric__infected_fraction: "gpu-ready" }, + cafeQueue: {}, + dronePatrol: {}, + supplyChainWithDisruption: { + metric_service_level: "gpu-ready", + metric_customer_pressure: "gpu-ready", + metric_stock_position: "gpu-ready", + metric_inbound_pipeline: "gpu-ready", + metric_average_inbound_risk: "gpu-ready", + metric_factory_available: "gpu-ready", + metric_scrap_rate: "gpu-ready", + metric_supplier_outages: "gpu-ready", + metric_average_order_age: "cpu-only", + }, + supplyChainProfit: { + metric_service_level: "gpu-ready", + metric_profit: "gpu-ready", + }, + vaccinationCampaign: { + metric__total_cost: "gpu-ready", + metric__infected: "gpu-ready", + metric__attack_rate: "gpu-ready", + }, + }); + + const production = analyze( + allExamples.productionMachines.petriNetDefinition, + ); + const productionRows = production.items.filter( + (item) => item.kind === "metric", + ); + for (const row of productionRows) { + if (row.itemId === "metric__average_machine_damage") { + expect(row.detail).toMatch(/Cannot be translated to WGSL: .*concat/); + } else { + expect(row.detail, row.itemId).toBe(production.shaderFailure); + } + expect(row.hirNodeCount).toBeGreaterThan(0); + } + const orderAge = analyze( + allExamples.supplyChainWithDisruption.petriNetDefinition, + ).items.find((item) => item.itemId === "metric_average_order_age"); + expect(orderAge?.detail).toMatch(/Cannot be translated to WGSL: .*concat/); + }); + + it("compiles the shader with the experiment's metrics", () => { + // Without specs no metric is emitted; with SIR's own metric as an + // expression spec the sample block is in the WGSL, so `wgsl` and + // `shaderFailure` cover metric emission and not only the net's code. + const sdcpn = sirModel.petriNetDefinition; + expect(analyze(sdcpn).wgsl).not.toContain("let v0: f32"); + + const { artifacts } = compileHirArtifacts(sdcpn, undefined, { + includeHir: true, + }); + const report = analyzeCompilation({ + sdcpn, + artifacts, + metricSpecs: [modelMetricSpec(sdcpn, "metric__infected_fraction")], + }); + + expect(report.gpuReady).toBe(true); + expect(report.metricFailure).toBeNull(); + expect(report.wgsl).toContain("let v0: f32 = select("); + }); + it("reports metric shapes the GPU histogram cannot serve", () => { const sdcpn = sirModel.petriNetDefinition; const { artifacts } = compileHirArtifacts(sdcpn, undefined, { @@ -286,8 +419,37 @@ describe("analyzeCompilation", () => { }, ], }); - expect(withFiringCount.metricFailure).not.toBeNull(); + expect(withFiringCount.metricFailure).toMatch(/transition firings/); expect(withFiringCount.gpuReady).toBe(false); + + const infectedFraction = modelMetricSpec( + sdcpn, + "metric__infected_fraction", + ); + const { hir: _stripped, ...artifactWithoutHir } = infectedFraction.artifact; + const withoutHir = analyzeCompilation({ + sdcpn, + artifacts, + metricSpecs: [{ ...infectedFraction, artifact: artifactWithoutHir }], + }); + expect(withoutHir.metricFailure).toMatch(/HIR tree/); + expect(withoutHir.gpuReady).toBe(false); + }); + + it("names the construct that keeps an expression metric on the CPU", () => { + const sdcpn = allExamples.productionMachines.petriNetDefinition; + const { artifacts } = compileHirArtifacts(sdcpn, undefined, { + includeHir: true, + }); + const report = analyzeCompilation({ + sdcpn, + artifacts, + metricSpecs: [modelMetricSpec(sdcpn, "metric__average_machine_damage")], + }); + + expect(report.metricFailure).toMatch( + /Metric "Average machine damage" cannot be translated to WGSL: .*concat.*\.$/, + ); }); it("does not run the metric gate when no metrics are given", () => { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts index b1492c249a3..512535846e0 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.ts @@ -9,11 +9,14 @@ * 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. + * 3. `toGpuMetricSpecs` — refuses metrics the shader cannot compute: transition + * firings, time aggregations, and expression bodies `tryTranslateMetric` + * cannot emit. * - * 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. + * A user hitting gate 2 or 3 would otherwise see one fallback sentence with no + * way to find out which transition or metric caused it. This report attributes + * each failure to an item so the UI can point at it: one row per condition, + * kernel, dynamics body and model metric. * * It is deliberately read-only and device-free: it answers "would this compile" * without acquiring a GPU, so it can run while editing. @@ -24,15 +27,17 @@ import { assessGpuEligibility } from "./eligibility"; import { toGpuMetricSpecs } from "./gpu-metric-frames"; import { hirFromArtifacts } from "./hir-from-artifacts"; import { tryTranslateKernel } from "./try-translate-kernel"; +import { tryTranslateMetric } from "./try-translate-metric"; 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"; +import type { GpuMetricSpecsResult } from "./gpu-metric-frames"; /** What kind of user code an item carries. */ -export type CompilationItemKind = "lambda" | "kernel" | "dynamics"; +export type CompilationItemKind = "lambda" | "kernel" | "dynamics" | "metric"; export type CompilationItemStatus = /** Lowered to HIR and emittable as WGSL. */ @@ -51,7 +56,10 @@ export type CompilationItemStatus = | "disabled"; export type CompilationItemReport = { - /** Place, transition or differential-equation id, for selecting the item. */ + /** + * Place, transition or differential-equation id, for selecting the item; a + * metric row carries the metric's id, which is not a canvas item. + */ itemId: string; itemName: string; kind: CompilationItemKind; @@ -113,7 +121,11 @@ export type AnalyzeCompilationInput = { * 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. */ + /** + * Metric specs an experiment would run. Omit to skip the metric gate; the + * accepted metrics are compiled into the shader, so `wgsl` and + * `shaderFailure` cover their emission and bin sizing. + */ metricSpecs?: readonly MonteCarloMetricSpec[]; dt?: number; }; @@ -138,6 +150,15 @@ export function analyzeCompilation({ ); const netHir = hirFromArtifacts(sdcpn, artifacts, extensions); const eligibility = assessGpuEligibility(sdcpn); + const gpuMetrics: GpuMetricSpecsResult = + metricSpecs === undefined + ? { ok: true, metrics: [] } + : toGpuMetricSpecs(metricSpecs, { + sdcpn, + extensions, + parameterValues: resolvedParameterValues, + }); + const metricFailure = gpuMetrics.ok ? null : gpuMetrics.reason; let shaderFailure: string | null = null; let wgsl: string | null = null; @@ -156,7 +177,7 @@ export function analyzeCompilation({ dt, // Only affects the emitted loop bound, not whether emission succeeds. framesPerDispatch: 64, - metrics: [], + metrics: gpuMetrics.ok ? gpuMetrics.metrics : [], odeMethod: "rk4", }); if (compiled.ok) { @@ -166,14 +187,6 @@ export function analyzeCompilation({ } } - 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`. @@ -290,6 +303,45 @@ export function analyzeCompilation({ }); } + // The model's own metrics, whether or not an experiment measures them: the + // panel shows a net being edited, and an author deciding how to write a + // metric wants to know before creating an experiment. + for (const metric of sdcpn.metrics ?? []) { + if (metric.code.trim() === "") { + continue; + } + const hir = artifacts.metrics[metric.id]?.hir; + const translation = + hir === undefined + ? null + : tryTranslateMetric({ + sdcpn, + hir, + extensions, + parameterValues: resolvedParameterValues, + }); + items.push({ + itemId: metric.id, + itemName: metric.name, + kind: "metric", + status: + hir === undefined + ? "no-hir" + : // As for kernels: a failed translation is a tested negative and stays + // `cpu-only` even when the net was refused before emission ran. + 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, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index f188d4daf2b..021348e05a4 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -12,6 +12,7 @@ * cleanly, because by then the experiment is already registered and showing as * running. */ +import { resolveNetParameterValues } from "../parameter-values"; import { appendMetricFrames, createEmptyMetricsState, @@ -172,7 +173,15 @@ const initialCount = (marking: InitialMarking[string] | undefined): number => export async function createGpuMonteCarloExperiment( config: CreateGpuMonteCarloExperimentConfig, ): Promise { - const gpuMetrics = toGpuMetricSpecs(config.metricSpecs); + const gpuMetrics = toGpuMetricSpecs(config.metricSpecs, { + sdcpn: config.sdcpn, + extensions: config.extensions, + parameterValues: resolveNetParameterValues( + config.sdcpn.parameters, + config.parameterValues, + config.extensions?.parameters ?? true, + ), + }); if (!gpuMetrics.ok) { return { supported: false, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts new file mode 100644 index 00000000000..4ee0943facc --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; + +import { productionMachines } from "../examples/production-with-machine-failure"; +import { sirModel } from "../examples/sir-model"; +import { compileHirArtifacts } from "../hir"; +import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames"; +import { decodeHistogramFrames } from "./runner/histogram-frames"; + +import type { + MonteCarloExpressionMetricSpec, + MonteCarloMetricSpec, +} from "../simulation/monte-carlo/metrics"; +import type { SDCPN } from "../types/sdcpn"; +import type { GpuHistogramFrame } from "./runner"; + +const sir = sirModel.petriNetDefinition; +const production = productionMachines.petriNetDefinition; + +/** One of the net's model metrics as the expression spec an experiment sends. */ +const modelMetricSpec = ( + sdcpn: SDCPN, + metricId: string, + overrides: Partial = {}, +): MonteCarloExpressionMetricSpec => { + const metric = sdcpn.metrics?.find((candidate) => candidate.id === metricId); + const artifact = compileHirArtifacts(sdcpn, undefined, { includeHir: true }) + .artifacts.metrics[metricId]; + if (metric === undefined || artifact === undefined) { + throw new Error(`metric ${metricId} is not on the net or did not compile`); + } + return { + kind: "expression", + id: metric.id, + label: metric.name, + code: metric.code, + artifact, + ...overrides, + }; +}; + +const susceptibleCount: MonteCarloMetricSpec = { + kind: "placeTokenCountMean", + id: "susceptible", + label: "Susceptible", + placeId: sir.places[0]!.id, +}; + +const infectionFirings: MonteCarloMetricSpec = { + kind: "transitionFiringCount", + id: "infections", + label: "Infections", + transitionId: sir.transitions[0]!.id, +}; + +describe("toGpuMetricSpecs", () => { + it("accepts a place count as an integer metric", () => { + expect(toGpuMetricSpecs([susceptibleCount], { sdcpn: sir })).toStrictEqual({ + ok: true, + metrics: [ + { + id: "susceptible", + integer: true, + sample: { kind: "placeCount", placeId: sir.places[0]!.id }, + }, + ], + }); + }); + + it("accepts a translatable expression metric, classified by its return type", () => { + // SIR's Infected Fraction divides two counts, so its samples are real. + const spec = modelMetricSpec(sir, "metric__infected_fraction"); + + const result = toGpuMetricSpecs([spec], { sdcpn: sir }); + + expect(result).toStrictEqual({ + ok: true, + metrics: [ + { + id: "metric__infected_fraction", + integer: false, + sample: { kind: "expression", hir: spec.artifact.hir }, + }, + ], + }); + }); + + it("refuses an expression the shader cannot translate and names the construct", () => { + const result = toGpuMetricSpecs( + [modelMetricSpec(production, "metric__average_machine_damage")], + { sdcpn: production }, + ); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok ? "" : result.reason).toMatch( + /^Metric "Average machine damage" cannot be translated to WGSL: `\.concat` joins the tokens of two places, which the shader reads one place at a time\.$/, + ); + }); + + it("refuses an expression compiled without its HIR tree", () => { + const spec = modelMetricSpec(sir, "metric__infected_fraction"); + const { hir: _stripped, ...artifact } = spec.artifact; + + const result = toGpuMetricSpecs([{ ...spec, artifact }], { sdcpn: sir }); + + expect(result).toStrictEqual({ + ok: false, + reason: + 'Metric "Infected Fraction" was compiled without its HIR tree, which the GPU shader is generated from; compile with includeHir.', + }); + }); + + it("refuses transition firings", () => { + expect(toGpuMetricSpecs([infectionFirings], { sdcpn: sir })).toStrictEqual({ + ok: false, + reason: + 'The GPU backend cannot measure transition firings; metric "Infections" counts them.', + }); + }); + + it("refuses a time aggregation before trying to translate", () => { + const result = toGpuMetricSpecs( + [ + modelMetricSpec(sir, "metric__infected_fraction", { + aggregateTime: "max", + }), + ], + { sdcpn: sir }, + ); + + expect(result).toStrictEqual({ + ok: false, + reason: + 'The GPU backend does not aggregate metrics over time yet; metric "Infected Fraction" uses a time aggregation.', + }); + }); + + it("stops at the first refusal", () => { + const result = toGpuMetricSpecs( + [ + susceptibleCount, + infectionFirings, + modelMetricSpec(production, "metric__average_machine_damage"), + ], + { sdcpn: sir }, + ); + + expect(result.ok ? "" : result.reason).toMatch(/transition firings/); + }); +}); + +describe("toGpuMetricFrames", () => { + it("carries a real window's centre labels and bin extent into a distribution frame", () => { + // Four bins of width 0.25 over [0, 1): the decoder labels each by its + // centre and the frame keeps that labelling and the half-stride reach. + const [histogram] = decodeHistogramFrames({ + data: Uint32Array.from([1, 0, 2, 1]), + firstFrame: 3, + frameCount: 1, + metricIds: ["metric__infected_fraction"], + histogramBins: 4, + windows: [{ lo: 0, stride: 0.25, integer: false }], + }); + + const frames = toGpuMetricFrames( + [histogram!], + [ + modelMetricSpec(sir, "metric__infected_fraction", { + runOutput: { type: "distribution" }, + }), + ], + 0.5, + ); + + expect(frames).toStrictEqual([ + { + metricId: "metric__infected_fraction", + label: "Infected Fraction", + outputType: "distribution", + frameNumber: 3, + time: 1.5, + value: null, + frameValue: null, + timeValue: null, + bins: [ + [0.125, 1], + [0.625, 2], + [0.875, 1], + ], + binExtent: { below: 0.125, above: 0.125 }, + runSampleCount: 4, + timeSampleCount: 4, + }, + ]); + }); + + it("reduces a scalar frame's run aggregate from integer bins", () => { + const histogram: GpuHistogramFrame = { + frameNumber: 2, + metricId: "susceptible", + bins: [ + [3, 2], + [5, 1], + ], + binExtent: { below: 0.5, above: 0.5 }, + sampleCount: 3, + }; + + const frames = toGpuMetricFrames([histogram], [susceptibleCount], 0.1); + + expect(frames).toStrictEqual([ + { + metricId: "susceptible", + label: "Susceptible", + outputType: "scalar", + frameNumber: 2, + time: 0.2, + value: 11 / 3, + frameValue: 11 / 3, + timeValue: null, + runSampleCount: 3, + timeSampleCount: 3, + runAggregate: { count: 3, sum: 11, min: 3, max: 5, last: 5 }, + aggregateRuns: "mean", + aggregateTime: "none", + }, + ]); + }); + + it("maps an expression spec's scalar frame and drops histograms no served spec names", () => { + const histogram = (metricId: string): GpuHistogramFrame => ({ + frameNumber: 0, + metricId, + bins: [[1, 4]], + binExtent: { below: 0.5, above: 0.5 }, + sampleCount: 4, + }); + + const frames = toGpuMetricFrames( + [ + histogram("metric__infected_fraction"), + histogram("infections"), + histogram("unknown"), + ], + [ + modelMetricSpec(sir, "metric__infected_fraction", { + aggregateRuns: "sum", + }), + infectionFirings, + ], + 0.1, + ); + + expect(frames.map((frame) => frame.metricId)).toStrictEqual([ + "metric__infected_fraction", + ]); + expect(frames[0]).toMatchObject({ + outputType: "scalar", + value: 4, + aggregateRuns: "sum", + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts index e661172d38d..7b23badcdeb 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts @@ -1,17 +1,48 @@ /** * Translating between metric specs and the GPU's on-device histograms. * - * Shared by the compilation report and the experiment handle - * (`gpu-experiment-handle.ts`) so a spec accepted by one is accepted by the - * other, and both produce byte-identical frames. + * Shared by the compilation report, the editor's GPU switch and the experiment + * handle (`gpu-experiment-handle.ts`) so a spec accepted by one is accepted by + * the other, and both produce byte-identical frames. + * + * What the shader serves: place-token-count metrics, and expression metrics + * whose body `tryTranslateMetric` can emit as WGSL — counts, parameters, + * arithmetic, conditionals, `tokens.length` and one place's `tokens.reduce` + * with a numeric or boolean accumulator. A body using `.concat`, indexing a + * token by position, a `string` or `uuid` attribute, a distribution or a + * non-finite constant is refused with the emitter's reason, and so is any + * metric with a time aggregation and every transition-firing metric. Across + * the bundled examples' 30 model metrics, 28 translate; the two `.concat` + * averages stay on the CPU. + * + * Where a GPU frame differs from the CPU's: the shader samples the runs still + * active in a frame, whatever `sampleRuns` asks (the spec is accepted and the + * setting ignored), so on a terminating net late frames weight the + * longest-lived runs. A non-finite sample halts its run on the device and the + * handle fails the experiment after the attempt, where the CPU throws at the + * frame. Scalar aggregates are reduced from bin labels — exact for integer + * metrics at stride 1, quantised to the labels otherwise — and `last` is the + * highest bin label rather than the highest run index's sample. */ +import { tryTranslateMetric } from "./try-translate-metric"; + +import type { PetrinautExtensionSettings } from "../extensions"; import type { MonteCarloMetricSpec, MonteCarloUserDefinedMetricFrame, } from "../simulation/monte-carlo/metrics"; +import type { SDCPN } from "../types/sdcpn"; import type { GpuMetricSpec } from "./compile-net-shader"; import type { GpuHistogramFrame } from "./runner"; +/** The net an expression metric is translated against. */ +export type GpuMetricNet = { + sdcpn: SDCPN; + extensions?: PetrinautExtensionSettings; + /** Resolved parameter values; defaults to the net's declared defaults. */ + parameterValues?: Readonly>; +}; + export type GpuMetricSpecsResult = | { ok: true; metrics: GpuMetricSpec[] } | { ok: false; reason: string }; @@ -19,23 +50,21 @@ export type GpuMetricSpecsResult = /** * 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. + * Accepts place-count metrics and translatable expression metrics; the first + * refusal wins, so the caller falls back to the CPU with one reason rather + * than being shown a different measurement than it asked for. */ export function toGpuMetricSpecs( specs: readonly MonteCarloMetricSpec[], + net: GpuMetricNet, ): GpuMetricSpecsResult { const metrics: GpuMetricSpec[] = []; for (const spec of specs) { - if (spec.kind !== "placeTokenCountMean") { + if (spec.kind === "transitionFiringCount") { return { ok: false, - reason: `The GPU backend can only measure place token counts; metric "${spec.label}" is a ${spec.kind} metric instead.`, + reason: `The GPU backend cannot measure transition firings; metric "${spec.label}" counts them.`, }; } if (spec.aggregateTime !== undefined && spec.aggregateTime !== "none") { @@ -46,16 +75,50 @@ export function toGpuMetricSpecs( reason: `The GPU backend does not aggregate metrics over time yet; metric "${spec.label}" uses a time aggregation.`, }; } + if (spec.kind === "placeTokenCountMean") { + metrics.push({ + id: spec.id, + integer: true, + sample: { kind: "placeCount", placeId: spec.placeId }, + }); + continue; + } + + const hir = spec.artifact.hir; + if (hir === undefined) { + return { + ok: false, + reason: `Metric "${spec.label}" was compiled without its HIR tree, which the GPU shader is generated from; compile with includeHir.`, + }; + } + const translation = tryTranslateMetric({ + sdcpn: net.sdcpn, + hir, + extensions: net.extensions, + parameterValues: net.parameterValues, + }); + if (!translation.translatable) { + return { + ok: false, + reason: `Metric "${spec.label}" cannot be translated to WGSL: ${translation.reason}.`, + }; + } metrics.push({ id: spec.id, - integer: true, - sample: { kind: "placeCount", placeId: spec.placeId }, + integer: translation.integer, + sample: { kind: "expression", hir }, }); } return { ok: true, metrics }; } +/** A spec the shader samples: everything but a transition-firing metric. */ +type GpuServedMetricSpec = Exclude< + MonteCarloMetricSpec, + { kind: "transitionFiringCount" } +>; + /** * Rebuilds one metric frame from a GPU histogram. * @@ -65,7 +128,7 @@ export function toGpuMetricSpecs( */ function toMetricFrame( histogram: GpuHistogramFrame, - spec: Extract, + spec: GpuServedMetricSpec, dt: number, ): MonteCarloUserDefinedMetricFrame { const time = histogram.frameNumber * dt; @@ -153,7 +216,7 @@ export function toGpuMetricFrames( ): MonteCarloUserDefinedMetricFrame[] { const specById = new Map( specs.flatMap((spec) => - spec.kind === "placeTokenCountMean" ? [[spec.id, spec] as const] : [], + spec.kind === "transitionFiringCount" ? [] : [[spec.id, spec] as const], ), ); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index 043add572b0..23041cbb78e 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -1391,9 +1391,10 @@ describe("ExperimentsProvider", () => { }); 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. + // The constant metric translates to WGSL, so the GPU backend gets as far as + // the net itself, which has nothing to simulate, and declines it. 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, { @@ -1430,10 +1431,10 @@ describe("ExperimentsProvider", () => { 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. + // The net, 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, + /no transitions/i, ); // It still ran, on the CPU worker. expect(worker.sent.map((message) => message.type)).toEqual([ 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 index 752f1ac21f6..c4ef10e1b88 100644 --- 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 @@ -150,6 +150,7 @@ const KIND_LABEL = { lambda: "condition", kernel: "kernel", dynamics: "dynamics", + metric: "metric", } as const; const STATUS_LABEL = { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts index 56e8acf9679..1f4a6d4d337 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts @@ -115,7 +115,7 @@ export const useGpuAvailability = ({ } if (histogramSpecs.length > 0) { - const gpuMetrics = toGpuMetricSpecs(histogramSpecs); + const gpuMetrics = toGpuMetricSpecs(histogramSpecs, { sdcpn, extensions }); if (!gpuMetrics.ok) { return { available: false, reason: gpuMetrics.reason, pending: false }; } From 2d314a206adcc958295ff1284b9b71a06ecf02de Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 02:18:42 +0200 Subject: [PATCH 06/21] Let the editor's GPU switch follow the compilation report for expression metrics --- .../experiment-sdcpn-with-metrics.ts | 19 ++ .../src/react/experiments/provider.tsx | 2 +- .../experiments/provider/create-experiment.ts | 15 -- .../ui/dev/gpu-parity/gpu-parity.stories.tsx | 103 ++++++++-- .../SimulateView/metrics/metric-form.tsx | 19 +- .../create-optimization-drawer.test.tsx | 180 ++++++++++++++---- .../create-optimization-drawer.tsx | 95 +++++---- .../view-optimization-drawer.test.tsx | 2 +- .../shared/compute-backend-badge.tsx | 2 +- .../shared/use-gpu-availability.ts | 105 ++++++---- 10 files changed, 389 insertions(+), 153 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/react/experiments/experiment-sdcpn-with-metrics.ts diff --git a/libs/@hashintel/petrinaut/src/react/experiments/experiment-sdcpn-with-metrics.ts b/libs/@hashintel/petrinaut/src/react/experiments/experiment-sdcpn-with-metrics.ts new file mode 100644 index 00000000000..1eaaf1f5f9d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/experiments/experiment-sdcpn-with-metrics.ts @@ -0,0 +1,19 @@ +import type { ExperimentMetricSpecInput } from "./context"; +import type { SDCPN } from "@hashintel/petrinaut-core"; + +/** + * The net an experiment compiles and runs: its metrics replaced by the + * experiment's expression metrics, so they compile alongside the model's user + * code in the language worker. The experiments provider builds the run's + * request from it and the editor's GPU switch analyses the same net, so the + * two cannot disagree about which metrics the shader is asked to compute. + */ +export const experimentSdcpnWithMetrics = ( + sdcpn: SDCPN, + metricSpecs: readonly ExperimentMetricSpecInput[], +): SDCPN => ({ + ...sdcpn, + metrics: metricSpecs + .filter((spec) => spec.kind === "expression") + .map((spec) => ({ id: spec.id, name: spec.label, code: spec.code })), +}); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index b471d822abf..6ac82eafd5c 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -40,13 +40,13 @@ import { isExperimentActive, isTerminalExperimentStatus, } from "./context"; +import { experimentSdcpnWithMetrics } from "./experiment-sdcpn-with-metrics"; import { assertExperimentInput, buildSweepAxes, compileExperimentScenario, createExperimentRequestBuilder, experimentBackendRegistrations, - experimentSdcpnWithMetrics, newExperimentRecord, } from "./provider/create-experiment"; import { diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts index d924c26362a..f10132a1769 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts @@ -384,21 +384,6 @@ export const newExperimentRecord = ({ sweep: axes.length > 0 ? idleSweepState(axes) : null, }); -/** - * The net the experiment compiles and runs: its metrics replaced by the - * experiment's expression metrics, so they compile alongside the model's user - * code in the language worker. - */ -export const experimentSdcpnWithMetrics = ( - sdcpn: SDCPN, - metricSpecs: CreateExperimentInput["metricSpecs"], -): SDCPN => ({ - ...sdcpn, - metrics: metricSpecs - .filter((spec) => spec.kind === "expression") - .map((spec) => ({ id: spec.id, name: spec.label, code: spec.code })), -}); - /** * Builds the backend request for the experiment. HIR artifacts are compiled * per `needsHirTrees` value and memoized: the trees roughly triple the diff --git a/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx b/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx index 30cf449181c..2d788c243c2 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx @@ -15,7 +15,9 @@ import { useEffect, useState } from "react"; import { DEFAULT_PETRINAUT_EXTENSIONS, + getOwn, type InitialMarking, + type MonteCarloMetricSpec, type MonteCarloUserDefinedMetricFrame, } from "@hashintel/petrinaut-core"; import { @@ -41,13 +43,22 @@ const meta = { export default meta; +/** One metric both backends measure, with a label for the report. */ +type ParityMeasure = + /** A place's token count, the shader's `counts[]` sample. */ + | { kind: "placeCount"; placeId: string; label: string } + /** + * One of the model's expression metrics, compiled with its HIR so the shader + * samples the same body the CPU evaluates. + */ + | { kind: "expression"; metricId: string; label: string }; + type ParityModel = { id: string; title: string; sdcpn: (typeof sirModel)["petriNetDefinition"]; initialMarking: InitialMarking; - /** Place to measure, with a label for the report. */ - measure: { placeId: string; label: string }; + measures: ParityMeasure[]; }; const MODELS: ParityModel[] = [ @@ -60,7 +71,14 @@ const MODELS: ParityModel[] = [ place__infected: 10, place__recovered: 0, }, - measure: { placeId: "place__infected", label: "Infected" }, + measures: [ + { kind: "placeCount", placeId: "place__infected", label: "Infected" }, + { + kind: "expression", + metricId: "metric__infected_fraction", + label: "Infected Fraction", + }, + ], }, { id: "cafe-queue", @@ -72,7 +90,9 @@ const MODELS: ParityModel[] = [ place__serving: 0, place__served: 0, }, - measure: { placeId: "place__served", label: "Served" }, + measures: [ + { kind: "placeCount", placeId: "place__served", label: "Served" }, + ], }, { id: "drone-patrol", @@ -85,7 +105,9 @@ const MODELS: ParityModel[] = [ })), place__airborne: [], }, - measure: { placeId: "place__airborne", label: "Airborne" }, + measures: [ + { kind: "placeCount", placeId: "place__airborne", label: "Airborne" }, + ], }, ]; @@ -95,6 +117,7 @@ type BackendReport = { }; type ParityReport = { + /** ` · `, one row per measured metric. */ model: string; runCount: number; frames: number; @@ -200,14 +223,52 @@ async function runBackend( return { frames, ms }; } -function compare( +/** The spec both backends run for a measure; the model metric's id doubles as the spec's. */ +function measureSpec( model: ParityModel, + measure: ParityMeasure, + artifacts: ReturnType["artifacts"], +): MonteCarloMetricSpec { + if (measure.kind === "placeCount") { + return { + kind: "placeTokenCountMean", + id: `parity-${measure.placeId}`, + label: measure.label, + placeId: measure.placeId, + runOutput: { type: "distribution", binning: "exact" }, + }; + } + const metric = model.sdcpn.metrics?.find(({ id }) => id === measure.metricId); + const artifact = getOwn(artifacts.metrics, measure.metricId); + if (metric === undefined || artifact === undefined) { + throw new Error( + `model metric ${measure.metricId} is missing from ${model.title} or did not compile`, + ); + } + return { + kind: "expression", + id: measure.metricId, + label: measure.label, + code: metric.code, + artifact, + sampleRuns: "all", + runOutput: { type: "distribution", binning: "exact" }, + }; +} + +function compare( + rowLabel: string, + metricId: string, runCount: number, cpu: BackendReport, gpu: BackendReport, ): ParityReport { const byFrame = (frames: MonteCarloUserDefinedMetricFrame[]) => - new Map(frames.map((frame) => [frame.frameNumber, frame])); + new Map( + frames + .filter((frame) => frame.metricId === metricId) + .map((frame) => [frame.frameNumber, frame]), + ); const cpuFrames = byFrame(cpu.frames); const gpuFrames = byFrame(gpu.frames); const common = [...cpuFrames.keys()] @@ -231,7 +292,7 @@ function compare( } const lastCommon = common.at(-1); return { - model: model.title, + model: rowLabel, runCount, frames: common.length, cpuMs: Math.round(cpu.ms), @@ -295,15 +356,9 @@ const ParityStory = ({ dt, maxTime, runCount, - metricSpecs: [ - { - kind: "placeTokenCountMean", - id: "parity", - label: model.measure.label, - placeId: model.measure.placeId, - runOutput: { type: "distribution", binning: "exact" }, - }, - ], + metricSpecs: model.measures.map((measure) => + measureSpec(model, measure, artifacts), + ), hirArtifacts: artifacts, }; const cpuBackend = createWorkerPoolExperimentBackend({ @@ -320,7 +375,17 @@ const ParityStory = ({ } else if ("error" in gpu) { results.push({ model: model.title, error: `GPU: ${gpu.error}` }); } else { - results.push(compare(model, runCount, cpu, gpu)); + for (const spec of request.metricSpecs) { + results.push( + compare( + `${model.title} · ${spec.label}`, + spec.id, + runCount, + cpu, + gpu, + ), + ); + } } setRows([...results]); } @@ -343,7 +408,7 @@ const ParityStory = ({ {[ - "model", + "measure", "frames", "cpu ms", "gpu ms", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx index 7730889332b..fe2804b2d07 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx @@ -9,6 +9,8 @@ import { Section, SectionList } from "../../../../../components/section"; import { CodeEditor } from "../../../../../monaco/code-editor"; import { getMetricDocumentUri } from "../../../../../monaco/editor-paths"; +import type { ExperimentComputeBackend } from "../../../../../../react/experiments/context"; + // -- Form state --------------------------------------------------------------- export interface MetricFormState { @@ -63,9 +65,22 @@ export interface UseMetricFormOptions { validateOnSubmit?: (value: MetricFormState) => Promise; } +/** + * What the submitting control hands to `handleSubmit(meta)`, for values the + * submit callback cannot close over. The metric drawers pass none. + */ +export interface MetricFormSubmitMeta { + /** Backend an optimization's Run control chose for the objective. */ + computeBackend?: ExperimentComputeBackend; +} + +const emptySubmitMeta: MetricFormSubmitMeta = {}; + export interface MetricFormSubmitContext { /** Reset the form to its default values. */ reset: () => void; + /** The meta `handleSubmit` was called with; empty when it was called bare. */ + meta: MetricFormSubmitMeta; } export function useMetricForm( @@ -79,9 +94,11 @@ export function useMetricForm( const existingNames = options.existingMetricNames ?? new Set(); return useForm({ defaultValues, - onSubmit: async ({ value, formApi }) => + onSubmitMeta: emptySubmitMeta, + onSubmit: async ({ value, formApi, meta }) => await onSubmit(value, { reset: () => formApi.reset(), + meta, }), validators: { onChange: ({ value }) => diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx index 442a02e6178..23354f0d832 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx @@ -19,6 +19,8 @@ import { getConstraintDocumentUri, synthesizeAdHocOptimization, } from "@hashintel/petrinaut-core"; +import { dronePatrol } from "@hashintel/petrinaut-core/examples"; +import { compileHirArtifacts } from "@hashintel/petrinaut-core/hir"; import { LanguageClientContext } from "../../../../../../react/lsp/context"; import { PetrinautOptimizationContext } from "../../../../../../react/optimization-context"; @@ -331,11 +333,13 @@ afterEach(() => { vi.clearAllMocks(); }); +/** + * A language client that compiles for real, so the GPU analysis sees the same + * HIR the app would: a stub returning empty artifacts would read every + * objective as uncompiled, which the analysis reports as unavailable for the + * wrong reason. + */ function makeSuccessfulLanguageClient(): LanguageClientContextValue { - type HirArtifacts = Awaited< - ReturnType - >["artifacts"]; - return { diagnosticsByUri: new Map(), totalDiagnosticsCount: 0, @@ -378,20 +382,8 @@ function makeSuccessfulLanguageClient(): LanguageClientContextValue { }), ), requestFormatExpression: vi.fn(() => Promise.resolve(null)), - requestHirArtifacts: vi.fn((sdcpn: SDCPN) => - Promise.resolve({ - artifacts: { - version: 4 as const, - fingerprint: "0000000000000000", - dynamics: {}, - lambdas: {}, - kernels: {}, - metrics: Object.fromEntries( - (sdcpn.metrics ?? []).map((metric) => [metric.id, {}]), - ) as HirArtifacts["metrics"], - }, - failures: [], - }), + requestHirArtifacts: vi.fn((sdcpn: SDCPN, extensions, options) => + Promise.resolve(compileHirArtifacts(sdcpn, extensions, options)), ), initializeScenarioSession: vi.fn(), updateScenarioSession: vi.fn(), @@ -1292,14 +1284,58 @@ describe("CreateOptimizationDrawer", () => { }); }); +/** + * Drone Patrol with two model metrics: one the shader translates and one over + * `.concat`, which it refuses. Its two typed places share the Drone colour. + */ +const dronePatrolSdcpnContextValue: SDCPNContextValue = { + ...sirSdcpnContextValue, + petriNetId: "drone-patrol-test-net", + title: dronePatrol.title, + petriNetDefinition: { + ...dronePatrol.petriNetDefinition, + metrics: [ + { + id: "metric__fleet_size", + name: "Fleet size", + code: "return state.places.Hangar.tokens.concat(state.places.Airborne.tokens).length;", + }, + { + id: "metric__airborne_count", + name: "Airborne drones", + code: "return state.places.Airborne.count;", + }, + ], + }, +}; + describe("CreateOptimizationDrawer backend choice", () => { - const openWithWebGpu = (props: TestProviderProps) => { + const openWithWebGpu = ({ + scenarioId = "scenario__seasonal_flu", + ...props + }: TestProviderProps & { scenarioId?: string }) => { // `isWebGpuAvailable()` only reads `navigator.gpu`, so a bare object is // enough — and spreading the real Navigator would drop its prototype. vi.stubGlobal("navigator", { gpu: {} }); - openConfiguration(props); + render(); + fireEvent.change( + screen.getByRole("combobox", { name: "Select a scenario" }), + { target: { value: scenarioId } }, + ); + expect(screen.getByText("Parameters")).toBeTruthy(); }; + /** `pending` until the analysis lands, then `available` or `unavailable`. */ + const backendState = (): string | null => + document + .querySelector("[data-backend-state]") + ?.getAttribute("data-backend-state") ?? null; + + const backendSwitch = (): HTMLInputElement => + document.querySelector( + "[data-backend-state] input[type='checkbox']", + )!; + it("offers no backend cell while WebGPU is off in settings", () => { openWithWebGpu({ connectedSource: true, webGpuEnabled: false }); @@ -1313,14 +1349,23 @@ describe("CreateOptimizationDrawer backend choice", () => { expect(document.querySelector("[data-backend-state]")).toBeNull(); }); - it("offers the cell for a connected optimizer and rules the GPU out for the expression objective", async () => { + it("offers the GPU for a translatable expression objective and submits it when switched on", async () => { + const createOptimization = vi.fn( + async ( + _input: PetrinautOptimizationInput, + _options?: CreateOptimizationOptions, + ) => "optimization-gpu", + ); openWithWebGpu({ connectedSource: true, webGpuEnabled: true, languageClient: makeSuccessfulLanguageClient(), + createOptimization, }); expect(screen.getByText("Backend")).toBeTruthy(); + // SIR's "Infected Fraction" reads three counts, a sum and a conditional + // division: every construct the shader translates. const savedMetric = sirSdcpnContextValue.petriNetDefinition.metrics?.[0]; fireEvent.change( screen.getByRole("combobox", { name: "Select a metric" }), @@ -1330,17 +1375,86 @@ describe("CreateOptimizationDrawer backend choice", () => { ); await waitFor(() => { - expect( - document - .querySelector("[data-backend-state]") - ?.getAttribute("data-backend-state"), - ).toBe("unavailable"); + expect(backendState()).toBe("available"); + }); + expect(backendSwitch().disabled).toBe(false); + + fireEvent.click(backendSwitch()); + await waitFor(() => { + expect(backendSwitch().checked).toBe(true); + }); + fireEvent.click( + screen.getByRole("checkbox", { name: "Optimize infected_ratio" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Maximize" })); + fireEvent.click(screen.getByRole("button", { name: /Run/ })); + + await waitFor(() => expect(createOptimization).toHaveBeenCalledOnce()); + expect(createOptimization.mock.calls[0]![1]).toEqual({ + computeBackend: "webgpu", + parallelism: 1, + }); + }); + + it("rules the GPU out for a `.concat` objective while offering it for a translatable one on the same net", async () => { + // Drone Patrol has two typed places sharing a colour, so `.concat` over + // their tokens typechecks on the CPU; the shader reads one place at a time + // and refuses it. The count metric on the same net proves the refusal is + // the metric's, not the net's. + openWithWebGpu({ + connectedSource: true, + webGpuEnabled: true, + languageClient: makeSuccessfulLanguageClient(), + sdcpnContextValue: dronePatrolSdcpnContextValue, + scenarioId: "scenario__standard_patrol", + }); + + fireEvent.change( + screen.getByRole("combobox", { name: "Select a metric" }), + { + target: { value: `${MODEL_METRIC_VALUE_PREFIX}metric__airborne_count` }, + }, + ); + await waitFor(() => { + expect(backendState()).toBe("available"); + }); + + fireEvent.change( + screen.getByRole("combobox", { name: "Select a metric" }), + { + target: { value: `${MODEL_METRIC_VALUE_PREFIX}metric__fleet_size` }, + }, + ); + await waitFor(() => { + expect(backendState()).toBe("unavailable"); + }); + expect(backendSwitch().disabled).toBe(true); + }); + + it("keeps the GPU unavailable for a custom objective until it has code", async () => { + openWithWebGpu({ + connectedSource: true, + webGpuEnabled: true, + languageClient: makeSuccessfulLanguageClient(), + }); + + fireEvent.change( + screen.getByRole("combobox", { name: "Select a metric" }), + { target: { value: CUSTOM_METRIC_VALUE } }, + ); + + // An empty body compiles to no artifact, so there is nothing to translate. + await waitFor(() => { + expect(backendState()).toBe("unavailable"); + }); + expect(backendSwitch().disabled).toBe(true); + + fireEvent.change(screen.getByRole("textbox", { name: "Metric code" }), { + target: { value: "return state.places.Infected.count;" }, + }); + await waitFor(() => { + expect(backendState()).toBe("available"); }); - expect( - document.querySelector( - "[data-backend-state] input[type='checkbox']", - )!.disabled, - ).toBe(true); }); it("passes the backend as a creation option", async () => { @@ -1372,8 +1486,8 @@ describe("CreateOptimizationDrawer backend choice", () => { fireEvent.click(screen.getByRole("button", { name: /Run/ })); await waitFor(() => expect(createOptimization).toHaveBeenCalledOnce()); - // The switch never left the CPU side: the objective is an expression - // metric, which the GPU backend cannot compute. + // The switch defaults to the CPU side, so an untouched switch submits the + // CPU even for an objective the GPU could run. expect(createOptimization.mock.calls[0]![1]).toEqual({ computeBackend: "cpu", parallelism: 1, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx index b75a074da6f..becea182646 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx @@ -797,42 +797,6 @@ export const CreateOptimizationDrawer = ({ ); }; - // The objective is an expression metric whichever way it is authored, which - // the GPU backend cannot compute, so the switch stays disabled with that - // reason; the net analysis still runs so the reason names the first - // blocker. The gate reads the metric's kind, not its code, so the custom - // objective counts before any code is typed. - const objectiveMetricForGpu = - metricSource === "saved" - ? selectedSavedMetric - : { id: customMetricId, name: CUSTOM_OBJECTIVE_METRIC_NAME, code: "" }; - const objectiveMetricSpecs: ExperimentMetricSpecInput[] | null = - objectiveMetricForGpu - ? [ - { - kind: "expression", - id: objectiveMetricForGpu.id, - label: objectiveMetricForGpu.name, - code: objectiveMetricForGpu.code, - sampleRuns: "all", - runOutput: { type: "distribution" }, - }, - ] - : null; - const webGpuAvailable = isWebGpuAvailable(); - const gpu = useGpuAvailability({ - enabled: open && backendSelectable && webGpuEnabled && webGpuAvailable, - sdcpn: petriNetDefinition, - extensions, - metricSpecs: objectiveMetricSpecs, - }); - // Derived rather than stored, so a net edited into ineligibility after the - // switch was flipped neither shows as on nor submits a GPU study. - const gpuSelected = gpuRequested && gpu.available; - const computeBackend: ExperimentComputeBackend = gpuSelected - ? "webgpu" - : "cpu"; - const resetConfigurationState = (scenario?: Scenario) => { setName("Optimization"); setDrafts(scenario ? createParameterDrafts(scenario) : {}); @@ -863,6 +827,7 @@ export const CreateOptimizationDrawer = ({ const submitOptimization = async ( metric: Metric, resetMetricForm: () => void, + backend: ExperimentComputeBackend, metricAlreadyValidated = false, ) => { const validationError = @@ -1025,7 +990,10 @@ export const CreateOptimizationDrawer = ({ constraints: manifestConstraints, constraintPolicy, }); - await createOptimization(input, { computeBackend, parallelism }); + await createOptimization(input, { + computeBackend: backend, + parallelism, + }); resetState(); resetMetricForm(); } catch (submitError) { @@ -1048,7 +1016,14 @@ export const CreateOptimizationDrawer = ({ setError(parsedMetric.error.issues[0]?.message ?? "Invalid metric"); return; } - await submitOptimization(parsedMetric.data, context.reset, true); + // Run passes the backend through `handleSubmit(meta)`: it is derived + // below from this form's own code, so the callback cannot close over it. + await submitOptimization( + parsedMetric.data, + context.reset, + context.meta.computeBackend ?? "cpu", + true, + ); }, { validateOnSubmit: async (value) => { @@ -1074,6 +1049,42 @@ export const CreateOptimizationDrawer = ({ customMetricForm.store, (state) => state.values, ); + + // The objective is an expression metric whichever way it is authored; the + // compilation report decides per objective whether it translates to the + // shader. A custom objective without code has no artifact and stays on the + // CPU until it compiles. + const objectiveMetricForGpu = + metricSource === "saved" + ? selectedSavedMetric + : buildMetricFromFormState(customMetricValues, customMetricId); + const objectiveMetricSpecs: ExperimentMetricSpecInput[] | null = + objectiveMetricForGpu + ? [ + { + kind: "expression", + id: objectiveMetricForGpu.id, + label: objectiveMetricForGpu.name, + code: objectiveMetricForGpu.code, + sampleRuns: "all", + runOutput: { type: "distribution" }, + }, + ] + : null; + const webGpuAvailable = isWebGpuAvailable(); + const gpu = useGpuAvailability({ + enabled: open && backendSelectable && webGpuEnabled && webGpuAvailable, + sdcpn: petriNetDefinition, + extensions, + metricSpecs: objectiveMetricSpecs, + }); + // Derived rather than stored, so a net edited into ineligibility after the + // switch was flipped neither shows as on nor submits a GPU study. + const gpuSelected = gpuRequested && gpu.available; + const computeBackend: ExperimentComputeBackend = gpuSelected + ? "webgpu" + : "cpu"; + const customMetricErrors = useStore( customMetricForm.store, (state) => state.errors, @@ -1180,10 +1191,12 @@ export const CreateOptimizationDrawer = ({ const handleSubmit = () => { if (metricSource === "custom") { - void customMetricForm.handleSubmit(); + void customMetricForm.handleSubmit({ computeBackend }); } else if (selectedSavedMetric) { - void submitOptimization(selectedSavedMetric, () => - customMetricForm.reset(), + void submitOptimization( + selectedSavedMetric, + () => customMetricForm.reset(), + computeBackend, ); } }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index 6f34e6619ae..dafb7406d3b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -530,7 +530,7 @@ describe("ViewOptimizationDrawer for a connected study", () => { connected: { ...following, computeBackendFallbackReason: - "the GPU cannot compute expression metrics", + 'Metric "Fleet size" cannot be translated to WGSL: `.concat` joins the tokens of two places, which the shader reads one place at a time.', }, }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx index ce1af54cc65..599db69aa4b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx @@ -34,7 +34,7 @@ const badgeStyle = css({ const describeComputeBackend = (backend: ComputeBackendSummary): string => { if (backend.computeBackend === "webgpu") { - return "Stepped on the GPU through WebGPU. Distributions match the CPU backend statistically; individual trajectories differ (different random generators)."; + return "Stepped on the GPU through WebGPU. Distributions match the CPU backend statistically — real-valued metrics are binned to a calibrated window; individual trajectories differ (different random generators)."; } if (backend.computeBackendFallbackReason !== null) { // The notification that carried this is gone by the time anyone wonders diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts index 1f4a6d4d337..b8626ea6605 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/use-gpu-availability.ts @@ -1,15 +1,17 @@ import { use, useEffect, useState } from "react"; +import { getOwn } from "@hashintel/petrinaut-core"; import { analyzeCompilation, summarizeGpuUnavailability, - toGpuMetricSpecs, } from "@hashintel/petrinaut-core/webgpu"; +import { experimentSdcpnWithMetrics } from "../../../../../../react/experiments/experiment-sdcpn-with-metrics"; import { LanguageClientContext } from "../../../../../../react/lsp/context"; import type { ExperimentMetricSpecInput } from "../../../../../../react/experiments/context"; import type { + HirArtifacts, MonteCarloMetricSpec, PetrinautExtensionSettings, SDCPN, @@ -21,13 +23,43 @@ export type GpuAvailability = { pending: boolean; }; +/** + * Attaches each expression spec's compiled artifact, as the experiment request + * does, so the compilation report can gate the metrics the run would carry. + * Null when a metric has no artifact, with the request builder's own sentence. + */ +const attachMetricArtifacts = ( + specs: readonly ExperimentMetricSpecInput[], + artifacts: HirArtifacts, +): + | { ok: true; specs: MonteCarloMetricSpec[] } + | { ok: false; reason: string } => { + const withArtifacts: MonteCarloMetricSpec[] = []; + for (const spec of specs) { + if (spec.kind !== "expression") { + withArtifacts.push(spec); + continue; + } + const artifact = getOwn(artifacts.metrics, spec.id); + if (!artifact) { + return { ok: false, reason: `Metric "${spec.label}" did not compile.` }; + } + withArtifacts.push({ ...spec, artifact }); + } + return { ok: true, specs: withArtifacts }; +}; + /** * Whether the GPU backend could run a compute request over this net with * these metrics, and the reason when it could not. * - * The net is analysed asynchronously (lowering user code happens in the - * language worker) but the metric gate is evaluated synchronously from the - * specs, so editing a metric updates the answer without another round-trip. + * One asynchronous path: the net the experiment would compile (its metrics + * replaced by the form's expression metrics) is lowered with its HIR trees in + * the language worker, and the compilation report gates the metrics and + * compiles the shader with the accepted ones, so the switch, the Compilation + * panel and the run-time backend selection give the same reason for the same + * metric. The form rebuilds its spec array every render, so the analysis keys + * on the specs' serialised content rather than on the array's identity. */ export const useGpuAvailability = ({ enabled, @@ -41,8 +73,9 @@ export const useGpuAvailability = ({ metricSpecs: readonly ExperimentMetricSpecInput[] | null; }): GpuAvailability => { const { requestHirArtifacts } = use(LanguageClientContext); - const [netReason, setNetReason] = useState(null); + const [reason, setReason] = useState(null); const [pending, setPending] = useState(false); + const specsKey = metricSpecs === null ? null : JSON.stringify(metricSpecs); useEffect(() => { if (!enabled) { @@ -51,23 +84,38 @@ export const useGpuAvailability = ({ let cancelled = false; setPending(true); + const specs = + specsKey === null + ? [] + : (JSON.parse(specsKey) as ExperimentMetricSpecInput[]); + const experimentSdcpn = experimentSdcpnWithMetrics(sdcpn, specs); const analyze = async () => { try { - const { artifacts } = await requestHirArtifacts(sdcpn, extensions, { - includeHir: true, - }); + const { artifacts } = await requestHirArtifacts( + experimentSdcpn, + extensions, + { includeHir: true }, + ); if (cancelled) { return; } - setNetReason( - summarizeGpuUnavailability( - analyzeCompilation({ sdcpn, artifacts, extensions }), - ), + const attached = attachMetricArtifacts(specs, artifacts); + setReason( + attached.ok + ? summarizeGpuUnavailability( + analyzeCompilation({ + sdcpn: experimentSdcpn, + artifacts, + extensions, + metricSpecs: attached.specs, + }), + ) + : attached.reason, ); } catch (caught) { if (!cancelled) { - setNetReason( + setReason( caught instanceof Error ? `The net could not be compiled: ${caught.message}` : "The net could not be compiled.", @@ -85,7 +133,7 @@ export const useGpuAvailability = ({ return () => { cancelled = true; }; - }, [enabled, sdcpn, extensions, requestHirArtifacts]); + }, [enabled, sdcpn, extensions, specsKey, requestHirArtifacts]); if (!enabled) { return { available: false, reason: null, pending: false }; @@ -93,33 +141,8 @@ export const useGpuAvailability = ({ if (pending) { return { available: false, reason: null, pending: true }; } - if (netReason !== null) { - return { available: false, reason: netReason, pending: false }; + if (reason !== null) { + return { available: false, reason, pending: false }; } - - // Expression metrics are computed from full simulation state, which the GPU - // path never materialises on the host, so they rule the backend out before the - // histogram gate is worth consulting. Narrowing as we go also gives - // `toGpuMetricSpecs` the compiled-spec type it wants without a cast: only - // expression specs lack an `artifact`. - const histogramSpecs: MonteCarloMetricSpec[] = []; - for (const spec of metricSpecs ?? []) { - if (spec.kind === "expression") { - return { - available: false, - reason: `Metric "${spec.label}" is an expression metric, which the GPU backend cannot compute. Use place token-count metrics to run on the GPU.`, - pending: false, - }; - } - histogramSpecs.push(spec); - } - - if (histogramSpecs.length > 0) { - const gpuMetrics = toGpuMetricSpecs(histogramSpecs, { sdcpn, extensions }); - if (!gpuMetrics.ok) { - return { available: false, reason: gpuMetrics.reason, pending: false }; - } - } - return { available: true, reason: null, pending: false }; }; From f15c2e38091d547f4fc465b62986004088aa60d0 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 02:32:29 +0200 Subject: [PATCH 07/21] Document GPU expression metrics and the f32 histogram window --- .../petrinaut/docs/compilation-output.md | 10 ++- libs/@hashintel/petrinaut/docs/examples.md | 8 +- libs/@hashintel/petrinaut/docs/experiments.md | 4 +- .../@hashintel/petrinaut/docs/optimization.md | 17 +++-- .../petrinaut/docs/petri-net-extensions.md | 2 +- .../diagrams/gpu-experiment-pipeline.d2 | 4 +- .../content/diagrams/gpu-histogram-sizing.d2 | 4 +- .../content/experiments/backend-selection.mdx | 12 +-- .../content/optimizer/browser-runtime.mdx | 19 ++--- .../content/simulation/gpu-backend.mdx | 17 +++-- .../simulation/gpu-histogram-sizing.mdx | 73 ++++++++++++------- .../simulation/gpu-shader-generation.mdx | 29 ++++---- .../content/simulation/performance.mdx | 11 +-- 13 files changed, 121 insertions(+), 89 deletions(-) diff --git a/libs/@hashintel/petrinaut/docs/compilation-output.md b/libs/@hashintel/petrinaut/docs/compilation-output.md index 070ceae8354..2879efd3ae8 100644 --- a/libs/@hashintel/petrinaut/docs/compilation-output.md +++ b/libs/@hashintel/petrinaut/docs/compilation-output.md @@ -1,6 +1,6 @@ # 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). +The **Compilation** tab explains what Petrinaut's compiler made of your net's code: which conditions, kernels, differential equations and metrics 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. @@ -14,13 +14,13 @@ Under **Settings → Simulation**, switch on **Compilation output**. A **Compila 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). +- **B/run** -- bytes of GPU state one simulation run needs. The backend refuses a run above one megabyte of state, 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. +Structural reasons the net was refused before any code was generated — an unsupported attribute type, an arc consuming more than two typed tokens from one place, a run whose state exceeds the device's memory gate. Each reason names the item; click it to select that item on the canvas. ### Shader emission failed @@ -28,9 +28,11 @@ The net passed the structural checks, but the generator could not turn some expr 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. +A metric reads as **CPU** when the shader cannot translate its body — `.concat` over two places, indexing a token by position, a `string` attribute. A metric is not a node on the canvas, so its row has no detail to open here; the **Run on GPU** switch in the Create Experiment drawer names the metric and the construct on hover when that metric is measured, and the experiment runs on the CPU with the same message. + ### 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: +One row per piece of user code — transition conditions, transition kernels, per-place dynamics, and metrics — with the size of its compiled expression and where it can run: | Label | Meaning | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/libs/@hashintel/petrinaut/docs/examples.md b/libs/@hashintel/petrinaut/docs/examples.md index fd61b9ef5b8..51c2c7b9d4e 100644 --- a/libs/@hashintel/petrinaut/docs/examples.md +++ b/libs/@hashintel/petrinaut/docs/examples.md @@ -31,7 +31,7 @@ The SIR model with two policy levers and a cost account, built as the model to o - **Parameter-driven rates** -- Infection fires at `infection_rate` scaled by `(1 - contact_reduction)` and by `(1 - vaccine_efficacy × vaccination_coverage)`, the share of contacts that land on an unprotected person; Recovery fires at `recovery_rate`. The wave persists while the scaled infection rate exceeds the recovery rate and dies out below it. - **Scenario parameters wired to the initial state and the rates** -- the _Winter wave_ scenario seeds `Vaccinated` from `vaccination_coverage` and overrides both lever parameters, so an optimization or a sweep over the levers changes the initial marking and the rates together. - **An objective with an interior optimum** -- the **Total cost** [metric](simulation.md) charges every case at `case_cost` and each lever at a price quadratic in its intensity (`campaign_cost`, `distancing_cost`), so both levers have diminishing returns against a rising price. Over a 60-day horizon the cost is about 960 near a coverage of 0.45 and a contact reduction of 0.4, against 1,280 to 2,220 in the corners of the domain. -- **GPU-ready modelling** -- untyped places and rates that read only parameters, so an experiment measuring the **Infected** place's token count (**Built-in › Place tokens**) runs on the GPU backend as shipped. The model metric of the same name is an expression, which keeps an experiment on the CPU. +- **GPU-ready modelling** -- untyped places and rates that read only parameters, so an experiment measuring the **Infected** place's token count (**Built-in › Place tokens**) runs on the GPU backend as shipped. The model's expression metrics — **Total cost**, **Infected**, **Attack rate** — compile to the GPU too. - Two further metrics -- **Infected** (the wave's curve, dying out or growing) and **Attack rate** (share of the population infected so far). **Suggested initial state:** pick **Winter wave** and, in the Optimizations tab, minimize **Total cost** over `vaccination_coverage` (0 to 0.9) and `contact_reduction` (0 to 0.8) with a max time of 60: the surface shows a valley along the epidemic threshold and the steps settle around a coverage of 0.45 and a contact reduction of 0.4. To watch a single run instead, press Play and select the **Infected** metric in the timeline. @@ -44,7 +44,7 @@ A small service system: customers arrive, wait, are served by a limited staff po **Demonstrates:** -- **GPU-ready modelling**: no typed tokens, no expression metrics — create an experiment measuring **Waiting** or **Served** and the GPU switch works as shipped. +- **GPU-ready modelling**: no typed tokens — create an experiment measuring **Waiting** or **Served** and the GPU switch works as shipped. - Rate parameters (`arrival_rate`, `begin_rate`, `service_rate`) a sweep can range over: the **Morning Rush** scenario exposes `arrival_rate` and `service_rate` as scenario parameters wired straight to the net's rates, so a two-parameter sweep explores under- and over-staffed regimes. - A conserved staff pool (**FreeStaff** + **Serving** always totals the staff count). @@ -56,7 +56,7 @@ A typed fleet of drones cycling between the hangar and the air: launch, drain ba **Demonstrates:** -- **Typed tokens with capacities**: both places declare `capacity: 16`, which the GPU needs to size its buffers. +- **Typed tokens with capacities**: both places declare `capacity: 16`, which the GPU uses to size its buffers and its metric histogram exactly, without a probe. - **Kernels** writing every attribute of produced tokens (launch altitude sampled from a Gaussian; a recharge on return). - **Continuous dynamics** on airborne drones (battery drains at `drain_rate`). - **Token-reading rates**: launch tempo scales with the candidate drone's battery, and returns become more likely as the battery falls. @@ -172,7 +172,7 @@ An orbital mechanics simulation: satellites are continuously launched into orbit - **Predicate transitions based on geometry** -- "Collision" checks the distance between two satellites and "Crash" checks distance from the planet's surface, routing tokens to the Debris place. - **Arc weight 2** on the "Collision" transition -- it consumes two satellites from the Space place at once to evaluate pairwise proximity. - **Scenarios** -- _Moon Orbit_ (low gravity, gentle arcs) and _Earth Orbit_ (high orbital velocities, frequent launches) preconfigure the gravitational constant, planet radius, and launch parameters. _Pre-deployed Constellation_ defines its initial state [as code](scenarios.md#code-mode-define-as-code), building a ring of satellites with `range(...).map(...)` from two scenario parameters (`number_of_satellites`, `initial_altitude`). Each satellite starts tangentially at circular-orbit speed, so the whole ring stays in orbit from the first frame. -- **[Metrics](simulation.md)** -- satellites in orbit, debris objects, average orbital radius, and average orbital speed. +- **[Metrics](simulation.md)** -- satellites in orbit, debris objects, average orbital radius, and average orbital speed. Its **Average orbital radius** and **Average orbital speed** metrics reduce over the satellites' attributes and compile to the GPU as loops. **Suggested initial state:** no initial tokens needed -- pick a scenario (e.g. _Earth Orbit_) and press Play. The "LaunchSatellite" source transition creates satellites with randomized orbital positions and velocities. Select the Space place and open the visualizer preview to watch the orbits fill up. The velocity for a roughly circular orbit at radius `r` is approximately `sqrt(gravitational_constant / r)`. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 25dad1a506c..c83e76eed97 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -93,12 +93,12 @@ The switch is greyed out when the current model cannot run on the GPU; hover it The GPU backend handles a **subset** of nets, and it tells you when it cannot take one rather than guessing. It needs: -- **token spans that fit the metric histogram.** Metrics are reduced on the device into a histogram whose bins cover a window of counts. The window calibrates itself: a short probe observes each measured place's range, the full run uses that range, and a run that escapes its window is recalibrated and re-run automatically — no refusals or warnings based on absolute counts. Calibration is remembered while an experiment is open, so moving the sweep's sliders does not re-probe what an earlier selection already measured. Only the _span_ is limited: up to three metrics get 1,024 distinct values each (more metrics share the budget), and a wider span is binned at reduced resolution; +- **metric values that fit the histogram.** Metrics are reduced on the device into a histogram whose bins cover a window of values — exact integers for counts, a calibrated range for real-valued metrics, whose bin width the heatmap shows. The window calibrates itself: a short probe observes each measured place's range, the full run uses that range, and a run that escapes its window is recalibrated and re-run automatically — no refusals or warnings based on absolute counts. Calibration is remembered while an experiment is open, so moving the sweep's sliders does not re-probe what an earlier selection already measured. Only the _span_ is limited: up to three metrics get 1,024 distinct values each (more metrics share the budget), and a wider span is binned at reduced resolution; - typed places to have _measurable_ token counts. A declared [token capacity](drawing-a-net.md#token-capacity) is used directly; without one, a short probe measures each place's real maximum and sizes the buffers from it (growing and re-running automatically if a run later outgrows the estimate). A place whose probe shows rare extreme outliers runs on the CPU instead — sizing every run for the outlier would waste the GPU's memory; - 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 cross-product enumeration across arcs (the one gate a bundled example — Production Machines — still hits); -- metrics that measure place token counts, without a time aggregation. +- metrics the shader can compute: place token counts, and expression metrics that read counts, parameters and one place's tokens (`.length`, `.reduce`). Metrics using `.concat`, indexing a token by position, string or uuid attributes, or a time aggregation run on the CPU, and the message names the metric. The GPU samples the runs still active in each frame. 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). diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index 27649d97746..ffc8a919f40 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -41,11 +41,13 @@ behind the scenes. steadier signal on a stochastic model, at the cost of more simulations per step. With the in-browser optimizer and **WebGPU** on in the [settings dialog](visual-settings.md#webgpu-experimental), a **Backend** - switch appears next to these fields. For an optimization it stays greyed - out, with the reason on hover: the objective is an expression metric, which - the GPU backend cannot compute (see - [Compute backend](experiments.md#compute-backend-experimental)), so the - steps run on the CPU. The in-browser optimizer also offers **Parallel + switch appears next to these fields. For an optimization it is available + when the objective compiles to the GPU (see + [Compute backend](experiments.md#compute-backend-experimental)); otherwise + it is greyed out with the reason on hover. A study with state constraints + runs its steps on the CPU even with the switch on, because constraints are + checked over time; its **Compute** badge says so. The in-browser optimizer + also offers **Parallel steps** (1 to 4, default `1`): how many steps it evaluates at once. The **Seed** field starts at a fresh random value each time the form opens; it seeds both the optimizer's proposals and the simulations' random draws, so @@ -217,9 +219,8 @@ in view on a laptop screen while the study streams: - The header's strip also shows the parallel steps when above one, an **Activity** column with the **N computing** chip, and a **Compute** column - saying where the steps run. The badge reads **CPU**, because the GPU backend - cannot compute an expression objective (see step 4 of - [Creating an optimization](#creating-an-optimization)); hover it for the + saying where the steps run. The badge reads **GPU** or **CPU** for where + the steps ran; when the GPU was asked for and declined, hover it for the reason. The chip counts the batches running right now (the steps in flight and the picked point's refinement), **0 computing** when nothing does, and opens a compact list with one row per batch and its own progress. diff --git a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md index 04b133da148..46e4a87e8a3 100644 --- a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md +++ b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md @@ -253,7 +253,7 @@ Dynamics, firing-rate, transition-kernel and metric code is compiled by Petrinau - `const` bindings (including destructuring like `const { a, b } = parameters` or `const [first] = input.Place`), a final `return`, and guard clauses (`if (condition) return value;`). - Arithmetic, comparisons, boolean logic, ternaries, and `Math.*` functions. - Token access (`input.Place[0].attr`, `.length`) and `Distribution.*` constructors (with `.map` transforms). -- Collection operators depend on the code surface: dynamics and statically sized transition token arrays support `.map(...)`; metric place-token arrays support `.reduce(...)` and `.concat(...)`, but not `.map(...)`. +- Collection operators depend on the code surface: dynamics and statically sized transition token arrays support `.map(...)`; metric place-token arrays support `.reduce(...)` and `.concat(...)`, but not `.map(...)`. On the GPU, `.reduce(...)` over one place's tokens compiles to a loop; `.concat(...)` keeps a metric on the CPU. - In metric code, place state access via `state.places..count` and `state.places..tokens` (a metric must `return` a number). Net parameters are available ambiently as `parameters.` (scenario parameters are not). Loops, `let`/`var`, object spread and arbitrary function calls are rejected with an error pointing at the offending code and suggesting the idiomatic alternative. This is what lets Petrinaut analyze your model (e.g. which parameters a rate depends on) and compile it to fast code that reads token values directly from the simulation's internal buffers — metrics included, so they stay cheap even across thousands of Monte Carlo runs. Scenario code is not affected by this subset. diff --git a/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-experiment-pipeline.d2 b/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-experiment-pipeline.d2 index 8d760459c52..454754ad28f 100644 --- a/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-experiment-pipeline.d2 +++ b/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-experiment-pipeline.d2 @@ -7,11 +7,11 @@ wgsl: "WGSL generation\n(compile-net-shader)" {style.fill: "#dcecff"; style.stro device: "GPU device" { style.stroke-dash: 4 shader: "shader\n(one invocation per run)" {style.fill: "#f2f2f2"; style.stroke: "#777777"} - histogram: "per-frame histogram\n(workgroup atomics)" {style.fill: "#f2f2f2"; style.stroke: "#777777"} + histogram: "per-frame histogram\n(workgroup atomics,\nsampled before each step)" {style.fill: "#f2f2f2"; style.stroke: "#777777"} summary: "per-run summary\n(~16 bytes per run)" {style.fill: "#f2f2f2"; style.stroke: "#777777"} } -frames: "metric frames\n(aggregates only)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} +frames: "metric frames\n(histograms, frame 0 included)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} hir -> wgsl wgsl -> device.shader: compile + dispatch\na chunk of frames diff --git a/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-histogram-sizing.d2 b/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-histogram-sizing.d2 index f3fb6e3b719..101fb637219 100644 --- a/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-histogram-sizing.d2 +++ b/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-histogram-sizing.d2 @@ -8,7 +8,7 @@ inputs: "shader inputs" { } sizing: "histogramBinCount\nbins = min(1024, 16 KB / (4 × metrics))\nthen min(bins, ceiling + 1)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} -window: "metric window (uniforms)\nbin i = lo + i × stride\nfirst attempt anchors on the\ninitial count" {style.fill: "#dcecff"; style.stroke: "#3676b8"} +window: "metric window (f32 uniforms)\nbin i = lo + i × stride\nexact for a ceiling-bounded count,\nblind and probed otherwise" {style.fill: "#dcecff"; style.stroke: "#3676b8"} device: "GPU device" { style.stroke-dash: 4 @@ -16,7 +16,7 @@ device: "GPU device" { global: "histogram buffer\n+ observed range and\nescape counters" {style.fill: "#f2f2f2"; style.stroke: "#777777"} } -decode: "host decode\nvalue = lo + bin × stride" {style.fill: "#dcecff"; style.stroke: "#3676b8"} +decode: "host decode\nlabel = lo + bin × stride\n(bin centre for real metrics)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} calibrate: "escapes?\nreplan window from the\nobserved range and re-run\n(same seeds — converges once)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} inputs.metrics -> sizing diff --git a/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx b/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx index a3c4960ea35..6df94ca8512 100644 --- a/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx +++ b/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx @@ -27,12 +27,12 @@ device that will not allocate is not the net's fault. A backend may be a **subset** engine, so declining a net is ordinary operation, not an exception. Blockers carry a `code`, an optional `itemId`, and an `origin`: -| origin | Who acts | Example | -| --------------- | -------------------------- | --------------------------------------- | -| `model` | edit the net | a typed place with no capacity | -| `configuration` | edit the experiment | a metric shape the backend cannot serve | -| `environment` | nobody, so hide the option | no WebGPU in this browser | -| `capacity` | retry, or use fewer runs | device out of memory | +| origin | Who acts | Example | +| --------------- | -------------------------- | ------------------------------------------------------------------------------------ | +| `model` | edit the net | a `string` attribute on a typed place | +| `configuration` | edit the experiment | a metric the shader cannot translate (`.concat` over two places, a time aggregation) | +| `environment` | nobody, so hide the option | no WebGPU in this browser | +| `capacity` | retry, or use fewer runs | device out of memory | `capacity` is separate from `environment` because it is transient: "use fewer runs" is right where "hide the option" would be wrong. diff --git a/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx b/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx index 435b2cdf955..96c4d4674b9 100644 --- a/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx +++ b/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx @@ -182,12 +182,13 @@ value. After its start-up trials the TPE sampler conditions each proposal on the objectives it was told, so with **Parallel steps** at 1 the study proposes the same parameter values step for step while the objective values match; above 1 the constant-liar sampler also counts the trials in flight, and the -proposals differ from a sequential study's. Every connected study runs on the -CPU: the objective is an expression metric, which the GPU backend cannot -compute, so `useGpuAvailability` reports it unavailable and the **Backend** -switch stays disabled. `test_runtime_lock.py` in optimizer-core fails when the -Optuna pin in `runtime-lock.json` differs from the version the package's own -lockfile installs; the service resolves the same version range -(`optuna>=4.9,<5`) in its own lockfile. The study lives in the tab: closing -the page ends it, and because a connected run is kept in memory only, the next -load shows no record for it. +proposals differ from a sequential study's. A connected study runs on the GPU +when its objective translates to WGSL (`useGpuAvailability` reads the +compilation report); a study with state constraints still falls back to the +CPU at run time, because their indicators aggregate over time. +`test_runtime_lock.py` in optimizer-core fails when the Optuna pin in +`runtime-lock.json` differs from the version the package's own lockfile +installs; the service resolves the same version range (`optuna>=4.9,<5`) in +its own lockfile. The study lives in the tab: closing the page ends it, and +because a connected run is kept in memory only, the next load shows no record +for it. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-backend.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-backend.mdx index 70755198d00..88491b9a341 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-backend.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-backend.mdx @@ -34,9 +34,14 @@ Two consequences of that shape: The backend is asked, not told: it reports whether it can run a net before the experiment starts, and one it declines runs on the CPU with the reason recorded. -It needs typed places to declare a capacity (buffer sizes must be known up front), -arcs consuming at most two typed tokens per place, no `string` or `uuid` -attributes, and metrics that measure place token counts. +It needs arcs consuming at most two typed tokens per place, no `string` or +`uuid` attributes (a typed place without a declared capacity is probed and +sized empirically), and metrics the shader can compute: place token counts, and +expression metrics over counts, parameters, arithmetic, conditionals, +`tokens.length` and a single place's `tokens.reduce` with a numeric accumulator. +Metrics using `.concat`, positional token indexing, strings or uuids, and +metrics with a time aggregation, keep the experiment on the CPU with the reason +recorded per metric. A weight-2 pairwise condition is scanned over every pair by combinatorial unranking, which preserves the CPU's lexicographic firing order. The pair that @@ -46,5 +51,7 @@ fires is the lowest-indexed passing one, not the one with the largest rate. WebGPU cannot reproduce the CPU generator, so trajectories differ while distributions agree. Continuous dynamics integrate with RK4, which is more -accurate than the CPU's Euler rather than merely different. Which backend ran an -experiment is therefore recorded alongside its results. +accurate than the CPU's Euler rather than merely different. Metric bodies +evaluate in f32 on the device and f64 on the CPU; integer-valued metrics are +exact below 2^24. Which backend ran an experiment is therefore recorded +alongside its results. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx index fc6159b64bc..f789defee72 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx @@ -6,11 +6,11 @@ attachTo: core.webgpu --- The GPU backend never stores per-run metric samples. Each frame, every -workgroup reduces its runs' place counts into a shared histogram and merges it -into a global buffer ([GPU backend](doc:simulation/gpu-backend)). That fixed -layout is what makes the memory static and the per-chunk streaming readback -possible. Two questions size it: how many bins a shader gets, and which count -range those bins cover. +workgroup reduces its runs' metric samples (place counts or expression values) +into a shared histogram and merges it into a global buffer +([GPU backend](doc:simulation/gpu-backend)). That fixed layout is what makes +the memory static and the per-chunk streaming readback possible. Two questions +size it: how many bins a shader gets, and which value range those bins cover. ## The problem @@ -49,9 +49,10 @@ same value. by construction, so the seventeen-metric pipeline failure cannot occur. - **A known count ceiling shrinks the histogram to fit it.** A typed place's count cannot exceed its slot capacity, and a declared capacity on any place - is enforced by the shader's eligibility constraints. When every sampled - place has such a ceiling, `bins = ceiling + 1` — capacity 16 gets 17 bins, - and the per-frame loops shrink accordingly. + is enforced by the shader's eligibility constraints. When every metric is a + place count with such a ceiling, `bins = ceiling + 1` — capacity 16 gets 17 + bins, and the per-frame loops shrink accordingly. An expression metric has + no ceiling, so any expression metric gives the shader the full budget. The budget is the WebGPU _baseline_ limit rather than the adapter's: `requestGpuDevice` raises only the buffer limits, so the created device sits at @@ -63,40 +64,56 @@ are linear in the bin count — 600 frames × 1024 bins × 3 metrics is 7.4 MB and the merge loop skips empty slots, so unused range costs little; the cap mainly bounds the decode and the buffer for long experiments. -**Window** — bin `i` covers counts `[lo + i × stride, lo + (i+1) × stride)`, -and `lo`/`stride` are _uniforms_ in the shader's config (`metric-windows.ts` -plans them), so moving the window needs no recompile. The window calibrates -itself instead of warning: - -1. The first attempt is exact for a ceiling-bounded place (`lo = 0`, - `stride = 1` when the capacity fits the bins) and a generous guess - anchored on the initial count otherwise — a wider window is a larger - stride over the same bins, so guessing large costs resolution, never +**Window** — bin `i` covers values `[lo + i × stride, lo + (i+1) × stride)`, +and `lo`/`stride` are f32 _uniforms_ in the shader's config — the same two +words per metric for every kind of metric (`metric-windows.ts` plans them), so +moving the window needs no recompile. Every sample, a place count included, is +an f32 binned through the one `window_bin` helper. A window carries an +`integer` flag: integer windows keep integer strides and label a bin by its +middle integer, so counts bin and label exactly as a u32 path would below +2^24 (above it a count rounds in f32 before binning, so a label can be off by +one); real windows label a bin by its centre with a `stride / 2` extent on +each side, and a constant real-valued metric gets one bin centred on its +value. `window_bin` divides once and then settles the quotient against the +exact edges the host labels by, because an f32 division carries up to 2.5 ULP +and a sample on an edge must land where the decoder says it does. The window +calibrates itself instead of warning: + +1. The first attempt is exact for a ceiling-bounded place count (`lo = 0`, + `stride = 1` when the capacity fits the bins) and a blind `lo 0, stride 1` + window otherwise, which the probe always replans — a wider window is a + larger stride over the same bins, so a window costs resolution, never memory. 2. The shader tracks each metric's observed min and max on the device (workgroup-reduced, like the histogram itself) and counts samples that - escaped the window into an edge bin — at both ends. + escaped the window into an edge bin — at both ends. Min and max travel as + order-preserving u32 keys (`f32_order_key`), so the existing u32 atomics + reduce floats and the workgroup budget is unchanged. 3. Any escape recalibrates: the experiment handle replans the window from the observed range and re-runs. Seeds derive from absolute run indices, so the re-run reproduces the same trajectories and the observed range is exact — one re-run always converges. -4. A large run with guessed windows probes first: a small prefix of the runs - (`GPU_PREVIEW_RUNS`) streams its frames to the charts, its observed - range plus margin sizes the full run's window, and the full attempt's - frames replace the probe's as they re-deliver. +4. Blind windows probe first: a small prefix of the runs (`probeRunCount`, + at most 128) streams its frames to the charts, its observed range plus + margin sizes the full run's window, and the full attempt's frames replace + the probe's as they re-deliver. Range tracking is independent of the + window, so the probe observes the exact range whatever its blind window + clamped. + +Frame 0 is sampled on the device before the first step, like every later +frame: the sample sits at the top of the frame iteration, so row `f` holds the +state after `f` steps. The histogram's last row was always empty (every run +still going takes its completed status at the frame limit, before it could be +sampled there), so the shift to sampling before the step costs no buffer. A count past the old fixed range is therefore no longer an error, a warning, or a refusal — a 1,030-token initial marking runs with a window that covers it, and a place living in [1000, 1200] gets bin-exact resolution across its -actual span. The clamped edge bins of a guessed first attempt are only ever -an intermediate picture on screen during calibration. +actual span. The clamped edge bins of a blind first attempt are only ever an +intermediate picture on screen during calibration. ## Potential future solutions -- **Range binning.** A host-declared window maps `bin = (count − lo) × bins / -(hi − lo)`. Counts in the tens of thousands become representable at reduced - resolution. This changes bin semantics from exact counts to intervals, so - the decode and the charts must carry the window. - **Exact readback for small run counts.** Below a few hundred runs the raw per-run counts are smaller than the histogram; an opt-in exact mode would remove the ceiling entirely where it is cheapest to do so. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-shader-generation.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-shader-generation.mdx index dae379ce9a1..a4e0158ef6d 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-shader-generation.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-shader-generation.mdx @@ -9,14 +9,15 @@ The WebGPU backend compiles a net into one compute shader and runs every run of an experiment as one invocation. The generator is organized by concern so that each planned extension lands mostly in one module: -| Concern | What it emits | Planned extension | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| Token layout | The words of a token: `real` attributes as f32, then `integer` and `boolean` words; the slab per typed place sized by its capacity | String and UUID attributes (an interned id or 128-bit words per attribute) | -| Transition firing | Enablement and the choice of tokens to consume: standard inputs by count, one typed input by walking the token combinations the CPU would walk | Several typed input places: a mixed-radix scan over the per-place combination counts, matching the CPU's combination order | -| Output emission | Kernel outputs written into the destination slabs at once, their counts folded in at the end of the frame as the CPU applies additions after its transition loop | — | -| Dynamics | The ODE stages (Euler, RK2, RK4) re-emitting the derivative HIR per stage | — | -| Histograms | Per-metric window uniforms, workgroup-memory histograms flushed per frame, observed min/max and escape counters | — | -| Run parameters | One f32 per (run, per-run parameter) read once into a per-invocation local instead of a literal | — | +| Concern | What it emits | Planned extension | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Token layout | The words of a token: `real` attributes as f32, then `integer` and `boolean` words; the slab per typed place sized by its capacity | String and UUID attributes (an interned id or 128-bit words per attribute) | +| Transition firing | Enablement and the choice of tokens to consume: standard inputs by count, one typed input by walking the token combinations the CPU would walk | Several typed input places: a mixed-radix scan over the per-place combination counts, matching the CPU's combination order | +| Output emission | Kernel outputs written into the destination slabs at once, their counts folded in at the end of the frame as the CPU applies additions after its transition loop | — | +| Dynamics | The ODE stages (Euler, RK2, RK4) re-emitting the derivative HIR per stage | — | +| Histograms | Per-metric f32 window uniforms, workgroup-memory histograms flushed at the top of each frame (frame 0 included), observed min/max as order-preserving u32 keys, escape counters, `window_bin` edge settlement | — | +| Metric sampling | `metric-sample.ts`: the `state` binder over the layout, expression samples emitted from the metric HIR, `tokens.reduce` as a loop over the live slots | `.concat` over several places' tokens; transition-firing samples | +| Run parameters | One f32 per (run, per-run parameter) read once into a per-invocation local instead of a literal | — | ## What runs today @@ -26,7 +27,9 @@ from one place, or when one run's state exceeds the memory gate. Shader generation refuses transitions consuming typed tokens from more than one place. The readiness of every example net is pinned in `compilation-report.test.ts`; any change to that table is a deliberate -decision, not a side effect. +decision, not a side effect. Metric translatability is pinned per bundled +example metric in `try-translate-metric.test.ts`, and the compilation report +carries a `metric` row per model metric. ## One batch on the GPU @@ -34,10 +37,10 @@ decision, not a side effect. The WebGPU experiment backend outlives batches and owns a lease-counted backend cache: one device, compiled shader and set of learned calibrations -per setup key. The key covers what shapes the shader (net and artifact -identity, baked parameter values, metric set, dt, integration method, -initial marking) and excludes the values of per-run-buffered parameters, so -range batches across different selections share one backend. +per setup key. The key covers what shapes the shader (net identity and +artifact fingerprint, baked parameter values, metric set, dt, integration +method, initial marking) and excludes the values of per-run-buffered +parameters, so range batches across different selections share one backend. A batch leases the backend, looks up the calibration for its marking, and if there is none runs a probe: a prefix of the runs at generous slabs, growing diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx index cacd39e29d5..0ee815bcccb 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx @@ -397,10 +397,9 @@ Only the pair case (`weight ≤ 2`) has a closed-form unranking that preserves the engine's ordering; wider arcs are refused. With kernels emitting too, the whole satellites net compiles once its places declare capacities — three lambdas, one dynamics, three kernels, 333 lines of WGSL over 552 bytes of -state per run. (Its own experiment still runs on the CPU for an unrelated -reason: two of its metrics reduce over token attributes, which only an -expression metric can express, and the GPU serves only place-token-count -metrics.) +state per run. (Its two token-reducing metrics now compile as loops over the +live slots; across the bundled examples only the two `.concat` metrics keep an +experiment on the CPU.) Bring-up left five records worth keeping: @@ -482,7 +481,9 @@ figure predates FE-1499: redrawing diverged against the then-inflated CPU test. With the CPU fixed, one consumed draw per enabled transition per frame is the shared rule, and FE-1553 aligned the shader to it — the committed `Dev / GpuParity` Storybook harness now measures the agreement instead of a -hand-run comparison.) +hand-run comparison. The harness also carries SIR's **Infected Fraction** +expression metric beside the place count, so the same run checks a metric body +evaluated in f32 on the device against the CPU's f64.) The run-count bound is gone: an experiment larger than the device's buffers runs as sequential tiles over one shared histogram buffer, which merges itself From 9c535b68394cd320a8b96ab3c68af30aa08d1931 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 03:10:10 +0200 Subject: [PATCH 08/21] Close the review gaps in the GPU metric commits --- libs/@hashintel/petrinaut-core/src/webgpu.ts | 10 +- .../src/webgpu/compile-net-shader.test.ts | 102 ++++++++++++++++-- .../webgpu/compile-net-shader/histograms.ts | 59 +++++++--- .../src/webgpu/gpu-metric-frames.test.ts | 23 ++++ .../src/webgpu/gpu-metric-frames.ts | 24 +++-- .../petrinaut-core/src/webgpu/runner.ts | 20 ++-- .../src/webgpu/runner/histogram-frames.ts | 3 +- libs/@hashintel/petrinaut/docs/experiments.md | 2 +- .../@hashintel/petrinaut/docs/optimization.md | 9 +- .../ui/dev/gpu-parity/gpu-parity.stories.tsx | 5 + .../browser-optimizer.stories.tsx | 6 +- .../create-optimization-drawer.test.tsx | 44 ++++++++ .../create-optimization-drawer.tsx | 29 ++++- .../view-optimization-drawer.stories.tsx | 2 +- .../content/optimizer/browser-runtime.mdx | 6 +- .../content/simulation/performance.mdx | 13 ++- 16 files changed, 295 insertions(+), 62 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu.ts b/libs/@hashintel/petrinaut-core/src/webgpu.ts index 940e87a5206..9cdb4caf0ca 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu.ts @@ -8,9 +8,9 @@ * touches the TypeScript frontend. * * Only the surface the app consumes is exported: the backend factory, the - * compilation report the editor renders, the metric-spec gate the experiment - * drawer applies, and the per-metric translation probe behind both. Everything - * else in `webgpu/` is internal; tests import it by relative path. + * compilation report the editor renders, and the metric-spec gate the + * experiment drawer applies. Everything else in `webgpu/` is internal; tests + * import it by relative path. * * "The WebGPU backend" in * `libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx` covers @@ -37,7 +37,3 @@ export type { export { toGpuMetricSpecs } from "./webgpu/gpu-metric-frames"; export type { GpuMetricSpec } from "./webgpu/compile-net-shader"; -export { - tryTranslateMetric, - type MetricTranslationResult, -} from "./webgpu/try-translate-metric"; 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 index e2795153387..ef33e5bdf89 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader.test.ts @@ -20,9 +20,14 @@ import type { SDCPN } from "../types/sdcpn"; import type { GpuMetricSpec, GpuOdeMethod } from "./compile-net-shader"; /** A metric sampling one place's token count. */ -const placeCount = (id: string, placeId: string): GpuMetricSpec => ({ +const placeCount = ( + id: string, + placeId: string, + sampleRuns: GpuMetricSpec["sampleRuns"] = "active", +): GpuMetricSpec => ({ id, integer: true, + sampleRuns, sample: { kind: "placeCount", placeId }, }); @@ -33,6 +38,7 @@ const placeCount = (id: string, placeId: string): GpuMetricSpec => ({ const expression = (id: string, hir: HirFunction): GpuMetricSpec => ({ id, integer: false, + sampleRuns: "active", sample: { kind: "expression", hir }, }); @@ -325,12 +331,13 @@ describe("compileNetShader", () => { expect(wgsl).not.toContain("if (running && status == 0u) {"); }); - it("never writes the histogram's last row, so sampling first costs no buffer", () => { - // The buffer holds `frame_limit` rows. Sampling after the step left row - // `frame_limit - 1` empty: every run still running takes status 2 at the - // frame limit inside the end-of-frame fold, and a finished run is never - // sampled. Sampling first fills rows 0..frame_limit - 1 of the same - // buffer, as long as that status flip still follows the sample. + it("flips a run's status after the sample, so a run leaves `active` in the frame it finishes", () => { + // The CPU excludes a run from `active` in the frame it completes or + // deadlocks and counts it as `completed` from that frame on. The shader + // matches as long as the end-of-frame fold's status flip follows the + // sample: row f then reads the status step f - 1 left, and row + // `frame_limit`, sampled by the host's extra iteration, sees every run + // at status 2. const result = compileFor(sir, { metrics: [placeCount("infected", "place__infected")], }); @@ -975,10 +982,18 @@ function unbalancedBraces(wgsl: string): number { /** * Expression metrics are emitted from their HIR at the top of the frame, - * inside the same `if (in_range && status == 0u)` block a place count is - * sampled in. These pin the emitted text for the shapes the bundled examples - * use: count arithmetic with a conditional, a `tokens.reduce` loop, and a - * swept parameter. + * inside the same status-guarded block a place count is sampled in. These pin + * the emitted text for the shapes the bundled examples use: count arithmetic + * with a conditional, a `tokens.reduce` loop, and a swept parameter. + * + * Validated by hand with naga 30.0.1 (`naga .wgsl`, "Validation + * successful") on three dumps of this emitter: SIR with Infected Fraction + * sampling `all` runs, SIR with an active place count beside Infected Fraction + * sampling `completed` runs, and capped satellites with all four model metrics + * (two of them `reduce` loops) sampling `all` runs — so `fn window_bin`, + * `f32_order_key`, the `var`/`for` reduce inside the frame loop, `select` over + * a division and each status guard pass a real validator, not only the scans + * below. */ describe("expression metrics", () => { const cappedSatellites = (): SDCPN => ({ @@ -1128,6 +1143,71 @@ describe("expression metrics", () => { }); }); +/** + * `sampleRuns` selects the runs a frame counts by the status word: 0 is a run + * still stepping, 1 deadlocked, 2 at the frame limit (both `complete` on the + * CPU), 3 and above halted by an overflow or a non-finite sample. A finished + * run's registers are frozen by the `running` gate, so a later frame reads + * its final state. + */ +describe("run sampling", () => { + it("samples active runs by default, as the CPU does", () => { + const result = compileFor(sir, { + metrics: [placeCount("infected", "place__infected")], + }); + if (!result.ok) throw new Error(result.reason); + + expect(result.shader.wgsl).toContain( + " if (in_range && status == 0u) {\n let v0: f32 = f32(counts[1u]);", + ); + }); + + it("samples completed runs through both finished statuses and never a halted one", () => { + const result = compileFor(sir, { + metrics: [placeCount("infected", "place__infected", "completed")], + }); + if (!result.ok) throw new Error(result.reason); + + expect(result.shader.wgsl).toContain( + " if (in_range && (status == 1u || status == 2u)) {\n let v0: f32 = f32(counts[1u]);", + ); + }); + + it("samples all runs below the halted statuses", () => { + const result = compileFor(sir, { + metrics: [placeCount("infected", "place__infected", "all")], + }); + if (!result.ok) throw new Error(result.reason); + + expect(result.shader.wgsl).toContain( + " if (in_range && status <= 2u) {\n let v0: f32 = f32(counts[1u]);", + ); + }); + + it("guards each metric by its own mode and scans clean", () => { + // The optimizer objective samples `all` runs beside a chart's default + // `active` place count; each block carries its own guard. + const result = compileFor(sir, { + metrics: [ + placeCount("infected", "place__infected"), + { + ...modelMetric(sir, "metric__infected_fraction"), + sampleRuns: "all", + }, + ], + }); + if (!result.ok) throw new Error(result.reason); + const { wgsl } = result.shader; + + expect(wgsl).toContain(" if (in_range && status == 0u) {\n let v0"); + expect(wgsl).toContain( + " if (in_range && status <= 2u) {\n let m1_u_0_s: f32 = f32(counts[0u]);", + ); + expect(sameScopeRedeclarations(wgsl)).toStrictEqual([]); + expect(unbalancedBraces(wgsl)).toBe(0); + }); +}); + describe("generated WGSL validity", () => { const cappedSatellites = (): SDCPN => ({ ...satellites, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts index 737028e43db..a37cba14dea 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compile-net-shader/histograms.ts @@ -12,6 +12,7 @@ import { WgslBailError } from "../emit-wgsl"; import { emitMetricSample } from "./metric-sample"; import type { HirFunction } from "../../hir/hir"; +import type { MonteCarloUserDefinedMetricSampleRuns } from "../../simulation/monte-carlo/metrics"; import type { GpuNetProfile } from "../eligibility"; import type { WgslParameterValue, WgslValue } from "../emit-wgsl"; @@ -33,6 +34,13 @@ export type GpuMetricSpec = { id: string; /** Every sample is a whole number, so bins keep exact integer labels. */ integer: boolean; + /** + * Which runs a frame samples, read off the run's status word as the CPU + * reads it off the run's status: `active` is a run still stepping, + * `completed` one that reached the frame limit or deadlocked, `all` both. + * A run halted by an error is never sampled. + */ + sampleRuns: MonteCarloUserDefinedMetricSampleRuns; sample: /** A place's token count, read from `counts[]`. */ | { kind: "placeCount"; placeId: string } @@ -171,13 +179,39 @@ export const workgroupHistogramLines = ( "", ]; +/** + * The status test a metric's `sampleRuns` selects. Status 0 is a run still + * stepping; 1 (deadlocked) and 2 (reached the frame limit) are both `complete` + * on the CPU; 3 and above are halted runs, which no mode samples. + */ +const sampledStatusCondition = ( + sampleRuns: MonteCarloUserDefinedMetricSampleRuns, +): string => { + switch (sampleRuns) { + case "active": + return "status == 0u"; + case "completed": + return "(status == 1u || status == 2u)"; + case "all": + return "status <= 2u"; + } +}; + /** * Emits the start-of-frame sampling: zero the workgroup histogram, sample each - * live run's metrics as f32 and bin them, then flush to the global histogram - * and range. Sampling precedes the step, so row `f` holds the state after `f` - * steps and row 0 is the initial marking; no row is spent on it, because the - * last row was never written when sampling followed the step (every run still - * running takes `status = 2u` at the frame limit). + * run the metric asks for as f32 and bin it, then flush to the global + * histogram and range. Sampling precedes the step, so row `f` holds the state + * after `f` steps and row 0 is the initial marking. The host dispatches one + * iteration past the frame limit, in which nothing runs: it writes row + * `frame_limit`, the CPU's final frame, where every run is complete and only a + * metric sampling completed runs has anything to count. + * + * A finished run's registers and token slots hold its final state, since + * every write is gated on `running`, so sampling it later is the same read as + * sampling a live run: `active` takes `status == 0u`, `completed` the two + * finished statuses, `all` both. A run halted by an overflow or a non-finite + * sample (status 3 and above) is never sampled, as the CPU skips an errored + * run. * * One path for every metric: the sample is an f32 — a place count cast from * its register, or a metric body emitted over `metricState` — its observed @@ -218,9 +252,11 @@ export const emitFrameHistograms = ( ` // \`absolute_frame\` steps, so row f is frame f and row 0 is the initial`, ); push( - ` // marking. A run is sampled while active, the CPU metric default, which`, + ` // marking. Each metric samples the runs its \`sampleRuns\` names by status:`, + ); + push( + ` // 0 active, 1 deadlocked, 2 complete; a halted run is never sampled.`, ); - push(` // excludes a run in the frame it deadlocks or completes.`); push( ` for (var b: u32 = lid; b < ${totalBins}u; b = b + ${workgroupSize}u) {`, ); @@ -237,13 +273,12 @@ export const emitFrameHistograms = ( const value = `v${metricIndex}`; const key = `k${metricIndex}`; const bin = `b${metricIndex}`; - // Samples only runs still active at the top of the frame: the previous - // step set the status, so the CPU metric default's exclusion of a run in - // the frame it deadlocks or completes holds here too. A sample outside the - // window clamps into the edge bin and is counted as an escape, which + // The previous step set the status, so a run is excluded from `active` + // in the frame it deadlocks or completes, as on the CPU. A sample outside + // the window clamps into the edge bin and is counted as an escape, which // triggers a recalibrated re-run — the clamped picture is only ever an // intermediate. - push(` if (in_range && status == 0u) {`); + push(` if (in_range && ${sampledStatusCondition(metric.sampleRuns)}) {`); if (metric.sample.kind === "placeCount") { const placeIndex = placeIndexById.get(metric.sample.placeId); if (placeIndex === undefined) { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts index 4ee0943facc..f0251969815 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.test.ts @@ -60,6 +60,7 @@ describe("toGpuMetricSpecs", () => { { id: "susceptible", integer: true, + sampleRuns: "active", sample: { kind: "placeCount", placeId: sir.places[0]!.id }, }, ], @@ -78,12 +79,34 @@ describe("toGpuMetricSpecs", () => { { id: "metric__infected_fraction", integer: false, + sampleRuns: "active", sample: { kind: "expression", hir: spec.artifact.hir }, }, ], }); }); + it("carries the spec's `sampleRuns` to the shader", () => { + // The optimizer objective and the experiment drawer sample `all` runs, so + // a finished run's final state counts in every later frame on both + // backends. + const result = toGpuMetricSpecs( + [ + { ...susceptibleCount, sampleRuns: "completed" }, + modelMetricSpec(sir, "metric__infected_fraction", { + sampleRuns: "all", + }), + ], + { sdcpn: sir }, + ); + + expect( + result.ok + ? result.metrics.map(({ sampleRuns }) => sampleRuns) + : result.reason, + ).toStrictEqual(["completed", "all"]); + }); + it("refuses an expression the shader cannot translate and names the construct", () => { const result = toGpuMetricSpecs( [modelMetricSpec(production, "metric__average_machine_damage")], diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts index 7b23badcdeb..78d76c99bed 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-metric-frames.ts @@ -15,14 +15,18 @@ * the bundled examples' 30 model metrics, 28 translate; the two `.concat` * averages stay on the CPU. * - * Where a GPU frame differs from the CPU's: the shader samples the runs still - * active in a frame, whatever `sampleRuns` asks (the spec is accepted and the - * setting ignored), so on a terminating net late frames weight the - * longest-lived runs. A non-finite sample halts its run on the device and the - * handle fails the experiment after the attempt, where the CPU throws at the - * frame. Scalar aggregates are reduced from bin labels — exact for integer - * metrics at stride 1, quantised to the labels otherwise — and `last` is the - * highest bin label rather than the highest run index's sample. + * `sampleRuns` is carried to the shader, which tests each run's status word + * the way the CPU tests the run's status, so a metric over `all` runs counts + * a finished run's final state in every later frame on both backends, and the + * device writes the CPU's final frame (row `frame_limit`, where every run is + * complete) rather than stopping one row short. + * + * Where a GPU frame differs from the CPU's: a non-finite sample halts its run + * on the device and the handle fails the experiment after the attempt, where + * the CPU throws at the frame. Scalar aggregates are reduced from bin labels — + * exact for integer metrics at stride 1, quantised to the labels otherwise — + * and `last` is the highest bin label rather than the highest run index's + * sample. */ import { tryTranslateMetric } from "./try-translate-metric"; @@ -75,10 +79,13 @@ export function toGpuMetricSpecs( reason: `The GPU backend does not aggregate metrics over time yet; metric "${spec.label}" uses a time aggregation.`, }; } + // The CPU's default when a spec leaves it unset (`shouldSampleRun`). + const sampleRuns = spec.sampleRuns ?? "active"; if (spec.kind === "placeTokenCountMean") { metrics.push({ id: spec.id, integer: true, + sampleRuns, sample: { kind: "placeCount", placeId: spec.placeId }, }); continue; @@ -106,6 +113,7 @@ export function toGpuMetricSpecs( metrics.push({ id: spec.id, integer: translation.integer, + sampleRuns, sample: { kind: "expression", hir }, }); } diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts index 32b42c00e08..b41666250da 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner.ts @@ -206,7 +206,11 @@ export async function runGpuExperiment( const bytesPerRun = shader.stateWordsPerRun * 4; const histWordsPerFrame = shader.histogramBins * metricCount; - const histBytes = Math.max(1, frameLimit * histWordsPerFrame) * 4; + // Rows 0..frameLimit: row f is the state after f steps, and the last row is + // the CPU's final frame, sampled by one loop iteration past the limit in + // which nothing runs. Only a metric sampling completed runs writes it. + const sampledRows = frameLimit + 1; + const histBytes = Math.max(1, sampledRows * histWordsPerFrame) * 4; const tooLarge = describeBufferOverflow({ histBytes, @@ -285,7 +289,7 @@ export async function runGpuExperiment( : device.createBuffer({ size: Math.max( 4, - Math.min(framesPerDispatch, frameLimit) * histWordsPerFrame * 4, + Math.min(framesPerDispatch, sampledRows) * histWordsPerFrame * 4, ), usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, }); @@ -435,7 +439,7 @@ export async function runGpuExperiment( let baseFrame = 0; for (const chunkFrameCount of dispatchChunkFrames( - frameLimit, + sampledRows, framesPerDispatch, )) { // Frames already advanced stay in the histogram, and the caller is @@ -460,13 +464,15 @@ export async function runGpuExperiment( // 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(); - const framesDone = Math.min(baseFrame + chunkFrameCount, frameLimit); + const rowsDone = baseFrame + chunkFrameCount; + // Progress counts steps, so the final sampling row is not a frame done. + const framesDone = Math.min(rowsDone, frameLimit); if (chunkReadback !== null) { // Every frame's bins are final once its dispatch retired, so the // chunk's range can be read while later dispatches queue. Later // tiles re-read ranges earlier tiles already streamed; the // re-decoded frames carry every tile's samples so far. - const chunkFrames = framesDone - baseFrame; + const chunkFrames = rowsDone - baseFrame; const chunkBytes = chunkFrames * histWordsPerFrame * 4; const copyEncoder = device.createCommandEncoder(); copyEncoder.copyBufferToBuffer( @@ -499,7 +505,7 @@ export async function runGpuExperiment( runsInTile, runCount, }); - baseFrame = framesDone; + baseFrame = rowsDone; } dispatchMs += now() - start; @@ -611,7 +617,7 @@ export async function runGpuExperiment( firstFrame: 0, frameCount: sampledFrameCount({ data: histogram, - frameLimit, + frameLimit: sampledRows, metricCount, histogramBins: shader.histogramBins, }), diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts index 187f09777d5..15c89dfb964 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/runner/histogram-frames.ts @@ -1,7 +1,8 @@ /** * Decoding the device's histogram buffer into per-frame metric frames. * - * The buffer holds `frameLimit × metrics × bins` u32 counts, frame-major then + * The buffer holds `(frameLimit + 1) × metrics × bins` u32 counts, one row + * per frame from the initial marking to the final state, frame-major then * metric-major; bin `b` covers the values `[lo + b × stride, lo + (b + 1) × * stride)` of its metric's window. An integer window labels the bin by its * middle integer, exact at stride 1; a real window labels it by its centre. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index c83e76eed97..9702de9f530 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -98,7 +98,7 @@ The GPU backend handles a **subset** of nets, and it tells you when it cannot ta - 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 cross-product enumeration across arcs (the one gate a bundled example — Production Machines — still hits); -- metrics the shader can compute: place token counts, and expression metrics that read counts, parameters and one place's tokens (`.length`, `.reduce`). Metrics using `.concat`, indexing a token by position, string or uuid attributes, or a time aggregation run on the CPU, and the message names the metric. The GPU samples the runs still active in each frame. +- metrics the shader can compute: place token counts, and expression metrics that read counts, parameters and one place's tokens (`.length`, `.reduce`). Metrics using `.concat`, indexing a token by position, string or uuid attributes, or a time aggregation run on the CPU, and the message names the metric. Each metric samples the same runs on either backend: the runs still active in a frame by default, or, for a metric the experiment form defines, every run, with a finished run keeping its final value. 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). diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index ffc8a919f40..03082ee1af2 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -44,11 +44,10 @@ behind the scenes. switch appears next to these fields. For an optimization it is available when the objective compiles to the GPU (see [Compute backend](experiments.md#compute-backend-experimental)); otherwise - it is greyed out with the reason on hover. A study with state constraints - runs its steps on the CPU even with the switch on, because constraints are - checked over time; its **Compute** badge says so. The in-browser optimizer - also offers **Parallel - steps** (1 to 4, default `1`): how many steps it evaluates at once. The + it is greyed out with the reason on hover. A state constraint greys it out + too, because constraints are checked over time, which the GPU does not do + yet. The in-browser optimizer also offers **Parallel steps** (1 to 4, + default `1`): how many steps it evaluates at once. The **Seed** field starts at a fresh random value each time the form opens; it seeds both the optimizer's proposals and the simulations' random draws, so keep a seed to reproduce a study and change it to explore a different set diff --git a/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx b/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx index 2d788c243c2..d668358147a 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/gpu-parity/gpu-parity.stories.tsx @@ -10,6 +10,11 @@ * reproduced by hand. It needs a browser with WebGPU (real hardware; the * GPU column reports why when unavailable), and it logs a `gpu-parity:` * JSON line so scripts can extract the numbers. + * + * The place-count rows sample active runs, the chart default; the expression + * row samples `all` runs, as the optimizer objective and the experiment form + * do, so it also checks the shader's run-status guard and the final frame + * (row `frame_limit`, where every run is complete) against the CPU. */ import { useEffect, useState } from "react"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/browser-optimizer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/browser-optimizer.stories.tsx index b27766e6657..ee7bd211c75 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/browser-optimizer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/browser-optimizer.stories.tsx @@ -127,7 +127,7 @@ const watchForNote = "Everything is in view at once: the summary strip (status, steps, best, backend, progress bars and the computing chip), the Parameters band, the Surface beside the objective's chart, and the steps table filling the rest. Watch the band follow each step, the Surface gain a dot per step — the best emphasized, the field filling in between them, the ringed dot on the step in flight streaming its running value — and the chart beside it stream the objective over the step's runs. While the study runs the sliders are disabled and a drag on the Surface does nothing; turn Follow steps off to take over early. Once complete, click the Surface or move a slider: the point refines in escalating batches, its value enters the field, and the chart streams again."; const gpuNote = - "With WebGPU on in settings, the create form's Backend switch appears but stays disabled for an expression objective by design: the GPU backend cannot compute expression metrics, so steps run on the CPU."; + "With WebGPU on in settings, the create form's Backend switch appears, available when the objective translates to WGSL (counts, parameters, arithmetic, conditionals and one place's tokens) and greyed out with the reason on hover otherwise; a drafted state constraint greys it out too, since its indicator aggregates over time."; export const SirCpu: Story = { name: "SIR CPU", @@ -153,7 +153,7 @@ export const SirGpuRequested: Story = { parameters: { docs: { description: { - story: `The SIR study with WebGPU enabled and the GPU requested for its steps. The GPU backend declines the expression objective, so the record's badge reads CPU and its tooltip carries the reason: the real fallback. ${firstRunNote} ${watchForNote} ${gpuNote}`, + story: `The SIR study with WebGPU enabled and the GPU requested for its steps. Infected Fraction translates to WGSL, so in a browser with WebGPU the steps run on the device and the record's badge reads GPU; without WebGPU the backend declines the request and the badge reads CPU with the reason in its tooltip: the real fallback. ${firstRunNote} ${watchForNote} ${gpuNote}`, }, }, }, @@ -191,7 +191,7 @@ export const VaccinationCampaign: Story = { parameters: { docs: { description: { - story: `The Vaccination Campaign example's Winter wave scenario, minimizing Total cost over vaccination coverage (0 to 0.9) and contact reduction (0 to 0.8) on the CPU: the model built for this drawer. Cases are priced against a campaign and distancing whose prices rise quadratically, so the Surface shows a valley along the epidemic threshold with its floor near a coverage of 0.45 and a contact reduction of 0.4 (about 960 against 1,280 to 2,220 in the corners). Six steps are still the sampler's random start-up, so expect scattered dots with the best step landing in the valley and the Surface field dipping there. The net is GPU-eligible, so an experiment on it runs on the GPU when one is available, while the study's expression objective keeps its steps on the CPU. ${firstRunNote} ${watchForNote} ${gpuNote}`, + story: `The Vaccination Campaign example's Winter wave scenario, minimizing Total cost over vaccination coverage (0 to 0.9) and contact reduction (0 to 0.8) on the CPU: the model built for this drawer. Cases are priced against a campaign and distancing whose prices rise quadratically, so the Surface shows a valley along the epidemic threshold with its floor near a coverage of 0.45 and a contact reduction of 0.4 (about 960 against 1,280 to 2,220 in the corners). Six steps are still the sampler's random start-up, so expect scattered dots with the best step landing in the valley and the Surface field dipping there. The net is GPU-eligible and Total cost translates to WGSL, so with WebGPU on the study's Backend switch is available. ${firstRunNote} ${watchForNote} ${gpuNote}`, }, }, }, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx index 23354f0d832..2cb482d822b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx @@ -1457,6 +1457,50 @@ describe("CreateOptimizationDrawer backend choice", () => { }); }); + it("rules the GPU out while a state constraint is drafted, since its indicator aggregates over time", async () => { + // The run carries one `min`-aggregated indicator metric per state + // constraint, which the GPU gate refuses; the switch must say so before + // the study is created rather than the study falling back at run time. + openWithWebGpu({ + connectedSource: true, + webGpuEnabled: true, + languageClient: makeSuccessfulLanguageClient(), + }); + const savedMetric = sirSdcpnContextValue.petriNetDefinition.metrics?.[0]; + fireEvent.change( + screen.getByRole("combobox", { name: "Select a metric" }), + { + target: { value: `${MODEL_METRIC_VALUE_PREFIX}${savedMetric!.id}` }, + }, + ); + await waitFor(() => { + expect(backendState()).toBe("available"); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Add state constraint" }), + ); + // An empty row is skipped at submission, so it does not gate the switch. + await waitFor(() => { + expect(backendState()).toBe("available"); + }); + const row = screen.getByRole("group", { name: "State constraint 1" }); + fireEvent.change(within(row).getByRole("textbox"), { + target: { value: "state.places.Infected.count < 100" }, + }); + await waitFor(() => { + expect(backendState()).toBe("unavailable"); + }); + expect(backendSwitch().disabled).toBe(true); + + fireEvent.click( + screen.getByRole("button", { name: "Remove state constraint 1" }), + ); + await waitFor(() => { + expect(backendState()).toBe("available"); + }); + }); + it("passes the backend as a creation option", async () => { const languageClient = makeSuccessfulLanguageClient(); const createOptimization = vi.fn( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx index becea182646..4c50b190e9e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx @@ -1058,7 +1058,31 @@ export const CreateOptimizationDrawer = ({ metricSource === "saved" ? selectedSavedMetric : buildMetricFromFormState(customMetricValues, customMetricId); - const objectiveMetricSpecs: ExperimentMetricSpecInput[] | null = + // Each state constraint runs as a 0/1 indicator metric aggregated with + // `min` over a run's frames (`stateConstraintMetrics`), which the GPU gate + // refuses before it reads anything else about the metric. The gate sees a + // place count carrying that aggregation under the row's name — the + // indicator itself exists only once the constraint's HIR is lowered at + // submission — so the switch is refused with the run-time gate's sentence, + // under the row's name. Empty rows are skipped at submission and here. + const firstPlaceId = petriNetDefinition.places[0]?.id; + const stateConstraintGateSpecs: ExperimentMetricSpecInput[] = + firstPlaceId === undefined + ? [] + : stateConstraintDrafts.flatMap((draft, index) => + draft.code.trim() === "" + ? [] + : [ + { + kind: "placeTokenCountMean" as const, + id: draft.id, + label: describeConstraint("state", index), + placeId: firstPlaceId, + aggregateTime: "min" as const, + }, + ], + ); + const gateMetricSpecs: ExperimentMetricSpecInput[] | null = objectiveMetricForGpu ? [ { @@ -1069,6 +1093,7 @@ export const CreateOptimizationDrawer = ({ sampleRuns: "all", runOutput: { type: "distribution" }, }, + ...stateConstraintGateSpecs, ] : null; const webGpuAvailable = isWebGpuAvailable(); @@ -1076,7 +1101,7 @@ export const CreateOptimizationDrawer = ({ enabled: open && backendSelectable && webGpuEnabled && webGpuAvailable, sdcpn: petriNetDefinition, extensions, - metricSpecs: objectiveMetricSpecs, + metricSpecs: gateMetricSpecs, }); // Derived rather than stored, so a net edited into ineligibility after the // switch was flipped neither shows as on nor submits a GPU study. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.stories.tsx index 89c2d63cd7a..bfa2bb570aa 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.stories.tsx @@ -136,7 +136,7 @@ export const ConnectedAfterGpuFallback: Story = { render: () => ( ), }; diff --git a/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx b/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx index 96c4d4674b9..de943ee8e25 100644 --- a/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx +++ b/libs/@local/petrinaut-arch-docs/content/optimizer/browser-runtime.mdx @@ -184,8 +184,10 @@ the same parameter values step for step while the objective values match; above 1 the constant-liar sampler also counts the trials in flight, and the proposals differ from a sequential study's. A connected study runs on the GPU when its objective translates to WGSL (`useGpuAvailability` reads the -compilation report); a study with state constraints still falls back to the -CPU at run time, because their indicators aggregate over time. +compilation report). The drawer's gate also carries one `min`-aggregated spec +per drafted state constraint, as the run carries their indicators, so a study +with state constraints is refused at the switch with the sentence the run-time +gate would give rather than falling back after the switch was flipped. `test_runtime_lock.py` in optimizer-core fails when the Optuna pin in `runtime-lock.json` differs from the version the package's own lockfile installs; the service resolves the same version range (`optuna>=4.9,<5`) in diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx index 0ee815bcccb..f50c54c3724 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx @@ -482,8 +482,17 @@ test. With the CPU fixed, one consumed draw per enabled transition per frame is the shared rule, and FE-1553 aligned the shader to it — the committed `Dev / GpuParity` Storybook harness now measures the agreement instead of a hand-run comparison. The harness also carries SIR's **Infected Fraction** -expression metric beside the place count, so the same run checks a metric body -evaluated in f32 on the device against the CPU's f64.) +expression metric beside the place count, sampling `all` runs as the optimizer +objective does, so the same run checks a metric body evaluated in f32 on the +device against the CPU's f64, the shader's run-status guard and the final +frame row. Measured on 2026-09-11, 2000 runs, `maxTime` 30, `dt` 0.1, seed +42, Apple M1 Max: the Infected Fraction means agree to 0.033% on average +and 0.085% at the worst of 301 frames, beside 0.221% / 0.578% for the Infected +count on the same runs — the fraction averages out the count's sampling +noise — at 158 ms on the GPU against 1028 ms on the four-shard CPU pool. Its +final-frame KS statistic of 0.06 is a binning artefact: the CPU bins each f64 +fraction exactly while the device bins into a calibrated 1024-bin window, so +the two step functions are compared at different resolutions.) The run-count bound is gone: an experiment larger than the device's buffers runs as sequential tiles over one shared histogram buffer, which merges itself From 910eae5e8192969efec2786cec628b177ccfa2ff Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 19:58:05 +0200 Subject: [PATCH 09/21] Describe the extra histogram row in the sizing page and stop exporting the GPU metric-spec gate --- libs/@hashintel/petrinaut-core/src/webgpu.ts | 10 +++------- .../content/simulation/gpu-histogram-sizing.mdx | 9 ++++++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu.ts b/libs/@hashintel/petrinaut-core/src/webgpu.ts index 9cdb4caf0ca..7a18769cfcf 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu.ts @@ -7,10 +7,9 @@ * the compiled artifacts (`webgpu/hir-from-artifacts.ts`), so nothing here * touches the TypeScript frontend. * - * Only the surface the app consumes is exported: the backend factory, the - * compilation report the editor renders, and the metric-spec gate the - * experiment drawer applies. Everything else in `webgpu/` is internal; tests - * import it by relative path. + * Only the surface the app consumes is exported: the backend factory and the + * compilation report the editor renders. Everything else in `webgpu/` is + * internal; tests import it by relative path. * * "The WebGPU backend" in * `libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx` covers @@ -34,6 +33,3 @@ export type { CompilationItemStatus, CompilationReport, } from "./webgpu/compilation-report"; - -export { toGpuMetricSpecs } from "./webgpu/gpu-metric-frames"; -export type { GpuMetricSpec } from "./webgpu/compile-net-shader"; diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx index f789defee72..e9fd9f32ff5 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-histogram-sizing.mdx @@ -102,9 +102,12 @@ calibrates itself instead of warning: Frame 0 is sampled on the device before the first step, like every later frame: the sample sits at the top of the frame iteration, so row `f` holds the -state after `f` steps. The histogram's last row was always empty (every run -still going takes its completed status at the frame limit, before it could be -sampled there), so the shift to sampling before the step costs no buffer. +state after `f` steps. Frame 0 reuses the row the old scheme left empty (every +run still going took its completed status at the frame limit, before it could +be sampled there), and the buffer gains one row, `frameLimit + 1` rows in all, +so row `frame_limit` holds the CPU's final frame for `completed` and `all` +metrics, matching `runner/histogram-frames.ts`'s "(frameLimit + 1) × metrics × +bins". A count past the old fixed range is therefore no longer an error, a warning, or a refusal — a 1,030-token initial marking runs with a window that covers From 8bb69988cf36c93fa9df337ca6c5a014cdac518d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 05:45:12 +0200 Subject: [PATCH 10/21] Report a metric's non-finite samples from the probe prefix instead of after every run executed --- .../src/webgpu/gpu-experiment-handle.ts | 51 ++++++++++++++----- .../gpu-experiment-handle/calibration.test.ts | 31 +++++++++++ .../gpu-experiment-handle/calibration.ts | 19 ++++++- .../metric-failure.test.ts | 48 +++++++++++++++++ .../gpu-experiment-handle/metric-failure.ts | 32 ++++++++++++ 5 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.ts diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 021348e05a4..ed70a0e9915 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -33,6 +33,7 @@ import { runUntilCalibrated, } from "./gpu-experiment-handle/calibration"; import { createFrameMerger } from "./gpu-experiment-handle/frame-merge"; +import { metricFailure } from "./gpu-experiment-handle/metric-failure"; import { deriveRunParameters } from "./gpu-experiment-handle/run-parameters"; import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames"; import { @@ -463,7 +464,21 @@ export async function createGpuMonteCarloExperiment( }, }); + /** A non-finite metric sample the probe halted runs on, reported as the full run would. */ + const metricFailureIn = ( + metricErrors: readonly number[], + runCount: number, + ): string | null => + metricFailure({ + metricIds, + metricSpecs: config.metricSpecs, + metricErrors, + runCount, + }); + let calibratedWindows: MetricWindow[] | null = null; + /** The capacity probe's halted-metric failure; run() reports it before its first attempt. */ + let probedFailure: string | null = null; if (cachedCalibration) { session.shader = cachedCalibration.shader; for (const [placeId, capacity] of cachedCalibration.capacities) { @@ -495,9 +510,14 @@ export async function createGpuMonteCarloExperiment( } calibratedWindows = probed.windows; storeCalibration(calibratedWindows); + probedFailure = metricFailureIn(probed.metricErrors, probed.probeRuns); } const run = async () => { + if (probedFailure !== null) { + fail(probedFailure); + return; + } // Blind windows (any metric without a ceiling) probe with a prefix of the // runs first, unless the capacity probe already calibrated them at // creation. @@ -511,11 +531,12 @@ export async function createGpuMonteCarloExperiment( metricIds.length > 0 && !aborted ) { + const probeRuns = probeRunCount(session.shader, config.runCount); const probe = await probeWindows({ session, windows, execute: executeAttempt, - runCount: probeRunCount(session.shader, config.runCount), + runCount: probeRuns, }); if (isDisposed()) { return; @@ -528,6 +549,14 @@ export async function createGpuMonteCarloExperiment( finish("cancelled"); return; } + const probeFailure = metricFailureIn( + probe.result.metricErrors, + probeRuns, + ); + if (probeFailure !== null) { + fail(probeFailure); + return; + } windows = probe.windows; storeCalibration(windows); } @@ -555,18 +584,14 @@ export async function createGpuMonteCarloExperiment( ); return; } - const erroredMetric = result.metricErrors.findIndex((runs) => runs > 0); - if (erroredMetric !== -1 && !result.cancelled) { - // The CPU evaluator throws on the first non-finite value and the - // experiment errors; the device halts the run instead, so the same - // failure is reported once the attempt returns. - const metricId = metricIds[erroredMetric]; - const label = - config.metricSpecs.find((spec) => spec.id === metricId)?.label ?? - metricId; - fail( - `Metric "${label}" returned a non-finite value in ${result.metricErrors[erroredMetric]} of ${config.runCount} runs, expected a finite number.`, - ); + // The CPU evaluator throws on the first non-finite value and the + // experiment errors; the device halts the run instead, so the same + // failure is reported once the attempt returns. + const runFailure = result.cancelled + ? null + : metricFailureIn(result.metricErrors, config.runCount); + if (runFailure !== null) { + fail(runFailure); return; } if (!result.cancelled) { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts index 9759cbfe5a6..039194b8fa1 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts @@ -340,9 +340,40 @@ describe("probeDerivedCapacities", () => { expect(probed).toEqual({ ok: true, windows: [{ lo: 14, stride: 1, integer: true }], + metricErrors: [], + probeRuns: 128, }); }); + it("hands the probe's halted-metric counts back with the windows, over the runs it executed", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { + ok: true, + result: outcome({ + derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }], + metricErrors: [2], + }), + }, + ]); + + const probed = await probeDerivedCapacities({ + session: current, + runCount: 10_000, + windowInputs: [{ integer: true, ceiling: null }], + placeCounts: [3], + execute, + }); + + expect(probed).toMatchObject({ + ok: true, + metricErrors: [2], + probeRuns: attempts[0]!.runCount, + }); + // The probe still calibrates: the handle reports the halt, not the probe. + expect(current.capacities).toEqual(new Map([["p", 19]])); + }); + it("hands back an abandoned probe without recompiling", async () => { const current = session({ p: 64 }); const { execute } = scripted([ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index 9e3521aa968..f9faf615ea9 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -283,7 +283,9 @@ export const slabsFromProbe = ( * can refuse cleanly and the caller falls back to the CPU: probes a small * prefix of the runs at generous slabs (growing on overflow), sizes each * place's slab from the observed maxima, and recompiles at those. The same - * probe observes the metric ranges, seeding the histogram windows. + * probe observes the metric ranges, seeding the histogram windows, and counts + * the runs a non-finite metric sample halted, which the handle reports as the + * full run would. */ export const probeDerivedCapacities = async (options: { session: CalibrationSession; @@ -297,7 +299,15 @@ export const probeDerivedCapacities = async (options: { */ stopped?: () => boolean; }): Promise< - { ok: true; windows: MetricWindow[] } | { ok: false; reason: string } + | { + ok: true; + windows: MetricWindow[]; + /** Runs halted by a non-finite metric sample, per metric, over `probeRuns` runs. */ + metricErrors: number[]; + /** The runs the probe's last attempt executed. */ + probeRuns: number; + } + | { ok: false; reason: string } > => { const { session, runCount, placeCounts, execute } = options; const stopped = options.stopped ?? (() => false); @@ -329,6 +339,9 @@ export const probeDerivedCapacities = async (options: { reason: `Probing this net's token counts kept overflowing past ${largest.toLocaleString()} tokens per place; running on the CPU, which sizes its buffers dynamically.`, }; } + // The last attempt ran at the shader still in force here, before the + // recompile at the probed slabs changes what a probe would run. + const probeRuns = probeRunCount(session.shader, runCount); const slabs = slabsFromProbe(session, probe.result, placeCounts); if (!slabs.ok) { return slabs; @@ -349,6 +362,8 @@ export const probeDerivedCapacities = async (options: { session.shader.histogramBins, PROBE_WINDOW_MARGIN, ), + metricErrors: probe.result.metricErrors, + probeRuns, }; }; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.test.ts new file mode 100644 index 00000000000..6a7491f1241 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { metricFailure } from "./metric-failure"; + +const metricIds = ["metric_infected", "metric_recovered"]; +const metricSpecs = [{ id: "metric_recovered", label: "Recovered at the end" }]; + +describe("metricFailure", () => { + it("is null while every metric stayed finite", () => { + expect( + metricFailure({ + metricIds, + metricSpecs, + metricErrors: [0, 0], + runCount: 128, + }), + ).toBeNull(); + expect( + metricFailure({ metricIds, metricSpecs, metricErrors: [], runCount: 8 }), + ).toBeNull(); + }); + + it("names the first halted metric by its label with the attempt's run count", () => { + expect( + metricFailure({ + metricIds, + metricSpecs, + metricErrors: [0, 3], + runCount: 128, + }), + ).toBe( + 'Metric "Recovered at the end" returned a non-finite value in 3 of 128 runs, expected a finite number.', + ); + }); + + it("falls back to the metric's id without a spec for it", () => { + expect( + metricFailure({ + metricIds, + metricSpecs, + metricErrors: [2, 3], + runCount: 10_000, + }), + ).toBe( + 'Metric "metric_infected" returned a non-finite value in 2 of 10000 runs, expected a finite number.', + ); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.ts new file mode 100644 index 00000000000..d5ae0095257 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/metric-failure.ts @@ -0,0 +1,32 @@ +/** + * The failure a GPU experiment reports when a metric produced a non-finite + * sample. The device halts the run and counts it per metric; the CPU + * evaluator throws on the same value, so any count fails the experiment. + * Seeds derive from the run index, so a run the probe prefix halted halts + * again in the full run: checking the probe's counts reports the failure + * after the prefix instead of after every run. + */ +export const metricFailure = ({ + metricIds, + metricSpecs, + metricErrors, + runCount, +}: { + /** The metrics in the order the device counts them. */ + metricIds: readonly string[]; + /** The specs the labels come from; a metric without one prints its id. */ + metricSpecs: readonly { id: string; label: string }[]; + /** Runs halted by a non-finite sample, per metric in `metricIds` order. */ + metricErrors: readonly number[]; + /** The runs the attempt executed, for the message's denominator. */ + runCount: number; +}): string | null => { + const erroredMetric = metricErrors.findIndex((runs) => runs > 0); + if (erroredMetric === -1) { + return null; + } + const metricId = metricIds[erroredMetric]; + const label = + metricSpecs.find((spec) => spec.id === metricId)?.label ?? metricId; + return `Metric "${label}" returned a non-finite value in ${metricErrors[erroredMetric]} of ${runCount} runs, expected a finite number.`; +}; From 23ff89864ef91ea48342029c7095b3d5f0b5e84b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 09:07:09 +0200 Subject: [PATCH 11/21] Hand a GPU attempt back as soon as a metric halts a run and report the halt ahead of an overflow --- .../src/webgpu/gpu-experiment-handle.ts | 15 ++-- .../gpu-experiment-handle/calibration.test.ts | 78 +++++++++++++++++++ .../gpu-experiment-handle/calibration.ts | 22 ++++-- .../simulation/gpu-capacity-calibration.mdx | 5 +- 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index ed70a0e9915..02f7a633d05 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -578,15 +578,10 @@ export async function createGpuMonteCarloExperiment( return; } const { result } = calibrated; - if (result.overflowRuns > 0 && !result.cancelled) { - fail( - "Token counts kept outgrowing their derived capacities even after growth; run this experiment on the CPU, which sizes its buffers dynamically.", - ); - return; - } // The CPU evaluator throws on the first non-finite value and the // experiment errors; the device halts the run instead, so the same - // failure is reported once the attempt returns. + // failure is reported once the attempt returns — ahead of an overflow + // the same attempt may carry, since the CPU would fail on the same sample. const runFailure = result.cancelled ? null : metricFailureIn(result.metricErrors, config.runCount); @@ -594,6 +589,12 @@ export async function createGpuMonteCarloExperiment( fail(runFailure); return; } + if (result.overflowRuns > 0 && !result.cancelled) { + fail( + "Token counts kept outgrowing their derived capacities even after growth; run this experiment on the CPU, which sizes its buffers dynamically.", + ); + return; + } if (!result.cancelled) { // The batch's final calibration — grown slabs, replanned windows — is // the best knowledge for the next batch on this marking. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts index 039194b8fa1..670181a3b95 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts @@ -11,6 +11,7 @@ import { runUntilCalibrated, slabsFromProbe, } from "./calibration"; +import { metricFailure } from "./metric-failure"; import type { GpuCalibration } from "../backend"; import type { CompiledNetShader } from "../compile-net-shader"; @@ -219,6 +220,40 @@ describe("runUntilCalibrated", () => { ]); }); + it("hands back an attempt a non-finite sample halted without growing or replanning", async () => { + // The same seeds halt the same run whatever the slab or the window, so + // another attempt could only repeat the failure. + const current = session({ p: 10 }); + const { execute, attempts } = scripted([ + { + ok: true, + result: outcome({ + overflowRuns: 2, + metricErrors: [1], + metricRanges: [{ min: 100, max: 163, below: 0, above: 5 }], + }), + }, + { ok: true, result: outcome() }, + ]); + + const run = await runUntilCalibrated({ + session: current, + runsFor: () => 100, + windows: [{ lo: 0, stride: 2, integer: true }], + execute, + policy: RUN_POLICY, + stopped: () => false, + }); + + expect(run).toMatchObject({ + ok: true, + result: { overflowRuns: 2, metricErrors: [1] }, + windows: [{ lo: 0, stride: 2, integer: true }], + }); + expect(attempts).toHaveLength(1); + expect(current.capacities.get("p")).toBe(10); + }); + it("stops at a cancelled or abandoned attempt without retrying", async () => { const current = session({ p: 10 }); const { execute, attempts } = scripted([ @@ -374,6 +409,49 @@ describe("probeDerivedCapacities", () => { expect(current.capacities).toEqual(new Map([["p", 19]])); }); + it("hands the halted-metric counts back when the probe also overflowed, instead of refusing for the CPU", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { + ok: true, + result: outcome({ + overflowRuns: 3, + metricErrors: [2], + derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }], + }), + }, + { ok: true, result: outcome({ overflowRuns: 3 }) }, + ]); + + const probed = await probeDerivedCapacities({ + session: current, + runCount: 10_000, + windowInputs: [{ integer: true, ceiling: null }], + placeCounts: [3], + execute, + }); + + // The CPU would fail on the same sample, so the halt is what the handle + // reports, named after the metric. + expect(attempts).toHaveLength(1); + expect(probed).toMatchObject({ + ok: true, + metricErrors: [2], + probeRuns: attempts[0]!.runCount, + }); + expect( + probed.ok && + metricFailure({ + metricIds: current.shader.metricIds, + metricSpecs: [{ id: "m0", label: "Infected" }], + metricErrors: probed.metricErrors, + runCount: probed.probeRuns, + }), + ).toBe( + `Metric "Infected" returned a non-finite value in 2 of ${attempts[0]!.runCount} runs, expected a finite number.`, + ); + }); + it("hands back an abandoned probe without recompiling", async () => { const current = session({ p: 64 }); const { execute } = scripted([ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index f9faf615ea9..85bb5d010d6 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -7,7 +7,9 @@ * slab overflow grows the slab (a recompile, capacities are baked); a window * escape replans the window (a uniform). Seeds derive from absolute run * indices, so a re-run reproduces the same trajectories: a window re-run - * cannot escape again, and slab growth is monotone. + * cannot escape again, slab growth is monotone, and a run a non-finite metric + * sample halted would halt again, so an attempt with one is handed back at + * once. * * A slab stops growing at its place's `derivedSlabCeiling`; an attempt that * still overflows there is handed back as it stands, and the caller sends the @@ -170,10 +172,15 @@ const grownSlabs = ( ); }; +/** Whether a non-finite metric sample halted any of the attempt's runs. */ +export const anyMetricHalted = (result: GpuExperimentResult): boolean => + result.metricErrors.some((runs) => runs > 0); + /** * Runs an attempt until neither a slab overflow nor a window escape remains, - * or the policy's retry budget runs out. Returns the last attempt's result — - * a remaining overflow is the caller's to report — with the windows it ran at. + * the policy's retry budget runs out, or a metric halts a run. Returns the + * last attempt's result — a remaining overflow or a halted run is the + * caller's to report — with the windows it ran at. */ export const runUntilCalibrated = async (options: { session: CalibrationSession; @@ -203,6 +210,10 @@ export const runUntilCalibrated = async (options: { if (result.cancelled || stopped()) { return { ok: true, result, windows }; } + // The same seeds halt the same run whatever the slab or the window. + if (anyMetricHalted(result)) { + return { ok: true, result, windows }; + } if (result.overflowRuns > 0) { if (growths >= policy.maxSlabGrowths) { return { ok: true, result, windows }; @@ -285,7 +296,8 @@ export const slabsFromProbe = ( * place's slab from the observed maxima, and recompiles at those. The same * probe observes the metric ranges, seeding the histogram windows, and counts * the runs a non-finite metric sample halted, which the handle reports as the - * full run would. + * full run would — ahead of an overflow the same probe may show, since the + * CPU would fail on the same sample. */ export const probeDerivedCapacities = async (options: { session: CalibrationSession; @@ -332,7 +344,7 @@ export const probeDerivedCapacities = async (options: { reason: "The capacity probe was abandoned before it finished.", }; } - if (probe.result.overflowRuns > 0) { + if (probe.result.overflowRuns > 0 && !anyMetricHalted(probe.result)) { const largest = Math.max(0, ...session.capacities.values()); return { ok: false, diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index 0c4a1ae6d69..dedcd7965f9 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -57,7 +57,10 @@ never blocks a firing over it. discards the attempt. The handle doubles the slabs, recompiles, and re-runs. Seeds derive from absolute run indices, so a re-run reproduces the same trajectories and each retry strictly grows — the loop converges - or hands the experiment to the CPU. + or hands the experiment to the CPU. A run a non-finite metric sample + halted would halt again on the same seeds, so an attempt with one is + handed back at once and the handle reports the metric, ahead of any + overflow the attempt also carries. Derived and declared capacities keep distinct semantics, recorded as `capacitySource` on the profile: declared blocks firings (CPU parity), From 3816a6c599aad40d66e15efa179388372e55c5c5 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 10:04:29 +0200 Subject: [PATCH 12/21] Report a GPU probe a metric halted before sizing its slabs and keep its calibration out of the cache --- .../src/webgpu/gpu-experiment-handle.ts | 9 ++++- .../gpu-experiment-handle/calibration.test.ts | 34 ++++++++++++++-- .../gpu-experiment-handle/calibration.ts | 40 +++++++++++-------- .../simulation/gpu-capacity-calibration.mdx | 5 ++- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 02f7a633d05..d59aab509dd 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -508,9 +508,14 @@ export async function createGpuMonteCarloExperiment( reason: probed.reason, }; } - calibratedWindows = probed.windows; - storeCalibration(calibratedWindows); probedFailure = metricFailureIn(probed.metricErrors, probed.probeRuns); + calibratedWindows = probed.windows; + // A halted probe's calibration is not stored: the next batch on this + // marking would adopt it, skip its probe and meet the halt only after a + // full attempt. + if (probedFailure === null) { + storeCalibration(calibratedWindows); + } } const run = async () => { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts index 670181a3b95..47d72b2ff07 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts @@ -380,7 +380,7 @@ describe("probeDerivedCapacities", () => { }); }); - it("hands the probe's halted-metric counts back with the windows, over the runs it executed", async () => { + it("hands the probe's halted-metric counts back over the runs it executed, without sizing or recompiling", async () => { const current = session({ p: 64 }); const { execute, attempts } = scripted([ { @@ -405,8 +405,10 @@ describe("probeDerivedCapacities", () => { metricErrors: [2], probeRuns: attempts[0]!.runCount, }); - // The probe still calibrates: the handle reports the halt, not the probe. - expect(current.capacities).toEqual(new Map([["p", 19]])); + // The handle reports the halt without running, so the probed slabs are + // never sized or compiled at. + expect(current.capacities).toEqual(new Map([["p", 64]])); + expect(current.shader.stateWordsPerRun).toBe(4 + 64 * 2); }); it("hands the halted-metric counts back when the probe also overflowed, instead of refusing for the CPU", async () => { @@ -452,6 +454,32 @@ describe("probeDerivedCapacities", () => { ); }); + it("hands the halted-metric counts back ahead of an arena refusal", async () => { + const current = session({ p: 64 }); + const heavyTail = outcome({ + derivedPlaceMaxes: [{ max: 20_000, meanRunMax: 10 }], + metricErrors: [1], + }); + expect(slabsFromProbe(current, heavyTail, [3])).toMatchObject({ + ok: false, + reason: /outlier runs/, + }); + const { execute } = scripted([{ ok: true, result: heavyTail }]); + + const probed = await probeDerivedCapacities({ + session: current, + runCount: 10_000, + windowInputs: [{ integer: true, ceiling: null }], + placeCounts: [3], + execute, + }); + + // The CPU would fail on the same sample, so the halt is what the handle + // reports — not the arena case that would send the experiment there. + expect(probed).toMatchObject({ ok: true, metricErrors: [1] }); + expect(current.capacities).toEqual(new Map([["p", 64]])); + }); + it("hands back an abandoned probe without recompiling", async () => { const current = session({ p: 64 }); const { execute } = scripted([ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index 85bb5d010d6..b13e01444fa 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -296,8 +296,10 @@ export const slabsFromProbe = ( * place's slab from the observed maxima, and recompiles at those. The same * probe observes the metric ranges, seeding the histogram windows, and counts * the runs a non-finite metric sample halted, which the handle reports as the - * full run would — ahead of an overflow the same probe may show, since the - * CPU would fail on the same sample. + * full run would. A halted probe is handed back before its slabs are sized: + * the CPU would fail on the same sample, so neither an overflow nor the arena + * case the same probe shows pre-empts the halt, and nothing is recompiled for + * a run the handle will not start. */ export const probeDerivedCapacities = async (options: { session: CalibrationSession; @@ -344,16 +346,30 @@ export const probeDerivedCapacities = async (options: { reason: "The capacity probe was abandoned before it finished.", }; } - if (probe.result.overflowRuns > 0 && !anyMetricHalted(probe.result)) { + // The last attempt ran at the shader still in force here, before the + // recompile at the probed slabs changes what a probe would run. + const probeRuns = probeRunCount(session.shader, runCount); + const observed = () => ({ + ok: true as const, + windows: windowsFromObserved( + probe.result.metricRanges, + probeWindows, + session.shader.histogramBins, + PROBE_WINDOW_MARGIN, + ), + metricErrors: probe.result.metricErrors, + probeRuns, + }); + if (anyMetricHalted(probe.result)) { + return observed(); + } + if (probe.result.overflowRuns > 0) { const largest = Math.max(0, ...session.capacities.values()); return { ok: false, reason: `Probing this net's token counts kept overflowing past ${largest.toLocaleString()} tokens per place; running on the CPU, which sizes its buffers dynamically.`, }; } - // The last attempt ran at the shader still in force here, before the - // recompile at the probed slabs changes what a probe would run. - const probeRuns = probeRunCount(session.shader, runCount); const slabs = slabsFromProbe(session, probe.result, placeCounts); if (!slabs.ok) { return slabs; @@ -366,17 +382,7 @@ export const probeDerivedCapacities = async (options: { if (!recompiled.ok) { return recompiled; } - return { - ok: true, - windows: windowsFromObserved( - probe.result.metricRanges, - probeWindows, - session.shader.histogramBins, - PROBE_WINDOW_MARGIN, - ), - metricErrors: probe.result.metricErrors, - probeRuns, - }; + return observed(); }; /** diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index dedcd7965f9..a8cb9025b00 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -60,7 +60,10 @@ never blocks a firing over it. or hands the experiment to the CPU. A run a non-finite metric sample halted would halt again on the same seeds, so an attempt with one is handed back at once and the handle reports the metric, ahead of any - overflow the attempt also carries. + overflow the attempt also carries. A probe a metric halted is handed back + before its slabs are sized, so neither an overflow nor the arena case + pre-empts the halt, and its calibration is not kept for the next batch on + the marking. Derived and declared capacities keep distinct semantics, recorded as `capacitySource` on the profile: declared blocks firings (CPU parity), From 8e777080201264c78943b47630906b66f432552d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 04:04:07 +0200 Subject: [PATCH 13/21] Share the objective-history chart and give it a style, a pinned x edge and dividers --- .../optimization-full-view.test.tsx | 2 +- .../optimizations/study-results.tsx | 2 +- .../study-results/objective-history-card.tsx | 42 +++ .../study-results/objective-history-chart.tsx | 225 -------------- .../study-results/steps-table.tsx | 2 +- .../view-optimization-drawer.test.tsx | 2 +- .../shared/infeasible-color.ts | 0 .../shared/objective-history-chart.tsx | 279 ++++++++++++++++++ .../objective-history-data.test.ts | 0 .../objective-history-data.ts | 0 .../content/ui/optimizations-tab.mdx | 21 +- 11 files changed, 338 insertions(+), 237 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-chart.tsx rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/{optimizations/study-results => }/shared/infeasible-color.ts (100%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/{optimizations/study-results => shared/objective-history-chart}/objective-history-data.test.ts (100%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/{optimizations/study-results => shared/objective-history-chart}/objective-history-data.ts (100%) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx index 25fc6a76a99..c4d3657a967 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx @@ -43,7 +43,7 @@ vi.mock("./optimization-surface", () => ({ })); // uPlot cannot mount in jsdom; the cards around the charts are real. -vi.mock("./study-results/objective-history-chart", () => +vi.mock("./study-results/objective-history-card", () => import("../shared/metric-timeline-test-stubs").then((stubs) => stubs.mockObjectiveHistoryCardModule(), ), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx index 20687c4d504..2b7e9d177d8 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx @@ -63,7 +63,7 @@ import { OptimizationSurface, } from "./optimization-surface"; import { ConstraintSummaryCard } from "./study-results/constraint-summary"; -import { ObjectiveHistoryCard } from "./study-results/objective-history-chart"; +import { ObjectiveHistoryCard } from "./study-results/objective-history-card"; import { OptimizationNavigator, OptimizationNavigatorStatus, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx new file mode 100644 index 00000000000..9e6fc7ff871 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx @@ -0,0 +1,42 @@ +/** + * The study drawer's objective by step: the shared chart in a card titled + * after the objective metric, the points built from the study's own trials. + */ +import { ChartCard, type ChartCardTone } from "../../shared/chart-card"; +import { + buildObjectiveHistory, + ObjectiveHistoryChart, +} from "../../shared/objective-history-chart"; +import { objectiveMetricName } from "../../shared/study-labels"; + +import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; + +export const ObjectiveHistoryCard = ({ + optimization, + plotHeight, + tone, +}: { + optimization: Pick< + OptimizationRecord, + "trials" | "input" | "best" | "completedTrials" + >; + plotHeight: number; + /** How the card reads: `paused` while the study is paused. */ + tone?: ChartCardTone; +}) => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-chart.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-chart.tsx deleted file mode 100644 index 012fb146114..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-chart.tsx +++ /dev/null @@ -1,225 +0,0 @@ -/** - * The objective over the study's steps: every step's value as a dot, the - * best so far as a step line over them, in a card of fixed height. A port - * of Optuna's `plot_optimization_history`. - */ -import { useEffect, useRef } from "react"; -import uPlot from "uplot"; - -import { css } from "@hashintel/ds-helpers/css"; -import "uplot/dist/uPlot.min.css"; - -import { useElementSize } from "../../../../../../../react/hooks/use-element-size"; -import { ChartCard, type ChartCardTone } from "../../shared/chart-card"; -import { objectiveMetricName } from "../../shared/study-labels"; -import { - buildObjectiveHistory, - toObjectiveHistoryData, -} from "./objective-history-data"; - -import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; - -const UPlot = uPlot; - -const STEP_COLOR = "#9ca3af"; -const BEST_COLOR = "#2563eb"; - -const frameStyle = css({ - position: "relative", - width: "full", - minWidth: "[0]", -}); - -const chartStyle = css({ - width: "full", - height: "full", - minWidth: "[0]", -}); - -const waitingStyle = css({ - position: "absolute", - inset: "[0]", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: "xs", - color: "neutral.s70", - pointerEvents: "none", -}); - -const stepIncrements = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1_000]; - -const tickFormat = new Intl.NumberFormat("en-US", { - maximumSignificantDigits: 4, -}); - -const chartOptions = ({ - width, - height, -}: { - width: number; - height: number; -}): uPlot.Options => ({ - width, - height, - pxAlign: false, - padding: [8, 8, 0, null], - cursor: { - drag: { x: false, y: false, setScale: false }, - lock: true, - }, - legend: { show: false }, - scales: { - // Half a step of air on each side, so the first and last dots are whole. - x: { - time: false, - range: (_u, min, max) => [ - Math.min(min, 1) - 0.5, - Math.max(max, min + 1) + 0.5, - ], - }, - y: { - range: (_u, min, max) => { - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return [0, 1]; - } - const padding = - min === max ? Math.max(1, Math.abs(max) * 0.05) : (max - min) * 0.08; - return [min - padding, max + padding]; - }, - }, - }, - // uPlot mutates its axis options, so each instance gets its own. - axes: [ - { - show: true, - side: 2, - size: 26, - font: "10px system-ui", - stroke: "#475569", - grid: { stroke: "#f3f4f6", width: 1 }, - ticks: { stroke: "#cbd5e1", width: 1, size: 6 }, - incrs: stepIncrements, - values: (_u, values) => - values.map((value) => (Number.isInteger(value) ? String(value) : "")), - }, - { - show: true, - size: 54, - font: "10px system-ui", - stroke: "#999", - grid: { stroke: "#f3f4f6", width: 1, dash: [4, 4] }, - ticks: { stroke: "#e5e7eb", width: 1 }, - values: (_u, values) => values.map((value) => tickFormat.format(value)), - }, - ], - series: [ - {}, - { - label: "step", - stroke: STEP_COLOR, - // Dots only: the line between steps would suggest an order that is - // not there. - paths: () => null, - points: { show: true, size: 6, fill: STEP_COLOR, stroke: STEP_COLOR }, - }, - { - label: "best so far", - stroke: BEST_COLOR, - width: 2, - paths: UPlot.paths.stepped?.({ align: 1 }), - points: { show: false }, - }, - ], -}); - -const ObjectiveHistoryChart = ({ - optimization, - plotHeight, -}: { - optimization: Pick; - /** The plot's height in pixels; the component is exactly this tall. */ - plotHeight: number; -}) => { - const chartRootRef = useRef(null); - const size = useElementSize(chartRootRef); - const plotRef = useRef(null); - const points = buildObjectiveHistory( - optimization.trials, - optimization.input.objective.direction, - ); - const data = toObjectiveHistoryData(points); - const width = size?.width ?? 0; - const hasWidth = width > 0; - - // The plot lives as long as the root has a width; a resize is pushed into - // it below rather than rebuilding it, so a drawer drag keeps the canvas, - // the axes and the cursor. - useEffect(() => { - const root = chartRootRef.current; - if (!root || !hasWidth) { - return; - } - const plot = new UPlot( - chartOptions({ width: root.clientWidth, height: plotHeight }), - [[], [], []] as uPlot.AlignedData, - root, - ); - plotRef.current = plot; - return () => { - plotRef.current = null; - plot.destroy(); - }; - }, [hasWidth, plotHeight]); - - useEffect(() => { - plotRef.current?.setSize({ width, height: plotHeight }); - }, [width, plotHeight]); - - // The data is applied in its own effect so a new step redraws the plot - // without recreating it, and a freshly created plot picks it up too. - useEffect(() => { - plotRef.current?.setData(data); - }, [data, hasWidth]); - - return ( -
-
- {points.length === 0 ? ( - Waiting for the first step - ) : null} -
- ); -}; - -/** The chart in its card, titled after the objective metric. */ -export const ObjectiveHistoryCard = ({ - optimization, - plotHeight, - tone, -}: { - optimization: Pick< - OptimizationRecord, - "trials" | "input" | "best" | "completedTrials" - >; - plotHeight: number; - /** How the card reads: `paused` while the study is paused. */ - tone?: ChartCardTone; -}) => ( - - - -); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/steps-table.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/steps-table.tsx index 1e7de59ba32..c568cd6b45b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/steps-table.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/steps-table.tsx @@ -16,7 +16,7 @@ import { } from "../../../../../../../react/optimizations/constraint-rates"; import { Table, type TableColumn } from "../../../../../../components/table"; import { formatNumber, formatParameters } from "../../shared/format-value"; -import { INFEASIBLE_COLOR } from "./shared/infeasible-color"; +import { INFEASIBLE_COLOR } from "../../shared/infeasible-color"; import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index dafb7406d3b..bea2fbf29d2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -161,7 +161,7 @@ vi.mock("./optimization-surface", () => ({ })); // uPlot cannot mount in jsdom; the card around the objective history is real. -vi.mock("./study-results/objective-history-chart", () => +vi.mock("./study-results/objective-history-card", () => import("../shared/metric-timeline-test-stubs").then((stubs) => stubs.mockObjectiveHistoryCardModule(), ), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/shared/infeasible-color.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/infeasible-color.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/shared/infeasible-color.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/infeasible-color.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx new file mode 100644 index 00000000000..bcca47551fd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx @@ -0,0 +1,279 @@ +/** + * The objective over a study's steps: every step's value as a dot, the best + * so far as a step line over them, at a fixed height. A port of Optuna's + * `plot_optimization_history`, drawn from points a caller builds with + * `buildObjectiveHistory`, so one chart serves the study drawer's card and + * the sweep's strip: the palette and sizes come in as a style, the x axis + * can be pinned to a right edge the data has not reached yet, and dashed + * dividers can mark where one study ended and the next began. + */ +import { useEffect, useRef } from "react"; +import uPlot from "uplot"; + +import { css } from "@hashintel/ds-helpers/css"; +import "uplot/dist/uPlot.min.css"; + +import { useElementSize } from "../../../../../../react/hooks/use-element-size"; +import { + type ObjectiveHistoryPoint, + toObjectiveHistoryData, +} from "./objective-history-chart/objective-history-data"; + +export { + buildObjectiveHistory, + type ObjectiveHistoryPoint, +} from "./objective-history-chart/objective-history-data"; + +const UPlot = uPlot; + +/** The canvas colours and sizes; raw hex, since uPlot paints a canvas the tokens cannot reach. */ +export type ObjectiveHistoryStyle = { + step: { fill: string; stroke: string; size: number }; + best: string; + axisText: { x: string; y: string }; + grid: string; + ticks: { x: string; y: string }; + divider: string; + /** The x axis's and the y axis's reserved size in pixels. */ + axisSize: { x: number; y: number }; + /** Air above the topmost dot, in pixels. */ + paddingTop: number; +}; + +/** The study drawer's look: grey dots, a blue best line. */ +export const defaultObjectiveHistoryStyle: ObjectiveHistoryStyle = { + step: { fill: "#9ca3af", stroke: "#9ca3af", size: 6 }, + best: "#2563eb", + axisText: { x: "#475569", y: "#999" }, + grid: "#f3f4f6", + ticks: { x: "#cbd5e1", y: "#e5e7eb" }, + divider: "#e5e7eb", + axisSize: { x: 26, y: 54 }, + paddingTop: 8, +}; + +const frameStyle = css({ + position: "relative", + width: "full", + minWidth: "[0]", +}); + +const chartStyle = css({ + width: "full", + height: "full", + minWidth: "[0]", +}); + +const waitingStyle = css({ + position: "absolute", + inset: "[0]", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "xs", + color: "neutral.s70", + pointerEvents: "none", +}); + +const stepIncrements = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1_000]; + +const tickFormat = new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 4, +}); + +/** Paints a dashed vertical line half a step before each divider step, over the plot area. */ +const drawDividers = ( + plot: uPlot, + dividers: readonly number[], + color: string, +): void => { + if (dividers.length === 0) { + return; + } + const { ctx, bbox } = plot; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + for (const step of dividers) { + const x = plot.valToPos(step - 0.5, "x", true); + ctx.beginPath(); + ctx.moveTo(x, bbox.top); + ctx.lineTo(x, bbox.top + bbox.height); + ctx.stroke(); + } + ctx.restore(); +}; + +const chartOptions = ({ + width, + height, + style, + xMax, + dividers, +}: { + width: number; + height: number; + style: ObjectiveHistoryStyle; + xMax: number | undefined; + dividers: readonly number[]; +}): uPlot.Options => ({ + width, + height, + pxAlign: false, + padding: [style.paddingTop, 8, 0, null], + cursor: { + drag: { x: false, y: false, setScale: false }, + lock: true, + }, + legend: { show: false }, + scales: { + // Half a step of air on each side, so the first and last dots are whole; + // a pinned right edge holds until the steps reach it. + x: { + time: false, + range: (_u, min, max) => [ + Math.min(min, 1) - 0.5, + Math.max(xMax ?? max, min + 1) + 0.5, + ], + }, + y: { + range: (_u, min, max) => { + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return [0, 1]; + } + const padding = + min === max ? Math.max(1, Math.abs(max) * 0.05) : (max - min) * 0.08; + return [min - padding, max + padding]; + }, + }, + }, + // uPlot mutates its axis options, so each instance gets its own. + axes: [ + { + show: true, + side: 2, + size: style.axisSize.x, + font: "10px system-ui", + stroke: style.axisText.x, + grid: { stroke: style.grid, width: 1 }, + ticks: { stroke: style.ticks.x, width: 1, size: 6 }, + incrs: stepIncrements, + values: (_u, values) => + values.map((value) => (Number.isInteger(value) ? String(value) : "")), + }, + { + show: true, + size: style.axisSize.y, + font: "10px system-ui", + stroke: style.axisText.y, + grid: { stroke: style.grid, width: 1, dash: [4, 4] }, + ticks: { stroke: style.ticks.y, width: 1 }, + values: (_u, values) => values.map((value) => tickFormat.format(value)), + }, + ], + series: [ + {}, + { + label: "step", + stroke: style.step.stroke, + // Dots only: the line between steps would suggest an order that is + // not there. + paths: () => null, + points: { + show: true, + size: style.step.size, + fill: style.step.fill, + stroke: style.step.stroke, + }, + }, + { + label: "best so far", + stroke: style.best, + width: 2, + paths: UPlot.paths.stepped?.({ align: 1 }), + points: { show: false }, + }, + ], + hooks: { + draw: [(plot) => drawDividers(plot, dividers, style.divider)], + }, +}); + +const noDividers: readonly number[] = []; + +export const ObjectiveHistoryChart = ({ + points, + plotHeight, + style = defaultObjectiveHistoryStyle, + xMax, + dividers = noDividers, +}: { + points: readonly ObjectiveHistoryPoint[]; + /** The plot's height in pixels; the component is exactly this tall. */ + plotHeight: number; + style?: ObjectiveHistoryStyle; + /** Pins the x axis's right edge; without it the axis follows the last step. */ + xMax?: number; + /** Steps a dashed vertical line is drawn before: where a new study began. */ + dividers?: readonly number[]; +}) => { + const chartRootRef = useRef(null); + const size = useElementSize(chartRootRef); + const plotRef = useRef(null); + const data = toObjectiveHistoryData(points); + const width = size?.width ?? 0; + const hasWidth = width > 0; + // The dividers reach the plot through its options; a change of them (a + // further study) rebuilds it once, never per step. + const dividersKey = dividers.join(","); + + // The plot lives as long as the root has a width; a resize is pushed into + // it below rather than rebuilding it, so a drawer drag keeps the canvas, + // the axes and the cursor. + useEffect(() => { + const root = chartRootRef.current; + if (!root || !hasWidth) { + return; + } + const plot = new UPlot( + chartOptions({ + width: root.clientWidth, + height: plotHeight, + style, + xMax, + dividers: dividersKey === "" ? [] : dividersKey.split(",").map(Number), + }), + [[], [], []] as uPlot.AlignedData, + root, + ); + plotRef.current = plot; + return () => { + plotRef.current = null; + plot.destroy(); + }; + }, [hasWidth, plotHeight, style, xMax, dividersKey]); + + useEffect(() => { + plotRef.current?.setSize({ width, height: plotHeight }); + }, [width, plotHeight]); + + // The data is applied in its own effect so a new step redraws the plot + // without recreating it, and a freshly created plot picks it up too. + useEffect(() => { + plotRef.current?.setData(data); + }, [data, hasWidth]); + + return ( +
+
+ {points.length === 0 ? ( + Waiting for the first step + ) : null} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-data.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-data.test.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.test.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-data.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-data.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.ts diff --git a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx index cb82b89fa5d..0e6038a7548 100644 --- a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx +++ b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx @@ -109,17 +109,22 @@ evaluated here receives importances, so a remote study has no Sensitivity analysis card rather than an empty one. Nothing mounts mid-stream: the frame's zero-layout-shift contract holds on the remote path as on the connected one. -`ObjectiveHistoryCard` (`study-results/objective-history-chart.tsx`) is the -objective by step: `buildObjectiveHistory` (`study-results/objective-history-data.ts`) +`ObjectiveHistoryCard` (`study-results/objective-history-card.tsx`) is the +objective by step, the shared `ObjectiveHistoryChart` +(`SimulateView/shared/objective-history-chart.tsx`) in a card: +`buildObjectiveHistory` (`shared/objective-history-chart/objective-history-data.ts`) orders the trials by step and threads the best so far through the completed ones, `toObjectiveHistoryData` lays them out as uPlot aligned data (`[steps, objectives, bestSoFar]`), and the chart draws the objectives as -dots and the best as a stepped line. `trialFeasibility` reads a point's -feasibility off the trial event's `constraints`: `unknown` without results, -`infeasible` for a draw pruned by a parameter constraint. A pruned step has -no objective, so it draws nothing, and an `infeasible` step never becomes the -best so far; the steps table marks its state in `INFEASIBLE_COLOR` -(`study-results/shared/infeasible-color.ts`, Optuna's grey). +dots and the best as a stepped line, in the palette and sizes its +`ObjectiveHistoryStyle` names, with an optional pinned right edge (`xMax`) +and dashed `dividers` between studies for the sweep's strip. +`trialFeasibility` reads a point's feasibility off the trial event's +`constraints`: `unknown` without results, `infeasible` for a draw pruned by a +parameter constraint. A pruned step has no objective, so it draws nothing, +and an `infeasible` step never becomes the best so far; the steps table marks +its state in `INFEASIBLE_COLOR` (`SimulateView/shared/infeasible-color.ts`, +Optuna's grey). A study with constraints reports them through `react/optimizations/constraint-rates.ts`, derived per render from From a9ccbee72c7510c9f4d0ce0845d27f377104b42d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 04:16:48 +0200 Subject: [PATCH 14/21] Draw every sweep study's objective by step under the Parameters sliders --- .changeset/sweep-objective-strip.md | 5 + libs/@hashintel/petrinaut/docs/experiments.md | 2 + .../@hashintel/petrinaut/docs/optimization.md | 4 +- .../experiments/experiment-results.test.tsx | 91 +++++++- .../experiments/experiment-results.tsx | 17 +- .../experiments/sweep-objective-strip.tsx | 198 ++++++++++++++++++ .../sweep-objective-history.test.ts | 131 ++++++++++++ .../sweep-objective-history.ts | 66 ++++++ .../experiments/sweep-optimizer.ts | 23 +- .../view-experiment-drawer.stories.tsx | 89 ++++++-- .../view-experiment-drawer.test.tsx | 72 ++++++- .../optimizations/study-results.tsx | 2 + .../SimulateView/shared/results-model.ts | 2 + .../SimulateView/shared/results-view.tsx | 1 + 14 files changed, 670 insertions(+), 33 deletions(-) create mode 100644 .changeset/sweep-objective-strip.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts diff --git a/.changeset/sweep-objective-strip.md b/.changeset/sweep-objective-strip.md new file mode 100644 index 00000000000..c66aa2444ec --- /dev/null +++ b/.changeset/sweep-objective-strip.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Draws a sweep's optimization objective by step under its Parameters sliders. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 9702de9f530..ef2f3e6a5e5 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -77,6 +77,8 @@ Every selection uses the same seed sequence (common random numbers), and a run's With the [in-browser optimizer](optimization.md#running-in-the-browser) turned on, the **Parameters** card's header carries one purple **Optimize** button. It asks which metric to optimize, whether to **Maximize** or **Minimize** it, and how many steps to take, then hands the sliders to the optimizer: the card turns purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls lock and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes. Each step computes eight runs at its point before the optimizer reads the metric's value there; the **N computing** chip lists that batch as **Step N**. While the study drives the sweep the same button reads **Stop**: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the next **Optimize** or the experiment's removal. **Cancel** in the drawer's footer stops the study as well as the sweep. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's drawer is its home, and removing the experiment removes it. +From the first **Optimize** on, an **Objective by step** strip sits under the sliders: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A further **Optimize** continues the same axis after the previous steps, with a dashed line where it began and its own best-so-far line, so one strip holds every optimization of the sweep. Before any **Optimize** the card shows no strip. + #### The surface view A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** card: a contour plot of one metric's final value over two parameters you pick, drawn from the points the sweep has computed. It starts empty. Every point you visit — by moving the sliders to a point, by clicking the plot, or through the optimizer — lands as a dot with its value, the field is interpolated between the dots once there are three, and the point being computed is a ring; its value joins the field once its batch completes. Points computed at other values of the parameters not shown are drawn too, projected onto the two you picked. The **X** and **Y** pickers sit in the row under the plot and the **Metric** picker in the row beneath them; every metric is measured at every point, so switching the shown metric repaints from what was already computed. The line under the card's title counts the points and what computes -- **computing the selected point** or **sampling across the selected ranges**, with the runs so far -- or, mid-drag, the values under the pointer. **The surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release every swept parameter collapses to a point -- the two shown at the place you released, the others at the middle of their current range -- which then computes. A dark ring marks where the navigator sits. While the optimizer drives the sweep the card is read-only: the plot only displays under a not-allowed cursor, the **X**, **Y** and **Metric** pickers lock, a purple **Read-only** mark sits beside them, and between two steps the line says the optimizer is choosing the next point. A cancelled sweep locks the same way, with the mark in grey. diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index 03082ee1af2..4c1a9f64697 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -176,7 +176,9 @@ scrolls on its own. Every card keeps its height whatever it shows. The **Objective by step** card draws every step's objective value as a dot over the step number, with the best so far as a line stepping up (or down, for a minimized objective) through them. Pruned and failed steps have no dot. -The line under the title counts the completed steps. +The line under the title counts the completed steps. A sweep optimized from +its own drawer draws the same chart, in purple, under its sliders (see +[Optimizing a sweep](experiments.md#optimizing-a-sweep)). The steps table sits at the bottom, newest steps first, each with its parameters, objective value and a state mark (complete, pruned or failed). It diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.test.tsx index 593f5f40006..c105c9c80a4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.test.tsx @@ -28,8 +28,14 @@ vi.mock("./experiment-metric-timeline", () => ({ MetricViewMenu: () => null, })); +// The strip draws through uPlot; the model hands it the studies and no more. +vi.mock("./sweep-objective-strip", () => ({ + SweepObjectiveStrip: () => null, +})); + const idleOptimizer: ExperimentResultsDependencies["optimizer"] = { available: false, + studies: [], study: null, driving: null, start: () => Promise.resolve(), @@ -173,6 +179,7 @@ describe("experimentResultsModel for a running sweep", () => { more: null, tone: "default", }); + expect(result.bands[0]!.below).toBeNull(); expect(isValidElement(result.surface)).toBe(true); expect(result.metrics).toMatchObject({ key: sweep.id, @@ -353,7 +360,13 @@ describe("experimentResultsModel with the optimizer", () => { const driving = { step: 5, total: 30 }; /** The record between two steps: the session idles until the next point. */ const betweenSteps = model(idleSweep, { - optimizer: { ...idleOptimizer, available: true, study, driving }, + optimizer: { + ...idleOptimizer, + available: true, + study, + studies: [study], + driving, + }, }); it("offers the Optimize control on the Parameters card when the optimizer is available", () => { @@ -366,7 +379,13 @@ describe("experimentResultsModel with the optimizer", () => { it("turns the Parameters card and the surface purple while a study drives the sweep", () => { const result = model(sweep, { - optimizer: { ...idleOptimizer, available: true, study, driving }, + optimizer: { + ...idleOptimizer, + available: true, + study, + studies: [study], + driving, + }, }); expect(result.bands[0]!.tone).toBe("optimizing"); expect(isValidElement(result.bands[0]!.trailing)).toBe(true); @@ -398,7 +417,15 @@ describe("experimentResultsModel with the optimizer", () => { { id: 7, kind: "selection", runCount: 8, completedRuns: 3 }, ], }, - { optimizer: { ...idleOptimizer, available: true, study, driving } }, + { + optimizer: { + ...idleOptimizer, + available: true, + study, + studies: [study], + driving, + }, + }, ); expect(result.header.activity?.map((batch) => batch.label)).toEqual([ "Step 5", @@ -409,12 +436,45 @@ describe("experimentResultsModel with the optimizer", () => { }); }); + it("puts the objective strip under the sliders from the first study on, driving or settled", () => { + expect( + model(sweep, { optimizer: { ...idleOptimizer, available: true } }) + .bands[0]!.below, + ).toBeNull(); + const strip = propsOf<{ studies: unknown[]; driving: boolean }>( + betweenSteps.bands[0]!.below, + ); + expect(isValidElement(betweenSteps.bands[0]!.below)).toBe(true); + expect(strip.studies).toHaveLength(1); + expect(strip.driving).toBe(true); + const settled = { ...study, status: "complete" as const }; + expect( + propsOf<{ driving: boolean }>( + model(idleSweep, { + optimizer: { + ...idleOptimizer, + available: true, + study: settled, + studies: [settled], + }, + }).bands[0]!.below, + ).driving, + ).toBe(false); + }); + it("stops the study before cancelling the sweep", () => { const stop = vi.fn(); const cancelExperiment = vi.fn(); const result = model(sweep, { actions: { ...dependencies.actions, cancelExperiment }, - optimizer: { ...idleOptimizer, available: true, study, driving, stop }, + optimizer: { + ...idleOptimizer, + available: true, + study, + studies: [study], + driving, + stop, + }, }); propsOf<{ onClick: () => void }>(footerButtonsOf(result)[0]).onClick(); expect(stop).toHaveBeenCalledOnce(); @@ -431,7 +491,12 @@ describe("experimentResultsModel with the optimizer", () => { completedTrials: 29, }; const result = model(idleSweep, { - optimizer: { ...idleOptimizer, available: true, study: finished }, + optimizer: { + ...idleOptimizer, + available: true, + study: finished, + studies: [finished], + }, }); expect(result.header.status.label).toBe("Idle"); expect(result.bands[0]!.tone).toBe("default"); @@ -453,7 +518,12 @@ describe("experimentResultsModel with the optimizer", () => { error: "The in-browser optimizer could not start", }; const result = model(idleSweep, { - optimizer: { ...idleOptimizer, available: true, study: failed }, + optimizer: { + ...idleOptimizer, + available: true, + study: failed, + studies: [failed], + }, }); expect(result.header.note).toEqual({ content: "The in-browser optimizer could not start", @@ -462,7 +532,14 @@ describe("experimentResultsModel with the optimizer", () => { expect( model( { ...idleSweep, error: "worker crashed" }, - { optimizer: { ...idleOptimizer, available: true, study: failed } }, + { + optimizer: { + ...idleOptimizer, + available: true, + study: failed, + studies: [failed], + }, + }, ).header.note?.content, ).toBe("worker crashed"); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.tsx index 68c8c16bf6d..177cd8ad4ca 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-results.tsx @@ -3,8 +3,9 @@ * one-line title, the status and the stat columns (errors and simulated time * for both kinds; runs and wall-clock time for a plain experiment, the * selection's sampling for a sweep), the computing chip and the compute - * badge, the Parameters card with its optimizer control and the surface for - * a sweep, one metric card per configured metric, and Remove, Cancel and + * badge, the Parameters card with its optimizer control and, once a study + * ran, the objective strip under its sliders, the surface for a sweep, one + * metric card per configured metric, and Remove, Cancel and * Close in the footer. While a study drives a sweep the header reads from * the study: Optimizing, its step as the progress, its step on the batch. */ @@ -26,6 +27,7 @@ import { formatCount, formatFixed } from "../shared/format-value"; import { METRIC_PLOT_HEIGHT, type MetricTile } from "../shared/metric-tiles"; import { ElapsedStat } from "./experiment-results/elapsed-stat"; import { SweepNavigator } from "./sweep-navigator"; +import { SweepObjectiveStrip } from "./sweep-objective-strip"; import { SweepOptimizeControl } from "./sweep-optimize-control"; import { type SweepOptimizer, @@ -245,7 +247,7 @@ export const experimentResultsModel = ( // computes afresh — and a sweep never completes. const locked = following !== null || experiment.status === "cancelled"; const tone: ChartCardTone = following ? "optimizing" : "default"; - const { study } = optimizer; + const { study, studies } = optimizer; return { header: { @@ -310,6 +312,15 @@ export const experimentResultsModel = ( } /> ), + // Every study started from the sweep, under the sliders, from the + // first Optimize on; before it the card is exactly as without. + below: + studies.length === 0 ? null : ( + + ), more: null, tone, }, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx new file mode 100644 index 00000000000..8b5381812df --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx @@ -0,0 +1,198 @@ +/** + * The sweep's objective by step, under the Parameters card's sliders: a + * disclosure row (a dot that breathes while a study drives the sweep, the + * title, the last study's metric with the steps run and the best found, a + * chevron) over a fold holding the shared objective chart in purple. Every + * study started from the sweep draws on one axis, oldest first, numbered end + * to end, with a dashed divider where each further study began and its own + * best-so-far line. Mounted from the first Optimize on and kept while the + * drawer lives; the fold clips height alone, so the plot keeps taking steps + * while collapsed and reopens on the current picture. + */ +import { useId, useState } from "react"; + +import { Icon } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { Fold } from "../shared/drawer-frame/fold"; +import { formatNumber } from "../shared/format-value"; +import { + ObjectiveHistoryChart, + type ObjectiveHistoryStyle, +} from "../shared/objective-history-chart"; +import { buildSweepObjectiveHistory } from "./sweep-objective-strip/sweep-objective-history"; + +import type { OptimizationRecord } from "../../../../../../react/optimizations/context"; + +/** The chart's height in pixels, x axis included; the fold's open height is a constant. */ +export const SWEEP_OBJECTIVE_PLOT_HEIGHT = 120; + +/** The strip's palette: the purple ramp's light hexes, since a canvas cannot read the tokens. */ +const sweepObjectiveStyle: ObjectiveHistoryStyle = { + step: { fill: "#be93e4", stroke: "#a671d5", size: 5 }, + best: "#8347b9", + axisText: { x: "#5f3289", y: "#5f3289" }, + grid: "#f2e2fc", + ticks: { x: "#e0c4f4", y: "#e0c4f4" }, + divider: "#d1afec", + axisSize: { x: 22, y: 44 }, + paddingTop: 6, +}; + +const stripStyle = css({ + marginTop: "3", + minWidth: "[0]", + borderTopWidth: "[1px]", + borderTopStyle: "solid", + borderTopColor: "purple.s40", +}); + +// One control the width of the strip; its text is its name. +const rowStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + height: "[24px]", + minWidth: "[0]", + margin: "[0]", + paddingX: "1", + paddingY: "[0]", + borderWidth: "[0]", + borderRadius: "md", + backgroundColor: "[transparent]", + color: "purple.s115", + font: "[inherit]", + textAlign: "left", + cursor: "pointer", + "&:hover": { backgroundColor: "purple.s20" }, + "&:focus-visible": { + outline: "[2px solid var(--colors-purple-s80)]", + outlineOffset: "[-2px]", + }, + "[data-animate=true] &": { + transition: "[background-color 120ms ease-out]", + }, +}); + +const chevronStyle = css({ + display: "inline-flex", + flexShrink: "0", + color: "purple.s100", + "&[data-open=false]": { transform: "rotate(-90deg)" }, + "[data-animate=true] &": { transition: "[transform 160ms ease-out]" }, +}); + +// The dot is always there, so the title never moves; driving, its halo +// breathes the way the card's does. +const dotStyle = css({ + position: "relative", + width: "[6px]", + height: "[6px]", + flexShrink: "0", + borderRadius: "full", + backgroundColor: "purple.s60", + "&[data-driving=true]": { backgroundColor: "purple.s90" }, + "&[data-driving=true]::after": { + content: '""', + position: "absolute", + inset: "[0]", + borderRadius: "[inherit]", + boxShadow: + "[0 0 0 3px var(--colors-purple-a40), 0 0 8px var(--colors-purple-a60)]", + opacity: "[0]", + "[data-animate=true] &": { + animationName: "[petrinautOptimizingGlow]", + animationDuration: "[2.4s]", + animationTimingFunction: "ease-in-out", + animationIterationCount: "[infinite]", + }, + }, +}); + +const titleStyle = css({ + flexShrink: "0", + fontSize: "xs", + fontWeight: "medium", + whiteSpace: "nowrap", +}); + +const summaryStyle = css({ + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontSize: "xs", + fontVariantNumeric: "tabular-nums", + color: "purple.s100", +}); + +// Air under the row so the first dot's halo clears it. +const chartWrapStyle = css({ + paddingTop: "2", +}); + +/** `Infected peak · 47 steps in 2 optimizations · best 650.500`. */ +const describeHistory = ( + metricName: string, + steps: number, + studies: number, + best: number | null, +): string => + [ + `${metricName} · ${steps} ${steps === 1 ? "step" : "steps"}${ + studies > 1 ? ` in ${studies} optimizations` : "" + }`, + ...(best === null ? [] : [`best ${formatNumber(best)}`]), + ].join(" · "); + +export const SweepObjectiveStrip = ({ + studies, + driving, +}: { + /** Every study started from the sweep, oldest first; never empty. */ + studies: readonly OptimizationRecord[]; + /** Whether the last study drives the sweep now: the row's dot breathes. */ + driving: boolean; +}) => { + const [expanded, setExpanded] = useState(true); + const foldId = useId(); + const history = buildSweepObjectiveHistory(studies); + + return ( +
+ + +
+ +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts new file mode 100644 index 00000000000..85e987137cf --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { buildSweepObjectiveHistory } from "./sweep-objective-history"; + +import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; +import type { + PetrinautOptimizationDirection, + PetrinautOptimizationTrialEvent, +} from "@hashintel/petrinaut-core"; + +const trial = ( + index: number, + objective: number | null, +): PetrinautOptimizationTrialEvent => ({ + type: "trial", + trial: index, + seq: index + 1, + parameters: {}, + objective, + state: objective === null ? "pruned" : "complete", + best: null, +}); + +/** A study over `objectives`, one trial each in order, asked for `requestedTrials`. */ +const study = ( + objectives: readonly (number | null)[], + { + direction = "maximize", + requestedTrials = objectives.length, + best = null, + }: { + direction?: PetrinautOptimizationDirection; + requestedTrials?: number; + best?: number | null; + } = {}, +): Pick< + OptimizationRecord, + "trials" | "input" | "requestedTrials" | "best" +> => ({ + trials: objectives.map((objective, index) => trial(index, objective)), + input: { + objective: { metricId: "infected", direction }, + model: { + title: "SIR", + definition: { + metrics: [{ id: "infected", name: "Infected peak", code: "" }], + }, + }, + } as OptimizationRecord["input"], + requestedTrials, + best: best === null ? null : { trial: 0, parameters: {}, objective: best }, +}); + +describe("buildSweepObjectiveHistory", () => { + it("numbers a second study's steps after the first's and divides where it began", () => { + const history = buildSweepObjectiveHistory([ + study([10, 12, 11]), + study([5, 9], { requestedTrials: 20, best: 9 }), + ]); + + expect(history.points.map((point) => point.step)).toEqual([1, 2, 3, 4, 5]); + expect(history.dividers).toEqual([4]); + expect(history.xMax).toBe(3 + 20); + expect(history.metricName).toBe("Infected peak"); + expect(history.best).toBe(9); + }); + + it("restarts the best so far with each study", () => { + const history = buildSweepObjectiveHistory([ + study([10, 12, 11]), + study([5, 9]), + ]); + + expect(history.points.map((point) => point.bestSoFar)).toEqual([ + 10, 12, 12, 5, 9, + ]); + }); + + it("follows a minimized study downwards after a maximized one", () => { + const history = buildSweepObjectiveHistory([ + study([3, 8]), + study([7, 4, 6], { direction: "minimize" }), + ]); + + expect(history.points.map((point) => point.bestSoFar)).toEqual([ + 3, 8, 7, 4, 4, + ]); + }); + + it("offsets by the steps a stopped study ran, not the steps it asked for", () => { + const history = buildSweepObjectiveHistory([ + study([1, 2], { requestedTrials: 30 }), + study([3], { requestedTrials: 30 }), + ]); + + expect(history.points.map((point) => point.step)).toEqual([1, 2, 3]); + expect(history.dividers).toEqual([3]); + expect(history.xMax).toBe(2 + 30); + }); + + it("pins the axis to one study's requested steps while it runs, and never short of its points", () => { + expect( + buildSweepObjectiveHistory([study([1, 2], { requestedTrials: 30 })]).xMax, + ).toBe(30); + expect( + buildSweepObjectiveHistory([study([1, 2, 3], { requestedTrials: 2 })]) + .xMax, + ).toBe(3); + }); + + it("keeps a pruned step in the numbering without a dot", () => { + const history = buildSweepObjectiveHistory([study([4, null, 6])]); + + expect(history.points.map((point) => point.objective)).toEqual([ + 4, + null, + 6, + ]); + expect(history.points.map((point) => point.step)).toEqual([1, 2, 3]); + }); + + it("is empty without a study", () => { + expect(buildSweepObjectiveHistory([])).toEqual({ + points: [], + xMax: 0, + dividers: [], + metricName: "", + best: null, + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts new file mode 100644 index 00000000000..57c5f93e5a5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts @@ -0,0 +1,66 @@ +/** + * The objective history of every study a sweep ran, end to end: what the + * strip under the sliders draws and what its row says. + */ +import { + buildObjectiveHistory, + type ObjectiveHistoryPoint, +} from "../../shared/objective-history-chart"; +import { objectiveMetricName } from "../../shared/study-labels"; + +import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; + +export type SweepObjectiveHistory = { + /** Every study's steps end to end, numbered from 1 across studies. */ + points: readonly ObjectiveHistoryPoint[]; + /** The x axis's right edge: the steps run so far plus the last study's steps still to come. */ + xMax: number; + /** The first global step of every study after the first: where a divider is drawn. */ + dividers: readonly number[]; + /** The last study's metric name and best, for the row's summary. */ + metricName: string; + best: number | null; +}; + +/** + * Concatenates the studies' objective histories: each study's steps are + * numbered after the previous study's run steps (`trials.length`, so a + * stopped study leaves no gap), and best-so-far restarts with each study, + * whose metric and direction may differ from the last. + */ +export const buildSweepObjectiveHistory = ( + studies: readonly Pick< + OptimizationRecord, + "trials" | "input" | "requestedTrials" | "best" + >[], +): SweepObjectiveHistory => { + const points: ObjectiveHistoryPoint[] = []; + const dividers: number[] = []; + let offset = 0; + for (const [index, study] of studies.entries()) { + if (index > 0) { + dividers.push(offset + 1); + } + for (const point of buildObjectiveHistory( + study.trials, + study.input.objective.direction, + )) { + points.push({ ...point, step: point.step + offset }); + } + offset += study.trials.length; + } + const last = studies.at(-1); + if (last === undefined) { + return { points, xMax: 0, dividers, metricName: "", best: null }; + } + return { + points, + xMax: Math.max( + points.length, + offset - last.trials.length + last.requestedTrials, + ), + dividers, + metricName: objectiveMetricName(last.input), + best: last.best?.objective ?? null, + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts index 87fe758e9b1..055993801e8 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts @@ -186,7 +186,12 @@ export type SweepOptimizer = { */ available: boolean; /** - * The study started from this sweep most recently; null before any. Read + * Every study started from this sweep, oldest first; empty before any. The + * objective strip draws them end to end. + */ + studies: readonly OptimizationRecord[]; + /** + * The study started most recently, `studies.at(-1)`; null before any. Read * for its outcome and its error once `driving` is null. */ study: OptimizationRecord | null; @@ -219,12 +224,15 @@ export const useSweepOptimizer = ( removeOptimization, } = use(OptimizationsContext); - const studies = optimizations.filter( - (optimization) => - optimization.origin?.kind === "sweep" && - optimization.origin.experimentId === experiment.id, - ); - const study = studies[0] ?? null; + // The provider prepends; the strip reads oldest first. + const studies = optimizations + .filter( + (optimization) => + optimization.origin?.kind === "sweep" && + optimization.origin.experimentId === experiment.id, + ) + .toSorted((left, right) => left.createdAt - right.createdAt); + const study = studies.at(-1) ?? null; const scenario = petriNetDefinition.scenarios?.find( (candidate) => candidate.id === experiment.scenarioId, @@ -238,6 +246,7 @@ export const useSweepOptimizer = ( return { available, + studies, study, driving: study !== null && isOptimizationActive(study) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx index 4a9a317fc0d..30428aec605 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx @@ -10,10 +10,16 @@ import { ExperimentsContext, } from "../../../../../../react/experiments/context"; import { PetrinautOptimizationContext } from "../../../../../../react/optimization-context"; -import { OptimizationsContext } from "../../../../../../react/optimizations/context"; +import { + foldBestTrial, + type OptimizationBest, + type OptimizationRecord, + OptimizationsContext, +} from "../../../../../../react/optimizations/context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { fakeStudyInput, + fakeStudyTrials, makeOptimizationRecord, makeOptimizationsContextValue, } from "../optimizations/optimizations-story-fixtures"; @@ -120,30 +126,80 @@ const storyOptimizer: PetrinautConnectedOptimization = { }, }; +/** A study started from the sweep, its first `steps` fake trials landed and its best among them. */ +const sweepStudy = ( + sweep: ExperimentRecord, + { + id, + status, + steps, + startedAgoMs, + }: { + id: string; + status: OptimizationRecord["status"]; + steps: number; + startedAgoMs: number; + }, +): OptimizationRecord => { + const trials = fakeStudyTrials.trials.slice(0, steps); + return { + ...makeOptimizationRecord({ + input: fakeStudyInput, + status, + trials, + best: trials.reduce( + (best, event) => foldBestTrial("maximize", best, event), + null, + ), + }), + id, + createdAt: Date.now() - startedAgoMs, + origin: { kind: "sweep", experimentId: sweep.id }, + }; +}; + /** * The sweep drawer with the in-browser optimizer available: the Parameters * card offers Optimize, and with a study driving the sweep it turns purple, - * the header reads Optimizing, its sliders follow the steps and the button - * reads Stop. + * the header reads Optimizing, its sliders follow the steps, the button + * reads Stop and the objective strip under the sliders fills in step by + * step. Settled, the strip keeps the whole history; `previous` adds an + * earlier, stopped study before it, so the strip shows the two end to end + * with a divider where the second began. */ -const OptimizableSweep = ({ driving }: { driving: boolean }) => { +const OptimizableSweep = ({ + driving, + previous = false, +}: { + driving: boolean; + previous?: boolean; +}) => { const sweep = makeParameterSweepExperiment(); - const study = { - ...makeOptimizationRecord({ - input: fakeStudyInput, - status: driving ? "running" : "cancelled", - }), - origin: { kind: "sweep" as const, experimentId: sweep.id }, - completedTrials: 3, - prunedTrials: 1, - }; + const study = sweepStudy(sweep, { + id: "sweep-study-2", + status: driving ? "running" : "complete", + steps: driving ? 4 : 30, + startedAgoMs: 90_000, + }); + const studies = previous + ? [ + study, + sweepStudy(sweep, { + id: "sweep-study-1", + status: "cancelled", + steps: 17, + startedAgoMs: 600_000, + }), + ] + : [study]; return ( , }; + +export const OptimizedTwice: Story = { + name: "Sweep, optimized twice", + render: () => , +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index 4bc805468f9..8c93e22938d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -17,6 +17,7 @@ import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; import { fakeStudyInput, + fakeStudyTrials, makeOptimizationRecord, makeOptimizationsContextValue, } from "../optimizations/optimizations-story-fixtures"; @@ -131,6 +132,20 @@ vi.mock("./sweep-surface", async () => { }; }); +// uPlot cannot mount in jsdom; the strip's row and fold around the chart are +// real, and so is the history the row describes. +vi.mock("../shared/objective-history-chart", async () => { + const data = await vi.importActual< + typeof import("../shared/objective-history-chart/objective-history-data") + >("../shared/objective-history-chart/objective-history-data"); + return { + buildObjectiveHistory: data.buildObjectiveHistory, + ObjectiveHistoryChart: ({ plotHeight }: { plotHeight: number }) => ( +
+ ), + }; +}); + // uPlot cannot mount in jsdom; the menu and the subtitle are real. vi.mock("./experiment-metric-timeline", () => import("../shared/metric-timeline-test-stubs").then((stubs) => @@ -186,7 +201,12 @@ const renderDrawerWithStudy = ( status: "running" | "cancelled", ) => { const study = { - ...makeOptimizationRecord({ input: fakeStudyInput, status }), + ...makeOptimizationRecord({ + input: fakeStudyInput, + status, + trials: fakeStudyTrials.trials.slice(0, 4), + best: { trial: 2, parameters: {}, objective: 650.5 }, + }), origin: { kind: "sweep" as const, experimentId: experiment.id }, completedTrials: 3, prunedTrials: 1, @@ -318,6 +338,56 @@ describe("ViewExperimentDrawer in the frame", () => { expect(screen.getByText(/^Cancelled after 4 of 30 steps/u)).toBeTruthy(); }); + it("shows no objective strip before any study", () => { + renderDrawer(sweep); + + expect(document.querySelector("[data-sweep-objective]")).toBeNull(); + expect( + screen.queryByRole("button", { name: /^Objective by step/u }), + ).toBeNull(); + }); + + it("draws the study's objective under the sliders while it drives the sweep and once it settles, at one layout", () => { + const signatures = (["running", "cancelled"] as const).map((status) => { + const view = renderDrawerWithStudy({ ...sweep, status: "idle" }, status); + const row = screen.getByRole("button", { name: /^Objective by step/u }); + expect(row.textContent).toMatch(/ · 4 steps · best 650\.500$/u); + expect(row.getAttribute("aria-expanded")).toBe("true"); + expect(document.querySelector("[data-sweep-objective]")).toBeTruthy(); + expect(screen.getByTestId("objective-history").style.height).toBe( + "120px", + ); + // The dot is there either way; it only breathes while driving. + expect( + row.querySelector("[data-driving]")?.getAttribute("data-driving"), + ).toBe(String(status === "running")); + const signature = frameLayoutSignature(view.container); + view.unmount(); + return signature; + }); + + expect(signatures[1]).toEqual(signatures[0]); + }); + + it("folds the objective chart away from its row and brings it back, the chart staying mounted", () => { + renderDrawerWithStudy({ ...sweep, status: "idle" }, "cancelled"); + const row = screen.getByRole("button", { name: /^Objective by step/u }); + const clip = document.querySelector("[data-sweep-objective]")!; + expect(row.getAttribute("aria-controls")).toBe(clip.id); + expect(clip.hasAttribute("inert")).toBe(false); + + fireEvent.click(row); + + expect(row.getAttribute("aria-expanded")).toBe("false"); + expect(clip.hasAttribute("inert")).toBe(true); + expect(screen.getByTestId("objective-history")).toBeTruthy(); + + fireEvent.click(row); + + expect(row.getAttribute("aria-expanded")).toBe("true"); + expect(clip.hasAttribute("inert")).toBe(false); + }); + it("shows a plain experiment's metric cards alone, with no parameters and no surface", () => { renderDrawer( makeExperiment(1, { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx index 2b7e9d177d8..64c120b6300 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results.tsx @@ -359,6 +359,7 @@ const parametersBand = ( onNavigationChange={onNavigationChange} /> ), + below: null, more: fixedCount === 0 ? null @@ -435,6 +436,7 @@ const bestParametersBand = (optimization: OptimizationRecord): ResultsBand => { )} /> ), + below: null, more: null, tone: "default", }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-model.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-model.ts index 05cc80fc53b..4c00c7ae7e9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-model.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-model.ts @@ -80,6 +80,8 @@ export type ResultsBand = { /** The header's right side: a state line, a switch. */ trailing: ReactNode | null; content: ReactNode; + /** Under the controls, spanning the body: a sweep's objective by step. Null gives the card nothing there. */ + below: ReactNode | null; more: FrameCardMore | null; /** The card's look: `optimizing` while an optimizer drives its controls. */ tone: ChartCardTone; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-view.tsx index a4ab01cce9f..45167f2cdad 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/results-view.tsx @@ -102,6 +102,7 @@ export const ResultsView = ({ tone={band.tone} > {band.content} + {band.below} ))} Date: Fri, 11 Sep 2026 04:34:32 +0200 Subject: [PATCH 15/21] Close the review gaps in the sweep objective strip --- libs/@hashintel/petrinaut/docs/experiments.md | 2 +- .../experiments/sweep-objective-strip.tsx | 4 +- .../sweep-objective-history.test.ts | 66 ++++++++++++++++--- .../sweep-objective-history.ts | 50 ++++++++++---- .../view-experiment-drawer.stories.tsx | 29 +++++--- .../view-experiment-drawer.test.tsx | 16 ++--- .../study-results/objective-history-card.tsx | 6 +- .../SimulateView/shared/drawer-frame.tsx | 6 +- .../shared/objective-history-chart.tsx | 7 +- .../objective-history-data.test.ts | 0 .../objective-history-data.ts | 0 .../content/ui/optimizations-tab.mdx | 34 +++++++--- 12 files changed, 154 insertions(+), 66 deletions(-) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/{objective-history-chart => }/objective-history-data.test.ts (100%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/{objective-history-chart => }/objective-history-data.ts (100%) diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index ef2f3e6a5e5..15c56e12086 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -77,7 +77,7 @@ Every selection uses the same seed sequence (common random numbers), and a run's With the [in-browser optimizer](optimization.md#running-in-the-browser) turned on, the **Parameters** card's header carries one purple **Optimize** button. It asks which metric to optimize, whether to **Maximize** or **Minimize** it, and how many steps to take, then hands the sliders to the optimizer: the card turns purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls lock and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes. Each step computes eight runs at its point before the optimizer reads the metric's value there; the **N computing** chip lists that batch as **Step N**. While the study drives the sweep the same button reads **Stop**: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the next **Optimize** or the experiment's removal. **Cancel** in the drawer's footer stops the study as well as the sweep. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's drawer is its home, and removing the experiment removes it. -From the first **Optimize** on, an **Objective by step** strip sits under the sliders: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A further **Optimize** continues the same axis after the previous steps, with a dashed line where it began and its own best-so-far line, so one strip holds every optimization of the sweep. Before any **Optimize** the card shows no strip. +From the first **Optimize** on, an **Objective by step** strip sits under the sliders: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A further **Optimize** continues the same axis after the previous steps, with a dashed line where it began and its own best-so-far line, so one strip holds every optimization of the sweep. Before any **Optimize** the card shows no strip. #### The surface view diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx index 8b5381812df..8fb6dac31f5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx @@ -14,7 +14,7 @@ import { useId, useState } from "react"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { Fold } from "../shared/drawer-frame/fold"; +import { Fold } from "../shared/drawer-frame"; import { formatNumber } from "../shared/format-value"; import { ObjectiveHistoryChart, @@ -177,7 +177,7 @@ export const SweepObjectiveStrip = ({ {describeHistory( history.metricName, history.points.length, - studies.length, + history.studyCount, history.best, )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts index 85e987137cf..42a4d92b0b9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts @@ -21,22 +21,25 @@ const trial = ( best: null, }); -/** A study over `objectives`, one trial each in order, asked for `requestedTrials`. */ +/** A study over `objectives`, one trial each in order, asked for `requestedTrials`; complete unless a `status` says otherwise. */ const study = ( objectives: readonly (number | null)[], { direction = "maximize", requestedTrials = objectives.length, best = null, + status = "complete", }: { direction?: PetrinautOptimizationDirection; requestedTrials?: number; best?: number | null; + status?: OptimizationRecord["status"]; } = {}, ): Pick< OptimizationRecord, - "trials" | "input" | "requestedTrials" | "best" + "trials" | "input" | "requestedTrials" | "best" | "status" > => ({ + status, trials: objectives.map((objective, index) => trial(index, objective)), input: { objective: { metricId: "infected", direction }, @@ -55,11 +58,12 @@ describe("buildSweepObjectiveHistory", () => { it("numbers a second study's steps after the first's and divides where it began", () => { const history = buildSweepObjectiveHistory([ study([10, 12, 11]), - study([5, 9], { requestedTrials: 20, best: 9 }), + study([5, 9], { requestedTrials: 20, best: 9, status: "running" }), ]); expect(history.points.map((point) => point.step)).toEqual([1, 2, 3, 4, 5]); expect(history.dividers).toEqual([4]); + expect(history.studyCount).toBe(2); expect(history.xMax).toBe(3 + 20); expect(history.metricName).toBe("Infected peak"); expect(history.best).toBe(9); @@ -89,8 +93,8 @@ describe("buildSweepObjectiveHistory", () => { it("offsets by the steps a stopped study ran, not the steps it asked for", () => { const history = buildSweepObjectiveHistory([ - study([1, 2], { requestedTrials: 30 }), - study([3], { requestedTrials: 30 }), + study([1, 2], { requestedTrials: 30, status: "cancelled" }), + study([3], { requestedTrials: 30, status: "running" }), ]); expect(history.points.map((point) => point.step)).toEqual([1, 2, 3]); @@ -100,14 +104,59 @@ describe("buildSweepObjectiveHistory", () => { it("pins the axis to one study's requested steps while it runs, and never short of its points", () => { expect( - buildSweepObjectiveHistory([study([1, 2], { requestedTrials: 30 })]).xMax, + buildSweepObjectiveHistory([ + study([1, 2], { requestedTrials: 30, status: "running" }), + ]).xMax, ).toBe(30); expect( - buildSweepObjectiveHistory([study([1, 2, 3], { requestedTrials: 2 })]) - .xMax, + buildSweepObjectiveHistory([ + study([1, 2, 3], { requestedTrials: 2, status: "running" }), + ]).xMax, ).toBe(3); }); + it("ends the axis at the last step run once the last study is stopped or done", () => { + expect( + buildSweepObjectiveHistory([ + study([1, 2], { requestedTrials: 30, status: "cancelled" }), + ]).xMax, + ).toBe(2); + expect( + buildSweepObjectiveHistory([ + study([1, 2, 3], { requestedTrials: 3, status: "complete" }), + ]).xMax, + ).toBe(3); + }); + + it("leaves a study that failed before its first step out of the axis, the dividers and the count", () => { + const history = buildSweepObjectiveHistory([ + study([1, 2]), + study([], { requestedTrials: 30, status: "error" }), + study([3], { requestedTrials: 30, status: "running" }), + ]); + + expect(history.dividers).toEqual([3]); + expect(history.studyCount).toBe(2); + expect(history.xMax).toBe(2 + 30); + expect( + buildSweepObjectiveHistory([ + study([1, 2]), + study([], { requestedTrials: 30, status: "error" }), + ]), + ).toMatchObject({ dividers: [], studyCount: 1, xMax: 2 }); + }); + + it("divides before a study that has just started, where its first step will land", () => { + const history = buildSweepObjectiveHistory([ + study([1, 2]), + study([], { requestedTrials: 30, status: "running" }), + ]); + + expect(history.dividers).toEqual([3]); + expect(history.studyCount).toBe(2); + expect(history.xMax).toBe(2 + 30); + }); + it("keeps a pruned step in the numbering without a dot", () => { const history = buildSweepObjectiveHistory([study([4, null, 6])]); @@ -124,6 +173,7 @@ describe("buildSweepObjectiveHistory", () => { points: [], xMax: 0, dividers: [], + studyCount: 0, metricName: "", best: null, }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts index 57c5f93e5a5..6557611cefe 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts @@ -5,18 +5,26 @@ import { buildObjectiveHistory, type ObjectiveHistoryPoint, -} from "../../shared/objective-history-chart"; +} from "../../shared/objective-history-data"; import { objectiveMetricName } from "../../shared/study-labels"; -import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; +import { + isOptimizationActive, + type OptimizationRecord, +} from "../../../../../../../react/optimizations/context"; export type SweepObjectiveHistory = { /** Every study's steps end to end, numbered from 1 across studies. */ points: readonly ObjectiveHistoryPoint[]; - /** The x axis's right edge: the steps run so far plus the last study's steps still to come. */ + /** + * The x axis's right edge: while the last study runs, the steps run so far + * plus its steps still to come; settled, the last step run. + */ xMax: number; - /** The first global step of every study after the first: where a divider is drawn. */ + /** The first global step of every study drawn after the first: where a divider is drawn. */ dividers: readonly number[]; + /** The studies that drew a step or are about to, for the row's count. */ + studyCount: number; /** The last study's metric name and best, for the row's summary. */ metricName: string; best: number | null; @@ -26,21 +34,27 @@ export type SweepObjectiveHistory = { * Concatenates the studies' objective histories: each study's steps are * numbered after the previous study's run steps (`trials.length`, so a * stopped study leaves no gap), and best-so-far restarts with each study, - * whose metric and direction may differ from the last. + * whose metric and direction may differ from the last. A study that ended + * without a step, failed at start, adds no divider and no count. */ export const buildSweepObjectiveHistory = ( studies: readonly Pick< OptimizationRecord, - "trials" | "input" | "requestedTrials" | "best" + "trials" | "input" | "requestedTrials" | "best" | "status" >[], ): SweepObjectiveHistory => { const points: ObjectiveHistoryPoint[] = []; const dividers: number[] = []; + let studyCount = 0; let offset = 0; - for (const [index, study] of studies.entries()) { - if (index > 0) { + for (const study of studies) { + if (study.trials.length === 0 && !isOptimizationActive(study)) { + continue; + } + if (studyCount > 0) { dividers.push(offset + 1); } + studyCount += 1; for (const point of buildObjectiveHistory( study.trials, study.input.objective.direction, @@ -51,15 +65,25 @@ export const buildSweepObjectiveHistory = ( } const last = studies.at(-1); if (last === undefined) { - return { points, xMax: 0, dividers, metricName: "", best: null }; + return { + points, + xMax: 0, + dividers, + studyCount, + metricName: "", + best: null, + }; } return { points, - xMax: Math.max( - points.length, - offset - last.trials.length + last.requestedTrials, - ), + xMax: isOptimizationActive(last) + ? Math.max( + points.length, + offset - last.trials.length + last.requestedTrials, + ) + : points.length, dividers, + studyCount, metricName: objectiveMetricName(last.input), best: last.best?.objective ?? null, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx index 30428aec605..456a5c3a2b9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx @@ -158,27 +158,31 @@ const sweepStudy = ( }; }; +/** The steps the latest study has landed in each state: 4 of 30 while it runs, 17 when Stop ended it. */ +const latestStudySteps = { running: 4, complete: 30, cancelled: 17 } as const; + /** * The sweep drawer with the in-browser optimizer available: the Parameters * card offers Optimize, and with a study driving the sweep it turns purple, * the header reads Optimizing, its sliders follow the steps, the button * reads Stop and the objective strip under the sliders fills in step by - * step. Settled, the strip keeps the whole history; `previous` adds an - * earlier, stopped study before it, so the strip shows the two end to end - * with a divider where the second began. + * step, its axis reaching to the steps asked for. Settled, the strip keeps + * the whole history and its axis ends at the last step run, complete or + * stopped; `previous` adds an earlier, stopped study before it, so the + * strip shows the two end to end with a divider where the second began. */ const OptimizableSweep = ({ - driving, + latest, previous = false, }: { - driving: boolean; + latest: keyof typeof latestStudySteps; previous?: boolean; }) => { const sweep = makeParameterSweepExperiment(); const study = sweepStudy(sweep, { id: "sweep-study-2", - status: driving ? "running" : "complete", - steps: driving ? 4 : 30, + status: latest, + steps: latestStudySteps[latest], startedAgoMs: 90_000, }); const studies = previous @@ -219,15 +223,20 @@ const OptimizableSweep = ({ export const Optimizable: Story = { name: "Sweep, optimizer available", - render: () => , + render: () => , }; export const Optimizing: Story = { name: "Sweep, optimizer driving", - render: () => , + render: () => , +}; + +export const StoppedOnce: Story = { + name: "Sweep, optimization stopped", + render: () => , }; export const OptimizedTwice: Story = { name: "Sweep, optimized twice", - render: () => , + render: () => , }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index 8c93e22938d..a07584ffcf0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -134,17 +134,11 @@ vi.mock("./sweep-surface", async () => { // uPlot cannot mount in jsdom; the strip's row and fold around the chart are // real, and so is the history the row describes. -vi.mock("../shared/objective-history-chart", async () => { - const data = await vi.importActual< - typeof import("../shared/objective-history-chart/objective-history-data") - >("../shared/objective-history-chart/objective-history-data"); - return { - buildObjectiveHistory: data.buildObjectiveHistory, - ObjectiveHistoryChart: ({ plotHeight }: { plotHeight: number }) => ( -
- ), - }; -}); +vi.mock("../shared/objective-history-chart", () => ({ + ObjectiveHistoryChart: ({ plotHeight }: { plotHeight: number }) => ( +
+ ), +})); // uPlot cannot mount in jsdom; the menu and the subtitle are real. vi.mock("./experiment-metric-timeline", () => diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx index 9e6fc7ff871..7d833b212c3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-results/objective-history-card.tsx @@ -3,10 +3,8 @@ * after the objective metric, the points built from the study's own trials. */ import { ChartCard, type ChartCardTone } from "../../shared/chart-card"; -import { - buildObjectiveHistory, - ObjectiveHistoryChart, -} from "../../shared/objective-history-chart"; +import { ObjectiveHistoryChart } from "../../shared/objective-history-chart"; +import { buildObjectiveHistory } from "../../shared/objective-history-data"; import { objectiveMetricName } from "../../shared/study-labels"; import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx index 07280c1f90f..f493db7e8cf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -1,8 +1,9 @@ /** * The frame kit every Simulate drawer and the full study view build on: the * frame itself, the header's stat columns and status pill, the columns, the - * spanning card and the computing chip. The parts live in `drawer-frame/`, - * which forms the frame's layer; this file is their one public door. + * spanning card, the computing chip and the fold a part hides behind. The + * parts live in `drawer-frame/`, which forms the frame's layer; this file is + * their one public door. */ export { DrawerFrame, @@ -17,6 +18,7 @@ export { } from "./drawer-frame/frame-header"; export { FrameColumns } from "./drawer-frame/frame-columns"; export { FrameCard, type FrameCardMore } from "./drawer-frame/frame-card"; +export { Fold, type FoldProps } from "./drawer-frame/fold"; export { type ComputeBatch, ComputeBatchesChip, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx index bcca47551fd..ac0c28d737b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx @@ -17,12 +17,7 @@ import { useElementSize } from "../../../../../../react/hooks/use-element-size"; import { type ObjectiveHistoryPoint, toObjectiveHistoryData, -} from "./objective-history-chart/objective-history-data"; - -export { - buildObjectiveHistory, - type ObjectiveHistoryPoint, -} from "./objective-history-chart/objective-history-data"; +} from "./objective-history-data"; const UPlot = uPlot; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.test.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.test.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart/objective-history-data.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.ts diff --git a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx index 0e6038a7548..9bc9b943a93 100644 --- a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx +++ b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx @@ -112,13 +112,14 @@ zero-layout-shift contract holds on the remote path as on the connected one. `ObjectiveHistoryCard` (`study-results/objective-history-card.tsx`) is the objective by step, the shared `ObjectiveHistoryChart` (`SimulateView/shared/objective-history-chart.tsx`) in a card: -`buildObjectiveHistory` (`shared/objective-history-chart/objective-history-data.ts`) +`buildObjectiveHistory` (`SimulateView/shared/objective-history-data.ts`) orders the trials by step and threads the best so far through the completed ones, `toObjectiveHistoryData` lays them out as uPlot aligned data (`[steps, objectives, bestSoFar]`), and the chart draws the objectives as dots and the best as a stepped line, in the palette and sizes its `ObjectiveHistoryStyle` names, with an optional pinned right edge (`xMax`) -and dashed `dividers` between studies for the sweep's strip. +and dashed `dividers` between studies for the sweep's objective strip +([Studies started from a sweep](#studies-started-from-a-sweep)). `trialFeasibility` reads a point's feasibility off the trial event's `constraints`: `unknown` without results, `infeasible` for a draw pruned by a parameter constraint. A pruned step has no objective, so it draws nothing, @@ -200,16 +201,31 @@ experimentId }`), no connected state and no drawer navigation, routes its run's `evaluateTrial` to a sweep trial evaluator ([react.optimizations.sweep-evaluator](layer:react.optimizations.sweep-evaluator)) in front of the channel, and hides it from this tab's list. The hook exposes -the study and `driving`, the step of the study driving the sweep or null. -While it drives, the card takes the `optimizing` tone, its sliders follow the -trials disabled, the same purple button reads Stop and cancels the study, and -the experiment drawer's header reads from the study: **Optimizing**, the -steps finished as the progress bar, `Step N` on the computing batch. Settling -parks the sweep, uncapped, on the best point, or on the point it was trying -when Stop ended it; the navigator's status line then keeps the outcome +the study, every study started from the sweep oldest first as `studies`, and +`driving`, the step of the study driving the sweep or null. While it drives, +the card takes the `optimizing` tone, its sliders follow the trials disabled, +the same purple button reads Stop and cancels the study, and the experiment +drawer's header reads from the study: **Optimizing**, the steps finished as +the progress bar, `Step N` on the computing batch. Settling parks the sweep, +uncapped, on the best point, or on the point it was trying when Stop ended +it; the navigator's status line then keeps the outcome (`describeStudyProgress`), the drawer's note row shows a failed study's error, and the experiment's Cancel stops a driving study first. +Once `studies` is non-empty, `experimentResultsModel` fills the Parameters +band's `below` slot (`ResultsBand` in `shared/results/results-model.ts`, +rendered after `band.content` in `results-view.tsx`) with +`SweepObjectiveStrip` (`experiments/sweep-objective-strip.tsx`): a disclosure +row over a `Fold` holding the shared `ObjectiveHistoryChart` in purple, kept +while the drawer lives. `buildSweepObjectiveHistory` +(`experiments/sweep-objective-strip/sweep-objective-history.ts`) numbers the +studies' steps end to end by `trials.length`, restarts best-so-far per study +and yields the `xMax` and `dividers` the chart takes: a divider at the first +step of every further study that drew a step or is about to, and the right +edge pinned to the last study's `requestedTrials` while it is active, so the +axis stops at the last step run once Stop or an error ended it. A study that +failed before its first step counts nowhere. + ## The steps table `StepsTable` lists `record.trials` newest first: the step number (Optuna's From ba81b2879bf7fd07f6b29ec8bb28639ff9f22947 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 04:35:46 +0200 Subject: [PATCH 16/21] Drop the changeset: the objective strip ships behind experimental flags --- .changeset/sweep-objective-strip.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/sweep-objective-strip.md diff --git a/.changeset/sweep-objective-strip.md b/.changeset/sweep-objective-strip.md deleted file mode 100644 index c66aa2444ec..00000000000 --- a/.changeset/sweep-objective-strip.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@hashintel/petrinaut": patch ---- - -Draws a sweep's optimization objective by step under its Parameters sliders. From b80a579a18b441b388d904b89a9b0cb40733981b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 19:37:52 +0200 Subject: [PATCH 17/21] Give the drawer test's fake optimizer the studies list the strip reads --- .../SimulateView/experiments/view-experiment-drawer.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index a07584ffcf0..f5376455466 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -41,6 +41,7 @@ const optimizer = vi.hoisted<{ current: SweepOptimizer | null }>(() => ({ const idleOptimizer: SweepOptimizer = { available: false, + studies: [], study: null, driving: null, start: () => Promise.resolve(), From 131313642330a49e4f439a859004076eaae20dcb Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 19:50:06 +0200 Subject: [PATCH 18/21] Point the results band's path mention at shared/results-model.ts --- .../@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx index 9bc9b943a93..3d1721d036c 100644 --- a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx +++ b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx @@ -213,7 +213,7 @@ it; the navigator's status line then keeps the outcome and the experiment's Cancel stops a driving study first. Once `studies` is non-empty, `experimentResultsModel` fills the Parameters -band's `below` slot (`ResultsBand` in `shared/results/results-model.ts`, +band's `below` slot (`ResultsBand` in `shared/results-model.ts`, rendered after `band.content` in `results-view.tsx`) with `SweepObjectiveStrip` (`experiments/sweep-objective-strip.tsx`): a disclosure row over a `Fold` holding the shared `ObjectiveHistoryChart` in purple, kept From 441d37ddf4d86e4a54c67b5b13340265d7386bfd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 20:08:55 +0200 Subject: [PATCH 19/21] Summarise the sweep strip from the last counted study, test two studies through the drawer and drop the unused FoldProps export --- .../sweep-objective-history.test.ts | 22 +++- .../sweep-objective-history.ts | 31 +++-- .../view-experiment-drawer.test.tsx | 111 ++++++++++++++---- .../SimulateView/shared/drawer-frame.tsx | 2 +- 4 files changed, 127 insertions(+), 39 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts index 42a4d92b0b9..a9e0082cf61 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts @@ -29,11 +29,13 @@ const study = ( requestedTrials = objectives.length, best = null, status = "complete", + metricName = "Infected peak", }: { direction?: PetrinautOptimizationDirection; requestedTrials?: number; best?: number | null; status?: OptimizationRecord["status"]; + metricName?: string; } = {}, ): Pick< OptimizationRecord, @@ -46,7 +48,7 @@ const study = ( model: { title: "SIR", definition: { - metrics: [{ id: "infected", name: "Infected peak", code: "" }], + metrics: [{ id: "infected", name: metricName, code: "" }], }, }, } as OptimizationRecord["input"], @@ -128,7 +130,7 @@ describe("buildSweepObjectiveHistory", () => { ).toBe(3); }); - it("leaves a study that failed before its first step out of the axis, the dividers and the count", () => { + it("leaves a study that failed before its first step out of the axis, the dividers, the count and the summary", () => { const history = buildSweepObjectiveHistory([ study([1, 2]), study([], { requestedTrials: 30, status: "error" }), @@ -140,10 +142,20 @@ describe("buildSweepObjectiveHistory", () => { expect(history.xMax).toBe(2 + 30); expect( buildSweepObjectiveHistory([ - study([1, 2]), - study([], { requestedTrials: 30, status: "error" }), + study([1, 2], { best: 2 }), + study([], { + requestedTrials: 30, + status: "error", + metricName: "Recovered peak", + }), ]), - ).toMatchObject({ dividers: [], studyCount: 1, xMax: 2 }); + ).toMatchObject({ + dividers: [], + studyCount: 1, + xMax: 2, + metricName: "Infected peak", + best: 2, + }); }); it("divides before a study that has just started, where its first step will land", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts index 6557611cefe..57d2cbb38dd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts @@ -1,3 +1,7 @@ +import { + isOptimizationActive, + type OptimizationRecord, +} from "../../../../../../../react/optimizations/context"; /** * The objective history of every study a sweep ran, end to end: what the * strip under the sliders draws and what its row says. @@ -8,11 +12,6 @@ import { } from "../../shared/objective-history-data"; import { objectiveMetricName } from "../../shared/study-labels"; -import { - isOptimizationActive, - type OptimizationRecord, -} from "../../../../../../../react/optimizations/context"; - export type SweepObjectiveHistory = { /** Every study's steps end to end, numbered from 1 across studies. */ points: readonly ObjectiveHistoryPoint[]; @@ -25,11 +24,19 @@ export type SweepObjectiveHistory = { dividers: readonly number[]; /** The studies that drew a step or are about to, for the row's count. */ studyCount: number; - /** The last study's metric name and best, for the row's summary. */ + /** + * The metric name and best of the last study that drew a step or is about + * to, for the row's summary; a study that failed at start never names it. + */ metricName: string; best: number | null; }; +type SweepStudy = Pick< + OptimizationRecord, + "trials" | "input" | "requestedTrials" | "best" | "status" +>; + /** * Concatenates the studies' objective histories: each study's steps are * numbered after the previous study's run steps (`trials.length`, so a @@ -38,14 +45,12 @@ export type SweepObjectiveHistory = { * without a step, failed at start, adds no divider and no count. */ export const buildSweepObjectiveHistory = ( - studies: readonly Pick< - OptimizationRecord, - "trials" | "input" | "requestedTrials" | "best" | "status" - >[], + studies: readonly SweepStudy[], ): SweepObjectiveHistory => { const points: ObjectiveHistoryPoint[] = []; const dividers: number[] = []; let studyCount = 0; + let lastCounted: SweepStudy | null = null; let offset = 0; for (const study of studies) { if (study.trials.length === 0 && !isOptimizationActive(study)) { @@ -55,6 +60,7 @@ export const buildSweepObjectiveHistory = ( dividers.push(offset + 1); } studyCount += 1; + lastCounted = study; for (const point of buildObjectiveHistory( study.trials, study.input.objective.direction, @@ -74,6 +80,7 @@ export const buildSweepObjectiveHistory = ( best: null, }; } + const summarised = lastCounted ?? last; return { points, xMax: isOptimizationActive(last) @@ -84,7 +91,7 @@ export const buildSweepObjectiveHistory = ( : points.length, dividers, studyCount, - metricName: objectiveMetricName(last.input), - best: last.best?.objective ?? null, + metricName: objectiveMetricName(summarised.input), + best: summarised.best?.objective ?? null, }; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index f5376455466..df970bb7dd6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -12,7 +12,11 @@ import { use } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { PetrinautOptimizationContext } from "../../../../../../react/optimization-context"; -import { OptimizationsContext } from "../../../../../../react/optimizations/context"; +import { + type OptimizationRecord, + OptimizationsContext, + type OptimizationsContextValue, +} from "../../../../../../react/optimizations/context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; import { @@ -134,10 +138,24 @@ vi.mock("./sweep-surface", async () => { }); // uPlot cannot mount in jsdom; the strip's row and fold around the chart are -// real, and so is the history the row describes. +// real, and so is the history the row describes. The axis edge and the +// dividers the chart would draw sit on the stub as data attributes. vi.mock("../shared/objective-history-chart", () => ({ - ObjectiveHistoryChart: ({ plotHeight }: { plotHeight: number }) => ( -
+ ObjectiveHistoryChart: ({ + plotHeight, + xMax, + dividers, + }: { + plotHeight: number; + xMax: number | undefined; + dividers: readonly number[]; + }) => ( +
), })); @@ -190,30 +208,40 @@ const WithInBrowserOptimizer = ({ children }: { children: ReactNode }) => { ); }; -/** The sweep's drawer with a study started from it, driving or settled. */ -const renderDrawerWithStudy = ( +/** A study started from the sweep, driving or settled: four steps, one of them pruned. */ +const sweepStudy = ( experiment: ExperimentRecord, status: "running" | "cancelled", -) => { - const study = { - ...makeOptimizationRecord({ - input: fakeStudyInput, - status, - trials: fakeStudyTrials.trials.slice(0, 4), - best: { trial: 2, parameters: {}, objective: 650.5 }, - }), - origin: { kind: "sweep" as const, experimentId: experiment.id }, - completedTrials: 3, - prunedTrials: 1, - }; - return render( + overrides: Partial = {}, +): OptimizationRecord => ({ + ...makeOptimizationRecord({ + input: fakeStudyInput, + status, + trials: fakeStudyTrials.trials.slice(0, 4), + best: { trial: 2, parameters: {}, objective: 650.5 }, + }), + origin: { kind: "sweep" as const, experimentId: experiment.id }, + completedTrials: 3, + prunedTrials: 1, + ...overrides, +}); + +/** The sweep's drawer over the host's studies, in the order the provider lists them. */ +const renderDrawerWithStudies = ( + experiment: ExperimentRecord, + [first, ...rest]: readonly [OptimizationRecord, ...OptimizationRecord[]], + overrides: Partial = {}, +) => + render( , ); -}; + +/** The sweep's drawer with a study started from it, driving or settled. */ +const renderDrawerWithStudy = ( + experiment: ExperimentRecord, + status: "running" | "cancelled", +) => renderDrawerWithStudies(experiment, [sweepStudy(experiment, status)]); /** The sweep in each state a drawer can show it. */ const sweepIn = (status: ExperimentRecord["status"]): ExperimentRecord => ({ @@ -493,4 +526,40 @@ describe("the Optimize control", () => { expect(stop).toHaveBeenCalledTimes(1); }); + + it("orders the host's studies by creation, summarises the strip from the later one and stops it", () => { + const cancelOptimization = vi.fn(); + const experiment = { ...sweep, status: "idle" as const }; + const earlier = sweepStudy(experiment, "cancelled", { + id: "study-1", + createdAt: Date.now() - 200_000, + }); + const later = sweepStudy(experiment, "running", { + id: "study-2", + trials: fakeStudyTrials.trials.slice(0, 3), + completedTrials: 3, + prunedTrials: 0, + best: { trial: 1, parameters: {}, objective: 700.25 }, + }); + // The provider prepends, so the later study comes first. + renderDrawerWithStudies(experiment, [later, earlier], { + cancelOptimization, + }); + + const row = screen.getByRole("button", { name: /^Objective by step/u }); + expect(row.textContent).toMatch( + / · 7 steps in 2 optimizations · best 700\.250$/u, + ); + // The divider sits at the later study's first step; the axis reaches its + // requested steps past the earlier study's four. + const chart = screen.getByTestId("objective-history"); + expect(chart.dataset.dividers).toBe("5"); + expect(chart.dataset.xMax).toBe(String(4 + 30)); + expect(screen.getByText(/^Following step 4 of 30/u)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Stop$/u })); + + expect(cancelOptimization).toHaveBeenCalledTimes(1); + expect(cancelOptimization).toHaveBeenCalledWith("study-2"); + }); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx index f493db7e8cf..37f20eae67d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -18,7 +18,7 @@ export { } from "./drawer-frame/frame-header"; export { FrameColumns } from "./drawer-frame/frame-columns"; export { FrameCard, type FrameCardMore } from "./drawer-frame/frame-card"; -export { Fold, type FoldProps } from "./drawer-frame/fold"; +export { Fold } from "./drawer-frame/fold"; export { type ComputeBatch, ComputeBatchesChip, From 7ff00468e63897bb415fa292c5dbd86acb8b4e40 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 02:27:16 +0200 Subject: [PATCH 20/21] Break the sweep's best-so-far line at each divider and give a sweep whose only study failed at start the empty summary --- .../experiments/sweep-objective-strip.tsx | 5 +-- .../sweep-objective-history.test.ts | 15 ++++++++ .../sweep-objective-history.ts | 10 +++--- .../view-experiment-drawer.test.tsx | 16 +++++++++ .../shared/objective-history-chart.tsx | 2 +- .../shared/objective-history-data.test.ts | 33 ++++++++++++++++++ .../shared/objective-history-data.ts | 34 +++++++++++++++---- .../content/ui/optimizations-tab.mdx | 6 ++-- 8 files changed, 105 insertions(+), 16 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx index 8fb6dac31f5..254e7407ac5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx @@ -132,7 +132,7 @@ const chartWrapStyle = css({ paddingTop: "2", }); -/** `Infected peak · 47 steps in 2 optimizations · best 650.500`. */ +/** `Infected peak · 47 steps in 2 optimizations · best 650.500`; without a metric name, `0 steps`. */ const describeHistory = ( metricName: string, steps: number, @@ -140,7 +140,8 @@ const describeHistory = ( best: number | null, ): string => [ - `${metricName} · ${steps} ${steps === 1 ? "step" : "steps"}${ + ...(metricName === "" ? [] : [metricName]), + `${steps} ${steps === 1 ? "step" : "steps"}${ studies > 1 ? ` in ${studies} optimizations` : "" }`, ...(best === null ? [] : [`best ${formatNumber(best)}`]), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts index a9e0082cf61..9cd10b9b43d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.test.ts @@ -158,6 +158,21 @@ describe("buildSweepObjectiveHistory", () => { }); }); + it("summarises nothing when the only study failed before its first step", () => { + expect( + buildSweepObjectiveHistory([ + study([], { requestedTrials: 30, status: "error", best: null }), + ]), + ).toEqual({ + points: [], + xMax: 0, + dividers: [], + studyCount: 0, + metricName: "", + best: null, + }); + }); + it("divides before a study that has just started, where its first step will land", () => { const history = buildSweepObjectiveHistory([ study([1, 2]), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts index 57d2cbb38dd..0777bcd0e5c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip/sweep-objective-history.ts @@ -42,7 +42,8 @@ type SweepStudy = Pick< * numbered after the previous study's run steps (`trials.length`, so a * stopped study leaves no gap), and best-so-far restarts with each study, * whose metric and direction may differ from the last. A study that ended - * without a step, failed at start, adds no divider and no count. + * without a step, failed at start, adds no divider and no count; a sweep + * whose studies all did is summarised as one that ran none. */ export const buildSweepObjectiveHistory = ( studies: readonly SweepStudy[], @@ -70,7 +71,7 @@ export const buildSweepObjectiveHistory = ( offset += study.trials.length; } const last = studies.at(-1); - if (last === undefined) { + if (last === undefined || lastCounted === null) { return { points, xMax: 0, @@ -80,7 +81,6 @@ export const buildSweepObjectiveHistory = ( best: null, }; } - const summarised = lastCounted ?? last; return { points, xMax: isOptimizationActive(last) @@ -91,7 +91,7 @@ export const buildSweepObjectiveHistory = ( : points.length, dividers, studyCount, - metricName: objectiveMetricName(summarised.input), - best: summarised.best?.objective ?? null, + metricName: objectiveMetricName(lastCounted.input), + best: lastCounted.best?.objective ?? null, }; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index df970bb7dd6..b00cbe9526e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -375,6 +375,22 @@ describe("ViewExperimentDrawer in the frame", () => { ).toBeNull(); }); + it("reads the strip's row as 0 steps, without a metric, when the sweep's only study failed before its first step", () => { + renderDrawerWithStudies({ ...sweep, status: "idle" }, [ + sweepStudy(sweep, "cancelled", { + status: "error", + error: "worker crashed", + trials: [], + best: null, + completedTrials: 0, + prunedTrials: 0, + }), + ]); + + const row = screen.getByRole("button", { name: /^Objective by step/u }); + expect(row.textContent).toMatch(/step0 steps$/u); + }); + it("draws the study's objective under the sliders while it drives the sweep and once it settles, at one layout", () => { const signatures = (["running", "cancelled"] as const).map((status) => { const view = renderDrawerWithStudy({ ...sweep, status: "idle" }, status); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx index ac0c28d737b..db755ec4c47 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx @@ -216,7 +216,7 @@ export const ObjectiveHistoryChart = ({ const chartRootRef = useRef(null); const size = useElementSize(chartRootRef); const plotRef = useRef(null); - const data = toObjectiveHistoryData(points); + const data = toObjectiveHistoryData(points, dividers); const width = size?.width ?? 0; const hasWidth = width > 0; // The dividers reach the plot through its options; a change of them (a diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.test.ts index 212cd9650dd..7fbd458ab92 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.test.ts @@ -87,6 +87,39 @@ describe("toObjectiveHistoryData", () => { [3, 3, 5], ]); }); + + it("breaks the best-so-far series before each divider", () => { + const points = [10, 12, 12, 5, 9].map((bestSoFar, index) => ({ + step: index + 1, + objective: bestSoFar, + bestSoFar, + feasibility: "unknown" as const, + })); + + expect(toObjectiveHistoryData(points, [4])).toEqual([ + [1, 2, 3, 3.5, 4, 5], + [10, 12, 12, null, 5, 9], + [10, 12, 12, null, 5, 9], + ]); + expect(toObjectiveHistoryData(points, [])).toEqual([ + [1, 2, 3, 4, 5], + [10, 12, 12, 5, 9], + [10, 12, 12, 5, 9], + ]); + }); + + it("lays no gap for a divider no step has reached yet", () => { + const points = buildObjectiveHistory( + [trial(0, 3), trial(1, 5)], + "maximize", + ); + + expect(toObjectiveHistoryData(points, [3])).toEqual([ + [1, 2], + [3, 5], + [3, 5], + ]); + }); }); describe("trialFeasibility", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.ts index c343911f8a0..8180a54d22a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-data.ts @@ -72,11 +72,33 @@ export const buildObjectiveHistory = ( }); }; -/** uPlot aligned data: `[steps, objectives, bestSoFar]`. */ +/** + * uPlot aligned data: `[steps, objectives, bestSoFar]`. Half a step before + * each divider step a gap sample (a null objective and best) is laid in, so + * the stepped best-so-far line ends with one study and starts anew with the + * next instead of holding across the divider; the dots skip the null, and + * the x axis labels whole steps alone. + */ export const toObjectiveHistoryData = ( points: readonly ObjectiveHistoryPoint[], -): uPlot.AlignedData => [ - points.map((point) => point.step), - points.map((point) => point.objective), - points.map((point) => point.bestSoFar), -]; + dividers: readonly number[] = [], +): uPlot.AlignedData => { + const steps: number[] = []; + const objectives: (number | null)[] = []; + const bestSoFar: (number | null)[] = []; + const pendingDividers = dividers.toSorted((left, right) => left - right); + let nextDivider = pendingDividers.at(0); + for (const point of points) { + while (nextDivider !== undefined && nextDivider <= point.step) { + steps.push(nextDivider - 0.5); + objectives.push(null); + bestSoFar.push(null); + pendingDividers.shift(); + nextDivider = pendingDividers.at(0); + } + steps.push(point.step); + objectives.push(point.objective); + bestSoFar.push(point.bestSoFar); + } + return [steps, objectives, bestSoFar]; +}; diff --git a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx index 3d1721d036c..b4575b84f82 100644 --- a/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx +++ b/libs/@local/petrinaut-arch-docs/content/ui/optimizations-tab.mdx @@ -115,7 +115,8 @@ objective by step, the shared `ObjectiveHistoryChart` `buildObjectiveHistory` (`SimulateView/shared/objective-history-data.ts`) orders the trials by step and threads the best so far through the completed ones, `toObjectiveHistoryData` lays them out as uPlot aligned data -(`[steps, objectives, bestSoFar]`), and the chart draws the objectives as +(`[steps, objectives, bestSoFar]`, with a null gap sample half a step before +each divider so the best line breaks between studies), and the chart draws the objectives as dots and the best as a stepped line, in the palette and sizes its `ObjectiveHistoryStyle` names, with an optional pinned right edge (`xMax`) and dashed `dividers` between studies for the sweep's objective strip @@ -224,7 +225,8 @@ and yields the `xMax` and `dividers` the chart takes: a divider at the first step of every further study that drew a step or is about to, and the right edge pinned to the last study's `requestedTrials` while it is active, so the axis stops at the last step run once Stop or an error ended it. A study that -failed before its first step counts nowhere. +failed before its first step counts nowhere; a sweep whose only study did +gets the empty summary, and the row reads `0 steps` with no metric. ## The steps table From 76d4a9e637b89bf9327aaa206c875574d0cfb496 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 05:54:00 +0200 Subject: [PATCH 21/21] Say the sweep's objective strip has no steps run instead of waiting when its every study failed at start --- .../experiments/sweep-objective-strip.tsx | 7 ++++++ .../view-experiment-drawer.test.tsx | 22 +++++++++++++++++++ .../shared/objective-history-chart.tsx | 5 ++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx index 254e7407ac5..ffb0e1eed2b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-objective-strip.tsx @@ -191,6 +191,13 @@ export const SweepObjectiveStrip = ({ style={sweepObjectiveStyle} xMax={history.xMax} dividers={history.dividers} + // A study about to draw its first step is waited for; a history + // whose every study failed at start has run none. + emptyLabel={ + history.studyCount === 0 + ? "No steps run" + : "Waiting for the first step" + } />
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index b00cbe9526e..9ef4b1ce9dc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -145,16 +145,19 @@ vi.mock("../shared/objective-history-chart", () => ({ plotHeight, xMax, dividers, + emptyLabel, }: { plotHeight: number; xMax: number | undefined; dividers: readonly number[]; + emptyLabel: string; }) => (
), })); @@ -389,6 +392,25 @@ describe("ViewExperimentDrawer in the frame", () => { const row = screen.getByRole("button", { name: /^Objective by step/u }); expect(row.textContent).toMatch(/step0 steps$/u); + // Nothing is coming: the fold says so instead of waiting for a step. + expect(screen.getByTestId("objective-history").dataset.emptyLabel).toBe( + "No steps run", + ); + }); + + it("waits for the first step in the strip's fold while the driving study has drawn none yet", () => { + renderDrawerWithStudies({ ...sweep, status: "idle" }, [ + sweepStudy(sweep, "running", { + trials: [], + best: null, + completedTrials: 0, + prunedTrials: 0, + }), + ]); + + expect(screen.getByTestId("objective-history").dataset.emptyLabel).toBe( + "Waiting for the first step", + ); }); it("draws the study's objective under the sliders while it drives the sweep and once it settles, at one layout", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx index db755ec4c47..60c06008dae 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/objective-history-chart.tsx @@ -203,6 +203,7 @@ export const ObjectiveHistoryChart = ({ style = defaultObjectiveHistoryStyle, xMax, dividers = noDividers, + emptyLabel = "Waiting for the first step", }: { points: readonly ObjectiveHistoryPoint[]; /** The plot's height in pixels; the component is exactly this tall. */ @@ -212,6 +213,8 @@ export const ObjectiveHistoryChart = ({ xMax?: number; /** Steps a dashed vertical line is drawn before: where a new study began. */ dividers?: readonly number[]; + /** What the plot says while there is no point to draw. */ + emptyLabel?: string; }) => { const chartRootRef = useRef(null); const size = useElementSize(chartRootRef); @@ -267,7 +270,7 @@ export const ObjectiveHistoryChart = ({ >
{points.length === 0 ? ( - Waiting for the first step + {emptyLabel} ) : null}
);