Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/streaming-range-sweeps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hashintel/petrinaut": patch
"@hashintel/petrinaut-core": patch
---

A sweep's range selection runs as one stochastic simulation over the ranges: every run draws its own value per ranged parameter, and the metric distribution over the region streams live. `ExperimentRequest` carries optional per-run overrides (`runs`), forwarded by the worker-pool backend and refused by the WebGPU backend.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PetrinautExtensionSettings } from "../extensions";
import type { HirArtifacts } from "../hir-runtime";
import type { InitialMarking } from "../simulation/api";
import type { MonteCarloMetricSpec } from "../simulation/monte-carlo/metrics/types";
import type { MonteCarloRunConfig } from "../simulation/monte-carlo/types";
import type { SDCPN } from "../types/sdcpn";

export type ExperimentRequest = {
Expand All @@ -25,6 +26,13 @@ export type ExperimentRequest = {
readonly dt: number;
readonly maxTime: number;
readonly runCount: number;
/**
* Per-run overrides, indexed by global run index; `runs.length` must equal
* `runCount` when present. A sweep over parameter ranges uses this to give
* every run its own drawn parameter values. Backends whose compiled form
* bakes parameters in (the WebGPU shader) refuse requests that carry it.
*/
readonly runs?: readonly MonteCarloRunConfig[];
/**
* Metrics to record.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function assess(
dt: request.dt,
maxTime: request.maxTime,
runCount: request.runCount,
...(request.runs === undefined ? {} : { runs: request.runs }),
metricSpecs: request.metricSpecs,
...(request.hirArtifacts === undefined
? {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ function assess(
request: ExperimentRequest,
options: WebGpuExperimentBackendOptions,
): ExperimentAssessment {
if (request.runs !== undefined) {
const blockers: ExperimentBlockers = [
{
code: "per-run-parameters",
message:
"The GPU backend bakes parameter values into its shader, so it cannot run an experiment whose runs carry their own parameter values (a sweep over a parameter range). It runs on the CPU instead.",
origin: "configuration",
},
];
return { eligible: false, blockers };
}
if (request.hirArtifacts === undefined) {
const blockers: ExperimentBlockers = [
{
Expand Down
6 changes: 3 additions & 3 deletions libs/@hashintel/petrinaut/docs/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ Flip **Sweep** on any numeric scenario parameter to explore an interval of value

A sweep computes **what you have selected**. The results drawer grows a **Parameters** strip β€” pinned while you scroll β€” with one slider per swept parameter. Each slider selects a range on its interval, and starts spanning the whole of it:

- **Range** (the default): Petrinaut samples points spread across the selected region, a small batch at a time, and the metric charts below show the distribution **over the region** β€” it takes shape after the first few points and keeps sharpening while you stay. Resize the range from either end to focus; the status line counts sampled points and runs.
- **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value.
- **Range** (the default): Petrinaut runs **one stochastic simulation over the ranges** β€” every run draws its own value for each ranged parameter, spread across the selected interval β€” and the metric charts stream the live distribution **over the region**, sharpening exactly like a plain experiment's. Resize a range from either end to focus; compute restarts on the new selection. Range selections run on the CPU at full parallelism (the GPU needs one parameter value per experiment); an initial state that a scenario derives from a ranged parameter holds at the range's midpoint, while the simulation itself reads each run's own value.
Comment thread
kube marked this conversation as resolved.
- **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value β€” including on the GPU.

Move a slider and compute immediately restarts on the new selection, like a raytracer dropping its rays when the camera moves. Every position you have visited keeps its results: narrowing a range, collapsing to a point, or sliding back to an earlier value restores its runs and distributions instantly, and refinement resumes where it left off.

Every sampled point uses the same seed sequence (common random numbers), so differences you see across the interval come from the parameters, not from sampling luck. The GPU backend works for sweeps the same way it does for a plain experiment: the choice is made on the first batch and each later batch reuses it.
Every selection uses the same seed sequence (common random numbers), and a run's parameter draw depends only on its position in the sequence, so differences you see between selections come from the parameters, not from sampling luck.

#### The surface view

Expand Down
10 changes: 2 additions & 8 deletions libs/@hashintel/petrinaut/src/react/experiments/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,17 +138,11 @@ export type ExperimentRecord = {
export type ExperimentSweepState = {
/** Inclusive position range per swept parameter identifier. */
selection: SweepSelection;
/** Concrete values of the cell currently being computed, or null. */
activeCellValues: Readonly<Record<string, number>> | null;
/** Finished runs across the selected region. */
/** Finished runs for the selection. */
runsCompleted: number;
/** Runs contributing to the shown frames, including the in-flight batch. */
runsSampled: number;
/** Cells of the region with at least one finished batch. */
cellsSampled: number;
/** Cells inside the selected region. */
cellsInRegion: number;
/** Ladder target the in-flight batch climbs its cell to; null when done. */
/** Ladder target the in-flight batch climbs to; null when saturated. */
runTarget: number | null;
computing: boolean;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import {
axisPositionFor,
axisValueAt,
buildParameterAxis,
countRegionCells,
enumerateRegionCells,
fullSweepSelection,
getNextRunTarget,
mergeMetricFramesAcrossCells,
normalizeSweepSelection,
SWEEP_AXIS_STEPS,
sweepRunFraction,
} from "./parameter-grid";

import type { ExperimentParameterAxis } from "./parameter-grid";
Expand Down Expand Up @@ -197,43 +196,35 @@ describe("selections and regions", () => {
}),
).toEqual({ x: { from: 0, to: 4 }, y: { from: 1, to: 1 } });
});
});

