@energy8platform/stake-math-tools 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -56
- package/package.json +1 -1
- package/src/index.ts +13 -0
- package/src/optimize-lookup.ts +174 -19
- package/src/stake-report.ts +145 -0
- package/src/tiered.ts +1832 -0
- package/src/transform-jsonl-zst.ts +285 -0
- package/src/types.ts +141 -0
- package/test/optimize-lookup.integration.test.ts +423 -0
- package/test/optimize-lookup.unit.test.ts +2 -0
- package/test/transform-jsonl-zst.test.ts +343 -0
package/src/tiered.ts
ADDED
|
@@ -0,0 +1,1832 @@
|
|
|
1
|
+
// src/tiered.ts
|
|
2
|
+
//
|
|
3
|
+
// Tier-based lookup-table compression.
|
|
4
|
+
//
|
|
5
|
+
// Unlike NNLS, this algorithm does NOT optimize toward (RTP, CV, hitRate) targets.
|
|
6
|
+
// It compresses the source distribution into `nRowsOut` rows while PRESERVING the
|
|
7
|
+
// natural rare-event rates. High-payout rows ("cap" / "large" tier) get weight=1
|
|
8
|
+
// (rarest); bulk rows ("small" tier) get weight=W >> 1 calculated so the natural
|
|
9
|
+
// cap probability is preserved.
|
|
10
|
+
//
|
|
11
|
+
// This is the canonical way Stake Engine expects lookup tables to be built: ETL
|
|
12
|
+
// (Expected Tail Liability) stays low because high-payout rows carry minimal
|
|
13
|
+
// weight, and the "Within Liability Limits" check passes by construction.
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
LookupRow,
|
|
17
|
+
OptimizeParams,
|
|
18
|
+
OptimizeResult,
|
|
19
|
+
ToleranceMet,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
import { computeMetrics, isNearMax } from './metrics.js';
|
|
22
|
+
import { mulberry32, weightedReservoirSample } from './sample.js';
|
|
23
|
+
import { computeStakeReport, detectHitRateGaps, HIT_RATE_RANGES } from './stake-report.js';
|
|
24
|
+
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
betCostCents: 100,
|
|
27
|
+
capPmFraction: 0.95, // capPmThreshold = capPmFraction × maxPm
|
|
28
|
+
requireMaxReached: true,
|
|
29
|
+
maxReachedFraction: 0.95,
|
|
30
|
+
seed: 0xc0ffee,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function buildTieredLookup(
|
|
34
|
+
rowsIn: Iterable<LookupRow>,
|
|
35
|
+
params: OptimizeParams,
|
|
36
|
+
): OptimizeResult {
|
|
37
|
+
// shapeAutoMatchCV: pick shapeDecayRatio so achieved CV lands at
|
|
38
|
+
// targetCV within toleranceCV. CV(ratio) is U-shaped — low ratios shrink
|
|
39
|
+
// the high tier so much that total weight T drops and per-row variance
|
|
40
|
+
// climbs back up, so naive bisection can get stuck on the wrong side of
|
|
41
|
+
// the minimum. Use a coarse 5-point grid sweep first, then refine around
|
|
42
|
+
// the closest-to-target probe. Disables itself on the recursive calls
|
|
43
|
+
// via `shapeAutoMatchCV: false` so the inner build takes the fast path.
|
|
44
|
+
if (
|
|
45
|
+
params.shapeAutoMatchCV &&
|
|
46
|
+
params.shapeDistribution &&
|
|
47
|
+
params.targetCV !== undefined &&
|
|
48
|
+
params.targetCV > 0
|
|
49
|
+
) {
|
|
50
|
+
// Cache the rows so we don't re-iterate a one-shot Iterable across
|
|
51
|
+
// multiple inner runs.
|
|
52
|
+
const cachedRows: LookupRow[] = [];
|
|
53
|
+
for (const r of rowsIn) cachedRows.push(r);
|
|
54
|
+
|
|
55
|
+
const targetCV = params.targetCV;
|
|
56
|
+
const tolerance = Math.max(0.01, params.toleranceCV);
|
|
57
|
+
const inner = { ...params, shapeAutoMatchCV: false };
|
|
58
|
+
const trail: Array<{ ratio: number; cv: number; result: OptimizeResult }> = [];
|
|
59
|
+
|
|
60
|
+
const run = (r: number): { cv: number; result: OptimizeResult } => {
|
|
61
|
+
const result = buildTieredLookup(cachedRows, { ...inner, shapeDecayRatio: r });
|
|
62
|
+
const cv = result.achieved.cv;
|
|
63
|
+
trail.push({ ratio: r, cv, result });
|
|
64
|
+
return { cv, result };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Coarse sweep across [0.15, 0.85] — covers a typical operating range
|
|
68
|
+
// without spending an evaluation at the rail extremes (those tend to
|
|
69
|
+
// hit max(1, …) clamping and plateau).
|
|
70
|
+
const coarse = [0.15, 0.3, 0.5, 0.7, 0.85];
|
|
71
|
+
for (const r of coarse) {
|
|
72
|
+
const { cv } = run(r);
|
|
73
|
+
if (Math.abs(cv - targetCV) <= tolerance) {
|
|
74
|
+
// Lucky early exit.
|
|
75
|
+
const final = trail[trail.length - 1].result;
|
|
76
|
+
final.warnings.push(
|
|
77
|
+
`shapeAutoMatchCV: shapeDecayRatio=${r.toFixed(3)} hit CV=${cv.toFixed(2)} ` +
|
|
78
|
+
`vs target ${targetCV} on coarse sweep (${trail.length} runs)`,
|
|
79
|
+
);
|
|
80
|
+
return final;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Refine: bisect between the best probe and its closest CV-neighbour on
|
|
85
|
+
// the side of `targetCV` we need to move toward. If targetCV > best.cv,
|
|
86
|
+
// we want to RAISE CV — find the probe with CV just above target and
|
|
87
|
+
// bisect between best.ratio and that probe's ratio. Symmetric for the
|
|
88
|
+
// other direction. Skip refinement when no useful neighbour exists
|
|
89
|
+
// (best is on the same side of target as every other probe → we're at
|
|
90
|
+
// the structural minimum of the U-curve).
|
|
91
|
+
for (let refine = 0; refine < 2 && trail.length < 8; refine++) {
|
|
92
|
+
trail.sort((a, b) => Math.abs(a.cv - targetCV) - Math.abs(b.cv - targetCV));
|
|
93
|
+
const best = trail[0];
|
|
94
|
+
const needHigherCV = best.cv < targetCV;
|
|
95
|
+
const neighbour = trail
|
|
96
|
+
.slice(1)
|
|
97
|
+
.filter((t) => (needHigherCV ? t.cv > targetCV : t.cv < targetCV))
|
|
98
|
+
.sort((a, b) => Math.abs(a.cv - targetCV) - Math.abs(b.cv - targetCV))[0];
|
|
99
|
+
if (!neighbour || Math.abs(neighbour.ratio - best.ratio) < 0.02) break;
|
|
100
|
+
const mid = (best.ratio + neighbour.ratio) / 2;
|
|
101
|
+
if (trail.some((t) => Math.abs(t.ratio - mid) < 0.01)) break;
|
|
102
|
+
const { cv } = run(mid);
|
|
103
|
+
if (Math.abs(cv - targetCV) <= tolerance) break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Final pick: the smallest-gap probe overall.
|
|
107
|
+
trail.sort((a, b) => Math.abs(a.cv - targetCV) - Math.abs(b.cv - targetCV));
|
|
108
|
+
const winner = trail[0];
|
|
109
|
+
const finalResult = winner.result;
|
|
110
|
+
const gap = Math.abs(winner.cv - targetCV);
|
|
111
|
+
const sortedTrail = trail.slice().sort((a, b) => a.ratio - b.ratio);
|
|
112
|
+
finalResult.warnings.push(
|
|
113
|
+
`shapeAutoMatchCV: chose shapeDecayRatio=${winner.ratio.toFixed(3)} ` +
|
|
114
|
+
`→ CV=${winner.cv.toFixed(2)} vs target ${targetCV} (gap ${gap.toFixed(2)} ` +
|
|
115
|
+
`after ${trail.length} runs; CV(r) sweep: ` +
|
|
116
|
+
`${sortedTrail.map((t) => `${t.ratio.toFixed(2)}→${t.cv.toFixed(2)}`).join(', ')})`,
|
|
117
|
+
);
|
|
118
|
+
return finalResult;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const betCost = params.betCostCents ?? DEFAULTS.betCostCents;
|
|
122
|
+
const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
|
|
123
|
+
const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
|
|
124
|
+
const seed = params.seed ?? DEFAULTS.seed;
|
|
125
|
+
|
|
126
|
+
// Phase 1: filter
|
|
127
|
+
const filtered: LookupRow[] = [];
|
|
128
|
+
for (const r of rowsIn) {
|
|
129
|
+
if (r.payoutCents > params.capMaxWin) continue;
|
|
130
|
+
filtered.push(r);
|
|
131
|
+
}
|
|
132
|
+
if (filtered.length < params.nRowsOut) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`tiered: filtered input has ${filtered.length} rows, fewer than nRowsOut=${params.nRowsOut}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sourceMetrics = computeMetrics(filtered);
|
|
139
|
+
|
|
140
|
+
// Phase 2: thresholds
|
|
141
|
+
const maxPm = sourceMetrics.maxPayout / betCost;
|
|
142
|
+
const capPmThreshold = params.capPmThreshold ?? DEFAULTS.capPmFraction * maxPm;
|
|
143
|
+
const capPayoutCents = Math.floor(capPmThreshold * betCost);
|
|
144
|
+
// When shapeDistribution is on and the caller didn't carve out a `large`
|
|
145
|
+
// tier, auto-set one so the log-decay shape has multiple Stake buckets to
|
|
146
|
+
// span (otherwise it would only see the single cap bucket and the shape
|
|
147
|
+
// would be a no-op).
|
|
148
|
+
const shapeDistribution = params.shapeDistribution ?? false;
|
|
149
|
+
const shapeDecayRatio = params.shapeDecayRatio ?? 0.5;
|
|
150
|
+
let largePmThreshold = params.largePmThreshold;
|
|
151
|
+
if (shapeDistribution && largePmThreshold === undefined) {
|
|
152
|
+
largePmThreshold = Math.max(50, capPmThreshold / 20);
|
|
153
|
+
}
|
|
154
|
+
const largePayoutCents =
|
|
155
|
+
largePmThreshold !== undefined ? Math.floor(largePmThreshold * betCost) : undefined;
|
|
156
|
+
|
|
157
|
+
// Phase 3: classify source
|
|
158
|
+
const srcCap: LookupRow[] = [];
|
|
159
|
+
const srcLarge: LookupRow[] = [];
|
|
160
|
+
const srcSmall: LookupRow[] = [];
|
|
161
|
+
for (const r of filtered) {
|
|
162
|
+
if (r.payoutCents >= capPayoutCents) srcCap.push(r);
|
|
163
|
+
else if (largePayoutCents !== undefined && r.payoutCents >= largePayoutCents) srcLarge.push(r);
|
|
164
|
+
else srcSmall.push(r);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Target rate for cap+large probability mass in OUTPUT.
|
|
168
|
+
const naturalRate = (srcCap.length + srcLarge.length) / filtered.length;
|
|
169
|
+
const target = params.largeTarget ?? naturalRate;
|
|
170
|
+
|
|
171
|
+
// Phase 4: pick output rows.
|
|
172
|
+
//
|
|
173
|
+
// When `largeTarget` is explicitly LOWER than the natural source rate, we
|
|
174
|
+
// MUST subsample cap+large or else Stake's "Max Win Achievability" check
|
|
175
|
+
// fails: keeping all 100K+ source rows at weight=1 forces W (and thus total
|
|
176
|
+
// weight) up by the ratio natural/target, making the single max-win row
|
|
177
|
+
// hide in a pool too large to be reachable at 1 in 20M.
|
|
178
|
+
//
|
|
179
|
+
// Subsampling target: keep approximately `target × nRowsOut` cap+large rows
|
|
180
|
+
// with weight=1, and fill the remaining slots with small-tier rows at
|
|
181
|
+
// weight ≈ 1. Total weight ≈ nRowsOut → P(max-win) = 1/nRowsOut (easily
|
|
182
|
+
// satisfies 1 in 20M for typical nRowsOut ≤ 200K).
|
|
183
|
+
let outCap: LookupRow[] = srcCap.slice();
|
|
184
|
+
let outLarge: LookupRow[] = srcLarge.slice();
|
|
185
|
+
const userTargetActive =
|
|
186
|
+
params.largeTarget !== undefined && params.largeTarget < naturalRate;
|
|
187
|
+
if (shapeDistribution) {
|
|
188
|
+
// Log-decay sample across Stake hit-rate buckets. Spreads cap+large
|
|
189
|
+
// rows so each higher bucket has roughly `ratio × prev` rows — fixes
|
|
190
|
+
// the typical `…18 → 1 → 1 → 1 → 4` cliff/spike at the tail.
|
|
191
|
+
const targetTotalCount = userTargetActive
|
|
192
|
+
? Math.max(1, Math.round(target * params.nRowsOut))
|
|
193
|
+
: undefined;
|
|
194
|
+
({ outCap, outLarge } = bucketDecaySampleHighTier(
|
|
195
|
+
srcCap,
|
|
196
|
+
srcLarge,
|
|
197
|
+
betCost,
|
|
198
|
+
capPayoutCents,
|
|
199
|
+
shapeDecayRatio,
|
|
200
|
+
targetTotalCount,
|
|
201
|
+
seed + 31,
|
|
202
|
+
));
|
|
203
|
+
} else if (userTargetActive) {
|
|
204
|
+
// Allocation: try to keep ~target × nRowsOut rare rows. Cap rows get
|
|
205
|
+
// priority (preserves requireMaxReached); large rows fill the rest.
|
|
206
|
+
const desiredRareCount = Math.max(1, Math.round(target * params.nRowsOut));
|
|
207
|
+
const capKeep = Math.min(srcCap.length, desiredRareCount);
|
|
208
|
+
outCap = [...srcCap].sort((a, b) => b.payoutCents - a.payoutCents).slice(0, capKeep);
|
|
209
|
+
const largeBudget = Math.max(0, desiredRareCount - outCap.length);
|
|
210
|
+
if (largeBudget < srcLarge.length) {
|
|
211
|
+
// Stratified-by-log-payout sample so we preserve distribution shape
|
|
212
|
+
// across the large tier (instead of just taking top-N by payout).
|
|
213
|
+
outLarge =
|
|
214
|
+
largeBudget > 0
|
|
215
|
+
? stratifiedSmallSampleNonZero(srcLarge, largeBudget, 50, seed + 31)
|
|
216
|
+
: [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (outCap.length > params.nRowsOut) {
|
|
221
|
+
// Too many cap rows — keep highest-payout
|
|
222
|
+
outCap = [...srcCap].sort((a, b) => b.payoutCents - a.payoutCents).slice(0, params.nRowsOut);
|
|
223
|
+
outLarge = [];
|
|
224
|
+
} else if (outCap.length + outLarge.length > params.nRowsOut) {
|
|
225
|
+
// Cap fits, but cap+large too many — drop some large
|
|
226
|
+
const allowedLarge = params.nRowsOut - outCap.length;
|
|
227
|
+
outLarge = [...srcLarge]
|
|
228
|
+
.sort((a, b) => b.payoutCents - a.payoutCents)
|
|
229
|
+
.slice(0, allowedLarge);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const slotsForSmall = params.nRowsOut - outCap.length - outLarge.length;
|
|
233
|
+
const warnings: string[] = [];
|
|
234
|
+
let outSmallZero: LookupRow[] = [];
|
|
235
|
+
let outSmallNonZero: LookupRow[] = [];
|
|
236
|
+
let srcSmallNonZeroAll: ReadonlyArray<LookupRow> = [];
|
|
237
|
+
// Refinement-pass swap counters.
|
|
238
|
+
let rtpSwaps = 0;
|
|
239
|
+
let cvSwaps = 0;
|
|
240
|
+
let gapFillSwaps = 0;
|
|
241
|
+
let gapsUnfillable = 0;
|
|
242
|
+
let diversifySwaps = 0;
|
|
243
|
+
// Diversify-pass budget inputs hoisted from the inner scope. The diversify
|
|
244
|
+
// pass runs AFTER gap-fill (outside the inner scope), but needs the same
|
|
245
|
+
// target Σ_smallNz_payout the cv pass used, plus the achievedSum the cv
|
|
246
|
+
// pass left, to compute the remaining RTP-drift headroom.
|
|
247
|
+
let targetSmallNzSumP = 0;
|
|
248
|
+
let cvAchievedSum: number | null = null;
|
|
249
|
+
// Compute W and small-tier subdivision now, so we can do RTP-aware non-zero
|
|
250
|
+
// sampling using the same W used in the output.
|
|
251
|
+
let W = 1;
|
|
252
|
+
if (slotsForSmall > 0 && srcSmall.length > 0) {
|
|
253
|
+
// Subdivide small into zero / non-zero so we can bias the sampling by
|
|
254
|
+
// params.targetHitRate. Tier-based preserves cap rate naturally, but the
|
|
255
|
+
// small-tier non-zero/zero composition can still be shifted to match a
|
|
256
|
+
// user-requested hit-rate.
|
|
257
|
+
const srcSmallZero: LookupRow[] = [];
|
|
258
|
+
const srcSmallNonZero: LookupRow[] = [];
|
|
259
|
+
for (const r of srcSmall) {
|
|
260
|
+
if (r.payoutCents === 0) srcSmallZero.push(r);
|
|
261
|
+
else srcSmallNonZero.push(r);
|
|
262
|
+
}
|
|
263
|
+
srcSmallNonZeroAll = srcSmallNonZero;
|
|
264
|
+
|
|
265
|
+
// Target cap rate (cap + large weight share) — same `target` used for W below.
|
|
266
|
+
const target_cap_rate = target;
|
|
267
|
+
const targetHitRate = params.targetHitRate;
|
|
268
|
+
|
|
269
|
+
// Solve for n_B (non-zero small rows) so that effective hit-rate = targetHitRate.
|
|
270
|
+
// (nHighOut + W × n_B) / (nHighOut + W × nSmall) = h
|
|
271
|
+
// where W is computed below using the same `target_cap_rate` formula, which
|
|
272
|
+
// implies high contributes target_cap_rate of total weight and small carries
|
|
273
|
+
// the remaining 1 - target_cap_rate split uniformly across nSmall.
|
|
274
|
+
// → n_B = nSmall × [h − (1−h) × target_cap_rate / (1 − target_cap_rate)]
|
|
275
|
+
const nHighOut = outCap.length + outLarge.length;
|
|
276
|
+
let nB: number;
|
|
277
|
+
if (target_cap_rate >= 1 || nHighOut === 0) {
|
|
278
|
+
// No high tier or fully high: every small row contributes h share uniformly.
|
|
279
|
+
nB = Math.round(slotsForSmall * targetHitRate);
|
|
280
|
+
} else {
|
|
281
|
+
const denom = 1 - target_cap_rate;
|
|
282
|
+
nB = Math.round(
|
|
283
|
+
slotsForSmall * (targetHitRate - ((1 - targetHitRate) * target_cap_rate) / denom),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const requestedNB = nB;
|
|
287
|
+
nB = Math.max(0, Math.min(nB, slotsForSmall, srcSmallNonZero.length));
|
|
288
|
+
let nA = slotsForSmall - nB;
|
|
289
|
+
// If zero bucket can't absorb nA, redirect overflow to non-zero
|
|
290
|
+
if (nA > srcSmallZero.length) {
|
|
291
|
+
const overflow = nA - srcSmallZero.length;
|
|
292
|
+
nA = srcSmallZero.length;
|
|
293
|
+
nB = Math.min(nB + overflow, srcSmallNonZero.length);
|
|
294
|
+
// If still short, the output will simply be under-filled and padded later.
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Warnings on unreachable hit-rate targets.
|
|
298
|
+
// Priority:
|
|
299
|
+
// 1. Source has too few non-zero rows (covers nB===0 from empty source too).
|
|
300
|
+
// 2. Cap-rate alone already meets/exceeds the target (formula yields nB<=0).
|
|
301
|
+
if (
|
|
302
|
+
requestedNB > srcSmallNonZero.length &&
|
|
303
|
+
nB === srcSmallNonZero.length &&
|
|
304
|
+
targetHitRate > 0
|
|
305
|
+
) {
|
|
306
|
+
warnings.push(
|
|
307
|
+
`source has only ${srcSmallNonZero.length} non-zero small rows; cannot reach targetHitRate=${targetHitRate}`,
|
|
308
|
+
);
|
|
309
|
+
} else if (requestedNB <= 0 && targetHitRate > 0 && nB === 0) {
|
|
310
|
+
warnings.push(
|
|
311
|
+
`targetHitRate=${targetHitRate} unreachable; cap+large weight share already meets or exceeds it (n_B clamped to 0)`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const bucketCount = params.bucketCount ?? 100;
|
|
316
|
+
// Sample zero sub-bucket: uniform reservoir.
|
|
317
|
+
outSmallZero =
|
|
318
|
+
nA >= srcSmallZero.length
|
|
319
|
+
? [...srcSmallZero]
|
|
320
|
+
: uniformReservoirSample(srcSmallZero, nA, seed);
|
|
321
|
+
|
|
322
|
+
// RTP-aware non-zero sampling.
|
|
323
|
+
// Compute the W we will use in the output (mirrors Phase 5 below). We have
|
|
324
|
+
// nSmall = nA + nB once sampled; tier-based has bounded weights by design.
|
|
325
|
+
const nSmallTotal = nA + nB;
|
|
326
|
+
let WforSampling = 1;
|
|
327
|
+
if (nSmallTotal > 0 && target > 0 && target < 1) {
|
|
328
|
+
WforSampling = Math.max(
|
|
329
|
+
1,
|
|
330
|
+
Math.round((nHighOut * (1 - target)) / (nSmallTotal * target)),
|
|
331
|
+
);
|
|
332
|
+
} else if (nHighOut === 0) {
|
|
333
|
+
WforSampling = 1;
|
|
334
|
+
}
|
|
335
|
+
W = WforSampling;
|
|
336
|
+
|
|
337
|
+
// Compute target mean payout for the non-zero sample so the overall RTP
|
|
338
|
+
// hits params.targetRTP.
|
|
339
|
+
// Total weight T = nHighOut + W × (nA + nB)
|
|
340
|
+
// Σ(w·p) needed = targetRTP × T × betCost (NOT × 100 — betCost may differ)
|
|
341
|
+
// Cap rows contribute Σ_cap = sum of cap+large payouts (weight=1 each)
|
|
342
|
+
// Σ_smallNz contribution = W × Σ_sampled_nz_payouts
|
|
343
|
+
// → Target Σ_sampled_nz_payouts = (targetRTP × T × betCost − Σ_cap) / W
|
|
344
|
+
const totalWeightTarget = nHighOut + W * (nA + nB);
|
|
345
|
+
const targetSumWP = params.targetRTP * totalWeightTarget * betCost;
|
|
346
|
+
let capSumP = 0;
|
|
347
|
+
for (const r of outCap) capSumP += r.payoutCents;
|
|
348
|
+
for (const r of outLarge) capSumP += r.payoutCents;
|
|
349
|
+
targetSmallNzSumP = W > 0 ? (targetSumWP - capSumP) / W : 0;
|
|
350
|
+
const targetMeanNz = nB > 0 ? targetSmallNzSumP / nB : 0;
|
|
351
|
+
|
|
352
|
+
if (nB >= srcSmallNonZero.length) {
|
|
353
|
+
outSmallNonZero = [...srcSmallNonZero];
|
|
354
|
+
} else if (nB > 0 && targetMeanNz > 0) {
|
|
355
|
+
const sampleResult = rtpAwareSampleNonZero(
|
|
356
|
+
srcSmallNonZero,
|
|
357
|
+
nB,
|
|
358
|
+
targetMeanNz,
|
|
359
|
+
bucketCount,
|
|
360
|
+
seed + 1,
|
|
361
|
+
);
|
|
362
|
+
outSmallNonZero = sampleResult.sampled;
|
|
363
|
+
if (sampleResult.clamped) {
|
|
364
|
+
warnings.push(
|
|
365
|
+
`targetRTP=${params.targetRTP} unreachable for non-zero sample: requested mean payout ` +
|
|
366
|
+
`${targetMeanNz.toFixed(0)} cents but achieved ${sampleResult.achievedMean.toFixed(0)} cents`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Iterative swap refinement: close residual RTP gap by swapping
|
|
371
|
+
// boundary rows in/out of the sample. Each swap is a single LookupRow
|
|
372
|
+
// exchange, so the weight distribution remains exactly intact.
|
|
373
|
+
//
|
|
374
|
+
// params.toleranceRTP is on LUT-RTP scale (e.g. 0.001 = 0.1pp LUT RTP).
|
|
375
|
+
// Achieved LUT RTP = (Σ_cap + W × Σ_smallNz) / (T × 100).
|
|
376
|
+
// Tolerable Σ_smallNz drift = toleranceRTP × T × 100 / W.
|
|
377
|
+
// Half it to leave a small safety budget for the CV pass that follows.
|
|
378
|
+
const T_out_predict = nHighOut + W * (nA + nB);
|
|
379
|
+
const rtpTolerance = W > 0 && T_out_predict > 0
|
|
380
|
+
? Math.max(1, 0.5 * params.toleranceRTP * T_out_predict * 100 / W)
|
|
381
|
+
: Math.max(1, 0.005 * targetSmallNzSumP);
|
|
382
|
+
const refined = refineRtpBySwap(
|
|
383
|
+
outSmallNonZero,
|
|
384
|
+
srcSmallNonZero,
|
|
385
|
+
targetSmallNzSumP,
|
|
386
|
+
rtpTolerance,
|
|
387
|
+
10000,
|
|
388
|
+
);
|
|
389
|
+
outSmallNonZero = refined.rows;
|
|
390
|
+
rtpSwaps = refined.swaps;
|
|
391
|
+
|
|
392
|
+
if (!refined.converged && refined.swaps > 0 && targetSmallNzSumP > 0) {
|
|
393
|
+
const achievedMean =
|
|
394
|
+
outSmallNonZero.length > 0 ? refined.achievedSum / outSmallNonZero.length : 0;
|
|
395
|
+
const targetMean =
|
|
396
|
+
outSmallNonZero.length > 0 ? targetSmallNzSumP / outSmallNonZero.length : 0;
|
|
397
|
+
const gap =
|
|
398
|
+
targetMean > 0 ? (Math.abs(achievedMean - targetMean) / targetMean) * 100 : 0;
|
|
399
|
+
warnings.push(
|
|
400
|
+
`RTP refinement did not fully converge after ${refined.swaps} swaps (${gap.toFixed(2)}% gap)`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Third refinement pass: Σ-preserving 2-swap pass to nudge CV toward
|
|
405
|
+
// targetCV. RTP (Σ payout) is preserved within a 0.5% tolerance; only
|
|
406
|
+
// Σ payout² is re-shaped. Increases CV by swapping a moderate (mid,mid)
|
|
407
|
+
// pair from the sample for a spread (low,high) pair from outside; or
|
|
408
|
+
// the inverse to decrease CV.
|
|
409
|
+
//
|
|
410
|
+
// Math:
|
|
411
|
+
// mean_out = (Σ_cap_payout + W × Σ_smallNz_payout) / T_out
|
|
412
|
+
// target_var = (targetCV × mean_out)²
|
|
413
|
+
// target E[X²] = target_var + mean_out² = mean_out² × (targetCV² + 1)
|
|
414
|
+
// target Σ(w·p²) = target_E[X²] × T_out
|
|
415
|
+
// target Σ_smallNz_p² = (target Σ(w·p²) − Σ_cap_p²) / W
|
|
416
|
+
if (params.targetCV > 0 && outSmallNonZero.length >= 2) {
|
|
417
|
+
const T_out = nHighOut + W * (nA + nB);
|
|
418
|
+
if (T_out > 0) {
|
|
419
|
+
let capSumP2 = 0;
|
|
420
|
+
for (const r of outCap) capSumP2 += r.payoutCents * r.payoutCents;
|
|
421
|
+
for (const r of outLarge) capSumP2 += r.payoutCents * r.payoutCents;
|
|
422
|
+
|
|
423
|
+
// mean_out predicted from converged RTP refinement.
|
|
424
|
+
const meanOutPredicted = (capSumP + W * refined.achievedSum) / T_out;
|
|
425
|
+
const targetEX2 = meanOutPredicted * meanOutPredicted * (params.targetCV ** 2 + 1);
|
|
426
|
+
const targetSumWP2 = targetEX2 * T_out;
|
|
427
|
+
const targetSmallNzSumP2 = W > 0 ? (targetSumWP2 - capSumP2) / W : 0;
|
|
428
|
+
|
|
429
|
+
if (targetSmallNzSumP2 > 0) {
|
|
430
|
+
// Cumulative Σ-drift cap per CV pass = the OTHER HALF of the user's
|
|
431
|
+
// RTP tolerance budget (the first half was spent by refineRtpBySwap).
|
|
432
|
+
// Σ tolerance = 0.5 × toleranceRTP × T × 100 / W (same conversion).
|
|
433
|
+
// This guarantees that even after both passes, total RTP drift
|
|
434
|
+
// stays within params.toleranceRTP.
|
|
435
|
+
const cvSumTolerance = W > 0
|
|
436
|
+
? Math.max(1, 0.5 * params.toleranceRTP * T_out * 100 / W)
|
|
437
|
+
: Math.max(1, 0.001 * targetSmallNzSumP);
|
|
438
|
+
// CV convergence threshold in Σ²-space:
|
|
439
|
+
// target E[X²] = mean² × (CV² + 1)
|
|
440
|
+
// d(Σ²_smallNz) / dCV = 2 × CV × mean² × T / W
|
|
441
|
+
// Σ²-tolerance = 2 × targetCV × mean² × T × toleranceCV / W
|
|
442
|
+
// Stop swapping when Σ² is within this band of target.
|
|
443
|
+
const cvSum2Tolerance = W > 0 && params.toleranceCV > 0 && params.targetCV > 0
|
|
444
|
+
? Math.max(1,
|
|
445
|
+
2 * params.targetCV * meanOutPredicted * meanOutPredicted *
|
|
446
|
+
T_out * params.toleranceCV / W)
|
|
447
|
+
: Math.max(1, 0.001 * Math.abs(targetSmallNzSumP2));
|
|
448
|
+
const cvRefined = refineCvBySwap(
|
|
449
|
+
outSmallNonZero,
|
|
450
|
+
srcSmallNonZero,
|
|
451
|
+
targetSmallNzSumP2,
|
|
452
|
+
cvSumTolerance,
|
|
453
|
+
cvSum2Tolerance,
|
|
454
|
+
500,
|
|
455
|
+
);
|
|
456
|
+
outSmallNonZero = cvRefined.rows;
|
|
457
|
+
cvSwaps = cvRefined.swaps;
|
|
458
|
+
cvAchievedSum = cvRefined.achievedSum;
|
|
459
|
+
|
|
460
|
+
// Warn if CV refinement spent more RTP budget than half-toleranceRTP
|
|
461
|
+
// (e.g. due to integer rounding in cvSumTolerance vs actual swap deltas).
|
|
462
|
+
if (targetSmallNzSumP > 0 && params.toleranceRTP > 0) {
|
|
463
|
+
const rtpDriftAbs =
|
|
464
|
+
Math.abs(cvRefined.achievedSum - targetSmallNzSumP);
|
|
465
|
+
if (rtpDriftAbs > cvSumTolerance * 1.1) {
|
|
466
|
+
const rtpDriftPct = (rtpDriftAbs / targetSmallNzSumP) * 100;
|
|
467
|
+
warnings.push(
|
|
468
|
+
`CV refinement drifted RTP by ${rtpDriftPct.toFixed(3)}% (${cvRefined.swaps} CV swaps)`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
// No RTP target signal (targetMeanNz <= 0 means cap already exceeds target,
|
|
477
|
+
// or no non-zero slots): fall back to stratified shape-preserving sample.
|
|
478
|
+
outSmallNonZero =
|
|
479
|
+
nB > 0
|
|
480
|
+
? stratifiedSmallSampleNonZero(srcSmallNonZero, nB, bucketCount, seed + 1)
|
|
481
|
+
: [];
|
|
482
|
+
if (nB > 0 && targetMeanNz <= 0 && targetSumWP > 0) {
|
|
483
|
+
warnings.push(
|
|
484
|
+
`targetRTP=${params.targetRTP} unreachable: cap+large rows alone already meet or exceed it`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Phase 4b: gap-filling pass — ensure no intermediate gaps in the Stake
|
|
492
|
+
// hit-rate distribution. Stake's "Gaps in the Hit Rate Table" check
|
|
493
|
+
// rejects publishing tables with empty ranges sandwiched between non-empty
|
|
494
|
+
// ones. The earlier stratified/RTP-aware sampling can leave a small but
|
|
495
|
+
// non-empty source range with 0 output slots after largest-remainder
|
|
496
|
+
// allocation; this pass swaps in a source row from any such missing range.
|
|
497
|
+
//
|
|
498
|
+
// Range occupancy is counted across ALL output rows (cap + large + small),
|
|
499
|
+
// so a range filled by cap/large rows is NOT considered a gap. Swaps only
|
|
500
|
+
// happen within the small-non-zero tier (where we have flexibility).
|
|
501
|
+
const ensureRangeCoverage = params.ensureRangeCoverage ?? true;
|
|
502
|
+
if (ensureRangeCoverage && outSmallNonZero.length > 0) {
|
|
503
|
+
// Sort by payout ascending for the range-scan inside fillStakeRangeGaps.
|
|
504
|
+
outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
505
|
+
const otherOutRows: LookupRow[] = [...outCap, ...outLarge];
|
|
506
|
+
const gapResult = fillStakeRangeGaps(
|
|
507
|
+
outSmallNonZero,
|
|
508
|
+
srcSmallNonZeroAll,
|
|
509
|
+
otherOutRows,
|
|
510
|
+
sourceMetrics.maxPayout,
|
|
511
|
+
betCost,
|
|
512
|
+
warnings,
|
|
513
|
+
);
|
|
514
|
+
gapFillSwaps = gapResult.swapsApplied;
|
|
515
|
+
gapsUnfillable = gapResult.unfillable;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Phase 4c: diversification pass — maximize distinct payoutCents in output.
|
|
519
|
+
// Stake Engine rejects "Insufficient Unique Events" when too few distinct
|
|
520
|
+
// payouts exist. Swap duplicate-payout rows in outSmallNonZero for source
|
|
521
|
+
// rows carrying NEW (unseen) payout values, subject to the remaining RTP
|
|
522
|
+
// drift budget.
|
|
523
|
+
const minUniqueRate = params.minUniqueEventsRate ?? 0.01;
|
|
524
|
+
if (minUniqueRate > 0 && outSmallNonZero.length > 0) {
|
|
525
|
+
const targetUnique = Math.ceil(minUniqueRate * params.nRowsOut);
|
|
526
|
+
const nHighOut2 = outCap.length + outLarge.length;
|
|
527
|
+
// Predict T_out and W as the gap-fill pass left them (W is final after
|
|
528
|
+
// Phase 5 computes it, but for the budget we use the same prediction the
|
|
529
|
+
// cv pass did).
|
|
530
|
+
const T_out_predict2 = nHighOut2 + W * (outSmallZero.length + outSmallNonZero.length);
|
|
531
|
+
// Diversify budget = full user-supplied RTP-drift envelope minus what RTP+CV
|
|
532
|
+
// passes already spent. The earlier passes are capped at 0.5 × full_budget
|
|
533
|
+
// each, but typically use far less — the leftover funds diversify. This
|
|
534
|
+
// guarantees cumulative drift across all refinement passes stays within
|
|
535
|
+
// params.toleranceRTP.
|
|
536
|
+
const fullBudget = W > 0 && T_out_predict2 > 0
|
|
537
|
+
? params.toleranceRTP * T_out_predict2 * 100 / W
|
|
538
|
+
: 0.01 * Math.abs(targetSmallNzSumP);
|
|
539
|
+
const spent =
|
|
540
|
+
cvAchievedSum !== null && targetSmallNzSumP !== 0
|
|
541
|
+
? Math.abs(cvAchievedSum - targetSmallNzSumP)
|
|
542
|
+
: 0;
|
|
543
|
+
const sumBudget = Math.max(1, fullBudget - spent);
|
|
544
|
+
// Make sure outSmallNonZero is sorted by payout ascending (gap-fill already
|
|
545
|
+
// maintained this invariant when run; if gap-fill was skipped, sort here).
|
|
546
|
+
outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
547
|
+
const otherOutRows: LookupRow[] = [...outCap, ...outLarge, ...outSmallZero];
|
|
548
|
+
const divResult = diversifyPayouts(
|
|
549
|
+
outSmallNonZero,
|
|
550
|
+
srcSmallNonZeroAll,
|
|
551
|
+
otherOutRows,
|
|
552
|
+
targetUnique,
|
|
553
|
+
sumBudget,
|
|
554
|
+
warnings,
|
|
555
|
+
);
|
|
556
|
+
diversifySwaps = divResult.swaps;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Phase 4d: final gap-fill then RTP polish.
|
|
560
|
+
//
|
|
561
|
+
// Run gap-fill FIRST so any range opened by diversify is restored. Then run
|
|
562
|
+
// polish with a "protected sims" set covering every small-non-zero row that
|
|
563
|
+
// is the only occupant of its Stake hit-rate range (counting cap + large +
|
|
564
|
+
// small-zero too). That way polish can never re-open a range it would
|
|
565
|
+
// otherwise have to fill in another cycle.
|
|
566
|
+
if (ensureRangeCoverage && outSmallNonZero.length > 0) {
|
|
567
|
+
outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
568
|
+
const otherOutRows: LookupRow[] = [...outCap, ...outLarge, ...outSmallZero];
|
|
569
|
+
const gapResult = fillStakeRangeGaps(
|
|
570
|
+
outSmallNonZero,
|
|
571
|
+
srcSmallNonZeroAll,
|
|
572
|
+
otherOutRows,
|
|
573
|
+
sourceMetrics.maxPayout,
|
|
574
|
+
betCost,
|
|
575
|
+
warnings,
|
|
576
|
+
);
|
|
577
|
+
gapFillSwaps += gapResult.swapsApplied;
|
|
578
|
+
gapsUnfillable = Math.max(gapsUnfillable, gapResult.unfillable);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (
|
|
582
|
+
targetSmallNzSumP > 0 &&
|
|
583
|
+
outSmallNonZero.length > 0 &&
|
|
584
|
+
srcSmallNonZeroAll.length > 0
|
|
585
|
+
) {
|
|
586
|
+
outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
587
|
+
const T_polish = (outCap.length + outLarge.length) + W * (outSmallZero.length + outSmallNonZero.length);
|
|
588
|
+
const polishTolerance =
|
|
589
|
+
W > 0 && T_polish > 0
|
|
590
|
+
? Math.max(1, params.toleranceRTP * T_polish * 100 / W)
|
|
591
|
+
: Math.max(1, 0.001 * targetSmallNzSumP);
|
|
592
|
+
|
|
593
|
+
// Range-coverage guard for polish. Counts every Stake hit-rate bucket
|
|
594
|
+
// across the FULL output (cap + large + small-zero + small-non-zero).
|
|
595
|
+
// refineRtpBySwap consults `counts` before every swap-out and refuses
|
|
596
|
+
// any that would drop a bucket to 0, then updates `counts` after each
|
|
597
|
+
// accepted swap — so a range starting with N rows gets protected the
|
|
598
|
+
// moment polish has drained it to a single row. This is the dynamic
|
|
599
|
+
// replacement for the old static `protectedSims` set, which couldn't
|
|
600
|
+
// see polish depleting multi-row ranges one swap at a time.
|
|
601
|
+
let rangeProtect: SwapRangeProtect | undefined;
|
|
602
|
+
if (ensureRangeCoverage) {
|
|
603
|
+
const counts = new Map<number, number>();
|
|
604
|
+
const tally = (r: LookupRow): void => {
|
|
605
|
+
const idx = findRange(r.payoutCents, betCost);
|
|
606
|
+
counts.set(idx, (counts.get(idx) ?? 0) + 1);
|
|
607
|
+
};
|
|
608
|
+
for (const r of outCap) tally(r);
|
|
609
|
+
for (const r of outLarge) tally(r);
|
|
610
|
+
for (const r of outSmallZero) tally(r);
|
|
611
|
+
for (const r of outSmallNonZero) tally(r);
|
|
612
|
+
rangeProtect = {
|
|
613
|
+
getRange: (r) => findRange(r.payoutCents, betCost),
|
|
614
|
+
counts,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const polishRefined = refineRtpBySwap(
|
|
619
|
+
outSmallNonZero,
|
|
620
|
+
srcSmallNonZeroAll,
|
|
621
|
+
targetSmallNzSumP,
|
|
622
|
+
polishTolerance,
|
|
623
|
+
10000,
|
|
624
|
+
rangeProtect,
|
|
625
|
+
);
|
|
626
|
+
outSmallNonZero = polishRefined.rows;
|
|
627
|
+
rtpSwaps += polishRefined.swaps;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const outSmall: LookupRow[] = [...outSmallZero, ...outSmallNonZero];
|
|
631
|
+
|
|
632
|
+
// Phase 5: compute W (recompute to match actual nSmall after sampling)
|
|
633
|
+
const nHigh = outCap.length + outLarge.length;
|
|
634
|
+
const nSmall = outSmall.length;
|
|
635
|
+
if (nSmall > 0 && target > 0 && target < 1) {
|
|
636
|
+
W = Math.max(1, Math.round((nHigh * (1 - target)) / (nSmall * target)));
|
|
637
|
+
} else if (nHigh === 0) {
|
|
638
|
+
W = 1; // no high tier — all uniform
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Phase 6: build output
|
|
642
|
+
const outRows: LookupRow[] = [];
|
|
643
|
+
for (const r of outCap) outRows.push({ sim: r.sim, weight: 1, payoutCents: r.payoutCents });
|
|
644
|
+
for (const r of outLarge) outRows.push({ sim: r.sim, weight: 1, payoutCents: r.payoutCents });
|
|
645
|
+
for (const r of outSmall) outRows.push({ sim: r.sim, weight: W, payoutCents: r.payoutCents });
|
|
646
|
+
|
|
647
|
+
// Pad with synthetic zero-payout rows if short
|
|
648
|
+
while (outRows.length < params.nRowsOut) {
|
|
649
|
+
outRows.push({ sim: -1, weight: 1, payoutCents: 0 });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Phase 7: metrics and report
|
|
653
|
+
const achieved = computeMetrics(outRows);
|
|
654
|
+
|
|
655
|
+
const toleranceMet: ToleranceMet = {
|
|
656
|
+
rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
|
|
657
|
+
cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
|
|
658
|
+
hitRate: Math.abs(achieved.hitRate - params.targetHitRate) <= params.toleranceHitRate,
|
|
659
|
+
maxReached:
|
|
660
|
+
!requireMaxReached ||
|
|
661
|
+
outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
|
|
662
|
+
rtpConcentration: true, // tier-based doesn't concentrate by design — always true
|
|
663
|
+
weightCap: true, // tier-based has bounded weights by design
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// maxRowRtpShare
|
|
667
|
+
let totalWP = 0;
|
|
668
|
+
for (const r of outRows) totalWP += r.weight * r.payoutCents;
|
|
669
|
+
let maxRowShare = 0;
|
|
670
|
+
if (totalWP > 0) {
|
|
671
|
+
for (const r of outRows) {
|
|
672
|
+
const share = (r.weight * r.payoutCents) / totalWP;
|
|
673
|
+
if (share > maxRowShare) maxRowShare = share;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Max weight ratio
|
|
678
|
+
const uniformPrior = achieved.totalWeight / outRows.length;
|
|
679
|
+
let maxWeightObs = 0;
|
|
680
|
+
for (const r of outRows) {
|
|
681
|
+
if (r.weight > maxWeightObs) maxWeightObs = r.weight;
|
|
682
|
+
}
|
|
683
|
+
const maxWeightRatio = uniformPrior > 0 ? maxWeightObs / uniformPrior : 1;
|
|
684
|
+
|
|
685
|
+
// Stake report
|
|
686
|
+
const stakeReport = computeStakeReport(outRows, achieved, betCost);
|
|
687
|
+
|
|
688
|
+
if (sourceMetrics.maxPayout < maxReachedFraction * params.capMaxWin && requireMaxReached) {
|
|
689
|
+
warnings.push(
|
|
690
|
+
`no row reaches ${maxReachedFraction * 100}% of capMaxWin; requireMaxReached cannot be honored`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Warn about intermediate gaps in the hit-rate distribution (Stake's
|
|
695
|
+
// "Gaps in the Hit Rate Table" check). Empty ranges above the highest
|
|
696
|
+
// non-empty range are natural and not flagged.
|
|
697
|
+
const gaps = detectHitRateGaps(stakeReport.hitRateDistribution);
|
|
698
|
+
if (gaps.length > 0) {
|
|
699
|
+
const formatted = gaps.map((g) => `[${g.low}, ${g.high})`).join(', ');
|
|
700
|
+
warnings.push(
|
|
701
|
+
`hit-rate distribution has ${gaps.length} intermediate gap(s) — Stake "Gaps in the Hit Rate Table" check may fail: ${formatted}`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
rows: outRows,
|
|
707
|
+
achieved,
|
|
708
|
+
toleranceMet,
|
|
709
|
+
maxRowRtpShare: maxRowShare,
|
|
710
|
+
maxWeightRatio,
|
|
711
|
+
refinement: { rtpSwaps, cvSwaps, gapFillSwaps, gapsUnfillable, diversifySwaps },
|
|
712
|
+
warnings,
|
|
713
|
+
stakeReport,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* RTP-aware non-zero sample: pick `k` rows from `srcNonZero` such that their
|
|
719
|
+
* MEAN payout is approximately `targetMeanPayout`, while preserving shape
|
|
720
|
+
* within each side of the split via stratified sampling.
|
|
721
|
+
*
|
|
722
|
+
* Strategy — two-side analytical LP:
|
|
723
|
+
* Split source into "low" (payout < targetMeanPayout) and "high" (>=).
|
|
724
|
+
* Compute μ_low, μ_high.
|
|
725
|
+
* Solve: n_high × μ_high + (k − n_high) × μ_low = k × targetMeanPayout
|
|
726
|
+
* → n_high = k × (targetMeanPayout − μ_low) / (μ_high − μ_low)
|
|
727
|
+
* Clamp to [0, |high|] and [0, |low|], then stratified-sample within each.
|
|
728
|
+
*
|
|
729
|
+
* If clamping prevents reaching the target mean, returns clamped=true.
|
|
730
|
+
*/
|
|
731
|
+
function rtpAwareSampleNonZero(
|
|
732
|
+
srcNonZero: ReadonlyArray<LookupRow>,
|
|
733
|
+
k: number,
|
|
734
|
+
targetMeanPayout: number,
|
|
735
|
+
bucketCount: number,
|
|
736
|
+
seed: number,
|
|
737
|
+
): { sampled: LookupRow[]; achievedMean: number; clamped: boolean } {
|
|
738
|
+
if (k === 0) return { sampled: [], achievedMean: 0, clamped: false };
|
|
739
|
+
if (k >= srcNonZero.length) {
|
|
740
|
+
let sum = 0;
|
|
741
|
+
for (const r of srcNonZero) sum += r.payoutCents;
|
|
742
|
+
const mean = srcNonZero.length > 0 ? sum / srcNonZero.length : 0;
|
|
743
|
+
return { sampled: [...srcNonZero], achievedMean: mean, clamped: true };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Compute source mean for the early-exit "close enough" check.
|
|
747
|
+
let srcSum = 0;
|
|
748
|
+
for (const r of srcNonZero) srcSum += r.payoutCents;
|
|
749
|
+
const sourceMean = srcSum / srcNonZero.length;
|
|
750
|
+
|
|
751
|
+
// If target is within 1% of source mean, plain stratified sample is fine
|
|
752
|
+
// (no bias needed).
|
|
753
|
+
if (sourceMean > 0 && Math.abs(targetMeanPayout - sourceMean) / sourceMean < 0.01) {
|
|
754
|
+
const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
|
|
755
|
+
let s = 0;
|
|
756
|
+
for (const r of sampled) s += r.payoutCents;
|
|
757
|
+
const mean = sampled.length > 0 ? s / sampled.length : 0;
|
|
758
|
+
return { sampled, achievedMean: mean, clamped: false };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Split into low (payout < targetMean) and high (payout >= targetMean).
|
|
762
|
+
const low: LookupRow[] = [];
|
|
763
|
+
const high: LookupRow[] = [];
|
|
764
|
+
for (const r of srcNonZero) {
|
|
765
|
+
if (r.payoutCents < targetMeanPayout) low.push(r);
|
|
766
|
+
else high.push(r);
|
|
767
|
+
}
|
|
768
|
+
if (low.length === 0 || high.length === 0) {
|
|
769
|
+
// Target outside source range: can't reach it. Sample uniformly + clamp.
|
|
770
|
+
const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
|
|
771
|
+
let s = 0;
|
|
772
|
+
for (const r of sampled) s += r.payoutCents;
|
|
773
|
+
const mean = sampled.length > 0 ? s / sampled.length : 0;
|
|
774
|
+
return { sampled, achievedMean: mean, clamped: true };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let lowSum = 0;
|
|
778
|
+
for (const r of low) lowSum += r.payoutCents;
|
|
779
|
+
let highSum = 0;
|
|
780
|
+
for (const r of high) highSum += r.payoutCents;
|
|
781
|
+
const muLow = lowSum / low.length;
|
|
782
|
+
const muHigh = highSum / high.length;
|
|
783
|
+
|
|
784
|
+
// Avoid division by zero if both groups collapse to same mean.
|
|
785
|
+
if (muHigh - muLow < 1e-9) {
|
|
786
|
+
const sampled = stratifiedSmallSampleNonZero(srcNonZero, k, bucketCount, seed);
|
|
787
|
+
let s = 0;
|
|
788
|
+
for (const r of sampled) s += r.payoutCents;
|
|
789
|
+
const mean = sampled.length > 0 ? s / sampled.length : 0;
|
|
790
|
+
return { sampled, achievedMean: mean, clamped: true };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let nHighOut = Math.round((k * (targetMeanPayout - muLow)) / (muHigh - muLow));
|
|
794
|
+
let clamped = false;
|
|
795
|
+
if (nHighOut < 0) {
|
|
796
|
+
nHighOut = 0;
|
|
797
|
+
clamped = true;
|
|
798
|
+
}
|
|
799
|
+
if (nHighOut > high.length) {
|
|
800
|
+
nHighOut = high.length;
|
|
801
|
+
clamped = true;
|
|
802
|
+
}
|
|
803
|
+
if (nHighOut > k) {
|
|
804
|
+
nHighOut = k;
|
|
805
|
+
clamped = true;
|
|
806
|
+
}
|
|
807
|
+
let nLowOut = k - nHighOut;
|
|
808
|
+
if (nLowOut > low.length) {
|
|
809
|
+
// Shouldn't happen given nHighOut bounds + (low+high=src) and k < src.length,
|
|
810
|
+
// but redirect overflow to high if it does.
|
|
811
|
+
const overflow = nLowOut - low.length;
|
|
812
|
+
nLowOut = low.length;
|
|
813
|
+
nHighOut = Math.min(nHighOut + overflow, high.length);
|
|
814
|
+
clamped = true;
|
|
815
|
+
}
|
|
816
|
+
if (nLowOut < 0) {
|
|
817
|
+
nLowOut = 0;
|
|
818
|
+
clamped = true;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const subBuckets = Math.max(2, Math.floor(bucketCount / 2));
|
|
822
|
+
const sampleLow =
|
|
823
|
+
nLowOut >= low.length
|
|
824
|
+
? [...low]
|
|
825
|
+
: nLowOut > 0
|
|
826
|
+
? stratifiedSmallSampleNonZero(low, nLowOut, subBuckets, seed)
|
|
827
|
+
: [];
|
|
828
|
+
const sampleHigh =
|
|
829
|
+
nHighOut >= high.length
|
|
830
|
+
? [...high]
|
|
831
|
+
: nHighOut > 0
|
|
832
|
+
? stratifiedSmallSampleNonZero(high, nHighOut, subBuckets, seed + 17)
|
|
833
|
+
: [];
|
|
834
|
+
|
|
835
|
+
const sampled = [...sampleLow, ...sampleHigh];
|
|
836
|
+
let sumOut = 0;
|
|
837
|
+
for (const r of sampled) sumOut += r.payoutCents;
|
|
838
|
+
const achievedMean = sampled.length > 0 ? sumOut / sampled.length : 0;
|
|
839
|
+
// If we hit a hard side cap (consumed entire low or entire high group), flag.
|
|
840
|
+
if (nHighOut === high.length || nLowOut === low.length) clamped = true;
|
|
841
|
+
return { sampled, achievedMean, clamped };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Iterative row-level swap refinement to close residual RTP gap.
|
|
846
|
+
*
|
|
847
|
+
* The analytical low/high partition in `rtpAwareSampleNonZero` lands within a
|
|
848
|
+
* few rows of the optimum but `Math.round(nHighOut)` and `Math.round(W)` leak
|
|
849
|
+
* ~1% of RTP. This function exchanges single rows in/out of the sample to
|
|
850
|
+
* close the residual Σ-payout gap to the target, without touching the
|
|
851
|
+
* row count or weight distribution.
|
|
852
|
+
*
|
|
853
|
+
* Each swap replaces ONE sample row with ONE outside row, so |sampled|
|
|
854
|
+
* stays exactly k. Converges in O(K) swaps where K is the initial gap
|
|
855
|
+
* measured in row-payout units.
|
|
856
|
+
*/
|
|
857
|
+
/** Optional dynamic protection for refineRtpBySwap. `getRange(row)` returns
|
|
858
|
+
* the row's group key (e.g. its Stake hit-rate bucket index). `counts` is
|
|
859
|
+
* the current per-group occupancy across the FULL output (caller pre-fills
|
|
860
|
+
* it including cap/large/other tiers); refineRtpBySwap decrements / increments
|
|
861
|
+
* it on every swap and refuses any swap-out that would drop a group's count
|
|
862
|
+
* to 0. This is what protects range coverage from being destroyed by the
|
|
863
|
+
* polish pass when multiple rows in a range can be picked off one by one. */
|
|
864
|
+
export interface SwapRangeProtect {
|
|
865
|
+
getRange(row: LookupRow): number;
|
|
866
|
+
counts: Map<number, number>;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function refineRtpBySwap(
|
|
870
|
+
sampled: ReadonlyArray<LookupRow>,
|
|
871
|
+
pool: ReadonlyArray<LookupRow>,
|
|
872
|
+
targetSumPayout: number,
|
|
873
|
+
tolerance: number,
|
|
874
|
+
maxSwaps: number,
|
|
875
|
+
rangeProtect?: SwapRangeProtect,
|
|
876
|
+
): { rows: LookupRow[]; achievedSum: number; swaps: number; converged: boolean } {
|
|
877
|
+
const inSet = new Set<number>();
|
|
878
|
+
for (const r of sampled) inSet.add(r.sim);
|
|
879
|
+
|
|
880
|
+
let achievedSum = 0;
|
|
881
|
+
for (const r of sampled) achievedSum += r.payoutCents;
|
|
882
|
+
|
|
883
|
+
const sampledArr = sampled.slice();
|
|
884
|
+
const outsideArr: LookupRow[] = [];
|
|
885
|
+
for (const r of pool) {
|
|
886
|
+
if (!inSet.has(r.sim)) outsideArr.push(r);
|
|
887
|
+
}
|
|
888
|
+
sampledArr.sort((a, b) => a.payoutCents - b.payoutCents); // ascending
|
|
889
|
+
outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
890
|
+
|
|
891
|
+
// Dynamic protection: a row is removable only if its range currently
|
|
892
|
+
// contains ≥ 2 rows across the whole output. Re-checked every iteration,
|
|
893
|
+
// so a range that starts with N rows is protected the moment we've
|
|
894
|
+
// depleted it down to 1. Defeats the static-set bug where polish drains
|
|
895
|
+
// ranges with multiple rows one swap at a time.
|
|
896
|
+
const isProtected = (row: LookupRow): boolean => {
|
|
897
|
+
if (!rangeProtect) return false;
|
|
898
|
+
const rng = rangeProtect.getRange(row);
|
|
899
|
+
return (rangeProtect.counts.get(rng) ?? 0) <= 1;
|
|
900
|
+
};
|
|
901
|
+
const recordSwap = (removed: LookupRow, inserted: LookupRow): void => {
|
|
902
|
+
if (!rangeProtect) return;
|
|
903
|
+
const removedRng = rangeProtect.getRange(removed);
|
|
904
|
+
const insertedRng = rangeProtect.getRange(inserted);
|
|
905
|
+
rangeProtect.counts.set(
|
|
906
|
+
removedRng,
|
|
907
|
+
(rangeProtect.counts.get(removedRng) ?? 0) - 1,
|
|
908
|
+
);
|
|
909
|
+
rangeProtect.counts.set(
|
|
910
|
+
insertedRng,
|
|
911
|
+
(rangeProtect.counts.get(insertedRng) ?? 0) + 1,
|
|
912
|
+
);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// Binary-search-by-payout helpers on a sorted array.
|
|
916
|
+
const lowerBound = (arr: ReadonlyArray<LookupRow>, target: number): number => {
|
|
917
|
+
let lo = 0;
|
|
918
|
+
let hi = arr.length;
|
|
919
|
+
while (lo < hi) {
|
|
920
|
+
const mid = (lo + hi) >>> 1;
|
|
921
|
+
if (arr[mid].payoutCents < target) lo = mid + 1;
|
|
922
|
+
else hi = mid;
|
|
923
|
+
}
|
|
924
|
+
return lo;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
let swaps = 0;
|
|
928
|
+
let converged = false;
|
|
929
|
+
|
|
930
|
+
while (swaps < maxSwaps) {
|
|
931
|
+
const delta = targetSumPayout - achievedSum;
|
|
932
|
+
if (Math.abs(delta) <= tolerance) {
|
|
933
|
+
converged = true;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (delta > 0) {
|
|
938
|
+
// Raise Σ: swap lowest non-protected sample OUT for highest outside row
|
|
939
|
+
// whose payout is ≤ (sampleLow + delta), but > sampleLow.
|
|
940
|
+
if (sampledArr.length === 0 || outsideArr.length === 0) break;
|
|
941
|
+
let sampleLowIdx = 0;
|
|
942
|
+
while (sampleLowIdx < sampledArr.length && isProtected(sampledArr[sampleLowIdx])) {
|
|
943
|
+
sampleLowIdx++;
|
|
944
|
+
}
|
|
945
|
+
if (sampleLowIdx >= sampledArr.length) break; // every row protected
|
|
946
|
+
const sampleLow = sampledArr[sampleLowIdx];
|
|
947
|
+
const desired = sampleLow.payoutCents + delta;
|
|
948
|
+
|
|
949
|
+
// Largest outside index with payout ≤ desired AND > sampleLow.payoutCents.
|
|
950
|
+
// Use lowerBound for desired+1 (first > desired) - 1 → last ≤ desired.
|
|
951
|
+
let bestIdx = lowerBound(outsideArr, desired + 1) - 1;
|
|
952
|
+
// Constraint: must be strictly greater than sampleLow to improve Σ.
|
|
953
|
+
if (bestIdx < 0 || outsideArr[bestIdx].payoutCents <= sampleLow.payoutCents) {
|
|
954
|
+
// No outside row in (sampleLow, sampleLow+delta]. Try the largest
|
|
955
|
+
// available outside row > sampleLow (would overshoot but reduce |delta|
|
|
956
|
+
// only if 2 * outsideRow - 2 * sampleLow ≤ delta is false → would
|
|
957
|
+
// overshoot more than current undershoot; skip).
|
|
958
|
+
// We strictly require non-overshooting swap → stop.
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
const outsideRow = outsideArr[bestIdx];
|
|
962
|
+
const newSum = achievedSum + outsideRow.payoutCents - sampleLow.payoutCents;
|
|
963
|
+
|
|
964
|
+
sampledArr.splice(sampleLowIdx, 1);
|
|
965
|
+
const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
|
|
966
|
+
sampledArr.splice(insertPos, 0, outsideRow);
|
|
967
|
+
// Remove outsideRow from outsideArr, insert sampleLow sorted.
|
|
968
|
+
outsideArr.splice(bestIdx, 1);
|
|
969
|
+
const outPos = lowerBound(outsideArr, sampleLow.payoutCents);
|
|
970
|
+
outsideArr.splice(outPos, 0, sampleLow);
|
|
971
|
+
|
|
972
|
+
inSet.delete(sampleLow.sim);
|
|
973
|
+
inSet.add(outsideRow.sim);
|
|
974
|
+
achievedSum = newSum;
|
|
975
|
+
recordSwap(sampleLow, outsideRow);
|
|
976
|
+
} else {
|
|
977
|
+
// Lower Σ: swap highest non-protected sample OUT for lowest outside row
|
|
978
|
+
// whose payout is ≥ (sampleHigh - |delta|), but < sampleHigh.
|
|
979
|
+
if (sampledArr.length === 0 || outsideArr.length === 0) break;
|
|
980
|
+
let sampleHighIdx = sampledArr.length - 1;
|
|
981
|
+
while (sampleHighIdx >= 0 && isProtected(sampledArr[sampleHighIdx])) {
|
|
982
|
+
sampleHighIdx--;
|
|
983
|
+
}
|
|
984
|
+
if (sampleHighIdx < 0) break; // every row protected
|
|
985
|
+
const sampleHigh = sampledArr[sampleHighIdx];
|
|
986
|
+
const needLoss = -delta;
|
|
987
|
+
const desired = sampleHigh.payoutCents - needLoss;
|
|
988
|
+
|
|
989
|
+
// Smallest outside index with payout ≥ desired AND < sampleHigh.payoutCents.
|
|
990
|
+
let bestIdx = lowerBound(outsideArr, desired);
|
|
991
|
+
if (bestIdx >= outsideArr.length || outsideArr[bestIdx].payoutCents >= sampleHigh.payoutCents) {
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
const outsideRow = outsideArr[bestIdx];
|
|
995
|
+
const newSum = achievedSum + outsideRow.payoutCents - sampleHigh.payoutCents;
|
|
996
|
+
|
|
997
|
+
sampledArr.splice(sampleHighIdx, 1);
|
|
998
|
+
const insertPos = lowerBound(sampledArr, outsideRow.payoutCents);
|
|
999
|
+
sampledArr.splice(insertPos, 0, outsideRow);
|
|
1000
|
+
outsideArr.splice(bestIdx, 1);
|
|
1001
|
+
const outPos = lowerBound(outsideArr, sampleHigh.payoutCents);
|
|
1002
|
+
outsideArr.splice(outPos, 0, sampleHigh);
|
|
1003
|
+
|
|
1004
|
+
inSet.delete(sampleHigh.sim);
|
|
1005
|
+
inSet.add(outsideRow.sim);
|
|
1006
|
+
achievedSum = newSum;
|
|
1007
|
+
recordSwap(sampleHigh, outsideRow);
|
|
1008
|
+
}
|
|
1009
|
+
swaps++;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return { rows: sampledArr, achievedSum, swaps, converged };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Σ-preserving 2-swap refinement to nudge CV toward target without
|
|
1017
|
+
* disturbing Σ payout (RTP).
|
|
1018
|
+
*
|
|
1019
|
+
* A "2-swap" exchanges two rows (a, b) currently IN the sample for two rows
|
|
1020
|
+
* (c, d) currently OUT, such that a + b ≈ c + d (within sumTolerance) and
|
|
1021
|
+
* a² + b² ≠ c² + d². RTP is preserved; only the second moment shifts.
|
|
1022
|
+
*
|
|
1023
|
+
* To INCREASE variance: swap moderate (mid, mid) → spread (low, high).
|
|
1024
|
+
* To DECREASE variance: swap spread (low, high) → moderate (mid, mid).
|
|
1025
|
+
*
|
|
1026
|
+
* Each iteration picks the best-improving swap from a small set of candidates
|
|
1027
|
+
* at the extremes / median of the current sorted sample and outside pool.
|
|
1028
|
+
*/
|
|
1029
|
+
function refineCvBySwap(
|
|
1030
|
+
sample: ReadonlyArray<LookupRow>,
|
|
1031
|
+
pool: ReadonlyArray<LookupRow>,
|
|
1032
|
+
targetSumPayout2: number,
|
|
1033
|
+
sumTolerance: number,
|
|
1034
|
+
sum2Tolerance: number,
|
|
1035
|
+
maxSwaps: number,
|
|
1036
|
+
): { rows: LookupRow[]; achievedSum: number; achievedSum2: number; swaps: number } {
|
|
1037
|
+
const inSet = new Set<number>();
|
|
1038
|
+
for (const r of sample) inSet.add(r.sim);
|
|
1039
|
+
|
|
1040
|
+
let sumP = 0;
|
|
1041
|
+
let sumP2 = 0;
|
|
1042
|
+
for (const r of sample) {
|
|
1043
|
+
sumP += r.payoutCents;
|
|
1044
|
+
sumP2 += r.payoutCents * r.payoutCents;
|
|
1045
|
+
}
|
|
1046
|
+
const initialSumP = sumP;
|
|
1047
|
+
|
|
1048
|
+
const sampleArr = sample.slice().sort((a, b) => a.payoutCents - b.payoutCents);
|
|
1049
|
+
const outsideArr: LookupRow[] = [];
|
|
1050
|
+
for (const r of pool) {
|
|
1051
|
+
if (!inSet.has(r.sim)) outsideArr.push(r);
|
|
1052
|
+
}
|
|
1053
|
+
outsideArr.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
1054
|
+
|
|
1055
|
+
let swaps = 0;
|
|
1056
|
+
while (swaps < maxSwaps) {
|
|
1057
|
+
const deltaSum2 = targetSumPayout2 - sumP2;
|
|
1058
|
+
if (Math.abs(deltaSum2) <= sum2Tolerance) break;
|
|
1059
|
+
|
|
1060
|
+
let bestSwap: {
|
|
1061
|
+
sampleA: LookupRow;
|
|
1062
|
+
sampleB: LookupRow;
|
|
1063
|
+
sampleIdxA: number;
|
|
1064
|
+
sampleIdxB: number;
|
|
1065
|
+
outsideC: LookupRow;
|
|
1066
|
+
outsideD: LookupRow;
|
|
1067
|
+
outsideIdxC: number;
|
|
1068
|
+
outsideIdxD: number;
|
|
1069
|
+
newSum: number;
|
|
1070
|
+
newSum2: number;
|
|
1071
|
+
gain: number;
|
|
1072
|
+
efficiency: number;
|
|
1073
|
+
} | null = null;
|
|
1074
|
+
|
|
1075
|
+
// Strategy: for each sample pair (a, b) with a < b, find an outside pair
|
|
1076
|
+
// (c, d) such that c + d ≈ a + b (RTP-preserving) but |c − (a+b)/2| ≠
|
|
1077
|
+
// |a − (a+b)/2|, i.e., the outside pair has different spread than the
|
|
1078
|
+
// sample pair. To INCREASE Σ p²: find outside pair with LARGER spread
|
|
1079
|
+
// (one row below `a`, the other above `b`). To DECREASE Σ p²: find
|
|
1080
|
+
// outside pair with SMALLER spread (both rows between `a` and `b`).
|
|
1081
|
+
//
|
|
1082
|
+
// Among heavy-tailed data the only pairs with non-trivial Σ² impact
|
|
1083
|
+
// anchor on a high-payout row. So we iterate sample's "high" half (anchor
|
|
1084
|
+
// = b, large index) and pair it with each anchor sample row a (a < b).
|
|
1085
|
+
// For increase: find outside c < a with c + d ≈ a + b, where d = a+b−c
|
|
1086
|
+
// and d must exist in outside near payout a+b−c, with d > b. For decrease:
|
|
1087
|
+
// find outside c > a, c < b such that d = a+b−c is also in outside with
|
|
1088
|
+
// a < d < b.
|
|
1089
|
+
if (sampleArr.length < 2 || outsideArr.length < 2) break;
|
|
1090
|
+
|
|
1091
|
+
const sLen = sampleArr.length;
|
|
1092
|
+
const outLen = outsideArr.length;
|
|
1093
|
+
|
|
1094
|
+
// Anchor count: how many sample pairs to probe per iteration. Larger →
|
|
1095
|
+
// better swap selection but slower. K_HI focuses on the high-payout end
|
|
1096
|
+
// (where Σ² is dominated); K_LO on the low end.
|
|
1097
|
+
const K_HI = 8;
|
|
1098
|
+
const K_LO = 8;
|
|
1099
|
+
|
|
1100
|
+
// For each candidate sample pair (aRow, bRow), choose outside `c` then
|
|
1101
|
+
// derive targetD = (a + b) − c. Binary-search outside for d-rows near
|
|
1102
|
+
// targetD. To INCREASE Σ²: pick c far from (a+b)/2 (more spread) — try
|
|
1103
|
+
// very small or very large outside indices. To DECREASE Σ²: pick c near
|
|
1104
|
+
// (a+b)/2 (less spread).
|
|
1105
|
+
//
|
|
1106
|
+
// We probe K_HI sample pairs anchored on high-payout sample rows (where
|
|
1107
|
+
// Σ² is dominated) plus a smattering of mid-range pairs.
|
|
1108
|
+
const cProbes = 32;
|
|
1109
|
+
const sampleAnchorPairs: [number, number][] = [];
|
|
1110
|
+
for (let hi = sLen - 1; hi >= Math.max(0, sLen - K_HI); hi--) {
|
|
1111
|
+
for (let lo = 0; lo < Math.min(K_LO, hi); lo++) {
|
|
1112
|
+
sampleAnchorPairs.push([lo, hi]);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
for (const [lo, hi] of sampleAnchorPairs) {
|
|
1117
|
+
const aRow = sampleArr[lo];
|
|
1118
|
+
const bRow = sampleArr[hi];
|
|
1119
|
+
if (aRow.payoutCents === bRow.payoutCents) continue;
|
|
1120
|
+
const oldSum = aRow.payoutCents + bRow.payoutCents;
|
|
1121
|
+
const oldSum2 =
|
|
1122
|
+
aRow.payoutCents * aRow.payoutCents + bRow.payoutCents * bRow.payoutCents;
|
|
1123
|
+
|
|
1124
|
+
// Pick c candidates. For INCREASE: c far from oldSum/2 (extremes of
|
|
1125
|
+
// outside). For DECREASE: c near oldSum/2.
|
|
1126
|
+
const cIdxs: number[] = [];
|
|
1127
|
+
if (deltaSum2 > 0) {
|
|
1128
|
+
// Take extremes: smallest few and largest few outside rows.
|
|
1129
|
+
const half = Math.ceil(cProbes / 2);
|
|
1130
|
+
for (let s = 0; s < Math.min(half, outLen); s++) cIdxs.push(s);
|
|
1131
|
+
for (let s = 0; s < Math.min(half, outLen); s++) {
|
|
1132
|
+
const idx = outLen - 1 - s;
|
|
1133
|
+
if (idx >= 0) cIdxs.push(idx);
|
|
1134
|
+
}
|
|
1135
|
+
} else {
|
|
1136
|
+
// Center of outside near oldSum/2.
|
|
1137
|
+
const target = oldSum / 2;
|
|
1138
|
+
const center = lowerBoundIdx(outsideArr, target);
|
|
1139
|
+
const half = Math.ceil(cProbes / 2);
|
|
1140
|
+
for (let off = -half; off <= half; off++) {
|
|
1141
|
+
const idx = center + off;
|
|
1142
|
+
if (idx >= 0 && idx < outLen) cIdxs.push(idx);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Tighten per-swap Σ drift: each candidate's newSum must stay within
|
|
1147
|
+
// sumTolerance of initialSumP (cumulative cap), not oldSum (local cap).
|
|
1148
|
+
const lowerOk = initialSumP - sumTolerance;
|
|
1149
|
+
const upperOk = initialSumP + sumTolerance;
|
|
1150
|
+
|
|
1151
|
+
for (const ci of cIdxs) {
|
|
1152
|
+
const cRow = outsideArr[ci];
|
|
1153
|
+
const targetD = oldSum - cRow.payoutCents;
|
|
1154
|
+
if (targetD <= 0) continue;
|
|
1155
|
+
// Per-swap delta limited by remaining cumulative budget so total Σ
|
|
1156
|
+
// stays within sumTolerance of initialSumP.
|
|
1157
|
+
const remainingBudget = Math.max(0, sumTolerance - Math.abs(sumP - initialSumP));
|
|
1158
|
+
const perSwapTol = Math.min(sumTolerance, remainingBudget + sumTolerance * 0.1);
|
|
1159
|
+
const dIdxLB = lowerBoundIdx(outsideArr, targetD - perSwapTol);
|
|
1160
|
+
const dIdxUB = lowerBoundIdx(outsideArr, targetD + perSwapTol + 1);
|
|
1161
|
+
for (let di = dIdxLB; di < dIdxUB && di < outLen; di++) {
|
|
1162
|
+
if (di === ci) continue;
|
|
1163
|
+
const dRow = outsideArr[di];
|
|
1164
|
+
const newSumPair = cRow.payoutCents + dRow.payoutCents;
|
|
1165
|
+
const candNewSumP = sumP - oldSum + newSumPair;
|
|
1166
|
+
// Cumulative drift constraint.
|
|
1167
|
+
if (candNewSumP < lowerOk || candNewSumP > upperOk) continue;
|
|
1168
|
+
const newSum2Pair =
|
|
1169
|
+
cRow.payoutCents * cRow.payoutCents + dRow.payoutCents * dRow.payoutCents;
|
|
1170
|
+
// Skip identity swap.
|
|
1171
|
+
if (
|
|
1172
|
+
(cRow.sim === aRow.sim && dRow.sim === bRow.sim) ||
|
|
1173
|
+
(cRow.sim === bRow.sim && dRow.sim === aRow.sim)
|
|
1174
|
+
)
|
|
1175
|
+
continue;
|
|
1176
|
+
const candNewSum2 = sumP2 - oldSum2 + newSum2Pair;
|
|
1177
|
+
const gain = Math.abs(deltaSum2) - Math.abs(targetSumPayout2 - candNewSum2);
|
|
1178
|
+
// Penalize swaps with non-zero Σ drift: efficiency = gain per unit
|
|
1179
|
+
// of |Σ delta| consumed (with small ε to avoid div-by-zero).
|
|
1180
|
+
const sumDelta = Math.abs(newSumPair - oldSum);
|
|
1181
|
+
const efficiency = gain / (1 + sumDelta);
|
|
1182
|
+
if (gain > 0 && (!bestSwap || efficiency > bestSwap.efficiency)) {
|
|
1183
|
+
bestSwap = {
|
|
1184
|
+
sampleA: aRow,
|
|
1185
|
+
sampleB: bRow,
|
|
1186
|
+
sampleIdxA: lo,
|
|
1187
|
+
sampleIdxB: hi,
|
|
1188
|
+
outsideC: cRow,
|
|
1189
|
+
outsideD: dRow,
|
|
1190
|
+
outsideIdxC: ci,
|
|
1191
|
+
outsideIdxD: di,
|
|
1192
|
+
newSum: candNewSumP,
|
|
1193
|
+
newSum2: candNewSum2,
|
|
1194
|
+
gain,
|
|
1195
|
+
efficiency,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (!bestSwap) break;
|
|
1203
|
+
|
|
1204
|
+
// Apply swap. Remove indices in descending order so earlier indices stay valid.
|
|
1205
|
+
const sampleRemove = [bestSwap.sampleIdxA, bestSwap.sampleIdxB].sort((x, y) => y - x);
|
|
1206
|
+
sampleArr.splice(sampleRemove[0], 1);
|
|
1207
|
+
sampleArr.splice(sampleRemove[1], 1);
|
|
1208
|
+
insertSorted(sampleArr, bestSwap.outsideC);
|
|
1209
|
+
insertSorted(sampleArr, bestSwap.outsideD);
|
|
1210
|
+
|
|
1211
|
+
const outsideRemove = [bestSwap.outsideIdxC, bestSwap.outsideIdxD].sort((x, y) => y - x);
|
|
1212
|
+
outsideArr.splice(outsideRemove[0], 1);
|
|
1213
|
+
outsideArr.splice(outsideRemove[1], 1);
|
|
1214
|
+
insertSorted(outsideArr, bestSwap.sampleA);
|
|
1215
|
+
insertSorted(outsideArr, bestSwap.sampleB);
|
|
1216
|
+
|
|
1217
|
+
inSet.delete(bestSwap.sampleA.sim);
|
|
1218
|
+
inSet.delete(bestSwap.sampleB.sim);
|
|
1219
|
+
inSet.add(bestSwap.outsideC.sim);
|
|
1220
|
+
inSet.add(bestSwap.outsideD.sim);
|
|
1221
|
+
|
|
1222
|
+
sumP = bestSwap.newSum;
|
|
1223
|
+
sumP2 = bestSwap.newSum2;
|
|
1224
|
+
swaps++;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return { rows: sampleArr, achievedSum: sumP, achievedSum2: sumP2, swaps };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function insertSorted(arr: LookupRow[], row: LookupRow): void {
|
|
1231
|
+
const lo = lowerBoundIdx(arr, row.payoutCents);
|
|
1232
|
+
arr.splice(lo, 0, row);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** First index `i` with `arr[i].payoutCents >= target`. */
|
|
1236
|
+
function lowerBoundIdx(arr: ReadonlyArray<LookupRow>, target: number): number {
|
|
1237
|
+
let lo = 0;
|
|
1238
|
+
let hi = arr.length;
|
|
1239
|
+
while (lo < hi) {
|
|
1240
|
+
const mid = (lo + hi) >>> 1;
|
|
1241
|
+
if (arr[mid].payoutCents < target) lo = mid + 1;
|
|
1242
|
+
else hi = mid;
|
|
1243
|
+
}
|
|
1244
|
+
return lo;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Stratified sample of `k` rows from non-zero `rows`, partitioning by
|
|
1249
|
+
* log(payout). Each bucket contributes a slot count proportional to its size
|
|
1250
|
+
* in the source, so the sample preserves the source's per-bucket population
|
|
1251
|
+
* and (in expectation) its mean payout — critical for RTP fidelity.
|
|
1252
|
+
*
|
|
1253
|
+
* A simple uniform reservoir over a long-tailed distribution can over-pick
|
|
1254
|
+
* tail rows by chance; with weight=W in the output, that drift gets amplified
|
|
1255
|
+
* (here observed as +7.6% RTP on real ANTE data). Stratification eliminates
|
|
1256
|
+
* that drift.
|
|
1257
|
+
*
|
|
1258
|
+
* Assumes all input rows have payoutCents > 0; the zero-payout rows are
|
|
1259
|
+
* handled separately by `uniformReservoirSample` so the caller can bias the
|
|
1260
|
+
* zero/non-zero ratio per `targetHitRate`.
|
|
1261
|
+
*/
|
|
1262
|
+
function stratifiedSmallSampleNonZero(
|
|
1263
|
+
rows: ReadonlyArray<LookupRow>,
|
|
1264
|
+
k: number,
|
|
1265
|
+
bucketCount: number,
|
|
1266
|
+
seed: number,
|
|
1267
|
+
): LookupRow[] {
|
|
1268
|
+
if (k >= rows.length) return [...rows];
|
|
1269
|
+
if (k <= 0) return [];
|
|
1270
|
+
|
|
1271
|
+
// Find min/max payout for log bucketing.
|
|
1272
|
+
let minPayout = Infinity;
|
|
1273
|
+
let maxPayout = 0;
|
|
1274
|
+
for (const r of rows) {
|
|
1275
|
+
if (r.payoutCents > 0 && r.payoutCents < minPayout) minPayout = r.payoutCents;
|
|
1276
|
+
if (r.payoutCents > maxPayout) maxPayout = r.payoutCents;
|
|
1277
|
+
}
|
|
1278
|
+
const usable = isFinite(minPayout) && maxPayout > 0;
|
|
1279
|
+
|
|
1280
|
+
type Bucket = { indices: number[] };
|
|
1281
|
+
const logBuckets: Bucket[] = Array.from({ length: bucketCount }, () => ({ indices: [] }));
|
|
1282
|
+
|
|
1283
|
+
const logMin = usable ? Math.log(minPayout) : 0;
|
|
1284
|
+
const logMax = usable ? Math.log(maxPayout) : 1;
|
|
1285
|
+
const logSpan = Math.max(logMax - logMin, 1e-9);
|
|
1286
|
+
|
|
1287
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1288
|
+
const r = rows[i];
|
|
1289
|
+
if (r.payoutCents <= 0) continue; // defensive — caller passes non-zero only
|
|
1290
|
+
let bidx = 0;
|
|
1291
|
+
if (usable && logSpan > 0) {
|
|
1292
|
+
const t = (Math.log(r.payoutCents) - logMin) / logSpan;
|
|
1293
|
+
bidx = Math.min(bucketCount - 1, Math.max(0, Math.floor(t * bucketCount)));
|
|
1294
|
+
}
|
|
1295
|
+
logBuckets[bidx].indices.push(i);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Allocate slots per bucket proportional to bucket size (largest-remainder).
|
|
1299
|
+
const sizes = logBuckets.map((b) => b.indices.length);
|
|
1300
|
+
const total = sizes.reduce((s, v) => s + v, 0);
|
|
1301
|
+
if (total === 0) return [];
|
|
1302
|
+
const proposed = sizes.map((s) => (s / total) * k);
|
|
1303
|
+
const floors = proposed.map(Math.floor);
|
|
1304
|
+
const used = floors.reduce((s, v) => s + v, 0);
|
|
1305
|
+
const remainders = proposed.map((p, i) => p - floors[i]);
|
|
1306
|
+
const order = remainders.map((_, i) => i).sort((a, b) => remainders[b] - remainders[a]);
|
|
1307
|
+
let extra = k - used;
|
|
1308
|
+
for (const i of order) {
|
|
1309
|
+
if (extra === 0) break;
|
|
1310
|
+
if (floors[i] < sizes[i]) {
|
|
1311
|
+
floors[i]++;
|
|
1312
|
+
extra--;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
for (let i = 0; i < floors.length; i++) {
|
|
1316
|
+
if (floors[i] > sizes[i]) floors[i] = sizes[i];
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const rng = mulberry32(seed);
|
|
1320
|
+
const out: LookupRow[] = [];
|
|
1321
|
+
for (let bi = 0; bi < logBuckets.length; bi++) {
|
|
1322
|
+
const slots = floors[bi];
|
|
1323
|
+
if (slots <= 0) continue;
|
|
1324
|
+
const indices = logBuckets[bi].indices;
|
|
1325
|
+
const weights = new Array(indices.length).fill(1);
|
|
1326
|
+
const sampled = weightedReservoirSample(indices, weights, slots, rng);
|
|
1327
|
+
for (const idx of sampled) out.push(rows[idx]);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
return out;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Uniform reservoir sample of `k` rows from `rows`. Used for the zero-payout
|
|
1335
|
+
* sub-bucket where stratification by payout is meaningless (single value).
|
|
1336
|
+
*/
|
|
1337
|
+
function uniformReservoirSample(
|
|
1338
|
+
rows: ReadonlyArray<LookupRow>,
|
|
1339
|
+
k: number,
|
|
1340
|
+
seed: number,
|
|
1341
|
+
): LookupRow[] {
|
|
1342
|
+
if (k >= rows.length) return [...rows];
|
|
1343
|
+
if (k <= 0) return [];
|
|
1344
|
+
const rng = mulberry32(seed);
|
|
1345
|
+
const indices = rows.map((_, i) => i);
|
|
1346
|
+
const weights = new Array(indices.length).fill(1);
|
|
1347
|
+
const sampled = weightedReservoirSample(indices, weights, k, rng);
|
|
1348
|
+
return sampled.map((idx) => rows[idx]);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* High-tier (cap + large) sampling that targets a smooth log-decay shape
|
|
1353
|
+
* across Stake hit-rate buckets. Used when `shapeDistribution=true`.
|
|
1354
|
+
*
|
|
1355
|
+
* Algorithm:
|
|
1356
|
+
* 1. Bucket all high-tier source rows by Stake hit-rate range.
|
|
1357
|
+
* 2. Treat the lowest non-empty bucket as the anchor; target counts for
|
|
1358
|
+
* higher buckets follow `anchor × ratio^k`.
|
|
1359
|
+
* 3. If a global total target is provided, rescale the base so the row
|
|
1360
|
+
* counts sum to it (subsample mode). Otherwise just use the anchor's
|
|
1361
|
+
* source count as base.
|
|
1362
|
+
* 4. Each bucket samples its target_count rows stratified-by-log-payout
|
|
1363
|
+
* from within the bucket — preserves shape variety inside the range.
|
|
1364
|
+
* 5. Cap-vs-large classification of each picked row mirrors the source
|
|
1365
|
+
* classification (payoutCents ≥ capPayoutCents → cap, else large).
|
|
1366
|
+
*
|
|
1367
|
+
* Guarantees at least 1 row per bucket that has source candidates, so the
|
|
1368
|
+
* top bucket (max-reach) stays populated. If a bucket's source count is
|
|
1369
|
+
* below the decay target, all source rows in that bucket are kept.
|
|
1370
|
+
*/
|
|
1371
|
+
function bucketDecaySampleHighTier(
|
|
1372
|
+
srcCap: ReadonlyArray<LookupRow>,
|
|
1373
|
+
srcLarge: ReadonlyArray<LookupRow>,
|
|
1374
|
+
betCost: number,
|
|
1375
|
+
capPayoutCents: number,
|
|
1376
|
+
decayRatio: number,
|
|
1377
|
+
/** When defined, scale base so Σ target counts ≈ this. */
|
|
1378
|
+
targetTotalCount: number | undefined,
|
|
1379
|
+
seed: number,
|
|
1380
|
+
): { outCap: LookupRow[]; outLarge: LookupRow[] } {
|
|
1381
|
+
const highSource = [...srcCap, ...srcLarge];
|
|
1382
|
+
if (highSource.length === 0) {
|
|
1383
|
+
return { outCap: [], outLarge: [] };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const byBucket = new Map<number, LookupRow[]>();
|
|
1387
|
+
for (const r of highSource) {
|
|
1388
|
+
const idx = findRange(r.payoutCents, betCost);
|
|
1389
|
+
let list = byBucket.get(idx);
|
|
1390
|
+
if (!list) {
|
|
1391
|
+
list = [];
|
|
1392
|
+
byBucket.set(idx, list);
|
|
1393
|
+
}
|
|
1394
|
+
list.push(r);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const bucketIdxs = [...byBucket.keys()].sort((a, b) => a - b);
|
|
1398
|
+
const lowestIdx = bucketIdxs[0];
|
|
1399
|
+
|
|
1400
|
+
const weights: number[] = bucketIdxs.map((bIdx) =>
|
|
1401
|
+
Math.pow(decayRatio, bIdx - lowestIdx),
|
|
1402
|
+
);
|
|
1403
|
+
const weightSum = weights.reduce((a, b) => a + b, 0);
|
|
1404
|
+
|
|
1405
|
+
let base: number;
|
|
1406
|
+
if (targetTotalCount !== undefined && targetTotalCount > 0) {
|
|
1407
|
+
base = targetTotalCount / weightSum;
|
|
1408
|
+
} else {
|
|
1409
|
+
base = byBucket.get(lowestIdx)!.length;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const outCap: LookupRow[] = [];
|
|
1413
|
+
const outLarge: LookupRow[] = [];
|
|
1414
|
+
|
|
1415
|
+
for (let i = 0; i < bucketIdxs.length; i++) {
|
|
1416
|
+
const bIdx = bucketIdxs[i];
|
|
1417
|
+
const candidates = byBucket.get(bIdx)!;
|
|
1418
|
+
const want = Math.max(1, Math.min(candidates.length, Math.round(base * weights[i])));
|
|
1419
|
+
|
|
1420
|
+
const sampled =
|
|
1421
|
+
want >= candidates.length
|
|
1422
|
+
? [...candidates]
|
|
1423
|
+
: stratifiedSmallSampleNonZero(candidates, want, 10, seed + i * 31);
|
|
1424
|
+
|
|
1425
|
+
for (const r of sampled) {
|
|
1426
|
+
if (r.payoutCents >= capPayoutCents) {
|
|
1427
|
+
outCap.push(r);
|
|
1428
|
+
} else {
|
|
1429
|
+
outLarge.push(r);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return { outCap, outLarge };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Find the index of the Stake hit-rate range that `payoutCents` falls into.
|
|
1438
|
+
* Returns -1 if no range matches (shouldn't happen given the [0, 0.1] +
|
|
1439
|
+
* [20000, ∞) coverage, but defensive).
|
|
1440
|
+
*/
|
|
1441
|
+
function findRange(payoutCents: number, betCostCents: number): number {
|
|
1442
|
+
const pm = payoutCents / betCostCents;
|
|
1443
|
+
for (let i = 0; i < HIT_RATE_RANGES.length; i++) {
|
|
1444
|
+
const [low, high] = HIT_RATE_RANGES[i];
|
|
1445
|
+
if (pm >= low && pm < high) return i;
|
|
1446
|
+
}
|
|
1447
|
+
return -1;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Fourth refinement pass: ensure no intermediate gaps in the Stake hit-rate
|
|
1452
|
+
* distribution table. Stake rejects publishing tables with empty ranges
|
|
1453
|
+
* sandwiched between non-empty ones ("Gaps in the Hit Rate Table" check).
|
|
1454
|
+
*
|
|
1455
|
+
* Algorithm: for each range below maxPayout that's empty in output, find a
|
|
1456
|
+
* source row in that range and swap it in by replacing an output row whose
|
|
1457
|
+
* payout is closest (minimizes Σ payout drift). Skips ranges where source
|
|
1458
|
+
* has no rows (impossible to fill — emit a one-time warning).
|
|
1459
|
+
*
|
|
1460
|
+
* Modifies `outSmallNonZero` in place (preserves sorted-by-payout-ascending
|
|
1461
|
+
* invariant). Returns number of swaps applied plus the number of ranges that
|
|
1462
|
+
* source couldn't fill.
|
|
1463
|
+
*
|
|
1464
|
+
* Performance: O(R × (N + |source|)) where R = 16 ranges; the rangeCount/
|
|
1465
|
+
* rangeIdx maps avoid the naive O(N²) inner range-count.
|
|
1466
|
+
*/
|
|
1467
|
+
function fillStakeRangeGaps(
|
|
1468
|
+
outSmallNonZero: LookupRow[],
|
|
1469
|
+
srcSmallNonZero: ReadonlyArray<LookupRow>,
|
|
1470
|
+
otherOutRows: ReadonlyArray<LookupRow>,
|
|
1471
|
+
maxPayoutCents: number,
|
|
1472
|
+
betCostCents: number,
|
|
1473
|
+
warnings: string[],
|
|
1474
|
+
): { swapsApplied: number; unfillable: number } {
|
|
1475
|
+
let swapsApplied = 0;
|
|
1476
|
+
let unfillable = 0;
|
|
1477
|
+
|
|
1478
|
+
// Build set of in-sample sim ids for fast membership tests.
|
|
1479
|
+
const inSample = new Set<number>();
|
|
1480
|
+
for (const r of outSmallNonZero) inSample.add(r.sim);
|
|
1481
|
+
|
|
1482
|
+
// Pre-compute per-row range index for the swappable tier (small non-zero).
|
|
1483
|
+
const rangeIdx: number[] = outSmallNonZero.map((r) =>
|
|
1484
|
+
findRange(r.payoutCents, betCostCents),
|
|
1485
|
+
);
|
|
1486
|
+
// Range counts over the FULL output (small + cap/large): a range filled by
|
|
1487
|
+
// cap/large rows is not a gap, even if small-tier alone has 0 in it.
|
|
1488
|
+
const rangeCount = new Map<number, number>();
|
|
1489
|
+
for (const idx of rangeIdx) rangeCount.set(idx, (rangeCount.get(idx) ?? 0) + 1);
|
|
1490
|
+
for (const r of otherOutRows) {
|
|
1491
|
+
const idx = findRange(r.payoutCents, betCostCents);
|
|
1492
|
+
rangeCount.set(idx, (rangeCount.get(idx) ?? 0) + 1);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Only consider Stake ranges whose lower bound is below maxPayout (in bet units).
|
|
1496
|
+
const maxPm = maxPayoutCents / betCostCents;
|
|
1497
|
+
|
|
1498
|
+
for (let rangeI = 0; rangeI < HIT_RATE_RANGES.length; rangeI++) {
|
|
1499
|
+
const [low, high] = HIT_RATE_RANGES[rangeI];
|
|
1500
|
+
if (low >= maxPm) break; // tail ranges above maxPayout — natural empty
|
|
1501
|
+
const lowCents = low * betCostCents;
|
|
1502
|
+
const highCents = high === Infinity ? Infinity : high * betCostCents;
|
|
1503
|
+
|
|
1504
|
+
// Skip the [0, 0.1) range — that's the zero-tier territory (payouts < 0.1
|
|
1505
|
+
// bet units, i.e. 0 cents at betCost=100). Zero-payouts are handled by the
|
|
1506
|
+
// zero sub-bucket; we don't fill via non-zero rows here.
|
|
1507
|
+
if (low === 0) continue;
|
|
1508
|
+
|
|
1509
|
+
// Skip if already populated.
|
|
1510
|
+
if ((rangeCount.get(rangeI) ?? 0) >= 1) continue;
|
|
1511
|
+
|
|
1512
|
+
// Find source rows in this range that aren't already in sample.
|
|
1513
|
+
const sourceCandidates: LookupRow[] = [];
|
|
1514
|
+
for (const r of srcSmallNonZero) {
|
|
1515
|
+
if (r.payoutCents >= lowCents && r.payoutCents < highCents && !inSample.has(r.sim)) {
|
|
1516
|
+
sourceCandidates.push(r);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (sourceCandidates.length === 0) {
|
|
1520
|
+
unfillable++;
|
|
1521
|
+
const rangeStr =
|
|
1522
|
+
high === Infinity ? `[${low}, infinity)` : `[${low}, ${high})`;
|
|
1523
|
+
warnings.push(
|
|
1524
|
+
`gap in hit-rate range ${rangeStr}x bet: source has no rows in this payout-multiplier range`,
|
|
1525
|
+
);
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Pick source row closest to range geometric mid so any subsequent
|
|
1530
|
+
// statistic sliding remains balanced.
|
|
1531
|
+
const midPayout =
|
|
1532
|
+
high === Infinity
|
|
1533
|
+
? Math.max(lowCents, maxPayoutCents)
|
|
1534
|
+
: Math.sqrt(lowCents * highCents);
|
|
1535
|
+
let swapInRow = sourceCandidates[0];
|
|
1536
|
+
let bestDist = Math.abs(swapInRow.payoutCents - midPayout);
|
|
1537
|
+
for (const r of sourceCandidates) {
|
|
1538
|
+
const d = Math.abs(r.payoutCents - midPayout);
|
|
1539
|
+
if (d < bestDist) {
|
|
1540
|
+
swapInRow = r;
|
|
1541
|
+
bestDist = d;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Pick output row to remove: payout closest to swapInRow.payoutCents so
|
|
1546
|
+
// Σ-payout drift (i.e. RTP impact) is minimized. Skip any row whose
|
|
1547
|
+
// removal would empty another range.
|
|
1548
|
+
let removeIdx = -1;
|
|
1549
|
+
let removeDist = Infinity;
|
|
1550
|
+
for (let i = 0; i < outSmallNonZero.length; i++) {
|
|
1551
|
+
if ((rangeCount.get(rangeIdx[i]) ?? 0) <= 1) continue; // protect other ranges
|
|
1552
|
+
const r = outSmallNonZero[i];
|
|
1553
|
+
const d = Math.abs(r.payoutCents - swapInRow.payoutCents);
|
|
1554
|
+
if (d < removeDist) {
|
|
1555
|
+
removeDist = d;
|
|
1556
|
+
removeIdx = i;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (removeIdx < 0) {
|
|
1560
|
+
// No safe removal candidate — every range has exactly 1 row. Skip
|
|
1561
|
+
// this gap rather than break other ranges.
|
|
1562
|
+
unfillable++;
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Apply the swap.
|
|
1567
|
+
const removedRow = outSmallNonZero[removeIdx];
|
|
1568
|
+
const removedRangeIdx = rangeIdx[removeIdx];
|
|
1569
|
+
inSample.delete(removedRow.sim);
|
|
1570
|
+
inSample.add(swapInRow.sim);
|
|
1571
|
+
// Remove the old row, then re-insert swapInRow at the correct sorted
|
|
1572
|
+
// position to preserve the ascending invariant. Also update rangeIdx
|
|
1573
|
+
// and rangeCount.
|
|
1574
|
+
outSmallNonZero.splice(removeIdx, 1);
|
|
1575
|
+
rangeIdx.splice(removeIdx, 1);
|
|
1576
|
+
rangeCount.set(removedRangeIdx, (rangeCount.get(removedRangeIdx) ?? 1) - 1);
|
|
1577
|
+
|
|
1578
|
+
let insertPos = 0;
|
|
1579
|
+
while (
|
|
1580
|
+
insertPos < outSmallNonZero.length &&
|
|
1581
|
+
outSmallNonZero[insertPos].payoutCents < swapInRow.payoutCents
|
|
1582
|
+
) {
|
|
1583
|
+
insertPos++;
|
|
1584
|
+
}
|
|
1585
|
+
outSmallNonZero.splice(insertPos, 0, swapInRow);
|
|
1586
|
+
rangeIdx.splice(insertPos, 0, rangeI);
|
|
1587
|
+
rangeCount.set(rangeI, (rangeCount.get(rangeI) ?? 0) + 1);
|
|
1588
|
+
|
|
1589
|
+
swapsApplied++;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
return { swapsApplied, unfillable };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** First index `i` with `arr[i] >= target` (number-array variant). */
|
|
1596
|
+
function lowerBoundNum(arr: ReadonlyArray<number>, target: number): number {
|
|
1597
|
+
let lo = 0;
|
|
1598
|
+
let hi = arr.length;
|
|
1599
|
+
while (lo < hi) {
|
|
1600
|
+
const mid = (lo + hi) >>> 1;
|
|
1601
|
+
if (arr[mid] < target) lo = mid + 1;
|
|
1602
|
+
else hi = mid;
|
|
1603
|
+
}
|
|
1604
|
+
return lo;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* 5th refinement pass: swap duplicate-payout rows for source rows with NEW
|
|
1609
|
+
* payout values until output has ≥ targetUnique distinct payoutCents. Source
|
|
1610
|
+
* provides candidate rows whose payoutCents is NOT currently in output.
|
|
1611
|
+
*
|
|
1612
|
+
* Each swap is constrained to keep Σ_smallNz drift ≤ remainingSumBudget. Picks
|
|
1613
|
+
* the swap-in payout closest to swap-out's payout to minimize RTP/CV impact.
|
|
1614
|
+
*
|
|
1615
|
+
* Updates `outSmallNonZero` in place. Returns the number of swaps applied,
|
|
1616
|
+
* achieved unique count across (otherOutRows ∪ outSmallNonZero), and whether
|
|
1617
|
+
* the target was reached.
|
|
1618
|
+
*/
|
|
1619
|
+
function diversifyPayouts(
|
|
1620
|
+
outSmallNonZero: LookupRow[],
|
|
1621
|
+
srcSmallNonZero: ReadonlyArray<LookupRow>,
|
|
1622
|
+
otherOutRows: ReadonlyArray<LookupRow>,
|
|
1623
|
+
targetUnique: number,
|
|
1624
|
+
remainingSumBudget: number,
|
|
1625
|
+
warnings: string[],
|
|
1626
|
+
): { swaps: number; achievedUnique: number; reached: boolean } {
|
|
1627
|
+
// Build the current set of payouts in output AND in-sample sim ids.
|
|
1628
|
+
// `payoutToOutRows` indexes positions in outSmallNonZero by their current
|
|
1629
|
+
// payoutCents value; this lets us locate "any row with payout p" in O(1)
|
|
1630
|
+
// and update incrementally on each swap (no array splice / re-scan).
|
|
1631
|
+
const inOutputPayouts = new Map<number, number>(); // payoutCents → count across all tiers
|
|
1632
|
+
const payoutToOutRows = new Map<number, Set<number>>(); // payoutCents → outSmallNonZero indices
|
|
1633
|
+
const inSampleSims = new Set<number>();
|
|
1634
|
+
for (const r of otherOutRows) {
|
|
1635
|
+
inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
|
|
1636
|
+
}
|
|
1637
|
+
for (let i = 0; i < outSmallNonZero.length; i++) {
|
|
1638
|
+
const r = outSmallNonZero[i];
|
|
1639
|
+
inOutputPayouts.set(r.payoutCents, (inOutputPayouts.get(r.payoutCents) ?? 0) + 1);
|
|
1640
|
+
inSampleSims.add(r.sim);
|
|
1641
|
+
let s = payoutToOutRows.get(r.payoutCents);
|
|
1642
|
+
if (!s) {
|
|
1643
|
+
s = new Set<number>();
|
|
1644
|
+
payoutToOutRows.set(r.payoutCents, s);
|
|
1645
|
+
}
|
|
1646
|
+
s.add(i);
|
|
1647
|
+
}
|
|
1648
|
+
let uniqueNow = inOutputPayouts.size;
|
|
1649
|
+
|
|
1650
|
+
// Index source rows by payoutCents → first available LookupRow (only those
|
|
1651
|
+
// NOT already in output and not in sample).
|
|
1652
|
+
const newPayoutsAvailable = new Map<number, LookupRow>();
|
|
1653
|
+
for (const r of srcSmallNonZero) {
|
|
1654
|
+
if (inOutputPayouts.has(r.payoutCents)) continue;
|
|
1655
|
+
if (inSampleSims.has(r.sim)) continue;
|
|
1656
|
+
if (!newPayoutsAvailable.has(r.payoutCents)) {
|
|
1657
|
+
newPayoutsAvailable.set(r.payoutCents, r);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
// Sorted list of new payout values for nearest-neighbor binary search.
|
|
1661
|
+
const newPayoutsSorted = Array.from(newPayoutsAvailable.keys()).sort((a, b) => a - b);
|
|
1662
|
+
|
|
1663
|
+
// Maintain the set of payoutCents values that have >= 2 rows in outSmallNonZero
|
|
1664
|
+
// (these are the swap-out candidates — losing one of them keeps the payout
|
|
1665
|
+
// represented at least once, so we don't drop a unique-value entirely unless
|
|
1666
|
+
// a copy exists in cap/large/zero tiers).
|
|
1667
|
+
const dupPayouts = new Set<number>();
|
|
1668
|
+
for (const [p, rows] of payoutToOutRows) {
|
|
1669
|
+
// payout is a "safe" swap-out source if the cross-tier count is >= 2
|
|
1670
|
+
// (removing one row still leaves the payout in output somewhere).
|
|
1671
|
+
if ((inOutputPayouts.get(p) ?? 0) >= 2 && rows.size >= 1) dupPayouts.add(p);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
if (newPayoutsSorted.length === 0) {
|
|
1675
|
+
if (uniqueNow < targetUnique) {
|
|
1676
|
+
warnings.push(
|
|
1677
|
+
`minUniqueEventsRate target ${targetUnique} unreachable: source has no distinct payout values not already in output (current ${uniqueNow})`,
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
return { swaps: 0, achievedUnique: uniqueNow, reached: uniqueNow >= targetUnique };
|
|
1681
|
+
}
|
|
1682
|
+
if (dupPayouts.size === 0) {
|
|
1683
|
+
if (uniqueNow < targetUnique) {
|
|
1684
|
+
warnings.push(
|
|
1685
|
+
`minUniqueEventsRate target ${targetUnique} unreachable: every small-non-zero row already has a unique payout (current ${uniqueNow})`,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
return { swaps: 0, achievedUnique: uniqueNow, reached: uniqueNow >= targetUnique };
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
let swaps = 0;
|
|
1692
|
+
// sumBudget bounds |running Σ-drift|, NOT a per-swap cost. Each swap can be
|
|
1693
|
+
// +Δ or −Δ depending on whether the new payout is higher or lower than the
|
|
1694
|
+
// old one; the running drift stays in [−sumBudget, +sumBudget]. This lets
|
|
1695
|
+
// up-swaps and down-swaps cancel each other so the pass keeps going long
|
|
1696
|
+
// after a one-directional budget would have exhausted.
|
|
1697
|
+
let sumBudget = remainingSumBudget;
|
|
1698
|
+
let runningDrift = 0;
|
|
1699
|
+
let exhaustedReason: 'budget' | 'sourceOrAllocation' | null = null;
|
|
1700
|
+
|
|
1701
|
+
// Maximize unique payouts — minUniqueEventsRate is a FLOOR, not a cap. Loop
|
|
1702
|
+
// until no further beneficial swap is available.
|
|
1703
|
+
//
|
|
1704
|
+
// Strategy: at each iteration scan dupPayouts; for each, examine its
|
|
1705
|
+
// sorted-list neighbours (signed delta = newPayout − oldPayout) and pick
|
|
1706
|
+
// the swap that brings |runningDrift + delta| closest to 0 (subject to
|
|
1707
|
+
// staying inside the ±sumBudget band). Up-deltas and down-deltas balance
|
|
1708
|
+
// each other across iterations, so the pass scales with the size of the
|
|
1709
|
+
// dup-pool, not with the per-pass drift budget.
|
|
1710
|
+
while (newPayoutsSorted.length > 0 && dupPayouts.size > 0) {
|
|
1711
|
+
let pickP = -1;
|
|
1712
|
+
let pickNewP = -1;
|
|
1713
|
+
let pickDelta = 0;
|
|
1714
|
+
let pickNewAbsDrift = Infinity; // |runningDrift + delta| for the chosen swap
|
|
1715
|
+
for (const p of dupPayouts) {
|
|
1716
|
+
const rows = payoutToOutRows.get(p);
|
|
1717
|
+
if (!rows || rows.size === 0) continue; // stale entry
|
|
1718
|
+
const ins = lowerBoundNum(newPayoutsSorted, p);
|
|
1719
|
+
for (const idx of [ins - 1, ins, ins + 1]) {
|
|
1720
|
+
if (idx < 0 || idx >= newPayoutsSorted.length) continue;
|
|
1721
|
+
const np = newPayoutsSorted[idx];
|
|
1722
|
+
const delta = np - p;
|
|
1723
|
+
const newDrift = runningDrift + delta;
|
|
1724
|
+
if (Math.abs(newDrift) > sumBudget) continue;
|
|
1725
|
+
const newAbs = Math.abs(newDrift);
|
|
1726
|
+
if (newAbs < pickNewAbsDrift) {
|
|
1727
|
+
pickNewAbsDrift = newAbs;
|
|
1728
|
+
pickP = p;
|
|
1729
|
+
pickNewP = np;
|
|
1730
|
+
pickDelta = delta;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (pickP < 0) {
|
|
1735
|
+
// No swap fits in the budget band. The only way to make progress would
|
|
1736
|
+
// be to first move runningDrift back toward 0, but every candidate we
|
|
1737
|
+
// just rejected already failed; we're stuck.
|
|
1738
|
+
exhaustedReason = 'budget';
|
|
1739
|
+
break;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const rowsForP = payoutToOutRows.get(pickP)!;
|
|
1743
|
+
if (rowsForP.size === 0) {
|
|
1744
|
+
// Stale entry — clean and retry.
|
|
1745
|
+
dupPayouts.delete(pickP);
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const swapOutIdx = rowsForP.values().next().value as number;
|
|
1749
|
+
const swapOutRow = outSmallNonZero[swapOutIdx];
|
|
1750
|
+
const swapOutP = pickP;
|
|
1751
|
+
const bestNewP = pickNewP;
|
|
1752
|
+
|
|
1753
|
+
const swapInRow = newPayoutsAvailable.get(bestNewP);
|
|
1754
|
+
if (!swapInRow) {
|
|
1755
|
+
// Defensive: shouldn't happen because newPayoutsSorted mirrors
|
|
1756
|
+
// newPayoutsAvailable. Remove the stale entry and retry.
|
|
1757
|
+
const removeAt = lowerBoundNum(newPayoutsSorted, bestNewP);
|
|
1758
|
+
if (removeAt < newPayoutsSorted.length && newPayoutsSorted[removeAt] === bestNewP) {
|
|
1759
|
+
newPayoutsSorted.splice(removeAt, 1);
|
|
1760
|
+
}
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// Apply swap IN-PLACE — overwrite at the same array slot so existing
|
|
1765
|
+
// indices in payoutToOutRows remain stable.
|
|
1766
|
+
outSmallNonZero[swapOutIdx] = swapInRow;
|
|
1767
|
+
inSampleSims.delete(swapOutRow.sim);
|
|
1768
|
+
inSampleSims.add(swapInRow.sim);
|
|
1769
|
+
|
|
1770
|
+
// Update payoutToOutRows / dupPayouts for the OLD payout.
|
|
1771
|
+
rowsForP.delete(swapOutIdx);
|
|
1772
|
+
const oldCount = inOutputPayouts.get(swapOutP) ?? 0;
|
|
1773
|
+
if (oldCount <= 1) {
|
|
1774
|
+
inOutputPayouts.delete(swapOutP);
|
|
1775
|
+
uniqueNow--;
|
|
1776
|
+
} else {
|
|
1777
|
+
inOutputPayouts.set(swapOutP, oldCount - 1);
|
|
1778
|
+
}
|
|
1779
|
+
if (rowsForP.size === 0) payoutToOutRows.delete(swapOutP);
|
|
1780
|
+
// After decrement, the cross-tier count may have fallen below 2 — then this
|
|
1781
|
+
// payout is no longer a safe swap-out source.
|
|
1782
|
+
if ((inOutputPayouts.get(swapOutP) ?? 0) < 2) dupPayouts.delete(swapOutP);
|
|
1783
|
+
|
|
1784
|
+
// Update for the NEW payout (now in output at index swapOutIdx).
|
|
1785
|
+
inOutputPayouts.set(bestNewP, (inOutputPayouts.get(bestNewP) ?? 0) + 1);
|
|
1786
|
+
let newRowsForP = payoutToOutRows.get(bestNewP);
|
|
1787
|
+
if (!newRowsForP) {
|
|
1788
|
+
newRowsForP = new Set<number>();
|
|
1789
|
+
payoutToOutRows.set(bestNewP, newRowsForP);
|
|
1790
|
+
}
|
|
1791
|
+
newRowsForP.add(swapOutIdx);
|
|
1792
|
+
uniqueNow++;
|
|
1793
|
+
|
|
1794
|
+
// bestNewP consumed: remove it from the available pool / sorted list.
|
|
1795
|
+
newPayoutsAvailable.delete(bestNewP);
|
|
1796
|
+
const removeAt = lowerBoundNum(newPayoutsSorted, bestNewP);
|
|
1797
|
+
if (removeAt < newPayoutsSorted.length && newPayoutsSorted[removeAt] === bestNewP) {
|
|
1798
|
+
newPayoutsSorted.splice(removeAt, 1);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
runningDrift += pickDelta;
|
|
1802
|
+
swaps++;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
if (uniqueNow < targetUnique) {
|
|
1806
|
+
if (exhaustedReason === 'budget') {
|
|
1807
|
+
warnings.push(
|
|
1808
|
+
`minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): RTP-drift budget exhausted`,
|
|
1809
|
+
);
|
|
1810
|
+
} else if (newPayoutsSorted.length === 0) {
|
|
1811
|
+
warnings.push(
|
|
1812
|
+
`minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): source has no more distinct payouts available`,
|
|
1813
|
+
);
|
|
1814
|
+
} else if (dupPayouts.size === 0) {
|
|
1815
|
+
warnings.push(
|
|
1816
|
+
`minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow}): every output row already has a unique payout in small-non-zero tier`,
|
|
1817
|
+
);
|
|
1818
|
+
} else {
|
|
1819
|
+
warnings.push(
|
|
1820
|
+
`minUniqueEventsRate target ${targetUnique} not reached (achieved ${uniqueNow})`,
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Final sort of outSmallNonZero by payoutCents so downstream stages see a
|
|
1826
|
+
// tidy ordering (the in-place overwrites preserved indices but scrambled the
|
|
1827
|
+
// payout order).
|
|
1828
|
+
outSmallNonZero.sort((a, b) => a.payoutCents - b.payoutCents);
|
|
1829
|
+
|
|
1830
|
+
return { swaps, achievedUnique: uniqueNow, reached: uniqueNow >= targetUnique };
|
|
1831
|
+
}
|
|
1832
|
+
|