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.
This commit is contained in:
Jan-Peter von Hunnius 2026-05-30 12:55:11 +02:00
parent a46a800d11
commit 458b8af580
8 changed files with 238 additions and 274 deletions

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

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

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

@ -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

@ -243,27 +243,37 @@ impl SupersonicEvaluator {
}
Err(e) => {
log::warn!(
"Failed to load Supersonic table ({}), falling back to Cactus Kev",
"Failed to load Supersonic table ({}), regenerating...",
e
);
}
}
}
// Table doesn't exist or is corrupt.
//
// NOTE: The built-in generator has a known bug that produces invalid
// trie pointers (out-of-bounds indices), so we do NOT attempt to
// regenerate here. Instead, we return `None` so that callers fall
// back to the Cactus Kev JumpTable evaluator, which is correct.
//
// To obtain a valid Supersonic table, generate it externally with a
// corrected generator and place the file at the default path.
// Table doesn't exist or is corrupt — generate a new one.
log::info!(
"Supersonic table not found at {}, falling back to Cactus Kev evaluator",
"Generating Supersonic LUT table at {} (takes a few seconds on first run)...",
path.display()
);
None
let hand_ranks = super::supersonic_generator::generate_hand_ranks();
match Self::from_hand_ranks(hand_ranks) {
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(evaluator)
}
Err(e) => {
log::error!("Failed to create Supersonic evaluator from generated table: {}", e);
None
}
}
}
// -----------------------------------------------------------------------
@ -686,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.
@ -1008,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 {

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

@ -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};
@ -732,28 +732,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);
}
}

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;
@ -228,121 +229,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);
}
}