PyAntiGen 1.0.12__tar.gz → 1.0.13__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 (73) hide show
  1. {pyantigen-1.0.12 → pyantigen-1.0.13}/PKG-INFO +1 -1
  2. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. pyantigen-1.0.13/PyAntiGen.egg-info/scm_version.json +8 -0
  4. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/_version.py +3 -3
  5. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Evaluator.py +107 -35
  6. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Fast_profile.py +22 -3
  7. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Identifiability.py +92 -44
  8. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Optimize.py +211 -9
  9. pyantigen-1.0.12/PyAntiGen.egg-info/scm_version.json +0 -8
  10. {pyantigen-1.0.12 → pyantigen-1.0.13}/.github/workflows/publish.yml +0 -0
  11. {pyantigen-1.0.12 → pyantigen-1.0.13}/.gitignore +0 -0
  12. {pyantigen-1.0.12 → pyantigen-1.0.13}/.vscode/launch.json +0 -0
  13. {pyantigen-1.0.12 → pyantigen-1.0.13}/LICENSE +0 -0
  14. {pyantigen-1.0.12 → pyantigen-1.0.13}/MANIFEST.in +0 -0
  15. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/SOURCES.txt +0 -0
  16. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  17. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/entry_points.txt +0 -0
  18. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/requires.txt +0 -0
  19. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/scm_file_list.json +0 -0
  20. {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/top_level.txt +0 -0
  21. {pyantigen-1.0.12 → pyantigen-1.0.13}/README.md +0 -0
  22. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/module_generation/SKILL.md +0 -0
  23. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  24. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  25. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.gitignore +0 -0
  26. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/AntimonyGen.py +0 -0
  27. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/RxnDict_to_antimony.py +0 -0
  28. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/TelluriumGen.py +0 -0
  29. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/__init__.py +0 -0
  30. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/antimony_utils.py +0 -0
  31. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/cli.py +0 -0
  32. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/data_interpolation.py +0 -0
  33. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/isotopomer_tools.py +0 -0
  34. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/model_generation.py +0 -0
  35. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/models.py +0 -0
  36. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/module_base.py +0 -0
  37. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/pyantigen.py +0 -0
  38. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/rate_laws.py +0 -0
  39. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/reaction_creation.py +0 -0
  40. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/AntiGen_paths.py +0 -0
  41. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Anchor_cache.py +0 -0
  42. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Deadline.py +0 -0
  43. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Event_times.py +0 -0
  44. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Fit_cache.py +0 -0
  45. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Model_optimize.py +0 -0
  46. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Model_simulate.py +0 -0
  47. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Nuisance_sensitivity.py +0 -0
  48. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Petab_export.py +0 -0
  49. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Preequil_cache.py +0 -0
  50. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Profile_checkpoint.py +0 -0
  51. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Results.py +0 -0
  52. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Sensitivity_analysis.py +0 -0
  53. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Simulate.py +0 -0
  54. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Flipflop_reference.py +0 -0
  55. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Model_generate.py +0 -0
  56. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Model_run.py +0 -0
  57. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Data.py +0 -0
  58. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Events.py +0 -0
  59. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Experiment.py +0 -0
  60. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Loss_config.py +0 -0
  61. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Observed_species.py +0 -0
  62. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Optimizer_settings.py +0 -0
  63. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Plots.py +0 -0
  64. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Solver_settings.py +0 -0
  65. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
  66. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Update_parameters.py +0 -0
  67. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/ADneg.csv +0 -0
  68. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/ADpos.csv +0 -0
  69. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/Flipflop.csv +0 -0
  70. {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/make_flipflop_data.py +0 -0
  71. {pyantigen-1.0.12 → pyantigen-1.0.13}/pyproject.toml +0 -0
  72. {pyantigen-1.0.12 → pyantigen-1.0.13}/setup.cfg +0 -0
  73. {pyantigen-1.0.12 → pyantigen-1.0.13}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.12
3
+ Version: 1.0.13
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.12
3
+ Version: 1.0.13
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.13",
3
+ "distance": 0,
4
+ "node": "ga5c2cee6bb58358aeefd1f48b50c73b952bfa343",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-17"
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.12'
22
- __version_tuple__ = version_tuple = (1, 0, 12)
21
+ __version__ = version = '1.0.13'
22
+ __version_tuple__ = version_tuple = (1, 0, 13)
23
23
 
24
- __commit_id__ = commit_id = 'g3450403b3'
24
+ __commit_id__ = commit_id = 'ga5c2cee6b'
@@ -208,7 +208,7 @@ def _init_worker(spec_blob):
208
208
  f"{time.time() - t0:.1f}s", flush=True)
209
209
 
210
210
 
211
- def _worker_nll(x):
211
+ def _worker_nll(x, frozen_sigmas=None):
212
212
  """The joint NLL, evaluated with this worker's own compiled models."""
213
213
  from Engine.Optimize import evaluate_nll_fixed
214
214
 
@@ -221,17 +221,18 @@ def _worker_nll(x):
221
221
  events_dynamic=spec.events_dynamic, failure_value=FAILURE_VALUE,
222
222
  for_inference=getattr(spec, "for_inference", True),
223
223
  concentrated=getattr(spec, "concentrated", True),
224
+ frozen_sigmas=frozen_sigmas,
224
225
  )
225
226
 
226
227
 
227
- def _eval_task(x):
228
+ def _eval_task(x, frozen_sigmas=None):
228
229
  """Evaluate one parameter vector. Never raises across the pool boundary."""
229
230
  if _WORKER["spec"] is None:
230
231
  return (FAILURE_VALUE, "worker-not-initialized", 0.0)
231
232
 
232
233
  t0 = time.time()
233
234
  try:
234
- val = _worker_nll(x)
235
+ val = _worker_nll(x, frozen_sigmas=frozen_sigmas)
235
236
  _WORKER["n_evals"] += 1
236
237
  status = "ok" if np.isfinite(val) and val < FAILURE_VALUE else "sentinel"
237
238
  return (float(val), status, time.time() - t0)
@@ -522,7 +523,17 @@ def _profile_task(job):
522
523
  cache = {}
523
524
  cache_order = []
524
525
 
