PyAntiGen 2.1.0__tar.gz → 2.1.2__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 (108) hide show
  1. {pyantigen-2.1.0 → pyantigen-2.1.2}/PKG-INFO +1 -1
  2. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/SOURCES.txt +3 -0
  4. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/scm_file_list.json +7 -0
  5. pyantigen-2.1.2/PyAntiGen.egg-info/scm_version.json +8 -0
  6. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/_version.py +3 -3
  7. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Evaluator.py +54 -1
  8. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Optimize.py +55 -4
  9. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Preequil_cache.py +9 -0
  10. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Profile_plan.py +187 -58
  11. pyantigen-2.1.2/pyantigen/engine/Segmented.py +157 -0
  12. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Simulate.py +58 -7
  13. pyantigen-2.1.2/pyantigen/engine/Stop_rules.py +205 -0
  14. pyantigen-2.1.2/pyantigen/generate/input_schedule.py +624 -0
  15. pyantigen-2.1.0/PyAntiGen.egg-info/scm_version.json +0 -8
  16. {pyantigen-2.1.0 → pyantigen-2.1.2}/.github/workflows/publish.yml +0 -0
  17. {pyantigen-2.1.0 → pyantigen-2.1.2}/.github/workflows/tests.yml +0 -0
  18. {pyantigen-2.1.0 → pyantigen-2.1.2}/.gitignore +0 -0
  19. {pyantigen-2.1.0 → pyantigen-2.1.2}/.vscode/launch.json +0 -0
  20. {pyantigen-2.1.0 → pyantigen-2.1.2}/LICENSE +0 -0
  21. {pyantigen-2.1.0 → pyantigen-2.1.2}/MANIFEST.in +0 -0
  22. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  23. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/entry_points.txt +0 -0
  24. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/requires.txt +0 -0
  25. {pyantigen-2.1.0 → pyantigen-2.1.2}/PyAntiGen.egg-info/top_level.txt +0 -0
  26. {pyantigen-2.1.0 → pyantigen-2.1.2}/README.md +0 -0
  27. {pyantigen-2.1.0 → pyantigen-2.1.2}/docs/MULTISTART.md +0 -0
  28. {pyantigen-2.1.0 → pyantigen-2.1.2}/docs/PROTOCOL_LAYER.md +0 -0
  29. {pyantigen-2.1.0 → pyantigen-2.1.2}/docs/V2_DESIGN.md +0 -0
  30. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/.agents/skills/module_generation/SKILL.md +0 -0
  31. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  32. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  33. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/.gitignore +0 -0
  34. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/__init__.py +0 -0
  35. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/cli.py +0 -0
  36. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Anchor_cache.py +0 -0
  37. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Deadline.py +0 -0
  38. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Differential_evolution.py +0 -0
  39. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Event_times.py +0 -0
  40. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Fast_profile.py +0 -0
  41. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Fit_cache.py +0 -0
  42. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Identifiability.py +0 -0
  43. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Model_cache.py +0 -0
  44. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Model_optimize.py +0 -0
  45. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Model_simulate.py +0 -0
  46. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Multistart.py +0 -0
  47. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Nelder_mead.py +0 -0
  48. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Noise_floor.py +0 -0
  49. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Petab_export.py +0 -0
  50. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Profile_checkpoint.py +0 -0
  51. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/README.md +0 -0
  52. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Results.py +0 -0
  53. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/Sensitivity_analysis.py +0 -0
  54. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/engine/__init__.py +0 -0
  55. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/AntimonyGen.py +0 -0
  56. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/RxnDict_to_antimony.py +0 -0
  57. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/TelluriumGen.py +0 -0
  58. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/__init__.py +0 -0
  59. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/antimony_utils.py +0 -0
  60. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/data_interpolation.py +0 -0
  61. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/isotopomer_tools.py +0 -0
  62. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/model_generation.py +0 -0
  63. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/models.py +0 -0
  64. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/module_base.py +0 -0
  65. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/pyantigen.py +0 -0
  66. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/rate_laws.py +0 -0
  67. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/generate/reaction_creation.py +0 -0
  68. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/__init__.py +0 -0
  69. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/assay.py +0 -0
  70. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/describe.py +0 -0
  71. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/design.py +0 -0
  72. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/lower_v1.py +0 -0
  73. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/optimization.py +0 -0
  74. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/params.py +0 -0
  75. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/quantity.py +0 -0
  76. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/reagent.py +0 -0
  77. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/refs.py +0 -0
  78. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/remarks.py +0 -0
  79. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/serialize.py +0 -0
  80. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/study/validate.py +0 -0
  81. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/AntiGen_paths.py +0 -0
  82. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Flipflop_reference.py +0 -0
  83. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Model_generate.py +0 -0
  84. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Model_run.py +0 -0
  85. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Data.py +0 -0
  86. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Events.py +0 -0
  87. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Experiment.py +0 -0
  88. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Loss_config.py +0 -0
  89. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Observed_species.py +0 -0
  90. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Optimizer_settings.py +0 -0
  91. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Plots.py +0 -0
  92. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Solver_settings.py +0 -0
  93. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Update_opt_parameters.py +0 -0
  94. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Update_parameters.py +0 -0
  95. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example1_ADneg.json +0 -0
  96. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example1_ADpos.json +0 -0
  97. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example3_joint.json +0 -0
  98. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example4_flipflop.json +0 -0
  99. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/example.py +0 -0
  100. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/studies/example.json +0 -0
  101. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/studies/example.py +0 -0
  102. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/Example/studies/flipflop.json +0 -0
  103. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/data/ADneg.csv +0 -0
  104. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/data/ADpos.csv +0 -0
  105. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/data/Flipflop.csv +0 -0
  106. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyantigen/template/data/make_flipflop_data.py +0 -0
  107. {pyantigen-2.1.0 → pyantigen-2.1.2}/pyproject.toml +0 -0
  108. {pyantigen-2.1.0 → pyantigen-2.1.2}/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.2
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.2
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
@@ -45,8 +45,10 @@ pyantigen/engine/Profile_checkpoint.py
45
45
  pyantigen/engine/Profile_plan.py
