@energy8platform/stake-math-tools 0.1.0 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/stake-math-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Node-only dev-time math utilities for the Energy8 Stake bridge: lookup-table (force matrix) builder",
5
5
  "author": "Energy8 Platform",
6
6
  "license": "MIT",
package/src/nnls.ts CHANGED
@@ -17,8 +17,9 @@ export interface NNLSOptions {
17
17
  * A is m×n (rows = features, cols = variables). m ≪ n is permitted thanks to ε > 0.
18
18
  *
19
19
  * Algorithm: classical active-set NNLS as in Lawson & Hanson §23.3. The Tikhonov term
20
- * is folded in by appending √ε · I to A and √ε · prior to b the augmented system
21
- * (m+n) × n is then well-posed for all passive subsets.
20
+ * is applied *implicitly* we never materialize the √ε · I block. Folding it into
21
+ * the gradient and the normal equations keeps the storage at O(m · n) instead of
22
+ * O(n²), which matters when n can reach 10⁵.
22
23
  */
23
24
  export function solveNNLS(
24
25
  A: ReadonlyArray<ReadonlyArray<number>>,
@@ -32,34 +33,25 @@ export function solveNNLS(
32
33
  const tol = options.tolerance ?? 1e-12;
33
34
  const maxIter = options.maxIterations ?? 3 * Math.max(1, n);
34
35
 
35
- // Augment: A_aug = [A; √ε I], b_aug = [b; √ε · prior]
36
- const sqrtEps = Math.sqrt(epsilon);
37
- const M = m + (epsilon > 0 ? n : 0);
38
- const Ah: number[][] = new Array(M);
39
- const bh: number[] = new Array(M);
40
- for (let i = 0; i < m; i++) {
41
- Ah[i] = A[i].slice();
42
- bh[i] = b[i];
43
- }
44
- if (epsilon > 0) {
45
- for (let j = 0; j < n; j++) {
46
- const row = new Array(n).fill(0);
47
- row[j] = sqrtEps;
48
- Ah[m + j] = row;
49
- bh[m + j] = sqrtEps * prior[j];
50
- }
51
- }
36
+ // No augmentation: A stays m×n. Tikhonov enters only via the gradient
37
+ // (in lawsonHansonNNLS) and the normal equations (in solveLS).
38
+ // Shallow-copy A to a mutable number[][] for the inner routine.
39
+ const Ah: number[][] = new Array(m);
40
+ for (let i = 0; i < m; i++) Ah[i] = A[i].slice();
41
+ const bh: number[] = b.slice();
52
42
 
53
- return lawsonHansonNNLS(Ah, bh, n, tol, maxIter);
43
+ return lawsonHansonNNLS(Ah, bh, n, tol, maxIter, epsilon, prior);
54
44
  }
55
45
 
56
46
  /**
57
- * Lawson–Hanson active-set NNLS, matrix form. Returns x ≥ 0 minimizing ||A x − b||².
47
+ * Lawson–Hanson active-set NNLS, matrix form. Returns x ≥ 0 minimizing
48
+ * ||A x − b||² + ε ||x − prior||².
58
49
  *
59
50
  * Variables:
60
51
  * P (passive set): indices where x_i > 0, x_i is "free"
61
52
  * Z (active set): indices where x_i = 0, x_i is "constrained"
62
- * w = Aᵀ(bAx) — gradient of the residual squared (negated)
53
+ * w = A_augᵀ(b_augA_aug x) — gradient of the augmented residual squared (negated).
54
+ * Split as w_j = (Aᵀ(b − A x))_j + ε · (prior_j − x_j).
63
55
  *
64
56
  * Outer loop: pick the most negative-gradient index from Z, move it to P.
65
57
  * Inner loop: solve unconstrained LS on P; if any x_i ≤ 0, perform an interpolation
@@ -71,6 +63,8 @@ function lawsonHansonNNLS(
71
63
  n: number,
72
64
  tol: number,
73
65
  maxIter: number,
66
+ epsilon: number,
67
+ prior: ReadonlyArray<number>,
74
68
  ): number[] {
75
69
  const m = A.length;
76
70
  const x = new Array(n).fill(0);
@@ -78,18 +72,19 @@ function lawsonHansonNNLS(
78
72
  let iter = 0;
79
73
 
80
74
  while (iter++ < maxIter) {
81
- // residual r = b − A x
75
+ // residual r = b − A x (against the un-augmented A only)
82
76
  const r = b.slice();
83
77
  for (let i = 0; i < m; i++) {
84
78
  let s = 0;
85
79
  for (let j = 0; j < n; j++) s += A[i][j] * x[j];
86
80
  r[i] -= s;
87
81
  }
88
- // w = Aᵀ r
82
+ // w = Aᵀ r + ε · (prior − x) ← implicit Tikhonov in the gradient
89
83
  const w = new Array(n).fill(0);
90
84
  for (let j = 0; j < n; j++) {
91
85
  let s = 0;
92
86
  for (let i = 0; i < m; i++) s += A[i][j] * r[i];
87
+ if (epsilon > 0) s += epsilon * (prior[j] - x[j]);
93
88
  w[j] = s;
94
89
  }
95
90
 
@@ -112,7 +107,7 @@ function lawsonHansonNNLS(
112
107
  // Solve LS over P only
113
108
  const pIdx: number[] = [];
114
109
  for (let j = 0; j < n; j++) if (inP[j]) pIdx.push(j);
115
- const sP = solveLS(A, b, pIdx);
110
+ const sP = solveLS(A, b, pIdx, epsilon, prior);
116
111
  // Build full s
117
112
  const s = new Array(n).fill(0);
118
113
  for (let k = 0; k < pIdx.length; k++) s[pIdx[k]] = sP[k];
@@ -153,12 +148,23 @@ function lawsonHansonNNLS(
153
148
  }
154
149
 
155
150
  /**
156
- * Solve unconstrained LS for the passive subset: argmin ‖A_P x_P − b‖² where A_P
157
- * is the columns of A indexed by `pIdx`. Uses normal equations (A_Pᵀ A_P) x = A_Pᵀ b
151
+ * Solve unconstrained LS for the passive subset: argmin ‖A_P x_P − b‖² + ε ‖x_P − prior_P‖²
152
+ * where A_P is the columns of A indexed by `pIdx`. Uses normal equations
153
+ * (A_Pᵀ A_P + ε I) x = A_Pᵀ b + ε · prior_P
158
154
  * with Gaussian elimination — adequate for the small passive sets that arise in
159
155
  * Tikhonov-regularized NNLS (|P| ≤ m + a few extras at convergence).
156
+ *
157
+ * The Tikhonov term enters as +ε on the Gram diagonal and +ε·prior on the RHS,
158
+ * which is exactly what augmenting A with √ε · I would produce — without the
159
+ * O(n²) storage.
160
160
  */
161
- function solveLS(A: number[][], b: number[], pIdx: ReadonlyArray<number>): number[] {
161
+ function solveLS(
162
+ A: number[][],
163
+ b: number[],
164
+ pIdx: ReadonlyArray<number>,
165
+ epsilon = 0,
166
+ prior?: ReadonlyArray<number>,
167
+ ): number[] {
162
168
  const m = A.length;
163
169
  const k = pIdx.length;
164
170
  if (k === 0) return [];
@@ -177,6 +183,14 @@ function solveLS(A: number[][], b: number[], pIdx: ReadonlyArray<number>): numbe
177
183
  G[a][k] = s;
178
184
  }
179
185
 
186
+ // Implicit Tikhonov: add ε to the Gram diagonal and ε·prior to the RHS.
187
+ if (epsilon > 0) {
188
+ for (let col = 0; col < k; col++) {
189
+ G[col][col] += epsilon;
190
+ if (prior !== undefined) G[col][k] += epsilon * prior[pIdx[col]];
191
+ }
192
+ }
193
+
180
194
  // Gaussian elimination with partial pivoting
181
195
  for (let col = 0; col < k; col++) {
182
196
  let pivot = col;
@@ -138,4 +138,39 @@ describe('integration', () => {
138
138
  for (const r of result.rows) sum += r.weight;
139
139
  expect(sum).toBe(1000 * 1_000_000);
140
140
  });
141
+
142
+ it('6. handles nRowsOut=5000 without n² memory blowup', () => {
143
+ // Pre-fix this would allocate a 5000×5000 dense matrix (200 MB Float64);
144
+ // after the implicit-Tikhonov fix it should fit in well under 100 MB and
145
+ // complete in a few seconds.
146
+ const rng = makeRng(6);
147
+ const rows: LookupRow[] = new Array(200_000);
148
+ for (let i = 0; i < 200_000; i++) {
149
+ const u = rng();
150
+ let p = 0;
151
+ if (u > 0.7) p = Math.floor(rng() * 200);
152
+ if (u > 0.97) p = Math.floor(rng() * 5_000);
153
+ if (u > 0.999) p = Math.floor(rng() * 50_000);
154
+ rows[i] = { sim: i, weight: 1 + Math.floor(rng() * 10), payoutCents: p };
155
+ }
156
+
157
+ const t0 = performance.now();
158
+ const result = optimizeLookupTable(rows, {
159
+ targetRTP: 0.5, toleranceRTP: 0.2,
160
+ targetCV: 3, toleranceCV: 5,
161
+ targetHitRate: 0.30, toleranceHitRate: 0.1,
162
+ capMaxWin: 50_000,
163
+ nRowsOut: 5_000,
164
+ requireMaxReached: false,
165
+ maxIterations: 1, // single pass — we're testing memory, not convergence
166
+ });
167
+ const elapsed = performance.now() - t0;
168
+
169
+ expect(result.rows).toHaveLength(5_000);
170
+ // Should be well under the testTimeout (30s). 60s as a generous upper bound.
171
+ expect(elapsed).toBeLessThan(60_000);
172
+ let sum = 0;
173
+ for (const r of result.rows) sum += r.weight;
174
+ expect(sum).toBe(5_000 * 1_000_000);
175
+ });
141
176
  });