@seanhogg/builderforce-memory-engine 2026.7.12 → 2026.9.27

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 (196) hide show
  1. package/README.md +14 -0
  2. package/dist/bench/adaptation.d.ts +57 -0
  3. package/dist/bench/adaptation.d.ts.map +1 -0
  4. package/dist/bench/adaptation.js +167 -0
  5. package/dist/bench/adaptation.js.map +1 -0
  6. package/dist/bench/harness.d.ts +2 -0
  7. package/dist/bench/harness.d.ts.map +1 -1
  8. package/dist/bench/harness.js +1 -1
  9. package/dist/bench/harness.js.map +1 -1
  10. package/dist/bench/index.d.ts +5 -3
  11. package/dist/bench/index.d.ts.map +1 -1
  12. package/dist/bench/index.js +4 -2
  13. package/dist/bench/index.js.map +1 -1
  14. package/dist/bench/types.d.ts +88 -0
  15. package/dist/bench/types.d.ts.map +1 -1
  16. package/dist/evermind/adapt.d.ts +40 -0
  17. package/dist/evermind/adapt.d.ts.map +1 -0
  18. package/dist/evermind/adapt.js +25 -0
  19. package/dist/evermind/adapt.js.map +1 -0
  20. package/dist/evermind/delta_wire.d.ts +43 -0
  21. package/dist/evermind/delta_wire.d.ts.map +1 -0
  22. package/dist/evermind/delta_wire.js +94 -0
  23. package/dist/evermind/delta_wire.js.map +1 -0
  24. package/dist/evermind/eval.d.ts +31 -0
  25. package/dist/evermind/eval.d.ts.map +1 -0
  26. package/dist/evermind/eval.js +36 -0
  27. package/dist/evermind/eval.js.map +1 -0
  28. package/dist/evermind/index.d.ts +16 -0
  29. package/dist/evermind/index.d.ts.map +1 -0
  30. package/dist/evermind/index.js +11 -0
  31. package/dist/evermind/index.js.map +1 -0
  32. package/dist/evermind/merge.d.ts +21 -0
  33. package/dist/evermind/merge.d.ts.map +1 -0
  34. package/dist/evermind/merge.js +79 -0
  35. package/dist/evermind/merge.js.map +1 -0
  36. package/dist/{moe/moe_package.d.ts → evermind/package.d.ts} +55 -5
  37. package/dist/evermind/package.d.ts.map +1 -0
  38. package/dist/{moe/moe_package.js → evermind/package.js} +118 -19
  39. package/dist/evermind/package.js.map +1 -0
  40. package/dist/import/foreign/adapter.d.ts +59 -0
  41. package/dist/import/foreign/adapter.d.ts.map +1 -0
  42. package/dist/import/foreign/adapter.js +76 -0
  43. package/dist/import/foreign/adapter.js.map +1 -0
  44. package/dist/import/foreign/apply.d.ts +23 -0
  45. package/dist/import/foreign/apply.d.ts.map +1 -0
  46. package/dist/import/foreign/apply.js +43 -0
  47. package/dist/import/foreign/apply.js.map +1 -0
  48. package/dist/import/foreign/codestral_mamba.d.ts +57 -0
  49. package/dist/import/foreign/codestral_mamba.d.ts.map +1 -0
  50. package/dist/import/foreign/codestral_mamba.js +174 -0
  51. package/dist/import/foreign/codestral_mamba.js.map +1 -0
  52. package/dist/import/foreign/falcon_mamba.d.ts +51 -0
  53. package/dist/import/foreign/falcon_mamba.d.ts.map +1 -0
  54. package/dist/import/foreign/falcon_mamba.js +131 -0
  55. package/dist/import/foreign/falcon_mamba.js.map +1 -0
  56. package/dist/import/foreign/index.d.ts +21 -0
  57. package/dist/import/foreign/index.d.ts.map +1 -0
  58. package/dist/import/foreign/index.js +18 -0
  59. package/dist/import/foreign/index.js.map +1 -0
  60. package/dist/import/foreign/plan.d.ts +122 -0
  61. package/dist/import/foreign/plan.d.ts.map +1 -0
  62. package/dist/import/foreign/plan.js +171 -0
  63. package/dist/import/foreign/plan.js.map +1 -0
  64. package/dist/import/foreign/registry.d.ts +35 -0
  65. package/dist/import/foreign/registry.d.ts.map +1 -0
  66. package/dist/import/foreign/registry.js +89 -0
  67. package/dist/import/foreign/registry.js.map +1 -0
  68. package/dist/import/index.d.ts +7 -0
  69. package/dist/import/index.d.ts.map +1 -1
  70. package/dist/import/index.js +7 -0
  71. package/dist/import/index.js.map +1 -1
  72. package/dist/index.d.ts +29 -12
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +24 -9
  75. package/dist/index.js.map +1 -1
  76. package/dist/kernels/selective_scan.d.ts.map +1 -1
  77. package/dist/kernels/selective_scan.js +15 -9
  78. package/dist/kernels/selective_scan.js.map +1 -1
  79. package/dist/kernels/slice.d.ts +26 -0
  80. package/dist/kernels/slice.d.ts.map +1 -0
  81. package/dist/kernels/slice.js +52 -0
  82. package/dist/kernels/slice.js.map +1 -0
  83. package/dist/kernels/weight_update.d.ts.map +1 -1
  84. package/dist/kernels/weight_update.js +30 -4
  85. package/dist/kernels/weight_update.js.map +1 -1
  86. package/dist/lm/evermind_lm.d.ts +180 -4
  87. package/dist/lm/evermind_lm.d.ts.map +1 -1
  88. package/dist/lm/evermind_lm.js +433 -116
  89. package/dist/lm/evermind_lm.js.map +1 -1
  90. package/dist/lm/index.d.ts +8 -2
  91. package/dist/lm/index.d.ts.map +1 -1
  92. package/dist/lm/index.js +6 -1
  93. package/dist/lm/index.js.map +1 -1
  94. package/dist/lm/text_embedder.d.ts +55 -0
  95. package/dist/lm/text_embedder.d.ts.map +1 -0
  96. package/dist/lm/text_embedder.js +62 -0
  97. package/dist/lm/text_embedder.js.map +1 -0
  98. package/dist/model/attention_block.d.ts.map +1 -1
  99. package/dist/model/attention_block.js +9 -5
  100. package/dist/model/attention_block.js.map +1 -1
  101. package/dist/model/mamba1_block.d.ts.map +1 -1
  102. package/dist/model/mamba1_block.js +28 -12
  103. package/dist/model/mamba1_block.js.map +1 -1
  104. package/dist/model/mamba2_block.d.ts.map +1 -1
  105. package/dist/model/mamba2_block.js +14 -9
  106. package/dist/model/mamba2_block.js.map +1 -1
  107. package/dist/model/mamba3_block.d.ts.map +1 -1
  108. package/dist/model/mamba3_block.js +14 -9
  109. package/dist/model/mamba3_block.js.map +1 -1
  110. package/dist/moe/index.d.ts +0 -2
  111. package/dist/moe/index.d.ts.map +1 -1
  112. package/dist/moe/index.js +0 -1
  113. package/dist/moe/index.js.map +1 -1
  114. package/dist/optim/adamw.d.ts +58 -1
  115. package/dist/optim/adamw.d.ts.map +1 -1
  116. package/dist/optim/adamw.js +69 -4
  117. package/dist/optim/adamw.js.map +1 -1
  118. package/dist/tokenizer/bpe.d.ts +28 -0
  119. package/dist/tokenizer/bpe.d.ts.map +1 -1
  120. package/dist/tokenizer/bpe.js +47 -0
  121. package/dist/tokenizer/bpe.js.map +1 -1
  122. package/dist/training/cpu_ops.d.ts +44 -0
  123. package/dist/training/cpu_ops.d.ts.map +1 -0
  124. package/dist/training/cpu_ops.js +111 -0
  125. package/dist/training/cpu_ops.js.map +1 -0
  126. package/dist/training/lora.d.ts +153 -0
  127. package/dist/training/lora.d.ts.map +1 -0
  128. package/dist/training/lora.js +324 -0
  129. package/dist/training/lora.js.map +1 -0
  130. package/dist/training/mamba1_cpu.d.ts +109 -0
  131. package/dist/training/mamba1_cpu.d.ts.map +1 -0
  132. package/dist/training/mamba1_cpu.js +316 -0
  133. package/dist/training/mamba1_cpu.js.map +1 -0
  134. package/dist/training/mixed_precision.d.ts +67 -0
  135. package/dist/training/mixed_precision.d.ts.map +1 -0
  136. package/dist/training/mixed_precision.js +101 -0
  137. package/dist/training/mixed_precision.js.map +1 -0
  138. package/dist/training/model_cpu.d.ts +100 -0
  139. package/dist/training/model_cpu.d.ts.map +1 -0
  140. package/dist/training/model_cpu.js +252 -0
  141. package/dist/training/model_cpu.js.map +1 -0
  142. package/dist/training/trainer.d.ts +156 -16
  143. package/dist/training/trainer.d.ts.map +1 -1
  144. package/dist/training/trainer.js +327 -101
  145. package/dist/training/trainer.js.map +1 -1
  146. package/dist/utils/base64.d.ts +12 -0
  147. package/dist/utils/base64.d.ts.map +1 -0
  148. package/dist/utils/base64.js +27 -0
  149. package/dist/utils/base64.js.map +1 -0
  150. package/package.json +3 -1
  151. package/src/bench/adaptation.ts +180 -0
  152. package/src/bench/harness.ts +1 -1
  153. package/src/bench/index.ts +13 -2
  154. package/src/bench/types.ts +93 -0
  155. package/src/evermind/adapt.ts +61 -0
  156. package/src/evermind/delta_wire.ts +114 -0
  157. package/src/evermind/eval.ts +62 -0
  158. package/src/evermind/index.ts +33 -0
  159. package/src/evermind/merge.ts +106 -0
  160. package/src/{moe/moe_package.ts → evermind/package.ts} +148 -17
  161. package/src/import/foreign/adapter.ts +106 -0
  162. package/src/import/foreign/apply.ts +54 -0
  163. package/src/import/foreign/codestral_mamba.ts +224 -0
  164. package/src/import/foreign/falcon_mamba.ts +173 -0
  165. package/src/import/foreign/index.ts +37 -0
  166. package/src/import/foreign/plan.ts +294 -0
  167. package/src/import/foreign/registry.ts +126 -0
  168. package/src/import/index.ts +30 -0
  169. package/src/index.ts +104 -13
  170. package/src/kernels/selective_scan.ts +15 -9
  171. package/src/kernels/slice.ts +64 -0
  172. package/src/kernels/weight_update.ts +30 -4
  173. package/src/lm/evermind_lm.ts +470 -116
  174. package/src/lm/index.ts +12 -2
  175. package/src/lm/text_embedder.ts +84 -0
  176. package/src/model/attention_block.ts +9 -5
  177. package/src/model/mamba1_block.ts +28 -12
  178. package/src/model/mamba2_block.ts +14 -9
  179. package/src/model/mamba3_block.ts +14 -9
  180. package/src/moe/index.ts +0 -9
  181. package/src/optim/adamw.ts +115 -7
  182. package/src/tokenizer/bpe.ts +55 -0
  183. package/src/training/cpu_ops.ts +127 -0
  184. package/src/training/lora.ts +365 -0
  185. package/src/training/mamba1_cpu.ts +421 -0
  186. package/src/training/mixed_precision.ts +119 -0
  187. package/src/training/model_cpu.ts +360 -0
  188. package/src/training/trainer.ts +397 -154
  189. package/src/utils/base64.ts +28 -0
  190. package/dist/model/mamba_block.d.ts +0 -64
  191. package/dist/model/mamba_block.d.ts.map +0 -1
  192. package/dist/model/mamba_block.js +0 -303
  193. package/dist/model/mamba_block.js.map +0 -1
  194. package/dist/moe/moe_package.d.ts.map +0 -1
  195. package/dist/moe/moe_package.js.map +0 -1
  196. package/src/model/mamba_block.ts +0 -401
