diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 30a7a3ae..7acf90bb 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, @@ -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 + }, } } } @@ -1871,8 +1895,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 +1905,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 @@ -2015,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 @@ -2033,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 @@ -2056,7 +2120,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, @@ -2270,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(); @@ -2319,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, @@ -2336,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 @@ -2355,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, @@ -2564,16 +2748,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 +2798,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 +2838,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 +2857,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 +2867,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; @@ -2694,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 @@ -2836,11 +3043,14 @@ 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); } - let probe = match excluded { + let (probe, found) = match excluded { None => self.probe(alpha, beta, depth), Some(_) => self.probe_for_ordering(), }; @@ -2852,13 +3062,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, @@ -2913,88 +3120,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; + } + if Some(*m) == excluded { + continue; + } + let decision = self.late_move_decision(&node, &mut rules, moves, m); + if let Decision::Skip = decision { + continue; } - Reached::Neither => {} + 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 43525a0f..75d81bcb 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" ); @@ -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 @@ -4175,7 +4243,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); @@ -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 342fb2c7..07d82d2b 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -4,14 +4,16 @@ //! 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 //! 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"); 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:?}"), }