TransitionListener 2.0.0__py3-none-any.whl

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 (95) hide show
  1. transitionlistener/__init__.py +21 -0
  2. transitionlistener/bubbledynamics.py +2250 -0
  3. transitionlistener/bubbledynamics_fixedstep.py +2929 -0
  4. transitionlistener/check_installation.py +309 -0
  5. transitionlistener/ckm.py +91 -0
  6. transitionlistener/colors.py +288 -0
  7. transitionlistener/config.py +185 -0
  8. transitionlistener/constants.py +64 -0
  9. transitionlistener/counterterms/__init__.py +230 -0
  10. transitionlistener/counterterms/cw.py +298 -0
  11. transitionlistener/counterterms/tensors.py +343 -0
  12. transitionlistener/counterterms/twohdm.py +520 -0
  13. transitionlistener/counterterms/twohdm_solver.py +374 -0
  14. transitionlistener/errors.py +315 -0
  15. transitionlistener/examples/bp_et.yaml +64 -0
  16. transitionlistener/examples/bp_lisa.yaml +70 -0
  17. transitionlistener/examples/bp_muares.yaml +69 -0
  18. transitionlistener/examples/bp_pta.yaml +64 -0
  19. transitionlistener/examples/example_dark_photon.yaml +73 -0
  20. transitionlistener/examples/example_grid.yaml +55 -0
  21. transitionlistener/examples/example_line.yaml +51 -0
  22. transitionlistener/examples/example_point.yaml +73 -0
  23. transitionlistener/examples/example_random_conformal.yaml +42 -0
  24. transitionlistener/examples/example_ultranest.yaml +39 -0
  25. transitionlistener/finiteT.py +468 -0
  26. transitionlistener/generic_potential.py +1521 -0
  27. transitionlistener/gridplots.py +592 -0
  28. transitionlistener/gwfopt.py +604 -0
  29. transitionlistener/helper_functions.py +843 -0
  30. transitionlistener/hydrodynamics.py +647 -0
  31. transitionlistener/interface/__init__.py +95 -0
  32. transitionlistener/interface/check.py +51 -0
  33. transitionlistener/interface/cli.py +211 -0
  34. transitionlistener/interface/config_loader.py +264 -0
  35. transitionlistener/interface/logging_utils.py +30 -0
  36. transitionlistener/interface/output_schema.py +296 -0
  37. transitionlistener/interface/pipeline.py +300 -0
  38. transitionlistener/interface/samplers.py +565 -0
  39. transitionlistener/interface/scans.py +468 -0
  40. transitionlistener/interface/single_point.py +581 -0
  41. transitionlistener/interface/state.py +61 -0
  42. transitionlistener/lineplots.py +597 -0
  43. transitionlistener/logo/TL-logo_large.png +0 -0
  44. transitionlistener/logo/TL-logo_small.png +0 -0
  45. transitionlistener/models/TL_2HDM.py +512 -0
  46. transitionlistener/models/TL_conformal_dark_u1.py +202 -0
  47. transitionlistener/models/TL_dark_U1.py +198 -0
  48. transitionlistener/models/TL_dark_U1_g_parameterization.py +198 -0
  49. transitionlistener/models/TL_dark_flipflop.py +274 -0
  50. transitionlistener/models/templatePotential.py +260 -0
  51. transitionlistener/nucleation.py +412 -0
  52. transitionlistener/observability.py +790 -0
  53. transitionlistener/particles.py +436 -0
  54. transitionlistener/pathDeformation.py +1462 -0
  55. transitionlistener/percolation_adaptive_gridbuilders.py +1385 -0
  56. transitionlistener/percolation_adaptive_rate.py +1049 -0
  57. transitionlistener/percolation_adaptivestepsize.py +1632 -0
  58. transitionlistener/percolation_fixedstepsize.py +618 -0
  59. transitionlistener/phases.py +1499 -0
  60. transitionlistener/plot_settings.py +121 -0
  61. transitionlistener/plots.py +1781 -0
  62. transitionlistener/runtime_options.py +278 -0
  63. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/bandwidths.npy +0 -0
  64. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/density.npy +0 -0
  65. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/freqs.npy +0 -0
  66. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log.txt +16 -0
  67. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log10rhogrid.npy +0 -0
  68. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log10rholabels.txt +30 -0
  69. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/pulsar_list.txt +1 -0
  70. transitionlistener/tab_data/HLV-O2-PLI.csv +82 -0
  71. transitionlistener/tab_data/HLV-O2-noise.csv +83 -0
  72. transitionlistener/tab_data/HLVK-design-PLI.csv +61 -0
  73. transitionlistener/tab_data/HLVK-design-noise.csv +83 -0
  74. transitionlistener/tab_data/NG15_PLI_hc.csv +31 -0
  75. transitionlistener/tab_data/NG15_noise_hc.csv +56 -0
  76. transitionlistener/tab_data/PLI-dataset.csv +327 -0
  77. transitionlistener/tab_data/energydensities.dat +1001 -0
  78. transitionlistener/tab_data/finiteT_b.dat.txt +1999 -0
  79. transitionlistener/tab_data/finiteT_f.dat.txt +1000 -0
  80. transitionlistener/tab_data/geff_SM.dat +10009 -0
  81. transitionlistener/tab_data/muAres_f_Hz_PLI_h2OmegaGW.csv +131 -0
  82. transitionlistener/tab_data/muAres_f_Hz_h2Omega_noise.csv +99 -0
  83. transitionlistener/tab_data/noise-dataset.csv +327 -0
  84. transitionlistener/tab_data/numberdensities.dat +1001 -0
  85. transitionlistener/tab_data/pressures.dat +1001 -0
  86. transitionlistener/thermodynamics.py +686 -0
  87. transitionlistener/transitionObservables.py +1199 -0
  88. transitionlistener/transitionObservables_fixedstep.py +976 -0
  89. transitionlistener/transitions.py +1000 -0
  90. transitionlistener/tunneling1D.py +910 -0
  91. transitionlistener-2.0.0.dist-info/METADATA +817 -0
  92. transitionlistener-2.0.0.dist-info/RECORD +95 -0
  93. transitionlistener-2.0.0.dist-info/WHEEL +4 -0
  94. transitionlistener-2.0.0.dist-info/entry_points.txt +2 -0
  95. transitionlistener-2.0.0.dist-info/licenses/LICENSE.txt +674 -0
