RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
runs.py
Go to the documentation of this file.
1"""
2runs.py — the run manifest for the paper-revision verification study.
3
4The ~25 runs of `docs/Formulation/verification_campaign/paper_revision_verification_plan.md` as DATA,
5not as two dozen hand-copied command lines. One entry per table cell; the
6runner (`campaign.py`) turns an entry into a `RadClusterSimulation` and a
7timestamped output directory.
8
9WHY A MANIFEST. Three of the plan's rules are only enforceable if the runs are
10enumerable in one place:
11
12 * S3.2 -- every Table 1-3 column must share `t_span` and `n_points`, or
13 "15.72 dpa" is a different point in different columns and the comparison
14 drifts. Here that is one constant (`GRID`) that every such entry inherits,
15 rather than a number retyped 15 times.
16 * S1.3 -- hold the bin ratio `r` at production and vary only `i_discrete`.
17 The rungs below carry the plan's `I_bin`/`V_bin` for exactly that reason;
18 `bin_layout()` then reads the REALISED layout back and refuses a silent
19 fallback.
20 * S3.4.1 -- no run that missed the comparison dose may be reported as a
21 metric. `dose_read` travels with the entry, so the reporting step can
22 check it mechanically instead of by discipline.
23
24Entries are ordered by the plan's execution order (S5): the Table 4 discrete
25arm is the cost probe and comes first among the runs that must actually be
26computed.
27"""
28from __future__ import annotations
29
30# ── Shared reference configuration (S1.4, from the reference provenance) ────
31# The anchor run these tables are read against. Deviating from any of this in
32# an entry is a deliberate act and shows up as an explicit key.
33REFERENCE = "20260906_063055_full_system_bin_moment_CD_fission_I80000V20000_im5vm5"
34
35# S3.2: ONE output grid for Tables 1-3. 15.72 dpa is point 35 of this grid.
36# t_span end is dose/G with G = 1e-7 dpa/s, i.e. 40 dpa -> 4e8 s.
37GRID = {"t_span_end_s": 4.0e8, "n_points": 37, "dose": 40.0}
38DOSE_READ_MAIN = 15.72 # the dose Tables 1-3 are scored at
39DOSE_READ_T4 = (0.2, 1.0, 2.0) # S2.4: three doses, not one
40
41BASE = {
42 "I": 80000, "V": 20000,
43 "equations": "bin_moment", "cascade": "fission",
44 "shape_function": "linear",
45 "solver_mode": "full_system",
46 "i_mobile": 5, "v_mobile": 5,
47 "rtol": 1e-5, "atol": 1e-20,
48 "preconditioner": "woodbury",
49 "he_kinetics": "quasi_steady_state",
50 "lnl": 1,
51 "omp_threads": 12,
52 "dose": GRID["dose"],
53 "n_points": GRID["n_points"],
54 "dose_read": DOSE_READ_MAIN,
55}
56
57# TIMEOUT for the non-linear closures (P=1 constant, P=3 lognormal). The plan
58# says of these "may be unstable / may time out -- that is a result, not a
59# failure" (S6), but the default 86400 s means collecting that result costs the
60# claiming machine a full day, during which it takes nothing else off the board.
61#
62# Measured 2026-09-06: T4_C4_P3_4k (lognormal, 3 moments/bin) ran 66 minutes and
63# reached 7.1e-4 of 2 dpa -- 0.04% -- while the SAME rung under the linear
64# closure finished in 54 s. Thirty minutes is already 33x the linear cost, so a
65# run still under a percent at that point has demonstrated what the table needs
66# to record. The cap turns a 24 h occupancy into a 30 min measurement of the
67# same fact; the record carries dose_reached and starved either way.
68TIMEOUT_NONLINEAR_CLOSURE = 1800
69
70# Production rung (= the reference run). Its grid recurs in several tables.
71B4_GRID = {"i_discrete": 100, "I_bin": 18, "v_discrete": 5, "V_bin": 20}
72
73
74# P = 3 (lognormal, 3 moments per bin) DOES NOT ADVANCE on this model. Measured
75# 2026-09-06 on both domains: T4_C4_P3_4k reached 0.0022 of 2 dpa, T2_P3 reached
76# 0.00054 of 40 dpa with a single output step running 2.9 h. Plan S6 anticipates
77# exactly this ("P = 3 may time out -- that is a result"), so the two rows stay
78# in the manifest as "did not reach" entries with the dose they achieved; they
79# are simply never CLAIMED again. Re-enable by clearing this field once the
80# lognormal closure's stiffness is addressed.
81# The TD pair was the decisive test for a path difference that is now FIXED
82# (cfce995). check_bm_vs_discrete.py re-asks it on every run in 20 s, so the
83# pair would spend ~2 days confirming what a 20 s test already shows. Domain
84# and dose coverage is bought instead by raising the test's own grid.
85TD_SUPERSEDED = ("superseded by cfce995 + check_bm_vs_discrete.py; the pair "
86 "cost ~2 days to re-confirm a 20 s result")
87
88P3_EXCLUDED = ("P=3 lognormal does not advance; measured 2026-09-06 on both "
89 "domains. Recorded as 'did not reach' per plan S3.4.1.")
90
91
92# ── Campaign M configuration (see manifest(), "Campaign M") ─────────────────
93# The domain the study is anchored on. This started out as "the largest M1
94# rung that finishes inside an hour", with M_BIN_DOMAIN left None until the
95# ladder measured it. It is now set directly: the reference arm is run to
96# completion WITHOUT a wall-clock cap, so the question the ladder existed to
97# answer -- which domain is affordable -- no longer gates the study. The
98# ladder rungs remain as the cost curve.
99M_BIN_DOMAIN = 10000
100
101# Bin counts for M2. Chosen so the realised ratio r = (I/50)^(1/I_bin)
102# brackets the 1.5-2.0 range CLAUDE.md S7 recommends from both sides: at
103# I = 8000 these give r = 3.56, 2.30, 1.83, 1.51, 1.37, 1.24, 1.17. A ladder
104# that only refines inside the recommended band cannot show what the
105# recommendation is worth.
106M2_BINS = (4, 6, 8, 12, 16, 24, 32)
107
108# The REFERENCED binning ladder, at the one domain whose exact arm completes.
109# Separate run_ids from M2 on purpose: the board already holds M2_B4 as an
110# I = 10000 result, and a run_id that silently changes meaning is worse than a
111# duplicated ladder. M2 stays as recorded self-convergence at 10000; M2R is
112# the one that can be scored.
113#
114# The bin list runs coarser here. i_discrete = 50 against I = 1000 is a ratio
115# of 20, not 200, so r = 20^(1/I_bin) reaches only 2.11 at I_bin = 4 -- the
116# whole coarse end of the M2 ladder would be missing. I_bin = 2 and 3 restore
117# it (r = 4.47 and 2.71), which matters because at I = 10000 the only rungs
118# that MOVED were the ones with r above the recommended band.
119M2R_DOMAIN = 1000
120M2R_BINS = (2, 3, 4, 6, 8, 12, 16, 24, 32)
121
122# The 2-D sweep. M2R varied I_bin at fixed i_discrete = 50 and found d_111
123# stuck at -5 to -7% however fine the bins got; the 2026-09-09 investigation
124# (docs/Formulation/verification_campaign/d111_investigation.md) showed why -- that error converges in
125# i_discrete, not in I_bin, so the ladder was turning the one knob that does not
126# control it. Table 4 had the opposite flaw: its rungs moved BOTH together, so
127# a deviation could not be attributed to either.
128#
129# Neither design can separate the two. This one sweeps them independently, so
130# every cell has a neighbour differing in exactly one axis.
131M2D_IDISC = (25, 50, 100, 200, 400, 800)
132M2D_BINS = (4, 8, 16, 32)
133
134# The reference domain first attempted: fully discrete, no wall-clock cap.
135# It DIVERGED (see M1REF_DIVERGED in manifest()), so it is not the reference.
136M_REF_DOMAIN = 10000
137
138# The M1 rung actually serving as the exact arm, once the ladder says which
139# domain completes. None means there is no reference yet -- make_M_report.py
140# withholds its deviation columns and make_M_figures.py stamps
141# "self-convergence only" on every panel rather than drawing a line that would
142# be read as the exact answer. Set it to the largest COMPLETED M1 rung.
143#
144# MEASURED 2026-09-09: that is I = V = 1000, and the cliff above it is brutal.
145#
146# M1_D1000 20 / 20 dpa in 5.2 min
147# M1_D2000 0.0384 / 20 dpa in 61.1 min (1 h cap, 0.2%)
148# M1_D10000 0.005 / 20 dpa in 48 min (diverging, ~4x per step)
149#
150# Doubling the domain takes the exact arm from five minutes to not finishing.
151# 4000 / 8000 / 16000 / 32000 were not run: once 2000 fails by that margin they
152# cannot succeed, and each would cost an hour to confirm it.
153M_REF_RUN = "M1_D1000"
154
155
156def _r(run_id, table, label, notes="", **over):
157 """One manifest entry: BASE, overridden, plus identity and provenance."""
158 e = dict(BASE)
159 e.update(over)
160 e.update({"run_id": run_id, "table": table, "label": label, "notes": notes})
161 return e
162
163
164def manifest() -> list[dict]:
165 """The full study, in execution order (plan S5)."""
166 runs: list[dict] = []
167
168 # ── Table 4 — binning verification against the exact solution ───────────
169 # I = V = 4000, MODEL-TO-MODEL. Runs FIRST: the discrete arm is the only
170 # genuinely uncertain cost (plan S5.2).
171 #
172 # WHY 4000 AND NOT THE PLAN'S I=8000/V=20000. The plan's revision note
173 # rejected a reduced domain because "V = 1000 sits below the mean cavity
174 # (1071 vacancies at 15.72 dpa), so d_void reads the ceiling, not the
175 # model". That is a VALIDATION criterion and Table 4 is not validation:
176 # both arms solve the same equations on the same domain, so a truncation
177 # affects them identically and cancels out of the closure error being
178 # measured. It limits what may be CLAIMED (nothing in Table 4 may be
179 # compared to experiment) rather than whether the measurement is sound.
180 # Tables 1-3 and 5, which ARE scored against experiment, stay at the
181 # production domain for exactly that reason.
182 #
183 # Measured, not assumed: the plan's own I=8000/V=20000 arm was launched
184 # 2026-09-06 and reached 2.2e-3 of 2 dpa in 14 minutes, with per-step cost
185 # growing ~3.3x per output step -- an extrapolated 12-30 days. The cost is
186 # in V, not I: N_eq = 8000 SIA + 20000 VAC + 6, plus an appended 8000
187 # <100> block, and the vacancy block is 71% of the main system. The plan's
188 # S6 fallback ("cut I to 4000") removes ~22% of the equations where ~100x
189 # was needed. The existing G5b run (discrete, I=4000, V=4000) reached
190 # 6.06 dpa in 6.8 h SINGLE-THREADED, which brackets this table's doses.
191 T4 = {"I": 4000, "V": 4000, "dose": 2.0, "n_points": 37,
192 "dose_read": DOSE_READ_T4}
193 runs.append(_r("T4_D_4k", "T4", "D (exact)",
194 "Fully discrete: the exact solution of the same equations. "
195 "Cost probe -- run first (plan S5.2).",
196 equations="discrete", **T4))
197 # The ladder holds r at the production value and varies only the resolution
198 # (plan S1.3). I_bin/V_bin below are ln(domain/discrete)/ln(r) rounded, so
199 # r_i stays 1.44-1.47 and r_v 1.51-1.53 across every rung.
200 for rid, lbl, ratio, g in (
201 ("T4_C1_4k", "C1", 10, {"i_discrete": 400, "I_bin": 6, "v_discrete": 400, "V_bin": 6}),
202 ("T4_C2_4k", "C2", 40, {"i_discrete": 100, "I_bin": 10, "v_discrete": 100, "V_bin": 9}),
203 ("T4_C3_4k", "C3", 160, {"i_discrete": 25, "I_bin": 14, "v_discrete": 25, "V_bin": 12}),
204 ("T4_C4_4k", "C4", 800, {"i_discrete": 5, "I_bin": 18, "v_discrete": 5, "V_bin": 16}),
205 ):
206 note = f"resolution 1/{ratio} on both axes"
207 if rid == "T4_C4_4k":
208 note = ("SIA axis is EXACTLY production: i_discrete/I = 5/4000 = "
209 "1/800, and holding r_i returns I_bin = 18, the production "
210 "value. The vacancy axis is 1/800, FINER than production's "
211 "5/20000 = 1/4000 -- matching it would need v_discrete = 1, "
212 "below v_mobile = 5. So this column if anything UNDERSTATES "
213 "the production vacancy closure error; state that rather "
214 "than claiming an exact two-axis analogue.")
215 runs.append(_r(rid, "T4", lbl, note, **T4, **g))
216 # S2.4: closure ORDER scored against the exact answer, not against B4.
217 C4 = {"i_discrete": 5, "I_bin": 18, "v_discrete": 5, "V_bin": 16}
218 runs.append(_r("T4_C4_P1_4k", "T4", "C4, P=1 constant",
219 "Closure order scored against the exact column.",
220 shape_function="constant", timeout_s=TIMEOUT_NONLINEAR_CLOSURE,
221 **T4, **C4))
222 runs.append(_r("T4_C4_P3_4k", "T4", "C4, P=3 lognormal",
223 "Closure order scored against the exact column. RAN and "
224 "did not advance: 0.0022 of 2 dpa in 1866 s, all three "
225 "scoring rungs MISSING. Kept as a 'did not reach' row.",
226 shape_function="lognormal", excluded=P3_EXCLUDED,
227 timeout_s=TIMEOUT_NONLINEAR_CLOSURE,
228 **T4, **C4))
229
230 # ── DIAGNOSTIC PAIR — is the gap the closure, or the two RHS paths? ─────
231 # Table 4 shows N_111 flat at ~3.6e21 across i_discrete 400 -> 5 (an 80x
232 # change) while the exact arm gives 1.26e22. C1 integrates sizes 1-400
233 # individually and the exact distribution holds 89% of its loops in n =
234 # 21-100, entirely inside that discrete region -- so a CLOSURE error cannot
235 # explain it. Either the binned large-size tail (77% of C4's content sits
236 # above n = 500, against 0.1% in the exact solution) starves the small
237 # loops through the coupled dynamics, or the two RHS implementations do not
238 # solve the same equations.
239 #
240 # CLAUDE.md S1: "When I_bin = 0 and i_discrete = I, all equations are
241 # discrete -> recovers full_CD". So BM_FULL is the bin_moment CODE PATH
242 # carrying NO closure at all. Run against DISC_REF on an identical grid:
243 #
244 # BM_FULL == DISC_REF -> the paths agree; the fault is the closure/tail
245 # BM_FULL != DISC_REF -> the paths differ; that is a solver defect and
246 # every bin_moment result in the study inherits it
247 #
248 # 0.2 dpa, not 2: the gap is already wide by ~1e-3 dpa (N_111 3.36e21 vs
249 # 2.30e21) and this costs ~1.5 h instead of ~17 h. Both share t_span and
250 # n_points so no interpolation is needed to compare them.
251 DIAG = {"I": 4000, "V": 4000, "dose": 0.2, "n_points": 37,
252 "dose_read": (0.05, 0.1, 0.2), "table": "TD"}
253 runs.append(_r("TD_BM_FULL", "TD", "bin_moment, i_discrete = I",
254 "The bin_moment code path with NO closure (I_bin = V_bin = 0). "
255 "SUPERSEDED: it was queued to find the path difference, which "
256 "cfce995 then found and fixed, and "
257 "codes/Python_Testing/check_bm_vs_discrete.py now settles the "
258 "same question in 20 s to 7e-8 rather than in ~2 days. Stopped "
259 "at 0.063 of 0.2 dpa with steps at 148 min and lengthening.",
260 excluded=TD_SUPERSEDED,
261 equations="bin_moment", i_discrete=4000, I_bin=0,
262 v_discrete=4000, V_bin=0,
263 **{k: v for k, v in DIAG.items() if k != "table"}))
264 runs.append(_r("TD_DISC_REF", "TD", "discrete, same grid",
265 "Matched-grid discrete reference for TD_BM_FULL. SUPERSEDED "
266 "with its pair: half a comparison proves nothing.",
267 excluded=TD_SUPERSEDED,
268 equations="discrete",
269 **{k: v for k, v in DIAG.items() if k != "table"}))
270
271 # ── Table 4-NC — closure verification WITHOUT loop coarsening ───────────
272 # Salvages the 17.6 h discrete arm of 2026-09-07
273 # (output/20260907_093333_T4_D_4k_...), which ran before cfce995 and so had
274 # loop_coal silently absent. Re-running the closure rungs with LOOP_COAL = 0
275 # puts both arms back on the same equations, and the comparison becomes a
276 # legitimate verification again -- at ~2 min a rung instead of days.
277 #
278 # WHY THIS IS SOUND. Verification asks whether the closure reproduces the
279 # exact solution OF THE SAME EQUATIONS. It does not require those equations
280 # to be physically right; that is validation, and plan S1.2 separates the
281 # two deliberately. Bin partitioning, moment closure and intra-bin shape --
282 # the approximations the reviewer actually asked about -- are all exercised
283 # here.
284 #
285 # WHAT MUST BE STATED. The no-coarsening variant is known-unphysical:
286 # rate_kernels.cpp:1930 says without the channel "the 1/2<111> mean size
287 # pins AT the cutoff" and "the density runs 20-60x over the EUROFER97 data",
288 # which is exactly what the 2026-09-07 arm shows (d_111 frozen at 1.76 nm,
289 # N_111 3.5x the closure's). So S1.3's TRANSFER argument weakens: the
290 # closure error measured here is not demonstrably the production run's
291 # closure error, because production runs WITH coarsening and that changes
292 # the distribution the closure has to represent. Say so in the paper rather
293 # than letting a reader assume otherwise.
294 NC = {"I": 4000, "V": 4000, "dose": 2.0, "n_points": 37,
295 "dose_read": (2.0,), "loop_coal": 0}
296 for rid, lbl, g in (
297 # C0 is finer than C1 -- N_eq 3214 against 830 -- so the size-effect
298 # figures have a rung ABOVE the previous finest and the trend is not
299 # read off an endpoint.
300 ("T4_C0_nc", "C0 (no coarsening)", {"i_discrete": 1600, "I_bin": 2, "v_discrete": 1600, "V_bin": 2}),
301 # C0b is the insurance rung: still finer than C1 (N_eq 1614 vs 830) but
302 # ~4x cheaper than C0, because the bin_moment discrete-discrete
303 # coalescence block is O(i_discrete^2). Whichever of C0/C0b lands is
304 # the "finer than C1" point in the size-effect figures.
305 ("T4_C0b_nc", "C0b (no coarsening)", {"i_discrete": 800, "I_bin": 4, "v_discrete": 800, "V_bin": 4}),
306 # C0c: the smallest step above C1 that still satisfies "more equations
307 # than C1" (1226 vs 830). Cost in this variant grows far faster than
308 # O(i_discrete^2) -- C1 ran in 12 s, C0b was at 1.1% after 7 min --
309 # because with LOOP_COAL = 0 the frozen spectrum is stiffer the more of
310 # it is resolved discretely rather than smoothed into bins.
311 ("T4_C0c_nc", "C0c (no coarsening)", {"i_discrete": 600, "I_bin": 5, "v_discrete": 600, "V_bin": 5}),
312 ("T4_C1_nc", "C1 (no coarsening)", {"i_discrete": 400, "I_bin": 6, "v_discrete": 400, "V_bin": 6}),
313 ("T4_C2_nc", "C2 (no coarsening)", {"i_discrete": 100, "I_bin": 10, "v_discrete": 100, "V_bin": 9}),
314 ("T4_C3_nc", "C3 (no coarsening)", {"i_discrete": 25, "I_bin": 14, "v_discrete": 25, "V_bin": 12}),
315 ("T4_C4_nc", "C4 (no coarsening)", {"i_discrete": 5, "I_bin": 18, "v_discrete": 5, "V_bin": 16}),
316 ):
317 runs.append(_r(rid, "T4NC", lbl,
318 "Scored against the 2026-09-07 discrete arm at 2.0 dpa.",
319 **NC, **g))
320 runs.append(_r("T4_C4_P1_nc", "T4NC", "C4, P=1 (no coarsening)",
321 "Closure ORDER against the same discrete arm.",
322 shape_function="constant", **NC,
323 **{"i_discrete": 5, "I_bin": 18, "v_discrete": 5, "V_bin": 16}))
324
325 # ── Table 4-NC extended to 40 dpa — for the size-effect figures ─────────
326 # C2-C4 rerun to the production horizon so their curves reach the dose where
327 # the EUROFER97 measurements actually sit (14.6-32 dpa for loops). At 2 dpa
328 # the model curves and the experimental band share no x-range at all.
329 #
330 # These do NOT change Table 4: that table is scored at 2.0 dpa against run D,
331 # and D itself cannot be carried to 40 dpa (the discrete arm was abandoned
332 # three times). So beyond 2 dpa these curves have NO exact reference --
333 # they show dose dependence and the approach to the measured band, not
334 # verified accuracy. The figure caption has to say so.
335 NC40 = {"I": 4000, "V": 4000, "dose": 40.0, "n_points": 37,
336 "dose_read": (2.0, 15.72), "loop_coal": 0}
337 for rid, lbl, g in (
338 ("T4_C2_40", "C2 to 40 dpa", {"i_discrete": 100, "I_bin": 10, "v_discrete": 100, "V_bin": 9}),
339 ("T4_C3_40", "C3 to 40 dpa", {"i_discrete": 25, "I_bin": 14, "v_discrete": 25, "V_bin": 12}),
340 ("T4_C4_40", "C4 to 40 dpa", {"i_discrete": 5, "I_bin": 18, "v_discrete": 5, "V_bin": 16}),
341 ):
342 runs.append(_r(rid, "T4NC40", lbl,
343 "Same grid as its 2 dpa twin, carried to the production "
344 "horizon for the size-effect figures.", **NC40, **g))
345
346 # ── Table 1 — closure convergence at the production domain ──────────────
347 # Hold r at production, vary only i_discrete (S1.3). B4 already exists as
348 # the reference run and is not recomputed.
349 for rid, lbl, g in (
350 ("T1_B1", "B1", {"i_discrete": 6400, "I_bin": 7, "v_discrete": 1600, "V_bin": 6}),
351 ("T1_B2", "B2", {"i_discrete": 1600, "I_bin": 11, "v_discrete": 400, "V_bin": 9}),
352 ("T1_B3", "B3", {"i_discrete": 400, "I_bin": 14, "v_discrete": 100, "V_bin": 13}),
353 ):
354 runs.append(_r(rid, "T1", lbl,
355 "B1 is the most-resolved rung; deviations in Table 1 are "
356 "quoted relative to it." if rid == "T1_B1" else "", **g))
357
358 # ── Table 2 — intra-bin closure and tolerance, ONE knob at a time ───────
359 # Deviations relative to B4, since these sit inside the closure rather than
360 # on the discrete/binned axis.
361 runs.append(_r("T2_P1", "T2", "P=1 constant",
362 "May be unstable -- that is a result, not a failure (S6).",
363 shape_function="constant", timeout_s=TIMEOUT_NONLINEAR_CLOSURE,
364 **B4_GRID))
365 runs.append(_r("T2_P3", "T2", "P=3 lognormal",
366 "May time out -- that is a result, not a failure (S6). It "
367 "did: 0.00054 of 40 dpa in 174 min, 2.9 h on a single output "
368 "step, stopped by hand rather than left to burn a core to "
369 "the 24 h cap.",
370 shape_function="lognormal", excluded=P3_EXCLUDED,
371 timeout_s=TIMEOUT_NONLINEAR_CLOSURE,
372 **B4_GRID))
373 runs.append(_r("T2_IBIN10", "T2", "I_bin 10",
374 **{**B4_GRID, "I_bin": 10}))
375 runs.append(_r("T2_IBIN40", "T2", "I_bin 40",
376 **{**B4_GRID, "I_bin": 40}))
377 runs.append(_r("T2_RTOL4", "T2", "rtol 1e-4", rtol=1e-4, **B4_GRID))
378 runs.append(_r("T2_RTOL6", "T2", "rtol 1e-6", rtol=1e-6, **B4_GRID))
379
380 # ── Table 3 — helium ───────────────────────────────────────────────────
381 # Model-to-model; no experimental column. The fusion arms are NOT optional
382 # (S2.3.2): a He block evaluated only at fission is flat across all six
383 # observables and reads as evasion. The flatness is the result, but only
384 # with the fusion columns beside it.
385 # CONTROL COLUMN (added 2026-09-07). The plan writes Table 3's first
386 # column as "fission QSS (= B4)" and S1.3 says B4 "already exists as the
387 # reference run and is not recomputed". That makes the one QSS/dynamic
388 # contrast in the study uncontrolled: the reference was produced by a
389 # different code state on a different output grid, so subtracting it from
390 # T3_FISS_DYN measures the code and the grid as well as the helium closure.
391 #
392 # This row is the controlled twin. It differs from T3_FISS_DYN in exactly
393 # one key -- he_kinetics -- and BASE already supplies cascade="fission" and
394 # he_kinetics="quasi_steady_state", so the difference is the closure and
395 # nothing else. It is also, by construction, the production rung, so it
396 # doubles as the B4 column Tables 1 and 2 quote.
397 runs.append(_r("T3_FISS_QSS", "T3", "fission QSS (control)",
398 "Controlled twin of T3_FISS_DYN: identical in every respect "
399 "except he_kinetics. Replaces the plan's uncomputed "
400 "'= B4' column, which was not a controlled comparator.",
401 **B4_GRID))
402 runs.append(_r("T3_FISS_DYN", "T3", "fission dynamic",
403 he_kinetics="dynamic", **B4_GRID))
404 runs.append(_r("T3_FUS_QSS", "T3", "fusion QSS",
405 cascade="fusion", **B4_GRID))
406 runs.append(_r("T3_FUS_DYN", "T3", "fusion dynamic",
407 cascade="fusion", he_kinetics="dynamic", **B4_GRID))
408 # The last two Table 3 columns need the S3.1 he_model unwelding, which this
409 # pass does NOT implement. They are carried here, blocked, so the manifest
410 # is the whole study rather than the runnable part of it.
411 runs.append(_r("T3_FUS_CASE1", "T3", "fusion Case 1",
412 "BLOCKED on plan S3.1 (unweld he_model from cascade).",
413 cascade="fusion", he_model="case1", blocked_on="S3.1", **B4_GRID))
414 runs.append(_r("T3_FUS_CASE2", "T3", "fusion Case 2",
415 "BLOCKED on plan S3.1 (unweld he_model from cascade).",
416 cascade="fusion", he_model="case2", blocked_on="S3.1", **B4_GRID))
417
418 # ── Table 5 — null knobs (the direct reply to the reviewer) ─────────────
419 # Performance-only knobs get a NULL TEST, not a curve (S3.4.4). If any of
420 # these moves a converged answer, that is a bug report.
421 runs.append(_r("T5_WOODBURY", "T5", "Woodbury",
422 "Null test: preconditioner enters no rate kernel.",
423 preconditioner="woodbury", **B4_GRID))
424 runs.append(_r("T5_JACOBI", "T5", "Jacobi",
425 "Null test: must agree with Woodbury to many sig figs.",
426 preconditioner="jacobi", **B4_GRID))
427 runs.append(_r("T5_THREADS1", "T5", "1 thread",
428 "Null test: thread count must not move the answer.",
429 omp_threads=1, **B4_GRID))
430 runs.append(_r("T5_THREADS12", "T5", "12 threads",
431 "Null test: paired with T5_THREADS1.",
432 omp_threads=12, **B4_GRID))
433
434 # ── Campaign M — system size and binning, model-to-model (2026-09-09) ───
435 # A SEPARATE STUDY from Tables 1-5, not another column of them. It is the
436 # study Table 4 could not be: a fully discrete EXACT arm carried to a real
437 # dose, at several domains, with the binned rungs scored against it.
438 #
439 # WHY NOT MONOMER-ONLY MOBILITY. This campaign was first specified with
440 # i_mobile = v_mobile = 1 -- CLAUDE.md S5's MONO-DEFECT form, where every
441 # coalescence sum collapses to its n' = 1 term -- on the reasoning that a
442 # smaller RHS makes the discrete arm affordable. It does the opposite.
443 # Measured 2026-09-09, I = V = 1000 discrete, 300 s budget, idle machine:
444 #
445 # i_mobile=5 LOOP_COAL=1 14.68 dpa
446 # i_mobile=1 LOOP_COAL=0 0.046 dpa
447 # i_mobile=1 LOOP_COAL=1 0.001 dpa
448 #
449 # ~300x SLOWER, and LOOP_COAL costs a further 46x on top of that. The RHS
450 # does shrink, but dropping the cutoff to 1 also switches off the
451 # fixed-sink and cavity loss channels for n = 2..5 (they apply only to
452 # n <= i_mobile), so those small immobile clusters pile up and their
453 # emission/re-absorption cycle becomes a fast stiff loop the integrator has
454 # to resolve. LOOP_COAL compounds it: its pair sum runs over
455 # i_mobile+1 .. I, so at i_mobile = 1 it takes in exactly those densely
456 # populated small sizes and the O(NC^2) blow-up that cost the Table 4 exact
457 # arm three attempts reappears. Neither rtol (1e-5 -> 1e-4 moved the rate
458 # under 5%) nor the preconditioner bandwidth (1.5x, below) recovers it.
459 #
460 # So the campaign runs at the PRODUCTION mobility instead, and buys its
461 # affordability from the dose horizon (20 dpa) rather than from the physics.
462 # That is the better trade: every rung is then a configuration the
463 # production runs actually use, and the 300x is not spent.
464 #
465 # WHAT IT BUYS. Table 4 measured closure error at ONE domain (I = V = 4000)
466 # and ONE dose (2 dpa), because that was all the exact arm could reach. The
467 # two ladders below separate the two effects that were confounded there:
468 #
469 # M1 vary the DOMAIN (I = V) at fixed exactness -- pure truncation error
470 # M2 vary the BINNING at fixed domain -- pure closure error
471 #
472 # so a deviation in M2 cannot be blamed on the domain and vice versa.
473 #
474 # WHAT IT DOES NOT BUY. The domain is still truncated -- V = 1000 sits
475 # below the mean cavity size at production dose -- so d_cavity reads the
476 # ceiling at the coarse rungs, not the model. That limits what may be
477 # CLAIMED (nothing here is a validation against experiment) rather than
478 # whether the measurement is sound: both arms of M2 see the identical
479 # truncation, so it cancels out of the closure error being measured.
480 MONO = {"equations": "discrete", "i_mobile": 5, "v_mobile": 5,
481 # 20 dpa, not 100. The horizon is what makes a discrete arm
482 # affordable at all: the I = V = 1000 rung reached 14.68 dpa in
483 # 360 s, so 20 dpa is ~8 min there and the ladder has room to climb
484 # before the hour runs out. 100 dpa was 5x further up a curve whose
485 # per-step cost is still growing.
486 "dose": 20.0, "n_points": 45,
487 "dose_read": (2.0, 15.72, 20.0),
488 # THE 1 h BUDGET IS THE MEASUREMENT, not a guard. The ladder is
489 # meant to be climbed until a rung will not finish in an hour, and
490 # the rung that does not is the answer -- so the cap has to be the
491 # stated hour exactly. A timed-out rung still publishes, carrying
492 # dose_reached, and S3.4.1 keeps it out of the metric columns.
493 "timeout_s": 3600,
494 # LOOP_COAL stays at the workbook default (1) and prec_bw at its
495 # auto value: at i_mobile = 5 that auto value is
496 # max(2*5, 2*5) + 1 = 11, which is exactly the width measured as
497 # optimal when i_mobile = 1 forced it down to 3 (100 s -> 65 s to
498 # 0.05 dpa; 51 was slower again at 139 s). The auto rule is
499 # mis-scaled at low mobility and correct here, so this campaign
500 # needs no override -- the finding is recorded in campaign.py's
501 # _solver_method() rather than worked around.
502 }
503
504 # M1 -- the domain ladder. Doubling, so each rung is a clean factor of two
505 # in both the SIA and the vacancy block and N_eq tracks 2 I + 2. Run in
506 # order and stop at the first rung that does not finish; the largest one
507 # that does is what M2 is then built on.
508 M1_LADDER = (1000, 2000, 4000, 8000, 16000, 32000)
509 for dom in M1_LADDER:
510 runs.append(_r(f"M1_D{dom}", "M1", f"discrete I=V={dom}",
511 "Exact solution of the monomer-mobility model at this "
512 "domain. Truncation error only: the closure is absent.",
513 I=dom, V=dom, **MONO))
514
515 # THE REFERENCE ARM. I = V = 10000, fully discrete, ONE core, no wall-clock
516 # cap -- the exact solution every M2 rung is scored against.
517 #
518 # 12 THREADS, measured -- and NOT the 16 the machine has. CLAUDE.md S9:
519 # the parallel regions are barrier-synchronised, so on Apple silicon a
520 # thread landing on an efficiency core stalls every join; the useful
521 # maximum is the performance-core count, which is 12 here.
522 #
523 # The discrete path does not scale like the binned one. rhs_case2 carries
524 # two OpenMP regions (both gated on x_hi_i_win + x_hi_v_win > 500, which
525 # does fire at this domain) against rhs_bin_moment's twelve, and the rest
526 # -- including the serial loop_coal pair sum -- does not parallelise at
527 # all. Measured 2026-09-09 at I = V = 10000 discrete, to 0.002 dpa:
528 #
529 # 1 thread 75 s
530 # 12 threads 49 s 1.53x
531 #
532 # Amdahl, not a defect. It was first assumed to be ~1.0x from cost probes
533 # showing CPU time ~ elapsed; those probes were i_mobile = 1, where the
534 # stiff serial fraction dominates, and the assumption did not survive
535 # measurement at the configuration actually being run.
536 #
537 # CAVEAT ON THE 1.53x: it is measured on an EARLY-dose segment, and
538 # loop_coal's serial pair sum grows with the number of POPULATED sizes.
539 # The serial fraction therefore rises with dose, so 1.53x is an upper
540 # bound on what holds out at 20 dpa.
541 #
542 # Both thread counts reached 0.002 dpa at delta_FP = 0.045 -- i.e. the
543 # T5 thread null test holds here, which is worth noting since T5 itself
544 # has never been run.
545 #
546 # timeout_s is 30 days rather than absent: solver_config() falls back to
547 # 86400 when the key is missing, and a 24 h cap is exactly the kind of
548 # silent limit that would truncate the reference and leave every M2 rung
549 # scored against a partial trajectory. The number is a backstop, not a
550 # budget.
551 # MEASURED AND ABANDONED, 2026-09-09. This rung DIVERGES rather than runs
552 # slowly. Its own event log, per output step:
553 #
554 # step 24 3.4e-4 dpa 1.1 s
555 # step 25 8.5e-4 dpa 45 s
556 # step 26 2.1e-3 dpa 186 s
557 # step 28 -- 2643 s and climbing
558 #
559 # ~4x per step with 17 of 45 steps still to go, at 104% CPU -- one core,
560 # i.e. entirely inside the SERIAL region. That region is loop_coal's
561 # O(NC^2) pair sum: the gate admits sessile sizes above 2 x C_floor, and at
562 # I = 10000 that population is ~10x the I = 1000 one, so the pair count is
563 # ~100x. Identical in kind to the three abandoned attempts at the Table 4
564 # exact arm.
565 #
566 # The 12-thread setting is not implicated and was not wrong -- threading
567 # only ever touched the two parallel loops in rhs_case2, and by step 26 the
568 # run spends almost nothing there. It is the documented caveat on the
569 # 1.53x (serial fraction rises with dose) turning out to be the whole story.
570 #
571 # M1_D1000 completes the same 20 dpa with the same physics in 5.2 min, so
572 # the exact arm is affordable -- the blow-up is in DOMAIN, not in the model.
573 # Re-enable only if the loop_coal participation gate is reworked; raising
574 # the domain alone will not help.
575 M1REF_DIVERGED = ("loop_coal O(NC^2) blow-up: ~4x per output step, 2643 s "
576 "on step 28 of 45 at 0.005 of 20 dpa, single-core in the "
577 "serial pair sum. Measured 2026-09-09.")
578 runs.append(_r(f"M1_D{M_REF_DOMAIN}", "M1REF",
579 f"REFERENCE discrete I=V={M_REF_DOMAIN}",
580 "Exact arm for campaign M. Run to completion, one core, "
581 "no time limit. Every M2 rung is a closure error against "
582 "THIS trajectory. DID NOT COMPLETE -- see M1REF_DIVERGED.",
583 excluded=M1REF_DIVERGED,
584 I=M_REF_DOMAIN, V=M_REF_DOMAIN,
585 omp_threads=12, **{**MONO, "timeout_s": 30 * 86400}))
586
587 # M2 -- the binning ladder, at the reference domain. i_discrete = v_discrete = 50 throughout (fixed by the study
588 # design), so the ONLY thing varying is how many bins cover 50 -> I and
589 # hence the bin ratio r = (I/50)^(1/I_bin). Its exact counterpart is the
590 # M1 rung at the same domain: same equations, same grid, no closure.
591 #
592 # M_BIN_DOMAIN stays None until M1 has measured it. An assumed value would
593 # put the whole ladder on a domain whose exact arm does not exist, and the
594 # comparison would silently have no reference -- the failure the T4 exact
595 # arm already cost this project three attempts.
596 if M_BIN_DOMAIN is not None:
597 dom = M_BIN_DOMAIN
598 for nb in M2_BINS:
599 r_i = (dom / 50.0) ** (1.0 / nb)
600 runs.append(_r(f"M2_B{nb}", "M2", f"I_bin=V_bin={nb}",
601 f"i_discrete = v_discrete = 50, r = {r_i:.3f}. "
602 f"Scored against M1_D{dom}, the exact arm on the "
603 f"same domain and grid. Runs alongside it on the "
604 f"remaining cores; the reference is pinned to one.",
605 I=dom, V=dom,
606 equations="bin_moment", i_mobile=1, v_mobile=1,
607 i_discrete=50, v_discrete=50,
608 I_bin=nb, V_bin=nb,
609 dose=MONO["dose"], n_points=MONO["n_points"],
610 dose_read=MONO["dose_read"],
611 # The reference arm has no cap; these do, because a
612 # binned rung that needs more than an hour at
613 # N_eq ~ 200 is not a convergence point, it is a
614 # symptom (the P=3 lognormal rows are the precedent).
615 timeout_s=3600,
616 # loop_coal and prec_bw are deliberately NOT set
617 # here: both arms take the workbook/auto default, so
618 # they solve the same equations by construction
619 # rather than by two keys being kept in step. If
620 # either is ever overridden on M1 it MUST be
621 # mirrored here, or the closure error stops being a
622 # closure error.
623 ))
624
625 # M2R -- the binning ladder AT THE REFERENCE DOMAIN. Identical in design
626 # to M2 (i_discrete = v_discrete = 50 fixed, only the bin count moving) but
627 # at I = V = 1000, where M1_D1000 supplies a completed exact arm. M2 at
628 # 10000 can only ever be self-convergence; this one measures closure error.
629 #
630 # WHAT THE SMALLER DOMAIN COSTS. V = 1000 caps a cavity at
631 # 2 (3*1000*Omega/4pi)^(1/3) ~ 2.8 nm, and M1_D1000 reports d_cav = 1.74 nm
632 # at 20 dpa -- 62% of the ceiling. That is real truncation and it means
633 # NOTHING here may be compared to experiment. It does not affect the
634 # measurement: both arms sit on the same truncated domain, so it cancels
635 # out of the difference being reported. Verification, not validation.
636 for nb in M2R_BINS:
637 r_i = (M2R_DOMAIN / 50.0) ** (1.0 / nb)
638 runs.append(_r(f"M2R_B{nb}", "M2R", f"I_bin=V_bin={nb} @ ref domain",
639 f"i_discrete = v_discrete = 50, r = {r_i:.3f}. Scored "
640 f"against {M_REF_RUN}, the exact arm on the same domain "
641 f"and grid.",
642 I=M2R_DOMAIN, V=M2R_DOMAIN,
643 equations="bin_moment",
644 i_discrete=50, v_discrete=50, I_bin=nb, V_bin=nb,
645 dose=MONO["dose"], n_points=MONO["n_points"],
646 dose_read=MONO["dose_read"], timeout_s=3600))
647
648 # M2D -- the two-dimensional sweep. i_discrete x I_bin at the reference
649 # domain, every cell scored against M1_D1000.
650 #
651 # COST IS NOT MONOTONIC IN EITHER AXIS. Measured at 0.05 dpa: pure discrete
652 # (i_d = 1000) ran in 13.8 s and i_d = 50 / I_bin = 16 in 4.2 s, but
653 # i_d = 400 / I_bin = 16 took 163.7 s and i_d = 800 / I_bin = 16 took
654 # 196.3 s -- an order of magnitude SLOWER than either pure form. The
655 # bin_moment path carries an O(i_discrete^2) discrete-discrete coalescence
656 # block on top of the binned machinery, so the hybrid pays for both. The
657 # expensive cells are therefore capped and may legitimately not finish;
658 # S3.4.1 records those as "did not reach" rather than as a metric.
659 for i_d in M2D_IDISC:
660 for nb in M2D_BINS:
661 runs.append(_r(f"M2D_I{i_d}_B{nb}", "M2D",
662 f"i_d={i_d} I_bin={nb}",
663 f"2-D sweep cell. Scored against {M_REF_RUN}.",
664 I=M2R_DOMAIN, V=M2R_DOMAIN, equations="bin_moment",
665 i_discrete=i_d, v_discrete=i_d,
666 I_bin=nb, V_bin=nb,
667 dose=MONO["dose"], n_points=MONO["n_points"],
668 dose_read=MONO["dose_read"], timeout_s=3600))
669
670 _check_unique(runs)
671 return runs
672
673
675 seen = {}
676 for e in runs:
677 if e["run_id"] in seen:
678 raise ValueError(f"duplicate run_id {e['run_id']!r} in manifest")
679 seen[e["run_id"]] = e
680
681
682# TABLE 5 IS NOT CLAIMED BY DEFAULT. Its columns measure WALL-CLOCK as the
683# dependent variable -- Woodbury vs Jacobi, 1 thread vs 12 -- so a T5 run that
684# shares a machine with other solves measures the contention, not the knob.
685# Plan S3.4.4: "performance-only knobs get a null test, not a curve"; a null
686# test taken under load is worthless. It happened: the unrestricted loop
687# finished the exact arm, moved on to the next free run, and started
688# T5_WOODBURY alongside three other solvers. Claim these only with an explicit
689# `--only T5` on an otherwise idle machine.
690# M1 joins T5 for the same reason: its result IS a wall-clock budget. "The
691# largest domain that finishes inside an hour" measured while three other
692# solvers hold the machine is a measurement of the contention -- which is not
693# hypothetical here, three abandoned T4NC rungs were found holding three cores
694# for two hours on 2026-09-09 and had to be killed before any of the timings
695# above meant anything. Claim M1 only with an explicit --only M1 on an idle
696# machine. M2 is deliberately NOT listed: its result is the six observables,
697# and those do not move with load.
698TIMING_SENSITIVE_TABLES = {"T5", "M1"}
699
700
701def runnable(runs=None, tables=None) -> list[dict]:
702 """Entries a worker may CLAIM: not blocked on unimplemented code, and not
703 excluded by a measured failure. Excluded entries stay in manifest() so the
704 board still displays their recorded outcome."""
705 want = set(tables or ())
706 return [e for e in (runs or manifest())
707 if not e.get("blocked_on") and not e.get("excluded")
708 and (e["table"] not in TIMING_SENSITIVE_TABLES or e["table"] in want)]
709
710
711if __name__ == "__main__":
712 import json, sys
714 if "--json" in sys.argv:
715 print(json.dumps(m, indent=2))
716 else:
717 blocked = [e for e in m if e.get("blocked_on")]
718 print(f"{len(m)} runs ({len(blocked)} blocked, {len(m) - len(blocked)} runnable)\n")
719 hdr = f"{'run_id':16s} {'tbl':4s} {'label':22s} {'eqs':9s} " \
720 f"{'I':>6s} {'V':>6s} {'i_d':>5s} {'I_b':>4s} {'v_d':>5s} {'V_b':>4s} {'dose':>5s}"
721 print(hdr); print("-" * len(hdr))
722 for e in m:
723 print(f"{e['run_id']:16s} {e['table']:4s} {e['label'][:22]:22s} "
724 f"{e['equations']:9s} {e['I']:6d} {e['V']:6d} "
725 f"{e.get('i_discrete', 0):5d} {e.get('I_bin', 0):4d} "
726 f"{e.get('v_discrete', 0):5d} {e.get('V_bin', 0):4d} "
727 f"{e['dose']:5.1f}" + (" BLOCKED" if e.get("blocked_on") else ""))
list[dict] manifest()
Definition runs.py:164
_check_unique(runs)
Definition runs.py:674
list[dict] runnable(runs=None, tables=None)
Definition runs.py:701
_r(run_id, table, label, notes="", **over)
Definition runs.py:156