1use crate::bitboard::Bitboard;
14use crate::game::{check_winner, current_player, WinStatus};
15use crate::moves::{apply_move, generate_legal_moves, Move};
16use crate::symmetry::SymmetryHandler;
17use rand::prelude::*;
18use std::collections::HashMap;
19use std::time::Instant;
20
21pub type Evaluator = Box<dyn Fn(&Bitboard) -> f64>;
24
25pub const UNIQUE_CANONICAL_STATES_PER_DEPTH: [(u32, u64); 8] = [
30 (1, 3),
31 (2, 51),
32 (3, 726),
33 (4, 10_946),
34 (5, 105_632),
35 (6, 901_916),
36 (7, 4_658_465),
37 (8, 17_900_160),
38];
39
40#[derive(Clone, Debug)]
42pub struct BeamSearchConfig {
43 pub beam_width: usize,
45 pub max_depth: u32,
47 pub rollouts_per_candidate: u32,
49 pub random_seed: Option<u64>,
50 pub beam_schedule: Option<Vec<usize>>,
54 pub rollout_schedule: Option<Vec<u32>>,
58 pub time_limit_s: Option<f64>,
63}
64
65impl Default for BeamSearchConfig {
66 fn default() -> Self {
67 Self {
68 beam_width: 64,
69 max_depth: 16,
70 rollouts_per_candidate: 8,
71 random_seed: None,
72 beam_schedule: None,
73 rollout_schedule: None,
74 time_limit_s: None,
75 }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq)]
81pub struct BeamLeaf {
82 pub moves: Vec<Move>,
84 pub value: f64,
86 pub depth: u32,
87 pub is_terminal: bool,
88 pub multiplicity: u64,
93}
94
95#[derive(Clone, Debug, PartialEq)]
102pub struct RankedRootMove {
103 pub mv: Move,
104 pub best_value: f64,
106 pub mean_value: f64,
108 pub win_probability: f64,
110 pub leaf_count: usize,
112 pub total_multiplicity: u64,
114 pub has_terminal_win: bool,
116}
117
118#[derive(Clone, Debug, Default, PartialEq)]
120pub struct BeamStats {
121 pub candidates_generated: u64,
122 pub candidates_deduped: u64,
123 pub nodes_inserted: u64,
124 pub nodes_pruned: u64,
125 pub evaluations: u64,
126 pub rollouts: u64,
127}
128
129#[derive(Clone, Debug, PartialEq)]
131pub struct BeamSearchResult {
132 pub best_leaf: Option<BeamLeaf>,
134 pub terminal_leaves: Vec<BeamLeaf>,
136 pub reached_terminal: bool,
137 pub max_depth_reached: u32,
138 pub stats: BeamStats,
139 pub root_player: u8,
141 pub frontier_leaves: Vec<BeamLeaf>,
144}
145
146impl BeamSearchResult {
147 pub fn ranked_root_moves(&self, top_k: Option<usize>) -> Vec<RankedRootMove> {
154 let mut order: Vec<Move> = Vec::new();
155 let mut groups: HashMap<(u8, u8, u8), Vec<&BeamLeaf>> = HashMap::new();
156
157 for leaf in self
158 .terminal_leaves
159 .iter()
160 .chain(self.frontier_leaves.iter())
161 {
162 let Some(first) = leaf.moves.first() else {
163 continue;
164 };
165 let key = (first.player, first.shape, first.position);
166 let entry = groups.entry(key).or_default();
167 if entry.is_empty() {
168 order.push(*first);
169 }
170 entry.push(leaf);
171 }
172
173 let root_perspective = |leaf: &BeamLeaf| -> f64 {
174 if self.root_player == 0 {
175 leaf.value
176 } else {
177 -leaf.value
178 }
179 };
180
181 let mut ranked: Vec<RankedRootMove> = order
182 .into_iter()
183 .map(|mv| {
184 let leaves = &groups[&(mv.player, mv.shape, mv.position)];
185 let total_multiplicity: u64 = leaves.iter().map(|l| l.multiplicity).sum();
186 let best_value = leaves
187 .iter()
188 .map(|l| root_perspective(l))
189 .fold(f64::NEG_INFINITY, f64::max);
190 let mean_value = leaves
191 .iter()
192 .map(|l| root_perspective(l) * l.multiplicity as f64)
193 .sum::<f64>()
194 / total_multiplicity as f64;
195 let has_terminal_win = leaves
196 .iter()
197 .any(|l| l.is_terminal && root_perspective(l) == 1.0);
198 RankedRootMove {
199 mv,
200 best_value,
201 mean_value,
202 win_probability: (mean_value + 1.0) / 2.0,
203 leaf_count: leaves.len(),
204 total_multiplicity,
205 has_terminal_win,
206 }
207 })
208 .collect();
209
210 ranked.sort_by(|a, b| {
211 b.best_value
212 .total_cmp(&a.best_value)
213 .then(b.mean_value.total_cmp(&a.mean_value))
214 .then(b.leaf_count.cmp(&a.leaf_count))
215 .then((a.mv.player, a.mv.shape, a.mv.position).cmp(&(
216 b.mv.player,
217 b.mv.shape,
218 b.mv.position,
219 )))
220 });
221
222 if let Some(k) = top_k {
223 ranked.truncate(k);
224 }
225 ranked
226 }
227}
228
229struct FrontierEntry {
233 bb: Bitboard,
234 moves: Vec<Move>,
235 multiplicity: u64,
236}
237
238struct Candidate {
242 bb: Bitboard,
243 moves: Vec<Move>,
244 mover: u8,
245 multiplicity: u64,
246}
247
248pub struct BeamSearchEngine {
250 pub config: BeamSearchConfig,
251 rng: StdRng,
252 evaluator: Option<Evaluator>,
253}
254
255impl BeamSearchEngine {
256 pub fn new(config: BeamSearchConfig) -> Result<Self, String> {
258 if config.beam_width < 1 {
259 return Err("beam_width must be >= 1".into());
260 }
261 if !(1..=16).contains(&config.max_depth) {
262 return Err("max_depth must be between 1 and 16".into());
263 }
264 if config.rollouts_per_candidate < 1 {
265 return Err("rollouts_per_candidate must be >= 1".into());
266 }
267 if let Some(schedule) = &config.beam_schedule {
268 if schedule.is_empty() {
269 return Err("beam_schedule must not be empty".into());
270 }
271 if schedule.iter().any(|&w| w < 1) {
272 return Err("beam_schedule entries must all be >= 1".into());
273 }
274 }
275 if let Some(schedule) = &config.rollout_schedule {
276 if schedule.is_empty() {
277 return Err("rollout_schedule must not be empty".into());
278 }
279 if schedule.iter().any(|&c| c < 1) {
280 return Err("rollout_schedule entries must all be >= 1".into());
281 }
282 }
283 if let Some(limit) = config.time_limit_s {
284 if limit <= 0.0 || !limit.is_finite() {
285 return Err(format!(
286 "time_limit_s must be positive and finite, got {limit}"
287 ));
288 }
289 }
290 let rng = match config.random_seed {
291 Some(s) => StdRng::seed_from_u64(s),
292 None => StdRng::from_entropy(),
293 };
294 Ok(Self {
295 config,
296 rng,
297 evaluator: None,
298 })
299 }
300
301 pub fn with_evaluator(mut self, evaluator: Evaluator) -> Self {
303 self.evaluator = Some(evaluator);
304 self
305 }
306
307 pub fn search(&mut self, root: &Bitboard) -> Result<BeamSearchResult, String> {
309 if check_winner(root) != WinStatus::NoWin {
310 return Err("Cannot search from an already-terminal root state.".into());
311 }
312 let root_player =
313 current_player(root).ok_or_else(|| "Inconsistent root piece counts.".to_string())?;
314 if generate_legal_moves(root).is_empty() {
315 return Err("Cannot search from a root state with no legal moves.".into());
316 }
317
318 let mut stats = BeamStats::default();
319 let mut terminal_leaves: Vec<BeamLeaf> = Vec::new();
320 let mut frontier: Vec<FrontierEntry> = vec![FrontierEntry {
321 bb: *root,
322 moves: Vec::new(),
323 multiplicity: 1,
324 }];
325 let mut frontier_values: Vec<f64> = vec![0.0];
328 let mut max_depth_reached = 0u32;
329
330 let deadline = self
331 .config
332 .time_limit_s
333 .map(|s| Instant::now() + std::time::Duration::from_secs_f64(s));
334
335 for depth in 1..=self.config.max_depth {
336 if frontier.is_empty() {
337 break;
338 }
339 if depth > 1 {
340 if let Some(deadline) = deadline {
341 if Instant::now() >= deadline {
342 break;
343 }
344 }
345 }
346
347 let candidates =
348 self.expand_frontier(&frontier, depth, &mut stats, &mut terminal_leaves);
349 let beam_width = self.beam_width_for_depth(depth);
350 let rollouts = self.rollouts_for_depth(depth);
351 let (next_frontier, next_values) =
352 self.score_and_prune(candidates, &mut stats, beam_width, rollouts);
353 frontier = next_frontier;
354 frontier_values = next_values;
355 max_depth_reached = depth;
356 }
357
358 let root_perspective = |leaf: &BeamLeaf| -> f64 {
359 if root_player == 0 {
360 leaf.value
361 } else {
362 -leaf.value
363 }
364 };
365
366 let frontier_leaves: Vec<BeamLeaf> = frontier
367 .into_iter()
368 .zip(frontier_values)
369 .map(|(entry, value)| BeamLeaf {
370 moves: entry.moves,
371 value,
372 depth: max_depth_reached,
373 is_terminal: false,
374 multiplicity: entry.multiplicity,
375 })
376 .collect();
377
378 let reached_terminal = frontier_leaves.is_empty();
379 let best_leaf = terminal_leaves
380 .iter()
381 .chain(frontier_leaves.iter())
382 .max_by(|a, b| root_perspective(a).total_cmp(&root_perspective(b)))
383 .cloned();
384 terminal_leaves.sort_by(|a, b| root_perspective(b).total_cmp(&root_perspective(a)));
385
386 Ok(BeamSearchResult {
387 best_leaf,
388 terminal_leaves,
389 reached_terminal,
390 max_depth_reached,
391 stats,
392 root_player,
393 frontier_leaves,
394 })
395 }
396
397 fn beam_width_for_depth(&self, depth: u32) -> usize {
399 match &self.config.beam_schedule {
400 None => self.config.beam_width,
401 Some(schedule) => {
402 let index = (depth as usize - 1).min(schedule.len() - 1);
403 schedule[index]
404 }
405 }
406 }
407
408 fn rollouts_for_depth(&self, depth: u32) -> u32 {
410 match &self.config.rollout_schedule {
411 None => self.config.rollouts_per_candidate,
412 Some(schedule) => {
413 let index = (depth as usize - 1).min(schedule.len() - 1);
414 schedule[index]
415 }
416 }
417 }
418
419 fn expand_frontier(
427 &mut self,
428 frontier: &[FrontierEntry],
429 depth: u32,
430 stats: &mut BeamStats,
431 terminal_leaves: &mut Vec<BeamLeaf>,
432 ) -> (Vec<Candidate>, HashMap<[u8; 16], usize>) {
433 let mut candidates: Vec<Candidate> = Vec::new();
434 let mut index_by_key: HashMap<[u8; 16], usize> = HashMap::new();
435
436 for entry in frontier {
437 let all_moves = generate_legal_moves(&entry.bb);
438 let mover = current_player(&entry.bb).unwrap_or(0);
439
440 if all_moves.is_empty() {
441 let value = if mover == 1 { 1.0 } else { -1.0 };
443 terminal_leaves.push(BeamLeaf {
444 moves: entry.moves.clone(),
445 value,
446 depth: depth - 1,
447 is_terminal: true,
448 multiplicity: entry.multiplicity,
449 });
450 stats.nodes_inserted += 1;
451 continue;
452 }
453
454 stats.candidates_generated += all_moves.len() as u64;
455
456 for mv in all_moves {
457 let new_bb = apply_move(&entry.bb, &mv);
458 let winner = check_winner(&new_bb);
459
460 if winner != WinStatus::NoWin {
461 let value = if winner == WinStatus::Player0Wins {
462 1.0
463 } else {
464 -1.0
465 };
466 let mut child_moves = entry.moves.clone();
467 child_moves.push(mv);
468 terminal_leaves.push(BeamLeaf {
469 moves: child_moves,
470 value,
471 depth,
472 is_terminal: true,
473 multiplicity: entry.multiplicity,
474 });
475 stats.nodes_inserted += 1;
476 continue;
477 }
478
479 let key = SymmetryHandler::canonical_payload(&new_bb);
480 if let Some(&existing) = index_by_key.get(&key) {
481 stats.candidates_deduped += 1;
482 candidates[existing].multiplicity += entry.multiplicity;
483 continue;
484 }
485 let mut child_moves = entry.moves.clone();
486 child_moves.push(mv);
487 index_by_key.insert(key, candidates.len());
488 candidates.push(Candidate {
489 bb: new_bb,
490 moves: child_moves,
491 mover: mv.player,
492 multiplicity: entry.multiplicity,
493 });
494 }
495 }
496
497 (candidates, index_by_key)
498 }
499
500 fn score_and_prune(
504 &mut self,
505 (candidates, _index): (Vec<Candidate>, HashMap<[u8; 16], usize>),
506 stats: &mut BeamStats,
507 beam_width: usize,
508 rollouts: u32,
509 ) -> (Vec<FrontierEntry>, Vec<f64>) {
510 let mut scored: Vec<(f64, usize, f64)> = Vec::with_capacity(candidates.len());
512 for (index, candidate) in candidates.iter().enumerate() {
513 let raw_value = self.evaluate(&candidate.bb, rollouts, stats);
514 stats.evaluations += 1;
515 let score = if candidate.mover == 0 {
516 raw_value
517 } else {
518 -raw_value
519 };
520 scored.push((score, index, raw_value));
521 }
522
523 scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
524 let kept = scored.len().min(beam_width);
525 stats.nodes_pruned += (scored.len() - kept) as u64;
526 scored.truncate(kept);
527
528 let mut slots: Vec<Option<Candidate>> = candidates.into_iter().map(Some).collect();
531 let mut next_frontier = Vec::with_capacity(kept);
532 let mut next_values = Vec::with_capacity(kept);
533 for (_, index, raw_value) in scored {
534 let candidate = slots[index].take().expect("survivor extracted once");
535 stats.nodes_inserted += 1;
536 next_frontier.push(FrontierEntry {
537 bb: candidate.bb,
538 moves: candidate.moves,
539 multiplicity: candidate.multiplicity,
540 });
541 next_values.push(raw_value);
542 }
543 (next_frontier, next_values)
544 }
545
546 fn evaluate(&mut self, bb: &Bitboard, rollouts: u32, stats: &mut BeamStats) -> f64 {
552 let raw = match &self.evaluator {
553 Some(evaluator) => evaluator(bb),
554 None => {
555 let mut total = 0.0;
556 for _ in 0..rollouts {
557 total += self.rollout(bb);
558 }
559 stats.rollouts += rollouts as u64;
560 total / rollouts as f64
561 }
562 };
563 raw.clamp(-1.0, 1.0)
564 }
565
566 fn rollout(&mut self, bb: &Bitboard) -> f64 {
571 let mut current = *bb;
572 loop {
573 let winner = check_winner(¤t);
574 if winner != WinStatus::NoWin {
575 return if winner == WinStatus::Player0Wins {
576 1.0
577 } else {
578 -1.0
579 };
580 }
581 let moves = generate_legal_moves(¤t);
582 if moves.is_empty() {
583 return if current_player(¤t) == Some(0) {
584 -1.0
585 } else {
586 1.0
587 };
588 }
589 let mv = moves[self.rng.gen_range(0..moves.len())];
590 current = apply_move(¤t, &mv);
591 }
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::game::has_winning_line;
599 use std::cell::Cell;
600 use std::rc::Rc;
601
602 fn engine(config: BeamSearchConfig) -> BeamSearchEngine {
603 BeamSearchEngine::new(config).unwrap()
604 }
605
606 fn immediate_win_board() -> Bitboard {
608 Bitboard::EMPTY
609 .with_move(0, 0, 0)
610 .with_move(1, 1, 1)
611 .with_move(0, 2, 2)
612 }
613
614 #[test]
615 fn invalid_configs_are_rejected() {
616 for config in [
617 BeamSearchConfig {
618 beam_width: 0,
619 ..Default::default()
620 },
621 BeamSearchConfig {
622 max_depth: 0,
623 ..Default::default()
624 },
625 BeamSearchConfig {
626 max_depth: 17,
627 ..Default::default()
628 },
629 BeamSearchConfig {
630 rollouts_per_candidate: 0,
631 ..Default::default()
632 },
633 BeamSearchConfig {
634 beam_schedule: Some(vec![]),
635 ..Default::default()
636 },
637 BeamSearchConfig {
638 beam_schedule: Some(vec![3, 0]),
639 ..Default::default()
640 },
641 BeamSearchConfig {
642 rollout_schedule: Some(vec![]),
643 ..Default::default()
644 },
645 BeamSearchConfig {
646 rollout_schedule: Some(vec![0]),
647 ..Default::default()
648 },
649 BeamSearchConfig {
650 time_limit_s: Some(0.0),
651 ..Default::default()
652 },
653 BeamSearchConfig {
654 time_limit_s: Some(f64::INFINITY),
655 ..Default::default()
656 },
657 ] {
658 assert!(BeamSearchEngine::new(config).is_err());
659 }
660 }
661
662 #[test]
663 fn immediate_win_found_and_ranked_first() {
664 let bb = immediate_win_board();
665 let mut engine = engine(BeamSearchConfig {
666 beam_width: 8,
667 max_depth: 2,
668 rollouts_per_candidate: 1,
669 random_seed: Some(1),
670 ..Default::default()
671 });
672 let result = engine.search(&bb).unwrap();
673 assert_eq!(result.root_player, 1);
674
675 let best = result.best_leaf.as_ref().unwrap();
676 assert!(best.is_terminal);
677 assert_eq!(best.depth, 1);
678 assert_eq!(best.value, -1.0, "P1 win is -1.0 in P0 perspective");
679 let winning_move = best.moves[0];
680 assert!(has_winning_line(&apply_move(&bb, &winning_move)));
681
682 let ranked = result.ranked_root_moves(None);
683 assert!(ranked[0].has_terminal_win);
684 assert_eq!(ranked[0].mv, winning_move);
685 assert_eq!(ranked[0].best_value, 1.0, "root-player perspective");
686 }
687
688 #[test]
689 fn full_game_reachability_and_replayable_pvs() {
690 let mut engine = engine(BeamSearchConfig {
691 beam_width: 4,
692 max_depth: 16,
693 rollouts_per_candidate: 1,
694 random_seed: Some(42),
695 ..Default::default()
696 });
697 let result = engine.search(&Bitboard::EMPTY).unwrap();
698 assert!(result.reached_terminal);
699 assert!(result.frontier_leaves.is_empty());
700 assert!(!result.terminal_leaves.is_empty());
701
702 for leaf in &result.terminal_leaves {
703 assert!(leaf.is_terminal);
704 assert!(leaf.value == 1.0 || leaf.value == -1.0);
705 let mut bb = Bitboard::EMPTY;
707 for mv in &leaf.moves {
708 assert!(generate_legal_moves(&bb).contains(mv));
709 bb = apply_move(&bb, mv);
710 }
711 assert!(has_winning_line(&bb) || generate_legal_moves(&bb).is_empty());
712 }
713 }
714
715 #[test]
716 fn symmetry_dedup_at_depth_one() {
717 let mut engine = engine(BeamSearchConfig {
718 beam_width: 64,
719 max_depth: 1,
720 rollouts_per_candidate: 1,
721 random_seed: Some(0),
722 ..Default::default()
723 });
724 let result = engine.search(&Bitboard::EMPTY).unwrap();
725 assert_eq!(result.stats.candidates_generated, 64);
726 assert_eq!(result.stats.candidates_deduped, 61);
728 assert_eq!(result.frontier_leaves.len(), 3);
729 let total: u64 = result.frontier_leaves.iter().map(|l| l.multiplicity).sum();
731 assert_eq!(total, 64);
732 }
733
734 #[test]
735 fn beam_schedule_extends_last_entry() {
736 let mut engine = engine(BeamSearchConfig {
737 beam_schedule: Some(vec![3, 2]),
738 max_depth: 4,
739 rollouts_per_candidate: 1,
740 random_seed: Some(9),
741 ..Default::default()
742 });
743 let result = engine.search(&Bitboard::EMPTY).unwrap();
744 assert!(result.frontier_leaves.len() <= 2);
746 assert!(result.stats.nodes_pruned > 0);
747 }
748
749 #[test]
750 fn rollout_schedule_counts_exactly() {
751 let mut engine = engine(BeamSearchConfig {
752 beam_width: 2,
753 max_depth: 2,
754 rollout_schedule: Some(vec![1, 8]),
755 random_seed: Some(3),
756 ..Default::default()
757 });
758 let result = engine.search(&Bitboard::EMPTY).unwrap();
759 let depth1 = 3u64;
762 let depth2 = result.stats.evaluations - depth1;
763 assert_eq!(result.stats.rollouts, depth1 + depth2 * 8);
764 }
765
766 #[test]
767 fn determinism_same_seed() {
768 let run = || {
769 let mut engine = engine(BeamSearchConfig {
770 beam_width: 8,
771 max_depth: 6,
772 rollouts_per_candidate: 2,
773 random_seed: Some(77),
774 ..Default::default()
775 });
776 engine.search(&Bitboard::EMPTY).unwrap()
777 };
778 assert_eq!(run(), run());
779 }
780
781 #[test]
782 fn custom_evaluator_used_and_clamped() {
783 let calls = Rc::new(Cell::new(0u64));
784 let calls_in = calls.clone();
785 let mut engine = engine(BeamSearchConfig {
786 beam_width: 4,
787 max_depth: 2,
788 random_seed: Some(5),
789 ..Default::default()
790 })
791 .with_evaluator(Box::new(move |_bb| {
792 calls_in.set(calls_in.get() + 1);
793 5.0 }));
795 let result = engine.search(&Bitboard::EMPTY).unwrap();
796 assert!(calls.get() > 0);
797 assert_eq!(result.stats.rollouts, 0, "custom evaluator: no rollouts");
798 for leaf in &result.frontier_leaves {
799 assert_eq!(leaf.value, 1.0);
800 }
801 }
802
803 #[test]
804 fn root_player_one_perspective() {
805 let bb = immediate_win_board();
806 let mut engine = engine(BeamSearchConfig {
807 beam_width: 4,
808 max_depth: 2,
809 rollouts_per_candidate: 1,
810 random_seed: Some(2),
811 ..Default::default()
812 });
813 let result = engine.search(&bb).unwrap();
814 assert_eq!(result.root_player, 1);
815 let first = &result.terminal_leaves[0];
817 assert_eq!(first.value, -1.0);
818 }
819
820 #[test]
821 fn root_errors() {
822 let won = Bitboard::EMPTY
823 .with_move(0, 0, 0)
824 .with_move(1, 1, 1)
825 .with_move(0, 2, 2)
826 .with_move(1, 3, 3);
827 let mut e = engine(BeamSearchConfig::default());
828 assert!(e.search(&won).is_err());
829 }
830
831 #[test]
832 fn memory_bound_holds() {
833 let mut engine = engine(BeamSearchConfig {
834 beam_width: 2,
835 max_depth: 8,
836 rollouts_per_candidate: 1,
837 random_seed: Some(13),
838 ..Default::default()
839 });
840 let result = engine.search(&Bitboard::EMPTY).unwrap();
841 assert!(result.frontier_leaves.len() <= 2);
844 }
845
846 #[test]
847 fn time_limit_stops_between_levels() {
848 let mut engine = engine(BeamSearchConfig {
849 beam_width: 512,
850 max_depth: 16,
851 rollouts_per_candidate: 8,
852 random_seed: Some(21),
853 time_limit_s: Some(0.02),
854 ..Default::default()
855 });
856 let start = Instant::now();
857 let result = engine.search(&Bitboard::EMPTY).unwrap();
858 assert!(result.max_depth_reached >= 1);
860 assert!(start.elapsed().as_secs_f64() < 5.0);
861 }
862}