@@ -0,0 +1,1385 @@
1
+ """Grid-building helpers for the adaptive_step_size solver.
2
+
3
+ This module owns the self-contained support-point and candidate-grid
4
+ construction logic used by
5
+ :mod:`transitionlistener.percolation_adaptivestepsize`. It takes numpy
6
+ arrays and settings in and produces candidate temperature grids; the
7
+ top-level scan controllers (and their :class:`PercolationState`
8
+ bookkeeping) stay in the parent module.
9
+
10
+ Part of TransitionListener v2.0
11
+ Documentation: https://tasillo.de/TransitionListener/
12
+
13
+ Authors:
14
+ Jonas Matuszak <jonas.matuszak@kit.edu>
15
+ Carlo Tasillo <carlo.tasillo@ific.uv.es>
16
+ """
17
+
18
+ from dataclasses import dataclass
19
+ import math
20
+ import numpy as np
21
+
22
+ from transitionlistener import errors
23
+ from transitionlistener.bubbledynamics import (
24
+ PercolationGrid,
25
+ _temperature_grid,
26
+ )
27
+ from transitionlistener.percolation_adaptive_rate import (
28
+ HOT_PROBABILITY_ANCHORS,
29
+ HOT_RATE_ACCEPT_LOG,
30
+ _build_peak_centered_step1_window,
31
+ _build_step1_hot_probability_anchor_grid,
32
+ _build_step1_hot_rate_anchor_grid,
33
+ _find_value_crossing,
34
+ _hot_head_underresolved,
35
+ _log_gamma_h4_array,
36
+ _probability_anchor_presence_tolerance,
37
+ _probability_at,
38
+ _rate_interval_is_post_peak_inert,
39
+ _remesh_dp_limit,
40
+ _temperature_midpoint,
41
+ _temperature_present,
42
+ )
43
+
44
+ # Fixed probability anchors used by the simplified step-2/step-3 remeshing
45
+ # controller. The idea is to approximate a near-uniform mesh in the physically
46
+ # interesting part of P(T) without requiring the caller to micromanage several
47
+ # different refinement heuristics. Anchors are only activated once the current
48
+ # profile actually reaches the corresponding probability.
49
+ _PROBABILITY_REMESH_ANCHORS = (
50
+ 1.0e-3,
51
+ 3.0e-3,
52
+ 1.0e-2,
53
+ 3.0e-2,
54
+ 1.0e-1,
55
+ 2.0e-1,
56
+ 3.5e-1,
57
+ 5.0e-1,
58
+ 6.5e-1,
59
+ 8.0e-1,
60
+ 9.0e-1,
61
+ 9.5e-1,
62
+ 9.8e-1,
63
+ 9.9e-1,
64
+ )
65
+
66
+ _STARTUP_WINDOW_HIGH = 0.999
67
+ _COLD_TAIL_TARGET_DP = 0.08
68
+ _COLD_TAIL_TARGET_DI = 0.25
69
+ _COLD_TAIL_INTEGRAL_SWITCH_P = 0.8
70
+ _COLD_TAIL_MIN_LOG_STEP = 0.25
71
+ _COLD_TAIL_MAX_LOG_STEP = 5.0
72
+ _COLD_TAIL_STEP_SHRINK_LIMIT = 0.35
73
+ _COLD_TAIL_STEP_GROWTH_LIMIT = 3.0
74
+ _COLD_TAIL_ZERO_GAIN_SCALE = 2.0
75
+ _COLD_TAIL_POST_PEAK_MIN_STREAK = 2
76
+ _COLD_TAIL_POST_PEAK_BOOST_COEFF = 0.25
77
+ _COLD_TAIL_POST_PEAK_MAX_BOOST = 3.0
78
+ _STRICT_JUMP_REFINE_THRESHOLD = 0.05
79
+ _STRICT_JUMP_SUCCESS_THRESHOLD = 0.05
80
+ _STRICT_JUMP_P_MAX = 0.2
81
+ _STRICT_JUMP_APPLY_GLOBALLY = False
82
+
83
+
84
+ def _tb_root_bracketed_for_tsym(
85
+ TSYM: float,
86
+ TBROmin: float,
87
+ TBROmax: float,
88
+ phase_symmetric,
89
+ phase_broken,
90
+ pot,
91
+ ) -> bool:
92
+ """Return whether the reheating criterion changes sign across ``[TBROmin, TBROmax]``."""
93
+ from transitionlistener import bubbledynamics as bd
94
+
95
+ crit_min = float(bd.Tb_criterion(TBROmin, TSYM, phase_symmetric, phase_broken, pot))
96
+ crit_max = float(bd.Tb_criterion(TBROmax, TSYM, phase_symmetric, phase_broken, pot))
97
+ if not (np.isfinite(crit_min) and np.isfinite(crit_max)):
98
+ return False
99
+ if crit_min == 0.0 or crit_max == 0.0:
100
+ return True
101
+ return bool(crit_min * crit_max < 0.0)
102
+
103
+
104
+ def _highest_tsym_with_tb_root(
105
+ tmin: float,
106
+ tmax: float,
107
+ TBROmin: float,
108
+ TBROmax: float,
109
+ phase_symmetric,
110
+ phase_broken,
111
+ pot,
112
+ ) -> float:
113
+ """Find the hottest traced temperature that still admits a bracketed reheating root."""
114
+ tmin = float(tmin)
115
+ tmax = float(tmax)
116
+ if _tb_root_bracketed_for_tsym(tmax, TBROmin, TBROmax, phase_symmetric, phase_broken, pot):
117
+ return tmax
118
+ if not _tb_root_bracketed_for_tsym(tmin, TBROmin, TBROmax, phase_symmetric, phase_broken, pot):
119
+ raise errors.PercolationError(
120
+ "Tb_criterion has no bracketed reheating root anywhere inside the traced "
121
+ f"phase-overlap interval [{tmin:.8g}, {tmax:.8g}]."
122
+ )
123
+
124
+ low = tmin
125
+ high = tmax
126
+ scale = max(abs(tmin), abs(tmax), abs(TBROmin), abs(TBROmax), 1.0)
127
+ tol = 1e-8 * scale
128
+ for _ in range(80):
129
+ if abs(high - low) <= tol:
130
+ break
131
+ mid = 0.5 * (low + high)
132
+ if _tb_root_bracketed_for_tsym(mid, TBROmin, TBROmax, phase_symmetric, phase_broken, pot):
133
+ low = mid
134
+ else:
135
+ high = mid
136
+ return float(low)
137
+
138
+
139
+ def _next_untried_scout_temperature(
140
+ high: float,
141
+ low: float,
142
+ tried: np.ndarray,
143
+ *,
144
+ prefer: str,
145
+ ) -> float | None:
146
+ """Bisect an interval until a temperature not already present in ``tried`` is found."""
147
+ high = float(high)
148
+ low = float(low)
149
+ tried = np.asarray(tried, dtype=float)
150
+ if not (np.isfinite(high) and np.isfinite(low)) or high <= low:
151
+ return None
152
+
153
+ current_high = high
154
+ current_low = low
155
+ for _ in range(32):
156
+ candidate = _temperature_midpoint(current_high, current_low)
157
+ if candidate is None or not np.isfinite(float(candidate)):
158
+ return None
159
+ if not _temperature_present(tried, float(candidate)):
160
+ return float(candidate)
161
+ if prefer == "hotter":
162
+ current_low = float(candidate)
163
+ else:
164
+ current_high = float(candidate)
165
+ if current_high <= current_low:
166
+ return None
167
+ return None
168
+
169
+
170
+ @dataclass
171
+ class _Step1ApproxProfile:
172
+ """Approximate percolation profile reconstructed from cached step-1 actions."""
173
+
174
+ sampled_temperatures: np.ndarray
175
+ temperatures: np.ndarray
176
+ actions: np.ndarray
177
+ gamma: np.ndarray
178
+ hubble_sym: np.ndarray
179
+ log_gamma_h4_sym: np.ndarray
180
+ i_approx: np.ndarray
181
+ probabilities: np.ndarray
182
+ tperc_approx: float
183
+ startup_center: float
184
+ window_high: float | None
185
+ window_low: float | None
186
+ window_source: str
187
+ peak_temperature: float | None = None
188
+ peak_log_gamma_h4: float | None = None
189
+
190
+
191
+ def _build_step1_approx_profile(
192
+ outdict: dict,
193
+ pot,
194
+ phase_symmetric,
195
+ phase_broken,
196
+ vw: float,
197
+ tmin: float,
198
+ tmax: float,
199
+ tperc_approx: float,
200
+ settings,
201
+ sampled_temperatures: np.ndarray | None = None,
202
+ ) -> _Step1ApproxProfile | None:
203
+ """Reconstruct an approximate percolation profile from cached action temperatures."""
204
+ from transitionlistener import bubbledynamics as bd
205
+
206
+ cached_temperatures = bd._transition_action_outdict_temperatures(
207
+ outdict,
208
+ phase_symmetric,
209
+ phase_broken,
210
+ tmin=tmin,
211
+ tmax=tmax,
212
+ )
213
+ all_sampled_temperatures = _temperature_grid(
214
+ np.concatenate(
215
+ (
216
+ np.asarray(cached_temperatures, dtype=float),
217
+ np.asarray([] if sampled_temperatures is None else sampled_temperatures, dtype=float),
218
+ )
219
+ )
220
+ )
221
+ if cached_temperatures.size < 2:
222
+ return None
223
+
224
+ actions = np.asarray(
225
+ [float(bd.calcAction(pot, float(temp), phase_symmetric, phase_broken, outdict)) for temp in cached_temperatures],
226
+ dtype=float,
227
+ )
228
+ mask = np.isfinite(cached_temperatures) & np.isfinite(actions)
229
+ temperatures = np.asarray(cached_temperatures[mask], dtype=float)
230
+ actions = np.asarray(actions[mask], dtype=float)
231
+ if temperatures.size < 2:
232
+ return None
233
+
234
+ hubble_sym = np.asarray(
235
+ [
236
+ float(bd.HubbleParameter(bd.energyDensity(pot, phase_symmetric, float(temp)), pot.conversionFactor))
237
+ for temp in temperatures
238
+ ],
239
+ dtype=float,
240
+ )
241
+ gamma = np.asarray(bd.Gamma(temperatures, actions), dtype=float)
242
+ log_gamma_h4_sym = np.asarray(bd.logGamma(temperatures, actions), dtype=float) - 4.0 * np.log(hubble_sym)
243
+ if not (np.all(np.isfinite(hubble_sym)) and np.all(np.isfinite(gamma)) and np.all(np.isfinite(log_gamma_h4_sym))):
244
+ return None
245
+
246
+ i_approx = np.zeros_like(temperatures, dtype=float)
247
+ probabilities = np.zeros_like(temperatures, dtype=float)
248
+ i_approx_vals, probabilities[:] = bd.percIntegralODE_full_sweep(
249
+ temperatures,
250
+ hubble_sym,
251
+ actions,
252
+ vw=vw,
253
+ pot=pot,
254
+ phase_symmetric=phase_symmetric,
255
+ time_temperature_mode=settings.time_temperature_mode,
256
+ integral_method=settings.integral_method,
257
+ )
258
+ i_approx[:] = i_approx_vals
259
+
260
+ window_high, _, _ = _find_value_crossing(
261
+ temperatures,
262
+ probabilities,
263
+ float(settings.f_start),
264
+ )
265
+ window_low, _, _ = _find_value_crossing(
266
+ temperatures,
267
+ probabilities,
268
+ _STARTUP_WINDOW_HIGH,
269
+ )
270
+ startup_center = float(tperc_approx)
271
+ window_source = "probability"
272
+ peak_temperature = None
273
+ peak_log_gamma_h4 = None
274
+
275
+ if window_high is None or window_low is None:
276
+ peak_temperature, peak_log_gamma_h4, peak_window_high, peak_window_low = _build_peak_centered_step1_window(
277
+ temperatures,
278
+ log_gamma_h4_sym,
279
+ settings,
280
+ )
281
+ if peak_window_high is not None and peak_window_low is not None:
282
+ window_high = float(peak_window_high)
283
+ window_low = float(peak_window_low)
284
+ startup_center = float(peak_temperature)
285
+ window_source = "peak_log_gamma_h4"
286
+
287
+ return _Step1ApproxProfile(
288
+ sampled_temperatures=all_sampled_temperatures,
289
+ temperatures=temperatures,
290
+ actions=actions,
291
+ gamma=gamma,
292
+ hubble_sym=hubble_sym,
293
+ log_gamma_h4_sym=log_gamma_h4_sym,
294
+ i_approx=i_approx,
295
+ probabilities=probabilities,
296
+ tperc_approx=float(tperc_approx),
297
+ startup_center=float(startup_center),
298
+ window_high=None if window_high is None else float(window_high),
299
+ window_low=None if window_low is None else float(window_low),
300
+ window_source=str(window_source),
301
+ peak_temperature=None if peak_temperature is None else float(peak_temperature),
302
+ peak_log_gamma_h4=None if peak_log_gamma_h4 is None else float(peak_log_gamma_h4),
303
+ )
304
+
305
+
306
+ def _scout_needs_hot_extension(profile: _Step1ApproxProfile | None, settings) -> bool:
307
+ """Decide whether the approximate step-1 profile must be extended to hotter temperatures."""
308
+ if profile is None:
309
+ return True
310
+ if profile.probabilities.size == 0:
311
+ return True
312
+ if float(profile.probabilities[0]) > float(settings.f_start):
313
+ return True
314
+ hot_head_unresolved, _, _ = _hot_head_underresolved(
315
+ profile.temperatures,
316
+ profile.probabilities,
317
+ profile.actions,
318
+ profile.hubble_sym,
319
+ settings,
320
+ for_acceptance=False,
321
+ )
322
+ if hot_head_unresolved:
323
+ return True
324
+ if profile.window_high is not None and profile.window_low is not None:
325
+ return False
326
+ return profile.window_high is None
327
+
328
+
329
+ def _scout_needs_cold_extension(profile: _Step1ApproxProfile | None, settings) -> bool:
330
+ """Decide whether the approximate step-1 profile must be extended to colder temperatures."""
331
+ if profile is None:
332
+ return True
333
+ if profile.window_high is not None and profile.window_low is not None:
334
+ return False
335
+ if profile.probabilities.size == 0:
336
+ return True
337
+ if float(profile.probabilities[-1]) < float(settings.f_final):
338
+ return True
339
+ return profile.window_low is None
340
+
341
+
342
+ def _remaining_scout_action_budget(
343
+ outdict: dict,
344
+ phase_symmetric,
345
+ phase_broken,
346
+ settings,
347
+ *,
348
+ tmin: float,
349
+ tmax: float,
350
+ ) -> int:
351
+ """Return how many more unique transition action temperatures may be added."""
352
+ from transitionlistener import bubbledynamics as bd
353
+
354
+ used = int(
355
+ len(
356
+ bd._transition_action_outdict_temperatures(
357
+ outdict,
358
+ phase_symmetric,
359
+ phase_broken,
360
+ tmin=tmin,
361
+ tmax=tmax,
362
+ )
363
+ )
364
+ )
365
+ return max(int(settings.max_action_temperatures) - int(used), 0)
366
+
367
+
368
+ def _extend_step1_scout_until_bracketed(
369
+ outdict: dict,
370
+ pot,
371
+ phase_symmetric,
372
+ phase_broken,
373
+ vw: float,
374
+ hot_grid_start: float,
375
+ tmin: float,
376
+ tmax: float,
377
+ tperc_approx: float,
378
+ settings,
379
+ verbose: bool,
380
+ ) -> _Step1ApproxProfile | None:
381
+ """Enlarge the step-1 scout bank until the approximate transition window is bracketed."""
382
+ from transitionlistener import bubbledynamics as bd
383
+
384
+ attempted_scout_temperatures = np.asarray([], dtype=float)
385
+
386
+ profile = _build_step1_approx_profile(
387
+ outdict,
388
+ pot,
389
+ phase_symmetric,
390
+ phase_broken,
391
+ vw,
392
+ tmin,
393
+ tmax,
394
+ tperc_approx,
395
+ settings,
396
+ sampled_temperatures=attempted_scout_temperatures,
397
+ )
398
+ max_extensions = max(int(settings.maxit), 1) * 4
399
+ for _ in range(max_extensions):
400
+ need_hot = _scout_needs_hot_extension(profile, settings)
401
+ need_cold = _scout_needs_cold_extension(profile, settings)
402
+ if not need_hot and not need_cold:
403
+ return profile
404
+
405
+ remaining_budget = _remaining_scout_action_budget(
406
+ outdict,
407
+ phase_symmetric,
408
+ phase_broken,
409
+ settings,
410
+ tmin=tmin,
411
+ tmax=tmax,
412
+ )
413
+ if remaining_budget <= 0:
414
+ break
415
+
416
+ all_cached = bd._transition_action_outdict_temperatures(
417
+ outdict,
418
+ phase_symmetric,
419
+ phase_broken,
420
+ tmin=tmin,
421
+ tmax=tmax,
422
+ )
423
+ occupied_scout_points = _temperature_grid(
424
+ np.concatenate((np.asarray(all_cached, dtype=float), attempted_scout_temperatures))
425
+ )
426
+ candidate_temperatures: list[float] = []
427
+ if need_hot:
428
+ hottest = float(occupied_scout_points[0]) if occupied_scout_points.size else (
429
+ float(profile.temperatures[0]) if profile is not None and profile.temperatures.size else float(tperc_approx)
430
+ )
431
+ hot_scout_bound = max(float(hot_grid_start), float(tmax))
432
+ scout_temp = _next_untried_scout_temperature(
433
+ hot_scout_bound,
434
+ hottest,
435
+ occupied_scout_points,
436
+ prefer="hotter",
437
+ )
438
+ if scout_temp is not None and scout_temp < hot_scout_bound and scout_temp > hottest:
439
+ candidate_temperatures.append(float(scout_temp))
440
+ if need_cold:
441
+ coldest = float(occupied_scout_points[-1]) if occupied_scout_points.size else (
442
+ float(profile.temperatures[-1]) if profile is not None and profile.temperatures.size else float(tperc_approx)
443
+ )
444
+ scout_temp = _next_untried_scout_temperature(
445
+ coldest,
446
+ float(tmin),
447
+ occupied_scout_points,
448
+ prefer="colder",
449
+ )
450
+ if scout_temp is not None and scout_temp < coldest and scout_temp > float(tmin):
451
+ candidate_temperatures.append(float(scout_temp))
452
+
453
+ if not candidate_temperatures:
454
+ break
455
+ unique_candidates = _temperature_grid(candidate_temperatures)
456
+ attempted_scout_temperatures = _temperature_grid(
457
+ np.concatenate((attempted_scout_temperatures, unique_candidates))
458
+ )
459
+ for temperature in unique_candidates[:remaining_budget]:
460
+ if verbose:
461
+ print(
462
+ "Step-1 scout extension:",
463
+ f"T = {float(temperature) * pot.conversionFactor:2.5g} GeV",
464
+ )
465
+ bd.calcAction(pot, float(temperature), phase_symmetric, phase_broken, outdict)
466
+
467
+ profile = _build_step1_approx_profile(
468
+ outdict,
469
+ pot,
470
+ phase_symmetric,
471
+ phase_broken,
472
+ vw,
473
+ tmin,
474
+ tmax,
475
+ tperc_approx,
476
+ settings,
477
+ sampled_temperatures=attempted_scout_temperatures,
478
+ )
479
+
480
+ return profile
481
+
482
+
483
+ def _fill_largest_gap(points: np.ndarray, high: float, low: float) -> float | None:
484
+ """Return the midpoint of the largest gap inside one ordered interval."""
485
+ anchors = [float(high), float(low)]
486
+ for value in np.asarray(points, dtype=float):
487
+ if float(low) <= float(value) <= float(high):
488
+ anchors.append(float(value))
489
+ anchors = np.asarray(sorted(set(anchors), reverse=True), dtype=float)
490
+ if anchors.size < 2:
491
+ return None
492
+ gaps = anchors[:-1] - anchors[1:]
493
+ if gaps.size == 0:
494
+ return None
495
+ gap_index = int(np.argmax(gaps))
496
+ upper = float(anchors[gap_index])
497
+ lower = float(anchors[gap_index + 1])
498
+ return _temperature_midpoint(upper, lower)
499
+
500
+
501
+ def _cached_promotion_bounds(active_grid: np.ndarray) -> tuple[float, float] | None:
502
+ """Return a narrow interval around the current active grid for cache promotion.
503
+
504
+ Dormant step-1 scout points remain available in the support bank, but only
505
+ the cached temperatures close to the currently active transition band should
506
+ be promoted automatically. Larger range changes are handled by the explicit
507
+ range-expansion logic.
508
+ """
509
+ temps = _temperature_grid(active_grid)
510
+ if temps.size == 0:
511
+ return None
512
+ high = float(temps[0])
513
+ low = float(temps[-1])
514
+ span = max(high - low, 0.0)
515
+ mean_spacing = span / max(len(temps) - 1, 1)
516
+ padding = max(0.15 * span, 1.5 * mean_spacing)
517
+ return float(high + padding), float(low - padding)
518
+
519
+
520
+ def _build_step1_active_grid(
521
+ profile: _Step1ApproxProfile,
522
+ settings,
523
+ ) -> np.ndarray | None:
524
+ """Build the compact active TSYM grid from the approximate step-1 profile."""
525
+ if profile.window_high is None or profile.window_low is None:
526
+ return None
527
+
528
+ temperatures = _temperature_grid(profile.temperatures)
529
+ scale = max(
530
+ abs(float(profile.window_high)),
531
+ abs(float(profile.window_low)),
532
+ abs(float(profile.startup_center)),
533
+ 1.0,
534
+ )
535
+ atol = 1e-10 * scale
536
+ active = np.asarray(
537
+ [
538
+ float(value)
539
+ for value in temperatures
540
+ if float(profile.window_low) - atol <= float(value) <= float(profile.window_high) + atol
541
+ ],
542
+ dtype=float,
543
+ )
544
+ active = _temperature_grid(active)
545
+
546
+ boundary_candidates = [float(profile.window_high), float(profile.window_low)]
547
+ for candidate in boundary_candidates:
548
+ if not np.any(np.isclose(active, candidate, atol=atol, rtol=0.0)):
549
+ active = _temperature_grid(np.append(active, candidate))
550
+
551
+ hot_probability_grid = _build_step1_hot_probability_anchor_grid(profile, settings, active)
552
+ if hot_probability_grid is not None:
553
+ for value in hot_probability_grid:
554
+ active = _temperature_grid(np.append(active, float(value)))
555
+
556
+ hot_rate_grid = _build_step1_hot_rate_anchor_grid(profile, settings, active)
557
+ if hot_rate_grid is not None:
558
+ for value in hot_rate_grid:
559
+ active = _temperature_grid(np.append(active, float(value)))
560
+
561
+ requested = max(int(settings.n_action_min), 2)
562
+ n_hi = max(int(math.ceil(requested * float(settings.weight))), 1)
563
+ n_lo = max(int(requested - n_hi), 1)
564
+
565
+ def _count_high(points: np.ndarray) -> int:
566
+ return int(np.count_nonzero(np.asarray(points, dtype=float) > float(profile.startup_center) + atol))
567
+
568
+ def _count_low(points: np.ndarray) -> int:
569
+ return int(np.count_nonzero(np.asarray(points, dtype=float) < float(profile.startup_center) - atol))
570
+
571
+ while _count_high(active) < n_hi:
572
+ midpoint = _fill_largest_gap(active, float(profile.window_high), float(profile.startup_center))
573
+ if midpoint is None:
574
+ break
575
+ updated_active = _temperature_grid(np.append(active, midpoint))
576
+ if updated_active.size == active.size:
577
+ break
578
+ active = updated_active
579
+
580
+ while _count_low(active) < n_lo:
581
+ midpoint = _fill_largest_gap(active, float(profile.startup_center), float(profile.window_low))
582
+ if midpoint is None:
583
+ break
584
+ updated_active = _temperature_grid(np.append(active, midpoint))
585
+ if updated_active.size == active.size:
586
+ break
587
+ active = updated_active
588
+
589
+ return _temperature_grid(active)
590
+
591
+
592
+ def _initial_temperature_grid(
593
+ outdict: dict,
594
+ pot,
595
+ Tstart: float,
596
+ phase_symmetric,
597
+ phase_broken,
598
+ vw: float,
599
+ nAction: int,
600
+ settings,
601
+ overlap_tmin: float,
602
+ overlap_tmax: float,
603
+ verbose: bool,
604
+ ) -> PercolationGrid:
605
+ """Step 1: build the initial TSYM grid around the approximate percolation point."""
606
+ from transitionlistener import bubbledynamics as bd
607
+ tmin = float(overlap_tmin)
608
+ raw_tmax = float(overlap_tmax)
609
+ hot_grid_start = _highest_tsym_with_tb_root(
610
+ tmin,
611
+ raw_tmax,
612
+ float(phase_broken.Tmin),
613
+ float(phase_broken.Tmax),
614
+ phase_symmetric,
615
+ phase_broken,
616
+ pot,
617
+ )
618
+ tmax = raw_tmax
619
+ if verbose and hot_grid_start < raw_tmax:
620
+ print(
621
+ "Initial active adaptive step size grid starts at the hottest TSYM that still "
622
+ "brackets Tb_criterion, but hotter pure-symmetric support remains available:",
623
+ f"{raw_tmax * pot.conversionFactor:2.5g} -> {hot_grid_start * pot.conversionFactor:2.5g} GeV",
624
+ )
625
+
626
+ if verbose:
627
+ print("\nPercolation step 1: calculating Tperc using the saddlepoint approximation")
628
+ try:
629
+ TpercApprox = bd.calcApproxPercolation(
630
+ outdict,
631
+ pot,
632
+ phase_symmetric,
633
+ phase_broken,
634
+ vw,
635
+ verbose,
636
+ tmin=overlap_tmin,
637
+ tmax=overlap_tmax,
638
+ )
639
+ except errors.PercolationApproximation1Error as err:
640
+ if verbose:
641
+ print(
642
+ "Percolation approximation 1 failed: ",
643
+ err,
644
+ ". We will continue with TpercApprox = hot_grid_start.",
645
+ )
646
+ TpercApprox = hot_grid_start
647
+ if verbose:
648
+ print(f"Approximate percolation temperature: {TpercApprox * pot.conversionFactor:2.5g} GeV")
649
+
650
+ initial_n_action = int(nAction)
651
+ grid_n = int(settings.n_action_min)
652
+
653
+ scout_profile = _extend_step1_scout_until_bracketed(
654
+ outdict,
655
+ pot,
656
+ phase_symmetric,
657
+ phase_broken,
658
+ vw,
659
+ hot_grid_start,
660
+ tmin,
661
+ tmax,
662
+ TpercApprox,
663
+ settings,
664
+ verbose,
665
+ )
666
+ if scout_profile is not None:
667
+ active_grid = _build_step1_active_grid(scout_profile, settings)
668
+ if active_grid is not None and active_grid.size >= 2:
669
+ if verbose:
670
+ print(
671
+ "Step-1 scout resolved an approximate active window:",
672
+ f"[{float(active_grid[-1]) * pot.conversionFactor:2.5g}, "
673
+ f"{float(active_grid[0]) * pot.conversionFactor:2.5g}] GeV with "
674
+ f"{len(active_grid)} active TSYM support points and "
675
+ f"{len(scout_profile.temperatures)} cached scout temperatures.",
676
+ f"(source: {scout_profile.window_source})",
677
+ )
678
+ return PercolationGrid(
679
+ TSYM=active_grid,
680
+ Tstart=float(active_grid[0]),
681
+ TpercApprox=float(scout_profile.tperc_approx),
682
+ tmin=tmin,
683
+ tmax=tmax,
684
+ TBROmin=phase_broken.Tmin,
685
+ TBROmax=phase_broken.Tmax,
686
+ free_support_bank=scout_profile.sampled_temperatures.copy(),
687
+ )
688
+
689
+ if verbose:
690
+ print(
691
+ "Warning: step-1 scout profile could not define any compact startup grid. "
692
+ "Using the broad startup-grid heuristic."
693
+ )
694
+
695
+ dT = (hot_grid_start - TpercApprox) / int(initial_n_action * settings.weight)
696
+
697
+ if TpercApprox < hot_grid_start:
698
+ upper = np.maximum(TpercApprox - int(initial_n_action * (1 - settings.weight)) * dT, tmin)
699
+ TSYM = np.linspace(hot_grid_start, upper, grid_n)
700
+ else:
701
+ if verbose:
702
+ print("Warning: TpercApprox > overlap Tmax, use interval around TpercApprox")
703
+ _, alpha_perc_approx, _, _, _, _, _ = bd.calcAlphas(
704
+ TpercApprox,
705
+ pot,
706
+ phase_symmetric,
707
+ phase_broken,
708
+ verbose=verbose,
709
+ )
710
+ if alpha_perc_approx < 1:
711
+ Tmin = 0.999 * TpercApprox
712
+ Tmax = 1.001 * TpercApprox
713
+ elif alpha_perc_approx < 10:
714
+ Tmin = 0.9 * TpercApprox
715
+ Tmax = 1.1 * TpercApprox
716
+ elif alpha_perc_approx < 100:
717
+ Tmin = 0.8 * TpercApprox
718
+ Tmax = 1.2 * TpercApprox
719
+ else:
720
+ Tmin = 0.5 * TpercApprox
721
+ Tmax = 1.5 * TpercApprox
722
+ Tmax = min(Tmax, tmax)
723
+ Tmin = max(Tmin, tmin)
724
+ if Tmax <= Tmin:
725
+ # If clipping the local interval around TpercApprox collapses or inverts
726
+ # the bracket, fall back to a narrow valid window at the hot overlap edge.
727
+ # This keeps the startup grid descending and lets step 2 decide whether
728
+ # the transition history has to expand colder.
729
+ Tmax = float(tmax)
730
+ Tmin = max(float(tmin), float(tmax) * (1.0 - 1e-3))
731
+ TSYM = np.linspace(Tmax, Tmin, grid_n)
732
+
733
+ TSYM = _temperature_grid(TSYM)
734
+
735
+ return PercolationGrid(
736
+ TSYM=TSYM,
737
+ Tstart=hot_grid_start,
738
+ TpercApprox=TpercApprox,
739
+ tmin=tmin,
740
+ tmax=tmax,
741
+ TBROmin=phase_broken.Tmin,
742
+ TBROmax=phase_broken.Tmax,
743
+ free_support_bank=None if scout_profile is None else scout_profile.sampled_temperatures.copy(),
744
+ )
745
+
746
+
747
+ def _build_dynamiczoomwindow_jump_grid(
748
+ jump_high: float,
749
+ jump_low: float,
750
+ *,
751
+ max_new_points: int,
752
+ ) -> np.ndarray | None:
753
+ """Resolve a sharp P(T) jump by adding a few intermediate temperatures.
754
+
755
+ Large late-time jumps often span several decades in temperature. In that
756
+ regime a geometric ladder is more informative than a linear spacing, while
757
+ modest intervals are still sampled linearly.
758
+ """
759
+ if max_new_points <= 0:
760
+ return None
761
+ jump_high = float(jump_high)
762
+ jump_low = float(jump_low)
763
+ if not (np.isfinite(jump_high) and np.isfinite(jump_low)) or jump_high <= jump_low:
764
+ return None
765
+ if jump_high / max(jump_low, 1e-30) - 1 <= 1e-10:
766
+ return None
767
+ if jump_low > 0.0 and jump_high / jump_low >= 2.0:
768
+ refined = np.geomspace(jump_high, jump_low, int(max_new_points) + 2, dtype=float)[1:-1]
769
+ else:
770
+ refined = np.linspace(jump_high, jump_low, int(max_new_points) + 2, dtype=float)[1:-1]
771
+ refined = _temperature_grid(refined)
772
+ return refined if refined.size > 0 else None
773
+
774
+
775
+ def _largest_probability_jump_interval(
776
+ temperatures: np.ndarray,
777
+ probabilities: np.ndarray,
778
+ *,
779
+ min_delta_p: float,
780
+ p_max: float | None = None,
781
+ apply_globally: bool = True,
782
+ prefer_hottest: bool = False,
783
+ ) -> tuple[float, float, float, float, float] | None:
784
+ """Return the largest neighbouring interval with Delta P above the threshold.
785
+
786
+ The return values are ``(jump_high, jump_low, probability_high,
787
+ probability_low, delta_p)`` in the descending-temperature convention used by
788
+ the support bank. Intervals that contain NaNs or non-descending
789
+ temperatures are ignored. When ``apply_globally`` is false, only intervals
790
+ touching ``P < p_max`` are considered.
791
+ """
792
+ temps = np.asarray(temperatures, dtype=float)
793
+ probs = np.asarray(probabilities, dtype=float)
794
+ if temps.size < 2 or probs.size < 2:
795
+ return None
796
+
797
+ best_interval: tuple[float, float, float, float, float] | None = None
798
+ best_delta_p = float(min_delta_p)
799
+ for i in range(min(len(temps), len(probs)) - 1):
800
+ jump_high = float(temps[i])
801
+ jump_low = float(temps[i + 1])
802
+ probability_high = float(probs[i])
803
+ probability_low = float(probs[i + 1])
804
+ if not (
805
+ np.isfinite(jump_high)
806
+ and np.isfinite(jump_low)
807
+ and np.isfinite(probability_high)
808
+ and np.isfinite(probability_low)
809
+ and jump_high > jump_low
810
+ ):
811
+ continue
812
+ if not apply_globally:
813
+ if p_max is None or not np.isfinite(float(p_max)) or float(p_max) <= 0.0:
814
+ continue
815
+ if min(probability_high, probability_low) >= float(p_max):
816
+ continue
817
+ delta_p = abs(probability_low - probability_high)
818
+ if delta_p <= float(min_delta_p):
819
+ continue
820
+ if prefer_hottest:
821
+ return (
822
+ jump_high,
823
+ jump_low,
824
+ probability_high,
825
+ probability_low,
826
+ float(delta_p),
827
+ )
828
+ if delta_p > best_delta_p:
829
+ best_delta_p = float(delta_p)
830
+ best_interval = (
831
+ jump_high,
832
+ jump_low,
833
+ probability_high,
834
+ probability_low,
835
+ float(delta_p),
836
+ )
837
+ return best_interval
838
+
839
+
840
+ def _preferred_probability_jump_interval(
841
+ temperatures: np.ndarray,
842
+ probabilities: np.ndarray,
843
+ settings,
844
+ *,
845
+ for_acceptance: bool,
846
+ ) -> tuple[tuple[float, float, float, float, float] | None, float, str]:
847
+ """Return the jump interval the controller should refine or enforce first.
848
+
849
+ The stricter optional threshold is tried before the historical global one.
850
+ This allows threshold studies to demand tighter neighbouring-``Delta P``
851
+ control either globally or only in the early-rise part of the curve.
852
+ """
853
+
854
+ strict_threshold = _STRICT_JUMP_SUCCESS_THRESHOLD if for_acceptance else _STRICT_JUMP_REFINE_THRESHOLD
855
+ generic_threshold = float(
856
+ settings.large_delta_p_success_threshold
857
+ if for_acceptance
858
+ else settings.large_delta_p_refine_threshold
859
+ )
860
+ generic_interval = _largest_probability_jump_interval(
861
+ temperatures,
862
+ probabilities,
863
+ min_delta_p=generic_threshold,
864
+ )
865
+
866
+ if strict_threshold > 0.0:
867
+ strict_interval = _largest_probability_jump_interval(
868
+ temperatures,
869
+ probabilities,
870
+ min_delta_p=strict_threshold,
871
+ p_max=_STRICT_JUMP_P_MAX,
872
+ apply_globally=_STRICT_JUMP_APPLY_GLOBALLY,
873
+ prefer_hottest=(not _STRICT_JUMP_APPLY_GLOBALLY and not bool(for_acceptance)),
874
+ )
875
+ if strict_interval is not None:
876
+ if (
877
+ not bool(for_acceptance)
878
+ and not _STRICT_JUMP_APPLY_GLOBALLY
879
+ and generic_interval is not None
880
+ and generic_threshold > 0.0
881
+ ):
882
+ strict_severity = float(strict_interval[-1]) / max(float(strict_threshold), 1.0e-30)
883
+ generic_severity = float(generic_interval[-1]) / max(float(generic_threshold), 1.0e-30)
884
+ if generic_severity > max(1.5 * strict_severity, strict_severity + 0.5):
885
+ return generic_interval, generic_threshold, "generic"
886
+ strict_mode = "strict global" if _STRICT_JUMP_APPLY_GLOBALLY else f"strict P<{_STRICT_JUMP_P_MAX:2.3g}"
887
+ return strict_interval, strict_threshold, strict_mode
888
+ return generic_interval, generic_threshold, "generic"
889
+
890
+
891
+ def _raise_if_large_delta_p_underresolved(
892
+ temperatures: np.ndarray,
893
+ probabilities: np.ndarray,
894
+ settings,
895
+ *,
896
+ stage: str,
897
+ outcome: str,
898
+ ) -> None:
899
+ """Reject a final profile when the remaining neighbouring jump is too large.
900
+
901
+ The controller already treats large neighbouring ``Delta P`` intervals as a
902
+ refinement target. This helper enforces a stricter final acceptance rule:
903
+ once the solver is about to return a physical verdict (success or eternal
904
+ inflation), the remaining largest neighbouring jump must be small enough to
905
+ trust that verdict numerically.
906
+ """
907
+
908
+ jump_interval, threshold, threshold_mode = _preferred_probability_jump_interval(
909
+ temperatures,
910
+ probabilities,
911
+ settings,
912
+ for_acceptance=True,
913
+ )
914
+ if threshold <= 0.0:
915
+ return
916
+ if jump_interval is None:
917
+ return
918
+ jump_high, jump_low, probability_high, probability_low, delta_p = jump_interval
919
+ raise errors.PercolationError(
920
+ "Numerical accuracy error: detected a sharp unresolved jump in P(T) at "
921
+ f"T = [{jump_low:2.5g}, {jump_high:2.5g}] where "
922
+ f"P = {probability_high:2.5g} -> {probability_low:2.5g} "
923
+ f"(Delta P = {delta_p:2.5g} > {threshold:2.5g}). "
924
+ f"{stage} cannot accept the {outcome} profile because this jump remained "
925
+ "after the controller already spent its remaining support budget on local "
926
+ f"jump refinement ({threshold_mode} criterion). This indicates a "
927
+ "non-smooth or unstable bounce/action input in that interval, so the "
928
+ "transition profile is not trustworthy."
929
+ )
930
+
931
+
932
+ def _raise_if_hot_head_underresolved(
933
+ temperatures: np.ndarray,
934
+ probabilities: np.ndarray,
935
+ actions: np.ndarray,
936
+ hubble: np.ndarray,
937
+ settings,
938
+ *,
939
+ stage: str,
940
+ outcome: str,
941
+ ) -> None:
942
+ """Reject a final profile when the hottest retained support point starts too late."""
943
+
944
+ hot_head_unresolved, probability_hot, log_gamma_h4_hot = _hot_head_underresolved(
945
+ temperatures,
946
+ probabilities,
947
+ actions,
948
+ hubble,
949
+ settings,
950
+ for_acceptance=True,
951
+ )
952
+ if not hot_head_unresolved:
953
+ return
954
+
955
+ probability_gate = float(settings.f_start)
956
+ threshold = HOT_RATE_ACCEPT_LOG
957
+ raise errors.PercolationError(
958
+ "Numerical accuracy error: the hot onset of P(T) is still underresolved. "
959
+ f"The hottest retained support point has P = {probability_hot:2.5g} <= {probability_gate:2.5g}, "
960
+ f"but log10(Gamma/H^4) = {log_gamma_h4_hot:2.5g} > {threshold:2.5g}. "
961
+ f"{stage} cannot accept the {outcome} profile because the support still starts "
962
+ "too late on the hot side. This misses part of the early nucleation tail and "
963
+ "can underestimate RH / betaH_from_RH."
964
+ )
965
+
966
+
967
+ def _build_dynamiczoomwindow_unresolved_jump_grid(
968
+ temperatures: np.ndarray,
969
+ probabilities: np.ndarray,
970
+ settings,
971
+ *,
972
+ max_new_points: int,
973
+ actions: np.ndarray | None = None,
974
+ hubble: np.ndarray | None = None,
975
+ vw: float = 1.0,
976
+ ) -> np.ndarray | None:
977
+ """Return mandatory jump-refinement points for unresolved-tail profiles.
978
+
979
+ Even before the full transition is bracketed, a broad neighbouring jump in
980
+ ``P(T)`` can already invalidate any later eternal-inflation / completion
981
+ verdict. In that situation the controller should spend support directly
982
+ inside the sharp interval before extending the cold tail further.
983
+ """
984
+
985
+ jump_interval, _, threshold_mode = _preferred_probability_jump_interval(
986
+ temperatures,
987
+ probabilities,
988
+ settings,
989
+ for_acceptance=True,
990
+ )
991
+ if jump_interval is None:
992
+ return None
993
+ jump_high, jump_low, _, _, delta_p = jump_interval
994
+ if _rate_interval_is_post_peak_inert(
995
+ temperatures,
996
+ actions,
997
+ hubble,
998
+ jump_high,
999
+ jump_low,
1000
+ settings,
1001
+ vw=vw,
1002
+ ):
1003
+ return None
1004
+ _, threshold, _ = _preferred_probability_jump_interval(
1005
+ temperatures,
1006
+ probabilities,
1007
+ settings,
1008
+ for_acceptance=True,
1009
+ )
1010
+ requested_points = int(max(1, max_new_points))
1011
+ if threshold > 0.0 and np.isfinite(delta_p):
1012
+ base_points = max(int(settings.n_action_increment), 1)
1013
+ severity = max(float(delta_p) / float(threshold), 1.0)
1014
+ boosted_points = int(math.ceil(base_points * severity))
1015
+ requested_points = min(
1016
+ int(max_new_points),
1017
+ max(base_points, min(boosted_points, 3 * base_points)),
1018
+ )
1019
+ return _build_dynamiczoomwindow_jump_grid(
1020
+ jump_high,
1021
+ jump_low,
1022
+ max_new_points=requested_points,
1023
+ )
1024
+
1025
+
1026
+ def _range_expand_batch_size(settings, remaining_budget: int) -> int:
1027
+ """Return the number of points one unresolved-edge range expansion may add.
1028
+
1029
+ Range growth is only meant to probe the next unresolved edge, not to flood
1030
+ the whole tail with support. Large batches were one of the main causes of
1031
+ the 150-250 point active grids in the first v2 tests, especially for low-g
1032
+ points that still needed many cold expansions.
1033
+ """
1034
+ if remaining_budget <= 0:
1035
+ return 0
1036
+ return max(1, min(int(settings.n_action_increment), int(remaining_budget), 5))
1037
+
1038
+
1039
+ def _build_dynamiczoomwindow_anchor_remesh_grid(
1040
+ temperatures: np.ndarray,
1041
+ probabilities: np.ndarray,
1042
+ settings,
1043
+ *,
1044
+ max_new_points: int | None = None,
1045
+ actions: np.ndarray | None = None,
1046
+ hubble: np.ndarray | None = None,
1047
+ vw: float = 1.0,
1048
+ ) -> tuple[np.ndarray | None, str | None]:
1049
+ """Return one simplified remeshing batch from fixed ``P(T)`` anchors.
1050
+
1051
+ The controller is intentionally simple:
1052
+
1053
+ 1. add temperatures that correspond to a fixed set of probability anchors
1054
+ already reached by the current profile,
1055
+ 2. then repeatedly split the largest neighbouring interval whose
1056
+ ``Delta P`` still exceeds the allowed band-dependent threshold.
1057
+
1058
+ This gives a near-uniform mesh in probability space, which is close to the
1059
+ original goal of distributing support roughly evenly over the whole rise of
1060
+ ``P(T)`` without stacking many separate refinement heuristics.
1061
+ """
1062
+
1063
+ temps = np.asarray(temperatures, dtype=float)
1064
+ probs = np.asarray(probabilities, dtype=float)
1065
+ finite_mask = np.isfinite(temps) & np.isfinite(probs)
1066
+ temps = temps[finite_mask]
1067
+ probs = probs[finite_mask]
1068
+ if temps.size < 2:
1069
+ return None, None
1070
+ temps = _temperature_grid(temps)
1071
+ if temps.size != probs.size:
1072
+ # Preserve the original descending ordering when finite masks shortened
1073
+ # the arrays asymmetrically.
1074
+ probs = np.asarray(
1075
+ [float(value) for _, value in sorted(zip(np.asarray(temperatures, dtype=float)[finite_mask], probs), reverse=True)],
1076
+ dtype=float,
1077
+ )
1078
+ probs = probs[: temps.size]
1079
+ if temps.size < 2 or probs.size < 2:
1080
+ return None, None
1081
+
1082
+ requested_new_points = max(int(settings.n_action_increment if max_new_points is None else max_new_points), 1)
1083
+ candidate = np.asarray([], dtype=float)
1084
+ occupied = temps.copy()
1085
+ added_labels: list[str] = []
1086
+ occupied_probs_cache = np.asarray(probs, dtype=float).copy()
1087
+
1088
+ def update_occupied_probabilities() -> np.ndarray:
1089
+ values: list[float] = []
1090
+ for value in occupied:
1091
+ probability = _probability_at(value, temps, probs)
1092
+ values.append(float("nan") if probability is None else float(probability))
1093
+ return np.asarray(values, dtype=float)
1094
+
1095
+ def add_candidate(temperature: float | None, label: str) -> bool:
1096
+ nonlocal candidate, occupied, occupied_probs_cache
1097
+ if temperature is None or not np.isfinite(float(temperature)):
1098
+ return False
1099
+ value = float(temperature)
1100
+ if _temperature_present(occupied, value) or _temperature_present(candidate, value):
1101
+ return False
1102
+ updated_occupied = _temperature_grid(np.append(occupied, value))
1103
+ candidate = _temperature_grid(np.append(candidate, value))
1104
+ occupied = updated_occupied
1105
+ occupied_probs_cache = update_occupied_probabilities()
1106
+ added_labels.append(label)
1107
+ return True
1108
+
1109
+ p_min = float(np.min(probs))
1110
+ p_max = float(np.max(probs))
1111
+ anchor_targets = sorted(
1112
+ {
1113
+ float(value)
1114
+ for value in _PROBABILITY_REMESH_ANCHORS
1115
+ if np.isfinite(float(value))
1116
+ }
1117
+ | {
1118
+ float(value)
1119
+ for value in HOT_PROBABILITY_ANCHORS
1120
+ if np.isfinite(float(value))
1121
+ }
1122
+ | {float(settings.f_perc), float(settings.f_final), 1.0e-2}
1123
+ )
1124
+
1125
+ for target in anchor_targets:
1126
+ if candidate.size >= requested_new_points:
1127
+ break
1128
+ if not (p_min + 1e-12 < target < p_max - 1e-12):
1129
+ continue
1130
+ target_tolerance = _probability_anchor_presence_tolerance(target, settings)
1131
+ if np.isfinite(target_tolerance) and np.any(
1132
+ np.isfinite(occupied_probs_cache)
1133
+ & (np.abs(occupied_probs_cache - float(target)) <= float(target_tolerance))
1134
+ ):
1135
+ continue
1136
+ crossing, _, crossing_index = _find_value_crossing(temps, probs, target)
1137
+ if crossing_index is None:
1138
+ continue
1139
+ add_candidate(crossing, f"P={target:2.3g}")
1140
+
1141
+ while candidate.size < requested_new_points:
1142
+ occupied_probs = occupied_probs_cache
1143
+
1144
+ best_interval: tuple[float, float, float, float, float, float] | None = None
1145
+ best_score = 1.0
1146
+ for i in range(min(len(occupied), len(occupied_probs)) - 1):
1147
+ upper = float(occupied[i])
1148
+ lower = float(occupied[i + 1])
1149
+ probability_high = float(occupied_probs[i])
1150
+ probability_low = float(occupied_probs[i + 1])
1151
+ if not (
1152
+ np.isfinite(upper)
1153
+ and np.isfinite(lower)
1154
+ and np.isfinite(probability_high)
1155
+ and np.isfinite(probability_low)
1156
+ and upper > lower
1157
+ ):
1158
+ continue
1159
+ delta_p = abs(probability_low - probability_high)
1160
+ threshold = _remesh_dp_limit(probability_high, probability_low, settings)
1161
+ if threshold <= 0.0:
1162
+ continue
1163
+ score = delta_p / threshold
1164
+ if _rate_interval_is_post_peak_inert(
1165
+ temps,
1166
+ actions,
1167
+ hubble,
1168
+ upper,
1169
+ lower,
1170
+ settings,
1171
+ vw=vw,
1172
+ ):
1173
+ continue
1174
+ if score > best_score:
1175
+ best_score = score
1176
+ best_interval = (
1177
+ upper,
1178
+ lower,
1179
+ probability_high,
1180
+ probability_low,
1181
+ delta_p,
1182
+ threshold,
1183
+ )
1184
+
1185
+ if best_interval is None:
1186
+ break
1187
+
1188
+ upper, lower, probability_high, probability_low, delta_p, threshold = best_interval
1189
+ midpoint = _temperature_midpoint(upper, lower)
1190
+ if not add_candidate(
1191
+ midpoint,
1192
+ f"dP={delta_p:2.3g}>{threshold:2.3g} at {probability_high:2.3g}->{probability_low:2.3g}",
1193
+ ):
1194
+ break
1195
+
1196
+ if candidate.size == 0:
1197
+ return None, None
1198
+
1199
+ reason = (
1200
+ "anchor_remesh from fixed P(T) anchors and largest remaining Delta P gaps; added "
1201
+ + ", ".join(added_labels)
1202
+ )
1203
+ return candidate, reason
1204
+
1205
+
1206
+ def _build_dynamiczoomwindow_range_grid(
1207
+ temperatures: np.ndarray,
1208
+ *,
1209
+ new_high: float,
1210
+ new_low: float,
1211
+ max_new_points: int,
1212
+ ) -> np.ndarray | None:
1213
+ """Extend the sampled temperature range with only a few new support points."""
1214
+ if max_new_points <= 0:
1215
+ return None
1216
+
1217
+ temps = _temperature_grid(temperatures)
1218
+ if temps.size == 0:
1219
+ return None
1220
+
1221
+ current_high = float(temps[0])
1222
+ current_low = float(temps[-1])
1223
+ span_high = max(float(new_high) - current_high, 0.0)
1224
+ span_low = max(current_low - float(new_low), 0.0)
1225
+ if span_high <= 0.0 and span_low <= 0.0:
1226
+ return None
1227
+
1228
+ remaining = int(max_new_points)
1229
+ n_high = 0
1230
+ n_low = 0
1231
+ if span_high > 0.0:
1232
+ n_high = 1
1233
+ remaining -= 1
1234
+ if span_low > 0.0 and remaining > 0:
1235
+ n_low = 1
1236
+ remaining -= 1
1237
+
1238
+ total_span = span_high + span_low
1239
+ while remaining > 0 and total_span > 0.0:
1240
+ if span_high > 0.0 and (
1241
+ span_low <= 0.0
1242
+ or (n_high - 1 + 1e-12) / max(span_high, 1e-30)
1243
+ <= (n_low - 1 + 1e-12) / max(span_low, 1e-30)
1244
+ ):
1245
+ n_high += 1
1246
+ elif span_low > 0.0:
1247
+ n_low += 1
1248
+ remaining -= 1
1249
+
1250
+ candidate = np.asarray([], dtype=float)
1251
+ if n_high > 0:
1252
+ for value in np.linspace(float(new_high), current_high, n_high + 1, dtype=float)[:-1]:
1253
+ candidate = _temperature_grid(np.append(candidate, float(value)))
1254
+ if n_low > 0:
1255
+ low_endpoint = float(new_low)
1256
+ low_ratio = (
1257
+ current_low / low_endpoint
1258
+ if current_low > 0.0 and low_endpoint > 0.0
1259
+ else 1.0
1260
+ )
1261
+ if low_ratio > 10.0:
1262
+ low_values = np.geomspace(current_low, low_endpoint, n_low + 1, dtype=float)[1:]
1263
+ else:
1264
+ low_values = np.linspace(current_low, low_endpoint, n_low + 1, dtype=float)[1:]
1265
+ for value in low_values:
1266
+ candidate = _temperature_grid(np.append(candidate, float(value)))
1267
+
1268
+ candidate = np.asarray(
1269
+ [
1270
+ float(value)
1271
+ for value in _temperature_grid(candidate)
1272
+ if not _temperature_present(temps, float(value))
1273
+ ],
1274
+ dtype=float,
1275
+ )
1276
+ return candidate if candidate.size > 0 else None
1277
+
1278
+
1279
+ def _adaptive_cold_boundary(
1280
+ *,
1281
+ current_low: float,
1282
+ target_low: float,
1283
+ current_probability: float,
1284
+ current_action: float,
1285
+ current_hubble: float,
1286
+ settings,
1287
+ last_temperature: float | None,
1288
+ last_probability: float | None,
1289
+ last_integral: float | None,
1290
+ last_log_gamma_h4: float | None,
1291
+ post_peak_streak: int,
1292
+ ) -> tuple[float, float, float | None, int]:
1293
+ """Choose the next cold boundary from the observed gain of the last batch.
1294
+
1295
+ The controller measures how much the previous cold-side extension increased
1296
+ ``P(Tmin)`` (or, close to completion, ``I(Tmin) = -ln(1-P)``). Large gains
1297
+ imply the next step should shrink; tiny gains imply that the remaining cold
1298
+ tail must be explored with a larger logarithmic jump. Once
1299
+ ``log(Gamma/H^4)`` is already falling on the cold side, the controller
1300
+ boosts the step size further because the rate has passed its peak.
1301
+ """
1302
+ current = float(current_low)
1303
+ target = float(target_low)
1304
+ if np.isfinite(current_probability):
1305
+ p_clipped = min(max(float(current_probability), 0.0), 1.0 - 1e-15)
1306
+ current_integral = float(-math.log1p(-p_clipped)) if p_clipped > 0.0 else 0.0
1307
+ else:
1308
+ current_integral = 0.0
1309
+ current_log_gamma_h4_raw = _log_gamma_h4_array(
1310
+ np.asarray([current], dtype=float),
1311
+ np.asarray([current_action], dtype=float),
1312
+ np.asarray([current_hubble], dtype=float),
1313
+ )[0]
1314
+ current_log_gamma_h4 = float(current_log_gamma_h4_raw) if np.isfinite(current_log_gamma_h4_raw) else None
1315
+
1316
+ if not (np.isfinite(current) and np.isfinite(target) and current > 0.0 and target > 0.0 and target < current):
1317
+ return target, current_integral, current_log_gamma_h4, 0
1318
+
1319
+ remaining_log_span = math.log(current / target)
1320
+ dlnT_next = settings.rel_increment * remaining_log_span
1321
+ dlnT_next = min(max(dlnT_next, _COLD_TAIL_MIN_LOG_STEP), _COLD_TAIL_MAX_LOG_STEP)
1322
+
1323
+ updated_streak = 0
1324
+ last_temp_finite = bool(last_temperature is not None and np.isfinite(last_temperature) and float(last_temperature) > current)
1325
+ if last_temp_finite:
1326
+ previous_log_step = math.log(float(last_temperature) / current)
1327
+ if previous_log_step > 0.0:
1328
+ if (
1329
+ current_log_gamma_h4 is not None
1330
+ and last_log_gamma_h4 is not None
1331
+ and np.isfinite(last_log_gamma_h4)
1332
+ ):
1333
+ if current_log_gamma_h4 < float(last_log_gamma_h4):
1334
+ updated_streak = int(post_peak_streak) + 1
1335
+ else:
1336
+ updated_streak = 0
1337
+ else:
1338
+ updated_streak = 0
1339
+
1340
+ if current_probability >= _COLD_TAIL_INTEGRAL_SWITCH_P:
1341
+ previous_metric = 0.0 if last_integral is None or not np.isfinite(last_integral) else float(last_integral)
1342
+ metric_gain = current_integral - previous_metric
1343
+ target_gain = _COLD_TAIL_TARGET_DI
1344
+ else:
1345
+ previous_metric = 0.0 if last_probability is None or not np.isfinite(last_probability) else float(last_probability)
1346
+ metric_gain = float(current_probability) - previous_metric
1347
+ target_gain = _COLD_TAIL_TARGET_DP
1348
+
1349
+ if not np.isfinite(metric_gain) or metric_gain <= 1e-8:
1350
+ # If the previous cold-side extension produced essentially no
1351
+ # gain in P(Tmin), do not keep exploring decade-by-decade. Jump
1352
+ # by at least two decades when possible; later remeshing is much
1353
+ # cheaper than repeatedly adding almost equivalent cold points.
1354
+ dlnT_next = max(
1355
+ previous_log_step * _COLD_TAIL_ZERO_GAIN_SCALE,
1356
+ math.log(100.0),
1357
+ )
1358
+ else:
1359
+ dlnT_next = previous_log_step * target_gain / metric_gain
1360
+
1361
+ dlnT_next = min(
1362
+ max(dlnT_next, previous_log_step * _COLD_TAIL_STEP_SHRINK_LIMIT),
1363
+ previous_log_step * _COLD_TAIL_STEP_GROWTH_LIMIT,
1364
+ )
1365
+ dlnT_next = min(max(dlnT_next, _COLD_TAIL_MIN_LOG_STEP), _COLD_TAIL_MAX_LOG_STEP)
1366
+
1367
+ if (
1368
+ updated_streak >= _COLD_TAIL_POST_PEAK_MIN_STREAK
1369
+ and current_log_gamma_h4 is not None
1370
+ and last_log_gamma_h4 is not None
1371
+ and np.isfinite(last_log_gamma_h4)
1372
+ ):
1373
+ slope = abs((current_log_gamma_h4 - float(last_log_gamma_h4)) / max(previous_log_step, 1e-12))
1374
+ boost = 1.0 + _COLD_TAIL_POST_PEAK_BOOST_COEFF * slope
1375
+ boost = min(boost, _COLD_TAIL_POST_PEAK_MAX_BOOST)
1376
+ dlnT_next *= boost
1377
+ dlnT_next = min(max(dlnT_next, _COLD_TAIL_MIN_LOG_STEP), _COLD_TAIL_MAX_LOG_STEP)
1378
+
1379
+ dlnT_next = min(dlnT_next, remaining_log_span)
1380
+ if dlnT_next <= 0.0:
1381
+ return target, current_integral, current_log_gamma_h4, updated_streak
1382
+
1383
+ if dlnT_next >= remaining_log_span * (1.0 - 1e-12):
1384
+ return target, current_integral, current_log_gamma_h4, updated_streak
1385
+ return float(current * math.exp(-dlnT_next)), current_integral, current_log_gamma_h4, updated_streak