From 8dcbb2cc9747a267a648623402a38da00423f336 Mon Sep 17 00:00:00 2001 From: Yoav Grimland Date: Wed, 13 Dec 2023 22:39:12 +0200 Subject: [PATCH] Created initial CEGO protocol driver code and search code. Updated Burn to 0.11.1 to utilize new features. --- README.md | 37 ++--- assets/docs/CEGO/REVISION-1/En passant.png | Bin 0 -> 75037 bytes docs/CEGO/REVISION-1.md | 93 ++++++++++++ docs/TOPOLOGY.md | 20 +++ docs/networks/H0.md | 1 + hash-core/src/board.rs | 13 +- hash-core/src/cache.rs | 58 -------- hash-core/src/game.rs | 38 +---- hash-core/src/lib.rs | 1 - hash-core/src/mg.rs | 46 +++--- hash-core/src/repr.rs | 8 +- hash-engine/Cargo.toml | 2 +- hash-engine/src/engine.rs | 164 +++++++++++++++++++++ hash-engine/src/lib.rs | 3 + hash-engine/src/main.rs | 9 +- hash-network/Cargo.toml | 2 +- hash-network/src/lib.rs | 34 ++--- hash-search/Cargo.toml | 3 +- hash-search/src/lib.rs | 6 +- hash-search/src/network.rs | 10 +- hash-search/src/puct.rs | 10 +- hash-search/src/search.rs | 23 +-- hash-search/src/tree.rs | 29 ++-- hash-train/Cargo.toml | 4 +- hash-train/src/lib.rs | 2 +- hash-train/src/main.rs | 5 +- hash-train/src/play.rs | 2 +- hash-train/src/train.rs | 8 +- rust-toolchain.toml | 2 +- 29 files changed, 402 insertions(+), 231 deletions(-) create mode 100644 assets/docs/CEGO/REVISION-1/En passant.png create mode 100644 docs/CEGO/REVISION-1.md create mode 100644 docs/TOPOLOGY.md create mode 100644 docs/networks/H0.md delete mode 100644 hash-core/src/cache.rs create mode 100644 hash-engine/src/engine.rs create mode 100644 hash-engine/src/lib.rs diff --git a/README.md b/README.md index bd40f7b..84cb63e 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,17 @@ # The Hash Chess Engine - [![Status](https://github.com/miestrode/hash/workflows/Rust/badge.svg)](https://github.com/miestrode/hash/actions) -Hash is an experimental Chess engine written in Rust, with the goal of putting to use recent advancements in statistics, -computer science and computer Chess. -Unlike most traditional Chess engines, Hash doesn't use the alpha-beta framework, and instead opts to perform directed -tree search in the form of AlphaZero-style MCTS. However, unlike Chess engines such as Leela Chess Zero, Hash -incorporates new ideas in its search, utilizing root-tree parallelization and move-picking via Murphy Sampling, which -should greatly improve its play. - -A secondary goal of Hash is to use as much Rust as possible in its design, to test the boundaries of what is possible -to do well currently, using Rust. Some areas may suffer, or just won't use Rust as a result, such as network training. - -## To do - -### Move generation (`hash-core`) +Hash is an experimental Chess engine written in Rust, with the goal of putting to use recent advancements in statistics, computer science and computer Chess. Unlike most traditional Chess engines, Hash doesn't use the alpha-beta framework, and instead opts to perform directed tree search in the form of AlphaZero-style MCTS, while in the future, incorporating and facilitating new ideas in network architecture and MCTS algorithmics. -- [ ] Make the FEN parser fail when the board it is parsing is illegal, as `Board` should never, whilst only using safe - functions result in an invalid position. -- [ ] Try to reimplement the `Pins` data structure and other ideas from the old move generation code. It is possible - that reimplementing the generation of slide constraints could make it a viable, fast option again. -- [ ] Refactor the build script, and it's magic bitboards setup (consider using `phf`, and unrelatedly switching to - black - magic bitboards) +A secondary goal of Hash is to use as much Rust as possible in its design, to test the boundaries of what is possible to do with modern Rust. Therefore, Hash currently uses the Burn deep learning framework for running its neural networks, instead of more established options, such as Tensorflow or PyTorch. The hope is that, in the future, there will be less of a feature gap between the frameworks, and that optimization tools will grow to support Burn and similar Rust-based projects. -### MCTS +Hash is currently in the process of being written, and has not officially released in any form. It is unlikely the code in the repository here currently works as a full Chess engine. -- [ ] Create an MCTS searcher using the networks (incorporating parallelism, Murphy Sampling and the like) -- [ ] Consider not tying a board to the tree, saving memory -- [ ] Consider to the contrary tying the relevant move to each child, or at least a move integer. +## CGCF (or, why Hash doesn't support UCI) +Hash doesn't support UCI, and instead uses its bespoke protocol, CGCF (Chess engine Game Control Format). The reasons for this are explained in [here](docs/CGCF.md). It suffices to say, we felt UCI assumed too many things about the engines implementing it, and that running Hash on a regular GUI was not a sought after goal at this time. -### Network training +### Documentation +As we feel Hash is a sufficiently large project, documentation explaining things such as its current network structure and things of the like can be seen in [here](docs/). Note that documentation is currently largely incomplete. -- [ ] Create a network trainer in Rust -- [ ] Create an evaluation framework, similar to FishTest or OpenBench +## Contributing +The project currently will not accept contributions which significantly alter the source code, and so does not have guidelines for doing so. This is because things are currently far too underdeveloped. In the future, a `CONTRIBUTING.md` file will be made. diff --git a/assets/docs/CEGO/REVISION-1/En passant.png b/assets/docs/CEGO/REVISION-1/En passant.png new file mode 100644 index 0000000000000000000000000000000000000000..6342896cb4510503495fe93025d03a49a6a2e076 GIT binary patch literal 75037 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 literal 0 HcmV?d00001 diff --git a/docs/CEGO/REVISION-1.md b/docs/CEGO/REVISION-1.md new file mode 100644 index 0000000..f0bf3c2 --- /dev/null +++ b/docs/CEGO/REVISION-1.md @@ -0,0 +1,93 @@ +# The CEGO (Chess Engine Game Operation) protocol, revision 1 +The CEGO protocol exists to answer a very specific need in Chess programming: the simple and fast operation of a Chess game between two Chess engines. All of this, while assuming little about how these engines operate. There exist other protocols to achieve this, such as the CECP, or UCI protocols. These answer a different, sometimes more general issue however, that of controlling a Chess engine in a GUI. In addition, they assume certain things on how engines implementing them operate. How can one generate a principal variation if all they are using is a neural network to pick the best move? This is not to say these protocols are bad, although they have some questionable and dated design decisions, but rather to say a better solution for the use-case described above may exist. It is the goal of this protocol to present such a solution. + +This document details revision 1 of CEGO. Whenever any change will be made to the specifications of the protocol, a new revision with an incremented number will be made. It is up to mediators and engines (see "Communication") to disclose which revision they currently use/support. + +## Communication +As a protocol, CEGO facilitates communication between two entities: an engine, and a mediator. A mediator here is a general term for any program which is operating a Chess game. This communication is done in plain-text, encoded in ASCII, by passing messages using the standard streams. Specifically, the mediator sends text to the standard input of the engine, and the engine sends text to its standard output. Every message must contain only a single newline, at its end. On windows, this means `\r\n`, and on other platforms, `\n`. + +Due to many reasons, communication can sometimes fail, resulting in malformed updates. Therefore, it is important to understand CEGO implements no mechanisms for fault tolerance. In other words, it fails *hard* and *fast*. In addition, text in the protocol is case-sensitive, and arbitrary whitespace separators, like those seen in UCI cannot be used. + +The specification here is written while only referring to the communications between the mediator and a single engine, although in actual use, the mediator would have to communicate with two Chess engines. Luckily, the protocol is entirely symmetric, making this trivial. Whenever one engine "sends a move", the mediator sends a message to the other Chess engine, using the protocol, so it can make a move. Once it does, that move is sent by the mediator to the original Chess engine, and this process repeats. + +## Message styling and protocol invariants +When displaying raw text in this specification, it should be interpreted as is, unless part of it is of the form: +``` + +``` +where `NAME` is some kind of "name". When this is the case, `` is a "parameter", and may be any string, based on the restrictions provided. Throughout the specification, parameters relating to time will always be in nanoseconds, as an integer. This means that 30 seconds would be represented as `30000000000`. Additionally, any Chess moves will be represented in long algebraic notation, as seen in UCI. + +### Long Algebraic Notation +Every single move in long Algebraic Notation is of the form: +``` + +``` + +such that these parameters take on different values for different kinds of moves, as described below. In all cases however, the square parameters will contain values such as `a4`, `e7`, and others, and the promotion parameter, which represents a piece, may be a `q`, `r`, `b`, `n`, or simply nothing, representing a queen, rook, bishop, knight, and no promotion taking place respectively. + +#### Normal Chess moves +For a normal Chess move, parameter values are as expected. +#### Promotions +Promotions, which of course can only happen with pawns, will have the promotion parameter set to the piece the pawn is promoted to. + +#### Castling +When castling, the origin and target squares used are those of the king. This means that for a king-side castling by white, the move will be `e1g1`. + +#### En passants +For en passants, the origin and target squares used are those of the pawn performing the capture. Therefore, for the following board: + +![A white pawn on E5, about to en passant a pawn on D5](../../assets/docs/CEGO/REVISION-1/En passant.png) + +the highlighted move will be `e5d6`. + +## Stages + +### Initialization +The first stage of CEGO is initialization, and it ensures the engine are ready for playing, as some engines have long initialization times, which can be caused by, for example, loading a neural network, downloading a tablebase, etc. Therefore, once communication begins, engines should send the message: +``` +ready\n +``` +as soon as it is ready to play. Note that like all messages, this one should be terminated by a newline, as seen above. + +### First move +After the mediator receives the confirmation, it should send the engine a first move message, of the form: +``` + \n +``` +where `` is the current position of the board, at the time of the engine's first move, in FEN notation. Note the increments which are a part of this message. `` should be added to the time left for the engine once it makes its move. With this in mind, one can see that this protocol doesn't support timing methods, such as Bronstein. + +Once the engine makes it move, it should send a message of the form: +``` +\n +``` +where `` is the move it has chosen to make. Even after the engine sends its move, it is free to ponder or do anything else. This is true in general: the engine may do anything at any point in time, provided it precisely follows the protocol. + +### Subsequent moves +Once the mediator receives the opponents chosen move, it should send a message of the form: +``` + \n +``` +where `` is the move the opponent made. Notice the increments are not present in this message, as they are constant throughout the game. Like in the "First move" section, the engine should reply with the move it has chosen to make. Once this is done, it will eventually receive a message of the same form as above, at which point the cycle repeats. + +## Termination of the game +### By the mediator +The mediator may stop the game at any time, at which point, they should terminate the two engine processes. No notice needs to be given to the engines. Despite this, the mediator *must* stop the game if it has reached a "definite conclusion". This means either stalemate, checkmate, or a draw by FIDE rules. When a game is terminated due to a FIDE draw, the mediator must make sure the playing engine could not deliver mate in their turn. + +Additionally, the mediator must stop the game if at least one of the engine processes somehow stop. + +### By an engine +When the game is terminated by an engine, unlike the mediator, it must be for a specific reason, and therefore, with a specific message to the mediator. + +#### Encountering a protocol error +When an engine encounters an error with the last message sent by the mediator, it will send the message: +``` +error\n +``` +once this message is sent, the engine may quit, and the game should be terminated by the mediator. + +#### Forfeiting the game +When the engine sees fit, instead of sending the expected message, it may send the message: +``` +forfeit\n +``` +to notify the mediator it has forfeited the game. Once this happens, the game must be terminated, and the engine may quit. Note that there's no need to use this mechanism for ending a game due to an internal engine error, as simply stopping the engine process will terminate the game. Therefore, a forfeit should be considered a win for the other engine, at all times. diff --git a/docs/TOPOLOGY.md b/docs/TOPOLOGY.md new file mode 100644 index 0000000..d49d601 --- /dev/null +++ b/docs/TOPOLOGY.md @@ -0,0 +1,20 @@ +# Topology of the project +Below is an explanation of the different files and folders in this project. + +## `hash-bootstrap` +Is a crate containing the basic constructs needed for the build script in `hash-core` to function. The build script is required for generating certain lookup tables for move generation. + +## `hash-core` +Is a crate containing code for performing move generation and representing a Chess board. + +## `hash-network` +Is a crate containing the model definition and supporting code for the Hash neural network (currently H0). It uses the Burn deep learning framework for this. + +## `hash-train` +Is a binary crate that uses `hash-network` in order to train a network using its model, and then save it so it can be used by the engine. + +## `hash-search` +Is a crate that implements the primary searching logic for the engine, by providing an advanced searching algorithm based on AlphaZero-style MCTS. + +## `hash-engine` +Is a binary crate functioning as the front-end for the Hash Chess engine. It contains logic for managing search using operations provided by `hash-search` and the networks produced by `hash-train`, and implements the CGCF protocol. It is intended to be used as a command-line program and has a CLI. diff --git a/docs/networks/H0.md b/docs/networks/H0.md new file mode 100644 index 0000000..e72d8f5 --- /dev/null +++ b/docs/networks/H0.md @@ -0,0 +1 @@ +# The H0 network architecture diff --git a/hash-core/src/board.rs b/hash-core/src/board.rs index 058c06d..99790b1 100644 --- a/hash-core/src/board.rs +++ b/hash-core/src/board.rs @@ -1,11 +1,10 @@ use std::{fmt, fmt::Display, mem, str::FromStr}; use crate::{ - cache::CacheHash, index, index::zobrist, mg, - repr::{ColoredPieceTable, Move, Piece, PieceKind, PieceTable, Player}, + repr::{ChessMove, ColoredPieceTable, Piece, PieceKind, PieceTable, Player}, }; use hash_bootstrap::{BitBoard, Color, Square}; @@ -23,12 +22,6 @@ pub struct Board { pub hash: u64, } -impl CacheHash for Board { - fn hash(&self) -> u64 { - self.hash - } -} - impl Board { pub fn starting_position() -> Self { // Taken from https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation @@ -124,7 +117,7 @@ impl Board { } // INVARIANT: The passed move must be legal in relation to the current board. - pub unsafe fn make_move_unchecked(&mut self, chess_move: &Move) { + pub unsafe fn make_move_unchecked(&mut self, chess_move: &ChessMove) { self.en_passant_capture_square = None; self.checkers = BitBoard::EMPTY; self.pinned = BitBoard::EMPTY; @@ -287,7 +280,7 @@ impl Board { } } - pub fn gen_child_boards(&self) -> impl Iterator + '_ { + pub fn gen_child_boards(&self) -> impl Iterator + '_ { mg::gen_moves(self).into_iter().map(|chess_move| { let mut new_board = *self; unsafe { diff --git a/hash-core/src/cache.rs b/hash-core/src/cache.rs deleted file mode 100644 index cf01ecf..0000000 --- a/hash-core/src/cache.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::{array, marker::PhantomData}; - -pub trait CacheHash { - fn hash(&self) -> u64; -} - -#[derive(Clone)] -struct Entry { - value: T, - hash: u64, -} - -impl Copy for Entry {} - -#[derive(Clone)] -pub struct Cache { - data: [Option>; N], - _marker: PhantomData, -} - -impl Cache { - // TODO: Rework this implementation to be less simple. The replacement strategy shown here - // should be tweaked to be more balanced, and of course, fixed-probing should be explored - // (probing up to some number H of buckets, and then simply replacing) - pub fn insert(&mut self, key: &K, value: V) { - let hash = key.hash(); - - self.data[hash as usize % N] = Some(Entry { value, hash }); - } - - pub fn get(&self, key: &K) -> Option<&V> { - let hash = key.hash(); - let entry = &self.data[hash as usize % self.data.len()]; - - entry.as_ref().and_then(|entry| { - if entry.hash == hash { - Some(&entry.value) - } else { - None - } - }) - } -} - -impl Cache { - pub fn new() -> Self { - Self { - data: array::from_fn(|_| None), - _marker: PhantomData, - } - } -} - -impl Default for Cache { - fn default() -> Self { - Self::new() - } -} diff --git a/hash-core/src/game.rs b/hash-core/src/game.rs index 19a3b29..d0830f5 100644 --- a/hash-core/src/game.rs +++ b/hash-core/src/game.rs @@ -2,48 +2,26 @@ use std::str::FromStr; use hash_bootstrap::Color; -use crate::{board::Board, cache::Cache, mg, repr::Move}; - -const REPETITIONS: usize = 1000; +use crate::{board::Board, mg, repr::ChessMove}; pub enum Outcome { Win(Color), Draw, } -#[derive(PartialEq)] -enum Repetition { - Once, - Never, -} - pub struct Game { board: Board, - repetitions: Cache, } impl Game { pub fn starting_position() -> Self { Self { board: Board::starting_position(), - repetitions: Cache::new(), } } - fn was_current_board_repeated_thrice(&self) -> bool { - if let Some(repetition) = self.repetitions.get(&self.board) { - *repetition == Repetition::Once - } else { - false - } - } - - fn can_either_player_claim_draw(&self) -> bool { - self.board.min_ply_clock >= 100 || self.was_current_board_repeated_thrice() - } - pub fn outcome(&self) -> Option { - if mg::gen_moves(&self.board).is_empty() || self.can_either_player_claim_draw() { + if mg::gen_moves(&self.board).is_empty() { Some(if self.board.in_check() { Outcome::Win(!self.board.playing_color) } else { @@ -54,16 +32,7 @@ impl Game { } } - pub unsafe fn make_move_unchecked(&mut self, chess_move: Move) { - self.repetitions.insert( - &self.board, - if self.repetitions.get(&self.board).is_none() { - Repetition::Never - } else { - Repetition::Once - }, - ); - + pub unsafe fn make_move_unchecked(&mut self, chess_move: ChessMove) { // SAFETY: Move is assumed to be legal in this position unsafe { self.board.make_move_unchecked(&chess_move); @@ -81,7 +50,6 @@ impl FromStr for Game { fn from_str(s: &str) -> Result { Ok(Self { board: Board::from_str(s)?, - repetitions: Cache::new(), }) } } diff --git a/hash-core/src/lib.rs b/hash-core/src/lib.rs index c7c1500..be2a1a3 100644 --- a/hash-core/src/lib.rs +++ b/hash-core/src/lib.rs @@ -1,7 +1,6 @@ #![feature(test)] pub mod board; -mod cache; pub mod game; mod index; pub mod mg; diff --git a/hash-core/src/mg.rs b/hash-core/src/mg.rs index c40a4a2..8328549 100644 --- a/hash-core/src/mg.rs +++ b/hash-core/src/mg.rs @@ -4,7 +4,7 @@ use hash_bootstrap::{BitBoard, Color, Square}; use crate::{ board::Board, index, - repr::{Move, PieceKind}, + repr::{ChessMove, PieceKind}, }; /// The maximum number of moves stored by [`Moves`]. This shouldn't be relevant for most @@ -12,7 +12,7 @@ use crate::{ pub const MOVES: usize = 218; /// An array of moves that is the output of move generation ([`mg::gen_moves`]). -pub type Moves = ArrayVec; +pub type Moves = ArrayVec; trait CheckType { const IN_CHECK: bool; @@ -68,7 +68,7 @@ trait Gen { (Self::pseudo_legal_moves(piece, board.us.occupation, occupation, board.playing_color) & valid_targets) .bits() - .map(move |target| Move { + .map(move |target| ChessMove { origin: piece, target, promotion: None, @@ -84,7 +84,7 @@ trait Gen { board.playing_color, ) & index::line_fit(king_square, piece)) .bits() - .map(move |target| Move { + .map(move |target| ChessMove { origin: piece, target, promotion: None, @@ -173,7 +173,7 @@ impl Gen for Pawn { ) & valid_targets) - BitBoard::EDGE_RANKS) .bits() - .map(move |target| Move { + .map(move |target| ChessMove { origin: piece, target, promotion: None, @@ -187,11 +187,13 @@ impl Gen for Pawn { & BitBoard::EDGE_RANKS) .bits() .flat_map(move |target| { - PieceKind::PROMOTIONS.into_iter().map(move |kind| Move { - origin: piece, - target, - promotion: Some(kind), - }) + PieceKind::PROMOTIONS + .into_iter() + .map(move |kind| ChessMove { + origin: piece, + target, + promotion: Some(kind), + }) }) })); @@ -205,7 +207,7 @@ impl Gen for Pawn { ) & index::line_fit(king_square, piece)) - BitBoard::EDGE_RANKS) .bits() - .map(move |target| Move { + .map(move |target| ChessMove { origin: piece, target, promotion: None, @@ -223,11 +225,13 @@ impl Gen for Pawn { & BitBoard::EDGE_RANKS) .bits() .flat_map(move |target| { - PieceKind::PROMOTIONS.into_iter().map(move |kind| Move { - origin: piece, - target, - promotion: Some(kind), - }) + PieceKind::PROMOTIONS + .into_iter() + .map(move |kind| ChessMove { + origin: piece, + target, + promotion: Some(kind), + }) }) })); } @@ -245,7 +249,7 @@ impl Gen for Pawn { origin, ) { - moves.push(Move { + moves.push(ChessMove { origin, target: en_passant_capture_square, promotion: None, @@ -300,7 +304,7 @@ impl Gen for Knight { (Self::pseudo_legal_moves(piece, board.us.occupation, occupation, board.playing_color) & valid_targets) .bits() - .map(move |target| Move { + .map(move |target| ChessMove { origin: piece, target, promotion: None, @@ -382,7 +386,7 @@ impl Gen for King { ) .bits() .filter(|square| !board.is_attacked(*square)) - .map(|target| Move { + .map(|target| ChessMove { origin: king_square, target, promotion: None, @@ -399,7 +403,7 @@ impl Gen for King { .bits() .all(|square| !board.is_attacked(square))) { - moves.push(Move { + moves.push(ChessMove { origin: king_square, target: match board.playing_color { Color::White => Square::G1, @@ -417,7 +421,7 @@ impl Gen for King { .bits() .all(|square| !board.is_attacked(square))) { - moves.push(Move { + moves.push(ChessMove { origin: king_square, target: match board.playing_color { Color::White => Square::C1, diff --git a/hash-core/src/repr.rs b/hash-core/src/repr.rs index abf30ea..807ea28 100644 --- a/hash-core/src/repr.rs +++ b/hash-core/src/repr.rs @@ -149,13 +149,13 @@ impl Display for Piece { #[derive(PartialEq, Eq, Clone, Copy)] /// Represents a move in the game of Chess. To create a move one can use [`Board::interpret_move`]. -pub struct Move { +pub struct ChessMove { pub origin: Square, pub target: Square, pub promotion: Option, } -impl Display for Move { +impl Display for ChessMove { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.origin.fmt(f)?; self.target.fmt(f)?; @@ -168,7 +168,7 @@ impl Display for Move { } } -impl FromStr for Move { +impl FromStr for ChessMove { type Err = &'static str; fn from_str(s: &str) -> Result { @@ -186,7 +186,7 @@ impl FromStr for Move { None }; - Ok(Move { + Ok(ChessMove { origin, target, promotion, diff --git a/hash-engine/Cargo.toml b/hash-engine/Cargo.toml index 3a03bf9..c191530 100644 --- a/hash-engine/Cargo.toml +++ b/hash-engine/Cargo.toml @@ -10,7 +10,7 @@ edition.workspace = true [dependencies] hash-bootstrap = { path = "../hash-bootstrap" } hash-core = { path = "../hash-core" } -hash-search = { path = "../hash-search" } +hash-search = { path = "../hash-search/" } [lints] workspace = true diff --git a/hash-engine/src/engine.rs b/hash-engine/src/engine.rs new file mode 100644 index 0000000..54ccba1 --- /dev/null +++ b/hash-engine/src/engine.rs @@ -0,0 +1,164 @@ +use std::{ + io::{BufRead, StdinLock}, + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; + +use hash_core::{board::Board, repr::ChessMove}; +use hash_search::tree::{Child, Tree}; + +const UPDATE_CAPACITY: usize = 60; + +struct Timings { + time_left: Duration, + increment: Duration, + opponent_time_left: Duration, + opponent_increment: Duration, +} + +impl FromStr for Timings { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let parts = s + .split(' ') + .map(|time| time.parse::().map(Duration::from_nanos)) + .try_collect::>() + .map_err(|_| "Could not parse time integer")?; + + if parts.len() != 4 { + Err("Timing information must consist of 4 space-separated parts") + } else { + Ok(Self { + time_left: parts[0], + increment: parts[1], + opponent_time_left: parts[2], + opponent_increment: parts[3], + }) + } + } +} + +struct InitialUpdate { + timings: Timings, + board: Board, +} + +struct RegularUpdate { + time_left: Duration, + opponent_time_left: Duration, + played_move: ChessMove, +} + +enum Update { + Initial(InitialUpdate), + Regular(RegularUpdate), +} + +pub struct Engine<'a> { + tree: Tree, + stdin_handle: StdinLock<'a>, + timings: Timings, +} + +impl<'a> Engine<'a> { + pub fn new(mut stdin_handle: StdinLock<'a>) -> Self { + let mut update = String::with_capacity(UPDATE_CAPACITY); + stdin_handle + .read_line(&mut update) + .expect("Failed to read update"); + update.pop(); // Remove trailing newline + let update_parts = update.split(' ').collect::>(); + + Self { + tree: Tree::new( + Board::from_str(&update_parts[4..].join(" ")).expect("Update FEN is invalid"), + ), + stdin_handle, + time_left: Duration::from_nanos(update_parts[0].parse::().unwrap()), + increment: Duration::from_nanos(update_parts[1].parse::().unwrap()), + opponent_time_left: Duration::from_nanos(update_parts[2].parse::().unwrap()), + opponent_increment: Duration::from_nanos(update_parts[3].parse::().unwrap()), + } + } + + fn start_search(tree: Tree) -> impl FnOnce() -> thread::Result { + let stop_search = Arc::new(AtomicBool::new(false)); + + let stop_search_clone = stop_search.clone(); + let join_handle = thread::spawn(move || hash_search::search(tree, stop_search_clone)); + + move || { + stop_search.store(true, Ordering::Relaxed); + join_handle.join() + } + } + + fn search(mut self, time: Duration) -> Self { + let stop_search = Self::start_search(self.tree); + + // It's fine to block command handling because the SCCS spec doesn't allow for commands to + // arrive while making a move + thread::sleep(time); + + self.tree = stop_search().expect("Could not join search thread"); + + self + } + + fn advance_tree(mut self, advancing_move: ChessMove) -> Self { + self.tree = self + .tree + .children() + .unwrap() + .into_iter() + .find(|Child { chess_move, .. }| *chess_move == advancing_move) + .unwrap() + .tree; + self + } + + // When pondering, instead of having a fixed time-frame, we want to naturally stop as soon as + // the opponent has made their move. This means that while the search thread runs, we must + // check for updates as to wether the opponent made their move + fn ponder(mut self) -> Self { + let stop_search = Self::start_search(self.tree); + + let mut update = String::with_capacity(UPDATE_CAPACITY); + self.stdin_handle + .read_line(&mut update) + .expect("Failed to read update"); + update.pop(); // Remove trailing newline + let update_parts = update.split(' ').collect::>(); + + self.time_left = Duration::from_nanos(update_parts[0].parse::().unwrap()); + self.opponent_time_left = Duration::from_nanos(update_parts[1].parse::().unwrap()); + + let played_move = + ChessMove::from_str(update_parts[2]).expect("Received update move is invalid"); + + self.tree = stop_search().expect("Couldn't join searching thread"); + self = self.advance_tree(played_move); + + self + } + + fn send_move(self) -> Self { + let best_move = self.tree.best_move(); + println!("{}", best_move); + self.advance_tree(best_move) + } + + pub fn run(mut self) { + loop { + self = self.search(Duration::from_secs(5)); + self = self.send_move(); + self = self.ponder(); + } + } +} diff --git a/hash-engine/src/lib.rs b/hash-engine/src/lib.rs new file mode 100644 index 0000000..68ce805 --- /dev/null +++ b/hash-engine/src/lib.rs @@ -0,0 +1,3 @@ +#![feature(iterator_try_collect)] +mod engine; +pub use engine::Engine; diff --git a/hash-engine/src/main.rs b/hash-engine/src/main.rs index e784b03..695d1d4 100644 --- a/hash-engine/src/main.rs +++ b/hash-engine/src/main.rs @@ -1,8 +1,7 @@ -use hash_core::board::Board; -use hash_search::search; +use std::io; -fn main() { - let board = Board::starting_position(); +use hash_engine::Engine; - println!("{}", search::search(board)); +fn main() { + Engine::new(io::stdin().lock()).run(); } diff --git a/hash-network/Cargo.toml b/hash-network/Cargo.toml index b2153d7..5a8da08 100644 --- a/hash-network/Cargo.toml +++ b/hash-network/Cargo.toml @@ -9,7 +9,7 @@ edition.workspace = true [dependencies] hash-bootstrap = { path = "../hash-bootstrap" } hash-core = { path = "../hash-core" } -burn = "0.9.0" +burn = "0.11.1" serde = "1.0.188" [lints] diff --git a/hash-network/src/lib.rs b/hash-network/src/lib.rs index e992b4a..9a514a4 100644 --- a/hash-network/src/lib.rs +++ b/hash-network/src/lib.rs @@ -5,32 +5,23 @@ use hash_core::{board::Board, repr::Player}; pub mod model; -pub fn stack( - tensors: Vec>, -) -> Tensor { - Tensor::cat( - tensors - .into_iter() - .map(|tensor| tensor.unsqueeze()) - .collect(), - 0, - ) -} - fn bitboard_to_tensor(bitboard: BitBoard) -> Tensor { Tensor::from_floats((Square::ALL).map(|square| f32::from(bitboard.get_bit(square)))) .reshape(Shape::new([8, 8])) } fn player_to_tensor(player: &Player) -> Tensor { - stack(vec![ - bitboard_to_tensor(player.pawns), - bitboard_to_tensor(player.knights), - bitboard_to_tensor(player.bishops), - bitboard_to_tensor(player.rooks), - bitboard_to_tensor(player.queens), - bitboard_to_tensor(player.king), - ]) + Tensor::stack( + vec![ + bitboard_to_tensor(player.pawns), + bitboard_to_tensor(player.knights), + bitboard_to_tensor(player.bishops), + bitboard_to_tensor(player.rooks), + bitboard_to_tensor(player.queens), + bitboard_to_tensor(player.king), + ], + 0, + ) } fn boolean_to_tensor(boolean: bool) -> Tensor { @@ -71,11 +62,12 @@ pub fn board_to_tensor(board: Option<&Board>) -> Tensor { // TODO: It might be the best to just fill the rest with zeroes on the tensor level, instead of // requiring one to pass a bunch of zeros pub fn boards_to_tensor(boards: Vec>) -> Tensor { - stack( + Tensor::cat( boards .iter() .copied() .map(|board| board_to_tensor(board)) .collect(), + 0, ) } diff --git a/hash-search/Cargo.toml b/hash-search/Cargo.toml index 2224f74..06f662b 100644 --- a/hash-search/Cargo.toml +++ b/hash-search/Cargo.toml @@ -15,7 +15,8 @@ hash-network = { path = "../hash-network" } arrayvec = "0.7.4" num-traits = "0.2.17" rand = { version = "0.8.5", features = ["min_const_gen"] } -burn = { version = "0.9.0", features = ["ndarray-blas-openblas-system"] } +burn = "0.11.1" +burn-wgpu = "0.11.1" serde = "1.0.189" [lints] diff --git a/hash-search/src/lib.rs b/hash-search/src/lib.rs index 3a67aa0..2346f54 100644 --- a/hash-search/src/lib.rs +++ b/hash-search/src/lib.rs @@ -2,5 +2,7 @@ pub mod network; pub mod puct; -pub mod search; -pub mod tree; \ No newline at end of file +mod search; +pub mod tree; + +pub use search::search; diff --git a/hash-search/src/network.rs b/hash-search/src/network.rs index 067393b..5b0440d 100644 --- a/hash-search/src/network.rs +++ b/hash-search/src/network.rs @@ -2,7 +2,7 @@ use burn::tensor::{backend::Backend, Tensor}; use hash_bootstrap::Square; use hash_core::{ board::Board, - repr::{Move, PieceKind}, + repr::{ChessMove, PieceKind}, }; use hash_network::model::{BatchOutput, Model}; use num_traits::ToPrimitive; @@ -22,7 +22,7 @@ impl MoveProbabilities { const ARRAY_LENGTH: usize = Self::REGULAR_MOVE_SECTION_LENGTH + 2 * Self::SINGLE_RANK_PROMOTION_SECTION_LENGTH; - pub fn new(probability_iter: impl Iterator) -> Self { + pub fn new(probability_iter: impl Iterator) -> Self { let mut move_probabilities = Self { probabilities: [0.0; Self::ARRAY_LENGTH], }; @@ -34,7 +34,7 @@ impl MoveProbabilities { move_probabilities } - fn move_to_index(chess_move: Move) -> usize { + fn move_to_index(chess_move: ChessMove) -> usize { if let Some(piece_kind) = chess_move.promotion { let promotion_number: usize = match piece_kind { PieceKind::Queen => 0, @@ -58,11 +58,11 @@ impl MoveProbabilities { // This function defines a one-to-one mapping between numbers from 0 to 4609 (non-inclusive) to // Chess moves, and back. - pub fn get_probability(&self, chess_move: Move) -> f32 { + pub fn get_probability(&self, chess_move: ChessMove) -> f32 { self.probabilities[Self::move_to_index(chess_move)] } - pub fn set_probability(&mut self, chess_move: Move, probability: f32) { + pub fn set_probability(&mut self, chess_move: ChessMove, probability: f32) { self.probabilities[Self::move_to_index(chess_move)] = probability; } } diff --git a/hash-search/src/puct.rs b/hash-search/src/puct.rs index c5859dc..73c39ee 100644 --- a/hash-search/src/puct.rs +++ b/hash-search/src/puct.rs @@ -16,13 +16,9 @@ impl PuctSelector { } impl Selector for PuctSelector { - fn choose_child<'a>(&mut self, tree: &'a Tree) -> Option<&'a Tree> { - tree.children_ref() - .and_then(|children| { - children - .iter() - .max_by(|child_a, child_b| self.puct(child_a).total_cmp(&self.puct(child_b))) - }) + fn choose_child<'a>(&mut self, children: impl Iterator) -> Option<&'a Tree> { + children + .max_by(|child_a, child_b| self.puct(child_a).total_cmp(&self.puct(child_b))) .map(|child| &child.tree) } } diff --git a/hash-search/src/search.rs b/hash-search/src/search.rs index 3850f5b..8c6ddc1 100644 --- a/hash-search/src/search.rs +++ b/hash-search/src/search.rs @@ -1,21 +1,24 @@ use crate::{puct::PuctSelector, tree::Tree}; -use burn::backend::NdArrayBackend; -use hash_core::{board::Board, repr::Move}; +use burn_wgpu::Wgpu; use hash_network::model::ModelConfig; -const EXPANSIONS: usize = 100; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + const EXPLORATION_RATE: f32 = 4.0; -pub fn search(board: Board) -> Move { +/// TODO: Add configuration options to here, such as the level of exploration that is desired +/// (exploration rate). This will be useful for, for example, performing a more explorative search +/// when pondering, which should account for uncertainty. +pub fn search(mut tree: Tree, stop_search: Arc) -> Tree { let mut selector = PuctSelector::new(EXPLORATION_RATE); - let network = ModelConfig::new().init::>(); - - let mut tree = Tree::new(board); + let network = ModelConfig::new().init::(); - for expansion in 0..EXPANSIONS { + while !stop_search.load(Ordering::Relaxed) { tree.expand(&mut selector, &network); - println!("FINISHED EXPANSION {expansion}"); } - tree.best_move() + tree } diff --git a/hash-search/src/tree.rs b/hash-search/src/tree.rs index 4bd4584..e66cca2 100644 --- a/hash-search/src/tree.rs +++ b/hash-search/src/tree.rs @@ -1,15 +1,15 @@ use crate::network::{Network, NetworkResult}; -use hash_core::{board::Board, mg, repr::Move}; +use hash_core::{board::Board, mg, repr::ChessMove}; use std::{cell::Cell, ops::Deref}; pub struct Child { pub tree: Tree, pub probability: f32, - pub chess_move: Move, + pub chess_move: ChessMove, } impl Child { - pub fn new(board: Board, probability: f32, chess_move: Move) -> Self { + pub fn new(board: Board, probability: f32, chess_move: ChessMove) -> Self { Self { tree: Tree::new(board), probability, @@ -22,11 +22,11 @@ pub struct Tree { pub board: Board, value_sum: Cell, visits: Cell, - children: Cell>>, + children: Cell>>, } pub trait Selector { - fn choose_child<'a>(&mut self, tree: &'a Tree) -> Option<&'a Tree>; + fn choose_child<'a>(&mut self, children: impl Iterator) -> Option<&'a Tree>; } impl Tree { @@ -39,7 +39,7 @@ impl Tree { } } - pub fn best_move(&self) -> Move { + pub fn best_move(&self) -> ChessMove { self.children_ref() .unwrap() .iter() @@ -57,7 +57,7 @@ impl Tree { self.visits.get() } - pub fn children(self) -> Option> { + pub fn children(self) -> Option> { self.children.into_inner() } @@ -68,7 +68,7 @@ impl Tree { .map(|child| child.as_ref()) } - fn expanded(&self) -> bool { + fn is_expanded(&self) -> bool { self.children_ref().is_some() } @@ -78,11 +78,20 @@ impl Tree { let node_to_expand = loop { let current_node = node_progression.last().unwrap(); - if !current_node.expanded() { + if !current_node.is_expanded() { break *current_node; } - node_progression.push(selector.choose_child(current_node).unwrap()); + node_progression.push( + selector + .choose_child(current_node.children_ref().unwrap().iter().filter(|child| { + child + .tree + .children_ref() + .map_or(true, |tree| !tree.is_empty()) + })) + .unwrap(), + ); }; let NetworkResult { diff --git a/hash-train/Cargo.toml b/hash-train/Cargo.toml index ec58b6f..167a152 100644 --- a/hash-train/Cargo.toml +++ b/hash-train/Cargo.toml @@ -12,8 +12,8 @@ hash-core = { path = "../hash-core" } hash-bootstrap = { path = "../hash-bootstrap" } hash-search = { path = "../hash-search" } hash-network = { path = "../hash-network" } - -burn = { version = "0.9.0", features = ["train", "tch"] } +burn = { version = "0.11.1", features = ["train"] } +burn-wgpu = "0.11.1" serde = "1.0.188" ringbuffer = "0.15.0" arrayvec = "0.7.4" diff --git a/hash-train/src/lib.rs b/hash-train/src/lib.rs index ac28c19..af2d75b 100644 --- a/hash-train/src/lib.rs +++ b/hash-train/src/lib.rs @@ -4,6 +4,6 @@ use ringbuffer::ConstGenericRingBuffer; mod play; pub mod train; -pub const TRAIN_BUFFER_CAPACITY: usize = 1 << 16; +pub const TRAIN_BUFFER_CAPACITY: usize = 1 << 6; pub type TrainBuffer = ConstGenericRingBuffer, TRAIN_BUFFER_CAPACITY>; diff --git a/hash-train/src/main.rs b/hash-train/src/main.rs index f8ddbb9..83e771a 100644 --- a/hash-train/src/main.rs +++ b/hash-train/src/main.rs @@ -1,5 +1,6 @@ -use burn::{autodiff::ADBackendDecorator, backend::TchBackend}; +use burn::backend::Autodiff; +use burn_wgpu::Wgpu; fn main() { - hash_train::train::run::>>(); + hash_train::train::run::>(); } diff --git a/hash-train/src/play.rs b/hash-train/src/play.rs index 5732c14..fda2ef3 100644 --- a/hash-train/src/play.rs +++ b/hash-train/src/play.rs @@ -66,7 +66,7 @@ pub fn gen_game( let child_index = rng .sample(WeightedIndex::new(children.iter().map(|child| child.tree.visits())).unwrap()); - let child = children.into_vec().into_iter().nth(child_index).unwrap(); + let child = children.into_iter().nth(child_index).unwrap(); // SAFETY: Children are generated using the standard move generator unsafe { diff --git a/hash-train/src/train.rs b/hash-train/src/train.rs index 8e48810..b86afb4 100644 --- a/hash-train/src/train.rs +++ b/hash-train/src/train.rs @@ -5,7 +5,7 @@ use burn::{ decay::WeightDecayConfig, momentum::MomentumConfig, GradientsParams, Optimizer, SgdConfig, }, tensor::{ - backend::{ADBackend, Backend}, + backend::{AutodiffBackend, Backend}, Shape, Tensor, }, }; @@ -69,7 +69,7 @@ fn loss( loss_per_item.mean() } -pub fn run() { +pub fn run() { let epochs = 1000; let ply_cap = 80; let mut games_per_iteration = 8; @@ -114,10 +114,10 @@ pub fn run() { }) .unzip(); - let batch = hash_network::stack(batch); + let batch = Tensor::stack(batch, 0); let (expected_values, expected_probabilities) = - decouple_output(hash_network::stack(expected_outputs)); + decouple_output(Tensor::stack(expected_outputs, 0)); let BatchOutput { values, probabilities, diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8cc7760..4a66ed5 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "nightly-2023-09-24" +channel = "nightly-2023-12-12"