@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/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);