PyAntiGen 2.1.1__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.1 → pyantigen-2.1.2}/PKG-INFO +1 -1
  2. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/SOURCES.txt +2 -0
  4. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/scm_file_list.json +5 -0
  5. pyantigen-2.1.2/PyAntiGen.egg-info/scm_version.json +8 -0
  6. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/_version.py +3 -3
  7. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Optimize.py +21 -2
  8. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Preequil_cache.py +9 -0
  9. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Profile_plan.py +187 -99
  10. pyantigen-2.1.2/pyantigen/engine/Segmented.py +157 -0
  11. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Simulate.py +58 -7
  12. pyantigen-2.1.2/pyantigen/generate/input_schedule.py +624 -0
  13. pyantigen-2.1.1/PyAntiGen.egg-info/scm_version.json +0 -8
  14. {pyantigen-2.1.1 → pyantigen-2.1.2}/.github/workflows/publish.yml +0 -0
  15. {pyantigen-2.1.1 → pyantigen-2.1.2}/.github/workflows/tests.yml +0 -0
  16. {pyantigen-2.1.1 → pyantigen-2.1.2}/.gitignore +0 -0
  17. {pyantigen-2.1.1 → pyantigen-2.1.2}/.vscode/launch.json +0 -0
  18. {pyantigen-2.1.1 → pyantigen-2.1.2}/LICENSE +0 -0
  19. {pyantigen-2.1.1 → pyantigen-2.1.2}/MANIFEST.in +0 -0
  20. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  21. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/entry_points.txt +0 -0
  22. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/requires.txt +0 -0
  23. {pyantigen-2.1.1 → pyantigen-2.1.2}/PyAntiGen.egg-info/top_level.txt +0 -0
  24. {pyantigen-2.1.1 → pyantigen-2.1.2}/README.md +0 -0
  25. {pyantigen-2.1.1 → pyantigen-2.1.2}/docs/MULTISTART.md +0 -0
  26. {pyantigen-2.1.1 → pyantigen-2.1.2}/docs/PROTOCOL_LAYER.md +0 -0
  27. {pyantigen-2.1.1 → pyantigen-2.1.2}/docs/V2_DESIGN.md +0 -0
  28. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/.agents/skills/module_generation/SKILL.md +0 -0
  29. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  30. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  31. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/.gitignore +0 -0
  32. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/__init__.py +0 -0
  33. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/cli.py +0 -0
  34. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Anchor_cache.py +0 -0
  35. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Deadline.py +0 -0
  36. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Differential_evolution.py +0 -0
  37. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Evaluator.py +0 -0
  38. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Event_times.py +0 -0
  39. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Fast_profile.py +0 -0
  40. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Fit_cache.py +0 -0
  41. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Identifiability.py +0 -0
  42. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Model_cache.py +0 -0
  43. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Model_optimize.py +0 -0
  44. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Model_simulate.py +0 -0
  45. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Multistart.py +0 -0
  46. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Nelder_mead.py +0 -0
  47. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Noise_floor.py +0 -0
  48. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Petab_export.py +0 -0
  49. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Profile_checkpoint.py +0 -0
  50. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/README.md +0 -0
  51. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Results.py +0 -0
  52. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Sensitivity_analysis.py +0 -0
  53. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/Stop_rules.py +0 -0
  54. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/engine/__init__.py +0 -0
  55. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/AntimonyGen.py +0 -0
  56. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/RxnDict_to_antimony.py +0 -0
  57. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/TelluriumGen.py +0 -0
  58. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/__init__.py +0 -0
  59. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/antimony_utils.py +0 -0
  60. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/data_interpolation.py +0 -0
  61. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/isotopomer_tools.py +0 -0
  62. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/model_generation.py +0 -0
  63. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/models.py +0 -0
  64. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/module_base.py +0 -0
  65. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/pyantigen.py +0 -0
  66. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/rate_laws.py +0 -0
  67. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/generate/reaction_creation.py +0 -0
  68. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/__init__.py +0 -0
  69. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/assay.py +0 -0
  70. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/describe.py +0 -0
  71. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/design.py +0 -0
  72. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/lower_v1.py +0 -0
  73. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/optimization.py +0 -0
  74. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/params.py +0 -0
  75. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/quantity.py +0 -0
  76. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/reagent.py +0 -0
  77. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/refs.py +0 -0
  78. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/remarks.py +0 -0
  79. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/serialize.py +0 -0
  80. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/study/validate.py +0 -0
  81. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/AntiGen_paths.py +0 -0
  82. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Flipflop_reference.py +0 -0
  83. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Model_generate.py +0 -0
  84. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Model_run.py +0 -0
  85. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Data.py +0 -0
  86. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Events.py +0 -0
  87. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Experiment.py +0 -0
  88. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Loss_config.py +0 -0
  89. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Observed_species.py +0 -0
  90. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Optimizer_settings.py +0 -0
  91. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Plots.py +0 -0
  92. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Solver_settings.py +0 -0
  93. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Update_opt_parameters.py +0 -0
  94. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/Modules/Update_parameters.py +0 -0
  95. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example1_ADneg.json +0 -0
  96. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example1_ADpos.json +0 -0
  97. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example3_joint.json +0 -0
  98. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/Example4_flipflop.json +0 -0
  99. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/optimizations/example.py +0 -0
  100. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/studies/example.json +0 -0
  101. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/studies/example.py +0 -0
  102. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/Example/studies/flipflop.json +0 -0
  103. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/data/ADneg.csv +0 -0
  104. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/data/ADpos.csv +0 -0
  105. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/data/Flipflop.csv +0 -0
  106. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyantigen/template/data/make_flipflop_data.py +0 -0
  107. {pyantigen-2.1.1 → pyantigen-2.1.2}/pyproject.toml +0 -0
  108. {pyantigen-2.1.1 → 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.1
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.1
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,6 +45,7 @@ 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
50
51
  pyantigen/engine/Stop_rules.py
@@ -55,6 +56,7 @@ pyantigen/generate/TelluriumGen.py
55
56
  pyantigen/generate/__init__.py
56
57
  pyantigen/generate/antimony_utils.py
57
58
  pyantigen/generate/data_interpolation.py
59
+ pyantigen/generate/input_schedule.py
58
60
  pyantigen/generate/isotopomer_tools.py
59
61
  pyantigen/generate/model_generation.py
60
62
  pyantigen/generate/models.py
@@ -37,6 +37,7 @@
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",
42
43
  "pyantigen/engine/Stop_rules.py",
@@ -47,6 +48,7 @@
47
48
  "pyantigen/generate/__init__.py",
48
49
  "pyantigen/generate/antimony_utils.py",
49
50
  "pyantigen/generate/data_interpolation.py",
51
+ "pyantigen/generate/input_schedule.py",
50
52
  "pyantigen/generate/isotopomer_tools.py",
51
53
  "pyantigen/generate/model_generation.py",
52
54
  "pyantigen/generate/models.py",
@@ -122,6 +124,8 @@
122
124
  "tests/engine/test_profile_stream.py",
123
125
  "tests/engine/test_results_csv_policy.py",
124
126
  "tests/engine/test_search_mode.py",
127
+ "tests/engine/test_segment_sensitivity.py",
128
+ "tests/engine/test_segmented.py",
125
129
  "tests/engine/test_sigma_floor.py",
126
130
  "tests/engine/test_slice_screen.py",
127
131
  "tests/engine/test_slice_window.py",
@@ -131,6 +135,7 @@
131
135
  "tests/engine/test_wald_units.py",
132
136
  "tests/generate/test_gen_antimony_utils.py",
133
137
  "tests/generate/test_gen_data_interpolation.py",
138
+ "tests/generate/test_gen_input_schedule.py",
134
139
  "tests/generate/test_gen_models.py",
135
140
  "tests/generate/test_gen_pyantigen_api.py",
136
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.1'
22
- __version_tuple__ = version_tuple = (2, 1, 1)
21
+ __version__ = version = '2.1.2'
22
+ __version_tuple__ = version_tuple = (2, 1, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g6282b0d51'
24
+ __commit_id__ = commit_id = 'g6b3300aa1'
@@ -5483,6 +5483,14 @@ def run_parallel_profile(
5483
5483
  key = ProfileCheckpointKey(res["x_fixed"])
5484
5484
  prev = completed.get(name, {}).get(key)
5485
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
5486
5494
  # Two evaluations at the same fixed value are both upper bounds on the
5487
5495
  # profile, so the lower one is the better estimate. Nothing in passes 1,
5488
5496
  # 2 or 4 re-visits a point, but keeping the rule here means the store
@@ -5494,8 +5502,17 @@ def run_parallel_profile(
5494
5502
  # Dropping it would resume the point forever against a budget that
5495
5503
  # never moves.
5496
5504
  if not (prev.get("interrupted") and res.get("status") == "ok"):
5497
- return
5498
- 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"]
5499
5516
  if checkpoint is not None and keep.get("status") == "ok":
5500
5517
  checkpoint.append(keep)
5501
5518
  completed.setdefault(name, {})[key] = keep
@@ -5972,6 +5989,8 @@ def _run_warm_passes(nll_batch_profile, completed, param_names, res_x, meta,
5972
5989
  # probe) and is counted by the refinement budget, so it must
5973
5990
  # survive a warm re-run regardless of which value won.
5974
5991
  keep["phase"] = prev.get("phase", res.get("phase", 1))
5992
+ if prev.get("reruns_tried"):
5993
+ keep["reruns_tried"] = prev["reruns_tried"]
5975
5994
  keep["warm_refined"] = int(
5976
5995
  (prev or {}).get("warm_refined", 0)) + 1
5977
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
 
@@ -21,16 +21,22 @@ 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 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;
24
+ * the innermost point past the threshold is the bracket's outer end only if it
25
+ is firm: it converged from a warm start, or it has been restarted once. Any
26
+ other value is an upper bound with an unknown excess -- a warm restart from a
27
+ nearer neighbour only lowers it, and the crossing may then lie beyond it -- so
28
+ it is restarted at once (the nearest two such points), alongside the probes
29
+ that narrow the bracket, not after them. The optimizer is deterministic, so a
30
+ restart is planned only from a start the point did not have: a nearer inner
31
+ neighbour, or its own end state if it did not converge. The probe goes between
32
+ the innermost point at or below the threshold and the outer end (sqrt(dNLL)
33
+ interpolation), warm from the former;
32
34
  * a bracketed side is narrowed by sqrt(dNLL) interpolation until the bracket is
33
- tight, then filled in toward ``n_grid`` points so the curve has a shape.
35
+ tight, then filled in toward ``n_grid`` points so the curve has a shape;
36
+ * workers left over once every side has its one probe are given extra probes
37
+ inside open brackets (see ``_SPEC_STEP``) and extra rungs beyond the next one
38
+ on a side still below the threshold (``_SPEC_MAX_EXT``), so a side advancing
39
+ one point per round does not hold the pool at a handful of cores.
34
40
 
35
41
  Below-threshold points count whether or not they converged: an upper bound at or
36
42
  under the threshold is still under it.
@@ -55,11 +61,74 @@ FIRST_PROBE_MODES = ("max", "slice", "slice_ladder")
55
61
  # termination independent of them.
56
62
  _SIDE_CEILING_EXTRA = 4
57
63
 
64
+ # Extra probes inside an open bracket, for workers that would otherwise idle. A
65
+ # bracket is narrowed by one probe per round, and a round is hours, so a side
66
+ # with one open bracket holds a single worker for as long as it takes; the rest
67
+ # of the pool drains as the other sides finish. The extras are spaced
68
+ # ``_SPEC_STEP`` of the bracket apart around the interpolated guess, nearest the
69
+ # guess first, and are only ever planned after every side has had its own
70
+ # primary probe (tier ``3 + j`` for the j-th extra), so they use cores that
71
+ # nothing else wanted.
72
+ _SPEC_STEP = 0.15
73
+ _SPEC_MAX = 4
74
+ # The same for a side still below the threshold: rungs beyond the next one.
75
+ _SPEC_MAX_EXT = 3
76
+ # Provisional points past the threshold restarted at once, beyond the nearest.
77
+ _SPEC_RERUN = 1
78
+
79
+
80
+ def _bracket_fracs(frac, n_extra=_SPEC_MAX, step=_SPEC_STEP):
81
+ """Fractions of a bracket to probe: the guess, then alternately either side."""
82
+ out = [float(frac)]
83
+ for k in range(1, n_extra + 1):
84
+ off = step * ((k + 1) // 2) * (1 if k % 2 else -1)
85
+ f = float(min(max(frac + off, 0.05), 0.95))
86
+ if all(abs(f - g) > 1e-9 for g in out):
87
+ out.append(f)
88
+ return out
89
+
58
90
 
59
91
  def _converged(rec):
60
92
  return bool(rec.get("converged")) and not rec.get("interrupted")
61
93
 
62
94
 
95
+ def _rerun_count(rec):
96
+ """How many warm restarts this point has had (the planner's, not the grid's)."""
97
+ n = rec.get("reruns_tried") or 0
98
+ if str(rec.get("pass_label", "")).endswith("rerun"):
99
+ n = max(int(n), 1)
100
+ return int(n)
101
+
102
+
103
+ def _is_firm(rec):
104
+ """Whether a point past the threshold can be trusted as a bracket end.
105
+
106
+ A value is firm once it has been through a warm start: it converged from a
107
+ neighbour's solution, or it has been restarted once and is accepted as it
108
+ stands. A cold or unconverged value is only an upper bound with an unknown
109
+ excess, so the crossing is not read from it until it has been restarted.
110
+ """
111
+ return _rerun_count(rec) >= 1 or (_converged(rec) and bool(rec.get("warm_seeded")))
112
+
113
+
114
+ def _rerun_seed(rec, inner):
115
+ """Where to restart *rec* from, or None if that would repeat it exactly.
116
+
117
+ The optimizer is deterministic, so a restart from the start the point
118
+ already had reproduces it to the last digit. From a nearer inner neighbour
119
+ it is new information; failing that, an unconverged point is carried on from
120
+ where it stopped.
121
+ """
122
+ if inner is not None:
123
+ xs, nx = rec.get("x_start"), inner.get("nuisance_x")
124
+ if (xs is None or nx is None or len(xs) != len(nx)
125
+ or not np.allclose(xs, nx, rtol=0.0, atol=1e-9)):
126
+ return inner
127
+ if not _converged(rec) and rec.get("nuisance_x"):
128
+ return rec
129
+ return None
130
+
131
+
63
132
  def _near(a, b, d_ref, rel=0.02):
64
133
  """Whether two opt-space values are the same point for planning purposes."""
65
134
  return abs(a - b) <= rel * max(abs(d_ref), 1e-9)
@@ -148,7 +217,8 @@ def plan_next_points(n_free, *, param_names, completed, in_flight, res_x,
148
217
  "tier"}``: the parameter index, the fixed value in optimizer space, the
149
218
  record to warm-start from (or None for a cold start from the optimum), the
150
219
  phase number the job should carry, why it was chosen, and its priority
151
- (0 first probe, 1 stepping or re-running, 2 filling a bracket, 3 spare).
220
+ (0 first probe, 1 stepping or re-running, 2 filling a bracket, 3 spare
221
+ ladder rung, 4 and up extra probes inside an open bracket).
152
222
 
153
223
  *in_flight* is the list of job dicts currently running. Their positions
154
224
  count as occupied: nothing is planned on top of one, and a side with a first
@@ -195,6 +265,26 @@ def plan_next_points(n_free, *, param_names, completed, in_flight, res_x,
195
265
  def free(x, d_ref):
196
266
  return not any(_near(x, o, d_ref) for o in occupied)
197
267
 
268
+ def probe_bracket(x_in, x_out, frac, seed, kind, d_ref):
269
+ """The guess inside (x_in, x_out), then spare-core extras.
270
+
271
+ A guess that lands on a point already measured means the
272
+ crossing is located by that point, so nothing is added: the
273
+ extras are for a side still being narrowed, not a licence to
274
+ keep probing one that has an answer.
275
+ """
276
+ if any(_near(x_in + frac * (x_out - x_in),
277
+ float(r["x_fixed"]), d_ref) for r in pts):
278
+ return
279
+ for j, fr in enumerate(_bracket_fracs(frac)):
280
+ x = x_in + fr * (x_out - x_in)
281
+ if free(x, d_ref) and \
282
+ ProfileCheckpointKey(x) not in completed.get(name, {}):
283
+ cands.append({"i": i, "name": name, "x": float(x),
284
+ "seed": seed, "phase": 1, "kind": kind,
285
+ "tier": 1 if j == 0 else 3 + j,
286
+ "sign": sign})
287
+
198
288
  ceiling = n_grid + max_extend + _SIDE_CEILING_EXTRA
199
289
  if len(pts) + len(fl) >= ceiling:
200
290
  continue
@@ -249,46 +339,79 @@ def plan_next_points(n_free, *, param_names, completed, in_flight, res_x,
249
339
  continue
250
340
 
251
341
  below = [r for r in pts if dn(r) <= thr]
252
- above_c = [r for r in pts if dn(r) > thr and _converged(r)]
253
- above_t = [r for r in pts if dn(r) > thr and not _converged(r)]
342
+ above = [r for r in pts if dn(r) > thr]
254
343
  outer_pt = pts[-1]
255
344
 
256
- if above_c:
257
- out = above_c[0]
258
- d_out = _ext_distance(p_opt, out["x_fixed"], is_log)
259
- inner_c = [r for r in below
260
- if _ext_distance(p_opt, r["x_fixed"], is_log) < d_out]
261
- inner = inner_c[-1] if inner_c else None
345
+ if above:
346
+ # The innermost point past the threshold is the outer end of the
347
+ # bracket -- but only if it is a real measurement. A cold or
348
+ # unconverged value is an upper bound with an unknown excess
349
+ # (observed: 0.05, 0.3 and 3.8 nats), and a warm restart from a
350
+ # nearer neighbour only ever lowers it, so the crossing may lie
351
+ # beyond it and every probe planned inside the bracket would
352
+ # read "below". Such a point is provisional: it is restarted
353
+ # warm at once, alongside the probes, instead of last.
354
+ tgt = above[0]
355
+ x_t = tgt["x_fixed"]
356
+ d_t = _ext_distance(p_opt, x_t, is_log)
357
+
358
+ def inner_of(t):
359
+ d_k = _ext_distance(p_opt, t["x_fixed"], is_log)
360
+ c = [r for r in below
361
+ if _ext_distance(p_opt, r["x_fixed"], is_log) < d_k]
362
+ return c[-1] if c else None
363
+
364
+ inner = inner_of(tgt)
262
365
  x_in = inner["x_fixed"] if inner else p_opt
263
- d_in_nll = dn(inner) if inner else 0.0
264
- tight = _bracket_is_tight(x_in, out["x_fixed"], p_opt, is_log,
265
- bracket_rtol)
366
+
367
+ # Only points before the first firm one can move the crossing;
368
+ # anything past a firm end is in the tail.
369
+ prov = []
370
+ for r in above:
371
+ if _is_firm(r):
372
+ break
373
+ prov.append(r)
374
+ prov = prov[:1 + _SPEC_RERUN]
375
+ for rank, t in enumerate(prov):
376
+ key = (name, ProfileCheckpointKey(t["x_fixed"]))
377
+ d_r = _ext_distance(p_opt, t["x_fixed"], is_log)
378
+ if key in attempted or _rerun_count(t) >= 1:
379
+ continue
380
+ if any(_near(f, t["x_fixed"], d_r) for f in fl):
381
+ continue
382
+ seed = _rerun_seed(t, inner_of(t))
383
+ if seed is None:
384
+ continue
385
+ cands.append({"i": i, "name": name, "x": float(t["x_fixed"]),
386
+ "seed": seed, "phase": 3, "kind": "rerun",
387
+ "tier": 1 if rank == 0 else 3 + rank,
388
+ "sign": sign, "attempt_key": key})
389
+
390
+ tight = _bracket_is_tight(x_in, x_t, p_opt, is_log, bracket_rtol)
266
391
  if not tight:
267
- r_in, r_out = np.sqrt(max(d_in_nll, 0.0)), np.sqrt(dn(out))
392
+ d_in_nll = dn(inner) if inner else 0.0
393
+ r_in, r_out = np.sqrt(max(d_in_nll, 0.0)), np.sqrt(dn(tgt))
268
394
  frac = ((np.sqrt(thr) - r_in) / (r_out - r_in)
269
395
  if r_out > r_in + 1e-12 else 0.5)
270
396
  frac = float(min(max(frac, 0.05), 0.95))
271
- x = x_in + frac * (out["x_fixed"] - x_in)
272
- if free(x, abs(out["x_fixed"] - p_opt)) and \
273
- ProfileCheckpointKey(x) not in completed.get(name, {}):
274
- cands.append({"i": i, "name": name, "x": float(x),
275
- "seed": inner, "phase": 1,
276
- "kind": "bracket", "tier": 1,
277
- "sign": sign})
397
+ probe_bracket(x_in, x_t, frac, inner,
398
+ "bracket" if _is_firm(tgt) else "narrow",
399
+ abs(x_t - p_opt))
278
400
  continue
279
- if len(pts) + len(fl) < n_grid:
401
+ if _is_firm(tgt) and len(pts) + len(fl) < n_grid:
280
402
  # Fill the curve's shape: halve the widest relative gap
281
403
  # between the optimum and the crossing.
282
404
  ds = sorted({0.0} | {
283
405
  _ext_distance(p_opt, r["x_fixed"], is_log)
284
406
  for r in pts if _ext_distance(
285
- p_opt, r["x_fixed"], is_log) <= d_out})
407
+ p_opt, r["x_fixed"], is_log) <= d_t})
286
408
  gaps = []
287
- for a, b in zip(ds[:-1], ds[1:]):
288
- gaps.append((b / a if a > 0 else float("inf"), a, b))
409
+ for g_a, g_b in zip(ds[:-1], ds[1:]):
410
+ gaps.append((g_b / g_a if g_a > 0 else float("inf"),
411
+ g_a, g_b))
289
412
  if gaps:
290
- _, a, b = max(gaps, key=lambda g: g[0])
291
- d_new = b / 2.0 if a == 0 else float(np.sqrt(a * b))
413
+ _, g_a, g_b = max(gaps, key=lambda g: g[0])
414
+ d_new = g_b / 2.0 if g_a == 0 else float(np.sqrt(g_a * g_b))
292
415
  x = (p_opt + sign * d_new if (is_log or p_opt <= 0)
293
416
  else p_opt * 10.0 ** (sign * d_new))
294
417
  ref = min((r for r in pts
@@ -297,71 +420,27 @@ def plan_next_points(n_free, *, param_names, completed, in_flight, res_x,
297
420
  key=lambda r: -_ext_distance(
298
421
  p_opt, r["x_fixed"], is_log),
299
422
  default=None)
300
- if free(x, abs(out["x_fixed"] - p_opt)):
423
+ if free(x, abs(x_t - p_opt)):
301
424
  cands.append({"i": i, "name": name, "x": float(x),
302
425
  "seed": ref, "phase": 1,
303
426
  "kind": "fill", "tier": 2,
304
427
  "sign": sign})
305
428
  continue
306
429
 
307
- if above_t:
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).
311
- tgt = above_t[0]
312
- x_t = tgt["x_fixed"]
313
- d_t = _ext_distance(p_opt, x_t, is_log)
314
- inner_c = [r for r in below
315
- if _ext_distance(p_opt, r["x_fixed"], is_log) < d_t]
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})
359
- continue
360
430
 
361
431
  # ── below the threshold everywhere so far: step outward ────────
432
+ # One rung per round would hold a single worker for as long as the
433
+ # side stays open, and an open side says nothing about its tail
434
+ # (a nonlinear profile can flatten and then climb), so it is not
435
+ # assumed to be flat. Instead the next rungs go out together:
436
+ # up to ``_SPEC_MAX_EXT`` beyond the first, each stepping on from
437
+ # the farthest rung already running or planned, so a relaunch or a
438
+ # rung landing continues the ladder instead of restarting it.
362
439
  far_fl = [f for f in fl
363
440
  if abs(f - p_opt) > abs(outer_pt["x_fixed"] - p_opt)]
364
- if far_fl:
441
+ need = min(1 + _SPEC_MAX_EXT - len(far_fl),
442
+ ceiling - len(pts) - len(fl))
443
+ if need <= 0:
365
444
  continue
366
445
  d_out = _ext_distance(p_opt, outer_pt["x_fixed"], is_log)
367
446
  growth = float(extend_growth)
@@ -370,15 +449,24 @@ def plan_next_points(n_free, *, param_names, completed, in_flight, res_x,
370
449
  growth = max(growth, 1.05)
371
450
  g = _extension_growth(pts, p_opt, is_log, thr, anchor, growth)
372
451
  g = float(min(max(g, 1.05), growth))
373
- x = _next_extension_value(p_opt, outer_pt["x_fixed"], lb, ub,
374
- sign, is_log, g)
375
- if x is None:
376
- continue
377
- if free(x, abs(outer_pt["x_fixed"] - p_opt)) and \
378
- ProfileCheckpointKey(x) not in completed.get(name, {}):
379
- cands.append({"i": i, "name": name, "x": float(x),
380
- "seed": outer_pt, "phase": 1, "kind": "extend",
381
- "tier": 1, "sign": sign})
452
+ x_ref = (max(far_fl) if sign > 0 else min(far_fl)) if far_fl \
453
+ else outer_pt["x_fixed"]
454
+ for j in range(need):
455
+ d_ref = _ext_distance(p_opt, x_ref, is_log)
456
+ g_j = g
457
+ if d_ref > 0 and max_step_decades:
458
+ g_j = min(g, max(1.0 + float(max_step_decades) / d_ref, 1.05))
459
+ x = _next_extension_value(p_opt, x_ref, lb, ub, sign, is_log, g_j)
460
+ if x is None:
461
+ break
462
+ x_ref = x
463
+ if free(x, abs(outer_pt["x_fixed"] - p_opt)) and \
464
+ ProfileCheckpointKey(x) not in completed.get(name, {}):
465
+ cands.append({"i": i, "name": name, "x": float(x),
466
+ "seed": outer_pt, "phase": 1, "kind": "extend",
467
+ "tier": 1 if (j == 0 and not far_fl)
468
+ else 3 + j + (1 if far_fl else 0),
469
+ "sign": sign})
382
470
 
383
471
  cands.sort(key=lambda c: (c["tier"], c["i"], c["sign"]))
384
472
  return cands[:n_free]