@seanhogg/builderforce-memory-engine 2026.10.2 → 2026.10.3
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.
- package/dist/evermind/adapt.d.ts +7 -0
- package/dist/evermind/adapt.d.ts.map +1 -1
- package/dist/evermind/adapt.js +1 -1
- package/dist/evermind/adapt.js.map +1 -1
- package/dist/experience/index.d.ts +2 -2
- package/dist/experience/index.d.ts.map +1 -1
- package/dist/experience/index.js.map +1 -1
- package/dist/experience/store.d.ts +19 -1
- package/dist/experience/store.d.ts.map +1 -1
- package/dist/experience/store.js +14 -2
- package/dist/experience/store.js.map +1 -1
- package/dist/experience/types.d.ts +18 -0
- package/dist/experience/types.d.ts.map +1 -1
- package/dist/experience/types.js.map +1 -1
- package/dist/kernels/weight_update.d.ts.map +1 -1
- package/dist/kernels/weight_update.js +12 -0
- package/dist/kernels/weight_update.js.map +1 -1
- package/dist/ml/classical/ewma.d.ts +5 -4
- package/dist/ml/classical/ewma.d.ts.map +1 -1
- package/dist/ml/classical/ewma.js +2 -2
- package/dist/ml/classical/ewma.js.map +1 -1
- package/dist/ml/classical/index.d.ts +2 -2
- package/dist/ml/classical/index.d.ts.map +1 -1
- package/dist/ml/classical/index.js +1 -1
- package/dist/ml/classical/index.js.map +1 -1
- package/dist/ml/classical/knn.d.ts +11 -4
- package/dist/ml/classical/knn.d.ts.map +1 -1
- package/dist/ml/classical/knn.js +26 -6
- package/dist/ml/classical/knn.js.map +1 -1
- package/dist/ml/classical/robust_z.d.ts +9 -3
- package/dist/ml/classical/robust_z.d.ts.map +1 -1
- package/dist/ml/classical/robust_z.js +5 -1
- package/dist/ml/classical/robust_z.js.map +1 -1
- package/dist/ml/head_trainer.d.ts +13 -1
- package/dist/ml/head_trainer.d.ts.map +1 -1
- package/dist/ml/head_trainer.js +47 -10
- package/dist/ml/head_trainer.js.map +1 -1
- package/dist/ml/tasks/experience.d.ts +61 -16
- package/dist/ml/tasks/experience.d.ts.map +1 -1
- package/dist/ml/tasks/experience.js +119 -24
- package/dist/ml/tasks/experience.js.map +1 -1
- package/dist/ml/tasks/run_anomaly.d.ts +26 -9
- package/dist/ml/tasks/run_anomaly.d.ts.map +1 -1
- package/dist/ml/tasks/run_anomaly.js +39 -10
- package/dist/ml/tasks/run_anomaly.js.map +1 -1
- package/dist/ml/train.d.ts +6 -0
- package/dist/ml/train.d.ts.map +1 -1
- package/dist/ml/train.js +19 -11
- package/dist/ml/train.js.map +1 -1
- package/dist/optim/adamw.d.ts +11 -5
- package/dist/optim/adamw.d.ts.map +1 -1
- package/dist/optim/adamw.js +19 -4
- package/dist/optim/adamw.js.map +1 -1
- package/package.json +1 -1
- package/src/evermind/adapt.ts +8 -2
- package/src/experience/index.ts +2 -1
- package/src/experience/store.ts +35 -3
- package/src/experience/types.ts +19 -0
- package/src/kernels/weight_update.ts +12 -0
- package/src/ml/classical/ewma.ts +5 -5
- package/src/ml/classical/index.ts +2 -2
- package/src/ml/classical/knn.ts +25 -6
- package/src/ml/classical/robust_z.ts +15 -5
- package/src/ml/head_trainer.ts +47 -8
- package/src/ml/tasks/experience.ts +148 -28
- package/src/ml/tasks/run_anomaly.ts +41 -10
- package/src/ml/train.ts +31 -12
- package/src/optim/adamw.ts +21 -5
package/src/ml/classical/knn.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
|
29
|
-
*
|
|
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?:
|
|
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
|
}
|
package/src/ml/head_trainer.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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 {
|
|
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
|
|
151
|
-
* (
|
|
152
|
-
*
|
|
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:
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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,
|
|
282
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
|
40
|
-
* A history
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
118
|
-
opts.models?.set(task.id, engineKey(ref), predictor);
|
|
119
|
-
return {
|
|
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
|
}
|