@seanhogg/builderforce-memory 2026.6.39 → 2026.7.12

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 (33) hide show
  1. package/dist/cognition/EvermindCognition.d.ts.map +1 -1
  2. package/dist/cognition/EvermindCognition.js +17 -7
  3. package/dist/cognition/EvermindCognition.js.map +1 -1
  4. package/dist/diagnostics/stack-diagnostic.d.ts +9 -0
  5. package/dist/diagnostics/stack-diagnostic.d.ts.map +1 -1
  6. package/dist/diagnostics/stack-diagnostic.js +28 -1
  7. package/dist/diagnostics/stack-diagnostic.js.map +1 -1
  8. package/dist/distillation/DistillationEngine.d.ts +6 -0
  9. package/dist/distillation/DistillationEngine.d.ts.map +1 -1
  10. package/dist/distillation/DistillationEngine.js +10 -4
  11. package/dist/distillation/DistillationEngine.js.map +1 -1
  12. package/dist/memory/MemoryStore.d.ts +10 -0
  13. package/dist/memory/MemoryStore.d.ts.map +1 -1
  14. package/dist/memory/MemoryStore.js +13 -2
  15. package/dist/memory/MemoryStore.js.map +1 -1
  16. package/dist/session/session.d.ts +22 -0
  17. package/dist/session/session.d.ts.map +1 -1
  18. package/dist/session/session.js +45 -2
  19. package/dist/session/session.js.map +1 -1
  20. package/dist/workflow/steps.d.ts.map +1 -1
  21. package/dist/workflow/steps.js +206 -1
  22. package/dist/workflow/steps.js.map +1 -1
  23. package/dist/workflow/templates.d.ts.map +1 -1
  24. package/dist/workflow/templates.js +8 -0
  25. package/dist/workflow/templates.js.map +1 -1
  26. package/package.json +4 -3
  27. package/src/cognition/EvermindCognition.ts +24 -7
  28. package/src/diagnostics/stack-diagnostic.ts +38 -1
  29. package/src/distillation/DistillationEngine.ts +17 -4
  30. package/src/memory/MemoryStore.ts +14 -2
  31. package/src/session/session.ts +72 -2
  32. package/src/workflow/steps.ts +242 -0
  33. package/src/workflow/templates.ts +8 -0
@@ -66,9 +66,46 @@ export interface StackDiagnosticResult {
66
66
  firstFailure?: StackStepResult;
67
67
  /** Outputs produced by the run (e.g. `artifacts.evermind` for a build workflow). */
68
68
  artifacts: Record<string, unknown>;
69
+ /**
70
+ * Curated STRUCTURED metrics the run computed (loss curve, perplexity/top-1,
71
+ * dataset quality, pass@1, convergence, codec MSE) — the numbers steps previously
72
+ * left buried in `ctx.bag` and only echoed into a human `detail` string, forcing a
73
+ * UI to regex-parse them. A UI can now chart/gauge these directly. Only the
74
+ * allow-listed metric keys are included; heavy scratch (model blob, tokenizer,
75
+ * corpus, candidate list) is deliberately excluded.
76
+ */
77
+ metrics: Record<string, unknown>;
69
78
  totalMs: number;
70
79
  }
71
80
 
