@energy8platform/stake-math-tools 0.3.0 → 0.5.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,1428 @@
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
+ const betCost = params.betCostCents ?? DEFAULTS.betCostCents;
38
+ const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
39
+ const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
40
+ const seed = params.seed ?? DEFAULTS.seed;
41
+
42
+ // Phase 1: filter
43
+ const filtered: LookupRow[] = [];
44
+ for (const r of rowsIn) {
45
+ if (r.payoutCents > params.capMaxWin) continue;
46
+ filtered.push(r);
47
+ }
48
+ if (filtered.length < params.nRowsOut) {
49
+ throw new Error(
50
+ `tiered: filtered input has ${filtered.length} rows, fewer than nRowsOut=${params.nRowsOut}`,
51
+ );
52
+ }
53
+
54
+ const sourceMetrics = computeMetrics(filtered);
55
+
56
+ // Phase 2: thresholds
57
+ const maxPm = sourceMetrics.maxPayout / betCost;
58
+ const capPmThreshold = params.capPmThreshold ?? DEFAULTS.capPmFraction * maxPm;
59
+ const capPayoutCents = Math.floor(capPmThreshold * betCost);
60
+ const largePmThreshold = params.largePmThreshold; // undefined → no large tier
61
+ const largePayoutCents =
62
+ largePmThreshold !== undefined ? Math.floor(largePmThreshold * betCost) : undefined;
63
+
64
+ // Phase 3: classify source
65
+ const srcCap: LookupRow[] = [];
66
+ const srcLarge: LookupRow[] = [];
67
+ const srcSmall: LookupRow[] = [];
68
+ for (const r of filtered) {
69
+ if (r.payoutCents >= capPayoutCents) srcCap.push(r);
70
+ else if (largePayoutCents !== undefined && r.payoutCents >= largePayoutCents) srcLarge.push(r);
71
+ else srcSmall.push(r);
72
+ }
73
+
74
+ // Target rate
75
+ const target =
76
+ params.largeTarget ?? (srcCap.length + srcLarge.length) / filtered.length;
77
+
78
+ // Phase 4: pick output rows
79
+ // Include all cap; include all large; fill remaining with small (random sample)
80
+ let outCap = srcCap;
81
+ let outLarge = srcLarge;
82
+
83
+ if (outCap.length > params.nRowsOut) {
84
+ // Too many cap rows — keep highest-payout
85
+ outCap = [...srcCap].sort((a, b) => b.payoutCents - a.payoutCents).slice(0, params.nRowsOut);
86
+ outLarge = [];
87
+ } else if (outCap.length + outLarge.length > params.nRowsOut) {
88
+ // Cap fits, but cap+large too many — drop some large
89
+ const allowedLarge = params.nRowsOut - outCap.length;
90
+ outLarge = [...srcLarge]
91
+ .sort((a, b) => b.payoutCents - a.payoutCents)
92
+ .slice(0, allowedLarge);
93
+ }
94
+
95
+ const slotsForSmall = params.nRowsOut - outCap.length - outLarge.length;
96
+ const warnings: string[] = [];
97
+ let outSmallZero: LookupRow[] = [];
98
+ let outSmallNonZero: LookupRow[] = [];
99
+ let srcSmallNonZeroAll: ReadonlyArray<LookupRow> = [];
100
+ // Refinement-pass swap counters.
101
+ let rtpSwaps = 0;
102
+ let cvSwaps = 0;
103
+ let gapFillSwaps = 0;
104
+ let gapsUnfillable = 0;
105
+ let diversifySwaps = 0;
106
+ // Diversify-pass budget inputs hoisted from the inner scope. The diversify
107
+ // pass runs AFTER gap-fill (outside the inner scope), but needs the same
108
+ // target Σ_smallNz_payout the cv pass used, plus the achievedSum the cv
109
+ // pass left, to compute the remaining RTP-drift headroom.
110
+ let targetSmallNzSumP = 0;
111
+ let cvAchievedSum: number | null = null;
112
+ // Compute W and small-tier subdivision now, so we can do RTP-aware non-zero
113
+ // sampling using the same W used in the output.
114
+ let W = 1;
115
+ if (slotsForSmall > 0 && srcSmall.length > 0) {
116
+ // Subdivide small into zero / non-zero so we can bias the sampling by
117
+ // params.targetHitRate. Tier-based preserves cap rate naturally, but the
118
+ // small-tier non-zero/zero composition can still be shifted to match a
119
+ // user-requested hit-rate.
120
+ const srcSmallZero: LookupRow[] = [];
121
+ const srcSmallNonZero: LookupRow[] = [];
122
+ for (const r of srcSmall) {
123
+ if (r.payoutCents === 0) srcSmallZero.push(r);
124
+ else srcSmallNonZero.push(r);
125
+ }
126
+ srcSmallNonZeroAll = srcSmallNonZero;
127
+
128
+ // Target cap rate (cap + large weight share) — same `target` used for W below.
129
+ const target_cap_rate = target;
130
+ const targetHitRate = params.targetHitRate;
131
+
132
+ // Solve for n_B (non-zero small rows) so that effective hit-rate = targetHitRate.
133
+ // (nHighOut + W × n_B) / (nHighOut + W × nSmall) = h
134
+ // where W is computed below using the same `target_cap_rate` formula, which
135
+ // implies high contributes target_cap_rate of total weight and small carries
136
+ // the remaining 1 - target_cap_rate split uniformly across nSmall.
137
+ // → n_B = nSmall × [h − (1−h) × target_cap_rate / (1 − target_cap_rate)]
138
+ const nHighOut = outCap.length + outLarge.length;
139
+ let nB: number;
140
+ if (target_cap_rate >= 1 || nHighOut === 0) {
141
+ // No high tier or fully high: every small row contributes h share uniformly.
142
+ nB = Math.round(slotsForSmall * targetHitRate);
143
+ } else {
144
+ const denom = 1 - target_cap_rate;
145
+ nB = Math.round(
146
+ slotsForSmall * (targetHitRate - ((1 - targetHitRate) * target_cap_rate) / denom),
147
+ );
148
+ }
149
+ const requestedNB = nB;
150
+ nB = Math.max(0, Math.min(nB, slotsForSmall, srcSmallNonZero.length));
151
+ let nA = slotsForSmall - nB;
152
+ // If zero bucket can't absorb nA, redirect overflow to non-zero
153
+ if (nA > srcSmallZero.length) {
154
+ const overflow = nA - srcSmallZero.length;
155
+ nA = srcSmallZero.length;
156
+ nB = Math.min(nB + overflow, srcSmallNonZero.length);
157
+ // If still short, the output will simply be under-filled and padded later.
158
+ }
159
+
160
+ // Warnings on unreachable hit-rate targets.
161
+ // Priority:
162
+ // 1. Source has too few non-zero rows (covers nB===0 from empty source too).
163
+ // 2. Cap-rate alone already meets/exceeds the target (formula yields nB<=0).
164
+ if (
165
+ requestedNB > srcSmallNonZero.length &&
166
+ nB === srcSmallNonZero.length &&
167
+ targetHitRate > 0
168
+ ) {
169
+ warnings.push(
170
+ `source has only ${srcSmallNonZero.length} non-zero small rows; cannot reach targetHitRate=${targetHitRate}`,
171
+ );
172
+ } else if (requestedNB <= 0 && targetHitRate > 0 && nB === 0) {
173
+ warnings.push(
174
+ `targetHitRate=${targetHitRate} unreachable; cap+large weight share already meets or exceeds it (n_B clamped to 0)`,
175
+ );
176
+ }
177
+
178
+ const bucketCount = params.bucketCount ?? 100;
179
+ // Sample zero sub-bucket: uniform reservoir.
180
+ outSmallZero =
181
+ nA >= srcSmallZero.length
182
+ ? [...srcSmallZero]
183
+ : uniformReservoirSample(srcSmallZero, nA, seed);
184
+
185
+ // RTP-aware non-zero sampling.
186
+ // Compute the W we will use in the output (mirrors Phase 5 below). We have
187
+ // nSmall = nA + nB once sampled; tier-based has bounded weights by design.
188
+ const nSmallTotal = nA + nB;
189
+ let WforSampling = 1;
190
+ if (nSmallTotal > 0 && target > 0 && target < 1) {
191
+ WforSampling = Math.max(
192
+ 1,
193
+ Math.round((nHighOut * (1 - target)) / (nSmallTotal * target)),
194
+ );
195
+ } else if (nHighOut === 0) {
196
+ WforSampling = 1;
197
+ }
198
+ W = WforSampling;
199
+
200
+ // Compute target mean payout for the non-zero sample so the overall RTP
201
+ // hits params.targetRTP.
202
+ // Total weight T = nHighOut + W × (nA + nB)
203
+ // Σ(w·p) needed = targetRTP × T × betCost (NOT × 100 — betCost may differ)
204
+ // Cap rows contribute Σ_cap = sum of cap+large payouts (weight=1 each)
205
+ // Σ_smallNz contribution = W × Σ_sampled_nz_payouts
206
+ // → Target Σ_sampled_nz_payouts = (targetRTP × T × betCost − Σ_cap) / W
207
+ const totalWeightTarget = nHighOut + W * (nA + nB);
208
+ const targetSumWP = params.targetRTP * totalWeightTarget * betCost;
209
+ let capSumP = 0;
210
+ for (const r of outCap) capSumP += r.payoutCents;
211
+ for (const r of outLarge) capSumP += r.payoutCents;
212
+ targetSmallNzSumP = W > 0 ? (targetSumWP - capSumP) / W : 0;
213
+ const targetMeanNz = nB > 0 ? targetSmallNzSumP / nB : 0;
214
+
215
+ if (nB >= srcSmallNonZero.length) {
216
+ outSmallNonZero = [...srcSmallNonZero];
217
+ } else if (nB > 0 && targetMeanNz > 0) {
218
+ const sampleResult = rtpAwareSampleNonZero(
219
+ srcSmallNonZero,
220
+ nB,
221
+ targetMeanNz,
222
+ bucketCount,
223
+ seed + 1,
224
+ );
225
+ outSmallNonZero = sampleResult.sampled;
226
+ if (sampleResult.clamped) {
227
+ warnings.push(
228
+ `targetRTP=${params.targetRTP} unreachable for non-zero sample: requested mean payout ` +
229
+ `${targetMeanNz.toFixed(0)} cents but achieved ${sampleResult.achievedMean.toFixed(0)} cents`,
230
+ );
231
+ }
232
+
233
+ // Iterative swap refinement: close residual RTP gap by swapping
234
+ // boundary rows in/out of the sample. Each swap is a single LookupRow
235
+ // exchange, so the weight distribution remains exactly intact.
236
+ //
237
+ // params.toleranceRTP is on LUT-RTP scale (e.g. 0.001 = 0.1pp LUT RTP).
238
+ // Achieved LUT RTP = (Σ_cap + W × Σ_smallNz) / (T × 100).
239
+ // Tolerable Σ_smallNz drift = toleranceRTP × T × 100 / W.
240
+ // Half it to leave a small safety budget for the CV pass that follows.
241
+ const T_out_predict = nHighOut + W * (nA + nB);
242
+ const rtpTolerance = W > 0 && T_out_predict > 0
243
+ ? Math.max(1, 0.5 * params.toleranceRTP * T_out_predict * 100 / W)
244
+ : Math.max(1, 0.005 * targetSmallNzSumP);
245
+ const refined = refineRtpBySwap(
246
+ outSmallNonZero,
247
+ srcSmallNonZero,
248
+ targetSmallNzSumP,
249
+ rtpTolerance,
250
+ 10000,
251
+ );
252
+ outSmallNonZero = refined.rows;
253
+ rtpSwaps = refined.swaps;
254
+
255
+ if (!refined.converged && refined.swaps > 0 && targetSmallNzSumP > 0) {
256
+ const achievedMean =
257
+ outSmallNonZero.length > 0 ? refined.achievedSum / outSmallNonZero.length : 0;
258
+ const targetMean =
259
+ outSmallNonZero.length > 0 ? targetSmallNzSumP / outSmallNonZero.length : 0;
260
+ const gap =
261
+ targetMean > 0 ? (Math.abs(achievedMean - targetMean) / targetMean) * 100 : 0;
262
+ warnings.push(
263
+ `RTP refinement did not fully converge after ${refined.swaps} swaps (${gap.toFixed(2)}% gap)`,
264
+ );
265
+ }
266
+
267
+ // Third refinement pass: Σ-preserving 2-swap pass to nudge CV toward
268
+ // targetCV. RTP (Σ payout) is preserved within a 0.5% tolerance; only
269
+ // Σ payout² is re-shaped. Increases CV by swapping a moderate (mid,mid)
270
+ // pair from the sample for a spread (low,high) pair from outside; or
271
+ // the inverse to decrease CV.
272
+ //
273
+ // Math:
274
+ // mean_out = (Σ_cap_payout + W × Σ_smallNz_payout) / T_out
275
+ // target_var = (targetCV × mean_out)²
276
+ // target E[X²] = target_var + mean_out² = mean_out² × (targetCV² + 1)
277
+ // target Σ(w·p²) = target_E[X²] × T_out
278
+ // target Σ_smallNz_p² = (target Σ(w·p²) − Σ_cap_p²) / W
279
+ if (params.targetCV > 0 && outSmallNonZero.length >= 2) {
280
+ const T_out = nHighOut + W * (nA + nB);
281
+ if (T_out > 0) {
282
+ let capSumP2 = 0;
283
+ for (const r of outCap) capSumP2 += r.payoutCents * r.payoutCents;
284
+ for (const r of outLarge) capSumP2 += r.payoutCents * r.payoutCents;
285
+
286
+ // mean_out predicted from converged RTP refinement.
287
+ const meanOutPredicted = (capSumP + W * refined.achievedSum) / T_out;
288
+ const targetEX2 = meanOutPredicted * meanOutPredicted * (params.targetCV ** 2 + 1);
289
+ const targetSumWP2 = targetEX2 * T_out;
290
+ const targetSmallNzSumP2 = W > 0 ? (targetSumWP2 - capSumP2) / W : 0;
291
+
292
+ if (targetSmallNzSumP2 > 0) {
293
+ // Cumulative Σ-drift cap per CV pass = the OTHER HALF of the user's
294
+ // RTP tolerance budget (the first half was spent by refineRtpBySwap).
295
+ // Σ tolerance = 0.5 × toleranceRTP × T × 100 / W (same conversion).
296
+ // This guarantees that even after both passes, total RTP drift
297
+ // stays within params.toleranceRTP.
298
+ const cvSumTolerance = W > 0
299
+ ? Math.max(1, 0.5 * params.toleranceRTP * T_out * 100 / W)
300
+ : Math.max(1, 0.001 * targetSmallNzSumP);
301
+ // CV convergence threshold in Σ²-space:
302
+ // target E[X²] = mean² × (CV² + 1)
303
+ // d(Σ²_smallNz) / dCV = 2 × CV × mean² × T / W
304
+ // Σ²-tolerance = 2 × targetCV × mean² × T × toleranceCV / W
305
+ // Stop swapping when Σ² is within this band of target.
306
+ const cvSum2Tolerance = W > 0 && params.toleranceCV > 0 && params.targetCV > 0
307
+ ? Math.max(1,
308
+ 2 * params.targetCV * meanOutPredicted * meanOutPredicted *
309
+ T_out * params.toleranceCV / W)
310
+ : Math.max(1, 0.001 * Math.abs(targetSmallNzSumP2));
311
+ const cvRefined = refineCvBySwap(
312
+ outSmallNonZero,
313
+ srcSmallNonZero,
314
+ targetSmallNzSumP2,
315
+ cvSumTolerance,
316
+ cvSum2Tolerance,
317
+ 500,
318
+ );
319
+ outSmallNonZero = cvRefined.rows;
320
+ cvSwaps = cvRefined.swaps;
321
+ cvAchievedSum = cvRefined.achievedSum;
322
+
323
+ // Warn if CV refinement spent more RTP budget than half-toleranceRTP
324
+ // (e.g. due to integer rounding in cvSumTolerance vs actual swap deltas).
325
+ if (targetSmallNzSumP > 0 && params.toleranceRTP > 0) {
326
+ const rtpDriftAbs =
327
+ Math.abs(cvRefined.achievedSum - targetSmallNzSumP);
328
+ if (rtpDriftAbs > cvSumTolerance * 1.1) {
329
+ const rtpDriftPct = (rtpDriftAbs / targetSmallNzSumP) * 100;
330
+ warnings.push(
331
+ `CV refinement drifted RTP by ${rtpDriftPct.toFixed(3)}% (${cvRefined.swaps} CV swaps)`,
332
+ );
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }
338
+ } else {
339
+ // No RTP target signal (targetMeanNz <= 0 means cap already exceeds target,
340
+ // or no non-zero slots): fall back to stratified shape-preserving sample.
341
+ outSmallNonZero =
342
+ nB > 0
343
+ ? stratifiedSmallSampleNonZero(srcSmallNonZero, nB, bucketCount, seed + 1)
344
+ : [];
345
+ if (nB > 0 && targetMeanNz <= 0 && targetSumWP > 0) {
346
+ warnings.push(
347
+ `targetRTP=${params.targetRTP} unreachable: cap+large rows alone already meet or exceed it`,
348
+ );
349
+ }
350
+ }
351
+
352
+ }
353
+
354
+ // Phase 4b: gap-filling pass — ensure no intermediate gaps in the Stake
355
+ // hit-rate distribution. Stake's "Gaps in the Hit Rate Table" check
356
+ // rejects publishing tables with empty ranges sandwiched between non-empty
357
+ // ones. The earlier stratified/RTP-aware sampling can leave a small but
358
+ // non-empty source range with 0 output slots after largest-remainder
359
+ // allocation; this pass swaps in a source row from any such missing range.
360
+ //
361
+ // Range occupancy is counted across ALL output rows (cap + large + small),
362
+ // so a range filled by cap/large rows is NOT considered a gap. Swaps only
363
+ // happen within the small-non-zero tier (where we have flexibility).
364
+ const ensureRangeCoverage = params.ensureRangeCoverage ?? true;
365
+ if (ensureRangeCoverage && outSmallNonZero.length > 0) {
366
+ // Sort by payout ascending for the range-scan inside fillStakeRangeGaps.
367
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
368
+ const otherOutRows: LookupRow[] = [...outCap, ...outLarge];
369
+ const gapResult = fillStakeRangeGaps(
370
+ outSmallNonZero,
371
+ srcSmallNonZeroAll,
372
+ otherOutRows,
373
+ sourceMetrics.maxPayout,
374
+ betCost,
375
+ warnings,
376
+ );
377
+ gapFillSwaps = gapResult.swapsApplied;
378
+ gapsUnfillable = gapResult.unfillable;
379
+ }
380
+
381
+ // Phase 4c: diversification pass — maximize distinct payoutCents in output.
382
+ // Stake Engine rejects "Insufficient Unique Events" when too few distinct
383
+ // payouts exist. Swap duplicate-payout rows in outSmallNonZero for source
384
+ // rows carrying NEW (unseen) payout values, subject to the remaining RTP
385
+ // drift budget.
386
+ const minUniqueRate = params.minUniqueEventsRate ?? 0.01;
387
+ if (minUniqueRate > 0 && outSmallNonZero.length > 0) {
388
+ const targetUnique = Math.ceil(minUniqueRate * params.nRowsOut);
389
+ const nHighOut2 = outCap.length + outLarge.length;
390
+ // Predict T_out and W as the gap-fill pass left them (W is final after
391
+ // Phase 5 computes it, but for the budget we use the same prediction the
392
+ // cv pass did).
393
+ const T_out_predict2 = nHighOut2 + W * (outSmallZero.length + outSmallNonZero.length);
394
+ // Remaining Σ-drift budget: total budget minus what CV already spent.
395
+ const totalBudget = W > 0 && T_out_predict2 > 0
396
+ ? 0.5 * params.toleranceRTP * T_out_predict2 * 100 / W
397
+ : 0.005 * Math.abs(targetSmallNzSumP);
398
+ const spent =
399
+ cvAchievedSum !== null && targetSmallNzSumP !== 0
400
+ ? Math.abs(cvAchievedSum - targetSmallNzSumP)
401
+ : 0;
402
+ const sumBudget = Math.max(1, totalBudget - spent);
403
+ // Make sure outSmallNonZero is sorted by payout ascending (gap-fill already
404
+ // maintained this invariant when run; if gap-fill was skipped, sort here).
405
+ outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
406
+ const otherOutRows: LookupRow[] = [...outCap, ...outLarge, ...outSmallZero];
407
+ const divResult = diversifyPayouts(
408
+ outSmallNonZero,
409
+ srcSmallNonZeroAll,
410
+ otherOutRows,
411
+ targetUnique,
412
+ sumBudget,
413
+ warnings,
414
+ );
415
+ diversifySwaps = divResult.swaps;
416
+ }
417
+
418
+ const outSmall: LookupRow[] = [...outSmallZero, ...outSmallNonZero];
419
+
420
+ // Phase 5: compute W (recompute to match actual nSmall after sampling)
421
+ const nHigh = outCap.length + outLarge.length;
422
+ const nSmall = outSmall.length;
423
+ if (nSmall > 0 && target > 0 && target < 1) {
424
+ W = Math.max(1, Math.round((nHigh * (1 - target)) / (nSmall * target)));
425
+ } else if (nHigh === 0) {
426
+ W = 1; // no high tier — all uniform
427
+ }
428
+
429
+ // Phase 6: build output
430
+ const outRows: LookupRow[] = [];
431
+ for (const r of outCap) outRows.push({ sim: r.sim, weight: 1, payoutCents: r.payoutCents });
432
+ for (const r of outLarge) outRows.push({ sim: r.sim, weight: 1, payoutCents: r.payoutCents });
433
+ for (const r of outSmall) outRows.push({ sim: r.sim, weight: W, payoutCents: r.payoutCents });
434
+
435
+ // Pad with synthetic zero-payout rows if short
436
+ while (outRows.length < params.nRowsOut) {
437
+ outRows.push({ sim: -1, weight: 1, payoutCents: 0 });
438
+ }
439
+
440
+ // Phase 7: metrics and report
441
+ const achieved = computeMetrics(outRows);
442
+
443
+ const toleranceMet: ToleranceMet = {
444
+ rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
445
+ cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
446
+ hitRate: Math.abs(achieved.hitRate - params.targetHitRate) <= params.toleranceHitRate,
447
+ maxReached:
448
+ !requireMaxReached ||
449
+ outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
450
+ rtpConcentration: true, // tier-based doesn't concentrate by design — always true
451
+ weightCap: true, // tier-based has bounded weights by design
452
+ };
453
+
454
+ // maxRowRtpShare
455
+ let totalWP = 0;
456
+ for (const r of outRows) totalWP += r.weight * r.payoutCents;
457
+ let maxRowShare = 0;
458
+ if (totalWP > 0) {
459
+ for (const r of outRows) {
460
+ const share = (r.weight * r.payoutCents) / totalWP;
461
+ if (share > maxRowShare) maxRowShare = share;
462
+ }
463
+ }
464
+
465
+ // Max weight ratio
466
+ const uniformPrior = achieved.totalWeight / outRows.length;
467
+ let maxWeightObs = 0;
468
+ for (const r of outRows) {
469
+ if (r.weight > maxWeightObs) maxWeightObs = r.weight;
470
+ }
471
+ const maxWeightRatio = uniformPrior > 0 ? maxWeightObs / uniformPrior : 1;
472
+
473
+ // Stake report
474
+ const stakeReport = computeStakeReport(outRows, achieved, betCost);
475
+
476
+ if (sourceMetrics.maxPayout < maxReachedFraction * params.capMaxWin && requireMaxReached) {
477
+ warnings.push(
478
+ `no row reaches ${maxReachedFraction * 100}% of capMaxWin; requireMaxReached cannot be honored`,
479
+ );
480
+ }
481
+
482
+ // Warn about intermediate gaps in the hit-rate distribution (Stake's
483
+ // "Gaps in the Hit Rate Table" check). Empty ranges above the highest
484
+ // non-empty range are natural and not flagged.
485
+ const gaps = detectHitRateGaps(stakeReport.hitRateDistribution);
486
+ if (gaps.length > 0) {
487
+ const formatted = gaps.map((g) => `[${g.low}, ${g.high})`).join(', ');
488
+ warnings.push(
489
+ `hit-rate distribution has ${gaps.length} intermediate gap(s) — Stake "Gaps in the Hit Rate Table" check may fail: ${formatted}`,
490
+ );
491
+ }
492
+
493
+ return {
494
+ rows: outRows,
495
+ achieved,
496
+ toleranceMet,
497
+ maxRowRtpShare: maxRowShare,
498
+ maxWeightRatio,
499
+ refinement: { rtpSwaps, cvSwaps, gapFillSwaps, gapsUnfillable, diversifySwaps },
500
+ warnings,
501
+ stakeReport,
502
+ };
503
+ }
504
+
505
+ /**
506
+ * RTP-aware non-zero sample: pick `k` rows from `srcNonZero` such that their
507
+ * MEAN payout is approximately `targetMeanPayout`, while preserving shape
508
+ * within each side of the split via stratified sampling.
509
+ *
510
+ * Strategy — two-side analytical LP:
511
+ * Split source into "low" (payout < targetMeanPayout) and "high" (>=).
512
+ * Compute μ_low, μ_high.
513
+ * Solve: n_high × μ_high + (k − n_high) × μ_low = k × targetMeanPayout
514
+ * → n_high = k × (targetMeanPayout − μ_low) / (μ_high − μ_low)
515
+ * Clamp to [0, |high|] and [0, |low|], then stratified-sample within each.
516
+ *
517
+ * If clamping prevents reaching the target mean, returns clamped=true.
518
+ */
519
+ function rtpAwareSampleNonZero(
520
+ srcNonZero: ReadonlyArray<LookupRow>,
521
+ k: number,
522
+ targetMeanPayout: number,
523
+ bucketCount: number,
524
+ seed: number,
525
+ ): { sampled: LookupRow[]; achievedMean: number; clamped: boolean } {
526
+ if (k === 0) return { sampled: [], achievedMean: 0, clamped: false };
527
+ if (k >= srcNonZero.length) {
528
+ let sum = 0;
529
+ for (const r of srcNonZero) sum += r.payoutCents;
530
+ const mean = srcNonZero.length > 0 ? sum / srcNonZero.length : 0;
531
+ return { sampled: [...srcNonZero], achievedMean: mean, clamped: true };
532
+ }
533
+
534
+ // Compute source mean for the early-exit "close enough" check.
535
+ let srcSum = 0;
536
+ for (const r of srcNonZero) srcSum += r.payoutCents;
537
+ const sourceMean = srcSum / srcNonZero.length;
538
+
539
+ // If target is within 1% of source mean, plain stratified sample is fine
540
+ // (no bias needed).
541
+ if (sourceMean > 0 && Math.abs(targetMeanPayout - sourceMean) / sourceMean < 0.01) {
542
+ const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
543
+ let s = 0;
544
+ for (const r of sampled) s += r.payoutCents;
545
+ const mean = sampled.length > 0 ? s / sampled.length : 0;
546
+ return { sampled, achievedMean: mean, clamped: false };
547
+ }
548
+
549
+ // Split into low (payout < targetMean) and high (payout >= targetMean).
550
+ const low: LookupRow[] = [];
551
+ const high: LookupRow[] = [];
552
+ for (const r of srcNonZero) {
553
+ if (r.payoutCents < targetMeanPayout) low.push(r);
554
+ else high.push(r);
555
+ }
556
+ if (low.length === 0 || high.length === 0) {
557
+ // Target outside source range: can't reach it. Sample uniformly + clamp.
558
+ const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
559
+ let s = 0;
560
+ for (const r of sampled) s += r.payoutCents;
561
+ const mean = sampled.length > 0 ? s / sampled.length : 0;
562
+ return { sampled, achievedMean: mean, clamped: true };
563
+ }
564
+
565
+ let lowSum = 0;
566
+ for (const r of low) lowSum += r.payoutCents;
567
+ let highSum = 0;
568
+ for (const r of high) highSum += r.payoutCents;
569
+ const muLow = lowSum / low.length;
570
+ const muHigh = highSum / high.length;
571
+
572
+ // Avoid division by zero if both groups collapse to same mean.
573
+ if (muHigh - muLow < 1e-9) {
574
+ const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
575
+ let s = 0;
576
+ for (const r of sampled) s += r.payoutCents;
577
+ const mean = sampled.length > 0 ? s / sampled.length : 0;
578
+ return { sampled, achievedMean: mean, clamped: true };
579
+ }
580
+
581
+ let nHighOut = Math.round((k * (targetMeanPayout - muLow)) / (muHigh - muLow));
582
+ let clamped = false;
583
+ if (nHighOut < 0) {
584
+ nHighOut = 0;
585
+ clamped = true;
586
+ }
587
+ if (nHighOut > high.length) {
588
+ nHighOut = high.length;
589
+ clamped = true;
590
+ }
591
+ if (nHighOut > k) {
592
+ nHighOut = k;
593
+ clamped = true;
594
+ }
595
+ let nLowOut = k - nHighOut;
596
+ if (nLowOut > low.length) {
597
+ // Shouldn't happen given nHighOut bounds + (low+high=src) and k < src.length,
598
+ // but redirect overflow to high if it does.
599
+ const overflow = nLowOut - low.length;
600
+ nLowOut = low.length;
601
+ nHighOut = Math.min(nHighOut + overflow, high.length);
602
+ clamped = true;
603
+ }
604
+ if (nLowOut < 0) {
605
+ nLowOut = 0;
606
+ clamped = true;
607
+ }
608
+
609
+ const subBuckets = Math.max(2, Math.floor(bucketCount / 2));
610
+ const sampleLow =
611
+ nLowOut >= low.length
612
+ ? [...low]
613
+ : nLowOut > 0
614
+ ? stratifiedSmallSampleNonZero(low, nLowOut, subBuckets, seed)
615
+ : [];
616
+ const sampleHigh =
617
+ nHighOut >= high.length
618
+ ? [...high]
619
+ : nHighOut > 0
620
+ ? stratifiedSmallSampleNonZero(high, nHighOut, subBuckets, seed + 17)
621
+ : [];
622
+
623
+ const sampled = [...sampleLow, ...sampleHigh];
624
+ let sumOut = 0;
625
+ for (const r of sampled) sumOut += r.payoutCents;
626
+ const achievedMean = sampled.length > 0 ? sumOut / sampled.length : 0;
627
+ // If we hit a hard side cap (consumed entire low or entire high group), flag.
628
+ if (nHighOut === high.length || nLowOut === low.length) clamped = true;
629
+ return { sampled, achievedMean, clamped };
630
+ }
631
+
632
+ /**
633
+ * Iterative row-level swap refinement to close residual RTP gap.
634
+ *
635
+ * The analytical low/high partition in `rtpAwareSampleNonZero` lands within a
636
+ * few rows of the optimum but `Math.round(nHighOut)` and `Math.round(W)` leak
637
+ * ~1% of RTP. This function exchanges single rows in/out of the sample to
638
+ * close the residual Σ-payout gap to the target, without touching the
639
+ * row count or weight distribution.
640
+ *
641
+ * Each swap replaces ONE sample row with ONE outside row, so |sampled|
642
+ * stays exactly k. Converges in O(K) swaps where K is the initial gap
643
+ * measured in row-payout units.
644
+ */
645
+ function refineRtpBySwap(
646
+ sampled: ReadonlyArray<LookupRow>,
647
+ pool: ReadonlyArray<LookupRow>,
648
+ targetSumPayout: number,
649
+ tolerance: number,
650
+ maxSwaps: number,
651
+ ): { rows: LookupRow[]; achievedSum: number; swaps: number; converged: boolean } {
652
+ const inSet = new Set<number>();
653
+ for (const r of sampled) inSet.add(r.sim);
654
+
655
+ let achievedSum = 0;
656
+ for (const r of sampled) achievedSum += r.payoutCents;
657
+
658
+ const sampledArr = sampled.slice();
659
+ const outsideArr: LookupRow[] = [];
660
+ for (const r of pool) {
661
+ if (!inSet.has(r.sim)) outsideArr.push(r);
662
+ }
663
+ sampledArr.sort((a, b) => a.payoutCents - b.payoutCents); // ascending
664
+ outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
665
+
666
+ // Binary-search-by-payout helpers on a sorted array.
667
+ const lowerBound = (arr: ReadonlyArray<LookupRow>, target: number): number => {
668
+ let lo = 0;
669
+ let hi = arr.length;
670
+ while (lo < hi) {
671
+ const mid = (lo + hi) >>> 1;
672
+ if (arr[mid].payoutCents < target) lo = mid + 1;
673
+ else hi = mid;
674
+ }
675
+ return lo;
676
+ };
677
+
678
+ let swaps = 0;
679
+ let converged = false;
680
+
681
+ while (swaps < maxSwaps) {
682
+ const delta = targetSumPayout - achievedSum;
683
+ if (Math.abs(delta) <= tolerance) {
684
+ converged = true;
685
+ break;
686
+ }
687
+
688
+ if (delta > 0) {
689
+ // Raise Σ: swap lowest sample OUT for highest outside row whose
690
+ // payout is ≤ (sampleLow + delta), but > sampleLow.
691
+ if (sampledArr.length === 0 || outsideArr.length === 0) break;
692
+ const sampleLow = sampledArr[0];
693
+ const desired = sampleLow.payoutCents + delta;
694
+
695
+ // Largest outside index with payout ≤ desired AND > sampleLow.payoutCents.
696
+ // Use lowerBound for desired+1 (first > desired) - 1 → last ≤ desired.
697
+ let bestIdx = lowerBound(outsideArr, desired + 1) - 1;
698
+ // Constraint: must be strictly greater than sampleLow to improve Σ.
699
+ if (bestIdx < 0 || outsideArr[bestIdx].payoutCents <= sampleLow.payoutCents) {
700
+ // No outside row in (sampleLow, sampleLow+delta]. Try the largest
701
+ // available outside row > sampleLow (would overshoot but reduce |delta|
702
+ // only if 2 * outsideRow - 2 * sampleLow ≤ delta is false → would
703
+ // overshoot more than current undershoot; skip).
704
+ // We strictly require non-overshooting swap → stop.
705
+ break;
706
+ }
707
+ const outsideRow = outsideArr[bestIdx];
708
+ const newSum = achievedSum + outsideRow.payoutCents - sampleLow.payoutCents;
709
+
710
+ // Apply swap: remove sampleLow (front), insert outsideRow sorted into sampledArr.
711
+ sampledArr.shift();
712
+ const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
713
+ sampledArr.splice(insertPos, 0, outsideRow);
714
+ // Remove outsideRow from outsideArr, insert sampleLow sorted.
715
+ outsideArr.splice(bestIdx, 1);
716
+ const outPos = lowerBound(outsideArr, sampleLow.payoutCents);
717
+ outsideArr.splice(outPos, 0, sampleLow);
718
+
719
+ inSet.delete(sampleLow.sim);
720
+ inSet.add(outsideRow.sim);
721
+ achievedSum = newSum;
722
+ } else {
723
+ // Lower Σ: swap highest sample OUT for lowest outside row whose
724
+ // payout is ≥ (sampleHigh - |delta|), but < sampleHigh.
725
+ if (sampledArr.length === 0 || outsideArr.length === 0) break;
726
+ const sampleHigh = sampledArr[sampledArr.length - 1];
727
+ const needLoss = -delta;
728
+ const desired = sampleHigh.payoutCents - needLoss;
729
+
730
+ // Smallest outside index with payout ≥ desired AND < sampleHigh.payoutCents.
731
+ let bestIdx = lowerBound(outsideArr, desired);
732
+ if (bestIdx >= outsideArr.length || outsideArr[bestIdx].payoutCents >= sampleHigh.payoutCents) {
733
+ break;
734
+ }
735
+ const outsideRow = outsideArr[bestIdx];
736
+ const newSum = achievedSum + outsideRow.payoutCents - sampleHigh.payoutCents;
737
+
738
+ sampledArr.pop();
739
+ const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
740
+ sampledArr.splice(insertPos, 0, outsideRow);
741
+ outsideArr.splice(bestIdx, 1);
742
+ const outPos = lowerBound(outsideArr, sampleHigh.payoutCents);
743
+ outsideArr.splice(outPos, 0, sampleHigh);
744
+
745
+ inSet.delete(sampleHigh.sim);
746
+ inSet.add(outsideRow.sim);
747
+ achievedSum = newSum;
748
+ }
749
+ swaps++;
750
+ }
751
+
752
+ return { rows: sampledArr, achievedSum, swaps, converged };
753
+ }
754
+
755
+ /**
756
+ * Σ-preserving 2-swap refinement to nudge CV toward target without
757
+ * disturbing Σ payout (RTP).
758
+ *
759
+ * A "2-swap" exchanges two rows (a, b) currently IN the sample for two rows
760
+ * (c, d) currently OUT, such that a + b ≈ c + d (within sumTolerance) and
761
+ * a² + b² ≠ c² + d². RTP is preserved; only the second moment shifts.
762
+ *
763
+ * To INCREASE variance: swap moderate (mid, mid) → spread (low, high).
764
+ * To DECREASE variance: swap spread (low, high) → moderate (mid, mid).
765
+ *
766
+ * Each iteration picks the best-improving swap from a small set of candidates
767
+ * at the extremes / median of the current sorted sample and outside pool.
768
+ */
769
+ function refineCvBySwap(
770
+ sample: ReadonlyArray<LookupRow>,
771
+ pool: ReadonlyArray<LookupRow>,
772
+ targetSumPayout2: number,
773
+ sumTolerance: number,
774
+ sum2Tolerance: number,
775
+ maxSwaps: number,
776
+ ): { rows: LookupRow[]; achievedSum: number; achievedSum2: number; swaps: number } {
777
+ const inSet = new Set<number>();
778
+ for (const r of sample) inSet.add(r.sim);
779
+
780
+ let sumP = 0;
781
+ let sumP2 = 0;
782
+ for (const r of sample) {
783
+ sumP += r.payoutCents;
784
+ sumP2 += r.payoutCents * r.payoutCents;
785
+ }
786
+ const initialSumP = sumP;
787
+
788
+ const sampleArr = sample.slice().sort((a, b) => a.payoutCents - b.payoutCents);
789
+ const outsideArr: LookupRow[] = [];
790
+ for (const r of pool) {
791
+ if (!inSet.has(r.sim)) outsideArr.push(r);
792
+ }
793
+ outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
794
+
795
+ let swaps = 0;
796
+ while (swaps < maxSwaps) {
797
+ const deltaSum2 = targetSumPayout2 - sumP2;
798
+ if (Math.abs(deltaSum2) <= sum2Tolerance) break;
799
+
800
+ let bestSwap: {
801
+ sampleA: LookupRow;
802
+ sampleB: LookupRow;
803
+ sampleIdxA: number;
804
+ sampleIdxB: number;
805
+ outsideC: LookupRow;
806
+ outsideD: LookupRow;
807
+ outsideIdxC: number;
808
+ outsideIdxD: number;
809
+ newSum: number;
810
+ newSum2: number;
811
+ gain: number;
812
+ efficiency: number;
813
+ } | null = null;
814
+
815
+ // Strategy: for each sample pair (a, b) with a < b, find an outside pair
816
+ // (c, d) such that c + d ≈ a + b (RTP-preserving) but |c − (a+b)/2| ≠
817
+ // |a − (a+b)/2|, i.e., the outside pair has different spread than the
818
+ // sample pair. To INCREASE Σ p²: find outside pair with LARGER spread
819
+ // (one row below `a`, the other above `b`). To DECREASE Σ p²: find
820
+ // outside pair with SMALLER spread (both rows between `a` and `b`).
821
+ //
822
+ // Among heavy-tailed data the only pairs with non-trivial Σ² impact
823
+ // anchor on a high-payout row. So we iterate sample's "high" half (anchor
824
+ // = b, large index) and pair it with each anchor sample row a (a < b).
825
+ // For increase: find outside c < a with c + d ≈ a + b, where d = a+b−c
826
+ // and d must exist in outside near payout a+b−c, with d > b. For decrease:
827
+ // find outside c > a, c < b such that d = a+b−c is also in outside with
828
+ // a < d < b.
829
+ if (sampleArr.length < 2 || outsideArr.length < 2) break;
830
+
831
+ const sLen = sampleArr.length;
832
+ const outLen = outsideArr.length;
833
+
834
+ // Anchor count: how many sample pairs to probe per iteration. Larger →
835
+ // better swap selection but slower. K_HI focuses on the high-payout end
836
+ // (where Σ² is dominated); K_LO on the low end.
837
+ const K_HI = 8;
838
+ const K_LO = 8;
839
+
840
+ // For each candidate sample pair (aRow, bRow), choose outside `c` then
841
+ // derive targetD = (a + b) − c. Binary-search outside for d-rows near
842
+ // targetD. To INCREASE Σ²: pick c far from (a+b)/2 (more spread) — try
843
+ // very small or very large outside indices. To DECREASE Σ²: pick c near
844
+ // (a+b)/2 (less spread).
845
+ //
846
+ // We probe K_HI sample pairs anchored on high-payout sample rows (where
847
+ // Σ² is dominated) plus a smattering of mid-range pairs.
848
+ const cProbes = 32;
849
+ const sampleAnchorPairs: [number, number][] = [];
850
+ for (let hi = sLen - 1; hi >= Math.max(0, sLen - K_HI); hi--) {
851
+ for (let lo = 0; lo < Math.min(K_LO, hi); lo++) {
852
+ sampleAnchorPairs.push([lo, hi]);
853
+ }
854
+ }
855
+
856
+ for (const [lo, hi] of sampleAnchorPairs) {
857
+ const aRow = sampleArr[lo];
858
+ const bRow = sampleArr[hi];
859
+ if (aRow.payoutCents === bRow.payoutCents) continue;
860
+ const oldSum = aRow.payoutCents + bRow.payoutCents;
861
+ const oldSum2 =
862
+ aRow.payoutCents * aRow.payoutCents + bRow.payoutCents * bRow.payoutCents;
863
+
864
+ // Pick c candidates. For INCREASE: c far from oldSum/2 (extremes of
865
+ // outside). For DECREASE: c near oldSum/2.
866
+ const cIdxs: number[] = [];
867
+ if (deltaSum2 > 0) {
868
+ // Take extremes: smallest few and largest few outside rows.
869
+ const half = Math.ceil(cProbes / 2);
870
+ for (let s = 0; s < Math.min(half, outLen); s++) cIdxs.push(s);
871
+ for (let s = 0; s < Math.min(half, outLen); s++) {
872
+ const idx = outLen - 1 - s;
873
+ if (idx >= 0) cIdxs.push(idx);
874
+ }
875
+ } else {
876
+ // Center of outside near oldSum/2.
877
+ const target = oldSum / 2;
878
+ const center = lowerBoundIdx(outsideArr, target);
879
+ const half = Math.ceil(cProbes / 2);
880
+ for (let off = -half; off <= half; off++) {
881
+ const idx = center + off;
882
+ if (idx >= 0 && idx < outLen) cIdxs.push(idx);
883
+ }
884
+ }
885
+
886
+ // Tighten per-swap Σ drift: each candidate's newSum must stay within
887
+ // sumTolerance of initialSumP (cumulative cap), not oldSum (local cap).
888
+ const lowerOk = initialSumP - sumTolerance;
889
+ const upperOk = initialSumP + sumTolerance;
890
+
891
+ for (const ci of cIdxs) {
892
+ const cRow = outsideArr[ci];
893
+ const targetD = oldSum - cRow.payoutCents;
894
+ if (targetD <= 0) continue;
895
+ // Per-swap delta limited by remaining cumulative budget so total Σ
896
+ // stays within sumTolerance of initialSumP.
897
+ const remainingBudget = Math.max(0, sumTolerance - Math.abs(sumP - initialSumP));
898
+ const perSwapTol = Math.min(sumTolerance, remainingBudget + sumTolerance * 0.1);
899
+ const dIdxLB = lowerBoundIdx(outsideArr, targetD - perSwapTol);
900
+ const dIdxUB = lowerBoundIdx(outsideArr, targetD + perSwapTol + 1);
901
+ for (let di = dIdxLB; di < dIdxUB && di < outLen; di++) {
902
+ if (di === ci) continue;
903
+ const dRow = outsideArr[di];
904
+ const newSumPair = cRow.payoutCents + dRow.payoutCents;
905
+ const candNewSumP = sumP - oldSum + newSumPair;
906
+ // Cumulative drift constraint.
907
+ if (candNewSumP < lowerOk || candNewSumP > upperOk) continue;
908
+ const newSum2Pair =
909
+ cRow.payoutCents * cRow.payoutCents + dRow.payoutCents * dRow.payoutCents;
910
+ // Skip identity swap.
911
+ if (
912
+ (cRow.sim === aRow.sim && dRow.sim === bRow.sim) ||
913
+ (cRow.sim === bRow.sim && dRow.sim === aRow.sim)
914
+ )
915
+ continue;
916
+ const candNewSum2 = sumP2 - oldSum2 + newSum2Pair;
917
+ const gain = Math.abs(deltaSum2) - Math.abs(targetSumPayout2 - candNewSum2);
918
+ // Penalize swaps with non-zero Σ drift: efficiency = gain per unit
919
+ // of |Σ delta| consumed (with small ε to avoid div-by-zero).
920
+ const sumDelta = Math.abs(newSumPair - oldSum);
921
+ const efficiency = gain / (1 + sumDelta);
922
+ if (gain > 0 && (!bestSwap || efficiency > bestSwap.efficiency)) {
923
+ bestSwap = {
924
+ sampleA: aRow,
925
+ sampleB: bRow,
926
+ sampleIdxA: lo,
927
+ sampleIdxB: hi,
928
+ outsideC: cRow,
929
+ outsideD: dRow,
930
+ outsideIdxC: ci,
931
+ outsideIdxD: di,
932
+ newSum: candNewSumP,
933
+ newSum2: candNewSum2,
934
+ gain,
935
+ efficiency,
936
+ };
937
+ }
938
+ }
939
+ }
940
+ }
941
+
942
+ if (!bestSwap) break;
943
+
944
+ // Apply swap. Remove indices in descending order so earlier indices stay valid.
945
+ const sampleRemove = [bestSwap.sampleIdxA, bestSwap.sampleIdxB].sort((x, y) => y - x);
946
+ sampleArr.splice(sampleRemove[0], 1);
947
+ sampleArr.splice(sampleRemove[1], 1);
948
+ insertSorted(sampleArr, bestSwap.outsideC);
949
+ insertSorted(sampleArr, bestSwap.outsideD);
950
+
951
+ const outsideRemove = [bestSwap.outsideIdxC, bestSwap.outsideIdxD].sort((x, y) => y - x);
952
+ outsideArr.splice(outsideRemove[0], 1);
953
+ outsideArr.splice(outsideRemove[1], 1);
954
+ insertSorted(outsideArr, bestSwap.sampleA);
955
+ insertSorted(outsideArr, bestSwap.sampleB);
956
+
957
+ inSet.delete(bestSwap.sampleA.sim);
958
+ inSet.delete(bestSwap.sampleB.sim);
959
+ inSet.add(bestSwap.outsideC.sim);
960
+ inSet.add(bestSwap.outsideD.sim);
961
+
962
+ sumP = bestSwap.newSum;
963
+ sumP2 = bestSwap.newSum2;
964
+ swaps++;
965
+ }
966
+
967
+ return { rows: sampleArr, achievedSum: sumP, achievedSum2: sumP2, swaps };
968
+ }
969
+
970
+ function insertSorted(arr: LookupRow[], row: LookupRow): void {
971
+ const lo = lowerBoundIdx(arr, row.payoutCents);
972
+ arr.splice(lo, 0, row);
973
+ }
974
+
975
+ /** First index `i` with `arr[i].payoutCents >= target`. */
976
+ function lowerBoundIdx(arr: ReadonlyArray<LookupRow>, target: number): number {
977
+ let lo = 0;
978
+ let hi = arr.length;
979
+ while (lo < hi) {
980
+ const mid = (lo + hi) >>> 1;
981
+ if (arr[mid].payoutCents < target) lo = mid + 1;
982
+ else hi = mid;
983
+ }
984
+ return lo;
985
+ }
986
+
987
+ /**
988
+ * Stratified sample of `k` rows from non-zero `rows`, partitioning by
989
+ * log(payout). Each bucket contributes a slot count proportional to its size
990
+ * in the source, so the sample preserves the source's per-bucket population
991
+ * and (in expectation) its mean payout — critical for RTP fidelity.
992
+ *
993
+ * A simple uniform reservoir over a long-tailed distribution can over-pick
994
+ * tail rows by chance; with weight=W in the output, that drift gets amplified
995
+ * (here observed as +7.6% RTP on real ANTE data). Stratification eliminates
996
+ * that drift.
997
+ *
998
+ * Assumes all input rows have payoutCents > 0; the zero-payout rows are
999
+ * handled separately by `uniformReservoirSample` so the caller can bias the
1000
+ * zero/non-zero ratio per `targetHitRate`.
1001
+ */
1002
+ function stratifiedSmallSampleNonZero(
1003
+ rows: ReadonlyArray<LookupRow>,
1004
+ k: number,
1005
+ bucketCount: number,
1006
+ seed: number,
1007
+ ): LookupRow[] {
1008
+ if (k >= rows.length) return [...rows];
1009
+ if (k <= 0) return [];
1010
+
1011
+ // Find min/max payout for log bucketing.
1012
+ let minPayout = Infinity;
1013
+ let maxPayout = 0;
1014
+ for (const r of rows) {
1015
+ if (r.payoutCents > 0 && r.payoutCents < minPayout) minPayout = r.payoutCents;
1016
+ if (r.payoutCents > maxPayout) maxPayout = r.payoutCents;
1017
+ }
1018
+ const usable = isFinite(minPayout) && maxPayout > 0;
1019
+
1020
+ type Bucket = { indices: number[] };
1021
+ const logBuckets: Bucket[] = Array.from({ length: bucketCount }, () => ({ indices: [] }));
1022
+
1023
+ const logMin = usable ? Math.log(minPayout) : 0;
1024
+ const logMax = usable ? Math.log(maxPayout) : 1;
1025
+ const logSpan = Math.max(logMax - logMin, 1e-9);
1026
+
1027
+ for (let i = 0; i < rows.length; i++) {
1028
+ const r = rows[i];
1029
+ if (r.payoutCents <= 0) continue; // defensive — caller passes non-zero only
1030
+ let bidx = 0;
1031
+ if (usable && logSpan > 0) {
1032
+ const t = (Math.log(r.payoutCents) - logMin) / logSpan;
1033
+ bidx = Math.min(bucketCount - 1, Math.max(0, Math.floor(t * bucketCount)));
1034
+ }
1035
+ logBuckets[bidx].indices.push(i);
1036
+ }
1037
+
1038
+ // Allocate slots per bucket proportional to bucket size (largest-remainder).
1039
+ const sizes = logBuckets.map((b) => b.indices.length);
1040
+ const total = sizes.reduce((s, v) => s + v, 0);
1041
+ if (total === 0) return [];
1042
+ const proposed = sizes.map((s) => (s / total) * k);
1043
+ const floors = proposed.map(Math.floor);
1044
+ const used = floors.reduce((s, v) => s + v, 0);
1045
+ const remainders = proposed.map((p, i) => p - floors[i]);
1046
+ const order = remainders.map((_, i) => i).sort((a, b) => remainders[b] - remainders[a]);
1047
+ let extra = k - used;
1048
+ for (const i of order) {
1049
+ if (extra === 0) break;
1050
+ if (floors[i] < sizes[i]) {
1051
+ floors[i]++;
1052
+ extra--;
1053
+ }
1054
+ }
1055
+ for (let i = 0; i < floors.length; i++) {
1056
+ if (floors[i] > sizes[i]) floors[i] = sizes[i];
1057
+ }
1058
+
1059
+ const rng = mulberry32(seed);
1060
+ const out: LookupRow[] = [];
1061
+ for (let bi = 0; bi < logBuckets.length; bi++) {
1062
+ const slots = floors[bi];
1063
+ if (slots <= 0) continue;
1064
+ const indices = logBuckets[bi].indices;
1065
+ const weights = new Array(indices.length).fill(1);
1066
+ const sampled = weightedReservoirSample(indices, weights, slots, rng);
1067
+ for (const idx of sampled) out.push(rows[idx]);
1068
+ }
1069
+
1070
+ return out;
1071
+ }
1072
+
1073
+ /**
1074
+ * Uniform reservoir sample of `k` rows from `rows`. Used for the zero-payout
1075
+ * sub-bucket where stratification by payout is meaningless (single value).
1076
+ */
1077
+ function uniformReservoirSample(
1078
+ rows: ReadonlyArray<LookupRow>,
1079
+ k: number,
1080
+ seed: number,
1081
+ ): LookupRow[] {
1082
+ if (k >= rows.length) return [...rows];
1083
+ if (k <= 0) return [];
1084
+ const rng = mulberry32(seed);
1085
+ const indices = rows.map((_, i) => i);
1086
+ const weights = new Array(indices.length).fill(1);
1087
+ const sampled = weightedReservoirSample(indices, weights, k, rng);
1088
+ return sampled.map((idx) => rows[idx]);
1089
+ }
1090
+
1091
+ /**
1092
+ * Find the index of the Stake hit-rate range that `payoutCents` falls into.
1093
+ * Returns -1 if no range matches (shouldn't happen given the [0, 0.1] +
1094
+ * [20000, ∞) coverage, but defensive).
1095
+ */
1096
+ function findRange(payoutCents: number, betCostCents: number): number {
1097
+ const pm = payoutCents / betCostCents;
1098
+ for (let i = 0; i < HIT_RATE_RANGES.length; i++) {
1099
+ const [low, high] = HIT_RATE_RANGES[i];
1100
+ if (pm >= low && pm < high) return i;
1101
+ }
1102
+ return -1;
1103
+ }
1104
+
1105
+ /**
1106
+ * Fourth refinement pass: ensure no intermediate gaps in the Stake hit-rate
1107
+ * distribution table. Stake rejects publishing tables with empty ranges
1108
+ * sandwiched between non-empty ones ("Gaps in the Hit Rate Table" check).
1109
+ *
1110
+ * Algorithm: for each range below maxPayout that's empty in output, find a
1111
+ * source row in that range and swap it in by replacing an output row whose
1112
+ * payout is closest (minimizes Σ payout drift). Skips ranges where source
1113
+ * has no rows (impossible to fill — emit a one-time warning).
1114
+ *
1115
+ * Modifies `outSmallNonZero` in place (preserves sorted-by-payout-ascending
1116
+ * invariant). Returns number of swaps applied plus the number of ranges that
1117
+ * source couldn't fill.
1118
+ *
1119
+ * Performance: O(R × (N + |source|)) where R = 16 ranges; the rangeCount/
1120
+ * rangeIdx maps avoid the naive O(N²) inner range-count.
1121
+ */
1122
+ function fillStakeRangeGaps(
1123
+ outSmallNonZero: LookupRow[],
1124
+ srcSmallNonZero: ReadonlyArray<LookupRow>,
1125
+ otherOutRows: ReadonlyArray<LookupRow>,
1126
+ maxPayoutCents: number,
1127
+ betCostCents: number,
1128
+ warnings: string[],
1129
+ ): { swapsApplied: number; unfillable: number } {
1130
+ let swapsApplied = 0;
1131
+ let unfillable = 0;
1132
+
1133
+ // Build set of in-sample sim ids for fast membership tests.
1134
+ const inSample = new Set<number>();
1135
+ for (const r of outSmallNonZero) inSample.add(r.sim);
1136
+
1137
+ // Pre-compute per-row range index for the swappable tier (small non-zero).
1138
+ const rangeIdx: number[] = outSmallNonZero.map((r) =>
1139
+ findRange(r.payoutCents, betCostCents),
1140
+ );
1141
+ // Range counts over the FULL output (small + cap/large): a range filled by
1142
+ // cap/large rows is not a gap, even if small-tier alone has 0 in it.
1143
+ const rangeCount = new Map<number, number>();
1144
+ for (const idx of rangeIdx) rangeCount.set(idx, (rangeCount.get(idx) ?? 0) + 1);
1145
+ for (const r of otherOutRows) {
1146
+ const idx = findRange(r.payoutCents, betCostCents);
1147
+ rangeCount.set(idx, (rangeCount.get(idx) ?? 0) + 1);
1148
+ }
1149
+
1150
+ // Only consider Stake ranges whose lower bound is below maxPayout (in bet units).
1151
+ const maxPm = maxPayoutCents / betCostCents;
1152
+
1153
+ for (let rangeI = 0; rangeI < HIT_RATE_RANGES.length; rangeI++) {
1154
+ const [low, high] = HIT_RATE_RANGES[rangeI];
1155
+ if (low >= maxPm) break; // tail ranges above maxPayout — natural empty
1156
+ const lowCents = low * betCostCents;
1157
+ const highCents = high === Infinity ? Infinity : high * betCostCents;
1158
+
1159
+ // Skip the [0, 0.1) range — that's the zero-tier territory (payouts < 0.1
1160
+ // bet units, i.e. 0 cents at betCost=100). Zero-payouts are handled by the
1161
+ // zero sub-bucket; we don't fill via non-zero rows here.
1162
+ if (low === 0) continue;
1163
+
1164
+ // Skip if already populated.
1165
+ if ((rangeCount.get(rangeI) ?? 0) >= 1) continue;
1166
+
1167
+ // Find source rows in this range that aren't already in sample.
1168
+ const sourceCandidates: LookupRow[] = [];
1169
+ for (const r of srcSmallNonZero) {
1170
+ if (r.payoutCents >= lowCents && r.payoutCents < highCents && !inSample.has(r.sim)) {
1171
+ sourceCandidates.push(r);
1172
+ }
1173
+ }
1174
+ if (sourceCandidates.length === 0) {
1175
+ unfillable++;
1176
+ const rangeStr =
1177
+ high === Infinity ? `[${low}, infinity)` : `[${low}, ${high})`;
1178
+ warnings.push(
1179
+ `gap in hit-rate range ${rangeStr}x bet: source has no rows in this payout-multiplier range`,
1180
+ );
1181
+ continue;
1182
+ }
1183
+
1184
+ // Pick source row closest to range geometric mid so any subsequent
1185
+ // statistic sliding remains balanced.
1186
+ const midPayout =
1187
+ high === Infinity
1188
+ ? Math.max(lowCents, maxPayoutCents)
1189
+ : Math.sqrt(lowCents * highCents);
1190
+ let swapInRow = sourceCandidates[0];
1191
+ let bestDist = Math.abs(swapInRow.payoutCents - midPayout);
1192
+ for (const r of sourceCandidates) {
1193
+ const d = Math.abs(r.payoutCents - midPayout);
1194
+ if (d < bestDist) {
1195
+ swapInRow = r;
1196
+ bestDist = d;
1197
+ }
1198
+ }
1199
+
1200
+ // Pick output row to remove: payout closest to swapInRow.payoutCents so
1201
+ // Σ-payout drift (i.e. RTP impact) is minimized. Skip any row whose
1202
+ // removal would empty another range.
1203
+ let removeIdx = -1;
1204
+ let removeDist = Infinity;
1205
+ for (let i = 0; i < outSmallNonZero.length; i++) {
1206
+ if ((rangeCount.get(rangeIdx[i]) ?? 0) <= 1) continue; // protect other ranges
1207
+ const r = outSmallNonZero[i];
1208
+ const d = Math.abs(r.payoutCents - swapInRow.payoutCents);
1209
+ if (d < removeDist) {
1210
+ removeDist = d;
1211
+ removeIdx = i;
1212
+ }
1213
+ }
1214
+ if (removeIdx < 0) {
1215
+ // No safe removal candidate — every range has exactly 1 row. Skip
1216
+ // this gap rather than break other ranges.
1217
+ unfillable++;
1218
+ continue;
1219
+ }
1220
+
1221
+ // Apply the swap.
1222
+ const removedRow = outSmallNonZero[removeIdx];
1223
+ const removedRangeIdx = rangeIdx[removeIdx];
1224
+ inSample.delete(removedRow.sim);
1225
+ inSample.add(swapInRow.sim);
1226
+ // Remove the old row, then re-insert swapInRow at the correct sorted
1227
+ // position to preserve the ascending invariant. Also update rangeIdx
1228
+ // and rangeCount.
1229
+ outSmallNonZero.splice(removeIdx, 1);
1230
+ rangeIdx.splice(removeIdx, 1);
1231
+ rangeCount.set(removedRangeIdx, (rangeCount.get(removedRangeIdx) ?? 1) - 1);
1232
+
1233
+ let insertPos = 0;
1234
+ while (
1235
+ insertPos < outSmallNonZero.length &&
1236
+ outSmallNonZero[insertPos].payoutCents < swapInRow.payoutCents
1237
+ ) {
1238
+ insertPos++;
1239
+ }
1240
+ outSmallNonZero.splice(insertPos, 0, swapInRow);
1241
+ rangeIdx.splice(insertPos, 0, rangeI);
1242
+ rangeCount.set(rangeI, (rangeCount.get(rangeI) ?? 0) + 1);
1243
+
1244
+ swapsApplied++;
1245
+ }
1246
+
1247
+ return { swapsApplied, unfillable };
1248
+ }
1249
+
1250
+ /** First index `i` with `arr[i] >= target` (number-array variant). */
1251
+ function lowerBoundNum(arr: ReadonlyArray<number>, target: number): number {
1252
+ let lo = 0;
1253
+ let hi = arr.length;
1254
+ while (lo < hi) {
1255
+ const mid = (lo + hi) >>> 1;
1256
+ if (arr[mid] < target) lo = mid + 1;
1257
+ else hi = mid;
1258
+ }
1259
+ return lo;
1260
+ }
1261
+
1262
+ /**
1263
+ * 5th refinement pass: swap duplicate-payout rows for source rows with NEW
1264
+ * payout values until output has ≥ targetUnique distinct payoutCents. Source
1265
+ * provides candidate rows whose payoutCents is NOT currently in output.
1266
+ *
1267
+ * Each swap is constrained to keep Σ_smallNz drift ≤ remainingSumBudget. Picks
1268
+ * the swap-in payout closest to swap-out's payout to minimize RTP/CV impact.
1269
+ *
1270
+ * Updates `outSmallNonZero` in place. Returns the number of swaps applied,
1271
+ * achieved unique count across (otherOutRows ∪ outSmallNonZero), and whether
1272
+ * the target was reached.
1273
+ */
1274
+ function diversifyPayouts(
1275
+ outSmallNonZero: LookupRow[],
1276
+ srcSmallNonZero: ReadonlyArray<LookupRow>,
1277
+ otherOutRows: ReadonlyArray<LookupRow>,
1278
+ targetUnique: number,
1279
+ remainingSumBudget: number,
1280
+ warnings: string[],
1281
+ ): { swaps: number; achievedUnique: number; reached: boolean } {
1282
+ // Build the current set of payouts in output AND in-sample sim ids.
1283
+ const inOutputPayouts = new Map<number, number>(); // payoutCents → count
1284
+ const inSampleSims = new Set<number>();
1285
+ for (const r of otherOutRows) {
1286
+ inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
1287
+ }
1288
+ for (const r of outSmallNonZero) {
1289
+ inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
1290
+ inSampleSims.add(r.sim);
1291
+ }
1292
+ let uniqueNow = inOutputPayouts.size;
1293
+ if (uniqueNow >= targetUnique) {
1294
+ return { swaps: 0, achievedUnique: uniqueNow, reached: true };
1295
+ }
1296
+
1297
+ // Index source rows by payoutCents → list of LookupRow (only those NOT in
1298
+ // output payouts and not already used in the sample).
1299
+ const newPayoutsAvailable = new Map<number, LookupRow[]>();
1300
+ for (const r of srcSmallNonZero) {
1301
+ if (inOutputPayouts.has(r.payoutCents)) continue;
1302
+ if (inSampleSims.has(r.sim)) continue;
1303
+ let arr = newPayoutsAvailable.get(r.payoutCents);
1304
+ if (!arr) {
1305
+ arr = [];
1306
+ newPayoutsAvailable.set(r.payoutCents, arr);
1307
+ }
1308
+ arr.push(r);
1309
+ }
1310
+ // Sorted list of new payout values for binary search by magnitude.
1311
+ const newPayoutsSorted = Array.from(newPayoutsAvailable.keys()).sort((a, b) => a - b);
1312
+
1313
+ if (newPayoutsSorted.length === 0) {
1314
+ warnings.push(
1315
+ `minUniqueEventsRate target ${targetUnique} unreachable: source has no distinct payout values not already in output (current ${uniqueNow})`,
1316
+ );
1317
+ return { swaps: 0, achievedUnique: uniqueNow, reached: false };
1318
+ }
1319
+
1320
+ // Find duplicate-payout rows in outSmallNonZero. Any small-non-zero row whose
1321
+ // payout count (across all output tiers) is ≥ 2 is a safe swap-out candidate
1322
+ // — removing one row still leaves the payout represented elsewhere, so the
1323
+ // swap nets +1 unique (modulo whether the swap-in payout is new).
1324
+ const swapOutCandidates: number[] = [];
1325
+ for (let i = 0; i < outSmallNonZero.length; i++) {
1326
+ const p = outSmallNonZero[i].payoutCents;
1327
+ if ((inOutputPayouts.get(p) ?? 0) >= 2) swapOutCandidates.push(i);
1328
+ }
1329
+ if (swapOutCandidates.length === 0) {
1330
+ warnings.push(
1331
+ `minUniqueEventsRate target ${targetUnique} unreachable: every small-non-zero row already has a unique payout (current ${uniqueNow})`,
1332
+ );
1333
+ return { swaps: 0, achievedUnique: uniqueNow, reached: false };
1334
+ }
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
+
1342
+ let swaps = 0;
1343
+ let sumBudget = remainingSumBudget;
1344
+
1345
+ // Indices in `swapOutCandidates` are positional into outSmallNonZero at the
1346
+ // time of sort; subsequent splices shift later indices. We re-validate the
1347
+ // duplicate condition before each swap, so stale indices that no longer
1348
+ // refer to duplicate rows are skipped harmlessly.
1349
+ for (const initialIdx of swapOutCandidates) {
1350
+ if (uniqueNow >= targetUnique) break;
1351
+ // Re-validate: array may have shifted from earlier splices. Walk to find
1352
+ // the current index of this row's payoutCents value matching the sim, but
1353
+ // simpler: just check the current position — if duplicate condition no
1354
+ // longer holds, skip. Note: after splice ops, `initialIdx` could be out of
1355
+ // range or point at a different row. Clamp and verify.
1356
+ if (initialIdx >= outSmallNonZero.length) continue;
1357
+ const swapOutRow = outSmallNonZero[initialIdx];
1358
+ const swapOutP = swapOutRow.payoutCents;
1359
+ if ((inOutputPayouts.get(swapOutP) ?? 0) < 2) continue;
1360
+
1361
+ // Find the new-payout value closest to swapOutP via binary search.
1362
+ if (newPayoutsSorted.length === 0) break;
1363
+ let bestNewP = newPayoutsSorted[0];
1364
+ let bestDist = Math.abs(bestNewP - swapOutP);
1365
+ const ins = lowerBoundNum(newPayoutsSorted, swapOutP);
1366
+ for (const idx of [ins - 1, ins, ins + 1]) {
1367
+ if (idx < 0 || idx >= newPayoutsSorted.length) continue;
1368
+ const np = newPayoutsSorted[idx];
1369
+ const d = Math.abs(np - swapOutP);
1370
+ if (d < bestDist) {
1371
+ bestDist = d;
1372
+ bestNewP = np;
1373
+ }
1374
+ }
1375
+
1376
+ // Check Σ-drift budget.
1377
+ if (bestDist > sumBudget) continue;
1378
+
1379
+ const candidates = newPayoutsAvailable.get(bestNewP);
1380
+ if (!candidates || candidates.length === 0) continue;
1381
+ const swapInRow = candidates[0];
1382
+
1383
+ // Apply swap: replace outSmallNonZero[initialIdx] with swapInRow, keeping
1384
+ // the array sorted by payoutCents.
1385
+ outSmallNonZero.splice(initialIdx, 1);
1386
+ const insertPos = lowerBoundIdx(outSmallNonZero, swapInRow.payoutCents);
1387
+ outSmallNonZero.splice(insertPos, 0, swapInRow);
1388
+
1389
+ // Update tracking.
1390
+ inSampleSims.delete(swapOutRow.sim);
1391
+ inSampleSims.add(swapInRow.sim);
1392
+
1393
+ const oldCount = inOutputPayouts.get(swapOutP) ?? 0;
1394
+ if (oldCount <= 1) {
1395
+ inOutputPayouts.delete(swapOutP);
1396
+ uniqueNow--;
1397
+ } else {
1398
+ inOutputPayouts.set(swapOutP, oldCount - 1);
1399
+ }
1400
+ inOutputPayouts.set(bestNewP, (inOutputPayouts.get(bestNewP) ?? 0) + 1);
1401
+ uniqueNow++;
1402
+
1403
+ // bestNewP is now consumed: remove it from the available pool.
1404
+ newPayoutsAvailable.delete(bestNewP);
1405
+ const removeAt = lowerBoundNum(newPayoutsSorted, bestNewP);
1406
+ if (removeAt < newPayoutsSorted.length && newPayoutsSorted[removeAt] === bestNewP) {
1407
+ newPayoutsSorted.splice(removeAt, 1);
1408
+ }
1409
+
1410
+ sumBudget -= bestDist;
1411
+ swaps++;
1412
+ }
1413
+
1414
+ const reached = uniqueNow >= targetUnique;
1415
+ if (!reached) {
1416
+ if (sumBudget <= 0) {
1417
+ warnings.push(
1418
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): RTP-drift budget exhausted`,
1419
+ );
1420
+ } else {
1421
+ warnings.push(
1422
+ `minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): source-or-allocation limit (${newPayoutsSorted.length} new payouts remained available)`,
1423
+ );
1424
+ }
1425
+ }
1426
+ return { swaps, achievedUnique: uniqueNow, reached };
1427
+ }
1428
+