test: add exhaustive multi-threaded 7-card correctness test across all three evaluators

This commit is contained in:
Jan-Peter von Hunnius 2026-05-30 15:26:54 +02:00
parent 458b8af580
commit ed911e3c3a
2 changed files with 333 additions and 0 deletions

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

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