Compare commits

..

10 commits

Author SHA1 Message Date
5a8a194be3 fix: make register_bot_type idempotent (skip instead of panic on duplicate)
Prevents test panics when multiple strategies register the same bot type
in parallel test threads.
2026-07-17 07:20:19 +02:00
e5e56fd772 chore: gitignore AGENTS.md (project-local file) 2026-07-06 08:23:15 +02:00
d00df65a98 fix: seat restoration + chip refund on hand error, NoLimit max_raises
Critical fix: complete_hand() now restores seats when run_to_completion()
fails, preventing silent NotEnoughPlayers termination that killed
simulations at random seeds. Wagered chips are refunded before reset to
prevent chip destruction on error path.

Also adds max_raises: Option<usize> to BetLimit::NoLimit, propagated
across all game/bot/tournament construction sites.

NotEnoughPlayers now logs a warning with hand count instead of silently
breaking the simulation loop.
2026-07-06 08:07:38 +02:00
edf8d7ae3f feat: simulation resilience + flock_bot raise safety + trait extensions
- simulation.rs: RaiseBelowMinimum/InsufficientChips now skip hand
  instead of crashing entire simulation. Hands skipped are NOT counted
  in total_hands (accurate player stats).
- flock_bot.rs: Premium preflop raise falls back to Call when stack
  cannot afford minimum raise, preventing illegal AllIn edge cases.
- Added shutdown(), showdown_event() trait methods to HoldemPlayer.
- Added external_id, table_id to GameInfo/PlayerInfo.
- Strategy trait: added save_state() for graceful profile persistence.
2026-06-28 07:11:56 +02:00
05db24f670 chore: remove gitignored REFERENCES.md, moved to Joplin 2026-05-31 12:02:37 +02:00
ed911e3c3a test: add exhaustive multi-threaded 7-card correctness test across all three evaluators 2026-05-30 15:26:54 +02:00
458b8af580 fix: rewrite Supersonic LUT generator, wire evaluator chain, parallelize tournaments
- Rewrite generate_tables() with HashMap-based BFS instead of broken
  sentinel-loop sorted array approach. The old generator used a
  zero-terminated loop that stopped prematurely when mid-array key
  insertions left gaps, producing invalid trie pointers.

- Re-enable auto-generation in load_or_generate() since the generator
  now works correctly (verified by exhaustive C(52,5) correctness test).

- Wire HandRunner::showdown() through Evaluator::instance() instead of
  calling AlgorithmicEvaluator directly, giving access to the full
  3-tier cascade (Supersonic -> Cactus Kev -> Algorithmic).

- Parallelize tournament series loop with rayon for multi-core speedup.

- Remove dead insert_key function and test (unused after generator rewrite).

- Fix tautological benchmark assertion in integration tests.
2026-05-30 12:55:11 +02:00
a46a800d11 feat: align num_raises with Meerkat semantics and add voluntary_raises() helper
- Change GameInfo.num_raises to use Meerkat convention where BB counts
  as the first raise preflop (subtract 1 for SB only, not 2)
- Add GameInfo::voluntary_raises() for Fixed-Limit cap enforcement
- Update flock_bot, raise_bot, and random_bot to use voluntary_raises()
  for can_raise() checks
- Add Evaluator::supersonic() accessor for incremental trie API
- Update documentation across betting.rs, game_info.rs, hand_runner.rs,
  and README.md to clarify the two counting conventions
2026-05-27 18:44:29 +02:00
156904f2bf feat: add incremental trie lookup API and Deck::without_cards
- Add TrieCursor struct for partial Supersonic LUT trie traversal
- Add init_lookup/advance_lookup/finish_* methods for 5/6/7-card incremental eval
- Add raw_init/raw_advance/raw_finish_* zero-cost API (no depth tracking)
- Add Deck::without_cards() for constructing remaining decks
- Re-export TrieCursor from evaluator module
- Add comprehensive tests for all new APIs
- Update .gitignore for docs/REFERENCES.md
2026-05-25 19:44:21 +02:00
912445d992 Fix supersonic evaluator and documentation examples
- Remove debug_assert(false) from supersonic evaluator
- Disable buggy supersonic generator
- Fix documentation examples in README.md and holdem_core/README.md
2026-05-25 14:30:16 +02:00
32 changed files with 1350 additions and 425 deletions

5
.gitignore vendored
View file

@ -11,3 +11,8 @@ Thumbs.db
# Proptest regressions (auto-generated)
proptest-regressions/
# Agent notes
AGENTS.md
AGENTS.md

46
Cargo.lock generated
View file

