diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 458b59f1..a5c59d76 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -155,38 +155,34 @@ impl Searched { } } -/// What a full width node settled before its move loop, as the census and -/// the effort instrument record it. -#[derive(Clone, Copy)] -struct NodeFacts { - depth: u8, - /// The alpha the node opened with. - alpha: Score, - beta: Score, - in_check: bool, - /// The ply the quiet memories are read at, or none. - ply: Option, - tt: census::Table, - /// The node count on entry, which prices what the node cost. - entered_at: u64, -} - -/// A full width node's answer as its moves come back. 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. -struct NodeAnswer { +/// 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, /// The bounds as they stand, alpha raised by every move that beat it. - alpha: Score, - beta: Score, + 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. + pub(crate) searched: usize, /// The alpha the node opened with, which says whether the answer is a - /// ceiling. + /// ceiling and is the window the census records. opening_alpha: Score, - root_bounds: RootBounds, + /// The node count on entry, which prices what the node cost. + entered_at: u64, best: Score, best_move: Option, taint: Taint, - /// How many moves the node has made and searched. - searched: usize, } /// What a searched move did to the node's bounds. @@ -199,17 +195,36 @@ enum Reached { Neither, } -impl NodeAnswer { - fn open(alpha: Score, beta: Score, root_bounds: RootBounds, taint: Taint) -> Self { +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 { Self { + depth, alpha, beta, - opening_alpha: alpha, root_bounds, + in_check, + ply, + tt, + searched: 0, + opening_alpha: alpha, + entered_at, best: Score::MIN + 1, best_move: None, taint, - searched: 0, } } @@ -241,12 +256,15 @@ impl NodeAnswer { } } -/// What the move loop does with one move. +/// What the move loop does with one move, which is also how it asks for +/// the child search. enum Decision { /// Not searched at all. Skip, - /// Searched, scouted `reduction` plies shallower first when that is not - /// zero, with the ledger's staging of the scout when one is armed. + /// The first move the node searches, at the window as it stands. + First, + /// A later move, scouted `reduction` plies shallower first when that is + /// not zero, with the ledger's staging of the scout when one is armed. Search { reduction: u8, staged: Option, @@ -1273,13 +1291,13 @@ impl AlphaBeta { fn staged_reduction( &self, m: &Play, - searched: usize, - node: &mut late_move::Node, + node: &Node, + rules: &mut late_move::Rules, moves: &[Play], ) -> reduction::Staged { reduction::Staged { play: *m, - features: late_move::features(&self.deciding(), node, moves, m, searched), + features: late_move::features(&self.deciding(), node, rules, moves, m), } } @@ -1299,10 +1317,11 @@ impl AlphaBeta { /// the record alone, and one that turns out illegal is not recorded. /// The fen and the sampling key are the position the move leaves, as /// for a scouted move, so the replay reads a skipped row as it reads a - /// low one. + /// low one. The bounds are the node's as the move is reached. #[cold] #[inline(never)] - fn ledger_skip(&mut self, staged: reduction::Staged, depth: u8, alpha: Score, beta: Score) { + fn ledger_skip(&mut self, staged: reduction::Staged, node: &Node) { + let (depth, alpha, beta) = (node.depth, node.alpha, node.beta); let board = &mut self.board; let Some(ledger) = self.ledger.as_mut() else { return; @@ -1400,7 +1419,8 @@ impl AlphaBeta { } /// One node answering out of the move loop, offered to the census: - /// which move cut it off, or none when the loop ran out. + /// which move cut it off, or none when the loop ran out. The window is + /// the one the node opened with. /// /// The killers and the history are read before `cutoff` teaches them /// the move, so a row says what the node knew when it chose. The @@ -1411,21 +1431,22 @@ impl AlphaBeta { #[inline(never)] fn census_event( &mut self, - facts: NodeFacts, + node: &Node, moves: &[Play], - searched: usize, quiets_scored: bool, cutting: Option>, ) { - let NodeFacts { + let Node { depth, - alpha, + opening_alpha: alpha, beta, in_check, ply, tt, + searched, entered_at, - } = facts; + .. + } = *node; let board = &self.board; let ordering = &self.ordering; let cost = self.nodes - entered_at; @@ -1970,28 +1991,28 @@ impl AlphaBeta { /// One child of a full width node, or nothing when the move is not /// legal here. The undo comes before the abort propagates; propagating /// is what keeps an aborted frame's meaningless score away from every - /// store above. `reduction` is how many plies shallower the scout runs, - /// zero for no scout, and `staged` is what the ledger has about the - /// move, or nothing. - // two arguments past clippy's limit: the staging travelling as a - // parameter rather than as a field of the engine, and the root bounds. + /// store above. `decision` says whether the move is the node's first or + /// a later one, and a later one carries the scout's reduction and the + /// ledger's staging, which travels as a parameter rather than as a + /// field of the engine. A skipped move never reaches here. #[inline(always)] - #[allow(clippy::too_many_arguments)] fn search_child( &mut self, m: &Play, alpha: Score, beta: Score, depth: u8, - first: bool, - reduction: u8, + decision: &Decision, root_bounds: RootBounds, - staged: Option<&reduction::Staged>, ) -> Result, Aborted> { + debug_assert!( + !matches!(decision, Decision::Skip), + "the loop searched a move it decided to skip" + ); if !self.board.make_move(m) { return Ok(None); } - let result = self.windowed(alpha, beta, depth, first, reduction, root_bounds, staged); + let result = self.windowed(alpha, beta, depth, decision, root_bounds); self.board.undo_move(); Ok(Some(result?)) } @@ -2011,27 +2032,27 @@ impl AlphaBeta { /// /// A body of its own rather than `search_child`'s so that an abort from /// any pass runs through the one undo there. - #[allow(clippy::too_many_arguments)] fn windowed( &mut self, alpha: Score, beta: Score, depth: u8, - first: bool, - reduction: u8, + decision: &Decision, root_bounds: RootBounds, - staged: Option<&reduction::Staged>, ) -> Result { - if first { - debug_assert!(reduction == 0, "a node's first move is never reduced"); - return Ok(-self.alpha_beta( - -beta, - -alpha, - depth - 1, - true, - root_bounds.child(ChildSearch::FirstMove), - )?); - } + let (reduction, staged) = match decision { + Decision::First => { + return Ok(-self.alpha_beta( + -beta, + -alpha, + depth - 1, + true, + root_bounds.child(ChildSearch::FirstMove), + )?); + } + Decision::Search { reduction, staged } => (*reduction, staged.as_ref()), + Decision::Skip => unreachable!("a skipped move is never searched"), + }; let mut tainted = false; if reduction > 0 { // `late_move::amount` clamps the reduction to `depth - 2` @@ -2110,28 +2131,21 @@ impl AlphaBeta { /// The table's move, searched before the rest are generated: it sorts /// ahead of everything else, so the nodes it cuts never generate or /// sort at all, and the tree searched is unchanged. A cutoff answers - /// the node. Otherwise the answer absorbs what the move scored, or + /// the node. Otherwise the node absorbs what the move scored, or /// nothing when the move was not legal here. - fn search_table_move( - &mut self, - tt: Play, - facts: NodeFacts, - answer: &mut NodeAnswer, - ) -> Result, Aborted> { + fn search_table_move(&mut self, tt: Play, node: &mut Node) -> Result, Aborted> { let Some(value) = self.search_child( &tt, - answer.alpha, - answer.beta, - facts.depth, - true, - 0, - answer.root_bounds, - None, + node.alpha, + node.beta, + node.depth, + &Decision::First, + node.root_bounds, )? else { return Ok(None); }; - if answer.absorb(&tt, value) != Reached::Beta { + if node.absorb(&tt, value) != Reached::Beta { return Ok(None); } let cutting = census::Cutting { @@ -2139,13 +2153,13 @@ impl AlphaBeta { reduced: false, table: true, }; - self.record_node(facts, &[], 1, false, Some(cutting)); + self.record_node(node, &[], false, Some(cutting)); Ok(Some(self.cutoff( &tt, &[], - answer.taint, + node.taint, value.score, - facts.depth, + node.depth, ))) } @@ -2165,8 +2179,8 @@ impl AlphaBeta { moves: &mut MoveList, i: usize, quiets: &mut QuietOrder, - node: &mut late_move::Node, - searched: usize, + node: &Node, + rules: &mut late_move::Rules, ) -> Option { let front = quiets.front; if i == front { @@ -2197,13 +2211,13 @@ impl AlphaBeta { let end = *end; if i + 1 >= end { quiets.lazy = None; - } else if node.shallow_active(&self.deciding(), searched) { + } else if rules.shallow_active(&self.deciding(), node) { let kept = self.ordering.keep_unskippable( &self.board, &mut moves[front..end], i - front, ply, - &mut node.check, + &mut rules.check, ); // a run whose every move survives drops none, and leaves // no run to step past @@ -2237,33 +2251,38 @@ impl AlphaBeta { /// only where the node admits one. A move passed over is never made, so /// whether it was legal is never learned and nothing is taught about /// it. The ledger's staged half travels to the scout as a parameter, - /// so the reduced moves inside it cannot mistake it for their own. + /// so the reduced moves inside it cannot mistake it for their own. The + /// first move the node searches is no late move, and none of this is + /// asked of it. #[inline(always)] fn late_move_decision( &mut self, - node: &mut late_move::Node, + node: &Node, + rules: &mut late_move::Rules, moves: &[Play], m: &Play, - searched: usize, ) -> Decision { - if node.skips(&self.deciding(), m, searched) { - self.record_skip(node, moves, m, searched); + if node.searched == 0 { + return Decision::First; + } + if rules.skips(&self.deciding(), node, m) { + self.record_skip(node, rules, moves, m); return Decision::Skip; } - if !node.admits(&self.deciding(), searched) || m.capture.is_some() || m.promote.is_some() { + if !rules.admits(node) || m.capture.is_some() || m.promote.is_some() { return Decision::Search { reduction: 0, staged: None, }; } - match late_move::decide_admitted(&self.deciding(), node, moves, m, searched) { + match late_move::decide_admitted(&self.deciding(), node, rules, moves, m) { late_move::Verdict::Skip => { - self.record_skip(node, moves, m, searched); + self.record_skip(node, rules, moves, m); Decision::Skip } late_move::Verdict::Scout(reduction) => Decision::Search { reduction, - staged: self.stage_scout(node, moves, m, searched, reduction), + staged: self.stage_scout(node, rules, moves, m, reduction), }, } } @@ -2333,33 +2352,26 @@ impl AlphaBeta { #[inline(always)] fn record_node( &mut self, - facts: NodeFacts, + node: &Node, moves: &[Play], - searched: usize, quiets_scored: bool, cutting: Option>, ) { if self.census.is_some() { - self.census_event(facts, moves, searched, quiets_scored, cutting); + self.census_event(node, moves, quiets_scored, cutting); } if self.effort.is_some() { - self.effort_event(facts.depth, cutting.is_some(), facts.entered_at); + self.effort_event(node.depth, cutting.is_some(), node.entered_at); } } /// A move the node passes over, offered to the ledger. Behind a bare /// check of the slot, so the search's own path stages nothing. #[inline(always)] - fn record_skip( - &mut self, - node: &mut late_move::Node, - moves: &[Play], - m: &Play, - searched: usize, - ) { + fn record_skip(&mut self, node: &Node, rules: &mut late_move::Rules, moves: &[Play], m: &Play) { if self.ledger.is_some() { - let staged = self.staged_reduction(m, searched, node, moves); - self.ledger_skip(staged, node.depth, node.alpha, node.beta); + let staged = self.staged_reduction(m, node, rules, moves); + self.ledger_skip(staged, node); } } @@ -2368,14 +2380,14 @@ impl AlphaBeta { #[inline(always)] fn stage_scout( &mut self, - node: &mut late_move::Node, + node: &Node, + rules: &mut late_move::Rules, moves: &[Play], m: &Play, - searched: usize, reduction: u8, ) -> Option { if reduction > 0 && self.ledger.is_some() { - Some(self.staged_reduction(m, searched, node, moves)) + Some(self.staged_reduction(m, node, rules, moves)) } else { None } @@ -2437,23 +2449,24 @@ impl AlphaBeta { return Ok(value); } - let mut answer = NodeAnswer::open(alpha, beta, root_bounds, taint); let table_move = pv_play.filter(|tt| self.board.is_pseudo_legal(tt)); - let facts = NodeFacts { + let mut node = Node::open( depth, alpha, beta, + root_bounds, in_check, - ply: self.memory_ply(), - tt: census::Table::of(pv_play.is_some(), table_move.is_some()), + self.memory_ply(), + census::Table::of(pv_play.is_some(), table_move.is_some()), entered_at, - }; + taint, + ); if let Some(tt) = table_move { - if let Some(value) = self.search_table_move(tt, facts, &mut answer)? { + if let Some(value) = self.search_table_move(tt, &mut node)? { return Ok(value); } } - let tt_searched = answer.searched > 0; + let tt_searched = node.searched > 0; let mut moves = MoveList::new(); let captures = if in_check { @@ -2463,7 +2476,7 @@ impl AlphaBeta { }; let ordered = self.ordering - .order_split(&self.board, &mut moves, captures, pv_play, facts.ply); + .order_split(&self.board, &mut moves, captures, pv_play, node.ply); // `order_split` sorts by `pv_play`, and the search played // `table_move`, which differ when `is_pseudo_legal` refused the move let tt_at = if table_move.is_some() { @@ -2471,18 +2484,8 @@ impl AlphaBeta { } else { None }; - let mut quiets = QuietOrder::new(&ordered, facts.ply); - let mut node = late_move::Node::new( - &self.deciding(), - depth, - answer.alpha, - beta, - answer.root_bounds, - in_check, - facts.ply, - facts.tt, - eval, - ); + let mut quiets = QuietOrder::new(&ordered, node.ply); + let mut rules = late_move::Rules::new(&self.deciding(), &node, eval); // a skipped move has no bit, and nor has one that turned out illegal let mut made = Searched::default(); let len = moves.len(); @@ -2490,8 +2493,7 @@ impl AlphaBeta { while next < len { let i = next; next += 1; - if let Some(past) = - self.order_quiets_at(&mut moves, i, &mut quiets, &mut node, answer.searched) + if let Some(past) = self.order_quiets_at(&mut moves, i, &mut quiets, &node, &mut rules) { next = past; continue; @@ -2504,37 +2506,24 @@ impl AlphaBeta { } continue; } - debug_assert_eq!( - node.alpha, answer.alpha, - "the node was not handed the alpha standing" - ); - let Decision::Search { reduction, staged } = - self.late_move_decision(&mut node, &moves, m, answer.searched) - else { + let decision = self.late_move_decision(&node, &mut rules, &moves, m); + if let Decision::Skip = decision { continue; - }; - let Some(value) = self.search_child( - m, - answer.alpha, - answer.beta, - depth, - answer.searched == 0, - reduction, - answer.root_bounds, - staged.as_ref(), - )? + } + let Some(value) = + self.search_child(m, node.alpha, node.beta, depth, &decision, node.root_bounds)? else { continue; }; made.mark(i); - match answer.absorb(m, value) { + match node.absorb(m, value) { Reached::Beta => { let cutting = census::Cutting { play: m, - reduced: reduction > 0, + reduced: matches!(decision, Decision::Search { reduction: 1.., .. }), table: false, }; - self.record_node(facts, &moves, answer.searched, quiets.scored, Some(cutting)); + self.record_node(&node, &moves, quiets.scored, Some(cutting)); // the moves the node searched, not the whole list: the // history marks down what the node asked and got // nothing from @@ -2543,7 +2532,7 @@ impl AlphaBeta { .enumerate() .filter(|(place, _)| made.holds(*place)) .map(|(_, tried)| tried); - return Ok(self.cutoff(m, tried, answer.taint, value.score, depth)); + return Ok(self.cutoff(m, tried, node.taint, value.score, depth)); } Reached::Alpha => { // the dropped moves stay dropped only while alpha is @@ -2552,16 +2541,15 @@ impl AlphaBeta { // or above beta and has cut the node off before // reaching here debug_assert!( - !(quiets.filtered && is_mate(answer.alpha)), + !(quiets.filtered && is_mate(node.alpha)), "a filtered node raised alpha to a mate without cutting off" ); - node.raised(answer.alpha, answer.root_bounds); } Reached::Neither => {} } debug_assert_eq!( made.count(), - answer.searched, + node.searched, "a bit for every move made and searched, and for no other" ); } @@ -2569,20 +2557,20 @@ impl AlphaBeta { // the held half, at the same rate: a cut-only stream would // reproduce the censoring the census measures, and a rule moves // effort between held nodes and cut ones as well as away from both - self.record_node(facts, &moves, answer.searched, quiets.scored, None); + self.record_node(&node, &moves, quiets.scored, None); - if answer.searched == 0 { + if node.searched == 0 { // clean: mate and stalemate are properties of the position if in_check { return Ok(Value::mated(self.board.line_ply)); } return Ok(Value::clean(0)); } - let play = answer + let play = node .best_move .expect("a legal move was found, so one of them is best"); - let value = answer.taint.stamp(answer.best); - self.store_answer(play, value, depth, answer.raised_alpha()); + let value = node.taint.stamp(node.best); + self.store_answer(play, value, depth, node.raised_alpha()); Ok(value) } @@ -2673,16 +2661,15 @@ impl AlphaBeta { // the root reduces nothing for m in &moves { - match self.search_child( - m, - alpha, - beta, - depth, - !found_legal_move, - 0, - root_bounds, - None, - ) { + let decision = if found_legal_move { + Decision::Search { + reduction: 0, + staged: None, + } + } else { + Decision::First + }; + match self.search_child(m, alpha, beta, depth, &decision, root_bounds) { Err(Aborted) => { // only a move that beat the opening alpha may be // answered with diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index f188b124..3dd3cf1e 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::{ - Limits, MAX_PLY, NULL_MOVE_MIN_DEPTH, NULL_MOVE_REDUCTION, Play, RootBounds, Score, - ScoreBound, SearchConfig, SearchOutcome, SearchParameters, SearchResult, TaintPolicy, - Value, null_move_reduction, + 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, @@ -27,6 +27,15 @@ mod search { use std::sync::atomic::{AtomicBool, Ordering}; use std::time; + /// A later move as the loop asks for it, scouted `reduction` plies + /// shallower first when that is not zero, with no ledger staging. + fn later(reduction: u8) -> Decision { + Decision::Search { + reduction, + staged: None, + } + } + /// The default table is 256MB, and one per test dominated the suite's /// memory and run time. const TABLE_BYTES: usize = 16 * 1024 * 1024; @@ -315,10 +324,8 @@ mod search { Score::MIN + 2, Score::MAX, 2, - true, - 0, + &Decision::First, RootBounds::Both, - None, ) else { panic!("an unlimited search aborted"); }; @@ -335,7 +342,7 @@ mod search { Value::clean(-alpha - 1), SEEDED_DEPTH )); - let Ok(value) = e.windowed(alpha, beta, 2, false, 0, RootBounds::Neither, None) else { + let Ok(value) = e.windowed(alpha, beta, 2, &later(0), RootBounds::Neither) else { panic!("an unlimited search aborted"); }; assert_eq!(value.score, exact.score); @@ -1982,10 +1989,8 @@ mod search { Score::MIN + 2, Score::MAX, DEPTH, - true, - 0, + &Decision::First, RootBounds::Both, - None, ) else { panic!("an unlimited search aborted"); }; @@ -2001,10 +2006,8 @@ mod search { alpha, alpha + 1, DEPTH, - false, - LATE_MOVE_REDUCTION, + &later(LATE_MOVE_REDUCTION), RootBounds::Neither, - None, ) else { panic!("an unlimited search aborted"); }; @@ -2012,8 +2015,7 @@ mod search { assert_eq!(value, scout_value); let mut probe = at_reducible_child(SearchConfig::reference()); - let Ok(unreduced) = - probe.windowed(alpha, alpha + 1, DEPTH, false, 0, RootBounds::Neither, None) + let Ok(unreduced) = probe.windowed(alpha, alpha + 1, DEPTH, &later(0), RootBounds::Neither) else { panic!("an unlimited search aborted"); }; @@ -2038,10 +2040,8 @@ mod search { Score::MIN + 2, Score::MAX, DEPTH, - true, - 0, + &Decision::First, RootBounds::Both, - None, ) else { panic!("an unlimited search aborted"); }; @@ -2056,7 +2056,7 @@ mod search { let mut then_probed = at_reducible_child(SearchConfig::reference()); scout(&mut then_probed, alpha, DEPTH); let Ok(unreduced) = - then_probed.windowed(alpha, beta, DEPTH, false, 0, RootBounds::Neither, None) + then_probed.windowed(alpha, beta, DEPTH, &later(0), RootBounds::Neither) else { panic!("an unlimited search aborted"); }; @@ -2070,10 +2070,8 @@ mod search { alpha, beta, DEPTH, - false, - LATE_MOVE_REDUCTION, + &later(LATE_MOVE_REDUCTION), RootBounds::Neither, - None, ) else { panic!("an unlimited search aborted"); }; @@ -2174,10 +2172,8 @@ mod search { Score::MIN + 2, Score::MAX, DEPTH, - true, - 0, + &Decision::First, RootBounds::Both, - None, ) else { panic!("an unlimited search aborted"); }; @@ -2203,10 +2199,8 @@ mod search { alpha, alpha + 1, DEPTH, - false, - DEEP_REDUCTION, + &later(DEEP_REDUCTION), RootBounds::Neither, - None, ) else { panic!("an unlimited search aborted"); }; @@ -2218,10 +2212,8 @@ mod search { alpha, alpha + 1, DEPTH, - false, - LATE_MOVE_REDUCTION, + &later(LATE_MOVE_REDUCTION), RootBounds::Neither, - None, ) else { panic!("an unlimited search aborted"); }; @@ -3157,9 +3149,10 @@ 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, NodeFacts, Score, SearchConfig}; + use crate::engine::{AlphaBeta, Board, Node, RootBounds, Score, SearchConfig}; use crate::play::Play; use crate::recorder::{Sampler, Window}; + use crate::value::{Taint, Value}; use pretty_assertions::assert_eq; const TABLE_BYTES: usize = 1024 * 1024; @@ -3170,6 +3163,31 @@ mod cutoffs { e } + /// A node as the loop hands it to the recorder: opened at these facts + /// with `searched` moves made and searched, out of check. + fn node( + depth: u8, + (alpha, beta): (Score, Score), + ply: Option, + tt: Table, + searched: usize, + entered_at: u64, + ) -> Node { + let mut node = Node::open( + depth, + alpha, + beta, + RootBounds::Neither, + false, + ply, + tt, + entered_at, + Taint::default(), + ); + node.searched = searched; + node + } + /// An engine nobody asked a census of holds none. #[test] fn an_engine_records_no_census_until_it_is_asked_to() { @@ -3199,17 +3217,8 @@ mod cutoffs { let moves = e.board.generate_moves(); let (alpha, beta): (Score, Score) = (10, 11); e.census_event( - NodeFacts { - depth: 3, - alpha, - beta, - in_check: false, - ply: Some(0), - tt: Table::Miss, - entered_at: e.nodes, - }, + &node(3, (alpha, beta), Some(0), Table::Miss, 2, e.nodes), &moves, - 2, true, Some(Cutting { play: &killer, @@ -3263,17 +3272,8 @@ mod cutoffs { .collect(); e.ordering.cutoff(color, &elsewhere, &marked, 1, 4); e.census_event( - NodeFacts { - depth: 3, - alpha: 10, - beta: 11, - in_check: false, - ply: Some(0), - tt: Table::Miss, - entered_at: e.nodes, - }, + &node(3, (10, 11), Some(0), Table::Miss, 2, e.nodes), &moves, - 2, true, Some(Cutting { play: &cut, @@ -3306,17 +3306,8 @@ mod cutoffs { .find(|m| m.capture.is_some()) .expect("a capture"); e.census_event( - NodeFacts { - depth: 4, - alpha: 10, - beta: 11, - in_check: false, - ply: None, - tt: Table::Move, - entered_at: e.nodes, - }, + &node(4, (10, 11), None, Table::Move, 1, e.nodes), &[], - 1, false, Some(Cutting { play: &take, @@ -3340,6 +3331,26 @@ mod cutoffs { assert_eq!(row.tt, Table::Move); } + /// A node whose alpha rose records the window it opened with: an open + /// window stays open when a move raises alpha to a point under beta. + #[test] + fn a_row_records_the_window_the_node_opened_with() { + let mut e = engine(SHARP_MIDDLEGAME); + 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)); + e.census_event(&node, &moves, true, None); + let sampled = e + .disarm::() + .expect("a census was installed") + .drain(); + let row = &sampled.taken[0]; + assert_eq!(row.window, Window::Open); + assert_eq!(row.searched, 1); + } + /// A node the loop finished: no cutting move, and the rest of the /// portrait still there, the quiets' largest history included. #[test] @@ -3349,17 +3360,15 @@ mod cutoffs { e.ordering.cutoff(e.board.active_color, &taught, &[], 0, 3); let moves = e.board.generate_moves(); e.census_event( - NodeFacts { - depth: 2, - alpha: -50, - beta: 60, - in_check: false, - ply: Some(0), - tt: Table::ScoreOnly, - entered_at: e.nodes, - }, + &node( + 2, + (-50, 60), + Some(0), + Table::ScoreOnly, + moves.len(), + e.nodes, + ), &moves, - moves.len(), true, None, ); @@ -3384,11 +3393,12 @@ mod reductions { use super::taught::{quiets, unmade_journey}; use crate::board::fens::SHARP_MIDDLEGAME; use crate::census::Table; - use crate::engine::{AlphaBeta, Board, RootBounds, Score}; + use crate::engine::{AlphaBeta, Board, Decision, Node, RootBounds, Score}; use crate::late_move; use crate::play::Play; use crate::recorder::{Sampler, Window}; use crate::reduction::{self, Scout}; + use crate::value::{Taint, Value}; use pretty_assertions::assert_eq; const TABLE_BYTES: usize = 1024 * 1024; @@ -3403,8 +3413,7 @@ mod reductions { /// neither the depth nor the bounds, so both stand at nothing. fn staged(e: &AlphaBeta, m: &Play, searched: usize, ply: Option) -> reduction::Staged { let moves = e.board.generate_moves(); - let mut node = late_move::Node::new( - &e.deciding(), + let mut node = Node::open( 0, 0, 1, @@ -3412,9 +3421,21 @@ mod reductions { false, ply, Table::Miss, - None, + 0, + Taint::default(), ); - e.staged_reduction(m, searched, &mut node, &moves) + node.searched = searched; + let mut rules = late_move::Rules::new(&e.deciding(), &node, None); + e.staged_reduction(m, &node, &mut rules, &moves) + } + + /// A later move scouted `reduction` plies shallower with the ledger's + /// staging, as the loop hands it to the scout. + fn staging(reduction: u8, staged: reduction::Staged) -> Decision { + Decision::Search { + reduction, + staged: Some(staged), + } } /// An engine nobody asked a ledger of holds none. @@ -3447,8 +3468,7 @@ mod reductions { let child_fen = e.board.to_fen(); let child_key = e.board.key; let (alpha, beta): (Score, Score) = (5000, 5001); - let Ok(value) = e.windowed(alpha, beta, 3, false, 1, RootBounds::Neither, Some(&staged)) - else { + let Ok(value) = e.windowed(alpha, beta, 3, &staging(1, staged), RootBounds::Neither) else { panic!("an unlimited search aborted"); }; assert!(value.score <= alpha, "the scout did not fail low"); @@ -3479,6 +3499,45 @@ mod reductions { assert_eq!(row.reduction, 1); } + /// A skipped move is recorded against the bounds as the move is + /// reached: alpha as a move before it raised it, not the alpha the node + /// opened with. + #[test] + fn a_skipped_row_reads_the_alpha_standing() { + let mut e = engine(SHARP_MIDDLEGAME); + let (m, raiser) = quiets(&e); + let parent_eval = i32::from(crate::eval::eval(&e.board)); + 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(), + ); + node.absorb(&raiser, Value::clean(10)); + assert_eq!(node.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"); + let sampled = e + .disarm::() + .expect("a ledger was installed") + .drain(); + assert_eq!(sampled.taken.len(), 1); + let row = &sampled.taken[0]; + assert_eq!(row.scout, Scout::Skipped); + assert_eq!(row.depth, 3); + assert_eq!(row.alpha, 10); + assert_eq!(row.window, Window::Open); + assert_eq!(row.alpha_gap, 10 - parent_eval); + assert_eq!(row.eval_beta, parent_eval - 60); + } + /// A reduced move the table has marked down stages the signed entry /// and a denominator clamped at zero. #[test] @@ -3507,8 +3566,7 @@ mod reductions { let staged = staged(&e, &m, 6, None); assert!(e.board.make_move(&m)); let (alpha, beta): (Score, Score) = (5000, 5001); - let Ok(value) = e.windowed(alpha, beta, 4, false, 2, RootBounds::Neither, Some(&staged)) - else { + let Ok(value) = e.windowed(alpha, beta, 4, &staging(2, staged), RootBounds::Neither) else { panic!("an unlimited search aborted"); }; assert!(value.score <= alpha, "the scout did not fail low"); @@ -3537,7 +3595,7 @@ mod reductions { // staged row is picked out by the position it left let left = e.board.to_fen(); let (alpha, beta): (Score, Score) = (-5000, -4999); - let Ok(_) = e.windowed(alpha, beta, 3, false, 1, RootBounds::Neither, Some(&staged)) else { + let Ok(_) = e.windowed(alpha, beta, 3, &staging(1, staged), RootBounds::Neither) else { panic!("an unlimited search aborted"); }; let sampled = e @@ -3615,3 +3673,98 @@ 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}; + 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(), + ) + } + + /// A move for the answer to name. It reads the value and not the move. + fn any_move() -> Play { + Play::new(12, 28, None, None, false, false) + } + + /// Alpha and the root bounds move together at a rise, and a later + /// move scoring less lowers neither. Started from the two states where + /// alpha is still the root's, so a rise that forgot the bounds shows. + #[test] + fn a_rise_moves_alpha_and_the_root_bounds_together_and_nothing_lowers_them() { + let m = any_move(); + for (from, to) in [ + (RootBounds::Both, RootBounds::Beta), + (RootBounds::Alpha, RootBounds::Neither), + ] { + let mut node = open(4, -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 + // 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); + } + } + + /// The answer is a ceiling until a move beats the alpha the node + /// 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"); + } + + /// The searched count is what the reductions read as a move's index: + /// the table's move is counted when it was made, and a table move that + /// turns out illegal is not. The knight is pinned to its king, so its + /// move is pseudo legal and refused when made. + #[test] + fn the_searched_count_takes_the_tables_move_and_not_an_illegal_one() { + let board = Board::from_fen("4r1k1/8/8/8/8/8/4N3/4K3 w - - 0 1").unwrap(); + let mut e = AlphaBeta::with_table_bytes(board, 1024 * 1024); + let pinned = play_named(&e.board, "e2c3"); + let step = play_named(&e.board, "e1d1"); + assert!(e.board.is_pseudo_legal(&pinned) && e.board.is_pseudo_legal(&step)); + let mut node = open(2, -20_000, 20_000, RootBounds::Neither); + assert!(matches!(e.search_table_move(pinned, &mut node), Ok(None))); + assert_eq!(node.searched, 0, "the illegal move was counted"); + assert!(!node.raised_alpha()); + assert!(matches!(e.search_table_move(step, &mut node), Ok(None))); + assert_eq!(node.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 4a937acd..f7475f87 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -6,16 +6,16 @@ //! at all. `decide_admitted` answers with a `Verdict`; the scout itself //! is `windowed`'s, in `engine.rs`. //! -//! At depths one to three `Node::skips` asks two pruning rules of a quiet +//! At depths one to three `Rules::skips` asks two pruning rules of a quiet //! move after the node's first: quiet futility, when the evaluation plus a //! margin a ply cannot reach alpha, and the late move count, when the node -//! has searched `LATE_MOVE_COUNT` moves a ply. Everything in them but one -//! comparison against alpha is settled by the node, so `Node` holds it, -//! is handed alpha again when alpha rises, and is asked per move. They -//! stop a ply under the model's floor, so a shallow rule and the model -//! never decide at one depth. +//! has searched `LATE_MOVE_COUNT` moves a ply. Everything in them but the +//! alpha and the searched count is settled by the node before its moves, +//! so `Rules` holds it and reads those two off the engine's `Node` per +//! move. They stop a ply under the model's floor, so a shallow rule and +//! the model never decide at one depth. //! -//! `Node::admits` and `decide_admitted` cover the rest. The late move +//! `Rules::admits` and `decide_admitted` cover the rest. The late move //! reduction scouts a quiet move searched after the fourth, with the //! exemptions `reduces` lists. From `DEEP_REDUCTION_MIN_DEPTH` the //! attention model (a logistic regression over the reduction ledger's @@ -31,7 +31,7 @@ use crate::board::Board; use crate::census; -use crate::engine::{RootBounds, SearchConfig}; +use crate::engine::{Node, SearchConfig}; use crate::misc::Score; use crate::ordering::MoveOrdering; use crate::play::Play; @@ -253,25 +253,14 @@ pub(crate) struct Search<'a> { pub(crate) config: &'a SearchConfig, } -/// One full width node as the late move rules see it: what was settled -/// before its move loop, the memos its moves fill, and the node's half of -/// each rule. Built once the node's table move has been searched and -/// alive for the move loop, which hands it alpha again at every rise -/// (`raised`). The list is not held, since the loop sorts it under the +/// What the late move rules hold across one full width node's move loop: +/// the memos its moves fill and the node's half of each rule. Built once +/// the node's table move has been searched. The node's facts, its alpha as +/// it stands and its searched count are the engine's `Node`, handed to +/// every call, so nothing here has to be kept in step with a rise of +/// alpha. The list is not held either, since the loop sorts it under the /// decision; the calls that read it are handed it. -pub(crate) struct Node { - /// The node's depth, the check extension included. - pub(crate) depth: u8, - /// The bounds as the move being decided is reached, and which of them - /// are still the root's. - pub(crate) alpha: Score, - pub(crate) beta: Score, - pub(crate) root_bounds: RootBounds, - pub(crate) in_check: bool, - /// The node's distance from the root, or none past the rail, which is - /// where the killer slots are read. - pub(crate) ply: Option, - pub(crate) tt: census::Table, +pub(crate) struct Rules { /// The node's static evaluation: what the shortcuts read, or none /// until the first move that needs it. The recorders take their own, /// so a node the gate scores nothing at never computes one. @@ -286,103 +275,43 @@ pub(crate) struct Node { admission: Admission, } -impl Node { - /// The node before its first move, with `eval` as the shortcuts left - /// it. The two rule halves are built here and handed alpha. - // two past clippy's limit: every fact the rules read, once - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - search: &Search, - depth: u8, - alpha: Score, - beta: Score, - root_bounds: RootBounds, - in_check: bool, - ply: Option, - tt: census::Table, - eval: Option, - ) -> Self { - let mut node = Self { - depth, - alpha, - beta, - root_bounds, - in_check, - ply, - tt, +impl Rules { + /// The rules once the node's table move has been searched, with `eval` + /// as the shortcuts left it. The two rule halves are read off the + /// node's facts here. + pub(crate) fn new(search: &Search, node: &Node, eval: Option) -> Self { + Self { eval, history_max: None, check: None, - shallow: shallow( - search.config, - search.board, - depth, - in_check, - beta, - root_bounds, - ), - admission: admission(search.config, depth, in_check, beta, root_bounds), - }; - node.raised(alpha, root_bounds); - node - } - - /// Alpha as the loop now holds it, and the root bounds with it: before - /// the first move and after every rise. Both rule halves read alpha - /// again. - #[inline] - pub(crate) fn raised(&mut self, alpha: Score, root_bounds: RootBounds) { - self.alpha = alpha; - self.root_bounds = root_bounds; - self.shallow.raised(alpha); - self.admission.raised(alpha); + shallow: shallow(search.config, search.board, node), + admission: admission(search.config, node), + } } - /// Whether either shallow rule drops this move. `searched` is how many - /// moves the node has searched already. + /// Whether either shallow rule drops this move, at the node's alpha and + /// searched count as the move is reached. #[inline] - pub(crate) fn skips(&mut self, search: &Search, m: &Play, searched: usize) -> bool { - self.shallow.skips( - search, - &mut self.eval, - &mut self.check, - m, - searched, - self.alpha, - ) + pub(crate) fn skips(&mut self, search: &Search, node: &Node, m: &Play) -> bool { + self.shallow + .skips(search, &mut self.eval, &mut self.check, m, node) } /// Whether either shallow rule would drop every later quiet that /// neither gives check nor promotes: the half of `skips` that does not /// read the move. It stays true once true while alpha is short of a - /// mate, since `searched` and alpha only rise; the lazy quiet ordering - /// relies on that. + /// mate, since the searched count and alpha only rise; the lazy quiet + /// ordering relies on that. #[inline] - pub(crate) fn shallow_active(&mut self, search: &Search, searched: usize) -> bool { - self.shallow - .active(search, &mut self.eval, searched, self.alpha) + pub(crate) fn shallow_active(&mut self, search: &Search, node: &Node) -> bool { + self.shallow.active(search, &mut self.eval, node) } /// Whether the node admits a reduction of its next move: `admits` - /// without the move, read off the node half and the count. The search - /// is read only by the debug build's check against `admits`. + /// without the move, read off the node half, the alpha and the count. #[inline] - pub(crate) fn admits(&self, search: &Search, searched: usize) -> bool { - let admitted = self.admission.admits(searched); - debug_assert_eq!( - admitted, - admits( - search.config, - self.depth, - searched, - self.in_check, - self.alpha, - self.beta, - self.root_bounds, - ), - "the reduction's node half was not handed the alpha standing" - ); - admitted + pub(crate) fn admits(&self, node: &Node) -> bool { + self.admission.admits(node) } } @@ -430,23 +359,23 @@ impl Features { } /// The verdict for one move at a full width node. `moves` is the node's -/// list and `searched` how many of them the node has searched already, -/// the table's move among them. The reduction's exemptions are asked -/// first, so a node that reduces nothing pays for no feature. The tests' -/// reference: the loop asks `Node::admits` and then `decide_admitted`, -/// which give the same verdict. +/// list, and the node's searched count says how many of them it has +/// searched already, the table's move among them. The reduction's +/// exemptions are asked first, so a node that reduces nothing pays for no +/// feature. The tests' reference: the loop asks `Rules::admits` and then +/// `decide_admitted`, which give the same verdict. #[cfg(test)] pub(crate) fn decide( search: &Search, - node: &mut Node, + node: &Node, + rules: &mut Rules, moves: &[Play], m: &Play, - searched: usize, ) -> Verdict { - if !reduces(search, node, m, searched) { + if !reduces(search, node, m) { return Verdict::Scout(0); } - gate(search, node, moves, m, searched) + gate(search, node, rules, moves, m) } /// `decide` for a quiet move at a node that already admitted it: what is @@ -454,16 +383,16 @@ pub(crate) fn decide( #[inline] pub(crate) fn decide_admitted( search: &Search, - node: &mut Node, + node: &Node, + rules: &mut Rules, moves: &[Play], m: &Play, - searched: usize, ) -> Verdict { debug_assert!( - reduces(search, node, m, searched), + reduces(search, node, m), "the loop admitted a move the reduction refuses" ); - gate(search, node, moves, m, searched) + gate(search, node, rules, moves, m) } /// The node's half of the two shallow rules, held across its move loop. @@ -480,83 +409,76 @@ pub(crate) fn decide_admitted( /// with no piece but pawns is exempt for the reason `shortcuts` refuses it, /// which also means the margin reads an evaluation the node already has. /// -/// Alpha only rises, so the margin's test is a latch (`under`). A rising -/// alpha can reach the mate window, which is an exemption, and can leave -/// it from below, so the node half is read again at every rise -/// (`raised`) rather than per move: between two rises only the searched -/// count moves, and it moves the rules only at the thresholds held here. +/// Alpha only rises, so the margin's test is a latch once it holds +/// (`under`). A rising alpha can reach the mate window, which is an +/// exemption, and a margin short at one alpha can hold at a higher one, so +/// both are read against the alpha handed in: the exemption at every move, +/// and the margin again whenever alpha differs from the one it was last +/// found short at. struct Shallow { - /// Whether the node's own facts admit either rule. - admits: bool, - /// The searched count from which either rule may drop a move: one while - /// the node admits them and alpha is short of a mate, else never. + /// The searched count from which either rule may drop a move while + /// alpha is short of a mate: one where the node's own facts admit them, + /// else never. from: usize, - /// `QUIET_FUTILITY_MARGIN` at this node's depth, or none with the - /// margin's switch off. - margin: Option, + /// `QUIET_FUTILITY_MARGIN` at this node's depth. + margin: i32, /// The searched count at or past which the count drops a quiet, or /// never with its switch off. count: usize, - /// The margin's answer at the alpha it was asked at: `ASK` when it has - /// to be asked, `SHORT` when it did not hold at the alpha standing, - /// `UNDER` once it held, which stands while alpha rises. - under: u8, + /// The margin's answer as far as it has been asked. + under: Under, } -const ASK: u8 = 0; -const SHORT: u8 = 1; -const UNDER: u8 = 2; +/// The quiet futility margin's answer at a node, as far as it has been +/// asked. +#[derive(Clone, Copy)] +enum Under { + /// Never asked: the margin's switch is off, or the node is exempt. + Off, + /// Not asked yet. + Ask, + /// Short at this alpha, and asked again at any other. + Short(Score), + /// Held, which stands while alpha rises. + Held, +} -/// The node's half of the two rules, read once before the loop. The node -/// hands it alpha with `raised` before the first move and at every rise. -fn shallow( - config: &SearchConfig, - board: &Board, - depth: u8, - in_check: bool, - beta: Score, - root_bounds: RootBounds, -) -> Shallow { +/// The node's half of the two rules, read once before the loop off the +/// node's facts. +fn shallow(config: &SearchConfig, board: &Board, node: &Node) -> Shallow { let admits = (config.quiet_futility || config.late_move_count) - && (1..=SHALLOW_MAX_DEPTH).contains(&depth) - && !in_check - && !is_mate(beta) - && !root_bounds.beta_is_roots() + && (1..=SHALLOW_MAX_DEPTH).contains(&node.depth) + && !node.in_check + && !is_mate(node.beta) + && !node.root_bounds.beta_is_roots() && board.has_non_pawn_material(); - let margin = (admits && config.quiet_futility) - .then(|| i32::from(QUIET_FUTILITY_MARGIN) * i32::from(depth)); Shallow { - admits, - from: usize::MAX, - margin, + from: if admits { 1 } else { usize::MAX }, + margin: i32::from(QUIET_FUTILITY_MARGIN) * i32::from(node.depth), count: if admits && config.late_move_count { - LATE_MOVE_COUNT * usize::from(depth) + LATE_MOVE_COUNT * usize::from(node.depth) } else { usize::MAX }, - // with the margin off the answer never has to be asked - under: if margin.is_some() { ASK } else { SHORT }, + under: if admits && config.quiet_futility { + Under::Ask + } else { + Under::Off + }, } } impl Shallow { - /// Alpha as the loop now holds it: before the first move and after - /// every rise. + /// Whether either rule may drop a move at this searched count and + /// alpha. The mate test is asked after the count, so a node the rules + /// do not admit never asks it. #[inline] - fn raised(&mut self, alpha: Score) { - self.from = if self.admits && !is_mate(alpha) { - 1 - } else { - usize::MAX - }; - if self.under == SHORT && self.margin.is_some() { - self.under = ASK; - } + fn reached(&self, searched: usize, alpha: Score) -> bool { + searched >= self.from && !is_mate(alpha) } - /// Whether either rule drops this move. `searched` is how many moves the - /// node has searched already and `alpha` its bound as the move is - /// reached, the one last handed to `raised`. + /// Whether either rule drops this move, at the node's alpha and + /// searched count as the move is reached. /// /// The order of the tests is the cost order: the count before the /// margin, so a move the count drops needs no evaluation, and the check @@ -570,18 +492,13 @@ impl Shallow { eval: &mut Option, check: &mut Option, m: &Play, - searched: usize, - alpha: Score, + node: &Node, ) -> bool { - debug_assert_eq!( - searched >= self.from, - self.admits && searched >= 1 && !is_mate(alpha), - "the node half was not handed the alpha standing" - ); - searched >= self.from + let searched = node.searched; + self.reached(searched, node.alpha) && m.capture.is_none() && m.promote.is_none() - && (searched >= self.count || self.under_alpha(search, eval, alpha)) + && (searched >= self.count || self.under_alpha(search, eval, node.alpha)) && !search .board .gives_check_with(check.get_or_insert_with(|| search.board.check_info()), m) @@ -589,81 +506,60 @@ impl Shallow { /// The half of `skips` that does not read the move. #[inline] - fn active( - &mut self, - search: &Search, - eval: &mut Option, - searched: usize, - alpha: Score, - ) -> bool { - debug_assert_eq!( - searched >= self.from, - self.admits && searched >= 1 && !is_mate(alpha), - "the node half was not handed the alpha standing" - ); - searched >= self.from && (searched >= self.count || self.under_alpha(search, eval, alpha)) + 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)) } #[inline] fn under_alpha(&mut self, search: &Search, eval: &mut Option, alpha: Score) -> bool { - if self.under == ASK { - let margin = self.margin.expect("the margin is asked only when it is on"); - self.under = if i32::from(eval_memo(search.board, eval)) + margin <= i32::from(alpha) { - UNDER - } else { - SHORT - }; + match self.under { + Under::Held => true, + Under::Off => false, + Under::Short(at) if at == alpha => false, + Under::Ask | Under::Short(_) => { + let held = + i32::from(eval_memo(search.board, eval)) + self.margin <= i32::from(alpha); + self.under = if held { + Under::Held + } else { + Under::Short(alpha) + }; + held + } } - debug_assert!( - self.under != SHORT - || self.margin.is_none_or(|margin| { - i32::from(eval_memo(search.board, eval)) + margin > i32::from(alpha) - }), - "a margin held short at an alpha it reaches" - ); - self.under == UNDER } } /// The late move reduction's node half: `admits` without the searched -/// count, held across the loop and read again at every rise of alpha, so -/// the loop asks one comparison per move. +/// count and alpha, held across the loop, so the loop asks the count and +/// then the mate test per move. struct Admission { - node: bool, - /// The searched count from which the node admits a reduction, or never. + /// The searched count from which the node admits a reduction while + /// alpha is short of a mate, or never. from: usize, } -fn admission( - config: &SearchConfig, - depth: u8, - in_check: bool, - beta: Score, - root_bounds: RootBounds, -) -> Admission { +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(); Admission { - node: config.late_move_reductions - && depth >= LATE_MOVE_MIN_DEPTH - && !in_check - && !is_mate(beta) - && !root_bounds.beta_is_roots(), - from: usize::MAX, - } -} - -impl Admission { - #[inline] - fn raised(&mut self, alpha: Score) { - self.from = if self.node && !is_mate(alpha) { + from: if admits { LATE_MOVE_THRESHOLD } else { usize::MAX - }; + }, } +} +impl Admission { #[inline] - fn admits(&self, searched: usize) -> bool { - searched >= self.from + fn admits(&self, node: &Node) -> bool { + node.searched >= self.from && !is_mate(node.alpha) } } @@ -688,43 +584,29 @@ impl Admission { /// A quiet move that gives check is reduced like any other: exempting /// checks was measured and lost (docs/ROADMAP.md). #[inline] -fn reduces(search: &Search, node: &Node, m: &Play, searched: usize) -> bool { - admits( - search.config, - node.depth, - searched, - node.in_check, - node.alpha, - node.beta, - node.root_bounds, - ) && m.capture.is_none() - && m.promote.is_none() +fn reduces(search: &Search, node: &Node, m: &Play) -> bool { + admits(search.config, node) && m.capture.is_none() && m.promote.is_none() } /// The half of `reduces` that reads the node and the count rather than -/// the move: what `Admission` holds across the loop. +/// the move: what `Admission` answers from the half it holds. #[inline] -fn admits( - config: &SearchConfig, - depth: u8, - searched: usize, - in_check: bool, - alpha: Score, - beta: Score, - root_bounds: RootBounds, -) -> bool { +fn admits(config: &SearchConfig, node: &Node) -> bool { config.late_move_reductions - && depth >= LATE_MOVE_MIN_DEPTH - && searched >= LATE_MOVE_THRESHOLD - && node_admits(in_check, alpha, beta, root_bounds) + && node.depth >= LATE_MOVE_MIN_DEPTH + && node.searched >= LATE_MOVE_THRESHOLD + && node_admits(node) } /// The exemptions the reduction and the two shallow rules share, for the /// reasons `reduces` gives. `Shallow` and `Admission` read the three fixed -/// at the node once and alpha's again at each rise. +/// at the node once and alpha's at every move. #[inline] -fn node_admits(in_check: bool, alpha: Score, beta: Score, root_bounds: RootBounds) -> bool { - !in_check && !is_mate(alpha) && !is_mate(beta) && !root_bounds.beta_is_roots() +fn node_admits(node: &Node) -> bool { + !node.in_check + && !is_mate(node.alpha) + && !is_mate(node.beta) + && !node.root_bounds.beta_is_roots() } /// Whether a move `reduces` already accepted is skipped, or scouted a ply @@ -733,14 +615,15 @@ fn node_admits(in_check: bool, alpha: Score, beta: Score, root_bounds: RootBound /// its full width ply, and neither is offered a move that gives check: the /// 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: &mut Node, moves: &[Play], m: &Play, searched: usize) -> Verdict { +fn gate(search: &Search, node: &Node, rules: &mut Rules, moves: &[Play], m: &Play) -> Verdict { + let searched = node.searched; if (!search.config.deep_reductions && !search.config.late_move_pruning) || node.depth < DEEP_REDUCTION_MIN_DEPTH { return Verdict::Scout(amount(search.config, node.depth, searched, 0)); } - let eval = i64::from(eval_memo(search.board, &mut node.eval)); - let f = features(search, node, moves, m, searched); + let eval = i64::from(eval_memo(search.board, &mut rules.eval)); + let f = features(search, node, rules, moves, m); let score = attention_score(&AttentionFeatures { depth: node.depth, index: f.index, @@ -752,7 +635,7 @@ fn gate(search: &Search, node: &mut Node, moves: &[Play], m: &Play, searched: us generated: f.generated, }); if search.config.late_move_pruning && score <= LATE_MOVE_PRUNING_THRESHOLD { - let info = node.check.get_or_insert_with(|| search.board.check_info()); + let info = rules.check.get_or_insert_with(|| search.board.check_info()); return if search.board.gives_check_with(info, m) { Verdict::Scout(amount(search.config, node.depth, searched, 0)) } else { @@ -762,7 +645,7 @@ fn gate(search: &Search, node: &mut Node, moves: &[Play], m: &Play, searched: us if search.config.deep_reductions && deepens(node.depth, searched) && !search.board.gives_check_with( - node.check.get_or_insert_with(|| search.board.check_info()), + rules.check.get_or_insert_with(|| search.board.check_info()), m, ) { @@ -808,20 +691,21 @@ fn amount(config: &SearchConfig, depth: u8, searched: usize, bonus: u8) -> u8 { } /// What the node knows about one move for the ledger. `moves` is the -/// node's list, which the denominator walks once. +/// node's list, which the denominator walks once. The index is the node's +/// searched count as the move is reached. pub(crate) fn features( search: &Search, - node: &mut Node, + node: &Node, + rules: &mut Rules, moves: &[Play], m: &Play, - searched: usize, ) -> Features { - let history_max = denominator(search, node, moves); + let history_max = denominator(search, rules, moves); let killers = node .ply .map_or([None, None], |ply| search.ordering.killers_at(ply)); Features { - index: searched, + index: node.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 @@ -839,8 +723,8 @@ fn eval_memo(board: &Board, eval: &mut Option) -> Score { /// The largest history score among the node's generated quiets, signed: /// `Features` does the clamping. -fn denominator(search: &Search, node: &mut Node, moves: &[Play]) -> i32 { - *node.history_max.get_or_insert_with(|| { +fn denominator(search: &Search, rules: &mut Rules, moves: &[Play]) -> i32 { + *rules.history_max.get_or_insert_with(|| { let quiet_history = |m: &Play| { if m.capture.is_none() && m.promote.is_none() { Some(search.ordering.history_score(search.board.active_color, m)) @@ -858,16 +742,17 @@ mod tests { ATTENTION_ALPHA_GAP, ATTENTION_EVAL_BETA, ATTENTION_KILLER, AttentionFeatures, DEEP_INDEX_FLOOR, DEEP_INDEX_SLOPE, DEEP_REDUCTION, DEEP_REDUCTION_BONUS, DEEP_REDUCTION_MIN_DEPTH, Features, LATE_MOVE_COUNT, LATE_MOVE_MIN_DEPTH, - LATE_MOVE_PRUNING_THRESHOLD, LATE_MOVE_REDUCTION, LATE_MOVE_THRESHOLD, Node, - QUIET_FUTILITY_MARGIN, REDUCTION, SHALLOW_MAX_DEPTH, Search, Verdict, amount, - attention_score, decide, features, + LATE_MOVE_PRUNING_THRESHOLD, LATE_MOVE_REDUCTION, LATE_MOVE_THRESHOLD, + QUIET_FUTILITY_MARGIN, REDUCTION, Rules, SHALLOW_MAX_DEPTH, Search, Verdict, admission, + amount, attention_score, decide, features, }; use crate::board::{Board, MoveList, fens, play_named}; use crate::census::Table; - use crate::engine::{MAX_PLY, RootBounds, SearchConfig}; + use crate::engine::{MAX_PLY, Node, RootBounds, SearchConfig}; use crate::misc::Score; use crate::ordering::MoveOrdering; use crate::play::Play; + use crate::value::Taint; use pretty_assertions::assert_eq; /// The reference with the late move reductions alone on. @@ -987,11 +872,10 @@ mod tests { } } - /// A node as the move loop builds it, seeded with the memos the - /// stand holds. - fn node(&self, depth: u8, alpha: Score, beta: Score) -> Node { - let mut node = Node::new( - &self.search(), + /// A node as the move loop builds it, with the rules seeded with + /// the memos the stand holds. + fn node(&self, depth: u8, alpha: Score, beta: Score) -> (Node, Rules) { + let node = Node::open( depth, alpha, beta, @@ -999,16 +883,18 @@ mod tests { self.in_check, self.ply, self.tt, - self.eval, + 0, + Taint::default(), ); - node.history_max = self.history_max; - node + let mut rules = Rules::new(&self.search(), &node, self.eval); + rules.history_max = self.history_max; + (node, rules) } - /// What a node left in its memos, kept for the next question. - fn keep(&mut self, node: &Node) { - self.eval = node.eval; - self.history_max = node.history_max; + /// What the rules left in their memos, kept for the next question. + fn keep(&mut self, rules: &Rules) { + self.eval = rules.eval; + self.history_max = rules.history_max; } /// The decision about one move. Each call is a node of its own: @@ -1024,15 +910,16 @@ mod tests { ) -> Verdict { self.eval = None; self.history_max = None; - let mut node = self.node(depth, alpha, beta); - let verdict = decide(&self.search(), &mut node, &self.moves, m, searched); - self.keep(&node); + let (mut node, mut rules) = self.node(depth, alpha, beta); + node.searched = searched; + let verdict = decide(&self.search(), &node, &mut rules, &self.moves, m); + self.keep(&rules); verdict } /// A node at one of the shallow rules' depths, as the move loop - /// builds it, before it is handed an alpha. - fn rule(&self, depth: u8, beta: Score) -> Node { + /// builds it, before its alpha is raised. + fn rule(&self, depth: u8, beta: Score) -> (Node, Rules) { self.node(depth, 0, beta) } @@ -1048,8 +935,8 @@ mod tests { ) -> bool { self.eval = None; self.history_max = None; - let mut node = self.rule(depth, beta); - self.asks(&mut node, m, searched, alpha) + let mut held = self.rule(depth, beta); + self.asks(&mut held, m, searched, alpha) } /// The same at a node whose evaluation the move loop already @@ -1065,16 +952,24 @@ mod tests { ) -> bool { self.eval = Some(seed); self.history_max = None; - let mut node = self.rule(depth, beta); - self.asks(&mut node, m, searched, alpha) + let mut held = self.rule(depth, beta); + self.asks(&mut held, m, searched, alpha) } - /// The question of a node the caller is holding, so a test can - /// ask one node twice and see what the latch carried. - fn asks(&mut self, node: &mut Node, m: &Play, searched: usize, alpha: Score) -> bool { - node.raised(alpha, node.root_bounds); - let skips = node.skips(&self.search(), m, searched); - self.keep(node); + /// The question of a node the caller is holding, with its alpha + /// and searched count set as the loop would have them, so a test + /// can ask one node twice and see what the latch carried. + fn asks( + &mut self, + (node, rules): &mut (Node, Rules), + m: &Play, + searched: usize, + alpha: Score, + ) -> bool { + node.alpha = alpha; + node.searched = searched; + let skips = rules.skips(&self.search(), node, m); + self.keep(rules); skips } @@ -1082,9 +977,10 @@ mod tests { /// without clearing what a verdict left behind. The depth and the /// bounds stand at nothing, since the features read neither. fn features(&mut self, m: &Play, searched: usize) -> Features { - let mut node = self.node(0, 0, 1); - let features = features(&self.search(), &mut node, &self.moves, m, searched); - self.keep(&node); + let (mut node, mut rules) = self.node(0, 0, 1); + node.searched = searched; + let features = features(&self.search(), &node, &mut rules, &self.moves, m); + self.keep(&rules); features } @@ -1166,6 +1062,48 @@ mod tests { ); } + /// The reduction's node half is built once, and asked per move with + /// the alpha standing, so it reads the mate window off the alpha it is + /// handed rather than the one it was built at. Read with no board: the + /// half reads the node and the configuration alone. + #[test] + fn a_mate_alpha_admits_no_reduction_at_any_count() { + let config = reducing(); + let mut node = Node::open( + LATE_MOVE_MIN_DEPTH, + 0, + 100, + RootBounds::Neither, + false, + None, + Table::Miss, + 0, + Taint::default(), + ); + let half = admission(&config, &node); + for searched in 0..64 { + node.searched = searched; + assert_eq!( + half.admits(&node), + searched >= LATE_MOVE_THRESHOLD, + "an ordinary alpha at {searched} searched" + ); + } + // 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)); + for searched in 0..64 { + node.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; + assert!(!half.admits(&node), "opened at a mate, {searched} searched"); + } + } + #[test] fn a_capture_is_never_reduced() { // the same call the quiet is reduced under, with the capture in