RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
run_ensemble.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""run_ensemble - the campaign worker. One instance per machine.
3
4 machine k of M -> runs every design row with row_id % M == k
5
6Deterministic, restartable, needs no shared filesystem and no coordination: a
7dead machine leaves an identifiable GAP rather than corrupting the design.
8Results are appended one JSON object per line, so a crash costs one row rather
9than the file, and merging is plain concatenation.
10
11 python run_ensemble.py --design design/T2_design_v1.csv \
12 --machine 0 --of 4 --workers 10
13
14WHY THE ADMISSIBILITY BLOCK EXISTS
15----------------------------------
16delta_FP is BLIND to grid truncation. On 2026-08-01 it held at 1e-8 while
1799.96 % of the <100> content was stacked against the top of the grid, because
18the `if (n < I)` growth guard in rate_kernels.cpp halts growth WITHOUT losing
19atoms. Conservation is a health check, not a grid-adequacy check.
20
21Across ~1250 rows with theta varying over its full prior box, some cells WILL
22saturate the grid. Without pile/occupancy recorded per row those cells enter
23the Sobol estimator as ordinary data and their sensitivity is an artefact of
24the grid, not of the physics. So every row carries:
25
26 occ_111, occ_100 mean size / I (project rule: > ~0.1 suspect)
27 pile_111, pile_100 content fraction in the top 2 % of the grid
28 pile_v, occ_v the SAME test on the VACANCY axis (added 2026-08-02)
29 d_over_ceiling_100 d_100 / d(n=I)
30 dose_reached, starved, delta_FP, delta_He, solver_rc, wall_s
31
32The vacancy axis went unchecked until 2026-08-02 and is not a minor addition:
33at V=120 the check_machine probe reported mean_n_v = 18.7 against a converged
34153.3 and N_voids 10.6x low. Note the asymmetry -- delta_FP is blind to
35SIA-axis truncation but IS sensitive to the vacancy axis, because the swelling
36identity S = S_I + Delta J^d has the vacancy inventory on the left. So on the
37vacancy side delta_FP is a real guard, and the admissibility bar was tightened
38from 1e-3 to 1e-6 to make it bite.
39
40and `admissible`, which merge_and_sobol uses to decide what may be estimated
41from. A row that is inadmissible is NOT a failed row -- it ran fine, it just
42cannot answer the question asked of it.
43"""
44from __future__ import annotations
45
46import argparse
47import hashlib
48import io
49import json
50import math
51import os
52import platform
53import re
54import signal
55import subprocess
56import sys
57import time
58import traceback
59from pathlib import Path
60
61# Force, do NOT setdefault: an inherited OMP_NUM_THREADS (the
62# workstation exports 24) would give every worker its own thread
63# pool -- 10 workers x 24 threads on 24 cores. Many serial workers
64# beat few threaded ones for an ensemble, so 1 stays the DEFAULT.
65#
66# The exception is a machine whose problem is the per-row DEADLINE rather
67# than throughput: there, cores spent inside a row buy dose that cores
68# spent on a neighbouring row do not, because a row that misses full dose
69# still lands in the pool -- it just never reaches the high rungs. Such a
70# machine sets `omp_threads` in machines.json; main() puts the value in
71# RADCLUSTER_OMP_THREADS and this line hands it to solver.exe.
72#
73# Read from a HANDOFF VARIABLE, not set directly, because Windows spawns
74# (not forks) its pool: every worker re-imports this module and would run
75# this line again, resetting to 1 whatever the parent had chosen. The
76# handoff survives because it is inherited as part of the child's env.
77#
78# Safe for pooling: the active_window kernels are `parallel for` loops in
79# which each iteration writes only its own dci[n]/dcv[m] (rate_kernels.cpp
80# ~281, ~433, ~801, ~920). There is no reduction, so no summation order
81# depends on the thread count and the trajectory is unchanged by it. That
82# is what makes this a resource knob and not a physics one -- which is also
83# why it stays OUT of run_cfg_sha, beside timeout_s.
84os.environ["OMP_NUM_THREADS"] = os.environ.get("RADCLUSTER_OMP_THREADS", "1")
85
86import numpy as np
87
88HERE = Path(__file__).resolve().parent
89REPO = HERE.parent.parent
90sys.path.insert(0, str(REPO))
91
92# Canonical A_111 <- E_b_i2 inversion, shared with ReactionRates so the two
93# cannot drift apart (see apply_theta).
94from radcluster_code.py_utils.binding_energies import A_111_from_E_b_i2
95
96STOP_FILE = HERE / "CAMPAIGN_STOP" # graceful-halt sentinel (campaign_ops)
97B111_M = 2.482e-10 # <111> Burgers magnitude [m], for loop_net_w_c units
98PILE_TOP_FRAC = 0.02 # "top 2 % of the grid"
99PILE_TOL = 0.05 # above this the size readout is a ceiling artefact
100OCC_TOL = 0.10 # RECORDED ONLY since 2026-08-03 -- no longer a gate.
101 # Probe: row 24 at occ_100=0.171 / pile_100=2.3e-10 is
102 # bit-identical at I=3200 and I=12800. Occupancy is not
103 # a truncation test (plan S11(c)-2).
104TOPBIN_TOL = 0.02 # bin_moment: max content fraction in the TOP BIN.
105 # `pile` is measured on the reconstructed per-size
106 # distribution and inherits the closure's smoothing;
107 # this reads the raw moments instead.
108
109# CLAUDE.md S8's own standard: delta_FP below ~1e-6 is good, above 1e-3 signals
110# a coding error. Admissibility used 1e-3, i.e. it only rejected the "coding
111# error" band. That is too loose for the VACANCY axis, where delta_FP is the
112# primary guard: the 2026-08-02 vacancy study passed V=240 at delta_FP=8.3e-4
113# (under the old bar) while mean_n_v was still 11 % from converged, and reached
114# 1.4e-7 at V=480 where it had converged. 1e-6 separates those two cleanly and
115# is comfortably met by good configs (production rows run 1.5e-8 to 2.9e-12).
116#
117# 2026-08-03: 1e-6 -> 1e-2 (plan S11(h)). Three reasons the tightening above
118# was wrong: (i) 1e-6 IS the solver's own rtol, so it rejected for solver noise
119# -- only 12 % of grid-converged v2 rows cleared it; (ii) it was tightened to
120# catch VACANCY truncation, which V=10000 now designs out, and at 1e-2 it still
121# rejects the known-bad case by 53x (row 229 at V=600 sits at 0.530, dropping to
122# 7.1e-6 at V=2400); (iii) bin_moment carries a closure error discrete does not,
123# so a bar set from discrete experience rejects the method rather than the run.
124#
125# THIS VALUE IS ADVISORY. It sets the worker's own `admissible` field, which
126# feeds only the live monitor. The gate that decides what enters a Sobol index
127# is applied post-hoc in merge_and_sobol.usable(), from the per-row quantities
128# recorded here -- so a threshold can be revised without re-running anything.
129# That is not a nicety: re-scoring the stopped v2 campaign under the corrected
130# rules moved it from 12 admissible rows to 52 at zero CPU cost.
131DFP_TOL = 1e-2
132
133# ---------------------------------------------------------------------------
134# TRUNCATION IS NO LONGER A GATE -- author decision, 2026-08-05 (plan S11(q)).
135#
136# The campaign's question is the RELATIVE RANKING of parameters, not the
137# absolute value of any observable, and a ranking survives a truncated tail.
138# Every truncation quantity below is still MEASURED AND RECORDED on every row;
139# what changed is that none of them may reject a row.
140#
141# delta_FP is included in this withdrawal, and that is the non-obvious part.
142# Under `discrete` it is an honest conservation check. Under `bin_moment` it is
143# not independent of truncation: the top bin's closure cannot absorb overflow,
144# so delta_FP tracks <100> truncation almost exactly -- 1.71e-04 on the
145# converged I=50000 row against 0.907 on the truncated I=10000 one, the same row
146# and the same theta. Leaving it as a gate at 1e-2 would go on rejecting
147# precisely the truncated rows this decision keeps, under a different name.
148#
149# What still rejects a row: the solver failed outright. Everything else is a
150# recorded covariate.
151TRUNCATION_GATES = False
152
153# ---------------------------------------------------------------------------
154# A TIMED-OUT ROW IS KEPT -- author decision, 2026-08-05 (plan S12(s)).
155#
156# `--timeout-s` does not kill the solver: cpp_bridge sends a graceful interrupt
157# and the solver flushes the trajectory it has, so a row that runs out of time
158# still returns everything up to the dose it reached. Discarding that was
159# throwing away finished work -- 57 of the 516 pooled v2 rows were dropped for
160# this alone.
161#
162# It is NOT, however, sound to read such a row at whatever dose it happened to
163# reach: dose moves the observables far harder than any numerical choice we have
164# measured, and how far a row gets is a function of theta. So the observables
165# are recorded on a fixed DOSE LADDER (below) and screened at a common dose.
166# `starved` stays recorded, and stops rejecting.
167STARVED_GATE = False
168
169# Dose ladder for the rev-6 1 dpa campaign. Must be ascending and should end at
170# the campaign dose. A row contributes to every rung it reached.
171# Low rungs added 2026-08-06. Dose accrues very late in wall-clock terms: a
172# 300 s cut of a row that reaches 1.0 dpa in 1700 s sat at 0.003 dpa and filled
173# ZERO of the old 0.1-1.0 rungs, so the partial trajectory the deadline exists
174# to preserve contributed nothing. Rungs are free -- the trajectory is already
175# computed -- so cover the decades a cut row can actually land in.
176#
177# EXTENDED FOR T4 (2026-08-11). The rungs above 1.0 are the EXPERIMENTAL doses
178# in targets_T4.json -- ion 4/11/30 dpa, neutron 15/16.3/32 dpa, plus the 47 dpa
179# round-robin point -- so every calibration condition's target dose is itself a
180# rung. That matters because a T4 row cut by the wall-clock budget can still be
181# compared against its measurement at the rung it did reach, instead of being
182# discarded the way 304 rows were in T3. Rungs are free: the trajectory is
183# already computed. Rows from the 1 dpa campaign are unaffected -- they simply
184# do not fill the new rungs.
185DOSE_CHECKPOINTS = (0.005, 0.01, 0.02, 0.05,
186 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0,
187 2.0, 4.0, 8.0, 11.0, 15.0, 15.72, 16.3, 20.0, 30.0, 32.0,
188 40.0, 47.0, 60.0, 80.0, 100.0)
189# 15.72 is the verification study's scoring dose (paper_revision_verification
190# _plan.md S2.1). It is not a round number by choice: on the reference run's
191# 37-point log grid over t_span (1e-6, 4e8) s it is output index 35, the last
192# point at or below 15.72 dpa, and every Table 1-3 column is required to share
193# that grid (S3.2) so the index means the same dose in each. Reading the
194# nearest existing checkpoint instead would land on index 34 (6.18 dpa) for
195# 15.0 -- a different point, which is the whole failure mode Figure 8 suffered.
196# Purely additive: it appends a key to `at_dose` and changes no existing one.
197#
198# 40, 60, 80 and 100 appended 2026-09-09 for the monomer-mobility campaign,
199# which runs to 100 dpa: without them the ladder topped out at 47 and a 100 dpa
200# run could only be scored at less than half its own horizon. 40.0 also closes
201# a gap the size-effect figures had been reading around -- the 40 dpa reruns
202# had no ladder rung at their own endpoint. Additive in the same sense as
203# 15.72: it appends keys and moves none.
204
205
206# ------------------------------------------------------------------- utilities
207def sha256_file(p: Path) -> str:
208 try:
209 return hashlib.sha256(Path(p).read_bytes()).hexdigest()
210 except Exception:
211 return "unavailable"
212
213
214def git_sha() -> str:
215 try:
216 return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=str(REPO),
217 stderr=subprocess.DEVNULL, text=True).strip()
218 except Exception:
219 return "unknown"
220
221
222def read_design(path: Path) -> tuple[list[dict], dict]:
223 meta_p = path.with_suffix(".meta.json")
224 meta = json.loads(meta_p.read_text(encoding="utf-8")) if meta_p.exists() else {}
225 got = sha256_file(path)
226 if meta.get("design_sha256") and meta["design_sha256"] != got:
227 raise SystemExit(
228 f"DESIGN HASH MISMATCH\n expected {meta['design_sha256']}\n"
229 f" got {got}\nThe design file has been edited since it was "
230 f"generated. Saltelli pairing cannot be trusted; regenerate or "
231 f"restore it. Refusing to run.")
232 lines = path.read_text(encoding="utf-8").strip().splitlines()
233 cols = lines[0].split(",")
234 rows = []
235 for ln in lines[1:]:
236 vals = ln.split(",")
237 d = {}
238 for c, v in zip(cols, vals):
239 if c in ("row_id", "cond_row_id", "base_idx", "param_j"):
240 d[c] = int(v)
241 elif c in ("matrix", "condition"):
242 d[c] = v
243 else:
244 d[c] = float(v)
245 rows.append(d)
246 return rows, meta
247
248
249# ------------------------------------------------------- theta -> InputData
250def apply_theta(sim, spec: dict, row: dict, cond: dict):
251 """Write one design row into the simulation's InputData.
252
253 Returns the dict of values actually written, for the provenance record.
254 Constructor-level parameters (i_mobile, v_mobile) are handled by the caller
255 BEFORE construction -- they cannot be set here.
256 """
257 d = sim.input_data
258 by_key = {p["key"]: p for p in spec["parameters"]}
259 written = {}
260
261 def put(sheet, key, val):
262 tgt = getattr(d, sheet, None)
263 if tgt is None:
264 raise KeyError(f"InputData has no sheet {sheet!r}")
265 tgt[key] = val
266 written[f"{sheet}.{key}"] = val
267
268 for key, val in row.items():
269 if key not in by_key:
270 continue
271 p = by_key[key]
272 sheets = p["sheet"]
273 if sheets in ("ctor", "postproc"):
274 continue # handled elsewhere / post-processing
275 if isinstance(sheets, str):
276 sheets = [sheets]
277 v = val
278 if key == "loop_net_w_c":
279 v = val * B111_M # spec is in units of b_111
280 for sh in sheets:
281 put(sh, key, v)
282 # SPECTRUM SHEET MIRROR (2026-08-21). The spec pins the cascade
283 # parameters (eta, f_cl_i, f_cl_v, s_i, s_v) to `production_fission`,
284 # but rate_equations/bin_moment_rates select the sheet from the
285 # SPECTRUM at build time -- `production_fusion` when cascade is
286 # fusion. A Case 1 run would therefore silently discard the entire
287 # calibrated production vector and substitute workbook defaults,
288 # while still reporting the design row in provenance: the run would
289 # not be the theta it claims to be. Mirroring costs nothing on a
290 # fission run (the fusion sheet is never read) and makes the cascade
291 # switch a pure change of He formulation, which is the only way the
292 # Case 1 / Case 2 comparison means anything.
293 if sh in ("production_fission", "production_fusion"):
294 other = ("production_fusion" if sh == "production_fission"
295 else "production_fission")
296 put(other, key, v)
297
298 # derived partners: the <100> binding law is a FIXED offset from <111>
299 # so the character ratio cannot absorb the f_100 data (plan S2.1).
300 if key == "B_111":
301 put("dissociation", "B_100", val * 0.9246)
302
303 # A_111 is DERIVED from E_b_i2 (plan S2.2c), never sampled directly.
304 if "E_b_i2" in row and "B_111" in row:
305 # The small-n branch is E_b^fit(n) = A_111 * n^{+B_111} (exponent
306 # POSITIVE -- binding increases with size), so at n=2
307 # E_b_i2 = A_111 * 2^{+B_111} => A_111 = E_b_i2 * 2^{-B_111}
308 # This used to be written with the opposite sign, which overstated
309 # A_111 by 2^{2*B_111} (1.54x at B_111=0.314). It was INERT --
310 # ReactionRates._precompute re-derives A_111 from E_b_i2 via
311 # A_111_from_E_b_i2 whenever E_b_i2 is present, and apply_theta always
312 # writes E_b_i2 -- verified 2026-08-16 by G_SIA being bit-identical for
313 # A_111 in {correct, old-wrong, 5.0}. No published result is affected.
314 # Routed through the canonical helper so the two can never diverge.
315 a111 = A_111_from_E_b_i2(float(row["E_b_i2"]), float(row["B_111"]))
316 put("dissociation", "A_111", a111)
317 put("dissociation", "A_100", a111 * 0.9545)
318
319 # fixed values, written explicitly so a stale workbook cannot leak in.
320 #
321 # A SAMPLED VALUE BEATS THE FIXED DEFAULT. This loop runs AFTER the row
322 # loop, so without the guard below a key present in BOTH `parameters` and
323 # `fixed` would be written from the design row and then silently overwritten
324 # by the constant -- the design would appear to vary a parameter that never
325 # actually moved, which is indistinguishable from the parameter having no
326 # effect. Relevant now because T_star_conv_C is being promoted from a fixed
327 # constant to a calibration parameter (2026-08-14).
328 for f in spec.get("fixed", []):
329 k, v = f["key"], f["value"]
330 if f"reactions.{k}" in written:
331 continue
332 if k in ("psucc_abs_pref", "dH_rev_conv", "gamma_a_conv", "n_j_min_junc",
333 "n_j_min_frac", "T_star_conv_C", "nu0_conv", "loop_net_rho_max",
334 "n_ref_conv", "absorb_boost_100", "grow_boost_100"):
335 put("reactions", k, v)
336
337 # condition: the ACTUAL T and dose rate of the experiment.
338 # T_eq = T - 60 C is a data-side plotting device (plan S3.2) and must never
339 # reach the simulator -- asserted here rather than trusted.
340 assert "T_eq" not in cond, "T_eq must not be passed to the simulator"
341 put("reactions", "T", float(cond["T_K"]))
342 # null in conditions.json means "keep the workbook value" -- test for None,
343 # not for key presence, or the null is coerced and blows up.
344 if cond.get("G") is not None:
345 put("reactions", "G", float(cond["G"]))
346 if cond.get("rho_d") is not None:
347 put("reactions", "rho_d", float(cond["rho_d"]))
348 # He PRODUCTION RATE is a property of the IRRADIATION, like G and T, but was
349 # readable only from the workbook -- so `cascade` was the sole way to move
350 # it, which welded the He SUPPLY to the He FORMULATION and made Case 1 vs
351 # Case 2 unfalsifiable. Exposing it per-condition separates the two.
352 if cond.get("G_He_r") is not None:
353 put("reactions", "G_He_r", float(cond["G_He_r"]))
354
355 # ½<111> LOOP COARSENING (2026-08-21). reaction_rates.py:516-541 implements
356 # the missing loop-loop coalescence edge -- D_loop_coal continues the 1-D
357 # glide law past the i_mobile cutoff, so a loop that grows past it can still
358 # coalesce -- and rhs_bin_moment:1691 consumes it. The whole chain is wired.
359 # But the switch reads `re.get('LOOP_COAL', 0)` from the workbook Reactions
360 # sheet, LOOP_COAL is absent from that sheet, from parameters_S4.json and
361 # from all 79 designs, so it has been 0 in EVERY run of this campaign. The
362 # mechanism three sessions called structural had a switch nobody flipped.
363 # Exposed per-condition so the test costs no workbook edit.
364 if cond.get("LOOP_COAL") is not None:
365 put("reactions", "LOOP_COAL", float(cond["LOOP_COAL"]))
366 if cond.get("loop_coal_pref") is not None:
367 put("reactions", "loop_coal_pref", float(cond["loop_coal_pref"]))
368
369 # <111> GROWTH-LAW OPTIONS (author-directed, 2026-08-31). Both default off
370 # in reaction_rates.py, so omitting them reproduces every prior run exactly.
371 # sia_D_flat : mobile clusters diffuse at the MONOMER value (removes the
372 # 1.8e6x cliff at n=4 and the D1D/rot_factor suppression),
373 # so raising i_mobile actually raises the interstitial flux.
374 # Z_loop_model : size-dependent Wolfer loop bias in place of the constant
375 # Z_i^loop, putting n inside the drift bracket so v(n) can
376 # have a zero crossing (a critical radius) at all.
377 # r_cap_i_b / r_cap_v_b : drift capture radii in units of b.
378 for _k in ("sia_D_flat", "Z_loop_model", "r_cap_i_b", "r_cap_v_b"):
379 if cond.get(_k) is not None:
380 put("reactions", _k, float(cond[_k]))
381
382 # CASCADE CLUSTER-SIZE CUTOFFS (2026-08-30). i_cascade / v_cascade are the
383 # largest SIA / vacancy clusters a cascade injects (workbook Production
384 # sheet: 20 and 10 for fission). They are not design columns and have never
385 # been varied. Exposed because the M1 ladder showed mean_n_111 SATURATES at
386 # ~21 across i_mobile = 5..100 and is identical with LOOP_COAL on or off at
387 # i_mobile >= 50 -- two independent ways of lifting the coalescence
388 # restriction converge on the same ceiling, so the limiter is neither of
389 # them. n ~ 21 against i_cascade = 20 is the coincidence this tests.
390 if cond.get("i_cascade") is not None:
391 for _sh in ("production_fission", "production_fusion"):
392 put(_sh, "i_cascade", float(cond["i_cascade"]))
393 if cond.get("v_cascade") is not None:
394 for _sh in ("production_fission", "production_fusion"):
395 put(_sh, "v_cascade", float(cond["v_cascade"]))
396
397 # CASCADE-SOURCE MIRROR (2026-08-21). `cascade` selects TWO things at once:
398 # the He coupling case (Case 1 per-size Q_m vs Case 2 scalar ell_bar) AND the
399 # cascade source spectrum. Those sheets differ in four parameters no design
400 # column controls -- i_cascade (20 -> 50), v_cascade (10 -> 20), C_i, C_v --
401 # and v_cascade is a direct void-NUCLEATION lever: doubling the largest
402 # vacancy cluster born in a cascade raised cavity density by 100-1000x in
403 # F1/F2 and buried the very effect the run was built to isolate. (The
404 # workbook labels these m1/n1; the parsed dict keys are i_cascade/v_cascade.)
405 #
406 # With mirror_production the fission source is copied wholesale onto the
407 # fusion sheet, so `cascade` becomes a PURE switch of He formulation and
408 # Case 1 vs Case 2 is answerable. This is a DIAGNOSTIC setting, not a
409 # fusion prediction: a real fusion calculation wants the fusion spectrum.
410 # Default off, so no other condition file changes behaviour.
411 if cond.get("mirror_production"):
412 for k, v in d.production_fission.items():
413 d.production_fusion[k] = v
414 written["production_fusion.<mirrored>"] = "from production_fission"
415 return written
416
417
418# --------------------------------------------------------- bin-moment config
419def apply_bin_config(sim, cfg):
420 """Write the bin-moment layout onto the workbook dict.
421
422 MUST be called after the constructor and before _calculate_derived() +
423 rebuild_rates(). Bin configuration is NOT a constructor argument -- passing
424 i_discrete=/I_bin=/shape_function= to RadClusterSimulation() raises
425 TypeError since 2026-08-02, and before that it was silently dropped, which
426 produced nine bit-identical "refinement" runs (plan S10(i)).
427
428 No-op in discrete mode, so it is safe to call unconditionally.
429 """
430 if cfg["equations"] != "bin_moment":
431 return
432 r = sim.input_data.reactions
433 r["i_discrete"] = int(cfg["i_discrete"])
434 r["v_discrete"] = int(cfg["v_discrete"])
435 r["I_bin"] = int(cfg["I_bin"])
436 r["V_bin"] = int(cfg["V_bin"])
437 r["shape_function"] = str(cfg["shape_function"])
438
439
440def bin_layout(sim, cfg):
441 """Read the REALISED bin layout back off sim.rate_equations and verify it.
442
443 Two reasons this is not bookkeeping:
444
445 1. The layout must be read from `rate_equations` (the BinMomentRateEquations
446 that owns i_discrete/I_bin/r/shape_function), NOT `reaction_rates` --
447 querying the wrong object returns nan rather than raising (plan S10(i)).
448 2. The realised bin count is NOT the requested one. `r` is derived as
449 (I/i_discrete)**(1/I_bin) and build_bins() then walks floor(edge*r) with
450 a minimum increment of 1, so the walk overshoots or undershoots the
451 target by a bin or two. Recording the request and silently running a
452 different layout is exactly the class of error S10(i) is about.
453
454 Raises if the request did not take effect at all -- a silent fallback to the
455 coarse default (i_discrete=10, I_bin=6) is the failure mode that must not
456 reach a production row.
457 """
458 out = {}
459 if cfg["equations"] != "bin_moment":
460 return out
461 re_obj = getattr(sim, "rate_equations", None)
462 if re_obj is None:
463 raise RuntimeError("bin_moment requested but sim.rate_equations is None")
464 got_id = int(getattr(re_obj, "i_discrete", -1))
465 got_ib = int(getattr(re_obj, "I_bin", -1))
466 got_vb = int(getattr(re_obj, "V_bin", -1))
467 got_sf = str(getattr(re_obj, "shape_function", "?"))
468 out["bin_i_discrete"] = got_id
469 out["bin_v_discrete"] = int(getattr(re_obj, "v_discrete", -1))
470 out["bin_I_bin"] = got_ib
471 out["bin_V_bin"] = got_vb
472 out["bin_r"] = float(getattr(re_obj, "r", float("nan")))
473 out["bin_shape"] = got_sf
474 out["bin_n_mom"] = int(getattr(re_obj, "n_mom", -1))
475 out["N_eq"] = int(getattr(re_obj, "N_eq", -1))
476 # The request must have LANDED. i_discrete and shape_function are set
477 # verbatim, so they must match exactly; the bin counts are allowed to drift
478 # by the floor() walk but not to collapse to the default.
479 if got_id != int(cfg["i_discrete"]) or got_sf != str(cfg["shape_function"]):
480 raise RuntimeError(
481 f"bin config did not take effect: asked i_discrete="
482 f"{cfg['i_discrete']} shape={cfg['shape_function']}, "
483 f"rate_equations reports i_discrete={got_id} shape={got_sf}. "
484 f"This is the silent-default failure of plan S10(i).")
485 for name, want, got in (("I_bin", int(cfg["I_bin"]), got_ib),
486 ("V_bin", int(cfg["V_bin"]), got_vb)):
487 if want > 0 and abs(got - want) > max(3, 0.2 * want):
488 raise RuntimeError(
489 f"bin config {name}: asked {want}, realised {got} -- too far "
490 f"off to be the floor() walk. Check i_discrete < I.")
491 return out
492
493
494# ------------------------------------------------------------- one evaluation
495def evaluate(args):
496 row, spec, cond, cfg = args
497 t0 = time.time()
498 rec = {"row_id": int(row["row_id"]), "cond_row_id": int(row["cond_row_id"]),
499 "matrix": row["matrix"], "base_idx": int(row["base_idx"]),
500 "param_j": int(row["param_j"]), "condition": row["condition"]}
501 # theta hash: catches design drift without storing 20 floats per row
502 theta_keys = sorted(k for k in row
503 if k not in ("row_id", "cond_row_id", "matrix",
504 "base_idx", "param_j", "condition"))
505 rec["theta_hash"] = hashlib.sha256(
506 json.dumps({k: row[k] for k in theta_keys}, sort_keys=True).encode()
507 ).hexdigest()[:16]
508
509 try:
510 from radcluster_code.py_utils.simulation import RadClusterSimulation
511 i_mob = int(row.get("i_mobile", cfg["i_mobile_default"]))
512 v_mob = int(row.get("v_mobile", cfg["v_mobile_default"]))
513 _s = sys.stdout, sys.stderr
514 buf = io.StringIO()
515 try:
516 sys.stdout = sys.stderr = buf
517 sim = RadClusterSimulation(
518 I=cfg["I"], V=cfg["V"], solver_mode=cfg["solver_mode"],
519 equations=cfg["equations"], cascade=cond.get("cascade", "fission"),
520 C_floor=cfg["C_floor"], he_kinetics="quasi_steady_state",
521 i_mobile=i_mob, v_mobile=v_mob)
522 written = apply_theta(sim, spec, row, cond)
523 apply_bin_config(sim, cfg)
524 sim.input_data._calculate_derived()
525 sim.rebuild_rates()
526 rec.update(bin_layout(sim, cfg))
527 G = float(sim.input_data.reactions["G"])
528 # PER-CONDITION DOSE (T4). Each calibration condition is one
529 # EXPERIMENTAL point and carries the dose that point was measured
530 # at -- 15, 32 and 16.3 dpa inside the neutron family alone -- so a
531 # single global --dose cannot express the campaign. Falls back to
532 # --dose when the condition does not set one, which leaves every
533 # T2/T3 command line producing exactly what it produced before.
534 #
535 # NOT in run_cfg_sha, for the same reason T_K is not: it is a
536 # property of the CONDITION, not of the numerical configuration.
537 # Recorded per row as dose_target so the comparison to the
538 # measurement is unambiguous.
539 dose_target = float(cond.get("dose_dpa", cfg["dose"]))
540 rec["dose_target"] = dose_target
541 scfg = {"t_span": (1e-6, dose_target / G), "n_points": cfg["n_points"],
542 "log_time": True, "rtol": cfg["rtol"], "atol": cfg["atol"],
543 "timeout_s": cfg["timeout_s"],
544 "solver_method": {"linsol": "gmres",
545 "preconditioner": cfg["preconditioner"],
546 "concentration_threshold": 1e-22},
547 "loop_conversion": 1}
548 # LOOP_NETWORK_LOSS NEEDS run_adaptive, and not merely for the
549 # rho_net feedback -- the channel is DEAD without it. Lambda_n^net
550 # = (v_net * rho_net * w_c) * P_ld, and v_net is built from
551 # ci1_seg / cv1_seg, the segment-frozen monomers. Those default to
552 # 0.0 and are written ONLY by the inter-segment refresh in
553 # simulation._advance_network (rho_net/ci1_seg/cv1_seg + rebuild).
554 # Under plain run() they never exist, so v_net = 0 and Lambda_net is
555 # identically zero at every size. Measured 2026-08-12: a 12-point
556 # sweep with chi 1->60, w_c 1->200 and K_rec 1e-6->1e-3 returned
557 # twelve BIT-IDENTICAL rows. The workbook note said "requires
558 # sim.run_adaptive()" and meant it literally.
559 #
560 # max_doublings=0: take the operator splitting, refuse the domain
561 # doubling. Growing I mid-row would leave rows of one design at
562 # different grids and contradict run_cfg_sha, which pins I.
563 #
564 # Gated on the flag so every T2/T3 command line still takes the
565 # single-shot run() path and reproduces bit-for-bit.
566 try: # blank/NaN cell must read as OFF, not crash the row
567 _lnl = int(float(sim.input_data.reactions.get(
568 "LOOP_NETWORK_LOSS", 0) or 0))
569 except (TypeError, ValueError):
570 _lnl = 0
571 if cfg.get("lnl") is not None: # CLI override beats the workbook
572 _lnl = int(cfg["lnl"])
573 sim.input_data.reactions["LOOP_NETWORK_LOSS"] = _lnl
574 sim.rebuild_rates()
575 rec["lnl"] = _lnl
576 if _lnl:
577 res = sim.run_adaptive(solver_config=scfg, save_output=False,
578 timeout_s=cfg["timeout_s"],
579 max_doublings=0)
580 else:
581 res = sim.run(solver_config=scfg, save_output=False)
582 finally:
583 sys.stdout, sys.stderr = _s
584 rec["solver_rc"] = 0
585 rec.update(observe(res, sim, cfg, float(row.get("d_min_tem", 1.0))))
586 # LOOP_NETWORK_LOSS DIAGNOSTICS. Without these the channel is not
587 # falsifiable from the row: a sweep over chi/w_c/K_rec that moves the
588 # observables by ~0 is indistinguishable from a channel that is wired
589 # but geometrically gated off, and the 2026-08-12 ion liveness check
590 # burned two 1 h rows before the cause could be read off at all.
591 # rho_net_end vs rho_d says whether the DYNAMIC network ever left the
592 # floor; Lambda_net_max says whether the loss term has any authority;
593 # d_lam_on is the smallest loop diameter the channel can touch, which
594 # is the quantity chi actually controls.
595 if _lnl:
596 try:
597 import numpy as _np
598 _rr = sim.reaction_rates
599 _L = _np.asarray(getattr(_rr, "Lambda_net_111", []), dtype=float)
600 _d = _np.asarray(getattr(_rr, "d_loop_111", []), dtype=float)
601 rec["rho_net_end"] = float(getattr(_rr, "rho_net", float("nan")))
602 rec["rho_net_floor"] = float(sim.input_data.reactions.get("rho_d", 0.0))
603 rec["Lambda_net_max"] = float(_L.max()) if _L.size else 0.0
604 _nz = _np.nonzero(_L)[0]
605 rec["d_lam_on_nm"] = (float(_d[_nz[0]] * 1e9)
606 if _nz.size and _d.size > _nz[0] else None)
607 except Exception as _e: # diagnostics must never fail a row
608 rec["lnl_diag_error"] = f"{type(_e).__name__}: {_e}"[:120]
609 rec["n_written"] = len(written)
610 except Exception as exc:
611 rec["solver_rc"] = 1
612 rec["error"] = f"{type(exc).__name__}: {exc}"[:300]
613 rec["traceback_tail"] = traceback.format_exc()[-500:]
614 rec["admissible"] = False
615 rec["wall_s"] = round(time.time() - t0, 1)
616 return rec
617
618
619def per_size_populations(res, sim, cfg, idx=-1):
620 """Return (c111, c_v, binned, topbin) at time index `idx`, per size.
621
622 `idx` defaults to -1 (the final time), which is every legacy call site. It
623 is a parameter because the CHARACTER has to be readable at a dose-ladder
624 checkpoint, not only at whatever dose a row happened to stop at: f_100 moves
625 strongly with dose (<100> is sessile and one-way, so it accumulates), and
626 two rows of the SAME theta that timed out at different doses cannot be
627 compared on it. Measured 2026-08-13 on T9 batch 1: theta 4's two ends
628 stopped at 15.000 and 0.106 dpa, theta 0's at 2.813 and 0.020 -- a 140x dose
629 ratio masquerading as a temperature effect.
630
631 One extraction that works in both modes, so every downstream gate is
632 computed the same way and is comparable across the discrete/bin_moment
633 bridge (plan S11(e)-2).
634
635 discrete : `y` is (I + V + extras, nt) -- plain slices.
636 bin_moment : `y` is (N_eq, nt) with the layout
637 [0 : i_d] discrete SIA
638 [i_d : i_d + P*I_bin] SIA bin moments, P per bin
639 [i_VAC : i_VAC + v_d] discrete vacancies
640 [i_VAC+v_d : ... + P*V_bin] vacancy bin moments
641 mirroring RadClusterSimulation._size_distributions.
642
643 `topbin` maps axis -> content fraction in the top bin, computed from the RAW
644 mu1 moments (not the reconstruction). Empty dict in discrete mode.
645
646 Returns c111 / c_v as None when an axis genuinely cannot be extracted; the
647 caller treats None as FAILING the gate, never as passing.
648 """
649 I, V = int(cfg["I"]), int(cfg["V"])
650 y = res.get("y")
651 topbin = {}
652 if y is None:
653 return None, None, False, topbin
654 y = np.asarray(y)
655 if y.ndim != 2:
656 return None, None, False, topbin
657 yj = np.maximum(y[:, idx], 0.0)
658
659 re_obj = getattr(sim, "rate_equations", None)
660 binned = cfg.get("equations") == "bin_moment" and hasattr(re_obj, "bins")
661
662 if not binned:
663 c111 = yj[:I] if yj.shape[0] >= I else None
664 c_v = yj[I:I + V] if yj.shape[0] >= I + V else None
665 return c111, c_v, False, topbin
666
667 from radcluster_code.py_utils.bin_moment_rates import reconstruct_distribution
668 P = int(getattr(re_obj, "n_mom", 2))
669 sf = str(getattr(re_obj, "shape_function", "linear"))
670 i_d = int(getattr(re_obj, "i_discrete", 0))
671 v_d = int(getattr(re_obj, "v_discrete", 0))
672 I_bin = int(getattr(re_obj, "I_bin", 0))
673 V_bin = int(getattr(re_obj, "V_bin", 0))
674 i_vac = int(getattr(re_obj, "i_VAC", i_d + P * I_bin))
675
676 def _axis(start, n_bins, n_disc, bins, n_max, disc_start):
677 """Reconstruct one axis and its top-bin content fraction."""
678 if n_bins <= 0:
679 c = np.zeros(n_max)
680 c[:n_disc] = yj[disc_start:disc_start + n_disc]
681 return c, None
682 mom = yj[start:start + P * n_bins]
683 if mom.size < P * n_bins:
684 return None, None
685 mu0 = mom[0::P][:n_bins]
686 mu1 = mom[1::P][:n_bins] if P >= 2 else None
687 mu2 = mom[2::P][:n_bins] if P >= 3 else None
688 c = reconstruct_distribution(sf, mu0, mu1, mu2, bins, n_max)
689 c[:n_disc] = yj[disc_start:disc_start + n_disc]
690 # Top-bin content fraction from the RAW moments. mu1 is content
691 # (sum n*c_n); with P==1 there is no mu1, so fall back to mu0 weighted
692 # by the bin's geometric midpoint.
693 if mu1 is not None:
694 tot = float(np.sum(np.maximum(mu1, 0.0)))
695 frac = float(max(mu1[-1], 0.0)) / tot if tot > 0 else None
696 else:
697 mid = np.array([(lo + hi - 1) / 2.0 for lo, hi in bins])
698 w = mid * np.maximum(mu0, 0.0)
699 tot = float(w.sum())
700 frac = float(w[-1]) / tot if tot > 0 else None
701 return c, frac
702
703 c111, topbin["111"] = _axis(i_d, I_bin, i_d, getattr(re_obj, "bins", []),
704 I, 0)
705 c_v, topbin["v"] = _axis(i_vac + v_d, V_bin, v_d,
706 getattr(re_obj, "vac_bins", []), V, i_vac)
707
708 # <100> is emitted per-size in both modes (y_sia100), so its top-bin figure
709 # is taken on the SIA bin partition it shares with <111>.
710 y100 = res.get("y_sia100")
711 if y100 is not None:
712 y100 = np.asarray(y100)
713 if y100.ndim == 2 and y100.shape[0] >= I and I_bin > 0:
714 c100 = np.maximum(y100[:I, -1], 0.0)
715 n_ax = np.arange(1, I + 1)
716 k100 = n_ax * c100
717 tot = float(k100.sum())
718 if tot > 0:
719 lo, hi = getattr(re_obj, "bins", [(I, I + 1)])[-1]
720 topbin["100"] = float(k100[lo - 1:min(hi - 1, I)].sum()) / tot
721 return c111, c_v, True, topbin
722
723
724def observe(res, sim, cfg, d_min_nm):
725 """Observation operator + the admissibility block."""
726 out = {}
727 d = sim.input_data
728 I = cfg["I"]
729 Om = float(d.derived["Omega"])
730 b100 = float(d.energetics.get("b_100", 0.2867)) * 1e-9
731 b111 = B111_M
732 t = np.asarray(res["t"], float)
733
734 def ser(k):
735 if k not in res:
736 return np.zeros_like(t)
737 v = np.asarray(res[k], float).ravel()
738 return v if v.size == t.size else np.zeros_like(t)
739
740 G = float(d.reactions["G"])
741 out["dose_reached"] = float(t[-1] * G)
742 out["starved"] = bool(out["dose_reached"] < 0.95 * cfg["dose"])
743 out["delta_FP"] = float(ser("delta_FP")[-1])
744 out["delta_He"] = float(ser("delta_He")[-1])
745
746 N111, N100 = float(ser("N_loops_111")[-1]), float(ser("N_loops_100")[-1])
747 n111, n100 = float(ser("mean_n_111")[-1]), float(ser("mean_n_100")[-1])
748 out["N_loops_111"], out["N_loops_100"] = N111, N100
749 out["mean_n_111"], out["mean_n_100"] = n111, n100
750 out["d_111_nm"] = float(2*np.sqrt(max(n111, 0)*Om/(np.pi*b111))*1e9)
751 out["d_100_nm"] = float(2*np.sqrt(max(n100, 0)*Om/(np.pi*b100))*1e9)
752 out["N_voids"] = float(ser("N_voids")[-1])
753 # Cavity SIZE, not just density. The database reports both, and with
754 # swelling withdrawn as an observable (it is N_voids x mean_n_v exactly, so
755 # it carries no independent information -- author, 2026-08-02) the void
756 # channel would otherwise be density-only. Spherical cavity:
757 # d = 2 (3 m Omega / 4 pi)^(1/3) [CLAUDE.md S8 / plan S3.1-2]
758 nv = float(ser("mean_n_v")[-1])
759 out["mean_n_v"] = nv
760 out["d_cavity_nm"] = float(
761 2.0 * (3.0 * max(nv, 0.0) * Om / (4.0 * np.pi)) ** (1.0 / 3.0) * 1e9)
762 # Still emitted (free, and a useful health check), but NOT screened or
763 # calibrated on -- see OBSERVABLES in merge_and_sobol.py. Note this is the
764 # vacancy-INVENTORY swelling of the conservation identity, which includes
765 # sub-visible clusters; the database reports S = (pi/6) N_c d_c^3 from the
766 # VISIBLE cavity population. They are different numbers (plan S3.1-3).
767 out["S_inventory"] = float(ser("swelling")[-1])
768
769 # ── DOSE LADDER ──────────────────────────────────────────────────────────
770 # Accepting a timed-out row AT WHATEVER DOSE IT REACHED would be a mistake
771 # of a different order than accepting a truncated tail. Row 24 gives
772 # d_100 = 5.36 nm at 0.1 dpa and 26.505 nm at 1.0 dpa -- a factor of 5 in
773 # the primary observable from dose alone, against the ~2 % the whole
774 # I=50000 -> 200000 grid refinement moved it. And because a row is
775 # expensive for PHYSICS reasons, `dose_reached` is correlated with theta:
776 # it is a confound aligned with the quantity being estimated, not noise.
777 #
778 # The fix costs nothing, because the trajectory is already here (n_points).
779 # Record the observables on a fixed dose ladder as well as at the end, so
780 # merge_and_sobol can screen every row at a COMMON dose. A timed-out row
781 # is then not discarded -- it contributes to every checkpoint it actually
782 # reached, and only drops out above that, pairwise, per checkpoint.
783 #
784 # f_100_* is deliberately absent: it needs the per-size reconstruction at
785 # that time index, and every f_100 variant is already withheld under
786 # bin_moment by BIN_MOMENT_BLOCKED, so there is nothing to screen with it.
787 dose_t = t * G
788 ladder = {}
789 # DOSE_SNAP / DOSE_OFF_GRID (added 2026-09-06). Two distinct defects, both
790 # of which silently substituted one dose for another.
791 #
792 # 1. The final grid point overshoots its target by rounding: a 2 dpa run
793 # ends at dose_dpa = 2.0000000000000004 (t_end = 2.0000000000000004e7 s
794 # at G = 1e-7). searchsorted(..., side="right") - 1 then steps PAST the
795 # final point and returns the penultimate one. On the 37-point log grid
796 # of a Table 4 run that is 0.6433 dpa -- so the "2 dpa" column carried
797 # 0.643 dpa numbers. A relative snap absorbs the rounding.
798 #
799 # 2. The grid is logarithmic in TIME, so a checkpoint that is not itself a
800 # grid point takes the last point BELOW it, however far below that is.
801 # Requesting 1 dpa on that same grid also returned 0.6433 dpa, making
802 # the 1 and 2 dpa columns byte-identical. That is the Figure 8 failure
803 # mode -- comparing points at unequal dose -- reappearing inside the
804 # study built to answer it. The entry is still recorded, because the
805 # numbers are real at the dose they were taken, but it is marked
806 # off_grid so the reporting step refuses it as a metric for `dck`
807 # (plan S3.4.1) instead of quietly mislabelling it.
808 DOSE_SNAP = 1e-6 # relative: absorbs end-point rounding only
809 DOSE_OFF_GRID = 0.02 # relative: how far below dck still counts as dck
810 for dck in DOSE_CHECKPOINTS:
811 if out["dose_reached"] < dck * (1.0 - 1e-9):
812 continue # never got here; not an error
813 j = int(np.searchsorted(dose_t, dck * (1.0 + DOSE_SNAP),
814 side="right")) - 1
815 if j < 0:
816 continue
817 rel = (dck - float(dose_t[j])) / dck if dck > 0 else 0.0
818 n111c = float(ser("mean_n_111")[j])
819 n100c = float(ser("mean_n_100")[j])
820 nvc = float(ser("mean_n_v")[j])
821 ladder[f"{dck:g}"] = {
822 "dose": float(dose_t[j]),
823 "dose_rel_err": float(rel),
824 "off_grid": bool(rel > DOSE_OFF_GRID),
825 "N_loops_111": float(ser("N_loops_111")[j]),
826 "N_loops_100": float(ser("N_loops_100")[j]),
827 "d_111_nm": float(2*np.sqrt(max(n111c, 0)*Om/(np.pi*b111))*1e9),
828 "d_100_nm": float(2*np.sqrt(max(n100c, 0)*Om/(np.pi*b100))*1e9),
829 "N_voids": float(ser("N_voids")[j]),
830 "mean_n_v": nvc,
831 "d_cavity_nm": float(
832 2.0*(3.0*max(nvc, 0.0)*Om/(4.0*np.pi))**(1.0/3.0)*1e9),
833 "delta_FP": float(ser("delta_FP")[j]),
834 }
835 # CHARACTER ON THE LADDER (added 2026-08-13). It used to be omitted
836 # here -- "f_100_* is deliberately absent: it needs the per-size
837 # reconstruction at that time index" -- which was true but left the one
838 # observable the character calibration turns on unavailable at a common
839 # dose. The cost of that: in T9 batch 1 every theta's two temperature
840 # ends timed out at DIFFERENT doses (theta 4 at 15.000 vs 0.106 dpa),
841 # and since <100> accumulates monotonically, comparing their end-state
842 # f_100 measured the dose gap, not the temperature response. One theta
843 # appeared to run the crossover BACKWARDS purely from this.
844 #
845 # The reconstruction is the same one the final-time path uses, at index
846 # j instead of -1, with the same floor treatment on each population:
847 # C_floor is subtracted from the 1/2<111> reconstruction here, while
848 # <100> is used as-is because cpp_bridge already floored it at the
849 # moment level (see the note at the c100 assignment below).
850 try:
851 _c111j, _, _, _ = per_size_populations(res, sim, cfg, idx=j)
852 _y100 = res.get("y_sia100")
853 if _c111j is not None and _y100 is not None:
854 _y100 = np.asarray(_y100)
855 if _y100.ndim == 2 and _y100.shape[0] >= I and _y100.shape[1] > j:
856 _c111j = np.maximum(_c111j[:I] - cfg["C_floor"], 0.0)
857 _c100j = np.maximum(_y100[:I, j], 0.0)
858 _nax = np.arange(1, I + 1)
859 _d100 = 2*np.sqrt(_nax*Om/(np.pi*b100))*1e9
860 _d111 = 2*np.sqrt(_nax*Om/(np.pi*b111))*1e9
861 _a0 = float(_c100j[_d100 >= d_min_nm].sum())
862 _a1 = float(_c111j[_d111 >= d_min_nm].sum())
863 ladder[f"{dck:g}"]["N_100_vis"] = _a0 / Om
864 ladder[f"{dck:g}"]["N_111_vis"] = _a1 / Om
865 ladder[f"{dck:g}"]["f_100_tem"] = (
866 _a0 / (_a0 + _a1)) if (_a0 + _a1) > 0 else None
867 except Exception as _e: # a ladder rung must never fail a row
868 ladder[f"{dck:g}"]["f_100_err"] = f"{type(_e).__name__}"[:40]
869 out["at_dose"] = ladder or None
870
871 # --- admissibility -----------------------------------------------------
872 out["occ_111"] = n111 / I
873 out["occ_100"] = n100 / I
874 d_ceil = float(2*np.sqrt(I*Om/(np.pi*b100))*1e9)
875 out["d_ceiling_100_nm"] = d_ceil
876 out["d_over_ceiling_100"] = out["d_100_nm"] / d_ceil if d_ceil > 0 else None
877
878 # PER-SIZE EXTRACTION. In discrete mode `y` is the per-size state and this
879 # is a slice. In bin_moment mode `y` is (N_eq, nt) with N_eq ~ 150 -- the
880 # old code sliced y[:I] out of it, got N_eq rows back, and died on the
881 # broadcast against arange(1, I+1). Worse, the vacancy guard below tested
882 # `y.shape[0] >= I + V`, which is false for a binned state, so pile_v fell
883 # through to None and the gate read None as PASSING (plan S11(h)).
884 c111, c_v, binned, topbin = per_size_populations(res, sim, cfg)
885 y100 = res.get("y_sia100")
886 pile111 = pile100 = None
887 f_num = f_cont = f_tem = None
888 if c111 is not None:
889 n_ax = np.arange(1, I + 1)
890 top = int((1 - PILE_TOP_FRAC) * I)
891 c111 = np.maximum(c111 - cfg["C_floor"], 0.0)
892 k111 = n_ax * c111
893 if k111.sum() > 0:
894 pile111 = float(k111[top:].sum() / k111.sum())
895 if y100 is not None:
896 y100 = np.asarray(y100)
897 # y_sia100 is emitted PER-SIZE (I, nt) in BOTH modes -- verified
898 # 2026-08-03 on a binned run (N_eq=55, y_sia100 shape (200, 20)) --
899 # so the <100> path needs no reconstruction.
900 if y100.ndim == 2 and y100.shape[0] >= I:
901 # NO C_floor SUBTRACTION HERE. cpp_bridge now removes the
902 # floor at the MOMENT level before the closure reconstructs
903 # y_sia100 (see the block guarded by `if _is_bin`), which is
904 # the only correct order -- subtracting per-size afterwards
905 # is what left the spurious large-n tail that inflated
906 # N_100_vis_1 by a median 626x. Subtracting again here would
907 # double-floor an already-floored array.
908 c100 = np.maximum(y100[:I, -1], 0.0)
909 k100 = n_ax * c100
910 if k100.sum() > 0:
911 pile100 = float(k100[top:].sum() / k100.sum())
912 # loop-fraction conventions (plan S3.1-4): all three
913 s0, s1 = float(c100.sum()), float(c111.sum())
914 f_num = s0 / (s0 + s1) if (s0 + s1) > 0 else None
915 q0, q1 = float(k100.sum()), float(k111.sum())
916 f_cont = q0 / (q0 + q1) if (q0 + q1) > 0 else None
917 # The cutoff is applied to BOTH characters: they have
918 # different b, so the same n maps to a different d.
919 dd100 = 2*np.sqrt(n_ax*Om/(np.pi*b100))*1e9
920 dd111 = 2*np.sqrt(n_ax*Om/(np.pi*b111))*1e9
921 # Emit the whole cutoff curve rather than one sampled value:
922 # d_min is post-processing, so this is free, and the spread
923 # across cutoffs is itself the floor on sigma_model for
924 # f_100 (plan S3.1-4; convention alone moved one measurement
925 # 0.824 -> 0.408).
926 # SIZE-RESOLVED LOOP DIAMETER (added 2026-08-21). The
927 # visible DENSITIES have been emitted here since the f_100
928 # work, but `d_111_nm` / `d_100_nm` above are built from the
929 # whole-distribution mean_n -- the same construction that
930 # made d_cavity_nm a grid artifact (report S2). TEM measures
931 # only resolvable loops, so the windowed mean is the honest
932 # comparator regardless of which way it moves the answer.
933 #
934 # WHAT IT ACTUALLY MEASURED (L1, I = 80k and 160k, drift
935 # 0.0 % at every entry). The window does NOT rescue d_111:
936 # 1.049 nm unwindowed -> 1.160 at d_min = 1.0 (the cutoff the
937 # 300 C/15 dpa distributions in MicroData.xlsx justify) ->
938 # 1.602 at 1.5, against a band of [3.4, 7.0]. The <111>
939 # population is a dense pile just ABOVE the cutoff, not a few
940 # visible loops buried under invisible ones, so windowing
941 # barely moves the mean. d_111 is a real model deficiency,
942 # not a comparator artifact -- the opposite of the d_cavity
943 # case, and the reason this had to be measured, not argued.
944 #
945 # What the window DOES change is the density: N_111_vis at
946 # d_min = 1.0 is 7.79e21, inside the ORIGINAL 1.1e22 ceiling,
947 # so the 2026-08-19 raise to 1.5e22 is unnecessary here.
948 for dm in cfg.get("d_min_sweep", [0.8, 1.0, 1.25, 1.5]):
949 s0m, s1m = dd100 >= dm, dd111 >= dm
950 a0 = float(c100[s0m].sum())
951 a1 = float(c111[s1m].sum())
952 tag = f"{dm:g}".replace(".", "p")
953 out[f"f_100_tem_{tag}"] = ((a0 / (a0 + a1))
954 if (a0 + a1) > 0 else None)
955 out[f"N_100_vis_{tag}"] = a0 / Om
956 out[f"N_111_vis_{tag}"] = a1 / Om
957 # Number-weighted, matching the database convention.
958 out[f"d_100_vis_{tag}"] = (
959 float((dd100[s0m] * c100[s0m]).sum() / a0)
960 if a0 > 0 else None)
961 out[f"d_111_vis_{tag}"] = (
962 float((dd111[s1m] * c111[s1m]).sum() / a1)
963 if a1 > 0 else None)
964 m0, m1 = dd100 >= d_min_nm, dd111 >= d_min_nm
965 v0 = float(c100[m0].sum())
966 v1 = float(c111[m1].sum())
967 f_tem = v0 / (v0 + v1) if (v0 + v1) > 0 else None
968 out["N_100_visible"] = v0 / Om
969 out["N_111_visible"] = v1 / Om
970 out["d_100_visible"] = (float((dd100[m0]*c100[m0]).sum()/v0)
971 if v0 > 0 else None)
972 out["d_111_visible"] = (float((dd111[m1]*c111[m1]).sum()/v1)
973 if v1 > 0 else None)
974 out["pile_111"], out["pile_100"] = pile111, pile100
975 out["f_100_number"], out["f_100_content"] = f_num, f_cont
976 out["f_100_tem"], out["d_min_tem_nm"] = f_tem, d_min_nm
977
978 # --- VACANCY axis -------------------------------------------------------
979 # The SIA-only check above is one-sided. delta_FP is built on the swelling
980 # identity S = S_I + Delta J^d, and S is the VACANCY inventory, so a
981 # truncated vacancy grid shows up there -- which is how this was found: the
982 # check_machine probe (V=120) sits at delta_FP = 4.1e-3 while mean cavity
983 # size is ~168 vacancies, i.e. the typical cavity does not fit on its grid.
984 #
985 # This matters more since swelling was withdrawn and d_cavity_nm added:
986 # N_voids / mean_n_v / d_cavity_nm are the observables a truncated V
987 # corrupts, and nothing was checking that axis.
988 #
989 # State layout (discrete): SIA 0..I-1, vacancies I..I+V-1.
990 # (bin_moment): i_discrete + P*I_bin + v_discrete + P*V_bin + He extras,
991 # handled by per_size_populations() above.
992 V = int(cfg["V"])
993 pile_v = None
994 if c_v is not None:
995 m_ax = np.arange(1, V + 1)
996 c_v = np.maximum(c_v - cfg["C_floor"], 0.0)
997 k_v = m_ax * c_v
998 if k_v.sum() > 0:
999 pile_v = float(k_v[int((1 - PILE_TOP_FRAC) * V):].sum() / k_v.sum())
1000 out["pile_v"] = pile_v
1001
1002 # ── SIZE-RESOLVED CAVITY DIAGNOSTIC (2026-08-20) ──────────────────────
1003 # THE CAVITY DISTRIBUTION IS BIMODAL (author): small near-equilibrium
1004 # bubbles held open by He gas pressure, plus a separate population that has
1005 # crossed the critical radius and grows like a void. `mean_n_v` is a
1006 # number-weighted mean over BOTH, so it reports the valley between the modes
1007 # -- a size at which there is no population -- and it is dragged by whatever
1008 # the void tail is doing. That is why `mean_n_v` drifts 314-56000 % with
1009 # grid extent while `N_voids`, a pure count carried by the numerous small
1010 # bubbles, drifts only 13-108 % on the same pairs.
1011 #
1012 # d_cavity_nm is derived from mean_n_v and inherits the defect, so the
1013 # campaign has been scoring the cavity SIZE on a statistic with no physical
1014 # referent. These fields resolve the modes instead: densities and
1015 # number-weighted mean diameters inside diameter WINDOWS, plus the content
1016 # fraction carried by the large-void tail.
1017 #
1018 # d(m) = 2*(3*m*Omega/4pi)^(1/3) -- the same spherical relation as
1019 # d_cavity_nm, so the window figures and the legacy figure are comparable.
1020 if c_v is not None and V > 0:
1021 m_ax = np.arange(1, V + 1)
1022 d_m = 2.0 * (3.0 * m_ax * Om / (4.0 * np.pi)) ** (1.0 / 3.0) * 1e9 # nm
1023 cv = np.maximum(c_v, 0.0)
1024 tot_n = float(cv.sum())
1025 tot_k = float((m_ax * cv).sum())
1026 # 1-4 nm is the BUBBLE window (author, 2026-08-20): near-equilibrium
1027 # He-stabilised bubbles. Below 1 nm is the di/tri-vacancy cloud no TEM
1028 # resolves and which is not a cavity population; above ~4 nm a cavity
1029 # has crossed the critical radius and is growing as a void.
1030 # `tem` is the HONEST comparator against a TEM measurement: everything
1031 # resolvable, d >= 1 nm, with NO upper cut. The bounded windows below
1032 # exclude large cavities by construction, so their diameter is partly a
1033 # property of the window -- d_cav_bub sits at 2.856 nm across a 5e7
1034 # range in N_cav_bub, which is the window's centroid, not a mode.
1035 for lo, hi, tag in ((1.0, np.inf, "tem"), # <-- what TEM would report
1036 (1.0, 4.0, "bub"), # He-bubble window
1037 (1.0, 10.0, "1_10"),
1038 (2.0, 4.0, "2_4"),
1039 (4.0, np.inf, "void")): # grown-void tail
1040 sel = (d_m >= lo) & (d_m < hi)
1041 n_sel = float(cv[sel].sum())
1042 out[f"N_cav_{tag}"] = n_sel / Om if n_sel > 0 else 0.0
1043 out[f"d_cav_{tag}"] = (float((d_m[sel] * cv[sel]).sum() / n_sel)
1044 if n_sel > 0 else None)
1045 out[f"kfrac_cav_{tag}"] = (float((m_ax[sel] * cv[sel]).sum() / tot_k)
1046 if tot_k > 0 else None)
1047 # Modal size: the diameter carrying the most CONTENT, found on a log
1048 # grid so the two modes are weighted comparably. For a bimodal
1049 # distribution this tracks a real peak where the mean tracks nothing.
1050 if tot_n > 0:
1051 kk = m_ax * cv
1052 out["d_cav_mode"] = float(d_m[int(np.argmax(kk))]) if kk.sum() > 0 else None
1053 else:
1054 out["d_cav_mode"] = None
1055
1056 out["occ_v"] = (out["mean_n_v"] / V) if V > 0 else None
1057
1058 # occ_v is recorded but deliberately NOT a reject criterion. The occupancy
1059 # heuristic assumes a converged mean sits far below the ceiling, which holds
1060 # for loops but not for cavities: the probe converges at mean_n_v = 153 with
1061 # occ_v = 0.319 (V=480). Rejecting on occ_v > 0.10 would demand V >~ 1600 to
1062 # accept an answer that is already converged at 480.
1063 #
1064 # occ_111 / occ_100 are now recorded on the SAME footing -- WITHDRAWN as
1065 # reject criteria on 2026-08-03 (plan S11(c)-2). Probe: row 24 at
1066 # occ_100 = 0.171 with pile_100 = 2.3e-10 returns d100 = 5.3573 and
1067 # N100 = 3.99268e21 at BOTH I=3200 and I=12800. Occupancy is not a
1068 # truncation test; pile is. Under the old rule 71 of 275 v2 rows with no
1069 # measurable truncation were discarded.
1070 bad_pile = (pile100 is not None and pile100 > PILE_TOL) or \
1071 (pile111 is not None and pile111 > PILE_TOL) or \
1072 (pile_v is not None and pile_v > PILE_TOL)
1073
1074 # TOP-BIN GATE (bin_moment only). `pile` is measured on the RECONSTRUCTED
1075 # per-size distribution, so it inherits the closure's smoothing and can look
1076 # clean while the top BIN -- the thing that actually holds the overflow --
1077 # is loaded. Gate on the raw moments as well.
1078 bad_topbin = False
1079 for axis in ("111", "100", "v"):
1080 val = topbin.get(axis)
1081 out[f"topbin_{axis}"] = val
1082 if val is not None and val > TOPBIN_TOL:
1083 bad_topbin = True
1084
1085 # FAIL CLOSED. A missing truncation measure is not a pass. Before
1086 # 2026-08-03 an uncomputable pile_v silently satisfied the gate, which is
1087 # exactly how a binned run would have reported an unguarded vacancy axis as
1088 # clean. If we could not measure an axis, we do not certify it.
1089 unmeasured = [nm for nm, val in (("pile_111", pile111), ("pile_100", pile100),
1090 ("pile_v", pile_v)) if val is None]
1091 if binned:
1092 unmeasured += [f"topbin_{ax}" for ax in ("111", "100", "v")
1093 if topbin.get(ax) is None]
1094 out["unmeasured_gates"] = unmeasured or None
1095
1096 # Still COMPUTED and RECORDED -- it is the covariate the ranking-stability
1097 # check needs (merge_and_sobol --require-converged) and the only way to say
1098 # afterwards how deep the truncation ran. It simply no longer rejects.
1099 out["grid_limited"] = bool(bad_pile or bad_topbin or unmeasured)
1100 out["grid_converged"] = not out["grid_limited"] # explicit, for clarity
1101
1102 # CONSERVATION IS A CORRECTNESS GATE, NOT A TRUNCATION PREFERENCE.
1103 # It used to live INSIDE the TRUNCATION_GATES branch, so the 2026-08-05
1104 # decision to stop rejecting truncated rows -- a deliberate and correct call
1105 # about tail resolution -- silently switched off the delta_FP check too.
1106 # Those are different kinds of claim: a truncated row is a real solution
1107 # observed over too small a grid, whereas a row that violates Frenkel-pair
1108 # conservation is not a solution of the stated equations at all.
1109 #
1110 # The cost of the coupling, measured 2026-08-12 on the completed T3 campaign:
1111 # delta_FP exceeded 1e-3 -- the value CLAUDE.md S8 calls a coding error -- in
1112 # 95.8 % of 1008 admissible rows, median 0.277, max 0.934, and every one of
1113 # them was certified admissible. It tracks <100> content directly (3.5e-4
1114 # when <100> holds under 10 % of the SIA content, 0.30 once it dominates), so
1115 # the failures concentrate exactly on the population being calibrated.
1116 #
1117 # RECORDED, NOT GATED -- author decision, 2026-08-13, reversing the gate this
1118 # function carried for one commit (61923a0).
1119 #
1120 # delta_FP here is a SYMPTOM OF SIZE, not a validity test. It is high
1121 # precisely when the model's loops are too large for the grid, and the
1122 # campaign data says so directly: rho(delta_FP, d_111_nm) = -0.462 -- the
1123 # strongest correlate of any observable -- while the vacancy-axis truncation
1124 # measures are flat (rho = -0.003 for pile_v, -0.041 for topbin_v). Rows
1125 # with 1/2<111> stuck below 1 nm have median delta_FP 0.506; rows near the
1126 # experimental 2-6 nm have 0.153. And pile_100 ~ 1.0 in essentially every
1127 # row, i.e. the <100> axis is saturated campaign-wide.
1128 #
1129 # So a high delta_FP means "I is too small for the sizes these parameters
1130 # produce", and the response is to move the parameters until the mean size
1131 # matches experiment -- at which point conservation follows -- NOT to gate it
1132 # away. Gating rejected ~95 % of rows and would remove exactly the search
1133 # space the calibration has to move through to get there.
1134 out["conserving"] = bool(abs(out["delta_FP"]) < DFP_TOL)
1135
1136 if TRUNCATION_GATES:
1137 out["admissible"] = bool((not out["starved"]) and (not out["grid_limited"])
1138 and out["conserving"])
1139 elif STARVED_GATE:
1140 out["admissible"] = not out["starved"]
1141 else:
1142 # A row is admissible if it EXISTS. The solver returned a trajectory;
1143 # how far up the dose ladder that trajectory reaches is recorded in
1144 # `at_dose` and decided per-checkpoint downstream, not here. See the
1145 # `conserving` note above for why delta_FP does not reject here either.
1146 out["admissible"] = True
1147 return out
1148
1149
1150_PATTERN_CACHE = {}
1151
1152
1153def _weighted_pattern(of, weights):
1154 """Deterministic machine-assignment pattern of length sum(weights).
1155
1156 Sainte-Lague / highest-averages: repeatedly hand the next slot to the
1157 machine with the largest w_k / (2 a_k + 1), where a_k is how many slots it
1158 already holds. This spreads each machine's slots as evenly as the weights
1159 allow, rather than giving it one contiguous block.
1160
1161 Interleaving is the point, not an aesthetic: the design rows are Saltelli
1162 A / B / AB_j matrices, so a contiguous block belongs disproportionately to
1163 a few base samples. If one machine dies, scattered gaps cost pairwise
1164 deletion a few rows spread over many base samples; a contiguous gap would
1165 wipe out whole base samples and take their partners with them.
1166
1167 Pure function of (of, weights) -- every machine computes the same pattern
1168 from the same CLI arguments, so no coordination is needed.
1169 """
1170 key = (of, tuple(weights))
1171 if key in _PATTERN_CACHE:
1172 return _PATTERN_CACHE[key]
1173 # integerise so the pattern is finite and exactly periodic
1174 scale = 1
1175 while any(abs(w * scale - round(w * scale)) > 1e-9 for w in weights) and scale < 10 ** 6:
1176 scale *= 10
1177 ints = [max(1, int(round(w * scale))) for w in weights]
1178 g = 0
1179 for v in ints:
1180 g = math.gcd(g, v)
1181 ints = [v // max(g, 1) for v in ints]
1182
1183 total = sum(ints)
1184 assigned = [0] * of
1185 pattern = []
1186 for _ in range(total):
1187 k = max(range(of), key=lambda j: (ints[j] / (2 * assigned[j] + 1), -j))
1188 pattern.append(k)
1189 assigned[k] += 1
1190 _PATTERN_CACHE[key] = pattern
1191 return pattern
1192
1193
1194MACHINES_FILE = HERE / "machines.json"
1195
1196
1197def _host_facts() -> dict:
1198 """Everything the registry is allowed to match on."""
1199 import subprocess as _sp
1200 hw_model = ""
1201 if platform.system() == "Darwin":
1202 try:
1203 hw_model = _sp.run(["sysctl", "-n", "hw.model"], capture_output=True,
1204 text=True, timeout=5).stdout.strip()
1205 except Exception:
1206 pass
1207 return {"system": platform.system(), "node": platform.node(),
1208 "hw_model": hw_model, "cpu_count": os.cpu_count() or 0,
1209 "env": set(os.environ)}
1210
1211
1212def _matches(rule: dict, f: dict) -> bool:
1213 if "system" in rule and rule["system"] != f["system"]:
1214 return False
1215 if "hw_model_prefix" in rule and not f["hw_model"].startswith(rule["hw_model_prefix"]):
1216 return False
1217 if "cpu_count" in rule and int(rule["cpu_count"]) != f["cpu_count"]:
1218 return False
1219 if "node_regex" in rule and not re.search(rule["node_regex"], f["node"], re.I):
1220 return False
1221 if "env_any" in rule and not any(v in f["env"] for v in rule["env_any"]):
1222 return False
1223 # A rule with no clause at all would match everything -- refuse it.
1224 return bool(rule)
1225
1226
1227def load_registry(path: Path = None) -> dict:
1228 p = path or MACHINES_FILE
1229 if not p.exists():
1230 raise SystemExit(f"--machine auto needs {p}, which does not exist.")
1231 return json.loads(p.read_text(encoding="utf-8"))
1232
1233
1234def detect_machine(reg: dict, facts: dict = None):
1235 """Which registry entry is this host? Returns the entry, or raises.
1236
1237 FAILS LOUDLY ON NO MATCH, and equally loudly on an AMBIGUOUS match. Both
1238 are the same underlying hazard: an index chosen wrongly means two machines
1239 compute the same rows and some other rows are computed by nobody, and that
1240 surfaces at merge time as "rows MISSING" -- which reads like a machine that
1241 never reported rather than the misconfiguration it is. A campaign is
1242 cheaper to start late than to re-run.
1243 """
1244 f = facts or _host_facts()
1245 hits = [m for m in reg["machines"]
1246 if any(_matches(rule, f)
1247 for rule in [m.get("match", {})] + m.get("match_alternatives", []))]
1248 if len(hits) == 1:
1249 return hits[0]
1250 where = (f"node={f['node']!r} system={f['system']!r} "
1251 f"hw_model={f['hw_model']!r} cpu_count={f['cpu_count']}")
1252 if not hits:
1253 raise SystemExit(
1254 f"\n*** machine detection FAILED -- this host matches no entry in "
1255 f"{MACHINES_FILE.name}.\n {where}\n"
1256 f" Known: " + ", ".join(f"{m['index']}={m['name']}"
1257 for m in reg["machines"]) + "\n"
1258 f" Fix it in ONE of two ways, never by guessing an index:\n"
1259 f" python run_ensemble.py --register <index> # pin this host\n"
1260 f" python run_ensemble.py --machine <index> ... # one-off override\n")
1261 raise SystemExit(
1262 f"\n*** machine detection AMBIGUOUS -- this host matches "
1263 f"{[m['index'] for m in hits]}.\n {where}\n"
1264 f" Tighten the match rules in {MACHINES_FILE.name}; two machines "
1265 f"sharing an index compute the same rows.\n")
1266
1267
1268def register_host(index: int, path: Path = None) -> None:
1269 """Pin THIS host to a registry index, by writing a fingerprint it will match.
1270
1271 Deliberately narrow: node + system + cpu_count. A loose rule is how two
1272 hosts end up sharing an index.
1273 """
1274 p = path or MACHINES_FILE
1275 reg = load_registry(p)
1276 f = _host_facts()
1277 ent = next((m for m in reg["machines"] if m["index"] == index), None)
1278 if ent is None:
1279 raise SystemExit(f"--register {index}: no such index in {p.name}")
1280 rule = {"system": f["system"], "node_regex": "^" + re.escape(f["node"]) + "$",
1281 "cpu_count": f["cpu_count"]}
1282 if f["hw_model"]:
1283 rule["hw_model_prefix"] = f["hw_model"]
1284 ent.setdefault("match_alternatives", []).append(rule)
1285 p.write_text(json.dumps(reg, indent=2), encoding="utf-8")
1286 print(f"registered this host to machine {index} ({ent['name']}): {rule}")
1287 print(f"*** COMMIT AND PUSH {p.name} -- every participant reads it.")
1288
1289
1290def assign_machine(row_id, of, weights=None):
1291 """Which machine owns this row_id.
1292
1293 weights=None reproduces the original even split (row_id % of) EXACTLY, so
1294 an existing campaign resumed without --weights keeps its assignment and
1295 every already-computed row stays where it was.
1296 """
1297 if not weights:
1298 return row_id % of
1299 pattern = _weighted_pattern(of, weights)
1300 return pattern[row_id % len(pattern)]
1301
1302
1303def main(argv=None):
1304 ap = argparse.ArgumentParser(description=__doc__,
1305 formatter_class=argparse.RawDescriptionHelpFormatter)
1306 ap.add_argument("--design", type=Path, required=True)
1307 # DETECTED, not typed. `auto` reads machines.json, matches this host, and
1308 # takes index/of/weights/workers from it, so the one value whose mistyping
1309 # silently corrupts a campaign is no longer typed at all. An integer still
1310 # works and still overrides.
1311 ap.add_argument("--machine", default="auto",
1312 help="this machine's index k, or 'auto' (default) to detect "
1313 "it from machines.json")
1314 ap.add_argument("--of", type=int, default=0,
1315 help="total machines M; taken from machines.json under --machine auto")
1316 ap.add_argument("--registry", type=Path, default=MACHINES_FILE,
1317 help="the machine registry (default machines.json)")
1318 ap.add_argument("--register", type=int, default=None, metavar="INDEX",
1319 help="pin THIS host to a registry index and exit. Use once "
1320 "per new participant, then commit machines.json.")
1321 # Hoffman2 is ONE machine index (3) but runs as a 16-task array job, so its
1322 # share is split a second time INSIDE the index. Each subtask takes every
1323 # Kth row of machine 3's assignment and writes its own results file, so
1324 # tasks never collide and a task killed at h_rt costs only its own rows.
1325 ap.add_argument("--subtask", type=int, default=0,
1326 help="this array task's 0-based id within the machine")
1327 ap.add_argument("--of-subtasks", type=int, default=1,
1328 help="number of array tasks sharing this machine index")
1329 # Sentinel, as for --timeout-s: the registry's `slots` is authoritative for a
1330 # production run, but an explicit --workers has to be able to win or a
1331 # 2-row smoke test cannot ask for 2 workers -- it silently got all 14.
1332 ap.add_argument("--workers", type=int, default=None)
1333 ap.add_argument("--weights", default=None,
1334 help="relative capacity of EACH machine, either a comma list "
1335 "('6,14,22,22', normally each machine's --workers) or "
1336 "'@path' to a campaign_layout.json written by "
1337 "campaign_layout.py. Splits the design in proportion to "
1338 "capacity instead of evenly, so heterogeneous machines "
1339 "finish together. MUST be identical on every machine -- "
1340 "prefer '@' form: a 66-entry list retyped on six hosts is "
1341 "a silent row collision waiting to happen, whereas the "
1342 "file is committed once and its hash is recorded per row. "
1343 "Omit for the default even split (row_id %% M).")
1344 ap.add_argument("--out", type=Path, default=None)
1345 ap.add_argument("--conditions", type=Path, default=HERE / "conditions.json")
1346 # Parameter spec is selectable so a side-study can add design columns (S3
1347 # adds the sink and cascade-spectrum levers) WITHOUT widening the shared
1348 # campaign spec -- design.py builds its Saltelli matrix from every entry in
1349 # the spec, so adding to parameters.json would silently move p off 24 and
1350 # invalidate the rev-6 sample size.
1351 ap.add_argument("--spec", type=Path, default=HERE / "parameters.json",
1352 help="parameter spec JSON (default parameters.json)")
1353 # LOOP_NETWORK_LOSS override. The flag lives in the workbook, but it is not
1354 # a physics constant -- it selects run_adaptive, which costs 4.6x the wall
1355 # time per unit dose (measured 2026-08-12, 0.0997 vs 0.4624 dpa/hr on the
1356 # same condition). The whole T3 baseline was produced with it OFF and the
1357 # T4 rows with it ON, so cost comparisons between them were confounded until
1358 # this switch existed. None = obey the workbook.
1359 ap.add_argument("--lnl", type=int, choices=(0, 1), default=None,
1360 help="force LOOP_NETWORK_LOSS on(1)/off(0); default: workbook")
1361 # FROZEN GRID, author 2026-08-06 (plan S12(u)). The single source of truth
1362 # is machines.json["grid"]; these defaults mirror it so a bare invocation
1363 # cannot silently run something else. Measured at 10-way concurrency
1364 # against the I=30000/V=5000/b25/i_mobile=50 base (4171 s on design row 21):
1365 # I=10000 1838 s (2.3x), screened observables move <=6%
1366 # V stays 5000 V=2000 collapses mean_n_v by 70%
1367 # dose stays 1.0 halving it saves 17%, not 50%
1368 # i_mobile 40 and bins 20 are the author's call; i_mobile is PHYSICS, so
1369 # these rows are NOT comparable with anything run at i_mobile=50.
1370 ap.add_argument("--I", type=int, default=10000)
1371 ap.add_argument("--V", type=int, default=5000)
1372 ap.add_argument("--dose", type=float, default=1.0)
1373 ap.add_argument("--equations", default="discrete",
1374 choices=["discrete", "bin_moment"])
1375 # --- bin_moment layout (ignored unless --equations bin_moment) -----------
1376 # These are NOT constructor kwargs; apply_bin_config() writes them to the
1377 # workbook dict and bin_layout() verifies they landed. Defaults are the
1378 # plan S11(f) rev-6 campaign values, NOT the library defaults -- the library
1379 # default (i_discrete=10, I_bin=6) is the coarse binning plan S10(i) warns
1380 # about, and silently running it is the failure this whole block prevents.
1381 ap.add_argument("--i-discrete", type=int, default=40,
1382 help="max individually-tracked SIA size; must be >= i_mobile")
1383 ap.add_argument("--v-discrete", type=int, default=5,
1384 help="max individually-tracked vacancy size; must be >= v_mobile")
1385 ap.add_argument("--i-bin", type=int, default=20,
1386 help="target SIA bin count; r=(I/i_discrete)^(1/I_bin) is DERIVED")
1387 ap.add_argument("--v-bin", type=int, default=20,
1388 help="target vacancy bin count; r derived the same way")
1389 ap.add_argument("--shape-function", default="linear",
1390 choices=["constant", "linear", "lognormal"],
1391 help="intra-bin closure: P=1/2/3 moments per bin")
1392 # Used only when the DESIGN does not carry an i_mobile / v_mobile column.
1393 # Rev 6 withdraws both from theta (plan S11(f)) precisely because a sampled
1394 # i_mobile would change the bin layout row to row, so under rev 6 these are
1395 # the campaign values, not fallbacks.
1396 # 40, frozen 2026-08-06. This is PHYSICS, not a numerical knob: it is the
1397 # maximum mobile SIA cluster size, and it was a Tier-2 theta with a prior
1398 # before bin_moment required it fixed. Measured at 30 vs 50 it moved
1399 # N_100 +109%, N_111 -18%, d_111 -9%, so rows produced here CANNOT be
1400 # pooled with anything run at i_mobile=50 -- run_cfg_sha enforces that.
1401 ap.add_argument("--i-mobile-default", type=int, default=40)
1402 ap.add_argument("--v-mobile-default", type=int, default=5)
1403 ap.add_argument("--solver-mode", default="active_window",
1404 choices=["active_window", "full_system"],
1405 help="active_window (default) keeps the ACTIVE system at "
1406 "50-200 unknowns regardless of I. Verified 2026-08-02 to "
1407 "reproduce full_system to printed precision at I=1600 "
1408 "(d100/occ/pile/N100 all identical) at 2.4x lower cost "
1409 "and 3 orders better delta_FP. Preconditioner follows "
1410 "this choice automatically.")
1411 ap.add_argument("--rtol", type=float, default=1e-6)
1412 # A BUDGET, NOT A CLIFF, since 2026-08-05 (plan S12(s)). On expiry the
1413 # solver is asked to finalize gracefully and its partial trajectory is KEPT;
1414 # the row then contributes to every rung of the dose ladder it reached. So
1415 # this no longer needs to cover the p99 to avoid losing work -- pick it from
1416 # the cost distribution so the COMMON RUNG lands high. 3600 s is a smoke-
1417 # test default and is far below the ~3130 s median / long tail of the rev-6
1418 # baseline grid; the campaign scripts set it explicitly.
1419 # default None, NOT 3600, so that "the user asked for a budget" is
1420 # distinguishable from "nobody said". Resolution order is
1421 # --timeout-s > machines.json entry > 3600. Without the sentinel the
1422 # registry override could never win, because the notebook always passes
1423 # this flag.
1424 ap.add_argument("--timeout-s", type=float, default=None)
1425 # Same sentinel discipline as --timeout-s: CLI > machines.json > 1. Exists
1426 # so the registry value can be MEASURED (sweep it at --workers 1 and compare
1427 # dose_reached at a fixed budget) instead of declared.
1428 ap.add_argument("--omp-threads", type=int, default=None,
1429 help="OpenMP threads INSIDE each row's solver (active_window "
1430 "mode only). Trades rows-in-flight for per-row speed; "
1431 "cannot change results (no reductions in the kernels).")
1432 ap.add_argument("--limit", type=int, default=0, help="run only the first n rows (smoke test)")
1433 ap.add_argument("--stop-after-s", type=float, default=0.0,
1434 help="stop SUBMITTING new rows after this many seconds and exit "
1435 "cleanly (0 = no limit). For batch schedulers: set it to "
1436 "the job's walltime minus one row's p99, so the worker "
1437 "parks itself before the scheduler SIGKILLs it mid-row. "
1438 "Rows already running are allowed to finish and are "
1439 "written; a resubmission resumes by skipping them.")
1440 ap.add_argument("--allow-mixed", action="store_true",
1441 help="resume even though git/solver/workbook/design changed "
1442 "since the existing rows (see RESTART SAFETY)")
1443 a = ap.parse_args(argv)
1444
1445 # ── who am I? ────────────────────────────────────────────────────────────
1446 if a.register is not None:
1447 register_host(a.register, a.registry)
1448 return 0
1449 detected = None
1450 if str(a.machine).lower() == "auto":
1451 reg = load_registry(a.registry)
1452 detected = detect_machine(reg)
1453 a.machine = int(detected["index"])
1454 a.of = a.of or int(reg["of"])
1455 if a.weights is None:
1456 a.weights = "@" + str(a.registry)
1457 if a.of_subtasks == 1 and detected.get("subtasks"):
1458 a.of_subtasks = int(detected["subtasks"])
1459 # Slots come from the registry too: a machine whose --workers does not
1460 # match the slot count its WEIGHT was computed from will finish out of
1461 # step with everyone else, which is the thing the weights exist to stop.
1462 if a.workers is None and detected.get("slots") is not None:
1463 a.workers = int(detected["slots"])
1464 if a.of_subtasks > 1:
1465 a.workers = max(1, a.workers // a.of_subtasks)
1466 # Per-machine ROW BUDGET. machines.json has carried `timeout_s` on
1467 # entries 1 and 3 since 2026-08-07 with a written rationale, but NOTHING
1468 # READ IT: the notebook passes reg['timeout_s'] -- the GLOBAL value -- to
1469 # --timeout-s for every participant, so those overrides took effect only
1470 # when someone retyped them on the command line, and silently did not
1471 # when they did not. Honour the entry here. An explicit --timeout-s
1472 # still wins (default is None, so "explicit" is detectable), which keeps
1473 # a smoke test able to ask for a short budget.
1474 if a.timeout_s is None:
1475 _to = detected.get("timeout_s", reg.get("timeout_s"))
1476 if _to is not None:
1477 a.timeout_s = float(_to)
1478 # Per-machine INTRA-ROW threads; see the OMP_NUM_THREADS note at the top
1479 # of this file for why this is a handoff variable and why it cannot move
1480 # results. Absent = 1 = the ensemble default, unchanged for everyone.
1481 if a.omp_threads is None:
1482 a.omp_threads = int(detected.get("omp_threads", 1) or 1)
1483 print(f" detected machine {a.machine} = {detected['name']} "
1484 f"({detected.get('speed_source','?')} speed "
1485 f"{detected.get('speed')}), {a.workers} worker(s)"
1486 + (f", subtask {a.subtask}/{a.of_subtasks}" if a.of_subtasks > 1 else "")
1487 + f", {a.omp_threads} OMP thread(s)/row")
1488 else:
1489 a.machine = int(a.machine)
1490 if not a.of:
1491 raise SystemExit("--of is required when --machine is given explicitly")
1492 # Fall back for --machine given explicitly, and for an auto machine whose
1493 # entry carries no override. 3600 s was the argparse default before the
1494 # registry could set it; keeping it here leaves every existing command line
1495 # producing exactly the value it produced before.
1496 if a.timeout_s is None:
1497 a.timeout_s = 3600.0
1498 if a.omp_threads is None:
1499 a.omp_threads = 1
1500 if a.workers is None:
1501 a.workers = max(1, (os.cpu_count() or 4) - 2)
1502 # Set BOTH: OMP_NUM_THREADS is what solver.exe reads, RADCLUSTER_OMP_THREADS
1503 # is what a spawned pool worker re-reads at import to rebuild the former.
1504 os.environ["RADCLUSTER_OMP_THREADS"] = str(a.omp_threads)
1505 os.environ["OMP_NUM_THREADS"] = str(a.omp_threads)
1506 # STOP MEANS STOP NOW, not "after the budget expires". The STOP file
1507 # already told the SUBMIT loop to stop queueing rows, but the rows already
1508 # in flight kept integrating to their full budget -- up to 8 h -- so a
1509 # detached worker could not be wound down promptly and a machine going
1510 # away took every in-flight row with it. cpp_bridge polls this path while
1511 # waiting on the solver and, when it appears, asks the solver to finalize
1512 # at the dose it has reached and flush. Inherited by the pool workers.
1513 os.environ["RADCLUSTER_ABORT_FILE"] = str(STOP_FILE)
1514 print(f" row budget {a.timeout_s:.0f} s, "
1515 f"OMP_NUM_THREADS={os.environ['OMP_NUM_THREADS']}")
1516 if not 0 <= a.subtask < a.of_subtasks:
1517 raise SystemExit(f"--subtask {a.subtask} outside 0..{a.of_subtasks-1}")
1518
1519 spec = json.loads(a.spec.read_text(encoding="utf-8"))
1520 rows, meta = read_design(a.design)
1521
1522 # ---------------------------------------------------- PRIOR-BOX AUDIT --
1523 # Every design reaches the solver through here, so this is the one place a
1524 # value outside its declared prior can be caught. plan.py already clips to
1525 # PRIOR_BOX; HAND-BUILT designs do not, and that is how the campaign came to
1526 # be running loop_net_w_c = 300 (box [1, 200]), gamma_s = 2.33 (box
1527 # [1.7, 2.3]) and phi_max_junc = 0.05 (box [0.1, 1.0]) -- discovered
1528 # 2026-08-20, after those values had already been used to justify a
1529 # "best row". A fit outside its own prior is not a calibration result.
1530 #
1531 # This WARNS rather than refuses: side-studies deliberately probe outside
1532 # the box (the T-series liveness probes did), and a hard stop would break
1533 # them. But it is loud, it names every offender, and it stamps the rows so
1534 # the ledger can see it -- silence was the actual failure.
1535 _box = {p["key"]: (p["lo"], p["hi"]) for p in spec.get("parameters", [])
1536 if p.get("lo") is not None and p.get("hi") is not None}
1537 _viol = {}
1538 for _r in rows:
1539 for _k, (_lo, _hi) in _box.items():
1540 _v = _r.get(_k)
1541 if _v is None or isinstance(_v, str):
1542 continue
1543 if not (_lo <= float(_v) <= _hi):
1544 _viol.setdefault(_k, {"box": (_lo, _hi), "vals": set(),
1545 "rows": []})
1546 _viol[_k]["vals"].add(float(_v))
1547 _viol[_k]["rows"].append(_r.get("row_id"))
1548 if _viol:
1549 print(f" !! PRIOR-BOX VIOLATIONS in {a.design.name}: "
1550 f"{len(_viol)} parameter(s)")
1551 for _k, _d in sorted(_viol.items()):
1552 _vs = ", ".join(f"{v:g}" for v in sorted(_d["vals"]))
1553 print(f" {_k}: {_vs} outside [{_d['box'][0]:g}, "
1554 f"{_d['box'][1]:g}] ({len(_d['rows'])} row(s))")
1555 print(" These rows are RUN, but a result that depends on them is "
1556 "not a calibration inside the declared prior.")
1557 _prior_viol = {k: {"box": list(d["box"]), "values": sorted(d["vals"]),
1558 "rows": d["rows"]} for k, d in _viol.items()}
1559
1560 # ── DUAL-DECLARED PARAMETER AUDIT (2026-08-21) ──────────────────────────
1561 # A key can appear in BOTH spec["parameters"] and spec["fixed"]. apply_theta
1562 # resolves that deterministically -- a sampled value beats the fixed default
1563 # -- but only if the DESIGN actually carries the column. When it does not,
1564 # the fixed value silently wins and the parameter is not calibrated at all,
1565 # while the spec still advertises it as a Tier-3 calibration parameter with
1566 # a prior box.
1567 #
1568 # This is not hypothetical. n_ref_conv and T_star_conv_C were promoted from
1569 # fixed to sampled on 2026-08-14 (spec ids 27, 28) and their `fixed` entries
1570 # were never removed. Exactly ONE design in the repository carries the
1571 # columns (T13_nref.csv), so every other run in the campaign -- every V, W,
1572 # G and L stage -- used n_ref_conv = 50 and T_star_conv_C = 450, the
1573 # PRE-PROMOTION values, against declared nominals of 2982 and a box reaching
1574 # 200. Nothing reported it.
1575 #
1576 # Deliberately NOT auto-resolved: changing which value wins is a physics
1577 # decision (T13 shows n_ref_conv inert over 1908-4294, while T_star -> 330 C
1578 # drives <100> to 4e23 m^-3 and 22 nm), so this names the ambiguity and
1579 # records what was actually in force, and leaves the choice to the author.
1580 _fixed_vals = {f["key"]: f["value"] for f in spec.get("fixed", [])}
1581 _sampled = {p["key"] for p in spec.get("parameters", [])}
1582 _cols = set().union(*(set(r) for r in rows)) if rows else set()
1583 _dual = {}
1584 for _k in sorted(_sampled & set(_fixed_vals)):
1585 _in_design = _k in _cols
1586 _dual[_k] = {"fixed_value": _fixed_vals[_k],
1587 "in_design": _in_design,
1588 "in_force": "design" if _in_design else "fixed"}
1589 _dual_silent = {k: v for k, v in _dual.items() if not v["in_design"]}
1590 if _dual_silent:
1591 print(f" !! DUAL-DECLARED (sampled AND fixed), design carries neither "
1592 f"column: {len(_dual_silent)} parameter(s)")
1593 for _k, _d in sorted(_dual_silent.items()):
1594 _b = next((f"[{p['lo']:g}, {p['hi']:g}]"
1595 for p in spec.get("parameters", [])
1596 if p["key"] == _k and p.get("lo") is not None), "?")
1597 print(f" {_k}: held at FIXED {_d['fixed_value']!r}, "
1598 f"declared prior {_b} -- NOT calibrated in this run.")
1599 conds = (json.loads(a.conditions.read_text(encoding="utf-8"))
1600 if a.conditions.exists() else
1601 {"N2": {"T_K": 573.15}, "N5": {"T_K": 623.15},
1602 "I1": {"T_K": 623.15, "cascade": "fission"}})
1603
1604 weights = None
1605 weights_sha = ""
1606 if a.weights:
1607 if a.weights.startswith("@"):
1608 lay_p = Path(a.weights[1:])
1609 if not lay_p.exists():
1610 raise SystemExit(f"--weights {a.weights}: {lay_p} does not exist. "
1611 f"Generate it with campaign_layout.py and commit "
1612 f"it, so every machine reads the same file.")
1613 lay = json.loads(lay_p.read_text(encoding="utf-8"))
1614 a.weights = lay["weights"]
1615 weights_sha = sha256_file(lay_p)[:16]
1616 if int(lay.get("of", 0)) != a.of:
1617 raise SystemExit(
1618 f"--of {a.of} but {lay_p.name} was built for "
1619 f"of={lay['of']}. They must agree, or the machines disagree "
1620 f"about how many participants exist and rows are both "
1621 f"duplicated and dropped. Re-run campaign_layout.py.")
1622 n_part = len(lay.get("participants") or lay.get("machines") or [])
1623 print(f" weights from {lay_p.name} (sha {weights_sha}): "
1624 f"{n_part} participants")
1625 weights = [float(w) for w in a.weights.split(",")]
1626 if len(weights) != a.of:
1627 raise SystemExit(f"--weights has {len(weights)} entries but --of is "
1628 f"{a.of}; they must match, and the SAME list must be "
1629 f"passed on every machine.")
1630 if min(weights) <= 0:
1631 raise SystemExit("--weights entries must all be > 0.")
1632 mine = [r for r in rows if assign_machine(r["row_id"], a.of, weights) == a.machine]
1633 # SECOND-LEVEL SPLIT, for a machine that runs as a scheduler array job.
1634 # Deterministic and stateless: task t takes every of_subtasks-th row of this
1635 # machine's assignment, in design order. Nothing is shared between tasks,
1636 # so they cannot collide, and a task killed at the walltime limit costs only
1637 # its own in-flight rows. of_subtasks=1 is the identity.
1638 if a.of_subtasks > 1:
1639 n_before = len(mine)
1640 mine = mine[a.subtask::a.of_subtasks]
1641 print(f" subtask {a.subtask}/{a.of_subtasks}: {len(mine)} of this "
1642 f"machine's {n_before} rows")
1643 if a.limit:
1644 mine = mine[:a.limit]
1645 _sfx = f"_machine{a.machine}" + (f"_t{a.subtask}" if a.of_subtasks > 1 else "")
1646 out = a.out or (HERE / "results" / f"{a.design.stem}{_sfx}.jsonl")
1647 out.parent.mkdir(parents=True, exist_ok=True)
1648
1649 # resume: skip row_ids already present
1650 done = set()
1651 prior = []
1652 if out.exists():
1653 for ln in out.read_text(encoding="utf-8").splitlines():
1654 try:
1655 r = json.loads(ln)
1656 except Exception:
1657 continue # truncated last line after a hard kill
1658 done.add(r["row_id"])
1659 prior.append(r)
1660 todo = [r for r in mine if r["row_id"] not in done]
1661
1662 # Provenance only -- the run itself resolves the binary via py_utils.cpp_bridge.
1663 # Must match cpp_bridge's search order, otherwise solver_sha256 records
1664 # 'unavailable' on every non-Windows machine and the whole point of hashing
1665 # the binary (proving four machines ran the SAME solver) is lost.
1666 _build = REPO / "radcluster_code" / "build"
1667 for _cand in (_build / "Release" / "solver.exe", _build / "solver.exe",
1668 _build / "solver"):
1669 if _cand.exists():
1670 solver = _cand
1671 break
1672 else:
1673 solver = _build / "solver"
1674 # RUN CONFIGURATION IS PROVENANCE. I/V/dose/equations/rtol are NOT part of
1675 # theta, so without recording them here nothing in the result row would show
1676 # that the numerics moved between two batches — and a grid change silently
1677 # invalidates comparability far more than most theta changes do. Hashed
1678 # into one short field so it can be compared cheaply on resume, and kept in
1679 # full in the manifest.
1680 # Preconditioner follows the solver mode and is NOT independently selectable.
1681 # CLAUDE.md S9: Woodbury is a full_system optimisation -- its 58-RHS setup
1682 # cost is counterproductive against active_window's 50-200 unknown active
1683 # system, where Jacobi+GMRES converges fine. Pairing them the other way
1684 # round is always a mistake, so the code does not offer the choice.
1685 precond = "Woodbury" if a.solver_mode == "full_system" else "Jacobi"
1686 run_cfg = {"I": a.I, "V": a.V, "dose": a.dose, "equations": a.equations,
1687 "rtol": a.rtol, "atol": 1e-20, "C_floor": 1e-25,
1688 "solver_mode": a.solver_mode, "linsol": "gmres",
1689 "preconditioner": precond, "loop_conversion": 1,
1690 "i_mobile_default": a.i_mobile_default,
1691 "v_mobile_default": a.v_mobile_default,
1692 "n_points": 40, "timeout_s": a.timeout_s,
1693 "lnl": a.lnl}
1694 # run_cfg is BOTH the runtime config handed to evaluate() AND the source of
1695 # run_cfg_sha. timeout_s must stay IN it -- cpp_bridge needs it -- but must
1696 # be left OUT of the hash: it is a wall-clock budget, not a model setting,
1697 # and the budgets have to differ between a Mac and a 4.67x-slower cluster
1698 # node whose h_rt caps a task at 24 h. Hashing it made identical physics
1699 # produce different hashes, which is the false alarm that teaches people to
1700 # ignore the check. Excluded at the hash, not removed from the dict:
1701 # deleting the key made every row die with KeyError: 'timeout_s' (2026-08-05).
1702 SHA_EXCLUDE = {"timeout_s"}
1703 # The bin layout changes the numerics as much as I/V do, so it belongs in
1704 # run_cfg_sha. Added only in bin_moment mode so an existing discrete
1705 # campaign's hash -- and therefore its resume check -- is unchanged.
1706 if a.equations == "bin_moment":
1707 run_cfg.update({"i_discrete": a.i_discrete, "v_discrete": a.v_discrete,
1708 "I_bin": a.i_bin, "V_bin": a.v_bin,
1709 "shape_function": a.shape_function})
1710 if a.i_discrete >= a.I or a.v_discrete >= a.V:
1711 raise SystemExit(
1712 f"bin_moment needs i_discrete < I and v_discrete < V; got "
1713 f"i_discrete={a.i_discrete} I={a.I}, v_discrete={a.v_discrete} "
1714 f"V={a.V}. Otherwise I_bin collapses to 0 and the run is "
1715 f"silently fully discrete.")
1716 # Mobile sizes must be individually resolved -- a mobile cluster living
1717 # inside a bin has no per-size concentration for the coalescence and
1718 # annihilation sums to use.
1719 if a.i_discrete < a.i_mobile_default or a.v_discrete < a.v_mobile_default:
1720 raise SystemExit(
1721 f"bin_moment needs i_discrete >= i_mobile and v_discrete >= "
1722 f"v_mobile; got i_discrete={a.i_discrete} vs i_mobile="
1723 f"{a.i_mobile_default}, v_discrete={a.v_discrete} vs v_mobile="
1724 f"{a.v_mobile_default}.")
1725 # A SAMPLED i_mobile/v_mobile changes i_discrete, hence the bin layout
1726 # and N_eq, from row to row. A variance decomposition across rows that
1727 # do not share a discretisation is not interpretable, which is why plan
1728 # S11(f) withdraws both from theta for the bin_moment campaign. Catch
1729 # the stale-design case here rather than after 1000 rows.
1730 sampled = sorted({k for k in ("i_mobile", "v_mobile")
1731 if any(k in r for r in rows)})
1732 if sampled and not a.allow_mixed:
1733 raise SystemExit(
1734 f"design carries sampled {'/'.join(sampled)} but --equations "
1735 f"bin_moment fixes the bin layout from --i-discrete/--v-discrete. "
1736 f"Rows would not share a discretisation and their indices would "
1737 f"not be comparable (plan S11(f)).\n"
1738 f" Use a design with {'/'.join(sampled)} withdrawn from theta, "
1739 f"or pass --allow-mixed if you have a specific reason.")
1740 run_cfg_sha = hashlib.sha256(json.dumps(
1741 {k: v for k, v in run_cfg.items() if k not in SHA_EXCLUDE},
1742 sort_keys=True).encode()).hexdigest()[:16]
1743 prov = {"git_sha": git_sha(), "machine_id": platform.node(),
1744 "solver_sha256": sha256_file(solver)[:16],
1745 "workbook_sha256": sha256_file(
1746 REPO / "radcluster_code" / "input" / "input_parameters.xlsx")[:16],
1747 "design_sha256": meta.get("design_sha256", "")[:16],
1748 "run_cfg_sha": run_cfg_sha,
1749 # Recorded, not hashed -- resource policy, not physics.
1750 "timeout_s": a.timeout_s, "stop_after_s": a.stop_after_s,
1751 "workers": a.workers,
1752 # Beside workers, because the two together are what a row's wall_s
1753 # has to be read against: 14x1 and 6x2 are the same 12 busy cores
1754 # but a different per-row speed, and comparing walls across them
1755 # without this field is comparing two different experiments.
1756 "omp_threads": a.omp_threads,
1757 # The row->machine map is a function of (of, weights). If two
1758 # machines disagree about it they silently compute overlapping rows
1759 # AND leave a hole, which looks like "some rows missing" at merge
1760 # time rather than like the configuration error it is. Carried per
1761 # row so merge_and_sobol can say which it was.
1762 "weights_sha": weights_sha,
1763 # weights_sha above is sha256 of the machines.json FILE, so it moves
1764 # when anything in that file moves -- timeout_s, a match rule, a
1765 # comment -- none of which change which machine owns which row.
1766 # Setting timeout_s 12000 -> 3600 duly reported a PROVENANCE SPLIT
1767 # between two machines whose partition was verified identical over
1768 # all 1008 rows. A checker that cries wolf is worse than no checker:
1769 # the real split it exists to catch arrives in the same list as the
1770 # noise. So hash the MAP -- (of, weights) and nothing else -- and
1771 # leave the file hash beside it as a record of which file was read.
1772 "weights_map_sha": hashlib.sha256(json.dumps(
1773 {"of": a.of, "weights": weights}, sort_keys=True
1774 ).encode()).hexdigest()[:16],
1775 "of": a.of,
1776 # carried per row (not only in run_cfg_sha) so merge_and_sobol can
1777 # apply the per-mode observable-fidelity restriction without having
1778 # to resolve the hash back to a configuration
1779 "equations": a.equations,
1780 # Carried per row so the ledger can tell a calibration result from
1781 # one that left its own prior box. Empty dict = fully in-prior.
1782 "prior_violations": _prior_viol or None,
1783 # Which dual-declared parameters were actually in force, and
1784 # whether the design or the fixed default supplied the value.
1785 "dual_declared": _dual or None,
1786 "python": platform.python_version()}
1787 print(f"machine {a.machine}/{a.of} rows {len(mine)} "
1788 f"({len(done)} done, {len(todo)} to run) workers {a.workers}")
1789 print(f" provenance {json.dumps(prov)}")
1790
1791 # RESTART SAFETY. Resuming is only a benefit if the new rows are
1792 # comparable to the old ones. If the code, solver or workbook moved since
1793 # the existing rows were written, appending to the same file silently mixes
1794 # two populations into one Sobol estimate. Refuse unless told otherwise.
1795 if prior:
1796 drift = {}
1797 for f in ("git_sha", "solver_sha256", "workbook_sha256", "design_sha256",
1798 "run_cfg_sha"):
1799 old = {r.get(f) for r in prior if r.get(f)}
1800 if old and prov[f] not in old:
1801 drift[f] = (sorted(old), prov[f])
1802 if drift and not a.allow_mixed:
1803 print("\n *** REFUSING TO RESUME - the environment changed since the "
1804 "existing rows were written:")
1805 for f, (old, new) in drift.items():
1806 print(f" {f}: was {old} -> now {new}")
1807 print("\n Those rows are not comparable to the ones this process "
1808 "would produce.")
1809 print(" Choose one:")
1810 print(" - restore the previous state (git checkout / rebuild), or")
1811 print(f" - archive the old results and start clean:")
1812 print(f" mv {out.name} {out.stem}_pre-change.jsonl")
1813 print(" - or, only if you are certain the change cannot affect "
1814 "results\n (e.g. a comment or README edit), re-run with "
1815 "--allow-mixed.")
1816 return 3
1817 if drift:
1818 print(f" WARNING: resuming across an environment change "
1819 f"({', '.join(drift)}) because --allow-mixed was given.")
1820 if meta.get("revision_pending"):
1821 print(f" *** design carries REVISION_PENDING parameters: "
1822 f"{meta['revision_pending']}")
1823
1824 # DERIVED from run_cfg, never rebuilt by hand. These were two separate
1825 # literals -- run_cfg for the provenance hash, cfg for the workers -- and
1826 # they had to be edited in lockstep. Adding solver_mode/preconditioner to
1827 # run_cfg alone made every row die with KeyError: 'solver_mode'. Deriving
1828 # one from the other makes that class of drift impossible, and also
1829 # guarantees that what the workers actually ran is exactly what run_cfg_sha
1830 # hashed -- which is the whole point of the restart-safety check.
1831 cfg = dict(run_cfg)
1832
1833 # GRACEFUL STOP. submit/as_completed rather than ex.map: map() has no way
1834 # to stop feeding work, so a stop request could only be honoured by killing
1835 # the pool, which loses every in-flight row. Here we simply stop SUBMITTING
1836 # once the sentinel appears, let the running rows finish and be written, and
1837 # exit 0. Because rows are appended as they complete and the resume filter
1838 # above skips row_ids already present, a restart picks up exactly where this
1839 # left off -- no row lost, none recomputed.
1840 from concurrent.futures import ProcessPoolExecutor, FIRST_COMPLETED, wait
1841 t0 = time.time()
1842
1843 # LIVE START MARKER. The manifest is written at the END of a run, so while
1844 # a campaign is in flight there is nothing on disk that says when it began
1845 # -- and without a true elapsed, the only throughput a status tool can form
1846 # is sum(row_wall)/workers over the rows that HAVE landed. That is biased
1847 # low exactly when it matters: early, with the slow rows still running and
1848 # contributing nothing to the numerator OR the denominator. Every wrong
1849 # ETA this campaign produced came from that estimator. Six lines here make
1850 # completions-per-hour a division by real wall instead.
1851 started_p = out.with_suffix(".started.json")
1852 started_p.write_text(json.dumps({
1853 "unix": t0, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
1854 "machine": a.machine, "workers": a.workers,
1855 "rows_assigned": len(mine), "rows_already_done": len(mine) - len(todo),
1856 "rows_to_run": len(todo), "machine_id": platform.node(),
1857 }, indent=2), encoding="utf-8")
1858
1859 # A SIGNAL IS A STOP REQUEST, NOT A KILL -- 2026-08-07.
1860 #
1861 # There was a graceful-stop path (the STOP sentinel, below) and no way for a
1862 # signal to reach it, so any signal unwound main() through the pool's
1863 # __exit__ and every in-flight row died unwritten. Measured 2026-08-07 on
1864 # machine 3: interrupting the notebook kernel to leave `ops.watch` delivered
1865 # SIGINT here as well -- `start_new_session=True` at launch is not the
1866 # protection it looks like, because the interrupt was aimed at this pid, not
1867 # at a process group -- and cost 6 in-flight rows, ~11 core-hours, at 89/233
1868 # done. campaign_ops.watch advertised that interrupt as safe.
1869 #
1870 # A signal now takes the SAME path as the sentinel: stop submitting, let the
1871 # running rows finish and be written, exit 0, resume on the next launch.
1872 # The SECOND signal aborts immediately, so a terminal user is never trapped.
1873 #
1874 # Scope: this protects a signal aimed at THIS process. A Ctrl-C in a
1875 # foreground terminal goes to the whole process group, so the solver
1876 # children get it too and the drained rows will carry solver_rc -- the
1877 # sentinel (ops.stop()) is still the clean way to halt a run.
1878 _sig_stop: dict = {}
1879
1880 def _on_signal(signum, _frame):
1881 nm = signal.Signals(signum).name
1882 if _sig_stop:
1883 print(f"\n second {nm} -- aborting NOW; in-flight rows are lost.\n",
1884 flush=True)
1885 signal.signal(signum, signal.SIG_DFL)
1886 os.kill(os.getpid(), signum)
1887 return
1888 _sig_stop["reason"] = f"{nm} (graceful; send again to abort)"
1889 print(f"\n {nm} received -- not submitting further rows; in-flight "
1890 f"rows will finish and be written. Re-run to resume. Send {nm} "
1891 f"again to abort immediately.\n", flush=True)
1892
1893 # getattr, not a bare name: SIGHUP is POSIX-only and does not exist on
1894 # Windows, where referencing it raises AttributeError. It was added bare on
1895 # 2026-08-07 (f097c8d) and took BOTH Windows participants -- machines 1 and
1896 # 2 -- off the campaign at the next relaunch: the traceback fires here, in
1897 # main(), AFTER the provenance line has printed and BEFORE the first row is
1898 # submitted, so a detached worker leaves a log that looks like a healthy
1899 # start and a results file that never grows. Absent signals are skipped;
1900 # SIGINT/SIGTERM still work everywhere.
1901 for _sig in (getattr(signal, n, None) for n in ("SIGINT", "SIGTERM", "SIGHUP")):
1902 if _sig is None:
1903 continue
1904 # NEVER un-ignore a signal. `nohup` and most detached launchers set
1905 # SIGHUP to SIG_IGN precisely so a closing terminal cannot reach the
1906 # job; installing a handler over that would RE-ENABLE delivery and turn
1907 # "log out" into "campaign drains and stops" -- the opposite of what the
1908 # launcher asked for. Only take over signals that are still default.
1909 if signal.getsignal(_sig) is signal.SIG_IGN:
1910 continue
1911 signal.signal(_sig, _on_signal)
1912
1913 n_ok = n_bad = n_inadm = 0
1914 stopped = None
1915 pending = list(todo)
1916 # WRITE BY PATH, NOT BY HELD HANDLE. This used to be
1917 # with ProcessPoolExecutor(...) as ex, out.open("a") as fh:
1918 # holding one file object open for the whole multi-day run. A file handle
1919 # names an INODE, not a path, so anything that replaces the file underneath
1920 # -- and `git pull --rebase --autostash` does exactly that when the results
1921 # file is dirty, which it always is while a worker is running -- leaves the
1922 # worker appending to an unlinked inode that nothing can ever read. It is
1923 # silent: the log keeps printing completed rows, the .jsonl stops growing,
1924 # and the rows are unrecoverable once the process exits (macOS volfs cannot
1925 # open an unlinked inode). Measured 2026-08-06: 5 rows, 6.2 core-hours,
1926 # written into a dead inode over three hours before anyone noticed.
1927 #
1928 # Re-opening per row costs one open()/close() against ~3000 s of solve, and
1929 # makes a swapped file cost nothing: the next row simply appends to whatever
1930 # now lives at that path.
1931 def append_row(rec):
1932 with out.open("a", encoding="utf-8") as fh:
1933 fh.write(json.dumps(rec) + "\n")
1934 fh.flush()
1935 os.fsync(fh.fileno())
1936
1937 with ProcessPoolExecutor(max_workers=a.workers) as ex:
1938
1939 def submit(r):
1940 return ex.submit(evaluate,
1941 (r, spec, conds.get(r["condition"], {"T_K": 623.15}),
1942 cfg))
1943
1944 inflight = set()
1945 while pending and len(inflight) < a.workers:
1946 inflight.add(submit(pending.pop(0)))
1947
1948 while inflight:
1949 finished, inflight = wait(inflight, return_when=FIRST_COMPLETED)
1950 for fut in finished:
1951 rec = fut.result()
1952 rec.update(prov)
1953 append_row(rec)
1954 if rec.get("solver_rc"):
1955 n_bad += 1
1956 elif not rec.get("admissible"):
1957 n_inadm += 1
1958 else:
1959 n_ok += 1
1960 print(f" row {rec['row_id']:6d} {rec['condition']:>4s} "
1961 f"{rec['matrix']:>2s} "
1962 f"{'FAIL' if rec.get('solver_rc') else ('INADM' if not rec.get('admissible') else ' ok ')} "
1963 f"d100={rec.get('d_100_nm', float('nan')):6.2f} "
1964 f"N100={rec.get('N_loops_100', float('nan')):9.3e} "
1965 f"pile={rec.get('pile_100')} "
1966 f"dFP={rec.get('delta_FP', float('nan')):8.1e} "
1967 f"{rec['wall_s']:.0f}s", flush=True)
1968 if stopped is None and _sig_stop:
1969 stopped = dict(_sig_stop)
1970 print(f"\n STOP on signal ({stopped.get('reason')}) -- "
1971 f"{len(inflight)} in flight will finish and be written.\n",
1972 flush=True)
1973 if stopped is None and STOP_FILE.exists():
1974 try:
1975 stopped = json.loads(STOP_FILE.read_text(encoding="utf-8"))
1976 except Exception:
1977 stopped = {"reason": "unreadable STOP file"}
1978 print(f"\n STOP requested ({stopped.get('reason')}) -- not "
1979 f"submitting further rows; {len(inflight)} in flight will "
1980 f"finish and be written.\n", flush=True)
1981 # SELF-IMPOSED DEADLINE, for batch schedulers. A job killed at its
1982 # walltime loses every in-flight row -- with 4 workers that is 4 rows
1983 # of up to an hour each, silently, on every task of an array job.
1984 # Parking one row's-worth of time early costs a fraction of that and
1985 # makes the shortfall visible in the manifest instead. Same path as
1986 # the STOP sentinel: stop SUBMITTING, let the running rows land.
1987 if stopped is None and a.stop_after_s > 0:
1988 el = time.time() - t0
1989 if el >= a.stop_after_s:
1990 stopped = {"reason": f"--stop-after-s {a.stop_after_s:.0f} "
1991 f"reached at {el:.0f}s (scheduler "
1992 f"walltime guard)"}
1993 print(f"\n DEADLINE reached ({el:.0f}s of "
1994 f"{a.stop_after_s:.0f}s) -- not submitting further "
1995 f"rows; {len(inflight)} in flight will finish and be "
1996 f"written. Resubmit to resume.\n", flush=True)
1997 if stopped is None:
1998 while pending and len(inflight) < a.workers:
1999 inflight.add(submit(pending.pop(0)))
2000
2001 # ── run-level manifest ───────────────────────────────────────────────────
2002 # The per-row block carries hashes so a mixed file is detectable; this
2003 # carries the FULL configuration behind those hashes, so a result set is
2004 # interpretable years later without re-deriving anything. Mirrors the
2005 # repo's output/<stamp>/provenance.md convention (CLAUDE.md "Output
2006 # format"), in JSON because it is also read back by campaign_ops.
2007 all_recs = []
2008 if out.exists():
2009 for ln in out.read_text(encoding="utf-8").splitlines():
2010 try:
2011 all_recs.append(json.loads(ln))
2012 except Exception:
2013 pass
2014 ws = [r["wall_s"] for r in all_recs if r.get("wall_s")]
2015 man = {
2016 "schema_version": "1",
2017 "written_at_unix": time.time(),
2018 "written_at": time.strftime("%Y-%m-%d %H:%M:%S"),
2019 "campaign": {"design_file": str(a.design), "design_sha256":
2020 meta.get("design_sha256"), "tier": meta.get("tier"),
2021 "N": meta.get("N"), "p": meta.get("p"),
2022 "conditions": meta.get("conditions"),
2023 "parameters": meta.get("parameters"),
2024 "parameters_version": meta.get("parameters_version"),
2025 "revision_pending": meta.get("revision_pending"),
2026 "rows_total": meta.get("rows_total")},
2027 "machine": {"index": a.machine, "of": a.of,
2028 "weights": weights, "rows_assigned": len(mine),
2029 "machine_id": platform.node(), "platform": platform.platform(),
2030 "python": platform.python_version(),
2031 "cpu_count": os.cpu_count(), "workers": a.workers,
2032 "omp_num_threads": os.environ.get("OMP_NUM_THREADS")},
2033 "code": {"git_sha": prov["git_sha"], "solver_sha256": prov["solver_sha256"],
2034 "solver_path": str(solver),
2035 "workbook_sha256": prov["workbook_sha256"]},
2036 "run_config": run_cfg,
2037 "run_cfg_sha": run_cfg_sha,
2038 "conditions_file": str(a.conditions),
2039 "conditions": conds,
2040 "rows": {"assigned": len(mine), "completed": len(all_recs),
2041 "admissible": sum(1 for r in all_recs
2042 if not r.get("solver_rc") and r.get("admissible")),
2043 "inadmissible": sum(1 for r in all_recs
2044 if not r.get("solver_rc") and not r.get("admissible")),
2045 "failed": sum(1 for r in all_recs if r.get("solver_rc")),
2046 "not_started": len(pending)},
2047 "timing": {"session_wall_s": round(time.time() - t0, 1),
2048 "row_wall_mean_s": (float(np.mean(ws)) if ws else None),
2049 "row_wall_median_s": (float(np.median(ws)) if ws else None),
2050 "row_wall_p90_s": (float(np.percentile(ws, 90)) if ws else None),
2051 "core_hours": (sum(ws) / 3600.0 if ws else 0.0)},
2052 "stopped": stopped,
2053 "admissibility_rule": {
2054 "pile_tol": PILE_TOL, "occ_tol": OCC_TOL,
2055 "delta_FP_tol": DFP_TOL,
2056 "pile_top_frac": PILE_TOP_FRAC,
2057 "axes": ["sia_111", "sia_100", "vacancy"],
2058 "note": "delta_FP is blind to SIA-axis truncation (the <100> pile-up "
2059 "held delta_FP at 1e-8 while 96% of content sat at n=I), so "
2060 "grid adequacy is judged by pile/occupancy. It is NOT blind "
2061 "to the VACANCY axis -- delta_FP rests on the swelling "
2062 "identity S = S_I + Delta J^d and S is the vacancy "
2063 "inventory. The vacancy axis was unchecked until 2026-08-02; "
2064 "at the check_machine probe (V=120) mean_n_v moved 18.7->136 "
2065 "and swelling 58x on going to V=240."},
2066 }
2067 man_p = out.with_suffix(".manifest.json")
2068 man_p.write_text(json.dumps(man, indent=2), encoding="utf-8")
2069
2070 tag = "STOPPED" if stopped else "done"
2071 print(f"\n{tag} in {time.time()-t0:.0f}s: {n_ok} admissible, "
2072 f"{n_inadm} inadmissible, {n_bad} failed -> {out}")
2073 print(f" manifest -> {man_p.name}")
2074 if stopped:
2075 print(f" {len(pending)} row(s) were never started and remain assigned "
2076 f"to this machine.")
2077 reason = str(stopped.get("reason", ""))
2078 if "walltime guard" in reason or "graceful; send again" in reason:
2079 # No sentinel was written, so telling the operator to clear one sends
2080 # them looking for a file that does not exist -- and on a cluster
2081 # this message is read by whoever is debugging a resubmission loop.
2082 what = ("a signal" if "graceful; send again" in reason
2083 else "the --stop-after-s deadline")
2084 print(f" No STOP flag was set: this was {what}. "
2085 "Resubmit the SAME command to resume; completed rows are "
2086 "skipped automatically.")
2087 else:
2088 print(" Clear the flag (campaign_ops.clear_stop()) and re-run the same "
2089 "command to resume; completed rows are skipped automatically.")
2090 return 0
2091
2092
2093if __name__ == "__main__":
2094 raise SystemExit(main())
main(argv=None)
None register_host(int index, Path path=None)
_weighted_pattern(of, weights)
per_size_populations(res, sim, cfg, idx=-1)
apply_bin_config(sim, cfg)
detect_machine(dict reg, dict facts=None)
observe(res, sim, cfg, d_min_nm)
str sha256_file(Path p)
bin_layout(sim, cfg)
apply_theta(sim, dict spec, dict row, dict cond)
bool _matches(dict rule, dict f)
tuple[list[dict], dict] read_design(Path path)
dict load_registry(Path path=None)
assign_machine(row_id, of, weights=None)