initium-deeplearning 0.1.0__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.
Files changed (71) hide show
  1. initium/LNN_controller/cfc_backbone_parallel.py +265 -0
  2. initium/LNN_controller/cfc_controller.py +325 -0
  3. initium/LNN_controller/chained_controller.py +83 -0
  4. initium/LNN_controller/hybrid_controller.py +90 -0
  5. initium/MoE/README.md +1 -0
  6. initium/MoE/moe_layer.py +552 -0
  7. initium/__init__.py +1 -0
  8. initium/config/controller_config.py +86 -0
  9. initium/config/train_config.py +64 -0
  10. initium/core_training.py +2182 -0
  11. initium/data/__init__.py +0 -0
  12. initium/data/audio/__init__.py +0 -0
  13. initium/data/audio/audio_dataset.py +96 -0
  14. initium/data/base_dataset.py +75 -0
  15. initium/data/common/__init__.py +0 -0
  16. initium/data/common/chain_task.py +542 -0
  17. initium/data/common/digit_codec.py +85 -0
  18. initium/data/common/graph_io.py +123 -0
  19. initium/data/common/real_data.py +747 -0
  20. initium/data/dataset_registry.py +59 -0
  21. initium/data/graph_traversal/__init__.py +0 -0
  22. initium/data/graph_traversal/graph_traversal.py +746 -0
  23. initium/data/multimodal/__init__.py +0 -0
  24. initium/data/multimodal/multimodal_dataset.py +401 -0
  25. initium/data/text/__init__.py +0 -0
  26. initium/data/text/text_dataset.py +79 -0
  27. initium/data/video/__init__.py +0 -0
  28. initium/data/video/video_dataset.py +85 -0
  29. initium/inference/__init__.py +1 -0
  30. initium/inference/cache/__init__.py +1 -0
  31. initium/inference/cache/base_cache.py +112 -0
  32. initium/inference/cache/cache_config.py +21 -0
  33. initium/inference/cache/cache_registry.py +99 -0
  34. initium/inference/cache/cached_engine.py +264 -0
  35. initium/inference/cache/keys.py +77 -0
  36. initium/inference/cache/prefix_cache.py +59 -0
  37. initium/inference/cache/result_cache.py +57 -0
  38. initium/inference/cache/state_utils.py +74 -0
  39. initium/inference/cache/stores.py +167 -0
  40. initium/inference/capabilities.py +86 -0
  41. initium/inference/checkpoint_io.py +63 -0
  42. initium/inference/engine.py +88 -0
  43. initium/inference/inference_config.py +22 -0
  44. initium/inference/metrics.py +103 -0
  45. initium/inference/model_loader.py +270 -0
  46. initium/inference/run_inference.py +526 -0
  47. initium/inference/run_logging.py +173 -0
  48. initium/inference/tasks/__init__.py +1 -0
  49. initium/inference/tasks/audio_task.py +114 -0
  50. initium/inference/tasks/base_task.py +60 -0
  51. initium/inference/tasks/graph_traversal_task.py +235 -0
  52. initium/inference/tasks/multimodal_task.py +74 -0
  53. initium/inference/tasks/task_registry.py +68 -0
  54. initium/inference/tasks/text_task.py +114 -0
  55. initium/inference/tasks/video_task.py +114 -0
  56. initium/mamba_controller/mamba2_controller.py +433 -0
  57. initium/mamba_controller/mamba3_controller.py +242 -0
  58. initium/mamba_controller/mamba_backbone_parallel.py +291 -0
  59. initium/mamba_controller/mamba_controller.py +983 -0
  60. initium/mamba_controller/split_graph_dnc.py +623 -0
  61. initium/memory_manipulation/dynamic_memory_resize.py +157 -0
  62. initium/memory_manipulation/dynamic_n_controller.py +152 -0
  63. initium/memory_manipulation/link_matrix_ablation.py +130 -0
  64. initium/memory_manipulation/nvrtc_compat.py +143 -0
  65. initium/memory_manipulation/stochastic_write_head_v2.py +545 -0
  66. initium/setup_wheels.py +175 -0
  67. initium_deeplearning-0.1.0.dist-info/METADATA +253 -0
  68. initium_deeplearning-0.1.0.dist-info/RECORD +71 -0
  69. initium_deeplearning-0.1.0.dist-info/WHEEL +4 -0
  70. initium_deeplearning-0.1.0.dist-info/entry_points.txt +3 -0
  71. initium_deeplearning-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,2182 @@