@@ -1,20 +1,50 @@
1
1
  /**
2
2
  * trainer.ts – MambaTrainer class
3
+ *
4
+ * REAL backpropagation. Until 2026-08 `_trainStep` handed the SAME `dLogits`
5
+ * buffer to AdamW for every parameter, so no parameter ever received its own
6
+ * gradient: `learn()` was SAFE (WSLA freezes the backbone, the trust region
7
+ * bounds every step, the NaN guard blocks a poisoned weight, and the session
8
+ * rolls back a regression) but INERT. It now runs a genuine backward pass —
9
+ * `cpuModelBackward` in `model_cpu.ts` — and each parameter is stepped with its
10
+ * OWN gradient.
11
+ *
12
+ * Where the maths runs, and why:
13
+ * • the backward pass is the CPU reference (`cpu_ops.ts` + `mamba1_cpu.ts`),
14
+ * a line-for-line mirror of the WGSL forward kernels. It is exact and it is
15
+ * PROVEN — `tests/backprop.test.ts` checks every parameter gradient against
16
+ * finite differences of the real loss, with no GPU involved;
17
+ * • the optimiser is {@link adamwUpdateInPlace}, the CPU mirror of
18
+ * `WEIGHT_UPDATE_WGSL`'s `adamw_update`, keeping the trust region and the
19
+ * NaN guard the safety story depends on;
20
+ * • the model's GPU buffers are written back after every optimiser step, so
21
+ * inference (which IS GPU) always serves the trained weights.
22
+ *
23
+ * The residual is a WGSL backward pass so training itself runs on the GPU. The
24
+ * backward kernels exist (`SELECTIVE_SCAN_BACKWARD_WGSL`, `SSD_BACKWARD_WGSL`,
25
+ * `CONV1D_BACKWARD_WGSL`, `LINEAR_BACKWARD_WGSL`, `ACTIVATIONS_BACKWARD_WGSL`)
26
+ * but wiring them cannot be validated numerically without a WebGPU device, and
27
+ * an unvalidated GPU backward is worse than a slower exact one.
3
28
  */
