@energy8platform/stake-math-tools 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -56
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/optimize-lookup.ts +324 -6
- package/src/stake-report.ts +145 -0
- package/src/tiered.ts +1428 -0
- package/src/types.ts +128 -0
- package/test/optimize-lookup.integration.test.ts +470 -0
- package/test/optimize-lookup.unit.test.ts +2 -0
package/src/optimize-lookup.ts
CHANGED
|
@@ -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:
|
|
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 = {
|
|
438
|
+
best = {
|
|
439
|
+
rows: outRows,
|
|
440
|
+
achieved,
|
|
441
|
+
toleranceMet,
|
|
442
|
+
maxRowShare,
|
|
443
|
+
maxWeightRatio,
|
|
444
|
+
lossSum,
|
|
445
|
+
capWarning,
|
|
446
|
+
};
|
|
171
447
|
}
|
|
172
448
|
|
|
173
|
-
if (
|
|
174
|
-
|
|
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
|
-
|
|
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
|
+
|