From d7f426697d56744eddf2bc9946e1f1a64c666c0f Mon Sep 17 00:00:00 2001 From: Eryn Wells Date: Thu, 5 Jun 2025 08:21:32 -0700 Subject: [PATCH] [board, position] Implement Zobrist hashing This change builds on several previous changes to implement Zobrist hashing of the board. This hash can be updated incrementally as changes are made to the board. In order to do that, various properties of the Board struct had to made internal. In the setters and various mutating members of Board, the hash is updated as state changes. The entire hashing mechanism is optional. If no ZobristState is provided when the Board is created, the hash is never computed. Plumb the Zobrist state through Position as well so that clients of Position (the ultimate interface for interacting with the chess engine) can provide global state to the whole engine. The explorer crate gives an example of how this works. Some global state is computed during initialization and then passed to the Position when it's created. --- Cargo.lock | 1 + board/Cargo.toml | 1 + board/src/board.rs | 113 ++++++++++++++++-- board/src/castle/rights.rs | 6 + board/src/fen.rs | 4 +- board/src/lib.rs | 2 + board/src/macros.rs | 19 ++- board/src/sight.rs | 8 +- board/src/zobrist.rs | 239 +++++++++++++++++++++++++++++++++++++ explorer/src/main.rs | 19 +-- position/src/position.rs | 14 +-- 11 files changed, 395 insertions(+), 31 deletions(-) create mode 100644 board/src/zobrist.rs diff --git a/Cargo.lock b/Cargo.lock index bed7c0d..e58f1e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ version = "0.1.0" dependencies = [ "chessfriend_bitboard", "chessfriend_core", + "rand", "thiserror", ] diff --git a/board/Cargo.toml b/board/Cargo.toml index 098d764..90de6eb 100644 --- a/board/Cargo.toml +++ b/board/Cargo.toml @@ -8,4 +8,5 @@ edition = "2021" [dependencies] chessfriend_bitboard = { path = "../bitboard" } chessfriend_core = { path = "../core" } +rand = "0.9.1" thiserror = "2" diff --git a/board/src/board.rs b/board/src/board.rs index 9c465e0..67f7f81 100644 --- a/board/src/board.rs +++ b/board/src/board.rs @@ -4,10 +4,12 @@ use crate::{ castle, display::DiagramFormatter, piece_sets::{PlacePieceError, PlacePieceStrategy}, + zobrist::{ZobristHash, ZobristState}, PieceSet, }; use chessfriend_bitboard::BitBoard; use chessfriend_core::{Color, Piece, Shape, Square, Wing}; +use std::sync::Arc; pub type HalfMoveClock = u32; pub type FullMoveClock = u32; @@ -20,18 +22,26 @@ pub struct Board { en_passant_target: Option, pub half_move_clock: HalfMoveClock, pub full_move_number: FullMoveClock, + zobrist_hash: Option, } impl Board { /// An empty board #[must_use] - pub fn empty() -> Self { - Board::default() + pub fn empty(zobrist: Option>) -> Self { + let mut board = Self { + zobrist_hash: zobrist.map(ZobristHash::new), + ..Default::default() + }; + + board.recompute_zobrist_hash(); + + board } /// The starting position #[must_use] - pub fn starting() -> Self { + pub fn starting(zobrist: Option>) -> Self { const BLACK_PIECES: [BitBoard; Shape::NUM] = [ BitBoard::new(0b0000_0000_1111_1111 << 48), BitBoard::new(0b0100_0010_0000_0000 << 48), @@ -50,10 +60,15 @@ impl Board { BitBoard::new(0b0000_0000_0001_0000), ]; - Self { + let mut board = Self { pieces: PieceSet::new([WHITE_PIECES, BLACK_PIECES]), + zobrist_hash: zobrist.map(ZobristHash::new), ..Default::default() - } + }; + + board.recompute_zobrist_hash(); + + board } } @@ -69,7 +84,12 @@ impl Board { } self.active_color = color; + + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_setting_active_color(color); + } } +} impl Board { #[must_use] @@ -78,7 +98,13 @@ impl Board { } pub fn set_castling_rights(&mut self, rights: castle::Rights) { + if rights == self.castling_rights { + return; + } + + let old_rights = self.castling_rights; self.castling_rights = rights; + self.update_zobrist_hash_castling_rights(old_rights); } #[must_use] @@ -92,15 +118,32 @@ impl Board { } pub fn grant_castling_right(&mut self, color: Color, wing: Wing) { + let old_rights = self.castling_rights; self.castling_rights.grant(color, wing); + self.update_zobrist_hash_castling_rights(old_rights); } pub fn revoke_all_castling_rights(&mut self) { + let old_rights = self.castling_rights; self.castling_rights.revoke_all(); + self.update_zobrist_hash_castling_rights(old_rights); } pub fn revoke_castling_right(&mut self, color: Color, wing: Wing) { + let old_rights = self.castling_rights; self.castling_rights.revoke(color, wing); + self.update_zobrist_hash_castling_rights(old_rights); + } + + fn update_zobrist_hash_castling_rights(&mut self, old_rights: castle::Rights) { + let new_rights = self.castling_rights; + if old_rights == new_rights { + return; + } + + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_modifying_castling_rights(new_rights, old_rights); + } } } @@ -116,11 +159,27 @@ impl Board { } pub fn set_en_passant_target_option(&mut self, square: Option) { + let old_target = self.en_passant_target; self.en_passant_target = square; + self.update_zobrist_hash_en_passant_target(old_target); } pub fn clear_en_passant_target(&mut self) { + let old_target = self.en_passant_target; self.en_passant_target = None; + self.update_zobrist_hash_en_passant_target(old_target); + } + + fn update_zobrist_hash_en_passant_target(&mut self, old_target: Option) { + let new_target = self.en_passant_target; + + if old_target == new_target { + return; + } + + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_setting_en_passant_target(old_target, new_target); + } } } @@ -148,11 +207,28 @@ impl Board { square: Square, strategy: PlacePieceStrategy, ) -> Result, PlacePieceError> { - self.pieces.place(piece, square, strategy) + let place_result = self.pieces.place(piece, square, strategy); + + if let Ok(Some(existing_piece)) = place_result.as_ref() { + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_removing_piece(square, *existing_piece); + zobrist.update_adding_piece(square, piece); + } + } + + place_result } pub fn remove_piece(&mut self, square: Square) -> Option { - self.pieces.remove(square) + let removed_piece = self.pieces.remove(square); + + if let Some(piece) = removed_piece { + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_removing_piece(square, piece); + } + } + + removed_piece } } @@ -209,6 +285,29 @@ impl Board { } } +impl Board { + pub fn zobrist_hash(&self) -> Option { + self.zobrist_hash.as_ref().map(ZobristHash::hash_value) + } + + pub fn recompute_zobrist_hash(&mut self) { + // Avoid overlapping borrows when borrowing zobrist_hash.as_mut() and + // then also borrowing self to update the board hash by computing the + // hash with the static function first, and then setting the hash value + // on the zobrist instance. Unfortuantely this requires unwrapping + // self.zobrist_hash twice. C'est la vie. + + let new_hash = self.zobrist_hash.as_ref().map(|zobrist| { + let state = zobrist.state(); + ZobristHash::compute_board_hash(self, state.as_ref()) + }); + + if let (Some(new_hash), Some(zobrist)) = (new_hash, self.zobrist_hash.as_mut()) { + zobrist.set_hash_value(new_hash); + } + } +} + impl Board { pub fn display(&self) -> DiagramFormatter<'_> { DiagramFormatter::new(self) diff --git a/board/src/castle/rights.rs b/board/src/castle/rights.rs index ccd56c6..b65461b 100644 --- a/board/src/castle/rights.rs +++ b/board/src/castle/rights.rs @@ -30,6 +30,12 @@ impl Rights { } } +impl Rights { + pub(crate) fn as_index(&self) -> usize { + self.0 as usize + } +} + impl Rights { fn flag_offset(color: Color, wing: Wing) -> usize { ((color as usize) << 1) + wing as usize diff --git a/board/src/fen.rs b/board/src/fen.rs index 470675b..12f59ed 100644 --- a/board/src/fen.rs +++ b/board/src/fen.rs @@ -185,7 +185,7 @@ impl FromFenStr for Board { type Error = FromFenStrError; fn from_fen_str(string: &str) -> Result { - let mut board = Board::empty(); + let mut board = Board::empty(None); let mut fields = string.split(' '); @@ -334,7 +334,7 @@ mod tests { #[test] fn from_starting_fen() { let board = fen!("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 0").unwrap(); - let expected = Board::starting(); + let expected = Board::starting(None); assert_eq!(board, expected, "{board:#?}\n{expected:#?}"); } } diff --git a/board/src/lib.rs b/board/src/lib.rs index 451bc23..ae80da8 100644 --- a/board/src/lib.rs +++ b/board/src/lib.rs @@ -8,6 +8,7 @@ pub mod fen; pub mod macros; pub mod movement; pub mod sight; +pub mod zobrist; mod board_provider; mod check; @@ -18,5 +19,6 @@ pub use board_provider::BoardProvider; pub use castle::Parameters as CastleParameters; pub use castle::Rights as CastleRights; pub use piece_sets::{PlacePieceError, PlacePieceStrategy}; +pub use zobrist::ZobristState; use piece_sets::PieceSet; diff --git a/board/src/macros.rs b/board/src/macros.rs index b3baf85..e2786e2 100644 --- a/board/src/macros.rs +++ b/board/src/macros.rs @@ -4,7 +4,7 @@ macro_rules! test_board { ($to_move:ident, [ $($color:ident $shape:ident on $square:ident),* $(,)? ], $en_passant:ident) => { { - let mut board = $crate::Board::empty(); + let mut board = $crate::Board::empty(Some($crate::test_zobrist!())); $(let _ = board.place_piece( chessfriend_core::Piece::new( chessfriend_core::Color::$color, @@ -23,7 +23,7 @@ macro_rules! test_board { }; ($to_move:ident, [ $($color:ident $shape:ident on $square:ident),* $(,)? ]) => { { - let mut board = $crate::Board::empty(); + let mut board = $crate::Board::empty(Some($crate::test_zobrist!())); $(let _ = board.place_piece( chessfriend_core::Piece::new( chessfriend_core::Color::$color, @@ -41,7 +41,7 @@ macro_rules! test_board { }; ($($color:ident $shape:ident on $square:ident),* $(,)?) => { { - let mut board = $crate::Board::empty(); + let mut board = $crate::Board::empty(Some($crate::test_zobrist!())); $(let _ = board.place_piece( chessfriend_core::Piece::new( chessfriend_core::Color::$color, @@ -58,16 +58,25 @@ macro_rules! test_board { }; (empty) => { { - let board = Board::empty(); + let board = Board::empty(Some($crate::test_zobrist!())); println!("{}", board.display()); board } }; (starting) => { { - let board = Board::starting(); + let board = Board::starting(Some($crate::test_zobrist!())); println!("{}", board.display()); board } }; } + +#[macro_export] +macro_rules! test_zobrist { + () => {{ + let mut rng = chessfriend_core::random::RandomNumberGenerator::default(); + let state = $crate::zobrist::ZobristState::new(&mut rng); + std::sync::Arc::new(state) + }}; +} diff --git a/board/src/sight.rs b/board/src/sight.rs index 9a84f7e..81fb120 100644 --- a/board/src/sight.rs +++ b/board/src/sight.rs @@ -228,7 +228,13 @@ mod tests { } }; ($test_name:ident, $piece:expr, $square:expr, $bitboard:expr) => { - sight_test! {$test_name, $crate::Board::empty(), $piece, $square, $bitboard} + sight_test! { + $test_name, + $crate::Board::empty(Some($crate::test_zobrist!())), + $piece, + $square, + $bitboard + } }; } diff --git a/board/src/zobrist.rs b/board/src/zobrist.rs new file mode 100644 index 0000000..39eee98 --- /dev/null +++ b/board/src/zobrist.rs @@ -0,0 +1,239 @@ +// Eryn Wells + +//! This module implements facilities for computing hash values of board +//! positions via the Zobrist hashing algorithm. +//! +//! ## See Also +//! +//! * The Chess Programming Wiki page on [Zobrist Hashing][1] +//! +//! [1]: https://www.chessprogramming.org/Zobrist_Hashing + +use crate::{castle, Board}; +use chessfriend_core::{random::RandomNumberGenerator, Color, File, Piece, Shape, Square, Wing}; +use rand::Fill; +use std::sync::Arc; + +const NUM_SQUARE_PIECE_VALUES: usize = Shape::NUM * Color::NUM * Square::NUM; +const NUM_CASTLING_RIGHTS_VALUES: usize = 16; + +type HashValue = u64; + +type SquarePieceValues = [[[HashValue; Color::NUM]; Shape::NUM]; Square::NUM]; +type CastlingRightsValues = [HashValue; NUM_CASTLING_RIGHTS_VALUES]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ZobristHash { + // TODO: Keep this field in mind if ChessFriend grows threads. It may also + // need a Mutex<>. + state: Arc, + + /// The current hash value. + hash_value: HashValue, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ZobristState { + square_piece_values: SquarePieceValues, + black_to_move_value: HashValue, + castling_rights_values: CastlingRightsValues, + en_passant_file_values: [HashValue; File::NUM], +} + +impl ZobristState { + #[must_use] + pub fn new(rng: &mut RandomNumberGenerator) -> Self { + let square_piece_values = { + let mut values = [[[0; Color::NUM]; Shape::NUM]; Square::NUM]; + for square in Square::ALL { + for shape in Shape::ALL { + for color in Color::ALL { + values[square as usize][shape as usize][color as usize] = rng.next_u64(); + } + } + } + + values + }; + + let mut castling_rights_values: CastlingRightsValues = [0; NUM_CASTLING_RIGHTS_VALUES]; + castling_rights_values.fill(rng.rand()); + + let mut en_passant_file_values = [0; File::NUM]; + en_passant_file_values.fill(rng.rand()); + + Self { + square_piece_values, + black_to_move_value: rng.next_u64(), + castling_rights_values, + en_passant_file_values, + } + } +} + +impl ZobristHash { + #[must_use] + pub fn new(state: Arc) -> Self { + Self { + state, + hash_value: 0, + } + } + + #[must_use] + pub fn hash_value(&self) -> HashValue { + self.hash_value + } + + pub(crate) fn set_hash_value(&mut self, value: HashValue) { + self.hash_value = value; + } + + #[must_use] + pub fn state(&self) -> Arc { + self.state.clone() + } + + #[must_use] + pub fn compute_board_hash(board: &Board, state: &ZobristState) -> HashValue { + let mut hash_value: HashValue = 0; + + for (square, piece) in board.iter() { + hash_value ^= state.square_piece_values[square as usize][piece.shape as usize] + [piece.color as usize]; + } + + if board.active_color() == Color::Black { + hash_value ^= state.black_to_move_value; + } + + if let Some(square) = board.en_passant_target() { + hash_value ^= state.en_passant_file_values[square.file().as_index()]; + } + + hash_value + } + + pub fn recompute_hash(&mut self, board: &Board) -> HashValue { + self.hash_value = Self::compute_board_hash(board, self.state.as_ref()); + self.hash_value + } + + pub fn update_adding_piece(&mut self, square: Square, piece: Piece) -> HashValue { + self.hash_value ^= self.xor_piece_square_operand(square, piece.shape, piece.color); + self.hash_value + } + + pub fn update_removing_piece(&mut self, square: Square, piece: Piece) -> HashValue { + self.hash_value ^= self.xor_piece_square_operand(square, piece.shape, piece.color); + self.hash_value + } + + pub fn update_setting_active_color(&mut self, _color: Color) -> HashValue { + self.hash_value ^= self.state.black_to_move_value; + self.hash_value + } + + pub fn update_modifying_castling_rights( + &mut self, + new_rights: castle::Rights, + old_rights: castle::Rights, + ) -> HashValue { + let state = self.state.as_ref(); + self.hash_value ^= state.castling_rights_values[new_rights.as_index()]; + self.hash_value ^= state.castling_rights_values[old_rights.as_index()]; + self.hash_value + } + + pub fn update_setting_en_passant_target( + &mut self, + old_target: Option, + new_target: Option, + ) -> HashValue { + let state = self.state.as_ref(); + + if let Some(old_target) = old_target { + self.hash_value ^= state.en_passant_file_values[old_target.file().as_index()]; + } + if let Some(new_target) = new_target { + self.hash_value ^= state.en_passant_file_values[new_target.file().as_index()]; + } + + self.hash_value + } + + fn xor_piece_square_operand(&self, square: Square, shape: Shape, color: Color) -> HashValue { + let square = square as usize; + let shape = shape as usize; + let color = color as usize; + self.state.square_piece_values[square][shape][color] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_state() -> ZobristState { + let mut rng = RandomNumberGenerator::default(); + ZobristState::new(&mut rng) + } + + fn test_hash() -> ZobristHash { + ZobristHash::new(Arc::new(test_state())) + } + + #[test] + fn hash_empty_board_ai_claude() { + let state = test_state(); + let board = Board::empty(None); + let hash = ZobristHash::compute_board_hash(&board, &state); + + // Empty board with white to move should only contribute active color + assert_eq!(hash, 0); + } + + #[test] + fn hash_board_with_black_to_move_ai_claude() { + let state = test_state(); + + let mut board = Board::empty(None); + board.set_active_color(Color::Black); + + let hash = ZobristHash::compute_board_hash(&board, &state); + assert_eq!(hash, state.black_to_move_value); + } + + #[test] + fn hash_different_en_passant_files_ai_claude() { + let mut board1 = Board::empty(None); + let mut board2 = Board::empty(None); + + // Different file should produce different hash values, even if ranks + // are the same. + board1.set_en_passant_target(Square::A3); + board2.set_en_passant_target(Square::H3); + + let state = test_state(); + let hash1 = ZobristHash::compute_board_hash(&board1, &state); + let hash2 = ZobristHash::compute_board_hash(&board2, &state); + + assert_ne!(hash1, hash2); + } + + #[test] + fn hash_en_passant_same_file_different_rank_ai_claude() { + let mut board1 = Board::empty(None); + let mut board2 = Board::empty(None); + + // Same file, different ranks should produce same hash (only file matters) + board1.set_en_passant_target(Square::E3); + board2.set_en_passant_target(Square::E6); + + let state = test_state(); + let hash1 = ZobristHash::compute_board_hash(&board1, &state); + let hash2 = ZobristHash::compute_board_hash(&board2, &state); + + assert_eq!(hash1, hash2); + } +} diff --git a/explorer/src/main.rs b/explorer/src/main.rs index 6fc13cf..59b790d 100644 --- a/explorer/src/main.rs +++ b/explorer/src/main.rs @@ -2,14 +2,16 @@ mod make_command; +use chessfriend_board::ZobristState; use chessfriend_board::{fen::FromFenStr, Board}; +use chessfriend_core::random::RandomNumberGenerator; use chessfriend_core::{Color, Piece, Shape, Square}; use chessfriend_moves::{Builder as MoveBuilder, GeneratedMove, MakeMove, ValidateMove}; use chessfriend_position::{fen::ToFenStr, PlacePieceStrategy, Position}; - use clap::{Arg, Command}; use rustyline::error::ReadlineError; use rustyline::DefaultEditor; +use std::sync::Arc; use thiserror::Error; struct CommandResult { @@ -28,6 +30,7 @@ impl Default for CommandResult { struct State { position: Position, + zobrist: Arc, } fn command_line() -> Command { @@ -171,10 +174,6 @@ fn respond(line: &str, state: &mut State) -> anyhow::Result { } Some(("moves", matches)) => result = do_moves_command(state, matches)?, Some(("movement", matches)) => result = do_movement_command(state, matches)?, - Some(("starting", _matches)) => { - let starting_position = Position::starting(); - state.position = starting_position; - } Some(("reset", matches)) => result = do_reset_command(state, matches)?, Some((name, _matches)) => unimplemented!("{name}"), None => unreachable!("Subcommand required"), @@ -188,8 +187,8 @@ fn do_reset_command( matches: &clap::ArgMatches, ) -> anyhow::Result { match matches.subcommand() { - None | Some(("clear", _)) => state.position = Position::empty(), - Some(("starting", _)) => state.position = Position::starting(), + None | Some(("clear", _)) => state.position = Position::empty(Some(state.zobrist.clone())), + Some(("starting", _)) => state.position = Position::starting(Some(state.zobrist.clone())), Some(("fen", matches)) => { let fen = matches .get_one::("fen") @@ -273,9 +272,13 @@ fn do_movement_command( fn main() -> Result<(), String> { let mut editor = DefaultEditor::new().map_err(|err| format!("Error: {err}"))?; - let starting_position = Position::starting(); + let mut rng = RandomNumberGenerator::default(); + let zobrist_state = Arc::new(ZobristState::new(&mut rng)); + + let starting_position = Position::starting(Some(zobrist_state.clone())); let mut state = State { position: starting_position, + zobrist: zobrist_state, }; let mut should_print_position = true; diff --git a/position/src/position.rs b/position/src/position.rs index 8b08bce..30afe0d 100644 --- a/position/src/position.rs +++ b/position/src/position.rs @@ -15,9 +15,10 @@ use captures::CapturesList; use chessfriend_bitboard::BitBoard; use chessfriend_board::{ display::DiagramFormatter, fen::ToFenStr, Board, PlacePieceError, PlacePieceStrategy, + ZobristState, }; use chessfriend_core::{Color, Piece, Shape, Square}; -use std::fmt; +use std::{fmt, sync::Arc}; #[must_use] #[derive(Clone, Debug, Default, Eq)] @@ -28,16 +29,13 @@ pub struct Position { } impl Position { - pub fn empty() -> Self { - Position::default() + pub fn empty(zobrist: Option>) -> Self { + Self::new(Board::empty(zobrist)) } /// Return a starting position. - pub fn starting() -> Self { - Self { - board: Board::starting(), - ..Default::default() - } + pub fn starting(zobrist: Option>) -> Self { + Self::new(Board::starting(zobrist)) } pub fn new(board: Board) -> Self {