2"""campaign_ops - build, monitor and stop control for the digital-twin campaign.
4Everything the control notebook drives lives here so the notebook cells stay
5thin and this logic stays testable from a shell.
9 BUILD ensure_solver() - verify or compile the C++ solver for THIS
10 machine. The binary is not in git, so each
11 machine must build its own.
13 STOP CONTROL request_stop() - graceful halt. Writes a sentinel that
14 run_ensemble checks between rows: it stops
15 submitting new work, lets in-flight rows
16 finish and be written, then exits cleanly.
17 Nothing is lost and the campaign resumes
18 from where it stopped.
20 STATUS campaign_status() - coverage, per-machine progress, timing and
21 remaining-compute estimate, observables
22 against the experimental bands, and the
23 reasons rows were rejected.
25from __future__
import annotations
33from pathlib
import Path
37HERE = Path(__file__).resolve().parent
38REPO = HERE.parent.parent
39MODULE = REPO /
"radcluster_code"
40STOP_FILE = HERE /
"CAMPAIGN_STOP"
45 for p
in (MODULE /
"build" /
"Release" /
"solver.exe",
46 MODULE /
"build" /
"solver.exe",
47 MODULE /
"build" /
"solver"):
55 return hashlib.sha256(Path(p).read_bytes()).hexdigest()
62 for pat
in (
"**/*.cpp",
"**/*.h",
"**/CMakeLists.txt"):
63 for f
in (MODULE /
"cpp_utils").glob(pat):
64 newest = max(newest, f.stat().st_mtime)
65 cml = MODULE /
"CMakeLists.txt"
67 newest = max(newest, cml.stat().st_mtime)
74 info = {
"path": str(p)
if p
else None,
"exists": p
is not None,
76 "mtime": p.stat().st_mtime
if p
else None,
77 "newest_source_mtime": src}
78 info[
"stale"] = bool(p
and src > p.stat().st_mtime)
83 """Verify the solver, and build it if missing, stale, or force=True.
85 'Stale' means a .cpp/.h/CMakeLists is newer than the binary -- the exact
86 situation that silently produces results from code you think you replaced.
89 need = force
or (
not info[
"exists"])
or info[
"stale"]
91 if not info[
"exists"]:
92 print(
" solver: NOT FOUND -> building")
94 print(
" solver: STALE (C++ source is newer than the binary) -> rebuilding")
96 print(
" solver: rebuild forced")
98 print(f
" solver: OK {info['path']} sha {info['sha256']}")
100 info[
"built"] =
False
106 cfg = subprocess.run([
"cmake",
"-S", str(MODULE /
"cpp_utils"),
107 "-B", str(MODULE /
"build"),
108 "-DCMAKE_BUILD_TYPE=Release"],
109 capture_output=
True, text=
True)
110 if cfg.returncode != 0
and not (MODULE /
"build").exists():
111 info[
"built"] =
False
112 info[
"error"] = (cfg.stdout + cfg.stderr)[-1500:]
114 print(
" cmake configure FAILED:\n" + info[
"error"])
116 bld = subprocess.run([
"cmake",
"--build", str(MODULE /
"build"),
117 "--config",
"Release"], capture_output=
True, text=
True)
118 out = bld.stdout + bld.stderr
119 if bld.returncode != 0:
120 info[
"built"] =
False
121 info[
"error"] = out[-2500:]
123 print(
" build FAILED:\n" + info[
"error"])
124 if "LNK1104" in out
or "Permission denied" in out
or "Text file busy" in out:
125 print(
"\n The linker could not write solver.exe. A campaign run is")
126 print(
" probably still holding it -- stop the workers first")
127 print(
" (campaign_ops.request_stop('rebuild')), then retry.")
132 print(f
" solver: BUILT {info['path']} sha {info['sha256']}")
138 gs = subprocess.check_output([
"git",
"rev-parse",
"HEAD"], cwd=str(REPO),
139 stderr=subprocess.DEVNULL, text=
True).strip()
140 br = subprocess.check_output([
"git",
"rev-parse",
"--abbrev-ref",
"HEAD"],
141 cwd=str(REPO), stderr=subprocess.DEVNULL,
143 dirty = bool(subprocess.check_output([
"git",
"status",
"--porcelain"],
144 cwd=str(REPO), text=
True).strip())
146 gs, br, dirty =
"unknown",
"unknown",
False
147 return {
"machine_id": platform.node(),
"platform": platform.platform(),
148 "python": platform.python_version(),
"cpu_count": os.cpu_count(),
149 "git_sha": gs,
"branch": br,
"worktree_dirty": dirty,
151 "input_parameters.xlsx")[:16]}
156 """Ask every running worker on THIS machine to stop gracefully.
158 Workers finish the rows already in flight, write them, and exit 0. No row
159 is lost and no partial row is written, so a later restart resumes cleanly.
161 STOP_FILE.write_text(json.dumps(
162 {
"reason": reason,
"requested_at": time.time(),
163 "by": platform.node()}, indent=2), encoding=
"utf-8")
164 print(f
" STOP requested: {reason}")
165 print(f
" wrote {STOP_FILE}")
166 print(
" Workers finish their in-flight rows, then exit. Clear it with")
167 print(
" campaign_ops.clear_stop() before restarting.")
172 if STOP_FILE.exists():
174 print(
" STOP cleared - the campaign may be restarted.")
176 print(
" no STOP flag set.")
180 if not STOP_FILE.exists():
183 return json.loads(STOP_FILE.read_text(encoding=
"utf-8"))
185 return {
"reason":
"unreadable STOP file"}
201 r = subprocess.run([
"git", *args], cwd=REPO, capture_output=
True, text=
True)
202 if check
and r.returncode:
203 raise RuntimeError(f
"git {' '.join(args)} failed:\n{r.stderr.strip()}")
204 return r.stdout.strip()
207def sync_results(machine: int |
None =
None, design_stem: str |
None =
None,
208 push: bool =
True, verbose: bool =
True) -> dict:
209 """Commit this machine's result files and exchange them with the remote.
211 Safe to call while the campaign is running: run_ensemble appends to the
212 .jsonl, so the worst case is that the newest row lands in the next sync.
213 Returns a dict rather than printing only, so the notebook can assert on it.
215 import run_ensemble
as RE
217 reg = json.loads((HERE /
"machines.json").read_text(encoding=
"utf-8"))
219 machine = RE.detect_machine(reg)[
"index"]
220 stem = design_stem
or Path(reg[
"design"]).stem
222 rel = f
"radcluster_code/digital_twin/results"
223 mine = sorted(Path(HERE /
"results").glob(f
"{stem}_machine{machine}*"))
224 mine = [p
for p
in mine
if p.suffix
in (
".jsonl",
".json")]
227 print(f
" machine {machine}: no {stem}_machine{machine}* files yet "
228 f
"- nothing to send.")
229 return {
"machine": machine,
"pushed":
False,
"files": [],
"reason":
"no files"}
234 before =
_git(
"rev-parse",
"HEAD")
235 _git(
"fetch",
"origin", check=
False)
238 _git(
"add",
"-f", f
"{rel}/{p.name}")
239 staged =
_git(
"diff",
"--cached",
"--name-only")
243 if p.suffix ==
".jsonl":
244 n[p.name] = sum(1
for ln
in p.read_text(encoding=
"utf-8").splitlines()
246 msg = (f
"results: machine {machine} ({reg['machines'][machine]['name']}) "
247 f
"{sum(n.values())} rows\n\n"
248 +
"\n".join(f
" {k}: {v} rows" for k, v
in sorted(n.items())))
249 _git(
"commit",
"-m", msg)
262 pull = subprocess.run([
"git",
"pull",
"--rebase",
"--autostash",
"origin",
"main"],
263 cwd=REPO, capture_output=
True, text=
True)
265 return {
"machine": machine,
"pushed":
False,
"files": [p.name
for p
in mine],
266 "error": f
"pull --rebase failed, NOTHING pushed:\n{pull.stderr.strip()}"}
268 out = {
"machine": machine,
"files": [p.name
for p
in mine],
269 "committed": bool(staged),
"pushed":
False}
271 pr = subprocess.run([
"git",
"push",
"origin",
"HEAD:main"],
272 cwd=REPO, capture_output=
True, text=
True)
273 out[
"pushed"] = pr.returncode == 0
275 out[
"error"] = pr.stderr.strip()
276 out[
"head"] =
_git(
"rev-parse",
"--short",
"HEAD")
277 out[
"moved"] = before !=
_git(
"rev-parse",
"HEAD")
281 print(f
" *** machine {machine}: {out['error']}")
283 print(f
" machine {machine}: {'pushed' if out['pushed'] else 'committed'}"
284 f
" {len(mine)} file(s) at {out['head']}")
290 p = path
or (HERE /
"targets.json")
291 return json.loads(p.read_text(encoding=
"utf-8"))
if p.exists()
else {}
295 meta_p = Path(design).with_suffix(
".meta.json")
296 meta = json.loads(meta_p.read_text(encoding=
"utf-8"))
if meta_p.exists()
else {}
297 lines = Path(design).read_text(encoding=
"utf-8").strip().splitlines()
298 cols = lines[0].split(
",")
301 v = dict(zip(cols, ln.split(
",")))
302 rows.append({
"row_id": int(v[
"row_id"]),
"condition": v[
"condition"],
303 "matrix": v[
"matrix"],
"base_idx": int(v[
"base_idx"]),
304 "param_j": int(v[
"param_j"])})
309 """Compare design hashes that may be stored at different lengths.
311 The rows carry a 16-char prefix (run_ensemble truncates for compactness)
312 while design/<name>.meta.json holds the full 64-char digest. A naive ==
313 is therefore always False, which silently rejects EVERY row -- the
314 filter's failure mode is to discard the whole campaign rather than to let
315 a stale one through, so it must be prefix-aware.
317 if not design_sha
or not row_sha:
319 n = min(len(row_sha), len(design_sha))
320 return row_sha[:n] == design_sha[:n]
323def load_results(results_dir: Path, design_sha: str |
None =
None) -> dict[int, dict]:
324 """Rows from results/*.jsonl, optionally restricted to ONE design.
326 design_sha matters more than it looks. row_id is unique within a design
327 but not across designs, so pooling every .jsonl in the directory lets a
328 superseded campaign's row 25 overwrite the current one's -- silently, and
329 with a plausible-looking value. Before this filter, T2_design_v2_* rows
330 left in results/ made the T3 campaign report 234 of 480 rows done on
331 machine 0 when 4 existed.
333 recs: dict[int, dict] = {}
335 for f
in sorted(Path(results_dir).glob(
"*.jsonl")):
336 for ln
in f.read_text(encoding=
"utf-8").splitlines():
341 except json.JSONDecodeError:
343 if design_sha
and not _same_design(r.get(
"design_sha256"), design_sha):
346 recs[r[
"row_id"]] = r
348 print(f
" ignored {skipped} row(s) belonging to a different design")
353 workers_per_machine: int |
None =
None) -> dict:
355 recs =
load_results(results_dir, meta.get(
"design_sha256"))
356 total = len(design_rows)
357 done = [r
for r
in recs.values()]
358 failed = [r
for r
in done
if r.get(
"solver_rc")]
359 admis = [r
for r
in done
if not r.get(
"solver_rc")
and r.get(
"admissible")]
360 inadm = [r
for r
in done
if not r.get(
"solver_rc")
and not r.get(
"admissible")]
362 walls = [r[
"wall_s"]
for r
in done
if r.get(
"wall_s")]
363 mean_w = float(np.mean(walls))
if walls
else None
364 med_w = float(np.median(walls))
if walls
else None
365 p90_w = float(np.percentile(walls, 90))
if walls
else None
375 import run_ensemble
as RE
376 _reg = json.loads((HERE /
"machines.json").read_text(encoding=
"utf-8"))
377 _w = [float(x)
for x
in str(_reg[
"weights"]).split(
",")]
378 n_machines = int(_reg[
"of"])
379 _owner =
lambda rid: RE.assign_machine(rid, n_machines, _w)
381 _owner =
lambda rid: rid % n_machines
384 for k
in range(n_machines):
385 mine = [d
for d
in design_rows
if _owner(d[
"row_id"]) == k]
386 got = [d
for d
in mine
if d[
"row_id"]
in recs]
387 ids = {recs[d[
"row_id"]].get(
"machine_id")
for d
in got}
388 per_machine[k] = {
"assigned": len(mine),
"done": len(got),
389 "remaining": len(mine) - len(got),
390 "machine_id": sorted(x
for x
in ids
if x)
or None}
392 W = workers_per_machine
or max(1, (os.cpu_count()
or 4) - 2)
395 for k, v
in per_machine.items():
396 eta[k] = v[
"remaining"] * mean_w / W
397 core_h_used = sum(walls) / 3600.0
if walls
else 0.0
398 core_h_left = (sum(v[
"remaining"]
for v
in per_machine.values()) * mean_w
399 / 3600.0)
if mean_w
else None
404 reasons[
"dose-starved"] = reasons.get(
"dose-starved", 0) + 1
405 if r.get(
"grid_limited"):
406 reasons[
"grid-limited"] = reasons.get(
"grid-limited", 0) + 1
407 if abs(r.get(
"delta_FP")
or 0) >= 1e-3:
408 reasons[
"delta_FP>=1e-3"] = reasons.get(
"delta_FP>=1e-3", 0) + 1
411 for f
in (
"git_sha",
"solver_sha256",
"workbook_sha256",
"design_sha256"):
414 vals.setdefault(r.get(f,
"missing"), []).append(r.get(
"machine_id",
"?"))
415 prov[f] = {k: sorted(set(v))
for k, v
in vals.items()}
417 return {
"design": str(design),
"meta": meta,
"total": total,
418 "done": len(done),
"admissible": len(admis),
419 "inadmissible": len(inadm),
"failed": len(failed),
420 "missing": total - len(done),
421 "pct": 100.0 * len(done) / total
if total
else 0.0,
422 "wall_mean_s": mean_w,
"wall_median_s": med_w,
"wall_p90_s": p90_w,
423 "core_hours_used": core_h_used,
"core_hours_remaining": core_h_left,
424 "per_machine": per_machine,
"eta_s": eta,
425 "inadmissible_reasons": reasons,
"provenance": prov,
426 "workers_assumed": W,
435 return f
"{s//3600:d}h{(s%3600)//60:02d}m" if s >= 3600
else f
"{s//60:d}m{s%60:02d}s"
439 """Where the ensemble sits relative to each experimental band."""
441 for key, t
in targets.get(
"observables", {}).items():
442 vals = np.array([r[key]
for r
in admis
443 if r.get(key)
is not None and np.isfinite(r.get(key, np.nan))],
446 out.append({
"observable": key,
"n": 0, **t})
448 lo, hi = t.get(
"lo"), t.get(
"hi")
449 frac_in = (float(np.mean((vals >= lo) & (vals <= hi)))
450 if lo
is not None and hi
is not None else None)
451 out.append({
"observable": key,
"n": int(vals.size),
452 "p05": float(np.percentile(vals, 5)),
453 "median": float(np.median(vals)),
454 "p95": float(np.percentile(vals, 95)),
455 "target": t.get(
"target"),
"lo": lo,
"hi": hi,
456 "units": t.get(
"units",
""),
"frac_in_band": frac_in,
457 "brackets": (bool(vals.min() <= (t.get(
"target")
or np.inf)
459 if t.get(
"target")
is not None else None)})
464 """Human-readable campaign report."""
467 print(f
"CAMPAIGN {Path(st['design']).name} "
468 f
"p={m.get('p','?')} N={m.get('N','?')} "
469 f
"conditions={','.join(m.get('conditions', []))}")
472 print(f
" *** STOP FLAG SET: {st['stop'].get('reason')} "
473 f
"(by {st['stop'].get('by')}) - workers are winding down ***\n")
476 filled = int(bar_n * st[
"pct"] / 100)
477 print(f
" progress [{'#'*filled}{'.'*(bar_n-filled)}] {st['pct']:5.1f}% "
478 f
"{st['done']}/{st['total']} rows")
479 print(f
" admissible {st['admissible']} "
480 f
"inadmissible {st['inadmissible']} failed {st['failed']} "
481 f
"missing {st['missing']}")
482 if st[
"inadmissible_reasons"]:
483 print(f
" rejected because: {st['inadmissible_reasons']}")
485 print(f
"\n timing per row mean {_fmt_hms(st['wall_mean_s'])} "
486 f
"median {_fmt_hms(st['wall_median_s'])} "
487 f
"p90 {_fmt_hms(st['wall_p90_s'])}")
488 print(f
" core-hours used {st['core_hours_used']:.1f}"
489 + (f
" remaining ~{st['core_hours_remaining']:.1f}"
490 if st[
"core_hours_remaining"]
is not None else ""))
492 print(f
"\n {'machine':>8s} {'id':>18s} {'assigned':>9s} {'done':>6s} "
493 f
"{'left':>6s} {'ETA @'+str(st['workers_assumed'])+'w':>12s}")
494 for k, v
in st[
"per_machine"].items():
495 mid = (v[
"machine_id"]
or [
"-"])[0]
496 print(f
" {k:>8d} {str(mid)[:18]:>18s} {v['assigned']:>9d} "
497 f
"{v['done']:>6d} {v['remaining']:>6d} "
498 f
"{_fmt_hms(st['eta_s'].get(k)):>12s}")
500 for f, vals
in st[
"provenance"].items():
502 print(f
"\n *** PROVENANCE SPLIT on {f} - results are NOT comparable:")
503 for v, ms
in vals.items():
504 print(f
" {str(v)[:16]} <- {ms}")
506 if targets
and st[
"_admis"]:
508 print(f
"\n {'observable':>16s} {'n':>5s} {'p05':>11s} {'median':>11s} "
509 f
"{'p95':>11s} {'target':>11s} {'in band':>8s}")
512 print(f
" {r['observable']:>16s} {0:>5d} (no admissible rows yet)")
514 fb = (
"n/a" if r[
"frac_in_band"]
is None
515 else f
"{100*r['frac_in_band']:.0f}%")
516 tg = (f
"{'n/a':>11s}" if r[
"target"]
is None
517 else f
"{r['target']:11.3g}")
518 print(f
" {r['observable']:>16s} {r['n']:>5d} {r['p05']:11.3g} "
519 f
"{r['median']:11.3g} {r['p95']:11.3g} {tg} {fb:>8s}")
520 print(
"\n 'in band' = fraction of admissible runs inside the experimental")
521 print(
" range. Early in a campaign a low value is normal - the prior box")
522 print(
" is wide on purpose. A value stuck at 0% once several hundred rows")
523 print(
" are in means the box does not contain the data, which is a result.")
526def watch(design: Path, results_dir: Path, n_machines: int = 4,
527 workers_per_machine: int |
None =
None, interval: float = 60.0,
528 iterations: int = 10_000, targets: dict |
None =
None) -> dict:
529 """Live monitor. Interrupt the cell (kernel stop) to leave it."""
534 from IPython.display
import clear_output
536 def clear_output(wait=False):
537 print(
"\n" +
"─" * 78 +
"\n")
538 targets = targets
if targets
is not None else load_targets()
541 for _
in range(iterations):
544 clear_output(wait=
True)
545 print(f
" refreshed {time.strftime('%H:%M:%S')} "
546 f
"(every {interval:.0f}s; interrupt the kernel to stop watching)\n")
548 if st[
"missing"] == 0:
549 print(
"\n CAMPAIGN COMPLETE.")
552 except KeyboardInterrupt:
559 print(
"\n (stopped watching)")
560 print(
" If the worker was launched from this kernel, check it is still"
562 print(
" pgrep -fl run_ensemble.py")
563 print(
" A kernel interrupt can reach it. It drains in-flight rows and"
565 print(
" re-run the launch cell to resume -- completed rows are never"
dict|None stop_requested()
dict[int, dict] load_results(Path results_dir, str|None design_sha=None)
dict campaign_status(Path design, Path results_dir, int n_machines=4, int|None workers_per_machine=None)
list[dict] observable_summary(list[dict] admis, dict targets)
None render_status(dict st, dict|None targets=None)
tuple[list[dict], dict] read_design(Path design)
Path request_stop(str reason="user requested")
dict ensure_solver(bool force=False, bool verbose=True)
dict load_targets(Path|None path=None)
dict sync_results(int|None machine=None, str|None design_stem=None, bool push=True, bool verbose=True)
float _newest_source_mtime()
dict watch(Path design, Path results_dir, int n_machines=4, int|None workers_per_machine=None, float interval=60.0, int iterations=10_000, dict|None targets=None)
bool _same_design(row_sha, design_sha)