@seanhogg/builderforce-memory-engine 2026.6.39 → 2026.7.11

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 (61) hide show
  1. package/dist/codec/evermind_video.d.ts +35 -0
  2. package/dist/codec/evermind_video.d.ts.map +1 -0
  3. package/dist/codec/evermind_video.js +37 -0
  4. package/dist/codec/evermind_video.js.map +1 -0
  5. package/dist/codec/image_rvq.d.ts +28 -0
  6. package/dist/codec/image_rvq.d.ts.map +1 -0
  7. package/dist/codec/image_rvq.js +39 -0
  8. package/dist/codec/image_rvq.js.map +1 -0
  9. package/dist/codec/index.d.ts +13 -0
  10. package/dist/codec/index.d.ts.map +1 -0
  11. package/dist/codec/index.js +11 -0
  12. package/dist/codec/index.js.map +1 -0
  13. package/dist/codec/multimodal_vocab.d.ts +79 -0
  14. package/dist/codec/multimodal_vocab.d.ts.map +1 -0
  15. package/dist/codec/multimodal_vocab.js +96 -0
  16. package/dist/codec/multimodal_vocab.js.map +1 -0
  17. package/dist/codec/video_rvq.d.ts +116 -0
  18. package/dist/codec/video_rvq.d.ts.map +1 -0
  19. package/dist/codec/video_rvq.js +450 -0
  20. package/dist/codec/video_rvq.js.map +1 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/kernels/selective_scan.d.ts.map +1 -1
  26. package/dist/kernels/selective_scan.js +9 -3
  27. package/dist/kernels/selective_scan.js.map +1 -1
  28. package/dist/kernels/ssd.d.ts.map +1 -1
  29. package/dist/kernels/ssd.js +8 -2
  30. package/dist/kernels/ssd.js.map +1 -1
  31. package/dist/kernels/weight_update.d.ts.map +1 -1
  32. package/dist/kernels/weight_update.js +22 -3
  33. package/dist/kernels/weight_update.js.map +1 -1
  34. package/dist/model/mamba_model.d.ts +17 -0
  35. package/dist/model/mamba_model.d.ts.map +1 -1
  36. package/dist/model/mamba_model.js +27 -0
  37. package/dist/model/mamba_model.js.map +1 -1
  38. package/dist/moe/index.d.ts +1 -1
  39. package/dist/moe/index.d.ts.map +1 -1
  40. package/dist/moe/moe_package.d.ts +40 -2
  41. package/dist/moe/moe_package.d.ts.map +1 -1
  42. package/dist/moe/moe_package.js +90 -7
  43. package/dist/moe/moe_package.js.map +1 -1
  44. package/dist/training/trainer.d.ts +34 -2
  45. package/dist/training/trainer.d.ts.map +1 -1
  46. package/dist/training/trainer.js +76 -26
  47. package/dist/training/trainer.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/codec/evermind_video.ts +55 -0
  50. package/src/codec/image_rvq.ts +50 -0
  51. package/src/codec/index.ts +16 -0
  52. package/src/codec/multimodal_vocab.ts +115 -0
  53. package/src/codec/video_rvq.ts +496 -0
  54. package/src/index.ts +22 -0
  55. package/src/kernels/selective_scan.ts +9 -3
  56. package/src/kernels/ssd.ts +8 -2
  57. package/src/kernels/weight_update.ts +22 -3
  58. package/src/model/mamba_model.ts +27 -0
  59. package/src/moe/index.ts +1 -0
  60. package/src/moe/moe_package.ts +107 -6
  61. package/src/training/trainer.ts +92 -28
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { SharedExpertMoE, type MoEConfig } from "./moe_model.js";
15
15
  import { EvermindLM, type EvermindLMConfig } from "../lm/evermind_lm.js";
16
+ import { VideoRVQCodec } from "../codec/video_rvq.js";
16
17
 
17
18
  /** First 4 bytes of a serialised package: "EVM1". */
18
19
  const PKG_MAGIC = 0x45564d31;
@@ -21,6 +22,9 @@ const PKG_VERSION = 1;
21
22
  /** The kinds of model an `.evermind` package can carry. */
22
23
  export type EvermindModelType = "shared-expert-moe" | "evermind-lm";
