RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
merge_and_sobol.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""merge_and_sobol - concatenate the machines' results and estimate S_i, S_i^T.
3
4 python merge_and_sobol.py --design design/T2_design_v1.csv \
5 --results results/ --out report/
6
7Merging is plain concatenation keyed on row_id, so it is order-independent and
8idempotent: re-running after a machine catches up simply adds the new rows.
9
10THE SUBTLETY THAT MAKES THIS FILE WORTH READING
11-----------------------------------------------
12Saltelli estimators are PAIRWISE. For parameter j and base index i they need
13the triple ( f(A_i), f(B_i), f(AB_i^(j)) ). The plan says "discard and flag"
14failed runs, which is right for the physics but incomplete here:
15
16 * dropping a row globally biases EVERY index, because the A_i and B_i rows are
17 shared across all p parameters;
18 * dropping nothing and imputing is worse.
19
20The correct handling is PAIRWISE DELETION: if AB_i^(j) is unusable, base index i
21is excluded from parameter j's estimator ONLY. If A_i or B_i is unusable, base
22index i is excluded from all p estimators (they genuinely share those rows).
23Every index therefore has its own effective sample size, reported as n_eff --
24an index computed from 3 of 16 base points is not comparable to one computed
25from 16 and must not be read as a screening result.
26
27"Unusable" means failed OR inadmissible. An inadmissible row (grid-limited or
28dose-starved) ran to completion and conserves, but its observables are an
29artefact of the numerics; feeding it to the estimator measures the grid, not
30the physics. See run_ensemble.py for why delta_FP cannot detect this.
31"""
32from __future__ import annotations
33
34import argparse
35import json
36from collections import defaultdict
37from pathlib import Path
38
39import numpy as np
40
41HERE = Path(__file__).resolve().parent
42
43# f_100_tem is emitted at every cutoff in the parameters.json d_min sweep, so the
44# convention spread shows up as the spread ACROSS these columns rather than being
45# buried in a variance decomposition (d_min is post-processing, not a design axis).
46# SWELLING IS WITHDRAWN AS AN OBSERVABLE (author, 2026-08-02). Two reasons,
47# the second the stronger:
48# (a) in ferritic/martensitic steels swelling is small and not a quantity the
49# campaign needs to reproduce accurately; and
50# (b) it carries NO INDEPENDENT INFORMATION. S_inventory is the vacancy
51# content, i.e. N_voids x mean_n_v exactly -- verified numerically:
52# (1-0.115)(1-0.269) = 0.647, reproducing the measured 35.3 % bin_moment
53# error to three digits. Feeding a deterministic function of two other
54# observables into a variance decomposition double-counts the void
55# channel and inflates the apparent number of constraints on theta.
56# It is still EMITTED by run_ensemble (free, and a useful health check); it is
57# simply not screened or calibrated on. d_cavity_nm/mean_n_v replace it so the
58# void channel keeps its size information rather than becoming density-only.
59OBSERVABLES = ["N_loops_100", "d_100_nm", "f_100_number", "f_100_content",
60 "f_100_tem_0p8", "f_100_tem_1", "f_100_tem_1p25", "f_100_tem_1p5",
61 "N_loops_111", "d_111_nm", "N_voids", "d_cavity_nm"]
62
63# ── OBSERVABLE FIDELITY BY EQUATIONS MODE ────────────────────────────────────
64# An observable is only screenable in a mode where it has been VALIDATED against
65# a converged discrete reference. This is not bookkeeping: an index computed
66# from an observable the closure gets wrong is a confident number about the
67# numerics, not the physics — the same failure mode as the grid saturation that
68# delta_FP could not see.
69#
70# bin_moment: the <100> block is a SESSILE, ONE-WAY population — born small by
71# conversion, then advected upward in size space with no back-diffusion. Moment
72# closures suffer numerical diffusion on sharply-peaked advecting distributions,
73# which broadens the reconstruction and biases the mean UP. Measured 2026-08-02
74# against a converged discrete reference (pile = 0.0000, delta_FP = 3.5e-07):
75# mean_n_100 648.0 vs 265.2, +144 %, and the error GREW with refinement of the
76# comparison (+58 % at the coarser setting). The 1/2<111> block is unaffected
77# (mean_n_111 within 6-7 % in two independent comparisons) because it is
78# populated across its whole range rather than peaked and advecting.
79#
80# Author's decision 2026-08-02: if the <100> closure problem persists, screen
81# with bin_moment on the 1/2<111>/void observables only. Encoded here so the
82# restriction is enforced by the tooling rather than remembered.
83BIN_MOMENT_BLOCKED = {"N_loops_100", "d_100_nm", "f_100_number", "f_100_content",
84 "f_100_tem_0p8", "f_100_tem_1", "f_100_tem_1p25",
85 "f_100_tem_1p5"}
86
87
88def observables_for_mode(equations: str, blocked_override=None) -> tuple[list, set]:
89 """Observables screenable in this equations mode, and those withheld."""
90 if equations != "bin_moment":
91 return list(OBSERVABLES), set()
92 blocked = set(blocked_override if blocked_override is not None
93 else BIN_MOMENT_BLOCKED)
94 return [o for o in OBSERVABLES if o not in blocked], blocked
95
96
97def equations_mode_of(recs: dict) -> str | None:
98 """What mode produced these rows? Read from the manifest-backed rows."""
99 modes = {r.get("equations") for r in recs.values() if r.get("equations")}
100 if len(modes) == 1:
101 return modes.pop()
102 return None if not modes else "MIXED"
103
104
105def _same_design(row_sha, design_sha) -> bool:
106 """Compare design hashes that may be stored at different lengths.
107
108 The rows carry a 16-char prefix (run_ensemble truncates for compactness)
109 while design/<name>.meta.json holds the full 64-char digest. A naive ==
110 is therefore always False, which silently rejects EVERY row -- the
111 filter's failure mode is to discard the whole campaign rather than to let
112 a stale one through, so it must be prefix-aware.
113 """
114 if not design_sha or not row_sha:
115 return True
116 n = min(len(row_sha), len(design_sha))
117 return row_sha[:n] == design_sha[:n]
118
119
120def load_results(res_dir: Path, design_sha: str | None = None) -> dict[int, dict]:
121 """Rows from results/*.jsonl, restricted to ONE design when design_sha is
122 given -- and it always should be from a CLI that knows the design.
123
124 row_id is unique within a design, NOT across designs. A superseded
125 campaign's file left in results/ therefore does not merely add noise: its
126 row 25 overwrites the current design's row 25, and the Sobol estimator
127 then attributes that value to a completely different theta. check_provenance
128 would report the design_sha256 split, but only as a printed warning after
129 the rows had already been merged. Filtering here makes it structural.
130 """
131 recs: dict[int, dict] = {}
132 files = sorted(res_dir.glob("*.jsonl"))
133 if not files:
134 raise SystemExit(f"no .jsonl files in {res_dir}")
135 dup = other = 0
136 for f in files:
137 for ln in f.read_text(encoding="utf-8").splitlines():
138 if not ln.strip():
139 continue
140 try:
141 r = json.loads(ln)
142 except json.JSONDecodeError:
143 continue # truncated final line after a crash
144 if design_sha and not _same_design(r.get("design_sha256"), design_sha):
145 other += 1
146 continue
147 rid = r["row_id"]
148 if rid in recs:
149 dup += 1
150 # keep the later record: a resumed run supersedes a crashed one
151 recs[rid] = r
152 print(f" loaded {len(recs)} unique rows from {len(files)} file(s)"
153 + (f" ({dup} duplicate row_id(s) superseded)" if dup else "")
154 + (f" ({other} row(s) from a DIFFERENT design ignored)" if other else ""))
155 if not recs:
156 raise SystemExit(f"no rows in {res_dir} match design_sha256={design_sha}")
157 return recs
158
159
160def check_provenance(recs: dict[int, dict]) -> None:
161 """Four machines must have run the SAME code against the SAME design."""
162 # weights_sha / of describe the row->machine MAP rather than the physics.
163 # A split on those is a different and nastier failure: the machines did not
164 # partition the design, so some rows were computed twice and others by
165 # nobody. That shows up at merge time as "rows MISSING", which reads like a
166 # machine that never reported rather than the misconfiguration it is.
167 # run_cfg_sha covers I, V, dose, equations, rtol, solver_mode, the bin
168 # layout and the mobility defaults -- i.e. THE GRID. Its absence here was a
169 # hole: every other hash could match while two machines ran different grids,
170 # because the grid arrives on the command line and nothing compared it.
171 # It excludes timeout_s/stop_after_s/workers by construction, which must
172 # differ between a Mac and a 4.67x-slower cluster node (plan S12(r)).
173 #
174 # TWO CLASSES OF FIELD, and conflating them made this check useless.
175 #
176 # BLOCKING ones must agree or the rows cannot be pooled.
177 #
178 # ADVISORY ones cannot agree across a heterogeneous fleet, by construction:
179 # solver_sha256 - each machine COMPILES ITS OWN binary (it is not in
180 # git), so the bytes differ between a Mac and a PC even
181 # from identical source. It split in the T2 campaign
182 # too (MATRIX-PC2 6d878b01 vs Mac 9366e576). What
183 # actually establishes numerical equivalence is
184 # check_machine.py's 12-field agreement gate, not a
185 # hash of the executable.
186 # weights_sha - sha256 of the machines.json FILE, so editing
187 # timeout_s moves it while the row->machine map is
188 # untouched. weights_map_sha is the blocking version.
189 # git_sha - a machine that started before a Python-only commit is
190 # not thereby incomparable. Whether the diff matters is
191 # a judgement about the diff; the tool reports it and
192 # says what to look at.
193 #
194 # A checker that reports all seven as fatal trains you to ignore it, and
195 # the one real split then arrives in the same list as the noise.
196 BLOCKING = ("workbook_sha256", "design_sha256", "run_cfg_sha",
197 "weights_map_sha", "of")
198 ADVISORY = ("git_sha", "solver_sha256", "weights_sha")
199
200 def _split(field):
201 vals = defaultdict(list)
202 for r in recs.values():
203 vals[r.get(field, "missing")].append(r.get("machine_id", "?"))
204 return vals if len(vals) > 1 else None
205
206 for field in BLOCKING:
207 # A field added mid-campaign is absent from the earlier rows. Comparing
208 # "missing" against a real hash would report a split that is only the
209 # field's own age -- and it would fire on the very rows the new field
210 # was added to protect. Say so instead.
211 absent = sum(1 for r in recs.values() if field not in r)
212 if absent and absent < len(recs):
213 print(f" note: {field} absent from {absent}/{len(recs)} rows "
214 f"(predate the field); not compared")
215 continue
216 vals = _split(field)
217 if vals:
218 print(f" *** PROVENANCE SPLIT on {field}: results are NOT "
219 f"comparable across machines")
220 for v, ms in vals.items():
221 print(f" {v} <- {sorted(set(ms))}")
222
223 notes = [(f, _split(f)) for f in ADVISORY]
224 notes = [(f, v) for f, v in notes if v]
225 for field, vals in notes:
226 print(f" note: {field} differs across machines "
227 + ("(expected - each machine builds its own solver; "
228 "check_machine.py is the real gate)" if field == "solver_sha256"
229 else "(file hash, not the row->machine map)"
230 if field == "weights_sha"
231 else "(check whether the diff can move results)"))
232 for v, ms in vals.items():
233 print(f" {v} <- {sorted(set(ms))}")
234
235
236# Admissibility is applied HERE, not in the worker. The worker records every
237# gate quantity per row; this module decides what passes. That separation is
238# the whole reason the 2026-08-03 re-scoring of the stopped v2 campaign cost no
239# CPU: moving delta_FP from 1e-6 to 1e-3 took its admissible count from 12 to 38
240# without re-running a single row (plan S11(h)). Never move a threshold into
241# the worker -- it makes every past row unusable at the new threshold.
242DFP_TOL_DEFAULT = 1e-2 # plan S11(h)
243
244
245PILE_TOL = 0.05
246TOPBIN_TOL = 0.02
247
248
249def grid_ok(r: dict):
250 """Re-derive the grid verdict from the recorded pile / top-bin quantities.
251
252 Deliberately does NOT trust the row's stored `grid_limited`. Rows written
253 before 2026-08-03 had the withdrawn `occ > 0.10` rule folded into that flag,
254 and on the stopped v2 campaign it alone condemned 71 of 275 rows that show
255 no measurable truncation (plan S11(c)-2). Recomputing here is what lets a
256 superseded scoring rule be undone without re-running anything.
257
258 Returns (ok, reason). reason is None when ok.
259 """
260 if r.get("starved"):
261 return False, "dose-starved"
262 piles = {k: r.get(k) for k in ("pile_111", "pile_100", "pile_v")}
263 # Fail closed: an axis we could not measure is not an axis we certified.
264 missing = [k for k, v in piles.items() if v is None]
265 if missing:
266 return False, f"unmeasured:{','.join(sorted(missing))}"
267 hot = [k for k, v in piles.items() if v > PILE_TOL]
268 if hot:
269 return False, f"pile>{PILE_TOL:g}:{','.join(sorted(hot))}"
270 if r.get("equations") == "bin_moment":
271 tb = {k: r.get(f"topbin_{k}") for k in ("111", "100", "v")}
272 miss_tb = [k for k, v in tb.items() if v is None]
273 if miss_tb or r.get("unmeasured_gates"):
274 return False, "unmeasured:topbin"
275 hot_tb = [k for k, v in tb.items() if v > TOPBIN_TOL]
276 if hot_tb:
277 return False, f"topbin>{TOPBIN_TOL:g}:{','.join(sorted(hot_tb))}"
278 return True, None
279
280
281def obs_value(r: dict, observable: str, at_dose=None):
282 """The observable, read either at end-of-run or off the dose ladder.
283
284 at_dose=None reads the end of the trajectory -- correct only when every row
285 ran to the same dose. Passing a rung reads `at_dose[rung]`, which is what
286 makes a timed-out row comparable with a complete one instead of merely
287 present alongside it.
288 """
289 if at_dose is None:
290 return r.get(observable)
291 lad = r.get("at_dose") or {}
292 cell = lad.get(f"{at_dose:g}")
293 return None if cell is None else cell.get(observable)
294
295
296def dose_ladder_coverage(recs, design_row_ids):
297 """rung -> how many of the design's rows reached it. Picks the common dose."""
298 out = {}
299 for rid in design_row_ids:
300 r = recs.get(rid)
301 if not r or r.get("solver_rc"):
302 continue
303 for rung in (r.get("at_dose") or {}):
304 out[rung] = out.get(rung, 0) + 1
305 return dict(sorted(out.items(), key=lambda kv: float(kv[0])))
306
307
308def usable(r: dict, dfp_tol: float = DFP_TOL_DEFAULT,
309 require_converged: bool = False, at_dose=None) -> bool:
310 """Does this row enter a Sobol index?
311
312 DEFAULT POLICY since 2026-08-05 (author, plan S11(q)): a row is usable if it
313 ran and reached the dose. Truncation of the size tail does not disqualify
314 it, because the campaign estimates a RANKING of parameters, not an absolute
315 observable, and the ranking tolerates a truncated tail.
316
317 require_converged=True restores the pre-2026-08-05 rule (grid_ok + delta_FP).
318 Keep it working: re-estimating the indices both ways is the cheap empirical
319 answer to "did truncation change the ranking", and it costs no CPU because
320 every gate quantity is still recorded per row. See --require-converged.
321 """
322 if not r or r.get("solver_rc"):
323 return False
324 if at_dose is not None:
325 # A timed-out row is not rejected -- it is simply absent from the rungs
326 # it never reached, pairwise, exactly like a missing AB row.
327 if f"{at_dose:g}" not in (r.get("at_dose") or {}):
328 return False
329 elif r.get("starved"):
330 # No common dose was requested, so this row's observables sit at a
331 # DIFFERENT dose from everyone else's. Refuse rather than silently
332 # pool doses; --at-dose is the way to include it (plan S12(s)).
333 return False
334 if not require_converged:
335 return True
336 # Rows too old to carry the gate quantities cannot be re-scored; fall back
337 # to the worker's verdict rather than guessing, and let the caller report
338 # them separately instead of silently pooling two scoring rules.
339 if r.get("pile_100") is None and r.get("grid_limited") is None:
340 return bool(r.get("admissible"))
341 ok, _ = grid_ok(r)
342 if not ok:
343 return False
344 dfp = r.get("delta_FP")
345 return dfp is not None and abs(dfp) < dfp_tol
346
347
348def sobol_indices(recs, design_rows, p_keys, observable, n_boot=200, seed=0,
349 dfp_tol=None, require_converged=False, at_dose=None):
350 """Saltelli/Jansen estimators with pairwise deletion.
351
352 S_j = mean_i f(B_i) [ f(AB_i^j) - f(A_i) ] / V (Saltelli 2010)
353 S_T_j = mean_i [ f(A_i) - f(AB_i^j) ]^2 / (2 V) (Jansen 1999)
354 """
355 # index the design: (base_idx, matrix, param_j) -> row_id
356 idx = {}
357 for d in design_rows:
358 idx[(d["base_idx"], d["matrix"], d["param_j"])] = d["row_id"]
359 bases = sorted({d["base_idx"] for d in design_rows})
360
361 def val(b, m, j):
362 rid = idx.get((b, m, j))
363 if rid is None:
364 return None
365 r = recs.get(rid)
366 if not usable(r, dfp_tol if dfp_tol is not None else DFP_TOL_DEFAULT,
367 require_converged, at_dose):
368 return None
369 v = obs_value(r, observable, at_dose)
370 if v is None:
371 return None
372 v = float(v)
373 return v if np.isfinite(v) else None
374
375 fA, fB, ok_base = {}, {}, []
376 for b in bases:
377 a_, b_ = val(b, "A", -1), val(b, "B", -1)
378 if a_ is not None and b_ is not None:
379 fA[b], fB[b] = a_, b_
380 ok_base.append(b)
381 if len(ok_base) < 3:
382 return None
383
384 allf = np.array([fA[b] for b in ok_base] + [fB[b] for b in ok_base])
385 V = float(np.var(allf, ddof=1))
386 rng = np.random.default_rng(seed)
387 res = {}
388 for j, key in enumerate(p_keys):
389 rows = [(fA[b], fB[b], val(b, "AB", j)) for b in ok_base]
390 rows = [(a_, b_, ab) for (a_, b_, ab) in rows if ab is not None]
391 n = len(rows)
392 if n < 3 or V <= 0:
393 res[key] = {"S": None, "ST": None, "n_eff": n,
394 "S_lo": None, "S_hi": None}
395 continue
396 A = np.array([r[0] for r in rows])
397 B = np.array([r[1] for r in rows])
398 AB = np.array([r[2] for r in rows])
399 S = float(np.mean(B * (AB - A)) / V)
400 ST = float(np.mean((A - AB) ** 2) / (2.0 * V))
401 # bootstrap over base indices
402 bs = []
403 for _ in range(n_boot):
404 k = rng.integers(0, n, n)
405 Vb = np.var(np.concatenate([A[k], B[k]]), ddof=1)
406 if Vb > 0:
407 bs.append(np.mean(B[k] * (AB[k] - A[k])) / Vb)
408 lo, hi = (float(np.percentile(bs, 2.5)),
409 float(np.percentile(bs, 97.5))) if bs else (None, None)
410 res[key] = {"S": S, "ST": ST, "n_eff": n, "S_lo": lo, "S_hi": hi}
411 return {"V": V, "n_base_ok": len(ok_base), "indices": res}
412
413
414def main(argv=None):
415 global DFP_TOL_DEFAULT
416 ap = argparse.ArgumentParser(description=__doc__,
417 formatter_class=argparse.RawDescriptionHelpFormatter)
418 ap.add_argument("--design", type=Path, required=True)
419 ap.add_argument("--results", type=Path, default=HERE / "results")
420 ap.add_argument("--out", type=Path, default=HERE / "report")
421 ap.add_argument("--n-boot", type=int, default=200)
422 ap.add_argument("--dfp-tol", type=float, default=DFP_TOL_DEFAULT,
423 help="delta_FP admissibility bar, applied HERE not in the "
424 "worker, so it can be changed without re-running "
425 "anything (plan S11(h)). Only has an effect together "
426 "with --require-converged. Default 1e-2.")
427 ap.add_argument("--require-converged", action="store_true",
428 help="restore the pre-2026-08-05 rule: reject rows whose "
429 "size tail is truncated (pile/top-bin) or whose "
430 "delta_FP exceeds --dfp-tol. OFF by default -- "
431 "truncation is recorded, not gated (plan S11(q)). Run "
432 "the report BOTH ways: if the parameter RANKING is the "
433 "same, truncation did not buy the ranking anything, "
434 "which is the claim the default policy rests on.")
435 ap.add_argument("--at-dose", type=float, default=None,
436 help="screen every row at this COMMON dose, read off the "
437 "per-row `at_dose` ladder, instead of at the end of "
438 "each trajectory. This is what lets a timed-out row "
439 "contribute: it enters every rung it reached and is "
440 "absent above that, pairwise. Without it, rows that "
441 "stopped early are excluded, because their "
442 "observables sit at a different dose from everyone "
443 "else's and dose moves them far harder than any "
444 "numerical choice (plan S12(s)).")
445 a = ap.parse_args(argv)
446 DFP_TOL_DEFAULT = a.dfp_tol
447 RC = a.require_converged
448 AD = a.at_dose
449
450 meta = json.loads(a.design.with_suffix(".meta.json").read_text(encoding="utf-8"))
451 p_keys = meta["parameters"]
452 lines = a.design.read_text(encoding="utf-8").strip().splitlines()
453 cols = lines[0].split(",")
454 design = []
455 for ln in lines[1:]:
456 v = dict(zip(cols, ln.split(",")))
457 design.append({"row_id": int(v["row_id"]), "condition": v["condition"],
458 "base_idx": int(v["base_idx"]), "matrix": v["matrix"],
459 "param_j": int(v["param_j"])})
460
461 meta_p = a.design.with_suffix('.meta.json')
462 dmeta = json.loads(meta_p.read_text(encoding='utf-8')) if meta_p.exists() else {}
463 recs = load_results(a.results, dmeta.get('design_sha256'))
464 check_provenance(recs)
465
466 total = len(design)
467 have = sum(1 for d in design if d["row_id"] in recs)
468 ok = sum(1 for d in design if usable(recs.get(d["row_id"]), a.dfp_tol, RC, AD))
469 failed = sum(1 for d in design
470 if d["row_id"] in recs and recs[d["row_id"]].get("solver_rc"))
471 inadm = have - ok - failed
472 print(f"\n policy: {'require-converged (pre-2026-08-05)' if RC else 'truncation RECORDED, NOT GATED (plan S11(q))'}"
473 f"{f'; screened at COMMON dose {AD:g} dpa' if AD is not None else '; screened at END OF RUN'}")
474 print(f" coverage: {have}/{total} rows present, {ok} admissible, "
475 f"{inadm} inadmissible, {failed} failed, {total-have} MISSING")
476
477 # The dose ladder decides which common dose is worth screening at, so print
478 # it before anything is estimated. A rung nearly every row reaches costs
479 # nothing to use; one reached by half of them halves n_eff everywhere.
480 cov = dose_ladder_coverage(recs, [d["row_id"] for d in design])
481 if cov:
482 print(f" dose ladder (rows reaching each rung, of {have} present):")
483 print(" " + " ".join(f"{k}:{v}" for k, v in cov.items()))
484 n_starved = sum(1 for r in recs.values() if r.get("starved"))
485 if AD is None and n_starved:
486 if cov:
487 best = max(float(k) for k, v in cov.items() if v == max(cov.values()))
488 print(f" *** {n_starved} timed-out row(s) EXCLUDED. Re-run with "
489 f"--at-dose {best:g} to include them at a common dose.")
490 else:
491 # Rows predating the dose ladder (plan S12(s)) carry no `at_dose`
492 # block, so --at-dose cannot rescue them; only a re-run can.
493 print(f" *** {n_starved} timed-out row(s) EXCLUDED, and these rows "
494 f"carry NO dose ladder -- they predate plan S12(s), so "
495 f"--at-dose cannot recover them.")
496 if RC:
497 # The delta_FP bar is the one gate whose right value is a judgement
498 # call, so show what the alternatives would buy BEFORE any index is
499 # read. A large spread here means the result depends on the threshold
500 # and the threshold needs an argument, not a default.
501 alts = [t for t in (1e-2, 1e-3, 1e-4, 1e-6) if t != a.dfp_tol]
502 counts = " ".join(
503 f"{t:.0e}: {sum(1 for d in design if usable(recs.get(d['row_id']), t, True))}"
504 for t in sorted([a.dfp_tol] + alts, reverse=True))
505 print(f" admissible vs delta_FP bar {counts} (in use: {a.dfp_tol:.0e})")
506 else:
507 # Truncation is not a gate, but it is not invisible either: say how much
508 # of the pool carries it, so a ranking read off this report is read in
509 # the knowledge of how much of it came from truncated rows.
510 pool = [recs[d["row_id"]] for d in design
511 if usable(recs.get(d["row_id"]), a.dfp_tol, False, AD)]
512 if pool:
513 trunc = sum(1 for r in pool if not grid_ok(r)[0])
514 hi_dfp = sum(1 for r in pool if abs(r.get("delta_FP") or 0) >= a.dfp_tol)
515 print(f" of the {len(pool)} rows used: {trunc} ({100*trunc/len(pool):.0f}%) "
516 f"have a truncated size tail, {hi_dfp} ({100*hi_dfp/len(pool):.0f}%) "
517 f"have delta_FP >= {a.dfp_tol:g}")
518 print(f" -> re-run with --require-converged and compare the RANKING, "
519 f"not the values")
520 if total - have:
521 miss = sorted(d["row_id"] for d in design if d["row_id"] not in recs)
522 by_mach = defaultdict(int)
523 for m in miss:
524 by_mach[m % 4] += 1
525 print(f" missing row_ids mod 4: {dict(by_mach)} "
526 f"(a whole residue class means a machine never reported)")
527
528 # why runs were inadmissible -- this is diagnostic, not bookkeeping.
529 # Reasons come from the SAME re-derivation usable() applies, so the counts
530 # add up against the coverage line above; reading the row's stored flags
531 # here would report the superseded rule.
532 reasons = defaultdict(int)
533 for r in recs.values():
534 if r.get("solver_rc") or usable(r, a.dfp_tol, RC, AD):
535 continue
536 if not RC:
537 # Under the default policy the only way to be inadmissible without
538 # having failed is to have not reached the dose.
539 reasons["dose-starved (use --at-dose)" if AD is None
540 else f"did not reach {AD:g} dpa"] += 1
541 continue
542 ok, why = grid_ok(r)
543 if not ok:
544 reasons[why] += 1
545 elif abs(r.get("delta_FP") or 0) >= a.dfp_tol:
546 reasons[f"delta_FP>={a.dfp_tol:g}"] += 1
547 if reasons:
548 print(" inadmissible because:")
549 for k, v in sorted(reasons.items(), key=lambda t: -t[1]):
550 print(f" {v:5d} {k}")
551
552 a.out.mkdir(parents=True, exist_ok=True)
553
554 # Restrict to observables validated in the mode that produced these rows.
555 mode = equations_mode_of(recs)
556 obs_list, blocked = observables_for_mode(mode or "discrete")
557 if mode == "MIXED":
558 print("\n *** rows come from MORE THAN ONE equations mode - they are "
559 "not poolable; separate them before estimating.")
560 if blocked:
561 print(f"\n equations mode = {mode}: withholding {len(blocked)} "
562 f"observable(s) whose closure is not validated in this mode:")
563 print(f" {', '.join(sorted(blocked))}")
564 print(" Screening on them would produce indices about the numerics, "
565 "not the physics.")
566 print(" (<100> in bin_moment: mean_n_100 +144% vs a converged discrete "
567 "reference, 2026-08-02.)")
568
569 rows_out = []
570 for cond in meta["conditions"]:
571 dc = [d for d in design if d["condition"] == cond]
572 for obs in obs_list:
573 r = sobol_indices(recs, dc, p_keys, obs, n_boot=a.n_boot,
574 dfp_tol=a.dfp_tol, require_converged=RC,
575 at_dose=AD)
576 if r is None:
577 print(f" {cond:>4s} {obs:16s} -- too few usable base points")
578 continue
579 print(f"\n {cond} / {obs} Var={r['V']:.4g} "
580 f"base points usable {r['n_base_ok']}/{meta['N']}")
581 ranked = sorted(r["indices"].items(),
582 key=lambda kv: -(kv[1]["ST"] or -1))
583 for key, v in ranked[:6]:
584 if v["S"] is None:
585 continue
586 flag = " <-- n_eff LOW" if v["n_eff"] < 0.5 * meta["N"] else ""
587 print(f" {key:18s} S={v['S']:7.3f} "
588 f"[{v['S_lo']:6.3f},{v['S_hi']:6.3f}] "
589 f"ST={v['ST']:7.3f} n_eff={v['n_eff']:3d}{flag}")
590 for key, v in r["indices"].items():
591 rows_out.append({"condition": cond, "observable": obs,
592 "parameter": key, **v})
593
594 csv = a.out / "T2_sobol_indices.csv"
595 with csv.open("w", encoding="utf-8", newline="") as fh:
596 fh.write("condition,observable,parameter,S,S_lo,S_hi,ST,n_eff\n")
597 for r in rows_out:
598 def g(k):
599 return "" if r[k] is None else f"{r[k]:.6g}"
600 fh.write(f"{r['condition']},{r['observable']},{r['parameter']},"
601 f"{g('S')},{g('S_lo')},{g('S_hi')},{g('ST')},{r['n_eff']}\n")
602 print(f"\n wrote {csv} ({len(rows_out)} rows)")
603 print("\n Read n_eff before reading any index: an S_i^T computed from a")
604 print(" handful of base points is not a screening result. Parameters whose")
605 print(" n_eff collapsed are telling you their prior region is unintegrable")
606 print(" at this tolerance -- itself a finding (plan Tier-2 failure handling).")
607 return 0
608
609
610if __name__ == "__main__":
611 raise SystemExit(main())
str|None equations_mode_of(dict recs)
dict[int, dict] load_results(Path res_dir, str|None design_sha=None)
bool usable(dict r, float dfp_tol=DFP_TOL_DEFAULT, bool require_converged=False, at_dose=None)
tuple[list, set] observables_for_mode(str equations, blocked_override=None)
bool _same_design(row_sha, design_sha)
dose_ladder_coverage(recs, design_row_ids)
obs_value(dict r, str observable, at_dose=None)