RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
plan.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Propose the next calibration stage for whatever machine this is.
3
4READS calibration_ledger.json (written by learn.py)
5WRITES design/<stage>_calib.csv, design/<stage>_labels.json, and the `next`
6 block of the ledger + CALIBRATION_GUIDE.md
7
8IT DOES NOT LAUNCH ANYTHING. It prints the exact run_ensemble command, sized
9to this machine slot count and row budget, and stops. A design nobody read
10can burn 20 h of a machine time, and the machine is usually one the author is
11not sitting at.
12
13HOW IT CHOOSES. Greedy, and deliberately simple enough to argue with:
14
15 1. Pin every parameter to the BEST VALID ROW in the ledger. That row is the
16 only point known to reach full dose grid-clean, so it is the only safe
17 place to stand while varying something.
18 2. Rank the residuals |log10(model/target)| over the six observables.
19 3. Choose levers for the biggest residual, preferring in this order:
20 never-varied > inconclusive > live
21 and never proposing a lever the ledger calls `dead`.
22 4. Size the factorial to the machine slot count so every row runs
23 concurrently, and refuse if the estimated cost exceeds the row budget.
24
25The lever->observable map below is the ONE piece of physics judgement in this
26file. It is explicit so it can be corrected as the campaign learns, rather
27than buried in a heuristic.
28
29Usage:
30 python plan.py # propose for this machine
31 python plan.py --machine 0 # propose as if machine 0
32 python plan.py --levers eta,f_cl_i # override the choice
33 python plan.py --stage S15 # override the name
34"""
35from __future__ import annotations
36
37import argparse
38import csv
39import json
40import math
41import re
42import sys
43from pathlib import Path
44
45sys.stdout.reconfigure(encoding="utf-8", errors="replace")
46
47HERE = Path(__file__).resolve().parent
48LEDGER = HERE / "calibration_ledger.json"
49MACHINES = HERE / "machines.json"
50
51# ---------------------------------------------------------------------------
52# WHICH LEVERS PLAUSIBLY MOVE WHICH OBSERVABLE.
53#
54# Physics judgement, stated openly. `candidates` are ordered by expected
55# strength. A lever is only ever proposed if the ledger has not called it
56# dead, so this table degrades gracefully as verdicts accumulate.
57# ---------------------------------------------------------------------------
58LEVER_MAP = {
59 # Too many loops / too much SIA locked in loops -> attack production and
60 # the clustered fraction born in cascades before touching sink geometry.
61 "N_100": ["eta", "f_cl_i", "E_b_i2", "i_mobile", "s_i"],
62 "N_111": ["eta", "f_cl_i", "E_b_i2", "B_111", "i_mobile"],
63 # Loops too small -> growth vs nucleation balance, then mobility.
64 "d_100": ["f_cl_i", "eta", "E_b_i2", "L_hat", "phi_max_junc"],
65 "d_111": ["B_111", "E_b_i2", "f_cl_i", "E_m_i", "L_hat"],
66 # Cavities absent -> vacancy clustering, surface energy, He.
67 "N_void": ["f_cl_v", "gamma_s", "E_b_v2", "E_b_hV_1", "s_v"],
68 "d_void": ["gamma_s", "E_b_v2", "f_cl_v", "E_m_v", "E_b_hV_1"],
69}
70
71# SIGN of d(observable)/d(lever), where it is unambiguous. Used to spend rows
72# on the corrective side instead of bracketing symmetrically: N_100 is 12x too
73# HIGH and eta raises it, so eta is swept DOWNWARD from the base rather than
74# across the whole box. Omit a pair whose sign is genuinely uncertain -- a
75# missing entry falls back to a symmetric bracket, which is never wrong, only
76# less efficient.
77DIRECTION = {
78 ("eta", "N_100"): +1, ("eta", "N_111"): +1, # more production, more nuclei
79 ("eta", "d_100"): -1, ("eta", "d_111"): -1, # ... spread over more loops
80 ("f_cl_i", "N_100"): +1, ("f_cl_i", "N_111"): +1, # born clustered = born nucleated
81 ("f_cl_i", "d_100"): -1, ("f_cl_i", "d_111"): -1,
82 ("E_b_i2", "N_100"): +1, ("E_b_i2", "N_111"): +1, # small clusters survive
83 ("E_b_i2", "d_100"): -1, ("E_b_i2", "d_111"): -1,
84 ("B_111", "d_111"): +1,
85 ("f_cl_v", "N_void"): +1,
86 # V1 measured both of these directly, one lever at a time:
87 # gamma_s 3.0 -> 1.5 N_void 0.00036x -> 47860x (lower capillary
88 # penalty, small cavities survive)
89 # E_m_v 0.50 -> 0.90 N_void 0.165x -> 6793x (slower vacancies,
90 # less recombination before they cluster)
91 ("gamma_s", "N_void"): -1, ("gamma_s", "d_void"): -1,
92 ("E_m_v", "N_void"): +1, ("E_m_v", "d_void"): +1,
93}
94
95# Prior box. A design must never leave this; values are physical bounds, not
96# preferences. (lo, hi, kind) where kind picks linear or log spacing.
97PRIOR_BOX = {
98 "eta": (0.05, 0.60, "log"),
99 "f_cl_i": (0.02, 0.50, "log"),
100 "f_cl_v": (0.05, 0.80, "linear"),
101 "E_b_i2": (0.40, 1.20, "linear"),
102 "E_b_v2": (0.10, 0.45, "linear"),
103 "B_111": (0.20, 0.60, "linear"),
104 "L_hat": (10.0, 3000.0, "log"),
105 "i_mobile": (5, 60, "linear"),
106 "gamma_s": (1.5, 3.0, "linear"),
107 "s_i": (1.0, 3.0, "linear"),
108 "s_v": (1.0, 3.0, "linear"),
109 "E_m_i": (0.25, 0.55, "linear"),
110 "E_m_v": (0.50, 0.90, "linear"),
111 "phi_max_junc": (0.05, 0.60, "linear"),
112 "E_b_hV_1": (1.0, 3.0, "linear"),
113}
114
115# NUMERICAL-VALIDITY CEILINGS, distinct from the physical prior box above.
116# A value can be perfectly physical and still make the run unusable -- not by
117# timing out (that is `affordability`, learned from results) but by driving the
118# solver grid into pile-up, which invalidates the whole row including the
119# observables the stage was actually about. Entries are curated, and each one
120# cites the evidence. Intersected with PRIOR_BOX by box_for().
121VALIDITY_BOX = {
122 # S14: f_cl_v = 0.5 ran grid-clean to 15 dpa (occ_100 = 0.34); f_cl_v = 0.7
123 # piled at the ceiling (occ_100 = 0.90, grid_limited). The boundary inside
124 # (0.5, 0.7) is unresolved, so 0.5 is the highest value known to work.
125 # NOTE the caveat: S14 moved f_cl_v, E_b_v2 and s_v together, so the
126 # pile-up is not attributable to f_cl_v alone. The cap is conservative for
127 # that reason and can be lifted once a stage varies f_cl_v on its own.
128 "f_cl_v": (None, 0.5),
129}
130
131# A lever whose corrective direction retains less than this fraction of its
132# prior box -- after affordability clipping -- is not worth a stage.
133MIN_USABLE_SPAN = 0.10
134
135# Columns the solver reads as counts, not reals. A design that writes
136# i_mobile = 32.5 is not a smaller step than 33 -- it is an invalid input.
137INTEGER_COLS = {"i_mobile", "v_mobile", "i_discrete", "v_discrete"}
138
139BOOKKEEPING = {"row_id", "condition", "cond_row_id", "matrix", "base_idx",
140 "param_j", "theta_id"}
141
142# Levers that are COUNTS, not continuous parameters. run_ensemble takes
143# i_mobile through int() at construction (line ~413), so an evenly spaced
144# float grid would run 32 while the design recorded 32.5 -- the row's own
145# provenance would then disagree with what was computed, and learn.py would
146# pair rows on a value no solver ever saw. Round here instead.
147INTEGER_LEVERS = {"i_mobile"}
148
149
150def load_ledger() -> dict:
151 if not LEDGER.exists():
152 raise SystemExit("no calibration_ledger.json -- run `python learn.py` first")
153 return json.loads(LEDGER.read_text(encoding="utf-8"))
154
155
156def detect(machine_index=None) -> tuple:
157 reg = json.loads(MACHINES.read_text(encoding="utf-8"))
158 if machine_index is not None:
159 for m in reg["machines"]:
160 if m["index"] == machine_index:
161 return m, reg
162 raise SystemExit("no machine with index %s in machines.json" % machine_index)
163 sys.path.insert(0, str(HERE))
164 import run_ensemble as RE
165 return RE.detect_machine(reg), reg
166
167
168def build_commands(stage: str, machine: dict, reg: dict, slots: int,
169 budget_h: float, share: list, grid_v: int = 2000,
170 grid_vbin: int = 20) -> list:
171 """The run_ensemble invocation(s) for this stage.
172
173 THE SHARD INDEX IS NOT THE REGISTRY INDEX. run_ensemble assigns a row with
174 `assign_machine(row_id, of) == row_id % of`, so with `--of 1` only
175 `--machine 0` is ever given any rows. Passing this host registry index
176 (2 for the workstation) with `--of 1` silently assigns it ZERO rows and the
177 run exits having done nothing. Shard index is therefore always the
178 position within the participating set, while the registry index is used
179 only to name the output file so two machines cannot collide.
180 """
181 design_rel = "design/%s_calib.csv" % stage
182
183 def one(shard: int, of: int, reg_index: int, workers: int) -> list:
184 return [
185 "python run_ensemble.py \\",
186 " --design %s \\" % design_rel,
187 " --conditions conditions_S8.json \\",
188 " --spec parameters_S4.json \\",
189 " --out results/%s_calib_machine%d.jsonl \\" % (stage, reg_index),
190 " --machine %d --of %d \\" % (shard, of),
191 " --equations bin_moment --i-discrete 100 --i-bin 36 \\",
192 " --v-discrete 5 --v-bin %d --allow-mixed \\" % grid_vbin,
193 " --I 80000 --V %d --dose 15.0 --lnl 1 --rtol 1e-5 \\" % grid_v,
194 " --solver-mode full_system \\",
195 " --timeout-s %d --workers %d --omp-threads 1"
196 % (int(budget_h * 3600), workers),
197 ]
198
199 if not share:
200 return one(0, 1, machine["index"], slots)
201
202 lines = []
203 for shard, reg_index in enumerate(share):
204 m = next(x for x in reg["machines"] if x["index"] == reg_index)
205 lines.append("# shard %d/%d -- run this ON %s"
206 % (shard, len(share), m["name"]))
207 lines.extend(one(shard, len(share), reg_index, m.get("slots", 1)))
208 lines.append("")
209 return lines
210
211
212def residual_rank(led: dict) -> tuple:
213 """Observables ordered by how badly they miss, worst first.
214
215 Returns (chaseable, deferred). Deferred observables are still ranked and
216 reported -- they are just not allowed to select levers. See
217 `policy.deprioritized_observables` in the ledger for why each is deferred.
218 """
219 r = led.get("residuals") or {}
220 dep = (led.get("policy") or {}).get("deprioritized_observables") or {}
221 out = [(k, abs(math.log10(v))) for k, v in r.items() if v]
222 out.sort(key=lambda kv: -kv[1])
223 return ([kv for kv in out if kv[0] not in dep],
224 [kv for kv in out if kv[0] in dep])
225
226
227def pick_levers(led: dict, worst: list, n_wanted: int = 3) -> tuple:
228 """Choose levers for the worst residual, honouring the ledger verdicts."""
229 verdict = {}
230 for name, L in led["levers"].items():
231 for c in L["columns"]:
232 # A column inherits the strongest claim made about any group it was
233 # in: live beats inconclusive beats dead is NOT the order -- dead is
234 # only earned by a group that isolated it, so a column that is dead
235 # in one group and live in another is treated as live.
236 cur = verdict.get(c)
237 rank = {"live": 3, "inconclusive": 2, "dead": 1}
238 if cur is None or rank[L["verdict"]] > rank[cur]:
239 verdict[c] = L["verdict"]
240 never = set(led.get("untested_columns") or [])
241 unwired = set(((led.get("policy") or {}).get("unwired_parameters") or {}))
242
243 def priority(col):
244 if col in unwired:
245 return 99 # physics never reads it -- see policy
246 if col in never:
247 return 0 # never varied -- most information
248 v = verdict.get(col)
249 if v == "inconclusive":
250 return 1
251 if v == "live":
252 return 2
253 return 99 # dead -- never propose
254
255 params = (led.get("best") or {}).get("params") or {}
256 ratios = led.get("residuals") or {}
257 chosen, why, skipped = [], [], []
258 for obs, miss in worst:
259 for col in LEVER_MAP.get(obs, []):
260 if col in chosen or priority(col) == 99:
261 continue
262 if col not in PRIOR_BOX:
263 continue
264 try:
265 base = float(params.get(col))
266 except (TypeError, ValueError):
267 base = sum(PRIOR_BOX[col][:2]) / 2.0
268 pref = prefer_for(col, obs, ratios.get(obs))
269 span = usable_span(col, led, base, pref)
270 if span < MIN_USABLE_SPAN:
271 skipped.append((col, obs, span))
272 continue
273 chosen.append(col)
274 why.append((col, obs, miss, priority(col)))
275 if len(chosen) >= n_wanted:
276 return chosen, why, skipped
277 return chosen, why, skipped
278
279
280def box_for(col: str, led: dict, base: float):
281 """The prior box for `col`, clipped to the region measured as affordable.
282
283 THE CLIFF LOCATION IS UNKNOWN. S14 shows E_b_i2 = 0.60 never reaches dose
284 and 0.75 always does; the boundary is somewhere in between and nothing
285 measures where. Placing a design point inside that unresolved interval is
286 a coin flip that costs a full row budget when it loses, so the box is
287 clipped to the nearest value KNOWN to complete -- not to just above the
288 value known to fail.
289 """
290 lo, hi, kind = PRIOR_BOX[col]
291 vlo, vhi = VALIDITY_BOX.get(col, (None, None))
292 if vlo is not None:
293 lo = max(lo, vlo)
294 if vhi is not None:
295 hi = min(hi, vhi)
296 rec = (led.get("affordability") or {}).get(col)
297 if not rec:
298 return lo, hi, kind
299 bad = rec.get("unaffordable_numeric") or []
300 good = rec.get("affordable_numeric") or []
301 below = [u for u in bad if u < base]
302 above = [u for u in bad if u > base]
303 if below:
304 safe = [g for g in good if g > max(below)]
305 lo = max(lo, min(safe) if safe else max(below))
306 if above:
307 safe = [g for g in good if g < min(above)]
308 hi = min(hi, max(safe) if safe else min(above))
309 return lo, hi, kind
310
311
312def usable_span(col: str, led: dict, base: float, prefer: int) -> float:
313 """Fraction of the prior box still available in the corrective direction."""
314 lo0, hi0, kind = PRIOR_BOX[col]
315 lo, hi, _ = box_for(col, led, base)
316 full = (math.log10(hi0 / lo0) if kind == "log" and lo0 > 0 else hi0 - lo0)
317 if prefer < 0:
318 a, b = lo, min(base, hi)
319 elif prefer > 0:
320 a, b = max(base, lo), hi
321 else:
322 a, b = lo, hi
323 if b <= a:
324 return 0.0
325 got = (math.log10(b / a) if kind == "log" and a > 0 else b - a)
326 return max(0.0, got / full) if full else 0.0
327
328
329def levels_for(col: str, base: float, n: int, prefer: int = 0,
330 box=None) -> list:
331 """n levels for `col`, spanning the prior box.
332
333 `prefer` is the corrective direction: -1 sweeps from the box floor up to
334 the base, +1 from the base up to the ceiling, 0 spans the whole box. The
335 base is always one of the endpoints when prefer != 0, so the stage stays
336 anchored to the point already known to be grid-clean.
337 """
338 lo, hi, kind = box or PRIOR_BOX[col]
339 base = min(max(base, lo), hi)
340 if prefer < 0:
341 lo, hi = lo, base
342 elif prefer > 0:
343 lo, hi = base, hi
344 if hi <= lo: # base sits on the box edge -- fall back
345 lo, hi, _ = box or PRIOR_BOX[col]
346 if kind == "log" and lo > 0:
347 a, b = math.log10(lo), math.log10(hi)
348 vals = [10 ** (a + (b - a) * i / (n - 1)) for i in range(n)]
349 else:
350 vals = [lo + (hi - lo) * i / (n - 1) for i in range(n)]
351 if col in INTEGER_LEVERS:
352 # sorted(set(...)) can return FEWER levels than asked when the box is
353 # narrow; that is correct -- duplicate rows would just be re-running
354 # the same point -- and build_design sizes the factorial off the list.
355 vals = sorted({int(round(v)) for v in vals})
356 return vals
357
358
359def prefer_for(col: str, obs: str, ratio) -> int:
360 """Which way to sweep `col` to shrink the residual on `obs`."""
361 sign = DIRECTION.get((col, obs))
362 if not sign or not ratio:
363 return 0
364 # ratio > 1 means the model is too HIGH, so move the lever the way that
365 # lowers the observable.
366 return -sign if ratio > 1 else +sign
367
368
369def parse_box_override(spec: str) -> dict:
370 """--box i_mobile=5:32 -> {"i_mobile": (5.0, 32.0)}."""
371 out = {}
372 for part in (spec or "").split(","):
373 part = part.strip()
374 if not part:
375 continue
376 col, _, rng = part.partition("=")
377 lo, _, hi = rng.partition(":")
378 col = col.strip()
379 if col not in PRIOR_BOX:
380 raise SystemExit("--box %s: unknown lever (not in PRIOR_BOX)" % col)
381 try:
382 out[col] = (float(lo), float(hi))
383 except ValueError:
384 raise SystemExit("--box %s: expected lever=lo:hi" % part)
385 if out[col][0] >= out[col][1]:
386 raise SystemExit("--box %s: lo must be < hi" % part)
387 return out
388
389
390def build_design(led: dict, levers: list, n_levels: list, stage: str,
391 row_base: int, why: list = None, box_override: dict = None) -> tuple:
392 best = led["best"]
393 if not best:
394 raise SystemExit("ledger has no valid best row -- nothing safe to pin to")
395 params = dict(best["params"])
396 target_obs = {c: o for c, o, _, _ in (why or [])}
397 ratios = led.get("residuals") or {}
398
399 grids = []
400 for col, n in zip(levers, n_levels):
401 try:
402 base = float(params.get(col))
403 except (TypeError, ValueError):
404 base = sum(PRIOR_BOX[col][:2]) / 2.0
405 obs = target_obs.get(col)
406 lo, hi, kind = box_for(col, led, base)
407 ov = (box_override or {}).get(col)
408 if ov:
409 # INTERSECT, never replace. An override is a statement about where
410 # this stage should spend its rows, not a licence to leave the
411 # physical box or re-enter a span the ledger measured as
412 # unaffordable.
413 lo, hi = max(lo, ov[0]), min(hi, ov[1])
414 if lo >= hi:
415 raise SystemExit(
416 "--box %s=%g:%g leaves nothing after intersecting the "
417 "admissible span (%g, %g) for that lever."
418 % (col, ov[0], ov[1], *box_for(col, led, base)[:2]))
419 grids.append(levels_for(col, base, n,
420 prefer_for(col, obs, ratios.get(obs)),
421 (lo, hi, kind)))
422
423 combos = [[]]
424 for g in grids:
425 combos = [c + [v] for c in combos for v in g]
426
427 header = ["row_id", "condition", "cond_row_id", "matrix", "base_idx",
428 "param_j", "theta_id"] + [k for k in params if k not in BOOKKEEPING]
429 rows, labels = [], {}
430 for i, combo in enumerate(combos):
431 rid = row_base + i
432 rec = {"row_id": rid, "condition": best.get("condition") or "S330",
433 "cond_row_id": rid, "matrix": "S", "base_idx": i,
434 "param_j": -1, "theta_id": i}
435 for k in header[7:]:
436 rec[k] = params[k]
437 tag = []
438 for col, v in zip(levers, combo):
439 if col in INTEGER_COLS:
440 v = int(round(v))
441 rec[col] = repr(v) if isinstance(v, float) else v
442 tag.append("%s%.3g" % (col.replace("_", ""), v))
443 rows.append(rec)
444 labels[str(rid)] = "%s%02d_%s" % (stage, i, "_".join(tag))
445 return header, rows, labels
446
447
448def cost_key(cm: dict, machine: dict):
449 """Which cost_model entry is this registry machine?
450
451 THE TWO FILES KEY THE SAME MACHINE DIFFERENTLY. learn.py keys cost_model by
452 platform.node() -- the value rows actually carry -- while machines.json keys
453 by human name. 'MATRIX-PC2' never equals 'Matrix-PC', so entry 1 matched
454 nothing and fell through to the "scaled from another machine" branch, which
455 then picked MATRIX-PC2's OWN numbers (it has the worst median) and inflated
456 them by the 0.5/0.4 speed ratio: a measured 14.02 h worst row was reported
457 as 17.5 h and tripped the budget refusal on a design that fits. Match on
458 the registry's own node_regex, which is the one field that already means
459 "what this host calls itself".
460 """
461 for k in (machine["name"], machine["name"].replace(" ", "-")):
462 if k in cm:
463 return k
464 pats = [r for r in [(machine.get("match") or {}).get("node_regex")] if r]
465 pats += [a["node_regex"] for a in machine.get("match_alternatives", [])
466 if a.get("node_regex")]
467 for k in cm:
468 if any(re.match(p, k, re.I) for p in pats):
469 return k
470 return None
471
472
473def estimate_cost(led: dict, machine: dict, n_rows: int) -> dict:
474 cm = led.get("cost_model", {})
475 # Prefer this machine own measured rows; fall back to the campaign worst
476 # case scaled by the registry speed ratio.
477 key = cost_key(cm, machine)
478 mine = cm.get(key) if key else None
479 if mine:
480 med, mx = mine["median_row_h"], mine["max_row_h"]
481 src = "measured on this machine"
482 elif any(v.get("n") for v in cm.values()):
483 worst = max((v for v in cm.values() if v.get("n")),
484 key=lambda v: v["median_row_h"])
485 ref_speed = 0.5
486 scale = ref_speed / max(machine.get("speed", 1.0), 1e-6)
487 med, mx = worst["median_row_h"] * scale, worst["max_row_h"] * scale
488 src = "scaled from another machine by registry `speed`"
489 else:
490 return {"known": False}
491 slots = machine.get("slots", 1)
492 waves = math.ceil(n_rows / max(slots, 1))
493 return {"known": True, "source": src, "median_row_h": round(med, 2),
494 "max_row_h": round(mx, 2), "slots": slots, "waves": waves,
495 "est_wall_h": round(mx * waves, 1)}
496
497
498def main(argv=None):
499 ap = argparse.ArgumentParser(description="Propose the next calibration stage.")
500 ap.add_argument("--machine", type=int, default=None)
501 ap.add_argument("--stage", default=None)
502 ap.add_argument("--levers", default=None,
503 help="comma-separated override, e.g. eta,f_cl_i,E_b_i2")
504 ap.add_argument("--for", dest="for_obs", default=None,
505 help="observable an explicit --levers override is aimed at, "
506 "e.g. --for N_void; without it the sweep is symmetric "
507 "because the corrective direction is unknown")
508 ap.add_argument("--levels", default=None,
509 help="comma-separated level count per lever, e.g. 3,2,2")
510 ap.add_argument("--row-base", type=int, default=None)
511 ap.add_argument("--box", default=None,
512 help="narrow a lever's box for this stage only, e.g. "
513 "--box gamma_s=1.5:1.8. Use to target a region a "
514 "sibling stage does not cover, instead of duplicating "
515 "it. Clamps into PRIOR_BOX; never widens past it.")
516 ap.add_argument("--grid-v", type=int, default=2000,
517 help="max vacancy cluster size (--V). Raise for cavity "
518 "stages: at V=2000 every setting that grows realistic "
519 "cavities hits the ceiling and returns GRID+NOCONV.")
520 ap.add_argument("--grid-vbin", type=int, default=20,
521 help="vacancy bin count. Scale with --grid-v to hold the "
522 "bin ratio: r = (V/v_discrete)^(1/v_bin), which is "
523 "1.349 at the V=2000/v_bin=20 default.")
524 ap.add_argument("--budget-h", type=float, default=None,
525 help="row budget in hours; overrides machines.json")
526 ap.add_argument("--share", default=None,
527 help="split this design across registry machine indices, "
528 "e.g. --share 0,2 for MacBook Pro + workstation")
529 ap.add_argument("--write", action="store_true",
530 help="write the design files and update the ledger/guide")
531 a = ap.parse_args(argv)
532
533 led = load_ledger()
534 if a.box:
535 for item in a.box.split(","):
536 col, rng = item.split("=")
537 lo, hi = (float(x) for x in rng.split(":"))
538 col = col.strip()
539 if col not in PRIOR_BOX:
540 raise SystemExit("--box names unknown lever %r" % col)
541 plo, phi, kind = PRIOR_BOX[col]
542 # Clamp INTO the prior box: a stage may narrow its search, never
543 # widen it past a bound that exists for physical reasons.
544 PRIOR_BOX[col] = (max(lo, plo), min(hi, phi), kind)
545 print("box override: %s -> %.4g .. %.4g"
546 % (col, PRIOR_BOX[col][0], PRIOR_BOX[col][1]))
547 machine, reg = detect(a.machine)
548 worst, deferred = residual_rank(led)
549
550 if a.levers:
551 levers = [s.strip() for s in a.levers.split(",") if s.strip()]
552 # An explicit override still may not sweep a parameter the physics does
553 # not read: those rows come back bit-identical, so the stage is pure
554 # waste no matter who asked for it.
555 unwired = ((led.get("policy") or {}).get("unwired_parameters") or {})
556 bad = [c for c in levers if c in unwired]
557 if bad:
558 for c in bad:
559 print("REFUSING --levers %s: the physics never reads it." % c)
560 print(" %s" % unwired[c])
561 raise SystemExit(2)
562 tgt = a.for_obs or "operator override"
563 why = [(c, tgt, 0.0, -1) for c in levers]
564 skipped = []
565 else:
566 levers, why, skipped = pick_levers(led, worst)
567 if not levers:
568 raise SystemExit("no admissible levers -- every candidate is dead in "
569 "the ledger. Widen LEVER_MAP or PRIOR_BOX.")
570
571 slots = machine.get("slots", 1)
572 if a.levels:
573 n_levels = [int(x) for x in a.levels.split(",")]
574 else:
575 # Largest full factorial that fits the slot count in one wave.
576 n_levels = [2] * len(levers)
577 while math.prod(n_levels) * 3 // 2 <= slots and max(n_levels) < 4:
578 n_levels[n_levels.index(min(n_levels))] += 1
579 n_rows = math.prod(n_levels)
580
581 # Stage name: next S-number after the highest already present.
582 if a.stage:
583 stage = a.stage
584 else:
585 nums = [int(s[1:].split("_")[0]) for s in led["stages"]
586 if s.startswith("S") and s[1:].split("_")[0].isdigit()]
587 stage = "S%d" % ((max(nums) if nums else 14) + 1)
588 row_base = a.row_base if a.row_base is not None else 4000 + \
589 (int(stage[1:]) - 10) * 100
590
591 header, rows, labels = build_design(led, levers, n_levels, stage, row_base,
592 why, parse_box_override(a.box))
593 share = [int(x) for x in a.share.split(",")] if a.share else []
594 cost = estimate_cost(led, machine,
595 math.ceil(n_rows / len(share)) if share else n_rows)
596
597 if a.budget_h:
598 budget_h, budget_src = a.budget_h, "--budget-h"
599 elif machine.get("timeout_s"):
600 budget_h, budget_src = machine["timeout_s"] / 3600.0, "machines.json (this machine)"
601 elif reg.get("timeout_s"):
602 budget_h, budget_src = reg["timeout_s"] / 3600.0, "machines.json (global default)"
603 else:
604 budget_h, budget_src = 20.0, "built-in fallback"
605 over = (cost.get("known") and budget_h and cost["max_row_h"] > budget_h)
606
607 b = led["best"]
608 print("machine : %d %s (%d slots)" % (machine["index"], machine["name"], slots))
609 print("pinned to : %s (%s, %d/6 in range)"
610 % (b["label"] or b["row_id"], b["stage"], b["n_in_range"]))
611 print("worst residuals:")
612 for k, m in worst[:4]:
613 print(" %-7s %8.4gx off (log10 = %.2f)" % (k, led["residuals"][k], m))
614 for k, m in deferred:
615 print(" %-7s %8.4gx off (log10 = %.2f) DEFERRED by policy"
616 % (k, led["residuals"][k], m))
617 print("levers chosen :")
618 tagname = {-1: "operator override", 0: "never varied",
619 1: "inconclusive", 2: "live"}
620 for col, obs, miss, pr in why:
621 lv = next((n for c, n in zip(levers, n_levels) if c == col), "?")
622 print(" %-12s %-2s levels for %-7s [%s]"
623 % (col, lv, obs, tagname.get(pr, "?")))
624 for col, obs, span in skipped:
625 print(" %-12s SKIPPED for %-7s -- only %.0f%% of its box is "
626 "affordable in the corrective direction" % (col, obs, span * 100))
627 print("design : %s, %d rows (%s)"
628 % (stage, n_rows, " x ".join(str(n) for n in n_levels)))
629 if cost.get("known"):
630 print("est. cost : %.1f h wall (%d wave(s) x %.1f h worst row, %s)"
631 % (cost["est_wall_h"], cost["waves"], cost["max_row_h"], cost["source"]))
632 if share:
633 print("sharded across : %s"
634 % ", ".join("%d=%s" % (i, next(m["name"] for m in reg["machines"]
635 if m["index"] == i)) for i in share))
636 print("row budget : %.1f h (from %s)%s"
637 % (budget_h, budget_src, " *** EXCEEDED ***" if over else ""))
638 print()
639
640 if over:
641 print("REFUSING to recommend as-is: the worst measured row (%.1f h) exceeds "
642 "the row budget (%.1f h)." % (cost["max_row_h"], budget_h))
643 print("Either pass --budget-h, raise timeout_s for machine %d in "
644 "machines.json, or cut the design with --levels."
645 % machine["index"])
646 print()
647
648 design_rel = "design/%s_calib.csv" % stage
649 labels_rel = "design/%s_labels.json" % stage
650 cmd = build_commands(stage, machine, reg, slots, budget_h, share,
651 a.grid_v, a.grid_vbin)
652
653 if not a.write:
654 print("DRY RUN -- nothing written. Re-run with --write to emit:")
655 print(" %s (%d rows)" % (design_rel, n_rows))
656 print(" %s" % labels_rel)
657 print()
658 print("Then run:")
659 print("\n".join(cmd))
660 return 0
661
662 dpath = HERE / design_rel
663 with dpath.open("w", newline="", encoding="utf-8") as fh:
664 w = csv.DictWriter(fh, fieldnames=header)
665 w.writeheader()
666 for r in rows:
667 w.writerow(r)
668 (HERE / labels_rel).write_text(json.dumps(labels, indent=1), encoding="utf-8")
669
670 claim = {
671 "stage": stage,
672 "machine": machine["index"], "machine_name": machine["name"],
673 "levers": levers, "n_levels": n_levels, "n_rows": n_rows,
674 "design": design_rel, "labels": labels_rel,
675 "pinned_to": {"stage": b["stage"], "row_id": b["row_id"],
676 "label": b["label"]},
677 "rationale": ("worst residuals are %s; sweeping %s, which the ledger "
678 "has not retired" % (
679 ", ".join("%s (%.3gx)" % (k, led["residuals"][k])
680 for k, _ in worst[:3]),
681 ", ".join(levers))),
682 "cost_estimate": cost,
683 "commands": cmd,
684 }
685 # PER-MACHINE CLAIMS. A single `next` field is one slot for a fleet that
686 # now runs three stages at once: writing S18 here silently erased
687 # Matrix-PC's S17 claim. `next` is kept as the claim made by THIS run so
688 # the guide has something to render, but the durable record is per machine.
689 led.setdefault("next_by_machine", {})[str(machine["index"])] = claim
690 led["next"] = claim
691 LEDGER.write_text(json.dumps(led, indent=2), encoding="utf-8")
692
693 sys.path.insert(0, str(HERE))
694 import learn
695 (HERE / "CALIBRATION_GUIDE.md").write_text(learn.render_guide(led),
696 encoding="utf-8")
697
698 print("wrote %s (%d rows), %s" % (design_rel, n_rows, labels_rel))
699 print("updated calibration_ledger.json[next] and CALIBRATION_GUIDE.md")
700 print()
701 print("Review the design, then run:")
702 print("\n".join(cmd))
703 return 0
704
705
706if __name__ == "__main__":
707 raise SystemExit(main())
str render_guide(dict led)
Definition learn.py:891
box_for(str col, dict led, float base)
Definition plan.py:280
list build_commands(str stage, dict machine, dict reg, int slots, float budget_h, list share, int grid_v=2000, int grid_vbin=20)
Definition plan.py:170
dict parse_box_override(str spec)
Definition plan.py:369
float usable_span(str col, dict led, float base, int prefer)
Definition plan.py:312
list levels_for(str col, float base, int n, int prefer=0, box=None)
Definition plan.py:330
dict estimate_cost(dict led, dict machine, int n_rows)
Definition plan.py:473
tuple residual_rank(dict led)
Definition plan.py:212
tuple pick_levers(dict led, list worst, int n_wanted=3)
Definition plan.py:227
int prefer_for(str col, str obs, ratio)
Definition plan.py:359
dict load_ledger()
Definition plan.py:150
tuple detect(machine_index=None)
Definition plan.py:156
cost_key(dict cm, dict machine)
Definition plan.py:448
tuple build_design(dict led, list levers, list n_levels, str stage, int row_base, list why=None, dict box_override=None)
Definition plan.py:391
main(argv=None)
Definition plan.py:498