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    /// Visit-count distribution over the root's legal moves from the most
137    /// recent `search()` call — the raw material for an AlphaZero-style
138    /// soft policy target (`visits / total_visits` per move), as opposed
139    /// to the single argmax move `search()` returns. Empty if `search()`
140    /// returned `None` (no legal moves) or hasn't been called yet.
141    ///
142    /// **With `use_transposition_table` enabled (the default), this is NOT
143    /// one entry per legal move.** Root moves that canonicalize to the same
144    /// child state are merged onto one shared node, reported under a single
145    /// arbitrary "first discovered" move — every other legal move that led
146    /// there is silently absent, not just uncounted. This is worst exactly
147    /// where self-play data collection needs it most: the empty board's 64
148    /// legal first moves collapse to 3 entries (see
149    /// `root_move_visits_default_config_collapses_symmetric_root_moves`
150    /// below, and `docs/benchmarks/quantik-game-tree-census-2026-07-13.md`
151    /// for how orbit size — and therefore collapse severity — shrinks with
152    /// depth but is large at the shallow plies every game starts from). For
153    /// a faithful per-legal-move policy target, run `search()` with
154    /// `use_transposition_table: false`.
155    pub fn root_move_visits(&self) -> Vec<(Move, u32)> {
156        let Some(root) = self.nodes.first() else {
157            return Vec::new();
158        };
159        root.children
160            .iter()
161            .map(|&child_idx| {
162                let child = &self.nodes[child_idx];
163                (
164                    child.mv.expect("child node always has a move"),
165                    child.visit_count,
166                )
167            })
168            .collect()
169    }
170
171    /// Descend by UCB1 from `node_id`, returning the visited path
172    /// (root..=leaf). Backpropagation follows this exact path — with
173    /// transposition merging a node can have several parents, so parent
174    /// pointers would be ambiguous.
175    fn select(&self, node_id: usize) -> Vec<usize> {
176        let mut path = vec![node_id];
177        let mut current = node_id;
178        loop {
179            let node = &self.nodes[current];
180            if node.is_terminal || !node.untried_moves.is_empty() || node.children.is_empty() {
181                return path;
182            }
183            let parent_visits = node.visit_count as f64;
184            let c = self.config.exploration_weight;
185            // The win rate must be from the perspective of the player
186            // choosing among this node's children — the side to move at
187            // THIS node — not player 0. Using p0's count unconditionally
188            // systematically preferred moves that were worse for the
189            // player actually choosing.
190            let mover = current_player(&node.bb).unwrap_or(0);
191
192            let mut best_ucb = f64::NEG_INFINITY;
193            let mut best_child = node.children[0];
194            for &child_id in &node.children {
195                let child = &self.nodes[child_id];
196                if child.visit_count == 0 {
197                    best_child = child_id;
198                    break;
199                }
200                let child_visits = child.visit_count as f64;
201                let wins = if mover == 0 {
202                    child.win_count_p0 as f64
203                } else {
204                    child.win_count_p1 as f64
205                };
206                let win_rate = wins / child_visits;
207                let ucb = win_rate + c * (parent_visits.ln() / child_visits).sqrt();
208                if ucb > best_ucb {
209                    best_ucb = ucb;
210                    best_child = child_id;
211                }
212            }
213            path.push(best_child);
214            current = best_child;
215        }
216    }
217
218    fn expand(&mut self, node_id: usize) -> usize {
219        if self.nodes[node_id].is_terminal || self.nodes[node_id].untried_moves.is_empty() {
220            return node_id;
221        }
222
223        let idx = self
224            .rng
225            .gen_range(0..self.nodes[node_id].untried_moves.len());
226        let mv = self.nodes[node_id].untried_moves.swap_remove(idx);
227        let parent_bb = self.nodes[node_id].bb;
228        let new_bb = apply_move(&parent_bb, &mv);
229
230        if self.config.use_transposition_table {
231            let key = State::new(new_bb).canonical_key();
232            if let Some(&existing) = self.transpositions.get(&key) {
233                if !self.nodes[node_id].children.contains(&existing) {
234                    self.nodes[node_id].children.push(existing);
235                }
236                return existing;
237            }
238        }
239
240        let legal = generate_legal_moves(&new_bb);
241        let terminal = check_winner(&new_bb);
242        let is_terminal = terminal != WinStatus::NoWin || legal.is_empty();
243        let terminal_value = match terminal {
244            WinStatus::Player0Wins => 1.0,
245            WinStatus::Player1Wins => -1.0,
246            WinStatus::NoWin if legal.is_empty() => {
247                // No legal moves: the player who cannot move loses
248                if current_player(&new_bb) == Some(0) {
249                    -1.0
250                } else {
251                    1.0
252                }
253            }
254            WinStatus::NoWin => 0.0,
255        };
256
257        let child_id = self.nodes.len();
258        self.nodes.push(MCTSNode {
259            bb: new_bb,
260            children: Vec::new(),
261            mv: Some(mv),
262            visit_count: 0,
263            win_count_p0: 0,
264            win_count_p1: 0,
265            untried_moves: legal,
266            is_terminal,
267            terminal_value,
268        });
269        if self.config.use_transposition_table {
270            self.transpositions
271                .insert(State::new(new_bb).canonical_key(), child_id);
272        }
273
274        self.nodes[node_id].children.push(child_id);
275        child_id
276    }
277
278    fn simulate(&mut self, node_id: usize) -> f64 {
279        let node = &self.nodes[node_id];
280        if node.is_terminal {
281            return node.terminal_value;
282        }
283
284        let mut current_bb = node.bb;
285        let mut depth = 0u32;
286
287        loop {
288            if depth >= self.config.max_depth {
289                return 0.0;
290            }
291            let w = check_winner(&current_bb);
292            if w != WinStatus::NoWin {
293                return match w {
294                    WinStatus::Player0Wins => 1.0,
295                    WinStatus::Player1Wins => -1.0,
296                    WinStatus::NoWin => unreachable!(),
297                };
298            }
299            let moves = generate_legal_moves(&current_bb);
300            if moves.is_empty() {
301                // No legal moves: the player who cannot move loses
302                return if current_player(&current_bb) == Some(0) {
303                    -1.0
304                } else {
305                    1.0
306                };
307            }
308            let mv = moves[self.rng.gen_range(0..moves.len())];
309            current_bb = apply_move(&current_bb, &mv);
310            depth += 1;
311        }
312    }
313
314    fn backpropagate(&mut self, path: &[usize], value: f64) {
315        for &node_id in path.iter().rev() {
316            let node = &mut self.nodes[node_id];
317            node.visit_count += 1;
318            if value > 0.0 {
319                node.win_count_p0 += 1;
320            } else if value < 0.0 {
321                node.win_count_p1 += 1;
322            }
323        }
324    }
325
326    fn best_move(&self, root_bb: &Bitboard) -> Option<(Move, f64)> {
327        let root = &self.nodes[0];
328        if root.children.is_empty() {
329            return None;
330        }
331
332        let mut best_visits = 0u32;
333        let mut best_child = root.children[0];
334        for &child_id in &root.children {
335            let child = &self.nodes[child_id];
336            if child.visit_count > best_visits {
337                best_visits = child.visit_count;
338                best_child = child_id;
339            }
340        }
341
342        let child = &self.nodes[best_child];
343        // Win probability from the perspective of the player who made the
344        // choice at the root (the root's mover), matching the UCB fix.
345        let mover = current_player(root_bb).unwrap_or(0);
346        let win_rate = if child.visit_count > 0 {
347            let wins = if mover == 0 {
348                child.win_count_p0 as f64
349            } else {
350                child.win_count_p1 as f64
351            };
352            wins / child.visit_count as f64
353        } else {
354            0.5
355        };
356
357        child.mv.map(|mv| (mv, win_rate))
358    }
359
360    pub fn iterations_performed(&self) -> u32 {
361        self.iterations_performed
362    }
363
364    pub fn nodes_created(&self) -> usize {
365        self.nodes.len()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::game::has_winning_line;
373
374    #[test]
375    fn mcts_returns_a_move() {
376        let mut engine = MCTSEngine::new(MCTSConfig {
377            max_iterations: 100,
378            seed: Some(42),
379            ..Default::default()
380        });
381        let result = engine.search(&Bitboard::EMPTY);
382        assert!(result.is_some());
383        let (mv, prob) = result.unwrap();
384        assert_eq!(mv.player, 0);
385        assert!(mv.shape < 4);
386        assert!(mv.position < 16);
387        assert!((0.0..=1.0).contains(&prob));
388    }
389
390    #[test]
391    fn root_move_visits_covers_every_legal_move_and_sums_to_iterations() {
392        let bb = Bitboard::EMPTY;
393        let legal = generate_legal_moves(&bb);
394
395        // Transposition merging is disabled for this test: on the empty
396        // board, `search()`'s default `use_transposition_table: true`
397        // canonicalizes away board/shape symmetry so aggressively that the
398        // 64 legal first moves collapse into just 3 canonical tree nodes
399        // (verified empirically), which would defeat the per-move
400        // accounting this test is checking. `root_move_visits` itself does
401        // not touch transposition behavior either way.
402        let mut engine = MCTSEngine::new(MCTSConfig {
403            max_iterations: 2000,
404            seed: Some(7),
405            use_transposition_table: false,
406            ..Default::default()
407        });
408        let (best_move, _win_prob) = engine.search(&bb).expect("legal moves exist");
409
410        let visits = engine.root_move_visits();
411
412        // Every legal root move was expanded at least once (2000 iterations
413        // against a 64-move branching factor is far more than one pass).
414        assert_eq!(visits.len(), legal.len());
415        let visited_moves: std::collections::HashSet<Move> =
416            visits.iter().map(|(mv, _)| *mv).collect();
417        for mv in &legal {
418            assert!(
419                visited_moves.contains(mv),
420                "missing {mv:?} from root_move_visits"
421            );
422        }
423
424        // Visit counts sum to the iterations actually performed (root gets
425        // one visit per iteration via the selection pass starting there).
426        let total_visits: u32 = visits.iter().map(|(_, v)| v).sum();
427        assert_eq!(total_visits, 2000);
428
429        // The move search() actually returned must be among the visited
430        // moves, and must have the maximum visit count (search() picks by
431        // visit count, not raw value).
432        let best_visits = visits
433            .iter()
434            .find(|(mv, _)| *mv == best_move)
435            .map(|(_, v)| *v)
436            .unwrap();
437        assert!(visits.iter().all(|(_, v)| *v <= best_visits));
438    }
439
440    #[test]
441    fn root_move_visits_empty_before_search() {
442        let engine = MCTSEngine::new(MCTSConfig::default());
443        assert!(engine.root_move_visits().is_empty());
444    }
445
446    #[test]
447    fn root_move_visits_default_config_collapses_symmetric_root_moves() {
448        // Documents, deliberately, the exact limitation described in
449        // `root_move_visits`'s doc comment: under the engine's actual
450        // default (`use_transposition_table: true`, i.e. `..Default::
451        // default()` with no override — what every real caller in this
452        // crate uses), the empty board's 64 legal first moves canonicalize
453        // onto just 3 shared tree nodes (matches the independently
454        // cross-validated depth-1 canonical count in
455        // docs/benchmarks/quantik-game-tree-census-2026-07-13.md: "3
456        // canonical states, 64 raw boards"). A caller building a per-move
457        // policy target from this output without disabling the
458        // transposition table would silently drop 61 of 64 legal moves and
459        // mislabel the rest — this test exists so that fact is asserted
460        // and visible, not discovered later against real training data.
461        let bb = Bitboard::EMPTY;
462        let legal = generate_legal_moves(&bb);
463        assert_eq!(legal.len(), 64);
464
465        let mut engine = MCTSEngine::new(MCTSConfig {
466            max_iterations: 2000,
467            seed: Some(7),
468            ..Default::default() // use_transposition_table: true, the real default
469        });
470        engine.search(&bb).expect("legal moves exist");
471
472        let visits = engine.root_move_visits();
473        assert_eq!(
474            visits.len(),
475            3,
476            "expected the empty board's legal moves to collapse to 3 canonical \
477             nodes under the default (transposition-table-enabled) config; if \
478             this changes, root_move_visits's doc comment and Task 6 of \
479             docs/superpowers/plans/2026-07-13-crates-io-packaging-and-ml-data-pipeline.md \
480             need to be re-checked, not just this assertion"
481        );
482
483        let total_visits: u32 = visits.iter().map(|(_, v)| v).sum();
484        assert_eq!(
485            total_visits, 2000,
486            "visit mass is preserved even though move identity is not"
487        );
488    }
489
490    #[test]
491    fn mcts_finds_winning_move() {
492        let bb = Bitboard::EMPTY
493            .with_move(0, 0, 0)
494            .with_move(1, 1, 5)
495            .with_move(0, 2, 2);
496        let mut engine = MCTSEngine::new(MCTSConfig {
497            max_iterations: 500,
498            seed: Some(123),
499            ..Default::default()
500        });
501        let result = engine.search(&bb);
502        assert!(result.is_some());
503    }
504
505    #[test]
506    fn mcts_no_moves_returns_none() {
507        // A terminal (won) position: row 0 complete
508        let bb = Bitboard::EMPTY
509            .with_move(0, 0, 0)
510            .with_move(1, 1, 1)
511            .with_move(0, 2, 2)
512            .with_move(1, 3, 3);
513        let mut engine = MCTSEngine::new(MCTSConfig {
514            max_iterations: 10,
515            seed: Some(1),
516            ..Default::default()
517        });
518        // The root is detected terminal, so it is never expanded: no
519        // children exist and no best move can be reported.
520        assert!(engine.search(&bb).is_none());
521    }
522
523    /// Regression for the UCB perspective bug: player 1 to move with an
524    /// immediate winning reply must select it. With the old p0-perspective
525    /// selection, p1's winning move was systematically starved.
526    #[test]
527    fn mcts_picks_immediate_win_for_player_1() {
528        // A@0, b@1, C@2: p1 to move, d@3 completes row 0 and wins for p1.
529        let bb = Bitboard::EMPTY
530            .with_move(0, 0, 0)
531            .with_move(1, 1, 1)
532            .with_move(0, 2, 2);
533        let mut engine = MCTSEngine::new(MCTSConfig {
534            max_iterations: 3_000,
535            seed: Some(7),
536            ..Default::default()
537        });
538        let (mv, prob) = engine.search(&bb).unwrap();
539        let after = apply_move(&bb, &mv);
540        assert!(
541            has_winning_line(&after),
542            "p1 must play the immediate win, got {mv:?} (prob {prob})"
543        );
544        assert!(prob > 0.5, "win probability is for the root mover");
545    }
546
547    #[test]
548    fn time_limit_stops_early() {
549        let mut engine = MCTSEngine::new(MCTSConfig {
550            max_iterations: u32::MAX,
551            seed: Some(3),
552            time_limit_s: Some(0.05),
553            ..Default::default()
554        });
555        let start = Instant::now();
556        let result = engine.search(&Bitboard::EMPTY);
557        assert!(result.is_some());
558        assert!(start.elapsed().as_secs_f64() < 1.0);
559        assert!(engine.iterations_performed() < u32::MAX);
560        assert!(engine.iterations_performed() > 0);
561    }
562
563    #[test]
564    fn same_seed_same_move() {
565        let bb = Bitboard::EMPTY.with_move(0, 0, 0);
566        let run = |seed| {
567            let mut engine = MCTSEngine::new(MCTSConfig {
568                max_iterations: 300,
569                seed: Some(seed),
570                ..Default::default()
571            });
572            engine.search(&bb).unwrap().0
573        };
574        assert_eq!(run(11), run(11));
575    }
576
577    #[test]
578    fn transposition_table_reduces_nodes() {
579        let run = |use_tt| {
580            let mut engine = MCTSEngine::new(MCTSConfig {
581                max_iterations: 2_000,
582                seed: Some(5),
583                use_transposition_table: use_tt,
584                ..Default::default()
585            });
586            engine.search(&Bitboard::EMPTY).unwrap();
587            engine.nodes_created()
588        };
589        assert!(run(true) < run(false));
590    }
591
592    #[test]
593    #[should_panic(expected = "time_limit_s must be positive")]
594    fn invalid_time_limit_panics() {
595        MCTSEngine::new(MCTSConfig {
596            time_limit_s: Some(0.0),
597            ..Default::default()
598        });
599    }
600}