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 pub time_limit_s: Option<f64>,
18 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>, 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, }
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 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 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 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 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 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(¤t_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(¤t_bb);
300 if moves.is_empty() {
301 return if current_player(¤t_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(¤t_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 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 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 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 let total_visits: u32 = visits.iter().map(|(_, v)| v).sum();
427 assert_eq!(total_visits, 2000);
428
429 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 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() });
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 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 assert!(engine.search(&bb).is_none());
521 }
522
523 #[test]
527 fn mcts_picks_immediate_win_for_player_1() {
528 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}