81
+ /**
82
+ * `ctx.bag` keys that are genuine metrics/reports worth surfacing on the result.
83
+ * Everything else in the bag is scratch (large objects / model instances) and is
84
+ * kept out so the metrics payload stays small and serializable.
85
+ */
86
+ const METRIC_BAG_KEYS = [
87
+ 'trainingHistory', // per-epoch loss curve (train-model / video-train)
88
+ 'datasetMetrics', // { words, sequences, avgSeqLen, duplicateRatio }
89
+ 'benchmark', // BenchmarkReport: perplexity, bitsPerToken, top1/topK, tok/s
90
+ 'converged', // convergence verdict + loss-drop numbers
91
+ 'codeBenchmark', // { pass1, passedTasks, totalTasks, perTask[] }
92
+ 'codeParse', // code-parse check result
93
+ 'codeEval', // code-eval check result
94
+ 'videoReconMSE', // video-roundtrip reconstruction error
95
+ 'videoCodecMSE', // video-train codec reconstruction error
96
+ 'videoTrainingHistory', // per-epoch loss curve for the video model
97
+ 'sample', // generate-check sample output
98
+ ] as const;
99
+
100
+ /** Pick the present metric keys out of the bag (skips absent + undefined values). */
101
+ function collectMetrics(bag: Record<string, unknown>): Record<string, unknown> {
102
+ const out: Record<string, unknown> = {};
103
+ for (const key of METRIC_BAG_KEYS) {
104
+ if (bag[key] !== undefined) out[key] = bag[key];
105
+ }
106
+ return out;
107
+ }
108
+
72
109
  /**
73
110
  * Run the steps in order, capturing each result. Never throws — a step that
74
111
  * throws becomes a `fail` row, and the run continues so the caller sees the full
@@ -99,5 +136,5 @@ export async function runStackDiagnostic(
99
136
  opts.onStep?.(result);
100
137
  }
101
138
 
102
- return { ok: !firstFailure, steps: results, ...(firstFailure ? { firstFailure } : {}), artifacts: ctx.artifacts, totalMs: now() - start };
139
+ return { ok: !firstFailure, steps: results, ...(firstFailure ? { firstFailure } : {}), artifacts: ctx.artifacts, metrics: collectMetrics(ctx.bag), totalMs: now() - start };
103
140
  }
@@ -82,6 +82,10 @@ export interface DistillResult {
82
82
  skipReason? : string;
83
83
  /** Number of past exemplars rehearsed alongside this one (EVM-5). */
84
84
  rehearsed? : number;
85
+ /** Student's pre-adapt perplexity on the teacher output, when the quality gate
86
+ * measured it — a "how novel was this exemplar" signal. Kept even when the
87
+ * exemplar was NOT skipped (previously discarded on the trained path). */
88
+ gatePerplexity?: number;
85
89
  }
86
90
 
