PyAntiGen 1.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. framework/AntimonyGen.py +48 -0
  2. framework/RxnDict_to_antimony.py +594 -0
  3. framework/TelluriumGen.py +16 -0
  4. framework/__init__.py +0 -0
  5. framework/antimony_utils.py +294 -0
  6. framework/cli.py +229 -0
  7. framework/data_interpolation.py +340 -0
  8. framework/isotopomer_tools.py +41 -0
  9. framework/model_generation.py +46 -0
  10. framework/models.py +189 -0
  11. framework/module_base.py +42 -0
  12. framework/pyantigen.py +51 -0
  13. framework/rate_laws.py +101 -0
  14. framework/reaction_creation.py +43 -0
  15. framework/template/Example/AntiGen_paths.py +23 -0
  16. framework/template/Example/Engine/Anchor_cache.py +193 -0
  17. framework/template/Example/Engine/Deadline.py +535 -0
  18. framework/template/Example/Engine/Evaluator.py +1176 -0
  19. framework/template/Example/Engine/Event_times.py +491 -0
  20. framework/template/Example/Engine/Fast_profile.py +701 -0
  21. framework/template/Example/Engine/Fit_cache.py +329 -0
  22. framework/template/Example/Engine/Identifiability.py +698 -0
  23. framework/template/Example/Engine/Model_optimize.py +1483 -0
  24. framework/template/Example/Engine/Model_simulate.py +124 -0
  25. framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
  26. framework/template/Example/Engine/Optimize.py +6862 -0
  27. framework/template/Example/Engine/Petab_export.py +398 -0
  28. framework/template/Example/Engine/Preequil_cache.py +361 -0
  29. framework/template/Example/Engine/Profile_checkpoint.py +399 -0
  30. framework/template/Example/Engine/Results.py +395 -0
  31. framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
  32. framework/template/Example/Engine/Simulate.py +617 -0
  33. framework/template/Example/Flipflop_reference.py +401 -0
  34. framework/template/Example/Model_generate.py +37 -0
  35. framework/template/Example/Model_run.py +261 -0
  36. framework/template/Example/Modules/Data.py +63 -0
  37. framework/template/Example/Modules/Events.py +14 -0
  38. framework/template/Example/Modules/Experiment.py +194 -0
  39. framework/template/Example/Modules/Loss_config.py +61 -0
  40. framework/template/Example/Modules/Observed_species.py +3 -0
  41. framework/template/Example/Modules/Optimizer_settings.py +258 -0
  42. framework/template/Example/Modules/Plots.py +89 -0
  43. framework/template/Example/Modules/Solver_settings.py +16 -0
  44. framework/template/Example/Modules/Update_opt_parameters.py +24 -0
  45. framework/template/Example/Modules/Update_parameters.py +49 -0
  46. framework/template/data/ADneg.csv +27 -0
  47. framework/template/data/ADpos.csv +27 -0
  48. framework/template/data/Flipflop.csv +29 -0
  49. framework/template/data/make_flipflop_data.py +174 -0
  50. pyantigen-1.0.9.dist-info/METADATA +129 -0
  51. pyantigen-1.0.9.dist-info/RECORD +55 -0
  52. pyantigen-1.0.9.dist-info/WHEEL +5 -0
  53. pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
  54. pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
  55. pyantigen-1.0.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1176 @@
