Skip to main content

quantik_core/
beam_search.rs

1//! Parametrizable beam search for Quantik.
2//!
3//! Descends level-by-level from a root state, keeping only the top
4//! `beam_width` non-terminal candidates per depth (breadth pruning) while
5//! always discovering and recording every true terminal state encountered,
6//! regardless of the beam width. Port of the Python
7//! `quantik_core.beam_search` module (schedules, multiplicity accounting,
8//! ranked root moves, wall-clock budget) — without the shared
9//! `CompactGameTree`: this engine is standalone, and `nodes_inserted`
10//! counts the terminal leaves recorded plus the survivors kept per level,
11//! the closest observable analogue of the Python tree-insertion counter.
12
13use 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
21/// Evaluates a position from player 0's perspective; values are clamped
22/// to `[-1, 1]` by the engine.
23pub type Evaluator = Box<dyn Fn(&Bitboard) -> f64>;
24
25/// Unique canonical states per depth (see `GAME_TREE_ANALYSIS.md` in the
26/// Python repository). Useful for building an exhaustive-prefix
27/// `beam_schedule` that keeps every legal line up to some depth before
28/// switching to guided sampling.
29pub 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/// Configuration for the beam search algorithm.
41#[derive(Clone, Debug)]
42pub struct BeamSearchConfig {
43    /// Frontier nodes kept per depth (>= 1).
44    pub beam_width: usize,
45    /// Plies from root; 16 = full Quantik game. Must be 1..=16.
46    pub max_depth: u32,
47    /// Rollout budget for the default evaluator (>= 1).
48    pub rollouts_per_candidate: u32,
49    pub random_seed: Option<u64>,
50    /// Depth-dependent beam width: width at depth d =
51    /// `beam_schedule[min(d-1, len-1)]`, so the last entry extends to all
52    /// deeper levels. `None` applies the flat `beam_width` everywhere.
53    pub beam_schedule: Option<Vec<usize>>,
54    /// Depth-dependent rollout budget for the BUILT-IN evaluator only — a
55    /// custom evaluator keeps its plain signature and ignores this.
56    /// Semantics mirror `beam_schedule`.
57    pub rollout_schedule: Option<Vec<u32>>,
58    /// Optional wall-clock budget for `search`, in seconds. Checked between
59    /// depth levels (after each completed level), so depth 1 always
60    /// completes and a wide level can overshoot the budget — callers that
61    /// need honest numbers should measure actual elapsed time themselves.
62    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/// A single collected leaf: a principal variation and its value.
80#[derive(Clone, Debug, PartialEq)]
81pub struct BeamLeaf {
82    /// Principal variation from the root.
83    pub moves: Vec<Move>,
84    /// P0 perspective; ±1.0 for terminal leaves.
85    pub value: f64,
86    pub depth: u32,
87    pub is_terminal: bool,
88    /// Number of raw (pre-canonicalization) move sequences this leaf stands
89    /// in for, accumulated by summing parent multiplicities across every
90    /// dedup hit on the path from the root. 1 for a leaf whose whole PV was
91    /// never merged with a symmetric sibling.
92    pub multiplicity: u64,
93}
94
95/// Aggregated beam-sampled statistics for one first move from the root.
96///
97/// These are optimistic, beam-sampled statistics computed over whichever
98/// leaves this particular engine run happened to discover and keep — they
99/// are **not** a minimax-proven guarantee. `win_probability` is a heuristic
100/// rescaling of `mean_value` into `[0, 1]`, not a calibrated probability.
101#[derive(Clone, Debug, PartialEq)]
102pub struct RankedRootMove {
103    pub mv: Move,
104    /// Max leaf value via this move, root-player perspective.
105    pub best_value: f64,
106    /// Multiplicity-weighted mean, root-player perspective.
107    pub mean_value: f64,
108    /// Heuristic rescaling: `(mean_value + 1) / 2`.
109    pub win_probability: f64,
110    /// Number of collected leaves supporting this move.
111    pub leaf_count: usize,
112    /// Sum of multiplicity over supporting leaves.
113    pub total_multiplicity: u64,
114    /// A proven root-player-winning terminal exists via this move.
115    pub has_terminal_win: bool,
116}
117
118/// Search statistics.
119#[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/// Result of a beam search run.
130#[derive(Clone, Debug, PartialEq)]
131pub struct BeamSearchResult {
132    /// Best leaf for the ROOT player to move.
133    pub best_leaf: Option<BeamLeaf>,
134    /// All terminals discovered, best first (root-player perspective).
135    pub terminal_leaves: Vec<BeamLeaf>,
136    pub reached_terminal: bool,
137    pub max_depth_reached: u32,
138    pub stats: BeamStats,
139    /// Player to move at the root.
140    pub root_player: u8,
141    /// Non-terminal leaves still live at `max_depth_reached`; empty once
142    /// the search fully resolves (`reached_terminal` is true).
143    pub frontier_leaves: Vec<BeamLeaf>,
144}
145
146impl BeamSearchResult {
147    /// Aggregate every collected leaf by its first move from the root.
148    ///
149    /// Groups `terminal_leaves` and `frontier_leaves` by the first move of
150    /// each leaf's principal variation and summarizes each group's value
151    /// from the root player's perspective. See [`RankedRootMove`] for the
152    /// caveat: these are beam-sampled statistics, not proven minimax values.
153    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
229/// Frontier entry: bitboard, move sequence from the root, evaluated value
230/// (P0 perspective; 0.0 for the never-scored root) and accumulated
231/// multiplicity.
232struct FrontierEntry {
233    bb: Bitboard,
234    moves: Vec<Move>,
235    multiplicity: u64,
236}
237
238/// Candidate keyed by canonical payload: the candidate's bitboard, move
239/// sequence, the player who made the move leading to it, and accumulated
240/// multiplicity.
241struct Candidate {
242    bb: Bitboard,
243    moves: Vec<Move>,
244    mover: u8,
245    multiplicity: u64,
246}
247
248/// Level-by-level beam search over the Quantik game tree.
249pub struct BeamSearchEngine {
250    pub config: BeamSearchConfig,
251    rng: StdRng,
252    evaluator: Option<Evaluator>,
253}
254
255impl BeamSearchEngine {
256    /// Initialize the engine, validating configuration.
257    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    /// Builder-style custom evaluator override (P0 perspective, `[-1, 1]`).
302    pub fn with_evaluator(mut self, evaluator: Evaluator) -> Self {
303        self.evaluator = Some(evaluator);
304        self
305    }
306
307    /// Run beam search from `root`.
308    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        // Values carried alongside the frontier (parallel vec keeps
326        // FrontierEntry small); 0.0 for the never-scored root.
327        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    /// Resolve the beam width to use at a given depth (1-indexed).
398    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    /// Resolve the built-in evaluator's rollout count at a depth (1-indexed).
409    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    /// Expand every frontier entry, recording terminals and candidates.
420    ///
421    /// Every raw legal move contributes the parent's multiplicity to
422    /// whatever it produces: its own terminal leaf, or — on a canonical
423    /// dedup hit — accumulated into the existing candidate's multiplicity
424    /// (the first-encountered move/parent is kept for the principal
425    /// variation; only the weight accumulates).
426    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                // Mover has no legal moves: the other player wins.
442                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    /// Evaluate candidates, keep the top `beam_width`, build the next
501    /// frontier. Scoring and pruning are purely value-based; multiplicity
502    /// is carried through unweighted.
503    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        // (mover-relative score, insertion index, raw value)
511        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        // Extract survivors by index. Move candidates out via Option to
529        // avoid cloning move vectors.
530        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    /// Evaluate a state from player 0's perspective, clamped to `[-1, 1]`.
547    ///
548    /// A custom evaluator is called as-is (its cost model is its own, so
549    /// `rollouts` is ignored and `stats.rollouts` stays untouched);
550    /// otherwise the built-in evaluator runs `rollouts` playouts.
551    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    /// Play uniformly random legal moves until a terminal state.
567    ///
568    /// A Quantik playout always resolves within 16 plies, so no depth
569    /// cutoff is required.
570    fn rollout(&mut self, bb: &Bitboard) -> f64 {
571        let mut current = *bb;
572        loop {
573            let winner = check_winner(&current);
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(&current);
582            if moves.is_empty() {
583                return if current_player(&current) == 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(&current, &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    /// A@0, b@1, C@2: P1 to move; d@3 completes row 0 and wins for P1.
607    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            // Replay the PV: every move legal, final state terminal.
706            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        // 64 raw moves collapse to 3 canonical states.
727        assert_eq!(result.stats.candidates_deduped, 61);
728        assert_eq!(result.frontier_leaves.len(), 3);
729        // Path-count accounting: multiplicities cover all 64 raw moves.
730        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        // Depth 1 keeps 3 (all canonical states), depths 2..4 keep 2 each.
745        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        // Depth 1: 3 canonical candidates × 1 rollout; depth 2: evaluations
760        // at 8 rollouts each.
761        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 // out of range: must clamp to 1.0
794        }));
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        // The P1-winning terminal (value -1.0) must rank first for P1.
816        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        // Survivors per level ≤ beam_width; terminals are extra but each
842        // level's kept frontier is bounded.
843        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        // Depth 1 always completes; the wide levels stop early.
859        assert!(result.max_depth_reached >= 1);
860        assert!(start.elapsed().as_secs_f64() < 5.0);
861    }
862}