diff --git a/.gitignore b/.gitignore index 0b0ee18..2fcfdf3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target +pgo.sh + toad-* \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4abf3bc..4b5cfbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "anstream" @@ -145,9 +145,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -169,9 +169,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.79" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -184,7 +184,16 @@ version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.64", +] + +[[package]] +name = "thiserror" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767" +dependencies = [ + "thiserror-impl 2.0.7", ] [[package]] @@ -198,6 +207,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toad" version = "2.0.0" @@ -205,16 +225,17 @@ dependencies = [ "anyhow", "arrayvec", "clap", + "thiserror 2.0.7", "uci-parser", ] [[package]] name = "uci-parser" version = "0.2.0" -source = "git+https://github.com/dannyhammer/uci-parser.git#aa3298eec590ab463dad5653062c8975fded0bba" +source = "git+https://github.com/dannyhammer/uci-parser.git#8fc724cd30cb136d18050470da9ba93c75082042" dependencies = [ "nom", - "thiserror", + "thiserror 1.0.64", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6b5d479..b264d38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,10 @@ -[package] -name = "toad" -version = "2.0.0" -edition = "2021" -authors = ["Danny Hammer "] -license = "MPL-2.0" -description = "A toy chess engine" -repository = "https://github.com/dannyhammer/toad" -homepage = "https://github.com/dannyhammer/toad" -keywords = ["chess", "chess-engine", "uci"] +[workspace] +resolver = "2" +members = ["toad"] -[dependencies] -anyhow = "1.0.89" -arrayvec = "0.7.6" -clap = { version = "4.5.18", features = ["derive", "string"] } -#uci-parser = { path = "../uci-parser", features = ["parse-go-perft", "parse-position-kiwipete", "clamp-negatives", "err-on-unused-input"] } -uci-parser = { git = "https://github.com/dannyhammer/uci-parser.git", features = ["parse-go-perft", "parse-position-kiwipete", "clamp-negatives", "err-on-unused-input"] } -#uci-parser = { version = "0.2.0", features = ["parse-go-perft", "parse-position-kiwipete", "clamp-negatives", "err-on-unused-input"] } +[profile.release] +panic = 'abort' +strip = true +lto = true +codegen-units = 1 +overflow-checks = true \ No newline at end of file diff --git a/Makefile b/Makefile index 62f697c..4e74679 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ # If on Windows, add the .exe extension to the executable and use PowerShell instead of `sed` ifeq ($(OS),Windows_NT) EXT := .exe - NAME := $(shell powershell -Command "(Get-Content Cargo.toml | Select-String '^name =').Line -replace '.*= ', '' -replace '\"', ''") - VERSION := $(shell powershell -Command "(Get-Content Cargo.toml | Select-String '^version =').Line -replace '.*= ', '' -replace '\"', ''") + NAME := $(shell powershell -Command "(Get-Content toad/Cargo.toml | Select-String '^name =').Line -replace '.*= ', '' -replace '\"', ''") + VERSION := $(shell powershell -Command "(Get-Content toad/Cargo.toml | Select-String '^version =').Line -replace '.*= ', '' -replace '\"', ''") else EXT := - NAME := $(shell sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml) - VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml) + NAME := $(shell sed -n 's/^name = "\(.*\)"/\1/p' toad/Cargo.toml) + VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' toad/Cargo.toml) endif # OpenBench specifies that the binary name should be changeable with the EXE parameter @@ -26,7 +26,7 @@ endif # Compile an executable for use with OpenBench openbench: @echo Compiling $(EXE) for OpenBench - cargo rustc --release --bin toad -- -C target-cpu=native --emit link=$(EXE) + cargo rustc --manifest-path ./toad/Cargo.toml --release --bin toad -- -C target-cpu=native --emit link=$(EXE) # Remove the EXE created clean: diff --git a/README.md b/README.md index 926a8a3..450ac77 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # Toad 🐸 A UCI-compatible toy chess engine Toad is a work-in-progress [chess engine](https://en.wikipedia.org/wiki/Chess_engine), and serves as my personal excuse to write fun code in Rust. -It was [originally](https://github.com/dannyhammer/toad/pull/73) built upon my [`chessie`](https://crates.io/crates/chessie) crate, which is a chess library that handles board representation, move generation and all other rules of chess. Development progress is recorded automatically in the [changelog](./CHANGELOG.md). All progression/non-regression testing is done through [OpenBench](https://github.com/AndyGrant/OpenBench) instance hosted [here](https://pyronomy.pythonanywhere.com/index/). -Strength of the latest version can be found on the [CCRL pages](https://computerchess.org.uk/ccrl/)- just search for `Toad`! +Strength of the latest version can be found on the [CCRL pages](https://computerchess.org.uk/ccrl/404/cgi/compare_engines.cgi?family=Toad&print=Rating+list&print=Results+table&print=LOS+table&print=Ponder+hit+table&print=Eval+difference+table&print=Comopp+gamenum+table&print=Overlap+table&print=Score+with+common+opponents). Up for a game? Play against Toad on [Lichess](https://lichess.org/@/toad-bot)! @@ -18,60 +17,6 @@ To run multiple commands on startup, pass them in with the `-c ""` flag You can pass in the `--no-exit` flag to continue execution after the command(s) have finished executing. Run the engine and execute the `help` command to see a list of available commands, and `--help` to view all CLI flags and arguments. -### UCI Commands - -Toad abides (mostly) by the [Universal Chess Interface](https://backscattering.de/chess/uci/) protocol, and communicates through `stdin` and `stdout`. -The parsing of UCI commands and responses is handled by my [`uci-parser`](https://crates.io/crates/uci-parser) crate. - -The following UCI commands (and arguments) are supported: - -- `uci` -- `debug [ on | off ]` -- `isready` -- `setoption name [value ]` -- `ucinewgame` -- `position [fen | startpos] [moves ... ]` - - Extended to include [`position kiwipete`](https://www.chessprogramming.org/Perft_Results#Position_2). -- `go wtime btime winc binc depth nodes movetime infinite` - - Extended to include `go perft ` -- `stop` -- `quit` - -### Custom Commands - -In addition to the above UCI commands, Toad also supports the following custom commands: - -``` -Commands: - await Await the current search, blocking until it completes - bench Run a benchmark with the provided parameters - changevariant Change the variant of chess being played, or display the current variant - display Print a visual representation of the current board state - eval Print an evaluation of the current position - exit Quit the engine - fen Generate and print a FEN string for the current position - flip Flips the side-to-move. Equivalent to playing a nullmove - hashinfo Display information about the current hash table(s) in the engine - makemove Apply the provided move to the game, if possible - moves Shows all legal moves in the current position, or for a specific piece - option Display the current value of the specified option - perft Performs a perft on the current position at the supplied depth, printing total node count - psqt Outputs the Piece-Square table value for the provided piece at the provided square, scaled with the endgame weight - splitperft Performs a split perft on the current position at the supplied depth - help Print this message or the help of the given subcommand(s) -``` - -For specifics on how a command works, run `toad --help` - -### UCI Options - -| Name | Values | Default | Description | -| -------------- | --------------- | ------- | --------------------------------------------------------------------------------- | -| `Clear Hash` | | | Clear the hash table(s) | -| `Hash` | `1..=1024` | `16` | Set the size (in MB) of the hash table(s) | -| `Threads` | `1..=1` | `1` | Only implemented for use with [OpenBench](https://github.com/AndyGrant/OpenBench) | -| `UCI_Chess960` | `true`, `false` | `false` | Enable support for [Chess960](https://en.wikipedia.org/wiki/Fischer_random_chess) | - ## Running To run Toad, head over to the [releases](https://github.com/dannyhammer/toad/releases) page to grab the latest pre-compiled release for your platform. @@ -108,14 +53,19 @@ If you are willing to test the installation and execution of Toad on other opera - [Time Management](https://www.chessprogramming.org/Time_Management) with soft and hard timeouts. - [Quiescence Search](https://www.chessprogramming.org/Quiescence_Search) in a fail soft framework. - [Draw detection](https://www.chessprogramming.org/Draw) through insufficient material, 2-fold repetition, and the 50-move rule. - - [Transposition Table](https://www.chessprogramming.org/Transposition_Table). + - [Transposition Table](https://www.chessprogramming.org/Transposition_Table) for move ordering and [cutoffs](https://www.chessprogramming.org/Transposition_Table#Transposition_Table_Cutoffs). - [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search). - [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) with [gradual widening](https://www.chessprogramming.org/Aspiration_Windows#Gradual_Widening). - [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning). - [Reverse Futility Pruning](https://www.chessprogramming.org/Reverse_Futility_Pruning). - [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions). - [Check Extensions](https://www.chessprogramming.org/Check_Extensions). - - [Transposition Table Cutoffs](https://www.chessprogramming.org/Transposition_Table#Transposition_Table_Cutoffs). + - [Razoring](https://www.chessprogramming.org/Razoring). + - [Internal Iterative Reductions (Transposition Table Reductions)](https://www.chessprogramming.org/Internal_Iterative_Reductions). + - [Internal Iterative Deepening](https://www.chessprogramming.org/Internal_Iterative_Deepening). + - [Late Move Pruning](https://www.chessprogramming.org/Futility_Pruning#MoveCountBasedPruning). + - [Mate Distance Pruning](https://www.chessprogramming.org/Mate_Distance_Pruning). + - Support for [fractional plies](https://www.chessprogramming.org/Depth#Fractional_Plies) - Move Ordering: - [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) with relative piece values `K < P < N < B < R < Q`, so `KxR` is ordered before `PxR`. - [Hash moves](https://www.chessprogramming.org/Hash_Move). @@ -129,15 +79,72 @@ If you are willing to test the installation and execution of Toad on other opera More features will be added as development continues! You can see most of my future plans in the [backlog](https://github.com/dannyhammer/toad/issues). +### UCI Commands + +Toad abides (mostly) by the [Universal Chess Interface](https://backscattering.de/chess/uci/) protocol, and communicates through `stdin` and `stdout`. +The parsing of UCI commands and responses is handled by my [`uci-parser`](https://crates.io/crates/uci-parser) crate. + +The following UCI commands (and arguments) are supported: + +- `uci` +- `debug [ on | off ]` +- `isready` +- `setoption name [value ]` +- `ucinewgame` +- `position [fen | startpos] [moves ... ]` + - Extended to include [`position kiwipete`](https://www.chessprogramming.org/Perft_Results#Position_2). +- `go wtime btime winc binc depth nodes movetime infinite` + - Extended to include `go perft ` +- `stop` +- `quit` + +### Custom Commands + +In addition to the above UCI commands, Toad also supports the following custom commands: + +``` +Commands: + bench Run a benchmark with the provided parameters + changevariant Change the variant of chess being played, or display the current variant + display Print a visual representation of the current board state + eval Print an evaluation of the current position + exit Quit the engine + fen Generate and print a FEN string for the current position + flip Flips the side-to-move. Equivalent to playing a nullmove + hashinfo Display information about the current hash table(s) in the engine + makemove Apply the provided move to the game, if possible + moves Shows all legal moves in the current position, or for a specific piece + option Display the current value of the specified option + perft Performs a perft on the current position at the supplied depth, printing total node count + place Place a piece on the provided square + psqt Outputs the Piece-Square table value for the provided piece at the provided square, scaled with the endgame weight + splitperft Performs a split perft on the current position at the supplied depth + take Remove the piece at the provided square + wait Await the current search, blocking until it completes + help Print this message or the help of the given subcommand(s) +``` + +For specifics on how a command works, run `toad --help` + +### UCI Options + +| Name | Values | Default | Description | +| -------------- | --------------- | ------- | --------------------------------------------------------------------------------- | +| `Clear Hash` | | | Clear the hash table(s) | +| `Hash` | `1..=1024` | `16` | Set the size (in MB) of the hash table(s) | +| `Threads` | `1..=1` | `1` | Only implemented for use with [OpenBench](https://github.com/AndyGrant/OpenBench) | +| `UCI_Chess960` | `true`, `false` | `false` | Enable support for [Chess960](https://en.wikipedia.org/wiki/Fischer_random_chess) | + ## Acknowledgements More people have helped me on this journey than I can track, but I'll name a few notable resources/people here: -- [Sebastian Lague](https://www.youtube.com/@SebastianLague), for his [chess programming series](https://www.youtube.com/watch?v=_vqlIPDR2TU&list=PLFt_AvWsXl0cvHyu32ajwh2qU1i6hl77c) on YouTube that ultimate inspired me to do this project. +- [Sebastian Lague](https://www.youtube.com/@SebastianLague), for his [chess programming series](https://www.youtube.com/watch?v=_vqlIPDR2TU&list=PLFt_AvWsXl0cvHyu32ajwh2qU1i6hl77c) on YouTube, which was the original inspiration for this project. - The [Chess Programming Wiki](https://www.chessprogramming.org/), and all those who contribute to free, open-source knowledge. - The folks over at the [Engine Programming Discord](https://discord.com/invite/F6W6mMsTGN), for their patience with my silly questions and invaluable help overall. - [Analog-Hors](https://github.com/analog-hors), for an excellent [article on magic bitboards](https://analog-hors.github.io/site/magic-bitboards/) - The authors of [viridithas](https://github.com/cosmobobak/viridithas/) and [Stormphrax](https://github.com/Ciekce/Stormphrax), for allowing their engines to be open source and for answering all my silly questions. - [Andrew Grant](https://github.com/AndyGrant/) for creating [OpenBench](https://github.com/AndyGrant/OpenBench) and being willing to help me with its setup and use. +- All those in the engine testing community, with special thanks for those who manage and host the [CCRL pages](https://computerchess.org.uk/ccrl/). - The authors of [Yukari](https://github.com/yukarichess/yukari) for motivation through friendly competition. - [Paul T](https://github.com/DeveloperPaul123), for feedback on my [`uci-parser`](https://crates.io/crates/uci-parser) crate. diff --git a/src/search.rs b/src/search.rs deleted file mode 100644 index d7a90d7..0000000 --- a/src/search.rs +++ /dev/null @@ -1,1207 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -use std::{ - fmt::{self, Debug}, - marker::PhantomData, - ops::Neg, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::{Duration, Instant}, -}; - -use uci_parser::{UciInfo, UciResponse, UciSearchOptions}; - -use crate::{ - tune, Color, Game, HistoryTable, LogLevel, LoggingLevel, Move, MoveList, Piece, PieceKind, - Position, Score, TTable, TTableEntry, Variant, ZobristKey, -}; - -/// Maximum depth that can be searched -pub const MAX_DEPTH: u8 = u8::MAX / 2; - -/// Minium depth at which null move pruning can be applied. -const MIN_NMP_DEPTH: u8 = tune::min_nmp_depth!(); - -/// Value to subtract from `depth` when applying null move pruning. -const NMP_REDUCTION_VALUE: u8 = tune::nmp_reduction_value!(); - -/// MAximum depth at which to apply reverse futility pruning. -const MAX_RFP_DEPTH: u8 = tune::max_rfp_depth!(); - -/// Minimum depth at which to apply late move reductions. -const MIN_LMR_DEPTH: u8 = tune::min_lmr_depth!(); - -/// Minimum moves that must be made before late move reductions can be applied. -const MIN_LMR_MOVES: usize = tune::min_lmr_moves!(); - -/// Base value in the LMR formula. -const LMR_OFFSET: f32 = tune::lmr_offset!(); - -/// Divisor in the LMR formula. -const LMR_DIVISOR: f32 = tune::lmr_divisor!(); - -/// Bounds within an alpha-beta search. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SearchBounds { - /// Lower bound. - /// - /// We are guaranteed a score that is AT LEAST `alpha`. - /// During search, if no move can raise `alpha`, we are said to have "failed low." - /// - /// On a fail-low, we do not have a "best move." - pub alpha: Score, - - /// Upper bound. - /// - /// Our opponent is guaranteed a score that is AT MOST `beta`. - /// During search, if a move scores higher than `beta`, we are said to have "failed high." - /// - /// On a fail-high, the branch is pruned, since our opponent has a better move to play earlier in the tree, - /// which would make this position unreachable for us. - pub beta: Score, -} - -impl SearchBounds { - /// Create a new [`SearchBounds`] from the provided `alpha` and `beta` values. - #[inline(always)] - const fn new(alpha: Score, beta: Score) -> Self { - Self { alpha, beta } - } - - /// Create a "null window" around `alpha`. - #[inline(always)] - fn null_alpha(self) -> Self { - Self::new(self.alpha, self.alpha + 1) - } - - /// Create a "null window" around `beta`. - #[inline(always)] - fn null_beta(self) -> Self { - Self::new(self.beta - 1, self.beta) - } -} - -impl Neg for SearchBounds { - type Output = Self; - /// Negating a [`SearchBounds`] swaps the `alpha` and `beta` fields and negates them both. - #[inline(always)] - fn neg(self) -> Self::Output { - Self { - alpha: -self.beta, - beta: -self.alpha, - } - } -} - -impl Default for SearchBounds { - /// Default [`SearchBounds`] are a `(-infinity, infinity)`. - #[inline(always)] - fn default() -> Self { - Self::new(Score::ALPHA, Score::BETA) - } -} - -/// Represents a window around a search result to act as our a/b bounds. -#[derive(Debug)] -struct AspirationWindow { - /// Bounds of this search window - bounds: SearchBounds, - - /// Number of times that a score has been returned above beta. - beta_fails: i32, - - /// Number of times that a score has been returned below alpha. - alpha_fails: i32, -} - -impl AspirationWindow { - /// Returns a delta value to change window's size. - /// - /// The value will differ depending on `depth`, with higher depths producing narrower windows. - #[inline(always)] - fn delta(depth: u8) -> Score { - let initial_delta = tune::initial_aspiration_window_delta!(); - - let min_delta = tune::min_aspiration_window_delta!(); - - // Gradually decrease the window size from `8*init` to `min` - let delta = ((initial_delta << 3) / depth as i32).max(min_delta); - - Score(delta) - } - - /// Creates a new [`AspirationWindow`] centered around `score`. - #[inline(always)] - fn new(score: Score, depth: u8) -> Self { - // If the score is mate, we expect search results to fluctuate, so set the windows to infinite. - // Also, we only want to use aspiration windows after certain depths, so check that, too. - let bounds = if depth < tune::min_aspiration_window_depth!() || score.is_mate() { - SearchBounds::default() - } else { - // Otherwise we build a window around the provided score. - let delta = Self::delta(depth); - SearchBounds::new( - (score - delta).max(Score::ALPHA), - (score + delta).min(Score::BETA), - ) - }; - - Self { - bounds, - alpha_fails: 0, - beta_fails: 0, - } - } - - /// Widens the window's `alpha` bound, expanding it downwards. - /// - /// This also resets the `beta` bound to `(alpha + beta) / 2` - #[inline(always)] - fn widen_down(&mut self, score: Score, depth: u8) { - // Compute a gradually-increasing delta - let delta = Self::delta(depth) * (1 << (self.alpha_fails + 1)); - - // By convention, we widen both bounds on a fail low. - self.bounds.beta = ((self.bounds.alpha + self.bounds.beta) / 2).min(Score::BETA); - self.bounds.alpha = (score - delta).max(Score::ALPHA); - - // Increase number of failures - self.alpha_fails += 1; - } - - /// Widens the window's `beta` bound, expanding it upwards. - #[inline(always)] - fn widen_up(&mut self, score: Score, depth: u8) { - // Compute a gradually-increasing delta - let delta = Self::delta(depth) * (1 << (self.beta_fails + 1)); - - // Widen the beta bound - self.bounds.beta = (score + delta).min(Score::BETA); - - // Increase number of failures - self.beta_fails += 1; - } - - /// Returns `true` if `score` fails low, meaning it is below `alpha` and the window must be expanded downwards. - #[inline(always)] - fn fails_low(&self, score: Score) -> bool { - self.bounds.alpha != Score::ALPHA && score <= self.bounds.alpha - } - - /// Returns `true` if `score` fails high, meaning it is above `beta` and the window must be expanded upwards. - #[inline(always)] - fn fails_high(&self, score: Score) -> bool { - self.bounds.beta != Score::BETA && score >= self.bounds.beta - } -} - -/// The result of a search, containing the best move found, score, and total nodes searched. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct SearchResult { - /// Number of nodes searched. - pub nodes: u64, - - /// Best move found during the search. - pub bestmove: Option, - - /// Evaluation of the position after `bestmove` is made. - pub score: Score, - - // The depth of the search that produced this result. - pub depth: u8, -} - -impl Default for SearchResult { - /// A default search result should initialize to a *very bad* value, - /// since there isn't a move to play. - #[inline(always)] - fn default() -> Self { - Self { - nodes: 0, - bestmove: None, - score: Score::ALPHA, - depth: 1, - } - } -} - -/// Configuration variables for executing a [`Search`]. -#[derive(Debug, Clone, Copy)] -pub struct SearchConfig { - /// Maximum depth to execute the search. - pub max_depth: u8, - - /// Node allowance. - /// - /// If the search exceeds this many nodes, it will exit as quickly as possible. - pub max_nodes: u64, - - /// Start time of the search. - pub starttime: Instant, - - /// Soft limit on search time. - /// - /// During iterative deepening, if a search concludes and this timeout is exceeded, - /// the entire search will exit, since there probably isn't enough time remaining - /// to conduct a search at a deeper depth. - pub soft_timeout: Duration, - - /// Hard limit on search time. - /// - /// During *any* point in the search, if this limit is exceeded, the search will cancel. - pub hard_timeout: Duration, -} - -impl SearchConfig { - /// Constructs a new [`SearchConfig`] from the provided UCI options and game. - /// - /// The [`Game`] is used to determine side to move, and other factors when computing the soft/hard timeouts. - pub fn new(options: UciSearchOptions, game: &Game) -> Self { - let mut config = Self::default(); - - // If supplied, set the max depth / node allowance - if let Some(depth) = options.depth { - config.max_depth = depth as u8; - } - - if let Some(nodes) = options.nodes { - config.max_nodes = nodes as u64; - } - - // If `movetime` was supplied, search that long. - if let Some(movetime) = options.movetime { - config.hard_timeout = movetime; - config.soft_timeout = movetime; - } else { - // Otherwise, search based on time remaining and increment - let (time, inc) = if game.side_to_move().is_white() { - (options.wtime, options.winc) - } else { - (options.btime, options.binc) - }; - - // Only calculate timeouts if a time was provided - if let Some(time) = time { - let inc = inc.unwrap_or(Duration::ZERO) / tune::time_inc_divisor!(); - - config.soft_timeout = time / tune::soft_timeout_divisor!() + inc; - config.hard_timeout = time / tune::hard_timeout_divisor!() + inc; - } - } - - config - } -} - -impl Default for SearchConfig { - /// A default [`SearchConfig`] will permit an "infinite" search. - /// - /// The word "infinite" is quoted here because the actual defaults are the `::MAX` values for each field. - #[inline(always)] - fn default() -> Self { - Self { - max_depth: MAX_DEPTH, - max_nodes: u64::MAX, - starttime: Instant::now(), - soft_timeout: Duration::MAX, - hard_timeout: Duration::MAX, - } - } -} - -/// Executes a search on the provided game at a specified depth. -pub struct Search<'a, const LOG: u8, V> { - /// Number of nodes searched. - nodes: u64, - - /// An atomic flag to determine if the search should be cancelled at any time. - /// - /// If this is ever `false`, the search must exit as soon as possible. - is_searching: Arc, - - /// Configuration variables for this instance of the search. - config: SearchConfig, - - /// Previous positions encountered during search. - prev_positions: Vec, - - /// Transposition table used to cache information during search. - ttable: &'a mut TTable, - - /// Storage for moves that cause a beta-cutoff during search. - history: &'a mut HistoryTable, - - /// Marker for what variant of Chess is being played - variant: PhantomData<&'a V>, -} - -impl<'a, const LOG: u8, V: Variant> Search<'a, LOG, V> { - /// Construct a new [`Search`] instance to execute. - #[inline(always)] - pub fn new( - is_searching: Arc, - config: SearchConfig, - prev_positions: Vec, - ttable: &'a mut TTable, - history: &'a mut HistoryTable, - ) -> Self { - Self { - nodes: 0, - is_searching, - config, - prev_positions, - ttable, - history, - variant: PhantomData, - } - } - - /// Start the search on the supplied [`Game`], returning a [`SearchResult`]. - /// - /// This is the entrypoint of the search, and prints UCI info before starting iterative deepening. - /// and concluding by sending the `bestmove` message and exiting. - #[inline(always)] - pub fn start(mut self, game: &Game) -> SearchResult { - if LOG.allows(LogLevel::Debug) { - self.send_string(format!("Starting search on {:?}", game.to_fen())); - - let soft = self.config.soft_timeout.as_millis(); - let hard = self.config.hard_timeout.as_millis(); - let nodes = self.config.max_nodes; - let depth = self.config.max_depth; - - if soft < Duration::MAX.as_millis() { - self.send_string(format!("Soft timeout := {soft}ms")); - } - if hard < Duration::MAX.as_millis() { - self.send_string(format!("Hard timeout := {hard}ms")); - } - if nodes < u64::MAX { - self.send_string(format!("Max nodes := {nodes} nodes")); - } - if depth < MAX_DEPTH { - self.send_string(format!("Max depth := {depth}")); - } - } - - let res = self.iterative_deepening(game); - - if LOG.allows(LogLevel::Debug) { - let hits = self.ttable.hits; - let accesses = self.ttable.accesses; - let hit_rate = hits as f32 / accesses as f32 * 100.0; - let collisions = self.ttable.collisions; - let info = format!("TT stats: {hits} hits / {accesses} accesses ({hit_rate:.2}% hit rate), {collisions} collisions"); - self.send_string(info); - } - - // Search has ended; send bestmove - if LOG.allows(LogLevel::Info) { - self.send_response(UciResponse::BestMove { - bestmove: res.bestmove.map(V::fmt_move), - ponder: None, - }); - } - - // Search has concluded, alert other thread(s) that we are no longer searching - self.is_searching.store(false, Ordering::Relaxed); - - res - } - - /// Sends a [`UciResponse`] to `stdout`. - #[inline(always)] - fn send_response(&self, response: UciResponse) { - println!("{response}"); - } - - /// Sends a [`UciInfo`] to `stdout`. - #[inline(always)] - fn send_info(&self, info: UciInfo) { - let resp = UciResponse::info(info); - self.send_response(resp); - } - - /// Sends UCI info about the conclusion of this search. - #[inline(always)] - fn send_end_of_search_info(&self, result: &SearchResult) { - let elapsed = self.config.starttime.elapsed(); - - self.send_info( - UciInfo::new() - .depth(result.depth) - .nodes(self.nodes) - .score(result.score) - .nps((self.nodes as f32 / elapsed.as_secs_f32()).trunc()) - .time(elapsed.as_millis()) - .pv(result.bestmove.map(V::fmt_move)), - ); - } - - /// Helper to send a [`UciInfo`] containing only a `string` message to `stdout`. - #[inline(always)] - fn send_string(&self, string: T) { - self.send_response(UciResponse::info_string(string)); - } - - /// Performs [iterative deepening](https://www.chessprogramming.org/Iterative_Deepening) (ID) on the Search's position. - /// - /// ID is a basic time management strategy for engines. - /// It involves performing a search at depth `n`, then, if there is enough time remaining, performing a search at depth `n + 1`. - /// On it's own, ID does not improve performance, because we are wasting work by re-running searches at low depth. - /// However, with features such as move ordering, a/b pruning, and aspiration windows, ID enhances performance. - /// - /// After each iteration, we check if we've exceeded our `soft_timeout` and, if we haven't, we run a search at a greater depth. - fn iterative_deepening(&mut self, game: &Game) -> SearchResult { - // Initialize `bestmove` to the first move available - let mut result = SearchResult { - bestmove: game.get_legal_moves().first().copied(), - ..Default::default() - }; - - /**************************************************************************************************** - * Iterative Deepening: https://www.chessprogramming.org/Iterative_Deepening - ****************************************************************************************************/ - - // The actual Iterative Deepening loop - 'iterative_deepening: while self.config.starttime.elapsed() < self.config.soft_timeout - && self.is_searching.load(Ordering::Relaxed) - && result.depth <= self.config.max_depth - { - /**************************************************************************************************** - * Aspiration Windows: https://www.chessprogramming.org/Aspiration_Windows - ****************************************************************************************************/ - - // Create a new aspiration window for this search - let mut window = AspirationWindow::new(result.score, result.depth); - - // Get a score from the a/b search while using aspiration windows - let score = 'aspiration_window: loop { - // Start a new search at the current depth - let score = self.negamax::(game, result.depth, 0, window.bounds); - - // If the score fell outside of the aspiration window, widen it gradually - if window.fails_low(score) { - window.widen_down(score, result.depth); - } else if window.fails_high(score) { - window.widen_up(score, result.depth); - } else { - // Otherwise, the window is OK and we can use the score - break 'aspiration_window score; - } - - // If we've ran out of time, we shouldn't update the score, because the last search iteration was forcibly cancelled. - // Instead, we should break out of the ID loop, using the result from the previous iteration - if self.search_cancelled() { - if LOG.allows(LogLevel::Debug) { - if let Some(bestmove) = self.get_tt_bestmove(game.key()) { - self.send_string(format!( - "Search cancelled during depth {} while evaluating {} with score {score}", - result.depth, - V::fmt_move(bestmove), - )); - } else { - self.send_string(format!( - "Search cancelled during depth {} with score {score} and no bestmove", - result.depth, - )); - } - } - break 'iterative_deepening; - } - }; - - /**************************************************************************************************** - * Update current best score - ****************************************************************************************************/ - - // Otherwise, we need to update the "best" result with the results from the new search - result.score = score; - - // Get the bestmove from the TTable - result.bestmove = self.ttable.get(&game.key()).map(|entry| entry.bestmove); - - // Send search info to the GUI - if LOG.allows(LogLevel::Info) { - self.send_end_of_search_info(&result); - } - - // Increase the depth for the next iteration - result.depth += 1; - } - - // Transfer the node count - result.nodes += self.nodes; - - // ID loop has concluded (either by finishing or timing out), - // so we return the result from the last successfully-completed search. - result - } - - /// Primary location of search logic. - /// - /// Uses the [negamax](https://www.chessprogramming.org/Negamax) algorithm in a [fail soft](https://www.chessprogramming.org/Alpha-Beta#Negamax_Framework) framework. - fn negamax( - &mut self, - game: &Game, - depth: u8, - ply: i32, - mut bounds: SearchBounds, - ) -> Score { - let original_alpha = bounds.alpha; - - /**************************************************************************************************** - * TT Cutoffs: https://www.chessprogramming.org/Transposition_Table#Transposition_Table_Cutoffs - ****************************************************************************************************/ - // Do not prune in PV nodes - if !PV { - // If we've seen this position before, and our previously-found score is valid, then don't bother searching anymore. - if let Some(tt_score) = self.probe_tt(game.key(), depth, ply, bounds) { - return tt_score; - } - } - - /**************************************************************************************************** - * Quiescence Search: https://www.chessprogramming.org/Quiescence_Search - ****************************************************************************************************/ - // If we've reached a terminal node, evaluate the current position - if depth == 0 { - return self.quiescence(game, ply, bounds); - } - - // If we CAN prune this node by means other than the TT, do so - if let Some(score) = self.node_pruning_score::(game, depth, ply, bounds) { - return score; - } - - // If there are no legal moves, it's either mate or a draw. - let mut moves = game.get_legal_moves(); - if moves.is_empty() { - return if game.is_in_check() { - // Offset by ply to prefer earlier mates - -Score::MATE + ply - } else { - // Drawing is better than losing - Score::DRAW - }; - } - - // Sort moves so that we look at "promising" ones first - let tt_move = self.get_tt_bestmove(game.key()); - moves.sort_by_cached_key(|mv| self.score_move(game, mv, tt_move)); - - // Start with a *really bad* initial score - let mut best = Score::ALPHA; - let mut bestmove = moves[0]; // Safe because we guaranteed `moves` to be nonempty above - - /**************************************************************************************************** - * Primary move loop - ****************************************************************************************************/ - - for (i, mv) in moves.iter().enumerate() { - // Copy-make the new position - let new = game.with_move_made(*mv); - let mut score = Score::DRAW; - - if !self.is_draw(&new) { - // Append the move onto the history - self.prev_positions.push(*new.position()); - - let new_depth = depth - 1 + self.extension_value(&new); - - // If this node can be reduced, search it with a reduced window. - if let Some(lmr_reduction) = self.reduction_value::(depth, &new, i) { - // Reduced depth should never exceed `new_depth` and should never be less than `1`. - let reduced_depth = (new_depth - lmr_reduction).max(1).min(new_depth); - - // Search at a reduced depth with a null window - score = - -self.negamax::(&new, reduced_depth, ply + 1, -bounds.null_alpha()); - - // If that failed *high* (raised alpha), re-search at the full depth with the null window - if score > bounds.alpha && reduced_depth < new_depth { - score = - -self.negamax::(&new, new_depth, ply + 1, -bounds.null_alpha()); - } - } else if !PV || i > 0 { - // All non-PV nodes get searched with a null window - score = -self.negamax::(&new, new_depth, ply + 1, -bounds.null_alpha()); - } - - /**************************************************************************************************** - * Principal Variation Search: https://en.wikipedia.org/wiki/Principal_variation_search#Pseudocode - ****************************************************************************************************/ - // If searching the PV, or if a reduced search failed *high*, we search with a full depth and window - if PV && (i == 0 || score > bounds.alpha) { - score = -self.negamax::(&new, new_depth, ply + 1, -bounds); - } - - // We've now searched this node - self.nodes += 1; - - // Pop the move from the history - self.prev_positions.pop(); - } - - /**************************************************************************************************** - * Score evaluation & bounds adjustments - ****************************************************************************************************/ - - // If we've found a better move than our current best, update the results - if score > best { - best = score; - - if score > bounds.alpha { - bounds.alpha = score; - // PV found - bestmove = *mv; - } - - // Fail soft beta-cutoff. - if score >= bounds.beta { - /**************************************************************************************************** - * History Heuristic - ****************************************************************************************************/ - // Simple bonus based on depth - let bonus = Score::HISTORY_MULTIPLIER * depth as i32 - Score::HISTORY_OFFSET; - - // Only update quiet moves - if mv.is_quiet() { - self.history.update(game, mv, bonus); - } - - // Apply a penalty to all quiets searched so far - for mv in moves[..i].iter().filter(|mv| mv.is_quiet()) { - self.history.update(game, mv, -bonus); - } - break; - } - } - - // Check if we can continue searching - if self.search_cancelled() { - break; - } - } - - // Save this node to the TTable - self.save_to_tt( - game.key(), - bestmove, - best, - SearchBounds::new(original_alpha, bounds.beta), - depth, - ply, - ); - - best - } - - /// Quiescence Search (QSearch) - /// - /// A search that looks at only possible captures and capture-chains. - /// This is called when [`Search::negamax`] reaches a depth of 0, and has no recursion limit. - fn quiescence(&mut self, game: &Game, _ply: i32, mut bounds: SearchBounds) -> Score { - // Evaluate the current position, to serve as our baseline - let stand_pat = game.eval(); - - // Beta cutoff; this position is "too good" and our opponent would never let us get here - if stand_pat >= bounds.beta { - return bounds.beta; - } else if stand_pat > bounds.alpha { - bounds.alpha = stand_pat; - } - - // Generate only the legal captures - // TODO: Is there a more concise way of doing this? - // The `game.into_iter().only_captures()` doesn't cover en passant... - let mut captures = game - .get_legal_moves() - .into_iter() - .filter(Move::is_capture) - .collect::(); - - // Can't check for mates in normal qsearch, since we're not looking at *all* moves. - // So, if there are no captures available, just return the current evaluation. - if captures.is_empty() { - return stand_pat; - } - - let tt_move = self.get_tt_bestmove(game.key()); - captures.sort_by_cached_key(|mv| self.score_move(game, mv, tt_move)); - - let mut best = stand_pat; - // let mut bestmove = captures[0]; // Safe because we ensured `captures` is not empty - // let original_alpha = alpha; - - /**************************************************************************************************** - * Primary move loop - ****************************************************************************************************/ - - for mv in captures { - // Copy-make the new position - let new = game.with_move_made(mv); - let score; - - // Normally, repetitions can't occur in QSearch, because captures are irreversible. - // However, some QSearch extensions (quiet TT moves, all moves when in check, etc.) may be reversible. - if self.is_draw(&new) { - score = Score::DRAW; - } else { - self.prev_positions.push(*new.position()); - - score = -self.quiescence(&new, _ply + 1, -bounds); - self.nodes += 1; // We've now searched this node - - self.prev_positions.pop(); - } - - /**************************************************************************************************** - * Score evaluation & bounds adjustments - ****************************************************************************************************/ - // If we've found a better move than our current best, update our result - if score > best { - best = score; - - if score > bounds.alpha { - bounds.alpha = score; - - // PV found - // bestmove = mv; - } - - // Fail soft beta-cutoff. - if score >= bounds.beta { - break; - } - } - - // Check if we can continue searching - if self.search_cancelled() { - break; - } - } - - // Save this node to the TTable - // self.save_to_tt(game.key(), bestmove, best, original_alpha, beta, 0, ply); - - best // fail-soft - } - - /// Checks if we've exceeded any conditions that would warrant the search to end. - #[inline(always)] - fn search_cancelled(&self) -> bool { - // Condition 1: We've exceeded the hard limit of our allotted search time - self.config.starttime.elapsed() >= self.config.hard_timeout || - // Condition 2: The search was stopped by an external factor, like the `stop` command - !self.is_searching.load(Ordering::Relaxed) || - // Condition 3: We've exceeded the maximum amount of nodes we're allowed to search - self.nodes >= self.config.max_nodes - } - - /// Checks if `game` is a repetition, comparing it to previous positions - #[inline(always)] - fn is_repetition(&self, game: &Game) -> bool { - // We can skip the previous position, because there's no way it can be a repetition - for prev in self.prev_positions.iter().rev().skip(1) { - if prev.key() == game.key() { - return true; - } else - // The halfmove counter only resets on irreversible moves (captures, pawns, etc.) so it can't be a repetition. - if prev.halfmove() == 0 { - return false; - } - } - - false - } - - /// Returns `true` if `game` can be claimed as a draw - #[inline(always)] - fn is_draw(&self, game: &Game) -> bool { - self.is_repetition(game) - || game.can_draw_by_fifty() - || game.can_draw_by_insufficient_material() - } - - /// Saves the provided data to an entry in the TTable. - #[inline(always)] - fn save_to_tt( - &mut self, - key: ZobristKey, - bestmove: Move, - score: Score, - bounds: SearchBounds, - depth: u8, - ply: i32, - ) { - let entry = TTableEntry::new(key, bestmove, score, bounds, depth, ply); - let old = self.ttable.store(entry); - - if LOG.allows(LogLevel::Debug) { - // If a previous entry existed and had a *different* key, this was a collision - if old.is_some_and(|old| old.key != key) { - self.ttable.collisions += 1; - } - } - } - - /// Gets the bestmove for the provided position from the TTable, if it exists. - #[inline(always)] - fn get_tt_bestmove(&mut self, key: ZobristKey) -> Option { - let mv = self.ttable.get(&key).map(|entry| entry.bestmove); - - if LOG.allows(LogLevel::Debug) { - // Regardless whether this was a hit, it was still an access - self.ttable.accesses += 1; - - // If a move was found, this was a hit - if mv.is_some() { - self.ttable.hits += 1; - } - } - - mv - } - - /// Applies a score to the provided move, intended to be used when ordering moves during search. - #[inline(always)] - fn score_move(&self, game: &Game, mv: &Move, tt_move: Option) -> Score { - // TT move should be looked at first, so assign it the best possible score and immediately exit. - if tt_move.is_some_and(|tt_mv| tt_mv == *mv) { - return Score(i32::MIN); - } - - // Safe unwrap because we can't move unless there's a piece at `from` - let piece = game.piece_at(mv.from()).unwrap(); - let to = mv.to(); - let mut score = Score::BASE_MOVE_SCORE; - - // Apply history bonus to quiets - if mv.is_quiet() { - score += self.history[piece][to]; - } else - // Capturing a high-value piece with a low-value piece is a good idea - if let Some(victim) = game.piece_at(to) { - score += MVV_LVA[piece][victim]; - } - - -score // We're sorting, so a lower number is better - } - - /// If we can prune the provided node, this function returns a score to return upon pruning. - /// - /// If we cannot prune the node, this function returns `None`. - #[inline] - fn node_pruning_score( - &mut self, - game: &Game, - depth: u8, - ply: i32, - bounds: SearchBounds, - ) -> Option { - // Cannot prune anything in a PV node or if we're in check - if PV || game.is_in_check() { - return None; - } - - /**************************************************************************************************** - * Reverse Futility Pruning: https://www.chessprogramming.org/Reverse_Futility_Pruning - ****************************************************************************************************/ - // If our static eval is too good (better than beta), we can prune this branch. - // Multiplying our margin by depth makes this pruning process less risky for higher depths. - let rfp_score = game.eval() - Score::RFP_MARGIN * depth as i32; - if depth <= MAX_RFP_DEPTH && rfp_score >= bounds.beta { - return Some(rfp_score); - } - - /**************************************************************************************************** - * Null Move Pruning: https://www.chessprogramming.org/Null_Move_Pruning - ****************************************************************************************************/ - // If the last move did not increment the fullmove, but *did* increment the halfmove, it was a nullmove - let last_move_was_nullmove = self.prev_positions.last().is_some_and(|pos| { - pos.fullmove() == game.fullmove() && pos.halfmove() == game.halfmove() + 1 - }); - - // All pieces that are not Kings or Pawns - let non_king_pawn_material = - game.occupied() ^ game.kind(PieceKind::Pawn) ^ game.kind(PieceKind::King); - - let can_perform_nmp = depth >= MIN_NMP_DEPTH // Can't play nullmove under a certain depth - && !last_move_was_nullmove // Can't play two nullmoves in a row - && non_king_pawn_material.is_nonempty(); // Can't play nullmove if insufficient material (only Kings and Pawns) - - if can_perform_nmp { - let null_game = game.with_nullmove_made(); - self.prev_positions.push(*null_game.position()); - - // Search at a reduced depth with a zero-window - let nmp_depth = depth - NMP_REDUCTION_VALUE; - let score = -self.negamax::(&null_game, nmp_depth, ply + 1, -bounds.null_beta()); - - self.prev_positions.pop(); - - // If making the nullmove produces a cutoff, we can assume that a full-depth search would also produce a cutoff - if score >= bounds.beta { - return Some(score); - } - } - - // If no pruning technique was possible, return no score - None - } - - /// Probes the [`TTable`] for an entry at the provided `key`, returning that entry's score, if appropriate. - /// - /// If an entry is found from a greater depth than `depth`, its score is returned if and only if: - /// 1. The entry is exact. - /// 2. The entry is an upper bound and its score is `<= alpha`. - /// 3. The entry is a lower bound and its score is `>= beta`. - /// - /// See [`TTableEntry::try_score`] for more. - #[inline(always)] - fn probe_tt( - &self, - key: ZobristKey, - depth: u8, - ply: i32, - bounds: SearchBounds, - ) -> Option { - // if-let chains are set to be stabilized in Rust 2024 (1.85.0): https://rust-lang.github.io/rfcs/2497-if-let-chains.html - if let Some(tt_entry) = self.ttable.get(&key) { - // Can only cut off if the existing entry came from a greater depth. - if tt_entry.depth >= depth { - return tt_entry.try_score(bounds, ply); - } - } - - None - } - - /// Compute a reduction value (`R`) to apply to a given node's search depth, if possible. - #[inline(always)] - fn reduction_value( - &self, - depth: u8, - game: &Game, - moves_made: usize, - ) -> Option { - /**************************************************************************************************** - * Late Move Reductions: https://www.chessprogramming.org/Late_Move_Reductions - ****************************************************************************************************/ - (depth >= MIN_LMR_DEPTH && moves_made >= MIN_LMR_MOVES + PV as usize).then(|| { - // Base LMR reduction increases as we go higher in depth and/or make more moves - let mut lmr_reduction = - (LMR_OFFSET + (depth as f32).ln() * (moves_made as f32).ln() / LMR_DIVISOR) as u8; - - // Increase/decrease the reduction based on current conditions - // lmr_reduction += something; - lmr_reduction -= game.is_in_check() as u8; - - lmr_reduction - }) - } - - /// Compute an extension value to apply to a given node's search depth. - #[inline(always)] - fn extension_value(&self, game: &Game) -> u8 { - /**************************************************************************************************** - * Check Extensions: https://www.chessprogramming.org/Check_Extensions - ****************************************************************************************************/ - game.is_in_check() as u8 - } -} - -/// This table represents values for [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) move ordering. -/// -/// It is indexed by `[attacker][victim]`, and yields a "score" that is used when sorting moves. -/// -/// The following table is produced: -/// ```text -/// VICTIM -/// A P N B R Q K -/// T +---------------------------------+ -/// T P| 900 3100 3200 4900 8900 0 -/// A N| 680 2880 2980 4680 8680 0 -/// C B| 670 2870 2970 4670 8670 0 -/// K R| 500 2700 2800 4500 8500 0 -/// E Q| 100 2300 2400 4100 8100 0 -/// R K| 1000 3200 3300 5000 9000 0 -/// ``` -/// -/// Note that the actual table is different, as it has size `12x12` instead of `6x6` -/// to account for the fact that castling is denoted as `KxR`. -/// The values are also all left-shifted by 16 bits, to ensure that captures are ranked above quiets in all cases. -/// -/// See [`print_mvv_lva_table`] to display this table. -const MVV_LVA: [[i32; Piece::COUNT]; Piece::COUNT] = { - let mut matrix = [[0; Piece::COUNT]; Piece::COUNT]; - let count = Piece::COUNT; - - let mut attacker = 0; - while attacker < count { - let mut victim = 0; - let atk_color = Color::from_bool(attacker < PieceKind::COUNT); - - while victim < count { - let atk = PieceKind::from_bits_unchecked(attacker as u8 % 6); - let vtm = PieceKind::from_bits_unchecked(victim as u8 % 6); - - let vtm_color = Color::from_bool(victim < PieceKind::COUNT); - - // Remove scores for capturing the King and friendly pieces (KxR for castling) - let can_capture = (atk_color.index() != vtm_color.index()) // Different colors - && victim != count - 1 // Can't capture White King - && victim != PieceKind::COUNT - 1; // Can't capture Black King - - // Rustic's way of doing things; Arbitrary increasing numbers for capturing pairs - // bench: 27609398 nodes 5716479 nps - // let score = (victim * 10 + (count - attacker)) as i32; - - // Default MVV-LVA except that the King is assigned a value of 0 if he is attacking - // bench: 27032804 nodes 8136592 nps - let score = 10 * vtm.value() - atk.value(); - - // If the attacker is the King, the score is half the victim's value. - // This encourages the King to attack, but not as strongly as other pieces. - // bench: 27107011 nodes 5647285 nps - // let score = if attacker == count - 1 { - // value_of(vtm) / 2 - // } else { - // // Standard MVV-LVA computation - // 10 * value_of(vtm) - value_of(atk) - // }; - - // Shift the value by a large amount so that captures are always ranked very highly - matrix[attacker][victim] = (score * can_capture as i32) << 16; - victim += 1; - } - attacker += 1; - } - matrix -}; - -/// Utility function to print the MVV-LVA table -#[allow(dead_code)] -pub fn print_mvv_lva_table() { - print!("\nX "); - for victim in Piece::all() { - print!("{victim} "); - } - print!("\n +"); - for _ in Piece::all() { - print!("------"); - } - println!("-+"); - for attacker in Piece::all() { - print!("{attacker}| "); - for victim in Piece::all() { - let score = MVV_LVA[attacker][victim]; - print!("{score:<4} ") - } - println!(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::*; - - fn run_search(fen: &str, config: SearchConfig) -> SearchResult { - let is_searching = Arc::new(AtomicBool::new(true)); - let game = fen.parse().unwrap(); - - let mut ttable = Default::default(); - let mut history = Default::default(); - Search::<{ LogLevel::None as u8 }, Standard>::new( - is_searching, - config, - Default::default(), - &mut ttable, - &mut history, - ) - .start(&game) - } - - fn ensure_is_mate_in(fen: &str, config: SearchConfig, moves: i32) -> SearchResult { - let res = run_search(fen, config); - assert!( - res.score.is_mate(), - "Search on {fen:?} with config {config:#?} produced result that is not mate.\nResult: {res:#?}" - ); - assert_eq!( - res.score.moves_to_mate(), - moves, - "Search on {fen:?} with config {config:#?} produced result not mate in {moves}.\nResult: {res:#?}" - ); - res - } - - #[test] - fn test_white_mate_in_1() { - let fen = "k7/8/KQ6/8/8/8/8/8 w - - 0 1"; - let config = SearchConfig { - max_depth: 2, - ..Default::default() - }; - - let res = ensure_is_mate_in(fen, config, 1); - assert_eq!(res.bestmove.unwrap(), "b6a7") - } - - #[test] - fn test_black_mated_in_1() { - let fen = "1k6/8/KQ6/2Q5/8/8/8/8 b - - 0 1"; - let config = SearchConfig { - max_depth: 3, - ..Default::default() - }; - - let res = ensure_is_mate_in(fen, config, -1); - assert_eq!(res.bestmove.unwrap(), "b8a8") - } - - #[test] - fn test_stalemate() { - let fen = "k7/8/KQ6/8/8/8/8/8 b - - 0 1"; - let config = SearchConfig::default(); - - let res = run_search(fen, config); - assert!(res.bestmove.is_none()); - assert_eq!(res.score, Score::DRAW); - } - - #[test] - fn test_obvious_capture_promote() { - // Pawn should take queen and also promote to queen - let fen = "3q1n2/4P3/8/8/8/8/k7/7K w - - 0 1"; - let config = SearchConfig { - max_depth: 1, - ..Default::default() - }; - - let res = run_search(fen, config); - assert_eq!(res.bestmove.unwrap(), "e7d8q"); - } - - #[test] - fn test_quick_search_finds_move() { - // If *any* legal move is available, it should be found, regardless of how much time was given. - let fen = FEN_STARTPOS; - let config = SearchConfig { - soft_timeout: Duration::from_millis(0), - hard_timeout: Duration::from_millis(0), - ..Default::default() - }; - - let res = run_search(fen, config); - assert!(res.bestmove.is_some()); - } -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 26e911e..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -/// Added functionality to `u8` to make the logging API cleaner. -/// -/// See [`LogLevel`] for more. -pub trait LoggingLevel -where - Self: Sized + PartialOrd, -{ - /// Returns `true` if `level` is at or above the provided [`LogLevel`]. - #[inline(always)] - fn allows(self, level: LogLevel) -> bool { - self >= level as u8 - } -} - -impl LoggingLevel for u8 {} - -/// Level of communication to output during search. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[repr(u8)] -pub enum LogLevel { - /// Do not print anything to stdout. - None, - - /// Only print basic communication, such as `bestmove`. - Info, - - /// Print additional messages through `info string`. - Debug, -} - -/// Number of bytes in a megabyte -pub const BYTES_IN_MB: usize = 1024 * 1024; - -pub const BENCHMARK_FENS: [&str; 128] = - ["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 ;D1 20 ;D2 400 ;D3 8902 ;D4 197281 ;D5 4865609 ;D6 119060324", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1 ;D1 48 ;D2 2039 ;D3 97862 ;D4 4085603 ;D5 193690690 ;D6 8031647685", "4k3/8/8/8/8/8/8/4K2R w K - 0 1 ;D1 15 ;D2 66 ;D3 1197 ;D4 7059 ;D5 133987 ;D6 764643", "4k3/8/8/8/8/8/8/R3K3 w Q - 0 1 ;D1 16 ;D2 71 ;D3 1287 ;D4 7626 ;D5 145232 ;D6 846648", "4k2r/8/8/8/8/8/8/4K3 w k - 0 1 ;D1 5 ;D2 75 ;D3 459 ;D4 8290 ;D5 47635 ;D6 899442", "r3k3/8/8/8/8/8/8/4K3 w q - 0 1 ;D1 5 ;D2 80 ;D3 493 ;D4 8897 ;D5 52710 ;D6 1001523", "4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1 ;D1 26 ;D2 112 ;D3 3189 ;D4 17945 ;D5 532933 ;D6 2788982", "r3k2r/8/8/8/8/8/8/4K3 w kq - 0 1 ;D1 5 ;D2 130 ;D3 782 ;D4 22180 ;D5 118882 ;D6 3517770", "8/8/8/8/8/8/6k1/4K2R w K - 0 1 ;D1 12 ;D2 38 ;D3 564 ;D4 2219 ;D5 37735 ;D6 185867", "8/8/8/8/8/8/1k6/R3K3 w Q - 0 1 ;D1 15 ;D2 65 ;D3 1018 ;D4 4573 ;D5 80619 ;D6 413018", "4k2r/6K1/8/8/8/8/8/8 w k - 0 1 ;D1 3 ;D2 32 ;D3 134 ;D4 2073 ;D5 10485 ;D6 179869", "r3k3/1K6/8/8/8/8/8/8 w q - 0 1 ;D1 4 ;D2 49 ;D3 243 ;D4 3991 ;D5 20780 ;D6 367724", "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1 ;D1 26 ;D2 568 ;D3 13744 ;D4 314346 ;D5 7594526 ;D6 179862938", "r3k2r/8/8/8/8/8/8/1R2K2R w Kkq - 0 1 ;D1 25 ;D2 567 ;D3 14095 ;D4 328965 ;D5 8153719 ;D6 195629489", "r3k2r/8/8/8/8/8/8/2R1K2R w Kkq - 0 1 ;D1 25 ;D2 548 ;D3 13502 ;D4 312835 ;D5 7736373 ;D6 184411439", "r3k2r/8/8/8/8/8/8/R3K1R1 w Qkq - 0 1 ;D1 25 ;D2 547 ;D3 13579 ;D4 316214 ;D5 7878456 ;D6 189224276", "1r2k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1 ;D1 26 ;D2 583 ;D3 14252 ;D4 334705 ;D5 8198901 ;D6 198328929", "2r1k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1 ;D1 25 ;D2 560 ;D3 13592 ;D4 317324 ;D5 7710115 ;D6 185959088", "r3k1r1/8/8/8/8/8/8/R3K2R w KQq - 0 1 ;D1 25 ;D2 560 ;D3 13607 ;D4 320792 ;D5 7848606 ;D6 190755813", "4k3/8/8/8/8/8/8/4K2R b K - 0 1 ;D1 5 ;D2 75 ;D3 459 ;D4 8290 ;D5 47635 ;D6 899442", "4k3/8/8/8/8/8/8/R3K3 b Q - 0 1 ;D1 5 ;D2 80 ;D3 493 ;D4 8897 ;D5 52710 ;D6 1001523", "4k2r/8/8/8/8/8/8/4K3 b k - 0 1 ;D1 15 ;D2 66 ;D3 1197 ;D4 7059 ;D5 133987 ;D6 764643", "r3k3/8/8/8/8/8/8/4K3 b q - 0 1 ;D1 16 ;D2 71 ;D3 1287 ;D4 7626 ;D5 145232 ;D6 846648", "4k3/8/8/8/8/8/8/R3K2R b KQ - 0 1 ;D1 5 ;D2 130 ;D3 782 ;D4 22180 ;D5 118882 ;D6 3517770", "r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1 ;D1 26 ;D2 112 ;D3 3189 ;D4 17945 ;D5 532933 ;D6 2788982", "8/8/8/8/8/8/6k1/4K2R b K - 0 1 ;D1 3 ;D2 32 ;D3 134 ;D4 2073 ;D5 10485 ;D6 179869", "8/8/8/8/8/8/1k6/R3K3 b Q - 0 1 ;D1 4 ;D2 49 ;D3 243 ;D4 3991 ;D5 20780 ;D6 367724", "4k2r/6K1/8/8/8/8/8/8 b k - 0 1 ;D1 12 ;D2 38 ;D3 564 ;D4 2219 ;D5 37735 ;D6 185867", "r3k3/1K6/8/8/8/8/8/8 b q - 0 1 ;D1 15 ;D2 65 ;D3 1018 ;D4 4573 ;D5 80619 ;D6 413018", "r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1 ;D1 26 ;D2 568 ;D3 13744 ;D4 314346 ;D5 7594526 ;D6 179862938", "r3k2r/8/8/8/8/8/8/1R2K2R b Kkq - 0 1 ;D1 26 ;D2 583 ;D3 14252 ;D4 334705 ;D5 8198901 ;D6 198328929", "r3k2r/8/8/8/8/8/8/2R1K2R b Kkq - 0 1 ;D1 25 ;D2 560 ;D3 13592 ;D4 317324 ;D5 7710115 ;D6 185959088", "r3k2r/8/8/8/8/8/8/R3K1R1 b Qkq - 0 1 ;D1 25 ;D2 560 ;D3 13607 ;D4 320792 ;D5 7848606 ;D6 190755813", "1r2k2r/8/8/8/8/8/8/R3K2R b KQk - 0 1 ;D1 25 ;D2 567 ;D3 14095 ;D4 328965 ;D5 8153719 ;D6 195629489", "2r1k2r/8/8/8/8/8/8/R3K2R b KQk - 0 1 ;D1 25 ;D2 548 ;D3 13502 ;D4 312835 ;D5 7736373 ;D6 184411439", "r3k1r1/8/8/8/8/8/8/R3K2R b KQq - 0 1 ;D1 25 ;D2 547 ;D3 13579 ;D4 316214 ;D5 7878456 ;D6 189224276", "8/1n4N1/2k5/8/8/5K2/1N4n1/8 w - - 0 1 ;D1 14 ;D2 195 ;D3 2760 ;D4 38675 ;D5 570726 ;D6 8107539", "8/1k6/8/5N2/8/4n3/8/2K5 w - - 0 1 ;D1 11 ;D2 156 ;D3 1636 ;D4 20534 ;D5 223507 ;D6 2594412", "8/8/4k3/3Nn3/3nN3/4K3/8/8 w - - 0 1 ;D1 19 ;D2 289 ;D3 4442 ;D4 73584 ;D5 1198299 ;D6 19870403", "K7/8/2n5/1n6/8/8/8/k6N w - - 0 1 ;D1 3 ;D2 51 ;D3 345 ;D4 5301 ;D5 38348 ;D6 588695", "k7/8/2N5/1N6/8/8/8/K6n w - - 0 1 ;D1 17 ;D2 54 ;D3 835 ;D4 5910 ;D5 92250 ;D6 688780", "8/1n4N1/2k5/8/8/5K2/1N4n1/8 b - - 0 1 ;D1 15 ;D2 193 ;D3 2816 ;D4 40039 ;D5 582642 ;D6 8503277", "8/1k6/8/5N2/8/4n3/8/2K5 b - - 0 1 ;D1 16 ;D2 180 ;D3 2290 ;D4 24640 ;D5 288141 ;D6 3147566", "8/8/3K4/3Nn3/3nN3/4k3/8/8 b - - 0 1 ;D1 4 ;D2 68 ;D3 1118 ;D4 16199 ;D5 281190 ;D6 4405103", "K7/8/2n5/1n6/8/8/8/k6N b - - 0 1 ;D1 17 ;D2 54 ;D3 835 ;D4 5910 ;D5 92250 ;D6 688780", "k7/8/2N5/1N6/8/8/8/K6n b - - 0 1 ;D1 3 ;D2 51 ;D3 345 ;D4 5301 ;D5 38348 ;D6 588695", "B6b/8/8/8/2K5/4k3/8/b6B w - - 0 1 ;D1 17 ;D2 278 ;D3 4607 ;D4 76778 ;D5 1320507 ;D6 22823890", "8/8/1B6/7b/7k/8/2B1b3/7K w - - 0 1 ;D1 21 ;D2 316 ;D3 5744 ;D4 93338 ;D5 1713368 ;D6 28861171", "k7/B7/1B6/1B6/8/8/8/K6b w - - 0 1 ;D1 21 ;D2 144 ;D3 3242 ;D4 32955 ;D5 787524 ;D6 7881673", "K7/b7/1b6/1b6/8/8/8/k6B w - - 0 1 ;D1 7 ;D2 143 ;D3 1416 ;D4 31787 ;D5 310862 ;D6 7382896", "B6b/8/8/8/2K5/5k2/8/b6B b - - 0 1 ;D1 6 ;D2 106 ;D3 1829 ;D4 31151 ;D5 530585 ;D6 9250746", "8/8/1B6/7b/7k/8/2B1b3/7K b - - 0 1 ;D1 17 ;D2 309 ;D3 5133 ;D4 93603 ;D5 1591064 ;D6 29027891", "k7/B7/1B6/1B6/8/8/8/K6b b - - 0 1 ;D1 7 ;D2 143 ;D3 1416 ;D4 31787 ;D5 310862 ;D6 7382896", "K7/b7/1b6/1b6/8/8/8/k6B b - - 0 1 ;D1 21 ;D2 144 ;D3 3242 ;D4 32955 ;D5 787524 ;D6 7881673", "7k/RR6/8/8/8/8/rr6/7K w - - 0 1 ;D1 19 ;D2 275 ;D3 5300 ;D4 104342 ;D5 2161211 ;D6 44956585", "R6r/8/8/2K5/5k2/8/8/r6R w - - 0 1 ;D1 36 ;D2 1027 ;D3 29215 ;D4 771461 ;D5 20506480 ;D6 525169084", "7k/RR6/8/8/8/8/rr6/7K b - - 0 1 ;D1 19 ;D2 275 ;D3 5300 ;D4 104342 ;D5 2161211 ;D6 44956585", "R6r/8/8/2K5/5k2/8/8/r6R b - - 0 1 ;D1 36 ;D2 1027 ;D3 29227 ;D4 771368 ;D5 20521342 ;D6 524966748", "6kq/8/8/8/8/8/8/7K w - - 0 1 ;D1 2 ;D2 36 ;D3 143 ;D4 3637 ;D5 14893 ;D6 391507", "6KQ/8/8/8/8/8/8/7k b - - 0 1 ;D1 2 ;D2 36 ;D3 143 ;D4 3637 ;D5 14893 ;D6 391507", "K7/8/8/3Q4/4q3/8/8/7k w - - 0 1 ;D1 6 ;D2 35 ;D3 495 ;D4 8349 ;D5 166741 ;D6 3370175", "6qk/8/8/8/8/8/8/7K b - - 0 1 ;D1 22 ;D2 43 ;D3 1015 ;D4 4167 ;D5 105749 ;D6 419369", "6KQ/8/8/8/8/8/8/7k b - - 0 1 ;D1 2 ;D2 36 ;D3 143 ;D4 3637 ;D5 14893 ;D6 391507", "K7/8/8/3Q4/4q3/8/8/7k b - - 0 1 ;D1 6 ;D2 35 ;D3 495 ;D4 8349 ;D5 166741 ;D6 3370175", "8/8/8/8/8/K7/P7/k7 w - - 0 1 ;D1 3 ;D2 7 ;D3 43 ;D4 199 ;D5 1347 ;D6 6249", "8/8/8/8/8/7K/7P/7k w - - 0 1 ;D1 3 ;D2 7 ;D3 43 ;D4 199 ;D5 1347 ;D6 6249", "K7/p7/k7/8/8/8/8/8 w - - 0 1 ;D1 1 ;D2 3 ;D3 12 ;D4 80 ;D5 342 ;D6 2343", "7K/7p/7k/8/8/8/8/8 w - - 0 1 ;D1 1 ;D2 3 ;D3 12 ;D4 80 ;D5 342 ;D6 2343", "8/2k1p3/3pP3/3P2K1/8/8/8/8 w - - 0 1 ;D1 7 ;D2 35 ;D3 210 ;D4 1091 ;D5 7028 ;D6 34834", "8/8/8/8/8/K7/P7/k7 b - - 0 1 ;D1 1 ;D2 3 ;D3 12 ;D4 80 ;D5 342 ;D6 2343", "8/8/8/8/8/7K/7P/7k b - - 0 1 ;D1 1 ;D2 3 ;D3 12 ;D4 80 ;D5 342 ;D6 2343", "K7/p7/k7/8/8/8/8/8 b - - 0 1 ;D1 3 ;D2 7 ;D3 43 ;D4 199 ;D5 1347 ;D6 6249", "7K/7p/7k/8/8/8/8/8 b - - 0 1 ;D1 3 ;D2 7 ;D3 43 ;D4 199 ;D5 1347 ;D6 6249", "8/2k1p3/3pP3/3P2K1/8/8/8/8 b - - 0 1 ;D1 5 ;D2 35 ;D3 182 ;D4 1091 ;D5 5408 ;D6 34822", "8/8/8/8/8/4k3/4P3/4K3 w - - 0 1 ;D1 2 ;D2 8 ;D3 44 ;D4 282 ;D5 1814 ;D6 11848", "4k3/4p3/4K3/8/8/8/8/8 b - - 0 1 ;D1 2 ;D2 8 ;D3 44 ;D4 282 ;D5 1814 ;D6 11848", "8/8/7k/7p/7P/7K/8/8 w - - 0 1 ;D1 3 ;D2 9 ;D3 57 ;D4 360 ;D5 1969 ;D6 10724", "8/8/k7/p7/P7/K7/8/8 w - - 0 1 ;D1 3 ;D2 9 ;D3 57 ;D4 360 ;D5 1969 ;D6 10724", "8/8/3k4/3p4/3P4/3K4/8/8 w - - 0 1 ;D1 5 ;D2 25 ;D3 180 ;D4 1294 ;D5 8296 ;D6 53138", "8/3k4/3p4/8/3P4/3K4/8/8 w - - 0 1 ;D1 8 ;D2 61 ;D3 483 ;D4 3213 ;D5 23599 ;D6 157093", "8/8/3k4/3p4/8/3P4/3K4/8 w - - 0 1 ;D1 8 ;D2 61 ;D3 411 ;D4 3213 ;D5 21637 ;D6 158065", "k7/8/3p4/8/3P4/8/8/7K w - - 0 1 ;D1 4 ;D2 15 ;D3 90 ;D4 534 ;D5 3450 ;D6 20960", "8/8/7k/7p/7P/7K/8/8 b - - 0 1 ;D1 3 ;D2 9 ;D3 57 ;D4 360 ;D5 1969 ;D6 10724", "8/8/k7/p7/P7/K7/8/8 b - - 0 1 ;D1 3 ;D2 9 ;D3 57 ;D4 360 ;D5 1969 ;D6 10724", "8/8/3k4/3p4/3P4/3K4/8/8 b - - 0 1 ;D1 5 ;D2 25 ;D3 180 ;D4 1294 ;D5 8296 ;D6 53138", "8/3k4/3p4/8/3P4/3K4/8/8 b - - 0 1 ;D1 8 ;D2 61 ;D3 411 ;D4 3213 ;D5 21637 ;D6 158065", "8/8/3k4/3p4/8/3P4/3K4/8 b - - 0 1 ;D1 8 ;D2 61 ;D3 483 ;D4 3213 ;D5 23599 ;D6 157093", "k7/8/3p4/8/3P4/8/8/7K b - - 0 1 ;D1 4 ;D2 15 ;D3 89 ;D4 537 ;D5 3309 ;D6 21104", "7k/3p4/8/8/3P4/8/8/K7 w - - 0 1 ;D1 4 ;D2 19 ;D3 117 ;D4 720 ;D5 4661 ;D6 32191", "7k/8/8/3p4/8/8/3P4/K7 w - - 0 1 ;D1 5 ;D2 19 ;D3 116 ;D4 716 ;D5 4786 ;D6 30980", "k7/8/8/7p/6P1/8/8/K7 w - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "k7/8/7p/8/8/6P1/8/K7 w - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "k7/8/8/6p1/7P/8/8/K7 w - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "k7/8/6p1/8/8/7P/8/K7 w - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "k7/8/8/3p4/4p3/8/8/7K w - - 0 1 ;D1 3 ;D2 15 ;D3 84 ;D4 573 ;D5 3013 ;D6 22886", "k7/8/3p4/8/8/4P3/8/7K w - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4271 ;D6 28662", "7k/3p4/8/8/3P4/8/8/K7 b - - 0 1 ;D1 5 ;D2 19 ;D3 117 ;D4 720 ;D5 5014 ;D6 32167", "7k/8/8/3p4/8/8/3P4/K7 b - - 0 1 ;D1 4 ;D2 19 ;D3 117 ;D4 712 ;D5 4658 ;D6 30749", "k7/8/8/7p/6P1/8/8/K7 b - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "k7/8/7p/8/8/6P1/8/K7 b - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "k7/8/8/6p1/7P/8/8/K7 b - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "k7/8/6p1/8/8/7P/8/K7 b - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "k7/8/8/3p4/4p3/8/8/7K b - - 0 1 ;D1 5 ;D2 15 ;D3 102 ;D4 569 ;D5 4337 ;D6 22579", "k7/8/3p4/8/8/4P3/8/7K b - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4271 ;D6 28662", "7k/8/8/p7/1P6/8/8/7K w - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "7k/8/p7/8/8/1P6/8/7K w - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "7k/8/8/1p6/P7/8/8/7K w - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "7k/8/1p6/8/8/P7/8/7K w - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "k7/7p/8/8/8/8/6P1/K7 w - - 0 1 ;D1 5 ;D2 25 ;D3 161 ;D4 1035 ;D5 7574 ;D6 55338", "k7/6p1/8/8/8/8/7P/K7 w - - 0 1 ;D1 5 ;D2 25 ;D3 161 ;D4 1035 ;D5 7574 ;D6 55338", "3k4/3pp3/8/8/8/8/3PP3/3K4 w - - 0 1 ;D1 7 ;D2 49 ;D3 378 ;D4 2902 ;D5 24122 ;D6 199002", "7k/8/8/p7/1P6/8/8/7K b - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "7k/8/p7/8/8/1P6/8/7K b - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "7k/8/8/1p6/P7/8/8/7K b - - 0 1 ;D1 5 ;D2 22 ;D3 139 ;D4 877 ;D5 6112 ;D6 41874", "7k/8/1p6/8/8/P7/8/7K b - - 0 1 ;D1 4 ;D2 16 ;D3 101 ;D4 637 ;D5 4354 ;D6 29679", "k7/7p/8/8/8/8/6P1/K7 b - - 0 1 ;D1 5 ;D2 25 ;D3 161 ;D4 1035 ;D5 7574 ;D6 55338", "k7/6p1/8/8/8/8/7P/K7 b - - 0 1 ;D1 5 ;D2 25 ;D3 161 ;D4 1035 ;D5 7574 ;D6 55338", "3k4/3pp3/8/8/8/8/3PP3/3K4 b - - 0 1 ;D1 7 ;D2 49 ;D3 378 ;D4 2902 ;D5 24122 ;D6 199002", "8/Pk6/8/8/8/8/6Kp/8 w - - 0 1 ;D1 11 ;D2 97 ;D3 887 ;D4 8048 ;D5 90606 ;D6 1030499", "n1n5/1Pk5/8/8/8/8/5Kp1/5N1N w - - 0 1 ;D1 24 ;D2 421 ;D3 7421 ;D4 124608 ;D5 2193768 ;D6 37665329", "8/PPPk4/8/8/8/8/4Kppp/8 w - - 0 1 ;D1 18 ;D2 270 ;D3 4699 ;D4 79355 ;D5 1533145 ;D6 28859283", "n1n5/PPPk4/8/8/8/8/4Kppp/5N1N w - - 0 1 ;D1 24 ;D2 496 ;D3 9483 ;D4 182838 ;D5 3605103 ;D6 71179139", "8/Pk6/8/8/8/8/6Kp/8 b - - 0 1 ;D1 11 ;D2 97 ;D3 887 ;D4 8048 ;D5 90606 ;D6 1030499", "n1n5/1Pk5/8/8/8/8/5Kp1/5N1N b - - 0 1 ;D1 24 ;D2 421 ;D3 7421 ;D4 124608 ;D5 2193768 ;D6 37665329", "8/PPPk4/8/8/8/8/4Kppp/8 b - - 0 1 ;D1 18 ;D2 270 ;D3 4699 ;D4 79355 ;D5 1533145 ;D6 28859283", "n1n5/PPPk4/8/8/8/8/4Kppp/5N1N b - - 0 1 ;D1 24 ;D2 496 ;D3 9483 ;D4 182838 ;D5 3605103 ;D6 71179139", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1 ;D1 14 ;D2 191 ;D3 2812 ;D4 43238 ;D5 674624 ;D6 11030083", "rnbqkb1r/ppppp1pp/7n/4Pp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3 ;D1 31 ;D2 570 ;D3 17546 ;D4 351806 ;D5 11139762 ;D6 244063299"] - ; diff --git a/toad/Cargo.toml b/toad/Cargo.toml new file mode 100644 index 0000000..e6fc257 --- /dev/null +++ b/toad/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "toad" +version = "2.0.0" +edition = "2021" +authors = ["Danny Hammer "] +license = "MPL-2.0" +description = "A toy chess engine" +repository = "https://github.com/dannyhammer/toad" +homepage = "https://github.com/dannyhammer/toad" +keywords = ["chess", "chess-engine", "uci"] + +[dependencies] +anyhow = "1.0.89" +arrayvec = "0.7.6" +clap = { version = "4.5.18", features = ["derive", "string"] } +thiserror = "2.0.7" +#uci-parser = { path = "../../uci-parser", features = ["parse-go-perft", "parse-position-kiwipete", "clamp-negatives", "err-on-unused-input"] } +uci-parser = { git = "https://github.com/dannyhammer/uci-parser.git", features = ["parse-go-perft", "parse-position-kiwipete", "clamp-negatives", "err-on-unused-input"] } +#uci-parser = { version = "0.2.0", features = ["parse-go-perft", "parse-position-kiwipete", "clamp-negatives", "err-on-unused-input"] } \ No newline at end of file diff --git a/toad/README.md b/toad/README.md new file mode 120000 index 0000000..dd18b66 --- /dev/null +++ b/toad/README.md @@ -0,0 +1 @@ +/home/danny/Projects/chess/toad/README.md \ No newline at end of file diff --git a/benches/standard.epd b/toad/benches/standard.epd similarity index 100% rename from benches/standard.epd rename to toad/benches/standard.epd diff --git a/src/board/bitboard.rs b/toad/src/board/bitboard.rs similarity index 99% rename from src/board/bitboard.rs rename to toad/src/board/bitboard.rs index b1e2072..dad415e 100644 --- a/src/board/bitboard.rs +++ b/toad/src/board/bitboard.rs @@ -12,7 +12,7 @@ use std::{ use anyhow::{anyhow, bail}; -use super::{Color, File, Rank, SmallDisplayTable, Square}; +use crate::{Color, File, Rank, SmallDisplayTable, Square}; /// A [`Bitboard`] represents the game board as a set of bits. /// They are used for various computations, such as fetching valid moves or computing move costs. diff --git a/src/board/magics.rs b/toad/src/board/magics.rs similarity index 100% rename from src/board/magics.rs rename to toad/src/board/magics.rs diff --git a/src/board/movegen.rs b/toad/src/board/movegen.rs similarity index 99% rename from src/board/movegen.rs rename to toad/src/board/movegen.rs index 860dfa0..4c0c5e3 100644 --- a/src/board/movegen.rs +++ b/toad/src/board/movegen.rs @@ -4,7 +4,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use super::{Bitboard, Color, Rank, Square}; +use crate::{Bitboard, Color, Rank, Square}; // Include the pre-generated magics include!("magics.rs"); diff --git a/src/board/moves.rs b/toad/src/board/moves.rs similarity index 95% rename from src/board/moves.rs rename to toad/src/board/moves.rs index 390273a..f3d6157 100644 --- a/src/board/moves.rs +++ b/toad/src/board/moves.rs @@ -4,11 +4,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use std::{fmt, str::FromStr}; +use std::{fmt, num::NonZeroU16, str::FromStr}; use anyhow::{anyhow, Result}; -use super::{File, Piece, PieceKind, Position, Rank, Square}; +use crate::{File, Piece, PieceKind, Position, Rank, Square}; /// Maximum possible number of moves in a given chess position. /// @@ -194,6 +194,10 @@ impl fmt::Display for MoveKind { } } +// Ensure that the `Move` type is correctly sized. +const _ASSERT_MOVE_SIZE_IS_2_BYTES: () = assert!(std::mem::size_of::() == 2); +const _ASSERT_OPTION_MOVE_SIZE_IS_2_BYTES: () = assert!(std::mem::size_of::>() == 2); + /// Represents a move made on a chess board, including whether a piece is to be promoted. /// /// Internally encoded using the following bit pattern: @@ -208,7 +212,7 @@ impl fmt::Display for MoveKind { /// Flags are fetched directly from the [Chess Programming Wiki](https://www.chessprogramming.org/Encoding_Moves#From-To_Based). #[derive(Clone, Copy, PartialEq, Eq, Hash)] #[repr(transparent)] -pub struct Move(u16); +pub struct Move(NonZeroU16); impl Move { /// Mask for the source ("from") bits. @@ -250,9 +254,12 @@ impl Move { /// ``` #[inline(always)] pub const fn new(from: Square, to: Square, kind: MoveKind) -> Self { - Self(kind as u16 | (to.inner() as u16) << Self::DST_BITS | from.inner() as u16) + let n = kind as u16 | (to.inner() as u16) << Self::DST_BITS | from.inner() as u16; + let inner = unsafe { NonZeroU16::new_unchecked(n) }; + Self(inner) } + /* /// Creates an "illegal" [`Move`], representing moving a piece to and from the same [`Square`]. /// /// Playing this move on a [`Position`] is *not* the same as playing a [null move](https://www.chessprogramming.org/Null_Move). @@ -261,11 +268,18 @@ impl Move { /// ``` /// # use toad::Move; /// let illegal = Move::illegal(); - /// assert_eq!(illegal.to_string(), "a1a1"); + /// assert_eq!(illegal.to_string(), "h8h8q"); /// ``` #[inline(always)] pub const fn illegal() -> Self { - Self(0) + Self(NonZeroU16::MAX) + } + */ + + /// Retrieve the inner `u16` representation of this [`Move`]. + #[inline(always)] + pub const fn inner(self) -> u16 { + self.0.get() } /// Fetches the source (or "from") part of this [`Move`], as a [`Square`]. @@ -279,7 +293,7 @@ impl Move { /// ``` #[inline(always)] pub const fn from(&self) -> Square { - Square::from_bits_unchecked((self.0 & Self::SRC_MASK) as u8) + Square::from_bits_unchecked((self.inner() & Self::SRC_MASK) as u8) } /// Fetches the destination (or "to") part of this [`Move`], as a [`Square`]. @@ -293,7 +307,7 @@ impl Move { /// ``` #[inline(always)] pub const fn to(&self) -> Square { - Square::from_bits_unchecked(((self.0 & Self::DST_MASK) >> Self::DST_BITS) as u8) + Square::from_bits_unchecked(((self.inner() & Self::DST_MASK) >> Self::DST_BITS) as u8) } /// Fetches the [`MoveKind`] part of this [`Move`]. @@ -308,7 +322,7 @@ impl Move { pub fn kind(&self) -> MoveKind { // Safety: Since a `Move` can ONLY be constructed through the public API, // any instance of a `Move` is guaranteed to have a valid bit pattern for its `MoveKind`. - unsafe { std::mem::transmute(self.0 & Self::FLG_MASK) } + unsafe { std::mem::transmute(self.inner() & Self::FLG_MASK) } } /// Returns `true` if this [`Move`] is a capture of any kind (capture, promotion-capture, en passant capture). @@ -326,7 +340,7 @@ impl Move { /// ``` #[inline(always)] pub const fn is_capture(&self) -> bool { - self.0 & Self::FLAG_CAPTURE != 0 + self.inner() & Self::FLAG_CAPTURE != 0 } /// Returns `true` if this [`Move`] is a non-capture (quiet) move. @@ -347,25 +361,25 @@ impl Move { /// ``` #[inline(always)] pub const fn is_quiet(&self) -> bool { - self.0 & Self::FLAG_CAPTURE == 0 + self.inner() & Self::FLAG_CAPTURE == 0 } /// Returns `true` if this [`Move`] is en passant. #[inline(always)] pub const fn is_en_passant(&self) -> bool { - (self.0 & Self::FLG_MASK) ^ Self::FLAG_EP_CAPTURE == 0 + (self.inner() & Self::FLG_MASK) ^ Self::FLAG_EP_CAPTURE == 0 } /// Returns `true` if this [`Move`] is a short (kingside) castle. #[inline(always)] pub const fn is_short_castle(&self) -> bool { - (self.0 & Self::FLG_MASK) ^ Self::FLAG_CASTLE_SHORT == 0 + (self.inner() & Self::FLG_MASK) ^ Self::FLAG_CASTLE_SHORT == 0 } /// Returns `true` if this [`Move`] is a long (queenside) castle. #[inline(always)] pub const fn is_long_castle(&self) -> bool { - (self.0 & Self::FLG_MASK) ^ Self::FLAG_CASTLE_LONG == 0 + (self.inner() & Self::FLG_MASK) ^ Self::FLAG_CASTLE_LONG == 0 } // #[inline(always)] @@ -376,9 +390,10 @@ impl Move { /// If this [`Move`] is a castling move, returns the [`File`]s of the destinations for the King and Rook, respectively. #[inline(always)] pub const fn castling_files(&self) -> Option<(File, File)> { - if (self.0 & Self::FLG_MASK) ^ Self::FLAG_CASTLE_SHORT == 0 { + let inner = self.inner(); + if (inner & Self::FLG_MASK) ^ Self::FLAG_CASTLE_SHORT == 0 { Some((File::G, File::F)) - } else if (self.0 & Self::FLG_MASK) ^ Self::FLAG_CASTLE_LONG == 0 { + } else if (inner & Self::FLG_MASK) ^ Self::FLAG_CASTLE_LONG == 0 { Some((File::C, File::D)) } else { None @@ -395,7 +410,7 @@ impl Move { /// ``` #[inline(always)] pub const fn is_pawn_double_push(&self) -> bool { - (self.0 & Self::FLG_MASK) ^ Self::FLAG_PAWN_DOUBLE == 0 + (self.inner() & Self::FLG_MASK) ^ Self::FLAG_PAWN_DOUBLE == 0 } /* @@ -430,7 +445,7 @@ impl Move { /// ``` #[inline(always)] pub fn promotion(&self) -> Option { - match self.0 & Self::FLG_MASK { + match self.inner() & Self::FLG_MASK { Self::FLAG_PROMO_QUEEN | Self::FLAG_CAPTURE_PROMO_QUEEN => Some(PieceKind::Queen), Self::FLAG_PROMO_KNIGHT | Self::FLAG_CAPTURE_PROMO_KNIGHT => Some(PieceKind::Knight), Self::FLAG_PROMO_ROOK | Self::FLAG_CAPTURE_PROMO_ROOK => Some(PieceKind::Rook), @@ -678,6 +693,7 @@ impl fmt::Debug for Move { } } +/* impl Default for Move { /// A "default" move is an illegal move. See [`Move::illegal`] /// @@ -687,6 +703,7 @@ impl Default for Move { Self::illegal() } } + */ impl> PartialEq for Move { #[inline(always)] diff --git a/src/board/perft.rs b/toad/src/board/perft.rs similarity index 99% rename from src/board/perft.rs rename to toad/src/board/perft.rs index 43afc5c..6d55ddc 100644 --- a/src/board/perft.rs +++ b/toad/src/board/perft.rs @@ -4,7 +4,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use super::{Game, Variant}; +use crate::{Game, Variant}; /// Perform a perft at the specified depth, collecting only data about the number of possible positions (nodes). /// diff --git a/src/board/piece.rs b/toad/src/board/piece.rs similarity index 100% rename from src/board/piece.rs rename to toad/src/board/piece.rs diff --git a/src/board/prng.rs b/toad/src/board/prng.rs similarity index 100% rename from src/board/prng.rs rename to toad/src/board/prng.rs diff --git a/src/board/square.rs b/toad/src/board/square.rs similarity index 99% rename from src/board/square.rs rename to toad/src/board/square.rs index 1e16480..6cefe10 100644 --- a/src/board/square.rs +++ b/toad/src/board/square.rs @@ -12,7 +12,7 @@ use std::{ use anyhow::{bail, Context, Result}; -use super::{Bitboard, Color}; +use crate::{Bitboard, Color}; /// Manhattan and Chebyshev distance between any two squares #[rustfmt::skip] diff --git a/src/board/table.rs b/toad/src/board/table.rs similarity index 100% rename from src/board/table.rs rename to toad/src/board/table.rs diff --git a/src/board/zobrist.rs b/toad/src/board/zobrist.rs similarity index 99% rename from src/board/zobrist.rs rename to toad/src/board/zobrist.rs index 632c4de..ca35772 100644 --- a/src/board/zobrist.rs +++ b/toad/src/board/zobrist.rs @@ -6,7 +6,7 @@ use std::fmt; -use super::{Board, CastlingRights, Color, Piece, Position, Rank, Square, XoShiRo}; +use crate::{Board, CastlingRights, Color, Piece, Position, Rank, Square, XoShiRo}; /// Stores Zobrist hash keys, for hashing [`Position`]s. /// diff --git a/src/cli.rs b/toad/src/cli.rs similarity index 96% rename from src/cli.rs rename to toad/src/cli.rs index 3ae78a7..7f39c5c 100644 --- a/src/cli.rs +++ b/toad/src/cli.rs @@ -19,12 +19,6 @@ use uci_parser::UciCommand; override_usage(" | ") )] pub enum EngineCommand { - /// Await the current search, blocking until it completes. - /// - /// This is primarily used when executing searches on startup, - /// to await their results before doing something else. - Await, - /// Run a benchmark with the provided parameters. Bench { /// If set, the benchmarking results will be printed in a well-formatted table. @@ -104,6 +98,9 @@ pub enum EngineCommand { /// Performs a perft on the current position at the supplied depth, printing total node count. Perft { depth: usize }, + /// Place a piece on the provided square. + Place { piece: Piece, square: Square }, + /// Outputs the Piece-Square table value for the provided piece at the provided square, scaled with the endgame weight. /// /// If no square was provided, the entire table(s) will be printed. @@ -124,9 +121,18 @@ pub enum EngineCommand { #[command(alias = "sperft")] Splitperft { depth: usize }, + /// Remove the piece at the provided square. + Take { square: Square }, + /// Wrapper over UCI commands sent to the engine. #[command(skip)] Uci { cmd: UciCommand }, + + /// Await the current search, blocking until it completes. + /// + /// This is primarily used when executing searches on startup, + /// to await their results before doing something else. + Wait, } impl FromStr for EngineCommand { diff --git a/toad/src/depth.rs b/toad/src/depth.rs new file mode 100644 index 0000000..b253e81 --- /dev/null +++ b/toad/src/depth.rs @@ -0,0 +1,258 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use std::{fmt, ops::AddAssign}; + +/// A representation of the vertical distance between nodes in a search tree. +/// +/// Internally this value is upper-bounded by [`Ply::MAX`] and uses +/// [fractional plies](https://www.chessprogramming.org/Ply#Fractional_Plies) +/// with a granularity defined internally. +#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct Ply(i32); + +impl Ply { + /// Maximum depth reachable during a search. + pub const MAX: Self = Self::new((u8::MAX / 2) as i32); + + /// A depth of zero plies. + pub const ZERO: Self = Self::new(0); + + /// A depth of one ply. + pub const ONE: Self = Self::new(1); + + /// Granularity of a single ply. + /// + /// The higher this number is, the finer the grain of fractional depth. + const GRAIN: i32 = 100; + + /// Constructs a new [`Ply`] instance that is `n` plies deep. + #[inline(always)] + pub const fn new(n: i32) -> Self { + Self(n * Self::GRAIN) + } + + /// Constructs a new [`Ply`] instance containing `n` as the raw internal value. + /// + /// This does *not* scale according to the granularity of [`Ply`]'s fractional depth. + #[inline(always)] + pub const fn from_raw(n: i32) -> Self { + Self(n) + } + + /// Returns the number of plies this depth represents, truncating any fractional depth. + #[inline(always)] + pub const fn plies(&self) -> i32 { + self.0 / Self::GRAIN + } + + /// Returns the number of plies this depth represents, rounding to the nearest whole depth. + #[inline(always)] + pub fn rounded(&self) -> i32 { + (self.0 as f32 / Self::GRAIN as f32).round() as i32 + } +} + +impl std::ops::Add for Ply { + type Output = Self; + + #[inline(always)] + fn add(self, rhs: Self) -> Self::Output { + Self(self.0.add(rhs.0)) + } +} + +impl std::ops::Sub for Ply { + type Output = Self; + + #[inline(always)] + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0.sub(rhs.0)) + } +} + +impl std::ops::Mul for Ply { + type Output = Self; + + #[inline(always)] + fn mul(self, rhs: Self) -> Self::Output { + Self(self.0.mul(rhs.plies())) + } +} + +impl std::ops::Div for Ply { + type Output = Self; + + #[inline(always)] + fn div(self, rhs: Self) -> Self::Output { + Self(self.0.div(rhs.plies())) + } +} + +impl std::ops::Add for Ply { + type Output = Ply; + #[inline(always)] + fn add(self, rhs: i32) -> Self::Output { + Self(self.0.add(Self::new(rhs).0)) + } +} + +impl std::ops::Sub for Ply { + type Output = Ply; + #[inline(always)] + fn sub(self, rhs: i32) -> Self::Output { + Self(self.0.sub(Self::new(rhs).0)) + } +} + +impl std::ops::Mul for Ply { + type Output = Ply; + #[inline(always)] + fn mul(self, rhs: i32) -> Self::Output { + Self(self.0.mul(rhs)) + } +} + +impl std::ops::Div for Ply { + type Output = Ply; + #[inline(always)] + fn div(self, rhs: i32) -> Self::Output { + Self(self.0.div(rhs)) + } +} + +impl AddAssign for Ply { + #[inline(always)] + fn add_assign(&mut self, rhs: Self) { + self.0.add_assign(rhs.0); + } +} + +impl std::ops::AddAssign for Ply { + #[inline(always)] + fn add_assign(&mut self, rhs: i32) { + self.0.add_assign(&Self::new(rhs).0); + } +} + +impl std::ops::SubAssign for Ply { + #[inline(always)] + fn sub_assign(&mut self, rhs: Self) { + self.0.sub_assign(rhs.0); + } +} + +impl std::ops::SubAssign for Ply { + #[inline(always)] + fn sub_assign(&mut self, rhs: i32) { + self.0.sub_assign(&Self::new(rhs).0); + } +} + +impl PartialEq for Ply { + #[inline(always)] + fn eq(&self, other: &i32) -> bool { + self.0.eq(&Self::new(*other).0) + } +} + +impl PartialOrd for Ply { + #[inline(always)] + fn partial_cmp(&self, other: &i32) -> Option { + self.0.partial_cmp(&(other * Self::GRAIN)) + } +} + +impl fmt::Display for Ply { + #[inline(always)] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.plies().fmt(f) + } +} + +impl fmt::Debug for Ply { + #[inline(always)] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self} ({})", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_math() { + let mut depth = Ply::ZERO; + assert_eq!(depth, Ply::new(0)); + + depth += 1; + assert_eq!(depth, Ply::new(1)); + assert_eq!(depth, Ply::ONE); + + depth = depth + 5; + assert_eq!(depth, Ply::new(6)); + + depth = depth * 2; + assert_eq!(depth, Ply::new(12)); + + depth = depth / 3; + assert_eq!(depth, Ply::new(4)); + + depth = depth - 3; + assert_eq!(depth, Ply::new(1)); + + // Now for combining depths + depth = depth + Ply::new(5); + assert_eq!(depth, Ply::new(6)); + + depth = depth * Ply::new(2); + assert_eq!(depth, Ply::new(12)); + + depth = depth / Ply::new(3); + assert_eq!(depth, Ply::new(4)); + + depth = depth - Ply::new(3); + assert_eq!(depth, Ply::new(1)); + } + + #[test] + fn test_fractional() { + // Ensure that fractional depths truncate properly after mathematical operations. + let mut depth = Ply(50); + assert_eq!(depth.plies(), 0); + + depth += 1; + assert_eq!(depth.plies(), 1); + + // Ensure that fractional depths are truncated/rounded appropriately + let half = Ply::GRAIN / 2; + for i in 0..Ply::GRAIN { + let depth = Ply(i); + assert_eq!(depth.plies(), 0, "Depth {depth:?} did not truncate to 0"); + + if i < half { + assert_eq!(depth.rounded(), 0, "Depth {depth:?} did not round to 0"); + } else { + assert_eq!(depth.rounded(), 1, "Depth {depth:?} did not round to 1"); + } + } + } + + #[test] + fn test_negative() { + let mut depth = Ply::ONE; + depth -= 1; + assert_eq!(depth, Ply(0)); + assert_eq!(depth.plies(), 0); + + depth -= 1; + assert_eq!(depth, Ply::new(-1)); + assert_eq!(depth, Ply(-Ply::GRAIN)); + assert_eq!(depth.plies(), -1); + } +} diff --git a/src/engine.rs b/toad/src/engine.rs similarity index 87% rename from src/engine.rs rename to toad/src/engine.rs index 3890e35..9067659 100644 --- a/src/engine.rs +++ b/toad/src/engine.rs @@ -4,9 +4,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use core::fmt; use std::{ - io, + fmt, + io::{self, Write}, ops::ControlFlow, sync::{ atomic::{AtomicBool, Ordering}, @@ -21,12 +21,13 @@ use uci_parser::{UciCommand, UciInfo, UciOption, UciParseError, UciResponse}; use crate::{ perft, splitperft, Bitboard, Chess960, EngineCommand, Game, GameVariant, HistoryTable, - LogLevel, MediumDisplayTable, Move, Piece, Position, Psqt, Score, Search, SearchConfig, - SearchResult, Square, Standard, TTable, Variant, BENCHMARK_FENS, + LogDebug, LogInfo, LogLevel, LogNone, MediumDisplayTable, Move, Piece, Ply, Position, Psqt, + Score, Search, SearchConfig, SearchParameters, SearchResult, Square, Standard, TTable, Variant, + BENCHMARK_FENS, }; /// Default depth at which to run the benchmark searches. -const BENCH_DEPTH: u8 = 11; +const BENCH_DEPTH: u8 = 13; /// The Toad chess engine. #[derive(Debug)] @@ -56,6 +57,9 @@ pub struct Engine { /// Whether to display extra information during execution. debug: bool, + + /// Parameters for search features like pruning, extensions, etc. + params: Arc>, } impl Engine { @@ -69,11 +73,12 @@ impl Engine { prev_positions: Vec::with_capacity(512), sender, receiver, - is_searching: Arc::default(), - search_thread: None, - ttable: Arc::default(), - history: Arc::default(), + is_searching: Default::default(), + search_thread: Default::default(), + ttable: Default::default(), + history: Default::default(), debug: false, + params: Default::default(), } } @@ -99,7 +104,9 @@ impl Engine { // Safe unwrap: `send` can only fail if it's corresponding receiver doesn't exist, // and the only way our engine's `Receiver` can no longer exist is when our engine // doesn't exist either, so this is always safe. - self.sender.send(command).unwrap(); + self.sender + .send(command) + .expect("Failed to send a command to the engine via channels."); } /// Entrypoint of the engine. @@ -145,13 +152,11 @@ impl Engine { // Execute commands as they are received while let Ok(cmd) = self.receiver.recv() { - if self.debug { - println!("info string Received command {cmd:?}"); - } + // if self.debug { + // println!("info string Received command {cmd:?}"); + // } match cmd { - EngineCommand::Await => _ = self.stop_search(), - EngineCommand::Bench { depth, pretty } => self.bench(depth, pretty), EngineCommand::ChangeVariant { variant } => { @@ -186,6 +191,8 @@ impl Engine { EngineCommand::Flip => game.toggle_side_to_move(), + EngineCommand::HashInfo => self.hash_info(), + EngineCommand::MakeMove { mv_string } => match Move::from_uci(&game, &mv_string) { Ok(mv) => self.make_move(&mut game, mv), Err(e) => eprintln!("{e:#}"), @@ -208,6 +215,13 @@ impl Engine { EngineCommand::Perft { depth } => println!("{}", perft(&game, depth)), + EngineCommand::Place { piece, square } => { + game.place(piece, square); + if self.debug { + println!("Placed {piece} at {square}"); + } + } + EngineCommand::Psqt { piece, square, @@ -218,7 +232,13 @@ impl Engine { println!("{}", splitperft(&game, depth)) } - EngineCommand::HashInfo => self.hash_info(), + EngineCommand::Take { square } => { + if let Some(piece) = game.take(square) { + if self.debug { + println!("Removed {piece} at {square}"); + } + } + } EngineCommand::Uci { cmd } => { // UCI spec states to continue execution if an error occurs @@ -226,6 +246,8 @@ impl Engine { eprintln!("Error: {e:#}"); } } + + EngineCommand::Wait => _ = self.stop_search(), }; } @@ -261,9 +283,9 @@ impl Engine { let config = SearchConfig::new(options, game); self.search_thread = if self.debug { - self.start_search::<{ LogLevel::Debug as u8 }, V>(*game, config) + self.start_search::(*game, config) } else { - self.start_search::<{ LogLevel::Info as u8 }, V>(*game, config) + self.start_search::(*game, config) }; } @@ -286,32 +308,40 @@ impl Engine { fn bench(&mut self, depth: Option, pretty: bool) { // Set up the benchmarking config let config = SearchConfig { - max_depth: depth.unwrap_or(BENCH_DEPTH), + max_depth: Ply::new(depth.unwrap_or(BENCH_DEPTH) as i32), ..Default::default() }; let benches = BENCHMARK_FENS; - let num_tests = benches.len(); let mut nodes = 0; - // Run a fixed search on each position - for (i, epd) in benches.into_iter().enumerate() { - // Parse the FEN and the total node count + // Padding for printing FENs + let width = benches.iter().map(|fen| fen.len()).max().unwrap(); + + println!( + "Running fixed-depth search (d={}) on {} positions", + config.max_depth, + benches.len() + ); - let fen = epd.split(';').next().unwrap(); - println!("Benchmark position {}/{}: {fen}", i + 1, num_tests); + // Run a fixed search on each position + for (i, fen) in benches.into_iter().enumerate() { + print!("{:>2}/{:>2}: {fen:(); } // Compute results @@ -323,14 +353,14 @@ impl Engine { if pretty { // Display the results in a nice table println!(); - println!("+--- Benchmark Complete ---+"); - println!("| time (ms) : {ms:<12}|"); - println!("| nodes : {nodes:<12}|"); - println!("| nps : {nps:<12}|"); - println!("| Mnps : {m_nps:<12.2}|"); - println!("+--------------------------+"); + println!("+-- Benchmark Complete --+"); + println!("| time (ms) {ms:<12}|"); + println!("| nodes {nodes:<12}|"); + println!("| nps {nps:<12}|"); + println!("| Mnps {m_nps:<12.2}|"); + println!("+------------------------+"); } else { - println!("{nodes} nodes {nps} nps"); + println!("{nodes} nodes / {elapsed:?} := {nps} nps"); } // Re-set the internal game state. @@ -342,7 +372,7 @@ impl Engine { use std::cmp::Ordering::*; if pretty { let color = game.side_to_move(); - let endgame_weight = game.endgame_weight(); + let endgame_weight = game.evaluator().endgame_weight(); let table = MediumDisplayTable::from_fn(|sq| { game.piece_at(sq) @@ -356,7 +386,7 @@ impl Engine { .unwrap_or_default() }); - let score = game.eval_for(color); + let score = game.evaluator().eval_for(color); let winning = match score.cmp(&Score::DRAW) { Greater => color.name(), @@ -447,7 +477,7 @@ impl Engine { // If pretty-printing, also display a Bitboard of all possible destinations if pretty { let bb = moves.iter().map(|mv| mv.to()).collect::(); - println!("{bb:?}\n\nmoves: {string}"); + println!("{bb:?}\n\nmoves ({}): {string}", moves.len()); } else { println!("{string}"); } @@ -501,7 +531,7 @@ impl Engine { endgame_weight: Option, ) { // Compute the current endgame weight, if it wasn't provided - let weight = endgame_weight.unwrap_or(game.endgame_weight()); + let weight = endgame_weight.unwrap_or(game.evaluator().endgame_weight()); // Fetch the middle-game and end-game tables let (mg, eg) = Psqt::get_tables_for(piece.kind()); @@ -542,7 +572,7 @@ impl Engine { } /// Starts a search on the current position, given the parameters in `config`. - fn start_search( + fn start_search( &mut self, game: Game, config: SearchConfig, @@ -562,20 +592,29 @@ impl Engine { prev_positions.push(*game.position()); let ttable = Arc::clone(&self.ttable); let history = Arc::clone(&self.history); + let params = Arc::clone(&self.params); // Spawn a thread to conduct the search let handle = thread::spawn(move || { // Lock the hash tables at the start of the search so that only the search thread may modify them - let mut ttable = ttable.lock().unwrap(); - let mut history = history.lock().unwrap(); + let mut ttable = ttable + .lock() + .expect("Failed to acquire Transposition Table at the start of search."); + let mut history = history + .lock() + .expect("Failed to acquire History Table at the start of search."); + let params = params + .lock() + .expect("Failed to acquire parameters at the start of search."); // Start the search, returning the result when completed. - Search::::new( + Search::::new( is_searching, config, prev_positions, &mut ttable, &mut history, + *params, ) .start(&game) }); diff --git a/src/psqt.rs b/toad/src/eval.rs similarity index 74% rename from src/psqt.rs rename to toad/src/eval.rs index 595e8f6..34546bf 100644 --- a/src/psqt.rs +++ b/toad/src/eval.rs @@ -6,10 +6,11 @@ use std::{ fmt, + marker::PhantomData, ops::{Deref, DerefMut}, }; -use crate::{Color, Piece, PieceKind, Score, SmallDisplayTable, Square, Table}; +use crate::{Color, Piece, PieceKind, Score, SmallDisplayTable, Square, Table, Variant}; /// Piece-Square tables copied from [PeSTO](https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function#Source_Code) #[rustfmt::skip] @@ -156,6 +157,92 @@ const KING_EG: Psqt = Psqt::new(PieceKind::King, [ -53, -34, -21, -11, -28, -14, -24, -43 ]); +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub struct Evaluator { + /// Material remaining on the board, for each side. + pub(crate) material: [i32; Color::COUNT], + + /// Mid-game and end-game evaluations of the board. + pub(crate) evals: (Score, Score), + + /// Variant of chess being played. + variant: PhantomData, +} + +impl Evaluator { + /// Construct a new [`Evaluator`] instance. + #[inline(always)] + pub const fn new() -> Self { + Self { + material: [0; Color::COUNT], + evals: (Score::DRAW, Score::DRAW), + variant: PhantomData, + } + } + + /// Evaluate this position from `color`'s perspective. + /// + /// A positive/high number is good for the `color`, while a negative number is better for the opponent. + /// A score of 0 is considered equal. + #[inline(always)] + pub fn eval_for(&self, color: Color) -> Score { + self.evals.0.lerp(self.evals.1, self.endgame_weight()) * color.negation_multiplier() as i32 + } + + /// Divides the original material value of the board by the current material value, yielding an `i32` in the range `[0, 100]` + /// + /// Lower numbers are closer to the beginning of the game. Higher numbers are closer to the end of the game. + /// + /// The King is ignored when performing this calculation. + #[inline(always)] + pub fn endgame_weight(&self) -> i32 { + let remaining = V::INITIAL_MATERIAL_VALUE - self.material_remaining(); + (remaining * 100 / V::INITIAL_MATERIAL_VALUE * 100) / 100 + } + + /// Returns the current mid-game and end-game evaluations. + #[inline(always)] + pub fn evals(&self) -> (Score, Score) { + self.evals + } + + /// Called when a piece is placed on a square to update the eval of the board. + #[inline(always)] + pub(crate) fn piece_placed(&mut self, piece: Piece, square: Square) { + let color = piece.color(); + let multiplier = color.negation_multiplier() as i32; + + self.material[color] += piece.kind().value(); + + // Update PSQT contributions + let (mg, eg) = Psqt::evals(piece, square); + self.evals.0 += mg * multiplier; + self.evals.1 += eg * multiplier; + } + + /// Called when a piece is removed from a square to update the eval of the board. + #[inline(always)] + pub(crate) fn piece_taken(&mut self, piece: Piece, square: Square) { + let color = piece.color(); + let multiplier = color.negation_multiplier() as i32; + + self.material[piece.color()] -= piece.kind().value(); + + // Update PSQT contributions + let (mg, eg) = Psqt::evals(piece, square); + self.evals.0 -= mg * multiplier; + self.evals.1 -= eg * multiplier; + } + + /// Counts the material value of all pieces on the board + /// + /// The King is not included in this count + #[inline(always)] + fn material_remaining(&self) -> i32 { + self.material[Color::White.index()] + self.material[Color::Black.index()] + } +} + /// A [Piece-Square Table](https://www.chessprogramming.org/Piece-Square_Tables) for use in evaluation. #[derive(Debug, Clone, Copy)] pub struct Psqt(Table); @@ -193,7 +280,7 @@ impl Psqt { while i < psqt.len() { // Flip the rank, not the file, so it can be used from White's perspective without modification // Also add in the value of this piece - flipped[i] = Score(psqt[i ^ 56] + kind.value()); + flipped[i] = Score::new(psqt[i ^ 56] + kind.value()); // flipped[i] = value_of(kind); // Functions like a material-only eval i += 1; } diff --git a/src/board/position.rs b/toad/src/game.rs similarity index 96% rename from src/board/position.rs rename to toad/src/game.rs index 0a44a13..2b9874d 100644 --- a/src/board/position.rs +++ b/toad/src/game.rs @@ -6,7 +6,6 @@ use std::{ fmt::{self, Debug, Write}, - marker::PhantomData, ops::{Deref, Index, IndexMut}, str::FromStr, }; @@ -15,9 +14,9 @@ use anyhow::{anyhow, bail, Result}; use crate::{ bishop_attacks, bishop_rays, king_attacks, knight_attacks, pawn_attacks, pawn_pushes, - queen_attacks, ray_between, ray_containing, rook_attacks, rook_rays, Bitboard, Color, File, - Move, MoveKind, MoveList, Piece, PieceKind, Psqt, Rank, Score, SmallDisplayTable, Square, - ZobristKey, + queen_attacks, ray_between, ray_containing, rook_attacks, rook_rays, Bitboard, Color, + Evaluator, File, Move, MoveKind, MoveList, Piece, PieceKind, Rank, Score, SmallDisplayTable, + Square, ZobristKey, }; use super::Table; @@ -58,6 +57,9 @@ pub trait Variant where Self: Copy + Send + 'static, { + /// Initial material value of all pieces in a standard setup. + const INITIAL_MATERIAL_VALUE: i32; + /// Formats a [`Move`] according to this variant's notation semantics. /// /// Calls the [`fmt::Display`] implementation. @@ -109,6 +111,12 @@ where #[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] pub struct Standard; impl Variant for Standard { + const INITIAL_MATERIAL_VALUE: i32 = PieceKind::Pawn.value() * 16 + + PieceKind::Knight.value() * 4 + + PieceKind::Bishop.value() * 4 + + PieceKind::Rook.value() * 4 + + PieceKind::Queen.value() * 2; + #[inline(always)] fn variant() -> GameVariant { GameVariant::Standard @@ -124,6 +132,12 @@ impl Variant for Standard { #[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] pub struct Chess960; impl Variant for Chess960 { + const INITIAL_MATERIAL_VALUE: i32 = PieceKind::Pawn.value() * 16 + + PieceKind::Knight.value() * 4 + + PieceKind::Bishop.value() * 4 + + PieceKind::Rook.value() * 4 + + PieceKind::Queen.value() * 2; + #[inline(always)] fn fmt_move(mv: Move) -> String { format!("{mv:#}") @@ -178,6 +192,14 @@ impl Variant for Chess960 { #[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] pub struct Horde; impl Variant for Horde { + const INITIAL_MATERIAL_VALUE: [i32; Color::COUNT] = [PieceKind::Pawn.value() * 32, + PieceKind::Pawn.value() * 8 + + PieceKind::Knight.value() * 2 + + PieceKind::Bishop.value() * 2 + + PieceKind::Rook.value() * 2 + + PieceKind::Queen.value() * 1; + ]; + #[inline(always)] fn variant() -> GameVariant { GameVariant::Horde @@ -215,20 +237,15 @@ pub struct Game { /// All squares (pseudo-legally) attacked by a specific color. attacks_by_color: [Bitboard; Color::COUNT], + /* /// Pseudo-legal moves (including Pawn pushes and castles) from every given square on the board. // mobility_at: [Bitboard; Square::COUNT], - + */ /// The square where the side-to-move's King resides. king_square: Square, - /// Value of remaining material for each side. - material: [i32; Color::COUNT], - - /// Current evaluations for the midgame and endgame, respectively - evals: (Score, Score), - - /// The variant of Chess this game represents. - variant: PhantomData, + /// Responsible for evaluating the current position into a [`Score`]. + evaluator: Evaluator, } /// Implementation details specific to standard chess. @@ -249,13 +266,6 @@ impl Game { */ impl Game { - /// Initial material value of all pieces in a standard setup. - const INITIAL_MATERIAL_VALUE: i32 = PieceKind::Pawn.value() * 16 - + PieceKind::Knight.value() * 4 - + PieceKind::Bishop.value() * 4 - + PieceKind::Rook.value() * 4 - + PieceKind::Queen.value() * 2; - /// Creates a new, empty [`Game`] with the following properties: /// * No pieces on the board /// * White moves first @@ -279,9 +289,7 @@ impl Game { checkmask: Bitboard::EMPTY_BOARD, pinned: Bitboard::EMPTY_BOARD, attacks_by_color: [Bitboard::EMPTY_BOARD; Color::COUNT], - material: [0; Color::COUNT], - evals: (Score::DRAW, Score::DRAW), - variant: PhantomData, + evaluator: Evaluator::new(), } } @@ -491,42 +499,14 @@ impl Game { self.attacks_by_color[color.index()] } - /// Counts the material value of all pieces on the board - /// - /// The King is not included in this count - #[inline(always)] - pub fn material_remaining(&self) -> i32 { - self.material[Color::White.index()] + self.material[Color::Black.index()] - } - - /// Divides the original material value of the board by the current material value, yielding an `i32` in the range `[0, 100]` - /// - /// Lower numbers are closer to the beginning of the game. Higher numbers are closer to the end of the game. - /// - /// The King is ignored when performing this calculation. - #[inline(always)] - pub fn endgame_weight(&self) -> i32 { - let remaining = Self::INITIAL_MATERIAL_VALUE - self.material_remaining(); - (remaining * 100 / Self::INITIAL_MATERIAL_VALUE * 100) / 100 - } - /// Evaluate this position from the side-to-move's perspective. /// /// A positive/high number is good for the side-to-move, while a negative number is better for the opponent. /// A score of 0 is considered equal. #[inline(always)] - pub fn eval(self) -> Score { + pub fn eval(&self) -> Score { let stm = self.side_to_move(); - self.eval_for(stm) - } - - /// Evaluate this position from `color`'s perspective. - /// - /// A positive/high number is good for the `color`, while a negative number is better for the opponent. - /// A score of 0 is considered equal. - #[inline(always)] - pub fn eval_for(&self, color: Color) -> Score { - self.evals.0.lerp(self.evals.1, self.endgame_weight()) * color.negation_multiplier() as i32 + self.evaluator().eval_for(stm) } /// Applies the provided [`Move`]. No enforcement of legality. @@ -551,7 +531,7 @@ impl Game { // Increment move counters self.position.halfmove += 1; // This is reset if a capture occurs or a pawn moves - self.position.fullmove += self.side_to_move().bits(); + self.position.fullmove += color.is_black() as u16; // First, deal with special cases like captures and castling if mv.is_capture() { @@ -684,12 +664,6 @@ impl Game { Ok(()) } - /// Returns the [`GameVariant`] of this game. - #[inline(always)] - pub fn variant(&self) -> GameVariant { - V::variant() - } - /// Fetch the internal [`Position`] of this [`Game`]. #[inline(always)] pub const fn position(&self) -> &Position { @@ -714,6 +688,12 @@ impl Game { self.pinned } + /// Fetch a reference to this game's [`Evaluator`]. + #[inline(always)] + pub fn evaluator(&self) -> &Evaluator { + &self.evaluator + } + /// Checks if playing the provided [`Move`] is legal on the current position. /// /// This assumes the move is pseudo-legal. i.e. not capturing friendly pieces, @@ -1239,34 +1219,19 @@ impl Game { /// Places a piece at the provided square, updating Zobrist hash information. #[inline(always)] - fn place(&mut self, piece: Piece, square: Square) { - let color = piece.color(); - let multiplier = color.negation_multiplier() as i32; - + pub fn place(&mut self, piece: Piece, square: Square) { self.position.board.place(piece, square); self.position.key.hash_piece(square, piece); - self.material[color] += piece.kind().value(); - - // Update PSQT contributions - let (mg, eg) = Psqt::evals(piece, square); - self.evals.0 += mg * multiplier; - self.evals.1 += eg * multiplier; + self.evaluator.piece_placed(piece, square); } /// Removes and returns a piece on the provided square, updating Zobrist hash information. #[inline(always)] - fn take(&mut self, square: Square) -> Option { + pub fn take(&mut self, square: Square) -> Option { let piece = self.position.board.take(square)?; - let color = piece.color(); - let multiplier = color.negation_multiplier() as i32; self.position.key.hash_piece(square, piece); - self.material[piece.color()] -= piece.kind().value(); - - // Update PSQT contributions - let (mg, eg) = Psqt::evals(piece, square); - self.evals.0 -= mg * multiplier; - self.evals.1 -= eg * multiplier; + self.evaluator.piece_taken(piece, square); Some(piece) } @@ -1334,17 +1299,16 @@ impl fmt::Display for Game { write!( f, " Material: {} (white) {} (black)", - self.material[Color::White], - self.material[Color::Black] + self.evaluator().material[Color::White], + self.evaluator().material[Color::Black] )?; } else if rank == Rank::TWO { + let (mg, eg) = self.evaluator().evals(); write!( f, - " Eval: {} (mg={}, eg={}, %={})", + " Eval: {} (mg={mg}, eg={eg}, %={})", self.eval(), - self.evals.0, - self.evals.1, - self.endgame_weight(), + self.evaluator().endgame_weight(), )?; // } else if rank == Rank::ONE { } @@ -1473,12 +1437,12 @@ pub struct Position { /// /// - Incremented after each move. /// - Reset after a capture or a pawn moves. - halfmove: u8, + halfmove: u16, /// Number of moves since the beginning of the game. /// /// A fullmove is a complete turn by white and then by black. - fullmove: u8, + fullmove: u16, /// Zobrist hash key of this position key: ZobristKey, @@ -1566,14 +1530,14 @@ impl Position { /// Returns the half-move counter of the current position. #[inline(always)] - pub const fn halfmove(&self) -> u8 { - self.halfmove + pub const fn halfmove(&self) -> usize { + self.halfmove as usize } /// Returns the full-move counter of the current position. #[inline(always)] - pub const fn fullmove(&self) -> u8 { - self.fullmove + pub const fn fullmove(&self) -> usize { + self.fullmove as usize } /// Fetch the Zobrist hash key of this position. @@ -2501,7 +2465,7 @@ pub struct BoardIter<'a> { occupancy: Bitboard, } -impl<'a> Iterator for BoardIter<'a> { +impl Iterator for BoardIter<'_> { type Item = (Square, Piece); #[inline(always)] @@ -2520,7 +2484,7 @@ impl<'a> Iterator for BoardIter<'a> { } } -impl<'a> ExactSizeIterator for BoardIter<'a> {} +impl ExactSizeIterator for BoardIter<'_> {} #[cfg(test)] mod tests { diff --git a/src/history.rs b/toad/src/history.rs similarity index 100% rename from src/history.rs rename to toad/src/history.rs diff --git a/src/lib.rs b/toad/src/lib.rs similarity index 84% rename from src/lib.rs rename to toad/src/lib.rs index cba95d0..c1e1da8 100644 --- a/src/lib.rs +++ b/toad/src/lib.rs @@ -6,12 +6,16 @@ /// Commands to be sent to the engine, and how to parse them. mod cli; +/// Types and utilities for measuring the vertical distance between nodes in a search. +mod depth; /// Code related to the engine's functionality, such as user input handling. mod engine; +/// Static evaluation of a given board state and the types used to compute it, such as piece-square tables. +mod eval; +/// A chessboard, complete with piece placements, game state, legality checks, etc. +mod game; /// Hash table for History Heuristic. mod history; -/// Piece-Square tables. -mod psqt; /// Types and utilities for rating how good/bad a position is. mod score; /// Main engine logic; all search related code. @@ -35,8 +39,6 @@ mod board { mod perft; /// Enums for piece kinds, colors, and a struct for a chess piece. mod piece; - /// A chessboard, complete with piece placements, game state, legality checks, etc. - mod position; /// Pseudo-random number generation, written to be usable in `const` settings. /// /// Primarily for Zobrist hashing and magic generation. @@ -53,7 +55,6 @@ mod board { pub use moves::*; pub use perft::*; pub use piece::*; - pub use position::*; pub use prng::*; pub use square::*; pub use table::*; @@ -62,9 +63,11 @@ mod board { pub use board::*; pub use cli::*; +use depth::*; pub use engine::*; +pub use eval::*; +pub use game::*; use history::*; -use psqt::*; use score::*; use search::*; use ttable::*; diff --git a/src/main.rs b/toad/src/main.rs similarity index 100% rename from src/main.rs rename to toad/src/main.rs diff --git a/src/score.rs b/toad/src/score.rs similarity index 69% rename from src/score.rs rename to toad/src/score.rs index 526079b..f53fa59 100644 --- a/src/score.rs +++ b/toad/src/score.rs @@ -8,14 +8,14 @@ use std::fmt; use uci_parser::UciScore; -use crate::{tune, MAX_DEPTH}; +use crate::{tune, Ply}; /// A numerical representation of the evaluation of a position / move, in units of ["centipawns"](https://www.chessprogramming.org/Score). /// /// This value is internally capped at [`Self::INF`]. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[repr(transparent)] -pub struct Score(pub i32); +pub struct Score(i32); impl Score { /// Largest possible score ever achievable. @@ -27,16 +27,10 @@ impl Score { /// Score of a draw. pub const DRAW: Self = Self(0); - /// Initial value of alpha in alpha-beta pruning. - pub const ALPHA: Self = Self(-Self::INF.0); - - /// Initial value of beta in alpha-beta pruning. - pub const BETA: Self = Self::INF; - /// Lowest possible score for mate. /// - /// This is only obtainable if mate is possible in [`MAX_DEPTH`] moves. - pub const LOWEST_MATE: Self = Self(Self::MATE.0 - MAX_DEPTH as i32); + /// This is only obtainable if mate is possible in [`Ply::MAX`] moves. + pub const LOWEST_MATE: Self = Self(Self::MATE.0 - Ply::MAX.plies()); /// Maximum bonus to apply to moves via history heuristic. pub const MAX_HISTORY: Self = Self(tune::max_history_bonus!()); @@ -44,19 +38,46 @@ impl Score { /// The base value of a move, used when ordering moves during search. pub const BASE_MOVE_SCORE: Self = Self(tune::base_move_score!()); - /// Value to multiply depth by when computing history scores. - pub const HISTORY_MULTIPLIER: Self = Self(tune::history_multiplier!()); + /// Value to multiply depth by when computing razoring margin. + pub const RAZORING_MULTIPLIER: Self = Self(tune::razoring_multiplier!()); - /// Value to subtract from a history score at a given depth. - pub const HISTORY_OFFSET: Self = Self(tune::history_offset!()); + /// Value to subtract from alpha bound when computing a razoring margin. + pub const RAZORING_OFFSET: Self = Self(tune::razoring_offset!()); + + /// Constructs a new [`Score`] instance. + #[inline(always)] + pub const fn new(score: i32) -> Self { + Self(score) + } - /// Safety margin when applying reverse futility pruning. - pub const RFP_MARGIN: Self = Self(tune::rfp_margin!()); + /// Constructs a new [`Score`] instance that represents *being* checkmated in `n` plies. + #[inline(always)] + pub const fn mated_in(n: Ply) -> Self { + Self(-Self::MATE.0 + n.plies()) + } + + /// Constructs a new [`Score`] instance that represents *giving* checkmate in `n` plies. + #[inline(always)] + pub const fn mate_in(n: Ply) -> Self { + Self(Self::MATE.0 - n.plies()) + } /// Returns `true` if the score is a mate score. #[inline(always)] - pub fn is_mate(&self) -> bool { - self.abs() >= Self::LOWEST_MATE + pub const fn is_mate(&self) -> bool { + self.abs().0 >= Self::LOWEST_MATE.0 + } + + /// Returns `true` if the score represents *being* checkmated. + #[inline(always)] + pub const fn mated(&self) -> bool { + self.0 <= -Self::LOWEST_MATE.0 + } + + /// Returns `true` if the score represents *giving* checkmated. + #[inline(always)] + pub const fn mating(&self) -> bool { + self.0 >= Self::LOWEST_MATE.0 } /// Converts this [`Score`] into a [`UciScore`], @@ -92,44 +113,16 @@ impl Score { relative_to_side / 2 } - /// Normalize the score to the provided ply. - /// - /// Score will be relative to `ply`. + /// Returns the absolute value of this [`Score`]. #[inline(always)] - pub fn relative(self, ply: i32) -> Self { - if self.is_mate() { - // Self(self.0 + ply) - if self > Self::DRAW { - self + ply - } else { - self - ply - } - } else { - self - } - } - - /// De-normalize the score from the provided ply. - /// - /// Score will be relative to root (0 ply). - #[inline(always)] - pub fn absolute(self, ply: i32) -> Self { - if self.is_mate() { - // Self(self.0 - ply) - if self > Self::DRAW { - self - ply - } else { - self + ply - } - } else { - self - } + pub const fn abs(self) -> Self { + Self(self.0.abs()) } - /// Returns the absolute value of this [`Score`].` + /// Returns the sign of this [`Score`]. #[inline(always)] - pub const fn abs(self) -> Self { - Self(self.0.abs()) + pub const fn signum(self) -> Self { + Self(self.0.signum()) } /// "Normalizes" a score so that it can be printed as a float. @@ -174,6 +167,42 @@ macro_rules! impl_binary_op { Self(self.0.$fn(rhs)) } } + + impl std::ops::$trait for i32 { + type Output = Score; + + #[inline(always)] + fn $fn(self, rhs: Score) -> Self::Output { + Score(self.$fn(rhs.0)) + } + } + + impl std::ops::$trait for Score { + type Output = Self; + + #[inline(always)] + fn $fn(self, rhs: Ply) -> Self::Output { + Self(self.0.$fn(rhs.plies() as i32)) + } + } + + impl std::ops::$trait for Ply { + type Output = Score; + + #[inline(always)] + fn $fn(self, rhs: Score) -> Self::Output { + Score((self.plies() as i32).$fn(rhs.0)) + } + } + + impl std::ops::$trait for Score { + type Output = Self; + + #[inline(always)] + fn $fn(self, rhs: bool) -> Self::Output { + Self(self.0.$fn(rhs as i32)) + } + } }; } @@ -245,7 +274,7 @@ impl fmt::Debug for Score { self.moves_to_mate() ) } else { - write!(f, "{}", self.0) + write!(f, "{self} ({})", self.0) } } } @@ -279,27 +308,13 @@ mod tests { #[test] fn test_relative_absolute() { - let plies = 3; + let plies = Ply::new(3); // Plies to mate let our_mate = Score::MATE - plies; - assert_eq!(our_mate.plies_to_mate(), plies); + assert_eq!(our_mate.plies_to_mate(), plies.plies() as i32); let their_mate = -(Score::MATE - plies); - assert_eq!(their_mate.plies_to_mate(), plies); - - // Relative scores - let our_relative = our_mate.relative(plies); - assert_eq!(our_relative, Score::MATE); - - let their_relative = their_mate.relative(plies); - assert_eq!(their_relative, -Score::MATE); - - // Absolute scores - let our_absolute = our_relative.absolute(plies); - assert_eq!(our_absolute, our_mate); - - let their_absolute = their_relative.absolute(plies); - assert_eq!(their_absolute, their_mate); + assert_eq!(their_mate.plies_to_mate(), plies.plies() as i32); } } diff --git a/toad/src/search.rs b/toad/src/search.rs new file mode 100644 index 0000000..78261cc --- /dev/null +++ b/toad/src/search.rs @@ -0,0 +1,1757 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use std::{ + fmt, + marker::PhantomData, + ops::Neg, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use arrayvec::ArrayVec; +use thiserror::Error; +use uci_parser::{UciInfo, UciResponse, UciSearchOptions}; + +use crate::{ + tune, Color, Game, HistoryTable, LogLevel, Move, MoveList, Piece, PieceKind, Ply, Position, + ProbeResult, Score, TTable, TTableEntry, Variant, ZobristKey, MAX_NUM_MOVES, +}; + +/// Reasons that a search can be cancelled. +#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)] +enum SearchCancelled { + /// Search ran out of time. + /// + /// Contains the amount of time since the timeout was exceeded. + #[error("Exceeded hard timeout of {0:?} by {1:?}")] + HardTimeout(Duration, Duration), + + /// Met or exceeded the maximum number of nodes allowed. + /// + /// Contains the number of nodes past the allowance that were searched. + #[error("Exceeded node allowance by {0}")] + MaxNodes(u64), + + /// Stopped by an external factor + #[error("Atomic flag was flipped")] + Stopped, +} + +/// A marker trait for the types of nodes encountered during search. +/// +/// Credit to Cosmo, author of Viridithas, +/// for the idea of using a const generic trait for this. +trait NodeType { + /// Is this node the first searched? + const ROOT: bool; + + /// Is this node a PV node? + const PV: bool; +} + +/// First node searched. +struct RootNode; +impl NodeType for RootNode { + const ROOT: bool = true; + const PV: bool = true; +} + +/// A node on the principal variation, searched with a non-null window. +struct PvNode; +impl NodeType for PvNode { + const ROOT: bool = false; + const PV: bool = true; +} + +/// A node not on the principal variation, searched with a null window. +struct NonPvNode; +impl NodeType for NonPvNode { + const ROOT: bool = false; + const PV: bool = false; +} + +/// Represents the best sequence of moves found during a search. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PrincipalVariation(ArrayVec); + +impl PrincipalVariation { + /// An empty PV. + const EMPTY: Self = Self(ArrayVec::new_const()); + + /// Clears the moves of `self`. + #[inline(always)] + fn clear(&mut self) { + self.0.clear(); + } + + /// Extend the contents of `self` with `mv` and the contents of `other`. + /// + /// # Panics + /// + /// Will panic if `mv` and `other` are longer than `self`'s capacity. + #[inline(always)] + fn extend(&mut self, mv: Move, other: &Self) { + self.clear(); + self.0.push(mv); + self.0 + .try_extend_from_slice(&other.0) + .unwrap_or_else(|err| { + panic!( + "{err}: Attempted to exceed PV capacity of {} pushing {mv:?} and {:?}", + Ply::MAX, + &other.0 + ); + }); + } +} + +impl Default for PrincipalVariation { + #[inline(always)] + fn default() -> Self { + Self::EMPTY + } +} + +/// Bounds within an alpha-beta search. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SearchBounds { + /// Lower bound. + /// + /// We are guaranteed a score that is AT LEAST `alpha`. + /// During search, if no move can raise `alpha`, we are said to have "failed low." + /// + /// On a fail-low, we do not have a "best move." + pub alpha: Score, + + /// Upper bound. + /// + /// Our opponent is guaranteed a score that is AT MOST `beta`. + /// During search, if a move scores higher than `beta`, we are said to have "failed high." + /// + /// On a fail-high, the branch is pruned, since our opponent has a better move to play earlier in the tree, + /// which would make this position unreachable for us. + pub beta: Score, +} + +impl SearchBounds { + /// Create a new [`SearchBounds`] from the provided `alpha` and `beta` values. + #[inline(always)] + const fn new(alpha: Score, beta: Score) -> Self { + Self { alpha, beta } + } + + /// Create a "null window" around `alpha`. + #[inline(always)] + fn null_alpha(self) -> Self { + Self::new(self.alpha, self.alpha + 1) + } + + /// Create a "null window" around `beta`. + #[inline(always)] + fn null_beta(self) -> Self { + Self::new(self.beta - 1, self.beta) + } +} + +impl Neg for SearchBounds { + type Output = Self; + /// Negating a [`SearchBounds`] swaps the `alpha` and `beta` fields and negates them both. + #[inline(always)] + fn neg(self) -> Self::Output { + Self { + alpha: -self.beta, + beta: -self.alpha, + } + } +} + +impl Default for SearchBounds { + /// Default [`SearchBounds`] are a `(-infinity, infinity)`. + #[inline(always)] + fn default() -> Self { + Self::new(-Score::INF, Score::INF) + } +} + +/// Represents a window around a search result to act as our a/b bounds. +#[derive(Debug)] +struct AspirationWindow { + /// Bounds of this search window + bounds: SearchBounds, + + /// Number of times that a score has been returned above beta. + beta_fails: i32, + + /// Number of times that a score has been returned below alpha. + alpha_fails: i32, +} + +impl AspirationWindow { + /// Returns a delta value to change window's size. + /// + /// The value will differ depending on `depth`, with higher depths producing narrower windows. + #[inline(always)] + fn delta(depth: Ply) -> Score { + let initial_delta = tune::initial_aspiration_window_delta!(); + + let min_delta = tune::min_aspiration_window_delta!(); + + // Gradually decrease the window size from `8*init` to `min` + Score::new(((initial_delta << 3) / depth.plies()).max(min_delta)) + } + + /// Creates a new [`AspirationWindow`] centered around `score`. + #[inline(always)] + fn new(score: Score, depth: Ply) -> Self { + // If the score is mate, we expect search results to fluctuate, so set the windows to infinite. + // Also, we only want to use aspiration windows after certain depths, so check that, too. + let bounds = if depth < tune::min_aspiration_window_depth!() || score.is_mate() { + SearchBounds::default() + } else { + // Otherwise we build a window around the provided score. + let delta = Self::delta(depth); + SearchBounds::new( + (score - delta).max(-Score::INF), + (score + delta).min(Score::INF), + ) + }; + + Self { + bounds, + alpha_fails: 0, + beta_fails: 0, + } + } + + /// Widens the window's `alpha` bound, expanding it downwards. + /// + /// This also resets the `beta` bound to `(alpha + beta) / 2` + #[inline(always)] + fn widen_down(&mut self, score: Score, depth: Ply) { + // Compute a gradually-increasing delta + let delta = Self::delta(depth) * (1 << (self.alpha_fails + 1)); + + // By convention, we widen both bounds on a fail low. + self.bounds.beta = ((self.bounds.alpha + self.bounds.beta) / 2).min(Score::INF); + self.bounds.alpha = (score - delta).max(-Score::INF); + + // Increase number of failures + self.alpha_fails += 1; + } + + /// Widens the window's `beta` bound, expanding it upwards. + #[inline(always)] + fn widen_up(&mut self, score: Score, depth: Ply) { + // Compute a gradually-increasing delta + let delta = Self::delta(depth) * (1 << (self.beta_fails + 1)); + + // Widen the beta bound + self.bounds.beta = (score + delta).min(Score::INF); + + // Increase number of failures + self.beta_fails += 1; + } + + /// Returns `true` if `score` fails low, meaning it is below `alpha` and the window must be expanded downwards. + #[inline(always)] + fn fails_low(&self, score: Score) -> bool { + self.bounds.alpha != -Score::INF && score <= self.bounds.alpha + } + + /// Returns `true` if `score` fails high, meaning it is above `beta` and the window must be expanded upwards. + #[inline(always)] + fn fails_high(&self, score: Score) -> bool { + self.bounds.beta != Score::INF && score >= self.bounds.beta + } +} + +/// The result of a search, containing the best move found, score, and total nodes searched. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SearchResult { + /// Number of nodes searched. + pub nodes: u64, + + /// Evaluation of the position after `bestmove` is made. + pub score: Score, + + /// The depth of the search that produced this result. + pub depth: Ply, + + /// The maximum depth (ply) reached during this search. + pub seldepth: Ply, + + /// Principal variation during this search. + /// + /// The first entry of this field represents the "best move" found during the search. + pub pv: PrincipalVariation, +} + +impl SearchResult { + /// Fetch the first move in this PV, it one exists. + #[inline(always)] + fn bestmove(&self) -> Option { + self.pv.0.first().copied() + } +} + +impl Default for SearchResult { + /// A default search result should initialize to a *very bad* value, + /// since there isn't a move to play. + #[inline(always)] + fn default() -> Self { + Self { + nodes: 0, + score: -Score::INF, + depth: Ply::ONE, + seldepth: Ply::ZERO, + pv: PrincipalVariation::EMPTY, + } + } +} + +/// Configuration variables for executing a [`Search`]. +#[derive(Debug, Clone, Copy)] +pub struct SearchConfig { + /// Maximum depth to execute the search. + pub max_depth: Ply, + + /// Node allowance. + /// + /// If the search exceeds this many nodes, it will exit as quickly as possible. + pub max_nodes: u64, + + /// Start time of the search. + pub starttime: Instant, + + /// Soft limit on search time. + /// + /// During iterative deepening, if a search concludes and this timeout is exceeded, + /// the entire search will exit, since there probably isn't enough time remaining + /// to conduct a search at a deeper depth. + pub soft_timeout: Duration, + + /// Hard limit on search time. + /// + /// During *any* point in the search, if this limit is exceeded, the search will cancel. + pub hard_timeout: Duration, +} + +impl SearchConfig { + /// Constructs a new [`SearchConfig`] from the provided UCI options and game. + /// + /// The [`Game`] is used to determine side to move, and other factors when computing the soft/hard timeouts. + pub fn new(options: UciSearchOptions, game: &Game) -> Self { + let mut config = Self::default(); + + // If supplied, set the max depth / node allowance + if let Some(depth) = options.depth { + config.max_depth = Ply::new(depth as i32); + } + + if let Some(nodes) = options.nodes { + config.max_nodes = nodes as u64; + } + + // If `movetime` was supplied, search that long. + if let Some(movetime) = options.movetime { + config.hard_timeout = movetime; + config.soft_timeout = movetime; + } else { + // Otherwise, search based on time remaining and increment + let (remaining, inc) = if game.side_to_move().is_white() { + (options.wtime, options.winc) + } else { + (options.btime, options.binc) + }; + + // Only calculate timeouts if a time was provided + if let Some(remaining) = remaining { + let inc = inc.unwrap_or(Duration::ZERO) / tune::time_inc_divisor!(); + + // Don't exceed time limit with increment. + config.soft_timeout = + remaining.min(remaining / tune::soft_timeout_divisor!() + inc); + config.hard_timeout = remaining / tune::hard_timeout_divisor!(); + } + } + + config + } +} + +impl Default for SearchConfig { + /// A default [`SearchConfig`] will permit an "infinite" search. + /// + /// The word "infinite" is quoted here because the actual defaults are the `::MAX` values for each field. + #[inline(always)] + fn default() -> Self { + Self { + max_depth: Ply::MAX, + max_nodes: u64::MAX, + starttime: Instant::now(), + soft_timeout: Duration::MAX, + hard_timeout: Duration::MAX, + } + } +} + +/// Parameters for the various features used to enhance the efficiency of a search. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SearchParameters { + /// Minium depth at which null move pruning can be applied. + min_nmp_depth: Ply, + + /// Value to subtract from `depth` when applying null move pruning. + nmp_reduction: Ply, + + /// Maximum depth at which to apply reverse futility pruning. + max_rfp_depth: Ply, + + /// Maximum depth at which to apply late move pruning. + max_lmp_depth: Ply, + + /// Minimum depth at which to apply late move reductions. + min_lmr_depth: Ply, + + /// Minimum moves that must be made before late move reductions can be applied. + min_lmr_moves: usize, + + /// Base value in the LMR formula. + lmr_offset: f32, + + /// Divisor in the LMR formula. + lmr_divisor: f32, + + /// Value to multiply depth by when computing history scores. + history_multiplier: Score, + + /// Value to subtract from a history score at a given depth. + history_offset: Score, + + /// Safety margin when applying reverse futility pruning. + rfp_margin: Score, + + /// Depth to extend by if the position is in check. + check_extensions_depth: Ply, + + /// Maximum depth at which razoring can be performed. + max_razoring_depth: Ply, + + /// Multiplier for the LMP formula. + lmp_multiplier: usize, + + /// Divisor for the LMP formula. + lmp_divisor: usize, + + /// Minimum depth at which IIR can be applied. + min_iir_depth: Ply, + + /// Minimum depth at which IID can be applied. + min_iid_depth: Ply, + + /// Offset to subtract from depth during IID. + iid_offset: Ply, + + /// Pre-computed table for Late Move Reduction values. + lmr_table: [[i32; MAX_NUM_MOVES]; Ply::MAX.plies() as usize + 1], +} + +impl Default for SearchParameters { + fn default() -> Self { + let lmr_offset = tune::lmr_offset!(); + let lmr_divisor = tune::lmr_divisor!(); + + // Initialize the table for Late Move Reductions, so that we don't need redo the floating-point arithmetic constantly. + let mut lmr_table = [[0; MAX_NUM_MOVES]; Ply::MAX.plies() as usize + 1]; + for (depth, entry) in lmr_table.iter_mut().enumerate().skip(1) { + for (moves_made, reduction) in entry.iter_mut().enumerate() { + let d = (depth as f32).ln(); + let m = (moves_made as f32).ln(); + let r = lmr_offset + d * m / lmr_divisor; + *reduction = r as i32; + + // eprintln!( + // "D: {depth:width$}, M: {moves_made:width$} := {r}", + // width = 3 + // ); + // assert!(!d.is_nan() && d.is_finite(), "{depth} produced {d}"); + // assert!(!m.is_nan() && m.is_finite(), "{moves_made} produced {m}"); + // assert!( + // !r.is_nan() && m.is_finite(), + // "{depth} x {moves_made} produced {r}" + // ); + } + } + + Self { + min_nmp_depth: Ply::from_raw(tune::min_nmp_depth!()), + nmp_reduction: Ply::from_raw(tune::nmp_reduction!()), + max_rfp_depth: Ply::from_raw(tune::max_rfp_depth!()), + max_lmp_depth: Ply::from_raw(tune::max_lmp_depth!()), + min_lmr_depth: Ply::from_raw(tune::min_lmr_depth!()), + min_lmr_moves: tune::min_lmr_moves!(), + lmr_offset, + lmr_divisor, + history_multiplier: Score::new(tune::history_multiplier!()), + history_offset: Score::new(tune::history_offset!()), + rfp_margin: Score::new(tune::rfp_margin!()), + check_extensions_depth: Ply::from_raw(tune::check_extensions_depth!()), + max_razoring_depth: Ply::from_raw(tune::max_razoring_depth!()), + lmp_multiplier: tune::lmp_multiplier!(), + lmp_divisor: tune::lmp_divisor!(), + min_iir_depth: Ply::from_raw(tune::min_iir_depth!()), + min_iid_depth: Ply::from_raw(tune::min_iid_depth!()), + iid_offset: Ply::from_raw(tune::iid_offset!()), + lmr_table, + } + } +} + +/// Executes a search on a game of chess. +pub struct Search<'a, Log, V> { + /// An atomic flag to determine if the search should be cancelled at any time. + /// + /// If this is ever `false`, the search must exit as soon as possible. + is_searching: Arc, + + /// Configuration variables for this instance of the search. + config: SearchConfig, + + /// Information collected that is returned at the conclusion of the search. + result: SearchResult, + + /// Previous positions encountered during search. + prev_positions: Vec, + + /// Transposition table used to cache information during search. + ttable: &'a mut TTable, + + /// Storage for moves that cause a beta-cutoff during search. + history: &'a mut HistoryTable, + + /// Parameters for search features like pruning, extensions, etc. + params: SearchParameters, + + /// Marker for what variant of Chess is being played. + variant: PhantomData, + + /// Marker for the level of logging to print. + log: PhantomData, +} + +impl<'a, Log: LogLevel, V: Variant> Search<'a, Log, V> { + /// Construct a new [`Search`] instance to execute. + #[inline(always)] + pub fn new( + is_searching: Arc, + config: SearchConfig, + prev_positions: Vec, + ttable: &'a mut TTable, + history: &'a mut HistoryTable, + params: SearchParameters, + ) -> Self { + Self { + is_searching, + config, + prev_positions, + ttable, + history, + params, + result: SearchResult::default(), + variant: PhantomData, + log: PhantomData, + } + } + + /// Start the search on the supplied [`Game`], returning a [`SearchResult`]. + /// + /// This is the entrypoint of the search, and prints UCI info before starting iterative deepening. + /// and concluding by sending the `bestmove` message and exiting. + #[inline(always)] + pub fn start(mut self, game: &Game) -> SearchResult { + if Log::DEBUG { + self.send_string(format!("Starting search on {:?}", game.to_fen())); + + let soft = self.config.soft_timeout; + let hard = self.config.hard_timeout; + let nodes = self.config.max_nodes; + let depth = self.config.max_depth; + + if soft < Duration::MAX { + self.send_string(format!("Soft timeout := {soft:?}")); + } + if hard < Duration::MAX { + self.send_string(format!("Hard timeout := {hard:?}")); + } + if nodes < u64::MAX { + self.send_string(format!("Max nodes := {nodes} nodes")); + } + if depth < Ply::MAX { + self.send_string(format!("Max depth := {depth}")); + } + } + + // Get the legal moves at the root, so we can ensure that there is at least one move we can play. + let moves = game.get_legal_moves(); + + // Get the search result, exiting early if possible. + match moves.len() { + // If no legal moves available, the game is over, so return immediately. + 0 => { + // It's either a draw or a checkmate + self.result.score = -Score::MATE * game.is_in_check(); + self.result.nodes += 1; + + if Log::DEBUG { + self.send_string(format!( + "Position {:?} has no legal moves available, evaluated at {}", + game.to_fen(), + self.result.score.into_uci(), + )); + } + } + + /* + // If only 1 legal move available, it is forced, so don't waste time on a full search. + 1 => { + // Get a quick, albeit poor, evaluation of the position. + // TODO: Replace this with a call to qsearch? + self.result.score = game.eval(); + self.result.nodes += 1; + + // Append the only legal move to the PV + let bestmove = moves[0]; + self.result.pv.0.push(bestmove); + + if Log::DEBUG { + self.send_string(format!( + "Position {:?} has only one legal move available ({bestmove}), evaluated at {}", + game.to_fen(), + self.result.score.into_uci(), + )); + } + } + */ + // Otherwise, start a search like normal. + _ => self.iterative_deepening(game), + } + + // Debug info about the termination of the search. + if Log::DEBUG { + if let Err(reason) = self.search_cancelled() { + if let Some(bestmove) = self.result.bestmove() { + self.send_string(format!( + "Search cancelled during depth {} while evaluating {} with score {}. Reason: {reason}", + self.result.depth, + V::fmt_move(bestmove), + self.result.score, + )); + } else { + self.send_string(format!( + "Search cancelled during depth {} with score {} and no bestmove. Reason: {reason}", + self.result.depth, self.result.score, + )); + } + } + + let hits = self.ttable.hits; + let reads = self.ttable.reads; + let writes = self.ttable.writes; + let hit_rate = (hits as f32 / reads as f32 * 100.0).min(0.0); + let collisions = self.ttable.collisions; + let info = format!("TT stats: {hits} hits / {reads} reads ({hit_rate:.2}% hit rate), {writes} writes, {collisions} collisions"); + self.send_string(info); + } + + // Sanity check: If no bestmove, but there is a legal move, update bestmove. + if self.result.bestmove().is_none() { + if let Some(first) = moves.first().copied() { + self.result.pv.0.push(first); + self.result.score = game.eval(); + } + } + + // Search has ended; send bestmove + if Log::INFO { + self.send_search_info(); // UCI spec states to send one last `info` before `bestmove`. + + // TODO: On a `go infinite` search, we should *only* send `bestmove` after `stop` is received, regardless of whether the search has concluded + self.send_response(UciResponse::BestMove { + bestmove: self.result.bestmove().map(V::fmt_move), + ponder: None, + }); + } + + // Search has concluded, alert other thread(s) that we are no longer searching + self.is_searching.store(false, Ordering::Relaxed); + + self.result + } + + /// Sends a [`UciResponse`] to `stdout`. + #[inline(always)] + fn send_response(&self, response: UciResponse) { + println!("{response}"); + } + + /// Sends a [`UciInfo`] to `stdout`. + #[inline(always)] + fn send_info(&self, info: UciInfo) { + let resp = UciResponse::info(info); + self.send_response(resp); + } + + /// Helper to send a [`UciInfo`] containing only a `string` message to `stdout`. + #[inline(always)] + fn send_string(&self, string: T) { + self.send_response(UciResponse::info_string(string)); + } + + /// Sends UCI info about the conclusion of a search. + /// + /// This is sent at the end of each new search in the iterative deepening loop. + #[inline(always)] + fn send_search_info(&self) { + let elapsed = self.config.starttime.elapsed(); + + self.send_info( + UciInfo::new() + .depth(self.result.depth) + .seldepth(self.result.seldepth) + .nodes(self.result.nodes) + .score(self.result.score) + .nps((self.result.nodes as f32 / elapsed.as_secs_f32()).trunc()) + .time(elapsed.as_millis()) + .pv(self.result.pv.0.iter().map(|&mv| V::fmt_move(mv))), + ); + } + + /// Performs [iterative deepening](https://www.chessprogramming.org/Iterative_Deepening) (ID) on the Search's position. + /// + /// ID is a basic time management strategy for engines. + /// It involves performing a search at depth `n`, then, if there is enough time remaining, performing a search at depth `n + 1`. + /// On it's own, ID does not improve performance, because we are wasting work by re-running searches at low depth. + /// However, with features such as move ordering, a/b pruning, and aspiration windows, ID enhances performance. + /// + /// After each iteration, we check if we've exceeded our `soft_timeout` and, if we haven't, we run a search at a greater depth. + fn iterative_deepening(&mut self, game: &Game) { + /**************************************************************************************************** + * Iterative Deepening: https://www.chessprogramming.org/Iterative_Deepening + * + * Since we don't know how much time a search will take, we perform a series of searches as increasing + * depths until we run out of time. + ****************************************************************************************************/ + 'iterative_deepening: while self.config.starttime.elapsed() < self.config.soft_timeout + && self.is_searching.load(Ordering::Relaxed) + && self.result.depth <= self.config.max_depth + { + /**************************************************************************************************** + * Aspiration Windows: https://www.chessprogramming.org/Aspiration_Windows + * + * If our search is stable, the result of a search from the next depth should be similar to our + * current result. Therefore, we can use the current result to initialize our alpha/beta bounds. + ****************************************************************************************************/ + + // Create a new aspiration window for this search + let mut window = AspirationWindow::new(self.result.score, self.result.depth); + let mut pv = PrincipalVariation::EMPTY; + + // Get a score from the a/b search while using aspiration windows + let score = 'aspiration_window: loop { + // Start a new search at the current depth, exiting the ID loop if we've ran out of time + let Ok(score) = self.negamax::( + game, + self.result.depth, + Ply::ZERO, + window.bounds, + &mut pv, + ) else { + break 'iterative_deepening; + }; + + // If the score fell outside of the aspiration window, widen it gradually + if window.fails_low(score) { + window.widen_down(score, self.result.depth); + } else if window.fails_high(score) { + window.widen_up(score, self.result.depth); + } else { + // Otherwise, the window is OK and we can use the score + break 'aspiration_window score; + } + }; + + /**************************************************************************************************** + * Update current best score + ****************************************************************************************************/ + + // We need to update our bestmove and score, since this iteration's search completed without timeout. + self.result.score = score; + self.result.pv = pv; + + // Hack; if we're on the last iteration, don't send an `info` line, as it gets sent just before `bestmove` anyway. + if self.result.depth == self.config.max_depth { + break; + } + + // Send search info to the GUI + if Log::INFO { + self.send_search_info(); + } + + // Increase the depth for the next iteration + self.result.depth += 1; + } + } + + /// Primary location of search logic. + /// + /// Uses the [negamax](https://www.chessprogramming.org/Negamax) algorithm in a [fail soft](https://www.chessprogramming.org/Alpha-Beta#Negamax_Framework) framework. + fn negamax( + &mut self, + game: &Game, + mut depth: Ply, + ply: Ply, + mut bounds: SearchBounds, + pv: &mut PrincipalVariation, + ) -> Result { + self.search_cancelled()?; // Exit early if search is terminated. + + /**************************************************************************************************** + * Quiescence Search: https://www.chessprogramming.org/Quiescence_Search + * + * In order to avoid the horizon effect, we don't stop searching at a depth of 0. Instead, we + * continue searching all "noisy" moves until we reach a "quiet" (quiescent) position. + ****************************************************************************************************/ + if depth <= 0 { + return self.quiescence::(game, ply, bounds, pv); + } + + // Record the max max height / max ply / seldepth + self.result.seldepth = self.result.seldepth.max(ply) * !Node::ROOT as i32; + + // Declare a local principal variation for nodes found during this search. + let mut local_pv = PrincipalVariation::default(); + // Clear any nodes in this PV, since we're searching from a new position + pv.clear(); + + // Initial node inspection, such as mate-distance pruning, draws, etc. + if !Node::ROOT { + /**************************************************************************************************** + * Mate-Distance Pruning: https://www.chessprogramming.org/Mate_Distance_Pruning + * + * If we've found a mate-in-n, prune all other branches that are not mate-in-m where m < n. + * This doesn't really affect playing strength, since it only occurs when the game result is certain, + * but helps avoid searching useless nodes. + ****************************************************************************************************/ + // Clamp the bounds to a mate-in-`ply` score, if possible. + bounds.alpha = bounds.alpha.max(Score::mated_in(ply)); + bounds.beta = bounds.beta.min(Score::mate_in(ply + 1)); + + // Prune this node if no shorter mate has been found. + if bounds.alpha >= bounds.beta { + return Ok(bounds.alpha); + } + } + + // Probe the TT to see if we can return early or use an existing bestmove. + if Log::DEBUG { + self.ttable.reads += 1; + } + let tt_move = match self.ttable.probe(game.key(), depth, bounds) { + /**************************************************************************************************** + * TT Cutoffs: https://www.chessprogramming.org/Transposition_Table#Transposition_Table_Cutoffs + * + * If we've already evaluated this position before at a higher depth, we can avoid re-doing a lot of + * work by just returning the evaluation stored in the transposition table. However, we must be sure + * that we are not in a PV node. + ****************************************************************************************************/ + ProbeResult::Cutoff(tt_score) if !Node::PV => return Ok(tt_score), + + // Entry was found, but could not be used to perform a cutoff + ProbeResult::Hit(tt_entry) => tt_entry.bestmove, + + // Miss or otherwise unusable result + _ => { + /**************************************************************************************************** + * Internal Iterative Deepening: https://www.chessprogramming.org/Internal_Iterative_Deepening + * + * If we're in a PV node and there was no TT hit, this is likely to be a costly search, due to poor + * move ordering. So, we perform a shallower search in order to get a TT move and to populate the + * hash tables. + ****************************************************************************************************/ + if Node::PV && depth >= self.params.min_iid_depth { + let iid_depth = depth - self.params.min_iid_depth + self.params.iid_offset; + self.negamax::(game, iid_depth, ply, bounds, &mut local_pv)?; + local_pv.0.first().copied() // Return the bestmove found during the reduced search + } else { + None + } + } + }; + + /**************************************************************************************************** + * Internal Iterative Reductions: https://www.chessprogramming.org/Internal_Iterative_Reductions + * + * Also known as Transposition Table Reductions. If no bestmove was found when probing the TT, we are + * likely to spend a lot of time on this search, due to poor move ordering. It is also likely that + * this node isn't *that* important, since it wasn't already in the TT. So, we perform a reduced-depth + * search to speed things up and hopefully deliver better results. + ****************************************************************************************************/ + if tt_move.is_none() && depth >= self.params.min_iir_depth { + depth -= 1; + } + + // If we CAN prune this node by means other than the TT, do so. + if let Some(score) = + self.node_pruning_score::(game, depth, ply, bounds, pv, &mut local_pv)? + { + return Ok(score); + } + + // If there are no legal moves, it's either mate or a draw. + let mut moves = game.get_legal_moves(); + if moves.is_empty() { + return Ok(-Score::MATE * game.is_in_check()); + } + + // Sort moves so that we look at "promising" ones first + moves.sort_by_cached_key(|mv| self.score_move(game, mv, tt_move)); + + // Start with a *really bad* initial score + let mut best = -Score::INF; + let mut bestmove = tt_move; // Ensures we don't overwrite TT entry's bestmove with `None` if one already existed. + let original_alpha = bounds.alpha; + + /**************************************************************************************************** + * Primary move loop + ****************************************************************************************************/ + + for (i, mv) in moves.iter().enumerate() { + /**************************************************************************************************** + * Move-Loop Pruning techniques + ****************************************************************************************************/ + if !Node::PV && !best.mated() { + /**************************************************************************************************** + * Late Move Pruning: https://www.chessprogramming.org/Futility_Pruning#MoveCountBasedPruning + * + * We assume our move ordering is so good and that the moves ordered last are so bad that we should + * not even bother searching them. + ****************************************************************************************************/ + let min_lmp_moves = + self.params.lmp_multiplier * moves.len() / self.params.lmp_divisor; + if depth <= self.params.max_lmp_depth && i >= min_lmp_moves { + break; + } + } + + // Copy-make the new position + let new = game.with_move_made(*mv); + let mut score = Score::DRAW; + + // The local PV is different for every node search after this one, so we must reset it in between recursive calls. + local_pv.clear(); + + /**************************************************************************************************** + * Recursion of the search + ****************************************************************************************************/ + // Don't bother searching drawn positions, unless we're in the root node. + if Node::ROOT || !self.is_draw(&new) { + // Append this position onto our stack, so we can detect repetitions + self.prev_positions.push(*new.position()); + + let new_depth = depth - 1 + self.extension_value(&new); + + // If this node can be reduced, search it with a reduced window. + if let Some(lmr_reduction) = self.reduction_value::(depth, &new, i) { + // Reduced depth should never exceed `new_depth` and should never be less than `1`. + let reduced_depth = (new_depth - lmr_reduction).max(Ply::ONE).min(new_depth); + + // Search at a reduced depth with a null window + score = -self.negamax::( + &new, + reduced_depth, + ply + 1, + -bounds.null_alpha(), + &mut local_pv, + )?; + + // If that failed *high* (raised alpha), re-search at the full depth with the null window + if score > bounds.alpha && reduced_depth < new_depth { + score = -self.negamax::( + &new, + new_depth, + ply + 1, + -bounds.null_alpha(), + &mut local_pv, + )?; + } + } else if !Node::PV || i > 0 { + // All non-PV nodes get searched with a null window + score = -self.negamax::( + &new, + new_depth, + ply + 1, + -bounds.null_alpha(), + &mut local_pv, + )?; + } + + /**************************************************************************************************** + * Principal Variation Search: https://en.wikipedia.org/wiki/Principal_variation_search#Pseudocode + * + * We assume our move ordering is so good that the first move searched is then best available. So, + * for every other move, we search with a null window and thus prune nodes easier. If we find + * something that beats the null window, we have to do a costly re-search. However, this happens so + * infrequently in practice that it ends up being an overall speedup. + ****************************************************************************************************/ + // If searching the PV, or if a reduced search failed *high*, we search with a full depth and window + if Node::PV && (i == 0 || score > bounds.alpha) { + score = -self.negamax::( + &new, + new_depth, + ply + 1, + -bounds, + &mut local_pv, + )?; + } + + self.search_cancelled()?; // Exit early if search is terminated. + + // We've now searched this node + self.result.nodes += 1; + + // Pop the move from the history + self.prev_positions.pop(); + } + + /**************************************************************************************************** + * Score evaluation & bounds adjustments + ****************************************************************************************************/ + + // If we've found a better move than our current best, update the results + if score > best { + best = score; + + // PV found + if score > bounds.alpha { + bounds.alpha = score; + bestmove = Some(*mv); + + // Only extend the PV if we're in a PV node + if Node::PV { + // assert_pv_is_legal(game, *mv, &local_pv); + pv.extend(*mv, &local_pv); + } + } + + // Fail high + if score >= bounds.beta { + /**************************************************************************************************** + * History Heuristic + * + * If a quiet move fails high, it is probably a good move. Therefore we want to look at it early on + * in future searches. We also penalize previously-searched quiets, since they are clearly not as good + * as this one (as they did not cause a beta cutoff). + ****************************************************************************************************/ + // Simple bonus based on depth + let bonus = self.params.history_multiplier * depth - self.params.history_offset; + + // Only update quiet moves + if mv.is_quiet() { + self.history.update(game, mv, bonus); + } + + // Apply a penalty to all quiets searched so far + for mv in moves[..i].iter().filter(|mv| mv.is_quiet()) { + self.history.update(game, mv, -bonus); + } + break; + } + } + } + + // Adjust mate score by 1 ply, since we're returning up the call stack + if best.is_mate() { + best -= best.signum(); + } + + // Save this node to the TTable. + self.save_to_tt( + game.key(), + bestmove, + best, + SearchBounds::new(original_alpha, bounds.beta), + depth, + ); + + Ok(best) + } + + /// Quiescence Search (QSearch) + /// + /// A search that looks at only possible captures and capture-chains. + /// This is called when [`Search::negamax`] reaches a depth of 0, and has no recursion limit. + fn quiescence( + &mut self, + game: &Game, + ply: Ply, + mut bounds: SearchBounds, + pv: &mut PrincipalVariation, + ) -> Result { + self.search_cancelled()?; // Exit early if search is terminated. + + // Record the max max height / max ply / seldepth + self.result.seldepth = self.result.seldepth.max(ply) * !Node::ROOT as i32; + + // Declare a local principal variation for nodes found during this search. + let mut local_pv = PrincipalVariation::default(); + // Clear any nodes in this PV, since we're searching from a new position + pv.clear(); + + // Evaluate the current position, to serve as our baseline + let static_eval = game.eval(); + + // Beta cutoff; this position is "too good" and our opponent would never let us get here + if static_eval >= bounds.beta { + return Ok(static_eval); // fail-soft + } else if static_eval > bounds.alpha { + bounds.alpha = static_eval; + } + + // Probe the TT to see if we can return early or use an existing bestmove. + let tt_move = match self.ttable.probe(game.key(), Ply::ZERO, bounds) { + /**************************************************************************************************** + * TT Cutoffs: https://www.chessprogramming.org/Transposition_Table#Transposition_Table_Cutoffs + * + * If we've already evaluated this position before at a higher depth, we can avoid re-doing a lot of + * work by just returning the evaluation stored in the transposition table. However, we must be sure + * that we are not in a PV node. + ****************************************************************************************************/ + ProbeResult::Cutoff(tt_score) if !Node::PV => return Ok(tt_score), + + // Entry was found, but could not be used to perform a cutoff + ProbeResult::Hit(tt_entry) => tt_entry.bestmove, + + // Miss or otherwise unusable result + _ => None, + }; + + // Generate only the legal captures + // TODO: Is there a more concise way of doing this? + // The `game.into_iter().only_captures()` doesn't cover en passant... + let mut moves = game + .get_legal_moves() + .into_iter() + .filter(Move::is_capture) + .collect::(); + + // Can't check for mates in normal qsearch, since we're not looking at *all* moves. + // So, if there are no captures available, just return the current evaluation. + if moves.is_empty() { + return Ok(static_eval); + } + + moves.sort_by_cached_key(|mv| self.score_move(game, mv, tt_move)); + + let mut best = static_eval; + let mut bestmove = tt_move; // Ensures we don't overwrite TT entry's bestmove with `None` if one already existed. + let original_alpha = bounds.alpha; + + /**************************************************************************************************** + * Primary move loop + ****************************************************************************************************/ + + for mv in moves { + // The local PV is different for every node search after this one, so we must reset it in between recursive calls. + local_pv.clear(); + + // Copy-make the new position + let new = game.with_move_made(mv); + let mut score = Score::DRAW; + + /**************************************************************************************************** + * Recursion of the search + ****************************************************************************************************/ + // Don't bother searching drawn positions, unless we're in the root node. + if Node::ROOT || !self.is_draw(&new) { + // Append this position onto our stack, so we can detect repetitions + self.prev_positions.push(*new.position()); + + score = -self.quiescence::(&new, ply + 1, -bounds, &mut local_pv)?; + + self.search_cancelled()?; // Exit early if search is terminated. + + self.result.nodes += 1; // We've now searched this node + + self.prev_positions.pop(); + } + + /**************************************************************************************************** + * Score evaluation & bounds adjustments + ****************************************************************************************************/ + // If we've found a better move than our current best, update our result + if score > best { + best = score; + + // PV found + if score > bounds.alpha { + bounds.alpha = score; + bestmove = Some(mv); + + // Only extend the PV if we're in a PV node + if Node::PV { + // assert_pv_is_legal(game, mv, &local_pv); + pv.extend(mv, &local_pv); + } + } + + // Fail high + if score >= bounds.beta { + break; + } + } + } + + // Adjust mate score by 1 ply, since we're returning up the call stack + if best.is_mate() { + best -= best.signum(); + } + + // Save this node to the TTable. + self.save_to_tt( + game.key(), + bestmove, + best, + SearchBounds::new(original_alpha, bounds.beta), + Ply::ZERO, + ); + + Ok(best) // fail-soft + } + + /// Checks if we've exceeded any conditions that would warrant the search to end. + /// + /// This method returns an `Err` of [`SearchCancelled`] if the search must end prematurely. + /// While a termination isn't truly an error, this API allows us to cleanly leverage the `?` operator. + #[inline(always)] + fn search_cancelled(&self) -> Result<(), SearchCancelled> { + // Only check for timeouts every 1024 nodes, because searches are fast and 1k nodes doesn't take too long.. + if self.result.nodes % 1024 == 0 { + // We've exceeded the hard limit of our allotted search time + if let Some(diff) = self + .config + .starttime + .elapsed() + .checked_sub(self.config.hard_timeout) + { + return Err(SearchCancelled::HardTimeout(self.config.hard_timeout, diff)); + } + } + + // We've exceeded the maximum amount of nodes we're allowed to search + if let Some(diff) = self.result.nodes.checked_sub(self.config.max_nodes) { + return Err(SearchCancelled::MaxNodes(diff)); + } + + // The search was stopped by an external factor, like the `stop` command + if !self.is_searching.load(Ordering::Relaxed) { + return Err(SearchCancelled::Stopped); + } + + // No conditions met; we can continue searching + Ok(()) + } + + /// Checks if `game` is a repetition, comparing it to previous positions + #[inline(always)] + fn is_repetition(&self, game: &Game) -> bool { + // We can skip the previous position, because there's no way it can be a repetition. + // We also only need to look check at most `halfmove` previous positions. + let n = game.halfmove(); + for prev in self.prev_positions.iter().rev().take(n).skip(1).step_by(2) { + if prev.key() == game.key() { + return true; + } else + // The halfmove counter only resets on irreversible moves (captures, pawns, etc.) so it can't be a repetition. + if prev.halfmove() == 0 { + return false; + } + } + + false + } + + /// Returns `true` if `game` can be claimed as a draw + #[inline(always)] + fn is_draw(&self, game: &Game) -> bool { + self.is_repetition(game) + || game.can_draw_by_fifty() + || game.can_draw_by_insufficient_material() + } + + /// Saves the provided data to an entry in the TTable. + #[inline(always)] + fn save_to_tt( + &mut self, + key: ZobristKey, + bestmove: Option, + score: Score, + bounds: SearchBounds, + depth: Ply, + ) { + let entry = TTableEntry::new(key, bestmove, score, bounds, depth); + let old = self.ttable.store(entry); + + if Log::DEBUG { + // If a previous entry existed and had a *different* key, this was a collision + if old.is_some_and(|old| old.key != key) { + self.ttable.collisions += 1; + } + + // This was a write, regardless. + self.ttable.writes += 1; + } + } + + /// Applies a score to the provided move, intended to be used when ordering moves during search. + #[inline(always)] + fn score_move(&self, game: &Game, mv: &Move, tt_move: Option) -> Score { + // TT move should be looked at first, so assign it the best possible score and immediately exit. + if tt_move.is_some_and(|tt_mv| tt_mv == *mv) { + return Score::new(i32::MIN); + } + + // Safe unwrap because we can't move unless there's a piece at `from` + let piece = game.piece_at(mv.from()).unwrap(); + let to = mv.to(); + let mut score = Score::BASE_MOVE_SCORE; + + // Apply history bonus to quiets + if mv.is_quiet() { + score += self.history[piece][to]; + } else + // Capturing a high-value piece with a low-value piece is a good idea + if let Some(victim) = game.piece_at(to) { + score += MVV_LVA[piece][victim]; + } + + -score // We're sorting, so a lower number is better + } + + /// If we can prune the provided node, this function returns a score to return upon pruning. + /// + /// If we cannot prune the node, this function returns `None`. + #[inline] + fn node_pruning_score( + &mut self, + game: &Game, + depth: Ply, + ply: Ply, + bounds: SearchBounds, + pv: &mut PrincipalVariation, + local_pv: &mut PrincipalVariation, + ) -> Result, SearchCancelled> { + // Cannot prune anything in a PV node or if we're in check + if Node::PV || game.is_in_check() { + return Ok(None); + } + + // Static evaluation of the current position is used in multiple pruning techniques. + let static_eval = game.eval(); + + /**************************************************************************************************** + * Razoring: https://www.chessprogramming.org/Razoring + * + * If the static eval of our position is low enough, check if a qsearch can beat alpha. + * If it can't, we can prune this node. + ****************************************************************************************************/ + let razoring_margin = Score::RAZORING_OFFSET + Score::RAZORING_MULTIPLIER * depth; + if depth <= self.params.max_razoring_depth && static_eval + razoring_margin < bounds.alpha { + let score = self.quiescence::(game, ply, bounds.null_alpha(), pv)?; + // If we can't beat alpha (without mating), we can prune. + if score < bounds.alpha && !score.is_mate() { + return Ok(Some(score)); // fail-soft + } + } + + /**************************************************************************************************** + * Reverse Futility Pruning: https://www.chessprogramming.org/Reverse_Futility_Pruning + * + * If our static eval is too good (better than beta), we can prune this branch. Multiplying our + * margin by depth makes this pruning process less risky for higher depths. + ****************************************************************************************************/ + let rfp_score = static_eval - self.params.rfp_margin * depth; + if depth <= self.params.max_rfp_depth && rfp_score >= bounds.beta { + return Ok(Some(rfp_score)); + } + + /**************************************************************************************************** + * Null Move Pruning: https://www.chessprogramming.org/Null_Move_Pruning + * + * If we can afford to skip our turn and give our opponent two moves in a row while maintaining a high + * enough score, we can prune this branch as our opponent would likely never let us reach it anyway. + ****************************************************************************************************/ + // If the last move did not increment the fullmove, but *did* increment the halfmove, it was a nullmove + let last_move_was_nullmove = self.prev_positions.last().is_some_and(|pos| { + pos.fullmove() == game.fullmove() && pos.halfmove() == game.halfmove() + 1 + }); + + // All pieces that are not Kings or Pawns + let non_king_pawn_material = + game.occupied() ^ game.kind(PieceKind::Pawn) ^ game.kind(PieceKind::King); + + let can_perform_nmp = depth >= self.params.min_nmp_depth // Can't play nullmove under a certain depth + && !last_move_was_nullmove // Can't play two nullmoves in a row + && non_king_pawn_material.is_nonempty(); // Can't play nullmove if insufficient material (only Kings and Pawns) + + if can_perform_nmp { + let null_game = game.with_nullmove_made(); + // Record this position in our stack, for repetition detection + self.prev_positions.push(*null_game.position()); + + // Search at a reduced depth with a zero-window + let nmp_depth = depth - self.params.nmp_reduction; + let score = -self.negamax::( + &null_game, + nmp_depth, + ply + 1, + -bounds.null_beta(), + local_pv, + )?; + + self.prev_positions.pop(); + + // If making the nullmove produces a cutoff, we can assume that a full-depth search would also produce a cutoff + if score >= bounds.beta { + return Ok(Some(score)); + } + } + + // If no pruning technique was possible, return no score + Ok(None) + } + + /// Compute a reduction value (`R`) to apply to a given node's search depth, if possible. + #[inline(always)] + fn reduction_value( + &self, + depth: Ply, + game: &Game, + moves_made: usize, + ) -> Option { + /**************************************************************************************************** + * Late Move Reductions: https://www.chessprogramming.org/Late_Move_Reductions + * + * We assume our move ordering will let us search the best moves first. Thus, the last moves are + * likely to be the worst moves. We can save some time by searching these at a lower depth and with + * a null window. If this fails, however, we must perform a costly re-search. + ****************************************************************************************************/ + (depth >= self.params.min_lmr_depth + && moves_made >= self.params.min_lmr_moves + Node::PV as usize) + .then(|| { + // Base LMR reduction increases as we go higher in depth and/or make more moves + let mut lmr_reduction = self.params.lmr_table[depth.plies() as usize][moves_made]; + // let mut lmr_reduction = (self.params.lmr_offset + // + (depth.plies() as f32).ln() * (moves_made as f32).ln() + // / self.params.lmr_divisor) as i32; + + // Increase/decrease the reduction based on current conditions + // lmr_reduction += something; + lmr_reduction -= game.is_in_check() as i32; + + lmr_reduction + }) + } + + /// Compute an extension value to apply to a given node's search depth. + #[inline(always)] + fn extension_value(&self, game: &Game) -> Ply { + let mut extension = Ply::ZERO; + + /**************************************************************************************************** + * Check Extensions: https://www.chessprogramming.org/Check_Extensions + * + * If we're in check, we should extend the search a bit, in hopes to find a good way to escape. + ****************************************************************************************************/ + if game.is_in_check() { + extension += self.params.check_extensions_depth + } + + extension + } +} + +/// Utility function to assert that the PV is legal for the provided game. +#[allow(dead_code)] +fn assert_pv_is_legal(game: &Game, mv: Move, local_pv: &PrincipalVariation) { + let fen = game.to_fen(); + let mut game = game.with_move_made(mv); + + for local_pv_mv in &local_pv.0 { + assert!( + game.is_legal(*local_pv_mv), + "Illegal PV move {local_pv_mv} found on {fen}\nFull PV: {}\nResulting FEN: {}", + [&mv] + .into_iter() + .chain(local_pv.0.iter()) + .map(|m| m.to_string()) + .collect::>() + .join(" "), + game.to_fen() + ); + game.make_move(*local_pv_mv); + } +} + +/// This table represents values for [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) move ordering. +/// +/// It is indexed by `[attacker][victim]`, and yields a "score" that is used when sorting moves. +/// +/// The following table is produced: +/// ```text +/// VICTIM +/// A P N B R Q K +/// T +---------------------------------+ +/// T P| 900 3100 3200 4900 8900 0 +/// A N| 680 2880 2980 4680 8680 0 +/// C B| 670 2870 2970 4670 8670 0 +/// K R| 500 2700 2800 4500 8500 0 +/// E Q| 100 2300 2400 4100 8100 0 +/// R K| 1000 3200 3300 5000 9000 0 +/// ``` +/// +/// Note that the actual table is different, as it has size `12x12` instead of `6x6` +/// to account for the fact that castling is denoted as `KxR`. +/// The values are also all left-shifted by 16 bits, to ensure that captures are ranked above quiets in all cases. +/// +/// See [`print_mvv_lva_table`] to display this table. +const MVV_LVA: [[i32; Piece::COUNT]; Piece::COUNT] = { + let mut matrix = [[0; Piece::COUNT]; Piece::COUNT]; + let count = Piece::COUNT; + + let mut attacker = 0; + while attacker < count { + let mut victim = 0; + let atk_color = Color::from_bool(attacker < PieceKind::COUNT); + + while victim < count { + let atk = PieceKind::from_bits_unchecked(attacker as u8 % 6); + let vtm = PieceKind::from_bits_unchecked(victim as u8 % 6); + + let vtm_color = Color::from_bool(victim < PieceKind::COUNT); + + // Remove scores for capturing the King and friendly pieces (KxR for castling) + let can_capture = (atk_color.index() != vtm_color.index()) // Different colors + && victim != count - 1 // Can't capture White King + && victim != PieceKind::COUNT - 1; // Can't capture Black King + + // Rustic's way of doing things; Arbitrary increasing numbers for capturing pairs + // bench: 27609398 nodes 5716479 nps + // let score = (victim * 10 + (count - attacker)) as i32; + + // Default MVV-LVA except that the King is assigned a value of 0 if he is attacking + // bench: 27032804 nodes 8136592 nps + let score = 10 * vtm.value() - atk.value(); + + // If the attacker is the King, the score is half the victim's value. + // This encourages the King to attack, but not as strongly as other pieces. + // bench: 27107011 nodes 5647285 nps + // let score = if attacker == count - 1 { + // value_of(vtm) / 2 + // } else { + // // Standard MVV-LVA computation + // 10 * value_of(vtm) - value_of(atk) + // }; + + // Shift the value by a large amount so that captures are always ranked very highly + matrix[attacker][victim] = (score * can_capture as i32) << 16; + victim += 1; + } + attacker += 1; + } + matrix +}; + +/// Utility function to print the MVV-LVA table +#[allow(dead_code)] +pub fn print_mvv_lva_table() { + print!("\nX "); + for victim in Piece::all() { + print!("{victim} "); + } + print!("\n +"); + for _ in Piece::all() { + print!("------"); + } + println!("-+"); + for attacker in Piece::all() { + print!("{attacker}| "); + for victim in Piece::all() { + let score = MVV_LVA[attacker][victim]; + print!("{score:<4} ") + } + println!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::*; + + fn run_search(fen: &str, config: SearchConfig) -> SearchResult { + let is_searching = Arc::new(AtomicBool::new(true)); + let game = fen.parse().unwrap(); + + let mut ttable = Default::default(); + let mut history = Default::default(); + Search::::new( + is_searching, + config, + Default::default(), + &mut ttable, + &mut history, + Default::default(), + ) + .start(&game) + } + + fn ensure_is_mate_in(fen: &str, config: SearchConfig, moves: i32) -> SearchResult { + let res = run_search(fen, config); + assert!( + res.score.is_mate(), + "Search on {fen:?} with config {config:#?} produced result that is not mate.\nResult: {res:#?}" + ); + assert_eq!( + res.score.moves_to_mate(), + moves, + "Search on {fen:?} with config {config:#?} produced result not mate in {moves}.\nResult: {res:#?}" + ); + res + } + + #[test] + fn test_white_mate_in_1() { + let fen = "k7/8/KQ6/8/8/8/8/8 w - - 0 1"; + let config = SearchConfig { + max_depth: Ply::new(2), + ..Default::default() + }; + + let res = ensure_is_mate_in(fen, config, 1); + assert_eq!(res.bestmove().unwrap(), "b6a7", "Result: {res:#?}"); + } + + #[test] + fn test_black_mated_in_1() { + let fen = "2k5/7Q/8/2K5/8/8/8/6Q1 b - - 0 1"; + let config = SearchConfig { + max_depth: Ply::new(3), + ..Default::default() + }; + + let res = ensure_is_mate_in(fen, config, -1); + assert!(["c8b8", "c8d8"].contains(&res.bestmove().unwrap().to_string().as_str())); + } + + #[test] + fn test_stalemate() { + let fen = "k7/8/KQ6/8/8/8/8/8 b - - 0 1"; + let config = SearchConfig::default(); + + let res = run_search(fen, config); + assert!(res.bestmove().is_none()); + assert_eq!(res.score, Score::DRAW); + } + + #[test] + fn test_obvious_capture_promote() { + // Pawn should take queen and also promote to queen + let fen = "3q1n2/4P3/8/8/8/8/k7/7K w - - 0 1"; + let config = SearchConfig { + max_depth: Ply::new(1), + ..Default::default() + }; + + let res = run_search(fen, config); + assert_eq!(res.bestmove().unwrap(), "e7d8q"); + } + + #[test] + fn test_quick_search_finds_move() { + // If *any* legal move is available, it should be found, regardless of how much time was given. + let fen = FEN_STARTPOS; + let config = SearchConfig { + soft_timeout: Duration::from_nanos(1), + hard_timeout: Duration::from_nanos(1), + ..Default::default() + }; + + let res = run_search(fen, config); + assert!(res.bestmove().is_some()); + } + + #[test] + fn test_go_nodes() { + let fen = FEN_KIWIPETE; + + let node_limits = [0, 1, 10, 17, 126, 192, 1748, 182048, 1928392]; + + for max_nodes in node_limits { + let config = SearchConfig { + max_nodes, + ..Default::default() + }; + + let res = run_search(fen, config); + + assert_eq!(res.nodes, max_nodes); + assert!(res.depth < Ply::MAX); // Ensure the ID didn't loop forever. + } + } + + #[test] + fn test_go_nodes_cutoff_search_still_gives_good_result() { + // d5e6 is a good capture, but will lead to mate on the next iteration. + let fen = "k6r/8/4q3/3P4/8/8/PP6/K7 w - - 0 1"; + + // Loop until we reach a depth that does NOT think d5e6 is the best move + let mut max_depth = Ply::ONE; + let max_nodes = loop { + let config = SearchConfig { + max_depth, + ..Default::default() + }; + + let res = run_search(fen, config); + if res.bestmove().unwrap() != "d5e6" { + break res.nodes; + } + + max_depth += 1; + }; + + // Now run a search on that position, stopping *just* before we would have found a move better than d5e6 + let config = SearchConfig { + max_nodes: max_nodes - 1, + ..Default::default() + }; + let res = run_search(fen, config); + assert_eq!(res.bestmove().unwrap(), "d5e6"); + + // Do the same, but stop just *after* the node count that lets us find a better move than d5e6 + let config = SearchConfig { + max_nodes: max_nodes + 1, + ..Default::default() + }; + let res = run_search(fen, config); + assert_ne!(res.bestmove().unwrap(), "d5e6"); + } +} diff --git a/src/ttable.rs b/toad/src/ttable.rs similarity index 75% rename from src/ttable.rs rename to toad/src/ttable.rs index bd11290..c57e3f9 100644 --- a/src/ttable.rs +++ b/toad/src/ttable.rs @@ -4,7 +4,23 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use crate::{Move, Score, SearchBounds, ZobristKey, BYTES_IN_MB}; +use crate::{Move, Ply, Score, SearchBounds, ZobristKey}; + +/// Number of bytes in a megabyte +const BYTES_IN_MB: usize = 1024 * 1024; + +/// Result of probing the [`TTable`]. +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +pub enum ProbeResult<'a> { + /// An entry was found and can be used to perform a cutoff. + Cutoff(Score), + + /// An entry was found, but it could not be used to perform a cutoff. + Hit(&'a TTableEntry), + + /// No entry was found for the provided key. + Miss, +} /// Type of node encountered during search. /// @@ -54,7 +70,7 @@ pub struct TTableEntry { pub depth: u8, /// Best move found for this position. - pub bestmove: Move, + pub bestmove: Option, /// Best score found for this position. pub score: Score, @@ -71,47 +87,24 @@ impl TTableEntry { #[inline(always)] pub fn new( key: ZobristKey, - bestmove: Move, + bestmove: Option, score: Score, bounds: SearchBounds, - depth: u8, - ply: i32, + depth: Ply, ) -> Self { - // Determine what kind of node this is fist, before score adjustment - let node_type = NodeType::new(score, bounds); - - // Adjust the score (if it was mate) to the ply at which we found it - let score = score.absolute(ply); - Self { key, bestmove, score, - depth, - node_type, + depth: depth.plies() as u8, + node_type: NodeType::new(score, bounds), } } - /// Determine whether the score in this entry can be used and, if so, return it. - /// - /// An entry's score can be used if and only if: - /// 1. The entry is exact ([`NodeType::Pv`]). - /// 2. The entry is an upper bound ([`NodeType::All`]) and its score is `<= alpha`. - /// 3. The entry is a lower bound ([`NodeType::Cut`]) and its score is `>= beta`. + /// Fetch the depth associated with this entry. #[inline(always)] - pub fn try_score(&self, bounds: SearchBounds, ply: i32) -> Option { - // Adjust mate scores to be relative to current ply - let score = if self.score.is_mate() { - self.score.relative(ply) - } else { - self.score - }; - - // If we can cutoff, do so - (self.node_type == NodeType::Pv - || ((self.node_type == NodeType::All && score <= bounds.alpha) - || (self.node_type == NodeType::Cut && score >= bounds.beta))) - .then_some(score) + pub fn depth(&self) -> Ply { + Ply::new(self.depth as i32) } } @@ -127,8 +120,11 @@ pub struct TTable { /// Number of collisions that have occurred since last clearing. pub(crate) collisions: usize, - /// Number of accesses that have occurred since last clearing. - pub(crate) accesses: usize, + /// Number of reads that have occurred since last clearing. + pub(crate) reads: usize, + + /// Number of writes that have occurred since last clearing. + pub(crate) writes: usize, /// Number of hits that have occurred since last clearing. pub(crate) hits: usize, @@ -158,17 +154,21 @@ impl TTable { Self { cache: vec![None; capacity], collisions: 0, - accesses: 0, + reads: 0, + writes: 0, hits: 0, } } /// Clears the entries of this [`TTable`]. + /// + /// Also resets all collected stats. #[inline(always)] pub fn clear(&mut self) { self.cache.iter_mut().for_each(|entry| *entry = None); self.collisions = 0; - self.accesses = 0; + self.reads = 0; + self.writes = 0; self.hits = 0; } @@ -234,6 +234,38 @@ impl TTable { let index = self.index(&entry.key); self.cache[index].replace(entry) } + + /// Probes the [`TTable`] for an entry at the provided `key`, returning that entry's score, if appropriate. + /// + /// If an entry is found from a greater depth than `depth`, its score is returned if and only if: + /// 1. The entry is exact. + /// 2. The entry is an upper bound and its score is `<= alpha`. + /// 3. The entry is a lower bound and its score is `>= beta`. + /// + /// See [`TTableEntry::try_score`] for more. + #[inline(always)] + pub fn probe(&self, key: ZobristKey, depth: Ply, bounds: SearchBounds) -> ProbeResult { + // if-let chains are set to be stabilized in Rust 2024 (1.85.0): https://rust-lang.github.io/rfcs/2497-if-let-chains.html + if let Some(entry) = self.get(&key) { + // Can only cut off if the existing entry came from a greater depth. + if entry.depth() >= depth { + let score = entry.score; + + // If we can cutoff, do so + if entry.node_type == NodeType::Pv + || ((entry.node_type == NodeType::All && score <= bounds.alpha) + || (entry.node_type == NodeType::Cut && score >= bounds.beta)) + { + return ProbeResult::Cutoff(score); + } + } + + // No cutoff was possible, but there was still an entry found. + return ProbeResult::Hit(entry); + } + + ProbeResult::Miss + } } impl Default for TTable { @@ -267,7 +299,7 @@ mod test { // Create entries for both positions let entry1 = TTableEntry { key: key1, - bestmove: Move::illegal(), + bestmove: None, score: Score::DRAW, depth: 0, node_type: NodeType::Pv, @@ -275,7 +307,7 @@ mod test { let entry2 = TTableEntry { key: key2, - bestmove: Move::illegal(), + bestmove: None, score: Score::MATE, depth: 0, node_type: NodeType::Pv, diff --git a/src/tune.rs b/toad/src/tune.rs similarity index 67% rename from src/tune.rs rename to toad/src/tune.rs index f33ddd4..db50791 100644 --- a/src/tune.rs +++ b/toad/src/tune.rs @@ -23,7 +23,7 @@ pub(crate) use soft_timeout_divisor; /// Divisor for computing the hard timeout of a search. macro_rules! hard_timeout_divisor { () => { - 5 + 3 }; } pub(crate) use hard_timeout_divisor; @@ -98,23 +98,23 @@ pub(crate) use history_offset; /// Minimum depth at which null move pruning can be applied. macro_rules! min_nmp_depth { () => { - 3 + 300 }; } pub(crate) use min_nmp_depth; /// Value to subtract from `depth` when applying null move pruning. -macro_rules! nmp_reduction_value { +macro_rules! nmp_reduction { () => { - 3 + 300 }; } -pub(crate) use nmp_reduction_value; +pub(crate) use nmp_reduction; /// Maximum depth at which to apply reverse futility pruning. macro_rules! max_rfp_depth { () => { - 5 + 600 }; } pub(crate) use max_rfp_depth; @@ -122,15 +122,23 @@ pub(crate) use max_rfp_depth; /// Safety margin when applying reverse futility pruning. macro_rules! rfp_margin { () => { - 75 + 70 }; } pub(crate) use rfp_margin; +/// Maximum depth at which to apply late move pruning. +macro_rules! max_lmp_depth { + () => { + 300 + }; +} +pub(crate) use max_lmp_depth; + /// Minimum depth at which to apply late move reductions. macro_rules! min_lmr_depth { () => { - 3 + 300 }; } pub(crate) use min_lmr_depth; @@ -158,3 +166,75 @@ macro_rules! lmr_divisor { }; } pub(crate) use lmr_divisor; + +/// Value to multiply depth by when computing razoring margin. +macro_rules! razoring_multiplier { + () => { + 128 + }; +} +pub(crate) use razoring_multiplier; + +/// Value to subtract from alpha bound when computing a razoring margin. +macro_rules! razoring_offset { + () => { + 256 + }; +} +pub(crate) use razoring_offset; + +/// Depth to extend by for check extensions. +macro_rules! check_extensions_depth { + () => { + 100 + }; +} +pub(crate) use check_extensions_depth; + +/// Maximum depth at which razoring can be applied. +macro_rules! max_razoring_depth { + () => { + 200 + }; +} +pub(crate) use max_razoring_depth; + +/// Multiplier for the LMP formula. +macro_rules! lmp_multiplier { + () => { + 1 + }; +} +pub(crate) use lmp_multiplier; + +/// Divisor for the LMP formula. +macro_rules! lmp_divisor { + () => { + 3 + }; +} +pub(crate) use lmp_divisor; + +/// Minimum depth at which IIR can be performed +macro_rules! min_iir_depth { + () => { + 500 + }; +} +pub(crate) use min_iir_depth; + +/// Minimum depth at which IID can be performed +macro_rules! min_iid_depth { + () => { + 400 + }; +} +pub(crate) use min_iid_depth; + +/// Offset to subtract from depth during IID. +macro_rules! iid_offset { + () => { + 200 + }; +} +pub(crate) use iid_offset; diff --git a/toad/src/utils.rs b/toad/src/utils.rs new file mode 100644 index 0000000..1082dd8 --- /dev/null +++ b/toad/src/utils.rs @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/// Level of communication to output during search. +pub trait LogLevel { + /// Print UCI search info. + const INFO: bool; + + /// Print additional debug and statistical information. + const DEBUG: bool; +} + +/// Do not print anything to stdout. +/// +/// See [`LogLevel`] for more. +pub struct LogNone; +impl LogLevel for LogNone { + const INFO: bool = false; + const DEBUG: bool = false; +} + +/// Only print basic communication, such as `bestmove`. +/// +/// See [`LogLevel`] for more. +pub struct LogInfo; +impl LogLevel for LogInfo { + const INFO: bool = true; + const DEBUG: bool = false; +} + +/// Print additional messages through `info string`. +/// +/// See [`LogLevel`] for more. +pub struct LogDebug; +impl LogLevel for LogDebug { + const INFO: bool = true; + const DEBUG: bool = true; +} + +/// Positions used when running engine benchmarks. +pub const BENCHMARK_FENS: [&str; 56] = [ + // From [Stormphrax](https://github.com/Ciekce/Stormphrax/blob/correct_ep_handling/src/bench.cpp#L29). + "q5k1/5ppp/1r3bn1/1B6/P1N2P2/BQ2P1P1/5K1P/8 b - - 2 34", + "6r1/5k2/p1b1r2p/1pB1p1p1/1Pp3PP/2P1R1K1/2P2P2/3R4 w - - 1 36", + "r1bq2k1/p4r1p/1pp2pp1/3p4/1P1B3Q/P2B1N2/2P3PP/4R1K1 b - - 2 19", + "2rr2k1/1p4bp/p1q1p1p1/4Pp1n/2PB4/1PN3P1/P3Q2P/2RR2K1 w - f6 0 20", + "3br1k1/p1pn3p/1p3n2/5pNq/2P1p3/1PN3PP/P2Q1PB1/4R1K1 w - - 0 23", + "2r2b2/5p2/5k2/p1r1pP2/P2pB3/1P3P2/K1P3R1/7R w - - 23 93", + "8/8/1p1kp1p1/p1pr1n1p/P6P/1R4P1/1P3PK1/1R6 b - - 15 45", + "8/8/1p1k2p1/p1prp2p/P2n3P/6P1/1P1R1PK1/4R3 b - - 5 49", + "8/8/1p4p1/p1p2k1p/P2npP1P/4K1P1/1P6/3R4 w - - 6 54", + "8/8/1p4p1/p1p2k1p/P2n1P1P/4K1P1/1P6/6R1 b - - 6 59", + "8/5k2/1p4p1/p1pK3p/P2n1P1P/6P1/1P6/4R3 b - - 14 63", + "8/1R6/1p1K1kp1/p6p/P1p2P1P/6P1/1Pn5/8 w - - 0 67", + // 218 legal moves available + "R6R/3Q4/1Q4Q1/4Q3/2Q4Q/Q4Q2/pp1Q4/kBNN1KB1 w - - 0 1", + "r6r/3q4/1q4q1/4q3/2q4q/q4q2/PP1q4/Kbnn1kb1 b - - 0 1", + // Checkmated positions + "4k3/4Q3/4K3/8/8/8/8/8 b - - 0 1", + "8/8/8/8/8/2k5/1q6/K7 w - - 0 1", + // Stalemated positions + "K7/8/kq6/8/8/8/8/8 w - - 0 1", + "8/8/8/8/8/5QK1/8/6k1 b - - 0 1", + // 3-fold repetition; best move here is c2c1 + "7k/2QQ4/8/8/8/PPP5/2q5/K7 b - - 0 1", + // 50 move rule; best move here is h2h3 + "7k/8/R7/1R6/7K/8/7P/8 w - - 99 1", + // A stalemate is better than losing; best move here is a1a7 + "k5q1/p7/8/6q1/6q1/6q1/8/Q6K w - - 0 1", + // Under-promotion; f2f1n is best + "8/2n5/1b6/8/4b1k1/8/5p1K/8 b - - 0 1", + // Zugzwang positions where null moves may fail + "8/8/p1p5/1p5p/1P5p/8/PPP2K1p/4R1rk w - - 0 1", + "1q1k4/2Rr4/8/2Q3K1/8/8/8/8 w - - 0 1", + "7k/5K2/5P1p/3p4/6P1/3p4/8/8 w - - 0 1", + "8/6B1/p5p1/Pp4kp/1P5r/5P1Q/4q1PK/8 w - - 0 32", + "8/8/1p1r1k2/p1pPN1p1/P3KnP1/1P6/8/3R4 b - - 0 1", + // Positions with only 1 legal move available + "k7/8/4rr2/7r/4K3/7r/8/8 w - - 0 1", + "k7/8/Q7/K7/8/8/8/8 b - - 0 1", + // Mate-in-1 + "3k3B/7p/p1Q1p3/2n5/6P1/K3b3/PP5q/R7 w - - 0 1", + "4bk2/ppp3p1/2np3p/2b5/2B2Bnq/2N5/PP4PP/4RR1K w - - 0 1", + "r3k1nr/p1p2p1p/2pP4/8/7q/7b/PPPP3P/RNBQ2KR b kq - 0 1", + // Mate-in-2 + "1B1Q1R2/8/qNrn3p/2p1rp2/Rn3k1K/8/5P2/bbN4B w - - 0 1", + "1B6/2R2PN1/8/7P/2p1pk2/2Q1pN1P/8/1B5K w - - 0 1", + "3q4/pp6/6p1/3Pp2k/1Q3p2/4r2P/P5RK/6R1 b - - 0 1", + // Mate-in-3 + "8/8/8/8/1p1N4/1Bk1K3/3N4/b7 w - - 0 1", + "5K1k/6R1/8/3b2P1/5p2/p6p/q7/8 w - - 0 1", + "2q3k1/1p4pp/3R1r2/p2bQ3/P7/1N2B3/1PP3rP/R3K3 b - - 0 1", + // Mate-in-4 + "8/3p1p2/5Ppp/K2R2bk/4pPrr/6Pp/4B2P/3N4 w - - 0 1", + "8/5p2/5p1p/5KPk/7p/7P/8/8 w - - 0 1", + "1r5k/1p1P2b1/p2Q3p/7P/2q5/2B4R/PP2n1r1/1K1R4 b - - 0 1", + // Mate-in-5 + "6b1/4Kpk1/5r2/8/3B2P1/7R/8/8 w - - 0 1", + "3k4/2pPp1n1/2K1p2b/1p2P1p1/bP1N1pP1/1p3Pp1/1P4P1/6B1 w - - 0 1", + "1b6/kPp5/p1P5/R5Rr/P1N1P3/8/6p1/6Kb w - - 0 1", + "4r1k1/pp6/6p1/3q4/1P1p2Pn/6K1/1B1Q1P2/3B4 b - - 0 1", + // Mate-in-6 + "1B3Nbb/1r2pn2/Bnp1P3/3kP3/p2PR3/1Pp1P1N1/5K2/8 w - - 0 1", + "1B4q1/1p6/4prb1/p3pr1p/P2RBkN1/5ppP/3N1RP1/1K6 w - - 0 1", + "1K1k1BB1/8/4P3/2p1P3/2p4b/8/8/8 w - - 0 1", + "r3k2r/pp1n1pp1/2p3p1/3p4/Pb1P4/1B2PPqP/1P4P1/R1BQ1R1K b kq - 0 1", + // Mate-in-7 + "1B2n3/8/2R5/5p2/3kp1n1/4p3/B3K3/b7 w - - 0 1", + "1B5b/1p1Np3/1Pp5/2P3p1/K2k3p/2N5/2nP1p2/5B2 w - - 0 1", + "1B5r/8/8/1b6/8/3p2RB/7k/5K2 w - - 0 1", + "r1b3k1/pp3pp1/5q1p/3pr3/1Q1n4/P1NB4/1P3PPP/R4RK1 b - - 0 1", + // Mate-in-8 + "1B1k1NRK/1p1BpP1N/P4p1p/4r2q/8/3b4/8/8 w - - 0 1", + "1B1rN3/p6q/5pp1/PPk4b/R1P1Pp1r/2pP1Bn1/2N1P2p/4KR2 w - - 0 1", + "4r1k1/8/2p5/2p1Rpq1/6P1/1PP4Q/P5K1/RN1r4 b - - 0 1", +]; diff --git a/tests/fischer.epd b/toad/tests/fischer.epd similarity index 100% rename from tests/fischer.epd rename to toad/tests/fischer.epd diff --git a/tests/perft.rs b/toad/tests/perft.rs similarity index 100% rename from tests/perft.rs rename to toad/tests/perft.rs diff --git a/tests/standard.epd b/toad/tests/standard.epd similarity index 100% rename from tests/standard.epd rename to toad/tests/standard.epd