87
91
  export interface DistillBatchResult {
@@ -100,6 +104,8 @@ export interface DistillationLog {
100
104
  skipReason? : string;
101
105
  finalLoss? : number;
102
106
  epochs : number;
107
+ /** Pre-adapt perplexity on the teacher output (when the quality gate measured it). */
108
+ gatePerplexity? : number;
103
109
  }
104
110
 
105
111
  /** Maximum number of distillation log entries to retain in memory. */
@@ -194,6 +200,11 @@ export class DistillationEngine {
194
200
 
195
201
  // ── Quality gate ──────────────────────────────────────────────────────
196
202
 
203
+ // Pre-adapt novelty of this exemplar (student perplexity on the teacher output).
204
+ // Measured by the gate below; hoisted so it survives to the TRAINED path instead
205
+ // of being dropped for exactly the exemplars we go on to learn from.
206
+ let gatePerplexity: number | undefined;
207
+
197
208
  if (opts.qualityGate) {
198
209
  const gate = opts.qualityGate;
199
210
 
@@ -216,19 +227,19 @@ export class DistillationEngine {
216
227
  }
217
228
 
218
229
  if (gate.maxPerplexity != null) {
219
- let perplexity: number | undefined;
220
230
  try {
221
- perplexity = await this._runtime.evaluate(teacherOutput);
231
+ gatePerplexity = await this._runtime.evaluate(teacherOutput);
222
232
  } catch {
223
233
  // Evaluation failure is non-fatal — proceed with adaptation
224
234
  }
225
- if (perplexity != null && perplexity < gate.maxPerplexity) {
235
+ if (gatePerplexity != null && gatePerplexity < gate.maxPerplexity) {
226
236
  const result: DistillResult = {
227
237
  input,
228
238
  teacherOutput,
229
239
  adaptResult : { losses: [], epochCount: 0, durationMs: 0 },
230
240
  skipped : true,
231
241
  skipReason : 'already_learned',
242
+ gatePerplexity,
232
243
  };
233
244
  this._appendLog({
234
245
  input,
@@ -236,6 +247,7 @@ export class DistillationEngine {
236
247
  skipped : true,
237
248
  skipReason : 'already_learned',
238
249
  epochs : 0,
250
+ gatePerplexity,
239
251
  });
240
252
  return result;
241
253
  }
@@ -270,9 +282,10 @@ export class DistillationEngine {
270
282
  skipped : false,
271
283
  finalLoss : adaptResult.losses.at(-1),
272
284
  epochs : adaptResult.epochCount,
285
+ gatePerplexity,
273
286
  });
274
287
 
275
- return { input, teacherOutput, adaptResult, skipped: false, rehearsed: rehearsed.length };
288
+ return { input, teacherOutput, adaptResult, skipped: false, rehearsed: rehearsed.length, gatePerplexity };
276
289
  }
277
290
 
278
291
  /**
@@ -328,6 +328,16 @@ export class MemoryStore {
328
328
  * reason, it transparently falls back to Jaccard word-overlap similarity.
329
329
  */
330
330
  async recallSimilar(query: string, topK: number, runtime?: SSMRuntimeRef): Promise<MemoryEntry[]> {
331
+ return (await this.recallSimilarScored(query, topK, runtime)).map(s => s.entry);
332
+ }
333
+
334
+ /**
335
+ * Like {@link recallSimilar} but returns each hit's real 0..1 relevance score
336
+ * (SSM-embedding cosine, or Jaccard on the fallback path) alongside the entry —
337
+ * so callers can rank/threshold by TRUE similarity instead of list position.
338
+ * {@link recallSimilar} is the score-dropping convenience wrapper over this.
339
+ */
340
+ async recallSimilarScored(query: string, topK: number, runtime?: SSMRuntimeRef): Promise<Array<{ entry: MemoryEntry; score: number }>> {
331
341
  const all = await this.recallAll();
332
342
  if (all.length === 0) return [];
333
343
 
@@ -344,7 +354,9 @@ export class MemoryStore {
344
354
  ? synced.index.search(queryVec, topK)
345
355
  // Below it, an exact scan over the (already-embedded) vectors.
346
356
  : denseSearch(queryVec, synced.items, topK, this._annThreshold);
347
- return hits.map(h => synced.byKey.get(h.id)).filter((e): e is MemoryEntry => !!e);
357
+ return hits
358
+ .map(h => { const entry = synced.byKey.get(h.id); return entry ? { entry, score: h.score } : null; })
359
+ .filter((e): e is { entry: MemoryEntry; score: number } => !!e);
348
360
  }
349
361
  }
350
362
  // Any failure falls through to the Jaccard path below.
@@ -359,7 +371,7 @@ export class MemoryStore {
359
371
  });
360
372
 
361
373
  scored.sort((a, b) => b.score - a.score);
362
- return scored.slice(0, topK).map(s => s.entry);
374
+ return scored.slice(0, topK);
363
375
  }
364
376
 
365
377
  /**
@@ -160,12 +160,34 @@ export interface AdaptOptions {
160
160
  wsla? : boolean; // Default: true (WSLA fast-adapt mode)
161
161
  fullTrain? : boolean; // Convenience alias: sets wsla=false and epochs=5
162
162
  onProgress? : (epoch: number, loss: number) => void;
163
+ /**
164
+ * Snapshot the weights before adapting and RESTORE them if the adapt produced
165
+ * a non-finite loss or clearly regressed the model's perplexity on the same
166
+ * text. Default true — this is the last line of defence behind WSLA + the
167
+ * per-step trust region that keeps write-through learning from "dying after
168
+ * several executions". Set false to skip the snapshot cost (e.g. bulk full
169
+ * training where you validate externally).
170
+ */
171
+ rollbackOnRegression? : boolean; // Default: true
172
+ /** Regression tolerance: roll back if post-perplexity > pre × this. Default 1.25. */
173
+ regressionTolerance? : number;
163
174
  }
164
175
 
165
176
  export interface AdaptResult {
166
177
  losses : number[];
167
178
  epochCount : number;
168
179
  durationMs : number;
180
+ /** True when the adapt was reverted to the pre-adapt snapshot (see below). */
181
+ rolledBack? : boolean;
182
+ /** Why it rolled back, when it did (`non-finite loss` | `perplexity regression`). */
183
+ rollbackReason?: string;
184
+ /** Perplexity on the adapt text before / after (when measured). */
185
+ perplexityBefore?: number;
186
+ perplexityAfter? : number;
187
+ /** Mean pre-clip gradient L2 norm across the adapt's epochs — the standard
188
+ * early-warning for an unstable update (it spikes before a loss goes non-finite).
189
+ * Undefined when the trainer didn't measure it. */
190
+ gradNorm?: number;
169
191
  }
170
192
 
171
193
  export type StorageTarget = 'indexedDB' | 'download' | 'fileSystem';
@@ -531,20 +553,68 @@ export class MambaSession {
531
553
  );
532
554
  }
