RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
campaign_ops.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""campaign_ops - build, monitor and stop control for the digital-twin campaign.
3
4Everything the control notebook drives lives here so the notebook cells stay
5thin and this logic stays testable from a shell.
6
7Three groups:
8
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.
12
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.
19
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.
24"""
25from __future__ import annotations
26
27import hashlib
28import json
29import os
30import platform
31import subprocess
32import time
33from pathlib import Path
34
35import numpy as np
36
37HERE = Path(__file__).resolve().parent
38REPO = HERE.parent.parent
39MODULE = REPO / "radcluster_code"
40STOP_FILE = HERE / "CAMPAIGN_STOP"
41
42
43# ══════════════════════════════════════════════════════════ build ════════════
44def find_solver() -> Path | None:
45 for p in (MODULE / "build" / "Release" / "solver.exe",
46 MODULE / "build" / "solver.exe",
47 MODULE / "build" / "solver"):
48 if p.exists():
49 return p
50 return None
51
52
53def sha256_file(p) -> str:
54 try:
55 return hashlib.sha256(Path(p).read_bytes()).hexdigest()
56 except Exception:
57 return "unavailable"
58
59
60def _newest_source_mtime() -> float:
61 newest = 0.0
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"
66 if cml.exists():
67 newest = max(newest, cml.stat().st_mtime)
68 return newest
69
70
71def solver_info() -> dict:
72 p = find_solver()
74 info = {"path": str(p) if p else None, "exists": p is not None,
75 "sha256": sha256_file(p)[:16] if p else 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)
79 return info
80
81
82def ensure_solver(force: bool = False, verbose: bool = True) -> dict:
83 """Verify the solver, and build it if missing, stale, or force=True.
84
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.
87 """
88 info = solver_info()
89 need = force or (not info["exists"]) or info["stale"]
90 if verbose:
91 if not info["exists"]:
92 print(" solver: NOT FOUND -> building")
93 elif info["stale"]:
94 print(" solver: STALE (C++ source is newer than the binary) -> rebuilding")
95 elif force:
96 print(" solver: rebuild forced")
97 else:
98 print(f" solver: OK {info['path']} sha {info['sha256']}")
99 if not need:
100 info["built"] = False
101 return info
102
103 # CMakeLists.txt lives in cpp_utils/ (core/ + materials/ two-layer split),
104 # NOT at the module root -- pointing -S at MODULE fails with
105 # "does not appear to contain CMakeLists.txt".
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:]
113 if verbose:
114 print(" cmake configure FAILED:\n" + info["error"])
115 return info
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:]
122 if verbose:
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.")
128 return info
129 info = solver_info()
130 info["built"] = True
131 if verbose:
132 print(f" solver: BUILT {info['path']} sha {info['sha256']}")
133 return info
134
135
136def environment() -> dict:
137 try:
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,
142 text=True).strip()
143 dirty = bool(subprocess.check_output(["git", "status", "--porcelain"],
144 cwd=str(REPO), text=True).strip())
145 except Exception:
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,
150 "workbook_sha256": sha256_file(MODULE / "input" /
151 "input_parameters.xlsx")[:16]}
152
153
154# ═══════════════════════════════════════════════════ stop control ════════════
155def request_stop(reason: str = "user requested") -> Path:
156 """Ask every running worker on THIS machine to stop gracefully.
157
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.
160 """
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.")
168 return STOP_FILE
169
170
171def clear_stop() -> None:
172 if STOP_FILE.exists():
173 STOP_FILE.unlink()
174 print(" STOP cleared - the campaign may be restarted.")
175 else:
176 print(" no STOP flag set.")
177
178
179def stop_requested() -> dict | None:
180 if not STOP_FILE.exists():
181 return None
182 try:
183 return json.loads(STOP_FILE.read_text(encoding="utf-8"))
184 except Exception:
185 return {"reason": "unreadable STOP file"}
186
187
188# ══════════════════════════════════════════════════════════ sync ═════════════
189# Git is the transport between campaign machines: results/*.jsonl are committed
190# on purpose (see radcluster_code/.gitignore) so the rows travel with the very
191# hashes that decide whether they may be pooled. That only works if each
192# machine actually pushes, and doing it by hand is the step that gets skipped.
193#
194# A machine only ever stages ITS OWN files -- results/<design>_machine<k>*.
195# Because the filenames are disjoint by construction, two machines pushing at
196# the same moment cannot produce a content conflict, and `pull --rebase`
197# resolves the ref race. Staging anything wider would let one machine commit
198# another's half-written file, or its own uncommitted source edits.
199
200def _git(*args, check=True):
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()
205
206
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.
210
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.
214 """
215 import run_ensemble as RE
216
217 reg = json.loads((HERE / "machines.json").read_text(encoding="utf-8"))
218 if machine is None:
219 machine = RE.detect_machine(reg)["index"]
220 stem = design_stem or Path(reg["design"]).stem
221
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")]
225 if not mine:
226 if verbose:
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"}
230
231 # Pull FIRST. Rebasing after committing would replay our commit over the
232 # others' -- same result, but a pull that fails for an unrelated reason
233 # then leaves a local commit stranded, which is harder to reason about.
234 before = _git("rev-parse", "HEAD")
235 _git("fetch", "origin", check=False)
236
237 for p in mine:
238 _git("add", "-f", f"{rel}/{p.name}")
239 staged = _git("diff", "--cached", "--name-only")
240 if staged:
241 n = {}
242 for p in mine:
243 if p.suffix == ".jsonl":
244 n[p.name] = sum(1 for ln in p.read_text(encoding="utf-8").splitlines()
245 if ln.strip())
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)
250
251 # A rebase with a dirty tree aborts, and a campaign machine very often has
252 # a dirty notebook. Autostash keeps the pull from becoming a manual
253 # cleanup job on a machine the user is not sitting at.
254 #
255 # But autostash REPLACES every file it touches, and a live results file is
256 # dirty by definition -- a row may land between the commit above and this
257 # pull. Stashing and reapplying it swaps the inode under the running
258 # worker, which then appends to an unlinked file forever. The worker no
259 # longer holds a handle across rows (see run_ensemble.append_row), so this
260 # is survivable; keeping the window small is still worth it, so results are
261 # committed first and only then is the tree touched.
262 pull = subprocess.run(["git", "pull", "--rebase", "--autostash", "origin", "main"],
263 cwd=REPO, capture_output=True, text=True)
264 if pull.returncode:
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()}"}
267
268 out = {"machine": machine, "files": [p.name for p in mine],
269 "committed": bool(staged), "pushed": False}
270 if push:
271 pr = subprocess.run(["git", "push", "origin", "HEAD:main"],
272 cwd=REPO, capture_output=True, text=True)
273 out["pushed"] = pr.returncode == 0
274 if pr.returncode:
275 out["error"] = pr.stderr.strip()
276 out["head"] = _git("rev-parse", "--short", "HEAD")
277 out["moved"] = before != _git("rev-parse", "HEAD")
278
279 if verbose:
280 if out.get("error"):
281 print(f" *** machine {machine}: {out['error']}")
282 else:
283 print(f" machine {machine}: {'pushed' if out['pushed'] else 'committed'}"
284 f" {len(mine)} file(s) at {out['head']}")
285 return out
286
287
288# ══════════════════════════════════════════════════════════ status ═══════════
289def load_targets(path: Path | None = None) -> dict:
290 p = path or (HERE / "targets.json")
291 return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
292
293
294def read_design(design: Path) -> tuple[list[dict], dict]:
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(",")
299 rows = []
300 for ln in lines[1:]:
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"])})
305 return rows, meta
306
307
308def _same_design(row_sha, design_sha) -> bool:
309 """Compare design hashes that may be stored at different lengths.
310
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.
316 """
317 if not design_sha or not row_sha:
318 return True
319 n = min(len(row_sha), len(design_sha))
320 return row_sha[:n] == design_sha[:n]
321
322
323def load_results(results_dir: Path, design_sha: str | None = None) -> dict[int, dict]:
324 """Rows from results/*.jsonl, optionally restricted to ONE design.
325
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.
332 """
333 recs: dict[int, dict] = {}
334 skipped = 0
335 for f in sorted(Path(results_dir).glob("*.jsonl")):
336 for ln in f.read_text(encoding="utf-8").splitlines():
337 if not ln.strip():
338 continue
339 try:
340 r = json.loads(ln)
341 except json.JSONDecodeError:
342 continue # truncated last line after a hard kill
343 if design_sha and not _same_design(r.get("design_sha256"), design_sha):
344 skipped += 1
345 continue
346 recs[r["row_id"]] = r
347 if skipped:
348 print(f" ignored {skipped} row(s) belonging to a different design")
349 return recs
350
351
352def campaign_status(design: Path, results_dir: Path, n_machines: int = 4,
353 workers_per_machine: int | None = None) -> dict:
354 design_rows, meta = read_design(design)
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")]
361
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
366
367 # WEIGHTED assignment, read from machines.json -- not row_id % n_machines.
368 # The even split stopped being the truth when the four machines were given
369 # capacity weights (14.0/1.6/7.0/6.8): under it this function told the
370 # notebook that Matrix-PC owned 252 rows when it owns 55, so "remaining"
371 # and every ETA derived from it were wrong for three machines out of four.
372 # Falls back to the modulo only when no registry is present, which is the
373 # one case where the modulo IS what run_ensemble used.
374 try:
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)
380 except Exception:
381 _owner = lambda rid: rid % n_machines
382
383 per_machine = {}
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}
391
392 W = workers_per_machine or max(1, (os.cpu_count() or 4) - 2)
393 eta = {}
394 if mean_w:
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
400
401 reasons = {}
402 for r in inadm:
403 if r.get("starved"):
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
409
410 prov = {}
411 for f in ("git_sha", "solver_sha256", "workbook_sha256", "design_sha256"):
412 vals = {}
413 for r in done:
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()}
416
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,
427 "stop": stop_requested(),
428 "_admis": admis}
429
430
431def _fmt_hms(s):
432 if s is None:
433 return "n/a"
434 s = int(s)
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"
436
437
438def observable_summary(admis: list[dict], targets: dict) -> list[dict]:
439 """Where the ensemble sits relative to each experimental band."""
440 out = []
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))],
444 dtype=float)
445 if vals.size == 0:
446 out.append({"observable": key, "n": 0, **t})
447 continue
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)
458 <= vals.max())
459 if t.get("target") is not None else None)})
460 return out
461
462
463def render_status(st: dict, targets: dict | None = None) -> None:
464 """Human-readable campaign report."""
465 m = st["meta"]
466 print("=" * 78)
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', []))}")
470 print("=" * 78)
471 if st["stop"]:
472 print(f" *** STOP FLAG SET: {st['stop'].get('reason')} "
473 f"(by {st['stop'].get('by')}) - workers are winding down ***\n")
474
475 bar_n = 46
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']}")
484
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 ""))
491
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}")
499
500 for f, vals in st["provenance"].items():
501 if len(vals) > 1:
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}")
505
506 if targets and st["_admis"]:
507 rows = observable_summary(st["_admis"], targets)
508 print(f"\n {'observable':>16s} {'n':>5s} {'p05':>11s} {'median':>11s} "
509 f"{'p95':>11s} {'target':>11s} {'in band':>8s}")
510 for r in rows:
511 if r["n"] == 0:
512 print(f" {r['observable']:>16s} {0:>5d} (no admissible rows yet)")
513 continue
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.")
524
525
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."""
530 # In-place refresh needs IPython; outside a notebook (or without it
531 # installed) fall back to scrolling output rather than failing — the
532 # monitor is the point, the redraw is cosmetic.
533 try:
534 from IPython.display import clear_output
535 except ImportError:
536 def clear_output(wait=False):
537 print("\n" + "─" * 78 + "\n")
538 targets = targets if targets is not None else load_targets()
539 st = None
540 try:
541 for _ in range(iterations):
542 st = campaign_status(design, results_dir, n_machines,
543 workers_per_machine)
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")
547 render_status(st, targets)
548 if st["missing"] == 0:
549 print("\n CAMPAIGN COMPLETE.")
550 break
551 time.sleep(interval)
552 except KeyboardInterrupt:
553 # This used to claim the campaign was "unaffected". It was not: on
554 # 2026-08-07 the kernel interrupt that left this loop was also delivered
555 # to the detached worker, which had no signal handler and died mid-pool,
556 # losing 6 in-flight rows. run_ensemble now drains gracefully on a
557 # signal, so the cost is bounded -- but a worker that drains has still
558 # STOPPED, and only the launch cell restarts it.
559 print("\n (stopped watching)")
560 print(" If the worker was launched from this kernel, check it is still"
561 " alive:")
562 print(" pgrep -fl run_ensemble.py")
563 print(" A kernel interrupt can reach it. It drains in-flight rows and"
564 " exits 0;")
565 print(" re-run the launch cell to resume -- completed rows are never"
566 " recomputed.")
567 return st
dict solver_info()
dict|None stop_requested()
dict[int, dict] load_results(Path results_dir, str|None design_sha=None)
_git(*args, check=True)
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)
None clear_stop()
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 environment()
dict sync_results(int|None machine=None, str|None design_stem=None, bool push=True, bool verbose=True)
Path|None find_solver()
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)
str sha256_file(p)