4
29
 
5
30
  import {
6
- createUniformBuffer,
7
- createStorageBuffer,
8
- createEmptyStorageBuffer,
9
- createComputePipeline,
10
- createBindGroup,
11
- dispatchKernel,
12
31
  readBuffer,
13
- cdiv,
32
+ uploadBuffer,
14
33
  } from '../utils/gpu_utils.js';
15
34
 
16
- import { crossEntropyLoss, crossEntropyGrad } from './autograd.js';
17
- import { WEIGHT_UPDATE_WGSL, GRAD_CLIP_WGSL } from '../kernels/weight_update.js';
35
+ import { crossEntropyLoss } from './autograd.js';
36
+ import { adamwUpdateInPlace, type ShardSpec } from '../optim/adamw.js';
37
+ import { LoRAAdapter, type LoRAConfig } from './lora.js';
38
+ import {
39
+ cpuModelBackward,
40
+ cpuWeightsFromNamed,
41
+ namedWeights,
42
+ paramMatrixShape,
43
+ DEFAULT_LORA_TARGETS,
44
+ CPU_GRADIENT_LAYER_TYPES,
45
+ type CpuModelDims,
46
+ type CpuModelWeights,
47
+ } from './model_cpu.js';
18
48
  import { HybridMambaModel, MambaModel } from '../model/mamba_model.js';
19
49
  import { BPETokenizer } from '../tokenizer/bpe.js';
20
50
  import type { LayerParam as BlockParam } from '../model/sequence_layer.js';