533
555
 
556
+ const rollbackOnRegression = options.rollbackOnRegression ?? true;
557
+ const regressionTolerance = options.regressionTolerance ?? 1.25;
558
+
559
+ // Snapshot the weights and measure the pre-adapt perplexity so a bad adapt
560
+ // can be reverted. This is what makes repeated write-through learning safe:
561
+ // an update that produces NaN/Inf or clearly worsens the model is undone
562
+ // instead of persisting and compounding across executions.
563
+ let snapshot: ArrayBuffer | null = null;
564
+ let perplexityBefore: number | undefined;
565
+ if (rollbackOnRegression) {
566
+ snapshot = await this._model.exportWeights();
567
+ perplexityBefore = await this._trainer.evaluate(text).catch(() => undefined);
568
+ }
569
+
534
570
  const startTime = Date.now();
571
+ // Capture the per-epoch grad norm (instability signal) without disturbing the
572
+ // caller's own progress callback.
573
+ const gradNorms: number[] = [];
535
574
  const losses = await this._trainer.train(text, {
536
575
  epochs,
537
576
  learningRate,
538
577
  seqLen,
539
578
  wsla,
540
- onEpochEnd: onProgress ?? null,
579
+ trackGradNorm: true,
580
+ onEpochEnd: (epoch, loss, gradNorm) => {
581
+ if (typeof gradNorm === 'number' && Number.isFinite(gradNorm)) gradNorms.push(gradNorm);
582
+ onProgress?.(epoch, loss);
583
+ },
541
584
  });
585
+ const gradNorm = gradNorms.length
586
+ ? gradNorms.reduce((a, b) => a + b, 0) / gradNorms.length
587
+ : undefined;
542
588
 
543
- return {
589
+ const base: AdaptResult = {
544
590
  losses,
545
591
  epochCount : losses.length,
546
592
  durationMs : Date.now() - startTime,
593
+ gradNorm,
547
594
  };
595
+
596
+ if (!rollbackOnRegression || !snapshot) return base;
597
+
598
+ // Validate the adapt. Roll back on a non-finite loss (the update poisoned
599
+ // the weights) or a perplexity regression beyond tolerance.
600
+ const nonFiniteLoss = losses.some((l) => !Number.isFinite(l));
601
+ const perplexityAfter = await this._trainer.evaluate(text).catch(() => Number.POSITIVE_INFINITY);
602
+ const regressed =
603
+ Number.isFinite(perplexityBefore ?? NaN) && Number.isFinite(perplexityAfter) &&
604
+ perplexityAfter > (perplexityBefore as number) * regressionTolerance;
605
+
606
+ if (nonFiniteLoss || !Number.isFinite(perplexityAfter) || regressed) {
607
+ await this._model.loadWeights(snapshot);
608
+ return {
609
+ ...base,
610
+ rolledBack : true,
611
+ rollbackReason: nonFiniteLoss || !Number.isFinite(perplexityAfter) ? 'non-finite loss' : 'perplexity regression',
612
+ perplexityBefore,
613
+ perplexityAfter,
614
+ };
615
+ }
616
+
617
+ return { ...base, rolledBack: false, perplexityBefore, perplexityAfter };
548
618
  }
549
619
 
550
620
  // ── Evaluation ─────────────────────────────────────────────────────────────
