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:
parent
912445d992
commit
156904f2bf
4 changed files with 455 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -11,3 +11,6 @@ Thumbs.db
|
|||
|
||||
# Proptest regressions (auto-generated)
|
||||
proptest-regressions/
|
||||
|
||||
# Local reference file (not tracked)
|
||||
docs/REFERENCES.md
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue