RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
learn.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Ingest every calibration stage and re-derive what the campaign has learned.
3
4WHY THIS EXISTS. S1-S14 each answered one question, and the answer lived in a
5commit message. A machine that pulls the repo cannot read commit messages into
6a plan, and a human returning after a crash re-derives the same verdicts by
7hand (this happened on 2026-08-18). This script turns the result files back
8into two artefacts that DO travel:
9
10 calibration_ledger.json machine-readable state -- what was swept, what it
11 did, which levers are dead, where the best point
12 is, what each machine costs per row.
13 CALIBRATION_GUIDE.md the same thing for a human, regenerated so it can
14 never drift from the results it summarises.
15
16`plan.py` reads the ledger and proposes the next stage. Nothing here writes a
17design or launches a run -- learning and planning are kept separate so that
18re-ingesting results can never, by itself, start a job.
19
20THE ONE RULE THIS FILE ENFORCES. A row is a MEASUREMENT only if it reached the
21target dose, is not grid-limited, and its grid converged. Rows that piled at
22the ceiling still carry information about DIRECTION, and are used for that, but
23their size readouts are never scored or fitted. S12 and the V2 block of S14
24are the reason: both look like "big loops" and neither is a number.
25
26Usage:
27 python learn.py # re-ingest, rewrite ledger + guide
28 python learn.py --quiet # no stdout summary
29"""
30from __future__ import annotations
31
32import argparse
33import csv
34import json
35import math
36import sys
37from datetime import datetime, timezone
38from pathlib import Path
39
40sys.stdout.reconfigure(encoding="utf-8", errors="replace")
41
42HERE = Path(__file__).resolve().parent
43LEDGER = HERE / "calibration_ledger.json"
44GUIDE = HERE / "CALIBRATION_GUIDE.md"
45TARGETS = HERE / "targets_330C_15dpa.json"
46
47# Bookkeeping columns -- never a physics lever.
48BOOKKEEPING = {"row_id", "condition", "cond_row_id", "matrix", "base_idx",
49 "param_j", "theta_id"}
50
51OBS = ["N_loops_100", "d_100_nm", "N_loops_111", "d_111_nm",
52 "N_voids", "d_cavity_nm"]
53SHORT = {"N_loops_100": "N_100", "d_100_nm": "d_100", "N_loops_111": "N_111",
54 "d_111_nm": "d_111", "N_voids": "N_void", "d_cavity_nm": "d_void"}
55
56# A lever whose FULL tested span moves every observable by less than this is
57# declared dead. 5 % is well above the solver run-to-run reproducibility and
58# well below anything that could close a 12x miss.
59DEAD_THRESHOLD = 0.05
60
61# Observables the planner should NOT chase, with the reason. This is a
62# deliberate, reviewable override of "worst residual first" -- it is seeded
63# once and then preserved across regenerations like `notes`, so changing your
64# mind means editing the ledger, not the code.
65DEFAULT_POLICY = {
66 "deprioritized_observables": {
67 "N_void": ("Missed by ~300x AND nearly unresponsive to its own "
68 "governing parameters: the S14 vacancy triple moved "
69 "N_void only 2.57e18 -> 9.08e18 (3.5x) while moving loop "
70 "content 160x. A residual that large with a response that "
71 "small is evidence of a structural defect in the cavity "
72 "channel, not a parameter that needs tuning. Re-enable "
73 "once cavity nucleation is shown to respond at all."),
74 "d_void": ("Pinned at 0.56-0.57 nm across every row of every stage, "
75 "including a 160x swing in loop content. Same reasoning as "
76 "N_void."),
77 },
78 # Parameters the design can set that the PHYSICS NEVER READS. Sweeping one
79 # produces bit-identical rows and burns the machine time silently. Each
80 # entry must cite how it was established.
81 "unwired_parameters": {
82 "E_b_v2": ("S18 rows 4801 and 4803 differ only in E_b_v2 (0.10 vs "
83 "0.45 eV, the full prior box) and agree in 63 of 67 numeric "
84 "fields BIT-FOR-BIT -- N_voids, mean_n_v and S_inventory to "
85 "the last digit; only row_id bookkeeping and wall_s differ. "
86 "At 603 K a 0.35 eV binding change should move di-vacancy "
87 "emission by exp(0.35/0.052) ~ 840x. Confirmed in source: "
88 "`E_b_v2` appears only in py_utils/create_excel.py, which "
89 "BUILDS the workbook; binding_energies.E_b_void() derives "
90 "the m=2 binding from the capillary term plus "
91 "A_void_0*exp(-lambda*(m-1)) and never reads it. Note the "
92 "same function's docstring records that `lambda` and "
93 "`A_void_0` were once carried by the workbook and unread -- "
94 "same bug, previously fixed. FIX THE CODE, then delete this "
95 "entry; do not sweep E_b_v2 until then."),
96 },
97}
98
99
100# --------------------------------------------------------------- loading ----
101def load_targets() -> dict:
102 return json.loads(TARGETS.read_text(encoding="utf-8"))
103
104
105def discover_stages() -> dict:
106 """Pair every results/*.jsonl with its design/*.csv and labels, if present."""
107 stages = {}
108 for res in sorted((HERE / "results").glob("*.jsonl")):
109 stem = res.stem
110 if stem.startswith("archive"):
111 continue
112 # S14_calib.jsonl -> S14 ; T9_screen_partial.jsonl -> T9
113 tag = stem.split("_")[0]
114 design = None
115 for cand in (HERE / "design" / (stem + ".csv"),
116 HERE / "design" / (tag + "_calib.csv"),
117 HERE / "design" / (tag + "_probe.csv")):
118 if cand.exists():
119 design = cand
120 break
121 labels = {}
122 for cand in (HERE / "design" / (tag + "_labels.json"),
123 HERE / "design" / (stem + "_labels.json")):
124 if cand.exists():
125 labels = json.loads(cand.read_text(encoding="utf-8"))
126 break
127 stages[stem] = {"tag": tag, "results": res, "design": design,
128 "labels": labels}
129 return stages
130
131
132def read_rows(path: Path) -> list:
133 out = []
134 for ln in path.read_text(encoding="utf-8").splitlines():
135 ln = ln.strip()
136 if not ln:
137 continue
138 try:
139 out.append(json.loads(ln))
140 except json.JSONDecodeError:
141 pass
142 return out
143
144
145def read_design(path) -> dict:
146 if not path or not Path(path).exists():
147 return {}
148 with Path(path).open(encoding="utf-8") as fh:
149 return {r["row_id"]: r for r in csv.DictReader(fh)}
150
151
152# -------------------------------------------------------------- validity ----
153PILE_TOL = 0.05 # mirrors run_ensemble.PILE_TOL
154TOPBIN_TOL = 0.02 # mirrors run_ensemble.TOPBIN_TOL
155
156
157def grid_components(row: dict):
158 """Split `grid_limited` into its parts. Returns (pile, sia_top, vac_top,
159 unmeasured, decomposable).
160
161 run_ensemble records the components; only the OR of them survives into
162 `grid_limited`, which is too coarse to act on (see classify).
163 """
164 keys = ("pile_100", "pile_111", "pile_v", "topbin_v")
165 if not any(k in row for k in keys):
166 return (False, False, False, False, False) # old row: cannot split
167 pile = any((row.get(f"pile_{a}") or 0.0) > PILE_TOL
168 for a in ("100", "111", "v"))
169 sia_top = any((row.get(f"topbin_{a}") or 0.0) > TOPBIN_TOL
170 for a in ("100", "111"))
171 vac_top = (row.get("topbin_v") or 0.0) > TOPBIN_TOL
172 return (pile, sia_top, vac_top, bool(row.get("unmeasured_gates")), True)
173
174
175def classify(row: dict, dose_target: float = 15.0):
176 """Is this row a measurement? Returns (valid, flags).
177
178 THE VACANCY TOP-BIN DOES NOT DISQUALIFY (2026-08-20). `topbin_v` is the
179 vacancy CONTENT fraction in the top bin, and on a log grid that fraction has
180 a floor set by the bin ratio, not by truncation: for a cavity tail
181 c(n) ~ n^-alpha it tends to 1 - r^-(2-alpha) whatever the ceiling is. The
182 campaign's cavity tail has alpha ~ 0.47, so at the production r = 1.514 the
183 floor is ~0.47 and TOPBIN_TOL = 0.02 is unreachable at any affordable bin
184 count (it would need ~630 vacancy bins).
185
186 Measured 2026-08-20 on the reference point and on the 4/6 anchor, refining
187 v_bin 20 -> 47 (r 1.514 -> 1.202):
188 * topbin_v fell 0.468 -> 0.1715 and 0.472 -> 0.1718 -- the SAME value to
189 three digits at mean_n_v = 1566 and 7115, a 4.5x span in mean void size.
190 A truncation measure cannot be blind to that; a geometric one is.
191 * the four loop observables moved by <= 0.1 % (grid-exact), and the 4/6
192 anchor still scored 4/6 with every in-band observable still in band.
193 So the vacancy top bin is recorded as a soft TOPBINV flag instead.
194
195 The SIA-axis top bin KEEPS disqualifying: those bins run at r = 1.204, where
196 the geometric floor is small, and the argument above does not transfer.
197 `pile` also keeps disqualifying -- it is measured on the reconstructed
198 distribution and is the real truncation test (1095 of the 1226 grid-flagged
199 rows fail it, so this change rescues 105 rows, not the whole campaign).
200
201 This restores agreement with run_ensemble.py, which withdrew truncation as a
202 reject criterion outright on 2026-08-05 (TRUNCATION_GATES = False); learn.py
203 had reintroduced it, so the ledger and CALIBRATION_GUIDE.md were reporting a
204 best-of-campaign that excluded the campaign's best rows -- and plan.py plans
205 from that ledger.
206 """
207 flags = []
208 pile, sia_top, vac_top, unmeas, ok = grid_components(row)
209 if ok:
210 hard_grid = pile or sia_top or unmeas
211 if vac_top:
212 flags.append("TOPBINV") # soft: recorded, not fatal
213 else:
214 hard_grid = bool(row.get("grid_limited")) \
215 or not row.get("grid_converged", True)
216 if hard_grid:
217 flags.append("GRID")
218 flags.append("NOCONV")
219 reached = row.get("dose_reached", 0.0) or 0.0
220 if abs(reached - dose_target) > 0.02 * dose_target:
221 flags.append("DOSE")
222 if row.get("starved"):
223 flags.append("STARV")
224 if not row.get("admissible", True):
225 flags.append("INADM")
226 if not row.get("conserving", True):
227 flags.append("NOCONS")
228 # NOCONS is recorded but does NOT disqualify: every <100> row in the
229 # campaign carries it (see commit ddcb203). Disqualifying on it would
230 # leave zero measurements and hide the rest of the signal.
231 hard = {"GRID", "NOCONV", "DOSE", "STARV", "INADM"}
232 return (not (hard & set(flags))), flags
233
234
235# ------------------------------------------------- EXTENT VERIFICATION -----
236# An observable is a MEASUREMENT only if it survives a change of grid EXTENT.
237# Refinement at fixed extent is NOT sufficient and never was: on 2026-08-20 the
238# reference theta moved mean_n_v by only -3.8 % from v_bin 20 -> 47 and then by
239# +316 % when V went 20000 -> 80000. Occupancy gives no warning either (a point
240# at occ_v = 0.042 was wrong by 14x, another at occ_v = 0.013 by 5x).
241#
242# The test: same theta_hash (computed from DESIGN columns only, so it is
243# grid-independent) run at two different V. If an observable moves by more than
244# EXTENT_TOL it is an artefact of the discretisation, not a prediction, and it
245# MUST NOT be scored. This is what makes the campaign self-correcting: the
246# ledger now refuses to call something a measurement until the grid says so.
247EXTENT_TOL = 0.10
248
249
250def recover_V(row: dict):
251 """Vacancy ceiling V. Not stored directly; occ_v = mean_n_v / V is."""
252 occ, mn = row.get("occ_v"), row.get("mean_n_v")
253 if not occ or mn is None or occ <= 0:
254 return None
255 return int(round(mn / occ))
256
257
258def extent_pairs(all_rows: list) -> dict:
259 """theta_hash -> {observable: max relative drift across grid EXTENTS}.
260
261 Only thetas actually run at two or more distinct V contribute. Everything
262 else is UNVERIFIED -- not passed, not failed, simply not yet tested.
263 """
264 by_theta = {}
265 for r in all_rows:
266 th, V = r.get("theta_hash"), recover_V(r)
267 if not th or not V or r.get("solver_rc", 0) != 0:
268 continue
269 by_theta.setdefault(th, {}).setdefault(V, r)
270 out = {}
271 for th, byV in by_theta.items():
272 if len(byV) < 2:
273 continue
274 drift = {}
275 for k in OBS:
276 vals = [r.get(k) for r in byV.values() if r.get(k) not in (None, 0)]
277 if len(vals) < 2:
278 continue
279 drift[k] = max(vals) / min(vals) - 1.0
280 if drift:
281 out[th] = {"drift": drift, "V_tested": sorted(byV)}
282 return out
283
284
285def verified_obs(row: dict, ext: dict) -> set:
286 """The observables this row is entitled to be scored on."""
287 e = ext.get(row.get("theta_hash"))
288 if not e:
289 return set()
290 return {k for k, d in e["drift"].items() if d <= EXTENT_TOL}
291
292
293def score(row: dict, targets: dict, ext: dict = None) -> dict:
294 """Band membership, reported THREE ways.
295
296 `n_in_range` - every observable, the historical figure.
297 `n_verified` - how many observables survived an extent change.
298 `n_in_range_verified` - in band AND extent-verified. THIS is the score
299 that ranks rows; the other two are diagnostics.
300 """
301 o = targets["observables"]
302 ver = verified_obs(row, ext or {})
303 per, n_ok, n_ok_ver = {}, 0, 0
304 for k in OBS:
305 v = row.get(k)
306 if v is None:
307 per[k] = None
308 continue
309 ok = o[k]["lo"] <= v <= o[k]["hi"]
310 n_ok += ok
311 n_ok_ver += (ok and k in ver)
312 per[k] = {"value": v, "in_range": bool(ok), "verified": k in ver,
313 "ratio": v / o[k]["target"] if o[k]["target"] else None}
314 return {"n_in_range": n_ok, "n_verified": len(ver),
315 "n_in_range_verified": n_ok_ver, "verified": sorted(ver), "per": per}
316
317
318def worst_margin(row: dict, targets: dict, only: set = None) -> float:
319 """Decades from the nearest band edge, for the observable closest to one.
320
321 Band membership is BINARY and so rewards edge-sitting: V15 row 7109 counts
322 the same 3/4 as row 7100 while sitting 1 % inside the N_111 ceiling
323 (1.49e22 against 1.5e22) where 7100 is 18 % clear on its tightest
324 observable. One of those is a calibration; the other is a coin flip against
325 solver noise and the next grid change. `log_distance` cannot separate them
326 either -- it is a global RMS to the target CENTRES, so it says nothing about
327 proximity to an EDGE.
328
329 Returned in decades so it is scale-free across densities and diameters.
330 Negative when an observable is outside its band (how far out it is).
331 None when nothing is scoreable.
332 """
333 o = targets["observables"]
334 ms = []
335 for k in (only if only is not None else OBS):
336 v = row.get(k)
337 if v is None or v <= 0:
338 continue
339 lo, hi = o[k]["lo"], o[k]["hi"]
340 if lo <= v <= hi:
341 ms.append(min(math.log10(v / lo), math.log10(hi / v)))
342 else:
343 ms.append(-min(abs(math.log10(v / lo)), abs(math.log10(v / hi))))
344 return min(ms) if ms else None
345
346
347def _margin_or_none(row: dict, targets: dict, sc: dict):
348 """worst_margin over the VERIFIED AND IN-BAND observables, else None."""
349 only = {k for k in sc["verified"]
350 if sc["per"].get(k) and sc["per"][k]["in_range"]}
351 if not only:
352 return None
353 wm = worst_margin(row, targets, only)
354 return round(wm, 3) if wm is not None else None
355
356
357def log_distance(row: dict, targets: dict) -> float:
358 """RMS of log10(model/target) over the six. Scale-free, so a 300x miss on
359 voids is not drowned out by a 1.2x miss on a diameter."""
360 o = targets["observables"]
361 acc, n = 0.0, 0
362 for k in OBS:
363 v, t = row.get(k), o[k]["target"]
364 if v and t and v > 0:
365 acc += math.log10(v / t) ** 2
366 n += 1
367 return math.sqrt(acc / n) if n else float("inf")
368
369
370# ---------------------------------------------------- lever sensitivity -----
371def covarying_groups(design: dict) -> list:
372 """Partition swept columns into blocks that always move together.
373
374 V0/V1/V2 changes f_cl_v, E_b_v2 and s_v as one knob; the S12 sink block
375 moves five Z-factors at once. Treating those as independent columns would
376 find no single-column pairs at all and report every lever as untested.
377 """
378 rows = list(design.values())
379 if not rows:
380 return []
381 swept = [c for c in rows[0]
382 if c not in BOOKKEEPING and len({r[c] for r in rows}) > 1]
383 sig = {}
384 for c in swept:
385 seen, pattern = {}, []
386 for r in rows: # value -> class index, by first use
387 pattern.append(seen.setdefault(r[c], len(seen)))
388 sig.setdefault(tuple(pattern), []).append(c)
389 return list(sig.values())
390
391
392def group_key(design_row: dict, group: list) -> tuple:
393 return tuple(design_row[c] for c in group)
394
395
396def sensitivity(stage: dict, rows: list, targets: dict,
397 dose_target: float = 15.0) -> dict:
398 """Response of each observable to each co-varying lever group.
399
400 Only pairs that differ in EXACTLY ONE group are used, so the response is
401 attributable. Magnitude is measured on valid rows only; a pair where one
402 side went invalid is recorded as a direction with `drove_invalid`.
403 """
404 design = read_design(stage["design"])
405 if not design:
406 return {}
407 groups = covarying_groups(design)
408 by_id = {str(r["row_id"]): r for r in rows if r.get("row_id") is not None}
409 out = {}
410
411 for group in groups:
412 others = [g for g in groups if g is not group]
413 pairs, drove_invalid = [], False
414 ids = [i for i in by_id if i in design]
415 for a in ids:
416 for b in ids:
417 if a >= b:
418 continue
419 da, db = design[a], design[b]
420 if group_key(da, group) == group_key(db, group):
421 continue
422 if any(group_key(da, g) != group_key(db, g) for g in others):
423 continue
424 ra, rb = by_id[a], by_id[b]
425 va, _ = classify(ra, dose_target)
426 vb, _ = classify(rb, dose_target)
427 if va and vb:
428 pairs.append((da, db, ra, rb))
429 else:
430 drove_invalid = True
431
432 resp = {}
433 for k in OBS:
434 best = 0.0
435 for da, db, ra, rb in pairs:
436 x, y = ra.get(k), rb.get(k)
437 if x and y and x > 0 and y > 0:
438 best = max(best, abs(y / x - 1.0))
439 resp[k] = best
440
441 span = {}
442 for c in group:
443 vals = sorted({float(design[i][c]) for i in ids})
444 span[c] = [vals[0], vals[-1]]
445
446 peak = max(resp.values()) if resp else 0.0
447 out["+".join(group)] = {
448 "columns": group, "span": span, "n_pairs": len(pairs),
449 "response": resp, "peak_response": peak,
450 "drove_invalid": drove_invalid,
451 "verdict": ("dead" if (pairs and peak < DEAD_THRESHOLD)
452 else "live" if pairs
453 else "inconclusive"),
454 }
455 return out
456
457
458# ---------------------------------------------------------- affordability ---
459def affordability(stage: dict, rows: list, dose_target: float) -> dict:
460 """Which lever VALUES prevent a row from ever reaching dose.
461
462 This is a COST verdict, not a physics one, and it is measured with a
463 controlled contrast: within one stage, a level counts as unaffordable only
464 if it produced zero full-dose rows while ANOTHER level of the same
465 co-varying group -- everything else held equal -- produced at least one.
466
467 S14 is the motivating case. All six rows at E_b_i2 = 0.60 burned the full
468 20 h budget and reached 0.004-6.06 of 15 dpa; all six at 0.75 finished in
469 5.3-19.8 h. Nothing about that is visible in the physics verdicts, because
470 a row that never reaches dose is excluded from scoring -- so without this
471 the planner cheerfully proposes another stage in the same dead direction.
472 """
473 design = read_design(stage["design"])
474 if not design:
475 return {}
476 by_id = {str(r["row_id"]): r for r in rows if r.get("row_id") is not None}
477 out = {}
478 for group in covarying_groups(design):
479 tally = {}
480 for rid, drow in design.items():
481 r = by_id.get(rid)
482 if r is None:
483 continue
484 reached = abs((r.get("dose_reached") or 0.0) - dose_target) \
485 <= 0.02 * dose_target
486 key = group_key(drow, group)
487 slot = tally.setdefault(key, [0, 0])
488 slot[0] += 1
489 slot[1] += reached
490 if len(tally) < 2:
491 continue
492 any_ok = any(v[1] > 0 for v in tally.values())
493 # ATTRIBUTE ONLY WHAT IS ATTRIBUTABLE. A co-varying group that fails
494 # says nothing about which member caused it: S14's V2 level moved
495 # f_cl_v, E_b_v2 and s_v together, so blaming each individually would
496 # cap E_b_v2 at 0.35 on evidence that never isolated it. Multi-column
497 # groups are recorded under the joined name and never clip a single
498 # column's box.
499 if len(group) > 1:
500 continue
501 for key, (n, ok) in tally.items():
502 for col, val in zip(group, key):
503 rec = out.setdefault(col, {"unaffordable": [], "affordable": [],
504 "evidence": []})
505 if ok == 0 and n >= 3 and any_ok:
506 if val not in rec["unaffordable"]:
507 rec["unaffordable"].append(val)
508 rec["evidence"].append(
509 "%s: 0 of %d rows reached dose at %s=%s"
510 % (stage.get("stem", "?"), n, col, val))
511 elif ok > 0 and val not in rec["affordable"]:
512 rec["affordable"].append(val)
513 # Drop columns with nothing adverse to say.
514 return {c: r for c, r in out.items() if r["unaffordable"]}
515
516
517# ------------------------------------------------------------ inventory -----
518def inventory(row: dict, targets: dict):
519 """Compare SIA content locked in loops against the measurement.
520
521 Uses the ROW OWN n<->d relation (n / d^2 from its own mean_n and mean d),
522 so no lattice constant or Burgers vector is assumed here and the comparison
523 cannot drift from whatever the solver actually used.
524 """
525 o = targets["observables"]
526 out = {}
527 for char, nkey, dkey, Nkey, tN, td in (
528 ("100", "mean_n_100", "d_100_nm", "N_loops_100", "N_loops_100", "d_100_nm"),
529 ("111", "mean_n_111", "d_111_nm", "N_loops_111", "N_loops_111", "d_111_nm"),
530 ):
531 n, d, N = row.get(nkey), row.get(dkey), row.get(Nkey)
532 if not (n and d and N and d > 0):
533 return None
534 n_per_d2 = n / (d ** 2)
535 n_exp = n_per_d2 * (o[td]["target"] ** 2)
536 out[char] = {
537 "model_N": N, "model_mean_n": n, "model_content": N * n,
538 "exp_N": o[tN]["target"], "exp_mean_n_at_target_d": n_exp,
539 "exp_content": o[tN]["target"] * n_exp,
540 "ratio": (N * n) / (o[tN]["target"] * n_exp),
541 }
542 tot_m = sum(v["model_content"] for v in out.values())
543 tot_e = sum(v["exp_content"] for v in out.values())
544 out["total"] = {"model_content": tot_m, "exp_content": tot_e,
545 "ratio": tot_m / tot_e if tot_e else None}
546 return out
547
548
549# ----------------------------------------------------------- cost model -----
550def cost_model(all_rows: list) -> dict:
551 """Wall time per row, per machine -- from COMPLETED rows only.
552
553 A row cut at the budget records wall_s == the budget, which measures the
554 timeout and not the cost. Counting those pins max_row_h to the budget, and
555 since plan.py refuses a design whose worst row exceeds the budget, one
556 timed-out stage would make the planner refuse every subsequent design on
557 that machine. S14 did exactly this: six rows at 20.0 h that never reached
558 dose. Timeouts are counted separately, because a machine that times out
559 often is still worth knowing about.
560 """
561 done, timed_out = {}, {}
562 for r in all_rows:
563 w, m = r.get("wall_s"), r.get("machine_id")
564 if not (w and m):
565 continue
566 tgt = r.get("dose_target")
567 reached = r.get("dose_reached")
568 finished = (tgt is None or reached is None
569 or abs(reached - tgt) <= 0.02 * tgt)
570 (done if finished else timed_out).setdefault(m, []).append(float(w))
571 out = {}
572 # SORTED, or the key order differs per machine and every learn.py run
573 # produces a spurious ledger diff -- the exact churn 72e1c9f fixed
574 # elsewhere. Unordered iteration here reintroduced it through cost_model.
575 for m in sorted(set(done) | set(timed_out)):
576 ws = sorted(done.get(m, []))
577 rec = {"n": len(ws), "n_timed_out": len(timed_out.get(m, []))}
578 if ws:
579 rec.update({"median_row_h": round(ws[len(ws) // 2] / 3600.0, 2),
580 "max_row_h": round(ws[-1] / 3600.0, 2),
581 "min_row_h": round(ws[0] / 3600.0, 2)})
582 out[m] = rec
583 return out
584
585
586# --------------------------------------------------------------- ingest -----
587def ingest() -> dict:
588 targets = load_targets()
589 stages_meta = discover_stages()
590 prev = json.loads(LEDGER.read_text(encoding="utf-8")) if LEDGER.exists() else {}
591
592 goal_dose = float(targets["condition"]["dose_dpa"])
593
594 # TWO PASSES. Extent verification is a GLOBAL property of a theta -- the
595 # pair that proves an observable may live in a different stage file (the
596 # grid ladders are their own stages) -- so every row must be on the table
597 # before any row can be scored.
598 rows_by_stage = {}
599 for stem, meta in sorted(stages_meta.items()):
600 rows = read_rows(meta["results"])
601 if rows:
602 rows_by_stage[stem] = rows
603 all_rows = [r for rows in rows_by_stage.values() for r in rows]
604 ext = extent_pairs(all_rows)
605
606 stages, best, best_raw = {}, None, None
607 for stem, rows in rows_by_stage.items():
608 meta = stages_meta[stem]
609
610 # A stage is IN SCOPE only if it ran to the dose the targets describe.
611 # The T-series ran to 1 dpa; scoring those against a 15 dpa target set
612 # would flag every row DOSE and then, because no valid pair survives,
613 # silently report their levers as "dead". That is how f_cl_i -- never
614 # validly tested at all -- was reported dead on the first ingest.
615 doses = [r.get("dose_target") for r in rows if r.get("dose_target")]
616 stage_dose = max(set(doses), key=doses.count) if doses else goal_dose
617 in_scope = abs(stage_dose - goal_dose) < 1e-9
618
619 design = read_design(meta["design"])
620 recs, n_valid = [], 0
621 for r in rows:
622 valid, flags = classify(r, stage_dose)
623 n_valid += valid
624 sc = score(r, targets, ext)
625 if sc["n_verified"] == 0:
626 flags = flags + ["UNVERIFIED"] # no extent pair exists yet
627 drow = design.get(str(r.get("row_id")), {})
628 rec = {"row_id": r.get("row_id"),
629 "label": meta["labels"].get(str(r.get("row_id"))),
630 "valid": valid, "flags": flags,
631 "n_in_range": sc["n_in_range"],
632 "n_verified": sc["n_verified"],
633 "n_in_range_verified": sc["n_in_range_verified"],
634 "verified": sc["verified"],
635 # Over the observables that are BOTH verified AND in band:
636 # this measures how robust the in-band CLAIMS are. Folding
637 # in the out-of-band ones would just restate what
638 # n_in_range_verified already says, and would drown the
639 # edge-sitting signal it exists to expose.
640 # `only=set()` must mean "nothing to measure" -> None, NOT
641 # "fall back to all six". An `or None` here would silently
642 # score an UNVERIFIED row against every observable including
643 # the out-of-band ones, which reads as a terrible margin and
644 # is really just an absence of evidence.
645 "worst_margin": _margin_or_none(r, targets, sc),
646 "log_distance": round(log_distance(r, targets), 3),
647 "dose_reached": r.get("dose_reached"),
648 "observables": {SHORT[k]: (sc["per"][k]["value"]
649 if sc["per"][k] else None)
650 for k in OBS}}
651 recs.append(rec)
652 if valid and in_scope:
653 cand = {"stage": stem, "row_id": r.get("row_id"),
654 "label": rec["label"], "n_in_range": sc["n_in_range"],
655 "n_verified": sc["n_verified"],
656 "n_in_range_verified": sc["n_in_range_verified"],
657 "verified": sc["verified"],
658 "worst_margin": rec["worst_margin"],
659 "V_tested": (ext.get(r.get("theta_hash")) or {}).get("V_tested"),
660 "log_distance": rec["log_distance"],
661 "flags": flags,
662 "observables": rec["observables"],
663 "ratios": {SHORT[k]: (round(sc["per"][k]["ratio"], 4)
664 if sc["per"][k] and sc["per"][k]["ratio"]
665 else None) for k in OBS},
666 "params": {c: v for c, v in drow.items()
667 if c not in BOOKKEEPING},
668 "inventory": inventory(r, targets)}
669 # RANK ON THE VERIFIED COUNT FIRST. An unverified row can no
670 # longer displace a verified one no matter how many bands it
671 # happens to sit in -- that is exactly how a grid artefact
672 # (d_cavity = 0.2825*(0.37V)^(1/3)) took the top of this
673 # leaderboard for twelve stages.
674 # Margin breaks ties BEFORE log_distance: between two rows with
675 # the same verified count, the one further from a band EDGE is
676 # the better calibration, and log_distance (RMS to the target
677 # centres) cannot see edges at all.
678 def rank(c):
679 return (c["n_in_range_verified"], c["n_verified"],
680 c["n_in_range"],
681 c["worst_margin"] if c["worst_margin"] is not None
682 else -9.9,
683 -c["log_distance"])
684 if best is None or rank(cand) > rank(best):
685 best = cand
686
687 # SECOND leaderboard, verification gate REMOVED. The gate above
688 # is right for "what have we actually established", but it is
689 # silent about a row that dominates on the raw score and simply
690 # has not been extent-paired yet -- row 9305 sat at 4/6 and
691 # log-distance 0.236 while the reported best was 2/6 at 0.65,
692 # and nothing in the guide said so. Same tie-breaks, minus the
693 # two verified terms. Report both; never let this one alone
694 # drive a decision, because an unverified row can still be the
695 # grid artefact the gate was built to catch.
696 # NOTE the tie-break differs from rank() on purpose: NO
697 # worst_margin term. _margin_or_none() measures only the
698 # observables that are both verified AND in band, so it is
699 # structurally None for precisely the unverified rows this
700 # leaderboard exists to surface -- including it sorts them to
701 # the bottom via the -9.9 sentinel and reproduces the very
702 # suppression being fixed (it ranked row 5812, 4/6 at 0.505,
703 # above row 9305, 4/6 at 0.236). log_distance is the only
704 # tie-break available to every row, verified or not.
705 def rank_raw(c):
706 return (c["n_in_range"], -c["log_distance"])
707 if best_raw is None or rank_raw(cand) > rank_raw(best_raw):
708 best_raw = cand
709
710 swept = ["+".join(g) for g in covarying_groups(design)] if design else []
711 stages[stem] = {
712 # as_posix(), never str(): str() of a Path emits os.sep, so the same
713 # results rewrite every stored path when the ingesting machine
714 # changes OS. Forward slashes read back correctly on Windows too.
715 "design": (meta["design"].relative_to(HERE).as_posix()
716 if meta["design"] else None),
717 "results": meta["results"].relative_to(HERE).as_posix(),
718 "dose_target": stage_dose, "in_scope": in_scope,
719 "n_rows": len(rows), "n_valid": n_valid,
720 "swept": swept,
721 "sensitivity": sensitivity(meta, rows, targets, stage_dose),
722 "affordability": (affordability(dict(meta, stem=stem), rows, stage_dose)
723 if in_scope else {}),
724 "rows": recs,
725 }
726
727 # Roll per-stage sensitivity up into one verdict per lever group. Only
728 # in-scope stages vote: a 1 dpa response is not evidence about a 15 dpa
729 # target, and mixing them would let a stage that never reached dose
730 # manufacture a verdict.
731 levers = {}
732 for stem, st in stages.items():
733 if not st["in_scope"]:
734 continue
735 for name, s in st["sensitivity"].items():
736 L = levers.setdefault(name, {"columns": s["columns"], "seen_in": [],
737 "peak_response": 0.0, "response": {},
738 "spans": {}, "n_pairs": 0,
739 "drove_invalid": False})
740 L["seen_in"].append(stem)
741 L["peak_response"] = max(L["peak_response"], s["peak_response"])
742 L["n_pairs"] += s["n_pairs"]
743 L["drove_invalid"] = L["drove_invalid"] or s["drove_invalid"]
744 for k, v in s["response"].items():
745 L["response"][k] = max(L["response"].get(k, 0.0), v)
746 for c, sp in s["span"].items():
747 old = L["spans"].get(c)
748 L["spans"][c] = ([min(sp[0], old[0]), max(sp[1], old[1])]
749 if old else sp)
750 for name, L in levers.items():
751 # ZERO valid pairs means UNTESTED, never dead. A dead verdict retires
752 # a lever from every future design, so it must be earned by a pair of
753 # real measurements that bracket the span and did not move.
754 if L["n_pairs"] == 0:
755 L["verdict"] = "inconclusive"
756 elif L["peak_response"] < DEAD_THRESHOLD:
757 L["verdict"] = "dead"
758 else:
759 L["verdict"] = "live"
760 moving = [k for k, v in L["response"].items() if v >= DEAD_THRESHOLD]
761 moving.sort(key=lambda k: -L["response"][k])
762 L["moves"] = [SHORT[k] for k in moving]
763
764 # REACHABILITY. How big a change each observable needs, against the
765 # biggest change any single lever has actually produced across its full
766 # tested span. S20 is why this exists: it swept E_m_i, L_hat and B_111
767 # over their whole boxes and moved d_111 from 1.05 to at most 1.33 nm,
768 # when reaching the measured 6.2 nm needs +490 %. Without this the
769 # planner keeps proposing those levers for d_111 forever, because "live"
770 # only means "moves more than 5 %".
771 #
772 # NOT A PROOF OF IMPOSSIBILITY. Levers can compound, so a residual no
773 # SINGLE lever can close may still be reachable by several together. It
774 # is recorded as a strong hint and ranked on, never as a hard block.
775 reach = {}
776 if best:
777 for k in OBS:
778 s_ = SHORT[k]
779 ratio = (best["ratios"] or {}).get(s_)
780 if not ratio:
781 continue
782 need = (1.0 / ratio) if ratio < 1 else ratio
783 top, who = 0.0, None
784 for name, L in levers.items():
785 r = L["response"].get(k, 0.0)
786 if r > top:
787 top, who = r, name
788 reach[s_] = {
789 "ratio": ratio,
790 "needed_factor": round(need, 3),
791 "needed_pct": round((need - 1) * 100, 1),
792 "best_single_lever_pct": round(top * 100, 1),
793 "best_single_lever": who,
794 "single_lever_sufficient": bool(top >= (need - 1.0)),
795 }
796
797 # Which physics columns has the campaign never varied AT THE TARGET DOSE?
798 # "Never varied" is literal -- the column took one value in every in-scope
799 # design. A column that WAS varied but yielded no valid pair is not listed
800 # here; it carries an `inconclusive` verdict in the lever table instead,
801 # which is a different and weaker kind of ignorance.
802 tested_cols = {c for L in levers.values() for c in L["columns"]}
803 all_cols = set()
804 for stem, st in stages.items():
805 if not st["in_scope"]:
806 continue
807 d = read_design(HERE / st["design"]) if st["design"] else {}
808 if d:
809 all_cols |= {c for c in next(iter(d.values())) if c not in BOOKKEEPING}
810 untested = sorted(all_cols - tested_cols)
811
812 # Roll affordability up across in-scope stages.
813 afford = {}
814 for stem, st in stages.items():
815 for col, rec in (st.get("affordability") or {}).items():
816 a = afford.setdefault(col, {"unaffordable": [], "affordable": [],
817 "evidence": []})
818 for kind in ("unaffordable", "affordable"):
819 for v in rec.get(kind, []):
820 if v not in a[kind]:
821 a[kind].append(v)
822 a["evidence"].extend(rec["evidence"])
823 for col, a in afford.items():
824 for kind in ("unaffordable", "affordable"):
825 try:
826 a[kind + "_numeric"] = sorted(float(v) for v in a[kind])
827 except (TypeError, ValueError):
828 pass
829
830 ledger = {
831 "schema": 1,
832 "updated": None, # set below, from CONTENT not clock
833 "goal": {
834 "statement": ("Find one parameter vector that puts all six "
835 "observables inside their experimental ranges at "
836 "EUROFER97 330 C / 15 dpa, neutron."),
837 "targets_file": TARGETS.name,
838 "condition": targets["condition"],
839 },
840 "stages": stages,
841 "levers": levers,
842 "untested_columns": untested,
843 "reachability": reach,
844 "affordability": afford,
845 "best": best,
846 "best_raw": best_raw,
847 "residuals": best["ratios"] if best else {},
848 "cost_model": cost_model(all_rows),
849 # Preserve curated policy, but MERGE IN any category the defaults have
850 # gained since the ledger was written -- otherwise a new policy kind
851 # (unwired_parameters) is silently shadowed by the stored dict and
852 # never takes effect. Existing keys are never overwritten.
853 "policy": {**DEFAULT_POLICY, **(prev.get("policy") or {})},
854 "notes": prev.get("notes", []), # hand-written insight survives
855 "next": prev.get("next"), # plan.py owns these two fields
856 "next_by_machine": prev.get("next_by_machine", {}),
857 }
858
859 # RE-INGESTING THE SAME RESULTS MUST BE A BYTE-FOR-BYTE NO-OP. The ledger
860 # travels between machines through git, and step 1 of the protocol is
861 # `git pull --rebase`. Any field that moves on every run -- a wall-clock
862 # stamp, or an os.sep-flavoured path -- makes two machines rewrite the same
863 # lines of a 30k-line JSON file to different values, which is a rebase
864 # conflict; and a machine that cannot rebase cannot ingest anyone else's
865 # results, which is exactly the cross-machine learning this file exists to
866 # carry. So `updated` records when the DERIVED CONTENT last changed, not
867 # when the script last ran. Compare as serialized JSON: `prev` came back
868 # through json.loads, so a tuple built here would never equal its list.
869 stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
870 if prev:
871 def body(d):
872 return json.dumps({k: v for k, v in d.items() if k != "updated"},
873 sort_keys=True, indent=2)
874 if body(ledger) == body(prev):
875 stamp = prev.get("updated") or stamp
876 ledger["updated"] = stamp
877 return ledger
878
879
880# --------------------------------------------------------------- render -----
881def fmt(v, sig=3):
882 if v is None:
883 return "-"
884 if isinstance(v, bool):
885 return str(v)
886 if isinstance(v, (int, float)):
887 return "%.*g" % (sig, v)
888 return str(v)
889
890
891def render_guide(led: dict) -> str:
892 T = load_targets()["observables"]
893 b = led["best"]
894 L = []
895 A = L.append
896
897 A("# Calibration Guide - EUROFER97 digital twin")
898 A("")
899 A("*Derived by `learn.py`; content last changed %s (re-running with no new "
900 "results leaves this file untouched). Do not hand-edit: edits are "
901 "overwritten. Durable notes belong in `calibration_ledger.json` under "
902 "`notes`, which is preserved across regenerations.*" % led["updated"])
903 A("")
904 A("## Goal")
905 A("")
906 A(led["goal"]["statement"])
907 A("")
908 c = led["goal"]["condition"]
909 A("Condition: **%s, %s C, %s dpa, %s** - targets in `%s`."
910 % (c["material"], c["T_C"], c["dose_dpa"], c["irradiation"],
911 led["goal"]["targets_file"]))
912 A("")
913
914 A("## Where the campaign stands")
915 A("")
916 if not b:
917 A("No valid (full-dose, grid-clean, converged) row yet.")
918 else:
919 A("Best **extent-verified** row: **%s** from `%s` - **%d/6** "
920 "observables in range, log-distance %s. This leaderboard ranks on "
921 "the verified count first, so a row whose in-band claims have not "
922 "survived a change of grid extent cannot take the top slot no "
923 "matter how many bands it sits in."
924 % (b["label"] or b["row_id"], b["stage"], b["n_in_range"],
925 b["log_distance"]))
926 A("")
927 A("| observable | model | target | range | ratio | in range |")
928 A("|---|---|---|---|---|---|")
929 for k in OBS:
930 s = SHORT[k]
931 v = b["observables"].get(s)
932 t = T[k]
933 inr = (t["lo"] <= v <= t["hi"]) if v is not None else False
934 A("| %s | %s | %s | %s - %s | %s x | %s |"
935 % (s, fmt(v), fmt(t["target"]), fmt(t["lo"]), fmt(t["hi"]),
936 fmt(b["ratios"].get(s)), "yes" if inr else "**no**"))
937 A("")
938 inv = b.get("inventory")
939 if inv:
940 A("### Defect inventory (SIA content locked in loops)")
941 A("")
942 A("Computed from the row own n<->d relation, so no lattice "
943 "constant is assumed.")
944 A("")
945 A("| character | model N x nbar | experiment N x nbar | ratio |")
946 A("|---|---|---|---|")
947 for ch in ("100", "111"):
948 d = inv[ch]
949 A("| <%s> | %s | %s | %s x |"
950 % (ch, fmt(d["model_content"]), fmt(d["exp_content"]),
951 fmt(d["ratio"])))
952 A("| **total** | %s | %s | **%s x** |"
953 % (fmt(inv["total"]["model_content"]),
954 fmt(inv["total"]["exp_content"]),
955 fmt(inv["total"]["ratio"])))
956 A("")
957
958 br = led.get("best_raw")
959 if br and b and (br["stage"], br["row_id"]) != (b["stage"], b["row_id"]):
960 A("### Best row ignoring verification")
961 A("")
962 A("Row **%s** from `%s` scores **%d/6** in range at log-distance %s - "
963 "better on the raw score than the verified leader above, but it is "
964 "%s, so the ranking will not promote it."
965 % (br["label"] or br["row_id"], br["stage"], br["n_in_range"],
966 br["log_distance"],
967 "UNVERIFIED (no extent pair exists yet)"
968 if br["n_verified"] == 0
969 else "verified on only %d observable(s)" % br["n_verified"]))
970 A("")
971 A("| observable | model | target | range | ratio | in range |")
972 A("|---|---|---|---|---|---|")
973 for k in OBS:
974 sk = SHORT[k]
975 v = br["observables"].get(sk)
976 t = T[k]
977 inr = (t["lo"] <= v <= t["hi"]) if v is not None else False
978 A("| %s | %s | %s | %s - %s | %s x | %s |"
979 % (sk, fmt(v), fmt(t["target"]), fmt(t["lo"]), fmt(t["hi"]),
980 fmt(br["ratios"].get(sk)), "yes" if inr else "**no**"))
981 A("")
982 A("Treat this as a lead, not a result: the verification gate exists "
983 "because an unverified row can be a grid artefact (d_cavity = "
984 "0.2825*(0.37V)^(1/3) once took the top of this leaderboard for "
985 "twelve stages). To promote it, re-run it at a second V extent.")
986 A("")
987
988 rch = led.get("reachability") or {}
989 if rch:
990 A("## Can any lever still close this?")
991 A("")
992 A("How far each observable must move from the best row, against the "
993 "largest change any SINGLE lever has actually produced across its "
994 "full tested span. Levers can compound, so a `no` is a strong hint "
995 "that the residual is structural rather than a proof that it is.")
996 A("")
997 A("| observable | ratio | needs | best single lever | that lever | single lever enough? |")
998 A("|---|---|---|---|---|---|")
999 for s_ in ("N_100", "d_100", "N_111", "d_111", "N_void", "d_void"):
1000 r = rch.get(s_)
1001 if not r:
1002 continue
1003 A("| %s | %s x | %+.0f%% | %+.0f%% | `%s` | %s |"
1004 % (s_, fmt(r["ratio"]), r["needed_pct"],
1005 r["best_single_lever_pct"], r["best_single_lever"] or "-",
1006 "yes" if r["single_lever_sufficient"] else "**no**"))
1007 A("")
1008
1009 A("## What each lever does")
1010 A("")
1011 A("A lever is **dead** when its full tested span moves every observable by "
1012 "less than %d%%. Only pairs of rows differing in exactly one lever are "
1013 "used, and only rows that are real measurements (full dose, grid-clean, "
1014 "converged)." % int(DEAD_THRESHOLD * 100))
1015 A("")
1016 A("`inconclusive` means the span was attempted but no pair of valid rows "
1017 "bracketed it - usually because the rows piled at the grid ceiling or "
1018 "never reached dose. An inconclusive lever is an OPEN question, not a "
1019 "closed one.")
1020 A("")
1021 A("| lever | tested span | verdict | valid pairs | peak response | moves | stages |")
1022 A("|---|---|---|---|---|---|---|")
1023 for name, v in sorted(led["levers"].items(),
1024 key=lambda kv: (kv[1]["verdict"] != "live",
1025 -kv[1]["peak_response"])):
1026 span = "; ".join("%s %s->%s" % (c, fmt(s[0]), fmt(s[1]))
1027 for c, s in v["spans"].items())
1028 moves = ", ".join(v["moves"]) or "-"
1029 note = " (drives rows off-grid)" if v["drove_invalid"] else ""
1030 peak = ("%.1f%%" % (v["peak_response"] * 100)) if v["n_pairs"] else "-"
1031 A("| `%s` | %s | **%s**%s | %d | %s | %s | %s |"
1032 % (name, span, v["verdict"], note, v["n_pairs"], peak, moves,
1033 ", ".join(sorted(set(v["seen_in"])))))
1034 A("")
1035 if led["untested_columns"]:
1036 A("### Never varied")
1037 A("")
1038 A("These columns exist in the design but have never taken more than one "
1039 "value, so the campaign has no evidence about them:")
1040 A("")
1041 A("`" + "`, `".join(led["untested_columns"]) + "`")
1042 A("")
1043
1044 A("## Stage history")
1045 A("")
1046 A("Only stages that ran to %s dpa are in scope; the rest are listed for "
1047 "provenance but cast no vote on any lever." % c["dose_dpa"])
1048 A("")
1049 A("| stage | dose | scope | rows | valid | swept | best in-range |")
1050 A("|---|---|---|---|---|---|---|")
1051 for stem, st in sorted(led["stages"].items()):
1052 bi = max([r["n_in_range"] for r in st["rows"] if r["valid"]] or [None])
1053 A("| `%s` | %s | %s | %d | %d | %s | %s |"
1054 % (stem, fmt(st["dose_target"]),
1055 "in" if st["in_scope"] else "out",
1056 st["n_rows"], st["n_valid"],
1057 ", ".join("`%s`" % s for s in st["swept"]) or "-",
1058 bi if bi is not None else "-"))
1059 A("")
1060
1061 A("## Cost model (measured)")
1062 A("")
1063 A("Completed rows only. A row cut at the budget measures the timeout, not "
1064 "the cost, so timeouts are counted in their own column.")
1065 A("")
1066 A("| machine | completed | median row | min | max | timed out |")
1067 A("|---|---|---|---|---|---|")
1068 for m, cm in sorted(led["cost_model"].items()):
1069 if cm["n"]:
1070 A("| %s | %d | %s h | %s h | %s h | %d |"
1071 % (m, cm["n"], cm["median_row_h"], cm["min_row_h"],
1072 cm["max_row_h"], cm["n_timed_out"]))
1073 else:
1074 A("| %s | 0 | - | - | - | %d |" % (m, cm["n_timed_out"]))
1075 A("")
1076 A("`plan.py` sizes a stage from this table and the machine slot count, and "
1077 "refuses to propose a design whose estimated cost exceeds the machine "
1078 "row budget.")
1079 A("")
1080
1081 aff = led.get("affordability") or {}
1082 if aff:
1083 A("## Unaffordable lever values")
1084 A("")
1085 A("Measured, not assumed: a level counts here only if it produced ZERO "
1086 "full-dose rows while another level of the same lever - everything "
1087 "else held equal - produced at least one. `plan.py` will not place a "
1088 "design point on these values.")
1089 A("")
1090 A("| lever | unaffordable at | evidence |")
1091 A("|---|---|---|")
1092 for col, rec in sorted(aff.items()):
1093 A("| `%s` | %s | %s |"
1094 % (col, ", ".join(str(v) for v in rec["unaffordable"]),
1095 "; ".join(rec["evidence"])))
1096 A("")
1097
1098 unw = (led.get("policy") or {}).get("unwired_parameters") or {}
1099 if unw:
1100 A("## Parameters the physics never reads")
1101 A("")
1102 A("A design can set these and the solver ignores them, so sweeping one "
1103 "produces bit-identical rows. `plan.py` refuses to propose them. "
1104 "These are BUGS to fix, not physics conclusions - remove the entry "
1105 "once the code reads the parameter.")
1106 A("")
1107 for k, why in sorted(unw.items()):
1108 A("- **`%s`** - %s" % (k, why))
1109 A("")
1110
1111 dep = (led.get("policy") or {}).get("deprioritized_observables") or {}
1112 if dep:
1113 A("## Deferred observables")
1114 A("")
1115 A("The planner will not propose levers for these until the entry is "
1116 "removed from `calibration_ledger.json` under "
1117 "`policy.deprioritized_observables`. They still appear in every "
1118 "score above - they are deferred, not ignored.")
1119 A("")
1120 for k, reason in sorted(dep.items()):
1121 A("- **%s** - %s" % (k, reason))
1122 A("")
1123
1124 if led.get("notes"):
1125 A("## Curated notes")
1126 A("")
1127 for n in led["notes"]:
1128 A("- %s" % n)
1129 A("")
1130
1131 claims = led.get("next_by_machine") or {}
1132 if claims:
1133 A("## Claimed stages")
1134 A("")
1135 A("One row per machine. A machine claims a stage by running `plan.py "
1136 "--write` on it; the claim is not a lock, only a record of what that "
1137 "machine was last told to run.")
1138 A("")
1139 A("| machine | stage | levers | rows | design |")
1140 A("|---|---|---|---|---|")
1141 for idx in sorted(claims, key=lambda k: int(k)):
1142 c = claims[idx]
1143 A("| %s (%s) | **%s** | %s | %s | `%s` |"
1144 % (idx, c.get("machine_name", "?"), c.get("stage"),
1145 ", ".join("`%s`" % l for l in c.get("levers", [])),
1146 c.get("n_rows", "?"), c.get("design", "")))
1147 A("")
1148
1149 A("## Next stage")
1150 A("")
1151 nx = led.get("next")
1152 if not nx:
1153 A("None proposed yet - run `python plan.py` on the machine that will "
1154 "execute it.")
1155 else:
1156 A("**%s** - %s" % (nx["stage"], nx["rationale"]))
1157 A("")
1158 A("Sweeping: %s" % ", ".join("`%s`" % s for s in nx["levers"]))
1159 A("")
1160 if nx.get("design"):
1161 A("Design: `%s` (%s rows)" % (nx["design"], nx.get("n_rows", "?")))
1162 A("")
1163 if nx.get("commands"):
1164 A("Run it with:")
1165 A("")
1166 A("```bash")
1167 for line in nx["commands"]:
1168 A(line)
1169 A("```")
1170 A("")
1171
1172 A("## Multi-machine protocol")
1173 A("")
1174 A("Results travel through git; `campaign_ops.sync_results()` is safe to "
1175 "call while a run is in flight (rows are appended, so the newest lands "
1176 "in the next sync).")
1177 A("")
1178 A("```bash")
1179 A("git pull --rebase origin main # 1. take everyone results")
1180 A("python learn.py # 2. re-derive ledger + this guide")
1181 A("python plan.py # 3. propose this machine next stage")
1182 A("# ... review the printed command, then run it ...")
1183 A("python -c \"import campaign_ops as c; c.sync_results()\" # 4. publish rows")
1184 A("```")
1185 A("")
1186 A("Two files must never be committed - both are in `.gitignore` and both "
1187 "have caused an outage before: `CAMPAIGN_STOP` (every machine that "
1188 "pulled it halted at startup, commit 8a600c7) and `results/*.pid`.")
1189 A("")
1190 return "\n".join(L) + "\n"
1191
1192
1193def main(argv=None):
1194 ap = argparse.ArgumentParser(description="Re-ingest calibration results.")
1195 ap.add_argument("--quiet", action="store_true")
1196 a = ap.parse_args(argv)
1197
1198 led = ingest()
1199 LEDGER.write_text(json.dumps(led, indent=2), encoding="utf-8")
1200 GUIDE.write_text(render_guide(led), encoding="utf-8")
1201
1202 if not a.quiet:
1203 b = led["best"]
1204 print("ingested %d stage files, %d rows (%d valid)"
1205 % (len(led["stages"]),
1206 sum(s["n_rows"] for s in led["stages"].values()),
1207 sum(s["n_valid"] for s in led["stages"].values())))
1208 if b:
1209 print("best (verified): %s (%s) %d/6 in range, log-distance %s"
1210 % (b["label"] or b["row_id"], b["stage"], b["n_in_range"],
1211 b["log_distance"]))
1212 br = led.get("best_raw")
1213 if br and b and (br["stage"], br["row_id"]) != (b["stage"], b["row_id"]):
1214 print("best (raw score, unpromoted): %s (%s) %d/6 in range, "
1215 "log-distance %s"
1216 % (br["label"] or br["row_id"], br["stage"],
1217 br["n_in_range"], br["log_distance"]))
1218 by = {}
1219 for k, v in led["levers"].items():
1220 by.setdefault(v["verdict"], []).append(k)
1221 print("in-scope stages: %d of %d"
1222 % (sum(1 for s in led["stages"].values() if s["in_scope"]),
1223 len(led["stages"])))
1224 print("levers: %d live, %d dead, %d inconclusive"
1225 % (len(by.get("live", [])), len(by.get("dead", [])),
1226 len(by.get("inconclusive", []))))
1227 for verdict in ("live", "dead", "inconclusive"):
1228 if by.get(verdict):
1229 print(" %-13s %s" % (verdict + ":", ", ".join(sorted(by[verdict]))))
1230 if led["untested_columns"]:
1231 print(" never varied: " + ", ".join(led["untested_columns"]))
1232 print("wrote %s and %s" % (LEDGER.name, GUIDE.name))
1233 return 0
1234
1235
1236if __name__ == "__main__":
1237 raise SystemExit(main())
recover_V(dict row)
Definition learn.py:250
list covarying_groups(dict design)
Definition learn.py:371
float worst_margin(dict row, dict targets, set only=None)
Definition learn.py:318
str render_guide(dict led)
Definition learn.py:891
fmt(v, sig=3)
Definition learn.py:881
set verified_obs(dict row, dict ext)
Definition learn.py:285
dict sensitivity(dict stage, list rows, dict targets, float dose_target=15.0)
Definition learn.py:397
dict score(dict row, dict targets, dict ext=None)
Definition learn.py:293
list read_rows(Path path)
Definition learn.py:132
main(argv=None)
Definition learn.py:1193
dict load_targets()
Definition learn.py:101
float log_distance(dict row, dict targets)
Definition learn.py:357
dict affordability(dict stage, list rows, float dose_target)
Definition learn.py:459
_margin_or_none(dict row, dict targets, dict sc)
Definition learn.py:347
dict read_design(path)
Definition learn.py:145
dict ingest()
Definition learn.py:587
classify(dict row, float dose_target=15.0)
Definition learn.py:175
dict extent_pairs(list all_rows)
Definition learn.py:258
dict discover_stages()
Definition learn.py:105
tuple group_key(dict design_row, list group)
Definition learn.py:392
grid_components(dict row)
Definition learn.py:157
inventory(dict row, dict targets)
Definition learn.py:518
dict cost_model(list all_rows)
Definition learn.py:550