@seanhogg/builderforce-memory-engine 2026.10.1 → 2026.10.2
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/delta_wire.d.ts +16 -1
- package/dist/evermind/delta_wire.d.ts.map +1 -1
- package/dist/evermind/delta_wire.js +22 -2
- package/dist/evermind/delta_wire.js.map +1 -1
- package/dist/evermind/index.d.ts +2 -2
- package/dist/evermind/index.d.ts.map +1 -1
- package/dist/evermind/index.js +1 -1
- package/dist/evermind/index.js.map +1 -1
- package/dist/evermind/merge.d.ts +5 -2
- package/dist/evermind/merge.d.ts.map +1 -1
- package/dist/evermind/merge.js +5 -2
- package/dist/evermind/merge.js.map +1 -1
- package/dist/evermind/ml_head.d.ts +28 -0
- package/dist/evermind/ml_head.d.ts.map +1 -0
- package/dist/evermind/ml_head.js +47 -0
- package/dist/evermind/ml_head.js.map +1 -0
- package/dist/evermind/package.d.ts +2 -2
- package/dist/evermind/package.d.ts.map +1 -1
- package/dist/evermind/package.js +1 -1
- package/dist/evermind/package.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limbic/limbic_model.d.ts.map +1 -1
- package/dist/limbic/limbic_model.js +1 -3
- package/dist/limbic/limbic_model.js.map +1 -1
- package/dist/ml/classical/anomaly_composite.d.ts +32 -0
- package/dist/ml/classical/anomaly_composite.d.ts.map +1 -0
- package/dist/ml/classical/anomaly_composite.js +29 -0
- package/dist/ml/classical/anomaly_composite.js.map +1 -0
- package/dist/ml/classical/ewma.d.ts +6 -1
- package/dist/ml/classical/ewma.d.ts.map +1 -1
- package/dist/ml/classical/ewma.js +5 -3
- package/dist/ml/classical/ewma.js.map +1 -1
- package/dist/ml/classical/gbm.d.ts.map +1 -1
- package/dist/ml/classical/gbm.js +2 -1
- package/dist/ml/classical/gbm.js.map +1 -1
- package/dist/ml/classical/index.d.ts +4 -0
- package/dist/ml/classical/index.d.ts.map +1 -1
- package/dist/ml/classical/index.js +4 -0
- package/dist/ml/classical/index.js.map +1 -1
- package/dist/ml/classical/kmeans.d.ts.map +1 -1
- package/dist/ml/classical/kmeans.js +23 -4
- package/dist/ml/classical/kmeans.js.map +1 -1
- package/dist/ml/classical/knn.d.ts +4 -1
- package/dist/ml/classical/knn.d.ts.map +1 -1
- package/dist/ml/classical/knn.js +2 -1
- package/dist/ml/classical/knn.js.map +1 -1
- package/dist/ml/classical/logistic.d.ts +1 -9
- package/dist/ml/classical/logistic.d.ts.map +1 -1
- package/dist/ml/classical/logistic.js +17 -9
- package/dist/ml/classical/logistic.js.map +1 -1
- package/dist/ml/classical/naive_bayes.d.ts +5 -1
- package/dist/ml/classical/naive_bayes.d.ts.map +1 -1
- package/dist/ml/classical/naive_bayes.js +8 -3
- package/dist/ml/classical/naive_bayes.js.map +1 -1
- package/dist/ml/classical/robust_z.d.ts +9 -1
- package/dist/ml/classical/robust_z.d.ts.map +1 -1
- package/dist/ml/classical/robust_z.js +2 -1
- package/dist/ml/classical/robust_z.js.map +1 -1
- package/dist/ml/eval_loss.d.ts +15 -0
- package/dist/ml/eval_loss.d.ts.map +1 -0
- package/dist/ml/eval_loss.js +20 -0
- package/dist/ml/eval_loss.js.map +1 -0
- package/dist/ml/federate.d.ts +11 -25
- package/dist/ml/federate.d.ts.map +1 -1
- package/dist/ml/federate.js +15 -38
- package/dist/ml/federate.js.map +1 -1
- package/dist/ml/head_trainer.d.ts +4 -2
- package/dist/ml/head_trainer.d.ts.map +1 -1
- package/dist/ml/head_trainer.js +19 -19
- package/dist/ml/head_trainer.js.map +1 -1
- package/dist/ml/run.d.ts +11 -2
- package/dist/ml/run.d.ts.map +1 -1
- package/dist/ml/run.js +56 -17
- package/dist/ml/run.js.map +1 -1
- package/dist/ml/tasks/experience.d.ts +42 -5
- package/dist/ml/tasks/experience.d.ts.map +1 -1
- package/dist/ml/tasks/experience.js +76 -19
- package/dist/ml/tasks/experience.js.map +1 -1
- package/dist/ml/tasks/run_anomaly.d.ts +20 -19
- package/dist/ml/tasks/run_anomaly.d.ts.map +1 -1
- package/dist/ml/tasks/run_anomaly.js +20 -30
- package/dist/ml/tasks/run_anomaly.js.map +1 -1
- package/dist/ml/train.d.ts +2 -7
- package/dist/ml/train.d.ts.map +1 -1
- package/dist/ml/train.js +2 -19
- package/dist/ml/train.js.map +1 -1
- package/dist/ml/types.d.ts +6 -3
- package/dist/ml/types.d.ts.map +1 -1
- package/dist/ml/types.js +3 -2
- package/dist/ml/types.js.map +1 -1
- package/package.json +1 -1
- package/src/evermind/delta_wire.ts +173 -148
- package/src/evermind/index.ts +36 -35
- package/src/evermind/merge.ts +10 -4
- package/src/evermind/ml_head.ts +63 -0
- package/src/evermind/package.ts +488 -488
- package/src/index.ts +2 -0
- package/src/limbic/limbic_model.ts +1 -4
- package/src/limbic/limbic_trainer.ts +91 -91
- package/src/ml/classical/anomaly_composite.ts +46 -0
- package/src/ml/classical/ewma.ts +7 -1
- package/src/ml/classical/gbm.ts +144 -143
- package/src/ml/classical/index.ts +6 -0
- package/src/ml/classical/kmeans.ts +22 -4
- package/src/ml/classical/knn.ts +6 -2
- package/src/ml/classical/logistic.ts +9 -9
- package/src/ml/classical/math.ts +93 -93
- package/src/ml/classical/naive_bayes.ts +10 -3
- package/src/ml/classical/robust_z.ts +11 -2
- package/src/ml/eval_loss.ts +27 -0
- package/src/ml/federate.ts +22 -52
- package/src/ml/head_trainer.ts +21 -42
- package/src/ml/registry.ts +44 -44
- package/src/ml/run.ts +57 -13
- package/src/ml/tasks/experience.ts +98 -19
- package/src/ml/tasks/run_anomaly.ts +30 -36
- package/src/ml/train.ts +3 -19
- package/src/ml/types.ts +135 -132
- package/src/training/cpu_ops.ts +141 -141
package/src/ml/registry.ts
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ml/registry.ts — the ONE registry of ML tasks, keyed by task id.
|
|
3
|
-
*
|
|
4
|
-
* A new task is a new row, not a new module. The registry only stores and looks
|
|
5
|
-
* up rows; running lives in `run.ts`, training in `train.ts`.
|
|
6
|
-
*/
|
|
7
|
-
import { ML_TASK_ID_RE, type MlTask } from "./types.js";
|
|
8
|
-
|
|
9
|
-
export class MlTaskRegistry {
|
|
10
|
-
private readonly _tasks = new Map<string, MlTask<any, any>>();
|
|
11
|
-
|
|
12
|
-
/** Register a row. Throws on a malformed id, a duplicate id, or a row with no engine. */
|
|
13
|
-
register<I, O>(task: MlTask<I, O>): MlTask<I, O> {
|
|
14
|
-
if (!ML_TASK_ID_RE.test(task.id)) throw new Error(`MlTaskRegistry: malformed task id '${task.id}'`);
|
|
15
|
-
if (this._tasks.has(task.id)) throw new Error(`MlTaskRegistry: task '${task.id}' is already registered`);
|
|
16
|
-
if (task.engines.length === 0) throw new Error(`MlTaskRegistry: task '${task.id}' lists no engine`);
|
|
17
|
-
if (task.kind === "classify" && !(task.output.labels && task.output.labels.length >= 2)) {
|
|
18
|
-
throw new Error(`MlTaskRegistry: classify task '${task.id}' needs at least two output labels`);
|
|
19
|
-
}
|
|
20
|
-
this._tasks.set(task.id, task);
|
|
21
|
-
return task;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** The row for `id`, or undefined. */
|
|
25
|
-
get<I = unknown, O = unknown>(id: string): MlTask<I, O> | undefined {
|
|
26
|
-
return this._tasks.get(id) as MlTask<I, O> | undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The row for `id`; throws when it is not registered. */
|
|
30
|
-
require<I = unknown, O = unknown>(id: string): MlTask<I, O> {
|
|
31
|
-
const t = this.get<I, O>(id);
|
|
32
|
-
if (!t) throw new Error(`MlTaskRegistry: unknown task '${id}'`);
|
|
33
|
-
return t;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
has(id: string): boolean {
|
|
37
|
-
return this._tasks.has(id);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Every row, in registration order. */
|
|
41
|
-
list(): MlTask<any, any>[] {
|
|
42
|
-
return [...this._tasks.values()];
|
|
43
|
-
}
|
|
44
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ml/registry.ts — the ONE registry of ML tasks, keyed by task id.
|
|
3
|
+
*
|
|
4
|
+
* A new task is a new row, not a new module. The registry only stores and looks
|
|
5
|
+
* up rows; running lives in `run.ts`, training in `train.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { ML_TASK_ID_RE, type MlTask } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export class MlTaskRegistry {
|
|
10
|
+
private readonly _tasks = new Map<string, MlTask<any, any>>();
|
|
11
|
+
|
|
12
|
+
/** Register a row. Throws on a malformed id, a duplicate id, or a row with no engine. */
|
|
13
|
+
register<I, O>(task: MlTask<I, O>): MlTask<I, O> {
|
|
14
|
+
if (!ML_TASK_ID_RE.test(task.id)) throw new Error(`MlTaskRegistry: malformed task id '${task.id}'`);
|
|
15
|
+
if (this._tasks.has(task.id)) throw new Error(`MlTaskRegistry: task '${task.id}' is already registered`);
|
|
16
|
+
if (task.engines.length === 0) throw new Error(`MlTaskRegistry: task '${task.id}' lists no engine`);
|
|
17
|
+
if (task.kind === "classify" && !(task.output.labels && task.output.labels.length >= 2)) {
|
|
18
|
+
throw new Error(`MlTaskRegistry: classify task '${task.id}' needs at least two output labels`);
|
|
19
|
+
}
|
|
20
|
+
this._tasks.set(task.id, task);
|
|
21
|
+
return task;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The row for `id`, or undefined. */
|
|
25
|
+
get<I = unknown, O = unknown>(id: string): MlTask<I, O> | undefined {
|
|
26
|
+
return this._tasks.get(id) as MlTask<I, O> | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The row for `id`; throws when it is not registered. */
|
|
30
|
+
require<I = unknown, O = unknown>(id: string): MlTask<I, O> {
|
|
31
|
+
const t = this.get<I, O>(id);
|
|
32
|
+
if (!t) throw new Error(`MlTaskRegistry: unknown task '${id}'`);
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
has(id: string): boolean {
|
|
37
|
+
return this._tasks.has(id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every row, in registration order. */
|
|
41
|
+
list(): MlTask<any, any>[] {
|
|
42
|
+
return [...this._tasks.values()];
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/ml/run.ts
CHANGED
|
@@ -112,28 +112,54 @@ export class MlEngineUnavailableError extends Error {
|
|
|
112
112
|
readonly taskId: string,
|
|
113
113
|
readonly decision: MlDecision,
|
|
114
114
|
) {
|
|
115
|
-
super(
|
|
115
|
+
super(
|
|
116
|
+
`runMlTask(${taskId}): no engine could answer in '${decision.mode}' mode (${
|
|
117
|
+
decision.skipped.map((s) => `${s.engine}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`).join(", ") || "none allowed"
|
|
118
|
+
})`,
|
|
119
|
+
);
|
|
116
120
|
this.name = "MlEngineUnavailableError";
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* The task's engines the mode allows, in the order the runner tries them. Exhaustive:
|
|
126
|
+
* a mode string outside {@link InferenceMode} (a host passing a stale or mistyped
|
|
127
|
+
* setting) throws instead of being read as `specific`.
|
|
128
|
+
*/
|
|
121
129
|
export function candidateEngines(task: MlTask<any, any>, mode: InferenceMode, specific?: MlEngineRef | string): MlEngineRef[] {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
switch (mode) {
|
|
131
|
+
case "local-only":
|
|
132
|
+
return task.engines.filter((e) => e.tier === "local");
|
|
133
|
+
case "hybrid":
|
|
134
|
+
return [...task.engines.filter((e) => e.tier === "local"), ...task.engines.filter((e) => e.tier === "cloud")];
|
|
135
|
+
case "specific": {
|
|
136
|
+
if (specific == null) throw new Error(`runMlTask(${task.id}): 'specific' mode needs ctx.engine`);
|
|
137
|
+
const key = typeof specific === "string" ? specific : engineKey(specific);
|
|
138
|
+
const match = task.engines.filter((e) => engineKey(e) === key);
|
|
139
|
+
if (match.length === 0) throw new Error(`runMlTask(${task.id}): engine '${key}' is not one of this task's engines`);
|
|
140
|
+
return match;
|
|
141
|
+
}
|
|
142
|
+
default: {
|
|
143
|
+
const unknown: never = mode;
|
|
144
|
+
throw new Error(`runMlTask(${task.id}): unknown inference mode '${String(unknown)}'`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
129
147
|
}
|
|
130
148
|
|
|
131
|
-
|
|
149
|
+
const errorDetail = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Answer task `id` for `input` with the first engine the mode allows that can. An
|
|
153
|
+
* engine that throws (feature extraction or its own predict) is recorded as an
|
|
154
|
+
* `error` skip and the next allowed engine is tried — in Hybrid, the cloud. Throws
|
|
155
|
+
* {@link MlEngineUnavailableError}, carrying every engine's reason, only when none answers.
|
|
156
|
+
*/
|
|
132
157
|
export async function runMlTask<O = unknown, I = unknown>(id: string, input: I, ctx: MlRunContext): Promise<MlResult<O>> {
|
|
133
158
|
const task = ctx.registry.require<I, O>(id);
|
|
134
159
|
const engines = candidateEngines(task, ctx.mode, ctx.engine);
|
|
135
160
|
const skipped: MlDecision["skipped"] = [];
|
|
136
161
|
let features: Float32Array | null = null;
|
|
162
|
+
let featureError: unknown = null;
|
|
137
163
|
let deferred: { ref: MlEngineRef; p: MlPrediction } | null = null;
|
|
138
164
|
|
|
139
165
|
const finish = (ref: MlEngineRef, p: MlPrediction): MlResult<O> => {
|
|
@@ -150,8 +176,26 @@ export async function runMlTask<O = unknown, I = unknown>(id: string, input: I,
|
|
|
150
176
|
skipped.push({ engine: key, reason: "unavailable" });
|
|
151
177
|
continue;
|
|
152
178
|
}
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
// Extract once; a failure (e.g. no embedder) fails every local engine alike, so
|
|
180
|
+
// it is remembered rather than retried per engine.
|
|
181
|
+
if (!features && featureError == null) {
|
|
182
|
+
try {
|
|
183
|
+
features = await extractFeatures(task.features, input, ctx.embedder);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
featureError = error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!features) {
|
|
189
|
+
skipped.push({ engine: key, reason: "error", detail: `features: ${errorDetail(featureError)}` });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let p: MlPrediction;
|
|
193
|
+
try {
|
|
194
|
+
p = predictor.predict(features);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
skipped.push({ engine: key, reason: "error", detail: errorDetail(error) });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
155
199
|
const cloudLater = ctx.mode === "hybrid" && !!ctx.cloud && engines.slice(i + 1).some((e) => e.tier === "cloud");
|
|
156
200
|
if (ctx.minConfidence != null && p.confidence < ctx.minConfidence && cloudLater) {
|
|
157
201
|
skipped.push({ engine: key, reason: "low-confidence", detail: p.confidence.toFixed(3) });
|
|
@@ -167,7 +211,7 @@ export async function runMlTask<O = unknown, I = unknown>(id: string, input: I,
|
|
|
167
211
|
try {
|
|
168
212
|
return finish(ref, await ctx.cloud.run({ taskId: id, engine: ref, input }));
|
|
169
213
|
} catch (error) {
|
|
170
|
-
skipped.push({ engine: key, reason: "error", detail:
|
|
214
|
+
skipped.push({ engine: key, reason: "error", detail: errorDetail(error) });
|
|
171
215
|
}
|
|
172
216
|
}
|
|
173
217
|
// The cloud could not answer after a low-confidence local one: the local answer stands.
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
import { experienceDocuments, type ExperienceDocument } from "../../experience/corpus.js";
|
|
8
8
|
import type { Episode, Run, Skill } from "../../experience/types.js";
|
|
9
9
|
import { KnnModel } from "../classical/knn.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import type
|
|
10
|
+
import { logScale, type TextEmbedder } from "../features.js";
|
|
11
|
+
import { MlTaskRegistry } from "../registry.js";
|
|
12
|
+
import { InMemoryMlModels, MlEngineUnavailableError, runMlTask, type MlRunContext } from "../run.js";
|
|
13
|
+
import { trainMlTask, type MlTrainExample } from "../train.js";
|
|
13
14
|
import type { MlPrediction, MlPredictor, MlTask } from "../types.js";
|
|
14
15
|
|
|
15
16
|
// ── experience.quality ────────────────────────────────────────────────────────
|
|
@@ -37,14 +38,20 @@ export const EXPERIENCE_QUALITY_TASK: MlTask<ExperienceQualityInput, ExperienceQ
|
|
|
37
38
|
description: "Is this procedure worth learning from (does it run, and run well)?",
|
|
38
39
|
features: {
|
|
39
40
|
text: (i) => i.doc.text,
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
// No success/failure rate: the label IS `succeeded / runs >= 0.5`, so a rate
|
|
42
|
+
// feature would leak it — the model would learn the label, not the procedure.
|
|
43
|
+
numeric: (i) => [logScale(i.steps, 50), logScale(i.runs, 20)],
|
|
44
|
+
numericNames: ["steps", "runs"],
|
|
42
45
|
},
|
|
43
46
|
engines: [
|
|
44
47
|
{ tier: "local", engine: "classical", algorithm: "knn" },
|
|
45
48
|
{ tier: "local", engine: "classical", algorithm: "logistic" },
|
|
46
49
|
],
|
|
47
|
-
|
|
50
|
+
// Below 12 labelled procedures the task stays untrained and curation keeps
|
|
51
|
+
// everything. At 12, after the 20% holdout ≈10 remain to fit, both classes can have
|
|
52
|
+
// several members, and kNN votes with k = ⌊√n⌋ = 3 neighbours rather than the whole
|
|
53
|
+
// set — with fewer, every point votes and the answer collapses to the class prior.
|
|
54
|
+
train: { minSamples: 12 },
|
|
48
55
|
output: {
|
|
49
56
|
labels: ["low", "high"],
|
|
50
57
|
decode: (p: MlPrediction) => ({ label: (p.scores[1] ?? 0) >= 0.5 ? "high" : "low", pHigh: p.scores[1] ?? 0 }),
|
|
@@ -141,7 +148,8 @@ export interface CurationOptions {
|
|
|
141
148
|
dedupeThreshold?: number;
|
|
142
149
|
/**
|
|
143
150
|
* Quality filter through the runner. A document whose P(high) is below `minPHigh`
|
|
144
|
-
* (default 0.5) is dropped; one the runner cannot score (no trained engine) is kept
|
|
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).
|
|
145
153
|
*/
|
|
146
154
|
quality?: {
|
|
147
155
|
ctx: MlRunContext;
|
|
@@ -150,8 +158,24 @@ export interface CurationOptions {
|
|
|
150
158
|
};
|
|
151
159
|
/** Document ids to leave out (e.g. those whose runs `run.anomaly` flagged). */
|
|
152
160
|
exclude?: ReadonlySet<string>;
|
|
161
|
+
/**
|
|
162
|
+
* Documents the model has ALREADY learned. They seed the de-duplication index, so a
|
|
163
|
+
* pending document that only repeats one of them is dropped (reported `of` that
|
|
164
|
+
* one); they are never in `kept`. Each is embedded once per call, so this is
|
|
165
|
+
* bounded — {@link curatePendingExperience} keeps the {@link CURATION_KNOWN_LIMIT}
|
|
166
|
+
* most recently learned.
|
|
167
|
+
*/
|
|
168
|
+
known?: readonly ExperienceDocument[];
|
|
153
169
|
}
|
|
154
170
|
|
|
171
|
+
/**
|
|
172
|
+
* How many already-learned documents curation de-duplicates against: the most
|
|
173
|
+
* recently learned ones. Each costs one CPU embed per curation call, and a
|
|
174
|
+
* repeat of something learned long ago is cheap to relearn, so a recent window is the
|
|
175
|
+
* trade — 200 embeds of a small Evermind take seconds on a laptop CPU.
|
|
176
|
+
*/
|
|
177
|
+
export const CURATION_KNOWN_LIMIT = 200;
|
|
178
|
+
|
|
155
179
|
export interface CurationResult {
|
|
156
180
|
kept: ExperienceDocument[];
|
|
157
181
|
dropped: { id: string; reason: "duplicate" | "low-quality" | "excluded"; of?: string; score?: number }[];
|
|
@@ -174,16 +198,24 @@ function memoEmbedder(e: TextEmbedder): TextEmbedder {
|
|
|
174
198
|
/**
|
|
175
199
|
* Curate documents before they are packed into adaptation passes: drop excluded
|
|
176
200
|
* ones, low-quality ones (when a quality filter is given), then near-duplicates of
|
|
177
|
-
* an earlier kept one. Order is preserved, and
|
|
178
|
-
* skills come before raw demonstrations in
|
|
179
|
-
* version is the one kept.
|
|
201
|
+
* an already-learned one (`known`) or an earlier kept one. Order is preserved, and
|
|
202
|
+
* the FIRST copy wins — compiled skills come before raw demonstrations in
|
|
203
|
+
* `experienceDocuments`, so the reviewed version is the one kept.
|
|
180
204
|
*/
|
|
181
205
|
export async function curateExperience(docs: readonly ExperienceDocument[], opts: CurationOptions): Promise<CurationResult> {
|
|
182
206
|
const embedder = memoEmbedder(opts.embedder);
|
|
183
207
|
const index = new DedupeIndex(opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD);
|
|
208
|
+
/** Index position → document id: the known documents first, then each kept one. */
|
|
209
|
+
const indexed: string[] = [];
|
|
184
210
|
const kept: ExperienceDocument[] = [];
|
|
185
211
|
const dropped: CurationResult["dropped"] = [];
|
|
186
212
|
const qctx = opts.quality ? { ...opts.quality.ctx, embedder } : null;
|
|
213
|
+
const embedDoc = (doc: ExperienceDocument) => embedder.embed(EXPERIENCE_DEDUPE_TASK.features.text!(doc));
|
|
214
|
+
|
|
215
|
+
for (const doc of opts.known ?? []) {
|
|
216
|
+
index.add(await embedDoc(doc));
|
|
217
|
+
indexed.push(doc.id);
|
|
218
|
+
}
|
|
187
219
|
|
|
188
220
|
for (const doc of docs) {
|
|
189
221
|
if (opts.exclude?.has(doc.id)) {
|
|
@@ -191,24 +223,71 @@ export async function curateExperience(docs: readonly ExperienceDocument[], opts
|
|
|
191
223
|
continue;
|
|
192
224
|
}
|
|
193
225
|
if (opts.quality && qctx) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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;
|
|
199
237
|
}
|
|
200
|
-
} catch (error) {
|
|
201
|
-
if (!(error instanceof MlEngineUnavailableError)) throw error;
|
|
202
238
|
}
|
|
203
239
|
}
|
|
204
|
-
const x = await
|
|
240
|
+
const x = await embedDoc(doc);
|
|
205
241
|
const d = EXPERIENCE_DEDUPE_TASK.output.decode(index.predict(x));
|
|
206
242
|
if (d.duplicate) {
|
|
207
|
-
dropped.push({ id: doc.id, reason: "duplicate", of:
|
|
243
|
+
dropped.push({ id: doc.id, reason: "duplicate", of: indexed[d.nearest]!, score: d.similarity });
|
|
208
244
|
continue;
|
|
209
245
|
}
|
|
210
246
|
index.add(x);
|
|
247
|
+
indexed.push(doc.id);
|
|
211
248
|
kept.push(doc);
|
|
212
249
|
}
|
|
213
250
|
return { kept, dropped };
|
|
214
251
|
}
|
|
252
|
+
|
|
253
|
+
/** What {@link curatePendingExperience} reads: the experience snapshot's raw records. */
|
|
254
|
+
export interface ExperienceCurationSource {
|
|
255
|
+
episodes: Episode[];
|
|
256
|
+
skills: Skill[];
|
|
257
|
+
runs: readonly Run[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 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.
|
|
268
|
+
*/
|
|
269
|
+
export async function curatePendingExperience(
|
|
270
|
+
pending: readonly ExperienceDocument[],
|
|
271
|
+
source: ExperienceCurationSource,
|
|
272
|
+
opts: { embedder: TextEmbedder; known?: readonly ExperienceDocument[]; dedupeThreshold?: number; minPHigh?: number },
|
|
273
|
+
): Promise<CurationResult> {
|
|
274
|
+
const known = opts.known?.slice(0, CURATION_KNOWN_LIMIT);
|
|
275
|
+
const embedder = memoEmbedder(opts.embedder);
|
|
276
|
+
const inputs = experienceQualityInputs(source.episodes, source.skills, source.runs);
|
|
277
|
+
const byId = new Map(inputs.map((i) => [i.doc.id, i] as const));
|
|
278
|
+
const registry = new MlTaskRegistry();
|
|
279
|
+
registry.register(EXPERIENCE_QUALITY_TASK);
|
|
280
|
+
const models = new InMemoryMlModels();
|
|
281
|
+
await trainMlTask(EXPERIENCE_QUALITY_TASK, experienceQualityExamples(inputs), { embedder, models });
|
|
282
|
+
return curateExperience(pending, {
|
|
283
|
+
embedder,
|
|
284
|
+
...(known ? { known } : {}),
|
|
285
|
+
...(opts.dedupeThreshold != null ? { dedupeThreshold: opts.dedupeThreshold } : {}),
|
|
286
|
+
quality: {
|
|
287
|
+
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 },
|
|
290
|
+
...(opts.minPHigh != null ? { minPHigh: opts.minPHigh } : {}),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
* outside its normal range. Flags runs to leave out of training and to surface as
|
|
4
4
|
* Insights.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* from normal") and EWMA (the recent trend — "drifting away from lately")
|
|
8
|
-
*
|
|
6
|
+
* ONE engine, `anomaly-composite`: robust z (median/MAD over the whole history —
|
|
7
|
+
* "far from normal") and EWMA (the recent trend — "drifting away from lately"),
|
|
8
|
+
* scored as the larger of the two so either one can flag. The row lists exactly
|
|
9
|
+
* that engine, so the key it is stored under is the key the runner asks for.
|
|
9
10
|
*/
|
|
10
11
|
import type { Run } from "../../experience/types.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
12
|
+
import { AnomalyCompositeModel, type AnomalyCompositeOptions } from "../classical/anomaly_composite.js";
|
|
13
|
+
import { fitEwma } from "../classical/ewma.js";
|
|
14
|
+
import { fitRobustZ } from "../classical/robust_z.js";
|
|
13
15
|
import { logScale } from "../features.js";
|
|
14
|
-
import type { MlPrediction,
|
|
16
|
+
import type { MlPrediction, MlSample, MlTask } from "../types.js";
|
|
15
17
|
|
|
16
18
|
/** What a run cost and how it went — the host supplies cost; the rest is in the run log. */
|
|
17
19
|
export interface RunMetrics {
|
|
@@ -33,38 +35,32 @@ export function runAnomalyFeatures(m: RunMetrics): number[] {
|
|
|
33
35
|
return [logScale(m.costUsd * 100, 1000), logScale(m.durationMs, 3_600_000), logScale(m.toolFailures, 50)];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
/**
|
|
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.)
|
|
46
|
+
*/
|
|
47
|
+
export const RUN_ANOMALY_MIN_SCALE = 0.1;
|
|
42
48
|
|
|
49
|
+
/** The composite detector with `run.anomaly`'s metric names on its explanation. */
|
|
50
|
+
export class RunAnomalyDetector extends AnomalyCompositeModel {
|
|
43
51
|
/** Which metrics are out of range, per detector (z / threshold, ≥ 1 = out of range). */
|
|
44
52
|
explain(x: ArrayLike<number>): { metric: (typeof RUN_ANOMALY_METRICS)[number]; population: number; trend: number }[] {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
return RUN_ANOMALY_METRICS.map((metric, i) => ({
|
|
48
|
-
metric,
|
|
49
|
-
population: pz[i]! / this.population.threshold,
|
|
50
|
-
trend: tz[i]! / this.trend.threshold,
|
|
51
|
-
}));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Fold a finished, non-anomalous run into the trend. */
|
|
55
|
-
observe(x: ArrayLike<number>): void {
|
|
56
|
-
this.trend.update(x);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
predict(x: Float32Array): MlPrediction {
|
|
60
|
-
const score = Math.max(this.population.predict(x).scores[0]!, this.trend.predict(x).scores[0]!);
|
|
61
|
-
return { scores: [score], confidence: anomalyConfidence(score) };
|
|
53
|
+
const per = this.featureScores(x);
|
|
54
|
+
return RUN_ANOMALY_METRICS.map((metric, i) => ({ metric, ...per[i]! }));
|
|
62
55
|
}
|
|
63
56
|
}
|
|
64
57
|
|
|
65
|
-
/** Fit both detectors on a chronological history of runs (as feature vectors). */
|
|
66
|
-
export function fitRunAnomaly(samples: readonly MlSample[], opts:
|
|
67
|
-
return new RunAnomalyDetector(
|
|
58
|
+
/** Fit both detectors on a chronological history of runs (as feature vectors), floored at {@link RUN_ANOMALY_MIN_SCALE}. */
|
|
59
|
+
export function fitRunAnomaly(samples: readonly MlSample[], opts: AnomalyCompositeOptions = {}): RunAnomalyDetector {
|
|
60
|
+
return new RunAnomalyDetector(
|
|
61
|
+
fitRobustZ(samples, { ...opts.robustZ, minScale: opts.robustZ?.minScale ?? RUN_ANOMALY_MIN_SCALE }),
|
|
62
|
+
fitEwma(samples, { ...opts.ewma, minScale: opts.ewma?.minScale ?? RUN_ANOMALY_MIN_SCALE }),
|
|
63
|
+
);
|
|
68
64
|
}
|
|
69
65
|
|
|
70
66
|
export const RUN_ANOMALY_TASK: MlTask<RunMetrics, RunAnomaly> = {
|
|
@@ -72,10 +68,8 @@ export const RUN_ANOMALY_TASK: MlTask<RunMetrics, RunAnomaly> = {
|
|
|
72
68
|
id: "run.anomaly",
|
|
73
69
|
description: "Did this run cost, take or fail far outside its normal range?",
|
|
74
70
|
features: { numeric: runAnomalyFeatures, numericNames: RUN_ANOMALY_METRICS },
|
|
75
|
-
engines: [
|
|
76
|
-
|
|
77
|
-
{ tier: "local", engine: "classical", algorithm: "ewma" },
|
|
78
|
-
],
|
|
71
|
+
engines: [{ tier: "local", engine: "classical", algorithm: "anomaly-composite" }],
|
|
72
|
+
// The task's own fit: the same composite, with the metric floor and named explanation.
|
|
79
73
|
train: { minSamples: 5, fit: (samples) => fitRunAnomaly(samples) },
|
|
80
74
|
output: { decode: (p: MlPrediction) => ({ anomalous: (p.scores[0] ?? 0) >= 1, score: p.scores[0] ?? 0 }) },
|
|
81
75
|
};
|
package/src/ml/train.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { fitClassical, type ClassicalOptions } from "./classical/index.js";
|
|
14
14
|
import { DenseHead } from "./dense_head.js";
|
|
15
|
+
import { mlEvalLoss } from "./eval_loss.js";
|
|
15
16
|
import { extractFeatureMatrix, type TextEmbedder } from "./features.js";
|
|
16
17
|
import { HeadTrainer, type HeadTrainOptions } from "./head_trainer.js";
|
|
17
18
|
import type { MlModelStore } from "./run.js";
|
|
18
|
-
import { engineKey, type MlEngineRef, type MlPredictor, type MlSample, type MlTask
|
|
19
|
+
import { engineKey, type MlEngineRef, type MlPredictor, type MlSample, type MlTask } from "./types.js";
|
|
19
20
|
|
|
20
21
|
/** One labelled example in input space. */
|
|
21
22
|
export interface MlTrainExample<I> {
|
|
@@ -50,24 +51,7 @@ export interface MlTrainResult {
|
|
|
50
51
|
holdoutLoss: number | null;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
* Mean loss of `predictor` on `samples` for a task of `kind` — classify: weighted
|
|
55
|
-
* cross-entropy of the true class; regress: weighted ½·squared error. Null for
|
|
56
|
-
* kinds without a supervised loss, or an empty set.
|
|
57
|
-
*/
|
|
58
|
-
export function mlEvalLoss(kind: MlTaskKind, predictor: MlPredictor, samples: readonly MlSample[]): number | null {
|
|
59
|
-
if (samples.length === 0 || (kind !== "classify" && kind !== "regress")) return null;
|
|
60
|
-
let sum = 0;
|
|
61
|
-
let wSum = 0;
|
|
62
|
-
for (const s of samples) {
|
|
63
|
-
const w = s.weight ?? 1;
|
|
64
|
-
const p = predictor.predict(s.x);
|
|
65
|
-
const loss = kind === "classify" ? -Math.log(Math.max(p.scores[s.y] ?? 0, 1e-12)) : 0.5 * (p.scores[0]! - s.y) ** 2;
|
|
66
|
-
sum += w * loss;
|
|
67
|
-
wSum += w;
|
|
68
|
-
}
|
|
69
|
-
return wSum > 0 ? sum / wSum : null;
|
|
70
|
-
}
|
|
54
|
+
export { mlEvalLoss } from "./eval_loss.js";
|
|
71
55
|
|
|
72
56
|
/** The task's first local engine the seam can train. */
|
|
73
57
|
function defaultEngine(task: MlTask<any, any>): MlEngineRef {
|