@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/package.json +1 -1
- package/src/index.ts +7 -0
- package/src/tiered.ts +512 -108
- package/src/transform-jsonl-zst.ts +285 -0
- package/src/types.ts +23 -0
- package/test/transform-jsonl-zst.test.ts +343 -0
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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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 →
|
|
1298
|
-
// output
|
|
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
|
-
|
|
1304
|
-
|
|
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
|
|
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
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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 (
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1377
|
-
if (
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1801
|
+
runningDrift += pickDelta;
|
|
1411
1802
|
swaps++;
|
|
1412
1803
|
}
|
|
1413
1804
|
|
|
1414
|
-
|
|
1415
|
-
|
|
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})
|
|
1820
|
+
`minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow})`,
|
|
1423
1821
|
);
|
|
1424
1822
|
}
|
|
1425
1823
|
}
|
|
1426
|
-
|
|
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
|
|