PyAntiGen 1.0.7__tar.gz → 1.0.9__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 (70) hide show
  1. {pyantigen-1.0.7 → pyantigen-1.0.9}/PKG-INFO +9 -1
  2. {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/PKG-INFO +9 -1
  3. {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/SOURCES.txt +11 -1
  4. {pyantigen-1.0.7 → pyantigen-1.0.9}/README.md +8 -0
  5. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/cli.py +22 -0
  6. pyantigen-1.0.9/framework/template/Example/Engine/Anchor_cache.py +193 -0
  7. pyantigen-1.0.9/framework/template/Example/Engine/Deadline.py +535 -0
  8. pyantigen-1.0.9/framework/template/Example/Engine/Evaluator.py +1176 -0
  9. pyantigen-1.0.9/framework/template/Example/Engine/Event_times.py +491 -0
  10. pyantigen-1.0.9/framework/template/Example/Engine/Fast_profile.py +701 -0
  11. pyantigen-1.0.9/framework/template/Example/Engine/Fit_cache.py +329 -0
  12. pyantigen-1.0.9/framework/template/Example/Engine/Identifiability.py +698 -0
  13. pyantigen-1.0.9/framework/template/Example/Engine/Model_optimize.py +1483 -0
  14. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Model_simulate.py +5 -0
  15. pyantigen-1.0.9/framework/template/Example/Engine/Optimize.py +6862 -0
  16. pyantigen-1.0.9/framework/template/Example/Engine/Preequil_cache.py +361 -0
  17. pyantigen-1.0.9/framework/template/Example/Engine/Profile_checkpoint.py +399 -0
  18. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Results.py +153 -2
  19. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Sensitivity_analysis.py +14 -4
  20. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Simulate.py +278 -11
  21. pyantigen-1.0.9/framework/template/Example/Flipflop_reference.py +401 -0
  22. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Model_generate.py +4 -1
  23. pyantigen-1.0.9/framework/template/Example/Model_run.py +261 -0
  24. pyantigen-1.0.9/framework/template/Example/Modules/Data.py +63 -0
  25. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Experiment.py +34 -0
  26. pyantigen-1.0.9/framework/template/Example/Modules/Loss_config.py +61 -0
  27. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Optimizer_settings.py +118 -0
  28. pyantigen-1.0.9/framework/template/Example/Modules/Plots.py +89 -0
  29. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Update_parameters.py +10 -0
  30. pyantigen-1.0.9/framework/template/data/Flipflop.csv +29 -0
  31. pyantigen-1.0.9/framework/template/data/make_flipflop_data.py +174 -0
  32. {pyantigen-1.0.7 → pyantigen-1.0.9}/pyproject.toml +1 -1
  33. {pyantigen-1.0.7 → pyantigen-1.0.9}/setup.py +1 -1
  34. pyantigen-1.0.7/framework/template/Example/Engine/Evaluator.py +0 -521
  35. pyantigen-1.0.7/framework/template/Example/Engine/Model_optimize.py +0 -704
  36. pyantigen-1.0.7/framework/template/Example/Engine/Optimize.py +0 -4108
  37. pyantigen-1.0.7/framework/template/Example/Engine/Profile_checkpoint.py +0 -206
  38. pyantigen-1.0.7/framework/template/Example/Model_run.py +0 -143
  39. pyantigen-1.0.7/framework/template/Example/Modules/Data.py +0 -36
  40. pyantigen-1.0.7/framework/template/Example/Modules/Loss_config.py +0 -21
  41. pyantigen-1.0.7/framework/template/Example/Modules/Plots.py +0 -51
  42. {pyantigen-1.0.7 → pyantigen-1.0.9}/LICENSE +0 -0
  43. {pyantigen-1.0.7 → pyantigen-1.0.9}/MANIFEST.in +0 -0
  44. {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  45. {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/entry_points.txt +0 -0
  46. {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/requires.txt +0 -0
  47. {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/top_level.txt +0 -0
  48. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/AntimonyGen.py +0 -0
  49. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/RxnDict_to_antimony.py +0 -0
  50. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/TelluriumGen.py +0 -0
  51. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/__init__.py +0 -0
  52. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/antimony_utils.py +0 -0
  53. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/data_interpolation.py +0 -0
  54. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/isotopomer_tools.py +0 -0
  55. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/model_generation.py +0 -0
  56. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/models.py +0 -0
  57. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/module_base.py +0 -0
  58. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/pyantigen.py +0 -0
  59. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/rate_laws.py +0 -0
  60. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/reaction_creation.py +0 -0
  61. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/AntiGen_paths.py +0 -0
  62. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Nuisance_sensitivity.py +0 -0
  63. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Petab_export.py +0 -0
  64. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Events.py +0 -0
  65. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Observed_species.py +0 -0
  66. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Solver_settings.py +0 -0
  67. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
  68. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/data/ADneg.csv +0 -0
  69. {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/data/ADpos.csv +0 -0
  70. {pyantigen-1.0.7 → pyantigen-1.0.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.7
3
+ Version: 1.0.9
4
4
  Summary: Short one-line description of what PyAntiGen does
5
5
  Author: Open Source Contributor
6
6
  Author-email: Don <you@example.com>
@@ -108,8 +108,16 @@ To run optimization examples use the --optimize flag:
108
108
  python Model_run.py --optimize Example1
109
109
  python Model_run.py --optimize Example2
110
110
  python Model_run.py --optimize Example3
111
+ python Model_run.py --optimize Example4
112
+ python Model_run.py --optimize Example5
111
113
  ```
112
114
 
115
+ Each example prints a banner explaining what it demonstrates before the run and a "what to look for" summary after it. All output lands in `results/Example/`, prefixed with the example and group names so runs never overwrite each other — e.g. `Example_Example2_ADpos_profile_likelihood.png`, `Example_Example4_Flipflop_optimization_results.csv`.
116
+
117
+ Examples 1–3 demonstrate structural identifiability: Example1/2 split the fit so each sub-problem is well-posed, while Example3 deliberately fits two exactly confounded parameters (`SF`/`V_Comp1`) jointly and shows how profile likelihood flags the ridge that likelihood slices and Sobol indices miss.
118
+
119
+ Examples 4–5 go further and test the *accuracy* of the profile likelihood ΔNLL itself, on a genuinely multimodal problem with a log10 objective. The chain A → B → C observed through `SF*B_Comp1/V_Comp1` has the classic pharmacokinetic "flip-flop" ambiguity — swapping the two rate constants and rescaling `SF` reproduces the data exactly — so the likelihood has two modes separated by a known ΔNLL gap (~2.4 at the NLL optimum, set by a few deliberately noisy observations of A and printed by `data/make_flipflop_data.py`). Example4 starts in the correct basin. Accurate profiles must (a) dip below zero by a known amount (~−2.1), because the fitting objective's per-observable averaging places the fit away from the inference NLL optimum that ΔNLL is anchored to, and (b) show the second mode at ~+0.8 — below the 95% threshold, so the correct confidence set is a union of two disjoint intervals; a walker that stops at the first threshold crossing never finds it, and a first-crossing CI extractor cannot represent it. Example5 starts in the wrong basin: sigmas are frozen at the wrong mode (inflating σ for the A data and deflating every ΔNLL), and the profile must dip to ~−0.98 at the true mode. `Projects/Example/Flipflop_reference.py` recomputes the exact reference profiles from the closed-form solution with scipy (independent of RoadRunner and of the framework's loss code), replicating the pipeline's conventions — fit objective for the anchor, MLE-frozen sigmas, summed NLL — and its `--compare results/Example/<run>.json` mode scores the framework's stored profile traces against the reference automatically.
120
+
113
121
  Your own model lives under `Projects/MyNewModel/`. Modify the code for your model in `Projects/MyNewModel/Modules/`, `Projects/MyNewModel/Model_generate.py`, and `Projects/MyNewModel/Model_run.py`. Your problem will also require new modules in `antimony_modules/` to define the model.
114
122
 
115
123
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.7
3
+ Version: 1.0.9
4
4
  Summary: Short one-line description of what PyAntiGen does
5
5
  Author: Open Source Contributor
6
6
  Author-email: Don <you@example.com>
@@ -108,8 +108,16 @@ To run optimization examples use the --optimize flag:
108
108
  python Model_run.py --optimize Example1
109
109
  python Model_run.py --optimize Example2
110
110
  python Model_run.py --optimize Example3
111
+ python Model_run.py --optimize Example4
112
+ python Model_run.py --optimize Example5
111
113
  ```
112
114
 
115
+ Each example prints a banner explaining what it demonstrates before the run and a "what to look for" summary after it. All output lands in `results/Example/`, prefixed with the example and group names so runs never overwrite each other — e.g. `Example_Example2_ADpos_profile_likelihood.png`, `Example_Example4_Flipflop_optimization_results.csv`.
116
+
117
+ Examples 1–3 demonstrate structural identifiability: Example1/2 split the fit so each sub-problem is well-posed, while Example3 deliberately fits two exactly confounded parameters (`SF`/`V_Comp1`) jointly and shows how profile likelihood flags the ridge that likelihood slices and Sobol indices miss.
118
+
119
+ Examples 4–5 go further and test the *accuracy* of the profile likelihood ΔNLL itself, on a genuinely multimodal problem with a log10 objective. The chain A → B → C observed through `SF*B_Comp1/V_Comp1` has the classic pharmacokinetic "flip-flop" ambiguity — swapping the two rate constants and rescaling `SF` reproduces the data exactly — so the likelihood has two modes separated by a known ΔNLL gap (~2.4 at the NLL optimum, set by a few deliberately noisy observations of A and printed by `data/make_flipflop_data.py`). Example4 starts in the correct basin. Accurate profiles must (a) dip below zero by a known amount (~−2.1), because the fitting objective's per-observable averaging places the fit away from the inference NLL optimum that ΔNLL is anchored to, and (b) show the second mode at ~+0.8 — below the 95% threshold, so the correct confidence set is a union of two disjoint intervals; a walker that stops at the first threshold crossing never finds it, and a first-crossing CI extractor cannot represent it. Example5 starts in the wrong basin: sigmas are frozen at the wrong mode (inflating σ for the A data and deflating every ΔNLL), and the profile must dip to ~−0.98 at the true mode. `Projects/Example/Flipflop_reference.py` recomputes the exact reference profiles from the closed-form solution with scipy (independent of RoadRunner and of the framework's loss code), replicating the pipeline's conventions — fit objective for the anchor, MLE-frozen sigmas, summed NLL — and its `--compare results/Example/<run>.json` mode scores the framework's stored profile traces against the reference automatically.
120
+
113
121
  Your own model lives under `Projects/MyNewModel/`. Modify the code for your model in `Projects/MyNewModel/Modules/`, `Projects/MyNewModel/Model_generate.py`, and `Projects/MyNewModel/Model_run.py`. Your problem will also require new modules in `antimony_modules/` to define the model.
114
122
 
115
123
 
@@ -24,14 +24,22 @@ framework/pyantigen.py
24
24
  framework/rate_laws.py
25
25
  framework/reaction_creation.py
26
26
  framework/template/Example/AntiGen_paths.py
27
+ framework/template/Example/Flipflop_reference.py
27
28
  framework/template/Example/Model_generate.py
28
29
  framework/template/Example/Model_run.py
30
+ framework/template/Example/Engine/Anchor_cache.py
31
+ framework/template/Example/Engine/Deadline.py
29
32
  framework/template/Example/Engine/Evaluator.py
33
+ framework/template/Example/Engine/Event_times.py
34
+ framework/template/Example/Engine/Fast_profile.py
35
+ framework/template/Example/Engine/Fit_cache.py
36
+ framework/template/Example/Engine/Identifiability.py
30
37
  framework/template/Example/Engine/Model_optimize.py
31
38
  framework/template/Example/Engine/Model_simulate.py
32
39
  framework/template/Example/Engine/Nuisance_sensitivity.py
33
40
  framework/template/Example/Engine/Optimize.py
34
41
  framework/template/Example/Engine/Petab_export.py
42
+ framework/template/Example/Engine/Preequil_cache.py
35
43
  framework/template/Example/Engine/Profile_checkpoint.py
36
44
  framework/template/Example/Engine/Results.py
37
45
  framework/template/Example/Engine/Sensitivity_analysis.py
@@ -47,4 +55,6 @@ framework/template/Example/Modules/Solver_settings.py
47
55
  framework/template/Example/Modules/Update_opt_parameters.py
48
56
  framework/template/Example/Modules/Update_parameters.py
49
57
  framework/template/data/ADneg.csv
50
- framework/template/data/ADpos.csv
58
+ framework/template/data/ADpos.csv
59
+ framework/template/data/Flipflop.csv
60
+ framework/template/data/make_flipflop_data.py
@@ -82,8 +82,16 @@ To run optimization examples use the --optimize flag:
82
82
  python Model_run.py --optimize Example1
83
83
  python Model_run.py --optimize Example2
84
84
  python Model_run.py --optimize Example3
85
+ python Model_run.py --optimize Example4
86
+ python Model_run.py --optimize Example5
85
87
  ```
86
88
 
89
+ Each example prints a banner explaining what it demonstrates before the run and a "what to look for" summary after it. All output lands in `results/Example/`, prefixed with the example and group names so runs never overwrite each other — e.g. `Example_Example2_ADpos_profile_likelihood.png`, `Example_Example4_Flipflop_optimization_results.csv`.
90
+
91
+ Examples 1–3 demonstrate structural identifiability: Example1/2 split the fit so each sub-problem is well-posed, while Example3 deliberately fits two exactly confounded parameters (`SF`/`V_Comp1`) jointly and shows how profile likelihood flags the ridge that likelihood slices and Sobol indices miss.
92
+
93
+ Examples 4–5 go further and test the *accuracy* of the profile likelihood ΔNLL itself, on a genuinely multimodal problem with a log10 objective. The chain A → B → C observed through `SF*B_Comp1/V_Comp1` has the classic pharmacokinetic "flip-flop" ambiguity — swapping the two rate constants and rescaling `SF` reproduces the data exactly — so the likelihood has two modes separated by a known ΔNLL gap (~2.4 at the NLL optimum, set by a few deliberately noisy observations of A and printed by `data/make_flipflop_data.py`). Example4 starts in the correct basin. Accurate profiles must (a) dip below zero by a known amount (~−2.1), because the fitting objective's per-observable averaging places the fit away from the inference NLL optimum that ΔNLL is anchored to, and (b) show the second mode at ~+0.8 — below the 95% threshold, so the correct confidence set is a union of two disjoint intervals; a walker that stops at the first threshold crossing never finds it, and a first-crossing CI extractor cannot represent it. Example5 starts in the wrong basin: sigmas are frozen at the wrong mode (inflating σ for the A data and deflating every ΔNLL), and the profile must dip to ~−0.98 at the true mode. `Projects/Example/Flipflop_reference.py` recomputes the exact reference profiles from the closed-form solution with scipy (independent of RoadRunner and of the framework's loss code), replicating the pipeline's conventions — fit objective for the anchor, MLE-frozen sigmas, summed NLL — and its `--compare results/Example/<run>.json` mode scores the framework's stored profile traces against the reference automatically.
94
+
87
95
  Your own model lives under `Projects/MyNewModel/`. Modify the code for your model in `Projects/MyNewModel/Modules/`, `Projects/MyNewModel/Model_generate.py`, and `Projects/MyNewModel/Model_run.py`. Your problem will also require new modules in `antimony_modules/` to define the model.
88
96
 
89
97
 
@@ -66,6 +66,26 @@ def create_project():
66
66
 
67
67
  # Add the reaction to the model
68
68
  self.add_reaction(Reaction_name, Reactants, Products, Rate_type, Rate_eqtn_prototype)
69
+
70
+
71
+ class BasicChainReaction(PyAntiGenModule):
72
+ \"\"\"
73
+ Adds the second step of the chain A -> B -> C.
74
+ With k_B_to_C = 0 (the default in Example_parameters.csv) the
75
+ model behaves exactly like the single-step A -> B examples;
76
+ Example4/Example5 fit k_B_to_C to demonstrate flip-flop
77
+ bimodality.
78
+ \"\"\"
79
+ def build(self):
80
+ Compartments = ['Comp1']
81
+ for Comp in Compartments:
82
+ Reaction_name = f"Basic_B_to_C_{{Comp}}"
83
+ Reactants = f"[B_{{Comp}}]"
84
+ Products = f"[C_{{Comp}}]"
85
+ Rate_type = "MA"
86
+ Rate_eqtn_prototype = "k_B_to_C"
87
+
88
+ self.add_reaction(Reaction_name, Reactants, Products, Rate_type, Rate_eqtn_prototype)
69
89
  """))
70
90
  print(f" Created folder: {d}/")
71
91
 
@@ -126,6 +146,7 @@ def create_project():
126
146
  with open(param_csv_path, "w") as f:
127
147
  f.write("Parameter,Value,Units,Comment\n")
128
148
  f.write("k_A_to_B,0.1,,Default rate constant for A to B\n")
149
+ f.write("k_B_to_C,0.0,,Default rate constant for B to C (0 disables the chain step)\n")
129
150
  f.write("V_Comp1,1.0,,Default compartment volume\n")
130
151
  print(" Created file: antimony_models/Example/Example_parameters.csv")
131
152
 
@@ -134,6 +155,7 @@ def create_project():
134
155
  f.write("Species,InitialCondition,Units,Comment\n")
135
156
  f.write("A_Comp1,0.0,,Initial amount of A\n")
136
157
  f.write("B_Comp1,0.0,,Initial amount of B\n")
158
+ f.write("C_Comp1,0.0,,Initial amount of C\n")
137
159
  print(" Created file: antimony_models/Example/Example_InitialConditions.csv")
138
160
 
139
161
  init_cond_path = os.path.join(example_antimony_dir, "Example_manual.txt")
@@ -0,0 +1,193 @@
1
+ """Hessian-derived quantities, cached against the fit they belong to.
2
+
3
+ The Wald statistics cost ``1 + 2k + 2k(k-1)`` objective evaluations -- 513 on
4
+ the 16-parameter SILK APP spec -- and they are recomputed from scratch on every
5
+ launch. That was tolerable when an evaluation was assumed to cost seconds. It
6
+ is not: the measured cost on that spec is 116 s, so across 39 workers the
7
+ Hessian alone is about 25 minutes, and it is charged again on every link of a
8
+ chain that may run to a hundred links.
9
+
10
+ On a preemptible partition the number matters for a second and sharper reason.
11
+ Nothing is written until a profile point finishes, so a link only makes
12
+ progress if the node survives setup *plus* one slice. Cutting 25 minutes off
13
+ setup lowers that threshold directly, which is the difference between a
14
+ short-lived node contributing something and contributing nothing at all.
15
+
16
+ Caching is safe here because the Hessian is a pure function of things the run
17
+ already fingerprints: the model, the optimization spec, the parameter scaling
18
+ and the optimum it is taken at. A change in any of them produces a different
19
+ key and a miss, so a stale Hessian cannot be silently reused -- the failure
20
+ mode that would matter, since an SE that does not belong to this fit would set
21
+ the profile's whole grid in the wrong place.
22
+ """
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ from datetime import datetime
28
+
29
+ import numpy as np
30
+
31
+ from Engine.Profile_checkpoint import sweep_stale_temp_files
32
+
33
+ # Everything _attach_wald_stats puts in out["stats"]. Cached and restored as a
34
+ # set: a partial restore would leave the CI from one fit beside the SE of
35
+ # another.
36
+ WALD_FIELDS = ("wald_cov", "wald_se", "wald_se_opt", "wald_ci",
37
+ "wald_correlation")
38
+
39
+ # Bumped when the set or meaning of the cached fields changes, so old files
40
+ # miss rather than being misread.
41
+ # v1 held only the linear "wald_se".
42
+ # v2 adds "wald_se_opt", the SE in the optimizer's own space, which is what
43
+ # the profile and slice grids are placed with. A v1 file restored into a
44
+ # v2 run would leave that key absent and silently drop every grid back to
45
+ # the range_factor fallback.
46
+ _FORMAT = "wald-v2"
47
+
48
+ # Without this the block is not worth restoring: the profile grid is placed
49
+ # from it, and "no SE at all" triggers a different, deliberate fallback than
50
+ # "an SE in the wrong units".
51
+ _REQUIRED = ("wald_se", "wald_se_opt")
52
+
53
+
54
+ def _encode(obj):
55
+ """Arrays to nested lists, with non-finite values as null.
56
+
57
+ Non-finite entries are meaningful here -- an SE of nan is how "this
58
+ direction is flat, there is no usable standard error" is reported -- but
59
+ they are not portable JSON. They come back as nan, which is what every
60
+ consumer tests for with ``np.isfinite``.
61
+ """
62
+ if obj is None:
63
+ return None
64
+ arr = np.asarray(obj, dtype=float)
65
+ out = arr.tolist()
66
+
67
+ def _clean(v):
68
+ if isinstance(v, list):
69
+ return [_clean(x) for x in v]
70
+ return v if np.isfinite(v) else None
71
+
72
+ return _clean(out)
73
+
74
+
75
+ def _decode(obj):
76
+ """The inverse: nulls back to nan, lists back to arrays."""
77
+ if obj is None:
78
+ return None
79
+
80
+ def _fill(v):
81
+ if isinstance(v, list):
82
+ return [_fill(x) for x in v]
83
+ return float("nan") if v is None else float(v)
84
+
85
+ return np.asarray(_fill(obj), dtype=float)
86
+
87
+
88
+ def bounds_fingerprint(bounds):
89
+ """Hash of the declared bounds.
90
+
91
+ Separate from the profile's own spec hash on purpose. Bounds change the
92
+ Wald *interval* (it is clipped to them) without changing the model or the
93
+ optimum, so they belong in this cache's key -- but adding them to
94
+ ``spec_fingerprint`` would change every existing profile directory name and
95
+ orphan work already done.
96
+ """
97
+ if bounds is None:
98
+ return "none"
99
+ try:
100
+ blob = json.dumps(
101
+ [None if b is None else [None if v is None else round(float(v), 12)
102
+ for v in b]
103
+ for b in bounds],
104
+ sort_keys=True,
105
+ )
106
+ except (TypeError, ValueError):
107
+ return "unhashable"
108
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
109
+
110
+
111
+ class AnchorCache:
112
+ """Reads and writes the Wald block for one fit."""
113
+
114
+ def __init__(self, root, run_id, model_hash, spec_hash, bounds_hash,
115
+ n_params, enabled=True):
116
+ self.enabled = bool(enabled and root)
117
+ self.model_hash = model_hash
118
+ self.spec_hash = spec_hash
119
+ self.bounds_hash = bounds_hash
120
+ self.n_params = int(n_params)
121
+ self.dir = os.path.join(root, "profiles", run_id) if root else None
122
+ if self.enabled and self.dir:
123
+ try:
124
+ os.makedirs(self.dir, exist_ok=True)
125
+ # A kill between writing a temp file and renaming it leaves the
126
+ # temp behind; this directory is where they collect.
127
+ sweep_stale_temp_files(self.dir)
128
+ except OSError:
129
+ self.enabled = False
130
+
131
+ @property
132
+ def path(self):
133
+ return os.path.join(self.dir, "anchor.json") if self.dir else None
134
+
135
+ def load(self):
136
+ """The cached Wald block, or None on any miss.
137
+
138
+ Every failure is a miss rather than an error: a corrupt, truncated or
139
+ stale file must cost the 25 minutes of recomputation it was meant to
140
+ save, never the correctness of the run.
141
+ """
142
+ if not (self.enabled and self.path and os.path.exists(self.path)):
143
+ return None
144
+ try:
145
+ with open(self.path, "r", encoding="utf-8") as fh:
146
+ data = json.load(fh)
147
+ except (OSError, ValueError):
148
+ return None
149
+
150
+ if (data.get("format") != _FORMAT
151
+ or data.get("model_hash") != self.model_hash
152
+ or data.get("spec_hash") != self.spec_hash
153
+ or data.get("bounds_hash") != self.bounds_hash
154
+ or int(data.get("n_params") or -1) != self.n_params):
155
+ return None
156
+
157
+ stats = {}
158
+ for field in WALD_FIELDS:
159
+ if field in data:
160
+ stats[field] = _decode(data[field])
161
+
162
+ for field in _REQUIRED:
163
+ arr = stats.get(field)
164
+ if arr is None or np.asarray(arr).shape != (self.n_params,):
165
+ return None
166
+ return stats
167
+
168
+ def save(self, stats):
169
+ """Write the Wald block, atomically."""
170
+ if not (self.enabled and self.path):
171
+ return
172
+ payload = {
173
+ "format": _FORMAT,
174
+ "model_hash": self.model_hash,
175
+ "spec_hash": self.spec_hash,
176
+ "bounds_hash": self.bounds_hash,
177
+ "n_params": self.n_params,
178
+ "saved": datetime.now().isoformat(timespec="seconds"),
179
+ }
180
+ for field in WALD_FIELDS:
181
+ if stats.get(field) is not None:
182
+ payload[field] = _encode(stats[field])
183
+
184
+ tmp = f"{self.path}.{os.getpid()}.tmp"
185
+ try:
186
+ with open(tmp, "w", encoding="utf-8") as fh:
187
+ json.dump(payload, fh)
188
+ os.replace(tmp, self.path)
189
+ except (OSError, TypeError, ValueError):
190
+ try:
191
+ os.unlink(tmp)
192
+ except OSError:
193
+ pass