@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.
@@ -11,6 +11,18 @@ import { bucketize } from './bucketize.js';
11
11
  import { mulberry32, computeQuotas, stratifiedSample } from './sample.js';
12
12
  import { solveNNLS } from './nnls.js';
13
13
  import { quantizeWeights } from './quantize.js';
14
+ import { buildTieredLookup } from './tiered.js';
15
+ import { computeStakeReport, detectHitRateGaps } from './stake-report.js';
16
+
17
+ function emitGapWarning(stakeReport: ReturnType<typeof computeStakeReport>, warnings: string[]): void {
18
+ const gaps = detectHitRateGaps(stakeReport.hitRateDistribution);
19
+ if (gaps.length > 0) {
20
+ const formatted = gaps.map((g) => `[${g.low}, ${g.high})`).join(', ');
21
+ warnings.push(
22
+ `hit-rate distribution has ${gaps.length} intermediate gap(s) — Stake "Gaps in the Hit Rate Table" check may fail: ${formatted}`,
23
+ );
24
+ }
25
+ }
14
26
 
15
27
  const DEFAULTS = {
16
28
  requireMaxReached: true,
@@ -20,12 +32,20 @@ const DEFAULTS = {
20
32
  maxIterations: 5,
21
33
  bucketCount: 100,
22
34
  minPerBucket: 3,
35
+ maxRowRtpShare: 0.05,
36
+ maxWeightPerRow: 10,
37
+ betCostCents: 100,
23
38
  };
24
39
 
25
40
  export function optimizeLookupTable(
26
41
  rowsIn: Iterable<LookupRow>,
27
42
  params: OptimizeParams,
28
43
  ): OptimizeResult {
44
+ const algorithm = params.algorithm ?? 'tiered';
45
+ if (algorithm === 'tiered') {
46
+ return buildTieredLookup(rowsIn, params);
47
+ }
48
+
29
49
  const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
30
50
  const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
31
51
  const totalWeightOut = params.totalWeightOut ?? params.nRowsOut * DEFAULTS.totalWeightOutPerRow;
@@ -33,6 +53,9 @@ export function optimizeLookupTable(
33
53
  const maxIterations = params.maxIterations ?? DEFAULTS.maxIterations;
34
54
  const bucketCount = params.bucketCount ?? DEFAULTS.bucketCount;
35
55
  let minPerBucket = params.minPerBucket ?? DEFAULTS.minPerBucket;
56
+ const maxRowRtpShare = params.maxRowRtpShare ?? DEFAULTS.maxRowRtpShare;
57
+ const maxWeightPerRow = params.maxWeightPerRow ?? DEFAULTS.maxWeightPerRow;
58
+ const betCostCents = params.betCostCents ?? DEFAULTS.betCostCents;
36
59
 
37
60
  const warnings: string[] = [];
38
61
 
@@ -67,7 +90,17 @@ export function optimizeLookupTable(
67
90
  }
68
91
 
69
92
  // ── Phases 2–6: try, expand, retry ────────────────────────────────────────────
70
- let best: { rows: LookupRow[]; achieved: OptimizeAchieved; toleranceMet: ToleranceMet; lossSum: number } | null = null;
93
+ let best:
94
+ | {
95
+ rows: LookupRow[];
96
+ achieved: OptimizeAchieved;
97
+ toleranceMet: ToleranceMet;
98
+ maxRowShare: number;
99
+ maxWeightRatio: number;
100
+ lossSum: number;
101
+ capWarning?: string;
102
+ }
103
+ | null = null;
71
104
 
72
105
  for (let iter = 0; iter < maxIterations; iter++) {
73
106
  const rng = mulberry32(seed + iter);
@@ -140,8 +173,219 @@ export function optimizeLookupTable(
140
173
  muHat = newMu;
141
174
  }
142
175
 
176
+ // ── Iterative RTP-share + per-row weight cap (Stake Engine "Within Liability Limits") ─
177
+ //
178
+ // After NNLS converges, one or a few rows may dominate the total RTP, or a single
179
+ // row may absorb enormous weight (zero-payout or near-zero-payout filler rows used
180
+ // to satisfy hit-rate / total-weight constraints cheaply). Stake Engine rejects
181
+ // tables where a single row carries an oversized share of expected return OR
182
+ // oversized weight (the Expected Tail Liability check). We iteratively cap any
183
+ // violating row and re-solve the (smaller) NNLS problem on the remaining rows
184
+ // until no violator remains or the iteration budget is exhausted.
185
+ const maxAllowedWeight = Number.isFinite(maxWeightPerRow)
186
+ ? maxWeightPerRow * (totalWeightOut / candidates.length)
187
+ : Infinity;
188
+ const fixedWeight = new Map<number, number>(); // candidate index → fixed weight
189
+ let capIters = 0;
190
+ const maxCapIters = 50;
191
+ let capConverged = false;
192
+
193
+ while (capIters++ < maxCapIters) {
194
+ // Compute current total w·p (including fixed contributions)
195
+ let totalWP = 0;
196
+ for (let i = 0; i < candidates.length; i++) {
197
+ const w = fixedWeight.has(i) ? fixedWeight.get(i)! : weights[i];
198
+ totalWP += w * candidates[i].payoutCents;
199
+ }
200
+
201
+ // Find violators (only among non-fixed rows). We check BOTH the RTP-share
202
+ // cap and the absolute per-row weight cap; either constraint suffices.
203
+ const violators: number[] = [];
204
+ for (let i = 0; i < candidates.length; i++) {
205
+ if (fixedWeight.has(i)) continue;
206
+ const w = weights[i];
207
+ const p = candidates[i].payoutCents;
208
+ const exceedsRtpShare =
209
+ totalWP > 0 && (w * p) / totalWP > maxRowRtpShare;
210
+ const exceedsWeight = w > maxAllowedWeight;
211
+ if (exceedsRtpShare || exceedsWeight) violators.push(i);
212
+ }
213
+ if (violators.length === 0) {
214
+ capConverged = true;
215
+ break;
216
+ }
217
+
218
+ // Cap each violator at the TIGHTEST applicable bound: RTP-share-derived
219
+ // limit (only meaningful for nonzero-payout rows) intersected with the
220
+ // absolute weight cap. Truncate to integer.
221
+ for (const i of violators) {
222
+ const p = candidates[i].payoutCents;
223
+ let cap = maxAllowedWeight;
224
+ if (p > 0 && totalWP > 0) {
225
+ const rtpCap = (maxRowRtpShare * totalWP) / p;
226
+ if (rtpCap < cap) cap = rtpCap;
227
+ }
228
+ const cappedW = Math.max(1, Math.floor(cap));
229
+ fixedWeight.set(i, cappedW);
230
+ }
231
+
232
+ // Re-run NNLS on remaining (non-fixed) candidates
233
+ const remainingIdx: number[] = [];
234
+ for (let i = 0; i < candidates.length; i++) {
235
+ if (!fixedWeight.has(i)) remainingIdx.push(i);
236
+ }
237
+ if (remainingIdx.length < 4) break; // not enough rows to solve
238
+
239
+ // Compute fixed contributions to subtract from b
240
+ let fixedW_RTP = 0;
241
+ let fixedW_CV = 0;
242
+ let fixedW_HR = 0;
243
+ let fixedW_Sum = 0;
244
+ for (const [idx, w] of fixedWeight) {
245
+ const p = candidates[idx].payoutCents;
246
+ fixedW_RTP += (w * p) / params.toleranceRTP;
247
+ fixedW_CV +=
248
+ (w * Math.pow(p - muHat, 2)) / Math.max(1, params.toleranceCV * muHat * muHat);
249
+ fixedW_HR += (w * (p > 0 ? 1 : 0)) / params.toleranceHitRate;
250
+ fixedW_Sum += w / 1e-6;
251
+ }
252
+
253
+ // Build reduced A, b
254
+ const remCandidates = remainingIdx.map((i) => candidates[i]);
255
+ const A_r: number[][] = [
256
+ remCandidates.map((r) => r.payoutCents / params.toleranceRTP),
257
+ remCandidates.map(
258
+ (r) =>
259
+ Math.pow(r.payoutCents - muHat, 2) /
260
+ Math.max(1, params.toleranceCV * muHat * muHat),
261
+ ),
262
+ remCandidates.map((r) => (r.payoutCents > 0 ? 1 : 0) / params.toleranceHitRate),
263
+ remCandidates.map(() => 1 / 1e-6),
264
+ ];
265
+ const b_r = [
266
+ (params.targetRTP * totalWeightOut * 100) / params.toleranceRTP - fixedW_RTP,
267
+ (Math.pow(params.targetCV * muHat, 2) * totalWeightOut) /
268
+ Math.max(1, params.toleranceCV * muHat * muHat) -
269
+ fixedW_CV,
270
+ (params.targetHitRate * totalWeightOut) / params.toleranceHitRate - fixedW_HR,
271
+ totalWeightOut / 1e-6 - fixedW_Sum,
272
+ ];
273
+
274
+ let fixedTotalW = 0;
275
+ for (const w of fixedWeight.values()) fixedTotalW += w;
276
+ const remainingFreeWeight = Math.max(0, totalWeightOut - fixedTotalW);
277
+ const remPrior = new Array(remCandidates.length).fill(
278
+ Math.max(1, remainingFreeWeight / remCandidates.length),
279
+ );
280
+ const newSol = solveNNLS(A_r, b_r, {
281
+ prior: remPrior,
282
+ regularization: 1e-6,
283
+ maxIterations: 200,
284
+ });
285
+
286
+ // Splice back into the full weights array
287
+ for (let k = 0; k < remainingIdx.length; k++) {
288
+ weights[remainingIdx[k]] = Math.max(0, newSol[k]);
289
+ }
290
+ for (const [idx, w] of fixedWeight) {
291
+ weights[idx] = w;
292
+ }
293
+ }
294
+
295
+ const capWarning =
296
+ !capConverged && fixedWeight.size > 0
297
+ ? `maxRowRtpShare / maxWeightPerRow cap could not converge in ${maxCapIters} iterations`
298
+ : undefined;
299
+
143
300
  // Quantize
144
301
  const quantized = quantizeWeights(weights, totalWeightOut);
302
+
303
+ // Post-quantize weight-cap enforcement: largest-remainder quantization can
304
+ // redistribute integer mass onto any row, potentially pushing capped rows
305
+ // (or previously-uncapped rows) above maxAllowedWeight. Walk the array
306
+ // greedily: peel excess off over-cap rows and pour it onto rows below cap.
307
+ //
308
+ // Recipient preference order:
309
+ // 1. Zero-payout rows (safe — don't disturb RTP-share cap), ordered by
310
+ // smallest current weight first (preserve shape).
311
+ // 2. Non-zero-payout rows, ordered by largest RTP-share headroom first
312
+ // (i.e., lowest current rtpShare / payout ratio) so we minimize the
313
+ // risk of pushing a row past maxRowRtpShare.
314
+ if (Number.isFinite(maxAllowedWeight)) {
315
+ const intCap = Math.max(1, Math.floor(maxAllowedWeight));
316
+ let totalExcess = 0;
317
+ for (let i = 0; i < quantized.length; i++) {
318
+ if (quantized[i] > intCap) {
319
+ totalExcess += quantized[i] - intCap;
320
+ quantized[i] = intCap;
321
+ }
322
+ }
323
+ if (totalExcess > 0) {
324
+ // Recompute current totalWP for per-row RTP-share bookkeeping. We need
325
+ // an upper bound on what totalWP could become after redistribution:
326
+ // pouring excess onto non-zero rows can only grow totalWP. Use the
327
+ // pre-redistribution snapshot (conservative — gives smaller rtpCapWP)
328
+ // and apply a safety margin of 95% to leave headroom for quantization
329
+ // and totalWP drift during pouring.
330
+ let curTotalWP = 0;
331
+ for (let i = 0; i < quantized.length; i++) {
332
+ curTotalWP += quantized[i] * candidates[i].payoutCents;
333
+ }
334
+ const rtpCapWP = 0.95 * maxRowRtpShare * Math.max(curTotalWP, 1);
335
+
336
+ // Bucket 1: zero-payout rows.
337
+ const zeroRecipients: number[] = [];
338
+ // Bucket 2: non-zero-payout rows with RTP-share headroom.
339
+ const nonZeroRecipients: number[] = [];
340
+ for (let i = 0; i < quantized.length; i++) {
341
+ if (quantized[i] >= intCap) continue;
342
+ if (candidates[i].payoutCents === 0) {
343
+ zeroRecipients.push(i);
344
+ } else {
345
+ nonZeroRecipients.push(i);
346
+ }
347
+ }
348
+ zeroRecipients.sort((a, b) => quantized[a] - quantized[b]);
349
+ // Sort non-zero recipients by current w·p ascending (most headroom first).
350
+ nonZeroRecipients.sort(
351
+ (a, b) =>
352
+ quantized[a] * candidates[a].payoutCents -
353
+ quantized[b] * candidates[b].payoutCents,
354
+ );
355
+
356
+ const pour = (recipients: number[], respectRtpCap: boolean): void => {
357
+ for (const r of recipients) {
358
+ if (totalExcess === 0) return;
359
+ const headroom = intCap - quantized[r];
360
+ if (headroom <= 0) continue;
361
+ let give = Math.min(headroom, totalExcess);
362
+ if (respectRtpCap) {
363
+ const p = candidates[r].payoutCents;
364
+ if (p > 0) {
365
+ const curWP = quantized[r] * p;
366
+ const maxAddWP = rtpCapWP - curWP;
367
+ if (maxAddWP <= 0) continue;
368
+ const maxAddW = Math.floor(maxAddWP / p);
369
+ if (maxAddW <= 0) continue;
370
+ give = Math.min(give, maxAddW);
371
+ }
372
+ }
373
+ quantized[r] += give;
374
+ totalExcess -= give;
375
+ }
376
+ };
377
+
378
+ pour(zeroRecipients, false);
379
+ pour(nonZeroRecipients, true);
380
+ // If excess remains, fall back to any below-cap row (cap was infeasible
381
+ // for this nRowsOut / totalWeightOut combination). toleranceMet.weightCap
382
+ // computed below reflects the actual result.
383
+ if (totalExcess > 0) {
384
+ pour(nonZeroRecipients, false);
385
+ }
386
+ }
387
+ }
388
+
145
389
  const outRows: LookupRow[] = candidates.map((r, i) => ({
146
390
  sim: r.sim,
147
391
  weight: quantized[i],
@@ -149,6 +393,26 @@ export function optimizeLookupTable(
149
393
  }));
150
394
 
151
395
  const achieved = computeMetrics(outRows);
396
+
397
+ // Compute the max single-row RTP share from final quantized output
398
+ let totalWPOut = 0;
399
+ for (const r of outRows) totalWPOut += r.weight * r.payoutCents;
400
+ let maxRowShare = 0;
401
+ if (totalWPOut > 0) {
402
+ for (const r of outRows) {
403
+ const share = (r.weight * r.payoutCents) / totalWPOut;
404
+ if (share > maxRowShare) maxRowShare = share;
405
+ }
406
+ }
407
+
408
+ // Compute max single-row weight ratio (as a multiple of uniform prior).
409
+ const uniformPrior = totalWeightOut / outRows.length;
410
+ let maxWeightObs = 0;
411
+ for (const r of outRows) {
412
+ if (r.weight > maxWeightObs) maxWeightObs = r.weight;
413
+ }
414
+ const maxWeightRatio = uniformPrior > 0 ? maxWeightObs / uniformPrior : 0;
415
+
152
416
  const toleranceMet: ToleranceMet = {
153
417
  rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
154
418
  cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
@@ -156,6 +420,8 @@ export function optimizeLookupTable(
156
420
  maxReached:
157
421
  !requireMaxReached ||
158
422
  outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
423
+ rtpConcentration: maxRowShare <= maxRowRtpShare,
424
+ weightCap: maxWeightRatio <= maxWeightPerRow + 1e-6,
159
425
  };
160
426
 
161
427
  // Loss for "best so far" tracking — Σ tolerance-normalized squared misses
@@ -163,15 +429,45 @@ export function optimizeLookupTable(
163
429
  Math.pow((achieved.rtp - params.targetRTP) / params.toleranceRTP, 2) +
164
430
  Math.pow((achieved.cv - params.targetCV) / params.toleranceCV, 2) +
165
431
  Math.pow((achieved.hitRate - params.targetHitRate) / params.toleranceHitRate, 2) +
166
- (toleranceMet.maxReached ? 0 : 1000);
432
+ (toleranceMet.maxReached ? 0 : 1000) +
433
+ (toleranceMet.rtpConcentration ? 0 : 1000) +
434
+ (toleranceMet.weightCap ? 0 : 1000);
167
435
  if (!Number.isFinite(lossSum)) lossSum = Infinity;
168
436
 
169
437
  if (!best || lossSum < best.lossSum) {
170
- best = { rows: outRows, achieved, toleranceMet, lossSum };
438
+ best = {
439
+ rows: outRows,
440
+ achieved,
441
+ toleranceMet,
442
+ maxRowShare,
443
+ maxWeightRatio,
444
+ lossSum,
445
+ capWarning,
446
+ };
171
447
  }
172
448
 
173
- if (toleranceMet.rtp && toleranceMet.cv && toleranceMet.hitRate && toleranceMet.maxReached) {
174
- return { rows: outRows, achieved, toleranceMet, warnings };
449
+ if (
450
+ toleranceMet.rtp &&
451
+ toleranceMet.cv &&
452
+ toleranceMet.hitRate &&
453
+ toleranceMet.maxReached &&
454
+ toleranceMet.rtpConcentration &&
455
+ toleranceMet.weightCap
456
+ ) {
457
+ const iterWarnings = warnings.slice();
458
+ if (capWarning) iterWarnings.push(capWarning);
459
+ const successReport = computeStakeReport(outRows, achieved, betCostCents);
460
+ emitGapWarning(successReport, iterWarnings);
461
+ return {
462
+ rows: outRows,
463
+ achieved,
464
+ toleranceMet,
465
+ maxRowRtpShare: maxRowShare,
466
+ maxWeightRatio,
467
+ refinement: { rtpSwaps: 0, cvSwaps: 0, gapFillSwaps: 0, gapsUnfillable: 0, diversifySwaps: 0 },
468
+ warnings: iterWarnings,
469
+ stakeReport: successReport,
470
+ };
175
471
  }
176
472
  }
177
473
 
@@ -196,6 +492,28 @@ export function optimizeLookupTable(
196
492
  if (!best.toleranceMet.maxReached) {
197
493
  warnings.push(`requireMaxReached=true but no near-max row in output`);
198
494
  }
495
+ if (!best.toleranceMet.rtpConcentration) {
496
+ warnings.push(
497
+ `maxRowRtpShare exceeded: ${(best.maxRowShare * 100).toFixed(2)}% > ${(maxRowRtpShare * 100).toFixed(2)}%`,
498
+ );
499
+ }
500
+ if (!best.toleranceMet.weightCap) {
501
+ warnings.push(
502
+ `maxWeightPerRow exceeded: max weight ratio ${best.maxWeightRatio.toFixed(2)} > ${maxWeightPerRow} × uniform prior`,
503
+ );
504
+ }
505
+ if (best.capWarning) warnings.push(best.capWarning);
199
506
 
200
- return { rows: best.rows, achieved: best.achieved, toleranceMet: best.toleranceMet, warnings };
507
+ const bestReport = computeStakeReport(best.rows, best.achieved, betCostCents);
508
+ emitGapWarning(bestReport, warnings);
509
+ return {
510
+ rows: best.rows,
511
+ achieved: best.achieved,
512
+ toleranceMet: best.toleranceMet,
513
+ maxRowRtpShare: best.maxRowShare,
514
+ maxWeightRatio: best.maxWeightRatio,
515
+ refinement: { rtpSwaps: 0, cvSwaps: 0, gapFillSwaps: 0, gapsUnfillable: 0, diversifySwaps: 0 },
516
+ warnings,
517
+ stakeReport: bestReport,
518
+ };
201
519
  }
@@ -0,0 +1,145 @@
1
+ import type { LookupRow, OptimizeAchieved, StakeReport, TopKShare, HitRateBucket } from './types.js';
2
+
3
+ /**
4
+ * Stake's hit-rate distribution table boundaries (payout multipliers).
5
+ * Mirrors the ranges shown in Stake Engine's publish UI under
6
+ * "Hit-Rate Ranges". Stake flags any intermediate empty range as a gap.
7
+ *
8
+ * Note: Stake displays the first range as `[0, 0.1)` (closed-open) — this
9
+ * captures zero-payout rows. All other ranges are `[low, high)` here for
10
+ * consistency; the last entry is `[20000, ∞)`.
11
+ */
12
+ export const HIT_RATE_RANGES: ReadonlyArray<readonly [number, number]> = [
13
+ [0, 0.1],
14
+ [0.1, 1],
15
+ [1, 2],
16
+ [2, 5],
17
+ [5, 10],
18
+ [10, 20],
19
+ [20, 50],
20
+ [50, 100],
21
+ [100, 200],
22
+ [200, 500],
23
+ [500, 1000],
24
+ [1000, 2000],
25
+ [2000, 5000],
26
+ [5000, 10000],
27
+ [10000, 20000],
28
+ [20000, Infinity],
29
+ ];
30
+
31
+ /**
32
+ * Compute the full Stake-compatible report from a finalized lookup table.
33
+ * Single source of truth for both tier-based and NNLS-based outputs.
34
+ */
35
+ export function computeStakeReport(
36
+ outRows: ReadonlyArray<LookupRow>,
37
+ achieved: OptimizeAchieved,
38
+ betCostCents: number,
39
+ ): StakeReport {
40
+ const threshold5K = 5000 * betCostCents;
41
+ const threshold10K = 10000 * betCostCents;
42
+
43
+ let w5K = 0n;
44
+ let w10K = 0n;
45
+ let wTotal = 0n;
46
+ const uniquePayouts = new Set<number>();
47
+ for (const r of outRows) {
48
+ const w = BigInt(r.weight);
49
+ wTotal += w;
50
+ if (r.payoutCents >= threshold5K) w5K += w;
51
+ if (r.payoutCents >= threshold10K) w10K += w;
52
+ uniquePayouts.add(r.payoutCents);
53
+ }
54
+ const prob5K = wTotal > 0n ? Number(w5K) / Number(wTotal) : 0;
55
+ const prob10K = wTotal > 0n ? Number(w10K) / Number(wTotal) : 0;
56
+
57
+ // Top-K cumulative RTP shares (by w·payout descending)
58
+ const wpEntries = outRows.map((r) => r.weight * r.payoutCents);
59
+ let totalWP = 0;
60
+ for (const v of wpEntries) totalWP += v;
61
+ const sortedWP = wpEntries.slice().sort((a, b) => b - a);
62
+ const topKShare: TopKShare[] = [];
63
+ const Ks = [1, 5, 10, 100];
64
+ let cum = 0;
65
+ let kIdx = 0;
66
+ for (let i = 0; i < sortedWP.length; i++) {
67
+ cum += sortedWP[i];
68
+ while (kIdx < Ks.length && i + 1 === Ks[kIdx]) {
69
+ topKShare.push({ k: Ks[kIdx], share: totalWP > 0 ? cum / totalWP : 0 });
70
+ kIdx++;
71
+ }
72
+ if (kIdx >= Ks.length) break;
73
+ }
74
+ while (kIdx < Ks.length) {
75
+ topKShare.push({ k: Ks[kIdx], share: totalWP > 0 ? cum / totalWP : 0 });
76
+ kIdx++;
77
+ }
78
+
79
+ // Hit-rate distribution table.
80
+ // pm (payout multiplier) = payoutCents / betCostCents. Range [low, high).
81
+ const counts = new Array<number>(HIT_RATE_RANGES.length).fill(0);
82
+ const weights = new Array<bigint>(HIT_RATE_RANGES.length).fill(0n);
83
+ for (const r of outRows) {
84
+ const pm = r.payoutCents / betCostCents;
85
+ for (let i = 0; i < HIT_RATE_RANGES.length; i++) {
86
+ const [low, high] = HIT_RATE_RANGES[i];
87
+ if (pm >= low && pm < high) {
88
+ counts[i]++;
89
+ weights[i] += BigInt(r.weight);
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ const totalWeightNum = Number(wTotal);
95
+ const hitRateDistribution: HitRateBucket[] = HIT_RATE_RANGES.map(([low, high], i) => ({
96
+ low,
97
+ high,
98
+ count: counts[i],
99
+ effectiveHitRate: totalWeightNum > 0 ? Number(weights[i]) / totalWeightNum : 0,
100
+ }));
101
+
102
+ return {
103
+ payoutMultMax: achieved.maxPayout / betCostCents,
104
+ baseStd: (achieved.cv * achieved.rtp * 100) / betCostCents,
105
+ prob5K,
106
+ prob10K,
107
+ topKShare,
108
+ hitRateDistribution,
109
+ uniqueEvents: uniquePayouts.size,
110
+ betCostCents,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Returns the [low, high) ranges that are EMPTY but lie BETWEEN two non-empty
116
+ * ranges. These are the "intermediate gaps" Stake's "Gaps in the Hit Rate
117
+ * Table" check flags. Empty ranges above the highest non-empty range are
118
+ * natural (the source distribution doesn't reach that far) and are not gaps.
119
+ */
120
+ export function detectHitRateGaps(
121
+ hitRateDistribution: ReadonlyArray<{ low: number; high: number; count: number }>,
122
+ ): Array<{ low: number; high: number }> {
123
+ // Find the index of the last non-empty range.
124
+ let lastNonEmpty = -1;
125
+ for (let i = hitRateDistribution.length - 1; i >= 0; i--) {
126
+ if (hitRateDistribution[i].count > 0) {
127
+ lastNonEmpty = i;
128
+ break;
129
+ }
130
+ }
131
+ if (lastNonEmpty < 0) return [];
132
+
133
+ const gaps: Array<{ low: number; high: number }> = [];
134
+ let seenNonEmpty = false;
135
+ for (let i = 0; i <= lastNonEmpty; i++) {
136
+ const b = hitRateDistribution[i];
137
+ if (b.count > 0) {
138
+ seenNonEmpty = true;
139
+ } else if (seenNonEmpty) {
140
+ gaps.push({ low: b.low, high: b.high });
141
+ }
142
+ }
143
+ return gaps;
144
+ }
145
+