@seanhogg/builderforce-memory-engine 2026.9.34 → 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 +26 -2
- package/dist/evermind/delta_wire.d.ts.map +1 -1
- package/dist/evermind/delta_wire.js +46 -4
- package/dist/evermind/delta_wire.js.map +1 -1
- package/dist/evermind/index.d.ts +5 -5
- package/dist/evermind/index.d.ts.map +1 -1
- package/dist/evermind/index.js +2 -2
- package/dist/evermind/index.js.map +1 -1
- package/dist/evermind/merge.d.ts +28 -0
- package/dist/evermind/merge.d.ts.map +1 -1
- package/dist/evermind/merge.js +33 -0
- 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 +9 -0
- package/dist/evermind/package.d.ts.map +1 -1
- package/dist/evermind/package.js +20 -0
- package/dist/evermind/package.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/limbic/index.d.ts +3 -0
- package/dist/limbic/index.d.ts.map +1 -1
- package/dist/limbic/index.js +3 -0
- package/dist/limbic/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/limbic/limbic_task.d.ts +22 -0
- package/dist/limbic/limbic_task.d.ts.map +1 -0
- package/dist/limbic/limbic_task.js +45 -0
- package/dist/limbic/limbic_task.js.map +1 -0
- package/dist/limbic/limbic_trainer.d.ts +17 -30
- package/dist/limbic/limbic_trainer.d.ts.map +1 -1
- package/dist/limbic/limbic_trainer.js +38 -144
- package/dist/limbic/limbic_trainer.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 +37 -0
- package/dist/ml/classical/ewma.d.ts.map +1 -0
- package/dist/ml/classical/ewma.js +48 -0
- package/dist/ml/classical/ewma.js.map +1 -0
- package/dist/ml/classical/gbm.d.ts +42 -0
- package/dist/ml/classical/gbm.d.ts.map +1 -0
- package/dist/ml/classical/gbm.js +117 -0
- package/dist/ml/classical/gbm.js.map +1 -0
- package/dist/ml/classical/index.d.ts +52 -0
- package/dist/ml/classical/index.d.ts.map +1 -0
- package/dist/ml/classical/index.js +49 -0
- package/dist/ml/classical/index.js.map +1 -0
- package/dist/ml/classical/isolation_forest.d.ts +37 -0
- package/dist/ml/classical/isolation_forest.d.ts.map +1 -0
- package/dist/ml/classical/isolation_forest.js +72 -0
- package/dist/ml/classical/isolation_forest.js.map +1 -0
- package/dist/ml/classical/kmeans.d.ts +31 -0
- package/dist/ml/classical/kmeans.d.ts.map +1 -0
- package/dist/ml/classical/kmeans.js +112 -0
- package/dist/ml/classical/kmeans.js.map +1 -0
- package/dist/ml/classical/knn.d.ts +51 -0
- package/dist/ml/classical/knn.d.ts.map +1 -0
- package/dist/ml/classical/knn.js +69 -0
- package/dist/ml/classical/knn.js.map +1 -0
- package/dist/ml/classical/linear.d.ts +29 -0
- package/dist/ml/classical/linear.d.ts.map +1 -0
- package/dist/ml/classical/linear.js +114 -0
- package/dist/ml/classical/linear.js.map +1 -0
- package/dist/ml/classical/logistic.d.ts +31 -0
- package/dist/ml/classical/logistic.d.ts.map +1 -0
- package/dist/ml/classical/logistic.js +111 -0
- package/dist/ml/classical/logistic.js.map +1 -0
- package/dist/ml/classical/math.d.ts +26 -0
- package/dist/ml/classical/math.d.ts.map +1 -0
- package/dist/ml/classical/math.js +90 -0
- package/dist/ml/classical/math.js.map +1 -0
- package/dist/ml/classical/naive_bayes.d.ts +28 -0
- package/dist/ml/classical/naive_bayes.d.ts.map +1 -0
- package/dist/ml/classical/naive_bayes.js +72 -0
- package/dist/ml/classical/naive_bayes.js.map +1 -0
- package/dist/ml/classical/robust_z.d.ts +38 -0
- package/dist/ml/classical/robust_z.d.ts.map +1 -0
- package/dist/ml/classical/robust_z.js +47 -0
- package/dist/ml/classical/robust_z.js.map +1 -0
- package/dist/ml/dense_head.d.ts +33 -0
- package/dist/ml/dense_head.d.ts.map +1 -0
- package/dist/ml/dense_head.js +140 -0
- package/dist/ml/dense_head.js.map +1 -0
- 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/features.d.ts +25 -0
- package/dist/ml/features.d.ts.map +1 -0
- package/dist/ml/features.js +46 -0
- package/dist/ml/features.js.map +1 -0
- package/dist/ml/federate.d.ts +56 -0
- package/dist/ml/federate.d.ts.map +1 -0
- package/dist/ml/federate.js +99 -0
- package/dist/ml/federate.js.map +1 -0
- package/dist/ml/head_trainer.d.ts +78 -0
- package/dist/ml/head_trainer.d.ts.map +1 -0
- package/dist/ml/head_trainer.js +182 -0
- package/dist/ml/head_trainer.js.map +1 -0
- package/dist/ml/index.d.ts +23 -0
- package/dist/ml/index.d.ts.map +1 -0
- package/dist/ml/index.js +11 -0
- package/dist/ml/index.js.map +1 -0
- package/dist/ml/registry.d.ts +20 -0
- package/dist/ml/registry.d.ts.map +1 -0
- package/dist/ml/registry.js +43 -0
- package/dist/ml/registry.js.map +1 -0
- package/dist/ml/run.d.ts +103 -0
- package/dist/ml/run.d.ts.map +1 -0
- package/dist/ml/run.js +169 -0
- package/dist/ml/run.js.map +1 -0
- package/dist/ml/tasks/experience.d.ts +127 -0
- package/dist/ml/tasks/experience.d.ts.map +1 -0
- package/dist/ml/tasks/experience.js +213 -0
- package/dist/ml/tasks/experience.js.map +1 -0
- package/dist/ml/tasks/index.d.ts +10 -0
- package/dist/ml/tasks/index.d.ts.map +1 -0
- package/dist/ml/tasks/index.js +28 -0
- package/dist/ml/tasks/index.js.map +1 -0
- package/dist/ml/tasks/local_success.d.ts +55 -0
- package/dist/ml/tasks/local_success.d.ts.map +1 -0
- package/dist/ml/tasks/local_success.js +77 -0
- package/dist/ml/tasks/local_success.js.map +1 -0
- package/dist/ml/tasks/run_anomaly.d.ts +52 -0
- package/dist/ml/tasks/run_anomaly.d.ts.map +1 -0
- package/dist/ml/tasks/run_anomaly.js +50 -0
- package/dist/ml/tasks/run_anomaly.js.map +1 -0
- package/dist/ml/train.d.ts +59 -0
- package/dist/ml/train.d.ts.map +1 -0
- package/dist/ml/train.js +84 -0
- package/dist/ml/train.js.map +1 -0
- package/dist/ml/types.d.ts +115 -0
- package/dist/ml/types.d.ts.map +1 -0
- package/dist/ml/types.js +24 -0
- package/dist/ml/types.js.map +1 -0
- package/dist/moe/moe_model.d.ts.map +1 -1
- package/dist/moe/moe_model.js +1 -16
- package/dist/moe/moe_model.js.map +1 -1
- package/dist/training/cpu_ops.d.ts +2 -0
- package/dist/training/cpu_ops.d.ts.map +1 -1
- package/dist/training/cpu_ops.js +16 -0
- package/dist/training/cpu_ops.js.map +1 -1
- package/package.json +1 -1
- package/src/evermind/delta_wire.ts +63 -4
- package/src/evermind/index.ts +7 -3
- package/src/evermind/merge.ts +59 -0
- package/src/evermind/ml_head.ts +63 -0
- package/src/evermind/package.ts +24 -0
- package/src/index.ts +17 -1
- package/src/limbic/index.ts +5 -0
- package/src/limbic/limbic_model.ts +1 -4
- package/src/limbic/limbic_task.ts +62 -0
- package/src/limbic/limbic_trainer.ts +41 -208
- package/src/ml/classical/anomaly_composite.ts +46 -0
- package/src/ml/classical/ewma.ts +74 -0
- package/src/ml/classical/gbm.ts +144 -0
- package/src/ml/classical/index.ts +82 -0
- package/src/ml/classical/isolation_forest.ts +88 -0
- package/src/ml/classical/kmeans.ts +120 -0
- package/src/ml/classical/knn.ts +102 -0
- package/src/ml/classical/linear.ts +119 -0
- package/src/ml/classical/logistic.ts +125 -0
- package/src/ml/classical/math.ts +93 -0
- package/src/ml/classical/naive_bayes.ts +84 -0
- package/src/ml/classical/robust_z.ts +74 -0
- package/src/ml/dense_head.ts +157 -0
- package/src/ml/eval_loss.ts +27 -0
- package/src/ml/features.ts +61 -0
- package/src/ml/federate.ts +130 -0
- package/src/ml/head_trainer.ts +247 -0
- package/src/ml/index.ts +57 -0
- package/src/ml/registry.ts +44 -0
- package/src/ml/run.ts +222 -0
- package/src/ml/tasks/experience.ts +293 -0
- package/src/ml/tasks/index.ts +30 -0
- package/src/ml/tasks/local_success.ts +118 -0
- package/src/ml/tasks/run_anomaly.ts +84 -0
- package/src/ml/train.ts +120 -0
- package/src/ml/types.ts +135 -0
- package/src/moe/moe_model.ts +1 -13
- package/src/training/cpu_ops.ts +14 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ml/tasks/experience.ts — `experience.quality` and `experience.dedupe`, and the
|
|
3
|
+
* opt-in curation step that applies them in front of the ONE adaptation recipe
|
|
4
|
+
* (`evermind/adapt.ts`), so the private model learns from good, varied examples
|
|
5
|
+
* instead of every transcript. Curation only drops documents; it never rewrites one.
|
|
6
|
+
*/
|
|
7
|
+
import { experienceDocuments, type ExperienceDocument } from "../../experience/corpus.js";
|
|
8
|
+
import type { Episode, Run, Skill } from "../../experience/types.js";
|
|
9
|
+
import { KnnModel } from "../classical/knn.js";
|
|
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";
|
|
14
|
+
import type { MlPrediction, MlPredictor, MlTask } from "../types.js";
|
|
15
|
+
|
|
16
|
+
// ── experience.quality ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** One learnable procedure plus the outcome fields that say how well it went. */
|
|
19
|
+
export interface ExperienceQualityInput {
|
|
20
|
+
doc: ExperienceDocument;
|
|
21
|
+
/** Steps in the procedure. */
|
|
22
|
+
steps: number;
|
|
23
|
+
/** Runs of the skill (0 for an uncompiled demonstration). */
|
|
24
|
+
runs: number;
|
|
25
|
+
succeeded: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExperienceQuality {
|
|
30
|
+
label: "low" | "high";
|
|
31
|
+
/** P(high). */
|
|
32
|
+
pHigh: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const EXPERIENCE_QUALITY_TASK: MlTask<ExperienceQualityInput, ExperienceQuality> = {
|
|
36
|
+
kind: "classify",
|
|
37
|
+
id: "experience.quality",
|
|
38
|
+
description: "Is this procedure worth learning from (does it run, and run well)?",
|
|
39
|
+
features: {
|
|
40
|
+
text: (i) => i.doc.text,
|
|
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"],
|
|
45
|
+
},
|
|
46
|
+
engines: [
|
|
47
|
+
{ tier: "local", engine: "classical", algorithm: "knn" },
|
|
48
|
+
{ tier: "local", engine: "classical", algorithm: "logistic" },
|
|
49
|
+
],
|
|
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 },
|
|
55
|
+
output: {
|
|
56
|
+
labels: ["low", "high"],
|
|
57
|
+
decode: (p: MlPrediction) => ({ label: (p.scores[1] ?? 0) >= 0.5 ? "high" : "low", pHigh: p.scores[1] ?? 0 }),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Quality inputs for every learnable document, with run outcomes joined by skill id. */
|
|
62
|
+
export function experienceQualityInputs(episodes: Episode[], skills: Skill[], runs: readonly Run[] = []): ExperienceQualityInput[] {
|
|
63
|
+
const steps = new Map<string, number>([...skills.map((s) => [s.id, s.steps.length] as const), ...episodes.map((e) => [e.id, e.steps.length] as const)]);
|
|
64
|
+
const byItem = new Map<string, { runs: number; succeeded: number; failed: number }>();
|
|
65
|
+
for (const r of runs) {
|
|
66
|
+
if (r.status === "running") continue;
|
|
67
|
+
const t = byItem.get(r.skillId) ?? { runs: 0, succeeded: 0, failed: 0 };
|
|
68
|
+
t.runs++;
|
|
69
|
+
if (r.status === "succeeded") t.succeeded++;
|
|
70
|
+
if (r.status === "failed") t.failed++;
|
|
71
|
+
byItem.set(r.skillId, t);
|
|
72
|
+
}
|
|
73
|
+
return experienceDocuments(episodes, skills).map((doc) => ({
|
|
74
|
+
doc,
|
|
75
|
+
steps: steps.get(doc.id) ?? 0,
|
|
76
|
+
...(byItem.get(doc.id) ?? { runs: 0, succeeded: 0, failed: 0 }),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The training label of a quality input: 1 (high) when most finished runs succeeded, 0 when not, null with no runs. */
|
|
81
|
+
export function experienceQualityLabel(i: ExperienceQualityInput): number | null {
|
|
82
|
+
if (i.runs === 0) return null;
|
|
83
|
+
return i.succeeded / i.runs >= 0.5 ? 1 : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Labelled examples for `experience.quality` — only the inputs that have run outcomes. */
|
|
87
|
+
export function experienceQualityExamples(inputs: readonly ExperienceQualityInput[]): MlTrainExample<ExperienceQualityInput>[] {
|
|
88
|
+
return inputs.flatMap((input) => {
|
|
89
|
+
const label = experienceQualityLabel(input);
|
|
90
|
+
return label == null ? [] : [{ input, label }];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── experience.dedupe ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export interface ExperienceDuplicate {
|
|
97
|
+
/** Index (in keep order) of the nearest already-kept document, or -1 when none. */
|
|
98
|
+
nearest: number;
|
|
99
|
+
similarity: number;
|
|
100
|
+
duplicate: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Cosine similarity at or above which two procedures count as one. */
|
|
104
|
+
export const DEFAULT_DEDUPE_THRESHOLD = 0.97;
|
|
105
|
+
|
|
106
|
+
export const EXPERIENCE_DEDUPE_TASK: MlTask<ExperienceDocument, ExperienceDuplicate> = {
|
|
107
|
+
kind: "cluster",
|
|
108
|
+
id: "experience.dedupe",
|
|
109
|
+
description: "Is this procedure a near-copy of one already kept?",
|
|
110
|
+
features: { text: (d) => d.text },
|
|
111
|
+
engines: [{ tier: "local", engine: "classical", algorithm: "knn" }],
|
|
112
|
+
output: {
|
|
113
|
+
decode: (p: MlPrediction) => ({ nearest: p.scores[0] ?? -1, similarity: p.scores[1] ?? 0, duplicate: (p.scores[2] ?? 0) === 1 }),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `experience.dedupe` predictor: a cosine nearest-neighbour index of the
|
|
119
|
+
* documents kept so far. `scores = [nearestIndex, similarity, duplicate ? 1 : 0]`.
|
|
120
|
+
*/
|
|
121
|
+
export class DedupeIndex implements MlPredictor {
|
|
122
|
+
private readonly _knn = new KnnModel(1, "cosine", 2);
|
|
123
|
+
|
|
124
|
+
constructor(readonly threshold: number = DEFAULT_DEDUPE_THRESHOLD) {}
|
|
125
|
+
|
|
126
|
+
get size(): number {
|
|
127
|
+
return this._knn.size;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
add(x: Float32Array): void {
|
|
131
|
+
this._knn.add(x);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
predict(x: Float32Array): MlPrediction {
|
|
135
|
+
const n = this._knn.nearest(x);
|
|
136
|
+
if (!n) return { scores: [-1, 0, 0], confidence: 1 };
|
|
137
|
+
const dup = n.similarity >= this.threshold;
|
|
138
|
+
return { scores: [n.index, n.similarity, dup ? 1 : 0], confidence: Math.abs(n.similarity) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── curation (opt-in, in front of adaptAndDiff) ─────────────────────────────
|
|
143
|
+
|
|
144
|
+
export interface CurationOptions {
|
|
145
|
+
/** Embeds each document once (the person's CPU Evermind: `EvermindTextEmbedder`). */
|
|
146
|
+
embedder: TextEmbedder;
|
|
147
|
+
/** Cosine similarity at or above which a document duplicates a kept one. Default {@link DEFAULT_DEDUPE_THRESHOLD}. */
|
|
148
|
+
dedupeThreshold?: number;
|
|
149
|
+
/**
|
|
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).
|
|
153
|
+
*/
|
|
154
|
+
quality?: {
|
|
155
|
+
ctx: MlRunContext;
|
|
156
|
+
input: (doc: ExperienceDocument) => ExperienceQualityInput;
|
|
157
|
+
minPHigh?: number;
|
|
158
|
+
};
|
|
159
|
+
/** Document ids to leave out (e.g. those whose runs `run.anomaly` flagged). */
|
|
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[];
|
|
169
|
+
}
|
|
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
|
+
|
|
179
|
+
export interface CurationResult {
|
|
180
|
+
kept: ExperienceDocument[];
|
|
181
|
+
dropped: { id: string; reason: "duplicate" | "low-quality" | "excluded"; of?: string; score?: number }[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Memoise an embedder for one call, so curation embeds each text once. */
|
|
185
|
+
function memoEmbedder(e: TextEmbedder): TextEmbedder {
|
|
186
|
+
const cache = new Map<string, Float32Array>();
|
|
187
|
+
return {
|
|
188
|
+
dimensions: e.dimensions,
|
|
189
|
+
fingerprint: e.fingerprint,
|
|
190
|
+
async embed(text: string) {
|
|
191
|
+
let v = cache.get(text);
|
|
192
|
+
if (!v) cache.set(text, (v = await e.embed(text)));
|
|
193
|
+
return v;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Curate documents before they are packed into adaptation passes: drop excluded
|
|
200
|
+
* ones, low-quality ones (when a quality filter is given), then near-duplicates of
|
|
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.
|
|
204
|
+
*/
|
|
205
|
+
export async function curateExperience(docs: readonly ExperienceDocument[], opts: CurationOptions): Promise<CurationResult> {
|
|
206
|
+
const embedder = memoEmbedder(opts.embedder);
|
|
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[] = [];
|
|
210
|
+
const kept: ExperienceDocument[] = [];
|
|
211
|
+
const dropped: CurationResult["dropped"] = [];
|
|
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
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const doc of docs) {
|
|
221
|
+
if (opts.exclude?.has(doc.id)) {
|
|
222
|
+
dropped.push({ id: doc.id, reason: "excluded" });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
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
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const x = await embedDoc(doc);
|
|
241
|
+
const d = EXPERIENCE_DEDUPE_TASK.output.decode(index.predict(x));
|
|
242
|
+
if (d.duplicate) {
|
|
243
|
+
dropped.push({ id: doc.id, reason: "duplicate", of: indexed[d.nearest]!, score: d.similarity });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
index.add(x);
|
|
247
|
+
indexed.push(doc.id);
|
|
248
|
+
kept.push(doc);
|
|
249
|
+
}
|
|
250
|
+
return { kept, dropped };
|
|
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ml/tasks — the built-in catalog rows (docs/ml-task-catalog.md §4), in order.
|
|
3
|
+
* `limbic.affect` is defined by limbic/ itself; this only lists it.
|
|
4
|
+
*/
|
|
5
|
+
import { LIMBIC_AFFECT_TASK } from "../../limbic/limbic_task.js";
|
|
6
|
+
import { MlTaskRegistry } from "../registry.js";
|
|
7
|
+
import type { MlTask } from "../types.js";
|
|
8
|
+
import { EXPERIENCE_DEDUPE_TASK, EXPERIENCE_QUALITY_TASK } from "./experience.js";
|
|
9
|
+
import { LOCAL_SUCCESS_TASK } from "./local_success.js";
|
|
10
|
+
import { RUN_ANOMALY_TASK } from "./run_anomaly.js";
|
|
11
|
+
|
|
12
|
+
/** The built-in rows, in catalog order. */
|
|
13
|
+
export const BUILTIN_ML_TASKS: readonly MlTask<any, any>[] = [
|
|
14
|
+
LIMBIC_AFFECT_TASK,
|
|
15
|
+
EXPERIENCE_QUALITY_TASK,
|
|
16
|
+
EXPERIENCE_DEDUPE_TASK,
|
|
17
|
+
RUN_ANOMALY_TASK,
|
|
18
|
+
LOCAL_SUCCESS_TASK,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** A registry holding every built-in row (add host rows with `register`). */
|
|
22
|
+
export function createMlRegistry(): MlTaskRegistry {
|
|
23
|
+
const r = new MlTaskRegistry();
|
|
24
|
+
for (const t of BUILTIN_ML_TASKS) r.register(t);
|
|
25
|
+
return r;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export * from "./experience.js";
|
|
29
|
+
export * from "./run_anomaly.js";
|
|
30
|
+
export * from "./local_success.js";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ml/tasks/local_success.ts — `run.local-success`: will this turn succeed on the
|
|
3
|
+
* local model? Exported for the Hybrid router (Builderforce.ai), which keeps a turn
|
|
4
|
+
* local when it can and sends it to the cloud when it can't.
|
|
5
|
+
*
|
|
6
|
+
* It LEARNS FROM THE CLOUD (docs/ml-task-catalog.md §6): in Hybrid, a turn that
|
|
7
|
+
* fell back to — or was also served by — the cloud is a training label for the
|
|
8
|
+
* local predictor, alongside local outcomes. See {@link localSuccessLabel}.
|
|
9
|
+
*/
|
|
10
|
+
import { clampRatio, logScale } from "../features.js";
|
|
11
|
+
import type { MlTrainExample } from "../train.js";
|
|
12
|
+
import type { MlPrediction, MlTask } from "../types.js";
|
|
13
|
+
|
|
14
|
+
/** What the router knows about a turn before it runs, plus the local model's served context. */
|
|
15
|
+
export interface LocalTurnFeatures {
|
|
16
|
+
promptTokens: number;
|
|
17
|
+
/** Tokens of context (history, files, memory) the turn carries. */
|
|
18
|
+
contextTokens: number;
|
|
19
|
+
/** The local model's served context window, in tokens. */
|
|
20
|
+
servedContextTokens: number;
|
|
21
|
+
/** Tools advertised to the turn. */
|
|
22
|
+
tools: number;
|
|
23
|
+
/** Position of the turn in its conversation (0 = first). */
|
|
24
|
+
turnIndex: number;
|
|
25
|
+
attachments?: number;
|
|
26
|
+
/** Recent local success rate for this person/workload in [0, 1], when known. */
|
|
27
|
+
priorLocalSuccessRate?: number;
|
|
28
|
+
/** The turn is mostly code (edits, diffs, stack traces). */
|
|
29
|
+
codeHeavy?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const LOCAL_SUCCESS_FEATURES = [
|
|
33
|
+
"promptTokens",
|
|
34
|
+
"contextTokens",
|
|
35
|
+
"contextFill",
|
|
36
|
+
"overflows",
|
|
37
|
+
"tools",
|
|
38
|
+
"turnIndex",
|
|
39
|
+
"attachments",
|
|
40
|
+
"priorLocalSuccessRate",
|
|
41
|
+
"codeHeavy",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
/** Fixed scales — the same on every machine, so the weights federate. */
|
|
45
|
+
export function localSuccessFeatures(t: LocalTurnFeatures): number[] {
|
|
46
|
+
const fill = clampRatio(t.promptTokens + t.contextTokens, t.servedContextTokens, 4);
|
|
47
|
+
return [
|
|
48
|
+
logScale(t.promptTokens, 100_000),
|
|
49
|
+
logScale(t.contextTokens, 100_000),
|
|
50
|
+
fill,
|
|
51
|
+
fill > 1 ? 1 : 0,
|
|
52
|
+
logScale(t.tools, 64),
|
|
53
|
+
logScale(t.turnIndex, 100),
|
|
54
|
+
logScale(t.attachments ?? 0, 16),
|
|
55
|
+
Math.max(0, Math.min(1, t.priorLocalSuccessRate ?? 0.5)),
|
|
56
|
+
t.codeHeavy ? 1 : 0,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LocalSuccess {
|
|
61
|
+
succeeds: boolean;
|
|
62
|
+
/** P(the local model succeeds). */
|
|
63
|
+
probability: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const LOCAL_SUCCESS_TASK: MlTask<LocalTurnFeatures, LocalSuccess> = {
|
|
67
|
+
kind: "classify",
|
|
68
|
+
id: "run.local-success",
|
|
69
|
+
description: "Will this turn succeed on the local model?",
|
|
70
|
+
features: { numeric: localSuccessFeatures, numericNames: LOCAL_SUCCESS_FEATURES },
|
|
71
|
+
engines: [
|
|
72
|
+
{ tier: "local", engine: "classical", algorithm: "logistic" },
|
|
73
|
+
{ tier: "local", engine: "classical", algorithm: "gbm-small" },
|
|
74
|
+
],
|
|
75
|
+
train: { minSamples: 8 },
|
|
76
|
+
output: {
|
|
77
|
+
labels: ["fails", "succeeds"],
|
|
78
|
+
decode: (p: MlPrediction) => ({ succeeds: (p.scores[1] ?? 0) >= 0.5, probability: p.scores[1] ?? 0 }),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type TurnResult = "succeeded" | "failed";
|
|
83
|
+
|
|
84
|
+
/** How one routed turn actually went. */
|
|
85
|
+
export interface LocalSuccessOutcome {
|
|
86
|
+
turn: LocalTurnFeatures;
|
|
87
|
+
/** The local model's result, when it ran. */
|
|
88
|
+
local?: TurnResult;
|
|
89
|
+
/** The cloud's result, when it ran. */
|
|
90
|
+
cloud?: TurnResult;
|
|
91
|
+
/** The local model ran first and the turn was handed to the cloud. */
|
|
92
|
+
fellBack?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Weight of a cloud failure on a turn the local model never tried — weak evidence the turn was hard. */
|
|
96
|
+
export const CLOUD_ONLY_FAILURE_WEIGHT = 0.5;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The training label of one outcome, or null when it says nothing about the local model.
|
|
100
|
+
* - local ran: its result (1 succeeded / 0 failed);
|
|
101
|
+
* - fell back to the cloud: 0 — the local model could not finish it;
|
|
102
|
+
* - cloud only, and the cloud failed: 0 at {@link CLOUD_ONLY_FAILURE_WEIGHT};
|
|
103
|
+
* - cloud only, and the cloud succeeded: no evidence about local → null.
|
|
104
|
+
*/
|
|
105
|
+
export function localSuccessLabel(o: LocalSuccessOutcome): { label: 0 | 1; weight: number } | null {
|
|
106
|
+
if (o.local) return { label: o.local === "succeeded" ? 1 : 0, weight: 1 };
|
|
107
|
+
if (o.fellBack) return { label: 0, weight: 1 };
|
|
108
|
+
if (o.cloud === "failed") return { label: 0, weight: CLOUD_ONLY_FAILURE_WEIGHT };
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Labelled examples from routed outcomes (local AND cloud), oldest first. */
|
|
113
|
+
export function localSuccessExamples(outcomes: readonly LocalSuccessOutcome[]): MlTrainExample<LocalTurnFeatures>[] {
|
|
114
|
+
return outcomes.flatMap((o) => {
|
|
115
|
+
const l = localSuccessLabel(o);
|
|
116
|
+
return l ? [{ input: o.turn, label: l.label, weight: l.weight }] : [];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ml/tasks/run_anomaly.ts — `run.anomaly`: a run that cost, took or failed far
|
|
3
|
+
* outside its normal range. Flags runs to leave out of training and to surface as
|
|
4
|
+
* Insights.
|
|
5
|
+
*
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
import type { Run } from "../../experience/types.js";
|
|
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";
|
|
15
|
+
import { logScale } from "../features.js";
|
|
16
|
+
import type { MlPrediction, MlSample, MlTask } from "../types.js";
|
|
17
|
+
|
|
18
|
+
/** What a run cost and how it went — the host supplies cost; the rest is in the run log. */
|
|
19
|
+
export interface RunMetrics {
|
|
20
|
+
costUsd: number;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
toolFailures: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunAnomaly {
|
|
26
|
+
anomalous: boolean;
|
|
27
|
+
/** Normalised score: ≥ 1 is anomalous. */
|
|
28
|
+
score: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const RUN_ANOMALY_METRICS = ["cost", "duration", "toolFailures"] as const;
|
|
32
|
+
|
|
33
|
+
/** Fixed log scales (a $10 run, a one-hour run, 50 failures map to 1). */
|
|
34
|
+
export function runAnomalyFeatures(m: RunMetrics): number[] {
|
|
35
|
+
return [logScale(m.costUsd * 100, 1000), logScale(m.durationMs, 3_600_000), logScale(m.toolFailures, 50)];
|
|
36
|
+
}
|
|
37
|
+
|
|
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;
|
|
48
|
+
|
|
49
|
+
/** The composite detector with `run.anomaly`'s metric names on its explanation. */
|
|
50
|
+
export class RunAnomalyDetector extends AnomalyCompositeModel {
|
|
51
|
+
/** Which metrics are out of range, per detector (z / threshold, ≥ 1 = out of range). */
|
|
52
|
+
explain(x: ArrayLike<number>): { metric: (typeof RUN_ANOMALY_METRICS)[number]; population: number; trend: number }[] {
|
|
53
|
+
const per = this.featureScores(x);
|
|
54
|
+
return RUN_ANOMALY_METRICS.map((metric, i) => ({ metric, ...per[i]! }));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
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
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const RUN_ANOMALY_TASK: MlTask<RunMetrics, RunAnomaly> = {
|
|
67
|
+
kind: "anomaly",
|
|
68
|
+
id: "run.anomaly",
|
|
69
|
+
description: "Did this run cost, take or fail far outside its normal range?",
|
|
70
|
+
features: { numeric: runAnomalyFeatures, numericNames: RUN_ANOMALY_METRICS },
|
|
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.
|
|
73
|
+
train: { minSamples: 5, fit: (samples) => fitRunAnomaly(samples) },
|
|
74
|
+
output: { decode: (p: MlPrediction) => ({ anomalous: (p.scores[0] ?? 0) >= 1, score: p.scores[0] ?? 0 }) },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Duration and tool-failure count of an experience run, with the host's cost. */
|
|
78
|
+
export function experienceRunMetrics(run: Run, costUsd = 0): RunMetrics {
|
|
79
|
+
return {
|
|
80
|
+
costUsd,
|
|
81
|
+
durationMs: run.endedAt != null ? Math.max(0, run.endedAt - run.startedAt) : 0,
|
|
82
|
+
toolFailures: run.steps.filter((s) => s.outcome === "failed").length,
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/ml/train.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ml/train.ts — the ONE trainer seam for catalog tasks.
|
|
3
|
+
*
|
|
4
|
+
* Classical engines fit in closed form or by small iteration (`classical/`);
|
|
5
|
+
* head engines train a {@link DenseHead} through the shared {@link HeadTrainer}
|
|
6
|
+
* (GPU AdamW when a device is supplied, CPU AdamW otherwise). A task can name its
|
|
7
|
+
* own composite fit (`TrainSpec.fit`), or say that something else trains it
|
|
8
|
+
* (`TrainSpec.trainedBy`, e.g. `LimbicTrainer` for `limbic.affect`).
|
|
9
|
+
*
|
|
10
|
+
* Held-out loss is measured the same way everywhere ({@link mlEvalLoss}) — it is
|
|
11
|
+
* also what gates a federated merge.
|
|
12
|
+
*/
|
|
13
|
+
import { fitClassical, type ClassicalOptions } from "./classical/index.js";
|
|
14
|
+
import { DenseHead } from "./dense_head.js";
|
|
15
|
+
import { mlEvalLoss } from "./eval_loss.js";
|
|
16
|
+
import { extractFeatureMatrix, type TextEmbedder } from "./features.js";
|
|
17
|
+
import { HeadTrainer, type HeadTrainOptions } from "./head_trainer.js";
|
|
18
|
+
import type { MlModelStore } from "./run.js";
|
|
19
|
+
import { engineKey, type MlEngineRef, type MlPredictor, type MlSample, type MlTask } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/** One labelled example in input space. */
|
|
22
|
+
export interface MlTrainExample<I> {
|
|
23
|
+
input: I;
|
|
24
|
+
/** classify: class index; regress: target value; cluster/anomaly: ignored (use 0). */
|
|
25
|
+
label: number;
|
|
26
|
+
weight?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MlTrainOptions {
|
|
30
|
+
/** Engine to fit (default: the task's first trainable local engine). */
|
|
31
|
+
engine?: MlEngineRef;
|
|
32
|
+
embedder?: TextEmbedder;
|
|
33
|
+
device?: GPUDevice | null;
|
|
34
|
+
classical?: ClassicalOptions;
|
|
35
|
+
head?: { hiddenDim?: number; seed?: number; train?: HeadTrainOptions };
|
|
36
|
+
/** Overrides the task's `holdoutFraction`. 0 disables the held-out measurement. */
|
|
37
|
+
holdoutFraction?: number;
|
|
38
|
+
/** After measuring on the held-out tail, refit on everything. Default true. */
|
|
39
|
+
refitOnAll?: boolean;
|
|
40
|
+
/** Store the fitted predictor here under (task id, engine key). */
|
|
41
|
+
models?: MlModelStore;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MlTrainResult {
|
|
45
|
+
predictor: MlPredictor;
|
|
46
|
+
engine: MlEngineRef;
|
|
47
|
+
/** Examples the final fit saw. */
|
|
48
|
+
samples: number;
|
|
49
|
+
/** Size of the held-out tail, and the model's loss on it (null when none was held out). */
|
|
50
|
+
holdout: number;
|
|
51
|
+
holdoutLoss: number | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { mlEvalLoss } from "./eval_loss.js";
|
|
55
|
+
|
|
56
|
+
/** The task's first local engine the seam can train. */
|
|
57
|
+
function defaultEngine(task: MlTask<any, any>): MlEngineRef {
|
|
58
|
+
const e = task.engines.find((r) => r.tier === "local" && (r.engine === "classical" || r.engine === "head"));
|
|
59
|
+
if (!e) throw new Error(`trainMlTask(${task.id}): no trainable local engine`);
|
|
60
|
+
return e;
|
|
61
|
+
}
|
|
62
|
+
|
|
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);
|
|
66
|
+
if (ref.engine === "head") {
|
|
67
|
+
const objective = task.kind === "classify" ? "softmax" : task.kind === "regress" ? "mse" : null;
|
|
68
|
+
if (!objective) throw new Error(`trainMlTask(${task.id}): a head engine trains classify or regress tasks only`);
|
|
69
|
+
const head = new DenseHead({
|
|
70
|
+
inputDim: samples[0]!.x.length,
|
|
71
|
+
hiddenDim: ref.head === "linear" ? 0 : (opts.head?.hiddenDim ?? 16),
|
|
72
|
+
outputDim: objective === "softmax" ? task.output.labels!.length : 1,
|
|
73
|
+
objective,
|
|
74
|
+
...(opts.head?.seed != null ? { seed: opts.head.seed } : {}),
|
|
75
|
+
});
|
|
76
|
+
await new HeadTrainer(head, opts.device ?? null, `trainMlTask(${task.id})`).train(samples, opts.head?.train);
|
|
77
|
+
return head;
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`trainMlTask(${task.id}): engine '${engineKey(ref)}' is not trained here`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fit task `task` on labelled examples. Returns null when there are fewer than
|
|
84
|
+
* the task's `minSamples` (it stays untrained). Throws for a task with no train
|
|
85
|
+
* spec, or one another trainer owns.
|
|
86
|
+
*/
|
|
87
|
+
export async function trainMlTask<I>(
|
|
88
|
+
task: MlTask<I, any>,
|
|
89
|
+
examples: readonly MlTrainExample<I>[],
|
|
90
|
+
opts: MlTrainOptions = {},
|
|
91
|
+
): Promise<MlTrainResult | null> {
|
|
92
|
+
const spec = task.train;
|
|
93
|
+
if (!spec) throw new Error(`trainMlTask(${task.id}): task is rule-based or pre-trained only`);
|
|
94
|
+
if (spec.trainedBy) throw new Error(`trainMlTask(${task.id}): trained by ${spec.trainedBy}`);
|
|
95
|
+
if (examples.length < spec.minSamples) return null;
|
|
96
|
+
|
|
97
|
+
const ref = opts.engine ?? defaultEngine(task);
|
|
98
|
+
const X = await extractFeatureMatrix(task.features, examples.map((e) => e.input), opts.embedder);
|
|
99
|
+
const samples: MlSample[] = examples.map((e, i) => ({ x: X[i]!, y: e.label, ...(e.weight != null ? { weight: e.weight } : {}) }));
|
|
100
|
+
|
|
101
|
+
// Hold out the chronological TAIL: examples arrive oldest first, and the question
|
|
102
|
+
// is always "does this generalise to what comes next".
|
|
103
|
+
const supervised = task.kind === "classify" || task.kind === "regress";
|
|
104
|
+
const frac = supervised ? (opts.holdoutFraction ?? spec.holdoutFraction ?? 0.2) : 0;
|
|
105
|
+
const nHold = Math.floor(samples.length * frac);
|
|
106
|
+
let holdoutLoss: number | null = null;
|
|
107
|
+
if (nHold >= 1 && samples.length - nHold >= 1) {
|
|
108
|
+
const train = samples.slice(0, samples.length - nHold);
|
|
109
|
+
const hold = samples.slice(samples.length - nHold);
|
|
110
|
+
const predictor = await fitEngine(task, ref, train, opts);
|
|
111
|
+
holdoutLoss = mlEvalLoss(task.kind, predictor, hold);
|
|
112
|
+
if (opts.refitOnAll === false) {
|
|
113
|
+
opts.models?.set(task.id, engineKey(ref), predictor);
|
|
114
|
+
return { predictor, engine: ref, samples: train.length, holdout: nHold, holdoutLoss };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
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 };
|
|
120
|
+
}
|