@seanhogg/builderforce-memory-engine 2026.10.1 → 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.
Files changed (149) hide show
  1. package/dist/evermind/adapt.d.ts +7 -0
  2. package/dist/evermind/adapt.d.ts.map +1 -1
  3. package/dist/evermind/adapt.js +1 -1
  4. package/dist/evermind/adapt.js.map +1 -1
  5. package/dist/evermind/delta_wire.d.ts +16 -1
  6. package/dist/evermind/delta_wire.d.ts.map +1 -1
  7. package/dist/evermind/delta_wire.js +22 -2
  8. package/dist/evermind/delta_wire.js.map +1 -1
  9. package/dist/evermind/index.d.ts +2 -2
  10. package/dist/evermind/index.d.ts.map +1 -1
  11. package/dist/evermind/index.js +1 -1
  12. package/dist/evermind/index.js.map +1 -1
  13. package/dist/evermind/merge.d.ts +5 -2
  14. package/dist/evermind/merge.d.ts.map +1 -1
  15. package/dist/evermind/merge.js +5 -2
  16. package/dist/evermind/merge.js.map +1 -1
  17. package/dist/evermind/ml_head.d.ts +28 -0
  18. package/dist/evermind/ml_head.d.ts.map +1 -0
  19. package/dist/evermind/ml_head.js +47 -0
  20. package/dist/evermind/ml_head.js.map +1 -0
  21. package/dist/evermind/package.d.ts +2 -2
  22. package/dist/evermind/package.d.ts.map +1 -1
  23. package/dist/evermind/package.js +1 -1
  24. package/dist/evermind/package.js.map +1 -1
  25. package/dist/experience/index.d.ts +2 -2
  26. package/dist/experience/index.d.ts.map +1 -1
  27. package/dist/experience/index.js.map +1 -1
  28. package/dist/experience/store.d.ts +19 -1
  29. package/dist/experience/store.d.ts.map +1 -1
  30. package/dist/experience/store.js +14 -2
  31. package/dist/experience/store.js.map +1 -1
  32. package/dist/experience/types.d.ts +18 -0
  33. package/dist/experience/types.d.ts.map +1 -1
  34. package/dist/experience/types.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/kernels/weight_update.d.ts.map +1 -1
  40. package/dist/kernels/weight_update.js +12 -0
  41. package/dist/kernels/weight_update.js.map +1 -1
  42. package/dist/limbic/limbic_model.d.ts.map +1 -1
  43. package/dist/limbic/limbic_model.js +1 -3
  44. package/dist/limbic/limbic_model.js.map +1 -1
  45. package/dist/ml/classical/anomaly_composite.d.ts +32 -0
  46. package/dist/ml/classical/anomaly_composite.d.ts.map +1 -0
  47. package/dist/ml/classical/anomaly_composite.js +29 -0
  48. package/dist/ml/classical/anomaly_composite.js.map +1 -0
  49. package/dist/ml/classical/ewma.d.ts +7 -1
  50. package/dist/ml/classical/ewma.d.ts.map +1 -1
  51. package/dist/ml/classical/ewma.js +6 -4
  52. package/dist/ml/classical/ewma.js.map +1 -1
  53. package/dist/ml/classical/gbm.d.ts.map +1 -1
  54. package/dist/ml/classical/gbm.js +2 -1
  55. package/dist/ml/classical/gbm.js.map +1 -1
  56. package/dist/ml/classical/index.d.ts +6 -2
  57. package/dist/ml/classical/index.d.ts.map +1 -1
  58. package/dist/ml/classical/index.js +5 -1
  59. package/dist/ml/classical/index.js.map +1 -1
  60. package/dist/ml/classical/kmeans.d.ts.map +1 -1
  61. package/dist/ml/classical/kmeans.js +23 -4
  62. package/dist/ml/classical/kmeans.js.map +1 -1
  63. package/dist/ml/classical/knn.d.ts +15 -5
  64. package/dist/ml/classical/knn.d.ts.map +1 -1
  65. package/dist/ml/classical/knn.js +28 -7
  66. package/dist/ml/classical/knn.js.map +1 -1
  67. package/dist/ml/classical/logistic.d.ts +1 -9
  68. package/dist/ml/classical/logistic.d.ts.map +1 -1
  69. package/dist/ml/classical/logistic.js +17 -9
  70. package/dist/ml/classical/logistic.js.map +1 -1
  71. package/dist/ml/classical/naive_bayes.d.ts +5 -1
  72. package/dist/ml/classical/naive_bayes.d.ts.map +1 -1
  73. package/dist/ml/classical/naive_bayes.js +8 -3
  74. package/dist/ml/classical/naive_bayes.js.map +1 -1
  75. package/dist/ml/classical/robust_z.d.ts +15 -1
  76. package/dist/ml/classical/robust_z.d.ts.map +1 -1
  77. package/dist/ml/classical/robust_z.js +6 -1
  78. package/dist/ml/classical/robust_z.js.map +1 -1
  79. package/dist/ml/eval_loss.d.ts +15 -0
  80. package/dist/ml/eval_loss.d.ts.map +1 -0
  81. package/dist/ml/eval_loss.js +20 -0
  82. package/dist/ml/eval_loss.js.map +1 -0
  83. package/dist/ml/federate.d.ts +11 -25
  84. package/dist/ml/federate.d.ts.map +1 -1
  85. package/dist/ml/federate.js +15 -38
  86. package/dist/ml/federate.js.map +1 -1
  87. package/dist/ml/head_trainer.d.ts +16 -2
  88. package/dist/ml/head_trainer.d.ts.map +1 -1
  89. package/dist/ml/head_trainer.js +64 -27
  90. package/dist/ml/head_trainer.js.map +1 -1
  91. package/dist/ml/run.d.ts +11 -2
  92. package/dist/ml/run.d.ts.map +1 -1
  93. package/dist/ml/run.js +56 -17
  94. package/dist/ml/run.js.map +1 -1
  95. package/dist/ml/tasks/experience.d.ts +95 -13
  96. package/dist/ml/tasks/experience.d.ts.map +1 -1
  97. package/dist/ml/tasks/experience.js +172 -20
  98. package/dist/ml/tasks/experience.js.map +1 -1
  99. package/dist/ml/tasks/run_anomaly.d.ts +37 -19
  100. package/dist/ml/tasks/run_anomaly.d.ts.map +1 -1
  101. package/dist/ml/tasks/run_anomaly.js +50 -31
  102. package/dist/ml/tasks/run_anomaly.js.map +1 -1
  103. package/dist/ml/train.d.ts +8 -7
  104. package/dist/ml/train.d.ts.map +1 -1
  105. package/dist/ml/train.js +21 -30
  106. package/dist/ml/train.js.map +1 -1
  107. package/dist/ml/types.d.ts +6 -3
  108. package/dist/ml/types.d.ts.map +1 -1
  109. package/dist/ml/types.js +3 -2
  110. package/dist/ml/types.js.map +1 -1
  111. package/dist/optim/adamw.d.ts +11 -5
  112. package/dist/optim/adamw.d.ts.map +1 -1
  113. package/dist/optim/adamw.js +19 -4
  114. package/dist/optim/adamw.js.map +1 -1
  115. package/package.json +1 -1
  116. package/src/evermind/adapt.ts +8 -2
  117. package/src/evermind/delta_wire.ts +173 -148
  118. package/src/evermind/index.ts +36 -35
  119. package/src/evermind/merge.ts +10 -4
  120. package/src/evermind/ml_head.ts +63 -0
  121. package/src/evermind/package.ts +488 -488
  122. package/src/experience/index.ts +2 -1
  123. package/src/experience/store.ts +35 -3
  124. package/src/experience/types.ts +19 -0
  125. package/src/index.ts +2 -0
  126. package/src/kernels/weight_update.ts +12 -0
  127. package/src/limbic/limbic_model.ts +1 -4
  128. package/src/limbic/limbic_trainer.ts +91 -91
  129. package/src/ml/classical/anomaly_composite.ts +46 -0
  130. package/src/ml/classical/ewma.ts +8 -2
  131. package/src/ml/classical/gbm.ts +144 -143
  132. package/src/ml/classical/index.ts +8 -2
  133. package/src/ml/classical/kmeans.ts +22 -4
  134. package/src/ml/classical/knn.ts +31 -8
  135. package/src/ml/classical/logistic.ts +9 -9
  136. package/src/ml/classical/math.ts +93 -93
  137. package/src/ml/classical/naive_bayes.ts +10 -3
  138. package/src/ml/classical/robust_z.ts +21 -2
  139. package/src/ml/eval_loss.ts +27 -0
  140. package/src/ml/federate.ts +22 -52
  141. package/src/ml/head_trainer.ts +66 -48
  142. package/src/ml/registry.ts +44 -44
  143. package/src/ml/run.ts +57 -13
  144. package/src/ml/tasks/experience.ts +222 -23
  145. package/src/ml/tasks/run_anomaly.ts +62 -37
  146. package/src/ml/train.ts +34 -31
  147. package/src/ml/types.ts +135 -132
  148. package/src/optim/adamw.ts +21 -5
  149. package/src/training/cpu_ops.ts +141 -141
