poker-bitvaljs 1.1.2 → 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 +574 -284
  4. package/package.json +11 -2
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
  // ============================================
@@ -672,13 +931,6 @@ class BitVal {
672
931
  // Generate valid matchups (always needed)
673
932
  const validMatchups = this._generateValidMatchups(heroHands, villainHands, boardCards);
674
933
 
675
- // Log all valid matchups for debugging
676
- console.log(`\n[Valid Matchups] Generated ${validMatchups.length} valid matchups:`);
677
- for (let i = 0; i < validMatchups.length; i++) {
678
- const m = validMatchups[i];
679
- console.log(` ${i + 1}. Hero: ${m.heroHand} vs Villain: ${m.villainHand}`);
680
- }
681
-
682
934
  // Calculate canonical mappings only if optimization is enabled
683
935
  let hCanon = null;
684
936
  let vCanon = null;
@@ -693,19 +945,24 @@ class BitVal {
693
945
  const boardMask = this.getBitMasked(boardCards);
694
946
  const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
695
947
 
696
- // 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.
697
950
  const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
698
- const canBeExhaustive = numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2;
951
+ const canBeExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
699
952
 
700
953
  // Calculate available cards using bitmask (more efficient than Set)
701
954
  const availableCards = 52 - this.countBits(deadCardsMask);
702
955
 
703
956
  // Determine if we should use exhaustive enumeration
704
- const exhaustiveCombinations = canBeExhaustive
705
- ? this.combinations(availableCards, numberOfCardsToDeal)
957
+ const exhaustiveCombinations = canBeExhaustive
958
+ ? this.combinations(availableCards, numberOfCardsToDeal)
706
959
  : Infinity;
707
- iterations = Math.min(iterations, exhaustiveCombinations);
708
- 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);
709
966
 
710
967
  // Generate combo array only if exhaustive
711
968
  const comboArray = isExhaustive
@@ -742,7 +999,8 @@ class BitVal {
742
999
  numberOfBoardCards: setup.numberOfBoardCards,
743
1000
  iterations: setup.iterations,
744
1001
  isExhaustive: setup.isExhaustive,
745
- numberOfCardsToDeal: setup.numberOfCardsToDeal
1002
+ numberOfCardsToDeal: setup.numberOfCardsToDeal,
1003
+ mcTarget: setup.mcTarget
746
1004
  // Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
747
1005
  // as matchups are pre-serialized and sent separately
748
1006
  };
@@ -790,55 +1048,84 @@ class BitVal {
790
1048
  * Optimized for browser performance with adaptive yielding and inlined hot loop.
791
1049
  * @private
792
1050
  */
793
- 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) {
794
1052
  let win = 0, tie = 0, lose = 0;
795
1053
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
796
- const matchupDeadMask = heroMask | villainMask; // Only matchup-level dead cards (excludes setup.deadCardsMask)
797
-
1054
+
798
1055
  // Initialize random number generator for Monte Carlo (not needed for exhaustive)
799
1056
  if (!setup.isExhaustive) {
800
1057
  this.xorShift = new XorShift32();
801
1058
  }
802
1059
 
803
- // Prepare for exhaustive enumeration if applicable
804
- 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`.
805
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;
806
1077
  if (setup.isExhaustive) {
807
- // Reuse pre-computed comboArray from setup (already computed in _compareRangeSetup)
808
- comboArray = setup.comboArray;
809
- 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;
810
1083
  }
811
-
812
- // Main evaluation loop - optimized with inlined functions and adaptive yielding
813
- 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;
814
1087
  let lastProgressTime = 0;
815
1088
  const progressUpdateInterval = 100; // Update progress at most every 100ms
816
-
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
+
817
1097
  for (let i = 0; i < iterations; i++) {
818
- // Generate board: use pre-computed combo for exhaustive, or deal randomly
819
- const board = setup.isExhaustive
820
- ? setup.boardMask | comboArray[i]
821
- : this.deal(setup.boardMask, deadMask, setup.numberOfCardsToDeal) | setup.boardMask;
822
-
823
- // Evaluate both hands - inlined for performance (no function call overhead)
824
- let hEval, hKick, vEval, vKick;
825
- if (evalCache) {
826
- [hEval, hKick] = this._getCachedEvaluation(evalCache.heroKey, evalCache.heroHand, board, evalCache.cache, matchupDeadMask);
827
- [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];
828
1104
  } else {
829
- [hEval, hKick] = this.evaluate(heroMask | board);
830
- [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
+ }
831
1115
  }
832
-
833
- // Compare and accumulate results - inlined for performance
834
- if (hEval > vEval || (hEval === vEval && hKick > vKick)) {
835
- win++;
836
- } else if (vEval > hEval || (vEval === hEval && vKick > hKick)) {
837
- lose++;
838
- } else {
839
- 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; }
840
1127
  }
841
-
1128
+
842
1129
  // Adaptive yielding: less frequent but still maintains UI responsiveness
843
1130
  if (progressCallback && i > 0 && i % yieldInterval === 0) {
844
1131
  const now = Date.now();
@@ -878,7 +1165,15 @@ class BitVal {
878
1165
  }
879
1166
  }
880
1167
  }
881
-
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
+
882
1177
  return { matchupWin: win, matchupTie: tie, matchupLose: lose };
883
1178
  }
884
1179
 
@@ -894,7 +1189,7 @@ class BitVal {
894
1189
 
895
1190
  for (let i = 0; i < total; i++) {
896
1191
  const { heroMask, villainMask } = setup.validMatchups[i];
897
- 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);
898
1193
  win += matchupWin; tie += matchupTie; lose += matchupLose;
899
1194
 
900
1195
  if (progressCallback && i % progressInterval === 0) {
@@ -939,7 +1234,6 @@ class BitVal {
939
1234
  */
940
1235
  async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
941
1236
  let win = 0, tie = 0, lose = 0;
942
- const evalCache = new FastestAutoClearingCache(16000000);
943
1237
  let lastProgressTime = 0;
944
1238
  const progressUpdateInterval = 100; // Update progress at most every 100ms
945
1239
 
@@ -948,118 +1242,129 @@ class BitVal {
948
1242
  ? this._getBoardSuitCounts(setup.boardCards)
949
1243
  : null;
950
1244
 
951
- // Group validMatchups by canonical key pair
952
- // This ensures we only process matchups that actually exist, avoiding Cartesian product issues
953
- const matchupGroups = new Map();
954
- console.log(`\n[Sequential - Grouping Matchups] Processing ${setup.validMatchups.length} valid matchups:`);
955
- for (const matchup of setup.validMatchups) {
956
- const mHKey = this._getCanonicalKey(
957
- matchup.heroHand,
958
- setup.boardCards,
959
- setup.numberOfBoardCards,
960
- boardSuitCounts
961
- );
962
- const mVKey = this._getCanonicalKey(
963
- matchup.villainHand,
964
- setup.boardCards,
965
- setup.numberOfBoardCards,
966
- boardSuitCounts
967
- );
968
- const key = `${mHKey}:${mVKey}`;
969
-
970
- console.log(` Hero: ${matchup.heroHand} (${mHKey}) vs Villain: ${matchup.villainHand} (${mVKey}) -> Key: ${key}`);
971
-
972
- if (!matchupGroups.has(key)) {
973
- matchupGroups.set(key, {
974
- heroKey: mHKey,
975
- villainKey: mVKey,
976
- representative: matchup,
977
- count: 0
978
- });
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++;
979
1257
  }
980
- matchupGroups.get(key).count++;
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; }
1280
+ }
1281
+ }
1282
+ }
1283
+ if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
1284
+ return { win, tie, lose };
981
1285
  }
982
- console.log(`\n[Sequential - Matchup Groups] Created ${matchupGroups.size} unique canonical key pairs:`);
983
- for (const [key, group] of matchupGroups) {
984
- console.log(` ${key}: ${group.count} matchup(s), example: ${group.representative.heroHand} vs ${group.representative.villainHand}`);
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);
985
1315
  }
986
-
987
- // Calculate accurate total matchups (number of unique canonical key pairs)
988
- const totalMatchups = matchupGroups.size;
989
-
990
- let matchupIndex = 0;
991
- // Iterate over actual matchup groups (not Cartesian product)
992
- console.log(`\n[Sequential - Evaluating Matchups] Starting evaluation of ${totalMatchups} unique matchups:`);
993
- for (const [key, group] of matchupGroups) {
994
- const validCount = group.count;
995
- const validPair = group.representative;
996
-
997
- // Log hand and canonical key mapping for debugging
998
- console.log(`[Matchup] Hero: ${validPair.heroHand} (Key: ${group.heroKey}) vs Villain: ${validPair.villainHand} (Key: ${group.villainKey}) | Count: ${validCount}`);
999
-
1000
- const cache = {
1001
- heroKey: group.heroKey,
1002
- heroHand: validPair.heroHand,
1003
- villainKey: group.villainKey,
1004
- villainHand: validPair.villainHand,
1005
- cache: evalCache
1006
- };
1007
-
1008
- const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
1009
- validPair.heroMask,
1010
- validPair.villainMask,
1011
- setup,
1012
- cache,
1013
- progressCallback,
1014
- matchupIndex,
1015
- totalMatchups
1016
- );
1017
-
1018
- // Log equity results for this matchup
1019
- const totalIterations = matchupWin + matchupTie + matchupLose;
1020
- const equity = totalIterations > 0 ? (matchupWin + matchupTie / 2) / totalIterations * 100 : 0;
1021
- const weightedEquity = equity * validCount;
1022
- console.log(`[Equity] ${validPair.heroHand} vs ${validPair.villainHand} (${validCount}x): Win=${matchupWin}, Tie=${matchupTie}, Lose=${matchupLose}, Total=${totalIterations}, Equity=${equity.toFixed(2)}%, Weighted=${weightedEquity.toFixed(2)}%`);
1023
-
1024
- win += matchupWin * validCount;
1025
- tie += matchupTie * validCount;
1026
- lose += matchupLose * validCount;
1027
-
1028
- matchupIndex++;
1029
- if (progressCallback && matchupIndex % progressInterval === 0) {
1030
- const now = Date.now();
1031
- const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
1032
-
1033
- // Use modern browser APIs for better scheduling when available
1034
- if (typeof scheduler !== 'undefined' && scheduler.postTask) {
1035
- await scheduler.postTask(() => {
1036
- if (shouldUpdateProgress) {
1037
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1038
- lastProgressTime = now;
1039
- }
1040
- }, { priority: 'user-blocking' });
1041
- } else if (typeof requestIdleCallback !== 'undefined') {
1042
- await new Promise(resolve => {
1043
- requestIdleCallback(() => {
1044
- if (shouldUpdateProgress) {
1045
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1046
- lastProgressTime = now;
1047
- }
1048
- resolve();
1049
- }, { timeout: 1 });
1050
- });
1051
- } else {
1052
- // Fallback to setTimeout
1053
- await new Promise(resolve => setTimeout(resolve, 0));
1054
- if (shouldUpdateProgress) {
1055
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1056
- lastProgressTime = now;
1057
- }
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;
1058
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++; }
1059
1366
  }
1060
1367
  }
1061
-
1062
- if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
1063
1368
  return { win, tie, lose };
1064
1369
  }
1065
1370
 
@@ -1069,15 +1374,26 @@ class BitVal {
1069
1374
  * @private
1070
1375
  */
1071
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
+
1072
1389
  // Pre-calculate board suit counts once (constant for all matchups)
1073
- const boardSuitCounts = setup.boardCards.length > 0
1074
- ? this._getBoardSuitCounts(setup.boardCards)
1390
+ const boardSuitCounts = setup.boardCards.length > 0
1391
+ ? this._getBoardSuitCounts(setup.boardCards)
1075
1392
  : null;
1076
1393
 
1077
1394
  // Group validMatchups by canonical key pair
1078
1395
  // This ensures we only process matchups that actually exist, avoiding Cartesian product issues
1079
1396
  const matchupGroups = new Map();
1080
- console.log(`\n[Parallel - Grouping Matchups] Processing ${setup.validMatchups.length} valid matchups:`);
1081
1397
  for (const matchup of setup.validMatchups) {
1082
1398
  const mHKey = this._getCanonicalKey(
1083
1399
  matchup.heroHand,
@@ -1093,8 +1409,6 @@ class BitVal {
1093
1409
  );
1094
1410
  const key = `${mHKey}:${mVKey}`;
1095
1411
 
1096
- console.log(` Hero: ${matchup.heroHand} (${mHKey}) vs Villain: ${matchup.villainHand} (${mVKey}) -> Key: ${key}`);
1097
-
1098
1412
  if (!matchupGroups.has(key)) {
1099
1413
  matchupGroups.set(key, {
1100
1414
  heroKey: mHKey,
@@ -1105,17 +1419,11 @@ class BitVal {
1105
1419
  }
1106
1420
  matchupGroups.get(key).count++;
1107
1421
  }
1108
- console.log(`\n[Parallel - Matchup Groups] Created ${matchupGroups.size} unique canonical key pairs:`);
1109
- for (const [key, group] of matchupGroups) {
1110
- console.log(` ${key}: ${group.count} matchup(s), example: ${group.representative.heroHand} vs ${group.representative.villainHand}`);
1111
- }
1112
1422
 
1113
1423
  // Collect all matchups to evaluate (only those that actually exist)
1114
1424
  const matchupsToEvaluate = [];
1115
- console.log(`\n[Parallel - Collecting Matchups] Building matchup list from ${matchupGroups.size} groups:`);
1116
1425
  for (const [key, group] of matchupGroups) {
1117
1426
  const validPair = group.representative;
1118
- console.log(` Adding: ${key} (${group.count}x) - Hero: ${validPair.heroHand} vs Villain: ${validPair.villainHand}`);
1119
1427
  matchupsToEvaluate.push({
1120
1428
  key,
1121
1429
  validCount: group.count,
@@ -1127,7 +1435,6 @@ class BitVal {
1127
1435
  villainMask: validPair.villainMask
1128
1436
  });
1129
1437
  }
1130
- console.log(`\n[Parallel - Matchups to Evaluate] Total: ${matchupsToEvaluate.length} unique matchups`);
1131
1438
 
1132
1439
  // Calculate total matchups (number of unique canonical key pairs)
1133
1440
  const totalMatchups = matchupsToEvaluate.length;
@@ -1149,15 +1456,17 @@ class BitVal {
1149
1456
  for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
1150
1457
  batches.push(matchupsToEvaluate.slice(i, i + batchSize));
1151
1458
  }
1152
-
1459
+
1153
1460
  // Serialize setup for workers
1154
1461
  const serializedSetup = this._serializeSetup(setup);
1155
-
1156
- // Create workers and distribute work
1157
- 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);
1158
1467
  const promises = [];
1159
1468
  let completedMatchups = 0;
1160
-
1469
+
1161
1470
  // Progress tracking
1162
1471
  let lastProgressTime = 0;
1163
1472
  const progressUpdateInterval = 100;
@@ -1168,58 +1477,50 @@ class BitVal {
1168
1477
  lastProgressTime = now;
1169
1478
  }
1170
1479
  }, progressUpdateInterval) : null;
1171
-
1480
+
1172
1481
  for (let i = 0; i < batches.length; i++) {
1173
- const worker = new Worker('./bitval-worker.js');
1174
- workers.push(worker);
1175
-
1482
+ const worker = workers[i];
1483
+
1176
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; };
1177
1488
  worker.onmessage = (e) => {
1489
+ cleanup();
1178
1490
  if (e.data.success) {
1179
1491
  completedMatchups += e.data.results.length;
1180
1492
  resolve(e.data.results);
1181
1493
  } else {
1182
1494
  reject(new Error(e.data.error || 'Worker error'));
1183
1495
  }
1184
- worker.terminate();
1185
1496
  };
1186
1497
  worker.onerror = (error) => {
1498
+ cleanup();
1499
+ // A crashed worker can't be reused - drop it from the pool.
1500
+ this._discardWorker(worker);
1187
1501
  reject(error);
1188
- worker.terminate();
1189
1502
  };
1190
-
1503
+
1191
1504
  // Serialize matchups for this batch
1192
1505
  const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
1193
-
1506
+
1194
1507
  worker.postMessage({
1195
1508
  matchups: serializedBatch,
1196
1509
  setupData: serializedSetup,
1197
1510
  workerId: i
1198
1511
  });
1199
1512
  });
1200
-
1513
+
1201
1514
  promises.push(promise);
1202
1515
  }
1203
-
1516
+
1204
1517
  // Wait for all workers and aggregate results
1205
1518
  let win = 0, tie = 0, lose = 0;
1206
1519
  try {
1207
1520
  const allResults = await Promise.all(promises);
1208
-
1521
+
1209
1522
  for (const batchResults of allResults) {
1210
1523
  for (const result of batchResults) {
1211
- // Log equity results for this matchup
1212
- const totalIterations = result.matchupWin + result.matchupTie + result.matchupLose;
1213
- const equity = totalIterations > 0 ? (result.matchupWin + result.matchupTie / 2) / totalIterations * 100 : 0;
1214
- const weightedEquity = equity * result.validCount;
1215
-
1216
- // Find the matchup details from matchupsToEvaluate
1217
- const matchup = matchupsToEvaluate.find(m => m.key === result.key);
1218
- const heroHand = matchup ? matchup.heroHand : 'Unknown';
1219
- const villainHand = matchup ? matchup.villainHand : 'Unknown';
1220
-
1221
- console.log(`[Equity] ${heroHand} vs ${villainHand} (${result.validCount}x): Win=${result.matchupWin}, Tie=${result.matchupTie}, Lose=${result.matchupLose}, Total=${totalIterations}, Equity=${equity.toFixed(2)}%, Weighted=${weightedEquity.toFixed(2)}%`);
1222
-
1223
1524
  win += result.matchupWin * result.validCount;
1224
1525
  tie += result.matchupTie * result.validCount;
1225
1526
  lose += result.matchupLose * result.validCount;
@@ -1230,58 +1531,55 @@ class BitVal {
1230
1531
  if (progressIntervalId) {
1231
1532
  clearInterval(progressIntervalId);
1232
1533
  }
1233
-
1234
- // Ensure all workers are terminated
1235
- workers.forEach(worker => {
1236
- try {
1237
- worker.terminate();
1238
- } catch (e) {
1239
- // Ignore termination errors
1240
- }
1241
- });
1242
-
1534
+
1535
+ // Workers are NOT terminated - they stay in the pool for the next call.
1536
+ // Callers can free them explicitly with terminateWorkerPool().
1537
+
1243
1538
  // Final progress update
1244
1539
  if (progressCallback) {
1245
1540
  progressCallback(totalMatchups, totalMatchups, 'Complete');
1246
1541
  }
1247
1542
  }
1248
-
1543
+
1249
1544
  return { win, tie, lose };
1250
1545
  }
1251
1546
 
1252
1547
  /**
1253
- * 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.
1254
1551
  * @private
1255
1552
  */
1256
- _getCacheKeyHash(canonicalKey, board, matchupDeadMask = 0n) {
1257
- // Hash the canonical key string
1258
- let hash = 0;
1259
- for (let i = 0; i < canonicalKey.length; i++) {
1260
- hash = ((hash << 5) - hash) + canonicalKey.charCodeAt(i);
1261
- hash = hash & hash; // Convert to 32-bit integer
1262
- }
1263
- // Combine with board bits (use lower 32 bits of BigInt for fast XOR)
1264
- const boardBits = Number(board & 0xFFFFFFFFn);
1265
- hash = hash ^ boardBits;
1266
-
1267
- // Include matchup dead mask to prevent cross-matchup cache collisions
1268
- // This ensures different matchups (with different dead cards) don't share cache entries
1269
- const deadBits = Number(matchupDeadMask & 0xFFFFFFFFn);
1270
- 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);
1271
1559
  }
1272
1560
 
1273
1561
  /**
1274
- * Gets cached evaluation or evaluates and caches the result.
1275
- * Uses numeric hash keys for faster cache lookups.
1562
+ * Removes a (typically crashed) worker from the pool and terminates it.
1276
1563
  * @private
1277
1564
  */
1278
- _getCachedEvaluation(canonicalKey, originalHand, completeBoard, evalCache, matchupDeadMask = 0n) {
1279
- const cacheKey = this._getCacheKeyHash(canonicalKey, completeBoard, matchupDeadMask);
1280
- if (evalCache.has(cacheKey)) return evalCache.get(cacheKey);
1281
- const handMask = this.getBitMasked(this._handStringToCards(originalHand));
1282
- const evaluation = this.evaluate(handMask | completeBoard);
1283
- evalCache.set(cacheKey, evaluation);
1284
- 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;
1285
1583
  }
1286
1584
 
1287
1585
  /**
@@ -1357,21 +1655,12 @@ class BitVal {
1357
1655
  const canonicalToHand = new Map();
1358
1656
  const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
1359
1657
 
1360
- console.log(`\n[Canonical Mapping] Processing ${hands.length} hands:`);
1361
1658
  for (const hand of hands) {
1362
1659
  const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
1363
1660
  canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
1364
1661
  if (!canonicalToHand.has(key)) {
1365
1662
  canonicalToHand.set(key, hand);
1366
1663
  }
1367
- console.log(` Hand: ${hand} -> Canonical Key: ${key}`);
1368
- }
1369
-
1370
- // Show summary of canonical key groupings
1371
- console.log(`\n[Canonical Summary] ${canonicalMap.size} unique canonical keys from ${hands.length} hands:`);
1372
- for (const [key, count] of canonicalMap.entries()) {
1373
- const representativeHand = canonicalToHand.get(key);
1374
- console.log(` ${key}: ${count} hand(s), example: ${representativeHand}`);
1375
1664
  }
1376
1665
 
1377
1666
  return { canonicalMap, canonicalToHand };
@@ -1441,6 +1730,7 @@ class BitVal {
1441
1730
  * @private
1442
1731
  */
1443
1732
  _getCombinations(availableMasks, k) {
1733
+ if (k === 0) return [0n]; // complete board: exactly one runout (the empty draw)
1444
1734
  if (k === 1) return availableMasks;
1445
1735
  if (k === 2) {
1446
1736
  let combos = [];