@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/README.md +223 -56
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/optimize-lookup.ts +324 -6
- package/src/stake-report.ts +145 -0
- package/src/tiered.ts +1428 -0
- package/src/types.ts +128 -0
- package/test/optimize-lookup.integration.test.ts +470 -0
- package/test/optimize-lookup.unit.test.ts +2 -0
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
|
+
|