@@ -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(`runMlTask(${taskId}): no engine could answer in '${decision.mode}' mode (${decision.skipped.map((s) => `${s.engine}: ${s.reason}`).join(", ") || "none allowed"})`);
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
- /** The task's engines the mode allows, in the order the runner tries them. */
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
- if (mode === "local-only") return task.engines.filter((e) => e.tier === "local");
123
- if (mode === "hybrid") return [...task.engines.filter((e) => e.tier === "local"), ...task.engines.filter((e) => e.tier === "cloud")];
124
- if (specific == null) throw new Error(`runMlTask(${task.id}): 'specific' mode needs ctx.engine`);
125
- const key = typeof specific === "string" ? specific : engineKey(specific);
126
- const match = task.engines.filter((e) => engineKey(e) === key);
127
- if (match.length === 0) throw new Error(`runMlTask(${task.id}): engine '${key}' is not one of this task's engines`);
128
- return match;
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
- /** Answer task `id` for `input` with the first engine the mode allows that can. */
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
- features ??= await extractFeatures(task.features, input, ctx.embedder);
154
- const p = predictor.predict(features);
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: error instanceof Error ? error.message : String(error) });
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.
@@ -5,11 +5,13 @@
5
5
  * instead of every transcript. Curation only drops documents; it never rewrites one.
