@seanhogg/builderforce-memory 2026.9.28 → 2026.9.30

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 (104) hide show
  1. package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
  2. package/dist/bridges/AnthropicBridge.js +24 -65
  3. package/dist/bridges/AnthropicBridge.js.map +1 -1
  4. package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
  5. package/dist/bridges/OpenAIBridge.js +12 -46
  6. package/dist/bridges/OpenAIBridge.js.map +1 -1
  7. package/dist/bridges/VertexAIBridge.d.ts.map +1 -1
  8. package/dist/bridges/VertexAIBridge.js +3 -34
  9. package/dist/bridges/VertexAIBridge.js.map +1 -1
  10. package/dist/distillation/DistillationEngine.d.ts +71 -54
  11. package/dist/distillation/DistillationEngine.d.ts.map +1 -1
  12. package/dist/distillation/DistillationEngine.js +112 -95
  13. package/dist/distillation/DistillationEngine.js.map +1 -1
  14. package/dist/distillation/index.d.ts +7 -1
  15. package/dist/distillation/index.d.ts.map +1 -1
  16. package/dist/distillation/index.js +5 -0
  17. package/dist/distillation/index.js.map +1 -1
  18. package/dist/distillation/ports.d.ts +43 -0
  19. package/dist/distillation/ports.d.ts.map +1 -0
  20. package/dist/distillation/ports.js +29 -0
  21. package/dist/distillation/ports.js.map +1 -0
  22. package/dist/eval/EvalHarness.d.ts +2 -1
  23. package/dist/eval/EvalHarness.d.ts.map +1 -1
  24. package/dist/eval/EvalHarness.js +13 -1
  25. package/dist/eval/EvalHarness.js.map +1 -1
  26. package/dist/eval/baseline.d.ts +33 -0
  27. package/dist/eval/baseline.d.ts.map +1 -0
  28. package/dist/eval/baseline.js +41 -0
  29. package/dist/eval/baseline.js.map +1 -0
  30. package/dist/eval/index.d.ts +1 -0
  31. package/dist/eval/index.d.ts.map +1 -1
  32. package/dist/eval/index.js +1 -0
  33. package/dist/eval/index.js.map +1 -1
  34. package/dist/eval/types.d.ts +7 -0
  35. package/dist/eval/types.d.ts.map +1 -1
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/rag/EnterpriseRetriever.d.ts.map +1 -1
  41. package/dist/rag/EnterpriseRetriever.js +2 -1
  42. package/dist/rag/EnterpriseRetriever.js.map +1 -1
  43. package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
  44. package/dist/retrieval/HybridRetriever.js +2 -1
  45. package/dist/retrieval/HybridRetriever.js.map +1 -1
  46. package/dist/retrieval/decay.d.ts +24 -0
  47. package/dist/retrieval/decay.d.ts.map +1 -0
  48. package/dist/retrieval/decay.js +29 -0
  49. package/dist/retrieval/decay.js.map +1 -0
  50. package/dist/retrieval/fusion.d.ts +13 -19
  51. package/dist/retrieval/fusion.d.ts.map +1 -1
  52. package/dist/retrieval/fusion.js +17 -44
  53. package/dist/retrieval/fusion.js.map +1 -1
  54. package/dist/retrieval/index.d.ts +13 -6
  55. package/dist/retrieval/index.d.ts.map +1 -1
  56. package/dist/retrieval/index.js +10 -5
  57. package/dist/retrieval/index.js.map +1 -1
  58. package/dist/retrieval/mmr.d.ts +60 -0
  59. package/dist/retrieval/mmr.d.ts.map +1 -0
  60. package/dist/retrieval/mmr.js +94 -0
  61. package/dist/retrieval/mmr.js.map +1 -0
  62. package/dist/retrieval/weightedFusion.d.ts +64 -0
  63. package/dist/retrieval/weightedFusion.d.ts.map +1 -0
  64. package/dist/retrieval/weightedFusion.js +80 -0
  65. package/dist/retrieval/weightedFusion.js.map +1 -0
  66. package/dist/similarity/index.d.ts +6 -4
  67. package/dist/similarity/index.d.ts.map +1 -1
  68. package/dist/similarity/index.js +5 -3
  69. package/dist/similarity/index.js.map +1 -1
  70. package/dist/wire/index.d.ts +18 -0
  71. package/dist/wire/index.d.ts.map +1 -0
  72. package/dist/wire/index.js +17 -0
  73. package/dist/wire/index.js.map +1 -0
  74. package/dist/wire/sse.d.ts +29 -0
  75. package/dist/wire/sse.d.ts.map +1 -0
  76. package/dist/wire/sse.js +81 -0
  77. package/dist/wire/sse.js.map +1 -0
  78. package/dist/wire/usage.d.ts +46 -0
  79. package/dist/wire/usage.d.ts.map +1 -0
  80. package/dist/wire/usage.js +78 -0
  81. package/dist/wire/usage.js.map +1 -0
  82. package/package.json +11 -3
  83. package/src/bridges/AnthropicBridge.ts +24 -68
  84. package/src/bridges/OpenAIBridge.ts +11 -50
  85. package/src/bridges/VertexAIBridge.ts +3 -32
  86. package/src/distillation/DistillationEngine.ts +176 -141
  87. package/src/distillation/index.ts +15 -1
  88. package/src/distillation/ports.ts +55 -0
  89. package/src/eval/EvalHarness.ts +12 -1
  90. package/src/eval/baseline.ts +51 -0
  91. package/src/eval/index.ts +1 -0
  92. package/src/eval/types.ts +7 -0
  93. package/src/index.ts +5 -2
  94. package/src/rag/EnterpriseRetriever.ts +2 -1
  95. package/src/retrieval/HybridRetriever.ts +2 -1
  96. package/src/retrieval/decay.ts +29 -0
  97. package/src/retrieval/fusion.ts +19 -52
  98. package/src/retrieval/index.ts +24 -6
  99. package/src/retrieval/mmr.ts +126 -0
  100. package/src/retrieval/weightedFusion.ts +101 -0
  101. package/src/similarity/index.ts +6 -4
  102. package/src/wire/index.ts +18 -0
  103. package/src/wire/sse.ts +74 -0
  104. package/src/wire/usage.ts +95 -0
