poker-bitvaljs 1.6.0 → 1.7.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
@@ -175,7 +175,7 @@ Compares two ranges of hands.
175
175
  - `optimize` (Boolean): Use the optimized path (default: `true`). On the flop/turn this is **exact** (byte-identical to `optimize: false`), just much faster.
176
176
  - `progressCallback` (Function): Optional callback `(current, total, message) => {}` (default: `null`)
177
177
  - `progressInterval` (Number): Update progress callback every N matchups (default: `100`)
178
- - `useWorkers` (Boolean): Use Web Workers for the Monte Carlo path (default: `true`). The exact flop/turn path runs on the main thread.
178
+ - `useWorkers` (Boolean): Use Web Workers (default: `true`) for Monte Carlo and for large exact flop/turn queries; small exact queries stay on the main thread.
179
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.
180
180
 
181
181
  **Returns:**
@@ -200,6 +200,32 @@ bitval.loadPreflopTable(fs.readFileSync(require.resolve('poker-bitvaljs/bench/pr
200
200
 
201
201
  Without a table loaded, preflop falls back to Monte Carlo — nothing else changes.
202
202
 
203
+ ### Shared-board Monte Carlo (opt-in)
204
+
205
+ Monte Carlo is only used when the preflop table can't be (preflop with dead
206
+ cards, or no table loaded). By default each matchup samples its own boards.
207
+ Setting `mcSharedBoards` samples boards once and evaluates every hand on each
208
+ board, counting all valid pairs at once — typically 25–100× faster for
209
+ range-vs-range, with no canonical-grouping approximation:
210
+
211
+ ```javascript
212
+ const bitval = new BitVal();
213
+ bitval.mcSharedBoards = true;
214
+ const r = await bitval.compareRange(heroRange, villainRange, [], ['2c'], 5, 100000);
215
+ ```
216
+
217
+ In this mode `iterations` is the maximum number of **boards** sampled (shared by
218
+ all matchups), `mcTargetPct` applies to the aggregate equity, and the returned
219
+ `win`/`tie`/`lose` are summed over valid pairs × boards. Equity percentages are
220
+ directly comparable to the default mode; raw counts are not.
221
+
222
+ ## Changes in 1.7.0
223
+
224
+ - Opt-in shared-board Monte Carlo (`mcSharedBoards`, above).
225
+ - Large exact flop/turn queries are split across Web Workers.
226
+ - Faster exact flop/turn kernel (radix sort), faster preflop table load and
227
+ lookup (~4.5× for full range vs full range). Results are unchanged.
228
+
203
229
  ## Changes in 1.6.0
204
230
 
205
231
  - **Flop/turn correctness fix:** exhaustive `compareRange` no longer counts
@@ -231,6 +257,7 @@ correctness with `npm test` (and `npm run test:full` for the exhaustive proofs).
231
257
  - Lower `mcTargetPct` (or set `0`) for higher-precision Monte Carlo; raise it for
232
258
  faster, looser preflop-with-dead-cards estimates.
233
259
  - Adjust `progressInterval` to balance UI responsiveness vs. performance.
260
+ - For preflop with dead cards, set `bitval.mcSharedBoards = true` (see above).
234
261
  - Web Workers load `bitval-worker.js` from next to `bitval.js` (resolved from
235
262
  the `<script>` tag's URL). If you bundle or relocate the files, set
236
263
  `bitval.workerUrl = '/path/to/bitval-worker.js'`. If workers can't start,
package/bitval-worker.js CHANGED
@@ -35,10 +35,18 @@ function deserializeMatchup(matchupData) {
35
35
  // Reused BitVal instance (holds the lookup tables; cheap to keep around).
36
36
  const _bitval = new BitVal();
37
37
 
38
- // Worker message handler. Workers handle the Monte Carlo path only; the
39
- // exhaustive exact path runs on the main thread (see BitVal._compareRangeOptimized).
38
+ // Worker message handler: per-matchup Monte Carlo batches, or exact-kernel
39
+ // batches (type: 'kernel').
40
40
  self.onmessage = async function(e) {
41
41
  try {
42
+ // Exact kernel over a batch of runouts / sampled boards
43
+ // (BitVal._runKernelBatches): exact flop/turn and shared-board Monte Carlo.
44
+ if (e.data.type === 'kernel') {
45
+ const { work, boards } = e.data;
46
+ self.postMessage({ success: true, result: _bitval._runExhaustive(work, boards, 0, boards.length >> 2) });
47
+ return;
48
+ }
49
+
42
50
  const { matchups, setupData, workerId } = e.data;
43
51
  const setup = deserializeSetup(setupData);
44
52
  const results = [];
package/bitval.js CHANGED
@@ -29,6 +29,12 @@ class XorShift32 {
29
29
  class BitVal {
30
30
  constructor() {
31
31
  this.workerUrl = _BITVAL_WORKER_URL;
32
+ // Opt-in Monte Carlo mode: sample boards once and evaluate every hand on
33
+ // each board (see _compareRangeSharedBoards). Off = per-matchup sampling.
34
+ this.mcSharedBoards = false;
35
+ // Exact flop/turn jobs at least this big (unique hands x runouts) are split
36
+ // across workers when workers are enabled.
37
+ this._EXACT_WORKER_THRESHOLD = 300000;
32
38
 
33
39
  // Suit constants
34
40
  this._DIAMOND = 3n;
@@ -214,7 +220,9 @@ class BitVal {
214
220
 
215
221
  // Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
216
222
  // suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
217
- this._pfMap = null; this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
223
+ // Lookup is an open-addressing hash in typed arrays (_pfKeys/_pfSlot).
224
+ this._pfSlot = null; this._pfKeys = null; this._pfMask = 0; this._pfShift = 0;
225
+ this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
218
226
  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]); })();
219
227
  }
220
228
 
@@ -337,9 +345,37 @@ class BitVal {
337
345
  }
338
346
 
339
347
  /**
340
- * Flattens a BigInt combo array into a single Int32Array of suit rank masks
341
- * (4 entries per combo: s, h, d, c). One contiguous buffer instead of N small
342
- * arrays — better cache locality, no per-combo GC. Cached on `setup`.
348
+ * All k-card runouts (k = 0..2) from the cards NOT in `exclude` (a {lo, hi}
349
+ * card bitset), as one flat Int32Array with 4 suit rank masks per runout
350
+ * (s, h, d, c) — the layout the exact kernel reads. Built from card indices
351
+ * directly (no BigInt).
352
+ * @private
353
+ */
354
+ _runoutSuits(exclude, k) {
355
+ const cards = [];
356
+ for (let c = 0; c < 52; c++) if (!(c < 32 ? exclude.lo & (1 << c) : exclude.hi & (1 << (c - 32)))) cards.push(c);
357
+ const n = cards.length;
358
+ if (k === 0) return new Int32Array(4);
359
+ if (k === 1) {
360
+ const out = new Int32Array(n * 4);
361
+ for (let i = 0; i < n; i++) out[i * 4 + (cards[i] & 3)] = 1 << (cards[i] >> 2);
362
+ return out;
363
+ }
364
+ const out = new Int32Array((n * (n - 1) / 2) * 4);
365
+ let o = 0;
366
+ for (let i = 0; i < n; i++) {
367
+ const ci = cards[i];
368
+ for (let j = i + 1; j < n; j++, o += 4) {
369
+ const cj = cards[j];
370
+ out[o + (ci & 3)] |= 1 << (ci >> 2);
371
+ out[o + (cj & 3)] |= 1 << (cj >> 2);
372
+ }
373
+ }
374
+ return out;
375
+ }
376
+
377
+ /**
378
+ * Converts BigInt runout masks to the 4-suit-masks-per-runout layout.
343
379
  * @private
344
380
  */
345
381
  _buildComboSuits(comboArray) {
@@ -514,7 +550,7 @@ class BitVal {
514
550
  let win = 0, tie = 0, lose = 0;
515
551
 
516
552
  if (isExhaustive) {
517
- const combos = this._buildComboSuits(this._getCombinations(this._getAvailableCardMasksByLookUp(deadMask), numberOfCardsToDeal));
553
+ const combos = this._runoutSuits(this._cardsBitset(allDeadCards), numberOfCardsToDeal);
518
554
  const n = Math.min(iterations, combos.length >> 2);
519
555
  for (let i = 0; i < n; i++) {
520
556
  const b = i << 2, d0 = combos[b], d1 = combos[b + 1], d2 = combos[b + 2], d3 = combos[b + 3];
@@ -573,7 +609,7 @@ class BitVal {
573
609
  // Preflop exact fast path: if the preflop equity table is loaded and this is
574
610
  // a full-board preflop query with no dead cards, look up every matchup's
575
611
  // exact equity instead of Monte Carlo. Instant and exact.
576
- if (this._pfMap && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
612
+ if (this._pfSlot && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
577
613
  const r = this._compareRangePreflopExact(heroHands, villainHands);
578
614
  if (progressCallback) progressCallback(1, 1, 'Complete');
579
615
  return r;
@@ -601,18 +637,34 @@ class BitVal {
601
637
  if (view.getUint32(0, true) !== 0x50464551) throw new Error('invalid preflop table (bad magic)');
602
638
  const count = view.getUint32(4, true);
603
639
  this._pfTotal = view.getUint32(8, true);
604
- this._pfWin = new Uint32Array(count);
605
- this._pfTie = new Uint32Array(count);
606
- this._pfMap = new Map();
640
+ const win = new Uint32Array(count), tie = new Uint32Array(count);
641
+ // Open-addressing hash (linear probing, load factor <= 0.5): key -> row.
642
+ let bits = 1; while ((1 << bits) < count * 2) bits++;
643
+ const size = 1 << bits, mask = size - 1, shift = 32 - bits;
644
+ const keys = new Uint32Array(size), slot = new Int32Array(size).fill(-1);
607
645
  for (let i = 0; i < count; i++) {
608
- const o = 12 + i * 12;
609
- this._pfMap.set(view.getUint32(o, true), i);
610
- this._pfWin[i] = view.getUint32(o + 4, true);
611
- this._pfTie[i] = view.getUint32(o + 8, true);
612
- }
646
+ const o = 12 + i * 12, key = view.getUint32(o, true);
647
+ win[i] = view.getUint32(o + 4, true);
648
+ tie[i] = view.getUint32(o + 8, true);
649
+ let h = Math.imul(key, 0x9E3779B1) >>> shift;
650
+ while (slot[h] !== -1) h = (h + 1) & mask;
651
+ keys[h] = key; slot[h] = i;
652
+ }
653
+ this._pfWin = win; this._pfTie = tie;
654
+ this._pfKeys = keys; this._pfSlot = slot; this._pfMask = mask; this._pfShift = shift;
613
655
  return count;
614
656
  }
615
657
 
658
+ /** Row index of a canonical preflop key in the loaded table, or -1. @private */
659
+ _pfLookup(key) {
660
+ const keys = this._pfKeys, slot = this._pfSlot, mask = this._pfMask;
661
+ let h = Math.imul(key, 0x9E3779B1) >>> this._pfShift;
662
+ for (let i = slot[h]; i !== -1; h = (h + 1) & mask, i = slot[h]) {
663
+ if (keys[h] === key) return i;
664
+ }
665
+ return -1;
666
+ }
667
+
616
668
  /** Card index (0..51) = rankIndex*4 + suitIndex, for a 2-char card string. @private */
617
669
  _cardIndex(card) { return this._N_RANK[card[0]] * 4 + this._N_SUIT[card[1]]; }
618
670
 
@@ -642,14 +694,39 @@ class BitVal {
642
694
  const heroC = [], villC = [];
643
695
  for (const h of heroHands) heroC.push(this._cardIndex(h.slice(0, 2)), this._cardIndex(h.slice(2, 4)));
644
696
  for (const v of villainHands) villC.push(this._cardIndex(v.slice(0, 2)), this._cardIndex(v.slice(2, 4)));
697
+ // The canonical key ((a*52+b)*52+c)*52+d is minimized over the 24 suit
698
+ // permutations; it's lexicographic in (hero part, villain part), so only
699
+ // the permutations that minimize the hero part can win (2 for offsuit or
700
+ // pairs, 6 for suited). Find those once per hero, then minimize the villain
701
+ // part over just them — same key as _preflopCanonKey, ~4-12x less work.
702
+ const S4 = this._S4, pWin = this._pfWin, pTie = this._pfTie;
703
+ const perms = new Int32Array(24 * 4); let np = 0;
645
704
  let win = 0, tie = 0, lose = 0;
646
705
  for (let hi = 0; hi < heroC.length; hi += 2) {
647
706
  const h1 = heroC[hi], h2 = heroC[hi + 1];
707
+ let bestH = Infinity; np = 0;
708
+ for (let pi = 0; pi < 24; pi++) {
709
+ const p = S4[pi];
710
+ const m1 = (h1 & ~3) | p[h1 & 3], m2 = (h2 & ~3) | p[h2 & 3];
711
+ const hk = m1 < m2 ? m1 * 52 + m2 : m2 * 52 + m1;
712
+ if (hk > bestH) continue;
713
+ if (hk < bestH) { bestH = hk; np = 0; }
714
+ perms[np * 4] = p[0]; perms[np * 4 + 1] = p[1]; perms[np * 4 + 2] = p[2]; perms[np * 4 + 3] = p[3]; np++;
715
+ }
716
+ const hBase = bestH * 2704;
648
717
  for (let vi = 0; vi < villC.length; vi += 2) {
649
718
  const v1 = villC[vi], v2 = villC[vi + 1];
650
719
  if (h1 === v1 || h1 === v2 || h2 === v1 || h2 === v2) continue; // conflicting hole cards
651
- const idx = this._pfMap.get(this._preflopCanonKey(h1, h2, v1, v2));
652
- const w = this._pfWin[idx], t = this._pfTie[idx];
720
+ let bestV = 2704;
721
+ const b1 = v1 & ~3, s1 = v1 & 3, b2 = v2 & ~3, s2 = v2 & 3;
722
+ for (let k = 0; k < np; k++) {
723
+ const q = k * 4, m1 = b1 | perms[q + s1], m2 = b2 | perms[q + s2];
724
+ const vk = m1 < m2 ? m1 * 52 + m2 : m2 * 52 + m1;
725
+ if (vk < bestV) bestV = vk;
726
+ }
727
+ const idx = this._pfLookup(hBase + bestV);
728
+ if (idx < 0) throw new Error('preflop table is missing a matchup (corrupt or incomplete table)');
729
+ const w = pWin[idx], t = pTie[idx];
653
730
  win += w; tie += t; lose += total - w - t;
654
731
  }
655
732
  }
@@ -982,14 +1059,11 @@ class BitVal {
982
1059
  // enumerate it exactly instead of Monte-Carloing a lower requested count.
983
1060
  // Exhaustive is exact and deterministic; the requested iteration count only
984
1061
  // matters for larger (>=3 cards to come, e.g. preflop) scenarios.
985
- if (isExhaustive) {
986
- iterations = this.combinations(52 - this.countBits(deadCardsMask), numberOfCardsToDeal);
987
- }
988
-
989
- // Generate combo array only if exhaustive
990
- const comboArray = isExhaustive
991
- ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
1062
+ // Runouts as suit masks, built from card indices (no BigInt).
1063
+ const comboSuits = isExhaustive
1064
+ ? this._runoutSuits(this._cardsBitset([...boardCards, ...deadCards]), numberOfCardsToDeal)
992
1065
  : null;
1066
+ if (isExhaustive) iterations = comboSuits.length >> 2;
993
1067
 
994
1068
  const self = this;
995
1069
  return {
@@ -999,9 +1073,11 @@ class BitVal {
999
1073
  deadCardsMask,
1000
1074
  iterations,
1001
1075
  isExhaustive,
1002
- comboArray,
1076
+ comboArray: null, // BigInt runouts are no longer built; see _comboSuits
1077
+ _comboSuits: comboSuits,
1003
1078
  numberOfCardsToDeal,
1004
1079
  boardCards,
1080
+ deadCards,
1005
1081
  numberOfBoardCards,
1006
1082
  _validMatchups: null,
1007
1083
  get validMatchups() {
@@ -1511,12 +1587,14 @@ class BitVal {
1511
1587
  }
1512
1588
 
1513
1589
  /**
1514
- * Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
1515
- * unique concrete hand once per runout, then resolves matchups by direct
1516
- * comparison (small fields) or, for large fields, a single sort of the
1517
- * unique hands followed by a linear sweep over equal-value groups that keeps
1518
- * running villain counts in total and per hole card (to subtract villains
1519
- * sharing a card with the hero). Byte-identical to unoptimized.
1590
+ * Exact exhaustive kernel over runouts [runStart, runEnd). Hands holding a
1591
+ * card dealt in the runout are dropped for that runout; every other unique
1592
+ * concrete hand is evaluated once. Matchups are then resolved by direct
1593
+ * comparison (small fields) or, for large fields, a radix sort of the live
1594
+ * hands by value followed by a linear sweep over equal-value groups that
1595
+ * keeps running villain counts in total and per hole card (to subtract
1596
+ * villains sharing a card with the hero). Byte-identical to unoptimized.
1597
+ * `comboSuits` holds 4 suit rank masks per runout (any number of cards).
1520
1598
  * @private
1521
1599
  */
1522
1600
  _runExhaustive(work, comboSuits, runStart, runEnd) {
@@ -1528,17 +1606,17 @@ class BitVal {
1528
1606
  const cardTot = useCount ? new Int32Array(52) : null;
1529
1607
  let win = 0, tie = 0, lose = 0;
1530
1608
 
1531
- // Large-field scratch: sort keys (value*U + u, exact in a double) and
1532
- // per-card villain counts strictly below / equal to the current value.
1533
- const keys = useCount ? new Float64Array(U) : null;
1609
+ // Large-field scratch: live hand list, radix-sort buffers and counters,
1610
+ // and per-card villain counts strictly below / equal to the current value.
1611
+ const live = useCount ? new Int32Array(U) : null;
1612
+ const tmp = useCount ? new Int32Array(U) : null;
1534
1613
  const order = useCount ? new Int32Array(U) : null;
1614
+ const cnt = useCount ? new Int32Array(3 * 257) : null;
1535
1615
  const lessC = useCount ? new Int32Array(52) : null;
1536
1616
  const eqC = useCount ? new Int32Array(52) : null;
1537
1617
 
1538
1618
  for (let i = runStart; i < runEnd; i++) {
1539
1619
  const b = i << 2;
1540
- const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
1541
- for (let u = 0; u < U; u++) { const q = u << 2; uVals[u] = this._eval7(uSuit[q] | d0, uSuit[q + 1] | d1, uSuit[q + 2] | d2, uSuit[q + 3] | d3); }
1542
1620
 
1543
1621
  // Mark hands holding a runout card; for counting, also remove those
1544
1622
  // villains from the field total and the per-card totals.
@@ -1558,6 +1636,14 @@ class BitVal {
1558
1636
  }
1559
1637
  }
1560
1638
 
1639
+ // Evaluate the live hands only.
1640
+ const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
1641
+ for (let u = 0; u < U; u++) {
1642
+ if (dead[u] === i) continue;
1643
+ const q = u << 2;
1644
+ uVals[u] = this._eval7(uSuit[q] | d0, uSuit[q + 1] | d1, uSuit[q + 2] | d2, uSuit[q + 3] | d3);
1645
+ }
1646
+
1561
1647
  if (!useCount) {
1562
1648
  for (let p = 0; p < P; p++) {
1563
1649
  const hu = pHa[p], vu = pVa[p];
@@ -1567,24 +1653,34 @@ class BitVal {
1567
1653
  continue;
1568
1654
  }
1569
1655
 
1570
- for (let u = 0; u < U; u++) keys[u] = uVals[u] * U + u;
1571
- keys.sort();
1572
- for (let k = 0; k < U; k++) { const key = keys[k]; order[k] = key - Math.floor(key / U) * U; }
1656
+ // LSD radix sort of the live hands by value (values < 2^24: 3 x 8 bits).
1657
+ let L = 0;
1658
+ cnt.fill(0);
1659
+ for (let u = 0; u < U; u++) {
1660
+ if (dead[u] === i) continue;
1661
+ live[L++] = u;
1662
+ const v = uVals[u];
1663
+ cnt[(v & 255) + 1]++; cnt[257 + ((v >> 8) & 255) + 1]++; cnt[514 + ((v >> 16) & 255) + 1]++;
1664
+ }
1665
+ for (let o = 0; o < 771; o += 257) for (let r = 0; r < 256; r++) cnt[o + r + 1] += cnt[o + r];
1666
+ for (let k = 0; k < L; k++) { const u = live[k]; tmp[cnt[uVals[u] & 255]++] = u; }
1667
+ for (let k = 0; k < L; k++) { const u = tmp[k]; live[cnt[257 + ((uVals[u] >> 8) & 255)]++] = u; }
1668
+ for (let k = 0; k < L; k++) { const u = live[k]; order[cnt[514 + ((uVals[u] >> 16) & 255)]++] = u; }
1573
1669
  lessC.fill(0);
1574
1670
 
1575
1671
  let below = 0; // villain entries with value < current group value
1576
- for (let g = 0; g < U;) {
1672
+ for (let g = 0; g < L;) {
1577
1673
  const x = uVals[order[g]];
1578
- let e = g + 1; while (e < U && uVals[order[e]] === x) e++;
1674
+ let e = g + 1; while (e < L && uVals[order[e]] === x) e++;
1579
1675
  // Villain counts at exactly x: total and per card.
1580
1676
  let eqTotal = 0;
1581
- for (let k = g; k < e; k++) { const u = order[k]; if (dead[u] === i) continue; const m = vMult[u]; if (m) { eqTotal += m; eqC[uA[u]] += m; eqC[uB[u]] += m; } }
1677
+ for (let k = g; k < e; k++) { const u = order[k], m = vMult[u]; if (m) { eqTotal += m; eqC[uA[u]] += m; eqC[uB[u]] += m; } }
1582
1678
  const above = nVi - below - eqTotal;
1583
1679
  // Heroes at x. A villain holding both hero cards (the identical hand)
1584
1680
  // is subtracted on each card, so add it back once (it ties at x).
1585
1681
  for (let k = g; k < e; k++) {
1586
1682
  const u = order[k], m = hMult[u];
1587
- if (!m || dead[u] === i) continue;
1683
+ if (!m) continue;
1588
1684
  const a = uA[u], c = uB[u];
1589
1685
  const w = below - lessC[a] - lessC[c];
1590
1686
  const t = eqTotal - eqC[a] - eqC[c] + vMult[u];
@@ -1592,7 +1688,7 @@ class BitVal {
1592
1688
  win += w * m; tie += t * m; lose += l * m;
1593
1689
  }
1594
1690
  // Fold this group into the running "below" counts.
1595
- for (let k = g; k < e; k++) { const u = order[k]; if (dead[u] === i) continue; const m = vMult[u]; if (m) { lessC[uA[u]] += m; lessC[uB[u]] += m; eqC[uA[u]] = 0; eqC[uB[u]] = 0; } }
1691
+ for (let k = g; k < e; k++) { const u = order[k], m = vMult[u]; if (m) { lessC[uA[u]] += m; lessC[uB[u]] += m; eqC[uA[u]] = 0; eqC[uB[u]] = 0; } }
1596
1692
  below += eqTotal;
1597
1693
  g = e;
1598
1694
  }
@@ -1606,18 +1702,36 @@ class BitVal {
1606
1702
  * @private
1607
1703
  */
1608
1704
  async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
1609
- // Exhaustive (flop/turn): run the EXACT kernel on the main thread. It's now
1610
- // fast enough (~tens of ms) that this is the right choice, and it makes the
1611
- // worker and non-worker paths agree byte-for-byte (fixes the exact-vs-
1612
- // approximate split that per-canonical-group workers had). Splitting runouts
1613
- // across workers was a net loss here (structured-cloning the pair/conflict
1614
- // work to every worker dwarfs the compute) — true off-thread parallelism of
1615
- // this path needs SharedArrayBuffer (COOP/COEP), left as a follow-up. The
1616
- // reusable _runExhaustive / _buildExhaustiveWork are kept for that.
1705
+ // Exhaustive (flop/turn): the EXACT kernel. Large jobs are split by runout
1706
+ // across the worker pool (the work is a few small typed arrays, cheap to
1707
+ // post); small ones stay on the main thread where they're faster than the
1708
+ // messaging. Totals are integer sums, so both agree byte-for-byte.
1617
1709
  if (setup.isExhaustive) {
1710
+ if (useWorkers && typeof Worker !== 'undefined') {
1711
+ const work = this._buildExhaustiveWork(setup);
1712
+ const comboSuits = setup._comboSuits;
1713
+ const nRun = comboSuits.length >> 2;
1714
+ if (work.U * nRun >= this._EXACT_WORKER_THRESHOLD) {
1715
+ const nw = (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4;
1716
+ // Runouts cost about the same, so one chunk per worker (fewer posts).
1717
+ const chunk = Math.max(1, Math.ceil(nRun / nw));
1718
+ let next = 0, done = 0, win = 0, tie = 0, lose = 0;
1719
+ await this._runKernelBatches(work,
1720
+ () => { if (next >= nRun) return null; const a = next; next = Math.min(nRun, next + chunk); return comboSuits.slice(a * 4, next * 4); },
1721
+ (r, n) => { win += r.win; tie += r.tie; lose += r.lose; done += n; if (progressCallback) progressCallback(done, nRun, `Evaluating runouts ${done}/${nRun}`); },
1722
+ true, progressCallback);
1723
+ if (progressCallback) progressCallback(nRun, nRun, 'Complete');
1724
+ return { win, tie, lose };
1725
+ }
1726
+ }
1618
1727
  return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1619
1728
  }
1620
1729
 
1730
+ // Opt-in shared-board Monte Carlo.
1731
+ if (this.mcSharedBoards) {
1732
+ return await this._compareRangeSharedBoards(setup, progressCallback, useWorkers);
1733
+ }
1734
+
1621
1735
  // Group validMatchups by canonical key pair (only matchups that exist).
1622
1736
  const matchupsToEvaluate = this._groupMonteCarloMatchups(setup);
1623
1737
  const totalMatchups = matchupsToEvaluate.length;
@@ -1710,6 +1824,115 @@ class BitVal {
1710
1824
  return { win, tie, lose };
1711
1825
  }
1712
1826
 
1827
+ /**
1828
+ * Runs the exact kernel (_runExhaustive) over batches of runouts — on the
1829
+ * worker pool when allowed and available, otherwise on the main thread.
1830
+ * `nextBatch()` returns an Int32Array of runouts (4 suit masks each) or null
1831
+ * when there's nothing more to do (it may stop early); `onResult(r, n)` gets
1832
+ * each batch's { win, tie, lose } and its runout count. Batches are handed
1833
+ * out to whichever worker is free; a worker that fails is dropped and its
1834
+ * batch (and, if none are left, the rest) runs on the main thread.
1835
+ * @private
1836
+ */
1837
+ async _runKernelBatches(work, nextBatch, onResult, useWorkers, progressCallback = null) {
1838
+ const runLocal = (b) => onResult(this._runExhaustive(work, b, 0, b.length >> 2), b.length >> 2);
1839
+ let workers = null;
1840
+ if (useWorkers && typeof Worker !== 'undefined') {
1841
+ const n = (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4;
1842
+ try { workers = this._acquireWorkers(n); } catch (e) { workers = null; }
1843
+ }
1844
+ if (!workers || workers.length === 0) {
1845
+ let b;
1846
+ while ((b = nextBatch())) {
1847
+ runLocal(b);
1848
+ // Keep the page responsive between batches when someone is watching.
1849
+ if (progressCallback) await new Promise(resolve => setTimeout(resolve, 0));
1850
+ }
1851
+ return;
1852
+ }
1853
+ let alive = workers.length;
1854
+ await Promise.all(workers.map((worker) => new Promise((resolve, reject) => {
1855
+ let current = null;
1856
+ const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
1857
+ const dispatch = () => {
1858
+ current = nextBatch();
1859
+ if (!current) { cleanup(); resolve(); return; }
1860
+ worker.postMessage({ type: 'kernel', work, boards: current });
1861
+ };
1862
+ worker.onmessage = (e) => {
1863
+ if (!e.data.success) { cleanup(); reject(new Error(e.data.error || 'Worker error')); return; }
1864
+ onResult(e.data.result, current.length >> 2);
1865
+ dispatch();
1866
+ };
1867
+ worker.onerror = (error) => {
1868
+ if (error && error.preventDefault) error.preventDefault();
1869
+ cleanup();
1870
+ this._discardWorker(worker);
1871
+ try {
1872
+ if (current) runLocal(current);
1873
+ if (--alive === 0) { let b; while ((b = nextBatch())) runLocal(b); }
1874
+ resolve();
1875
+ } catch (err) { reject(err); }
1876
+ };
1877
+ dispatch();
1878
+ })));
1879
+ }
1880
+
1881
+ /**
1882
+ * Opt-in Monte Carlo (bitval.mcSharedBoards = true): sample boards once from
1883
+ * the live deck and run the exact kernel over them, so every hand is
1884
+ * evaluated once per board and ALL valid pairs are counted on every board.
1885
+ * Hands that collide with a sampled board sit that board out, which is
1886
+ * exactly conditional sampling per matchup, so the equity is unbiased (and
1887
+ * there's no canonical grouping). `iterations` caps the number of boards;
1888
+ * sampling stops early once the batch-means 95% CI half-width of the
1889
+ * aggregate equity is below `mcTarget` percentage points.
1890
+ * @private
1891
+ */
1892
+ async _compareRangeSharedBoards(setup, progressCallback = null, useWorkers = true) {
1893
+ const work = this._buildExhaustiveWork(setup);
1894
+ const ex = this._cardsBitset([...setup.boardCards, ...(setup.deadCards || [])]);
1895
+ const deck = [];
1896
+ for (let c = 0; c < 52; c++) if (!(c < 32 ? ex.lo & (1 << c) : ex.hi & (1 << (c - 32)))) deck.push(c);
1897
+ const pool = Int32Array.from(deck), poolLen = pool.length;
1898
+ const k = setup.numberOfCardsToDeal, total = setup.iterations;
1899
+ if (k > poolLen || total <= 0) return { win: 0, tie: 0, lose: 0 };
1900
+ const batchSize = Math.max(64, Math.min(1024, Math.ceil(total / 16)));
1901
+ const target = setup.mcTarget > 0 ? setup.mcTarget / 100 : 0;
1902
+ let st = new XorShift32().state;
1903
+ let issued = 0, done = 0, stop = false, win = 0, tie = 0, lose = 0;
1904
+ let nb = 0, sumEq = 0, sumEq2 = 0;
1905
+
1906
+ const nextBatch = () => {
1907
+ if (stop || issued >= total) return null;
1908
+ const n = Math.min(batchSize, total - issued);
1909
+ issued += n;
1910
+ const out = new Int32Array(n * 4);
1911
+ for (let i = 0; i < n; i++) {
1912
+ for (let j = 0; j < k; j++) { // partial Fisher-Yates over the live deck
1913
+ st ^= st << 13; st ^= st >> 17; st ^= st << 5;
1914
+ const r = j + Math.abs(st) % (poolLen - j);
1915
+ const c = pool[r]; pool[r] = pool[j]; pool[j] = c;
1916
+ out[i * 4 + (c & 3)] |= 1 << (c >> 2);
1917
+ }
1918
+ }
1919
+ return out;
1920
+ };
1921
+ const onResult = (r, n) => {
1922
+ win += r.win; tie += r.tie; lose += r.lose; done += n;
1923
+ const t = r.win + r.tie + r.lose;
1924
+ if (t > 0) { const e = (r.win + r.tie / 2) / t; nb++; sumEq += e; sumEq2 += e * e; }
1925
+ if (target && nb >= 8) {
1926
+ const mean = sumEq / nb, v = Math.max(0, (sumEq2 - nb * mean * mean) / (nb - 1));
1927
+ if (1.96 * Math.sqrt(v / nb) < target) stop = true;
1928
+ }
1929
+ if (progressCallback) progressCallback(done, total, `Sampled ${done.toLocaleString()} boards`);
1930
+ };
1931
+ await this._runKernelBatches(work, nextBatch, onResult, useWorkers, progressCallback);
1932
+ if (progressCallback) progressCallback(total, total, 'Complete');
1933
+ return { win, tie, lose };
1934
+ }
1935
+
1713
1936
  /**
1714
1937
  * Returns `count` persistent workers, lazily growing the pool as needed.
1715
1938
  * Reused across compareRange calls so thread-spawn + importScripts parse is
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "poker-bitvaljs",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
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
7
  "test": "node bench/correctness.js && node bench/determinism.js && node bench/exact-optimize.js 300 && node bench/eval-equivalence.js 1000000 && node bench/simulate-equivalence.js",
8
- "test:full": "node bench/eval-equivalence.js --full && node bench/engine-differential.js && node bench/exact-optimize.js 2000",
8
+ "test:full": "node bench/eval-equivalence.js --full && node bench/engine-differential.js && node bench/exact-optimize.js 2000 && node bench/mc-shared-board.js",
9
9
  "test:equiv": "node bench/eval-equivalence.js",
10
10
  "test:exact": "node bench/exact-optimize.js",
11
11
  "test:differential": "node bench/engine-differential.js",
12
12
  "test:preflop": "node bench/preflop-validate.js",
13
13
  "test:simulate": "node bench/simulate-equivalence.js",
14
+ "test:mc": "node bench/mc-shared-board.js",
14
15
  "gen:preflop": "node bench/gen-preflop-table.js --out bench/preflop-table.bin",
15
16
  "bench": "node bench/bench.js",
16
17
  "bench:correct": "node bench/correctness.js",