Compare commits

...

2 Commits

Author SHA1 Message Date
1d651de4a0
feat: scripts for automated chess tournaments and analysis 2024-12-21 23:55:58 -05:00
4ee30bd278
feat: recapture extension
"quiescence" search
2024-12-20 22:50:35 -05:00
4 changed files with 229 additions and 47 deletions

82
contrib/fast-chess-branch.sh Executable file
View File

@ -0,0 +1,82 @@
#!/bin/sh
# Runs a fast-chess (https://github.com/Disservin/fastchess) tournament based
# on two branches of the chess_inator
# (https://github.com/dogeystamp/chess_inator) engine.
#
# Example usage:
#
# cd chess_tournaments
# fast-chess-branch.sh quiescence no-quiescence -openings file=8moves_v3.pgn format=pgn order=random -each tc=300+0.1 -rounds 12 -repeat -concurrency 8 -recover -sprt elo0=0 elo1=10 alpha=0.05 beta=0.05
#
# Do not use `main` as a branch, or any other branch already checked out in
# another directory. You need to be in a chess_inator Git repository to run
# this script. Ensure that the repository you're in is a throw-away worktree.
# Create one using
#
# git worktree add ../chess_tournaments
#
# inside the original chess_inator repo.
# Also, get an opening book from Stockfish's books:
#
# curl -O https://github.com/official-stockfish/books/raw/refs/heads/master/8moves_v3.pgn.zip
#
# The sprt mode is a statistical hypothesis testing mode that will tell you how
# probably the first branch is better than the second branch. The Elo ratings
# given are the "indifference zone" where the result is acceptable. To check
# that the engine hasn't had a regression, set them to [-10, 0]. To check for
# an improvement, use [0, 10]. Alpha and beta are probabilities for statistical
# errors. The tournament automatically ends when a statistically significant
# result is obtained.
#
# By default, a PGN file will be exported with the games played, and the
# fast-chess SPRT output will be appended. This comment may interfere with
# importing the PGN. But Lichess will ignore it, so it's probably fine.
set -e
BRANCH1="$1"
BRANCH2="$2"
# if this line fails it's because you don't have enough arguments
shift 2
COMM1=$(git rev-parse --short "$BRANCH1")
COMM2=$(git rev-parse --short "$BRANCH2")
mkdir -p games
PGN=games/"$BRANCH1"__"$BRANCH2".pgn
rm -f engine1 engine2
if [ -f "$PGN" ]; then
rm -i "$PGN"
fi
git switch "$BRANCH1"
cargo build --release
cp target/release/chess_inator engine1
git switch "$BRANCH2"
cargo build --release
cp target/release/chess_inator engine2
OUTPUT=$(mktemp)
fastchess \
-engine cmd=engine1 name="c_i $BRANCH1 ($COMM1)" \
-engine cmd=engine2 name="c_i $BRANCH2 ($COMM2)" \
-pgnout file="$PGN" \
timeleft=true \
$@ \
2>&1 | tee -a "$OUTPUT"
printf "\n{" >> "$PGN"
# match between ------- markers in fastchess output, strip newline and then output to PGN
awk '/-{50}/{f+=1; print; next} f%2' "$OUTPUT" \
| head -c -1 \
>> "$PGN"
printf "}" >> "$PGN"
rm "$OUTPUT"

39
contrib/tl_to_clk.py Normal file
View File

@ -0,0 +1,39 @@
# /usr/bin/env python
"""
Converts PGN files from fast-chess's `tl` time left notation to the standard `%clk` clock time notation.
May be buggy; other comments may break this script.
Takes stdin and converts to stdout.
"""
import sys
import re
pgn_value = sys.stdin.read()
def convert(m: re.Match[str]) -> str:
seconds_total = float(m.group(1))
mins, secs = divmod(seconds_total, 60)
hrs, mins = divmod(mins, 60)
secs = round(secs, 4)
mins = round(mins)
hrs = round(hrs)
return f"{{ [%clk {hrs}:{mins:02}:{secs}] }}"
pgn_value = re.sub(
pattern=r"{book}",
repl="",
string=pgn_value,
)
print(
re.sub(
pattern=r"{.*?tl=(.*?)s.*?}",
repl=convert,
string=pgn_value,
)
)