6
6
  */
7
7
  import { experienceDocuments, type ExperienceDocument } from "../../experience/corpus.js";
8
- import type { Episode, Run, Skill } from "../../experience/types.js";
8
+ import type { CurationRecord } from "../../experience/store.js";
9
+ import type { CuratedOut, Episode, Run, Skill } from "../../experience/types.js";
9
10
  import { KnnModel } from "../classical/knn.js";
10
- import { clampRatio, logScale, type TextEmbedder } from "../features.js";
11
- import { MlEngineUnavailableError, runMlTask, type MlRunContext } from "../run.js";
12
- import type { MlTrainExample } from "../train.js";
11
+ import { logScale, type TextEmbedder } from "../features.js";
12
+ import { MlTaskRegistry } from "../registry.js";
13
+ import { InMemoryMlModels, MlEngineUnavailableError, runMlTask, type MlModelStore, type MlRunContext } from "../run.js";
14
+ import { trainMlTask, type MlTrainExample } from "../train.js";
13
15
  import type { MlPrediction, MlPredictor, MlTask } from "../types.js";
14
16
 
15
17
  // ── experience.quality ────────────────────────────────────────────────────────
@@ -37,14 +39,20 @@ export const EXPERIENCE_QUALITY_TASK: MlTask<ExperienceQualityInput, ExperienceQ
37
39
  description: "Is this procedure worth learning from (does it run, and run well)?",
38
40
  features: {
39
41
  text: (i) => i.doc.text,
40
- numeric: (i) => [logScale(i.steps, 50), logScale(i.runs, 20), clampRatio(i.succeeded, i.runs, 1), clampRatio(i.failed, i.runs, 1)],
41
- numericNames: ["steps", "runs", "successRate", "failureRate"],
42
+ // No success/failure rate: the label IS `succeeded / runs >= 0.5`, so a rate
43
+ // feature would leak it — the model would learn the label, not the procedure.
44
+ numeric: (i) => [logScale(i.steps, 50), logScale(i.runs, 20)],
45
+ numericNames: ["steps", "runs"],
42
46
  },
43
47
  engines: [
44
48
  { tier: "local", engine: "classical", algorithm: "knn" },
45
49
  { tier: "local", engine: "classical", algorithm: "logistic" },
46
50
  ],