525
- raw_objective = _make_nuisance_objective(_worker_nll, param_idx, n_params)
526
+ # Every profile point pins each floored block at its own sigma_used
527
+ # from the fit rather than letting it re-concentrate (see
528
+ # Engine.Optimize._freeze_floor) -- stamped onto the job by
529
+ # profile_batch's frozen_sigmas, not decided here, so the caller
530
+ # controls it per batch.
531
+ frozen = job.get("frozen_sigmas")
532
+
533
+ def _pinned_nll(x_full):
534
+ return _worker_nll(x_full, frozen_sigmas=frozen)
535
+
536
+ raw_objective = _make_nuisance_objective(_pinned_nll, param_idx, n_params)
526
537
 
527
538
  def nuisance_objective(x_nuisance, fixed_val):
528
539
  x_arr = np.asarray(x_nuisance, dtype=float)
@@ -877,32 +888,50 @@ class ParallelEvaluator:
877
888
 
878
889
  # -- evaluation --------------------------------------------------------
879
890
 
880
- def evaluate_batch(self, xs, label=None):
881
- """Evaluate every parameter vector in *xs*; return losses in input order."""
891
+ def evaluate_batch(self, xs, label=None, heartbeat_s=_HEARTBEAT_SECONDS,
892
+ frozen_sigmas=None):
893
+ """Evaluate every parameter vector in *xs*; return losses in input order.
894
+
895
+ Uses submit/wait, not map -- see profile_batch's docstring for the
896
+ general reasoning. map() (the previous implementation here) returns
897
+ nothing until the WHOLE batch is done, so one slow straggler among
898
+ many fast points -- a slice-screen point far from the optimum landing
899
+ in a stiff numerical regime, say -- makes the entire batch silent for
900
+ as long as that one point takes, indistinguishable from a hang. A
901
+ heartbeat every heartbeat_s while nothing has landed answers that
902
+ directly: it says how many are done, how many are still in flight, and
903
+ an ETA once at least one has finished.
904
+
905
+ chunk_size no longer applies to this method: submitting one task per
906
+ vector is what makes the heartbeat and per-point completion visibility
907
+ possible at all, and no caller in this codebase sets chunk_size to
908
+ anything but the default anyway.
909
+
910
+ Still returns losses in INPUT order, not completion order -- unlike
911
+ profile_batch, whose callers key off fields in each job/result dict,
912
+ callers here (the slice screen especially) index into the return value
913
+ positionally.
914
+ """
882
915
  xs = [np.asarray(x, dtype=float) for x in xs]
883
- if not xs:
916
+ n = len(xs)
917
+ if n == 0:
884
918
  return []
885
919
  if self._pool is None:
886
920
  self.start()
887
921
 
888
- chunk = self.chunk_size
889
- if chunk is None:
890
- # Enough chunks to keep every worker fed, few enough to avoid
891
- # per-task overhead dominating.
892
- chunk = max(1, len(xs) // (self.n_workers * 4) or 1)
922
+ from concurrent.futures import wait, FIRST_COMPLETED
893
923
 
894
924
  t0 = time.time()
925
+ tag = f" [{label}]" if label else ""
895
926
  if self.verbose:
896
- # map() returns nothing until the whole batch is done, so this line
897
- # is the only warning the caller gets that the next stretch of
898
- # silence is expected. Individual points are reported by
899
- # profile_batch; this path deliberately trades that for chunking.
900
- tag = f" [{label}]" if label else ""
901
- print(f"[pool]{tag} {len(xs)} evaluation(s) submitted to "
902
- f"{self.n_workers} worker(s) in chunks of {chunk}; "
903
- f"no output until the batch completes.", flush=True)
927
+ print(f"[pool]{tag} {n} evaluation(s) submitted to "
928
+ f"{self.n_workers} worker(s); progress every "
929
+ f"{_fmt_dur(heartbeat_s)} until results start landing.",
930
+ flush=True)
931
+
904
932
  try:
905
- out = list(self._pool.map(_eval_task, xs, chunksize=chunk))
933
+ futures = {self._pool.submit(_eval_task, x, frozen_sigmas): i
934
+ for i, x in enumerate(xs)}
906
935
  except RuntimeError as exc:
907
936
  if "bootstrapping phase" in str(exc):
908
937
  # spawn re-imports the __main__ module in every worker. If the
@@ -922,15 +951,46 @@ class ParallelEvaluator:
922
951
  ) from exc
923
952
  raise
924
953
 
925
- losses = []
954
+ pending = set(futures)
955
+ out = [None] * n
956
+ work = 0.0
957
+ done = 0
926
958
  failures = []
927
- for i, (val, status, secs) in enumerate(out):
928
- losses.append(val)
929
- self.total_worker_seconds += secs
930
- if status != "ok":
931
- failures.append((i, status))
932
959
 
933
- self.n_evals += len(xs)
960
+ while pending:
961
+ finished, pending = wait(pending, timeout=heartbeat_s,
962
+ return_when=FIRST_COMPLETED)
963
+
964
+ if not finished:
965
+ if self.verbose:
966
+ now = time.time()
967
+ msg = (f" [pool{tag}] {done}/{n} done, "
968
+ f"{len(pending)} in flight, "
969
+ f"{_fmt_dur(now - t0)} elapsed")
970
+ if done:
971
+ rate = done / max(now - t0, 1e-9)
972
+ msg += f", ~{_fmt_dur((n - done) / rate)} remaining"
973
+ else:
974
+ msg += " (no point has finished yet, so no estimate)"
975
+ print(msg, flush=True)
976
+ continue
977
+
978
+ for fut in finished:
979
+ i = futures[fut]
980
+ try:
981
+ val, status, secs = fut.result()
982
+ except Exception as exc:
983
+ val = FAILURE_VALUE
984
+ status = f"error: {type(exc).__name__}: {exc}"
985
+ secs = 0.0
986
+ out[i] = val
987
+ work += secs
988
+ self.total_worker_seconds += secs
989
+ if status != "ok":
990
+ failures.append((i, status))
991
+ done += 1
992
+
993
+ self.n_evals += n
934
994
  self.n_failures += len(failures)
935
995
 
936
996
  if self.verbose:
@@ -939,22 +999,31 @@ class ParallelEvaluator:
939
999
  # wall time is the speedup actually realized. On the first batch it
940
1000
  # includes worker startup, so it understates steady-state throughput
941
1001
  # -- report both numbers rather than one flattering one.
942
- work = sum(o[2] for o in out)
943
- tag = f" [{label}]" if label else ""
944
- print(f"[pool]{tag} {len(xs)} evals in {elapsed:.1f}s wall "
1002
+ print(f"[pool]{tag} {n} evals in {elapsed:.1f}s wall "
945
1003
  f"({work:.1f}s of work, {work / elapsed:.1f}x, "
946
- f"{len(xs) / elapsed:.1f} eval/s)", flush=True)
1004
+ f"{n / elapsed:.1f} eval/s)", flush=True)
947
1005
  if failures:
