@energy8platform/stake-math-tools 0.5.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/src/tiered.ts CHANGED
@@ -34,6 +34,90 @@ export function buildTieredLookup(
34
34
  rowsIn: Iterable<LookupRow>,
35
35
  params: OptimizeParams,
36
36
  ): OptimizeResult {
37
+ // shapeAutoMatchCV: pick shapeDecayRatio so achieved CV lands at
38
+ // targetCV within toleranceCV. CV(ratio) is U-shaped — low ratios shrink
39
+ // the high tier so much that total weight T drops and per-row variance
40
+ // climbs back up, so naive bisection can get stuck on the wrong side of
41
+ // the minimum. Use a coarse 5-point grid sweep first, then refine around
42
+ // the closest-to-target probe. Disables itself on the recursive calls
43
+ // via `shapeAutoMatchCV: false` so the inner build takes the fast path.
44
+ if (
45
+ params.shapeAutoMatchCV &&
46
+ params.shapeDistribution &&
47
+ params.targetCV !== undefined &&
48
+ params.targetCV > 0
49
+ ) {
50
+ // Cache the rows so we don't re-iterate a one-shot Iterable across
51
+ // multiple inner runs.
52
+ const cachedRows: LookupRow[] = [];
53
+ for (const r of rowsIn) cachedRows.push(r);
54
+
55
+ const targetCV = params.targetCV;
56
+ const tolerance = Math.max(0.01, params.toleranceCV);
57
+ const inner = { ...params, shapeAutoMatchCV: false };
58
+ const trail: Array<{ ratio: number; cv: number; result: OptimizeResult }> = [];
59
+
60
+ const run = (r: number): { cv: number; result: OptimizeResult } => {
61
+ const result = buildTieredLookup(cachedRows, { ...inner, shapeDecayRatio: r });
62
+ const cv = result.achieved.cv;
63
+ trail.push({ ratio: r, cv, result });
64
+ return { cv, result };
65
+ };
66
+
67
+ // Coarse sweep across [0.15, 0.85] — covers a typical operating range
68
+ // without spending an evaluation at the rail extremes (those tend to
69
+ // hit max(1, …) clamping and plateau).
70
+ const coarse = [0.15, 0.3, 0.5, 0.7, 0.85];
71
+ for (const r of coarse) {
72
+ const { cv } = run(r);
73
+ if (Math.abs(cv - targetCV) <= tolerance) {
74
+ // Lucky early exit.
75
+ const final = trail[trail.length - 1].result;
76
+ final.warnings.push(
77
+ `shapeAutoMatchCV: shapeDecayRatio=${r.toFixed(3)} hit CV=${cv.toFixed(2)} ` +
78
+ `vs target ${targetCV} on coarse sweep (${trail.length} runs)`,
79
+ );
80
+ return final;
81
+ }
82
+ }
83
+
84
+ // Refine: bisect between the best probe and its closest CV-neighbour on
85
+ // the side of `targetCV` we need to move toward. If targetCV > best.cv,
86
+ // we want to RAISE CV — find the probe with CV just above target and
87
+ // bisect between best.ratio and that probe's ratio. Symmetric for the
88
+ // other direction. Skip refinement when no useful neighbour exists
89
+ // (best is on the same side of target as every other probe → we're at
90
+ // the structural minimum of the U-curve).
91
+ for (let refine = 0; refine < 2 && trail.length < 8; refine++) {
92
+ trail.sort((a, b) => Math.abs(a.cv - targetCV) - Math.abs(b.cv - targetCV));
93
+ const best = trail[0];
94
+ const needHigherCV = best.cv < targetCV;
95
+ const neighbour = trail
96
+ .slice(1)
97
+ .filter((t) => (needHigherCV ? t.cv > targetCV : t.cv < targetCV))
98
+ .sort((a, b) => Math.abs(a.cv - targetCV) - Math.abs(b.cv - targetCV))[0];
99
+ if (!neighbour || Math.abs(neighbour.ratio - best.ratio) < 0.02) break;
100
+ const mid = (best.ratio + neighbour.ratio) / 2;
101
+ if (trail.some((t) => Math.abs(t.ratio - mid) < 0.01)) break;
102
+ const { cv } = run(mid);
103
+ if (Math.abs(cv - targetCV) <= tolerance) break;
104
+ }
105
+
106
+ // Final pick: the smallest-gap probe overall.
107
+ trail.sort((a, b) => Math.abs(a.cv - targetCV) - Math.abs(b.cv - targetCV));
108
+ const winner = trail[0];
109
+ const finalResult = winner.result;
110
+ const gap = Math.abs(winner.cv - targetCV);
111
+ const sortedTrail = trail.slice().sort((a, b) => a.ratio - b.ratio);
112
+ finalResult.warnings.push(
113
+ `shapeAutoMatchCV: chose shapeDecayRatio=${winner.ratio.toFixed(3)} ` +
114
+ `→ CV=${winner.cv.toFixed(2)} vs target ${targetCV} (gap ${gap.toFixed(2)} ` +
115
+ `after ${trail.length} runs; CV(r) sweep: ` +
116
+ `${sortedTrail.map((t) => `${t.ratio.toFixed(2)}→${t.cv.toFixed(2)}`).join(', ')})`,
117
+ );
118
+ return finalResult;
119
+ }
120
+
37
121
  const betCost = params.betCostCents ?? DEFAULTS.betCostCents;
38
122
  const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
39
123
  const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
@@ -57,7 +141,16 @@ export function buildTieredLookup(
57
141
  const maxPm = sourceMetrics.maxPayout / betCost;
58
142
  const capPmThreshold = params.capPmThreshold ?? DEFAULTS.capPmFraction * maxPm;
59
143
  const capPayoutCents = Math.floor(capPmThreshold * betCost);
60
- const largePmThreshold = params.largePmThreshold; // undefined no large tier
144
+ // When shapeDistribution is on and the caller didn't carve out a `large`
145
+ // tier, auto-set one so the log-decay shape has multiple Stake buckets to
146
+ // span (otherwise it would only see the single cap bucket and the shape
147
+ // would be a no-op).
148
+ const shapeDistribution = params.shapeDistribution ?? false;
149
+ const shapeDecayRatio = params.shapeDecayRatio ?? 0.5;
150
+ let largePmThreshold = params.largePmThreshold;
151
+ if (shapeDistribution && largePmThreshold === undefined) {
152
+ largePmThreshold = Math.max(50, capPmThreshold / 20);
153
+ }
61
154
  const largePayoutCents =
62
155
  largePmThreshold !== undefined ? Math.floor(largePmThreshold * betCost) : undefined;
63
156
 
@@ -71,14 +164,58 @@ export function buildTieredLookup(
71
164
  else srcSmall.push(r);
72
165
  }
73
166
 
74
- // Target rate
75
- const target =
76
- params.largeTarget ?? (srcCap.length + srcLarge.length) / filtered.length;
167
+ // Target rate for cap+large probability mass in OUTPUT.
168
+ const naturalRate = (srcCap.length + srcLarge.length) / filtered.length;
169
+ const target = params.largeTarget ?? naturalRate;
77
170
 
78
- // Phase 4: pick output rows
79
- // Include all cap; include all large; fill remaining with small (random sample)
80
- let outCap = srcCap;
81
- let outLarge = srcLarge;
171
+ // Phase 4: pick output rows.
172
+ //
173
+ // When `largeTarget` is explicitly LOWER than the natural source rate, we
174
+ // MUST subsample cap+large or else Stake's "Max Win Achievability" check
175
+ // fails: keeping all 100K+ source rows at weight=1 forces W (and thus total
176
+ // weight) up by the ratio natural/target, making the single max-win row
177
+ // hide in a pool too large to be reachable at 1 in 20M.
178
+ //
179
+ // Subsampling target: keep approximately `target × nRowsOut` cap+large rows
180
+ // with weight=1, and fill the remaining slots with small-tier rows at
181
+ // weight ≈ 1. Total weight ≈ nRowsOut → P(max-win) = 1/nRowsOut (easily
182
+ // satisfies 1 in 20M for typical nRowsOut ≤ 200K).
183
+ let outCap: LookupRow[] = srcCap.slice();
184
+ let outLarge: LookupRow[] = srcLarge.slice();
185
+ const userTargetActive =
186
+ params.largeTarget !== undefined && params.largeTarget < naturalRate;
187
+ if (shapeDistribution) {
188
+ // Log-decay sample across Stake hit-rate buckets. Spreads cap+large
189
+ // rows so each higher bucket has roughly `ratio × prev` rows — fixes
190
+ // the typical `…18 → 1 → 1 → 1 → 4` cliff/spike at the tail.
191
+ const targetTotalCount = userTargetActive
192
+ ? Math.max(1, Math.round(target * params.nRowsOut))
193
+ : undefined;
194
+ ({ outCap, outLarge } = bucketDecaySampleHighTier(
195
+ srcCap,
196
+ srcLarge,
197
+ betCost,
198
+ capPayoutCents,
199
+ shapeDecayRatio,
200
+ targetTotalCount,
201
+ seed + 31,
202
+ ));
203
+ } else if (userTargetActive) {
204
+ // Allocation: try to keep ~target × nRowsOut rare rows. Cap rows get
205
+ // priority (preserves requireMaxReached); large rows fill the rest.
206
+ const desiredRareCount = Math.max(1, Math.round(target * params.nRowsOut));
207
+ const capKeep = Math.min(srcCap.length, desiredRareCount);
208
+ outCap = [...srcCap].sort((a, b) => b.payoutCents - a.payoutCents).slice(0, capKeep);
209
+ const largeBudget = Math.max(0, desiredRareCount - outCap.length);
210
+ if (largeBudget < srcLarge.length) {
211
+ // Stratified-by-log-payout sample so we preserve distribution shape
212
+ // across the large tier (instead of just taking top-N by payout).
213
+ outLarge =
214
+ largeBudget > 0
215
+ ? stratifiedSmallSampleNonZero(srcLarge, largeBudget, 50, seed + 31)
216
+ : [];
217
+ }
218
+ }
82
219
 
83
220
  if (outCap.length > params.nRowsOut) {
84
221
  // Too many cap rows — keep highest-payout
@@ -391,15 +528,19 @@ export function buildTieredLookup(
391
528
  // Phase 5 computes it, but for the budget we use the same prediction the
392
529
  // cv pass did).
393
530
  const T_out_predict2 = nHighOut2 + W * (outSmallZero.length + outSmallNonZero.length);
394
- // Remaining Σ-drift budget: total budget minus what CV already spent.
395
- const totalBudget = W > 0 && T_out_predict2 > 0
396
- ? 0.5 * params.toleranceRTP * T_out_predict2 * 100 / W
397
- : 0.005 * Math.abs(targetSmallNzSumP);
531
+ // Diversify budget = full user-supplied RTP-drift envelope minus what RTP+CV
532
+ // passes already spent. The earlier passes are capped at 0.5 × full_budget
533
+ // each, but typically use far less the leftover funds diversify. This
534
+ // guarantees cumulative drift across all refinement passes stays within
535
+ // params.toleranceRTP.
536
+ const fullBudget = W > 0 && T_out_predict2 > 0
537
+ ? params.toleranceRTP * T_out_predict2 * 100 / W
538
+ : 0.01 * Math.abs(targetSmallNzSumP);
398
539
  const spent =
399
540
  cvAchievedSum !== null && targetSmallNzSumP !== 0
400
541
  ? Math.abs(cvAchievedSum - targetSmallNzSumP)
401
542
  : 0;
402
- const sumBudget = Math.max(1, totalBudget - spent);
543
+ const sumBudget = Math.max(1, fullBudget - spent);
403
544
  // Make sure outSmallNonZero is sorted by payout ascending (gap-fill already
404
545
  // maintained this invariant when run; if gap-fill was skipped, sort here).
405
546
  outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
@@ -415,6 +556,77 @@ export function buildTieredLookup(
415
556
  diversifySwaps = divResult.swaps;
416
557
  }
417
558
 
559
+ // Phase 4d: final gap-fill then RTP polish.
560
+ //
561
+ // Run gap-fill FIRST so any range opened by diversify is restored. Then run
562
+ // polish with a "protected sims" set covering every small-non-zero row that
563
+ // is the only occupant of its Stake hit-rate range (counting cap + large +
564
+ // small-zero too). That way polish can never re-open a range it would
565
+ // otherwise have to fill in another cycle.
566
+ if (ensureRangeCoverage && outSmallNonZero.length > 0) {
567
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
568
+ const otherOutRows: LookupRow[] = [...outCap, ...outLarge, ...outSmallZero];
569
+ const gapResult = fillStakeRangeGaps(
570
+ outSmallNonZero,
571
+ srcSmallNonZeroAll,
572
+ otherOutRows,
573
+ sourceMetrics.maxPayout,
574
+ betCost,
575
+ warnings,
576
+ );
577
+ gapFillSwaps += gapResult.swapsApplied;
578
+ gapsUnfillable = Math.max(gapsUnfillable, gapResult.unfillable);
579
+ }
580
+
581
+ if (
582
+ targetSmallNzSumP > 0 &&
583
+ outSmallNonZero.length > 0 &&
584
+ srcSmallNonZeroAll.length > 0
585
+ ) {
586
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
587
+ const T_polish = (outCap.length + outLarge.length) + W * (outSmallZero.length + outSmallNonZero.length);
588
+ const polishTolerance =
589
+ W > 0 && T_polish > 0
590
+ ? Math.max(1, params.toleranceRTP * T_polish * 100 / W)
591
+ : Math.max(1, 0.001 * targetSmallNzSumP);
592
+
593
+ // Range-coverage guard for polish. Counts every Stake hit-rate bucket
594
+ // across the FULL output (cap + large + small-zero + small-non-zero).
595
+ // refineRtpBySwap consults `counts` before every swap-out and refuses
596
+ // any that would drop a bucket to 0, then updates `counts` after each
597
+ // accepted swap — so a range starting with N rows gets protected the
598
+ // moment polish has drained it to a single row. This is the dynamic
599
+ // replacement for the old static `protectedSims` set, which couldn't
600
+ // see polish depleting multi-row ranges one swap at a time.
601
+ let rangeProtect: SwapRangeProtect | undefined;
602
+ if (ensureRangeCoverage) {
603
+ const counts = new Map<number, number>();
604
+ const tally = (r: LookupRow): void => {
605
+ const idx = findRange(r.payoutCents, betCost);
606
+ counts.set(idx, (counts.get(idx) ?? 0) + 1);
607
+ };
608
+ for (const r of outCap) tally(r);
609
+ for (const r of outLarge) tally(r);
610
+ for (const r of outSmallZero) tally(r);
611
+ for (const r of outSmallNonZero) tally(r);
612
+ rangeProtect = {
613
+ getRange: (r) => findRange(r.payoutCents, betCost),
614
+ counts,
615
+ };
616
+ }
617
+
618
+ const polishRefined = refineRtpBySwap(
619
+ outSmallNonZero,
620
+ srcSmallNonZeroAll,
621
+ targetSmallNzSumP,
622
+ polishTolerance,
623
+ 10000,
624
+ rangeProtect,
625
+ );
626
+ outSmallNonZero = polishRefined.rows;
627
+ rtpSwaps += polishRefined.swaps;
628
+ }
629
+
418
630
  const outSmall: LookupRow[] = [...outSmallZero, ...outSmallNonZero];
419
631
 
420
632
  // Phase 5: compute W (recompute to match actual nSmall after sampling)
@@ -642,12 +854,25 @@ function rtpAwareSampleNonZero(
642
854
  * stays exactly k. Converges in O(K) swaps where K is the initial gap
643
855
  * measured in row-payout units.
644
856
  */
857
+ /** Optional dynamic protection for refineRtpBySwap. `getRange(row)` returns
858
+ * the row's group key (e.g. its Stake hit-rate bucket index). `counts` is
859
+ * the current per-group occupancy across the FULL output (caller pre-fills
860
+ * it including cap/large/other tiers); refineRtpBySwap decrements / increments
861
+ * it on every swap and refuses any swap-out that would drop a group's count
862
+ * to 0. This is what protects range coverage from being destroyed by the
863
+ * polish pass when multiple rows in a range can be picked off one by one. */
864
+ export interface SwapRangeProtect {
865
+ getRange(row: LookupRow): number;
866
+ counts: Map<number, number>;
867
+ }
868
+
645
869
  function refineRtpBySwap(
646
870
  sampled: ReadonlyArray<LookupRow>,
647
871
  pool: ReadonlyArray<LookupRow>,
648
872
  targetSumPayout: number,
649
873
  tolerance: number,
650
874
  maxSwaps: number,
875
+ rangeProtect?: SwapRangeProtect,
651
876
  ): { rows: LookupRow[]; achievedSum: number; swaps: number; converged: boolean } {
652
877
  const inSet = new Set<number>();
653
878
  for (const r of sampled) inSet.add(r.sim);
@@ -663,6 +888,30 @@ function refineRtpBySwap(
663
888
  sampledArr.sort((a, b) => a.payoutCents - b.payoutCents); // ascending
664
889
  outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
665
890
 
891
+ // Dynamic protection: a row is removable only if its range currently
892
+ // contains ≥ 2 rows across the whole output. Re-checked every iteration,
893
+ // so a range that starts with N rows is protected the moment we've
894
+ // depleted it down to 1. Defeats the static-set bug where polish drains
895
+ // ranges with multiple rows one swap at a time.
896
+ const isProtected = (row: LookupRow): boolean => {
897
+ if (!rangeProtect) return false;
898
+ const rng = rangeProtect.getRange(row);
899
+ return (rangeProtect.counts.get(rng) ?? 0) <= 1;
900
+ };
901
+ const recordSwap = (removed: LookupRow, inserted: LookupRow): void => {
902
+ if (!rangeProtect) return;
903
+ const removedRng = rangeProtect.getRange(removed);
904
+ const insertedRng = rangeProtect.getRange(inserted);
905
+ rangeProtect.counts.set(
906
+ removedRng,
907
+ (rangeProtect.counts.get(removedRng) ?? 0) - 1,
908
+ );
909
+ rangeProtect.counts.set(
910
+ insertedRng,
911
+ (rangeProtect.counts.get(insertedRng) ?? 0) + 1,
912
+ );
913
+ };
914
+
666
915
  // Binary-search-by-payout helpers on a sorted array.
667
916
  const lowerBound = (arr: ReadonlyArray<LookupRow>, target: number): number => {
668
917
  let lo = 0;
@@ -686,10 +935,15 @@ function refineRtpBySwap(
686
935
  }
687
936
 
688
937
  if (delta > 0) {
689
- // Raise Σ: swap lowest sample OUT for highest outside row whose
690
- // payout is ≤ (sampleLow + delta), but > sampleLow.
938
+ // Raise Σ: swap lowest non-protected sample OUT for highest outside row
939
+ // whose payout is ≤ (sampleLow + delta), but > sampleLow.
691
940
  if (sampledArr.length === 0 || outsideArr.length === 0) break;
692
- const sampleLow = sampledArr[0];
941
+ let sampleLowIdx = 0;
942
+ while (sampleLowIdx < sampledArr.length && isProtected(sampledArr[sampleLowIdx])) {
943
+ sampleLowIdx++;
944
+ }
945
+ if (sampleLowIdx >= sampledArr.length) break; // every row protected
946
+ const sampleLow = sampledArr[sampleLowIdx];
693
947
  const desired = sampleLow.payoutCents + delta;
694
948
 
695
949
  // Largest outside index with payout ≤ desired AND > sampleLow.payoutCents.
@@ -707,8 +961,7 @@ function refineRtpBySwap(
707
961
  const outsideRow = outsideArr[bestIdx];
708
962
  const newSum = achievedSum + outsideRow.payoutCents - sampleLow.payoutCents;
709
963
 
710
- // Apply swap: remove sampleLow (front), insert outsideRow sorted into sampledArr.
711
- sampledArr.shift();
964
+ sampledArr.splice(sampleLowIdx, 1);
712
965
  const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
713
966
  sampledArr.splice(insertPos, 0, outsideRow);
714
967
  // Remove outsideRow from outsideArr, insert sampleLow sorted.
@@ -719,11 +972,17 @@ function refineRtpBySwap(
719
972
  inSet.delete(sampleLow.sim);
720
973
  inSet.add(outsideRow.sim);
721
974
  achievedSum = newSum;
975
+ recordSwap(sampleLow, outsideRow);
722
976
  } else {
723
- // Lower Σ: swap highest sample OUT for lowest outside row whose
724
- // payout is ≥ (sampleHigh - |delta|), but < sampleHigh.
977
+ // Lower Σ: swap highest non-protected sample OUT for lowest outside row
978
+ // whose payout is ≥ (sampleHigh - |delta|), but < sampleHigh.
725
979
  if (sampledArr.length === 0 || outsideArr.length === 0) break;
726
- const sampleHigh = sampledArr[sampledArr.length - 1];
980
+ let sampleHighIdx = sampledArr.length - 1;
981
+ while (sampleHighIdx >= 0 && isProtected(sampledArr[sampleHighIdx])) {
982
+ sampleHighIdx--;
983
+ }
984
+ if (sampleHighIdx < 0) break; // every row protected
985
+ const sampleHigh = sampledArr[sampleHighIdx];
727
986
  const needLoss = -delta;
728
987
  const desired = sampleHigh.payoutCents - needLoss;
729
988
 
@@ -735,7 +994,7 @@ function refineRtpBySwap(
735
994
  const outsideRow = outsideArr[bestIdx];
736
995
  const newSum = achievedSum + outsideRow.payoutCents - sampleHigh.payoutCents;
737
996
 
738
- sampledArr.pop();
997
+ sampledArr.splice(sampleHighIdx, 1);
739
998
  const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
740
999
  sampledArr.splice(insertPos, 0, outsideRow);
741
1000
  outsideArr.splice(bestIdx, 1);
@@ -745,6 +1004,7 @@ function refineRtpBySwap(
745
1004
  inSet.delete(sampleHigh.sim);
746
1005
  inSet.add(outsideRow.sim);
747
1006
  achievedSum = newSum;
1007
+ recordSwap(sampleHigh, outsideRow);
748
1008
  }
749
1009
  swaps++;
750
1010
  }
@@ -1088,6 +1348,91 @@ function uniformReservoirSample(
1088
1348
  return sampled.map((idx) => rows[idx]);
1089
1349
  }
1090
1350
 
1351
+ /**
1352
+ * High-tier (cap + large) sampling that targets a smooth log-decay shape
1353
+ * across Stake hit-rate buckets. Used when `shapeDistribution=true`.
1354
+ *
1355
+ * Algorithm:
1356
+ * 1. Bucket all high-tier source rows by Stake hit-rate range.
1357
+ * 2. Treat the lowest non-empty bucket as the anchor; target counts for
1358
+ * higher buckets follow `anchor × ratio^k`.
1359
+ * 3. If a global total target is provided, rescale the base so the row
1360
+ * counts sum to it (subsample mode). Otherwise just use the anchor's
1361
+ * source count as base.
1362
+ * 4. Each bucket samples its target_count rows stratified-by-log-payout
1363
+ * from within the bucket — preserves shape variety inside the range.
1364
+ * 5. Cap-vs-large classification of each picked row mirrors the source
1365
+ * classification (payoutCents ≥ capPayoutCents → cap, else large).
1366
+ *
1367
+ * Guarantees at least 1 row per bucket that has source candidates, so the
1368
+ * top bucket (max-reach) stays populated. If a bucket's source count is
1369
+ * below the decay target, all source rows in that bucket are kept.
1370
+ */
1371
+ function bucketDecaySampleHighTier(
1372
+ srcCap: ReadonlyArray<LookupRow>,
1373
+ srcLarge: ReadonlyArray<LookupRow>,
1374
+ betCost: number,
1375
+ capPayoutCents: number,
1376
+ decayRatio: number,
1377
+ /** When defined, scale base so Σ target counts ≈ this. */
1378
+ targetTotalCount: number | undefined,
1379
+ seed: number,
1380
+ ): { outCap: LookupRow[]; outLarge: LookupRow[] } {
1381
+ const highSource = [...srcCap, ...srcLarge];
1382
+ if (highSource.length === 0) {
1383
+ return { outCap: [], outLarge: [] };
1384
+ }
1385
+
1386
+ const byBucket = new Map<number, LookupRow[]>();
1387
+ for (const r of highSource) {
1388
+ const idx = findRange(r.payoutCents, betCost);
1389
+ let list = byBucket.get(idx);
1390
+ if (!list) {
1391
+ list = [];
1392
+ byBucket.set(idx, list);
1393
+ }
1394
+ list.push(r);
1395
+ }
1396
+
1397
+ const bucketIdxs = [...byBucket.keys()].sort((a, b) => a - b);
1398
+ const lowestIdx = bucketIdxs[0];
1399
+
1400
+ const weights: number[] = bucketIdxs.map((bIdx) =>
1401
+ Math.pow(decayRatio, bIdx - lowestIdx),
1402
+ );
1403
+ const weightSum = weights.reduce((a, b) => a + b, 0);
1404
+
1405
+ let base: number;
1406
+ if (targetTotalCount !== undefined && targetTotalCount > 0) {
1407
+ base = targetTotalCount / weightSum;
1408
+ } else {
1409
+ base = byBucket.get(lowestIdx)!.length;
1410
+ }
1411
+
1412
+ const outCap: LookupRow[] = [];
1413
+ const outLarge: LookupRow[] = [];
1414
+
1415
+ for (let i = 0; i < bucketIdxs.length; i++) {
1416
+ const bIdx = bucketIdxs[i];
1417
+ const candidates = byBucket.get(bIdx)!;
1418
+ const want = Math.max(1, Math.min(candidates.length, Math.round(base * weights[i])));
1419
+
1420
+ const sampled =
1421
+ want >= candidates.length
1422
+ ? [...candidates]
1423
+ : stratifiedSmallSampleNonZero(candidates, want, 10, seed + i * 31);
1424
+
1425
+ for (const r of sampled) {
1426
+ if (r.payoutCents >= capPayoutCents) {
1427
+ outCap.push(r);
1428
+ } else {
1429
+ outLarge.push(r);
1430
+ }
1431
+ }
1432
+ }
1433
+ return { outCap, outLarge };
1434
+ }
1435
+
1091
1436
  /**
1092
1437
  * Find the index of the Stake hit-rate range that `payoutCents` falls into.
1093
1438
  * Returns -1 if no range matches (shouldn't happen given the [0, 0.1] +
@@ -1280,116 +1625,150 @@ function diversifyPayouts(
1280
1625
  warnings: string[],
1281
1626
  ): { swaps: number; achievedUnique: number; reached: boolean } {
1282
1627
  // Build the current set of payouts in output AND in-sample sim ids.
1283
- const inOutputPayouts = new Map<number, number>(); // payoutCents → count
1628
+ // `payoutToOutRows` indexes positions in outSmallNonZero by their current
1629
+ // payoutCents value; this lets us locate "any row with payout p" in O(1)
1630
+ // and update incrementally on each swap (no array splice / re-scan).
1631
+ const inOutputPayouts = new Map<number, number>(); // payoutCents → count across all tiers
1632
+ const payoutToOutRows = new Map<number, Set<number>>(); // payoutCents → outSmallNonZero indices
1284
1633
  const inSampleSims = new Set<number>();
1285
1634
  for (const r of otherOutRows) {
1286
1635
  inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
1287
1636
  }
1288
- for (const r of outSmallNonZero) {
1637
+ for (let i = 0; i < outSmallNonZero.length; i++) {
1638
+ const r = outSmallNonZero[i];
1289
1639
  inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
1290
1640
  inSampleSims.add(r.sim);
1641
+ let s = payoutToOutRows.get(r.payoutCents);
1642
+ if (!s) {
1643
+ s = new Set<number>();
1644
+ payoutToOutRows.set(r.payoutCents, s);
1645
+ }
1646
+ s.add(i);
1291
1647
  }
1292
1648
  let uniqueNow = inOutputPayouts.size;
1293
- if (uniqueNow >= targetUnique) {
1294
- return { swaps: 0, achievedUnique: uniqueNow, reached: true };
1295
- }
1296
1649
 
1297
- // Index source rows by payoutCents → list of LookupRow (only those NOT in
1298
- // output payouts and not already used in the sample).
1299
- const newPayoutsAvailable = new Map<number, LookupRow[]>();
1650
+ // Index source rows by payoutCents → first available LookupRow (only those
1651
+ // NOT already in output and not in sample).
1652
+ const newPayoutsAvailable = new Map<number, LookupRow>();
1300
1653
  for (const r of srcSmallNonZero) {
1301
1654
  if (inOutputPayouts.has(r.payoutCents)) continue;
1302
1655
  if (inSampleSims.has(r.sim)) continue;
1303
- let arr = newPayoutsAvailable.get(r.payoutCents);
1304
- if (!arr) {
1305
- arr = [];
1306
- newPayoutsAvailable.set(r.payoutCents, arr);
1656
+ if (!newPayoutsAvailable.has(r.payoutCents)) {
1657
+ newPayoutsAvailable.set(r.payoutCents, r);
1307
1658
  }
1308
- arr.push(r);
1309
1659
  }
1310
- // Sorted list of new payout values for binary search by magnitude.
1660
+ // Sorted list of new payout values for nearest-neighbor binary search.
1311
1661
  const newPayoutsSorted = Array.from(newPayoutsAvailable.keys()).sort((a, b) => a - b);
1312
1662
 
1313
- if (newPayoutsSorted.length === 0) {
1314
- warnings.push(
1315
- `minUniqueEventsRate target ${targetUnique} unreachable: source has no distinct payout values not already in output (current ${uniqueNow})`,
1316
- );
1317
- return { swaps: 0, achievedUnique: uniqueNow, reached: false };
1663
+ // Maintain the set of payoutCents values that have >= 2 rows in outSmallNonZero
1664
+ // (these are the swap-out candidates — losing one of them keeps the payout
1665
+ // represented at least once, so we don't drop a unique-value entirely unless
1666
+ // a copy exists in cap/large/zero tiers).
1667
+ const dupPayouts = new Set<number>();
1668
+ for (const [p, rows] of payoutToOutRows) {
1669
+ // payout is a "safe" swap-out source if the cross-tier count is >= 2
1670
+ // (removing one row still leaves the payout in output somewhere).
1671
+ if ((inOutputPayouts.get(p) ?? 0) >= 2 && rows.size >= 1) dupPayouts.add(p);
1318
1672
  }
1319
1673
 
1320
- // Find duplicate-payout rows in outSmallNonZero. Any small-non-zero row whose
1321
- // payout count (across all output tiers) is ≥ 2 is a safe swap-out candidate
1322
- // — removing one row still leaves the payout represented elsewhere, so the
1323
- // swap nets +1 unique (modulo whether the swap-in payout is new).
1324
- const swapOutCandidates: number[] = [];
1325
- for (let i = 0; i < outSmallNonZero.length; i++) {
1326
- const p = outSmallNonZero[i].payoutCents;
1327
- if ((inOutputPayouts.get(p) ?? 0) >= 2) swapOutCandidates.push(i);
1674
+ if (newPayoutsSorted.length === 0) {
1675
+ if (uniqueNow < targetUnique) {
1676
+ warnings.push(
1677
+ `minUniqueEventsRate target ${targetUnique} unreachable: source has no distinct payout values not already in output (current ${uniqueNow})`,
1678
+ );
1679
+ }
1680
+ return { swaps: 0, achievedUnique: uniqueNow, reached: uniqueNow >= targetUnique };
1328
1681
  }
1329
- if (swapOutCandidates.length === 0) {
1330
- warnings.push(
1331
- `minUniqueEventsRate target ${targetUnique} unreachable: every small-non-zero row already has a unique payout (current ${uniqueNow})`,
1332
- );
1333
- return { swaps: 0, achievedUnique: uniqueNow, reached: false };
1682
+ if (dupPayouts.size === 0) {
1683
+ if (uniqueNow < targetUnique) {
1684
+ warnings.push(
1685
+ `minUniqueEventsRate target ${targetUnique} unreachable: every small-non-zero row already has a unique payout (current ${uniqueNow})`,
1686
+ );
1687
+ }
1688
+ return { swaps: 0, achievedUnique: uniqueNow, reached: uniqueNow >= targetUnique };
1334
1689
  }
1335
- // Sort: most-duplicated payout first (cheapest to lose one of).
1336
- swapOutCandidates.sort((a, b) => {
1337
- const ca = inOutputPayouts.get(outSmallNonZero[a].payoutCents) ?? 0;
1338
- const cb = inOutputPayouts.get(outSmallNonZero[b].payoutCents) ?? 0;
1339
- return cb - ca;
1340
- });
1341
1690
 
1342
1691
  let swaps = 0;
1692
+ // sumBudget bounds |running Σ-drift|, NOT a per-swap cost. Each swap can be
1693
+ // +Δ or −Δ depending on whether the new payout is higher or lower than the
1694
+ // old one; the running drift stays in [−sumBudget, +sumBudget]. This lets
1695
+ // up-swaps and down-swaps cancel each other so the pass keeps going long
1696
+ // after a one-directional budget would have exhausted.
1343
1697
  let sumBudget = remainingSumBudget;
1698
+ let runningDrift = 0;
1699
+ let exhaustedReason: 'budget' | 'sourceOrAllocation' | null = null;
1344
1700
 
1345
- // Indices in `swapOutCandidates` are positional into outSmallNonZero at the
1346
- // time of sort; subsequent splices shift later indices. We re-validate the
1347
- // duplicate condition before each swap, so stale indices that no longer
1348
- // refer to duplicate rows are skipped harmlessly.
1349
- for (const initialIdx of swapOutCandidates) {
1350
- if (uniqueNow >= targetUnique) break;
1351
- // Re-validate: array may have shifted from earlier splices. Walk to find
1352
- // the current index of this row's payoutCents value matching the sim, but
1353
- // simpler: just check the current position — if duplicate condition no
1354
- // longer holds, skip. Note: after splice ops, `initialIdx` could be out of
1355
- // range or point at a different row. Clamp and verify.
1356
- if (initialIdx >= outSmallNonZero.length) continue;
1357
- const swapOutRow = outSmallNonZero[initialIdx];
1358
- const swapOutP = swapOutRow.payoutCents;
1359
- if ((inOutputPayouts.get(swapOutP) ?? 0) < 2) continue;
1360
-
1361
- // Find the new-payout value closest to swapOutP via binary search.
1362
- if (newPayoutsSorted.length === 0) break;
1363
- let bestNewP = newPayoutsSorted[0];
1364
- let bestDist = Math.abs(bestNewP - swapOutP);
1365
- const ins = lowerBoundNum(newPayoutsSorted, swapOutP);
1366
- for (const idx of [ins - 1, ins, ins + 1]) {
1367
- if (idx < 0 || idx >= newPayoutsSorted.length) continue;
1368
- const np = newPayoutsSorted[idx];
1369
- const d = Math.abs(np - swapOutP);
1370
- if (d < bestDist) {
1371
- bestDist = d;
1372
- bestNewP = np;
1701
+ // Maximize unique payouts minUniqueEventsRate is a FLOOR, not a cap. Loop
1702
+ // until no further beneficial swap is available.
1703
+ //
1704
+ // Strategy: at each iteration scan dupPayouts; for each, examine its
1705
+ // sorted-list neighbours (signed delta = newPayout − oldPayout) and pick
1706
+ // the swap that brings |runningDrift + delta| closest to 0 (subject to
1707
+ // staying inside the ±sumBudget band). Up-deltas and down-deltas balance
1708
+ // each other across iterations, so the pass scales with the size of the
1709
+ // dup-pool, not with the per-pass drift budget.
1710
+ while (newPayoutsSorted.length > 0 && dupPayouts.size > 0) {
1711
+ let pickP = -1;
1712
+ let pickNewP = -1;
1713
+ let pickDelta = 0;
1714
+ let pickNewAbsDrift = Infinity; // |runningDrift + delta| for the chosen swap
1715
+ for (const p of dupPayouts) {
1716
+ const rows = payoutToOutRows.get(p);
1717
+ if (!rows || rows.size === 0) continue; // stale entry
1718
+ const ins = lowerBoundNum(newPayoutsSorted, p);
1719
+ for (const idx of [ins - 1, ins, ins + 1]) {
1720
+ if (idx < 0 || idx >= newPayoutsSorted.length) continue;
1721
+ const np = newPayoutsSorted[idx];
1722
+ const delta = np - p;
1723
+ const newDrift = runningDrift + delta;
1724
+ if (Math.abs(newDrift) > sumBudget) continue;
1725
+ const newAbs = Math.abs(newDrift);
1726
+ if (newAbs < pickNewAbsDrift) {
1727
+ pickNewAbsDrift = newAbs;
1728
+ pickP = p;
1729
+ pickNewP = np;
1730
+ pickDelta = delta;
1731
+ }
1373
1732
  }
1374
1733
  }
1734
+ if (pickP < 0) {
1735
+ // No swap fits in the budget band. The only way to make progress would
1736
+ // be to first move runningDrift back toward 0, but every candidate we
1737
+ // just rejected already failed; we're stuck.
1738
+ exhaustedReason = 'budget';
1739
+ break;
1740
+ }
1375
1741
 
1376
- // Check Σ-drift budget.
1377
- if (bestDist > sumBudget) continue;
1378
-
1379
- const candidates = newPayoutsAvailable.get(bestNewP);
1380
- if (!candidates || candidates.length === 0) continue;
1381
- const swapInRow = candidates[0];
1382
-
1383
- // Apply swap: replace outSmallNonZero[initialIdx] with swapInRow, keeping
1384
- // the array sorted by payoutCents.
1385
- outSmallNonZero.splice(initialIdx, 1);
1386
- const insertPos = lowerBoundIdx(outSmallNonZero, swapInRow.payoutCents);
1387
- outSmallNonZero.splice(insertPos, 0, swapInRow);
1742
+ const rowsForP = payoutToOutRows.get(pickP)!;
1743
+ if (rowsForP.size === 0) {
1744
+ // Stale entry — clean and retry.
1745
+ dupPayouts.delete(pickP);
1746
+ continue;
1747
+ }
1748
+ const swapOutIdx = rowsForP.values().next().value as number;
1749
+ const swapOutRow = outSmallNonZero[swapOutIdx];
1750
+ const swapOutP = pickP;
1751
+ const bestNewP = pickNewP;
1752
+
1753
+ const swapInRow = newPayoutsAvailable.get(bestNewP);
1754
+ if (!swapInRow) {
1755
+ // Defensive: shouldn't happen because newPayoutsSorted mirrors
1756
+ // newPayoutsAvailable. Remove the stale entry and retry.
1757
+ const removeAt = lowerBoundNum(newPayoutsSorted, bestNewP);
1758
+ if (removeAt < newPayoutsSorted.length && newPayoutsSorted[removeAt] === bestNewP) {
1759
+ newPayoutsSorted.splice(removeAt, 1);
1760
+ }
1761
+ continue;
1762
+ }
1388
1763
 
1389
- // Update tracking.
1764
+ // Apply swap IN-PLACE — overwrite at the same array slot so existing
1765
+ // indices in payoutToOutRows remain stable.
1766
+ outSmallNonZero[swapOutIdx] = swapInRow;
1390
1767
  inSampleSims.delete(swapOutRow.sim);
1391
1768
  inSampleSims.add(swapInRow.sim);
1392
1769
 
1770
+ // Update payoutToOutRows / dupPayouts for the OLD payout.
1771
+ rowsForP.delete(swapOutIdx);
1393
1772
  const oldCount = inOutputPayouts.get(swapOutP) ?? 0;
1394
1773
  if (oldCount <= 1) {
1395
1774
  inOutputPayouts.delete(swapOutP);
@@ -1397,32 +1776,57 @@ function diversifyPayouts(
1397
1776
  } else {
1398
1777
  inOutputPayouts.set(swapOutP, oldCount - 1);
1399
1778
  }
1779
+ if (rowsForP.size === 0) payoutToOutRows.delete(swapOutP);
1780
+ // After decrement, the cross-tier count may have fallen below 2 — then this
1781
+ // payout is no longer a safe swap-out source.
1782
+ if ((inOutputPayouts.get(swapOutP) ?? 0) < 2) dupPayouts.delete(swapOutP);
1783
+
1784
+ // Update for the NEW payout (now in output at index swapOutIdx).
1400
1785
  inOutputPayouts.set(bestNewP, (inOutputPayouts.get(bestNewP) ?? 0) + 1);
1786
+ let newRowsForP = payoutToOutRows.get(bestNewP);
1787
+ if (!newRowsForP) {
1788
+ newRowsForP = new Set<number>();
1789
+ payoutToOutRows.set(bestNewP, newRowsForP);
1790
+ }
1791
+ newRowsForP.add(swapOutIdx);
1401
1792
  uniqueNow++;
1402
1793
 
1403
- // bestNewP is now consumed: remove it from the available pool.
1794
+ // bestNewP consumed: remove it from the available pool / sorted list.
1404
1795
  newPayoutsAvailable.delete(bestNewP);
1405
1796
  const removeAt = lowerBoundNum(newPayoutsSorted, bestNewP);
1406
1797
  if (removeAt < newPayoutsSorted.length && newPayoutsSorted[removeAt] === bestNewP) {
1407
1798
  newPayoutsSorted.splice(removeAt, 1);
1408
1799
  }
1409
1800
 
1410
- sumBudget -= bestDist;
1801
+ runningDrift += pickDelta;
1411
1802
  swaps++;
1412
1803
  }
1413
1804
 
1414
- const reached = uniqueNow >= targetUnique;
1415
- if (!reached) {
1416
- if (sumBudget <= 0) {
1805
+ if (uniqueNow < targetUnique) {
1806
+ if (exhaustedReason === 'budget') {
1417
1807
  warnings.push(
1418
1808
  `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): RTP-drift budget exhausted`,
1419
1809
  );
1810
+ } else if (newPayoutsSorted.length === 0) {
1811
+ warnings.push(
1812
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): source has no more distinct payouts available`,
1813
+ );
1814
+ } else if (dupPayouts.size === 0) {
1815
+ warnings.push(
1816
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): every output row already has a unique payout in small-non-zero tier`,
1817
+ );
1420
1818
  } else {
1421
1819
  warnings.push(
1422
- `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): source-or-allocation limit (${newPayoutsSorted.length} new payouts remained available)`,
1820
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow})`,
1423
1821
  );
1424
1822
  }
1425
1823
  }
1426
- return { swaps, achievedUnique: uniqueNow, reached };
1824
+
1825
+ // Final sort of outSmallNonZero by payoutCents so downstream stages see a
1826
+ // tidy ordering (the in-place overwrites preserved indices but scrambled the
1827
+ // payout order).
1828
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
1829
+
1830
+ return { swaps, achievedUnique: uniqueNow, reached: uniqueNow >= targetUnique };
1427
1831
  }
1428
1832