Skip to main content

blunders_engine/search/
negamax.rs

1//! Negamax implementation of Minimax with Alpha-Beta pruning.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::Instant;
6
7use crate::arrayvec::{self, ArrayVec};
8use crate::coretypes::{Cp, Move, MoveInfo, MoveKind, PieceKind, PlyKind, MAX_DEPTH};
9use crate::eval::{draw, terminal};
10use crate::movelist::{Line, MoveInfoList};
11use crate::moveorder::order_all_moves;
12use crate::position::{Cache, Position};
13use crate::search::{quiescence, History, SearchResult};
14use crate::timeman::Mode;
15use crate::transposition::{Entry, NodeKind, TranspositionTable};
16use crate::zobrist::HashKind;
17
18/// Negamax implementation of Minimax with alpha-beta pruning.
19/// Negamax searches to a given depth and returns the best move found.
20/// Internally, Negamax treats the active player as the maxing player,
21/// however the final centipawn score of the position returned is
22/// absolute with White as maxing and Black as minning.
23pub fn negamax(mut position: Position, ply: PlyKind, tt: &TranspositionTable) -> SearchResult {
24    assert!(0 < ply && ply < MAX_DEPTH);
25
26    let root_player = *position.player();
27    let hash = tt.generate_hash(&position);
28    let instant = Instant::now();
29    let age = position.age();
30
31    let mut pv = Line::new();
32    let mut nodes = 0;
33
34    let best_score = negamax_impl(
35        &mut position,
36        tt,
37        hash,
38        &mut pv,
39        &mut nodes,
40        ply,
41        Cp::MIN,
42        Cp::MAX,
43        age,
44    );
45
46    SearchResult {
47        player: root_player,
48        depth: ply,
49        best_move: *pv.get(0).unwrap(),
50        score: best_score * root_player.sign(),
51        pv,
52        nodes,
53        elapsed: instant.elapsed(),
54        ..Default::default()
55    }
56}
57
58/// The player whose turn it is to move for a position is always treated as the maxing player.
59/// negamax_impl returns the max possible score of the current maxing player.
60/// Therefore, when interpreting the score of a child node, the score needs to be negated.
61///
62/// negamax_impl stores the principal variation of the current move into the pv parameter.
63///
64/// Parameters:
65///
66/// position: current position to search.
67/// tt: Transposition Table used for recalling search history.
68/// hash: Incrementally updatable hash of provided position.
69/// pv: Line of moves in principal variation.
70/// nodes: Counter for number of nodes visited in search.
71/// ply: remaining depth to search to.
72/// alpha: Best (greatest) guaranteed value for current player.
73/// beta: Best (lowest) guaranteed value for opposite player.
74fn negamax_impl(
75    position: &mut Position,
76    tt: &TranspositionTable,
77    hash: HashKind,
78    pv: &mut Line,
79    nodes: &mut u64,
80    ply: PlyKind,
81    mut alpha: Cp,
82    beta: Cp,
83    age: u8,
84) -> Cp {
85    *nodes += 1;
86
87    let legal_moves = position.get_legal_moves();
88    let num_moves = legal_moves.len();
89
90    // Save tt lookup from nested if.
91    let mut hash_move = None;
92
93    // Search can return when any of the following are encountered:
94    // * Checkmate / Stalemate (terminal node)
95    // * Tt move evaluated at equal or greater depth than searching depth
96    // * depth 0 reached (leaf node)
97    //
98    // An eval is returned with respect to the current player.
99    // (+Cp good, -Cp bad)
100    // Terminal and leaf nodes have no following moves so pv of parent is cleared.
101    if num_moves == 0 {
102        pv.clear();
103        return terminal(&position);
104    }
105    // Check if current move exists in tt. If so, we might be able to return that value
106    // right away if has a greater or equal depth than we are considering.
107    // Check that the tt key_move is a legal move, as extra (but not complete)
108    // protection against Key collisions.
109    // TODO: Verify that this is bug free. It is possible this may cut the Pv line,
110    //       or that returning early is incorrect.
111    else if let Some(entry) = tt.get(hash) {
112        if entry.ply >= ply && legal_moves.contains(&entry.key_move) {
113            pv.clear();
114            pv.push(entry.key_move);
115            return entry.score;
116        }
117        hash_move = Some(entry.key_move);
118
119    // Run a Quiescence Search for non-terminal leaf nodes to find a more stable
120    // evaluation than a static evaluation.
121    // The parent of this node receives an empty pv,
122    // because this leaf node has no best move, and is not in history.
123    } else if ply == 0 {
124        pv.clear();
125        let q_ply = 10;
126        return quiescence(position, alpha, beta, q_ply, nodes);
127    }
128
129    // Move Ordering
130    // Sort legal moves with estimated best move first.
131    let legal_moves = legal_moves
132        .into_iter()
133        .map(|move_| position.move_info(move_))
134        .collect();
135    let ordered_legal_moves = order_all_moves(legal_moves, hash_move);
136    debug_assert_eq!(num_moves, ordered_legal_moves.len());
137
138    // Placeholder best_move, is guaranteed to be overwritten as there is at
139    // lest one legal move, and the score of that move is better than worst
140    // possible score.
141    let cache = position.cache();
142    let mut best_move = Move::illegal();
143    let mut local_pv = Line::new();
144    let mut best_score = Cp::MIN;
145    let mut alpha_raised = false;
146
147    // For each child of current position, recursively find maxing move.
148    for legal_move_info in ordered_legal_moves.into_iter().rev() {
149        // Get value of a move relative to active player.
150        position.do_move_info(legal_move_info);
151        let move_hash = tt.update_from_hash(hash, &position, legal_move_info, cache);
152        let move_score = -negamax_impl(
153            position,
154            tt,
155            move_hash,
156            &mut local_pv,
157            nodes,
158            ply - 1,
159            -beta,
160            -alpha,
161            age,
162        );
163        position.undo_move(legal_move_info, cache);
164
165        // Update best_* trackers if this move is best of all seen so far.
166        if move_score > best_score {
167            best_score = move_score;
168            best_move = legal_move_info.move_();
169        }
170
171        // Cut-off has occurred, no further children of this position need to be searched.
172        // This branch will not be taken further up the tree as there is a better move.
173        // Push this cut-node into the tt, with a score relative to this node's active player.
174        if move_score >= beta {
175            let cut_move = legal_move_info.move_();
176            let entry = Entry::new(hash, cut_move, move_score, ply, NodeKind::Cut);
177            tt.replace_by(entry, age, replace_scheme);
178            return move_score;
179        }
180
181        // A new local PV line has been found. Update alpha and store new Line.
182        if best_score > alpha {
183            alpha_raised = true;
184            alpha = best_score;
185            pv.clear();
186            pv.push(best_move);
187            arrayvec::append(pv, local_pv.clone());
188        }
189    }
190
191    // Every move for this node has been evaluated, and best_score did not exceed beta.
192    let node_kind = match alpha_raised {
193        true => NodeKind::Pv,
194        false => NodeKind::All,
195    };
196    let entry = Entry::new(hash, best_move, best_score, ply, node_kind);
197
198    // Always replace with a PV node, otherwise replace conditionally.
199    if node_kind == NodeKind::Pv {
200        tt.replace(entry, age);
201    } else {
202        tt.replace_by(entry, age, replace_scheme);
203    }
204
205    best_score
206}
207
208/// Label represents what stage of processing a node is in.
209#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
210enum Label {
211    Initialize,
212    Search,
213    Retrieve,
214}
215
216/// Frame contains all the variables needed during the evaluation of a node.
217/// It is somewhat like the call frame for recursive negamax.
218#[derive(Debug, Clone)]
219struct Frame {
220    pub label: Label,
221    pub local_pv: Line,
222    pub legal_moves: MoveInfoList,
223    pub alpha: Cp,
224    pub beta: Cp,
225    pub best_score: Cp,
226    pub best_move: Move,
227    pub hash: HashKind,
228    pub move_info: MoveInfo,
229    pub cache: Cache,
230    pub alpha_raised: bool,
231}
232/// A frame defaults with junk data, however this is acceptable
233/// because nodes set appropriate data before using.
234impl Default for Frame {
235    fn default() -> Self {
236        let illegal_move = Move::illegal();
237        Self {
238            label: Label::Initialize,
239            local_pv: Line::new(),
240            legal_moves: MoveInfoList::new(),
241            alpha: Cp::MIN,
242            beta: Cp::MAX,
243            best_score: Cp::MIN,
244            best_move: Move::illegal(),
245            hash: 0,
246            move_info: MoveInfo {
247                from: illegal_move.from,
248                to: illegal_move.to,
249                promotion: illegal_move.promotion,
250                piece_kind: PieceKind::Pawn,
251                move_kind: MoveKind::Quiet,
252            },
253            cache: Cache::illegal(),
254            alpha_raised: false,
255        }
256    }
257}
258
259/// Extract a "Window" from a frame stack, where a window is a reference to
260/// the parent, current, and child frames of the given frame index.
261/// Frame index must not be 0.
262#[inline(always)]
263fn split_window_frames(frames: &mut [Frame], idx: usize) -> (&mut Frame, &mut Frame, &mut Frame) {
264    debug_assert!(idx > 0, "cannot get parent frame of index 0");
265    // split_at_mut includes the index in the second slice.
266    let (parent_slice, rest) = frames.split_at_mut(idx);
267    let (curr_slice, rest) = rest.split_at_mut(1);
268
269    let parent_frame = parent_slice.last_mut().unwrap();
270    let current_frame = &mut curr_slice[0];
271    let child_frame = &mut rest[0];
272
273    (parent_frame, current_frame, child_frame)
274}
275
276/// Given a frame index, returns the index of the frame's parent.
277#[inline(always)]
278fn parent_idx(frame_idx: usize) -> usize {
279    frame_idx - 1
280}
281
282/// Given a frame index, returns the index of the frame's child.
283#[inline(always)]
284fn child_idx(frame_idx: usize) -> usize {
285    frame_idx + 1
286}
287
288/// Convert a frame index to a ply.
289#[inline(always)]
290fn curr_ply(frame_idx: usize) -> PlyKind {
291    debug_assert!(frame_idx > 0);
292    (frame_idx - 1) as PlyKind
293}
294
295/// TT entry replacement scheme, assuming PV node are unconditionally replaced elsewhere.
296/// This is a replacement scheme assuming new_entry is All or Cut.
297/// Goals:
298/// * Always replace entries from previous searches.
299/// * Prioritize deeper searched nodes.
300#[inline]
301fn replace_scheme(new_entry: &Entry, new_age: u8, existing: &Entry, existing_age: u8) -> bool {
302    new_age != existing_age || (existing.node_kind != NodeKind::Pv && new_entry.ply >= existing.ply)
303}
304
305/// Iterative fail-soft Negamax implementation with alpha-beta pruning and transposition table lookup.
306///
307/// In fail-soft, the return value of a call can exceed its given bounds alpha and beta (score < alpha, score > beta).
308///
309/// Why change from recursive to iterative?
310/// * Need to be able to STOP searching at any time.
311/// This is hard to do from a recursive search without changing/checking return value.
312/// * Makes it easier to tell how far a node is from root.
313/// * Easy to stop without risk of corrupting transposition table entries.
314pub fn iterative_negamax(
315    mut position: Position,
316    ply: PlyKind,
317    mode: Mode,
318    mut history: History,
319    tt: &TranspositionTable,
320    stopper: Arc<AtomicBool>,
321) -> Option<SearchResult> {
322    // Guard: must have a valid searchable ply, and root position must not be terminal.
323    assert!(0 < ply && ply <= MAX_DEPTH);
324    assert_ne!(position.get_legal_moves().len(), 0);
325
326    // Meta Search variables
327    let instant = Instant::now(); // Timer for search.
328    let root_position = position.clone(); // For assertions
329    let root_hash = tt.generate_hash(&position); // Keep copy of root hash for assertions
330    let root_history = history.clone();
331    let age = position.age();
332
333    // Early Stop variables
334    let nodes_per_stop_check = 2000; // Number of nodes between updates to stopped flag
335    let mut stopped = false; // Indicates if search was stopped
336    let mut stop_check_counter = nodes_per_stop_check; // When this hits 0, update stopped and reset
337
338    // A score assigned to draws to lean engine away from drawing (Cp 0) when slightly behind.
339    let contempt = Cp(50);
340
341    // Update Metrics in SearchResult.
342    let mut metrics = SearchResult::default();
343    metrics.player = root_position.player;
344    metrics.depth = ply;
345    metrics.stopped = false;
346
347    // Stack holds frame data, where each ply gets one frame.
348    // Size is +1 because the 0th index holds the PV so far for root position.
349    const BASE_IDX: usize = 0; // Root passes PV to this parent frame
350    const ROOT_IDX: usize = 1; // Root position data frame
351    let mut stack: ArrayVec<Frame, { (MAX_DEPTH + 1) as usize }> = ArrayVec::new();
352    // Fill stack with default values to navigate, opposed to pushing and popping.
353    while !stack.is_full() {
354        stack.push(Default::default());
355    }
356    // Set initial valid root parameters.
357    stack[ROOT_IDX].label = Label::Initialize;
358    stack[ROOT_IDX].hash = root_hash;
359    stack[ROOT_IDX].cache = root_position.cache();
360
361    // Frame indexer, begins at 1 (root) as 0 is for global pv.
362    // Incrementing -> recurse to child, Decrementing -> return to parent.
363    let mut frame_idx: usize = ROOT_IDX;
364
365    // MAIN ITERATIVE LOOP
366    while frame_idx > 0 {
367        // Take a mut sliding window view into the stack.
368        let (parent, us, child) = split_window_frames(&mut stack, frame_idx);
369        // How many ply left to target depth.
370        let remaining_ply = ply - curr_ply(frame_idx);
371        let label: Label = us.label;
372
373        // Stop Check: Before processing, check if search has been told to stop.
374        // It is safe to stop at anytime outside of the processing modes below.
375        if label == Label::Initialize && stop_check_counter <= 0 {
376            stop_check_counter = nodes_per_stop_check;
377            stopped |= stopper.load(Ordering::Acquire);
378            stopped |= mode.stop(root_position.player, ply);
379        }
380
381        // If stopped flag is ever set, breaking ends search early.
382        if stopped {
383            break;
384        }
385
386        // INITIALIZE MODE
387        // A new node has been created.
388        // If it is terminal, a leaf, or has been evaluated in the past,
389        // it immediately returns its evaluation up the stack to its parent.
390        // Otherwise, it has children nodes to search and sets itself into Search mode.
391        //
392        // Flow: Return eval to parent || set self to search mode
393        if Label::Initialize == label {
394            stop_check_counter -= 1;
395            metrics.nodes += 1;
396
397            let legal_moves = position.get_legal_moves();
398            let num_moves = legal_moves.len();
399
400            // Save TT lookup to avoid re-locking.
401            let mut hash_move = None;
402
403            // This position has no best move.
404            // Store its evaluation and tell parent to retrieve value.
405            if num_moves == 0 {
406                parent.label = Label::Retrieve;
407                parent.local_pv.clear();
408                us.best_score = terminal(&position);
409
410                frame_idx = parent_idx(frame_idx);
411                continue;
412            }
413            // Check for draw by repetition or fifty-move rule.
414            // After terminal because terminal can't be repeated, mate presides over 50-move rule.
415            // Before tt lookup because a repeated position has a different score than when previously visited.
416            // TODO:
417            // Change to twofold_repetition but avoid error where root is in history.
418            else if position.fifty_move_rule(num_moves)
419                || history.is_threefold_repetition(us.hash)
420            {
421                parent.label = Label::Retrieve;
422                parent.local_pv.clear();
423                us.best_score = draw(root_position.player == position.player, contempt);
424
425                frame_idx = parent_idx(frame_idx);
426                continue;
427            }
428            // Check if this position exists in tt and has been searched to/beyond our ply.
429            // If so the score is usable, store this value and return to parent.
430            else if let Some(entry) = tt.get(us.hash) {
431                metrics.tt_hits += 1;
432                if entry.ply >= remaining_ply && legal_moves.contains(&entry.key_move) {
433                    metrics.tt_cuts += 1;
434                    parent.label = Label::Retrieve;
435                    parent.local_pv.clear();
436                    parent.local_pv.push(entry.key_move);
437
438                    us.best_score = entry.score;
439                    us.best_move = entry.key_move;
440
441                    frame_idx = parent_idx(frame_idx);
442                    continue;
443                }
444                hash_move = Some(entry.key_move);
445            }
446            // Max depth (leaf node) reached. Statically evaluate position and return value.
447            else if remaining_ply == 0 {
448                parent.label = Label::Retrieve;
449                parent.local_pv.clear();
450
451                let q_ply = 10;
452                let q_instant = Instant::now();
453                let mut q_nodes = 0;
454                us.best_score = quiescence(&mut position, us.alpha, us.beta, q_ply, &mut q_nodes);
455                metrics.q_elapsed += q_instant.elapsed();
456                metrics.nodes += q_nodes;
457                metrics.q_nodes += q_nodes;
458
459                frame_idx = parent_idx(frame_idx);
460                continue;
461            }
462
463            // This node has not returned early, so it has moves to search.
464            // Order all of this node's legal moves, and set it to search mode.
465            // Optional: Either Sort all moves first, or pick best each time.
466            let legal_moves: MoveInfoList = legal_moves
467                .into_iter()
468                .map(|move_| position.move_info(move_))
469                .collect();
470
471            us.legal_moves = order_all_moves(legal_moves, hash_move);
472            us.cache = position.cache();
473            us.label = Label::Search;
474
475        // SEARCH MODE
476        // If a node ever enters search mode, it is guaranteed to have had a legal move to search.
477        // Each search either pushes a child node onto the stack during which it waits
478        // to be set to RETRIEVE, or it sees that it has evaluated all of its children and returns
479        // its own score to its parent.
480        //
481        // Flow: (Moves to search) ? recurse to child : return eval to parent
482        } else if Label::Search == label {
483            // This position has a child position to search, initialize its frame.
484            if let Some(legal_move) = us.legal_moves.pop() {
485                us.move_info = legal_move;
486                position.do_move_info(legal_move);
487                history.push(us.hash, us.move_info.is_unrepeatable());
488
489                let child_hash = tt.update_from_hash(us.hash, &position, us.move_info, us.cache);
490                child.label = Label::Initialize;
491                child.hash = child_hash;
492                child.alpha = -us.beta;
493                child.beta = -us.alpha;
494                child.best_score = Cp::MIN;
495                child.alpha_raised = false;
496
497                frame_idx = child_idx(frame_idx);
498
499            // Every move for this node has been evaluated, so its complete score is returned.
500            } else {
501                let node_kind = match us.alpha_raised {
502                    true => {
503                        metrics.pv_nodes += 1;
504                        NodeKind::Pv
505                    }
506                    false => {
507                        metrics.all_nodes += 1;
508                        NodeKind::All
509                    }
510                };
511
512                let entry = Entry::new(
513                    us.hash,
514                    us.best_move,
515                    us.best_score,
516                    remaining_ply,
517                    node_kind,
518                );
519
520                // Always replace PV nodes, and replace others conditionally.
521                if node_kind == NodeKind::Pv {
522                    tt.replace(entry, age);
523                } else {
524                    tt.replace_by(entry, age, replace_scheme);
525                }
526
527                parent.label = Label::Retrieve;
528                frame_idx = parent_idx(frame_idx);
529            }
530
531        // RETRIEVE MODE
532        // Only a child of the current node sets this value to RETRIEVE.
533        // This node is allowed to take the return value and process it.
534        //
535        // Flow: (beta cutoff) ? Return best-score to parent : continue searching this node
536        } else if Label::Retrieve == label {
537            position.undo_move(us.move_info, us.cache);
538            history.pop();
539
540            // Negate child's best score so it's relative to this node.
541            let move_score = -child.best_score;
542
543            // Update our best_* trackers if this move is best seen so far.
544            if move_score > us.best_score {
545                us.best_score = move_score;
546                us.best_move = us.move_info.move_();
547            }
548
549            // Cut-off has occurred, no further children of this position need to be searched.
550            // This branch will not be taken further up the tree as there is a better move.
551            if us.best_score >= us.beta {
552                metrics.cut_nodes += 1;
553                let entry = Entry::new(
554                    us.hash,
555                    us.best_move,
556                    us.best_score,
557                    remaining_ply,
558                    NodeKind::Cut,
559                );
560                tt.replace_by(entry, age, replace_scheme);
561
562                // Early return.
563                parent.label = Label::Retrieve;
564                frame_idx = parent_idx(frame_idx);
565                continue;
566            }
567
568            // New local PV has been found. Update alpha and store new Line.
569            // Update this node in tt as a PV node.
570            if us.best_score > us.alpha {
571                us.alpha_raised = true;
572                us.alpha = us.best_score;
573
574                // Give parent updated PV by appending child PV to our best move.
575                parent.local_pv.clear();
576                parent.local_pv.push(us.best_move);
577                arrayvec::append(&mut parent.local_pv, us.local_pv.clone());
578            }
579
580            // Default action is to attempt to continue searching this node.
581            us.label = Label::Search;
582        }
583    }
584
585    if !stopped {
586        // Position has been returned to root position. Hashes should be equal.
587        debug_assert_eq!(root_hash, tt.generate_hash(&position));
588        debug_assert_eq!(root_hash, stack[ROOT_IDX].hash);
589        // History modified from search should return to equal that before searching.
590        debug_assert_eq!(root_history, history);
591    }
592
593    // The search may not run to completion. If at any point the Root node's PV gets updated,
594    // the base will have a non-zero length PV as the default is zero length.
595    // This PV can be returned as a best guess. If this is coming from iterative deepening
596    // this partial-search PV is guaranteed to be at least more accurate than what came from
597    // a lesser depth, as long as the previous depth PV was searched first.
598    if stack[BASE_IDX].local_pv.len() == 0 {
599        None
600    } else {
601        let best_move = stack[ROOT_IDX].best_move;
602        let score = stack[ROOT_IDX].best_score * root_position.player.sign();
603        let pv = stack[BASE_IDX].local_pv.clone();
604        assert_ne!(best_move, Move::illegal());
605        assert_eq!(metrics.player, root_position.player);
606        assert_eq!(metrics.depth, ply);
607
608        metrics.elapsed = instant.elapsed();
609        metrics.best_move = best_move;
610        metrics.score = score;
611        metrics.pv = pv;
612        metrics.stopped = stopped;
613
614        Some(metrics)
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::coretypes::{Color, Move, Square::*};
622    use crate::fen::Fen;
623
624    #[test]
625    #[ignore]
626    fn mate_pv() {
627        let position =
628            Position::parse_fen("r4rk1/1b3ppp/pp2p3/2p5/P1B1NR1Q/3P3P/2q3P1/7K w - - 0 24")
629                .unwrap();
630
631        let mut tt = TranspositionTable::new();
632        let result = negamax(position, 6, &mut tt);
633        assert_eq!(result.leading(), Some(Color::White));
634        assert_eq!(result.best_move, Move::new(E4, F6, None));
635        println!("{:?}", result.pv);
636    }
637
638    #[test]
639    fn color_sign() {
640        let cp = Cp(40); // Absolute score.
641
642        // Relative scores.
643        let w_signed = cp * Color::White.sign();
644        let b_signed = cp * Color::Black.sign();
645        assert_eq!(w_signed, Cp(40));
646        assert_eq!(b_signed, Cp(-40));
647    }
648
649    #[test]
650    fn nodetype_ordering() {
651        // Negamax replacement scheme assumes PV nodes are greater than others.
652        assert!(NodeKind::Pv > NodeKind::All);
653        assert!(NodeKind::Pv > NodeKind::Cut);
654    }
655}