@@ -1,20 +1,24 @@
1
1
  /**
2
- * DistillationEngine – JS-only online knowledge distillation.
2
+ * DistillationEngine – online knowledge distillation.
3
3
  *
4
- * The core insight: use a transformer as a *teacher* to generate high-quality
5
- * responses, then adapt the SSM *student* on those responses using WSLA.
6
- * This runs entirely in the browser with no Python or full-retraining required.
4
+ * A *teacher* (a frontier model) writes the ideal response; the *student* is adapted
5
+ * on it. Runs with no Python and no full retraining.
7
6
  *
8
7
  * Distillation flow:
9
- * 1. bridge.generate(input) → teacher output
10
- * 2. runtime.adapt(teacherOutput, opts.adapt) → SSM trains on it
11
- * 3. Return both results for inspection
8
+ * 1. teacher.generate(input) → exemplar
9
+ * 2. quality gate (length / already-learned) → skip, or fall back to the caller's text
10
+ * 3. student.adapt(input + exemplar, + rehearsed past exemplars)
11
+ * 4. log the outcome
12
+ *
13
+ * Teacher and student are ports ({@link ./ports}). Passing an `SSMRuntime` and a
14
+ * `TransformerBridge` — the original pairing — still works unchanged.
12
15
  */
13
16
 
14
17
  import type { AdaptOptions, AdaptResult } from '../session/index.js';
15
18
  import type { SSMRuntime } from '../runtime/SSMRuntime.js';
16
- import type { TransformerBridge, BridgeGenerateOptions } from '../bridges/TransformerBridge.js';
19
+ import type { BridgeGenerateOptions } from '../bridges/TransformerBridge.js';
17
20
  import { SSMError } from '../errors/SSMError.js';
21
+ import { isDistillationStudent, ssmRuntimeStudent, type DistillationStudent, type DistillationTeacher } from './ports.js';
18
22
 
19
23
  // ── Types ─────────────────────────────────────────────────────────────────────
20
24
 
@@ -25,25 +29,18 @@ export interface QualityGate {
25
29
  */
26
30
  minLength? : number;
27
31
  /**
28
- * Maximum SSM perplexity threshold.
29
- * When the SSM already achieves perplexity below this value on the teacher
32
+ * Maximum student perplexity threshold (needs a student with `evaluate`).
33
+ * When the student already achieves perplexity below this value on the teacher
30
34
  * output, the content is considered already learned and adaptation is skipped.
31
35
  */
32
36
  maxPerplexity? : number;
33
37
  }
34
38
 