948
1006
  shown = "; ".join(f"#{i}: {s}" for i, s in failures[:3])
949
1007
  more = f" (+{len(failures) - 3} more)" if len(failures) > 3 else ""
950
1008
  print(f"[pool]{tag} {len(failures)} failed — {shown}{more}", flush=True)
951
1009
 
952
- return losses
1010
+ return out
953
1011
 
954
1012
  def profile_batch(self, jobs, on_result=None, label=None,
955
- heartbeat_s=_HEARTBEAT_SECONDS, budget=None):
1013
+ heartbeat_s=_HEARTBEAT_SECONDS, budget=None,
1014
+ frozen_sigmas=None):
956
1015
  """Run profile-likelihood points in parallel, within a wall budget.
957
1016
 
1017
+ ``frozen_sigmas``, stamped onto every job here rather than left to
1018
+ each caller's job-building code, is a ``{(block_key_or_exp_id,
1019
+ obs_label): sigma_used}`` lookup (see ``Engine.Optimize.
1020
+ block_sigmas``) pinning each data-floored block found in it at its own
1021
+ resolved sigma for the point's whole nuisance re-optimization, instead
1022
+ of letting it re-concentrate as the nuisance vector moves -- see
1023
+ ``Engine.Optimize._freeze_floor``. Every profile pass submitted
1024
+ through one ``batch()`` closure gets it uniformly this way, with no
1025
+ change needed at the individual job-building sites.
1026
+
958
1027
  Unlike ``evaluate_batch`` this uses submit/wait rather than map, because
959
1028
  each job is minutes to hours long and results must be checkpointed *as
960
1029
  they land* -- the whole point of checkpointing is that killing the run
@@ -994,7 +1063,10 @@ class ParallelEvaluator:
994
1063
 
995
1064
  t0 = time.time()
996
1065
  results = []
997
- backlog = list(jobs)
1066
+ # Stamped once here, not per admitted job: unlike deadline/sec_per_eval
1067
+ # this does not depend on the clock, so every job in the batch gets it
1068
+ # up front.
1069
+ backlog = [dict(j, frozen_sigmas=frozen_sigmas) for j in jobs]
998
1070
  futures = {}
999
1071
  pending = set()
1000
1072
  n_jobs = len(jobs)
@@ -281,13 +281,27 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
281
281
  n_rounds=DEFAULT_ROUNDS,
282
282
  near_zero_frac=DEFAULT_NEAR_ZERO_FRAC,
283
283
  span_decades=SPAN_DECADES,
284
- min_reach_decades=MIN_REACH_DECADES, verbose=True):
284
+ min_reach_decades=MIN_REACH_DECADES, verbose=True,
285
+ nll_at_optimum_profile=None):
285
286
  """Screen, then one capped profile point per crossed side, in rounds.
286
287
 
287
288
  *batch* has the profile_batch signature: ``batch(jobs, on_result, label)``.
288
289
  *nll_batch* evaluates a list of full parameter vectors, for the screen.
289
290
  Returns the report dict; :func:`print_fast_profile_report` renders it and
290
291
  :func:`fast_profile_summary` shrinks it for the results snapshot.
292
+
293
+ *nll_at_optimum* anchors the screen, which goes through *nll_batch* and so
294
+ is never sigma-frozen (see ``Engine.Optimize._freeze_floor``).
295
+ *nll_at_optimum_profile* anchors the profile points that come back through
296
+ *batch*, which the caller submits with each floored block's sigma pinned
297
+ at its own ``sigma_used`` from the fit (``frozen_sigmas=sigma_by_block``,
298
+ not the raw floor -- see ``Engine.Optimize._freeze_floor``). Reusing the
299
+ unfrozen anchor for a frozen point's dNLL would compare two different
300
+ objectives, and if freezing were done at the raw floor instead of the
301
+ block's own resolved sigma that mismatch could run to hundreds of nats
302
+ even a fraction of a Wald SE from the optimum, since most floored blocks
303
+ are not binding at a good fit. Defaults to *nll_at_optimum* when not
304
+ given, which is correct whenever the caller submits unfrozen points too.
291
305
  """
292
306
  from Engine.Optimize import _cold_simplex, _param_bounds
293
307
 
@@ -295,6 +309,8 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
295
309
  n = len(param_names)
296
310
  scales = list(scales) if scales is not None else ["lin"] * n
297
311
  t_start = time.time()
312
+ if nll_at_optimum_profile is None:
313
+ nll_at_optimum_profile = nll_at_optimum
298
314
 
299
315
  # ── The screen, reused when this fit already has one ──────────────────
300
316
  screen = load_screen(ckpt_dir, param_names, res_x, threshold,
@@ -411,7 +427,7 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
411
427
  nll = res.get("nll")
412
428
  ok = (res.get("status") == "ok" and nll is not None
413
429
  and np.isfinite(nll) and nll < FAILURE_VALUE)
414
- d = float(nll) - float(nll_at_optimum) if ok else None
430
+ d = float(nll) - float(nll_at_optimum_profile) if ok else None
415
431
  prev = side["dnll"]
416
432
  side["status"] = res.get("status")
417
433
  side["rounds"].append({
@@ -525,7 +541,10 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
525
541
 
526
542
  report = {
527
543
  "threshold": float(threshold),
528
- "anchor": float(nll_at_optimum),
544
+ # The anchor every dNLL in this report was actually measured against
545
+ # -- the frozen one, since every round's points come back through
546
+ # batch(). Not the same number as the screen's own anchor above.
547
+ "anchor": float(nll_at_optimum_profile),
529
548
  "res_x": [float(v) for v in res_x],
530
549
  "param_names": list(param_names),
531
550
  "round_evals": int(round_evals),
@@ -133,6 +133,17 @@ class UnidentifiableParameters(RuntimeError):
133
133
  )
134
134
 
135
135
 
136
+ def _is_usable(p):
137
+ """Whether a slice point carries real information rather than a failure
138
+ sentinel. Shared by :func:`_verdict` (to find the true furthest usable
139
+ point) and :func:`run_slice_screen` (to decide whether a ladder's
140
+ outermost rung already settles a side) so the two never disagree about
141
+ what counts as a real crossing. ``nll < FAILURE_VALUE`` matters because
142
+ the sentinel is a large *finite* number, not inf or nan -- a failed
143
+ integration must not read as "crossed"."""
144
+ return np.isfinite(p["dnll"]) and p["nll"] < FAILURE_VALUE
145
+
146
+
136
147
  def _se_for(wald_se, param_idx):
137
148
  """The Wald SE for one parameter in optimizer space, or None.
