From 156904f2bf253a1b7b28f3c4b72ac4a719f3b277 Mon Sep 17 00:00:00 2001 From: "Jan-Peter v. Hunnius" Date: Mon, 25 May 2026 19:44:21 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + holdem_core/src/deck.rs | 36 +++ holdem_core/src/evaluator/mod.rs | 3 + holdem_core/src/evaluator/supersonic.rs | 413 ++++++++++++++++++++++++ 4 files changed, 455 insertions(+) diff --git a/.gitignore b/.gitignore index 7e5cf05..f3b19d1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Thumbs.db # Proptest regressions (auto-generated) proptest-regressions/ + +# Local reference file (not tracked) +docs/REFERENCES.md diff --git a/holdem_core/src/deck.rs b/holdem_core/src/deck.rs index 262fa7e..ca7f762 100644 --- a/holdem_core/src/deck.rs +++ b/holdem_core/src/deck.rs @@ -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 = excluded.iter().copied().collect(); + let cards: Vec = self + .cards + .iter() + .copied() + .filter(|c| !exclude_set.contains(c)) + .collect(); + Deck { cards } + } } impl Default for Deck { diff --git a/holdem_core/src/evaluator/mod.rs b/holdem_core/src/evaluator/mod.rs index 07346b2..3e9bfb7 100644 --- a/holdem_core/src/evaluator/mod.rs +++ b/holdem_core/src/evaluator/mod.rs @@ -54,6 +54,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; diff --git a/holdem_core/src/evaluator/supersonic.rs b/holdem_core/src/evaluator/supersonic.rs index 73f48c5..5d6ab70 100644 --- a/holdem_core/src/evaluator/supersonic.rs +++ b/holdem_core/src/evaluator/supersonic.rs @@ -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 // --------------------------------------------------------------------------- @@ -338,6 +381,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 { + 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) + } } // --------------------------------------------------------------------------- @@ -882,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); + } }