PyAntiGen 1.0.14__tar.gz → 1.0.15__tar.gz

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 (76) hide show
  1. {pyantigen-1.0.14 → pyantigen-1.0.15}/PKG-INFO +1 -1
  2. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. pyantigen-1.0.15/PyAntiGen.egg-info/scm_version.json +8 -0
  4. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/_version.py +3 -3
  5. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Evaluator.py +21 -2
  6. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Optimize.py +204 -28
  7. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Profile_checkpoint.py +48 -9
  8. pyantigen-1.0.14/PyAntiGen.egg-info/scm_version.json +0 -8
  9. {pyantigen-1.0.14 → pyantigen-1.0.15}/.github/workflows/publish.yml +0 -0
  10. {pyantigen-1.0.14 → pyantigen-1.0.15}/.gitignore +0 -0
  11. {pyantigen-1.0.14 → pyantigen-1.0.15}/.vscode/launch.json +0 -0
  12. {pyantigen-1.0.14 → pyantigen-1.0.15}/LICENSE +0 -0
  13. {pyantigen-1.0.14 → pyantigen-1.0.15}/MANIFEST.in +0 -0
  14. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/SOURCES.txt +0 -0
  15. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  16. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/entry_points.txt +0 -0
  17. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/requires.txt +0 -0
  18. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/scm_file_list.json +0 -0
  19. {pyantigen-1.0.14 → pyantigen-1.0.15}/PyAntiGen.egg-info/top_level.txt +0 -0
  20. {pyantigen-1.0.14 → pyantigen-1.0.15}/README.md +0 -0
  21. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/.agents/skills/module_generation/SKILL.md +0 -0
  22. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  23. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  24. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/.gitignore +0 -0
  25. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/AntimonyGen.py +0 -0
  26. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/RxnDict_to_antimony.py +0 -0
  27. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/TelluriumGen.py +0 -0
  28. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/__init__.py +0 -0
  29. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/antimony_utils.py +0 -0
  30. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/cli.py +0 -0
  31. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/data_interpolation.py +0 -0
  32. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/isotopomer_tools.py +0 -0
  33. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/model_generation.py +0 -0
  34. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/models.py +0 -0
  35. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/module_base.py +0 -0
  36. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/pyantigen.py +0 -0
  37. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/rate_laws.py +0 -0
  38. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/reaction_creation.py +0 -0
  39. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/AntiGen_paths.py +0 -0
  40. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Anchor_cache.py +0 -0
  41. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Deadline.py +0 -0
  42. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Differential_evolution.py +0 -0
  43. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Event_times.py +0 -0
  44. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Fast_profile.py +0 -0
  45. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Fit_cache.py +0 -0
  46. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Identifiability.py +0 -0
  47. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Model_optimize.py +0 -0
  48. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Model_simulate.py +0 -0
  49. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Nelder_mead.py +0 -0
  50. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Noise_floor.py +0 -0
  51. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Petab_export.py +0 -0
  52. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Preequil_cache.py +0 -0
  53. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/README.md +0 -0
  54. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Results.py +0 -0
  55. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Sensitivity_analysis.py +0 -0
  56. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Engine/Simulate.py +0 -0
  57. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Flipflop_reference.py +0 -0
  58. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Model_generate.py +0 -0
  59. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Model_run.py +0 -0
  60. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Data.py +0 -0
  61. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Events.py +0 -0
  62. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Experiment.py +0 -0
  63. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Loss_config.py +0 -0
  64. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Observed_species.py +0 -0
  65. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Optimizer_settings.py +0 -0
  66. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Plots.py +0 -0
  67. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Solver_settings.py +0 -0
  68. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
  69. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/Example/Modules/Update_parameters.py +0 -0
  70. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/data/ADneg.csv +0 -0
  71. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/data/ADpos.csv +0 -0
  72. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/data/Flipflop.csv +0 -0
  73. {pyantigen-1.0.14 → pyantigen-1.0.15}/framework/template/data/make_flipflop_data.py +0 -0
  74. {pyantigen-1.0.14 → pyantigen-1.0.15}/pyproject.toml +0 -0
  75. {pyantigen-1.0.14 → pyantigen-1.0.15}/setup.cfg +0 -0
  76. {pyantigen-1.0.14 → pyantigen-1.0.15}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.14