138
149
 
@@ -313,14 +324,11 @@ def _verdict(points, bound, threshold, p_opt, sign, is_log,
313
324
  to be spent there. It is recorded rather than consumed: wiring it into the
314
325
  profile's opening grid is a separate change.
315
326
  """
316
- def _usable(p):
317
- return np.isfinite(p["dnll"]) and p["nll"] < FAILURE_VALUE
318
-
319
- finite = [p for p in points if _usable(p)]
327
+ finite = [p for p in points if _is_usable(p)]
320
328
 
321
329
  inner = None
322
330
  for p in points: # points are ordered outward
323
- if not _usable(p) or p["dnll"] > threshold:
331
+ if not _is_usable(p) or p["dnll"] > threshold:
324
332
  break
325
333
  inner = p["x_linear"]
326
334
 
@@ -389,25 +397,46 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
389
397
  max_points=6, growth=2.0, range_factor=2.0,
390
398
  span_decades=SPAN_DECADES,
391
399
  min_reach_decades=MIN_REACH_DECADES, verbose=True):
392
- """Evaluate every parameter's slice out across decades, as one batch.
393
-
394
- A slice point has no dependency on any other, so all of them go out
395
- together: this is the cheapest possible use of the pool, one evaluation per
396
- point with no optimizer wrapped around it.
397
-
398
- That shape has a second use. If evaluations far from the fitted values are
399
- pathologically slow -- the region where the integrator struggles and
400
- ``safe_simulate`` enters its retry ladder -- this finds out in minutes, and
401
- says so plainly, instead of the run discovering it hours into a profile
402
- batch where every stuck evaluation is buried inside a nuisance
403
- minimization.
400
+ """Evaluate every parameter's slice out across decades, stopping each side
401
+ the moment it crosses.
402
+
403
+ The screen's job is to hand the profile a starting point, not to walk
404
+ every side all the way out. So the candidate ladder for each side --
405
+ :func:`screen_values`, unchanged -- is submitted one round at a time: round
406
+ *r* asks the pool for every side's *r*-th candidate, all still-active
407
+ sides together in one batch, which keeps this as parallel as the old
408
+ single-batch version was. A side is retired the instant one of its points
409
+ reads ``dnll > threshold`` and never contributes another evaluation --
410
+ :func:`_verdict` reads a side's state from whichever point is furthest out
411
+ among those actually evaluated, so nothing is lost by stopping there. A
412
+ side that never crosses keeps going, round after round, all the way to its
413
+ candidate list's last point -- the declared bound or ``span_decades`` out,
414
+ whichever is further, see :func:`screen_target` -- exactly as before,
415
+ because that is the one thing this screen has to be able to prove.
416
+
417
+ Why stopping matters now and did not always: once a block's sigma is
418
+ capped at a data-derived floor (``sigma_floor_from_data``), a point far
419
+ outside the fitted region can return a genuinely enormous NLL rather than
420
+ a merely large one -- the self-forgiving log(sigma) term that used to
421
+ compress a bad far-out fit is exactly what the floor removes -- and that
422
+ region is also where the integrator is slowest. A declared bound is a
423
+ user-supplied number with no guaranteed relationship to anything physical,
424
+ so nothing about it -- not "it is inside the box", not "it is only one
425
+ point" -- is safe to evaluate unconditionally. The only bound that is safe
426
+ is not asking for a point once its side has already answered the question.
427
+
428
+ The one thing round-by-round stopping gives up: a slice that crosses early
429
+ and then dips back below threshold at a point it never reaches would have
430
+ been read as "open" by a full evaluation, and reads "crossed" here
431
+ instead. That trades a slower profile on a rare, specific slice shape for
432
+ never paying an unbounded evaluation on the common one.
404
433
  """
405
434
  from Engine.Optimize import _param_bounds
406
435
 
407
436
  res_x = np.asarray(res_x, dtype=float)
408
437
  scales = list(scales) if scales is not None else ["lin"] * len(param_names)
409
438
 
410
- plan, xs = [], []
439
+ plan = []
411
440
  for i, name in enumerate(param_names):
412
441
  is_log = scales[i] == "log10"
413
442
  lb, ub = _param_bounds(bounds, i)
@@ -418,21 +447,48 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
418
447
  range_factor=range_factor,
419
448
  span_decades=span_decades)
420
449
  plan.append({"index": i, "name": name, "side": side, "sign": sign,
421
- "is_log": is_log, "values": vals,
422
- "p_opt": float(res_x[i]),
423
- "bound": (lb if sign < 0 else ub)})
424
- for v in vals:
425
- x = res_x.copy()
426
- x[i] = v
427
- xs.append(x)
450
+ "is_log": is_log, "values": vals, "points": [],
451
+ "settled": False,
452
+ "p_opt": float(res_x[i]),
453
+ "bound": (lb if sign < 0 else ub)})
428
454
 
455
+ n_candidates = sum(len(e["values"]) for e in plan)
456
+ max_rounds = max((len(e["values"]) for e in plan), default=0)
429
457
  if verbose:
430
458
  print(f"\n[screen] slice screen: {len(param_names)} parameter(s) x 2 "
431
- f"side(s) = {len(xs)} evaluation(s), submitted as one batch. "
432
- f"No nuisance optimization: each point is an upper bound on the "
433
- f"profile, which is all the screen needs.", flush=True)
434
-
435
- values = list(nll_batch(xs, label="slice-screen")) if xs else []
459
+ f"side(s); up to {n_candidates} evaluation(s) across at most "
460
+ f"{max_rounds} round(s), one round per pool submission. A side "
461
+ f"stops the round it first crosses dNLL={threshold:g}; only "
462
+ f"sides still undecided pay for the next, further-out point. "
463
+ f"No nuisance optimization: each point is an upper bound on "
464
+ f"the profile, which is all the screen needs.", flush=True)
465
+
466
+ n_evaluations = 0
467
+ for r in range(max_rounds):
468
+ round_xs, round_owner = [], []
469
+ for entry in plan:
470
+ if entry["settled"] or r >= len(entry["values"]):
471
+ continue
472
+ v = entry["values"][r]
473
+ x = res_x.copy()
474
+ x[entry["index"]] = v
475
+ round_xs.append(x)
476
+ round_owner.append((entry, v))
477
+ if not round_xs:
478
+ break
479
+ if verbose:
480
+ print(f"[screen] round {r + 1}: {len(round_xs)} side(s) still "
481
+ f"undecided.", flush=True)
482
+ n_evaluations += len(round_xs)
483
+ nlls = nll_batch(round_xs, label=f"slice-screen-r{r + 1}")
484
+ for (entry, v), nll in zip(round_owner, nlls):
485
+ nll = float(nll)
486
+ point = {"x": float(v),
487
+ "x_linear": float(10.0 ** v if entry["is_log"] else v),
488
+ "nll": nll, "dnll": float(nll - nll_at_optimum)}
489
+ entry["points"].append(point)
490
+ if _is_usable(point) and point["dnll"] > threshold:
491
+ entry["settled"] = True
436
492
 
