anima-python 0.13.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
anima_py/cli/sweep.py ADDED
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env python3
2
+ """cli/sweep.py — the CANONICAL anima multi-GPU lever-sweep orchestrator (`anima sweep`).
3
+
4
+ >>> This file promotes the ad-hoc scratch shell orchestration (fire_4gpu_big.sh and
5
+ >>> friends) into a proper, reproducible single-entry subcommand, SYMMETRIC to
6
+ >>> cli/train.py (LEARNING) and cli/evaluate.py (MEASUREMENT). `anima sweep <args>`
7
+ >>> dispatches HERE. No scratch fire_*.sh sprawl.
8
+ >>>
9
+ >>> WHAT IT DOES: run a ρ·weave-lever sweep (recombination wall · frozen bar = former G1 ·
10
+ >>> H_1129) over the matrix ARMS × OBJECTIVES. Each
11
+ >>> (arm, objective) pair is one "cell". Cells are pinned round-robin to the GPUs in
12
+ >>> --gpus and run concurrently (max-concurrent = number of GPUs). Each cell:
13
+ >>> (1) TRAINS a 303M CLMConvMoE via `python3 cli/train.py …` (CUDA_VISIBLE_DEVICES
14
+ >>> pinned to its GPU), serializing <out-dir>/<tag>.clm.
15
+ >>> (2) if --measure, ρ-AXON reach-MEASURES (former G0-G6) the resulting .clm via `python3 cli/evaluate.py
16
+ >>> <clm> --corpus … --gen N` (CPU, torch-free numpy) -> <out-dir>/<tag>.meas.log.
17
+ >>> (3) touches a per-cell done flag.
18
+ >>> After every cell finishes, the orchestrator PARSES each <tag>.meas.log for the
19
+ >>> reach-bar lines (ρ·form/weave/leap/fan, parsed by their frozen-bar G-labels) and prints
20
+ >>> + writes a summary TABLE (SWEEP_SUMMARY.md),
21
+ >>> flagging any ρ·weave-PASS candidate (former G1 · best_distinct >= 2 AND > max_single) and any
22
+ >>> overfit-collapse INVALID cell (ρ·form/G0 FAIL + collapsed train CE).
23
+ >>>
24
+ >>> DESIGN INVARIANT (single-entry discipline, a_engine_native_learning): sweep is
25
+ >>> ONLY an orchestrator. It NEVER imports torch / the model / the scorers — it shells
26
+ >>> out to the two canonical engines (cli/train.py, cli/evaluate.py) as SUBPROCESSES,
27
+ >>> exactly as the hexa anima_train_mode / anima_evaluate_mode dispatch shells out.
28
+ >>> This keeps sweep torch-free (pure orchestration + subprocess + text parsing) and
29
+ >>> makes the trainer/evaluator the single sources of truth. The engine-native TERMINAL
30
+ >>> verdict is the .clm re-measure via cli/evaluate.py (already the measure step here);
31
+ >>> the torch-side training CE is DIRECTIONAL only (a_engine_native_learning).
32
+ >>>
33
+ >>> bf16 vs fp32 (convergence lesson): objective==constructive_bind uses torch.fft
34
+ >>> (HRR circular convolution), which has NO bf16 kernel — bf16 there silently dies
35
+ >>> (H_1823). So the orchestrator AUTO-DROPS --bf16 for constructive_bind (fp32) and
36
+ >>> keeps --bf16 for every other objective. This mirrors fire_4gpu_big.sh (B_CBIND ran
37
+ >>> with an empty bf16 flag).
38
+
39
+ USAGE (installed `anima` PATH command after `hx install anima`):
40
+ anima sweep --arms ctrl --objectives ce_marginal,composed_nce,infonce,constructive_bind \\
41
+ --steps 8000 --gpus 0,1,2,3 \\
42
+ --corpus dancinlab/anima-corpus-5lang-unified-v2 dancinlab/anima-corpus-ko-general \\
43
+ --cell-label general-5lang ko-general --measure
44
+ """
45
+ from __future__ import annotations
46
+ import argparse
47
+ import os
48
+ import re
49
+ import subprocess
50
+ import glob
51
+ import sys
52
+ import threading
53
+ import time
54
+
55
+ # ── canonical engine paths (resolved relative to THIS file so it works installed) ──
56
+ _HERE = os.path.dirname(os.path.abspath(__file__)) # …/cli
57
+ _REPO = os.environ.get("ANIMA_SRC") or os.path.dirname(_HERE) # repo root (parent of cli/)
58
+ _TRAIN_PY = os.path.join(_HERE, "train.py") # canonical trainer
59
+ _EVAL_PY = os.path.join(_HERE, "evaluate.py") # canonical evaluator
60
+
61
+ # objectives whose loss uses torch.fft (HRR bind) → NO bf16 kernel → force fp32.
62
+ _FP32_ONLY_OBJECTIVES = {"constructive_bind"}
63
+
64
+ # overfit-collapse heuristic (convergence train-py-3): a memorized/collapsed run drives
65
+ # the TRAIN CE toward ~0 while the model is garbage (held-out never descends, ρ·form/G0 fails).
66
+ # So ρ·form/G0 FAIL + a collapsed final train CE => the ρ-AXON reach measurement (former G0-G6) is INVALID (not a real
67
+ # capability floor, just corpus starvation / memorization). Threshold is frozen, documented.
68
+ _OVERFIT_LOSSF_THRESH = 0.5
69
+
70
+
71
+ # ════════════════════════════════════════════════════════════════════════════
72
+ # GPU discovery
73
+ # ════════════════════════════════════════════════════════════════════════════
74
+ def _default_gpus() -> list[str]:
75
+ """Default GPU index list = all visible. Honor CUDA_VISIBLE_DEVICES if set, else
76
+ probe `nvidia-smi -L`, else fall back to a single ['0']."""
77
+ cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
78
+ if cvd:
79
+ # pass the visible physical indices through as-is — each becomes a child's
80
+ # CUDA_VISIBLE_DEVICES (do NOT re-index, or a user's pre-restriction is lost).
81
+ vis = [g.strip() for g in cvd.split(",") if g.strip()]
82
+ if vis:
83
+ return vis
84
+ try:
85
+ out = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True, timeout=15)
86
+ n = len([ln for ln in out.stdout.splitlines() if ln.strip().startswith("GPU ")])
87
+ if n > 0:
88
+ return [str(i) for i in range(n)]
89
+ except Exception:
90
+ pass
91
+ return ["0"]
92
+
93
+
94
+ # ════════════════════════════════════════════════════════════════════════════
95
+ # one cell = one (arm, objective) train (+ optional measure)
96
+ # ════════════════════════════════════════════════════════════════════════════
97
+ def _cell_tag(arm: str, objective: str, seed: int) -> str:
98
+ return f"{arm}_{objective}_s{seed}"
99
+
100
+
101
+ def _build_train_cmd(a, arm: str, objective: str, tag: str, out_dir: str) -> list[str]:
102
+ clm = os.path.join(out_dir, tag + ".clm")
103
+ pt = os.path.join(out_dir, tag + ".pt")
104
+ gj = os.path.join(out_dir, tag + ".json")
105
+ cmd = [sys.executable, _TRAIN_PY,
106
+ "--arm", arm, "--objective", objective,
107
+ "--tlora-rank", str(a.tlora_rank),
108
+ "--seed", str(a.seed),
109
+ "--steps", str(a.steps),
110
+ "--sample", a.sample,
111
+ "--val-frac", str(a.val_frac),
112
+ "--val-every", str(a.val_every),
113
+ "--dbes-every", str(a.dbes_every),
114
+ "--out", clm, "--ckpt-out", pt, "--gauges-out", gj]
115
+ if a.canon:
116
+ cmd.append("--canon")
117
+ if a.corpus:
118
+ cmd += ["--corpus", *a.corpus]
119
+ if a.cell_label:
120
+ cmd += ["--cell-label", *a.cell_label]
121
+ # bf16 EXCEPT for fft-based objectives (constructive_bind → fp32, H_1823 lesson).
122
+ if a.bf16 and objective not in _FP32_ONLY_OBJECTIVES:
123
+ cmd.append("--bf16")
124
+ # N6 regularization-floor passthrough: force a CONSTANT dropout/weight-decay
125
+ # (>=0 overrides the savant decay schedule) — needed to escape the 8000-step
126
+ # generation-collapse (train-py-4) by holding inhibition at a floor.
127
+ if a.dropout_floor >= 0.0:
128
+ cmd += ["--dropout-floor", str(a.dropout_floor)]
129
+ if a.wd_floor >= 0.0:
130
+ cmd += ["--wd-floor", str(a.wd_floor)]
131
+ # step-window multiplex: train.py dumps <clm>.step<N>.clm every N steps so ONE run
132
+ # yields the 2000/4000/… checkpoints (train-py-4 confound isolation, no re-train).
133
+ if a.ckpt_every > 0:
134
+ cmd += ["--ckpt-every", str(a.ckpt_every)]
135
+ return cmd
136
+
137
+
138
+ def _ckpt_step(path: str):
139
+ """Extract N from a `<clm>.step<N>.clm` intermediate-checkpoint path (else None)."""
140
+ m = re.search(r"\.step(\d+)\.clm$", path)
141
+ return int(m.group(1)) if m else None
142
+
143
+
144
+ def _build_measure_cmd(a, tag: str, out_dir: str) -> list[str]:
145
+ clm = os.path.join(out_dir, tag + ".clm")
146
+ cmd = [sys.executable, _EVAL_PY, clm, "--gen", str(a.gen)]
147
+ if a.corpus:
148
+ cmd += ["--corpus", *a.corpus]
149
+ return cmd
150
+
151
+
152
+ def run_cell(a, arm: str, objective: str, gpu: str, out_dir: str, log_lock: threading.Lock):
153
+ """Train (GPU-pinned) then optionally measure (CPU) one (arm,objective) cell."""
154
+ tag = _cell_tag(arm, objective, a.seed)
155
+ clm = os.path.join(out_dir, tag + ".clm")
156
+ train_log = os.path.join(out_dir, tag + ".log")
157
+ meas_log = os.path.join(out_dir, tag + ".meas.log")
158
+ bf = "fp32" if objective in _FP32_ONLY_OBJECTIVES else ("bf16" if a.bf16 else "fp32")
159
+
160
+ def _say(msg):
161
+ with log_lock:
162
+ print(f"[{time.strftime('%H:%M:%S')}] [gpu{gpu}] {tag}: {msg}", flush=True)
163
+
164
+ # ── (1) TRAIN (CUDA_VISIBLE_DEVICES pinned to this cell's GPU) ────────────
165
+ env = os.environ.copy()
166
+ env["CUDA_VISIBLE_DEVICES"] = str(gpu)
167
+ _say(f"TRAIN start ({arm}/{objective}, {bf}) -> {os.path.basename(clm)}")
168
+ with open(train_log, "w") as lf:
169
+ rc = subprocess.run(_build_train_cmd(a, arm, objective, tag, out_dir),
170
+ cwd=_REPO, env=env, stdout=lf, stderr=subprocess.STDOUT).returncode
171
+ have_clm = os.path.exists(clm) and os.path.getsize(clm) > 0
172
+ _say(f"TRAIN done rc={rc} clm={'OK' if have_clm else 'MISSING'}")
173
+ open(os.path.join(out_dir, tag + ".done"), "w").close() # per-cell train done flag
174
+
175
+ # ── (2) MEASURE (CPU, torch-free numpy evaluate — CUDA hidden) ────────────
176
+ # step-window: measure the FINAL <tag>.clm AND every intermediate --ckpt-every
177
+ # checkpoint (<tag>.clm.step<N>.clm), each into its own <label>.meas.log so the
178
+ # aggregate can plot ρ·form/ρ·weave (former G0/G1) across steps (train-py-4 confound: where does ρ·form/G0 break?).
179
+ if a.measure:
180
+ menv = os.environ.copy()
181
+ menv["CUDA_VISIBLE_DEVICES"] = "" # force CPU numpy path
182
+ menv.setdefault("OMP_NUM_THREADS", "8")
183
+ targets = []
184
+ for cp in sorted((p for p in glob.glob(clm + ".step*.clm")), key=_ckpt_step):
185
+ n = _ckpt_step(cp)
186
+ targets.append((cp, f"{tag}__s{n}"))
187
+ if have_clm:
188
+ targets.append((clm, tag)) # final full-run (bare tag)
189
+ if not targets:
190
+ with open(meas_log, "w") as lf:
191
+ lf.write("NO CKPT — train produced no .clm; measure skipped\n")
192
+ for cpath, label in targets:
193
+ ml = os.path.join(out_dir, label + ".meas.log")
194
+ with open(ml, "w") as lf:
195
+ lf.write(f"=== [{label}] ρ-AXON reach measure · former G0-G6 ({time.strftime('%H:%M:%S')}) ===\n")
196
+ lf.flush()
197
+ mcmd = [sys.executable, _EVAL_PY, cpath, "--gen", str(a.gen)]
198
+ if a.corpus:
199
+ mcmd += ["--corpus", *a.corpus]
200
+ mrc = subprocess.run(mcmd, cwd=_REPO, env=menv, stdout=lf,
201
+ stderr=subprocess.STDOUT).returncode
202
+ lf.write(f"\n=== [{label}] measure rc={mrc} ===\n")
203
+ open(os.path.join(out_dir, tag + ".meas.done"), "w").close() # per-cell measure flag
204
+ _say(f"MEASURE done ({len(targets)} checkpoint(s))")
205
+
206
+
207
+ # ════════════════════════════════════════════════════════════════════════════
208
+ # aggregate — parse each <tag>.meas.log (+ <tag>.log) into a summary table
209
+ # ════════════════════════════════════════════════════════════════════════════
210
+ _RE_G1 = re.compile(r"best_distinct=(\d+)\s*>\s*max_single=(\d+)")
211
+ _RE_G2 = re.compile(r"novel=(\d+)")
212
+ _RE_G6 = re.compile(r"distinct=(\d+)\s*\(need>=5\).*?falsifiable=(\d+)")
213
+ _RE_G0N = re.compile(r"on\s+(\d+)/5")
214
+ _RE_LOSSF = re.compile(r"lossF=([0-9.]+)")
215
+ _RE_VALPOOL = re.compile(r"FINAL val_CE\(pooled\)=(\S+)")
216
+
217
+
218
+ def _gate_pass(line: str) -> bool:
219
+ # evaluate.py prints 🟢 for PASS, 🔴 for FAIL on G0/G1/G2/G6.
220
+ return "🟢" in line
221
+
222
+
223
+ def parse_cell(tag: str, out_dir: str) -> dict:
224
+ """Parse one cell's measure log (+ train log) into a result dict."""
225
+ meas_log = os.path.join(out_dir, tag + ".meas.log")
226
+ # a checkpoint label is "<base>__s<N>"; its train log is the base cell's <base>.log
227
+ base_tag = tag.split("__s")[0]
228
+ train_log = os.path.join(out_dir, base_tag + ".log")
229
+ r = {"tag": tag, "form": None, "form_n": None, "weave": None, "weave_bd": None,
230
+ "weave_ms": None, "leap": None, "leap_novel": None, "fan": None, "fan_dist": None,
231
+ "fan_fals": None, "closure": None, "lossf": None, "val_pool": None,
232
+ "status": "?", "note": ""}
233
+
234
+ # train log → collapse signals
235
+ if os.path.exists(train_log):
236
+ tl = open(train_log, encoding="utf-8", errors="replace").read()
237
+ m = _RE_LOSSF.search(tl)
238
+ if m:
239
+ r["lossf"] = float(m.group(1))
240
+ m = _RE_VALPOOL.search(tl)
241
+ if m and m.group(1) not in ("None", "nan"):
242
+ try:
243
+ r["val_pool"] = float(m.group(1))
244
+ except ValueError:
245
+ pass
246
+
247
+ if not os.path.exists(meas_log):
248
+ r["status"] = "NO-MEAS"
249
+ r["note"] = "no measure log"
250
+ return r
251
+ txt = open(meas_log, encoding="utf-8", errors="replace").read()
252
+ if "NO CKPT" in txt:
253
+ r["status"] = "NO-CKPT"
254
+ r["note"] = "train produced no .clm"
255
+ return r
256
+
257
+ for line in txt.splitlines():
258
+ if "ρ·form COHERENCE" in line:
259
+ r["form"] = _gate_pass(line)
260
+ mm = _RE_G0N.search(line)
261
+ if mm:
262
+ r["form_n"] = int(mm.group(1))
263
+ elif "ρ·weave RECOMBINATION" in line:
264
+ r["weave"] = _gate_pass(line)
265
+ mm = _RE_G1.search(line)
266
+ if mm:
267
+ r["weave_bd"] = int(mm.group(1)); r["weave_ms"] = int(mm.group(2))
268
+ elif "ρ·leap NOVELTY" in line:
269
+ r["leap"] = _gate_pass(line)
270
+ mm = _RE_G2.search(line)
271
+ if mm:
272
+ r["leap_novel"] = int(mm.group(1))
273
+ elif "ρ·fan IDEATION" in line:
274
+ r["fan"] = _gate_pass(line)
275
+ mm = _RE_G6.search(line)
276
+ if mm:
277
+ r["fan_dist"] = int(mm.group(1)); r["fan_fals"] = int(mm.group(2))
278
+ elif line.strip().startswith("CLOSURE"):
279
+ r["closure"] = _gate_pass(line)
280
+
281
+ # status classification
282
+ # overfit-INVALID applies to the FINAL run only (lossf is the end-of-run CE; an
283
+ # intermediate step-window checkpoint keeps its own ρ·form/ρ·weave · former G0/G1 row, not an overfit verdict).
284
+ overfit = ("__s" not in tag and r["form"] is False and r["lossf"] is not None
285
+ and r["lossf"] < _OVERFIT_LOSSF_THRESH)
286
+ weave_cand = (r["weave_bd"] is not None and r["weave_ms"] is not None
287
+ and r["weave_bd"] >= 2 and r["weave_bd"] > r["weave_ms"])
288
+ if r["form"] is None:
289
+ r["status"] = "INCOMPLETE"
290
+ r["note"] = "measure did not finish (no gate lines)"
291
+ elif overfit:
292
+ r["status"] = "INVALID"
293
+ r["note"] = (f"overfit collapse — G0 FAIL + train CE→{r['lossf']:.3f} "
294
+ f"(<{_OVERFIT_LOSSF_THRESH}); measurement invalid (corpus starvation)")
295
+ elif weave_cand:
296
+ r["status"] = "ρ·weave-PASS?"
297
+ r["note"] = (f"ρ·weave-PASS candidate: best_distinct={r['weave_bd']} > "
298
+ f"max_single={r['weave_ms']}")
299
+ elif r["closure"]:
300
+ r["status"] = "CLOSURE"
301
+ else:
302
+ r["status"] = "OK"
303
+ return r
304
+
305
+
306
+ def _fmt(v, dash="—"):
307
+ return dash if v is None else str(v)
308
+
309
+
310
+ def _pf(v):
311
+ if v is None:
312
+ return "—"
313
+ return "🟢" if v else "🔴"
314
+
315
+
316
+ def _row_sortkey(lbl: str):
317
+ base = lbl.split("__s")[0]
318
+ st = lbl.split("__s")[1] if "__s" in lbl else None
319
+ return (base, int(st) if (st and st.isdigit()) else 10 ** 9) # final (no __s) sorts last
320
+
321
+
322
+ def aggregate(a, cells, out_dir: str):
323
+ # one row per measured checkpoint: glob every <label>.meas.log (final <tag> +
324
+ # step-window <tag>__s<N>), so --ckpt-every runs expand into per-step rows.
325
+ labels = sorted((os.path.basename(p)[:-len(".meas.log")]
326
+ for p in glob.glob(os.path.join(out_dir, "*.meas.log"))),
327
+ key=_row_sortkey)
328
+ if labels:
329
+ rows = [parse_cell(lbl, out_dir) for lbl in labels]
330
+ else:
331
+ rows = [parse_cell(_cell_tag(arm, obj, a.seed), out_dir) for (arm, obj) in cells]
332
+
333
+ # console + markdown table
334
+ header = ("| tag | ρ·form | ρ·weave bd/ms | ρ·leap novel | ρ·fan dist/fals | closure | status |")
335
+ sep = ("|---|---|---|---|---|---|---|")
336
+ lines = [header, sep]
337
+ for r in rows:
338
+ weave = f"{_fmt(r['weave_bd'])}/{_fmt(r['weave_ms'])}"
339
+ fan = f"{_fmt(r['fan_dist'])}/{_fmt(r['fan_fals'])}"
340
+ lines.append(
341
+ f"| {r['tag']} | {_pf(r['form'])} | {weave} | {_fmt(r['leap_novel'])} | "
342
+ f"{fan} | {_pf(r['closure'])} | {r['status']} |")
343
+
344
+ weave_cands = [r for r in rows if r["status"] == "ρ·weave-PASS?"]
345
+ invalids = [r for r in rows if r["status"] == "INVALID"]
346
+
347
+ md = []
348
+ md.append(f"# anima sweep summary — {time.strftime('%Y-%m-%d %H:%M:%S')}")
349
+ md.append("")
350
+ md.append(f"- matrix: arms={a.arms} × objectives={a.objectives} "
351
+ f"(seed={a.seed}, steps={a.steps}, gen={a.gen})")
352
+ md.append(f"- cells: {len(rows)} · out-dir: `{out_dir}`")
353
+ md.append(f"- engines: `cli/train.py` (GPU) + `cli/evaluate.py` (CPU numpy, torch-free)")
354
+ md.append("")
355
+ md += lines
356
+ md.append("")
357
+ md.append("## flags")
358
+ if weave_cands:
359
+ md.append("**🟢 ρ·weave-PASS candidate(s)** (best_distinct ≥ 2 AND > max_single — "
360
+ "engine-native recombination signal, verify byte-exact before cementing):")
361
+ for r in weave_cands:
362
+ md.append(f"- `{r['tag']}` — {r['note']}")
363
+ else:
364
+ md.append("- ρ·weave-PASS candidates: none (all cells floored best_distinct ≤ max_single).")
365
+ if invalids:
366
+ md.append("")
367
+ md.append("**⚠️ INVALID (overfit collapse — measurement not trustworthy):**")
368
+ for r in invalids:
369
+ md.append(f"- `{r['tag']}` — {r['note']}")
370
+ md.append("")
371
+ md.append("> HONESTY (a_engine_native_learning / c9): the ρ-AXON reach measure (former G0-G6) here IS the "
372
+ "engine-native `.clm` re-measure (cli/evaluate.py numpy mirror = terminal-eligible). "
373
+ "The torch-side training CE (in each `<tag>.log`) is DIRECTIONAL only. A ρ·weave-PASS (former G1) "
374
+ "candidate still needs the frozen-bar verdict cemented from THIS measure output "
375
+ "verbatim (no tune-to-green).")
376
+ md_text = "\n".join(md) + "\n"
377
+
378
+ summary_path = os.path.join(out_dir, "SWEEP_SUMMARY.md")
379
+ with open(summary_path, "w", encoding="utf-8") as f:
380
+ f.write(md_text)
381
+
382
+ print("")
383
+ print("════════════════════════════════════════════════════════════════")
384
+ print(f" anima sweep aggregate ({len(rows)} cells)")
385
+ print("════════════════════════════════════════════════════════════════")
386
+ for ln in lines:
387
+ print(" " + ln)
388
+ print("")
389
+ if weave_cands:
390
+ print(" 🟢 ρ·weave-PASS candidate(s):")
391
+ for r in weave_cands:
392
+ print(f" {r['tag']} — {r['note']}")
393
+ else:
394
+ print(" ρ·weave-PASS candidates: none.")
395
+ if invalids:
396
+ print(" ⚠️ INVALID (overfit collapse):")
397
+ for r in invalids:
398
+ print(f" {r['tag']} — {r['note']}")
399
+ print("")
400
+ print(f" summary -> {summary_path}")
401
+ return rows
402
+
403
+
404
+ # ════════════════════════════════════════════════════════════════════════════
405
+ def _csv(s: str) -> list[str]:
406
+ return [x.strip() for x in s.split(",") if x.strip()]
407
+
408
+
409
+ def main(argv=None):
410
+ ap = argparse.ArgumentParser(
411
+ prog="anima sweep",
412
+ description="canonical multi-GPU lever-sweep orchestrator — the arms×objectives "
413
+ "matrix, GPU-pinned round-robin, subprocess-shells cli/train.py + "
414
+ "cli/evaluate.py, aggregates the ρ-AXON reach bars · former G0-G6 (replaces scratch fire_*.sh).")
415
+ ap.add_argument("--arms", default="ctrl",
416
+ help="comma list of train.py --arm values (default ctrl)")
417
+ ap.add_argument("--objectives", default="ce_marginal,composed_nce,infonce,constructive_bind",
418
+ help="comma list of train.py --objective values (matrix = arms × objectives)")
419
+ ap.add_argument("--steps", type=int, default=8000)
420
+ ap.add_argument("--ckpt-every", type=int, default=0,
421
+ help="0=final .clm only; N=also dump+measure a checkpoint every N steps "
422
+ "(step-window: one run → ρ·form/ρ·weave · former G0/G1 across 2000/4000/… — train-py-4 isolation)")
423
+ ap.add_argument("--seed", type=int, default=7)
424
+ ap.add_argument("--corpus", nargs="*", default=[],
425
+ help="corpus paths / HF ids passed through to train.py + evaluate.py")
426
+ ap.add_argument("--cell-label", nargs="*", default=[],
427
+ help="per-corpus register labels passed through to train.py")
428
+ ap.add_argument("--gpus", default="",
429
+ help="comma list of GPU indices to pin round-robin (default = all visible)")
430
+ ap.add_argument("--out-dir", default="./sweeprun",
431
+ help="dir for .clm/.pt/.log/.meas.log + SWEEP_SUMMARY.md")
432
+ ap.add_argument("--gen", type=int, default=80, help="measure decode budget (tokens)")
433
+ ap.add_argument("--sample", choices=["roundrobin", "proportional"], default="proportional")
434
+ ap.add_argument("--val-frac", type=float, default=0.02)
435
+ ap.add_argument("--val-every", type=int, default=500)
436
+ ap.add_argument("--dbes-every", type=int, default=100000)
437
+ ap.add_argument("--tlora-rank", type=int, default=8)
438
+ # N6 regularization floor (savant decay override) — forwarded to train.py only when >=0.
439
+ ap.add_argument("--dropout-floor", type=float, default=-1.0,
440
+ help="force constant dropout (>=0 overrides savant decay; escapes 8000-step collapse)")
441
+ ap.add_argument("--wd-floor", type=float, default=-1.0,
442
+ help="force constant weight-decay (>=0 overrides savant decay)")
443
+ # bf16 default ON (train.py toy default is fp32, but a 303M GPU sweep wants bf16);
444
+ # constructive_bind is auto-forced fp32 regardless (fft has no bf16 kernel).
445
+ bf = ap.add_mutually_exclusive_group()
446
+ bf.add_argument("--bf16", dest="bf16", action="store_true", default=True)
447
+ bf.add_argument("--no-bf16", dest="bf16", action="store_false")
448
+ ap.add_argument("--canon", dest="canon", action="store_true", default=True,
449
+ help="303M CLMConvMoE (d3784·L4) — default ON")
450
+ ap.add_argument("--no-canon", dest="canon", action="store_false",
451
+ help="toy shape (d64·L2) for a CPU smoke of the orchestration")
452
+ ms = ap.add_mutually_exclusive_group()
453
+ ms.add_argument("--measure", dest="measure", action="store_true", default=True,
454
+ help="after each train, ρ-AXON reach-measure · former G0-G6 the .clm (default)")
455
+ ms.add_argument("--no-measure", dest="measure", action="store_false",
456
+ help="train only, skip measurement")
457
+ a = ap.parse_args(argv)
458
+
459
+ a.arms = _csv(a.arms)
460
+ a.objectives = _csv(a.objectives)
461
+ gpus = _csv(a.gpus) if a.gpus else _default_gpus()
462
+ out_dir = os.path.abspath(a.out_dir)
463
+ os.makedirs(out_dir, exist_ok=True)
464
+
465
+ # matrix = arms × objectives (each a cell)
466
+ cells = [(arm, obj) for arm in a.arms for obj in a.objectives]
467
+
468
+ print("=== anima sweep (canonical multi-GPU lever-sweep orchestrator) ===", flush=True)
469
+ print(f" engines : train={_TRAIN_PY}", flush=True)
470
+ print(f" eval ={_EVAL_PY}", flush=True)
471
+ print(f" matrix : arms={a.arms} × objectives={a.objectives} = {len(cells)} cells", flush=True)
472
+ print(f" gpus : {gpus} (max-concurrent = {len(gpus)})", flush=True)
473
+ print(f" cfg : steps={a.steps} seed={a.seed} gen={a.gen} bf16={a.bf16} "
474
+ f"canon={a.canon} measure={a.measure} sample={a.sample}", flush=True)
475
+ print(f" out-dir : {out_dir}", flush=True)
476
+ for i, (arm, obj) in enumerate(cells):
477
+ g = gpus[i % len(gpus)]
478
+ bfnote = "fp32(fft)" if obj in _FP32_ONLY_OBJECTIVES else ("bf16" if a.bf16 else "fp32")
479
+ print(f" cell[{i}] {_cell_tag(arm, obj, a.seed):<32s} -> gpu{g} [{bfnote}]", flush=True)
480
+ print("", flush=True)
481
+
482
+ # ── round-robin assign cells to GPUs; one worker thread per GPU runs its cells
483
+ # sequentially (train then measure). max-concurrent = number of GPUs. ────────
484
+ per_gpu: dict[str, list] = {g: [] for g in gpus}
485
+ for i, cell in enumerate(cells):
486
+ per_gpu[gpus[i % len(gpus)]].append(cell)
487
+
488
+ log_lock = threading.Lock()
489
+
490
+ def gpu_worker(gpu: str, assigned: list):
491
+ for (arm, obj) in assigned:
492
+ run_cell(a, arm, obj, gpu, out_dir, log_lock)
493
+
494
+ threads = [threading.Thread(target=gpu_worker, args=(g, per_gpu[g]), daemon=True)
495
+ for g in gpus if per_gpu[g]]
496
+ for t in threads:
497
+ t.start()
498
+ for t in threads:
499
+ t.join()
500
+
501
+ if a.measure:
502
+ aggregate(a, cells, out_dir)
503
+ else:
504
+ print(" --no-measure: trained only, no ρ-AXON reach aggregate (former G0-G6).", flush=True)
505
+ return 0
506
+
507
+
508
+ if __name__ == "__main__":
509
+ sys.exit(main() or 0)