@@ -22,6 +52,8 @@ import type { LayerParam as BlockParam } from '../model/sequence_layer.js';
22
52
  export interface TrainOptions {
23
53
  learningRate?: number;
24
54
  epochs?: number;
55
+ /** Sequences trained together per optimiser step. Chunks of equal length are
56
+ * grouped into batches of this size; a ragged tail trains on its own. */
25
57
  batchSize?: number;
26
58
  seqLen?: number;
27
59
  maxGradNorm?: number;
@@ -33,11 +65,46 @@ export interface TrainOptions {
33
65
  /** Trust region: max |Δθ| per optimizer step. 0 disables. Defaults to
34
66
  * {@link WSLA_MAX_DELTA} in WSLA (write-through) mode, else 0 (full training). */
35
67
  maxDelta?: number;
36
- /** Read back the pre-clip gradient L2 norm each step (the `grad_norm_reduce`
37
- * kernel already computes it — this just maps it to the CPU). OFF by default: the
38
- * readback is a GPU sync point that would slow a long finetune. `adapt()` turns it
39
- * ON so the norm reaches {@link AdaptResult} as an instability signal. */
68
+ /**
69
+ * @deprecated The pre-clip gradient L2 norm is now computed exactly on the way
70
+ * through the optimiser and is ALWAYS reported — it no longer costs a GPU
71
+ * readback, so there is nothing to opt into. Accepted for API compatibility.
72
+ */
40
73
  trackGradNorm?: boolean;
74
+ /**
75
+ * Train LOW-RANK ADAPTERS over the block projections instead of the weights
76
+ * themselves (see {@link LoRAAdapter}). The base weights are frozen; only A and
77
+ * B train, so an adapt costs a fraction of the optimiser state and the result is
78
+ * a small, mergeable delta rather than a whole checkpoint.
79
+ *
80
+ * `targets` selects which parameters get an adapter, by their unqualified name
81
+ * (`wInProj`, `wXProj`, `wDtProj`, `wOutProj`, `wConv`, `A_log`, `embedding`);
82
+ * omit for {@link DEFAULT_LORA_TARGETS}. A rank larger than a target's smaller
83
+ * dimension is clamped to it rather than throwing — the small projections
84
+ * (`wDtProj` is `dInner x dtRank`) would otherwise reject a sensible global rank.
85
+ */
86
+ lora?: LoRAConfig & { targets?: readonly string[] };
87
+ /**
88
+ * Batches to accumulate gradients over before one optimiser step. Default 1.
89
+ * The effective batch size is `batchSize * gradientAccumulation` at the memory
90
+ * cost of `batchSize` — the standard way to train at a batch size the host
91
+ * cannot hold at once.
92
+ */
93
+ gradientAccumulation?: number;
94
+ /**
95
+ * Shard the optimiser state (the Adam moments — 2x the parameter bytes, the
96
+ * dominant cost of a full fine-tune). This instance then OWNS only parameters
97
+ * where `index % count === index`, and allocates moments for those alone. Run
98
+ * `count` trainers over the same model and the union of their steps equals one
99
+ * unsharded step. Same contract as {@link ShardSpec} on {@link AdamW}.
100
+ */
101
+ shard?: ShardSpec;
102
+ /**
103
+ * Recompute each layer's activations in the backward pass instead of retaining
104
+ * all of them. Gradients are identical; peak memory drops from O(layers)
105
+ * activation caches to one, at the cost of a second forward per layer.
106
+ */
107
+ activationCheckpointing?: boolean;
41
108
  onEpochEnd?: ((epoch: number, loss: number, gradNorm?: number) => void) | null;
42
109
  }
43
110
 
@@ -51,8 +118,8 @@ export interface TrainOptions {
51
118
  export const WSLA_MAX_DELTA = 0.05;
52
119
 
53
120
  interface AdamMoments {
54
- m: GPUBuffer;
55
- v: GPUBuffer;
121
+ m: Float32Array;
122
+ v: Float32Array;
56
123
  }
57
124
 
58
125
  interface AdamHyperparams {
@@ -64,8 +131,15 @@ interface AdamHyperparams {
64
131
  beta1_t: number;
65
132
  beta2_t: number;
66
133
  maxDelta: number;
134
+ gradScale: number;
67
135
  }
68
136
 
137
+ /** One training example: a token window and its next-token targets. */
138
+ interface TrainChunk { inputs: number[]; targets: number[] }
139
+
140
+ /** A batch of equal-length chunks trained in one optimiser step. */
141
+ interface TrainBatch { inputs: number[]; targets: number[]; batch: number; seqLen: number }
142
+
69
143
  export class MambaTrainer {
70
144
  model: HybridMambaModel;
71
145
  tokenizer: BPETokenizer | null;
@@ -74,15 +148,21 @@ export class MambaTrainer {
74
148
  * Adam moments keyed by parameter NAME (not array index). Name-keying is what
75
149
  * lets WSLA toggle safely: a narrow write-through step updates only the
76
150
  * `layer{i}.wXProj/bXProj` subset, a full fine-tune updates everything, and
77
- * both reuse the SAME `m`/`v` buffers per parameter. Index-keyed moments (the
78
- * old scheme) silently misaligned the moment with the wrong parameter the
79
- * moment the trainable set changed shape — corrupting the update.
151
+ * both reuse the SAME `m`/`v` per parameter. Index-keyed moments (the old
152
+ * scheme) silently misaligned the moment with the wrong parameter the moment
153
+ * the trainable set changed shape — corrupting the update.
80
154
  */
81
155
  private _moments: Map<string, AdamMoments>;
82
156
  private _step: number;
83
- private _adamwPipeline: GPUComputePipeline;
84
- private _clipReducePipeline: GPUComputePipeline;
85
- private _clipScalePipeline: GPUComputePipeline;
157
+ private readonly _dims: CpuModelDims;
158
+ /** CPU mirror of every model tensor — the gradient engine's working set. */
159
+ private _mirror: CpuModelWeights | null;
160
+ private _mirrorByName: Map<string, Float32Array> | null;
161
+ /** Low-rank adapters by parameter name, when training with `lora`. */
162
+ private _adapters: Map<string, LoRAAdapter>;
163
+ /** Frozen base weights the adapters sit on top of (LoRA only). */
164
+ private _loraBase: Map<string, Float32Array>;
165
+ private _shard: ShardSpec | null;
86
166
 
87
167
  constructor(model: HybridMambaModel | MambaModel, tokenizer: BPETokenizer | null = null) {
88
168
  this.model = model;
@@ -91,25 +171,103 @@ export class MambaTrainer {
91
171
 
92
172
  this._moments = new Map();
93
173
  this._step = 0;
174
+ this._dims = cpuDimsFor(model);
175
+ this._mirror = null;
176
+ this._mirrorByName = null;
177
+ this._adapters = new Map();
178
+ this._loraBase = new Map();
179
+ this._shard = null;
180
+ }
181
+
182
+ /**
183
+ * The low-rank adapters this trainer is training, by parameter name — empty
184
+ * unless `train({ lora })` was used. Exposed so a caller can serialise just the
185
+ * adapter (a few hundred KB) instead of the whole checkpoint.
186
+ */
187
+ get adapters(): ReadonlyMap<string, LoRAAdapter> { return this._adapters; }
188
+
189
+ /** Trainable scalar count for the current mode — the LoRA saving, measured. */
190
+ trainableParamCount(): number {
191
+ const params = this.model.getTrainableParams();
192
+ if (this._adapters.size === 0) return params.reduce((n, p) => n + p.numel, 0);
193
+ let n = 0;
194
+ for (const p of params) {
195
+ const a = this._adapters.get(p.name);
196
+ if (a) n += a.numParams();
197
+ }
198
+ return n;
199
+ }
200
+
201
+ /** Whether this trainer owns parameter `index` under the active shard. */
202
+ private _owns(index: number): boolean {
203
+ return this._shard === null || index % this._shard.count === this._shard.index;
204
+ }
205
+
206
+ /**
207
+ * Build (once) an adapter for every LoRA-target parameter, snapshot the frozen
208
+ * base, and return the set. Rank is clamped per parameter to its smaller
209
+ * dimension so one global rank works across projections of very different shape.
210
+ */
211
+ private _installAdapters(cfg: LoRAConfig & { targets?: readonly string[] }): void {
212
+ if (this._adapters.size > 0) return; // already installed; keep training them
213
+ const targets = new Set<string>(cfg.targets ?? DEFAULT_LORA_TARGETS);
214
+ const weights = this._mirrorByName!;
215
+ for (const p of this.model.getTrainableParams()) {
216
+ const local = p.name.includes('.') ? p.name.slice(p.name.indexOf('.') + 1) : p.name;
217
+ if (!targets.has(local)) continue;
218
+ const shape = paramMatrixShape(p.name, this._dims);
219
+ if (!shape) continue; // a vector parameter — nothing for a low-rank adapter to do
220
+ const [rows, cols] = shape;
221
+ const rank = Math.max(1, Math.min(cfg.rank ?? 8, rows, cols));
222
+ this._adapters.set(p.name, new LoRAAdapter(rows, cols, { ...cfg, rank }));
223
+ this._loraBase.set(p.name, Float32Array.from(weights.get(p.name)!));
224
+ }
225
+ if (this._adapters.size === 0) {
226
+ throw new Error(
227
+ `MambaTrainer: lora was requested but no trainable parameter matched targets ` +
228
+ `[${[...targets].join(', ')}]. Valid targets: wInProj, wXProj, wDtProj, wOutProj, wConv, A_log, embedding.`,
229
+ );
230
+ }
231
+ }
94
232
 
95
- this._adamwPipeline = createComputePipeline(this.device, WEIGHT_UPDATE_WGSL, 'adamw_update');
96
- this._clipReducePipeline = createComputePipeline(this.device, GRAD_CLIP_WGSL, 'grad_norm_reduce');
97
- this._clipScalePipeline = createComputePipeline(this.device, GRAD_CLIP_WGSL, 'grad_clip_scale');
233
+ /** Write `base + adapter.delta()` into the mirror, so the forward sees the adapted weights. */
234
+ private _materialiseAdapters(): void {
235
+ const weights = this._mirrorByName!;
236
+ for (const [name, adapter] of this._adapters) {
237
+ const w = weights.get(name)!;
238
+ const base = this._loraBase.get(name)!;
239
+ const delta = adapter.delta();
240
+ for (let i = 0; i < w.length; i++) w[i] = base[i]! + delta[i]!;
241
+ }
98
242
  }
99
243
 
100
244
  /** Get-or-create the Adam first/second moments for a parameter, by name. */
101
245
  private _momentFor(p: BlockParam): AdamMoments {
102
246
  let mom = this._moments.get(p.name);
103
247
  if (!mom) {
104
- mom = {
105
- m: createEmptyStorageBuffer(this.device, p.numel * 4, false),
106
- v: createEmptyStorageBuffer(this.device, p.numel * 4, false),
107
- };
248
+ mom = { m: new Float32Array(p.numel), v: new Float32Array(p.numel) };
108
249
  this._moments.set(p.name, mom);
109
250
  }
110
251
  return mom;
111
252
  }
112
253
 
254
+ /**
255
+ * Pull every model tensor off the GPU into the CPU mirror. Done once per
256
+ * `train()` call (not per step) so an externally loaded checkpoint or a
257
+ * previous session's weights are picked up, after which the mirror is
258
+ * authoritative and each step writes the updated tensors back.
259
+ */
260
+ private async _syncMirrorFromGpu(): Promise<void> {
261
+ const named = new Map<string, Float32Array>();
262
+ for (const p of this.model.parameters()) {
263
+ named.set(p.name, await readBuffer(this.device, p.buf, p.numel * 4));
264
+ }
265
+ named.set('lm_head_bias',
266
+ await readBuffer(this.device, this.model.gpuLMHeadBias, this._dims.vocabSize * 4));
267
+ this._mirror = cpuWeightsFromNamed(named, this._dims);
268
+ this._mirrorByName = namedWeights(this._mirror);
269
+ }
270
+
113
271
  async train(input: string | number[], opts: TrainOptions = {}): Promise<number[]> {
114
272
  const {
115
273
  learningRate = 1e-4,
@@ -122,9 +280,13 @@ export class MambaTrainer {
122
280
  beta2 = 0.999,
123
281
  eps = 1e-8,
124
282
  wsla = false,
125
- trackGradNorm = false,
126
283
  onEpochEnd = null,
127
284
  } = opts;
285
+ const accumulation = Math.max(1, Math.floor(opts.gradientAccumulation ?? 1));
286
+ this._shard = opts.shard ?? null;
287
+ if (this._shard && (this._shard.count <= 0 || this._shard.index < 0 || this._shard.index >= this._shard.count)) {
288
+ throw new Error(`MambaTrainer: invalid shard ${this._shard.index}/${this._shard.count}`);
289
+ }
128
290
  // Trust region defaults ON for write-through (WSLA) adapts, OFF otherwise.
129
291
  const maxDelta = opts.maxDelta ?? (wsla ? WSLA_MAX_DELTA : 0);
130
292
 
@@ -151,10 +313,12 @@ export class MambaTrainer {
151
313
  if (chunks.length === 0) {
152
314
  throw new Error('Input is too short to form any training chunk.');
153
315
  }
316
+ const batches = buildBatches(chunks, Math.max(1, Math.floor(batchSize)));
154
317
 
155
- // Moments are created lazily per-parameter (name-keyed) on first touch —
156
- // no upfront allocation needed, and it stays correct as WSLA narrows the
157
- // trainable set.
318
+ // The mirror is the gradient engine's working set; refresh it from the GPU
319
+ // once per train() so a freshly loaded checkpoint is trained, not stale weights.
320
+ await this._syncMirrorFromGpu();
321
+ if (opts.lora) this._installAdapters(opts.lora);
158
322
 
159
323
  const epochLosses: number[] = [];
160
324
 
@@ -163,158 +327,187 @@ export class MambaTrainer {
163
327
  let epochGradNorm = 0;
164
328
  let numSteps = 0;
165
329
 
166
- for (const { inputs, targets } of chunks) {
330
+ // Gradient accumulation: `accumulation` batches contribute to one
331
+ // optimiser step, so the effective batch is batchSize x accumulation at
332
+ // the memory of one batch.
333
+ for (let i = 0; i < batches.length; i += accumulation) {
334
+ const group = batches.slice(i, i + accumulation);
167
335
  const { loss, gradNorm } = await this._trainStep(
168
- inputs, targets, batchSize,
169
- { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, wsla, maxDelta, trackGradNorm }
336
+ group,
337
+ { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, wsla, maxDelta,
338
+ ...(opts.activationCheckpointing ? { activationCheckpointing: true } : {}) },
170
339
  );
171
340
  epochLoss += loss;
172
- if (gradNorm != null) epochGradNorm += gradNorm;
341
+ epochGradNorm += gradNorm;
173
342
  numSteps++;
174
343
  }
175
344
 
176
345
  const avgLoss = epochLoss / numSteps;
177
346
  epochLosses.push(avgLoss);
178
-
179
- // Mean pre-clip grad norm this epoch (only measured when trackGradNorm is on).
180
- const avgGradNorm = trackGradNorm ? epochGradNorm / numSteps : undefined;
181
- if (onEpochEnd) onEpochEnd(epoch + 1, avgLoss, avgGradNorm);
347
+ if (onEpochEnd) onEpochEnd(epoch + 1, avgLoss, epochGradNorm / numSteps);
182
348
  }
183
349
 
184
350
  if (wsla) this.model.setWSLAMode(false);
185
351
  return epochLosses;
186
352
  }
187
353
 
354
+ /**
355
+ * One optimiser step over one batch: real forward + backward, a global
356
+ * gradient-norm clip over the trainable set, then AdamW per parameter with
357
+ * its OWN gradient, written straight back to the GPU buffer.
358
+ */
188
359
  private async _trainStep(
189
- inputs: number[],
190
- targets: number[],
191
- batch: number,
192
- hyperparams: TrainOptions & { learningRate: number; maxGradNorm: number; weightDecay: number; beta1: number; beta2: number; eps: number; maxDelta: number; trackGradNorm?: boolean }
193
- ): Promise<{ loss: number; gradNorm: number | null }> {
194
- const { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, maxDelta, trackGradNorm } = hyperparams;
360
+ group: TrainBatch[],
361
+ hyperparams: TrainOptions & { learningRate: number; maxGradNorm: number; weightDecay: number; beta1: number; beta2: number; eps: number; maxDelta: number }
362
+ ): Promise<{ loss: number; gradNorm: number }> {
363
+ const { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, maxDelta } = hyperparams;
195
364
 
196
- this._step++;
197
- const seqLen = inputs.length;
198
- const vocabSize = this.model.config.vocabSize;
199
-
200
- const { logits, gpuLogits } = await this.model.forward(
201
- new Uint32Array(inputs), batch, seqLen
202
- );
365
+ if (!this._mirror || !this._mirrorByName) await this._syncMirrorFromGpu();
366
+ const mirror = this._mirror!;
367
+ const weights = this._mirrorByName!;
203
368
 
204
- let totalLoss = 0;
205
- const dLogits = new Float32Array(batch * seqLen * vocabSize);
206
-
207
- for (let i = 0; i < seqLen; i++) {
208
- const offset = i * vocabSize;
209
- const logitSlice = logits.slice(offset, offset + vocabSize);
210
- const target = targets[i]!;
211
- totalLoss += crossEntropyLoss(logitSlice, target);
212
- const grad = crossEntropyGrad(logitSlice, target);
213
- for (let v = 0; v < vocabSize; v++) {
214
- dLogits[offset + v] = grad[v]! / seqLen;
369
+ this._step++;
370
+ // Under LoRA the forward must see base + Delta, not the stale merge from the
371
+ // previous step.
372
+ if (this._adapters.size > 0) this._materialiseAdapters();
373
+
374
+ const bwOpts = hyperparams.activationCheckpointing ? { activationCheckpointing: true } : {};
375
+ let loss = 0;
376
+ let grads = new Map<string, Float32Array>();
377
+ for (const b of group) {
378
+ const step = cpuModelBackward(b.inputs, b.targets, mirror, this._dims, b.batch, b.seqLen, bwOpts);
379
+ loss += step.loss / group.length;
380
+ if (grads.size === 0) {
381
+ // First micro-batch owns the accumulator; scale it as we go so the
382
+ // accumulated gradient is the MEAN over the group, matching a single
383
+ // pass at the larger batch size.
384
+ for (const v of step.grads.values()) for (let i = 0; i < v.length; i++) v[i] = v[i]! / group.length;
385
+ grads = step.grads;
386
+ } else {
387
+ for (const [k, v] of step.grads) {
388
+ const acc = grads.get(k)!;
389
+ for (let i = 0; i < v.length; i++) acc[i] = acc[i]! + v[i]! / group.length;
390
+ }
215
391
  }
216
392
  }
217
- const loss = totalLoss / seqLen;
218
-
219
- const dLogitsBuf = createStorageBuffer(this.device, dLogits, false);
220
-
221
- const gradNorm = await this._clipGradients(dLogitsBuf, dLogits.length, maxGradNorm, !!trackGradNorm);
222
393
 
223
394
  // Only the trainable set is updated. Under WSLA (write-through) that is
224
395
  // the narrow per-layer subset and the backbone (incl. every A_log) stays
225
396
  // frozen — the guarantee that repeated adapts can't destabilise the SSM.
226
- const params = this.model.getTrainableParams();
227
- const beta1_t = Math.pow(beta1, this._step);
228
- const beta2_t = Math.pow(beta2, this._step);
229
-
230
- await this._adamwStep(
231
- params, [dLogitsBuf],
232
- { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta }
233
- );
397
+ const params = this.model.getTrainableParams();
398
+
399
+ // Under LoRA the base weights are frozen: project each base gradient onto
400
+ // its adapter, and the adapter's A/B are what the optimiser steps.
401
+ if (this._adapters.size > 0) {
402
+ for (const [name, adapter] of this._adapters) {
403
+ const g = grads.get(name);
404
+ if (!g) continue;
405
+ adapter.zeroGrad();
406
+ adapter.accumulateGradient(g);
407
+ }
408
+ }
234
409
 
235
- dLogitsBuf.destroy();
236
- gpuLogits.destroy();
410
+ // Global pre-clip L2 norm ACROSS the whole trainable set. The old GPU
411
+ // reduce clipped one buffer at a time and raced on `norm_sq[0]` across
412
+ // workgroups; this is the true global norm and needs no readback.
413
+ let sumSq = 0;
414
+ for (const p of params) {
415
+ const g = grads.get(p.name);
416
+ if (!g) continue;
417
+ for (let i = 0; i < g.length; i++) sumSq += g[i]! * g[i]!;
418
+ }
419
+ const gradNorm = Math.sqrt(sumSq);
420
+ const gradScale = maxGradNorm > 0 && gradNorm > maxGradNorm ? maxGradNorm / gradNorm : 1;
421
+
422
+ this._adamwStep(params, grads, weights, {
423
+ learningRate, weightDecay, beta1, beta2, eps,
424
+ beta1_t: Math.pow(beta1, this._step),
425
+ beta2_t: Math.pow(beta2, this._step),
426
+ maxDelta, gradScale,
427
+ });
237
428
 
238
429
  return { loss, gradNorm };
239
430
  }
240
431
 
241
- private async _adamwStep(
432
+ /**
433
+ * AdamW over the trainable set. Each parameter is paired with its OWN
434
+ * gradient by NAME — the bug this replaces passed one shared `dLogits`
435
+ * buffer to every parameter — and the updated tensor is uploaded to the GPU
436
+ * so inference serves it.
437
+ */
438
+ private _adamwStep(
242
439
  params: BlockParam[],
243
- gradBufs: GPUBuffer[],
244
- hp: AdamHyperparams
245
- ): Promise<void> {
246
- const { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta } = hp;
247
-
248
- for (let i = 0; i < params.length; i++) {
249
- const p = params[i]!;
250
- const gradBuf = gradBufs[Math.min(i, gradBufs.length - 1)]!;
440
+ grads: Map<string, Float32Array>,
441
+ weights: Map<string, Float32Array>,
442
+ hp: AdamHyperparams,
443
+ ): void {
444
+ const { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta, gradScale } = hp;
445
+ const step = { lr: learningRate, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta, gradScale };
446
+
447
+ for (let idx = 0; idx < params.length; idx++) {
448
+ const p = params[idx]!;
449
+ if (!this._owns(idx)) continue; // another shard holds this one's moments
450
+
451
+ // LoRA: step A and B, then re-merge base + Delta into the mirror so the
452
+ // GPU buffer (what inference reads) always holds the adapted weights.
453
+ const adapter = this._adapters.get(p.name);
454
+ if (adapter) {
455
+ const w = weights.get(p.name)!;
456
+ const base = this._loraBase.get(p.name)!;
457
+ const [aP, bP] = [adapter.A, adapter.B];
458
+ const [gA, gB] = [adapter.gradients()[1]!.data, adapter.gradients()[0]!.data];
459
+ const momB = this._momentNamed(`${p.name}::loraB`, bP.length);
460
+ const momA = this._momentNamed(`${p.name}::loraA`, aP.length);
461
+ adamwUpdateInPlace(bP, gB, momB.m, momB.v, step);
462
+ adamwUpdateInPlace(aP, gA, momA.m, momA.v, step);
463
+ const delta = adapter.delta();
464
+ for (let i = 0; i < w.length; i++) w[i] = base[i]! + delta[i]!;
465
+ uploadBuffer(this.device, p.buf, w);
466
+ continue;
467
+ }
251
468
 
252
- if (!gradBuf || gradBuf.size < p.numel * 4) continue;
469
+ const g = grads.get(p.name);
470
+ const w = weights.get(p.name);
471
+ if (!g || !w) continue;
472
+ if (g.length !== p.numel || w.length !== p.numel) {
473
+ throw new Error(
474
+ `MambaTrainer: parameter "${p.name}" is ${p.numel} elements but the ` +
475
+ `gradient engine produced ${g.length} — model and CPU mirror disagree.`
476
+ );
477
+ }
253
478
 
254
479
  const mom = this._momentFor(p);
255
- const paramsBuf = createUniformBuffer(this.device, packAdamParams(
256
- p.numel, learningRate, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta
257
- ));
258
-
259
- const bg = createBindGroup(this.device, this._adamwPipeline, [
260
- paramsBuf,
261
- p.buf,
262
- gradBuf,
263
- mom.m,
264
- mom.v,
265
- ]);
266
-
267
- dispatchKernel(this.device, this._adamwPipeline, bg,
268
- [cdiv(p.numel, 256), 1, 1]);
269
-
270
- paramsBuf.destroy();
480
+ adamwUpdateInPlace(w, g, mom.m, mom.v, step);
481
+ uploadBuffer(this.device, p.buf, w);
271
482
  }
272
483
  }
273
484
 
274
- /**
275
- * Clip gradients to `maxNorm` in place. The reduce kernel writes Σg² into
276
- * `normSqBuf`; the scale kernel then rescales the grads. When `trackNorm` is set
277
- * we read Σg² back and return the true pre-clip L2 norm (√Σg²) — the kernel
278
- * already computed it, so this is only a small buffer readback (a GPU sync,
279
- * hence opt-in). Returns null when not tracking.
280
- */
281
- private async _clipGradients(gradBuf: GPUBuffer, numel: number, maxNorm: number, trackNorm = false): Promise<number | null> {
282
- const normSqBuf = createEmptyStorageBuffer(this.device, 4, true);
283
- this.device.queue.writeBuffer(normSqBuf, 0, new Float32Array([0.0]));
284
-
285
- const clipParams = new ArrayBuffer(8);
286
- new Uint32Array(clipParams, 0, 1).set([numel]);
287
- new Float32Array(clipParams, 4, 1).set([maxNorm * maxNorm]);
288
- const pBuf = createUniformBuffer(this.device, clipParams);
289
-
290
- const bg1 = createBindGroup(this.device, this._clipReducePipeline,
291
- [pBuf, gradBuf, normSqBuf]);
292
- dispatchKernel(this.device, this._clipReducePipeline, bg1,
293
- [cdiv(numel, 256), 1, 1]);
294
-
295
- // Read Σg² AFTER the reduce (the scale kernel only reads it, never overwrites),
296
- // so the returned norm is the pre-clip magnitude — the instability signal.
297
- let gradNorm: number | null = null;
298
- if (trackNorm) {
299
- const out = await readBuffer(this.device, normSqBuf, 4);
300
- gradNorm = Math.sqrt(Math.max(0, out[0] ?? 0));
485
+ /** Get-or-create moments for a synthetic parameter (a LoRA factor), by name. */
486
+ private _momentNamed(name: string, numel: number): AdamMoments {
487
+ let mom = this._moments.get(name);
488
+ if (!mom) {
489
+ mom = { m: new Float32Array(numel), v: new Float32Array(numel) };
490
+ this._moments.set(name, mom);
301
491
  }
492
+ return mom;
493
+ }
302
494
 
303
- const bg2 = createBindGroup(this.device, this._clipScalePipeline,
304
- [pBuf, gradBuf, normSqBuf]);
305
- dispatchKernel(this.device, this._clipScalePipeline, bg2,
306
- [cdiv(numel, 256), 1, 1]);
307
-
308
- pBuf.destroy();
309
- normSqBuf.destroy();
310
- return gradNorm;
495
+ /**
496
+ * Bytes of optimiser state (Adam m + v) this trainer holds. Under a shard that
497
+ * is 1/count of the full state; under LoRA it is the adapters' state, not the
498
+ * weights'. The measurable point of both features.
499
+ */
500
+ optimizerStateBytes(): number {
501
+ let n = 0;
502
+ for (const mom of this._moments.values()) n += mom.m.byteLength + mom.v.byteLength;
503
+ return n;
311
504
  }
312
505
 
313
506
  async evaluate(input: string | number[]): Promise<number> {
314
507
  let tokenIds: number[];
315
508
  if (typeof input === 'string') {
316
509
  if (!this.tokenizer) throw new Error('Tokenizer required for string input.');
317
- tokenIds = this.tokenizer.encode(input);
510
+ tokenIds = Array.from(this.tokenizer.encode(input));
318
511
  } else {
319
512
  tokenIds = Array.from(input);
320
513
  }
@@ -340,8 +533,44 @@ export class MambaTrainer {
340
533
  }
341
534
  }
342
535
 
343
- function buildChunks(ids: number[], seqLen: number): Array<{inputs: number[], targets: number[]}> {
344
- const chunks: Array<{inputs: number[], targets: number[]}> = [];
536
+ /**
537
+ * Derive the gradient engine's dimensions from a model, rejecting layer types
538
+ * the CPU reference cannot differentiate.
539
+ *
540
+ * A hybrid schedule (`jamba`/`zamba`, or any explicit schedule containing
541
+ * `mamba2`/`mamba3`/`attention`) fails LOUDLY here rather than silently training
542
+ * on gradients that do not exist — which is exactly what the old trainer did.
543
+ */
544
+ export function cpuDimsFor(model: HybridMambaModel): CpuModelDims {
545
+ const unsupported = model.layerSpecs
546
+ .map((s, i) => ({ i, type: s.type }))
547
+ .filter(({ type }) => !(CPU_GRADIENT_LAYER_TYPES as readonly string[]).includes(type));
548
+ if (unsupported.length > 0) {
549
+ const detail = unsupported.map(({ i, type }) => `layer${i}=${type}`).join(', ');
550
+ throw new Error(
551
+ `MambaTrainer: the gradient engine differentiates ${CPU_GRADIENT_LAYER_TYPES.join('/')} ` +
552
+ `layers only, but this model has ${detail}. Build the model with the default ` +
553
+ `mamba1 schedule to train it, or extend the CPU reference to cover those blocks.`
554
+ );
555
+ }
556
+
557
+ const first = model.layers[0] as unknown as { dInner?: number; dtRank?: number } | undefined;
558
+ const dInner = first?.dInner ?? model.config.expand * model.config.dModel;
559
+ const dtRank = first?.dtRank ?? Math.ceil(model.config.dModel / 16);
560
+
561
+ return {
562
+ vocabSize: model.config.vocabSize,
563
+ dModel : model.config.dModel,
564
+ dState : model.config.dState,
565
+ dConv : model.config.dConv,
566
+ dInner,
567
+ dtRank,
568
+ numLayers: model.layers.length,
569
+ };
570
+ }
571
+
572
+ export function buildChunks(ids: number[], seqLen: number): TrainChunk[] {
573
+ const chunks: TrainChunk[] = [];
345
574
  for (let start = 0; start + seqLen < ids.length; start += seqLen) {
346
575
  chunks.push({
347
576
  inputs : ids.slice(start, start + seqLen),
@@ -359,15 +588,29 @@ function buildChunks(ids: number[], seqLen: number): Array<{inputs: number[], ta
359
588
  return chunks;
360
589
  }
361
590
 
362
- function packAdamParams(
363
- numElements: number, lr: number, beta1: number, beta2: number,
364
- eps: number, weightDecay: number, beta1_t: number, beta2_t: number,
365
- maxDelta: number
366
- ): ArrayBuffer {
367
- // 48 bytes: u32 + 8×f32 = 36, padded to a 16-byte multiple for the uniform
368
- // layout (matches AdamParams' _pad0.._pad2 in WEIGHT_UPDATE_WGSL).
369
- const buf = new ArrayBuffer(48);
370
- new Uint32Array(buf, 0, 1).set([numElements]);
371
- new Float32Array(buf, 4, 8).set([lr, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta]);
372
- return buf;
591
+ /**
592
+ * Group equal-length chunks into batches of at most `batchSize`.
593
+ *
594
+ * `batchSize` used to be accepted and ignored — every chunk trained alone. The
595
+ * batch dimension the model and the gradient engine both take is real, so it is
596
+ * honoured here; a ragged tail chunk simply trains as its own batch of one.
597
+ */
598
+ export function buildBatches(chunks: TrainChunk[], batchSize: number): TrainBatch[] {
599
+ const batches: TrainBatch[] = [];
600
+ let i = 0;
601
+ while (i < chunks.length) {
602
+ const seqLen = chunks[i]!.inputs.length;
603
+ const group: TrainChunk[] = [];
604
+ while (i < chunks.length && group.length < batchSize && chunks[i]!.inputs.length === seqLen) {
605
+ group.push(chunks[i]!);
606
+ i++;
607
+ }
608
+ batches.push({
609
+ inputs : group.flatMap((c) => c.inputs),
610
+ targets: group.flatMap((c) => c.targets),
611
+ batch : group.length,
612
+ seqLen,
613
+ });
614
+ }
615
+ return batches;
373
616
  }