@energy8platform/stake-math-tools 0.3.0 → 0.4.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/package.json +1 -1
- package/src/optimize-lookup.ts +169 -6
- package/src/types.ts +10 -0
- package/test/optimize-lookup.integration.test.ts +47 -0
package/package.json
CHANGED
package/src/optimize-lookup.ts
CHANGED
|
@@ -20,6 +20,7 @@ const DEFAULTS = {
|
|
|
20
20
|
maxIterations: 5,
|
|
21
21
|
bucketCount: 100,
|
|
22
22
|
minPerBucket: 3,
|
|
23
|
+
maxRowRtpShare: 0.05,
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
export function optimizeLookupTable(
|
|
@@ -33,6 +34,7 @@ export function optimizeLookupTable(
|
|
|
33
34
|
const maxIterations = params.maxIterations ?? DEFAULTS.maxIterations;
|
|
34
35
|
const bucketCount = params.bucketCount ?? DEFAULTS.bucketCount;
|
|
35
36
|
let minPerBucket = params.minPerBucket ?? DEFAULTS.minPerBucket;
|
|
37
|
+
const maxRowRtpShare = params.maxRowRtpShare ?? DEFAULTS.maxRowRtpShare;
|
|
36
38
|
|
|
37
39
|
const warnings: string[] = [];
|
|
38
40
|
|
|
@@ -67,7 +69,16 @@ export function optimizeLookupTable(
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
// ── Phases 2–6: try, expand, retry ────────────────────────────────────────────
|
|
70
|
-
let best:
|
|
72
|
+
let best:
|
|
73
|
+
| {
|
|
74
|
+
rows: LookupRow[];
|
|
75
|
+
achieved: OptimizeAchieved;
|
|
76
|
+
toleranceMet: ToleranceMet;
|
|
77
|
+
maxRowShare: number;
|
|
78
|
+
lossSum: number;
|
|
79
|
+
capWarning?: string;
|
|
80
|
+
}
|
|
81
|
+
| null = null;
|
|
71
82
|
|
|
72
83
|
for (let iter = 0; iter < maxIterations; iter++) {
|
|
73
84
|
const rng = mulberry32(seed + iter);
|
|
@@ -140,6 +151,118 @@ export function optimizeLookupTable(
|
|
|
140
151
|
muHat = newMu;
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
// ── Iterative RTP-share cap (Stake Engine "Within Liability Limits") ─────
|
|
155
|
+
//
|
|
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.
|
|
161
|
+
const fixedWeight = new Map<number, number>(); // candidate index → fixed weight
|
|
162
|
+
let capIters = 0;
|
|
163
|
+
const maxCapIters = 50;
|
|
164
|
+
let capConverged = false;
|
|
165
|
+
|
|
166
|
+
while (capIters++ < maxCapIters) {
|
|
167
|
+
// Compute current total w·p (including fixed contributions)
|
|
168
|
+
let totalWP = 0;
|
|
169
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
170
|
+
const w = fixedWeight.has(i) ? fixedWeight.get(i)! : weights[i];
|
|
171
|
+
totalWP += w * candidates[i].payoutCents;
|
|
172
|
+
}
|
|
173
|
+
if (totalWP <= 0) {
|
|
174
|
+
capConverged = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Find violators (only among non-fixed rows)
|
|
179
|
+
const violators: number[] = [];
|
|
180
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
181
|
+
if (fixedWeight.has(i)) continue;
|
|
182
|
+
const w = weights[i];
|
|
183
|
+
const share = (w * candidates[i].payoutCents) / totalWP;
|
|
184
|
+
if (share > maxRowRtpShare) violators.push(i);
|
|
185
|
+
}
|
|
186
|
+
if (violators.length === 0) {
|
|
187
|
+
capConverged = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Cap each violator at maxRowRtpShare × totalWP / payout (truncate to integer)
|
|
192
|
+
for (const i of violators) {
|
|
193
|
+
const p = candidates[i].payoutCents;
|
|
194
|
+
const cappedW = Math.max(1, Math.floor((maxRowRtpShare * totalWP) / Math.max(1, p)));
|
|
195
|
+
fixedWeight.set(i, cappedW);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Re-run NNLS on remaining (non-fixed) candidates
|
|
199
|
+
const remainingIdx: number[] = [];
|
|
200
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
201
|
+
if (!fixedWeight.has(i)) remainingIdx.push(i);
|
|
202
|
+
}
|
|
203
|
+
if (remainingIdx.length < 4) break; // not enough rows to solve
|
|
204
|
+
|
|
205
|
+
// Compute fixed contributions to subtract from b
|
|
206
|
+
let fixedW_RTP = 0;
|
|
207
|
+
let fixedW_CV = 0;
|
|
208
|
+
let fixedW_HR = 0;
|
|
209
|
+
let fixedW_Sum = 0;
|
|
210
|
+
for (const [idx, w] of fixedWeight) {
|
|
211
|
+
const p = candidates[idx].payoutCents;
|
|
212
|
+
fixedW_RTP += (w * p) / params.toleranceRTP;
|
|
213
|
+
fixedW_CV +=
|
|
214
|
+
(w * Math.pow(p - muHat, 2)) / Math.max(1, params.toleranceCV * muHat * muHat);
|
|
215
|
+
fixedW_HR += (w * (p > 0 ? 1 : 0)) / params.toleranceHitRate;
|
|
216
|
+
fixedW_Sum += w / 1e-6;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build reduced A, b
|
|
220
|
+
const remCandidates = remainingIdx.map((i) => candidates[i]);
|
|
221
|
+
const A_r: number[][] = [
|
|
222
|
+
remCandidates.map((r) => r.payoutCents / params.toleranceRTP),
|
|
223
|
+
remCandidates.map(
|
|
224
|
+
(r) =>
|
|
225
|
+
Math.pow(r.payoutCents - muHat, 2) /
|
|
226
|
+
Math.max(1, params.toleranceCV * muHat * muHat),
|
|
227
|
+
),
|
|
228
|
+
remCandidates.map((r) => (r.payoutCents > 0 ? 1 : 0) / params.toleranceHitRate),
|
|
229
|
+
remCandidates.map(() => 1 / 1e-6),
|
|
230
|
+
];
|
|
231
|
+
const b_r = [
|
|
232
|
+
(params.targetRTP * totalWeightOut * 100) / params.toleranceRTP - fixedW_RTP,
|
|
233
|
+
(Math.pow(params.targetCV * muHat, 2) * totalWeightOut) /
|
|
234
|
+
Math.max(1, params.toleranceCV * muHat * muHat) -
|
|
235
|
+
fixedW_CV,
|
|
236
|
+
(params.targetHitRate * totalWeightOut) / params.toleranceHitRate - fixedW_HR,
|
|
237
|
+
totalWeightOut / 1e-6 - fixedW_Sum,
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
let fixedTotalW = 0;
|
|
241
|
+
for (const w of fixedWeight.values()) fixedTotalW += w;
|
|
242
|
+
const remainingFreeWeight = Math.max(0, totalWeightOut - fixedTotalW);
|
|
243
|
+
const remPrior = new Array(remCandidates.length).fill(
|
|
244
|
+
Math.max(1, remainingFreeWeight / remCandidates.length),
|
|
245
|
+
);
|
|
246
|
+
const newSol = solveNNLS(A_r, b_r, {
|
|
247
|
+
prior: remPrior,
|
|
248
|
+
regularization: 1e-6,
|
|
249
|
+
maxIterations: 200,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Splice back into the full weights array
|
|
253
|
+
for (let k = 0; k < remainingIdx.length; k++) {
|
|
254
|
+
weights[remainingIdx[k]] = Math.max(0, newSol[k]);
|
|
255
|
+
}
|
|
256
|
+
for (const [idx, w] of fixedWeight) {
|
|
257
|
+
weights[idx] = w;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const capWarning =
|
|
262
|
+
!capConverged && fixedWeight.size > 0
|
|
263
|
+
? `maxRowRtpShare cap could not converge in ${maxCapIters} iterations`
|
|
264
|
+
: undefined;
|
|
265
|
+
|
|
143
266
|
// Quantize
|
|
144
267
|
const quantized = quantizeWeights(weights, totalWeightOut);
|
|
145
268
|
const outRows: LookupRow[] = candidates.map((r, i) => ({
|
|
@@ -149,6 +272,18 @@ export function optimizeLookupTable(
|
|
|
149
272
|
}));
|
|
150
273
|
|
|
151
274
|
const achieved = computeMetrics(outRows);
|
|
275
|
+
|
|
276
|
+
// Compute the max single-row RTP share from final quantized output
|
|
277
|
+
let totalWPOut = 0;
|
|
278
|
+
for (const r of outRows) totalWPOut += r.weight * r.payoutCents;
|
|
279
|
+
let maxRowShare = 0;
|
|
280
|
+
if (totalWPOut > 0) {
|
|
281
|
+
for (const r of outRows) {
|
|
282
|
+
const share = (r.weight * r.payoutCents) / totalWPOut;
|
|
283
|
+
if (share > maxRowShare) maxRowShare = share;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
152
287
|
const toleranceMet: ToleranceMet = {
|
|
153
288
|
rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
|
|
154
289
|
cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
|
|
@@ -156,6 +291,7 @@ export function optimizeLookupTable(
|
|
|
156
291
|
maxReached:
|
|
157
292
|
!requireMaxReached ||
|
|
158
293
|
outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
|
|
294
|
+
rtpConcentration: maxRowShare <= maxRowRtpShare,
|
|
159
295
|
};
|
|
160
296
|
|
|
161
297
|
// Loss for "best so far" tracking — Σ tolerance-normalized squared misses
|
|
@@ -163,15 +299,30 @@ export function optimizeLookupTable(
|
|
|
163
299
|
Math.pow((achieved.rtp - params.targetRTP) / params.toleranceRTP, 2) +
|
|
164
300
|
Math.pow((achieved.cv - params.targetCV) / params.toleranceCV, 2) +
|
|
165
301
|
Math.pow((achieved.hitRate - params.targetHitRate) / params.toleranceHitRate, 2) +
|
|
166
|
-
(toleranceMet.maxReached ? 0 : 1000)
|
|
302
|
+
(toleranceMet.maxReached ? 0 : 1000) +
|
|
303
|
+
(toleranceMet.rtpConcentration ? 0 : 1000);
|
|
167
304
|
if (!Number.isFinite(lossSum)) lossSum = Infinity;
|
|
168
305
|
|
|
169
306
|
if (!best || lossSum < best.lossSum) {
|
|
170
|
-
best = { rows: outRows, achieved, toleranceMet, lossSum };
|
|
307
|
+
best = { rows: outRows, achieved, toleranceMet, maxRowShare, lossSum, capWarning };
|
|
171
308
|
}
|
|
172
309
|
|
|
173
|
-
if (
|
|
174
|
-
|
|
310
|
+
if (
|
|
311
|
+
toleranceMet.rtp &&
|
|
312
|
+
toleranceMet.cv &&
|
|
313
|
+
toleranceMet.hitRate &&
|
|
314
|
+
toleranceMet.maxReached &&
|
|
315
|
+
toleranceMet.rtpConcentration
|
|
316
|
+
) {
|
|
317
|
+
const iterWarnings = warnings.slice();
|
|
318
|
+
if (capWarning) iterWarnings.push(capWarning);
|
|
319
|
+
return {
|
|
320
|
+
rows: outRows,
|
|
321
|
+
achieved,
|
|
322
|
+
toleranceMet,
|
|
323
|
+
maxRowRtpShare: maxRowShare,
|
|
324
|
+
warnings: iterWarnings,
|
|
325
|
+
};
|
|
175
326
|
}
|
|
176
327
|
}
|
|
177
328
|
|
|
@@ -196,6 +347,18 @@ export function optimizeLookupTable(
|
|
|
196
347
|
if (!best.toleranceMet.maxReached) {
|
|
197
348
|
warnings.push(`requireMaxReached=true but no near-max row in output`);
|
|
198
349
|
}
|
|
350
|
+
if (!best.toleranceMet.rtpConcentration) {
|
|
351
|
+
warnings.push(
|
|
352
|
+
`maxRowRtpShare exceeded: ${(best.maxRowShare * 100).toFixed(2)}% > ${(maxRowRtpShare * 100).toFixed(2)}%`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (best.capWarning) warnings.push(best.capWarning);
|
|
199
356
|
|
|
200
|
-
return {
|
|
357
|
+
return {
|
|
358
|
+
rows: best.rows,
|
|
359
|
+
achieved: best.achieved,
|
|
360
|
+
toleranceMet: best.toleranceMet,
|
|
361
|
+
maxRowRtpShare: best.maxRowShare,
|
|
362
|
+
warnings,
|
|
363
|
+
};
|
|
201
364
|
}
|
package/src/types.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface OptimizeParams {
|
|
|
37
37
|
bucketCount?: number;
|
|
38
38
|
/** Minimum sample slots per non-empty non-zero bucket. Default 3. */
|
|
39
39
|
minPerBucket?: number;
|
|
40
|
+
|
|
41
|
+
/** Maximum fraction of total RTP that any single output row may contribute.
|
|
42
|
+
* Stake Engine's "Within Liability Limits" check fails when one row dominates RTP.
|
|
43
|
+
* Default 0.05 (5%). Set to 1.0 to disable.
|
|
44
|
+
*/
|
|
45
|
+
maxRowRtpShare?: number;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export interface OptimizeAchieved {
|
|
@@ -52,11 +58,15 @@ export interface ToleranceMet {
|
|
|
52
58
|
cv: boolean;
|
|
53
59
|
hitRate: boolean;
|
|
54
60
|
maxReached: boolean;
|
|
61
|
+
/** True if no output row contributes more than maxRowRtpShare of total RTP. */
|
|
62
|
+
rtpConcentration: boolean;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export interface OptimizeResult {
|
|
58
66
|
rows: LookupRow[];
|
|
59
67
|
achieved: OptimizeAchieved;
|
|
60
68
|
toleranceMet: ToleranceMet;
|
|
69
|
+
/** The single output row's largest fraction of total RTP. */
|
|
70
|
+
maxRowRtpShare: number;
|
|
61
71
|
warnings: string[];
|
|
62
72
|
}
|
|
@@ -175,6 +175,53 @@ describe('integration', () => {
|
|
|
175
175
|
expect(zeroRowFraction).toBeLessThan(0.85);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
it('8. caps single-row RTP contribution to maxRowRtpShare', () => {
|
|
179
|
+
const rng = makeRng(8);
|
|
180
|
+
const rows: LookupRow[] = new Array(200_000);
|
|
181
|
+
for (let i = 0; i < 200_000; i++) {
|
|
182
|
+
const u = rng();
|
|
183
|
+
let p = 0;
|
|
184
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
185
|
+
if (u > 0.97) p = Math.floor(rng() * 50_000);
|
|
186
|
+
if (u > 0.9995) p = Math.floor(rng() * 5_000_000);
|
|
187
|
+
rows[i] = { sim: i, weight: 1 + Math.floor(rng() * 100), payoutCents: p };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const result = optimizeLookupTable(rows, {
|
|
191
|
+
targetRTP: 0.96, toleranceRTP: 0.005,
|
|
192
|
+
targetCV: 8.0, toleranceCV: 1.0,
|
|
193
|
+
targetHitRate: 0.30, toleranceHitRate: 0.02,
|
|
194
|
+
capMaxWin: 5_000_000,
|
|
195
|
+
nRowsOut: 10_000,
|
|
196
|
+
requireMaxReached: true,
|
|
197
|
+
maxRowRtpShare: 0.05,
|
|
198
|
+
maxIterations: 2,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(result.maxRowRtpShare).toBeLessThanOrEqual(0.05 + 0.001); // tiny epsilon for quantize rounding
|
|
202
|
+
expect(result.toleranceMet.rtpConcentration).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('9. respects maxRowRtpShare=1.0 (disabled cap, preserves old behavior)', () => {
|
|
206
|
+
const rng = makeRng(9);
|
|
207
|
+
const rows: LookupRow[] = [];
|
|
208
|
+
for (let i = 0; i < 5000; i++) {
|
|
209
|
+
rows.push({ sim: i, weight: 1, payoutCents: rng() > 0.7 ? Math.floor(rng() * 5000) : 0 });
|
|
210
|
+
}
|
|
211
|
+
const result = optimizeLookupTable(rows, {
|
|
212
|
+
targetRTP: 0.5, toleranceRTP: 0.5,
|
|
213
|
+
targetCV: 3, toleranceCV: 100,
|
|
214
|
+
targetHitRate: 0.3, toleranceHitRate: 0.5,
|
|
215
|
+
capMaxWin: 5000,
|
|
216
|
+
nRowsOut: 500,
|
|
217
|
+
requireMaxReached: false,
|
|
218
|
+
maxRowRtpShare: 1.0,
|
|
219
|
+
maxIterations: 2,
|
|
220
|
+
});
|
|
221
|
+
// With disabled cap, no warning about concentration
|
|
222
|
+
expect(result.warnings.find(w => w.includes('maxRowRtpShare'))).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
178
225
|
it('6. handles nRowsOut=5000 without n² memory blowup', () => {
|
|
179
226
|
// Pre-fix this would allocate a 5000×5000 dense matrix (200 MB Float64);
|
|
180
227
|
// after the implicit-Tikhonov fix it should fit in well under 100 MB and
|