diff --git a/arche-core/src/bench.rs b/arche-core/src/bench.rs index 25f92ee2..6a2f47a8 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 { @@ -685,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(); @@ -700,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 @@ -797,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/board.rs b/arche-core/src/board.rs index 813341a8..40780159 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); } } @@ -1637,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); @@ -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]; @@ -1821,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), } @@ -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()) @@ -2162,18 +2152,17 @@ 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"), }; 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. @@ -2821,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(); @@ -2830,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()); @@ -2845,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); @@ -2865,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)); @@ -2877,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)); } @@ -3332,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"); } } @@ -4082,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"); } } @@ -4418,7 +4343,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 +4508,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/census.rs b/arche-core/src/census.rs index 67fe61fe..240e5d39 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 {}", @@ -380,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) @@ -721,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 73d56926..953239a0 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 { @@ -664,7 +664,7 @@ impl fmt::Display for Report { mod tests { use super::*; 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] @@ -1045,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, @@ -1143,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 @@ -1162,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/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/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/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 27d72a09..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]; @@ -125,14 +115,11 @@ 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. +/// 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] } @@ -454,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 @@ -534,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}; @@ -546,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. @@ -1066,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/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/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/misc.rs b/arche-core/src/misc.rs index 7084e248..84fb8b7f 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)] @@ -253,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', @@ -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. @@ -330,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, @@ -348,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), @@ -395,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) } } @@ -413,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)) } } @@ -434,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/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..6f6a760c 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, @@ -52,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(()) diff --git a/arche-core/src/psqt.rs b/arche-core/src/psqt.rs index b6f9ca63..fe9e32a7 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)), @@ -536,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 @@ -648,20 +632,8 @@ 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 `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/recorder.rs b/arche-core/src/recorder.rs index 89a0a192..a146c1f3 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,12 +288,45 @@ 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 { + 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. @@ -313,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 @@ -325,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/reduction.rs b/arche-core/src/reduction.rs index 81778062..95a2a9bf 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 @@ -582,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. @@ -623,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] diff --git a/arche-core/src/residual.rs b/arche-core/src/residual.rs index 6fd112e6..6801655d 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; + 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; @@ -627,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 @@ -726,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); @@ -815,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 ); @@ -915,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, @@ -935,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"); @@ -1034,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"); @@ -1168,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/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/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, diff --git a/arche-core/src/tune.rs b/arche-core/src/tune.rs index 1baa6982..d000c2ca 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; @@ -495,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)); @@ -502,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); } @@ -543,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), @@ -568,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 @@ -587,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 @@ -663,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 @@ -1160,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 @@ -1222,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)); } } 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. diff --git a/src/instruments.rs b/src/instruments.rs index 84729ee6..d8cc23dc 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)?; @@ -754,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); ( @@ -919,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"), @@ -956,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})"), @@ -1029,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/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..fc1dbdac 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)] @@ -796,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 @@ -857,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) @@ -1411,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); @@ -1432,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); } @@ -2178,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() } @@ -2237,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); @@ -2281,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 @@ -2302,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)); @@ -2408,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(); } @@ -2431,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]