PyAntiGen 2.1.0__tar.gz → 2.1.1__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 (106) hide show
  1. {pyantigen-2.1.0 → pyantigen-2.1.1}/PKG-INFO +1 -1
  2. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/SOURCES.txt +1 -0
  4. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/scm_file_list.json +2 -0
  5. pyantigen-2.1.1/PyAntiGen.egg-info/scm_version.json +8 -0
  6. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/_version.py +3 -3
  7. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Evaluator.py +54 -1
  8. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Optimize.py +34 -2
  9. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Profile_plan.py +56 -15
  10. pyantigen-2.1.1/pyantigen/engine/Stop_rules.py +205 -0
  11. pyantigen-2.1.0/PyAntiGen.egg-info/scm_version.json +0 -8
  12. {pyantigen-2.1.0 → pyantigen-2.1.1}/.github/workflows/publish.yml +0 -0
  13. {pyantigen-2.1.0 → pyantigen-2.1.1}/.github/workflows/tests.yml +0 -0
  14. {pyantigen-2.1.0 → pyantigen-2.1.1}/.gitignore +0 -0
  15. {pyantigen-2.1.0 → pyantigen-2.1.1}/.vscode/launch.json +0 -0
  16. {pyantigen-2.1.0 → pyantigen-2.1.1}/LICENSE +0 -0
  17. {pyantigen-2.1.0 → pyantigen-2.1.1}/MANIFEST.in +0 -0
  18. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  19. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/entry_points.txt +0 -0
  20. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/requires.txt +0 -0
  21. {pyantigen-2.1.0 → pyantigen-2.1.1}/PyAntiGen.egg-info/top_level.txt +0 -0
  22. {pyantigen-2.1.0 → pyantigen-2.1.1}/README.md +0 -0
  23. {pyantigen-2.1.0 → pyantigen-2.1.1}/docs/MULTISTART.md +0 -0
  24. {pyantigen-2.1.0 → pyantigen-2.1.1}/docs/PROTOCOL_LAYER.md +0 -0
  25. {pyantigen-2.1.0 → pyantigen-2.1.1}/docs/V2_DESIGN.md +0 -0
  26. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/.agents/skills/module_generation/SKILL.md +0 -0
  27. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  28. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  29. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/.gitignore +0 -0
  30. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/__init__.py +0 -0
  31. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/cli.py +0 -0
  32. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Anchor_cache.py +0 -0
  33. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Deadline.py +0 -0
  34. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Differential_evolution.py +0 -0
  35. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Event_times.py +0 -0
  36. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Fast_profile.py +0 -0
  37. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Fit_cache.py +0 -0
  38. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Identifiability.py +0 -0
  39. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Model_cache.py +0 -0
  40. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Model_optimize.py +0 -0
  41. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Model_simulate.py +0 -0
  42. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Multistart.py +0 -0
  43. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Nelder_mead.py +0 -0
  44. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Noise_floor.py +0 -0
  45. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Petab_export.py +0 -0
  46. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Preequil_cache.py +0 -0
  47. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Profile_checkpoint.py +0 -0
  48. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/README.md +0 -0
  49. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Results.py +0 -0
  50. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Sensitivity_analysis.py +0 -0
  51. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/Simulate.py +0 -0
  52. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/engine/__init__.py +0 -0
  53. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/AntimonyGen.py +0 -0
  54. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/RxnDict_to_antimony.py +0 -0
  55. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/TelluriumGen.py +0 -0
  56. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/__init__.py +0 -0
  57. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/antimony_utils.py +0 -0
  58. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/data_interpolation.py +0 -0
  59. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/isotopomer_tools.py +0 -0
  60. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/model_generation.py +0 -0
  61. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/models.py +0 -0
  62. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/module_base.py +0 -0
  63. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/pyantigen.py +0 -0
  64. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/rate_laws.py +0 -0
  65. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/generate/reaction_creation.py +0 -0
  66. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/__init__.py +0 -0
  67. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/assay.py +0 -0
  68. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/describe.py +0 -0
  69. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/design.py +0 -0
  70. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/lower_v1.py +0 -0
  71. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/optimization.py +0 -0
  72. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/params.py +0 -0
  73. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/quantity.py +0 -0
  74. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/reagent.py +0 -0
  75. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/refs.py +0 -0
  76. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/remarks.py +0 -0
  77. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/serialize.py +0 -0
  78. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/study/validate.py +0 -0
  79. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/AntiGen_paths.py +0 -0
  80. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Flipflop_reference.py +0 -0
  81. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Model_generate.py +0 -0
  82. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Model_run.py +0 -0
  83. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Data.py +0 -0
  84. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Events.py +0 -0
  85. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Experiment.py +0 -0
  86. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Loss_config.py +0 -0
  87. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Observed_species.py +0 -0
  88. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Optimizer_settings.py +0 -0
  89. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Plots.py +0 -0
  90. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Solver_settings.py +0 -0
  91. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Update_opt_parameters.py +0 -0
  92. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/Modules/Update_parameters.py +0 -0
  93. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/optimizations/Example1_ADneg.json +0 -0
  94. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/optimizations/Example1_ADpos.json +0 -0
  95. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/optimizations/Example3_joint.json +0 -0
  96. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/optimizations/Example4_flipflop.json +0 -0
  97. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/optimizations/example.py +0 -0
  98. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/studies/example.json +0 -0
  99. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/studies/example.py +0 -0
  100. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/Example/studies/flipflop.json +0 -0
  101. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/data/ADneg.csv +0 -0
  102. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/data/ADpos.csv +0 -0
  103. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/data/Flipflop.csv +0 -0
  104. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyantigen/template/data/make_flipflop_data.py +0 -0
  105. {pyantigen-2.1.0 → pyantigen-2.1.1}/pyproject.toml +0 -0
  106. {pyantigen-2.1.0 → pyantigen-2.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: Declarative generation, simulation and identifiability analysis of compartmental Antimony/SBML models
5
5
  Author-email: Don Elbert <elbert5770@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: Declarative generation, simulation and identifiability analysis of compartmental Antimony/SBML models
5
5
  Author-email: Don Elbert <elbert5770@gmail.com>
6
6
  License-Expression: MIT
@@ -47,6 +47,7 @@ pyantigen/engine/README.md
47
47
  pyantigen/engine/Results.py
48
48
  pyantigen/engine/Sensitivity_analysis.py
49
49
  pyantigen/engine/Simulate.py
50
+ pyantigen/engine/Stop_rules.py
50
51
  pyantigen/engine/__init__.py
51
52
  pyantigen/generate/AntimonyGen.py
52
53
  pyantigen/generate/RxnDict_to_antimony.py
@@ -39,6 +39,7 @@
39
39
  "pyantigen/engine/Results.py",
40
40
  "pyantigen/engine/Sensitivity_analysis.py",
41
41
  "pyantigen/engine/Simulate.py",
42
+ "pyantigen/engine/Stop_rules.py",
42
43
  "pyantigen/engine/__init__.py",
43
44
  "pyantigen/generate/AntimonyGen.py",
44
45
  "pyantigen/generate/RxnDict_to_antimony.py",
@@ -125,6 +126,7 @@
125
126
  "tests/engine/test_slice_screen.py",
126
127
  "tests/engine/test_slice_window.py",
127
128
  "tests/engine/test_solver_fingerprint.py",
129
+ "tests/engine/test_stop_rules.py",
128
130
  "tests/engine/test_tolerance_floor.py",
129
131
  "tests/engine/test_wald_units.py",
130
132
  "tests/generate/test_gen_antimony_utils.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.1.1",
3
+ "distance": 0,
4
+ "node": "g6282b0d51a9efb9b414f250be15a996d5e131645",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-08"
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 = '2.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.1.1'
22
+ __version_tuple__ = version_tuple = (2, 1, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g664f039a1'
24
+ __commit_id__ = commit_id = 'g6282b0d51'
@@ -353,6 +353,11 @@ def _profile_task(job):
353
353
  out.pop("frozen_sigmas", None)
354
354
  # A location on this machine's disk, meaningless in a stored record.
355
355
  out.pop("state_path", None)
356
+ # An input as well: the settings for the shadow stop rules. What they
357
+ # observed comes back as `trace`, `shadow_stops` and `stop_reason` below;
358
+ # `trace` and `shadow_stops` are also inputs when a point is resumed, and
359
+ # are overwritten with the continued history.
360
+ stop_rules = out.pop("stop_rules", None)
356
361
  out.update({"nll": None, "status": "ok", "n_evals": 0, "wall_s": 0.0,
357
362
  "worker": os.getpid(), "converged": True, "nit": -1,
358
363
  "nfev": -1, "opt_message": "", "interrupted": False})
@@ -384,6 +389,12 @@ def _profile_task(job):
384
389
  # because a point stopped before its first simplex is complete has no
385
390
  # sorted vertex to read it from.
386
391
  best = {"f": float("inf"), "x": x_start}
392
+ # The shadow stop rules' watcher (pyantigen.engine.Stop_rules). It only
393
+ # observes -- nothing it computes feeds back into the optimizer -- and
394
+ # is None unless the job asked for it. `base` is the point's evaluation
395
+ # count when this job began, so a resumed point numbers on from where
396
+ # it stopped.
397
+ shadow = {"mon": None, "base": 0}
387
398
 
388
399
  # Every profile point pins each floored block at its own sigma_used
389
400
  # from the fit rather than letting it re-concentrate (see
@@ -404,6 +415,12 @@ def _profile_task(job):
404
415
  if np.isfinite(v) and v < best["f"]:
405
416
  best["f"] = float(v)
406
417
  best["x"] = x_arr.copy()
418
+ if shadow["mon"] is not None:
419
+ try:
420
+ shadow["mon"].update(shadow["base"] + eval_count["n"],
421
+ best["f"])
422
+ except Exception: # a watcher must never fail a point
423
+ shadow["mon"] = None
407
424
  return v
408
425
 
409
426
  def time_is_up():
@@ -419,7 +436,8 @@ def _profile_task(job):
419
436
  # is just the objective at the fixed value -- exact by definition.
420
437
  nll = raw_objective(x_start, x_fixed)
421
438
  x_opt = x_start
422
- out.update({"converged": True, "nit": 0, "nfev": 1})
439
+ out.update({"converged": True, "nit": 0, "nfev": 1,
440
+ "stop_reason": "exact"})
423
441
  eval_count["n"] = 1
424
442
  else:
425
443
  options, extra_kwargs = nuisance_options(
@@ -429,6 +447,19 @@ def _profile_task(job):
429
447
  resumable = str(method).lower() == "nelder-mead" and can_run(
430
448
  options, extra_kwargs)
431
449
 
450
+ def start_shadow(base):
451
+ if not stop_rules or stop_rules.get("anchor") is None:
452
+ return
453
+ try:
454
+ from pyantigen.engine.Stop_rules import PointMonitor
455
+ shadow["mon"] = PointMonitor(
456
+ stop_rules["anchor"], max_fev, x_start.size,
457
+ cfg=stop_rules, trace=job.get("trace"),
458
+ fired=job.get("shadow_stops"))
459
+ shadow["base"] = int(base)
460
+ except Exception: # a watcher must never fail a point
461
+ shadow["mon"] = None
462
+
432
463
  res = None
433
464
  outcome = "done"
434
465
  if resumable:
@@ -457,6 +488,7 @@ def _profile_task(job):
457
488
  # run, and nothing to save either.
458
489
  outcome = "capped"
459
490
  else:
491
+ start_shadow(nm_state["nfev"])
460
492
  interval = float(job.get("state_interval_s",
461
493
  POINT_STATE_INTERVAL_S))
462
494
  last_save = [0.0]
@@ -490,6 +522,8 @@ def _profile_task(job):
490
522
  if nfev_used >= max_fev or nit_used >= max_it:
491
523
  outcome = "capped"
492
524
  else:
525
+ start_shadow(nfev_used)
526
+
493
527
  def guarded(x_nuisance, fixed_val):
494
528
  v = nuisance_objective(x_nuisance, fixed_val)
495
529
  if time_is_up():
@@ -549,6 +583,20 @@ def _profile_task(job):
549
583
  out["nm_simplex"] = np.asarray(nm_state["sim"],
550
584
  dtype=float).tolist()
551
585
 
586
+ # Why this job's optimization ended, for reading a finished run:
587
+ # `converged` alone cannot tell a point that met its tolerance from
588
+ # one that spent its cap and happened to be at the minimum, and at
589
+ # a 1500 cap most points are the second kind.
590
+ if outcome == "interrupted":
591
+ out["stop_reason"] = "interrupted"
592
+ elif res is None or not np.isfinite(getattr(res, "fun", np.inf)):
593
+ out["stop_reason"] = "budget-spent"
594
+ elif outcome == "capped" or not getattr(res, "success", True):
595
+ out["stop_reason"] = ("maxiter" if getattr(res, "status", None) == 2
596
+ else "maxfev")
597
+ else:
598
+ out["stop_reason"] = "tolerance"
599
+
552
600
  if nm_state is not None:
553
601
  nfev_total, nit_total = nm_state["nfev"], nm_state["nit"]
554
602
  else:
@@ -565,6 +613,11 @@ def _profile_task(job):
565
613
  "nit_total": nit_total,
566
614
  "status": "ok" if np.isfinite(nll) and nll < FAILURE_VALUE else "sentinel",
567
615
  })
616
+ if shadow["mon"] is not None:
617
+ try:
618
+ out.update(shadow["mon"].finish(nfev_total, best["f"]))
619
+ except Exception:
620
+ pass
568
621
  except Exception as exc:
569
622
  out.update({"status": f"error: {type(exc).__name__}: {exc}",
570
623
  "nll": FAILURE_VALUE, "interrupted": False})
@@ -5166,7 +5166,8 @@ def _carry_resume_state(keep, res):
5166
5166
  allowance never advancing and its optimizer state never moving.
5167
5167
  """
5168
5168
  for field in ("interrupted", "converged", "nfev_total", "nit_total",
5169
- "nm_simplex", "opt_message"):
5169
+ "nm_simplex", "opt_message", "stop_reason", "trace",
5170
+ "shadow_stops"):
5170
5171
  if field in res:
5171
5172
  keep[field] = res[field]
5172
5173
  return keep
@@ -5233,10 +5234,17 @@ def run_parallel_profile(
5233
5234
  max_extend=8, extend_growth=2.0, bracket_rtol=0.05,
5234
5235
  screen=None, open_decades=None, grid_spacing="linear",
5235
5236
  adaptive_pass1=False, max_step_decades=0.7, n_workers=None,
5236
- first_probe="max",
5237
+ first_probe="max", shadow_stop_rules=True,
5237
5238
  ):
5238
5239
  """Profile likelihood for every parameter as parallel batches.
5239
5240
 
5241
+ *shadow_stop_rules* attaches a watcher to every point (see
5242
+ :mod:`pyantigen.engine.Stop_rules`): a sparse trace of the best value reached
5243
+ and the evaluation at which each candidate stop rule would first have
5244
+ fired, written into the record, and summarised at the end of the run. It
5245
+ never stops anything. ``False`` turns it off; a dict overrides the rule
5246
+ constants.
5247
+
5240
5248
  Five passes:
5241
5249
  1. coarse grid, cold-started from the optimum, fully parallel;
5242
5250
  2. outward extension: any side whose points all sit below the threshold
@@ -5369,6 +5377,15 @@ def run_parallel_profile(
5369
5377
  # this one's travel into a rate. See _predicted_travel.
5370
5378
  "x_step": x_step,
5371
5379
  }
5380
+ if shadow_stop_rules:
5381
+ cfg = dict(shadow_stop_rules) if isinstance(shadow_stop_rules, dict) else {}
5382
+ cfg.setdefault("threshold", float(threshold))
5383
+ cfg["anchor"] = float(nll_at_optimum)
5384
+ job["stop_rules"] = cfg
5385
+ if resume_from is not None:
5386
+ # One history across launches of the same point.
5387
+ job["trace"] = resume_from.get("trace")
5388
+ job["shadow_stops"] = resume_from.get("shadow_stops")
5372
5389
  # Every job carries an explicit starting simplex, from its neighbour
5373
5390
  # where there is one and from the Wald SE otherwise. Leaving it to
5374
5391
  # scipy is what the two constructors exist to avoid: its 5%-of-value
@@ -5765,6 +5782,21 @@ def run_parallel_profile(
5765
5782
  param_names, res_x, meta, anchor, where, nll_at_optimum)
5766
5783
  _print_profile_convergence(convergence)
5767
5784
  _print_profile_reach(convergence["reach"])
5785
+ if shadow_stop_rules:
5786
+ try:
5787
+ from pyantigen.engine.Stop_rules import (
5788
+ format_shadow_summary, shadow_summary)
5789
+ cfg = (dict(shadow_stop_rules)
5790
+ if isinstance(shadow_stop_rules, dict) else {})
5791
+ cfg.setdefault("threshold", float(threshold))
5792
+ text = format_shadow_summary(shadow_summary(
5793
+ [r for pts in completed.values() for r in pts.values()],
5794
+ nll_at_optimum, cfg))
5795
+ if text:
5796
+ print(f"\n{text}", flush=True)
5797
+ except Exception as exc: # reporting must never end a run
5798
+ print(f"[profile] shadow stop rules: summary failed ({exc})",
5799
+ flush=True)
5768
5800
  if convergence["incomplete"]:
5769
5801
  parts = []
5770
5802
  if still_unfinished:
@@ -21,9 +21,14 @@ What it reads, per side of each parameter, in the optimizer's own space:
21
21
  * a side whose points are all below the threshold is extended outward by the
22
22
  curve-fitted step (``_extension_growth``), capped at ``max_step_decades`` per
23
23
  step so a flat curve cannot launch a jump of several decades;
24
- * a side that has crossed only on a point that did not converge has that point
25
- re-run warm from its inner neighbour, because an unconverged dNLL is an upper
26
- bound and a crossing read from one is not a crossing;
24
+ * a side that has crossed only on points that did not converge is narrowed, not
25
+ repeated: an unconverged dNLL is an upper bound, so a crossing read from one
26
+ is not a crossing, and re-running the same far point from the same start
27
+ reproduces it exactly. The probe goes between the innermost converged point
28
+ below the threshold and the innermost unconverged one above it (sqrt(dNLL)
29
+ interpolation), warm from the former, and waits while anything is still
30
+ running inside that bracket. Only once the bracket is tight is the outer
31
+ point itself re-run;
27
32
  * a bracketed side is narrowed by sqrt(dNLL) interpolation until the bracket is
28
33
  tight, then filled in toward ``n_grid`` points so the curve has a shape.
29
34
 
@@ -300,21 +305,57 @@ def plan_next_points(n_free, *, param_names, completed, in_flight, res_x,
300
305
  continue
301
306
 
302
307
  if above_t:
303
- # Crossed only on points that did not converge. Re-run the
304
- # innermost of them warm from its inner neighbour.
308
+ # Crossed only on points that did not converge. Each is an
309
+ # upper bound, so the crossing lies somewhere inside the
310
+ # bracket (nearest converged-below point, innermost of them).
305
311
  tgt = above_t[0]
306
- key = (name, ProfileCheckpointKey(tgt["x_fixed"]))
307
- d_t = _ext_distance(p_opt, tgt["x_fixed"], is_log)
312
+ x_t = tgt["x_fixed"]
313
+ d_t = _ext_distance(p_opt, x_t, is_log)
308
314
  inner_c = [r for r in below
309
315
  if _ext_distance(p_opt, r["x_fixed"], is_log) < d_t]
310
- if key not in attempted and not any(
311
- _near(tgt["x_fixed"], f, abs(tgt["x_fixed"] - p_opt))
312
- for f in fl):
313
- cands.append({"i": i, "name": name,
314
- "x": float(tgt["x_fixed"]),
315
- "seed": inner_c[-1] if inner_c else None,
316
- "phase": 3, "kind": "rerun", "tier": 1,
317
- "sign": sign, "attempt_key": key})
316
+ inner = inner_c[-1] if inner_c else None
317
+ x_in = inner["x_fixed"] if inner else p_opt
318
+ # Anything still running between the optimum and the failed
319
+ # point is about to become a better inner neighbour, or to
320
+ # replace the point we would plan. Planning now would seed from
321
+ # whatever has landed so far -- often nothing -- and a cold
322
+ # restart of a deterministic optimizer from the same start
323
+ # reproduces the failed point to the last digit.
324
+ if any(_ext_distance(p_opt, f, is_log) < d_t for f in fl):
325
+ continue
326
+ tight = _bracket_is_tight(x_in, x_t, p_opt, is_log,
327
+ bracket_rtol)
328
+ if not tight:
329
+ # Narrow the bracket toward the threshold instead of
330
+ # repeating the far point: a probe between the inner
331
+ # neighbour and the failed point is a short, warm
332
+ # continuation and so can converge where the long one
333
+ # could not. dnll at the failed point is too high, which
334
+ # puts the probe on the near side of the crossing; the
335
+ # next round narrows from there.
336
+ d_in_nll = dn(inner) if inner else 0.0
337
+ r_in, r_out = np.sqrt(max(d_in_nll, 0.0)), np.sqrt(dn(tgt))
338
+ frac = ((np.sqrt(thr) - r_in) / (r_out - r_in)
339
+ if r_out > r_in + 1e-12 else 0.5)
340
+ frac = float(min(max(frac, 0.05), 0.95))
341
+ x = x_in + frac * (x_t - x_in)
342
+ if free(x, abs(x_t - p_opt)) and \
343
+ ProfileCheckpointKey(x) not in completed.get(name, {}):
344
+ cands.append({"i": i, "name": name, "x": float(x),
345
+ "seed": inner, "phase": 1,
346
+ "kind": "narrow", "tier": 1,
347
+ "sign": sign})
348
+ continue
349
+ # The bracket is tight and the outer end still has not
350
+ # converged: give it one more go, warm from the inner point.
351
+ # With no inner point and a cold original this would be the
352
+ # same computation again, so it is not planned.
353
+ key = (name, ProfileCheckpointKey(x_t))
354
+ if key not in attempted and (
355
+ inner is not None or tgt.get("warm_seeded")):
356
+ cands.append({"i": i, "name": name, "x": float(x_t),
357
+ "seed": inner, "phase": 3, "kind": "rerun",
358
+ "tier": 1, "sign": sign, "attempt_key": key})
318
359
  continue
319
360
 
320
361
  # ── below the threshold everywhere so far: step outward ────────
@@ -0,0 +1,205 @@
1
+ """Shadow stop rules for profile points: record what they would have done.
2
+
3
+ A profile point is a nuisance minimization, and its only use to the planner
4
+ (:mod:`pyantigen.engine.Profile_plan`) is a verdict on the value it reaches U, an
5
+ upper bound on the profile there, against the threshold. Most of the 1500
6
+ evaluations a point is allowed buy digits that do not change the verdict:
7
+
8
+ * U already below the threshold is a proof that the profile is, and more
9
+ evaluations only lower it;
10
+ * U that is creeping down from far above the threshold will not get there
11
+ within the cap, and its magnitude is not trusted either way;
12
+ * U that has stopped improving by more than a fraction of its distance from the
13
+ threshold has said everything it is going to say.
14
+
15
+ None of this has been calibrated against real trajectories, and loosening the
16
+ optimizer's tolerance blind has cost accuracy here before (see the TOLERANCES
17
+ note in Optimizer_settings). So this module only *watches*. For each point it
18
+ keeps a sparse trace of the best value reached, and the first evaluation at which
19
+ each rule would have fired. Nothing is ever stopped. The numbers are for reading
20
+ off a finished run (:func:`shadow_summary`) and tuning the constants below
21
+ before any of them is allowed to end a point.
22
+
23
+ The rules, with U = best nll reached - anchor and thr the profile threshold:
24
+
25
+ ``below`` U < ``below_frac`` * thr. A proof, needs no minimum spend.
26
+ ``hopeless`` U > ``u_trust`` and, extrapolating the log-rate of the last
27
+ window to the evaluation cap, U would still be above ``u_trust``.
28
+ The extrapolation is optimistic -- Nelder-Mead's descent usually
29
+ slows -- so a point it fires on is very unlikely to have got
30
+ anywhere useful.
31
+ ``settled`` U improved by less than ``kappa`` * |U - thr| over the last
32
+ window: the tolerance scales with the distance from the decision,
33
+ so a U of 7.6 needs ~0.4 and a U of 0.04 needs almost nothing.
34
+ Measured on progress over a window, not on the simplex spread,
35
+ which fires on a collapsed simplex in a curved valley.
36
+
37
+ The two window rules wait for ``max(MIN_EVALS, MIN_EVALS_PER_DIM * (n + 1))``
38
+ evaluations plus one window, so the initial simplex has been built and the
39
+ descent has started.
40
+ """
41
+ import math
42
+
43
+ DEFAULTS = {
44
+ "threshold": 1.9207,
45
+ "below_frac": 0.25,
46
+ "u_trust": 20.0,
47
+ "kappa": 0.05,
48
+ "window": 100,
49
+ "sample_every": 25,
50
+ }
51
+ MIN_EVALS = 60
52
+ MIN_EVALS_PER_DIM = 5
53
+
54
+ RULES = ("below", "hopeless", "settled")
55
+
56
+
57
+ class PointMonitor:
58
+ """Watches one point's best-so-far; never alters it.
59
+
60
+ *trace* and *fired* continue those of an earlier launch of the same point,
61
+ so a point resumed after the clock stopped keeps one history.
62
+ """
63
+
64
+ def __init__(self, anchor, max_fev, n_nuis, cfg=None, trace=None, fired=None):
65
+ c = dict(DEFAULTS)
66
+ c.update({k: v for k, v in (cfg or {}).items() if k in DEFAULTS})
67
+ self.cfg = c
68
+ self.anchor = float(anchor)
69
+ self.max_fev = (float(max_fev) if max_fev is not None
70
+ and math.isfinite(float(max_fev)) else None)
71
+ self.min_evals = max(MIN_EVALS, MIN_EVALS_PER_DIM * (int(n_nuis) + 1))
72
+ self.trace = [list(map(float, p)) for p in (trace or [])]
73
+ self.fired = {k: dict(v) for k, v in (fired or {}).items()}
74
+ self._last_n = int(self.trace[-1][0]) if self.trace else 0
75
+ self._best = self.trace[-1][1] if self.trace else math.inf
76
+
77
+ # -- feeding -----------------------------------------------------------
78
+ def update(self, nfev, best_nll):
79
+ """Call after every evaluation with the running total and best so far."""
80
+ if best_nll is None or not math.isfinite(best_nll):
81
+ return
82
+ nfev = int(nfev)
83
+ if nfev <= self._last_n and self.trace:
84
+ return
85
+ self._best = float(best_nll)
86
+ self._last_n = nfev
87
+ u = self._best - self.anchor
88
+ if "below" not in self.fired and u < self.cfg["below_frac"] * self.cfg["threshold"]:
89
+ self.fired["below"] = {"nfev": nfev, "U": u}
90
+ if nfev == 1 or nfev % self.cfg["sample_every"] == 0:
91
+ self.trace.append([nfev, self._best])
92
+ self._window_rules(nfev, u)
93
+
94
+ def _reference(self, nfev):
95
+ """The last sample at least one window back, or None."""
96
+ want = nfev - self.cfg["window"]
97
+ ref = None
98
+ for n, b in self.trace:
99
+ if n <= want:
100
+ ref = (n, b)
101
+ else:
102
+ break
103
+ return ref
104
+
105
+ def _window_rules(self, nfev, u):
106
+ if nfev < self.min_evals + self.cfg["window"]:
107
+ return
108
+ ref = self._reference(nfev)
109
+ if ref is None:
110
+ return
111
+ n_ref, b_ref = ref
112
+ u_ref = b_ref - self.anchor
113
+ thr, u_trust = self.cfg["threshold"], self.cfg["u_trust"]
114
+ gain = u_ref - u
115
+
116
+ if "settled" not in self.fired and gain < self.cfg["kappa"] * abs(u - thr):
117
+ self.fired["settled"] = {"nfev": nfev, "U": u, "gain": gain}
118
+
119
+ if ("hopeless" not in self.fired and u > u_trust and self.max_fev is not None
120
+ and u_ref > 0 and u > 0):
121
+ span = nfev - n_ref
122
+ rate = math.log(u_ref / u) / span if span > 0 else 0.0
123
+ u_cap = (u * math.exp(-rate * max(self.max_fev - nfev, 0.0))
124
+ if rate > 0 else u)
125
+ if u_cap > u_trust:
126
+ self.fired["hopeless"] = {"nfev": nfev, "U": u, "U_cap": u_cap}
127
+
128
+ # -- reporting ---------------------------------------------------------
129
+ def finish(self, nfev, best_nll):
130
+ """The fields to merge into the point's record."""
131
+ if best_nll is not None and math.isfinite(best_nll):
132
+ if not self.trace or int(self.trace[-1][0]) != int(nfev):
133
+ self.trace.append([int(nfev), float(best_nll)])
134
+ return {"trace": self.trace, "shadow_stops": self.fired}
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Reading a finished run
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def _verdict_class(u, thr, u_trust):
142
+ if u < thr:
143
+ return "below"
144
+ return "mid" if u < u_trust else "high"
145
+
146
+
147
+ def shadow_summary(records, anchor, cfg=None):
148
+ """What the rules would have saved, and what they would have got wrong.
149
+
150
+ *records* is any iterable of point records carrying ``shadow_stops``. For
151
+ each rule: how many points it fired on, the evaluations it would have
152
+ saved (the point's ``nfev_total`` minus the evaluation it fired at), and how
153
+ many of those points ended in a different class -- below the threshold,
154
+ between it and ``u_trust``, or above -- than the value the rule fired on.
155
+ A class change is the only thing that matters to the planner, so that count
156
+ is the cost of the rule, in the only currency that counts.
157
+ """
158
+ c = dict(DEFAULTS)
159
+ c.update({k: v for k, v in (cfg or {}).items() if k in DEFAULTS})
160
+ thr, u_trust = c["threshold"], c["u_trust"]
161
+ out = {r: {"fired": 0, "saved": 0, "class_changed": 0, "examples": []}
162
+ for r in RULES}
163
+ n_points, spent = 0, 0
164
+ for rec in records:
165
+ stops = rec.get("shadow_stops")
166
+ nll, total = rec.get("nll"), rec.get("nfev_total")
167
+ if stops is None or nll is None or total is None:
168
+ continue
169
+ n_points += 1
170
+ spent += int(total)
171
+ u_final = float(nll) - float(anchor)
172
+ for rule, hit in stops.items():
173
+ if rule not in out:
174
+ continue
175
+ o = out[rule]
176
+ o["fired"] += 1
177
+ o["saved"] += max(int(total) - int(hit["nfev"]), 0)
178
+ if (_verdict_class(hit["U"], thr, u_trust)
179
+ != _verdict_class(u_final, thr, u_trust)):
180
+ o["class_changed"] += 1
181
+ if len(o["examples"]) < 5:
182
+ o["examples"].append({
183
+ "param": rec.get("param_name"), "x": rec.get("x_fixed"),
184
+ "nfev": hit["nfev"], "U_at_fire": hit["U"],
185
+ "U_final": u_final})
186
+ return {"n_points": n_points, "evals_spent": spent, "rules": out}
187
+
188
+
189
+ def format_shadow_summary(summary):
190
+ """The summary as log lines."""
191
+ s = summary
192
+ if not s["n_points"]:
193
+ return ""
194
+ lines = [f"[profile] shadow stop rules (nothing was stopped): "
195
+ f"{s['n_points']} point(s), {s['evals_spent']} evaluation(s) spent"]
196
+ for rule in RULES:
197
+ o = s["rules"][rule]
198
+ lines.append(f" {rule:<9} fired on {o['fired']:>3}, would have saved "
199
+ f"{o['saved']:>6} eval(s), class changed on "
200
+ f"{o['class_changed']}")
201
+ for e in o["examples"]:
202
+ lines.append(f" changed: {e['param']} at {e['x']:.6g}: "
203
+ f"U {e['U_at_fire']:.3g} at eval {e['nfev']} -> "
204
+ f"{e['U_final']:.3g} final")
205
+ return "\n".join(lines)
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.1.0",
3
- "distance": 0,
4
- "node": "g664f039a120684d736966bef028ad1f83320d9bc",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-06"
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
File without changes
File without changes