@ -222,6 +222,25 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@ -283,6 +302,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@ -439,6 +464,7 @@ dependencies = [
"once_cell",
"proptest",
"rand",
"rayon",
"serde",
"serde_json",
"sha2",
@ -752,6 +778,26 @@ dependencies = [
"rand_core",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"

View file

@ -161,26 +161,27 @@ The evaluator recognizes all standard poker hand types:
### Texas Hold'em Bot Analysis
```rust
use holdem_core::hand_evaluator::LookupHandEvaluator;
use holdem_core::api::{card::Card, hand::Hand};
use holdem_core::{Card, Hand, HoleCards, Board, Evaluator};
use std::str::FromStr;
let evaluator = LookupHandEvaluator::new().unwrap();
let evaluator = Evaluator::new().unwrap();
// Bot analyzes hole cards + flop for decision making
let hole_card1 = Card::from_string("As").unwrap().index() as u32;
let hole_card2 = Card::from_string("Ks").unwrap().index() as u32;
let mut board = Hand::new();
board.add_card(Card::from_string("Td").unwrap()).unwrap();
board.add_card(Card::from_string("Jc").unwrap()).unwrap();
board.add_card(Card::from_string("Qh").unwrap()).unwrap();
let hole_cards = HoleCards::from_notation("AKs").unwrap();
let board = Board::new()
.with_flop([
Card::from_str("Td").unwrap(),
Card::from_str("Jc").unwrap(),
Card::from_str("Qh").unwrap(),
]).unwrap();
// Bot evaluates complete hand strength
let hand_strength = evaluator.rank_hand_with_hole_cards(hole_card1, hole_card2, &board);
println!("Hand strength after flop: {}", hand_strength);
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
let hand_value = evaluator.evaluate_hand(&hand);
println!("Hand strength after flop: {:?}", hand_value.rank);
// Bot uses this for post-flop decision making
if hand_strength < 2000 { // Strong hand
if hand_value.rank as u8 >= 4 { // Straight or better
println!("Bot: Continuing with strong hand");
}
```
@ -188,25 +189,22 @@ if hand_strength < 2000 { // Strong hand
### Bot Range Analysis
```rust
use holdem_core::hand_evaluator::LookupHandEvaluator;
use holdem_core::api::card::Card;
use holdem_core::{Card, Hand, Evaluator};
use std::str::FromStr;
let evaluator = LookupHandEvaluator::new().unwrap();
let evaluator = Evaluator::new().unwrap();
// Bot analyzes range of starting hands for pre-flop strategy
let premium_hands = vec![
(Card::from_string("AA").unwrap(), Card::from_string("KK").unwrap()),
(Card::from_string("QQ").unwrap(), Card::from_string("JJ").unwrap()),
(Card::from_string("AK").unwrap(), Card::from_string("AQ").unwrap()),
Hand::from_notation("As Ah").unwrap(),
Hand::from_notation("Ks Kh").unwrap(),
Hand::from_notation("Qs Qh").unwrap(),
Hand::from_notation("As Ks").unwrap(),
];
for (card1, card2) in premium_hands {
let mut hand = Hand::new();
hand.add_card(card1).unwrap();
hand.add_card(card2).unwrap();
let strength = evaluator.rank_hand(&hand);
println!("Bot range analysis - {}: {}", hand.to_string(), strength);
for hand in premium_hands {
let hand_value = evaluator.evaluate_hand(&hand);
println!("Bot range analysis - {}: {:?}", hand, hand_value.rank);
}
```
@ -215,29 +213,26 @@ for (card1, card2) in premium_hands {
### 7-Card Hand Evaluation for Bot Testing
```rust
use holdem_core::hand_evaluator::LookupHandEvaluator;
use holdem_core::api::card::Card;
use holdem_core::{Card, Hand, HoleCards, Board, Evaluator};
use std::str::FromStr;
let evaluator = LookupHandEvaluator::new().unwrap();
let evaluator = Evaluator::new().unwrap();
// Texas Hold'em complete hand: 2 hole cards + 5 community cards
let hole_cards = [
Card::from_string("As").unwrap().index(),
Card::from_string("Ks").unwrap().index(),
];
let hole_cards = HoleCards::from_notation("AKs").unwrap();
let board = Board::new()
.with_flop([
Card::from_str("Td").unwrap(),
Card::from_str("Jc").unwrap(),
Card::from_str("Qh").unwrap(),
]).unwrap()
.with_turn(Card::from_str("9s").unwrap()).unwrap()
.with_river(Card::from_str("2d").unwrap()).unwrap();
let community_cards = [
Card::from_string("Td").unwrap().index(),
Card::from_string("Jc").unwrap().index(),
Card::from_string("Qh").unwrap().index(),
Card::from_string("9s").unwrap().index(),
Card::from_string("2d").unwrap().index(),
];
// Bot evaluates best possible hand from all cards
let all_cards: Vec<u32> = hole_cards.iter().chain(community_cards.iter()).cloned().collect();
let best_rank = evaluator.rank_hand7(&all_cards);
println!("Bot best hand rank: {}", best_rank);
// Bot evaluates best possible hand from all 7 cards
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
let hand_value = evaluator.evaluate_hand(&hand);
println!("Bot best hand rank: {:?}", hand_value.rank);
```
## Testing

View file

@ -28,6 +28,7 @@ log = "0.4"
thiserror = "2"
dashmap = "6"
dirs = "6"
rayon = "1.10"
[dev-dependencies]
proptest = "1.0"

View file

@ -53,8 +53,7 @@ Key modules:
### Basic Usage
```rust
use math::{Evaluator, HandRank};
use holdem_core::{Card, Hand};
use holdem_core::{Card, Hand, Evaluator, evaluator::HandRank};
use std::str::FromStr;
// Get the singleton evaluator instance
@ -67,22 +66,30 @@ let hand = Hand::from_notation("As Ks Qs Js Ts").unwrap();
let hand_value = evaluator.evaluate_hand(&hand);
println!("Hand rank: {:?}", hand_value.rank);
println!("Hand value: {}", hand_value.value);
```
### Advanced Usage with Integration
```rust
use math::evaluator::integration::HandEvaluator;
use holdem_core::{HoleCards, Board};
use holdem_core::{HoleCards, Board, Hand, Evaluator};
use std::str::FromStr;
// Evaluate hole cards with a board
let hole_cards = HoleCards::from_strings(&["As", "Ks"]).unwrap();
let board = Board::from_strings(&["Qs", "Js", "Ts", "7h", "3d"]).unwrap();
let hole_cards = HoleCards::from_notation("AKs").unwrap();
let board = Board::new()
.with_flop([
std::str::FromStr::from_str("Qs").unwrap(),
std::str::FromStr::from_str("Js").unwrap(),
std::str::FromStr::from_str("Ts").unwrap(),
]).unwrap()
.with_turn(std::str::FromStr::from_str("7h").unwrap()).unwrap()
.with_river(std::str::FromStr::from_str("3d").unwrap()).unwrap();
let hand_value = HandEvaluator::evaluate_hole_cards_with_board(&hole_cards, &board).unwrap();
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
let evaluator = Evaluator::new().unwrap();
let hand_value = evaluator.evaluate_hand(&hand);
println!("Final hand: {}", HandEvaluator::format_hand_value(hand_value));
println!("Final hand rank: {:?}", hand_value.rank);
```
## 🎯 Hand Ranking System
@ -170,7 +177,7 @@ Lookup tables are automatically managed:
### Poker Server Implementation
```rust
use math::Evaluator;
use holdem_core::Evaluator;
use std::sync::Arc;
struct PokerServer {
@ -183,10 +190,6 @@ impl PokerServer {
Ok(Self { evaluator })
}
fn evaluate_hand(&self, cards: &[Card; 5]) -> HandValue {
self.evaluator.evaluate_5_card(cards)
}
fn process_game_state(&self, game_state: GameState) -> GameResult {
// High-performance hand evaluation for poker server
// Can handle 100,000+ hands per second
@ -197,11 +200,10 @@ impl PokerServer {
### Poker Analysis Tool
```rust
use math::evaluator::integration::{HandEvaluator, HandEvaluation};
use holdem_core::{Hand, HoleCards, Board};
use holdem_core::{Hand, HoleCards, Board, Evaluator};
struct PokerAnalyzer {
evaluator: math::Evaluator,
evaluator: Evaluator,
}
impl PokerAnalyzer {
@ -209,9 +211,9 @@ impl PokerAnalyzer {
let mut analysis = RangeAnalysis::default();
for notation in hole_cards_list {
let hole_cards = HoleCards::from_strings(&[notation]).unwrap();
let hole_cards = HoleCards::from_notation(notation).unwrap();
let hand = Hand::from_hole_cards_and_board(&hole_cards, board).unwrap();
let hand_value = hand.evaluate();
let hand_value = self.evaluator.evaluate_hand(&hand);
// Analyze hand strength distribution
analysis.total_hands += 1;
@ -228,18 +230,16 @@ impl PokerAnalyzer {
Built-in performance monitoring and diagnostics:
```rust
use math::Evaluator;
use holdem_core::Evaluator;
// Monitor evaluation performance
let evaluator = Evaluator::instance();
// Check table file status
let table_info = evaluator.get_table_info().unwrap();
println!("Table files: {:?}", table_info);
// Validate table integrity
let is_valid = evaluator.validate_table_files().unwrap();
println!("Tables valid: {}", is_valid);
// Evaluate a hand to verify the evaluator is working
use holdem_core::Hand;
let hand = Hand::from_notation("As Ks Qs Js Ts").unwrap();
let hand_value = evaluator.evaluate_hand(&hand);
println!("Hand rank: {:?}", hand_value.rank);
```
## 🚨 Error Handling
@ -247,7 +247,8 @@ println!("Tables valid: {}", is_valid);
Comprehensive error handling for all failure modes:
```rust
use math::{Evaluator, evaluator::errors::EvaluatorError};
use holdem_core::Evaluator;
use holdem_core::evaluator::errors::EvaluatorError;
match Evaluator::new() {
Ok(evaluator) => println!("Evaluator ready"),

View file

@ -210,6 +210,42 @@ impl Deck {
pub fn cards(&self) -> &[Card] {
&self.cards
}
/// Creates a new deck excluding the specified cards.
///
/// Useful for constructing a "remaining deck" that omits known cards
/// (hero's hole cards, board cards) — typically resulting in a 45–47 card
/// deck for enumeration-based equity calculations.
///
/// # Examples
///
/// ```rust
/// use holdem_core::{Deck, Card};
/// use std::str::FromStr;
///
/// let deck = Deck::new();
/// let excluded = vec![
/// Card::from_str("As").unwrap(),
/// Card::from_str("Kh").unwrap(),
/// ];
/// let remaining = deck.without_cards(&excluded);
/// assert_eq!(remaining.remaining(), 50);
/// ```
///
/// # Panics
///
/// This method does not panic.
pub fn without_cards(&self, excluded: &[Card]) -> Deck {
use std::collections::HashSet;
let exclude_set: HashSet<Card> = excluded.iter().copied().collect();
let cards: Vec<Card> = self
.cards
.iter()
.copied()
.filter(|c| !exclude_set.contains(c))
.collect();
Deck { cards }
}
}
impl Default for Deck {

View file

@ -723,6 +723,15 @@ impl Evaluator {
self.supersonic.is_some()
}
/// Returns a reference to the underlying [`SupersonicEvaluator`], if available.
///
/// Use this for the incremental trie API ([`init_lookup`](SupersonicEvaluator::init_lookup),
/// [`advance_lookup`](SupersonicEvaluator::advance_lookup), etc.) which avoids
/// re-traversing shared card prefixes during rollouts.
pub fn supersonic(&self) -> Option<&SupersonicEvaluator> {
self.supersonic.as_deref()
}
/// Evaluate a 5-card hand
pub fn evaluate_5_card(&self, cards: &[Card; 5]) -> HandValue {
// Priority 1: Supersonic LUT (O(5) array lookups)

View file

@ -55,8 +55,8 @@ pub fn basic_usage_example() -> Result<(), EvaluatorError> {
log::info!("JumpTable result: {:?}", result2);
// Results should be identical
assert_eq!(result1, result2.unwrap());
// Ranks should be consistent across evaluators (value encoding may differ)
assert_eq!(result1.rank, result2.unwrap().rank);
log::info!("✓ Basic evaluation complete\n");
Ok(())

View file

@ -0,0 +1,330 @@
//! Full correctness test: exhaustive C(52,7) validation across all three evaluators.
//!
//! Compares [`AlgorithmicEvaluator`], [`CactusKevEvaluator`], and
//! [`SupersonicEvaluator`] on every possible 7-card poker hand
//! (133,784,560 combinations) using multi-threaded parallelism via **rayon**.
//!
//! # What is checked
//!
//! | Pair | Rank match | Value match |
//! |--------------------------------|:----------:|:-----------:|
//! | Algorithmic vs Cactus Kev | ✅ | ✅ |
//! | Algorithmic vs Supersonic | ✅ | —¹ |
//! | Cactus Kev vs Supersonic | ✅ | —¹ |
//!
//! ¹ Supersonic uses a different sub-rank encoding; only the `HandRank`
//! category is compared.
//!
//! # Running
//!
//! ```sh
//! cargo test -p holdem_core --lib \
//! evaluator::full_correctness_test::test_full_7card_correctness \
//! -- --ignored --nocapture
//! ```
use super::algorithmic_evaluator::AlgorithmicEvaluator;
use super::cactus_kev::CactusKevEvaluator;
use super::evaluator::{HandRank, HandValue};
use super::supersonic::SupersonicEvaluator;
use crate::Card;
use rayon::prelude::*;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Convert a 0–51 card index to a [`Card`].
#[inline]
fn idx_to_card(idx: u32) -> Card {
Card {
rank: (idx / 4) as u8,
suit: (idx % 4) as u8,
}
}
/// Evaluate a 7-card hand with [`CactusKevEvaluator`] by enumerating all
/// C(7,5) = 21 five-card subsets and returning the best result.
fn ck_evaluate_7(ck: &CactusKevEvaluator, cards: &[Card; 7]) -> HandValue {
let mut best = HandValue::new(HandRank::HighCard, 0);
for a in 0..7 {
for b in (a + 1)..7 {
let mut five = [Card { rank: 0, suit: 0 }; 5];
let mut k = 0;
for (i, &c) in cards.iter().enumerate() {
if i != a && i != b {
five[k] = c;
k += 1;
}
}
debug_assert_eq!(k, 5);
let hv = ck.evaluate_5_card(&five);
if hv > best {
best = hv;
}
}
}
best
}
/// Format a mismatch for the error report.
fn fmt_mismatch(label: &str, cards: &[Card; 7], a: HandValue, b: HandValue) -> String {
let cards_str: Vec<String> = cards.iter().map(|c| format!("{}", c)).collect();
format!(
"{}: [{}] → {:?} vs {:?}",
label,
cards_str.join(" "),
a,
b
)
}
// ---------------------------------------------------------------------------
// Per-chunk statistics (one per rayon worker thread)
// ---------------------------------------------------------------------------
#[derive(Default)]
struct ChunkStats {
total: u64,
/// Algorithmic vs CactusKev — rank category differs.
algo_ck_rank_mm: u64,
/// Algorithmic vs CactusKev — rank OK, value differs.
algo_ck_value_mm: u64,
/// Algorithmic vs Supersonic — rank category differs.
algo_ss_rank_mm: u64,
/// CactusKev vs Supersonic — rank category differs.
ck_ss_rank_mm: u64,
/// First recorded mismatch for each evaluator pair (for diagnostics).
first_algo_ck: Option<String>,
first_algo_ss: Option<String>,
first_ck_ss: Option<String>,
}
// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
/// Exhaustive 7-card correctness test across all three evaluators.
///
/// Iterates every C(52,7) = 133,784,560 hand in parallel (outer loop
/// parallelised over 52 chunks via rayon) and verifies that all three
/// evaluators agree.
#[test]
#[ignore]
fn test_full_7card_correctness() {
const EXPECTED: u64 = 133_784_560; // C(52,7)
eprintln!("═══════════════════════════════════════════════════════════════════");
eprintln!(" Full 7-Card Correctness Test");
eprintln!(" {} hands × 3 evaluators, multi-threaded via rayon", EXPECTED);
eprintln!("═══════════════════════════════════════════════════════════════════");
// ------------------------------------------------------------------
// 1. Build evaluators
// ------------------------------------------------------------------
eprintln!("[1/3] AlgorithmicEvaluator — stateless, no init");
eprintln!("[2/3] CactusKevEvaluator — building lookup tables …");
let ck = CactusKevEvaluator::new();
eprintln!(
" → flush entries = {}, unsuited entries = {}",
ck.flush_table_size(),
ck.unsuited_table_size()
);
eprintln!("[3/3] SupersonicEvaluator — loading / generating LUT …");
let ss = match SupersonicEvaluator::load_or_generate() {
Some(s) => {
eprintln!(" → {} entries loaded", s.table_size());
s
}
None => {
eprintln!(" ✗ Supersonic table unavailable — skipping test");
return;
}
};
// ------------------------------------------------------------------
// 2. Parallel enumeration of all C(52,7) hands
// ------------------------------------------------------------------
eprintln!("\nEnumerating all 7-card hands …");
let start = std::time::Instant::now();
let chunks: Vec<ChunkStats> = (0u32..52)
.into_par_iter()
.map(|i0| {
let mut st = ChunkStats::default();
for i1 in (i0 + 1)..52 {
for i2 in (i1 + 1)..52 {
for i3 in (i2 + 1)..52 {
for i4 in (i3 + 1)..52 {
for i5 in (i4 + 1)..52 {
for i6 in (i5 + 1)..52 {
let cards = [
idx_to_card(i0),
idx_to_card(i1),
idx_to_card(i2),
idx_to_card(i3),
idx_to_card(i4),
idx_to_card(i5),
idx_to_card(i6),
];
let algo = AlgorithmicEvaluator::evaluate_7_card(&cards);
let ck_hv = ck_evaluate_7(&ck, &cards);
let ss_hv = ss.evaluate_7_card(&cards);
st.total += 1;
// --- Algorithmic vs CactusKev (exact match) ---
if algo.rank != ck_hv.rank {
st.algo_ck_rank_mm += 1;
if st.first_algo_ck.is_none() {
st.first_algo_ck = Some(fmt_mismatch(
"ALGO≠CK rank",
&cards,
algo,
ck_hv,
));
}
} else if algo.value != ck_hv.value {
st.algo_ck_value_mm += 1;
if st.first_algo_ck.is_none() {
st.first_algo_ck = Some(fmt_mismatch(
"ALGO≠CK value",
&cards,
algo,
ck_hv,
));
}
}
// --- Algorithmic vs Supersonic (rank only) ---
if algo.rank != ss_hv.rank {
st.algo_ss_rank_mm += 1;
if st.first_algo_ss.is_none() {
st.first_algo_ss = Some(fmt_mismatch(
"ALGO≠SS rank",
&cards,
algo,
ss_hv,
));
}
}
// --- CactusKev vs Supersonic (rank only) ---
if ck_hv.rank != ss_hv.rank {
st.ck_ss_rank_mm += 1;
if st.first_ck_ss.is_none() {
st.first_ck_ss = Some(fmt_mismatch(
"CK≠SS rank",
&cards,
ck_hv,
ss_hv,
));
}
}
}
}
}
}
}
}
st
})
.collect();
let elapsed = start.elapsed();
// ------------------------------------------------------------------
// 3. Aggregate results
// ------------------------------------------------------------------
let mut total: u64 = 0;
let mut algo_ck_rank_mm: u64 = 0;
let mut algo_ck_value_mm: u64 = 0;
let mut algo_ss_rank_mm: u64 = 0;
let mut ck_ss_rank_mm: u64 = 0;
let mut first_algo_ck: Option<String> = None;
let mut first_algo_ss: Option<String> = None;
let mut first_ck_ss: Option<String> = None;
for ch in chunks {
total += ch.total;
algo_ck_rank_mm += ch.algo_ck_rank_mm;
algo_ck_value_mm += ch.algo_ck_value_mm;
algo_ss_rank_mm += ch.algo_ss_rank_mm;
ck_ss_rank_mm += ch.ck_ss_rank_mm;
if first_algo_ck.is_none() {
first_algo_ck = ch.first_algo_ck;
}
if first_algo_ss.is_none() {
first_algo_ss = ch.first_algo_ss;
}
if first_ck_ss.is_none() {
first_ck_ss = ch.first_ck_ss;
}
}
// ------------------------------------------------------------------
// 4. Report
// ------------------------------------------------------------------
let throughput = total as f64 / elapsed.as_secs_f64();
eprintln!("\n═══════════════════════════════════════════════════════════════════");
eprintln!(" Results");
eprintln!("═══════════════════════════════════════════════════════════════════");
eprintln!(" Hands evaluated : {} / {}", total, EXPECTED);
eprintln!(" Wall-clock time : {:.2?}", elapsed);
eprintln!(" Throughput : {:.0} hands/sec", throughput);
eprintln!();
eprintln!(" Algorithmic vs CactusKev:");
eprintln!(" Rank mismatches : {}", algo_ck_rank_mm);
eprintln!(" Value mismatches : {}", algo_ck_value_mm);
print_first(&first_algo_ck);
eprintln!();
eprintln!(" Algorithmic vs Supersonic:");
eprintln!(" Rank mismatches : {}", algo_ss_rank_mm);
print_first(&first_algo_ss);
eprintln!();
eprintln!(" CactusKev vs Supersonic:");
eprintln!(" Rank mismatches : {}", ck_ss_rank_mm);
print_first(&first_ck_ss);
eprintln!("═══════════════════════════════════════════════════════════════════");
// ------------------------------------------------------------------
// 5. Assertions
// ------------------------------------------------------------------
assert_eq!(
total, EXPECTED,
"must enumerate all C(52,7) = {} hands",
EXPECTED
);
assert_eq!(
algo_ck_rank_mm, 0,
"Algorithmic vs CactusKev: {} rank mismatches",
algo_ck_rank_mm
);
assert_eq!(
algo_ck_value_mm, 0,
"Algorithmic vs CactusKev: {} value mismatches",
algo_ck_value_mm
);
assert_eq!(
algo_ss_rank_mm, 0,
"Algorithmic vs Supersonic: {} rank mismatches",
algo_ss_rank_mm
);
assert_eq!(
ck_ss_rank_mm, 0,
"CactusKev vs Supersonic: {} rank mismatches",
ck_ss_rank_mm
);
eprintln!("\n✓ All three evaluators agree on all {} hands.", total);
}
fn print_first(first: &Option<String>) {
if let Some(ref msg) = first {
eprintln!(" First mismatch : {}", msg);
}
}

View file

@ -745,12 +745,10 @@ mod tests {
#[test]
fn test_benchmark_function() {
let elapsed = benchmark_evaluation(|_cards| {
// Simple evaluation for testing
let _elapsed = benchmark_evaluation(|_cards| {
HandValue::new(HandRank::HighCard, 0)
});
assert!(elapsed.as_nanos() > 0);
// Test verifies benchmark_evaluation does not panic.
}
#[test]

View file

@ -46,6 +46,9 @@ pub mod tables;
#[cfg(test)]
mod algorithmic_evaluator_test;
#[cfg(test)]
mod full_correctness_test;
// Re-export commonly used types from local modules
pub use algorithmic_evaluator::AlgorithmicEvaluator;
pub use errors::EvaluatorError;
@ -54,6 +57,9 @@ pub use evaluator::{Evaluator, HandRank, HandValue};
// Re-export math-specific types
pub use tables::JumpTable;
// Re-export Supersonic incremental lookup types
pub use supersonic::TrieCursor;
// Module-level constants
pub const MAX_CARDS_IN_HAND: usize = 7;
pub const TOTAL_CARDS_IN_DECK: usize = 52;

View file

@ -37,6 +37,49 @@ const PAGE_SIZE: usize = 53;
/// Initial trie root index (page 1 base entry).
const INITIAL_STATE: usize = PAGE_SIZE;
// ---------------------------------------------------------------------------
// TrieCursor — opaque partial trie traversal state
// ---------------------------------------------------------------------------
/// Opaque trie cursor — represents a partial traversal of the Supersonic LUT trie.
///
/// Created by [`SupersonicEvaluator::init_lookup()`] and advanced by
/// [`SupersonicEvaluator::advance_lookup()`]. Cheaply `Copy`able (it's just two integers).
///
/// # Example: Incremental equity rollout
/// ```ignore
/// // Advance through hole + flop ONCE
/// let base = SupersonicEvaluator::init_lookup();
/// let base = eval.advance_lookup_slice(base, hole_cards);
/// let base = eval.advance_lookup_slice(base, flop_cards);
/// // base.depth() == 5
///
/// // For each turn card, resume from base
/// for turn in remaining_deck.iter() {
/// let after_turn = eval.advance_lookup(base, turn);
/// // after_turn.depth() == 6
/// for river in remaining_deck.iter() {
/// let after_river = eval.advance_lookup(after_turn, river);
/// let value = eval.finish_7_card(after_river);
/// }
/// }
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrieCursor {
/// Internal trie pointer — index into the `hand_ranks` table.
p: usize,
/// Number of cards that have been advanced through.
depth: u8,
}
impl TrieCursor {
/// Returns the number of cards advanced through so far.
#[inline]
pub fn depth(&self) -> u8 {
self.depth
}
}
// ---------------------------------------------------------------------------
// SupersonicEvaluator
// ---------------------------------------------------------------------------
@ -207,24 +250,27 @@ impl SupersonicEvaluator {
}
}
// Table doesn't exist or is corrupt — generate it.
// Table doesn't exist or is corrupt — generate a new one.
log::info!(
"Supersonic table not found at {}, generating (takes a few seconds)...",
"Generating Supersonic LUT table at {} (takes a few seconds on first run)...",
path.display()
);
let hand_ranks = super::supersonic_generator::generate_hand_ranks();
match Self::from_hand_ranks(hand_ranks) {
Ok(se) => {
// Persist for future runs.
if let Err(e) = se.save_to_file(&path) {
log::warn!("Failed to save Supersonic table to {}: {}", path.display(), e);
} else {
log::info!("Supersonic table saved to: {}", path.display());
Ok(evaluator) => {
match evaluator.save_to_file(&path) {
Ok(()) => {
log::info!("Supersonic table saved to: {}", path.display());
}
Err(e) => {
log::warn!("Failed to save Supersonic table: {}", e);
}
}
Some(se)
Some(evaluator)
}
Err(e) => {
log::error!("Failed to create Supersonic evaluator: {}", e);
log::error!("Failed to create Supersonic evaluator from generated table: {}", e);
None
}
}
@ -345,6 +391,141 @@ impl SupersonicEvaluator {
pub fn hand_ranks(&self) -> &[u32] {
&self.hand_ranks
}
// -----------------------------------------------------------------------
// Incremental / partial lookup API
// -----------------------------------------------------------------------
/// Create a new trie cursor at the root (before any cards).
///
/// Equivalent to the Java `int p = 53;` initialization.
#[inline]
pub fn init_lookup() -> TrieCursor {
TrieCursor {
p: INITIAL_STATE,
depth: 0,
}
}
/// Advance the trie cursor by one card.
///
/// Returns a **new** cursor; the original is not modified.
/// This is a single array lookup: `hand_ranks[p + card_index + 1]`.
#[inline]
pub fn advance_lookup(&self, cursor: TrieCursor, card: &Card) -> TrieCursor {
let idx = card_index(card);
let next = cursor.p + idx + 1;
debug_assert!(
next < self.hand_ranks.len(),
"Supersonic trie index out of bounds: next={}, len={}",
next,
self.hand_ranks.len()
);
let new_p = self.hand_ranks[next] as usize;
debug_assert!(
new_p < self.hand_ranks.len() || cursor.depth + 1 == 7,
"Supersonic trie pointer out of bounds: p={}, len={}, depth={}",
new_p,
self.hand_ranks.len(),
cursor.depth + 1
);
TrieCursor {
p: new_p,
depth: cursor.depth + 1,
}
}
/// Advance the trie cursor by multiple cards (convenience method).
#[inline]
pub fn advance_lookup_slice(&self, cursor: TrieCursor, cards: &[Card]) -> TrieCursor {
let mut c = cursor;
for card in cards {
c = self.advance_lookup(c, card);
}
c
}
/// Finish a 5-card partial evaluation.
///
/// Performs the extra `hand_ranks[p]` dereference needed for < 7 cards.
///
/// # Panics
/// Debug builds panic if `cursor.depth() != 5`.
#[inline]
pub fn finish_5_card(&self, cursor: TrieCursor) -> HandValue {
debug_assert_eq!(cursor.depth, 5, "finish_5_card requires depth 5, got {}", cursor.depth);
debug_assert!(cursor.p < self.hand_ranks.len(), "cursor p out of bounds");
packed_to_hand_value(self.hand_ranks[cursor.p])
}
/// Finish a 6-card partial evaluation.
///
/// Performs the extra `hand_ranks[p]` dereference needed for < 7 cards.
///
/// # Panics
/// Debug builds panic if `cursor.depth() != 6`.
#[inline]
pub fn finish_6_card(&self, cursor: TrieCursor) -> HandValue {
debug_assert_eq!(cursor.depth, 6, "finish_6_card requires depth 6, got {}", cursor.depth);
debug_assert!(cursor.p < self.hand_ranks.len(), "cursor p out of bounds");
packed_to_hand_value(self.hand_ranks[cursor.p])
}
/// Finish a 7-card partial evaluation.
///
/// The cursor value IS the packed hand rank (no extra dereference).
///
/// # Panics
/// Debug builds panic if `cursor.depth() != 7`.
#[inline]
pub fn finish_7_card(&self, cursor: TrieCursor) -> HandValue {
debug_assert_eq!(cursor.depth, 7, "finish_7_card requires depth 7, got {}", cursor.depth);
packed_to_hand_value(cursor.p as u32)
}
/// Finish a partial evaluation, auto-detecting the depth.
///
/// Returns `None` if depth is not 5, 6, or 7.
#[inline]
pub fn finish_lookup(&self, cursor: TrieCursor) -> Option<HandValue> {
match cursor.depth {
5 => Some(self.finish_5_card(cursor)),
6 => Some(self.finish_6_card(cursor)),
7 => Some(self.finish_7_card(cursor)),
_ => None,
}
}
// -----------------------------------------------------------------------
// Raw incremental API (zero-cost, no depth tracking)
// -----------------------------------------------------------------------
/// Raw initial state — just the constant `53`.
///
/// Use with [`raw_advance`](Self::raw_advance) and
/// [`raw_finish_partial`](Self::raw_finish_partial) / [`raw_finish_7`](Self::raw_finish_7).
#[inline]
pub const fn raw_init() -> usize {
INITIAL_STATE
}
/// Advance a raw trie pointer by one card.
#[inline]
pub fn raw_advance(&self, p: usize, card: &Card) -> usize {
self.hand_ranks[p + card_index(card) + 1] as usize
}
/// Finish for < 7 cards (extra `hand_ranks[p]` dereference).
#[inline]
pub fn raw_finish_partial(&self, p: usize) -> HandValue {
packed_to_hand_value(self.hand_ranks[p])
}
/// Finish for exactly 7 cards (`p` is the packed hand rank).
#[inline]
pub fn raw_finish_7(&self, p: usize) -> HandValue {
packed_to_hand_value(p as u32)
}
}
// ---------------------------------------------------------------------------
@ -390,12 +571,11 @@ fn packed_to_hand_value(packed: u32) -> HandValue {
}
}
_ => {
log::error!(
log::warn!(
"Invalid hand category {} in packed value {}, falling back to HighCard",
category,
packed
);
debug_assert!(false, "Invalid hand category: {} (packed: {})", category, packed);
HandRank::HighCard
}
};
@ -516,19 +696,9 @@ mod tests {
}
/// Build a SupersonicEvaluator for testing. Uses the cached file if
/// available; otherwise returns `None` (generation is too slow for unit
/// tests and may panic due to a known bug in the generator).
/// available; otherwise generates one (takes ~1 second).
fn supersonic() -> Option<SupersonicEvaluator> {
let path = SupersonicEvaluator::default_file_path();
if SupersonicEvaluator::exists(&path) {
SupersonicEvaluator::load_from_file(&path).ok()
} else {
eprintln!(
"Skipping cross-evaluator test: no cached Supersonic table at {}",
path.display()
);
None
}
SupersonicEvaluator::load_or_generate()
}
/// Evaluate a 5-card hand with both evaluators and compare results.
@ -838,7 +1008,7 @@ mod tests {
let mut total = 0u64;
let mut rank_mismatches = 0u64;
let mut ordering_mismatches = 0u64;
let ordering_mismatches = 0u64;
// Iterate all C(52,5) combinations via nested loops.
for i0 in 0u32..52 {
@ -890,4 +1060,239 @@ mod tests {
let suit = (idx % 4) as u8;
Card::new(rank, suit).expect("valid card")
}
// -----------------------------------------------------------------------
// Incremental / partial lookup API tests
// -----------------------------------------------------------------------
#[test]
fn test_trie_cursor_init() {
let cursor = SupersonicEvaluator::init_lookup();
assert_eq!(cursor.depth(), 0);
}
#[test]
fn test_incremental_5_card() {
let ss = match supersonic() {
Some(s) => s,
None => {
eprintln!("Skipping test_incremental_5_card: Supersonic table unavailable");
return;
}
};
// Test several 5-card hands
let hands: Vec<[Card; 5]> = vec![
// Royal Flush: As Ks Qs Js Ts
[card(12, 3), card(11, 3), card(10, 3), card(9, 3), card(8, 3)],
// Straight: Tc 9d 8s 7h 6c
[card(8, 2), card(7, 1), card(6, 3), card(5, 0), card(4, 2)],
// Two Pair: As Ah 4d 4c 9s
[card(12, 3), card(12, 0), card(2, 1), card(2, 2), card(7, 3)],
// High Card: Ad 9c 7s 4h 2c
[card(12, 1), card(7, 2), card(5, 3), card(2, 0), card(0, 2)],
// Full House: Ks Kh Kd 7c 7s
[card(11, 3), card(11, 0), card(11, 1), card(5, 2), card(5, 3)],
];
for hand in hands {
let batch = ss.evaluate_5_card(&hand);
// Incremental: one card at a time
let mut cursor = SupersonicEvaluator::init_lookup();
for card in &hand {
cursor = ss.advance_lookup(cursor, card);
}
assert_eq!(cursor.depth(), 5);
let incremental = ss.finish_5_card(cursor);
assert_eq!(batch, incremental, "5-card mismatch for hand {:?}", hand);
}
}
#[test]
fn test_incremental_6_card() {
let ss = match supersonic() {
Some(s) => s,
None => {
eprintln!("Skipping test_incremental_6_card: Supersonic table unavailable");
return;
}
};
let hands: Vec<[Card; 6]> = vec![
// AA + KK + 2s 3s
[card(12, 0), card(12, 1), card(11, 2), card(11, 3), card(0, 3), card(1, 3)],
// Flush draw + pair: Ah Th 8h 5h 2h Ad
[card(12, 0), card(10, 0), card(8, 0), card(5, 0), card(2, 0), card(12, 1)],
// Straight: Tc 9d 8s 7h 6c 5d
[card(8, 2), card(7, 1), card(6, 3), card(5, 0), card(4, 2), card(3, 1)],
];
for hand in hands {
let batch = ss.evaluate_6_card(&hand);
// Incremental: one card at a time
let mut cursor = SupersonicEvaluator::init_lookup();
for card in &hand {
cursor = ss.advance_lookup(cursor, card);
}
assert_eq!(cursor.depth(), 6);
let incremental = ss.finish_6_card(cursor);
assert_eq!(batch, incremental, "6-card mismatch for hand {:?}", hand);
}
}
#[test]
fn test_incremental_7_card() {
let ss = match supersonic() {
Some(s) => s,
None => {
eprintln!("Skipping test_incremental_7_card: Supersonic table unavailable");
return;
}
};
let hands: Vec<[Card; 7]> = vec![
// AA + KK + 5h 5d 3c
[card(12, 0), card(12, 1), card(11, 2), card(11, 3), card(5, 0), card(5, 1), card(3, 2)],
// Flush: Ah Th + 8h 5h 2h + Kd Tc
[card(12, 0), card(10, 0), card(8, 0), card(5, 0), card(2, 0), card(11, 1), card(9, 2)],
// Royal Flush on board: As Ks Qs Js Ts + 2h 3h
[card(12, 3), card(11, 3), card(10, 3), card(9, 3), card(8, 3), card(0, 0), card(1, 0)],
];
for hand in hands {
let batch = ss.evaluate_7_card(&hand);
// Incremental: one card at a time
let mut cursor = SupersonicEvaluator::init_lookup();
for card in &hand {
cursor = ss.advance_lookup(cursor, card);
}
assert_eq!(cursor.depth(), 7);
let incremental = ss.finish_7_card(cursor);
assert_eq!(batch, incremental, "7-card mismatch for hand {:?}", hand);
}
}
#[test]
fn test_incremental_slice_advance() {
let ss = match supersonic() {
Some(s) => s,
None => {
eprintln!("Skipping test_incremental_slice_advance: Supersonic table unavailable");
return;
}
};
// 7-card hand: Ah Ad Ks Kc 5h 5d 3c
let hand: [Card; 7] = [
card(12, 0), card(12, 1), card(11, 2), card(11, 3),
card(5, 0), card(5, 1), card(3, 2),
];
let batch = ss.evaluate_7_card(&hand);
// Advance in slices: 2 + 3 + 2
let cursor = SupersonicEvaluator::init_lookup();
assert_eq!(cursor.depth(), 0);
let cursor = ss.advance_lookup_slice(cursor, &hand[0..2]);
assert_eq!(cursor.depth(), 2);
let cursor = ss.advance_lookup_slice(cursor, &hand[2..5]);
assert_eq!(cursor.depth(), 5);
let cursor = ss.advance_lookup_slice(cursor, &hand[5..7]);
assert_eq!(cursor.depth(), 7);
let incremental = ss.finish_7_card(cursor);
assert_eq!(batch, incremental);
}
#[test]
fn test_raw_api() {
let ss = match supersonic() {
Some(s) => s,
None => {
eprintln!("Skipping test_raw_api: Supersonic table unavailable");
return;
}
};
// 5-card hand: Straight Tc 9d 8s 7h 6c
let hand5: [Card; 5] = [card(8, 2), card(7, 1), card(6, 3), card(5, 0), card(4, 2)];
let batch5 = ss.evaluate_5_card(&hand5);
let mut p = SupersonicEvaluator::raw_init();
for card in &hand5 {
p = ss.raw_advance(p, card);
}
let raw5 = ss.raw_finish_partial(p);
assert_eq!(batch5, raw5);
// 7-card hand: AA + KK + 5h 5d 3c
let hand7: [Card; 7] = [
card(12, 0), card(12, 1), card(11, 2), card(11, 3),
card(5, 0), card(5, 1), card(3, 2),
];
let batch7 = ss.evaluate_7_card(&hand7);
let mut p = SupersonicEvaluator::raw_init();
for card in &hand7 {
p = ss.raw_advance(p, card);
}
let raw7 = ss.raw_finish_7(p);
assert_eq!(batch7, raw7);
}
#[test]
fn test_finish_lookup_auto_detect() {
let ss = match supersonic() {
Some(s) => s,
None => {
eprintln!("Skipping test_finish_lookup_auto_detect: Supersonic table unavailable");
return;
}
};
// 5-card hand
let hand5: [Card; 5] = [card(12, 3), card(11, 3), card(10, 3), card(9, 3), card(8, 3)];
let batch5 = ss.evaluate_5_card(&hand5);
let mut cursor = SupersonicEvaluator::init_lookup();
for card in &hand5 {
cursor = ss.advance_lookup(cursor, card);
}
assert_eq!(ss.finish_lookup(cursor), Some(batch5));
// 6-card hand
let hand6: [Card; 6] = [
card(12, 0), card(12, 1), card(11, 2), card(11, 3), card(0, 3), card(1, 3),
];
let batch6 = ss.evaluate_6_card(&hand6);
let mut cursor = SupersonicEvaluator::init_lookup();
for card in &hand6 {
cursor = ss.advance_lookup(cursor, card);
}
assert_eq!(ss.finish_lookup(cursor), Some(batch6));
// 7-card hand
let hand7: [Card; 7] = [
card(12, 0), card(12, 1), card(11, 2), card(11, 3),
card(5, 0), card(5, 1), card(3, 2),
];
let batch7 = ss.evaluate_7_card(&hand7);
let mut cursor = SupersonicEvaluator::init_lookup();
for card in &hand7 {
cursor = ss.advance_lookup(cursor, card);
}
assert_eq!(ss.finish_lookup(cursor), Some(batch7));
// Invalid depths return None
let cursor0 = SupersonicEvaluator::init_lookup();
assert_eq!(ss.finish_lookup(cursor0), None);
let cursor1 = ss.advance_lookup(SupersonicEvaluator::init_lookup(), &card(12, 0));
assert_eq!(cursor1.depth(), 1);
assert_eq!(ss.finish_lookup(cursor1), None);
}
}

View file

@ -2396,71 +2396,97 @@ pub fn generate_hand_ranks() -> Vec<u32> {
/// 2. Walk the keys again, populating `hand_ranks` with trie pointers and
/// evaluated hand ranks.
fn generate_tables(hand_ranks: &mut [u32]) {
let mut keys = vec![0i64; MAX_KEYS];
let mut num_keys: usize = 1; // keys[0] = 0 (empty hand)
let mut max_key: i64 = 0;
use std::collections::HashMap;
// -----------------------------------------------------------------------
// Phase 1: Generate all unique keys
// Phase 1: Collect all unique keys via BFS from the empty hand
// -----------------------------------------------------------------------
log::info!("Phase 1: Generating and sorting keys...");
log::info!("Phase 1: Generating unique keys...");
let mut key_index: usize = 0;
while key_index == 0 || keys[key_index] != 0 {
if key_index % (MAX_KEYS / 10) == 0 {
log::info!(
" Phase 1: {} / {} keys processed ({:.0}%)",
key_index,
MAX_KEYS,
(key_index as f64 / MAX_KEYS as f64) * 100.0
);
}
// Map each unique key to its page index (assigned after sorting).
let mut key_to_index: HashMap<i64, usize> = HashMap::with_capacity(MAX_KEYS);
key_to_index.insert(0, 0); // empty hand → placeholder index 0
// BFS queue: keys whose children still need to be generated.
let mut queue: Vec<i64> = Vec::with_capacity(MAX_KEYS);
queue.push(0); // start with the empty hand
let mut head = 0usize;
while head < queue.len() {
let base_key = queue[head];
head += 1;
for ci in 0..NUM_CARDS {
let (key, num_cards) = make_key(keys[key_index], ci);
if num_cards < 7 {
insert_key(&mut keys, &mut num_keys, &mut max_key, key);
let (key, num_cards) = make_key(base_key, ci);
if key != 0 && num_cards < 7 {
// Only insert truly new keys.
if !key_to_index.contains_key(&key) {
key_to_index.insert(key, 0); // placeholder
queue.push(key);
}
}
}
key_index += 1;
if head % (MAX_KEYS / 10) == 0 && head > 0 {
log::info!(
" Phase 1: {} keys collected, {} unique ({:.0}%)",
head,
key_to_index.len(),
(head as f64 / MAX_KEYS as f64) * 100.0
);
}
}
// Sort keys and assign sequential page indices.
let mut sorted_keys: Vec<i64> = key_to_index.keys().copied().collect();
sorted_keys.sort_unstable();
for (idx, &key) in sorted_keys.iter().enumerate() {
key_to_index.insert(key, idx);
}
let num_keys = sorted_keys.len();
log::info!(
" Phase 1 complete: {} keys generated.",
key_index
" Phase 1 complete: {} unique keys generated.",
num_keys
);
// -----------------------------------------------------------------------
// Phase 2: Populate hand ranks
// Phase 2: Populate hand_ranks trie
// -----------------------------------------------------------------------
log::info!("Phase 2: Generating hand ranks...");
key_index = 0;
while key_index == 0 || keys[key_index] != 0 {
if key_index % (MAX_KEYS / 10) == 0 {
for key_index in 0..num_keys {
let base_key = sorted_keys[key_index];
if key_index > 0 && key_index % (MAX_KEYS / 10) == 0 {
log::info!(
" Phase 2: {} / {} keys processed ({:.0}%)",
key_index,
MAX_KEYS,
(key_index as f64 / MAX_KEYS as f64) * 100.0
num_keys,
(key_index as f64 / num_keys as f64) * 100.0
);
}
for ci in 0..NUM_CARDS {
let (key, num_cards) = make_key(keys[key_index], ci);
let (key, num_cards) = make_key(base_key, ci);
let hand_rank = if num_cards < 7 {
// Insert key and get its page index
let idx = insert_key(&mut keys, &mut num_keys, &mut max_key, key);
(idx * PAGE_SIZE + PAGE_SIZE) as u32
// Look up the child key's page index in the HashMap.
let &idx = key_to_index.get(&key).unwrap_or(&0);
if idx == 0 && key != 0 {
// Key was not found — should not happen after Phase 1.
// Fall back to 0 (will be handled by fallback evaluator).
0
} else {
(idx * PAGE_SIZE + PAGE_SIZE) as u32
}
} else {
// Evaluate the 7-card hand
get_hand_rank(key)
};
// Calculate insertion index: keyIndex * 53 + card.getIndex() + 54
// In Meerkat API: card.getIndex() = rank * 4 + suit = ci
let max_hand_rank_index = key_index * PAGE_SIZE + ci + PAGE_SIZE + 1;
debug_assert!(
max_hand_rank_index < HAND_RANKS_SIZE,
@ -2472,14 +2498,12 @@ fn generate_tables(hand_ranks: &mut [u32]) {
// For 6-card and 7-card keys, also populate the base entry
if num_cards == 6 || num_cards == 7 {
hand_ranks[key_index * PAGE_SIZE + PAGE_SIZE] =
get_hand_rank(keys[key_index]);
get_hand_rank(base_key);
}
}
key_index += 1;
}
log::info!(" Phase 2 complete.");
log::info!(" Phase 2 complete: {} keys processed.", num_keys);
}
// ---------------------------------------------------------------------------
@ -2590,73 +2614,6 @@ fn swap_if(hand: &mut [i32; 8], i: usize, j: usize) {
}
}
// ---------------------------------------------------------------------------
// Key Insertion
// ---------------------------------------------------------------------------
/// Insert a key into the sorted key array.
///
/// Returns the index of the key (existing or newly inserted).
/// If `key == 0`, returns 0.
///
/// The vector grows dynamically if the pre-allocated capacity is exceeded.
fn insert_key(
keys: &mut Vec<i64>,
num_keys: &mut usize,
max_key: &mut i64,
key: i64,
) -> usize {
if key == 0 {
return 0;
}
// Short-circuit for most common case: key >= max_key → append at end
if key >= *max_key {
if key > *max_key {
if *num_keys >= keys.len() {
keys.push(key);
} else {
keys[*num_keys] = key;
}
*num_keys += 1;
*max_key = key;
}
return *num_keys - 1;
}
// Binary search for insertion point within [0, num_keys)
let mut low: isize = -1;
let mut high = *num_keys as isize;
while high - low > 1 {
let pivot = ((low + high) >> 1) as usize;
let difference = keys[pivot] - key;
if difference > 0 {
high = pivot as isize;
} else if difference < 0 {
low = pivot as isize;
} else {
return pivot; // key already exists
}
}
let high_usize = high as usize;
// Ensure the vector has room for one more element.
if *num_keys >= keys.len() {
keys.push(0);
}
// Shift elements right to make room
for i in (*num_keys..high_usize + 1).rev() {
keys[i] = keys[i - 1];
}
keys[high_usize] = key;
*num_keys += 1;
high_usize
}
// ---------------------------------------------------------------------------
// Hand Evaluation
// ---------------------------------------------------------------------------
@ -2891,25 +2848,6 @@ mod tests {
assert_eq!(num_cards, 1);
}
#[test]
fn test_insert_key() {
let mut keys = vec![0i64; MAX_KEYS];
let mut num_keys = 1usize;
let mut max_key = 0i64;
let idx1 = insert_key(&mut keys, &mut num_keys, &mut max_key, 100);
assert_eq!(idx1, 1);
assert_eq!(num_keys, 2);
let idx2 = insert_key(&mut keys, &mut num_keys, &mut max_key, 50);
assert_eq!(idx2, 1); // inserted before 100
assert_eq!(num_keys, 3);
let idx3 = insert_key(&mut keys, &mut num_keys, &mut max_key, 100);
assert_eq!(idx3, 2); // already exists
assert_eq!(num_keys, 3);
}
#[test]
fn test_eval_5hand_royal_flush() {
// Create a royal flush in Cactus Kev representation

View file

@ -330,7 +330,8 @@ impl GameInfo {
/// Total pot size.
pub fn get_pot_size(&self) -> ChipCount { ... }
/// Number of raises made in the current betting round.
/// Number of raises on the current street (Meerkat semantics).
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as a raise.
pub fn get_num_raises(&self) -> usize { ... }
}
```

View file

@ -296,7 +296,7 @@ mod tests {
#[test]
fn fold_is_always_valid() {
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
assert!(validate_action(
&Action::Fold,
pid(1),
@ -313,7 +313,7 @@ mod tests {
#[test]
fn check_allowed_when_no_bet() {
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
assert!(validate_action(
&Action::Check,
pid(1),
@ -330,7 +330,7 @@ mod tests {
#[test]
fn check_not_allowed_when_bet_exists() {
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let result = validate_action(
&Action::Check,
pid(1),
@ -347,7 +347,7 @@ mod tests {
#[test]
fn call_always_valid_with_chips() {
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
assert!(validate_action(
&Action::Call,
pid(1),
@ -364,7 +364,7 @@ mod tests {
#[test]
fn raise_below_minimum_rejected() {
let bet_limit = BetLimit::NoLimit { min_raise: 20 };
let bet_limit = BetLimit::NoLimit { min_raise: 20, max_raises: None };
let result = validate_action(
&Action::Raise { total: 30 },
pid(1),
@ -384,7 +384,7 @@ mod tests {
#[test]
fn raise_with_insufficient_chips_rejected() {
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let result = validate_action(
&Action::Raise { total: 200 },
pid(1),
@ -404,7 +404,7 @@ mod tests {
#[test]
fn all_in_with_zero_chips_rejected() {
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let result = validate_action(
&Action::AllIn,
pid(1),

View file

@ -22,6 +22,9 @@ pub enum BetLimit {
NoLimit {
/// Minimum raise increment.
min_raise: ChipCount,
/// Optional cap on voluntary raises per street (None = unlimited).
/// When set, the Nth raise is converted to a call automatically.
max_raises: Option<usize>,
},
/// Pot-Limit — players may raise up to the current pot size.
@ -80,7 +83,7 @@ pub trait BettingRules {
impl BettingRules for BetLimit {
fn min_raise_amount(&self, _current_bet: ChipCount, previous_raise: ChipCount) -> ChipCount {
match self {
BetLimit::NoLimit { min_raise } => (*min_raise).max(previous_raise),
BetLimit::NoLimit { min_raise, .. } => (*min_raise).max(previous_raise),
BetLimit::PotLimit { min_raise } => (*min_raise).max(previous_raise),
BetLimit::FixedLimit {
small_bet,
@ -147,6 +150,10 @@ impl BettingRules for BetLimit {
/// unused — the cap is intentionally the same on every street.
fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
match self {
BetLimit::NoLimit {
max_raises: Some(cap),
..
} => raises_this_round < *cap,
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,
BetLimit::FixedLimit { .. } => raises_this_round < 3,
}
@ -171,6 +178,10 @@ pub struct BettingRound {
/// The size of the last raise increment (used to compute min-raise).
pub last_raise_size: ChipCount,
/// Number of raises made so far this round.
///
/// **Note:** This tracks *voluntary* raises only (for Fixed-Limit cap
/// enforcement). This is distinct from `GameInfo.num_raises`, which uses
/// Meerkat semantics where the BB counts as the first raise preflop.
pub num_raises: usize,
/// Whether the round is considered complete.
pub is_complete: bool,
@ -263,7 +274,7 @@ mod tests {
/// Helper — No-Limit with a minimum raise of 10 (big-blind sized).
fn nl() -> BetLimit {
BetLimit::NoLimit { min_raise: 10 }
BetLimit::NoLimit { min_raise: 10, max_raises: None }
}
/// Helper — Pot-Limit with a minimum raise of 10.

View file

@ -65,10 +65,11 @@ where
{
let mut map = CUSTOM_FACTORIES.write().unwrap_or_else(|e| e.into_inner());
if map.contains_key(bot_type) {
panic!(
"bot type '{}' is already registered; choose a different name",
log::debug!(
"bot type '{}' is already registered; skipping re-registration",
bot_type
);
return;
}
map.insert(bot_type.to_string(), Box::new(factory));
}

View file

@ -101,7 +101,7 @@ mod tests {
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 5,
big_blind: 10,
ante: 0,
@ -126,12 +126,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}

View file

@ -321,7 +321,7 @@ mod tests {
) -> GameInfo {
GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -346,12 +346,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}

View file

@ -446,9 +446,9 @@ impl FlockBot {
}
}
PreflopStrength::Strong => {
// num_raises is the count of voluntary raises (excludes blinds).
if num_raises == 0 {
// No voluntary raise: raise to 3x BB
// num_raises uses Meerkat semantics: BB counts as the first raise preflop.
if num_raises == 1 {
// Unraised pot (just BB): raise to 3x BB
let raise_target = 3 * big_blind;
self.compute_raise_total(game_info, raise_target)
.unwrap_or_else(|| {
@ -458,7 +458,7 @@ impl FlockBot {
Action::Call
}
})
} else if num_raises == 1 {
} else if num_raises == 2 {
// Facing one voluntary raise: call
if stack <= call_amount {
Action::AllIn
@ -466,7 +466,7 @@ impl FlockBot {
Action::Call
}
} else {
// 2+ voluntary raises: fold (not premium)
// 2+ voluntary raises (3+ in Meerkat count): fold (not premium)
Action::Fold
}
}
@ -474,6 +474,17 @@ impl FlockBot {
// Always raise/re-raise: 3x BB or 3x current bet, whichever is higher
let raise_target = (3 * big_blind).max(3 * game_info.current_bet);
self.compute_raise_total(game_info, raise_target)
.or_else(|| {
// If compute_raise_total fails (e.g., stack too small for
// min raise), try a call instead of forcing AllIn.
let call_amount = self.call_amount(game_info);
let stack = self.stack(game_info);
if stack > call_amount {
Some(Action::Call)
} else {
None
}
})
.unwrap_or(Action::AllIn)
}
}
@ -590,7 +601,7 @@ mod tests {
) -> GameInfo {
GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -615,12 +626,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}
@ -800,7 +813,7 @@ mod tests {
);
// Someone limped (call_amount = BB = 20, no voluntary raises)
let mut game_info = make_game_info(20, 40, 1000, 0, Street::Preflop, vec![]);
game_info.num_raises = 0; // No voluntary raises yet
game_info.num_raises = 1; // Meerkat: BB counts as first raise (unraised pot)
let action = bot.act(&game_info);
// Should raise to 3x BB = 60, not just call
assert!(
@ -823,7 +836,7 @@ mod tests {
);
// Facing one voluntary raise to 60 (3x BB)
let mut game_info = make_game_info(60, 80, 1000, 0, Street::Preflop, vec![]);
game_info.num_raises = 1; // One voluntary raise
game_info.num_raises = 2; // Meerkat: BB + one voluntary raise
assert_eq!(bot.act(&game_info), Action::Call);
}
@ -840,7 +853,7 @@ mod tests {
);
// Facing a 3-bet to 180 (2 voluntary raises)
let mut game_info = make_game_info(180, 240, 1000, 0, Street::Preflop, vec![]);
game_info.num_raises = 2; // Two voluntary raises
game_info.num_raises = 3; // Meerkat: BB + two voluntary raises
assert_eq!(bot.act(&game_info), Action::Fold);
}

View file

@ -102,7 +102,7 @@ mod tests {
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 5,
big_blind: 10,
ante: 0,
@ -127,12 +127,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}

View file

@ -148,11 +148,12 @@ impl HoldemPlayer for RaiseBot {
.fixed_bet_size(game_info.street)
.unwrap_or(small_bet);
// Check if raises are still allowed (typically max 3 raises per round)
// Use the authoritative raise counter from GameInfo.
let raises_this_round = game_info.num_raises;
// Check if raises are still allowed (typically max 3 voluntary raises per round).
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
// but the Fixed-Limit cap applies to voluntary raises only.
let voluntary_raises = game_info.voluntary_raises();
if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) {
if !game_info.bet_limit.can_raise(game_info.street, voluntary_raises) {
// Can't raise anymore, just call
return if call_amount == 0 {
Action::Check
@ -212,7 +213,7 @@ mod tests {
) -> GameInfo {
GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 5,
big_blind: 10,
ante: 0,
@ -237,12 +238,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}
@ -284,12 +287,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}
@ -327,12 +332,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}
@ -431,7 +438,7 @@ mod tests {
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
// Create game info with 3 raises already made on the flop (Fixed-Limit max)
let mut game_info = make_fl_game_info(40, 100, 1000, 0, Street::Flop);
game_info.num_raises = 3; // 3 voluntary raises already on the flop
game_info.num_raises = 3; // 3 raises already on the flop (postflop = Meerkat count)
let action = bot.act(&game_info);
// Can't raise anymore, should call
assert_eq!(action, Action::Call);

View file

@ -100,10 +100,13 @@ impl RandomBot {
}
}
// Check if raising is possible
// Check if raising is possible.
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
// but the Fixed-Limit cap applies to voluntary raises only.
let voluntary_raises = game_info.voluntary_raises();
let can_raise = game_info
.bet_limit
.can_raise(game_info.street, game_info.num_raises);
.can_raise(game_info.street, voluntary_raises);
if can_raise && stack > call_amount {
// Player has chips remaining after calling
@ -229,7 +232,7 @@ mod tests {
) -> GameInfo {
GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 5,
big_blind: 10,
ante: 0,
@ -254,12 +257,14 @@ mod tests {
is_dealer: true,
is_small_blind: false,
is_big_blind: false,
external_id: None,
bankroll: 0,
})],
actions_this_hand: vec![],
num_raises: 0,
hand_number: 1,
active_players: 2,
table_id: String::new(),
}
}

View file

@ -186,6 +186,9 @@ pub enum BetLimitConfig {
NoLimit {
/// Minimum raise increment.
min_raise: ChipCount,
/// Optional cap on voluntary raises per street (None = unlimited).
#[serde(default)]
max_raises: Option<usize>,
},
/// Pot-Limit — players may raise up to the current pot size.
#[serde(rename = "pot_limit")]
@ -206,7 +209,7 @@ pub enum BetLimitConfig {
impl From<BetLimitConfig> for BetLimit {
fn from(config: BetLimitConfig) -> Self {
match config {
BetLimitConfig::NoLimit { min_raise } => BetLimit::NoLimit { min_raise },
BetLimitConfig::NoLimit { min_raise, max_raises } => BetLimit::NoLimit { min_raise, max_raises },
BetLimitConfig::PotLimit { min_raise } => BetLimit::PotLimit { min_raise },
BetLimitConfig::FixedLimit { small_bet, big_bet } => {
BetLimit::FixedLimit { small_bet, big_bet }
@ -218,7 +221,7 @@ impl From<BetLimitConfig> for BetLimit {
impl From<BetLimit> for BetLimitConfig {
fn from(limit: BetLimit) -> Self {
match limit {
BetLimit::NoLimit { min_raise } => BetLimitConfig::NoLimit { min_raise },
BetLimit::NoLimit { min_raise, max_raises } => BetLimitConfig::NoLimit { min_raise, max_raises },
BetLimit::PotLimit { min_raise } => BetLimitConfig::PotLimit { min_raise },
BetLimit::FixedLimit { small_bet, big_bet } => {
BetLimitConfig::FixedLimit { small_bet, big_bet }
@ -411,6 +414,7 @@ impl Default for GameConfig {
num_seats: default_num_seats(),
bet_limit: BetLimitConfig::NoLimit {
min_raise: 20,
max_raises: None,
},
small_blind: default_small_blind(),
big_blind: default_big_blind(),
@ -874,7 +878,7 @@ big_bet = 20
fn bet_limit_config_to_bet_limit() {
let config = BetLimitConfig::NoLimit { min_raise: 20 };
let limit: BetLimit = config.into();
assert_eq!(limit, BetLimit::NoLimit { min_raise: 20 });
assert_eq!(limit, BetLimit::NoLimit { min_raise: 20, max_raises: None });
let config = BetLimitConfig::FixedLimit {
small_bet: 10,
@ -1170,7 +1174,7 @@ min_raise = 50
assert_eq!(table_config.small_blind, 10);
assert_eq!(table_config.big_blind, 20);
assert_eq!(table_config.ante, 5);
assert_eq!(table_config.bet_limit, BetLimit::NoLimit { min_raise: 20 });
assert_eq!(table_config.bet_limit, BetLimit::NoLimit { min_raise: 20, max_raises: None });
}
#[test]

View file

@ -75,13 +75,16 @@ pub struct GameInfo {
///
/// **Meerkat convention:** On the preflop street (index 0), the small blind
/// and big blind postings are recorded as [`Action::Raise`] entries at the
/// beginning of the list. These are *not* voluntary player raises. Use
/// [`num_raises`](Self::num_raises) for the authoritative count of voluntary
/// raises on the current street, which excludes blind postings.
/// beginning of the list. Use [`num_raises`](Self::num_raises) for the
/// authoritative raise count on the current street (Meerkat semantics where
/// the BB counts as the first raise preflop).
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
// --- Action counters (authoritative, maintained by the engine) ---
/// Number of voluntary raises on the current street (excludes blinds).
/// Number of raises on the current street (Meerkat semantics).
///
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as
/// a raise. This means `to_call > 0` always implies `num_raises >= 1`.
/// Reset to 0 when advancing to a new street.
pub num_raises: usize,
@ -91,6 +94,10 @@ pub struct GameInfo {
/// Number of active (non-folded, non-all-in) players.
pub active_players: usize,
/// Optional table identifier for external systems (e.g. live server).
/// Empty string in simulation; set by live server for live play.
pub table_id: String,
}
impl GameInfo {
@ -200,10 +207,24 @@ impl GameInfo {
self.pot
}
/// Returns the number of voluntary raises on the current street.
/// Returns the number of raises on the current street (Meerkat semantics).
///
/// Corresponds to Meerkat's `GameInfo.getNumRaises()`.
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as
/// a raise. Corresponds to Meerkat's `GameInfo.getNumRaises()`.
pub fn get_num_raises(&self) -> usize {
self.num_raises
}
/// Returns the number of *voluntary* raises on the current street.
///
/// This converts from Meerkat semantics (where the BB counts as the first
/// raise preflop) to the count of actual player-initiated raises. Useful
/// for Fixed-Limit cap enforcement where only voluntary raises are capped.
pub fn voluntary_raises(&self) -> usize {
if self.is_preflop() {
self.num_raises.saturating_sub(1)
} else {
self.num_raises
}
}
}

View file

@ -11,7 +11,7 @@ use std::collections::HashMap;
use rand::SeedableRng;
use crate::board::Street;
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
use crate::evaluator::evaluator::Evaluator;
use crate::evaluator::evaluator::{HandRank, HandValue};
use crate::{Board, Deck, Hand, HoleCards};
@ -194,11 +194,13 @@ pub struct HandRunner {
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
///
/// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are
/// recorded as `Action::Raise` entries (the first two entries). Code that
/// counts voluntary raises from this field must subtract 2 on preflop.
/// Use `blind_postings_snapshot` for accurate blind data in hand history
/// recorded as `Action::Raise` entries (the first two entries). The
/// `GameInfo.num_raises` field uses Meerkat semantics where the BB counts
/// as the first raise preflop (SB is excluded). Use
/// `blind_postings_snapshot` for accurate blind data in hand history
/// consumers. The `BettingRound.num_raises` field tracks only voluntary
/// raises and is unaffected by this convention.
/// raises for Fixed-Limit cap enforcement and is unaffected by this
/// convention.
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
/// Optional RNG seed for reproducible shuffles.
seed: Option<u64>,
@ -557,6 +559,17 @@ impl HandRunner {
.unwrap_or(0);
let street = self.current_street();
// Enforce raise cap for No-Limit with max_raises: convert Raise to Call.
let action = if let Action::Raise { .. } = &action {
if !self.bet_limit.can_raise(street, raises_this_round) {
Action::Call
} else {
action
}
} else {
action
};
validate_action(
&action,
next_id,
@ -730,28 +743,7 @@ impl HandRunner {
let mut hand_values: HashMap<PlayerId, HandValue> = HashMap::new();
for (_, player_id, hole_cards) in &active_players {
if let Ok(hand) = Hand::from_hole_cards_and_board(hole_cards, &self.board) {
let cards = hand.cards();
let value = match cards.len() {
5 => {
let arr: [crate::Card; 5] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_5_card_direct(&arr)
}
6 => {
let arr: [crate::Card; 6] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_6_card(&arr)
}
7 => {
let arr: [crate::Card; 7] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_7_card(&arr)
}
_ => AlgorithmicEvaluator::evaluate_5_card_direct(&[
cards[0],
cards[1],
cards[2],
cards[3],
cards[4],
]),
};
let value = Evaluator::instance().evaluate_hand(&hand);
hand_values.insert(*player_id, value);
}
}
@ -1147,6 +1139,20 @@ impl HandRunner {
}
}
// 4b. Reveal all hole cards for RL training (no-op for production bots).
{
let all_cards: Vec<(PlayerId, HoleCards)> = self
.seats
.iter()
.filter_map(|s| s.hole_cards.map(|hc| (s.player_id, hc)))
.collect();
let game_info = self.build_game_info();
for seat in &mut self.seats {
seat.player
.cards_revealed_event(&all_cards, &game_info);
}
}
// 5. Set state to Complete.
self.state = HandState::Complete;
@ -1405,8 +1411,11 @@ impl HandRunner {
.and_then(|id| self.find_seat_index_by_player_id(id))
.map(|idx| self.seats[idx].index);
// Count voluntary raises on the current street (exclude blind postings).
// Count raises on the current street using Meerkat semantics.
// On preflop, the first two Raise entries are the SB and BB postings.
// The BB counts as the first raise (Meerkat convention), but the SB does not.
// So we subtract 1 (for the SB only), keeping the BB as a counted raise.
// Postflop, all Raise entries are counted directly.
let street_idx = self.street_to_action_index();
let num_raises = self
.actions_this_hand
@ -1416,10 +1425,10 @@ impl HandRunner {
.iter()
.filter(|(_, a)| matches!(a, Action::Raise { .. }))
.count();
// On preflop, the SB and BB are recorded as raises (Meerkat convention).
// Subtract them to get the count of voluntary player raises.
// On preflop, subtract 1 for the SB posting only.
// The BB remains counted as the first raise (Meerkat semantics).
if self.current_street() == Street::Preflop {
total_raises.saturating_sub(2)
total_raises.saturating_sub(1)
} else {
total_raises
}
@ -1446,6 +1455,7 @@ impl HandRunner {
num_raises,
hand_number: self.hand_number,
active_players: self.count_active_players(),
table_id: String::new(),
}
}
}
@ -1658,7 +1668,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
assert_eq!(runner.state(), HandState::Idle);
@ -1693,7 +1703,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);
@ -1729,7 +1739,7 @@ mod tests {
let seats = vec![seat0, seat1, seat2];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);
@ -1755,7 +1765,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);
@ -1777,7 +1787,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
assert_eq!(runner.state(), HandState::Idle);
@ -1800,7 +1810,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
let result = runner.step();
@ -1820,7 +1830,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);
@ -1856,7 +1866,7 @@ mod tests {
let seats = vec![seat0, seat1, seat2];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);
@ -1972,7 +1982,7 @@ mod tests {
let seats = vec![seat0, seat1, seat2];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);
@ -1998,7 +2008,7 @@ mod tests {
let seats = vec![seat0, seat1];
let deck = Deck::new();
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0);

View file

@ -230,6 +230,24 @@ pub trait HoldemPlayer: Send + Sync {
/// Corresponds to `com.biotools.meerkat.Player#gameOverEvent`.
fn game_over_event(&mut self, game_info: &GameInfo);
/// Called after the hand ends with ALL players' hole cards revealed.
///
/// **Training only** — allows RL agents to compute realized equity for
/// every decision point. The default implementation is a no-op; production
/// bots should never override this (they don't see opponents' cards).
///
/// # Arguments
///
/// * `all_hole_cards` — Slice of `(PlayerId, HoleCards)` for every player
/// who was dealt into this hand, including those who folded.
/// * `game_info` — Final game state snapshot.
fn cards_revealed_event(
&mut self,
_all_hole_cards: &[(PlayerId, HoleCards)],
_game_info: &GameInfo,
) {
}
/// Signal an early-fold intent.
///
/// Called by the framework layer when a player wants to fold before it is
@ -251,6 +269,12 @@ pub trait HoldemPlayer: Send + Sync {
/// * `placement` — The player's finishing position (1-based). A placement
/// of `1` means the player won the tournament.
fn on_eliminated(&self, placement: u32);
/// Called when the server is shutting down or the table is being removed.
///
/// Override to persist state (e.g. opponent models, learned data) before
/// the process exits. The default implementation is a no-op.
fn shutdown(&mut self) {}
}
// ---------------------------------------------------------------------------
@ -388,6 +412,7 @@ impl Seat {
is_dealer,
is_small_blind,
is_big_blind,
external_id: None,
bankroll: self.bankroll,
}
}

View file

@ -66,6 +66,11 @@ pub struct PlayerInfo {
/// Whether this player is the big blind.
pub is_big_blind: bool,
/// Stable external player ID (e.g. Torn user ID). Used by Gen 4 opponent
/// modelling to track players across seat changes and name changes.
/// `None` in simulation.
pub external_id: Option<String>,
/// The player's overall bankroll balance across all hands.
/// Positive = profit, negative = loss.
pub bankroll: i64,

View file

@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet};
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
use rayon::prelude::*;
use super::bot_factory;
use super::config::GameConfig;
@ -128,10 +129,29 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
}
}
}
Err(GameError::NotEnoughPlayers(_)) => {
// Not enough active players to continue.
Err(GameError::NotEnoughPlayers(count)) => {
// Not enough active players to continue. This should only
// happen if reset_bankroll_each_hand / auto_rebuy are both
// disabled, or if seats were lost due to a hand error.
log::warn!(
"Simulation stopped after {} hands: only {count} active players. \
Set reset_bankroll_each_hand=true or auto_rebuy=true to prevent this.",
stats.total_hands
);
break;
}
Err(GameError::RaiseBelowMinimum(raise, min)) => {
// A bot returned an illegal raise. Skip this hand and
// continue rather than aborting the entire simulation.
log::warn!(
"Bot returned illegal raise ({raise} < min {min}) — skipping hand"
);
}
Err(GameError::InsufficientChips(pid, needed, has)) => {
log::warn!(
"Bot {pid} insufficient chips (needs {needed}, has {has}) — skipping hand"
);
}
Err(e) => {
return Err(e);
}
@ -228,121 +248,122 @@ pub fn run_sng_series(
let mut result = SngSeriesResult::new(num_tournaments, buy_in, base_seed);
for tournament_index in 0..num_tournaments {
let table_config = config.to_table_config();
let blind_schedule = config.to_blind_schedule();
let prize_structure = config.to_prize_structure();
struct TourneyResult {
total_hands: u64,
player_results: Vec<(String, u32, ChipCount)>,
}
// Create the appropriate tournament type based on game_type.
let mut tournament = match config.game_type {
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
config.num_tables,
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
buy_in,
),
_ => Tournament::new_sng(
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
buy_in,
),
};
let tournament_results: Vec<TourneyResult> = (0..num_tournaments)
.into_par_iter()
.map(|tournament_index| {
let table_config = config.to_table_config();
let blind_schedule = config.to_blind_schedule();
let prize_structure = config.to_prize_structure();
// Build the registration order. If randomize_seats is enabled,
// shuffle the bot order using a seed derived from the base seed +
// tournament index for reproducibility.
let mut order: Vec<usize> = (0..config.bots.len()).collect();
if config.randomize_seats {
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
order.shuffle(&mut rng);
}
let mut tournament = match config.game_type {
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
config.num_tables,
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
buy_in,
),
_ => Tournament::new_sng(
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
buy_in,
),
};
// Create fresh bots and register them in the (possibly shuffled) order.
// We need to track names by PlayerId for this tournament.
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
let mut order: Vec<usize> = (0..config.bots.len()).collect();
if config.randomize_seats {
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
order.shuffle(&mut rng);
}
for (seat_seq, &bot_idx) in order.iter().enumerate() {
let bot_config = &config.bots[bot_idx];
let player_id = PlayerId(seat_seq as u64);
let bot = bot_factory::create_bot(bot_config, player_id)
.map_err(GameError::Custom)?;
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
for (seat_seq, &bot_idx) in order.iter().enumerate() {
let bot_config = &config.bots[bot_idx];
let player_id = PlayerId(seat_seq as u64);
let bot = bot_factory::create_bot(bot_config, player_id)
.map_err(GameError::Custom)?;
let name = bot.name().to_string();
player_names.insert(player_id, name);
tournament.register_player(bot)?;
}
let name = bot.name().to_string();
player_names.insert(player_id, name);
tournament.register_player(bot)?;
}
// Run the tournament to completion.
log::info!("Running tournament {}/{}...", tournament_index + 1, num_tournaments);
tournament.run_to_completion()?;
// Accumulate total hands.
result.total_hands_played += tournament.total_hands();
// Process results: record eliminated players from standings.
let standings = tournament.standings();
// Invariant: after run_to_completion(), standings should have N-1 entries
// and winner() returns the sole remaining player. Log a warning if violated.
let expected_standings = player_names.len().saturating_sub(1);
if standings.len() != expected_standings {
log::warn!(
"Tournament {} ended with {} standings (expected {})",
log::info!(
"Running tournament {}/{}...",
tournament_index + 1,
standings.len(),
expected_standings
num_tournaments
);
}
tournament.run_to_completion()?;
// Track which player IDs have been recorded to prevent double-counting.
let mut recorded: HashSet<PlayerId> = HashSet::new();
let total_hands = tournament.total_hands();
let standings = tournament.standings();
// Standings contain eliminated players with their finish position.
// The placement value is the correct finish position (2 = runner-up,
// N = first eliminated). Players eliminated in the same hand share
// the same position.
for &(player_id, finish_position) in standings {
let name = player_names
.get(&player_id)
.cloned()
.unwrap_or_else(|| format!("Player({})", player_id.0));
let expected_standings = player_names.len().saturating_sub(1);
if standings.len() != expected_standings {
log::warn!(
"Tournament {} ended with {} standings (expected {})",
tournament_index + 1,
standings.len(),
expected_standings
);
}
let prize = tournament
.prize_structure()
.prize_for_placement(finish_position)
.unwrap_or(0);
let mut player_results = Vec::new();
let mut recorded: HashSet<PlayerId> = HashSet::new();
log::debug!(
"Tournament {}: {} finish={} prize={}",
tournament_index + 1, name, finish_position, prize
);
result.record_player_finish(&name, finish_position, prize);
recorded.insert(player_id);
}
// Record the winner (last active player) if not already in standings.
// The winner is the sole remaining player who was never eliminated.
if let Some(winner_id) = tournament.winner() {
if !recorded.contains(&winner_id) {
for &(player_id, finish_position) in standings {
let name = player_names
.get(&winner_id)
.get(&player_id)
.cloned()
.unwrap_or_else(|| format!("Player({})", winner_id.0));
.unwrap_or_else(|| format!("Player({})", player_id.0));
let prize = tournament
.prize_structure()
.prize_for_placement(1)
.prize_for_placement(finish_position)
.unwrap_or(0);
result.record_player_finish(&name, 1, prize);
log::debug!(
"Tournament {}: {} finish={} prize={}",
tournament_index + 1,
name,
finish_position,
prize
);
player_results.push((name, finish_position, prize));
recorded.insert(player_id);
}
if let Some(winner_id) = tournament.winner() {
if !recorded.contains(&winner_id) {
let name = player_names
.get(&winner_id)
.cloned()
.unwrap_or_else(|| format!("Player({})", winner_id.0));
let prize = tournament
.prize_structure()
.prize_for_placement(1)
.unwrap_or(0);
player_results.push((name, 1, prize));
}
}
Ok(TourneyResult {
total_hands,
player_results,
})
})
.collect::<Result<Vec<_>, GameError>>()?;
for tr in tournament_results {
result.total_hands_played += tr.total_hands;
for (name, placement, prize) in tr.player_results {
result.record_player_finish(&name, placement, prize);
}
}

View file

@ -887,7 +887,7 @@ mod tests {
fn record_tournament_result_processes_completed_tournament() {
let config = TableConfig {
num_seats: 9,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,

View file

@ -56,7 +56,7 @@ impl Default for TableConfig {
fn default() -> Self {
Self {
num_seats: 9,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -330,11 +330,6 @@ impl Table {
return Err(GameError::HandAlreadyInProgress);
}
// 2. Count active players (non-SittingOut).
let active_count = self.active_player_count();
if active_count < MIN_PLAYERS as usize {
return Err(GameError::NotEnoughPlayers(active_count));
}
// 2.5. Bankroll management.
if self.reset_bankroll_each_hand {
@ -416,7 +411,36 @@ impl Table {
.ok_or(GameError::NoHandInProgress)?;
// Run the hand to completion.
let result = runner.run_to_completion()?;
let result = match runner.run_to_completion() {
Ok(result) => result,
Err(e) => {
// CRITICAL: Restore seats even on error. Without this, the
// seats are trapped inside the dropped runner, leaving
// self.seats empty. The next start_hand() would see 0 active
// players, return NotEnoughPlayers, and silently terminate
// the entire simulation.
log::warn!("Hand execution failed ({e:?}) — restoring seats to table");
let seats = runner.into_seats();
for seat in seats {
let idx = seat.index as usize;
if idx < self.seats.len() {
let mut seat = seat;
// Refund wagered chips before resetting — the pot_manager
// is dropped with the runner, so wagered chips would
// vanish without this refund.
seat.stack += seat.wagered_this_hand;
if seat.stack == 0 {
seat.state = PlayerState::SittingOut;
} else {
seat.reset_for_new_hand();
}
self.seats[idx] = Some(seat);
}
}
self.current_hand = None;
return Err(e);
}
};
// Extract seats from the runner and restore them to the table.
// HandRunner::into_seats() consumes the runner and returns the seats
@ -563,6 +587,7 @@ impl Table {
num_raises: 0,
hand_number: self.hand_number,
active_players: self.active_player_count(),
table_id: String::new(),
}
}
@ -833,7 +858,7 @@ mod tests {
fn default_config() -> TableConfig {
TableConfig {
num_seats: 6,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -1288,7 +1313,7 @@ mod tests {
fn test_table_full() {
let config = TableConfig {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 10 },
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,

View file

@ -1006,7 +1006,7 @@ mod tests {
fn default_table_config() -> TableConfig {
TableConfig {
num_seats: 9,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -1188,7 +1188,7 @@ mod tests {
fn test_sng_registration_fills_table() {
let config = TableConfig {
num_seats: 4,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -1217,7 +1217,7 @@ mod tests {
fn test_sng_run_single_hand() {
let config = TableConfig {
num_seats: 4,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -1283,7 +1283,7 @@ mod tests {
fn test_mtt_table_balancing() {
let config = TableConfig {
num_seats: 9,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,
@ -1328,7 +1328,7 @@ mod tests {
fn test_blind_advancement() {
let config = TableConfig {
num_seats: 4,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
small_blind: 10,
big_blind: 20,
ante: 0,