@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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// test/metrics.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computeMetrics, isNearMax } from '../src/metrics.js';
|
|
4
|
+
import type { LookupRow } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
describe('computeMetrics', () => {
|
|
7
|
+
it('returns weighted RTP, CV, hitRate, maxPayout, totalWeight on a hand-checked input', () => {
|
|
8
|
+
// 4 rows, weight=1 each: payouts 0, 100, 200, 100
|
|
9
|
+
// mean payout = (0 + 100 + 200 + 100) / 4 = 100
|
|
10
|
+
// RTP = mean / 100 = 1.0
|
|
11
|
+
// var = ((0-100)^2 + 0 + (200-100)^2 + 0) / 4 = 5000
|
|
12
|
+
// stddev = sqrt(5000) ≈ 70.7106781
|
|
13
|
+
// CV = stddev / mean ≈ 0.7071068
|
|
14
|
+
// hitRate = 3/4 = 0.75
|
|
15
|
+
const rows: LookupRow[] = [
|
|
16
|
+
{ sim: 1, weight: 1, payoutCents: 0 },
|
|
17
|
+
{ sim: 2, weight: 1, payoutCents: 100 },
|
|
18
|
+
{ sim: 3, weight: 1, payoutCents: 200 },
|
|
19
|
+
{ sim: 4, weight: 1, payoutCents: 100 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const m = computeMetrics(rows);
|
|
23
|
+
|
|
24
|
+
expect(m.totalWeight).toBe(4);
|
|
25
|
+
expect(m.rtp).toBeCloseTo(1.0, 10);
|
|
26
|
+
expect(m.maxPayout).toBe(200);
|
|
27
|
+
expect(m.hitRate).toBeCloseTo(0.75, 10);
|
|
28
|
+
expect(m.cv).toBeCloseTo(Math.sqrt(5000) / 100, 10);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('honors weights (non-uniform)', () => {
|
|
32
|
+
// 2 rows: (w=3, p=0), (w=1, p=400) → totalW=4, mean=100, RTP=1.0, hitRate=0.25
|
|
33
|
+
const rows: LookupRow[] = [
|
|
34
|
+
{ sim: 1, weight: 3, payoutCents: 0 },
|
|
35
|
+
{ sim: 2, weight: 1, payoutCents: 400 },
|
|
36
|
+
];
|
|
37
|
+
const m = computeMetrics(rows);
|
|
38
|
+
expect(m.rtp).toBeCloseTo(1.0, 10);
|
|
39
|
+
expect(m.hitRate).toBeCloseTo(0.25, 10);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns CV=0 and rtp=0 when all payouts are zero', () => {
|
|
43
|
+
const rows: LookupRow[] = [
|
|
44
|
+
{ sim: 1, weight: 5, payoutCents: 0 },
|
|
45
|
+
{ sim: 2, weight: 7, payoutCents: 0 },
|
|
46
|
+
];
|
|
47
|
+
const m = computeMetrics(rows);
|
|
48
|
+
expect(m.rtp).toBe(0);
|
|
49
|
+
expect(m.cv).toBe(0);
|
|
50
|
+
expect(m.hitRate).toBe(0);
|
|
51
|
+
expect(m.maxPayout).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isNearMax', () => {
|
|
56
|
+
it('returns true when payout ≥ fraction × cap', () => {
|
|
57
|
+
expect(isNearMax(950, 1000, 0.95)).toBe(true);
|
|
58
|
+
expect(isNearMax(1000, 1000, 0.95)).toBe(true);
|
|
59
|
+
expect(isNearMax(949, 1000, 0.95)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// test/nnls.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { solveNNLS } from '../src/nnls.js';
|
|
4
|
+
|
|
5
|
+
describe('solveNNLS — overdetermined (textbook cases)', () => {
|
|
6
|
+
it('solves trivial scalar case', () => {
|
|
7
|
+
// A = [[2]], b = [4] → x = 2
|
|
8
|
+
const x = solveNNLS([[2]], [4]);
|
|
9
|
+
expect(x[0]).toBeCloseTo(2, 8);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('clips negative LS solution to zero (canonical NNLS behavior)', () => {
|
|
13
|
+
// Unconstrained LS for A=[[1]], b=[-3] gives x=-3; NNLS gives x=0.
|
|
14
|
+
const x = solveNNLS([[1]], [-3]);
|
|
15
|
+
expect(x[0]).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns the LS solution when it is already non-negative', () => {
|
|
19
|
+
// A diag(1, 1), b = [2, 3] → x = [2, 3]
|
|
20
|
+
const x = solveNNLS([[1, 0], [0, 1]], [2, 3]);
|
|
21
|
+
expect(x[0]).toBeCloseTo(2, 8);
|
|
22
|
+
expect(x[1]).toBeCloseTo(3, 8);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('classic 2x3 case', () => {
|
|
26
|
+
// A = [[1, 2, 3], [4, 5, 6]], b = [7, 8]
|
|
27
|
+
// Every passive subset that includes x[0] or x[1] yields infeasible (negative)
|
|
28
|
+
// unconstrained LS coords, so NNLS pins them to 0. The unique optimum is the
|
|
29
|
+
// single-column passive subset {2}: x = [0, 0, 23/15] ≈ [0, 0, 1.5333] with
|
|
30
|
+
// residual norm ≈ 2.6833.
|
|
31
|
+
const x = solveNNLS([[1, 2, 3], [4, 5, 6]], [7, 8]);
|
|
32
|
+
expect(x.every((v) => v >= 0)).toBe(true);
|
|
33
|
+
expect(x[0]).toBeCloseTo(0, 8);
|
|
34
|
+
expect(x[1]).toBeCloseTo(0, 8);
|
|
35
|
+
expect(x[2]).toBeCloseTo(23 / 15, 6);
|
|
36
|
+
// Residual norm matches the NNLS optimum (not the unconstrained LS optimum).
|
|
37
|
+
const r0 = x[0] + 2 * x[1] + 3 * x[2] - 7;
|
|
38
|
+
const r1 = 4 * x[0] + 5 * x[1] + 6 * x[2] - 8;
|
|
39
|
+
expect(Math.sqrt(r0 * r0 + r1 * r1)).toBeCloseTo(2.6832815729997477, 6);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('solveNNLS — underdetermined (Tikhonov-regularized)', () => {
|
|
44
|
+
it('solves a 2x4 case toward a uniform prior', () => {
|
|
45
|
+
// 2 equations, 4 unknowns. Many solutions exist.
|
|
46
|
+
// Tikhonov prior x0 = [1, 1, 1, 1] biases toward the uniform answer.
|
|
47
|
+
// A = [[1, 1, 0, 0], [0, 0, 1, 1]], b = [4, 6] → many feasible
|
|
48
|
+
// x0 = [1, 1, 1, 1] picks x ≈ [2, 2, 3, 3] (uniform within each pair)
|
|
49
|
+
const x = solveNNLS(
|
|
50
|
+
[[1, 1, 0, 0], [0, 0, 1, 1]],
|
|
51
|
+
[4, 6],
|
|
52
|
+
{ prior: [1, 1, 1, 1], regularization: 1e-6 },
|
|
53
|
+
);
|
|
54
|
+
expect(x.every((v) => v >= 0)).toBe(true);
|
|
55
|
+
expect(x[0]).toBeCloseTo(2, 2);
|
|
56
|
+
expect(x[1]).toBeCloseTo(2, 2);
|
|
57
|
+
expect(x[2]).toBeCloseTo(3, 2);
|
|
58
|
+
expect(x[3]).toBeCloseTo(3, 2);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// test/optimize-lookup.integration.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { optimizeLookupTable } from '../src/optimize-lookup.js';
|
|
4
|
+
import type { LookupRow } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
function makeRng(seed: number): () => number {
|
|
7
|
+
let s = seed >>> 0;
|
|
8
|
+
return () => {
|
|
9
|
+
s = (s + 0x6D2B79F5) >>> 0;
|
|
10
|
+
let t = s;
|
|
11
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
12
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
13
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate rows where the natural population has approximately the requested
|
|
19
|
+
* RTP and CV, by mixing zero-payout rows (probability 1−hitRate) with payouts
|
|
20
|
+
* drawn from a log-normal scaled to hit the moments.
|
|
21
|
+
*/
|
|
22
|
+
function genTargeted(n: number, targetRTP: number, targetHitRate: number, capCents: number, seed: number): LookupRow[] {
|
|
23
|
+
const rng = makeRng(seed);
|
|
24
|
+
const rows: LookupRow[] = [];
|
|
25
|
+
// mean payout when hit: targetRTP * 100 / targetHitRate
|
|
26
|
+
const meanHit = (targetRTP * 100) / targetHitRate;
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
const u = rng();
|
|
29
|
+
let payoutCents: number;
|
|
30
|
+
if (u > targetHitRate) {
|
|
31
|
+
payoutCents = 0;
|
|
32
|
+
} else {
|
|
33
|
+
// log-normal-ish payout
|
|
34
|
+
const v = rng();
|
|
35
|
+
const draw = -Math.log(Math.max(1e-9, v)) * meanHit;
|
|
36
|
+
payoutCents = Math.min(Math.floor(draw), capCents);
|
|
37
|
+
}
|
|
38
|
+
rows.push({ sim: i, weight: 1, payoutCents });
|
|
39
|
+
}
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('integration', () => {
|
|
44
|
+
it('1. trivial recovery — natural distribution matches targets', () => {
|
|
45
|
+
const rows = genTargeted(2000, 0.96, 0.30, 50_000, 1);
|
|
46
|
+
const result = optimizeLookupTable(rows, {
|
|
47
|
+
targetRTP: 0.96, toleranceRTP: 0.02,
|
|
48
|
+
targetCV: 5.0, toleranceCV: 2.0,
|
|
49
|
+
targetHitRate: 0.30, toleranceHitRate: 0.05,
|
|
50
|
+
capMaxWin: 50_000,
|
|
51
|
+
nRowsOut: 200,
|
|
52
|
+
requireMaxReached: false,
|
|
53
|
+
maxIterations: 3,
|
|
54
|
+
});
|
|
55
|
+
expect(result.toleranceMet.rtp).toBe(true);
|
|
56
|
+
expect(result.toleranceMet.hitRate).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('2. filtered overshoot — input RTP=1.05 → optimizer pulls to 0.96', () => {
|
|
60
|
+
const rows = genTargeted(3000, 1.05, 0.40, 50_000, 2);
|
|
61
|
+
const result = optimizeLookupTable(rows, {
|
|
62
|
+
targetRTP: 0.96, toleranceRTP: 0.02,
|
|
63
|
+
targetCV: 5.0, toleranceCV: 2.0,
|
|
64
|
+
targetHitRate: 0.30, toleranceHitRate: 0.10,
|
|
65
|
+
capMaxWin: 50_000,
|
|
66
|
+
nRowsOut: 300,
|
|
67
|
+
requireMaxReached: false,
|
|
68
|
+
maxIterations: 3,
|
|
69
|
+
});
|
|
70
|
+
expect(result.toleranceMet.rtp).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('3. infeasible target — graceful degradation', () => {
|
|
74
|
+
const rows = genTargeted(500, 0.30, 0.10, 1000, 3);
|
|
75
|
+
const result = optimizeLookupTable(rows, {
|
|
76
|
+
targetRTP: 0.30, toleranceRTP: 0.05,
|
|
77
|
+
targetCV: 50, toleranceCV: 0.1, // infeasibly large CV
|
|
78
|
+
targetHitRate: 0.10, toleranceHitRate: 0.05,
|
|
79
|
+
capMaxWin: 1000,
|
|
80
|
+
nRowsOut: 100,
|
|
81
|
+
requireMaxReached: false,
|
|
82
|
+
maxIterations: 2,
|
|
83
|
+
});
|
|
84
|
+
expect(result.toleranceMet.cv).toBe(false);
|
|
85
|
+
expect(result.warnings.some((w) => /CV/i.test(w))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('4. near-max representation — top-end row is in output', () => {
|
|
89
|
+
const rng = makeRng(4);
|
|
90
|
+
const rows: LookupRow[] = [];
|
|
91
|
+
for (let i = 0; i < 1000; i++) {
|
|
92
|
+
rows.push({
|
|
93
|
+
sim: i,
|
|
94
|
+
weight: 1,
|
|
95
|
+
payoutCents: rng() < 0.7 ? 0 : Math.floor(rng() * 50_000),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
rows.push({ sim: 9999, weight: 1, payoutCents: 990_000 }); // near-max of 1_000_000
|
|
99
|
+
const result = optimizeLookupTable(rows, {
|
|
100
|
+
targetRTP: 0.96, toleranceRTP: 0.5,
|
|
101
|
+
targetCV: 3, toleranceCV: 100,
|
|
102
|
+
targetHitRate: 0.30, toleranceHitRate: 0.5,
|
|
103
|
+
capMaxWin: 1_000_000,
|
|
104
|
+
maxReachedFraction: 0.95,
|
|
105
|
+
requireMaxReached: true,
|
|
106
|
+
nRowsOut: 100,
|
|
107
|
+
maxIterations: 2,
|
|
108
|
+
});
|
|
109
|
+
expect(result.achieved.maxPayout).toBeGreaterThanOrEqual(0.95 * 1_000_000);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('5. smoke at scale — 1M synthetic rows in under 30s', () => {
|
|
113
|
+
const rng = makeRng(5);
|
|
114
|
+
const rows: LookupRow[] = new Array(1_000_000);
|
|
115
|
+
for (let i = 0; i < 1_000_000; i++) {
|
|
116
|
+
const u = rng();
|
|
117
|
+
let p = 0;
|
|
118
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
119
|
+
if (u > 0.97) p = Math.floor(rng() * 5_000);
|
|
120
|
+
if (u > 0.999) p = Math.floor(rng() * 50_000);
|
|
121
|
+
rows[i] = { sim: i, weight: 1 + Math.floor(rng() * 10), payoutCents: p };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const t0 = performance.now();
|
|
125
|
+
const result = optimizeLookupTable(rows, {
|
|
126
|
+
targetRTP: 0.5, toleranceRTP: 0.2,
|
|
127
|
+
targetCV: 3, toleranceCV: 5,
|
|
128
|
+
targetHitRate: 0.30, toleranceHitRate: 0.1,
|
|
129
|
+
capMaxWin: 50_000,
|
|
130
|
+
nRowsOut: 1000,
|
|
131
|
+
requireMaxReached: false,
|
|
132
|
+
maxIterations: 2,
|
|
133
|
+
});
|
|
134
|
+
const elapsed = performance.now() - t0;
|
|
135
|
+
|
|
136
|
+
expect(elapsed).toBeLessThan(30_000);
|
|
137
|
+
let sum = 0;
|
|
138
|
+
for (const r of result.rows) sum += r.weight;
|
|
139
|
+
expect(sum).toBe(1000 * 1_000_000);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { optimizeLookupTable } from '../src/optimize-lookup.js';
|
|
3
|
+
import type { LookupRow } from '../src/types.js';
|
|
4
|
+
|
|
5
|
+
function genRows(n: number, rng: () => number, capCents: number): LookupRow[] {
|
|
6
|
+
// Mix of zero, small, and occasional large payouts
|
|
7
|
+
const rows: LookupRow[] = [];
|
|
8
|
+
for (let i = 0; i < n; i++) {
|
|
9
|
+
const u = rng();
|
|
10
|
+
let payoutCents = 0;
|
|
11
|
+
if (u > 0.7) payoutCents = Math.floor(rng() * 200); // small win
|
|
12
|
+
if (u > 0.95) payoutCents = Math.floor(rng() * 5_000); // medium win
|
|
13
|
+
if (u > 0.999) payoutCents = Math.floor(rng() * capCents); // big win
|
|
14
|
+
rows.push({ sim: i, weight: 1 + Math.floor(rng() * 100), payoutCents });
|
|
15
|
+
}
|
|
16
|
+
return rows;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rng(seed: number): () => number {
|
|
20
|
+
let s = seed >>> 0;
|
|
21
|
+
return () => {
|
|
22
|
+
s = (s + 0x6D2B79F5) >>> 0;
|
|
23
|
+
let t = s;
|
|
24
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
25
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
26
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('optimizeLookupTable', () => {
|
|
31
|
+
it('returns exactly nRowsOut rows with integer weights summing to totalWeightOut', () => {
|
|
32
|
+
const rows = genRows(2000, rng(1), 100_000);
|
|
33
|
+
const result = optimizeLookupTable(rows, {
|
|
34
|
+
targetRTP: 0.96, toleranceRTP: 0.01,
|
|
35
|
+
targetCV: 5.0, toleranceCV: 1.0,
|
|
36
|
+
targetHitRate: 0.3, toleranceHitRate: 0.05,
|
|
37
|
+
capMaxWin: 100_000,
|
|
38
|
+
nRowsOut: 100,
|
|
39
|
+
});
|
|
40
|
+
expect(result.rows).toHaveLength(100);
|
|
41
|
+
let sum = 0;
|
|
42
|
+
for (const r of result.rows) {
|
|
43
|
+
expect(Number.isInteger(r.weight)).toBe(true);
|
|
44
|
+
expect(r.weight).toBeGreaterThanOrEqual(1);
|
|
45
|
+
sum += r.weight;
|
|
46
|
+
}
|
|
47
|
+
expect(sum).toBe(100 * 1_000_000); // default totalWeightOut
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('drops rows with payout > capMaxWin from candidate pool', () => {
|
|
51
|
+
const rows: LookupRow[] = [
|
|
52
|
+
...Array.from({ length: 100 }, (_, i) => ({ sim: i, weight: 10, payoutCents: 0 })),
|
|
53
|
+
{ sim: 999, weight: 1, payoutCents: 999_999 }, // way above cap
|
|
54
|
+
];
|
|
55
|
+
const result = optimizeLookupTable(rows, {
|
|
56
|
+
targetRTP: 0, toleranceRTP: 0.01,
|
|
57
|
+
targetCV: 0.1, toleranceCV: 1,
|
|
58
|
+
targetHitRate: 0.05, toleranceHitRate: 0.5,
|
|
59
|
+
capMaxWin: 1000,
|
|
60
|
+
nRowsOut: 50,
|
|
61
|
+
requireMaxReached: false,
|
|
62
|
+
});
|
|
63
|
+
expect(result.rows.find((r) => r.sim === 999)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('emits a warning and toleranceMet=false when target is infeasible', () => {
|
|
67
|
+
// All payouts zero → CV=0 always; targetCV=10 is infeasible
|
|
68
|
+
const rows: LookupRow[] = Array.from({ length: 200 }, (_, i) => ({
|
|
69
|
+
sim: i, weight: 1, payoutCents: 0,
|
|
70
|
+
}));
|
|
71
|
+
const result = optimizeLookupTable(rows, {
|
|
72
|
+
targetRTP: 0, toleranceRTP: 0.0001,
|
|
73
|
+
targetCV: 10, toleranceCV: 0.1,
|
|
74
|
+
targetHitRate: 0, toleranceHitRate: 0.0001,
|
|
75
|
+
capMaxWin: 1000,
|
|
76
|
+
nRowsOut: 50,
|
|
77
|
+
requireMaxReached: false,
|
|
78
|
+
maxIterations: 2,
|
|
79
|
+
});
|
|
80
|
+
expect(result.toleranceMet.cv).toBe(false);
|
|
81
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('honors requireMaxReached when a near-max row exists', () => {
|
|
85
|
+
const rows: LookupRow[] = [
|
|
86
|
+
...Array.from({ length: 500 }, (_, i) => ({ sim: i, weight: 100, payoutCents: 0 })),
|
|
87
|
+
...Array.from({ length: 50 }, (_, i) => ({ sim: 1000 + i, weight: 10, payoutCents: 100 })),
|
|
88
|
+
{ sim: 9999, weight: 1, payoutCents: 990 }, // near-max for cap=1000
|
|
89
|
+
];
|
|
90
|
+
const result = optimizeLookupTable(rows, {
|
|
91
|
+
targetRTP: 0.96, toleranceRTP: 0.5, // very loose, just exercising near-max
|
|
92
|
+
targetCV: 5, toleranceCV: 100,
|
|
93
|
+
targetHitRate: 0.1, toleranceHitRate: 0.5,
|
|
94
|
+
capMaxWin: 1000,
|
|
95
|
+
maxReachedFraction: 0.95,
|
|
96
|
+
requireMaxReached: true,
|
|
97
|
+
nRowsOut: 100,
|
|
98
|
+
});
|
|
99
|
+
expect(result.toleranceMet.maxReached).toBe(true);
|
|
100
|
+
expect(result.rows.find((r) => r.sim === 9999)).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('produces deterministic output for a fixed seed', () => {
|
|
104
|
+
const rows = genRows(1000, rng(42), 10_000);
|
|
105
|
+
const params = {
|
|
106
|
+
targetRTP: 0.5, toleranceRTP: 0.5,
|
|
107
|
+
targetCV: 3, toleranceCV: 100,
|
|
108
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
109
|
+
capMaxWin: 10_000,
|
|
110
|
+
nRowsOut: 50,
|
|
111
|
+
seed: 1234,
|
|
112
|
+
};
|
|
113
|
+
const a = optimizeLookupTable(rows, params);
|
|
114
|
+
const b = optimizeLookupTable(rows, params);
|
|
115
|
+
expect(a.rows.map((r) => r.sim)).toEqual(b.rows.map((r) => r.sim));
|
|
116
|
+
expect(a.rows.map((r) => r.weight)).toEqual(b.rows.map((r) => r.weight));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// test/quantize.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { quantizeWeights } from '../src/quantize.js';
|
|
4
|
+
|
|
5
|
+
describe('quantizeWeights', () => {
|
|
6
|
+
it('exactly preserves the target sum (deficit > 0 case)', () => {
|
|
7
|
+
// floors = [10, 20, 30, 40] → sum 100; total 103 → deficit 3
|
|
8
|
+
// remainders all 0.7 → tie; first 3 indices get +1
|
|
9
|
+
const out = quantizeWeights([10.7, 20.7, 30.7, 40.7], 103);
|
|
10
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(103);
|
|
11
|
+
expect(out).toEqual([11, 21, 31, 40]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('exactly preserves the target sum (deficit < 0 case)', () => {
|
|
15
|
+
// floors max(1, ...) = [10, 20, 30, 40] → sum 100; total 99 → deficit -1
|
|
16
|
+
// largest current weight is 40 → decrement to 39
|
|
17
|
+
const out = quantizeWeights([10.7, 20.3, 30.7, 40.3], 99);
|
|
18
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(99);
|
|
19
|
+
expect(out).toEqual([10, 20, 30, 39]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('clamps each output to ≥ 1 (so output never drops a row)', () => {
|
|
23
|
+
// floors = [1, 1, 1, 100] sum 103; total 103 → deficit 0
|
|
24
|
+
// (raw floor of 0.1 would be 0, but max(1, …) bumps it to 1)
|
|
25
|
+
const out = quantizeWeights([0.1, 0.2, 0.3, 100], 103);
|
|
26
|
+
expect(out.length).toBe(4);
|
|
27
|
+
for (const w of out) expect(w).toBeGreaterThanOrEqual(1);
|
|
28
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(103);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('throws when total < n (impossible to satisfy w_i ≥ 1)', () => {
|
|
32
|
+
expect(() => quantizeWeights([1, 1, 1], 2)).toThrow(/total.*>= n/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles ties deterministically (lower index wins on tie)', () => {
|
|
36
|
+
// weights [1.5, 2.5, 3.5], floors=[1,2,3] sum 6, total 8 → deficit 2
|
|
37
|
+
// all remainders are 0.5 → indices 0 and 1 should win (lower index breaks ties)
|
|
38
|
+
const out = quantizeWeights([1.5, 2.5, 3.5], 8);
|
|
39
|
+
expect(out).toEqual([2, 3, 3]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// test/sample.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { mulberry32, weightedReservoirSample, computeQuotas, stratifiedSample } from '../src/sample.js';
|
|
4
|
+
import type { Bucket } from '../src/bucketize.js';
|
|
5
|
+
|
|
6
|
+
describe('mulberry32', () => {
|
|
7
|
+
it('is deterministic for a given seed', () => {
|
|
8
|
+
const a = mulberry32(0xC0FFEE);
|
|
9
|
+
const b = mulberry32(0xC0FFEE);
|
|
10
|
+
for (let i = 0; i < 100; i++) {
|
|
11
|
+
expect(a()).toBe(b());
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
it('produces different streams for different seeds', () => {
|
|
15
|
+
const a = mulberry32(1);
|
|
16
|
+
const b = mulberry32(2);
|
|
17
|
+
expect(a()).not.toBe(b());
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('weightedReservoirSample (A-Res)', () => {
|
|
22
|
+
it('samples k items, biased toward higher weights, deterministically per seed', () => {
|
|
23
|
+
// 5 candidates, weights heavily skewed toward index 4
|
|
24
|
+
const weights = [1, 1, 1, 1, 1_000_000];
|
|
25
|
+
const k = 1;
|
|
26
|
+
const rng = mulberry32(42);
|
|
27
|
+
const sampled = weightedReservoirSample([0, 1, 2, 3, 4], weights, k, rng);
|
|
28
|
+
expect(sampled).toEqual([4]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns all items if k >= n (no replacement)', () => {
|
|
32
|
+
const rng = mulberry32(1);
|
|
33
|
+
const sampled = weightedReservoirSample([0, 1, 2], [1, 1, 1], 5, rng);
|
|
34
|
+
expect(sampled.sort()).toEqual([0, 1, 2]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('produces stable output for a given seed (snapshot)', () => {
|
|
38
|
+
const rng = mulberry32(0xC0FFEE);
|
|
39
|
+
const sampled = weightedReservoirSample([0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
40
|
+
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3, rng);
|
|
41
|
+
// Snapshot: any change in mulberry32 or A-Res will surface here.
|
|
42
|
+
expect(sampled.sort()).toMatchSnapshot();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('computeQuotas', () => {
|
|
47
|
+
it('honors minPerBucket on non-empty non-zero buckets', () => {
|
|
48
|
+
const zero: Bucket = { indices: Array(100).fill(0), totalWeight: 100, weightedPayoutSum: 0 };
|
|
49
|
+
const log: Bucket[] = [
|
|
50
|
+
{ indices: [0, 1, 2], totalWeight: 3, weightedPayoutSum: 30 },
|
|
51
|
+
{ indices: [3, 4, 5, 6, 7], totalWeight: 5, weightedPayoutSum: 200 },
|
|
52
|
+
{ indices: [], totalWeight: 0, weightedPayoutSum: 0 },
|
|
53
|
+
];
|
|
54
|
+
const nearMax: Bucket = { indices: [7], totalWeight: 1, weightedPayoutSum: 100 };
|
|
55
|
+
|
|
56
|
+
const quotas = computeQuotas({
|
|
57
|
+
zeroBucket: zero, logBuckets: log, nearMaxBucket: nearMax,
|
|
58
|
+
}, { nRowsOut: 20, minPerBucket: 3, requireMaxReached: true });
|
|
59
|
+
|
|
60
|
+
expect(quotas.logBuckets[0]).toBeGreaterThanOrEqual(3);
|
|
61
|
+
expect(quotas.logBuckets[1]).toBeGreaterThanOrEqual(3);
|
|
62
|
+
expect(quotas.logBuckets[2]).toBe(0); // empty bucket, zero quota
|
|
63
|
+
expect(quotas.nearMaxBucket).toBeGreaterThanOrEqual(1);
|
|
64
|
+
const total = quotas.zeroBucket + quotas.logBuckets.reduce((a,b) => a+b, 0) + quotas.nearMaxBucket;
|
|
65
|
+
expect(total).toBe(20);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('caps a quota at the bucket size (cannot ask for more rows than the bucket has)', () => {
|
|
69
|
+
const zero: Bucket = { indices: [0], totalWeight: 1, weightedPayoutSum: 0 };
|
|
70
|
+
const log: Bucket[] = [
|
|
71
|
+
{ indices: [1, 2], totalWeight: 2, weightedPayoutSum: 200 }, // only 2 rows here
|
|
72
|
+
];
|
|
73
|
+
const nearMax: Bucket = { indices: [], totalWeight: 0, weightedPayoutSum: 0 };
|
|
74
|
+
const quotas = computeQuotas({ zeroBucket: zero, logBuckets: log, nearMaxBucket: nearMax },
|
|
75
|
+
{ nRowsOut: 10, minPerBucket: 5, requireMaxReached: true });
|
|
76
|
+
expect(quotas.logBuckets[0]).toBeLessThanOrEqual(2);
|
|
77
|
+
expect(quotas.zeroBucket).toBeLessThanOrEqual(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('computeQuotas (over-allocation guard)', () => {
|
|
82
|
+
it('caps total quota at nRowsOut even when min allocation would overshoot', () => {
|
|
83
|
+
// 10 non-empty log buckets, minPerBucket=3, nRowsOut=20 → 30 > 20
|
|
84
|
+
const log: Bucket[] = Array.from({ length: 10 }, (_, i) => ({
|
|
85
|
+
indices: [i * 10, i * 10 + 1, i * 10 + 2],
|
|
86
|
+
totalWeight: 3,
|
|
87
|
+
weightedPayoutSum: 100 * (i + 1),
|
|
88
|
+
}));
|
|
89
|
+
const zero: Bucket = { indices: [], totalWeight: 0, weightedPayoutSum: 0 };
|
|
90
|
+
const nearMax: Bucket = { indices: [], totalWeight: 0, weightedPayoutSum: 0 };
|
|
91
|
+
const quotas = computeQuotas(
|
|
92
|
+
{ zeroBucket: zero, logBuckets: log, nearMaxBucket: nearMax },
|
|
93
|
+
{ nRowsOut: 20, minPerBucket: 3, requireMaxReached: false },
|
|
94
|
+
);
|
|
95
|
+
const total = quotas.zeroBucket + quotas.logBuckets.reduce((a, b) => a + b, 0) + quotas.nearMaxBucket;
|
|
96
|
+
expect(total).toBe(20);
|
|
97
|
+
expect(quotas.zeroBucket).toBeGreaterThanOrEqual(0);
|
|
98
|
+
for (const q of quotas.logBuckets) expect(q).toBeGreaterThanOrEqual(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('stratifiedSample (overlap top-up)', () => {
|
|
103
|
+
it('delivers exactly the total quota even when near-max overlaps log buckets', () => {
|
|
104
|
+
// Top log bucket overlaps near-max bucket; near-max consumes enough that the
|
|
105
|
+
// log bucket cannot fulfil its quota from its own indices alone.
|
|
106
|
+
const rows = Array.from({ length: 20 }, () => ({ weight: 1 }));
|
|
107
|
+
const zero: Bucket = { indices: [0, 1, 2, 3, 4, 5], totalWeight: 6, weightedPayoutSum: 0 };
|
|
108
|
+
const log: Bucket[] = [
|
|
109
|
+
{ indices: [10, 11, 12, 13, 14], totalWeight: 5, weightedPayoutSum: 1000 },
|
|
110
|
+
];
|
|
111
|
+
const nearMax: Bucket = { indices: [10, 11, 12, 13, 14], totalWeight: 5, weightedPayoutSum: 1000 };
|
|
112
|
+
// Near-max takes 3, log wants 3 — only 2 will be available after overlap → shortfall of 1.
|
|
113
|
+
const quotas = { zeroBucket: 3, logBuckets: [3], nearMaxBucket: 3 }; // total 9
|
|
114
|
+
const rng = mulberry32(1);
|
|
115
|
+
const sampled = stratifiedSample(
|
|
116
|
+
{ zeroBucket: zero, logBuckets: log, nearMaxBucket: nearMax },
|
|
117
|
+
rows,
|
|
118
|
+
quotas,
|
|
119
|
+
rng,
|
|
120
|
+
);
|
|
121
|
+
expect(sampled.length).toBe(9);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/tsconfig.json
ADDED