@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 +112 -0
- package/package.json +22 -0
- package/src/bucketize.ts +99 -0
- package/src/index.ts +19 -0
- package/src/metrics.ts +55 -0
- package/src/nnls.ts +202 -0
- package/src/optimize-lookup.ts +200 -0
- package/src/quantize.ts +73 -0
- package/src/sample.ts +278 -0
- package/src/types.ts +62 -0
- package/test/__snapshots__/sample.test.ts.snap +9 -0
- package/test/bucketize.test.ts +60 -0
- package/test/metrics.test.ts +61 -0
- package/test/nnls.test.ts +60 -0
- package/test/optimize-lookup.integration.test.ts +141 -0
- package/test/optimize-lookup.unit.test.ts +118 -0
- package/test/quantize.test.ts +41 -0
- package/test/sample.test.ts +123 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +8 -0
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
|
+
}
|
package/src/bucketize.ts
ADDED
|
@@ -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
|
+
}
|