Skip to content

Commit 0680782

Browse files
fix(arena): thread competition RNG through sim builder and RandomAgent (#266)
Two holes made "seeded competition" runs non-deterministic: 1. `StandardSimulationIterator::next()` built each simulation via `HoldemSimulationBuilder::build()`, which called `rand::rng()` unconditionally to draw the simulation ID. Two runs with the same competition seed produced different IDs, which flow into hand histories and CFR node keys. 2. `RandomAgentGenerator::generate()` constructed each `RandomAgent` via `RandomAgent::new`, which seeds from OS entropy. Any game involving a `RandomAgent` pulled fresh randomness per hand. Add a `with_rng` constructor to `StandardSimulationIterator` that stores a `StdRng` and feeds it into `build_with_rng`. Add a `seeded(seed)` constructor to `RandomAgentGenerator` that derives a per-player seed (`seed + player_idx`) for each generated agent, so repeated runs produce identical action streams. Cover both with regression tests.
1 parent d5f8c63 commit 0680782

2 files changed

Lines changed: 151 additions & 6 deletions

File tree

src/arena/agent/random.rs

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ pub struct RandomAgentGenerator {
141141
name: Option<String>,
142142
percent_fold: Vec<f64>,
143143
percent_call: Vec<f64>,
144+
/// Optional base seed. When set, each generated `RandomAgent` is
145+
/// seeded from `base.wrapping_add(player_idx as u64)` so that
146+
/// repeated runs of the same competition produce bit-identical
147+
/// decisions. When `None` the agents draw fresh OS entropy.
148+
seed: Option<u64>,
144149
}
145150

146151
impl RandomAgentGenerator {
@@ -149,6 +154,19 @@ impl RandomAgentGenerator {
149154
name: None,
150155
percent_fold,
151156
percent_call,
157+
seed: None,
158+
}
159+
}
160+
161+
/// Create a generator whose produced agents are seeded from a
162+
/// deterministic base. Each player's `RandomAgent` is seeded from
163+
/// `seed.wrapping_add(player_idx as u64)`.
164+
pub fn seeded(percent_fold: Vec<f64>, percent_call: Vec<f64>, seed: u64) -> Self {
165+
Self {
166+
name: None,
167+
percent_fold,
168+
percent_call,
169+
seed: Some(seed),
152170
}
153171
}
154172

@@ -157,6 +175,12 @@ impl RandomAgentGenerator {
157175
self
158176
}
159177

178+
/// Set (or clear) the seed used to construct generated agents.
179+
pub fn with_seed(mut self, seed: Option<u64>) -> Self {
180+
self.seed = seed;
181+
self
182+
}
183+
160184
fn resolve_name(&self, player_idx: usize) -> String {
161185
self.name
162186
.clone()
@@ -166,11 +190,20 @@ impl RandomAgentGenerator {
166190

167191
impl AgentGenerator for RandomAgentGenerator {
168192
fn generate(&self, player_idx: usize, _game_state: &GameState) -> Box<dyn Agent> {
169-
Box::new(RandomAgent::new(
170-
self.resolve_name(player_idx),
171-
self.percent_fold.clone(),
172-
self.percent_call.clone(),
173-
))
193+
let name = self.resolve_name(player_idx);
194+
match self.seed {
195+
Some(base) => Box::new(RandomAgent::new_with_seed(
196+
name,
197+
self.percent_fold.clone(),
198+
self.percent_call.clone(),
199+
base.wrapping_add(player_idx as u64),
200+
)),
201+
None => Box::new(RandomAgent::new(
202+
name,
203+
self.percent_fold.clone(),
204+
self.percent_call.clone(),
205+
)),
206+
}
174207
}
175208
}
176209

@@ -387,6 +420,51 @@ mod tests {
387420
}
388421
}
389422

423+
/// Regression test for M2: `RandomAgentGenerator::seeded` must
424+
/// produce deterministic agents — two generators built with the
425+
/// same seed must generate agents whose action streams are
426+
/// identical across runs.
427+
#[test]
428+
fn test_seeded_random_agent_generator_is_deterministic() {
429+
let game_state = GameStateBuilder::new()
430+
.num_players_with_stack(2, 500.0)
431+
.blinds(10.0, 5.0)
432+
.build()
433+
.unwrap();
434+
435+
let collect_actions = || {
436+
let generator =
437+
RandomAgentGenerator::seeded(vec![0.1, 0.2], vec![0.4, 0.3], 0xdeadbeef);
438+
let mut agent = generator.generate(1, &game_state);
439+
let mut actions = Vec::new();
440+
for i in 0..64u128 {
441+
actions.push(agent.act(i, &game_state));
442+
}
443+
actions
444+
};
445+
446+
let run_a = collect_actions();
447+
let run_b = collect_actions();
448+
assert_eq!(run_a, run_b);
449+
}
450+
451+
/// Different player indices must still produce independent streams
452+
/// even when seeded from the same base.
453+
#[test]
454+
fn test_seeded_random_agent_generator_differs_across_players() {
455+
let game_state = GameStateBuilder::new()
456+
.num_players_with_stack(2, 500.0)
457+
.blinds(10.0, 5.0)
458+
.build()
459+
.unwrap();
460+
let generator = RandomAgentGenerator::seeded(vec![0.1, 0.2], vec![0.4, 0.3], 0xdeadbeef);
461+
let mut p0 = generator.generate(0, &game_state);
462+
let mut p1 = generator.generate(1, &game_state);
463+
let actions0: Vec<_> = (0..32u128).map(|i| p0.act(i, &game_state)).collect();
464+
let actions1: Vec<_> = (0..32u128).map(|i| p1.act(i, &game_state)).collect();
465+
assert_ne!(actions0, actions1);
466+
}
467+
390468
#[test]
391469
fn test_random_generator_uses_custom_name() {
392470
let generator = RandomAgentGenerator::new(vec![0.0], vec![1.0]).with_name("RandomHero");

src/arena/competition/sim_iterator.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,64 @@
1+
use rand::{SeedableRng, rng as os_rng, rngs::StdRng};
2+
13
use crate::arena::{
24
AgentGenerator, GameState, HoldemSimulation, HoldemSimulationBuilder,
35
historian::HistorianGenerator,
46
};
57

8+
/// Iterator that materialises one [`HoldemSimulation`] per item pulled from
9+
/// the underlying game-state iterator.
10+
///
11+
/// The iterator owns an RNG used exclusively to generate per-simulation
12+
/// IDs (see [`HoldemSimulationBuilder::build_with_rng`]). By default the
13+
/// RNG is seeded from OS entropy, so repeated runs produce distinct IDs.
14+
/// For deterministic runs (e.g., reproducing a seeded competition), build
15+
/// with [`StandardSimulationIterator::with_rng`] and pass an RNG forked
16+
/// from the competition's own RNG.
617
pub struct StandardSimulationIterator<G>
718
where
819
G: Iterator<Item = GameState>,
920
{
1021
agent_generators: Vec<Box<dyn AgentGenerator>>,
1122
historian_generators: Vec<Box<dyn HistorianGenerator>>,
1223
game_state_iterator: G,
24+
rng: StdRng,
1325
}
1426

1527
impl<G> StandardSimulationIterator<G>
1628
where
1729
G: Iterator<Item = GameState>,
1830
{
31+
/// Create a new iterator seeded from OS entropy.
32+
///
33+
/// Equivalent to [`Self::with_rng`] seeded from `rand::rng()`.
1934
pub fn new(
2035
agent_generators: Vec<Box<dyn AgentGenerator>>,
2136
historian_generators: Vec<Box<dyn HistorianGenerator>>,
2237
game_state_iterator: G,
38+
) -> StandardSimulationIterator<G> {
39+
Self::with_rng(
40+
agent_generators,
41+
historian_generators,
42+
game_state_iterator,
43+
StdRng::from_rng(&mut os_rng()),
44+
)
45+
}
46+
47+
/// Create a new iterator that draws simulation IDs from the provided
48+
/// RNG. Use this when you need repeated runs of the same competition
49+
/// to produce identical simulation IDs (and therefore identical hand
50+
/// histories and CFR keys).
51+
pub fn with_rng(
52+
agent_generators: Vec<Box<dyn AgentGenerator>>,
53+
historian_generators: Vec<Box<dyn HistorianGenerator>>,
54+
game_state_iterator: G,
55+
rng: StdRng,
2356
) -> StandardSimulationIterator<G> {
2457
StandardSimulationIterator {
2558
agent_generators,
2659
historian_generators,
2760
game_state_iterator,
61+
rng,
2862
}
2963
}
3064
}
@@ -50,7 +84,7 @@ where
5084
.agents(agents)
5185
.historians(historians)
5286
.game_state(game_state)
53-
.build()
87+
.build_with_rng(&mut self.rng)
5488
.ok()
5589
}
5690
}
@@ -100,4 +134,37 @@ mod tests {
100134
.next()
101135
.expect("There should always be a first simulation");
102136
}
137+
138+
/// Regression test for M2: two iterators seeded from the same RNG
139+
/// must produce identical simulation IDs. Previously the builder
140+
/// called `rand::rng()` unconditionally, so repeated runs diverged
141+
/// even with identical inputs.
142+
#[test]
143+
fn test_with_rng_is_deterministic() {
144+
let build = || {
145+
let generators: Vec<Box<dyn AgentGenerator>> = vec![
146+
Box::<FoldingAgentGenerator>::default(),
147+
Box::<FoldingAgentGenerator>::default(),
148+
];
149+
let game_state = GameStateBuilder::new()
150+
.stacks(vec![100.0; 2])
151+
.blinds(10.0, 5.0)
152+
.build()
153+
.unwrap();
154+
StandardSimulationIterator::with_rng(
155+
generators,
156+
vec![],
157+
CloneGameStateGenerator::new(game_state),
158+
StdRng::seed_from_u64(42),
159+
)
160+
};
161+
162+
let mut a = build();
163+
let mut b = build();
164+
for _ in 0..5 {
165+
let sa = a.next().unwrap();
166+
let sb = b.next().unwrap();
167+
assert_eq!(sa.id, sb.id);
168+
}
169+
}
103170
}

0 commit comments

Comments
 (0)