From 331aecf58a10f500894b6b7791592b0b67bb6821 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:39:49 +0000 Subject: [PATCH 01/10] refactor(board): Name the square behind a pawn and the pawn attack masks once The square behind a pawn (the one a double push steps over, and where the pawn taken en passant stands) was a match on the colour at seven sites in make, unmake, the swap and the check tests; `behind` names it once. The pawn attack tables are laid out by the attacked square, so the squares a pawn attacks are read from the other colour's table, an inversion spelt out at nine sites and now two methods on `AttackMasks`, `pawn_attackers_of` and `pawn_attacks_from`. `Piece::table_index` is the row a piece and colour take in the zobrist, piece square and evaluation tables, which each of the three computed for itself. The generator and the pseudo legality check read a square's rank through `index_to_coordinate`, which also built the file; `rank_of` reads the rank alone. `move_accumulators` and `relocate_piece_index` repeated the board half of `place_bare` and `relocate_bare` and now call them. `see` spelt out `attackers_to`. `Board::key` and `Board::line_ply` had no caller and go, and the `Default` comment said the same of itself while a test calls it. Every helper is inline(always) or a const fn. The one read the generator's pawn loop makes of the attack table stays written out, with the measurement beside it: through the helper the compiler laid the loop out differently and the full generator read 7% more instructions. As it stands the bench is unchanged at 5965973, and callgrind over the bench counts 9,012,901,827 instructions against 9,094,321,693 before (-0.9%), most of the saving in make_move, whose code shrank with the shared bare moves. The perft suites, the unmake tests and the swap tests cover the paths. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/board.rs | 225 +++++++++++++++---------------------- arche-core/src/eval/mod.rs | 6 +- arche-core/src/misc.rs | 20 +++- arche-core/src/psqt.rs | 21 +--- arche-core/src/zobrist.rs | 6 +- 5 files changed, 114 insertions(+), 164 deletions(-) diff --git a/arche-core/src/board.rs b/arche-core/src/board.rs index 813341a8..8efdcee9 100644 --- a/arche-core/src/board.rs +++ b/arche-core/src/board.rs @@ -3,8 +3,7 @@ use super::bitboard::BitBoard; use super::misc::{ - CastlePermissions, Color, Coordinate, File, Piece, PromotePiece, coordinate_to_index, - index_to_coordinate, + CastlePermissions, Color, Coordinate, File, Piece, PromotePiece, coordinate_to_index, rank_of, }; use super::play::Play; use crate::eval::{self, Accumulator}; @@ -360,6 +359,39 @@ struct AttackMasks { kings: [u64; 64], } +impl AttackMasks { + /// Where a pawn of `color` stands when it attacks `square`. The pawn + /// tables are laid out by the attacked square, which is why the squares + /// a pawn attacks are read from the other colour's table below. + #[inline(always)] + fn pawn_attackers_of(&self, square: u8, color: Color) -> u64 { + match color { + Color::White => self.white_pawns[square as usize], + Color::Black => self.black_pawns[square as usize], + } + } + + /// The squares a pawn of `color` on `square` attacks. + #[inline(always)] + fn pawn_attacks_from(&self, square: u8, color: Color) -> u64 { + match color { + Color::White => self.black_pawns[square as usize], + Color::Black => self.white_pawns[square as usize], + } + } +} + +/// The square behind `square` from `color`'s side of the board: the one a +/// pawn of that colour has just stepped over on a double push, and where +/// the pawn taken en passant stands when `square` is the capturer's landing. +#[inline(always)] +const fn behind(square: u8, color: Color) -> u8 { + match color { + Color::White => square - 8, + Color::Black => square + 8, + } +} + /// The squares one step from `from` in each of `steps`, a step that leaves /// the board dropped. Steps are a rank and a file, so a step off the side /// is caught by the file going out of range. @@ -553,8 +585,7 @@ pub struct Board { pub(crate) pawn_key: u64, } -/// Nothing calls this. Clippy asks for a `Default` beside a `new` taking no -/// arguments. +/// Clippy asks for a `Default` beside a `new` taking no arguments. impl Default for Board { fn default() -> Self { Board::new() @@ -570,17 +601,6 @@ impl Board { self.active_color } - /// The position key: the zobrist hash of the pieces, the side to move, - /// the castle rights and the en passant square. - pub fn key(&self) -> u64 { - self.key - } - - /// Plies since the root of the current search. - pub fn line_ply(&self) -> usize { - self.line_ply - } - /// Make this position the root of a search: the line ply starts from /// zero here, whatever moves the board was played through to arrive. pub(crate) fn start_line(&mut self) { @@ -656,10 +676,7 @@ impl Board { // one a double push just passed over, which is empty, and the pawn // that takes has to attack it if m.en_passant { - let attacks = match self.active_color { - Color::White => attack_masks.black_pawns[m.from as usize], - Color::Black => attack_masks.white_pawns[m.from as usize], - }; + let attacks = attack_masks.pawn_attacks_from(m.from, self.active_color); return piece == Piece::Pawn && m.capture == Some(Piece::Pawn) && m.promote.is_none() @@ -688,7 +705,7 @@ impl Board { || magic.get_diagonal_move(m.from, all_pieces).is_bit_set(m.to) } Piece::Pawn => { - let (rank, _) = index_to_coordinate(m.from); + let rank = rank_of(m.from); // a pawn one step from the far rank only ever promotes, and // no other pawn does let promotes = match self.active_color { @@ -699,10 +716,9 @@ impl Board { return false; } if m.capture.is_some() { - return match self.active_color { - Color::White => attack_masks.black_pawns[m.from as usize].is_bit_set(m.to), - Color::Black => attack_masks.white_pawns[m.from as usize].is_bit_set(m.to), - }; + return attack_masks + .pawn_attacks_from(m.from, self.active_color) + .is_bit_set(m.to); } let one = match self.active_color { Color::White => m.from as isize + 8, @@ -892,11 +908,15 @@ impl Board { let mut pawns = self.pawns() & color_mask; while pawns != 0 { let from = pop_lsb(&mut pawns); - let (rank, _) = index_to_coordinate(from); + let rank = rank_of(from); let can_promote = match self.active_color { Color::White => rank == 7, Color::Black => rank == 2, }; + // read off the table here rather than through `pawn_attacks_from`: + // through the helper the compiler laid this loop out differently + // and the full generator measured 7% more instructions over the + // bench (callgrind against 31eb33b), 0.25% of the whole run let pmoves: u64 = match self.active_color { Color::White => attack_masks.black_pawns[from as usize] & capture_mask, Color::Black => attack_masks.white_pawns[from as usize] & capture_mask, @@ -955,10 +975,9 @@ impl Board { } if let Some(en_passant) = &self.en_passant { let i = en_passant.as_index(); - let can_en_passant = match self.active_color { - Color::White => attack_masks.black_pawns[from as usize].is_bit_set(i), - Color::Black => attack_masks.white_pawns[from as usize].is_bit_set(i), - }; + let can_en_passant = attack_masks + .pawn_attacks_from(from, self.active_color) + .is_bit_set(i); if can_en_passant { moves.capture(Play::new(from, i, Some(Piece::Pawn), None, true, false)); } @@ -1096,11 +1115,8 @@ impl Board { fn square_attacked_through(&self, index: u8, color: Color, all: u64) -> bool { let attack_masks = &ATTACK_MASKS; let magic = &MAGIC; - let (color_mask, pawn_masks) = match color { - Color::Black => (self.black, &attack_masks.black_pawns), - Color::White => (self.white, &attack_masks.white_pawns), - }; - if (pawn_masks[index as usize] & self.pawns() & color_mask) > 0 { + let (color_mask, _) = self.sides(color); + if (attack_masks.pawn_attackers_of(index, color) & self.pawns() & color_mask) > 0 { return true; } @@ -1222,14 +1238,10 @@ impl Board { let mut occupied = self.white | self.black; occupied &= !(1u64 << m.from); if m.en_passant { - let taken = match self.active_color { - Color::White => m.to - 8, - Color::Black => m.to + 8, - }; - occupied &= !(1u64 << taken); + occupied &= !(1u64 << behind(m.to, self.active_color)); } let (ours, theirs) = self.sides(self.active_color); - let bearing = (self.steppers_onto(m.to) | self.sliders_onto(m.to, occupied)) & occupied; + let bearing = self.attackers_to(m.to, occupied); let defenders = bearing & theirs; if defenders == 0 { debug_assert_eq!(won, self.swap_walk(m), "{m}: an undefended capture"); @@ -1304,11 +1316,7 @@ impl Board { let mut occupied = self.white | self.black; occupied &= !(1u64 << m.from); if m.en_passant { - let taken = match self.active_color { - Color::White => m.to - 8, - Color::Black => m.to + 8, - }; - occupied &= !(1u64 << taken); + occupied &= !(1u64 << behind(m.to, self.active_color)); } let mut on_square = self .get_piece_index(m.from) @@ -1538,21 +1546,15 @@ impl Board { // can take on it. Hashing it unconditionally makes one // position hash two ways, which costs transposition hits and // hides a repetition either side of a double push - let passed = match self.active_color { - Color::White => play.to - 8, - Color::Black => play.to + 8, - }; + let passed = behind(play.to, self.active_color); if self.pawn_can_capture_on(passed, opposing_color) { self.en_passant = Some(Coordinate::from_index(passed)); self.key ^= ZOBRIST.en_passant_key(passed); } } if play.en_passant { - let clear_index = match self.active_color { - Color::White => play.to - 8, - Color::Black => play.to + 8, - }; - self.clear_piece_index(clear_index, Piece::Pawn, opposing_color); + let taken = behind(play.to, self.active_color); + self.clear_piece_index(taken, Piece::Pawn, opposing_color); } } @@ -1659,7 +1661,7 @@ impl Board { self.history[previous] = None; let play = history.play; - let opposing_color = !self.active_color; + let mover = !self.active_color; self.castle = history.castle; self.en_passant = history.en_passant; self.fifty_move_rule = history.fifty_move_rule; @@ -1667,21 +1669,18 @@ impl Board { self.line_ply -= 1; if play.en_passant { - let en_passant_index = match opposing_color { - Color::White => play.to - 8, - Color::Black => play.to + 8, - }; - self.place_bare::(en_passant_index, Piece::Pawn, self.active_color); + let taken = behind(play.to, mover); + self.place_bare::(taken, Piece::Pawn, self.active_color); } if let Some(promote) = play.promote { - self.place_bare::(play.to, (&promote).into(), opposing_color); - self.place_bare::(play.from, Piece::Pawn, opposing_color); + self.place_bare::(play.to, (&promote).into(), mover); + self.place_bare::(play.from, Piece::Pawn, mover); } else { let from_piece = self .get_piece_index(play.to) .expect("The to square must always be occupied when undoing"); - self.relocate_bare(play.to, play.from, from_piece, opposing_color); + self.relocate_bare(play.to, play.from, from_piece, mover); } if let Some(capture) = play.capture { @@ -1691,15 +1690,15 @@ impl Board { } if play.castle { match play.to { - C1 => self.relocate_bare(D1, A1, Piece::Rook, opposing_color), - C8 => self.relocate_bare(D8, A8, Piece::Rook, opposing_color), - G1 => self.relocate_bare(F1, H1, Piece::Rook, opposing_color), - G8 => self.relocate_bare(F8, H8, Piece::Rook, opposing_color), + C1 => self.relocate_bare(D1, A1, Piece::Rook, mover), + C8 => self.relocate_bare(D8, A8, Piece::Rook, mover), + G1 => self.relocate_bare(F1, H1, Piece::Rook, mover), + G8 => self.relocate_bare(F8, H8, Piece::Rook, mover), _ => unreachable!(), } } - self.active_color = opposing_color; + self.active_color = mover; // the key, the pawn key and the accumulator come back by copy rather // than being unfolded: the pieces are moved back on the boards alone let kept = &self.kept.0[self.ply % KEPT_PLIES]; @@ -1726,7 +1725,9 @@ impl Board { } } - /// Put down or pick up a piece on the boards and `squares` alone. + /// Put down or pick up a piece on the boards and `squares` alone. The + /// callers assert that a set lands on an empty square and a clear on an + /// occupied one, so this never asks what was standing there. #[inline(always)] fn place_bare(&mut self, index: u8, piece: Piece, color: Color) { let board = &mut self.pieces[piece as usize]; @@ -1877,7 +1878,7 @@ impl Board { /// clears a pawn from a square holding something else, or lands the /// capturer on top of a piece nothing took. fn en_passant_can_be_played(&self, index: u8) -> bool { - let (rank, _) = index_to_coordinate(index); + let rank = rank_of(index); let crossed = match self.active_color { Color::White => 6, Color::Black => 3, @@ -1886,10 +1887,7 @@ impl Board { return false; } // the rank check above is what keeps this on the board - let taken = match self.active_color { - Color::White => index - 8, - Color::Black => index + 8, - }; + let taken = behind(index, self.active_color); self.pawn_can_capture_on(index, self.active_color) && !(self.white | self.black).is_bit_set(index) && self.get_piece_and_color_index(taken) == Some((Piece::Pawn, !self.active_color)) @@ -1897,12 +1895,8 @@ impl Board { /// Whether a pawn of this colour is placed to take on this square. fn pawn_can_capture_on(&self, index: u8, capturer: Color) -> bool { - let attack_masks = &ATTACK_MASKS; - let (from, pawns) = match capturer { - Color::White => (attack_masks.white_pawns[index as usize], self.white), - Color::Black => (attack_masks.black_pawns[index as usize], self.black), - }; - from & self.pawns() & pawns != 0 + let (pawns, _) = self.sides(capturer); + ATTACK_MASKS.pawn_attackers_of(index, capturer) & self.pawns() & pawns != 0 } #[inline] @@ -2019,11 +2013,10 @@ impl Board { match landed { Piece::Pawn => { - let masks = match !defender { - Color::White => &attack_masks.white_pawns, - Color::Black => &attack_masks.black_pawns, - }; - if masks[king as usize].is_bit_set(to) { + if attack_masks + .pawn_attackers_of(king, !defender) + .is_bit_set(to) + { checkers.set_bit(to); } } @@ -2074,10 +2067,7 @@ impl Board { let magic = &MAGIC; let occupied = self.white | self.black; let (ours, _) = self.sides(self.active_color); - let pawns = match self.active_color { - Color::White => attack_masks.white_pawns[king as usize], - Color::Black => attack_masks.black_pawns[king as usize], - }; + let pawns = attack_masks.pawn_attackers_of(king, self.active_color); let diagonal = magic.get_diagonal_move(king, occupied); let straight = magic.get_straight_move(king, occupied); let mut snipers = (attack_masks.diagonal[king as usize] & (self.bishops() | self.queens()) @@ -2169,11 +2159,10 @@ impl Board { }; match landed { Piece::Pawn => { - let masks = match self.active_color { - Color::White => &attack_masks.white_pawns, - Color::Black => &attack_masks.black_pawns, - }; - if masks[king as usize].is_bit_set(m.to) { + if attack_masks + .pawn_attackers_of(king, self.active_color) + .is_bit_set(m.to) + { return true; } } @@ -2192,11 +2181,7 @@ impl Board { } if m.en_passant { - let taken = match self.active_color { - Color::White => m.to - 8, - Color::Black => m.to + 8, - }; - occupied &= !(1u64 << taken); + occupied &= !(1u64 << behind(m.to, self.active_color)); } else if m.castle { let (rook_from, rook_to) = match m.to { C1 => (A1, D1), @@ -2309,18 +2294,7 @@ impl Board { self.pawn_key ^= moved; } self.eval.relocate(left, arrived); - - let both = (1u64 << from) | (1u64 << to); - self.pieces[piece as usize] ^= both; - match color { - Color::Black => self.black ^= both, - Color::White => self.white ^= both, - } - self.squares[(from & 63) as usize] = None; - self.squares[(to & 63) as usize] = Some(piece); - if piece == Piece::King { - self.king_squares[color as usize] = to; - } + self.relocate_bare(from, to, piece, color); } /// Take a piece off a square, undoing everything `set_piece_index` did. @@ -2340,26 +2314,7 @@ impl Board { self.pawn_key ^= piece_key; } self.eval.count::(row, piece); - - let board = &mut self.pieces[piece as usize]; - if SET { - board.set_bit(index); - } else { - board.clear_bit(index); - } - // the callers assert that a set lands on an empty square and a clear - // on an occupied one, so this never asks what was standing there - self.squares[(index & 63) as usize] = if SET { Some(piece) } else { None }; - - let side = match color { - Color::Black => &mut self.black, - Color::White => &mut self.white, - }; - if SET { - side.set_bit(index); - } else { - side.clear_bit(index); - } + self.place_bare::(index, piece, color); } /// What stands on a square, read rather than searched for. @@ -4418,7 +4373,7 @@ mod between { #[cfg(test)] mod see { - use super::{Board, Color, Piece, Play, SEE_VALUES, play_named}; + use super::{Board, Color, Piece, Play, SEE_VALUES, behind, play_named}; use pretty_assertions::assert_eq; /// Every capture in every position of the three suites, and in every @@ -4583,11 +4538,7 @@ mod see { let victim = m.capture.expect("only captures are priced"); let mut occupied = (board.white | board.black) & !(1u64 << m.from); if m.en_passant { - let taken = match board.active_color { - Color::White => m.to - 8, - Color::Black => m.to + 8, - }; - occupied &= !(1u64 << taken); + occupied &= !(1u64 << behind(m.to, board.active_color)); } let mover = board .get_piece_index(m.from) diff --git a/arche-core/src/eval/mod.rs b/arche-core/src/eval/mod.rs index 27d72a09..cac430f4 100644 --- a/arche-core/src/eval/mod.rs +++ b/arche-core/src/eval/mod.rs @@ -125,11 +125,7 @@ const fn rows() -> [Row; 768] { /// The row of a piece on a square. #[inline(always)] pub(crate) fn row(index: u8, piece: Piece, color: Color) -> &'static Row { - let table = match color { - Color::White => piece as usize, - Color::Black => piece as usize + 6, - }; - &ROWS[table * 64 + (index & 63) as usize] + &ROWS[piece.table_index(color) * 64 + (index & 63) as usize] } /// The material weight of one piece, for the board's own seeding walk. diff --git a/arche-core/src/misc.rs b/arche-core/src/misc.rs index 7084e248..b7bde2e5 100644 --- a/arche-core/src/misc.rs +++ b/arche-core/src/misc.rs @@ -217,9 +217,14 @@ pub const fn coordinate_to_large_index(rank: u8, file: File) -> u8 { } pub fn index_to_coordinate(index: u8) -> (u8, File) { - let rank = ((index) / 8) + 1; let file = File::try_from(index % 8).unwrap(); - (rank, file) + (rank_of(index), file) +} + +/// The rank a square stands on, one to eight. +#[inline(always)] +pub(crate) const fn rank_of(index: u8) -> u8 { + index / 8 + 1 } #[cfg(test)] @@ -275,6 +280,17 @@ pub enum Piece { } impl Piece { + /// This piece's row in a table laid out as the six pieces for white and + /// then the six for black: the zobrist keys, the piece square tables and + /// the evaluation's rows all read it. + #[inline(always)] + pub(crate) const fn table_index(self, color: Color) -> usize { + match color { + Color::White => self as usize, + Color::Black => self as usize + 6, + } + } + /// The pieces in discriminant order, for walking something indexed the /// way `pieces` and the tables are. The assertion below holds the order /// to the discriminants. diff --git a/arche-core/src/psqt.rs b/arche-core/src/psqt.rs index b6f9ca63..2a07fb3c 100644 --- a/arche-core/src/psqt.rs +++ b/arche-core/src/psqt.rs @@ -268,21 +268,12 @@ impl PieceSquareTables { /// The packed pair for a piece on a square, both phases at once. #[inline] pub fn get_value(&self, index: usize, piece: Piece, color: Color) -> i32 { - self.tables[Self::table_index(piece, color)][index] + self.tables[piece.table_index(color)][index] } - /// The same shape `Zobrist` indexes its piece keys by: white takes the - /// piece's own row, black the one six further on. - #[inline] - const fn table_index(piece: Piece, color: Color) -> usize { - match color { - Color::White => piece as usize, - Color::Black => piece as usize + 6, - } - } - - /// Built at compile time, in the order `table_index` reads it: the six - /// pieces as `Piece` declares them for white, then the same six for black. + /// Built at compile time, in the order `Piece::table_index` reads it: the + /// six pieces as `Piece` declares them for white, then the same six for + /// black. pub const TABLES: PieceSquareTables = PieceSquareTables { tables: [ packed(mirror(&PAWNS), mirror(&PAWNS_END)), @@ -660,8 +651,8 @@ mod tests { } } - /// Every piece reads its own two tables, in the order `table_index` asks - /// for them. + /// Every piece reads its own two tables, in the order `Piece::table_index` + /// asks for them. /// /// The shape tests mostly cannot see one piece handed another's table: a /// bishop's and a queen's pass each other's, and so do three of the four diff --git a/arche-core/src/zobrist.rs b/arche-core/src/zobrist.rs index c26ae931..086e34b3 100644 --- a/arche-core/src/zobrist.rs +++ b/arche-core/src/zobrist.rs @@ -78,11 +78,7 @@ impl Zobrist { #[inline] pub fn get_piece_key(&self, index: u8, piece: Piece, color: Color) -> u64 { - let piece_index = match color { - Color::White => piece as usize, - Color::Black => piece as usize + 6, - }; - self.pieces[piece_index][index as usize] + self.pieces[piece.table_index(color)][index as usize] } /// A piece key by table row, for building tables at compile time. From cc19448bc8c12ec3712739d039f094d61442ad4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:44:21 +0000 Subject: [PATCH 02/10] test(board): Walk the perft cases through one helper for the three generators The six perft tests, three over the standard positions and three over the edge cases, each repeated the loop over the cases and differed only in which walk they called and the words in the failure. Each module now has one helper taking the walk and the words, and the six tests are a line each. The three properties stay three tests: the plain generator, the evasion mask and the checkers maintenance fail on their own. The three repetition tests built the same four rook moves; `CYCLE` is the one constant. The psqt reflection test walks `Piece::PIECES` rather than its own list of the six. `each_new_endgame_table_is_written_out_rather_than_aliased` read the source for the four endgame tables. The test above it refuses a table whose endgame half matches its midgame half on every square, which is what an alias or a copy gives, so the source read added no case and goes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/board.rs | 104 ++++++++++++++-------------------------- arche-core/src/psqt.rs | 21 +------- 2 files changed, 38 insertions(+), 87 deletions(-) diff --git a/arche-core/src/board.rs b/arche-core/src/board.rs index 8efdcee9..4133dbee 100644 --- a/arche-core/src/board.rs +++ b/arche-core/src/board.rs @@ -2776,6 +2776,9 @@ mod make_move { const NEAR_THE_WRAP: &str = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 b - - 3 127"; + /// Four rook moves that bring the shuffle position back to itself. + const CYCLE: [(u8, u8); 4] = [(A8, B8), (A1, B1), (B8, A8), (B1, A1)]; + #[test] fn a_repetition_is_still_seen_when_the_history_wraps() { let mut board = Board::from_fen(NEAR_THE_WRAP).unwrap(); @@ -2785,12 +2788,11 @@ mod make_move { "the cycle must cross the wrap" ); - let cycle = [(A8, B8), (A1, B1), (B8, A8), (B1, A1)]; - for (from, to) in cycle { + for (from, to) in CYCLE { assert!(board.make_move(&Play::new(from, to, None, None, false, false))); } assert!(board.has_repeated()); - for (from, to) in cycle { + for (from, to) in CYCLE { assert!(board.make_move(&Play::new(from, to, None, None, false, false))); } assert!(board.is_repetition()); @@ -2800,11 +2802,10 @@ mod make_move { fn moves_can_be_unmade_across_the_wrap() { let start = Board::from_fen(NEAR_THE_WRAP).unwrap(); let mut board = start.clone(); - let cycle = [(A8, B8), (A1, B1), (B8, A8), (B1, A1)]; - for (from, to) in cycle { + for (from, to) in CYCLE { assert!(board.make_move(&Play::new(from, to, None, None, false, false))); } - for _ in cycle { + for _ in CYCLE { board.undo_move(); } assert_eq!(board, start); @@ -2820,10 +2821,9 @@ mod make_move { #[test] fn has_repeated_fires_a_cycle_before_is_repetition() { let mut board = Board::from_fen(fens::SHUFFLE).unwrap(); - let cycle = [(A8, B8), (A1, B1), (B8, A8), (B1, A1)]; assert_eq!(board.has_repeated(), false); - for (from, to) in cycle { + for (from, to) in CYCLE { // and no false positives anywhere on the way round assert_eq!(board.is_repetition(), false); board.make_move(&Play::new(from, to, None, None, false, false)); @@ -2832,7 +2832,7 @@ mod make_move { assert_eq!(board.has_repeated(), true); assert_eq!(board.is_repetition(), false); - for (from, to) in cycle { + for (from, to) in CYCLE { assert_eq!(board.is_repetition(), false); board.make_move(&Play::new(from, to, None, None, false, false)); } @@ -3287,57 +3287,40 @@ mod perft { ), ]; - #[test] - fn the_standard_positions_count_exactly() { + /// Every case at every depth through one of the three walks, `how` + /// naming the walk in the failure. + fn counts_exactly(perft: fn(&mut Board, u8) -> u64, how: &str) { for (description, fen, counts) in CASES { let mut board = Board::from_fen(fen).unwrap(); for (i, &expected) in counts.iter().enumerate() { let depth = i as u8 + 1; assert_eq!( - board.perft(depth), + perft(&mut board, depth), expected, - "{} at depth {}", + "{} at depth {}{}", description, - depth + depth, + how ); } } } + #[test] + fn the_standard_positions_count_exactly() { + counts_exactly(Board::perft, ""); + } + /// See `perft_through_evasions`. #[test] fn the_standard_positions_count_the_same_through_evasions() { - for (description, fen, counts) in CASES { - let mut board = Board::from_fen(fen).unwrap(); - for (i, &expected) in counts.iter().enumerate() { - let depth = i as u8 + 1; - assert_eq!( - board.perft_through_evasions(depth), - expected, - "{} at depth {}, through evasions", - description, - depth - ); - } - } + counts_exactly(Board::perft_through_evasions, ", through evasions"); } /// See `perft_as_played`. #[test] fn the_standard_positions_count_the_same_as_played() { - for (description, fen, counts) in CASES { - let mut board = Board::from_fen(fen).unwrap(); - for (i, &expected) in counts.iter().enumerate() { - let depth = i as u8 + 1; - assert_eq!( - board.perft_as_played(depth), - expected, - "{} at depth {}, as played", - description, - depth - ); - } - } + counts_exactly(Board::perft_as_played, ", as played"); } } @@ -4037,53 +4020,40 @@ mod perft_edge_cases { ), ]; - #[test] - fn every_edge_case_counts_exactly() { + /// Every case through one of the three walks, `how` naming the walk in + /// the failure. + fn counts_exactly(perft: fn(&mut Board, u8) -> u64, how: &str) { for (fen, depth, expected, description) in CASES { let mut board = Board::from_fen(fen).unwrap(); assert_eq!( - board.perft(depth), + perft(&mut board, depth), expected, - "{} ({} at depth {})", + "{} ({} at depth {}){}", description, fen, - depth + depth, + how ); } } + #[test] + fn every_edge_case_counts_exactly() { + counts_exactly(Board::perft, ""); + } + /// The shapes most likely to catch the evasion mask out: promotions that /// answer a check, the en passant captures the mask does not examine, /// and pins that leave a move looking like an answer. #[test] fn every_edge_case_counts_the_same_through_evasions() { - for (fen, depth, expected, description) in CASES { - let mut board = Board::from_fen(fen).unwrap(); - assert_eq!( - board.perft_through_evasions(depth), - expected, - "{} ({} at depth {}), through evasions", - description, - fen, - depth - ); - } + counts_exactly(Board::perft_through_evasions, ", through evasions"); } /// The shapes most likely to catch checkers maintenance out. #[test] fn every_edge_case_counts_the_same_as_played() { - for (fen, depth, expected, description) in CASES { - let mut board = Board::from_fen(fen).unwrap(); - assert_eq!( - board.perft_as_played(depth), - expected, - "{} ({} at depth {}), as played", - description, - fen, - depth - ); - } + counts_exactly(Board::perft_as_played, ", as played"); } } diff --git a/arche-core/src/psqt.rs b/arche-core/src/psqt.rs index 2a07fb3c..fe9e32a7 100644 --- a/arche-core/src/psqt.rs +++ b/arche-core/src/psqt.rs @@ -527,14 +527,7 @@ mod tests { /// changed without the other. #[test] fn the_two_colours_are_reflections_of_each_other() { - for piece in [ - Piece::Pawn, - Piece::Knight, - Piece::Bishop, - Piece::Rook, - Piece::Queen, - Piece::King, - ] { + for piece in Piece::PIECES { for rank in 1..=8 { for file in File::VARIANTS { // the whole pair at once, so a table tapered for one @@ -639,18 +632,6 @@ mod tests { } } - /// Each of the four is written out rather than aliased to its midgame - /// twin. Nothing at run time can tell the copy from the alias, so this - /// reads the source. - #[test] - fn each_new_endgame_table_is_written_out_rather_than_aliased() { - let source = include_str!("psqt.rs"); - for table in ["KNIGHTS_END", "BISHOPS_END", "ROOKS_END", "QUEENS_END"] { - let written = format!("const {}: [i16; 64] = [", table); - assert!(source.contains(&written), "{} is not written out", table); - } - } - /// Every piece reads its own two tables, in the order `Piece::table_index` /// asks for them. /// From d964ad493107a6f659c1abf97a7d393ca8ea9f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:53:34 +0000 Subject: [PATCH 03/10] refactor(eval): Fold each term through weigh directly and pin the arithmetic once Each of the four leaf terms carried a `fold_with` that handed caller named weights to `weigh`, with `fold` its one caller passing the live array, so that a test could fold against weights of its own. The four tests that did so were one test written four times: the same hand sum over a trial array, pinning `weigh`'s arithmetic, which is one generic function. That test now stands once beside `weigh` in eval/mod.rs. Each term's test keeps its hand counts and holds `fold` to `weigh` over them against the live weights, which is the part of a fold that is the term's own (which counts, which weights, white less black). `fold_with` goes. `Accumulator::recomputed` walked the occupancy by hand and then handed `pieces_of` to the machine; it walks `pieces_of` and names the colour's sign once. The static copy of the piece square tables it read goes, as does the six piece list in `rows()`, which is `Piece::PIECES`. The king attack term read its weights for a non zero one with a function of its own; `mobility::scored_kinds` takes the weights and answers both terms. The factors tests and the shared walk test built the same list of the four suites' positions, now `suite_fens`, and the term test reads `pieces_of` rather than restating it. Two comments name `tune.py::bounds_hold` the way the tuner's own does, and `material`'s doc names its three readers. Nothing the evaluation scores changes: `fold` calls `weigh` on the same arguments, `recomputed` runs under debug assertions alone, and the compile time constants are the same. The bench is unchanged at 5965973, and callgrind over the bench counts 9,012,901,827 instructions, the commit before's count to the instruction. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/eval/factors.rs | 23 ++----- arche-core/src/eval/king_attack.rs | 65 +++++-------------- arche-core/src/eval/mobility.rs | 75 +++++++++------------- arche-core/src/eval/mod.rs | 91 +++++++++++++++++---------- arche-core/src/eval/pawn_structure.rs | 55 ++++------------ arche-core/src/eval/shelter.rs | 50 +++------------ arche-core/src/misc.rs | 9 +++ 7 files changed, 138 insertions(+), 230 deletions(-) diff --git a/arche-core/src/eval/factors.rs b/arche-core/src/eval/factors.rs index 9d74faa4..55efacfc 100644 --- a/arche-core/src/eval/factors.rs +++ b/arche-core/src/eval/factors.rs @@ -327,20 +327,12 @@ impl Machine { #[cfg(test)] mod features { use super::{Machine, RANK, feature}; + use crate::bench; use crate::board::{Board, fens}; - use crate::eval::eval; + use crate::eval::{eval, pieces_of, suite_fens}; use crate::misc::{Color, Piece}; - use crate::{bench, strategy, tactics}; use pretty_assertions::assert_eq; - fn suites() -> Vec { - let mut fens: Vec = fens::CORE.iter().map(|f| f.to_string()).collect(); - fens.extend(bench::positions().into_iter().map(|p| p.fen)); - fens.extend(tactics::positions().into_iter().map(|p| p.fen)); - fens.extend(strategy::positions().into_iter().map(|p| p.fen)); - fens - } - /// The colour mirror of a fen. fn mirrored(fen: &str) -> String { let fields: Vec<&str> = fen.split(' ').collect(); @@ -378,7 +370,7 @@ mod features { /// position, which needs the divide to truncate toward zero. #[test] fn a_mirrored_position_scores_the_same_across_the_suites() { - for fen in suites() { + for fen in suite_fens() { let board = Board::from_fen(&fen).unwrap(); let mirror = Board::from_fen(&mirrored(&fen)).unwrap(); assert_eq!(eval(&board), eval(&mirror), "{}", fen); @@ -419,16 +411,11 @@ mod features { if RANK == 0 { return; } - let scored = suites() + let scored = suite_fens() .iter() .map(|fen| { let board = Board::from_fen(fen).unwrap(); - let pieces = (0..64).filter_map(|index| { - board - .get_piece_and_color_index(index) - .map(|(piece, color)| (index, piece, color)) - }); - Machine::of(pieces).score() + Machine::of(pieces_of(&board)).score() }) .filter(|&score| score != 0) .count(); diff --git a/arche-core/src/eval/king_attack.rs b/arche-core/src/eval/king_attack.rs index d889a0db..01d82736 100644 --- a/arche-core/src/eval/king_attack.rs +++ b/arche-core/src/eval/king_attack.rs @@ -14,7 +14,7 @@ use super::weigh; use crate::board::{Board, king_attacks, knight_attacks, pop_lsb}; use crate::magic::MAGIC; use crate::misc::Color; -use crate::psqt::{eg_value, mg_value, pack}; +use crate::psqt::pack; /// One count per piece kind, in the order [`mobility::PIECES`] names them, /// so one index in the shared walk in `eval/mod.rs` reads both terms' @@ -31,9 +31,9 @@ pub(crate) const COUNTS: usize = mobility::PIECES.len(); /// docs/ROADMAP.md has what the term leaves out. What counting the ring at /// every evaluation costs is there too, and in 7991f40. /// -/// `bounds_hold` charges one piece of each kind two squares of the ring for a -/// knight, three for a bishop, four for a rook and six for a queen, both sides -/// counted. That puts this term at 624 in the midgame and the whole vector's +/// `tune.py::bounds_hold` charges one piece of each kind two squares of the +/// ring for a knight, three for a bishop, four for a rook and six for a queen, +/// both sides counted. That puts this term at 624 in the midgame and the whole vector's /// boardful at 10,118, against the 32,767 a half has to stay inside. static KING_ATTACK: [i32; COUNTS] = [pack(11, 3), pack(22, -3), pack(29, -3), pack(18, 9)]; @@ -49,21 +49,7 @@ pub(crate) const fn weight(index: usize) -> i32 { /// /// A count at a zero weight is not folded away: llvm leaves the walk over /// the pieces standing, and 188297f measured what that cost over the bench. -pub(crate) const SCORED: bool = scored(&KING_ATTACK); - -/// Whether `weights` prices anything, read at compile time. Both halves are -/// asked about: a weight worth nothing in the midgame and something in the -/// ending is still a weight and still has to be counted. -const fn scored(weights: &[i32; COUNTS]) -> bool { - let mut index = 0; - while index < COUNTS { - if mg_value(weights[index]) != 0 || eg_value(weights[index]) != 0 { - return true; - } - index += 1; - } - false -} +pub(crate) const SCORED: bool = mobility::scored_kinds(&KING_ATTACK) != 0; /// How many squares of the other king's ring this side's knights, bishops, /// rooks and queens attack, a count per piece kind in the order @@ -138,14 +124,8 @@ pub(crate) fn counts(board: &Board, color: Color, into: &mut [i32]) { /// it. #[cfg(test)] pub(crate) fn fold(board: &Board) -> i32 { - fold_with(board, &KING_ATTACK) -} - -/// The same fold against weights named by the caller. -#[cfg(test)] -fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 { weigh( - weights, + &KING_ATTACK, counts_of(board, Color::White), counts_of(board, Color::Black), ) @@ -153,7 +133,8 @@ fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 { #[cfg(test)] mod tests { - use super::{Board, COUNTS, Color, SCORED, counts_of, king_attacks, scored}; + use super::mobility::scored_kinds; + use super::{Board, COUNTS, Color, SCORED, counts_of, king_attacks, weigh}; use crate::psqt::{eg_value, mg_value, pack}; use pretty_assertions::assert_eq; @@ -356,33 +337,20 @@ mod tests { const WHITE_BEARS: [i32; COUNTS] = [2, 1, 3, 4]; const BLACK_BEARS: [i32; COUNTS] = [0, 2, 0, 3]; - /// Four weights that differ from each other at both ends of the taper, so - /// that a pair read into the wrong piece's slot lands on a different - /// number. The four differences between the halves are 9, -20, 8 and -12, - /// which differ from each other too. - const TRIAL: [i32; COUNTS] = [pack(11, 2), pack(-7, 13), pack(3, -5), pack(29, 41)]; - - /// What the fold does with weights that are not the shipped ones, which - /// nothing holds apart from each other: white's count less black's, piece - /// by piece, each half of the pair summed on its own. + /// The fold is `weigh` (pinned in `eval/mod.rs`) over the two sides' + /// counts, white less black and against the live weights. #[test] - fn the_king_attack_fold_reads_white_less_black_piece_by_piece() { + fn the_king_attack_fold_weighs_white_less_black() { let board = Board::from_fen(BEARING).unwrap(); assert_eq!(counts_of(&board, Color::White), WHITE_BEARS); assert_eq!(counts_of(&board, Color::Black), BLACK_BEARS); - let midgame: i32 = (0..COUNTS) - .map(|i| mg_value(TRIAL[i]) * (WHITE_BEARS[i] - BLACK_BEARS[i])) - .sum(); - let endgame: i32 = (0..COUNTS) - .map(|i| eg_value(TRIAL[i]) * (WHITE_BEARS[i] - BLACK_BEARS[i])) - .sum(); + let expected = weigh(&super::KING_ATTACK, WHITE_BEARS, BLACK_BEARS); assert_ne!( - midgame, endgame, - "the two halves would not tell a swap apart" + expected, 0, + "the live weights level this position, so the fold's order would not show: \ + pick another position" ); - let packed = super::fold_with(&board, &TRIAL); - assert_ne!(midgame, 0, "black less white would answer the same here"); - assert_eq!((mg_value(packed), eg_value(packed)), (midgame, endgame)); + assert_eq!(super::fold(&board), expected); } /// What the leaf is allowed to leave out, which is the whole of the @@ -396,6 +364,7 @@ mod tests { mg_value(weight) != 0 || eg_value(weight) != 0 }); assert_eq!(SCORED, priced); + let scored = |weights: &[i32; COUNTS]| scored_kinds(weights) != 0; assert!(!scored(&[pack(0, 0); COUNTS])); for index in 0..COUNTS { let mut midgame = [pack(0, 0); COUNTS]; diff --git a/arche-core/src/eval/mobility.rs b/arche-core/src/eval/mobility.rs index 27810731..bbb8adb2 100644 --- a/arche-core/src/eval/mobility.rs +++ b/arche-core/src/eval/mobility.rs @@ -55,22 +55,22 @@ pub(crate) const ALL_KINDS: u8 = (1 << COUNTS) - 1; /// refit changes the set with nothing else edited. When the first fit left /// six of the eight weights at zero, leaving three kinds out took a bit over /// a third off what the term cost (7b0f38b). -pub(crate) const SCORED_KINDS: u8 = scored_kinds(); +pub(crate) const SCORED_KINDS: u8 = scored_kinds(&MOBILITY); /// Whether `kinds` names the piece at `index` in [`PIECES`]. pub(crate) const fn counted(kinds: u8, index: usize) -> bool { kinds & (1 << index) != 0 } -/// [`SCORED_KINDS`], read off the weights at compile time. Both halves are -/// asked about: a weight worth nothing in the midgame and something in the -/// ending is still a weight and still has to be counted. -const fn scored_kinds() -> u8 { +/// The kinds `weights` prices, read at compile time; [`SCORED_KINDS`] here +/// and [`super::king_attack::SCORED`] are read off it. Both halves are asked +/// about: a weight worth nothing in the midgame and something in the ending +/// is still a weight and still has to be counted. +pub(crate) const fn scored_kinds(weights: &[i32; COUNTS]) -> u8 { let mut kinds = 0; let mut index = 0; while index < COUNTS { - let weight = weight(index); - if mg_value(weight) != 0 || eg_value(weight) != 0 { + if mg_value(weights[index]) != 0 || eg_value(weights[index]) != 0 { kinds |= 1 << index; } index += 1; @@ -153,28 +153,20 @@ pub(crate) fn counts(board: &Board, color: Color, into: &mut [i32]) { /// attack zone. The tests hold the sum to it. #[cfg(test)] pub(crate) fn fold(board: &Board) -> i32 { - fold_with::(board, &MOBILITY) -} - -/// The same fold over the kinds `KINDS` names, against weights named by the -/// caller. A kind outside `KINDS` counts zero and so has to be worth zero. -/// The tests supply weights of their own because what they pin is the fold -/// rather than the fit: a permuted [`MOBILITY`] would be a different -/// evaluation and not a wrong one. -#[cfg(test)] -fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 { weigh( - weights, - counts_of::(board, Color::White), - counts_of::(board, Color::Black), + &MOBILITY, + counts_of::(board, Color::White), + counts_of::(board, Color::Black), ) } #[cfg(test)] mod tests { - use super::{ALL_KINDS, Board, COUNTS, Color, MOBILITY, PIECES, SCORED_KINDS, counts_of, fold}; + use super::{ + ALL_KINDS, Board, COUNTS, Color, MOBILITY, PIECES, SCORED_KINDS, counts_of, fold, weigh, + }; use crate::board::fens; - use crate::psqt::{eg_value, mg_value, pack}; + use crate::psqt::{eg_value, mg_value}; use pretty_assertions::assert_eq; /// The counts by hand, because nothing else pins them: the tuner reads @@ -269,7 +261,7 @@ mod tests { ); } - /// The position the two tests below are read against, and what each side + /// The position the fold test below is read against, and what each side /// covers in it, worked out square by square rather than read back off /// [`counts_of`]. /// @@ -288,17 +280,12 @@ mod tests { const WHITE_COVERS: [i32; COUNTS] = [3, 2, 7, 10]; const BLACK_COVERS: [i32; COUNTS] = [0, 7, 9, 9]; - /// Four weights that differ from each other at both ends of the taper, so - /// that a pair read into the wrong piece's slot lands on a different - /// number. The four differences are 3, -5, -2 and 1, which differ from - /// each other too, so a permutation of either array shows. - const TRIAL: [i32; COUNTS] = [pack(11, 2), pack(-7, 13), pack(3, -5), pack(29, 41)]; - - /// What the fold does with weights that are not the shipped ones, which - /// are the fit's and will move again: white's count less black's, piece - /// by piece, each half of the pair summed on its own. + /// The fold is `weigh` (pinned in `eval/mod.rs`) over the two sides' + /// counts, white less black and against the live weights. A kind the + /// fold skips is one whose weight is zero, so the hand counts of all four + /// weigh the same. #[test] - fn the_mobility_fold_reads_white_less_black_piece_by_piece() { + fn the_mobility_fold_weighs_white_less_black() { let board = Board::from_fen(COUNTED).unwrap(); assert_eq!( counts_of::<{ ALL_KINDS }>(&board, Color::White), @@ -308,19 +295,13 @@ mod tests { counts_of::<{ ALL_KINDS }>(&board, Color::Black), BLACK_COVERS ); - let midgame: i32 = (0..COUNTS) - .map(|i| mg_value(TRIAL[i]) * (WHITE_COVERS[i] - BLACK_COVERS[i])) - .sum(); - let endgame: i32 = (0..COUNTS) - .map(|i| eg_value(TRIAL[i]) * (WHITE_COVERS[i] - BLACK_COVERS[i])) - .sum(); + let expected = weigh(&MOBILITY, WHITE_COVERS, BLACK_COVERS); assert_ne!( - midgame, endgame, - "the two halves would not tell a swap apart" + expected, 0, + "the live weights level this position, so the fold's order would not show: \ + pick another position" ); - let packed = super::fold_with::<{ ALL_KINDS }>(&board, &TRIAL); - assert_ne!(midgame, 0, "black less white would answer the same here"); - assert_eq!((mg_value(packed), eg_value(packed)), (midgame, endgame)); + assert_eq!(fold(&board), expected); } /// What the evaluation is allowed to leave out, which is the whole of the @@ -361,7 +342,11 @@ mod tests { ); } } - let whole = super::fold_with::<{ ALL_KINDS }>(&board, &MOBILITY); + let whole = weigh( + &MOBILITY, + counts_of::<{ ALL_KINDS }>(&board, Color::White), + counts_of::<{ ALL_KINDS }>(&board, Color::Black), + ); assert_ne!( whole, 0, "mobility is level here, so this position says nothing about the fold" diff --git a/arche-core/src/eval/mod.rs b/arche-core/src/eval/mod.rs index cac430f4..65c5c37e 100644 --- a/arche-core/src/eval/mod.rs +++ b/arche-core/src/eval/mod.rs @@ -26,8 +26,6 @@ use crate::magic::MAGIC; use crate::misc::{Color, Piece, Score}; use crate::psqt::{PieceSquareTables, eg_value, mg_value}; -static PIECE_SQUARE_TABLES: PieceSquareTables = PieceSquareTables::TABLES; - /// What each piece leaves on the board, in `Piece` order, on the scale the /// two halves of a tapered score are interpolated on: a queen four, a rook /// two and a minor one, so the opening's pieces come to `TOTAL_PHASE`. Pawns @@ -70,14 +68,6 @@ const fn rows() -> [Row; 768] { key: 0, }; let mut out = [empty; 768]; - let pieces = [ - Piece::Pawn, - Piece::Knight, - Piece::Bishop, - Piece::Rook, - Piece::Queen, - Piece::King, - ]; let mut table = 0; while table < 12 { let color = if table < 6 { @@ -85,7 +75,7 @@ const fn rows() -> [Row; 768] { } else { Color::Black }; - let piece = pieces[table % 6]; + let piece = Piece::PIECES[table % 6]; let mut square = 0; while square < 64 { let row = &mut out[table * 64 + square]; @@ -128,7 +118,8 @@ pub(crate) fn row(index: u8, piece: Piece, color: Color) -> &'static Row { &ROWS[piece.table_index(color) * 64 + (index & 63) as usize] } -/// The material weight of one piece, for the board's own seeding walk. +/// The material weight of one piece, which the board's seeding walk, the +/// delta margin in quiescence and the tuner read. pub(crate) fn material(piece: Piece) -> u32 { MATERIAL[piece as usize] } @@ -450,23 +441,12 @@ impl Accumulator { /// and the check would still pass. pub(crate) fn recomputed(board: &Board) -> Self { let mut recomputed = Self::EMPTY; - let mut occupied = board.occupied(); - while occupied != 0 { - let index = occupied.trailing_zeros() as u8; - occupied &= occupied - 1; - if let Some((piece, color)) = board.get_piece_and_color_index(index) { - let psqt = PIECE_SQUARE_TABLES.get_value(index as usize, piece, color); - match color { - Color::White => recomputed.psqt += psqt, - Color::Black => recomputed.psqt -= psqt, - } - let value = MATERIAL[piece as usize] as i32; - match color { - Color::White => recomputed.material += value, - Color::Black => recomputed.material -= value, - } - recomputed.phase += PHASE_WEIGHTS[piece as usize]; - } + for (index, piece, color) in pieces_of(board) { + let sign = color.sign(); + let psqt = PieceSquareTables::TABLES.get_value(index as usize, piece, color); + recomputed.psqt += sign * psqt; + recomputed.material += sign * MATERIAL[piece as usize] as i32; + recomputed.phase += PHASE_WEIGHTS[piece as usize]; } recomputed.machine = factors::Machine::of(pieces_of(board)); recomputed @@ -530,11 +510,25 @@ fn pieces_of(board: &Board) -> impl Iterator + '_ { }) } +/// Every position of the core, bench, tactical and strategic suites, for a +/// test that wants the whole of what the tree holds. +#[cfg(test)] +pub(crate) fn suite_fens() -> Vec { + let mut fens: Vec = crate::board::fens::CORE + .iter() + .map(|f| f.to_string()) + .collect(); + fens.extend(crate::bench::positions().into_iter().map(|p| p.fen)); + fens.extend(crate::tactics::positions().into_iter().map(|p| p.fen)); + fens.extend(crate::strategy::positions().into_iter().map(|p| p.fen)); + fens +} + #[cfg(test)] mod evaluate { use super::{ Board, Caches, Memo, PawnCache, ShelterCache, TERMS, TOTAL_PHASE, eval, eval_cached, - factors, king_attack, mobility, pawn_structure, pieces_of, shelter, + factors, king_attack, mobility, pawn_structure, pieces_of, shelter, suite_fens, weigh, }; use crate::board::fens; use crate::misc::{Color, File, coordinate_to_index}; @@ -542,6 +536,38 @@ mod evaluate { use pretty_assertions::assert_eq; use std::collections::{HashMap, HashSet}; + /// White's counts less black's, count by count, each half of the pair + /// summed on its own. Every term's fold is this over its own counts and + /// weights, and the terms' tests hold each fold to it, so the arithmetic + /// is pinned here once. The weights differ from each other at both ends + /// of the taper, so a count read into the wrong slot lands on a + /// different number: the four differences between the halves are 9, -20, + /// 8 and -12. + #[test] + fn weigh_reads_white_less_black_count_by_count() { + const TRIAL: [i32; 4] = [pack(11, 2), pack(-7, 13), pack(3, -5), pack(29, 41)]; + const WHITE: [i32; 4] = [3, 2, 7, 10]; + const BLACK: [i32; 4] = [0, 7, 9, 9]; + let midgame: i32 = (0..4) + .map(|i| mg_value(TRIAL[i]) * (WHITE[i] - BLACK[i])) + .sum(); + let endgame: i32 = (0..4) + .map(|i| eg_value(TRIAL[i]) * (WHITE[i] - BLACK[i])) + .sum(); + assert_ne!( + midgame, endgame, + "the two halves would not tell a swap apart" + ); + assert_ne!(midgame, 0, "black less white would answer the same here"); + let packed = weigh(&TRIAL, WHITE, BLACK); + assert_eq!((mg_value(packed), eg_value(packed)), (midgame, endgame)); + assert_ne!( + weigh(&TRIAL, [2, 3, 7, 10], BLACK), + packed, + "a permutation shows" + ); + } + /// Both the accumulator and its recompute read `PHASE_WEIGHTS`, so the /// state check holds them to each other and neither to what the weights /// should be. This says what they add up to. @@ -1062,10 +1088,7 @@ mod evaluate { /// own counts, and under the skips. #[test] fn the_shared_walk_counts_what_each_term_counts_alone() { - let mut fens: Vec = fens::CORE.iter().map(|f| f.to_string()).collect(); - fens.extend(crate::bench::positions().into_iter().map(|p| p.fen)); - fens.extend(crate::tactics::positions().into_iter().map(|p| p.fen)); - fens.extend(crate::strategy::positions().into_iter().map(|p| p.fen)); + let fens = suite_fens(); assert!(fens.len() > 1_500, "{} positions", fens.len()); for fen in fens { let board = Board::from_fen(&fen).unwrap_or_else(|e| panic!("{}: {}", fen, e)); diff --git a/arche-core/src/eval/pawn_structure.rs b/arche-core/src/eval/pawn_structure.rs index 5a6592a3..10db350a 100644 --- a/arche-core/src/eval/pawn_structure.rs +++ b/arche-core/src/eval/pawn_structure.rs @@ -143,8 +143,8 @@ pub(super) const fn files_of(pawns: u64) -> u8 { /// /// One pawn can be passed, isolated and doubled at once, but no count exceeds /// eight and the six passed counts share the eight pawns, so this term stays -/// far inside the 32,767 a half has to stay inside. `bounds_hold` charges -/// eight of every count, which is the looser screen. +/// far inside the 32,767 a half has to stay inside. `tune.py::bounds_hold` +/// charges eight of every count, which is the looser screen. static PAWN_STRUCTURE: [i32; COUNTS] = [ pack(-20, 20), pack(-27, 18), @@ -231,17 +231,8 @@ pub(crate) fn counts(board: &Board, color: Color, into: &mut [i32]) { /// the piece square pair is on. #[inline] pub(crate) fn fold(board: &Board) -> i32 { - fold_with(board, &PAWN_STRUCTURE) -} - -/// The same fold against weights named by the caller. The tests supply -/// weights of their own because what they pin is the fold rather than the -/// fit: a permuted [`PAWN_STRUCTURE`] would be a different evaluation and not -/// a wrong one. -#[inline] -fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 { weigh( - weights, + &PAWN_STRUCTURE, counts_of(board, Color::White), counts_of(board, Color::Black), ) @@ -265,7 +256,6 @@ pub(super) const CACHE_BITS: usize = 12; #[cfg(test)] mod tests { use super::{Board, COUNTS, Color, MASKS, ahead_of, counts_of, files_of, pawn_files, spread}; - use crate::psqt::{eg_value, mg_value, pack}; use pretty_assertions::assert_eq; /// The counts by hand, because nothing else pins them: `eval` and the @@ -612,42 +602,19 @@ mod tests { const WHITE_STRUCTURE: [i32; COUNTS] = [0, 1, 0, 1, 0, 1, 2, 3]; const BLACK_STRUCTURE: [i32; COUNTS] = [1, 0, 1, 0, 2, 0, 0, 0]; - /// Eight weights that differ from each other at both ends of the taper, - /// so that a pair read into the wrong count's slot lands on a different - /// number. The eight differences between the halves are 38, 20, -24, -30, - /// 36, 25, -26 and -28, which differ from each other too, so a - /// permutation of either array shows. - const TRIAL: [i32; COUNTS] = [ - pack(3, 41), - pack(-7, 13), - pack(29, 5), - pack(11, -19), - pack(17, 53), - pack(-23, 2), - pack(-5, -31), - pack(37, 9), - ]; - - /// What the fold does with weights that are not the shipped ones, which - /// are the fit's and will move again: white's count less black's, count - /// by count, each half of the pair summed on its own. + /// The fold is `weigh` (pinned in `eval/mod.rs`) over the two sides' + /// counts, white less black and against the live weights. #[test] - fn the_pawn_structure_fold_reads_white_less_black_count_by_count() { + fn the_pawn_structure_fold_weighs_white_less_black() { let board = Board::from_fen(STRUCTURED).unwrap(); assert_eq!(counts_of(&board, Color::White), WHITE_STRUCTURE); assert_eq!(counts_of(&board, Color::Black), BLACK_STRUCTURE); - let midgame: i32 = (0..COUNTS) - .map(|i| mg_value(TRIAL[i]) * (WHITE_STRUCTURE[i] - BLACK_STRUCTURE[i])) - .sum(); - let endgame: i32 = (0..COUNTS) - .map(|i| eg_value(TRIAL[i]) * (WHITE_STRUCTURE[i] - BLACK_STRUCTURE[i])) - .sum(); + let expected = super::weigh(&super::PAWN_STRUCTURE, WHITE_STRUCTURE, BLACK_STRUCTURE); assert_ne!( - midgame, endgame, - "the two halves would not tell a swap apart" + expected, 0, + "the live weights level this position, so the fold's order would not show: \ + pick another position" ); - let packed = super::fold_with(&board, &TRIAL); - assert_ne!(midgame, 0, "black less white would answer the same here"); - assert_eq!((mg_value(packed), eg_value(packed)), (midgame, endgame)); + assert_eq!(super::fold(&board), expected); } } diff --git a/arche-core/src/eval/shelter.rs b/arche-core/src/eval/shelter.rs index 71097b90..be3bd686 100644 --- a/arche-core/src/eval/shelter.rs +++ b/arche-core/src/eval/shelter.rs @@ -209,17 +209,8 @@ pub(crate) fn counts(board: &Board, color: Color, into: &mut [i32]) { /// the piece square pair is on. #[inline] pub(crate) fn fold(board: &Board) -> i32 { - fold_with(board, &SHELTER) -} - -/// The same fold against weights named by the caller. The tests supply -/// weights of their own because what they pin is the fold rather than the -/// fit: a permuted [`SHELTER`] would be a different evaluation and not a -/// wrong one. -#[inline] -fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 { weigh( - weights, + &SHELTER, counts_of(board, Color::White), counts_of(board, Color::Black), ) @@ -239,7 +230,6 @@ pub(super) const CACHE_BITS: usize = 13; #[cfg(test)] mod tests { use super::{Board, COUNTS, Color, MASKS, RANKS_AHEAD, counts_of, files_of, key, king_files}; - use crate::psqt::{eg_value, mg_value, pack}; use pretty_assertions::assert_eq; /// A piece that is neither a pawn nor a king leaves the key alone, and @@ -485,41 +475,19 @@ mod tests { const WHITE_SHELTERS: [i32; COUNTS] = [2, 1, 0, 0, 1, 2, 3]; const BLACK_SHELTERS: [i32; COUNTS] = [0, 0, 2, 1, 0, 0, 0]; - /// Seven weights that differ from each other at both ends of the taper, so - /// that a pair read into the wrong count's slot lands on a different - /// number. The seven differences between the halves are 9, -20, 8, -12, - /// 20, -29 and -14, which differ from each other too, so a permutation of - /// either array shows. - const TRIAL: [i32; COUNTS] = [ - pack(11, 2), - pack(-7, 13), - pack(3, -5), - pack(29, 41), - pack(17, -3), - pack(-23, 6), - pack(5, 19), - ]; - - /// What the fold does with weights that are not the shipped ones, which - /// are the fit's and will move again: white's count less black's, count - /// by count, each half of the pair summed on its own. + /// The fold is `weigh` (pinned in `eval/mod.rs`) over the two sides' + /// counts, white less black and against the live weights. #[test] - fn the_shelter_fold_reads_white_less_black_count_by_count() { + fn the_shelter_fold_weighs_white_less_black() { let board = Board::from_fen(SHELTERED).unwrap(); assert_eq!(counts_of(&board, Color::White), WHITE_SHELTERS); assert_eq!(counts_of(&board, Color::Black), BLACK_SHELTERS); - let midgame: i32 = (0..COUNTS) - .map(|i| mg_value(TRIAL[i]) * (WHITE_SHELTERS[i] - BLACK_SHELTERS[i])) - .sum(); - let endgame: i32 = (0..COUNTS) - .map(|i| eg_value(TRIAL[i]) * (WHITE_SHELTERS[i] - BLACK_SHELTERS[i])) - .sum(); + let expected = super::weigh(&super::SHELTER, WHITE_SHELTERS, BLACK_SHELTERS); assert_ne!( - midgame, endgame, - "the two halves would not tell a swap apart" + expected, 0, + "the live weights level this position, so the fold's order would not show: \ + pick another position" ); - let packed = super::fold_with(&board, &TRIAL); - assert_ne!(midgame, 0, "black less white would answer the same here"); - assert_eq!((mg_value(packed), eg_value(packed)), (midgame, endgame)); + assert_eq!(super::fold(&board), expected); } } diff --git a/arche-core/src/misc.rs b/arche-core/src/misc.rs index b7bde2e5..ef585a68 100644 --- a/arche-core/src/misc.rs +++ b/arche-core/src/misc.rs @@ -364,6 +364,15 @@ pub enum Color { } impl Color { + /// Plus one for white and minus one for black: the sign a white relative + /// sum applies to this side's share. + pub(crate) const fn sign(self) -> i32 { + match self { + Color::White => 1, + Color::Black => -1, + } + } + pub fn from_char(c: char) -> Option { match c { 'b' | 'B' => Some(Color::Black), From edaed0c39965135de7e1debcb3c2338b16701a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:44:56 +0000 Subject: [PATCH 04/10] refactor(uci): Remove duplicated branches and dead match arms The info line was built by two format strings that differed only in the score word. The score word is built first and one format string takes it. The spin reader scanned the line twice, once for the word and once for its number; it reads the word once and parses it, with the two refusals as they were. The panic hook wrote one of two lines depending on whether the panic had a location; the " at " suffix is built once and one line carries it. The epd reader's two early returns with the same refusal are one condition. The match arms for an unreadable `off` or `kinds` word were dead: `Params::value` never reads Unreadable, since any word is a word, and the arm is merged with the bare one. The single-line `use arche_core::X` blocks in uci.rs and instruments.rs are one braced import each. Nothing printed changes. The info line is pinned by a_report_is_said_as_an_info_line, the spin refusals by a_hash_value_that_cannot_be_read_leaves_the_table_alone and a_move_overhead_that_cannot_be_read_leaves_the_one_in_force, the panic line by a_panic_is_said_where_the_interface_can_read_it, and the epd and off refusals by the instruments tests and the session tests against the binary. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- src/instruments.rs | 32 +++++++++++--------------------- src/session.rs | 12 ++++-------- src/uci.rs | 43 +++++++++++++++++-------------------------- 3 files changed, 32 insertions(+), 55 deletions(-) diff --git a/src/instruments.rs b/src/instruments.rs index 84729ee6..e0b0a28a 100644 --- a/src/instruments.rs +++ b/src/instruments.rs @@ -11,17 +11,10 @@ use crate::command::{Command, Keyword}; use crate::params::{NO_VALUE, Param, Params}; use crate::uci; -use arche_core::Ablation; -use arche_core::Board; -use arche_core::SearchConfig; -use arche_core::bench; -use arche_core::census; -use arche_core::effort; -use arche_core::forced; -use arche_core::recorder; -use arche_core::reduction; -use arche_core::residual; -use arche_core::tune; +use arche_core::{ + Ablation, Board, SearchConfig, bench, census, effort, forced, recorder, reduction, residual, + tune, +}; use std::fmt; /// A line that has been read and not yet run. Running it does the work and @@ -334,12 +327,10 @@ fn read_epd(path: &str) -> Result, String> { let refused = || format!("epd: {path}"); let text = std::fs::read_to_string(path).map_err(|_| refused())?; let positions = bench::parse_epd(&text); - if positions.is_empty() { - return Err(refused()); - } - if positions - .iter() - .any(|position| Board::from_fen(&position.fen).is_err()) + if positions.is_empty() + || positions + .iter() + .any(|position| Board::from_fen(&position.fen).is_err()) { return Err(refused()); } @@ -464,8 +455,8 @@ pub fn effort_settings(params: &Params) -> Result { let off = match params.value("off") { Param::Absent => None, Param::Read(word) => Some(ablation(word)?), - Param::Bare => return Err(no_such_switch(NO_VALUE)), - Param::Unreadable(word) => return Err(no_such_switch(word)), + // `value` never reads Unreadable, since any word is a word + Param::Bare | Param::Unreadable(_) => return Err(no_such_switch(NO_VALUE)), }; let budget = params.parse::("budget").or_refuse("budget")?; let (epd, positions) = suite(params)?; @@ -533,8 +524,7 @@ pub fn forced_settings(params: &Params) -> Result { let kinds = match params.value("kinds") { Param::Absent => forced::Kinds::ALL, Param::Read(word) => kinds(word)?, - Param::Bare => return Err(no_such_kind(NO_VALUE)), - Param::Unreadable(word) => return Err(no_such_kind(word)), + Param::Bare | Param::Unreadable(_) => return Err(no_such_kind(NO_VALUE)), }; let from = params.parse::("from").or_refuse("from")?.unwrap_or(0); let (epd, positions) = suite(params)?; diff --git a/src/session.rs b/src/session.rs index de08117b..538934a4 100644 --- a/src/session.rs +++ b/src/session.rs @@ -326,14 +326,10 @@ pub(crate) fn report_panics_to(out: SharedWriter) Err(std::sync::TryLockError::WouldBlock) => None, }; if let Some(mut out) = held { - match panic.location() { - Some(at) => { - let _ = writeln!(out, "info string panicked at {}: {}", at, message); - } - None => { - let _ = writeln!(out, "info string panicked: {}", message); - } - } + let at = panic + .location() + .map_or(String::new(), |at| format!(" at {}", at)); + let _ = writeln!(out, "info string panicked{}: {}", at, message); } previous(panic); })); diff --git a/src/uci.rs b/src/uci.rs index 4083381b..1b1953e4 100644 --- a/src/uci.rs +++ b/src/uci.rs @@ -5,16 +5,10 @@ use crate::command::{Command, Keyword}; use crate::params::{Param, Params}; use crate::session::{self, SessionControl, SharedWriter, first_word, report_panics_to}; use crate::time_control::{DEFAULT_MOVE_OVERHEAD_MS, TimeControl}; -use arche_core::Color; -use arche_core::Engine; -use arche_core::Limits; -use arche_core::ScoreBound; -use arche_core::SearchConfig; -use arche_core::SearchOutcome; -use arche_core::SearchParameters; -use arche_core::SearchResult; -use arche_core::bench; -use arche_core::{Play, Score}; +use arche_core::{ + Color, Engine, Limits, Play, Score, ScoreBound, SearchConfig, SearchOutcome, SearchParameters, + SearchResult, bench, +}; use std::fmt; use std::io::{BufRead, Stdout, Write}; use std::ops::RangeInclusive; @@ -154,13 +148,12 @@ struct Held { /// missing one: either way there is nothing to apply. The range is one /// argument so that its two ends cannot be swapped. fn read_spin(name: &str, range: RangeInclusive, params: &Params) -> Result { - let (word, value) = match (params.value("value"), params.parse::("value")) { - (Param::Read(word), Param::Read(value)) => (word, value), - (_, Param::Unreadable(word)) => { - return Err(format!("unrecognised {} value: {}", name, word)); - } - _ => return Err(format!("{} was sent without a value", name)), + let Param::Read(word) = params.value("value") else { + return Err(format!("{} was sent without a value", name)); }; + let value = word + .parse::() + .map_err(|_| format!("unrecognised {} value: {}", name, word))?; let held = value.clamp(*range.start(), *range.end()); let said = (held != value).then(|| { format!( @@ -773,16 +766,14 @@ fn format_info( ScoreBound::Lower => " lowerbound", ScoreBound::Upper => " upperbound", }; - match result.checkmate_in() { - Some(mate_in) => format!( - "info depth {} seldepth {} nodes {} time {} nps {} score mate {}{} pv {}", - depth, result.selective_depth, result.nodes, millis, nps, mate_in, qualifier, pv - ), - None => format!( - "info depth {} seldepth {} nodes {} time {} nps {} score cp {}{} pv {}", - depth, result.selective_depth, result.nodes, millis, nps, result.score, qualifier, pv - ), - } + let score = match result.checkmate_in() { + Some(mate_in) => format!("mate {}", mate_in), + None => format!("cp {}", result.score), + }; + format!( + "info depth {} seldepth {} nodes {} time {} nps {} score {}{} pv {}", + depth, result.selective_depth, result.nodes, millis, nps, score, qualifier, pv + ) } #[cfg(test)] From c0a8167c3dcb689baf3fb0d50721f08f67ad88f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:52:34 +0000 Subject: [PATCH 05/10] test(uci): Table the reader tests and share the session fixtures The instruments tests had one depth, rate and cap test and one unreadable setting test per sampling reader, with the same rows under four names, and one suite test per reader that takes a file, with the same body under four names. Each family is now one test over a table of the readers. The sampling table maps every reader to its (depth, every, cap) or its refusal, so the two shared tests read it; the suite table maps every reader to the depth, file and positions it read, and includes the forced reader, which had no suite test before. The rows that are one reader's own stay as their own tests: the residuals policy and its refusal, the effort switch and budget and their refusals, and the terms refusals, which differ because terms takes no depth. Every assertion message names the instrument, so a failure still says which reader broke. The rows are the union of what each test had, so each reader is asked everything any of them was. In the uci tests, the small table engine and the recording session are built in one place each, a_real_engine_keeps_the_same_promises uses the uci() fixture it was repeating, and Driven::wait_for_times replaces two copies of the loop that waited for a second bestmove. Those two waits now have the thirty second deadline every other wait has rather than ten; the deadline bounds a hang and asserts nothing. In the session tests, Session::quit says quit and checks the exit, which nine tests wrote out, move_of reads the move off a bestmove line, which four tests wrote out, and the swap test uses line_opens_with in place of its own copy. No test is dropped and no property changes hands. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- src/instruments.rs | 286 ++++++++++++++++++------------------------- src/uci.rs | 64 +++++----- tests/uci_session.rs | 66 +++++----- 3 files changed, 179 insertions(+), 237 deletions(-) diff --git a/src/instruments.rs b/src/instruments.rs index e0b0a28a..d8cc23dc 100644 --- a/src/instruments.rs +++ b/src/instruments.rs @@ -744,159 +744,147 @@ mod tests { assert!(!printed.ends_with("\n\n"), "{printed}"); } - /// The settings alone, not a run: the run costs minutes. - #[test] - fn a_residuals_argument_reads_its_depth_rate_cap_and_policy() { - const CAP: usize = recorder::DEFAULT_CAP; - let read = |line: &str| { - let settings = residual_settings(&Params::of(line)).expect(line); - ( - settings.depth, - settings.every, - settings.cap, - settings.config.taint_word().to_string(), - ) - }; - assert_eq!( - read("residuals"), - (bench::DEPTH, 1000, CAP, "rule50".to_string()) - ); - assert_eq!(read("residuals 4"), (4, 1000, CAP, "rule50".to_string())); - assert_eq!( - read("residuals 4 every 50"), - (4, 50, CAP, "rule50".to_string()) - ); - // a keyword where the depth would be means the depth was left out - assert_eq!( - read("residuals every 50 taint trust"), - (bench::DEPTH, 50, CAP, "trust".to_string()) - ); - assert_eq!( - read("residuals cap 500"), - (bench::DEPTH, 1000, 500, "rule50".to_string()) - ); - // zero records every event, up to the cap - assert_eq!( - read("residuals 2 every 0"), - (2, 0, CAP, "rule50".to_string()) - ); - assert_eq!( - read("residuals 4 every 50 cap 200000"), - (4, 50, 200_000, "rule50".to_string()) - ); - } - - #[test] - fn a_residuals_argument_reads_the_suite_it_was_given() { - let bench = residual_settings(&Params::of("residuals")).expect("residuals"); - assert_eq!(bench.epd, None); - assert_eq!(bench.positions, bench::positions()); - - let path = SUITE; - let line = format!("residuals 4 epd {path}"); - let named = residual_settings(&Params::of(&line)).expect(&line); - assert_eq!(named.depth, 4); - assert_eq!(named.epd.as_deref(), Some(path)); - // a file other than the bench's, so handing back the bench's own - // positions is caught - assert_eq!(named.positions, from_file(path)); - assert_ne!(named.positions, bench::positions()); + /// A sampling reader by name, as the depth, rate and cap it read off a + /// line or its refusal. + type SamplingReader = (&'static str, fn(&str) -> Result<(u8, u32, usize), String>); + + /// The four share `sampling`, so what it reads is tested through this + /// table and each reader's own settings beside it. + fn sampling_readers() -> [SamplingReader; 4] { + [ + ("residuals", |line| { + residual_settings(&Params::of(line)).map(|s| (s.depth, s.every, s.cap)) + }), + ("cutoffs", |line| { + cutoff_settings(&Params::of(line)).map(|s| (s.depth, s.every, s.cap)) + }), + ("reductions", |line| { + reduction_settings(&Params::of(line)).map(|s| (s.depth, s.every, s.cap)) + }), + ("effort", |line| { + effort_settings(&Params::of(line)).map(|s| (s.depth, s.every, s.cap)) + }), + ] } + /// The settings alone, not a run: the run costs minutes. #[test] - fn an_unreadable_residuals_setting_is_named_rather_than_run() { - for (line, what) in [ - ("residuals abc", "depth: abc"), - ("residuals 300", "depth: 300"), - ("residuals 4 every lots", "every: lots"), - ("residuals 4 cap lots", "cap: lots"), - ("residuals 4 taint maybe", "taint: maybe"), - ] { + fn every_sampling_instrument_reads_its_depth_rate_and_cap() { + const CAP: usize = recorder::DEFAULT_CAP; + for (name, read) in sampling_readers() { + let settings = |rest: &str| { + let line = format!("{name} {rest}"); + let line = line.trim_end(); + read(line).expect(line) + }; + assert_eq!(settings(""), (bench::DEPTH, 1000, CAP), "{name}"); + assert_eq!(settings("4"), (4, 1000, CAP), "{name}"); + assert_eq!(settings("4 every 50"), (4, 50, CAP), "{name}"); + // a keyword where the depth would be means the depth was left out assert_eq!( - residual_settings(&Params::of(line)).err(), - Some(what.to_string()), - "{line}" + settings("every 50 cap 500"), + (bench::DEPTH, 50, 500), + "{name}" + ); + assert_eq!(settings("cap 500"), (bench::DEPTH, 1000, 500), "{name}"); + // zero records every event, up to the cap + assert_eq!(settings("2 every 0"), (2, 0, CAP), "{name}"); + assert_eq!( + settings("4 every 50 cap 200000"), + (4, 50, 200_000), + "{name}" ); } } #[test] - fn a_cutoffs_argument_reads_its_depth_rate_and_cap() { - let read = |line: &str| { - let settings = cutoff_settings(&Params::of(line)).expect(line); - (settings.depth, settings.every, settings.cap) - }; - const CAP: usize = recorder::DEFAULT_CAP; - assert_eq!(read("cutoffs"), (bench::DEPTH, 1000, CAP)); - assert_eq!(read("cutoffs 4"), (4, 1000, CAP)); - assert_eq!(read("cutoffs 4 every 50"), (4, 50, CAP)); - assert_eq!(read("cutoffs every 50 cap 500"), (bench::DEPTH, 50, 500)); - assert_eq!(read("cutoffs 2 every 0"), (2, 0, CAP)); - } - - #[test] - fn an_unreadable_cutoffs_setting_is_named_rather_than_run() { - for (line, what) in [ - ("cutoffs abc", "depth: abc"), - ("cutoffs 300", "depth: 300"), - ("cutoffs 4 every lots", "every: lots"), - ("cutoffs 4 cap lots", "cap: lots"), - ] { - assert_eq!( - cutoff_settings(&Params::of(line)).err(), - Some(what.to_string()), - "{line}" - ); + fn an_unreadable_sampling_setting_is_named_rather_than_run() { + for (name, read) in sampling_readers() { + for (rest, what) in [ + ("abc", "depth: abc"), + ("300", "depth: 300"), + ("4 every lots", "every: lots"), + ("4 cap lots", "cap: lots"), + ] { + let line = format!("{name} {rest}"); + assert_eq!(read(&line).err(), Some(what.to_string()), "{line}"); + } } } + /// The policy is the residuals argument's own, the bench's when absent. #[test] - fn a_reductions_argument_reads_its_depth_rate_and_cap() { - let read = |line: &str| { - let settings = reduction_settings(&Params::of(line)).expect(line); - (settings.depth, settings.every, settings.cap) + fn a_residuals_argument_reads_its_taint_policy() { + let policy = |line: &str| { + let settings = residual_settings(&Params::of(line)).expect(line); + settings.config.taint_word().to_string() }; - const CAP: usize = recorder::DEFAULT_CAP; - assert_eq!(read("reductions"), (bench::DEPTH, 1000, CAP)); - assert_eq!(read("reductions 4"), (4, 1000, CAP)); - assert_eq!(read("reductions 4 every 50"), (4, 50, CAP)); - assert_eq!(read("reductions every 50 cap 500"), (bench::DEPTH, 50, 500)); - assert_eq!(read("reductions 2 every 0"), (2, 0, CAP)); + assert_eq!(policy("residuals"), "rule50"); + assert_eq!(policy("residuals every 50 taint trust"), "trust"); + assert_eq!( + residual_settings(&Params::of("residuals 4 taint maybe")).err(), + Some("taint: maybe".to_string()) + ); } - #[test] - fn a_reductions_argument_reads_the_suite_it_was_given() { - let bench = reduction_settings(&Params::of("reductions")).expect("reductions"); - assert_eq!(bench.epd, None); - assert_eq!(bench.positions, bench::positions()); - - let path = SUITE; - let line = format!("reductions 4 epd {path}"); - let named = reduction_settings(&Params::of(&line)).expect(&line); - assert_eq!(named.depth, 4); - assert_eq!(named.epd.as_deref(), Some(path)); - assert_eq!(named.positions, from_file(path)); - assert_ne!(named.positions, bench::positions()); - } + /// What a reader that takes a suite read of one: the depth, where the + /// command takes one, the file named and its positions. + type ReadSuite = (Option, Option, Vec); + + /// A reader that takes a suite, with its command. + type SuiteReader = (&'static Command, fn(&str) -> ReadSuite); #[test] - fn an_unreadable_reductions_setting_is_named_rather_than_run() { - for (line, what) in [ - ("reductions abc", "depth: abc"), - ("reductions 300", "depth: 300"), - ("reductions 4 every lots", "every: lots"), - ("reductions 4 cap lots", "cap: lots"), - ] { - assert_eq!( - reduction_settings(&Params::of(line)).err(), - Some(what.to_string()), - "{line}" - ); + fn every_instrument_that_takes_a_suite_reads_the_one_it_was_given() { + let readers: [SuiteReader; 5] = [ + (&RESIDUALS, |line| { + let s = residual_settings(&Params::of(line)).expect(line); + (Some(s.depth), s.epd, s.positions) + }), + (&REDUCTIONS, |line| { + let s = reduction_settings(&Params::of(line)).expect(line); + (Some(s.depth), s.epd, s.positions) + }), + (&EFFORT, |line| { + let s = effort_settings(&Params::of(line)).expect(line); + (Some(s.depth), s.epd, s.positions) + }), + (&FORCED, |line| { + let s = forced_settings(&Params::of(line)).expect(line); + (Some(s.depth), s.epd, s.positions) + }), + (&TERMS, |line| { + let s = term_settings(&Params::of(line)).expect(line); + (None, s.epd, s.positions) + }), + ]; + for (command, read) in readers { + let name = command.name; + let (_, epd, positions) = read(name); + assert_eq!(epd, None, "{name}"); + assert_eq!(positions, bench::positions(), "{name}"); + + let path = SUITE; + // a depth before the file, where the command takes one, is read + // with it + let depth = command.depth.then_some(4); + let line = match depth { + Some(depth) => format!("{name} {depth} epd {path}"), + None => format!("{name} epd {path}"), + }; + let (read_depth, epd, positions) = read(&line); + assert_eq!(read_depth, depth, "{line}"); + assert_eq!(epd.as_deref(), Some(path), "{line}"); + // a file other than the bench's, so handing back the bench's own + // positions is caught + assert_eq!(positions, from_file(path), "{line}"); + assert_ne!(positions, bench::positions(), "{line}"); } } + /// The switch and the budget are the effort argument's own. #[test] - fn an_effort_argument_reads_its_depth_rate_cap_switch_and_budget() { + fn an_effort_argument_reads_its_switch_and_budget() { let read = |line: &str| { let settings = effort_settings(&Params::of(line)).expect(line); ( @@ -909,8 +897,6 @@ mod tests { }; const CAP: usize = recorder::DEFAULT_CAP; assert_eq!(read("effort"), (bench::DEPTH, 1000, CAP, None, None)); - assert_eq!(read("effort 4"), (4, 1000, CAP, None, None)); - assert_eq!(read("effort 4 every 50"), (4, 50, CAP, None, None)); // a keyword where the depth would be means the depth was left out assert_eq!( read("effort off null_move"), @@ -946,27 +932,9 @@ mod tests { } #[test] - fn an_effort_argument_reads_the_suite_it_was_given() { - let bench = effort_settings(&Params::of("effort")).expect("effort"); - assert_eq!(bench.epd, None); - assert_eq!(bench.positions, bench::positions()); - - let path = SUITE; - let line = format!("effort 4 epd {path}"); - let named = effort_settings(&Params::of(&line)).expect(&line); - assert_eq!(named.depth, 4); - assert_eq!(named.epd.as_deref(), Some(path)); - assert_eq!(named.positions, from_file(path)); - assert_ne!(named.positions, bench::positions()); - } - - #[test] - fn an_unreadable_effort_setting_is_named_rather_than_run() { + fn an_unreadable_effort_switch_or_budget_is_named_rather_than_run() { let switches = SearchConfig::SWITCHES.map(|(name, _)| name).join(", "); for (line, what) in [ - ("effort abc".to_string(), "depth: abc".to_string()), - ("effort 4 every lots".to_string(), "every: lots".to_string()), - ("effort 4 cap lots".to_string(), "cap: lots".to_string()), ( "effort 4 off quiet_futilty".to_string(), format!("off: quiet_futilty (a switch is one of {switches})"), @@ -1019,20 +987,6 @@ mod tests { ); } - #[test] - fn a_terms_argument_reads_the_suite_it_was_given() { - let bench = term_settings(&Params::of("terms")).expect("terms"); - assert_eq!(bench.epd, None); - assert_eq!(bench.positions, bench::positions()); - - let path = SUITE; - let line = format!("terms epd {path}"); - let named = term_settings(&Params::of(&line)).expect(&line); - assert_eq!(named.epd.as_deref(), Some(path)); - assert_eq!(named.positions, from_file(path)); - assert_ne!(named.positions, bench::positions()); - } - /// This argument has no depth, so a number where one would stand is a /// word it does not know. #[test] diff --git a/src/uci.rs b/src/uci.rs index 1b1953e4..fc1dbdac 100644 --- a/src/uci.rs +++ b/src/uci.rs @@ -787,12 +787,14 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; - /// A table small enough to afford one per case, speaking into a buffer. + /// A table small enough to afford one per case. + fn small_engine() -> AlphaBeta { + AlphaBeta::with_table_bytes(Board::new(), 8 * 1024) + } + + /// A small engine speaking into a buffer. fn uci() -> UCI> { - UCI::with_output( - AlphaBeta::with_table_bytes(Board::new(), 8 * 1024), - Vec::new(), - ) + UCI::with_output(small_engine(), Vec::new()) } /// An engine that searches nothing and keeps what it was asked for, so a @@ -848,6 +850,11 @@ mod tests { } } + /// A recorder with white to move, speaking into a buffer. + fn recording() -> UCI> { + UCI::with_output(Recorder::to_move(Color::White), Vec::new()) + } + /// What a `go` line asks of the engine behind it. fn asked_of_engine(line: &str) -> SearchParameters { asked_of_engine_as(line, Color::White) @@ -1402,7 +1409,7 @@ go depth 3 "setoption name Clear Hash", "setoption name Clear Hash value", ] { - let mut uci = UCI::with_output(Recorder::to_move(Color::White), Vec::new()); + let mut uci = recording(); uci.run(Cursor::new(format!("{}\n", line))); assert_eq!(uci.engine.cleared, 1, "{}", line); assert_eq!(uci.out.read_back(), "", "{} was answered", line); @@ -1423,7 +1430,7 @@ go depth 3 "info string Move Overhead 99999 is outside 0 to 5000, using 5000\n" ); - let mut uci = UCI::with_output(Recorder::to_move(Color::White), Vec::new()); + let mut uci = recording(); uci.run(Cursor::new("setoption name clear hash\n")); assert_eq!(uci.engine.cleared, 1); } @@ -2169,7 +2176,7 @@ go depth 3 } fn run_session(lines: &[String]) -> String { - let mut uci = UCI::with_output(Recorder::to_move(Color::White), Vec::new()); + let mut uci = recording(); uci.run(Cursor::new(lines.join("\n") + "\n")); uci.out.read_back() } @@ -2228,10 +2235,7 @@ go depth 3 _ => line, }) .collect(); - let mut uci = UCI::with_output( - AlphaBeta::with_table_bytes(Board::new(), 8 * 1024), - Vec::new(), - ); + let mut uci = uci(); uci.run(Cursor::new(lines.join("\n") + "\n")); let spoken = said(&uci); @@ -2272,7 +2276,7 @@ go depth 3 } fn searching() -> Self { - Self::of(AlphaBeta::with_table_bytes(Board::new(), 8 * 1024)) + Self::of(small_engine()) } /// An engine that answers at once, so a held answer can be tested @@ -2293,16 +2297,24 @@ go depth 3 /// was said instead. The deadline bounds a real search on any /// machine. fn wait_for(&self, what: &str) -> String { + self.wait_for_times(what, 1) + } + + /// Everything said once `what` has been said `times` over. + fn wait_for_times(&self, what: &str, times: usize) -> String { let deadline = Instant::now() + Duration::from_secs(30); loop { let said = self.said(); - if said.contains(what) { + let count = said.matches(what).count(); + if count >= times { return said; } assert!( Instant::now() < deadline, - "nothing said {:?} in thirty seconds, only: {}", + "{:?} was said {} times, not {}, in thirty seconds: {}", what, + count, + times, said ); thread::sleep(Duration::from_millis(1)); @@ -2399,15 +2411,8 @@ go depth 3 for line in ["go infinite", "stop", "go infinite", "stop"] { driven.type_line(line); } - let deadline = Instant::now() + Duration::from_secs(10); - while driven.said().matches("bestmove").count() < 2 { - assert!( - Instant::now() < deadline, - "the second search was never stopped: {}", - driven.said() - ); - thread::sleep(Duration::from_millis(1)); - } + // both answer, or the second search was never stopped + driven.wait_for_times("bestmove", 2); driven.finish(); } @@ -2422,15 +2427,8 @@ go depth 3 for line in ["stop", "go infinite", "stop", "go infinite", "stop"] { driven.type_line(line); } - let deadline = Instant::now() + Duration::from_secs(10); - while driven.said().matches("bestmove").count() < 2 { - assert!( - Instant::now() < deadline, - "a search was never stopped: {}", - driven.said() - ); - thread::sleep(Duration::from_millis(1)); - } + // both answer, or a search was never stopped + driven.wait_for_times("bestmove", 2); driven.finish(); } diff --git a/tests/uci_session.rs b/tests/uci_session.rs index 771e8638..87e3fabb 100644 --- a/tests/uci_session.rs +++ b/tests/uci_session.rs @@ -99,6 +99,12 @@ impl Session { thread::sleep(Duration::from_millis(10)); } } + + /// Says quit and checks the engine exits cleanly on it. + fn quit(mut self) { + self.say("quit"); + assert!(self.finished().success()); + } } impl Drop for Session { @@ -108,6 +114,13 @@ impl Drop for Session { } } +/// The move a bestmove line names. +fn move_of(answer: &str) -> &str { + answer + .strip_prefix("bestmove ") + .unwrap_or_else(|| panic!("not a bestmove: {}", answer)) +} + fn looks_like_a_move(line: &str) -> bool { let Some(m) = line.strip_prefix("bestmove ") else { return false; @@ -132,8 +145,7 @@ fn the_handshake_answers_the_way_the_smoke_test_expects() { s.say("go movetime 200"); let best = s.wait_for(|l| l.starts_with("bestmove")); assert!(looks_like_a_move(&best), "not a move: {}", best); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] @@ -145,8 +157,7 @@ fn a_stop_ends_an_infinite_search_with_a_real_move() { s.say("stop"); let best = s.wait_for(|l| l.starts_with("bestmove")); assert!(looks_like_a_move(&best), "a stopped search said: {}", best); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] @@ -159,8 +170,7 @@ fn an_infinite_search_holds_its_answer_for_the_stop() { s.stays_quiet_for(Duration::from_millis(400)); s.say("stop"); s.wait_for(|l| l == "bestmove 0000"); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] @@ -187,8 +197,7 @@ fn a_stop_with_nothing_running_is_taken_in_silence() { "the stop was complained about: {:#?}", s.said ); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] @@ -308,8 +317,7 @@ fn the_clear_hash_button_empties_the_table() { s.said ); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } /// A middlegame with enough going on at the root for a cut-short iteration @@ -341,28 +349,19 @@ fn the_move_a_swap_answers_with_opens_the_last_line_said() { s.say(&format!("position fen {}", SHARP_MIDDLEGAME)); s.say("go nodes 41000"); let answer = s.wait_for(|l| l.starts_with("bestmove")); - let best = answer - .strip_prefix("bestmove ") - .unwrap_or_else(|| panic!("not a bestmove: {}", answer)); + let best = move_of(&answer); let info = s .said .iter() .rfind(|l| l.starts_with("info depth ")) - .unwrap_or_else(|| panic!("the search reported no depth: {:#?}", s.said)) - .clone(); - let first = info - .split(" pv ") - .nth(1) - .and_then(|line| line.split_whitespace().next()) - .unwrap_or_else(|| panic!("no line in {}", info)); - assert_eq!(first, best, "the last line said: {}", info); + .unwrap_or_else(|| panic!("the search reported no depth: {:#?}", s.said)); + assert_eq!(line_opens_with(info), best, "the last line said: {}", info); assert!( info.contains(" lowerbound "), "a partial depth was reported as an exact score: {}", info ); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } fn score_of(info: &str) -> i32 { @@ -397,9 +396,7 @@ fn an_iteration_no_root_move_reached_answers_with_the_depth_before_it() { s.say(&format!("position fen {}", KIWIPETE)); s.say("go nodes 9700"); let answer = s.wait_for(|l| l.starts_with("bestmove")); - let best = answer - .strip_prefix("bestmove ") - .unwrap_or_else(|| panic!("not a bestmove: {}", answer)); + let best = move_of(&answer); let lines: Vec<&String> = s .said @@ -455,8 +452,7 @@ fn an_iteration_no_root_move_reached_answers_with_the_depth_before_it() { "the ceiling already covered the whole search: {}", ceiling ); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] @@ -476,9 +472,7 @@ fn a_root_move_that_reaches_beta_is_reported_as_a_floor_and_then_answered_with() s.say(&format!("position fen {}", ITALIAN)); s.say("go depth 7"); let answer = s.wait_for(|l| l.starts_with("bestmove")); - let best = answer - .strip_prefix("bestmove ") - .unwrap_or_else(|| panic!("not a bestmove: {}", answer)); + let best = move_of(&answer); let deepest: Vec<&String> = s .said @@ -500,8 +494,7 @@ fn a_root_move_that_reaches_beta_is_reported_as_a_floor_and_then_answered_with() "the wider search answered with another move: {}", exact ); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] @@ -522,9 +515,7 @@ fn a_floor_answers_until_the_wider_search_replaces_it() { s.say(&format!("position fen {}", WAC_021)); s.say("go nodes 110000"); let answer = s.wait_for(|l| l.starts_with("bestmove")); - let best = answer - .strip_prefix("bestmove ") - .unwrap_or_else(|| panic!("not a bestmove: {}", answer)); + let best = move_of(&answer); let lines: Vec<&String> = s .said @@ -552,8 +543,7 @@ fn a_floor_answers_until_the_wider_search_replaces_it() { "the floor names what the last completed depth answered, so this \ says nothing about which of the two was held" ); - s.say("quit"); - assert!(s.finished().success()); + s.quit(); } #[test] From 00a0d2e26250122be1c2fb157ca2c72c949fe5b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:48:24 +0000 Subject: [PATCH 06/10] refactor(search): Remove duplicated code from the table, the gate and the ledger Ten places spelt the same thing twice. Each is now written once. The transposition table's three `record_` methods built the same entry with a different bound, and `entry()` wrote `NO_EVAL` only for the caller to overwrite it. `entry()` takes the static evaluation and one private `record()` serves the three. The probe tested `cuts` three times in a row; it tests it once and nests the two refusals under it. The empty entry's move and the ordering's `NOWHERE` were the same hand written null move; it is `Play::NOWHERE`. The ledger's skip and scout recorders each spelt out a sixteen field `reduction::Event`; `Event::recorded` builds it for both. The census's quiet history closure and the late move denominator's were the same filter; it is `MoveOrdering::quiet_history`. The gate and `attention()` built the same `AttentionFeatures`; `model_score` builds it for both, and one `hist_milli` serves `Features` and the ledger row score. The gate's plain scout verdict is spelt once, as a closure so the table read stays on the paths that had it (computed eagerly it cost 0.08% of instructions). `Decision::Search { reduction: 0, staged: None }` appeared five times and is `Decision::UNREDUCED`. `ordering_key` forwarded to `keyed` from one call and is inlined there. `quiescence_value` and `search_root` shared a prologue, now `begin`. Nothing the search does changes. The tests in engine/tests.rs, late_move.rs, transposition.rs and reduction.rs cover the paths touched, and `node_counts_have_not_moved` pins the tree. The bench is unchanged at 5965973. Callgrind over the bench counts 9,010,901,496 instructions against the commit before's 9,012,901,827 (-0.02%), one run a side. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/engine.rs | 111 +++++++++++++------------------- arche-core/src/late_move.rs | 92 +++++++++++++------------- arche-core/src/ordering.rs | 45 +++++-------- arche-core/src/play.rs | 11 ++++ arche-core/src/reduction.rs | 36 +++++++++++ arche-core/src/transposition.rs | 84 +++++++++++++----------- 6 files changed, 202 insertions(+), 177 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index a6e0b7d3..32d08b01 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -308,6 +308,15 @@ enum Decision { }, } +impl Decision { + /// A later move searched at the node's depth, with no scout and + /// nothing staged. + const UNREDUCED: Decision = Decision::Search { + reduction: 0, + staged: None, + }; +} + /// The lazy ordering of a node's quiet run, as far as the loop has read /// it. `order_quiets_at` drives it a place at a time. struct QuietOrder { @@ -1544,24 +1553,17 @@ impl AlphaBeta { board.make_move(&staged.play), "the skipped move was made once already" ); - reduction::Event { + reduction::Event::recorded( fen, depth, - window: Window::of(alpha, beta), - index: staged.features.index, - searched: staged.features.index + 1, - generated: staged.features.generated, - history: staged.features.history, - history_max: staged.features.history_max, - killer: staged.features.killer, - tt: staged.features.tt, - eval_beta: eval - i32::from(beta), - alpha_gap: i32::from(alpha) - eval, alpha, - scout: reduction::Scout::Skipped, - cost: 0, - reduction: 0, - } + beta, + eval, + &staged.features, + reduction::Scout::Skipped, + 0, + 0, + ) }); board.undo_move(); } @@ -1598,28 +1600,22 @@ impl AlphaBeta { board.make_move(&staged.play), "the staged move was made once already" ); - reduction::Event { + let scout = if scout <= alpha { + reduction::Scout::Low + } else { + reduction::Scout::High + }; + reduction::Event::recorded( fen, depth, - window: Window::of(alpha, beta), - index: staged.features.index, - searched: staged.features.index + 1, - generated: staged.features.generated, - history: staged.features.history, - history_max: staged.features.history_max, - killer: staged.features.killer, - tt: staged.features.tt, - eval_beta: eval - i32::from(beta), - alpha_gap: i32::from(alpha) - eval, alpha, - scout: if scout <= alpha { - reduction::Scout::Low - } else { - reduction::Scout::High - }, + beta, + eval, + &staged.features, + scout, cost, reduction, - } + ) }); } @@ -1665,13 +1661,7 @@ impl AlphaBeta { census.event(key, || { // the memories score quiet moves alone, so a capture or a // promotion reads 0 here and is priced by its class instead - let quiet_history = |m: &Play| { - if m.capture.is_none() && m.promote.is_none() { - Some(ordering.history_score(board.active_color, m)) - } else { - None - } - }; + let quiet_history = |m: &Play| ordering.quiet_history(board.active_color, m); let killers = ply.map_or([None, None], |ply| ordering.killers_at(ply)); census::Event { fen: board.to_fen(), @@ -1918,17 +1908,24 @@ impl AlphaBeta { } } + /// What every search entry writes before its first node: the limits + /// and the stop the deadline poll reads, the counters and the board's + /// line. + fn begin(&mut self, limits: Limits, stop: Option>) { + self.limits = limits; + self.stop = stop; + self.next_check = 0; + self.nodes = 0; + self.board.start_line(); + } + /// What a capture search makes of the position this engine holds, over /// the open window and under no limits, so what comes back is a value. /// For the tuner's quiet test. `quiescence` itself stays private, /// because a caller free to choose the window could read a bound as a /// value. pub(crate) fn quiescence_value(&mut self) -> Score { - self.limits = Limits::unlimited(); - self.stop = None; - self.next_check = 0; - self.nodes = 0; - self.board.start_line(); + self.begin(Limits::unlimited(), None); match self.quiescence(Score::MIN + 1, Score::MAX - 1) { Ok(value) => value.score, Err(Aborted) => unreachable!("an unlimited capture search runs to the end"), @@ -2506,27 +2503,18 @@ impl AlphaBeta { } if rules.skips(&self.deciding(), node, m) { if self.forced.is_some() && self.forced_skip(node, rules, moves, m) { - return Decision::Search { - reduction: 0, - staged: None, - }; + return Decision::UNREDUCED; } self.record_skip(node, rules, moves, m); return Decision::Skip; } if !rules.admits(node) || m.capture.is_some() || m.promote.is_some() { - return Decision::Search { - reduction: 0, - staged: None, - }; + return Decision::UNREDUCED; } match late_move::decide_admitted(&self.deciding(), node, rules, moves, m) { late_move::Verdict::Skip => { if self.forced.is_some() && self.forced_skip(node, rules, moves, m) { - return Decision::Search { - reduction: 0, - staged: None, - }; + return Decision::UNREDUCED; } self.record_skip(node, rules, moves, m); Decision::Skip @@ -2899,12 +2887,8 @@ impl AlphaBeta { ) -> SearchOutcome { // held to the rail here too, so the check extension cannot overflow depth = depth.min(MAX_PLY); - self.limits = limits; - self.stop = stop; - self.next_check = 0; - self.nodes = 0; + self.begin(limits, stop); self.selective_depth = depth; - self.board.start_line(); if self.poll_deadline().is_err() { return SearchOutcome::Aborted(None); @@ -2932,10 +2916,7 @@ impl AlphaBeta { // the root reduces nothing for m in &moves { let decision = if answer.searched > 0 { - Decision::Search { - reduction: 0, - staged: None, - } + Decision::UNREDUCED } else { Decision::First }; diff --git a/arche-core/src/late_move.rs b/arche-core/src/late_move.rs index 6303a1df..e19e1a8c 100644 --- a/arche-core/src/late_move.rs +++ b/arche-core/src/late_move.rs @@ -228,17 +228,22 @@ fn attention_score(f: &AttentionFeatures) -> i64 { /// below the depth the gate reads the model at, where no decision of the /// move ever consulted it. pub(crate) fn attention(depth: u8, f: &Features, eval_beta: i64, alpha_gap: i64) -> Option { - (depth >= DEEP_REDUCTION_MIN_DEPTH).then(|| { - attention_score(&AttentionFeatures { - depth, - index: f.index, - hist_milli: f.hist_milli(), - killer: f.killer, - tt: f.tt, - eval_beta, - alpha_gap, - generated: f.generated, - }) + (depth >= DEEP_REDUCTION_MIN_DEPTH).then(|| model_score(depth, f, eval_beta, alpha_gap)) +} + +/// The model's score from the ledger's features, the node's depth and the +/// two evaluation terms: what the gate and `attention` both read it at. +#[inline] +fn model_score(depth: u8, f: &Features, eval_beta: i64, alpha_gap: i64) -> i64 { + attention_score(&AttentionFeatures { + depth, + index: f.index, + hist_milli: f.hist_milli(), + killer: f.killer, + tt: f.tt, + eval_beta, + alpha_gap, + generated: f.generated, }) } @@ -250,11 +255,7 @@ pub(crate) fn ledger_row_score(e: &crate::reduction::Event) -> i64 { let f = AttentionFeatures { depth: e.depth, index: e.index, - hist_milli: if e.history_max > 0 { - i64::from(e.history.max(0)) * 1000 / i64::from(e.history_max) - } else { - 0 - }, + hist_milli: hist_milli(e.history, e.history_max), killer: e.killer, tt: e.tt, eval_beta: i64::from(e.eval_beta), @@ -365,16 +366,20 @@ pub(crate) struct Features { } impl Features { - /// The history feature the weights were fitted on, zero to a - /// thousand. A marked down move reads as one the history knows nothing - /// about, rather than pushing the score where the fit never saw, and a - /// denominator at or under zero reads as nothing known. fn hist_milli(&self) -> i64 { - if self.history_max > 0 { - i64::from(self.history.max(0)) * 1000 / i64::from(self.history_max) - } else { - 0 - } + hist_milli(self.history, self.history_max) + } +} + +/// The history feature the weights were fitted on, zero to a thousand. A +/// marked down move reads as one the history knows nothing about, rather +/// than pushing the score where the fit never saw, and a denominator at or +/// under zero reads as nothing known. +fn hist_milli(history: i32, history_max: i32) -> i64 { + if history_max > 0 { + i64::from(history.max(0)) * 1000 / i64::from(history_max) + } else { + 0 } } @@ -637,27 +642,24 @@ fn node_admits(node: &Node) -> bool { /// runs last because the slider probes cost more than everything before it. fn gate(search: &Search, node: &Node, rules: &mut Rules, moves: &[Play], m: &Play) -> Verdict { let searched = node.answer.searched; + let plain = || Verdict::Scout(amount(search.config, node.depth, searched, 0)); if (!search.config.deep_reductions && !search.config.late_move_pruning) || node.depth < DEEP_REDUCTION_MIN_DEPTH { - return Verdict::Scout(amount(search.config, node.depth, searched, 0)); + return plain(); } let eval = i64::from(eval_memo(search.board, &mut rules.eval)); let f = features(search, node, rules, moves, m); - let score = attention_score(&AttentionFeatures { - depth: node.depth, - index: f.index, - hist_milli: f.hist_milli(), - killer: f.killer, - tt: f.tt, - eval_beta: eval - i64::from(node.answer.beta), - alpha_gap: i64::from(node.answer.alpha) - eval, - generated: f.generated, - }); + let score = model_score( + node.depth, + &f, + eval - i64::from(node.answer.beta), + i64::from(node.answer.alpha) - eval, + ); if search.config.late_move_pruning && score <= LATE_MOVE_PRUNING_THRESHOLD { let info = rules.check.get_or_insert_with(|| search.board.check_info()); return if search.board.gives_check_with(info, m) { - Verdict::Scout(amount(search.config, node.depth, searched, 0)) + plain() } else { Verdict::Skip }; @@ -676,7 +678,7 @@ fn gate(search: &Search, node: &Node, rules: &mut Rules, moves: &[Play], m: &Pla DEEP_REDUCTION_BONUS, )); } - Verdict::Scout(amount(search.config, node.depth, searched, 0)) + plain() } /// Whether the gate gives a move the deeper scout's extra ply: by depth @@ -745,14 +747,12 @@ fn eval_memo(board: &Board, eval: &mut Option) -> Score { /// `Features` does the clamping. fn denominator(search: &Search, rules: &mut Rules, moves: &[Play]) -> i32 { *rules.history_max.get_or_insert_with(|| { - let quiet_history = |m: &Play| { - if m.capture.is_none() && m.promote.is_none() { - Some(search.ordering.history_score(search.board.active_color, m)) - } else { - None - } - }; - moves.iter().filter_map(quiet_history).max().unwrap_or(0) + let color = search.board.active_color; + moves + .iter() + .filter_map(|m| search.ordering.quiet_history(color, m)) + .max() + .unwrap_or(0) }) } diff --git a/arche-core/src/ordering.rs b/arche-core/src/ordering.rs index 84d8a8cd..c087e833 100644 --- a/arche-core/src/ordering.rs +++ b/arche-core/src/ordering.rs @@ -80,17 +80,6 @@ const _: () = assert!(MOVE_LIST_INLINE <= 1 << PLACE_BITS); /// not widen that. const _: () = assert!(MOVE_LIST_INLINE <= u64::BITS as usize); -/// What the scratch buffers hold before a sort has written to them: a -/// move no generator produces, never read. -const NOWHERE: Play = Play { - from: 0, - to: 0, - capture: None, - promote: None, - en_passant: false, - castle: false, -}; - /// Cutoffs against tries for each quiet move, by the side that played it /// and the from and to squares (a butterfly table: the piece is not part /// of the index, so two pieces that can make the same journey share an @@ -137,11 +126,11 @@ impl MoveOrdering { pub(crate) fn new() -> Self { Self { keys: [0; MOVE_LIST_INLINE], - sorted: [NOWHERE; MOVE_LIST_INLINE], + sorted: [Play::NOWHERE; MOVE_LIST_INLINE], killers: [[None; 2]; MAX_PLY as usize], history: [[[0; 64]; 64]; 2], quiet_keys: Box::new([[0; QUIET_KEYS]; MAX_PLY as usize]), - quiet_orig: Box::new([[NOWHERE; MOVE_LIST_INLINE]; MAX_PLY as usize]), + quiet_orig: Box::new([[Play::NOWHERE; MOVE_LIST_INLINE]; MAX_PLY as usize]), } } @@ -249,6 +238,13 @@ impl MoveOrdering { 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 + /// promotion: the memories score quiet moves alone. + #[inline] + pub(crate) fn quiet_history(&self, color: Color, m: &Play) -> Option { + (m.capture.is_none() && m.promote.is_none()).then(|| self.history_score(color, m)) + } + /// The first stage: the table's move, if there is one, then the /// winning and even captures, sorted, at the front of the list; the /// quiet moves behind them in generated order; the losing captures, @@ -467,7 +463,7 @@ impl MoveOrdering { killers: self.killers[ply], history: &self.history[board.active_color as usize], }); - moves.sort_by_cached_key(|m| ordering_key(board, m, table_move, quiet.as_ref())); + 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 // the head here too. It is rare enough to read the place off // the sorted list rather than carry it out of the closure @@ -1055,19 +1051,8 @@ fn gravitate(entry: &mut i32, bonus: i32) { } /// What a move sorts by, smallest first: a capture by the swap, a quiet -/// move by the memories, and the table's move ahead of everything. -#[inline(always)] -fn ordering_key( - board: &Board, - m: &Play, - table_move: Option, - quiet: Option<&Quiet<'_>>, -) -> i64 { - keyed(board, m, table_move == Some(*m), quiet) -} - -/// The same, for a caller that has already compared the move with the -/// table's. +/// move by the memories, and the table's move ahead of everything. The +/// caller says whether the move is the table's, having compared already. #[inline(always)] fn keyed(board: &Board, m: &Play, is_table_move: bool, quiet: Option<&Quiet<'_>>) -> i64 { let mut score = match m.capture { @@ -1680,8 +1665,8 @@ mod memory { #[cfg(test)] mod stack_sort { use super::{ - KILLER_BONUS, MOVE_LIST_INLINE, NOWHERE, SEE_UNIT, TABLE_MOVE_BONUS, WINNING_CAPTURE_BASE, - pack, sort_on_the_stack, + KILLER_BONUS, MOVE_LIST_INLINE, SEE_UNIT, TABLE_MOVE_BONUS, WINNING_CAPTURE_BASE, pack, + sort_on_the_stack, }; use crate::play::Play; use proptest::prelude::*; @@ -1712,7 +1697,7 @@ mod stack_sort { scored += 1; } } - let mut scratch = [NOWHERE; MOVE_LIST_INLINE]; + let mut scratch = [Play::NOWHERE; MOVE_LIST_INLINE]; let mut sorted = moves; sort_on_the_stack(&mut sorted, &mut keys[..scored], plain, front, &mut scratch); (sorted, expected) diff --git a/arche-core/src/play.rs b/arche-core/src/play.rs index e5fac1a1..774cfb09 100644 --- a/arche-core/src/play.rs +++ b/arche-core/src/play.rs @@ -27,6 +27,17 @@ pub struct Play { } impl Play { + /// A move no generator produces: what a sort buffer holds before it is + /// written and what an empty table slot carries. Never read as a move. + pub(crate) const NOWHERE: Play = Play { + from: 0, + to: 0, + capture: None, + promote: None, + en_passant: false, + castle: false, + }; + pub(crate) fn new( from: u8, to: u8, diff --git a/arche-core/src/reduction.rs b/arche-core/src/reduction.rs index 81778062..cd68e94d 100644 --- a/arche-core/src/reduction.rs +++ b/arche-core/src/reduction.rs @@ -147,6 +147,42 @@ pub struct Event { } impl Event { + /// A row from what the move loop staged and what the recorder read at + /// the node: the fen the move left, the node's depth, bounds and own + /// evaluation, and how the scout answered. `searched` is `index + 1`, + /// as every row prints it. + #[allow(clippy::too_many_arguments)] + pub(crate) fn recorded( + fen: String, + depth: u8, + alpha: Score, + beta: Score, + eval: i32, + features: &late_move::Features, + scout: Scout, + cost: u64, + reduction: u8, + ) -> Event { + Event { + fen, + depth, + window: Window::of(alpha, beta), + index: features.index, + searched: features.index + 1, + generated: features.generated, + history: features.history, + history_max: features.history_max, + killer: features.killer, + tt: features.tt, + eval_beta: eval - i32::from(beta), + alpha_gap: i32::from(alpha) - eval, + alpha, + scout, + cost, + reduction, + } + } + /// The depth the reduced move was denied: what the replay searches the /// fen to. Floored at one, which is what a depth one skip asks for: the /// counterfactual is then a search of the same depth rather than diff --git a/arche-core/src/transposition.rs b/arche-core/src/transposition.rs index aad8e622..ab8e69ab 100644 --- a/arche-core/src/transposition.rs +++ b/arche-core/src/transposition.rs @@ -301,14 +301,7 @@ const GENERATIONS: u8 = 31; impl Entry { const EMPTY: Entry = Entry { key: 0, - play: Play { - from: 0, - to: 0, - capture: None, - promote: None, - en_passant: false, - castle: false, - }, + play: Play::NOWHERE, score: 0, depth: 0, flags: 0, @@ -1007,11 +1000,7 @@ impl TranspositionTable { depth: u8, static_eval: Score, ) -> bool { - let pv = Pv { - static_eval, - ..entry(board, play, floor, depth, Bound::Lower) - }; - self.set(board.key, pv) + self.record(board, play, floor, depth, Bound::Lower, static_eval) } /// Every move here fell short of the window: the score is a ceiling, @@ -1026,11 +1015,7 @@ impl TranspositionTable { depth: u8, static_eval: Score, ) -> bool { - let pv = Pv { - static_eval, - ..entry(board, play, ceiling, depth, Bound::Upper) - }; - self.set(board.key, pv) + self.record(board, play, ceiling, depth, Bound::Upper, static_eval) } /// The best move found by searching all of them here, with its exact @@ -1044,18 +1029,35 @@ impl TranspositionTable { depth: u8, static_eval: Score, ) -> bool { - let pv = Pv { - static_eval, - ..entry(board, play, score, depth, Bound::Exact) - }; - self.set(board.key, pv) + self.record(board, play, score, depth, Bound::Exact, static_eval) + } + + /// What the three `record_` methods share: the entry, offered to the + /// depth contest. + #[inline(always)] + fn record( + &mut self, + board: &Board, + play: Play, + value: Value, + depth: u8, + bound: Bound, + static_eval: Score, + ) -> bool { + self.set( + board.key, + entry(board, play, value, depth, bound, static_eval), + ) } /// The move the engine is about to answer with, stored past the depth /// contest for the reason `set_always` gives, so it always lands. #[must_use] pub fn record_answer(&mut self, board: &Board, play: Play, score: Value, depth: u8) -> bool { - self.set_always(board.key, entry(board, play, score, depth, Bound::Exact)); + self.set_always( + board.key, + entry(board, play, score, depth, Bound::Exact, NO_EVAL), + ); true } @@ -1070,7 +1072,10 @@ impl TranspositionTable { floor: Value, depth: u8, ) -> bool { - self.set_always(board.key, entry(board, play, floor, depth, Bound::Lower)); + self.set_always( + board.key, + entry(board, play, floor, depth, Bound::Lower, NO_EVAL), + ); true } @@ -1106,16 +1111,16 @@ impl TranspositionTable { Bound::Lower => score >= beta, Bound::Ordering => false, }; - if cuts && guard_rule50 && board.fifty_move_near_expiry() { - // near the horizon every stored score is suspect, tainted - // or not - return Probe::Refused(pv.play); - } - if cuts && refuse_tainted && pv.tainted { - // the stored draw may not be reachable by this path - return Probe::Refused(pv.play); - } if cuts { + if guard_rule50 && board.fifty_move_near_expiry() { + // near the horizon every stored score is suspect, + // tainted or not + return Probe::Refused(pv.play); + } + if refuse_tainted && pv.tainted { + // the stored draw may not be reachable by this path + return Probe::Refused(pv.play); + } self.count_false_accept_cutoff(foreign); return Probe::Cut(Value::with_taint(score, pv.tainted)); } @@ -1155,11 +1160,18 @@ impl TranspositionTable { /// Fold a position and a result into an entry, converting the score to the /// table's form so no caller has to. #[inline] -fn entry(board: &Board, play: Play, value: Value, depth: u8, bound: Bound) -> Pv { +fn entry( + board: &Board, + play: Play, + value: Value, + depth: u8, + bound: Bound, + static_eval: Score, +) -> Pv { Pv { play, depth, - static_eval: NO_EVAL, + static_eval, score: score_to_tt(value.score, board.line_ply), bound, tainted: value.tainted, From 964acb35dc3146ae636f7ffc25faef3e1d907f3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:53:30 +0000 Subject: [PATCH 07/10] test(search): Merge the repeated table seeding, replay and fifty move tests Six tests in engine/tests.rs planted an entry, seven times, with the same five argument `record_best` call wrapped in an assert. A `seed` helper does it for them; the quiescence entry test keeps its own call because the depth of zero is its point. The four replay label tests in reduction.rs (a fail low and a skipped move, each harmful and harmless) were the same two fens and the same assertions under a different `Scout`. One test loops over the two kinds and names the kind in every assertion message, so a failure still says which of the four properties broke. The skipped rows zero their cost and reduction as the recorder does; the replay does not read either. `a_losing_side_plays_for_the_fifty_move_draw` is deleted. It was the same fen, the same depth three search and the same assertion as `the_same_root_one_ply_before_expiry_answers_the_same_way`, which keeps its note about why the position draws. Both sat at a counter of 99, so neither tested a boundary the other did not. Test code only. The bench is unchanged at 5965973. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/engine/tests.rs | 82 ++++++++---------------------- arche-core/src/reduction.rs | 92 ++++++++++++++-------------------- 2 files changed, 60 insertions(+), 114 deletions(-) diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index aa3f1a56..39e19e7d 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -255,6 +255,18 @@ mod search { /// search can outrank it. const SEEDED_DEPTH: u8 = 5; + /// Plant `play` as the exact answer at `board`, at the seeded depth and + /// with no evaluation, and check that the entry landed. + fn seed(table: &mut crate::transposition::TranspositionTable, board: &Board, play: Play) { + assert!(table.record_best( + board, + play, + Value::clean(0), + SEEDED_DEPTH, + crate::transposition::NO_EVAL + )); + } + #[test] fn a_losing_position_is_still_losing_with_a_warm_table() { // a search of another position first once left entries that made @@ -334,26 +346,14 @@ mod search { let (best, best_score) = scored[2]; let mut e = engine(Board::from_fen(FEN).unwrap()); - assert!(e.transpositions.record_best( - &e.board, - middle, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &e.board, middle); let result = completed(e.search(1)); assert_eq!(result.best_move, best); assert_eq!(result.score, best_score); assert_eq!(e.nodes, 9); let mut e = engine(Board::from_fen(FEN).unwrap()); - assert!(e.transpositions.record_best( - &e.board, - best, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &e.board, best); let result = completed(e.search(1)); assert_eq!(result.best_move, best); assert_eq!(result.score, best_score); @@ -885,16 +885,6 @@ mod search { ); } - #[test] - fn a_losing_side_plays_for_the_fifty_move_draw() { - // white is a bishop down, and every move but a pawn push or a - // capture takes the clock to a hundred - let game = Board::from_fen("5k2/1p3p1p/p3pK1P/P1P1P3/4bP2/8/8/8 w - - 99 112").unwrap(); - let mut e = engine(game); - let result = completed(e.search(3)); - assert_eq!(result.score, 0); - } - /// A fifty move draw is claimable and not automatic (FIDE 9.3), so a /// root whose counter has expired still answers with a move rather /// than `bestmove 0000`. The score is zero because every move here @@ -913,7 +903,9 @@ mod search { } /// The same position one ply before expiry, so the pair says the - /// counter is what changed and not the position. + /// counter is what changed and not the position. White is a bishop + /// down, and every move but a pawn push or a capture takes the clock + /// to a hundred, so the losing side plays for the draw. #[test] fn the_same_root_one_ply_before_expiry_answers_the_same_way() { let game = Board::from_fen("5k2/1p3p1p/p3pK1P/P1P1P3/4bP2/8/8/8 w - - 99 112").unwrap(); @@ -2653,13 +2645,7 @@ mod search { let mut board = e.board.clone(); for name in cycle.iter().cycle().take(16) { let play = play_named(&board, name); - assert!(e.transpositions.record_best( - &board, - play, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &board, play); assert!(board.make_move(&play), "failed to play {}", name); } @@ -2673,13 +2659,7 @@ mod search { let mut board = e.board.clone(); for name in ["c3d4", "f8g8"] { let play = play_named(&board, name); - assert!(e.transpositions.record_best( - &board, - play, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &board, play); assert!(board.make_move(&play), "failed to play {}", name); } assert!(board.fifty_move_expired()); @@ -2694,13 +2674,7 @@ mod search { let a2 = 8; let a5 = 32; let colliding = Play::new(a2, a5, None, None, false, false); - assert!(e.transpositions.record_best( - &e.board, - colliding, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &e.board, colliding); assert_eq!(format!("{}", e.pv_line()), ""); } @@ -2729,13 +2703,7 @@ mod search { let board = Board::from_fen("4r2k/8/8/8/8/8/4N3/4K3 w - - 0 1").unwrap(); let mut e = engine(board); let pinned = play_named(&e.board, "e2d4"); - assert!(e.transpositions.record_best( - &e.board, - pinned, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &e.board, pinned); assert_eq!(format!("{}", e.pv_line()), ""); } @@ -2772,13 +2740,7 @@ mod search { } let play = chosen.unwrap_or_else(|| panic!("nothing carries the line on at ply {}", ply)); - assert!(e.transpositions.record_best( - &board, - play, - Value::clean(0), - SEEDED_DEPTH, - crate::transposition::NO_EVAL - )); + seed(&mut e.transpositions, &board, play); assert!(board.make_move(&play), "failed to play {}", play); } diff --git a/arche-core/src/reduction.rs b/arche-core/src/reduction.rs index cd68e94d..95a2a9bf 100644 --- a/arche-core/src/reduction.rs +++ b/arche-core/src/reduction.rs @@ -618,30 +618,46 @@ mod tests { /// The sign every label rests on: the replay's answer is from the side /// the move was played against, so it is negated before it meets /// alpha. A side left facing a bare queen is lost, so the scout that - /// wrote the move off threw a winning move away. This fen and the - /// next differ only in which side holds the queen. + /// wrote the move off threw a winning move away; where the side to + /// move holds the queen the full search agrees with the scout. The two + /// fens differ only in which side holds the queen. A skipped move is + /// replayed on the fail low's terms, and its labels land the same way. #[test] - fn a_fail_low_the_full_search_would_raise_alpha_on_is_harmful() { - let events = vec![made_up("7k/8/8/8/8/8/8/1Q5K b - - 0 1", 3, 0, Scout::Low)]; - let (rows, unplayable) = replay(&events); - assert_eq!(unplayable, 0); - assert_eq!(rows.len(), 1); - let reference = rows[0].reference.expect("a fail low is replayed"); - assert!(reference < 0, "the side to move is lost: {}", reference); - assert_eq!(rows[0].harmful(), Some(true)); - assert_eq!(rows[0].label_word(), "harmful"); - } + fn a_fail_low_or_a_skip_is_harmful_only_where_the_full_search_would_raise_alpha() { + for (kind, scout, depth) in [("fail low", Scout::Low, 3), ("skip", Scout::Skipped, 4)] { + let mut lost = made_up("7k/8/8/8/8/8/8/1Q5K b - - 0 1", depth, 0, scout); + let mut winning = made_up("7k/8/8/8/8/8/1q6/7K b - - 0 1", depth, 0, scout); + if scout == Scout::Skipped { + for event in [&mut lost, &mut winning] { + event.cost = 0; + event.reduction = 0; + } + } - /// The same ask where the side to move holds the queen: the full search - /// agrees with the scout. - #[test] - fn a_fail_low_the_full_search_agrees_with_is_harmless() { - let events = vec![made_up("7k/8/8/8/8/8/1q6/7K b - - 0 1", 3, 0, Scout::Low)]; - let (rows, _) = replay(&events); - let reference = rows[0].reference.expect("a fail low is replayed"); - assert!(reference > 0, "the side to move is winning: {}", reference); - assert_eq!(rows[0].harmful(), Some(false)); - assert_eq!(rows[0].label_word(), "harmless"); + let (rows, unplayable) = replay(&[lost]); + assert_eq!(unplayable, 0, "{}", kind); + assert_eq!(rows.len(), 1, "{}", kind); + let reference = rows[0].reference.expect("a fail low or a skip is replayed"); + assert!( + reference < 0, + "{}: the side to move is lost: {}", + kind, + reference + ); + assert_eq!(rows[0].harmful(), Some(true), "{}", kind); + assert_eq!(rows[0].label_word(), "harmful", "{}", kind); + + let (rows, _) = replay(&[winning]); + let reference = rows[0].reference.expect("a fail low or a skip is replayed"); + assert!( + reference > 0, + "{}: the side to move is winning: {}", + kind, + reference + ); + assert_eq!(rows[0].harmful(), Some(false), "{}", kind); + assert_eq!(rows[0].label_word(), "harmless", "{}", kind); + } } /// An answer exactly at alpha raises nothing: the label is strict. @@ -659,38 +675,6 @@ mod tests { assert_eq!(raised.harmful(), Some(true)); } - /// A skipped move is replayed on the fail low's terms. The bare queen - /// fens are the two fail low tests', and the labels land the same way. - #[test] - fn a_skipped_move_the_full_search_would_raise_alpha_on_is_harmful() { - let mut event = made_up("7k/8/8/8/8/8/8/1Q5K b - - 0 1", 4, 0, Scout::Skipped); - event.cost = 0; - event.reduction = 0; - let (rows, unplayable) = replay(&[event]); - assert_eq!(unplayable, 0); - assert_eq!(rows.len(), 1); - let reference = rows[0].reference.expect("a skip is replayed"); - assert!(reference < 0, "the side to move is lost: {}", reference); - assert_eq!(rows[0].harmful(), Some(true)); - assert_eq!(rows[0].label_word(), "harmful"); - } - - /// The same ask where the skip was right. - #[test] - fn a_skipped_move_the_full_search_agrees_with_is_harmless() { - let events = vec![made_up( - "7k/8/8/8/8/8/1q6/7K b - - 0 1", - 4, - 0, - Scout::Skipped, - )]; - let (rows, _) = replay(&events); - let reference = rows[0].reference.expect("a skip is replayed"); - assert!(reference > 0, "the side to move is winning: {}", reference); - assert_eq!(rows[0].harmful(), Some(false)); - assert_eq!(rows[0].label_word(), "harmless"); - } - /// A fail high is kept and never replayed: its fen is unreadable, so a /// replay that touched it would show in the unplayable count. #[test] From f01bbe7f4f80eebccfd0bc8add4c8e47eef200ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:57:52 +0000 Subject: [PATCH 08/10] refactor(board): Read a file's letter and index off its discriminant `File` is declared with the discriminants zero to seven in file order, so its letter is one addition and its parse from a number or a letter is one lookup in `VARIANTS`; the three impls spelt out eight arms each. The promotion piece is `Copy`, and its conversions to a piece and to a letter now take it by value, so the four callers in make and unmake no longer borrow it to convert it. The `File` conversions are reached from fen and move parsing and printing, none from the search, and the promotion conversions change only how they take their argument. The bench is unchanged at 5965973. Bench: 5965973 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/board.rs | 8 +++---- arche-core/src/misc.rs | 49 +++++++++-------------------------------- arche-core/src/play.rs | 2 +- 3 files changed, 16 insertions(+), 43 deletions(-) diff --git a/arche-core/src/board.rs b/arche-core/src/board.rs index 4133dbee..40780159 100644 --- a/arche-core/src/board.rs +++ b/arche-core/src/board.rs @@ -1639,7 +1639,7 @@ impl Board { } else { if MAINTAIN_CHECKERS { let landed = match play.promote { - Some(promote) => (&promote).into(), + Some(promote) => promote.into(), None => from_piece, }; self.checkers = self.checkers_given(play, landed); @@ -1674,7 +1674,7 @@ impl Board { } if let Some(promote) = play.promote { - self.place_bare::(play.to, (&promote).into(), mover); + self.place_bare::(play.to, promote.into(), mover); self.place_bare::(play.from, Piece::Pawn, mover); } else { let from_piece = self @@ -1822,7 +1822,7 @@ impl Board { // both change Some(promote) => { self.clear_piece_index(from, piece, color); - self.set_piece_index(to, (&promote).into(), color); + self.set_piece_index(to, promote.into(), color); } None => self.relocate_piece_index(from, to, piece, color), } @@ -2152,7 +2152,7 @@ impl Board { let mut straight = (self.rooks() | self.queens()) & ours & !from_bit; let landed = match m.promote { - Some(promote) => (&promote).into(), + Some(promote) => promote.into(), None => self .get_piece_index(m.from) .expect("a move moves a piece of ours"), diff --git a/arche-core/src/misc.rs b/arche-core/src/misc.rs index ef585a68..84fb8b7f 100644 --- a/arche-core/src/misc.rs +++ b/arche-core/src/misc.rs @@ -258,8 +258,8 @@ impl PromotePiece { ]; } -impl From<&PromotePiece> for char { - fn from(c: &PromotePiece) -> Self { +impl From for char { + fn from(c: PromotePiece) -> Self { match c { PromotePiece::Knight => 'n', PromotePiece::Bishop => 'b', @@ -346,8 +346,8 @@ impl TryFrom for Piece { } } -impl From<&PromotePiece> for Piece { - fn from(c: &PromotePiece) -> Self { +impl From for Piece { + fn from(c: PromotePiece) -> Self { match c { PromotePiece::Knight => Piece::Knight, PromotePiece::Bishop => Piece::Bishop, @@ -420,17 +420,7 @@ impl File { impl fmt::Display for File { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - File::A => write!(f, "a")?, - File::B => write!(f, "b")?, - File::C => write!(f, "c")?, - File::D => write!(f, "d")?, - File::E => write!(f, "e")?, - File::F => write!(f, "f")?, - File::G => write!(f, "g")?, - File::H => write!(f, "h")?, - } - Ok(()) + write!(f, "{}", (b'a' + *self as u8) as char) } } @@ -438,20 +428,10 @@ impl TryFrom for File { type Error = String; fn try_from(i: u8) -> Result { - match i { - 0 => Ok(File::A), - 1 => Ok(File::B), - 2 => Ok(File::C), - 3 => Ok(File::D), - 4 => Ok(File::E), - 5 => Ok(File::F), - 6 => Ok(File::G), - 7 => Ok(File::H), - _ => Err(format!( - "{} is not a valid File value. File only has 8 variants.", - i - )), - } + File::VARIANTS + .get(usize::from(i)) + .copied() + .ok_or_else(|| format!("{} is not a valid File value. File only has 8 variants.", i)) } } @@ -459,15 +439,8 @@ impl TryFrom for File { type Error = String; fn try_from(c: char) -> Result { - match c { - 'A' | 'a' => Ok(File::A), - 'B' | 'b' => Ok(File::B), - 'C' | 'c' => Ok(File::C), - 'D' | 'd' => Ok(File::D), - 'E' | 'e' => Ok(File::E), - 'F' | 'f' => Ok(File::F), - 'G' | 'g' => Ok(File::G), - 'H' | 'h' => Ok(File::H), + match c.to_ascii_lowercase() { + lower @ 'a'..='h' => Ok(File::VARIANTS[(lower as u8 - b'a') as usize]), _ => Err(format!("{} is not a valid File token", c)), } } diff --git a/arche-core/src/play.rs b/arche-core/src/play.rs index 774cfb09..6f6a760c 100644 --- a/arche-core/src/play.rs +++ b/arche-core/src/play.rs @@ -63,7 +63,7 @@ impl fmt::Display for Play { let (to_rank, to_file) = index_to_coordinate(self.to); write!(f, "{}{}", from_file, from_rank)?; write!(f, "{}{}", to_file, to_rank)?; - if let Some(promote) = &self.promote { + if let Some(promote) = self.promote { write!(f, "{}", char::from(promote))?; } Ok(()) From 11c56f3753aee2bd72d78500baceac8f7fe65bbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:45:51 +0000 Subject: [PATCH 09/10] refactor(bench): Share the instruments' position parsing, header and share helpers Six instruments each parsed a suite position with the same panic on a fen that does not read. `Position::board(instrument)` in bench.rs does it once, with the same text, and the bench, the recorders, the effort and forced instruments, the tactical suite and the terms extraction call it. The strategic suite's two share methods repeated the bench's private `share` in f64 form. It is now `pub(crate)` and they call it. The u32 values go through u64, which is exact, so the percentages are the same. The cutoff census, the residuals and the effort instrument each wrote the opening of their header (name, depth, every, then the cap when it is not the default and the epd when a suite was named) and each sorted and deduped the depths their summaries run over. `recorder::write_settings` and `recorder::depths` carry both. The forced instrument's header states every setting and stays on its own form; the reduction ledger is left as it was. In forced.rs the one call of `count_share` is written out and the `dash` closure folded into its use. In tune.rs `Terms::of` names the colour sign once instead of matching `Color` twice. No printed string changes. The header unit tests in each module and the session tests under tests/ pin the output, and every instrument was run at a small depth before and after and the output compared byte for byte, with only the bench's ms and nps columns moving. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/bench.rs | 18 ++++++++++++---- arche-core/src/census.rs | 10 ++------- arche-core/src/effort.rs | 41 +++++++++++++++++++------------------ arche-core/src/forced.rs | 21 ++++++++----------- arche-core/src/recorder.rs | 38 +++++++++++++++++++++++++++++----- arche-core/src/residual.rs | 42 +++++++++++++++++--------------------- arche-core/src/strategy.rs | 14 +++---------- arche-core/src/tactics.rs | 5 ++--- arche-core/src/tune.rs | 14 +++---------- 9 files changed, 105 insertions(+), 98 deletions(-) diff --git a/arche-core/src/bench.rs b/arche-core/src/bench.rs index 25f92ee2..eadf776b 100644 --- a/arche-core/src/bench.rs +++ b/arche-core/src/bench.rs @@ -56,6 +56,16 @@ pub struct Position { pub operations: HashMap, } +impl Position { + /// The position as a board. A suite is read from a committed file, so a + /// fen that does not parse is a broken suite and panics, naming the + /// instrument that was reading it. + pub fn board(&self, instrument: &str) -> Board { + Board::from_fen(&self.fen) + .unwrap_or_else(|e| panic!("{} position {} does not parse: {}", instrument, self.id, e)) + } +} + pub fn positions() -> Vec { parse_epd(SUITE) } @@ -245,9 +255,7 @@ fn run( let positions = positions .iter() .map(|position| { - let board = Board::from_fen(&position.fen) - .unwrap_or_else(|e| panic!("bench position {} does not parse: {}", position.id, e)); - let mut engine = AlphaBeta::with_config(board, table_bytes, config); + let mut engine = AlphaBeta::with_config(position.board("bench"), table_bytes, config); if audit && !engine.audit_signatures() { return None; } @@ -294,7 +302,9 @@ fn run( }) } -fn share(part: u64, whole: u64) -> f64 { +/// A part as a percentage of the whole, and zero where there is no whole. +/// The strategic suite's shares are taken the same way. +pub(crate) fn share(part: u64, whole: u64) -> f64 { if whole == 0 { 0.0 } else { diff --git a/arche-core/src/census.rs b/arche-core/src/census.rs index 67fe61fe..00534c6e 100644 --- a/arche-core/src/census.rs +++ b/arche-core/src/census.rs @@ -262,10 +262,7 @@ impl Report { /// Shallowest depth first. pub fn summaries(&self) -> Vec { - let mut depths: Vec = self.rows.iter().map(|row| row.depth).collect(); - depths.sort_unstable(); - depths.dedup(); - depths + recorder::depths(self.rows.iter().map(|row| row.depth)) .into_iter() .filter_map(|depth| self.summary(depth)) .collect() @@ -289,10 +286,7 @@ fn mean(total: usize, over: usize) -> String { /// print `-` rather than moving the columns. impl fmt::Display for Report { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "cutoffs depth {} every {}", self.depth, self.every)?; - if self.cap != recorder::DEFAULT_CAP { - write!(f, " cap {}", self.cap)?; - } + recorder::write_settings(f, "cutoffs", self.depth, self.every, self.cap, None)?; write!( f, " positions {} events {} records {}", diff --git a/arche-core/src/effort.rs b/arche-core/src/effort.rs index 73d56926..158ffa6d 100644 --- a/arche-core/src/effort.rs +++ b/arche-core/src/effort.rs @@ -22,7 +22,6 @@ //! measure. use crate::bench::{self, Position}; -use crate::board::Board; use crate::engine::{ Ablation, AlphaBeta, Engine, ScoreBound, SearchConfig, SearchOutcome, SearchParameters, }; @@ -273,9 +272,8 @@ fn side( let mut depths = Depths::default(); let mut answered = Vec::with_capacity(positions.len()); for position in positions { - let board = Board::from_fen(&position.fen) - .unwrap_or_else(|e| panic!("effort position {} does not parse: {}", position.id, e)); - let mut engine = AlphaBeta::with_config(board, bench::TABLE_BYTES, config); + let mut engine = + AlphaBeta::with_config(position.board("effort"), bench::TABLE_BYTES, config); engine.arm(sampler); let mut reached = 0; let outcome = engine.iterative_deepening_search( @@ -525,15 +523,16 @@ impl Report { /// Shallowest depth first. A depth either side reached has a line even /// where the sampling kept no row of it. pub fn summaries(&self) -> Vec { - let mut depths = self.nodes_on.reached(); - depths.extend(self.nodes_off.reached()); - depths.extend(self.rows.iter().map(|row| row.depth)); - depths.sort_unstable(); - depths.dedup(); - depths - .into_iter() - .filter_map(|depth| self.summary(depth)) - .collect() + recorder::depths( + self.nodes_on + .reached() + .into_iter() + .chain(self.nodes_off.reached()) + .chain(self.rows.iter().map(|row| row.depth)), + ) + .into_iter() + .filter_map(|depth| self.summary(depth)) + .collect() } } @@ -558,13 +557,14 @@ fn signed_share(delta: i64, of: u64) -> String { /// collision guard dropped. impl fmt::Display for Report { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "effort depth {} every {}", self.depth, self.every)?; - if self.cap != recorder::DEFAULT_CAP { - write!(f, " cap {}", self.cap)?; - } - if let Some(suite) = &self.suite { - write!(f, " epd {}", suite)?; - } + recorder::write_settings( + f, + "effort", + self.depth, + self.every, + self.cap, + self.suite.as_deref(), + )?; // stated either way, since the null run is a reading write!(f, " off {}", self.off.as_deref().unwrap_or("none"))?; if let Some(budget) = self.budget { @@ -663,6 +663,7 @@ impl fmt::Display for Report { #[cfg(test)] mod tests { use super::*; + use crate::board::Board; use crate::recorder::DEFAULT_CAP; use crate::recorder::fixtures::{recording_leaves_the_search_where_it_was, suite}; use pretty_assertions::assert_eq; diff --git a/arche-core/src/forced.rs b/arche-core/src/forced.rs index 4294d612..27ca85df 100644 --- a/arche-core/src/forced.rs +++ b/arche-core/src/forced.rs @@ -19,7 +19,6 @@ //! fresh engine and table with no clock. use crate::bench::{self, Position}; -use crate::board::Board; use crate::engine::{AlphaBeta, Engine, SearchOutcome, SearchParameters}; use crate::late_move::Features; use crate::misc::Score; @@ -260,9 +259,7 @@ pub struct Answer { /// bench's and there is no clock, so two searches of a root agree until /// the arm makes them differ. fn search(position: &Position, depth: u8, arm: Arm) -> (Answer, Arm) { - let board = Board::from_fen(&position.fen) - .unwrap_or_else(|e| panic!("forced position {} does not parse: {}", position.id, e)); - let mut engine = AlphaBeta::with_table_bytes(board, bench::TABLE_BYTES); + let mut engine = AlphaBeta::with_table_bytes(position.board("forced"), bench::TABLE_BYTES); engine.arm_forced(arm); let outcome = engine.iterative_deepening_search(SearchParameters::to_depth(depth), |_, _, _, _| {}); @@ -385,11 +382,6 @@ pub fn run( } } -/// A count and a share, or a `-` with nothing under it. -fn count_share(part: usize, of: usize) -> String { - format!("{} {}", part, recorder::share(part, of)) -} - /// A header and the rows, then under `summary` the tallies for each kind /// and what the default answered at each root. /// @@ -419,7 +411,6 @@ impl fmt::Display for Report { )?; for row in &self.rows { let e = &row.event; - let dash = || "-".to_string(); let features = match &e.features { Some(m) => format!( "{} {} {} {} {} {} {}", @@ -441,7 +432,8 @@ impl fmt::Display for Report { features, e.eval_beta, e.alpha_gap, - e.attention.map_or_else(dash, |score| score.to_string()), + e.attention + .map_or_else(|| "-".to_string(), |score| score.to_string()), row.answered.map_or("-", Answered::word), row.visits, e.root, @@ -467,13 +459,15 @@ impl fmt::Display for Report { .collect(); let unmet = rows.iter().filter(|row| row.visits == 0).count(); let flipped = rows.iter().filter(|row| row.flipped()).count(); + // the count and its share, which is `-` with nothing under it writeln!( f, - "kind {} forced {} unmet {} flipped {}", + "kind {} forced {} unmet {} flipped {} {}", kind.word(), rows.len(), unmet, - count_share(flipped, rows.len()), + flipped, + recorder::share(flipped, rows.len()), )?; } for (root, (id, answer)) in self.roots.iter().enumerate() { @@ -490,6 +484,7 @@ impl fmt::Display for Report { #[cfg(test)] mod tests { use super::*; + use crate::board::Board; use crate::recorder::DEFAULT_CAP; use crate::recorder::fixtures::{recording_leaves_the_search_where_it_was, suite}; use pretty_assertions::assert_eq; diff --git a/arche-core/src/recorder.rs b/arche-core/src/recorder.rs index 89a0a192..c2196066 100644 --- a/arche-core/src/recorder.rs +++ b/arche-core/src/recorder.rs @@ -9,10 +9,10 @@ //! there was a reservoir at all, which the pinned bench counts stand behind. use crate::bench::{self, Position}; -use crate::board::Board; use crate::engine::{AlphaBeta, Engine, Recorded, SearchConfig, SearchParameters}; use crate::misc::Score; use std::collections::BinaryHeap; +use std::fmt; /// The window a node was searched with, read from alpha and beta alone. /// @@ -277,10 +277,8 @@ pub(crate) fn record( ) -> Sampled { let mut sampler = Sampler::with_cap(every, cap); for position in positions { - let board = Board::from_fen(&position.fen).unwrap_or_else(|e| { - panic!("{} position {} does not parse: {}", T::WHAT, position.id, e) - }); - let mut engine = AlphaBeta::with_config(board, bench::TABLE_BYTES, config); + let mut engine = + AlphaBeta::with_config(position.board(T::WHAT), bench::TABLE_BYTES, config); engine.arm(sampler); engine.iterative_deepening_search(SearchParameters::to_depth(depth), |_, _, _, _| {}); sampler = engine @@ -290,6 +288,36 @@ pub(crate) fn record( sampler.drain() } +/// The opening of a sampling instrument's header: the instrument's name, +/// the depth and the rate, then the cap and the suite where they are not +/// the defaults. The default cap and the bench's own suite are left out, so +/// the header says how to rerun the run it heads and no more. +pub(crate) fn write_settings( + f: &mut fmt::Formatter<'_>, + instrument: &str, + depth: u8, + every: u32, + cap: usize, + suite: Option<&str>, +) -> fmt::Result { + write!(f, "{} depth {} every {}", instrument, depth, every)?; + if cap != DEFAULT_CAP { + write!(f, " cap {}", cap)?; + } + if let Some(suite) = suite { + write!(f, " epd {}", suite)?; + } + Ok(()) +} + +/// The depths a report summarises, each once, shallowest first. +pub(crate) fn depths(reached: impl Iterator) -> Vec { + let mut depths: Vec = reached.collect(); + depths.sort_unstable(); + depths.dedup(); + depths +} + /// What the recorders' tests share. #[cfg(test)] pub(crate) mod fixtures { diff --git a/arche-core/src/residual.rs b/arche-core/src/residual.rs index 6fd112e6..3d90e674 100644 --- a/arche-core/src/residual.rs +++ b/arche-core/src/residual.rs @@ -20,7 +20,7 @@ use crate::bench::Position; use crate::board::Board; use crate::engine::{AlphaBeta, Engine, SearchConfig, SearchOutcome, SearchParameters}; use crate::misc::Score; -use crate::recorder::{self, DEFAULT_CAP, Window}; +use crate::recorder::{self, Window}; use crate::value::Value; use std::fmt; @@ -371,18 +371,15 @@ impl Report { /// Every summary of one kind, shallowest depth first. pub fn summaries(&self, kind: Shortcut) -> Vec { - let mut depths: Vec = self - .rows - .iter() - .filter(|row| row.kind == kind) - .map(|row| row.depth) - .collect(); - depths.sort_unstable(); - depths.dedup(); - depths - .into_iter() - .filter_map(|depth| self.summary(kind, depth)) - .collect() + recorder::depths( + self.rows + .iter() + .filter(|row| row.kind == kind) + .map(|row| row.depth), + ) + .into_iter() + .filter_map(|depth| self.summary(kind, depth)) + .collect() } } @@ -400,15 +397,14 @@ impl Report { /// node, and whether the node stood on the line to the root. impl fmt::Display for Report { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "residuals depth {} every {}", self.depth, self.every)?; - // the default cap and the bench's own suite are left out, so the - // header says how to rerun the run it heads - if self.cap != DEFAULT_CAP { - write!(f, " cap {}", self.cap)?; - } - if let Some(suite) = &self.suite { - write!(f, " epd {}", suite)?; - } + recorder::write_settings( + f, + "residuals", + self.depth, + self.every, + self.cap, + self.suite.as_deref(), + )?; write!( f, " taint {} positions {} events {} records {}", @@ -478,8 +474,8 @@ impl fmt::Display for Report { #[cfg(test)] mod tests { use super::*; - use crate::recorder::Sampler; use crate::recorder::fixtures::{recording_leaves_the_search_where_it_was, suite}; + use crate::recorder::{DEFAULT_CAP, Sampler}; /// Scores just inside the mate window, either side of it. const MATING: Score = crate::value::CHECKMATE_THRESHOLD + 1; diff --git a/arche-core/src/strategy.rs b/arche-core/src/strategy.rs index 742df703..7dfe7787 100644 --- a/arche-core/src/strategy.rs +++ b/arche-core/src/strategy.rs @@ -11,7 +11,7 @@ //! points. A fixed depth and a fixed table make the total exact and the same //! on any machine, which is what lets it gate rather than only report. -use crate::bench::{Position, parse_epd}; +use crate::bench::{Position, parse_epd, share}; use crate::engine::SearchConfig; use crate::tactics; use std::fmt; @@ -93,11 +93,7 @@ pub struct ThemeReport { impl ThemeReport { /// The share of the theme's points taken, as a percentage. pub fn share(&self) -> f64 { - if self.available == 0 { - 0.0 - } else { - 100.0 * f64::from(self.scored) / f64::from(self.available) - } + share(u64::from(self.scored), u64::from(self.available)) } } @@ -128,11 +124,7 @@ impl Report { } pub fn share(&self) -> f64 { - if self.available() == 0 { - 0.0 - } else { - 100.0 * f64::from(self.scored()) / f64::from(self.available()) - } + share(u64::from(self.scored()), u64::from(self.available())) } } diff --git a/arche-core/src/tactics.rs b/arche-core/src/tactics.rs index 2ff6d88b..e553bd6e 100644 --- a/arche-core/src/tactics.rs +++ b/arche-core/src/tactics.rs @@ -11,7 +11,6 @@ //! than only report. use crate::bench::{Position, parse_epd}; -use crate::board::Board; use crate::engine::{AlphaBeta, Engine, SearchConfig, SearchOutcome, SearchParameters}; /// The depth every position is searched to, chosen from a measurement and @@ -216,8 +215,7 @@ pub fn run_suite( let positions = positions .iter() .map(|position| { - let board = Board::from_fen(&position.fen) - .unwrap_or_else(|e| panic!("suite position {} does not parse: {}", position.id, e)); + let board = position.board("suite"); // whole tokens rather than a fixed width: a promotion is five // characters, and a matcher that sliced four would call a queen // and a knight the same move @@ -255,6 +253,7 @@ pub fn run_suite( #[cfg(test)] mod tests { use super::*; + use crate::board::Board; #[test] fn every_position_parses_and_names_a_move() { diff --git a/arche-core/src/tune.rs b/arche-core/src/tune.rs index 1baa6982..6df1bfa7 100644 --- a/arche-core/src/tune.rs +++ b/arche-core/src/tune.rs @@ -140,10 +140,7 @@ impl Terms { // the sign the evaluation applies at the end is folded into every // coefficient. The divide truncates toward zero, which is odd, so a // sign inside it and a sign outside it give the same integer - let mover = match board.active_color { - Color::White => 1, - Color::Black => -1, - }; + let mover = board.active_color.sign(); let mut coefficients = [0_i32; SLOTS]; let mut occupied = board.occupied(); while occupied != 0 { @@ -152,11 +149,7 @@ impl Terms { let Some((piece, color)) = board.get_piece_and_color_index(index) else { continue; }; - let sign = mover - * match color { - Color::White => 1, - Color::Black => -1, - }; + let sign = mover * color.sign(); coefficients[MATERIAL_SLOT + piece as usize] += sign; // white reads the tables mirrored and black as written, so the // slot is named by the table's own index @@ -362,8 +355,7 @@ pub fn run(positions: &[Position], suite: Option<&str>) -> Report { rows: Vec::new(), }; for position in positions { - let board = Board::from_fen(&position.fen) - .unwrap_or_else(|e| panic!("terms position {} does not parse: {}", position.id, e)); + let board = position.board("terms"); if board.in_check() { report.in_check += 1; continue; From cbb986bc6695089aa37dbb96fd9f46177f31bf43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:54:44 +0000 Subject: [PATCH 10/10] test(bench): Share the recorders' arming fixture and the terms tests' hand count helpers The census, residual and effort recorders each armed the shared fixture with the same two closures, a reservoir at rate one with the default cap and a disarm that counts what it kept. The fixture now has a generic form, `reservoir_leaves_the_search_where_it_was::(depth, config)`, that writes them once, and the closure form stays for the ledger and the forced instrument, which arm differently. The configuration is a parameter, so the effort instrument's baseline test, which repeated the fixture's loop under a switch off, is one call of the same function. In tune.rs the tests that hand count a term's coefficients each copied the slot lookup, the phase guard and the loop over both ends of the taper, and the four mirrored position tests were one test with different fens. Each is a helper now (`coefficient`, `phase_off_the_middle`, `both_ends_hold`, `mirrored_rows_agree`) and the hand counts and their reasons stay in the tests. The side to move check walks the same positions as `a_positions_terms_reconstruct_its_evaluation`, so it is a second assertion in that test's loop rather than a second walk of every_shape(). In residual.rs three tests wrote a `Report` literal that `report_of` builds, and the suite header test ran two depth two searches with a full replay to read one word of the header. It now runs one search at depth one with a cap of nothing, which still carries the name from the run to the header, and reads the bench shaped header off a made up report. The four made up rows of the row label test were the rows of the summary test, so their label assertions are made there, on named rows, before the counts; nothing is dropped. The bench's second audited run moves into the test that compares an audited run with a plain one, as one more assertion, which saves a search. The effort switch test reads its three outcome counts off `summaries()` instead of counting the rows by hand. No test is weaker: every assertion that was made is still made, and the test names docs/INSTRUMENTS.md cites are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm --- arche-core/src/bench.rs | 15 +- arche-core/src/census.rs | 17 +- arche-core/src/effort.rs | 62 ++---- arche-core/src/recorder.rs | 43 +++- arche-core/src/residual.rs | 83 +++---- arche-core/src/tune.rs | 439 +++++++++++++++---------------------- 6 files changed, 267 insertions(+), 392 deletions(-) diff --git a/arche-core/src/bench.rs b/arche-core/src/bench.rs index eadf776b..6a2f47a8 100644 --- a/arche-core/src/bench.rs +++ b/arche-core/src/bench.rs @@ -695,6 +695,8 @@ mod tests { } } + /// Two audited runs of it count the same, since the audit reads the + /// search and nothing about the run. #[test] fn an_audited_run_searches_the_same_tree() { let suite = small_suite(); @@ -710,6 +712,9 @@ mod tests { .collect::>() }; assert_eq!(played(&plain), played(&audited)); + let again = run_audited_suite(&suite, Reach::Depth(4), 4 << 20, SearchConfig::default()) + .expect("the keys"); + assert_eq!(audited.signatures(), again.signatures()); } /// The false accepts are pinned at zero rather than bounded. The @@ -807,16 +812,6 @@ mod tests { assert!(none.narrow().all(|(_, _, expected)| expected == 0.0)); } - #[test] - fn two_audited_runs_count_the_same() { - let suite = small_suite(); - let first = run_audited_suite(&suite, Reach::Depth(4), 4 << 20, SearchConfig::default()) - .expect("keys"); - let second = run_audited_suite(&suite, Reach::Depth(4), 4 << 20, SearchConfig::default()) - .expect("keys"); - assert_eq!(first.signatures(), second.signatures()); - } - /// A deliberate change to the search is expected to move these: update /// them in the same commit, from `arche bench`, so the diff states how /// much of each tree the engine now looks at. diff --git a/arche-core/src/census.rs b/arche-core/src/census.rs index 00534c6e..240e5d39 100644 --- a/arche-core/src/census.rs +++ b/arche-core/src/census.rs @@ -374,8 +374,8 @@ mod tests { use super::*; use crate::board::Board; use crate::misc::Piece; - use crate::recorder::fixtures::{recording_leaves_the_search_where_it_was, suite}; - use crate::recorder::{DEFAULT_CAP, Sampler}; + use crate::recorder::DEFAULT_CAP; + use crate::recorder::fixtures::{reservoir_leaves_the_search_where_it_was, suite}; fn quiet(from: u8, to: u8) -> Play { Play::new(from, to, None, None, false, false) @@ -715,18 +715,7 @@ mod tests { #[test] fn recording_leaves_the_measured_search_where_it_was() { - recording_leaves_the_search_where_it_was( - 4, - |engine| engine.arm(Sampler::::with_cap(1, DEFAULT_CAP)), - |engine| { - engine - .disarm::() - .expect("the sampler comes back") - .drain() - .taken - .len() - }, - ); + reservoir_leaves_the_search_where_it_was::(4, SearchConfig::default()); } #[test] diff --git a/arche-core/src/effort.rs b/arche-core/src/effort.rs index 158ffa6d..953239a0 100644 --- a/arche-core/src/effort.rs +++ b/arche-core/src/effort.rs @@ -663,9 +663,8 @@ impl fmt::Display for Report { #[cfg(test)] mod tests { use super::*; - use crate::board::Board; use crate::recorder::DEFAULT_CAP; - use crate::recorder::fixtures::{recording_leaves_the_search_where_it_was, suite}; + use crate::recorder::fixtures::{reservoir_leaves_the_search_where_it_was, suite}; use pretty_assertions::assert_eq; #[test] @@ -1046,14 +1045,13 @@ mod tests { report.positions.iter().any(|p| p.nodes_on < p.nodes_off), "the switch removed nothing" ); - let mut outcomes = (0, 0, 0); - for row in &report.rows { - match row.outcome { - Outcome::Both => outcomes.0 += 1, - Outcome::OnlyOn => outcomes.1 += 1, - Outcome::OnlyOff => outcomes.2 += 1, - } - } + let summaries = report.summaries(); + let counted = |of: fn(&Summary) -> usize| summaries.iter().map(of).sum::(); + let outcomes = ( + counted(|s| s.both), + counted(|s| s.only_on), + counted(|s| s.only_off), + ); // all three, the created population included assert!( outcomes.0 > 0 && outcomes.1 > 0 && outcomes.2 > 0, @@ -1144,18 +1142,7 @@ mod tests { #[test] fn recording_leaves_the_measured_search_where_it_was() { - recording_leaves_the_search_where_it_was( - 4, - |engine| engine.arm(Sampler::::with_cap(1, DEFAULT_CAP)), - |engine| { - engine - .disarm::() - .expect("the reservoir comes back") - .drain() - .taken - .len() - }, - ); + reservoir_leaves_the_search_where_it_was::(4, SearchConfig::default()); } /// The same, under a baseline configuration: this is the one instrument @@ -1163,31 +1150,12 @@ mod tests { /// pinned for a switch off, or every arm would edit it. #[test] fn recording_changes_nothing_under_the_baseline_configuration_either() { - let config = SearchConfig::without("quiet_futility") - .expect("a switch of the table") - .config(); - let mut kept = 0; - for position in suite() { - let board = Board::from_fen(&position.fen).unwrap(); - let searched = |engine: &mut AlphaBeta| match engine - .iterative_deepening_search(SearchParameters::to_depth(4), |_, _, _, _| {}) - { - SearchOutcome::Complete(result, _) => result.nodes, - other => panic!("{}: {:?}", position.id, other), - }; - let mut plain = AlphaBeta::with_config(board.clone(), bench::TABLE_BYTES, config); - let plain_nodes = searched(&mut plain); - let mut armed = AlphaBeta::with_config(board, bench::TABLE_BYTES, config); - armed.arm(Sampler::::with_cap(1, DEFAULT_CAP)); - assert_eq!(searched(&mut armed), plain_nodes, "{}", position.id); - kept += armed - .disarm::() - .expect("the reservoir comes back") - .drain() - .taken - .len(); - } - assert!(kept > 0, "the armed runs recorded nothing"); + reservoir_leaves_the_search_where_it_was::( + 4, + SearchConfig::without("quiet_futility") + .expect("a switch of the table") + .config(), + ); } #[test] diff --git a/arche-core/src/recorder.rs b/arche-core/src/recorder.rs index c2196066..a146c1f3 100644 --- a/arche-core/src/recorder.rs +++ b/arche-core/src/recorder.rs @@ -321,9 +321,12 @@ pub(crate) fn depths(reached: impl Iterator) -> Vec { /// What the recorders' tests share. #[cfg(test)] pub(crate) mod fixtures { + use super::{DEFAULT_CAP, Sampler}; use crate::bench::{self, Position}; use crate::board::Board; - use crate::engine::{AlphaBeta, Engine, SearchConfig, SearchOutcome, SearchParameters}; + use crate::engine::{ + AlphaBeta, Engine, Recorded, SearchConfig, SearchOutcome, SearchParameters, + }; /// Two positions: enough to record something, few enough for a replay /// to finish inside a test. @@ -341,6 +344,38 @@ pub(crate) mod fixtures { depth: u8, arm: impl Fn(&mut AlphaBeta), take: impl Fn(&mut AlphaBeta) -> usize, + ) { + recording_under(SearchConfig::default(), depth, arm, take); + } + + /// The contract for a reservoir of one kind of record, armed through + /// `Engine::arm` at a rate of one with the default cap, which is how + /// every such recorder asks it. The configuration is the caller's, since + /// one instrument searches under a configuration its caller chose. + pub(crate) fn reservoir_leaves_the_search_where_it_was( + depth: u8, + config: SearchConfig, + ) { + recording_under( + config, + depth, + |engine| engine.arm(Sampler::::with_cap(1, DEFAULT_CAP)), + |engine| { + engine + .disarm::() + .expect("the reservoir comes back") + .drain() + .taken + .len() + }, + ); + } + + fn recording_under( + config: SearchConfig, + depth: u8, + arm: impl Fn(&mut AlphaBeta), + take: impl Fn(&mut AlphaBeta) -> usize, ) { let searched_nodes = |engine: &mut AlphaBeta, id: &str| { let outcome = engine @@ -353,11 +388,9 @@ pub(crate) mod fixtures { let mut kept = 0; for position in &suite() { let board = Board::from_fen(&position.fen).unwrap(); - let mut plain = - AlphaBeta::with_config(board.clone(), bench::TABLE_BYTES, SearchConfig::default()); + let mut plain = AlphaBeta::with_config(board.clone(), bench::TABLE_BYTES, config); let plain_nodes = searched_nodes(&mut plain, &position.id); - let mut armed = - AlphaBeta::with_config(board, bench::TABLE_BYTES, SearchConfig::default()); + let mut armed = AlphaBeta::with_config(board, bench::TABLE_BYTES, config); arm(&mut armed); let armed_nodes = searched_nodes(&mut armed, &position.id); assert_eq!(armed_nodes, plain_nodes, "{}", position.id); diff --git a/arche-core/src/residual.rs b/arche-core/src/residual.rs index 3d90e674..6801655d 100644 --- a/arche-core/src/residual.rs +++ b/arche-core/src/residual.rs @@ -474,8 +474,8 @@ impl fmt::Display for Report { #[cfg(test)] mod tests { use super::*; - use crate::recorder::fixtures::{recording_leaves_the_search_where_it_was, suite}; - use crate::recorder::{DEFAULT_CAP, Sampler}; + use crate::recorder::DEFAULT_CAP; + use crate::recorder::fixtures::{reservoir_leaves_the_search_where_it_was, suite}; /// Scores just inside the mate window, either side of it. const MATING: Score = crate::value::CHECKMATE_THRESHOLD + 1; @@ -623,18 +623,7 @@ mod tests { #[test] fn recording_leaves_the_measured_search_where_it_was() { - recording_leaves_the_search_where_it_was( - 4, - |engine| engine.arm(Sampler::::with_cap(1, DEFAULT_CAP)), - |engine| { - engine - .disarm::() - .expect("the sampler comes back") - .drain() - .taken - .len() - }, - ); + reservoir_leaves_the_search_where_it_was::(4, SearchConfig::default()); } /// A run at rate seven records exactly the events of the rate one run @@ -722,19 +711,6 @@ mod tests { assert_eq!(below.overstating_word(), "held"); } - #[test] - fn a_row_can_cross_without_overstating_and_overstate_without_crossing() { - // beta is 100 in every made up row - let crossed_only = claiming(2, 70, 90); - let overstated_only = claiming(2, 150, 120); - let both = claiming(2, 100, 60); - let neither = claiming(2, 100, 140); - assert!(crossed_only.crossed() && !crossed_only.overstated()); - assert!(!overstated_only.crossed() && overstated_only.overstated()); - assert!(both.crossed() && both.overstated()); - assert!(!neither.crossed() && !neither.overstated()); - } - #[test] fn a_mate_reference_is_overstated_by_a_claim_above_it() { let over_a_mated = claiming(2, 100, MATED); @@ -811,26 +787,28 @@ mod tests { #[test] fn the_header_names_a_suite_that_is_not_the_benchs() { + // depth one with a cap of nothing: the name has to travel from the + // run to the header, and the search and the replay cost nothing let named = run( &suite(), Some("held_out.epd"), - 2, + 1, + 1, 0, - DEFAULT_CAP, SearchConfig::default(), ); assert!( named .to_string() - .starts_with("residuals depth 2 every 1 epd held_out.epd taint"), + .starts_with("residuals depth 1 every 1 cap 0 epd held_out.epd taint"), "{}", named ); - let bench = run(&suite(), None, 2, 0, DEFAULT_CAP, SearchConfig::default()); + let bench = report_of(Vec::new()); assert!( bench .to_string() - .starts_with("residuals depth 2 every 1 taint"), + .starts_with("residuals depth 4 every 1 taint"), "{}", bench ); @@ -911,16 +889,9 @@ mod tests { #[test] fn a_row_reads_left_to_right_with_the_fen_last() { let report = Report { - depth: 4, every: 10, - cap: DEFAULT_CAP, - suite: None, - config: SearchConfig::default(), - positions: 1, events: 40, - overflowed: 0, - unplayable: 0, - rows: vec![Row { + ..report_of(vec![Row { kind: Shortcut::NullMove, depth: 3, window: Window::Zero, @@ -931,7 +902,7 @@ mod tests { claimed: 200, reference: 150, fen: "4k3/8/8/8/8/8/8/4K3 w - - 0 1".to_string(), - }], + }]) }; let text = report.to_string(); let row = text.lines().nth(1).expect("a row"); @@ -1030,16 +1001,20 @@ mod tests { ); } + /// The two labels are independent: a row can cross without overstating + /// and overstate without crossing, and the summary counts them apart. #[test] fn the_summary_counts_the_overstatements_apart_from_the_crossings() { - // one crossed and overstated, one overstated with beta cleared, one - // crossed with the claim under the reference, and one neither - let report = report_of(vec![ - claiming(2, 100, 60), - claiming(2, 150, 120), - claiming(2, 70, 90), - claiming(2, 100, 140), - ]); + // beta is 100 in every made up row + let both = claiming(2, 100, 60); + let overstated_only = claiming(2, 150, 120); + let crossed_only = claiming(2, 70, 90); + let neither = claiming(2, 100, 140); + assert!(both.crossed() && both.overstated()); + assert!(!overstated_only.crossed() && overstated_only.overstated()); + assert!(crossed_only.crossed() && !crossed_only.overstated()); + assert!(!neither.crossed() && !neither.overstated()); + let report = report_of(vec![both, overstated_only, crossed_only, neither]); let summary = report .summary(Shortcut::ReverseFutility, 2) .expect("four rows of the pair"); @@ -1164,16 +1139,8 @@ mod tests { #[test] fn the_header_says_when_the_buffer_or_the_replay_dropped_something() { let mut report = Report { - depth: 4, - every: 1, - cap: DEFAULT_CAP, - suite: None, - config: SearchConfig::default(), - positions: 1, events: 0, - overflowed: 0, - unplayable: 0, - rows: Vec::new(), + ..report_of(Vec::new()) }; let quiet = report.to_string(); assert!(!quiet.contains("overflow"), "{}", quiet); diff --git a/arche-core/src/tune.rs b/arche-core/src/tune.rs index 6df1bfa7..d000c2ca 100644 --- a/arche-core/src/tune.rs +++ b/arche-core/src/tune.rs @@ -487,6 +487,70 @@ mod tests { panic!("no term is called {}", name) } + /// The coefficient a row carries on a slot, zero where it carries none. + fn coefficient(terms: &Terms, slot: usize) -> i32 { + terms + .coefficients + .iter() + .find(|(named, _)| usize::from(*named) == slot) + .map_or(0, |(_, coefficient)| *coefficient) + } + + /// The phase a hand counted position was worked out at, and that it is + /// off the middle of the taper: where the two shares are equal, a + /// coefficient written to the wrong end is the same number. + fn phase_off_the_middle(terms: &Terms, phase: i32) { + assert_eq!(terms.phase, phase); + assert_ne!( + terms.phase, + TOTAL_PHASE - terms.phase, + "the two ends hold the same share here, so this test cannot tell them apart" + ); + } + + /// Each hand count of a term, as `(index, count, why)`, stands on its + /// slot at both ends of the taper: the midgame half scaled by the phase + /// and the endgame half by the rest. + fn both_ends_hold(terms: &Terms, name: &str, counts: &[(usize, i32, &str)]) { + let (start, width) = term(name); + for &(index, count, why) in counts { + assert_eq!( + coefficient(terms, start + index), + count * terms.phase, + "{} midgame", + why + ); + assert_eq!( + coefficient(terms, start + width + index), + count * (TOTAL_PHASE - terms.phase), + "{} endgame", + why + ); + } + } + + /// A position and its reflection with the colours swapped state the same + /// row, coefficient for coefficient, so a term's counts are signed and + /// slotted the same way for both sides. The side to move is reflected + /// too, which is what makes the rows identical rather than opposite. The + /// row has to carry a coefficient of the term, or the test says nothing + /// about it. + fn mirrored_rows_agree(white: &str, black: &str, name: &str) { + let white = Board::from_fen(white).unwrap(); + let black = Board::from_fen(black).unwrap(); + let terms = Terms::of(&white); + assert!( + terms + .coefficients + .iter() + .any(|(slot, _)| usize::from(*slot) >= term(name).0), + "no {} coefficient here, so this test says nothing about one", + name + ); + assert_eq!(terms, Terms::of(&black)); + assert_eq!(eval::eval(&white), eval::eval(&black)); + } + fn every_shape() -> Vec { let mut fens: Vec = fens::CORE.iter().map(|f| f.to_string()).collect(); fens.extend(bench::positions().into_iter().map(|p| p.fen)); @@ -494,26 +558,16 @@ mod tests { fens } + /// The row from the other side to move is its negative, so a row states + /// the evaluation from the side to move. #[test] fn a_positions_terms_reconstruct_its_evaluation() { let fens = every_shape(); assert!(fens.len() > 1_500, "{} positions", fens.len()); for fen in fens { - let board = Board::from_fen(&fen).unwrap_or_else(|e| panic!("{}: {}", fen, e)); - assert_eq!( - reconstruct(&Terms::of(&board)), - eval::eval(&board), - "{}", - fen - ); - } - } - - #[test] - fn a_row_reconstructs_from_the_side_to_move() { - for fen in every_shape() { - let mut board = Board::from_fen(&fen).unwrap(); + let mut board = Board::from_fen(&fen).unwrap_or_else(|e| panic!("{}: {}", fen, e)); let ours = reconstruct(&Terms::of(&board)); + assert_eq!(ours, eval::eval(&board), "{}", fen); board.active_color = !board.active_color; assert_eq!(reconstruct(&Terms::of(&board)), -ours, "{}", fen); } @@ -535,19 +589,7 @@ mod tests { let terms = Terms::of(&board); // a knight and a bishop at one apiece, a rook at two and a queen at // four - assert_eq!(terms.phase, 8); - assert_ne!( - terms.phase, - TOTAL_PHASE - terms.phase, - "the two ends hold the same share here, so this test cannot tell them apart" - ); - let coefficient = |slot: usize| { - terms - .coefficients - .iter() - .find(|(named, _)| usize::from(*named) == slot) - .map_or(0, |(_, coefficient)| *coefficient) - }; + phase_off_the_middle(&terms, 8); for (piece, file, rank) in [ (Piece::Pawn, File::E, 2), (Piece::Knight, File::B, 1), @@ -560,9 +602,14 @@ mod tests { // named by the square black would be on let entry = usize::from(coordinate_to_index(rank, file) ^ 56); let slot = piece as usize * 64 + entry; - assert_eq!(coefficient(slot), terms.phase, "{:?} midgame", piece); assert_eq!( - coefficient(MIDGAME_SLOTS + slot), + coefficient(&terms, slot), + terms.phase, + "{:?} midgame", + piece + ); + assert_eq!( + coefficient(&terms, MIDGAME_SLOTS + slot), TOTAL_PHASE - terms.phase, "{:?} endgame", piece @@ -579,70 +626,38 @@ mod tests { fn every_mobile_piece_writes_both_ends_of_the_taper() { // the piece square test's position, for the same reason: one white // piece of each mobile kind and no black piece to cancel it out - let fen = "7k/8/8/8/8/8/4P3/RNBQK3 w - - 0 1"; - let board = Board::from_fen(fen).unwrap(); + let board = Board::from_fen("7k/8/8/8/8/8/4P3/RNBQK3 w - - 0 1").unwrap(); let terms = Terms::of(&board); - assert_eq!(terms.phase, 8); - assert_ne!( - terms.phase, - TOTAL_PHASE - terms.phase, - "the two ends hold the same share here, so this test cannot tell them apart" + phase_off_the_middle(&terms, 8); + both_ends_hold( + &terms, + "mobility", + &[ + // the knight on b1 has a3, c3 and d2 + (0, 3, "knight"), + // the bishop on c1 has b2 and a3 one way, and d2 out to h6 + // the other + (1, 7, "bishop"), + // the rook on a1 has the a file, and the knight beside it is + // neither scope nor something to see through + (2, 7, "rook"), + // the queen on d1 has the d file, and c2, b3 and a4 the + // other way. The pawn on e2 blocks the diagonal beside that + // one + (3, 10, "queen"), + ], ); - let coefficient = |slot: usize| { - terms - .coefficients - .iter() - .find(|(named, _)| usize::from(*named) == slot) - .map_or(0, |(_, coefficient)| *coefficient) - }; - for (index, count, why) in [ - // the knight on b1 has a3, c3 and d2 - (0, 3, "knight"), - // the bishop on c1 has b2 and a3 one way, and d2 out to h6 the - // other - (1, 7, "bishop"), - // the rook on a1 has the a file, and the knight beside it is - // neither scope nor something to see through - (2, 7, "rook"), - // the queen on d1 has the d file, and c2, b3 and a4 the other - // way. The pawn on e2 blocks the diagonal beside that one - (3, 10, "queen"), - ] { - let (start, width) = term("mobility"); - assert_eq!( - coefficient(start + index), - count * terms.phase, - "{} midgame", - why - ); - assert_eq!( - coefficient(start + width + index), - count * (TOTAL_PHASE - terms.phase), - "{} endgame", - why - ); - } } - /// A position and its reflection with the colours swapped state the same - /// row, coefficient for coefficient, so the mobility counts are signed - /// and slotted the same way for both sides. The side to move is - /// reflected too, which is what makes the rows identical rather than - /// opposite. + /// The mobility counts are signed and slotted the same way for both + /// sides. #[test] fn a_mirrored_position_states_the_same_row() { - let white = Board::from_fen("4k3/pp6/2n5/8/3B4/8/6PP/4K3 w - - 0 1").unwrap(); - let black = Board::from_fen("4k3/6pp/8/3b4/8/2N5/PP6/4K3 b - - 0 1").unwrap(); - let terms = Terms::of(&white); - assert!( - terms - .coefficients - .iter() - .any(|(slot, _)| usize::from(*slot) >= term("mobility").0), - "no mobility coefficient here, so this test says nothing about one" + mirrored_rows_agree( + "4k3/pp6/2n5/8/3B4/8/6PP/4K3 w - - 0 1", + "4k3/6pp/8/3b4/8/2N5/PP6/4K3 b - - 0 1", + "mobility", ); - assert_eq!(terms, Terms::of(&black)); - assert_eq!(eval::eval(&white), eval::eval(&black)); } /// Every piece bearing on the enemy king's ring writes both ends of the @@ -655,66 +670,35 @@ mod tests { // one white piece of each kind that carries a weight, and no black // piece of any of them to cancel a coefficient out. The black king on // g8 has the ring f7, g7, h7, f8 and h8 - let fen = "6k1/R7/4N2Q/8/8/3B4/8/6K1 w - - 0 1"; - let board = Board::from_fen(fen).unwrap(); + let board = Board::from_fen("6k1/R7/4N2Q/8/8/3B4/8/6K1 w - - 0 1").unwrap(); let terms = Terms::of(&board); - assert_eq!(terms.phase, 8); - assert_ne!( - terms.phase, - TOTAL_PHASE - terms.phase, - "the two ends hold the same share here, so this test cannot tell them apart" + phase_off_the_middle(&terms, 8); + both_ends_hold( + &terms, + "king_attack", + &[ + // the knight on e6 has f8 and g7 of its eight + (0, 2, "knight"), + // the bishop on d3 has h7, up e4, f5 and g6 + (1, 1, "bishop"), + // the rook on a7 has f7, g7 and h7 along the rank + (2, 3, "rook"), + // the queen on h6 has h7 and h8 up the file and g7 and f8 up + // the diagonal + (3, 4, "queen"), + ], ); - let coefficient = |slot: usize| { - terms - .coefficients - .iter() - .find(|(named, _)| usize::from(*named) == slot) - .map_or(0, |(_, coefficient)| *coefficient) - }; - for (index, count, why) in [ - // the knight on e6 has f8 and g7 of its eight - (0, 2, "knight"), - // the bishop on d3 has h7, up e4, f5 and g6 - (1, 1, "bishop"), - // the rook on a7 has f7, g7 and h7 along the rank - (2, 3, "rook"), - // the queen on h6 has h7 and h8 up the file and g7 and f8 up the - // diagonal - (3, 4, "queen"), - ] { - let (start, width) = term("king_attack"); - assert_eq!( - coefficient(start + index), - count * terms.phase, - "{} midgame", - why - ); - assert_eq!( - coefficient(start + width + index), - count * (TOTAL_PHASE - terms.phase), - "{} endgame", - why - ); - } } - /// A position and its reflection with the colours swapped state the same - /// row here too, so the king attack counts are signed and slotted the same - /// way for both sides and each side reads the other king's ring. + /// The king attack counts are signed and slotted the same way for both + /// sides, and each side reads the other king's ring. #[test] fn a_mirrored_position_states_the_same_king_attack_row() { - let white = Board::from_fen("4k3/8/2N5/8/8/5b2/8/4K3 w - - 0 1").unwrap(); - let black = Board::from_fen("4k3/8/5B2/8/8/2n5/8/4K3 b - - 0 1").unwrap(); - let terms = Terms::of(&white); - assert!( - terms - .coefficients - .iter() - .any(|(slot, _)| usize::from(*slot) >= term("king_attack").0), - "no king attack coefficient here, so this test says nothing about one" + mirrored_rows_agree( + "4k3/8/2N5/8/8/5b2/8/4K3 w - - 0 1", + "4k3/8/5B2/8/8/2n5/8/4K3 b - - 0 1", + "king_attack", ); - assert_eq!(terms, Terms::of(&black)); - assert_eq!(eval::eval(&white), eval::eval(&black)); } /// A position whose piece square numerator is negative and does not @@ -1152,53 +1136,31 @@ mod tests { /// so that a coefficient written to the wrong end of it shows. #[test] fn every_shelter_count_writes_both_ends_of_the_taper() { - let fen = "1k6/8/8/8/P4ppp/5pPp/5PpP/R2Q2KR w - - 0 1"; - let board = Board::from_fen(fen).unwrap(); + let board = Board::from_fen("1k6/8/8/8/P4ppp/5pPp/5PpP/R2Q2KR w - - 0 1").unwrap(); let terms = Terms::of(&board); - assert_eq!(terms.phase, 8); - assert_ne!( - terms.phase, - TOTAL_PHASE - terms.phase, - "the two ends hold the same share here, so this test cannot tell them apart" + phase_off_the_middle(&terms, 8); + both_ends_hold( + &terms, + "shelter", + &[ + // f2 and h2, and black has nothing on the rank in front of + // b8 + (0, 2, "the pawns one rank ahead"), + // g3, against nothing on a6, b6 or c6 + (1, 1, "the pawns two ranks ahead"), + // the b and c files hold no pawn at all, and none of white's + // three is bare + (2, -2, "the open files"), + // the a file holds a white pawn and no black one + (3, -1, "the half open files"), + // g2, and no white pawn within three ranks of b8 + (4, 1, "the storm one rank ahead"), + // f3 and h3 + (5, 2, "the storm two ranks ahead"), + // f4, g4 and h4 + (6, 3, "the storm three ranks ahead"), + ], ); - let coefficient = |slot: usize| { - terms - .coefficients - .iter() - .find(|(named, _)| usize::from(*named) == slot) - .map_or(0, |(_, coefficient)| *coefficient) - }; - for (index, count, why) in [ - // f2 and h2, and black has nothing on the rank in front of b8 - (0, 2, "the pawns one rank ahead"), - // g3, against nothing on a6, b6 or c6 - (1, 1, "the pawns two ranks ahead"), - // the b and c files hold no pawn at all, and none of white's - // three is bare - (2, -2, "the open files"), - // the a file holds a white pawn and no black one - (3, -1, "the half open files"), - // g2, and no white pawn within three ranks of b8 - (4, 1, "the storm one rank ahead"), - // f3 and h3 - (5, 2, "the storm two ranks ahead"), - // f4, g4 and h4 - (6, 3, "the storm three ranks ahead"), - ] { - let (start, width) = term("shelter"); - assert_eq!( - coefficient(start + index), - count * terms.phase, - "{} midgame", - why - ); - assert_eq!( - coefficient(start + width + index), - count * (TOTAL_PHASE - terms.phase), - "{} endgame", - why - ); - } } /// Each of the eight pawn counts writes its own coefficient at both ends @@ -1214,91 +1176,52 @@ mod tests { /// shows. #[test] fn every_pawn_count_writes_both_ends_of_the_taper() { - let fen = "3k4/P4p2/8/3P2p1/3P4/PP2p2p/1P6/QQ4KR w - - 0 1"; - let board = Board::from_fen(fen).unwrap(); + let board = Board::from_fen("3k4/P4p2/8/3P2p1/3P4/PP2p2p/1P6/QQ4KR w - - 0 1").unwrap(); let terms = Terms::of(&board); - assert_eq!(terms.phase, 10); - assert_ne!( - terms.phase, - TOTAL_PHASE - terms.phase, - "the two ends hold the same share here, so this test cannot tell them apart" + phase_off_the_middle(&terms, 10); + both_ends_hold( + &terms, + "pawn_structure", + &[ + // black's f7, against nothing of white's on its second + (0, -1, "the passers on the second"), + // white's b3, against nothing of black's + (1, 1, "the passers on the third"), + // black's g5 + (2, -1, "the passers on the fourth"), + // white's d5 + (3, 1, "the passers on the fifth"), + // black's e3 and h3 + (4, -2, "the passers on the sixth"), + // white's a7 + (5, 1, "the passers on the seventh"), + // white's d5 and d4, and no black pawn stands alone + (6, 2, "the isolated pawns"), + // white's a7, b3 and d5 each have one behind them + (7, 3, "the doubled pawns"), + ], ); - let coefficient = |slot: usize| { - terms - .coefficients - .iter() - .find(|(named, _)| usize::from(*named) == slot) - .map_or(0, |(_, coefficient)| *coefficient) - }; - for (index, count, why) in [ - // black's f7, against nothing of white's on its second - (0, -1, "the passers on the second"), - // white's b3, against nothing of black's - (1, 1, "the passers on the third"), - // black's g5 - (2, -1, "the passers on the fourth"), - // white's d5 - (3, 1, "the passers on the fifth"), - // black's e3 and h3 - (4, -2, "the passers on the sixth"), - // white's a7 - (5, 1, "the passers on the seventh"), - // white's d5 and d4, and no black pawn stands alone - (6, 2, "the isolated pawns"), - // white's a7, b3 and d5 each have one behind them - (7, 3, "the doubled pawns"), - ] { - let (start, width) = term("pawn_structure"); - assert_eq!( - coefficient(start + index), - count * terms.phase, - "{} midgame", - why - ); - assert_eq!( - coefficient(start + width + index), - count * (TOTAL_PHASE - terms.phase), - "{} endgame", - why - ); - } } - /// A position and its reflection with the colours swapped state the same - /// pawn row, so the eight counts are signed and slotted the same way for - /// both sides. + /// The eight pawn counts are signed and slotted the same way for both + /// sides. #[test] fn a_mirrored_position_states_the_same_pawn_row() { - let white = Board::from_fen("4k3/P4p2/8/3P2p1/3P4/PP2p2p/1P6/4K3 w - - 0 1").unwrap(); - let black = Board::from_fen("4k3/1p6/pp2P2P/3p4/3p2P1/8/p4P2/4K3 b - - 0 1").unwrap(); - let terms = Terms::of(&white); - assert!( - terms - .coefficients - .iter() - .any(|(slot, _)| usize::from(*slot) >= term("pawn_structure").0), - "no pawn structure coefficient here, so this test says nothing about one" + mirrored_rows_agree( + "4k3/P4p2/8/3P2p1/3P4/PP2p2p/1P6/4K3 w - - 0 1", + "4k3/1p6/pp2P2P/3p4/3p2P1/8/p4P2/4K3 b - - 0 1", + "pawn_structure", ); - assert_eq!(terms, Terms::of(&black)); - assert_eq!(eval::eval(&white), eval::eval(&black)); } - /// A position and its reflection with the colours swapped state the same - /// shelter row, so the seven counts are signed and slotted the same way - /// for both sides. + /// The seven shelter counts are signed and slotted the same way for both + /// sides. #[test] fn a_mirrored_position_states_the_same_shelter_row() { - let white = Board::from_fen("4k3/pp6/8/8/8/8/3PPP2/4K3 w - - 0 1").unwrap(); - let black = Board::from_fen("4k3/3ppp2/8/8/8/8/PP6/4K3 b - - 0 1").unwrap(); - let terms = Terms::of(&white); - assert!( - terms - .coefficients - .iter() - .any(|(slot, _)| usize::from(*slot) >= term("shelter").0), - "no shelter coefficient here, so this test says nothing about one" + mirrored_rows_agree( + "4k3/pp6/8/8/8/8/3PPP2/4K3 w - - 0 1", + "4k3/3ppp2/8/8/8/8/PP6/4K3 b - - 0 1", + "shelter", ); - assert_eq!(terms, Terms::of(&black)); - assert_eq!(eval::eval(&white), eval::eval(&black)); } }