3
+ Version: 1.0.15
4
4
  Summary: Short one-line description of what PyAntiGen does
5
5
  Author: Open Source Contributor
6
6
  Author-email: Don <you@example.com>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.14
3
+ Version: 1.0.15
4
4
  Summary: Short one-line description of what PyAntiGen does
5
5
  Author: Open Source Contributor
6
6
  Author-email: Don <you@example.com>
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.0.15",
3
+ "distance": 0,
4
+ "node": "gd95e4a4f4c02ea9c4988d93cc24ff19529147f0c",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-30"
8
+ }
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.0.14'
22
- __version_tuple__ = version_tuple = (1, 0, 14)
21
+ __version__ = version = '1.0.15'
22
+ __version_tuple__ = version_tuple = (1, 0, 15)
23
23
 
24
- __commit_id__ = commit_id = 'gd512f8305'
24
+ __commit_id__ = commit_id = 'gd95e4a4f4'
@@ -197,6 +197,10 @@ def _init_worker(spec_blob):
197
197
  replicate["Update_parameters"](
198
198
  OptRoadRunnerProxy(r, spec.param_names), replicate
199
199
  )
200
+ # Every run in this worker starts from here; see
201
+ # Optimize.restore_parameter_baseline.
202
+ from Engine.Optimize import remember_parameter_baseline
203
+ remember_parameter_baseline(r)
200
204
  # The parent's attachment closes over the parent's RoadRunner and could
201
205
  # not be shipped here, so it was stripped from the spec. A worker must
202
206
  # attach against the model it will integrate anyway -- reusing the
@@ -402,7 +406,9 @@ def _profile_task(job):
402
406
  res = None
403
407
  outcome = "done"
404
408
  if resumable:
405
- identity = point_identity(param_idx, x_fixed, x_start.size, method)
409
+ identity = point_identity(
410
+ param_idx, x_fixed, x_start.size, method,
411
+ param_name=spec.param_names[param_idx], bounds=bounds)
406
412
  nm_state = state_from_json(
407
413
  load_point_state(state_path, identity), x_start.size)
408
414
  if nm_state is not None:
@@ -642,7 +648,9 @@ class ParallelEvaluator:
642
648
  losses = ev.evaluate_batch(xs)
643
649
 
644
650
  ``evaluate_batch`` preserves input order. Failures come back as
