@@ -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" ;
5254import {
5355 buildParameterAxis ,
@@ -58,6 +60,7 @@ import {
5860import {
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