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/train.py ADDED
@@ -0,0 +1,1667 @@
1
+ #!/usr/bin/env python3
2
+ """cli/train.py — the CANONICAL anima python training entry (`anima-python train`).
3
+
4
+ >>> This file is the working python training entry, SYMMETRIC to cli/evaluate.py
5
+ >>> (the canonical python eval entry). `anima-python train <args>` (pip channel) dispatches HERE;
6
+ >>> hexa `anima train <args>` dispatches to the hexa-native cli/train.hexa.
7
+ >>>
8
+ >>> WHY python is the canonical working path RIGHT NOW: the hexa-native production
9
+ >>> trainer cli/train.hexa (a_train_flame_forge) is single-thread native-CPU-scalar-
10
+ >>> bound (#2598/#2600: util peak ~65% / sustained <30%) and is temporarily under a
11
+ >>> GPU-utilization fix, so a real 303M GPU train on it idles the GPU. This torch
12
+ >>> Lane-P path is GPU-BOUND (cuda GEMM-saturating), so it trains the real clm303
13
+ >>> (L4·d3784·E2->Emax4) efficiently today — the explicitly-sanctioned REFERENCE +
14
+ >>> BRIDGE path (a_clm_gen_pipeline: "Lane-P torch = REFERENCE + bridge, forge is the
15
+ >>> PUBLIC production trainer"). It trains a CLMConvMoE GPU-bound, then SERIALIZES to
16
+ >>> a .clm v0.3 (CLM\\x01 magic + CLMX trailer) that CORE core/clm_decode.hexa loads
17
+ >>> back byte-exact for the engine-native ρ-AXON reach verdict (former G0-G6).
18
+ >>>
19
+ >>> ENGINE-NATIVE GATE (a_engine_native_learning, HARD-GATE): torch-side CE / gauges
20
+ >>> here = DIRECTIONAL only (NOT terminal). TERMINAL verdict = CORE re-measure of the
21
+ >>> serialized .clm via `anima-python evaluate <clm>` on the frozen ρ-AXON reach bars (former G0-G6). Pull the
22
+ >>> trained ckpt before teardown (a_fire_recover_complete) so engine-check is possible.
23
+
24
+ This trainer carries the full SAVANT + MITOSIS recipe (parity with cli/train.hexa) AND
25
+ the H_1640 OBJECTIVE-DISCOVERY surface: `--arm {ctrl,tlora,tlora_dict,tlora_jamo}` ×
26
+ `--objective {ce_marginal,infonce,contrastive_equilibrium,predictive_info,
27
+ constructive_bind,composed_nce}`. The last THREE objectives are the NEW compositional
28
+ ρ·weave levers (recombination · frozen bar = former G1 · added on top of CE) — this arm ×
29
+ objective matrix is the whole point of the
30
+ py entry (the trunk-objective search for the ρ·weave (former G1) recombination / ρ·fan (former G6) ideation wall).
31
+
32
+ WHY the objective lever (context, do not re-derive): the ρ·weave (former G1) recombination / ρ·fan (former G6) ideation
33
+ wall is confirmed TRUNK-OBJECTIVE-BOUND — cross-entropy does NOT reward COMPOSITION of
34
+ concepts, so every READOUT op tried (multiplicative binding exp3, CLS pattern-sep
35
+ H_1815, TLoRA expert-weight H_1813, plain-InfoNCE recomb-objective H_9024) only lifts ρ·leap/G2
36
+ novelty (orthogonal) and floors ρ·weave/G1. External lit converges: the lever is the OBJECTIVE +
37
+ regularization, NOT the operator. So a NEW lever must be a NEW LOSS FUNCTION that rewards
38
+ compositional structure IN THE TRUNK, added to CE. This package adds three such losses:
39
+
40
+ predictive_info — MULTI-STEP predictive-coding aux. Aux linear heads predict the
41
+ tokens k=2,3,4 steps AHEAD from the trunk penultimate (not just
42
+ the immediate next token). Rewards the penultimate for carrying
43
+ predictive info about the FUTURE beyond t+1 = the cortical
44
+ predictive hierarchy / predictive-information bottleneck
45
+ (Bialek-Tishby predictive information; van den Oord CPC 1807.03748;
46
+ Rao&Ballard predictive coding). Heads DROPPED at serialize.
47
+
48
+ constructive_bind — TRAINED CONSTRUCTIVE BIND aux (the one untried piece of the
49
+ substrate framebreak). Two learned projections extract role r and
50
+ filler f from the penultimate; they are BOUND by circular
51
+ convolution c = r⊛f (Plate 1995 Holographic Reduced Representations
52
+ / Smolensky 1990 Tensor-Product Representations). Two constraints
53
+ sculpt a compositional code: (1) UNBIND recovers the filler
54
+ (unbind(c,r)≈f, cos loss) so the code must support clean
55
+ compose/decompose, and (2) the bound composite must PREDICT the
56
+ next token (dec(c)→y, CE) so binding carries task signal. Heads
57
+ DROPPED at serialize.
58
+
59
+ composed_nce — COMPOSED-NEGATIVE InfoNCE. Plain InfoNCE's negatives are RANDOM
60
+ vocab tokens (concept-membership only). Here the negatives are the
61
+ SAME bag of tokens present in the window but assigned to the WRONG
62
+ position (targets permuted WITHIN each sequence). Contrasting the
63
+ true token-to-position assignment against same-concept-set /
64
+ wrong-composition assignments directly rewards getting the
65
+ COMPOSITION right, not just the concept set (hard-negative /
66
+ order-sensitive contrastive; CPC-style). Operates on logits — no
67
+ aux params, gradient flows readout→trunk.
68
+
69
+ All three are DIRECTIONAL torch-side training pressures; the verdict is later via
70
+ `anima-python evaluate <clm>` engine-native on the FROZEN ρ·weave bar (recombination · former G1 · a_engine_native_learning).
71
+ The .clm path stays OPEN: aux heads/projections live OUTSIDE model.state_dict (in the
72
+ objective module), so serialize_v3 writes only the standard additive-readout CLMConvMoE.
73
+
74
+ LEVERS (arm axis, orthogonal to the readout-floor result, all on the SAME trunk):
75
+ N1 TLoRA / TensorPoly expert-weight reparameterization (2405.16671): each ConvExpert
76
+ conv weight W∈(d,d,K) is REPARAMETERIZED as a low-rank tensor product
77
+ W = sum_r (a_r ⊗ b_r) ⊗ k_r (+ optional dense base), learned via the factors then
78
+ MATERIALIZED back to the dense (d,d,K) conv weight (so the .clm path stays OPEN).
79
+ N3 DBES expert-specialization diagnostic (2605.18523, MEASURE-ONLY, gradient-free).
80
+ N7 dictionary/sparse-coding aux loss (2603.28744): L1 sparsity on the penultimate.
81
+ N8 jamo (자모) compositional teach signal (2604.12377): next-jamo-class aux head.
82
+ N6 regularization schedule sweep (--wd-floor / --dropout-floor override knobs).
83
+
84
+ Arms (single structural variable each, vs ctrl):
85
+ ctrl : production CLMConvMoE, plain CE. The discriminating control.
86
+ tlora : N1 TLoRA expert-weight (rank R, base on) + CE.
87
+ tlora_dict : N1 TLoRA + N7 dictionary/sparse aux.
88
+ tlora_jamo : N1 TLoRA + N8 jamo teach aux.
89
+
90
+ ARCH (--arch {clm,bytegpt}, default clm — preserves current behavior): the objective
91
+ levers are ARCH-AGNOSTIC (they operate on logits + an optional penultimate), so they can
92
+ be tested on EITHER trunk. `--arch bytegpt` builds a 24-layer GPT-2-class ByteGPT (the
93
+ CLEAN ρ·weave recombination wall (former G1): ByteGPT single=2, vs CLMConvMoE's single=0 coverage-floor) and serializes a
94
+ `.bin` (5×u32 header) via core/serialize.py (the unified serializer) instead of a `.clm`. For bytegpt the
95
+ CLM-specific levers (savant/mitosis/tlora/dict/jamo) are gated OFF — only arm=ctrl × the
96
+ objective matrix is supported (that's exactly what the G1-lever test needs). `anima-python
97
+ evaluate` auto-detects `.bin` vs `.clm` by header, so a ByteGPT `.bin` measures through
98
+ the bytegpt mouth automatically.
99
+
100
+ USAGE (installed `anima` PATH command after `hx install anima`):
101
+ # CLM trunk (default):
102
+ anima-python train --arm ctrl --objective constructive_bind --steps 8000 \\
103
+ --canon --corpus <p1..p4> --cell-label ko-general en-general ko-sns en-sns \\
104
+ --seed 7 --val-frac 0.05 --val-every 200 --sample proportional \\
105
+ --out ckpt/ctrl_cbind_seed7.clm --ckpt-out ckpt/ctrl_cbind_seed7.pt \\
106
+ --gauges-out ckpt/ctrl_cbind_seed7.json
107
+ # ByteGPT trunk (the CLEAN ρ·weave recombination wall · former G1) — arm=ctrl × the objective matrix:
108
+ anima-python train --arch bytegpt --arm ctrl --objective composed_nce --steps 8000 \\
109
+ --canon --corpus <p1..p4> --seed 7 --out ckpt/bg_ctrl_cnce.bin \\
110
+ --gauges-out ckpt/bg_ctrl_cnce.json
111
+ """
112
+ from __future__ import annotations
113
+ import argparse, json, math, os, sys, time
114
+ import torch
115
+ import torch.nn as nn
116
+ import torch.nn.functional as F
117
+ import torch.distributed as dist # DDP collectives (§1/§3/§6)
118
+ from torch.nn.parallel import DistributedDataParallel as DDP # §4 composite-shell wrap
119
+
120
+ # ── locate the CLM model + serializer/verifier — all CORE-owned (core/) ──────
121
+ # LOCATION-INDEPENDENT (the installed `anima` runs from ANY cwd + this file lives in
122
+ # cli/, not the repo root): the UNIFIED CLM+ByteGPT torch model (model.py), the held-out
123
+ # DESCENT verifier (verify_clm_v2.py), the historical serialize backend
124
+ # (clm_serialize_v2.py) and the .pt→.clm bridge (serialize_standalone.py)
125
+ # ALL live in core/ (owner directive: core-related lives in core/; no archive/train
126
+ # import — a_no_archive_import). core/ is added to sys.path so bare `import model` /
127
+ # `import serialize` resolve, the same resolution cli/evaluate.py uses for `import decode`.
128
+ _HERE = os.path.dirname(os.path.abspath(__file__)) # …/cli
129
+ _ROOTS = []
130
+ _ENV_SRC = os.environ.get("ANIMA_SRC")
131
+ if _ENV_SRC:
132
+ _ROOTS.append(_ENV_SRC)
133
+ _ROOTS.append(os.path.dirname(_HERE)) # repo root = parent of cli/
134
+ # tool/ (gauge_lib) is best-effort (the ρ·weave/ρ·fan (former G1/G6) torch probe below is wrapped in try/except).
135
+ for _r in _ROOTS:
136
+ _t = os.path.join(_r, "tool")
137
+ if os.path.isdir(_t) and _t not in sys.path:
138
+ sys.path.insert(0, _t)
139
+ # core/ is the engine package — the CLM model, the unified serializer (serialize.py),
140
+ # and the verifier all resolve from here.
141
+ _CORE = None
142
+ for _r in _ROOTS:
143
+ _c = os.path.join(_r, "core")
144
+ if os.path.isdir(_c):
145
+ if _c not in sys.path:
146
+ sys.path.insert(0, _c)
147
+ if _CORE is None and os.path.exists(os.path.join(_c, "model.py")):
148
+ _CORE = _c
149
+ if _CORE is None:
150
+ raise ImportError(
151
+ "cli/train.py: could not locate core/model.py under the repo root or "
152
+ "$ANIMA_SRC. Set $ANIMA_SRC to the anima source root.")
153
+
154
+ # (imports resolve via the core/ sys.path insert above.)
155
+ from model import (CLMConfig, CLMConvMoE, MoEStats, CausalDilatedConv1d,
156
+ ByteGPTConfig, ByteGPT) # core/model.py (unified CONV+BYTE)
157
+ import serialize as S # core/serialize.py — serialize_v3 = bridge SSOT
158
+ import verify_clm_v2 as VC # core/verify_clm_v2.py — clm_decodable / descent
159
+ # ByteGPT .pt -> .bin serializer is folded into the SAME unified core/serialize.py.
160
+ import serialize as BGS # core/serialize.py — serialize(pt_path, bin_path)
161
+
162
+
163
+ # ════════════════════════════════════════════════════════════════════════════
164
+ # golden-zone constants (SAVANT/savant_lib.hexa H_347/348, verbatim — same as
165
+ # cli/train.hexa gz_lower()/gz_upper()).
166
+ # ════════════════════════════════════════════════════════════════════════════
167
+ GZ_LOWER = 0.21231792755821914 # 1/2 - ln(4/3) (sa_gz_lower)
168
+ GZ_UPPER = 0.5 # sa_gz_upper
169
+ LN2 = 0.6931471805599453
170
+
171
+
172
+ def savant_inhibition(step: int, n_steps: int, i0: float, i_floor: float,
173
+ latch: dict) -> float:
174
+ """Golden-zone cusp-anneal inhibition for THIS step (a_savant_train).
175
+
176
+ Linear anneal I0 -> I_floor over training. When I first crosses INTO the
177
+ golden zone [GZ_LOWER, GZ_UPPER] on the way down, latch['on'] hard-steps to
178
+ True (cusp, H_1562) and STAYS True for the rest of training (asymmetric
179
+ hysteresis, H_1563) even as the anneal drives I below GZ_LOWER (H_1559: the
180
+ sweep below the floor is intentional so the learning sweet-spot is reached).
181
+ Mirrors cli/train.hexa::savant_inhibition in arithmetic.
182
+ """
183
+ denom = (n_steps - 1) if n_steps > 1 else 1
184
+ frac = (step - 1) / denom
185
+ inh = i0 + (i_floor - i0) * frac
186
+ if not latch["on"]:
187
+ if GZ_LOWER <= inh <= GZ_UPPER:
188
+ latch["on"] = True
189
+ if latch["at"] == 0:
190
+ latch["at"] = step
191
+ return inh
192
+
193
+
194
+ def inhibition_to_wd(inh: float) -> float:
195
+ """Map inhibition I -> AdamW weight decay (I in [0,0.5] -> wd in [0,0.05]).
196
+ Deterministic (p7). Same linear lever as cli/train.hexa::inhibition_to_wd."""
197
+ return inh * 0.1
198
+
199
+
200
+ def inhibition_to_dropout(inh: float) -> float:
201
+ """Inhibition also realized as dropout p (clamped to a sane [0, 0.5]). The
202
+ dropout/weight-decay/temperature variants share the single scalar lever."""
203
+ return max(0.0, min(0.5, inh))
204
+
205
+
206
+ # ════════════════════════════════════════════════════════════════════════════
207
+ # MITOSIS split E -> E+1 (a_mitosis_train) — continuity-preserving cell division
208
+ # on the live torch MoEConvLayer. Port of cli/train.hexa::train_mitosis_split /
209
+ # clm_mitosis.hexa::mitosis_split semantics:
210
+ # * child expert conv = copy(parent) + tiny alternating +/-1e-4 perturbation
211
+ # * router weight row for child = copy(parent row)
212
+ # * router bias: both parent and child get (parent_bias - ln2) so the two
213
+ # children jointly reproduce the parent's gate mass (near-continuous split)
214
+ # * the optimizer's Adam moments for the touched params are reset to 0.
215
+ # Experts are pre-allocated at Emax (inactive experts masked out of the router
216
+ # until they are "born"), so a split just UNMASKS + seeds the next slot.
217
+ # ════════════════════════════════════════════════════════════════════════════
218
+ class MitosisMoE:
219
+ """Tracks the ACTIVE expert count E while experts/router are physically
220
+ allocated at Emax. Inactive experts are masked out of the router softmax
221
+ (logit += -1e9) so they are inert until born."""
222
+
223
+ def __init__(self, model: CLMConvMoE, e0: int, emax: int):
224
+ self.model = model
225
+ self.e_active = e0
226
+ self.emax = emax
227
+ dev = next(model.parameters()).device
228
+ self.active_mask = torch.zeros(emax, device=dev)
229
+ self.active_mask[:e0] = 1.0
230
+
231
+ def neg_inf_bias(self) -> torch.Tensor:
232
+ # additive router-logit bias: 0 for active slots, -1e9 for dormant.
233
+ return (self.active_mask - 1.0) * 1e9
234
+
235
+ @torch.no_grad()
236
+ def split(self, parent: int, opt: torch.optim.Optimizer) -> int:
237
+ """Divide `parent` into a fresh child slot (= current e_active index).
238
+ Returns the new active count, or the unchanged count if at Emax."""
239
+ if self.e_active >= self.emax:
240
+ return self.e_active
241
+ child = self.e_active
242
+ moe = self.model.moe
243
+ pe = moe.experts[parent].conv.conv # parent expert Conv1d
244
+ ce = moe.experts[child].conv.conv # child expert Conv1d
245
+ # copy weights + tiny alternating perturbation (parity w/ hexa eps loop)
246
+ pw = pe.weight.detach().clone()
247
+ flat = pw.reshape(-1)
248
+ eps = torch.full_like(flat, 1e-4)
249
+ eps[1::2] = -1e-4
250
+ ce.weight.copy_((flat + eps).reshape(pw.shape))
251
+ if pe.bias is not None and ce.bias is not None:
252
+ ce.bias.copy_(pe.bias)
253
+ # router: child row = parent row; bias splits by -ln2 on BOTH children
254
+ rw = moe.router.weight # (Emax, d, 1)
255
+ rb = moe.router.bias # (Emax,)
256
+ rw[child].copy_(rw[parent])
257
+ pb = rb[parent].item()
258
+ rb[parent] = pb - LN2
259
+ rb[child] = pb - LN2
260
+ # reset Adam moments for the touched params (m,v -> 0).
261
+ for p in (pe.weight, ce.weight, pe.bias, ce.bias, rw, rb):
262
+ if p is None:
263
+ continue
264
+ st = opt.state.get(p, None)
265
+ if st:
266
+ if "exp_avg" in st:
267
+ st["exp_avg"].zero_()
268
+ if "exp_avg_sq" in st:
269
+ st["exp_avg_sq"].zero_()
270
+ self.active_mask[child] = 1.0
271
+ self.e_active = child + 1
272
+ return self.e_active
273
+
274
+
275
+ # ════════════════════════════════════════════════════════════════════════════
276
+ # Router masking — patch MoEConvLayer.forward so dormant experts are inert.
277
+ # We add the dormant-slot -inf bias to the router logits before softmax so an
278
+ # allocated-at-Emax model behaves EXACTLY like an E-active model until a mitosis
279
+ # split unmasks the next slot. Pure additive logit mask; trunk/embed/readout are
280
+ # untouched, so the serialized .clm round-trips the grown E.
281
+ # ════════════════════════════════════════════════════════════════════════════
282
+ def install_router_mask(model: CLMConvMoE, mito: MitosisMoE):
283
+ moe = model.moe
284
+ orig_router = moe.router
285
+
286
+ def masked_forward(x):
287
+ B, C, T = x.shape
288
+ n_e = moe.rc.n_experts
289
+ logits = orig_router(x) + mito.neg_inf_bias().view(1, n_e, 1)
290
+ probs = F.softmax(logits, dim=1)
291
+ ent_tok = -(probs * torch.log(probs + 1e-9)).sum(dim=1)
292
+ entropy = ent_tok.mean()
293
+ ex_out = torch.stack([e(x) for e in moe.experts], dim=1)
294
+ if moe.rc.hard_top_k:
295
+ k = min(moe.rc.top_k, int(mito.e_active))
296
+ topv, topi = probs.topk(k, dim=1)
297
+ gate = topv / (topv.sum(dim=1, keepdim=True) + 1e-9)
298
+ mask = torch.zeros_like(probs).scatter_(1, topi, gate)
299
+ else:
300
+ mask = probs
301
+ y = (mask.unsqueeze(2) * ex_out).sum(dim=1)
302
+ usage = probs.mean(dim=(0, 2))
303
+ aux = x.new_zeros(())
304
+ if moe.rc.load_balance_coef > 0.0:
305
+ top1 = probs.argmax(dim=1)
306
+ f_i = F.one_hot(top1, n_e).to(probs.dtype).mean(dim=(0, 1))
307
+ lb = n_e * (f_i * usage).sum()
308
+ aux = aux + moe.rc.load_balance_coef * lb
309
+ if moe.rc.entropy_coef > 0.0:
310
+ aux = aux - moe.rc.entropy_coef * entropy
311
+ return y, MoEStats(usage=usage, aux_loss=aux, entropy=entropy)
312
+
313
+ moe.forward = masked_forward
314
+
315
+
316
+ # ════════════════════════════════════════════════════════════════════════════
317
+ # 4-cell register corpus (a_chat_registers) — {ko·en}x{normal·SNS}. Each
318
+ # --corpus entry is a LOCAL byte file OR an HF dataset path (resolved to a local
319
+ # cached byte stream). Windows are sampled round-robin across the cells so every
320
+ # step sees the register mix. Files are mmap'd so a multi-GB cell is never
321
+ # slurped whole into RAM.
322
+ # ════════════════════════════════════════════════════════════════════════════
323
+ class ByteCell:
324
+ """One register cell — a memory-mapped byte file sampled by random windows.
325
+
326
+ The TAIL `val_frac` of the file is reserved as a held-out validation region
327
+ DISJOINT from the train region (overfit detector, a_savant_train held-out
328
+ monitor). Train windows are sampled only from [0, train_end); val windows
329
+ only from [train_end, size). A window can NEVER straddle the boundary
330
+ (start ranges are clamped to keep start+seq_len+1 within the region), so the
331
+ val region's bytes are never seen by a training gradient step."""
332
+
333
+ def __init__(self, path: str, val_frac: float = 0.0):
334
+ import mmap
335
+ self.path = path
336
+ self.size = os.path.getsize(path)
337
+ self._f = open(path, "rb")
338
+ if self.size > 0:
339
+ self._mm = mmap.mmap(self._f.fileno(), 0, access=mmap.ACCESS_READ)
340
+ else:
341
+ self._mm = b""
342
+ # boundary: bytes [0, train_end) = train, [train_end, size) = held-out val.
343
+ vf = max(0.0, min(0.5, val_frac))
344
+ self.train_end = int(self.size * (1.0 - vf)) if self.size > 0 else 0
345
+
346
+ def _window_in(self, lo: int, hi_excl: int, seq_len: int,
347
+ gen: torch.Generator):
348
+ """Sample one (x,y) window whose [start, start+seq_len+1) lies entirely
349
+ inside the byte range [lo, hi_excl). Returns None if the range is too
350
+ small for even one window."""
351
+ if hi_excl - lo < seq_len + 2:
352
+ return None
353
+ hi = hi_excl - seq_len - 1 # last valid start (exclusive upper bound)
354
+ start = int(torch.randint(lo, hi, (1,), generator=gen).item())
355
+ chunk = self._mm[start:start + seq_len + 1]
356
+ buf = torch.frombuffer(bytearray(chunk), dtype=torch.uint8).long()
357
+ return buf[:seq_len], buf[1:seq_len + 1]
358
+
359
+ def window_spec(self, seq_len: int, gen: torch.Generator):
360
+ """§3 SPEC phase — the RNG-only half of a TRAIN window: the bounds-check + randint
361
+ that `_window_in(0, train_end, …)` does, WITHOUT the mmap read. Returns the start
362
+ index, or None when [0, train_end) is too small for even one window — in which case
363
+ NO randint is consumed (identical early-return to `_window_in`). Every DDP rank
364
+ replays this off the SHARED gen so the GLOBAL window set is byte-identical to the
365
+ 1-GPU draw; each rank only materializes its own slice (window desync would break
366
+ both the frozen-recipe comparison AND N==1 byte-identity, §10.4)."""
367
+ lo, hi_excl = 0, self.train_end
368
+ if hi_excl - lo < seq_len + 2:
369
+ return None
370
+ hi = hi_excl - seq_len - 1 # last valid start (exclusive upper bound)
371
+ return int(torch.randint(lo, hi, (1,), generator=gen).item())
372
+
373
+ def materialize(self, start: int, seq_len: int):
374
+ """§3 MATERIALIZE phase — the mmap-read half: given a `start` from window_spec,
375
+ return the (x, y) window. Kept separate so a rank only touches disk for its own
376
+ shard. Byte-for-byte identical to `_window_in`'s post-randint tail."""
377
+ chunk = self._mm[start:start + seq_len + 1]
378
+ buf = torch.frombuffer(bytearray(chunk), dtype=torch.uint8).long()
379
+ return buf[:seq_len], buf[1:seq_len + 1]
380
+
381
+ def window(self, seq_len: int, gen: torch.Generator):
382
+ """A TRAIN window — sampled only from [0, train_end) (never the val tail). Delegates
383
+ to window_spec (RNG) then materialize (mmap) so the N==1 op + RNG-draw order is
384
+ identical to the pre-refactor `_window_in` (bounds-check → randint → mmap read)."""
385
+ start = self.window_spec(seq_len, gen)
386
+ if start is None:
387
+ return None
388
+ return self.materialize(start, seq_len)
389
+
390
+ def val_window(self, seq_len: int, gen: torch.Generator):
391
+ """A held-out VAL window — sampled only from [train_end, size)."""
392
+ return self._window_in(self.train_end, self.size, seq_len, gen)
393
+
394
+
395
+ def resolve_corpus_path(spec: str) -> str:
396
+ """Resolve a --corpus entry to a local byte-file path.
397
+
398
+ * existing local path -> use directly.
399
+ * else treat as an HF dataset id (a_chat_registers names like
400
+ 'anima-corpus-5lang-unified-v2', 'anima-corpus-ko-fineweb2-broad',
401
+ 'anima-persona-sns-corpus') and stream it to a local cached byte file
402
+ under $ANIMA_CORPUS_CACHE (default ./.corpus_cache) via `datasets`; the
403
+ text column is concatenated UTF-8 -> raw bytes (V=256).
404
+ """
405
+ if os.path.exists(spec):
406
+ return spec
407
+ cache_root = os.environ.get("ANIMA_CORPUS_CACHE",
408
+ os.path.join(os.getcwd(), ".corpus_cache"))
409
+ os.makedirs(cache_root, exist_ok=True)
410
+ local = os.path.join(cache_root, spec.replace("/", "__") + ".bytes")
411
+ if os.path.exists(local) and os.path.getsize(local) > 0:
412
+ return local
413
+ try:
414
+ from datasets import load_dataset
415
+ except Exception as e:
416
+ raise FileNotFoundError(
417
+ f"--corpus '{spec}' is not a local file and `datasets` is not "
418
+ f"installed to fetch it as an HF dataset ({e}). Provide a local "
419
+ f"byte file, or `pip install datasets`."
420
+ )
421
+ # An HF id never looks like a bare local filename (no slash AND ends in a
422
+ # file extension that doesn't exist locally) — a plain missing path is almost
423
+ # always a staging mistake, so make the HF intent explicit in the log.
424
+ repo = spec if "/" in spec else f"dancinlab/{spec}"
425
+ print(f" resolve: '{spec}' is not a local path -> streaming HF dataset {repo}")
426
+ ds = load_dataset(repo, split="train", streaming=True)
427
+ maxrows = int(os.environ.get("ANIMA_CORPUS_MAXROWS", "0") or 0)
428
+ text_col = None
429
+ with open(local, "wb") as out:
430
+ for i, row in enumerate(ds):
431
+ if text_col is None:
432
+ for c in ("text", "content", "body", "caption"):
433
+ if c in row:
434
+ text_col = c
435
+ break
436
+ if text_col is None:
437
+ text_col = next(iter(row))
438
+ out.write(str(row[text_col]).encode("utf-8", "replace"))
439
+ out.write(b"\n")
440
+ if maxrows > 0 and i + 1 >= maxrows:
441
+ break
442
+ return local
443
+
444
+
445
+ # ── frozen lever hyperparams (pre-registered in PREREG.md — tune-to-green 금지) ──
446
+ TLORA_RANK = 8 # default tensor-product rank R (a_r⊗b_r⊗k_r factors)
447
+ TLORA_BASE = True # keep a small dense base weight alongside the low-rank TP
448
+ DICT_LAMBDA = 1e-3 # N7 trunk-penultimate L1 sparsity weight (Stop-Probing)
449
+ JAMO_LAMBDA = 0.3 # N8 next-jamo-class aux head weight (SCRIPT)
450
+ INFONCE_LAMBDA = 1.0; INFONCE_NEG = 64
451
+ EQ_LAMBDA = 1.0; EQ_MARGIN = 0.5
452
+
453
+ # ── NEW OBJECTIVE frozen hyperparams (H_1640 — pre-registered in PREREG.md) ──
454
+ PREDINFO_LAMBDA = 0.5 # multi-step predictive-coding aux weight (per horizon, averaged)
455
+ PREDINFO_HORIZONS = (2, 3, 4) # predict tokens 2/3/4 steps AHEAD from penultimate
456
+ CBIND_LAMBDA = 0.5 # constructive-bind aux weight (unbind-recon + composite-CE)
457
+ CBIND_DIM = 256 # HRR role/filler factor dim (power-of-2 friendly for FFT)
458
+ CBIND_UNBIND_W = 1.0 # weight on the unbind-recovers-filler term
459
+ CBIND_PRED_W = 1.0 # weight on the bound-composite-predicts-next-token term
460
+ CNCE_LAMBDA = 1.0 # composed-negative InfoNCE weight
461
+ CNCE_PERMS = 8 # # of within-window target permutations = wrong-composition negatives
462
+
463
+
464
+ # ════════════════════════════════════════════════════════════════════════════
465
+ # N1 — TLoRA / TensorPoly expert weight.
466
+ # A drop-in replacement for ConvExpert whose conv weight W∈(d_out=d,d_in=d,K) is
467
+ # reparameterized as a sum of R rank-1 tensor products plus an optional small
468
+ # dense base. The forward is still a plain causal conv (engine-compatible). The
469
+ # effective dense weight is exposed via .materialized_weight() so it can be
470
+ # written into a standard CLMConvMoE state_dict for serialize_v3 (engine-native).
471
+ # ════════════════════════════════════════════════════════════════════════════
472
+ class TLoRAConvExpert(nn.Module):
473
+ """ConvExpert with a tensor-product-factorized conv weight (N1, TLoRA).
474
+
475
+ W[o,i,k] = base[o,i,k] + sum_r A[r,o] * B[r,i] * Kf[r,k]
476
+ where A∈(R,d), B∈(R,d), Kf∈(R,K). This is the Tucker/CP tensor-product
477
+ reparameterization (TensorPoly/TLoRA, 2405.16671) applied to the expert
478
+ weight position — a STRUCTURED (low-rank, compositional) prior on how the
479
+ expert mixes channels, distinct from the readout-position Hadamard we
480
+ already floored (exp3). The bias is a normal learnable vector."""
481
+
482
+ def __init__(self, cfg: CLMConfig, rank: int, base: bool):
483
+ super().__init__()
484
+ d, K = cfg.d_model, cfg.expert_kernel_size
485
+ self.d, self.K, self.R = d, K, rank
486
+ self.dilation = 1
487
+ self.pad = (K - 1) * self.dilation
488
+ # tensor-product factors (CP decomposition of the (d,d,K) conv tensor)
489
+ self.A = nn.Parameter(torch.empty(rank, d)) # out-channel factor
490
+ self.B = nn.Parameter(torch.empty(rank, d)) # in-channel factor
491
+ self.Kf = nn.Parameter(torch.empty(rank, K)) # kernel-tap factor
492
+ nn.init.normal_(self.A, std=d ** -0.5)
493
+ nn.init.normal_(self.B, std=d ** -0.5)
494
+ nn.init.normal_(self.Kf, std=K ** -0.5)
495
+ if base:
496
+ # small dense base so the expert is never strictly rank-R limited
497
+ self.base = nn.Parameter(torch.zeros(d, d, K))
498
+ nn.init.normal_(self.base, std=(d * K) ** -0.5 * 0.1)
499
+ else:
500
+ self.register_parameter("base", None)
501
+ self.bias = nn.Parameter(torch.zeros(d))
502
+ self.act = nn.GELU()
503
+
504
+ def materialized_weight(self) -> torch.Tensor:
505
+ """Compose the TP factors (+ base) into the dense (d_out, d_in, K) conv
506
+ weight that nn.Conv1d / the .clm format expects. einsum: r o, r i, r k -> o i k."""
507
+ W = torch.einsum("ro,ri,rk->oik", self.A, self.B, self.Kf)
508
+ if self.base is not None:
509
+ W = W + self.base
510
+ return W
511
+
512
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
513
+ # x: (B, C, T) ; causal left-pad then functional conv with materialized W
514
+ W = self.materialized_weight()
515
+ xp = F.pad(x, (self.pad, 0))
516
+ y = F.conv1d(xp, W, self.bias, dilation=self.dilation)
517
+ return self.act(y)
518
+
519
+
520
+ def install_tlora_experts(model: CLMConvMoE, rank: int, base: bool):
521
+ """Replace every ConvExpert in model.moe.experts with a TLoRAConvExpert
522
+ (N1). Returns the new ModuleList so the optimizer sees the TP factors."""
523
+ cfg = model.cfg
524
+ new = nn.ModuleList(TLoRAConvExpert(cfg, rank, base)
525
+ for _ in range(len(model.moe.experts)))
526
+ model.moe.experts = new
527
+ return new
528
+
529
+
530
+ def tlora_aware_split(mito, parent: int, opt) -> int:
531
+ """Mitosis cell-division for TLoRA experts (parity with MitosisMoE.split, but
532
+ operating on the TP factors instead of .conv.conv).
533
+
534
+ MitosisMoE.split() assumes a standard ConvExpert (.conv.conv Conv1d);
535
+ TLoRAConvExpert has TP factors (A,B,Kf[,base],bias) instead. We replicate the
536
+ same semantics: child = clone(parent) + tiny alternating perturbation, router
537
+ row copied, both children's router bias -= ln2, Adam moments reset. This keeps
538
+ the savant×mitosis recipe identical (only the expert PARAMETERIZATION differs,
539
+ which is exactly the single variable under test)."""
540
+ if mito.e_active >= mito.emax:
541
+ return mito.e_active
542
+ import torch as _t
543
+ with _t.no_grad():
544
+ child = mito.e_active
545
+ moe = mito.model.moe
546
+ pe = moe.experts[parent]; ce = moe.experts[child]
547
+ touched = []
548
+ for name in ("A", "B", "Kf", "base", "bias"):
549
+ pp = getattr(pe, name, None); cp = getattr(ce, name, None)
550
+ if pp is None or cp is None:
551
+ continue
552
+ flat = pp.detach().clone().reshape(-1)
553
+ eps = _t.full_like(flat, 1e-4); eps[1::2] = -1e-4
554
+ cp.copy_((flat + eps).reshape(pp.shape))
555
+ touched += [pp, cp]
556
+ rw = moe.router.weight; rb = moe.router.bias
557
+ rw[child].copy_(rw[parent])
558
+ pb = rb[parent].item()
559
+ rb[parent] = pb - LN2; rb[child] = pb - LN2
560
+ touched += [rw, rb]
561
+ for p in touched:
562
+ st = opt.state.get(p, None)
563
+ if st:
564
+ if "exp_avg" in st: st["exp_avg"].zero_()
565
+ if "exp_avg_sq" in st: st["exp_avg_sq"].zero_()
566
+ mito.active_mask[child] = 1.0
567
+ mito.e_active = child + 1
568
+ return mito.e_active
569
+
570
+
571
+ def materialize_experts_into_state(model: CLMConvMoE):
572
+ """Return a state_dict where each TLoRA expert is written under the STANDARD
573
+ keys 'moe.experts.{j}.conv.conv.{weight,bias}' (the dense form serialize_v3
574
+ reads). Non-expert keys pass through unchanged. This is what makes the .clm
575
+ engine-loadable despite the reparameterization."""
576
+ sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
577
+ out = {k: v for k, v in sd.items() if not k.startswith("moe.experts.")}
578
+ for j, e in enumerate(model.moe.experts):
579
+ if isinstance(e, TLoRAConvExpert):
580
+ out[f"moe.experts.{j}.conv.conv.weight"] = e.materialized_weight().detach().cpu()
581
+ out[f"moe.experts.{j}.conv.conv.bias"] = e.bias.detach().cpu()
582
+ else: # plain ConvExpert (ctrl) — already standard keys, copy through
583
+ for k, v in sd.items():
584
+ if k.startswith(f"moe.experts.{j}."):
585
+ out[k] = v
586
+ return out
587
+
588
+
589
+ # ════════════════════════════════════════════════════════════════════════════
590
+ # N3 — DBES expert-specialization diagnostic (MEASURE-ONLY, gradient-free).
591
+ # ════════════════════════════════════════════════════════════════════════════
592
+ @torch.no_grad()
593
+ def dbes_specialization(model: CLMConvMoE, x: torch.Tensor) -> dict:
594
+ """Differentiation-of-Behaviour Expert Specialization (DBES, 2605.18523).
595
+
596
+ On a batch x (B,T) of bytes, run the trunk up to the MoE input, then:
597
+ * expert_div = mean pairwise (1 - cosine) between expert OUTPUT maps
598
+ (how differently the experts transform the same input).
599
+ * router_entropy = mean per-token routing entropy (nats).
600
+ * usage_gini = Gini of mean per-expert routing mass (1=one expert hogs all).
601
+ Low expert_div + low usage spread => experts are NOT differentiated, a
602
+ candidate cause of a ρ·weave recombination floor (former G1). Pure diagnostic — no grad."""
603
+ b = model
604
+ h = b.embed(x).transpose(1, 2)
605
+ h = b.embed_conv(h)
606
+ for layer in b.trunk:
607
+ h = layer(h)
608
+ # expert outputs on the SAME pre-MoE activation
609
+ outs = []
610
+ for e in b.moe.experts:
611
+ outs.append(e(h)) # (B, C, T)
612
+ n_e = len(outs)
613
+ # pairwise output cosine distance (flatten B,C,T)
614
+ flat = [o.reshape(-1) for o in outs]
615
+ div, npair = 0.0, 0
616
+ for i in range(n_e):
617
+ for j in range(i + 1, n_e):
618
+ cos = F.cosine_similarity(flat[i], flat[j], dim=0).item()
619
+ div += (1.0 - cos); npair += 1
620
+ expert_div = (div / npair) if npair else 0.0
621
+ # router stats
622
+ logits = b.moe.router(h) # (B, n_e, T)
623
+ probs = F.softmax(logits, dim=1)
624
+ ent = -(probs * torch.log(probs + 1e-9)).sum(dim=1).mean().item()
625
+ usage = probs.mean(dim=(0, 2)) # (n_e,)
626
+ u = torch.sort(usage).values
627
+ nn_ = u.numel()
628
+ # Gini = (2*sum(i*u_i)/(n*sum u) ) - (n+1)/n
629
+ idx = torch.arange(1, nn_ + 1, dtype=u.dtype, device=u.device)
630
+ gini = (2.0 * (idx * u).sum() / (nn_ * u.sum() + 1e-9) - (nn_ + 1) / nn_).item()
631
+ return {"expert_div": round(expert_div, 5),
632
+ "router_entropy": round(ent, 5),
633
+ "usage_gini": round(gini, 5),
634
+ "usage": [round(float(z), 5) for z in usage.tolist()],
635
+ "n_experts": n_e}
636
+
637
+
638
+ # ════════════════════════════════════════════════════════════════════════════
639
+ # N8 — jamo (자모) compositional teach signal. We predict, per Hangul-syllable
640
+ # byte position, a coarse jamo class so the trunk learns sub-character structure.
641
+ # Hangul syllables are UTF-8 3-byte sequences (0xEA..0xED leading); we derive a
642
+ # cheap jamo-bucket target from the syllable code point's (lead, vowel, tail).
643
+ # ════════════════════════════════════════════════════════════════════════════
644
+ class JamoHead(nn.Module):
645
+ """Aux head: trunk penultimate -> coarse jamo class logits. Dropped at serialize."""
646
+ def __init__(self, d, n_jamo=64):
647
+ super().__init__()
648
+ self.proj = nn.Conv1d(d, n_jamo, 1)
649
+ self.n_jamo = n_jamo
650
+
651
+ def forward(self, h): # h: (B, d, T) -> (B, n_jamo, T)
652
+ return self.proj(h)
653
+
654
+
655
+ def jamo_targets(tokens: torch.Tensor, n_jamo: int) -> torch.Tensor:
656
+ """Map each byte to a coarse jamo bucket (0=non-Hangul-lead). Cheap, byte-level:
657
+ Hangul UTF-8 lead bytes 0xEA-0xED get a bucket from (byte & 0x3f) % (n_jamo-1) +1,
658
+ everything else -> 0 (ignored class). This is a weak teach signal that biases the
659
+ trunk toward Korean sub-character regularity without needing a full jamo decomposer."""
660
+ is_lead = (tokens >= 0xEA) & (tokens <= 0xED)
661
+ bucket = ((tokens & 0x3F) % (n_jamo - 1)) + 1
662
+ return torch.where(is_lead, bucket, torch.zeros_like(tokens))
663
+
664
+
665
+ # ════════════════════════════════════════════════════════════════════════════
666
+ # Objective heads (carried over from objrun H_1602 — OPTIONAL coupling).
667
+ # ════════════════════════════════════════════════════════════════════════════
668
+ def _ce(logits, targets, V):
669
+ return F.cross_entropy(logits.transpose(1, 2).reshape(-1, V), targets.reshape(-1))
670
+
671
+
672
+ # NOTE (H_1640): every objective now accepts an OPTIONAL penultimate=(B,d,T) kwarg
673
+ # (the post-MoE pre-readout trunk site). The inherited objectives ignore it; the new
674
+ # compositional objectives consume it. Plain-function objectives have no params; the
675
+ # two aux-head objectives are nn.Modules whose params are added to the optimizer in
676
+ # main() and DROPPED at serialize (they never enter model.state_dict).
677
+ def loss_ce_marginal(logits, targets, V, gen, penultimate=None):
678
+ return _ce(logits, targets, V), {}
679
+
680
+
681
+ def loss_infonce(logits, targets, V, gen, penultimate=None):
682
+ ce = _ce(logits, targets, V)
683
+ lg = logits.transpose(1, 2).reshape(-1, V)
684
+ tgt = targets.reshape(-1); N = tgt.shape[0]
685
+ pos = lg.gather(1, tgt.unsqueeze(1))
686
+ neg_idx = torch.randint(0, V, (N, INFONCE_NEG), generator=gen, device=lg.device)
687
+ neg = lg.gather(1, neg_idx).masked_fill(neg_idx == tgt.unsqueeze(1), float("-inf"))
688
+ cand = torch.cat([pos, neg], dim=1)
689
+ infonce = F.cross_entropy(cand, torch.zeros(N, dtype=torch.long, device=lg.device))
690
+ return ce + INFONCE_LAMBDA * infonce, {"infonce": float(infonce.detach())}
691
+
692
+
693
+ def loss_contrastive_equilibrium(logits, targets, V, gen, penultimate=None):
694
+ ce = _ce(logits, targets, V)
695
+ lg = logits.transpose(1, 2).reshape(-1, V); tgt = targets.reshape(-1)
696
+ logp = F.log_softmax(lg, dim=1)
697
+ e_pos = -logp.gather(1, tgt.unsqueeze(1)).mean()
698
+ with torch.no_grad():
699
+ samp = torch.multinomial(logp.exp(), 1, generator=gen).squeeze(1)
700
+ e_neg = -logp.gather(1, samp.unsqueeze(1)).mean()
701
+ eq = F.relu(e_pos - e_neg + EQ_MARGIN)
702
+ return ce + EQ_LAMBDA * eq, {"e_pos": float(e_pos.detach()),
703
+ "e_neg": float(e_neg.detach()), "eq": float(eq.detach())}
704
+
705
+
706
+ # ════════════════════════════════════════════════════════════════════════════
707
+ # ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ NEW OBJECTIVES (H_1640) ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▛
708
+ # Three NEW compositional TRAINING-OBJECTIVE loss functions added to CE. Each is a
709
+ # training-side pressure that reshapes the gradient the TRUNK receives (not a readout
710
+ # op). Verdict later = engine-native `anima-python evaluate` on the frozen G1 bar.
711
+ # ════════════════════════════════════════════════════════════════════════════
712
+
713
+ # ── LEVER 1: predictive_info — multi-step predictive-coding aux ───────────────
714
+ class PredictiveInfoObjective(nn.Module):
715
+ """Reward the trunk penultimate h_t for carrying predictive info about FUTURE
716
+ tokens k=2,3,4 steps ahead, not just the immediate next token (which CE already
717
+ covers). One linear head per horizon maps h_t -> V-way logits over token y_{t+j}.
718
+
719
+ aux = mean_j CE( head_j(h[:, :, :T-j]) , y[:, j:] ) (j in HORIZONS-1)
720
+
721
+ (y is already next-token, so y[:,0] = token after x_0; predicting y[:,j:] from
722
+ h[:, :, :T-j] means predicting the token j positions further ahead.) This is the
723
+ predictive-information bottleneck / CPC objective (Bialek-Tishby predictive
724
+ information; van den Oord CPC 1807.03748; Rao&Ballard predictive coding): to
725
+ minimize it the trunk must compress the context into a code that stays predictive
726
+ multiple steps out, which requires COMPOSING context factors rather than memorizing
727
+ the 1-step marginal. Heads are DROPPED at serialize (engine reads only the additive
728
+ 1-step readout)."""
729
+
730
+ def __init__(self, d, V, horizons=PREDINFO_HORIZONS, lam=PREDINFO_LAMBDA):
731
+ super().__init__()
732
+ self.horizons = tuple(int(j) - 1 for j in horizons) # steps BEYOND next-token
733
+ self.lam = lam; self.V = V
734
+ self.heads = nn.ModuleList(nn.Conv1d(d, V, 1) for _ in self.horizons)
735
+
736
+ def forward(self, logits, targets, V, gen, penultimate=None):
737
+ ce = _ce(logits, targets, V)
738
+ if penultimate is None:
739
+ return ce, {}
740
+ h = penultimate # (B, d, T)
741
+ T = h.shape[-1]
742
+ terms = []
743
+ for hd, j in zip(self.heads, self.horizons):
744
+ if j <= 0 or T - j < 1:
745
+ continue
746
+ pl = hd(h[:, :, :T - j]) # (B, V, T-j)
747
+ pt = targets[:, j:] # (B, T-j) token j-ahead
748
+ terms.append(F.cross_entropy(
749
+ pl.transpose(1, 2).reshape(-1, V), pt.reshape(-1)))
750
+ if not terms:
751
+ return ce, {}
752
+ aux = torch.stack(terms).mean()
753
+ return ce + self.lam * aux, {"predinfo": float(aux.detach()),
754
+ "predinfo_h": len(terms)}
755
+
756
+
757
+ # ── LEVER 2: constructive_bind — HRR trained-bind reconstruction aux ──────────
758
+ def _circ_conv(a, b):
759
+ """Circular convolution (HRR binding) along the last dim via FFT. Real inputs."""
760
+ fa = torch.fft.rfft(a, dim=-1); fb = torch.fft.rfft(b, dim=-1)
761
+ return torch.fft.irfft(fa * fb, n=a.shape[-1], dim=-1)
762
+
763
+
764
+ def _circ_corr(c, a):
765
+ """Circular correlation (HRR UNbinding): recover b from c=a⊛b given a.
766
+ unbind(c, a) = c ⊛ involution(a); in Fourier: irfft( conj(fft a) * fft c )."""
767
+ fc = torch.fft.rfft(c, dim=-1); fa = torch.fft.rfft(a, dim=-1)
768
+ return torch.fft.irfft(torch.conj(fa) * fc, n=c.shape[-1], dim=-1)
769
+
770
+
771
+ class ConstructiveBindObjective(nn.Module):
772
+ """TRAINED CONSTRUCTIVE BIND on the trunk penultimate (the untried framebreak piece).
773
+
774
+ From each penultimate vector h_t, two learned linear projections extract a ROLE r_t
775
+ and a FILLER f_t (dim m). They are BOUND by circular convolution c_t = r_t ⊛ f_t
776
+ (Plate 1995 Holographic Reduced Representations / Smolensky 1990 Tensor-Product
777
+ Representations — VSA binding). Two constraints sculpt a COMPOSITIONAL code:
778
+
779
+ (1) UNBIND-RECOVERS-FILLER: unbind(c_t, r_t) ≈ f_t → 1 - cos(f_hat, f_t).
780
+ Forces the bound composite to actually SUPPORT clean decomposition (a real
781
+ bind, not an additive blur — the exact property a pure additive readout lacks).
782
+ (2) COMPOSITE-PREDICTS-NEXT: a linear decoder maps the bound c_t to next-token
783
+ logits, CE against y_t. Forces the binding to carry TASK signal, so gradient
784
+ sculpts task-relevant compositional factors into the trunk.
785
+
786
+ aux = CBIND_UNBIND_W * (1 - cos(unbind(r⊛f, r), f)).mean()
787
+ + CBIND_PRED_W * CE(dec(r⊛f), y)
788
+
789
+ All of {Wr, Wf, dec} live OUTSIDE model.state_dict → DROPPED at serialize; the
790
+ engine reads only the standard additive readout. Gradient flows into the trunk
791
+ through h_t, so the trunk is pushed toward a bind-decomposable representation."""
792
+
793
+ def __init__(self, d, V, m=CBIND_DIM, lam=CBIND_LAMBDA):
794
+ super().__init__()
795
+ self.m = m; self.lam = lam; self.V = V
796
+ self.role = nn.Conv1d(d, m, 1)
797
+ self.fill = nn.Conv1d(d, m, 1)
798
+ self.dec = nn.Conv1d(m, V, 1) # bound composite -> next-token logits
799
+
800
+ def forward(self, logits, targets, V, gen, penultimate=None):
801
+ ce = _ce(logits, targets, V)
802
+ if penultimate is None:
803
+ return ce, {}
804
+ h = penultimate # (B, d, T)
805
+ r = self.role(h).transpose(1, 2) # (B, T, m)
806
+ f = self.fill(h).transpose(1, 2) # (B, T, m)
807
+ c = _circ_conv(r, f) # (B, T, m) bound composite
808
+ # (1) unbind must recover the filler
809
+ f_hat = _circ_corr(c, r) # (B, T, m)
810
+ unbind = (1.0 - F.cosine_similarity(f_hat, f, dim=-1)).mean()
811
+ # (2) bound composite must predict the next token
812
+ dec_logits = self.dec(c.transpose(1, 2)) # (B, V, T)
813
+ pred = F.cross_entropy(
814
+ dec_logits.transpose(1, 2).reshape(-1, V), targets.reshape(-1))
815
+ aux = CBIND_UNBIND_W * unbind + CBIND_PRED_W * pred
816
+ return ce + self.lam * aux, {"cbind_unbind": float(unbind.detach()),
817
+ "cbind_pred": float(pred.detach()),
818
+ "cbind": float(aux.detach())}
819
+
820
+
821
+ # ── LEVER 3: composed_nce — composed-negative (wrong-composition) InfoNCE ─────
822
+ def loss_composed_nce(logits, targets, V, gen, penultimate=None):
823
+ """InfoNCE whose negatives are the SAME bag of tokens present in the window but at
824
+ the WRONG position (targets permuted WITHIN each sequence) = same-concept-set /
825
+ wrong-composition. Contrasting the true token->position assignment against these
826
+ hard negatives directly rewards getting the COMPOSITION right, not merely the
827
+ concept set (plain infonce uses RANDOM vocab negatives = membership-only).
828
+
829
+ pos_n = logit[n, y_t] (right token here)
830
+ neg_n,p = logit[n, y_perm_p(t)] for p=1..CNCE_PERMS (a within-window token
831
+ assigned to the wrong slot)
832
+ L = CE( [pos, neg...] , 0 ) softmax over the assignment
833
+
834
+ Operates on the readout logits — no aux params; gradient flows readout->trunk. A
835
+ permuted token that coincides with the true target is masked to -inf (not a negative)."""
836
+ ce = _ce(logits, targets, V)
837
+ B, Vv, T = logits.shape
838
+ lg = logits.transpose(1, 2).reshape(-1, Vv) # (B*T, V)
839
+ tgt = targets.reshape(-1) # (B*T,)
840
+ N = tgt.shape[0]
841
+ pos = lg.gather(1, tgt.unsqueeze(1)) # (N,1)
842
+ negs = []
843
+ for _ in range(CNCE_PERMS):
844
+ # independent within-sequence permutation of the target order per batch row
845
+ perm = torch.stack([torch.randperm(T, generator=gen, device=logits.device)
846
+ for _ in range(B)]) # (B, T)
847
+ y_perm = targets.gather(1, perm).reshape(-1) # (N,) same bag, wrong slots
848
+ s = lg.gather(1, y_perm.unsqueeze(1)) # (N,1)
849
+ s = s.masked_fill((y_perm == tgt).unsqueeze(1), float("-inf"))
850
+ negs.append(s)
851
+ cand = torch.cat([pos] + negs, dim=1) # (N, 1+CNCE_PERMS)
852
+ cnce = F.cross_entropy(cand, torch.zeros(N, dtype=torch.long, device=lg.device))
853
+ return ce + CNCE_LAMBDA * cnce, {"composed_nce": float(cnce.detach())}
854
+
855
+
856
+ # Objective registry. Value = a BUILDER(d, V, device) so the two aux-head objectives can
857
+ # allocate learnable params; the plain-function ones ignore the args and return the fn.
858
+ # `needs_penultimate` marks which objectives consume the trunk penultimate site.
859
+ OBJECTIVE_BUILDERS = {
860
+ "ce_marginal": lambda d, V, dev: loss_ce_marginal,
861
+ "infonce": lambda d, V, dev: loss_infonce,
862
+ "contrastive_equilibrium": lambda d, V, dev: loss_contrastive_equilibrium,
863
+ "predictive_info": lambda d, V, dev: PredictiveInfoObjective(d, V).to(dev),
864
+ "constructive_bind": lambda d, V, dev: ConstructiveBindObjective(d, V).to(dev),
865
+ "composed_nce": lambda d, V, dev: loss_composed_nce,
866
+ }
867
+ OBJ_NEEDS_PENULTIMATE = {"predictive_info", "constructive_bind"}
868
+ OBJECTIVES = OBJECTIVE_BUILDERS # back-compat alias for --objective choices list
869
+
870
+ # arm -> (tlora_on, dict_aux_on, jamo_aux_on)
871
+ ARMS = {
872
+ "ctrl": (False, False, False),
873
+ "tlora": (True, False, False),
874
+ "tlora_dict": (True, True, False),
875
+ "tlora_jamo": (True, False, True),
876
+ }
877
+
878
+
879
+ # ════════════════════════════════════════════════════════════════════════════
880
+ # WARM-START (`--init`) — load a base ckpt's weights into a freshly-built model.
881
+ # The KEYSTONE for continue-training / warm-FT (e.g. ρ·form🟢 (former G0) h1129 trunk → ρ·weave (former G1) lever
882
+ # test, per memory g1-fromscratch-blocked-by-g0-undertrain). Symmetric with
883
+ # serialize: ByteGPT `.bin` is read by core/serialize.deserialize_bytegpt (the
884
+ # byte-inverse of serialize()); `.pt` is a plain torch state_dict. H_247 lesson —
885
+ # a silent shape mismatch can floor +2.5 nats, so every path shape-guards HARD.
886
+ # ════════════════════════════════════════════════════════════════════════════
887
+ def _warm_start(model, init_path, is_bytegpt, expect_cfg):
888
+ """Load weights from `init_path` into `model` in place. Returns a 1-line report str.
889
+
890
+ expect_cfg = {vocab,d,n_layer,n_head,block} of the freshly-built model (bytegpt) or
891
+ {d,L} (clm). Raises ValueError on any dim/layer mismatch (H_247 hard guard)."""
892
+ low = str(init_path).lower()
893
+ if low.endswith(".clm"):
894
+ raise ValueError(
895
+ f"--init {init_path}: a quantized `.clm` (int4) is NOT a warm-start source — "
896
+ f"dequant→torch-state_dict remap is a follow-on. Warm-start from the pre-serialize "
897
+ f"`.pt` (full precision) instead (H_247: quant noise as warm-init can floor CE).")
898
+
899
+ if low.endswith(".bin"):
900
+ if not is_bytegpt:
901
+ raise ValueError(f"--init {init_path}: a `.bin` is a ByteGPT engine ckpt but "
902
+ f"--arch=clm; use a CLM `.pt`, or --arch bytegpt.")
903
+ sd, cfg = S.deserialize_bytegpt(init_path)
904
+ # HARD shape guard (H_247): every header field must match the built model.
905
+ for k in ("vocab", "d", "n_layer", "n_head", "block"):
906
+ if int(cfg[k]) != int(expect_cfg[k]):
907
+ raise ValueError(
908
+ f"--init {init_path}: ByteGPT header {k}={cfg[k]} != built model {k}="
909
+ f"{expect_cfg[k]}. Match --d/--L/--seq-len (H_247: warm-init mismatch floors CE).")
910
+ missing, unexpected = model.load_state_dict(sd, strict=False)
911
+ # tied head → tok/head share storage; only benign missing/unexpected tolerated.
912
+ bad_missing = [k for k in missing if k not in ("head.weight",)]
913
+ if bad_missing or unexpected:
914
+ raise ValueError(f"--init {init_path}: state_dict key mismatch "
915
+ f"missing={list(missing)} unexpected={list(unexpected)}")
916
+ return (f"warm-start ✓ ByteGPT .bin loaded ({cfg['n_layer']}L d={cfg['d']} "
917
+ f"block={cfg['block']}) missing={list(missing)} unexpected={list(unexpected)}")
918
+
919
+ if low.endswith(".pt") or low.endswith(".pth"):
920
+ ck = torch.load(init_path, map_location="cpu", weights_only=False)
921
+ sd = ck.get("model", ck) if isinstance(ck, dict) else ck
922
+ model_sd = model.state_dict()
923
+ # per-key HARD shape guard (H_247) — reject any shape-mismatched overlap.
924
+ loadable, shape_bad = {}, []
925
+ for k, v in sd.items():
926
+ if k in model_sd:
927
+ if tuple(model_sd[k].shape) == tuple(v.shape):
928
+ loadable[k] = v
929
+ else:
930
+ shape_bad.append(f"{k}:{tuple(v.shape)}!={tuple(model_sd[k].shape)}")
931
+ if shape_bad:
932
+ raise ValueError(f"--init {init_path}: shape mismatch on {shape_bad} "
933
+ f"(H_247: warm-init mismatch floors CE — match --d/--L/--arch).")
934
+ if not loadable:
935
+ raise ValueError(f"--init {init_path}: 0 keys overlap the built model — "
936
+ f"wrong arch/config? (ckpt keys e.g. {list(sd)[:4]})")
937
+ missing, unexpected = model.load_state_dict(loadable, strict=False)
938
+ return (f"warm-start ✓ .pt loaded {len(loadable)}/{len(model_sd)} keys "
939
+ f"(untouched={len(missing)} extra-in-ckpt={len(sd) - len(loadable)})")
940
+
941
+ raise ValueError(f"--init {init_path}: unknown extension — expected .bin (ByteGPT engine) "
942
+ f"or .pt/.pth (torch state_dict).")
943
+
944
+
945
+ # ════════════════════════════════════════════════════════════════════════════
946
+ # §4 TrainShell — composite train module (DDP wrap target).
947
+ # Wraps (model, objfn-if-Module, jamo_head) so DistributedDataParallel's reducer
948
+ # covers the aux-head params (predinfo/cbind heads + jamo head live OUTSIDE model —
949
+ # wrapping only `model` would silently NEVER allreduce their grads, §10.1). forward()
950
+ # holds the VERBATIM per-step loss-composition block (both the bf16-autocast and fp32
951
+ # variants) relocated from the train loop, incl. trunk_penultimate as a method reading
952
+ # self.model. N==1: the shell is called UNWRAPPED — shell(x, y, …) is the same graph +
953
+ # op order + RNG-draw order as the pre-refactor inline block, so the .clm is byte-
954
+ # identical (the refactor regression gate 9.1). The shell has NO own params/buffers, so
955
+ # constructing it consumes no RNG.
956
+ # ════════════════════════════════════════════════════════════════════════════
957
+ class TrainShell(nn.Module):
958
+ def __init__(self, model, objfn, jamo_head, *, is_bytegpt, V,
959
+ obj_needs_pen, dict_on, jamo_on, bf16, device):
960
+ super().__init__()
961
+ self.model = model
962
+ # objfn is EITHER an nn.Module (predictive_info/constructive_bind aux heads) or a
963
+ # plain function (ce_marginal/infonce/…). Register the module form as a submodule so
964
+ # its params enter the DDP bucket set; keep the function form as a bare attribute.
965
+ self.objfn = objfn if isinstance(objfn, nn.Module) else None
966
+ self._objfn_fn = None if isinstance(objfn, nn.Module) else objfn
967
+ self.jamo_head = jamo_head # None when jamo off
968
+ self.is_bytegpt = is_bytegpt
969
+ self.V = V
970
+ self.obj_needs_pen = obj_needs_pen
971
+ self.dict_on = dict_on
972
+ self.jamo_on = jamo_on
973
+ self.need_pen = obj_needs_pen or dict_on or jamo_on
974
+ self.bf16 = bf16
975
+ self.device = device
976
+
977
+ def _objfn(self):
978
+ return self.objfn if self.objfn is not None else self._objfn_fn
979
+
980
+ def trunk_penultimate(self, x):
981
+ # VERBATIM relocation of the former module-level trunk_penultimate closure
982
+ # (reads self.model). ByteGPT exposes its pre-head hidden directly; CLM recomputes
983
+ # the trunk to the pre-readout MoE/norm_out site (note: pre-SLW, as before).
984
+ m = self.model
985
+ if self.is_bytegpt:
986
+ return m(x)["penultimate"] # (B, d, T) — ln_f(x) pre-head
987
+ h = m.embed(x).transpose(1, 2)
988
+ h = m.embed_conv(h)
989
+ for layer in m.trunk:
990
+ h = layer(h)
991
+ hm, _ = m.moe(h)
992
+ hm = m.norm_out(hm)
993
+ return hm # (B, d, T) — pre-readout dictionary site
994
+
995
+ def forward(self, x, y, obj_gen, dict_lambda, jamo_lambda):
996
+ # ── VERBATIM relocation of the per-step loss-composition block (bf16 + fp32). The
997
+ # autocast context stays wrapping ONLY the forward/compose (backward is at the
998
+ # callsite, outside autocast — DDP hooks fire there). Returns (loss, detached CE,
999
+ # aux) so the callsite can backward + all-reduce the shard CE (§3).
1000
+ model = self.model
1001
+ objfn = self._objfn()
1002
+ V = self.V
1003
+ aux = {}
1004
+ need_pen = self.need_pen
1005
+ if self.bf16 and self.device.startswith("cuda"):
1006
+ with torch.autocast("cuda", dtype=torch.bfloat16):
1007
+ out = model(x, y)
1008
+ h = self.trunk_penultimate(x) if need_pen else None
1009
+ pen = h.float() if (h is not None and self.obj_needs_pen) else None
1010
+ obj_loss, oaux = objfn(out["logits"].float(), y, V, obj_gen, penultimate=pen)
1011
+ loss = obj_loss + out["aux_loss"]
1012
+ if self.dict_on:
1013
+ dloss = dict_lambda * h.abs().mean()
1014
+ loss = loss + dloss; aux["dict_l1"] = float(dloss.detach())
1015
+ if self.jamo_on:
1016
+ jl = self.jamo_head(h.float())
1017
+ jt = jamo_targets(y, self.jamo_head.n_jamo)
1018
+ jloss = jamo_lambda * F.cross_entropy(
1019
+ jl.transpose(1, 2).reshape(-1, self.jamo_head.n_jamo),
1020
+ jt.reshape(-1), ignore_index=0)
1021
+ loss = loss + jloss; aux["jamo"] = float(jloss.detach())
1022
+ else:
1023
+ out = model(x, y)
1024
+ h = self.trunk_penultimate(x) if need_pen else None
1025
+ pen = h if self.obj_needs_pen else None
1026
+ obj_loss, oaux = objfn(out["logits"], y, V, obj_gen, penultimate=pen)
1027
+ loss = obj_loss + out["aux_loss"]
1028
+ if self.dict_on:
1029
+ dloss = dict_lambda * h.abs().mean()
1030
+ loss = loss + dloss; aux["dict_l1"] = float(dloss.detach())
1031
+ if self.jamo_on:
1032
+ jl = self.jamo_head(h)
1033
+ jt = jamo_targets(y, self.jamo_head.n_jamo)
1034
+ jloss = jamo_lambda * F.cross_entropy(
1035
+ jl.transpose(1, 2).reshape(-1, self.jamo_head.n_jamo),
1036
+ jt.reshape(-1), ignore_index=0)
1037
+ loss = loss + jloss; aux["jamo"] = float(jloss.detach())
1038
+ aux.update(oaux)
1039
+ return loss, out["ce_loss"].detach(), aux
1040
+
1041
+
1042
+ def main():
1043
+ ap = argparse.ArgumentParser(
1044
+ description="anima canonical python trainer (`anima-python train`) — CLMConvMoE "
1045
+ "SAVANT+MITOSIS recipe + H_1640 arm×objective compositional levers")
1046
+ ap.add_argument("--arch", default="clm", choices=["clm", "bytegpt"],
1047
+ help="trunk architecture: clm=CLMConvMoE (default, .clm out) | "
1048
+ "bytegpt=24-layer GPT-2-class ByteGPT (.bin out) — the CLEAN G1 "
1049
+ "wall (single=2). The arm×objective compositional levers are "
1050
+ "arch-agnostic (operate on logits+penultimate); the CLM-specific "
1051
+ "levers (savant/mitosis/tlora/dict/jamo) are gated OFF for bytegpt.")
1052
+ ap.add_argument("--arm", default="ctrl", choices=list(ARMS))
1053
+ ap.add_argument("--objective", default="ce_marginal", choices=list(OBJECTIVES),
1054
+ help="OPTIONAL objrun coupling (default ce_marginal = standalone)")
1055
+ ap.add_argument("--tlora-rank", type=int, default=TLORA_RANK)
1056
+ ap.add_argument("--tlora-no-base", action="store_true", help="drop the dense base")
1057
+ ap.add_argument("--dict-lambda", type=float, default=DICT_LAMBDA)
1058
+ ap.add_argument("--jamo-lambda", type=float, default=JAMO_LAMBDA)
1059
+ # H_9200 E1 — gated-write forward-slot (SLW). --slw engages the CORE-owned
1060
+ # (core/slw.py) module on the CLMConvMoE penultimate; weights serialize into the
1061
+ # "SLW\x01" .clm trailer. Plain CE alone induces the slots (rung-3 de-risk 0.976
1062
+ # vs additive 0.145), so this is an ARCH lever (--objective stays ce_marginal).
1063
+ ap.add_argument("--slw", action="store_true",
1064
+ help="H_9200 E1: engage the gated-write forward-slot (core/slw.py)")
1065
+ ap.add_argument("--slw-n-slot", type=int, default=8, help="SLW addressable slots")
1066
+ ap.add_argument("--slw-k", type=int, default=64, help="SLW role/read key dim")
1067
+ ap.add_argument("--seed", type=int, default=7)
1068
+ ap.add_argument("--corpus", nargs="*", default=[])
1069
+ ap.add_argument("--cell-label", nargs="*", default=[])
1070
+ ap.add_argument("--canon", action="store_true")
1071
+ ap.add_argument("--d", type=int, default=0)
1072
+ ap.add_argument("--L", type=int, default=0)
1073
+ ap.add_argument("--steps", type=int, default=0)
1074
+ ap.add_argument("--seq-len", type=int, default=0)
1075
+ ap.add_argument("--batch-size", type=int, default=8)
1076
+ ap.add_argument("--lr", type=float, default=3e-4)
1077
+ ap.add_argument("--e0", type=int, default=2)
1078
+ ap.add_argument("--emax", type=int, default=3)
1079
+ ap.add_argument("--no-savant", action="store_true")
1080
+ ap.add_argument("--no-mitosis", action="store_true")
1081
+ ap.add_argument("--wd-floor", type=float, default=-1.0,
1082
+ help="N6 sweep: override savant wd floor (>=0 forces constant wd)")
1083
+ ap.add_argument("--dropout-floor", type=float, default=-1.0,
1084
+ help="N6 sweep: override savant dropout floor (>=0 forces constant dp)")
1085
+ ap.add_argument("--bf16", action="store_true")
1086
+ ap.add_argument("--sample", choices=["roundrobin", "proportional"], default="proportional")
1087
+ ap.add_argument("--val-frac", type=float, default=0.05)
1088
+ ap.add_argument("--val-every", type=int, default=200)
1089
+ ap.add_argument("--val-batches", type=int, default=4)
1090
+ ap.add_argument("--log-every", type=int, default=50)
1091
+ ap.add_argument("--dbes-every", type=int, default=0, help="0=final only; N=also every N steps")
1092
+ ap.add_argument("--ckpt-every", type=int, default=0,
1093
+ help="0=final .clm only; N=also dump <out>.step<N>.clm every N steps "
1094
+ "(step-window multiplex — 1 run yields 2000/4000/… checkpoints, train-py-4 isolation)")
1095
+ ap.add_argument("--out", default="")
1096
+ ap.add_argument("--ckpt-out", default="")
1097
+ ap.add_argument("--gauges-out", default="")
1098
+ ap.add_argument("--init", default="",
1099
+ help="warm-start ckpt path — load weights into the freshly-built model "
1100
+ "BEFORE training. ByteGPT: an engine `.bin` (5×u32 header) or a `.pt`; "
1101
+ "CLM: a `.pt` torch state_dict. Dim/layer mismatch → hard error "
1102
+ "(H_247: a warm-init shape mismatch can floor +2.5 nats). Quantized "
1103
+ "`.clm` is refused (dequant→state_dict remap is a follow-on; warm-start "
1104
+ "from the pre-serialize `.pt`).")
1105
+ # ── §7 multi-GPU data-parallel (DDP) launch surface ──────────────────────────
1106
+ ap.add_argument("--gpus", default="",
1107
+ help="multi-GPU DATA-PARALLEL (DDP) device ids, e.g. '0,1,2,3' (mirrors "
1108
+ "`anima sweep --gpus`). >1 id => self-re-exec under torchrun, one "
1109
+ "process/GPU. GLOBAL batch is PRESERVED = --batch-size (per-rank "
1110
+ "B/N); LR/schedule/corpus-mix/val-stream/serialize-format UNCHANGED "
1111
+ "vs the 1-GPU recipe (DDP is an execution strategy, not a recipe "
1112
+ "change). --batch-size MUST be divisible by N (hard error otherwise "
1113
+ "— no silent effective-batch change). 0 or 1 id => single-GPU path, "
1114
+ "byte-identical to today (every DDP branch skipped).")
1115
+ ap.add_argument("--ddp-verify-sync", action="store_true",
1116
+ help="DDP debug: every --val-every steps all-reduce a param-checksum and "
1117
+ "assert cross-rank agreement (catches a mitosis/optimizer desync at "
1118
+ "the split). Off by default (costs one collective per val).")
1119
+ ap.add_argument("--ddp-find-unused", action="store_true",
1120
+ help="DDP debug/escape-hatch: pass find_unused_parameters=True to DDP. Off "
1121
+ "by default — the current objective set fires every head every step "
1122
+ "(§4). Flip ON only if a FUTURE per-step-gated head makes DDP error on "
1123
+ "an unused param.")
1124
+ a = ap.parse_args()
1125
+
1126
+ # ══ §7 DDP launch: torchrun self-re-exec + worker init + N==1 short-circuit ══
1127
+ # Runs FIRST (before any CUDA allocation). os.execvpe replaces the process, so the
1128
+ # launcher's `> rf 2>&1` redirect is inherited and only rank 0 prints via p0().
1129
+ gpu_ids = [g for g in a.gpus.split(",") if g.strip() != ""]
1130
+ under_torchrun = "RANK" in os.environ
1131
+ # (1) RE-EXEC branch — >1 GPU requested and not yet under torchrun.
1132
+ if len(gpu_ids) > 1 and not under_torchrun:
1133
+ if not torch.cuda.is_available():
1134
+ sys.exit(f"[ddp] --gpus {a.gpus} requests {len(gpu_ids)}-way DDP but CUDA is not "
1135
+ f"available on this host. Multi-GPU DDP requires CUDA (NCCL backend); a "
1136
+ f"CPU-only run must use a single device (--gpus <one> or omit).")
1137
+ if torch.cuda.device_count() < len(gpu_ids):
1138
+ sys.exit(f"[ddp] --gpus {a.gpus} requests {len(gpu_ids)} devices but only "
1139
+ f"{torch.cuda.device_count()} CUDA device(s) present.")
1140
+ if a.batch_size % len(gpu_ids) != 0:
1141
+ sys.exit(f"[ddp] --batch-size {a.batch_size} is not divisible by --gpus N="
1142
+ f"{len(gpu_ids)} (global batch is preserved as per-rank B/N; adjust "
1143
+ f"--batch-size or --gpus). Refusing to pad — silent effective-batch "
1144
+ f"change would corrupt the frozen-recipe comparison.")
1145
+ _env = dict(os.environ)
1146
+ _env["CUDA_VISIBLE_DEVICES"] = ",".join(gpu_ids)
1147
+ os.execvpe("torchrun",
1148
+ ["torchrun", "--standalone", f"--nproc_per_node={len(gpu_ids)}",
1149
+ os.path.abspath(__file__), *sys.argv[1:]], _env)
1150
+ # (3)/(4) WORKER init vs N==1 short-circuit.
1151
+ ddp_on = False
1152
+ rank, world, local_rank = 0, 1, 0
1153
+ if under_torchrun and int(os.environ.get("WORLD_SIZE", "1")) > 1:
1154
+ local_rank = int(os.environ["LOCAL_RANK"])
1155
+ rank = int(os.environ["RANK"])
1156
+ world = int(os.environ["WORLD_SIZE"])
1157
+ if a.batch_size % world != 0:
1158
+ sys.exit(f"[ddp] --batch-size {a.batch_size} not divisible by world_size {world}.")
1159
+ torch.cuda.set_device(local_rank)
1160
+ dist.init_process_group("nccl")
1161
+ ddp_on = True
1162
+
1163
+ def p0(*args, **kwargs):
1164
+ """rank-0 print gate — every info/log print routes through this so an N-GPU run
1165
+ does not N-duplicate stdout (§7.3). N==1: rank==0, so p0 == print."""
1166
+ if rank == 0:
1167
+ print(*args, **kwargs)
1168
+
1169
+ is_bytegpt = (a.arch == "bytegpt")
1170
+ tlora_on, dict_on, jamo_on = ARMS[a.arm]
1171
+ savant_on = not a.no_savant
1172
+ mitosis_on = not a.no_mitosis
1173
+ # ── ByteGPT: the CLM-specific levers (savant/mitosis/tlora/dict/jamo) are gated OFF.
1174
+ # ByteGPT is a plain transformer (no MoE experts to split, no ConvExpert weight to
1175
+ # TLoRA-reparameterize); the ρ·weave (former G1) lever test it enables is arm=ctrl × the objective
1176
+ # matrix (the arch-agnostic trunk-objective losses). Only n_head is bytegpt-only.
1177
+ bg_n_head = 0
1178
+ if is_bytegpt:
1179
+ if a.arm != "ctrl":
1180
+ p0(f" [bytegpt] arm={a.arm} is CLM-specific → forcing arm=ctrl "
1181
+ f"(tlora/dict/jamo are ConvMoE-only)", flush=True)
1182
+ tlora_on = dict_on = jamo_on = False
1183
+ if savant_on or mitosis_on:
1184
+ p0(" [bytegpt] savant/mitosis are CLM-MoE-specific → gated OFF for bytegpt",
1185
+ flush=True)
1186
+ savant_on = False
1187
+ mitosis_on = False
1188
+ if a.canon:
1189
+ if is_bytegpt:
1190
+ d = a.d or 768; L = a.L or 24
1191
+ seq_len = a.seq_len or 1024; steps = a.steps or 2000
1192
+ # head_dim=64 invariant: n_head = d//64 (d=768→12, d=1024→16). A hardcoded
1193
+ # 12 broke warm-FT of the production d=1024 h1129 base (1024%12≠0 → "embed_dim
1194
+ # must be divisible by num_heads"; also mismatched h1129's n_head=16 state_dict).
1195
+ bg_n_head = max(1, d // 64)
1196
+ else:
1197
+ d = a.d or 3784; L = a.L or 4
1198
+ seq_len = a.seq_len or 1024; steps = a.steps or 2000
1199
+ else:
1200
+ if is_bytegpt:
1201
+ d = a.d or 64; L = a.L or 2
1202
+ seq_len = a.seq_len or 128; steps = a.steps or 60
1203
+ bg_n_head = 2
1204
+ else:
1205
+ d = a.d or 64; L = a.L or 2
1206
+ seq_len = a.seq_len or 128; steps = a.steps or 60
1207
+ e0, emax = a.e0, a.emax
1208
+ V, K = 256, 3
1209
+ # §7 device: under DDP each rank pins its own cuda:local_rank; N==1 = today's path.
1210
+ if ddp_on:
1211
+ device = f"cuda:{local_rank}"
1212
+ else:
1213
+ device = "cuda" if torch.cuda.is_available() else "cpu"
1214
+ objfn = OBJECTIVE_BUILDERS[a.objective](d, V, device) # aux-head objectives allocate params
1215
+ obj_is_module = isinstance(objfn, nn.Module)
1216
+ obj_needs_pen = a.objective in OBJ_NEEDS_PENULTIMATE
1217
+
1218
+ p0(f"=== anima-python train (canonical) arch={a.arch} arm={a.arm} obj={a.objective} seed={a.seed} ===", flush=True)
1219
+ if ddp_on:
1220
+ # §10.2 — prove the GLOBAL batch in the run record (global batch preserved, per-rank B/N).
1221
+ p0(f" [ddp] world_size={world} global_batch={a.batch_size} per_rank_batch="
1222
+ f"{a.batch_size // world} LR/schedule/corpus/val UNCHANGED vs 1-GPU", flush=True)
1223
+ if is_bytegpt:
1224
+ p0(f" levers: bytegpt trunk (CLM-specific tlora/dict/jamo/savant/mitosis OFF) "
1225
+ f"n_head={bg_n_head} block={seq_len}", flush=True)
1226
+ else:
1227
+ p0(f" levers: tlora={tlora_on}(rank={a.tlora_rank},base={not a.tlora_no_base}) "
1228
+ f"dict_aux={dict_on}(λ={a.dict_lambda}) jamo_aux={jamo_on}(λ={a.jamo_lambda})", flush=True)
1229
+ p0(f" device={device} d={d} L={L} E0={e0} Emax={emax} seq_len={seq_len} "
1230
+ f"steps={steps} bs={a.batch_size} sample={a.sample}", flush=True)
1231
+ if str(device).startswith("cuda"):
1232
+ cap = torch.cuda.get_device_capability(local_rank)
1233
+ p0(f" cuda: {torch.cuda.get_device_name(local_rank)} cap={cap[0]}.{cap[1]} torch={torch.__version__}", flush=True)
1234
+
1235
+ torch.manual_seed(a.seed)
1236
+
1237
+ if is_bytegpt:
1238
+ # ByteGPT block = the context window; use seq_len as the positional block size so a
1239
+ # non-canon toy stays small. n_head must divide d (validated by the config).
1240
+ bg_block = seq_len
1241
+ bg_cfg = ByteGPTConfig(vocab=V, d=d, n_layer=L, n_head=bg_n_head, block=bg_block)
1242
+ model = ByteGPT(bg_cfg).to(device)
1243
+ cfg = None
1244
+ jamo_head = None
1245
+ mito = None # no MoE experts to grow
1246
+ else:
1247
+ cfg = CLMConfig(n_experts=emax, n_trunk_layers=L, d_model=d, kernel_size=K,
1248
+ variant="AB", dilation_base=2, max_dilation=512,
1249
+ slw=a.slw, slw_n_slot=a.slw_n_slot, slw_k=a.slw_k)
1250
+ model = CLMConvMoE(cfg).to(device) # production additive readout (all arms)
1251
+ if tlora_on:
1252
+ install_tlora_experts(model, a.tlora_rank, base=not a.tlora_no_base)
1253
+ model.to(device)
1254
+ jamo_head = JamoHead(d).to(device) if jamo_on else None
1255
+ n_params = sum(p.numel() for p in model.parameters())
1256
+ p0(f" params: {n_params} ({n_params/1e6:.3f}M)"
1257
+ f"{' (+jamo head)' if jamo_on else ''}", flush=True)
1258
+
1259
+ if not is_bytegpt:
1260
+ mito = MitosisMoE(model, e0, emax)
1261
+ install_router_mask(model, mito)
1262
+
1263
+ # ── warm-start (`--init`): load a base ckpt into the freshly-built model. Done AFTER
1264
+ # the full architecture is built (tlora/mitosis installed) so state_dict keys line up;
1265
+ # strict=False tolerates lever-only keys (tlora/mito) absent from a plain-trunk base.
1266
+ if a.init:
1267
+ expect_cfg = ({"vocab": V, "d": d, "n_layer": L, "n_head": bg_n_head, "block": seq_len}
1268
+ if is_bytegpt else {"d": d, "L": L})
1269
+ report = _warm_start(model, a.init, is_bytegpt, expect_cfg)
1270
+ model.to(device)
1271
+ p0(f" [--init] {report}", flush=True)
1272
+
1273
+ params = (list(model.parameters())
1274
+ + (list(jamo_head.parameters()) if jamo_head else [])
1275
+ + (list(objfn.parameters()) if obj_is_module else [])) # H_1640 aux-head params
1276
+ if obj_is_module:
1277
+ n_obj = sum(p.numel() for p in objfn.parameters())
1278
+ p0(f" objective '{a.objective}' aux params: {n_obj} "
1279
+ f"(DROPPED at serialize — not in model.state_dict)", flush=True)
1280
+ opt = torch.optim.AdamW(params, lr=a.lr, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.0)
1281
+
1282
+ # ══ §4 TrainShell + DDP wrap (execution strategy — the recipe is unchanged) ═════
1283
+ # core_model = the UNWRAPPED inner model, used for ALL rank-0 probes/serialize
1284
+ # (dbes/gauges/val/materialize/_write_clm) — never through the DDP wrapper (§4/§6/§10.9).
1285
+ core_model = model
1286
+ shell = TrainShell(model, objfn, jamo_head, is_bytegpt=is_bytegpt, V=V,
1287
+ obj_needs_pen=obj_needs_pen, dict_on=dict_on, jamo_on=jamo_on,
1288
+ bf16=a.bf16, device=device)
1289
+ # §10.1 defense — the shell's param set MUST equal the optimizer's (aux heads covered).
1290
+ assert {id(p) for p in shell.parameters()} == {id(p) for p in params}, \
1291
+ "TrainShell params != optimizer params — an aux head would never be allreduced."
1292
+ if ddp_on:
1293
+ # §4/§10.8 — CLMConvMoE/ByteGPT/SLW carry NO batch-stat buffers, and mito.active_mask
1294
+ # is an intentionally-unregistered per-rank tensor; assert zero buffers so a FUTURE
1295
+ # registered buffer fails loudly instead of being silently stomped (broadcast_buffers=False).
1296
+ assert len(list(shell.buffers())) == 0, \
1297
+ "TrainShell grew a buffer — broadcast_buffers=False would desync it (§4/§10.8)."
1298
+ train_module = DDP(shell, device_ids=[local_rank], broadcast_buffers=False,
1299
+ find_unused_parameters=a.ddp_find_unused)
1300
+ # §2/§4 per-rank RNG divergence AFTER construction+wrap: decorrelate dropout masks
1301
+ # (default RNG) across ranks — a SHARED default stream gives sample i the SAME dropout
1302
+ # mask on every rank, a correlation the 1-GPU run lacks. Reseed AFTER wrap so init +
1303
+ # DDP's param broadcast already made params identical (reseeding BEFORE diverges init).
1304
+ torch.manual_seed(a.seed + 100003 * (rank + 1))
1305
+ if torch.cuda.is_available():
1306
+ torch.cuda.manual_seed_all(a.seed + 100003 * (rank + 1))
1307
+ else:
1308
+ train_module = shell # N==1: call the shell UNWRAPPED (same graph, no collectives)
1309
+
1310
+ gen = torch.Generator().manual_seed(42) # §3/§10.3 SHARED across ranks (NOT rank-offset)
1311
+ val_gen = torch.Generator().manual_seed(1234)
1312
+ # §4 obj_gen: reseed per rank so negatives/permutations decorrelate across shards (rank=0
1313
+ # for N==1 ⇒ +0 ⇒ byte-identical to today). Exact 1-GPU negative reproduction is impossible
1314
+ # anyway (it draws all negatives in one call) — accepted stochastic-equivalence (DESCENT gate).
1315
+ obj_gen = torch.Generator(device=device).manual_seed(20260628 + a.seed + 7919 * rank)
1316
+
1317
+ latch = {"on": False, "at": 0}
1318
+ i0 = GZ_UPPER
1319
+ i_floor = GZ_LOWER - 0.05
1320
+ split_step = max(1, steps // 2)
1321
+
1322
+ # ── corpus cells (reuse ByteCell + resolver) ─────────────────────────────
1323
+ cells, labels = [], []
1324
+ for ci, spec in enumerate(a.corpus):
1325
+ p = resolve_corpus_path(spec)
1326
+ cells.append(ByteCell(p, val_frac=a.val_frac))
1327
+ labels.append(a.cell_label[ci] if ci < len(a.cell_label) else f"cell{ci}")
1328
+ c = cells[-1]
1329
+ p0(f" corpus cell[{ci}] {labels[ci]:<12s} {p} size={c.size} "
1330
+ f"train={c.train_end} val_tail={c.size - c.train_end}", flush=True)
1331
+ if not cells:
1332
+ p0(" corpus: NONE -> synthetic smoke", flush=True)
1333
+
1334
+ _samp_cells = [c for c in cells if c.train_end >= seq_len + 2]
1335
+ _samp_w = torch.tensor([float(c.train_end) for c in _samp_cells]) \
1336
+ if _samp_cells else torch.tensor([1.0])
1337
+
1338
+ # §3/§5 GLOBAL batch preserved: --batch-size is the GLOBAL batch; each rank materializes
1339
+ # B_local = B_global // world. world==1 ⇒ B_local == B_global (N==1 byte-identical).
1340
+ B_global = a.batch_size
1341
+ B_local = B_global // world
1342
+
1343
+ def get_batch(step):
1344
+ if cells:
1345
+ # ── §3 SPEC phase (ALL ranks, IDENTICAL shared gen=42): draw the GLOBAL batch's
1346
+ # window specs in TODAY's interleaved order (proportional: multinomial→spec;
1347
+ # roundrobin: index→spec). This global spec list is byte-identical to the
1348
+ # 1-GPU draw; the proportional cell weighting is globally exact.
1349
+ specs = []
1350
+ for b in range(B_global):
1351
+ if a.sample == "proportional" and _samp_cells:
1352
+ ci = int(torch.multinomial(_samp_w, 1, generator=gen).item())
1353
+ cell = _samp_cells[ci]
1354
+ else:
1355
+ cell = cells[(step - 1 + b) % len(cells)]
1356
+ start = cell.window_spec(seq_len, gen) # None ⇒ synthetic fallback (no randint)
1357
+ specs.append((cell, start))
1358
+ # ── §3 MATERIALIZE phase (rank-local slice): rank r takes [r*B_local, (r+1)*B_local).
1359
+ lo = rank * B_local
1360
+ xs, ys = [], []
1361
+ for cell, start in specs[lo:lo + B_local]:
1362
+ if start is None:
1363
+ base = torch.arange(seq_len)
1364
+ w = (base % V, (base + 1) % V)
1365
+ else:
1366
+ w = cell.materialize(start, seq_len)
1367
+ xs.append(w[0]); ys.append(w[1])
1368
+ return torch.stack(xs).to(device), torch.stack(ys).to(device)
1369
+ base = torch.arange(seq_len)
1370
+ x = ((3 + base * 37) % V).unsqueeze(0).repeat(B_local, 1).to(device)
1371
+ y = ((14 + base * 37) % V).unsqueeze(0).repeat(B_local, 1).to(device)
1372
+ return x, y
1373
+
1374
+ # NOTE (§4): the trunk-penultimate helper (N7 dict/jamo aux + compositional objectives)
1375
+ # was RELOCATED verbatim into TrainShell.trunk_penultimate so the per-step loss graph is
1376
+ # one DDP forward(). The rank-0 probes below (val/dbes/gauges) use `model` directly.
1377
+
1378
+ @torch.no_grad()
1379
+ def cell_val_ce(c):
1380
+ if c.size - c.train_end < seq_len + 2:
1381
+ return None
1382
+ was = model.training; model.eval()
1383
+ tot, nb = 0.0, 0
1384
+ for _ in range(a.val_batches):
1385
+ xs, ys = [], []
1386
+ for _ in range(a.batch_size):
1387
+ w = c.val_window(seq_len, val_gen)
1388
+ if w is None: continue
1389
+ xs.append(w[0]); ys.append(w[1])
1390
+ if not xs: continue
1391
+ vx = torch.stack(xs).to(device); vy = torch.stack(ys).to(device)
1392
+ if a.bf16 and device.startswith("cuda"):
1393
+ with torch.autocast("cuda", dtype=torch.bfloat16):
1394
+ vo = model(vx, vy)
1395
+ else:
1396
+ vo = model(vx, vy)
1397
+ tot += float(vo["ce_loss"].detach()); nb += 1
1398
+ if was: model.train()
1399
+ return (tot / nb) if nb else None
1400
+
1401
+ def val_per_cell():
1402
+ return {lab: v for lab, c in zip(labels, cells)
1403
+ if (v := cell_val_ce(c)) is not None}
1404
+
1405
+ # ── serialize helper (end-of-run AND intermediate --ckpt-every checkpoints) ──
1406
+ # CLM → .clm v0.3 (CLMConvMoE additive readout, materialized TLoRA experts).
1407
+ # ByteGPT → .pt (cfg+state_dict) → core/serialize.py::serialize → .bin (5×u32
1408
+ # header). The engine (generator L3 mouth-sniff) auto-dispatches .bin to the bytegpt
1409
+ # decode; `anima-python evaluate` auto-detects .bin vs .clm by header, so no eval change.
1410
+ def _write_bin(out_path):
1411
+ # write the torch .pt in the exact shape bytegpt_serialize.serialize reads, next to
1412
+ # the .bin, then bridge it. The aux-head objective params are OUTSIDE model.state_dict
1413
+ # (they live on objfn), so serialize sees only the standard ByteGPT weights.
1414
+ pt_path = out_path + ".pt" if not out_path.endswith(".pt") else out_path
1415
+ bin_path = out_path
1416
+ sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
1417
+ ck = {"model": sd, "config": model.cfg.as_dict(),
1418
+ "val_ce": (round(lossF, 5) if lossF is not None else None),
1419
+ "step": steps, "nparam": n_params}
1420
+ torch.save(ck, pt_path)
1421
+ BGS.serialize(pt_path, bin_path)
1422
+ print(f" .bin WRITTEN {os.path.getsize(bin_path)} bytes -> {bin_path} "
1423
+ f"(via {pt_path})", flush=True)
1424
+
1425
+ def _write_clm(out_path):
1426
+ if is_bytegpt:
1427
+ _write_bin(out_path)
1428
+ return
1429
+ e_ser = mito.e_active
1430
+ mat = materialize_experts_into_state(model)
1431
+ sd_active = {}
1432
+ for k, vv in mat.items():
1433
+ if k in ("moe.router.weight", "moe.router.bias"):
1434
+ sd_active[k] = vv[:e_ser].contiguous()
1435
+ elif k.startswith("moe.experts."):
1436
+ if int(k.split(".")[2]) < e_ser:
1437
+ sd_active[k] = vv
1438
+ else:
1439
+ sd_active[k] = vv
1440
+ S.serialize_v3(sd_active, n_trunk_layers=L, n_experts=e_ser, out_path=out_path)
1441
+ # H_9200 E1 — append the "SLW\x01" gated-write forward-slot trailer if engaged
1442
+ # (core/serialize.append_slw_trailer). Additive models skip it (byte-identical).
1443
+ if getattr(model, "slw", None) is not None:
1444
+ nb = S.append_slw_trailer(out_path, model.slw)
1445
+ print(f" SLW trailer appended {nb} bytes (n_slot={model.slw.n_slot} "
1446
+ f"k={model.slw.k} d_s={model.slw.d_s})", flush=True)
1447
+ print(f" .clm WRITTEN {os.path.getsize(out_path)} bytes -> {out_path}", flush=True)
1448
+ print(f" clm_decodable={VC.clm_decodable(open(out_path, 'rb').read())}", flush=True)
1449
+
1450
+ # ── train loop ───────────────────────────────────────────────────────────
1451
+ model.train()
1452
+ t0 = time.time(); loss0 = lossF = None
1453
+ last_aux = {}; dbes_log = []
1454
+ # active-expert count for logs/summary — CLM tracks it on the mitosis controller;
1455
+ # ByteGPT has no experts (mito is None) so it is a fixed 1.
1456
+ def e_now():
1457
+ return mito.e_active if mito is not None else 1
1458
+ # intermediate-ckpt extension: bytegpt writes .bin, clm writes .clm.
1459
+ _ck_ext = ".bin" if is_bytegpt else ".clm"
1460
+ for step in range(1, steps + 1):
1461
+ # --ckpt-every: dump an intermediate ckpt of the state AFTER (step-1) updates
1462
+ # (step-window multiplex — one run yields 2000/4000/… checkpoints, no re-train).
1463
+ # §6 rank-0-only intermediate serialize on the UNWRAPPED core_model; barrier AFTER the
1464
+ # write (nothing to protect before) so non-zero ranks don't race into the next step's
1465
+ # collective while rank 0 serializes a 303M .clm.
1466
+ if a.ckpt_every > 0 and a.out and step > 1 and (step - 1) % a.ckpt_every == 0:
1467
+ if rank == 0:
1468
+ _write_clm(f"{a.out}.step{step - 1}{_ck_ext}")
1469
+ model.train()
1470
+ if ddp_on:
1471
+ dist.barrier()
1472
+ if savant_on:
1473
+ inh = savant_inhibition(step, steps, i0, i_floor, latch)
1474
+ wd = inhibition_to_wd(inh); dp = inhibition_to_dropout(inh)
1475
+ else:
1476
+ wd, dp = 0.0, 0.0
1477
+ if a.wd_floor >= 0.0: wd = a.wd_floor # N6 sweep override
1478
+ if a.dropout_floor >= 0.0: dp = a.dropout_floor # N6 sweep override
1479
+ for grp in opt.param_groups:
1480
+ grp["weight_decay"] = wd
1481
+ for m in model.modules():
1482
+ if isinstance(m, nn.Dropout):
1483
+ m.p = dp
1484
+ if mitosis_on and step == split_step and mito.e_active < emax:
1485
+ prev = mito.e_active
1486
+ new_e = (tlora_aware_split(mito, 0, opt) if tlora_on
1487
+ else mito.split(0, opt))
1488
+ p0(f" step {step} (MITOSIS SPLIT) E {prev}->{new_e}", flush=True)
1489
+ # §1 belt-and-braces: the split is provably deterministic across ranks (bitwise-
1490
+ # identical params ⇒ identical children + mask flip; NO change to split() itself),
1491
+ # but broadcast the touched tensors from rank 0 + MAX-assert e_active so ANY future
1492
+ # nondeterminism in split() is caught HERE, not as a silent mid-run divergence.
1493
+ if ddp_on:
1494
+ moe = core_model.moe
1495
+ child = new_e - 1
1496
+ touched = (list(moe.experts[0].parameters())
1497
+ + list(moe.experts[child].parameters())
1498
+ + [moe.router.weight, moe.router.bias])
1499
+ for t in touched:
1500
+ dist.broadcast(t.data, src=0)
1501
+ ea = torch.tensor([float(mito.e_active)], device=device)
1502
+ mx = ea.clone(); dist.all_reduce(mx, op=dist.ReduceOp.MAX)
1503
+ assert int(mx.item()) == mito.e_active, \
1504
+ f"[ddp] mitosis e_active desync: local {mito.e_active} != max {int(mx.item())}"
1505
+ x, y = get_batch(step)
1506
+ opt.zero_grad(set_to_none=True)
1507
+ # §4 one composite DDP forward() → (loss, detached shard-CE, aux). Backward runs at the
1508
+ # callsite OUTSIDE the shell's internal autocast; DDP's grad hooks fire here and
1509
+ # allreduce every param's grad (aux heads included — §10.1). clip_grad_norm_ runs AFTER
1510
+ # on the already-averaged grads, so every rank's clip scale = the 1-GPU global-grad norm.
1511
+ loss, ce_local, aux = train_module(x, y, obj_gen, a.dict_lambda, a.jamo_lambda)
1512
+ loss.backward()
1513
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
1514
+ opt.step()
1515
+ # §3 CE all-reduce (AVG): per-rank ce_local is a shard statistic; average it so
1516
+ # loss0/lossF/logs equal the 1-GPU GLOBAL-batch CE (equal shard sizes ⇒ mean-of-means
1517
+ # = global mean). N==1: no collective — ce = the shard CE = the global CE.
1518
+ if ddp_on:
1519
+ ct = ce_local.clone()
1520
+ dist.all_reduce(ct, op=dist.ReduceOp.SUM); ct /= world
1521
+ ce = float(ct)
1522
+ else:
1523
+ ce = float(ce_local)
1524
+ last_aux = aux
1525
+ if loss0 is None: loss0 = ce
1526
+ lossF = ce
1527
+ do_val = a.val_every > 0 and (step == 1 or step % a.val_every == 0 or step == steps)
1528
+ # §1/§7 --ddp-verify-sync: at every val cadence, all-reduce a param-checksum (ALL ranks
1529
+ # participate — a collective) and assert cross-rank agreement, catching a split/optimizer
1530
+ # desync (especially the first val after split_step).
1531
+ if ddp_on and a.ddp_verify_sync and do_val:
1532
+ with torch.no_grad():
1533
+ cs = torch.zeros(1, device=device)
1534
+ for p in params:
1535
+ cs += p.detach().float().sum()
1536
+ csx = cs.clone(); csn = cs.clone()
1537
+ dist.all_reduce(csx, op=dist.ReduceOp.MAX)
1538
+ dist.all_reduce(csn, op=dist.ReduceOp.MIN)
1539
+ assert float(csx - csn) < 1e-3, \
1540
+ f"[ddp] param-checksum desync at step {step}: spread {float(csx - csn)}"
1541
+ # §6 DBES/val/logs = rank-0-only diagnostics on the UNWRAPPED model (no collective inside,
1542
+ # so non-zero ranks skip and simply wait at the next step's forward/allreduce).
1543
+ if (not is_bytegpt) and a.dbes_every and (step % a.dbes_every == 0 or step == steps):
1544
+ if rank == 0:
1545
+ db = dbes_specialization(model, x); db["step"] = step
1546
+ dbes_log.append(db)
1547
+ if step == 1 or step % a.log_every == 0 or step == steps:
1548
+ vtxt = ""
1549
+ ptxt = ""
1550
+ if do_val and rank == 0:
1551
+ per = val_per_cell()
1552
+ vc = (sum(per.values()) / len(per)) if per else float("nan")
1553
+ vtxt = f" val_CE={vc:.5f}"
1554
+ # ⑤ per-cell CE dict (ko/en × general/sns) — MONITOR-ONLY, additive log of
1555
+ # the already-tracked per-register held-out CE. NEVER enters the loss
1556
+ # (a_train_inline_gauge · p7 NO PERPLEXITY VERDICT); decomposes the pooled
1557
+ # val_CE above so per-cell descent is visible per (a_chat_registers) cell.
1558
+ if per:
1559
+ ptxt = " per_cell_CE=" + json.dumps(
1560
+ {k: round(v, 5) for k, v in per.items()})
1561
+ atxt = (" " + json.dumps({k: round(v, 4) for k, v in aux.items()})) if aux else ""
1562
+ p0(f" step {step:5d} CE={ce:.5f} E={e_now()} "
1563
+ f"wd={wd:.4f} dp={dp:.4f}{vtxt}{ptxt}{atxt}", flush=True)
1564
+ wall = time.time() - t0
1565
+
1566
+ # ══ §6 FINALIZE — held-out val / DBES / gauges / ckpt / summary / serialize are ALL
1567
+ # RANK-0-ONLY on the UNWRAPPED core_model (`model`). A non-zero rank writing files =
1568
+ # double-write corruption; val_gen must advance only on rank 0 so its stream matches
1569
+ # the 1-GPU run (the DESCENT-gate comparability contract, §10.6). Non-zero ranks skip
1570
+ # straight to the barrier + destroy_process_group. N==1: rank==0, so this all runs.
1571
+ if rank == 0:
1572
+ # ── FINAL held-out val per register (DESCENT gate, plain CE) ──────────────
1573
+ uniform = math.log(V)
1574
+ per = val_per_cell()
1575
+ descent = {}; n_desc = 0
1576
+ print(f" ── FINAL held-out val-CE per register (uniform={uniform:.4f}) ──", flush=True)
1577
+ for lab, vc in per.items():
1578
+ ok = vc < uniform; n_desc += int(ok)
1579
+ descent[lab] = {"val_ce": round(vc, 5), "descent": ok}
1580
+ print(f" {lab:<12s} val_CE={vc:.5f} {'DESCENT' if ok else 'NO-DESCENT'}", flush=True)
1581
+ final_val = (sum(per.values()) / len(per)) if per else None
1582
+ print(f" FINAL val_CE(pooled)={final_val} registers_DESCENT={n_desc}/{len(per)}", flush=True)
1583
+ print(f" loss0={loss0:.5f} lossF={lossF:.5f} wall={wall:.1f}s "
1584
+ f"savant_latched_at={latch['at']} E0={e0}->E={e_now()}", flush=True)
1585
+
1586
+ # ── N3 DBES final diagnostic (gradient-free, measure-only) ────────────────
1587
+ # DBES probes MoE expert differentiation — CLM-only (no experts in ByteGPT).
1588
+ dbes_final = None
1589
+ if not is_bytegpt:
1590
+ try:
1591
+ xb, _ = get_batch(steps + 1)
1592
+ dbes_final = dbes_specialization(model, xb)
1593
+ print(f" [N3 DBES expert-specialization] {json.dumps(dbes_final, ensure_ascii=False)}", flush=True)
1594
+ except Exception as e:
1595
+ print(f" DBES error: {e}", flush=True)
1596
+
1597
+ # ── ρ·weave/ρ·fan (former G1/G6) torch-probe gauges (DIRECTIONAL, a_train_inline_gauge) ──
1598
+ # gauge_lib.compute_inline_gauges decodes via the CLM mouth (CLMConvMoE-specific);
1599
+ # skip for bytegpt (the terminal verdict is `anima-python evaluate <.bin>` engine-native
1600
+ # through the bytegpt mouth anyway — this torch probe is DIRECTIONAL only).
1601
+ gauges = None
1602
+ if not is_bytegpt:
1603
+ try:
1604
+ import gauge_lib
1605
+ was = model.training; model.eval()
1606
+ gauges = gauge_lib.compute_inline_gauges(
1607
+ model, None, seeds=7, corpus_index=[c.path for c in cells],
1608
+ ce=lossF, step=steps, torch=torch)
1609
+ if was: model.train()
1610
+ print(f" [ρ·weave/ρ·fan (G1/G6) torch-probe DIRECTIONAL] {json.dumps(gauges, ensure_ascii=False)}", flush=True)
1611
+ except Exception as e:
1612
+ print(f" gauges error: {e}", flush=True)
1613
+
1614
+ # ── persist torch ckpt (ALWAYS — a_fire_recover_complete) ────────────────
1615
+ full_sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
1616
+ if jamo_head:
1617
+ for k, v in jamo_head.state_dict().items():
1618
+ full_sd[f"_jamo_head.{k}"] = v.detach().cpu()
1619
+ if a.ckpt_out:
1620
+ torch.save(full_sd, a.ckpt_out)
1621
+ print(f" torch ckpt -> {a.ckpt_out} ({os.path.getsize(a.ckpt_out)} bytes)", flush=True)
1622
+
1623
+ # ── summary json ──────────────────────────────────────────────────────────
1624
+ summary = {"entry": "anima-python train", "arch": a.arch, "arm": a.arm,
1625
+ "objective": a.objective, "seed": a.seed,
1626
+ "ddp": {"world_size": world, "global_batch": B_global,
1627
+ "per_rank_batch": B_local, "gpus": a.gpus},
1628
+ "obj_aux_params": (sum(p.numel() for p in objfn.parameters())
1629
+ if obj_is_module else 0),
1630
+ "levers": {"tlora": tlora_on, "tlora_rank": a.tlora_rank,
1631
+ "tlora_base": not a.tlora_no_base, "dict_aux": dict_on,
1632
+ "jamo_aux": jamo_on, "wd_floor": a.wd_floor,
1633
+ "dropout_floor": a.dropout_floor},
1634
+ "n_params": n_params, "loss0": round(loss0, 5), "lossF": round(lossF, 5),
1635
+ "wall_s": round(wall, 1), "uniform_ce": round(uniform, 5),
1636
+ "final_val_ce_pooled": (round(final_val, 5) if final_val else None),
1637
+ "registers_descent": f"{n_desc}/{len(per)}", "heldout_descent": descent,
1638
+ "last_aux": last_aux, "dbes_final": dbes_final, "dbes_log": dbes_log,
1639
+ # §4 diagnostic honesty: under DDP the rank-0 DBES probe runs on a rank-LOCAL
1640
+ # shard (B_local=B/N), NOT the global batch B, so dbes_final/dbes_log are NOT
1641
+ # numerically 1:1 comparable with a 1-GPU record (which used full B). Label the
1642
+ # input scope explicitly (DBES is MONITOR-ONLY/DIRECTIONAL, excluded from the
1643
+ # DESCENT gate + engine-native verdict, so this does not corrupt the verdict).
1644
+ "dbes_batch_scope": (f"per_rank_shard(B_local={B_local})" if world > 1
1645
+ else f"full_batch(B={B_global})"),
1646
+ "gauges_g1g6_torch_probe": gauges,
1647
+ "tier": ("engine-native-eligible (.bin ByteGPT via bytegpt mouth); torch probe DIRECTIONAL"
1648
+ if is_bytegpt else
1649
+ "engine-native-eligible (.clm additive, TLoRA materialized); torch probe DIRECTIONAL")}
1650
+ if a.gauges_out:
1651
+ with open(a.gauges_out, "w") as f:
1652
+ json.dump(summary, f, ensure_ascii=False, indent=2)
1653
+ print(f" summary -> {a.gauges_out}", flush=True)
1654
+
1655
+ # ── serialize the trained ckpt: CLM → .clm v0.3 (additive readout + MATERIALIZED
1656
+ # experts) | ByteGPT → .bin (5×u32 header via core/serialize.py). ──
1657
+ if a.out:
1658
+ _write_clm(a.out) # final full-run checkpoint (same helper as --ckpt-every)
1659
+
1660
+ # §6 barrier so no rank tears down while rank 0 serializes the 303M .clm, then release NCCL.
1661
+ if ddp_on:
1662
+ dist.barrier()
1663
+ dist.destroy_process_group()
1664
+
1665
+
1666
+ if __name__ == "__main__":
1667
+ main()