645
- ``FAILURE_VALUE`` and are counted in ``ev.n_failures`` rather than raised.
651
+ ``FAILURE_VALUE`` and are counted in ``ev.n_failures`` rather than raised
652
+ -- except a broken pool, which raises ``BrokenProcessPool`` so the caller
653
+ can redo the batch rather than consume a half-sentinel result.
646
654
  """
647
655
 
648
656
  def __init__(self, spec, n_workers=None, chunk_size=None, verbose=True,
@@ -814,6 +822,7 @@ class ParallelEvaluator:
814
822
  self.start()
815
823
 
816
824
  from concurrent.futures import wait, FIRST_COMPLETED
825
+ from concurrent.futures.process import BrokenProcessPool
817
826
 
818
827
  t0 = time.time()
819
828
  tag = f" [{label}]" if label else ""
@@ -873,6 +882,16 @@ class ParallelEvaluator:
873
882
  i = futures[fut]
874
883
  try:
875
884
  val, status, secs = fut.result()
885
+ except BrokenProcessPool as exc:
886
+ # A dead worker (segfault, OOM kill) fails every future
887
+ # still in flight, not just its own. Scoring those as
888
+ # sentinels would hand the caller a batch that is part
889
+ # real values and part 1e10 -- a Hessian stencil built
890
+ # from that is finite and silently meaningless. Raise so
891
+ # the caller redoes the whole batch serially instead.
892
+ raise BrokenProcessPool(
893
+ f"worker pool broke during batch{tag} after "
894
+ f"{done}/{n} evaluation(s): {exc}") from exc
876
895
  except Exception as exc:
877
896
  val = FAILURE_VALUE
878
897
  status = f"error: {type(exc).__name__}: {exc}"
@@ -301,6 +301,59 @@ def _resolve_obs_df(df_dict, obs_cfg):
301
301
  )
302
302
 
303
303
 
304
+ # ---------------------------------------------------------------------------
305
+ # Global parameters an event assigned survive r.reset()
306
+ # ---------------------------------------------------------------------------
307
+ #
308
+ # RoadRunner's reset() restores time, floating species and rate-rule values,
309
+ # but NOT a global parameter that an event assigned during the previous run.
310
+ # An arm whose events switch something on and leave it on -- a labelling
311
+ # fraction, a washout rate, a catheter drain -- therefore starts every
312
+ # simulation after the first from birth with that switch already thrown, and
313
+ # the objective stops being a function of the parameters alone: the first
314
+ # evaluation differs from every later one at the same point.
315
+ #
316
+ # Measured on the Cook arms, whose end-of-infusion event leaves k_leu_washout
317
+ # at 0.125: at cook_turnover's 2026-09-29 optimum the first evaluation drew a
318
+ # vehicle NewAB40_CM peak of 1523 pM at 18.5 h and every later one 690 pM at
319
+ # 13.3 h, the label washing out from t = 0. The fit minimized the second
320
+ # function while --simulate (a fresh model, one run) draws the first.
321
+ #
322
+ # Ported from PyAntiGen v2 (cdf1fd8). Each model's global parameter values are
323
+ # recorded once, when the Engine builds it, and run_all puts back any that
324
+ # differ after every reset(). Only parameters the run changed are touched, and
325
+ # assignment-rule parameters, which cannot be set, are skipped. A model built
326
+ # elsewhere is recorded the first time run_all sees it.
327
+
328
+ def remember_parameter_baseline(r):
329
+ """Record r's settable global parameter values as its per-run start."""
330
+ ids = list(r.getGlobalParameterIds())
331
+ rules = set(r.getAssignmentRuleIds())
332
+ idx = np.array([i for i, p in enumerate(ids) if p not in rules], dtype=int)
333
+ vals = np.asarray(r.getGlobalParameterValues(), dtype=float)
334
+ r._pyantigen_param_baseline = (ids, idx, vals[idx].copy())
335
+
336
+
337
+ def restore_parameter_baseline(r):
338
+ """Put back every settable global parameter that differs from the baseline.
339
+
340
+ Returns the names restored (empty when nothing had changed).
341
+ """
342
+ base = getattr(r, "_pyantigen_param_baseline", None)
343
+ if base is None:
344
+ remember_parameter_baseline(r)
345
+ return []
346
+ ids, idx, vals = base
347
+ cur = np.asarray(r.getGlobalParameterValues(), dtype=float)[idx]
348
+ changed = np.flatnonzero(~((cur == vals) | (np.isnan(cur) & np.isnan(vals))))
349
+ names = []
350
+ for j in changed:
351
+ name = ids[idx[j]]
352
+ r[name] = float(vals[j])
353
+ names.append(name)
354
+ return names
355
+
356
+
304
357
  def run_all(r, exp_num, experiment, df_dict, set_parameters=None, parameters=None,
305
358
  preequil_cache=None):
306
359
  """
@@ -318,6 +371,9 @@ def run_all(r, exp_num, experiment, df_dict, set_parameters=None, parameters=Non
318
371
  Returns a results dict keyed by treatment label.
319
372
  """
320
373
  r.reset()
374
+ # reset() leaves event-assigned global parameters at their end-of-run
375
+ # values; see restore_parameter_baseline.
376
+ restore_parameter_baseline(r)
321
377
 
322
378
  # Re-apply treatment-specific parameters which were wiped out by r.reset()
