@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,200 @@
|
|
|
1
|
+
// src/optimize-lookup.ts
|
|
2
|
+
import type {
|
|
3
|
+
LookupRow,
|
|
4
|
+
OptimizeParams,
|
|
5
|
+
OptimizeResult,
|
|
6
|
+
OptimizeAchieved,
|
|
7
|
+
ToleranceMet,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import { computeMetrics, isNearMax } from './metrics.js';
|
|
10
|
+
import { bucketize } from './bucketize.js';
|
|
11
|
+
import { mulberry32, computeQuotas, stratifiedSample } from './sample.js';
|
|
12
|
+
import { solveNNLS } from './nnls.js';
|
|
13
|
+
import { quantizeWeights } from './quantize.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
requireMaxReached: true,
|
|
17
|
+
maxReachedFraction: 0.95,
|
|
18
|
+
totalWeightOutPerRow: 1_000_000,
|
|
19
|
+
seed: 0xC0FFEE,
|
|
20
|
+
maxIterations: 5,
|
|
21
|
+
bucketCount: 100,
|
|
22
|
+
minPerBucket: 3,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function optimizeLookupTable(
|
|
26
|
+
rowsIn: Iterable<LookupRow>,
|
|
27
|
+
params: OptimizeParams,
|
|
28
|
+
): OptimizeResult {
|
|
29
|
+
const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
|
|
30
|
+
const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
|
|
31
|
+
const totalWeightOut = params.totalWeightOut ?? params.nRowsOut * DEFAULTS.totalWeightOutPerRow;
|
|
32
|
+
const seed = params.seed ?? DEFAULTS.seed;
|
|
33
|
+
const maxIterations = params.maxIterations ?? DEFAULTS.maxIterations;
|
|
34
|
+
const bucketCount = params.bucketCount ?? DEFAULTS.bucketCount;
|
|
35
|
+
let minPerBucket = params.minPerBucket ?? DEFAULTS.minPerBucket;
|
|
36
|
+
|
|
37
|
+
const warnings: string[] = [];
|
|
38
|
+
|
|
39
|
+
// ── Phase 1: filter + materialize ─────────────────────────────────────────────
|
|
40
|
+
const filtered: LookupRow[] = [];
|
|
41
|
+
for (const r of rowsIn) {
|
|
42
|
+
if (r.payoutCents > params.capMaxWin) continue;
|
|
43
|
+
filtered.push(r);
|
|
44
|
+
}
|
|
45
|
+
if (filtered.length < params.nRowsOut) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`optimizeLookupTable: filtered input has ${filtered.length} rows, fewer than nRowsOut=${params.nRowsOut}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (totalWeightOut < params.nRowsOut) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`optimizeLookupTable: totalWeightOut (${totalWeightOut}) must be >= nRowsOut (${params.nRowsOut})`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Source statistics for early infeasibility warnings
|
|
57
|
+
const sourceMetrics = computeMetrics(filtered);
|
|
58
|
+
if (sourceMetrics.rtp < params.targetRTP - params.toleranceRTP) {
|
|
59
|
+
warnings.push(
|
|
60
|
+
`source RTP (${sourceMetrics.rtp.toFixed(4)}) is below targetRTP (${params.targetRTP}) − tolerance; result may miss target`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (sourceMetrics.maxPayout < maxReachedFraction * params.capMaxWin && requireMaxReached) {
|
|
64
|
+
warnings.push(
|
|
65
|
+
`no row reaches ${maxReachedFraction * 100}% of capMaxWin; requireMaxReached cannot be honored`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Phases 2–6: try, expand, retry ────────────────────────────────────────────
|
|
70
|
+
let best: { rows: LookupRow[]; achieved: OptimizeAchieved; toleranceMet: ToleranceMet; lossSum: number } | null = null;
|
|
71
|
+
|
|
72
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
73
|
+
const rng = mulberry32(seed + iter);
|
|
74
|
+
const buckets = bucketize(filtered, {
|
|
75
|
+
capMaxWin: params.capMaxWin,
|
|
76
|
+
bucketCount,
|
|
77
|
+
maxReachedFraction,
|
|
78
|
+
});
|
|
79
|
+
const quotas = computeQuotas(buckets, {
|
|
80
|
+
nRowsOut: params.nRowsOut,
|
|
81
|
+
minPerBucket,
|
|
82
|
+
requireMaxReached,
|
|
83
|
+
});
|
|
84
|
+
const sampledIdx = stratifiedSample(buckets, filtered, quotas, rng);
|
|
85
|
+
|
|
86
|
+
if (sampledIdx.length !== params.nRowsOut) {
|
|
87
|
+
// Should not happen with fixed sample.ts (computeQuotas + stratifiedSample
|
|
88
|
+
// honor their invariants); kept as defense in depth.
|
|
89
|
+
minPerBucket = Math.max(1, minPerBucket - 1);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const candidates = sampledIdx.map((i) => filtered[i]);
|
|
94
|
+
|
|
95
|
+
// Build A, b for NNLS — feature rows: RTP, var (using μ̂), hit-rate, sum
|
|
96
|
+
// Each feature row scaled by 1/tolerance so loss is "tolerance-units".
|
|
97
|
+
const muHatTarget = params.targetRTP * 100;
|
|
98
|
+
let muHat = muHatTarget;
|
|
99
|
+
let weights: number[] = [];
|
|
100
|
+
|
|
101
|
+
for (let inner = 0; inner < 5; inner++) {
|
|
102
|
+
const A: number[][] = [
|
|
103
|
+
// RTP row: payouts (cents), scaled
|
|
104
|
+
candidates.map((r) => r.payoutCents / params.toleranceRTP),
|
|
105
|
+
// CV row: (payout − μ̂)², scaled — note we encode CV² via variance = CV² · μ²
|
|
106
|
+
candidates.map((r) => Math.pow(r.payoutCents - muHat, 2) / Math.max(1, params.toleranceCV * muHat * muHat)),
|
|
107
|
+
// Hit-rate row: 1 if payout > 0
|
|
108
|
+
candidates.map((r) => (r.payoutCents > 0 ? 1 : 0) / params.toleranceHitRate),
|
|
109
|
+
// Sum row: 1 — heavily weighted (1/tolerance set very small ≡ very strict)
|
|
110
|
+
candidates.map(() => 1 / 1e-6),
|
|
111
|
+
];
|
|
112
|
+
const bVec = [
|
|
113
|
+
(params.targetRTP * totalWeightOut * 100) / params.toleranceRTP,
|
|
114
|
+
(Math.pow(params.targetCV * muHat, 2) * totalWeightOut) / Math.max(1, params.toleranceCV * muHat * muHat),
|
|
115
|
+
(params.targetHitRate * totalWeightOut) / params.toleranceHitRate,
|
|
116
|
+
totalWeightOut / 1e-6,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const prior = new Array(candidates.length).fill(totalWeightOut / candidates.length);
|
|
120
|
+
const sol = solveNNLS(A, bVec, {
|
|
121
|
+
prior,
|
|
122
|
+
regularization: 1e-6,
|
|
123
|
+
maxIterations: 200,
|
|
124
|
+
});
|
|
125
|
+
weights = sol;
|
|
126
|
+
|
|
127
|
+
// Update μ̂
|
|
128
|
+
let sumW = 0;
|
|
129
|
+
let sumWP = 0;
|
|
130
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
131
|
+
sumW += sol[i];
|
|
132
|
+
sumWP += sol[i] * candidates[i].payoutCents;
|
|
133
|
+
}
|
|
134
|
+
const newMu = sumW > 0 ? sumWP / sumW : muHatTarget;
|
|
135
|
+
if (Math.abs(newMu - muHat) < 1e-3) {
|
|
136
|
+
muHat = newMu;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
muHat = newMu;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Quantize
|
|
143
|
+
const quantized = quantizeWeights(weights, totalWeightOut);
|
|
144
|
+
const outRows: LookupRow[] = candidates.map((r, i) => ({
|
|
145
|
+
sim: r.sim,
|
|
146
|
+
weight: quantized[i],
|
|
147
|
+
payoutCents: r.payoutCents,
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
const achieved = computeMetrics(outRows);
|
|
151
|
+
const toleranceMet: ToleranceMet = {
|
|
152
|
+
rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
|
|
153
|
+
cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
|
|
154
|
+
hitRate: Math.abs(achieved.hitRate - params.targetHitRate) <= params.toleranceHitRate,
|
|
155
|
+
maxReached:
|
|
156
|
+
!requireMaxReached ||
|
|
157
|
+
outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Loss for "best so far" tracking — Σ tolerance-normalized squared misses
|
|
161
|
+
let lossSum =
|
|
162
|
+
Math.pow((achieved.rtp - params.targetRTP) / params.toleranceRTP, 2) +
|
|
163
|
+
Math.pow((achieved.cv - params.targetCV) / params.toleranceCV, 2) +
|
|
164
|
+
Math.pow((achieved.hitRate - params.targetHitRate) / params.toleranceHitRate, 2) +
|
|
165
|
+
(toleranceMet.maxReached ? 0 : 1000);
|
|
166
|
+
if (!Number.isFinite(lossSum)) lossSum = Infinity;
|
|
167
|
+
|
|
168
|
+
if (!best || lossSum < best.lossSum) {
|
|
169
|
+
best = { rows: outRows, achieved, toleranceMet, lossSum };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (toleranceMet.rtp && toleranceMet.cv && toleranceMet.hitRate && toleranceMet.maxReached) {
|
|
173
|
+
return { rows: outRows, achieved, toleranceMet, warnings };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fell through max iterations — return best-effort
|
|
178
|
+
if (!best) throw new Error('optimizeLookupTable: failed to produce any candidate');
|
|
179
|
+
|
|
180
|
+
if (!best.toleranceMet.rtp) {
|
|
181
|
+
warnings.push(
|
|
182
|
+
`RTP off target by ${(best.achieved.rtp - params.targetRTP).toFixed(6)} (tolerance ${params.toleranceRTP})`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!best.toleranceMet.cv) {
|
|
186
|
+
warnings.push(
|
|
187
|
+
`CV off target by ${(best.achieved.cv - params.targetCV).toFixed(4)} (tolerance ${params.toleranceCV})`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (!best.toleranceMet.hitRate) {
|
|
191
|
+
warnings.push(
|
|
192
|
+
`hitRate off target by ${(best.achieved.hitRate - params.targetHitRate).toFixed(4)} (tolerance ${params.toleranceHitRate})`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (!best.toleranceMet.maxReached) {
|
|
196
|
+
warnings.push(`requireMaxReached=true but no near-max row in output`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { rows: best.rows, achieved: best.achieved, toleranceMet: best.toleranceMet, warnings };
|
|
200
|
+
}
|
package/src/quantize.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/quantize.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Largest-remainder quantization with a strict per-row floor of 1.
|
|
5
|
+
*
|
|
6
|
+
* - input: continuous weights wᵢ ≥ 0, target sum T (integer)
|
|
7
|
+
* - output: integer weights wᵢ′ ≥ 1, Σwᵢ′ = T (exact)
|
|
8
|
+
*
|
|
9
|
+
* Throws if T < weights.length, since wᵢ′ ≥ 1 then can't sum to T.
|
|
10
|
+
*
|
|
11
|
+
* Tie-breaking: when multiple rows have the same remainder, lower index wins
|
|
12
|
+
* (deterministic; matches the order indices come back from a stable sort).
|
|
13
|
+
*/
|
|
14
|
+
export function quantizeWeights(weights: ReadonlyArray<number>, total: number): number[] {
|
|
15
|
+
const n = weights.length;
|
|
16
|
+
if (total < n) {
|
|
17
|
+
throw new Error(`quantizeWeights: total (${total}) must be >= n (${n}); cannot satisfy w_i >= 1`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const floors = weights.map((w) => Math.max(1, Math.floor(w)));
|
|
21
|
+
let sumFloors = 0;
|
|
22
|
+
for (const v of floors) sumFloors += v;
|
|
23
|
+
|
|
24
|
+
let deficit = total - sumFloors; // positive: need to add; negative: need to remove
|
|
25
|
+
|
|
26
|
+
if (deficit > 0) {
|
|
27
|
+
// Add 1's to rows with the largest remainder = w − floor(w).
|
|
28
|
+
// (When floor was clamped to 1 because raw floor was 0, the "remainder" we
|
|
29
|
+
// care about is the leftover capacity above 1, i.e. max(0, w − 1).)
|
|
30
|
+
// Round to 10 decimal places to suppress floating-point noise so that
|
|
31
|
+
// conceptually equal remainders compare equal and lower-index wins on tie.
|
|
32
|
+
const remainders = weights.map((w, i) =>
|
|
33
|
+
Math.round(Math.max(0, w - floors[i]) * 1e10) / 1e10,
|
|
34
|
+
);
|
|
35
|
+
const order = indicesSortedByDesc(remainders);
|
|
36
|
+
for (let k = 0; k < deficit; k++) floors[order[k]]++;
|
|
37
|
+
} else if (deficit < 0) {
|
|
38
|
+
// Remove 1's from rows with the largest current weight, never going below 1.
|
|
39
|
+
let toRemove = -deficit;
|
|
40
|
+
while (toRemove > 0) {
|
|
41
|
+
const order = indicesSortedByDesc(floors);
|
|
42
|
+
let progress = false;
|
|
43
|
+
for (const i of order) {
|
|
44
|
+
if (toRemove === 0) break;
|
|
45
|
+
if (floors[i] > 1) {
|
|
46
|
+
floors[i]--;
|
|
47
|
+
toRemove--;
|
|
48
|
+
progress = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!progress) {
|
|
52
|
+
// Shouldn't happen: total >= n was checked; sumFloors was at most total + (max(1, .) bias),
|
|
53
|
+
// and that bias is ≤ n which can always be reclaimed.
|
|
54
|
+
throw new Error('quantizeWeights: cannot reduce further while keeping w_i >= 1');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return floors;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function indicesSortedByDesc(values: ReadonlyArray<number>): number[] {
|
|
63
|
+
const idx = values.map((_, i) => i);
|
|
64
|
+
// Stable sort: ties preserve original (ascending) index — lower index wins.
|
|
65
|
+
// We add a secondary sort on index to make tie-breaking explicit and immune
|
|
66
|
+
// to floating-point near-equality issues.
|
|
67
|
+
idx.sort((a, b) => {
|
|
68
|
+
const diff = values[b] - values[a];
|
|
69
|
+
if (diff !== 0) return diff;
|
|
70
|
+
return a - b; // lower index wins on tie
|
|
71
|
+
});
|
|
72
|
+
return idx;
|
|
73
|
+
}
|
package/src/sample.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// src/sample.ts
|
|
2
|
+
import type { BucketizeResult, Bucket } from './bucketize.js';
|
|
3
|
+
|
|
4
|
+
/** Mulberry32 — small deterministic PRNG returning floats in [0, 1). */
|
|
5
|
+
export function mulberry32(seed: number): () => number {
|
|
6
|
+
let s = seed >>> 0;
|
|
7
|
+
return () => {
|
|
8
|
+
s = (s + 0x6D2B79F5) >>> 0;
|
|
9
|
+
let t = s;
|
|
10
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
11
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
12
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Weighted reservoir sampling without replacement (Algorithm A-Res, Efraimidis & Spirakis).
|
|
18
|
+
* Each item gets a key u^(1/w); we keep the top-k keys.
|
|
19
|
+
*
|
|
20
|
+
* Returns the chosen indices (subset of `indices`).
|
|
21
|
+
*/
|
|
22
|
+
export function weightedReservoirSample(
|
|
23
|
+
indices: ReadonlyArray<number>,
|
|
24
|
+
weights: ReadonlyArray<number>,
|
|
25
|
+
k: number,
|
|
26
|
+
rng: () => number,
|
|
27
|
+
): number[] {
|
|
28
|
+
const n = indices.length;
|
|
29
|
+
if (k >= n) return [...indices];
|
|
30
|
+
if (k <= 0) return [];
|
|
31
|
+
|
|
32
|
+
// Min-heap of {key, idx} sized k. Inline implementation (no deps).
|
|
33
|
+
const heapKeys: number[] = [];
|
|
34
|
+
const heapIdx: number[] = [];
|
|
35
|
+
|
|
36
|
+
const swap = (i: number, j: number) => {
|
|
37
|
+
[heapKeys[i], heapKeys[j]] = [heapKeys[j], heapKeys[i]];
|
|
38
|
+
[heapIdx[i], heapIdx[j]] = [heapIdx[j], heapIdx[i]];
|
|
39
|
+
};
|
|
40
|
+
const siftUp = (i: number) => {
|
|
41
|
+
while (i > 0) {
|
|
42
|
+
const p = (i - 1) >> 1;
|
|
43
|
+
if (heapKeys[p] > heapKeys[i]) { swap(p, i); i = p; } else break;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const siftDown = (i: number) => {
|
|
47
|
+
const sz = heapKeys.length;
|
|
48
|
+
while (true) {
|
|
49
|
+
const l = 2 * i + 1, r = 2 * i + 2;
|
|
50
|
+
let s = i;
|
|
51
|
+
if (l < sz && heapKeys[l] < heapKeys[s]) s = l;
|
|
52
|
+
if (r < sz && heapKeys[r] < heapKeys[s]) s = r;
|
|
53
|
+
if (s !== i) { swap(s, i); i = s; } else break;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < n; i++) {
|
|
58
|
+
const w = weights[i];
|
|
59
|
+
if (w <= 0) continue;
|
|
60
|
+
// key = u^(1/w) — equivalently log(u)/w; use log form for numerical stability with huge w
|
|
61
|
+
const u = rng();
|
|
62
|
+
const key = Math.log(u) / w;
|
|
63
|
+
if (heapKeys.length < k) {
|
|
64
|
+
heapKeys.push(key);
|
|
65
|
+
heapIdx.push(indices[i]);
|
|
66
|
+
siftUp(heapKeys.length - 1);
|
|
67
|
+
} else if (key > heapKeys[0]) {
|
|
68
|
+
heapKeys[0] = key;
|
|
69
|
+
heapIdx[0] = indices[i];
|
|
70
|
+
siftDown(0);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return heapIdx.slice();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface QuotaInput {
|
|
78
|
+
zeroBucket: Bucket;
|
|
79
|
+
logBuckets: Bucket[];
|
|
80
|
+
nearMaxBucket: Bucket;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface QuotaParams {
|
|
84
|
+
nRowsOut: number;
|
|
85
|
+
minPerBucket: number;
|
|
86
|
+
requireMaxReached: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Quotas {
|
|
90
|
+
zeroBucket: number;
|
|
91
|
+
logBuckets: number[];
|
|
92
|
+
nearMaxBucket: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Distributes `nRowsOut` slots across (zero, log[…], nearMax) buckets:
|
|
97
|
+
* 1. Each non-empty non-zero log bucket gets `minPerBucket` (capped at bucket size).
|
|
98
|
+
* 2. nearMax gets ≥ 1 if requireMaxReached and non-empty.
|
|
99
|
+
* 3. Remaining slots → distributed proportional to bucket variance contribution
|
|
100
|
+
* (weight × meanPayout²), capped at bucket size.
|
|
101
|
+
* 4. zeroBucket absorbs the leftover.
|
|
102
|
+
*
|
|
103
|
+
* All quotas are integers and sum to nRowsOut.
|
|
104
|
+
*/
|
|
105
|
+
export function computeQuotas(buckets: QuotaInput, params: QuotaParams): Quotas {
|
|
106
|
+
const { nRowsOut, minPerBucket, requireMaxReached } = params;
|
|
107
|
+
|
|
108
|
+
// Count non-empty log buckets — these are the ones eligible for minPerBucket.
|
|
109
|
+
const nonEmptyLogCount = buckets.logBuckets.reduce(
|
|
110
|
+
(s, b) => s + (b.indices.length > 0 ? 1 : 0),
|
|
111
|
+
0,
|
|
112
|
+
);
|
|
113
|
+
const wantNearMax = requireMaxReached && buckets.nearMaxBucket.indices.length > 0;
|
|
114
|
+
|
|
115
|
+
// Compute an effective minPerBucket so the floor allocation does not exceed nRowsOut.
|
|
116
|
+
// Floor at 0; near-max keeps its 1 slot when room allows, dropped only as a last resort.
|
|
117
|
+
let effectiveMinPerBucket = minPerBucket;
|
|
118
|
+
while (
|
|
119
|
+
effectiveMinPerBucket > 0 &&
|
|
120
|
+
nonEmptyLogCount * effectiveMinPerBucket + (wantNearMax ? 1 : 0) > nRowsOut
|
|
121
|
+
) {
|
|
122
|
+
effectiveMinPerBucket--;
|
|
123
|
+
}
|
|
124
|
+
let nearMaxQuota = wantNearMax && nonEmptyLogCount * effectiveMinPerBucket < nRowsOut ? 1 : 0;
|
|
125
|
+
|
|
126
|
+
const logQuotas = buckets.logBuckets.map((b) => {
|
|
127
|
+
if (b.indices.length === 0) return 0;
|
|
128
|
+
return Math.min(effectiveMinPerBucket, b.indices.length);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let assigned = logQuotas.reduce((s, q) => s + q, 0) + nearMaxQuota;
|
|
132
|
+
let remaining = nRowsOut - assigned;
|
|
133
|
+
|
|
134
|
+
// Variance-contribution distribution
|
|
135
|
+
if (remaining > 0) {
|
|
136
|
+
const contrib = buckets.logBuckets.map((b) => {
|
|
137
|
+
if (b.indices.length === 0) return 0;
|
|
138
|
+
const mean = b.weightedPayoutSum / Math.max(1, b.totalWeight);
|
|
139
|
+
return b.totalWeight * mean * mean;
|
|
140
|
+
});
|
|
141
|
+
const totalContrib = contrib.reduce((s, v) => s + v, 0);
|
|
142
|
+
if (totalContrib > 0) {
|
|
143
|
+
const proposed = contrib.map((c) => (c / totalContrib) * remaining);
|
|
144
|
+
// Floor + largest-remainder
|
|
145
|
+
const floors = proposed.map(Math.floor);
|
|
146
|
+
let used = floors.reduce((s, v) => s + v, 0);
|
|
147
|
+
const remainders = proposed.map((p, i) => p - floors[i]);
|
|
148
|
+
const order = remainders
|
|
149
|
+
.map((_, i) => i)
|
|
150
|
+
.sort((a, b) => remainders[b] - remainders[a]);
|
|
151
|
+
let extra = remaining - used;
|
|
152
|
+
for (const i of order) {
|
|
153
|
+
if (extra === 0) break;
|
|
154
|
+
floors[i]++;
|
|
155
|
+
extra--;
|
|
156
|
+
}
|
|
157
|
+
// Cap each at (bucket size − minPerBucket already given)
|
|
158
|
+
for (let i = 0; i < floors.length; i++) {
|
|
159
|
+
const room = buckets.logBuckets[i].indices.length - logQuotas[i];
|
|
160
|
+
if (floors[i] > room) {
|
|
161
|
+
floors[i] = room;
|
|
162
|
+
}
|
|
163
|
+
logQuotas[i] += floors[i];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
assigned = logQuotas.reduce((s, q) => s + q, 0) + nearMaxQuota;
|
|
167
|
+
remaining = nRowsOut - assigned;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const zeroQuota = Math.max(0, Math.min(remaining, buckets.zeroBucket.indices.length));
|
|
171
|
+
// If zero bucket can't soak it all up, dump the rest into the largest log bucket
|
|
172
|
+
let leftover = remaining - zeroQuota;
|
|
173
|
+
if (leftover > 0) {
|
|
174
|
+
const order = buckets.logBuckets
|
|
175
|
+
.map((b, i) => ({ i, room: b.indices.length - logQuotas[i] }))
|
|
176
|
+
.sort((a, b) => b.room - a.room);
|
|
177
|
+
for (const { i, room } of order) {
|
|
178
|
+
if (leftover === 0) break;
|
|
179
|
+
const give = Math.min(room, leftover);
|
|
180
|
+
logQuotas[i] += give;
|
|
181
|
+
leftover -= give;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Defensive invariant: quotas must sum to exactly nRowsOut, unless the
|
|
186
|
+
// total available indices across all buckets are fewer than nRowsOut (in
|
|
187
|
+
// which case the cap at total available is the best achievable).
|
|
188
|
+
const totalAvailable =
|
|
189
|
+
buckets.zeroBucket.indices.length +
|
|
190
|
+
buckets.logBuckets.reduce((s, b) => s + b.indices.length, 0) +
|
|
191
|
+
buckets.nearMaxBucket.indices.length;
|
|
192
|
+
const expected = Math.min(nRowsOut, totalAvailable);
|
|
193
|
+
const total = zeroQuota + logQuotas.reduce((s, q) => s + q, 0) + nearMaxQuota;
|
|
194
|
+
if (total !== expected) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`computeQuotas invariant violated: total=${total}, expected=${expected} (nRowsOut=${nRowsOut}, totalAvailable=${totalAvailable})`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { zeroBucket: zeroQuota, logBuckets: logQuotas, nearMaxBucket: nearMaxQuota };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Apply quotas: sample row indices from each bucket using weighted reservoir sampling.
|
|
205
|
+
* Returns the union of sampled indices.
|
|
206
|
+
*
|
|
207
|
+
* Note: nearMax bucket overlaps with log buckets, so we sample it first and then
|
|
208
|
+
* skip those indices when sampling the log buckets to avoid duplicates.
|
|
209
|
+
*/
|
|
210
|
+
export function stratifiedSample(
|
|
211
|
+
buckets: QuotaInput,
|
|
212
|
+
rows: ReadonlyArray<{ weight: number }>,
|
|
213
|
+
quotas: Quotas,
|
|
214
|
+
rng: () => number,
|
|
215
|
+
): number[] {
|
|
216
|
+
const chosen = new Set<number>();
|
|
217
|
+
const totalQuota =
|
|
218
|
+
quotas.zeroBucket + quotas.logBuckets.reduce((s, q) => s + q, 0) + quotas.nearMaxBucket;
|
|
219
|
+
|
|
220
|
+
// 1. Near-max first (these indices may overlap log buckets)
|
|
221
|
+
if (quotas.nearMaxBucket > 0 && buckets.nearMaxBucket.indices.length > 0) {
|
|
222
|
+
const w = buckets.nearMaxBucket.indices.map((i) => rows[i].weight);
|
|
223
|
+
for (const idx of weightedReservoirSample(buckets.nearMaxBucket.indices, w, quotas.nearMaxBucket, rng)) {
|
|
224
|
+
chosen.add(idx);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 2. Log buckets, excluding already-chosen indices
|
|
229
|
+
for (let bi = 0; bi < buckets.logBuckets.length; bi++) {
|
|
230
|
+
const need = quotas.logBuckets[bi];
|
|
231
|
+
if (need <= 0) continue;
|
|
232
|
+
const filteredIdx: number[] = [];
|
|
233
|
+
const filteredW: number[] = [];
|
|
234
|
+
for (const i of buckets.logBuckets[bi].indices) {
|
|
235
|
+
if (!chosen.has(i)) {
|
|
236
|
+
filteredIdx.push(i);
|
|
237
|
+
filteredW.push(rows[i].weight);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const idx of weightedReservoirSample(filteredIdx, filteredW, need, rng)) {
|
|
241
|
+
chosen.add(idx);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 3. Zero bucket
|
|
246
|
+
if (quotas.zeroBucket > 0) {
|
|
247
|
+
const w = buckets.zeroBucket.indices.map((i) => rows[i].weight);
|
|
248
|
+
for (const idx of weightedReservoirSample(buckets.zeroBucket.indices, w, quotas.zeroBucket, rng)) {
|
|
249
|
+
chosen.add(idx);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 4. Top up any shortfall caused by overlapping buckets (e.g. near-max
|
|
254
|
+
// consumes indices a log bucket also wanted). Sample the remainder of
|
|
255
|
+
// `rows` (anything not already chosen) by weight.
|
|
256
|
+
if (chosen.size < totalQuota) {
|
|
257
|
+
const remIdx: number[] = [];
|
|
258
|
+
const remW: number[] = [];
|
|
259
|
+
for (let i = 0; i < rows.length; i++) {
|
|
260
|
+
if (!chosen.has(i)) {
|
|
261
|
+
remIdx.push(i);
|
|
262
|
+
remW.push(rows[i].weight);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const need = totalQuota - chosen.size;
|
|
266
|
+
for (const idx of weightedReservoirSample(remIdx, remW, need, rng)) {
|
|
267
|
+
chosen.add(idx);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const out = [...chosen];
|
|
272
|
+
if (out.length !== totalQuota) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`stratifiedSample invariant violated: produced ${out.length}, expected ${totalQuota}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface LookupRow {
|
|
2
|
+
/** Simulation number — opaque identifier, preserved on output. */
|
|
3
|
+
sim: number;
|
|
4
|
+
/** Input weight, integer (typically large, e.g. 1.99e11). */
|
|
5
|
+
weight: number;
|
|
6
|
+
/** Payout multiplier × 100, integer, ≥ 0. */
|
|
7
|
+
payoutCents: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OptimizeParams {
|
|
11
|
+
targetRTP: number;
|
|
12
|
+
toleranceRTP: number;
|
|
13
|
+
|
|
14
|
+
targetCV: number;
|
|
15
|
+
toleranceCV: number;
|
|
16
|
+
|
|
17
|
+
targetHitRate: number;
|
|
18
|
+
toleranceHitRate: number;
|
|
19
|
+
|
|
20
|
+
/** Hard cap. Rows with payoutCents > capMaxWin are dropped. */
|
|
21
|
+
capMaxWin: number;
|
|
22
|
+
|
|
23
|
+
/** When true, force ≥ 1 row with payoutCents ≥ maxReachedFraction × capMaxWin. Default true. */
|
|
24
|
+
requireMaxReached?: boolean;
|
|
25
|
+
/** Default 0.95. */
|
|
26
|
+
maxReachedFraction?: number;
|
|
27
|
+
|
|
28
|
+
nRowsOut: number;
|
|
29
|
+
/** Sum of integer output weights. Default = nRowsOut × 1_000_000. Must be ≥ nRowsOut. */
|
|
30
|
+
totalWeightOut?: number;
|
|
31
|
+
|
|
32
|
+
/** Sampling RNG seed. Default 0xC0FFEE. */
|
|
33
|
+
seed?: number;
|
|
34
|
+
/** Expand-and-retry attempts on tolerance miss. Default 5. */
|
|
35
|
+
maxIterations?: number;
|
|
36
|
+
/** Number of log-buckets between min-nonzero and capMaxWin. Default 100. */
|
|
37
|
+
bucketCount?: number;
|
|
38
|
+
/** Minimum sample slots per non-empty non-zero bucket. Default 3. */
|
|
39
|
+
minPerBucket?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OptimizeAchieved {
|
|
43
|
+
rtp: number;
|
|
44
|
+
cv: number;
|
|
45
|
+
hitRate: number;
|
|
46
|
+
maxPayout: number;
|
|
47
|
+
totalWeight: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ToleranceMet {
|
|
51
|
+
rtp: boolean;
|
|
52
|
+
cv: boolean;
|
|
53
|
+
hitRate: boolean;
|
|
54
|
+
maxReached: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface OptimizeResult {
|
|
58
|
+
rows: LookupRow[];
|
|
59
|
+
achieved: OptimizeAchieved;
|
|
60
|
+
toleranceMet: ToleranceMet;
|
|
61
|
+
warnings: string[];
|
|
62
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// test/bucketize.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { bucketize } from '../src/bucketize.js';
|
|
4
|
+
import type { LookupRow } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
describe('bucketize', () => {
|
|
7
|
+
it('places zero payouts in bucket 0; non-zero in log buckets; near-max in its own bucket', () => {
|
|
8
|
+
const rows: LookupRow[] = [
|
|
9
|
+
{ sim: 1, weight: 100, payoutCents: 0 }, // → bucket 0
|
|
10
|
+
{ sim: 2, weight: 200, payoutCents: 0 }, // → bucket 0
|
|
11
|
+
{ sim: 3, weight: 50, payoutCents: 10 }, // → low log bucket
|
|
12
|
+
{ sim: 4, weight: 30, payoutCents: 100 }, // → mid log bucket
|
|
13
|
+
{ sim: 5, weight: 10, payoutCents: 9_500 }, // → top log bucket AND near-max (cap=10000, frac=0.95)
|
|
14
|
+
{ sim: 6, weight: 5, payoutCents: 10_000 }, // → top log bucket AND near-max
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const result = bucketize(rows, {
|
|
18
|
+
capMaxWin: 10_000,
|
|
19
|
+
bucketCount: 4,
|
|
20
|
+
maxReachedFraction: 0.95,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// 1 zero bucket + 4 log buckets + 1 near-max bucket = 6 entries
|
|
24
|
+
expect(result.zeroBucket.indices).toEqual([0, 1]);
|
|
25
|
+
expect(result.zeroBucket.totalWeight).toBe(300);
|
|
26
|
+
|
|
27
|
+
// log buckets have 4 entries (some may be empty)
|
|
28
|
+
expect(result.logBuckets).toHaveLength(4);
|
|
29
|
+
|
|
30
|
+
// near-max bucket: rows whose payout >= 0.95 * 10_000 = 9_500
|
|
31
|
+
expect(result.nearMaxBucket.indices.sort()).toEqual([4, 5]);
|
|
32
|
+
expect(result.nearMaxBucket.totalWeight).toBe(15);
|
|
33
|
+
|
|
34
|
+
// Sanity: every non-zero row appears in exactly one log bucket
|
|
35
|
+
const seen = new Set<number>();
|
|
36
|
+
for (const b of result.logBuckets) for (const i of b.indices) seen.add(i);
|
|
37
|
+
expect([...seen].sort()).toEqual([2, 3, 4, 5]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('drops nothing — caller is expected to filter capMaxWin before calling (defense in depth)', () => {
|
|
41
|
+
const rows: LookupRow[] = [
|
|
42
|
+
{ sim: 1, weight: 1, payoutCents: 0 },
|
|
43
|
+
{ sim: 2, weight: 1, payoutCents: 50 },
|
|
44
|
+
];
|
|
45
|
+
const result = bucketize(rows, { capMaxWin: 100, bucketCount: 3, maxReachedFraction: 0.95 });
|
|
46
|
+
expect(result.zeroBucket.totalWeight).toBe(1);
|
|
47
|
+
const totalLog = result.logBuckets.reduce((s, b) => s + b.totalWeight, 0);
|
|
48
|
+
expect(totalLog).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('handles a single non-zero payout (no log spread)', () => {
|
|
52
|
+
const rows: LookupRow[] = [
|
|
53
|
+
{ sim: 1, weight: 1, payoutCents: 0 },
|
|
54
|
+
{ sim: 2, weight: 1, payoutCents: 500 },
|
|
55
|
+
];
|
|
56
|
+
const result = bucketize(rows, { capMaxWin: 1000, bucketCount: 5, maxReachedFraction: 0.95 });
|
|
57
|
+
const totalLog = result.logBuckets.reduce((s, b) => s + b.totalWeight, 0);
|
|
58
|
+
expect(totalLog).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
});
|