@energy8platform/stake-math-tools 0.3.0 → 0.5.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 +223 -56
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/optimize-lookup.ts +324 -6
- package/src/stake-report.ts +145 -0
- package/src/tiered.ts +1428 -0
- package/src/types.ts +128 -0
- package/test/optimize-lookup.integration.test.ts +470 -0
- package/test/optimize-lookup.unit.test.ts +2 -0
package/src/types.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface OptimizeParams {
|
|
|
20
20
|
/** Hard cap. Rows with payoutCents > capMaxWin are dropped. */
|
|
21
21
|
capMaxWin: number;
|
|
22
22
|
|
|
23
|
+
/** Cost of a single bet in cents. Used to convert payouts to "bet multiplier" units
|
|
24
|
+
* for the Stake-style report. Default 100 (1.0 bet = 100 cents). */
|
|
25
|
+
betCostCents?: number;
|
|
26
|
+
|
|
23
27
|
/** When true, force ≥ 1 row with payoutCents ≥ maxReachedFraction × capMaxWin. Default true. */
|
|
24
28
|
requireMaxReached?: boolean;
|
|
25
29
|
/** Default 0.95. */
|
|
@@ -37,6 +41,56 @@ export interface OptimizeParams {
|
|
|
37
41
|
bucketCount?: number;
|
|
38
42
|
/** Minimum sample slots per non-empty non-zero bucket. Default 3. */
|
|
39
43
|
minPerBucket?: number;
|
|
44
|
+
|
|
45
|
+
/** Maximum fraction of total RTP that any single output row may contribute.
|
|
46
|
+
* Stake Engine's "Within Liability Limits" check fails when one row dominates RTP.
|
|
47
|
+
* Default 0.05 (5%). Set to 1.0 to disable.
|
|
48
|
+
*/
|
|
49
|
+
maxRowRtpShare?: number;
|
|
50
|
+
|
|
51
|
+
/** Maximum integer weight allowed for any single output row, as a multiple of the
|
|
52
|
+
* uniform prior weight (totalWeightOut / nRowsOut). E.g., 10 means no row can have
|
|
53
|
+
* weight greater than 10 × (totalWeightOut / nRowsOut). This prevents Stake's ETL
|
|
54
|
+
* ("Within Liability Limits") check from failing due to over-concentrated weight.
|
|
55
|
+
* Default 10. Set to Infinity to disable. */
|
|
56
|
+
maxWeightPerRow?: number;
|
|
57
|
+
|
|
58
|
+
/** Algorithm for compressing source rows into a weighted lookup table.
|
|
59
|
+
* - 'tiered' (default): tier-based rarity weighting (cap/large rows get weight=1,
|
|
60
|
+
* small rows get calculated weight W). Preserves source distribution rates;
|
|
61
|
+
* passes Stake Engine's "Within Liability Limits" check.
|
|
62
|
+
* - 'nnls': legacy NNLS optimization; hits RTP/CV/HR targets exactly but may
|
|
63
|
+
* concentrate weight on few rows and fail Stake's Liability check. */
|
|
64
|
+
algorithm?: 'tiered' | 'nnls';
|
|
65
|
+
|
|
66
|
+
/** Tier-based only: payout multiplier (payoutCents / betCostCents) above which
|
|
67
|
+
* a row is in the "cap" tier (weight=1, rare). Default: 0.95 × max source pm. */
|
|
68
|
+
capPmThreshold?: number;
|
|
69
|
+
|
|
70
|
+
/** Tier-based only: payout multiplier threshold for the "large" tier.
|
|
71
|
+
* Rows with capPmThreshold > pm >= largePmThreshold get weight=1.
|
|
72
|
+
* Default: undefined (no large tier — only cap vs small). */
|
|
73
|
+
largePmThreshold?: number;
|
|
74
|
+
|
|
75
|
+
/** Tier-based only: target effective probability for cap+large rows in output.
|
|
76
|
+
* Default: natural rate from source = (n_cap + n_large) / n_source. */
|
|
77
|
+
largeTarget?: number;
|
|
78
|
+
|
|
79
|
+
/** Tier-based only: when true, ensure every Stake hit-rate distribution range
|
|
80
|
+
* up to the actual max payout has ≥ 1 output row when source has rows in
|
|
81
|
+
* that range. Prevents Stake's "Gaps in the Hit Rate Table" rejection.
|
|
82
|
+
* Default true. */
|
|
83
|
+
ensureRangeCoverage?: boolean;
|
|
84
|
+
|
|
85
|
+
/** Tier-based only: minimum fraction of nRowsOut that must be distinct payoutCents
|
|
86
|
+
* values in the output. Stake Engine rejects "Insufficient Unique Events" when
|
|
87
|
+
* too few distinct outcomes exist (same events repeat in a session). Default 0.01
|
|
88
|
+
* (1%). For 100K output → 1K unique payouts required. Set to 0 to disable.
|
|
89
|
+
*
|
|
90
|
+
* When the target cannot be reached (source lacks enough distinct payouts, or
|
|
91
|
+
* RTP-drift budget exhausts), the optimizer falls back to maximizing unique
|
|
92
|
+
* count under the budget and emits a warning. */
|
|
93
|
+
minUniqueEventsRate?: number;
|
|
40
94
|
}
|
|
41
95
|
|
|
42
96
|
export interface OptimizeAchieved {
|
|
@@ -52,11 +106,85 @@ export interface ToleranceMet {
|
|
|
52
106
|
cv: boolean;
|
|
53
107
|
hitRate: boolean;
|
|
54
108
|
maxReached: boolean;
|
|
109
|
+
/** True if no output row contributes more than maxRowRtpShare of total RTP. */
|
|
110
|
+
rtpConcentration: boolean;
|
|
111
|
+
/** True if no output row's weight exceeds maxWeightPerRow × (totalWeightOut / nRowsOut). */
|
|
112
|
+
weightCap: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TopKShare {
|
|
116
|
+
/** Cumulative share of total RTP coming from the top-K rows (ordered by w·payout descending). */
|
|
117
|
+
k: number;
|
|
118
|
+
share: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface HitRateBucket {
|
|
122
|
+
/** Inclusive lower bound of the payout-multiplier range. */
|
|
123
|
+
low: number;
|
|
124
|
+
/** Exclusive upper bound (Infinity for the open top bucket). */
|
|
125
|
+
high: number;
|
|
126
|
+
/** Number of distinct output rows with pm in [low, high). */
|
|
127
|
+
count: number;
|
|
128
|
+
/** Σ weight in this range / Σ weight total — the player-facing probability. */
|
|
129
|
+
effectiveHitRate: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface StakeReport {
|
|
133
|
+
/** Maximum payout in the output, as a bet multiplier (payoutCents / betCostCents). */
|
|
134
|
+
payoutMultMax: number;
|
|
135
|
+
|
|
136
|
+
/** Standard deviation of payouts in bet-cost units (= stddev_payout_cents / betCostCents).
|
|
137
|
+
* Equivalent to cv × rtp × (100 / betCostCents). For bet=100 cents, equals cv × rtp × 1. */
|
|
138
|
+
baseStd: number;
|
|
139
|
+
|
|
140
|
+
/** Probability that a sampled spin pays ≥ 5000 × betCost. */
|
|
141
|
+
prob5K: number;
|
|
142
|
+
|
|
143
|
+
/** Probability that a sampled spin pays ≥ 10000 × betCost. */
|
|
144
|
+
prob10K: number;
|
|
145
|
+
|
|
146
|
+
/** Top-K cumulative RTP shares, sorted by per-row (w × payout) descending.
|
|
147
|
+
* Standard K values reported: 1, 5, 10, 100. */
|
|
148
|
+
topKShare: TopKShare[];
|
|
149
|
+
|
|
150
|
+
/** Stake's hit-rate-distribution table: payout-multiplier ranges with row count
|
|
151
|
+
* and effective probability. Ranges are: [0, 0.1), [0.1, 1), [1, 2), [2, 5),
|
|
152
|
+
* [5, 10), [10, 20), [20, 50), [50, 100), [100, 200), [200, 500), [500, 1000),
|
|
153
|
+
* [1000, 2000), [2000, 5000), [5000, 10000), [10000, 20000), [20000, ∞).
|
|
154
|
+
* Stake fails publication when any intermediate range is empty (gap). */
|
|
155
|
+
hitRateDistribution: HitRateBucket[];
|
|
156
|
+
|
|
157
|
+
/** Number of distinct payoutCents values in the output. Stake flags "Insufficient
|
|
158
|
+
* Unique Events" when this is too low — same outcomes repeat in a session. */
|
|
159
|
+
uniqueEvents: number;
|
|
160
|
+
|
|
161
|
+
/** Bet cost in cents used for the multiplier conversions (echoed from params). */
|
|
162
|
+
betCostCents: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface RefinementStats {
|
|
166
|
+
/** Single-row swaps applied during refineRtpBySwap to close residual RTP gap. */
|
|
167
|
+
rtpSwaps: number;
|
|
168
|
+
/** Σ-preserving 2-swaps applied during refineCvBySwap to nudge CV. */
|
|
169
|
+
cvSwaps: number;
|
|
170
|
+
/** Swaps applied to fill empty Stake distribution ranges (ensureRangeCoverage). */
|
|
171
|
+
gapFillSwaps: number;
|
|
172
|
+
/** Stake distribution ranges where source has no rows — gaps that cannot be filled. */
|
|
173
|
+
gapsUnfillable: number;
|
|
174
|
+
/** Swaps applied to introduce new distinct payoutCents into the output (minUniqueEventsRate). */
|
|
175
|
+
diversifySwaps: number;
|
|
55
176
|
}
|
|
56
177
|
|
|
57
178
|
export interface OptimizeResult {
|
|
58
179
|
rows: LookupRow[];
|
|
59
180
|
achieved: OptimizeAchieved;
|
|
60
181
|
toleranceMet: ToleranceMet;
|
|
182
|
+
/** The single output row's largest fraction of total RTP. */
|
|
183
|
+
maxRowRtpShare: number;
|
|
184
|
+
/** Maximum integer weight observed in output, as a multiple of uniform prior. */
|
|
185
|
+
maxWeightRatio: number;
|
|
186
|
+
/** Per-pass swap counters from the refinement loops. */
|
|
187
|
+
refinement: RefinementStats;
|
|
61
188
|
warnings: string[];
|
|
189
|
+
stakeReport: StakeReport;
|
|
62
190
|
}
|
|
@@ -51,6 +51,7 @@ describe('integration', () => {
|
|
|
51
51
|
nRowsOut: 200,
|
|
52
52
|
requireMaxReached: false,
|
|
53
53
|
maxIterations: 3,
|
|
54
|
+
algorithm: 'nnls',
|
|
54
55
|
});
|
|
55
56
|
expect(result.toleranceMet.rtp).toBe(true);
|
|
56
57
|
expect(result.toleranceMet.hitRate).toBe(true);
|
|
@@ -66,6 +67,7 @@ describe('integration', () => {
|
|
|
66
67
|
nRowsOut: 300,
|
|
67
68
|
requireMaxReached: false,
|
|
68
69
|
maxIterations: 3,
|
|
70
|
+
algorithm: 'nnls',
|
|
69
71
|
});
|
|
70
72
|
expect(result.toleranceMet.rtp).toBe(true);
|
|
71
73
|
});
|
|
@@ -80,6 +82,7 @@ describe('integration', () => {
|
|
|
80
82
|
nRowsOut: 100,
|
|
81
83
|
requireMaxReached: false,
|
|
82
84
|
maxIterations: 2,
|
|
85
|
+
algorithm: 'nnls',
|
|
83
86
|
});
|
|
84
87
|
expect(result.toleranceMet.cv).toBe(false);
|
|
85
88
|
expect(result.warnings.some((w) => /CV/i.test(w))).toBe(true);
|
|
@@ -130,6 +133,7 @@ describe('integration', () => {
|
|
|
130
133
|
nRowsOut: 1000,
|
|
131
134
|
requireMaxReached: false,
|
|
132
135
|
maxIterations: 2,
|
|
136
|
+
algorithm: 'nnls',
|
|
133
137
|
});
|
|
134
138
|
const elapsed = performance.now() - t0;
|
|
135
139
|
|
|
@@ -161,6 +165,7 @@ describe('integration', () => {
|
|
|
161
165
|
nRowsOut: 1000,
|
|
162
166
|
requireMaxReached: false,
|
|
163
167
|
maxIterations: 3,
|
|
168
|
+
algorithm: 'nnls',
|
|
164
169
|
});
|
|
165
170
|
|
|
166
171
|
// Weighted hit-rate hits target.
|
|
@@ -175,6 +180,193 @@ describe('integration', () => {
|
|
|
175
180
|
expect(zeroRowFraction).toBeLessThan(0.85);
|
|
176
181
|
});
|
|
177
182
|
|
|
183
|
+
it('8. caps single-row RTP contribution to maxRowRtpShare', () => {
|
|
184
|
+
const rng = makeRng(8);
|
|
185
|
+
const rows: LookupRow[] = new Array(200_000);
|
|
186
|
+
for (let i = 0; i < 200_000; i++) {
|
|
187
|
+
const u = rng();
|
|
188
|
+
let p = 0;
|
|
189
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
190
|
+
if (u > 0.97) p = Math.floor(rng() * 50_000);
|
|
191
|
+
if (u > 0.9995) p = Math.floor(rng() * 5_000_000);
|
|
192
|
+
rows[i] = { sim: i, weight: 1 + Math.floor(rng() * 100), payoutCents: p };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const result = optimizeLookupTable(rows, {
|
|
196
|
+
targetRTP: 0.96, toleranceRTP: 0.005,
|
|
197
|
+
targetCV: 8.0, toleranceCV: 1.0,
|
|
198
|
+
targetHitRate: 0.30, toleranceHitRate: 0.02,
|
|
199
|
+
capMaxWin: 5_000_000,
|
|
200
|
+
nRowsOut: 10_000,
|
|
201
|
+
requireMaxReached: true,
|
|
202
|
+
maxRowRtpShare: 0.05,
|
|
203
|
+
maxWeightPerRow: Infinity, // isolate RTP-share cap from weight cap
|
|
204
|
+
maxIterations: 2,
|
|
205
|
+
algorithm: 'nnls',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result.maxRowRtpShare).toBeLessThanOrEqual(0.05 + 0.001); // tiny epsilon for quantize rounding
|
|
209
|
+
expect(result.toleranceMet.rtpConcentration).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('9. respects maxRowRtpShare=1.0 (disabled cap, preserves old behavior)', () => {
|
|
213
|
+
const rng = makeRng(9);
|
|
214
|
+
const rows: LookupRow[] = [];
|
|
215
|
+
for (let i = 0; i < 5000; i++) {
|
|
216
|
+
rows.push({ sim: i, weight: 1, payoutCents: rng() > 0.7 ? Math.floor(rng() * 5000) : 0 });
|
|
217
|
+
}
|
|
218
|
+
const result = optimizeLookupTable(rows, {
|
|
219
|
+
targetRTP: 0.5, toleranceRTP: 0.5,
|
|
220
|
+
targetCV: 3, toleranceCV: 100,
|
|
221
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
222
|
+
capMaxWin: 5000,
|
|
223
|
+
nRowsOut: 500,
|
|
224
|
+
requireMaxReached: false,
|
|
225
|
+
maxRowRtpShare: 1.0,
|
|
226
|
+
maxIterations: 2,
|
|
227
|
+
});
|
|
228
|
+
// With disabled cap, no warning about concentration
|
|
229
|
+
expect(result.warnings.find(w => w.includes('maxRowRtpShare'))).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('10. stakeReport — basic metrics and topKShare structure', () => {
|
|
233
|
+
// Simple input: 1000 rows, mix of zero/small/large payouts
|
|
234
|
+
const rng = makeRng(10);
|
|
235
|
+
const rows: LookupRow[] = [];
|
|
236
|
+
for (let i = 0; i < 5000; i++) {
|
|
237
|
+
let p = 0;
|
|
238
|
+
const u = rng();
|
|
239
|
+
if (u > 0.7) p = Math.floor(rng() * 1000);
|
|
240
|
+
if (u > 0.97) p = Math.floor(rng() * 50_000);
|
|
241
|
+
rows.push({ sim: i, weight: 1 + Math.floor(rng() * 100), payoutCents: p });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = optimizeLookupTable(rows, {
|
|
245
|
+
targetRTP: 0.5, toleranceRTP: 0.2,
|
|
246
|
+
targetCV: 3, toleranceCV: 5,
|
|
247
|
+
targetHitRate: 0.3, toleranceHitRate: 0.1,
|
|
248
|
+
capMaxWin: 50_000,
|
|
249
|
+
nRowsOut: 500,
|
|
250
|
+
requireMaxReached: false,
|
|
251
|
+
maxIterations: 1,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(result.stakeReport).toBeDefined();
|
|
255
|
+
expect(result.stakeReport.betCostCents).toBe(100); // default
|
|
256
|
+
expect(result.stakeReport.payoutMultMax).toBeCloseTo(result.achieved.maxPayout / 100, 6);
|
|
257
|
+
expect(result.stakeReport.baseStd).toBeGreaterThanOrEqual(0);
|
|
258
|
+
expect(result.stakeReport.prob5K).toBeGreaterThanOrEqual(0);
|
|
259
|
+
expect(result.stakeReport.prob5K).toBeLessThanOrEqual(1);
|
|
260
|
+
expect(result.stakeReport.prob10K).toBeLessThanOrEqual(result.stakeReport.prob5K);
|
|
261
|
+
|
|
262
|
+
// topKShare should have entries for K=1, 5, 10, 100
|
|
263
|
+
expect(result.stakeReport.topKShare.map(t => t.k)).toEqual([1, 5, 10, 100]);
|
|
264
|
+
// Monotonically non-decreasing
|
|
265
|
+
for (let i = 1; i < result.stakeReport.topKShare.length; i++) {
|
|
266
|
+
expect(result.stakeReport.topKShare[i].share).toBeGreaterThanOrEqual(
|
|
267
|
+
result.stakeReport.topKShare[i - 1].share,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
// Top-1 share matches maxRowRtpShare exactly
|
|
271
|
+
expect(result.stakeReport.topKShare[0].share).toBeCloseTo(result.maxRowRtpShare, 6);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('11. stakeReport — respects betCostCents parameter', () => {
|
|
275
|
+
const rows: LookupRow[] = [];
|
|
276
|
+
for (let i = 0; i < 2000; i++) {
|
|
277
|
+
rows.push({
|
|
278
|
+
sim: i,
|
|
279
|
+
weight: 10,
|
|
280
|
+
payoutCents: i % 5 === 0 ? Math.floor(Math.random() * 5000) : 0,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// With betCostCents = 100, max payout 5000 → payoutMultMax = 50
|
|
285
|
+
// (Disable gap-fill so the output is strictly determined by sampling +
|
|
286
|
+
// refinement; gap-fill behavior depends on betCost via the Stake range
|
|
287
|
+
// boundaries, which would break the betCost-proportionality check on
|
|
288
|
+
// baseStd below.)
|
|
289
|
+
const r1 = optimizeLookupTable(rows, {
|
|
290
|
+
targetRTP: 0.1, toleranceRTP: 0.5,
|
|
291
|
+
targetCV: 3, toleranceCV: 100,
|
|
292
|
+
targetHitRate: 0.2, toleranceHitRate: 0.5,
|
|
293
|
+
capMaxWin: 5000,
|
|
294
|
+
nRowsOut: 200,
|
|
295
|
+
requireMaxReached: false,
|
|
296
|
+
maxIterations: 1,
|
|
297
|
+
betCostCents: 100,
|
|
298
|
+
ensureRangeCoverage: false,
|
|
299
|
+
});
|
|
300
|
+
expect(r1.stakeReport.payoutMultMax).toBeCloseTo(r1.achieved.maxPayout / 100, 6);
|
|
301
|
+
|
|
302
|
+
// With betCostCents = 200, multipliers halve
|
|
303
|
+
const r2 = optimizeLookupTable(rows, {
|
|
304
|
+
targetRTP: 0.1, toleranceRTP: 0.5,
|
|
305
|
+
targetCV: 3, toleranceCV: 100,
|
|
306
|
+
targetHitRate: 0.2, toleranceHitRate: 0.5,
|
|
307
|
+
capMaxWin: 5000,
|
|
308
|
+
nRowsOut: 200,
|
|
309
|
+
requireMaxReached: false,
|
|
310
|
+
maxIterations: 1,
|
|
311
|
+
betCostCents: 200,
|
|
312
|
+
ensureRangeCoverage: false,
|
|
313
|
+
});
|
|
314
|
+
expect(r2.stakeReport.payoutMultMax).toBeCloseTo(r2.achieved.maxPayout / 200, 6);
|
|
315
|
+
expect(r2.stakeReport.baseStd).toBeCloseTo(r1.stakeReport.baseStd / 2, 5);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('12. caps single-row weight to maxWeightPerRow × prior', () => {
|
|
319
|
+
const rng = makeRng(12);
|
|
320
|
+
const rows: LookupRow[] = new Array(100_000);
|
|
321
|
+
for (let i = 0; i < 100_000; i++) {
|
|
322
|
+
const u = rng();
|
|
323
|
+
let p = 0;
|
|
324
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
325
|
+
if (u > 0.97) p = Math.floor(rng() * 50_000);
|
|
326
|
+
if (u > 0.9995) p = Math.floor(rng() * 1_000_000);
|
|
327
|
+
rows[i] = { sim: i, weight: 1 + Math.floor(rng() * 100), payoutCents: p };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = optimizeLookupTable(rows, {
|
|
331
|
+
targetRTP: 0.96, toleranceRTP: 0.01,
|
|
332
|
+
targetCV: 5.0, toleranceCV: 2.0,
|
|
333
|
+
targetHitRate: 0.20, toleranceHitRate: 0.05,
|
|
334
|
+
capMaxWin: 1_000_000,
|
|
335
|
+
nRowsOut: 1000,
|
|
336
|
+
requireMaxReached: false,
|
|
337
|
+
maxWeightPerRow: 10, // cap at 10× prior
|
|
338
|
+
maxIterations: 2,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const uniformPrior = (1000 * 1_000_000) / 1000; // = 1_000_000
|
|
342
|
+
const maxAllowedWeight = 10 * uniformPrior;
|
|
343
|
+
for (const r of result.rows) {
|
|
344
|
+
expect(r.weight).toBeLessThanOrEqual(maxAllowedWeight + 1);
|
|
345
|
+
}
|
|
346
|
+
expect(result.maxWeightRatio).toBeLessThanOrEqual(10 + 1e-6);
|
|
347
|
+
expect(result.toleranceMet.weightCap).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('13. maxWeightPerRow=Infinity disables the cap (preserves old behavior)', () => {
|
|
351
|
+
const rng = makeRng(13);
|
|
352
|
+
const rows: LookupRow[] = new Array(50_000);
|
|
353
|
+
for (let i = 0; i < 50_000; i++) {
|
|
354
|
+
rows[i] = { sim: i, weight: 1, payoutCents: rng() > 0.7 ? Math.floor(rng() * 5000) : 0 };
|
|
355
|
+
}
|
|
356
|
+
const result = optimizeLookupTable(rows, {
|
|
357
|
+
targetRTP: 0.5, toleranceRTP: 0.5,
|
|
358
|
+
targetCV: 3, toleranceCV: 100,
|
|
359
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
360
|
+
capMaxWin: 5000,
|
|
361
|
+
nRowsOut: 1000,
|
|
362
|
+
requireMaxReached: false,
|
|
363
|
+
maxWeightPerRow: Infinity,
|
|
364
|
+
maxIterations: 1,
|
|
365
|
+
});
|
|
366
|
+
// No weight-cap warning when disabled
|
|
367
|
+
expect(result.warnings.find(w => w.includes('maxWeightPerRow'))).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
|
|
178
370
|
it('6. handles nRowsOut=5000 without n² memory blowup', () => {
|
|
179
371
|
// Pre-fix this would allocate a 5000×5000 dense matrix (200 MB Float64);
|
|
180
372
|
// after the implicit-Tikhonov fix it should fit in well under 100 MB and
|
|
@@ -199,6 +391,7 @@ describe('integration', () => {
|
|
|
199
391
|
nRowsOut: 5_000,
|
|
200
392
|
requireMaxReached: false,
|
|
201
393
|
maxIterations: 1, // single pass — we're testing memory, not convergence
|
|
394
|
+
algorithm: 'nnls',
|
|
202
395
|
});
|
|
203
396
|
const elapsed = performance.now() - t0;
|
|
204
397
|
|
|
@@ -209,4 +402,281 @@ describe('integration', () => {
|
|
|
209
402
|
for (const r of result.rows) sum += r.weight;
|
|
210
403
|
expect(sum).toBe(5_000 * 1_000_000);
|
|
211
404
|
});
|
|
405
|
+
|
|
406
|
+
it('14. tiered algorithm — preserves source distribution and bounds weight', () => {
|
|
407
|
+
const rng = makeRng(14);
|
|
408
|
+
const rows: LookupRow[] = new Array(100_000);
|
|
409
|
+
for (let i = 0; i < 100_000; i++) {
|
|
410
|
+
const u = rng();
|
|
411
|
+
let p = 0;
|
|
412
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
413
|
+
if (u > 0.97) p = Math.floor(rng() * 5_000);
|
|
414
|
+
if (u > 0.999) p = Math.floor(rng() * 100_000);
|
|
415
|
+
rows[i] = { sim: i, weight: 1, payoutCents: p };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = optimizeLookupTable(rows, {
|
|
419
|
+
targetRTP: 0.5, toleranceRTP: 1.0,
|
|
420
|
+
targetCV: 5, toleranceCV: 100,
|
|
421
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
422
|
+
capMaxWin: 100_000,
|
|
423
|
+
nRowsOut: 10_000,
|
|
424
|
+
requireMaxReached: false,
|
|
425
|
+
algorithm: 'tiered',
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(result.rows).toHaveLength(10_000);
|
|
429
|
+
// Tier-based should keep maxWeightRatio bounded (typically ~1 for high tier, W for small)
|
|
430
|
+
// No row should have astronomical weight
|
|
431
|
+
let maxWeight = 0;
|
|
432
|
+
for (const r of result.rows) {
|
|
433
|
+
if (r.weight > maxWeight) maxWeight = r.weight;
|
|
434
|
+
}
|
|
435
|
+
// Tier-based bounds: cap=1, large=1, small=W. W is computed but typically modest.
|
|
436
|
+
// For this test, just check W isn't astronomical (< 1M).
|
|
437
|
+
expect(maxWeight).toBeLessThan(1_000_000);
|
|
438
|
+
|
|
439
|
+
// Stake report present
|
|
440
|
+
expect(result.stakeReport).toBeDefined();
|
|
441
|
+
expect(result.stakeReport.topKShare).toHaveLength(4);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('15. tiered algorithm — explicit largeTarget controls effective rate', () => {
|
|
445
|
+
const rng = makeRng(15);
|
|
446
|
+
const rows: LookupRow[] = [];
|
|
447
|
+
for (let i = 0; i < 50_000; i++) {
|
|
448
|
+
let p = 0;
|
|
449
|
+
const u = rng();
|
|
450
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
451
|
+
if (u > 0.99) p = Math.floor(rng() * 50_000); // ~1% large rows in source
|
|
452
|
+
rows.push({ sim: i, weight: 1, payoutCents: p });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const result = optimizeLookupTable(rows, {
|
|
456
|
+
targetRTP: 0.5, toleranceRTP: 1.0,
|
|
457
|
+
targetCV: 5, toleranceCV: 100,
|
|
458
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
459
|
+
capMaxWin: 50_000,
|
|
460
|
+
nRowsOut: 5_000,
|
|
461
|
+
requireMaxReached: false,
|
|
462
|
+
algorithm: 'tiered',
|
|
463
|
+
largePmThreshold: 100, // pm >= 100 (= payout >= 10000 cents) = "large"
|
|
464
|
+
largeTarget: 0.001, // 0.1% effective probability
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Find total weight on rows with payout >= 10000 cents
|
|
468
|
+
let largeWeight = 0, totalWeight = 0;
|
|
469
|
+
for (const r of result.rows) {
|
|
470
|
+
totalWeight += r.weight;
|
|
471
|
+
if (r.payoutCents >= 10_000) largeWeight += r.weight;
|
|
472
|
+
}
|
|
473
|
+
const effectiveLargeRate = largeWeight / totalWeight;
|
|
474
|
+
// Should be close to 0.001 (the largeTarget)
|
|
475
|
+
expect(effectiveLargeRate).toBeGreaterThan(0.0005);
|
|
476
|
+
expect(effectiveLargeRate).toBeLessThan(0.005);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('17. tiered honors targetHitRate via sample bias', () => {
|
|
480
|
+
// Source: 5% non-zero, but we'll target 30%
|
|
481
|
+
const rng = makeRng(17);
|
|
482
|
+
const rows: LookupRow[] = [];
|
|
483
|
+
for (let i = 0; i < 100_000; i++) {
|
|
484
|
+
const u = rng();
|
|
485
|
+
let p = 0;
|
|
486
|
+
if (u > 0.95) p = Math.floor(rng() * 1000); // 5% non-zero
|
|
487
|
+
if (u > 0.999) p = Math.floor(rng() * 100_000); // 0.1% high
|
|
488
|
+
rows.push({ sim: i, weight: 1, payoutCents: p });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const result = optimizeLookupTable(rows, {
|
|
492
|
+
targetRTP: 0.5, toleranceRTP: 1.0,
|
|
493
|
+
targetCV: 5, toleranceCV: 100,
|
|
494
|
+
targetHitRate: 0.30, // target above source 5%
|
|
495
|
+
toleranceHitRate: 0.05,
|
|
496
|
+
capMaxWin: 100_000,
|
|
497
|
+
nRowsOut: 10_000,
|
|
498
|
+
requireMaxReached: false,
|
|
499
|
+
algorithm: 'tiered',
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// achieved hit-rate should be close to target 0.30
|
|
503
|
+
expect(result.achieved.hitRate).toBeGreaterThan(0.25);
|
|
504
|
+
expect(result.achieved.hitRate).toBeLessThan(0.35);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('18. tiered emits warning when targetHitRate unreachable', () => {
|
|
508
|
+
// Source has too few non-zero rows for high target
|
|
509
|
+
const rows: LookupRow[] = [];
|
|
510
|
+
for (let i = 0; i < 10_000; i++) {
|
|
511
|
+
// Only 1% non-zero
|
|
512
|
+
rows.push({ sim: i, weight: 1, payoutCents: i < 100 ? 1000 : 0 });
|
|
513
|
+
}
|
|
514
|
+
const result = optimizeLookupTable(rows, {
|
|
515
|
+
targetRTP: 0.5, toleranceRTP: 1.0,
|
|
516
|
+
targetCV: 5, toleranceCV: 100,
|
|
517
|
+
targetHitRate: 0.50, // 50% target but only 1% non-zero available
|
|
518
|
+
toleranceHitRate: 0.05,
|
|
519
|
+
capMaxWin: 10_000,
|
|
520
|
+
nRowsOut: 1000,
|
|
521
|
+
requireMaxReached: false,
|
|
522
|
+
algorithm: 'tiered',
|
|
523
|
+
});
|
|
524
|
+
// Should emit warning about unreachable target
|
|
525
|
+
expect(result.warnings.some(w => w.includes('non-zero'))).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('19. tiered hits both hitRate AND RTP targets via dual biasing', () => {
|
|
529
|
+
const rng = makeRng(19);
|
|
530
|
+
const rows: LookupRow[] = [];
|
|
531
|
+
for (let i = 0; i < 200_000; i++) {
|
|
532
|
+
const u = rng();
|
|
533
|
+
let p = 0;
|
|
534
|
+
if (u > 0.85) p = Math.floor(rng() * 200); // small wins
|
|
535
|
+
if (u > 0.99) p = Math.floor(rng() * 5000); // mid wins
|
|
536
|
+
if (u > 0.9999) p = Math.floor(rng() * 50_000); // big
|
|
537
|
+
rows.push({ sim: i, weight: 1, payoutCents: p });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const result = optimizeLookupTable(rows, {
|
|
541
|
+
targetRTP: 0.96,
|
|
542
|
+
toleranceRTP: 0.03, // 3pp tolerance for tier-based (less precise than NNLS)
|
|
543
|
+
targetCV: 5, toleranceCV: 100,
|
|
544
|
+
targetHitRate: 0.20, // bias above source ~15%
|
|
545
|
+
toleranceHitRate: 0.02,
|
|
546
|
+
capMaxWin: 50_000,
|
|
547
|
+
nRowsOut: 10_000,
|
|
548
|
+
requireMaxReached: false,
|
|
549
|
+
algorithm: 'tiered',
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Both targets met
|
|
553
|
+
expect(result.achieved.hitRate).toBeGreaterThan(0.17);
|
|
554
|
+
expect(result.achieved.hitRate).toBeLessThan(0.23);
|
|
555
|
+
expect(result.achieved.rtp).toBeGreaterThan(0.92);
|
|
556
|
+
expect(result.achieved.rtp).toBeLessThan(1.00);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('16. NNLS algorithm still works via algorithm: "nnls"', () => {
|
|
560
|
+
const rng = makeRng(16);
|
|
561
|
+
const rows: LookupRow[] = [];
|
|
562
|
+
for (let i = 0; i < 5_000; i++) {
|
|
563
|
+
rows.push({ sim: i, weight: 1, payoutCents: rng() > 0.7 ? Math.floor(rng() * 5000) : 0 });
|
|
564
|
+
}
|
|
565
|
+
const result = optimizeLookupTable(rows, {
|
|
566
|
+
targetRTP: 0.5, toleranceRTP: 0.3,
|
|
567
|
+
targetCV: 3, toleranceCV: 100,
|
|
568
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
569
|
+
capMaxWin: 5000,
|
|
570
|
+
nRowsOut: 500,
|
|
571
|
+
requireMaxReached: false,
|
|
572
|
+
algorithm: 'nnls',
|
|
573
|
+
maxRowRtpShare: 0.1,
|
|
574
|
+
maxWeightPerRow: Infinity,
|
|
575
|
+
maxIterations: 1,
|
|
576
|
+
});
|
|
577
|
+
// NNLS produces valid output
|
|
578
|
+
expect(result.rows).toHaveLength(500);
|
|
579
|
+
expect(result.stakeReport).toBeDefined();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('20. tiered fills intermediate hit-rate distribution gaps when source has rows', () => {
|
|
583
|
+
// Construct source where natural stratified sampling would likely miss a range:
|
|
584
|
+
// many rows in [0, ~2)x bet, one row in [100, 200)x bet, no rows above.
|
|
585
|
+
const rows: LookupRow[] = [];
|
|
586
|
+
for (let i = 0; i < 50_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 0 });
|
|
587
|
+
for (let i = 50_000; i < 60_000; i++) {
|
|
588
|
+
rows.push({ sim: i, weight: 1, payoutCents: 100 + (i % 100) }); // pm in [1, 2)
|
|
589
|
+
}
|
|
590
|
+
// Single row in [100, 200) — sampler must keep it to avoid creating a gap.
|
|
591
|
+
rows.push({ sim: 99999, weight: 1, payoutCents: 15000 }); // pm 150
|
|
592
|
+
|
|
593
|
+
const result = optimizeLookupTable(rows, {
|
|
594
|
+
targetRTP: 0.05, toleranceRTP: 1.0,
|
|
595
|
+
targetCV: 3, toleranceCV: 100,
|
|
596
|
+
targetHitRate: 0.2, toleranceHitRate: 0.5,
|
|
597
|
+
capMaxWin: 100_000,
|
|
598
|
+
nRowsOut: 1000,
|
|
599
|
+
requireMaxReached: false,
|
|
600
|
+
algorithm: 'tiered',
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// The [100, 200) range should have ≥ 1 row in output (source has it).
|
|
604
|
+
const bucket = result.stakeReport.hitRateDistribution.find(
|
|
605
|
+
(b) => b.low === 100 && b.high === 200,
|
|
606
|
+
);
|
|
607
|
+
expect(bucket?.count).toBeGreaterThanOrEqual(1);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('21. tiered warns when a range is unfillable (no source rows)', () => {
|
|
611
|
+
// Source has rows in [0.5, 1)x bet and a high cluster around 15000x bet,
|
|
612
|
+
// nothing in between. The intermediate ranges [1, 10000) are unfillable.
|
|
613
|
+
const rows: LookupRow[] = [];
|
|
614
|
+
for (let i = 0; i < 50_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 0 });
|
|
615
|
+
for (let i = 50_000; i < 51_000; i++) {
|
|
616
|
+
rows.push({ sim: i, weight: 1, payoutCents: 50 }); // pm 0.5
|
|
617
|
+
}
|
|
618
|
+
for (let i = 51_000; i < 51_010; i++) {
|
|
619
|
+
rows.push({ sim: i, weight: 1, payoutCents: 1_500_000 }); // pm 15000
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const result = optimizeLookupTable(rows, {
|
|
623
|
+
targetRTP: 1.0, toleranceRTP: 1.0,
|
|
624
|
+
targetCV: 3, toleranceCV: 100,
|
|
625
|
+
targetHitRate: 0.2, toleranceHitRate: 0.5,
|
|
626
|
+
capMaxWin: 1_500_000,
|
|
627
|
+
nRowsOut: 500,
|
|
628
|
+
requireMaxReached: false,
|
|
629
|
+
algorithm: 'tiered',
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Should emit a warning about an unfillable gap.
|
|
633
|
+
const gapWarning = result.warnings.find((w) => w.includes('source has no rows'));
|
|
634
|
+
expect(gapWarning).toBeDefined();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('22. tiered diversifies to reach minUniqueEventsRate target', () => {
|
|
638
|
+
// Source has many duplicate payouts (lots of payoutCents=100 wins)
|
|
639
|
+
const rows: LookupRow[] = [];
|
|
640
|
+
for (let i = 0; i < 50_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 0 });
|
|
641
|
+
for (let i = 50_000; i < 60_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 100 }); // all same
|
|
642
|
+
// But also lots of unique payouts available
|
|
643
|
+
for (let i = 60_000; i < 70_000; i++) {
|
|
644
|
+
rows.push({ sim: i, weight: 1, payoutCents: 100 + i }); // each unique
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const result = optimizeLookupTable(rows, {
|
|
648
|
+
targetRTP: 0.5, toleranceRTP: 1.0,
|
|
649
|
+
targetCV: 3, toleranceCV: 100,
|
|
650
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
651
|
+
capMaxWin: 100_000,
|
|
652
|
+
nRowsOut: 1000,
|
|
653
|
+
requireMaxReached: false,
|
|
654
|
+
algorithm: 'tiered',
|
|
655
|
+
minUniqueEventsRate: 0.05, // 5% = 50 unique payouts required
|
|
656
|
+
});
|
|
657
|
+
expect(result.stakeReport.uniqueEvents).toBeGreaterThanOrEqual(50);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('23. tiered warns when minUniqueEventsRate is unreachable', () => {
|
|
661
|
+
// Source has ONLY 5 unique payout values, but target wants 100
|
|
662
|
+
const rows: LookupRow[] = [];
|
|
663
|
+
for (let i = 0; i < 50_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 0 });
|
|
664
|
+
for (let i = 50_000; i < 55_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 100 });
|
|
665
|
+
for (let i = 55_000; i < 56_000; i++) rows.push({ sim: i, weight: 1, payoutCents: 200 });
|
|
666
|
+
for (let i = 56_000; i < 56_500; i++) rows.push({ sim: i, weight: 1, payoutCents: 500 });
|
|
667
|
+
for (let i = 56_500; i < 56_600; i++) rows.push({ sim: i, weight: 1, payoutCents: 1000 });
|
|
668
|
+
|
|
669
|
+
const result = optimizeLookupTable(rows, {
|
|
670
|
+
targetRTP: 0.05, toleranceRTP: 1.0,
|
|
671
|
+
targetCV: 3, toleranceCV: 100,
|
|
672
|
+
targetHitRate: 0.2, toleranceHitRate: 0.5,
|
|
673
|
+
capMaxWin: 1000,
|
|
674
|
+
nRowsOut: 10_000,
|
|
675
|
+
requireMaxReached: false,
|
|
676
|
+
algorithm: 'tiered',
|
|
677
|
+
minUniqueEventsRate: 0.05, // wants 500 unique, source has 5
|
|
678
|
+
});
|
|
679
|
+
const warn = result.warnings.find((w) => w.includes('minUniqueEventsRate'));
|
|
680
|
+
expect(warn).toBeDefined();
|
|
681
|
+
});
|
|
212
682
|
});
|
|
@@ -36,6 +36,7 @@ describe('optimizeLookupTable', () => {
|
|
|
36
36
|
targetHitRate: 0.3, toleranceHitRate: 0.05,
|
|
37
37
|
capMaxWin: 100_000,
|
|
38
38
|
nRowsOut: 100,
|
|
39
|
+
algorithm: 'nnls',
|
|
39
40
|
});
|
|
40
41
|
expect(result.rows).toHaveLength(100);
|
|
41
42
|
let sum = 0;
|
|
@@ -76,6 +77,7 @@ describe('optimizeLookupTable', () => {
|
|
|
76
77
|
nRowsOut: 50,
|
|
77
78
|
requireMaxReached: false,
|
|
78
79
|
maxIterations: 2,
|
|
80
|
+
algorithm: 'nnls',
|
|
79
81
|
});
|
|
80
82
|
expect(result.toleranceMet.cv).toBe(false);
|
|
81
83
|
expect(result.warnings.length).toBeGreaterThan(0);
|