diff --git a/holdem_core/src/evaluator/full_correctness_test.rs b/holdem_core/src/evaluator/full_correctness_test.rs new file mode 100644 index 0000000..3a30273 --- /dev/null +++ b/holdem_core/src/evaluator/full_correctness_test.rs @@ -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 = 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, + first_algo_ss: Option, + first_ck_ss: Option, +} + +// --------------------------------------------------------------------------- +// 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 = (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 = None; + let mut first_algo_ss: Option = None; + let mut first_ck_ss: Option = 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) { + if let Some(ref msg) = first { + eprintln!(" First mismatch : {}", msg); + } +} diff --git a/holdem_core/src/evaluator/mod.rs b/holdem_core/src/evaluator/mod.rs index 3e9bfb7..447fc79 100644 --- a/holdem_core/src/evaluator/mod.rs +++ b/holdem_core/src/evaluator/mod.rs @@ -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;