View File

@ -466,6 +466,9 @@ pub struct Board {
/// Hash state to incrementally update.
zobrist: Zobrist,
/// Last captured square
recap_sq: Option<Square>,
}
impl Board {
@ -541,6 +544,7 @@ impl Board {
castle: CastleRights(self.castle.0),
eval: Default::default(),
zobrist: Zobrist::default(),
recap_sq: self.recap_sq.map(|sq| sq.mirror_vert()),
};
new_board.castle.0.reverse();

View File

@ -110,6 +110,8 @@ pub struct SearchConfig {
pub alpha_beta_on: bool,
/// Limit regular search depth
pub depth: usize,
/// Limit quiescence search depth
pub qdepth: usize,
/// Enable transposition table.
pub enable_trans_table: bool,
/// Transposition table size (2^n where this is n)
@ -122,6 +124,7 @@ impl Default for SearchConfig {
alpha_beta_on: true,
// try to make this even to be more conservative and avoid horizon problem
depth: 10,
qdepth: 1,
enable_trans_table: true,
transposition_size: 24,
}
@ -155,26 +158,33 @@ fn move_priority(board: &mut Board, mv: &Move, state: &mut EngineState) -> EvalI
eval
}
/// State specifically for a minmax call.
struct MinmaxState {
/// how many plies left to search in this call
depth: usize,
/// best score (absolute, from current player perspective) guaranteed for current player.
alpha: Option<EvalInt>,
/// best score (absolute, from current player perspective) guaranteed for other player.
beta: Option<EvalInt>,
/// quiescence search flag
quiesce: bool,
}
/// Search the game tree to find the absolute (positive good) move and corresponding eval for the
/// current player.
///
/// This also integrates quiescence search, which looks for a calm (quiescent) position where
/// there are no recaptures.
///
/// # Arguments
///
/// * board: board position to analyze.
/// * depth: how deep to analyze the game tree.
/// * alpha: best score (absolute, from current player perspective) guaranteed for current player.
/// * beta: best score (absolute, from current player perspective) guaranteed for other player.
///
/// # Returns
///
/// The best line (in reverse move order), and its corresponding absolute eval for the current player.
fn minmax(
board: &mut Board,
state: &mut EngineState,
depth: usize,
alpha: Option<EvalInt>,
beta: Option<EvalInt>,
) -> (Vec<Move>, SearchEval) {
fn minmax(board: &mut Board, state: &mut EngineState, mm: MinmaxState) -> (Vec<Move>, SearchEval) {
// these operations are relatively expensive, so only run them occasionally
if state.node_count % (1 << 16) == 0 {
// respect the hard stop if given
@ -199,16 +209,31 @@ fn minmax(
}
}
// default to worst, then gradually improve
let mut alpha = alpha.unwrap_or(EVAL_WORST);
// our best is their worst
let beta = beta.unwrap_or(EVAL_BEST);
if depth == 0 {
let eval = board.eval() * EvalInt::from(board.turn.sign());
return (Vec::new(), SearchEval::Exact(eval));
if mm.depth == 0 {
if mm.quiesce || board.recap_sq.is_none() {
// if we're done with quiescence, static eval.
// if there is no capture, skip straight to static eval.
let eval = board.eval() * EvalInt::from(board.turn.sign());
return (Vec::new(), SearchEval::Exact(eval));
} else {
return minmax(
board,
state,
MinmaxState {
depth: state.config.qdepth,
alpha: mm.alpha,
beta: mm.beta,
quiesce: true,
},
);
}
}
// default to worst, then gradually improve
let mut alpha = mm.alpha.unwrap_or(EVAL_WORST);
// our best is their worst
let beta = mm.beta.unwrap_or(EVAL_BEST);
let mut mvs: Vec<_> = board
.gen_moves()
.into_iter()
@ -220,7 +245,7 @@ fn minmax(
// get transposition table entry
if state.config.enable_trans_table {
if let Some(entry) = &state.cache[board.zobrist] {
if entry.depth >= depth {
if entry.is_qsearch == mm.quiesce && entry.depth >= mm.depth {
if let SearchEval::Exact(_) | SearchEval::Upper(_) = entry.eval {
// no point looking for a better move
return (vec![entry.best_move], entry.eval);
@ -237,18 +262,48 @@ fn minmax(
let mut best_move: Option<Move> = None;
let mut best_continuation: Vec<Move> = Vec::new();
if mvs.is_empty() {
if board.is_check(board.turn) {
let n_non_qmoves = mvs.len();
// determine moves that are allowed in quiescence
if mm.quiesce {
mvs.retain(|(_priority, mv): &(EvalInt, Move)| -> bool {
if let Some(recap_sq) = board.recap_sq {
if mv.dest == recap_sq {
return false;
}
}
false
});
}
if n_non_qmoves == 0 {
let is_in_check = board.is_check(board.turn);
if is_in_check {
return (Vec::new(), SearchEval::Checkmate(-1));
} else {
// stalemate
return (Vec::new(), SearchEval::Exact(0));
}
} else if mvs.is_empty() {
// pruned all the moves due to quiescence
let eval = board.eval() * EvalInt::from(board.turn.sign());
return (Vec::new(), SearchEval::Exact(eval));
}
for (_priority, mv) in mvs {
let anti_mv = mv.make(board);
let (continuation, score) = minmax(board, state, depth - 1, Some(-beta), Some(-alpha));
let (continuation, score) = minmax(
board,
state,
MinmaxState {
depth: mm.depth - 1,
alpha: Some(-beta),
beta: Some(-alpha),
quiesce: mm.quiesce,
},
);
// propagate hard stops
if matches!(score, SearchEval::Stopped) {
@ -283,7 +338,8 @@ fn minmax(
state.cache[board.zobrist] = Some(TranspositionEntry {
best_move,
eval: abs_best,
depth,
depth: mm.depth,
is_qsearch: mm.quiesce,
});
}
}
@ -300,48 +356,49 @@ pub struct TranspositionEntry {
eval: SearchEval,
/// depth of this entry
depth: usize,
/// is this score within the context of quiescence
is_qsearch: bool,
}
pub type TranspositionTable = ZobristTable<TranspositionEntry>;
/// Iteratively deepen search until it is stopped.
fn iter_deep(board: &mut Board, state: &mut EngineState) -> (Vec<Move>, SearchEval) {
// always preserve two lines (1 is most recent)
let (mut line1, mut eval1) = minmax(board, state, 1, None, None);
let (mut line2, mut eval2) = (line1.clone(), eval1);
let (mut prev_line, mut prev_eval) = minmax(
board,
state,
MinmaxState {
depth: 1,
alpha: None,
beta: None,
quiesce: false,
},
);
for depth in 2..=state.config.depth {
let (line, eval) = minmax(board, state, depth, None, None);
let mut have_to_ret = false;
// depth of the line we're about to return.
// our knock-off "quiescence" is skeptical of odd depths, so we need to know this.
let mut ret_depth = depth;
let (line, eval) = minmax(
board,
state,
MinmaxState {
depth,
alpha: None,
beta: None,
quiesce: false,
},
);
if matches!(eval, SearchEval::Stopped) {
ret_depth -= 1;
have_to_ret = true;
return (prev_line, prev_eval);
} else {
(line2, eval2) = (line1, eval1);
(line1, eval1) = (line, eval);
if let Some(soft_lim) = state.time_lims.soft {
if Instant::now() > soft_lim {
have_to_ret = true;
return (line, eval);
}
}
}
if have_to_ret {
if ret_depth & 1 == 1 && (EvalInt::from(eval1) - EvalInt::from(eval2) > 300) {
// be skeptical if we move last and we suddenly earn a lot of
// centipawns. this may be a sign of horizon problem
return (line2, eval2);
} else {
return (line1, eval1);
}
(prev_line, prev_eval) = (line, eval);
}
}
(line1, eval1)
(prev_line, prev_eval)
}
/// Deadlines for the engine to think of a move.