poker-bitvaljs 1.2.0 → 1.5.2

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.
Files changed (4) hide show
  1. package/README.md +48 -27
  2. package/bitval-worker.js +66 -71
  3. package/bitval.js +575 -230
  4. package/package.json +11 -2
package/README.md CHANGED
@@ -6,11 +6,15 @@ High-performance poker hand evaluator optimized for browser environments, using
6
6
 
7
7
  ## Features
8
8
 
9
- - **Fast hand evaluation** using bitwise operations
10
- - **Range vs range equity calculations** with canonical key caching optimization
11
- - **Web Workers support** for parallel processing across multiple CPU cores (3-7x speedup)
12
- - **Monte Carlo simulation** for preflop and postflop scenarios
13
- - **Exhaustive enumeration** for flop/turn scenarios (2 or fewer cards to come)
9
+ - **Fast hand evaluation** using four 13-bit suit lanes and plain 32-bit integers
10
+ (no BigInt in the hot path) — ~28–70× faster than the previous BigInt engine
11
+ - **Exact range vs range equity** on the flop and turn via evaluate-once with
12
+ counting (byte-identical to brute force, no approximation)
13
+ - **Exact, instant preflop** via an optional precomputed equity table
14
+ (`loadPreflopTable`) — replaces Monte Carlo entirely for preflop
15
+ - **Monte Carlo** with confidence-interval early-stop for the remaining sampled
16
+ case (preflop with dead cards)
17
+ - **Web Workers** for the Monte Carlo path
14
18
  - **Progress callbacks** with configurable update intervals
15
19
  - **Browser and Node.js compatible**
16
20
 
@@ -157,9 +161,9 @@ Simulates a single hand vs hand matchup.
157
161
  **Returns:**
158
162
  - `{ win, tie, lose }`: Results object
159
163
 
160
- ### `compareRange(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize, progressCallback, progressInterval, useWorkers)`
164
+ ### `compareRange(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize, progressCallback, progressInterval, useWorkers, mcTargetPct)`
161
165
 
162
- Compares two ranges of hands with optional optimization and Web Workers parallelization.
166
+ Compares two ranges of hands.
163
167
 
164
168
  **Parameters:**
165
169
  - `heroHands` (Array): Array of hero hand strings (e.g., `['AsAh', 'AsAd']`)
@@ -167,40 +171,57 @@ Compares two ranges of hands with optional optimization and Web Workers parallel
167
171
  - `boardCards` (Array): Board cards as strings (default: `[]`)
168
172
  - `deadCards` (Array): Dead cards as strings (default: `[]`)
169
173
  - `numberOfBoardCards` (Number): Total board cards (default: `5`)
170
- - `iterations` (Number): Number of simulations per matchup (default: `10000`)
171
- - `optimize` (Boolean): Use canonical key caching for performance (default: `true`). **Note:** Optimizations may result in a ±0.5% margin of error compared to unoptimized calculations.
174
+ - `iterations` (Number): Monte Carlo samples per matchup (default: `10000`). Ignored on the flop/turn (exhaustive) and preflop-table paths, which are exact.
175
+ - `optimize` (Boolean): Use the optimized path (default: `true`). On the flop/turn this is **exact** (byte-identical to `optimize: false`), just much faster.
172
176
  - `progressCallback` (Function): Optional callback `(current, total, message) => {}` (default: `null`)
173
177
  - `progressInterval` (Number): Update progress callback every N matchups (default: `100`)
174
- - `useWorkers` (Boolean): Use Web Workers for parallelization across CPU cores (default: `true`). Automatically falls back to sequential execution if workers are unavailable or workload is too small (< 4 matchups).
178
+ - `useWorkers` (Boolean): Use Web Workers for the Monte Carlo path (default: `true`). The exact flop/turn path runs on the main thread.
179
+ - `mcTargetPct` (Number): Monte Carlo early-stop target — stop sampling a matchup once its 95% confidence-interval half-width is within this many equity percentage points (default: `0.3`). Pass `0` to force the full `iterations`. Only affects the Monte Carlo (preflop) path.
175
180
 
176
181
  **Returns:**
177
182
  - `Promise<{ win, tie, lose }>`: Results object
178
183
 
