Skip to content

Commit e367010

Browse files
authored
FE-1536: Contour surface for optimization studies with local recompute (#9398)
1 parent d40b283 commit e367010

12 files changed

Lines changed: 1316 additions & 1 deletion

File tree

.changeset/optimization-surface.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hashintel/petrinaut": patch
3+
"@hashintel/ds-components": patch
4+
---
5+
6+
Optimization studies with two or more optimized numeric parameters gain a Surface section: a contour of the objective over two chosen parameters, computed locally against the study's frozen model, with the study's trials as markers. Sliders and clicks move the selected point, which refines with escalating batches. `Slider` accepts `step` and `onChangeEnd`.

libs/@hashintel/ds-components/src/components/Slider/slider.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,33 @@ export interface SliderProps {
2323
style?: React.CSSProperties;
2424
min?: number;
2525
max?: number;
26+
step?: number;
2627
value?: number;
2728
defaultValue?: number;
2829
label?: string;
2930
showValueText?: boolean;
3031
onChange?: (value: number) => void;
32+
/** Fires once when a drag or keyboard interaction settles. */
33+
onChangeEnd?: (value: number) => void;
3134
}
3235

3336
export const Slider: React.FC<SliderProps> = ({
3437
className,
3538
style,
3639
min,
3740
max,
41+
step,
3842
value,
3943
defaultValue,
4044
label,
4145
showValueText = false,
4246
onChange,
47+
onChangeEnd,
4348
}) => {
4449
return (
4550
<BaseSlider.Root
4651
min={min}
52+
step={step}
4753
className={cx(
4854
css({
4955
position: "relative",
@@ -64,6 +70,12 @@ export const Slider: React.FC<SliderProps> = ({
6470
onChange?.(newValue);
6571
}
6672
}}
73+
onValueChangeEnd={(details) => {
74+
const newValue = details.value[0];
75+
if (newValue !== undefined) {
76+
onChangeEnd?.(newValue);
77+
}
78+
}}
6779
>
6880
{label && (
6981
<BaseSlider.Label

libs/@hashintel/petrinaut/docs/optimization.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,25 @@ connection reports how many of the requested trials had completed and includes
8888
a diagnostic identifier for support. Trials received before the failure are
8989
kept, and a **Retry** action starts a fresh run with the same settings.
9090

91+
## The surface view
92+
93+
A study with two or more optimized numeric parameters grows a **Surface**
94+
section at the bottom of its drawer: an Optuna-style contour of the objective
95+
over two parameters you pick. The study's own trials appear as rings (the best
96+
trial highlighted), and the filled contour comes from points **computed
97+
locally on your machine** — the study's model snapshot runs on a background
98+
worker, a few runs per point, and the plot fills in coarse shape first.
99+
100+
One slider per optimized parameter navigates the space; parameters not shown
101+
on the plot hold at their slider position, which starts at the best trial's
102+
value. Move a slider or **click the plot** and the selected point recomputes
103+
with escalating batches while the readout streams the objective's mean and
104+
median. Points you have visited are cached, so returning to them is instant.
105+
106+
Log-scale domains slide in log space, and integer domains snap to their step.
107+
Local points always reflect the model as it was when the study launched, even
108+
if you have edited the net since.
109+
91110
## Connection drops and reloads
92111

93112
An optimization runs on the server, not in your browser tab. If the connection

libs/@hashintel/petrinaut/src/react/experiments/context.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
import type { SweepCellSnapshot, SweepSelection } from "./sweep-session";
88
import type {
99
AdHocScenarioState,
10+
SDCPN,
1011
MonteCarloExpressionMetricSpec,
1112
MonteCarloMetricSpec,
1213
MonteCarloUserDefinedMetricFrame,
@@ -201,9 +202,37 @@ export type ExperimentsContextValue = {
201202
*/
202203
sampleSweepCell: (
203204
experimentId: string,
204-
parameterValues: Readonly<Record<string, number>>,
205+
position: Readonly<Record<string, number>>,
205206
minRuns: number,
206207
) => Promise<SweepCellSnapshot | null>;
208+
/**
209+
* Computes one metric sample against an arbitrary net snapshot, on the
210+
* background single-worker lane — the optimization surface's local compute
211+
* path, which must run a study's frozen model rather than the live editor
212+
* net. Batches are serialized; compilation is cached per `cacheKey`.
213+
* Resolves null when the batch is refused or fails (a hole in the surface,
214+
* not an error).
215+
*/
216+
sampleDetachedObjective: (
217+
request: DetachedObjectiveRequest,
218+
) => Promise<SweepCellSnapshot | null>;
219+
};
220+
221+
/** One local compute batch for an optimization study's objective. */
222+
export type DetachedObjectiveRequest = {
223+
/** Compile-cache identity; one study keeps one compiled snapshot. */
224+
cacheKey: string;
225+
/** The frozen model snapshot to run (not the live editor net). */
226+
definition: SDCPN;
227+
scenarioId: string;
228+
/** Parsed values for every scenario parameter (bindings plus navigation). */
229+
scenarioParameterValues: Readonly<Record<string, number | boolean>>;
230+
/** The study's objective metric, evaluated as an expression metric. */
231+
metric: { id: string; label: string; code: string };
232+
seed: number;
233+
runCount: number;
234+
dt: number;
235+
maxTime: number;
207236
};
208237

209238
const DEFAULT_CONTEXT_VALUE: ExperimentsContextValue = {
@@ -216,6 +245,7 @@ const DEFAULT_CONTEXT_VALUE: ExperimentsContextValue = {
216245
removeExperiment: () => {},
217246
setSweepSelection: () => {},
218247
sampleSweepCell: () => Promise.resolve(null),
248+
sampleDetachedObjective: () => Promise.resolve(null),
219249
};
220250

221251
export const ExperimentsContext = createContext<ExperimentsContextValue>(

libs/@hashintel/petrinaut/src/react/experiments/provider.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type MonteCarloExperimentState,
1717
getDefaultMonteCarloShardCount,
1818
type WorkerFactory,
19+
DEFAULT_PETRINAUT_EXTENSIONS,
1920
type Scenario,
2021
type ScenarioParameter,
2122
} from "@hashintel/petrinaut-core";
@@ -48,6 +49,7 @@ import {
4849
type ExperimentsContextValue,
4950
isExperimentActive,
5051
isTerminalExperimentStatus,
52+
type DetachedObjectiveRequest,
5153
} from "./context";
5254
import {
5355
buildParameterAxis,
@@ -58,6 +60,7 @@ import {
5860
import {
5961
createSweepSession,
6062
type SweepSession,
63+
type SweepCellSnapshot,
6164
type SweepSessionUpdate,
6265
} from "./sweep-session";
6366

@@ -259,6 +262,24 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
259262
new Map<string, PendingExperimentRegistration>(),
260263
);
261264
const sweepSessionsRef = useRef(new Map<string, SweepSession>());
265+
/** Serializes detached objective batches on one background worker. */
266+
const detachedChainRef = useRef<Promise<unknown>>(Promise.resolve());
267+
const detachedCpuBackendRef = useRef<ExperimentBackend | null>(null);
268+
const detachedCompileCacheRef = useRef(
269+
new Map<
270+
string,
271+
Promise<{
272+
scenario: Scenario;
273+
scenarioHir: Awaited<ReturnType<typeof requestScenarioHir>>;
274+
artifacts: Awaited<ReturnType<typeof requestHirArtifacts>>["artifacts"];
275+
metricArtifact: NonNullable<
276+
Awaited<
277+
ReturnType<typeof requestHirArtifacts>
278+
>["artifacts"]["metrics"][string]
279+
>;
280+
}>
281+
>(),
282+
);
262283
const [experiments, setExperiments] = useState<ExperimentRecord[]>([]);
263284
const selectedExperimentId =
264285
navigation.state.simulateResource?.type === "experiment"
@@ -1019,6 +1040,145 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
10191040
.get(experimentId)
10201041
?.sampleCell(parameterValues, minRuns) ?? Promise.resolve(null);
10211042

1043+
const runDetachedObjectiveBatch = async (
1044+
request: DetachedObjectiveRequest,
1045+
): Promise<SweepCellSnapshot | null> => {
1046+
try {
1047+
// One compiled snapshot per study: the frozen definition, its scenario
1048+
// HIR, and its HIR artifacts never change for a given cacheKey.
1049+
let compiled = detachedCompileCacheRef.current.get(request.cacheKey);
1050+
if (!compiled) {
1051+
compiled = (async () => {
1052+
const scenario = (request.definition.scenarios ?? []).find(
1053+
(candidate: Scenario) => candidate.id === request.scenarioId,
1054+
);
1055+
if (!scenario) {
1056+
throw new Error(
1057+
`Scenario ${request.scenarioId} is not in the model snapshot`,
1058+
);
1059+
}
1060+
// The snapshot runs under default extensions, as it does on the
1061+
// optimizer service — the live editor's toggles do not apply to a
1062+
// frozen study.
1063+
const { artifacts, failures } = await requestHirArtifacts(
1064+
request.definition,
1065+
DEFAULT_PETRINAUT_EXTENSIONS,
1066+
{ includeHir: false },
1067+
);
1068+
const metricArtifact = getOwn(artifacts.metrics, request.metric.id);
1069+
if (!metricArtifact) {
1070+
throw new Error(
1071+
failures
1072+
.map((failure) => failure.diagnostics[0]?.message)
1073+
.filter(Boolean)
1074+
.join("; ") || "The objective metric did not compile",
1075+
);
1076+
}
1077+
const scenarioHir = await requestScenarioHir(scenario);
1078+
return { scenario, scenarioHir, artifacts, metricArtifact };
1079+
})();
1080+
detachedCompileCacheRef.current.set(request.cacheKey, compiled);
1081+
compiled.catch(() => {
1082+
// A failed compile is retried on the next sample rather than cached.
1083+
detachedCompileCacheRef.current.delete(request.cacheKey);
1084+
});
1085+
}
1086+
const { scenario, scenarioHir, artifacts, metricArtifact } =
1087+
await compiled;
1088+
1089+
const compiledScenario = compileScenario(
1090+
scenario,
1091+
scenarioHir,
1092+
request.definition.parameters,
1093+
request.definition.places,
1094+
request.definition.types,
1095+
{
1096+
// Scenario compilation is numeric; boolean bindings arrive as their
1097+
// 0/1 encoding, matching how the engine stores boolean parameters.
1098+
scenarioParameterValues: Object.fromEntries(
1099+
Object.entries(request.scenarioParameterValues).map(
1100+
([identifier, value]) => [
1101+
identifier,
1102+
typeof value === "boolean" ? (value ? 1 : 0) : value,
1103+
],
1104+
),
1105+
),
1106+
},
1107+
);
1108+
if (!compiledScenario.ok) {
1109+
return null;
1110+
}
1111+
1112+
detachedCpuBackendRef.current ??= createWorkerPoolExperimentBackend({
1113+
createWorker: workerFactoryRef.current,
1114+
shardCount: 1,
1115+
});
1116+
const backend = detachedCpuBackendRef.current;
1117+
1118+
const abortController = new AbortController();
1119+
const assessment = await backend.assess({
1120+
sdcpn: request.definition,
1121+
extensions: DEFAULT_PETRINAUT_EXTENSIONS,
1122+
initialMarking: compiledScenario.result.initialState,
1123+
parameterValues: compiledScenario.result.parameterValues,
1124+
seed: request.seed,
1125+
dt: request.dt,
1126+
maxTime: request.maxTime,
1127+
runCount: request.runCount,
1128+
metricSpecs: [
1129+
{
1130+
kind: "expression",
1131+
id: request.metric.id,
1132+
label: request.metric.label,
1133+
code: request.metric.code,
1134+
sampleRuns: "all",
1135+
runOutput: { type: "distribution" },
1136+
artifact: metricArtifact,
1137+
},
1138+
],
1139+
hirArtifacts: artifacts,
1140+
});
1141+
if (!assessment.eligible) {
1142+
return null;
1143+
}
1144+
const instantiated = await assessment.instantiate({
1145+
signal: abortController.signal,
1146+
});
1147+
if (!instantiated.ok) {
1148+
return null;
1149+
}
1150+
const handle = instantiated.handle;
1151+
1152+
const done = new Promise<boolean>((resolve) => {
1153+
const offEvents = handle.events.subscribe((event) => {
1154+
offEvents();
1155+
resolve(event.type === "complete");
1156+
});
1157+
});
1158+
handle.start();
1159+
const completed = await done;
1160+
const frames = handle.metrics.get().frames;
1161+
handle.dispose();
1162+
if (!completed) {
1163+
return null;
1164+
}
1165+
return { runsCompleted: request.runCount, metricFrames: frames };
1166+
} catch {
1167+
// A refused or failed batch is a hole in the surface, not an error the
1168+
// optimization view should surface.
1169+
return null;
1170+
}
1171+
};
1172+
1173+
const sampleDetachedObjective: ExperimentsContextValue["sampleDetachedObjective"] =
1174+
(request) => {
1175+
const next = detachedChainRef.current.then(() =>
1176+
runDetachedObjectiveBatch(request),
1177+
);
1178+
detachedChainRef.current = next.catch(() => null);
1179+
return next;
1180+
};
1181+
10221182
const selectedExperiment =
10231183
experiments.find((experiment) => experiment.id === selectedExperimentId) ??
10241184
null;
@@ -1033,6 +1193,7 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
10331193
removeExperiment: useStableCallback(removeExperiment),
10341194
setSweepSelection: useStableCallback(setSweepSelection),
10351195
sampleSweepCell: useStableCallback(sampleSweepCell),
1196+
sampleDetachedObjective: useStableCallback(sampleDetachedObjective),
10361197
};
10371198

10381199
return (

0 commit comments

Comments
 (0)