Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/elo-bench-pr-vs-main.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: ELO Benchmark between PR and base branch
on:
pull_request:
types: [opened, reopened, labeled]
types: [opened, reopened, labeled, synchronize]
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -131,7 +131,11 @@ jobs:

- name: Extract benchmark summary
if: github.event_name == 'pull_request'
run: tail -n 10 benchmark_summary.txt > benchmark_for_comment.txt
run: |
tac benchmark_summary.txt | awk '/^---+/{count++} {print} count==2{exit}' | tac > raw_summary.txt
echo '```text' > benchmark_for_comment.txt
cat raw_summary.txt >> benchmark_for_comment.txt
echo '```' >> benchmark_for_comment.txt

- name: Post Benchmark Results to PR
if: github.event_name == 'pull_request'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Thumbs.db
# output files of testing scripts
/testing/config.json
/testing/sprt.pgn
/testing/sprt.log
/testing/current
/testing/previous
/testing/engines
28 changes: 27 additions & 1 deletion src/debug/custom_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,18 @@ pub fn handle_custom_commands(board: &mut Board, command: &str, args: &[&str]) {
println!("Quiets: {quiets:?}");
println!("Captures: {captures:?}");
}
"eval" => println!("Depth 0 Board Evaluation: {}", board.evaluate()),
"eval" => {
println!("Depth 0 Board Evaluation: {}\n", board.evaluate());
let doubled_pawns = board.doubled_pawn_penalties();
println!(
"Doubled Pawn offset: -({} - {}) = {}",
doubled_pawns[0],
doubled_pawns[1],
-(doubled_pawns[0] - doubled_pawns[1])
);

println!("Passed Pawn eval: {}", board.pawn_structure());
}
"do" => {
let mv_str: &str = args[0];
let mv = DecodedMove::from_coords(mv_str, board);
Expand Down Expand Up @@ -77,6 +88,21 @@ pub fn handle_custom_commands(board: &mut Board, command: &str, args: &[&str]) {
"occupied" => {
println!("{:?}", board.occupied());
}
"openfiles" => {
println!("{:?}", board.open_files());
}
"passedmask" => {
let square_str: &str = args[0];
let color_str: &str = args[1];
let bit = Bit::from_coords(square_str).unwrap();
let color = match color_str {
"white" | "w" => Color::White,
"black" | "b" => Color::Black,
_ => panic!("illegal color")
};
let passed_pawn_mask = Bitboard::passed_pawn_mask(bit, color);
println!("{passed_pawn_mask:?}");
}
"hash" => {
println!("{:?}", board.hash());
}
Expand Down
129 changes: 123 additions & 6 deletions src/evaluation.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::prelude::*;
use crate::{prelude::*, settings};

pub const MATE_SCORE: i32 = 30_000;

Expand All @@ -19,6 +19,24 @@ const GAMEPHASE_INC: [i32; 12] = [
0, 0, KNIGHT, KNIGHT, BISHOP, BISHOP, ROOK, ROOK, QUEEN, QUEEN, 0, 0,
];

// TODO: probably interpolation of these values between MG and EG makes sense
// rooks on open files are a rather weak positional idea so this should be kept pretty low
// Additionally, Endgames typically have a lot of open files, so there's no benefit to occupying one (hence 0 EG score)
// format: [MG, EG]
const ROOK_OPEN_FILE_BONUS: [i32; 2] = [25, 0];

// a doubled pawn should be worth about half a pawn
// For now we just linearly scale this; may be worth tho looking at punishing tripled pawns harder than doubled pawns
const DOUBLED_PAWN_PENALTY: i32 = 20;

// these are derived by Sebastian Lague
// TODO: he said "these are pulled from thin air" so... needs texel tuning haha
const PASSED_PAWN_BONUS: [i16; 8] = [0, 15, 15, 25, 40, 60, 80, 0];
// const ISOLATED_PAWN_PENALTY: [i32; 8]

// format: [MG, EG]
// PASSED_PAWN_BONUS: [i32; 2] =

// Flips square index to flip rows but keep columns the same
// e.g. a1 becomes a8; e4 -> e5
const fn flip(sq: usize) -> usize {
Expand Down Expand Up @@ -137,25 +155,37 @@ pub const EG_BASE_POSITION_TABLE: [[i32; 64]; 6] = [
];

/// Evaluates the board
/// positive -> advantage for white,
/// negative -> advantage for black,
/// Uses Piece-Square Tables a base, and augments values of individual pieces as fitting.
/// Concepts that aren't specific to a certain piece (e.g. doubled pawns) are evaluated seperately and added to the augmented PSQT score.
/// Throughout the function, scores are to be interpreted as follows:
/// - positive -> advantage for white,
/// - negative -> advantage for black,
///
/// !! Return type is relativized for the current player for negamax search
/// Unit = Centipawns, 100 Centipawns => 1 Pawn
impl Board {
pub fn evaluate(&self) -> i32 {
let white = 0usize;
let black = 1usize;
let mut mg = [0i32; 2];
let mut eg = [0i32; 2];
let open_files = self.open_files();

let mut phase = TOTAL;
for i in 0..=11 {
let mut bb = self.figure_bb_by_index(i);
// println!("figure: {:?}", Figure::from_idx(i));
for bit in bb.iter_mut() {
// rooks on open files
if settings::ROOKS_OPEN_FILES
&& (i == 6 || i == 7)
&& open_files.is_position_set(bit)
{
mg[i & 1] += ROOK_OPEN_FILE_BONUS[0];
eg[i & 1] += ROOK_OPEN_FILE_BONUS[1];
}
let square = bit.to_square();
mg[i & 1] += MG_TABLE[i][square];
eg[i & 1] += EG_TABLE[i][square];
// println!("blended: {}", (MG_TABLE[i][square] * (256 - 224) + EG_TABLE[i][square] * 224 >> 8));
phase -= GAMEPHASE_INC[i];
}
}
Expand All @@ -172,6 +202,93 @@ impl Board {
Black => -1,
};

((mg_score * (256 - gamephase) + eg_score * gamephase) >> 8) * current_color_multiplier
// Final aggregation of scoring aspects
let mut score = (mg_score * (256 - gamephase) + eg_score * gamephase) >> 8;

if settings::DOUBLED_PAWNS {
let doubled_pawns = self.doubled_pawn_penalties();
score -= doubled_pawns[white] - doubled_pawns[black];
}

if settings::PASSED_PAWNS {
score += i32::from(self.pawn_structure());
}

score * current_color_multiplier
}

pub fn pawn_structure(&self) -> i16 {
// from white's perspective
let mut pawn_bonus = 0;

// white pawns -> add
for pawn in self.figure_bb_by_index(0).iter_mut() {
println!("{}: {}", pawn.to_coords(), self.passed_pawn_bonus(pawn, Color::White));
pawn_bonus += self.passed_pawn_bonus(pawn, Color::White);
}

// black pawns -> subtract
for pawn in self.figure_bb_by_index(1).iter_mut() {
println!("{}: -{}", pawn.to_coords(), self.passed_pawn_bonus(pawn, Color::Black));
pawn_bonus -= self.passed_pawn_bonus(pawn, Color::Black);
}

pawn_bonus
}

fn passed_pawn_bonus(&self, pawn: Bit, friendly: Color) -> i16 {
let opponent_pawns = self.figure_bb(!friendly, Piece::Pawn);
let scan_mask = Bitboard::passed_pawn_mask(pawn, friendly);

let idx = match friendly {
White => pawn.to_y(),
Black => 7 - pawn.to_y(),
};

i16::from((scan_mask & opponent_pawns).is_empty()) * PASSED_PAWN_BONUS[idx]
}

/// Calculate the penalties for doubled pawns for both white and black
/// returns: 2-element array: `[white_penalty, black_penalty]`
///
/// Note: penalties are positive for both sides
/// TODO: this can probably be improved by weighing it against the remaining pawns (the less pawns are on the board, the worse it is if they're doubled)
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
pub fn doubled_pawn_penalties(&self) -> [i32; 2] {
let white_pawns = self.figure_bb_by_index(0);
let black_pawns = self.figure_bb_by_index(1);
// [white, black]
let mut penalties: [i32; 2] = [0i32; 2];
for i in 0..=7 {
let file = Bitboard::file(i);
let file_pawns = [(file & white_pawns).0, (file & black_pawns).0];
// x & (x - 1) flips the lowest set bit -> essentially "removing" one pawn from the file
// we explicitly allow overflows to deal with the case where there's 0 pawns
let file_pawns = [
(file_pawns[0] & file_pawns[0].wrapping_sub(1)).count_ones() as i32,
(file_pawns[1] & file_pawns[1].wrapping_sub(1)).count_ones() as i32,
];

penalties[0] += file_pawns[0] * DOUBLED_PAWN_PENALTY;
penalties[1] += file_pawns[1] * DOUBLED_PAWN_PENALTY;
}

penalties
}

// pub fn passed_pawn_bonus(&self) -> [i32]

/// Calculate a bitboard marking open files (files without any pawns on them)
pub fn open_files(&self) -> Bitboard {
let pawn_structure =
self.figure_bb(Color::White, Piece::Pawn) | self.figure_bb(Color::Black, Piece::Pawn);
let mut open_files = Bitboard::EMPTY;
for i in 0..=7 {
let file = Bitboard::file(i);
if (file & pawn_structure).is_empty() {
open_files += file;
}
}
open_files
}
}
3 changes: 3 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ pub const PVS: bool = cfg!(feature = "pvs");
pub const KILLERS: bool = cfg!(feature = "killers");
pub const HISTORIES: bool = cfg!(feature = "histories");
pub const LMR: bool = cfg!(feature = "lmr");
pub const ROOKS_OPEN_FILES: bool = true;
pub const DOUBLED_PAWNS: bool = true;
pub const PASSED_PAWNS: bool = false;

// These can be tweaked, have an effect on elo
pub const QS_CHECK_EVASION_LIMIT: usize = 2;
Expand Down
69 changes: 65 additions & 4 deletions src/types/bitboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ use std::{
#[derive(Copy, Clone, Eq, PartialEq)]
pub struct Bitboard(pub u64);

impl<T> std::ops::Shl<T> for Bitboard
where
u64: std::ops::Shl<T, Output = u64>,
{
type Output = Self;

fn shl(self, rhs: T) -> Self::Output {
Self(self.0 << rhs)
}
}
impl<T> std::ops::Shr<T> for Bitboard
where
u64: std::ops::Shr<T, Output = u64>,
{
type Output = Self;

fn shr(self, rhs: T) -> Self::Output {
Self(self.0 >> rhs)
}
}

impl std::ops::AddAssign for Bitboard {
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}

impl fmt::Debug for Bitboard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f)?; // Start with a newline for better formatting in some contexts
Expand Down Expand Up @@ -53,6 +80,31 @@ impl Bitboard {
self.0 ^= 1 << square.i();
}

#[allow(clippy::unreadable_literal)]
#[inline]
pub const fn file(file_idx: i16) -> Self {
debug_assert!(file_idx <= 7);
// 0x0101010101010101 is the A file
// shift it to get other files
Self(0x0101010101010101 << file_idx)
}

#[inline]
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
pub fn passed_pawn_mask(bit: Bit, color: Color) -> Self {
let (x, y) = (bit.to_x() as i16, bit.to_y() as u16);

let relevant_files =
Self::file(x) | Self::file((x - 1).max(0)) | Self::file((x + 1).min(7));

// Note: & 63 ensures we don't exceed the maximum bit shift; allowing compiler optimizations
match color {
White => relevant_files << (((y + 1) * 8) & 63),
Black => relevant_files >> ((8u16.saturating_sub(y) * 8) & 63),
}

}

#[inline]
pub fn pop_lsb_position(&mut self) -> Option<Bit> {
if *self == Self(0) {
Expand All @@ -70,6 +122,7 @@ impl Bitboard {
}

/// Iterator for Bitboard, uses pop lsb to always return
/// Yields only the bits that are set
pub fn iter_mut(&mut self) -> impl Iterator<Item = Bit> + '_ {
std::iter::from_fn(move || self.pop_lsb_position())
}
Expand All @@ -88,10 +141,6 @@ impl Bitboard {
pub const fn is_empty(self) -> bool {
self.0 == 0
}

pub const fn count(self) -> usize {
self.0.count_ones() as usize
}
}

impl Not for Bitboard {
Expand Down Expand Up @@ -192,3 +241,15 @@ impl BitAnd<u64> for Bitboard {
Self(self.0 & rhs)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn print_file_bbs() {
for i in 0..=7 {
print!("{:?}", Bitboard::file(i))
}
}
}
10 changes: 10 additions & 0 deletions src/types/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ impl Color {
White => 1,
}
}


#[inline(always)]
pub fn from_usize(val: usize) -> Self {
match val {
0 => Color::White,
1 => Color::Black,
_ => unreachable!(),
}
}
}

impl Not for Color {
Expand Down
1 change: 1 addition & 0 deletions testing/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
Loading
Loading