@energy8platform/stake-math-tools 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tiered.ts ADDED
@@ -0,0 +1,1832 @@
1
+ // src/tiered.ts
2
+ //
3
+ // Tier-based lookup-table compression.
4
+ //
5
+ // Unlike NNLS, this algorithm does NOT optimize toward (RTP, CV, hitRate) targets.
6
+ // It compresses the source distribution into `nRowsOut` rows while PRESERVING the
7
+ // natural rare-event rates. High-payout rows ("cap" / "large" tier) get weight=1
8
+ // (rarest); bulk rows ("small" tier) get weight=W >> 1 calculated so the natural
9
+ // cap probability is preserved.
10
+ //
11
+ // This is the canonical way Stake Engine expects lookup tables to be built: ETL
12
+ // (Expected Tail Liability) stays low because high-payout rows carry minimal
13
+ // weight, and the "Within Liability Limits" check passes by construction.
14
+
15
+ import type {
16
+ LookupRow,
17
+ OptimizeParams,
18
+ OptimizeResult,
19
+ ToleranceMet,
20
+ } from './types.js';
21
+ import { computeMetrics, isNearMax } from './metrics.js';
22
+ import { mulberry32, weightedReservoirSample } from './sample.js';
23
+ import { computeStakeReport, detectHitRateGaps, HIT_RATE_RANGES } from './stake-report.js';
24
+
25
+ const DEFAULTS = {
26
+ betCostCents: 100,
27
+ capPmFraction: 0.95, // capPmThreshold = capPmFraction × maxPm
28
+ requireMaxReached: true,
29
+ maxReachedFraction: 0.95,
30
+ seed: 0xc0ffee,
31
+ };
32
+
33
+ export function buildTieredLookup(
34
+ rowsIn: Iterable<LookupRow>,
35
+ params: OptimizeParams,
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
+
121
+ const betCost = params.betCostCents ?? DEFAULTS.betCostCents;
122
+ const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
123
+ const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
124
+ const seed = params.seed ?? DEFAULTS.seed;
125
+
126
+ // Phase 1: filter
127
+ const filtered: LookupRow[] = [];
128
+ for (const r of rowsIn) {
129
+ if (r.payoutCents > params.capMaxWin) continue;
130
+ filtered.push(r);
131
+ }
132
+ if (filtered.length < params.nRowsOut) {
133
+ throw new Error(
134
+ `tiered: filtered input has ${filtered.length} rows, fewer than nRowsOut=${params.nRowsOut}`,
135
+ );
136
+ }
137
+
138
+ const sourceMetrics = computeMetrics(filtered);
139
+
140
+ // Phase 2: thresholds
141
+ const maxPm = sourceMetrics.maxPayout / betCost;
142
+ const capPmThreshold = params.capPmThreshold ?? DEFAULTS.capPmFraction * maxPm;
143
+ const capPayoutCents = Math.floor(capPmThreshold * betCost);
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
+ }
154
+ const largePayoutCents =
155
+ largePmThreshold !== undefined ? Math.floor(largePmThreshold * betCost) : undefined;
156
+
157
+ // Phase 3: classify source
158
+ const srcCap: LookupRow[] = [];
159
+ const srcLarge: LookupRow[] = [];
160
+ const srcSmall: LookupRow[] = [];
161
+ for (const r of filtered) {
162
+ if (r.payoutCents >= capPayoutCents) srcCap.push(r);
163
+ else if (largePayoutCents !== undefined && r.payoutCents >= largePayoutCents) srcLarge.push(r);
164
+ else srcSmall.push(r);
165
+ }
166
+
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;
170
+
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
+ }
219
+
220
+ if (outCap.length > params.nRowsOut) {
221
+ // Too many cap rows — keep highest-payout
222
+ outCap = [...srcCap].sort((a, b) => b.payoutCents - a.payoutCents).slice(0, params.nRowsOut);
223
+ outLarge = [];
224
+ } else if (outCap.length + outLarge.length > params.nRowsOut) {
225
+ // Cap fits, but cap+large too many — drop some large
226
+ const allowedLarge = params.nRowsOut - outCap.length;
227
+ outLarge = [...srcLarge]
228
+ .sort((a, b) => b.payoutCents - a.payoutCents)
229
+ .slice(0, allowedLarge);
230
+ }
231
+
232
+ const slotsForSmall = params.nRowsOut - outCap.length - outLarge.length;
233
+ const warnings: string[] = [];
234
+ let outSmallZero: LookupRow[] = [];
235
+ let outSmallNonZero: LookupRow[] = [];
236
+ let srcSmallNonZeroAll: ReadonlyArray<LookupRow> = [];
237
+ // Refinement-pass swap counters.
238
+ let rtpSwaps = 0;
239
+ let cvSwaps = 0;
240
+ let gapFillSwaps = 0;
241
+ let gapsUnfillable = 0;
242
+ let diversifySwaps = 0;
243
+ // Diversify-pass budget inputs hoisted from the inner scope. The diversify
244
+ // pass runs AFTER gap-fill (outside the inner scope), but needs the same
245
+ // target Σ_smallNz_payout the cv pass used, plus the achievedSum the cv
246
+ // pass left, to compute the remaining RTP-drift headroom.
247
+ let targetSmallNzSumP = 0;
248
+ let cvAchievedSum: number | null = null;
249
+ // Compute W and small-tier subdivision now, so we can do RTP-aware non-zero
250
+ // sampling using the same W used in the output.
251
+ let W = 1;
252
+ if (slotsForSmall > 0 && srcSmall.length > 0) {
253
+ // Subdivide small into zero / non-zero so we can bias the sampling by
254
+ // params.targetHitRate. Tier-based preserves cap rate naturally, but the
255
+ // small-tier non-zero/zero composition can still be shifted to match a
256
+ // user-requested hit-rate.
257
+ const srcSmallZero: LookupRow[] = [];
258
+ const srcSmallNonZero: LookupRow[] = [];
259
+ for (const r of srcSmall) {
260
+ if (r.payoutCents === 0) srcSmallZero.push(r);
261
+ else srcSmallNonZero.push(r);
262
+ }
263
+ srcSmallNonZeroAll = srcSmallNonZero;
264
+
265
+ // Target cap rate (cap + large weight share) — same `target` used for W below.
266
+ const target_cap_rate = target;
267
+ const targetHitRate = params.targetHitRate;
268
+
269
+ // Solve for n_B (non-zero small rows) so that effective hit-rate = targetHitRate.
270
+ // (nHighOut + W × n_B) / (nHighOut + W × nSmall) = h
271
+ // where W is computed below using the same `target_cap_rate` formula, which
272
+ // implies high contributes target_cap_rate of total weight and small carries
273
+ // the remaining 1 - target_cap_rate split uniformly across nSmall.
274
+ // → n_B = nSmall × [h − (1−h) × target_cap_rate / (1 − target_cap_rate)]
275
+ const nHighOut = outCap.length + outLarge.length;
276
+ let nB: number;
277
+ if (target_cap_rate >= 1 || nHighOut === 0) {
278
+ // No high tier or fully high: every small row contributes h share uniformly.
279
+ nB = Math.round(slotsForSmall * targetHitRate);
280
+ } else {
281
+ const denom = 1 - target_cap_rate;
282
+ nB = Math.round(
283
+ slotsForSmall * (targetHitRate - ((1 - targetHitRate) * target_cap_rate) / denom),
284
+ );
285
+ }
286
+ const requestedNB = nB;
287
+ nB = Math.max(0, Math.min(nB, slotsForSmall, srcSmallNonZero.length));
288
+ let nA = slotsForSmall - nB;
289
+ // If zero bucket can't absorb nA, redirect overflow to non-zero
290
+ if (nA > srcSmallZero.length) {
291
+ const overflow = nA - srcSmallZero.length;
292
+ nA = srcSmallZero.length;
293
+ nB = Math.min(nB + overflow, srcSmallNonZero.length);
294
+ // If still short, the output will simply be under-filled and padded later.
295
+ }
296
+
297
+ // Warnings on unreachable hit-rate targets.
298
+ // Priority:
299
+ // 1. Source has too few non-zero rows (covers nB===0 from empty source too).
300
+ // 2. Cap-rate alone already meets/exceeds the target (formula yields nB<=0).
301
+ if (
302
+ requestedNB > srcSmallNonZero.length &&
303
+ nB === srcSmallNonZero.length &&
304
+ targetHitRate > 0
305
+ ) {
306
+ warnings.push(
307
+ `source has only ${srcSmallNonZero.length} non-zero small rows; cannot reach targetHitRate=${targetHitRate}`,
308
+ );
309
+ } else if (requestedNB <= 0 && targetHitRate > 0 && nB === 0) {
310
+ warnings.push(
311
+ `targetHitRate=${targetHitRate} unreachable; cap+large weight share already meets or exceeds it (n_B clamped to 0)`,
312
+ );
313
+ }
314
+
315
+ const bucketCount = params.bucketCount ?? 100;
316
+ // Sample zero sub-bucket: uniform reservoir.
317
+ outSmallZero =
318
+ nA >= srcSmallZero.length
319
+ ? [...srcSmallZero]
320
+ : uniformReservoirSample(srcSmallZero, nA, seed);
321
+
322
+ // RTP-aware non-zero sampling.
323
+ // Compute the W we will use in the output (mirrors Phase 5 below). We have
324
+ // nSmall = nA + nB once sampled; tier-based has bounded weights by design.
325
+ const nSmallTotal = nA + nB;
326
+ let WforSampling = 1;
327
+ if (nSmallTotal > 0 && target > 0 && target < 1) {
328
+ WforSampling = Math.max(
329
+ 1,
330
+ Math.round((nHighOut * (1 - target)) / (nSmallTotal * target)),
331
+ );
332
+ } else if (nHighOut === 0) {
333
+ WforSampling = 1;
334
+ }
335
+ W = WforSampling;
336
+
337
+ // Compute target mean payout for the non-zero sample so the overall RTP
338
+ // hits params.targetRTP.
339
+ // Total weight T = nHighOut + W × (nA + nB)
340
+ // Σ(w·p) needed = targetRTP × T × betCost (NOT × 100 — betCost may differ)
341
+ // Cap rows contribute Σ_cap = sum of cap+large payouts (weight=1 each)
342
+ // Σ_smallNz contribution = W × Σ_sampled_nz_payouts
343
+ // → Target Σ_sampled_nz_payouts = (targetRTP × T × betCost − Σ_cap) / W
344
+ const totalWeightTarget = nHighOut + W * (nA + nB);
345
+ const targetSumWP = params.targetRTP * totalWeightTarget * betCost;
346
+ let capSumP = 0;
347
+ for (const r of outCap) capSumP += r.payoutCents;
348
+ for (const r of outLarge) capSumP += r.payoutCents;
349
+ targetSmallNzSumP = W > 0 ? (targetSumWP - capSumP) / W : 0;
350
+ const targetMeanNz = nB > 0 ? targetSmallNzSumP / nB : 0;
351
+
352
+ if (nB >= srcSmallNonZero.length) {
353
+ outSmallNonZero = [...srcSmallNonZero];
354
+ } else if (nB > 0 && targetMeanNz > 0) {
355
+ const sampleResult = rtpAwareSampleNonZero(
356
+ srcSmallNonZero,
357
+ nB,
358
+ targetMeanNz,
359
+ bucketCount,
360
+ seed + 1,
361
+ );
362
+ outSmallNonZero = sampleResult.sampled;
363
+ if (sampleResult.clamped) {
364
+ warnings.push(
365
+ `targetRTP=${params.targetRTP} unreachable for non-zero sample: requested mean payout ` +
366
+ `${targetMeanNz.toFixed(0)} cents but achieved ${sampleResult.achievedMean.toFixed(0)} cents`,
367
+ );
368
+ }
369
+
370
+ // Iterative swap refinement: close residual RTP gap by swapping
371
+ // boundary rows in/out of the sample. Each swap is a single LookupRow
372
+ // exchange, so the weight distribution remains exactly intact.
373
+ //
374
+ // params.toleranceRTP is on LUT-RTP scale (e.g. 0.001 = 0.1pp LUT RTP).
375
+ // Achieved LUT RTP = (Σ_cap + W × Σ_smallNz) / (T × 100).
376
+ // Tolerable Σ_smallNz drift = toleranceRTP × T × 100 / W.
377
+ // Half it to leave a small safety budget for the CV pass that follows.
378
+ const T_out_predict = nHighOut + W * (nA + nB);
379
+ const rtpTolerance = W > 0 && T_out_predict > 0
380
+ ? Math.max(1, 0.5 * params.toleranceRTP * T_out_predict * 100 / W)
381
+ : Math.max(1, 0.005 * targetSmallNzSumP);
382
+ const refined = refineRtpBySwap(
383
+ outSmallNonZero,
384
+ srcSmallNonZero,
385
+ targetSmallNzSumP,
386
+ rtpTolerance,
387
+ 10000,
388
+ );
389
+ outSmallNonZero = refined.rows;
390
+ rtpSwaps = refined.swaps;
391
+
392
+ if (!refined.converged && refined.swaps > 0 && targetSmallNzSumP > 0) {
393
+ const achievedMean =
394
+ outSmallNonZero.length > 0 ? refined.achievedSum / outSmallNonZero.length : 0;
395
+ const targetMean =
396
+ outSmallNonZero.length > 0 ? targetSmallNzSumP / outSmallNonZero.length : 0;
397
+ const gap =
398
+ targetMean > 0 ? (Math.abs(achievedMean - targetMean) / targetMean) * 100 : 0;
399
+ warnings.push(
400
+ `RTP refinement did not fully converge after ${refined.swaps} swaps (${gap.toFixed(2)}% gap)`,
401
+ );
402
+ }
403
+
404
+ // Third refinement pass: Σ-preserving 2-swap pass to nudge CV toward
405
+ // targetCV. RTP (Σ payout) is preserved within a 0.5% tolerance; only
406
+ // Σ payout² is re-shaped. Increases CV by swapping a moderate (mid,mid)
407
+ // pair from the sample for a spread (low,high) pair from outside; or
408
+ // the inverse to decrease CV.
409
+ //
410
+ // Math:
411
+ // mean_out = (Σ_cap_payout + W × Σ_smallNz_payout) / T_out
412
+ // target_var = (targetCV × mean_out)²
413
+ // target E[X²] = target_var + mean_out² = mean_out² × (targetCV² + 1)
414
+ // target Σ(w·p²) = target_E[X²] × T_out
415
+ // target Σ_smallNz_p² = (target Σ(w·p²) − Σ_cap_p²) / W
416
+ if (params.targetCV > 0 && outSmallNonZero.length >= 2) {
417
+ const T_out = nHighOut + W * (nA + nB);
418
+ if (T_out > 0) {
419
+ let capSumP2 = 0;
420
+ for (const r of outCap) capSumP2 += r.payoutCents * r.payoutCents;
421
+ for (const r of outLarge) capSumP2 += r.payoutCents * r.payoutCents;
422
+
423
+ // mean_out predicted from converged RTP refinement.
424
+ const meanOutPredicted = (capSumP + W * refined.achievedSum) / T_out;
425
+ const targetEX2 = meanOutPredicted * meanOutPredicted * (params.targetCV ** 2 + 1);
426
+ const targetSumWP2 = targetEX2 * T_out;
427
+ const targetSmallNzSumP2 = W > 0 ? (targetSumWP2 - capSumP2) / W : 0;
428
+
429
+ if (targetSmallNzSumP2 > 0) {
430
+ // Cumulative Σ-drift cap per CV pass = the OTHER HALF of the user's
431
+ // RTP tolerance budget (the first half was spent by refineRtpBySwap).
432
+ // Σ tolerance = 0.5 × toleranceRTP × T × 100 / W (same conversion).
433
+ // This guarantees that even after both passes, total RTP drift
434
+ // stays within params.toleranceRTP.
435
+ const cvSumTolerance = W > 0
436
+ ? Math.max(1, 0.5 * params.toleranceRTP * T_out * 100 / W)
437
+ : Math.max(1, 0.001 * targetSmallNzSumP);
438
+ // CV convergence threshold in Σ²-space:
439
+ // target E[X²] = mean² × (CV² + 1)
440
+ // d(Σ²_smallNz) / dCV = 2 × CV × mean² × T / W
441
+ // Σ²-tolerance = 2 × targetCV × mean² × T × toleranceCV / W
442
+ // Stop swapping when Σ² is within this band of target.
443
+ const cvSum2Tolerance = W > 0 && params.toleranceCV > 0 && params.targetCV > 0
444
+ ? Math.max(1,
445
+ 2 * params.targetCV * meanOutPredicted * meanOutPredicted *
446
+ T_out * params.toleranceCV / W)
447
+ : Math.max(1, 0.001 * Math.abs(targetSmallNzSumP2));
448
+ const cvRefined = refineCvBySwap(
449
+ outSmallNonZero,
450
+ srcSmallNonZero,
451
+ targetSmallNzSumP2,
452
+ cvSumTolerance,
453
+ cvSum2Tolerance,
454
+ 500,
455
+ );
456
+ outSmallNonZero = cvRefined.rows;
457
+ cvSwaps = cvRefined.swaps;
458
+ cvAchievedSum = cvRefined.achievedSum;
459
+
460
+ // Warn if CV refinement spent more RTP budget than half-toleranceRTP
461
+ // (e.g. due to integer rounding in cvSumTolerance vs actual swap deltas).
462
+ if (targetSmallNzSumP > 0 && params.toleranceRTP > 0) {
463
+ const rtpDriftAbs =
464
+ Math.abs(cvRefined.achievedSum - targetSmallNzSumP);
465
+ if (rtpDriftAbs > cvSumTolerance * 1.1) {
466
+ const rtpDriftPct = (rtpDriftAbs / targetSmallNzSumP) * 100;
467
+ warnings.push(
468
+ `CV refinement drifted RTP by ${rtpDriftPct.toFixed(3)}% (${cvRefined.swaps} CV swaps)`,
469
+ );
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ } else {
476
+ // No RTP target signal (targetMeanNz <= 0 means cap already exceeds target,
477
+ // or no non-zero slots): fall back to stratified shape-preserving sample.
478
+ outSmallNonZero =
479
+ nB > 0
480
+ ? stratifiedSmallSampleNonZero(srcSmallNonZero, nB, bucketCount, seed + 1)
481
+ : [];
482
+ if (nB > 0 && targetMeanNz <= 0 && targetSumWP > 0) {
483
+ warnings.push(
484
+ `targetRTP=${params.targetRTP} unreachable: cap+large rows alone already meet or exceed it`,
485
+ );
486
+ }
487
+ }
488
+
489
+ }
490
+
491
+ // Phase 4b: gap-filling pass — ensure no intermediate gaps in the Stake
492
+ // hit-rate distribution. Stake's "Gaps in the Hit Rate Table" check
493
+ // rejects publishing tables with empty ranges sandwiched between non-empty
494
+ // ones. The earlier stratified/RTP-aware sampling can leave a small but
495
+ // non-empty source range with 0 output slots after largest-remainder
496
+ // allocation; this pass swaps in a source row from any such missing range.
497
+ //
498
+ // Range occupancy is counted across ALL output rows (cap + large + small),
499
+ // so a range filled by cap/large rows is NOT considered a gap. Swaps only
500
+ // happen within the small-non-zero tier (where we have flexibility).
501
+ const ensureRangeCoverage = params.ensureRangeCoverage ?? true;
502
+ if (ensureRangeCoverage && outSmallNonZero.length > 0) {
503
+ // Sort by payout ascending for the range-scan inside fillStakeRangeGaps.
504
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
505
+ const otherOutRows: LookupRow[] = [...outCap, ...outLarge];
506
+ const gapResult = fillStakeRangeGaps(
507
+ outSmallNonZero,
508
+ srcSmallNonZeroAll,
509
+ otherOutRows,
510
+ sourceMetrics.maxPayout,
511
+ betCost,
512
+ warnings,
513
+ );
514
+ gapFillSwaps = gapResult.swapsApplied;
515
+ gapsUnfillable = gapResult.unfillable;
516
+ }
517
+
518
+ // Phase 4c: diversification pass — maximize distinct payoutCents in output.
519
+ // Stake Engine rejects "Insufficient Unique Events" when too few distinct
520
+ // payouts exist. Swap duplicate-payout rows in outSmallNonZero for source
521
+ // rows carrying NEW (unseen) payout values, subject to the remaining RTP
522
+ // drift budget.
523
+ const minUniqueRate = params.minUniqueEventsRate ?? 0.01;
524
+ if (minUniqueRate > 0 && outSmallNonZero.length > 0) {
525
+ const targetUnique = Math.ceil(minUniqueRate * params.nRowsOut);
526
+ const nHighOut2 = outCap.length + outLarge.length;
527
+ // Predict T_out and W as the gap-fill pass left them (W is final after
528
+ // Phase 5 computes it, but for the budget we use the same prediction the
529
+ // cv pass did).
530
+ const T_out_predict2 = nHighOut2 + W * (outSmallZero.length + outSmallNonZero.length);
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);
539
+ const spent =
540
+ cvAchievedSum !== null && targetSmallNzSumP !== 0
541
+ ? Math.abs(cvAchievedSum - targetSmallNzSumP)
542
+ : 0;
543
+ const sumBudget = Math.max(1, fullBudget - spent);
544
+ // Make sure outSmallNonZero is sorted by payout ascending (gap-fill already
545
+ // maintained this invariant when run; if gap-fill was skipped, sort here).
546
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
547
+ const otherOutRows: LookupRow[] = [...outCap, ...outLarge, ...outSmallZero];
548
+ const divResult = diversifyPayouts(
549
+ outSmallNonZero,
550
+ srcSmallNonZeroAll,
551
+ otherOutRows,
552
+ targetUnique,
553
+ sumBudget,
554
+ warnings,
555
+ );
556
+ diversifySwaps = divResult.swaps;
557
+ }
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
+
630
+ const outSmall: LookupRow[] = [...outSmallZero, ...outSmallNonZero];
631
+
632
+ // Phase 5: compute W (recompute to match actual nSmall after sampling)
633
+ const nHigh = outCap.length + outLarge.length;
634
+ const nSmall = outSmall.length;
635
+ if (nSmall > 0 && target > 0 && target < 1) {
636
+ W = Math.max(1, Math.round((nHigh * (1 - target)) / (nSmall * target)));
637
+ } else if (nHigh === 0) {
638
+ W = 1; // no high tier — all uniform
639
+ }
640
+
641
+ // Phase 6: build output
642
+ const outRows: LookupRow[] = [];
643
+ for (const r of outCap) outRows.push({ sim: r.sim, weight: 1, payoutCents: r.payoutCents });
644
+ for (const r of outLarge) outRows.push({ sim: r.sim, weight: 1, payoutCents: r.payoutCents });
645
+ for (const r of outSmall) outRows.push({ sim: r.sim, weight: W, payoutCents: r.payoutCents });
646
+
647
+ // Pad with synthetic zero-payout rows if short
648
+ while (outRows.length < params.nRowsOut) {
649
+ outRows.push({ sim: -1, weight: 1, payoutCents: 0 });
650
+ }
651
+
652
+ // Phase 7: metrics and report
653
+ const achieved = computeMetrics(outRows);
654
+
655
+ const toleranceMet: ToleranceMet = {
656
+ rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
657
+ cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
658
+ hitRate: Math.abs(achieved.hitRate - params.targetHitRate) <= params.toleranceHitRate,
659
+ maxReached:
660
+ !requireMaxReached ||
661
+ outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
662
+ rtpConcentration: true, // tier-based doesn't concentrate by design — always true
663
+ weightCap: true, // tier-based has bounded weights by design
664
+ };
665
+
666
+ // maxRowRtpShare
667
+ let totalWP = 0;
668
+ for (const r of outRows) totalWP += r.weight * r.payoutCents;
669
+ let maxRowShare = 0;
670
+ if (totalWP > 0) {
671
+ for (const r of outRows) {
672
+ const share = (r.weight * r.payoutCents) / totalWP;
673
+ if (share > maxRowShare) maxRowShare = share;
674
+ }
675
+ }
676
+
677
+ // Max weight ratio
678
+ const uniformPrior = achieved.totalWeight / outRows.length;
679
+ let maxWeightObs = 0;
680
+ for (const r of outRows) {
681
+ if (r.weight > maxWeightObs) maxWeightObs = r.weight;
682
+ }
683
+ const maxWeightRatio = uniformPrior > 0 ? maxWeightObs / uniformPrior : 1;
684
+
685
+ // Stake report
686
+ const stakeReport = computeStakeReport(outRows, achieved, betCost);
687
+
688
+ if (sourceMetrics.maxPayout < maxReachedFraction * params.capMaxWin && requireMaxReached) {
689
+ warnings.push(
690
+ `no row reaches ${maxReachedFraction * 100}% of capMaxWin; requireMaxReached cannot be honored`,
691
+ );
692
+ }
693
+
694
+ // Warn about intermediate gaps in the hit-rate distribution (Stake's
695
+ // "Gaps in the Hit Rate Table" check). Empty ranges above the highest
696
+ // non-empty range are natural and not flagged.
697
+ const gaps = detectHitRateGaps(stakeReport.hitRateDistribution);
698
+ if (gaps.length > 0) {
699
+ const formatted = gaps.map((g) => `[${g.low}, ${g.high})`).join(', ');
700
+ warnings.push(
701
+ `hit-rate distribution has ${gaps.length} intermediate gap(s) — Stake "Gaps in the Hit Rate Table" check may fail: ${formatted}`,
702
+ );
703
+ }
704
+
705
+ return {
706
+ rows: outRows,
707
+ achieved,
708
+ toleranceMet,
709
+ maxRowRtpShare: maxRowShare,
710
+ maxWeightRatio,
711
+ refinement: { rtpSwaps, cvSwaps, gapFillSwaps, gapsUnfillable, diversifySwaps },
712
+ warnings,
713
+ stakeReport,
714
+ };
715
+ }
716
+
717
+ /**
718
+ * RTP-aware non-zero sample: pick `k` rows from `srcNonZero` such that their
719
+ * MEAN payout is approximately `targetMeanPayout`, while preserving shape
720
+ * within each side of the split via stratified sampling.
721
+ *
722
+ * Strategy — two-side analytical LP:
723
+ * Split source into "low" (payout < targetMeanPayout) and "high" (>=).
724
+ * Compute μ_low, μ_high.
725
+ * Solve: n_high × μ_high + (k − n_high) × μ_low = k × targetMeanPayout
726
+ * → n_high = k × (targetMeanPayout − μ_low) / (μ_high − μ_low)
727
+ * Clamp to [0, |high|] and [0, |low|], then stratified-sample within each.
728
+ *
729
+ * If clamping prevents reaching the target mean, returns clamped=true.
730
+ */
731
+ function rtpAwareSampleNonZero(
732
+ srcNonZero: ReadonlyArray<LookupRow>,
733
+ k: number,
734
+ targetMeanPayout: number,
735
+ bucketCount: number,
736
+ seed: number,
737
+ ): { sampled: LookupRow[]; achievedMean: number; clamped: boolean } {
738
+ if (k === 0) return { sampled: [], achievedMean: 0, clamped: false };
739
+ if (k >= srcNonZero.length) {
740
+ let sum = 0;
741
+ for (const r of srcNonZero) sum += r.payoutCents;
742
+ const mean = srcNonZero.length > 0 ? sum / srcNonZero.length : 0;
743
+ return { sampled: [...srcNonZero], achievedMean: mean, clamped: true };
744
+ }
745
+
746
+ // Compute source mean for the early-exit "close enough" check.
747
+ let srcSum = 0;
748
+ for (const r of srcNonZero) srcSum += r.payoutCents;
749
+ const sourceMean = srcSum / srcNonZero.length;
750
+
751
+ // If target is within 1% of source mean, plain stratified sample is fine
752
+ // (no bias needed).
753
+ if (sourceMean > 0 && Math.abs(targetMeanPayout - sourceMean) / sourceMean < 0.01) {
754
+ const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
755
+ let s = 0;
756
+ for (const r of sampled) s += r.payoutCents;
757
+ const mean = sampled.length > 0 ? s / sampled.length : 0;
758
+ return { sampled, achievedMean: mean, clamped: false };
759
+ }
760
+
761
+ // Split into low (payout < targetMean) and high (payout >= targetMean).
762
+ const low: LookupRow[] = [];
763
+ const high: LookupRow[] = [];
764
+ for (const r of srcNonZero) {
765
+ if (r.payoutCents < targetMeanPayout) low.push(r);
766
+ else high.push(r);
767
+ }
768
+ if (low.length === 0 || high.length === 0) {
769
+ // Target outside source range: can't reach it. Sample uniformly + clamp.
770
+ const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
771
+ let s = 0;
772
+ for (const r of sampled) s += r.payoutCents;
773
+ const mean = sampled.length > 0 ? s / sampled.length : 0;
774
+ return { sampled, achievedMean: mean, clamped: true };
775
+ }
776
+
777
+ let lowSum = 0;
778
+ for (const r of low) lowSum += r.payoutCents;
779
+ let highSum = 0;
780
+ for (const r of high) highSum += r.payoutCents;
781
+ const muLow = lowSum / low.length;
782
+ const muHigh = highSum / high.length;
783
+
784
+ // Avoid division by zero if both groups collapse to same mean.
785
+ if (muHigh - muLow < 1e-9) {
786
+ const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
787
+ let s = 0;
788
+ for (const r of sampled) s += r.payoutCents;
789
+ const mean = sampled.length > 0 ? s / sampled.length : 0;
790
+ return { sampled, achievedMean: mean, clamped: true };
791
+ }
792
+
793
+ let nHighOut = Math.round((k * (targetMeanPayout - muLow)) / (muHigh - muLow));
794
+ let clamped = false;
795
+ if (nHighOut < 0) {
796
+ nHighOut = 0;
797
+ clamped = true;
798
+ }
799
+ if (nHighOut > high.length) {
800
+ nHighOut = high.length;
801
+ clamped = true;
802
+ }
803
+ if (nHighOut > k) {
804
+ nHighOut = k;
805
+ clamped = true;
806
+ }
807
+ let nLowOut = k - nHighOut;
808
+ if (nLowOut > low.length) {
809
+ // Shouldn't happen given nHighOut bounds + (low+high=src) and k < src.length,
810
+ // but redirect overflow to high if it does.
811
+ const overflow = nLowOut - low.length;
812
+ nLowOut = low.length;
813
+ nHighOut = Math.min(nHighOut + overflow, high.length);
814
+ clamped = true;
815
+ }
816
+ if (nLowOut < 0) {
817
+ nLowOut = 0;
818
+ clamped = true;
819
+ }
820
+
821
+ const subBuckets = Math.max(2, Math.floor(bucketCount / 2));
822
+ const sampleLow =
823
+ nLowOut >= low.length
824
+ ? [...low]
825
+ : nLowOut > 0
826
+ ? stratifiedSmallSampleNonZero(low, nLowOut, subBuckets, seed)
827
+ : [];
828
+ const sampleHigh =
829
+ nHighOut >= high.length
830
+ ? [...high]
831
+ : nHighOut > 0
832
+ ? stratifiedSmallSampleNonZero(high, nHighOut, subBuckets, seed + 17)
833
+ : [];
834
+
835
+ const sampled = [...sampleLow, ...sampleHigh];
836
+ let sumOut = 0;
837
+ for (const r of sampled) sumOut += r.payoutCents;
838
+ const achievedMean = sampled.length > 0 ? sumOut / sampled.length : 0;
839
+ // If we hit a hard side cap (consumed entire low or entire high group), flag.
840
+ if (nHighOut === high.length || nLowOut === low.length) clamped = true;
841
+ return { sampled, achievedMean, clamped };
842
+ }
843
+
844
+ /**
845
+ * Iterative row-level swap refinement to close residual RTP gap.
846
+ *
847
+ * The analytical low/high partition in `rtpAwareSampleNonZero` lands within a
848
+ * few rows of the optimum but `Math.round(nHighOut)` and `Math.round(W)` leak
849
+ * ~1% of RTP. This function exchanges single rows in/out of the sample to
850
+ * close the residual Σ-payout gap to the target, without touching the
851
+ * row count or weight distribution.
852
+ *
853
+ * Each swap replaces ONE sample row with ONE outside row, so |sampled|
854
+ * stays exactly k. Converges in O(K) swaps where K is the initial gap
855
+ * measured in row-payout units.
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
+
869
+ function refineRtpBySwap(
870
+ sampled: ReadonlyArray<LookupRow>,
871
+ pool: ReadonlyArray<LookupRow>,
872
+ targetSumPayout: number,
873
+ tolerance: number,
874
+ maxSwaps: number,
875
+ rangeProtect?: SwapRangeProtect,
876
+ ): { rows: LookupRow[]; achievedSum: number; swaps: number; converged: boolean } {
877
+ const inSet = new Set<number>();
878
+ for (const r of sampled) inSet.add(r.sim);
879
+
880
+ let achievedSum = 0;
881
+ for (const r of sampled) achievedSum += r.payoutCents;
882
+
883
+ const sampledArr = sampled.slice();
884
+ const outsideArr: LookupRow[] = [];
885
+ for (const r of pool) {
886
+ if (!inSet.has(r.sim)) outsideArr.push(r);
887
+ }
888
+ sampledArr.sort((a, b) => a.payoutCents - b.payoutCents); // ascending
889
+ outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
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
+
915
+ // Binary-search-by-payout helpers on a sorted array.
916
+ const lowerBound = (arr: ReadonlyArray<LookupRow>, target: number): number => {
917
+ let lo = 0;
918
+ let hi = arr.length;
919
+ while (lo < hi) {
920
+ const mid = (lo + hi) >>> 1;
921
+ if (arr[mid].payoutCents < target) lo = mid + 1;
922
+ else hi = mid;
923
+ }
924
+ return lo;
925
+ };
926
+
927
+ let swaps = 0;
928
+ let converged = false;
929
+
930
+ while (swaps < maxSwaps) {
931
+ const delta = targetSumPayout - achievedSum;
932
+ if (Math.abs(delta) <= tolerance) {
933
+ converged = true;
934
+ break;
935
+ }
936
+
937
+ if (delta > 0) {
938
+ // Raise Σ: swap lowest non-protected sample OUT for highest outside row
939
+ // whose payout is ≤ (sampleLow + delta), but > sampleLow.
940
+ if (sampledArr.length === 0 || outsideArr.length === 0) break;
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];
947
+ const desired = sampleLow.payoutCents + delta;
948
+
949
+ // Largest outside index with payout ≤ desired AND > sampleLow.payoutCents.
950
+ // Use lowerBound for desired+1 (first > desired) - 1 → last ≤ desired.
951
+ let bestIdx = lowerBound(outsideArr, desired + 1) - 1;
952
+ // Constraint: must be strictly greater than sampleLow to improve Σ.
953
+ if (bestIdx < 0 || outsideArr[bestIdx].payoutCents <= sampleLow.payoutCents) {
954
+ // No outside row in (sampleLow, sampleLow+delta]. Try the largest
955
+ // available outside row > sampleLow (would overshoot but reduce |delta|
956
+ // only if 2 * outsideRow - 2 * sampleLow ≤ delta is false → would
957
+ // overshoot more than current undershoot; skip).
958
+ // We strictly require non-overshooting swap → stop.
959
+ break;
960
+ }
961
+ const outsideRow = outsideArr[bestIdx];
962
+ const newSum = achievedSum + outsideRow.payoutCents - sampleLow.payoutCents;
963
+
964
+ sampledArr.splice(sampleLowIdx, 1);
965
+ const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
966
+ sampledArr.splice(insertPos, 0, outsideRow);
967
+ // Remove outsideRow from outsideArr, insert sampleLow sorted.
968
+ outsideArr.splice(bestIdx, 1);
969
+ const outPos = lowerBound(outsideArr, sampleLow.payoutCents);
970
+ outsideArr.splice(outPos, 0, sampleLow);
971
+
972
+ inSet.delete(sampleLow.sim);
973
+ inSet.add(outsideRow.sim);
974
+ achievedSum = newSum;
975
+ recordSwap(sampleLow, outsideRow);
976
+ } else {
977
+ // Lower Σ: swap highest non-protected sample OUT for lowest outside row
978
+ // whose payout is ≥ (sampleHigh - |delta|), but < sampleHigh.
979
+ if (sampledArr.length === 0 || outsideArr.length === 0) break;
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];
986
+ const needLoss = -delta;
987
+ const desired = sampleHigh.payoutCents - needLoss;
988
+
989
+ // Smallest outside index with payout ≥ desired AND < sampleHigh.payoutCents.
990
+ let bestIdx = lowerBound(outsideArr, desired);
991
+ if (bestIdx >= outsideArr.length || outsideArr[bestIdx].payoutCents >= sampleHigh.payoutCents) {
992
+ break;
993
+ }
994
+ const outsideRow = outsideArr[bestIdx];
995
+ const newSum = achievedSum + outsideRow.payoutCents - sampleHigh.payoutCents;
996
+
997
+ sampledArr.splice(sampleHighIdx, 1);
998
+ const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
999
+ sampledArr.splice(insertPos, 0, outsideRow);
1000
+ outsideArr.splice(bestIdx, 1);
1001
+ const outPos = lowerBound(outsideArr, sampleHigh.payoutCents);
1002
+ outsideArr.splice(outPos, 0, sampleHigh);
1003
+
1004
+ inSet.delete(sampleHigh.sim);
1005
+ inSet.add(outsideRow.sim);
1006
+ achievedSum = newSum;
1007
+ recordSwap(sampleHigh, outsideRow);
1008
+ }
1009
+ swaps++;
1010
+ }
1011
+
1012
+ return { rows: sampledArr, achievedSum, swaps, converged };
1013
+ }
1014
+
1015
+ /**
1016
+ * Σ-preserving 2-swap refinement to nudge CV toward target without
1017
+ * disturbing Σ payout (RTP).
1018
+ *
1019
+ * A "2-swap" exchanges two rows (a, b) currently IN the sample for two rows
1020
+ * (c, d) currently OUT, such that a + b ≈ c + d (within sumTolerance) and
1021
+ * a² + b² ≠ c² + d². RTP is preserved; only the second moment shifts.
1022
+ *
1023
+ * To INCREASE variance: swap moderate (mid, mid) → spread (low, high).
1024
+ * To DECREASE variance: swap spread (low, high) → moderate (mid, mid).
1025
+ *
1026
+ * Each iteration picks the best-improving swap from a small set of candidates
1027
+ * at the extremes / median of the current sorted sample and outside pool.
1028
+ */
1029
+ function refineCvBySwap(
1030
+ sample: ReadonlyArray<LookupRow>,
1031
+ pool: ReadonlyArray<LookupRow>,
1032
+ targetSumPayout2: number,
1033
+ sumTolerance: number,
1034
+ sum2Tolerance: number,
1035
+ maxSwaps: number,
1036
+ ): { rows: LookupRow[]; achievedSum: number; achievedSum2: number; swaps: number } {
1037
+ const inSet = new Set<number>();
1038
+ for (const r of sample) inSet.add(r.sim);
1039
+
1040
+ let sumP = 0;
1041
+ let sumP2 = 0;
1042
+ for (const r of sample) {
1043
+ sumP += r.payoutCents;
1044
+ sumP2 += r.payoutCents * r.payoutCents;
1045
+ }
1046
+ const initialSumP = sumP;
1047
+
1048
+ const sampleArr = sample.slice().sort((a, b) => a.payoutCents - b.payoutCents);
1049
+ const outsideArr: LookupRow[] = [];
1050
+ for (const r of pool) {
1051
+ if (!inSet.has(r.sim)) outsideArr.push(r);
1052
+ }
1053
+ outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
1054
+
1055
+ let swaps = 0;
1056
+ while (swaps < maxSwaps) {
1057
+ const deltaSum2 = targetSumPayout2 - sumP2;
1058
+ if (Math.abs(deltaSum2) <= sum2Tolerance) break;
1059
+
1060
+ let bestSwap: {
1061
+ sampleA: LookupRow;
1062
+ sampleB: LookupRow;
1063
+ sampleIdxA: number;
1064
+ sampleIdxB: number;
1065
+ outsideC: LookupRow;
1066
+ outsideD: LookupRow;
1067
+ outsideIdxC: number;
1068
+ outsideIdxD: number;
1069
+ newSum: number;
1070
+ newSum2: number;
1071
+ gain: number;
1072
+ efficiency: number;
1073
+ } | null = null;
1074
+
1075
+ // Strategy: for each sample pair (a, b) with a < b, find an outside pair
1076
+ // (c, d) such that c + d ≈ a + b (RTP-preserving) but |c − (a+b)/2| ≠
1077
+ // |a − (a+b)/2|, i.e., the outside pair has different spread than the
1078
+ // sample pair. To INCREASE Σ p²: find outside pair with LARGER spread
1079
+ // (one row below `a`, the other above `b`). To DECREASE Σ p²: find
1080
+ // outside pair with SMALLER spread (both rows between `a` and `b`).
1081
+ //
1082
+ // Among heavy-tailed data the only pairs with non-trivial Σ² impact
1083
+ // anchor on a high-payout row. So we iterate sample's "high" half (anchor
1084
+ // = b, large index) and pair it with each anchor sample row a (a < b).
1085
+ // For increase: find outside c < a with c + d ≈ a + b, where d = a+b−c
1086
+ // and d must exist in outside near payout a+b−c, with d > b. For decrease:
1087
+ // find outside c > a, c < b such that d = a+b−c is also in outside with
1088
+ // a < d < b.
1089
+ if (sampleArr.length < 2 || outsideArr.length < 2) break;
1090
+
1091
+ const sLen = sampleArr.length;
1092
+ const outLen = outsideArr.length;
1093
+
1094
+ // Anchor count: how many sample pairs to probe per iteration. Larger →
1095
+ // better swap selection but slower. K_HI focuses on the high-payout end
1096
+ // (where Σ² is dominated); K_LO on the low end.
1097
+ const K_HI = 8;
1098
+ const K_LO = 8;
1099
+
1100
+ // For each candidate sample pair (aRow, bRow), choose outside `c` then
1101
+ // derive targetD = (a + b) − c. Binary-search outside for d-rows near
1102
+ // targetD. To INCREASE Σ²: pick c far from (a+b)/2 (more spread) — try
1103
+ // very small or very large outside indices. To DECREASE Σ²: pick c near
1104
+ // (a+b)/2 (less spread).
1105
+ //
1106
+ // We probe K_HI sample pairs anchored on high-payout sample rows (where
1107
+ // Σ² is dominated) plus a smattering of mid-range pairs.
1108
+ const cProbes = 32;
1109
+ const sampleAnchorPairs: [number, number][] = [];
1110
+ for (let hi = sLen - 1; hi >= Math.max(0, sLen - K_HI); hi--) {
1111
+ for (let lo = 0; lo < Math.min(K_LO, hi); lo++) {
1112
+ sampleAnchorPairs.push([lo, hi]);
1113
+ }
1114
+ }
1115
+
1116
+ for (const [lo, hi] of sampleAnchorPairs) {
1117
+ const aRow = sampleArr[lo];
1118
+ const bRow = sampleArr[hi];
1119
+ if (aRow.payoutCents === bRow.payoutCents) continue;
1120
+ const oldSum = aRow.payoutCents + bRow.payoutCents;
1121
+ const oldSum2 =
1122
+ aRow.payoutCents * aRow.payoutCents + bRow.payoutCents * bRow.payoutCents;
1123
+
1124
+ // Pick c candidates. For INCREASE: c far from oldSum/2 (extremes of
1125
+ // outside). For DECREASE: c near oldSum/2.
1126
+ const cIdxs: number[] = [];
1127
+ if (deltaSum2 > 0) {
1128
+ // Take extremes: smallest few and largest few outside rows.
1129
+ const half = Math.ceil(cProbes / 2);
1130
+ for (let s = 0; s < Math.min(half, outLen); s++) cIdxs.push(s);
1131
+ for (let s = 0; s < Math.min(half, outLen); s++) {
1132
+ const idx = outLen - 1 - s;
1133
+ if (idx >= 0) cIdxs.push(idx);
1134
+ }
1135
+ } else {
1136
+ // Center of outside near oldSum/2.
1137
+ const target = oldSum / 2;
1138
+ const center = lowerBoundIdx(outsideArr, target);
1139
+ const half = Math.ceil(cProbes / 2);
1140
+ for (let off = -half; off <= half; off++) {
1141
+ const idx = center + off;
1142
+ if (idx >= 0 && idx < outLen) cIdxs.push(idx);
1143
+ }
1144
+ }
1145
+
1146
+ // Tighten per-swap Σ drift: each candidate's newSum must stay within
1147
+ // sumTolerance of initialSumP (cumulative cap), not oldSum (local cap).
1148
+ const lowerOk = initialSumP - sumTolerance;
1149
+ const upperOk = initialSumP + sumTolerance;
1150
+
1151
+ for (const ci of cIdxs) {
1152
+ const cRow = outsideArr[ci];
1153
+ const targetD = oldSum - cRow.payoutCents;
1154
+ if (targetD <= 0) continue;
1155
+ // Per-swap delta limited by remaining cumulative budget so total Σ
1156
+ // stays within sumTolerance of initialSumP.
1157
+ const remainingBudget = Math.max(0, sumTolerance - Math.abs(sumP - initialSumP));
1158
+ const perSwapTol = Math.min(sumTolerance, remainingBudget + sumTolerance * 0.1);
1159
+ const dIdxLB = lowerBoundIdx(outsideArr, targetD - perSwapTol);
1160
+ const dIdxUB = lowerBoundIdx(outsideArr, targetD + perSwapTol + 1);
1161
+ for (let di = dIdxLB; di < dIdxUB && di < outLen; di++) {
1162
+ if (di === ci) continue;
1163
+ const dRow = outsideArr[di];
1164
+ const newSumPair = cRow.payoutCents + dRow.payoutCents;
1165
+ const candNewSumP = sumP - oldSum + newSumPair;
1166
+ // Cumulative drift constraint.
1167
+ if (candNewSumP < lowerOk || candNewSumP > upperOk) continue;
1168
+ const newSum2Pair =
1169
+ cRow.payoutCents * cRow.payoutCents + dRow.payoutCents * dRow.payoutCents;
1170
+ // Skip identity swap.
1171
+ if (
1172
+ (cRow.sim === aRow.sim && dRow.sim === bRow.sim) ||
1173
+ (cRow.sim === bRow.sim && dRow.sim === aRow.sim)
1174
+ )
1175
+ continue;
1176
+ const candNewSum2 = sumP2 - oldSum2 + newSum2Pair;
1177
+ const gain = Math.abs(deltaSum2) - Math.abs(targetSumPayout2 - candNewSum2);
1178
+ // Penalize swaps with non-zero Σ drift: efficiency = gain per unit
1179
+ // of |Σ delta| consumed (with small ε to avoid div-by-zero).
1180
+ const sumDelta = Math.abs(newSumPair - oldSum);
1181
+ const efficiency = gain / (1 + sumDelta);
1182
+ if (gain > 0 && (!bestSwap || efficiency > bestSwap.efficiency)) {
1183
+ bestSwap = {
1184
+ sampleA: aRow,
1185
+ sampleB: bRow,
1186
+ sampleIdxA: lo,
1187
+ sampleIdxB: hi,
1188
+ outsideC: cRow,
1189
+ outsideD: dRow,
1190
+ outsideIdxC: ci,
1191
+ outsideIdxD: di,
1192
+ newSum: candNewSumP,
1193
+ newSum2: candNewSum2,
1194
+ gain,
1195
+ efficiency,
1196
+ };
1197
+ }
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ if (!bestSwap) break;
1203
+
1204
+ // Apply swap. Remove indices in descending order so earlier indices stay valid.
1205
+ const sampleRemove = [bestSwap.sampleIdxA, bestSwap.sampleIdxB].sort((x, y) => y - x);
1206
+ sampleArr.splice(sampleRemove[0], 1);
1207
+ sampleArr.splice(sampleRemove[1], 1);
1208
+ insertSorted(sampleArr, bestSwap.outsideC);
1209
+ insertSorted(sampleArr, bestSwap.outsideD);
1210
+
1211
+ const outsideRemove = [bestSwap.outsideIdxC, bestSwap.outsideIdxD].sort((x, y) => y - x);
1212
+ outsideArr.splice(outsideRemove[0], 1);
1213
+ outsideArr.splice(outsideRemove[1], 1);
1214
+ insertSorted(outsideArr, bestSwap.sampleA);
1215
+ insertSorted(outsideArr, bestSwap.sampleB);
1216
+
1217
+ inSet.delete(bestSwap.sampleA.sim);
1218
+ inSet.delete(bestSwap.sampleB.sim);
1219
+ inSet.add(bestSwap.outsideC.sim);
1220
+ inSet.add(bestSwap.outsideD.sim);
1221
+
1222
+ sumP = bestSwap.newSum;
1223
+ sumP2 = bestSwap.newSum2;
1224
+ swaps++;
1225
+ }
1226
+
1227
+ return { rows: sampleArr, achievedSum: sumP, achievedSum2: sumP2, swaps };
1228
+ }
1229
+
1230
+ function insertSorted(arr: LookupRow[], row: LookupRow): void {
1231
+ const lo = lowerBoundIdx(arr, row.payoutCents);
1232
+ arr.splice(lo, 0, row);
1233
+ }
1234
+
1235
+ /** First index `i` with `arr[i].payoutCents >= target`. */
1236
+ function lowerBoundIdx(arr: ReadonlyArray<LookupRow>, target: number): number {
1237
+ let lo = 0;
1238
+ let hi = arr.length;
1239
+ while (lo < hi) {
1240
+ const mid = (lo + hi) >>> 1;
1241
+ if (arr[mid].payoutCents < target) lo = mid + 1;
1242
+ else hi = mid;
1243
+ }
1244
+ return lo;
1245
+ }
1246
+
1247
+ /**
1248
+ * Stratified sample of `k` rows from non-zero `rows`, partitioning by
1249
+ * log(payout). Each bucket contributes a slot count proportional to its size
1250
+ * in the source, so the sample preserves the source's per-bucket population
1251
+ * and (in expectation) its mean payout — critical for RTP fidelity.
1252
+ *
1253
+ * A simple uniform reservoir over a long-tailed distribution can over-pick
1254
+ * tail rows by chance; with weight=W in the output, that drift gets amplified
1255
+ * (here observed as +7.6% RTP on real ANTE data). Stratification eliminates
1256
+ * that drift.
1257
+ *
1258
+ * Assumes all input rows have payoutCents > 0; the zero-payout rows are
1259
+ * handled separately by `uniformReservoirSample` so the caller can bias the
1260
+ * zero/non-zero ratio per `targetHitRate`.
1261
+ */
1262
+ function stratifiedSmallSampleNonZero(
1263
+ rows: ReadonlyArray<LookupRow>,
1264
+ k: number,
1265
+ bucketCount: number,
1266
+ seed: number,
1267
+ ): LookupRow[] {
1268
+ if (k >= rows.length) return [...rows];
1269
+ if (k <= 0) return [];
1270
+
1271
+ // Find min/max payout for log bucketing.
1272
+ let minPayout = Infinity;
1273
+ let maxPayout = 0;
1274
+ for (const r of rows) {
1275
+ if (r.payoutCents > 0 && r.payoutCents < minPayout) minPayout = r.payoutCents;
1276
+ if (r.payoutCents > maxPayout) maxPayout = r.payoutCents;
1277
+ }
1278
+ const usable = isFinite(minPayout) && maxPayout > 0;
1279
+
1280
+ type Bucket = { indices: number[] };
1281
+ const logBuckets: Bucket[] = Array.from({ length: bucketCount }, () => ({ indices: [] }));
1282
+
1283
+ const logMin = usable ? Math.log(minPayout) : 0;
1284
+ const logMax = usable ? Math.log(maxPayout) : 1;
1285
+ const logSpan = Math.max(logMax - logMin, 1e-9);
1286
+
1287
+ for (let i = 0; i < rows.length; i++) {
1288
+ const r = rows[i];
1289
+ if (r.payoutCents <= 0) continue; // defensive — caller passes non-zero only
1290
+ let bidx = 0;
1291
+ if (usable && logSpan > 0) {
1292
+ const t = (Math.log(r.payoutCents) - logMin) / logSpan;
1293
+ bidx = Math.min(bucketCount - 1, Math.max(0, Math.floor(t * bucketCount)));
1294
+ }
1295
+ logBuckets[bidx].indices.push(i);
1296
+ }
1297
+
1298
+ // Allocate slots per bucket proportional to bucket size (largest-remainder).
1299
+ const sizes = logBuckets.map((b) => b.indices.length);
1300
+ const total = sizes.reduce((s, v) => s + v, 0);
1301
+ if (total === 0) return [];
1302
+ const proposed = sizes.map((s) => (s / total) * k);
1303
+ const floors = proposed.map(Math.floor);
1304
+ const used = floors.reduce((s, v) => s + v, 0);
1305
+ const remainders = proposed.map((p, i) => p - floors[i]);
1306
+ const order = remainders.map((_, i) => i).sort((a, b) => remainders[b] - remainders[a]);
1307
+ let extra = k - used;
1308
+ for (const i of order) {
1309
+ if (extra === 0) break;
1310
+ if (floors[i] < sizes[i]) {
1311
+ floors[i]++;
1312
+ extra--;
1313
+ }
1314
+ }
1315
+ for (let i = 0; i < floors.length; i++) {
1316
+ if (floors[i] > sizes[i]) floors[i] = sizes[i];
1317
+ }
1318
+
1319
+ const rng = mulberry32(seed);
1320
+ const out: LookupRow[] = [];
1321
+ for (let bi = 0; bi < logBuckets.length; bi++) {
1322
+ const slots = floors[bi];
1323
+ if (slots <= 0) continue;
1324
+ const indices = logBuckets[bi].indices;
1325
+ const weights = new Array(indices.length).fill(1);
1326
+ const sampled = weightedReservoirSample(indices, weights, slots, rng);
1327
+ for (const idx of sampled) out.push(rows[idx]);
1328
+ }
1329
+
1330
+ return out;
1331
+ }
1332
+
1333
+ /**
1334
+ * Uniform reservoir sample of `k` rows from `rows`. Used for the zero-payout
1335
+ * sub-bucket where stratification by payout is meaningless (single value).
1336
+ */
1337
+ function uniformReservoirSample(
1338
+ rows: ReadonlyArray<LookupRow>,
1339
+ k: number,
1340
+ seed: number,
1341
+ ): LookupRow[] {
1342
+ if (k >= rows.length) return [...rows];
1343
+ if (k <= 0) return [];
1344
+ const rng = mulberry32(seed);
1345
+ const indices = rows.map((_, i) => i);
1346
+ const weights = new Array(indices.length).fill(1);
1347
+ const sampled = weightedReservoirSample(indices, weights, k, rng);
1348
+ return sampled.map((idx) => rows[idx]);
1349
+ }
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
+
1436
+ /**
1437
+ * Find the index of the Stake hit-rate range that `payoutCents` falls into.
1438
+ * Returns -1 if no range matches (shouldn't happen given the [0, 0.1] +
1439
+ * [20000, ∞) coverage, but defensive).
1440
+ */
1441
+ function findRange(payoutCents: number, betCostCents: number): number {
1442
+ const pm = payoutCents / betCostCents;
1443
+ for (let i = 0; i < HIT_RATE_RANGES.length; i++) {
1444
+ const [low, high] = HIT_RATE_RANGES[i];
1445
+ if (pm >= low && pm < high) return i;
1446
+ }
1447
+ return -1;
1448
+ }
1449
+
1450
+ /**
1451
+ * Fourth refinement pass: ensure no intermediate gaps in the Stake hit-rate
1452
+ * distribution table. Stake rejects publishing tables with empty ranges
1453
+ * sandwiched between non-empty ones ("Gaps in the Hit Rate Table" check).
1454
+ *
1455
+ * Algorithm: for each range below maxPayout that's empty in output, find a
1456
+ * source row in that range and swap it in by replacing an output row whose
1457
+ * payout is closest (minimizes Σ payout drift). Skips ranges where source
1458
+ * has no rows (impossible to fill — emit a one-time warning).
1459
+ *
1460
+ * Modifies `outSmallNonZero` in place (preserves sorted-by-payout-ascending
1461
+ * invariant). Returns number of swaps applied plus the number of ranges that
1462
+ * source couldn't fill.
1463
+ *
1464
+ * Performance: O(R × (N + |source|)) where R = 16 ranges; the rangeCount/
1465
+ * rangeIdx maps avoid the naive O(N²) inner range-count.
1466
+ */
1467
+ function fillStakeRangeGaps(
1468
+ outSmallNonZero: LookupRow[],
1469
+ srcSmallNonZero: ReadonlyArray<LookupRow>,
1470
+ otherOutRows: ReadonlyArray<LookupRow>,
1471
+ maxPayoutCents: number,
1472
+ betCostCents: number,
1473
+ warnings: string[],
1474
+ ): { swapsApplied: number; unfillable: number } {
1475
+ let swapsApplied = 0;
1476
+ let unfillable = 0;
1477
+
1478
+ // Build set of in-sample sim ids for fast membership tests.
1479
+ const inSample = new Set<number>();
1480
+ for (const r of outSmallNonZero) inSample.add(r.sim);
1481
+
1482
+ // Pre-compute per-row range index for the swappable tier (small non-zero).
1483
+ const rangeIdx: number[] = outSmallNonZero.map((r) =>
1484
+ findRange(r.payoutCents, betCostCents),
1485
+ );
1486
+ // Range counts over the FULL output (small + cap/large): a range filled by
1487
+ // cap/large rows is not a gap, even if small-tier alone has 0 in it.
1488
+ const rangeCount = new Map<number, number>();
1489
+ for (const idx of rangeIdx) rangeCount.set(idx, (rangeCount.get(idx) ?? 0) + 1);
1490
+ for (const r of otherOutRows) {
1491
+ const idx = findRange(r.payoutCents, betCostCents);
1492
+ rangeCount.set(idx, (rangeCount.get(idx) ?? 0) + 1);
1493
+ }
1494
+
1495
+ // Only consider Stake ranges whose lower bound is below maxPayout (in bet units).
1496
+ const maxPm = maxPayoutCents / betCostCents;
1497
+
1498
+ for (let rangeI = 0; rangeI < HIT_RATE_RANGES.length; rangeI++) {
1499
+ const [low, high] = HIT_RATE_RANGES[rangeI];
1500
+ if (low >= maxPm) break; // tail ranges above maxPayout — natural empty
1501
+ const lowCents = low * betCostCents;
1502
+ const highCents = high === Infinity ? Infinity : high * betCostCents;
1503
+
1504
+ // Skip the [0, 0.1) range — that's the zero-tier territory (payouts < 0.1
1505
+ // bet units, i.e. 0 cents at betCost=100). Zero-payouts are handled by the
1506
+ // zero sub-bucket; we don't fill via non-zero rows here.
1507
+ if (low === 0) continue;
1508
+
1509
+ // Skip if already populated.
1510
+ if ((rangeCount.get(rangeI) ?? 0) >= 1) continue;
1511
+
1512
+ // Find source rows in this range that aren't already in sample.
1513
+ const sourceCandidates: LookupRow[] = [];
1514
+ for (const r of srcSmallNonZero) {
1515
+ if (r.payoutCents >= lowCents && r.payoutCents < highCents && !inSample.has(r.sim)) {
1516
+ sourceCandidates.push(r);
1517
+ }
1518
+ }
1519
+ if (sourceCandidates.length === 0) {
1520
+ unfillable++;
1521
+ const rangeStr =
1522
+ high === Infinity ? `[${low}, infinity)` : `[${low}, ${high})`;
1523
+ warnings.push(
1524
+ `gap in hit-rate range ${rangeStr}x bet: source has no rows in this payout-multiplier range`,
1525
+ );
1526
+ continue;
1527
+ }
1528
+
1529
+ // Pick source row closest to range geometric mid so any subsequent
1530
+ // statistic sliding remains balanced.
1531
+ const midPayout =
1532
+ high === Infinity
1533
+ ? Math.max(lowCents, maxPayoutCents)
1534
+ : Math.sqrt(lowCents * highCents);
1535
+ let swapInRow = sourceCandidates[0];
1536
+ let bestDist = Math.abs(swapInRow.payoutCents - midPayout);
1537
+ for (const r of sourceCandidates) {
1538
+ const d = Math.abs(r.payoutCents - midPayout);
1539
+ if (d < bestDist) {
1540
+ swapInRow = r;
1541
+ bestDist = d;
1542
+ }
1543
+ }
1544
+
1545
+ // Pick output row to remove: payout closest to swapInRow.payoutCents so
1546
+ // Σ-payout drift (i.e. RTP impact) is minimized. Skip any row whose
1547
+ // removal would empty another range.
1548
+ let removeIdx = -1;
1549
+ let removeDist = Infinity;
1550
+ for (let i = 0; i < outSmallNonZero.length; i++) {
1551
+ if ((rangeCount.get(rangeIdx[i]) ?? 0) <= 1) continue; // protect other ranges
1552
+ const r = outSmallNonZero[i];
1553
+ const d = Math.abs(r.payoutCents - swapInRow.payoutCents);
1554
+ if (d < removeDist) {
1555
+ removeDist = d;
1556
+ removeIdx = i;
1557
+ }
1558
+ }
1559
+ if (removeIdx < 0) {
1560
+ // No safe removal candidate — every range has exactly 1 row. Skip
1561
+ // this gap rather than break other ranges.
1562
+ unfillable++;
1563
+ continue;
1564
+ }
1565
+
1566
+ // Apply the swap.
1567
+ const removedRow = outSmallNonZero[removeIdx];
1568
+ const removedRangeIdx = rangeIdx[removeIdx];
1569
+ inSample.delete(removedRow.sim);
1570
+ inSample.add(swapInRow.sim);
1571
+ // Remove the old row, then re-insert swapInRow at the correct sorted
1572
+ // position to preserve the ascending invariant. Also update rangeIdx
1573
+ // and rangeCount.
1574
+ outSmallNonZero.splice(removeIdx, 1);
1575
+ rangeIdx.splice(removeIdx, 1);
1576
+ rangeCount.set(removedRangeIdx, (rangeCount.get(removedRangeIdx) ?? 1) - 1);
1577
+
1578
+ let insertPos = 0;
1579
+ while (
1580
+ insertPos < outSmallNonZero.length &&
1581
+ outSmallNonZero[insertPos].payoutCents < swapInRow.payoutCents
1582
+ ) {
1583
+ insertPos++;
1584
+ }
1585
+ outSmallNonZero.splice(insertPos, 0, swapInRow);
1586
+ rangeIdx.splice(insertPos, 0, rangeI);
1587
+ rangeCount.set(rangeI, (rangeCount.get(rangeI) ?? 0) + 1);
1588
+
1589
+ swapsApplied++;
1590
+ }
1591
+
1592
+ return { swapsApplied, unfillable };
1593
+ }
1594
+
1595
+ /** First index `i` with `arr[i] >= target` (number-array variant). */
1596
+ function lowerBoundNum(arr: ReadonlyArray<number>, target: number): number {
1597
+ let lo = 0;
1598
+ let hi = arr.length;
1599
+ while (lo < hi) {
1600
+ const mid = (lo + hi) >>> 1;
1601
+ if (arr[mid] < target) lo = mid + 1;
1602
+ else hi = mid;
1603
+ }
1604
+ return lo;
1605
+ }
1606
+
1607
+ /**
1608
+ * 5th refinement pass: swap duplicate-payout rows for source rows with NEW
1609
+ * payout values until output has ≥ targetUnique distinct payoutCents. Source
1610
+ * provides candidate rows whose payoutCents is NOT currently in output.
1611
+ *
1612
+ * Each swap is constrained to keep Σ_smallNz drift ≤ remainingSumBudget. Picks
1613
+ * the swap-in payout closest to swap-out's payout to minimize RTP/CV impact.
1614
+ *
1615
+ * Updates `outSmallNonZero` in place. Returns the number of swaps applied,
1616
+ * achieved unique count across (otherOutRows ∪ outSmallNonZero), and whether
1617
+ * the target was reached.
1618
+ */
1619
+ function diversifyPayouts(
1620
+ outSmallNonZero: LookupRow[],
1621
+ srcSmallNonZero: ReadonlyArray<LookupRow>,
1622
+ otherOutRows: ReadonlyArray<LookupRow>,
1623
+ targetUnique: number,
1624
+ remainingSumBudget: number,
1625
+ warnings: string[],
1626
+ ): { swaps: number; achievedUnique: number; reached: boolean } {
1627
+ // Build the current set of payouts in output AND in-sample sim ids.
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
1633
+ const inSampleSims = new Set<number>();
1634
+ for (const r of otherOutRows) {
1635
+ inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
1636
+ }
1637
+ for (let i = 0; i < outSmallNonZero.length; i++) {
1638
+ const r = outSmallNonZero[i];
1639
+ inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
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);
1647
+ }
1648
+ let uniqueNow = inOutputPayouts.size;
1649
+
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>();
1653
+ for (const r of srcSmallNonZero) {
1654
+ if (inOutputPayouts.has(r.payoutCents)) continue;
1655
+ if (inSampleSims.has(r.sim)) continue;
1656
+ if (!newPayoutsAvailable.has(r.payoutCents)) {
1657
+ newPayoutsAvailable.set(r.payoutCents, r);
1658
+ }
1659
+ }
1660
+ // Sorted list of new payout values for nearest-neighbor binary search.
1661
+ const newPayoutsSorted = Array.from(newPayoutsAvailable.keys()).sort((a, b) => a - b);
1662
+
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);
1672
+ }
1673
+
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 };
1681
+ }
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 };
1689
+ }
1690
+
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.
1697
+ let sumBudget = remainingSumBudget;
1698
+ let runningDrift = 0;
1699
+ let exhaustedReason: 'budget' | 'sourceOrAllocation' | null = null;
1700
+
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
+ }
1732
+ }
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
+ }
1741
+
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
+ }
1763
+
1764
+ // Apply swap IN-PLACE — overwrite at the same array slot so existing
1765
+ // indices in payoutToOutRows remain stable.
1766
+ outSmallNonZero[swapOutIdx] = swapInRow;
1767
+ inSampleSims.delete(swapOutRow.sim);
1768
+ inSampleSims.add(swapInRow.sim);
1769
+
1770
+ // Update payoutToOutRows / dupPayouts for the OLD payout.
1771
+ rowsForP.delete(swapOutIdx);
1772
+ const oldCount = inOutputPayouts.get(swapOutP) ?? 0;
1773
+ if (oldCount <= 1) {
1774
+ inOutputPayouts.delete(swapOutP);
1775
+ uniqueNow--;
1776
+ } else {
1777
+ inOutputPayouts.set(swapOutP, oldCount - 1);
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).
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);
1792
+ uniqueNow++;
1793
+
1794
+ // bestNewP consumed: remove it from the available pool / sorted list.
1795
+ newPayoutsAvailable.delete(bestNewP);
1796
+ const removeAt = lowerBoundNum(newPayoutsSorted, bestNewP);
1797
+ if (removeAt < newPayoutsSorted.length && newPayoutsSorted[removeAt] === bestNewP) {
1798
+ newPayoutsSorted.splice(removeAt, 1);
1799
+ }
1800
+
1801
+ runningDrift += pickDelta;
1802
+ swaps++;
1803
+ }
1804
+
1805
+ if (uniqueNow < targetUnique) {
1806
+ if (exhaustedReason === 'budget') {
1807
+ warnings.push(
1808
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): RTP-drift budget exhausted`,
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
+ );
1818
+ } else {
1819
+ warnings.push(
1820
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow})`,
1821
+ );
1822
+ }
1823
+ }
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 };
1831
+ }
1832
+