47
- train: { minSamples: 4 },
51
+ // Below 12 labelled procedures the task stays untrained and curation keeps
52
+ // everything. At 12, after the 20% holdout ≈10 remain to fit, both classes can have
53
+ // several members, and kNN votes with k = ⌊√n⌋ = 3 neighbours rather than the whole
54
+ // set — with fewer, every point votes and the answer collapses to the class prior.
55
+ train: { minSamples: 12 },
48
56
  output: {
49
57
  labels: ["low", "high"],
50
58
  decode: (p: MlPrediction) => ({ label: (p.scores[1] ?? 0) >= 0.5 ? "high" : "low", pHigh: p.scores[1] ?? 0 }),
@@ -76,6 +84,14 @@ export function experienceQualityLabel(i: ExperienceQualityInput): number | null
76
84
  return i.succeeded / i.runs >= 0.5 ? 1 : 0;
77
85
  }
78
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
+
79
95
  /** Labelled examples for `experience.quality` — only the inputs that have run outcomes. */
80
96
  export function experienceQualityExamples(inputs: readonly ExperienceQualityInput[]): MlTrainExample<ExperienceQualityInput>[] {
81
97
  return inputs.flatMap((input) => {
@@ -140,21 +156,72 @@ export interface CurationOptions {
140
156
  /** Cosine similarity at or above which a document duplicates a kept one. Default {@link DEFAULT_DEDUPE_THRESHOLD}. */
141
157
  dedupeThreshold?: number;
142
158
  /**
143
- * 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.
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.
145
164
  */
146
165
  quality?: {
147
166
  ctx: MlRunContext;
148
167
  input: (doc: ExperienceDocument) => ExperienceQualityInput;
149
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[];
150
176
  };
151
177
  /** Document ids to leave out (e.g. those whose runs `run.anomaly` flagged). */
152
178
  exclude?: ReadonlySet<string>;
179
+ /**
180
+ * Documents the model has ALREADY learned. They seed the de-duplication index, so a
181
+ * pending document that only repeats one of them is dropped (reported `of` that
182
+ * one); they are never in `kept`. Each is embedded once per call, so this is
183
+ * bounded — {@link curatePendingExperience} keeps the {@link CURATION_KNOWN_LIMIT}
184
+ * most recently learned.
185
+ */
186
+ known?: readonly ExperienceDocument[];
187
+ }
188
+
189
+ /**
190
+ * How many already-learned documents curation de-duplicates against: the most
191
+ * recently learned ones. Each costs one CPU embed per curation call, and a
192
+ * repeat of something learned long ago is cheap to relearn, so a recent window is the
193
+ * trade — 200 embeds of a small Evermind take seconds on a laptop CPU.
194
+ */
195
+ export const CURATION_KNOWN_LIMIT = 200;
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;
153
203
  }
154
204
 
155
205
  export interface CurationResult {
156
206
  kept: ExperienceDocument[];
157
- dropped: { id: string; reason: "duplicate" | "low-quality" | "excluded"; of?: string; score?: number }[];
207
+ dropped: CurationDrop[];
208
+ /**
209
+ * Documents an EARLIER curation left out whose verdict still stands (see
210
+ * {@link CuratedOut}) — skipped without being embedded or judged again. Only
211
+ * {@link curatePendingExperience} fills it, from the snapshot's `curated` records.
212
+ */
213
+ alreadyDropped?: CurationDrop[];
214
+ }
215
+
216
+ /** A model store whose kNN for `taskId` leaves the point at `index` out of every vote. */
217
+ function leaveOneOut(models: MlModelStore, taskId: string, index: number): MlModelStore {
218
+ return {
219
+ get: (t, e) => {
220
+ const p = models.get(t, e);
221
+ return t === taskId && p instanceof KnnModel && index < p.size ? p.excluding(index) : p;
222
+ },
223
+ set: (t, e, p) => models.set(t, e, p),
224
+ };
158
225
  }
159
226
 
160
227
  /** Memoise an embedder for one call, so curation embeds each text once. */
@@ -171,19 +238,47 @@ function memoEmbedder(e: TextEmbedder): TextEmbedder {
171
238
  };
172
239
  }
173
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
+
174
260
  /**
175
261
  * Curate documents before they are packed into adaptation passes: drop excluded
176
262
  * ones, low-quality ones (when a quality filter is given), then near-duplicates of
177
- * an earlier kept one. Order is preserved, and the FIRST copy wins — compiled
178
- * skills come before raw demonstrations in `experienceDocuments`, so the reviewed
179
- * version is the one kept.
263
+ * an already-learned one (`known`) or an earlier kept one. Order is preserved, and
264
+ * the FIRST copy wins — compiled skills come before raw demonstrations in
265
+ * `experienceDocuments`, so the reviewed version is the one kept.
180
266
  */
181
267
  export async function curateExperience(docs: readonly ExperienceDocument[], opts: CurationOptions): Promise<CurationResult> {
182
268
  const embedder = memoEmbedder(opts.embedder);
183
269
  const index = new DedupeIndex(opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD);
270
+ /** Index position → document id: the known documents first, then each kept one. */
271
+ const indexed: string[] = [];
184
272
  const kept: ExperienceDocument[] = [];
185
273
  const dropped: CurationResult["dropped"] = [];
186
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));
276
+ const embedDoc = (doc: ExperienceDocument) => embedder.embed(EXPERIENCE_DEDUPE_TASK.features.text!(doc));
277
+
278
+ for (const doc of opts.known ?? []) {
279
+ index.add(await embedDoc(doc));
280
+ indexed.push(doc.id);
281
+ }
187
282
 
