@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,93 +1,93 @@
1
- /**
2
- * ml/classical/math.ts — the small vector helpers every classical algorithm shares.
3
- * Pure, allocation-light, deterministic.
4
- */
5
- import { SeededRng } from "../../utils/rng.js";
6
-
7
- /** The engine's ONE sigmoid / softmax (`training/cpu_ops.ts`). */
8
- export { sigmoid, softmax } from "../../training/cpu_ops.js";
9
-
10
- export type Vec = ArrayLike<number>;
11
-
12
- export function dot(a: Vec, b: Vec): number {
13
- const n = Math.min(a.length, b.length);
14
- let s = 0;
15
- for (let i = 0; i < n; i++) s += a[i]! * b[i]!;
16
- return s;
17
- }
18
-
19
- export function norm(a: Vec): number {
20
- return Math.sqrt(dot(a, a));
21
- }
22
-
23
- /** Cosine similarity in [-1, 1]; 0 when either vector is zero. */
24
- export function cosine(a: Vec, b: Vec): number {
25
- const na = norm(a);
26
- const nb = norm(b);
27
- return na > 0 && nb > 0 ? dot(a, b) / (na * nb) : 0;
28
- }
29
-
30
- export function squaredDistance(a: Vec, b: Vec): number {
31
- const n = Math.min(a.length, b.length);
32
- let s = 0;
33
- for (let i = 0; i < n; i++) {
34
- const d = a[i]! - b[i]!;
35
- s += d * d;
36
- }
37
- return s;
38
- }
39
-
40
- /** Median of a numeric list (copy-sorted). NaN for an empty list. */
41
- export function median(values: readonly number[]): number {
42
- if (values.length === 0) return Number.NaN;
43
- const s = [...values].sort((a, b) => a - b);
44
- const m = s.length >> 1;
45
- return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
46
- }
47
-
48
- /** Index of the largest value (first on ties). */
49
- export function argmax(values: ArrayLike<number>): number {
50
- let best = 0;
51
- for (let i = 1; i < values.length; i++) if (values[i]! > values[best]!) best = i;
52
- return best;
53
- }
54
-
55
- /** Number of distinct classes implied by integer labels 0..k-1. */
56
- export function classCount(labels: readonly number[]): number {
57
- let k = 0;
58
- for (const y of labels) {
59
- if (!Number.isInteger(y) || y < 0) throw new Error(`class label must be a non-negative integer, got ${y}`);
60
- k = Math.max(k, y + 1);
61
- }
62
- return Math.max(k, 2);
63
- }
64
-
65
- /** Feature dimension of a non-empty sample matrix; throws on ragged rows. */
66
- export function featureDim(X: readonly Vec[]): number {
67
- if (X.length === 0) throw new Error("no samples");
68
- const d = X[0]!.length;
69
- for (const x of X) if (x.length !== d) throw new Error(`ragged features: expected ${d}, got ${x.length}`);
70
- return d;
71
- }
72
-
73
- /** Confidence of a probability vector: its top probability. */
74
- export function topProbability(p: readonly number[]): number {
75
- return p.length ? Math.max(...p) : 0;
76
- }
77
-
78
- /** A deterministic [0,1) source for algorithms that sample — the engine's ONE seeded RNG. */
79
- export function seededUniform(seed: number): () => number {
80
- const rng = new SeededRng(seed);
81
- return () => rng.next();
82
- }
83
-
84
- /**
85
- * Average path length of an unsuccessful BST search over n points — the
86
- * isolation-forest normaliser c(n).
87
- */
88
- export function averagePathLength(n: number): number {
89
- if (n <= 1) return 0;
90
- if (n === 2) return 1;
91
- const harmonic = Math.log(n - 1) + 0.5772156649;
92
- return 2 * harmonic - (2 * (n - 1)) / n;
93
- }
1
+ /**
2
+ * ml/classical/math.ts — the small vector helpers every classical algorithm shares.
3
+ * Pure, allocation-light, deterministic.
4
+ */
5
+ import { SeededRng } from "../../utils/rng.js";
6
+
7
+ /** The engine's ONE sigmoid / softmax (`training/cpu_ops.ts`). */
8
+ export { sigmoid, softmax } from "../../training/cpu_ops.js";
9
+
10
+ export type Vec = ArrayLike<number>;
11
+
12
+ export function dot(a: Vec, b: Vec): number {
13
+ const n = Math.min(a.length, b.length);
14
+ let s = 0;
15
+ for (let i = 0; i < n; i++) s += a[i]! * b[i]!;
16
+ return s;
17
+ }
18
+
19
+ export function norm(a: Vec): number {
20
+ return Math.sqrt(dot(a, a));
21
+ }
22
+
23
+ /** Cosine similarity in [-1, 1]; 0 when either vector is zero. */
24
+ export function cosine(a: Vec, b: Vec): number {
25
+ const na = norm(a);
26
+ const nb = norm(b);
27
+ return na > 0 && nb > 0 ? dot(a, b) / (na * nb) : 0;
28
+ }
29
+
30
+ export function squaredDistance(a: Vec, b: Vec): number {
31
+ const n = Math.min(a.length, b.length);
32
+ let s = 0;
33
+ for (let i = 0; i < n; i++) {
34
+ const d = a[i]! - b[i]!;
35
+ s += d * d;
36
+ }
37
+ return s;
38
+ }
39
+
40
+ /** Median of a numeric list (copy-sorted). NaN for an empty list. */
41
+ export function median(values: readonly number[]): number {
42
+ if (values.length === 0) return Number.NaN;
43
+ const s = [...values].sort((a, b) => a - b);
44
+ const m = s.length >> 1;
45
+ return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
46
+ }
47
+
48
+ /** Index of the largest value (first on ties). */
49
+ export function argmax(values: ArrayLike<number>): number {
50
+ let best = 0;
51
+ for (let i = 1; i < values.length; i++) if (values[i]! > values[best]!) best = i;
52
+ return best;
53
+ }
54
+
55
+ /** Number of distinct classes implied by integer labels 0..k-1. */
56
+ export function classCount(labels: readonly number[]): number {
57
+ let k = 0;
58
+ for (const y of labels) {
59
+ if (!Number.isInteger(y) || y < 0) throw new Error(`class label must be a non-negative integer, got ${y}`);
60
+ k = Math.max(k, y + 1);
61
+ }
62
+ return Math.max(k, 2);
63
+ }
64
+
65
+ /** Feature dimension of a non-empty sample matrix; throws on ragged rows. */
66
+ export function featureDim(X: readonly Vec[]): number {
67
+ if (X.length === 0) throw new Error("no samples");
68
+ const d = X[0]!.length;
69
+ for (const x of X) if (x.length !== d) throw new Error(`ragged features: expected ${d}, got ${x.length}`);
70
+ return d;
71
+ }
72
+
73
+ /** Confidence of a probability vector: its top probability. */
74
+ export function topProbability(p: readonly number[]): number {
75
+ return p.length ? Math.max(...p) : 0;
76
+ }
77
+
78
+ /** A deterministic [0,1) source for algorithms that sample — the engine's ONE seeded RNG. */
79
+ export function seededUniform(seed: number): () => number {
80
+ const rng = new SeededRng(seed);
81
+ return () => rng.next();
82
+ }
83
+
84
+ /**
85
+ * Average path length of an unsuccessful BST search over n points — the
86
+ * isolation-forest normaliser c(n).
87
+ */
88
+ export function averagePathLength(n: number): number {
89
+ if (n <= 1) return 0;
90
+ if (n === 2) return 1;
91
+ const harmonic = Math.log(n - 1) + 0.5772156649;
92
+ return 2 * harmonic - (2 * (n - 1)) / n;
93
+ }
@@ -4,6 +4,10 @@
4
4
  * Per class: a weighted prior, and an independent Gaussian per feature. Variance
