From 55d19a3cf02ce72feb8c0ebd78c74eb08ba9ce7f Mon Sep 17 00:00:00 2001 From: blacktoe Date: Wed, 30 Sep 2026 21:17:25 +1000 Subject: [PATCH 1/2] refactor(search): Hold a node's facts and its answer in one value the reductions read A full width node's state was held three times. `NodeFacts` had the seven facts the census and the effort instrument record. `NodeAnswer` had the bounds as they stood, the opening alpha, the root bounds, the fail soft best, the taint and the searched count. `late_move::Node` repeated six of the seven facts beside its three memos and the two rule halves. The loop kept the copies in step by handing the late move node alpha after every rise, and only debug assertions checked that it had. Now one `Node` in engine.rs holds the facts and the answer, and alpha lives there alone. `open`, `absorb` and `raised_alpha` are its methods. The late move struct keeps only the memos and the rule halves, and is renamed `Rules`. Its calls take the node, so each rule half reads alpha and the searched count off it as the move is reached: the mate test on alpha is asked per move rather than per rise, and the futility memo records the alpha it was found short at and asks again at any other. `raised` goes, with the assertions that kept the copies in step and the `search` parameter only one of them read. `record_node`, `census_event`, `ledger_skip` and `search_table_move` read the node. The census still records the window the node opened with, and the ledger the bounds as the move is reached. New tests read the node with no search behind it: a rise moves alpha and the root bounds together and nothing lowers either, the answer is a ceiling until a rise, and the searched count takes a legal table move and not a pinned piece's. The tree is unchanged. The bench counts 5,965,973, and every instrument's rows at depth 5 print as on the parent: cutoffs, reductions, effort with no switch off and with null_move off, and residuals, both at their default sampling and at every 1. Callgrind over the depth 5 bench counts 201,360,782 instructions on the parent and 202,312,034 here, +951,252 (+0.47%). All but 12,103 of it is in the body of alpha_beta, where every node path is inlined. There the rules' own lines are 155,063 fewer and the mate test on alpha is 175,428 more (is_mate and the abs it calls). The rest is spread over engine.rs, board.rs and value.rs lines that did not change, which reads as register allocation: a variant that changed only how the cold recorders take the node moved the same body by a further 364,571. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 --- arche-core/src/engine.rs | 243 +++++++------- arche-core/src/engine/tests.rs | 246 ++++++++++++--- arche-core/src/late_move.rs | 562 +++++++++++++++------------------ 3 files changed, 567 insertions(+), 484 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 458b59f1..7c996c2d 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, } } @@ -1273,13 +1288,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 +1314,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 +1416,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 +1428,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; @@ -2110,28 +2128,23 @@ 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, + node.alpha, + node.beta, + node.depth, true, 0, - answer.root_bounds, + node.root_bounds, None, )? 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 +2152,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 +2178,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 +2210,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 @@ -2241,29 +2254,29 @@ impl AlphaBeta { #[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 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 +2346,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 +2374,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 +2443,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 +2470,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 +2478,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 +2487,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 +2500,33 @@ 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) + self.late_move_decision(&node, &mut rules, &moves, m) else { continue; }; let Some(value) = self.search_child( m, - answer.alpha, - answer.beta, + node.alpha, + node.beta, depth, - answer.searched == 0, + node.searched == 0, reduction, - answer.root_bounds, + node.root_bounds, staged.as_ref(), )? 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, 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 +2535,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 +2544,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 +2560,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) } diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index f188b124..fb549da2 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -3157,9 +3157,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 +3171,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 +3225,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 +3280,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 +3314,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 +3339,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 +3368,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 +3401,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, 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 +3421,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 +3429,12 @@ 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) } /// An engine nobody asked a ledger of holds none. @@ -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] @@ -3615,3 +3674,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 From fb42c41d765b7e28774277c1ad8e06deb7bde55b Mon Sep 17 00:00:00 2001 From: blacktoe Date: Wed, 30 Sep 2026 21:26:26 +1000 Subject: [PATCH 2/2] refactor(search): Ask for a child search with the loop's decision A child search was asked for with three parameters, `first`, `reduction` and `staged`, through `search_child` and `windowed`, and an assertion held that a first move carried no reduction. The move loop already had the later move's case as a value, `Decision::Search { reduction, staged }`. `Decision` gains `First` beside `Skip` and `Search`, and both functions take the decision by reference in place of the three. A first move cannot carry a reduction, so the assertion goes. `late_move_decision` answers `First` for the node's first searched move without asking the late move rules, which could not act on it (neither shallow rule reaches a node that has searched nothing, and the reduction starts at the fifth). The table's move is asked for as `First`, and the root builds the same two cases from its own locals. A `Skip` reaching `search_child` is a programming error: a debug assertion says so there, and `windowed` treats it as unreachable. The `too_many_arguments` allowances on the two functions go. The tree is unchanged. The bench counts 5,965,973, and every instrument's rows at depth 5 print as on the parent: cutoffs, reductions, effort with no switch off and with null_move off, and residuals, both at their default sampling and at every 1. Callgrind over the depth 5 bench counts 202,312,034 instructions on the parent and 201,147,133 here, -1,164,901 (-0.58%). The body of alpha_beta is 1,658,535 fewer across its two copies, and windowed, which now matches on the decision, is 481,276 more. Of the fall in alpha_beta, 502,559 is on the lines of std's option.rs and 385,287 on the late move rules' lines, which the first move no longer asks. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 --- arche-core/src/engine.rs | 108 ++++++++++++++++----------------- arche-core/src/engine/tests.rs | 73 +++++++++++----------- 2 files changed, 88 insertions(+), 93 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 7c996c2d..a5c59d76 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -256,12 +256,15 @@ impl Node { } } -/// 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, @@ -1988,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?)) } @@ -2029,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` @@ -2136,10 +2139,8 @@ impl AlphaBeta { node.alpha, node.beta, node.depth, - true, - 0, + &Decision::First, node.root_bounds, - None, )? else { return Ok(None); @@ -2250,7 +2251,9 @@ 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, @@ -2259,6 +2262,9 @@ impl AlphaBeta { moves: &[Play], m: &Play, ) -> Decision { + if node.searched == 0 { + return Decision::First; + } if rules.skips(&self.deciding(), node, m) { self.record_skip(node, rules, moves, m); return Decision::Skip; @@ -2500,21 +2506,12 @@ impl AlphaBeta { } continue; } - let Decision::Search { reduction, staged } = - self.late_move_decision(&node, &mut rules, &moves, m) - 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, - node.alpha, - node.beta, - depth, - node.searched == 0, - reduction, - node.root_bounds, - staged.as_ref(), - )? + } + let Some(value) = + self.search_child(m, node.alpha, node.beta, depth, &decision, node.root_bounds)? else { continue; }; @@ -2523,7 +2520,7 @@ impl AlphaBeta { Reached::Beta => { let cutting = census::Cutting { play: m, - reduced: reduction > 0, + reduced: matches!(decision, Decision::Search { reduction: 1.., .. }), table: false, }; self.record_node(&node, &moves, quiets.scored, Some(cutting)); @@ -2664,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 fb549da2..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"); }; @@ -3401,7 +3393,7 @@ mod reductions { use super::taught::{quiets, unmade_journey}; use crate::board::fens::SHARP_MIDDLEGAME; use crate::census::Table; - use crate::engine::{AlphaBeta, Board, Node, RootBounds, Score}; + use crate::engine::{AlphaBeta, Board, Decision, Node, RootBounds, Score}; use crate::late_move; use crate::play::Play; use crate::recorder::{Sampler, Window}; @@ -3437,6 +3429,15 @@ mod reductions { 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. #[test] fn an_engine_records_no_ledger_until_it_is_asked_to() { @@ -3467,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"); @@ -3566,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"); @@ -3596,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