From a55ceae1a0e3b27b9ffb4092743517b6a9e8e965 Mon Sep 17 00:00:00 2001 From: blacktoe Date: Sun, 4 Oct 2026 21:24:57 +1100 Subject: [PATCH] refactor(search): Answer quiescence and the root through one fail soft value A search's fail soft answer (the window as it stands, the best score and the move that scored it, the taint, the count of moves searched, and whether alpha rose) was written three times. `Node` held it with `absorb` and `raised_alpha`. Quiescence kept six locals for it and repeated `absorb` by hand in its capture loop. `search_root` kept seven and did the same in its move loop, with `alpha != opening_alpha` as whether an aborted iteration may answer. The answer half of `Node` is now its own value, `FailSoft`, which `Node` holds as `answer`. It has `open`, `absorb` (which still moves alpha and the root bounds together at a rise) and `raised_alpha`, and a `stand_pat` step for quiescence that sets the best score and alpha from the static score with no move and no count. Quiescence opens one before its stand pat test, absorbs each capture, reads a side in check with no legal move off a searched count of zero, and stores as before (its standing evaluation with the entry) with `raised_alpha` from the answer. The root opens one at the aspiration window with both root bounds, absorbs each move, stops at beta, answers with the best move and score, and reads whether an aborted iteration may answer off `raised_alpha`. The late move rules, the census, the effort instrument and the ledger read the bounds and the count through `node.answer`, a field path that costs nothing over the old one. `Node::open` takes the opened answer, and its `too_many_arguments` allowance goes. The name keeps clear of the protocol's `Answer` and the effort instrument's `Answered`, which are finished answers rather than one being built. `absorb` asks beta only of a score above alpha, as quiescence and the root did, where `Node` asked beta first. With alpha under beta the two give the same answer, and a move that fails low (most of them) asks one question. A debug assertion holds the window open. Asking beta first measured 206,734 more instructions over the depth 5 bench (0.1%), most of it in quiescence. The root's store keeps its exemption from the taint policy, now as `store_root_answer` with the reason beside it: the reported line is read back from the root's slot, so the root stores whatever its taint, and under the skip policy the rare tainted cutoff that slot offers is refused as under refuse. No test held the exemption, so one now searches a root a half move short of the fifty move draw under the skip policy and reads that its one tainted store landed. The answer's tests move to the new value, and two more read it with no board: the stand pat sets the best score and alpha and counts no move, and a fresh answer counts nothing searched until a move is absorbed. The bench and a fixed depth search open the root at the full window, so no test reached the root's ceiling or its abort below alpha. Two more call the root at a window no move beats. A depth five search of the opening at 500 to 560 answers a ceiling, and the same search stopped one node short answers nothing rather than the closest move. A root whose every move scores exactly alpha answers a ceiling too, since meeting alpha does not raise it. The tree is unchanged. The bench counts 5,965,973, and its per position rows print as on the parent under each of the four taint policies, with the time masked. Every instrument's rows at depth 5 print as on the parent: cutoffs, reductions, effort with no switch off and with null_move off, residuals, and forced with every kind and with null_move, reverse_futility, and skip and trusted_scout from depth 2 alone, at their default sampling and at every 1. The info lines of `go depth 12` from the start position through the protocol match the parent's, with the time masked. Callgrind over the depth 5 bench counts 194,139,961 instructions on the parent and 194,078,291 here, -61,670 (-0.03%). By symbol, summing each function's two copies, alpha_beta is 180,733 fewer and search_root 21,250, windowed is unchanged, and quiescence is 140,252 more (1.2% of its own). Quiescence's own lines in engine.rs lost 228,276 and the integer comparisons inlined into it gained 267,714, much of that the same comparisons credited to the operator's line rather than the caller's. Its mate test asks the searched count before the check flag. Written the other way round, the compiler kept the flag on the stack rather than in a register, every test of it read memory, and quiescence ran 45,834 more. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 --- arche-core/src/engine.rs | 343 +++++++++++++++++++-------------- arche-core/src/engine/tests.rs | 239 ++++++++++++++++------- arche-core/src/late_move.rs | 70 ++++--- 3 files changed, 396 insertions(+), 256 deletions(-) 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"); } }