@seanhogg/builderforce-memory-engine 2026.10.2 → 2026.10.22

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 (68) hide show
  1. package/dist/evermind/adapt.d.ts +7 -0
  2. package/dist/evermind/adapt.d.ts.map +1 -1
  3. package/dist/evermind/adapt.js +1 -1
  4. package/dist/evermind/adapt.js.map +1 -1
  5. package/dist/experience/index.d.ts +2 -2
  6. package/dist/experience/index.d.ts.map +1 -1
  7. package/dist/experience/index.js.map +1 -1
  8. package/dist/experience/store.d.ts +19 -1
  9. package/dist/experience/store.d.ts.map +1 -1
  10. package/dist/experience/store.js +14 -2
  11. package/dist/experience/store.js.map +1 -1
  12. package/dist/experience/types.d.ts +18 -0
  13. package/dist/experience/types.d.ts.map +1 -1
  14. package/dist/experience/types.js.map +1 -1
  15. package/dist/kernels/weight_update.d.ts.map +1 -1
  16. package/dist/kernels/weight_update.js +12 -0
  17. package/dist/kernels/weight_update.js.map +1 -1
  18. package/dist/ml/classical/ewma.d.ts +5 -4
  19. package/dist/ml/classical/ewma.d.ts.map +1 -1
  20. package/dist/ml/classical/ewma.js +2 -2
  21. package/dist/ml/classical/ewma.js.map +1 -1
  22. package/dist/ml/classical/index.d.ts +2 -2
  23. package/dist/ml/classical/index.d.ts.map +1 -1
  24. package/dist/ml/classical/index.js +1 -1
  25. package/dist/ml/classical/index.js.map +1 -1
  26. package/dist/ml/classical/knn.d.ts +11 -4
  27. package/dist/ml/classical/knn.d.ts.map +1 -1
  28. package/dist/ml/classical/knn.js +26 -6
  29. package/dist/ml/classical/knn.js.map +1 -1
  30. package/dist/ml/classical/robust_z.d.ts +9 -3
  31. package/dist/ml/classical/robust_z.d.ts.map +1 -1
  32. package/dist/ml/classical/robust_z.js +5 -1
  33. package/dist/ml/classical/robust_z.js.map +1 -1
  34. package/dist/ml/head_trainer.d.ts +13 -1
  35. package/dist/ml/head_trainer.d.ts.map +1 -1
  36. package/dist/ml/head_trainer.js +47 -10
  37. package/dist/ml/head_trainer.js.map +1 -1
  38. package/dist/ml/tasks/experience.d.ts +61 -16
  39. package/dist/ml/tasks/experience.d.ts.map +1 -1
  40. package/dist/ml/tasks/experience.js +119 -24
  41. package/dist/ml/tasks/experience.js.map +1 -1
  42. package/dist/ml/tasks/run_anomaly.d.ts +26 -9
  43. package/dist/ml/tasks/run_anomaly.d.ts.map +1 -1
  44. package/dist/ml/tasks/run_anomaly.js +39 -10
  45. package/dist/ml/tasks/run_anomaly.js.map +1 -1
  46. package/dist/ml/train.d.ts +6 -0
  47. package/dist/ml/train.d.ts.map +1 -1
  48. package/dist/ml/train.js +19 -11
  49. package/dist/ml/train.js.map +1 -1
  50. package/dist/optim/adamw.d.ts +11 -5
  51. package/dist/optim/adamw.d.ts.map +1 -1
  52. package/dist/optim/adamw.js +19 -4
  53. package/dist/optim/adamw.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/evermind/adapt.ts +8 -2
  56. package/src/experience/index.ts +2 -1
  57. package/src/experience/store.ts +35 -3
  58. package/src/experience/types.ts +19 -0
  59. package/src/kernels/weight_update.ts +12 -0
  60. package/src/ml/classical/ewma.ts +5 -5
  61. package/src/ml/classical/index.ts +2 -2
  62. package/src/ml/classical/knn.ts +25 -6
  63. package/src/ml/classical/robust_z.ts +15 -5
  64. package/src/ml/head_trainer.ts +47 -8
  65. package/src/ml/tasks/experience.ts +148 -28
  66. package/src/ml/tasks/run_anomaly.ts +41 -10
  67. package/src/ml/train.ts +31 -12
  68. package/src/optim/adamw.ts +21 -5
@@ -56,9 +56,12 @@ export class KnnModel implements MlPredictor {
56
56
  return this.metric === "cosine" ? cosine(a, b) : 1 / (1 + Math.sqrt(squaredDistance(a, b)));
57
57
  }
58
58
 