188
283
  for (const doc of docs) {
189
284
  if (opts.exclude?.has(doc.id)) {
@@ -191,24 +286,128 @@ export async function curateExperience(docs: readonly ExperienceDocument[], opts
191
286
  continue;
192
287
  }
193
288
  if (opts.quality && qctx) {
194
- try {
195
- const r = await runMlTask<ExperienceQuality>(EXPERIENCE_QUALITY_TASK.id, opts.quality.input(doc), qctx);
196
- if (r.output.pHigh < (opts.quality.minPHigh ?? 0.5)) {
197
- dropped.push({ id: doc.id, reason: "low-quality", score: r.output.pHigh });
198
- continue;
199
- }
200
- } catch (error) {
201
- if (!(error instanceof MlEngineUnavailableError)) throw error;
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;
202
293
  }
203
294
  }
204
- const x = await embedder.embed(EXPERIENCE_DEDUPE_TASK.features.text!(doc));
295
+ const x = await embedDoc(doc);
205
296
  const d = EXPERIENCE_DEDUPE_TASK.output.decode(index.predict(x));
206
297
  if (d.duplicate) {
207
- dropped.push({ id: doc.id, reason: "duplicate", of: kept[d.nearest]!.id, score: d.similarity });
298
+ dropped.push({ id: doc.id, reason: "duplicate", of: indexed[d.nearest]!, score: d.similarity });
208
299
  continue;
209
300
  }
210
301
  index.add(x);
302
+ indexed.push(doc.id);
211
303
  kept.push(doc);
212
304
  }
213
305
  return { kept, dropped };
214
306
  }
