@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 CHANGED
@@ -1,30 +1,88 @@
1
1
  # @energy8platform/stake-math-tools
2
2
 
3
- Node-only dev-time utilities for building [Stake Engine](https://stake-engine.com/docs) **lookup tables (force matrices)** from raw simulation output. Companion to [`@energy8platform/stake-bridge`](../stake-bridge).
3
+ Node-only dev-time utilities for building [Stake Engine](https://stake-engine.com/docs) **lookup tables (force matrices)** from raw simulation output. Compresses millions of source simulations into a small weighted table that passes Stake's publish-time validation gates (Liability Limits, Gaps in Hit Rate Table, Unique Events). Companion to [`@energy8platform/stake-bridge`](../stake-bridge).
4
4
 
5
5
  ## Why
6
6
 
7
- Stake Engine games ship a pre-built weighted lookup table: each row is `(simulation_number, weight, payout_multiplier_cents)` and the RGS samples a row at runtime to decide each round's outcome. The math team's job is to compress millions of raw simulations down to a much smaller weighted table whose aggregate distribution still hits the design's target RTP / volatility / hit-rate, all under a hard `capMaxWin` ceiling.
7
+ Stake Engine games ship a pre-built weighted lookup table: each row is `(sim_id, weight, payout_cents)` and the RGS samples a row at runtime to decide each round's outcome. The math team's job is to compress millions of raw simulations down to a much smaller weighted table whose aggregate distribution still hits the design's target **RTP / volatility / hit-rate** under a hard `capMaxWin` ceiling, **and** passes Stake's risk-management checks.
8
8
 
9
9
  This package does that compression in one call.
10
10
 
11
- ## Architecture
11
+ ## Two algorithms, one entry point
12
12
 
13
13
  ```
14
- raw simulations (1M–10M rows) ──► optimizeLookupTable(rows, params) ──► lookup table (10K–100K rows)
15
-
16
- ├─ filter: drop payout > capMaxWin
17
- ├─ bucketize: zero / log-spaced / near-max
18
- ├─ sample: stratified weighted-reservoir (A-Res, seeded)
19
- ├─ solve: Lawson–Hanson NNLS with Tikhonov regularization
20
- + fixed-point iteration on μ̂
21
- ├─ quantize: largest-remainder, wᵢ ≥ 1, exact Σ
22
- └─ verify-and-retry: up to maxIterations expand-resample
14
+ optimizeLookupTable(rows, params)
15
+
16
+ ├─ algorithm: 'tiered' (default, recommended for Stake)
17
+ │ └─ tier rows by payout magnitude; cap+large rows get weight 1;
18
+ │ small rows get weight W calibrated to preserve cap rate.
19
+ │ Three refinement passes composition (hit-rate),
20
+ RTP-aware partition (mean), Σ-preserving 2-swap (variance).
21
+ │ Stake-Liability-safe by design.
22
+
23
+ └─ algorithm: 'nnls' (legacy, exact target-fitting)
24
+ └─ Lawson–Hanson NNLS over sampled candidates.
25
+ Hits RTP/CV/hit-rate exactly but tends to concentrate
26
+ weight on few rows — typically fails Stake's
27
+ "Within Liability Limits" check on volatile games.
23
28
  ```
24
29
 
25
- Six-phase pipeline. Each phase is its own module and is independently tested. Determinism is preserved through a single `seed` parameter that threads all RNG calls.
30
+ The default is `'tiered'`. Pick `'nnls'` only when Stake-compatibility is not a concern (custom RGS, internal tooling, etc.).
26
31
 
27
- Design rationale and trade-offs: [`docs/superpowers/specs/2026-05-08-stake-lookup-optimizer-design.md`](../../docs/superpowers/specs/2026-05-08-stake-lookup-optimizer-design.md).
32
+ ## Architecture (tiered, default)
33
+
34
+ ```
35
+ raw simulations (1M–10M rows) lookup table (10K–100K rows)
36
+ │ ▲
37
+ ▼ │
38
+ filter (payout ≤ capMaxWin) │
39
+ │ │
40
+ ▼ │
41
+ classify by payout multiplier: │
42
+ cap (pm ≥ capPmThreshold) weight = 1 │
43
+ large (largePm ≤ pm < cap) weight = 1 ◄── rare │
44
+ small (zero + bulk) weight = W │
45
+ │ │
46
+ ▼ │
47
+ sample composition biased by targetHitRate │
48
+ (n_nonzero / n_zero proportion in small tier) │
49
+ │ │
50
+ ▼ │
51
+ RTP-aware partition of non-zero small: │
52
+ solve n_high·μ_high + n_low·μ_low = n_B · μ_target │
53
+ then stratified log-payout sample within each side │
54
+ │ │
55
+ ▼ │
56
+ refineRtpBySwap — single-row in↔out swaps close the residual │
57
+ RTP gap within toleranceRTP budget │
58
+ │ │
59
+ ▼ │
60
+ refineCvBySwap — Σ-preserving 2-swaps adjust Σ payout² toward │
61
+ target without disturbing the RTP we just │
62
+ achieved (Σ-drift bounded by toleranceRTP) │
63
+ │ │
64
+ ▼ │
65
+ fillStakeRangeGaps — for each Stake distribution range up to │
66
+ maxPayout that's empty but source has rows,│
67
+ swap in a source row. Prevents "Gaps in │
68
+ the Hit Rate Table" rejection. │
69
+ │ │
70
+ ▼ │
71
+ diversifyPayouts — if uniqueEvents < minUniqueEventsRate × │
72
+ nRowsOut, swap duplicate-payout rows for │
73
+ source rows with new payout values until │
74
+ target unique count reached or RTP budget │
75
+ exhausted. Prevents "Insufficient Unique │
76
+ Events" rejection. │
77
+ │ │
78
+ ▼ │
79
+ W = n_high·(1 − target_cap_rate) / (n_small · target_cap_rate) │
80
+ │ │
81
+ ▼ │
82
+ compute stakeReport (top-K, distribution, unique events) ───────┘
83
+ ```
84
+
85
+ Determinism is preserved through a single `seed` parameter that threads every RNG call.
28
86
 
29
87
  ## Install
30
88
 
@@ -36,77 +94,186 @@ The package is a monorepo workspace member; consumers inside the repo just impor
36
94
  import { optimizeLookupTable, type LookupRow } from '@energy8platform/stake-math-tools';
37
95
 
38
96
  // 1. Parse simulation dump (CSV → array). No CSV parser is included on purpose —
39
- // the math team's pipeline already has one. The input is just an Iterable<LookupRow>.
97
+ // the math team's pipeline already has one. The input is just Iterable<LookupRow>.
40
98
  const rows: LookupRow[] = parseCsv('./sim_output.csv');
41
99
 
42
- // 2. Compress into a target lookup table.
100
+ // 2. Compress.
43
101
  const result = optimizeLookupTable(rows, {
44
- targetRTP: 0.96, toleranceRTP: 0.0005,
45
- targetCV: 8.0, toleranceCV: 0.1,
102
+ targetRTP: 0.96, toleranceRTP: 0.005,
103
+ targetCV: 8.0, toleranceCV: 1.0,
46
104
  targetHitRate: 0.30, toleranceHitRate: 0.01,
47
- capMaxWin: 5_000_000, // payout cents (50000.00x)
48
- nRowsOut: 50_000,
105
+ capMaxWin: 5_000_000, // payout cents (50000.00x bet)
106
+ nRowsOut: 100_000,
107
+
108
+ // Stake-tuning knobs (recommended for production):
109
+ largePmThreshold: 50, // pm ≥ 50 → large tier (weight=1). Lower = lower concentration,
110
+ // slower convergence. 50–500 is a typical range.
49
111
  });
50
112
 
51
- // 3. Inspect the achieved metrics + tolerance status.
52
- if (!result.toleranceMet.rtp || !result.toleranceMet.cv) {
53
- console.warn('targets not fully met:', result.warnings);
54
- }
55
- console.log(result.achieved); // { rtp, cv, hitRate, maxPayout, totalWeight }
113
+ // 3. Inspect.
114
+ console.log(result.achieved); // { rtp, cv, hitRate, maxPayout, totalWeight }
115
+ console.log(result.toleranceMet); // booleans per target
116
+ console.log(result.maxRowRtpShare); // top-1 RTP share — Stake Liability indicator
117
+ console.log(result.stakeReport); // full Stake-style report (see below)
118
+ if (result.warnings.length) console.warn(result.warnings);
56
119
 
57
- // 4. Write rows out in the format Stake expects.
58
- writeCsv('./force_base.csv', result.rows);
120
+ // 4. Write rows out in the format Stake expects: (sim_id, weight, payoutCents)
121
+ writeCsv('./lookUpTable_BASE_0.csv', result.rows);
59
122
  ```
60
123
 
61
124
  ## Public API
62
125
 
63
126
  | Export | Purpose |
64
127
  |---|---|
65
- | `optimizeLookupTable(rows, params)` | The main entry. Six-phase pipeline. |
128
+ | **`optimizeLookupTable(rows, params)`** | Main entry. Dispatches to tiered or nnls. |
129
+ | `buildTieredLookup(rows, params)` | Tier-based algorithm directly (bypasses dispatcher). |
130
+ | `computeStakeReport(rows, achieved, betCostCents)` | Compute Stake-style report from a built table. |
131
+ | `detectHitRateGaps(distribution)` | Find intermediate empty buckets in the hit-rate table. |
66
132
  | `computeMetrics(rows)` | Weighted RTP / CV / hit-rate / maxPayout. BigInt-safe accumulators. |
67
- | `bucketize(rows, opts)` | Zero / log-spaced / near-max partition. |
133
+ | `bucketize(rows, opts)` | Zero / log-spaced / near-max payout partition. |
68
134
  | `mulberry32(seed)` | Tiny deterministic PRNG. |
69
135
  | `weightedReservoirSample(indices, weights, k, rng)` | Algorithm A-Res. |
70
- | `computeQuotas(buckets, params)` | Min-per-bucket + variance-proportional distribution. |
71
- | `stratifiedSample(buckets, rows, quotas, rng)` | Apply quotas via A-Res, dedupe overlaps. |
72
136
  | `solveNNLS(A, b, opts?)` | Lawson–Hanson NNLS with Tikhonov regularization. |
137
+ | `solveQP(A, b, opts)` | FISTA + simplex projection (alternative QP solver). |
73
138
  | `quantizeWeights(weights, total)` | Largest-remainder, `wᵢ ≥ 1`, exact `Σ = total`. |
74
139
 
75
- Lower-level pieces are exported so the math pipeline can build alternative compressors or test individual phases in isolation. Internal helpers (`lawsonHansonNNLS`, `solveLS`) are not exported.
76
-
77
- Full types in [`src/types.ts`](./src/types.ts).
140
+ Full types in [`src/types.ts`](./src/types.ts). Internal helpers (`lawsonHansonNNLS`, `solveLS`, …) are not exported.
78
141
 
79
142
  ## `optimizeLookupTable(rows, params)`
80
143
 
81
- | Param | Type | Default | Description |
82
- |---|---|---|---|
83
- | `targetRTP` | `number` | *(required)* | E.g. `0.96`. |
84
- | `toleranceRTP` | `number` | *(required)* | Acceptable `±` deviation. |
85
- | `targetCV` | `number` | *(required)* | Coefficient of variation (volatility). |
86
- | `toleranceCV` | `number` | *(required)* | |
87
- | `targetHitRate` | `number` | *(required)* | Fraction of weighted spins with payout > 0. |
88
- | `toleranceHitRate` | `number` | *(required)* | |
89
- | `capMaxWin` | `number` | *(required)* | Hard cap in payout cents. Rows above are dropped. |
90
- | `nRowsOut` | `number` | *(required)* | Exact output size. |
91
- | `requireMaxReached` | `boolean` | `true` | Force 1 output row near the cap. |
92
- | `maxReachedFraction` | `number` | `0.95` | What counts as "near" the cap. |
93
- | `totalWeightOut` | `number` | `nRowsOut × 1_000_000` | Sum of integer output weights. |
94
- | `seed` | `number` | `0xC0FFEE` | Sampling RNG seed. |
95
- | `maxIterations` | `number` | `5` | Expand-and-retry attempts on tolerance miss. |
96
- | `bucketCount` | `number` | `100` | Log-buckets between min-nonzero and cap. |
97
- | `minPerBucket` | `number` | `3` | Min sample slots per non-empty non-zero bucket. |
98
-
99
- Returns `{ rows, achieved, toleranceMet, warnings }`. Never throws on tolerance miss — returns the best-effort result and lists what missed in `warnings`. Only throws when the input has fewer than `nRowsOut` rows after filtering, or `totalWeightOut < nRowsOut`.
100
-
101
- Determinism: same `(rows, params)` produces bit-identical output. Different `seed` produces a different (also reproducible) draw.
144
+ ### Required
145
+
146
+ | Param | Type | Description |
147
+ |---|---|---|
148
+ | `targetRTP` | `number` | LUT-RTP target (`Σ(w·payout) / (Σw · betCostCents)`). E.g. `0.96`. For buy-bonus modes, set to `gameRtp × cost`. |
149
+ | `toleranceRTP` | `number` | Tight tolerance drives refinement-loop precision. E.g. `0.001`. |
150
+ | `targetCV` | `number` | Coefficient of variation (volatility). |
151
+ | `toleranceCV` | `number` | Exits CV refinement when gap drops below this. |
152
+ | `targetHitRate` | `number` | Fraction of weighted output landing on `payout > 0`. |
153
+ | `toleranceHitRate` | `number` | |
154
+ | `capMaxWin` | `number` | Hard cap in payout cents. Rows above are dropped. |
155
+ | `nRowsOut` | `number` | Exact output row count. |
156
+
157
+ ### Tier-based knobs (recommended for Stake)
158
+
159
+ | Param | Default | Description |
160
+ |---|---|---|
161
+ | `algorithm` | `'tiered'` | `'tiered'` or `'nnls'`. |
162
+ | `capPmThreshold` | `0.95 × maxPm` | pm this cap tier (weight 1). |
163
+ | `largePmThreshold` | `undefined` | pm in `[largePm, cap)` → large tier (weight 1). Set this to **lower the top-K RTP share** and improve Stake-Liability margin. Typical: 50–500. |
164
+ | `largeTarget` | natural rate | Effective P(cap+large) in output. Override with Stake's per-tier limits if needed. |
165
+ | `betCostCents` | `100` | Bet cost (1 bet = 100 cents). Used for pm = payoutCents / betCostCents. |
166
+ | `ensureRangeCoverage` | `true` | Run a 4th refinement pass that guarantees every Stake distribution range up to actual maxPayout has ≥ 1 output row when source has rows in it. Prevents "Gaps in the Hit Rate Table" rejection. Set to `false` to disable. |
167
+ | `minUniqueEventsRate` | `0.01` | Minimum fraction of `nRowsOut` that must be distinct `payoutCents` values. Stake rejects "Insufficient Unique Events" when too few outcomes exist. 100K output → ≥1K unique. 300K → ≥3K. Set to `0` to disable. When source can't supply enough new payouts, optimizer maximizes under budget and emits a warning. |
168
+
169
+ ### Output sizing
170
+
171
+ | Param | Default | Description |
172
+ |---|---|---|
173
+ | `requireMaxReached` | `true` | Force ≥ 1 output row close to `capMaxWin`. |
174
+ | `maxReachedFraction` | `0.95` | What counts as "close". |
175
+ | `totalWeightOut` | `nRowsOut × 1_000_000` | Sum of integer output weights. |
176
+ | `seed` | `0xC0FFEE` | Deterministic seed for all RNG. |
177
+
178
+ ### NNLS-only knobs
179
+
180
+ | Param | Default | Description |
181
+ |---|---|---|
182
+ | `maxIterations` | `5` | Expand-and-retry attempts on tolerance miss. |
183
+ | `bucketCount` | `100` | Log-buckets between min-nonzero and cap. |
184
+ | `minPerBucket` | `3` | Min sample slots per non-empty non-zero bucket. |
185
+ | `maxRowRtpShare` | `0.05` | Per-row cap on RTP contribution (iterative cap-and-resolve). |
186
+ | `maxWeightPerRow` | `10` | Per-row weight ≤ N × uniform-prior. |
187
+
188
+ ### Returns
189
+
190
+ ```ts
191
+ {
192
+ rows: LookupRow[], // exactly nRowsOut rows; sim_id preserved
193
+ achieved: {
194
+ rtp, cv, hitRate, maxPayout, totalWeight
195
+ },
196
+ toleranceMet: {
197
+ rtp, cv, hitRate, maxReached,
198
+ rtpConcentration, weightCap // NNLS-only constraints
199
+ },
200
+ maxRowRtpShare: number, // largest single-row RTP fraction
201
+ maxWeightRatio: number, // max weight / uniform-prior
202
+ refinement: { // per-pass swap counters
203
+ rtpSwaps, // refineRtpBySwap iterations
204
+ cvSwaps, // refineCvBySwap (Σ-preserving 2-swaps)
205
+ gapFillSwaps, // ensureRangeCoverage swaps
206
+ diversifySwaps, // minUniqueEventsRate swaps
207
+ gapsUnfillable, // ranges source couldn't fill
208
+ },
209
+ warnings: string[], // human-readable issues (gaps, target misses, …)
210
+ stakeReport: { // Stake-publish-UI-equivalent metrics
211
+ payoutMultMax, // ≡ Stake's "Payout Mult"
212
+ baseStd, // ≡ Stake's "Base STD"
213
+ prob5K, prob10K, // ≡ "Within 5K/10K Probability Limits"
214
+ topKShare: [{k: 1, share}, …], // top-1/5/10/100 RTP shares
215
+ hitRateDistribution: HitRateBucket[], // 16-bucket pm table mirroring Stake's UI
216
+ uniqueEvents: number, // distinct payoutCents — ≡ "Insufficient Unique Events"
217
+ betCostCents
218
+ }
219
+ }
220
+ ```
221
+
222
+ Never throws on tolerance miss — returns the best-effort result with `warnings`. Only throws when the filtered input has fewer than `nRowsOut` rows.
223
+
224
+ Determinism: same `(rows, params)` → bit-identical output.
225
+
226
+ ## Hit-rate distribution table
227
+
228
+ `result.stakeReport.hitRateDistribution` mirrors what Stake Engine shows in the publish UI. 16 payout-multiplier buckets:
229
+
230
+ ```
231
+ [0, 0.1) [0.1, 1) [1, 2) [2, 5) [5, 10) [10, 20)
232
+ [20, 50) [50, 100) [100, 200) [200, 500)
233
+ [500, 1000) [1000, 2000) [2000, 5000) [5000, 10000)
234
+ [10000, 20000) [20000, ∞)
235
+ ```
236
+
237
+ For each bucket: `count` (rows in range), `effectiveHitRate` (Σ weight in range / total weight).
238
+
239
+ `detectHitRateGaps(distribution)` returns the **intermediate** empty buckets (sandwiched between non-empty ones) — these are what Stake's "Gaps in the Hit Rate Table" check flags. Empty buckets at the tail (above the highest non-empty bucket) are natural and not flagged.
240
+
241
+ The optimizer **proactively prevents** intermediate gaps via the `ensureRangeCoverage` pass (default on for tier-based): after RTP+CV refinement, any empty intermediate bucket gets a row swapped in from source. If a range can't be filled (source has no rows in that pm range), a warning is emitted — that's a game-design issue your simulation needs to address.
242
+
243
+ ## Stake publish-UI mapping
244
+
245
+ | Stake UI metric | `result.stakeReport` field | Notes |
246
+ |---|---|---|
247
+ | Payout Mult | `payoutMultMax` | max payout / bet |
248
+ | Base STD | `baseStd` | stddev in bet units |
249
+ | Within 5K/10K Probability Limits | `prob5K`, `prob10K` | typically 0 for non-progressive games |
250
+ | Within Liability Limits | `topKShare[0]` (top-1) | usually < 0.05 with `largePmThreshold` set |
251
+ | Within Risk Limits | (compute from `baseStd × betCost × maxBet`) | |
252
+ | Hit-Rate Distribution table | `hitRateDistribution` | full match by range |
253
+ | Insufficient Unique Events | `uniqueEvents` | distinct payoutCents in output. Auto-driven to `minUniqueEventsRate × nRowsOut` via the diversify pass. |
254
+ | Gaps in Hit Rate Table | `detectHitRateGaps(...)` returns `[]` | tail empties are natural |
255
+
256
+ ## How tolerance flows
257
+
258
+ Both refinement passes derive their per-iteration Σ-drift budget from `params.toleranceRTP` so the user's `tolerance*` values **actually drive the precision**:
259
+
260
+ - `refineRtpBySwap` uses `0.5 × toleranceRTP × T × 100 / W` cents of Σ-drift budget.
261
+ - `refineCvBySwap` uses the other `0.5 × toleranceRTP × …`, and exits when `|Σ²_achieved − Σ²_target| ≤ 2 × targetCV × mean² × T × toleranceCV / W`.
262
+
263
+ Tighten `toleranceRTP` for sub-percent precision; loosen `toleranceCV` to let CV refinement exit earlier when the source distribution can't reach the target.
102
264
 
103
265
  ## Scripts
104
266
 
105
267
  ```bash
106
- npm test # 36 vitest tests, including a 1M-row smoke test (~1.3s)
268
+ npm test # vitest run full suite (~15s)
107
269
  npm run typecheck # tsc --noEmit
108
270
  ```
109
271
 
272
+ ## Design history
273
+
274
+ - [`docs/superpowers/specs/2026-05-08-stake-lookup-optimizer-design.md`](../../docs/superpowers/specs/2026-05-08-stake-lookup-optimizer-design.md) — original NNLS-based design.
275
+ - Subsequent commits added the tiered algorithm in response to Stake's "Within Liability Limits" rejection of the NNLS-concentrated output. The tier-based approach is what Stake's reference implementations use; we converged independently on the same algorithm via empirical iteration.
276
+
110
277
  ## License
111
278
 
112
279
  MIT
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.5.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",
package/src/index.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  // src/index.ts
2
2
  export { optimizeLookupTable } from './optimize-lookup.js';
3
+ export { buildTieredLookup } from './tiered.js';
3
4
  export type {
4
5
  LookupRow,
5
6
  OptimizeParams,
6
7
  OptimizeResult,
7
8
  OptimizeAchieved,
8
9
  ToleranceMet,
10
+ StakeReport,
11
+ TopKShare,
12
+ HitRateBucket,
13
+ RefinementStats,
9
14
  } from './types.js';
15
+ export { computeStakeReport, detectHitRateGaps } from './stake-report.js';
10
16
 
11
17
  // Lower-level pieces — exposed so callers can build alternative pipelines or test in isolation.
12
18
  export { computeMetrics, isNearMax } from './metrics.js';