From 8435ccc39c86a5503f12ba4979494fa8ac842231 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 19:59:53 +0000 Subject: [PATCH 1/2] feat(search): Add one ply continuation history to the quiet order from depth two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quiet's history is the butterfly entry plus an entry keyed by its piece and to square under the piece and to square of the move that reached the node. The second table is taught as the first is, by the same cutoff with the same bonus, malus and bound, and is cleared at each go. A null move leaves the node under it nothing to key by. The table feeds the order alone. A node reads and teaches the table only with two or more plies left, any reduction and the check extension included, and a node is told the move that reached it only where its parent has more than two. Under that a node keys its quiets by the butterfly entry in the loop it had before. The same table read at every depth was played in October and read +6 ±8 over 4,000 games at 0.946 to 0.961 of master's nodes a second. On the games suite 65% of the quiets keyed are keyed at depth one and 21% at depth two. Doing the table's work on master's exact tree costs 6.44% of the suite's instructions read everywhere and 3.95% from depth two, and 1.39 points of each are the clear at each go, which the suite pays once a position and a game once a move. Over 268 positions from games, searched to depth 12, the tree is no smaller with the table than without: 0.983 of master's nodes read everywhere (95% 0.910 to 1.061) and 0.996 from depth two (0.932 to 1.064). The threshold was chosen on that and the cost together, and the choice is inside the noise of both. Against 8e088cd at 10+0.1 the sprt [0, 5] passed on its eleventh batch at LLR 3.01: +8 ±6 over 5,500 games (pairs 123, 630, 1141, 704, 152), with no game lost on time, at 0.970 to 0.989 of master's nodes a second in every batch. The figure is a stopped estimate and leans high; the batches read from -1 to +22. The games were played on 4e77791, which differs from this commit in comments, a paragraph of the architecture notes and the two suite counts below. The bench falls 21.9% (11,916,992 to 9,312,660). Promotions carries 51% of the fall (1,643,811 to 315,447), lucena 37% and perft 4 34%, while the start position, the ruy lopez and the sharp middlegame grow. The tactical suite finds 231 (230) and the strategic suite scores 104,989 (104,395). SearchConfig::continuation_history is the fifteenth switch, on by default and off in the reference, where the bench is master's. Bench: 9312660 Elo: +8 ±6 (sprt [0, 5] passed, 5500 games, 10+0.1, vs 8e088cd) Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01EYmME6yiwe8qbHTU6UgM2z --- arche-core/src/bench.rs | 36 +-- arche-core/src/census.rs | 5 +- arche-core/src/engine.rs | 38 ++- arche-core/src/engine/tests.rs | 19 +- arche-core/src/late_move.rs | 20 +- arche-core/src/ordering.rs | 421 +++++++++++++++++++++++++++------ arche-core/src/strategy.rs | 2 +- arche-core/src/tactics.rs | 2 +- docs/ARCHITECTURE.md | 7 +- docs/INSTRUMENTS.md | 18 +- tests/effort_session.rs | 2 +- 11 files changed, 443 insertions(+), 127 deletions(-) diff --git a/arche-core/src/bench.rs b/arche-core/src/bench.rs index 9286465c..e838f2e1 100644 --- a/arche-core/src/bench.rs +++ b/arche-core/src/bench.rs @@ -616,8 +616,8 @@ mod tests { ], [ [182_505, 7_085, 22_788, 535, 0, 287, 0], - [11_884, 914, 3_056, 52, 24, 0, 0], - [15_165, 1_224, 3_823, 3, 0, 0, 39], + [10_070, 755, 2_598, 57, 28, 0, 0], + [12_991, 977, 3_364, 3, 0, 0, 47], ], "nodes, cutoffs, stores, tainted, tcuts, refused, skipped" ); @@ -833,24 +833,24 @@ mod tests { assert_eq!( counted, vec![ - ("start", 564_019), - ("italian", 1_137_689), - ("ruy lopez", 378_968), - ("kiwipete", 1_625_682), - ("perft 4", 2_901_384), - ("promotions", 1_643_811), - ("middlegame", 494_644), - ("sharp middlegame", 449_762), + ("start", 810_901), + ("italian", 925_606), + ("ruy lopez", 568_063), + ("kiwipete", 1_717_616), + ("perft 4", 2_017_487), + ("promotions", 315_447), + ("middlegame", 519_737), + ("sharp middlegame", 683_004), ("bratko kopec 1", 11_568), ("wac 4", 1_516), - ("rook and pawns", 355_432), - ("tarrasch", 136_354), - ("lucena", 1_403_340), - ("philidor", 237_927), - ("minor endgame", 156_423), - ("queen endgame", 337_681), - ("king and pawn", 45_261), - ("trebuchet", 35_531), + ("rook and pawns", 314_171), + ("tarrasch", 186_731), + ("lucena", 442_470), + ("philidor", 190_861), + ("minor endgame", 185_295), + ("queen endgame", 360_712), + ("king and pawn", 34_240), + ("trebuchet", 27_235), ] ); } diff --git a/arche-core/src/census.rs b/arche-core/src/census.rs index 240e5d39..f2986774 100644 --- a/arche-core/src/census.rs +++ b/arche-core/src/census.rs @@ -133,7 +133,8 @@ pub struct Cut { /// it. pub index: usize, pub class: Class, - /// The history score at the cutoff, quiets only and 0 otherwise. + /// The butterfly history's score at the cutoff, quiets only and 0 + /// otherwise. The continuation entry the order adds is not in it. /// Signed: a move tried more often than it cuts sits below zero. Read as /// a fraction of `history_max`, since the raw number moves with what the /// search has learned. @@ -157,7 +158,7 @@ pub struct Event { pub searched: usize, /// None when the loop ran out. pub cut: Option, - /// The largest history score among the generated quiets, clamped at + /// The largest butterfly score among the generated quiets, clamped at /// zero: what `Cut::history` is read against. pub history_max: i32, /// Whether the staged ordering scored the quiet band before the node diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 7acf90bb..3195dcb5 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -576,6 +576,12 @@ pub struct SearchConfig { /// other move, shown by a half depth search with that move excluded, /// searches the table move a ply deeper. pub singular_extensions: bool, + /// Whether the search tells the move ordering what it played, so a + /// quiet's history adds the continuation table's entry under the move + /// that reached the node. Off, the table is never written or read and + /// the history is the butterfly table alone. It does nothing with + /// `move_memory` off. + pub continuation_history: bool, } /// What to do with a draw tainted score: one stored by a search that read @@ -681,7 +687,7 @@ impl SearchConfig { /// /// `taint` is not among them: it is a policy with four values rather /// than a switch, and `residuals` already takes it. - pub const SWITCHES: [(&'static str, TurnOff); 14] = [ + pub const SWITCHES: [(&'static str, TurnOff); 15] = [ ("reverse_futility", |config| config.reverse_futility = false), ("null_move", |config| config.null_move = false), ("adaptive_null_move", |config| { @@ -704,6 +710,9 @@ impl SearchConfig { ("singular_extensions", |config| { config.singular_extensions = false }), + ("continuation_history", |config| { + config.continuation_history = false + }), ]; /// The default with one switch off, or none for a name the table does @@ -740,6 +749,7 @@ impl SearchConfig { move_memory: false, aspiration: false, singular_extensions: false, + continuation_history: false, } } @@ -795,6 +805,7 @@ impl Default for SearchConfig { move_memory: true, aspiration: true, singular_extensions: true, + continuation_history: true, } } } @@ -1888,7 +1899,7 @@ impl AlphaBeta { ) { if let Some(ply) = self.memory_ply() { self.ordering - .cutoff(self.board.active_color, m, tried, ply, depth); + .cutoff(&self.board, self.board.active_color, m, tried, ply, depth); } } @@ -2170,7 +2181,7 @@ impl AlphaBeta { // no memories here: they say nothing about captures or evasions let Ordered { front, .. } = self.ordering - .order_split(&self.board, &mut moves, captures, pv_play, None); + .order_split(&self.board, &mut moves, captures, pv_play, None, 0); for (i, m) in moves.iter().enumerate() { // two skips the reference does not make. A promotion is exempt @@ -2333,6 +2344,7 @@ impl AlphaBeta { // a zero window: the question is only whether a pass beats beta if pass && eval >= beta { let reduction = null_move_reduction(self.config, depth, eval - beta); + self.ordering.passed(self.board.line_ply); self.board.make_null_move(); let result = self.child_search( -beta, @@ -2400,6 +2412,10 @@ impl AlphaBeta { !matches!(decision, Decision::Skip), "the loop searched a move it decided to skip" ); + if self.config.continuation_history { + self.ordering + .played(&self.board, m, self.board.line_ply, depth); + } if !self.board.make_move(m) { return Ok(None); } @@ -2778,6 +2794,7 @@ impl AlphaBeta { &mut moves[front..], quiets.losing, ply, + node.depth, ); } else { let run = self.ordering.key_quiets( @@ -2785,6 +2802,7 @@ impl AlphaBeta { &mut moves[front..], quiets.losing, ply, + node.depth, ); if run > 1 { quiets.lazy = Some((front + run, 0)); @@ -3109,9 +3127,14 @@ impl AlphaBeta { } else { self.board.generate_moves_into(&mut moves) }; - let ordered = - self.ordering - .order_split(&self.board, &mut moves, captures, pv_play, node.ply); + let ordered = self.ordering.order_split( + &self.board, + &mut moves, + captures, + pv_play, + node.ply, + node.depth, + ); // `order_split` sorts by `pv_play`, and the search played // `table_move`, which differ when `is_pseudo_legal` refused the move let tt_at = if table_move.is_some() { @@ -3339,7 +3362,8 @@ impl AlphaBeta { // debug assertion in `order` holds the table's move at the head let pv_play = self.transpositions.ordering_play(&self.board); let mut moves = self.board.generate_moves(); - self.ordering.order(&self.board, &mut moves, pv_play, None); + self.ordering + .order(&self.board, &mut moves, pv_play, None, 0); // the root reduces nothing for m in &moves { diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 443efe91..7909d9e2 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -3616,8 +3616,8 @@ mod cutoffs { let color = e.board.active_color; // the first is taught at another ply with the larger history, so // only the killer slot makes the second the class - e.ordering.cutoff(color, &cool, &[], 1, 5); - e.ordering.cutoff(color, &killer, &[], 0, 4); + e.ordering.cutoff(&e.board, color, &cool, &[], 1, 5); + e.ordering.cutoff(&e.board, color, &killer, &[], 0, 4); let moves = e.board.generate_moves(); let (alpha, beta): (Score, Score) = (10, 11); e.census_event( @@ -3674,7 +3674,8 @@ mod cutoffs { .filter(|m| m.capture.is_none() && m.promote.is_none()) .copied() .collect(); - e.ordering.cutoff(color, &elsewhere, &marked, 1, 4); + e.ordering + .cutoff(&e.board, color, &elsewhere, &marked, 1, 4); e.census_event( &node(3, (10, 11), Some(0), Table::Miss, 2, e.nodes), &moves, @@ -3761,7 +3762,8 @@ mod cutoffs { fn a_held_node_records_no_cutting_move() { let mut e = engine(SHARP_MIDDLEGAME); let (taught, _) = quiets(&e); - e.ordering.cutoff(e.board.active_color, &taught, &[], 0, 3); + e.ordering + .cutoff(&e.board, e.board.active_color, &taught, &[], 0, 3); let moves = e.board.generate_moves(); e.census_event( &node( @@ -3861,8 +3863,8 @@ mod reductions { let color = e.board.active_color; // the first is taught at another ply with the larger history, so // only the killer slot makes the second the class - e.ordering.cutoff(color, &cool, &[], 1, 5); - e.ordering.cutoff(color, &killer, &[], 0, 4); + e.ordering.cutoff(&e.board, color, &cool, &[], 1, 5); + e.ordering.cutoff(&e.board, color, &killer, &[], 0, 4); let moves = e.board.generate_moves(); let parent_eval = i32::from(crate::eval::eval(&e.board)); let staged = staged(&e, &killer, 5, Some(0)); @@ -3952,7 +3954,8 @@ mod reductions { .filter(|q| q.capture.is_none() && q.promote.is_none()) .copied() .collect(); - e.ordering.cutoff(color, &elsewhere, &marked, 1, 4); + e.ordering + .cutoff(&e.board, color, &elsewhere, &marked, 1, 4); let staged = staged(&e, &m, 5, Some(0)); assert_eq!(staged.features.history, -16); assert_eq!(staged.features.history_max, 0); @@ -4241,7 +4244,7 @@ mod node { node.answer.searched = 12; let ordered = e .ordering - .order_split(&e.board, &mut moves, captures, None, node.ply); + .order_split(&e.board, &mut moves, captures, None, node.ply, node.depth); let mut quiets = QuietOrder::new(&ordered, node.ply); let mut rules = late_move::Rules::new(&e.deciding(), &node, None); let front = ordered.front; diff --git a/arche-core/src/late_move.rs b/arche-core/src/late_move.rs index 07d82d2b..8c2f0606 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -258,9 +258,10 @@ pub(crate) struct Features { /// it was searched, is 0. pub(crate) index: usize, pub(crate) generated: usize, - /// The history table's score for the move at the decision, signed. + /// The butterfly history's score for the move at the decision, signed. + /// The continuation entry the order adds is not in it. pub(crate) history: i32, - /// The largest history score among the node's generated quiets, + /// The largest butterfly score among the node's generated quiets, /// clamped at zero: the denominator `history` is read against. pub(crate) history_max: i32, pub(crate) killer: bool, @@ -1493,8 +1494,8 @@ mod tests { // is neither nothing nor the whole. The slot it took at ply zero // is the killer column, which the node reads here s.ply = Some(0); - s.ordering.cutoff(color, &quiet, &[], 0, 4); - s.ordering.cutoff(color, &rival, &[], 1, 5); + s.ordering.cutoff(&s.board, color, &quiet, &[], 0, 4); + s.ordering.cutoff(&s.board, color, &rival, &[], 1, 5); const SEARCHED: usize = 10; let generated = s.moves.len(); let f = s.features(&quiet, SEARCHED); @@ -1726,9 +1727,12 @@ mod tests { // the check is taught at another ply, so it is no killer at this one const PLY: usize = 0; let color = s.board.active_color; - s.ordering.cutoff(color, &checks, &[], PLY + 1, 5); + s.ordering.cutoff(&s.board, color, &checks, &[], PLY + 1, 5); let mut run = vec![neither[0], promotes, neither[1], checks, neither[2]]; - assert_eq!(s.ordering.key_quiets(&s.board, &mut run, 0, PLY), run.len()); + assert_eq!( + s.ordering.key_quiets(&s.board, &mut run, 0, PLY, u8::MAX), + run.len() + ); let info = s.board.check_info(); let kept = s .ordering @@ -1920,11 +1924,11 @@ mod tests { let quiet = play_named(&s.board, "a4a5"); let rival = play_named(&s.board, "a4a6"); let color = s.board.active_color; - s.ordering.cutoff(color, &rival, &[], 1, 5); + s.ordering.cutoff(&s.board, color, &rival, &[], 1, 5); assert_eq!(s.features(&quiet, 10).history_max, 25); // taught again and deeper, as a child search between two of the // node's moves would teach it - s.ordering.cutoff(color, &rival, &[], 1, 8); + s.ordering.cutoff(&s.board, color, &rival, &[], 1, 8); let taught = s.ordering.history_score(color, &rival); assert!(taught > 25, "the second cutoff taught nothing: {}", taught); assert_eq!(s.features(&quiet, 10).history_max, 25); diff --git a/arche-core/src/ordering.rs b/arche-core/src/ordering.rs index 80c9c8ae..94a833a5 100644 --- a/arche-core/src/ordering.rs +++ b/arche-core/src/ordering.rs @@ -22,8 +22,13 @@ //! Two memories carry across nodes: the killers, the quiet moves that cut //! a node off at each distance from the root, and the history, how often //! each quiet move has cut off against how often it was tried and did not. -//! The search fills both, and `SearchConfig::move_memory` says whether a -//! node consults them. +//! The history has two tables. The butterfly table keys a move by its +//! squares alone, and the continuation table keys it by its piece and to +//! square under the move that reached the node, so a reply is scored +//! against what it replies to. A quiet's history is the two entries +//! summed. The search fills the memories, `SearchConfig::move_memory` says +//! whether a node consults them, and `SearchConfig::continuation_history` +//! whether the search tells the continuation table what it played. //! //! `the_bands_do_not_overlap` checks the constants. The root's //! aborted-answer swap is sound only because the table's move sorts first, @@ -50,11 +55,12 @@ const SEE_UNIT: i64 = 2_000; /// The two killers, in the order they are tried. Both sit under the /// smallest even capture, and above the quiet moves themselves. const KILLER_BONUS: [i64; 2] = [9_000_000, 8_000_000]; -/// The bound `gravitate` holds a history entry to, either way. Three -/// orders under the second killer, and under the least losing capture's -/// `SEE_UNIT * 100` less the largest tiebreak, so a marked down quiet stays -/// ahead of every losing capture on the spilled list path. -/// `the_bands_do_not_overlap` checks both. +/// The bound `gravitate` holds a history entry to, either way. A quiet +/// sums two entries, and twice the bound is still two orders under the +/// second killer, and under the least losing capture's `SEE_UNIT * 100` +/// less the largest tiebreak, so a marked down quiet stays ahead of every +/// losing capture on the spilled list path. `the_bands_do_not_overlap` +/// checks both. const HISTORY_MAX: i32 = 8_192; /// Most valuable victim: what taking each piece is worth. @@ -66,9 +72,12 @@ const ATTACKER_SCORES: [i64; 6] = [6, 5, 4, 3, 2, 1]; /// in. Six, since a list wider than the buffer takes the other sort. const PLACE_BITS: u32 = 6; const PLACE_MASK: i64 = (1 << PLACE_BITS) - 1; +/// The widest a quiet's history can be either way: the butterfly entry and +/// the continuation entry, each held to `HISTORY_MAX`. +const QUIET_HISTORY_MAX: i32 = 2 * HISTORY_MAX; /// The killers' ranks in the quiet run's 32 bit keys: the two just over -/// the history's bound, so the keys order as the 64 bit bonuses do. -const QUIET_KILLER: [i32; 2] = [HISTORY_MAX + 2, HISTORY_MAX + 1]; +/// the summed history's bound, so the keys order as the 64 bit bonuses do. +const QUIET_KILLER: [i32; 2] = [QUIET_HISTORY_MAX + 2, QUIET_HISTORY_MAX + 1]; /// A quiet run's keys and a vector's width of padding after the run, which /// `key_quiets` fills with `i32::MAX`. const QUIET_KEYS: usize = MOVE_LIST_INLINE + 4; @@ -87,6 +96,30 @@ const _: () = assert!(MOVE_LIST_INLINE <= u64::BITS as usize); /// ordering behind one the search knows nothing about. type History = [[[i32; 64]; 64]; 2]; +/// The previous moves the continuation table keys by: each piece and to +/// square. +const CONTINUATION_PREVIOUS: usize = 6 * 64; +/// The least depth a node has to have left, any reduction and the check +/// extension included, to read the continuation table and to teach it. On +/// the games suite 65% of the quiets keyed are keyed at depth one, where +/// the order decides little, and the read is most of what the table costs +/// a node: at two the table costs 2.6% of the suite's instructions in +/// play against 5.1% read everywhere. +pub(crate) const CONTINUATION_MIN_DEPTH: u8 = 2; + +/// One block of the continuation table: the entries of every piece and to +/// square under one previous move by one side, and a seventh row of zeros +/// that an empty from square reads, so the read needs no branch. Nothing +/// writes that row: an update needs a piece. +const CONTINUATION_BLOCK: usize = 7 * 64; +/// Under each side to move, a block for each previous move. +const CONTINUATION_LEN: usize = 2 * CONTINUATION_PREVIOUS * CONTINUATION_BLOCK; +/// What a node with no previous move reads in place of a block. +static NO_CONTINUATION: [i16; CONTINUATION_BLOCK] = [0; CONTINUATION_BLOCK]; +/// What `line` holds at a ply whose move the table does not key: a pass, +/// or a search that does not tell the table what it played. +const NO_PREVIOUS: u16 = u16::MAX; + /// What `order` worked out about the list while it keyed it. #[derive(Debug, PartialEq, Eq)] pub(crate) struct Ordered { @@ -114,6 +147,16 @@ pub(crate) struct MoveOrdering { /// node's list. killers: [[Option; 2]; MAX_PLY as usize], history: History, + /// The continuation table, cutoffs against tries as `history` keeps + /// them, indexed by the side to move, the previous move's piece and to + /// square, then this move's piece and to square. Sixteen bits, since + /// an entry is held to `HISTORY_MAX`, so the table is 672 kilobytes and + /// the block one node reads is 896 bytes. + continuation: Box<[i16]>, + /// The piece and to square of the move played at each ply, as the + /// continuation table indexes a previous move, or `NO_PREVIOUS`. A node + /// reads the entry one ply above its own. + line: [u16; MAX_PLY as usize], /// The quiet run's packed keys at each ply, kept as the search picks /// from it, and the run in generated order, which a key's place /// indexes. Per ply because the picking spans the node's child @@ -129,14 +172,16 @@ impl MoveOrdering { sorted: [Play::NOWHERE; MOVE_LIST_INLINE], killers: [[None; 2]; MAX_PLY as usize], history: [[[0; 64]; 64]; 2], + continuation: vec![0; CONTINUATION_LEN].into_boxed_slice(), + line: [NO_PREVIOUS; MAX_PLY as usize], quiet_keys: Box::new([[0; QUIET_KEYS]; MAX_PLY as usize]), quiet_orig: Box::new([[Play::NOWHERE; MOVE_LIST_INLINE]; MAX_PLY as usize]), } } - /// Forget both memories. Each `go` starts with this: the memories - /// describe the tree being searched now, and the iterations of one - /// deepening share them. + /// Forget both memories, and the line the continuation table is read + /// under. Each `go` starts with this: the memories describe the tree + /// being searched now, and the iterations of one deepening share them. pub(crate) fn forget(&mut self) { self.killers.fill([None; 2]); for side in self.history.iter_mut() { @@ -144,20 +189,67 @@ impl MoveOrdering { from.fill(0); } } + self.continuation.fill(0); + self.line.fill(NO_PREVIOUS); + } + + /// The move about to be made at `ply` by a node with `depth` left, for + /// the continuation table of the node it leads to. Called with the move + /// unmade, so the board says which piece makes it. A parent at the + /// table's least depth or under writes no move: its children are + /// shallower than the least depth unless they are in check, and a child + /// the check extension brings back up to it keys by the butterfly entry + /// alone and teaches the table nothing. + #[inline(always)] + pub(crate) fn played(&mut self, board: &Position, m: &Play, ply: usize, depth: u8) { + if let Some(slot) = self.line.get_mut(ply) { + *slot = if depth > CONTINUATION_MIN_DEPTH { + board + .get_piece_index(m.from) + .map_or(NO_PREVIOUS, |piece| piece_square(piece, m.to) as u16) + } else { + NO_PREVIOUS + }; + } + } + + /// A pass at `ply`, which leaves the node under it nothing to key by. + #[inline(always)] + pub(crate) fn passed(&mut self, ply: usize) { + if let Some(slot) = self.line.get_mut(ply) { + *slot = NO_PREVIOUS; + } + } + + /// Where the block a node at `ply` with `depth` left reads for `color` + /// starts, or none at the root, under a move the line does not hold and + /// under the table's least depth. + #[inline(always)] + fn continuation_base(&self, color: Color, ply: usize, depth: u8) -> Option { + if depth < CONTINUATION_MIN_DEPTH { + return None; + } + let previous = *self.line.get(ply.checked_sub(1)?)?; + (previous != NO_PREVIOUS).then(|| { + (color as usize * CONTINUATION_PREVIOUS + usize::from(previous)) * CONTINUATION_BLOCK + }) } /// A move that cut a node off, `ply` from the root with `depth` left, /// and the moves the node made and searched before it. It becomes this - /// ply's first killer. Its history entry gains the square of the depth + /// ply's first killer. Its history entries gain the square of the depth /// and every quiet in `tried` loses the same: the maluses are the /// denominator a count of cutoffs lacks, without which an entry - /// rewards a move for being ordered early. + /// rewards a move for being ordered early. The continuation entries + /// are taught only under a previous move the line holds, and `board`, + /// the node's position, says which piece each move is. /// /// A capture is dropped, cutting or tried, since the swap orders the /// captures already. The caller passes the moves it has rather than /// sorting the quiets out first. pub(crate) fn cutoff<'a>( &mut self, + board: &Position, color: Color, m: &Play, tried: impl IntoIterator, @@ -179,18 +271,34 @@ impl MoveOrdering { // an entry past it. Only the rail plus a chain of check extensions // reaches such a depth, but both bands rest on the bound holding let bonus = (i32::from(depth) * i32::from(depth)).min(HISTORY_MAX); + let base = self.continuation_base(color, ply, depth); for t in tried { if t.capture.is_none() { gravitate( &mut self.history[color as usize][t.from as usize][t.to as usize], -bonus, ); + self.continue_from(base, board, t, -bonus); } } gravitate( &mut self.history[color as usize][m.from as usize][m.to as usize], bonus, ); + self.continue_from(base, board, m, bonus); + } + + /// One continuation entry's update, when the node has a block and the + /// board a piece on the move's from square. + #[inline(always)] + fn continue_from(&mut self, base: Option, board: &Position, m: &Play, bonus: i32) { + let (Some(base), Some(piece)) = (base, board.get_piece_index(m.from)) else { + return; + }; + let entry = &mut self.continuation[base + piece_square(piece, m.to)]; + let mut wide = i32::from(*entry); + gravitate(&mut wide, bonus); + *entry = wide as i16; } /// Test-only. Signed, so a side taught nothing and a side whose @@ -231,13 +339,15 @@ impl MoveOrdering { self.killers[ply] } - /// The history score of one of `color`'s moves, read without teaching - /// anything. + /// The butterfly table's score for one of `color`'s moves, read without + /// teaching anything. The instruments read this one table: the + /// continuation entry the order adds depends on the line, which their + /// rows do not carry. pub(crate) fn history_score(&self, color: Color, m: &Play) -> i32 { self.history[color as usize][m.from as usize][m.to as usize] } - /// The history score of a quiet move, and none for a capture or a + /// The butterfly score of a quiet move, and none for a capture or a /// promotion: the memories score quiet moves alone. #[inline] pub(crate) fn quiet_history(&self, color: Color, m: &Play) -> Option { @@ -275,9 +385,10 @@ impl MoveOrdering { moves: &mut MoveList, table_move: Option, ply: Option, + depth: u8, ) -> Ordered { let captures = moves.iter().take_while(|m| m.capture.is_some()).count(); - self.order_split(board, moves, captures, table_move, ply) + self.order_split(board, moves, captures, table_move, ply, depth) } /// `order` for a list whose first `captures` moves are its captures, @@ -295,6 +406,7 @@ impl MoveOrdering { captures: usize, table_move: Option, ply: Option, + depth: u8, ) -> Ordered { // no capture and no quiet table's move: nothing is keyed and nothing // moves, which 29% of the lists came to on the games suite @@ -302,7 +414,7 @@ impl MoveOrdering { let quiet_table_move = table_move.is_some_and(|m| m.capture.is_none()); if captures == 0 && !quiet_table_move && moves.len() <= MOVE_LIST_INLINE { debug_assert_eq!( - self.order_by_sort(board, &mut moves.clone(), table_move, ply), + self.order_by_sort(board, &mut moves.clone(), table_move, ply, depth), Ordered { front: 0, table_at: None, @@ -315,7 +427,7 @@ impl MoveOrdering { losing: 0, }; } - self.order_keyed(board, moves, captures, table_move, ply) + self.order_keyed(board, moves, captures, table_move, ply, depth) } #[inline(never)] @@ -326,16 +438,17 @@ impl MoveOrdering { captures: usize, table_move: Option, ply: Option, + depth: u8, ) -> Ordered { debug_assert!(moves[..captures].iter().all(|m| m.capture.is_some())); debug_assert!(moves[captures..].iter().all(|m| m.capture.is_none())); #[cfg(debug_assertions)] let expected = { let mut copy = moves.clone(); - let ordered = self.order_by_sort(board, &mut copy, table_move, ply); + let ordered = self.order_by_sort(board, &mut copy, table_move, ply, depth); (copy, ordered) }; - let ordered = self.order_split_inner(board, moves, captures, table_move, ply); + let ordered = self.order_split_inner(board, moves, captures, table_move, ply, depth); #[cfg(debug_assertions)] { assert_eq!(expected.1, ordered, "the split order's counts"); @@ -352,9 +465,10 @@ impl MoveOrdering { captures: usize, table_move: Option, ply: Option, + depth: u8, ) -> Ordered { if moves.len() > MOVE_LIST_INLINE { - return self.order_by_sort(board, moves, table_move, ply); + return self.order_by_sort(board, moves, table_move, ply, depth); } let len = moves.len(); let keys = &mut self.keys; @@ -451,6 +565,7 @@ impl MoveOrdering { moves: &mut MoveList, table_move: Option, ply: Option, + depth: u8, ) -> Ordered { let keys = &mut self.keys; let sorted = &mut self.sorted; @@ -461,6 +576,10 @@ impl MoveOrdering { let quiet = ply.map(|ply| Quiet { killers: self.killers[ply], history: &self.history[board.active_color as usize], + continuation: self + .continuation_base(board.active_color, ply, depth) + .map(|base| &self.continuation[base..base + CONTINUATION_BLOCK]), + board, }); moves.sort_by_cached_key(|m| keyed(board, m, table_move == Some(*m), quiet.as_ref())); // the same key orders this path, so the table's move is at @@ -530,6 +649,7 @@ impl MoveOrdering { rest: &mut [Play], losing: usize, ply: usize, + depth: u8, ) { debug_assert!(ply < MAX_PLY as usize, "no killers past the rail"); // `order` hands back the whole length of a list that spilled @@ -548,6 +668,10 @@ impl MoveOrdering { let quiet = Quiet { killers: self.killers[ply], history: &self.history[board.active_color as usize], + continuation: self + .continuation_base(board.active_color, ply, depth) + .map(|base| &self.continuation[base..base + CONTINUATION_BLOCK]), + board, }; let keys = &mut self.keys; let sorted = &mut self.sorted; @@ -583,10 +707,16 @@ impl MoveOrdering { rest: &mut [Play], losing: usize, ply: usize, + depth: u8, ) -> usize { let run = rest.len() - losing; let killers = self.killers[ply]; let history = &self.history[board.active_color as usize]; + let continuation = self + .continuation_base(board.active_color, ply, depth) + .map(|base| &self.continuation[base..base + CONTINUATION_BLOCK]); + // the zeros stand in where the slower path below reads a block + let block = continuation.unwrap_or(&NO_CONTINUATION); let keys = &mut self.quiet_keys[ply]; let orig = &mut self.quiet_orig[ply]; orig[..run].copy_from_slice(&rest[..run]); @@ -595,14 +725,27 @@ impl MoveOrdering { .all(|k| k.is_none_or(|k| named_by_its_squares(&k))) { // a killer is the only quiet on its squares, so a quiet's key is - // its history entry unless its index is a killer's, when it is - // the killer's rank: one read and two compares. The history is - // read and never written here + // its entries unless its index is a killer's, when it is the + // killer's rank: two compares, and three reads under a block or + // one without. The history is read and never written here let ranks = KillerRanks::of(killers); let flat = history.as_flattened(); - let fours = key_fours(flat, &ranks, &rest[..run], &mut keys[..run]); - for i in fours..run { - keys[i] = pack_quiet(ranks.entry(flat, history_index(&rest[i])), i); + // a node with no block keys the butterfly entries alone, in a + // loop that reads nothing else + if continuation.is_some() { + let fours = + key_fours::(flat, block, board, &ranks, &rest[..run], &mut keys[..run]); + for i in fours..run { + let index = history_index(&rest[i]); + keys[i] = pack_quiet(ranks.entry::(flat, block, board, index), i); + } + } else { + let fours = + key_fours::(flat, block, board, &ranks, &rest[..run], &mut keys[..run]); + for i in fours..run { + let index = history_index(&rest[i]); + keys[i] = pack_quiet(ranks.entry::(flat, block, board, index), i); + } } } else { for (i, m) in rest[..run].iter().enumerate() { @@ -612,6 +755,7 @@ impl MoveOrdering { QUIET_KILLER[1] } else { history[(m.from & 63) as usize][(m.to & 63) as usize] + + continued(block, board, m) }; keys[i] = pack_quiet(bonus, i); } @@ -623,6 +767,8 @@ impl MoveOrdering { let quiet = Quiet { killers, history: &self.history[board.active_color as usize], + continuation, + board, }; for (i, m) in rest[..run].iter().enumerate() { let wide = pack(-quiet.bonus(m), i); @@ -735,11 +881,14 @@ impl MoveOrdering { } } -/// What the memories say at one node: this ply's killers and the side to -/// move's half of the history. +/// What the memories say at one node: this ply's killers, the side to +/// move's half of the butterfly history, and the continuation block under +/// the move that reached the node, with the position it names pieces in. struct Quiet<'a> { killers: [Option; 2], history: &'a [[i32; 64]; 64], + continuation: Option<&'a [i16]>, + board: &'a Position, } impl Quiet<'_> { @@ -760,10 +909,39 @@ impl Quiet<'_> { if self.killers[1] == Some(*m) { return KILLER_BONUS[1]; } - i64::from(self.history[(m.from & 63) as usize][(m.to & 63) as usize]) + let butterfly = self.history[(m.from & 63) as usize][(m.to & 63) as usize]; + let continued = self + .continuation + .map_or(0, |block| continued(block, self.board, m)); + i64::from(butterfly + continued) } } +/// A piece and a square as one index into a continuation block, and as a +/// previous move's index into the blocks. +#[inline(always)] +fn piece_square(piece: Piece, to: u8) -> usize { + (piece as usize) << 6 | usize::from(to & 63) +} + +/// A quiet's entry in a node's continuation block. A move whose from +/// square is empty reads the zero row, which only a test's made up move +/// can do. +#[inline(always)] +fn continued(block: &[i16], board: &Position, m: &Play) -> i32 { + continued_at(block, board, history_index(m)) +} + +/// The same read by the move's butterfly index, from times sixty four +/// plus to, which is what the vector keying holds. +#[inline(always)] +fn continued_at(block: &[i16], board: &Position, index: usize) -> i32 { + let row = board + .get_piece_index((index >> 6) as u8) + .map_or(6, |piece| piece as usize); + i32::from(block[row << 6 | index & 63]) +} + /// Whether a killer's squares name it among the quiets of any list: no /// promotion push and no castle can go from its square to its square, so /// the only quiet a list can hold on that journey is the killer itself. @@ -782,7 +960,10 @@ fn named_by_its_squares(k: &Play) -> bool { #[inline(always)] fn pack_quiet(bonus: i32, place: usize) -> i32 { debug_assert!(place < 1 << PLACE_BITS, "a place has to fit its field"); - debug_assert!(bonus.abs() <= HISTORY_MAX + 2, "the bonus outgrew its band"); + debug_assert!( + bonus.abs() <= QUIET_HISTORY_MAX + 2, + "the bonus outgrew its band" + ); place as i32 - (bonus << PLACE_BITS) } @@ -837,12 +1018,19 @@ fn least_from(keys: &[i32; QUIET_KEYS], t: usize, len: usize) -> usize { } /// Key the run's quiets four at a time, as far as whole fours go, by their -/// history entries or a killer's rank, and return how many were keyed. The -/// entries are read one by one; only the indexes are computed four at a -/// time. +/// two history entries or a killer's rank, and return how many were keyed. +/// The entries are read one by one; only the indexes are computed four at +/// a time. #[cfg(all(target_arch = "x86_64", target_feature = "sse4.1"))] #[inline(always)] -fn key_fours(history: &[i32], ranks: &KillerRanks, quiets: &[Play], keys: &mut [i32]) -> usize { +fn key_fours( + history: &[i32], + block: &[i16], + board: &Position, + ranks: &KillerRanks, + quiets: &[Play], + keys: &mut [i32], +) -> usize { use core::arch::x86_64::{ __m128i, _mm_add_epi32, _mm_and_si128, _mm_blendv_epi8, _mm_cmpeq_epi32, _mm_cvtepu16_epi32, _mm_cvtsi128_si32, _mm_extract_epi32, _mm_loadu_si128, @@ -921,11 +1109,24 @@ fn key_fours(history: &[i32], ranks: &KillerRanks, quiets: &[Play], keys: &mut [ square, ); let index = _mm_cvtepu16_epi32(_mm_maddubs_epi16(squares, weights)); + let lanes = [ + _mm_cvtsi128_si32(index) as usize, + _mm_extract_epi32::<1>(index) as usize, + _mm_extract_epi32::<2>(index) as usize, + _mm_extract_epi32::<3>(index) as usize, + ]; + let entry = |lane: usize| { + if CONTINUED { + *table.add(lane) + continued_at(block, board, lane) + } else { + *table.add(lane) + } + }; let mut entries = _mm_setr_epi32( - *table.add(_mm_cvtsi128_si32(index) as usize), - *table.add(_mm_extract_epi32::<1>(index) as usize), - *table.add(_mm_extract_epi32::<2>(index) as usize), - *table.add(_mm_extract_epi32::<3>(index) as usize), + entry(lanes[0]), + entry(lanes[1]), + entry(lanes[2]), + entry(lanes[3]), ); // a lane whose index is a killer's takes the killer's rank, the // first killer last as in `entry`; no two quiets share an index @@ -946,7 +1147,14 @@ fn key_fours(history: &[i32], ranks: &KillerRanks, quiets: &[Play], keys: &mut [ #[cfg(not(all(target_arch = "x86_64", target_feature = "sse4.1")))] #[inline(always)] -fn key_fours(_: &[i32], _: &KillerRanks, _: &[Play], _: &mut [i32]) -> usize { +fn key_fours( + _: &[i32], + _: &[i16], + _: &Position, + _: &KillerRanks, + _: &[Play], + _: &mut [i32], +) -> usize { 0 } @@ -971,13 +1179,21 @@ impl KillerRanks { } /// What a quiet whose entry sits at `index` keys at: a killer's rank - /// when the index is one of theirs, else the entry itself. + /// when the index is one of theirs, else its two entries. #[inline(always)] - fn entry(&self, history: &[i32], index: usize) -> i32 { + fn entry( + &self, + history: &[i32], + block: &[i16], + board: &Position, + index: usize, + ) -> i32 { if index == self.index[0] as usize { QUIET_KILLER[0] } else if index == self.index[1] as usize { QUIET_KILLER[1] + } else if CONTINUED { + history[index] + continued_at(block, board, index) } else { history[index] } @@ -1189,7 +1405,7 @@ fn sort_on_the_stack( #[cfg(test)] mod order { - use super::MoveOrdering; + use super::{CONTINUATION_MIN_DEPTH, MoveOrdering, NO_PREVIOUS}; use crate::board::Board; use crate::misc::Color; use crate::play::Play; @@ -1214,9 +1430,9 @@ mod order { fn ordered_by(fen: &str, table_move: Option, ordering: &mut MoveOrdering) -> Vec { let board = Board::from_fen(fen).unwrap(); let mut moves = board.generate_moves(); - let ordered = ordering.order(&board, &mut moves, table_move, Some(0)); + let ordered = ordering.order(&board, &mut moves, table_move, Some(0), u8::MAX); let front = ordered.front; - ordering.order_quiets(&board, &mut moves[front..], ordered.losing, 0); + ordering.order_quiets(&board, &mut moves[front..], ordered.losing, 0, u8::MAX); moves.to_vec() } @@ -1256,7 +1472,7 @@ mod order { let taught = named(&board.generate_moves(), "g1f1"); // taught at another ply, so it sorts by history and not as a killer let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &taught, &[], 1, 4); + ordering.cutoff(&board, Color::White, &taught, &[], 1, 4); let moves = ordered_by(CAPTURES, None, &mut ordering); let losing = position_of(&moves, "h5h7"); @@ -1266,6 +1482,69 @@ mod order { } } + // a reply taught under one previous move ranks by both entries under + // that move, and by the butterfly entry alone under another move or + // after a pass. Read at a ply neither move was taught at, so neither is + // a killer there, and on both the sorted and the keyed path + #[test] + fn a_reply_is_ranked_by_the_move_it_replies_to() { + let board = Board::new(); + let generated = board.generate_moves(); + let (plain, reply) = (named(&generated, "a2a3"), named(&generated, "h2h3")); + let (asked, other) = (named(&generated, "g1f3"), named(&generated, "b1c3")); + let mut ordering = MoveOrdering::new(); + // 25 in the butterfly table alone, since the root has no previous move + ordering.cutoff(&board, Color::White, &plain, &[], 0, 5); + // 16 in each table, under the knight's move to f3 + ordering.played(&board, &asked, 0, u8::MAX); + ordering.cutoff(&board, Color::White, &reply, &[], 1, 4); + let mut first = |previous: Option<&Play>, depth: u8| { + match previous { + Some(previous) => ordering.played(&board, previous, 2, u8::MAX), + None => ordering.passed(2), + } + let mut sorted = generated.clone(); + ordering.order_quiets(&board, &mut sorted[..], 0, 3, depth); + let mut keyed = generated.clone(); + let run = ordering.key_quiets(&board, &mut keyed[..], 0, 3, depth); + ordering.sort_rest(&mut keyed[..run], 0, 3); + assert_eq!(&sorted[..], &keyed[..], "the two paths disagree"); + sorted[0] + }; + assert_eq!(first(Some(&asked), u8::MAX), reply); + assert_eq!(first(Some(&other), u8::MAX), plain); + assert_eq!(first(None, u8::MAX), plain); + // the table is read from its least depth and not under it + assert_eq!(first(Some(&asked), CONTINUATION_MIN_DEPTH), reply); + assert_eq!(first(Some(&asked), CONTINUATION_MIN_DEPTH - 1), plain); + } + + // a node under the table's least depth teaches it nothing, and a node + // at that depth is not told the move that reached its children + #[test] + fn the_continuation_table_is_left_alone_under_its_least_depth() { + let board = Board::new(); + let generated = board.generate_moves(); + let (asked, reply) = (named(&generated, "g1f3"), named(&generated, "h2h3")); + let mut ordering = MoveOrdering::new(); + ordering.played(&board, &asked, 0, u8::MAX); + ordering.cutoff( + &board, + Color::White, + &reply, + &[], + 1, + CONTINUATION_MIN_DEPTH - 1, + ); + assert!(ordering.continuation.iter().all(|&entry| entry == 0)); + ordering.cutoff(&board, Color::White, &reply, &[], 1, CONTINUATION_MIN_DEPTH); + assert!(ordering.continuation.iter().any(|&entry| entry != 0)); + ordering.played(&board, &asked, 0, CONTINUATION_MIN_DEPTH); + assert_eq!(ordering.line[0], NO_PREVIOUS); + ordering.played(&board, &asked, 0, CONTINUATION_MIN_DEPTH + 1); + assert_ne!(ordering.line[0], NO_PREVIOUS); + } + // a rook for a rook: an even swap ranks with the winning captures, // above a killer, not with the losing ones #[test] @@ -1274,7 +1553,7 @@ mod order { let board = Board::from_fen(ROOKS).unwrap(); let killer = named(&board.generate_moves(), "h1g1"); let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &killer, &[], 0, 4); + ordering.cutoff(&board, Color::White, &killer, &[], 0, 4); let moves = ordered_by(ROOKS, None, &mut ordering); assert!(position_of(&moves, "e1e8") < position_of(&moves, "h1g1")); @@ -1296,7 +1575,7 @@ mod order { ] { let table_move = table_move.map(|name| named(&generated, name)); let mut moves = generated.clone(); - let ordered = MoveOrdering::new().order(&board, &mut moves, table_move, ply); + let ordered = MoveOrdering::new().order(&board, &mut moves, table_move, ply, u8::MAX); let front = ordered.front; assert_eq!(front, 2 + usize::from(table_move.is_some())); // the place the search skips, which it plays without @@ -1325,7 +1604,7 @@ mod order { let table_move = table_move.map(|name| named(&moves, name)); let spilled = moves.len() > crate::board::MOVE_LIST_INLINE; assert_eq!(spilled, fen == CROWDED, "{fen}"); - let ordered = MoveOrdering::new().order(&board, &mut moves, table_move, None); + let ordered = MoveOrdering::new().order(&board, &mut moves, table_move, None, u8::MAX); let front = ordered.front; assert_eq!(ordered.table_at.map(|at| moves[at]), table_move, "{fen}"); if spilled { @@ -1358,7 +1637,7 @@ mod order { for killer in [false, true] { let mut ordering = MoveOrdering::new(); if killer { - ordering.cutoff(Color::White, &push, &[], 0, 4); + ordering.cutoff(&board, Color::White, &push, &[], 0, 4); } let moves = ordered_by(CAPTURES, Some(push), &mut ordering); assert_eq!(moves[0], push, "killer {killer}"); @@ -1379,7 +1658,7 @@ mod order { let board = Board::from_fen(CAPTURES).unwrap(); let killer = named(&board.generate_moves(), "e4e5"); let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &killer, &[], 0, 4); + ordering.cutoff(&board, Color::White, &killer, &[], 0, 4); let moves = ordered_by(CAPTURES, None, &mut ordering); assert!(position_of(&moves, "e4e5") > position_of(&moves, "e4d5")); @@ -1395,8 +1674,8 @@ mod order { let first = named(&generated, "e4e5"); let second = named(&generated, "g1f1"); let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &second, &[], 0, 4); - ordering.cutoff(Color::White, &first, &[], 0, 4); + ordering.cutoff(&board, Color::White, &second, &[], 0, 4); + ordering.cutoff(&board, Color::White, &first, &[], 0, 4); let moves = quiets(&ordered_by(CAPTURES, None, &mut ordering)); assert_eq!(moves[0], first); @@ -1412,8 +1691,8 @@ mod order { // taught at another ply, so this ply's killers are empty and what // is left to order the two by is the history alone let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &generated[0], &[], 1, 1); - ordering.cutoff(Color::White, &last, &[], 1, 4); + ordering.cutoff(&board, Color::White, &generated[0], &[], 1, 1); + ordering.cutoff(&board, Color::White, &last, &[], 1, 4); let moves = quiets(&ordered_by(CAPTURES, None, &mut ordering)); assert_eq!(moves[0], last); @@ -1435,7 +1714,7 @@ mod order { // taught at another ply, so nothing here stands in a killer slot // and the history alone orders the band let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &cut, &tried, 1, 4); + ordering.cutoff(&board, Color::White, &cut, &tried, 1, 4); let moves = ordered_by(CAPTURES, None, &mut ordering); let mut expected = vec![cut]; @@ -1448,7 +1727,7 @@ mod order { // the same band on the spilled list path, where one sort orders the // quiets and the losing captures together and a marked down quiet keys // positive like a losing capture. What keeps the two apart is the - // distance between `HISTORY_MAX` and the least losing capture's key + // distance between twice `HISTORY_MAX` and the least losing capture's key #[test] fn a_marked_down_quiet_stays_ahead_of_the_losing_captures_on_a_spilled_list() { let board = Board::from_fen(CROWDED).unwrap(); @@ -1463,7 +1742,7 @@ mod order { // taught at another ply, so this ply's killers are empty and the // history alone orders the quiet moves let mut ordering = MoveOrdering::new(); - ordering.cutoff(Color::White, &cut, &[marked], 1, 4); + ordering.cutoff(&board, Color::White, &cut, &[marked], 1, 4); assert!(ordering.history_score(Color::White, &marked) < 0); let moves = ordered_by(CROWDED, None, &mut ordering); @@ -1486,9 +1765,11 @@ mod order { #[cfg(test)] mod memory { + use super::QUIET_HISTORY_MAX; use super::SEE_UNIT; use super::{ATTACKER_SCORES, HISTORY_MAX, KILLER_BONUS, MoveOrdering, PLACE_BITS}; use super::{TABLE_MOVE_BONUS, VICTIM_SCORES, WINNING_CAPTURE_BASE, gravitate}; + use crate::board::Board; use crate::board::SEE_VALUES; use crate::engine::MAX_PLY; use crate::misc::{Color, Piece}; @@ -1534,14 +1815,14 @@ mod memory { // order above the history assert!(WINNING_CAPTURE_BASE + smallest > KILLER_BONUS[0]); assert!(KILLER_BONUS[0] > KILLER_BONUS[1]); - assert!(KILLER_BONUS[1] > i64::from(HISTORY_MAX)); + assert!(KILLER_BONUS[1] > i64::from(QUIET_HISTORY_MAX)); // the other side of the history's band: a losing capture loses a // pawn at least, so the best it can key is that swap with the - // largest tiebreak on top, and the worst a quiet can key is the - // whole bound. The quiet has to stay ahead on the spilled list path + // largest tiebreak on top, and the worst a quiet can key is both + // tables' bound. The quiet has to stay ahead on the spilled list path let least_losing = -i64::from(SEE_VALUES[Piece::Pawn as usize]) * SEE_UNIT + biggest; - assert!(-i64::from(HISTORY_MAX) > least_losing); + assert!(-i64::from(QUIET_HISTORY_MAX) > least_losing); // the widest key there could be has to survive the place shift; the // place half of `pack` has a compile time assert of its own @@ -1566,7 +1847,7 @@ mod memory { (second, [Some(second), Some(first)]), (first, [Some(first), Some(second)]), ] { - ordering.cutoff(Color::White, &m, &[], 3, 4); + ordering.cutoff(&Board::new(), Color::White, &m, &[], 3, 4); assert_eq!(ordering.killers[3], killers, "after {m}"); } // and the ply is what indexes them @@ -1578,7 +1859,7 @@ mod memory { let mut ordering = MoveOrdering::new(); let take = Play::new(8, 16, Some(Piece::Pawn), None, false, false); let tried = quiet(9, 17); - ordering.cutoff(Color::White, &take, &[tried], 0, 4); + ordering.cutoff(&Board::new(), Color::White, &take, &[tried], 0, 4); assert_eq!(ordering.killers[0], [None, None]); assert_eq!(ordering.history[Color::White as usize][8][16], 0); // the moves tried before it are not marked down either @@ -1591,7 +1872,7 @@ mod memory { let m = quiet(8, 16); let tried = quiet(9, 17); let take = Play::new(10, 18, Some(Piece::Pawn), None, false, false); - ordering.cutoff(Color::White, &m, &[tried, take], 0, 5); + ordering.cutoff(&Board::new(), Color::White, &m, &[tried, take], 0, 5); let history = &ordering.history[Color::White as usize]; // gravity gives back a share of the entry, which is nothing at // zero, so a first update is the step itself @@ -1623,7 +1904,7 @@ mod memory { let hot = quiet(8, 16); let cold = quiet(9, 17); for _ in 0..10_000 { - ordering.cutoff(Color::White, &hot, &[cold], 0, DEPTH); + ordering.cutoff(&Board::new(), Color::White, &hot, &[cold], 0, DEPTH); } let history = &ordering.history[Color::White as usize]; assert!(history[8][16] > HISTORY_MAX / 2 && history[8][16] <= HISTORY_MAX); @@ -1638,7 +1919,7 @@ mod memory { let m = quiet(8, 16); assert!(i32::from(MAX_PLY) * i32::from(MAX_PLY) > HISTORY_MAX); for _ in 0..2 { - ordering.cutoff(Color::White, &m, &[], 0, MAX_PLY); + ordering.cutoff(&Board::new(), Color::White, &m, &[], 0, MAX_PLY); assert_eq!(ordering.history[Color::White as usize][8][16], HISTORY_MAX); } } @@ -1648,7 +1929,7 @@ mod memory { let mut ordering = MoveOrdering::new(); let m = quiet(8, 16); let tried = quiet(9, 17); - ordering.cutoff(Color::White, &m, &[tried], 2, 4); + ordering.cutoff(&Board::new(), Color::White, &m, &[tried], 2, 4); assert!(ordering.history[Color::White as usize][9][17] < 0); ordering.forget(); assert_eq!(ordering.killers[2], [None, None]); diff --git a/arche-core/src/strategy.rs b/arche-core/src/strategy.rs index af084440..a6eb36d2 100644 --- a/arche-core/src/strategy.rs +++ b/arche-core/src/strategy.rs @@ -32,7 +32,7 @@ pub const TABLE_BYTES: usize = 16 * 1024 * 1024; /// How many of the suite's points the search takes at that depth with that /// table. Exact, not a floor: a change that moves it updates this number in /// the same commit. -pub const EXPECTED_POINTS: u32 = 104395; +pub const EXPECTED_POINTS: u32 = 104989; const SUITE: &str = include_str!("../strategy.epd"); diff --git a/arche-core/src/tactics.rs b/arche-core/src/tactics.rs index dfd2c28f..9fb2e55f 100644 --- a/arche-core/src/tactics.rs +++ b/arche-core/src/tactics.rs @@ -33,7 +33,7 @@ pub const TABLE_BYTES: usize = 16 * 1024 * 1024; /// is the gate. A change that moves it in either direction updates this /// number in the same commit, and a change that lowers it says what the /// positions were spent on. -pub const EXPECTED_PASSES: usize = 230; +pub const EXPECTED_PASSES: usize = 231; /// The count the suite may not go under, whatever a commit says it meant to /// spend. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bd96c1ad..f04617bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -92,8 +92,11 @@ factorization machine term over every pair of pieces. what each wins with most valuable victim / least valuable attacker breaking the ties, then the quiet moves by two memories the search fills as it goes: the killers, which are the quiet moves that cut off at this - distance from the root, and a history table of how often each quiet move - has cut off anywhere against how often it was tried and did not. A move + distance from the root, and a history of how often each quiet move has + cut off against how often it was tried and did not. The history is two + tables summed: one keyed by the move's squares, and from two plies of + depth a continuation table keyed by the move's piece and to square under + the move that reached the node. A move the table has marked down sorts behind the quiet moves nothing is known about. The losing captures close the list. The quiet moves are put in order only as far as the move loop reads them. Alpha beta prunes more the diff --git a/docs/INSTRUMENTS.md b/docs/INSTRUMENTS.md index 844d2b55..c129d15c 100644 --- a/docs/INSTRUMENTS.md +++ b/docs/INSTRUMENTS.md @@ -156,19 +156,19 @@ Quiescence and the root are out of scope: quiescence cuts on capture order, and the root searches every move. Each row is `depth window check outcome generated searched index class history -history_max scored tt eval_beta cost reduced fen`. A `cut` row names the -cutting move's place among the searched moves (`index`, 0 for a table move -searched first), its `class` (`table`, `capture`, `promotion`, `killer` or -`quiet`, material first, so a capture that is also a killer is a capture), the -history table's score for it (`history`, quiet moves only and signed, since a -move tried more often than it cuts sits under zero), and whether its answer came -through the reduced scout (`reduced`). A `held` row prints `-` in those four -columns. +history_max scored tt eval_beta cost reduced fen`. A `cut` row names the cutting +move's place among the searched moves (`index`, 0 for a table move searched +first), its `class` (`table`, `capture`, `promotion`, `killer` or `quiet`, +material first, so a capture that is also a killer is a capture), the butterfly +history's score for it (`history`, quiet moves only and signed, since a move +tried more often than it cuts sits under zero; the continuation entry the +order adds is not in it), and whether its answer came through the reduced +scout (`reduced`). A `held` row prints `-` in those four columns. `generated` and `searched` are what the list held and what the loop made; the censored count is the reader's subtraction, and a node its table move cut before anything was generated says `generated 0`. `history_max` is the largest -history score among the generated quiets, clamped at zero, which is what +butterfly score among the generated quiets, clamped at zero, which is what `history` is read against; it is 0 when every one of them is marked down. `scored` says whether the staged ordering ever scored the quiet band at this node. `tt` is what the probe gave: `miss`, `move`, or `score_only` for a hit diff --git a/tests/effort_session.rs b/tests/effort_session.rs index 5f334407..7d876ece 100644 --- a/tests/effort_session.rs +++ b/tests/effort_session.rs @@ -182,6 +182,6 @@ fn a_switch_the_engine_does_not_have_is_refused() { .strip_prefix("unrecognised effort off: quiet_futilty (a switch is one of ") .and_then(|rest| rest.strip_suffix(')')) .unwrap_or_else(|| panic!("stderr: {printed}")); - assert_eq!(named.split(", ").count(), 14, "stderr: {printed}"); + assert_eq!(named.split(", ").count(), 15, "stderr: {printed}"); assert!(output.stdout.is_empty()); } From 2a7c38fabfb4e6e76f5e6f25115522041951eb97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 02:07:07 +0000 Subject: [PATCH 2/2] build(deps): Bump rustix from 1.1.4 to 1.1.6 RUSTSEC-2026-0486 marks the extended attribute functions of rustix 1.1.4 unsound, and cargo deny fails on it. The crate reaches the tree only through tempfile under proptest, a dev dependency, so no shipped binary changes. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01EYmME6yiwe8qbHTU6UgM2z --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9000429..426a7d30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,9 +556,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno",