@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/stake-math-tools",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Node-only dev-time math utilities for the Energy8 Stake bridge: lookup-table (force matrix) builder",
5
5
  "author": "Energy8 Platform",
6
6
  "license": "MIT",
@@ -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: { rows: LookupRow[]; achieved: OptimizeAchieved; toleranceMet: ToleranceMet; lossSum: number } | null = null;
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 (toleranceMet.rtp && toleranceMet.cv && toleranceMet.hitRate && toleranceMet.maxReached) {
174
- return { rows: outRows, achieved, toleranceMet, warnings };
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 { rows: best.rows, achieved: best.achieved, toleranceMet: best.toleranceMet, warnings };
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