Skip to main content

turnbase_bots/
ismcts.rs

1//! Information-set Monte Carlo tree search (single-observer ISMCTS).
2
3use std::f64::consts::SQRT_2;
4
5use turnbase::{Determinize, Game, PlayerId, Prng, sample_chance};
6
7use crate::{Bot, RankedBot};
8
9/// Single-observer information-set MCTS for hidden-information games.
10///
11/// Standard [`Mcts`](crate::Mcts) cheats at hidden information: it searches the
12/// one true state, so it sees opponents' secret cards. ISMCTS instead searches
13/// from a player's *information set*. Each simulation asks the game for a fresh
14/// determinization ([`Determinize::determinize`]) — a full world consistent with
15/// what the searcher can see, but with the hidden parts resampled — and runs one
16/// UCT iteration in that world. Averaging over many sampled worlds yields a move
17/// that is good on expectation without ever peeking.
18///
19/// A single tree is shared across determinizations. Because different worlds
20/// offer different legal moves, selection uses an *availability* count (how many
21/// simulations a move was legal for) in the UCB denominator, and only moves
22/// legal in the current world are considered. Values are backed up as a vector,
23/// one entry per seat, and each node selects to maximize the mover's own entry
24/// (`max^n`), so this handles three- and four-player games, not just two-player
25/// zero-sum. Rollouts are uniform-random. All randomness comes from an internal
26/// seeded generator, so a run is reproducible.
27pub struct Ismcts {
28    iterations: u32,
29    exploration: f64,
30    rng: Prng,
31}
32
33impl Ismcts {
34    /// Creates a search running `iterations` simulations per move, seeded from
35    /// `seed`. Uses the standard UCT exploration constant (sqrt 2).
36    #[must_use]
37    pub const fn new(iterations: u32, seed: u64) -> Self {
38        Self {
39            iterations,
40            exploration: SQRT_2,
41            rng: Prng::new(seed),
42        }
43    }
44
45    /// Sets the UCT exploration constant (higher explores more).
46    #[must_use]
47    pub const fn with_exploration(mut self, exploration: f64) -> Self {
48        self.exploration = exploration;
49        self
50    }
51
52    /// Estimates `player`'s value of `state` as the mean rollout reward over
53    /// sampled worlds, searching from `player`'s information set.
54    pub fn evaluate<G>(&mut self, game: &G, state: &G::State, player: PlayerId) -> f64
55    where
56        G: Determinize,
57        G::State: Clone,
58        G::Action: Clone,
59    {
60        let tree = self.run(game, state, player);
61        tree[0].mean(player.index() as usize)
62    }
63
64    fn run<G>(
65        &mut self,
66        game: &G,
67        root_state: &G::State,
68        observer: PlayerId,
69    ) -> Vec<Node<G::Action>>
70    where
71        G: Determinize,
72        G::State: Clone,
73        G::Action: Clone,
74    {
75        let players = game.num_players();
76        let mut nodes = vec![Node::new(players)];
77        for _ in 0..self.iterations {
78            let mut world = game.determinize(root_state, observer, &mut self.rng);
79            let mut path = vec![0usize];
80            // Nodes whose availability counts to bump: (node, its legal children).
81            let mut available: Vec<Vec<usize>> = Vec::new();
82            let mut current = 0usize;
83
84            loop {
85                if game.is_terminal(&world) {
86                    break;
87                }
88                let Some(actor) = game.active_players(&world).iter().next() else {
89                    break;
90                };
91                if actor.is_chance() {
92                    let Some(action) = sample_chance(game, &world, &mut self.rng) else {
93                        break;
94                    };
95                    game.apply(&mut world, PlayerId::CHANCE, action.clone());
96                    current = child_for(&mut nodes, current, &action, players);
97                    path.push(current);
98                    continue;
99                }
100
101                let legal = game.legal_actions(&world, actor);
102                if legal.is_empty() {
103                    break;
104                }
105                let untried: Vec<G::Action> = legal
106                    .iter()
107                    .filter(|a| !nodes[current].children.iter().any(|(c, _)| c == *a))
108                    .cloned()
109                    .collect();
110
111                if !untried.is_empty() {
112                    // Index is strictly below len, so the cast cannot truncate.
113                    #[allow(clippy::cast_possible_truncation)]
114                    let pick = self.rng.below(untried.len() as u64) as usize;
115                    let action = untried[pick].clone();
116                    game.apply(&mut world, actor, action.clone());
117                    nodes.push(Node::new(players));
118                    let child = nodes.len() - 1;
119                    nodes[current].children.push((action, child));
120                    available.push(legal_children(&nodes[current], &legal));
121                    path.push(child);
122                    break;
123                }
124
125                let seat = actor.index() as usize;
126                let legal_ids = legal_children(&nodes[current], &legal);
127                let (action, child) = self.select(&nodes, current, &legal_ids, seat);
128                available.push(legal_ids);
129                game.apply(&mut world, actor, action);
130                current = child;
131                path.push(current);
132            }
133
134            let rewards = self.rollout(game, world, players);
135            for &id in &path {
136                nodes[id].visits += 1;
137                for (seat, reward) in rewards.iter().enumerate() {
138                    nodes[id].value[seat] += reward;
139                }
140            }
141            for ids in &available {
142                for &id in ids {
143                    nodes[id].avails += 1;
144                }
145            }
146        }
147        nodes
148    }
149
150    /// UCB1 over the currently-legal children, from `seat`'s perspective. The
151    /// exploration term divides by the child's availability count, not the
152    /// parent's visits, because a move may be legal in only some worlds.
153    fn select<A: Clone>(
154        &self,
155        nodes: &[Node<A>],
156        parent: usize,
157        legal_ids: &[usize],
158        seat: usize,
159    ) -> (A, usize) {
160        let mut best = None;
161        let mut best_score = f64::NEG_INFINITY;
162        for &id in legal_ids {
163            let child = &nodes[id];
164            let exploit = child.mean(seat);
165            let explore = self.exploration
166                * (f64::from(child.avails.max(1)).ln() / f64::from(child.visits.max(1))).sqrt();
167            let score = exploit + explore;
168            if score > best_score {
169                best_score = score;
170                best = Some(id);
171            }
172        }
173        let id = best.expect("a fully expanded node has legal children");
174        let action = nodes[parent]
175            .children
176            .iter()
177            .find(|(_, cid)| *cid == id)
178            .map(|(a, _)| a.clone())
179            .expect("selected child belongs to the parent");
180        (action, id)
181    }
182
183    fn rollout<G>(&mut self, game: &G, mut state: G::State, players: usize) -> Vec<f64>
184    where
185        G: Game,
186    {
187        while !game.is_terminal(&state) {
188            let Some(actor) = game.active_players(&state).iter().next() else {
189                break;
190            };
191            if actor.is_chance() {
192                let Some(action) = sample_chance(game, &state, &mut self.rng) else {
193                    break;
194                };
195                game.apply(&mut state, PlayerId::CHANCE, action);
196            } else {
197                let mut actions = game.legal_actions(&state, actor);
198                if actions.is_empty() {
199                    break;
200                }
201                // Index is strictly below len, so the cast cannot truncate.
202                #[allow(clippy::cast_possible_truncation)]
203                let index = self.rng.below(actions.len() as u64) as usize;
204                game.apply(&mut state, actor, actions.swap_remove(index));
205            }
206        }
207        (0..players)
208            .map(|seat| game.reward(&state, PlayerId::new(u32::try_from(seat).unwrap())))
209            .collect()
210    }
211}
212
213/// A shared-tree node. Its state is not stored; each simulation replays from the
214/// root in a freshly sampled world. Values are one running sum per seat.
215struct Node<A> {
216    visits: u32,
217    avails: u32,
218    value: Vec<f64>,
219    children: Vec<(A, usize)>,
220}
221
222impl<A> Node<A> {
223    fn new(players: usize) -> Self {
224        Self {
225            visits: 0,
226            avails: 0,
227            value: vec![0.0; players],
228            children: Vec::new(),
229        }
230    }
231
232    fn mean(&self, seat: usize) -> f64 {
233        if self.visits == 0 {
234            0.0
235        } else {
236            self.value[seat] / f64::from(self.visits)
237        }
238    }
239}
240
241/// The child node ids whose action is legal in the current world.
242fn legal_children<A: PartialEq>(node: &Node<A>, legal: &[A]) -> Vec<usize> {
243    node.children
244        .iter()
245        .filter(|(action, _)| legal.contains(action))
246        .map(|(_, id)| *id)
247        .collect()
248}
249
250/// Finds or creates the chance child reached by `action`.
251fn child_for<A: PartialEq + Clone>(
252    nodes: &mut Vec<Node<A>>,
253    parent: usize,
254    action: &A,
255    players: usize,
256) -> usize {
257    if let Some((_, id)) = nodes[parent].children.iter().find(|(a, _)| a == action) {
258        return *id;
259    }
260    nodes.push(Node::new(players));
261    let id = nodes.len() - 1;
262    nodes[parent].children.push((action.clone(), id));
263    id
264}
265
266impl<G> Bot<G> for Ismcts
267where
268    G: Determinize,
269    G::State: Clone,
270    G::Action: Clone,
271{
272    fn choose(&mut self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action> {
273        if game.legal_actions(state, player).is_empty() {
274            return None;
275        }
276        let tree = self.run(game, state, player);
277        tree[0]
278            .children
279            .iter()
280            .max_by_key(|(_, id)| tree[*id].visits)
281            .map(|(action, _)| action.clone())
282    }
283}
284
285impl<G> RankedBot<G> for Ismcts
286where
287    G: Determinize,
288    G::State: Clone,
289    G::Action: Clone,
290{
291    /// Ranks the searcher's moves by visit share (the ISMCTS policy). Scores are
292    /// the fraction of simulations spent on each move and sum to 1.
293    fn rank(&mut self, game: &G, state: &G::State, player: PlayerId) -> Vec<(G::Action, f64)> {
294        let tree = self.run(game, state, player);
295        let total = f64::from(tree[0].visits.max(1));
296        let mut ranked: Vec<(G::Action, f64)> = tree[0]
297            .children
298            .iter()
299            .map(|(action, id)| (action.clone(), f64::from(tree[*id].visits) / total))
300            .collect();
301        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
302        ranked
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::Ismcts;
309    use crate::{Bot, Random};
310    use coup::{Coup, CoupState};
311    use tic_tac_toe::{Move, TicTacToe};
312    use turnbase::{Game, PlayerId};
313
314    const P0: PlayerId = PlayerId::new(0);
315
316    /// Drives a two-seat match: seat 0 uses `a`, seat 1 uses `b`.
317    fn play<G, A, B>(game: &G, mut state: G::State, a: &mut A, b: &mut B) -> G::State
318    where
319        G: Game,
320        A: Bot<G>,
321        B: Bot<G>,
322    {
323        let mut steps = 0;
324        while !game.is_terminal(&state) {
325            let player = game.active_players(&state).iter().next().unwrap();
326            let action = if player.index() == 0 {
327                a.choose(game, &state, player)
328            } else {
329                b.choose(game, &state, player)
330            }
331            .expect("an active player has a move");
332            game.apply(&mut state, player, action);
333            steps += 1;
334            assert!(steps < 20_000, "match did not terminate");
335        }
336        state
337    }
338
339    #[test]
340    fn ismcts_reduces_to_mcts_on_perfect_information() {
341        // With determinize = clone, ISMCTS is ordinary UCT and must not lose to
342        // random play at tic-tac-toe.
343        let game = TicTacToe;
344        for seed in 0..6 {
345            let mut x = Ismcts::new(2000, seed);
346            let mut o = Random::new(seed + 100);
347            let end = play(&game, game.new_initial_state(0), &mut x, &mut o);
348            assert!(
349                game.reward(&end, P0) >= 0.0,
350                "ISMCTS X lost to random O (seed {seed})"
351            );
352        }
353    }
354
355    #[test]
356    fn ismcts_takes_an_immediate_win() {
357        let game = TicTacToe;
358        let mut state = game.new_initial_state(0);
359        for (seat, cell) in [(0u32, 0u8), (1, 3), (0, 1), (1, 4)] {
360            game.apply(&mut state, PlayerId::new(seat), Move(cell));
361        }
362        let mut bot = Ismcts::new(3000, 1);
363        assert_eq!(bot.choose(&game, &state, P0), Some(Move(2)));
364    }
365
366    #[test]
367    fn ismcts_beats_random_at_coup() {
368        // Hidden-information play with no cheating: ISMCTS searches sampled
369        // worlds. Over many two-player matches it should beat a random opponent
370        // comfortably. Seat 0 is ISMCTS; alternate who moves first by seed.
371        let game = Coup::new(2);
372        let mut wins = 0;
373        let matches = 30;
374        for seed in 0..matches {
375            let start = game.new_initial_state(seed);
376            let end: CoupState = if seed % 2 == 0 {
377                let mut a = Ismcts::new(400, seed);
378                let mut b = Random::new(seed ^ 0x55);
379                play(&game, start, &mut a, &mut b)
380            } else {
381                // Odd seeds: still measure seat 0 = ISMCTS, fresh generators.
382                let mut a = Ismcts::new(400, seed ^ 0xAA);
383                let mut b = Random::new(seed);
384                play(&game, start, &mut a, &mut b)
385            };
386            if game.reward(&end, P0) > 0.0 {
387                wins += 1;
388            }
389        }
390        assert!(
391            wins * 2 > matches,
392            "ISMCTS won only {wins}/{matches} vs random"
393        );
394    }
395}