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