[board, core, position] A simple static evaluation method for scoring positions

Implement a new Evaluator struct that evaluates a Board and returns a score. This
evaluation mechanism uses only a material balance function. It doesn't account
for anything else.

Supporting this, add a Counts struct to the internal piece set structure of a
Board. This struct is responsible for keeping counts of how many pieces of each
shape are on the board for each color. Export a count_piece() method on Board
that returns a count of the number of pieces of a particular color and shape.

Implement a newtype wrapper around i32 called Score that represents the score of
a position in centipawns, i.e. hundredths of a pawn. Add piece values to the
Shape enum.
This commit is contained in:
Eryn Wells 2025-06-20 14:23:57 -07:00
parent 481ae70698
commit 7f25548335
10 changed files with 249 additions and 10 deletions

View file

@ -3,7 +3,7 @@
use crate::{
CastleRights, PieceSet,
display::DiagramFormatter,
piece_sets::{PlacePieceError, PlacePieceStrategy},
piece_sets::{Counter, PlacePieceError, PlacePieceStrategy},
zobrist::{ZobristHash, ZobristState},
};
use chessfriend_bitboard::BitBoard;
@ -219,6 +219,11 @@ impl Board {
removed_piece
}
#[must_use]
pub fn count_piece(&self, piece: &Piece) -> Counter {
self.pieces.count(piece)
}
}
impl Board {

View file

@ -9,9 +9,10 @@ use thiserror::Error;
#[macro_export]
macro_rules! fen {
($fen_string:literal) => {
($fen_string:literal) => {{
use $crate::fen::FromFenStr;
Board::from_fen_str($fen_string)
};
}};
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]

View file

@ -1,8 +1,9 @@
// Eryn Wells <eryn@erynwells.me>
mod counts;
mod mailbox;
use self::mailbox::Mailbox;
use self::{counts::Counts, mailbox::Mailbox};
use chessfriend_bitboard::{BitBoard, IterationDirection};
use chessfriend_core::{Color, Piece, Shape, Square};
use std::{
@ -11,6 +12,8 @@ use std::{
};
use thiserror::Error;
pub(crate) use counts::Counter;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PlacePieceStrategy {
#[default]
@ -29,6 +32,7 @@ pub enum PlacePieceError {
#[derive(Clone, Debug, Default, Eq)]
pub struct PieceSet {
mailbox: Mailbox,
counts: Counts,
color_occupancy: [BitBoard; Color::NUM],
shape_occupancy: [BitBoard; Shape::NUM],
}
@ -36,18 +40,21 @@ pub struct PieceSet {
impl PieceSet {
pub(crate) fn new(pieces: [[BitBoard; Shape::NUM]; Color::NUM]) -> Self {
let mut mailbox = Mailbox::default();
let mut counts = Counts::default();
let mut color_occupancy: [BitBoard; Color::NUM] = Default::default();
let mut shape_occupancy: [BitBoard; Shape::NUM] = Default::default();
for (color_index, color) in Color::iter().enumerate() {
for (color_index, color) in Color::into_iter().enumerate() {
for (shape_index, shape) in Shape::into_iter().enumerate() {
let bitboard = pieces[color_index][shape_index];
color_occupancy[color_index] |= bitboard;
shape_occupancy[shape_index] |= bitboard;
counts.increment(color, shape);
for square in bitboard.occupied_squares(&IterationDirection::default()) {
let piece = Piece::new(*color, shape);
let piece = Piece::new(color, shape);
mailbox.set(piece, square);
}
}
@ -55,6 +62,7 @@ impl PieceSet {
Self {
mailbox,
counts,
color_occupancy,
shape_occupancy,
}
@ -94,6 +102,10 @@ impl PieceSet {
self.mailbox.get(square)
}
pub(crate) fn count(&self, piece: &Piece) -> Counter {
self.counts.get(piece.color, piece.shape)
}
// TODO: Rename this. Maybe get_all() is better?
pub(crate) fn find_pieces(&self, piece: Piece) -> BitBoard {
let color_occupancy = self.color_occupancy[piece.color as usize];
@ -120,6 +132,7 @@ impl PieceSet {
self.color_occupancy[color as usize].set(square);
self.shape_occupancy[shape as usize].set(square);
self.counts.increment(color, shape);
self.mailbox.set(piece, square);
Ok(existing_piece)
@ -127,8 +140,12 @@ impl PieceSet {
pub(crate) fn remove(&mut self, square: Square) -> Option<Piece> {
if let Some(piece) = self.mailbox.get(square) {
self.color_occupancy[piece.color as usize].clear(square);
self.shape_occupancy[piece.shape as usize].clear(square);
let color_index = piece.color as usize;
let shape_index = piece.shape as usize;
self.color_occupancy[color_index].clear(square);
self.shape_occupancy[shape_index].clear(square);
self.counts.decrement(piece.color, piece.shape);
self.mailbox.remove(square);
Some(piece)

View file

@ -0,0 +1,60 @@
// Eryn Wells <eryn@erynwells.me>
use chessfriend_core::{Color, Shape, Square};
pub(crate) type Counter = u8;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct Counts([[Counter; Shape::NUM]; Color::NUM]);
impl Counts {
pub fn get(&self, color: Color, shape: Shape) -> Counter {
self.0[color as usize][shape as usize]
}
pub fn increment(&mut self, color: Color, shape: Shape) {
#[allow(clippy::cast_possible_truncation)]
const SQUARE_NUM: u8 = Square::NUM as u8;
let updated_value = self.0[color as usize][shape as usize] + 1;
if updated_value <= SQUARE_NUM {
self.0[color as usize][shape as usize] = updated_value;
} else {
unreachable!("piece count for {color} {shape} overflowed");
}
}
pub fn decrement(&mut self, color: Color, shape: Shape) {
let count = self.0[color as usize][shape as usize];
if let Some(updated_count) = count.checked_sub(1) {
self.0[color as usize][shape as usize] = updated_count;
} else {
unreachable!("piece count for {color} {shape} underflowed");
}
}
#[cfg(test)]
fn set(&mut self, color: Color, shape: Shape, value: u8) {
self.0[color as usize][shape as usize] = value;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "underflowed")]
fn underflow() {
let mut counts = Counts::default();
counts.decrement(Color::White, Shape::Queen);
}
#[test]
#[should_panic(expected = "overflowed")]
fn overflow() {
let mut counts = Counts::default();
counts.set(Color::White, Shape::Queen, 64);
counts.increment(Color::White, Shape::Queen);
}
}

View file

@ -1,6 +1,6 @@
// Eryn Wells <eryn@erynwells.me>
use crate::Direction;
use crate::{Direction, score::ScoreInner};
use thiserror::Error;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
@ -56,6 +56,14 @@ impl Color {
Color::Black => "black",
}
}
#[must_use]
pub const fn score_factor(self) -> ScoreInner {
match self {
Color::White => 1,
Color::Black => -1,
}
}
}
impl std::fmt::Display for Color {

View file

@ -4,6 +4,7 @@ pub mod colors;
pub mod coordinates;
pub mod pieces;
pub mod random;
pub mod score;
pub mod shapes;
mod macros;

71
core/src/score.rs Normal file
View file

@ -0,0 +1,71 @@
// Eryn Wells <eryn@erynwells.me>
use std::ops::{Add, AddAssign, Mul, Sub, SubAssign};
pub(crate) type ScoreInner = i32;
/// A score for a position in centipawns.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Score(ScoreInner);
impl Score {
#[must_use]
pub const fn zero() -> Self {
Self(0)
}
#[must_use]
pub const fn new(value: ScoreInner) -> Self {
Self(value)
}
}
impl Add for Score {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Score(self.0 + rhs.0)
}
}
impl AddAssign for Score {
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}
impl Sub for Score {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Score(self.0 - rhs.0)
}
}
impl SubAssign for Score {
fn sub_assign(&mut self, rhs: Self) {
self.0 -= rhs.0;
}
}
impl Mul<ScoreInner> for Score {
type Output = Score;
fn mul(self, rhs: ScoreInner) -> Self::Output {
Score(self.0 * rhs)
}
}
impl Mul<Score> for ScoreInner {
type Output = Score;
fn mul(self, rhs: Score) -> Self::Output {
Score(self * rhs.0)
}
}
impl From<ScoreInner> for Score {
fn from(value: ScoreInner) -> Self {
Score(value)
}
}

View file

@ -3,6 +3,8 @@
use std::{array, fmt, slice, str::FromStr};
use thiserror::Error;
use crate::score::Score;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Shape {
Pawn = 0,
@ -71,6 +73,17 @@ impl Shape {
pub fn is_promotable(&self) -> bool {
Self::PROMOTABLE_SHAPES.contains(self)
}
#[must_use]
pub fn score(self) -> Score {
match self {
Shape::Pawn => Score::new(100),
Shape::Knight | Shape::Bishop => Score::new(300),
Shape::Rook => Score::new(500),
Shape::Queen => Score::new(900),
Shape::King => Score::new(20000),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]

View file

@ -0,0 +1,62 @@
// Eryn Wells <eryn@erynwells.me>
use chessfriend_board::Board;
use chessfriend_core::{Color, Piece, Shape, score::Score};
struct Evaluator;
impl Evaluator {
pub fn evaluate_symmetric_unwrapped(board: &Board, color: Color) -> Score {
let material_balance = Self::material_balance(board, color);
let to_move_factor = color.score_factor();
to_move_factor * material_balance
}
/// Evaluate a board using the symmetric evaluation algorithm defined by
/// Claude Shannon.
fn material_balance(board: &Board, color: Color) -> Score {
let other_color = color.other();
Shape::into_iter().fold(Score::zero(), |acc, shape| {
let (active_pieces, other_pieces) = (
board.count_piece(&Piece::new(color, shape)) as i32,
board.count_piece(&Piece::new(other_color, shape)) as i32,
);
let factor = shape.score() * (active_pieces - other_pieces);
acc + factor
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chessfriend_board::fen;
#[test]
fn pawn_material_balance() -> Result<(), Box<dyn std::error::Error>> {
let board = fen!("8/8/8/8/8/3P4/8/8 w - - 0 1")?;
assert_eq!(
Evaluator::material_balance(&board, Color::White),
100i32.into()
);
let board = fen!("8/8/3p4/8/8/3P4/8/8 w - - 0 1")?;
assert_eq!(Evaluator::material_balance(&board, Color::White), 0.into());
Ok(())
}
#[test]
fn starting_position_is_even() {
let board = Board::starting(None);
assert_eq!(
Evaluator::evaluate_symmetric_unwrapped(&board, Color::White),
Evaluator::evaluate_symmetric_unwrapped(&board, Color::Black)
);
}
}

View file

@ -1,11 +1,12 @@
// Eryn Wells <eryn@erynwells.me>
mod evaluation;
mod position;
#[macro_use]
mod macros;
pub use chessfriend_board::{fen, PlacePieceError, PlacePieceStrategy};
pub use chessfriend_board::{PlacePieceError, PlacePieceStrategy, fen};
pub use chessfriend_moves::{GeneratedMove, ValidateMove};
pub use position::Position;