@energy8platform/stake-math-tools 0.4.0 → 0.6.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 +13 -0
- package/src/optimize-lookup.ts +174 -19
- package/src/stake-report.ts +145 -0
- package/src/tiered.ts +1832 -0
- package/src/transform-jsonl-zst.ts +285 -0
- package/src/types.ts +141 -0
- package/test/optimize-lookup.integration.test.ts +423 -0
- package/test/optimize-lookup.unit.test.ts +2 -0
- package/test/transform-jsonl-zst.test.ts +343 -0
|
@@ -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.
|
|
@@ -195,7 +200,9 @@ describe('integration', () => {
|
|
|
195
200
|
nRowsOut: 10_000,
|
|
196
201
|
requireMaxReached: true,
|
|
197
202
|
maxRowRtpShare: 0.05,
|
|
203
|
+
maxWeightPerRow: Infinity, // isolate RTP-share cap from weight cap
|
|
198
204
|
maxIterations: 2,
|
|
205
|
+
algorithm: 'nnls',
|
|
199
206
|
});
|
|
200
207
|
|
|
201
208
|
expect(result.maxRowRtpShare).toBeLessThanOrEqual(0.05 + 0.001); // tiny epsilon for quantize rounding
|
|
@@ -222,6 +229,144 @@ describe('integration', () => {
|
|
|
222
229
|
expect(result.warnings.find(w => w.includes('maxRowRtpShare'))).toBeUndefined();
|
|
223
230
|
});
|
|
224
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
|
+
|
|
225
370
|
it('6. handles nRowsOut=5000 without n² memory blowup', () => {
|
|
226
371
|
// Pre-fix this would allocate a 5000×5000 dense matrix (200 MB Float64);
|
|
227
372
|
// after the implicit-Tikhonov fix it should fit in well under 100 MB and
|
|
@@ -246,6 +391,7 @@ describe('integration', () => {
|
|
|
246
391
|
nRowsOut: 5_000,
|
|
247
392
|
requireMaxReached: false,
|
|
248
393
|
maxIterations: 1, // single pass — we're testing memory, not convergence
|
|
394
|
+
algorithm: 'nnls',
|
|
249
395
|
});
|
|
250
396
|
const elapsed = performance.now() - t0;
|
|
251
397
|
|
|
@@ -256,4 +402,281 @@ describe('integration', () => {
|
|
|
256
402
|
for (const r of result.rows) sum += r.weight;
|
|
257
403
|
expect(sum).toBe(5_000 * 1_000_000);
|
|
258
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
|
+
});
|
|
259
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);
|