179
- **Web Workers Notes:**
180
- - Web Workers provide 3-7x speedup on multi-core systems by parallelizing matchup evaluations
181
- - Workers are automatically disabled when:
182
- - `useWorkers` is `false`
183
- - Workers are not supported by the browser
184
- - There are fewer than 4 matchups (overhead not worth it)
185
- - For local testing with `file://` protocol, use a local web server (see [Testing Locally](#testing-locally))
184
+ ### `loadPreflopTable(buffer)`
185
+
186
+ Loads the precomputed exact preflop equity table and enables the preflop fast
187
+ path in `compareRange` (empty board, no dead cards). Accepts an `ArrayBuffer`
188
+ (browser) or Node `Buffer`. Generate the table with `npm run gen:preflop`.
189
+
190
+ ```javascript
191
+ // Browser
192
+ const buf = await (await fetch('./bench/preflop-table.bin')).arrayBuffer();
193
+ bitval.loadPreflopTable(buf);
194
+
195
+ // Node
196
+ const fs = require('fs');
197
+ bitval.loadPreflopTable(fs.readFileSync('bench/preflop-table.bin'));
198
+ ```
199
+
200
+ Without a table loaded, preflop falls back to Monte Carlo — nothing else changes.
186
201
 
187
202
  ## Performance
188
203
 
189
- - **Optimized for browser environments** with efficient bitwise operations
190
- - **Web Workers parallelization** provides 3-7x speedup on multi-core systems
191
- - Canonical key caching reduces redundant evaluations (may introduce ±0.5% margin of error)
192
- - Exhaustive enumeration for flop/turn (2 or fewer cards to come)
193
- - Monte Carlo simulation for preflop and river scenarios
194
- - Adaptive progress reporting to minimize UI blocking
204
+ - **No BigInt in the hot path** — cards are four 13-bit suit lanes evaluated with
205
+ plain 32-bit integer ops. ~28–70× faster than the previous BigInt engine.
206
+ - **Flop/turn: exact and fast.** Each unique concrete hand is evaluated once per
207
+ runout; wide range-vs-range is resolved by counting. No ±0.5% approximation.
208
+ - **Preflop: exact table lookup** (with `loadPreflopTable`) instead of Monte
209
+ Carlo — effectively instant.
210
+ - **Monte Carlo** (only preflop with dead cards) uses confidence-interval
211
+ early-stop and Web Workers.
212
+
213
+ Reproduce the benchmarks with `node bench/pre-vs-post.js` and `npm run bench`;
214
+ correctness with `npm test` (and `npm run test:full` for the exhaustive proofs).
195
215
 
196
216
  **Test performance and benchmark online:** [https://darraghgeo.github.io/poker-bitvaljs/](https://darraghgeo.github.io/poker-bitvaljs/)
197
217
 
198
218
  ### Performance Tips
199
219
 
200
- - Enable Web Workers (default) for best performance on multi-core systems
201
- - Use canonical caching (`optimize: true`) for large range comparisons
202
- - Adjust `progressInterval` to balance UI responsiveness vs. performance
203
- - For very small workloads (< 4 matchups), workers are automatically disabled to avoid overhead
220
+ - Call `loadPreflopTable` once at startup for exact, instant preflop equity.
221
+ - Keep `optimize: true` (default) — on the flop/turn it is exact and much faster.
222
+ - Lower `mcTargetPct` (or set `0`) for higher-precision Monte Carlo; raise it for
223
+ faster, looser preflop-with-dead-cards estimates.
224
+ - Adjust `progressInterval` to balance UI responsiveness vs. performance.
204
225
 
205
226
  ## Testing Locally
206
227
 
package/bitval-worker.js CHANGED
@@ -36,103 +36,98 @@ function deserializeMatchup(matchupData) {
36
36
  * Evaluates a single matchup in the worker context
37
37
  * Similar to _evaluateMatchup but without progress callbacks
38
38
  */
39
- async function evaluateMatchupInWorker(heroMask, villainMask, setup, evalCache, cacheInfo, bitval) {
39
+ async function evaluateMatchupInWorker(heroMask, villainMask, setup, bitval) {
40
40
  let win = 0, tie = 0, lose = 0;
41
41
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
42
- const matchupDeadMask = heroMask | villainMask; // Only matchup-level dead cards (excludes setup.deadCardsMask)
43
-
42
+
44
43
  // Initialize random number generator for Monte Carlo (not needed for exhaustive)
45
44
  if (!setup.isExhaustive) {
46
45
  bitval.xorShift = new XorShift32();
47
46
  }
48
47
 
49
- // Prepare for exhaustive enumeration if applicable
50
- let comboArray = null;
48
+ // BigInt-free (Number) hot loop, mirroring BitVal._evaluateMatchup. Cards are
49
+ // four 13-bit suit rank masks; bitval._eval7 does the evaluation with no BigInt.
51
50
  let iterations = setup.iterations;
51
+ const heroSuits = bitval._suitsFromBigMask(heroMask);
52
+ const villainSuits = bitval._suitsFromBigMask(villainMask);
53
+ const boardBaseSuits = bitval._suitsFromBigMask(setup.boardMask);
54
+ const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
55
+ const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
56
+ const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
57
+
58
+ let comboSuits = null; // flat Int32Array, 4 entries per combo
59
+ let pool = null, poolLen = 0; // packed Int32Array of available cards
60
+ const nDeal = setup.numberOfCardsToDeal;
52
61
  if (setup.isExhaustive) {
53
- // Use comboArray from setup if available, otherwise compute it
54
- if (setup.comboArray && setup.comboArray.length > 0) {
55
- comboArray = setup.comboArray;
56
- iterations = comboArray.length;
57
- } else {
58
- // Compute comboArray if not provided
59
- const availableMasks = bitval._getAvailableCardMasksByLookUp(deadMask);
60
- comboArray = bitval._getCombinations(availableMasks, setup.numberOfCardsToDeal);
61
- iterations = comboArray.length;
62
- }
62
+ // Use comboArray from setup if available, otherwise compute it.
63
+ let comboArray = (setup.comboArray && setup.comboArray.length > 0)
64
+ ? setup.comboArray
65
+ : bitval._getCombinations(bitval._getAvailableCardMasksByLookUp(deadMask), nDeal);
66
+ comboSuits = bitval._buildComboSuits(comboArray);
67
+ iterations = comboSuits.length >> 2;
68
+ } else {
69
+ pool = bitval._availableSuitCards(deadMask);
70
+ poolLen = pool.length;
63
71
  }
64
-
72
+
73
+ // Monte Carlo early-stop (mirrors BitVal._evaluateMatchup).
74
+ const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
75
+ const reqIters = iterations;
76
+ let ran = iterations;
77
+
65
78
  // Main evaluation loop
66
79
  for (let i = 0; i < iterations; i++) {
67
- // Generate board: use pre-computed combo for exhaustive, or deal randomly
68
- const board = setup.isExhaustive
69
- ? setup.boardMask | comboArray[i]
70
- : bitval.deal(setup.boardMask, deadMask, setup.numberOfCardsToDeal) | setup.boardMask;
71
-
72
- // Evaluate both hands
73
- let hEval, hKick, vEval, vKick;
74
- if (cacheInfo && evalCache) {
75
- [hEval, hKick] = bitval._getCachedEvaluation(cacheInfo.heroKey, cacheInfo.heroHand, board, evalCache, matchupDeadMask);
76
- [vEval, vKick] = bitval._getCachedEvaluation(cacheInfo.villainKey, cacheInfo.villainHand, board, evalCache, matchupDeadMask);
80
+ let d0, d1, d2, d3;
81
+ if (comboSuits) {
82
+ const b = i << 2;
83
+ d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
77
84
  } else {
78
- [hEval, hKick] = bitval.evaluate(heroMask | board);
79
- [vEval, vKick] = bitval.evaluate(villainMask | board);
85
+ d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
86
+ for (let kk = 0; kk < nDeal; kk++) {
87
+ const j = kk + bitval.xorShift.next(poolLen - kk);
88
+ const tmp = pool[kk]; pool[kk] = pool[j]; pool[j] = tmp;
89
+ const p = pool[kk], bit = p & 0xFFFF, suit = p >> 16;
90
+ if (suit === 0) d0 |= bit;
91
+ else if (suit === 1) d1 |= bit;
92
+ else if (suit === 2) d2 |= bit;
93
+ else d3 |= bit;
94
+ }
80
95
  }
81
-
82
- // Compare and accumulate results
83
- if (hEval > vEval || (hEval === vEval && hKick > vKick)) {
84
- win++;
85
- } else if (vEval > hEval || (vEval === hEval && vKick > hKick)) {
86
- lose++;
87
- } else {
88
- tie++;
96
+ const hE = bitval._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
97
+ const vE = bitval._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
98
+ if (hE > vE) win++;
99
+ else if (vE > hE) lose++;
100
+ else tie++;
101
+
102
+ if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
103
+ const n = i + 1, p = (win + tie * 0.5) / n;
104
+ if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
89
105
  }
90
106
  }
91
-
107
+
108
+ if (ran < reqIters && ran > 0) {
109
+ const s = reqIters / ran;
110
+ win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
111
+ }
112
+
92
113
  return { matchupWin: win, matchupTie: tie, matchupLose: lose };
93
114
  }
94
115
 
95
- // Worker message handler
116
+ // Reused BitVal instance (holds the lookup tables; cheap to keep around).
117
+ const _bitval = new BitVal();
118
+
119
+ // Worker message handler. Workers handle the Monte Carlo path only; the
120
+ // exhaustive exact path runs on the main thread (see BitVal._compareRangeOptimized).
96
121
  self.onmessage = async function(e) {
97
122
  try {
98
123
  const { matchups, setupData, workerId } = e.data;
99
-
100
- // Deserialize setup
101
124
  const setup = deserializeSetup(setupData);
102
-
103
- // Create BitVal instance and cache for this worker (reused for all matchups)
104
- const bitval = new BitVal();
105
- const evalCache = new FastestAutoClearingCache(16000000);
106
-
107
125
  const results = [];
108
-
109
- // Evaluate each matchup in the batch
126
+
110
127
  for (const matchupData of matchups) {
111
128
  const matchup = deserializeMatchup(matchupData);
112
-
113
- // Prepare cache info if available
114
- const cacheInfo = matchup.heroKey ? {
115
- heroKey: matchup.heroKey,
116
- heroHand: matchup.heroHand,
117
- villainKey: matchup.villainKey,
118
- villainHand: matchup.villainHand
119
- } : null;
120
-
121
- // Evaluate matchup
122
- const result = await evaluateMatchupInWorker(
123
- matchup.heroMask,
124
- matchup.villainMask,
125
- setup,
126
- evalCache,
127
- cacheInfo,
128
- bitval
129
- );
130
-
131
- results.push({
132
- key: matchup.key,
133
- validCount: matchup.validCount,
134
- ...result
135
- });
129
+ const result = await evaluateMatchupInWorker(matchup.heroMask, matchup.villainMask, setup, _bitval);
130
+ results.push({ key: matchup.key, validCount: matchup.validCount, ...result });
136
131
  }
137
132
 
138
133
  // Send results back to main thread
package/bitval.js CHANGED
@@ -13,46 +13,6 @@ class XorShift32 {
13
13
  }
14
14
  }
15
15
 
16
- /**
17
- * Fastest auto-clearing cache with minimal overhead.
18
- * Uses bitwise counter check to avoid expensive modulo operations.
19
- */
20
- class FastestAutoClearingCache {
21
- constructor(maxSize = 16000000) {
22
- this.maxSize = maxSize;
23
- this.clearThreshold = Math.floor(maxSize * 0.95); // Clear at 95% capacity
24
- this.cache = new Map();
25
- this.counter = 0;
26
- }
27
-
28
- has(key) {
29
- return this.cache.has(key); // No overhead on hot path
30
- }
31
-
32
- get(key) {
33
- return this.cache.get(key); // No overhead on hot path
34
- }
35
-
36
- set(key, value) {
37
- // Bitwise check is faster than modulo - check every ~1 million operations
38
- // 0xFFFFF = 1,048,576 (2^20)
39
- if ((++this.counter & 0xFFFFF) === 0) {
40
- if (this.cache.size >= this.clearThreshold) {
41
- this.cache.clear();
42
- }
43
- }
44
- this.cache.set(key, value);
45
- }
46
-
47
- clear() {
48
- this.cache.clear();
49
- }
50
-
51
- get size() {
52
- return this.cache.size;
53
- }
54
- }
55
-
56
16
  class BitVal {
57
17
  constructor() {
58
18
  // Suit constants
@@ -183,6 +143,43 @@ class BitVal {
183
143
  4503599627370496n: 67553994410557455n,
184
144
  1n: 67553994410557455n
185
145
  };
146
+
147
+ // ---- BigInt-free fast path (Number evaluator) lookups ----
148
+ // Rank index 0..12 = 2..A; suit index 0..3. A card set is four 13-bit rank
149
+ // masks (one per suit); evaluation and set-union run entirely on Numbers.
150
+ this._N_RANK = { '2': 0, '3': 1, '4': 2, '5': 3, '6': 4, '7': 5, '8': 6, '9': 7, 'T': 8, 'J': 9, 'Q': 10, 'K': 11, 'A': 12 };
151
+ this._N_SUIT = { s: 0, h: 1, d: 2, c: 3 };
152
+ this.CARD_SUITS = {}; // 'As' -> { suit, bit }
153
+ this._MASK_TO_SUIT = new Map(); // single-card BigInt mask -> { suit, bit }
154
+ for (const card in this.CARD_MASKS) {
155
+ const sr = { suit: this._N_SUIT[card[1]], bit: 1 << this._N_RANK[card[0]] };
156
+ this.CARD_SUITS[card] = sr;
157
+ this._MASK_TO_SUIT.set(this.CARD_MASKS[card], sr);
158
+ }
159
+
160
+ // 13-bit rank-mask lookup tables for the fast evaluator. Each is 8192
161
+ // entries (~8-16 KB total), built once here, cache-resident at runtime.
162
+ // _POPC13[m] = number of set bits (suit length / rank count).
163
+ // _STRAIGHT13[m] = top rank (4..12) of the best straight, 3 for the wheel,
164
+ // or -1 for none. Replaces the per-eval straight scan loop.
165
+ this._POPC13 = new Uint8Array(8192);
166
+ this._STRAIGHT13 = new Int8Array(8192);
167
+ for (let m = 0; m < 8192; m++) {
168
+ let c = 0, x = m;
169
+ while (x) { x &= x - 1; c++; }
170
+ this._POPC13[m] = c;
171
+ let run = 0, top = -1;
172
+ for (let r = 12; r >= 0; r--) {
173
+ if (m & (1 << r)) { if (++run >= 5) { top = r + 4; break; } } else run = 0;
174
+ }
175
+ if (top < 0 && (m & (1 << 12)) && (m & 0b1111) === 0b1111) top = 3; // wheel
176
+ this._STRAIGHT13[m] = top;
177
+ }
178
+
179
+ // Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
180
+ // suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
181
+ this._pfMap = null; this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
182
+ this._S4 = (function () { const p = a => a.length <= 1 ? [a] : a.flatMap((x, i) => p(a.slice(0, i).concat(a.slice(i + 1))).map(r => [x, ...r])); return p([0, 1, 2, 3]); })();
186
183
  }
187
184
 
188
185
  // ============================================
@@ -241,6 +238,177 @@ class BitVal {
241
238
  return [this.stripBits(this.normalize(hand), 5), null];
242
239
  }
243
240
 
241
+ // ============================================
242
+ // FAST PATH - BigInt-free (Number) evaluator
243
+ // ============================================
244
+
245
+ /**
246
+ * Population count of a 13-bit rank mask.
247
+ * @private
248
+ */
249
+ _popc(m) { return this._POPC13[m]; }
250
+
251
+ /**
252
+ * Index (0..12) of the highest set bit of a non-zero 13-bit rank mask.
253
+ * @private
254
+ */
255
+ _high(m) { return 31 - Math.clz32(m); }
256
+
257
+ /**
258
+ * Top rank (4..12) of the best 5-card straight in a 13-bit rank mask, or -1.
259
+ * Handles the A-2-3-4-5 wheel (returns 3, i.e. a five-high straight).
260
+ * @private
261
+ */
262
+ _straightTop(m) { return this._STRAIGHT13[m]; }
263
+
264
+ /**
265
+ * Builds the four 13-bit suit rank masks for an array of card strings.
266
+ * @private
267
+ */
268
+ _suitsOfCards(cards) {
269
+ const suits = [0, 0, 0, 0];
270
+ for (let i = 0; i < cards.length; i++) {
271
+ const sr = this.CARD_SUITS[cards[i]];
272
+ suits[sr.suit] |= sr.bit;
273
+ }
274
+ return suits;
275
+ }
276
+
277
+ /**
278
+ * Decomposes a BigInt card-set mask into four 13-bit suit rank masks.
279
+ * Used once per matchup / combo, never in the innermost loop.
280
+ * @private
281
+ */
282
+ _suitsFromBigMask(bigMask) {
283
+ const suits = [0, 0, 0, 0];
284
+ for (const [mask, sr] of this._MASK_TO_SUIT) {
285
+ if ((bigMask & mask) === mask) suits[sr.suit] |= sr.bit;
286
+ }
287
+ return suits;
288
+ }
289
+
290
+ /**
291
+ * Available cards (as {suit, bit}) not present in the given BigInt dead mask.
292
+ * Built once per matchup for the Monte Carlo dealing loop.
293
+ * @private
294
+ */
295
+ _availableSuitCards(deadMask) {
296
+ const out = [];
297
+ for (const [mask, sr] of this._MASK_TO_SUIT) {
298
+ if ((deadMask & mask) === 0n) out.push((sr.suit << 16) | sr.bit);
299
+ }
300
+ return Int32Array.from(out); // packed: suit in bits 16-17, rank bit in low 13
301
+ }
302
+
303
+ /**
304
+ * Flattens a BigInt combo array into a single Int32Array of suit rank masks
305
+ * (4 entries per combo: s, h, d, c). One contiguous buffer instead of N small
306
+ * arrays — better cache locality, no per-combo GC. Cached on `setup`.
307
+ * @private
308
+ */
309
+ _buildComboSuits(comboArray) {
310
+ const out = new Int32Array(comboArray.length * 4);
311
+ for (let i = 0; i < comboArray.length; i++) {
312
+ const s = this._suitsFromBigMask(comboArray[i]);
313
+ const b = i * 4;
314
+ out[b] = s[0]; out[b + 1] = s[1]; out[b + 2] = s[2]; out[b + 3] = s[3];
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /**
320
+ * Evaluates a 5-7 card hand given its four 13-bit suit rank masks and returns
321
+ * a single Number whose ordering is identical to `evaluate()` (score+kickers).
322
+ * Higher = stronger. No BigInt, no allocation.
323
+ * @private
324
+ */
325
+ _eval7(s, h, d, c) {
326
+ const all = s | h | d | c;
327
+ const POPC = this._POPC13, STR = this._STRAIGHT13;
328
+
329
+ // Flush suit (>=5 of one suit), reused for straight-flush and flush.
330
+ let fm = 0;
331
+ if (POPC[s] >= 5) fm = s;
332
+ else if (POPC[h] >= 5) fm = h;
333
+ else if (POPC[d] >= 5) fm = d;
334
+ else if (POPC[c] >= 5) fm = c;
335
+
336
+ // Straight flush
337
+ if (fm) {
338
+ const t = STR[fm];
339
+ if (t >= 0) return 8 * 1048576 + t * 65536;
340
+ }
341
+
342
+ // Per-rank suit-count masks (exact via boolean suit combinations)
343
+ const four = s & h & d & c;
344
+ const threePlus = (s & h & d) | (s & h & c) | (s & d & c) | (h & d & c);
345
+ const twoPlus = (s & h) | (s & d) | (s & c) | (h & d) | (h & c) | (d & c);
346
+ const trips = threePlus & ~four; // exactly three
347
+ const pairs = twoPlus & ~threePlus; // exactly two
348
+
349
+ // Quads
350
+ if (four) {
351
+ const q = this._high(four);
352
+ const k = this._high(all & ~(1 << q));
353
+ return 7 * 1048576 + q * 65536 + k * 4096;
354
+ }
355
+
356
+ // Full house (a trip plus another trip or a pair)
357
+ if (trips) {
358
+ const t1 = this._high(trips);
359
+ const restTrips = trips & ~(1 << t1);
360
+ const p = restTrips ? this._high(restTrips) : (pairs ? this._high(pairs) : -1);
361
+ if (p >= 0) return 6 * 1048576 + t1 * 65536 + p * 4096;
362
+ }
363
+
364
+ // Flush
365
+ if (fm) {
366
+ let m = fm, v = 5 * 1048576, shift = 65536;
367
+ for (let n = 0; n < 5; n++) { const r = this._high(m); v += r * shift; m &= ~(1 << r); shift >>= 4; }
368
+ return v;
369
+ }
370
+
371
+ // Straight
372
+ {
373
+ const t = STR[all];
374
+ if (t >= 0) return 4 * 1048576 + t * 65536;
375
+ }
376
+
377
+ // Trips
378
+ if (trips) {
379
+ const t = this._high(trips);
380
+ let rest = all & ~(1 << t);
381
+ const k1 = this._high(rest); rest &= ~(1 << k1);
382
+ const k2 = this._high(rest);
383
+ return 3 * 1048576 + t * 65536 + k1 * 4096 + k2 * 256;
384
+ }
385
+
386
+ // Two pair
387
+ if (pairs && (pairs & (pairs - 1))) {
388
+ const p1 = this._high(pairs);
389
+ const p2 = this._high(pairs & ~(1 << p1));
390
+ const k = this._high(all & ~(1 << p1) & ~(1 << p2));
391
+ return 2 * 1048576 + p1 * 65536 + p2 * 4096 + k * 256;
392
+ }
393
+
394
+ // One pair
395
+ if (pairs) {
396
+ const p = this._high(pairs);
397
+ let rest = all & ~(1 << p);
398
+ const k1 = this._high(rest); rest &= ~(1 << k1);
399
+ const k2 = this._high(rest); rest &= ~(1 << k2);
400
+ const k3 = this._high(rest);
401
+ return 1 * 1048576 + p * 65536 + k1 * 4096 + k2 * 256 + k3 * 16;
402
+ }
403
+
404
+ // High card
405
+ {
406
+ let m = all, v = 0, shift = 65536;
407
+ for (let n = 0; n < 5; n++) { const r = this._high(m); v += r * shift; m &= ~(1 << r); shift >>= 4; }
408
+ return v;
409
+ }
410
+ }
411
+
244
412
  /**
245
413
  * Gets the human-readable hand strength from an evaluation mask.
246
414
  * @param {BigInt} handMask - Evaluation mask with score bits
@@ -297,9 +465,14 @@ class BitVal {
297
465
 
298
466
  const availableCards = 52 - new Set(allDeadCards).size;
299
467
  const exhaustiveCombinations = this.combinations(availableCards, numberOfCardsToDeal);
300
- iterations = Math.min(iterations, exhaustiveCombinations);
301
468
 
302
- const isExhaustive = iterations === exhaustiveCombinations && numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2;
469
+ // For <=2 cards to come the full runout space is tiny (<=1326), so always
470
+ // enumerate it exactly instead of Monte-Carloing a low iteration count.
471
+ // 0 cards to come (a complete board) is also exhaustive: exactly one runout.
472
+ // Exhaustive is exact and deterministic; the iteration count only matters
473
+ // for larger (>=3 cards to come) scenarios.
474
+ const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
475
+ iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
303
476
  let comboArray = null;
304
477
  if (isExhaustive) {
305
478
  const availableMasks = this._getAvailableCardMasksByLookUp(deadCards);
@@ -368,13 +541,99 @@ class BitVal {
368
541
  * @param {Function} progressCallback - Optional progress callback (current, total, message)
369
542
  * @param {Number} progressInterval - Update progress every N matchups (default: 100)
370
543
  * @param {Boolean} useWorkers - Use Web Workers for parallelization (default: true)
544
+ * @param {Number} mcTargetPct - Monte Carlo early-stop target: stop sampling a
545
+ * matchup once its 95% CI half-width is within this many equity percentage
546
+ * points (default: 0.3). Pass 0 to force the full `iterations`. Only affects
547
+ * Monte Carlo (preflop); the exact flop/turn and preflop-table paths ignore it.
371
548
  * @returns {Promise<Object>} { win, tie, lose } - Results object
372
549
  */
373
- async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true) {
550
+ async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true, mcTargetPct = 0.3) {
551
+ // Preflop exact fast path: if the preflop equity table is loaded and this is
552
+ // a full-board preflop query with no dead cards, look up every matchup's
553
+ // exact equity instead of Monte Carlo. Instant and exact.
554
+ if (this._pfMap && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
555
+ const r = this._compareRangePreflopExact(heroHands, villainHands);
556
+ if (progressCallback) progressCallback(1, 1, 'Complete');
557
+ return r;
558
+ }
374
559
  const setup = this._compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize);
560
+ // Monte Carlo early-stop target (95% CI half-width, in equity percentage
561
+ // points). Default on; pass 0 to force full `iterations`. Ignored by the
562
+ // exact (exhaustive) path. Only affects the remaining MC case (preflop with
563
+ // dead cards, or preflop with no table loaded).
564
+ setup.mcTarget = mcTargetPct;
375
565
  return optimize ? await this._compareRangeOptimized(setup, progressCallback, progressInterval, useWorkers) : await this._compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback, progressInterval);
376
566
  }
377
567
 
568
+ /**
569
+ * Loads the exact preflop equity table (produced by bench/gen-preflop-table.js).
570
+ * Accepts an ArrayBuffer (browser fetch) or Node Buffer. Enables the preflop
571
+ * fast path in compareRange.
572
+ * @param {ArrayBuffer|Uint8Array|Buffer} buffer
573
+ * @returns {Number} number of canonical matchups loaded
574
+ */
575
+ loadPreflopTable(buffer) {
576
+ const ab = buffer instanceof ArrayBuffer ? buffer : buffer.buffer;
577
+ const off = buffer instanceof ArrayBuffer ? 0 : (buffer.byteOffset || 0);
578
+ const view = new DataView(ab, off, buffer.byteLength !== undefined ? buffer.byteLength : ab.byteLength);
579
+ if (view.getUint32(0, true) !== 0x50464551) throw new Error('invalid preflop table (bad magic)');
580
+ const count = view.getUint32(4, true);
581
+ this._pfTotal = view.getUint32(8, true);
582
+ this._pfWin = new Uint32Array(count);
583
+ this._pfTie = new Uint32Array(count);
584
+ this._pfMap = new Map();
585
+ for (let i = 0; i < count; i++) {
586
+ const o = 12 + i * 12;
587
+ this._pfMap.set(view.getUint32(o, true), i);
588
+ this._pfWin[i] = view.getUint32(o + 4, true);
589
+ this._pfTie[i] = view.getUint32(o + 8, true);
590
+ }
591
+ return count;
592
+ }
593
+
594
+ /** Card index (0..51) = rankIndex*4 + suitIndex, for a 2-char card string. @private */
595
+ _cardIndex(card) { return this._N_RANK[card[0]] * 4 + this._N_SUIT[card[1]]; }
596
+
597
+ /** Suit-isomorphic canonical key of a hero-vs-villain matchup (card indices). @private */
598
+ _preflopCanonKey(h1, h2, v1, v2) {
599
+ let best = Infinity;
600
+ const S4 = this._S4;
601
+ for (let pi = 0; pi < 24; pi++) {
602
+ const p = S4[pi];
603
+ const mh1 = (h1 & ~3) | p[h1 & 3], mh2 = (h2 & ~3) | p[h2 & 3];
604
+ const mv1 = (v1 & ~3) | p[v1 & 3], mv2 = (v2 & ~3) | p[v2 & 3];
605
+ const a = mh1 < mh2 ? mh1 : mh2, b = mh1 < mh2 ? mh2 : mh1;
606
+ const c = mv1 < mv2 ? mv1 : mv2, d = mv1 < mv2 ? mv2 : mv1;
607
+ const key = ((a * 52 + b) * 52 + c) * 52 + d;
608
+ if (key < best) best = key;
609
+ }
610
+ return best;
611
+ }
612
+
613
+ /**
614
+ * Exact preflop range-vs-range via the loaded table: sum each valid (hero,
615
+ * villain) matchup's exact win/tie/lose over all C(48,5) boards.
616
+ * @private
617
+ */
618
+ _compareRangePreflopExact(heroHands, villainHands) {
619
+ const total = this._pfTotal;
620
+ const heroC = [], villC = [];
621
+ for (const h of heroHands) heroC.push(this._cardIndex(h.slice(0, 2)), this._cardIndex(h.slice(2, 4)));
622
+ for (const v of villainHands) villC.push(this._cardIndex(v.slice(0, 2)), this._cardIndex(v.slice(2, 4)));
623
+ let win = 0, tie = 0, lose = 0;
624
+ for (let hi = 0; hi < heroC.length; hi += 2) {
625
+ const h1 = heroC[hi], h2 = heroC[hi + 1];
626
+ for (let vi = 0; vi < villC.length; vi += 2) {
627
+ const v1 = villC[vi], v2 = villC[vi + 1];
628
+ if (h1 === v1 || h1 === v2 || h2 === v1 || h2 === v2) continue; // conflicting hole cards
629
+ const idx = this._pfMap.get(this._preflopCanonKey(h1, h2, v1, v2));
630
+ const w = this._pfWin[idx], t = this._pfTie[idx];
631
+ win += w; tie += t; lose += total - w - t;
632
+ }
633
+ }
634
+ return { win, tie, lose };
635
+ }
636
+
378
637
  // ============================================
379
638
  // PUBLIC API - Utility Methods
380
639
  // ============================================
@@ -686,19 +945,24 @@ class BitVal {
686
945
  const boardMask = this.getBitMasked(boardCards);
687
946
  const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
688
947
 
689
- // Calculate cards to deal and determine if exhaustive enumeration is possible
948
+ // Calculate cards to deal and determine if exhaustive enumeration is possible.
949
+ // 0 cards to come (a complete board) is exhaustive too: exactly one runout.
690
950
  const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
691
- const canBeExhaustive = numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2;
951
+ const canBeExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
692
952
 
693
953
  // Calculate available cards using bitmask (more efficient than Set)
694
954
  const availableCards = 52 - this.countBits(deadCardsMask);
695
955
 
696
956
  // Determine if we should use exhaustive enumeration
697
- const exhaustiveCombinations = canBeExhaustive
698
- ? this.combinations(availableCards, numberOfCardsToDeal)
957
+ const exhaustiveCombinations = canBeExhaustive
958
+ ? this.combinations(availableCards, numberOfCardsToDeal)
699
959
  : Infinity;
700
- iterations = Math.min(iterations, exhaustiveCombinations);
701
- const isExhaustive = canBeExhaustive && iterations === exhaustiveCombinations && exhaustiveCombinations < Infinity;
960
+ // For <=2 cards to come the full runout space is tiny (<=1326), so always
961
+ // enumerate it exactly instead of Monte-Carloing a lower requested count.
962
+ // Exhaustive is exact and deterministic; the requested iteration count only
963
+ // matters for larger (>=3 cards to come, e.g. preflop) scenarios.
964
+ const isExhaustive = canBeExhaustive && exhaustiveCombinations < Infinity;
965
+ iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
702
966
 
703
967
  // Generate combo array only if exhaustive
704
968
  const comboArray = isExhaustive
@@ -735,7 +999,8 @@ class BitVal {
735
999
  numberOfBoardCards: setup.numberOfBoardCards,
736
1000
  iterations: setup.iterations,
737
1001
  isExhaustive: setup.isExhaustive,
738
- numberOfCardsToDeal: setup.numberOfCardsToDeal
1002
+ numberOfCardsToDeal: setup.numberOfCardsToDeal,
1003
+ mcTarget: setup.mcTarget
739
1004
  // Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
740
1005
  // as matchups are pre-serialized and sent separately
741
1006
  };
@@ -783,55 +1048,84 @@ class BitVal {
783
1048
  * Optimized for browser performance with adaptive yielding and inlined hot loop.
784
1049
  * @private
785
1050
  */
786
- async _evaluateMatchup(heroMask, villainMask, setup, evalCache = null, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
1051
+ async _evaluateMatchup(heroMask, villainMask, setup, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
787
1052
  let win = 0, tie = 0, lose = 0;
788
1053
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
789
- const matchupDeadMask = heroMask | villainMask; // Only matchup-level dead cards (excludes setup.deadCardsMask)
790
-
1054
+
791
1055
  // Initialize random number generator for Monte Carlo (not needed for exhaustive)
792
1056
  if (!setup.isExhaustive) {
793
1057
  this.xorShift = new XorShift32();
794
1058
  }
795
1059
 
796
- // Prepare for exhaustive enumeration if applicable
797
- let comboArray = null;
1060
+ // Prepare BigInt-free (Number) representations for the hot loop. The old
1061
+ // path built a BigInt board and called the BigInt evaluate() twice per
1062
+ // iteration; here cards are four 13-bit suit rank masks and _eval7 does the
1063
+ // work with no BigInt and no allocation. The board suit-masks and the combo
1064
+ // suit-masks are constant across matchups, so they're computed once and
1065
+ // cached on `setup`.
798
1066
  let iterations = setup.iterations;
1067
+ const heroSuits = this._suitsFromBigMask(heroMask);
1068
+ const villainSuits = this._suitsFromBigMask(villainMask);
1069
+ const boardBaseSuits = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1070
+ const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
1071
+ const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
1072
+ const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
1073
+
1074
+ let comboSuits = null; // flat Int32Array, 4 entries per combo
1075
+ let pool = null, poolLen = 0; // packed Int32Array of available cards
1076
+ const nDeal = setup.numberOfCardsToDeal;
799
1077
  if (setup.isExhaustive) {
800
- // Reuse pre-computed comboArray from setup (already computed in _compareRangeSetup)
801
- comboArray = setup.comboArray;
802
- iterations = comboArray.length;
1078
+ comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1079
+ iterations = comboSuits.length >> 2;
1080
+ } else {
1081
+ pool = this._availableSuitCards(deadMask);
1082
+ poolLen = pool.length;
803
1083
  }
804
-
805
- // Main evaluation loop - optimized with inlined functions and adaptive yielding
806
- const yieldInterval = 5000; // Increased from 1000 - reduces async overhead while maintaining responsiveness
1084
+
1085
+ // Main evaluation loop - adaptive yielding for UI responsiveness
1086
+ const yieldInterval = 5000;
807
1087
  let lastProgressTime = 0;
808
1088
  const progressUpdateInterval = 100; // Update progress at most every 100ms
809
-
1089
+
1090
+ // Monte Carlo early-stop: stop once this matchup's 95% CI half-width is
1091
+ // within the target (lopsided matchups converge fast). Never exceeds the
1092
+ // requested iterations; disabled for exact enumeration and when mcTarget<=0.
1093
+ const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
1094
+ const reqIters = iterations;
1095
+ let ran = iterations;
1096
+
810
1097
  for (let i = 0; i < iterations; i++) {
811
- // Generate board: use pre-computed combo for exhaustive, or deal randomly
812
- const board = setup.isExhaustive
813
- ? setup.boardMask | comboArray[i]
814
- : this.deal(setup.boardMask, deadMask, setup.numberOfCardsToDeal) | setup.boardMask;
815
-
816
- // Evaluate both hands - inlined for performance (no function call overhead)
817
- let hEval, hKick, vEval, vKick;
818
- if (evalCache) {
819
- [hEval, hKick] = this._getCachedEvaluation(evalCache.heroKey, evalCache.heroHand, board, evalCache.cache, matchupDeadMask);
820
- [vEval, vKick] = this._getCachedEvaluation(evalCache.villainKey, evalCache.villainHand, board, evalCache.cache, matchupDeadMask);
1098
+ // Build the runout as suit rank-masks: precomputed combo (exhaustive) or
1099
+ // a Fisher-Yates draw from the available pool (Monte Carlo).
1100
+ let d0, d1, d2, d3;
1101
+ if (comboSuits) {
1102
+ const b = i << 2;
1103
+ d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
821
1104
  } else {
822
- [hEval, hKick] = this.evaluate(heroMask | board);
823
- [vEval, vKick] = this.evaluate(villainMask | board);
1105
+ d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
1106
+ for (let k = 0; k < nDeal; k++) {
1107
+ const j = k + this.xorShift.next(poolLen - k);
1108
+ const tmp = pool[k]; pool[k] = pool[j]; pool[j] = tmp;
1109
+ const p = pool[k], bit = p & 0xFFFF, suit = p >> 16;
1110
+ if (suit === 0) d0 |= bit;
1111
+ else if (suit === 1) d1 |= bit;
1112
+ else if (suit === 2) d2 |= bit;
1113
+ else d3 |= bit;
1114
+ }
824
1115
  }
825
-
826
- // Compare and accumulate results - inlined for performance
827
- if (hEval > vEval || (hEval === vEval && hKick > vKick)) {
828
- win++;
829
- } else if (vEval > hEval || (vEval === hEval && vKick > hKick)) {
830
- lose++;
831
- } else {
832
- tie++;
1116
+
1117
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1118
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1119
+ if (hE > vE) win++;
1120
+ else if (vE > hE) lose++;
1121
+ else tie++;
1122
+
1123
+ // Monte Carlo early-stop check (every 4096 draws, past a warm-up).
1124
+ if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
1125
+ const n = i + 1, p = (win + tie * 0.5) / n;
1126
+ if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
833
1127
  }
834
-
1128
+
835
1129
  // Adaptive yielding: less frequent but still maintains UI responsiveness
836
1130
  if (progressCallback && i > 0 && i % yieldInterval === 0) {
837
1131
  const now = Date.now();
@@ -871,7 +1165,15 @@ class BitVal {
871
1165
  }
872
1166
  }
873
1167
  }
874
-
1168
+
1169
+ // If early-stopped, scale counts back to the requested iteration count so
1170
+ // the aggregate (which sums raw counts across matchups) weights every
1171
+ // matchup equally regardless of how early it converged.
1172
+ if (ran < reqIters && ran > 0) {
1173
+ const s = reqIters / ran;
1174
+ win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
1175
+ }
1176
+
875
1177
  return { matchupWin: win, matchupTie: tie, matchupLose: lose };
876
1178
  }
877
1179
 
@@ -887,7 +1189,7 @@ class BitVal {
887
1189
 
888
1190
  for (let i = 0; i < total; i++) {
889
1191
  const { heroMask, villainMask } = setup.validMatchups[i];
890
- const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, null, progressCallback, i, total);
1192
+ const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, progressCallback, i, total);
891
1193
  win += matchupWin; tie += matchupTie; lose += matchupLose;
892
1194
 
893
1195
  if (progressCallback && i % progressInterval === 0) {
@@ -932,7 +1234,6 @@ class BitVal {
932
1234
  */
933
1235
  async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
934
1236
  let win = 0, tie = 0, lose = 0;
935
- const evalCache = new FastestAutoClearingCache(16000000);
936
1237
  let lastProgressTime = 0;
937
1238
  const progressUpdateInterval = 100; // Update progress at most every 100ms
938
1239
 
@@ -941,101 +1242,129 @@ class BitVal {
941
1242
  ? this._getBoardSuitCounts(setup.boardCards)
942
1243
  : null;
943
1244
 
944
- // Group validMatchups by canonical key pair
945
- // This ensures we only process matchups that actually exist, avoiding Cartesian product issues
946
- const matchupGroups = new Map();
947
- for (const matchup of setup.validMatchups) {
948
- const mHKey = this._getCanonicalKey(
949
- matchup.heroHand,
950
- setup.boardCards,
951
- setup.numberOfBoardCards,
952
- boardSuitCounts
953
- );
954
- const mVKey = this._getCanonicalKey(
955
- matchup.villainHand,
956
- setup.boardCards,
957
- setup.numberOfBoardCards,
958
- boardSuitCounts
959
- );
960
- const key = `${mHKey}:${mVKey}`;
961
-
962
- if (!matchupGroups.has(key)) {
963
- matchupGroups.set(key, {
964
- heroKey: mHKey,
965
- villainKey: mVKey,
966
- representative: matchup,
967
- count: 0
968
- });
1245
+ // Monte Carlo (>=3 cards to come): canonical grouping + independent
1246
+ // per-matchup sampling (each matchup deals its own boards, so runouts can't
1247
+ // be shared). Grouping only pays off here, where a matchup is expensive.
1248
+ if (!setup.isExhaustive) {
1249
+ const matchupGroups = new Map();
1250
+ for (const matchup of setup.validMatchups) {
1251
+ const mHKey = this._getCanonicalKey(matchup.heroHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
1252
+ const mVKey = this._getCanonicalKey(matchup.villainHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
1253
+ const key = `${mHKey}:${mVKey}`;
1254
+ let g = matchupGroups.get(key);
1255
+ if (!g) { g = { heroKey: mHKey, villainKey: mVKey, representative: matchup, count: 0 }; matchupGroups.set(key, g); }
1256
+ g.count++;
969
1257
  }
970
- matchupGroups.get(key).count++;
971
- }
972
-
973
- // Calculate accurate total matchups (number of unique canonical key pairs)
974
- const totalMatchups = matchupGroups.size;
975
-
976
- let matchupIndex = 0;
977
- // Iterate over actual matchup groups (not Cartesian product)
978
- for (const [key, group] of matchupGroups) {
979
- const validCount = group.count;
980
- const validPair = group.representative;
981
-
982
- const cache = {
983
- heroKey: group.heroKey,
984
- heroHand: validPair.heroHand,
985
- villainKey: group.villainKey,
986
- villainHand: validPair.villainHand,
987
- cache: evalCache
988
- };
989
-
990
- const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
991
- validPair.heroMask,
992
- validPair.villainMask,
993
- setup,
994
- cache,
995
- progressCallback,
996
- matchupIndex,
997
- totalMatchups
998
- );
999
-
1000
- win += matchupWin * validCount;
1001
- tie += matchupTie * validCount;
1002
- lose += matchupLose * validCount;
1003
-
1004
- matchupIndex++;
1005
- if (progressCallback && matchupIndex % progressInterval === 0) {
1006
- const now = Date.now();
1007
- const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
1008
-
1009
- // Use modern browser APIs for better scheduling when available
1010
- if (typeof scheduler !== 'undefined' && scheduler.postTask) {
1011
- await scheduler.postTask(() => {
1012
- if (shouldUpdateProgress) {
1013
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1014
- lastProgressTime = now;
1015
- }
1016
- }, { priority: 'user-blocking' });
1017
- } else if (typeof requestIdleCallback !== 'undefined') {
1018
- await new Promise(resolve => {
1019
- requestIdleCallback(() => {
1020
- if (shouldUpdateProgress) {
1021
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1022
- lastProgressTime = now;
1023
- }
1024
- resolve();
1025
- }, { timeout: 1 });
1026
- });
1027
- } else {
1028
- // Fallback to setTimeout
1029
- await new Promise(resolve => setTimeout(resolve, 0));
1030
- if (shouldUpdateProgress) {
1031
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1032
- lastProgressTime = now;
1258
+ const totalMatchups = matchupGroups.size;
1259
+ let matchupIndex = 0;
1260
+ for (const [key, group] of matchupGroups) {
1261
+ const validCount = group.count;
1262
+ const validPair = group.representative;
1263
+ const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
1264
+ validPair.heroMask, validPair.villainMask, setup, progressCallback, matchupIndex, totalMatchups
1265
+ );
1266
+ win += matchupWin * validCount;
1267
+ tie += matchupTie * validCount;
1268
+ lose += matchupLose * validCount;
1269
+ matchupIndex++;
1270
+ if (progressCallback && matchupIndex % progressInterval === 0) {
1271
+ const now = Date.now();
1272
+ const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
1273
+ if (typeof scheduler !== 'undefined' && scheduler.postTask) {
1274
+ await scheduler.postTask(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } }, { priority: 'user-blocking' });
1275
+ } else if (typeof requestIdleCallback !== 'undefined') {
1276
+ await new Promise(resolve => { requestIdleCallback(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } resolve(); }, { timeout: 1 }); });
1277
+ } else {
1278
+ await new Promise(resolve => setTimeout(resolve, 0));
1279
+ if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; }
1033
1280
  }
1034
1281
  }
1035
1282
  }
1283
+ if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
1284
+ return { win, tie, lose };
1285
+ }
1286
+
1287
+ // Exhaustive: EXACT evaluate-once. Build the shared work structure and run
1288
+ // the kernel over all runouts. The Web Worker path (_compareRangeOptimized)
1289
+ // runs the SAME kernel over runout slices, so workers and sequential agree
1290
+ // byte-for-byte.
1291
+ const work = this._buildExhaustiveWork(setup);
1292
+ const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1293
+ const nRun = comboSuits.length >> 2;
1294
+ const r = this._runExhaustive(work, comboSuits, 0, nRun);
1295
+ if (progressCallback) progressCallback(nRun, nRun, 'Complete');
1296
+ return r;
1297
+ }
1298
+
1299
+ /**
1300
+ * Builds the reusable per-run work for the exact exhaustive kernel: unique
1301
+ * concrete hero/villain suit masks, the valid-pair list, and (for large
1302
+ * fields) the card-conflict CSR. Everything is transferable typed arrays so
1303
+ * it can be serialized to workers unchanged.
1304
+ * @private
1305
+ */
1306
+ _buildExhaustiveWork(setup) {
1307
+ const heroIdx = new Map(), villIdx = new Map();
1308
+ const heroSuit = [], villSuit = [], pairH = [], pairV = [];
1309
+ for (const m of setup.validMatchups) {
1310
+ let hi = heroIdx.get(m.heroMask);
1311
+ if (hi === undefined) { hi = heroSuit.length >> 2; heroIdx.set(m.heroMask, hi); const s = this._suitsFromBigMask(m.heroMask); heroSuit.push(s[0], s[1], s[2], s[3]); }
1312
+ let vi = villIdx.get(m.villainMask);
1313
+ if (vi === undefined) { vi = villSuit.length >> 2; villIdx.set(m.villainMask, vi); const s = this._suitsFromBigMask(m.villainMask); villSuit.push(s[0], s[1], s[2], s[3]); }
1314
+ pairH.push(hi); pairV.push(vi);
1315
+ }
1316
+ const H = heroSuit.length >> 2, V = villSuit.length >> 2, P = pairH.length;
1317
+ const hSuit = Int32Array.from(heroSuit), vSuit = Int32Array.from(villSuit);
1318
+ const pHa = Int32Array.from(pairH), pVa = Int32Array.from(pairV);
1319
+ const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1320
+
1321
+ const useCount = H >= 16 && V >= 16;
1322
+ let confList = null, confOff = null;
1323
+ if (useCount) {
1324
+ const confListArr = [];
1325
+ confOff = new Int32Array(H + 1);
1326
+ for (let h = 0; h < H; h++) {
1327
+ const q = h << 2, a0 = hSuit[q], a1 = hSuit[q + 1], a2 = hSuit[q + 2], a3 = hSuit[q + 3];
1328
+ for (let v = 0; v < V; v++) { const r = v << 2; if ((a0 & vSuit[r]) | (a1 & vSuit[r + 1]) | (a2 & vSuit[r + 2]) | (a3 & vSuit[r + 3])) confListArr.push(v); }
1329
+ confOff[h + 1] = confListArr.length;
1330
+ }
1331
+ confList = Int32Array.from(confListArr);
1332
+ }
1333
+ return { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3], H, V, P };
1334
+ }
1335
+
1336
+ /**
1337
+ * Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
1338
+ * unique concrete hand once per runout, then resolves matchups by direct
1339
+ * comparison (small fields) or sorted-villain binary-search counting with
1340
+ * card-conflict correction (large fields). Byte-identical to unoptimized.
1341
+ * @private
1342
+ */
1343
+ _runExhaustive(work, comboSuits, runStart, runEnd) {
1344
+ const { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0, bb1, bb2, bb3, H, V, P } = work;
1345
+ const heroVals = new Int32Array(H), villVals = new Int32Array(V);
1346
+ const sortedV = useCount ? new Int32Array(V) : null;
1347
+ let win = 0, tie = 0, lose = 0;
1348
+ for (let i = runStart; i < runEnd; i++) {
1349
+ const b = i << 2;
1350
+ const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
1351
+ for (let h = 0; h < H; h++) { const q = h << 2; heroVals[h] = this._eval7(hSuit[q] | d0, hSuit[q + 1] | d1, hSuit[q + 2] | d2, hSuit[q + 3] | d3); }
1352
+ for (let v = 0; v < V; v++) { const q = v << 2; villVals[v] = this._eval7(vSuit[q] | d0, vSuit[q + 1] | d1, vSuit[q + 2] | d2, vSuit[q + 3] | d3); }
1353
+ if (useCount) {
1354
+ sortedV.set(villVals);
1355
+ sortedV.sort();
1356
+ for (let h = 0; h < H; h++) {
1357
+ const x = heroVals[h];
1358
+ let lo = 0, hb = V; while (lo < hb) { const mid = (lo + hb) >> 1; if (sortedV[mid] < x) lo = mid + 1; else hb = mid; }
1359
+ let up = lo; hb = V; while (up < hb) { const mid = (up + hb) >> 1; if (sortedV[mid] <= x) up = mid + 1; else hb = mid; }
1360
+ let wH = lo, tH = up - lo, lH = V - up;
1361
+ for (let ci = confOff[h], end = confOff[h + 1]; ci < end; ci++) { const cv = villVals[confList[ci]]; if (cv < x) wH--; else if (cv === x) tH--; else lH--; }
1362
+ win += wH; tie += tH; lose += lH;
1363
+ }
1364
+ } else {
1365
+ for (let p = 0; p < P; p++) { const hv = heroVals[pHa[p]], vv = villVals[pVa[p]]; if (hv > vv) win++; else if (vv > hv) lose++; else tie++; }
1366
+ }
1036
1367
  }
1037
-
1038
- if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
1039
1368
  return { win, tie, lose };
1040
1369
  }
1041
1370
 
@@ -1045,9 +1374,21 @@ class BitVal {
1045
1374
  * @private
1046
1375
  */
1047
1376
  async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
1377
+ // Exhaustive (flop/turn): run the EXACT kernel on the main thread. It's now
1378
+ // fast enough (~tens of ms) that this is the right choice, and it makes the
1379
+ // worker and non-worker paths agree byte-for-byte (fixes the exact-vs-
1380
+ // approximate split that per-canonical-group workers had). Splitting runouts
1381
+ // across workers was a net loss here (structured-cloning the pair/conflict
1382
+ // work to every worker dwarfs the compute) — true off-thread parallelism of
1383
+ // this path needs SharedArrayBuffer (COOP/COEP), left as a follow-up. The
1384
+ // reusable _runExhaustive / _buildExhaustiveWork are kept for that.
1385
+ if (setup.isExhaustive) {
1386
+ return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1387
+ }
1388
+
1048
1389
  // Pre-calculate board suit counts once (constant for all matchups)
1049
- const boardSuitCounts = setup.boardCards.length > 0
1050
- ? this._getBoardSuitCounts(setup.boardCards)
1390
+ const boardSuitCounts = setup.boardCards.length > 0
1391
+ ? this._getBoardSuitCounts(setup.boardCards)
1051
1392
  : null;
1052
1393
 
1053
1394
  // Group validMatchups by canonical key pair
@@ -1115,15 +1456,17 @@ class BitVal {
1115
1456
  for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
1116
1457
  batches.push(matchupsToEvaluate.slice(i, i + batchSize));
1117
1458
  }
1118
-
1459
+
1119
1460
  // Serialize setup for workers
1120
1461
  const serializedSetup = this._serializeSetup(setup);
1121
-
1122
- // Create workers and distribute work
1123
- const workers = [];
1462
+
1463
+ // Acquire persistent workers from the pool (created once, reused across calls).
1464
+ // Previously a fresh Worker was spawned and terminated per compareRange call,
1465
+ // paying thread-spawn + importScripts('./bitval.js') parse on every query.
1466
+ const workers = this._acquireWorkers(batches.length);
1124
1467
  const promises = [];
1125
1468
  let completedMatchups = 0;
1126
-
1469
+
1127
1470
  // Progress tracking
1128
1471
  let lastProgressTime = 0;
1129
1472
  const progressUpdateInterval = 100;
@@ -1134,44 +1477,48 @@ class BitVal {
1134
1477
  lastProgressTime = now;
1135
1478
  }
1136
1479
  }, progressUpdateInterval) : null;
1137
-
1480
+
1138
1481
  for (let i = 0; i < batches.length; i++) {
1139
- const worker = new Worker('./bitval-worker.js');
1140
- workers.push(worker);
1141
-
1482
+ const worker = workers[i];
1483
+
1142
1484
  const promise = new Promise((resolve, reject) => {
1485
+ // One-shot handlers per dispatch; cleared on settle so the pooled worker
1486
+ // is clean for the next call. (Assumes compareRange calls don't overlap.)
1487
+ const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
1143
1488
  worker.onmessage = (e) => {
1489
+ cleanup();
1144
1490
  if (e.data.success) {
1145
1491
  completedMatchups += e.data.results.length;
1146
1492
  resolve(e.data.results);
1147
1493
  } else {
1148
1494
  reject(new Error(e.data.error || 'Worker error'));
1149
1495
  }
1150
- worker.terminate();
1151
1496
  };
1152
1497
  worker.onerror = (error) => {
1498
+ cleanup();
1499
+ // A crashed worker can't be reused - drop it from the pool.
1500
+ this._discardWorker(worker);
1153
1501
  reject(error);
1154
- worker.terminate();
1155
1502
  };
1156
-
1503
+
1157
1504
  // Serialize matchups for this batch
1158
1505
  const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
1159
-
1506
+
1160
1507
  worker.postMessage({
1161
1508
  matchups: serializedBatch,
1162
1509
  setupData: serializedSetup,
1163
1510
  workerId: i
1164
1511
  });
1165
1512
  });
1166
-
1513
+
1167
1514
  promises.push(promise);
1168
1515
  }
1169
-
1516
+
1170
1517
  // Wait for all workers and aggregate results
1171
1518
  let win = 0, tie = 0, lose = 0;
1172
1519
  try {
1173
1520
  const allResults = await Promise.all(promises);
1174
-
1521
+
1175
1522
  for (const batchResults of allResults) {
1176
1523
  for (const result of batchResults) {
1177
1524
  win += result.matchupWin * result.validCount;
@@ -1184,58 +1531,55 @@ class BitVal {
1184
1531
  if (progressIntervalId) {
1185
1532
  clearInterval(progressIntervalId);
1186
1533
  }
1187
-
1188
- // Ensure all workers are terminated
1189
- workers.forEach(worker => {
1190
- try {
1191
- worker.terminate();
1192
- } catch (e) {
1193
- // Ignore termination errors
1194
- }
1195
- });
1196
-
1534
+
1535
+ // Workers are NOT terminated - they stay in the pool for the next call.
1536
+ // Callers can free them explicitly with terminateWorkerPool().
1537
+
1197
1538
  // Final progress update
1198
1539
  if (progressCallback) {
1199
1540
  progressCallback(totalMatchups, totalMatchups, 'Complete');
1200
1541
  }
1201
1542
  }
1202
-
1543
+
1203
1544
  return { win, tie, lose };
1204
1545
  }
1205
1546
 
1206
1547
  /**
1207
- * Generates a numeric hash key from canonical key and board for faster cache lookups.
1548
+ * Returns `count` persistent workers, lazily growing the pool as needed.
1549
+ * Reused across compareRange calls so thread-spawn + importScripts parse is
1550
+ * paid once rather than per query.
1208
1551
  * @private
1209
1552
  */
1210
- _getCacheKeyHash(canonicalKey, board, matchupDeadMask = 0n) {
1211
- // Hash the canonical key string
1212
- let hash = 0;
1213
- for (let i = 0; i < canonicalKey.length; i++) {
1214
- hash = ((hash << 5) - hash) + canonicalKey.charCodeAt(i);
1215
- hash = hash & hash; // Convert to 32-bit integer
1216
- }
1217
- // Combine with board bits (use lower 32 bits of BigInt for fast XOR)
1218
- const boardBits = Number(board & 0xFFFFFFFFn);
1219
- hash = hash ^ boardBits;
1220
-
1221
- // Include matchup dead mask to prevent cross-matchup cache collisions
1222
- // This ensures different matchups (with different dead cards) don't share cache entries
1223
- const deadBits = Number(matchupDeadMask & 0xFFFFFFFFn);
1224
- return hash ^ deadBits;
1553
+ _acquireWorkers(count) {
1554
+ if (!this._workerPool) this._workerPool = [];
1555
+ while (this._workerPool.length < count) {
1556
+ this._workerPool.push(new Worker('./bitval-worker.js'));
1557
+ }
1558
+ return this._workerPool.slice(0, count);
1225
1559
  }
1226
1560
 
1227
1561
  /**
1228
- * Gets cached evaluation or evaluates and caches the result.
1229
- * Uses numeric hash keys for faster cache lookups.
1562
+ * Removes a (typically crashed) worker from the pool and terminates it.
1230
1563
  * @private
1231
1564
  */
1232
- _getCachedEvaluation(canonicalKey, originalHand, completeBoard, evalCache, matchupDeadMask = 0n) {
1233
- const cacheKey = this._getCacheKeyHash(canonicalKey, completeBoard, matchupDeadMask);
1234
- if (evalCache.has(cacheKey)) return evalCache.get(cacheKey);
1235
- const handMask = this.getBitMasked(this._handStringToCards(originalHand));
1236
- const evaluation = this.evaluate(handMask | completeBoard);
1237
- evalCache.set(cacheKey, evaluation);
1238
- return evaluation;
1565
+ _discardWorker(worker) {
1566
+ if (this._workerPool) {
1567
+ const idx = this._workerPool.indexOf(worker);
1568
+ if (idx !== -1) this._workerPool.splice(idx, 1);
1569
+ }
1570
+ try { worker.terminate(); } catch (e) { /* ignore */ }
1571
+ }
1572
+
1573
+ /**
1574
+ * Terminates and clears the persistent worker pool. Call when done with an
1575
+ * instance (e.g. on teardown) to free the worker threads.
1576
+ */
1577
+ terminateWorkerPool() {
1578
+ if (!this._workerPool) return;
1579
+ for (const worker of this._workerPool) {
1580
+ try { worker.terminate(); } catch (e) { /* ignore */ }
1581
+ }
1582
+ this._workerPool = null;
1239
1583
  }
1240
1584
 
1241
1585
  /**
@@ -1386,6 +1730,7 @@ class BitVal {
1386
1730
  * @private
1387
1731
  */
1388
1732
  _getCombinations(availableMasks, k) {
1733
+ if (k === 0) return [0n]; // complete board: exactly one runout (the empty draw)
1389
1734
  if (k === 1) return availableMasks;
1390
1735
  if (k === 2) {
1391
1736
  let combos = [];
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "poker-bitvaljs",
3
- "version": "1.2.0",
3
+ "version": "1.5.2",
4
4
  "description": "High-performance poker hand evaluator optimized for browser environments, using bitwise operations for rapid poker hand analysis and equity calculations.",
5
5
  "main": "bitval.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "node bench/correctness.js && node bench/determinism.js && node bench/exact-optimize.js 300 && node bench/eval-equivalence.js 1000000",
8
+ "test:full": "node bench/eval-equivalence.js --full && node bench/engine-differential.js && node bench/exact-optimize.js 2000",
9
+ "test:equiv": "node bench/eval-equivalence.js",
10
+ "test:exact": "node bench/exact-optimize.js",
11
+ "test:differential": "node bench/engine-differential.js",
12
+ "test:preflop": "node bench/preflop-validate.js",
13
+ "gen:preflop": "node bench/gen-preflop-table.js --out bench/preflop-table.bin",
14
+ "bench": "node bench/bench.js",
15
+ "bench:correct": "node bench/correctness.js",
16
+ "bench:determinism": "node bench/determinism.js"
8
17
  },
9
18
  "keywords": [
10
19
  "poker",