323
379
  update_params = experiment.get("Update_parameters")
@@ -1674,7 +1730,9 @@ def compute_hessian_numdifftools(func, params):
1674
1730
  # _finite_difference_steps: 0.02 keeps the noise term 4*sigma/h^2 near 0.1 for
1675
1731
  # objective noise anywhere from 1e-7 to 1e-4 nats, while the truncation term
1676
1732
  # H*h^2/12 stays near 0.003 for curvatures of order 100. Two decades of margin
1677
- # either side, which is what a numerically noisy objective needs.
1733
+ # either side, which is what a numerically noisy objective needs. It is the
1734
+ # starting and largest step: compute_hessian_batched shrinks it per parameter
1735
+ # where the curvature is too high for it (see _FD_TARGET_RISE).
1678
1736
  _LOG_FD_STEP = 0.02
1679
1737
 
1680
1738
 
@@ -1706,6 +1764,10 @@ def _finite_difference_steps(params, epsilon=1e-4, abs_floor=None, scales=None,
1706
1764
  1e-5 nats, not 1e-16. At h=3e-4 that is an error near 450 against curvatures
1707
1765
  of order 100: the Hessian becomes noise, the matrix stops being positive
1708
1766
  definite, and sqrt(diag(inv(H))) returns NaN.
1767
+
1768
+ For log10 coordinates this is only the starting step. A parameter whose
1769
+ SE is comparable to it is probed outside its quadratic region, and
1770
+ compute_hessian_batched shrinks the step for it (_calibrate_log_steps).
1709
1771
  """
1710
1772
  params = np.atleast_1d(np.asarray(params, dtype=float))
1711
1773
  if abs_floor is None:
@@ -1727,7 +1789,101 @@ def _finite_difference_steps(params, epsilon=1e-4, abs_floor=None, scales=None,
1727
1789
  return steps
1728
1790
 
1729
1791
 
1730
- def compute_hessian_batched(nll_batch, params, epsilon=1e-4, scales=None):
1792
+ # A log10 step is sized so that one diagonal probe raises the NLL by about this
1793
+ # many nats. _LOG_FD_STEP alone is a single compromise for every parameter, and
1794
+ # it fails in the tight direction: on the Example spec two parameters with SEs
1795
+ # near 0.01 decades sat 2 SEs from the centre at a 0.02-decade step, outside
1796
+ # the quadratic region, and came back with SEs 12% and 18% off and a
1797
+ # correlation of -0.25 instead of -0.55. Sizing by the rise instead balances
1798
+ # both errors for every parameter at once: truncation is set by how far past
1799
+ # quadratic the probe reaches, which a rise of a tenth of a nat keeps small,
1800
+ # and the noise term 4*sigma/h^2 relative to the curvature is about
1801
+ # 2*sigma/rise, ~2e-4 at sigma = 1e-5 nats. At 0.1 the Example's log10 SEs
1802
+ # land within 0.3% of the exact values; at 0.2, within 0.5%.
1803
+ _FD_TARGET_RISE = 0.1
1804
+ # Smallest log10 step the calibration may choose, in decades. Below this the
1805
+ # noise term takes over again on an ODE objective.
1806
+ _LOG_FD_STEP_MIN = 1e-4
1807
+
1808
+
1809
+ def _check_stencil_values(vals, failure_value, what):
1810
+ """Raise if any stencil value is non-finite or the failure sentinel."""
1811
+ # The failure sentinel is finite, so a stencil point that failed to
1812
+ # integrate would otherwise pass straight through as a curvature of order
1813
+ # 1e10/h^2 and come out the other side as a plausible-looking SE.
1814
+ bad = ~np.isfinite(vals) | (vals >= failure_value)
1815
+ if np.any(bad):
1816
+ raise ValueError(
1817
+ f"{int(bad.sum())}/{vals.size} {what} point(s) failed to evaluate "
1818
+ f"(failure sentinel or non-finite NLL); the Hessian would be "
1819
+ f"meaningless.")
1820
+
1821
+
1822
+ def _calibrate_log_steps(nll_batch, params, steps, scales, failure_value,
1823
+ target=_FD_TARGET_RISE, min_step=_LOG_FD_STEP_MIN,
1824
+ max_passes=4):
1825
+ """Shrink each log10 step until one probe raises the NLL by ~*target* nats.
1826
+
1827
+ A pilot pass evaluates the centre and +/- the default step for every
1828
+ log10 parameter. Its mean rise r approximates H_ii h^2 / 2, so the step
1829
+ that gives a rise of *target* is h * sqrt(target / r). A step is accepted
1830
+ once its rise is at most twice *target*.
1831
+
1832
+ That estimate is taken from a probe that is, by construction, too far out,
1833
+ and on a likelihood shaped like (n/2) log(SSE) -- which flattens away from
1834
+ the optimum -- it underestimates the curvature, so a single rescaling can
1835
+ still land a probe at a rise of a nat or more. Parameters whose new step
1836
+ still rises too far are therefore probed again, up to *max_passes* passes
1837
+ in all; each extra pass costs 2 evaluations per such parameter only.
1838
+
1839
+ Steps only shrink: a parameter whose default probe rises less than the
1840
+ limit is inside its quadratic region, and there the larger step is the
1841
+ better defence against integration noise. A rise that is not positive -- a
1842
+ flat or noisy direction -- also keeps the default, as does a step that has
1843
+ reached *min_step*.
1844
+
1845
+ Returns ``(steps, known)``: the calibrated steps, and the pilot values
1846
+ ``{point_key: value}`` evaluated at them, so the stencil does not evaluate
1847
+ the same point twice.
1848
+ """
1849
+ pending = [i for i, s in enumerate(scales or [])
1850
+ if s == "log10" and i < params.size]
1851
+ if not pending:
1852
+ return steps, {}
1853
+
1854
+ steps = np.array(steps, dtype=float, copy=True)
1855
+ known = {}
1856
+ for _ in range(max_passes):
1857
+ points = [] if known else [params.copy()]
1858
+ for i in pending:
1859
+ p_plus = params.copy(); p_plus[i] += steps[i]
1860
+ p_minus = params.copy(); p_minus[i] -= steps[i]
1861
+ points += [p_plus, p_minus]
1862
+ vals = np.asarray(nll_batch(points, label="hessian-step"), dtype=float)
1863
+ _check_stencil_values(vals, failure_value, "Hessian step-calibration")
1864
+ if not known:
1865
+ known[("c",)] = float(vals[0])
1866
+ vals = vals[1:]
1867
+ f0 = known[("c",)]
1868
+
1869
+ still = []
1870
+ for k, i in enumerate(pending):
1871
+ f_plus, f_minus = float(vals[2 * k]), float(vals[2 * k + 1])
1872
+ rise = 0.5 * (f_plus + f_minus) - f0
1873
+ if rise > 2.0 * target and steps[i] > min_step:
1874
+ steps[i] = max(min_step, steps[i] * np.sqrt(target / rise))
1875
+ still.append(i)
1876
+ else:
1877
+ known[("d", i, +1)] = f_plus
1878
+ known[("d", i, -1)] = f_minus
1879
+ pending = still
1880
+ if not pending:
1881
+ break
1882
+ return steps, known
1883
+
1884
+
1885
+ def compute_hessian_batched(nll_batch, params, epsilon=1e-4, scales=None,
1886
+ failure_value=1e10):
1731
1887
  """Central-difference Hessian evaluated as one batch.
1732
1888
 
1733
1889
  The stencil is fixed in advance -- 1 centre, 2k diagonal points and 4 points
@@ -1735,41 +1891,49 @@ def compute_hessian_batched(nll_batch, params, epsilon=1e-4, scales=None):
1735
1891
  instead of trickling through numdifftools one call at a time. That is
1736
1892
  2k^2 + 1 evaluations with no dependencies, which is exactly what the pool
1737
1893
  is for.
1894
+
1895
+ Log10 steps are calibrated first (see :func:`_calibrate_log_steps`). Its
1896
+ pilot values are reused for the final steps, so a parameter whose default
1897
+ step already fits costs nothing extra, and one that had to shrink costs 2
1898
+ evaluations per calibration pass.
1738
1899
  """
1739
1900
  params = np.atleast_1d(np.asarray(params, dtype=float))
1740
1901
  n = params.size
1741
1902
  steps = _finite_difference_steps(params, epsilon, scales=scales)
1903
+ steps, known = _calibrate_log_steps(nll_batch, params, steps, scales,
1904
+ failure_value)
1742
1905
 
1743
- points = [params.copy()] # index 0: centre
1744
- index = {}
1745
-
1906
+ # Every stencil point by key; the ones the calibration already evaluated
1907
+ # are filled in from it, the rest go to the pool in one batch.
1908
+ keys, points = [("c",)], [params.copy()]
1746
1909
  for i in range(n):
1747
- p_plus = params.copy(); p_plus[i] += steps[i]
1748
- p_minus = params.copy(); p_minus[i] -= steps[i]
1749
- index[("d", i)] = (len(points), len(points) + 1)
1750
- points += [p_plus, p_minus]
1751
-
1910
+ for sign in (+1, -1):
1911
+ p = params.copy(); p[i] += sign * steps[i]
1912
+ keys.append(("d", i, sign)); points.append(p)
1752
1913
  for i in range(n):
1753
1914
  for j in range(i + 1, n):
1754
- pp = params.copy(); pp[i] += steps[i]; pp[j] += steps[j]
1755
- pm = params.copy(); pm[i] += steps[i]; pm[j] -= steps[j]
1756
- mp = params.copy(); mp[i] -= steps[i]; mp[j] += steps[j]
1757
- mm = params.copy(); mm[i] -= steps[i]; mm[j] -= steps[j]
1758
- index[("o", i, j)] = tuple(range(len(points), len(points) + 4))
1759
- points += [pp, pm, mp, mm]
1760
-
1761
- vals = np.asarray(nll_batch(points, label="hessian"), dtype=float)
1762
- f0 = vals[0]
1915
+ for si, sj in ((+1, +1), (+1, -1), (-1, +1), (-1, -1)):
1916
+ p = params.copy(); p[i] += si * steps[i]; p[j] += sj * steps[j]
1917
+ keys.append(("o", i, j, si, sj)); points.append(p)
1918
+
1919
+ todo = [k for k, key in enumerate(keys) if key not in known]
1920
+ fresh = np.asarray(nll_batch([points[k] for k in todo], label="hessian"),
1921
+ dtype=float)
1922
+ _check_stencil_values(fresh, failure_value, "Hessian stencil")
1923
+ val = dict(known)
1924
+ val.update({keys[k]: float(v) for k, v in zip(todo, fresh)})
1925
+ f0 = val[("c",)]
1763
1926
 
1764
1927
  hessian = np.zeros((n, n))
1765
1928
  for i in range(n):
1766
- a, b = index[("d", i)]
1767
- hessian[i, i] = (vals[a] - 2.0 * f0 + vals[b]) / (steps[i] ** 2)
1929
+ hessian[i, i] = (val[("d", i, +1)] - 2.0 * f0
1930
+ + val[("d", i, -1)]) / (steps[i] ** 2)
1768
1931
  for i in range(n):
1769
1932
  for j in range(i + 1, n):
1770
- a, b, c, d = index[("o", i, j)]
1771
- val = (vals[a] - vals[b] - vals[c] + vals[d]) / (4.0 * steps[i] * steps[j])
1772
- hessian[i, j] = hessian[j, i] = val
1933
+ h_ij = (val[("o", i, j, +1, +1)] - val[("o", i, j, +1, -1)]
1934
+ - val[("o", i, j, -1, +1)] + val[("o", i, j, -1, -1)]
1935
+ ) / (4.0 * steps[i] * steps[j])
1936
+ hessian[i, j] = hessian[j, i] = h_ij
1773
1937
  return hessian
1774
1938
 
1775
1939
 
@@ -1875,11 +2039,20 @@ def compute_wald_uncertainty(nll_func, x, bounds=None, loss_scale=1.0, alpha=0.0
1875
2039
  print(f"\n[Wald] Computing Hessian for {k} parameter(s) "
1876
2040
  f"(~{n_evals} silent NLL evaluations)...")
1877
2041
 
2042
+ if nll_batch is None:
2043
+ # Serial runs (no worker pool) go through the same stencil as pooled
2044
+ # ones, evaluated one point at a time. The numdifftools route used to
2045
+ # be taken here, with a fixed absolute step of 1e-5 and no knowledge of
2046
+ # parameter scales: far below the ODE objective's numerical noise for a
2047
+ # log10 coordinate, and larger than the parameter itself for a small
2048
+ # linear rate constant -- the two failures _finite_difference_steps
2049
+ # exists to prevent. It also bypassed the failure-sentinel check, and
2050
+ # made a serial run's SEs incomparable with a pooled run's.
2051
+ def nll_batch(xs, label=None):
2052
+ return [nll_func(np.asarray(xi, dtype=float)) for xi in xs]
2053
+
1878
2054
  try:
1879
- if nll_batch is not None:
1880
- hessian_raw = compute_hessian_batched(nll_batch, x, scales=scales)
1881
- else:
1882
- hessian_raw = compute_hessian_numdifftools(nll_func, x)
2055
+ hessian_raw = compute_hessian_batched(nll_batch, x, scales=scales)
1883
2056
  except Exception as exc:
1884
2057
  print(f"[Wald] Hessian computation failed: {exc}")
1885
2058
  return None, None, nan_ci
@@ -5575,6 +5748,7 @@ def run_optimization(
5575
5748
  r = TelluriumGen(model_text + "\n" + events_str, paths)
5576
5749
  r_proxy = OptRoadRunnerProxy(r, param_names)
5577
5750
  experiment["Update_parameters"](r_proxy, experiment)
5751
+ remember_parameter_baseline(r)
5578
5752
  attach_event_times(experiment, r, verbose=True)
5579
5753
 
5580
5754
  models[exp_num] = {"r_ic": r_ic, "r": r, "df_dict": df_dict}
@@ -6015,6 +6189,8 @@ def run_optimization_from_groups(
6015
6189
  r = TelluriumGen(model_text + "\n" + events_str, paths)
6016
6190
  r_proxy = OptRoadRunnerProxy(r, param_names)
6017
6191
  replicate["Update_parameters"](r_proxy, replicate)
6192
+ # Before anything integrates it: every later run starts from here.
6193
+ remember_parameter_baseline(r)
6018
6194
 
6019
6195
  # Where this arm's discontinuities are, read off the compiled model.
6020
6196
  # Attached rather than computed, because a trigger built on a fitted
@@ -116,7 +116,7 @@ def point_state_path(state_dir, param_name, x_fixed):
116
116
  return os.path.join(state_dir, _safe_name(f"{param_name}__{key!r}") + ".json")
117
117
 
118
118
 
119
- def point_identity(param_idx, x_fixed, n_nuisance, method):
119
+ def point_identity(param_idx, x_fixed, n_nuisance, method, param_name, bounds):
120
120
  """What a saved state must match to be resumed by a job.
121
121
 
122
122
  Deliberately not the job's starting point or its simplex: those differ
@@ -124,10 +124,42 @@ def point_identity(param_idx, x_fixed, n_nuisance, method):
124
124
  stopped on the clock is resumed from a record, not from the job that began
125
125
  it), and a state is the more exact of the two resume sources whenever it
