feat: align num_raises with Meerkat semantics and add voluntary_raises() helper

- Change GameInfo.num_raises to use Meerkat convention where BB counts
  as the first raise preflop (subtract 1 for SB only, not 2)
- Add GameInfo::voluntary_raises() for Fixed-Limit cap enforcement
- Update flock_bot, raise_bot, and random_bot to use voluntary_raises()
  for can_raise() checks
- Add Evaluator::supersonic() accessor for incremental trie API
- Update documentation across betting.rs, game_info.rs, hand_runner.rs,
  and README.md to clarify the two counting conventions
This commit is contained in:
Jan-Peter von Hunnius 2026-05-27 18:44:29 +02:00
parent 156904f2bf
commit a46a800d11
8 changed files with 70 additions and 30 deletions

View file

@ -723,6 +723,15 @@ impl Evaluator {
self.supersonic.is_some()
}
/// Returns a reference to the underlying [`SupersonicEvaluator`], if available.
///
/// Use this for the incremental trie API ([`init_lookup`](SupersonicEvaluator::init_lookup),
/// [`advance_lookup`](SupersonicEvaluator::advance_lookup), etc.) which avoids
/// re-traversing shared card prefixes during rollouts.
pub fn supersonic(&self) -> Option<&SupersonicEvaluator> {
self.supersonic.as_deref()
}
/// Evaluate a 5-card hand
pub fn evaluate_5_card(&self, cards: &[Card; 5]) -> HandValue {
// Priority 1: Supersonic LUT (O(5) array lookups)

View file

@ -330,7 +330,8 @@ impl GameInfo {
/// Total pot size.
pub fn get_pot_size(&self) -> ChipCount { ... }
/// Number of raises made in the current betting round.
/// Number of raises on the current street (Meerkat semantics).
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as a raise.
pub fn get_num_raises(&self) -> usize { ... }
}
```

View file

@ -171,6 +171,10 @@ pub struct BettingRound {
/// The size of the last raise increment (used to compute min-raise).
pub last_raise_size: ChipCount,
/// Number of raises made so far this round.
///
/// **Note:** This tracks *voluntary* raises only (for Fixed-Limit cap
/// enforcement). This is distinct from `GameInfo.num_raises`, which uses
/// Meerkat semantics where the BB counts as the first raise preflop.
pub num_raises: usize,
/// Whether the round is considered complete.
pub is_complete: bool,

View file

@ -446,9 +446,9 @@ impl FlockBot {
}
}
PreflopStrength::Strong => {
// num_raises is the count of voluntary raises (excludes blinds).
if num_raises == 0 {
// No voluntary raise: raise to 3x BB
// num_raises uses Meerkat semantics: BB counts as the first raise preflop.
if num_raises == 1 {
// Unraised pot (just BB): raise to 3x BB
let raise_target = 3 * big_blind;
self.compute_raise_total(game_info, raise_target)
.unwrap_or_else(|| {
@ -458,7 +458,7 @@ impl FlockBot {
Action::Call
}
})
} else if num_raises == 1 {
} else if num_raises == 2 {
// Facing one voluntary raise: call
if stack <= call_amount {
Action::AllIn
@ -466,7 +466,7 @@ impl FlockBot {
Action::Call
}
} else {
// 2+ voluntary raises: fold (not premium)
// 2+ voluntary raises (3+ in Meerkat count): fold (not premium)
Action::Fold
}
}
@ -800,7 +800,7 @@ mod tests {
);
// Someone limped (call_amount = BB = 20, no voluntary raises)
let mut game_info = make_game_info(20, 40, 1000, 0, Street::Preflop, vec![]);
game_info.num_raises = 0; // No voluntary raises yet
game_info.num_raises = 1; // Meerkat: BB counts as first raise (unraised pot)
let action = bot.act(&game_info);
// Should raise to 3x BB = 60, not just call
assert!(
@ -823,7 +823,7 @@ mod tests {
);
// Facing one voluntary raise to 60 (3x BB)
let mut game_info = make_game_info(60, 80, 1000, 0, Street::Preflop, vec![]);
game_info.num_raises = 1; // One voluntary raise
game_info.num_raises = 2; // Meerkat: BB + one voluntary raise
assert_eq!(bot.act(&game_info), Action::Call);
}
@ -840,7 +840,7 @@ mod tests {
);
// Facing a 3-bet to 180 (2 voluntary raises)
let mut game_info = make_game_info(180, 240, 1000, 0, Street::Preflop, vec![]);
game_info.num_raises = 2; // Two voluntary raises
game_info.num_raises = 3; // Meerkat: BB + two voluntary raises
assert_eq!(bot.act(&game_info), Action::Fold);
}

View file

@ -148,11 +148,12 @@ impl HoldemPlayer for RaiseBot {
.fixed_bet_size(game_info.street)
.unwrap_or(small_bet);
// Check if raises are still allowed (typically max 3 raises per round)
// Use the authoritative raise counter from GameInfo.
let raises_this_round = game_info.num_raises;
// Check if raises are still allowed (typically max 3 voluntary raises per round).
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
// but the Fixed-Limit cap applies to voluntary raises only.
let voluntary_raises = game_info.voluntary_raises();
if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) {
if !game_info.bet_limit.can_raise(game_info.street, voluntary_raises) {
// Can't raise anymore, just call
return if call_amount == 0 {
Action::Check
@ -431,7 +432,7 @@ mod tests {
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
// Create game info with 3 raises already made on the flop (Fixed-Limit max)
let mut game_info = make_fl_game_info(40, 100, 1000, 0, Street::Flop);
game_info.num_raises = 3; // 3 voluntary raises already on the flop
game_info.num_raises = 3; // 3 raises already on the flop (postflop = Meerkat count)
let action = bot.act(&game_info);
// Can't raise anymore, should call
assert_eq!(action, Action::Call);

View file

@ -100,10 +100,13 @@ impl RandomBot {
}
}
// Check if raising is possible
// Check if raising is possible.
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
// but the Fixed-Limit cap applies to voluntary raises only.
let voluntary_raises = game_info.voluntary_raises();
let can_raise = game_info
.bet_limit
.can_raise(game_info.street, game_info.num_raises);
.can_raise(game_info.street, voluntary_raises);
if can_raise && stack > call_amount {
// Player has chips remaining after calling

View file

@ -75,13 +75,16 @@ pub struct GameInfo {
///
/// **Meerkat convention:** On the preflop street (index 0), the small blind
/// and big blind postings are recorded as [`Action::Raise`] entries at the
/// beginning of the list. These are *not* voluntary player raises. Use
/// [`num_raises`](Self::num_raises) for the authoritative count of voluntary
/// raises on the current street, which excludes blind postings.
/// beginning of the list. Use [`num_raises`](Self::num_raises) for the
/// authoritative raise count on the current street (Meerkat semantics where
/// the BB counts as the first raise preflop).
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
// --- Action counters (authoritative, maintained by the engine) ---
/// Number of voluntary raises on the current street (excludes blinds).
/// Number of raises on the current street (Meerkat semantics).
///
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as
/// a raise. This means `to_call > 0` always implies `num_raises >= 1`.
/// Reset to 0 when advancing to a new street.
pub num_raises: usize,
@ -200,10 +203,24 @@ impl GameInfo {
self.pot
}
/// Returns the number of voluntary raises on the current street.
/// Returns the number of raises on the current street (Meerkat semantics).
///
/// Corresponds to Meerkat's `GameInfo.getNumRaises()`.
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as
/// a raise. Corresponds to Meerkat's `GameInfo.getNumRaises()`.
pub fn get_num_raises(&self) -> usize {
self.num_raises
}
/// Returns the number of *voluntary* raises on the current street.
///
/// This converts from Meerkat semantics (where the BB counts as the first
/// raise preflop) to the count of actual player-initiated raises. Useful
/// for Fixed-Limit cap enforcement where only voluntary raises are capped.
pub fn voluntary_raises(&self) -> usize {
if self.is_preflop() {
self.num_raises.saturating_sub(1)
} else {
self.num_raises
}
}
}

View file

@ -194,11 +194,13 @@ pub struct HandRunner {
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
///
/// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are
/// recorded as `Action::Raise` entries (the first two entries). Code that
/// counts voluntary raises from this field must subtract 2 on preflop.
/// Use `blind_postings_snapshot` for accurate blind data in hand history
/// recorded as `Action::Raise` entries (the first two entries). The
/// `GameInfo.num_raises` field uses Meerkat semantics where the BB counts
/// as the first raise preflop (SB is excluded). Use
/// `blind_postings_snapshot` for accurate blind data in hand history
/// consumers. The `BettingRound.num_raises` field tracks only voluntary
/// raises and is unaffected by this convention.
/// raises for Fixed-Limit cap enforcement and is unaffected by this
/// convention.
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
/// Optional RNG seed for reproducible shuffles.
seed: Option<u64>,
@ -1405,8 +1407,11 @@ impl HandRunner {
.and_then(|id| self.find_seat_index_by_player_id(id))
.map(|idx| self.seats[idx].index);
// Count voluntary raises on the current street (exclude blind postings).
// Count raises on the current street using Meerkat semantics.
// On preflop, the first two Raise entries are the SB and BB postings.
// The BB counts as the first raise (Meerkat convention), but the SB does not.
// So we subtract 1 (for the SB only), keeping the BB as a counted raise.
// Postflop, all Raise entries are counted directly.
let street_idx = self.street_to_action_index();
let num_raises = self
.actions_this_hand
@ -1416,10 +1421,10 @@ impl HandRunner {
.iter()
.filter(|(_, a)| matches!(a, Action::Raise { .. }))
.count();
// On preflop, the SB and BB are recorded as raises (Meerkat convention).
// Subtract them to get the count of voluntary player raises.
// On preflop, subtract 1 for the SB posting only.
// The BB remains counted as the first raise (Meerkat semantics).
if self.current_street() == Street::Preflop {
total_raises.saturating_sub(2)
total_raises.saturating_sub(1)
} else {
total_raises
}