diff --git a/.github/workflows/elo-bench-pr-vs-main.yml b/.github/workflows/elo-bench-pr-vs-main.yml index 61323c0..b1aef69 100644 --- a/.github/workflows/elo-bench-pr-vs-main.yml +++ b/.github/workflows/elo-bench-pr-vs-main.yml @@ -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: @@ -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' diff --git a/.gitignore b/.gitignore index b7bae3f..9b47b02 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/debug/custom_commands.rs b/src/debug/custom_commands.rs index aba66b0..4a0ce93 100644 --- a/src/debug/custom_commands.rs +++ b/src/debug/custom_commands.rs @@ -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); @@ -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()); } diff --git a/src/evaluation.rs b/src/evaluation.rs index 70ab186..cd557cc 100644 --- a/src/evaluation.rs +++ b/src/evaluation.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{prelude::*, settings}; pub const MATE_SCORE: i32 = 30_000; @@ -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 { @@ -137,8 +155,13 @@ 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 { @@ -146,16 +169,23 @@ impl Board { 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]; } } @@ -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 } } diff --git a/src/settings.rs b/src/settings.rs index 5b50856..c66c19f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -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; diff --git a/src/types/bitboard.rs b/src/types/bitboard.rs index fd20996..199ca69 100644 --- a/src/types/bitboard.rs +++ b/src/types/bitboard.rs @@ -9,6 +9,33 @@ use std::{ #[derive(Copy, Clone, Eq, PartialEq)] pub struct Bitboard(pub u64); +impl std::ops::Shl for Bitboard +where + u64: std::ops::Shl, +{ + type Output = Self; + + fn shl(self, rhs: T) -> Self::Output { + Self(self.0 << rhs) + } +} +impl std::ops::Shr for Bitboard +where + u64: std::ops::Shr, +{ + 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 @@ -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 { if *self == Self(0) { @@ -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 + '_ { std::iter::from_fn(move || self.pop_lsb_position()) } @@ -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 { @@ -192,3 +241,15 @@ impl BitAnd 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)) + } + } +} diff --git a/src/types/color.rs b/src/types/color.rs index ef45581..cd2e9f5 100644 --- a/src/types/color.rs +++ b/src/types/color.rs @@ -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 { diff --git a/testing/.python-version b/testing/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/testing/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/testing/pgn_to_uci.py b/testing/pgn_to_uci.py new file mode 100644 index 0000000..1c0f529 --- /dev/null +++ b/testing/pgn_to_uci.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Convert PGN (Portable Game Notation) or raw moves to UCI (Universal Chess Interface) notation. +Uses the python-chess library. + +Usage: + python pgn_to_uci.py + python pgn_to_uci.py -f + +Examples: + python pgn_to_uci.py "1. e4 e5 2. Nf3 Nc6" + python pgn_to_uci.py -f game.pgn + cat game.pgn | python pgn_to_uci.py -f - +""" + +import sys +import re +import chess +import chess.pgn +from pathlib import Path +from io import StringIO + + +def extract_moves_from_text(text): + """ + Extract moves from raw algebraic notation text. + Handles formats like: + - "1. e4 e5 2. Nf3" (with move numbers) + - "e4 e5 Nf3" (without move numbers) + """ + # Remove move numbers (e.g., "1.", "2.", etc.) + text_no_numbers = re.sub(r'\d+\.', '', text) + + # Split by whitespace + tokens = text_no_numbers.split() + + # Filter tokens that look like moves (not just numbers or dots) + moves = [] + for token in tokens: + # Skip if it's just a number or empty + if not token or token.isdigit(): + continue + # Keep the token as-is for SAN parsing + moves.append(token) + + return moves + + +def san_to_uci(san_moves): + """ + Convert a list of moves in Standard Algebraic Notation (SAN) to UCI notation. + + Args: + san_moves: List of moves in SAN format + + Returns: + List of moves in UCI format + """ + board = chess.Board() + uci_moves = [] + + for san_move in san_moves: + if not san_move.strip(): + continue + + try: + move = board.parse_san(san_move) + uci_moves.append(move.uci()) + board.push(move) + except (ValueError, chess.IllegalMoveError) as e: + print(f"Warning: Could not parse move '{san_move}': {e}", file=sys.stderr) + continue + + return uci_moves + + +def pgn_to_uci(pgn_content): + """ + Convert PGN content to UCI moves. + Handles both properly formatted PGN and raw move notation. + + Args: + pgn_content: PGN string content + + Returns: + List of tuples (game_number, uci_moves_list) + """ + results = [] + + # Try standard PGN parsing first + games = [] + try: + pgn_io = StringIO(pgn_content) + while True: + game = chess.pgn.read_game(pgn_io) + if game is None: + break + games.append(game) + except Exception: + # If standard parsing fails, try to extract raw moves + games = [] + + # If we got games from standard PGN parsing, use those + if games: + for game_num, game in enumerate(games, 1): + board = game.board() + uci_moves = [] + + for move in game.mainline_moves(): + uci_moves.append(move.uci()) + board.push(move) + + results.append((game_num, uci_moves)) + else: + # Fall back to raw move extraction + san_moves = extract_moves_from_text(pgn_content) + if san_moves: + uci_moves = san_to_uci(san_moves) + results.append((1, uci_moves)) + + return results + + +def main(): + if len(sys.argv) < 2: + print("Usage: python pgn_to_uci.py ") + print(" python pgn_to_uci.py -f ") + print("\nExamples:") + print(' python pgn_to_uci.py "1. e4 e5 2. Nf3"') + print(" python pgn_to_uci.py -f game.pgn") + print(" cat game.pgn | python pgn_to_uci.py -f -") + sys.exit(1) + + pgn_content = None + + # Check if using file input + if sys.argv[1] == "-f": + if len(sys.argv) < 3: + print("Error: -f flag requires a file path argument", file=sys.stderr) + sys.exit(1) + + pgn_source = sys.argv[2] + + # Read PGN content + try: + if pgn_source == "-": + pgn_content = sys.stdin.read() + else: + pgn_path = Path(pgn_source) + if not pgn_path.exists(): + print(f"Error: File '{pgn_source}' not found", file=sys.stderr) + sys.exit(1) + with open(pgn_path, 'r') as f: + pgn_content = f.read() + except Exception as e: + print(f"Error reading input: {e}", file=sys.stderr) + sys.exit(1) + else: + # Use string argument + pgn_content = sys.argv[1] + + # Convert and output + results = pgn_to_uci(pgn_content) + + if not results: + print("No games found in PGN", file=sys.stderr) + sys.exit(1) + + # Output results + for game_num, uci_moves in results: + if len(results) > 1: + print(f"Game {game_num}:") + print(" ".join(uci_moves)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/testing/pyproject.toml b/testing/pyproject.toml new file mode 100644 index 0000000..8d85552 --- /dev/null +++ b/testing/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "testing" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "python-chess>=1.999", +] diff --git a/testing/uv.lock b/testing/uv.lock new file mode 100644 index 0000000..6e1755c --- /dev/null +++ b/testing/uv.lock @@ -0,0 +1,32 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "chess" +version = "1.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/09/7d04d7581ae3bb8b598017941781bceb7959dd1b13e3ebf7b6a2cd843bc9/chess-1.11.2.tar.gz", hash = "sha256:a8b43e5678fdb3000695bdaa573117ad683761e5ca38e591c4826eba6d25bb39", size = 6131385, upload-time = "2025-02-25T19:10:27.328Z" } + +[[package]] +name = "python-chess" +version = "1.999" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/60/7c7d132b6683ff215bf705fc55ffc0240a6cddea89657407ca0a4fb628d0/python-chess-1.999.tar.gz", hash = "sha256:8cad0388c42242d890ac6368ad64def15cd0165db033df0ad479492e266e5e6c", size = 1453, upload-time = "2020-10-26T11:30:10.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/47/dfebc06e589530691d33c71bc7b3d8d311252b58ae338980fd4327fe77f2/python_chess-1.999-py3-none-any.whl", hash = "sha256:93b562f8f1124cb7bf56fb095e18743758e69dc6a028ccda0badcaa5c59d88c8", size = 1401, upload-time = "2020-10-26T11:30:07.758Z" }, +] + +[[package]] +name = "testing" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-chess" }, +] + +[package.metadata] +requires-dist = [{ name = "python-chess", specifier = ">=1.999" }]