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}