23
24
 
25
+ /** Output modality. `text` (default) needs no codec; `video`/`image` bundle a VRQ codec. */
26
+ export type EvermindModality = "text" | "video" | "image";
27
+
24
28
  /** Human-facing description published with the model (the "model card"). */
25
29
  export interface EvermindModelCard {
26
30
  description: string;
@@ -38,14 +42,26 @@ export interface EvermindModelManifest {
38
42
  name: string;
39
43
  version: string;
40
44
  modelType: EvermindModelType;
45
+ /** Output modality; absent ⇒ "text". Video/image also carry a `codec` section. */
46
+ modality?: EvermindModality;
41
47
  /** Flat numeric model config (the constructor args), serialised verbatim. */
42
48
  config: Record<string, number>;
43
49
  /** Total trainable scalar parameters (for sizing / pricing / display). */
44
50
  paramCount: number;
45
51
  checkpointFormat: "MoE0" | "EVL0";
46
52
  checkpointFp16: boolean;
53
+ /**
54
+ * Byte length of the checkpoint section — REQUIRED when a codec section follows
55
+ * (so the reader knows where the checkpoint ends). Absent ⇒ checkpoint runs to
56
+ * end-of-blob (the original text-only layout, still read verbatim).
57
+ */
58
+ checkpointBytes?: number;
47
59
  /** 32-bit FNV-1a over the checkpoint bytes — integrity for download/purchase. */
48
60
  checksum: number;
61
+ /** Media codec section format (present for video/image packages). */
62
+ codecFormat?: "VRQ0";
63
+ /** 32-bit FNV-1a over the codec bytes — integrity for the bundled codec. */
64
+ codecChecksum?: number;
49
65
  card: EvermindModelCard;
50
66
  /** ISO timestamp, caller-supplied (the engine avoids Date for determinism). */
51
67
  createdAt?: string;
@@ -84,6 +100,8 @@ export class EvermindModelPackage {
84
100
  constructor(
85
101
  readonly manifest: EvermindModelManifest,
86
102
  readonly checkpoint: ArrayBuffer,
103
+ /** Serialized media codec ("VRQ0" blob), present for video/image packages. */
104
+ readonly codec?: ArrayBuffer,
87
105
  ) {}
88
106
 
89
107
  /** Package a trained model with its publishing metadata. */
@@ -126,17 +144,61 @@ export class EvermindModelPackage {
126
144
  return new EvermindModelPackage(manifest, checkpoint);
127
145
  }
128
146
 
129
- /** Serialise to a single `.evermind` blob: magic, version, manifest, checkpoint. */
147
+ /**
148
+ * Package a video/image generative model: its {@link EvermindLM} weights AND the
149
+ * {@link VideoRVQCodec} needed to turn generated tokens back into frames. Without
150
+ * the bundled codec a served media model would emit undecodable token ids — this
151
+ * is what makes an `.evermind` media artifact self-contained and servable.
152
+ */
153
+ static fromMediaLM(
154
+ lm: EvermindLM,
155
+ codec: VideoRVQCodec,
156
+ meta: PackageMeta & { modality: "video" | "image" },
157
+ ): EvermindModelPackage {
158
+ if (lm.config.vocabSize !== codec.vocabSize) {
159
+ throw new Error(
160
+ `fromMediaLM: LM vocabSize (${lm.config.vocabSize}) must equal codec.vocabSize (${codec.vocabSize})`,
161
+ );
162
+ }
163
+ const fp16 = meta.fp16 ?? false;
164
+ const checkpoint = lm.exportWeights({ fp16 });
165
+ const codecBlob = codec.serialize();
166
+ const manifest: EvermindModelManifest = {
167
+ schema: "evermind.model/1",
168
+ name: meta.name,
169
+ version: meta.version,
170
+ modelType: "evermind-lm",
171
+ modality: meta.modality,
172
+ config: lm.config as unknown as Record<string, number>,
173
+ paramCount: lm.parameters().reduce((n, p) => n + p.data.length, 0),
174
+ checkpointFormat: "EVL0",
175
+ checkpointFp16: fp16,
176
+ checkpointBytes: checkpoint.byteLength,
177
+ checksum: fnv1a(new Uint8Array(checkpoint)),
178
+ codecFormat: "VRQ0",
179
+ codecChecksum: fnv1a(new Uint8Array(codecBlob)),
180
+ card: meta.card,
181
+ ...(meta.createdAt ? { createdAt: meta.createdAt } : {}),
182
+ };
183
+ return new EvermindModelPackage(manifest, checkpoint, codecBlob);
184
+ }
185
+
186
+ /** Serialise to a single `.evermind` blob: magic, version, manifest, checkpoint[, codec]. */
130
187
  toBlob(): ArrayBuffer {
131
188
  const manifestBytes = new TextEncoder().encode(JSON.stringify(this.manifest));
132
189
  const headerBytes = 12; // magic, version, manifestLen
133
- const out = new ArrayBuffer(headerBytes + manifestBytes.byteLength + this.checkpoint.byteLength);
190
+ const codecBytes = this.codec?.byteLength ?? 0;
191
+ const out = new ArrayBuffer(headerBytes + manifestBytes.byteLength + this.checkpoint.byteLength + codecBytes);
134
192
  const head = new Uint32Array(out, 0, 3);
135
193
  head[0] = PKG_MAGIC;
136
194
  head[1] = PKG_VERSION;
137
195
  head[2] = manifestBytes.byteLength;
138
- new Uint8Array(out, headerBytes, manifestBytes.byteLength).set(manifestBytes);
139
- new Uint8Array(out, headerBytes + manifestBytes.byteLength).set(new Uint8Array(this.checkpoint));
196
+ let o = headerBytes;
197
+ new Uint8Array(out, o, manifestBytes.byteLength).set(manifestBytes);
198
+ o += manifestBytes.byteLength;
199
+ new Uint8Array(out, o, this.checkpoint.byteLength).set(new Uint8Array(this.checkpoint));
200
+ o += this.checkpoint.byteLength;
201
+ if (this.codec) new Uint8Array(out, o, codecBytes).set(new Uint8Array(this.codec));
140
202
  return out;
141
203
  }
142
204
 
@@ -152,8 +214,19 @@ export class EvermindModelPackage {
152
214
  }
153
215
  const manifestBytes = new Uint8Array(buffer, headerBytes, manifestLen);
154
216
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as EvermindModelManifest;
155
- const checkpoint = buffer.slice(headerBytes + manifestLen);
156
- return new EvermindModelPackage(manifest, checkpoint);
217
+ const bodyStart = headerBytes + manifestLen;
218
+ // A codec section follows the checkpoint only when the manifest fixed the
219
+ // checkpoint length; otherwise the checkpoint runs to end-of-blob (text layout).
220
+ const cpBytes = manifest.checkpointBytes;
221
+ if (cpBytes != null) {
222
+ if (bodyStart + cpBytes > buffer.byteLength) {
223
+ throw new Error("EvermindModelPackage.fromBlob: truncated (checkpointBytes exceeds blob)");
224
+ }
225
+ const checkpoint = buffer.slice(bodyStart, bodyStart + cpBytes);
226
+ const codec = bodyStart + cpBytes < buffer.byteLength ? buffer.slice(bodyStart + cpBytes) : undefined;
227
+ return new EvermindModelPackage(manifest, checkpoint, codec);
228
+ }
229
+ return new EvermindModelPackage(manifest, buffer.slice(bodyStart));
157
230
  }
158
231
 
159
232
  /** Verify integrity + structural sanity before trusting a downloaded package. */
@@ -176,6 +249,19 @@ export class EvermindModelPackage {
176
249
  if (actual !== this.manifest.checksum) {
177
250
  errors.push(`checksum mismatch (manifest ${this.manifest.checksum}, actual ${actual}) — corrupt or tampered checkpoint`);
178
251
  }
252
+ // Media packages must carry an integral codec — a served media model can't
253
+ // decode tokens → frames without it.
254
+ const modality = this.manifest.modality ?? "text";
255
+ if (modality === "video" || modality === "image") {
256
+ if (!this.codec) {
257
+ errors.push(`modality '${modality}' package is missing its codec section`);
258
+ } else {
259
+ const codecActual = fnv1a(new Uint8Array(this.codec));
260
+ if (codecActual !== this.manifest.codecChecksum) {
261
+ errors.push(`codec checksum mismatch (manifest ${this.manifest.codecChecksum}, actual ${codecActual}) — corrupt codec`);
262
+ }
263
+ }
264
+ }
179
265
  return { ok: errors.length === 0, errors };
180
266
  }
181
267
 
@@ -202,4 +288,19 @@ export class EvermindModelPackage {
202
288
  lm.loadWeights(this.checkpoint);
203
289
  return lm;
204
290
  }
291
+
292
+ /**
293
+ * Reconstruct a video/image model: the generative {@link EvermindLM} plus its
294
+ * {@link VideoRVQCodec}. Pair with `generateVideo` / `generateImage` to produce
295
+ * frames. This is what a media-serving path (gateway) loads and runs.
296
+ */
297
+ loadMediaLM(): { lm: EvermindLM; codec: VideoRVQCodec; modality: EvermindModality } {
298
+ const v = this.validate();
299
+ if (!v.ok) throw new Error(`EvermindModelPackage.loadMediaLM: ${v.errors.join("; ")}`);
300
+ const modality = this.manifest.modality ?? "text";
301
+ if (modality !== "video" && modality !== "image") {
302
+ throw new Error(`loadMediaLM: package modality is '${modality}', use loadLM()`);
303
+ }
304
+ return { lm: this.loadLM(), codec: VideoRVQCodec.deserialize(this.codec!), modality };
305
+ }
205
306
  }
@@ -9,6 +9,7 @@ import {
9
9
  createComputePipeline,
10
10
  createBindGroup,
11
11
  dispatchKernel,
12
+ readBuffer,
12
13
  cdiv,
13
14
  } from '../utils/gpu_utils.js';
14
15
 
@@ -29,9 +30,26 @@ export interface TrainOptions {
29
30
  beta2?: number;
30
31
  eps?: number;
31
32
  wsla?: boolean;
32
- onEpochEnd?: ((epoch: number, loss: number) => void) | null;
33
+ /** Trust region: max |Δθ| per optimizer step. 0 disables. Defaults to
34
+ * {@link WSLA_MAX_DELTA} in WSLA (write-through) mode, else 0 (full training). */
35
+ 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. */
40
+ trackGradNorm?: boolean;
41
+ onEpochEnd?: ((epoch: number, loss: number, gradNorm?: number) => void) | null;
33
42
  }
34
43
 
44
+ /**
45
+ * Default per-step trust region for WSLA / write-through adaptation. Small
46
+ * enough that a single `adapt()` nudges the narrow params without lurching, so
47
+ * repeated adapts stay stable (and any that regress are cheaply rolled back by
48
+ * the session). Full-training callers pass `maxDelta: 0` (or omit it in
49
+ * non-WSLA mode) for unbounded steps.
50
+ */
51
+ export const WSLA_MAX_DELTA = 0.05;
52
+
35
53
  interface AdamMoments {
36
54
  m: GPUBuffer;
37
55
  v: GPUBuffer;
@@ -45,13 +63,22 @@ interface AdamHyperparams {
45
63
  eps: number;
46
64
  beta1_t: number;
47
65
  beta2_t: number;
66
+ maxDelta: number;
48
67
  }
49
68
 
50
69
  export class MambaTrainer {
51
70
  model: HybridMambaModel;
52
71
  tokenizer: BPETokenizer | null;
53
72
  device: GPUDevice;
54
- private _moments: AdamMoments[] | null;
73
+ /**
74
+ * Adam moments keyed by parameter NAME (not array index). Name-keying is what
75
+ * lets WSLA toggle safely: a narrow write-through step updates only the
76
+ * `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.
80
+ */
81
+ private _moments: Map<string, AdamMoments>;
55
82
  private _step: number;
56
83
  private _adamwPipeline: GPUComputePipeline;
57
84
  private _clipReducePipeline: GPUComputePipeline;
@@ -62,7 +89,7 @@ export class MambaTrainer {
62
89
  this.tokenizer = tokenizer;
63
90
  this.device = model.device;
64
91
 
65
- this._moments = null;
92
+ this._moments = new Map();
66
93
  this._step = 0;
67
94
 
68
95
  this._adamwPipeline = createComputePipeline(this.device, WEIGHT_UPDATE_WGSL, 'adamw_update');
@@ -70,12 +97,17 @@ export class MambaTrainer {
70
97
  this._clipScalePipeline = createComputePipeline(this.device, GRAD_CLIP_WGSL, 'grad_clip_scale');
71
98
  }
72
99
 
73
- private _initMoments(): void {
74
- if (this._moments) return;
75
- this._moments = this.model.parameters().map(p => ({
76
- m: createEmptyStorageBuffer(this.device, p.numel * 4, false),
77
- v: createEmptyStorageBuffer(this.device, p.numel * 4, false),
78
- }));
100
+ /** Get-or-create the Adam first/second moments for a parameter, by name. */
101
+ private _momentFor(p: BlockParam): AdamMoments {
102
+ let mom = this._moments.get(p.name);
103
+ if (!mom) {
104
+ mom = {
105
+ m: createEmptyStorageBuffer(this.device, p.numel * 4, false),
106
+ v: createEmptyStorageBuffer(this.device, p.numel * 4, false),
107
+ };
108
+ this._moments.set(p.name, mom);
109
+ }
110
+ return mom;
79
111
  }
80
112
 
81
113
  async train(input: string | number[], opts: TrainOptions = {}): Promise<number[]> {
@@ -90,8 +122,11 @@ export class MambaTrainer {
90
122
  beta2 = 0.999,
91
123
  eps = 1e-8,
92
124
  wsla = false,
125
+ trackGradNorm = false,
93
126
  onEpochEnd = null,
94
127
  } = opts;
128
+ // Trust region defaults ON for write-through (WSLA) adapts, OFF otherwise.
129
+ const maxDelta = opts.maxDelta ?? (wsla ? WSLA_MAX_DELTA : 0);
95
130
 
96
131
  if (wsla) this.model.setWSLAMode(true);
97
132
 
@@ -117,27 +152,33 @@ export class MambaTrainer {
117
152
  throw new Error('Input is too short to form any training chunk.');
118
153
  }
119
154
 
120
- this._initMoments();
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.
121
158
 
122
159
  const epochLosses: number[] = [];
123
160
 
124
161
  for (let epoch = 0; epoch < epochs; epoch++) {
125
162
  let epochLoss = 0;
163
+ let epochGradNorm = 0;
126
164
  let numSteps = 0;
127
165
 
128
166
  for (const { inputs, targets } of chunks) {
129
- const loss = await this._trainStep(
167
+ const { loss, gradNorm } = await this._trainStep(
130
168
  inputs, targets, batchSize,
131
- { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, wsla }
169
+ { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, wsla, maxDelta, trackGradNorm }
132
170
  );
133
171
  epochLoss += loss;
172
+ if (gradNorm != null) epochGradNorm += gradNorm;
134
173
  numSteps++;
135
174
  }
136
175
 
137
176
  const avgLoss = epochLoss / numSteps;
138
177
  epochLosses.push(avgLoss);
139
178
 
140
- if (onEpochEnd) onEpochEnd(epoch + 1, avgLoss);
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);
141
182
  }
142
183
 
143
184
  if (wsla) this.model.setWSLAMode(false);
@@ -148,9 +189,9 @@ export class MambaTrainer {
148
189
  inputs: number[],
149
190
  targets: number[],
150
191
  batch: number,
151
- hyperparams: TrainOptions & { learningRate: number; maxGradNorm: number; weightDecay: number; beta1: number; beta2: number; eps: number }
152
- ): Promise<number> {
153
- const { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps } = hyperparams;
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;
154
195
 
155
196
  this._step++;
156
197
  const seqLen = inputs.length;
@@ -177,21 +218,24 @@ export class MambaTrainer {
177
218
 
178
219
  const dLogitsBuf = createStorageBuffer(this.device, dLogits, false);
179
220
 
180
- await this._clipGradients(dLogitsBuf, dLogits.length, maxGradNorm);
221
+ const gradNorm = await this._clipGradients(dLogitsBuf, dLogits.length, maxGradNorm, !!trackGradNorm);
181
222
 
182
- const params = this.model.parameters();
223
+ // Only the trainable set is updated. Under WSLA (write-through) that is
224
+ // the narrow per-layer subset and the backbone (incl. every A_log) stays
225
+ // frozen — the guarantee that repeated adapts can't destabilise the SSM.
226
+ const params = this.model.getTrainableParams();
183
227
  const beta1_t = Math.pow(beta1, this._step);
184
228
  const beta2_t = Math.pow(beta2, this._step);
185
229
 
186
230
  await this._adamwStep(
187
231
  params, [dLogitsBuf],
188
- { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t }
232
+ { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta }
189
233
  );
190
234
 
191
235
  dLogitsBuf.destroy();
192
236
  gpuLogits.destroy();
193
237
 
194
- return loss;
238
+ return { loss, gradNorm };
195
239
  }
196
240
 
197
241
  private async _adamwStep(
@@ -199,7 +243,7 @@ export class MambaTrainer {
199
243
  gradBufs: GPUBuffer[],
200
244
  hp: AdamHyperparams
201
245
  ): Promise<void> {
202
- const { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t } = hp;
246
+ const { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta } = hp;
203
247
 
204
248
  for (let i = 0; i < params.length; i++) {
205
249
  const p = params[i]!;
@@ -207,16 +251,17 @@ export class MambaTrainer {
207
251
 
208
252
  if (!gradBuf || gradBuf.size < p.numel * 4) continue;
209
253
 
254
+ const mom = this._momentFor(p);
210
255
  const paramsBuf = createUniformBuffer(this.device, packAdamParams(
211
- p.numel, learningRate, beta1, beta2, eps, weightDecay, beta1_t, beta2_t
256
+ p.numel, learningRate, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta
212
257
  ));
213
258
 
214
259
  const bg = createBindGroup(this.device, this._adamwPipeline, [
215
260
  paramsBuf,
216
261
  p.buf,
217
262
  gradBuf,
218
- this._moments![i]!.m,
219
- this._moments![i]!.v,
263
+ mom.m,
264
+ mom.v,
220
265
  ]);
221
266
 
222
267
  dispatchKernel(this.device, this._adamwPipeline, bg,
@@ -226,7 +271,14 @@ export class MambaTrainer {
226
271
  }
227
272
  }
228
273
 
229
- private async _clipGradients(gradBuf: GPUBuffer, numel: number, maxNorm: number): Promise<void> {
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> {
230
282
  const normSqBuf = createEmptyStorageBuffer(this.device, 4, true);
231
283
  this.device.queue.writeBuffer(normSqBuf, 0, new Float32Array([0.0]));
232
284
 
@@ -240,6 +292,14 @@ export class MambaTrainer {
240
292
  dispatchKernel(this.device, this._clipReducePipeline, bg1,
241
293
  [cdiv(numel, 256), 1, 1]);
242
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));
301
+ }
302
+
243
303
  const bg2 = createBindGroup(this.device, this._clipScalePipeline,
244
304
  [pBuf, gradBuf, normSqBuf]);
245
305
  dispatchKernel(this.device, this._clipScalePipeline, bg2,
@@ -247,6 +307,7 @@ export class MambaTrainer {
247
307
 
248
308
  pBuf.destroy();
249
309
  normSqBuf.destroy();
310
+ return gradNorm;
250
311
  }
251
312
 
252
313
  async evaluate(input: string | number[]): Promise<number> {
@@ -300,10 +361,13 @@ function buildChunks(ids: number[], seqLen: number): Array<{inputs: number[], ta
300
361
 
301
362
  function packAdamParams(
302
363
  numElements: number, lr: number, beta1: number, beta2: number,
303
- eps: number, weightDecay: number, beta1_t: number, beta2_t: number
364
+ eps: number, weightDecay: number, beta1_t: number, beta2_t: number,
365
+ maxDelta: number
304
366
  ): ArrayBuffer {
305
- const buf = new ArrayBuffer(32);
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);
306
370
  new Uint32Array(buf, 0, 1).set([numElements]);
307
- new Float32Array(buf, 4, 7).set([lr, beta1, beta2, eps, weightDecay, beta1_t, beta2_t]);
371
+ new Float32Array(buf, 4, 8).set([lr, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta]);
308
372
  return buf;
309
373
  }