Compare commits

...

7 Commits

Author SHA1 Message Date
093dc51eed
tune: make only three repetitions (not two) evaluate as draw
this commit might fix some positions being evaluated as zero
2024-12-25 20:08:32 -05:00
e06d614885
tune: only push history for real moves 2024-12-25 17:31:32 -05:00
955c9f1f5e
fix: do not play a nullmove in a drawn position 2024-12-25 16:42:58 -05:00
cac74a46cd
tune: reduce board history size
seemingly avoiding repetition performs worse as of last commit.
2024-12-25 16:15:37 -05:00
db365c124e
feat: detect draw by repetition 2024-12-25 15:05:41 -05:00
a6a7b33553
docs: update readme feature list 2024-12-25 12:22:25 -05:00
8137cbc2fe
tune: increase max depths for search 2024-12-25 12:06:37 -05:00
4 changed files with 274 additions and 7 deletions

View File

@ -9,8 +9,9 @@ Features:
- Tapered midgame-endgame evaluation
- UCI compatibility
- Iterative deepening
- Time management
- Transposition table (Zobrist hashing)
- Currently only stores best move.
- Quiescence search
## instructions

View File

@ -449,6 +449,127 @@ impl Display for CastleRights {
}
}
/// Ring-buffer pointer that will never point outside the buffer.
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
struct RingPtr<const N: usize>(usize);
impl<const N: usize> From<RingPtr<N>> for usize {
fn from(value: RingPtr<N>) -> Self {
debug_assert!((0..N).contains(&value.0));
value.0
}
}
impl<const N: usize> std::ops::Add<usize> for RingPtr<N> {
type Output = Self;
fn add(self, rhs: usize) -> Self::Output {
Self((self.0 + rhs) % N)
}
}
impl<const N: usize> std::ops::Sub<usize> for RingPtr<N> {
type Output = Self;
fn sub(self, rhs: usize) -> Self::Output {
Self((self.0 + N - rhs) % N)
}
}
impl<const N: usize> std::ops::AddAssign<usize> for RingPtr<N> {
fn add_assign(&mut self, rhs: usize) {
self.0 = (self.0 + rhs) % N;
}
}
impl<const N: usize> std::ops::SubAssign<usize> for RingPtr<N> {
fn sub_assign(&mut self, rhs: usize) {
self.0 = (self.0 + N - rhs) % N;
}
}
impl<const N: usize> Default for RingPtr<N> {
fn default() -> Self {
Self(0)
}
}
impl<const N: usize> RingPtr<N> {}
#[cfg(test)]
mod ringptr_tests {
use super::*;
/// ring buffer pointer behaviour
#[test]
fn test_ringptr() {
let ptr_start: RingPtr<3> = RingPtr::default();
let ptr: RingPtr<3> = RingPtr::default() + 3;
assert_eq!(ptr, ptr_start);
let ptr2: RingPtr<3> = RingPtr::default() + 2;
assert_eq!(ptr2, ptr_start - 1);
assert_eq!(ptr2, ptr_start + 2);
}
}
/// Ring-buffer of previously seen hashes, used to avoid draw by repetition.
///
/// Only stores at most `HISTORY_SIZE` plies, since most cases of repetition happen recently.
/// Technically, it should be 100 plies because of the 50-move rule.
#[derive(Default, Clone, Copy, Debug)]
struct BoardHistory {
hashes: [Zobrist; HISTORY_SIZE],
/// Index of the start of the history in the buffer
ptr_start: RingPtr<HISTORY_SIZE>,
/// Index one-past-the-end of the history in the buffer
ptr_end: RingPtr<HISTORY_SIZE>,
}
impl PartialEq for BoardHistory {
/// Always equal, since comparing two boards with different histories shouldn't matter.
fn eq(&self, _other: &Self) -> bool {
true
}
}
impl Eq for BoardHistory {}
/// Size in plies of the board history.
///
/// Actual capacity is one less than this.
const HISTORY_SIZE: usize = 10;
impl BoardHistory {
/// Counts occurences of this hash in the history.
fn count(&self, hash: Zobrist) -> usize {
let mut ans = 0;
let mut i = self.ptr_start;
while i != self.ptr_end {
if self.hashes[usize::from(i)] == hash {
ans += 1;
}
i += 1;
}
ans
}
/// Add (push) hash to history.
fn push(&mut self, hash: Zobrist) {
self.hashes[usize::from(self.ptr_end)] = hash;
self.ptr_end += 1;
// replace old entries
if self.ptr_end == self.ptr_start {
self.ptr_start += 1;
}
}
}
/// Game state, describes a position.
///
/// Default is empty.
@ -485,6 +606,9 @@ pub struct Board {
/// Last captured square
recap_sq: Option<Square>,
/// History of recent hashes to avoid repetition draws.
history: BoardHistory,
}
impl Board {
@ -493,6 +617,11 @@ impl Board {
Board::from_fen(START_POSITION).unwrap()
}
/// Save the current position's hash in the history.
pub fn push_history(&mut self) {
self.history.push(self.zobrist);
}
/// Get iterator over all squares.
pub fn squares() -> impl Iterator<Item = Square> {
(0..N_SQUARES).map(Square::try_from).map(|x| x.unwrap())
@ -564,13 +693,11 @@ impl Board {
turn: self.turn.flip(),
half_moves: self.half_moves,
full_moves: self.full_moves,
players: Default::default(),
mail: Default::default(),
ep_square: self.ep_square.map(|sq| sq.mirror_vert()),
castle: CastleRights(self.castle.0),
eval: Default::default(),
zobrist: Zobrist::default(),
recap_sq: self.recap_sq.map(|sq| sq.mirror_vert()),
..Default::default()
};
new_board.castle.0.reverse();
@ -672,6 +799,7 @@ mod tests {
use super::*;
use fen::FromFen;
use movegen::{FromUCIAlgebraic, Move, ToUCIAlgebraic};
#[test]
fn test_square_casts() {
@ -799,4 +927,131 @@ mod tests {
);
}
}
#[test]
fn test_history() {
let board = Board::starting_pos();
let mut history = BoardHistory::default();
for _ in 0..(HISTORY_SIZE + 15) {
history.push(board.zobrist);
}
assert_eq!(history.count(board.zobrist), HISTORY_SIZE - 1);
let board_empty = Board::default();
history.push(board_empty.zobrist);
assert_eq!(history.count(board.zobrist), HISTORY_SIZE - 2);
assert_eq!(history.count(board_empty.zobrist), 1);
for _ in 0..3 {
history.push(board_empty.zobrist);
}
assert_eq!(history.count(board_empty.zobrist), 4);
assert_eq!(history.count(board.zobrist), HISTORY_SIZE - 5);
}
#[test]
fn repetition_detection() {
let mut board = Board::starting_pos();
let mvs = "b1c3 b8c6 c3b1 c6b8"
.split_whitespace()
.map(Move::from_uci_algebraic)
.map(|x| x.unwrap())
.collect::<Vec<_>>();
for _ in 0..2 {
for mv in &mvs {
board.push_history();
let _ = mv.make(&mut board);
}
}
// this is the third occurence, but beforehand there are two occurences
assert_eq!(board.history.count(board.zobrist), 2);
}
/// engine should take advantage of the three time repetition rule
#[test]
fn find_repetition() {
use eval::EvalInt;
use search::{best_line, EngineState, SearchConfig, TranspositionTable};
let mut board = Board::from_fen("qqqp4/pkpp4/8/8/8/8/8/K7 b - - 0 1").unwrap();
let mvs = "b7b6 a1a2 b6b7 a2a1 b7b6 a1a2 b6b7"
.split_whitespace()
.map(Move::from_uci_algebraic)
.map(|x| x.unwrap())
.collect::<Vec<_>>();
let expected_bestmv = Move::from_uci_algebraic("a2a1").unwrap();
let mut cnt = 0;
for mv in &mvs {
board.push_history();
cnt += 1;
let _ = mv.make(&mut board);
}
eprintln!("board is: '{}'", board.to_fen());
eprintln!("added {} history entries", cnt);
let (_tx, rx) = std::sync::mpsc::channel();
let cache = TranspositionTable::new(1);
let mut engine_state = EngineState::new(
SearchConfig {
depth: 1,
qdepth: 0,
enable_trans_table: false,
..Default::default()
},
rx,
cache,
search::TimeLimits::default(),
);
let (line, eval) = best_line(&mut board, &mut engine_state);
let best_mv = line.last().unwrap();
expected_bestmv.make(&mut board);
eprintln!(
"after expected mv, board repeated {} times",
board.history.count(board.zobrist)
);
assert_eq!(
*best_mv,
expected_bestmv,
"got {} (eval {:?}) instead of {}",
best_mv.to_uci_algebraic(),
eval,
expected_bestmv.to_uci_algebraic()
);
assert!(EvalInt::from(eval) == 0);
// now ensure that it's completely one-sided without the repetition opportunity
let mut board = Board::from_fen("qqqp4/pkpp4/8/8/8/8/8/K7 w - - 0 1").unwrap();
let (_tx, rx) = std::sync::mpsc::channel();
let cache = TranspositionTable::new(1);
let mut engine_state = EngineState::new(
SearchConfig {
depth: 1,
qdepth: 0,
enable_trans_table: false,
..Default::default()
},
rx,
cache,
search::TimeLimits::default(),
);
let (_line, eval) = best_line(&mut board, &mut engine_state);
assert!(EvalInt::from(eval) < 0);
}
}

View File

@ -63,6 +63,7 @@ fn cmd_position_moves(mut tokens: std::str::SplitWhitespace<'_>, mut board: Boar
"moves" => {
for mv in tokens.by_ref() {
let mv = Move::from_uci_algebraic(mv).unwrap();
board.push_history();
let _ = mv.make(&mut board);
}
}

View File

@ -122,8 +122,8 @@ impl Default for SearchConfig {
fn default() -> Self {
SearchConfig {
alpha_beta_on: true,
depth: 10,
qdepth: 4,
depth: 16,
qdepth: 6,
enable_trans_table: true,
transposition_size: 24,
}
@ -208,12 +208,18 @@ fn minmax(board: &mut Board, state: &mut EngineState, mm: MinmaxState) -> (Vec<M
}
}
let is_repetition_draw = board.history.count(board.zobrist) >= 2;
// quiescence stand-pat score (only calculated if needed).
// this is where static eval goes.
let mut board_eval: Option<EvalInt> = None;
if mm.quiesce {
board_eval = Some(board.eval() * EvalInt::from(board.turn.sign()));
board_eval = Some(if is_repetition_draw {
0
} else {
board.eval() * EvalInt::from(board.turn.sign())
});
}
if mm.depth == 0 {
@ -341,6 +347,10 @@ fn minmax(board: &mut Board, state: &mut EngineState, mm: MinmaxState) -> (Vec<M
}
}
if is_repetition_draw {
abs_best = SearchEval::Exact(0);
}
if let Some(best_move) = best_move {
best_continuation.push(best_move);
if state.config.enable_trans_table {