Skip to content

Commit ccb012a

Browse files
fix(cfr): fall back to Call when preflop chart prescribes raise but cap is reached (#271)
When `max_raises_per_round` (default 3) was reached during preflop, `PreflopChartActionGenerator::convert_preflop_action` still emitted a `Bet(three_bet_amount)` for chart entries marked Raise/ThreeBet/FourBet. The downstream `validate_actions` pipeline (introduced in 598ebb5) then stripped this bet via `filter_raises_when_capped`, leaving an empty action set. `ActionPicker::pick_action` subsequently panicked with "cannot sample empty range" inside `rng.random_range(0..0)`. The fix checks `game_state.is_raise_capped()` in `convert_preflop_action` and returns `AgentAction::Call` for any raise-type chart action when the cap is reached. This preserves the chart's intent — the hand is playable — while respecting the raise limit so the agent calls to see the flop instead of producing an action that gets filtered into an empty set.
1 parent 8d0daa3 commit ccb012a

1 file changed

Lines changed: 103 additions & 0 deletions

File tree

src/arena/cfr/action_generator/preflop_chart.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,14 @@ impl PreflopChartActionGenerator {
350350
let player_stack = game_state.stacks[player_idx];
351351
let to_call = current_bet - player_bet;
352352

353+
// When the raise cap has been reached, any Raise/3-bet/4-bet the
354+
// chart prescribes is illegal. Fall back to Call so the agent still
355+
// plays the hand the chart flagged as playable rather than folding
356+
// it — and, more importantly, so the validated action set is never
357+
// empty (which would panic the picker with "cannot sample empty
358+
// range").
359+
let raise_capped = game_state.is_raise_capped();
360+
353361
match action {
354362
PreflopActionType::Fold => {
355363
// If there's nothing to call, fold becomes check
@@ -361,6 +369,9 @@ impl PreflopChartActionGenerator {
361369
}
362370
PreflopActionType::Call => Some(AgentAction::Call),
363371
PreflopActionType::Raise => {
372+
if raise_capped {
373+
return Some(AgentAction::Call);
374+
}
364375
// Standard open raise: raise_size_bb * big_blind
365376
let raise_amount = self.config.preflop_config.raise_size_bb * big_blind;
366377

@@ -376,12 +387,18 @@ impl PreflopChartActionGenerator {
376387
}
377388
}
378389
PreflopActionType::ThreeBet => {
390+
if raise_capped {
391+
return Some(AgentAction::Call);
392+
}
379393
// 3-bet: multiply current bet by three_bet_multiplier
380394
let three_bet_amount =
381395
current_bet * self.config.preflop_config.three_bet_multiplier;
382396
Some(self.bet_or_all_in(three_bet_amount, player_stack, player_bet))
383397
}
384398
PreflopActionType::FourBet => {
399+
if raise_capped {
400+
return Some(AgentAction::Call);
401+
}
385402
// 4-bet: typically 2.5x the 3-bet
386403
let four_bet_amount = current_bet * 2.5;
387404
Some(self.bet_or_all_in(four_bet_amount, player_stack, player_bet))
@@ -491,6 +508,92 @@ mod tests {
491508
)
492509
}
493510

511+
/// Regression: when the raise cap is reached preflop and the chart says
512+
/// Raise, the generator used to output a `Bet(three_bet_amount)` that
513+
/// `validate_actions` would then strip as a capped raise, leaving an
514+
/// empty action set and panicking the picker with "cannot sample empty
515+
/// range". A preflop chart agent must fall back to a non-raise action
516+
/// (Call or Fold) so the validated set is never empty.
517+
#[test]
518+
fn test_chart_raise_when_raise_capped_falls_back_to_call() {
519+
use crate::arena::cfr::{ValidatorMode, validate_actions};
520+
use crate::arena::game_state::{Round, RoundData};
521+
use crate::core::{Hand, PlayerBitSet};
522+
523+
// 6-handed preflop, three raises already in the pot.
524+
// Player 0 is to act; they hold AA so the chart entry says Raise.
525+
let num_players = 6;
526+
let big_blind = 5.0;
527+
let small_blind = 2.5;
528+
529+
// Round bets: P1 opens to 12.5, P3 3-bets to 37.5, P5 4-bets to 112.5.
530+
// Everyone else has 0 in this round (P0 is about to act).
531+
let round_player_bet = vec![0.0, 12.5, 0.0, 37.5, 0.0, 112.5];
532+
// Starting stacks uniform.
533+
let stacks: Vec<f32> = vec![500.0; num_players];
534+
// Carry round bets into total player_bet for accurate state.
535+
let player_bet = round_player_bet.clone();
536+
537+
let mut round_data = RoundData::new_with_bets(
538+
big_blind, // min raise
539+
PlayerBitSet::new(num_players),
540+
0, // P0 to act
541+
round_player_bet,
542+
);
543+
// Force raise-capped state (default cap is 3).
544+
round_data.total_raise_count = 3;
545+
546+
let mut hands = vec![Hand::default(); num_players];
547+
hands[0] = Hand::new_from_str("AsAh").unwrap();
548+
549+
let game_state = GameStateBuilder::new()
550+
.round(Round::Preflop)
551+
.round_data(round_data)
552+
.stacks(stacks)
553+
.player_bet(player_bet)
554+
.big_blind(big_blind)
555+
.small_blind(small_blind)
556+
.hands(hands)
557+
.build()
558+
.unwrap();
559+
560+
assert!(
561+
game_state.is_raise_capped(),
562+
"test setup: expected raise cap reached"
563+
);
564+
565+
// Pure-Raise chart for AA, matching the experiment config shape.
566+
let mut chart = PreflopChart::new();
567+
let aa = PreflopHand::new(Value::Ace, Value::Ace, false);
568+
chart.set(aa, PreflopStrategy::pure(PreflopActionType::Raise));
569+
let config = PreflopChartActionConfig {
570+
preflop_config: PreflopChartConfig::with_single_chart(chart),
571+
postflop_config: ConfigurableActionConfig::default(),
572+
};
573+
574+
let generator = create_generator(&game_state, config);
575+
let raw = generator.gen_possible_actions(&game_state);
576+
assert!(
577+
!raw.is_empty(),
578+
"generator must always produce at least one action"
579+
);
580+
581+
let validated = validate_actions(raw.clone(), &game_state, ValidatorMode::Standard);
582+
assert!(
583+
!validated.is_empty(),
584+
"validated action set must not be empty (raw was {raw:?})"
585+
);
586+
587+
// Intelligent fallback: calling should always be possible when
588+
// facing a raise-capped bet, so the agent can still see the flop.
589+
assert!(
590+
validated
591+
.iter()
592+
.any(|a| matches!(a, AgentAction::Call | AgentAction::Fold)),
593+
"expected Call or Fold in fallback action set, got {validated:?}"
594+
);
595+
}
596+
494597
#[test]
495598
fn test_preflop_actions_for_chart_hand() {
496599
let game_state = create_test_game_state();

0 commit comments

Comments
 (0)