35
- export interface DistillOptions {
36
- /**
37
- * Options forwarded to `runtime.adapt()`.
38
- * Default: { wsla: true, epochs: 3 }
39
- * WSLA is preferred because it is fast and targets the selective
40
- * projection rows — exactly the parameters that encode token routing.
41
- */
42
- adapt? : AdaptOptions;
39
+ export interface DistillOptions<O = AdaptOptions> {
40
+ /** Options forwarded to the student's `adapt()`. */
41
+ adapt? : O;
43
42
 
44
- /**
45
- * Options forwarded to `bridge.generate()`.
46
- */
43
+ /** Options forwarded to the teacher's `generate()` (system prompt, max tokens, …). */
47
44
  generate? : BridgeGenerateOptions;
48
45
 
49
46
  /**
@@ -51,14 +48,27 @@ export interface DistillOptions {
51
48
  * already-learned inputs.
52
49
  */
53
50
  qualityGate? : QualityGate;
51
+
52
+ /**
53
+ * What to learn when the teacher yields nothing usable — it threw, or its output
54
+ * failed the `minLength` gate. Without it such an input is skipped (a teacher
55
+ * error is thrown); with it the student learns this text instead, so the
56
+ * contribution is never lost, and the result says it was not distilled.
57
+ */
58
+ fallbackText?: string;
59
+
60
+ /**
61
+ * Cap on the characters of `input` that prefix the exemplar in the training text,
62
+ * so a long input can never crowd the exemplar out of the student's window.
63
+ */
64
+ contextChars?: number;
54
65
  }
55
66
 
56
67
  /**
57
- * Catastrophic-forgetting guard (EVM-5). Online WSLA adapts a narrow set of
58
- * weights toward the newest exemplar, which can erode previously-learned
59
- * knowledge. A rehearsal (experience-replay) buffer mitigates this: each adapt
60
- * also trains on a sample of past exemplars, so old knowledge is continually
61
- * reinforced instead of overwritten.
68
+ * Catastrophic-forgetting guard (EVM-5). Online adaptation moves a narrow set of
69
+ * weights toward the newest exemplar, which can erode previously-learned knowledge.
70
+ * A rehearsal (experience-replay) buffer mitigates this: each adapt also trains on a
71
+ * sample of past exemplars, so old knowledge is continually reinforced.
62
72
  */
63
73
  export interface RehearsalOptions {
64
74
  /** Ring-buffer capacity of past exemplars. 0 disables rehearsal. Default 0. */
@@ -69,27 +79,33 @@ export interface RehearsalOptions {
69
79
  seed?: number;
70
80
  }
71
81
 
72
- export interface DistillResult {
82
+ /** Why an input was not learned from a teacher exemplar. */
83
+ export type DistillSkipReason = 'low_quality' | 'already_learned' | 'teacher_failed';
84
+
85
+ export interface DistillResult<R = AdaptResult> {
73
86
  /** The input prompt that was distilled. */
74
87
  input : string;
75
- /** The teacher's (transformer bridge) response to the input. */
88
+ /** The teacher's response to the input ('' when the teacher failed). */
76
89
  teacherOutput: string;
77
- /** The adapt() result from training the SSM on the teacher output. */
78
- adaptResult : AdaptResult;
79
- /** Whether adaptation was skipped by the quality gate. */
90
+ /** The student's result — its skipped result when nothing was adapted. */
91
+ adaptResult : R;
92
+ /** True when nothing was adapted at all. */
80
93
  skipped? : boolean;
81
- /** Reason adaptation was skipped, if applicable. */
82
- skipReason? : string;
94
+ /** True when the student learned the teacher's exemplar (not a fallback). */
95
+ distilled : boolean;
96
+ /** Why the exemplar was not learned, when it was not. */
97
+ skipReason? : DistillSkipReason;
98
+ /** What the teacher threw, when `skipReason` is `teacher_failed`. */
99
+ teacherError?: unknown;
83
100
  /** Number of past exemplars rehearsed alongside this one (EVM-5). */
84
101
  rehearsed? : number;
85
102
  /** 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). */
103
+ * measured it — a "how novel was this exemplar" signal. */
88
104
  gatePerplexity?: number;
89
105
  }
90
106
 
91
- export interface DistillBatchResult {
92
- results : DistillResult[];
107
+ export interface DistillBatchResult<R = AdaptResult> {
108
+ results : DistillResult<R>[];
93
109
  /** Total number of adapt epochs run across all inputs. */
94
110
  totalEpochs: number;
95
111
  /** Wall-clock time for the entire batch in milliseconds. */
@@ -101,7 +117,9 @@ export interface DistillationLog {
101
117
  input : string;
102
118
  teacherOutputLength: number;
103
119
  skipped : boolean;
104
- skipReason? : string;
120
+ /** True when the exemplar (not a fallback) was learned. */
121
+ distilled : boolean;
122
+ skipReason? : DistillSkipReason;
105
123
  finalLoss? : number;
106
124
  epochs : number;
107
125
  /** Pre-adapt perplexity on the teacher output (when the quality gate measured it). */
@@ -113,9 +131,9 @@ const MAX_LOG_ENTRIES = 200;
113
131
 
114
132
  // ── DistillationEngine ────────────────────────────────────────────────────────
115
133
 
116
- export class DistillationEngine {
117
- private readonly _runtime : SSMRuntime;
118
- private readonly _bridge : TransformerBridge;
134
+ export class DistillationEngine<R = AdaptResult, O = AdaptOptions> {
135
+ private readonly _student : DistillationStudent<R, O>;
136
+ private readonly _teacher : DistillationTeacher;
119
137
  private readonly _log : DistillationLog[] = [];
120
138
 
121
139
  // ── Rehearsal buffer (EVM-5 catastrophic-forgetting guard) ─────────────────
@@ -125,16 +143,16 @@ export class DistillationEngine {
125
143
  private _rehearsalState : number;
126
144
 
127
145
  /**
128
- * @param runtime The SSMRuntime whose SSM will be trained as the student.
129
- * @param bridge The transformer bridge acting as teacher.
130
- * A bridge must be provided — distillation requires one.
131
- * @param rehearsal Optional experience-replay config (EVM-5). When
132
- * `bufferSize > 0`, each adapt also trains on a sample of past
133
- * exemplars to guard against catastrophic forgetting.
146
+ * @param student What learns: an `SSMRuntime`, or any {@link DistillationStudent}.
147
+ * @param teacher What teaches: any `TransformerBridge`, or any {@link DistillationTeacher}.
148
+ * @param rehearsal Optional experience-replay config (EVM-5). When `bufferSize > 0`,
149
+ * each adapt also trains on a sample of past exemplars.
134
150
  */
135
- constructor(runtime: SSMRuntime, bridge: TransformerBridge, rehearsal: RehearsalOptions = {}) {
136
- this._runtime = runtime;
137
- this._bridge = bridge;
151
+ constructor(student: SSMRuntime | DistillationStudent<R, O>, teacher: DistillationTeacher, rehearsal: RehearsalOptions = {}) {
152
+ this._student = isDistillationStudent<R, O>(student)
153
+ ? student
154
+ : (ssmRuntimeStudent(student as SSMRuntime) as unknown as DistillationStudent<R, O>);
155
+ this._teacher = teacher;
138
156
  this._rehearsalSize = Math.max(0, rehearsal.bufferSize ?? 0);
139
157
  this._rehearsalK = Math.max(0, rehearsal.sampleK ?? 2);
140
158
  this._rehearsalState = (rehearsal.seed ?? 1) >>> 0 || 1;
@@ -174,133 +192,88 @@ export class DistillationEngine {
174
192
  * Runs a single distillation pass:
175
193
  * 1. Teacher generates a response for `input`
176
194
  * 2. Quality gate is evaluated (if configured)
177
- * 3. SSM is adapted on the teacher's output (WSLA by default)
195
+ * 3. Student is adapted on `input` + the teacher's output
178
196
  *
179
- * The training signal is the teacher's full response — this teaches the
180
- * SSM what a good response to that prompt looks like, without requiring
181
- * labelled data or a loss function beyond the standard LM objective.
197
+ * The training signal is the teacher's full response — this teaches the student
198
+ * what a good response to that prompt looks like, without labelled data.
182
199
  */
183
- async distill(input: string, opts: DistillOptions = {}): Promise<DistillResult> {
184
- const adaptOpts: AdaptOptions = {
185
- wsla : true,
186
- epochs : 3,
187
- ...opts.adapt,
188
- };
189
-
190
- let teacherOutput: string;
200
+ async distill(input: string, opts: DistillOptions<O> = {}): Promise<DistillResult<R>> {
201
+ let teacherOutput = '';
202
+ let teacherError: unknown;
191
203
  try {
192
- teacherOutput = await this._bridge.generate(input, opts.generate);
204
+ teacherOutput = await this._teacher.generate(input, opts.generate);
193
205
  } catch (err) {
194
- throw new SSMError(
195
- 'DISTILL_FAILED',
196
- `Teacher bridge failed to generate for distillation: ${err instanceof Error ? err.message : String(err)}`,
197
- err,
198
- );
206
+ if (opts.fallbackText === undefined) {
207
+ throw new SSMError(
208
+ 'DISTILL_FAILED',
209
+ `Teacher bridge failed to generate for distillation: ${err instanceof Error ? err.message : String(err)}`,
210
+ err,
211
+ );
212
+ }
213
+ teacherError = err;
214
+ }
215
+ if (teacherError !== undefined) {
216
+ return this._learnFallback(input, '', 'teacher_failed', opts, teacherError);
199
217
  }
200
218
 
201
219
  // ── Quality gate ──────────────────────────────────────────────────────
202
220
 
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.
221
+ // Pre-adapt novelty of this exemplar, hoisted so it survives to the TRAINED path.
206
222
  let gatePerplexity: number | undefined;
223
+ const gate = opts.qualityGate;
207
224
 
208
- if (opts.qualityGate) {
209
- const gate = opts.qualityGate;
210
-
211
- if (gate.minLength != null && teacherOutput.length < gate.minLength) {
212
- const result: DistillResult = {
213
- input,
214
- teacherOutput,
215
- adaptResult : { losses: [], epochCount: 0, durationMs: 0 },
216
- skipped : true,
217
- skipReason : 'low_quality',
218
- };
219
- this._appendLog({
220
- input,
221
- teacherOutputLength: teacherOutput.length,
222
- skipped : true,
223
- skipReason : 'low_quality',
224
- epochs : 0,
225
- });
226
- return result;
227
- }
225
+ if (gate?.minLength != null && teacherOutput.length < gate.minLength) {
226
+ if (opts.fallbackText !== undefined) return this._learnFallback(input, teacherOutput, 'low_quality', opts);
227
+ return this._skip(input, teacherOutput, 'low_quality');
228
+ }
228
229
 
229
- if (gate.maxPerplexity != null) {
230
- try {
231
- gatePerplexity = await this._runtime.evaluate(teacherOutput);
232
- } catch {
233
- // Evaluation failure is non-fatal — proceed with adaptation
234
- }
235
- if (gatePerplexity != null && gatePerplexity < gate.maxPerplexity) {
236
- const result: DistillResult = {
237
- input,
238
- teacherOutput,
239
- adaptResult : { losses: [], epochCount: 0, durationMs: 0 },
240
- skipped : true,
241
- skipReason : 'already_learned',
242
- gatePerplexity,
243
- };
244
- this._appendLog({
245
- input,
246
- teacherOutputLength: teacherOutput.length,
247
- skipped : true,
248
- skipReason : 'already_learned',
249
- epochs : 0,
250
- gatePerplexity,
251
- });
252
- return result;
253
- }
230
+ if (gate?.maxPerplexity != null && this._student.evaluate) {
231
+ try {
232
+ gatePerplexity = await this._student.evaluate(teacherOutput);
233
+ } catch {
234
+ // Evaluation failure is non-fatal — proceed with adaptation
235
+ }
236
+ if (gatePerplexity != null && gatePerplexity < gate.maxPerplexity) {
237
+ return this._skip(input, teacherOutput, 'already_learned', gatePerplexity);
254
238
  }
255
239
  }
256
240
 
257
- // Train the SSM on the teacher's output, prepending the input so the model
258
- // learns the (prompt → response) mapping. EVM-5: interleave a sample of
259
- // past exemplars (experience replay) so this adapt reinforces prior
260
- // knowledge instead of overwriting it (catastrophic-forgetting guard).
241
+ // Train on the (prompt → response) mapping. EVM-5: interleave a sample of past
242
+ // exemplars (experience replay) so this adapt reinforces prior knowledge
243
+ // instead of overwriting it.
261
244
  const rehearsed = this._sampleRehearsal();
262
- const pairs = [...rehearsed, { input, teacherOutput }].map((p) => `${p.input}\n${p.teacherOutput}`);
263
- const trainingText = pairs.join('\n\n');
264
-
265
- let adaptResult: AdaptResult;
266
- try {
267
- adaptResult = await this._runtime.adapt(trainingText, adaptOpts);
268
- } catch (err) {
269
- throw new SSMError(
270
- 'DISTILL_FAILED',
271
- `SSM adaptation failed during distillation: ${err instanceof Error ? err.message : String(err)}`,
272
- err,
273
- );
274
- }
245
+ const context = (i: string) => (opts.contextChars != null ? i.trim().slice(0, opts.contextChars) : i);
246
+ const trainingText = [...rehearsed, { input, teacherOutput }]
247
+ .map((p) => `${context(p.input)}\n${p.teacherOutput}`)
248
+ .join('\n\n');
275
249
 
276
- // Record the new exemplar for future rehearsal.
250
+ const adaptResult = await this._adapt(trainingText, opts);
277
251
  this._pushRehearsal(input, teacherOutput);
278
-
279
252
  this._appendLog({
280
253
  input,
281
254
  teacherOutputLength: teacherOutput.length,
282
255
  skipped : false,
283
- finalLoss : adaptResult.losses.at(-1),
284
- epochs : adaptResult.epochCount,
256
+ distilled : true,
257
+ ...this._student.describe(adaptResult),
285
258
  gatePerplexity,
286
259
  });
287
260
 
288
- return { input, teacherOutput, adaptResult, skipped: false, rehearsed: rehearsed.length, gatePerplexity };
261
+ return { input, teacherOutput, adaptResult, skipped: false, distilled: true, rehearsed: rehearsed.length, gatePerplexity };
289
262
  }
290
263
 
291
264
  /**
292
265
  * Runs distillation for each input in sequence.
293
266
  * Aggregate statistics are returned alongside individual results.
294
267
  */
295
- async distillBatch(inputs: string[], opts: DistillOptions = {}): Promise<DistillBatchResult> {
268
+ async distillBatch(inputs: string[], opts: DistillOptions<O> = {}): Promise<DistillBatchResult<R>> {
296
269
  const startMs = Date.now();
297
- const results: DistillResult[] = [];
270
+ const results: DistillResult<R>[] = [];
298
271
  let totalEpochs = 0;
299
272
 
300
273
  for (const input of inputs) {
301
274
  const result = await this.distill(input, opts);
302
275
  results.push(result);
303
- totalEpochs += result.adaptResult.epochCount;
276
+ totalEpochs += this._student.describe(result.adaptResult).epochs;
304
277
  }
305
278
 
306
279
  return {
@@ -319,6 +292,68 @@ export class DistillationEngine {
319
292
 
320
293
  // ── Private helpers ───────────────────────────────────────────────────────
321
294
 
295
+ private async _adapt(trainingText: string, opts: DistillOptions<O>): Promise<R> {
296
+ try {
297
+ return await this._student.adapt(trainingText, opts.adapt);
298
+ } catch (err) {
299
+ throw new SSMError(
300
+ 'DISTILL_FAILED',
301
+ `SSM adaptation failed during distillation: ${err instanceof Error ? err.message : String(err)}`,
302
+ err,
303
+ );
304
+ }
305
+ }
306
+
307
+ /** No exemplar worth learning: learn the caller's fallback text instead. */
308
+ private async _learnFallback(
309
+ input: string,
310
+ teacherOutput: string,
311
+ skipReason: DistillSkipReason,
312
+ opts: DistillOptions<O>,
313
+ teacherError?: unknown,
314
+ ): Promise<DistillResult<R>> {
315
+ const adaptResult = await this._adapt(opts.fallbackText!, opts);
316
+ this._appendLog({
317
+ input,
318
+ teacherOutputLength: teacherOutput.length,
319
+ skipped : false,
320
+ distilled : false,
321
+ skipReason,
322
+ ...this._student.describe(adaptResult),
323
+ });
324
+ return {
325
+ input,
326
+ teacherOutput,
327
+ adaptResult,
328
+ skipped: false,
329
+ distilled: false,
330
+ skipReason,
331
+ ...(teacherError !== undefined ? { teacherError } : {}),
332
+ };
333
+ }
334
+
335
+ /** Nothing adapted. */
336
+ private _skip(input: string, teacherOutput: string, skipReason: DistillSkipReason, gatePerplexity?: number): DistillResult<R> {
337
+ this._appendLog({
338
+ input,
339
+ teacherOutputLength: teacherOutput.length,
340
+ skipped : true,
341
+ distilled : false,
342
+ skipReason,
343
+ epochs : 0,
344
+ gatePerplexity,
345
+ });
346
+ return {
347
+ input,
348
+ teacherOutput,
349
+ adaptResult: this._student.skippedResult(),
350
+ skipped: true,
351
+ distilled: false,
352
+ skipReason,
353
+ ...(gatePerplexity !== undefined ? { gatePerplexity } : {}),
354
+ };
355
+ }
356
+
322
357
  private _appendLog(entry: Omit<DistillationLog, 'timestamp'>): void {
323
358
  this._log.push({ timestamp: Date.now(), ...entry });
324
359
  if (this._log.length > MAX_LOG_ENTRIES) {
@@ -1,2 +1,16 @@
1
+ /**
2
+ * Distillation — a teacher's exemplars adapted into a student, behind two ports.
3
+ * Engine-free: the `@seanhogg/builderforce-memory/distillation` subpath.
4
+ */
1
5
  export { DistillationEngine } from './DistillationEngine.js';
2
- export type { DistillOptions, DistillResult, DistillBatchResult, DistillationLog, QualityGate, RehearsalOptions } from './DistillationEngine.js';
6
+ export type {
7
+ DistillOptions,
8
+ DistillResult,
9
+ DistillBatchResult,
10
+ DistillationLog,
11
+ DistillSkipReason,
12
+ QualityGate,
13
+ RehearsalOptions,
14
+ } from './DistillationEngine.js';
15
+ export { ssmRuntimeStudent, SSM_STUDENT_DEFAULT_ADAPT } from './ports.js';
16
+ export type { DistillationTeacher, DistillationStudent } from './ports.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The two ports a {@link DistillationEngine} runs between.
3
+ *
4
+ * Distillation is one recipe — teacher answers, gate, rehearse, adapt, log — over
5
+ * any teacher and any student. The package's own pairing (a `TransformerBridge`
6
+ * teaching an `SSMRuntime`) is one instance; a host that teaches through a metered
7
+ * gateway and adapts a packaged checkpoint into a mergeable diff is another. Both
8
+ * plug in here instead of restating the recipe.
9
+ */
10
+
11
+ import type { AdaptOptions, AdaptResult } from '../session/index.js';
12
+ import type { SSMRuntime } from '../runtime/SSMRuntime.js';
13
+ import type { BridgeGenerateOptions } from '../bridges/TransformerBridge.js';
14
+
15
+ /** Produces the exemplar for an input. Any `TransformerBridge` is one. */
16
+ export interface DistillationTeacher {
17
+ /** The exemplar for `input`. Throws when there is none; the engine records the error. */
18
+ generate(input: string, opts?: BridgeGenerateOptions): Promise<string>;
19
+ }
20
+
21
+ /**
22
+ * Learns from a training text. `R` is whatever one adaptation yields (an `AdaptResult`,
23
+ * a checkpoint diff, …); `O` the per-call options it takes.
24
+ */
25
+ export interface DistillationStudent<R, O = undefined> {
26
+ adapt(trainingText: string, opts?: O): Promise<R>;
27
+ /** Perplexity on `text` — enables the `maxPerplexity` gate. Optional. */
28
+ evaluate?(text: string): Promise<number>;
29
+ /** The result recorded when nothing was adapted (the gate skipped the input). */
30
+ skippedResult(): R;
31
+ /** Final loss and epoch count of one result, for the log and batch totals. */
32
+ describe(result: R): { finalLoss?: number; epochs: number };
33
+ }
34
+
35
+ /** The adapt options an {@link SSMRuntime} student uses unless a call overrides them. */
36
+ export const SSM_STUDENT_DEFAULT_ADAPT: AdaptOptions = { wsla: true, epochs: 3 };
37
+
38
+ /**
39
+ * An {@link SSMRuntime} as a student. WSLA by default: it is fast and targets the
40
+ * selective-projection rows, exactly the parameters that encode token routing.
41
+ */
42
+ export function ssmRuntimeStudent(runtime: SSMRuntime): DistillationStudent<AdaptResult, AdaptOptions> {
43
+ return {
44
+ adapt: (text, opts) => runtime.adapt(text, { ...SSM_STUDENT_DEFAULT_ADAPT, ...opts }),
45
+ evaluate: (text) => runtime.evaluate(text),
46
+ skippedResult: () => ({ losses: [], epochCount: 0, durationMs: 0 }),
47
+ describe: (r) => ({ finalLoss: r.losses.at(-1), epochs: r.epochCount }),
48
+ };
49
+ }
50
+
51
+ /** True when `x` already speaks the student port (an `SSMRuntime` does not: it has no `describe`). */
52
+ export function isDistillationStudent<R, O>(x: unknown): x is DistillationStudent<R, O> {
53
+ return !!x && typeof (x as { describe?: unknown }).describe === 'function'
54
+ && typeof (x as { skippedResult?: unknown }).skippedResult === 'function';
55
+ }
@@ -12,6 +12,7 @@ import { mapWithConcurrency } from '../ingest/IngestionPipeline.js';
12
12
  import { percentile } from '../telemetry/MetricsRegistry.js';
13
13
  import type { Tracer } from '../telemetry/Tracer.js';
14
14
  import { DEFAULT_RAG_GRADERS } from './graders.js';
15
+ import { baselineRatio, meetsBaselineRatio, sameEvalProblem, type EvalReportSummary } from './baseline.js';
15
16
  import type {
16
17
  CaseResult,
17
18
  EvalDataset,
@@ -139,7 +140,7 @@ export class EvalHarness {
139
140
  }
140
141
 
141
142
  /** Applies a gate to a report. Pure, so a build can re-gate an archived report. */
142
- export function evaluateGate(report: EvalReport, gate: EvalGate): GateVerdict {
143
+ export function evaluateGate(report: EvalReport, gate: EvalGate, baseline?: EvalReportSummary): GateVerdict {
143
144
  const failures: string[] = [];
144
145
  const thresholds = Object.entries(gate).filter(([, v]) => v !== undefined);
145
146
 
@@ -158,6 +159,16 @@ export function evaluateGate(report: EvalReport, gate: EvalGate): GateVerdict {
158
159
  if (gate.maxP95LatencyMs !== undefined && report.p95LatencyMs > gate.maxP95LatencyMs) {
159
160
  failures.push(`p95 latency ${Math.round(report.p95LatencyMs)}ms > allowed ${gate.maxP95LatencyMs}ms`);
160
161
  }
162
+ if (gate.minBaselineRatio !== undefined) {
163
+ const problem = baseline ? sameEvalProblem(report, baseline) : 'gate needs a baseline report to compare against';
164
+ const ratio = baseline ? baselineRatio(report.meanScore, baseline.meanScore) : null;
165
+ if (problem) failures.push(problem);
166
+ else if (!meetsBaselineRatio(ratio, gate.minBaselineRatio)) {
167
+ failures.push(ratio === null
168
+ ? 'baseline scored 0, so no ratio can be taken'
169
+ : `score is ${pct(ratio)} of the baseline < required ${pct(gate.minBaselineRatio)}`);
170
+ }
171
+ }
161
172
  for (const [grader, minimum] of Object.entries(gate.minGraderScore ?? {})) {
162
173
  const actual = report.graderScores[grader];
163
174
  if (actual === undefined) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * eval/baseline.ts — gating a candidate RELATIVE to a baseline.
3
+ *
4
+ * "Good enough to launch" is often not an absolute score but a fraction of what an
5
+ * incumbent scores on the same suite: a small model may serve a task once it reaches
6
+ * 90% of the frontier model it replaces. That comparison only means something when
7
+ * both reports came from the SAME eval (same dataset, same cases), and when the
8
+ * baseline scored above zero. These are the rules for it, used by
9
+ * {@link evaluateGate}'s `minBaselineRatio` and by any caller that stores the two
10
+ * scores and re-checks the bar later without the reports.
11
+ */
12
+
13
+ /** The three facts of a report that a baseline comparison reads. */
14
+ export interface EvalReportSummary {
15
+ dataset: string;
16
+ meanScore: number;
17
+ /** The cases array, or just its length. */
18
+ cases: number | readonly unknown[];
19
+ }
20
+
21
+ /** Float tolerance for a ratio bar, so an exact 90% (0.81 / 0.9) meets a 0.9 bar. */
22
+ export const BASELINE_RATIO_EPSILON = 1e-9;
23
+
24
+ /**
25
+ * `score ÷ baselineScore`, or null when the pair cannot be compared — a non-finite or
26
+ * negative score, or a baseline at or below zero, proves nothing.
27
+ */
28
+ export function baselineRatio(score: number, baselineScore: number): number | null {
29
+ if (!Number.isFinite(score) || !Number.isFinite(baselineScore) || score < 0 || baselineScore <= 0) return null;
30
+ return score / baselineScore;
31
+ }
32
+
33
+ /** True when `ratio` reaches `minRatio` (within {@link BASELINE_RATIO_EPSILON}). A null ratio never does. */
34
+ export function meetsBaselineRatio(ratio: number | null, minRatio: number): boolean {
35
+ return ratio !== null && ratio + BASELINE_RATIO_EPSILON >= minRatio;
36
+ }
37
+
38
+ /**
39
+ * Why two reports are not the same eval, or null when they are. "The same eval" is
40
+ * enforced, not assumed: a ratio of scores from different suites compares nothing.
41
+ */
42
+ export function sameEvalProblem(candidate: EvalReportSummary, baseline: EvalReportSummary): string | null {
43
+ const count = (c: EvalReportSummary['cases']) => (typeof c === 'number' ? c : c.length);
44
+ if (candidate.dataset !== baseline.dataset) {
45
+ return `reports are from different evals ('${candidate.dataset}' vs '${baseline.dataset}')`;
46
+ }
47
+ if (count(candidate.cases) !== count(baseline.cases)) {
48
+ return `reports scored different case counts (${count(candidate.cases)} vs ${count(baseline.cases)})`;
49
+ }
50
+ return null;
51
+ }
package/src/eval/index.ts CHANGED
@@ -24,3 +24,4 @@
24
24
  export * from './types.js';
25
25
  export * from './graders.js';
26
26
  export * from './EvalHarness.js';
27
+ export * from './baseline.js';
package/src/eval/types.ts CHANGED
@@ -80,6 +80,13 @@ export interface EvalGate {
80
80
  maxP95LatencyMs?: number;
81
81
  /** Maximum share of cases that may error outright. */
82
82
  maxErrorRate?: number;
83
+ /**
84
+ * Minimum `meanScore` as a fraction of a BASELINE report's on the same eval —
85
+ * "reach 90% of the model this replaces". Needs the baseline passed to
86
+ * {@link evaluateGate}; without one, or with a baseline from a different eval,
87
+ * the gate fails and says why.
88
+ */
89
+ minBaselineRatio?: number;
83
90
  }
84
91
 
85
92
  export interface GateVerdict {
package/src/index.ts CHANGED
@@ -473,15 +473,18 @@ export { publishToHuggingFace, writeExportToDir } from './publish/index.js';
473
473
  export type { HuggingFaceTarget, PublishOutcome, HubClient, FsLike } from './publish/index.js';
474
474
 
475
475
  // ── Distillation ──────────────────────────────────────────────────────────────
476
- export { DistillationEngine } from './distillation/DistillationEngine.js';
476
+ export { DistillationEngine, ssmRuntimeStudent } from './distillation/index.js';
477
477
  export type {
478
478
  DistillOptions,
479
479
  DistillResult,
480
480
  DistillBatchResult,
481
481
  DistillationLog,
482
+ DistillSkipReason,
482
483
  QualityGate,
483
484
  RehearsalOptions,
484
- } from './distillation/DistillationEngine.js';
485
+ DistillationTeacher,
486
+ DistillationStudent,
487
+ } from './distillation/index.js';
485
488
 
486
489
  // ── Agent ─────────────────────────────────────────────────────────────────────
487
490
  export { SSMAgent } from './agent/SSMAgent.js';
@@ -23,7 +23,8 @@
23
23
  */
24
24
 
25
25
  import type { TransformerBridge } from '../bridges/TransformerBridge.js';
26
- import { maximalMarginalRelevance, reciprocalRankFusion } from '../retrieval/fusion.js';
26
+ import { reciprocalRankFusion } from '../retrieval/fusion.js';
27
+ import { maximalMarginalRelevance } from '../retrieval/mmr.js';
27
28
  import type { Span, Tracer } from '../telemetry/Tracer.js';
28
29
  import {
29
30
  SOURCE_ID_FIELD,