Skip to main content

quantik_core/
minimax.rs

1//! Classical alpha-beta minimax (negamax formulation) for Quantik.
2//!
3//! Searches the exact game tree using `has_winning_line` for terminal
4//! detection and falls back to [`crate::evaluation::evaluate`] once
5//! `max_depth` is exhausted. With `max_depth = 16` ([`MinimaxEngine::solve`])
6//! the search always reaches true terminal states — no Quantik game exceeds
7//! 16 plies — so it acts as an exact solver, not just a heuristic engine.
8//!
9//! Negamax sign convention: `negamax` returns the value of a position from
10//! the perspective of the side to move *at that node*. A caller negates a
11//! child's value to fold it back into its own perspective.
12//!
13//! Terminal values use `win - ply` (not a flat `win`) so that a forced mate
14//! found sooner scores strictly higher than one found deeper.
15//!
16//! `State::canonical_key()` collapses the D4 × S4 = 192 board symmetries
17//! *without* swapping colors, so the negamax value (always relative to the
18//! side to move, not a fixed color) is safe to cache/dedup by that key.
19//! Only the value/bound is ever cached — never the move — since the key
20//! alone doesn't preserve which concrete move produced a given child.
21//!
22//! Sibling dedup (`dedup_children`) and the transposition table
23//! (`use_transposition_table`) both key off `canonical_key()`. Where dedup
24//! and the TT are both active on the same call, the child key already
25//! computed by dedup is threaded into the recursive call so the TT probe
26//! does not recompute it.
27
28use crate::bitboard::Bitboard;
29use crate::evaluation::{evaluate, EvalConfig};
30use crate::game::{current_player, has_winning_line};
31use crate::moves::{apply_move, generate_legal_moves, Move};
32use crate::state::State;
33use rand::prelude::*;
34use std::collections::HashMap;
35use std::time::Instant;
36
37/// Transposition-table bound kind for a stored negamax value.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum Bound {
40    Exact,
41    Lower,
42    Upper,
43}
44
45/// Configuration for [`MinimaxEngine`].
46#[derive(Clone, Debug)]
47pub struct MinimaxConfig {
48    pub max_depth: u32,
49    pub time_limit_s: Option<f64>,
50    pub use_alpha_beta: bool,
51    pub use_transposition_table: bool,
52    pub dedup_children: bool,
53    pub eval_config: EvalConfig,
54    pub random_seed: Option<u64>,
55}
56
57impl Default for MinimaxConfig {
58    fn default() -> Self {
59        Self {
60            max_depth: 16,
61            time_limit_s: None,
62            use_alpha_beta: true,
63            use_transposition_table: true,
64            dedup_children: true,
65            eval_config: EvalConfig::default(),
66            random_seed: None,
67        }
68    }
69}
70
71/// Result of a [`MinimaxEngine::search`] (or `.solve`) call.
72#[derive(Clone, Debug)]
73pub struct MinimaxResult {
74    pub best_move: Move,
75    pub score: f64,
76    pub depth_reached: u32,
77    pub nodes: u64,
78    pub pv: Vec<Move>,
79    pub elapsed: f64,
80}
81
82type TTEntry = (u32, f64, Bound);
83
84/// A legal move paired with the bitboard it produces and — when dedup
85/// computed one — that child's canonical key.
86type ChildEntry = (Move, Bitboard, Option<[u8; 18]>);
87
88/// Internal signal that the configured time limit was reached.
89struct TimeUp;
90
91fn move_sort_key(mv: &Move) -> (u8, u8) {
92    (mv.shape, mv.position)
93}
94
95/// Alpha-beta negamax search engine over the exact Quantik game tree.
96pub struct MinimaxEngine {
97    pub config: MinimaxConfig,
98    tt: HashMap<[u8; 18], TTEntry>,
99    nodes: u64,
100    deadline: Option<Instant>,
101    rng: Option<StdRng>,
102    pv_hint: Vec<Move>,
103}
104
105impl MinimaxEngine {
106    pub fn new(config: MinimaxConfig) -> Self {
107        let rng = config.random_seed.map(StdRng::seed_from_u64);
108        Self {
109            config,
110            tt: HashMap::new(),
111            nodes: 0,
112            deadline: None,
113            rng,
114            pv_hint: Vec::new(),
115        }
116    }
117
118    /// Exact solve: [`Self::search`] with `max_depth = 16` and no time limit.
119    ///
120    /// Every Quantik game resolves (win or no-legal-moves) within 16 plies,
121    /// so a depth-16 search from any reachable position always terminates on
122    /// true terminal nodes rather than the heuristic eval cutoff.
123    pub fn solve(&mut self, state: &State) -> Result<MinimaxResult, String> {
124        let original = self.config.clone();
125        self.config.max_depth = 16;
126        self.config.time_limit_s = None;
127        let result = self.search(state);
128        self.config = original;
129        result
130    }
131
132    /// Iterative-deepening alpha-beta negamax search from `state`.
133    ///
134    /// Deepens from depth 1 to `config.max_depth` (or until
135    /// `config.time_limit_s` elapses), seeding each iteration's root move
136    /// order with the previous iteration's principal variation. Returns the
137    /// deepest iteration that completed before any time limit; the depth-1
138    /// iteration always runs to completion.
139    pub fn search(&mut self, state: &State) -> Result<MinimaxResult, String> {
140        let start = Instant::now();
141        self.nodes = 0;
142        self.tt.clear();
143        self.pv_hint.clear();
144        self.deadline = self
145            .config
146            .time_limit_s
147            .map(|s| start + std::time::Duration::from_secs_f64(s));
148
149        let bb = state.bb;
150        let root_moves = generate_legal_moves(&bb);
151        if root_moves.is_empty() {
152            return Err("Cannot search from a state with no legal moves.".into());
153        }
154
155        let mut result: Option<MinimaxResult> = None;
156        for depth in 1..=self.config.max_depth {
157            match self.search_root(&bb, &root_moves, depth) {
158                Ok((score, best_move, pv)) => {
159                    self.pv_hint = pv.clone();
160                    result = Some(MinimaxResult {
161                        best_move,
162                        score,
163                        depth_reached: depth,
164                        nodes: self.nodes,
165                        pv,
166                        elapsed: start.elapsed().as_secs_f64(),
167                    });
168                    if let Some(deadline) = self.deadline {
169                        if Instant::now() >= deadline {
170                            break;
171                        }
172                    }
173                }
174                Err(TimeUp) => break,
175            }
176        }
177
178        // The depth-1 iteration is cheap enough that the first time check
179        // (every 1024 nodes) cannot fire before it completes on any
180        // reachable position; mirror the Python assertion.
181        let mut result = result.expect("depth-1 iteration always completes");
182        result.elapsed = start.elapsed().as_secs_f64();
183        Ok(result)
184    }
185
186    // ----- internals -----------------------------------------------------
187
188    /// Sort root moves by the deterministic tie-break, then float the prior
189    /// iteration's PV move (if any) to the front for better alpha-beta
190    /// cutoffs on the next, deeper iteration.
191    fn order_root_moves(&self, moves: &[Move]) -> Vec<Move> {
192        let mut ordered: Vec<Move> = moves.to_vec();
193        ordered.sort_by_key(move_sort_key);
194        if let Some(pv_move) = self.pv_hint.first() {
195            if let Some(i) = ordered.iter().position(|m| m == pv_move) {
196                let mv = ordered.remove(i);
197                ordered.insert(0, mv);
198            }
199        }
200        ordered
201    }
202
203    /// Apply each move in `moves` (assumed pre-sorted), pairing it with the
204    /// resulting bitboard and (when `dedup`) that child's canonical key.
205    ///
206    /// When `dedup`, siblings whose resulting state shares a
207    /// `State::canonical_key()` collapse onto a single representative (the
208    /// first survivor in `moves`' order, preserving the lowest
209    /// `(shape, position)` tie-break).
210    fn children(bb: &Bitboard, moves: &[Move], dedup: bool) -> Vec<ChildEntry> {
211        if !dedup {
212            return moves
213                .iter()
214                .map(|&mv| (mv, apply_move(bb, &mv), None))
215                .collect();
216        }
217        let mut seen: HashMap<[u8; 18], ()> = HashMap::new();
218        let mut children = Vec::with_capacity(moves.len());
219        for &mv in moves {
220            let child_bb = apply_move(bb, &mv);
221            let key = State::new(child_bb).canonical_key();
222            if seen.insert(key, ()).is_some() {
223                continue;
224            }
225            children.push((mv, child_bb, Some(key)));
226        }
227        children
228    }
229
230    /// Search the root position to `depth`, returning `(score, best_move, pv)`.
231    ///
232    /// Every root child is searched with a FULL (-inf, +inf) window, so each
233    /// returned value is EXACT rather than a fail-soft bound. This matters
234    /// for the tie-break: a narrowed window could let an inferior sibling
235    /// fail low onto a bound equal to `best_value`, pollute the equal-value
236    /// candidate set, and — with `random_seed` set — be chosen. Each child's
237    /// own subtree is still alpha-beta pruned inside `negamax`.
238    fn search_root(
239        &mut self,
240        bb: &Bitboard,
241        moves: &[Move],
242        depth: u32,
243    ) -> Result<(f64, Move, Vec<Move>), TimeUp> {
244        let ordered = self.order_root_moves(moves);
245        let children = Self::children(bb, &ordered, self.config.dedup_children);
246
247        let mut best_value = f64::NEG_INFINITY;
248        let mut scored: Vec<(Move, f64, Vec<Move>)> = Vec::with_capacity(children.len());
249
250        for (mv, child_bb, child_key) in children {
251            let mut child_pv = Vec::new();
252            let value = -self.negamax(
253                &child_bb,
254                depth - 1,
255                f64::NEG_INFINITY,
256                f64::INFINITY,
257                1,
258                &mut child_pv,
259                child_key,
260            )?;
261            if value > best_value {
262                best_value = value;
263            }
264            scored.push((mv, value, child_pv));
265        }
266
267        let candidates: Vec<&(Move, f64, Vec<Move>)> =
268            scored.iter().filter(|(_, v, _)| *v == best_value).collect();
269        let (mv, _, child_pv) = match self.rng.as_mut() {
270            Some(rng) => candidates[rng.gen_range(0..candidates.len())],
271            None => candidates[0],
272        };
273        let mut pv = vec![*mv];
274        pv.extend(child_pv.iter().copied());
275        Ok((best_value, *mv, pv))
276    }
277
278    fn check_time(&self) -> Result<(), TimeUp> {
279        if let Some(deadline) = self.deadline {
280            if Instant::now() >= deadline {
281                return Err(TimeUp);
282            }
283        }
284        Ok(())
285    }
286
287    /// Negamax value of `bb` from the side-to-move's perspective.
288    ///
289    /// `pv_out` is filled in place with the principal variation from this
290    /// node downward (empty if the node is terminal or a leaf).
291    /// `precomputed_key` is this node's `canonical_key()` if the caller's
292    /// sibling-dedup pass already computed it (else `None`, computed lazily
293    /// here only if the TT is enabled).
294    #[allow(clippy::too_many_arguments)]
295    fn negamax(
296        &mut self,
297        bb: &Bitboard,
298        depth: u32,
299        mut alpha: f64,
300        mut beta: f64,
301        ply: u32,
302        pv_out: &mut Vec<Move>,
303        precomputed_key: Option<[u8; 18]>,
304    ) -> Result<f64, TimeUp> {
305        self.nodes += 1;
306        if self.deadline.is_some() && (self.nodes & 0x3FF) == 0 {
307            self.check_time()?;
308        }
309
310        let win = self.config.eval_config.win;
311
312        if has_winning_line(bb) {
313            // The previous mover completed a line: the side to move here has
314            // just lost. `ply` makes a sooner loss/win score more extremely
315            // than a deeper one (shallower mates score higher).
316            return Ok(-(win - ply as f64));
317        }
318
319        let moves = generate_legal_moves(bb);
320        if moves.is_empty() {
321            // No legal moves: the side to move also loses.
322            return Ok(-(win - ply as f64));
323        }
324
325        if depth == 0 {
326            let side = current_player(bb).unwrap_or(0);
327            return Ok(evaluate(bb, side, &self.config.eval_config));
328        }
329
330        let mut tt_key: Option<[u8; 18]> = None;
331        let orig_alpha = alpha;
332        let orig_beta = beta;
333        if self.config.use_transposition_table {
334            let key = precomputed_key.unwrap_or_else(|| State::new(*bb).canonical_key());
335            tt_key = Some(key);
336            if let Some(&(stored_depth, stored_value, bound)) = self.tt.get(&key) {
337                if stored_depth >= depth {
338                    if bound == Bound::Exact {
339                        return Ok(stored_value);
340                    }
341                    // LOWER/UPPER entries only narrow the window when
342                    // alpha-beta is enabled: with `use_alpha_beta = false`
343                    // the search contract is an exact, unpruned value, and
344                    // reusing a bound would silently reintroduce pruning.
345                    if self.config.use_alpha_beta {
346                        match bound {
347                            Bound::Lower => alpha = alpha.max(stored_value),
348                            Bound::Upper => beta = beta.min(stored_value),
349                            Bound::Exact => unreachable!(),
350                        }
351                        if alpha >= beta {
352                            return Ok(stored_value);
353                        }
354                    }
355                }
356            }
357        }
358
359        let mut ordered = moves;
360        ordered.sort_by_key(move_sort_key);
361        let mut children = Self::children(bb, &ordered, self.config.dedup_children);
362        // Move ordering: try immediate winning replies first — a move that
363        // completes a line makes this node a forced win, so exploring it
364        // first yields the earliest possible beta cutoff. Stable, so the
365        // deterministic (shape, position) order is preserved among equals.
366        children.sort_by_key(|(_, child_bb, _)| !has_winning_line(child_bb));
367
368        let mut best_value = f64::NEG_INFINITY;
369        let mut best_move: Option<Move> = None;
370        let mut best_child_pv: Vec<Move> = Vec::new();
371
372        for (mv, child_bb, child_key) in children {
373            let mut child_pv = Vec::new();
374            let value = -self.negamax(
375                &child_bb,
376                depth - 1,
377                -beta,
378                -alpha,
379                ply + 1,
380                &mut child_pv,
381                child_key,
382            )?;
383            if value > best_value {
384                best_value = value;
385                best_move = Some(mv);
386                best_child_pv = child_pv;
387            }
388            if self.config.use_alpha_beta {
389                alpha = alpha.max(best_value);
390                if alpha >= beta {
391                    break;
392                }
393            }
394        }
395
396        if let Some(mv) = best_move {
397            pv_out.push(mv);
398            pv_out.extend(best_child_pv);
399        }
400
401        if let Some(key) = tt_key {
402            // Classify against the ORIGINAL (pre-TT-narrowing) window, not
403            // the possibly-tightened alpha/beta used for this search.
404            let bound = if best_value <= orig_alpha {
405                Bound::Upper
406            } else if self.config.use_alpha_beta && best_value >= orig_beta {
407                Bound::Lower
408            } else {
409                Bound::Exact
410            };
411            self.tt.insert(key, (depth, best_value, bound));
412        }
413
414        Ok(best_value)
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    /// A@0, b@1, C@2: whoever moves can win immediately with D (d) at 3.
423    fn immediate_win_board() -> Bitboard {
424        Bitboard::EMPTY
425            .with_move(0, 0, 0)
426            .with_move(1, 1, 1)
427            .with_move(0, 2, 2)
428    }
429
430    #[test]
431    fn finds_immediate_winning_move() {
432        let bb = immediate_win_board();
433        let mut engine = MinimaxEngine::new(MinimaxConfig {
434            max_depth: 3,
435            ..Default::default()
436        });
437        let result = engine.search(&State::new(bb)).unwrap();
438        // p1 to move: d at position 3 completes row 0.
439        assert_eq!(result.best_move, Move::new(1, 3, 3));
440        // Child is terminal at ply 1: value is win - 1.
441        assert_eq!(result.score, 10_000.0 - 1.0);
442    }
443
444    #[test]
445    fn pv_head_matches_best_move() {
446        let bb = immediate_win_board();
447        let mut engine = MinimaxEngine::new(MinimaxConfig {
448            max_depth: 4,
449            ..Default::default()
450        });
451        let result = engine.search(&State::new(bb)).unwrap();
452        assert_eq!(result.pv[0], result.best_move);
453        assert!(result.pv.len() as u32 <= result.depth_reached);
454    }
455
456    #[test]
457    fn alpha_beta_equals_plain_minimax() {
458        let bb = Bitboard::EMPTY
459            .with_move(0, 0, 0)
460            .with_move(1, 3, 8)
461            .with_move(0, 2, 2)
462            .with_move(1, 3, 13)
463            .with_move(0, 1, 1)
464            .with_move(1, 0, 10);
465        let mut pruned = MinimaxEngine::new(MinimaxConfig {
466            max_depth: 3,
467            use_alpha_beta: true,
468            ..Default::default()
469        });
470        let mut plain = MinimaxEngine::new(MinimaxConfig {
471            max_depth: 3,
472            use_alpha_beta: false,
473            ..Default::default()
474        });
475        let state = State::new(bb);
476        let a = pruned.search(&state).unwrap();
477        let b = plain.search(&state).unwrap();
478        assert_eq!(a.score, b.score);
479        assert_eq!(a.best_move, b.best_move);
480        assert!(pruned.nodes <= plain.nodes);
481    }
482
483    /// Play `plies` deterministic pseudo-random legal moves from the empty
484    /// board, retrying until the line hits no win/dead-end along the way.
485    fn random_position(seed: u64, plies: usize) -> Bitboard {
486        let mut rng = StdRng::seed_from_u64(seed);
487        'attempt: loop {
488            let mut bb = Bitboard::EMPTY;
489            for _ in 0..plies {
490                let moves = generate_legal_moves(&bb);
491                if moves.is_empty() {
492                    continue 'attempt;
493                }
494                bb = apply_move(&bb, &moves[rng.gen_range(0..moves.len())]);
495                if has_winning_line(&bb) {
496                    continue 'attempt;
497                }
498            }
499            if generate_legal_moves(&bb).is_empty() {
500                continue 'attempt;
501            }
502            return bb;
503        }
504    }
505
506    #[test]
507    fn solve_reaches_terminal_depth() {
508        // 10 pieces on the board (≤ 6 plies remain); solve must complete all
509        // 16 iterations with an exact terminal-range score.
510        let bb = random_position(42, 10);
511        let mut engine = MinimaxEngine::new(MinimaxConfig::default());
512        let result = engine.solve(&State::new(bb)).unwrap();
513        assert_eq!(result.depth_reached, 16);
514        // Exact solve of a Quantik position is a forced win for one side:
515        // score magnitude must be in the terminal range, not heuristic.
516        assert!(result.score.abs() > 9_000.0, "score {}", result.score);
517    }
518
519    #[test]
520    fn time_limit_returns_depth_one_result() {
521        let mut engine = MinimaxEngine::new(MinimaxConfig {
522            max_depth: 16,
523            time_limit_s: Some(0.001),
524            ..Default::default()
525        });
526        let result = engine.search(&State::empty()).unwrap();
527        assert!(result.depth_reached >= 1);
528        assert!(result.pv.first() == Some(&result.best_move));
529    }
530
531    #[test]
532    fn deterministic_without_seed() {
533        let bb = immediate_win_board();
534        let mut e1 = MinimaxEngine::new(MinimaxConfig {
535            max_depth: 2,
536            ..Default::default()
537        });
538        let mut e2 = MinimaxEngine::new(MinimaxConfig {
539            max_depth: 2,
540            ..Default::default()
541        });
542        assert_eq!(
543            e1.search(&State::new(bb)).unwrap().best_move,
544            e2.search(&State::new(bb)).unwrap().best_move
545        );
546    }
547
548    #[test]
549    fn solve_prefers_faster_win() {
550        // With an immediate win available, an exact solve of a late position
551        // must take it: score win - 1. Deep start keeps the tree small.
552        let bb = random_position(7, 11);
553        let mut engine = MinimaxEngine::new(MinimaxConfig::default());
554        let result = engine.solve(&State::new(bb)).unwrap();
555        let winning: Vec<Move> = generate_legal_moves(&bb)
556            .into_iter()
557            .filter(|m| has_winning_line(&apply_move(&bb, m)))
558            .collect();
559        if winning.is_empty() {
560            // No immediate win from this seed: still an exact result.
561            assert!(result.score.abs() > 9_000.0);
562        } else {
563            assert!(winning.contains(&result.best_move));
564            assert_eq!(result.score, 10_000.0 - 1.0);
565        }
566    }
567}