it("counts the cells of a region", () => {
expect(countRegionCells(axes, fullSweepSelection(axes))).toBe(15);
expect(
countRegionCells(axes, {
x: { from: 1, to: 2 },
y: { from: 0, to: 0 },
}),
).toBe(2);
});

it("enumerates every region cell exactly once", () => {
const selection = { x: { from: 0, to: 4 }, y: { from: 0, to: 2 } };
const seen = [...enumerateRegionCells(axes, selection)].map(
(cell) => `${cell.x},${cell.y}`,
describe("sweepRunFraction", () => {
it("is prefix-stable: a run's draw never depends on how many runs exist", () => {
const first = Array.from({ length: 8 }, (_, index) =>
sweepRunFraction(index, 0),
);
expect(seen).toHaveLength(15);
expect(new Set(seen).size).toBe(15);
const extended = Array.from({ length: 25 }, (_, index) =>
sweepRunFraction(index, 0),
);
expect(extended.slice(0, 8)).toEqual(first);
});

it("spreads early cells across the region rather than scanning corner-first", () => {
const wide: ExperimentParameterAxis[] = [
{ identifier: "x", min: 0, max: 1, stepCount: 50, integer: false },
];
const selection = { x: { from: 0, to: 50 } };
const first = [...enumerateRegionCells(wide, selection)]
.slice(0, 4)
.map((cell) => cell.x!);
// The first few positions span the interval instead of clustering at 0.
expect(Math.max(...first) - Math.min(...first)).toBeGreaterThan(20);
it("spreads early runs across the unit interval", () => {
const fractions = Array.from({ length: 8 }, (_, index) =>
sweepRunFraction(index, 0),
);
expect(Math.min(...fractions)).toBeLessThan(0.2);
expect(Math.max(...fractions)).toBeGreaterThan(0.8);
});

it("enumerates a point region as its single cell", () => {
const selection = { x: { from: 2, to: 2 }, y: { from: 1, to: 1 } };
expect([...enumerateRegionCells(axes, selection)]).toEqual([
{ x: 2, y: 1 },
]);
it("draws different axes from different sequences", () => {
const xDraws = Array.from({ length: 6 }, (_, index) =>
sweepRunFraction(index, 0),
);
const yDraws = Array.from({ length: 6 }, (_, index) =>
sweepRunFraction(index, 1),
);
expect(xDraws).not.toEqual(yDraws);
});
});

Expand Down
97 changes: 19 additions & 78 deletions libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,16 +220,12 @@ export function normalizeSweepSelection(
);
}

/** Number of quantized cells inside the selected region. */
export function countRegionCells(
axes: readonly ExperimentParameterAxis[],
selection: SweepSelection,
): number {
return axes.reduce((product, axis) => {
const range = selection[axis.identifier] ?? { from: 0, to: axis.stepCount };
return product * (range.to - range.from + 1);
}, 1);
}
// One prime base per swept axis: two axes sharing a base would draw along a
// diagonal. A sweep can range every scenario parameter, so the list is long.
const HALTON_BASES = [
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71,
73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131,
];

/** Radical inverse of `index` in `base` β€” the Halton sequence's coordinate. */
function radicalInverse(index: number, base: number): number {
Expand All @@ -244,76 +240,21 @@ function radicalInverse(index: number, base: number): number {
return result;
}

const HALTON_BASES = [2, 3, 5, 7, 11, 13, 17, 19];

/**
* Enumerates every cell (position tuple) of the selected region exactly once,
* in a deterministic low-discrepancy order: early cells spread across the
* whole region, so a merged view over the region takes shape after a handful
* of batches instead of filling corner-first. Falls back to scanning the
* remaining cells in index order once the Halton sequence stops finding new
* ones, so coverage always completes.
* Where run `globalRunIndex` falls along ranged axis `axisIndex`, in [0, 1):
* a per-axis low-discrepancy sequence (radical inverse in a distinct prime
* base per axis), so any prefix of runs covers every range near-uniformly and
* jointly. Prefix-stable in the run index β€” a ladder batch extends the exact
* sequence earlier batches drew from, so cached runs never go stale.
*/
export function* enumerateRegionCells(
axes: readonly ExperimentParameterAxis[],
selection: SweepSelection,
): Generator<Record<string, number>, void, undefined> {
const ranges = axes.map((axis) => {
const range = selection[axis.identifier] ?? { from: 0, to: axis.stepCount };
return { identifier: axis.identifier, ...range };
});
const total = countRegionCells(axes, selection);
const yielded = new Set<string>();

const cellAt = (positions: number[]): Record<string, number> =>
Object.fromEntries(
ranges.map((range, axisIndex) => [
range.identifier,
positions[axisIndex]!,
]),
);

let stagnation = 0;
for (let index = 0; yielded.size < total && stagnation < total * 8; index++) {
const positions = ranges.map((range, axisIndex) => {
const span = range.to - range.from + 1;
const fraction = radicalInverse(
index + 1,
HALTON_BASES[axisIndex % HALTON_BASES.length]!,
);
return range.from + Math.min(Math.floor(fraction * span), span - 1);
});
const key = positions.join("|");
if (yielded.has(key)) {
stagnation += 1;
continue;
}
stagnation = 0;
yielded.add(key);
yield cellAt(positions);
}

if (yielded.size >= total) {
return;
}
// Sweep the stragglers in index order (first axis slowest).
const counters = ranges.map((range) => range.from);
for (;;) {
const key = counters.join("|");
if (!yielded.has(key)) {
yielded.add(key);
yield cellAt(counters);
}
let axisIndex = ranges.length - 1;
while (axisIndex >= 0 && counters[axisIndex]! >= ranges[axisIndex]!.to) {
counters[axisIndex] = ranges[axisIndex]!.from;
axisIndex -= 1;
}
if (axisIndex < 0) {
return;
}
counters[axisIndex]! += 1;
}
export function sweepRunFraction(
globalRunIndex: number,
axisIndex: number,
): number {
return radicalInverse(
globalRunIndex + 1,
HALTON_BASES[axisIndex % HALTON_BASES.length]!,
);
Comment thread
kube marked this conversation as resolved.
}

function mergeDistributionBins(
Expand Down
45 changes: 37 additions & 8 deletions libs/@hashintel/petrinaut/src/react/experiments/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import {
} from "./context";
import {
buildParameterAxis,
countRegionCells,
fullSweepSelection,
type ExperimentParameterAxis,
} from "./parameter-grid";
Expand Down Expand Up @@ -462,7 +461,7 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
override?: Partial<
Pick<
ExperimentRequest,
"parameterValues" | "initialMarking" | "seed" | "runCount"
"parameterValues" | "initialMarking" | "seed" | "runCount" | "runs"
>
>;
}) => Promise<ExperimentRequest>;
Expand All @@ -480,6 +479,11 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
* a sweep whose surface view is never opened spawns nothing extra.
*/
let backgroundCpuBackend: ExperimentBackend | null = null;
/**
* Range batches carry per-run parameter values, which the GPU refuses,
* so they go straight to the CPU worker pool at full parallelism.
*/
let rangeCpuBackend: ExperimentBackend | null = null;

const onNote = (note: { message: string }) => {
addNotification({
Expand All @@ -498,6 +502,7 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
seed: experiment.seed,
instantiateBatch: async ({
parameterValues,
runs,
seed,
runCount,
background,
Expand All @@ -509,8 +514,38 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
initialMarking: compiled.result.initialState,
seed,
runCount,
...(runs === undefined ? {} : { runs }),
Comment thread
kube marked this conversation as resolved.
};

if (runs !== undefined) {
// One stochastic experiment over the selected ranges: full worker
// pool, no backend walk β€” per-run parameter values are CPU-only.
rangeCpuBackend ??= createWorkerPoolExperimentBackend({
createWorker: workerFactoryRef.current,
shardCount:
shardCountRef.current ?? getDefaultMonteCarloShardCount(),
});
const request = await buildRequest({
needsHirTrees: rangeCpuBackend.needsHirTrees,
override,
});
const assessment = await rangeCpuBackend.assess(request);
if (!assessment.eligible) {
throw new Error(describeBlockers(assessment.blockers));
}
const instantiated = await assessment.instantiate({
signal,
onNote,
});
if (!instantiated.ok) {
throw new Error(describeBlockers(instantiated.blockers));
}
if (experiment.computeBackend !== "cpu") {
patchExperiment(experimentId, { computeBackend: "cpu" });
}
Comment thread
kube marked this conversation as resolved.
return instantiated.handle;
Comment thread
kube marked this conversation as resolved.
}

if (!chosenBackend) {
const selection = await selectExperimentBackend({
registrations,
Expand Down Expand Up @@ -572,11 +607,8 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
progress: update.progress,
sweep: {
selection: update.selection,
activeCellValues: update.activeCellValues,
Comment thread
kube marked this conversation as resolved.
runsCompleted: update.runsCompleted,
runsSampled: update.runsSampled,
cellsSampled: update.cellsSampled,
cellsInRegion: update.cellsInRegion,
runTarget: update.runTarget,
computing: update.computing,
},
Expand Down Expand Up @@ -750,11 +782,8 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
axes.length > 0
? {
selection: fullSweepSelection(axes),
activeCellValues: null,
runsCompleted: 0,
runsSampled: 0,
cellsSampled: 0,
cellsInRegion: countRegionCells(axes, fullSweepSelection(axes)),
runTarget: null,
computing: true,
}
Expand Down
Loading
Loading