diff --git a/.changeset/metric-card-chart-options.md b/.changeset/metric-card-chart-options.md new file mode 100644 index 00000000000..a81685f56d4 --- /dev/null +++ b/.changeset/metric-card-chart-options.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Metric cards lay out their chart options as runs and time aggregates and gain an Enlarge button. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts b/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts index 087bfde99d6..4a64383759f 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts @@ -106,6 +106,12 @@ export type GpuBackend = { * through the same escape/overflow re-runs that calibrate from scratch. */ calibration: Map; + /** + * Probes in flight, keyed like `calibration`: a batch that starts while + * another still probes its marking awaits the entry instead of probing + * too (`gpu-experiment-handle/shared-calibration`). + */ + calibrating: Map>; framesPerDispatch: number; /** Notes that did not prevent use, e.g. user code that fell back to a default. */ warnings: string[]; @@ -236,6 +242,7 @@ export async function requestGpuExperimentBackend( derivedCapacities: probeCapacities, recompile: compileWith, calibration: new Map(), + calibrating: new Map(), framesPerDispatch, warnings, }; 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 90429633344..4f20ee0d88c 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 @@ -245,8 +245,16 @@ describe("gpuBackendSetupKey", () => { }; it("ignores the values of per-run-buffered parameters", () => { + // A point batch at one rate and a range batch at another both carry the + // rate in the buffer, so every selection of a sweep shares one setup. const other = { ...base, parameterValues: { rate: "3.9", size: "10" } }; expect(gpuBackendSetupKey(base)).toBe(gpuBackendSetupKey(other)); + expect(gpuBackendSetupKey(base)).toBe( + gpuBackendSetupKey({ + ...base, + parameterValues: { rate: "0.25", size: "10" }, + }), + ); }); it("keys on baked values, marking, net identity, and metric set", () => { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts new file mode 100644 index 00000000000..1221bf0b05d --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { requestGpuExperimentBackend } from "./backend"; +import { createGpuMonteCarloExperiment } from "./gpu-experiment-handle"; +import { + outcome, + placeAt, + shaderAt, +} from "./gpu-experiment-handle/calibration.test-helpers"; +import { runGpuExperiment } from "./runner"; + +import type { HirArtifacts } from "../hir-runtime"; +import type { SDCPN } from "../types/sdcpn"; +import type { GpuBackend } from "./backend"; +import type { CompiledNetShader } from "./compile-net-shader"; +import type { GpuExperimentRequest, GpuExperimentResult } from "./runner"; + +vi.mock("./backend", async (importOriginal) => ({ + ...(await importOriginal>()), + requestGpuExperimentBackend: vi.fn(), +})); + +vi.mock("./runner", async (importOriginal) => ({ + ...(await importOriginal>()), + runGpuExperiment: vi.fn(), +})); + +const emptyNet: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +/** A backend with a derived-capacity place per slab and no device behind it. */ +const fakeBackend = (capacities: Record): GpuBackend => { + const derived = new Map(Object.entries(capacities)); + return { + supported: true, + handle: { + device: { destroy: () => {}, lost: new Promise(() => {}) }, + info: "fake adapter", + } as unknown as GpuBackend["handle"], + shader: shaderAt(derived, { metricIds: [] }), + profile: { + places: [...derived].map((entry) => placeAt(entry)), + uncolouredOnly: derived.size === 0, + bytesPerRun: 16, + }, + derivedCapacities: derived, + recompile: (next) => ({ + ok: true, + shader: shaderAt(next, { metricIds: [] }), + }), + calibration: new Map(), + calibrating: new Map(), + framesPerDispatch: 16, + warnings: [], + }; +}; + +type PendingRun = { + shader: CompiledNetShader; + request: GpuExperimentRequest; + resolve: (result: GpuExperimentResult) => void; +}; + +/** Every attempt the handles under test asked the runner for, unresolved until the test says. */ +const pendingRuns: PendingRun[] = []; + +const createHandle = async (backend: GpuBackend) => { + vi.mocked(requestGpuExperimentBackend).mockResolvedValue(backend); + const created = await createGpuMonteCarloExperiment({ + sdcpn: emptyNet, + hirArtifacts: {} as unknown as HirArtifacts, + initialMarking: {}, + parameterValues: {}, + seed: 1, + dt: 0.1, + maxTime: 1, + runCount: 1000, + metricSpecs: [], + }); + if (!created.supported) { + throw new Error(created.reason); + } + return created.handle; +}; + +/** Lets the handles' attempt chains settle: each await in them costs a microtask. */ +const flush = async () => { + for (let tick = 0; tick < 32; tick += 1) { + await Promise.resolve(); + } +}; + +describe("createGpuMonteCarloExperiment", () => { + beforeEach(() => { + pendingRuns.length = 0; + vi.mocked(runGpuExperiment).mockImplementation( + (_handle, shader, request) => + new Promise((resolve) => { + pendingRuns.push({ + shader, + request, + resolve: (result) => resolve({ ok: true, result }), + }); + }), + ); + }); + + it("runs two batches on one marking at once when neither needs a probe", async () => { + const backend = fakeBackend({}); + const first = await createHandle(backend); + const second = await createHandle(backend); + + first.start(); + second.start(); + await flush(); + + expect(pendingRuns.map(({ request }) => request.runCount)).toEqual([ + 1000, 1000, + ]); + + for (const pending of pendingRuns) { + pending.resolve(outcome({ completedRuns: 1000 })); + } + await flush(); + expect([first.status.get(), second.status.get()]).toEqual([ + "Complete", + "Complete", + ]); + }); + + it("makes a batch wait for the probe another runs on its marking, then adopt it", async () => { + const backend = fakeBackend({ p: 64 }); + const first = await createHandle(backend); + const second = await createHandle(backend); + + first.start(); + await flush(); + second.start(); + await flush(); + + expect(pendingRuns.map(({ request }) => request.runCount)).toEqual([128]); + + pendingRuns[0]!.resolve( + outcome({ derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }] }), + ); + await flush(); + + // Both full attempts run at the slab the one probe sized. + expect(pendingRuns.slice(1).map(({ request }) => request.runCount)).toEqual( + [1000, 1000], + ); + expect( + pendingRuns.slice(1).map(({ shader }) => shader.stateWordsPerRun), + ).toEqual([4 + 19 * 2, 4 + 19 * 2]); + expect(backend.calibration.size).toBe(1); + }); + + it("lets one waiter probe again when the shared probe stored nothing", async () => { + const backend = fakeBackend({ p: 64 }); + const first = await createHandle(backend); + const second = await createHandle(backend); + const third = await createHandle(backend); + + first.start(); + await flush(); + second.start(); + third.start(); + await flush(); + + expect(pendingRuns.map(({ request }) => request.runCount)).toEqual([128]); + + // An abandoned probe releases both waiters without a calibration to + // adopt; only the first to wake may probe, the other waits on it. + pendingRuns[0]!.resolve(outcome({ cancelled: true })); + await flush(); + + expect(first.status.get()).toBe("Error"); + expect(pendingRuns.slice(1).map(({ request }) => request.runCount)).toEqual( + [128], + ); + expect(backend.calibrating.size).toBe(1); + + pendingRuns[1]!.resolve( + outcome({ derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }] }), + ); + await flush(); + + expect(pendingRuns.slice(2).map(({ request }) => request.runCount)).toEqual( + [1000, 1000], + ); + expect( + pendingRuns.slice(2).map(({ shader }) => shader.stateWordsPerRun), + ).toEqual([4 + 19 * 2, 4 + 19 * 2]); + expect([second.status.get(), third.status.get()]).toEqual([ + "Running", + "Running", + ]); + }); + + it("publishes no progress for a probe's chunks", async () => { + const handle = await createHandle(fakeBackend({ p: 64 })); + + handle.start(); + await flush(); + + pendingRuns[0]!.request.onChunk?.({ + framesDone: 10, + frameLimit: 10, + runsCompleted: 128, + runsInTile: 0, + runCount: 128, + }); + expect(handle.progress.get()?.completedRuns).toBe(0); + + pendingRuns[0]!.resolve( + outcome({ derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }] }), + ); + await flush(); + pendingRuns[1]!.request.onChunk?.({ + framesDone: 5, + frameLimit: 10, + runsCompleted: 0, + runsInTile: 128, + runCount: 1000, + }); + expect(handle.progress.get()).toMatchObject({ + completedRuns: 0, + advancedRuns: 128, + }); + }); +}); 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 d59aab509dd..3e7a363573a 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -5,12 +5,14 @@ * care which backend produced them. Keeping that contract identical is what lets * the GPU path be a setting rather than a parallel UI. * - * Supportability is resolved *before* the handle exists — eligibility, HIR - * lowering, shader generation and the capacity probe all happen in `create...`, - * which returns a reason instead of a handle when the net cannot run. A handle - * that could fail on `start()` would leave the caller unable to fall back - * cleanly, because by then the experiment is already registered and showing as - * running. + * Supportability that can be decided statically — eligibility, HIR lowering, + * shader generation, a marking against declared capacities — is resolved + * *before* the handle exists: `create...` returns a reason instead of a handle + * when the net cannot run, so the caller can still fall back. What only a run + * can tell — the slab a derived-capacity place needs, a metric's window — is + * calibrated as the first phase of `start()`, so the probe's chunks stream to + * the charts like every other attempt's instead of arriving en bloc once the + * handle exists. A probe that concedes ends the run with its reason. */ import { resolveNetParameterValues } from "../parameter-values"; import { @@ -24,23 +26,17 @@ import { requestGpuExperimentBackend } from "./backend"; import { encodeInitialTokenWords } from "./compile-net-shader"; import { placeCountCeiling } from "./eligibility"; import { gpuBackendSetupKey } from "./gpu-backend-cache"; -import { - probeDerivedCapacities, - probeRunCount, - probeWindows, - rememberCalibration, - RUN_POLICY, - runUntilCalibrated, -} from "./gpu-experiment-handle/calibration"; +import { rememberCalibration } 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 { - anyEscapes, - calibrationKey, - planInitialWindows, -} from "./metric-windows"; + needsProbe, + runCalibratedExperiment, +} from "./gpu-experiment-handle/run-phase"; +import { shareCalibration } from "./gpu-experiment-handle/shared-calibration"; +import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames"; +import { anyEscapes, calibrationKey } from "./metric-windows"; import { GPU_PREVIEW_RUNS, runGpuExperiment } from "./runner"; import type { AbortSignalLike } from "../environment"; @@ -101,10 +97,10 @@ export type CreateGpuMonteCarloExperimentConfig = { /** Caps runs per tile below the device's limit. For tests and benchmarks. */ maxRunsPerTile?: number; /** - * Abandons creation: checked after acquiring the backend and between the - * capacity probe's attempts, each of which is a GPU round-trip. Creation - * then throws an `AbortError`, which the selection walk rethrows rather - * than treating as a refusal that sends the experiment to the CPU. + * Abandons creation: checked once the backend is acquired, when creation + * throws an `AbortError`, which the selection walk rethrows rather than + * treating as a refusal that sends the experiment to the CPU. A handle + * already created stops at its next chunk instead. */ signal?: AbortSignalLike; /** @@ -273,7 +269,7 @@ export async function createGpuMonteCarloExperiment( let aborted = false; // A minimal `AbortSignalLike`: the runner only reads `aborted`, and building // a real AbortController would pull a DOM global into this package. The - // creation signal folds in so an abandoned probe stops at its next chunk. + // creation signal folds in so an abandoned run stops at its next chunk. const signal = { get aborted() { return aborted || creationAborted(); @@ -386,7 +382,17 @@ export async function createGpuMonteCarloExperiment( placeTokenWords, metricIds, }); - const cachedCalibration = backend.calibration.get(batchCalibrationKey); + const adoptCalibration = (): MetricWindow[] | null => { + const cached = backend.calibration.get(batchCalibrationKey); + if (cached === undefined) { + return null; + } + session.shader = cached.shader; + for (const [placeId, capacity] of cached.capacities) { + session.capacities.set(placeId, capacity); + } + return [...cached.windows]; + }; const storeCalibration = (windows: readonly MetricWindow[]) => { if (metricIds.length === 0 && session.capacities.size === 0) { return; @@ -404,6 +410,7 @@ export async function createGpuMonteCarloExperiment( runCount: attemptRunCount, windows, preview, + probe, }) => runGpuExperiment(backend.handle, shader, { runCount: attemptRunCount, @@ -440,7 +447,9 @@ export async function createGpuMonteCarloExperiment( }), signal, onChunk: ({ framesDone, runsCompleted, runsInTile }) => { - if (disposed) { + // A probe's runs are a prefix the full attempt runs again, so + // reporting them would show progress that then falls back to zero. + if (disposed || probe) { return; } // Overall position, monotone across tiles: finished tiles count as @@ -476,109 +485,52 @@ export async function createGpuMonteCarloExperiment( 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) { - session.capacities.set(placeId, capacity); - } - calibratedWindows = [...cachedCalibration.windows]; - } else if (session.capacities.size > 0) { - const probed = await probeDerivedCapacities({ - session, - runCount: config.runCount, - windowInputs, - placeCounts, - execute: executeAttempt, - stopped: creationAborted, - }); - if (creationAborted()) { - disposed = true; - releaseBackend(); - throw abortError(); - } - if (!probed.ok) { - disposed = true; - releaseBackend({ evict: true }); - return { - supported: false, - cause: "net-unsupported", - reason: probed.reason, - }; - } - 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 () => { - 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. - let windows = - calibratedWindows ?? - planInitialWindows(windowInputs, session.shader.histogramBins); - const blindWindows = windowInputs.some((input) => input.ceiling === null); - if ( - calibratedWindows === null && - blindWindows && - metricIds.length > 0 && - !aborted - ) { - const probeRuns = probeRunCount(session.shader, config.runCount); - const probe = await probeWindows({ - session, - windows, - execute: executeAttempt, - runCount: probeRuns, - }); - if (isDisposed()) { - return; + // The probes run here, after `start()`, so their chunks stream like + // every other attempt's: the first picture a batch shows is the probe's, + // overwritten progressively as the full attempt lands. A batch that + // starts while another on this marking still probes waits for that + // calibration rather than probing too; one that needs no probe has + // nothing to wait for and runs at once. A probe that stores nothing + // (failed, cancelled) wakes every waiter at once, so each re-reads the + // map after waiting: the first to wake claims, the rest wait on it. + let calibratedWindows = adoptCalibration(); + let settle = () => {}; + while (calibratedWindows === null && needsProbe(session, windowInputs)) { + const share = shareCalibration(backend.calibrating, batchCalibrationKey); + if (share.inFlight === undefined) { + settle = share.claim(); + break; } - if (!probe.ok) { - fail(probe.reason); - return; - } - if (probe.result.cancelled) { - finish("cancelled"); - return; - } - const probeFailure = metricFailureIn( - probe.result.metricErrors, - probeRuns, - ); - if (probeFailure !== null) { - fail(probeFailure); + await share.inFlight; + if (isDisposed()) { return; } - windows = probe.windows; - storeCalibration(windows); + calibratedWindows = adoptCalibration(); } - - const calibrated = await runUntilCalibrated({ + const calibrated = await runCalibratedExperiment({ session, - runsFor: () => config.runCount, - windows, + calibratedWindows, + windowInputs, + placeCounts, + runCount: config.runCount, execute: executeAttempt, - policy: RUN_POLICY, - stopped: () => isDisposed() || aborted, - }); + stopped: () => isDisposed() || signal.aborted, + remember: (windows) => { + storeCalibration(windows); + settle(); + }, + metricFailure: metricFailureIn, + }).finally(settle); if (isDisposed()) { return; } - if (!calibrated.ok) { + if (calibrated.kind === "stopped") { + finish("cancelled"); + return; + } + if (calibrated.kind === "failed") { fail(calibrated.reason); return; } diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts new file mode 100644 index 00000000000..702847d25ea --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts @@ -0,0 +1,92 @@ +/** + * Test builders for the GPU handle's calibration sessions: shaders whose + * only relevant facts are their size and derived places, profiles with one + * derived-capacity place per slab, and runner outcomes. Not shipped — only + * imported from `*.test.ts` files. + */ +import type { CompiledNetShader } from "../compile-net-shader"; +import type { GpuNetProfile } from "../eligibility"; +import type { GpuExperimentResult } from "../runner"; +import type { CalibrationSession } from "./calibration"; + +/** A shader whose only relevant facts are its size and its derived places. */ +export const shaderAt = ( + capacities: ReadonlyMap, + { metricIds = ["m0"] }: { metricIds?: string[] } = {}, +): CompiledNetShader => { + const slabWords = [...capacities.values()].reduce( + (sum, capacity) => sum + capacity * 2, + 0, + ); + return { + wgsl: "", + stateWordsPerRun: 4 + slabWords, + summaryWordsPerRun: 2 + capacities.size, + placeCountOffsets: [0], + placeTokenOffsets: [4], + placeTokenStrides: [2], + summaryStatusOffset: 1, + rngOffset: 2, + statusOffset: 3, + derivedCapacityPlaceIndices: [...capacities.keys()].map( + (_, index) => index, + ), + metricIds, + histogramBins: 64, + runParameterIds: [], + compiledLambdas: [], + }; +}; + +/** A derived-capacity place per slab, in slab order. */ +export const placeAt = ( + [id, capacity]: [string, number], + { pairConsumed = false }: { pairConsumed?: boolean } = {}, +): GpuNetProfile["places"][number] => ({ + id, + name: id.toUpperCase(), + capacity, + capacitySource: "derived", + declaredCapacity: 0xffffffff, + realFields: ["x", "y"], + discreteFields: [], + colored: true, + pairConsumed, +}); + +/** A session over a derived-capacity place per slab, recompiling at any size. */ +export const session = ( + capacities: Record, + { pairConsumed = false }: { pairConsumed?: boolean } = {}, +): CalibrationSession => { + const initial = new Map(Object.entries(capacities)); + return { + backend: { + recompile: (next) => ({ ok: true, shader: shaderAt(next) }), + profile: { + places: [...initial].map((entry) => placeAt(entry, { pairConsumed })), + uncolouredOnly: false, + bytesPerRun: 16, + }, + }, + shader: shaderAt(initial), + capacities: initial, + }; +}; + +/** A finished runner result with nothing observed, overridable per test. */ +export const outcome = ( + overrides: Partial = {}, +): GpuExperimentResult => ({ + cancelled: false, + frames: [], + finalPlaceCounts: new Uint32Array(0), + deadlockedRuns: 0, + completedRuns: 0, + overflowRuns: 0, + derivedPlaceMaxes: [], + dispatchMs: 0, + metricRanges: [], + metricErrors: [], + ...overrides, +}); 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 47d72b2ff07..5aeac1e2be1 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,90 +11,11 @@ import { runUntilCalibrated, slabsFromProbe, } from "./calibration"; +import { outcome, session, shaderAt } from "./calibration.test-helpers"; import { metricFailure } from "./metric-failure"; import type { GpuCalibration } from "../backend"; -import type { CompiledNetShader } from "../compile-net-shader"; -import type { GpuExperimentResult } from "../runner"; -import type { - AttemptResult, - CalibrationSession, - ExecuteAttempt, -} from "./calibration"; - -/** A shader whose only relevant facts are its size and its derived places. */ -const shaderAt = ( - capacities: ReadonlyMap, - metricCount = 1, -): CompiledNetShader => { - const slabWords = [...capacities.values()].reduce( - (sum, capacity) => sum + capacity * 2, - 0, - ); - return { - wgsl: "", - stateWordsPerRun: 4 + slabWords, - summaryWordsPerRun: 2 + capacities.size, - placeCountOffsets: [0], - placeTokenOffsets: [4], - placeTokenStrides: [2], - summaryStatusOffset: 1, - rngOffset: 2, - statusOffset: 3, - derivedCapacityPlaceIndices: [...capacities.keys()].map(() => 0), - metricIds: Array.from({ length: metricCount }, (_, index) => `m${index}`), - histogramBins: 64, - runParameterIds: [], - compiledLambdas: [], - }; -}; - -const session = ( - capacities: Record, - { pairConsumed = false }: { pairConsumed?: boolean } = {}, -): CalibrationSession => { - const initial = new Map(Object.entries(capacities)); - return { - backend: { - recompile: (next) => ({ ok: true, shader: shaderAt(next) }), - profile: { - places: [ - { - id: "p", - name: "P", - capacity: initial.get("p") ?? 0, - capacitySource: "derived", - declaredCapacity: 0xffffffff, - realFields: ["x", "y"], - discreteFields: [], - colored: true, - pairConsumed, - }, - ], - uncolouredOnly: false, - bytesPerRun: 16, - }, - }, - shader: shaderAt(initial), - capacities: initial, - }; -}; - -const outcome = ( - overrides: Partial = {}, -): GpuExperimentResult => ({ - cancelled: false, - frames: [], - finalPlaceCounts: new Uint32Array(0), - deadlockedRuns: 0, - completedRuns: 0, - overflowRuns: 0, - derivedPlaceMaxes: [], - dispatchMs: 0, - metricRanges: [{ min: 3, max: 9, below: 0, above: 0 }], - metricErrors: [], - ...overrides, -}); +import type { AttemptResult, ExecuteAttempt } from "./calibration"; /** Replays scripted results and records what each attempt asked for. */ const scripted = (results: AttemptResult[]) => { @@ -130,7 +51,9 @@ describe("runUntilCalibrated", () => { expect(attempts.map((attempt) => attempt.shader.stateWordsPerRun)).toEqual([ 24, 44, ]); - expect(attempts.every((attempt) => attempt.preview)).toBe(true); + expect(attempts.every((attempt) => attempt.preview && !attempt.probe)).toBe( + true, + ); }); it("gives up growing after the policy's budget and hands back the overflow", async () => { @@ -332,6 +255,19 @@ describe("slabsFromProbe", () => { ).toEqual({ ok: true, capacities: new Map([["p", 40]]) }); }); + it("never sizes a slab below its floor", () => { + const current = session({ p: 64 }); + + expect( + slabsFromProbe( + current, + outcome({ derivedPlaceMaxes: [{ max: 20, meanRunMax: 15 }] }), + [3], + new Map([["p", 200]]), + ), + ).toEqual({ ok: true, capacities: new Map([["p", 200]]) }); + }); + it("refuses a heavy tail whose slab would exceed the arena threshold", () => { const current = session({ p: 64 }); @@ -367,7 +303,7 @@ describe("probeDerivedCapacities", () => { }); expect(attempts).toEqual([ - expect.objectContaining({ runCount: 128, preview: false }), + expect.objectContaining({ runCount: 128, preview: false, probe: true }), ]); expect(current.capacities).toEqual(new Map([["p", 19]])); expect(current.shader.stateWordsPerRun).toBe(4 + 19 * 2); @@ -525,7 +461,7 @@ describe("probeWindows", () => { // 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 }), + expect.objectContaining({ runCount: 5, preview: false, probe: true }), ]); // 21 counts observed, margin ceil(21 × 0.25) = 6 → [14, 46] over 64 bins. expect(probed).toMatchObject({ 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 b13e01444fa..f73c893fe6b 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 @@ -12,8 +12,8 @@ * 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 - * experiment to the CPU. + * still overflows there is handed back as it stands, and the caller reports + * why. */ import { derivedSlabCeiling, tokenWordCount } from "../eligibility"; import { @@ -42,8 +42,8 @@ export const GPU_PROBE_MEMORY_BYTES = 128 * 1024 * 1024; * its probe shows a heavy tail — outlier runs far past the typical maximum. * Below it, sizing for the outlier is cheap enough to just do; past it, the * right structure is a per-run token arena (shared place-tagged slots sized - * by the simultaneous total), and until that exists the experiment runs on - * the CPU, which sizes its buffers dynamically. + * by the simultaneous total), and until that exists the run is refused with + * a reason that points at the CPU, which sizes its buffers dynamically. */ export const GPU_ARENA_SLAB_BYTES = 64 * 1024; @@ -67,6 +67,8 @@ export type CalibrationPolicy = { maxWindowReplans: number; /** Whether attempts open with a preview tile. */ preview: boolean; + /** Whether the attempts calibrate rather than run the experiment itself. */ + probe: boolean; }; /** @@ -79,6 +81,7 @@ export const PROBE_POLICY: CalibrationPolicy = { maxSlabGrowths: 7, maxWindowReplans: 0, preview: false, + probe: true, }; /** @@ -90,6 +93,21 @@ export const RUN_POLICY: CalibrationPolicy = { maxSlabGrowths: 3, maxWindowReplans: 1, preview: true, + probe: false, +}; + +/** + * A full attempt on a calibration another batch measured grows once, by the + * probe's factor: a small overflow is a tail that one step covers, and a + * larger one means this batch's dynamics differ from the measured ones, so + * the caller probes afresh rather than doubling towards them. + */ +export const CACHED_RUN_POLICY: CalibrationPolicy = { + slabGrowth: 4, + maxSlabGrowths: 1, + maxWindowReplans: 1, + preview: true, + probe: false, }; /** The shader in force and the derived slabs it was compiled at. */ @@ -108,6 +126,11 @@ export type ExecuteAttempt = (attempt: { runCount: number; windows: readonly MetricWindow[]; preview: boolean; + /** + * A calibration attempt over a prefix of the runs: its frames stream like + * any other's, but its run counts are not the experiment's progress. + */ + probe: boolean; }) => Promise; export type CalibratedRun = @@ -202,6 +225,7 @@ export const runUntilCalibrated = async (options: { runCount: runsFor(session.shader), windows, preview: policy.preview, + probe: policy.probe, }); if (!attempt.ok) { return attempt; @@ -254,12 +278,15 @@ export const runUntilCalibrated = async (options: { * The slab each derived place gets for the full run, from the probe's per-run * maxima: the observed maximum plus margin, unless a heavy-tailed outlier * would need a slab past `GPU_ARENA_SLAB_BYTES` — that shape belongs on the - * CPU. + * CPU. A slab never shrinks below its place's `slabFloor`: a re-probe after + * a full attempt overflowed at grown slabs observes only a prefix of the + * runs, and sizing below what already overflowed would overflow again. */ export const slabsFromProbe = ( session: CalibrationSession, probe: GpuExperimentResult, placeCounts: readonly number[], + slabFloor?: ReadonlyMap, ): | { ok: true; capacities: Map } | { ok: false; reason: string } => { @@ -270,36 +297,43 @@ export const slabsFromProbe = ( ] of session.shader.derivedCapacityPlaceIndices.entries()) { const place = session.backend.profile.places[placeIndex]!; const stats = probe.derivedPlaceMaxes[slot] ?? { max: 0, meanRunMax: 0 }; - const capacity = Math.min( - Math.max(8, Math.ceil(stats.max * 1.5) + 4, placeCounts[placeIndex] ?? 0), - derivedSlabCeiling(place), + const observed = Math.max( + 8, + Math.ceil(stats.max * 1.5) + 4, + placeCounts[placeIndex] ?? 0, ); - const slabBytes = capacity * Math.max(1, tokenWordCount(place)) * 4; + const slabBytes = observed * Math.max(1, tokenWordCount(place)) * 4; if ( stats.max > 4 * Math.max(1, stats.meanRunMax) && slabBytes > GPU_ARENA_SLAB_BYTES ) { return { ok: false, - reason: `Probing \`${place.name}\` saw outlier runs reach ${stats.max} tokens against a typical per-run maximum of ${Math.round(stats.meanRunMax)}. Sizing every run for the outlier would take ${Math.round(slabBytes / 1024)} KB per run — that heavy-tailed shape needs a per-run token arena, so this experiment runs on the CPU.`, + reason: `Probing \`${place.name}\` saw outlier runs reach ${stats.max} tokens against a typical per-run maximum of ${Math.round(stats.meanRunMax)}. Sizing every run for the outlier would take ${Math.round(slabBytes / 1024)} KB per run — that heavy-tailed shape needs a per-run token arena. Switch this experiment to the CPU backend, which sizes its buffers dynamically.`, }; } - capacities.set(place.id, capacity); + capacities.set( + place.id, + Math.min( + Math.max(observed, slabFloor?.get(place.id) ?? 0), + derivedSlabCeiling(place), + ), + ); } return { ok: true, capacities }; }; /** - * Calibrates derived capacities before the handle exists, so the arena case - * can refuse cleanly and the caller falls back to the CPU: probes a small + * Calibrates derived capacities as a run's first attempts: 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, and counts - * the runs a non-finite metric sample halted, which the handle reports as the + * the runs a non-finite metric sample halted, which the caller reports as the * 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. + * a run the caller will not start. The arena case refuses with a reason the + * caller reports. */ export const probeDerivedCapacities = async (options: { session: CalibrationSession; @@ -312,6 +346,8 @@ export const probeDerivedCapacities = async (options: { * attempts and before the recompile at the probed slabs. */ stopped?: () => boolean; + /** Slabs the probed ones may not shrink below — see `slabsFromProbe`. */ + slabFloor?: ReadonlyMap; }): Promise< | { ok: true; @@ -323,7 +359,7 @@ export const probeDerivedCapacities = async (options: { } | { ok: false; reason: string } > => { - const { session, runCount, placeCounts, execute } = options; + const { session, runCount, placeCounts, execute, slabFloor } = options; const stopped = options.stopped ?? (() => false); const probeWindows = planInitialWindows( options.windowInputs, @@ -367,10 +403,10 @@ export const probeDerivedCapacities = async (options: { 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.`, + reason: `Probing this net's token counts kept overflowing past ${largest.toLocaleString()} tokens per place. Switch this experiment to the CPU backend, which sizes its buffers dynamically.`, }; } - const slabs = slabsFromProbe(session, probe.result, placeCounts); + const slabs = slabsFromProbe(session, probe.result, placeCounts, slabFloor); if (!slabs.ok) { return slabs; } @@ -402,6 +438,7 @@ export const probeWindows = async (options: { runCount, windows, preview: false, + probe: true, }); if (!attempt.ok) { return attempt; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts new file mode 100644 index 00000000000..512c905ce62 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; + +import { CACHED_RUN_POLICY, rememberCalibration } from "./calibration"; +import { outcome as emptyOutcome, session } from "./calibration.test-helpers"; +import { runCalibratedExperiment } from "./run-phase"; + +import type { GpuCalibration } from "../backend"; +import type { GpuExperimentResult } from "../runner"; +import type { + AttemptResult, + CalibrationSession, + ExecuteAttempt, +} from "./calibration"; + +/** A runner result whose probe observed one derived place and one metric. */ +const outcome = ( + overrides: Partial = {}, +): GpuExperimentResult => + emptyOutcome({ + derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }], + metricRanges: [{ min: 20, max: 40, below: 0, above: 0 }], + ...overrides, + }); + +/** Replays scripted results and records what each attempt asked for. */ +const scripted = (results: AttemptResult[]) => { + const attempts: Parameters[0][] = []; + const execute: ExecuteAttempt = (attempt) => { + attempts.push(attempt); + return Promise.resolve( + results.shift() ?? { ok: false, reason: "script exhausted" }, + ); + }; + return { execute, attempts }; +}; + +const blind = [{ integer: true, ceiling: null }]; + +const runWith = ( + current: CalibrationSession, + execute: ExecuteAttempt, + overrides: Partial[0]> = {}, +) => { + const remembered: (readonly { lo: number; stride: number }[])[] = []; + const run = runCalibratedExperiment({ + session: current, + calibratedWindows: null, + windowInputs: blind, + placeCounts: [3], + runCount: 1000, + execute, + stopped: () => false, + remember: (windows) => remembered.push(windows), + metricFailure: () => null, + ...overrides, + }); + return { run, remembered }; +}; + +/** Reports the first halted metric over the attempt's runs, as the handle's message does. */ +const haltedMetric = ( + metricErrors: readonly number[], + runCount: number, +): string | null => { + const halted = metricErrors.find((runs) => runs > 0); + return halted === undefined ? null : `halted ${halted} of ${runCount} runs`; +}; + +describe("runCalibratedExperiment", () => { + it("probes the derived capacities through the same execute before the full attempt", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome() }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute); + const result = await run; + + // The probe runs a prefix without a preview tile; the full attempt runs + // everything with one, at the slab the probe sized. + expect( + attempts.map(({ runCount, preview, probe }) => ({ + runCount, + preview, + probe, + })), + ).toEqual([ + { runCount: 128, preview: false, probe: true }, + { runCount: 1000, preview: true, probe: false }, + ]); + expect(attempts[1]?.shader.stateWordsPerRun).toBe(4 + 19 * 2); + expect(remembered).toEqual([[{ lo: 14, stride: 1, integer: true }]]); + expect(result).toMatchObject({ + kind: "calibrated", + result: { completedRuns: 1000 }, + }); + }); + + it("runs a cached calibration as a single attempt", async () => { + const current = session({ p: 19 }); + const { execute, attempts } = scripted([{ ok: true, result: outcome() }]); + + const { run, remembered } = runWith(current, execute, { + calibratedWindows: [{ lo: 14, stride: 1, integer: true }], + }); + await run; + + expect(attempts).toEqual([ + expect.objectContaining({ + runCount: 1000, + preview: true, + windows: [{ lo: 14, stride: 1, integer: true }], + }), + ]); + expect(remembered).toEqual([]); + }); + + it("probes afresh when a cached calibration still overflows after growth", async () => { + // Another selection's slabs undersize this one past the one growth a + // cached calibration gets: rather than failing, the run probes as a + // first batch would. + const current = session({ p: 10 }); + const overflowing = Array.from( + { length: 1 + CACHED_RUN_POLICY.maxSlabGrowths }, + () => ({ ok: true as const, result: outcome({ overflowRuns: 1 }) }), + ); + const { execute, attempts } = scripted([ + ...overflowing, + { + ok: true, + result: outcome({ derivedPlaceMaxes: [{ max: 100, meanRunMax: 90 }] }), + }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute, { + calibratedWindows: [{ lo: 0, stride: 1, integer: true }], + }); + const result = await run; + + expect(attempts.map(({ preview }) => preview)).toEqual([ + ...overflowing.map(() => true), + false, + true, + ]); + // The one growth is the probe's factor; the probe then sizes from what + // it observed. + expect(attempts[1]?.shader.stateWordsPerRun).toBe(4 + 40 * 2); + expect(current.capacities.get("p")).toBe(154); + expect(remembered).toHaveLength(1); + expect(result).toMatchObject({ + kind: "calibrated", + result: { completedRuns: 1000, overflowRuns: 0 }, + }); + }); + + it("floors the re-probe at the grown slabs and replaces the stale entry", async () => { + // The re-probe sees a prefix of the runs, not necessarily the one that + // overflowed: a place it observes small keeps the grown slab, while the + // place it observes large is sized from the observation — so the fresh + // calibration is no smaller than the stale one anywhere and replaces it. + const current = session({ p: 10, q: 50 }); + const key = "marking"; + const calibrations = new Map(); + rememberCalibration(calibrations, key, session({ p: 10, q: 50 }), []); + const overflowing = Array.from( + { length: 1 + CACHED_RUN_POLICY.maxSlabGrowths }, + () => ({ ok: true as const, result: outcome({ overflowRuns: 1 }) }), + ); + const { execute, attempts } = scripted([ + ...overflowing, + { + ok: true, + result: outcome({ + derivedPlaceMaxes: [ + { max: 100, meanRunMax: 90 }, + { max: 10, meanRunMax: 8 }, + ], + }), + }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run } = runWith(current, execute, { + calibratedWindows: [{ lo: 0, stride: 1, integer: true }], + placeCounts: [3, 3], + remember: (windows) => + rememberCalibration(calibrations, key, current, windows), + }); + const result = await run; + + const grown = new Map([ + ["p", 40], + ["q", 200], + ]); + const probed = new Map([ + ["p", 154], + ["q", 200], + ]); + expect(current.capacities).toEqual(probed); + expect(attempts.at(-1)?.shader.stateWordsPerRun).toBe(4 + (154 + 200) * 2); + for (const [placeId, capacity] of probed) { + expect(capacity).toBeGreaterThanOrEqual(grown.get(placeId)!); + } + expect(calibrations.get(key)?.capacities).toEqual(probed); + expect(result).toMatchObject({ + kind: "calibrated", + result: { completedRuns: 1000, overflowRuns: 0 }, + }); + }); + + it("hands back a cached calibration's attempt a metric halted instead of probing afresh", async () => { + // The same seeds halt the same run at any slab, so the re-probe could + // only repeat the failure the handle is about to report. + const current = session({ p: 10 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome({ overflowRuns: 1, metricErrors: [2] }) }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute, { + calibratedWindows: [{ lo: 0, stride: 1, integer: true }], + }); + const result = await run; + + expect(attempts).toHaveLength(1); + expect(current.capacities.get("p")).toBe(10); + expect(remembered).toHaveLength(0); + expect(result).toMatchObject({ + kind: "calibrated", + result: { overflowRuns: 1, metricErrors: [2] }, + }); + }); + + it("probes blind windows alone when no place needs a slab", async () => { + const current = session({}); + const { execute, attempts } = scripted([ + { ok: true, result: outcome() }, + { ok: true, result: outcome() }, + ]); + + const { run, remembered } = runWith(current, execute); + await run; + + expect( + attempts.map(({ runCount, preview }) => ({ runCount, preview })), + ).toEqual([ + { runCount: 128, preview: false }, + { runCount: 1000, preview: true }, + ]); + expect(remembered).toHaveLength(1); + }); + + it("skips every probe when the windows have ceilings and no slab is derived", async () => { + const current = session({}); + const { execute, attempts } = scripted([{ ok: true, result: outcome() }]); + + const { run } = runWith(current, execute, { + windowInputs: [{ integer: true, ceiling: 200 }], + }); + await run; + + expect(attempts).toHaveLength(1); + expect(attempts[0]?.preview).toBe(true); + }); + + it("reports a probe that concedes as failed, telling the user what to do", async () => { + const current = session({ p: 64 }); + const { execute } = scripted([ + { + ok: true, + result: outcome({ + derivedPlaceMaxes: [{ max: 20_000, meanRunMax: 100 }], + }), + }, + ]); + + const { run } = runWith(current, execute); + + expect(await run).toMatchObject({ + kind: "failed", + reason: /outlier runs.*Switch this experiment to the CPU backend/, + }); + }); + + it("ends the run on a metric the capacity probe halted, before the full attempt and without remembering the probe", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome({ metricErrors: [2] }) }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute, { + metricFailure: haltedMetric, + }); + + expect(await run).toEqual({ + kind: "failed", + reason: "halted 2 of 128 runs", + }); + expect(attempts).toHaveLength(1); + // A halted probe is not remembered: the next batch on the marking would + // adopt it, skip its probe and meet the halt only after a full attempt. + expect(remembered).toHaveLength(0); + }); + + it("ends the run on a metric the window probe halted, before the full attempt", async () => { + const current = session({}); + const { execute, attempts } = scripted([ + { ok: true, result: outcome({ metricErrors: [1] }) }, + { ok: true, result: outcome() }, + ]); + + const { run } = runWith(current, execute, { metricFailure: haltedMetric }); + + expect(await run).toEqual({ + kind: "failed", + reason: "halted 1 of 128 runs", + }); + expect(attempts).toHaveLength(1); + }); + + it("reports a stop during the probe as stopped, not as an error", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome() }, + { ok: true, result: outcome() }, + ]); + + const { run } = runWith(current, execute, { + stopped: () => attempts.length > 0, + }); + + expect(await run).toEqual({ kind: "stopped" }); + expect(attempts).toHaveLength(1); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts new file mode 100644 index 00000000000..6143a5db415 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -0,0 +1,187 @@ +/** + * The attempts one GPU experiment makes once `start()` is called, in order: + * calibrate what the shader cannot know up front, then run in full. + * + * Every attempt streams its chunks through the same `execute`, so the first + * probe's frames reach the charts as early as any later chunk. Keeping the + * ordering here, over a plain `ExecuteAttempt`, is what lets it be tested + * without a device. + */ +import { planInitialWindows } from "../metric-windows"; +import { + anyMetricHalted, + CACHED_RUN_POLICY, + probeDerivedCapacities, + probeRunCount, + probeWindows, + RUN_POLICY, + runUntilCalibrated, +} from "./calibration"; + +import type { MetricWindow, MetricWindowInput } from "../metric-windows"; +import type { GpuExperimentResult } from "../runner"; +import type { CalibrationSession, ExecuteAttempt } from "./calibration"; + +export type RunPhaseOutcome = + /** The caller cancelled or disposed the experiment before the full attempt. */ + | { kind: "stopped" } + | { kind: "failed"; reason: string } + /** + * The full attempt's last result — possibly cancelled midway, possibly + * still overflowing or halted by a metric — with the windows it ran at. + */ + | { + kind: "calibrated"; + result: GpuExperimentResult; + windows: MetricWindow[]; + }; + +/** + * Whether an uncalibrated run must probe before its full attempt: a derived + * slab needs measuring, or a metric has no declared ceiling to plan its + * window from. A run that needs neither has nothing another batch on the + * same marking could wait for. + */ +export const needsProbe = ( + session: Pick, + windowInputs: readonly MetricWindowInput[], +): boolean => + session.capacities.size > 0 || + windowInputs.some((input) => input.ceiling === null); + +/** + * Probes when nothing is calibrated yet — derived capacities first, which + * also observes the metric ranges; else the blind windows alone — then runs + * the full attempt under `RUN_POLICY`. A cached calibration runs under + * `CACHED_RUN_POLICY` instead, and outgrowing it sends the run back through + * the probe — unless a metric halted a run, which a fresh probe would only + * halt again. + */ +export const runCalibratedExperiment = async (options: { + session: CalibrationSession; + /** Windows an earlier batch calibrated on this marking; null probes afresh. */ + calibratedWindows: readonly MetricWindow[] | null; + windowInputs: readonly MetricWindowInput[]; + placeCounts: readonly number[]; + runCount: number; + execute: ExecuteAttempt; + /** Whether the caller has abandoned the run, checked between attempts. */ + stopped: () => boolean; + /** + * Hears each calibration a probe settles, for later batches on this + * marking. A probe a metric halted settles none: a batch adopting it would + * skip its own probe and meet the halt only after a full attempt. + */ + remember: (windows: readonly MetricWindow[]) => void; + /** + * The failure an attempt's halted-metric counts amount to over the runs it + * executed, or null. Seeds derive from the run index, so a run a probe + * halted halts again in full: the probe's counts end the run before the + * full attempt executes. + */ + metricFailure: ( + metricErrors: readonly number[], + runCount: number, + ) => string | null; + /** + * Slabs a fresh probe may not size below: the grown ones a cached + * calibration's full attempt still overflowed at. + */ + slabFloor?: ReadonlyMap; +}): Promise => { + const { + session, + calibratedWindows, + windowInputs, + placeCounts, + runCount, + execute, + stopped, + remember, + metricFailure, + slabFloor, + } = options; + + let windows: readonly MetricWindow[]; + if (calibratedWindows !== null) { + windows = calibratedWindows; + } else if (!needsProbe(session, windowInputs)) { + windows = planInitialWindows(windowInputs, session.shader.histogramBins); + } else if (session.capacities.size > 0) { + const probed = await probeDerivedCapacities({ + session, + runCount, + windowInputs, + placeCounts, + execute, + stopped, + slabFloor, + }); + if (stopped()) { + return { kind: "stopped" }; + } + if (!probed.ok) { + return { kind: "failed", reason: probed.reason }; + } + const probedFailure = metricFailure(probed.metricErrors, probed.probeRuns); + if (probedFailure !== null) { + return { kind: "failed", reason: probedFailure }; + } + windows = probed.windows; + remember(windows); + } else { + const probeRuns = probeRunCount(session.shader, runCount); + const probe = await probeWindows({ + session, + windows: planInitialWindows(windowInputs, session.shader.histogramBins), + execute, + runCount: probeRuns, + }); + if (!probe.ok) { + return { kind: "failed", reason: probe.reason }; + } + if (probe.result.cancelled || stopped()) { + return { kind: "stopped" }; + } + const probeFailure = metricFailure(probe.result.metricErrors, probeRuns); + if (probeFailure !== null) { + return { kind: "failed", reason: probeFailure }; + } + windows = probe.windows; + remember(windows); + } + + const calibrated = await runUntilCalibrated({ + session, + runsFor: () => runCount, + windows, + execute, + policy: calibratedWindows === null ? RUN_POLICY : CACHED_RUN_POLICY, + stopped, + }); + if (!calibrated.ok) { + return { kind: "failed", reason: calibrated.reason }; + } + if ( + calibratedWindows !== null && + calibrated.result.overflowRuns > 0 && + !anyMetricHalted(calibrated.result) && + !calibrated.result.cancelled && + !stopped() + ) { + // A calibration learned on another selection undersizes this one past + // what growth covers: probe afresh, as a first batch would, from the + // grown slabs — and never below them, so the probe's result is at least + // the stale entry's on every place and replaces it. + return runCalibratedExperiment({ + ...options, + calibratedWindows: null, + slabFloor: new Map(session.capacities), + }); + } + return { + kind: "calibrated", + result: calibrated.result, + windows: calibrated.windows, + }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts new file mode 100644 index 00000000000..196b7169142 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { shareCalibration } from "./shared-calibration"; + +describe("shareCalibration", () => { + it("lets a later batch wait for the batch that claimed the key", async () => { + const calibrating = new Map>(); + const first = shareCalibration(calibrating, "marking"); + expect(first.inFlight).toBeUndefined(); + const settle = first.claim(); + + const second = shareCalibration(calibrating, "marking"); + expect(second.inFlight).toBeDefined(); + let woke = false; + void second.inFlight!.then(() => { + woke = true; + }); + await Promise.resolve(); + expect(woke).toBe(false); + + settle(); + await Promise.resolve(); + expect(woke).toBe(true); + expect(calibrating.has("marking")).toBe(false); + }); + + it("settles idempotently and never drops a newer claim", () => { + const calibrating = new Map>(); + const stale = shareCalibration(calibrating, "marking").claim(); + stale(); + const fresh = shareCalibration(calibrating, "marking"); + expect(fresh.inFlight).toBeUndefined(); + const settleFresh = fresh.claim(); + + stale(); + expect(calibrating.has("marking")).toBe(true); + + settleFresh(); + expect(calibrating.has("marking")).toBe(false); + expect(shareCalibration(calibrating, "other").inFlight).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts new file mode 100644 index 00000000000..853ee8f3619 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts @@ -0,0 +1,42 @@ +/** + * One probe per marking, however many batches start on it at once. + * + * The ladder pipelines its rungs off the first streamed chunk, and the first + * chunk a fresh marking streams is its probe's — so the next rung starts + * while the calibration it could reuse is still being measured. The batch + * that probes claims the key; batches arriving meanwhile wait for its + * settle and read the stored calibration instead of probing too. When the + * settle stored none, the waiters look the key up again: the first finds it + * free and claims, the others find that claim and wait on it. + */ +export type CalibrationShare = { + /** Another batch's probe of this key, settled once it stored or gave up. */ + inFlight: Promise | undefined; + /** + * Registers this batch as the one probing the key. The returned settle is + * idempotent and releases the waiters; call it once the calibration is + * stored, and again from a `finally` so a failed or cancelled probe + * releases them too. + */ + claim: () => () => void; +}; + +export const shareCalibration = ( + calibrating: Map>, + key: string, +): CalibrationShare => ({ + inFlight: calibrating.get(key), + claim: () => { + let release = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + calibrating.set(key, pending); + return () => { + if (calibrating.get(key) === pending) { + calibrating.delete(key); + } + release(); + }; + }, +}); diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 15c56e12086..7f8d4f749e9 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -96,13 +96,13 @@ 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: - **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; +- 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, or whose counts keep outgrowing the buffers however far the probe grows them, stops the experiment with a message asking you to run it 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 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). +When an experiment misses a requirement the model alone decides — attributes, arcs, metrics — it runs on the CPU instead and a message explains which one, so you do not need to check in advance. The two requirements only a run can measure — a heavy-tailed place, counts that keep outgrowing their buffers — surface as that experiment's error once it has started, with a message asking you to switch it to the CPU backend. 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). Run count has no ceiling of its own: runs beyond what your GPU can hold at once execute as sequential tiles. What still falls back to the CPU is a single run whose own state exceeds the device's buffer limits, or a metric histogram too large for the device; the message says which. @@ -135,12 +135,12 @@ Once the drawer's body has scrolled, the header condenses to one line, with the ### Metric charts -Each metric gets its own card in a grid of equal-sized cards, charting its values over simulation time. Every card stays the same size whatever it draws, so changing a chart's view never moves the charts around it. A scalar metric draws a line. A distribution metric (one value per run) defaults to a **heatmap**: each time step is a column shaded on a pale-to-dark color ramp, where the darkest cell marks the value most runs had at that moment and paler shades mark rarer values. Shading is relative to each time step on its own, so a moment where runs agree and a moment where they spread out are both readable. While results still stream, each update eases into the picture over a few refreshes instead of snapping, so a batch landing or a re-run replacing earlier samples reads as the distribution firming up rather than flashing. +Each metric gets its own card in a grid of equal-sized cards, charting its values over simulation time. Every card stays the same size whatever it draws, so changing a chart's view never moves the charts around it. The **Enlarge** button, right of the `…` button, is the one thing that resizes a card: it spreads across the whole row at twice the height, the cards after it fill the cells its row has left and the rest move below, and **Shrink** puts it back. A scalar metric draws a line. A distribution metric (one value per run) defaults to a **heatmap**: each time step is a column shaded on a pale-to-dark color ramp, where the darkest cell marks the value most runs had at that moment and paler shades mark rarer values. Shading is relative to each time step on its own, so a moment where runs agree and a moment where they spread out are both readable. While results still stream, each update eases into the picture over a few refreshes instead of snapping, so a batch landing or a re-run replacing earlier samples reads as the distribution firming up rather than flashing. -The **Chart options** menu (the `…` button in the card's header) changes what is plotted, and the line under the card's title reads the current choice, for example "median over runs · value over time". The menu has two groups, with the current choice in each marked: +The **Chart options** menu (the `…` button in the card's header) changes what is plotted, and the line under the card's title reads the current choice, for example "median over runs · value over time". The menu has one block per dimension the data can be collapsed along, each with a switch between drawing everything and aggregating, and a list of what to draw or which statistic to take; switching a block back restores the choice it last had: -- **Runs** (distribution metrics only): **Heatmap** or **Percentile lines** draw every time step's whole distribution; percentile lines draw the mean, median, and the 10/25/75/90th percentiles as separate lines. **Average**, **Median**, **Minimum**, **Maximum** or a **percentile** collapses each time step's distribution to that one statistic and draws it as a line. -- **Time**: **Value**, **Minimum to date** or **Maximum to date** plots each time step's own value, or the running minimum or maximum up to that point. **Average**, **Minimum**, **Maximum** or **Sum over time** collapses the whole series: a scalar-like series becomes a single number, and an unaggregated distribution becomes one histogram whose bar heights are the chosen statistic of each value's frequency over time. +- **Runs** (distribution metrics only): **Every run** draws every time step's whole distribution, as a **Heatmap** or as **Percentile lines** (the mean, median, and the 10/25/75/90th percentiles as separate lines). **Aggregate** collapses each time step's distribution to one statistic — **Average**, **Median**, **Minimum**, **Maximum** or a **percentile** — and draws it as a line. +- **Time**: **Every step** plots each time step's own **Value**, or the running **Minimum to date** or **Maximum to date**. **Aggregate** collapses the whole series with its **Average**, **Minimum**, **Maximum** or **Sum**: a scalar-like series becomes a single number, and an unaggregated distribution becomes one histogram whose bar heights are the chosen statistic of each value's frequency over time. Click (or drag across) a timeline chart to inspect single time steps — a popover shows that moment's exact value, or its whole distribution as a small histogram with value and count axes, however many bins the frame carries. diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index 4c1a9f64697..f160efbc15c 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -261,9 +261,10 @@ in view on a laptop screen while the study streams: follows its steps, and **Objective at the selected point** otherwise; the line under the title names the metric and the current view. It streams again whenever the position changes, so the surface and the chart always - describe the same point. Its **Chart options** menu, in the card's header, - offers the same views as an experiment's [metric - charts](experiments.md#metric-charts). + describe the same point. Its **Chart options** menu and **Enlarge** button, + in the card's header, work as on an experiment's [metric + charts](experiments.md#metric-charts); enlarged, the card takes the whole + metrics column at twice its height and the cards after it move beneath. - The **Objective by step** card follows these two, so the surface, the point in flight and the study's history are read together. - A study with [constraints](#constraints) adds a **Constraints** card as the diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index 6ac82eafd5c..4ab2e80e0c6 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -361,6 +361,7 @@ export const ExperimentsProvider: React.FC = ({ // and leave the rest of the UI most of each frame's budget. publishThrottleMs: 100, instantiateBatch: createSweepBatchInstantiator({ + axes, registrations, buildRequest, compiler, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts index d18af69ed46..383b56348bf 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts @@ -1,8 +1,13 @@ import { selectExperimentBackend } from "@hashintel/petrinaut-core/experiments"; import { instantiateOnBackend } from "./shared/instantiate-on-backend"; -import { translateRangeDraws } from "./sweep-batch-instantiation/sweep-run-overrides"; +import { + constantRunPlan, + sweptNetParameterIds, + translateRangeDraws, +} from "./sweep-batch-instantiation/sweep-run-overrides"; +import type { ExperimentParameterAxis } from "../parameter-grid"; import type { InstantiateSweepBatch } from "../sweep-session"; import type { BuildExperimentRequest, @@ -21,10 +26,15 @@ import type { * * The first batch walks the backend selection — so GPU-vs-CPU choice and * fallback reporting behave as for a plain experiment — and later batches - * re-assess the chosen backend with their own request (the GPU backend - * regenerates its shader for the new parameter values there). + * re-assess the chosen backend with their own request. + * + * Every batch carries the swept net parameters in its run plan, a point + * selection as one constant row per run: the values then ride the per-run + * buffer rather than being baked into the request, so the GPU backend keeps + * one compiled setup — and its calibration — across every selection. */ export const createSweepBatchInstantiator = ({ + axes, registrations, buildRequest, compiler, @@ -32,6 +42,7 @@ export const createSweepBatchInstantiator = ({ onBackendChosen, onNote, }: { + axes: readonly ExperimentParameterAxis[]; registrations: readonly ExperimentBackendRegistration[]; buildRequest: BuildExperimentRequest; compiler: SweptScenarioCompiler; @@ -43,8 +54,16 @@ export const createSweepBatchInstantiator = ({ onNote: (note: ExperimentNote) => void; }): InstantiateSweepBatch => { let chosenBackend: ExperimentBackend | null = null; + // Found on the first batch, where a compile error fails that batch rather + // than the session's creation. + let sweptIds: readonly string[] | null = null; return async ({ parameterValues, draws, seed, runCount, signal }) => { + sweptIds ??= sweptNetParameterIds({ + axes, + compileRunNumbers: compiler.compileRunNumbers, + netParameterVariableNames, + }); const compiled = compiler.compileForValues(parameterValues); const baseParameters = compiler.compileRunNumbers(parameterValues).parameters; @@ -53,7 +72,7 @@ export const createSweepBatchInstantiator = ({ // gives every backend net-keyed per-run values. const runPlan = draws === undefined - ? undefined + ? constantRunPlan(sweptIds, baseParameters, runCount) : await translateRangeDraws({ draws, signal, @@ -61,6 +80,7 @@ export const createSweepBatchInstantiator = ({ baseParameters, compileRunNumbers: compiler.compileRunNumbers, netParameterVariableNames, + ids: sweptIds, }); const override: ExperimentRequestOverride = { parameterValues: compiled.result.parameterValues, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts index c0e77e4f87c..4d4328010ff 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { translateRangeDraws } from "./sweep-run-overrides"; +import { + constantRunPlan, + sweptNetParameterIds, + translateRangeDraws, +} from "./sweep-run-overrides"; + +import type { ExperimentParameterAxis } from "../../parameter-grid"; /** Overrides: net `rate` = scenario `speed` × 2; net `size` is untouched. */ const compileRunNumbers = (swept: Readonly>) => ({ @@ -16,6 +22,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers, netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["rate"]); @@ -31,6 +38,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers, netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["rate"]); @@ -44,6 +52,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers: () => ({ parameters: { ...baseParameters } }), netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["size"]); @@ -57,6 +66,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers: () => ({ parameters: { ...baseParameters } }), netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan).toBeUndefined(); @@ -71,6 +81,7 @@ describe("translateRangeDraws", () => { parameters: { rate: 3, armed: (swept.speed ?? 0) > 2 }, }), netParameterVariableNames: new Set(["rate", "armed"]), + ids: [], }); expect(plan?.ids).toEqual(["armed"]); @@ -87,6 +98,7 @@ describe("translateRangeDraws", () => { baseParameters: { rate: 3, armed: false }, compileRunNumbers: () => ({ parameters: { rate: 3, armed: false } }), netParameterVariableNames: new Set(["rate", "armed"]), + ids: [], }); expect(plan?.ids).toEqual(["armed"]); @@ -107,9 +119,87 @@ describe("translateRangeDraws", () => { baseParameters: compileNumbers({ speed: 1.5 }).parameters, compileRunNumbers: compileNumbers, netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["rate", "size"]); expect([...plan!.values]).toEqual([6, 7, 3, 9]); }); }); + +describe("sweptNetParameterIds", () => { + const axis = ( + identifier: string, + min: number, + max: number, + ): ExperimentParameterAxis => ({ + identifier, + min, + max, + stepCount: 50, + integer: false, + }); + + it("finds the names an override computes, a direct net-name axis, and skips untouched names", () => { + const ids = sweptNetParameterIds({ + axes: [axis("speed", 1, 2), axis("size", 5, 9), axis("irrelevant", 0, 1)], + compileRunNumbers: (swept) => ({ + parameters: { + rate: (swept.speed ?? 1) * 2, + size: swept.size ?? 7, + fixed: 3, + }, + }), + netParameterVariableNames: new Set(["rate", "size", "fixed"]), + }); + + expect(ids).toEqual(["rate", "size"]); + }); + + it("is empty when no axis reaches a net parameter", () => { + const ids = sweptNetParameterIds({ + axes: [axis("irrelevant", 0, 1)], + compileRunNumbers: () => ({ parameters: { rate: 3 } }), + netParameterVariableNames: new Set(["rate"]), + }); + + expect(ids).toEqual([]); + }); +}); + +describe("translateRangeDraws with a supplied id set", () => { + it("carries every supplied id even when no run moves it", async () => { + // Both runs draw the midpoint: nothing differs from the base, but the + // column still rides so this batch lays out the same buffer as any other. + const plan = await translateRangeDraws({ + draws: { identifiers: ["speed"], values: new Float64Array([1.5, 1.5]) }, + midValues: { speed: 1.5 }, + baseParameters, + compileRunNumbers, + netParameterVariableNames: new Set(["rate", "size"]), + ids: ["rate"], + }); + + expect(plan?.ids).toEqual(["rate"]); + expect([...plan!.values]).toEqual([3, 3]); + }); +}); + +describe("constantRunPlan", () => { + it("repeats the point's values for every run, run-major", () => { + const plan = constantRunPlan(["rate", "size"], { rate: 3, size: 7 }, 3); + + expect(plan?.ids).toEqual(["rate", "size"]); + expect([...plan!.values]).toEqual([3, 7, 3, 7, 3, 7]); + }); + + it("carries a boolean as 1/0", () => { + const plan = constantRunPlan(["armed"], { armed: true }, 2); + + expect([...plan!.values]).toEqual([1, 1]); + }); + + it("is undefined when there is nothing to carry", () => { + expect(constantRunPlan([], { rate: 3 }, 4)).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts index d2475a5847c..d5ad52e24ed 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts @@ -1,5 +1,5 @@ /** - * Translates a range batch's per-run scenario-parameter draws into a per-run + * Translates a sweep batch's swept scenario parameters into a per-run * net-parameter plan. * * A sweep draws values for *scenario* parameters, but a run's simulation @@ -11,12 +11,24 @@ * A scenario identifier that IS a net variable name passes through as a * direct override where no override expression computed that name, matching * how the engine merges run values by variable name. + * + * Every batch of one experiment carries the same net names — the ones any + * swept axis can reach, found once from the axes — whether its runs vary + * them or not, a point selection included. A backend that keys its compiled + * setup on which parameters ride in the per-run buffer then keeps one setup, + * and the calibration learned with it, across every selection of the sweep. */ import { createCooperativeYielder } from "../../cooperative-yield"; +import { axisValueAt } from "../../parameter-grid"; +import type { ExperimentParameterAxis } from "../../parameter-grid"; import type { SweepRunDraws } from "../../sweep-session"; import type { ExperimentRunPlan } from "@hashintel/petrinaut-core/experiments"; +type CompileRunNumbers = (swept: Readonly>) => { + parameters: Readonly>; +}; + export type TranslateRangeDrawsOptions = { /** The batch's per-run draws, keyed by scenario parameter identifier. */ draws: SweepRunDraws; @@ -27,11 +39,14 @@ export type TranslateRangeDrawsOptions = { /** Net parameter values the batch compiled at `midValues`, as numbers. */ baseParameters: Readonly>; /** Compiles the scenario for one concrete swept assignment, as numbers. */ - compileRunNumbers: (swept: Readonly>) => { - parameters: Readonly>; - }; + compileRunNumbers: CompileRunNumbers; /** The net's parameter variable names, for direct-override passthrough. */ netParameterVariableNames: ReadonlySet; + /** + * Net names the plan carries whatever the runs draw — + * `sweptNetParameterIds` — so every batch lays out the same buffer. + */ + ids: readonly string[]; }; /** Booleans ride the plan as 1/0; the engine parses them back by type. */ @@ -39,9 +54,77 @@ const planNumber = (value: number | boolean): number => typeof value === "boolean" ? (value ? 1 : 0) : value; /** - * Returns a run-major plan over the union of every net name any run - * changes, or undefined when no run changes anything (the whole batch - * behaves as the midpoint compilation). + * The net parameter names a sweep's axes can move, sorted: every name whose + * compiled value at an axis's end differs from the all-midpoint compile, and + * every axis that names a net parameter directly. Found once per experiment, + * this is the id set every batch's plan carries. + */ +export const sweptNetParameterIds = (options: { + axes: readonly ExperimentParameterAxis[]; + compileRunNumbers: CompileRunNumbers; + netParameterVariableNames: ReadonlySet; +}): readonly string[] => { + const { axes, compileRunNumbers, netParameterVariableNames } = options; + const midpoints: Record = {}; + for (const axis of axes) { + midpoints[axis.identifier] = axisValueAt(axis, axis.stepCount / 2); + } + const base = compileRunNumbers(midpoints).parameters; + const ids = new Set(); + for (const axis of axes) { + if ( + netParameterVariableNames.has(axis.identifier) && + base[axis.identifier] !== undefined + ) { + ids.add(axis.identifier); + } + for (const position of [0, axis.stepCount]) { + const { parameters } = compileRunNumbers({ + ...midpoints, + [axis.identifier]: axisValueAt(axis, position), + }); + for (const [name, value] of Object.entries(parameters)) { + if (value !== base[name]) { + ids.add(name); + } + } + } + } + return [...ids].sort(); +}; + +/** + * A point selection's plan: every run carries the point's compiled values + * for `ids`, so the batch lays out the same buffer as a range batch would. + * Undefined when there is nothing to carry. + */ +export const constantRunPlan = ( + ids: readonly string[], + baseParameters: Readonly>, + runCount: number, +): ExperimentRunPlan | undefined => { + const row: number[] = []; + for (const id of ids) { + const base = baseParameters[id]; + if (base === undefined) { + return undefined; + } + row.push(planNumber(base)); + } + if (row.length === 0) { + return undefined; + } + const values = new Float64Array(runCount * row.length); + for (let run = 0; run < runCount; run++) { + values.set(row, run * row.length); + } + return { ids, values }; +}; + +/** + * Returns a run-major plan over `ids` plus every net name any run changes, + * or undefined when there is nothing to carry (the whole batch behaves as + * the midpoint compilation). * * Every run carries every id in the plan — a run whose draw compiles back * to a base value carries that base value explicitly. Backends lay per-run @@ -66,7 +149,7 @@ export const translateRangeDraws = async ( return undefined; } - // One column per net name some run changes, pre-filled with the base value + // One column per net name the plan carries, pre-filled with the base value // so runs that draw the base carry it without a separate fill pass. const columns = new Map(); const columnFor = (name: string, base: number): Float64Array => { @@ -77,6 +160,12 @@ export const translateRangeDraws = async ( } return column; }; + for (const id of options.ids) { + const base = baseParameters[id]; + if (base !== undefined) { + columnFor(id, planNumber(base)); + } + } // The parameter record's key set is identical across compiles (the same // defaults template seeds every call), so the keys are read once. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.stories.tsx new file mode 100644 index 00000000000..c80cff9b1c6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.stories.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { userEvent, within } from "storybook/test"; + +import { ChartCard } from "../../shared/chart-card"; +import { ExperimentMetricTimeline } from "../experiment-metric-timeline"; +import { sirInfectedFrame } from "../experiments-story-fixtures"; +import { describeMetricView } from "./describe-metric-view"; +import { MetricViewMenu } from "./metric-view-menu"; +import { + DEFAULT_METRIC_VIEW_SETTINGS, + type MetricViewSettings, +} from "./view-state"; + +import type { MetricFrame } from "./shared/metric-frames"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Simulate / MetricViewMenu", + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const FRAME_COUNT = 46; +const PLOT_HEIGHT = 220; + +const distributionFrames: MetricFrame[] = Array.from( + { length: FRAME_COUNT }, + (_, frameNumber) => + sirInfectedFrame({ + frameNumber, + transmissionRate: 0.3, + recoveryDays: 8, + spread: 9, + runs: 25, + }), +); + +/** A metric card owning its view settings, the way `MetricTiles` does. */ +const MetricCard = ({ + title, + outputType, + frames, + initialSettings = DEFAULT_METRIC_VIEW_SETTINGS, +}: { + title: string; + outputType: MetricFrame["outputType"]; + frames: readonly MetricFrame[]; + initialSettings?: MetricViewSettings; +}) => { + const [settings, setSettings] = useState(initialSettings); + return ( +
+ + } + bodyHeight={PLOT_HEIGHT} + > + + +
+ ); +}; + +const openMenu: Story["play"] = async ({ canvasElement }) => { + await userEvent.click( + within(canvasElement).getByRole("button", { name: "Chart options" }), + ); +}; + +/** + * A distribution metric's options: the Runs block, here aggregated to the + * median, above the Time block drawing every step. Flip a block's switch and + * its list follows; the chart behind re-draws while the popover stays open. + */ +export const DistributionMetric: Story = { + render: () => ( + + ), + play: openMenu, +}; + +/** A scalar metric has no runs to collapse, so its options are the Time block alone. */ +export const ScalarMetric: Story = { + render: () => ( + + ), + play: openMenu, +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.test.tsx new file mode 100644 index 00000000000..04b894397f2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.test.tsx @@ -0,0 +1,222 @@ +/** + * @vitest-environment jsdom + */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { describeMetricView } from "./describe-metric-view"; +import { MetricViewMenu } from "./metric-view-menu"; +import { + DEFAULT_METRIC_VIEW_SETTINGS, + type MetricViewSettings, +} from "./view-state"; + +import type { MetricFrame } from "./shared/metric-frames"; + +vi.mock("@hashintel/ds-components", async (importOriginal) => { + const [actual, stubs] = await Promise.all([ + importOriginal(), + import("../../shared/ds-control-stubs"), + ]); + return { ...actual, ...stubs }; +}); + +afterEach(cleanup); + +/** The menu over its own settings, with the subtitle a card would read them back as. */ +const Harness = ({ + outputType, + initialSettings = DEFAULT_METRIC_VIEW_SETTINGS, + onChange, +}: { + outputType: MetricFrame["outputType"]; + initialSettings?: MetricViewSettings; + onChange?: (settings: MetricViewSettings) => void; +}) => { + const [settings, setSettings] = useState(initialSettings); + return ( + <> + { + setSettings(next); + onChange?.(next); + }} + /> + + {describeMetricView(settings, outputType)} + + + ); +}; + +const openMenu = () => { + fireEvent.click(screen.getByRole("button", { name: "Chart options" })); +}; + +const modeGroup = (label: string) => + screen.getByRole("group", { name: `${label} mode` }); + +const pickMode = (label: string, mode: string) => { + fireEvent.click(within(modeGroup(label)).getByRole("button", { name: mode })); +}; + +const pickChoice = (label: string, value: string) => { + fireEvent.change(screen.getByRole("combobox", { name: label }), { + target: { value }, + }); +}; + +const choice = (label: string): string => + screen.getByRole("combobox", { name: label }).value; + +const isPressed = (label: string, mode: string): boolean => + within(modeGroup(label)) + .getByRole("button", { name: mode }) + .getAttribute("aria-pressed") === "true"; + +describe("MetricViewMenu", () => { + it("opens a popover from the ellipsis button and says so on the button", () => { + render(); + const trigger = screen.getByRole("button", { name: "Chart options" }); + + expect(trigger.getAttribute("aria-haspopup")).toBe("dialog"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByRole("combobox")).toBeNull(); + + openMenu(); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByText("Runs")).toBeTruthy(); + expect(screen.getByText("Time")).toBeTruthy(); + }); + + it("offers the runs block for a distribution metric only", () => { + const view = render(); + openMenu(); + expect(screen.getByRole("combobox", { name: "Runs" })).toBeTruthy(); + expect(screen.getByRole("combobox", { name: "Time" })).toBeTruthy(); + view.unmount(); + + render(); + openMenu(); + expect(screen.queryByRole("combobox", { name: "Runs" })).toBeNull(); + expect(screen.queryByText("Runs")).toBeNull(); + expect(screen.getByRole("combobox", { name: "Time" })).toBeTruthy(); + }); + + it("shows each block's current side and choice", () => { + render( + , + ); + openMenu(); + + expect(isPressed("Runs", "Aggregate")).toBe(true); + expect(isPressed("Runs", "Every run")).toBe(false); + expect(choice("Runs")).toBe("p90"); + expect(isPressed("Time", "Every step")).toBe(true); + expect(choice("Time")).toBe("maxToDate"); + }); + + it("aggregates the runs field by field and keeps the statistic when switching back", () => { + const onChange = vi.fn(); + render(); + openMenu(); + + pickMode("Runs", "Aggregate"); + expect(onChange).toHaveBeenLastCalledWith({ + ...DEFAULT_METRIC_VIEW_SETTINGS, + aggregateRuns: true, + }); + expect(choice("Runs")).toBe("mean"); + + pickChoice("Runs", "median"); + expect(onChange).toHaveBeenLastCalledWith({ + ...DEFAULT_METRIC_VIEW_SETTINGS, + aggregateRuns: true, + runAggregation: "median", + }); + expect(screen.getByTestId("subtitle").textContent).toBe( + "median over runs · value over time", + ); + + pickMode("Runs", "Every run"); + expect(onChange).toHaveBeenLastCalledWith({ + ...DEFAULT_METRIC_VIEW_SETTINGS, + aggregateRuns: false, + runAggregation: "median", + }); + expect(choice("Runs")).toBe("heatmap"); + expect(screen.getByTestId("subtitle").textContent).toBe( + "heatmap · value over time", + ); + + pickChoice("Runs", "bands"); + expect(onChange).toHaveBeenLastCalledWith({ + ...DEFAULT_METRIC_VIEW_SETTINGS, + distributionView: "bands", + runAggregation: "median", + }); + }); + + it("aggregates over time field by field and keeps the trace when switching back", () => { + const onChange = vi.fn(); + render(); + openMenu(); + + pickChoice("Time", "minToDate"); + expect(screen.getByTestId("subtitle").textContent).toBe("minimum to date"); + + pickMode("Time", "Aggregate"); + pickChoice("Time", "sum"); + expect(onChange).toHaveBeenLastCalledWith({ + ...DEFAULT_METRIC_VIEW_SETTINGS, + timeTrace: "minToDate", + aggregateTime: true, + timeAggregation: "sum", + }); + expect(screen.getByTestId("subtitle").textContent).toBe("sum over time"); + + pickMode("Time", "Every step"); + expect(onChange).toHaveBeenLastCalledWith({ + ...DEFAULT_METRIC_VIEW_SETTINGS, + timeTrace: "minToDate", + aggregateTime: false, + timeAggregation: "sum", + }); + expect(choice("Time")).toBe("minToDate"); + }); + + it("stays open across choices", () => { + render(); + openMenu(); + + pickMode("Runs", "Aggregate"); + pickChoice("Runs", "max"); + pickMode("Time", "Aggregate"); + + expect(screen.getByRole("combobox", { name: "Runs" })).toBeTruthy(); + expect( + screen + .getByRole("button", { name: "Chart options" }) + .getAttribute("aria-expanded"), + ).toBe("true"); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.tsx index 5db0740773b..e79eca3f8a5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-metric-timeline/metric-view-menu.tsx @@ -1,117 +1,33 @@ /** - * A metric chart's view menu, in its card header: one "Runs" group choosing - * how the runs collapse (distribution metrics only) and one "Time" group - * choosing how the series reads along time. The current choice in each group - * is marked; picking another replaces it. + * A metric chart's view menu, in its card header: an ellipsis button and, + * while open, a popover with one block per dimension the data can be + * collapsed along — "Runs" (distribution metrics only) and "Time". Each + * block switches between drawing everything and aggregating, and offers the + * list its side has; the popover stays open across choices so the chart + * behind it re-draws as they are made. */ -import { ChartCardMenu } from "../../shared/chart-card"; +import { useRef, useState } from "react"; + +import { Button, Popover } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + import { DISTRIBUTION_VIEW_LABELS, RUN_AGGREGATION_LABELS, TIME_AGGREGATION_LABELS, TIME_TRACE_LABELS, } from "./describe-metric-view"; +import { AggregationDimension } from "./metric-view-menu/aggregation-dimension"; import type { MetricFrame } from "./shared/metric-frames"; import type { MetricViewSettings } from "./view-state"; -import type { Menu } from "@hashintel/ds-components"; -import type { ComponentProps } from "react"; - -type MenuEntries = ComponentProps["items"]; - -/** One choice: whether the settings already hold it, and the settings that would. */ -type ViewChoice = { - id: string; - text: string; - isSelected: (settings: MetricViewSettings) => boolean; - apply: (settings: MetricViewSettings) => MetricViewSettings; -}; - -const typedKeys = (record: Record): Key[] => - Object.keys(record) as Key[]; -const runsChoices: readonly ViewChoice[] = [ - ...typedKeys(DISTRIBUTION_VIEW_LABELS).map( - (distributionView): ViewChoice => ({ - id: `runs-view-${distributionView}`, - text: DISTRIBUTION_VIEW_LABELS[distributionView], - isSelected: (settings) => - !settings.aggregateRuns && - settings.distributionView === distributionView, - apply: (settings) => ({ - ...settings, - aggregateRuns: false, - distributionView, - }), - }), - ), - ...typedKeys(RUN_AGGREGATION_LABELS).map( - (runAggregation): ViewChoice => ({ - id: `runs-aggregate-${runAggregation}`, - text: RUN_AGGREGATION_LABELS[runAggregation], - isSelected: (settings) => - settings.aggregateRuns && settings.runAggregation === runAggregation, - apply: (settings) => ({ - ...settings, - aggregateRuns: true, - runAggregation, - }), - }), - ), -]; - -const timeChoices: readonly ViewChoice[] = [ - ...typedKeys(TIME_TRACE_LABELS).map( - (timeTrace): ViewChoice => ({ - id: `time-trace-${timeTrace}`, - text: TIME_TRACE_LABELS[timeTrace], - isSelected: (settings) => - !settings.aggregateTime && settings.timeTrace === timeTrace, - apply: (settings) => ({ ...settings, aggregateTime: false, timeTrace }), - }), - ), - ...typedKeys(TIME_AGGREGATION_LABELS).map( - (timeAggregation): ViewChoice => ({ - id: `time-aggregate-${timeAggregation}`, - text: `${TIME_AGGREGATION_LABELS[timeAggregation]} over time`, - isSelected: (settings) => - settings.aggregateTime && settings.timeAggregation === timeAggregation, - apply: (settings) => ({ - ...settings, - aggregateTime: true, - timeAggregation, - }), - }), - ), -]; - -/** The menu's entries for these settings; picking one calls `onChange` with the new settings. */ -const metricViewMenuItems = ( - outputType: MetricFrame["outputType"], - value: MetricViewSettings, - onChange: (settings: MetricViewSettings) => void, -): MenuEntries => { - const group = ( - id: string, - label: string, - choices: readonly ViewChoice[], - ) => ({ - id, - label, - items: choices.map((choice) => ({ - id: choice.id, - text: choice.text, - selected: choice.isSelected(value), - onClick: () => onChange(choice.apply(value)), - })), - }); - return [ - ...(outputType === "distribution" - ? [group("runs", "Runs", runsChoices)] - : []), - group("time", "Time", timeChoices), - ]; -}; +const bodyStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2.5", + width: "[312px]", +}); export const MetricViewMenu = ({ outputType, @@ -121,9 +37,71 @@ export const MetricViewMenu = ({ outputType: MetricFrame["outputType"]; value: MetricViewSettings; onChange: (settings: MetricViewSettings) => void; -}) => ( - -); +}) => { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + + return ( + <> + - ))} - - - ); - // The Ark popover positions itself against a trigger jsdom cannot lay out; - // this one renders its panel in place. - const Popover = Object.assign( - ({ children }: { children: ReactNode }) =>
{children}
, - { - Container: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - Header: ({ title }: { title: ReactNode }) =>
{title}
, - Footer: ({ actions }: { actions: ReactNode }) =>
{actions}
, - }, - ); - return { ...actual, Drawer, Menu, Popover, Tooltip }; + const stubs = await import("../shared/ds-control-stubs"); + return { ...actual, ...stubs, Drawer }; }); // The contour surface draws on a canvas jsdom cannot host; the card around @@ -340,12 +296,55 @@ describe("ViewExperimentDrawer in the frame", () => { fireEvent.click( screen.getAllByRole("button", { name: "Chart options" })[0]!, ); - fireEvent.click(screen.getAllByRole("menuitem", { name: "Median" })[0]!); + fireEvent.click( + within(screen.getByRole("group", { name: "Runs mode" })).getByRole( + "button", + { name: "Aggregate" }, + ), + ); + fireEvent.change(screen.getByRole("combobox", { name: "Runs" }), { + target: { value: "median" }, + }); expect(screen.getAllByText(/^median over runs/u).length).toBeGreaterThan(0); expect(frameLayoutSignature(view.container)).toEqual(before); }); + it("enlarges one metric card to the full row at twice the height and leaves the others alone", () => { + const view = renderDrawer(sweep); + const before = frameLayoutSignature(view.container); + const metricCards = before.cards.filter(([, height]) => height === "220px"); + expect(metricCards.length).toBeGreaterThan(0); + expect(before.cards.length).toBeGreaterThan(metricCards.length); + + const enlarge = screen.getAllByRole("button", { name: "Enlarge" })[0]!; + expect(enlarge.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(enlarge); + + // Two 307px rows and the 16px gap between them, less the card's chrome. + const after = frameLayoutSignature(view.container); + const [enlarged, ...rest] = after.cards.filter( + ([title]) => title === metricCards[0]![0], + ); + expect(enlarged![1]).toBe("543px"); + expect(rest).toEqual([]); + expect( + screen.getAllByTestId("metric-timeline")[0]!.dataset.plotHeight, + ).toBe("543"); + expect( + screen.getByRole("button", { name: "Shrink", pressed: true }), + ).toBeTruthy(); + expect(after.cards.filter(([title]) => title !== enlarged![0])).toEqual( + before.cards.filter(([title]) => title !== enlarged![0]), + ); + expect(after.gridRows).toEqual(before.gridRows); + + fireEvent.click(screen.getByRole("button", { name: "Shrink" })); + + expect(frameLayoutSignature(view.container)).toEqual(before); + expect(screen.queryByRole("button", { name: "Shrink" })).toBeNull(); + }); + it("reads Optimizing from the study driving the sweep, with Stop on the Parameters card and Cancel in the footer", () => { renderDrawerWithStudy({ ...sweep, status: "idle" }, "running"); 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 bea2fbf29d2..104493d0cb1 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 @@ -7,6 +7,7 @@ import { fireEvent, render, screen, + within, } from "@testing-library/react"; import { cloneElement, use, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -120,18 +121,8 @@ vi.mock("@hashintel/ds-components", async (importOriginal) => { ); }; - // The Ark popover positions itself against a trigger jsdom cannot lay out; - // this one renders its panel in place. - const Popover = Object.assign( - ({ children }: { children: ReactNode }) =>
{children}
, - { - Container: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - }, - ); - - return { ...actual, Drawer, Menu, Popover, Slider, Tooltip }; + const stubs = await import("../shared/ds-control-stubs"); + return { ...actual, ...stubs, Drawer, Menu, Slider, Tooltip }; }); vi.mock("./optimization-surface", () => ({ @@ -1142,12 +1133,47 @@ describe("ViewOptimizationDrawer holds every box still across states", () => { expect(note.title).toBe(note.textContent); }); + it("enlarges the objective card to the full row at twice the height and leaves the study's cards alone", () => { + const view = renderDrawer(states[0]!); + const before = frameLayoutSignature(view.container); + const objectiveTitle = "Objective at the step in flight"; + const others = (cards: typeof before.cards) => + cards.filter(([title]) => title !== objectiveTitle); + + const enlarge = screen.getByRole("button", { name: "Enlarge" }); + expect(enlarge.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(enlarge); + + // Two 408px rows and the 16px gap between them, less the card's chrome. + const after = frameLayoutSignature(view.container); + expect(after.cards.find(([title]) => title === objectiveTitle)![1]).toBe( + "745px", + ); + expect( + screen.getByRole("button", { name: "Shrink", pressed: true }), + ).toBeTruthy(); + expect(others(after.cards)).toEqual(others(before.cards)); + expect(after.gridRows).toEqual(before.gridRows); + + fireEvent.click(screen.getByRole("button", { name: "Shrink" })); + + expect(frameLayoutSignature(view.container)).toEqual(before); + }); + it("leaves the objective card's height alone when its aggregation changes", () => { const view = renderDrawer(states[0]!); const before = frameLayoutSignature(view.container); fireEvent.click(screen.getByRole("button", { name: "Chart options" })); - fireEvent.click(screen.getByRole("menuitem", { name: "Median" })); + fireEvent.click( + within(screen.getByRole("group", { name: "Runs mode" })).getByRole( + "button", + { name: "Aggregate" }, + ), + ); + fireEvent.change(screen.getByRole("combobox", { name: "Runs" }), { + target: { value: "median" }, + }); expect(frameLayoutSignature(view.container)).toEqual(before); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.stories.tsx index 31a53af4722..bc1f6a7807a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.stories.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { expect, userEvent, within } from "storybook/test"; import { Chip, Select } from "@hashintel/ds-components"; @@ -16,6 +17,7 @@ import { chartCardHeight, ChartCardMenu, } from "./chart-card"; +import { type MetricTile, MetricTiles } from "./metric-tiles"; import { SURFACE_FOOTER_TWO_ROW_HEIGHT, SurfaceAxisControls, @@ -175,6 +177,53 @@ export const Grid: Story = { ), }; +const tiles: MetricTile[] = [ + "Infected", + "Recovered", + "Susceptible", + "Hospitalized", +].map((title) => ({ + id: title.toLowerCase(), + title, + metricName: null, + frames, + outputType: "distribution", +})); + +/** + * The metric tiles with the second card enlarged: it spans the full row at + * twice the row height, the first card keeps its cell, the third fills the + * cell the second left beside it, and the fourth moves below. Shrink puts + * it back. Tab from the first card reaches the third card's controls before + * the enlarged card's where the browser supports `reading-flow`, and the + * enlarged card's first elsewhere. + */ +export const GridWithLargeCard: Story = { + render: () => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getAllByRole("button", { name: "Enlarge" })[1]!, + ); + + const cards = canvasElement.querySelectorAll("[data-chart-card]"); + const nextCard = cards[CSS.supports("reading-flow", "grid-order") ? 2 : 1]!; + canvas.getAllByRole("button", { name: "Enlarge" })[0]!.focus(); + await userEvent.tab(); + await expect(nextCard.contains(document.activeElement)).toBe(true); + }, +}; + export const Paused: Story = { render: () => (
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.test.tsx index 51569aba8a9..68e1501affc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.test.tsx @@ -13,6 +13,7 @@ import { CHART_CARD_FOOTER_CHROME, CHART_CARD_HEADER_HEIGHT, ChartCard, + chartCardBodyHeight, ChartCardGrid, chartCardHeight, } from "./chart-card"; @@ -117,6 +118,13 @@ describe("chartCardHeight", () => { }); }); +describe("chartCardBodyHeight", () => { + it("inverts chartCardHeight for a card without a footer", () => { + expect(chartCardBodyHeight(chartCardHeight({ bodyHeight: 220 }))).toBe(220); + expect(chartCardBodyHeight(2 * 307 + 16)).toBe(543); + }); +}); + describe("ChartCardGrid", () => { it("fixes every row's height and fits as many columns as the width allows", () => { render( @@ -137,4 +145,18 @@ describe("ChartCardGrid", () => { ); expect(grid.querySelectorAll("[data-chart-card]")).toHaveLength(2); }); + + it("declares reading-flow so Tab follows the packed order where the browser supports it", () => { + render( + + + + + , + ); + + const grid = document.querySelector("[data-chart-card-grid]")!; + expect(grid.className).toContain("grid-af_row_dense"); + expect(grid.className).toContain("reading-flow_grid-order"); + }); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx index 454bb0ed74d..d5cdf81c85c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx @@ -43,6 +43,11 @@ export const chartCardHeight = ({ bodyHeight + (footerHeight > 0 ? footerHeight + CHART_CARD_FOOTER_CHROME : 0); +/** The body height that makes a card exactly `cardHeight` tall; the inverse of `chartCardHeight` without a footer. */ +export const chartCardBodyHeight = (cardHeight: number): number => + cardHeight - + (CHART_CARD_BORDER + CHART_CARD_HEADER_HEIGHT + CHART_CARD_BODY_PADDING * 2); + // The card clips its content, with a margin so the halo it draws while an // optimizer drives it shows. The halo is a pseudo-element carrying the peak // shadow whose opacity breathes: the compositor runs that, not the painter. @@ -287,8 +292,19 @@ export const ChartCardMenu = ({ label, items }: ChartCardMenuProps) => ( */ export const CHART_CARD_MIN_WIDTH = 320; +/** The grid's gap between cards, in pixels (the `4` spacing token). */ +export const CHART_CARD_GRID_GAP = 16; + +// Dense packing fills the cells a spanning card leaves in its row with the +// cards after it instead of holes; with equal cards it places them in order. +// The cards stay in source order; where the browser supports `reading-flow`, +// Tab follows the packed order, so a card pulled up beside an earlier card +// comes before the enlarged one, and elsewhere Tab follows source order. const gridStyle = css({ display: "grid", + gridAutoFlow: "row dense", + // @ts-expect-error reading-flow is a valid CSS property Panda's types do not know + readingFlow: "grid-order", alignItems: "stretch", gap: "4", }); @@ -303,7 +319,7 @@ export type ChartCardGridProps = { /** * A fixed grid of cards. Nothing in the grid moves when a card's content - * changes; only the card count changes the grid. + * changes; only the card count, or a card spanning cells, changes the grid. */ export const ChartCardGrid = ({ minColumnWidth, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx new file mode 100644 index 00000000000..e337f163827 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx @@ -0,0 +1,75 @@ +/** + * Native stand-ins for the ds controls a metric card's chart options are + * built from, for tests under jsdom: the Ark popover positions itself + * against a trigger jsdom cannot lay out, and the Ark select and segment + * group float portalled lists it cannot measure. The popover renders its + * panel in place, the select is a ` onChange(event.target.value)} + > + {items.map((item) => ( + + ))} + +); + +export const SegmentedControl = ({ + "aria-label": ariaLabel, + items, + onChange, + value, +}: { + "aria-label"?: string; + items: readonly { value: string; label?: ReactNode }[]; + onChange: (value: string) => void; + value: string; +}) => ( +
+ {items.map((item) => ( + + ))} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx index 61209f1aac1..00b5bff7a6b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx @@ -3,10 +3,16 @@ * whatever cards follow them. Every card is the same height whatever it * draws, so changing a chart's view moves nothing around it, and before any * frame has arrived the cards are stable shells per configured metric, so - * the first data causes no layout shift. + * the first data causes no layout shift. The one thing that resizes a card + * is its Enlarge button: the card then spans the grid's full row at twice + * the row height, the cards after it fill the cells its row has left, and + * the rest move below. */ import { type ReactNode, useState } from "react"; +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + import { DEFAULT_METRIC_VIEW_SETTINGS, describeMetricView, @@ -15,8 +21,10 @@ import { type MetricViewSettings, } from "../experiments/experiment-metric-timeline"; import { + CHART_CARD_GRID_GAP, CHART_CARD_MIN_WIDTH, ChartCard, + chartCardBodyHeight, ChartCardGrid, chartCardHeight, type ChartCardTone, @@ -41,6 +49,14 @@ export type MetricTile = { /** The plot's height inside an experiment's metric cards; the smallest the axes and labels fit in. */ export const METRIC_PLOT_HEIGHT = 220; +/** "large" spans the grid's full row and two of its rows; "default" is one cell. */ +type MetricCardSize = "default" | "large"; + +const largeTileStyle = css({ + gridColumn: "[1 / -1]", + gridRow: "[span 2]", +}); + export const MetricTiles = ({ tiles, timeDomain, @@ -66,15 +82,21 @@ export const MetricTiles = ({ const [settingsById, setSettingsById] = useState< Record >({}); + const [sizeById, setSizeById] = useState>({}); + const rowHeight = chartCardHeight({ bodyHeight: plotHeight }); + // Two rows and the gap between them, less the card's own chrome. + const largePlotHeight = chartCardBodyHeight( + 2 * rowHeight + CHART_CARD_GRID_GAP, + ); return ( - + {tiles.map((tile) => { const settings = settingsById[tile.id] ?? DEFAULT_METRIC_VIEW_SETTINGS; const view = describeMetricView(settings, tile.outputType); + const large = (sizeById[tile.id] ?? "default") === "large"; + const tilePlotHeight = large ? largePlotHeight : plotHeight; + const sizeLabel = large ? "Shrink" : "Enlarge"; return ( - setSettingsById((previous) => ({ - ...previous, - [tile.id]: next, - })) - } - /> + <> + + setSettingsById((previous) => ({ + ...previous, + [tile.id]: next, + })) + } + /> +