diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 73e5a53b..a6e0b7d3 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -158,82 +158,78 @@ impl Searched { } } -/// A full width node: what it settled before its move loop, and its answer -/// as its moves come back. The late move rules, the census, the effort -/// instrument and the ledger all read it. Fail soft, as in quiescence: the -/// best score is kept whether or not it reached alpha, and the move that -/// scored it is what the table remembers. -pub(crate) struct Node { - /// The node's depth, the check extension included. - pub(crate) depth: u8, +/// A search's fail soft answer as its moves come back: the window as it +/// stands, the best score whether or not it reached alpha, the move that +/// scored it, which is what the table remembers, the taint of every value +/// it took, and how many moves it searched. A full width node holds one, +/// and quiescence and the root each open their own. +pub(crate) struct FailSoft { /// The bounds as they stand, alpha raised by every move that beat it. pub(crate) alpha: Score, pub(crate) beta: Score, /// Which of the two bounds are still the root's, moved with alpha. pub(crate) root_bounds: RootBounds, - pub(crate) in_check: bool, - /// The ply the quiet memories are read at, or none. - pub(crate) ply: Option, - pub(crate) tt: census::Table, - /// How many moves the node has made and searched: the table's move - /// when it was legal, and never a move that turned out illegal. + /// How many moves have been made and searched: at a full width node + /// the table's move when it was legal, and never a move that turned out + /// illegal. A stand pat is not a move. pub(crate) searched: usize, - /// The alpha the node opened with, which says whether the answer is a - /// ceiling and is the window the census records. + /// The alpha the search opened with, which says whether the answer is + /// a ceiling and is the window the census records. opening_alpha: Score, - /// The node count on entry, which prices what the node cost. - entered_at: u64, best: Score, best_move: Option, taint: Taint, } -/// What a searched move did to the node's bounds. +/// What a searched move did to the bounds. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Reached { - /// At or above beta: the node is cut off. + /// At or above beta: the search is cut off. Beta, /// Above alpha and under beta: alpha is raised to it. Alpha, Neither, } -impl Node { - /// The node before its first move, with the taint the shortcuts left. - // two past clippy's limit: every fact the node settles before its - // moves, once - #[allow(clippy::too_many_arguments)] - pub(crate) fn open( - depth: u8, - alpha: Score, - beta: Score, - root_bounds: RootBounds, - in_check: bool, - ply: Option, - tt: census::Table, - entered_at: u64, - taint: Taint, - ) -> Self { +impl FailSoft { + /// The answer before its first move, with whatever taint the search + /// read on the way to it. + #[inline(always)] + pub(crate) fn open(alpha: Score, beta: Score, root_bounds: RootBounds, taint: Taint) -> Self { Self { - depth, alpha, beta, root_bounds, - in_check, - ply, - tt, searched: 0, opening_alpha: alpha, - entered_at, best: Score::MIN + 1, best_move: None, taint, } } - /// One searched move's value, absorbed into the answer. + /// Quiescence's stand pat: the static score, already under beta, taken + /// as the best so far and as a floor under alpha, with no move and no + /// count. It is a floor the search stands on rather than a move that + /// beat alpha, so the alpha the answer opened with rises with it, and + /// the answer is a ceiling until a capture beats it. + #[inline(always)] + fn stand_pat(&mut self, score: Score) { + debug_assert!(score < self.beta, "a stand pat at beta answers alone"); + debug_assert_eq!(self.searched, 0, "the stand pat comes before every move"); + self.best = score; + self.alpha = self.alpha.max(score); + self.opening_alpha = self.alpha; + } + + /// One searched move's value, absorbed into the answer. Beta is asked + /// only of a score above alpha, which with alpha under beta is every + /// score at or above it, so most moves (which fail low) ask one + /// question. Asking beta first measured 0.1% more instructions over + /// the bench, most of it in quiescence. #[inline(always)] fn absorb(&mut self, m: &Play, value: Value) -> Reached { + debug_assert!(self.alpha < self.beta, "an empty window"); self.searched += 1; self.taint.absorb(value); let score = value.score; @@ -241,10 +237,10 @@ impl Node { self.best = score; self.best_move = Some(*m); } - if score >= self.beta { - return Reached::Beta; - } if score > self.alpha { + if score >= self.beta { + return Reached::Beta; + } self.alpha = score; self.root_bounds = self.root_bounds.alpha_raised(); return Reached::Alpha; @@ -254,11 +250,49 @@ impl Node { /// Whether a move raised alpha: the answer is then a score or a floor /// rather than a ceiling. + #[inline(always)] fn raised_alpha(&self) -> bool { self.alpha != self.opening_alpha } } +/// A full width node: what it settled before its move loop, and its answer +/// as its moves come back. The late move rules, the census, the effort +/// instrument and the ledger all read it. +pub(crate) struct Node { + /// The node's depth, the check extension included. + pub(crate) depth: u8, + pub(crate) in_check: bool, + /// The ply the quiet memories are read at, or none. + pub(crate) ply: Option, + pub(crate) tt: census::Table, + /// The node count on entry, which prices what the node cost. + entered_at: u64, + pub(crate) answer: FailSoft, +} + +impl Node { + /// The node before its first move, its answer opened with the taint + /// the shortcuts left. + pub(crate) fn open( + depth: u8, + in_check: bool, + ply: Option, + tt: census::Table, + entered_at: u64, + answer: FailSoft, + ) -> Self { + Self { + depth, + in_check, + ply, + tt, + entered_at, + answer, + } + } +} + /// What the move loop does with one move, which is also how it asks for /// the child search. enum Decision { @@ -1383,8 +1417,10 @@ impl AlphaBeta { } arm.offer(address, |root, at| { let eval = i64::from(crate::eval::eval(board)); - let (eval_beta, alpha_gap) = - (eval - i64::from(node.beta), i64::from(node.alpha) - eval); + let (eval_beta, alpha_gap) = ( + eval - i64::from(node.answer.beta), + i64::from(node.answer.alpha) - eval, + ); forced::Event { address, root, @@ -1490,7 +1526,7 @@ impl AlphaBeta { #[cold] #[inline(never)] fn ledger_skip(&mut self, staged: reduction::Staged, node: &Node) { - let (depth, alpha, beta) = (node.depth, node.alpha, node.beta); + let (depth, alpha, beta) = (node.depth, node.answer.alpha, node.answer.beta); let board = &mut self.board; let Some(ledger) = self.ledger.as_mut() else { return; @@ -1607,15 +1643,18 @@ impl AlphaBeta { ) { let Node { depth, - opening_alpha: alpha, - beta, in_check, ply, tt, - searched, entered_at, .. } = *node; + let FailSoft { + opening_alpha: alpha, + beta, + searched, + .. + } = node.answer; let board = &self.board; let ordering = &self.ordering; let cost = self.nodes - entered_at; @@ -1896,7 +1935,7 @@ impl AlphaBeta { } } - fn quiescence(&mut self, mut alpha: Score, beta: Score) -> Result { + fn quiescence(&mut self, alpha: Score, beta: Score) -> Result { // no repetition check: a capture cannot repeat a position and the // only quiet moves here are evasions, so a cycle needs a line of // nothing but mutual quiet checks, which the rail bounds @@ -1913,7 +1952,6 @@ impl AlphaBeta { // The full search never enters here in check (the extension // searches those nodes full width), so a check seen here was // delivered by a capture searched here. Fail soft. - let mut best = Score::MIN + 1; let in_check = self.board.in_check(); // a stalemate is not in check, so standing pat would read it as the // eval. Only a side with nothing but pawns and a king is asked: a @@ -1926,20 +1964,19 @@ impl AlphaBeta { return Ok(Value::clean(0)); } let standing = if in_check { None } else { Some(self.eval()) }; + // quiescence reads no draw by rule itself, but a probe trusting + // tainted scores can cut on one inside a capture tree. The root + // bounds are read by no rule here + let mut answer = FailSoft::open(alpha, beta, RootBounds::Neither, Taint::default()); if let Some(score) = standing { if score >= beta { return Ok(Value::clean(score)); } - best = score; - if score >= alpha { - alpha = score; - } + answer.stand_pat(score); } - let mut best_move: Option = None; - let old_alpha = alpha; // a probe at depth zero: any stored bound is deep enough here - let pv_play = match self.probe(alpha, beta, 0) { + let pv_play = match self.probe(answer.alpha, beta, 0) { Probe::Cut(value) => return Ok(value), Probe::Order(play) | Probe::Refused(play) => Some(play), Probe::Miss => None, @@ -1960,7 +1997,7 @@ impl AlphaBeta { // buffer is ordered with no losing band (`MoveOrdering::order_split`) if let Some(standing) = standing { if self.config.delta_margin - && !is_mate(alpha) + && !is_mate(answer.alpha) && !is_mate(beta) && moves.len() <= MOVE_LIST_INLINE { @@ -1971,7 +2008,7 @@ impl AlphaBeta { let m = moves[j]; let keep = match m.capture { Some(captured) if m.promote.is_none() => { - !short_of_alpha(standing, captured, alpha) + !short_of_alpha(standing, captured, answer.alpha) } _ => true, }; @@ -1991,10 +2028,6 @@ impl AlphaBeta { self.ordering .order_split(&self.board, &mut moves, captures, pv_play, None); - // quiescence reads no draw by rule itself, but a probe trusting - // tainted scores can cut on one inside a capture tree - let mut taint = Taint::default(); - let mut found_legal_move = false; for (i, m) in moves.iter().enumerate() { // two skips the reference does not make. A promotion is exempt // because the swap prices the arriving piece as the pawn that @@ -2005,8 +2038,9 @@ impl AlphaBeta { // already in hand. A sacrifice that would find a first mate is // skipped like any other losing capture if let (Some(standing), Some(captured)) = (standing, m.capture) { - if !is_mate(alpha) && m.promote.is_none() { - if self.config.delta_margin && short_of_alpha(standing, captured, alpha) { + if !is_mate(answer.alpha) && m.promote.is_none() { + if self.config.delta_margin && short_of_alpha(standing, captured, answer.alpha) + { continue; } // every capture behind the front is one the swap priced @@ -2017,39 +2051,33 @@ impl AlphaBeta { } } if self.board.make_move(m) { - found_legal_move = true; // undo before an abort can propagate - let result = self.quiescence(-beta, -alpha); + let result = self.quiescence(-beta, -answer.alpha); self.board.undo_move(); let value = -result?; - taint.absorb(value); - let score = value.score; - if score > best { - best = score; - best_move = Some(*m); - } - if score > alpha { - if score >= beta { - let value = taint.stamp(score); - self.store_cutoff(m, value, 0, standing.unwrap_or(NO_EVAL)); - return Ok(value); - } - alpha = score; + if answer.absorb(m, value) == Reached::Beta { + let value = answer.taint.stamp(value.score); + self.store_cutoff(m, value, 0, standing.unwrap_or(NO_EVAL)); + return Ok(value); } } } - if in_check && !found_legal_move { + // in check there is no stand pat, so nothing searched is no legal + // move. The count is asked first: written the other way round, the + // compiler kept the check flag on the stack rather than in a + // register and quiescence ran more instructions + if answer.searched == 0 && in_check { return Ok(Value::mated(self.board.line_ply)); } - let value = taint.stamp(best); - if let Some(play) = best_move { + let value = answer.taint.stamp(answer.best); + if let Some(play) = answer.best_move { self.store_answer( play, value, 0, - alpha != old_alpha, + answer.raised_alpha(), standing.unwrap_or(NO_EVAL), ); } @@ -2343,16 +2371,16 @@ impl AlphaBeta { ) -> Result, Aborted> { let Some(value) = self.search_child( &tt, - node.alpha, - node.beta, + node.answer.alpha, + node.answer.beta, node.depth, &Decision::First, - node.root_bounds, + node.answer.root_bounds, )? else { return Ok(None); }; - if node.absorb(&tt, value) != Reached::Beta { + if node.answer.absorb(&tt, value) != Reached::Beta { return Ok(None); } let cutting = census::Cutting { @@ -2364,7 +2392,7 @@ impl AlphaBeta { Ok(Some(self.cutoff( &tt, &[], - node.taint, + node.answer.taint, value.score, node.depth, static_eval, @@ -2473,7 +2501,7 @@ impl AlphaBeta { moves: &[Play], m: &Play, ) -> Decision { - if node.searched == 0 { + if node.answer.searched == 0 { return Decision::First; } if rules.skips(&self.deciding(), node, m) { @@ -2685,21 +2713,18 @@ impl AlphaBeta { let table_move = pv_play.filter(|tt| self.board.is_pseudo_legal(tt)); let mut node = Node::open( depth, - alpha, - beta, - root_bounds, in_check, self.memory_ply(), census::Table::of(pv_play.is_some(), table_move.is_some()), entered_at, - taint, + FailSoft::open(alpha, beta, root_bounds, taint), ); if let Some(tt) = table_move { if let Some(value) = self.search_table_move(tt, &mut node, static_eval)? { return Ok(value); } } - let tt_searched = node.searched > 0; + let tt_searched = node.answer.searched > 0; let mut moves = MoveList::new(); let captures = if in_check { @@ -2743,13 +2768,19 @@ impl AlphaBeta { if let Decision::Skip = decision { continue; } - let Some(value) = - self.search_child(m, node.alpha, node.beta, depth, &decision, node.root_bounds)? + let Some(value) = self.search_child( + m, + node.answer.alpha, + node.answer.beta, + depth, + &decision, + node.answer.root_bounds, + )? else { continue; }; made.mark(i); - match node.absorb(m, value) { + match node.answer.absorb(m, value) { Reached::Beta => { let cutting = census::Cutting { play: m, @@ -2765,7 +2796,14 @@ impl AlphaBeta { .enumerate() .filter(|(place, _)| made.holds(*place)) .map(|(_, tried)| tried); - return Ok(self.cutoff(m, tried, node.taint, value.score, depth, static_eval)); + return Ok(self.cutoff( + m, + tried, + node.answer.taint, + value.score, + depth, + static_eval, + )); } Reached::Alpha => { // the dropped moves stay dropped only while alpha is @@ -2774,7 +2812,7 @@ impl AlphaBeta { // or above beta and has cut the node off before // reaching here debug_assert!( - !(quiets.filtered && is_mate(node.alpha)), + !(quiets.filtered && is_mate(node.answer.alpha)), "a filtered node raised alpha to a mate without cutting off" ); } @@ -2782,7 +2820,7 @@ impl AlphaBeta { } debug_assert_eq!( made.count(), - node.searched, + node.answer.searched, "a bit for every move made and searched, and for no other" ); } @@ -2792,7 +2830,7 @@ impl AlphaBeta { // effort between held nodes and cut ones as well as away from both self.record_node(&node, &moves, quiets.scored, None); - if node.searched == 0 { + if node.answer.searched == 0 { // clean: mate and stalemate are properties of the position if in_check { return Ok(Value::mated(self.board.line_ply)); @@ -2800,10 +2838,11 @@ impl AlphaBeta { return Ok(Value::clean(0)); } let play = node + .answer .best_move .expect("a legal move was found, so one of them is best"); - let value = node.taint.stamp(node.best); - self.store_answer(play, value, depth, node.raised_alpha(), static_eval); + let value = node.answer.taint.stamp(node.answer.best); + self.store_answer(play, value, depth, node.answer.raised_alpha(), static_eval); Ok(value) } @@ -2876,14 +2915,12 @@ impl AlphaBeta { depth += 1; } - let opening_alpha = window.alpha; - let beta = window.beta; - let mut alpha = opening_alpha; - let mut root_bounds = RootBounds::Both; - // the fail soft answer, whether or not anything reached alpha - let mut top: Option<(Play, Score)> = None; - let mut found_legal_move = false; - let mut taint = Taint::default(); + let mut answer = FailSoft::open( + window.alpha, + window.beta, + RootBounds::Both, + Taint::default(), + ); // the previous depth's answer is tried first, which the aborted // iteration's swap in `iterative_deepening_search` rests on. The @@ -2894,7 +2931,7 @@ impl AlphaBeta { // the root reduces nothing for m in &moves { - let decision = if found_legal_move { + let decision = if answer.searched > 0 { Decision::Search { reduction: 0, staged: None, @@ -2902,28 +2939,25 @@ impl AlphaBeta { } else { Decision::First }; - match self.search_child(m, alpha, beta, depth, &decision, root_bounds) { + match self.search_child( + m, + answer.alpha, + answer.beta, + depth, + &decision, + answer.root_bounds, + ) { Err(Aborted) => { // only a move that beat the opening alpha may be // answered with - let answerable = (alpha != opening_alpha).then_some(top).flatten(); + let answerable = answer.best_move.filter(|_| answer.raised_alpha()); return SearchOutcome::Aborted( - answerable.map(|(play, score)| self.result_for(play, score)), + answerable.map(|play| self.result_for(play, answer.best)), ); } Ok(None) => {} Ok(Some(value)) => { - found_legal_move = true; - taint.absorb(value); - let score = value.score; - if top.is_none_or(|(_, best)| score > best) { - top = Some((*m, score)); - } - if score > alpha { - alpha = score; - root_bounds = root_bounds.alpha_raised(); - } - if score >= beta { + if answer.absorb(m, value) == Reached::Beta { // the rest are the wider re-search's to ask break; } @@ -2931,37 +2965,48 @@ impl AlphaBeta { } } - if !found_legal_move { + if answer.searched == 0 { // checkmate or stalemate. An expired fifty move counter is not // a way out: that draw is claimable and not automatic (FIDE // 9.3), so the side to move may still play return SearchOutcome::GameOver; } - let (play, score) = top.expect("a legal move was found, so one of them scored best"); - let value = taint.stamp(score); - // the answer and the floor are stored past the depth contest, - // because the reported line is read back from this slot. A ceiling - // is not stored, so the closest move is never promoted over a move - // it was not shown to beat - let bound = if score >= beta { - let landed = self - .transpositions - .record_floor_answer(&self.board, play, value, depth); - self.ghi.count_store(landed, value); + let play = answer + .best_move + .expect("a legal move was found, so one of them scored best"); + let score = answer.best; + let bound = if score >= answer.beta { ScoreBound::Lower - } else if score <= opening_alpha { - ScoreBound::Upper - } else { - let landed = self - .transpositions - .record_answer(&self.board, play, value, depth); - self.ghi.count_store(landed, value); + } else if answer.raised_alpha() { ScoreBound::Exact + } else { + ScoreBound::Upper }; + self.store_root_answer(play, answer.taint.stamp(score), depth, bound); SearchOutcome::Complete(self.result_for(play, score), bound) } + /// The root's answer to the table, past the taint policy and the depth + /// contest, because the reported line is read back from this slot. + /// Under `Skip` a tainted root answer is stored too, and the rare + /// tainted cutoff it then offers is refused as under `Refuse`. A + /// ceiling is not stored, so the closest move is never promoted over a + /// move it was not shown to beat. + fn store_root_answer(&mut self, play: Play, value: Value, depth: u8, bound: ScoreBound) { + let landed = match bound { + ScoreBound::Lower => { + self.transpositions + .record_floor_answer(&self.board, play, value, depth) + } + ScoreBound::Exact => self + .transpositions + .record_answer(&self.board, play, value, depth), + ScoreBound::Upper => return, + }; + self.ghi.count_store(landed, value); + } + /// Replay the line the table holds on a copy of the board, checking /// each stored move is legal there and stopping at a draw. pub fn pv_line(&self) -> PvLine { diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 272d4583..aa3f1a56 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -11,9 +11,9 @@ mod search { use crate::engine::Board; use crate::engine::Engine; use crate::engine::{ - Decision, Limits, MAX_PLY, NULL_MOVE_MIN_DEPTH, NULL_MOVE_REDUCTION, Play, RootBounds, - Score, ScoreBound, SearchConfig, SearchOutcome, SearchParameters, SearchResult, - TaintPolicy, Value, null_move_reduction, + ASPIRATION_MIN_DEPTH, Aspiration, Decision, Limits, MAX_PLY, NULL_MOVE_MIN_DEPTH, + NULL_MOVE_REDUCTION, Play, RootBounds, Score, ScoreBound, SearchConfig, SearchOutcome, + SearchParameters, SearchResult, TaintPolicy, Value, null_move_reduction, }; use crate::late_move::{ DEEP_REDUCTION, DEEP_REDUCTION_MIN_DEPTH, LATE_MOVE_MIN_DEPTH, LATE_MOVE_REDUCTION, @@ -1214,6 +1214,51 @@ mod search { assert_eq!(result.best_move, previous); } + /// A fresh engine's depth five search from the opening under `budget` + /// nodes, opened at a window far above what the position is worth, so + /// every root move fails low. The deepening never opens one that wide + /// of its last score, which is why this goes to the root directly. + fn failing_low(budget: u64) -> SearchOutcome { + let depth = ASPIRATION_MIN_DEPTH; + let window = Aspiration::open(Some(530), depth); + assert_eq!((window.alpha, window.beta), (500, 560)); + engine(Board::new()).search_root(depth, nodes_only(budget), None, window) + } + + #[test] + fn a_root_no_move_lifted_answers_a_ceiling_when_complete_and_nothing_when_aborted() { + let SearchOutcome::Complete(result, bound) = failing_low(u64::MAX) else { + panic!("an unlimited search did not complete"); + }; + assert!(result.score <= 500, "the opening is worth {}", result.score); + assert_eq!(bound, ScoreBound::Upper); + // one node short of the whole search stops in the last root move, + // with every move before it searched and none of them past alpha. + // The closest of them is never answered with + assert!(matches!( + failing_low(result.nodes - 1), + SearchOutcome::Aborted(None) + )); + } + + #[test] + fn a_root_whose_best_move_only_meets_alpha_answers_a_ceiling() { + // one half move short of the fifty move draw with no capture on + // the board, so every move scores the draw, exactly the alpha of a + // window opened at thirty. Meeting alpha does not raise it + let fen = "4k3/8/8/8/8/8/8/R3K3 w - - 99 120"; + let depth = ASPIRATION_MIN_DEPTH; + let window = Aspiration::open(Some(30), depth); + assert_eq!(window.alpha, 0); + let mut e = engine(Board::from_fen(fen).unwrap()); + let SearchOutcome::Complete(result, bound) = + e.search_root(depth, Limits::unlimited(), None, window) + else { + panic!("an unlimited search did not complete"); + }; + assert_eq!((result.score, bound), (0, ScoreBound::Upper)); + } + #[test] fn a_swapped_answer_is_reported_before_the_search_ends() { // the swap is the one answer no completed depth reported. A sweep of @@ -2503,6 +2548,24 @@ mod search { ); } + #[test] + fn the_root_stores_a_tainted_answer_under_the_skipping_policy() { + // one half move short of the fifty move draw with no capture on + // the board, so every reply is the tainted draw and nothing under + // the root stores. The root's answer is stored past the policy, + // since the reported line is read back from its slot + let fen = "4k3/8/8/8/8/8/8/R3K3 w - - 99 120"; + let skipping = SearchConfig { + taint: TaintPolicy::Skip, + ..SearchConfig::reference() + }; + let mut e = AlphaBeta::with_config(Board::from_fen(fen).unwrap(), TABLE_BYTES, skipping); + assert_eq!(completed(e.search(3)).score, 0); + let ghi = e.ghi(); + assert_eq!((ghi.stores, ghi.tainted_stores), (1, 1)); + assert_eq!(ghi.skipped_stores, 0, "a store under the root was offered"); + } + #[test] fn a_warm_cache_matches_a_cold_search() { let fens = [ @@ -3228,7 +3291,7 @@ mod cutoffs { use super::taught::{quiets, unmade_journey}; use crate::board::fens::SHARP_MIDDLEGAME; use crate::census::{self, Class, Cutting, Table}; - use crate::engine::{AlphaBeta, Board, Node, RootBounds, Score, SearchConfig}; + use crate::engine::{AlphaBeta, Board, FailSoft, Node, RootBounds, Score, SearchConfig}; use crate::play::Play; use crate::recorder::{Sampler, Window}; use crate::value::{Taint, Value}; @@ -3254,16 +3317,13 @@ mod cutoffs { ) -> Node { let mut node = Node::open( depth, - alpha, - beta, - RootBounds::Neither, false, ply, tt, entered_at, - Taint::default(), + FailSoft::open(alpha, beta, RootBounds::Neither, Taint::default()), ); - node.searched = searched; + node.answer.searched = searched; node } @@ -3418,8 +3478,8 @@ mod cutoffs { let (m, _) = quiets(&e); let moves = e.board.generate_moves(); let mut node = node(2, (-50, 60), Some(0), Table::Miss, 0, e.nodes); - node.absorb(&m, Value::clean(59)); - assert_eq!((node.alpha, node.beta), (59, 60)); + node.answer.absorb(&m, Value::clean(59)); + assert_eq!((node.answer.alpha, node.answer.beta), (59, 60)); e.census_event(&node, &moves, true, None); let sampled = e .disarm::() @@ -3472,7 +3532,7 @@ mod reductions { use super::taught::{quiets, unmade_journey}; use crate::board::fens::SHARP_MIDDLEGAME; use crate::census::Table; - use crate::engine::{AlphaBeta, Board, Decision, Node, RootBounds, Score}; + use crate::engine::{AlphaBeta, Board, Decision, FailSoft, Node, RootBounds, Score}; use crate::late_move; use crate::play::Play; use crate::recorder::{Sampler, Window}; @@ -3494,16 +3554,13 @@ mod reductions { let moves = e.board.generate_moves(); let mut node = Node::open( 0, - 0, - 1, - RootBounds::Neither, false, ply, Table::Miss, 0, - Taint::default(), + FailSoft::open(0, 1, RootBounds::Neither, Taint::default()), ); - node.searched = searched; + node.answer.searched = searched; let mut rules = late_move::Rules::new(&e.deciding(), &node, None); e.staged_reduction(m, &node, &mut rules, &moves) } @@ -3589,17 +3646,14 @@ mod reductions { let staged = staged(&e, &m, 5, Some(0)); let mut node = Node::open( 3, - -50, - 60, - RootBounds::Neither, false, Some(0), Table::Miss, e.nodes, - Taint::default(), + FailSoft::open(-50, 60, RootBounds::Neither, Taint::default()), ); - node.absorb(&raiser, Value::clean(10)); - assert_eq!(node.alpha, 10); + node.answer.absorb(&raiser, Value::clean(10)); + assert_eq!(node.answer.alpha, 10); let fen = e.board.to_fen(); e.ledger_skip(staged, &node); assert_eq!(e.board.to_fen(), fen, "the board was not left as it was"); @@ -3753,28 +3807,15 @@ mod reductions { } } -/// A full width node's answer as its moves come back, read off the node -/// with no search behind it but the one the table's move asks for. -mod node { - use crate::board::play_named; - use crate::census::Table; - use crate::engine::{AlphaBeta, Board, Node, Reached, RootBounds, Score}; +/// A search's fail soft answer as its moves come back, read with no board. +mod fail_soft { + use crate::engine::{FailSoft, Reached, RootBounds, Score}; use crate::play::Play; use crate::value::{Taint, Value}; use pretty_assertions::assert_eq; - fn open(depth: u8, alpha: Score, beta: Score, root_bounds: RootBounds) -> Node { - Node::open( - depth, - alpha, - beta, - root_bounds, - false, - Some(0), - Table::Miss, - 0, - Taint::default(), - ) + fn open(alpha: Score, beta: Score, root_bounds: RootBounds) -> FailSoft { + FailSoft::open(alpha, beta, root_bounds, Taint::default()) } /// A move for the answer to name. It reads the value and not the move. @@ -3792,40 +3833,98 @@ mod node { (RootBounds::Both, RootBounds::Beta), (RootBounds::Alpha, RootBounds::Neither), ] { - let mut node = open(4, -100, 100, from); + let mut answer = open(-100, 100, from); // under alpha, and at it, raise nothing - assert_eq!(node.absorb(&m, Value::clean(-150)), Reached::Neither); - assert_eq!(node.absorb(&m, Value::clean(-100)), Reached::Neither); - assert_eq!((node.alpha, node.root_bounds), (-100, from)); - assert_eq!(node.absorb(&m, Value::clean(20)), Reached::Alpha); - assert_eq!((node.alpha, node.root_bounds), (20, to)); - assert_eq!(node.absorb(&m, Value::clean(10)), Reached::Neither); - assert_eq!((node.alpha, node.root_bounds), (20, to)); - assert_eq!(node.absorb(&m, Value::clean(50)), Reached::Alpha); - assert_eq!((node.alpha, node.root_bounds), (50, to)); - // a cutoff answers the node and leaves the bounds where they + assert_eq!(answer.absorb(&m, Value::clean(-150)), Reached::Neither); + assert_eq!(answer.absorb(&m, Value::clean(-100)), Reached::Neither); + assert_eq!((answer.alpha, answer.root_bounds), (-100, from)); + assert_eq!(answer.absorb(&m, Value::clean(20)), Reached::Alpha); + assert_eq!((answer.alpha, answer.root_bounds), (20, to)); + assert_eq!(answer.absorb(&m, Value::clean(10)), Reached::Neither); + assert_eq!((answer.alpha, answer.root_bounds), (20, to)); + assert_eq!(answer.absorb(&m, Value::clean(50)), Reached::Alpha); + assert_eq!((answer.alpha, answer.root_bounds), (50, to)); + // a cutoff answers the search and leaves the bounds where they // stood - assert_eq!(node.absorb(&m, Value::clean(100)), Reached::Beta); - assert_eq!((node.alpha, node.beta, node.root_bounds), (50, 100, to)); - assert_eq!(node.searched, 6); + assert_eq!(answer.absorb(&m, Value::clean(100)), Reached::Beta); + assert_eq!( + (answer.alpha, answer.beta, answer.root_bounds), + (50, 100, to) + ); + assert_eq!(answer.searched, 6); } } - /// The answer is a ceiling until a move beats the alpha the node - /// opened with, and a score or a floor from then on. + /// The answer is a ceiling until a move beats the alpha it opened + /// with, and a score or a floor from then on. #[test] fn alpha_is_raised_after_a_rise_and_not_before() { let m = any_move(); - let mut node = open(4, -50, 50, RootBounds::Neither); - assert!(!node.raised_alpha()); - node.absorb(&m, Value::clean(-60)); - assert!(!node.raised_alpha(), "a move under alpha"); - node.absorb(&m, Value::clean(-50)); - assert!(!node.raised_alpha(), "a move at alpha"); - node.absorb(&m, Value::clean(0)); - assert!(node.raised_alpha()); - node.absorb(&m, Value::clean(-60)); - assert!(node.raised_alpha(), "a move after the rise"); + let mut answer = open(-50, 50, RootBounds::Neither); + assert!(!answer.raised_alpha()); + answer.absorb(&m, Value::clean(-60)); + assert!(!answer.raised_alpha(), "a move under alpha"); + answer.absorb(&m, Value::clean(-50)); + assert!(!answer.raised_alpha(), "a move at alpha"); + answer.absorb(&m, Value::clean(0)); + assert!(answer.raised_alpha()); + answer.absorb(&m, Value::clean(-60)); + assert!(answer.raised_alpha(), "a move after the rise"); + } + + /// The stand pat is the best so far and a floor under alpha, with no + /// move and no count. It is not a rise: the answer stays a ceiling + /// until a capture beats it. A stand pat under alpha leaves alpha. + #[test] + fn the_stand_pat_sets_best_and_alpha_and_counts_no_move() { + let m = any_move(); + let mut answer = open(-50, 50, RootBounds::Neither); + answer.stand_pat(20); + assert_eq!((answer.best, answer.alpha), (20, 20)); + assert_eq!((answer.best_move, answer.searched), (None, 0)); + assert!(!answer.raised_alpha(), "the stand pat read as a rise"); + assert_eq!(answer.absorb(&m, Value::clean(10)), Reached::Neither); + assert_eq!((answer.best, answer.best_move), (20, None)); + assert!(!answer.raised_alpha(), "a capture under the stand pat"); + assert_eq!(answer.absorb(&m, Value::clean(30)), Reached::Alpha); + assert!(answer.raised_alpha()); + + let mut low = open(-50, 50, RootBounds::Neither); + low.stand_pat(-80); + assert_eq!((low.best, low.alpha, low.searched), (-80, -50, 0)); + assert!(!low.raised_alpha()); + } + + /// A fresh answer has searched nothing until a move is absorbed, and + /// one move absorbed counts however badly it scored. Quiescence in + /// check and the root read a side with no legal move off that count. + #[test] + fn an_answer_counts_nothing_searched_until_a_move_is_absorbed() { + let mut answer = open(-50, 50, RootBounds::Neither); + assert_eq!((answer.searched, answer.best_move), (0, None)); + answer.absorb(&any_move(), Value::mated(3)); + assert_eq!(answer.searched, 1); + } +} + +/// A full width node's answer as its moves come back, read off the node +/// with no search behind it but the one the table's move asks for. +mod node { + use crate::board::play_named; + use crate::census::Table; + use crate::engine::{AlphaBeta, Board, FailSoft, Node, RootBounds, Score}; + use crate::value::Taint; + use pretty_assertions::assert_eq; + + fn open(depth: u8, alpha: Score, beta: Score, root_bounds: RootBounds) -> Node { + Node::open( + depth, + false, + Some(0), + Table::Miss, + 0, + FailSoft::open(alpha, beta, root_bounds, Taint::default()), + ) } /// The searched count is what the reductions read as a move's index: @@ -3844,12 +3943,12 @@ mod node { e.search_table_move(pinned, &mut node, crate::transposition::NO_EVAL), Ok(None) )); - assert_eq!(node.searched, 0, "the illegal move was counted"); - assert!(!node.raised_alpha()); + assert_eq!(node.answer.searched, 0, "the illegal move was counted"); + assert!(!node.answer.raised_alpha()); assert!(matches!( e.search_table_move(step, &mut node, crate::transposition::NO_EVAL), Ok(None) )); - assert_eq!(node.searched, 1, "the table's move was not counted"); + assert_eq!(node.answer.searched, 1, "the table's move was not counted"); } } diff --git a/arche-core/src/late_move.rs b/arche-core/src/late_move.rs index 078fbde8..6303a1df 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -469,8 +469,8 @@ fn shallow(config: &SearchConfig, board: &Board, node: &Node) -> Shallow { let admits = (config.quiet_futility || config.late_move_count) && (1..=SHALLOW_MAX_DEPTH).contains(&node.depth) && !node.in_check - && !is_mate(node.beta) - && !node.root_bounds.beta_is_roots() + && !is_mate(node.answer.beta) + && !node.answer.root_bounds.beta_is_roots() && board.has_non_pawn_material(); Shallow { from: if admits { 1 } else { usize::MAX }, @@ -514,11 +514,11 @@ impl Shallow { m: &Play, node: &Node, ) -> bool { - let searched = node.searched; - self.reached(searched, node.alpha) + let searched = node.answer.searched; + self.reached(searched, node.answer.alpha) && m.capture.is_none() && m.promote.is_none() - && (searched >= self.count || self.under_alpha(search, eval, node.alpha)) + && (searched >= self.count || self.under_alpha(search, eval, node.answer.alpha)) && !search .board .gives_check_with(check.get_or_insert_with(|| search.board.check_info()), m) @@ -527,9 +527,9 @@ impl Shallow { /// The half of `skips` that does not read the move. #[inline] fn active(&mut self, search: &Search, eval: &mut Option, node: &Node) -> bool { - let searched = node.searched; - self.reached(searched, node.alpha) - && (searched >= self.count || self.under_alpha(search, eval, node.alpha)) + let searched = node.answer.searched; + self.reached(searched, node.answer.alpha) + && (searched >= self.count || self.under_alpha(search, eval, node.answer.alpha)) } #[inline] @@ -565,8 +565,8 @@ fn admission(config: &SearchConfig, node: &Node) -> Admission { let admits = config.late_move_reductions && node.depth >= LATE_MOVE_MIN_DEPTH && !node.in_check - && !is_mate(node.beta) - && !node.root_bounds.beta_is_roots(); + && !is_mate(node.answer.beta) + && !node.answer.root_bounds.beta_is_roots(); Admission { from: if admits { LATE_MOVE_THRESHOLD @@ -579,7 +579,7 @@ fn admission(config: &SearchConfig, node: &Node) -> Admission { impl Admission { #[inline] fn admits(&self, node: &Node) -> bool { - node.searched >= self.from && !is_mate(node.alpha) + node.answer.searched >= self.from && !is_mate(node.answer.alpha) } } @@ -614,7 +614,7 @@ fn reduces(search: &Search, node: &Node, m: &Play) -> bool { fn admits(config: &SearchConfig, node: &Node) -> bool { config.late_move_reductions && node.depth >= LATE_MOVE_MIN_DEPTH - && node.searched >= LATE_MOVE_THRESHOLD + && node.answer.searched >= LATE_MOVE_THRESHOLD && node_admits(node) } @@ -624,9 +624,9 @@ fn admits(config: &SearchConfig, node: &Node) -> bool { #[inline] fn node_admits(node: &Node) -> bool { !node.in_check - && !is_mate(node.alpha) - && !is_mate(node.beta) - && !node.root_bounds.beta_is_roots() + && !is_mate(node.answer.alpha) + && !is_mate(node.answer.beta) + && !node.answer.root_bounds.beta_is_roots() } /// Whether a move `reduces` already accepted is skipped, or scouted a ply @@ -636,7 +636,7 @@ fn node_admits(node: &Node) -> bool { /// exemption arm measured checks as the scout's blind spot. The check test /// runs last because the slider probes cost more than everything before it. fn gate(search: &Search, node: &Node, rules: &mut Rules, moves: &[Play], m: &Play) -> Verdict { - let searched = node.searched; + let searched = node.answer.searched; if (!search.config.deep_reductions && !search.config.late_move_pruning) || node.depth < DEEP_REDUCTION_MIN_DEPTH { @@ -650,8 +650,8 @@ fn gate(search: &Search, node: &Node, rules: &mut Rules, moves: &[Play], m: &Pla hist_milli: f.hist_milli(), killer: f.killer, tt: f.tt, - eval_beta: eval - i64::from(node.beta), - alpha_gap: i64::from(node.alpha) - eval, + eval_beta: eval - i64::from(node.answer.beta), + alpha_gap: i64::from(node.answer.alpha) - eval, generated: f.generated, }); if search.config.late_move_pruning && score <= LATE_MOVE_PRUNING_THRESHOLD { @@ -725,7 +725,7 @@ pub(crate) fn features( .ply .map_or([None, None], |ply| search.ordering.killers_at(ply)); Features { - index: node.searched, + index: node.answer.searched, generated: moves.len(), history: search.ordering.history_score(search.board.active_color, m), // a fraction of a marked down largest would be on no scale @@ -768,7 +768,7 @@ mod tests { }; use crate::board::{Board, MoveList, fens, play_named}; use crate::census::Table; - use crate::engine::{MAX_PLY, Node, RootBounds, SearchConfig}; + use crate::engine::{FailSoft, MAX_PLY, Node, RootBounds, SearchConfig}; use crate::misc::Score; use crate::ordering::MoveOrdering; use crate::play::Play; @@ -897,14 +897,11 @@ mod tests { fn node(&self, depth: u8, alpha: Score, beta: Score) -> (Node, Rules) { let node = Node::open( depth, - alpha, - beta, - self.root_bounds, self.in_check, self.ply, self.tt, 0, - Taint::default(), + FailSoft::open(alpha, beta, self.root_bounds, Taint::default()), ); let mut rules = Rules::new(&self.search(), &node, self.eval); rules.history_max = self.history_max; @@ -931,7 +928,7 @@ mod tests { self.eval = None; self.history_max = None; let (mut node, mut rules) = self.node(depth, alpha, beta); - node.searched = searched; + node.answer.searched = searched; let verdict = decide(&self.search(), &node, &mut rules, &self.moves, m); self.keep(&rules); verdict @@ -986,8 +983,8 @@ mod tests { searched: usize, alpha: Score, ) -> bool { - node.alpha = alpha; - node.searched = searched; + node.answer.alpha = alpha; + node.answer.searched = searched; let skips = rules.skips(&self.search(), node, m); self.keep(rules); skips @@ -998,7 +995,7 @@ mod tests { /// bounds stand at nothing, since the features read neither. fn features(&mut self, m: &Play, searched: usize) -> Features { let (mut node, mut rules) = self.node(0, 0, 1); - node.searched = searched; + node.answer.searched = searched; let features = features(&self.search(), &node, &mut rules, &self.moves, m); self.keep(&rules); features @@ -1091,18 +1088,15 @@ mod tests { let config = reducing(); let mut node = Node::open( LATE_MOVE_MIN_DEPTH, - 0, - 100, - RootBounds::Neither, false, None, Table::Miss, 0, - Taint::default(), + FailSoft::open(0, 100, RootBounds::Neither, Taint::default()), ); let half = admission(&config, &node); for searched in 0..64 { - node.searched = searched; + node.answer.searched = searched; assert_eq!( half.admits(&node), searched >= LATE_MOVE_THRESHOLD, @@ -1110,16 +1104,18 @@ mod tests { ); } // alpha a mate against the side to move, under an ordinary beta - node.alpha = -29_500; - assert!(crate::value::is_mate(node.alpha) && !crate::value::is_mate(node.beta)); + node.answer.alpha = -29_500; + assert!( + crate::value::is_mate(node.answer.alpha) && !crate::value::is_mate(node.answer.beta) + ); for searched in 0..64 { - node.searched = searched; + node.answer.searched = searched; assert!(!half.admits(&node), "a mate alpha at {searched} searched"); } // and a node that opened there admits nothing either let half = admission(&config, &node); for searched in 0..64 { - node.searched = searched; + node.answer.searched = searched; assert!(!half.admits(&node), "opened at a mate, {searched} searched"); } }