@@ -18,9 +18,13 @@ import {
18
18
  exportEvermind,
19
19
  importEvermind,
20
20
  benchmarkText,
21
+ VideoRVQCodec,
22
+ buildVideoSequence,
23
+ generateVideo,
21
24
  type ExportFormat,
22
25
  type BenchmarkReport,
23
26
  type ImportOptions,
27
+ type Video,
24
28
  } from "@seanhogg/builderforce-memory-engine";
25
29
  import { EvermindCognition } from "../cognition/index.js";
26
30
  import { hybridRetrieve, chunkText } from "../retrieval/index.js";
@@ -236,6 +240,63 @@ function corpusToSequences(corpus: string, tok: BPETokenizer): number[][] {
236
240
  .filter((ids) => ids.length >= 2);
237
241
  }
238
242
 
243
+ /** Build a VideoRVQCodec from step params (shared by the video build steps). */
244
+ function makeVideoCodec(cfg: WorkflowStepConfig, textVocabSize: number): VideoRVQCodec {
245
+ return new VideoRVQCodec({
246
+ height: pNum(cfg, "height", 8),
247
+ width: pNum(cfg, "width", 8),
248
+ channels: pNum(cfg, "channels", 3),
249
+ patch: pNum(cfg, "patch", 4),
250
+ levels: pNum(cfg, "levels", 2),
251
+ codebookSize: pNum(cfg, "codebookSize", 16),
252
+ keyframeInterval: pNum(cfg, "keyframeInterval", 12),
253
+ textVocabSize,
254
+ seed: pNum(cfg, "seed", 7),
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Deterministic synthetic clips so the video steps are self-runnable (the way the
260
+ * text steps default their corpus). A smooth spatial pattern that drifts a little
261
+ * per frame — enough temporal redundancy to exercise the inter-frame path.
262
+ */
263
+ function synthVideos(count: number, frames: number, h: number, w: number, c: number): Video[] {
264
+ const out: Video[] = [];
265
+ for (let v = 0; v < count; v++) {
266
+ const clip: Video = [];
267
+ for (let t = 0; t < frames; t++) {
268
+ const f = new Float32Array(h * w * c);
269
+ for (let y = 0; y < h; y++) {
270
+ for (let x = 0; x < w; x++) {
271
+ for (let ch = 0; ch < c; ch++) {
272
+ f[((y * w) + x) * c + ch] = 0.5 + 0.4 * Math.sin((x + v * 1.3 + t * 0.3) * 0.7 + y * 0.5 + ch);
273
+ }
274
+ }
275
+ }
276
+ clip.push(f);
277
+ }
278
+ out.push(clip);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /** Mean per-pixel reconstruction MSE of a codec over clips (the video roundtrip metric). */
284
+ function videoReconMSE(codec: VideoRVQCodec, videos: Video[]): number {
285
+ let se = 0;
286
+ let n = 0;
287
+ for (const v of videos) {
288
+ const r = codec.decode(codec.encode(v));
289
+ for (let t = 0; t < v.length; t++) {
290
+ for (let k = 0; k < v[t]!.length; k++) {
291
+ const d = v[t]![k]! - r[t]![k]!;
292
+ se += d * d;
293
+ }
294
+ n += v[t]!.length;
295
+ }
296
+ }
297
+ return n > 0 ? se / n : 0;
298
+ }
299
+
239
300
  interface Registered {
240
301
  info: StepTypeInfo;
241
302
  factory: StepFactory;
@@ -552,6 +613,127 @@ export const BUILTIN_STEPS: Record<string, Registered> = {
552
613
  return `artifact ${blob.byteLength} bytes; trained vs served output matches`;
553
614
  }),
554
615
  },
616
+ "video-train": {
617
+ info: {
618
+ type: "video-train",
619
+ layer: "BUILD",
620
+ label: "Train video model",
621
+ description:
622
+ "Fit a temporal residual-VQ codec on clips, then train an EvermindLM on the video token streams — the model that generates video (params: height, width, channels, patch, levels, codebookSize, clips, frames, epochs, lr)",
623
+ },
624
+ factory: (cfg) =>
625
+ mkStep(cfg, "BUILD", "Train video model (codec + EvermindLM)", async (ctx) => {
626
+ const videos =
627
+ (ctx.bag.videos as Video[] | undefined) ??
628
+ synthVideos(
629
+ pNum(cfg, "clips", 3),
630
+ pNum(cfg, "frames", 4),
631
+ pNum(cfg, "height", 8),
632
+ pNum(cfg, "width", 8),
633
+ pNum(cfg, "channels", 3),
634
+ );
635
+ if (videos.length === 0) throw new Error("no clips to train on — provide ctx.bag.videos or a positive 'clips' param");
636
+
637
+ // Learn the codec's codebooks, then autoregress the (unchanged) EvermindLM
638
+ // over the resulting token streams. Text region is 0 for a pure-video model.
639
+ const codec = makeVideoCodec(cfg, 0);
640
+ const codecMSE = codec.fit(videos, { iterations: pNum(cfg, "codecIterations", 10) });
641
+ const sequences = videos.map((v) => buildVideoSequence(codec, [], v));
642
+ const model = new EvermindLM({
643
+ vocabSize: codec.vocabSize,
644
+ dModel: pNum(cfg, "dModel", 32),
645
+ numLayers: pNum(cfg, "numLayers", 2),
646
+ hiddenDim: pNum(cfg, "hiddenDim", 48),
647
+ seed: pNum(cfg, "seed", 11),
648
+ });
649
+ const epochs = pNum(cfg, "epochs", 60);
650
+ const history = new EvermindLMTrainer(model, { lr: pNum(cfg, "lr", 0.03), epochs }).fit(sequences);
651
+
652
+ ctx.bag.videoCodec = codec;
653
+ ctx.bag.videoModel = model;
654
+ ctx.bag.videoTrainingHistory = history;
655
+ // Surface the codec's reconstruction MSE as a metric (symmetric with the
656
+ // video-roundtrip step's ctx.bag.videoReconMSE) so it isn't stranded in the
657
+ // human detail string — runStackDiagnostic().metrics can chart it.
658
+ ctx.bag.videoCodecMSE = codecMSE;
659
+ const last = history.at(-1) ?? 0;
660
+ return `codec MSE ${codecMSE.toFixed(4)}; trained ${epochs} epochs over ${sequences.length} clip(s), loss ${last.toFixed(3)}`;
661
+ }),
662
+ },
663
+ "video-roundtrip": {
664
+ info: {
665
+ type: "video-roundtrip",
666
+ layer: "BUILD",
667
+ label: "Video round-trip",
668
+ description:
669
+ "Fit the video codec and gate reconstruction quality (encode→decode MSE), then smoke-test generate→decode yields valid frames (params: maxMSE, height, width, levels, codebookSize)",
670
+ },
671
+ factory: (cfg) =>
672
+ mkStep(cfg, "BUILD", "Video codec round-trip (encode→decode)", async (ctx) => {
673
+ const videos =
674
+ (ctx.bag.videos as Video[] | undefined) ??
675
+ synthVideos(
676
+ pNum(cfg, "clips", 3),
677
+ pNum(cfg, "frames", 4),
678
+ pNum(cfg, "height", 8),
679
+ pNum(cfg, "width", 8),
680
+ pNum(cfg, "channels", 3),
681
+ );
682
+ // Reuse a codec trained earlier if present; otherwise fit a fresh one.
683
+ let codec = ctx.bag.videoCodec as VideoRVQCodec | undefined;
684
+ const before = codec ? videoReconMSE(codec, videos) : Infinity;
685
+ if (!codec) {
686
+ codec = makeVideoCodec(cfg, 0);
687
+ codec.fit(videos, { iterations: pNum(cfg, "codecIterations", 10) });
688
+ }
689
+ const mse = videoReconMSE(codec, videos);
690
+
691
+ // Structural check: encode is a self-delimiting stream that decodes to the
692
+ // same frame count and shape — the real serve smoke test for video.
693
+ const toks = codec.encode(videos[0]!);
694
+ const recon = codec.decode(toks);
695
+ if (recon.length !== videos[0]!.length) throw new Error(`round-trip frame count ${recon.length} ≠ ${videos[0]!.length}`);
696
+ for (const f of recon) {
697
+ if (f.length !== videos[0]![0]!.length) throw new Error("round-trip frame shape mismatch");
698
+ }
699
+
700
+ // A trained model (if present) → the real deploy round-trip: package the
701
+ // model + codec into a self-contained .evermind, reload it, and generate.
702
+ // Served output must match the in-memory model, exactly like text roundtrip.
703
+ const model = ctx.bag.videoModel as EvermindLM | undefined;
704
+ if (model) {
705
+ const before = generateVideo(model, codec, [], { maxNewTokens: toks.length });
706
+ const modality = pNum(cfg, "frames", 4) === 1 ? "image" : "video";
707
+ const blob = EvermindModelPackage.fromMediaLM(model, codec, {
708
+ name: pStr(cfg, "name", "custom-evermind-video"),
709
+ version: pStr(cfg, "version", "1.0.0"),
710
+ modality,
711
+ card: { description: pStr(cfg, "description", "round-trip validated media model") },
712
+ }).toBlob();
713
+ const pkg = EvermindModelPackage.fromBlob(blob);
714
+ const val = pkg.validate();
715
+ if (!val.ok) throw new Error(`packaged media artifact failed validation: ${val.errors.join("; ")}`);
716
+ const served = pkg.loadMediaLM();
717
+ const after = generateVideo(served.lm, served.codec, [], { maxNewTokens: toks.length });
718
+ if (after.tokens.join(",") !== before.tokens.join(",")) {
719
+ throw new Error("served media model produced different tokens than the trained model");
720
+ }
721
+ for (const f of after.video) {
722
+ if (f.length !== videos[0]![0]!.length) throw new Error("generated frame shape mismatch");
723
+ }
724
+ ctx.artifacts.evermind = blob;
725
+ }
726
+
727
+ // Quality gate — only enforced when configured.
728
+ const maxMSE = pNum(cfg, "maxMSE", 0);
729
+ if (maxMSE > 0 && mse > maxMSE) {
730
+ throw new Error(`video reconstruction MSE ${mse.toFixed(4)} exceeds max ${maxMSE} (raise levels/codebookSize or fit longer)`);
731
+ }
732
+ ctx.bag.videoReconMSE = mse;
733
+ const improved = Number.isFinite(before) ? ` (was ${before.toFixed(4)} unfit)` : "";
734
+ return `recon MSE ${mse.toFixed(4)}${improved}; ${recon.length} frames round-trip clean`;
735
+ }),
736
+ },
555
737
  package: {
556
738
  info: { type: "package", layer: "BUILD", label: "Package", description: "Bundle the trained model into a portable .evermind artifact (params: name, version)" },
557
739
  factory: (cfg) =>
@@ -776,4 +958,64 @@ export const BUILTIN_STEPS: Record<string, Registered> = {
776
958
  : "no cases — execution reward skipped";
777
959
  }),
778
960
  },
961
+ "code-benchmark": {
962
+ info: {
963
+ type: "code-benchmark",
964
+ layer: "BUILD",
965
+ label: "Code benchmark (pass@1)",
966
+ description:
967
+ "Held-out coding benchmark: prompt the TRAINED model on N unseen tasks, execute each generated solution against its cases, and score pass@1 (a task passes only if ALL its cases pass). The symmetric coding counterpart to `benchmark`'s held-out perplexity (params: tasks:[{prompt,cases,maxNewTokens?}], maxNewTokens, temperature, minPass1)",
968
+ },
969
+ factory: (cfg) =>
970
+ mkStep(cfg, "BUILD", "Code benchmark (held-out pass@1)", async (ctx) => {
971
+ const model = ctx.bag.model as EvermindLM | undefined;
972
+ const tok = ctx.bag.tokenizer as BPETokenizer | undefined;
973
+ if (!model || !tok) throw new Error("no trained model — add 'train-model' first");
974
+
975
+ // Held-out tasks: each is a prompt the model did NOT train on plus the
976
+ // execution cases its output must satisfy. A task counts as passed only
977
+ // when EVERY case passes (strict pass@1), so this measures whether the
978
+ // student learned to GENERATE working code, not just clean corpus tokens.
979
+ const rawTasks = cfg.params?.tasks;
980
+ const tasks = Array.isArray(rawTasks)
981
+ ? rawTasks.filter(
982
+ (t): t is { prompt: string; cases?: unknown; maxNewTokens?: unknown } =>
983
+ typeof (t as { prompt?: unknown })?.prompt === "string",
984
+ )
985
+ : [];
986
+ if (tasks.length === 0) {
987
+ throw new Error("no held-out tasks — pass params.tasks: [{ prompt, cases: [{ call, expect }] }]");
988
+ }
989
+
990
+ const defaultMaxNewTokens = pNum(cfg, "maxNewTokens", 24);
991
+ const temperature = pNum(cfg, "temperature", 0);
992
+ const perTask = tasks.map((t) => {
993
+ const cases: CodeCase[] = Array.isArray(t.cases)
994
+ ? (t.cases as unknown[]).filter(
995
+ (c): c is CodeCase => typeof (c as { call?: unknown })?.call === "string",
996
+ )
997
+ : [];
998
+ const maxNewTokens = typeof t.maxNewTokens === "number" ? t.maxNewTokens : defaultMaxNewTokens;
999
+ const generated = model.generateText(t.prompt, tok, { maxNewTokens, temperature });
1000
+ const code = stripCodeFences(typeof generated === "string" ? generated : "");
1001
+ const evalResult = runJsCases(code, cases);
1002
+ // Strict pass@1: the task passes only when it HAS cases and they ALL pass.
1003
+ const passed = evalResult.total > 0 && evalResult.passRate === 1;
1004
+ return { prompt: t.prompt, passed, cases: evalResult.total, casesPassed: evalResult.passed, error: evalResult.error };
1005
+ });
1006
+
1007
+ const passedTasks = perTask.filter((r) => r.passed).length;
1008
+ const pass1 = perTask.length > 0 ? passedTasks / perTask.length : 0;
1009
+ ctx.bag.codeBenchmark = { pass1, passedTasks, totalTasks: perTask.length, perTask };
1010
+
1011
+ // Optional quality gate — symmetric with `benchmark`'s maxPerplexity/minTop1.
1012
+ const minPass1 = pNum(cfg, "minPass1", 0); // only enforced when configured
1013
+ if (minPass1 > 0 && pass1 < minPass1) {
1014
+ throw new Error(
1015
+ `code pass@1 ${(pass1 * 100) | 0}% below min ${(minPass1 * 100) | 0}% (student did not learn to generate passing code — more/better TEACH_CODE corpus or epochs)`,
1016
+ );
1017
+ }
1018
+ return `pass@1 ${passedTasks}/${perTask.length} (${(pass1 * 100) | 0}%) over held-out coding tasks`;
1019
+ }),
1020
+ },
779
1021
  };
@@ -122,6 +122,14 @@ export const TEACH_CODE: WorkflowConfig = {
122
122
  { id: "parse", type: "code-parse-check", params: { language: "js" } },
123
123
  { id: "reward", type: "code-eval", params: { cases: [{ call: "add(2, 3)", expect: 5 }] } },
124
124
  { id: "bench", type: "benchmark" },
125
+ // Held-out pass@1 — measures whether the student can GENERATE passing code on
126
+ // unseen prompts (symmetric with `bench`'s held-out perplexity). Ungated here
127
+ // (a demo-scale model won't pass) so it reports the metric without failing the
128
+ // pipeline; real corpora set `minPass1` to enforce a student-quality floor.
129
+ { id: "codebench", type: "code-benchmark", params: { tasks: [
130
+ { prompt: "function subtract", cases: [{ call: "subtract(5, 2)", expect: 3 }] },
131
+ { prompt: "function isEven", cases: [{ call: "isEven(4)", expect: true }, { call: "isEven(3)", expect: false }] },
132
+ ] } },
125
133
  { id: "pkg", type: "roundtrip", params: { name: "evermind-coder" } },
126
134
  { id: "export", type: "export", params: { format: "huggingface", name: "evermind-coder", version: "1.0.0" } },
127
135
  ],