46
46
  pyantigen/engine/README.md
47
47
  pyantigen/engine/Results.py
48
+ pyantigen/engine/Segmented.py
48
49
  pyantigen/engine/Sensitivity_analysis.py
49
50
  pyantigen/engine/Simulate.py
51
+ pyantigen/engine/Stop_rules.py
50
52
  pyantigen/engine/__init__.py
51
53
  pyantigen/generate/AntimonyGen.py
52
54
  pyantigen/generate/RxnDict_to_antimony.py
@@ -54,6 +56,7 @@ pyantigen/generate/TelluriumGen.py
54
56
  pyantigen/generate/__init__.py
55
57
  pyantigen/generate/antimony_utils.py
56
58
  pyantigen/generate/data_interpolation.py
59
+ pyantigen/generate/input_schedule.py
57
60
  pyantigen/generate/isotopomer_tools.py
58
61
  pyantigen/generate/model_generation.py
59
62
  pyantigen/generate/models.py
@@ -37,8 +37,10 @@
37
37
  "pyantigen/engine/Profile_plan.py",
38
38
  "pyantigen/engine/README.md",
39
39
  "pyantigen/engine/Results.py",
40
+ "pyantigen/engine/Segmented.py",
40
41
  "pyantigen/engine/Sensitivity_analysis.py",
41
42
  "pyantigen/engine/Simulate.py",
43
+ "pyantigen/engine/Stop_rules.py",
42
44
  "pyantigen/engine/__init__.py",
43
45
  "pyantigen/generate/AntimonyGen.py",
44
46
  "pyantigen/generate/RxnDict_to_antimony.py",
@@ -46,6 +48,7 @@
46
48
  "pyantigen/generate/__init__.py",
47
49
  "pyantigen/generate/antimony_utils.py",
48
50
  "pyantigen/generate/data_interpolation.py",
51
+ "pyantigen/generate/input_schedule.py",
49
52
  "pyantigen/generate/isotopomer_tools.py",
50
53
  "pyantigen/generate/model_generation.py",
51
54
  "pyantigen/generate/models.py",
@@ -121,14 +124,18 @@
121
124
  "tests/engine/test_profile_stream.py",
122
125
  "tests/engine/test_results_csv_policy.py",
123
126
  "tests/engine/test_search_mode.py",
127
+ "tests/engine/test_segment_sensitivity.py",
128
+ "tests/engine/test_segmented.py",
124
129
  "tests/engine/test_sigma_floor.py",
125
130
  "tests/engine/test_slice_screen.py",
126
131
  "tests/engine/test_slice_window.py",
127
132
  "tests/engine/test_solver_fingerprint.py",
133
+ "tests/engine/test_stop_rules.py",
128
134
  "tests/engine/test_tolerance_floor.py",
129
135
  "tests/engine/test_wald_units.py",
130
136
  "tests/generate/test_gen_antimony_utils.py",
131
137
  "tests/generate/test_gen_data_interpolation.py",
138
+ "tests/generate/test_gen_input_schedule.py",
132
139
  "tests/generate/test_gen_models.py",
133
140
  "tests/generate/test_gen_pyantigen_api.py",
134
141
  "tests/generate/test_gen_rxndict_to_antimony.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.1.2",
