Skip to main content

quantik_core/
mcts.rs

1use crate::bitboard::Bitboard;
2use crate::game::{check_winner, current_player, WinStatus};
3use crate::moves::{apply_move, generate_legal_moves, Move};
4use crate::state::State;
5use rand::prelude::*;
6use std::collections::HashMap;
7use std::time::Instant;
8
9pub struct MCTSConfig {
10    pub exploration_weight: f64,
11    pub max_iterations: u32,
12    pub max_depth: u32,
13    pub seed: Option<u64>,
14    /// Optional wall-clock budget for `search`, in seconds. Checked after
15    /// each completed iteration; `None` means the iteration count is the
16    /// only stop condition.
17    pub time_limit_s: Option<f64>,
18    /// Merge children that reach an already-seen canonical state into the
19    /// existing node instead of allocating a fresh one. `false` always
20    /// allocates, so revisited canonical states get independent statistics.
21    pub use_transposition_table: bool,
22}
23
24impl Default for MCTSConfig {
25    fn default() -> Self {
26        Self {
27            exploration_weight: std::f64::consts::SQRT_2,
28            max_iterations: 10_000,
29            max_depth: 16,
30            seed: None,
31            time_limit_s: None,
32            use_transposition_table: true,
33        }
34    }
35}
36
37struct MCTSNode {
38    bb: Bitboard,
39    children: Vec<usize>,
40    mv: Option<Move>, // move that led here (first discovery)
41    visit_count: u32,
42    win_count_p0: u32,
43    win_count_p1: u32,
44    untried_moves: Vec<Move>,
45    is_terminal: bool,
46    terminal_value: f64, // +1 p0 win, -1 p1 win
47}
48
49pub struct MCTSEngine {
50    config: MCTSConfig,
51    nodes: Vec<MCTSNode>,
52    transpositions: HashMap<[u8; 18], usize>,
53    rng: StdRng,
54    iterations_performed: u32,
55}
56
57impl MCTSEngine {
58    pub fn new(config: MCTSConfig) -> Self {
59        if let Some(limit) = config.time_limit_s {
60            assert!(
61                limit > 0.0 && limit.is_finite(),
62                "time_limit_s must be positive and finite, got {limit}"
63            );
64        }
65        let rng = match config.seed {
66            Some(s) => StdRng::seed_from_u64(s),
67            None => StdRng::from_entropy(),
68        };
69        Self {
70            config,
71            nodes: Vec::new(),
72            transpositions: HashMap::new(),
73            rng,
74            iterations_performed: 0,
75        }
76    }
77
78    /// Run MCTS from the given bitboard and return
79    /// `(best_move, win_probability_for_the_root_mover)`.
80    pub fn search(&mut self, bb: &Bitboard) -> Option<(Move, f64)> {
81        self.nodes.clear();
82        self.transpositions.clear();
83        self.iterations_performed = 0;
84
85        let legal = generate_legal_moves(bb);
86        if legal.is_empty() {
87            return None;
88        }
89
90        let terminal = check_winner(bb);
91        let is_terminal = terminal != WinStatus::NoWin;
92        let terminal_value = match terminal {
93            WinStatus::Player0Wins => 1.0,
94            WinStatus::Player1Wins => -1.0,
95            WinStatus::NoWin => 0.0,
96        };
97
98        self.nodes.push(MCTSNode {
99            bb: *bb,
100            children: Vec::new(),
101            mv: None,
102            visit_count: 0,
103            win_count_p0: 0,
104            win_count_p1: 0,
105            untried_moves: legal,
106            is_terminal,
107            terminal_value,
108        });
109
110        let deadline = self
111            .config
112            .time_limit_s
113            .map(|s| Instant::now() + std::time::Duration::from_secs_f64(s));
114
115        for _ in 0..self.config.max_iterations {
116            let mut path = self.select(0);
117            let leaf = *path.last().expect("path always contains the root");
118            let expanded = self.expand(leaf);
119            if expanded != leaf {
120                path.push(expanded);
121            }
122            let value = self.simulate(expanded);
123            self.backpropagate(&path, value);
124            self.iterations_performed += 1;
125
126            if let Some(deadline) = deadline {
127                if Instant::now() >= deadline {
128                    break;
129                }
130            }
131        }
132
133        self.best_move(bb)
134    }
135
136    /// Descend by UCB1 from `node_id`, returning the visited path
137    /// (root..=leaf). Backpropagation follows this exact path — with
138    /// transposition merging a node can have several parents, so parent
139    /// pointers would be ambiguous.
140    fn select(&self, node_id: usize) -> Vec<usize> {
141        let mut path = vec![node_id];
142        let mut current = node_id;
143        loop {
144            let node = &self.nodes[current];
145            if node.is_terminal || !node.untried_moves.is_empty() || node.children.is_empty() {
146                return path;
147            }
148            let parent_visits = node.visit_count as f64;
149            let c = self.config.exploration_weight;
150            // The win rate must be from the perspective of the player
151            // choosing among this node's children — the side to move at
152            // THIS node — not player 0. Using p0's count unconditionally
153            // systematically preferred moves that were worse for the
154            // player actually choosing.
155            let mover = current_player(&node.bb).unwrap_or(0);
156
157            let mut best_ucb = f64::NEG_INFINITY;
158            let mut best_child = node.children[0];
159            for &child_id in &node.children {
160                let child = &self.nodes[child_id];
161                if child.visit_count == 0 {
162                    best_child = child_id;
163                    break;
164                }
165                let child_visits = child.visit_count as f64;
166                let wins = if mover == 0 {
167                    child.win_count_p0 as f64
168                } else {
169                    child.win_count_p1 as f64
170                };
171                let win_rate = wins / child_visits;
172                let ucb = win_rate + c * (parent_visits.ln() / child_visits).sqrt();
173                if ucb > best_ucb {
174                    best_ucb = ucb;
175                    best_child = child_id;
176                }
177            }
178            path.push(best_child);
179            current = best_child;
180        }
181    }
182
183    fn expand(&mut self, node_id: usize) -> usize {
184        if self.nodes[node_id].is_terminal || self.nodes[node_id].untried_moves.is_empty() {
185            return node_id;
186        }
187
188        let idx = self
189            .rng
190            .gen_range(0..self.nodes[node_id].untried_moves.len());
191        let mv = self.nodes[node_id].untried_moves.swap_remove(idx);
192        let parent_bb = self.nodes[node_id].bb;
193        let new_bb = apply_move(&parent_bb, &mv);
194
195        if self.config.use_transposition_table {
196            let key = State::new(new_bb).canonical_key();
197            if let Some(&existing) = self.transpositions.get(&key) {
198                if !self.nodes[node_id].children.contains(&existing) {
199                    self.nodes[node_id].children.push(existing);
200                }
201                return existing;
202            }
203        }
204
205        let legal = generate_legal_moves(&new_bb);
206        let terminal = check_winner(&new_bb);
207        let is_terminal = terminal != WinStatus::NoWin || legal.is_empty();
208        let terminal_value = match terminal {
209            WinStatus::Player0Wins => 1.0,
210            WinStatus::Player1Wins => -1.0,
211            WinStatus::NoWin if legal.is_empty() => {
212                // No legal moves: the player who cannot move loses
213                if current_player(&new_bb) == Some(0) {
214                    -1.0
215                } else {
216                    1.0
217                }
218            }
219            WinStatus::NoWin => 0.0,
220        };
221
222        let child_id = self.nodes.len();
223        self.nodes.push(MCTSNode {
224            bb: new_bb,
225            children: Vec::new(),
226            mv: Some(mv),
227            visit_count: 0,
228            win_count_p0: 0,
229            win_count_p1: 0,
230            untried_moves: legal,
231            is_terminal,
232            terminal_value,
233        });
234        if self.config.use_transposition_table {
235            self.transpositions
236                .insert(State::new(new_bb).canonical_key(), child_id);
237        }
238
239        self.nodes[node_id].children.push(child_id);
240        child_id
241    }
242
243    fn simulate(&mut self, node_id: usize) -> f64 {
244        let node = &self.nodes[node_id];
245        if node.is_terminal {
246            return node.terminal_value;
247        }
248
249        let mut current_bb = node.bb;
250        let mut depth = 0u32;
251
252        loop {
253            if depth >= self.config.max_depth {
254                return 0.0;
255            }
256            let w = check_winner(&current_bb);
257            if w != WinStatus::NoWin {
258                return match w {
259                    WinStatus::Player0Wins => 1.0,
260                    WinStatus::Player1Wins => -1.0,
261                    WinStatus::NoWin => unreachable!(),
262                };
263            }
264            let moves = generate_legal_moves(&current_bb);
265            if moves.is_empty() {
266                // No legal moves: the player who cannot move loses
267                return if current_player(&current_bb) == Some(0) {
268                    -1.0
269                } else {
270                    1.0
271                };
272            }
273            let mv = moves[self.rng.gen_range(0..moves.len())];
274            current_bb = apply_move(&current_bb, &mv);
275            depth += 1;
276        }
277    }
278
279    fn backpropagate(&mut self, path: &[usize], value: f64) {
280        for &node_id in path.iter().rev() {
281            let node = &mut self.nodes[node_id];
282            node.visit_count += 1;
283            if value > 0.0 {
284                node.win_count_p0 += 1;
285            } else if value < 0.0 {
286                node.win_count_p1 += 1;
287            }
288        }
289    }
290
291    fn best_move(&self, root_bb: &Bitboard) -> Option<(Move, f64)> {
292        let root = &self.nodes[0];
293        if root.children.is_empty() {
294            return None;
295        }
296
297        let mut best_visits = 0u32;
298        let mut best_child = root.children[0];
299        for &child_id in &root.children {
300            let child = &self.nodes[child_id];
301            if child.visit_count > best_visits {
302                best_visits = child.visit_count;
303                best_child = child_id;
304            }
305        }
306
307        let child = &self.nodes[best_child];
308        // Win probability from the perspective of the player who made the
309        // choice at the root (the root's mover), matching the UCB fix.
310        let mover = current_player(root_bb).unwrap_or(0);
311        let win_rate = if child.visit_count > 0 {
312            let wins = if mover == 0 {
313                child.win_count_p0 as f64
314            } else {
315                child.win_count_p1 as f64
316            };
317            wins / child.visit_count as f64
318        } else {
319            0.5
320        };
321
322        child.mv.map(|mv| (mv, win_rate))
323    }
324
325    pub fn iterations_performed(&self) -> u32 {
326        self.iterations_performed
327    }
328
329    pub fn nodes_created(&self) -> usize {
330        self.nodes.len()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::game::has_winning_line;
338
339    #[test]
340    fn mcts_returns_a_move() {
341        let mut engine = MCTSEngine::new(MCTSConfig {
342            max_iterations: 100,
343            seed: Some(42),
344            ..Default::default()
345        });
346        let result = engine.search(&Bitboard::EMPTY);
347        assert!(result.is_some());
348        let (mv, prob) = result.unwrap();
349        assert_eq!(mv.player, 0);
350        assert!(mv.shape < 4);
351        assert!(mv.position < 16);
352        assert!((0.0..=1.0).contains(&prob));
353    }
354
355    #[test]
356    fn mcts_finds_winning_move() {
357        let bb = Bitboard::EMPTY
358            .with_move(0, 0, 0)
359            .with_move(1, 1, 5)
360            .with_move(0, 2, 2);
361        let mut engine = MCTSEngine::new(MCTSConfig {
362            max_iterations: 500,
363            seed: Some(123),
364            ..Default::default()
365        });
366        let result = engine.search(&bb);
367        assert!(result.is_some());
368    }
369
370    #[test]
371    fn mcts_no_moves_returns_none() {
372        // A terminal (won) position: row 0 complete
373        let bb = Bitboard::EMPTY
374            .with_move(0, 0, 0)
375            .with_move(1, 1, 1)
376            .with_move(0, 2, 2)
377            .with_move(1, 3, 3);
378        let mut engine = MCTSEngine::new(MCTSConfig {
379            max_iterations: 10,
380            seed: Some(1),
381            ..Default::default()
382        });
383        // The root is detected terminal, so it is never expanded: no
384        // children exist and no best move can be reported.
385        assert!(engine.search(&bb).is_none());
386    }
387
388    /// Regression for the UCB perspective bug: player 1 to move with an
389    /// immediate winning reply must select it. With the old p0-perspective
390    /// selection, p1's winning move was systematically starved.
391    #[test]
392    fn mcts_picks_immediate_win_for_player_1() {
393        // A@0, b@1, C@2: p1 to move, d@3 completes row 0 and wins for p1.
394        let bb = Bitboard::EMPTY
395            .with_move(0, 0, 0)
396            .with_move(1, 1, 1)
397            .with_move(0, 2, 2);
398        let mut engine = MCTSEngine::new(MCTSConfig {
399            max_iterations: 3_000,
400            seed: Some(7),
401            ..Default::default()
402        });
403        let (mv, prob) = engine.search(&bb).unwrap();
404        let after = apply_move(&bb, &mv);
405        assert!(
406            has_winning_line(&after),
407            "p1 must play the immediate win, got {mv:?} (prob {prob})"
408        );
409        assert!(prob > 0.5, "win probability is for the root mover");
410    }
411
412    #[test]
413    fn time_limit_stops_early() {
414        let mut engine = MCTSEngine::new(MCTSConfig {
415            max_iterations: u32::MAX,
416            seed: Some(3),
417            time_limit_s: Some(0.05),
418            ..Default::default()
419        });
420        let start = Instant::now();
421        let result = engine.search(&Bitboard::EMPTY);
422        assert!(result.is_some());
423        assert!(start.elapsed().as_secs_f64() < 1.0);
424        assert!(engine.iterations_performed() < u32::MAX);
425        assert!(engine.iterations_performed() > 0);
426    }
427
428    #[test]
429    fn same_seed_same_move() {
430        let bb = Bitboard::EMPTY.with_move(0, 0, 0);
431        let run = |seed| {
432            let mut engine = MCTSEngine::new(MCTSConfig {
433                max_iterations: 300,
434                seed: Some(seed),
435                ..Default::default()
436            });
437            engine.search(&bb).unwrap().0
438        };
439        assert_eq!(run(11), run(11));
440    }
441
442    #[test]
443    fn transposition_table_reduces_nodes() {
444        let run = |use_tt| {
445            let mut engine = MCTSEngine::new(MCTSConfig {
446                max_iterations: 2_000,
447                seed: Some(5),
448                use_transposition_table: use_tt,
449                ..Default::default()
450            });
451            engine.search(&Bitboard::EMPTY).unwrap();
452            engine.nodes_created()
453        };
454        assert!(run(true) < run(false));
455    }
456
457    #[test]
458    #[should_panic(expected = "time_limit_s must be positive")]
459    fn invalid_time_limit_panics() {
460        MCTSEngine::new(MCTSConfig {
461            time_limit_s: Some(0.0),
462            ..Default::default()
463        });
464    }
465}