@energy8platform/stake-math-tools 0.1.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/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @energy8platform/stake-math-tools
2
+
3
+ Node-only dev-time utilities for building [Stake Engine](https://stake-engine.com/docs) **lookup tables (force matrices)** from raw simulation output. Companion to [`@energy8platform/stake-bridge`](../stake-bridge).
4
+
5
+ ## Why
6
+
7
+ Stake Engine games ship a pre-built weighted lookup table: each row is `(simulation_number, weight, payout_multiplier_cents)` and the RGS samples a row at runtime to decide each round's outcome. The math team's job is to compress millions of raw simulations down to a much smaller weighted table whose aggregate distribution still hits the design's target RTP / volatility / hit-rate, all under a hard `capMaxWin` ceiling.
8
+
9
+ This package does that compression in one call.
10
+
11
+ ## Architecture
12
+
13
+ ```
14
+ raw simulations (1M–10M rows) ──► optimizeLookupTable(rows, params) ──► lookup table (10K–100K rows)
15
+
16
+ ├─ filter: drop payout > capMaxWin
17
+ ├─ bucketize: zero / log-spaced / near-max
18
+ ├─ sample: stratified weighted-reservoir (A-Res, seeded)
19
+ ├─ solve: Lawson–Hanson NNLS with Tikhonov regularization
20
+ │ + fixed-point iteration on μ̂
21
+ ├─ quantize: largest-remainder, wᵢ ≥ 1, exact Σ
22
+ └─ verify-and-retry: up to maxIterations expand-resample
23
+ ```
24
+
25
+ Six-phase pipeline. Each phase is its own module and is independently tested. Determinism is preserved through a single `seed` parameter that threads all RNG calls.
26
+
27
+ Design rationale and trade-offs: [`docs/superpowers/specs/2026-05-08-stake-lookup-optimizer-design.md`](../../docs/superpowers/specs/2026-05-08-stake-lookup-optimizer-design.md).
28
+
29
+ ## Install
30
+
31
+ The package is a monorepo workspace member; consumers inside the repo just import it. It is not published to npm.
32
+
33
+ ## Quick start
34
+
35
+ ```ts
36
+ import { optimizeLookupTable, type LookupRow } from '@energy8platform/stake-math-tools';
37
+
38
+ // 1. Parse simulation dump (CSV → array). No CSV parser is included on purpose —
39
+ // the math team's pipeline already has one. The input is just an Iterable<LookupRow>.
40
+ const rows: LookupRow[] = parseCsv('./sim_output.csv');
41
+
42
+ // 2. Compress into a target lookup table.
43
+ const result = optimizeLookupTable(rows, {
44
+ targetRTP: 0.96, toleranceRTP: 0.0005,
45
+ targetCV: 8.0, toleranceCV: 0.1,
46
+ targetHitRate: 0.30, toleranceHitRate: 0.01,
47
+ capMaxWin: 5_000_000, // payout cents (50000.00x)
48
+ nRowsOut: 50_000,
49
+ });
50
+
51
+ // 3. Inspect the achieved metrics + tolerance status.
52
+ if (!result.toleranceMet.rtp || !result.toleranceMet.cv) {
53
+ console.warn('targets not fully met:', result.warnings);
54
+ }
55
+ console.log(result.achieved); // { rtp, cv, hitRate, maxPayout, totalWeight }
56
+
57
+ // 4. Write rows out in the format Stake expects.
58
+ writeCsv('./force_base.csv', result.rows);
59
+ ```
60
+
61
+ ## Public API
62
+
63
+ | Export | Purpose |
64
+ |---|---|
65
+ | `optimizeLookupTable(rows, params)` | The main entry. Six-phase pipeline. |
66
+ | `computeMetrics(rows)` | Weighted RTP / CV / hit-rate / maxPayout. BigInt-safe accumulators. |
67
+ | `bucketize(rows, opts)` | Zero / log-spaced / near-max partition. |
68
+ | `mulberry32(seed)` | Tiny deterministic PRNG. |
69
+ | `weightedReservoirSample(indices, weights, k, rng)` | Algorithm A-Res. |
70
+ | `computeQuotas(buckets, params)` | Min-per-bucket + variance-proportional distribution. |
71
+ | `stratifiedSample(buckets, rows, quotas, rng)` | Apply quotas via A-Res, dedupe overlaps. |
72
+ | `solveNNLS(A, b, opts?)` | Lawson–Hanson NNLS with Tikhonov regularization. |
73
+ | `quantizeWeights(weights, total)` | Largest-remainder, `wᵢ ≥ 1`, exact `Σ = total`. |
74
+
75
+ Lower-level pieces are exported so the math pipeline can build alternative compressors or test individual phases in isolation. Internal helpers (`lawsonHansonNNLS`, `solveLS`) are not exported.
76
+
77
+ Full types in [`src/types.ts`](./src/types.ts).
78
+
79
+ ## `optimizeLookupTable(rows, params)`
80
+
81
+ | Param | Type | Default | Description |
82
+ |---|---|---|---|
83
+ | `targetRTP` | `number` | *(required)* | E.g. `0.96`. |
84
+ | `toleranceRTP` | `number` | *(required)* | Acceptable `±` deviation. |
85
+ | `targetCV` | `number` | *(required)* | Coefficient of variation (volatility). |
86
+ | `toleranceCV` | `number` | *(required)* | |
87
+ | `targetHitRate` | `number` | *(required)* | Fraction of weighted spins with payout > 0. |
88
+ | `toleranceHitRate` | `number` | *(required)* | |
89
+ | `capMaxWin` | `number` | *(required)* | Hard cap in payout cents. Rows above are dropped. |
90
+ | `nRowsOut` | `number` | *(required)* | Exact output size. |
91
+ | `requireMaxReached` | `boolean` | `true` | Force ≥ 1 output row near the cap. |
92
+ | `maxReachedFraction` | `number` | `0.95` | What counts as "near" the cap. |
93
+ | `totalWeightOut` | `number` | `nRowsOut × 1_000_000` | Sum of integer output weights. |
94
+ | `seed` | `number` | `0xC0FFEE` | Sampling RNG seed. |
95
+ | `maxIterations` | `number` | `5` | Expand-and-retry attempts on tolerance miss. |
96
+ | `bucketCount` | `number` | `100` | Log-buckets between min-nonzero and cap. |
97
+ | `minPerBucket` | `number` | `3` | Min sample slots per non-empty non-zero bucket. |
98
+
99
+ Returns `{ rows, achieved, toleranceMet, warnings }`. Never throws on tolerance miss — returns the best-effort result and lists what missed in `warnings`. Only throws when the input has fewer than `nRowsOut` rows after filtering, or `totalWeightOut < nRowsOut`.
100
+
101
+ Determinism: same `(rows, params)` produces bit-identical output. Different `seed` produces a different (also reproducible) draw.
102
+
103
+ ## Scripts
104
+
105
+ ```bash
106
+ npm test # 36 vitest tests, including a 1M-row smoke test (~1.3s)
107
+ npm run typecheck # tsc --noEmit
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@energy8platform/stake-math-tools",
3
+ "version": "0.1.0",
4
+ "description": "Node-only dev-time math utilities for the Energy8 Stake bridge: lookup-table (force matrix) builder",
5
+ "author": "Energy8 Platform",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "type": "module",
9
+ "main": "src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "scripts": {
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.0.0",
20
+ "vitest": "^1.6.0"
21
+ }
22
+ }
@@ -0,0 +1,99 @@
1
+ // src/bucketize.ts
2
+ import type { LookupRow } from './types.js';
3
+
4
+ export interface Bucket {
5
+ /** Indices into the original rows array. */
6
+ indices: number[];
7
+ /** Σ weight of rows in this bucket. */
8
+ totalWeight: number;
9
+ /** Σ (weight × payout) — used by stratified sampling for variance-contribution heuristic. */
10
+ weightedPayoutSum: number;
11
+ }
12
+
13
+ export interface BucketizeResult {
14
+ zeroBucket: Bucket;
15
+ /** Length = bucketCount; some buckets may be empty (totalWeight = 0). */
16
+ logBuckets: Bucket[];
17
+ /** Rows with payout ≥ maxReachedFraction × capMaxWin. May overlap with the top log bucket. */
18
+ nearMaxBucket: Bucket;
19
+ }
20
+
21
+ export interface BucketizeOptions {
22
+ capMaxWin: number;
23
+ bucketCount: number;
24
+ maxReachedFraction: number;
25
+ }
26
+
27
+ /**
28
+ * Partitions rows into:
29
+ * - one zero-payout bucket
30
+ * - `bucketCount` log-spaced buckets between min-nonzero payout and capMaxWin
31
+ * - one near-max bucket (payout ≥ maxReachedFraction × capMaxWin)
32
+ *
33
+ * The near-max bucket overlaps with the top log bucket(s) — the optimizer uses it
34
+ * to enforce the "max-reached" constraint in phase 3 (sampling), not to displace
35
+ * the log buckets.
36
+ *
37
+ * Caller is expected to have already filtered `payoutCents > capMaxWin` rows out.
38
+ * If any slip through, they are placed into the top log bucket but trip nothing —
39
+ * defensive behavior.
40
+ */
41
+ export function bucketize(
42
+ rows: ReadonlyArray<LookupRow>,
43
+ options: BucketizeOptions,
44
+ ): BucketizeResult {
45
+ const { capMaxWin, bucketCount, maxReachedFraction } = options;
46
+
47
+ // First pass: find min-nonzero payout
48
+ let minNonzero = Infinity;
49
+ for (const r of rows) {
50
+ if (r.payoutCents > 0 && r.payoutCents < minNonzero) minNonzero = r.payoutCents;
51
+ }
52
+ // If there's no nonzero payout at all, log buckets are empty
53
+ const hasNonzero = isFinite(minNonzero);
54
+
55
+ const logMin = hasNonzero ? Math.log(minNonzero) : 0;
56
+ const logMax = Math.log(Math.max(minNonzero, capMaxWin));
57
+ const logSpan = Math.max(logMax - logMin, 1e-9);
58
+ const nearMaxThreshold = maxReachedFraction * capMaxWin;
59
+
60
+ const zeroBucket: Bucket = { indices: [], totalWeight: 0, weightedPayoutSum: 0 };
61
+ const logBuckets: Bucket[] = Array.from({ length: bucketCount }, () => ({
62
+ indices: [],
63
+ totalWeight: 0,
64
+ weightedPayoutSum: 0,
65
+ }));
66
+ const nearMaxBucket: Bucket = { indices: [], totalWeight: 0, weightedPayoutSum: 0 };
67
+
68
+ for (let i = 0; i < rows.length; i++) {
69
+ const r = rows[i];
70
+ if (r.payoutCents === 0) {
71
+ zeroBucket.indices.push(i);
72
+ zeroBucket.totalWeight += r.weight;
73
+ // weightedPayoutSum stays 0
74
+ continue;
75
+ }
76
+
77
+ // Pick log bucket
78
+ let bucketIdx: number;
79
+ if (!hasNonzero || logSpan === 0) {
80
+ bucketIdx = 0;
81
+ } else {
82
+ const t = (Math.log(r.payoutCents) - logMin) / logSpan;
83
+ bucketIdx = Math.min(bucketCount - 1, Math.max(0, Math.floor(t * bucketCount)));
84
+ }
85
+ const b = logBuckets[bucketIdx];
86
+ b.indices.push(i);
87
+ b.totalWeight += r.weight;
88
+ b.weightedPayoutSum += r.weight * r.payoutCents;
89
+
90
+ // Near-max bucket (overlaps top log buckets — that's intentional)
91
+ if (r.payoutCents >= nearMaxThreshold) {
92
+ nearMaxBucket.indices.push(i);
93
+ nearMaxBucket.totalWeight += r.weight;
94
+ nearMaxBucket.weightedPayoutSum += r.weight * r.payoutCents;
95
+ }
96
+ }
97
+
98
+ return { zeroBucket, logBuckets, nearMaxBucket };
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // src/index.ts
2
+ export { optimizeLookupTable } from './optimize-lookup.js';
3
+ export type {
4
+ LookupRow,
5
+ OptimizeParams,
6
+ OptimizeResult,
7
+ OptimizeAchieved,
8
+ ToleranceMet,
9
+ } from './types.js';
10
+
11
+ // Lower-level pieces — exposed so callers can build alternative pipelines or test in isolation.
12
+ export { computeMetrics, isNearMax } from './metrics.js';
13
+ export { bucketize } from './bucketize.js';
14
+ export type { Bucket, BucketizeResult, BucketizeOptions } from './bucketize.js';
15
+ export { mulberry32, weightedReservoirSample, computeQuotas, stratifiedSample } from './sample.js';
16
+ export type { Quotas, QuotaInput, QuotaParams } from './sample.js';
17
+ export { solveNNLS } from './nnls.js';
18
+ export type { NNLSOptions } from './nnls.js';
19
+ export { quantizeWeights } from './quantize.js';
package/src/metrics.ts ADDED
@@ -0,0 +1,55 @@
1
+ // src/metrics.ts
2
+ import type { LookupRow, OptimizeAchieved } from './types.js';
3
+
4
+ /**
5
+ * Computes weighted aggregate metrics over an array of rows.
6
+ *
7
+ * RTP = Σ(w·payout) / (Σw · 100) // payout is cents-int (×100), bet unit = 100 cents
8
+ * mean = Σ(w·payout) / Σw
9
+ * var = Σ(w·(payout − mean)²) / Σw
10
+ * CV = √var / mean // 0 when mean = 0
11
+ * hitRate = Σ_{payout>0} w / Σw
12
+ *
13
+ * Uses BigInt for the Σ accumulators to be safe against overflow on large input
14
+ * weights (e.g. ~2e11 per row × 10M rows × payout² up to 1e12 ≈ 2e33).
15
+ */
16
+ export function computeMetrics(rows: ReadonlyArray<LookupRow>): OptimizeAchieved {
17
+ let totalW = 0n;
18
+ let sumWPayout = 0n;
19
+ let sumWPayout2 = 0n;
20
+ let nonzeroW = 0n;
21
+ let maxPayout = 0;
22
+
23
+ for (const r of rows) {
24
+ const w = BigInt(r.weight);
25
+ const p = BigInt(r.payoutCents);
26
+ totalW += w;
27
+ sumWPayout += w * p;
28
+ sumWPayout2 += w * p * p;
29
+ if (r.payoutCents > 0) nonzeroW += w;
30
+ if (r.payoutCents > maxPayout) maxPayout = r.payoutCents;
31
+ }
32
+
33
+ const totalWeight = Number(totalW);
34
+ if (totalWeight === 0) {
35
+ return { rtp: 0, cv: 0, hitRate: 0, maxPayout: 0, totalWeight: 0 };
36
+ }
37
+
38
+ const mean = Number(sumWPayout) / totalWeight;
39
+ const meanSq = Number(sumWPayout2) / totalWeight;
40
+ const variance = Math.max(0, meanSq - mean * mean);
41
+ const stddev = Math.sqrt(variance);
42
+
43
+ return {
44
+ rtp: mean / 100,
45
+ cv: mean === 0 ? 0 : stddev / mean,
46
+ hitRate: Number(nonzeroW) / totalWeight,
47
+ maxPayout,
48
+ totalWeight,
49
+ };
50
+ }
51
+
52
+ /** True when payout reaches the configured fraction of the cap. */
53
+ export function isNearMax(payoutCents: number, capMaxWin: number, fraction: number): boolean {
54
+ return payoutCents >= fraction * capMaxWin;
55
+ }
package/src/nnls.ts ADDED
@@ -0,0 +1,202 @@
1
+ // src/nnls.ts
2
+
3
+ export interface NNLSOptions {
4
+ /** Tikhonov prior: regularize toward x ≈ prior. Default zero vector. */
5
+ prior?: ReadonlyArray<number>;
6
+ /** Tikhonov coefficient ε (default 0). When > 0, makes underdetermined problems well-posed. */
7
+ regularization?: number;
8
+ /** Max NNLS iterations. Default 3 × n. */
9
+ maxIterations?: number;
10
+ /** Tolerance for treating a value as zero. Default 1e-12. */
11
+ tolerance?: number;
12
+ }
13
+
14
+ /**
15
+ * Solve `min ||A x − b||² + ε ||x − prior||² s.t. x ≥ 0` via Lawson–Hanson NNLS.
16
+ *
17
+ * A is m×n (rows = features, cols = variables). m ≪ n is permitted thanks to ε > 0.
18
+ *
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.
22
+ */
23
+ export function solveNNLS(
24
+ A: ReadonlyArray<ReadonlyArray<number>>,
25
+ b: ReadonlyArray<number>,
26
+ options: NNLSOptions = {},
27
+ ): number[] {
28
+ const m = A.length;
29
+ const n = m === 0 ? 0 : A[0].length;
30
+ const epsilon = options.regularization ?? 0;
31
+ const prior = options.prior ?? new Array(n).fill(0);
32
+ const tol = options.tolerance ?? 1e-12;
33
+ const maxIter = options.maxIterations ?? 3 * Math.max(1, n);
34
+
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
+ }
52
+
53
+ return lawsonHansonNNLS(Ah, bh, n, tol, maxIter);
54
+ }
55
+
56
+ /**
57
+ * Lawson–Hanson active-set NNLS, matrix form. Returns x ≥ 0 minimizing ||A x − b||².
58
+ *
59
+ * Variables:
60
+ * P (passive set): indices where x_i > 0, x_i is "free"
61
+ * Z (active set): indices where x_i = 0, x_i is "constrained"
62
+ * w = Aᵀ(b − Ax) — gradient of the residual squared (negated)
63
+ *
64
+ * Outer loop: pick the most negative-gradient index from Z, move it to P.
65
+ * Inner loop: solve unconstrained LS on P; if any x_i ≤ 0, perform an interpolation
66
+ * back to the boundary and move violators to Z; repeat.
67
+ */
68
+ function lawsonHansonNNLS(
69
+ A: number[][],
70
+ b: number[],
71
+ n: number,
72
+ tol: number,
73
+ maxIter: number,
74
+ ): number[] {
75
+ const m = A.length;
76
+ const x = new Array(n).fill(0);
77
+ const inP = new Array(n).fill(false);
78
+ let iter = 0;
79
+
80
+ while (iter++ < maxIter) {
81
+ // residual r = b − A x
82
+ const r = b.slice();
83
+ for (let i = 0; i < m; i++) {
84
+ let s = 0;
85
+ for (let j = 0; j < n; j++) s += A[i][j] * x[j];
86
+ r[i] -= s;
87
+ }
88
+ // w = Aᵀ r
89
+ const w = new Array(n).fill(0);
90
+ for (let j = 0; j < n; j++) {
91
+ let s = 0;
92
+ for (let i = 0; i < m; i++) s += A[i][j] * r[i];
93
+ w[j] = s;
94
+ }
95
+
96
+ // Pick j* in Z with max w[j]
97
+ let jStar = -1;
98
+ let wMax = tol;
99
+ for (let j = 0; j < n; j++) {
100
+ if (!inP[j] && w[j] > wMax) {
101
+ wMax = w[j];
102
+ jStar = j;
103
+ }
104
+ }
105
+ if (jStar < 0) break; // KKT satisfied
106
+
107
+ inP[jStar] = true;
108
+
109
+ // Inner loop
110
+ let inner = 0;
111
+ while (inner++ < maxIter) {
112
+ // Solve LS over P only
113
+ const pIdx: number[] = [];
114
+ for (let j = 0; j < n; j++) if (inP[j]) pIdx.push(j);
115
+ const sP = solveLS(A, b, pIdx);
116
+ // Build full s
117
+ const s = new Array(n).fill(0);
118
+ for (let k = 0; k < pIdx.length; k++) s[pIdx[k]] = sP[k];
119
+
120
+ let minS = Infinity;
121
+ for (const j of pIdx) if (s[j] < minS) minS = s[j];
122
+
123
+ if (minS > tol) {
124
+ // All passive coords positive — accept and break inner
125
+ for (let j = 0; j < n; j++) x[j] = s[j];
126
+ break;
127
+ }
128
+
129
+ // Find α = min over j∈P with s[j]≤0 of x[j]/(x[j]−s[j])
130
+ let alpha = Infinity;
131
+ for (const j of pIdx) {
132
+ if (s[j] <= tol) {
133
+ const denom = x[j] - s[j];
134
+ if (denom > tol) {
135
+ const a = x[j] / denom;
136
+ if (a < alpha) alpha = a;
137
+ }
138
+ }
139
+ }
140
+ if (!isFinite(alpha)) break; // numerical degenerate — bail
141
+
142
+ // x = x + α (s − x), then move violators to Z
143
+ for (let j = 0; j < n; j++) x[j] = x[j] + alpha * (s[j] - x[j]);
144
+ for (let j = 0; j < n; j++) {
145
+ if (inP[j] && Math.abs(x[j]) < tol) {
146
+ x[j] = 0;
147
+ inP[j] = false;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return x;
153
+ }
154
+
155
+ /**
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
158
+ * with Gaussian elimination — adequate for the small passive sets that arise in
159
+ * Tikhonov-regularized NNLS (|P| ≤ m + a few extras at convergence).
160
+ */
161
+ function solveLS(A: number[][], b: number[], pIdx: ReadonlyArray<number>): number[] {
162
+ const m = A.length;
163
+ const k = pIdx.length;
164
+ if (k === 0) return [];
165
+
166
+ // Form normal equations: G = A_Pᵀ A_P (k×k), h = A_Pᵀ b (k)
167
+ const G: number[][] = Array.from({ length: k }, () => new Array(k + 1).fill(0));
168
+ for (let a = 0; a < k; a++) {
169
+ for (let bb = a; bb < k; bb++) {
170
+ let s = 0;
171
+ for (let i = 0; i < m; i++) s += A[i][pIdx[a]] * A[i][pIdx[bb]];
172
+ G[a][bb] = s;
173
+ G[bb][a] = s;
174
+ }
175
+ let s = 0;
176
+ for (let i = 0; i < m; i++) s += A[i][pIdx[a]] * b[i];
177
+ G[a][k] = s;
178
+ }
179
+
180
+ // Gaussian elimination with partial pivoting
181
+ for (let col = 0; col < k; col++) {
182
+ let pivot = col;
183
+ for (let r = col + 1; r < k; r++) if (Math.abs(G[r][col]) > Math.abs(G[pivot][col])) pivot = r;
184
+ if (pivot !== col) [G[col], G[pivot]] = [G[pivot], G[col]];
185
+ if (Math.abs(G[col][col]) < 1e-14) {
186
+ // Singular — fall back to zero for this column to keep the algorithm progressing
187
+ G[col][col] = 1e-14;
188
+ }
189
+ for (let r = col + 1; r < k; r++) {
190
+ const f = G[r][col] / G[col][col];
191
+ for (let c = col; c <= k; c++) G[r][c] -= f * G[col][c];
192
+ }
193
+ }
194
+ // Back-substitution
195
+ const x = new Array(k).fill(0);
196
+ for (let r = k - 1; r >= 0; r--) {
197
+ let s = G[r][k];
198
+ for (let c = r + 1; c < k; c++) s -= G[r][c] * x[c];
199
+ x[r] = s / G[r][r];
200
+ }
201
+ return x;
202
+ }