1
+ """
2
+ file: core_training.py
3
+
4
+ Dataset-agnostic DNC training core (stochastic write head + learned-prior KL,
5
+ selectable controller, AMP, LR schedule, checkpointing, periodic ID/OOD/
6
+ memory-dependency logging). All dataset specifics (data generation, encoding,
7
+ loss, curriculum, evaluation, OOD test) come from data/ via
8
+ dataset = get_dataset(--dataset-type, --dataset-link)
9
+ (default: graph-traversal). See data/base_dataset.py for the interface.
10
+
11
+ Version notes (core-level):
12
+ - v3: capacity scale-up (MODEL_* constants), linear LR warmup, AMP
13
+ init_scale=128 and GradScaler state saved/restored in checkpoints.
14
+ - v5 (Phase 2): learned, periodically-snapshotted prior N(mu_g, Sigma_g)
15
+ (math in stochastic_write_head_v2.py); prior_snapshots.csv log;
16
+ snapshot_step column; prior_state in checkpoints; run_id tag "_learnedprior".
17
+ - v6: snapshot_step in the console line; ood_rng state in checkpoints.
18
+ - v7+: pluggable controllers via MambaDNC (lstm/mamba/mamba2/mamba3/cfc/
19
+ hybrids), MoE (Option 4), split-graph (Option 5), link-matrix ablation
20
+ (Option 1), static/dynamic N (Option 2), dynamic beta. Each keeps its own
21
+ CLI flag and run_id tag so runs never collide in logs/.
22
+ - v17: dataset split out of this file into data/. Core only talk to the
23
+ dataset through the BaseDataset interface; curriculum's per-lesson memory
24
+ size lives on the curriculum instance (curriculum.lesson_nr_cells).
25
+ - v18: constants moved out: train_config.py (loop/model/KL/prior/checkpoint)
26
+ and controller_config.py (controllers, MoE, split-graph, link-matrix,
27
+ Dynamic-N). Pure move, no value changes.
28
+ """
29
+
30
+ import csv
31
+ import math
32
+ import os
33
+ import random
34
+ import shutil
35
+ import time
36
+ from typing import Any
37
+
38
+ import numpy as np
39
+ import torch
40
+ import torch.nn as nn
41
+
42
+ from initium.memory_manipulation.nvrtc_compat import patch_prod_jiterator
43
+
44
+ patch_prod_jiterator() # environment workaround -- see module docstring
45
+
46
+ from initium.config.controller_config import (
47
+ CFC_ACTIVATION,
48
+ CFC_BACKBONE_DROPOUT,
49
+ CFC_BACKBONE_LAYERS,
50
+ CFC_BACKBONE_UNITS,
51
+ CFC_MIXED_MEMORY,
52
+ CFC_MODE,
53
+ CFC_RESIDUAL,
54
+ CONTROLLER_TYPE,
55
+ DYNAMIC_N_CEILING,
56
+ DYNAMIC_N_COOLDOWN_STEPS,
57
+ DYNAMIC_N_EMA_DECAY,
58
+ DYNAMIC_N_FLOOR,
59
+ DYNAMIC_N_GROWTH_FACTOR,
60
+ DYNAMIC_N_MODE,
61
+ DYNAMIC_N_TRIGGER_FRAC,
62
+ DYNAMIC_N_USAGE_HIGH,
63
+ HYBRID_CFC_NUM_BLOCKS,
64
+ ISOLATE_LINK_ABLATION,
65
+ LINK_MATRIX_MODE,
66
+ LINK_MATRIX_TOPK,
67
+ MAMBA2_D_CONV,
68
+ MAMBA2_D_STATE,
69
+ MAMBA2_EXPAND,
70
+ MAMBA2_HEADDIM,
71
+ MAMBA2_NGROUPS,
72
+ MAMBA3_D_STATE,
73
+ MAMBA3_EXPAND,
74
+ MAMBA3_HEADDIM,
75
+ MAMBA3_ROPE_FRACTION,
76
+ MAMBA_D_CONV,
77
+ MAMBA_D_STATE,
78
+ MAMBA_EXPAND,
79
+ MOE_CAPACITY_FACTOR,
80
+ MOE_ENABLED,
81
+ MOE_EXPERT_DIM,
82
+ MOE_LOAD_BALANCE_ALPHA,
83
+ MOE_NUM_EXPERTS,
84
+ MOE_TOP_K,
85
+ SPLIT_GRAPH_COMBINE_READS,
86
+ SPLIT_GRAPH_COMBINER_CFC_MULTI_SOURCE_MOE,
87
+ SPLIT_GRAPH_COMBINER_MODE,
88
+ SPLIT_GRAPH_COMBINER_NUM_BLOCKS,
89
+ SPLIT_GRAPH_COMBINER_VARIANT,
90
+ SPLIT_GRAPH_ENABLED,
91
+ SPLIT_GRAPH_MAMBA_HEADDIM,
92
+ SPLIT_GRAPH_MAMBA_VARIANT,
93
+ SPLIT_GRAPH_NUM_BLOCKS,
94
+ )
95
+ from initium.config.train_config import (
96
+ AMP_INIT_SCALE,
97
+ BATCH_SIZE,
98
+ BETA_CTRL_ACC_TARGET,
99
+ BETA_CTRL_EMA_DECAY,
100
+ BETA_CTRL_LR,
101
+ BETA_CTRL_MAX,
102
+ BETA_CTRL_MIN,
103
+ BETA_MODE,
104
+ BETAS_TO_SWEEP,
105
+ CHECKPOINT_DIR,
106
+ CHECKPOINT_EVERY,
107
+ DATASET_LINK,
108
+ DATASET_TYPE,
109
+ EVAL_EVERY,
110
+ FREE_BITS,
111
+ KL_ANNEAL_STEPS,
112
+ LESSON_KL_DIP_STEPS,
113
+ LOG_DIR,
114
+ LOG_EVERY,
115
+ LR,
116
+ LR_DECAY_STEPS,
117
+ LR_MIN,
118
+ MODEL_CELL_SIZE,
119
+ MODEL_HIDDEN_SIZE,
120
+ MODEL_NR_CELLS,
121
+ MODEL_READ_HEADS,
122
+ OOD_EVAL_EPISODES,
123
+ OOD_EVAL_EPISODES_PERIODIC,
124
+ PRIOR_MAX_LOGVAR,
125
+ PRIOR_MIN_LOGVAR,
126
+ PRIOR_SNAPSHOT_EVERY,
127
+ SEED,
128
+ TOTAL_STEPS,
129
+ USE_AMP,
130
+ WARMUP_STEPS,
131
+ )
132
+ from initium.data.dataset_registry import get_dataset
133
+ from initium.mamba_controller.mamba_controller import MambaDNC
134
+ from initium.mamba_controller.split_graph_dnc import SplitGraphDNC
135
+ from initium.memory_manipulation.dynamic_memory_resize import resize_memory
136
+ from initium.memory_manipulation.dynamic_n_controller import DynamicNController
137
+ from initium.memory_manipulation.link_matrix_ablation import patch_link_matrix
138
+ from initium.memory_manipulation.stochastic_write_head_v2 import (
139
+ get_prior_state,
140
+ install_stochastic_write_heads,
141
+ load_prior_state,
142
+ pop_total_kl,
143
+ update_all_prior_snapshots,
144
+ )
145
+ from initium.MoE.moe_layer import pop_total_moe_aux_loss
146
+
147
+ device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
148
+ print(f"Using device: {device}")
149
+
150
+
151
+ def _arch_desc(
152
+ controller,
153
+ sg_enabled,
154
+ sg_variant,
155
+ sg_blocks,
156
+ sg_comb_mode,
157
+ sg_comb_variant,
158
+ sg_comb_blocks,
159
+ sg_combine_reads,
160
+ ):
161
+ if not sg_enabled:
162
+ return f"controller={controller}"
163
+ if sg_comb_mode == "controller":
164
+ comb = f"controller:{sg_comb_variant}x{sg_comb_blocks}"
165
+ else:
166
+ comb = "linear" if sg_combine_reads else "none(no-combine)"
167
+ return f"split-graph[backbone={sg_variant}x{sg_blocks} | combiner={comb}]"
168
+
169
+
170
+ def _first_nonfinite_report(tensor, name, step):
171
+ """Silent when finite; prints once when not. Exists to bisect WHICH
172
+ pipeline stage a NaN/Inf first appears at, since the LOG_EVERY-averaged
173
+ training log only shows the aggregate, not the originating stage."""
174
+ ok = torch.isfinite(tensor).all()
175
+ if not ok:
176
+ bad_frac = (~torch.isfinite(tensor)).float().mean().item()
177
+ print(
178
+ f"[NaN-TRACE] step {step}: non-finite in '{name}' ({bad_frac * 100:.2f}% of elements)"
179
+ )
180
+ return ok
181
+
182
+
183
+ def save_checkpoint(
184
+ path,
185
+ rnn,
186
+ output_proj,
187
+ stochastic_heads,
188
+ optimizer,
189
+ curriculum,
190
+ step,
191
+ beta_target,
192
+ run_id,
193
+ scaler,
194
+ ood_rng,
195
+ controller_type=CONTROLLER_TYPE,
196
+ link_matrix_mode=LINK_MATRIX_MODE,
197
+ link_matrix_topk=LINK_MATRIX_TOPK, # Static Option 1
198
+ dynamic_n_mode=DYNAMIC_N_MODE,
199
+ dynamic_n_state=None, # Dynamic-N (macro-scale)
200
+ moe_enabled=MOE_ENABLED,
201
+ moe_num_experts=MOE_NUM_EXPERTS,
202
+ moe_expert_dim=MOE_EXPERT_DIM,
203
+ moe_capacity_factor=MOE_CAPACITY_FACTOR,
204
+ moe_load_balance_alpha=MOE_LOAD_BALANCE_ALPHA,
205
+ moe_top_k=MOE_TOP_K,
206
+ split_graph_enabled=SPLIT_GRAPH_ENABLED,
207
+ split_graph_variant=SPLIT_GRAPH_MAMBA_VARIANT,
208
+ extra_model_config=None,
209
+ ):
210
+ """Save everything needed to resume training or re-run eval later:
211
+ - model + output-projection + optimizer state
212
+ - LR is NOT saved separately -- it's now a pure function of `step`
213
+ (see lr_at_step()/set_lr() in run()), clamped at LR_DECAY_STEPS, so
214
+ it's fully reconstructible from `step` alone on resume. This also
215
+ sidesteps the CosineAnnealingLR periodicity bug (see chat log /
216
+ Q21-Experiment-Log Section 5) that previously let LR drift back up
217
+ after LR_DECAY_STEPS via the old scheduler's carried internal state.
218
+ - the stochastic write head(s)' own params are already inside
219
+ rnn.state_dict() (they were installed as submodules via the
220
+ write_vector_transform swap), so no separate save needed for those
221
+ - RNG state (python/numpy/torch/cuda) for reproducibility
222
+ - curriculum lesson, so eval-time episode difficulty matches training
223
+ - v3 fix: GradScaler state (scale, growth tracker, etc). Previously
224
+ NOT saved -- `scaler = torch.amp.GradScaler(...)` was rebuilt fresh
225
+ on every resume, throwing away whatever scale the prior leg had
226
+ found and re-running a smaller version of the AMP overflow cascade
227
+ right after every resume (see header note). `scaler` is now a
228
+ required arg so this can't silently regress back to being dropped.
229
+ - v5 (Phase 2): `prior_state` -- mu_g, Sigma_g (as prior_logvar), and
230
+ last_snapshot_step for every installed stochastic write head (see
231
+ get_prior_state() in stochastic_write_head.py). NOT covered by
232
+ rnn.state_dict(): prior_mu/prior_logvar are registered buffers, so
233
+ they normally WOULD ride along inside rnn.state_dict() automatically
234
+ -- but they are captured here explicitly, as their own top-level
235
+ checkpoint key, so this checkpoint's prior state is self-describing
236
+ and independently loadable (e.g. by eval_from_checkpoint.py without
237
+ needing to reload the entire rnn state_dict), and so a Phase-1-era
238
+ reader that doesn't expect these buffers can still load
239
+ rnn_state_dict unmodified. Mirrors the reasoning already applied to
240
+ scaler_state_dict: model weights alone are not enough to reproduce
241
+ this run's behavior on resume/re-eval.
242
+ - v6: `ood_rng_state` -- the dedicated OOD-sampling random.Random
243
+ instance's own state (via .getstate()), saved separately from the
244
+ existing `rng_state` block (which only ever covered the global
245
+ python/numpy/torch/cuda streams). Without this, a resumed run would
246
+ silently re-seed ood_rng back to its start-of-process value
247
+ (deterministic from `seed` alone -- see run()) instead of
248
+ continuing the exact OOD walk sequence the pre-resume leg had
249
+ reached, which is a smaller version of the same "silently evaluate
250
+ against the wrong state" bug class prior_state (above) exists to
251
+ catch -- it doesn't change the metric's validity (ood_rng was
252
+ always decoupled from the training stream, which was the actual
253
+ fix), but it does mean a resume's OOD trajectory isn't bit-for-bit
254
+ continuous with the pre-resume leg unless this is restored. `ood_rng`
255
+ is now a required arg so this can't silently regress the way the
256
+ scaler-state omission originally did.
257
+ - v7 (Alternate Phase 3, Step 1): `model_config` gained `controller_type`
258
+ and (only when it's "mamba") `mamba_d_state`/`mamba_d_conv`/
259
+ `mamba_expand`. Same "self-describing checkpoint" rationale as
260
+ prior_state/scaler_state_dict above: an LSTM checkpoint's
261
+ rnn_state_dict has `lstm_layer_0...` keys, a Mamba checkpoint's has
262
+ `mamba_layer_0...` keys (see mamba_controller.py's MambaDNC), and
263
+ without this field a reader has no way to know which model class to
264
+ reconstruct before attempting `load_state_dict`.
265
+ - Dynamic-N (macro-scale): `model_config["dynamic_n_mode"]`, same
266
+ self-describing-checkpoint rationale as controller_type/
267
+ link_matrix_mode above -- records WHICH mechanism was responsible
268
+ for whatever nr_cells this checkpoint's rnn.memories[0].nr_cells
269
+ already self-describes (curriculum lookup vs. usage-triggered
270
+ growth), even though the resulting nr_cells value itself doesn't
271
+ care which mechanism produced it. Also `dynamic_n_state` (top-level
272
+ key, not inside model_config, same convention as prior_state /
273
+ scaler_state_dict): DynamicNController's usage-EMA, cooldown
274
+ counter, and full growth_history -- this is run *state*, not model
275
+ architecture, so it lives alongside prior_state/ood_rng_state
276
+ rather than inside model_config. None when this run isn't in
277
+ dynamic_n_mode.
278
+ """
279
+ os.makedirs(os.path.dirname(path), exist_ok=True)
280
+ model_config = {
281
+ "input_size": rnn.input_size,
282
+ "hidden_size": MODEL_HIDDEN_SIZE,
283
+ "num_hidden_layers": getattr(rnn, "num_hidden_layers", None),
284
+ "nr_cells": rnn.memories[0].nr_cells,
285
+ "cell_size": MODEL_CELL_SIZE,
286
+ "read_heads": MODEL_READ_HEADS,
287
+ "controller_type": controller_type, # v7
288
+ "link_matrix_mode": link_matrix_mode,
289
+ "link_matrix_topk": link_matrix_topk, # Static Option 1
290
+ "dynamic_n_mode": dynamic_n_mode, # Dynamic-N (macro-scale)
291
+ "moe_enabled": moe_enabled,
292
+ "moe_num_experts": moe_num_experts, # v8 (Option 4)
293
+ "moe_expert_dim": moe_expert_dim,
294
+ "moe_capacity_factor": moe_capacity_factor,
295
+ "moe_load_balance_alpha": moe_load_balance_alpha,
296
+ "moe_top_k": moe_top_k,
297
+ "split_graph_enabled": split_graph_enabled, # v9 (Option 5)
298
+ "split_graph_variant": split_graph_variant,
299
+ }
300
+ if controller_type == "mamba": # v7
301
+ model_config.update(
302
+ {
303
+ "mamba_d_state": MAMBA_D_STATE,
304
+ "mamba_d_conv": MAMBA_D_CONV,
305
+ "mamba_expand": MAMBA_EXPAND,
306
+ }
307
+ )
308
+ elif controller_type == "mamba2":
309
+ model_config.update(
310
+ {
311
+ "mamba2_d_state": MAMBA2_D_STATE,
312
+ "mamba2_d_conv": MAMBA2_D_CONV,
313
+ "mamba2_expand": MAMBA2_EXPAND,
314
+ "mamba2_headdim": MAMBA2_HEADDIM,
315
+ "mamba2_ngroups": MAMBA2_NGROUPS,
316
+ }
317
+ )
318
+ elif controller_type == "mamba3":
319
+ model_config.update(
320
+ {
321
+ "mamba3_d_state": MAMBA3_D_STATE,
322
+ "mamba3_expand": MAMBA3_EXPAND,
323
+ "mamba3_headdim": MAMBA3_HEADDIM,
324
+ "mamba3_rope_fraction": MAMBA3_ROPE_FRACTION,
325
+ }
326
+ )
327
+ elif controller_type == "cfc":
328
+ model_config.update(
329
+ {
330
+ "cfc_mode": CFC_MODE,
331
+ "cfc_backbone_units": CFC_BACKBONE_UNITS,
332
+ "cfc_backbone_layers": CFC_BACKBONE_LAYERS,
333
+ "cfc_backbone_dropout": CFC_BACKBONE_DROPOUT,
334
+ "cfc_activation": CFC_ACTIVATION,
335
+ "cfc_mixed_memory": CFC_MIXED_MEMORY,
336
+ "cfc_residual": CFC_RESIDUAL,
337
+ }
338
+ )
339
+ elif "+" in controller_type: # v14: hybrid chains, e.g. "mamba+cfc"
340
+ model_config.update(
341
+ {
342
+ "mamba_d_state": MAMBA_D_STATE,
343
+ "mamba_d_conv": MAMBA_D_CONV,
344
+ "mamba_expand": MAMBA_EXPAND,
345
+ "mamba2_d_state": MAMBA2_D_STATE,
346
+ "mamba2_d_conv": MAMBA2_D_CONV,
347
+ "mamba2_expand": MAMBA2_EXPAND,
348
+ "mamba2_headdim": MAMBA2_HEADDIM,
349
+ "mamba2_ngroups": MAMBA2_NGROUPS,
350
+ "mamba3_d_state": MAMBA3_D_STATE,
351
+ "mamba3_expand": MAMBA3_EXPAND,
352
+ "mamba3_headdim": MAMBA3_HEADDIM,
353
+ "mamba3_rope_fraction": MAMBA3_ROPE_FRACTION,
354
+ "cfc_mode": CFC_MODE,
355
+ "cfc_backbone_units": CFC_BACKBONE_UNITS,
356
+ "cfc_backbone_layers": CFC_BACKBONE_LAYERS,
357
+ "cfc_backbone_dropout": CFC_BACKBONE_DROPOUT,
358
+ "cfc_activation": CFC_ACTIVATION,
359
+ "cfc_mixed_memory": CFC_MIXED_MEMORY,
360
+ "cfc_residual": CFC_RESIDUAL,
361
+ "hybrid_cfc_num_blocks": HYBRID_CFC_NUM_BLOCKS,
362
+ }
363
+ )
364
+
365
+ if extra_model_config: # v19: dataset type / dims / split-graph details, read by inference/
366
+ model_config.update(extra_model_config)
367
+
368
+ torch.save(
369
+ {
370
+ "step": step,
371
+ "run_id": run_id,
372
+ "beta_target": beta_target,
373
+ "model_config": model_config,
374
+ "rnn_state_dict": rnn.state_dict(),
375
+ "output_proj_state_dict": output_proj.state_dict(),
376
+ "optimizer_state_dict": optimizer.state_dict(),
377
+ "scaler_state_dict": scaler.state_dict(), # v3 fix
378
+ "curriculum_lesson": curriculum.lesson,
379
+ "prior_state": get_prior_state(stochastic_heads), # v5 (Phase 2)
380
+ "ood_rng_state": ood_rng.getstate(), # v6
381
+ "dynamic_n_state": dynamic_n_state, # Dynamic-N (macro-scale); None if not dynamic_n_mode
382
+ "rng_state": {
383
+ "python": random.getstate(),
384
+ "numpy": np.random.get_state(),
385
+ "torch": torch.get_rng_state(),
386
+ "cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
387
+ },
388
+ },
389
+ path,
390
+ )
391
+
392
+
393
+ def load_checkpoint_for_resume(path, device):
394
+ """Load a checkpoint saved by save_checkpoint(), for CONTINUING training
395
+ (as opposed to eval_from_checkpoint.py, which loads for eval only and
396
+ doesn't need optimizer/scheduler/RNG state).
397
+
398
+ map_location='cpu' (not `device`): torch.load's map_location moves
399
+ EVERY tensor in the checkpoint, including the RNG-state ByteTensors.
400
+ torch.cuda.set_rng_state_all() requires those to stay plain CPU
401
+ ByteTensors -- if map_location drags them onto CUDA they become
402
+ torch.cuda.ByteTensor and set_rng_state_all rejects them. Loading to
403
+ CPU and letting rnn.load_state_dict()/optimizer.load_state_dict() do
404
+ their own (automatic) device casting for the model/optimizer tensors
405
+ avoids that without needing two different map_locations for one file.
406
+
407
+ weights_only=False: this checkpoint stores non-tensor python/numpy RNG
408
+ state alongside the tensors, and PyTorch >=2.6 defaults torch.load to
409
+ weights_only=True. Safe here since it's a checkpoint we produced
410
+ ourselves, not a downloaded/untrusted file.
411
+ """
412
+ return torch.load(path, map_location="cpu", weights_only=False)
413
+
414
+
415
+ def restore_rng_state(rng_state):
416
+ random.setstate(rng_state["python"])
417
+ np.random.set_state(rng_state["numpy"])
418
+ torch.set_rng_state(
419
+ rng_state["torch"].cpu() if torch.is_tensor(rng_state["torch"]) else rng_state["torch"]
420
+ )
421
+ if rng_state["cuda"] is not None and torch.cuda.is_available():
422
+ torch.cuda.set_rng_state_all(rng_state["cuda"])
423
+
424
+
425
+ # ==========================================
426
+ # TRAINING LOOP
427
+ # ==========================================
428
+ def run(
429
+ beta_target: float,
430
+ run_id: str,
431
+ seed: int = SEED,
432
+ resume_from: str | None = None,
433
+ controller: str = CONTROLLER_TYPE, # v7 (Alternate Phase 3, Step 1)
434
+ beta_mode: str = BETA_MODE, # dynamic-beta toggle: "static" or "dynamic"
435
+ total_steps: int = TOTAL_STEPS, # see --total-steps.
436
+ checkpoint_every: int = CHECKPOINT_EVERY, # see --checkpoint-every.
437
+ link_matrix_mode: str = LINK_MATRIX_MODE, # Static Option 1
438
+ link_matrix_topk: int | None = LINK_MATRIX_TOPK, # Static Option 1
439
+ isolate_link_ablation: bool = ISOLATE_LINK_ABLATION, # Static Option 1
440
+ dynamic_n_mode: bool = DYNAMIC_N_MODE, # Dynamic-N (macro-scale)
441
+ dynamic_n_floor: int = DYNAMIC_N_FLOOR,
442
+ dynamic_n_ceiling: int = DYNAMIC_N_CEILING,
443
+ dynamic_n_trigger_frac: float = DYNAMIC_N_TRIGGER_FRAC,
444
+ dynamic_n_cooldown_steps: int = DYNAMIC_N_COOLDOWN_STEPS,
445
+ moe_enabled: bool = MOE_ENABLED,
446
+ moe_num_experts: int = MOE_NUM_EXPERTS,
447
+ moe_expert_dim: int | None = MOE_EXPERT_DIM,
448
+ moe_capacity_factor: float = MOE_CAPACITY_FACTOR,
449
+ moe_load_balance_alpha: float = MOE_LOAD_BALANCE_ALPHA,
450
+ moe_top_k: int = MOE_TOP_K,
451
+ split_graph_combiner_cfc_multi_source_moe: bool = SPLIT_GRAPH_COMBINER_CFC_MULTI_SOURCE_MOE,
452
+ split_graph_enabled: bool = SPLIT_GRAPH_ENABLED,
453
+ split_graph_variant: str = SPLIT_GRAPH_MAMBA_VARIANT,
454
+ split_graph_num_blocks: int = SPLIT_GRAPH_NUM_BLOCKS,
455
+ split_graph_headdim: int = SPLIT_GRAPH_MAMBA_HEADDIM,
456
+ split_graph_combine_reads: bool = SPLIT_GRAPH_COMBINE_READS,
457
+ split_graph_combiner_mode: str = SPLIT_GRAPH_COMBINER_MODE,
458
+ split_graph_combiner_variant: str = SPLIT_GRAPH_COMBINER_VARIANT,
459
+ split_graph_combiner_num_blocks: int = SPLIT_GRAPH_COMBINER_NUM_BLOCKS,
460
+ dataset_type: str = DATASET_TYPE,
461
+ dataset_link: str | None = DATASET_LINK,
462
+ test_dataset_link: str | None = None,
463
+ ):
464
+ # Deliberately does NOT touch LR_DECAY_STEPS - that's a separate
465
+ # module-level constant, fixed at import time from the *original*
466
+ # TOTAL_STEPS, and lr_at_step()/set_lr() below read it directly by
467
+ # name, not through this parameter. A pilot run still anneals LR on
468
+ # the full 120000-step schedule and simply stops early partway
469
+ # through it, exactly as the --total-steps help text promises.
470
+ dataset = get_dataset(dataset_type, dataset_link, test_dataset_link=test_dataset_link)
471
+ INPUT_DIM, TRIPLE_DIM = dataset.input_dim, dataset.output_dim
472
+ beta_ctrl_acc_target = (
473
+ BETA_CTRL_ACC_TARGET if BETA_CTRL_ACC_TARGET is not None else dataset.advance_threshold
474
+ )
475
+
476
+ torch.manual_seed(seed)
477
+ random.seed(seed)
478
+ np.random.seed(seed)
479
+ torch.backends.cudnn.benchmark = True
480
+
481
+ os.makedirs(LOG_DIR, exist_ok=True)
482
+ log_path = os.path.join(LOG_DIR, f"run_{run_id}.csv")
483
+ resuming = resume_from is not None
484
+ log_file = open(log_path, "a" if resuming else "w", newline="")
485
+ log_writer = csv.writer(log_file)
486
+ if not resuming:
487
+ log_writer.writerow(
488
+ [
489
+ "step",
490
+ "lesson",
491
+ "beta_effective",
492
+ "task_loss",
493
+ "kl_loss",
494
+ "total_loss",
495
+ "digit_diversity",
496
+ "kl_mean",
497
+ "kl_max",
498
+ "kl_min",
499
+ "kl_std",
500
+ "lr",
501
+ "grad_norm",
502
+ "amp_scale",
503
+ "elapsed_sec",
504
+ "snapshot_step",
505
+ "gpu_mem_peak_mb",
506
+ "param_count",
507
+ "moe_aux_loss",
508
+ "moe_cv_importance",
509
+ "moe_cv_load",
510
+ "moe_max_load_frac",
511
+ ]
512
+ )
513
+
514
+ ood_log_path = os.path.join(LOG_DIR, f"run_{run_id}_ood.csv")
515
+ ood_log_file = open(ood_log_path, "a" if resuming else "w", newline="")
516
+ ood_log_writer = csv.writer(ood_log_file)
517
+ if not resuming:
518
+ ood_log_writer.writerow(
519
+ [
520
+ "step",
521
+ "lesson",
522
+ "id_triple_acc",
523
+ "id_perfect_frac",
524
+ "ood_triple_acc",
525
+ "ood_perfect_frac",
526
+ "ood_offset_triple",
527
+ "ood_offset_perfect",
528
+ ]
529
+ )
530
+
531
+ # Functional-usage check (LB-9/Concept 6): same-cadence, same-distribution
532
+ # companion to the ID/OOD log above, but with the model's own memory
533
+ # path switched off (see evaluate_traversal's ablate_memory kwarg). A
534
+ # standing per-run check, not a one-off sanity pass, per the roadmap's
535
+ # explicit requirement -- catches rsDNC's silent bypass-path collapse
536
+ # (still trains, still runs, loss curves look fine) that a KL- or
537
+ # loss-only view can't see.
538
+ mem_check_path = os.path.join(LOG_DIR, f"run_{run_id}_memory_dependency.csv")
539
+ mem_check_file = open(mem_check_path, "a" if resuming else "w", newline="")
540
+ mem_check_writer = csv.writer(mem_check_file)
541
+ if not resuming:
542
+ mem_check_writer.writerow(
543
+ [
544
+ "step",
545
+ "lesson",
546
+ "id_triple_acc",
547
+ "id_perfect_frac",
548
+ "ablated_triple_acc",
549
+ "ablated_perfect_frac",
550
+ "memory_dependency_triple",
551
+ "memory_dependency_perfect",
552
+ ]
553
+ )
554
+
555
+ combiner_stage_log_path = os.path.join(LOG_DIR, f"run_{run_id}_combiner_stage_dependency.csv")
556
+ combiner_stage_log_file = open(combiner_stage_log_path, "a" if resuming else "w", newline="")
557
+ combiner_stage_log_writer = csv.writer(combiner_stage_log_file)
558
+ if not resuming:
559
+ combiner_stage_log_writer.writerow(
560
+ [
561
+ "step",
562
+ "lesson",
563
+ "stage_index",
564
+ "stage_kind",
565
+ "id_triple_acc",
566
+ "id_perfect_frac",
567
+ "ablated_triple_acc",
568
+ "ablated_perfect_frac",
569
+ "stage_dependency_triple",
570
+ "stage_dependency_perfect",
571
+ ]
572
+ )
573
+
574
+ lesson_log_path = os.path.join(LOG_DIR, f"run_{run_id}_lesson_advances.csv")
575
+ modality_log_path = os.path.join(LOG_DIR, f"run_{run_id}_modality_dependency.csv")
576
+ modality_log_file = open(modality_log_path, "a" if resuming else "w", newline="")
577
+ modality_log_writer = csv.writer(modality_log_file)
578
+ if not resuming:
579
+ modality_log_writer.writerow(
580
+ [
581
+ "step",
582
+ "lesson",
583
+ "modality",
584
+ "id_acc",
585
+ "id_perfect_frac",
586
+ "ablated_acc",
587
+ "ablated_perfect_frac",
588
+ "modality_dependency_acc",
589
+ "modality_dependency_perfect",
590
+ ]
591
+ )
592
+ lesson_log_file = open(lesson_log_path, "a" if resuming else "w", newline="")
593
+ lesson_log_writer = csv.writer(lesson_log_file)
594
+ if not resuming:
595
+ lesson_log_writer.writerow(["step", "new_lesson", "of_total_lessons"])
596
+
597
+ # Static Option 1 watch-item: per-hop-count accuracy, logged as its own
598
+ # trajectory instead of console-only. The link-matrix ablation/sparsify
599
+ # finding this option validates (Session 009 synthesis) was established
600
+ # on QA-style tasks, where each answer is effectively a 1-hop lookup and
601
+ # content-based addressing dominates. Graph traversal chains multiple
602
+ # hops per episode -- exactly where temporal/sequential-link addressing
603
+ # could matter more than it does for QA. So the thing to watch isn't
604
+ # whether aggregate triple_acc holds after ablating/sparsifying the link
605
+ # matrix, it's whether accuracy holds *evenly across hop counts* or
606
+ # collapses specifically at higher hops. hop_breakdown already computes
607
+ # this at every advance-check; this file just persists it.
608
+ hop_log_path = os.path.join(LOG_DIR, f"run_{run_id}_hop_breakdown.csv")
609
+ hop_log_file = open(hop_log_path, "a" if resuming else "w", newline="")
610
+ hop_log_writer = csv.writer(hop_log_file)
611
+ if not resuming:
612
+ hop_log_writer.writerow(
613
+ [
614
+ "step",
615
+ "lesson",
616
+ "eval_type",
617
+ "hop_count",
618
+ "triple_acc",
619
+ "perfect_frac",
620
+ "n_episodes",
621
+ ]
622
+ )
623
+
624
+ # v5 (Phase 2): periodic "prior snapshot updated" log -- net-new file,
625
+ # written every PRIOR_SNAPSHOT_EVERY steps by update_all_prior_snapshots()
626
+ # below. This is the direct evidence for the Q48 periodic-snapshot
627
+ # classification and for a collapse/drift check on the prior itself
628
+ # (Sigma_g -> 0 would not show up in the existing kl_mean diagnostic,
629
+ # which only ever describes q(v_t), never the prior it's compared
630
+ # against). `snapshot_step` here is always == `step` (the update just
631
+ # happened at this step); it's included anyway so this file's schema is
632
+ # self-describing without cross-referencing the main log.
633
+ prior_log_path = os.path.join(LOG_DIR, f"run_{run_id}_prior_snapshots.csv")
634
+ prior_log_file = open(prior_log_path, "a" if resuming else "w", newline="")
635
+ prior_log_writer = csv.writer(prior_log_file)
636
+ if not resuming:
637
+ prior_log_writer.writerow(
638
+ [
639
+ "step",
640
+ "snapshot_step",
641
+ "mu_g_norm",
642
+ "sigma_g_mean",
643
+ "sigma_g_min",
644
+ "sigma_g_max",
645
+ "trace_sigma_g",
646
+ "n_samples",
647
+ "raw_var_mean",
648
+ "raw_var_max",
649
+ "raw_hi_frac",
650
+ ]
651
+ )
652
+
653
+ curriculum = dataset.make_curriculum()
654
+ curriculum.advance_log_writer = lesson_log_writer # log addition #4, read by maybe_advance
655
+ curriculum.advance_log_file = lesson_log_file # flushed after each write
656
+ curriculum.hop_log_writer = hop_log_writer # Static Option 1 watch-item
657
+ curriculum.hop_log_file = hop_log_file
658
+
659
+ field_log_path = os.path.join(LOG_DIR, f"run_{run_id}_field_breakdown.csv")
660
+ field_log_file = open(field_log_path, "a" if resuming else "w", newline="")
661
+ field_log_writer = csv.writer(field_log_file)
662
+ if os.path.getsize(field_log_path) == 0:
663
+ # v20: header is dataset-owned (see BaseDataset.field_log_header) instead of
664
+ # hardcoded to graph's src/edge/dst schema, since text/audio/video/multimodal
665
+ # now log their own value/cumsum breakdown into this same file.
666
+ field_log_writer.writerow(["step", "lesson", "eval_type"] + dataset.field_log_header())
667
+ curriculum.field_log_writer = field_log_writer
668
+ curriculum.field_log_file = field_log_file
669
+
670
+ # v5 (Phase 2): dedicated RNG stream for OOD (London Underground) walk
671
+ # sampling, decoupled from the global `random` stream that curriculum ID
672
+ # sampling uses. Constructed once per run (not re-seeded per call --
673
+ # that was exactly the bug already fixed for
674
+ # build_london_underground_eval()'s station/label mapping, see the v2
675
+ # header note) and passed as `rng=` to every OOD evaluate_traversal()
676
+ # call below. Seed is derived from this run's own seed so different
677
+ # seeds in a panel still get different (but each internally
678
+ # reproducible) OOD walk sequences, rather than all sharing one fixed
679
+ # stream. Purely a cleanliness fix for the cross-seed comparisons Phase
680
+ # 2 relies on -- see Section 2g in
681
+ # Q21-Phase_1_Multi-seed_verification.md for the confound this removes.
682
+ ood_rng = random.Random(seed * 1_000_003 + 17)
683
+
684
+ starting_nr_cells = dynamic_n_floor if dynamic_n_mode else MODEL_NR_CELLS
685
+ hidden_size, nr_cells, cell_size, read_heads = (
686
+ MODEL_HIDDEN_SIZE,
687
+ starting_nr_cells,
688
+ MODEL_CELL_SIZE,
689
+ MODEL_READ_HEADS,
690
+ )
691
+
692
+ print(
693
+ f"[{run_id}] Model config: hidden={hidden_size} nr_cells={nr_cells} "
694
+ f"cell_size={cell_size} read_heads={read_heads} | beta={beta_target} "
695
+ f"| "
696
+ + _arch_desc(
697
+ controller,
698
+ split_graph_enabled,
699
+ split_graph_variant,
700
+ split_graph_num_blocks,
701
+ split_graph_combiner_mode,
702
+ split_graph_combiner_variant,
703
+ split_graph_combiner_num_blocks,
704
+ split_graph_combine_reads,
705
+ )
706
+ + (f" | moe={moe_num_experts}e" if moe_enabled else "")
707
+ )
708
+
709
+ if isolate_link_ablation:
710
+ curriculum.lesson_nr_cells = [MODEL_NR_CELLS] * len(curriculum.table)
711
+ print(
712
+ f"[{run_id}] isolate_link_ablation=True - nr_cells held fixed "
713
+ f"at {MODEL_NR_CELLS} for the whole run (Option 2's resize "
714
+ f"mechanism will never fire this run)."
715
+ )
716
+ if dynamic_n_mode:
717
+ # Same isolation mechanism as isolate_link_ablation above (Concept
718
+ # 16/SP-10): hold the curriculum-indexed lookup constant so
719
+ # TraversalCurriculum.maybe_advance()'s own resize_memory() call
720
+ # never fires -- DynamicNController is the only thing allowed to
721
+ # resize nr_cells this run.
722
+ curriculum.lesson_nr_cells = [dynamic_n_floor] * len(curriculum.table)
723
+ print(
724
+ f"[{run_id}] dynamic_n_mode=True - nr_cells starts at "
725
+ f"{dynamic_n_floor} and grows via usage-triggered "
726
+ f"DynamicNController (ceiling={dynamic_n_ceiling}, "
727
+ f"trigger_frac={dynamic_n_trigger_frac}, "
728
+ f"cooldown_steps={dynamic_n_cooldown_steps}); Option 2's "
729
+ f"curriculum-indexed resize mechanism will never fire this run."
730
+ )
731
+ if isolate_link_ablation:
732
+ print(
733
+ f"[{run_id}] NOTE: isolate_link_ablation AND dynamic_n_mode "
734
+ f"are both set -- these are two independent overrides of "
735
+ f"LESSON_NR_CELLS (dynamic_n_mode's wins, since it's "
736
+ f"checked second). Combining an isolated-Option-1 run "
737
+ f"with Dynamic-N growth is unusual; make sure that's "
738
+ f"actually what you meant to measure per Concept 16/SP-10."
739
+ )
740
+
741
+ mamba_kwargs: dict[str, Any] = {}
742
+ if controller == "mamba":
743
+ mamba_kwargs = dict(
744
+ mamba_d_state=MAMBA_D_STATE,
745
+ mamba_d_conv=MAMBA_D_CONV,
746
+ mamba_expand=MAMBA_EXPAND,
747
+ moe_enabled=moe_enabled,
748
+ moe_num_experts=moe_num_experts,
749
+ moe_expert_dim=moe_expert_dim,
750
+ moe_capacity_factor=moe_capacity_factor,
751
+ moe_load_balance_alpha=moe_load_balance_alpha,
752
+ moe_top_k=moe_top_k,
753
+ )
754
+ elif controller == "mamba2":
755
+ mamba_kwargs = dict(
756
+ mamba2_d_state=MAMBA2_D_STATE,
757
+ mamba2_d_conv=MAMBA2_D_CONV,
758
+ mamba2_expand=MAMBA2_EXPAND,
759
+ mamba2_headdim=MAMBA2_HEADDIM,
760
+ mamba2_ngroups=MAMBA2_NGROUPS,
761
+ moe_enabled=moe_enabled,
762
+ moe_num_experts=moe_num_experts,
763
+ moe_expert_dim=moe_expert_dim,
764
+ moe_capacity_factor=moe_capacity_factor,
765
+ moe_load_balance_alpha=moe_load_balance_alpha,
766
+ moe_top_k=moe_top_k,
767
+ )
768
+
769
+ if controller == "mamba3":
770
+ mamba_kwargs = dict(
771
+ mamba3_d_state=MAMBA3_D_STATE,
772
+ mamba3_expand=MAMBA3_EXPAND,
773
+ mamba3_headdim=MAMBA3_HEADDIM,
774
+ mamba3_rope_fraction=MAMBA3_ROPE_FRACTION,
775
+ moe_enabled=moe_enabled,
776
+ moe_num_experts=moe_num_experts,
777
+ moe_expert_dim=moe_expert_dim,
778
+ moe_capacity_factor=moe_capacity_factor,
779
+ moe_load_balance_alpha=moe_load_balance_alpha,
780
+ moe_top_k=moe_top_k,
781
+ )
782
+
783
+ if controller == "cfc":
784
+ mamba_kwargs = dict(
785
+ cfc_mode=CFC_MODE,
786
+ cfc_backbone_units=CFC_BACKBONE_UNITS,
787
+ cfc_backbone_layers=CFC_BACKBONE_LAYERS,
788
+ cfc_backbone_dropout=CFC_BACKBONE_DROPOUT,
789
+ cfc_activation=CFC_ACTIVATION,
790
+ cfc_mixed_memory=CFC_MIXED_MEMORY,
791
+ cfc_residual=CFC_RESIDUAL,
792
+ moe_enabled=moe_enabled,
793
+ moe_top_k=moe_top_k,
794
+ )
795
+
796
+ if "+" in controller: # v14: hybrid chain, e.g. "mamba+cfc"
797
+ mamba_kwargs = dict(
798
+ mamba_d_state=MAMBA_D_STATE,
799
+ mamba_d_conv=MAMBA_D_CONV,
800
+ mamba_expand=MAMBA_EXPAND,
801
+ mamba2_d_state=MAMBA2_D_STATE,
802
+ mamba2_d_conv=MAMBA2_D_CONV,
803
+ mamba2_expand=MAMBA2_EXPAND,
804
+ mamba2_headdim=MAMBA2_HEADDIM,
805
+ mamba2_ngroups=MAMBA2_NGROUPS,
806
+ mamba3_d_state=MAMBA3_D_STATE,
807
+ mamba3_expand=MAMBA3_EXPAND,
808
+ mamba3_headdim=MAMBA3_HEADDIM,
809
+ mamba3_rope_fraction=MAMBA3_ROPE_FRACTION,
810
+ cfc_mode=CFC_MODE,
811
+ cfc_backbone_units=CFC_BACKBONE_UNITS,
812
+ cfc_backbone_layers=CFC_BACKBONE_LAYERS,
813
+ cfc_backbone_dropout=CFC_BACKBONE_DROPOUT,
814
+ cfc_activation=CFC_ACTIVATION,
815
+ cfc_mixed_memory=CFC_MIXED_MEMORY,
816
+ cfc_residual=CFC_RESIDUAL,
817
+ hybrid_cfc_num_blocks=HYBRID_CFC_NUM_BLOCKS,
818
+ moe_enabled=moe_enabled,
819
+ moe_top_k=moe_top_k,
820
+ )
821
+
822
+ if split_graph_enabled:
823
+ if moe_enabled:
824
+ print(
825
+ f"[{run_id}] moe_enabled=True with split_graph_enabled=True: MoE is now "
826
+ f"wired into both the parallel backbone and the sequential combiner (see "
827
+ f"split_graph_dnc.py). A combiner_variant='cfc' additionally routes its two "
828
+ f"natural input sources (backbone output, previous read vector) through "
829
+ f"per-source specialized experts when "
830
+ f"split_graph_combiner_cfc_multi_source_moe={split_graph_combiner_cfc_multi_source_moe} "
831
+ f"(see moe_layer.MultiSourceMoEBlock)."
832
+ )
833
+
834
+ # v10: variant-matched hyperparameter defaults instead of always
835
+ # reusing the Mamba-1 constants here -- harmless previously (any
836
+ # d_state/headdim is accepted), but not what the SSD paper's own
837
+ # Mamba-2 defaults are.
838
+ _sg_d_state, _sg_d_conv, _sg_expand = (
839
+ (MAMBA3_D_STATE, MAMBA_D_CONV, MAMBA3_EXPAND)
840
+ if split_graph_variant.startswith("mamba3")
841
+ else (MAMBA2_D_STATE, MAMBA2_D_CONV, MAMBA2_EXPAND)
842
+ if split_graph_variant.startswith("mamba2")
843
+ else (MAMBA_D_STATE, MAMBA_D_CONV, MAMBA_EXPAND)
844
+ )
845
+ rnn = SplitGraphDNC(
846
+ input_size=INPUT_DIM,
847
+ hidden_size=hidden_size,
848
+ nr_cells=nr_cells,
849
+ cell_size=cell_size,
850
+ read_heads=read_heads,
851
+ num_backbone_blocks=split_graph_num_blocks,
852
+ mamba_variant=split_graph_variant,
853
+ mamba_d_state=_sg_d_state,
854
+ mamba_d_conv=_sg_d_conv,
855
+ mamba_expand=_sg_expand,
856
+ mamba_headdim=split_graph_headdim,
857
+ cfc_kwargs=dict(
858
+ mode=CFC_MODE,
859
+ backbone_units=CFC_BACKBONE_UNITS,
860
+ backbone_layers=CFC_BACKBONE_LAYERS,
861
+ backbone_dropout=CFC_BACKBONE_DROPOUT,
862
+ activation=CFC_ACTIVATION,
863
+ mixed_memory=CFC_MIXED_MEMORY,
864
+ residual=CFC_RESIDUAL,
865
+ ),
866
+ combine_reads=split_graph_combine_reads,
867
+ combiner_mode=split_graph_combiner_mode,
868
+ combiner_variant=split_graph_combiner_variant,
869
+ combiner_num_blocks=split_graph_combiner_num_blocks,
870
+ independent_linears=True,
871
+ moe_enabled=moe_enabled,
872
+ moe_num_experts=moe_num_experts,
873
+ moe_expert_dim=moe_expert_dim,
874
+ moe_top_k=moe_top_k,
875
+ moe_capacity_factor=moe_capacity_factor,
876
+ moe_load_balance_alpha=moe_load_balance_alpha,
877
+ moe_cfc_multi_source=split_graph_combiner_cfc_multi_source_moe,
878
+ device=device,
879
+ ).to(device)
880
+ else:
881
+ rnn = MambaDNC(
882
+ input_size=INPUT_DIM,
883
+ hidden_size=hidden_size,
884
+ rnn_type=controller, # v7: 'lstm' (default) or 'mamba'
885
+ num_layers=1,
886
+ nr_cells=nr_cells,
887
+ cell_size=cell_size,
888
+ read_heads=read_heads,
889
+ batch_first=True,
890
+ device=device,
891
+ independent_linears=True, # Phase 1: required so Memory exposes a
892
+ # standalone write_vector_transform Linear for
893
+ # install_stochastic_write_heads to swap out. Doesn't change the
894
+ # addressing math - just which code path builds the (functionally
895
+ # equivalent) per-head transforms. See header note + Section 5 of
896
+ # Q21-Experiment-Log.md for why this makes Run 0 an anchor-by-
897
+ # equivalence rather than a bit-identical beta=0 substitute.
898
+ # v7: unaffected by the controller swap -- MambaDNC builds
899
+ # dnc.memory.Memory identically regardless of rnn_type.
900
+ **mamba_kwargs,
901
+ ).to(device)
902
+
903
+ combiner_stage_kinds = getattr(
904
+ getattr(rnn, "combiner_wrapper", None), "stage_kinds", None
905
+ ) # set only for a hybrid ("<kind>+<kind>") split-graph combiner; None otherwise
906
+ # v19: self-describing metadata for inference/ (capabilities + exact rebuild)
907
+ extra_model_config = {
908
+ "dataset_type": dataset_type,
909
+ "dataset_name": dataset.name,
910
+ "supported_dataset_types": [dataset_type], # use ["*"] for a future multi-modal model
911
+ "input_dim": INPUT_DIM,
912
+ "output_dim": TRIPLE_DIM,
913
+ "num_hidden_layers": getattr(rnn, "num_hidden_layers", None),
914
+ "split_graph_num_blocks": split_graph_num_blocks,
915
+ "split_graph_headdim": split_graph_headdim,
916
+ "split_graph_combine_reads": split_graph_combine_reads,
917
+ "split_graph_combiner_mode": split_graph_combiner_mode,
918
+ "split_graph_combiner_variant": split_graph_combiner_variant,
919
+ "split_graph_combiner_num_blocks": split_graph_combiner_num_blocks,
920
+ "split_graph_mamba_hparams": (
921
+ dict(d_state=_sg_d_state, d_conv=_sg_d_conv, expand=_sg_expand)
922
+ if split_graph_enabled
923
+ else None
924
+ ),
925
+ "split_graph_cfc_kwargs": (
926
+ dict(
927
+ mode=CFC_MODE,
928
+ backbone_units=CFC_BACKBONE_UNITS,
929
+ backbone_layers=CFC_BACKBONE_LAYERS,
930
+ backbone_dropout=CFC_BACKBONE_DROPOUT,
931
+ activation=CFC_ACTIVATION,
932
+ mixed_memory=CFC_MIXED_MEMORY,
933
+ residual=CFC_RESIDUAL,
934
+ )
935
+ if split_graph_enabled
936
+ else None
937
+ ),
938
+ "split_graph_combiner_cfc_multi_source_moe": split_graph_combiner_cfc_multi_source_moe,
939
+ }
940
+
941
+ if link_matrix_mode != "dense":
942
+ patch_link_matrix(rnn, mode=link_matrix_mode, topk=link_matrix_topk)
943
+ topk_note = f" topk={link_matrix_topk}" if link_matrix_mode == "sparse_topk" else ""
944
+ print(
945
+ f"[{run_id}] link_matrix_mode={link_matrix_mode}{topk_note} -- "
946
+ f"Static Option 1 applied to rnn.memories."
947
+ )
948
+
949
+ output_proj = nn.Linear(INPUT_DIM, TRIPLE_DIM).to(device)
950
+ dataset.set_output_proj(output_proj)
951
+
952
+ # Install the stochastic write head(s) BEFORE building the
953
+ # optimizer, so their parameters (mu_transform, logvar_transform) are
954
+ # included in optimizer.parameters(). mu_transform is initialized from
955
+ # the original deterministic write_vector_transform's weights, so at
956
+ # step 0 the sampled mean exactly matches Phase 0's write vector.
957
+ # sample=True for all beta in BETAS_TO_SWEEP now that beta=0 is dropped
958
+ # from the sweep (see header note) -- beta_target is always > 0.0 here.
959
+ stochastic_heads = install_stochastic_write_heads(
960
+ rnn, device=device, sample=(beta_target > 0.0)
961
+ )
962
+
963
+ optimizer = torch.optim.Adam(list(rnn.parameters()) + list(output_proj.parameters()), lr=LR)
964
+ # Option 3 (Concept 24/LB-17): multi-axis efficiency accounting.
965
+ # param_count is fixed for the life of this run -- static Option 2's
966
+ # nr_cells resize never changes it (every Memory sublayer is sized by
967
+ # cell_size/read_heads/input_size, never nr_cells, per the Option-2
968
+ # analysis) -- so this is a one-time computation, not per-step.
969
+ param_count = sum(p.numel() for p in rnn.parameters()) + sum(
970
+ p.numel() for p in output_proj.parameters()
971
+ )
972
+ amp_enabled = USE_AMP and device.type == "cuda"
973
+ kl_on = stochastic_heads[0].sample # False when beta==0 -> no KL/prior terms in console
974
+ print(
975
+ f"[{run_id}] params {param_count} | kl_terms={'on' if kl_on else 'off'} | amp={'on' if amp_enabled else 'off'}"
976
+ )
977
+ scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled, init_scale=AMP_INIT_SCALE)
978
+
979
+ # Dynamic-N (macro-scale): None unless dynamic_n_mode -- every call site
980
+ # below guards on `dynamic_n_ctrl is not None` so this is a true no-op
981
+ # (not just a disabled-but-present object) when the flag is off.
982
+ dynamic_n_ctrl = (
983
+ DynamicNController(
984
+ floor=dynamic_n_floor,
985
+ ceiling=dynamic_n_ceiling,
986
+ growth_factor=DYNAMIC_N_GROWTH_FACTOR,
987
+ trigger_frac=dynamic_n_trigger_frac,
988
+ ema_decay=DYNAMIC_N_EMA_DECAY,
989
+ cooldown_steps=dynamic_n_cooldown_steps,
990
+ )
991
+ if dynamic_n_mode
992
+ else None
993
+ )
994
+ dynamic_n_log_writer = None
995
+ if dynamic_n_ctrl is not None:
996
+ # Own CSV, same one-file-per-mechanism convention as
997
+ # run_{run_id}_lesson_advances.csv / _prior_snapshots.csv above.
998
+ dynamic_n_log_path = os.path.join(LOG_DIR, f"run_{run_id}_dynamic_n_growth.csv")
999
+ dynamic_n_log_file = open(dynamic_n_log_path, "a" if resuming else "w", newline="")
1000
+ dynamic_n_log_writer = csv.writer(dynamic_n_log_file)
1001
+ if not resuming:
1002
+ dynamic_n_log_writer.writerow(
1003
+ ["step", "lesson", "old_nr_cells", "new_nr_cells", "usage_ema"]
1004
+ )
1005
+
1006
+ # --- LR schedule -----------------------------------------------------
1007
+ # FIX (see chat log / Q21-Experiment-Log Section 5): CosineAnnealingLR's
1008
+ # formula -- eta_min + 0.5*(base_lr-eta_min)*(1+cos(pi*last_epoch/T_max))
1009
+ # -- is PERIODIC in last_epoch with period 2*T_max, not a one-way ramp
1010
+ # that flatlines at eta_min once last_epoch exceeds T_max. Every run
1011
+ # that trains past LR_DECAY_STEPS=20000 (i.e. every extended/switch-late
1012
+ # run in this project, since TOTAL_STEPS=80000) was therefore climbing
1013
+ # back toward LR=3e-4 after each trough at step 20000, 60000, ... and
1014
+ # peaking again at step 40000, 80000, ... -- confirmed empirically via
1015
+ # the added LR log column (observed ~2.94e-4 at step 78000, matching
1016
+ # the closed-form prediction almost exactly). The original 20,000-step
1017
+ # beta-sweep (Run 1 beta in {0.01,0.1,1.0}) is NOT affected -- it ends
1018
+ # exactly at the first trough -- but the extended beta=0 run and both
1019
+ # switch-late runs (beta=0.001, beta=0.01) trained/evaluated well past
1020
+ # that point under a partially-cycling, not monotonically-decaying, LR.
1021
+ #
1022
+ # Fix: don't use torch's stateful scheduler at all (its internal
1023
+ # last_epoch counter is also what silently carried the drift across
1024
+ # checkpoint resumes). Instead recompute LR directly from the absolute
1025
+ # step every iteration, with the step clamped at LR_DECAY_STEPS so the
1026
+ # schedule flatlines at LR_MIN once reached, exactly matching the
1027
+ # "cosine anneal from LR to LR_MIN over this many steps" comment this
1028
+ # constant always had -- it just was never enforced past that point.
1029
+ #
1030
+ # v3 addition: linear warmup 0 -> LR over the first WARMUP_STEPS,
1031
+ # before the cosine anneal (which now spans WARMUP_STEPS..LR_DECAY_STEPS
1032
+ # instead of 0..LR_DECAY_STEPS, so it still reaches LR_MIN exactly at
1033
+ # LR_DECAY_STEPS as before). Since LR is still a pure function of the
1034
+ # absolute step, this stays resume-safe for the same reason the original
1035
+ # fix was -- a resume past WARMUP_STEPS just lands in the cosine part,
1036
+ # no separate warmup state to save/restore.
1037
+ def lr_at_step(s):
1038
+ if s < WARMUP_STEPS:
1039
+ return LR * (s + 1) / WARMUP_STEPS
1040
+ s_clamped = min(s, LR_DECAY_STEPS)
1041
+ decay_span = max(1, LR_DECAY_STEPS - WARMUP_STEPS)
1042
+ progress = (s_clamped - WARMUP_STEPS) / decay_span
1043
+ return LR_MIN + 0.5 * (LR - LR_MIN) * (1 + math.cos(math.pi * progress))
1044
+
1045
+ def set_lr(step_now):
1046
+ lr_now = lr_at_step(step_now)
1047
+ for g in optimizer.param_groups:
1048
+ g["lr"] = lr_now
1049
+ return lr_now
1050
+
1051
+ step = 0
1052
+ anneal_start_step = 0 # Phase 1: absolute step the KL anneal ramp is measured from.
1053
+ # Stays 0 for a normal fresh run or a same-beta resume (anneal counted
1054
+ # from the true start, as before). Reset to the resume step below when
1055
+ # the checkpoint's beta_target differs from this run's -- i.e. a
1056
+ # deliberate deterministic(or other-beta)->this-beta switch -- so the
1057
+ # ramp restarts at 0 from the switch point instead of reading the
1058
+ # already-large absolute step and jumping straight to full beta_target
1059
+ # on the first post-switch update.
1060
+ lesson_dip_start_step = -(
1061
+ 10**9
1062
+ ) # idle at run start (so the dip ramp is already at 1.0 immediately);
1063
+ # reset to `step` on every lesson advance instead
1064
+ if resuming:
1065
+ ckpt = load_checkpoint_for_resume(resume_from, device)
1066
+ # Static Option 2 resume fix: rnn was just constructed above at
1067
+ # LESSON_NR_CELLS[0] (curriculum.lesson doesn't exist yet at that
1068
+ # point in run()). If this checkpoint is mid-schedule, the live
1069
+ # model's Memory is still the wrong size -- maybe_advance() only
1070
+ # resizes on a LIVE lesson transition, which never replays for
1071
+ # lessons already passed before this resume. Must happen BEFORE
1072
+ # rnn.load_state_dict() below, so load_state_dict fills the
1073
+ # resized module's parameters with the checkpoint's trained values
1074
+ # directly, instead of transplanting fresh-init weights that then
1075
+ # get immediately overwritten anyway.
1076
+ # Prefer the checkpoint's own recorded nr_cells (self-describing,
1077
+ # Edit A above) over recomputing from the current LESSON_NR_CELLS
1078
+ # list, in case the schedule was edited since this checkpoint was
1079
+ # produced.
1080
+ ckpt_nr_cells = ckpt.get("model_config", {}).get("nr_cells")
1081
+ if ckpt_nr_cells is None:
1082
+ ckpt_nr_cells = curriculum.lesson_nr_cells[ckpt["curriculum_lesson"]]
1083
+ print(
1084
+ f"[{run_id}] WARNING: checkpoint predates per-lesson "
1085
+ f"nr_cells recording (static Option 2) -- inferring "
1086
+ f"nr_cells={ckpt_nr_cells} from LESSON_NR_CELLS"
1087
+ f"[{ckpt['curriculum_lesson']}] instead of a recorded value."
1088
+ )
1089
+ resize_memory(rnn, ckpt_nr_cells, device=device, optimizer=optimizer)
1090
+
1091
+ rnn.load_state_dict(ckpt["rnn_state_dict"])
1092
+ output_proj.load_state_dict(ckpt["output_proj_state_dict"])
1093
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
1094
+ # v3 fix: restore GradScaler state (scale, growth tracker) instead
1095
+ # of leaving `scaler` at its freshly-constructed init_scale -- see
1096
+ # header note. `.get(...)` instead of ["..."] so a pre-v3 checkpoint
1097
+ # (saved before this key existed) still loads, just without this fix.
1098
+ if "scaler_state_dict" in ckpt:
1099
+ scaler.load_state_dict(ckpt["scaler_state_dict"])
1100
+ else:
1101
+ print(
1102
+ f"[{run_id}] WARNING: checkpoint predates GradScaler-state "
1103
+ f"saving (v3 fix) -- scaler starting fresh at "
1104
+ f"init_scale={scaler.get_scale()} instead of resuming the "
1105
+ # (continued below, unchanged)
1106
+ f"prior leg's scale."
1107
+ )
1108
+ # NOT loading scheduler_state_dict: LR is now recomputed from the
1109
+ # absolute step every iteration via set_lr() (see above), clamped
1110
+ # at LR_DECAY_STEPS -- this sidesteps the periodicity bug entirely,
1111
+ # and also means old checkpoints' scheduler_state_dict (which may
1112
+ # already reflect the buggy drifted state) is simply ignored rather
1113
+ # than needing migration.
1114
+ curriculum.lesson = ckpt["curriculum_lesson"]
1115
+ # Dynamic-N (macro-scale): restore the usage-EMA/cooldown/growth
1116
+ # history, so a resumed run doesn't silently reset the trigger's
1117
+ # sustained-saturation window back to 0 (which would just delay the
1118
+ # next growth event by however long the EMA takes to re-climb --
1119
+ # not incorrect, but not reproducible either) and so growth_history
1120
+ # stays complete across resumed legs. `.get(...)` so a checkpoint
1121
+ # saved before dynamic_n_mode existed, or a checkpoint from a
1122
+ # non-dynamic-N run, still loads -- the controller (if this run
1123
+ # even has one) just starts fresh, same as a brand-new run would.
1124
+ if dynamic_n_ctrl is not None:
1125
+ if ckpt.get("dynamic_n_state") is not None:
1126
+ dynamic_n_ctrl.load_state_dict(ckpt["dynamic_n_state"])
1127
+ else:
1128
+ print(
1129
+ f"[{run_id}] WARNING: checkpoint has no dynamic_n_state "
1130
+ f"(pre-dates Dynamic-N, or was saved by a non-dynamic-N "
1131
+ f"run) -- usage EMA/cooldown/growth_history starting "
1132
+ f"fresh instead of resuming the prior leg's."
1133
+ )
1134
+ # v5 (Phase 2): restore (mu_g, Sigma_g, last_snapshot_step) for every
1135
+ # installed stochastic write head. Without this, resuming would
1136
+ # silently continue training/evaluating against whatever prior
1137
+ # snapshot happens to sit in each freshly-constructed head's
1138
+ # zero-init buffers (i.e. silently reset to N(0,I)) instead of the
1139
+ # snapshot this checkpoint was actually trained against -- the same
1140
+ # class of bug the write_mode deterministic/sampled mismatch check
1141
+ # already exists to catch in eval_from_checkpoint.py. `.get(...)`
1142
+ # instead of ["..."] so a pre-v5 (Phase 1) checkpoint, which has no
1143
+ # prior state to restore at all, still loads -- just starting the
1144
+ # learned prior fresh at N(0,I), same as a brand-new run would.
1145
+ if "prior_state" in ckpt:
1146
+ load_prior_state(stochastic_heads, ckpt["prior_state"])
1147
+ else:
1148
+ print(
1149
+ f"[{run_id}] WARNING: checkpoint predates the learned-prior "
1150
+ f"snapshot (v5/Phase 2) - prior starting fresh at N(0,I) "
1151
+ f"instead of resuming a prior snapshot."
1152
+ )
1153
+ # v6: restore the dedicated OOD-sampling RNG's state, so a resumed
1154
+ # run's OOD walk sequence continues from exactly where the
1155
+ # pre-resume leg left off, rather than silently restarting from
1156
+ # ood_rng's fresh, seed-derived starting state (see save_checkpoint()
1157
+ # docstring). `.get(...)` so a pre-v6 checkpoint still loads --
1158
+ # ood_rng just starts fresh in that case, identical to today's
1159
+ # behavior before this fix.
1160
+ if "ood_rng_state" in ckpt:
1161
+ ood_rng.setstate(ckpt["ood_rng_state"])
1162
+ else:
1163
+ print(
1164
+ f"[{run_id}] WARNING: checkpoint predates ood_rng-state "
1165
+ f"saving (v6 fix) -- OOD sampling restarting from its "
1166
+ f"fresh, seed-derived state instead of continuing the "
1167
+ f"pre-resume leg's exact walk sequence."
1168
+ )
1169
+ restore_rng_state(ckpt["rng_state"])
1170
+ step = ckpt["step"]
1171
+ if ckpt["beta_target"] != beta_target:
1172
+ anneal_start_step = step # Phase 1: restart the ramp here
1173
+ print(
1174
+ f"[{run_id}] beta_target changed on resume "
1175
+ f"({ckpt['beta_target']} -> {beta_target}); KL anneal "
1176
+ f"restarted from step {anneal_start_step}, ramping over "
1177
+ f"the next {KL_ANNEAL_STEPS} steps."
1178
+ )
1179
+ print(
1180
+ f"[{run_id}] Resumed from {resume_from} at step {step} "
1181
+ f"(lesson {curriculum.lesson + 1}/{len(curriculum.table)}); "
1182
+ f"continuing to total_steps={total_steps}"
1183
+ )
1184
+ if step >= total_steps:
1185
+ print(
1186
+ f"[{run_id}] Checkpoint step {step} already >= total_steps "
1187
+ f"{total_steps} -- nothing to do. Raise --total-steps if you "
1188
+ f"want to extend further."
1189
+ )
1190
+
1191
+ print(
1192
+ f"\n=== [{run_id}] Training (beta_target={beta_target}, beta_mode={beta_mode}) "
1193
+ f"{'[resumed]' if resuming else ''} ==="
1194
+ )
1195
+ rnn.train()
1196
+ set_lr(step) # Phase 1 FIX: ensure correct clamped LR from the very first
1197
+ # iteration -- otherwise a resumed run's first step would briefly use
1198
+ # whatever LR was saved inside optimizer_state_dict at checkpoint time
1199
+ # (potentially still reflecting the old buggy drifted value).
1200
+ running_task_loss, running_kl_loss, running_div, running_grad_norm = 0.0, 0.0, 0, 0.0
1201
+
1202
+ # Dynamic-beta controller state (no-op / unused when beta_mode == "static").
1203
+ # Initialized post-resume so a resumed dynamic run starts its health check
1204
+ # from the just-restored scaler scale rather than a fresh-process default.
1205
+ beta_constraint_ema = 0.0
1206
+ scale_at_last_eval = scaler.get_scale()
1207
+ grad_was_finite_since_eval = True
1208
+
1209
+ t0 = time.time()
1210
+
1211
+ if torch.cuda.is_available():
1212
+ torch.cuda.reset_peak_memory_stats(device)
1213
+
1214
+ # Log addition #5: cumulative wall-clock for this run(). Starts fresh on
1215
+ # a resumed leg too (i.e. measures THIS process's elapsed time, not
1216
+ # elapsed time since the original run began across all resumed legs --
1217
+ # legs already have their own timestamped log rows if that's needed).
1218
+ t_run_start = time.time()
1219
+
1220
+ while step < total_steps:
1221
+ input_seq, target_digits, answer_mask = dataset.sample_batch(curriculum, BATCH_SIZE)
1222
+
1223
+ input_seq = input_seq.to(device, non_blocking=True)
1224
+ target_digits = target_digits.to(device, non_blocking=True)
1225
+ answer_mask = answer_mask.to(device, non_blocking=True)
1226
+ _first_nonfinite_report(input_seq, "input_seq", step)
1227
+ hidden = (None, None, None)
1228
+ optimizer.zero_grad(set_to_none=True)
1229
+
1230
+ with torch.amp.autocast("cuda", enabled=amp_enabled):
1231
+ output, hidden = rnn(input_seq, hidden, reset_experience=True)
1232
+ _first_nonfinite_report(output, "rnn_output", step)
1233
+ output = output.transpose(0, 1).contiguous() # (B, T, 92)
1234
+ output = output_proj(output) # (B, T, 90)
1235
+ task_loss = dataset.loss(output, target_digits, answer_mask)
1236
+
1237
+ _first_nonfinite_report(task_loss, "task_loss", step)
1238
+ kl_loss, kl_diag = pop_total_kl(stochastic_heads, free_bits=FREE_BITS) # Phase 1
1239
+ _first_nonfinite_report(kl_loss, "kl_loss", step)
1240
+ moe_aux_loss, moe_diag = (
1241
+ pop_total_moe_aux_loss(rnn.moe_layers)
1242
+ if moe_enabled
1243
+ else (torch.zeros((), device=device), {})
1244
+ ) # v8 (Option 4): Switch-style load-balancing loss, already scaled
1245
+ # by moe_load_balance_alpha inside SwitchMoE.pop_aux_loss() -- no
1246
+ # extra weighting applied here, unlike beta_eff*kl_loss below.
1247
+ global_ramp = min(1.0, (step - anneal_start_step) / max(1, KL_ANNEAL_STEPS))
1248
+ lesson_dip_ramp = min(1.0, (step - lesson_dip_start_step) / max(1, LESSON_KL_DIP_STEPS))
1249
+ beta_eff = beta_target * global_ramp * lesson_dip_ramp
1250
+ loss = (
1251
+ task_loss + beta_eff * kl_loss + moe_aux_loss
1252
+ ) # Phase 1 + Option 4: L = L_task + beta*L_KL + L_moe_aux
1253
+ scaler.scale(loss).backward()
1254
+ scaler.unscale_(optimizer)
1255
+ # Log addition #2: capture the pre-clip gradient norm instead of
1256
+ # discarding clip_grad_norm_'s return value. Gives a β=0 baseline
1257
+ # gradient-scale trajectory to compare a future β>0 switch-in
1258
+ # against, useful for telling a genuine instability apart from the
1259
+ # expected one-off "shock" when sampling noise is switched on.
1260
+ grad_norm = torch.nn.utils.clip_grad_norm_(rnn.parameters(), max_norm=10.0)
1261
+ if not math.isfinite(float(grad_norm)):
1262
+ grad_was_finite_since_eval = False
1263
+ # clip_grad_norm_ computes clip_coef = max_norm/(total_norm+eps)
1264
+ # and multiplies EVERY gradient by it. A NaN total_norm therefore
1265
+ # NaNs out every gradient in the model, including ones that were
1266
+ # finite. GradScaler's found_inf was recorded during unscale_,
1267
+ # BEFORE this clip, so it can be clean while the post-clip grads
1268
+ # are all NaN -- scaler.step() then applies them and the weights
1269
+ # are permanently poisoned, which is exactly the one-way collapse
1270
+ # at step 23700. Zero the grads and skip the step instead: this
1271
+ # costs one wasted batch and is fully recoverable.
1272
+ optimizer.zero_grad(set_to_none=True)
1273
+ scaler.update()
1274
+ else:
1275
+ scaler.step(optimizer)
1276
+ scaler.update()
1277
+ # Log addition #3: AMP loss-scale value. A collapsing/repeatedly
1278
+ # halved scale is the standard AMP symptom of inf/NaN gradients --
1279
+ # the exp()/logvar path in the stochastic write head is the most
1280
+ # numerically fragile part of this pipeline, so a clean β=0 baseline
1281
+ # of scaler behavior is useful context once β>0 introduces that path.
1282
+ amp_scale = scaler.get_scale()
1283
+ running_task_loss += task_loss.item()
1284
+ running_kl_loss += kl_loss.item()
1285
+ running_div += dataset.diversity(output.detach(), target_digits, answer_mask)
1286
+ running_grad_norm += float(grad_norm)
1287
+ step += 1
1288
+ current_lr = set_lr(step) # Phase 1 FIX: clamped manual LR, replaces scheduler.step()
1289
+
1290
+ # Dynamic-N (usage-triggered), macro-scale: record this just-finished
1291
+ # episode's memory-usage saturation and, if the trigger has been
1292
+ # sustained past cooldown, resize nr_cells before the NEXT episode's
1293
+ # forward pass. Placed here -- after scaler.step()/scaler.update()
1294
+ # above, using `hidden` from the forward pass that just completed --
1295
+ # so a resize never touches a live autograd graph: this episode's
1296
+ # backward pass and optimizer step are both already done, and the
1297
+ # very next `hidden = (None, None, None)` at the top of the loop
1298
+ # means nothing carries the old size forward anyway (same "no
1299
+ # cross-episode memory content to preserve" fact static Option 2
1300
+ # relies on -- see dynamic_memory_resize.py's module docstring).
1301
+ # This record()/should_grow() split (see dynamic_n_controller.py) is
1302
+ # deliberately timing-agnostic -- it's what lets a future mid-episode
1303
+ # (micro-scale) variant reuse the same decision core fed from a
1304
+ # per-timestep usage reading instead, without redesigning the
1305
+ # EMA/cooldown logic.
1306
+ if dynamic_n_ctrl is not None:
1307
+ with torch.no_grad():
1308
+ # mem_hidden's shape differs by controller: MambaDNC's
1309
+ # _init_hidden returns mhx as a bare dict for rnn_type!=
1310
+ # 'mamba' (deferring to stock dnc.DNC._init_hidden, share_
1311
+ # memory_between_layers=True case), but wraps it in a
1312
+ # single-entry list for rnn_type=='mamba' (see
1313
+ # mamba_controller.py's _init_hidden memory-state branch,
1314
+ # which is otherwise byte-identical to upstream). Both cases
1315
+ # are layer 0 of a single shared Memory in this project, so
1316
+ # this just picks the right unwrap for whichever controller
1317
+ # is active rather than hardcoding one shape.
1318
+ mem_hidden = hidden[1]
1319
+ mhx = mem_hidden[0] if isinstance(mem_hidden, list) else mem_hidden
1320
+ usage = mhx["usage_vector"].float()
1321
+ frac_saturated = (usage > DYNAMIC_N_USAGE_HIGH).float().mean().item()
1322
+ dynamic_n_ctrl.record(frac_saturated)
1323
+ last_frac_saturated = frac_saturated
1324
+ current_nr_cells = rnn.memories[0].nr_cells
1325
+ new_n = dynamic_n_ctrl.should_grow(current_nr_cells)
1326
+ if new_n is not None:
1327
+ ema_at_trigger = dynamic_n_ctrl.ema # captured before mark_grown() resets it to 0.0
1328
+ resize_memory(rnn, new_n, device=device, optimizer=optimizer)
1329
+ dynamic_n_ctrl.mark_grown(step, current_nr_cells, new_n)
1330
+ print(
1331
+ f"[{run_id}] Step {step} Dynamic-N growth: nr_cells "
1332
+ f"{current_nr_cells} -> {new_n} (usage-saturation EMA "
1333
+ f"{ema_at_trigger:.3f} had reached the "
1334
+ f"{dynamic_n_ctrl.trigger_frac:.2f} trigger, sustained "
1335
+ f"past cooldown)"
1336
+ )
1337
+ assert dynamic_n_log_writer is not None and dynamic_n_log_file is not None
1338
+ dynamic_n_log_writer.writerow(
1339
+ [
1340
+ step,
1341
+ curriculum.lesson + 1,
1342
+ current_nr_cells,
1343
+ new_n,
1344
+ ema_at_trigger,
1345
+ ]
1346
+ )
1347
+ dynamic_n_log_file.flush()
1348
+
1349
+ if step % LOG_EVERY == 0:
1350
+ elapsed = time.time() - t0
1351
+ t0 = time.time()
1352
+ total_elapsed = time.time() - t_run_start # log addition #5
1353
+ avg_task = running_task_loss / LOG_EVERY
1354
+ avg_kl = running_kl_loss / LOG_EVERY
1355
+ avg_div = running_div / LOG_EVERY
1356
+ avg_grad_norm = running_grad_norm / LOG_EVERY
1357
+ kl_contrib = (
1358
+ beta_eff * avg_kl
1359
+ ) # actual beta*L_KL added to total loss, vs. avg_kl (pre-beta, raw clamped sum)
1360
+ gpu_mem_peak_mb = (
1361
+ torch.cuda.max_memory_allocated(device) / 1e6 if torch.cuda.is_available() else 0.0
1362
+ )
1363
+ dyn_n_str = ""
1364
+ if dynamic_n_ctrl is not None:
1365
+ dyn_n_str = (
1366
+ f"| dynN[nr_cells {rnn.memories[0].nr_cells} frac_sat {last_frac_saturated:.3f} "
1367
+ f"ema {dynamic_n_ctrl.ema:.3f} cooldown {dynamic_n_ctrl.cooldown_remaining}] "
1368
+ )
1369
+
1370
+ parts = [
1371
+ f"[{run_id}] Step {step}/{total_steps} | Lesson {curriculum.lesson + 1}/{len(curriculum.table)}",
1372
+ f"L_task {avg_task:.4f}",
1373
+ ]
1374
+ if kl_on:
1375
+ parts += [
1376
+ f"L_KL {avg_kl:.4f}",
1377
+ f"beta {beta_eff:.4f}",
1378
+ f"KL_contrib {kl_contrib:.4f}",
1379
+ f"KL[mean {kl_diag['kl_mean']:.4f} max {kl_diag['kl_max']:.4f}]",
1380
+ f"clamp_frac {kl_diag['clamp_frac']:.4f}",
1381
+ f"floor_frac {kl_diag['floor_frac']:.4f}",
1382
+ f"snapshot_step {kl_diag['snapshot_step']}",
1383
+ ]
1384
+ parts += [
1385
+ f"diversity {avg_div:.2f}",
1386
+ f"{LOG_EVERY / elapsed:.2f} steps/s",
1387
+ f"LR {current_lr:.6f}",
1388
+ f"grad_norm {avg_grad_norm:.4f}",
1389
+ ]
1390
+ if amp_enabled:
1391
+ parts.append(f"amp_scale {amp_scale:.1f}")
1392
+ parts.append(f"gpu_mem_peak_mb {gpu_mem_peak_mb:.1f}")
1393
+ if moe_enabled:
1394
+ parts += [
1395
+ f"moe_aux {float(moe_aux_loss.detach()):.4f}",
1396
+ f"moe_cv_load {moe_diag.get('moe_cv_load', 0.0):.4f}",
1397
+ f"moe_max_load_frac {moe_diag.get('moe_max_load_frac', 0.0):.4f}",
1398
+ ]
1399
+ if dyn_n_str:
1400
+ parts.append(dyn_n_str.strip(" |"))
1401
+ print(" | ".join(parts))
1402
+
1403
+ log_writer.writerow(
1404
+ [
1405
+ step,
1406
+ curriculum.lesson + 1,
1407
+ beta_eff,
1408
+ avg_task,
1409
+ avg_kl,
1410
+ kl_contrib,
1411
+ avg_task + kl_contrib,
1412
+ avg_div,
1413
+ kl_diag["kl_mean"],
1414
+ kl_diag["kl_max"],
1415
+ kl_diag["kl_min"],
1416
+ kl_diag["kl_std"],
1417
+ kl_diag["clamp_frac"],
1418
+ current_lr,
1419
+ avg_grad_norm,
1420
+ amp_scale,
1421
+ total_elapsed,
1422
+ kl_diag["snapshot_step"],
1423
+ gpu_mem_peak_mb,
1424
+ param_count,
1425
+ float(moe_aux_loss.detach()),
1426
+ moe_diag.get("moe_cv_importance", 0.0),
1427
+ moe_diag.get("moe_cv_load", 0.0),
1428
+ moe_diag.get("moe_max_load_frac", 0.0),
1429
+ ]
1430
+ )
1431
+ if torch.cuda.is_available():
1432
+ torch.cuda.reset_peak_memory_stats(device) # so next window's peak isn't cumulative
1433
+ log_file.flush()
1434
+ running_task_loss, running_kl_loss, running_div, running_grad_norm = 0.0, 0.0, 0, 0.0
1435
+
1436
+ if kl_on and step % PRIOR_SNAPSHOT_EVERY == 0:
1437
+ # v5 (Phase 2): refit (mu_g, Sigma_g) from the writes
1438
+ # accumulated since the last snapshot, freeze the result into
1439
+ # every stochastic write head's buffers, and log a summary.
1440
+ # This call -- and only this call -- is where the prior's
1441
+ # non-locality lives (see stochastic_write_head.py v2,
1442
+ # update_prior_snapshot); it happens here, outside the
1443
+ # per-step backward pass above, never inside it. This is a
1444
+ # brand-new console line and a brand-new log file -- it does
1445
+ # not touch the per-step console line above, or any other
1446
+ # existing print/log call in this loop.
1447
+ snap_diag = update_all_prior_snapshots(
1448
+ stochastic_heads,
1449
+ step,
1450
+ min_logvar=PRIOR_MIN_LOGVAR,
1451
+ max_logvar=PRIOR_MAX_LOGVAR,
1452
+ )
1453
+ print(
1454
+ f"[{run_id}] Step {step} prior snapshot updated | "
1455
+ f"||mu_g|| {snap_diag['mu_g_norm']:.4f} | "
1456
+ f"diag(Sigma_g) mean/min/max {snap_diag['sigma_g_mean']:.4f}/"
1457
+ f"{snap_diag['sigma_g_min']:.4f}/{snap_diag['sigma_g_max']:.4f} | "
1458
+ f"trace(Sigma_g) {snap_diag['trace_sigma_g']:.4f} | "
1459
+ f"n_samples {snap_diag['n_samples']} "
1460
+ f" | raw_var mean/max {snap_diag['raw_var_mean']:.3f}/{snap_diag['raw_var_max']:.3f} hi_frac {snap_diag['raw_hi_frac']:.2f}"
1461
+ )
1462
+ prior_log_writer.writerow(
1463
+ [
1464
+ step,
1465
+ snap_diag["snapshot_step"],
1466
+ snap_diag["mu_g_norm"],
1467
+ snap_diag["sigma_g_mean"],
1468
+ snap_diag["sigma_g_min"],
1469
+ snap_diag["sigma_g_max"],
1470
+ snap_diag["trace_sigma_g"],
1471
+ snap_diag["n_samples"],
1472
+ snap_diag["raw_var_mean"],
1473
+ snap_diag["raw_var_max"],
1474
+ snap_diag["raw_hi_frac"],
1475
+ ]
1476
+ )
1477
+ prior_log_file.flush()
1478
+
1479
+ if step % EVAL_EVERY == 0:
1480
+ pre_advance_lesson = curriculum.lesson # capture before maybe_advance can bump it
1481
+ _, id_triple_acc, id_perfect_frac = curriculum.maybe_advance(
1482
+ rnn, device, step=step, optimizer=optimizer
1483
+ )
1484
+
1485
+ if curriculum.lesson != pre_advance_lesson:
1486
+ lesson_dip_start_step = step
1487
+ if kl_on:
1488
+ print(
1489
+ f"[{run_id}] Step {step} lesson advance {pre_advance_lesson + 1}->"
1490
+ f"{curriculum.lesson + 1}: KL briefly dipping and ramping back up "
1491
+ f"over the next {LESSON_KL_DIP_STEPS} steps (global anneal unaffected)."
1492
+ )
1493
+ if beta_mode == "dynamic":
1494
+ # Hard safety ceiling -- enforced every eval cycle unconditionally,
1495
+ # independent of the health gate below. Without this, starting
1496
+ # beta_target above [BETA_CTRL_MIN, BETA_CTRL_MAX] (e.g. testing
1497
+ # 0.001) could ride out the whole KL_ANNEAL_STEPS window unclamped
1498
+ # if early-training grad instability keeps failing the health
1499
+ # check on every eval cycle -- i.e. behave identically to static
1500
+ # mode at the known-collapse value, silently. This is a clamp,
1501
+ # not a rate-limited step: it can move beta_target more than
1502
+ # BETA_CTRL_LR in one cycle, on purpose.
1503
+ if not (BETA_CTRL_MIN <= beta_target <= BETA_CTRL_MAX):
1504
+ clamped = float(np.clip(beta_target, BETA_CTRL_MIN, BETA_CTRL_MAX))
1505
+ print(
1506
+ f"[{run_id}] Step {step} beta_target {beta_target:.6f} outside "
1507
+ f"[{BETA_CTRL_MIN}, {BETA_CTRL_MAX}] -- hard-clamping to {clamped:.6f} "
1508
+ f"(unconditional, ignores health gate below)"
1509
+ )
1510
+ beta_target = clamped
1511
+
1512
+ scale_backed_off = scaler.get_scale() < scale_at_last_eval
1513
+ healthy = grad_was_finite_since_eval and not scale_backed_off
1514
+ if healthy:
1515
+ acc_frac = id_triple_acc / 100.0
1516
+ raw_constraint = acc_frac - beta_ctrl_acc_target
1517
+ beta_constraint_ema = (
1518
+ BETA_CTRL_EMA_DECAY * beta_constraint_ema
1519
+ + (1 - BETA_CTRL_EMA_DECAY) * raw_constraint
1520
+ )
1521
+ beta_target = float(
1522
+ np.clip(
1523
+ beta_target + BETA_CTRL_LR * beta_constraint_ema,
1524
+ BETA_CTRL_MIN,
1525
+ BETA_CTRL_MAX,
1526
+ )
1527
+ )
1528
+ print(
1529
+ f"[{run_id}] Step {step} dynamic-beta update | "
1530
+ f"acc {acc_frac:.4f} target {beta_ctrl_acc_target:.4f} | "
1531
+ f"constraint_ema {beta_constraint_ema:.4f} | beta -> {beta_target:.6f}"
1532
+ )
1533
+ else:
1534
+ print(
1535
+ f"[{run_id}] Step {step} dynamic-beta update SKIPPED "
1536
+ f"(grad_finite={grad_was_finite_since_eval}, "
1537
+ f"scale_backoff={scale_backed_off}) | beta stays {beta_target:.6f}"
1538
+ )
1539
+ scale_at_last_eval = scaler.get_scale()
1540
+ grad_was_finite_since_eval = True
1541
+
1542
+ ood_field_log: dict[tuple[int, int], list[int]] = {}
1543
+ ood_triple_acc, ood_perfect_frac, ood_hop_breakdown = dataset.evaluate_ood(
1544
+ rnn,
1545
+ device,
1546
+ num_episodes=OOD_EVAL_EPISODES_PERIODIC,
1547
+ rng=ood_rng,
1548
+ field_log=ood_field_log,
1549
+ )
1550
+
1551
+ ood_log_writer.writerow(
1552
+ [
1553
+ step,
1554
+ curriculum.lesson + 1,
1555
+ id_triple_acc,
1556
+ id_perfect_frac,
1557
+ ood_triple_acc,
1558
+ ood_perfect_frac,
1559
+ id_triple_acc - ood_triple_acc,
1560
+ id_perfect_frac - ood_perfect_frac,
1561
+ ]
1562
+ )
1563
+ ood_log_file.flush()
1564
+ dataset.write_field_log(
1565
+ field_log_writer, field_log_file, step, curriculum.lesson + 1, "ood", ood_field_log
1566
+ )
1567
+ print(
1568
+ f"[{run_id}] Step {step} periodic OOD check: "
1569
+ f"ID {id_triple_acc:.2f}% | OOD {ood_triple_acc:.2f}% | "
1570
+ f"offset {id_triple_acc - ood_triple_acc:.2f}"
1571
+ )
1572
+ for hops, (acc, pf, n) in sorted(ood_hop_breakdown.items()):
1573
+ hop_log_writer.writerow([step, curriculum.lesson + 1, "ood", hops, acc, pf, n])
1574
+ hop_log_file.flush()
1575
+
1576
+ # Functional-usage check: same lesson distribution the ID eval
1577
+ # above just used (pre_advance_lesson, not curriculum.lesson --
1578
+ # this call may have just advanced it).
1579
+ ablated_triple_acc, ablated_perfect_frac = dataset.evaluate_id_ablated(
1580
+ rnn, device, curriculum, pre_advance_lesson
1581
+ )
1582
+
1583
+ mem_check_writer.writerow(
1584
+ [
1585
+ step,
1586
+ pre_advance_lesson + 1,
1587
+ id_triple_acc,
1588
+ id_perfect_frac,
1589
+ ablated_triple_acc,
1590
+ ablated_perfect_frac,
1591
+ id_triple_acc - ablated_triple_acc,
1592
+ id_perfect_frac - ablated_perfect_frac,
1593
+ ]
1594
+ )
1595
+ mem_check_file.flush()
1596
+ print(
1597
+ f"[{run_id}] Step {step} memory-dependency check: "
1598
+ f"ID (memory on) {id_triple_acc:.2f}% | ablated (memory off) "
1599
+ f"{ablated_triple_acc:.2f}% | dependency {id_triple_acc - ablated_triple_acc:.2f}"
1600
+ )
1601
+
1602
+ if combiner_stage_kinds is not None:
1603
+ # Same pattern as the memory-dependency check above, but
1604
+ # ablating one combiner stage (e.g. the CfC block in a
1605
+ # "mamba+cfc" combiner) at a time, so each stage's actual
1606
+ # contribution to accuracy is measured directly instead of
1607
+ # inferred from loss curves / plateau shape.
1608
+ for stage_idx, stage_kind in enumerate(combiner_stage_kinds):
1609
+ stage_triple_acc, stage_perfect_frac = (
1610
+ dataset.evaluate_id_combiner_stage_ablated(
1611
+ rnn, device, curriculum, pre_advance_lesson, skip_stages={stage_idx}
1612
+ )
1613
+ )
1614
+ combiner_stage_log_writer.writerow(
1615
+ [
1616
+ step,
1617
+ pre_advance_lesson + 1,
1618
+ stage_idx,
1619
+ stage_kind,
1620
+ id_triple_acc,
1621
+ id_perfect_frac,
1622
+ stage_triple_acc,
1623
+ stage_perfect_frac,
1624
+ id_triple_acc - stage_triple_acc,
1625
+ id_perfect_frac - stage_perfect_frac,
1626
+ ]
1627
+ )
1628
+ combiner_stage_log_file.flush()
1629
+ print(
1630
+ f"[{run_id}] Step {step} combiner-stage-dependency check "
1631
+ f"[{stage_idx}:{stage_kind}]: ID (full) {id_triple_acc:.2f}% | "
1632
+ f"ablated ({stage_kind} off) {stage_triple_acc:.2f}% | "
1633
+ f"dependency {id_triple_acc - stage_triple_acc:.2f}"
1634
+ )
1635
+
1636
+ if getattr(dataset, "modalities", None):
1637
+ for modality in dataset.modalities:
1638
+ m_acc, m_pf = dataset.evaluate_modality_ablated(
1639
+ rnn, device, curriculum, pre_advance_lesson, modality
1640
+ )
1641
+ modality_log_writer.writerow(
1642
+ [
1643
+ step,
1644
+ pre_advance_lesson + 1,
1645
+ modality,
1646
+ id_triple_acc,
1647
+ id_perfect_frac,
1648
+ m_acc,
1649
+ m_pf,
1650
+ id_triple_acc - m_acc,
1651
+ id_perfect_frac - m_pf,
1652
+ ]
1653
+ )
1654
+ modality_log_file.flush()
1655
+ print(
1656
+ f"[{run_id}] Step {step} modality-dependency check "
1657
+ f"[{modality}]: full {id_triple_acc:.2f}% | ablated ({modality} off) "
1658
+ f"{m_acc:.2f}% | dependency {id_triple_acc - m_acc:.2f}"
1659
+ )
1660
+
1661
+ if step % checkpoint_every == 0 or step == total_steps:
1662
+ ckpt_path = os.path.join(CHECKPOINT_DIR, f"{run_id}_step{step}.pt")
1663
+ save_checkpoint(
1664
+ ckpt_path,
1665
+ rnn,
1666
+ output_proj,
1667
+ stochastic_heads,
1668
+ optimizer,
1669
+ curriculum,
1670
+ step,
1671
+ beta_target,
1672
+ run_id,
1673
+ scaler,
1674
+ ood_rng,
1675
+ controller_type=controller,
1676
+ link_matrix_mode=link_matrix_mode,
1677
+ link_matrix_topk=link_matrix_topk,
1678
+ dynamic_n_mode=dynamic_n_mode,
1679
+ dynamic_n_state=(
1680
+ dynamic_n_ctrl.state_dict() if dynamic_n_ctrl is not None else None
1681
+ ),
1682
+ moe_enabled=moe_enabled,
1683
+ moe_num_experts=moe_num_experts,
1684
+ moe_expert_dim=moe_expert_dim,
1685
+ moe_capacity_factor=moe_capacity_factor,
1686
+ moe_load_balance_alpha=moe_load_balance_alpha,
1687
+ moe_top_k=moe_top_k,
1688
+ split_graph_enabled=split_graph_enabled,
1689
+ split_graph_variant=split_graph_variant,
1690
+ extra_model_config=extra_model_config,
1691
+ )
1692
+ latest_path = os.path.join(CHECKPOINT_DIR, f"{run_id}_latest.pt")
1693
+ shutil.copyfile(ckpt_path, latest_path)
1694
+
1695
+ print(f"\n[{run_id}] Training complete. Final evaluation on training-distribution lesson:")
1696
+ _, id_triple_acc, id_perfect_frac = curriculum.maybe_advance(
1697
+ rnn, device, step=step, optimizer=optimizer
1698
+ )
1699
+
1700
+ print(f"\n[{run_id}] Generalization test:")
1701
+
1702
+ ood_triple_acc, ood_perfect_frac, ood_hop_breakdown = dataset.evaluate_ood(
1703
+ rnn,
1704
+ device,
1705
+ num_episodes=OOD_EVAL_EPISODES,
1706
+ rng=ood_rng,
1707
+ verbose_n=10,
1708
+ )
1709
+
1710
+ ood_offset_triple = id_triple_acc - ood_triple_acc
1711
+ ood_offset_perfect = id_perfect_frac - ood_perfect_frac
1712
+
1713
+ ood_log_writer.writerow(
1714
+ [
1715
+ step,
1716
+ curriculum.lesson + 1,
1717
+ id_triple_acc,
1718
+ id_perfect_frac,
1719
+ ood_triple_acc,
1720
+ ood_perfect_frac,
1721
+ ood_offset_triple,
1722
+ ood_offset_perfect,
1723
+ ]
1724
+ )
1725
+ ood_log_file.flush()
1726
+
1727
+ for hops, (acc, pf, n) in sorted(ood_hop_breakdown.items()):
1728
+ hop_log_writer.writerow([step, curriculum.lesson + 1, "ood", hops, acc, pf, n])
1729
+ hop_log_file.flush()
1730
+
1731
+ summary = {
1732
+ "run_id": run_id,
1733
+ "beta_target": beta_target,
1734
+ "id_triple_acc": id_triple_acc,
1735
+ "id_perfect_frac": id_perfect_frac,
1736
+ "ood_triple_acc": ood_triple_acc,
1737
+ "ood_perfect_frac": ood_perfect_frac,
1738
+ "ood_offset_triple": ood_offset_triple,
1739
+ "ood_offset_perfect": ood_offset_perfect,
1740
+ "total_elapsed_sec": time.time() - t_run_start,
1741
+ }
1742
+ print(f"\n[{run_id}] SUMMARY: {summary}")
1743
+
1744
+ log_writer.writerow([])
1745
+ log_writer.writerow(["SUMMARY"] + list(summary.keys()))
1746
+ log_writer.writerow([""] + list(summary.values()))
1747
+ log_file.close()
1748
+ ood_log_file.close()
1749
+ lesson_log_file.close()
1750
+ prior_log_file.close()
1751
+ field_log_file.close() # v16
1752
+ combiner_stage_log_file.close()
1753
+ modality_log_file.close()
1754
+ if dynamic_n_ctrl is not None:
1755
+ dynamic_n_log_file.close()
1756
+
1757
+ return summary
1758
+
1759
+
1760
+ # ==========================================
1761
+ # 11. GATE
1762
+ # ==========================================
1763
+ if __name__ == "__main__":
1764
+ import argparse
1765
+ import sys
1766
+
1767
+ parser = argparse.ArgumentParser(
1768
+ description="Phase 2 KL sweep (learned, periodically-snapshotted "
1769
+ "prior). Positional beta for single-beta mode; "
1770
+ "no beta runs the full BETAS_TO_SWEEP list sequentially."
1771
+ )
1772
+ parser.add_argument(
1773
+ "beta",
1774
+ nargs="?",
1775
+ type=float,
1776
+ default=None,
1777
+ help=f"beta to run, one of {BETAS_TO_SWEEP}. Omit to run the full sweep sequentially.",
1778
+ )
1779
+ parser.add_argument(
1780
+ "--resume",
1781
+ type=str,
1782
+ default=None,
1783
+ help="path to a checkpoint .pt to resume from (requires --beta / positional beta "
1784
+ "to also be given, so run_id/beta_target can be matched against the checkpoint). "
1785
+ "Training continues from the checkpoint's step up to TOTAL_STEPS.",
1786
+ )
1787
+ parser.add_argument(
1788
+ "--run-id-suffix",
1789
+ type=str,
1790
+ default=None,
1791
+ help="appended to the derived run_id (e.g. 'switch') so this run's checkpoints "
1792
+ "(checkpoints/beta_XXX<suffix>_stepN.pt) and log "
1793
+ "(logs/run_beta_XXX<suffix>.csv) don't collide with an existing run "
1794
+ "that used the same beta value -- e.g. resuming a beta=0 checkpoint into a "
1795
+ "beta=0.01 run would otherwise reuse the same run_id/files as a prior plain "
1796
+ "beta=0.01 sweep run.",
1797
+ )
1798
+ parser.add_argument(
1799
+ "--seed",
1800
+ type=int,
1801
+ default=SEED,
1802
+ help="random seed for torch/random/numpy (default: SEED module constant)",
1803
+ )
1804
+ parser.add_argument(
1805
+ "--controller",
1806
+ type=str,
1807
+ default=CONTROLLER_TYPE,
1808
+ choices=[
1809
+ "lstm",
1810
+ "mamba",
1811
+ "mamba2",
1812
+ "mamba3",
1813
+ "cfc",
1814
+ "mamba+cfc",
1815
+ "mamba2+cfc",
1816
+ "mamba3+cfc",
1817
+ ],
1818
+ help="DNC controller type. 'lstm' (default) "
1819
+ "reproduces Phase 2 exactly. 'mamba' swaps in the "
1820
+ "Mamba-1 controller from mamba_controller.py. "
1821
+ "'mamba2' swaps in the standalone, "
1822
+ "per-timestep Mamba-2 controller from "
1823
+ "mamba_controller/mamba2_controller.py - distinct "
1824
+ "from --split-graph-variant=mamba2, which is a "
1825
+ "parallel whole-sequence backbone, not an "
1826
+ "interleaved controller. Both require mamba-ssm.",
1827
+ )
1828
+ parser.add_argument(
1829
+ "--beta-mode",
1830
+ type=str,
1831
+ default=BETA_MODE,
1832
+ choices=["static", "dynamic"],
1833
+ help="'static' (default): beta_target is fixed for the run, "
1834
+ "current sweep behavior, unchanged. 'dynamic': beta_target "
1835
+ "starts at the given positional beta and is then adjusted "
1836
+ "every EVAL_EVERY steps by a task-accuracy-constrained "
1837
+ "controller, bounded to [BETA_CTRL_MIN, BETA_CTRL_MAX].",
1838
+ )
1839
+ parser.add_argument(
1840
+ "--total-steps",
1841
+ type=int,
1842
+ default=None,
1843
+ help="Override the training loop's stopping point for a "
1844
+ "cheap pilot run (e.g. the seed-0/seed-2, short-budget staging pass "
1845
+ "suggested before committing to the full 4-seed x 3-config x "
1846
+ f"{TOTAL_STEPS}-step sweep). Defaults to the module constant "
1847
+ f"TOTAL_STEPS ({TOTAL_STEPS}) if omitted. Does NOT change "
1848
+ "LR_DECAY_STEPS -- a pilot run still anneals on the full schedule.",
1849
+ )
1850
+ parser.add_argument(
1851
+ "--checkpoint-every",
1852
+ type=int,
1853
+ default=None,
1854
+ help="Override how often (in steps) a periodic safety checkpoint "
1855
+ "is saved, in addition to the end-of-run checkpoint. Defaults "
1856
+ f"to the module constant CHECKPOINT_EVERY ({CHECKPOINT_EVERY}) "
1857
+ "if omitted.",
1858
+ )
1859
+ parser.add_argument(
1860
+ "--link-matrix-mode",
1861
+ type=str,
1862
+ default=LINK_MATRIX_MODE,
1863
+ choices=["dense", "ablated", "sparse_topk"],
1864
+ help="Static Option 1: link-matrix ablation/sparsification at "
1865
+ "fixed N. 'dense' (default): unchanged Memory behavior. "
1866
+ "'ablated': link matrix never updated (temporal addressing "
1867
+ "contributes nothing). 'sparse_topk': link matrix updated "
1868
+ "as normal, then each row keeps only its --link-matrix-topk "
1869
+ "largest-magnitude entries.",
1870
+ )
1871
+ parser.add_argument(
1872
+ "--link-matrix-topk",
1873
+ type=int,
1874
+ default=LINK_MATRIX_TOPK,
1875
+ help="Required when --link-matrix-mode=sparse_topk: how many "
1876
+ "entries each link-matrix row keeps per step.",
1877
+ )
1878
+ parser.add_argument(
1879
+ "--isolate-link-ablation",
1880
+ action="store_true",
1881
+ help="Hold nr_cells fixed at MODEL_NR_CELLS for the whole run "
1882
+ "(overrides LESSON_NR_CELLS to a constant list), so Option "
1883
+ "2's curriculum-indexed resize never fires. Use this for "
1884
+ "the isolated Option-1 run the roadmap's ordering requires "
1885
+ "-- do not combine with Option 2 in the same run.",
1886
+ )
1887
+ parser.add_argument(
1888
+ "--dynamic-n-mode",
1889
+ action="store_true",
1890
+ help="Dynamic-N, macro-scale: usage-triggered nr_cells growth "
1891
+ "between episodes, instead of LESSON_NR_CELLS's "
1892
+ "curriculum-indexed lookup (which this disables, same "
1893
+ "isolation as --isolate-link-ablation). Model starts at "
1894
+ "--dynamic-n-floor and grows toward --dynamic-n-ceiling "
1895
+ "whenever memory-usage saturation stays above "
1896
+ "--dynamic-n-trigger-frac past the post-growth cooldown.",
1897
+ )
1898
+ parser.add_argument(
1899
+ "--dynamic-n-floor",
1900
+ type=int,
1901
+ default=DYNAMIC_N_FLOOR,
1902
+ help="Starting (and minimum) nr_cells under --dynamic-n-mode.",
1903
+ )
1904
+ parser.add_argument(
1905
+ "--dynamic-n-ceiling",
1906
+ type=int,
1907
+ default=DYNAMIC_N_CEILING,
1908
+ help="Hard ceiling nr_cells will never grow past under "
1909
+ "--dynamic-n-mode (Strategy doc: sparse-link-matrix "
1910
+ "approximation only validated to N=512).",
1911
+ )
1912
+ parser.add_argument(
1913
+ "--dynamic-n-trigger-frac",
1914
+ type=float,
1915
+ default=DYNAMIC_N_TRIGGER_FRAC,
1916
+ help="Growth fires when the EMA fraction of memory cells with "
1917
+ "usage above DYNAMIC_N_USAGE_HIGH exceeds this, past cooldown.",
1918
+ )
1919
+ parser.add_argument(
1920
+ "--dynamic-n-cooldown-steps",
1921
+ type=int,
1922
+ default=DYNAMIC_N_COOLDOWN_STEPS,
1923
+ help="Steps to wait after a growth event before another can fire, "
1924
+ "so the model's addressing policy gets time to re-settle.",
1925
+ )
1926
+ parser.add_argument(
1927
+ "--moe",
1928
+ action="store_true",
1929
+ help="Alternative Phase 3, Step 2, Option 4: interleave external "
1930
+ "SwitchMoE blocks between Mamba controller blocks (mamba_controller.py "
1931
+ "/ moe_layer.py).",
1932
+ )
1933
+ parser.add_argument(
1934
+ "--moe-num-experts",
1935
+ type=int,
1936
+ default=MOE_NUM_EXPERTS,
1937
+ help="Number of experts per MoE block (>=4 enforced by SwitchMoE; "
1938
+ "roadmap recommends 8+, per Dead-End #45).",
1939
+ )
1940
+ parser.add_argument(
1941
+ "--moe-expert-dim",
1942
+ type=int,
1943
+ default=MOE_EXPERT_DIM,
1944
+ help="Hidden dim of each expert FFN. Defaults to 3x the controller's "
1945
+ "hidden_size (MoE-Mamba's own '3:3' active-parameter ratio) if omitted.",
1946
+ )
1947
+ parser.add_argument(
1948
+ "--moe-capacity-factor",
1949
+ type=float,
1950
+ default=MOE_CAPACITY_FACTOR,
1951
+ help="Switch-style expert capacity buffer above an even token split.",
1952
+ )
1953
+ parser.add_argument(
1954
+ "--moe-load-balance-alpha",
1955
+ type=float,
1956
+ default=MOE_LOAD_BALANCE_ALPHA,
1957
+ help="Weight on the Switch-style auxiliary load-balancing loss.",
1958
+ )
1959
+ parser.add_argument("--moe-top-k", type=int, default=MOE_TOP_K, help="Number of top-k")
1960
+ parser.add_argument(
1961
+ "--split-graph",
1962
+ action="store_true",
1963
+ help="Alternate Phase 3, Step 2, Option 5: enable the "
1964
+ "split-compute-graph controller (parallel Mamba backbone + "
1965
+ "sequential memory addressing). DISABLED unless this flag is "
1966
+ "passed -- roadmap flags this as untested/isolated-ablation-"
1967
+ "required. Overrides --controller entirely when set.",
1968
+ )
1969
+ parser.add_argument(
1970
+ "--split-graph-variant",
1971
+ type=str,
1972
+ default=SPLIT_GRAPH_MAMBA_VARIANT,
1973
+ choices=["mamba1", "mamba2", "mamba3", "cfc", "mamba1+cfc", "mamba2+cfc", "mamba3+cfc"],
1974
+ help="Backbone variant for --split-graph. 'mamba2' requires "
1975
+ "mamba_ssm.modules.mamba2.Mamba2 to be importable. - same with 'mamba3'",
1976
+ )
1977
+ parser.add_argument(
1978
+ "--split-graph-num-blocks",
1979
+ type=int,
1980
+ default=SPLIT_GRAPH_NUM_BLOCKS,
1981
+ help="Number of stacked backbone blocks for --split-graph.",
1982
+ )
1983
+ parser.add_argument(
1984
+ "--split-graph-headdim",
1985
+ type=int,
1986
+ default=SPLIT_GRAPH_MAMBA_HEADDIM,
1987
+ help="Mamba2 headdim (mamba2 variant only).",
1988
+ )
1989
+ parser.add_argument(
1990
+ "--split-graph-no-combine",
1991
+ action="store_true",
1992
+ help="Built-in ablation for the roadmap's required 'read now, "
1993
+ "decide next hop' verification: disables the read-vector "
1994
+ "combiner entirely, so addressing never sees any read vector. "
1995
+ "Only meaningful with --split-graph-combiner-mode=linear.",
1996
+ )
1997
+ parser.add_argument(
1998
+ "--split-graph-combiner-mode",
1999
+ type=str,
2000
+ default=SPLIT_GRAPH_COMBINER_MODE,
2001
+ choices=["linear", "controller"],
2002
+ help="v11: 'linear' (default) is the original, already-efficient "
2003
+ "stateless combiner. 'controller' drives the sequential "
2004
+ "addressing step with a real interleaved Mamba controller "
2005
+ "cell instead (see --split-graph-combiner-variant).",
2006
+ )
2007
+ parser.add_argument(
2008
+ "--split-graph-combiner-variant",
2009
+ type=str,
2010
+ default=SPLIT_GRAPH_COMBINER_VARIANT,
2011
+ choices=[
2012
+ "mamba1",
2013
+ "mamba2",
2014
+ "mamba3",
2015
+ "cfc",
2016
+ "mamba+cfc",
2017
+ "mamba2+cfc",
2018
+ "mamba3+cfc",
2019
+ "cfc+mamba",
2020
+ "cfc+cfc",
2021
+ "mamba+mamba",
2022
+ "mamba2+mamba2",
2023
+ "mamba3+mamba3",
2024
+ ],
2025
+ help="v11: which controller drives the sequential combiner when "
2026
+ "--split-graph-combiner-mode=controller. 'mamba1' is the "
2027
+ "recommended/efficient choice; 'mamba2' is wired but not "
2028
+ "expected to be used (known inefficient, same reason plain "
2029
+ "--controller mamba2 runs aren't pursued). Same-kind stacks "
2030
+ "(e.g. 'cfc+cfc') isolate depth from cross-kind mixing -- "
2031
+ "hybrid_controller.py's STAGE_KINDS already supports "
2032
+ "duplicated kinds, this just widens the CLI's accepted list.",
2033
+ )
2034
+ parser.add_argument(
2035
+ "--split-graph-combiner-num-blocks",
2036
+ type=int,
2037
+ default=SPLIT_GRAPH_COMBINER_NUM_BLOCKS,
2038
+ help="Number of stacked blocks in the controller combiner "
2039
+ "(--split-graph-combiner-mode=controller only). Kept small "
2040
+ "by default -- this step is meant to stay cheap.",
2041
+ )
2042
+ parser.add_argument(
2043
+ "--dataset-type",
2044
+ type=str,
2045
+ default=DATASET_TYPE,
2046
+ choices=["graph", "text", "audio", "video", "multimodal"],
2047
+ help="Dataset plugged into the core loop (data/dataset_registry.py). "
2048
+ "Only 'graph' (graph-traversal) is implemented so far.",
2049
+ )
2050
+ parser.add_argument(
2051
+ "--dataset-link",
2052
+ type=str,
2053
+ default=DATASET_LINK,
2054
+ help="Real training-data source for --dataset-type: a graph edge file "
2055
+ "(graph), a text file (text), an audio file (audio, needs torchaudio), "
2056
+ "or a video file (video, needs opencv-python). Omit (or 'graph-traversal' "
2057
+ "for graph) to keep the built-in synthetic data for that type.",
2058
+ )
2059
+ parser.add_argument(
2060
+ "--test-dataset-link",
2061
+ type=str,
2062
+ default=None,
2063
+ help="Path to a real held-out TEST source (a second text/audio/video file, "
2064
+ "or a graph edge file), used instead of the default synthetic/seeded "
2065
+ "held-out split -- the modality's analogue of the graph dataset's "
2066
+ "London Underground test graph. Omit to keep default behavior: for "
2067
+ "graph, the built-in London Underground; for text/audio/video, a "
2068
+ "disjoint-key held-out slice of --dataset-link (or the fully synthetic "
2069
+ "seeded table if --dataset-link is also omitted).",
2070
+ )
2071
+ args = parser.parse_args()
2072
+
2073
+ if args.resume is not None and args.beta is None:
2074
+ raise SystemExit(
2075
+ "--resume requires the beta positional arg too, e.g.:\n"
2076
+ f" python3 {sys.argv[0]} 0.02 --resume checkpoints/beta_0p02_latest.pt"
2077
+ )
2078
+
2079
+ if args.link_matrix_mode == "sparse_topk" and args.link_matrix_topk is None:
2080
+ raise SystemExit("--link-matrix-mode=sparse_topk requires --link-matrix-topk")
2081
+
2082
+ if args.beta is not None:
2083
+ betas_to_run = [args.beta]
2084
+ if args.beta not in BETAS_TO_SWEEP:
2085
+ print(
2086
+ f"WARNING: {args.beta} is not in the planned sweep "
2087
+ f"{BETAS_TO_SWEEP} -- running it anyway, but it won't be "
2088
+ f"picked up by the combined summary step below."
2089
+ )
2090
+ else:
2091
+ betas_to_run = BETAS_TO_SWEEP
2092
+
2093
+ all_summaries = []
2094
+ for beta in betas_to_run:
2095
+ # These are made to avoid checkpoint collisions between different types of runs.
2096
+ run_id = f"beta_{beta}_seed{args.seed}_learnedprior".replace(".", "p")
2097
+ if args.controller == "mamba":
2098
+ run_id = f"{run_id}_mambactrl"
2099
+ elif args.controller == "mamba2":
2100
+ run_id = f"{run_id}_mamba2ctrl"
2101
+ elif args.controller == "mamba3":
2102
+ run_id = f"{run_id}_mamba3ctrl"
2103
+ elif args.controller == "cfc":
2104
+ run_id = f"{run_id}_cfcctrl"
2105
+ elif "+" in args.controller:
2106
+ run_id = f"{run_id}_{args.controller.replace('+', '')}ctrl"
2107
+ if args.beta_mode == "dynamic":
2108
+ run_id = f"{run_id}_dynbeta"
2109
+ if args.link_matrix_mode != "dense":
2110
+ tag = (
2111
+ args.link_matrix_mode
2112
+ if args.link_matrix_mode != "sparse_topk"
2113
+ else f"sparsetopk{args.link_matrix_topk}"
2114
+ )
2115
+ run_id = f"{run_id}_link{tag}"
2116
+ if args.dynamic_n_mode:
2117
+ run_id = f"{run_id}_dynN"
2118
+ if args.moe:
2119
+ run_id = f"{run_id}_moe{args.moe_num_experts}e"
2120
+ if args.split_graph:
2121
+ run_id = f"{run_id}_splitgraph_{args.split_graph_variant.replace('+', '')}"
2122
+ if args.split_graph_combiner_mode == "controller":
2123
+ run_id = f"{run_id}_combctrl{args.split_graph_combiner_variant.replace('+', '')}"
2124
+ if args.dataset_type != "graph":
2125
+ run_id = f"{run_id}_ds{args.dataset_type}"
2126
+ if args.run_id_suffix:
2127
+ run_id = f"{run_id}_{args.run_id_suffix}"
2128
+ summary = run(
2129
+ beta_target=beta,
2130
+ run_id=run_id,
2131
+ seed=args.seed,
2132
+ resume_from=args.resume,
2133
+ controller=args.controller, # v7
2134
+ beta_mode=args.beta_mode,
2135
+ total_steps=(args.total_steps if args.total_steps is not None else TOTAL_STEPS),
2136
+ checkpoint_every=(
2137
+ args.checkpoint_every if args.checkpoint_every is not None else CHECKPOINT_EVERY
2138
+ ),
2139
+ link_matrix_mode=args.link_matrix_mode,
2140
+ link_matrix_topk=args.link_matrix_topk,
2141
+ isolate_link_ablation=args.isolate_link_ablation,
2142
+ dynamic_n_mode=args.dynamic_n_mode,
2143
+ dynamic_n_floor=args.dynamic_n_floor,
2144
+ dynamic_n_ceiling=args.dynamic_n_ceiling,
2145
+ dynamic_n_trigger_frac=args.dynamic_n_trigger_frac,
2146
+ dynamic_n_cooldown_steps=args.dynamic_n_cooldown_steps,
2147
+ moe_enabled=args.moe,
2148
+ moe_num_experts=args.moe_num_experts,
2149
+ moe_expert_dim=args.moe_expert_dim,
2150
+ moe_capacity_factor=args.moe_capacity_factor,
2151
+ moe_load_balance_alpha=args.moe_load_balance_alpha,
2152
+ moe_top_k=args.moe_top_k,
2153
+ split_graph_enabled=args.split_graph,
2154
+ split_graph_variant=args.split_graph_variant,
2155
+ split_graph_num_blocks=args.split_graph_num_blocks,
2156
+ split_graph_headdim=args.split_graph_headdim,
2157
+ split_graph_combine_reads=not args.split_graph_no_combine,
2158
+ split_graph_combiner_mode=args.split_graph_combiner_mode,
2159
+ split_graph_combiner_variant=args.split_graph_combiner_variant,
2160
+ split_graph_combiner_num_blocks=args.split_graph_combiner_num_blocks,
2161
+ dataset_type=args.dataset_type,
2162
+ dataset_link=args.dataset_link,
2163
+ test_dataset_link=args.test_dataset_link,
2164
+ )
2165
+ all_summaries.append(summary)
2166
+
2167
+ print("\n===== Sweep summary (this process) =====")
2168
+ for s in all_summaries:
2169
+ print(s)
2170
+
2171
+ # Per-process partial summary - when running one beta per process, each
2172
+ # writes its own file; merge them by hand (or a short follow-up script)
2173
+ # once all processes finish. Only the no-arg (full sequential) path
2174
+ # produces a complete combined summary in one file.
2175
+ summary_suffix = "_".join(f"b{b}".replace(".", "p") for b in betas_to_run)
2176
+ summary_path = os.path.join(LOG_DIR, f"gate1_sweep_summary_{summary_suffix}.csv")
2177
+ with open(summary_path, "w", newline="") as f:
2178
+ w = csv.DictWriter(f, fieldnames=list(all_summaries[0].keys()))
2179
+ w.writeheader()
2180
+ for s in all_summaries:
2181
+ w.writerow(s)
2182
+ print(f"\nWrote summary to {summary_path}")