126
126
  exists. What it must never be is another *problem*.
127
+
128
+ THAT GUARANTEE WAS MISSING until 2026-09-25. ``param_idx`` is a POSITION,
129
+ not an identity: a reparametrization that keeps the same total and
130
+ nuisance parameter counts but changes what occupies each slot leaves
131
+ ``param_idx``, ``n`` and ``method`` all unchanged while the meaning of
132
+ every nuisance coordinate changes underneath them. This happened for
133
+ real the same day -- four independent k_gammasec_*_base constants
134
+ (silk_appfull, slots 5-8) were replaced by Vm_C99_Abeta_base and the
135
+ softmax logits y_gammaAB38/40/42, same 18 total, same 17 nuisance, same
136
+ slots -- and a state resumed from before the change into a run after it
137
+ would have been accepted, with the incumbent vertex it carried never
138
+ revalidated against the new bounds (``Engine.Nelder_mead.state_from_json``
139
+ checks shape, not bounds; only a *fresh* ``new_state`` clips). ``bounds``
140
+ closes the same gap for a bounds-only change: :func:`spec_fingerprint`
141
+ does not hash bounds at all, so tightening or correcting one does not
142
+ start a new checkpoint directory on its own, and nothing else stood
143
+ between an old, wider-bounded simplex and a run enforcing a new bound.
144
+
145
+ ``param_name`` and ``bounds`` are therefore folded into the identity
146
+ below as a hash, not compared field by field: their exact representation
147
+ (a numpy dtype, a tuple vs a list) can differ harmlessly between the run
148
+ that wrote a state and the one resuming it, and a hash of their JSON form
149
+ is stable across that while still changing the moment either actually
150
+ means something different.
127
151
  """
128
- return {"param_idx": int(param_idx),
129
- "x_fixed": round(float(x_fixed), 12),
130
- "n": int(n_nuisance), "method": str(method).lower()}
152
+ payload = {"param_idx": int(param_idx),
153
+ "x_fixed": round(float(x_fixed), 12),
154
+ "n": int(n_nuisance), "method": str(method).lower()}
155
+ blob = json.dumps({
156
+ "param_name": str(param_name),
157
+ "bounds": [None if b is None else
158
+ [None if v is None else round(float(v), 12) for v in b]
159
+ for b in bounds] if bounds is not None else None,
160
+ }, sort_keys=True)
161
+ payload["fingerprint"] = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
162
+ return payload
131
163
 
132
164
 
133
165
  def save_point_state(path, identity, nm_state):
@@ -338,11 +370,18 @@ def spec_fingerprint(param_names, x_opt, groups, scales, model_text,
338
370
  # sigma profiled out analytically. It is the same function the
339
371
  # fit minimizes, so its dNLL is on a different scale again and
340
372
  # v1/v2 points must not be resumed into a v3 run.
341
- "likelihood_convention": "v3-concentrated-gaussian",
342
- # Under v3 sigma is profiled out per evaluation rather than frozen,
343
- # so these no longer enter dNLL. They are still hashed because they
344
- # are a compact fingerprint of the residuals at the optimum, which
345
- # does change whenever the fit lands somewhere else.
373
+ # v4 keeps v3 for the fit, Wald and slices, but evaluates every
374
+ # profile point with each sigma-floored block pinned at its
375
+ # own sigma_used from the fit (Optimize._freeze_floor), and
376
+ # measures dNLL from an anchor computed the same way. v3
377
+ # points re-concentrated those sigmas at every point, so they
378
+ # sit on a different curve and must not be resumed into v4.
379
+ "likelihood_convention": "v4-concentrated-gaussian-frozen-floor",
380
+ # The fit's per-block sigmas. Unfloored blocks are still profiled
381
+ # out per evaluation, but floored blocks are frozen at these values
382
+ # for every profile point, so they now enter dNLL directly -- a
383
+ # different sigma is a different profile. Also a compact
384
+ # fingerprint of the residuals at the optimum.
346
385
  "sigmas": sorted(
347
386
  (str(k), round(float(v), 12))
348
387
  for k, v in (fixed_sigmas or {}).items()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "1.0.14",
3
- "distance": 0,
4
- "node": "gd512f83056b8ddfb329244c8f23a6b90a2408735",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-09-23"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes