@energy8platform/stake-math-tools 0.4.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,
@@ -21,12 +33,19 @@ const DEFAULTS = {
21
33
  bucketCount: 100,
22
34
  minPerBucket: 3,
23
35
  maxRowRtpShare: 0.05,
36
+ maxWeightPerRow: 10,
37
+ betCostCents: 100,
24
38
  };
25
39
 
26
40
  export function optimizeLookupTable(
27
41
  rowsIn: Iterable<LookupRow>,
28
42
  params: OptimizeParams,
29
43
  ): OptimizeResult {
44
+ const algorithm = params.algorithm ?? 'tiered';
45
+ if (algorithm === 'tiered') {
46
+ return buildTieredLookup(rowsIn, params);
47
+ }
48
+
30
49
  const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
31
50
  const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
32
51
  const totalWeightOut = params.totalWeightOut ?? params.nRowsOut * DEFAULTS.totalWeightOutPerRow;
@@ -35,6 +54,8 @@ export function optimizeLookupTable(
35
54
  const bucketCount = params.bucketCount ?? DEFAULTS.bucketCount;
36
55
  let minPerBucket = params.minPerBucket ?? DEFAULTS.minPerBucket;
37
56
  const maxRowRtpShare = params.maxRowRtpShare ?? DEFAULTS.maxRowRtpShare;
57
+ const maxWeightPerRow = params.maxWeightPerRow ?? DEFAULTS.maxWeightPerRow;
58
+ const betCostCents = params.betCostCents ?? DEFAULTS.betCostCents;
38
59
 
39
60
  const warnings: string[] = [];
40
61
 
@@ -75,6 +96,7 @@ export function optimizeLookupTable(
75
96
  achieved: OptimizeAchieved;
76
97
  toleranceMet: ToleranceMet;
77
98
  maxRowShare: number;
99
+ maxWeightRatio: number;
78
100
  lossSum: number;
79
101
  capWarning?: string;
80
102
  }
@@ -151,13 +173,18 @@ export function optimizeLookupTable(
151
173
  muHat = newMu;
152
174
  }
153
175
 
154
- // ── Iterative RTP-share cap (Stake Engine "Within Liability Limits") ─────
176
+ // ── Iterative RTP-share + per-row weight cap (Stake Engine "Within Liability Limits")
155
177
  //
156
- // After NNLS converges, one or a few rows may dominate the total RTP. Stake
157
- // Engine rejects tables where a single row carries an oversized share of the
158
- // expected return. We iteratively cap any such row's weight and re-solve the
159
- // (smaller) NNLS problem on the remaining rows until no violator remains or
160
- // the iteration budget is exhausted.
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;
161
188
  const fixedWeight = new Map<number, number>(); // candidate index → fixed weight
162
189
  let capIters = 0;
163
190
  const maxCapIters = 50;
@@ -170,28 +197,35 @@ export function optimizeLookupTable(
170
197
  const w = fixedWeight.has(i) ? fixedWeight.get(i)! : weights[i];
171
198
  totalWP += w * candidates[i].payoutCents;
172
199
  }
173
- if (totalWP <= 0) {
174
- capConverged = true;
175
- break;
176
- }
177
200
 
178
- // Find violators (only among non-fixed rows)
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.
179
203
  const violators: number[] = [];
180
204
  for (let i = 0; i < candidates.length; i++) {
181
205
  if (fixedWeight.has(i)) continue;
182
206
  const w = weights[i];
183
- const share = (w * candidates[i].payoutCents) / totalWP;
184
- if (share > maxRowRtpShare) violators.push(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);
185
212
  }
186
213
  if (violators.length === 0) {
187
214
  capConverged = true;
188
215
  break;
189
216
  }
190
217
 
191
- // Cap each violator at maxRowRtpShare × totalWP / payout (truncate to integer)
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.
192
221
  for (const i of violators) {
193
222
  const p = candidates[i].payoutCents;
194
- const cappedW = Math.max(1, Math.floor((maxRowRtpShare * totalWP) / Math.max(1, p)));
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));
195
229
  fixedWeight.set(i, cappedW);
196
230
  }
197
231
 
@@ -260,11 +294,98 @@ export function optimizeLookupTable(
260
294
 
261
295
  const capWarning =
262
296
  !capConverged && fixedWeight.size > 0
263
- ? `maxRowRtpShare cap could not converge in ${maxCapIters} iterations`
297
+ ? `maxRowRtpShare / maxWeightPerRow cap could not converge in ${maxCapIters} iterations`
264
298
  : undefined;
265
299
 
266
300
  // Quantize
267
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
+
268
389
  const outRows: LookupRow[] = candidates.map((r, i) => ({
269
390
  sim: r.sim,
270
391
  weight: quantized[i],
@@ -284,6 +405,14 @@ export function optimizeLookupTable(
284
405
  }
285
406
  }
286
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
+
287
416
  const toleranceMet: ToleranceMet = {
288
417
  rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
289
418
  cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
@@ -292,6 +421,7 @@ export function optimizeLookupTable(
292
421
  !requireMaxReached ||
293
422
  outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
294
423
  rtpConcentration: maxRowShare <= maxRowRtpShare,
424
+ weightCap: maxWeightRatio <= maxWeightPerRow + 1e-6,
295
425
  };
296
426
 
297
427
  // Loss for "best so far" tracking — Σ tolerance-normalized squared misses
@@ -300,11 +430,20 @@ export function optimizeLookupTable(
300
430
  Math.pow((achieved.cv - params.targetCV) / params.toleranceCV, 2) +
301
431
  Math.pow((achieved.hitRate - params.targetHitRate) / params.toleranceHitRate, 2) +
302
432
  (toleranceMet.maxReached ? 0 : 1000) +
303
- (toleranceMet.rtpConcentration ? 0 : 1000);
433
+ (toleranceMet.rtpConcentration ? 0 : 1000) +
434
+ (toleranceMet.weightCap ? 0 : 1000);
304
435
  if (!Number.isFinite(lossSum)) lossSum = Infinity;
305
436
 
306
437
  if (!best || lossSum < best.lossSum) {
307
- best = { rows: outRows, achieved, toleranceMet, maxRowShare, lossSum, capWarning };
438
+ best = {
439
+ rows: outRows,
440
+ achieved,
441
+ toleranceMet,
442
+ maxRowShare,
443
+ maxWeightRatio,
444
+ lossSum,
445
+ capWarning,
446
+ };
308
447
  }
309
448
 
310
449
  if (
@@ -312,16 +451,22 @@ export function optimizeLookupTable(
312
451
  toleranceMet.cv &&
313
452
  toleranceMet.hitRate &&
314
453
  toleranceMet.maxReached &&
315
- toleranceMet.rtpConcentration
454
+ toleranceMet.rtpConcentration &&
455
+ toleranceMet.weightCap
316
456
  ) {
317
457
  const iterWarnings = warnings.slice();
318
458
  if (capWarning) iterWarnings.push(capWarning);
459
+ const successReport = computeStakeReport(outRows, achieved, betCostCents);
460
+ emitGapWarning(successReport, iterWarnings);
319
461
  return {
320
462
  rows: outRows,
321
463
  achieved,
322
464
  toleranceMet,
323
465
  maxRowRtpShare: maxRowShare,
466
+ maxWeightRatio,
467
+ refinement: { rtpSwaps: 0, cvSwaps: 0, gapFillSwaps: 0, gapsUnfillable: 0, diversifySwaps: 0 },
324
468
  warnings: iterWarnings,
469
+ stakeReport: successReport,
325
470
  };
326
471
  }
327
472
  }
@@ -352,13 +497,23 @@ export function optimizeLookupTable(
352
497
  `maxRowRtpShare exceeded: ${(best.maxRowShare * 100).toFixed(2)}% > ${(maxRowRtpShare * 100).toFixed(2)}%`,
353
498
  );
354
499
  }
500
+ if (!best.toleranceMet.weightCap) {
501
+ warnings.push(
502
+ `maxWeightPerRow exceeded: max weight ratio ${best.maxWeightRatio.toFixed(2)} > ${maxWeightPerRow} × uniform prior`,
503
+ );
504
+ }
355
505
  if (best.capWarning) warnings.push(best.capWarning);
356
506
 
507
+ const bestReport = computeStakeReport(best.rows, best.achieved, betCostCents);
508
+ emitGapWarning(bestReport, warnings);
357
509
  return {
358
510
  rows: best.rows,
359
511
  achieved: best.achieved,
360
512
  toleranceMet: best.toleranceMet,
361
513
  maxRowRtpShare: best.maxRowShare,
514
+ maxWeightRatio: best.maxWeightRatio,
515
+ refinement: { rtpSwaps: 0, cvSwaps: 0, gapFillSwaps: 0, gapsUnfillable: 0, diversifySwaps: 0 },
362
516
  warnings,
517
+ stakeReport: bestReport,
363
518
  };
364
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
+