@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.
@@ -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
+ }
@@ -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,9 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`weightedReservoirSample (A-Res) > produces stable output for a given seed (snapshot) 1`] = `
4
+ [
5
+ 2,
6
+ 3,
7
+ 8,
8
+ ]
9
+ `;
@@ -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
+ });