[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.
This commit is contained in:
Eryn Wells 2025-06-05 08:21:32 -07:00
parent 404212363e
commit d7f426697d
11 changed files with 395 additions and 31 deletions

View file

@ -8,4 +8,5 @@ edition = "2021"
[dependencies]
chessfriend_bitboard = { path = "../bitboard" }
chessfriend_core = { path = "../core" }
rand = "0.9.1"
thiserror = "2"

View file

@ -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<Square>,
pub half_move_clock: HalfMoveClock,
pub full_move_number: FullMoveClock,
zobrist_hash: Option<ZobristHash>,
}
impl Board {
/// An empty board
#[must_use]
pub fn empty() -> Self {
Board::default()
pub fn empty(zobrist: Option<Arc<ZobristState>>) -> 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<Arc<ZobristState>>) -> 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<Square>) {
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<Square>) {
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<Option<Piece>, 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<Piece> {
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<u64> {
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)

View file

@ -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

View file

@ -185,7 +185,7 @@ impl FromFenStr for Board {
type Error = FromFenStrError;
fn from_fen_str(string: &str) -> Result<Self, Self::Error> {
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:#?}");
}
}

View file

@ -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;

View file

@ -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)
}};
}

View file

@ -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
}
};
}

239
board/src/zobrist.rs Normal file
View file

@ -0,0 +1,239 @@
// Eryn Wells <eryn@erynwells.me>
//! 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<ZobristState>,
/// 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<ZobristState>) -> 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<ZobristState> {
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<Square>,
new_target: Option<Square>,
) -> 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);
}
}