5
5
  * is smoothed by a fraction of the largest feature variance (as scikit-learn's
6
6
  * `var_smoothing`), so a constant feature never divides by zero.
7
+ *
8
+ * A class with no training weight has prior −∞ and probability exactly 0: its
9
+ * Gaussian is a placeholder (mean 0, variance ≈ the smoothing floor), which would
10
+ * otherwise out-score every real class on an input near zero.
7
11
  */
8
12
  import type { MlPrediction, MlPredictor, MlSample } from "../types.js";
9
13
  import { classCount, featureDim, softmax, topProbability } from "./math.js";
@@ -21,11 +25,12 @@ export class NaiveBayesModel implements MlPredictor {
21
25
  readonly variance: Float64Array[],
22
26
  ) {}
23
27
 
24
- /** Per-class joint log-likelihood log P(c) + Σ log N(x_i | μ, σ²). */
28
+ /** Per-class joint log-likelihood log P(c) + Σ log N(x_i | μ, σ²); −∞ for an unseen class. */
25
29
  jointLogLikelihood(x: ArrayLike<number>): number[] {
26
30
  return this.mean.map((mu, c) => {
27
31
  const v = this.variance[c]!;
28
32
  let ll = this.logPrior[c]!;
33
+ if (ll === -Infinity) return ll;
29
34
  for (let i = 0; i < mu.length; i++) {
30
35
  const d = (x[i] ?? 0) - mu[i]!;
31
36
  ll += -0.5 * Math.log(2 * Math.PI * v[i]!) - (d * d) / (2 * v[i]!);
@@ -71,7 +76,9 @@ export function fitNaiveBayes(samples: readonly MlSample[], opts: NaiveBayesOpti
71
76
  for (let c = 0; c < k; c++) for (let i = 0; i < d; i++) variance[c]![i] = variance[c]![i]! + eps;
72
77
 
73
78
  const total = wSum.reduce((a, b) => a + b, 0);
74
- // An unseen class keeps a vanishing (not -∞) prior so softmax stays defined.
75
- const logPrior = Float64Array.from(wSum, (w) => Math.log(Math.max(w, 1e-12) / total));
79
+ if (!(total > 0)) throw new Error("fitNaiveBayes: the samples carry no weight");
80
+ // An unseen class is masked out (−∞, so softmax gives it exactly 0). At least one
81
+ // class has weight, so softmax always has a finite maximum.
82
+ const logPrior = Float64Array.from(wSum, (w) => (w > 0 ? Math.log(w / total) : -Infinity));
76
83
  return new NaiveBayesModel(logPrior, mean, variance);
77
84
  }
@@ -4,7 +4,10 @@
4
4
  * Per feature: z = |x − median| / (1.4826 · MAD). Median and MAD are not dragged
5
5
  * by the very outliers being looked for, unlike mean and standard deviation. When
6
6
  * MAD is 0 (more than half the history identical) the mean absolute deviation
7
- * stands in, then a floor, so a constant history still scores a change.
7
+ * stands in, then two floors: a fraction of |median| (relative) and `minScale`
8
+ * (absolute, in the feature's own units). Without the absolute one a history that
9
+ * is all zeros — no tool failures, ever — gets a scale of ~1e-9, and the first
10
+ * small non-zero value scores astronomically.
8
11
  *
9
12
  * Prediction: `scores[0] = max_i z_i / threshold` — ≥ 1 is anomalous.
10
13
  */
@@ -21,6 +24,21 @@ export interface RobustZOptions {
21
24
  threshold?: number;
22
25
  /** Smallest scale per feature, as a fraction of |median| (plus 1e-9). Default 0.01. */
23
26
  minScaleFraction?: number;
27
+ /**
28
+ * Smallest scale per feature in absolute feature units: one value for every
29
+ * feature, or one per feature (a missing entry is 0). Default 0 — the algorithm
30
+ * cannot know the units; a task on a fixed scale sets it (`run.anomaly` does,
31
+ * per feature, because each of its metrics is normalised by a different reference).
32
+ */
33
+ minScale?: MinScale;
34
+ }
35
+
36
+ /** An absolute scale floor: one for every feature, or one per feature. */
37
+ export type MinScale = number | readonly number[];
38
+
39
+ /** The floor for feature `i` (a per-feature array's missing entry is 0). */
40
+ export function minScaleAt(minScale: MinScale, i: number): number {
41
+ return typeof minScale === "number" ? minScale : (minScale[i] ?? 0);
24
42
  }
25
43
 
26
44
  export class RobustZModel implements MlPredictor {
@@ -50,6 +68,7 @@ export function fitRobustZ(samples: readonly MlSample[], opts: RobustZOptions =
50
68
  const X = samples.map((s) => s.x);
51
69
  const d = featureDim(X);
52
70
  const minFrac = opts.minScaleFraction ?? 0.01;
71
+ const minAbs: MinScale = opts.minScale ?? 0;
53
72
  const center = new Float64Array(d);
54
73
  const scale = new Float64Array(d);
55
74
  for (let i = 0; i < d; i++) {
@@ -59,7 +78,7 @@ export function fitRobustZ(samples: readonly MlSample[], opts: RobustZOptions =
59
78
  let s = MAD_TO_SD * median(dev);
60
79
  if (!(s > 0)) s = MEANAD_TO_SD * (dev.reduce((a, b) => a + b, 0) / dev.length);
61
80
  center[i] = med;
62
- scale[i] = Math.max(s, minFrac * Math.abs(med) + 1e-9);
81
+ scale[i] = Math.max(s, minFrac * Math.abs(med) + 1e-9, minScaleAt(minAbs, i));
63
82
  }
64
83
  return new RobustZModel(center, scale, opts.threshold ?? 3.5);
65
84
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * ml/eval_loss.ts — the ONE held-out loss for catalog models.
3
+ *
4
+ * What the trainer reports as `holdoutLoss`, what a federated merge is gated on, and
5
+ * what `LogisticModel.loss` returns. A leaf (types only), so a classical algorithm can
6
+ * use it without importing the trainer that imports the algorithms.
7
+ */
8
+ import type { MlPredictor, MlSample, MlTaskKind } from "./types.js";
9
+
10
+ /**
11
+ * Mean loss of `predictor` on `samples` for a task of `kind` — classify: weighted
12
+ * cross-entropy of the true class; regress: weighted ½·squared error. Null for
13
+ * kinds without a supervised loss, or an empty (or zero-weight) set.
14
+ */
15
+ export function mlEvalLoss(kind: MlTaskKind, predictor: MlPredictor, samples: readonly MlSample[]): number | null {
16
+ if (samples.length === 0 || (kind !== "classify" && kind !== "regress")) return null;
17
+ let sum = 0;
18
+ let wSum = 0;
19
+ for (const s of samples) {
20
+ const w = s.weight ?? 1;
21
+ const p = predictor.predict(s.x);
22
+ const loss = kind === "classify" ? -Math.log(Math.max(p.scores[s.y] ?? 0, 1e-12)) : 0.5 * (p.scores[0]! - s.y) ** 2;
23
+ sum += w * loss;
24
+ wSum += w;
25
+ }
26
+ return wSum > 0 ? sum / wSum : null;
27
+ }
@@ -8,62 +8,26 @@
8
8
  * `target: "ml:<task>"`, and the writer folds N of them with the existing FedAvg
9
9
  * (`mergeCheckpointDiffsGated` → `mergeCheckpointDiffs`), refusing a merge whose
10
10
  * held-out loss regresses. No second wire, no second merge.
11
+ *
12
+ * The head's manifest entry and checkpoint format live with the artifact
13
+ * (`evermind/ml_head.ts`); this module only imports evermind/, never the reverse.
11
14
  */
12
- import { buildDeltaLearnPayload, decodeDeltaPayload, deltaUnusableReason, mlHeadTarget, type DeltaLearnPayload } from "../evermind/delta_wire.js";
15
+ import {
16
+ buildDeltaLearnPayload,
17
+ decodeDeltaPayload,
18
+ deltaTargetMismatch,
19
+ deltaUnusableReason,
20
+ mlHeadTarget,
21
+ type DeltaLearnPayload,
22
+ } from "../evermind/delta_wire.js";
13
23
  import { mergeCheckpointDiffsGated, type GatedMergeResult, type MergeGateOptions } from "../evermind/merge.js";
24
+ import { headCheckpoint, headParams, type EvermindMlHead } from "../evermind/ml_head.js";
14
25
  import type { EvermindModelPackage } from "../evermind/package.js";
15
26
  import { base64ToBytes, bytesToBase64 } from "../utils/base64.js";
16
- import { appendCrcTrailer, verifyCrcTrailer } from "../utils/crc32.js";
17
27
  import { diffCheckpoints } from "../utils/delta.js";
18
28
 
19
- /**
20
- * One trained ML-catalog head carried in a model's manifest (`mlHeads`): small, so
21
- * it rides in the manifest, and versioned, so it federates — a delta against it is
22
- * `target: "ml:<task>"`, `baseVersion: version` on the delta wire.
23
- */
24
- export interface EvermindMlHead {
25
- /** Catalog task id, e.g. `run.local-success`. */
26
- task: string;
27
- /** Engine key, e.g. `local:classical:logistic`. */
28
- engine: string;
29
- /** Integer head version — what a federated delta for it is diffed against. */
30
- version: number;
31
- /** What a loader needs to rebuild the predictor (e.g. `{ dim, classes }`). */
32
- shape: Record<string, number>;
33
- /** base64 of the CRC-trailed f32 checkpoint of the head's flat parameters. */
34
- params: string;
35
- /** Held-out loss of this version, when measured. */
36
- evalLoss?: number;
37
- }
38
-
39
- /** A head's flat parameters as a CRC-trailed f32 checkpoint. */
40
- export function headCheckpoint(params: ArrayLike<number>): ArrayBuffer {
41
- const body = new ArrayBuffer(params.length * 4);
42
- new Float32Array(body).set(Array.from(params));
43
- return appendCrcTrailer(body);
44
- }
45
-
46
- /** The flat parameters of a head checkpoint. Throws on a failed CRC. */
47
- export function headParams(checkpoint: ArrayBuffer): Float32Array {
48
- const crc = verifyCrcTrailer(checkpoint);
49
- if (!crc.ok) throw new Error("headParams: checkpoint failed its CRC check");
50
- if (crc.body.byteLength % 4 !== 0) throw new Error("headParams: checkpoint is not f32");
51
- return new Float32Array(crc.body);
52
- }
53
-
54
- /** Why a manifest head entry is unusable, or null (the package's validate() calls this). */
55
- export function mlHeadUnusableReason(h: EvermindMlHead): string | null {
56
- if (!h || typeof h.task !== "string" || typeof h.engine !== "string") return "task and engine are required";
57
- if (!Number.isInteger(h.version) || h.version < 0) return "version must be a non-negative integer";
58
- try {
59
- const crc = verifyCrcTrailer(base64ToBytes(h.params));
60
- if (!crc.hasTrailer || !crc.ok) return "params checkpoint failed its CRC check";
61
- if (crc.body.byteLength % 4 !== 0) return "params checkpoint is not f32";
62
- } catch {
63
- return "params is not base64";
64
- }
65
- return null;
66
- }
29
+ export { headCheckpoint, headParams, mlHeadUnusableReason } from "../evermind/ml_head.js";
30
+ export type { EvermindMlHead } from "../evermind/ml_head.js";
67
31
 
68
32
  /** A manifest entry for a head's current parameters. */
69
33
  export function mlHeadEntry(
@@ -121,6 +85,11 @@ export interface MlHeadMergeOutcome {
121
85
  * target, stale base or a malformed diff costs only itself), FedAvg the rest, and
122
86
  * keep the result only if `score` (held-out loss of a parameter vector, lower is
123
87
  * better) does not regress.
88
+ *
89
+ * Fails CLOSED: `requireEval` defaults to true here, so a round `score` cannot judge
90
+ * (null — e.g. `mlEvalLoss` for an anomaly/cluster head or an empty holdout — or a
91
+ * non-finite loss) is refused. A caller that federates an unscorable head on purpose
92
+ * passes `{ requireEval: false }` explicitly.
124
93
  */
125
94
  export function mergeMlHeadDeltas(
126
95
  base: EvermindMlHead,
@@ -134,7 +103,8 @@ export function mergeMlHeadDeltas(
134
103
  const weights: number[] = [];
135
104
  const rejected: { index: number; reason: string }[] = [];
136
105
  payloads.forEach((p, index) => {
137
- if (p.target !== target) return void rejected.push({ index, reason: `target ${p.target ?? "(model)"} is not ${target}` });
106
+ const wrongTarget = deltaTargetMismatch(p.target, target);
107
+ if (wrongTarget) return void rejected.push({ index, reason: wrongTarget });
138
108
  if (p.baseVersion !== base.version) return void rejected.push({ index, reason: `stale base version ${p.baseVersion} (head is ${base.version})` });
139
109
  let diff: ArrayBuffer;
140
110
  try {
@@ -148,7 +118,7 @@ export function mergeMlHeadDeltas(
148
118
  weights.push(p.weight ?? 1);
149
119
  });
150
120
 
151
- const merge = mergeCheckpointDiffsGated(baseCp, diffs, weights, (cp) => score(headParams(cp)), opts);
121
+ const merge = mergeCheckpointDiffsGated(baseCp, diffs, weights, (cp) => score(headParams(cp)), { ...opts, requireEval: opts.requireEval ?? true });
152
122
  if (!merge.accepted) return { head: base, merge, rejected };
153
123
  const head: EvermindMlHead = {
154
124
  ...base,
@@ -5,8 +5,13 @@
5
5
  *
6
6
  * Full-batch gradient descent with AdamW. When a GPUDevice is supplied the
7
7
  * optimiser step runs on the GPU via the shared WEIGHT_UPDATE_WGSL kernel; with
8
- * no device it uses a numerically-equivalent CPU AdamW, so training works
9
- * everywhere (CI, Node without @webgpu/node, a laptop with no GPU).
8
+ * no device it runs the kernel's CPU mirror, `adamwUpdateInPlace` (`optim/adamw.ts`,
9
+ * NaN/Inf guard included), so training works everywhere (CI, Node without
10
+ * @webgpu/node, a laptop with no GPU). Neither path bounds the step (maxDelta 0).
11
+ *
12
+ * A non-finite gradient element is skipped by both paths — its moments and weight
13
+ * are left as they were, so the next finite step trains it again — and counted in
14
+ * {@link HeadTrainer.skippedNonFinite}, so a caller can see a run that diverged.
10
15
  *
11
16
  * The head owns its forward/backward and its loss (MSE for regression, BCE /
12
17
  * softmax cross-entropy for classification); this file owns only the loop:
@@ -23,6 +28,7 @@ import {
23
28
  cdiv,
24
29
  } from "../utils/gpu_utils.js";
25
30
  import { WEIGHT_UPDATE_WGSL } from "../kernels/weight_update.js";
31
+ import { adamwUpdateInPlace, type AdamWKernelStep } from "../optim/adamw.js";
26
32
 
27
33
  /** A named trainable tensor (flat row-major Float32Array). */
28
34
  export interface HeadParam {
@@ -63,29 +69,19 @@ interface AdamMoment {
63
69
  v: Float32Array;
64
70
  }
65
71
 
66
- interface AdamHyper {
67
- learningRate: number;
68
- weightDecay: number;
69
- beta1: number;
70
- beta2: number;
71
- eps: number;
72
- beta1_t: number;
73
- beta2_t: number;
74
- }
72
+ /** Byte size of WEIGHT_UPDATE_WGSL's `AdamParams` uniform: 1 u32 + 8 f32 + 3 f32 pad = 48. */
73
+ const ADAM_PARAMS_BYTES = 48;
75
74
 
76
- function packAdamParams(
77
- numElements: number,
78
- lr: number,
79
- beta1: number,
80
- beta2: number,
81
- eps: number,
82
- weightDecay: number,
83
- beta1_t: number,
84
- beta2_t: number,
85
- ): ArrayBuffer {
86
- const buf = new ArrayBuffer(32);
75
+ /**
76
+ * Pack `AdamParams` exactly as WEIGHT_UPDATE_WGSL declares it:
77
+ * `num_elements: u32, lr, beta1, beta2, eps, weight_decay, beta1_t, beta2_t,
78
+ * max_delta, _pad0, _pad1, _pad2` (all f32 after the first) — 48 bytes.
79
+ */
80
+ function packAdamParams(numElements: number, hp: AdamWKernelStep): ArrayBuffer {
81
+ const buf = new ArrayBuffer(ADAM_PARAMS_BYTES);
87
82
  new Uint32Array(buf, 0, 1).set([numElements]);
88
- new Float32Array(buf, 4, 7).set([lr, beta1, beta2, eps, weightDecay, beta1_t, beta2_t]);
83
+ // Pads stay 0 (fresh ArrayBuffer).
84
+ new Float32Array(buf, 4, 8).set([hp.lr, hp.beta1, hp.beta2, hp.eps, hp.weightDecay, hp.beta1_t, hp.beta2_t, hp.maxDelta]);
89
85
  return buf;
90
86
  }
91
87
 
@@ -94,6 +90,7 @@ export class HeadTrainer<S> {
94
90
  readonly device: GPUDevice | null;
95
91
  private _moments: AdamMoment[] | null = null;
96
92
  private _step = 0;
93
+ private _skippedNonFinite = 0;
97
94
  private readonly _adamwPipeline: GPUComputePipeline | null;
98
95
 
99
96
  constructor(
@@ -109,6 +106,19 @@ export class HeadTrainer<S> {
109
106
  : null;
110
107
  }
111
108
 
109
+ /**
110
+ * Parameter elements whose gradient was non-finite (NaN/±Inf) and whose update was
111
+ * therefore skipped, summed over every step this trainer has taken. 0 on a healthy run.
112
+ */
113
+ get skippedNonFinite(): number {
114
+ return this._skippedNonFinite;
115
+ }
116
+
117
+ /** Whether any step had to skip a non-finite gradient element. */
118
+ get diverged(): boolean {
119
+ return this._skippedNonFinite > 0;
120
+ }
121
+
112
122
  /** Whether the optimiser step runs on the GPU. */
113
123
  get gpuTraining(): boolean {
114
124
  return this.device != null && this._adamwPipeline != null;
@@ -159,7 +169,7 @@ export class HeadTrainer<S> {
159
169
  this._step++;
160
170
  const beta1_t = Math.pow(beta1, this._step);
161
171
  const beta2_t = Math.pow(beta2, this._step);
162
- const hp: AdamHyper = { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t };
172
+ const hp: AdamWKernelStep = { lr: learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta: 0 };
163
173
  if (this.gpuTraining) {
164
174
  await this._adamwStepGpu(grads, hp);
165
175
  } else {
@@ -181,42 +191,31 @@ export class HeadTrainer<S> {
181
191
  return total / samples.length;
182
192
  }
183
193
 
184
- private _adamwStepCpu(grads: HeadParam[], hp: AdamHyper): void {
194
+ /** AdamW on the CPU via the kernel's shared mirror (`adamwUpdateInPlace`), which counts its skips. */
195
+ private _adamwStepCpu(grads: HeadParam[], hp: AdamWKernelStep): void {
185
196
  const params = this.head.parameters();
186
- const { learningRate: lr, weightDecay: wd, beta1, beta2, eps, beta1_t, beta2_t } = hp;
187
197
  for (let pi = 0; pi < params.length; pi++) {
188
- const p = params[pi]!.data;
189
- const g = grads[pi]!.data;
190
198
  const mom = this._moments![pi]!;
191
- for (let i = 0; i < p.length; i++) {
192
- const gi = g[i]!;
193
- mom.m[i] = beta1 * mom.m[i]! + (1 - beta1) * gi;
194
- mom.v[i] = beta2 * mom.v[i]! + (1 - beta2) * gi * gi;
195
- const mHat = mom.m[i]! / (1 - beta1_t);
196
- const vHat = mom.v[i]! / (1 - beta2_t);
197
- p[i] = p[i]! * (1 - lr * wd) - (lr * mHat) / (Math.sqrt(vHat) + eps);
198
- }
199
+ this._skippedNonFinite += adamwUpdateInPlace(params[pi]!.data, grads[pi]!.data, mom.m, mom.v, hp);
199
200
  }
200
201
  }
201
202
 
202
203
  /** AdamW on the GPU via the shared WEIGHT_UPDATE_WGSL kernel. Awaited per step. */
203
- private async _adamwStepGpu(grads: HeadParam[], hp: AdamHyper): Promise<void> {
204
+ private async _adamwStepGpu(grads: HeadParam[], hp: AdamWKernelStep): Promise<void> {
204
205
  const device = this.device!;
205
206
  const pipeline = this._adamwPipeline!;
206
207
  const params = this.head.parameters();
207
- const { learningRate: lr, weightDecay: wd, beta1, beta2, eps, beta1_t, beta2_t } = hp;
208
208
 
209
209
  for (let pi = 0; pi < params.length; pi++) {
210
210
  const p = params[pi]!;
211
211
  const mom = this._moments![pi]!;
212
+ // The kernel skips these elements too; it cannot count, so count them here.
213
+ this._skippedNonFinite += countNonFinite(grads[pi]!.data);
212
214
  const paramBuf = createStorageBuffer(device, p.data, true);
213
215
  const gradBuf = createStorageBuffer(device, grads[pi]!.data, false);
214
216
  const mBuf = createStorageBuffer(device, mom.m, true);
215
217
  const vBuf = createStorageBuffer(device, mom.v, true);
216
- const uni = createUniformBuffer(
217
- device,
218
- packAdamParams(p.numel, lr, beta1, beta2, eps, wd, beta1_t, beta2_t),
219
- );
218
+ const uni = createUniformBuffer(device, packAdamParams(p.numel, hp));
220
219
  const bg = createBindGroup(device, pipeline, [uni, paramBuf, gradBuf, mBuf, vBuf]);
221
220
  dispatchKernel(device, pipeline, bg, [cdiv(p.numel, 256), 1, 1]);
222
221
 
@@ -233,16 +232,35 @@ export class HeadTrainer<S> {
233
232
  }
234
233
  }
235
234
 
236
- /** Clip grads to `maxNorm` in place; returns the PRE-clip L2 norm (the signal). */
235
+ /** Elements of `a` that are NaN or ±Inf. */
236
+ function countNonFinite(a: Float32Array): number {
237
+ let n = 0;
238
+ for (let i = 0; i < a.length; i++) if (!Number.isFinite(a[i]!)) n++;
239
+ return n;
240
+ }
241
+
242
+ /**
243
+ * Clip grads to `maxNorm` in place; returns the PRE-clip L2 norm (the signal — NaN
244
+ * or Inf when any element is). The clip itself uses the norm of the FINITE elements:
245
+ * those are skipped by the optimiser anyway, and one NaN must not exempt the rest
246
+ * from clipping.
247
+ */
237
248
  function clipGradients(grads: HeadParam[], maxNorm: number): number {
238
249
  let normSq = 0;
239
- for (const g of grads) for (let i = 0; i < g.data.length; i++) normSq += g.data[i]! * g.data[i]!;
240
- const norm = Math.sqrt(normSq);
241
- if (norm > maxNorm && norm > 0) {
242
- const scale = maxNorm / norm;
250
+ let finiteSq = 0;
251
+ for (const g of grads) {
252
+ for (let i = 0; i < g.data.length; i++) {
253
+ const sq = g.data[i]! * g.data[i]!;
254
+ normSq += sq;
255
+ if (Number.isFinite(sq)) finiteSq += sq;
256
+ }
257
+ }
258
+ const finiteNorm = Math.sqrt(finiteSq);
259
+ if (finiteNorm > maxNorm && finiteNorm > 0) {
260
+ const scale = maxNorm / finiteNorm;
243
261
  for (const g of grads) for (let i = 0; i < g.data.length; i++) g.data[i]! *= scale;
244
262
  }
245
- return norm;
263
+ return Math.sqrt(normSq);
246
264
  }
247
265
 
248
266
  /** Concatenate a head's parameters into one flat vector (the federation unit). */