RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
check_machine.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""Run this on EVERY machine before it joins the campaign.
3
4 python check_machine.py # compare against the committed reference
5 python check_machine.py --write # (re)generate the reference, once, on one machine
6
7Why this exists
8---------------
9The C++ solver is NOT in git -- `build/` is ignored, so every machine compiles
10its own from cpp_utils/. Four machines with different compiler or SUNDIALS
11versions produce numerically different results while every hash in the
12provenance block still looks plausible, and merge_and_sobol would then mix them
13into one Sobol estimate. That is the same class of error as the grid
14saturation this project already hit: a believable number with no validity.
15
16So: pin one fixed configuration, run it, and compare against a reference
17committed from a known-good machine. It takes ~30 s and it is the difference
18between "the four machines agree" and "the four machines were assumed to
19agree".
20
21The check deliberately uses a SMALL, FAST configuration. It is verifying that
22the toolchain agrees, not that the physics is right -- the run is expected to be
23inadmissible (the grid is far too coarse), and that is fine.
24
25Tolerance: 1e-6 relative -- the campaign's own integration rtol. CVODE with
26fixed tolerances is bit-deterministic for a FIXED BINARY, so an earlier 1e-9
27bar was right as long as every machine ran the same build. It is unreachable
28across ARCHITECTURES: Windows/x86 (MSVC + MKL, /arch:AVX2) and macOS/arm64
29(AppleClang + Accelerate, -march=native) reassociate and vectorise the same
30reductions differently, which shows up as ~1e-7 drift on the CVODE-integrated
31fields while the closed-form fields (Di_eff, Dv_eff, conv_*) still match to
320.0 exactly. Holding the gate tighter than the integrator's own accuracy
33rejects machines whose physics is identical, so the bar is set AT the
34integration tolerance: anything above it is a real discrepancy, anything below
35it is beneath the resolution the solver claims in the first place.
36OMP_NUM_THREADS is pinned to 1 so reduction order cannot vary within a build.
37"""
38from __future__ import annotations
39
40import argparse
41import hashlib
42import io
43import json
44import os
45import platform
46import subprocess
47import sys
48from pathlib import Path
49
50os.environ["OMP_NUM_THREADS"] = "1"
51
52HERE = Path(__file__).resolve().parent
53REPO = HERE.parent.parent
54sys.path.insert(0, str(REPO))
55REF = HERE / "machine_reference.json"
56
57# Fixed probe configuration -- do not change without regenerating the reference
58# on a known-good machine and saying so in the commit message.
59# solver_mode is part of the probe: it must match what the campaign actually
60# runs, or the agreement check validates a code path nothing uses.
61#
62# V=480, not 120. At V=120 this probe reported mean_n_v = 18.7 against a
63# converged 153.3, N_voids 10.6x low, and delta_FP = 4.1e-3 -- above CLAUDE.md
64# S8's "coding error" threshold. The vacancy grid was truncating: mean cavity
65# size is ~153 vacancies, so V=120 could not hold a typical cavity at all.
66# delta_FP falls to 1.4e-7 at V=480 and is unchanged at V=960 (2026-08-02).
67# I=300, not 150, for the same reason on the other axis. At I=150 delta_FP is
68# 4-5e-3 REGARDLESS of V, because mean_n_111 = 35.9 on a 150-point grid is an
69# occupancy of 0.24 and the 1/2<111> population itself truncates. Note the
70# asymmetry with the <100> pile-up, where delta_FP stayed at 1e-8: <100> is
71# sessile and one-way, so its growth guard halts without losing atoms, whereas
72# truncating the mobile 1/2<111> population does lose them.
73# I=300, V=480 gives delta_FP = 1.4e-7 in ~35 s.
74PROBE = dict(I=300, V=480, i_mobile=10, v_mobile=5, T_K=623.15, dose=0.02,
75 rho_d=1.0e14, E_a0_conv=2.2, dH2_abs_conv=0.30, psucc_abs_pref=2.0,
76 gamma_a_conv=0.02, phi_max_junc=0.0, f_cl_i=0.25, f_cl_v=0.05,
77 rtol=1e-6, solver_mode="active_window")
78
79# Preconditioner follows solver_mode -- never selected independently (CLAUDE.md S9).
80PRECOND = {"full_system": "Woodbury", "active_window": "Jacobi"}
81
82# Quantities compared. Mixed scales on purpose: a state variable, a derived
83# rate, an integrated observable and a conservation residual, so a build
84# difference cannot hide in a corner of the model.
85FIELDS = ["Di_eff", "Dv_eff", "conv_psuccess", "conv_psuccess_abs",
86 "N_loops_100", "N_loops_111", "mean_n_100", "mean_n_111",
87 "C_SIA_tot", "C_VAC_tot", "swelling", "delta_FP"]
88RTOL = 1e-6 # = PROBE["rtol"]; see module docstring -- the gate cannot be
89 # tighter than the integration tolerance it is checking
90
91# PER-FIELD ABSOLUTE FLOOR. A pure relative comparison is meaningless for a
92# quantity that is legitimately near zero, and two of the FIELDS above are:
93# delta_FP is a conservation RESIDUAL (reference 1.4e-07) and conv_psuccess is a
94# small probability (2.2e-06).
95#
96# This is not hypothetical. Re-running three MATRIX-PC2 design rows on the Mac
97# (plan S11(l)) gave delta_FP agreeing to 6.9e-13 ABSOLUTE while differing by
98# 1.5e-05 RELATIVE -- 15x past RTOL. The same comparison put pile_100 at 54 %
99# relative disagreement between two values that were both ~1e-9 against a 0.05
100# bar. Judged relatively, a build in excellent agreement looks broken; a naive
101# max-relative-deviation over all quantities returns "NOT POOLABLE" from data
102# that agrees to 3e-8 on everything that matters.
103#
104# So each entry is "the largest ABSOLUTE difference that provably cannot change
105# a downstream decision", derived from the bar that field is judged against:
106# delta_FP admissibility bar is 1e-2 (run_ensemble.DFP_TOL); 1e-9 is
107# seven orders below it, so it cannot flip any row's verdict,
108# while a real 10x shift (1.4e-7 -> 1.4e-6) is still caught.
109# conv_psuccess a probability feeding N_100 multiplicatively; 1e-12 is six
110# orders below the reference value.
111# Everything else is a physical observable at its natural scale: no floor, pure
112# relative, unchanged behaviour.
113ATOL = {"delta_FP": 1e-9, "conv_psuccess": 1e-12}
114
115
116def sha256_file(p: Path) -> str:
117 try:
118 return hashlib.sha256(Path(p).read_bytes()).hexdigest()
119 except Exception:
120 return "unavailable"
121
122
123def probe() -> dict:
124 from radcluster_code.py_utils.simulation import RadClusterSimulation
125 import numpy as np
126 _s = sys.stdout, sys.stderr
127 buf = io.StringIO()
128 try:
129 sys.stdout = sys.stderr = buf
130 sim = RadClusterSimulation(I=PROBE["I"], V=PROBE["V"],
131 solver_mode=PROBE["solver_mode"],
132 equations="discrete", cascade="fission",
133 C_floor=1e-25,
134 he_kinetics="quasi_steady_state",
135 i_mobile=PROBE["i_mobile"],
136 v_mobile=PROBE["v_mobile"])
137 d = sim.input_data
138 for k in ("T", "rho_d", "E_a0_conv", "dH2_abs_conv", "psucc_abs_pref",
139 "gamma_a_conv", "phi_max_junc"):
140 key = "T" if k == "T" else k
141 d.reactions[key] = PROBE["T_K"] if k == "T" else PROBE[k]
142 d.production_fission["f_cl_i"] = PROBE["f_cl_i"]
143 d.production_fission["f_cl_v"] = PROBE["f_cl_v"]
144 d._calculate_derived()
145 sim.rebuild_rates()
146 rr = sim.reaction_rates
147 G = float(d.reactions["G"])
148 res = sim.run(solver_config={
149 "t_span": (1e-6, PROBE["dose"] / G), "n_points": 12,
150 "log_time": True, "rtol": PROBE["rtol"], "atol": 1e-20,
151 "timeout_s": 900,
152 "solver_method": {"linsol": "gmres",
153 "preconditioner": PRECOND[PROBE["solver_mode"]],
154 "concentration_threshold": 1e-22},
155 "loop_conversion": 1}, save_output=False)
156 finally:
157 sys.stdout, sys.stderr = _s
158
159 out = {"Di_eff": float(d.derived["Di_eff"]),
160 "Dv_eff": float(d.derived["Dv_eff"]),
161 "conv_psuccess": float(rr.conv_psuccess),
162 "conv_psuccess_abs": float(rr.conv_psuccess_abs)}
163 for k in FIELDS:
164 if k in out:
165 continue
166 v = res.get(k)
167 out[k] = float(np.asarray(v, float).ravel()[-1]) if v is not None else None
168 return out
169
170
171def env() -> dict:
172 solver = REPO / "radcluster_code" / "build" / "Release" / "solver.exe"
173 if not solver.exists():
174 solver = REPO / "radcluster_code" / "build" / "solver"
175 try:
176 gs = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=str(REPO),
177 stderr=subprocess.DEVNULL, text=True).strip()
178 except Exception:
179 gs = "unknown"
180 return {"machine_id": platform.node(), "platform": platform.platform(),
181 "python": platform.python_version(), "git_sha": gs,
182 "solver_sha256": sha256_file(solver),
183 "solver_exists": solver.exists(),
184 "workbook_sha256": sha256_file(
185 REPO / "radcluster_code" / "input" / "input_parameters.xlsx")}
186
187
188def main(argv=None):
189 ap = argparse.ArgumentParser(description=__doc__,
190 formatter_class=argparse.RawDescriptionHelpFormatter)
191 ap.add_argument("--write", action="store_true",
192 help="generate the reference (run once, on a known-good machine)")
193 a = ap.parse_args(argv)
194
195 e = env()
196 print(f"machine {e['machine_id']} ({e['platform']})")
197 print(f"python {e['python']}")
198 print(f"git {e['git_sha'][:12]}")
199 print(f"solver {e['solver_sha256'][:16]} exists={e['solver_exists']}")
200 print(f"workbook {e['workbook_sha256'][:16]}")
201 if not e["solver_exists"]:
202 print("\n solver binary NOT FOUND. Build it first:")
203 print(" cd radcluster_code && cmake -S cpp_utils -B build && "
204 "cmake --build build --config Release")
205 return 2
206
207 print("\nrunning probe (I=150, 0.02 dpa, ~30 s) ...")
208 got = probe()
209
210 if a.write:
211 REF.write_text(json.dumps({"probe": PROBE, "fields": got,
212 "generated_on": e}, indent=2),
213 encoding="utf-8")
214 print(f"\n wrote {REF.name} from this machine. Commit it, and have every")
215 print(" other machine run `python check_machine.py` before joining.")
216 for k in FIELDS:
217 print(f" {k:20s} {got[k]!r}")
218 return 0
219
220 if not REF.exists():
221 print(f"\n no {REF.name} committed yet. Generate it on the machine you")
222 print(" trust with: python check_machine.py --write")
223 return 2
224
225 ref = json.loads(REF.read_text(encoding="utf-8"))
226 if ref["probe"] != PROBE:
227 print("\n *** the probe configuration has changed since the reference "
228 "was written; regenerate it.")
229 return 2
230
231 print(f"\n reference generated on {ref['generated_on']['machine_id']} "
232 f"(git {ref['generated_on']['git_sha'][:12]}, "
233 f"solver {ref['generated_on']['solver_sha256'][:16]})")
234 if ref["generated_on"]["git_sha"] != e["git_sha"]:
235 print(" note: git SHA differs from the reference machine - pull first "
236 "if that is not intentional.")
237
238 bad = []
239 print(f"\n {'field':20s} {'this machine':>16s} {'reference':>16s} {'rel diff':>11s}")
240 for k in FIELDS:
241 g, r = got.get(k), ref["fields"].get(k)
242 if g is None or r is None:
243 print(f" {k:20s} {'None' if g is None else g:>16} "
244 f"{'None' if r is None else r:>16} (skipped)")
245 continue
246 den = max(abs(r), 1e-300)
247 rel = abs(g - r) / den
248 atol = ATOL.get(k, 0.0)
249 ok = abs(g - r) <= RTOL * abs(r) + atol
250 # Say so when a field passes ONLY because of its absolute floor. The
251 # floor exists to stop noise-level differences failing the gate, not to
252 # hide them -- if delta_FP starts riding its floor on a new machine that
253 # is worth seeing, even though it is not a failure.
254 if ok and rel > RTOL:
255 flag = f" (within atol {atol:g})"
256 elif ok:
257 flag = ""
258 else:
259 flag = " <-- MISMATCH"
260 bad.append((k, g, r, rel))
261 print(f" {k:20s} {g:16.9e} {r:16.9e} {rel:11.2e}{flag}")
262
263 if bad:
264 print(f"\n *** {len(bad)} field(s) exceed rtol={RTOL:g}.")
265 print(" This machine does NOT reproduce the reference. Its results")
266 print(" cannot be pooled with the others in one Sobol estimate.")
267 print(" Most likely a different SUNDIALS/compiler version - compare")
268 print(" solver_sha256 above, rebuild from the same commit, re-check.")
269 return 1
270 print(f"\n all {len(FIELDS)} fields agree within {RTOL:g}.")
271 print(" This machine may join the campaign.")
272 return 0
273
274
275if __name__ == "__main__":
276 raise SystemExit(main())
str sha256_file(Path p)
main(argv=None)