307
+
308
+ /** What {@link curatePendingExperience} reads: the experience snapshot's raw records. */
309
+ export interface ExperienceCurationSource {
310
+ episodes: Episode[];
311
+ skills: Skill[];
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;
327
+ }
328
+
329
+ /**
330
+ * The whole curation step a host runs in front of the adaptation passes, so every
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.
343
+ */
344
+ export async function curatePendingExperience(
345
+ pending: readonly ExperienceDocument[],
346
+ source: ExperienceCurationSource,
347
+ opts: { embedder: TextEmbedder; known?: readonly ExperienceDocument[]; dedupeThreshold?: number; minPHigh?: number },
348
+ ): Promise<CurationResult> {
349
+ const known = opts.known?.slice(0, CURATION_KNOWN_LIMIT);
350
+ const embedder = memoEmbedder(opts.embedder);
351
+ const inputs = experienceQualityInputs(source.episodes, source.skills, source.runs);
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
+
374
+ const registry = new MlTaskRegistry();
375
+ registry.register(EXPERIENCE_QUALITY_TASK);
376
+ const models = new InMemoryMlModels();
377
+ const fitted = await trainMlTask(EXPERIENCE_QUALITY_TASK, train, { embedder, models });
378
+ const r = await curateExperience(toCurate, {
379
+ embedder,
380
+ ...(known ? { known } : {}),
381
+ ...(opts.dedupeThreshold != null ? { dedupeThreshold: opts.dedupeThreshold } : {}),
382
+ quality: {
383
+ ctx: { registry, mode: "local-only", models },
384
+ input: inputOf,
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) } : {}),
388
+ },
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) };
413
+ }
@@ -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
- * Two detectors, one answer: robust z (median/MAD over the whole history — "far
7
- * from normal") and EWMA (the recent trend — "drifting away from lately"). The
8
- * score is the larger of the two, so either one can flag.
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 { EwmaModel, fitEwma, type EwmaOptions } from "../classical/ewma.js";
12
- import { anomalyConfidence, fitRobustZ, RobustZModel, type RobustZOptions } from "../classical/robust_z.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";
13
15
  import { logScale } from "../features.js";
14
- import type { MlPrediction, MlPredictor, MlSample, MlTask } from "../types.js";
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 {
@@ -28,43 +30,68 @@ export interface RunAnomaly {
28
30
 
29
31
  export const RUN_ANOMALY_METRICS = ["cost", "duration", "toolFailures"] as const;
30
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
+
31
38
  /** Fixed log scales (a $10 run, a one-hour run, 50 failures map to 1). */
32
39
  export function runAnomalyFeatures(m: RunMetrics): number[] {
33
- return [logScale(m.costUsd * 100, 1000), logScale(m.durationMs, 3_600_000), logScale(m.toolFailures, 50)];
40
+ return [
41
+ logScale(m.costUsd * 100, COST_REF_CENTS),
42
+ logScale(m.durationMs, DURATION_REF_MS),
43
+ logScale(m.toolFailures, FAILURES_REF),
44
+ ];
34
45
  }
35
46
 
36
- /** Robust z over the history, combined with EWMA over its recent trend. */
37
- export class RunAnomalyDetector implements MlPredictor {
38
- constructor(
39
- readonly population: RobustZModel,
40
- readonly trend: EwmaModel,
41
- ) {}
47
+ /**
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.)
73
+ */
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
+ ];
42
79
 
80
+ /** The composite detector with `run.anomaly`'s metric names on its explanation. */
81
+ export class RunAnomalyDetector extends AnomalyCompositeModel {
43
82
  /** Which metrics are out of range, per detector (z / threshold, ≥ 1 = out of range). */
44
83
  explain(x: ArrayLike<number>): { metric: (typeof RUN_ANOMALY_METRICS)[number]; population: number; trend: number }[] {
45
- const pz = this.population.zScores(x);
46
- const tz = this.trend.zScores(x);
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) };
84
+ const per = this.featureScores(x);
85
+ return RUN_ANOMALY_METRICS.map((metric, i) => ({ metric, ...per[i]! }));
62
86
  }
63
87
  }
64
88
 
65
- /** Fit both detectors on a chronological history of runs (as feature vectors). */
66
- export function fitRunAnomaly(samples: readonly MlSample[], opts: { robustZ?: RobustZOptions; ewma?: EwmaOptions } = {}): RunAnomalyDetector {
67
- return new RunAnomalyDetector(fitRobustZ(samples, opts.robustZ), fitEwma(samples, opts.ewma));
89
+ /** Fit both detectors on a chronological history of runs (as feature vectors), floored per metric at {@link RUN_ANOMALY_MIN_SCALE}. */
90
+ export function fitRunAnomaly(samples: readonly MlSample[], opts: AnomalyCompositeOptions = {}): RunAnomalyDetector {
91
+ return new RunAnomalyDetector(
92
+ fitRobustZ(samples, { ...opts.robustZ, minScale: opts.robustZ?.minScale ?? RUN_ANOMALY_MIN_SCALE }),
93
+ fitEwma(samples, { ...opts.ewma, minScale: opts.ewma?.minScale ?? RUN_ANOMALY_MIN_SCALE }),
94
+ );
68
95
  }
69
96
 
70
97
  export const RUN_ANOMALY_TASK: MlTask<RunMetrics, RunAnomaly> = {
@@ -72,10 +99,8 @@ export const RUN_ANOMALY_TASK: MlTask<RunMetrics, RunAnomaly> = {
72
99
  id: "run.anomaly",
73
100
  description: "Did this run cost, take or fail far outside its normal range?",
74
101
  features: { numeric: runAnomalyFeatures, numericNames: RUN_ANOMALY_METRICS },
75
- engines: [
76
- { tier: "local", engine: "classical", algorithm: "robust-z" },
77
- { tier: "local", engine: "classical", algorithm: "ewma" },
78
- ],
102
+ engines: [{ tier: "local", engine: "classical", algorithm: "anomaly-composite" }],
103
+ // The task's own fit: the same composite, with the metric floor and named explanation.
79
104
  train: { minSamples: 5, fit: (samples) => fitRunAnomaly(samples) },
80
105
  output: { decode: (p: MlPrediction) => ({ anomalous: (p.scores[0] ?? 0) >= 1, score: p.scores[0] ?? 0 }) },
81
106
  };