1
+ """Parallel evaluation service for the proper joint NLL.
2
+
3
+ Every expensive diagnostic in this Engine -- the Wald Hessian, likelihood
4
+ slices, Sobol sampling, and profile likelihood -- is a large batch of
5
+ independent ``nll_func_fixed`` evaluations. This module turns that into one
6
+ primitive::
7
+
8
+ with ParallelEvaluator(spec, n_workers=16) as ev:
9
+ losses = ev.evaluate_batch([x1, x2, x3, ...])
10
+
11
+ and every consumer above it becomes embarrassingly parallel.
12
+
13
+ Design notes
14
+ ------------
15
+
16
+ **Windows-first, not fork.** The previous parallel path forked, so it did
17
+ nothing on Windows. This uses a ``spawn`` context, which behaves identically on
18
+ Windows, Linux and macOS. Spawn cannot inherit memory, so each worker rebuilds
19
+ what it needs in an initializer -- which is the right shape anyway.
20
+
21
+ **Compile once per worker, not once per task.** Compiling the model costs
22
+ 1.7-4.9 s while a single evaluation costs ~1 s, so a naive "one task = one
23
+ process" pool would spend all its time in ``te.loada``. Workers are persistent
24
+ and compile every needed RoadRunner exactly once at startup.
25
+
26
+ **cloudpickle for the spec, plain pickle for the tasks.** Replicate dicts hold
27
+ callables, and some ``loss_config`` entries are closures produced by factories
28
+ (e.g. ``figure5_loss_config_factory``), which ``pickle`` cannot serialize.
29
+ ``cloudpickle`` can. We serialize the spec once, pass it to the initializer as
30
+ *bytes* (which pickle handles fine), and thereafter send only parameter vectors.
31
+ That keeps per-task IPC tiny.
32
+
33
+ **Failure is data.** A worker never raises across the boundary; it returns a
34
+ status and the failure sentinel, so one bad integration cannot abort a batch of
35
+ several hundred. Callers get a count of what failed rather than silence.
36
+
37
+ **No plotting in workers.** The progress overlay writes a fixed PNG/JSON path
38
+ from module-level mutable state; with N workers that becomes N processes
39
+ fighting over one file. Workers only ever compute.
40
+
41
+ Calling scripts must guard their entry point
42
+ --------------------------------------------
43
+
44
+ ``spawn`` re-imports the ``__main__`` module inside every worker, so any script
45
+ that reaches this code must do its work under a guard::
46
+
47
+ if __name__ == "__main__":
48
+ main()
49
+
50
+ ``Model_run.py`` already does. An ad-hoc analysis script that calls
51
+ ``setup_optimization_from_groups`` at module level will have each worker re-run
52
+ the whole analysis, and multiprocessing raises "an attempt has been made to start
53
+ a new process before the current process has finished its bootstrapping phase".
54
+ ``evaluate_batch`` rewrites that message to name the actual cause; the caller
55
+ then falls back to serial rather than losing the run.
56
+
57
+ Note that even with the guard, module-level work in the main script (imports,
58
+ building EXPERIMENT registries) is repeated in every worker at startup, so
59
+ keeping that work light pays off directly in pool start-up time.
60
+ """
61
+
62
+ import os
63
+ import sys
64
+ import time
65
+ from dataclasses import dataclass, field
66
+
67
+ import numpy as np
68
+
69
+ try:
70
+ import cloudpickle as _serializer
71
+ _SERIALIZER_NAME = "cloudpickle"
72
+ except ImportError: # pragma: no cover - cloudpickle is a declared dependency
73
+ import pickle as _serializer
74
+ _SERIALIZER_NAME = "pickle"
75
+
76
+
77
+ # Failure sentinel, matching Engine/Optimize.py.
78
+ FAILURE_VALUE = 1e10
79
+
80
+ # Windows caps ProcessPoolExecutor at 61 (WaitForMultipleObjects); leave headroom.
81
+ _MAX_WORKERS_WINDOWS = 60
82
+
83
+ # How often to say something while a batch is running but nothing has landed.
84
+ # A profile point is a whole nuisance minimization and can take hours, so with
85
+ # every worker busy on its first point the run is silent from the moment the
86
+ # models finish compiling until the first result -- which on a cluster is
87
+ # indistinguishable from a hang, for hours at a time.
88
+ _HEARTBEAT_SECONDS = 300
89
+
90
+
91
+ def _fmt_dur(seconds):
92
+ """Compact duration: '3h07m', '12m40s', '45s'."""
93
+ s = int(max(0.0, seconds))
94
+ h, rem = divmod(s, 3600)
95
+ m, sec = divmod(rem, 60)
96
+ if h:
97
+ return f"{h}h{m:02d}m"
98
+ if m:
99
+ return f"{m}m{sec:02d}s"
100
+ return f"{sec}s"
101
+
102
+
103
+ @dataclass
104
+ class EvalSpec:
105
+ """Everything a worker needs to rebuild the objective from scratch.
106
+
107
+ Must be serializable by cloudpickle. It deliberately carries the *event
108
+ strings* rather than a way to regenerate them, so workers never re-run
109
+ data-dependent event generation and cannot disagree with the parent.
110
+ """
111
+ model_text: str
112
+ paths: dict
113
+ events: dict # sim_name -> antimony event block
114
+ replicates: dict # sim_name -> replicate dict
115
+ param_names: list
116
+ scales: list
117
+ groups: dict
118
+ group_normalization: str
119
+ fixed_sigmas: dict
120
+ events_dynamic: bool = False
121
+ data_path: str = None
122
+ # Workers must compute exactly what the parent computes. True means the
123
+ # joint log-likelihood (plain sum over loss elements, unit weights); False
124
+ # reproduces the objective's own normalization and weighting.
125
+ for_inference: bool = True
126
+ # The concentrated likelihood is the single objective shared by the fit and
127
+ # every diagnostic; workers must use it too or the profile would be anchored
128
+ # on a function the parent never minimized.
129
+ concentrated: bool = True
130
+ # Whether workers may reuse the pre-dose block across evaluations. Decided
131
+ # in the parent, which runs the invariance check once; a worker must never
132
+ # make that call on its own, or 40 of them would each re-derive it.
133
+ preequil_cache: bool = False
134
+ # Reserved for future use by the profile grid (Stage 2).
135
+ meta: dict = field(default_factory=dict)
136
+
137
+
138
+ # --------------------------------------------------------------------------
139
+ # Worker side
140
+ # --------------------------------------------------------------------------
141
+
142
+ _WORKER = {"spec": None, "models": None, "n_evals": 0}
143
+
144
+
145
+ def _init_worker(spec_blob):
146
+ """Compile every model this worker needs, exactly once."""
147
+ # Import here: with spawn, the worker re-imports the module tree anyway, and
148
+ # keeping these out of module scope avoids paying for them in the parent.
149
+ from framework.TelluriumGen import TelluriumGen
150
+ from Engine.Event_times import attach_event_times
151
+ from Engine.Optimize import OptRoadRunnerProxy
152
+
153
+ spec = _serializer.loads(spec_blob)
154
+ models = {}
155
+ t0 = time.time()
156
+ for sim_name, replicate in spec.replicates.items():
157
+ df_dict = replicate["Data"](replicate, spec.data_path or spec.paths["data_path"])
158
+ events_str = spec.events.get(sim_name, "")
159
+
160
+ r_ic = None
161
+ if spec.events_dynamic:
162
+ r_ic = TelluriumGen(spec.model_text, spec.paths)
163
+ replicate["Update_parameters"](
164
+ OptRoadRunnerProxy(r_ic, spec.param_names), replicate
165
+ )
166
+
167
+ r = TelluriumGen(spec.model_text + "\n" + events_str, spec.paths)
168
+ replicate["Update_parameters"](
169
+ OptRoadRunnerProxy(r, spec.param_names), replicate
170
+ )
171
+ # The parent's attachment closes over the parent's RoadRunner and could
172
+ # not be shipped here, so it was stripped from the spec. A worker must
173
+ # attach against the model it will integrate anyway -- reusing the
174
+ # parent's times would be reading one model's schedule off another's.
175
+ # Silent: forty workers each printing the same summary is noise.
176
+ attach_event_times(replicate, r)
177
+ entry = {"r": r, "r_ic": r_ic, "df_dict": df_dict}
178
+ # The parent already verified that the pre-dose block does not depend on
179
+ # the fitted parameters; workers only act on that verdict.
180
+ if getattr(spec, "preequil_cache", False) and not spec.events_dynamic:
181
+ from Engine.Preequil_cache import PreequilCache
182
+ entry["preequil_cache"] = PreequilCache(enabled=True)
183
+ models[sim_name] = entry
184
+
185
+ _WORKER["spec"] = spec
186
+ _WORKER["models"] = models
187
+ _WORKER["n_evals"] = 0
188
+ print(f" [worker {os.getpid()}] compiled {len(models)} model(s) in "
189
+ f"{time.time() - t0:.1f}s", flush=True)
190
+
191
+
192
+ def _worker_nll(x):
193
+ """The joint NLL, evaluated with this worker's own compiled models."""
194
+ from Engine.Optimize import evaluate_nll_fixed
195
+
196
+ spec = _WORKER["spec"]
197
+ return evaluate_nll_fixed(
198
+ np.asarray(x, dtype=float),
199
+ _WORKER["models"], spec.replicates, spec.param_names, spec.scales,
200
+ spec.groups, spec.group_normalization, spec.fixed_sigmas,
201
+ model_text=spec.model_text, paths=spec.paths,
202
+ events_dynamic=spec.events_dynamic, failure_value=FAILURE_VALUE,
203
+ for_inference=getattr(spec, "for_inference", True),
204
+ concentrated=getattr(spec, "concentrated", True),
205
+ )
206
+
207
+
208
+ def _eval_task(x):
209
+ """Evaluate one parameter vector. Never raises across the pool boundary."""
210
+ if _WORKER["spec"] is None:
211
+ return (FAILURE_VALUE, "worker-not-initialized", 0.0)
212
+
213
+ t0 = time.time()
214
+ try:
215
+ val = _worker_nll(x)
216
+ _WORKER["n_evals"] += 1
217
+ status = "ok" if np.isfinite(val) and val < FAILURE_VALUE else "sentinel"
218
+ return (float(val), status, time.time() - t0)
219
+ except Exception as exc:
220
+ return (FAILURE_VALUE, f"error: {type(exc).__name__}: {exc}", time.time() - t0)
221
+
222
+
223
+ class _TimeUp(Exception):
224
+ """Raised inside the objective when a point's wall slice has run out."""
225
+
226
+
227
+ # How much wall clock one inner minimization slice aims to cover. Every slice
228
+ # boundary is a chance to capture the optimizer's state, so this sets how much
229
+ # progress a hard kill can cost; the cost of more slices is near zero because
230
+ # the vertices they re-evaluate come from the cache below.
231
+ _SLICE_TARGET_S = 600.0
232
+
233
+ # Evaluations in the opening slice when nothing is known about their cost yet.
234
+ # Enough to measure a rate, few enough to be cheap if each one is slow.
235
+ _SLICE_PROBE_EVALS = 24
236
+
237
+ # Fraction of the remaining time a slice is allowed to plan for. The margin
238
+ # absorbs variance in per-evaluation cost, which on an ODE model is
239
+ # substantial: a stiff parameter vector can take several times the median. A
240
+ # slice that overruns is stopped by the backstop and hands back no simplex, so
241
+ # finishing early is worth much more than the evaluations it gives up.
242
+ _SLICE_SAFETY = 0.8
243
+
244
+ # Objective values kept for reuse. A slice boundary makes scipy re-evaluate
245
+ # every simplex vertex, which is exactly what was computed just before, so a
246
+ # handful of entries turns the restart cost into nothing.
247
+ _EVAL_CACHE_SIZE = 128
248
+
249
+
250
+ def _simplex_including(sim, fsim, x, f):
251
+ """The simplex with *x* substituted for its worst vertex, if *x* beats it.
252
+
253
+ Used when a slice is killed by the clock rather than returning normally: the
254
+ last complete simplex is the optimizer state worth keeping, but the best
255
+ point found during the killed slice would otherwise be thrown away. Swapping
256
+ it in for the worst vertex keeps both, and yields a simplex that is still
257
+ a valid starting shape.
258
+ """
259
+ if sim is None or fsim is None or x is None or not np.isfinite(f):
260
+ return sim
261
+ sim = np.asarray(sim, dtype=float).copy()
262
+ fsim = np.asarray(fsim, dtype=float)
263
+ if sim.shape[0] != fsim.shape[0]:
264
+ return sim
265
+ worst = int(np.argmax(fsim))
266
+ if f < fsim[worst]:
267
+ sim[worst] = np.asarray(x, dtype=float)
268
+ return sim
269
+
270
+
271
+ def _minimize_in_slices(objective, x0, args, method, bounds, optimizer_kwargs,
272
+ fev_left, it_left, simplex, deadline, sec_per_eval,
273
+ eval_count):
274
+ """Minimize in slices so the optimizer's state is never out of reach.
275
+
276
+ The problem this solves: a profile point can want far more wall clock than
277
+ the queue will give it, so it has to be stoppable and resumable. Stopping is
278
+ easy -- raise out of the objective. Resuming is the hard half, because for
279
+ Nelder-Mead the optimizer's entire state is its simplex, and an exception
280
+ thrown through ``scipy.optimize.minimize`` takes that simplex with it. A
281
+ resume from the best point alone re-derives the simplex from scratch and
282
+ spends most of its next slice relearning what it already knew: measured on a
283
+ 4-parameter quadratic, thirty further evaluations from the bare best point
284
+ moved the objective from 6.71 to 6.63, while the same thirty with the
285
+ simplex restored reached 4.99.
286
+
287
+ So the minimization is run as a sequence of bounded slices. Each one returns
288
+ normally, which means each one hands back ``final_simplex``, so there is
289
+ always a complete and current state to write down. Slices are sized from the
290
+ measured cost of an evaluation to land near ``_SLICE_TARGET_S``, and the
291
+ last one is sized to finish just before the deadline.
292
+
293
+ Slicing is close to free because of the evaluation cache: the vertices scipy
294
+ re-evaluates when handed an ``initial_simplex`` are precisely the points the
295
+ previous slice just computed.
296
+
297
+ Returns ``(res, simplex, outcome)`` where *outcome* is one of ``"done"``
298
+ (the optimizer stopped on its own terms), ``"capped"`` (the point's total
299
+ allowance across all launches is spent) or ``"interrupted"`` (the clock).
300
+ """
301
+ from Engine.Optimize import _minimize_nuisance
302
+
303
+ supports_simplex = str(method).lower() == "nelder-mead"
304
+ n = max(1, len(np.atleast_1d(x0)))
305
+ res = None
306
+ x_cur = np.asarray(x0, dtype=float)
307
+
308
+ # Below n+1 evaluations scipy cannot even establish a simplex, so a slice
309
+ # that short returns no state at all and the point resumes cold. Above it,
310
+ # a slice returns a usable simplex whether or not it also made progress.
311
+ state_floor = n + 1
312
+ useful = 2 * (n + 1)
313
+
314
+ # One measured evaluation before committing to a slice length. Without a
315
+ # rate the first slice has to be guessed at, and guessing high on a slow
316
+ # objective is how a point ends up killed by the backstop with nothing
317
+ # saved -- which is exactly what happened on the first cluster run: five
318
+ # evaluations of a 116 s objective inside an eight-minute slice, when
319
+ # sixteen were needed before any state existed. The probe is close to free
320
+ # because its result is cached, so the slice that follows re-uses it
321
+ # instead of recomputing it.
322
+ if (deadline is not None and not sec_per_eval and eval_count["n"] == 0
323
+ and time.time() < deadline):
324
+ try:
325
+ objective(x_cur, *args)
326
+ except _TimeUp:
327
+ return None, simplex, "interrupted"
328
+
329
+ while True:
330
+ if fev_left <= 0 or it_left <= 0:
331
+ return res, simplex, "capped"
332
+
333
+ slice_fev = fev_left
334
+ if deadline is not None:
335
+ t_left = deadline - time.time()
336
+ if t_left <= 0:
337
+ return res, simplex, "interrupted"
338
+ rate = sec_per_eval or (eval_count["seconds"] / eval_count["n"]
339
+ if eval_count["n"] else None)
340
+ if rate and rate > 0:
341
+ # Deliberately short of what the clock allows. A slice sized to
342
+ # consume every remaining second finishes only if the rate
343
+ # estimate is perfect; any variance trips the backstop, and the
344
+ # backstop is the one exit that returns no simplex. Aiming to
345
+ # land early is what makes the state reliably saveable.
346
+ affordable = int(_SLICE_SAFETY * t_left / rate)
347
+ if affordable < state_floor:
348
+ if res is not None:
349
+ # Not even enough left to re-establish a simplex. Stop
350
+ # while the state from the last slice is intact rather
351
+ # than spending the remainder and losing it.
352
+ return res, simplex, "interrupted"
353
+ # No slice here can reach a state worth saving: scipy needs
354
+ # n+1 evaluations before a simplex exists, and there is not
355
+ # time for them. Attempting it anyway spends the whole
356
+ # slice and hands back nothing.
357
+ #
358
+ # That is not hypothetical. One SILK link ran with the
359
+ # 30-minute default cap and no timing history, against a
360
+ # 15-nuisance-parameter model at 113 s per evaluation: a
361
+ # simplex costs 30.2 minutes, so every point burned its
362
+ # entire slice on exactly n+1 evaluations, took zero
363
+ # Nelder-Mead iterations, and came back with its nuisance
364
+ # vector still equal to its starting point and no simplex.
365
+ # Across 39 workers that is hours of wall clock for
366
+ # nothing.
367
+ #
368
+ # Stopping now costs one evaluation instead of sixteen, and
369
+ # -- the point of it -- that evaluation measures the rate,
370
+ # which is written to timing.json and lets the next round
371
+ # size its cap correctly.
372
+ if eval_count["n"] == 0:
373
+ try:
374
+ objective(x_cur, *args)
375
+ except _TimeUp:
376
+ pass
377
+ return None, simplex, "interrupted"
378
+ else:
379
+ # Ask for what actually fits, not for what would be ideal.
380
+ # Requesting more than the clock allows guarantees the
381
+ # backstop fires, and the backstop is the one exit that
382
+ # returns no simplex.
383
+ slice_fev = min(fev_left, affordable,
384
+ max(useful, int(_SLICE_TARGET_S / rate)))
385
+ slice_fev = max(slice_fev, min(fev_left, state_floor))
386
+ else:
387
+ slice_fev = min(slice_fev, max(state_floor, _SLICE_PROBE_EVALS))
388
+
389
+ extra = {"maxfev": int(slice_fev),
390
+ "maxiter": int(min(it_left, slice_fev))}
391
+ if simplex is not None and supports_simplex:
392
+ extra["initial_simplex"] = np.asarray(simplex, dtype=float)
393
+
394
+ before = eval_count["n"]
395
+ try:
396
+ res = _minimize_nuisance(objective, x_cur, args, method, bounds,
397
+ optimizer_kwargs, extra_options=extra)
398
+ except _TimeUp:
399
+ # The rate estimate was too optimistic -- one evaluation took far
400
+ # longer than the others. The previous slice's simplex is still the
401
+ # best state available, so keep it rather than losing everything.
402
+ return res, simplex, "interrupted"
403
+
404
+ final = getattr(res, "final_simplex", None)
405
+ if final is not None:
406
+ simplex = np.asarray(final[0], dtype=float)
407
+ x_cur = np.asarray(res.x, dtype=float)
408
+
409
+ # The point's allowance is spent in *evaluations of the model*, so it
410
+ # is charged what actually ran. scipy's own nfev counts the vertex
411
+ # re-evaluations at each slice boundary, which the cache serves for
412
+ # free; charging those would make a heavily-sliced point exhaust its
413
+ # budget without doing the work the budget was meant to buy.
414
+ fev_left -= max(0, eval_count["n"] - before)
415
+ it_left -= max(1, int(getattr(res, "nit", 0) or 0))
416
+
417
+ # Stopping short of the slice cap means the optimizer stopped for its
418
+ # own reasons -- converged, or on xatol/fatol -- and there is nothing
419
+ # more to do. Filling the slice means it was cut off by us, not by the
420
+ # problem, so there is more to do if there is time to do it.
421
+ if int(getattr(res, "nfev", 0) or 0) < slice_fev:
422
+ return res, simplex, "done"
423
+
424
+
425
+ def _profile_task(job):
426
+ """Run one profile-likelihood point: minimize over the nuisance parameters
427
+ with parameter ``param_idx`` pinned at ``x_fixed``.
428
+
429
+ A profile point is a whole optimization, not a single evaluation, so the
430
+ scipy call runs *inside* the worker against its local models. That is what
431
+ makes the profile parallel: 2k x n_grid independent optimizations in flight,
432
+ instead of one adaptive walk stepping sequentially.
433
+
434
+ **A point does not have to fit in one job.** On a four-hour queue a single
435
+ nuisance minimization can easily want forty, so the point carries a
436
+ ``deadline`` and stops itself when it arrives, reporting where it had got
437
+ to. That is sound rather than merely convenient: every evaluation of this
438
+ objective is an upper bound on the profile, so a half-finished point is a
439
+ real point that happens to sit too high, and the store keeps the lowest
440
+ value seen at each fixed value. Resuming can therefore only lower the
441
+ curve, never raise it -- the same invariant the warm-continuation pass
442
+ already relies on.
443
+
444
+ Two things are carried across the interruption, and the second is what
445
+ makes it worth doing:
446
+
447
+ * ``nuisance_x`` -- the best nuisance vector reached so far, which becomes
448
+ the next job's starting point.
449
+ * ``nm_simplex`` -- for Nelder-Mead, the whole simplex. Without it a resume
450
+ restarts the simplex from a single point and spends most of its next
451
+ slice rebuilding what it already knew; measured on a 4-parameter
452
+ quadratic, 30 further evaluations from the bare best point recovered
453
+ almost nothing while the same 30 with the simplex restored made normal
454
+ progress. The optimizer's state *is* the simplex, so saving it is the
455
+ difference between resuming and starting over.
456
+
457
+ ``job`` is a plain dict so it pickles cheaply. Returns a result dict that is
458
+ written straight to the checkpoint file.
459
+ """
460
+ from Engine.Optimize import (
461
+ _make_nuisance_objective, _minimize_nuisance, nuisance_convergence,
462
+ nuisance_option_budget,
463
+ )
464
+
465
+ t0 = time.time()
466
+ out = dict(job)
467
+ # An input, not a result, and a bulky one: a 15-nuisance simplex is 240
468
+ # floats, which every record would otherwise carry into the checkpoint
469
+ # alongside the nm_simplex it actually needs to store. The resume path
470
+ # reads nm_simplex, never this.
471
+ out.pop("initial_simplex", None)
472
+ out.update({"nll": None, "status": "ok", "n_evals": 0, "wall_s": 0.0,
473
+ "worker": os.getpid(), "converged": True, "nit": -1,
474
+ "nfev": -1, "opt_message": "", "interrupted": False})
475
+
476
+ if _WORKER["spec"] is None:
477
+ out.update({"status": "worker-not-initialized", "nll": FAILURE_VALUE})
478
+ return out
479
+
480
+ try:
481
+ spec = _WORKER["spec"]
482
+ n_params = len(spec.param_names)
483
+ param_idx = int(job["param_idx"])
484
+ x_fixed = float(job["x_fixed"])
485
+ x_start = np.asarray(job["x_start"], dtype=float)
486
+ method = job.get("method", "Nelder-Mead")
487
+ deadline = job.get("deadline")
488
+
489
+ # What earlier jobs on this same point already spent. The caps are a
490
+ # total across every launch, so a point that keeps being interrupted
491
+ # still terminates instead of being resumed forever.
492
+ nfev_used = int(job.get("nfev_used") or 0)
493
+ nit_used = int(job.get("nit_used") or 0)
494
+
495
+ # Real evaluations and the time they cost, which is what sizes the next
496
+ # slice. Cache hits are excluded from both: they are neither work done
497
+ # nor budget spent.
498
+ eval_count = {"n": 0, "seconds": 0.0}
499
+ # The best point seen. Tracked here rather than read off an
500
+ # OptimizeResult because when the clock stops a slice there is no
501
+ # OptimizeResult to read it from.
502
+ best = {"f": float("inf"), "x": x_start}
503
+ cache = {}
504
+ cache_order = []
505
+
506
+ raw_objective = _make_nuisance_objective(_worker_nll, param_idx, n_params)
507
+
508
+ def nuisance_objective(x_nuisance, fixed_val):
509
+ x_arr = np.asarray(x_nuisance, dtype=float)
510
+ key = x_arr.tobytes()
511
+ hit = cache.get(key)
512
+ if hit is not None:
513
+ return hit
514
+
515
+ t_eval = time.time()
516
+ v = raw_objective(x_arr, fixed_val)
517
+ eval_count["n"] += 1
518
+ eval_count["seconds"] += time.time() - t_eval
519
+
520
+ cache[key] = v
521
+ cache_order.append(key)
522
+ if len(cache_order) > _EVAL_CACHE_SIZE:
523
+ cache.pop(cache_order.pop(0), None)
524
+
525
+ if np.isfinite(v) and v < best["f"]:
526
+ best["f"] = float(v)
527
+ best["x"] = x_arr.copy()
528
+ # Checked after recording, so the value just computed is never lost
529
+ # to the interruption that follows it.
530
+ if deadline is not None and time.time() >= deadline:
531
+ raise _TimeUp()
532
+ return v
533
+
534
+ bounds = job.get("nuisance_bounds")
535
+ if bounds is not None:
536
+ bounds = [tuple(b) if b is not None else None for b in bounds]
537
+
538
+ if x_start.size == 0:
539
+ # Single-parameter fit: nothing to re-optimize, so the profile value
540
+ # is just the objective at the fixed value -- exact by definition.
541
+ nll = raw_objective(x_start, x_fixed)
542
+ x_opt = x_start
543
+ out.update({"converged": True, "nit": 0, "nfev": 1})
544
+ eval_count["n"] = 1
545
+ else:
546
+ caps = nuisance_option_budget(method, x_start.size,
547
+ job.get("optimizer_kwargs"))
548
+ fev_left = caps.get("maxfev", 10 ** 9) - nfev_used
549
+ it_left = caps.get("maxiter", 10 ** 9) - nit_used
550
+
551
+ simplex = job.get("initial_simplex")
552
+ res, simplex, outcome = _minimize_in_slices(
553
+ nuisance_objective, x_start, (x_fixed,), method, bounds,
554
+ job.get("optimizer_kwargs"), fev_left, it_left, simplex,
555
+ deadline, job.get("sec_per_eval"), eval_count,
556
+ )
557
+
558
+ if res is not None:
559
+ out.update(nuisance_convergence(res))
560
+
561
+ if outcome == "interrupted":
562
+ # Not a failure: the slice ended. Report where the search had
563
+ # reached and mark the point so a later launch continues it.
564
+ nll = best["f"] if np.isfinite(best["f"]) else (
565
+ float(res.fun) if res is not None else FAILURE_VALUE)
566
+ x_opt = np.asarray(best["x"], dtype=float)
567
+ fsim = (getattr(res, "final_simplex", (None, None))[1]
568
+ if res is not None else None)
569
+ simplex = _simplex_including(simplex, fsim, best["x"], best["f"])
570
+ out.update({
571
+ "interrupted": True,
572
+ "converged": False,
573
+ "opt_message": "stopped on the wall clock; resumable",
574
+ })
575
+ elif res is None:
576
+ # Capped before a single slice could run: the point has spent
577
+ # its whole allowance across earlier launches. It reports the
578
+ # value it had already reached, so the record stays usable and
579
+ # -- crucially -- checkpointable. A sentinel here would never be
580
+ # written, leaving the stored record marked interrupted and the
581
+ # point resumed on every future link for no work at all.
582
+ prior = job.get("nll_so_far")
583
+ nll = float(prior) if prior is not None else best["f"]
584
+ x_opt = np.asarray(x_start, dtype=float)
585
+ out.update({
586
+ "converged": False, "nit": nit_used, "nfev": nfev_used,
587
+ "opt_message": "evaluation budget exhausted across launches",
588
+ })
589
+ else:
590
+ nll = float(res.fun)
591
+ x_opt = np.asarray(res.x, dtype=float)
592
+ if outcome == "capped":
593
+ out.update({
594
+ "converged": False,
595
+ "opt_message": "evaluation budget exhausted across launches",
596
+ })
597
+
598
+ if simplex is not None:
599
+ out["nm_simplex"] = np.asarray(simplex, dtype=float).tolist()
600
+
601
+ out.update({
602
+ "nll": float(nll),
603
+ "nuisance_x": np.asarray(x_opt, dtype=float).tolist(),
604
+ "n_evals": eval_count["n"],
605
+ # Totals across every launch this point has had, so the next one
606
+ # knows how much of the allowance is left and the point terminates
607
+ # instead of being resumed forever.
608
+ "nfev_total": nfev_used + eval_count["n"],
609
+ "nit_total": nit_used + max(0, int(out.get("nit") or 0)),
610
+ "status": "ok" if np.isfinite(nll) and nll < FAILURE_VALUE else "sentinel",
611
+ })
612
+ except Exception as exc:
613
+ out.update({"status": f"error: {type(exc).__name__}: {exc}",
614
+ "nll": FAILURE_VALUE, "interrupted": False})
615
+
616
+ out["wall_s"] = time.time() - t0
617
+ return out
618
+
619
+
620
+ # --------------------------------------------------------------------------
621
+ # Parent side
622
+ # --------------------------------------------------------------------------
623
+
624
+ def available_cpus():
625
+ """CPUs this process may actually use -- not what the machine has.
626
+
627
+ On a scheduler-managed node ``os.cpu_count()`` reports the whole machine.
628
+ A job allocated 8 cores of a 40-core node would then start 39 workers
629
+ inside an 8-core cgroup: roughly a fivefold slowdown from oversubscription,
630
+ while taking cores from whoever else is sharing the node.
631
+
632
+ ``SLURM_CPUS_PER_TASK`` is what the allocation asked for;
633
+ ``sched_getaffinity`` is the cgroup or taskset the kernel will actually
634
+ honour. ``os.cpu_count()`` is the last resort, and is right on a laptop.
635
+ """
636
+ for var in ("SLURM_CPUS_PER_TASK", "SLURM_JOB_CPUS_PER_NODE"):
637
+ raw = os.environ.get(var)
638
+ if not raw:
639
+ continue
640
+ # SLURM_JOB_CPUS_PER_NODE can read "8", "8(x2)" or "8,4".
641
+ head = raw.split("(")[0].split(",")[0].strip()
642
+ try:
643
+ n = int(head)
644
+ except ValueError:
645
+ continue
646
+ if n > 0:
647
+ return n
648
+ try:
649
+ return max(1, len(os.sched_getaffinity(0)))
650
+ except AttributeError: # not Linux
651
+ pass
652
+ return max(1, os.cpu_count() or 2)
653
+
654
+
655
+ def available_memory_gb():
656
+ """Memory this process may actually use, in GB, or None if unknown.
657
+
658
+ The counterpart to :func:`available_cpus`, and needed for the same reason:
659
+ a scheduler hands out cores and memory separately, and for this workload it
660
+ is memory that runs out first. Each worker compiles every simulation in the
661
+ spec, so the pool's footprint is ``n_workers x n_simulations x per-model``
662
+ while the core count grows only in the first factor.
663
+
664
+ ``SLURM_MEM_PER_NODE`` is what the allocation asked for, in MB.
665
+ ``SLURM_MEM_PER_CPU`` is the same budget expressed per core and has to be
666
+ multiplied back up. Off-cluster the machine's own total is the answer.
667
+
668
+ None means "no idea", which callers must treat as "do not restrict" -- an
669
+ unknown limit has to fail open, or a laptop with an unreadable meminfo
670
+ would silently drop to a single worker.
671
+ """
672
+ raw = os.environ.get("SLURM_MEM_PER_NODE")
673
+ if raw:
674
+ try:
675
+ return float(raw) / 1024.0
676
+ except ValueError:
677
+ pass
678
+
679
+ raw = os.environ.get("SLURM_MEM_PER_CPU")
680
+ if raw:
681
+ try:
682
+ return float(raw) * available_cpus() / 1024.0
683
+ except ValueError:
684
+ pass
685
+
686
+ try:
687
+ import psutil
688
+ return psutil.virtual_memory().total / 1e9
689
+ except Exception:
690
+ pass
691
+
692
+ try:
693
+ return (os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")) / 1e9
694
+ except (AttributeError, ValueError, OSError):
695
+ return None
696
+
697
+
698
+ def default_worker_count(n_workers=None):
699
+ """Workers to use: explicit value, else all available cores but one.
700
+
701
+ Cores only. The memory ceiling is applied by :class:`ParallelEvaluator`,
702
+ which is the first place that knows how large the models are.
703
+ """
704
+ if n_workers is None:
705
+ n_workers = max(1, available_cpus() - 1)
706
+ n_workers = max(1, int(n_workers))
707
+ if sys.platform == "win32":
708
+ n_workers = min(n_workers, _MAX_WORKERS_WINDOWS)
709
+ return n_workers
710
+
711
+
712
+ class ParallelEvaluator:
713
+ """A pool of persistent workers evaluating the joint NLL.
714
+
715
+ Use as a context manager so the pool is always shut down::
716
+
717
+ with ParallelEvaluator(spec, n_workers=16) as ev:
718
+ losses = ev.evaluate_batch(xs)
719
+
720
+ ``evaluate_batch`` preserves input order. Failures come back as
721
+ ``FAILURE_VALUE`` and are counted in ``ev.n_failures`` rather than raised.
722
+ """
723
+
724
+ def __init__(self, spec, n_workers=None, chunk_size=None, verbose=True,
725
+ memory_limit_gb=None):
726
+ self.spec = spec
727
+ self.chunk_size = chunk_size
728
+ self.verbose = verbose
729
+ self._pool = None
730
+ self._blob = None
731
+ self.n_evals = 0
732
+ self.n_failures = 0
733
+ self.total_worker_seconds = 0.0
734
+
735
+ self.n_workers_requested = default_worker_count(n_workers)
736
+ self.n_workers = self._fit_to_memory(self.n_workers_requested,
737
+ memory_limit_gb)
738
+
739
+ # -- lifecycle ---------------------------------------------------------
740
+
741
+ # Calibration point: the ~370-species / 978-reaction SILK variant, whose
742
+ # antimony source is ~124k characters, costs ~0.35 GB per compiled model.
743
+ _REF_MODEL_CHARS = 124_000
744
+ _REF_MODEL_GB = 0.35
745
+
746
+ # Fraction of the allocation the pool is allowed to plan for. The estimate
747
+ # below is a proxy rather than a measurement, and the parent process, the
748
+ # data and the plotting all want memory the workers are not accounted for,
749
+ # so the pool aims well short of the limit.
750
+ _MEM_HEADROOM = 0.8
751
+
752
+ def per_worker_memory_gb(self):
753
+ """Rough resident cost of one worker, in GB.
754
+
755
+ Per-model cost is scaled from the antimony source length against a
756
+ measured reference. That is a crude proxy -- it tracks model size, not
757
+ RoadRunner's exact allocation -- so treat it as an order of magnitude.
758
+ """
759
+ chars = max(len(self.spec.model_text or ""), 1)
760
+ per_model_gb = self._REF_MODEL_GB * (chars / self._REF_MODEL_CHARS)
761
+ overhead_gb = 0.15
762
+ n_models = max(len(self.spec.replicates), 1)
763
+ return n_models * per_model_gb + overhead_gb
764
+
765
+ def memory_estimate_gb(self):
766
+ """Rough resident-memory estimate for the whole pool, in GB.
767
+
768
+ Each worker compiles every simulation in the spec, so the footprint
769
+ scales as ``n_workers x n_simulations x per-model``. That product, not
770
+ the core count, is what limits how wide this can run: a 12-simulation
771
+ spec at 40 workers wants well over 100 GB.
772
+ """
773
+ return self.n_workers * self.per_worker_memory_gb()
774
+
775
+ def _fit_to_memory(self, n_workers, memory_limit_gb=None):
776
+ """Lower *n_workers* until the pool is expected to fit in memory.
777
+
778
+ This used to be a warning, and a warning was the wrong response: a pool
779
+ that overcommits memory is not slower, it is OOM-killed partway through
780
+ a batch, and on a preemptible queue that looks exactly like the
781
+ eviction it is not. Being scheduled 40 cores does not mean 40 workers
782
+ fit -- for a spec with a dozen simulations it usually means the
783
+ opposite -- so cores propose and memory disposes.
784
+
785
+ The cap applies even to an explicitly requested worker count, because
786
+ the failure it prevents is not a matter of taste. ``PROFILE_MEM_LIMIT_GB``
787
+ overrides the detected limit, and setting it to 0 disables the cap for
788
+ anyone who knows better than the estimate.
789
+ """
790
+ limit_gb = memory_limit_gb
791
+ if limit_gb is None:
792
+ raw = os.environ.get("PROFILE_MEM_LIMIT_GB")
793
+ if raw:
794
+ try:
795
+ limit_gb = float(raw)
796
+ except ValueError:
797
+ limit_gb = None
798
+ if limit_gb is None:
799
+ limit_gb = available_memory_gb()
800
+
801
+ if not limit_gb or limit_gb <= 0:
802
+ return n_workers
803
+
804
+ per_worker = self.per_worker_memory_gb()
805
+ if per_worker <= 0:
806
+ return n_workers
807
+
808
+ affordable = int((limit_gb * self._MEM_HEADROOM) // per_worker)
809
+ # At least one worker regardless: a spec too large for even a single
810
+ # worker is a real problem, but refusing to start is not how to report
811
+ # it -- let it run and fail with RoadRunner's own message.
812
+ affordable = max(1, affordable)
813
+ if affordable >= n_workers:
814
+ return n_workers
815
+
816
+ if self.verbose:
817
+ print(f"[pool] {n_workers} core(s) available but only {affordable} "
818
+ f"worker(s) fit in {limit_gb:.0f} GB at ~{per_worker:.1f} GB "
819
+ f"each ({len(self.spec.replicates)} model(s) per worker); "
820
+ f"memory is the binding constraint.", flush=True)
821
+ return affordable
822
+
823
+ def start(self):
824
+ if self._pool is not None:
825
+ return self
826
+ import multiprocessing as mp
827
+ from concurrent.futures import ProcessPoolExecutor
828
+
829
+ self._blob = _serializer.dumps(self.spec)
830
+ n_models = len(self.spec.replicates)
831
+ est_gb = self.memory_estimate_gb()
832
+ if self.verbose:
833
+ capped = (f", capped from {self.n_workers_requested}"
834
+ if self.n_workers < self.n_workers_requested else "")
835
+ print(f"[pool] starting {self.n_workers} worker(s){capped} "
836
+ f"({_SERIALIZER_NAME} spec: {len(self._blob) / 1e6:.1f} MB, "
837
+ f"{n_models} model(s) each, ~{est_gb:.0f} GB estimated)",
838
+ flush=True)
839
+ self._pool = ProcessPoolExecutor(
840
+ max_workers=self.n_workers,
841
+ mp_context=mp.get_context("spawn"),
842
+ initializer=_init_worker,
843
+ initargs=(self._blob,),
844
+ )
845
+ return self
846
+
847
+ def shutdown(self):
848
+ if self._pool is not None:
849
+ self._pool.shutdown(wait=True)
850
+ self._pool = None
851
+
852
+ def __enter__(self):
853
+ return self.start()
854
+
855
+ def __exit__(self, exc_type, exc, tb):
856
+ self.shutdown()
857
+ return False
858
+
859
+ # -- evaluation --------------------------------------------------------
860
+
861
+ def evaluate_batch(self, xs, label=None):
862
+ """Evaluate every parameter vector in *xs*; return losses in input order."""
863
+ xs = [np.asarray(x, dtype=float) for x in xs]
864
+ if not xs:
865
+ return []
866
+ if self._pool is None:
867
+ self.start()
868
+
869
+ chunk = self.chunk_size
870
+ if chunk is None:
871
+ # Enough chunks to keep every worker fed, few enough to avoid
872
+ # per-task overhead dominating.
873
+ chunk = max(1, len(xs) // (self.n_workers * 4) or 1)
874
+
875
+ t0 = time.time()
876
+ if self.verbose:
877
+ # map() returns nothing until the whole batch is done, so this line
878
+ # is the only warning the caller gets that the next stretch of
879
+ # silence is expected. Individual points are reported by
880
+ # profile_batch; this path deliberately trades that for chunking.
881
+ tag = f" [{label}]" if label else ""
882
+ print(f"[pool]{tag} {len(xs)} evaluation(s) submitted to "
883
+ f"{self.n_workers} worker(s) in chunks of {chunk}; "
884
+ f"no output until the batch completes.", flush=True)
885
+ try:
886
+ out = list(self._pool.map(_eval_task, xs, chunksize=chunk))
887
+ except RuntimeError as exc:
888
+ if "bootstrapping phase" in str(exc):
889
+ # spawn re-imports the __main__ module in every worker. If the
890
+ # caller's script runs its work at module level, each worker
891
+ # re-runs the whole analysis and multiprocessing refuses. The
892
+ # stock message never mentions the caller's script, so say it.
893
+ raise RuntimeError(
894
+ "The parallel evaluator needs the calling script to guard "
895
+ "its entry point:\n\n"
896
+ " if __name__ == '__main__':\n"
897
+ " main()\n\n"
898
+ "Worker processes are started with 'spawn', which re-imports "
899
+ "the main module; without the guard each worker would re-run "
900
+ "your analysis from the top. Model_run.py already does this — "
901
+ "ad-hoc analysis scripts need it too. "
902
+ f"(original error: {exc})"
903
+ ) from exc
904
+ raise
905
+
906
+ losses = []
907
+ failures = []
908
+ for i, (val, status, secs) in enumerate(out):
909
+ losses.append(val)
910
+ self.total_worker_seconds += secs
911
+ if status != "ok":
912
+ failures.append((i, status))
913
+
914
+ self.n_evals += len(xs)
915
+ self.n_failures += len(failures)
916
+
917
+ if self.verbose:
918
+ elapsed = max(time.time() - t0, 1e-9)
919
+ # Worker-seconds is the serial cost of the same work; the ratio to
920
+ # wall time is the speedup actually realized. On the first batch it
921
+ # includes worker startup, so it understates steady-state throughput
922
+ # -- report both numbers rather than one flattering one.
923
+ work = sum(o[2] for o in out)
924
+ tag = f" [{label}]" if label else ""
925
+ print(f"[pool]{tag} {len(xs)} evals in {elapsed:.1f}s wall "
926
+ f"({work:.1f}s of work, {work / elapsed:.1f}x, "
927
+ f"{len(xs) / elapsed:.1f} eval/s)", flush=True)
928
+ if failures:
929
+ shown = "; ".join(f"#{i}: {s}" for i, s in failures[:3])
930
+ more = f" (+{len(failures) - 3} more)" if len(failures) > 3 else ""
931
+ print(f"[pool]{tag} {len(failures)} failed — {shown}{more}", flush=True)
932
+
933
+ return losses
934
+
935
+ def profile_batch(self, jobs, on_result=None, label=None,
936
+ heartbeat_s=_HEARTBEAT_SECONDS, budget=None):
937
+ """Run profile-likelihood points in parallel, within a wall budget.
938
+
939
+ Unlike ``evaluate_batch`` this uses submit/wait rather than map, because
940
+ each job is minutes to hours long and results must be checkpointed *as
941
+ they land* -- the whole point of checkpointing is that killing the run
942
+ halfway keeps the half that finished.
943
+
944
+ Points are admitted a poolful at a time rather than submitted all at
945
+ once. Two things follow from that, and the second is the reason for it:
946
+
947
+ * Only ``n_workers`` points are ever committed, so an eviction that
948
+ arrives anyway destroys at most that many, not the whole batch.
949
+ * A point is admitted against the clock as it is about to *start*, not
950
+ when the batch was assembled. Submitting the whole list up front and
951
+ checking the deadline there would clear a point that will not begin
952
+ for another three hours, which is precisely the case the check exists
953
+ to catch.
954
+
955
+ When *budget* runs out the already-running points are waited for --
956
+ they were admitted in good faith and are usually the expensive ones --
957
+ and then :class:`~Engine.Deadline.DeadlineReached` is raised naming how
958
+ many never started. Everything that landed has already been through
959
+ *on_result*, so nothing computed is lost by the raise.
960
+
961
+ A heartbeat is printed every ``heartbeat_s`` while nothing is landing.
962
+ Waiting on completions alone means that with as many workers as points
963
+ in flight, the run says nothing between the models compiling and the
964
+ first result -- hours of silence that reads exactly like a hang.
965
+
966
+ *on_result* is called with each result dict the moment it arrives.
967
+ Returns results in completion order; callers key off the job fields.
968
+ """
969
+ if not jobs:
970
+ return []
971
+ if self._pool is None:
972
+ self.start()
973
+ from concurrent.futures import wait, FIRST_COMPLETED
974
+ from Engine.Deadline import DeadlineReached
975
+
976
+ t0 = time.time()
977
+ results = []
978
+ backlog = list(jobs)
979
+ futures = {}
980
+ pending = set()
981
+ n_jobs = len(jobs)
982
+ done = 0
983
+ halted = False
984
+ tag = f" [{label}]" if label else ""
985
+
986
+ sec_per_eval = budget.seconds_per_eval() if budget is not None else None
987
+
988
+ # What one slice has to be allowed, so that it ends with optimizer
989
+ # state worth resuming from rather than being cut off before any
990
+ # exists. Nelder-Mead needs n+1 evaluations before it has a simplex at
991
+ # all, and twice that before it has also made progress.
992
+ n_nuisance = max(1, len(jobs[0].get("x_start") or ()))
993
+ state_slice_s = (2 * (n_nuisance + 1) * sec_per_eval
994
+ if sec_per_eval else None)
995
+ if (self.verbose and budget is not None and budget.is_limited
996
+ and state_slice_s):
997
+ room = budget.remaining() - budget.margin_s
998
+ if state_slice_s > room:
999
+ print(f"[pool]{tag} WARNING: at {sec_per_eval:.0f}s per "
1000
+ f"evaluation a {n_nuisance + 1}-vertex simplex needs "
1001
+ f"~{state_slice_s / 60.0:.0f} min, but only "
1002
+ f"{room / 60.0:.0f} min remain. Points will be stopped "
1003
+ f"before their optimizer state can be saved and will "
1004
+ f"resume cold — give the link more wall clock.",
1005
+ flush=True)
1006
+
1007
+ def admit():
1008
+ """Start points until the pool is full or the clock says stop."""
1009
+ nonlocal halted
1010
+ while backlog and len(pending) < self.n_workers:
1011
+ if budget is not None and not budget.admits():
1012
+ halted = True
1013
+ return
1014
+ job = backlog.pop(0)
1015
+ # Stamped here rather than where the job was built, because
1016
+ # this is the only place that knows both the clock and the
1017
+ # moment the point actually starts -- and the slice cap is
1018
+ # measured from that moment, so it has to be resolved per job
1019
+ # rather than once for the batch.
1020
+ job = dict(job,
1021
+ deadline=(budget.job_deadline(state_slice_s)
1022
+ if budget is not None else None),
1023
+ sec_per_eval=sec_per_eval)
1024
+ fut = self._pool.submit(_profile_task, job)
1025
+ futures[fut] = job
1026
+ pending.add(fut)
1027
+
1028
+ if self.verbose:
1029
+ print(f"[pool]{tag} {n_jobs} profile point(s) for "
1030
+ f"{self.n_workers} worker(s); progress every "
1031
+ f"{_fmt_dur(heartbeat_s)} until results start landing.",
1032
+ flush=True)
1033
+ if budget is not None and budget.is_limited:
1034
+ print(f"[pool]{tag} {budget.describe(state_slice_s)}",
1035
+ flush=True)
1036
+
1037
+ admit()
1038
+
1039
+ while pending:
1040
+ finished, pending = wait(pending, timeout=heartbeat_s,
1041
+ return_when=FIRST_COMPLETED)
1042
+
1043
+ if not finished:
1044
+ if self.verbose:
1045
+ now = time.time()
1046
+ msg = (f" [profile{tag}] {done}/{n_jobs} done, "
1047
+ f"{len(pending)} in flight, "
1048
+ f"{len(backlog)} not started, "
1049
+ f"{_fmt_dur(now - t0)} elapsed")
1050
+ if done:
1051
+ rate = done / max(now - t0, 1e-9)
1052
+ msg += f", ~{_fmt_dur((n_jobs - done) / rate)} remaining"
1053
+ else:
1054
+ msg += " (no point has finished yet, so no estimate)"
1055
+ if halted:
1056
+ msg += "; admitting no more work before the deadline"
1057
+ print(msg, flush=True)
1058
+ continue
1059
+
1060
+ for fut in finished:
1061
+ try:
1062
+ res = fut.result()
1063
+ except Exception as exc:
1064
+ job = futures[fut]
1065
+ res = dict(job)
1066
+ res.update({"nll": FAILURE_VALUE, "n_evals": 0, "wall_s": 0.0,
1067
+ "status": f"error: {type(exc).__name__}: {exc}"})
1068
+ results.append(res)
1069
+ done += 1
1070
+ self.n_evals += int(res.get("n_evals") or 0)
1071
+ self.total_worker_seconds += float(res.get("wall_s") or 0.0)
1072
+ if res.get("status") != "ok":
1073
+ self.n_failures += 1
1074
+ # Only a real point teaches anything about what a point costs;
1075
+ # a job that died on the way out returns wall_s 0 and would
1076
+ # drag the estimate toward zero, which is the direction that
1077
+ # admits work there is no time for.
1078
+ if budget is not None and res.get("status") == "ok":
1079
+ budget.record(res.get("wall_s"), res.get("n_evals"))
1080
+ if on_result is not None:
1081
+ on_result(res)
1082
+ if self.verbose:
1083
+ elapsed = time.time() - t0
1084
+ # An interrupted point is progress, not a problem, and the
1085
+ # log has to say so or every link will read as a run of
1086
+ # failures.
1087
+ state = " CONTINUES" if res.get("interrupted") else ""
1088
+ print(f" [profile {done}/{n_jobs}]{state} "
1089
+ f"{res.get('param_name')} "
1090
+ f"= {res.get('x_fixed_linear', res.get('x_fixed')):.4g} "
1091
+ f"dNLL={res.get('dnll', float('nan')):.4g} "
1092
+ f"({res.get('n_evals')} evals, {res.get('wall_s', 0):.0f}s)"
1093
+ f" [{_fmt_dur(elapsed)} elapsed]", flush=True)
1094
+
1095
+ admit()
1096
+
1097
+ if self.verbose:
1098
+ elapsed = max(time.time() - t0, 1e-9)
1099
+ work = sum(float(r.get("wall_s") or 0.0) for r in results)
1100
+ print(f"[pool]{tag} {done} profile points in {elapsed:.0f}s wall "
1101
+ f"({work:.0f}s of work, {work / elapsed:.1f}x)", flush=True)
1102
+
1103
+ if backlog:
1104
+ if budget is not None:
1105
+ budget.stopped_early = True
1106
+ budget.save()
1107
+ raise DeadlineReached(len(backlog), label)
1108
+ return results
1109
+
1110
+ def as_scalar_func(self):
1111
+ """A serial-looking ``f(x) -> float`` backed by the pool.
1112
+
1113
+ Convenience for code that cannot batch. It gives no speedup on its own
1114
+ (one vector at a time) -- prefer ``evaluate_batch``.
1115
+ """
1116
+ def _f(x):
1117
+ return self.evaluate_batch([x])[0]
1118
+ return _f
1119
+
1120
+
1121
+ def build_eval_spec(
1122
+ model_text, paths, events, replicates, param_names, scales, groups,
1123
+ group_normalization, fixed_sigmas, events_dynamic=False, data_path=None,
1124
+ for_inference=True, concentrated=True, preequil_cache=False,
1125
+ ):
1126
+ """Convenience constructor mirroring the spec-route local variables."""
1127
+ from Engine.Event_times import without_event_times
1128
+
1129
+ return EvalSpec(
1130
+ model_text=model_text,
1131
+ paths=paths,
1132
+ events=dict(events),
1133
+ # The parent's event-time callable closes over the parent's RoadRunner.
1134
+ # cloudpickle will happily carry it -- that is the problem. Shipped, it
1135
+ # would resolve each worker's event times against the *parent's*
1136
+ # parameter values, and during a profile the worker is by definition
1137
+ # holding a different vector. With SubCut_D1 among the fitted
1138
+ # parameters, the Gantenerumab infusion-off edges would land in the
1139
+ # wrong place in every worker, silently. It also drags a duplicate
1140
+ # RoadRunner (~13 kB per arm) through the spec for nothing.
1141
+ # _init_worker re-attaches against the model the worker compiled.
1142
+ replicates=without_event_times(replicates),
1143
+ param_names=list(param_names),
1144
+ scales=list(scales),
1145
+ groups=groups,
1146
+ group_normalization=group_normalization,
1147
+ fixed_sigmas=dict(fixed_sigmas or {}),
1148
+ events_dynamic=bool(events_dynamic),
1149
+ data_path=data_path or paths.get("data_path"),
1150
+ for_inference=bool(for_inference),
1151
+ concentrated=bool(concentrated),
1152
+ preequil_cache=bool(preequil_cache),
1153
+ )
1154
+
1155
+
1156
+ def check_spec_serializable(spec, verbose=True):
1157
+ """Round-trip the spec so serialization problems surface in the parent.
1158
+
1159
+ Without this, an unpicklable ``loss_config`` closure fails inside worker
1160
+ startup, where the traceback is far less legible.
1161
+ """
1162
+ try:
1163
+ blob = _serializer.dumps(spec)
1164
+ _serializer.loads(blob)
1165
+ if verbose:
1166
+ print(f"[pool] spec serializes cleanly via {_SERIALIZER_NAME} "
1167
+ f"({len(blob) / 1e6:.1f} MB)")
1168
+ return True, len(blob), None
1169
+ except Exception as exc:
1170
+ msg = (f"{type(exc).__name__}: {exc}. The parallel path needs every "
1171
+ f"replicate callable and loss_config to be serializable; a "
1172
+ f"closure that captures a RoadRunner or an open file will fail "
1173
+ f"here. Falling back to serial evaluation.")
1174
+ if verbose:
1175
+ print(f"[pool] spec is NOT serializable — {msg}")
1176
+ return False, 0, msg