3
+ "distance": 0,
4
+ "node": "g6b3300aa1e0f55f4d35c0849df65cfa2bbbd8183",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-11"
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.2'
22
+ __version_tuple__ = version_tuple = (2, 1, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g664f039a1'
24
+ __commit_id__ = commit_id = 'g6b3300aa1'
@@ -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
@@ -5466,6 +5483,14 @@ def run_parallel_profile(
5466
5483
  key = ProfileCheckpointKey(res["x_fixed"])
5467
5484
  prev = completed.get(name, {}).get(key)
5468
5485
  keep = res
5486
+ # A planner restart (see Profile_plan) that ran to its end is counted on
5487
+ # the point, so a relaunch does not restart it again: the planner reads
5488
+ # the count to decide whether a value past the threshold can be trusted.
5489
+ finished_rerun = (str(res.get("pass_label", "")).endswith("rerun")
5490
+ and res.get("status") == "ok"
5491
+ and not res.get("interrupted"))
5492
+ if prev is not None and finished_rerun:
5493
+ res["reruns_tried"] = int(prev.get("reruns_tried") or 0) + 1
5469
5494
  # Two evaluations at the same fixed value are both upper bounds on the
5470
5495
  # profile, so the lower one is the better estimate. Nothing in passes 1,
5471
5496
  # 2 or 4 re-visits a point, but keeping the rule here means the store
@@ -5477,8 +5502,17 @@ def run_parallel_profile(
5477
5502
  # Dropping it would resume the point forever against a budget that
5478
5503
  # never moves.
5479
5504
  if not (prev.get("interrupted") and res.get("status") == "ok"):
5480
- return
5481
- keep = _carry_resume_state(dict(prev), res)
5505
+ if not finished_rerun:
5506
+ return
5507
+ # A restart that did not lower the point: the value stands, but
5508
+ # the point has had its restart. Written as a tie, which the
5509
+ # store resolves to the later record.
5510
+ keep = dict(prev)
5511
+ keep["reruns_tried"] = res["reruns_tried"]
5512
+ else:
5513
+ keep = _carry_resume_state(dict(prev), res)
5514
+ if finished_rerun:
5515
+ keep["reruns_tried"] = res["reruns_tried"]
5482
5516
  if checkpoint is not None and keep.get("status") == "ok":
5483
5517
  checkpoint.append(keep)
5484
5518
  completed.setdefault(name, {})[key] = keep
@@ -5765,6 +5799,21 @@ def run_parallel_profile(
5765
5799
  param_names, res_x, meta, anchor, where, nll_at_optimum)
5766
5800
  _print_profile_convergence(convergence)
5767
5801
  _print_profile_reach(convergence["reach"])
5802
+ if shadow_stop_rules:
5803
+ try:
5804
+ from pyantigen.engine.Stop_rules import (
5805
+ format_shadow_summary, shadow_summary)
5806
+ cfg = (dict(shadow_stop_rules)
5807
+ if isinstance(shadow_stop_rules, dict) else {})
5808
+ cfg.setdefault("threshold", float(threshold))
5809
+ text = format_shadow_summary(shadow_summary(
5810
+ [r for pts in completed.values() for r in pts.values()],
5811
+ nll_at_optimum, cfg))
5812
+ if text:
5813
+ print(f"\n{text}", flush=True)
5814
+ except Exception as exc: # reporting must never end a run
5815
+ print(f"[profile] shadow stop rules: summary failed ({exc})",
5816
+ flush=True)
5768
5817
  if convergence["incomplete"]:
5769
5818
  parts = []
5770
5819
  if still_unfinished:
@@ -5940,6 +5989,8 @@ def _run_warm_passes(nll_batch_profile, completed, param_names, res_x, meta,
5940
5989
  # probe) and is counted by the refinement budget, so it must
5941
5990
  # survive a warm re-run regardless of which value won.
5942
5991
  keep["phase"] = prev.get("phase", res.get("phase", 1))
5992
+ if prev.get("reruns_tried"):
5993
+ keep["reruns_tried"] = prev["reruns_tried"]
5943
5994
  keep["warm_refined"] = int(
5944
5995
  (prev or {}).get("warm_refined", 0)) + 1
5945
5996
  # How much re-running this point warm actually lowered it. This is
@@ -128,6 +128,15 @@ class PreequilCache:
128
128
  if block is None:
129
129
  return solver_settings
130
130
 
131
+ # The cached block is integrated on its own, without the schedule's
132
+ # steps (see Segmented). A schedule that switches inside it cannot be
133
+ # replayed from a cached end state.
134
+ schedule = solver_settings.get("schedule")
135
+ if schedule is not None:
136
+ from pyantigen.engine.Segmented import switches_before
137
+ if switches_before(r, schedule, float(block["end"])):
138
+ return solver_settings
139
+
131
140
  configure_integrator(r, solver_settings)
132
141
  key = _digest(save_model_state(r), block, solver_settings)
133
142