59
- /** The `k` closest points, closest first. */
60
- neighbours(x: Vec, k: number = this.k): Neighbour[] {
61
- const all = this._x.map((p, index) => ({ index, similarity: this.similarity(x, p) }));
59
+ /** The `k` closest points, closest first — never the point at `exclude` (leave-one-out). */
60
+ neighbours(x: Vec, k: number = this.k, exclude?: number): Neighbour[] {
61
+ const all: Neighbour[] = [];
62
+ for (let index = 0; index < this._x.length; index++) {
63
+ if (index !== exclude) all.push({ index, similarity: this.similarity(x, this._x[index]!) });
64
+ }
62
65
  all.sort((a, b) => b.similarity - a.similarity || a.index - b.index);
63
66
  return all.slice(0, Math.min(k, all.length));
64
67
  }
@@ -73,10 +76,15 @@ export class KnnModel implements MlPredictor {
73
76
  return this._y[index]!;
74
77
  }
75
78
 
76
- /** Class probabilities from similarity-weighted votes (uniform when empty). */
77
- proba(x: Vec): number[] {
79
+ /**
80
+ * Class probabilities from similarity-weighted votes (uniform when empty). With
81
+ * `exclude`, the point at that index does not vote — the leave-one-out score of a
82
+ * point that is itself in the index, which would otherwise vote for itself at
83
+ * similarity 1.
84
+ */
85
+ proba(x: Vec, exclude?: number): number[] {
78
86
  const votes = new Array<number>(this.classes).fill(0);
79
- for (const n of this.neighbours(x)) {
87
+ for (const n of this.neighbours(x, this.k, exclude)) {
80
88
  // Shift cosine into (0, 2] so an orthogonal neighbour still casts a small vote.
81
89
  const w = (this.metric === "cosine" ? n.similarity + 1 : n.similarity) * this._w[n.index]! + 1e-9;
82
90
  votes[this._y[n.index]!]! += w;
@@ -89,6 +97,17 @@ export class KnnModel implements MlPredictor {
89
97
  const scores = this.proba(x);
90
98
  return { scores, confidence: topProbability(scores) };
91
99
  }
100
+
101
+ /** This index as a predictor that leaves the point at `index` out of every vote. */
102
+ excluding(index: number): MlPredictor {
103
+ if (!(index >= 0 && index < this._x.length)) throw new Error(`KnnModel.excluding: no point ${index} (size ${this._x.length})`);
104
+ return {
105
+ predict: (x: Float32Array): MlPrediction => {
106
+ const scores = this.proba(x, index);
107
+ return { scores, confidence: topProbability(scores) };
108
+ },
109
+ };
110
+ }
92
111
  }
93
112
 
94
113
  /** Index `samples` for kNN classification. */
@@ -25,10 +25,20 @@ export interface RobustZOptions {
25
25
  /** Smallest scale per feature, as a fraction of |median| (plus 1e-9). Default 0.01. */
26
26
  minScaleFraction?: number;
27
27
  /**
28
- * Smallest scale per feature in absolute feature units. Default 0 — the algorithm
29
- * cannot know the units; a task on a fixed scale sets it (`run.anomaly` does).
28
+ * Smallest scale per feature in absolute feature units: one value for every
29
+ * feature, or one per feature (a missing entry is 0). Default 0 — the algorithm
30
+ * cannot know the units; a task on a fixed scale sets it (`run.anomaly` does,
31
+ * per feature, because each of its metrics is normalised by a different reference).
30
32
  */
31
- minScale?: number;
33
+ minScale?: MinScale;
34
+ }
35
+
36
+ /** An absolute scale floor: one for every feature, or one per feature. */
37
+ export type MinScale = number | readonly number[];
38
+
39
+ /** The floor for feature `i` (a per-feature array's missing entry is 0). */
40
+ export function minScaleAt(minScale: MinScale, i: number): number {
41
+ return typeof minScale === "number" ? minScale : (minScale[i] ?? 0);
32
42
  }
33
43
 
34
44
  export class RobustZModel implements MlPredictor {
@@ -58,7 +68,7 @@ export function fitRobustZ(samples: readonly MlSample[], opts: RobustZOptions =
58
68
  const X = samples.map((s) => s.x);
59
69
  const d = featureDim(X);
60
70
  const minFrac = opts.minScaleFraction ?? 0.01;
61
- const minAbs = opts.minScale ?? 0;
71
+ const minAbs: MinScale = opts.minScale ?? 0;
62
72
  const center = new Float64Array(d);
63
73
  const scale = new Float64Array(d);
64
74
  for (let i = 0; i < d; i++) {
@@ -68,7 +78,7 @@ export function fitRobustZ(samples: readonly MlSample[], opts: RobustZOptions =
68
78
  let s = MAD_TO_SD * median(dev);
69
79
  if (!(s > 0)) s = MEANAD_TO_SD * (dev.reduce((a, b) => a + b, 0) / dev.length);
70
80
  center[i] = med;
71
- scale[i] = Math.max(s, minFrac * Math.abs(med) + 1e-9, minAbs);
81
+ scale[i] = Math.max(s, minFrac * Math.abs(med) + 1e-9, minScaleAt(minAbs, i));
72
82
  }
73
83
  return new RobustZModel(center, scale, opts.threshold ?? 3.5);
74
84
  }
@@ -9,6 +9,10 @@
9
9
  * NaN/Inf guard included), so training works everywhere (CI, Node without
10
10
  * @webgpu/node, a laptop with no GPU). Neither path bounds the step (maxDelta 0).
11
11
  *
12
+ * A non-finite gradient element is skipped by both paths — its moments and weight
13
+ * are left as they were, so the next finite step trains it again — and counted in
14
+ * {@link HeadTrainer.skippedNonFinite}, so a caller can see a run that diverged.
15
+ *
12
16
  * The head owns its forward/backward and its loss (MSE for regression, BCE /
13
17
  * softmax cross-entropy for classification); this file owns only the loop:
14
18
  * zero → accumulate per sample → average → clip → AdamW step.
@@ -86,6 +90,7 @@ export class HeadTrainer<S> {
86
90
  readonly device: GPUDevice | null;
87
91
  private _moments: AdamMoment[] | null = null;
88
92
  private _step = 0;
93
+ private _skippedNonFinite = 0;
89
94
  private readonly _adamwPipeline: GPUComputePipeline | null;
90
95
 
91
96
  constructor(
@@ -101,6 +106,19 @@ export class HeadTrainer<S> {
101
106
  : null;
102
107
  }
103
108
 
109
+ /**
110
+ * Parameter elements whose gradient was non-finite (NaN/±Inf) and whose update was
111
+ * therefore skipped, summed over every step this trainer has taken. 0 on a healthy run.
112
+ */
113
+ get skippedNonFinite(): number {
114
+ return this._skippedNonFinite;
115
+ }
116
+
117
+ /** Whether any step had to skip a non-finite gradient element. */
118
+ get diverged(): boolean {
119
+ return this._skippedNonFinite > 0;
120
+ }
121
+
104
122
  /** Whether the optimiser step runs on the GPU. */
105
123
  get gpuTraining(): boolean {
106
124
  return this.device != null && this._adamwPipeline != null;
@@ -173,12 +191,12 @@ export class HeadTrainer<S> {
173
191
  return total / samples.length;
174
192
  }
175
193
 
176
- /** AdamW on the CPU via the kernel's shared mirror (`adamwUpdateInPlace`). */
194
+ /** AdamW on the CPU via the kernel's shared mirror (`adamwUpdateInPlace`), which counts its skips. */
177
195
  private _adamwStepCpu(grads: HeadParam[], hp: AdamWKernelStep): void {
178
196
  const params = this.head.parameters();
179
197
  for (let pi = 0; pi < params.length; pi++) {
180
198
  const mom = this._moments![pi]!;
181
- adamwUpdateInPlace(params[pi]!.data, grads[pi]!.data, mom.m, mom.v, hp);
199
+ this._skippedNonFinite += adamwUpdateInPlace(params[pi]!.data, grads[pi]!.data, mom.m, mom.v, hp);
182
200
  }
183
201
  }
184
202
 
@@ -191,6 +209,8 @@ export class HeadTrainer<S> {
191
209
  for (let pi = 0; pi < params.length; pi++) {
192
210
  const p = params[pi]!;
193
211
  const mom = this._moments![pi]!;
212
+ // The kernel skips these elements too; it cannot count, so count them here.
213
+ this._skippedNonFinite += countNonFinite(grads[pi]!.data);
194
214
  const paramBuf = createStorageBuffer(device, p.data, true);
195
215
  const gradBuf = createStorageBuffer(device, grads[pi]!.data, false);
196
216
  const mBuf = createStorageBuffer(device, mom.m, true);
@@ -212,16 +232,35 @@ export class HeadTrainer<S> {
212
232
  }
213
233
  }
214
234
 
215
- /** Clip grads to `maxNorm` in place; returns the PRE-clip L2 norm (the signal). */
235
+ /** Elements of `a` that are NaN or ±Inf. */
236
+ function countNonFinite(a: Float32Array): number {
237
+ let n = 0;
238
+ for (let i = 0; i < a.length; i++) if (!Number.isFinite(a[i]!)) n++;
239
+ return n;
240
+ }
241
+
242
+ /**
243
+ * Clip grads to `maxNorm` in place; returns the PRE-clip L2 norm (the signal — NaN
244
+ * or Inf when any element is). The clip itself uses the norm of the FINITE elements:
245
+ * those are skipped by the optimiser anyway, and one NaN must not exempt the rest
246
+ * from clipping.
247
+ */
216
248
  function clipGradients(grads: HeadParam[], maxNorm: number): number {
217
249
  let normSq = 0;
218
- for (const g of grads) for (let i = 0; i < g.data.length; i++) normSq += g.data[i]! * g.data[i]!;
219
- const norm = Math.sqrt(normSq);
220
- if (norm > maxNorm && norm > 0) {
221
- const scale = maxNorm / norm;
250
+ let finiteSq = 0;
251
+ for (const g of grads) {
252
+ for (let i = 0; i < g.data.length; i++) {
253
+ const sq = g.data[i]! * g.data[i]!;
254
+ normSq += sq;
255
+ if (Number.isFinite(sq)) finiteSq += sq;
256
+ }
257
+ }
258
+ const finiteNorm = Math.sqrt(finiteSq);
259
+ if (finiteNorm > maxNorm && finiteNorm > 0) {
260
+ const scale = maxNorm / finiteNorm;
222
261
  for (const g of grads) for (let i = 0; i < g.data.length; i++) g.data[i]! *= scale;
223
262
  }
224
- return norm;
263
+ return Math.sqrt(normSq);
225
264
  }
226
265
 
227
266
  /** Concatenate a head's parameters into one flat vector (the federation unit). */
@@ -5,11 +5,12 @@
5
5
  * instead of every transcript. Curation only drops documents; it never rewrites one.
6
6
  */
7
7
  import { experienceDocuments, type ExperienceDocument } from "../../experience/corpus.js";
8
- import type { Episode, Run, Skill } from "../../experience/types.js";
8
+ import type { CurationRecord } from "../../experience/store.js";
9
+ import type { CuratedOut, Episode, Run, Skill } from "../../experience/types.js";
9
10
  import { KnnModel } from "../classical/knn.js";
10
11
  import { logScale, type TextEmbedder } from "../features.js";
11
12
  import { MlTaskRegistry } from "../registry.js";
12
- import { InMemoryMlModels, MlEngineUnavailableError, runMlTask, type MlRunContext } from "../run.js";
13
+ import { InMemoryMlModels, MlEngineUnavailableError, runMlTask, type MlModelStore, type MlRunContext } from "../run.js";
13
14
  import { trainMlTask, type MlTrainExample } from "../train.js";
14
15
  import type { MlPrediction, MlPredictor, MlTask } from "../types.js";
15
16
 
@@ -83,6 +84,14 @@ export function experienceQualityLabel(i: ExperienceQualityInput): number | null
83
84
  return i.succeeded / i.runs >= 0.5 ? 1 : 0;
84
85
  }
85
86
 
87
+ /**
88
+ * From this many finished runs, curation judges a procedure by its OBSERVED success
89
+ * rate (`succeeded / runs`) instead of the model: the label is then known, and better
90
+ * than any guess from neighbours. The model only scores procedures with 1–4 runs;
91
+ * never-run ones (0) are kept.
92
+ */
93
+ export const QUALITY_OBSERVED_MIN_RUNS = 5;
94
+
86
95
  /** Labelled examples for `experience.quality` — only the inputs that have run outcomes. */
87
96
  export function experienceQualityExamples(inputs: readonly ExperienceQualityInput[]): MlTrainExample<ExperienceQualityInput>[] {
88
97
  return inputs.flatMap((input) => {
@@ -147,14 +156,23 @@ export interface CurationOptions {
147
156
  /** Cosine similarity at or above which a document duplicates a kept one. Default {@link DEFAULT_DEDUPE_THRESHOLD}. */
148
157
  dedupeThreshold?: number;
149
158
  /**
150
- * Quality filter through the runner. A document whose P(high) is below `minPHigh`
151
- * (default 0.5) is dropped; one the runner cannot score (no trained engine) is kept,
152
- * and so is an UNSCORED one (`runs === 0` — never run is unknown, not bad).
159
+ * Quality filter. A document whose P(high) is below `minPHigh` (default 0.5) is
160
+ * dropped. P(high) is the OBSERVED success rate once it has
161
+ * {@link QUALITY_OBSERVED_MIN_RUNS} finished runs; with 1–4 it is the runner's
162
+ * answer, and one the runner cannot score (no trained engine) is kept. An UNSCORED
163
+ * document (`runs === 0` — never run is unknown, not bad) is always kept.
153
164
  */
154
165
  quality?: {
155
166
  ctx: MlRunContext;
156
167
  input: (doc: ExperienceDocument) => ExperienceQualityInput;
157
168
  minPHigh?: number;
169
+ /**
170
+ * The ids of the documents the quality model was trained on, in training order. A
171
+ * document among them is scored LEAVE-ONE-OUT — its own example does not vote, or
172
+ * a kNN would count it at similarity 1. Exact for the kNN engine (the one
173
+ * {@link curatePendingExperience} trains); another engine answers as fitted.
174
+ */
175
+ trainedIds?: readonly string[];
158
176
  };
159
177
  /** Document ids to leave out (e.g. those whose runs `run.anomaly` flagged). */
160
178
  exclude?: ReadonlySet<string>;
@@ -176,9 +194,34 @@ export interface CurationOptions {
176
194
  */
177
195
  export const CURATION_KNOWN_LIMIT = 200;
178
196
 
197
+ /** One document curation left out, and why. */
198
+ export interface CurationDrop {
199
+ id: string;
200
+ reason: CuratedOut["reason"];
201
+ of?: string;
202
+ score?: number;
203
+ }
204
+
179
205
  export interface CurationResult {
180
206
  kept: ExperienceDocument[];
181
- dropped: { id: string; reason: "duplicate" | "low-quality" | "excluded"; of?: string; score?: number }[];
207
+ dropped: CurationDrop[];
208
+ /**
209
+ * Documents an EARLIER curation left out whose verdict still stands (see
210
+ * {@link CuratedOut}) — skipped without being embedded or judged again. Only
211
+ * {@link curatePendingExperience} fills it, from the snapshot's `curated` records.
212
+ */
213
+ alreadyDropped?: CurationDrop[];
214
+ }
215
+
216
+ /** A model store whose kNN for `taskId` leaves the point at `index` out of every vote. */
217
+ function leaveOneOut(models: MlModelStore, taskId: string, index: number): MlModelStore {
218
+ return {
219
+ get: (t, e) => {
220
+ const p = models.get(t, e);
221
+ return t === taskId && p instanceof KnnModel && index < p.size ? p.excluding(index) : p;
222
+ },
223
+ set: (t, e, p) => models.set(t, e, p),
224
+ };
182
225
  }
183
226
 
184
227
  /** Memoise an embedder for one call, so curation embeds each text once. */
@@ -195,6 +238,25 @@ function memoEmbedder(e: TextEmbedder): TextEmbedder {
195
238
  };
196
239
  }
197
240
 
241
+ /**
242
+ * P(high) of one quality input, or null when it cannot be judged (never run, or no
243
+ * trained engine). Observed rate from {@link QUALITY_OBSERVED_MIN_RUNS} runs; the
244
+ * runner's answer below that, leave-one-out when the document is training example
245
+ * `trainedIndex`.
246
+ */
247
+ async function qualityScore(input: ExperienceQualityInput, ctx: MlRunContext, trainedIndex: number | undefined): Promise<number | null> {
248
+ // Never run ⇒ no outcome to judge it by: keep it rather than let a guess drop it.
249
+ if (input.runs <= 0) return null;
250
+ if (input.runs >= QUALITY_OBSERVED_MIN_RUNS) return input.succeeded / input.runs;
251
+ const runCtx = trainedIndex == null || !ctx.models ? ctx : { ...ctx, models: leaveOneOut(ctx.models, EXPERIENCE_QUALITY_TASK.id, trainedIndex) };
252
+ try {
253
+ return (await runMlTask<ExperienceQuality>(EXPERIENCE_QUALITY_TASK.id, input, runCtx)).output.pHigh;
254
+ } catch (error) {
255
+ if (!(error instanceof MlEngineUnavailableError)) throw error;
256
+ return null;
257
+ }
258
+ }
259
+
198
260
  /**
199
261
  * Curate documents before they are packed into adaptation passes: drop excluded
200
262
  * ones, low-quality ones (when a quality filter is given), then near-duplicates of
@@ -210,6 +272,7 @@ export async function curateExperience(docs: readonly ExperienceDocument[], opts
210
272
  const kept: ExperienceDocument[] = [];
211
273
  const dropped: CurationResult["dropped"] = [];
212
274
  const qctx = opts.quality ? { ...opts.quality.ctx, embedder } : null;
275
+ const trainedAt = new Map((opts.quality?.trainedIds ?? []).map((id, i) => [id, i] as const));
213
276
  const embedDoc = (doc: ExperienceDocument) => embedder.embed(EXPERIENCE_DEDUPE_TASK.features.text!(doc));
214
277
 
215
278
  for (const doc of opts.known ?? []) {
@@ -223,18 +286,10 @@ export async function curateExperience(docs: readonly ExperienceDocument[], opts
223
286
  continue;
224
287
  }
225
288
  if (opts.quality && qctx) {
226
- const input = opts.quality.input(doc);
227
- // Never run ⇒ no outcome to judge it by: keep it rather than let a guess drop it.
228
- if (input.runs > 0) {
229
- try {
230
- const r = await runMlTask<ExperienceQuality>(EXPERIENCE_QUALITY_TASK.id, input, qctx);
231
- if (r.output.pHigh < (opts.quality.minPHigh ?? 0.5)) {
232
- dropped.push({ id: doc.id, reason: "low-quality", score: r.output.pHigh });
233
- continue;
234
- }
235
- } catch (error) {
236
- if (!(error instanceof MlEngineUnavailableError)) throw error;
237
- }
289
+ const pHigh = await qualityScore(opts.quality.input(doc), qctx, trainedAt.get(doc.id));
290
+ if (pHigh != null && pHigh < (opts.quality.minPHigh ?? 0.5)) {
291
+ dropped.push({ id: doc.id, reason: "low-quality", score: pHigh });
292
+ continue;
238
293
  }
239
294
  }
240
295
  const x = await embedDoc(doc);
@@ -255,16 +310,36 @@ export interface ExperienceCurationSource {
255
310
  episodes: Episode[];
256
311
  skills: Skill[];
257
312
  runs: readonly Run[];
313
+ /** Earlier curations' verdicts (the snapshot's `curated`); a standing one is skipped. */
314
+ curated?: Readonly<Record<string, CuratedOut>>;
315
+ }
316
+
317
+ /**
318
+ * Whether an earlier `curated_out` verdict still stands (see {@link CuratedOut}): a
319
+ * duplicate while what it repeats is learned (in `learnedIds`), a low-quality one
320
+ * while its finished-run count is unchanged. An exclusion is always reconsidered.
321
+ */
322
+ export function curationStands(verdict: CuratedOut | undefined, finishedRuns: number, learnedIds: ReadonlySet<string>): boolean {
323
+ if (!verdict || verdict.status !== "curated_out") return false;
324
+ if (verdict.reason === "duplicate") return verdict.of != null && learnedIds.has(verdict.of);
325
+ if (verdict.reason === "low-quality") return verdict.runs === finishedRuns;
326
+ return false;
258
327
  }
259
328
 
260
329
  /**
261
330
  * The whole curation step a host runs in front of the adaptation passes, so every
262
- * host wires it the same way: train `experience.quality` on the run outcomes there
263
- * are (it stays untrained below its `minSamples`, and then nothing is dropped for
264
- * quality), then {@link curateExperience} the pending documents with it,
265
- * de-duplicating against each other and against `known` — the already-learned
266
- * documents, MOST RECENTLY LEARNED FIRST; only the first {@link CURATION_KNOWN_LIMIT}
267
- * are embedded, so a host can pass its whole ledger.
331
+ * host wires it the same way:
332
+ *
333
+ * 1. skip each pending document whose earlier `curated_out` verdict still stands
334
+ * ({@link curationStands}) — reported in `alreadyDropped`, never re-embedded;
335
+ * 2. train `experience.quality` on the run outcomes of at most
336
+ * {@link CURATION_KNOWN_LIMIT} scored procedures — the pending ones, then the
337
+ * learned ones most recently learned first (it stays untrained below its
338
+ * `minSamples`, and then only the observed-rate rule drops for quality);
339
+ * 3. {@link curateExperience} the rest with it — leave-one-out for a document in
340
+ * that training set — de-duplicating against each other and against `known`:
341
+ * the already-learned documents, MOST RECENTLY LEARNED FIRST; only the first
342
+ * {@link CURATION_KNOWN_LIMIT} are embedded, so a host can pass its whole ledger.
268
343
  */
269
344
  export async function curatePendingExperience(
270
345
  pending: readonly ExperienceDocument[],
@@ -275,19 +350,64 @@ export async function curatePendingExperience(
275
350
  const embedder = memoEmbedder(opts.embedder);
276
351
  const inputs = experienceQualityInputs(source.episodes, source.skills, source.runs);
277
352
  const byId = new Map(inputs.map((i) => [i.doc.id, i] as const));
353
+ // A document with no joined input (not in the snapshot) counts as never run.
354
+ const inputOf = (doc: ExperienceDocument): ExperienceQualityInput => byId.get(doc.id) ?? { doc, steps: 0, runs: 0, succeeded: 0, failed: 0 };
355
+
356
+ const learnedIds = new Set((opts.known ?? []).map((d) => d.id));
357
+ const alreadyDropped: CurationDrop[] = [];
358
+ const toCurate: ExperienceDocument[] = [];
359
+ for (const doc of pending) {
360
+ const verdict = source.curated?.[doc.id];
361
+ if (verdict && curationStands(verdict, inputOf(doc).runs, learnedIds)) {
362
+ alreadyDropped.push({ id: doc.id, reason: verdict.reason, ...(verdict.of != null ? { of: verdict.of } : {}), ...(verdict.score != null ? { score: verdict.score } : {}) });
363
+ } else toCurate.push(doc);
364
+ }
365
+
366
+ // The bounded training set: newest first (pending, then most recently learned),
367
+ // scored only, then reversed — trainMlTask holds out the chronological tail.
368
+ const recentFirst = [...pending, ...(opts.known ?? [])]
369
+ .map((d) => byId.get(d.id))
370
+ .filter((i): i is ExperienceQualityInput => i != null && experienceQualityLabel(i) != null)
371
+ .slice(0, CURATION_KNOWN_LIMIT);
372
+ const train = experienceQualityExamples(recentFirst.reverse());
373
+
278
374
  const registry = new MlTaskRegistry();
279
375
  registry.register(EXPERIENCE_QUALITY_TASK);
280
376
  const models = new InMemoryMlModels();
281
- await trainMlTask(EXPERIENCE_QUALITY_TASK, experienceQualityExamples(inputs), { embedder, models });
282
- return curateExperience(pending, {
377
+ const fitted = await trainMlTask(EXPERIENCE_QUALITY_TASK, train, { embedder, models });
378
+ const r = await curateExperience(toCurate, {
283
379
  embedder,
284
380
  ...(known ? { known } : {}),
285
381
  ...(opts.dedupeThreshold != null ? { dedupeThreshold: opts.dedupeThreshold } : {}),
286
382
  quality: {
287
383
  ctx: { registry, mode: "local-only", models },
288
- // A document with no joined input (not in the snapshot) counts as never run.
289
- input: (doc) => byId.get(doc.id) ?? { doc, steps: 0, runs: 0, succeeded: 0, failed: 0 },
384
+ input: inputOf,
290
385
  ...(opts.minPHigh != null ? { minPHigh: opts.minPHigh } : {}),
386
+ // The refit on all examples keeps their order, so kNN point i is train[i].
387
+ ...(fitted ? { trainedIds: train.map((e) => e.input.doc.id) } : {}),
291
388
  },
292
389
  });
390
+ return { ...r, alreadyDropped };
391
+ }
392
+
393
+ /**
394
+ * The `curated_out` records a host persists (`ExperienceStore.recordCuration`) for one
395
+ * curation result: each dropped document with its verdict and finished-run count, and
396
+ * the kept ids (whose earlier verdicts clear).
397
+ */
398
+ export function curationRecords(result: CurationResult, source: Pick<ExperienceCurationSource, "runs">, at: number): CurationRecord {
399
+ const finished = new Map<string, number>();
400
+ for (const run of source.runs) if (run.status !== "running") finished.set(run.skillId, (finished.get(run.skillId) ?? 0) + 1);
401
+ const dropped: Record<string, CuratedOut> = {};
402
+ for (const d of result.dropped) {
403
+ dropped[d.id] = {
404
+ status: "curated_out",
405
+ reason: d.reason,
406
+ ...(d.of != null ? { of: d.of } : {}),
407
+ ...(d.score != null ? { score: d.score } : {}),
408
+ runs: finished.get(d.id) ?? 0,
409
+ at,
410
+ };
411
+ }
412
+ return { dropped, kept: result.kept.map((d) => d.id) };
293
413
  }
@@ -30,21 +30,52 @@ export interface RunAnomaly {
30
30
 
31
31
  export const RUN_ANOMALY_METRICS = ["cost", "duration", "toolFailures"] as const;
32
32
 
33
+ /** The fixed log references: cost in cents ($10), duration in ms (one hour), tool failures (50). */
34
+ const COST_REF_CENTS = 1000;
35
+ const DURATION_REF_MS = 3_600_000;
36
+ const FAILURES_REF = 50;
37
+
33
38
  /** Fixed log scales (a $10 run, a one-hour run, 50 failures map to 1). */
34
39
  export function runAnomalyFeatures(m: RunMetrics): number[] {
35
- return [logScale(m.costUsd * 100, 1000), logScale(m.durationMs, 3_600_000), logScale(m.toolFailures, 50)];
40
+ return [
41
+ logScale(m.costUsd * 100, COST_REF_CENTS),
42
+ logScale(m.durationMs, DURATION_REF_MS),
43
+ logScale(m.toolFailures, FAILURES_REF),
44
+ ];
36
45
  }
37
46
 
38
47
  /**
39
- * The smallest per-metric scale (σ) both detectors use, in the fixed log units above.
40
- * A history with no tool failures has MAD = mean-AD = 0 and EWMA σ = 0, so without an
41
- * absolute floor ONE failure (log1p(1)/log1p(50) ≈ 0.18) scored as a huge outlier and
42
- * every run with any failure was flagged. At 0.1, one or two failures after a clean
43
- * history stay normal (z ≈ 1.8 / 2.8) and three or more flag (z ≥ 3.5); on cost, 0.1
44
- * is ~0.7 nats, so a constant-cost history flags a ~10× jump. (0.05 was too tight: it
45
- * still flagged a single failure.)
48
+ * The multiplicative jump in cost or duration that flags against a constant history.
49
+ * A constant history has MAD = mean-AD = 0 and EWMA σ = 0, so the floor below IS the
50
+ * scale both detectors divide by.
51
+ */
52
+ export const RUN_ANOMALY_FLAG_RATIO = 3;
53
+
54
+ /**
55
+ * The smallest per-metric scale (σ) both detectors use, in the fixed log units above —
56
+ * ONE PER METRIC, because each metric is divided by a different reference: a feature
57
+ * unit is log1p(ref) nats, i.e. log1p(1000) ≈ 6.909 nats for cost but log1p(3.6e6) ≈
58
+ * 15.096 nats for duration. One scalar floor (it was 0.1) therefore meant ~0.69 nats
59
+ * (2×) on cost but ~1.51 nats (4.5×) on duration, and a 10 s → 15 min run (4.5 nats,
60
+ * z ≈ 3.0) slipped under EWMA's 3.
61
+ *
62
+ * Derivation, cost and duration: for values well above 1, a k× jump moves log1p by
63
+ * ≈ ln k nats = ln k / log1p(ref) feature units. The composite flags at the smaller of
64
+ * the two thresholds (EWMA 3; robust z is 3.5), so the floor that flags a 3× jump is
65
+ * floor = ln 3 / (3 · log1p(ref))
66
+ * cost: 1.0986 / (3 · 6.9088) ≈ 0.0530 (robust z alone flags from ≈ 3.6×)
67
+ * duration: 1.0986 / (3 · 15.0964) ≈ 0.0243 (robust z alone flags from ≈ 3.6×)
68
+ * A 1.5× wobble (0.405 nats) scores z ≈ 1.1 on either — normal.
69
+ *
70
+ * Tool failures keep the absolute 0.1: from a clean history one or two failures
71
+ * (log1p(1|2)/log1p(50) ≈ 0.18 | 0.28) stay normal (z ≈ 1.8 / 2.8) and three or more
72
+ * (≈ 0.35) flag. (0.05 was too tight: it flagged a single failure.)
46
73
  */
47
- export const RUN_ANOMALY_MIN_SCALE = 0.1;
74
+ export const RUN_ANOMALY_MIN_SCALE: readonly number[] = [
75
+ Math.log(RUN_ANOMALY_FLAG_RATIO) / (3 * Math.log1p(COST_REF_CENTS)),
76
+ Math.log(RUN_ANOMALY_FLAG_RATIO) / (3 * Math.log1p(DURATION_REF_MS)),
77
+ 0.1,
78
+ ];
48
79
 
49
80
  /** The composite detector with `run.anomaly`'s metric names on its explanation. */
50
81
  export class RunAnomalyDetector extends AnomalyCompositeModel {
@@ -55,7 +86,7 @@ export class RunAnomalyDetector extends AnomalyCompositeModel {
55
86
  }
56
87
  }
57
88
 
58
- /** Fit both detectors on a chronological history of runs (as feature vectors), floored at {@link RUN_ANOMALY_MIN_SCALE}. */
89
+ /** Fit both detectors on a chronological history of runs (as feature vectors), floored per metric at {@link RUN_ANOMALY_MIN_SCALE}. */
59
90
  export function fitRunAnomaly(samples: readonly MlSample[], opts: AnomalyCompositeOptions = {}): RunAnomalyDetector {
60
91
  return new RunAnomalyDetector(
61
92
  fitRobustZ(samples, { ...opts.robustZ, minScale: opts.robustZ?.minScale ?? RUN_ANOMALY_MIN_SCALE }),
package/src/ml/train.ts CHANGED
@@ -49,6 +49,12 @@ export interface MlTrainResult {
49
49
  /** Size of the held-out tail, and the model's loss on it (null when none was held out). */
50
50
  holdout: number;
51
51
  holdoutLoss: number | null;
52
+ /**
53
+ * Head engines: parameter elements the final fit skipped for a non-finite gradient
54
+ * ({@link HeadTrainer.skippedNonFinite}) — above 0, the fit diverged somewhere. 0
55
+ * for classical engines and task-owned fits.
56
+ */
57
+ skippedNonFinite: number;
52
58
  }
53
59
 
54
60
  export { mlEvalLoss } from "./eval_loss.js";
@@ -60,9 +66,14 @@ function defaultEngine(task: MlTask<any, any>): MlEngineRef {
60
66
  return e;
61
67
  }
62
68
 
63
- async function fitEngine(task: MlTask<any, any>, ref: MlEngineRef, samples: MlSample[], opts: MlTrainOptions): Promise<MlPredictor> {
64
- if (task.train?.fit) return task.train.fit(samples, ref);
65
- if (ref.engine === "classical") return fitClassical(ref.algorithm, samples, opts.classical);
69
+ interface FittedEngine {
70
+ predictor: MlPredictor;
71
+ skippedNonFinite: number;
72
+ }
73
+
74
+ async function fitEngine(task: MlTask<any, any>, ref: MlEngineRef, samples: MlSample[], opts: MlTrainOptions): Promise<FittedEngine> {
75
+ if (task.train?.fit) return { predictor: await task.train.fit(samples, ref), skippedNonFinite: 0 };
76
+ if (ref.engine === "classical") return { predictor: fitClassical(ref.algorithm, samples, opts.classical), skippedNonFinite: 0 };
66
77
  if (ref.engine === "head") {
67
78
  const objective = task.kind === "classify" ? "softmax" : task.kind === "regress" ? "mse" : null;
68
79
  if (!objective) throw new Error(`trainMlTask(${task.id}): a head engine trains classify or regress tasks only`);
@@ -73,8 +84,9 @@ async function fitEngine(task: MlTask<any, any>, ref: MlEngineRef, samples: MlSa
73
84
  objective,
74
85
  ...(opts.head?.seed != null ? { seed: opts.head.seed } : {}),
75
86
  });
76
- await new HeadTrainer(head, opts.device ?? null, `trainMlTask(${task.id})`).train(samples, opts.head?.train);
77
- return head;
87
+ const trainer = new HeadTrainer(head, opts.device ?? null, `trainMlTask(${task.id})`);
88
+ await trainer.train(samples, opts.head?.train);
89
+ return { predictor: head, skippedNonFinite: trainer.skippedNonFinite };
78
90
  }
79
91
  throw new Error(`trainMlTask(${task.id}): engine '${engineKey(ref)}' is not trained here`);
80
92
  }
@@ -107,14 +119,21 @@ export async function trainMlTask<I>(
107
119
  if (nHold >= 1 && samples.length - nHold >= 1) {
108
120
  const train = samples.slice(0, samples.length - nHold);
109
121
  const hold = samples.slice(samples.length - nHold);
110
- const predictor = await fitEngine(task, ref, train, opts);
111
- holdoutLoss = mlEvalLoss(task.kind, predictor, hold);
122
+ const fit = await fitEngine(task, ref, train, opts);
123
+ holdoutLoss = mlEvalLoss(task.kind, fit.predictor, hold);
112
124
  if (opts.refitOnAll === false) {
113
- opts.models?.set(task.id, engineKey(ref), predictor);
114
- return { predictor, engine: ref, samples: train.length, holdout: nHold, holdoutLoss };
125
+ opts.models?.set(task.id, engineKey(ref), fit.predictor);
126
+ return { predictor: fit.predictor, engine: ref, samples: train.length, holdout: nHold, holdoutLoss, skippedNonFinite: fit.skippedNonFinite };
115
127
  }
116
128
  }
117
- const predictor = await fitEngine(task, ref, samples, opts);
118
- opts.models?.set(task.id, engineKey(ref), predictor);
119
- return { predictor, engine: ref, samples: samples.length, holdout: nHold >= 1 ? nHold : 0, holdoutLoss };
129
+ const fit = await fitEngine(task, ref, samples, opts);
130
+ opts.models?.set(task.id, engineKey(ref), fit.predictor);
131
+ return {
132
+ predictor: fit.predictor,
133
+ engine: ref,
134
+ samples: samples.length,
135
+ holdout: nHold >= 1 ? nHold : 0,
136
+ holdoutLoss,
137
+ skippedNonFinite: fit.skippedNonFinite,
138
+ };
120
139
  }