diff --git a/.changeset/ds-range-slider.md b/.changeset/ds-range-slider.md new file mode 100644 index 00000000000..200ab1c45cd --- /dev/null +++ b/.changeset/ds-range-slider.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +Add `RangeSlider`: a two-thumb slider selecting an inclusive range, whose thumbs may coincide for single-point selections. diff --git a/.changeset/interval-sweeps-range-slider.md b/.changeset/interval-sweeps-range-slider.md new file mode 100644 index 00000000000..e34c382bd93 --- /dev/null +++ b/.changeset/interval-sweeps-range-slider.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Parameter sweeps declare an interval per swept parameter instead of a value count. The navigator becomes a range slider per parameter — the whole interval selected by default, resizable, collapsible to a point — with the interval quantized into ~50 positions so revisited positions restore their cached runs. A range selection samples points across the region in a low-discrepancy order and streams the merged distribution over the region; a point behaves as before. diff --git a/libs/@hashintel/ds-components/src/components/Slider/range-slider.tsx b/libs/@hashintel/ds-components/src/components/Slider/range-slider.tsx new file mode 100644 index 00000000000..ef791bf2b31 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Slider/range-slider.tsx @@ -0,0 +1,128 @@ +import { Slider as BaseSlider } from "@ark-ui/react/slider"; + +import { css, cx } from "@hashintel/ds-helpers/css"; + +const THUMB_SIZE = 12; + +const thumbStyles = css({ + outline: "none", + display: "block", + width: `[${THUMB_SIZE}px]`, + height: `[${THUMB_SIZE}px]`, + borderRadius: "full", + border: "[1px solid rgba(255,255,255,0.45)]", + background: + "[linear-gradient(180deg, rgba(59,130,246,0.95) 0%, rgba(37,99,235,0.98) 100%)]", + boxShadow: "[0 1px 6px rgba(37,99,235,0.28)]", + transition: "[transform 0.15s ease]", + "&[data-dragging]": { + transform: "scale(1.3)", + }, + "&[data-focus]": { + boxShadow: "[0 0 0 3px rgba(59,130,246,0.3)]", + }, +}); + +export interface RangeSliderProps { + className?: string; + style?: React.CSSProperties; + min: number; + max: number; + step?: number; + /** Both ends of the selected range; they may coincide (a point). */ + value: [number, number]; + "aria-label"?: string; + disabled?: boolean; + onChange?: (value: [number, number]) => void; + /** Fires once when a drag or keyboard interaction settles. */ + onChangeEnd?: (value: [number, number]) => void; +} + +/** + * A two-thumb slider selecting an inclusive range. The thumbs may occupy the + * same position, which callers treat as a single-point selection. + */ +export const RangeSlider: React.FC = ({ + className, + style, + min, + max, + step, + value, + "aria-label": ariaLabel, + disabled, + onChange, + onChangeEnd, +}) => { + const emit = (values: number[]): [number, number] => { + const [start = min, end = max] = values; + return start <= end ? [start, end] : [end, start]; + }; + + return ( + { + onChange?.(emit(details.value)); + }} + onValueChangeEnd={(details) => { + onChangeEnd?.(emit(details.value)); + }} + > + + + + + + + + + + + + + + ); +}; diff --git a/libs/@hashintel/ds-components/src/main.ts b/libs/@hashintel/ds-components/src/main.ts index 66e814ee7d7..c911652bebb 100644 --- a/libs/@hashintel/ds-components/src/main.ts +++ b/libs/@hashintel/ds-components/src/main.ts @@ -67,6 +67,10 @@ export { type SegmentedControlProps, } from "./components/SegmentedControl/segmented-control"; export { Select, type SelectItem } from "./components/Select/select"; +export { + RangeSlider, + type RangeSliderProps, +} from "./components/Slider/range-slider"; export { Slider, type SliderProps } from "./components/Slider/slider"; export { TextArea } from "./components/TextArea/text-area"; export { TextInput } from "./components/TextInput/text-input"; diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 5a6fd740c2e..4a44c4d0719 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -18,7 +18,7 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo | **Name** | `Experiment` | Free text. | | **Scenario** | `(Default)` | Either `(Default)` (no scenario; uses each place's manually-set initial marking and net-level parameter defaults) or one of your saved [scenarios](scenarios.md). An experiment runs against exactly one scenario. | | **Scenario parameters** | each scenario parameter's default | When a scenario is selected, you can override its scenario parameters per experiment. Expressions are evaluated once at start. Each numeric parameter also has a **Sweep** toggle — see [Parameter sweeps](#parameter-sweeps). | -| **Runs** | `1000` | Positive integer; how many independent simulations to run. For a sweep, this is the run budget **per combination**. | +| **Runs** | `1000` | Positive integer; how many independent simulations to run. For a sweep, this is the run budget **per sampled point**. | | **Time step (dt)** | `0.1` | Same meaning as in single-run simulations (see [Simulation](simulation.md#time-step-dt)). | | **Max time (seconds)** | `180` | Each run advances until simulation time reaches this value, then completes. | | **Run on GPU** | off | Only shown when **WebGPU** is on under **Settings → Simulation**. Greyed out with the reason on hover when this model cannot run on the GPU. See [Compute backend](#compute-backend-experimental). | @@ -35,7 +35,7 @@ Experiments progress through these status labels: | ---------------- | ------------------------------------------------------------------------------------------------- | | **Initializing** | The experiment has been created and its workers are starting up. | | **Running** | Runs are in progress. | -| **Idle** | A sweep whose selected combination is fully sampled. Moving a parameter control resumes running. | +| **Idle** | A sweep whose selected region is fully sampled. Moving a parameter control resumes running. | | **Complete** | All runs finished without error. | | **Error** | The experiment failed to start or hit an unrecoverable error. The drawer shows the error message. | | **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | @@ -55,15 +55,20 @@ Two consequences worth knowing: ### Parameter sweeps -Flip **Sweep** on any numeric scenario parameter to explore a range of values instead of one. Set the minimum, the maximum, and how many evenly spaced values to take; several swept parameters form a grid of combinations (capped at 200), and the form shows the grid size before you run. +Flip **Sweep** on any numeric scenario parameter to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so results can be cached and restored per position. -A sweep never computes its whole grid up front. It computes **the combination you are looking at**: the results drawer grows a **Parameters** strip — pinned while you scroll — with one control per swept parameter. Runs for the selected combination accumulate in escalating batches (8, 25, 100, … up to your run budget), and the metric charts below sharpen as they stream in. Move a control and compute immediately restarts on the new combination, like a raytracer dropping its rays when the camera moves. Combinations you have visited keep their results, so stepping back is instant and refinement resumes where it left off. +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: -Every combination samples the same seed sequence (common random numbers), so differences you see between combinations 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 combination reuses 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. + +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. #### The surface view -A sweep with two or more swept parameters grows a **Surface** section under the metrics: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at its navigator value. The plot fills in live — combinations are sampled a few at a time (8 runs each), coarse shape first — and **clicking the surface moves the navigator** to the nearest combination, which then refines it with more runs. Changing the fixed parameters, the axes, or the metric restarts the fill for the new slice. +A sweep with two or more swept parameters grows a **Surface** section under the metrics: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The plot fills in live — points are sampled a few runs at a time (8 runs each), coarse shape first — and **clicking the surface moves the navigator**: both shown parameters collapse to a point at the clicked position, which then refines with more runs. Changing the fixed parameters, the axes, or the metric restarts the fill for the new slice. ### Compute backend (experimental) diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.ts index eddb800c708..22f1e392737 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/context.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/context.ts @@ -128,15 +128,19 @@ export type ExperimentRecord = { /** Navigator-facing state of a sweep experiment. */ export type ExperimentSweepState = { - /** Value index per swept parameter identifier. */ + /** Inclusive position range per swept parameter identifier. */ selection: SweepSelection; - /** Concrete swept values for `selection`. */ - parameterValues: Readonly>; - /** Finished runs for the selected combination. */ + /** Concrete values of the cell currently being computed, or null. */ + activeCellValues: Readonly> | null; + /** Finished runs across the selected region. */ runsCompleted: number; /** Runs contributing to the shown frames, including the in-flight batch. */ runsSampled: number; - /** Ladder target the in-flight batch climbs to; null when saturated. */ + /** 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. */ runTarget: number | null; computing: boolean; }; diff --git a/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.test.ts index d1792e63546..bc36b9bfa99 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from "vitest"; import { - buildParameterGridCombinations, - buildParameterRangeValues, - countGridCombinations, + axisPositionFor, + axisValueAt, + buildParameterAxis, + countRegionCells, + enumerateRegionCells, + fullSweepSelection, getNextRunTarget, mergeMetricFramesAcrossCells, + normalizeSweepSelection, + SWEEP_AXIS_STEPS, } from "./parameter-grid"; +import type { ExperimentParameterAxis } from "./parameter-grid"; import type { MonteCarloUserDefinedDistributionMetricFrame, MonteCarloUserDefinedScalarMetricFrame, @@ -53,135 +59,181 @@ function makeScalarFrame( }; } -describe("buildParameterRangeValues", () => { - it("expands an inclusive range into evenly spaced values", () => { - const outcome = buildParameterRangeValues( - { identifier: "x", type: "real" }, - { mode: "range", min: 0, max: 9, valueCount: 10 }, +describe("buildParameterAxis", () => { + it("quantizes a real interval into SWEEP_AXIS_STEPS steps", () => { + const outcome = buildParameterAxis( + { identifier: "beta", type: "real" }, + { min: 0, max: 1 }, ); - expect(outcome).toEqual({ ok: true, - values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + axis: { + identifier: "beta", + min: 0, + max: 1, + stepCount: SWEEP_AXIS_STEPS, + integer: false, + }, }); }); - it("keeps fractional steps free of float artifacts", () => { - const outcome = buildParameterRangeValues( - { identifier: "x", type: "real" }, - { mode: "range", min: 0.1, max: 0.5, valueCount: 5 }, + it("gives a narrow integer interval one step per integer", () => { + const outcome = buildParameterAxis( + { identifier: "count", type: "integer" }, + { min: 3, max: 9 }, ); - expect(outcome).toEqual({ ok: true, - values: [0.1, 0.2, 0.3, 0.4, 0.5], + axis: { + identifier: "count", + min: 3, + max: 9, + stepCount: 6, + integer: true, + }, }); }); - it("always includes max as the final value", () => { - const outcome = buildParameterRangeValues( - { identifier: "x", type: "real" }, - { mode: "range", min: 0, max: 1, valueCount: 3 }, + it("caps a wide integer interval at SWEEP_AXIS_STEPS", () => { + const outcome = buildParameterAxis( + { identifier: "count", type: "integer" }, + { min: 0, max: 1000 }, ); - - expect(outcome).toEqual({ ok: true, values: [0, 0.5, 1] }); + expect(outcome.ok && outcome.axis.stepCount).toBe(SWEEP_AXIS_STEPS); }); - it("produces a single value when valueCount is 1", () => { - const outcome = buildParameterRangeValues( - { identifier: "x", type: "real" }, - { mode: "range", min: 2.5, max: 2.5, valueCount: 1 }, - ); - - expect(outcome).toEqual({ ok: true, values: [2.5] }); + it("rejects boolean parameters", () => { + expect( + buildParameterAxis( + { identifier: "flag", type: "boolean" }, + { min: 0, max: 1 }, + ).ok, + ).toBe(false); }); - it("rounds integer parameter values", () => { - const outcome = buildParameterRangeValues( - { identifier: "n", type: "integer" }, - { mode: "range", min: 0, max: 10, valueCount: 3 }, - ); + it("rejects ratio intervals outside [0, 1]", () => { + expect( + buildParameterAxis( + { identifier: "share", type: "ratio" }, + { min: -0.1, max: 0.5 }, + ).ok, + ).toBe(false); + }); - expect(outcome).toEqual({ ok: true, values: [0, 5, 10] }); + it("rejects max <= min", () => { + expect( + buildParameterAxis( + { identifier: "beta", type: "real" }, + { min: 1, max: 1 }, + ).ok, + ).toBe(false); }); +}); - it("rejects integer ranges that round onto duplicate values", () => { - const outcome = buildParameterRangeValues( - { identifier: "n", type: "integer" }, - { mode: "range", min: 0, max: 2, valueCount: 5 }, - ); +describe("axisValueAt / axisPositionFor", () => { + const beta: ExperimentParameterAxis = { + identifier: "beta", + min: 0, + max: 1, + stepCount: 50, + integer: false, + }; - expect(outcome.ok).toBe(false); - if (!outcome.ok) { - expect(outcome.error).toContain("duplicate integer values"); - } + it("maps position endpoints to the interval endpoints", () => { + expect(axisValueAt(beta, 0)).toBe(0); + expect(axisValueAt(beta, 50)).toBe(1); + expect(axisValueAt(beta, 25)).toBe(0.5); }); - it("rejects boolean parameters", () => { - const outcome = buildParameterRangeValues( - { identifier: "flag", type: "boolean" }, - { mode: "range", min: 0, max: 1, valueCount: 2 }, - ); - - expect(outcome.ok).toBe(false); + it("keeps generated values free of float artifacts", () => { + const axis: ExperimentParameterAxis = { + identifier: "x", + min: 0, + max: 0.7, + stepCount: 50, + integer: false, + }; + // 0.7 * 15 / 50 in raw float arithmetic carries an artifact tail. + expect(String(axisValueAt(axis, 15)).length).toBeLessThanOrEqual(6); }); - it("rejects ratio ranges outside [0, 1]", () => { - const outcome = buildParameterRangeValues( - { identifier: "p", type: "ratio" }, - { mode: "range", min: 0.5, max: 1.5, valueCount: 3 }, - ); + it("rounds integer axis values to integers", () => { + const axis: ExperimentParameterAxis = { + identifier: "count", + min: 0, + max: 1000, + stepCount: 50, + integer: true, + }; + expect(axisValueAt(axis, 7)).toBe(140); + }); - expect(outcome.ok).toBe(false); + it("round-trips values to their nearest position", () => { + expect(axisPositionFor(beta, 0.5)).toBe(25); + expect(axisPositionFor(beta, 0.501)).toBe(25); + expect(axisPositionFor(beta, 2)).toBe(50); + expect(axisPositionFor(beta, -1)).toBe(0); }); +}); - it("rejects max <= min when more than one value is requested", () => { - const outcome = buildParameterRangeValues( - { identifier: "x", type: "real" }, - { mode: "range", min: 5, max: 5, valueCount: 2 }, - ); +describe("selections and regions", () => { + const axes: ExperimentParameterAxis[] = [ + { identifier: "x", min: 0, max: 1, stepCount: 4, integer: false }, + { identifier: "y", min: 0, max: 1, stepCount: 2, integer: false }, + ]; - expect(outcome.ok).toBe(false); + it("defaults to the whole interval per axis", () => { + expect(fullSweepSelection(axes)).toEqual({ + x: { from: 0, to: 4 }, + y: { from: 0, to: 2 }, + }); }); - it("rejects non-integer or non-positive value counts", () => { - for (const valueCount of [0, -1, 2.5, Number.NaN]) { - const outcome = buildParameterRangeValues( - { identifier: "x", type: "real" }, - { mode: "range", min: 0, max: 1, valueCount }, - ); + it("normalizes reversed and out-of-bounds ranges", () => { + expect( + normalizeSweepSelection(axes, { + x: { from: 9, to: -3 }, + y: { from: 1, to: 1 }, + }), + ).toEqual({ x: { from: 0, to: 4 }, y: { from: 1, to: 1 } }); + }); - expect(outcome.ok).toBe(false); - } + 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); }); -}); -describe("buildParameterGridCombinations", () => { - it("returns a single empty combination without axes", () => { - expect(buildParameterGridCombinations([])).toEqual([{}]); - expect(countGridCombinations([])).toBe(1); + 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}`, + ); + expect(seen).toHaveLength(15); + expect(new Set(seen).size).toBe(15); }); - it("builds the row-major cartesian product of the axes", () => { - const combinations = buildParameterGridCombinations([ - { identifier: "a", values: [1, 2] }, - { identifier: "b", values: [10, 20, 30] }, - ]); + 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); + }); - expect(combinations).toEqual([ - { a: 1, b: 10 }, - { a: 1, b: 20 }, - { a: 1, b: 30 }, - { a: 2, b: 10 }, - { a: 2, b: 20 }, - { a: 2, b: 30 }, + 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 }, ]); - expect( - countGridCombinations([ - { identifier: "a", values: [1, 2] }, - { identifier: "b", values: [10, 20, 30] }, - ]), - ).toBe(6); }); }); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.ts b/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.ts index b3a11b9e8b0..ace19a6d6c0 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/parameter-grid.ts @@ -17,15 +17,14 @@ export type ExperimentParameterFixedInput = { }; /** - * A scenario parameter swept across `valueCount` evenly spaced values from - * `min` to `max` (both inclusive). Each value becomes one axis position in - * the experiment's parameter grid. + * A scenario parameter swept across the interval `[min, max]`. The interval + * quantizes internally (`buildParameterAxis`); nothing about its resolution + * is declared here. */ export type ExperimentParameterRangeInput = { mode: "range"; min: number; max: number; - valueCount: number; }; export type ExperimentParameterInput = @@ -33,20 +32,36 @@ export type ExperimentParameterInput = | ExperimentParameterRangeInput; /** - * One ranged parameter of an experiment: the discrete values it takes across - * the grid. The cartesian product of all axes defines the experiment's cells. + * One swept parameter of an experiment: its interval, quantized into + * `stepCount` steps. Positions run `0..stepCount` inclusive; position `p` + * maps to the value `axisValueAt(axis, p)`. Quantization is what makes + * revisited slider positions cache hits: every computed batch is keyed by + * position, so returning to a position restores its runs and distributions. */ export type ExperimentParameterAxis = { identifier: string; - values: readonly number[]; + min: number; + max: number; + /** Positions run 0..stepCount inclusive. */ + stepCount: number; + integer: boolean; }; /** - * Hard cap on the parameter grid size. Every combination runs `runCount` - * simulations and stores per-frame metric distributions, so an unbounded grid - * exhausts browser memory long before it finishes computing. + * Quantization steps per axis. Fine enough that the slider feels continuous + * against the interval, coarse enough that positions repeat (and therefore + * hit the per-position cache) when the user returns to one. + */ +export const SWEEP_AXIS_STEPS = 50; + +/** Inclusive position range of one axis; `from === to` is a point. */ +export type SweepAxisSelection = { from: number; to: number }; + +/** + * The navigator's selection: an inclusive position range per swept + * parameter. The default selection spans every axis whole. */ -export const MAX_EXPERIMENT_COMBINATIONS = 200; +export type SweepSelection = Readonly>; /** * Cumulative run targets a combination climbs through as it is refined: @@ -88,11 +103,8 @@ export function getNextRunTarget( return Math.min(target, maxRuns); } -/** Grid size above which the create form warns about cost. */ -export const WARN_EXPERIMENT_COMBINATIONS = 50; - -export type BuildRangeValuesOutcome = - | { ok: true; values: number[] } +export type BuildAxisOutcome = + | { ok: true; axis: ExperimentParameterAxis } | { ok: false; error: string }; /** Strips float artifacts (e.g. 0.30000000000000004) from generated values. */ @@ -101,15 +113,14 @@ function normalizeRangeValue(value: number): number { } /** - * Expands a range input into its discrete values: `valueCount` evenly spaced - * points from `min` to `max` inclusive. Integer parameters have each point - * rounded to the nearest integer (a range that rounds two points onto the - * same integer is rejected rather than silently deduplicated). + * Builds a swept parameter's quantized axis from its interval. Integer + * parameters get one step per integer when the interval is narrower than + * `SWEEP_AXIS_STEPS`, so every position is a distinct integer. */ -export function buildParameterRangeValues( +export function buildParameterAxis( parameter: Pick, - range: ExperimentParameterRangeInput, -): BuildRangeValuesOutcome { + range: Pick, +): BuildAxisOutcome { const { identifier, type } = parameter; if (type === "boolean") { @@ -124,77 +135,185 @@ export function buildParameterRangeValues( error: `${identifier}: range min and max must be finite numbers`, }; } - if (!Number.isInteger(range.valueCount) || range.valueCount < 1) { + if (type === "ratio" && (range.min < 0 || range.max > 1)) { return { ok: false, - error: `${identifier}: range needs a whole number of values (at least 1)`, + error: `${identifier}: ratio ranges must stay between 0 and 1`, }; } - if (range.valueCount > 1 && range.max <= range.min) { + + const min = type === "integer" ? Math.round(range.min) : range.min; + const max = type === "integer" ? Math.round(range.max) : range.max; + if (max <= min) { return { ok: false, error: `${identifier}: range max must be greater than min`, }; } - if (type === "ratio" && (range.min < 0 || range.max > 1)) { - return { - ok: false, - error: `${identifier}: ratio ranges must stay between 0 and 1`, - }; - } - if (range.valueCount === 1) { - const single = type === "integer" ? Math.round(range.min) : range.min; - return { ok: true, values: [normalizeRangeValue(single)] }; - } + return { + ok: true, + axis: { + identifier, + min, + max, + stepCount: + type === "integer" + ? Math.min(SWEEP_AXIS_STEPS, max - min) + : SWEEP_AXIS_STEPS, + integer: type === "integer", + }, + }; +} - const step = (range.max - range.min) / (range.valueCount - 1); - const values: number[] = []; - for (let index = 0; index < range.valueCount; index++) { - const raw = - index === range.valueCount - 1 ? range.max : range.min + step * index; - values.push( - normalizeRangeValue(type === "integer" ? Math.round(raw) : raw), - ); - } +/** The concrete parameter value at a quantized position (0..stepCount). */ +export function axisValueAt( + axis: ExperimentParameterAxis, + position: number, +): number { + const clamped = Math.min(Math.max(position, 0), axis.stepCount); + const raw = axis.min + ((axis.max - axis.min) * clamped) / axis.stepCount; + return normalizeRangeValue(axis.integer ? Math.round(raw) : raw); +} - if (type === "integer" && new Set(values).size !== values.length) { - return { - ok: false, - error: `${identifier}: the range produces duplicate integer values — reduce the number of values`, - }; - } +/** The quantized position nearest to `value` (0..stepCount). */ +export function axisPositionFor( + axis: ExperimentParameterAxis, + value: number, +): number { + const fraction = (value - axis.min) / (axis.max - axis.min); + return Math.min( + Math.max(Math.round(fraction * axis.stepCount), 0), + axis.stepCount, + ); +} + +/** The default selection: every axis spans its whole interval. */ +export function fullSweepSelection( + axes: readonly ExperimentParameterAxis[], +): SweepSelection { + return Object.fromEntries( + axes.map((axis) => [axis.identifier, { from: 0, to: axis.stepCount }]), + ); +} - return { ok: true, values }; +/** Clamps a selection to the axes and orders each range's ends. */ +export function normalizeSweepSelection( + axes: readonly ExperimentParameterAxis[], + selection: SweepSelection, +): SweepSelection { + return Object.fromEntries( + axes.map((axis) => { + const range = selection[axis.identifier] ?? { + from: 0, + to: axis.stepCount, + }; + const clamp = (position: number) => + Math.min(Math.max(Math.round(position), 0), axis.stepCount); + const from = clamp(range.from); + const to = clamp(range.to); + return [ + axis.identifier, + from <= to ? { from, to } : { from: to, to: from }, + ]; + }), + ); } -/** Number of cells the cartesian product of the axes produces (1 when empty). */ -export function countGridCombinations( +/** Number of quantized cells inside the selected region. */ +export function countRegionCells( axes: readonly ExperimentParameterAxis[], + selection: SweepSelection, ): number { - return axes.reduce((product, axis) => product * axis.values.length, 1); + return axes.reduce((product, axis) => { + const range = selection[axis.identifier] ?? { from: 0, to: axis.stepCount }; + return product * (range.to - range.from + 1); + }, 1); } +/** Radical inverse of `index` in `base` — the Halton sequence's coordinate. */ +function radicalInverse(index: number, base: number): number { + let result = 0; + let fraction = 1 / base; + let remaining = index; + while (remaining > 0) { + result += (remaining % base) * fraction; + remaining = Math.floor(remaining / base); + fraction /= base; + } + return result; +} + +const HALTON_BASES = [2, 3, 5, 7, 11, 13, 17, 19]; + /** - * Cartesian product of the axes' values, row-major (the first axis varies - * slowest). Returns a single empty combination when there are no axes, so a - * range-less experiment is just a grid with one cell. + * 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. */ -export function buildParameterGridCombinations( +export function* enumerateRegionCells( axes: readonly ExperimentParameterAxis[], -): Record[] { - let combinations: Record[] = [{}]; - - for (const axis of axes) { - combinations = combinations.flatMap((combination) => - axis.values.map((value) => ({ - ...combination, - [axis.identifier]: value, - })), + selection: SweepSelection, +): Generator, 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(); + + const cellAt = (positions: number[]): Record => + 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); } - return combinations; + 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; + } } function mergeDistributionBins( diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index da3fabe234b..b1efaeaaa5a 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -316,36 +316,36 @@ describe("buildSweepAxes", () => { it("splits fixed values from sweep axes", () => { const { fixedValues, axes } = buildSweepAxes(scenario, { - beta: { mode: "range", min: 0, max: 1, valueCount: 5 }, + beta: { mode: "range", min: 0, max: 1 }, count: { mode: "fixed", value: "12" }, }); expect(fixedValues).toEqual({ count: "12" }); expect(axes).toEqual([ - { identifier: "beta", values: [0, 0.25, 0.5, 0.75, 1] }, + { identifier: "beta", min: 0, max: 1, stepCount: 50, integer: false }, ]); }); - it("rejects an invalid range with the parameter named", () => { - expect(() => - buildSweepAxes(scenario, { - beta: { mode: "range", min: 1, max: 0, valueCount: 5 }, - }), - ).toThrow("beta"); + it("gives integer parameters one step per integer on narrow intervals", () => { + const { axes } = buildSweepAxes(scenario, { + count: { mode: "range", min: 0, max: 20 }, + }); + expect(axes).toEqual([ + { identifier: "count", min: 0, max: 20, stepCount: 20, integer: true }, + ]); }); - it("rejects a grid over the combination cap", () => { + it("rejects an invalid range with the parameter named", () => { expect(() => buildSweepAxes(scenario, { - beta: { mode: "range", min: 0, max: 1, valueCount: 21 }, - count: { mode: "range", min: 0, max: 20, valueCount: 21 }, + beta: { mode: "range", min: 1, max: 0 }, }), - ).toThrow("maximum is 200"); + ).toThrow("beta"); }); it("ignores inputs for parameters the scenario does not declare", () => { const { axes } = buildSweepAxes(scenario, { - ghost: { mode: "range", min: 0, max: 1, valueCount: 3 }, + ghost: { mode: "range", min: 0, max: 1 }, }); expect(axes).toEqual([]); }); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index 1a118d5894c..1304547c250 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -45,9 +45,9 @@ import { isTerminalExperimentStatus, } from "./context"; import { - buildParameterRangeValues, - countGridCombinations, - MAX_EXPERIMENT_COMBINATIONS, + buildParameterAxis, + countRegionCells, + fullSweepSelection, type ExperimentParameterAxis, } from "./parameter-grid"; import { @@ -97,17 +97,11 @@ export function buildSweepAxes( continue; } - const outcome = buildParameterRangeValues(parameter, input); + const outcome = buildParameterAxis(parameter, input); if (!outcome.ok) { throw new Error(outcome.error); } - axes.push({ identifier: parameter.identifier, values: outcome.values }); - } - - if (countGridCombinations(axes) > MAX_EXPERIMENT_COMBINATIONS) { - throw new Error( - `The parameter ranges produce ${countGridCombinations(axes)} combinations; the maximum is ${MAX_EXPERIMENT_COMBINATIONS}`, - ); + axes.push(outcome.axis); } return { fixedValues, axes }; @@ -526,9 +520,11 @@ export const ExperimentsProvider: React.FC = ({ progress: update.progress, sweep: { selection: update.selection, - parameterValues: update.parameterValues, + activeCellValues: update.activeCellValues, runsCompleted: update.runsCompleted, runsSampled: update.runsSampled, + cellsSampled: update.cellsSampled, + cellsInRegion: update.cellsInRegion, runTarget: update.runTarget, computing: update.computing, }, @@ -663,14 +659,12 @@ export const ExperimentsProvider: React.FC = ({ sweep: axes.length > 0 ? { - selection: Object.fromEntries( - axes.map((axis) => [axis.identifier, 0]), - ), - parameterValues: Object.fromEntries( - axes.map((axis) => [axis.identifier, axis.values[0]!]), - ), + selection: fullSweepSelection(axes), + activeCellValues: null, runsCompleted: 0, runsSampled: 0, + cellsSampled: 0, + cellsInRegion: countRegionCells(axes, fullSweepSelection(axes)), runTarget: null, computing: true, } diff --git a/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.test.ts index 4e6b8bad147..b321a188882 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.test.ts @@ -4,10 +4,11 @@ import { createSweepSession, sweepBatchSeed, sweepCellKey, - sweepSelectionValues, + sweepCellValues, } from "./sweep-session"; -import type { SweepSessionUpdate } from "./sweep-session"; +import type { ExperimentParameterAxis } from "./parameter-grid"; +import type { SweepSelection, SweepSessionUpdate } from "./sweep-session"; import type { MonteCarloExperiment, MonteCarloExperimentEvent, @@ -15,10 +16,30 @@ import type { MonteCarloWorkerProgress, } from "@hashintel/petrinaut-core"; -const AXES = [ - { identifier: "x", values: [0, 1, 2] }, - { identifier: "y", values: [10, 20] }, -]; +/** Positions 0..2 map to values 0, 1, 2. */ +const X_AXIS: ExperimentParameterAxis = { + identifier: "x", + min: 0, + max: 2, + stepCount: 2, + integer: false, +}; + +/** Positions 0..1 map to values 10, 20. */ +const Y_AXIS: ExperimentParameterAxis = { + identifier: "y", + min: 10, + max: 20, + stepCount: 1, + integer: false, +}; + +const AXES = [X_AXIS, Y_AXIS]; + +const point = (x: number, y: number): SweepSelection => ({ + x: { from: x, to: x }, + y: { from: y, to: y }, +}); function frame( runSampleCount: number, @@ -149,7 +170,7 @@ function makeFakeBatch(request: { }; } -function makeHarness(runCount: number) { +function makeHarness(runCount: number, initialSelection?: SweepSelection) { const batches: ReturnType[] = []; const updates: SweepSessionUpdate[] = []; const onError = vi.fn(); @@ -158,6 +179,7 @@ function makeHarness(runCount: number) { axes: AXES, runCount, seed: 42, + ...(initialSelection ? { initialSelection } : {}), instantiateBatch: (request) => { const batch = makeFakeBatch(request); batches.push(batch); @@ -168,7 +190,7 @@ function makeHarness(runCount: number) { }); const settle = async () => { - // Instantiation resolves through microtasks; two flushes cover the chain. + // Instantiation resolves through microtasks; three flushes cover the chain. await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -177,14 +199,14 @@ function makeHarness(runCount: number) { return { session, batches, updates, onError, settle }; } -describe("sweepSelectionValues / sweepCellKey / sweepBatchSeed", () => { - it("maps value indices to concrete values, clamped to the axis", () => { - expect(sweepSelectionValues(AXES, { x: 2, y: 5 })).toEqual({ x: 2, y: 20 }); - expect(sweepSelectionValues(AXES, {})).toEqual({ x: 0, y: 10 }); +describe("sweepCellValues / sweepCellKey / sweepBatchSeed", () => { + it("maps positions to concrete values, clamped to the axis", () => { + expect(sweepCellValues(AXES, { x: 2, y: 5 })).toEqual({ x: 2, y: 20 }); + expect(sweepCellValues(AXES, {})).toEqual({ x: 0, y: 10 }); }); - it("keys cells by values in axis order", () => { - expect(sweepCellKey(AXES, { y: 20, x: 1 })).toBe("x=1|y=20"); + it("keys cells by positions in axis order", () => { + expect(sweepCellKey(AXES, { y: 1, x: 2 })).toBe("x=2|y=1"); }); it("keeps the base seed verbatim for the first batch", () => { @@ -194,8 +216,8 @@ describe("sweepSelectionValues / sweepCellKey / sweepBatchSeed", () => { }); describe("createSweepSession", () => { - it("climbs the ladder on the initial selection, folding batches into the cache", async () => { - const { session, batches, updates, settle } = makeHarness(25); + it("climbs the ladder on a point selection, folding batches into the cache", async () => { + const { session, batches, updates, settle } = makeHarness(25, point(0, 0)); await settle(); expect(batches).toHaveLength(1); @@ -224,16 +246,50 @@ describe("createSweepSession", () => { expect(last.computing).toBe(false); expect(last.runTarget).toBeNull(); expect(last.runsCompleted).toBe(25); + expect(last.cellsInRegion).toBe(1); expect(last.metricFrames[0]).toMatchObject({ bins: [[1, 25]] }); session.dispose(); }); - it("restarts on the new combination when the selection changes", async () => { - const { session, batches, updates, settle } = makeHarness(25); + it("levels every cell of the default full region at the first rung", async () => { + const { session, batches, updates, settle } = makeHarness(8); + + // 3 x-positions × 2 y-positions = 6 cells, each one 8-run batch at the + // first rung, all with the base seed — common random numbers across the + // region. + const seenCells = new Set(); + for (let index = 0; index < 6; index++) { + await settle(); + const batch = batches.at(-1)!; + expect(batch.request.seed).toBe(42); + expect(batch.request.runCount).toBe(8); + seenCells.add( + `${batch.request.parameterValues.x},${batch.request.parameterValues.y}`, + ); + batch.stream([frame(8, [[1, 8]])]); + batch.complete(); + } + await settle(); + + expect(batches).toHaveLength(6); + expect(seenCells.size).toBe(6); + + const last = updates.at(-1)!; + expect(last.computing).toBe(false); + expect(last.cellsInRegion).toBe(6); + expect(last.cellsSampled).toBe(6); + expect(last.runsCompleted).toBe(48); + // The merged region view sums every cell's bins. + expect(last.metricFrames[0]).toMatchObject({ bins: [[1, 48]] }); + session.dispose(); + }); + + it("restarts on the new region when the selection changes", async () => { + const { session, batches, updates, settle } = makeHarness(25, point(0, 0)); await settle(); batches[0]!.stream([frame(4, [[1, 4]])]); - session.setSelection({ x: 1, y: 0 }); + session.setSelection(point(1, 0)); await settle(); // The first batch was cancelled; its in-flight frames are discarded. @@ -243,28 +299,28 @@ describe("createSweepSession", () => { expect(batches[1]!.request).toMatchObject({ seed: 42, runCount: 8 }); const last = updates.at(-1)!; - expect(last.parameterValues).toEqual({ x: 1, y: 10 }); + expect(last.activeCellValues).toEqual({ x: 1, y: 10 }); expect(last.runsCompleted).toBe(0); session.dispose(); }); - it("resumes a revisited combination from its ladder position", async () => { - const { session, batches, settle } = makeHarness(100); + it("resumes a revisited point from its ladder position", async () => { + const { session, batches, settle } = makeHarness(100, point(0, 0)); await settle(); batches[0]!.stream([frame(8, [[2, 8]])]); batches[0]!.complete(); await settle(); - expect(batches).toHaveLength(2); // second rung of {x:0,y:10} running + expect(batches).toHaveLength(2); // second rung of {x:0,y:0} running - session.setSelection({ x: 1, y: 0 }); + session.setSelection(point(1, 0)); await settle(); expect(batches).toHaveLength(3); - session.setSelection({ x: 0, y: 0 }); + session.setSelection(point(0, 0)); await settle(); - // Back on the first combination: its 8 finished runs survive, so the new - // batch starts at the second rung, not the first. + // Back on the first point: its 8 finished runs survive, so the new batch + // starts at the second rung, not the first. const resumed = batches.at(-1)!; expect(resumed.request.parameterValues).toEqual({ x: 0, y: 10 }); expect(resumed.request.runCount).toBe(17); @@ -272,8 +328,31 @@ describe("createSweepSession", () => { session.dispose(); }); + it("shows cached cells of a widened region immediately", async () => { + const { session, batches, updates, settle } = makeHarness(8, point(0, 0)); + await settle(); + batches[0]!.stream([frame(8, [[3, 8]])]); + batches[0]!.complete(); + await settle(); + + // Widen x to cover positions 0..1: the cached point contributes to the + // merged view straight away, before the new cell finishes. + session.setSelection({ x: { from: 0, to: 1 }, y: { from: 0, to: 0 } }); + await settle(); + + const during = updates.at(-1)!; + expect(during.cellsInRegion).toBe(2); + expect(during.cellsSampled).toBe(1); + expect(during.runsCompleted).toBe(8); + expect(during.metricFrames[0]).toMatchObject({ bins: [[3, 8]] }); + session.dispose(); + }); + it("stops and reports when a batch errors", async () => { - const { session, batches, updates, onError, settle } = makeHarness(25); + const { session, batches, updates, onError, settle } = makeHarness( + 25, + point(0, 0), + ); await settle(); batches[0]!.error("device lost"); @@ -286,12 +365,12 @@ describe("createSweepSession", () => { }); it("samples background cells up the same seed ladder, one at a time", async () => { - const { session, batches, settle } = makeHarness(100); + const { session, batches, settle } = makeHarness(100, point(0, 0)); await settle(); expect(batches).toHaveLength(1); // the navigator's own first rung - const first = session.sampleCell({ x: 2, y: 20 }, 8); - const second = session.sampleCell({ x: 1, y: 20 }, 8); + const first = session.sampleCell({ x: 2, y: 1 }, 8); + const second = session.sampleCell({ x: 1, y: 1 }, 8); await settle(); // Serialized: the second background batch waits for the first. @@ -314,38 +393,38 @@ describe("createSweepSession", () => { await expect(second).resolves.toMatchObject({ runsCompleted: 8 }); // Sampled cells are readable like navigator-visited ones. - expect(session.getCell({ x: 2, y: 20 })).toMatchObject({ + expect(session.getCell({ x: 2, y: 1 })).toMatchObject({ runsCompleted: 8, }); session.dispose(); }); it("resolves a sampled cell from cache when it is already deep enough", async () => { - const { session, batches, settle } = makeHarness(25); + const { session, batches, settle } = makeHarness(25, point(0, 0)); await settle(); batches[0]!.stream([frame(8, [[1, 8]])]); batches[0]!.complete(); await settle(); - // The navigator already took {x:0,y:10} to 8 runs; sampling it is free. - await expect(session.sampleCell({ x: 0, y: 10 }, 8)).resolves.toMatchObject( - { runsCompleted: 8 }, - ); + // The navigator already took {x:0,y:0} to 8 runs; sampling it is free. + await expect(session.sampleCell({ x: 0, y: 0 }, 8)).resolves.toMatchObject({ + runsCompleted: 8, + }); expect(batches.filter((batch) => batch.request.background).length).toBe(0); session.dispose(); }); it("exposes finished cells to other readers", async () => { - const { session, batches, settle } = makeHarness(8); + const { session, batches, settle } = makeHarness(8, point(0, 0)); await settle(); batches[0]!.stream([frame(8, [[3, 8]])]); batches[0]!.complete(); await settle(); - expect(session.getCell({ x: 0, y: 10 })).toMatchObject({ + expect(session.getCell({ x: 0, y: 0 })).toMatchObject({ runsCompleted: 8, }); - expect(session.getCell({ x: 2, y: 20 })).toBeUndefined(); + expect(session.getCell({ x: 2, y: 1 })).toBeUndefined(); session.dispose(); }); }); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.ts b/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.ts index a757db5bb8d..52aaa7fc283 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/sweep-session.ts @@ -1,58 +1,77 @@ /** * Progressive computation of one parameter-sweep experiment. * - * A sweep never computes its whole grid. It computes the combination the - * navigator points at, in batches that climb `EXPERIMENT_RUN_LADDER`, and - * restarts on the new combination the moment the selection changes — the way - * a raytracer drops its rays when the camera moves. Finished batches fold - * into a per-combination cache, so revisiting a combination resumes from its - * ladder position instead of starting over. + * A sweep never computes its whole quantized space eagerly. It computes the + * region the navigator selects — a position range per parameter, a single + * point in the degenerate case — and restarts on the new region the moment + * the selection changes, the way a raytracer drops its rays when the camera + * moves. A point refines by climbing `EXPERIMENT_RUN_LADDER`; a region + * levels its cells rung by rung, visiting them in a low-discrepancy order so + * the merged view spreads across the region early. Finished batches fold + * into a per-position cache, so revisiting a position — narrowing a range, + * collapsing to a point, or sliding back — restores everything already + * computed. * * The session is backend-agnostic: it asks an injected `instantiateBatch` for * a `MonteCarloExperiment` per batch and only consumes the handle's stores, - * so CPU worker pools and the WebGPU backend behave identically here. + * so CPU worker pools and the WebGPU backend behave identically here. Each + * batch has one concrete value per parameter, so per-batch scenario + * compilation and GPU eligibility are untouched by region selections. * - * Determinism: batch *b* covering runs `[from, target)` derives its base seed - * as `deriveRunSeed(seed, from)` (the first batch keeps `seed` verbatim). - * Every combination climbs the same ladder, so the same rung uses the same - * seeds in every combination — common random numbers across the grid — and + * Determinism: a cell's batch covering runs `[from, target)` derives its + * base seed as `deriveRunSeed(seed, from)` (the first batch keeps `seed` + * verbatim). Every cell climbs the same ladder, so the same rung uses the + * same seeds in every cell — common random numbers across the space — and * re-running a rung after a cancellation repeats it exactly. */ import { deriveRunSeed } from "@hashintel/petrinaut-core"; import { + axisValueAt, + countRegionCells, + enumerateRegionCells, + fullSweepSelection, getNextRunTarget, mergeMetricFramesAcrossCells, + normalizeSweepSelection, } from "./parameter-grid"; -import type { ExperimentParameterAxis } from "./parameter-grid"; +import type { ExperimentParameterAxis, SweepSelection } from "./parameter-grid"; import type { MonteCarloExperiment, MonteCarloUserDefinedMetricFrame, MonteCarloWorkerProgress, } from "@hashintel/petrinaut-core"; -/** Navigator position: value *index* per swept parameter identifier. */ -export type SweepSelection = Readonly>; +export type { SweepSelection } from "./parameter-grid"; -/** Finished batches of one combination, merged. */ +/** Finished batches of one cell (quantized position tuple), merged. */ export type SweepCellSnapshot = { runsCompleted: number; metricFrames: readonly MonteCarloUserDefinedMetricFrame[]; }; +/** A cached cell: its position tuple and its merged finished batches. */ +type SweepCellEntry = SweepCellSnapshot & { + position: Readonly>; +}; + /** What the session streams to its owner on every meaningful change. */ export type SweepSessionUpdate = { selection: SweepSelection; - /** Concrete swept values for `selection`, keyed by identifier. */ - parameterValues: Readonly>; - /** Cached batches plus the in-flight batch, merged. */ + /** Concrete values of the cell the in-flight batch computes, or null. */ + activeCellValues: Readonly> | null; + /** Cached batches of every cell in the region, plus the in-flight batch. */ metricFrames: readonly MonteCarloUserDefinedMetricFrame[]; /** Runs contributing to `metricFrames`, including the in-flight batch. */ runsSampled: number; - /** Runs in finished batches only. */ + /** Runs in finished batches across the region. */ runsCompleted: number; - /** Ladder target the in-flight batch climbs to; null when saturated. */ + /** 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. */ runTarget: number | null; /** Live progress of the in-flight batch; null when idle. */ progress: MonteCarloWorkerProgress | null; @@ -76,9 +95,11 @@ export type InstantiateSweepBatch = (options: { export type CreateSweepSessionOptions = { axes: readonly ExperimentParameterAxis[]; - /** Maximum runs per combination — the top of the ladder. */ + /** Maximum runs per cell — the top of the ladder. */ runCount: number; seed: number; + /** Starting selection; the whole space when omitted. */ + initialSelection?: SweepSelection; instantiateBatch: InstantiateSweepBatch; onUpdate: (update: SweepSessionUpdate) => void; /** A failed batch stops the session; the owner decides how to surface it. */ @@ -88,14 +109,14 @@ export type CreateSweepSessionOptions = { export type SweepSession = { setSelection: (selection: SweepSelection) => void; /** - * Reads a combination's finished-batch snapshot, by concrete values. + * Reads a cell's finished-batch snapshot, by quantized position. * The surface sampler uses this to reuse navigator work. */ getCell: ( - parameterValues: Readonly>, + position: Readonly>, ) => SweepCellSnapshot | undefined; /** - * Brings a combination up to at least `minRuns` finished runs, off the + * Brings a cell up to at least `minRuns` finished runs, off the * navigator's lane — the surface view samples its grid through this. * * Background batches run one at a time on a single worker, so the @@ -106,36 +127,47 @@ export type SweepSession = { * run sequence. Resolves null when the session is disposed first. */ sampleCell: ( - parameterValues: Readonly>, + position: Readonly>, minRuns: number, ) => Promise; dispose: () => void; }; -/** Canonical cache key for a combination: values in axis order. */ +/** Canonical cache key for a cell: quantized positions in axis order. */ export function sweepCellKey( axes: readonly ExperimentParameterAxis[], - parameterValues: Readonly>, + position: Readonly>, ): string { return axes - .map((axis) => `${axis.identifier}=${parameterValues[axis.identifier]}`) + .map((axis) => `${axis.identifier}=${position[axis.identifier] ?? 0}`) .join("|"); } -/** Concrete values for a selection of value indices. */ -export function sweepSelectionValues( +/** Concrete parameter values of a cell's position tuple. */ +export function sweepCellValues( axes: readonly ExperimentParameterAxis[], - selection: SweepSelection, + position: Readonly>, ): Record { const values: Record = {}; for (const axis of axes) { - const index = selection[axis.identifier] ?? 0; - values[axis.identifier] = - axis.values[Math.min(Math.max(index, 0), axis.values.length - 1)]!; + values[axis.identifier] = axisValueAt(axis, position[axis.identifier] ?? 0); } return values; } +/** Whether `position` lies inside the selected region. */ +function cellInRegion( + axes: readonly ExperimentParameterAxis[], + selection: SweepSelection, + position: Readonly>, +): boolean { + return axes.every((axis) => { + const range = selection[axis.identifier] ?? { from: 0, to: axis.stepCount }; + const cellPosition = position[axis.identifier] ?? 0; + return cellPosition >= range.from && cellPosition <= range.to; + }); +} + /** The base seed of the batch whose first run has global index `from`. */ export function sweepBatchSeed(seed: number, from: number): number { return from === 0 ? seed : deriveRunSeed(seed, from); @@ -146,9 +178,10 @@ export function createSweepSession( ): SweepSession { const { axes, runCount, seed, instantiateBatch, onUpdate, onError } = options; - const cells = new Map(); - let selection: SweepSelection = Object.fromEntries( - axes.map((axis) => [axis.identifier, 0]), + const cells = new Map(); + let selection: SweepSelection = normalizeSweepSelection( + axes, + options.initialSelection ?? fullSweepSelection(axes), ); let disposed = false; let failed = false; @@ -156,11 +189,29 @@ export function createSweepSession( let generation = 0; let abortCurrent: (() => void) | null = null; - const snapshotFor = (key: string): SweepCellSnapshot => - cells.get(key) ?? { runsCompleted: 0, metricFrames: [] }; + const snapshotFor = ( + position: Readonly>, + ): SweepCellSnapshot => + cells.get(sweepCellKey(axes, position)) ?? { + runsCompleted: 0, + metricFrames: [], + }; + + const foldCell = ( + position: Readonly>, + snapshot: SweepCellSnapshot, + ) => { + cells.set(sweepCellKey(axes, position), { ...snapshot, position }); + }; + + /** Cached cells inside the current region. */ + const regionEntries = (): SweepCellEntry[] => + [...cells.values()].filter((entry) => + cellInRegion(axes, selection, entry.position), + ); const publish = (update: { - values: Record; + activeCell: Readonly> | null; inFlightFrames?: readonly MonteCarloUserDefinedMetricFrame[]; inFlightRuns?: number; runTarget: number | null; @@ -170,17 +221,27 @@ export function createSweepSession( if (disposed) { return; } - const snapshot = snapshotFor(sweepCellKey(axes, update.values)); + const entries = regionEntries(); const inFlight = update.inFlightFrames ?? []; + const frameSets = entries.map((entry) => entry.metricFrames); + if (inFlight.length > 0) { + frameSets.push(inFlight); + } onUpdate({ selection, - parameterValues: update.values, - metricFrames: - inFlight.length > 0 - ? mergeMetricFramesAcrossCells([snapshot.metricFrames, inFlight]) - : snapshot.metricFrames, - runsSampled: snapshot.runsCompleted + (update.inFlightRuns ?? 0), - runsCompleted: snapshot.runsCompleted, + activeCellValues: update.activeCell + ? sweepCellValues(axes, update.activeCell) + : null, + metricFrames: mergeMetricFramesAcrossCells(frameSets), + runsSampled: + entries.reduce((sum, entry) => sum + entry.runsCompleted, 0) + + (update.inFlightRuns ?? 0), + runsCompleted: entries.reduce( + (sum, entry) => sum + entry.runsCompleted, + 0, + ), + cellsSampled: entries.filter((entry) => entry.runsCompleted > 0).length, + cellsInRegion: countRegionCells(axes, selection), runTarget: update.runTarget, progress: update.progress ?? null, computing: update.computing, @@ -203,11 +264,11 @@ export function createSweepSession( */ const executeBatch = async ( loopGeneration: number, - values: Record, - key: string, + position: Readonly>, snapshot: SweepCellSnapshot, target: number, ): Promise<"continue" | "stop"> => { + const values = sweepCellValues(axes, position); const abortController = new AbortController(); // Until the handle exists, aborting the controller is all a restart can // do; instantiation rejects with AbortError and the stale loop exits. @@ -231,7 +292,7 @@ export function createSweepSession( onError( error instanceof Error ? error.message : "Failed to start a batch", ); - publish({ values, runTarget: target, computing: false }); + publish({ activeCell: position, runTarget: target, computing: false }); return "stop"; } @@ -253,7 +314,7 @@ export function createSweepSession( return; } publish({ - values, + activeCell: position, inFlightFrames: handle.metrics.get().frames, inFlightRuns: handle.progress.get()?.completedRuns ?? 0, runTarget: target, @@ -291,7 +352,7 @@ export function createSweepSession( if (failed && !isStale(loopGeneration)) { // The session idles after a failure; leave the last good frames up // rather than a spinner that will never finish. - publish({ values, runTarget: null, computing: false }); + publish({ activeCell: position, runTarget: null, computing: false }); } const finishedFrames = handle.metrics.get().frames; handle.dispose(); @@ -300,7 +361,7 @@ export function createSweepSession( if (outcome === "complete") { // Fold the batch into the cache even when the user has moved on — // completed rays are never thrown away. - cells.set(key, { + foldCell(position, { runsCompleted: target, metricFrames: mergeMetricFramesAcrossCells([ snapshot.metricFrames, @@ -313,31 +374,49 @@ export function createSweepSession( return "stop"; }; + /** + * Levels the region rung by rung: every cell reaches a ladder target + * before any cell climbs past it, and cells are visited in the enumerator's + * low-discrepancy order so early coverage spreads across the region. For a + * point selection this degenerates to climbing the ladder on one cell. + */ const refineLoop = async (loopGeneration: number): Promise => { - while (!isStale(loopGeneration) && !failed) { - const values = sweepSelectionValues(axes, selection); - const key = sweepCellKey(axes, values); - const snapshot = snapshotFor(key); - const target = getNextRunTarget(snapshot.runsCompleted, runCount); - - if (target === null) { - publish({ values, runTarget: null, computing: false }); - return; + for (;;) { + let advanced = false; + + for (const position of enumerateRegionCells(axes, selection)) { + if (isStale(loopGeneration) || failed) { + return; + } + const snapshot = snapshotFor(position); + const target = getNextRunTarget(snapshot.runsCompleted, runCount); + if (target === null) { + continue; + } + // One rung per cell per pass: the pass takes every cell up one + // ladder step before any cell climbs further, so a broad region + // shows broad coverage before depth. + publish({ activeCell: position, runTarget: target, computing: true }); + const outcome = await executeBatch( + loopGeneration, + position, + snapshot, + target, + ); + if (outcome === "stop") { + return; + } + advanced = true; } - publish({ values, runTarget: target, computing: true }); - - const outcome = await executeBatch( - loopGeneration, - values, - key, - snapshot, - target, - ); - if (outcome === "stop") { + if (isStale(loopGeneration) || failed) { + return; + } + if (!advanced) { + // Every cell in the region is saturated. + publish({ activeCell: null, runTarget: null, computing: false }); return; } - // Loop: next ladder rung for the (possibly unchanged) selection. } }; @@ -354,14 +433,13 @@ export function createSweepSession( let backgroundChain: Promise = Promise.resolve(); const runBackgroundBatch = async ( - values: Record, + position: Readonly>, minRuns: number, ): Promise => { if (disposed || failed) { return null; } - const key = sweepCellKey(axes, values); - const snapshot = snapshotFor(key); + const snapshot = snapshotFor(position); const target = Math.min(minRuns, runCount); if (snapshot.runsCompleted >= target) { return snapshot; @@ -371,7 +449,7 @@ export function createSweepSession( let handle: MonteCarloExperiment; try { handle = await instantiateBatch({ - parameterValues: values, + parameterValues: sweepCellValues(axes, position), seed: sweepBatchSeed(seed, snapshot.runsCompleted), runCount: target - snapshot.runsCompleted, background: true, @@ -401,19 +479,18 @@ export function createSweepSession( if (!completed || isDisposed()) { return null; } - const merged: SweepCellSnapshot = { - runsCompleted: target, - metricFrames: mergeMetricFramesAcrossCells([ - snapshot.metricFrames, - frames, - ]), - }; // The navigator may have refined this cell further while we sampled; the // deeper snapshot wins. - if (snapshotFor(key).runsCompleted < target) { - cells.set(key, merged); + if (snapshotFor(position).runsCompleted < target) { + foldCell(position, { + runsCompleted: target, + metricFrames: mergeMetricFramesAcrossCells([ + snapshot.metricFrames, + frames, + ]), + }); } - return snapshotFor(key); + return snapshotFor(position); }; return { @@ -421,23 +498,24 @@ export function createSweepSession( if (disposed) { return; } - const changed = axes.some( - (axis) => (next[axis.identifier] ?? 0) !== selection[axis.identifier], - ); + const normalized = normalizeSweepSelection(axes, next); + const changed = axes.some((axis) => { + const current = selection[axis.identifier]!; + const incoming = normalized[axis.identifier]!; + return current.from !== incoming.from || current.to !== incoming.to; + }); if (!changed) { return; } - selection = Object.fromEntries( - axes.map((axis) => [axis.identifier, next[axis.identifier] ?? 0]), - ); + selection = normalized; restart(); }, - getCell(parameterValues) { - return cells.get(sweepCellKey(axes, parameterValues)); + getCell(position) { + return cells.get(sweepCellKey(axes, position)); }, - sampleCell(parameterValues, minRuns) { + sampleCell(position, minRuns) { const next = backgroundChain.then(() => - runBackgroundBatch({ ...parameterValues }, minRuns), + runBackgroundBatch({ ...position }, minRuns), ); backgroundChain = next.catch(() => null); return next; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx index 267032e531f..f0ee6a64a43 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx @@ -26,10 +26,7 @@ import { type ExperimentMetricSpecInput, } from "../../../../../../react/experiments/context"; import { - buildParameterRangeValues, - countGridCombinations, - MAX_EXPERIMENT_COMBINATIONS, - WARN_EXPERIMENT_COMBINATIONS, + buildParameterAxis, type ExperimentParameterAxis, type ExperimentParameterInput, type ExperimentParameterRangeInput, @@ -184,10 +181,6 @@ const paramRangeStyle = css({ "& > *": { flex: "1", minWidth: "[0]" }, }); -const paramRangeCountStyle = css({ - flex: "[0 0 84px]", -}); - const sweepSummaryStyle = css({ fontSize: "xs", color: "neutral.s80", @@ -573,27 +566,20 @@ function buildMetricSpecs( // -- Component ---------------------------------------------------------------- -const DEFAULT_RANGE_VALUE_COUNT = 5; - -/** The range a parameter starts sweeping with: around its default. */ +/** The interval a parameter starts sweeping with: around its default. */ function initialRangeFor( param: ScenarioParameter, ): ExperimentParameterRangeInput { const base = typeof param.default === "number" ? param.default : 0; if (param.type === "ratio") { - return { - mode: "range", - min: 0, - max: 1, - valueCount: DEFAULT_RANGE_VALUE_COUNT, - }; + return { mode: "range", min: 0, max: 1 }; } const spread = Math.max(Math.abs(base), 1); const min = param.type === "integer" ? Math.round(base - spread) : base - spread; const max = param.type === "integer" ? Math.round(base + spread) : base + spread; - return { mode: "range", min, max, valueCount: DEFAULT_RANGE_VALUE_COUNT }; + return { mode: "range", min, max }; } const ScenarioParameterRow = ({ @@ -624,17 +610,6 @@ const ScenarioParameterRow = ({ value={Number.isFinite(value.max) ? value.max : null} onChange={(max) => onChange({ ...value, max: max ?? Number.NaN })} /> -
- - onChange({ ...value, valueCount: valueCount ?? 0 }) - } - /> -
) : ( MAX_EXPERIMENT_COMBINATIONS) { - return { - text: `${combinations} combinations — the maximum is ${MAX_EXPERIMENT_COMBINATIONS}`, - tone: "error", - error: true, - }; - } + const names = axes.map((axis) => axis.identifier).join(", "); return { - text: `${combinations} combinations × ${runCount === "" ? "?" : runCount} runs each, computed one combination at a time${ - combinations > WARN_EXPERIMENT_COMBINATIONS - ? " — a large grid takes a while to explore" - : "" - }`, - tone: combinations > WARN_EXPERIMENT_COMBINATIONS ? "warning" : "neutral", + text: `${axes.length === 1 ? `${names} swept over its interval` : `${names} swept over their intervals`} — the whole selection computes progressively, and the navigator narrows it to regions or points`, + tone: "neutral", error: false, }; })(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx index c02fc54e8d5..a45339feffb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx @@ -158,17 +158,31 @@ export function makeParameterSweepExperiment(): ExperimentRecord { }, ], parameterAxes: [ - { identifier: "transmission_rate", values: [0.1, 0.2, 0.3, 0.4, 0.5] }, + { + identifier: "transmission_rate", + min: 0.1, + max: 0.5, + stepCount: 50, + integer: false, + }, { identifier: "recovery_days", - values: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], + min: 2, + max: 20, + stepCount: 18, + integer: true, }, ], sweep: { - selection: { transmission_rate: 2, recovery_days: 3 }, - parameterValues: { transmission_rate: 0.3, recovery_days: 8 }, + selection: { + transmission_rate: { from: 25, to: 25 }, + recovery_days: { from: 6, to: 6 }, + }, + activeCellValues: { transmission_rate: 0.3, recovery_days: 8 }, runsCompleted: 25, runsSampled: 61, + cellsSampled: 1, + cellsInRegion: 1, runTarget: 100, computing: true, }, @@ -299,12 +313,12 @@ export function FakeExperimentsProvider({ ), ); }, - sampleSweepCell: (_experimentId, parameterValues) => { + sampleSweepCell: (_experimentId, position) => { // A synthetic objective surface — a smooth bump — so the story's // contour fills in the way a real sweep's would, walk delay included. - const coordinates = Object.values(parameterValues); - const x = coordinates[0] ?? 0; - const y = coordinates[1] ?? 0; + // Positions are quantized indices; map them back to values. + const x = 0.1 + ((position.transmission_rate ?? 0) / 50) * 0.4; + const y = 2 + (position.recovery_days ?? 0); const objective = 100 * Math.exp(-((x - 0.35) ** 2) * 20 - ((y - 10) / 14) ** 2) + 6 * Math.sin(x * 9) + diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx index 0b007784d87..4d9e4df6565 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx @@ -1,32 +1,36 @@ /** - * The parameter navigator of a sweep experiment: one control row per swept - * parameter, plus a refinement status line. Moving any control redirects - * compute to the newly selected combination; the metrics below re-stream for - * it, so this strip lives in the section's sticky band and stays visible - * while the charts scroll. + * The parameter navigator of a sweep experiment: one slider row per swept + * parameter, plus a refinement status line. Each slider selects a position + * range on the parameter's quantized interval — the whole interval by + * default, collapsible to a single point — and moving it redirects compute to + * the newly selected region; the metrics below re-stream for it, so this + * strip lives in the section's sticky band and stays visible while the + * charts scroll. * - * Axes with few values render every value as a segmented control (one click - * to jump anywhere); longer axes step through values with prev/next. + * Slider moves commit on release: dragging previews the range locally and + * `setSweepSelection` fires once, so the session cancels at most one batch + * per gesture rather than one per pixel. */ -import { use } from "react"; +import { use, useState } from "react"; import { - Button, LoadingSpinner, + RangeSlider, SegmentedControl, } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { ExperimentsContext } from "../../../../../../react/experiments/context"; +import { axisValueAt } from "../../../../../../react/experiments/parameter-grid"; import type { ExperimentRecord, ExperimentSweepState, } from "../../../../../../react/experiments/context"; -import type { ExperimentParameterAxis } from "../../../../../../react/experiments/parameter-grid"; - -/** Above this many values, a segmented control becomes a stepper. */ -const SEGMENTED_VALUE_LIMIT = 6; +import type { + ExperimentParameterAxis, + SweepAxisSelection, +} from "../../../../../../react/experiments/parameter-grid"; const navigatorStyle = css({ display: "flex", @@ -51,24 +55,13 @@ const nameStyle = css({ whiteSpace: "nowrap", }); -const stepperStyle = css({ - display: "flex", - alignItems: "center", - gap: "1", -}); - -const stepperValueStyle = css({ +const readoutStyle = css({ fontSize: "xs", fontVariantNumeric: "tabular-nums", color: "neutral.s100", - minWidth: "[96px]", - textAlign: "center", -}); - -const stepperPositionStyle = css({ - fontSize: "[10px]", - color: "neutral.s80", - fontVariantNumeric: "tabular-nums", + width: "[128px]", + flexShrink: 0, + textAlign: "right", }); const statusStyle = css({ @@ -87,60 +80,75 @@ function formatAxisValue(value: number): string { const abs = Math.abs(value); return abs !== 0 && (abs < 0.001 || abs >= 10_000) ? value.toExponential(2) - : String(Number(value.toPrecision(6))); + : String(Number(value.toPrecision(4))); } const AxisControl = ({ axis, - selectedIndex, + selected, onSelect, }: { axis: ExperimentParameterAxis; - selectedIndex: number; - onSelect: (index: number) => void; + selected: SweepAxisSelection; + onSelect: (range: SweepAxisSelection) => void; }) => { - if (axis.values.length <= SEGMENTED_VALUE_LIMIT) { - return ( - ({ - value: String(index), - label: formatAxisValue(value), - }))} - value={String(selectedIndex)} - onChange={(next) => onSelect(Number(next))} - /> - ); - } + /** Range being dragged; null when the slider mirrors the committed state. */ + const [draft, setDraft] = useState<[number, number] | null>(null); + + const isPoint = selected.from === selected.to; + const shown = draft ?? [selected.from, selected.to]; + + const commit = (range: [number, number]) => { + setDraft(null); + onSelect({ from: range[0], to: range[1] }); + }; + + /** In point mode both thumbs coincide; the moved end is the new point. */ + const pointAt = (range: [number, number]): number => + range[0] !== selected.from ? range[0] : range[1]; return ( -
-
+ ); }; @@ -150,24 +158,31 @@ const RefinementStatus = ({ }: { sweep: ExperimentSweepState; runCount: number; -}) => ( -
- {sweep.computing ? ( - <> - +}) => { + const region = + sweep.cellsInRegion > 1 + ? `${sweep.cellsSampled} of ${sweep.cellsInRegion} cells · ${sweep.runsCompleted} runs` + : `${sweep.runsSampled} of ${sweep.runTarget ?? runCount} runs`; + + return ( +
+ {sweep.computing ? ( + <> + + {region} — refining while you stay here + + ) : ( - {sweep.runsSampled} of {sweep.runTarget ?? runCount} runs — refining - while you stay here + {sweep.cellsInRegion > 1 + ? region + : `${sweep.runsCompleted} of ${runCount} runs${ + sweep.runsCompleted >= runCount ? " — fully sampled" : "" + }`} - - ) : ( - - {sweep.runsCompleted} of {runCount} runs - {sweep.runsCompleted >= runCount ? " — fully sampled" : ""} - - )} -
-); + )} +
+ ); +}; export const SweepNavigator = ({ experiment, @@ -189,11 +204,16 @@ export const SweepNavigator = ({ + selected={ + sweep.selection[axis.identifier] ?? { + from: 0, + to: axis.stepCount, + } + } + onSelect={(range) => setSweepSelection(experiment.id, { ...sweep.selection, - [axis.identifier]: index, + [axis.identifier]: range, }) } /> diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx index 071b971b08f..e547777ca47 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx @@ -34,6 +34,22 @@ import type { ExperimentParameterAxis } from "../../../../../../react/experiment /** Runs a surface cell needs before its point appears. */ const SURFACE_CELL_RUNS = 8; +/** + * Sampled positions per axis on the surface: a sub-grid of the slider's + * quantization, coarse enough that a full X×Y sweep at `SURFACE_CELL_RUNS` + * stays affordable while the picture keeps filling in. + */ +const SURFACE_GRID_POSITIONS = 11; + +/** Evenly spread quantized positions of `axis` shown on the surface. */ +function surfacePositions(axis: ExperimentParameterAxis): number[] { + const count = Math.min(SURFACE_GRID_POSITIONS, axis.stepCount + 1); + const positions = Array.from({ length: count }, (_, index) => + Math.round((index * axis.stepCount) / (count - 1)), + ); + return [...new Set(positions)]; +} + /** Raster resolution per grid cell, in pixels of interpolation lattice. */ const RASTER_SUBDIVISION = 8; @@ -88,18 +104,22 @@ function surfaceCellKey(xIndex: number, yIndex: number): string { return `${xIndex},${yIndex}`; } -/** The navigator's values for every axis not shown on the surface. */ -function fixedValuesKey( +/** + * The navigator's position for every axis not shown on the surface: the + * middle of its selected range (the range itself when it is a point). + */ +function fixedPositionsKey( experiment: ExperimentRecord, xAxis: string, yAxis: string, ): string { return experiment.parameterAxes .filter((axis) => axis.identifier !== xAxis && axis.identifier !== yAxis) - .map( - (axis) => - `${axis.identifier}=${experiment.sweep?.selection[axis.identifier] ?? 0}`, - ) + .map((axis) => { + const range = experiment.sweep?.selection[axis.identifier]; + const position = range ? Math.round((range.from + range.to) / 2) : 0; + return `${axis.identifier}=${position}`; + }) .join("|"); } @@ -107,11 +127,11 @@ function drawSurface(options: { canvas: HTMLCanvasElement; width: number; height: number; - xAxis: ExperimentParameterAxis; - yAxis: ExperimentParameterAxis; + nx: number; + ny: number; values: SurfaceValues; }): void { - const { canvas, width, height, xAxis, yAxis, values } = options; + const { canvas, width, height, nx, ny, values } = options; const pixelRatio = globalThis.devicePixelRatio || 1; canvas.width = Math.max(1, Math.round(width * pixelRatio)); canvas.height = Math.max(1, Math.round(height * pixelRatio)); @@ -131,8 +151,6 @@ function drawSurface(options: { return; } - const nx = xAxis.values.length; - const ny = yAxis.values.length; const rasterWidth = Math.max(2, (nx - 1) * RASTER_SUBDIVISION + 1); const rasterHeight = Math.max(2, (ny - 1) * RASTER_SUBDIVISION + 1); const raster = idwRaster({ @@ -220,7 +238,7 @@ export const SweepSurface = ({ const xAxis = axes.find((axis) => axis.identifier === xAxisId); const yAxis = axes.find((axis) => axis.identifier === yAxisId); - const slice = fixedValuesKey(experiment, xAxisId, yAxisId); + const slice = fixedPositionsKey(experiment, xAxisId, yAxisId); const experimentId = experiment.id; const sweepSelection = experiment.sweep?.selection; @@ -252,28 +270,27 @@ export const SweepSurface = ({ ), ); + const xPositions = surfacePositions(xAxis); + const yPositions = surfacePositions(yAxis); const run = async () => { for (const cell of coarseToFineOrder( - xAxis.values.length, - yAxis.values.length, + xPositions.length, + yPositions.length, )) { if (isWalkStale()) { return; } - const parameterValues: Record = { - [xAxis.identifier]: xAxis.values[cell.x]!, - [yAxis.identifier]: yAxis.values[cell.y]!, + const position: Record = { + [xAxis.identifier]: xPositions[cell.x]!, + [yAxis.identifier]: yPositions[cell.y]!, }; - for (const [identifier, indexText] of fixedEntries) { - const axis = axes.find((it) => it.identifier === identifier); - if (axis) { - parameterValues[identifier] = axis.values[Number(indexText)]!; - } + for (const [identifier, positionText] of fixedEntries) { + position[identifier] = Number(positionText); } const snapshot = await sampleSweepCell( experimentId, - parameterValues, + position, SURFACE_CELL_RUNS, ); if (isWalkStale()) { @@ -309,8 +326,8 @@ export const SweepSurface = ({ canvas, width: size.width, height: 280, - xAxis, - yAxis, + nx: surfacePositions(xAxis).length, + ny: surfacePositions(yAxis).length, values: cellValues, }); }, [cellValues, size, xAxis, yAxis]); @@ -335,23 +352,22 @@ export const SweepSurface = ({ const bounds = event.currentTarget.getBoundingClientRect(); const relativeX = (event.clientX - bounds.left) / bounds.width; const relativeY = 1 - (event.clientY - bounds.top) / bounds.height; - const xIndex = Math.round(relativeX * (xAxis.values.length - 1)); - const yIndex = Math.round(relativeY * (yAxis.values.length - 1)); + const clamp = (fraction: number) => Math.min(Math.max(fraction, 0), 1); + // Clicking collapses both shown axes to a point at the clicked position. + const xPosition = Math.round(clamp(relativeX) * xAxis.stepCount); + const yPosition = Math.round(clamp(relativeY) * yAxis.stepCount); setSweepSelection(experimentId, { ...sweepSelection, - [xAxis.identifier]: Math.min( - Math.max(xIndex, 0), - xAxis.values.length - 1, - ), - [yAxis.identifier]: Math.min( - Math.max(yIndex, 0), - yAxis.values.length - 1, - ), + [xAxis.identifier]: { from: xPosition, to: xPosition }, + [yAxis.identifier]: { from: yPosition, to: yPosition }, }); }; const sampledCount = cellValues.size; - const totalCells = (xAxis?.values.length ?? 0) * (yAxis?.values.length ?? 0); + const totalCells = + xAxis && yAxis + ? surfacePositions(xAxis).length * surfacePositions(yAxis).length + : 0; return (
diff --git a/libs/@local/petrinaut-arch-docs/content/diagrams/sweep-refinement.d2 b/libs/@local/petrinaut-arch-docs/content/diagrams/sweep-refinement.d2 index b3deb52e893..2d6b96ca962 100644 --- a/libs/@local/petrinaut-arch-docs/content/diagrams/sweep-refinement.d2 +++ b/libs/@local/petrinaut-arch-docs/content/diagrams/sweep-refinement.d2 @@ -1,15 +1,15 @@ # Hand-written; rendered by the arch-docs build. Palette matches src/emit/d2.ts. direction: right -navigator: "sweep navigator\n(sticky parameter strip)" {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} -session: "sweep session\n(one live combination)" {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} -cache: "combination cache\n(finished batches,\nmerged frames)" {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} +navigator: "sweep navigator\n(range slider per\nparameter, sticky)" {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} +session: "sweep session\n(selected region:\nposition ranges)" {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} +cache: "cell cache\n(finished batches\nper position)" {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} backend: "chosen backend\n(CPU worker pool\nor WebGPU)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} charts: "metric charts\n(stream while batches run)" {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} -navigator -> session: selection change\n(cancel + restart) {style.stroke-dash: 4} -session -> backend: "batch: runs\n[completed, next rung)" +navigator -> session: "selection change\n(cancel + restart)" {style.stroke-dash: 4} +session -> backend: "one cell's batch: runs\n[completed, next rung)" backend -> session: frames stream session -> cache: fold finished batch -cache -> session: resume mid-ladder\non revisit {style.stroke-dash: 4} -session -> charts: cached + in-flight\nframes, merged +cache -> session: "resume mid-ladder\non revisit" {style.stroke-dash: 4} +session -> charts: "region's cached +\nin-flight frames, merged" diff --git a/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx b/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx index 12384a00c72..ca07bbf273a 100644 --- a/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx +++ b/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx @@ -1,51 +1,68 @@ --- title: Parameter sweeps -description: Why a sweep computes one combination at a time, and how the refinement ladder, the cache, and common random numbers fit together. +description: Why a sweep computes only the selected region, and how quantized intervals, the refinement ladder, the cache, and common random numbers fit together. sidebar_order: 20 attachTo: react.experiments --- -A sweep experiment defines a grid — evenly spaced values per swept scenario -parameter, capped at 200 combinations — but never computes the grid. It -computes the combination the navigator points at, and moves compute the moment -the navigator does. The model is a raytracer's: rays for the current camera, -dropped on movement, never precomputed for cameras nobody is looking through. +A sweep experiment declares an interval per swept scenario parameter — nothing +else. Each interval quantizes into ~50 positions (`SWEEP_AXIS_STEPS`, +`parameter-grid.ts`; integer parameters step by whole numbers), and the +navigator selects an inclusive position range per parameter: the whole +interval by default, collapsible to a single point. Only the selected region +computes, and compute moves the moment the selection does. The model is a +raytracer's: rays for the current camera, dropped on movement, never +precomputed for cameras nobody is looking through. -![One combination refining](@diagrams/sweep-refinement.svg) +![The selected region refining](@diagrams/sweep-refinement.svg) -## The refinement ladder +## Cells, regions, and the refinement ladder -Runs for a combination accumulate in escalating batches — 8, 25, 100, 500, -1000, then ×5/×2 — capped by the experiment's per-combination run budget -(`parameter-grid.ts`). The first rung is small so a fresh selection paints in -under a second; each later rung sharpens the same distributions. A batch that -finishes folds into the combination's cache entry; a batch cancelled by a -selection change is discarded whole. Revisiting a combination resumes from its -cached rung rather than starting over. +A **cell** is one position tuple; every batch runs one cell, so a batch has +one concrete value per parameter — scenario compilation stays per-batch and +GPU eligibility is untouched by range selections. A **region** (the selected +ranges' product) levels its cells rung by rung up the ladder — 8, 25, 100, +500, 1000, then ×5/×2, capped by the per-cell run budget — visiting cells in +a deterministic low-discrepancy order (`enumerateRegionCells`, Halton bases +per axis), so the merged view spreads across the region after a handful of +batches instead of filling corner-first. A point selection is a one-cell +region: it degenerates to climbing the ladder on that cell. + +The drawer shows the **merge** of every cached cell in the region plus the +in-flight batch (`mergeMetricFramesAcrossCells`): distribution bins add, +scalar frames recombine through their run-aggregate monoid. A batch that +finishes folds into its cell's cache entry; a batch cancelled by a selection +change is discarded whole. Because cells are keyed by quantized position, +narrowing a range, collapsing to a point, or revisiting an earlier position +restores its cached runs instantly and refinement resumes from the cached +rung. ## Determinism -Batch _b_ covering runs `[from, target)` seeds itself with +A cell's batch covering runs `[from, target)` seeds itself with `deriveRunSeed(seed, from)` (the first batch keeps the base seed verbatim). -The ladder is the same for every combination, so the same rung draws the same -seeds everywhere — common random numbers across the grid — and a re-run of a +The ladder is the same for every cell, so the same rung draws the same seeds +everywhere — common random numbers across the space — and a re-run of a cancelled rung repeats it exactly. Cached results never go stale: the same -combination always means the same runs. +position always means the same runs. ## Where the pieces live `createSweepSession` (`react/experiments/sweep-session.ts`) owns the loop: -selection, ladder, cache, cancellation. It is backend-agnostic — it consumes -an injected `instantiateBatch` returning a `MonteCarloExperiment`, so the CPU -worker pool and the WebGPU backend behave identically. The provider wires that -seam: a sweep's first batch runs the same +selection, region enumeration, ladder, cache, cancellation. It is +backend-agnostic — it consumes an injected `instantiateBatch` returning a +`MonteCarloExperiment`, so the CPU worker pool and the WebGPU backend behave +identically. The provider wires that seam: a sweep's first batch runs the same [backend-selection walk](doc:experiments/backend-selection) as a plain experiment, and later batches re-assess the chosen backend with each batch's request — which is where the GPU backend regenerates its shader for the new parameter values. Scenario compilation also happens per batch, because a -combination's swept values change the compiled initial state. +cell's swept values change the compiled initial state. -The navigator (`sweep-navigator.tsx`) renders in the drawer section's sticky -band, so the controls stay pinned while the charts stream beneath them. Its -updates go through one context call, `setSweepSelection`; everything else the -UI shows is plain record state the session streams out. +The navigator (`sweep-navigator.tsx`) renders one range slider per parameter +in the drawer section's sticky band, so the controls stay pinned while the +charts stream beneath them. Sliders commit on release — dragging previews +locally — and a Range/Point control collapses a slider to a single value or +expands it back to the whole interval. Updates go through one context call, +`setSweepSelection`; everything else the UI shows is plain record state the +session streams out.