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
This commit is contained in:
Jan-Peter von Hunnius 2026-05-25 19:44:21 +02:00
parent 912445d992
commit 156904f2bf
4 changed files with 455 additions and 0 deletions

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

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

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
// ---------------------------------------------------------------------------
@ -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<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)
}
}
// ---------------------------------------------------------------------------
@ -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);
}
}