437
493
  report = {"threshold": float(threshold),
438
494
  "anchor": float(nll_at_optimum),
@@ -440,25 +496,17 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
440
496
  "param_names": list(param_names),
441
497
  "span_decades": float(span_decades),
442
498
  "min_reach_decades": float(min_reach_decades),
443
- "n_evaluations": len(xs),
499
+ "n_evaluations": n_evaluations,
500
+ "n_candidates": n_candidates,
444
501
  "parameters": {}}
445
502
 
446
- pos = 0
447
503
  for entry in plan:
448
- pts = []
449
- for v in entry["values"]:
450
- nll = float(values[pos])
451
- pos += 1
452
- pts.append({
453
- "x": float(v),
454
- "x_linear": float(10.0 ** v if entry["is_log"] else v),
455
- "nll": nll,
456
- "dnll": float(nll - nll_at_optimum),
457
- })
458
- side = _verdict(pts, entry["bound"], threshold, entry["p_opt"],
459
- entry["sign"], entry["is_log"],
504
+ side = _verdict(entry["points"], entry["bound"], threshold,
505
+ entry["p_opt"], entry["sign"], entry["is_log"],
460
506
  min_reach_decades=min_reach_decades)
461
507
  side["is_log"] = entry["is_log"]
508
+ side["stopped_early"] = bool(entry["settled"]
509
+ and len(entry["points"]) < len(entry["values"]))
462
510
  report["parameters"].setdefault(entry["name"], {})[entry["side"]] = side
463
511
 
464
512
  states = [s["state"] for sides in report["parameters"].values()
@@ -479,6 +479,42 @@ def _sigma_floor_for(obs_cfg, t_data, y_data, y_sim_ref=None):
479
479
  return (nf.sigma if nf is not None else None), nf
480
480
 
481
481
 
482
+ def _freeze_floor(known_sigma, sigma_floor, frozen_value):
483
+ """Pin a floored block's sigma at *frozen_value* instead of re-concentrating.
484
+
485
+ Used during profile-point evaluation, wired from :func:`evaluate_nll_fixed`
486
+ down from :meth:`ParallelEvaluator.profile_batch`, so a floored block's
487
+ sigma sits still for the whole nuisance re-optimization instead of being
488
+ re-estimated at every point the profile visits -- that re-estimation is
489
+ the self-forgiveness the floor exists to stop when it pushes sigma_hat
490
+ *above* the floor, and is worth stopping just as much below it, where the
491
+ floor itself has nothing to say.
492
+
493
+ *frozen_value* is the block's own ``sigma_used`` at the fitted optimum
494
+ (``min(sigma_hat_opt, floor)``; see :func:`block_sigmas`), not the raw
495
+ floor constant. That matters: most floored blocks are *not* binding at a
496
+ good optimum (``sigma_hat_opt`` already sits below the floor, which is
497
+ exactly why the fit was allowed to reach it), and pinning those at the
498
+ floor anyway would inflate every such block's term for the whole profile,
499
+ not just far from the optimum where self-forgiveness is the actual risk --
500
+ flattening the profile's true local sensitivity and reporting a large,
501
+ spurious dNLL even a fraction of a Wald SE from the fitted value. Pinning
502
+ at the block's own resolved value instead reproduces the concentrated
503
+ likelihood exactly at the optimum (the anchor and the profile then agree
504
+ there by construction) and only departs from it exactly where the block's
505
+ fit genuinely degrades as the profiled parameter moves.
506
+
507
+ The main fit and every other diagnostic (Wald, slice, Sobol) pass
508
+ ``frozen_value=None`` and keep floored blocks profiled as before; only the
509
+ profile passes supply one. A block with no floor (``sigma_floor is
510
+ None``) is untouched either way, and a block with a true declared sigma
511
+ (``known_sigma`` already set) is never overridden.
512
+ """
513
+ if frozen_value is not None and known_sigma is None and sigma_floor is not None:
514
+ return float(frozen_value), None
515
+ return known_sigma, sigma_floor
516
+
517
+
482
518
  def _apply_n_eff_scale(weights, n_eff_scale):
483
519
  """Rescale a block's per-point weights to state an effective sample size.
484
520
 
@@ -729,6 +765,7 @@ def effective_k(param_names, blocks):
729
765
  def collect_loss_blocks(
730
766
  sim_results, groups, replicates, param_names, p_lin, p_dict,
731
767
  trace_collector=None, loss_components=None, seen=None,
768
+ frozen_sigmas=None,
732
769
  ):
733
770
  """Walk every loss element in *groups* and return ``{block: [sse, n]}``.
734
771
 
@@ -759,6 +796,9 @@ def collect_loss_blocks(
759
796
  every contributing block is added to it. Block count cannot measure data
760
797
  coverage once elements share a sigma_block, so coverage checks must count
761
798
  these instead.
799
+
800
+ ``frozen_sigmas`` is passed straight through to every element's sigma
801
+ resolution -- see :func:`_freeze_floor`.
762
802
  """
763
803
  blocks = {}
764
804
  for g_name, g_config in groups.items():
@@ -787,6 +827,7 @@ def collect_loss_blocks(
787
827
  trace_collector=trace_collector, blocks=blocks,
788
828
  block_key=elem.get("sigma_block") or key, seen=seen,
789
829
  n_eff_scale=elem.get("n_eff_scale"),
830
+ frozen_sigmas=frozen_sigmas,
790
831
  )
791
832
  else:
792
833
  sim = elem.get("simulation")
@@ -799,6 +840,7 @@ def collect_loss_blocks(
799
840
  loss_config=lc, trace_collector=trace_collector,
800
841
  blocks=blocks, block_key=elem.get("sigma_block") or key,
801
842
  seen=seen, n_eff_scale=elem.get("n_eff_scale"),
843
+ frozen_sigmas=frozen_sigmas,
802
844
  )
803
845
 
804
846
  if loss_components is not None:
@@ -819,6 +861,7 @@ def loss_function_evaluated(
819
861
  block_key=None,
820
862
  seen=None,
821
863
  n_eff_scale=None,
864
+ frozen_sigmas=None,
822
865
  ):
823
866
  """
824
867
  Evaluate the loss for already simulated results.
@@ -830,6 +873,10 @@ def loss_function_evaluated(
830
873
  runs, so it does not depend on ``loss_type`` or on which sigma heuristic
831
874
  fired. *block_key* lets the caller pin a stable identity for the element
832
875
  even when ``results_dict`` is keyed by a display label.
876
+
877
+ *frozen_sigmas*, when given, is a ``{(block_key_or_exp_id, obs_label):
878
+ sigma_used}`` lookup (see :func:`block_sigmas`) consulted for every
879
+ floored observable -- see :func:`_freeze_floor`.
833
880
  """
834
881
  loss_config = loss_config or {}
835
882
  observables_config = loss_config.get("observables", [])
@@ -920,11 +967,16 @@ def loss_function_evaluated(
920
967
  n_eff = obs_weights_v.sum()
921
968
  loss_type = obs_cfg.get("loss_type", "nll")
922
969
 
970
+ block_id = block_key if block_key is not None else exp_id
923
971
  known_sigma = _known_sigma_for(obs_cfg, obs_df, valid)
924
972
  sigma_floor, floor_obj = (
925
973
  _sigma_floor_for(obs_cfg, t_data, y_data, y_sim_ref=y_pred)
926
974
  if known_sigma is None else (None, None))
927
- _record_block(blocks, block_key if block_key is not None else exp_id,
975
+ frozen_value = (frozen_sigmas.get((block_id, _short_obs_label(obs)))
976
+ if frozen_sigmas else None)
977
+ known_sigma, sigma_floor = _freeze_floor(
978
+ known_sigma, sigma_floor, frozen_value)
979
+ _record_block(blocks, block_id,
928
980
  obs, residuals, obs_weights_v,
929
981
  known_sigma=known_sigma, sigma_floor=sigma_floor)
930
982
  if seen is not None:
@@ -1034,6 +1086,7 @@ def loss_function_composite(
1034
1086
  block_key=None,
1035
1087
  seen=None,
1036
1088
  n_eff_scale=None,
1089
+ frozen_sigmas=None,
1037
1090
  ):
1038
1091
  """
1039
1092
  Evaluate aggregated composite loss across multiple simulated results.
@@ -1177,11 +1230,16 @@ def loss_function_composite(
1177
1230
  n_eff = obs_weights_v.sum()
1178
1231
  loss_type = obs_cfg.get("loss_type", "nll")
1179
1232
 
1233
+ block_id = block_key if block_key is not None else exp_id
1180
1234
  known_sigma = _known_sigma_for(obs_cfg, obs_df, valid)
1181
1235
  sigma_floor, floor_obj = (
1182
1236
  _sigma_floor_for(obs_cfg, t_data, y_data, y_sim_ref=y_pred)
1183
1237
  if known_sigma is None else (None, None))
1184
- _record_block(blocks, block_key if block_key is not None else exp_id,
1238
+ frozen_value = (frozen_sigmas.get((block_id, _short_obs_label(obs)))
1239
+ if frozen_sigmas else None)
1240
+ known_sigma, sigma_floor = _freeze_floor(
1241
+ known_sigma, sigma_floor, frozen_value)
1242
+ _record_block(blocks, block_id,
1185
1243
  obs, residuals, obs_weights_v,
1186
1244
  known_sigma=known_sigma, sigma_floor=sigma_floor)
1187
1245
  if seen is not None:
@@ -1458,7 +1516,7 @@ def evaluate_nll_fixed(
1458
1516
  p, models, replicates, param_names, scales, groups, group_normalization,
1459
1517
  fixed_sigmas, model_text=None, paths=None, events_dynamic=False,
1460
1518
  failure_value=1e10, for_inference=True, concentrated=True,
1461
- include_constant=False,
1519
+ include_constant=False, frozen_sigmas=None,
1462
1520
  ):
1463
1521
  """Joint NLL at *p* (optimizer space).
1464
1522
 
@@ -1474,6 +1532,12 @@ def evaluate_nll_fixed(
1474
1532
  ``concentrated=False`` reproduces the older frozen-sigma behaviour, where
1475
1533
  ``fixed_sigmas`` carries sigmas estimated once at the optimum. Retained for
1476
1534
  comparison against archived runs; it is not the inference path.
1535
+
1536
+ ``frozen_sigmas``, when given, pins every data-floored block found in it
1537
+ at its own ``sigma_used`` from the fit (see :func:`block_sigmas`), instead
1538
+ of letting it re-concentrate (up to its floor) at this particular point --
1539
+ see :func:`_freeze_floor`. Only the profile passes supply this; every
1540
+ other diagnostic leaves it None.
1477
1541
  """
1478
1542
  p_lin = _to_linear(p, scales)
1479
1543
  p_dict = dict(zip(param_names, p_lin.tolist()))
@@ -1487,6 +1551,7 @@ def evaluate_nll_fixed(
1487
1551
  if concentrated:
1488
1552
  blocks = collect_loss_blocks(
1489
1553
  sim_results, groups, replicates, param_names, p_lin, p_dict,
1554
+ frozen_sigmas=frozen_sigmas,
1490
1555
  )
1491
1556
  if not blocks:
1492
1557
  return failure_value
@@ -2051,6 +2116,73 @@ def _resolve_profile_optimizer(method, optimizer_kwargs):
2051
2116
  return profile_method, profile_kwargs
2052
2117
 
2053
2118
 
2119
+ def _resolve_named(value, param_names, label):
2120
+ """Normalize a per-parameter spec value (``x0`` or ``bounds``) to a list
2121
+ aligned with ``param_names``.
2122
+
2123
+ Accepts a ``{name: value}`` dict or an explicit sequence already aligned
2124
+ with ``param_names`` position-for-position -- the same duality
2125
+ ``_resolve_scales`` already gives ``parameter_scale``, for the same
2126
+ reason: ``param_names``, ``x0`` and ``bounds`` are three separately
2127
+ authored per-parameter lists that today have no cross-check that they
2128
+ actually correspond, so reordering or editing one without the matching
2129
+ edit to the others is a silent misalignment, not an error. A dict closes
2130
+ that off structurally.
2131
+
2132
+ Unlike ``parameter_scale``'s dict, where an unlisted name safely defaults
2133
+ to "lin", there is no safe default for a missing x0 or bounds entry -- so
2134
+ both an unknown name (a typo, or a name that's no longer in param_names)
2135
+ and a missing one (an omission) raise, naming the parameter, rather than
2136
+ silently doing something plausible-looking with the wrong one or falling
2137
+ back to unbounded.
2138
+ """
2139
+ if value is None:
2140
+ return None
2141
+ if isinstance(value, dict):
2142
+ names = set(param_names)
2143
+ unknown = sorted(set(value) - names)
2144
+ missing = sorted(names - set(value))
2145
+ if unknown or missing:
2146
+ problems = []
2147
+ if unknown:
2148
+ problems.append(f"unknown name(s) {unknown}")
2149
+ if missing:
2150
+ problems.append(f"missing name(s) {missing}")
2151
+ raise ValueError(
2152
+ f"{label} dict does not match param_names: "
2153
+ + "; ".join(problems)
2154
+ )
2155
+ return [value[name] for name in param_names]
2156
+ values = list(value)
2157
+ if len(values) != len(param_names):
2158
+ raise ValueError(
2159
+ f"{label} has {len(values)} entries but there are "
2160
+ f"{len(param_names)} parameters"
2161
+ )
2162
+ return values
2163
+
2164
+
2165
+ def _resolve_bounds(bounds, param_names):
2166
+ """``bounds`` as a ``{name: (lo, hi)}`` dict or an aligned sequence,
2167
+ normalized to the list every downstream consumer (``_param_bounds``,
2168
+ ``_multistart_points``, the Wald Hessian, the profile grid, ``EvalSpec``,
2169
+ checkpoint fingerprinting) already expects. ``None`` means unbounded and
2170
+ passes through unchanged -- see ``_resolve_named``.
2171
+ """
2172
+ return _resolve_named(bounds, param_names, "bounds")
2173
+
2174
+
2175
+ def _resolve_x0(x0, param_names):
2176
+ """``x0`` as a ``{name: value}`` dict or an aligned sequence, normalized
2177
+ the same way as ``_resolve_bounds``. Unlike bounds, x0 is required --
2178
+ ``None`` is a spec error, not "no starting point".
2179
+ """
2180
+ resolved = _resolve_named(x0, param_names, "x0")
2181
+ if resolved is None:
2182
+ raise ValueError("x0 is required and was None")
2183
+ return resolved
2184
+
2185
+
2054
2186
  def _auto_scale(value, bound, search_decades=None,
2055
2187
  min_decades=_AUTO_MIN_DECADES):
2056
2188
  """Pick 'log10' or 'lin' for one parameter.
@@ -3773,6 +3905,20 @@ def run_parallel_profile(
3773
3905
  n_params = len(param_names)
3774
3906
  completed = checkpoint.load(param_names) if checkpoint is not None else \
3775
3907
  {n: {} for n in param_names}
3908
+ # A loaded record's dnll was computed against whatever anchor was current
3909
+ # in the launch that wrote it. If that differs from this launch's anchor
3910
+ # -- the frozen-sigma anchor shipping is exactly such a change, see
3911
+ # _run_parallel_profile_with_checkpoint -- every downstream threshold
3912
+ # comparison below would silently compare against the wrong number.
3913
+ # Recomputed here, once, from the anchor-independent nll every record
3914
+ # already carries, so a resume is never wrong about the anchor a fresh
3915
+ # point would use, and no checkpoint has to be discarded when the anchor
3916
+ # definition changes.
3917
+ for pts in completed.values():
3918
+ for r in pts.values():
3919
+ nll = r.get("nll")
3920
+ r["dnll"] = (float(nll) - nll_at_optimum
3921
+ if nll is not None else float("nan"))
3776
3922
 
3777
3923
  def _lin(v, is_log):
3778
3924
  return 10.0 ** v if is_log else v
@@ -4748,7 +4894,7 @@ def _run_parallel_profile_with_checkpoint(
4748
4894
  n_grid, range_factor, se_span, n_refine, run_id, checkpoint_enabled=True,
4749
4895
  fixed_sigmas=None, warm_passes=1, max_extend=8, extend_growth=2.0,
4750
4896
  bracket_rtol=0.05, screen_span_decades=None, screen_min_reach_decades=None,
4751
- replicates=None,
4897
+ replicates=None, sigma_by_block=None,
4752
4898
  ):
4753
4899
  """Wire the pool, the checkpoint store, the wall budget and the profile."""
4754
4900
  from Engine.Profile_checkpoint import (
@@ -4829,12 +4975,34 @@ def _run_parallel_profile_with_checkpoint(
4829
4975
  )
4830
4976
 
4831
4977
  def batch(jobs, on_result=None, label=None):
4978
+ # sigma_by_block: every profile point is a nuisance re-optimization,
4979
+ # and letting a floored block's sigma re-concentrate at each point is
4980
+ # the same self-forgiveness the floor exists to stop. Pinned instead
4981
+ # at the block's own sigma_used from the fit -- its floor only when
4982
+ # the floor was actually binding there, its sharper sigma_hat
4983
+ # otherwise -- for the whole profile. See _freeze_floor.
4832
4984
  return evaluator.profile_batch(jobs, on_result=on_result, label=label,
4833
- budget=budget)
4985
+ budget=budget,
4986
+ frozen_sigmas=sigma_by_block)
4987
+
4988
+ # Every profile point comes back frozen (batch, above), so dNLL has to be
4989
+ # read against an anchor computed the same way -- reusing nll_at_optimum
4990
+ # (unfrozen; correct for the screen just above) would compare a frozen
4991
+ # point's NLL against an unfrozen anchor. Freezing each block at its own
4992
+ # sigma_used from the fit (rather than always at the raw floor) means this
4993
+ # reproduces the concentrated likelihood exactly at the optimum, so the
4994
+ # two anchors should differ only by float noise -- a real gap here means
4995
+ # sigma_by_block does not match opt_blocks, not that floors are binding.
4996
+ nll_at_optimum_frozen = evaluator.evaluate_batch(
4997
+ [res_x], label="profile-anchor-frozen", frozen_sigmas=sigma_by_block)[0]
4998
+ drift = nll_at_optimum_frozen - nll_at_optimum
4999
+ print(f"[profile] frozen anchor at the optimum: {nll_at_optimum_frozen:.6g} "
5000
+ f"(unfrozen: {nll_at_optimum:.6g}, {drift:+.6g}) -- every profile "
5001
+ f"dNLL below is measured from the frozen value.")
4834
5002
 
4835
5003
  try:
4836
5004
  traces, anchor, where, convergence = run_parallel_profile(
4837
- batch, res_x, nll_at_optimum, param_names,
5005
+ batch, res_x, nll_at_optimum_frozen, param_names,
4838
5006
  bounds, scales, method=method, optimizer_kwargs=optimizer_kwargs,
4839
5007
  wald_se=wald_se, n_grid=n_grid, range_factor=range_factor,
4840
5008
  se_span=se_span, n_refine=n_refine, checkpoint=ckpt,
@@ -4859,6 +5027,7 @@ def _run_fast_profile_with_checkpoint(
4859
5027
  checkpoint_enabled=True, fixed_sigmas=None, replicates=None,
4860
5028
  round_evals=None, n_rounds=None, near_zero_frac=None,
4861
5029
  screen_span_decades=None, screen_min_reach_decades=None,
5030
+ sigma_by_block=None,
4862
5031
  ):
4863
5032
  """Wire the pool, the checkpoint store and the wall budget to the fast pass.
4864
5033
 
@@ -4898,18 +5067,43 @@ def _run_fast_profile_with_checkpoint(
4898
5067
  print(f"[fast profile] {budget.describe()}")
4899
5068
 
4900
5069
  def batch(jobs, on_result=None, label=None):
5070
+ # Same reasoning as the full profile's own batch() -- a fast-profile
5071
+ # point is also a nuisance re-optimization, so each floored block's
5072
+ # sigma is pinned at its own sigma_used from the fit (its floor only
5073
+ # where that was actually binding) for the point rather than
5074
+ # re-concentrated at it. See _freeze_floor.
4901
5075
  return evaluator.profile_batch(jobs, on_result=on_result, label=label,
4902
- budget=budget)
5076
+ budget=budget,
5077
+ frozen_sigmas=sigma_by_block)
4903
5078
 
4904
5079
  def nll_batch(xs, label=None):
5080
+ # Plain evaluations for the screen this pass reuses -- no nuisance
5081
+ # re-optimization happens here, so left unfrozen like every other
5082
+ # slice-style evaluation.
4905
5083
  return evaluator.evaluate_batch(xs, label=label)
4906
5084
 
5085
+ # See the full profile's own comment at the matching line: batch() above
5086
+ # submits every point frozen at sigma_by_block, so its dNLL has to be
5087
+ # read against an anchor computed the same way, not the unfrozen
5088
+ # nll_at_optimum the screen (nll_batch) uses. Freezing at each block's own
5089
+ # sigma_used (not the raw floor) means this should equal nll_at_optimum to
5090
+ # float noise -- a real gap means sigma_by_block is stale, not that floors
5091
+ # are binding.
5092
+ nll_at_optimum_frozen = evaluator.evaluate_batch(
5093
+ [res_x], label="profile-anchor-frozen", frozen_sigmas=sigma_by_block)[0]
5094
+ drift = nll_at_optimum_frozen - nll_at_optimum
5095
+ print(f"[fast profile] frozen anchor at the optimum: "
5096
+ f"{nll_at_optimum_frozen:.6g} (unfrozen: {nll_at_optimum:.6g}, "
5097
+ f"{drift:+.6g}) -- every profile-point dNLL below is measured "
5098
+ f"from the frozen value.")
5099
+
4907
5100
  try:
4908
5101
  report = run_fast_profile(
4909
5102
  batch, nll_batch, res_x, nll_at_optimum, param_names, bounds,
4910
5103
  scales, method=method, optimizer_kwargs=optimizer_kwargs,
4911
5104
  wald_se=wald_se, wald_cov=wald_cov, checkpoint=ckpt,
4912
5105
  ckpt_dir=ckpt.dir, threshold=_PROFILE_THRESHOLD,
5106
+ nll_at_optimum_profile=nll_at_optimum_frozen,
4913
5107
  round_evals=(round_evals if round_evals is not None
4914
5108
  else DEFAULT_ROUND_EVALS),
4915
5109
  n_rounds=n_rounds if n_rounds is not None else DEFAULT_ROUNDS,
@@ -5327,8 +5521,14 @@ def run_optimization_from_groups(
5327
5521
  # =========================================================================
5328
5522
  if optimization_spec is not None:
5329
5523
  param_names = optimization_spec.param_names
5330
- x0_lin = optimization_spec.x0
5331
- bounds_lin = optimization_spec.bounds
5524
+ # x0 and bounds may each be authored as a {name: value} dict or a
5525
+ # sequence already aligned with param_names -- resolved to the aligned
5526
+ # list here, once, so every consumer below and in every module this
5527
+ # spec's fields get threaded into (Engine.Evaluator's EvalSpec,
5528
+ # checkpoint fingerprinting, the profile grid, ...) keeps working with
5529
+ # plain positional values exactly as before. See _resolve_named.
5530
+ x0_lin = _resolve_x0(optimization_spec.x0, param_names)
5531
+ bounds_lin = _resolve_bounds(optimization_spec.bounds, param_names)
5332
5532
  method = optimization_spec.method
5333
5533
  optimizer_kwargs = optimization_spec.optimizer_kwargs or {}
5334
5534
  selected_group_names = set(optimization_spec.groups.keys())
@@ -6075,6 +6275,7 @@ def run_optimization_from_groups(
6075
6275
  screen_span_decades=screen_span_decades,
6076
6276
  screen_min_reach_decades=(
6077
6277
  screen_min_reach_decades),
6278
+ sigma_by_block=sigma_by_block,
6078
6279
  )
6079
6280
  )
6080
6281
  elif profile_likelihood_analysis:
@@ -6120,6 +6321,7 @@ def run_optimization_from_groups(
6120
6321
  screen_min_reach_decades=(
6121
6322
  screen_min_reach_decades),
6122
6323
  replicates=active_replicates,
6324
+ sigma_by_block=sigma_by_block,
6123
6325
  )
6124
6326
  )
6125
6327
 
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "1.0.12",
3
- "distance": 0,
4
- "node": "g3450403b38a37a7b62ac7644e0d82e8592310347",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-09-16"
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