From 3a8a067fa1e1c142796cb644f56aeed368159b53 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:12:06 +0000 Subject: [PATCH 1/3] perf(search): Hand the probe's entry back to the node and read the age penalty from an array A probe wrote the entry it found into a cell on the table, and a full width node read its static evaluation and its floor back from the cell straight after. Every probe paid for the write, quiescence's included, which reads nothing back. A hit also built the entry on the stack to have something to write, and since the write went through a `Cell` the compiler reloaded what it had read through `&self` around it. The probe now returns the entry beside its answer, as `Found`, and the node reads the evaluation and the floor from that. Quiescence drops it. The cell goes, and with it the debug check that no other probe came between the probe and the read: the entry now travels with the node that asked for it. The store's contest took each entry's age modulo the 31 generations and multiplied it by the age weight. The table now keeps the weight times each generation's age in a 32 slot array beside the generation, rewritten wherever the generation or the weight changes, and the contest reads it. The debug build checks it against the formula at every call. Every probe answers and every store lands as before. Node counts and every bench line are unchanged. Callgrind against the parent: the games suite's tree search goes from 11,050,460,280 instructions to 10,847,099,545 (-1.840%), the bench's from 16,254,520,835 to 15,987,709,636 (-1.641%). Measured apart on the parent's parent, which has the same search code, the entry handed back is 1.628 points of the games suite and the penalty array 0.212. Bench: 11088536 Speed: +2.4% (bench nps, 95% interval +0.4% to +4.6%, 60 interleaved rounds over shuffled layouts vs b48777a) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae --- arche-core/src/engine.rs | 27 +++-- arche-core/src/engine/tests.rs | 16 +-- arche-core/src/transposition.rs | 186 ++++++++++++++++---------------- 3 files changed, 115 insertions(+), 114 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 30a7a3ae..ae1189de 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -16,7 +16,7 @@ use crate::recorder::{Sampler, Window}; use crate::reduction; use crate::residual::{Sample, Shortcut}; use crate::transposition::{ - DEFAULT_TABLE_BYTES, Floor, NO_EVAL, Probe, SignatureCounters, TranspositionTable, + DEFAULT_TABLE_BYTES, Floor, Found, NO_EVAL, Probe, SignatureCounters, TranspositionTable, }; use crate::value::{ MateDistanceWindow, Taint, Value, below_the_mate_window, is_mate, mate_distance_window, @@ -1871,8 +1871,8 @@ impl AlphaBeta { /// What the table knows about the position, under the taint policy, /// counted. #[inline(always)] - fn probe(&mut self, alpha: Score, beta: Score, depth: u8) -> Probe { - let probe = self.transpositions.probe( + fn probe(&mut self, alpha: Score, beta: Score, depth: u8) -> (Probe, Found) { + let (probe, found) = self.transpositions.probe( &self.board, alpha, beta, @@ -1881,17 +1881,17 @@ impl AlphaBeta { self.config.taint.guards_rule50(), ); self.ghi.count_probe(probe); - probe + (probe, found) } /// The table's move for ordering and never its cutoff, counted: what a /// search with a move excluded asks, since the entry's score is the /// excluded move's. #[inline(always)] - fn probe_for_ordering(&mut self) -> Probe { - let probe = self.transpositions.probe_for_ordering(&self.board); + fn probe_for_ordering(&mut self) -> (Probe, Found) { + let (probe, found) = self.transpositions.probe_for_ordering(&self.board); self.ghi.count_probe(probe); - probe + (probe, found) } /// Whether a result may be stored under the taint policy. A refused @@ -2056,7 +2056,7 @@ impl AlphaBeta { } // a probe at depth zero: any stored bound is deep enough here - let pv_play = match self.probe(answer.alpha, beta, 0) { + let pv_play = match self.probe(answer.alpha, beta, 0).0 { Probe::Cut(value) => return Ok(value), Probe::Order(play) | Probe::Refused(play) => Some(play), Probe::Miss => None, @@ -2840,7 +2840,7 @@ impl AlphaBeta { return self.quiescence(alpha, beta); } - let probe = match excluded { + let (probe, found) = match excluded { None => self.probe(alpha, beta, depth), Some(_) => self.probe_for_ordering(), }; @@ -2852,13 +2852,10 @@ impl AlphaBeta { let mut taint = Taint::default(); // the node's static evaluation, filled by the shortcuts and read by // the late move decision, or found in the table's entry. The entry's - // floor is read here too, before the shortcuts' searches probe - // other keys - let table_eval = self.transpositions.probed_eval(self.board.key); + // floor is read from the probe's entry too + let table_eval = found.eval(); let mut eval: Option = (table_eval != NO_EVAL).then_some(table_eval); - let floor = self - .transpositions - .probed_floor(self.board.key, self.board.line_ply); + let floor = found.floor(self.board.line_ply); if let Some(value) = self.shortcuts( alpha, beta, diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 43525a0f..6d2c5a3e 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -54,10 +54,10 @@ mod search { #[test] fn a_table_entry_holds_its_own_positions_evaluation() { fn stored(e: &AlphaBeta, board: &Board) -> Option { - let _ = e - .transpositions - .probe(board, Score::MIN + 1, Score::MAX - 1, 0, false, false); - let found = e.transpositions.probed_eval(board.key); + let (_, found) = + e.transpositions + .probe(board, Score::MIN + 1, Score::MAX - 1, 0, false, false); + let found = found.eval(); (found != crate::transposition::NO_EVAL).then_some(found) } for fen in fens::CORE { @@ -359,11 +359,11 @@ mod search { rest.score ); assert_eq!(e.singular_counts().asked, 0, "the excluded search asked"); - let _ = e - .transpositions - .probe(&e.board, Score::MIN + 1, Score::MAX - 1, 0, false, false); + let (_, found) = + e.transpositions + .probe(&e.board, Score::MIN + 1, Score::MAX - 1, 0, false, false); assert_eq!( - e.transpositions.probed_floor(e.board.key, 0), + found.floor(0), Some(planted), "the excluded search stored over the entry" ); diff --git a/arche-core/src/transposition.rs b/arche-core/src/transposition.rs index b40d090a..3879c8f2 100644 --- a/arche-core/src/transposition.rs +++ b/arche-core/src/transposition.rs @@ -244,6 +244,38 @@ pub(crate) struct Floor { pub(crate) depth: u8, } +/// The entry a probe found, or none on a miss, handed back beside its +/// answer for the node to read its static evaluation and its floor from. +/// A node that reads neither (quiescence, or one the answer cut) drops it, +/// and nothing of it is kept. +#[derive(Copy, Clone, Debug)] +pub(crate) struct Found(Option); + +impl Found { + /// The entry's static evaluation, or `NO_EVAL` where there is no entry + /// or the entry holds none. + #[inline(always)] + pub(crate) fn eval(self) -> Score { + self.0.map_or(NO_EVAL, |pv| pv.static_eval) + } + + /// The floor the entry puts under the position, where its bound is a + /// floor or the truth, or none on a miss or a ceiling. The score is made + /// relative to the root at `line_ply`. + #[inline(always)] + pub(crate) fn floor(self, line_ply: usize) -> Option { + let pv = self.0?; + match pv.bound { + Bound::Exact | Bound::Lower => Some(Floor { + play: pv.play, + score: score_from_tt(pv.score, line_ply), + depth: pv.depth, + }), + Bound::Upper | Bound::Ordering => None, + } + } +} + /// What a probe found. #[derive(Copy, Clone, Debug)] pub enum Probe { @@ -520,6 +552,16 @@ fn replaceable_under(generation: u8) -> u32 { }) } +/// The age weight times each generation's age under `generation`, a +/// generation a slot. +fn penalties(generation: u8, weight: i16) -> [i16; 32] { + let mut penalty = [0; 32]; + for (g, slot) in (0..).zip(penalty.iter_mut()) { + *slot = weight * i16::from((generation + GENERATIONS - g) % GENERATIONS); + } + penalty +} + /// Asking the kernel to back the table with huge pages. /// /// Every probe that misses the translation buffer pays a page walk before @@ -676,17 +718,14 @@ pub struct TranspositionTable { replaceable: u32, /// `age_weight` of the search under way's root. age_weight: i16, + /// What `worth` takes off an entry of each generation: the age weight + /// times the searches since that generation, kept beside the generation + /// so that a contest reads it rather than taking the age modulo the + /// cycle. + penalty: [i16; 32], /// The full keys of the entries, or none, which is what every table an /// engine plays with holds. `audit_signatures` fills it in. audit: Option>, - /// The entry the last probe found, or none on a miss, for the node to - /// read its static evaluation and its floor back from. A cell, since - /// the probe reads the table. - probed: Cell>, - /// The key the last probe asked for, so a debug build can tell a read - /// of `probed` that another probe came between. - #[cfg(debug_assertions)] - probed_key: Cell, } /// What an entry holds for the static evaluation when the node that stored @@ -742,10 +781,8 @@ impl TranspositionTable { generation: 1, replaceable: replaceable_under(1), age_weight: AGE_WEIGHT, + penalty: penalties(1, AGE_WEIGHT), audit: None, - probed: Cell::new(None), - #[cfg(debug_assertions)] - probed_key: Cell::new(0), }) } @@ -753,6 +790,7 @@ impl TranspositionTable { self.table.fill(Bucket::EMPTY); self.generation = 1; self.replaceable = replaceable_under(1); + self.penalty = penalties(1, self.age_weight); if let Some(audit) = self.audit.as_deref_mut() { audit.keys.fill(0); } @@ -824,6 +862,7 @@ impl TranspositionTable { self.generation = self.generation % GENERATIONS + 1; self.replaceable = replaceable_under(self.generation); self.age_weight = age_weight(phase); + self.penalty = penalties(self.generation, self.age_weight); } /// How full the table is in permille, as the protocol's `hashfull` @@ -846,10 +885,16 @@ impl TranspositionTable { /// What an entry is worth keeping: its depth, less the search's age /// weight for each search since it was stored. An entry from this - /// search is worth its depth. + /// search is worth its depth. The weight times the age is read from + /// `penalty` rather than worked out at each contest. #[inline(always)] fn worth(&self, entry: Entry) -> i16 { - i16::from(entry.depth) - self.age_weight * i16::from(self.age(entry)) + let worth = i16::from(entry.depth) - self.penalty[usize::from(entry.generation())]; + debug_assert_eq!( + worth, + i16::from(entry.depth) - self.age_weight * i16::from(self.age(entry)) + ); + worth } /// How many searches ago an entry was stored. @@ -1160,7 +1205,7 @@ impl TranspositionTable { /// the entry is deep enough, its bound allows a cutoff at the window, /// and the policy (`refuse_tainted`, `guard_rule50`) trusts it. A /// cutoff the policy turns away comes back as `Refused`, so the caller - /// can count it. + /// can count it. The entry found is handed back beside the answer. #[inline(always)] pub fn probe( &self, @@ -1170,14 +1215,12 @@ impl TranspositionTable { depth: u8, refuse_tainted: bool, guard_rule50: bool, - ) -> Probe { + ) -> (Probe, Found) { let (found, foreign) = self.get_audited(board.key); - #[cfg(debug_assertions)] - self.probed_key.set(board.key); - self.probed.set(found); let Some(pv) = found else { - return Probe::Miss; + return (Probe::Miss, Found(None)); }; + let found = Found(found); if pv.depth >= depth { let score = score_from_tt(pv.score, board.line_ply); let cuts = match pv.bound { @@ -1190,69 +1233,31 @@ impl TranspositionTable { if guard_rule50 && board.fifty_move_near_expiry() { // near the horizon every stored score is suspect, // tainted or not - return Probe::Refused(pv.play); + return (Probe::Refused(pv.play), found); } if refuse_tainted && pv.tainted { // the stored draw may not be reachable by this path - return Probe::Refused(pv.play); + return (Probe::Refused(pv.play), found); } self.count_false_accept_cutoff(foreign); - return Probe::Cut(Value::with_taint(score, pv.tainted)); + return (Probe::Cut(Value::with_taint(score, pv.tainted)), found); } } - Probe::Order(pv.play) + (Probe::Order(pv.play), found) } /// The entry's move for ordering and never its cutoff, with the entry - /// kept for the node to read back as `probe` keeps it. For a search - /// with a move excluded: the entry's score is the excluded move's, so - /// it answers nothing about the rest. + /// beside it as `probe` hands it. For a search with a move + /// excluded: the entry's score is the excluded move's, so it answers + /// nothing about the rest. #[inline(always)] - pub(crate) fn probe_for_ordering(&self, board: &Board) -> Probe { + pub(crate) fn probe_for_ordering(&self, board: &Board) -> (Probe, Found) { let (found, _) = self.get_audited(board.key); - #[cfg(debug_assertions)] - self.probed_key.set(board.key); - self.probed.set(found); - match found { + let probe = match found { Some(pv) => Probe::Order(pv.play), None => Probe::Miss, - } - } - - /// The entry the last probe found for the position `key` names, or - /// none. The last probe must have been of `key`: a search between the - /// two would leave its own entry here. - #[inline(always)] - fn probed(&self, key: u64) -> Option { - #[cfg(debug_assertions)] - assert_eq!(self.probed_key.get(), key, "another probe came between"); - #[cfg(not(debug_assertions))] - let _ = key; - self.probed.get() - } - - /// The static evaluation the last probe found in the entry of the - /// position `key` names, or `NO_EVAL` where it found none or the entry - /// holds none. - #[inline(always)] - pub(crate) fn probed_eval(&self, key: u64) -> Score { - self.probed(key).map_or(NO_EVAL, |pv| pv.static_eval) - } - - /// The floor the last probe's entry puts under the position `key` - /// names, where its bound is a floor or the truth, or none on a miss or - /// a ceiling. The score is made relative to the root at `line_ply`. - #[inline(always)] - pub(crate) fn probed_floor(&self, key: u64, line_ply: usize) -> Option { - let pv = self.probed(key)?; - match pv.bound { - Bound::Exact | Bound::Lower => Some(Floor { - play: pv.play, - score: score_from_tt(pv.score, line_ply), - depth: pv.depth, - }), - Bound::Upper | Bound::Ordering => None, - } + }; + (probe, Found(found)) } /// The move to try first here, whatever wrote it, quiescence included. @@ -1506,18 +1511,18 @@ mod tests { let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); assert!(table.record_ceiling(&board, play, Value::clean(-50), 5, super::NO_EVAL)); - match table.probe(&board, -10, 10, 5, true, false) { + match table.probe(&board, -10, 10, 5, true, false).0 { Probe::Cut(value) => assert_eq!(value, Value::clean(-50)), other => panic!("a ceiling under alpha did not cut: {other:?}"), } assert!(matches!( - table.probe(&board, -100, 10, 5, true, false), + table.probe(&board, -100, 10, 5, true, false).0, Probe::Order(_) )); } /// The ordering probe hands back the move of an entry that would have - /// cut, and keeps the entry for the floor read as the probe does; a + /// cut, and the entry beside it for the floor read as the probe does; a /// ceiling is no floor under either probe. #[test] fn an_ordering_probe_never_cuts_and_a_floor_is_read_from_either() { @@ -1526,24 +1531,20 @@ mod tests { let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); assert!(table.record_cutoff(&board, play, Value::clean(50), 5, super::NO_EVAL)); - assert!(matches!( - table.probe(&board, -10, 10, 5, true, false), - Probe::Cut(_) - )); + let (probe, found) = table.probe(&board, -10, 10, 5, true, false); + assert!(matches!(probe, Probe::Cut(_))); let floor = Some(Floor { play, score: 50, depth: 5, }); - assert_eq!(table.probed_floor(board.key, 0), floor); - assert!(matches!( - table.probe_for_ordering(&board), - Probe::Order(found) if found == play - )); - assert_eq!(table.probed_floor(board.key, 0), floor); + assert_eq!(found.floor(0), floor); + let (probe, found) = table.probe_for_ordering(&board); + assert!(matches!(probe, Probe::Order(found) if found == play)); + assert_eq!(found.floor(0), floor); assert!(table.record_ceiling(&board, play, Value::clean(-50), 6, super::NO_EVAL)); - let _ = table.probe_for_ordering(&board); - assert_eq!(table.probed_floor(board.key, 0), None); + let (_, found) = table.probe_for_ordering(&board); + assert_eq!(found.floor(0), None); } #[test] @@ -1584,7 +1585,10 @@ mod tests { assert!(table.record_best(&board, play, stored, 5, super::NO_EVAL)); board.line_ply = PROBED_AT; - match table.probe(&board, Score::MIN + 1, Score::MAX - 1, 5, false, false) { + match table + .probe(&board, Score::MIN + 1, Score::MAX - 1, 5, false, false) + .0 + { Probe::Cut(value) => assert_eq!(value.score, wanted.score, "{what}"), other => panic!("{what} did not cut: {other:?}"), } @@ -1603,10 +1607,10 @@ mod tests { let play = Play::new(0, 1, None, None, false, false); assert!(table.record_best(&fresh, play, Value::clean(0), 5, super::NO_EVAL)); assert!(matches!( - table.probe(&fresh, -10, 10, 5, false, true), + table.probe(&fresh, -10, 10, 5, false, true).0, Probe::Cut(_) )); - match table.probe(&near, -10, 10, 5, false, true) { + match table.probe(&near, -10, 10, 5, false, true).0 { Probe::Refused(refused) => assert_eq!(refused, play), other => panic!("the horizon did not refuse the cutoff: {other:?}"), } @@ -1621,17 +1625,17 @@ mod tests { let board = crate::board::Board::new(); let play = Play::new(0, 1, None, None, false, false); assert!(table.record_best(&board, play, Value::tainted(0), 5, super::NO_EVAL)); - match table.probe(&board, -10, 10, 5, false, false) { + match table.probe(&board, -10, 10, 5, false, false).0 { Probe::Cut(value) => assert_eq!(value, Value::tainted(0)), other => panic!("a trusting probe did not cut: {other:?}"), } - match table.probe(&board, -10, 10, 5, true, false) { + match table.probe(&board, -10, 10, 5, true, false).0 { Probe::Refused(refused) => assert_eq!(refused, play), other => panic!("a refusing probe did not refuse: {other:?}"), } // too shallow to cut is a move alone, not a refusal assert!(matches!( - table.probe(&board, -10, 10, 6, true, false), + table.probe(&board, -10, 10, 6, true, false).0, Probe::Order(_) )); } @@ -1645,7 +1649,7 @@ mod tests { assert!(table.record_best(&board, play, Value::clean(20), 5, super::NO_EVAL)); let shared = &table; assert!(matches!( - shared.probe(&board, -10, 10, 5, true, false), + shared.probe(&board, -10, 10, 5, true, false).0, Probe::Cut(_) )); } @@ -2033,7 +2037,7 @@ mod tests { // a key differing above the slice, sharing the one bucket's index let mut twin = board.clone(); twin.key ^= 1 << 63; - match table.probe(&twin, -10, 10, 5, true, false) { + match table.probe(&twin, -10, 10, 5, true, false).0 { Probe::Cut(value) => assert_eq!(value, Value::clean(20)), other => panic!("the foreign entry did not cut: {other:?}"), } From 4d4741e5be57c8f2f4b9d5d9fca0380753deda86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:12:06 +0000 Subject: [PATCH 2/3] perf(search): Build the late move rules only after a node's first searched move, in a two phase move loop A full width node built the node halves of the late move reduction and of quiet futility and move count pruning (`Rules::new`) before its move loop, and the compiler worked out the loop's invariants for the gate on entry. No rule acts on the first move a node searches, and most loops never get past it: on the games suite the rules are now built at 808,818 of 1,823,170 loops (counted by an assertion build). The loop now runs in two phases when the table's move was not searched. The first takes the places up to the first move searched, asks no rule, and stops after that move; a cutoff returns as before. Only a node with a place left builds the rules and enters the second phase, which is the old loop less its test for the first move. Three smaller changes go with it: - The loop reads the move list through a slice, so an index no longer asks whether the list spilled its inline buffer. - The quiet ordering keeps the first place it has work at (`QuietOrder::at`), so a place outside the quiet run asks one compare in place of three tests. - The two rule halves hold the mate test on alpha, asked again when the loop raises alpha (`Rules::raise`), rather than asking it at every move. This puts back a value kept in step with alpha, which ba83869 removed. The debug build checks the held answer against the test at every call. Before the first searched move the searched count is zero, so no rule could have acted there. The rules read the node's facts, the position and the evaluation, none of which the first search changes, and alpha, which the held test reads when the rules are built and at every rise. Node counts and every bench line are unchanged. Callgrind against the parent: the games suite's tree search goes from 10,847,099,545 instructions to 10,525,445,589 (2.911 points of the tree search before the series), the bench's from 15,987,709,636 to 15,600,283,382 (2.383 points). `alpha_beta` shrinks from 15,812 bytes to 13,713, with the quiet keying (1,571 bytes) and `Rules::new` (364) now out of line. Bench: 11088536 Speed: +0.3% (bench nps, 95% interval -1.1% to +1.8%, 60 interleaved rounds over shuffled layouts vs 3a8a067) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae --- arche-core/src/engine.rs | 230 ++++++++++++++++++++++----------- arche-core/src/engine/tests.rs | 2 +- arche-core/src/late_move.rs | 122 ++++++++++++----- 3 files changed, 249 insertions(+), 105 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index ae1189de..68d57682 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -128,6 +128,20 @@ fn null_move_reduction(config: SearchConfig, depth: u8, eval_beta: Score) -> u8 grown.min(depth - 1) } +/// The moves a node searched before place `i`, not the whole list: the +/// history marks down what the node asked and got nothing from. +fn tried_before<'a>( + moves: &'a [Play], + made: &'a Searched, + i: usize, +) -> impl Iterator + 'a { + moves[..i] + .iter() + .enumerate() + .filter(|(place, _)| made.holds(*place)) + .map(|(_, tried)| tried) +} + /// Quiescence's delta test: a capture short of alpha with its piece counted /// as fully won is expected to be worth less than alpha. Which captures it /// applies to is the caller's, and the move loop says why. @@ -353,6 +367,11 @@ struct QuietOrder { scored: bool, /// Whether a shallow rule filtered the run. filtered: bool, + /// The first place at which there is work to do: the front while the + /// run waits to be keyed, the next place while it is taken in order, + /// then the dropped run's start. The loop's other places ask nothing + /// more of the ordering. + at: usize, } impl QuietOrder { @@ -365,6 +384,11 @@ impl QuietOrder { dropped: usize::MAX..usize::MAX, scored: false, filtered: false, + at: if ply.is_some() { + ordered.front + } else { + usize::MAX + }, } } } @@ -2564,16 +2588,24 @@ impl AlphaBeta { /// on for the rest of the node, when only the moves it would still /// search are kept and the rest of the run is dropped. Returns the /// place the loop continues from when place `i` opens a dropped run, - /// which the loop steps past whole. + /// which the loop steps past whole. `rules` is none before the node's + /// first searched move, where no shallow rule is on. A place under + /// `QuietOrder::at` asks nothing more. #[inline(always)] fn order_quiets_at( &mut self, - moves: &mut MoveList, + moves: &mut [Play], i: usize, quiets: &mut QuietOrder, node: &Node, - rules: &mut late_move::Rules, + rules: Option<&mut late_move::Rules>, ) -> Option { + debug_assert!(rules.is_some() || node.answer.searched == 0); + if i < quiets.at { + debug_assert!(i != quiets.front || quiets.ply.is_none()); + debug_assert!(quiets.lazy.is_none() && i < quiets.dropped.start); + return None; + } let front = quiets.front; if i == front { if let Some(ply) = quiets.ply { @@ -2606,7 +2638,15 @@ impl AlphaBeta { let end = *end; if i + 1 >= end { quiets.lazy = None; - } else if rules.shallow_active(&self.deciding(), node) { + } else if let Some(rules) = + // none before the node's first searched move, where neither + // shallow rule is on + rules.and_then(|rules| { + rules + .shallow_active(&self.deciding(), node) + .then_some(rules) + }) + { let board = &self.board; let info = *rules.check.get_or_insert_with(|| board.check_info()); // the promotions and the checks, a handful at most, which @@ -2638,8 +2678,14 @@ impl AlphaBeta { debug_assert!(i < quiets.dropped.end, "the dropped run is behind the loop"); let past = quiets.dropped.end; quiets.dropped = usize::MAX..usize::MAX; + quiets.at = usize::MAX; return Some(past); } + quiets.at = if quiets.lazy.is_some() { + i + 1 + } else { + quiets.dropped.start + }; None } @@ -2651,8 +2697,8 @@ impl AlphaBeta { /// 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. The - /// first move the node searches is no late move, and none of this is - /// asked of it. + /// first move the node searches is no late move, and the loop asks + /// none of this of it. #[inline(always)] fn late_move_decision( &mut self, @@ -2661,9 +2707,7 @@ impl AlphaBeta { moves: &[Play], m: &Play, ) -> Decision { - if node.answer.searched == 0 { - return Decision::First; - } + debug_assert!(node.answer.searched > 0, "the first move is no late move"); if rules.skips(&self.deciding(), node, m) { if self.forced.is_some() && self.forced_skip(node, rules, moves, m) { return Decision::UNREDUCED; @@ -2910,88 +2954,128 @@ impl AlphaBeta { None }; 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 moves = moves.as_mut_slice(); let len = moves.len(); let mut next = 0; - while next < len { - let i = next; - next += 1; - if let Some(past) = self.order_quiets_at(&mut moves, i, &mut quiets, &node, &mut rules) - { - next = past; - continue; - } - let m = &moves[i]; - if tt_at == Some(i) { - debug_assert_eq!(table_move, Some(*m), "the place is not the table's move"); - if tt_searched { - made.mark(i); + // up to the first move searched, when the table's move was not: + // that move is no late move and no rule is asked of it, so a node + // whose first move cuts never builds the rules' node halves + if !tt_searched { + while next < len { + let i = next; + next += 1; + if let Some(past) = self.order_quiets_at(moves, i, &mut quiets, &node, None) { + next = past; + continue; } - continue; - } - if Some(*m) == excluded { - continue; - } - 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.answer.alpha, - node.answer.beta, - depth, - &decision, - node.answer.root_bounds, - )? - else { - continue; - }; - made.mark(i); - match node.answer.absorb(m, value) { - Reached::Beta => { + let m = &moves[i]; + // the table's place holds a move that was not legal here + if tt_at == Some(i) || Some(*m) == excluded { + continue; + } + let Some(value) = self.search_child( + m, + node.answer.alpha, + node.answer.beta, + depth, + &Decision::First, + node.answer.root_bounds, + )? + else { + continue; + }; + made.mark(i); + if let Reached::Beta = node.answer.absorb(m, value) { let cutting = census::Cutting { play: m, - reduced: matches!(decision, Decision::Search { reduction: 1.., .. }), + reduced: false, table: false, }; - 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 - let tried = moves[..i] - .iter() - .enumerate() - .filter(|(place, _)| made.holds(*place)) - .map(|(_, tried)| tried); + self.record_node(&node, moves, quiets.scored, Some(cutting)); + let tried = tried_before(moves, &made, i); return Ok(self.cutoff(m, tried, &node, value.score, static_eval)); } - Reached::Alpha => { - // the dropped moves stay dropped only while alpha is - // short of a mate. The rules admit no node whose beta - // is a mate score, so any mate a survivor finds is at - // or above beta and has cut the node off before - // reaching here - debug_assert!( - !(quiets.filtered && is_mate(node.answer.alpha)), - "a filtered node raised alpha to a mate without cutting off" - ); + break; + } + } + // built only for a node with a place after its first searched move + if next < len { + let mut rules = late_move::Rules::new(&self.deciding(), &node, eval); + while next < len { + let i = next; + next += 1; + if let Some(past) = + self.order_quiets_at(moves, i, &mut quiets, &node, Some(&mut rules)) + { + next = past; + continue; + } + let m = &moves[i]; + if tt_at == Some(i) { + debug_assert_eq!(table_move, Some(*m), "the place is not the table's move"); + if tt_searched { + made.mark(i); + } + continue; } - Reached::Neither => {} + if Some(*m) == excluded { + continue; + } + 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.answer.alpha, + node.answer.beta, + depth, + &decision, + node.answer.root_bounds, + )? + else { + continue; + }; + made.mark(i); + match node.answer.absorb(m, value) { + Reached::Beta => { + let cutting = census::Cutting { + play: m, + reduced: matches!(decision, Decision::Search { reduction: 1.., .. }), + table: false, + }; + self.record_node(&node, moves, quiets.scored, Some(cutting)); + let tried = tried_before(moves, &made, i); + return Ok(self.cutoff(m, tried, &node, value.score, static_eval)); + } + Reached::Alpha => { + rules.raise(node.answer.alpha); + // the dropped moves stay dropped only while alpha is + // short of a mate. The rules admit no node whose beta + // is a mate score, so any mate a survivor finds is at + // or above beta and has cut the node off before + // reaching here + debug_assert!( + !(quiets.filtered && is_mate(node.answer.alpha)), + "a filtered node raised alpha to a mate without cutting off" + ); + } + Reached::Neither => {} + } + debug_assert_eq!( + made.count(), + node.answer.searched, + "a bit for every move made and searched, and for no other" + ); } - debug_assert_eq!( - made.count(), - node.answer.searched, - "a bit for every move made and searched, and for no other" - ); } // 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(&node, &moves, quiets.scored, None); + self.record_node(&node, moves, quiets.scored, None); if node.answer.searched == 0 { // clean: mate and stalemate are properties of the position diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 6d2c5a3e..49b9ab06 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -4175,7 +4175,7 @@ mod node { let mut rules = late_move::Rules::new(&e.deciding(), &node, None); let front = ordered.front; assert_eq!( - e.order_quiets_at(&mut moves, front, &mut quiets, &node, &mut rules), + e.order_quiets_at(&mut moves, front, &mut quiets, &node, Some(&mut rules)), None ); assert!(quiets.filtered); diff --git a/arche-core/src/late_move.rs b/arche-core/src/late_move.rs index 342fb2c7..9d485efc 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -9,9 +9,11 @@ //! 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 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 +//! has searched `LATE_MOVE_COUNT` moves a ply. Everything in them but +//! alpha and the searched count is settled by the node before the moves +//! they can drop, so `Rules` holds it. It holds the mate test on alpha as +//! well, asked again at each rise of alpha (`Rules::raise`), and reads the +//! searched count, and alpha for the margin, off the engine's `Node` per //! move. They stop a ply under the deep skip's floor, so a shallow rule //! and the deep skip never decide at one depth. //! @@ -162,11 +164,13 @@ pub(crate) struct Search<'a> { /// 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. +/// the node has searched a move (the table's or the loop's first) and has +/// a place left, since no rule acts on the first move searched. The node's +/// facts, its alpha as it stands and its searched count are the engine's +/// `Node`, handed to every call. Each half holds whether alpha is short of +/// a mate, so the loop hands every rise of alpha to `raise`. The list is +/// not held, since the loop sorts it under the decision; the calls that +/// read it are handed it. pub(crate) struct Rules { /// The node's static evaluation: what the table's entry held or the /// shortcuts read, or none until the first move that needs it. The @@ -185,9 +189,9 @@ pub(crate) struct Rules { } impl Rules { - /// The rules once the node's table move has been searched, with `eval` - /// as the table and the shortcuts left it. The two rule halves are read - /// off the node's facts here. + /// The rules once the node has searched a move, with `eval` as the + /// table and the shortcuts left it. The two rule halves are read off the + /// node's facts here, and the mate test off its alpha as it stands. pub(crate) fn new(search: &Search, node: &Node, eval: Option) -> Self { Self { eval, @@ -198,6 +202,15 @@ impl Rules { } } + /// The rules at the node's alpha as it now stands: the loop hands + /// them every rise, since a rise into the mate window turns both rules + /// off. + #[inline] + pub(crate) fn raise(&mut self, alpha: Score) { + self.shallow.raise(alpha); + self.admission.raise(alpha); + } + /// Whether either shallow rule drops this move, at the node's alpha and /// searched count as the move is reached. #[inline] @@ -286,14 +299,17 @@ pub(crate) fn decide(search: &Search, node: &Node, rules: &mut Rules, m: &Play) /// 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. +/// both are read against alpha as it stands: the exemption held in `reach` +/// and read again at each rise (`Rules::raise`), and the margin again +/// whenever alpha differs from the one it was last found short at. struct Shallow { /// 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, + /// `from` while alpha is short of a mate, else never: the count the + /// loop compares, so it asks no mate test per move. + reach: usize, /// `QUIET_FUTILITY_MARGIN` at this node's depth. margin: i32, /// The searched count at or past which the count drops a quiet, or @@ -317,8 +333,8 @@ enum Under { Held, } -/// The node's half of the two rules, read once before the loop off the -/// node's facts. +/// The node's half of the two rules, read off the node's facts when the +/// rules are built, after the node's first searched move. fn shallow(config: &SearchConfig, board: &Position, node: &Node) -> Shallow { let admits = (config.quiet_futility || config.late_move_count) && (1..=SHALLOW_MAX_DEPTH).contains(&node.depth) @@ -326,8 +342,14 @@ fn shallow(config: &SearchConfig, board: &Position, node: &Node) -> Shallow { && !is_mate(node.answer.beta) && !node.answer.root_bounds.beta_is_roots() && board.has_non_pawn_material(); + let from = if admits { 1 } else { usize::MAX }; Shallow { - from: if admits { 1 } else { usize::MAX }, + from, + reach: if is_mate(node.answer.alpha) { + usize::MAX + } else { + from + }, margin: i32::from(QUIET_FUTILITY_MARGIN) * i32::from(node.depth), count: if admits && config.late_move_count { LATE_MOVE_COUNT * usize::from(node.depth) @@ -344,11 +366,25 @@ fn shallow(config: &SearchConfig, board: &Position, node: &Node) -> Shallow { impl Shallow { /// 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. + /// alpha: one compare, with the mate test on alpha held in `reach`. A + /// debug build asks the test again. #[inline] fn reached(&self, searched: usize, alpha: Score) -> bool { - searched >= self.from && !is_mate(alpha) + debug_assert_eq!( + searched >= self.reach, + searched >= self.from && !is_mate(alpha), + "the held mate test" + ); + searched >= self.reach + } + + /// The mate test on a risen alpha. + fn raise(&mut self, alpha: Score) { + self.reach = if is_mate(alpha) { + usize::MAX + } else { + self.from + }; } /// Whether either rule drops this move, at the node's alpha and @@ -418,12 +454,14 @@ pub(crate) fn survives_shallow(board: &Position, info: &CheckInfo, m: &Play) -> } /// The late move reduction's node half: what the node's facts settle, held -/// across the loop, so the loop asks the searched count, the mate test and -/// the move per move. +/// across the loop with the mate test on alpha, so the loop asks the +/// searched count and the move per move. struct Admission { /// The searched count from which the node admits a reduction while /// alpha is short of a mate, or never. from: usize, + /// `from` while alpha is short of a mate, else never. + reach: usize, } fn admission(config: &SearchConfig, node: &Node) -> Admission { @@ -432,11 +470,17 @@ fn admission(config: &SearchConfig, node: &Node) -> Admission { && !node.in_check && !is_mate(node.answer.beta) && !node.answer.root_bounds.beta_is_roots(); + let from = if admits { + LATE_MOVE_THRESHOLD + } else { + usize::MAX + }; Admission { - from: if admits { - LATE_MOVE_THRESHOLD - } else { + from, + reach: if is_mate(node.answer.alpha) { usize::MAX + } else { + from }, } } @@ -446,7 +490,21 @@ impl Admission { /// move: the searched count and alpha against the half held. #[inline] fn admits(&self, node: &Node) -> bool { - node.answer.searched >= self.from && !is_mate(node.answer.alpha) + debug_assert_eq!( + node.answer.searched >= self.reach, + node.answer.searched >= self.from && !is_mate(node.answer.alpha), + "the held mate test" + ); + node.answer.searched >= self.reach + } + + /// The mate test on a risen alpha. + fn raise(&mut self, alpha: Score) { + self.reach = if is_mate(alpha) { + usize::MAX + } else { + self.from + }; } /// Whether a move at a full width node is scouted shallower before it @@ -837,6 +895,7 @@ mod tests { ) -> bool { node.answer.alpha = alpha; node.answer.searched = searched; + rules.raise(alpha); let skips = rules.skips(&self.search(), node, m); self.keep(rules); skips @@ -914,10 +973,10 @@ 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. + /// The reduction's node half holds the mate test on alpha, read when + /// it is built and again at each rise the loop hands it (`raise`), so + /// alpha risen into the mate window admits nothing at any count. 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(); @@ -930,7 +989,7 @@ mod tests { None, FailSoft::open(0, 100, RootBounds::Neither, Taint::default()), ); - let half = admission(&config, &node); + let mut half = admission(&config, &node); for searched in 0..64 { node.answer.searched = searched; assert_eq!( @@ -944,6 +1003,7 @@ mod tests { assert!( crate::value::is_mate(node.answer.alpha) && !crate::value::is_mate(node.answer.beta) ); + half.raise(node.answer.alpha); for searched in 0..64 { node.answer.searched = searched; assert!(!half.admits(&node), "a mate alpha at {searched} searched"); From 43a4d206a60fbe49f4215ee02f85b544016d1579 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:12:06 +0000 Subject: [PATCH 3/3] perf(search): Ask the first search from the move loop and send a depth zero child to a leaf entry Every searched move went through `windowed`, an out of line function between the node and its child that asked the child's searches: on the games suite 5,925,401 calls at 56.7 instructions of its own each, for what is one search unless a later move rises above alpha. A child at depth zero out of check then entered `alpha_beta` only to run its entry tests and hand the position to quiescence, which repeated part of them. `search_child` now asks principal variation search's first search itself. The rest of the sequence (the probe after a scout that rose above alpha, and the proof at an open window) is the cold `windowed_rest`. `windowed` stays whole for a move whose scout an instrument staged, so the ledger and the forced decision instrument see the calls they saw; a debug assertion holds that a reduced move reaching the new path has no instrument armed. A child at depth zero out of check goes to `leaf`, a small out of line entry that runs `alpha_beta`'s entry for such a node in its order (the poll, the node count, the fifty move rule and repetition, the rail, mate distance) and then the quiescence body. Quiescence is split into an inlined head and that one body, so there is still one copy of it. The null move's pass goes through the same dispatch. The leaf writes out `answered_by_rule` with the check known false, and comments on both and on `alpha_beta`'s depth zero exit say a rule added to one goes to the other. A new test asks `alpha_beta` at depth zero and the leaf the same nodes on two engines (every bench and games suite position out of check, a fifty move draw and a repetition, at four line plies and seven windows, two of which mate distance narrows or closes) and holds the values, taints, node counts and selective depth equal. Another asks `search_child` and `windowed` the same children under the loop's three decisions, at positions two or three plies short of the fifty move rule, and holds the values, the taints `windowed_rest` carries and the node counts equal. Every node is searched with the window, depth and bounds it had. Node counts and every bench line are unchanged. Callgrind against the parent: the games suite's tree search goes from 10,525,445,589 instructions to 10,231,570,885 (2.659 points of the tree search before the series), the bench's from 15,600,283,382 to 15,285,054,673 (1.939 points). The code on the per child path grows by about 425 bytes, with `windowed` (705) and `windowed_rest` (214) now off it, and a leaf touches 624 bytes of stack where the parent's call into `windowed`, `alpha_beta` and quiescence touched 2,184. Bench: 11088536 Speed: +1.1% (bench nps, 95% interval -1.3% to +3.5%, 60 interleaved rounds over shuffled layouts vs 4d4741e) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DtepPHhwporfDvjPSnX4ae --- arche-core/src/engine.rs | 174 ++++++++++++++++++++++++++++++++- arche-core/src/engine/tests.rs | 134 +++++++++++++++++++++++++ arche-core/src/late_move.rs | 2 +- 3 files changed, 305 insertions(+), 5 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 68d57682..7acf90bb 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -2039,6 +2039,7 @@ impl AlphaBeta { } } + #[inline(always)] fn quiescence(&mut self, alpha: Score, beta: Score) -> Result { // no repetition check: a capture cannot repeat a position and the // only quiet moves here are evasions, so a cycle needs a line of @@ -2057,6 +2058,45 @@ impl AlphaBeta { // searches those nodes full width), so a check seen here was // delivered by a capture searched here. Fail soft. let in_check = self.board.in_check(); + self.quiescence_body(alpha, beta, in_check) + } + + /// A full width child at depth zero out of check: `alpha_beta`'s entry + /// for such a node, then quiescence's, then the body they share. + /// The draw rules and the rail below are `answered_by_rule`'s with the + /// check known false, written out; the two change together. + #[inline(never)] + fn leaf(&mut self, mut alpha: Score, mut beta: Score) -> Result { + // `alpha_beta`'s entry at depth zero out of check, in its order + self.poll_deadline()?; + self.selective_depth = self.selective_depth.max(self.board.line_ply as u8); + self.nodes += 1; + debug_assert!(!self.board.in_check()); + if self.board.fifty_move_expired() || self.board.has_repeated() { + return Ok(Value::tainted(0)); + } + if self.board.line_ply >= MAX_PLY as usize { + return Ok(Value::clean(self.eval())); + } + (alpha, beta) = match mate_distance_window(alpha, beta, self.board.line_ply) { + MateDistanceWindow::Open { alpha, beta } => (alpha, beta), + MateDistanceWindow::Closed(score) => return Ok(Value::clean(score)), + }; + + self.poll_deadline()?; + self.nodes += 1; + self.quiescence_nodes += 1; + self.quiescence_body(alpha, beta, false) + } + + /// Quiescence after its entry, which says whether the side is in check. + #[inline(never)] + fn quiescence_body( + &mut self, + alpha: Score, + beta: Score, + in_check: bool, + ) -> Result { // a stalemate is not in check, so standing pat would read it as the // eval. Only a side with nothing but pawns and a king is asked: a // piece almost always has a move, and where the king test fails the @@ -2294,13 +2334,12 @@ impl AlphaBeta { if pass && eval >= beta { let reduction = null_move_reduction(self.config, depth, eval - beta); self.board.make_null_move(); - let result = self.alpha_beta( + let result = self.child_search( -beta, -beta + 1, depth - 1 - reduction, false, root_bounds.child(ChildSearch::Pass), - None, ); // undo before an abort can propagate self.board.undo_null_move(); @@ -2343,6 +2382,10 @@ impl AlphaBeta { /// 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. + /// + /// The first search `windowed` would ask is asked here, and the rest of + /// the sequence (`windowed_rest`) only where it rose above alpha. A + /// child at depth zero out of check goes straight to `leaf`. #[inline(always)] fn search_child( &mut self, @@ -2360,11 +2403,124 @@ impl AlphaBeta { if !self.board.make_move(m) { return Ok(None); } - let result = self.windowed(alpha, beta, depth, decision, root_bounds); + // the first search `windowed` would ask, asked here. At a zero + // window with no reduction it is the only one, and the rest of the + // sequence runs only where it rose above alpha. The instruments' + // scouts keep the whole sequence in `windowed` + let result = match decision { + Decision::First => self + .child_search( + -beta, + -alpha, + depth - 1, + true, + root_bounds.child(ChildSearch::FirstMove), + ) + .map(|value| -value), + Decision::Search { + reduction, + staged: None, + } => { + let reduction = *reduction; + // the scout `forced_scout` and `ledger_event` read is staged + // whenever either is armed (`stage_scout`), so a scout here + // has neither to tell + debug_assert!( + reduction == 0 || (self.forced.is_none() && self.ledger.is_none()), + "an instrument's scout was not staged" + ); + match self.child_search( + -alpha - 1, + -alpha, + depth - 1 - reduction, + true, + root_bounds.child(ChildSearch::Probe), + ) { + Ok(value) => { + let value = -value; + if value.score > alpha && (reduction > 0 || alpha + 1 < beta) { + self.windowed_rest(value, alpha, beta, depth, reduction, root_bounds) + } else { + Ok(value) + } + } + Err(aborted) => Err(aborted), + } + } + _ => self.windowed(alpha, beta, depth, decision, root_bounds), + }; self.board.undo_move(); Ok(Some(result?)) } + /// What `windowed` asks after its first search rose above alpha at a + /// later move: after a scout, the probe at full depth and then the + /// proof; after a probe, the proof. + #[cold] + #[inline(never)] + fn windowed_rest( + &mut self, + first: Value, + alpha: Score, + beta: Score, + depth: u8, + reduction: u8, + root_bounds: RootBounds, + ) -> Result { + let (probe, tainted) = if reduction > 0 { + debug_assert!(depth > reduction + 1, "the scout would be quiescence"); + let probe = -self.alpha_beta( + -alpha - 1, + -alpha, + depth - 1, + true, + root_bounds.child(ChildSearch::Probe), + None, + )?; + if probe.score <= alpha || alpha + 1 >= beta { + return Ok(Value::with_taint( + probe.score, + probe.tainted || first.tainted, + )); + } + (probe, first.tainted) + } else { + (first, false) + }; + let proof = -self.alpha_beta( + -beta, + -alpha, + depth - 1, + true, + root_bounds.child(ChildSearch::Proof), + None, + )?; + Ok(Value::with_taint( + proof.score, + proof.tainted || probe.tainted || tainted, + )) + } + + /// A child of a full width node, asked straight after the make. A + /// child at depth zero and out of check is a leaf: what `alpha_beta` + /// asks of it before handing it to quiescence is asked by the leaf's + /// own entry, without the full width frame between them. + #[inline(always)] + fn child_search( + &mut self, + alpha: Score, + beta: Score, + depth: u8, + can_null: bool, + root_bounds: RootBounds, + ) -> Result { + if depth == 0 && !self.board.in_check() { + self.leaf(alpha, beta) + } else { + self.alpha_beta(alpha, beta, depth, can_null, root_bounds, None) + } + } + /// Principal variation search: the recursion behind one made move. The /// node's first move is searched with the window as it stands. Every /// later move is probed with a zero width search first, which can only @@ -2379,7 +2535,11 @@ impl AlphaBeta { /// does. /// /// A body of its own rather than `search_child`'s so that an abort from - /// any pass runs through the one undo there. + /// any pass runs through the one undo there. `search_child` asks the + /// first search of this sequence itself and `windowed_rest` the rest, + /// so this whole body runs only for a move the instruments staged a + /// scout for. + #[inline(never)] fn windowed( &mut self, alpha: Score, @@ -2738,6 +2898,9 @@ impl AlphaBeta { /// below the root, so a /// repetition is a draw either side can take; at the root the engine /// still has to move. + /// + /// `leaf` asks the same rules of a depth zero child out of check, + /// written out there, so a rule added here goes there too. fn answered_by_rule(&mut self, in_check: bool) -> Option { if self.board.fifty_move_expired() { // a mate delivered by the hundredth half move is a mate. A @@ -2880,6 +3043,9 @@ impl AlphaBeta { if in_check { depth += 1; } + // a child the loop asks at depth zero out of check goes to `leaf` + // instead, which repeats the entry above: a change to it goes there + // too if depth == 0 { return self.quiescence(alpha, beta); } diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 49b9ab06..75d81bcb 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -463,6 +463,74 @@ mod search { assert_eq!(e.nodes, 7); } + #[test] + fn the_loop_asks_a_child_as_windowed_does() { + // `search_child` asks a move's first search itself and the rest of + // principal variation search from `windowed_rest`, which carries + // the taints as `windowed` did. Both are asked the same children + // under the three decisions the loop makes (the first move, a later + // one, and a later one scouted a ply shallower), each on an engine + // of its own, and must return the same values and taints after + // counting the same nodes. The positions stand two or three plies + // short of the fifty move rule, so many scores carry a draw's taint. + let decisions = [Decision::First, Decision::UNREDUCED, later(1)]; + let mut asked = 0; + let mut tainted = 0; + for position in crate::bench::positions() + .iter() + .chain(crate::bench::games().iter()) + { + for counter in ["97", "98"] { + let mut fields: Vec<&str> = position.fen.split_whitespace().take(4).collect(); + fields.extend([counter, "60"]); + let board = Board::from_fen(&fields.join(" ")).unwrap(); + if board.in_check() { + continue; + } + let mut direct = AlphaBeta::with_table_bytes(board.clone(), 64 * 1024); + let mut whole = AlphaBeta::with_table_bytes(board, 64 * 1024); + let moves = direct.board.generate_moves(); + for m in moves.iter().take(4) { + for decision in &decisions { + for (alpha, beta) in [(-30, 30), (-1, 0), (0, 1), (40, 41)] { + let Ok(answer) = direct.search_child( + m, + alpha, + beta, + 3, + decision, + RootBounds::Neither, + ) else { + panic!("an unlimited search aborted"); + }; + let expected = if whole.board.make_move(m) { + let result = + whole.windowed(alpha, beta, 3, decision, RootBounds::Neither); + whole.board.undo_move(); + let Ok(value) = result else { + panic!("an unlimited search aborted"); + }; + Some(value) + } else { + None + }; + assert_eq!( + answer.map(|v| (v.score, v.tainted)), + expected.map(|v| (v.score, v.tainted)), + "{} {m} {alpha} {beta}", + position.id + ); + assert_eq!(direct.nodes, whole.nodes); + asked += 1; + tainted += usize::from(answer.is_some_and(|v| v.tainted)); + } + } + } + } + } + assert!(asked > 5000 && tainted > 500, "{asked} {tainted}"); + } + #[test] fn a_re_search_answers_with_its_own_score_not_the_probes() { // The node count above says a re-search ran, not whose answer came @@ -4193,3 +4261,69 @@ mod node { assert_eq!(kept - front, 6); } } + +mod leaf { + use crate::board::play_named; + use crate::engine::{AlphaBeta, Board, MAX_PLY, RootBounds, Score}; + use crate::value::CHECKMATE_THRESHOLD; + + #[test] + fn the_leaf_answers_as_alpha_beta_does_at_depth_zero() { + // A child at depth zero out of check goes from its parent to the + // leaf, which asks what `alpha_beta` asks of such a node before it + // hands the node to quiescence. The two are asked the same nodes + // here, each on an engine of its own, and must return the same + // values after counting the same nodes: the draw rules, the rail + // and mate distance included. + let mut boards: Vec = crate::bench::positions() + .iter() + .chain(crate::bench::games().iter()) + .map(|position| Board::from_fen(&position.fen).unwrap()) + .collect(); + boards.push(Board::from_fen("8/8/4k3/8/8/3K4/8/7R w - - 100 80").unwrap()); + let mut again = Board::from_fen(crate::STARTING_FEN).unwrap(); + for name in ["g1f3", "g8f6", "f3g1", "f6g8"] { + let m = play_named(&again, name); + assert!(again.make_move(&m)); + } + assert!(again.has_repeated()); + boards.push(again); + let windows = [ + (Score::MIN + 1, Score::MAX - 1), + (-30, 30), + (0, 1), + (CHECKMATE_THRESHOLD, Score::MAX - 1), + (Score::MIN + 1, -CHECKMATE_THRESHOLD), + // windows mate distance narrows or closes at these plies + (CHECKMATE_THRESHOLD + 995, Score::MAX - 1), + (Score::MIN + 1, -CHECKMATE_THRESHOLD - 995), + ]; + let mut asked = 0; + for board in boards.into_iter().filter(|board| !board.in_check()) { + let mut full = AlphaBeta::with_table_bytes(board.clone(), 64 * 1024); + let mut leaf = AlphaBeta::with_table_bytes(board, 64 * 1024); + for line_ply in [0, 3, MAX_PLY as usize - 1, MAX_PLY as usize] { + full.board.line_ply = line_ply; + leaf.board.line_ply = line_ply; + for (alpha, beta) in windows { + let (Ok(expected), Ok(value)) = ( + full.alpha_beta(alpha, beta, 0, true, RootBounds::Neither, None), + leaf.leaf(alpha, beta), + ) else { + panic!("an unlimited search aborted"); + }; + assert_eq!( + (value.score, value.tainted), + (expected.score, expected.tainted) + ); + assert_eq!( + (leaf.nodes, leaf.quiescence_nodes, leaf.selective_depth), + (full.nodes, full.quiescence_nodes, full.selective_depth) + ); + asked += 1; + } + } + } + assert!(asked > 1000, "{asked}"); + } +} diff --git a/arche-core/src/late_move.rs b/arche-core/src/late_move.rs index 9d485efc..07d82d2b 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -4,7 +4,7 @@ //! What a full width node does with a quiet move its ordering put late: //! search it whole, scout it some plies shallower first, or not search it //! at all. `decide_admitted` answers with a `Verdict`; the scout itself -//! is `windowed`'s, in `engine.rs`. +//! is asked by `search_child`, in `engine.rs`. //! //! 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