PyAntiGen 1.0.9__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.
- framework/AntimonyGen.py +48 -0
- framework/RxnDict_to_antimony.py +594 -0
- framework/TelluriumGen.py +16 -0
- framework/__init__.py +0 -0
- framework/antimony_utils.py +294 -0
- framework/cli.py +229 -0
- framework/data_interpolation.py +340 -0
- framework/isotopomer_tools.py +41 -0
- framework/model_generation.py +46 -0
- framework/models.py +189 -0
- framework/module_base.py +42 -0
- framework/pyantigen.py +51 -0
- framework/rate_laws.py +101 -0
- framework/reaction_creation.py +43 -0
- framework/template/Example/AntiGen_paths.py +23 -0
- framework/template/Example/Engine/Anchor_cache.py +193 -0
- framework/template/Example/Engine/Deadline.py +535 -0
- framework/template/Example/Engine/Evaluator.py +1176 -0
- framework/template/Example/Engine/Event_times.py +491 -0
- framework/template/Example/Engine/Fast_profile.py +701 -0
- framework/template/Example/Engine/Fit_cache.py +329 -0
- framework/template/Example/Engine/Identifiability.py +698 -0
- framework/template/Example/Engine/Model_optimize.py +1483 -0
- framework/template/Example/Engine/Model_simulate.py +124 -0
- framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
- framework/template/Example/Engine/Optimize.py +6862 -0
- framework/template/Example/Engine/Petab_export.py +398 -0
- framework/template/Example/Engine/Preequil_cache.py +361 -0
- framework/template/Example/Engine/Profile_checkpoint.py +399 -0
- framework/template/Example/Engine/Results.py +395 -0
- framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
- framework/template/Example/Engine/Simulate.py +617 -0
- framework/template/Example/Flipflop_reference.py +401 -0
- framework/template/Example/Model_generate.py +37 -0
- framework/template/Example/Model_run.py +261 -0
- framework/template/Example/Modules/Data.py +63 -0
- framework/template/Example/Modules/Events.py +14 -0
- framework/template/Example/Modules/Experiment.py +194 -0
- framework/template/Example/Modules/Loss_config.py +61 -0
- framework/template/Example/Modules/Observed_species.py +3 -0
- framework/template/Example/Modules/Optimizer_settings.py +258 -0
- framework/template/Example/Modules/Plots.py +89 -0
- framework/template/Example/Modules/Solver_settings.py +16 -0
- framework/template/Example/Modules/Update_opt_parameters.py +24 -0
- framework/template/Example/Modules/Update_parameters.py +49 -0
- framework/template/data/ADneg.csv +27 -0
- framework/template/data/ADpos.csv +27 -0
- framework/template/data/Flipflop.csv +29 -0
- framework/template/data/make_flipflop_data.py +174 -0
- pyantigen-1.0.9.dist-info/METADATA +129 -0
- pyantigen-1.0.9.dist-info/RECORD +55 -0
- pyantigen-1.0.9.dist-info/WHEEL +5 -0
- pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
- pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
- pyantigen-1.0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Reuse of the pre-dose simulation segment across objective evaluations.
|
|
2
|
+
|
|
3
|
+
The PK and antibody solver settings open with a ``preequil`` block that ages the
|
|
4
|
+
model from birth to the first dose -- seventy-odd years of integration whose
|
|
5
|
+
trajectory is never fitted (``tracked: False``); only the state it leaves behind
|
|
6
|
+
matters. On the microglia group that block is 1.5 s of the 5.2 s each arm costs,
|
|
7
|
+
so across twelve arms it is roughly 28% of every objective evaluation, repeated
|
|
8
|
+
unchanged for every one of the thousands of evaluations a fit or a profile runs.
|
|
9
|
+
|
|
10
|
+
It is unchanged because the parameters being fitted cannot act before the first
|
|
11
|
+
dose. Microglial activation is gated on ``f10_dense``, which is zero while
|
|
12
|
+
``Sat_Dense`` -- the antibody-bound fraction of dense plaque -- is zero;
|
|
13
|
+
``Microglia_high`` starts at zero with no other production route, so the
|
|
14
|
+
high-state clearances and ``k_deact_high`` multiply zero; and every remaining
|
|
15
|
+
fitted parameter either scales an ``__Antibody`` species or is an antibody
|
|
16
|
+
binding rate. With no antibody in the system they are all inert.
|
|
17
|
+
|
|
18
|
+
So the block is computed once per model and its end state restored thereafter.
|
|
19
|
+
Two things make that safe rather than merely fast:
|
|
20
|
+
|
|
21
|
+
* **The key is the state, not a name.** The cache is keyed on the model's entire
|
|
22
|
+
state after ``reset()`` and ``Update_parameters`` -- before any fitted value is
|
|
23
|
+
applied -- together with the block and solver spec. That state is a pure
|
|
24
|
+
function of (model, arm), so it is identical across evaluations by
|
|
25
|
+
construction and no assumption about *which* parameters are inert is encoded
|
|
26
|
+
in the key. Anything that does change the pre-dose setup changes the key and
|
|
27
|
+
gets its own entry.
|
|
28
|
+
|
|
29
|
+
* **The assumption is tested, not asserted.** Using the cache means applying the
|
|
30
|
+
fitted parameters *after* the pre-dose block instead of before it, which is
|
|
31
|
+
only valid while those parameters are inert there. :func:`verify_invariance`
|
|
32
|
+
checks exactly that at startup by integrating the block under two different
|
|
33
|
+
parameter vectors and comparing the results, and the cache refuses to enable
|
|
34
|
+
itself if the check fails. Without it, a later model edit that let a fitted
|
|
35
|
+
parameter act before the first dose would silently corrupt every result with
|
|
36
|
+
nothing in the output to show for it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
from Engine.Simulate import (
|
|
44
|
+
clamp_state_dust,
|
|
45
|
+
configure_integrator,
|
|
46
|
+
restore_model_state,
|
|
47
|
+
safe_simulate,
|
|
48
|
+
save_model_state,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def split_preequil_block(solver_settings):
|
|
53
|
+
"""Return ``(cacheable_block, remaining_settings)``.
|
|
54
|
+
|
|
55
|
+
The candidate is the *leading* block, and only when it is untracked: an
|
|
56
|
+
untracked block contributes no output, so replaying its end state is
|
|
57
|
+
equivalent to running it. A tracked leading block (Figure8's, for instance)
|
|
58
|
+
contributes rows to the result and is left alone.
|
|
59
|
+
"""
|
|
60
|
+
blocks = solver_settings.get("simulation_blocks")
|
|
61
|
+
if isinstance(blocks, dict):
|
|
62
|
+
items = list(blocks.items())
|
|
63
|
+
elif isinstance(blocks, (list, tuple)):
|
|
64
|
+
items = list(enumerate(blocks))
|
|
65
|
+
else:
|
|
66
|
+
return None, solver_settings
|
|
67
|
+
|
|
68
|
+
if len(items) < 2:
|
|
69
|
+
# Nothing would remain to simulate; not worth special-casing.
|
|
70
|
+
return None, solver_settings
|
|
71
|
+
|
|
72
|
+
_name, first = items[0]
|
|
73
|
+
if not isinstance(first, dict) or first.get("tracked", True):
|
|
74
|
+
return None, solver_settings
|
|
75
|
+
|
|
76
|
+
rest = dict(solver_settings)
|
|
77
|
+
if isinstance(blocks, dict):
|
|
78
|
+
rest["simulation_blocks"] = {k: v for k, v in items[1:]}
|
|
79
|
+
else:
|
|
80
|
+
rest["simulation_blocks"] = [b for _k, b in items[1:]]
|
|
81
|
+
return first, rest
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _digest(state, block, solver_settings):
|
|
85
|
+
"""Identity of a pre-dose result: the starting state and how it is run."""
|
|
86
|
+
h = hashlib.sha256()
|
|
87
|
+
for k, v in zip(state["keys"], state["values"]):
|
|
88
|
+
h.update(str(k).encode("utf-8"))
|
|
89
|
+
h.update(repr(float(v)).encode("utf-8"))
|
|
90
|
+
for field in ("start", "end", "n_points", "variable_step_size",
|
|
91
|
+
"maximum_num_steps"):
|
|
92
|
+
h.update(f"{field}={block.get(field)!r}".encode("utf-8"))
|
|
93
|
+
for field in ("integrator", "absolute_tolerance", "relative_tolerance",
|
|
94
|
+
"stiff", "maximum_num_steps"):
|
|
95
|
+
h.update(f"{field}={solver_settings.get(field)!r}".encode("utf-8"))
|
|
96
|
+
return h.hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PreequilCache:
|
|
100
|
+
"""Stores the end state of the pre-dose block for one model.
|
|
101
|
+
|
|
102
|
+
In practice this holds a single entry -- one arm per RoadRunner instance,
|
|
103
|
+
one pre-dose block per arm -- but it is keyed rather than a bare slot so
|
|
104
|
+
that anything which does alter the pre-dose setup produces a new entry
|
|
105
|
+
instead of silently reusing the wrong state.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, enabled=True):
|
|
109
|
+
self.enabled = bool(enabled)
|
|
110
|
+
self._store = {}
|
|
111
|
+
self.n_hits = 0
|
|
112
|
+
self.n_misses = 0
|
|
113
|
+
|
|
114
|
+
def apply(self, r, solver_settings, observed_species, label=None):
|
|
115
|
+
"""Resolve the pre-dose block; return the settings still to simulate.
|
|
116
|
+
|
|
117
|
+
On a hit the model is left in the stored end state. On a miss the block
|
|
118
|
+
is integrated here, under the same solver configuration ``simulate``
|
|
119
|
+
would have used, and the resulting state stored.
|
|
120
|
+
"""
|
|
121
|
+
if not self.enabled:
|
|
122
|
+
return solver_settings
|
|
123
|
+
|
|
124
|
+
block, rest = split_preequil_block(solver_settings)
|
|
125
|
+
if block is None:
|
|
126
|
+
return solver_settings
|
|
127
|
+
|
|
128
|
+
configure_integrator(r, solver_settings)
|
|
129
|
+
key = _digest(save_model_state(r), block, solver_settings)
|
|
130
|
+
|
|
131
|
+
hit = self._store.get(key)
|
|
132
|
+
if hit is not None:
|
|
133
|
+
restore_model_state(r, hit)
|
|
134
|
+
self.n_hits += 1
|
|
135
|
+
return rest
|
|
136
|
+
|
|
137
|
+
safe_simulate(r, block, observed_species, label=label)
|
|
138
|
+
# Clear the dust before snapshotting, so every later cache hit restores
|
|
139
|
+
# an already-clean state and the saving compounds with the cache rather
|
|
140
|
+
# than being re-paid on each hit.
|
|
141
|
+
clamp_state_dust(r)
|
|
142
|
+
self._store[key] = save_model_state(r)
|
|
143
|
+
self.n_misses += 1
|
|
144
|
+
return rest
|
|
145
|
+
|
|
146
|
+
def stats(self):
|
|
147
|
+
return {"hits": self.n_hits, "misses": self.n_misses,
|
|
148
|
+
"entries": len(self._store)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# The self-check
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def _setup_and_integrate(r, replicate, param_names, p_vec, block,
|
|
156
|
+
solver_settings, observed_species):
|
|
157
|
+
"""One pre-dose run at *p_vec*, in the order the uncached path uses.
|
|
158
|
+
|
|
159
|
+
Returns the state immediately after setup and the state after integrating,
|
|
160
|
+
so the caller can tell a value that was *set* differently from one that
|
|
161
|
+
*evolved* differently.
|
|
162
|
+
"""
|
|
163
|
+
from Engine.Optimize import set_parameters_from_dict
|
|
164
|
+
|
|
165
|
+
r.reset()
|
|
166
|
+
upd = replicate.get("Update_parameters")
|
|
167
|
+
if upd is not None:
|
|
168
|
+
upd(r, replicate)
|
|
169
|
+
|
|
170
|
+
set_parameters_from_dict(r, dict(zip(param_names, np.asarray(p_vec).tolist())))
|
|
171
|
+
for hook in replicate.get("parameter_hooks", []):
|
|
172
|
+
hook(r, p_vec)
|
|
173
|
+
upd_opt = replicate.get("Update_opt_parameters")
|
|
174
|
+
if upd_opt is not None:
|
|
175
|
+
upd_opt(r, replicate, p_vec)
|
|
176
|
+
|
|
177
|
+
before = save_model_state(r)
|
|
178
|
+
configure_integrator(r, solver_settings)
|
|
179
|
+
_res, meta = safe_simulate(r, block, observed_species, label="preequil-check")
|
|
180
|
+
# Match what apply() stores, so the check is run against the state the cache
|
|
181
|
+
# would actually hand to the dosed block.
|
|
182
|
+
clamp_state_dust(r)
|
|
183
|
+
return before, save_model_state(r), meta
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _achieved_settings(meta, solver_settings):
|
|
187
|
+
"""The tolerances the pre-dose block actually converged at.
|
|
188
|
+
|
|
189
|
+
``safe_simulate`` loosens tolerances and subdivides until an integration
|
|
190
|
+
succeeds, then restores the original settings, so the accuracy a block was
|
|
191
|
+
computed at is not the accuracy that was requested. Where it subdivided, the
|
|
192
|
+
loosest tolerance reached anywhere is what limits the result.
|
|
193
|
+
|
|
194
|
+
Returns ``(settings_or_None, rel_tol)``; the first is None when the very
|
|
195
|
+
first attempt succeeded, meaning nothing needs overriding.
|
|
196
|
+
"""
|
|
197
|
+
requested = float(solver_settings.get("relative_tolerance", 1e-8))
|
|
198
|
+
|
|
199
|
+
def walk(m):
|
|
200
|
+
found = []
|
|
201
|
+
if not isinstance(m, dict):
|
|
202
|
+
return found
|
|
203
|
+
if m.get("rel_tol") is not None:
|
|
204
|
+
found.append({k: m.get(k) for k in
|
|
205
|
+
("abs_tol", "rel_tol", "max_steps", "initial_time_step")})
|
|
206
|
+
for key in ("meta1", "meta2"):
|
|
207
|
+
found.extend(walk(m.get(key)))
|
|
208
|
+
return found
|
|
209
|
+
|
|
210
|
+
candidates = walk(meta)
|
|
211
|
+
if not candidates:
|
|
212
|
+
return None, requested
|
|
213
|
+
|
|
214
|
+
worst = max(candidates, key=lambda d: float(d.get("rel_tol") or 0.0))
|
|
215
|
+
rel = float(worst.get("rel_tol") or requested)
|
|
216
|
+
settings = dict(solver_settings)
|
|
217
|
+
if worst.get("abs_tol") is not None:
|
|
218
|
+
settings["absolute_tolerance"] = float(worst["abs_tol"])
|
|
219
|
+
settings["relative_tolerance"] = rel
|
|
220
|
+
if worst.get("max_steps") is not None:
|
|
221
|
+
settings["maximum_num_steps"] = int(worst["max_steps"])
|
|
222
|
+
return settings, rel
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _alternate_vector(x0, bounds):
|
|
226
|
+
"""A parameter vector far from *x0* but inside the bounds.
|
|
227
|
+
|
|
228
|
+
Multiplying by five moves every parameter far enough that any pre-dose
|
|
229
|
+
influence would show; where that hits a bound the value is moved the other
|
|
230
|
+
way instead, so no entry silently stays put and weakens the test.
|
|
231
|
+
"""
|
|
232
|
+
x0 = np.asarray(x0, dtype=float)
|
|
233
|
+
out = np.array(x0, dtype=float, copy=True)
|
|
234
|
+
for i, v in enumerate(x0):
|
|
235
|
+
lo, hi = (bounds[i] if bounds is not None and i < len(bounds)
|
|
236
|
+
else (None, None))
|
|
237
|
+
lo = -np.inf if lo is None else float(lo)
|
|
238
|
+
hi = np.inf if hi is None else float(hi)
|
|
239
|
+
for cand in (v * 5.0, v / 5.0, (lo + hi) / 2.0 if np.isfinite(lo + hi) else v):
|
|
240
|
+
c = min(max(cand, lo), hi)
|
|
241
|
+
if not np.isclose(c, v, rtol=1e-6, atol=0.0):
|
|
242
|
+
out[i] = c
|
|
243
|
+
break
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def verify_invariance(r, replicate, param_names, x0, bounds, rtol=1e-8,
|
|
248
|
+
atol=1e-20, verbose=True):
|
|
249
|
+
"""Is the pre-dose block independent of the fitted parameters?
|
|
250
|
+
|
|
251
|
+
Integrates it under two different parameter vectors and compares. Entries
|
|
252
|
+
that already differ *before* integration are excluded: those are the fitted
|
|
253
|
+
parameters themselves and whatever ``Update_opt_parameters`` derives from
|
|
254
|
+
them, which are expected to differ and say nothing about the dynamics. Any
|
|
255
|
+
entry that starts equal and ends different is a genuine pre-dose dependence,
|
|
256
|
+
and means the cache must not be used.
|
|
257
|
+
|
|
258
|
+
Returns ``(ok, report)``.
|
|
259
|
+
"""
|
|
260
|
+
solver_settings = replicate["Solver_settings"](replicate)
|
|
261
|
+
block, _rest = split_preequil_block(solver_settings)
|
|
262
|
+
if block is None:
|
|
263
|
+
return False, {"reason": "no cacheable pre-dose block",
|
|
264
|
+
"offenders": []}
|
|
265
|
+
|
|
266
|
+
observed_species = replicate["Observed_species"](r)
|
|
267
|
+
x_alt = _alternate_vector(x0, bounds)
|
|
268
|
+
if np.allclose(np.asarray(x0, dtype=float), x_alt):
|
|
269
|
+
return False, {"reason": "could not build a distinct second parameter "
|
|
270
|
+
"vector inside the bounds", "offenders": []}
|
|
271
|
+
|
|
272
|
+
before_a, after_a, meta_a = _setup_and_integrate(
|
|
273
|
+
r, replicate, param_names, x0, block, solver_settings, observed_species)
|
|
274
|
+
|
|
275
|
+
# If the first run needed the retry ladder, it converged at looser
|
|
276
|
+
# tolerances than were requested. Run the second under those same settled
|
|
277
|
+
# settings: otherwise the two integrations differ in how they were computed
|
|
278
|
+
# as well as in their parameters, and the comparison cannot separate the
|
|
279
|
+
# two. This is what made 'Aducanumab_3mgkg' report a false dependence -- its
|
|
280
|
+
# pre-dose block fell back to 1e-7, and two runs at 1e-7 agreeing only to
|
|
281
|
+
# 5e-6 is the solver working correctly, not a parameter acting pre-dose.
|
|
282
|
+
settled, achieved_rel = _achieved_settings(meta_a, solver_settings)
|
|
283
|
+
before_b, after_b, _meta_b = _setup_and_integrate(
|
|
284
|
+
r, replicate, param_names, x_alt, block,
|
|
285
|
+
settled or solver_settings, observed_species)
|
|
286
|
+
|
|
287
|
+
# Two integrations cannot be asked to agree more closely than either was
|
|
288
|
+
# computed. Over seventy years the error accumulates well past the per-step
|
|
289
|
+
# tolerance, so allow three orders of magnitude above it -- still five
|
|
290
|
+
# orders below the ~0.4 relative divergence a genuinely pre-dose-active
|
|
291
|
+
# parameter produces.
|
|
292
|
+
rtol = max(rtol, 1000.0 * achieved_rel)
|
|
293
|
+
|
|
294
|
+
keys = list(after_a["keys"])
|
|
295
|
+
va, vb = np.asarray(after_a["values"], float), np.asarray(after_b["values"], float)
|
|
296
|
+
sa, sb = np.asarray(before_a["values"], float), np.asarray(before_b["values"], float)
|
|
297
|
+
|
|
298
|
+
if not (list(before_a["keys"]) == list(before_b["keys"]) == keys
|
|
299
|
+
and len(keys) == va.size == vb.size == sa.size):
|
|
300
|
+
return False, {"reason": "state layout changed between runs",
|
|
301
|
+
"offenders": []}
|
|
302
|
+
|
|
303
|
+
set_differently = ~np.isclose(sa, sb, rtol=0.0, atol=0.0)
|
|
304
|
+
diverged = ~np.isclose(va, vb, rtol=rtol, atol=atol) & ~set_differently
|
|
305
|
+
|
|
306
|
+
offenders = []
|
|
307
|
+
for i in np.flatnonzero(diverged):
|
|
308
|
+
den = max(abs(va[i]), abs(vb[i]), 1e-300)
|
|
309
|
+
offenders.append({"name": keys[i], "a": float(va[i]), "b": float(vb[i]),
|
|
310
|
+
"rel": float(abs(va[i] - vb[i]) / den)})
|
|
311
|
+
offenders.sort(key=lambda d: -d["rel"])
|
|
312
|
+
|
|
313
|
+
report = {
|
|
314
|
+
"reason": "ok" if not offenders else "pre-dose state depends on the "
|
|
315
|
+
"fitted parameters",
|
|
316
|
+
"n_compared": int((~set_differently).sum()),
|
|
317
|
+
"n_excluded": int(set_differently.sum()),
|
|
318
|
+
"offenders": offenders,
|
|
319
|
+
"block_end": float(block.get("end", float("nan"))),
|
|
320
|
+
"achieved_rel_tol": float(achieved_rel),
|
|
321
|
+
"requested_rel_tol": float(solver_settings.get("relative_tolerance",
|
|
322
|
+
achieved_rel)),
|
|
323
|
+
"compare_rtol": float(rtol),
|
|
324
|
+
"retried": settled is not None,
|
|
325
|
+
}
|
|
326
|
+
if verbose:
|
|
327
|
+
_print_report(replicate, report)
|
|
328
|
+
return (not offenders), report
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _print_report(replicate, report):
|
|
332
|
+
label = replicate.get("Label") or "?"
|
|
333
|
+
if report.get("retried"):
|
|
334
|
+
# Worth saying out loud regardless of the verdict: this arm's pre-dose
|
|
335
|
+
# state is computed at lower accuracy than the spec asks for, and that
|
|
336
|
+
# applies to every run of it, not just to this check.
|
|
337
|
+
print(f"[preequil] note: '{label}' needed the solver retry ladder for "
|
|
338
|
+
f"its pre-dose block and converged at rel_tol="
|
|
339
|
+
f"{report['achieved_rel_tol']:.0e} rather than the requested "
|
|
340
|
+
f"{report['requested_rel_tol']:.0e}; the invariance check was "
|
|
341
|
+
f"compared at {report['compare_rtol']:.0e} to match.")
|
|
342
|
+
if not report["offenders"]:
|
|
343
|
+
print(f"[preequil] invariance check passed on '{label}': "
|
|
344
|
+
f"{report['n_compared']} state value(s) agree after "
|
|
345
|
+
f"{report['block_end'] / 24 / 365:.1f} y of pre-dose integration "
|
|
346
|
+
f"under two different parameter vectors "
|
|
347
|
+
f"({report['n_excluded']} parameter value(s) excluded).")
|
|
348
|
+
return
|
|
349
|
+
print()
|
|
350
|
+
print(f"*** preequil cache DISABLED: the pre-dose segment of '{label}' "
|
|
351
|
+
f"depends on the fitted parameters.")
|
|
352
|
+
print(f" {len(report['offenders'])} state value(s) diverged before the "
|
|
353
|
+
f"first dose; the cache would have replayed a stale state for all of "
|
|
354
|
+
f"them. Largest differences:")
|
|
355
|
+
for d in report["offenders"][:8]:
|
|
356
|
+
print(f" {d['name']:<48} {d['a']:>15.8g} {d['b']:>15.8g} "
|
|
357
|
+
f"rel={d['rel']:.3e}")
|
|
358
|
+
if len(report["offenders"]) > 8:
|
|
359
|
+
print(f" ... and {len(report['offenders']) - 8} more")
|
|
360
|
+
print(f" Runs continue uncached and correct, just slower.")
|
|
361
|
+
print()
|