|
|
|
@ -19,6 +19,7 @@ import eval
|
|
|
|
|
import transpositions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import std/math
|
|
|
|
|
import std/times
|
|
|
|
|
import std/atomics
|
|
|
|
|
import std/algorithm
|
|
|
|
@ -26,38 +27,54 @@ import std/monotimes
|
|
|
|
|
import std/strformat
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const NUM_KILLERS = 2
|
|
|
|
|
const NUM_KILLERS* = 2
|
|
|
|
|
const MAX_DEPTH* = 255
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func computeLMRTable: array[MAX_DEPTH, array[218, int]] {.compileTime.} =
|
|
|
|
|
## Precomputes the table containing reduction offsets at compile
|
|
|
|
|
## time
|
|
|
|
|
for i in 1..result.high():
|
|
|
|
|
for j in 1..result[0].high():
|
|
|
|
|
result[i][j] = round(0.8 + ln(i.float) * ln(j.float) * 0.4).int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const LMR_TABLE {.used.} = computeLMRTable()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type
|
|
|
|
|
HistoryTable* = array[PieceColor.White..PieceColor.Black, array[Square(0)..Square(63), array[Square(0)..Square(63), Score]]]
|
|
|
|
|
KillersTable* = seq[array[NUM_KILLERS, Move]]
|
|
|
|
|
KillersTable* = array[MAX_DEPTH, array[NUM_KILLERS, Move]]
|
|
|
|
|
SearchManager* = object
|
|
|
|
|
## A simple state storage
|
|
|
|
|
## for our search
|
|
|
|
|
searchFlag: ptr Atomic[bool]
|
|
|
|
|
stopFlag: ptr Atomic[bool]
|
|
|
|
|
board: Chessboard
|
|
|
|
|
bestMoveRoot: Move
|
|
|
|
|
bestRootScore: Score
|
|
|
|
|
searchStart: MonoTime
|
|
|
|
|
hardLimit: MonoTime
|
|
|
|
|
softLimit: MonoTime
|
|
|
|
|
nodeCount: uint64
|
|
|
|
|
maxNodes: uint64
|
|
|
|
|
currentMove: Move
|
|
|
|
|
currentMoveNumber: int
|
|
|
|
|
searchMoves: seq[Move]
|
|
|
|
|
previousBestMove: Move
|
|
|
|
|
transpositionTable: ptr TTable
|
|
|
|
|
history: ptr HistoryTable
|
|
|
|
|
killers: ptr KillersTable
|
|
|
|
|
# We keep one extra entry so we don't need any special casing
|
|
|
|
|
# inside the search function when constructing pv lines
|
|
|
|
|
pvMoves: array[MAX_DEPTH + 1, array[MAX_DEPTH + 1, Move]]
|
|
|
|
|
selectiveDepth: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc newSearchManager*(board: Chessboard, transpositions: ptr TTable, stopFlag, searchFlag: ptr Atomic[bool],
|
|
|
|
|
history: ptr HistoryTable, killers: ptr KillersTable): SearchManager =
|
|
|
|
|
result = SearchManager(board: board, bestMoveRoot: nullMove(), transpositionTable: transpositions, stopFlag: stopFlag,
|
|
|
|
|
result = SearchManager(board: board, transpositionTable: transpositions, stopFlag: stopFlag,
|
|
|
|
|
searchFlag: searchFlag, history: history, killers: killers)
|
|
|
|
|
for i in 0..MAX_DEPTH:
|
|
|
|
|
for j in 0..MAX_DEPTH:
|
|
|
|
|
result.pvMoves[i][j] = nullMove()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc isSearching*(self: SearchManager): bool =
|
|
|
|
@ -91,14 +108,11 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): Score =
|
|
|
|
|
sideToMove = self.board.position.sideToMove
|
|
|
|
|
nonSideToMove = sideToMove.opposite()
|
|
|
|
|
|
|
|
|
|
if self.previousBestMove != nullMove() and move == self.previousBestMove:
|
|
|
|
|
return highestEval() + 1
|
|
|
|
|
|
|
|
|
|
let query = self.transpositionTable[].get(self.board.position.zobristKey)
|
|
|
|
|
if query.success and query.entry.bestMove != nullMove() and query.entry.bestMove == move:
|
|
|
|
|
return highestEval() - 1
|
|
|
|
|
return highestEval() + 1
|
|
|
|
|
|
|
|
|
|
if self.isKillerMove(move, ply):
|
|
|
|
|
if ply > 0 and self.isKillerMove(move, ply):
|
|
|
|
|
result += self.board.position.getPieceScore(move.startSquare) * 5
|
|
|
|
|
|
|
|
|
|
if not move.isCapture():
|
|
|
|
@ -156,10 +170,14 @@ proc log(self: SearchManager, depth: int) =
|
|
|
|
|
let
|
|
|
|
|
elapsedMsec = self.elapsedTime().uint64
|
|
|
|
|
nps = 1000 * (self.nodeCount div max(elapsedMsec, 1))
|
|
|
|
|
var logMsg = &"info depth {depth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}"
|
|
|
|
|
logMsg &= &" hashfull {self.transpositionTable[].getFillEstimate()}"
|
|
|
|
|
if self.bestMoveRoot != nullMove():
|
|
|
|
|
logMsg &= &" score cp {self.bestRootScore} pv {self.bestMoveRoot.toAlgebraic()}"
|
|
|
|
|
var logMsg = &"info depth {depth} seldepth {self.selectiveDepth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}"
|
|
|
|
|
logMsg &= &" hashfull {self.transpositionTable[].getFillEstimate()} score cp {self.bestRootScore}"
|
|
|
|
|
if self.pvMoves[0][0] != nullMove():
|
|
|
|
|
logMsg &= " pv "
|
|
|
|
|
for move in self.pvMoves[0]:
|
|
|
|
|
if move == nullMove():
|
|
|
|
|
break
|
|
|
|
|
logMsg &= &"{move.toAlgebraic()} "
|
|
|
|
|
echo logMsg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -186,6 +204,23 @@ proc getSearchExtension(self: SearchManager, move: Move): int {.used.} =
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc getReduction(self: SearchManager, move: Move, depth, ply, moveNumber: int, isPV: bool): int =
|
|
|
|
|
## Returns the amount a search depth can be reduced to
|
|
|
|
|
|
|
|
|
|
# Move reduction gains: 54.1 +/- 25.5
|
|
|
|
|
if moveNumber > 3 and depth > 2:
|
|
|
|
|
result = LMR_TABLE[depth][moveNumber]
|
|
|
|
|
if not isPV:
|
|
|
|
|
# Reduce non PV nodes more
|
|
|
|
|
inc(result)
|
|
|
|
|
|
|
|
|
|
if self.board.inCheck():
|
|
|
|
|
# Reduce less when opponent is in check
|
|
|
|
|
dec(result)
|
|
|
|
|
# Keep the reduction in the right range
|
|
|
|
|
result = result.clamp(0, depth - 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
|
|
|
|
|
## Negamax search with a/b pruning that is restricted to
|
|
|
|
|
## capture moves (commonly called quiescent search). The
|
|
|
|
@ -202,7 +237,7 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
|
|
|
|
|
## exist
|
|
|
|
|
if self.shouldStop():
|
|
|
|
|
return
|
|
|
|
|
if ply == 127:
|
|
|
|
|
if ply == MAX_DEPTH:
|
|
|
|
|
return Score(0)
|
|
|
|
|
let score = self.board.position.evaluate()
|
|
|
|
|
if score >= beta:
|
|
|
|
@ -234,7 +269,7 @@ proc qsearch(self: var SearchManager, ply: int, alpha, beta: Score): Score =
|
|
|
|
|
return bestScore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc storeKillerMove(self: SearchManager, ply: int, move: Move) =
|
|
|
|
|
proc storeKillerMove(self: SearchManager, ply: int, move: Move) {.used.} =
|
|
|
|
|
## Stores a killer move into our killers table at the given
|
|
|
|
|
## ply
|
|
|
|
|
|
|
|
|
@ -249,25 +284,21 @@ proc storeKillerMove(self: SearchManager, ply: int, move: Move) =
|
|
|
|
|
# Shift moves one spot down
|
|
|
|
|
self.killers[][ply][j + 1] = self.killers[][ply][j];
|
|
|
|
|
dec(j)
|
|
|
|
|
self.killers[][ply][0] = move;
|
|
|
|
|
self.killers[][ply][0] = move
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc shouldReduce(self: SearchManager, move: Move, depth, moveNumber: int): bool =
|
|
|
|
|
## Returns whether the search should be reduced at the given
|
|
|
|
|
## depth and move number
|
|
|
|
|
return defined(searchLMR) and moveNumber >= 5 and depth > 3 and not move.isCapture()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score {.discardable.} =
|
|
|
|
|
proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV: bool): Score {.discardable.} =
|
|
|
|
|
## Negamax search with various optimizations and search features
|
|
|
|
|
|
|
|
|
|
# Clear the PV table
|
|
|
|
|
for i in 0..MAX_DEPTH:
|
|
|
|
|
self.pvMoves[ply][i] = nullMove()
|
|
|
|
|
if depth > 1 and self.shouldStop():
|
|
|
|
|
# We do not let ourselves get cancelled at depth
|
|
|
|
|
# one because then we wouldn't have a move to return.
|
|
|
|
|
# In practice this should not be a problem
|
|
|
|
|
return
|
|
|
|
|
when defined(killers):
|
|
|
|
|
if self.killers[].high() < ply:
|
|
|
|
|
self.killers[].add([nullMove(), nullMove()])
|
|
|
|
|
self.selectiveDepth = max(self.selectiveDepth, ply)
|
|
|
|
|
if ply > 0:
|
|
|
|
|
let query = self.transpositionTable[].get(self.board.position.zobristKey, depth.uint8)
|
|
|
|
|
if query.success:
|
|
|
|
@ -285,7 +316,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
|
|
|
|
|
if depth == 0:
|
|
|
|
|
# Quiescent search gain: 264.8 +/- 71.6
|
|
|
|
|
return self.qsearch(0, alpha, beta)
|
|
|
|
|
var
|
|
|
|
|
var
|
|
|
|
|
moves = newMoveList()
|
|
|
|
|
depth = depth
|
|
|
|
|
bestMove = nullMove()
|
|
|
|
@ -305,14 +336,14 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
|
|
|
|
|
var alpha = alpha
|
|
|
|
|
let
|
|
|
|
|
sideToMove = self.board.position.sideToMove
|
|
|
|
|
nonSideToMove = sideToMove.opposite()
|
|
|
|
|
nonSideToMove {.used.} = sideToMove.opposite()
|
|
|
|
|
for i, move in moves:
|
|
|
|
|
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
|
|
|
|
|
continue
|
|
|
|
|
self.board.doMove(move)
|
|
|
|
|
self.currentMove = move
|
|
|
|
|
self.currentMoveNumber = i
|
|
|
|
|
let extension = self.getSearchExtension(move)
|
|
|
|
|
let
|
|
|
|
|
extension = self.getSearchExtension(move)
|
|
|
|
|
reduction = self.getReduction(move, depth, ply, i, isPV)
|
|
|
|
|
inc(self.nodeCount)
|
|
|
|
|
# Find the best move for us (worst move
|
|
|
|
|
# for our opponent, hence the negative sign)
|
|
|
|
@ -321,30 +352,30 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
|
|
|
|
|
if i == 0:
|
|
|
|
|
# Due to our move ordering scheme, the first move is always the "best", so
|
|
|
|
|
# search it always at full depth with the full search window
|
|
|
|
|
score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha)
|
|
|
|
|
elif extension == 0 and self.shouldReduce(move, depth, i):
|
|
|
|
|
score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha, isPV)
|
|
|
|
|
elif reduction > 0:
|
|
|
|
|
# Late Move Reductions: assume our move orderer did a good job,
|
|
|
|
|
# so it is not worth it to look at all moves at the same depth equally.
|
|
|
|
|
# If this move turns out to be better than we expected, we'll re-search
|
|
|
|
|
# it at full depth
|
|
|
|
|
const reduction = 1
|
|
|
|
|
|
|
|
|
|
# We first do a null-window search to see if there's a move that beats alpha
|
|
|
|
|
# (we don't care about the actual value, so we search in the range [alpha, alpha + 1]
|
|
|
|
|
# to increase the number of cutoffs)
|
|
|
|
|
score = -self.search(depth - 1 - reduction, ply + 1, -alpha - 1, -alpha)
|
|
|
|
|
score = -self.search(depth - 1 - reduction, ply + 1, -alpha - 1, -alpha, isPV=false)
|
|
|
|
|
# If the null window search beats alpha, we do a full window reduced search to get a
|
|
|
|
|
# better feel for the actual score of the position. If the score turns out to beat alpha
|
|
|
|
|
# (but not beta) again, we'll re-search this at full depth later
|
|
|
|
|
if score > alpha:
|
|
|
|
|
score = -self.search(depth - 1 - reduction, ply + 1, -beta, -alpha)
|
|
|
|
|
score = -self.search(depth - 1 - reduction, ply + 1, -beta, -alpha, isPV=false)
|
|
|
|
|
else:
|
|
|
|
|
# Move wasn't reduced, just do a null window search
|
|
|
|
|
score = -self.search(depth - 1 + extension, ply + 1, -alpha - 1, -alpha)
|
|
|
|
|
score = -self.search(depth - 1 + extension, ply + 1, -alpha - 1, -alpha, isPV=false)
|
|
|
|
|
if i > 0 and score > alpha and score < beta:
|
|
|
|
|
# The move failed high (and not low, which would mean it was too good for us and
|
|
|
|
|
# The position failed high (and not low, which would mean it was too good for us and
|
|
|
|
|
# our opponent wouldn't let us play it) in the null window search, search it
|
|
|
|
|
# again with the full depth and full window
|
|
|
|
|
score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha)
|
|
|
|
|
score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha, isPV)
|
|
|
|
|
self.board.unmakeMove()
|
|
|
|
|
# When a search is cancelled or times out, we need
|
|
|
|
|
# to make sure the entire call stack unwinds back
|
|
|
|
@ -356,7 +387,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
|
|
|
|
|
if not move.isCapture() and self.history[][sideToMove][move.startSquare][move.targetSquare] < highestEval():
|
|
|
|
|
# History euristic: keep track of moves that caused a beta cutoff and order
|
|
|
|
|
# them early in subsequent searches, as they might be really good later. A
|
|
|
|
|
# quadratic bonus wrt. depth is usually the bonus that is used (though some
|
|
|
|
|
# quadratic bonus wrt. depth is usually the value that is used (though some
|
|
|
|
|
# engines, namely Stockfish, use a linear bonus. Maybe we can investigate this)
|
|
|
|
|
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(depth * depth)
|
|
|
|
|
# Killer move heuristic: store moves that caused a beta cutoff according to the distance from
|
|
|
|
@ -369,8 +400,17 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score): Score
|
|
|
|
|
alpha = score
|
|
|
|
|
bestMove = move
|
|
|
|
|
if ply == 0:
|
|
|
|
|
self.bestMoveRoot = move
|
|
|
|
|
self.bestRootScore = bestScore
|
|
|
|
|
self.bestRootScore = score
|
|
|
|
|
if isPV:
|
|
|
|
|
# This loop is why pvMoves has one extra move.
|
|
|
|
|
# We can just do ply + 1 and i + 1 without ever
|
|
|
|
|
# fearing about buffer overflows
|
|
|
|
|
for i, pv in self.pvMoves[ply + 1]:
|
|
|
|
|
if pv == nullMove():
|
|
|
|
|
self.pvMoves[ply][i + 1] = nullMove()
|
|
|
|
|
break
|
|
|
|
|
self.pvMoves[ply][i + 1] = pv
|
|
|
|
|
self.pvMoves[ply][0] = move
|
|
|
|
|
# TODO
|
|
|
|
|
# else:
|
|
|
|
|
# when defined(noScaleHistory):
|
|
|
|
@ -397,15 +437,14 @@ proc findBestMove*(self: var SearchManager, timeRemaining, increment: int64, max
|
|
|
|
|
## nodes. If searchMoves is provided and is not empty, search will
|
|
|
|
|
## be restricted to the moves in the list. Note that regardless of
|
|
|
|
|
## any time limitations or explicit cancellations, the search will
|
|
|
|
|
## not stop until it has at least cleared depth one
|
|
|
|
|
## not stop until it has at least cleared depth one. Search depth
|
|
|
|
|
## is always constrained to at most MAX_DEPTH ply from the root
|
|
|
|
|
|
|
|
|
|
# Apparently negative remaining time is a thing. Welp
|
|
|
|
|
let
|
|
|
|
|
maxSearchTime = max(1, (timeRemaining div 10) + (increment div 2))
|
|
|
|
|
softLimit = maxSearchTime div 3
|
|
|
|
|
echo maxSearchTime
|
|
|
|
|
self.bestMoveRoot = nullMove()
|
|
|
|
|
result = self.bestMoveRoot
|
|
|
|
|
result = nullMove()
|
|
|
|
|
self.maxNodes = maxNodes
|
|
|
|
|
self.searchMoves = searchMoves
|
|
|
|
|
self.searchStart = getMonoTime()
|
|
|
|
@ -416,23 +455,18 @@ proc findBestMove*(self: var SearchManager, timeRemaining, increment: int64, max
|
|
|
|
|
maxDepth = 30
|
|
|
|
|
self.searchFlag[].store(true)
|
|
|
|
|
# Iterative deepening loop
|
|
|
|
|
for i in 1..maxDepth:
|
|
|
|
|
# Search the previous best move first
|
|
|
|
|
self.previousBestMove = self.bestMoveRoot
|
|
|
|
|
self.search(i, 0, lowestEval(), highestEval())
|
|
|
|
|
# Since we always search the best move from the
|
|
|
|
|
# previous iteration, we can use partial search
|
|
|
|
|
# results: the engine will either not have changed
|
|
|
|
|
# its mind, or it will have found an even better move
|
|
|
|
|
# in the meantime, which we should obviously use!
|
|
|
|
|
result = self.bestMoveRoot
|
|
|
|
|
for i in 1..min(MAX_DEPTH, maxDepth):
|
|
|
|
|
self.search(i, 0, lowestEval(), highestEval(), true)
|
|
|
|
|
if self.pvMoves[0][0] != nullMove():
|
|
|
|
|
result = self.pvMoves[0][0]
|
|
|
|
|
if self.shouldStop():
|
|
|
|
|
self.log(i - 1)
|
|
|
|
|
break
|
|
|
|
|
self.log(i)
|
|
|
|
|
# Soft time management: don't start a new search iteration
|
|
|
|
|
# if the soft limit has expired, as it is unlikely to complete
|
|
|
|
|
# anyway
|
|
|
|
|
if self.shouldStop() or getMonoTime() >= self.softLimit:
|
|
|
|
|
self.log(i - 1)
|
|
|
|
|
if getMonoTime() >= self.softLimit:
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
self.log(i)
|
|
|
|
|
self.searchFlag[].store(false)
|
|
|
|
|
self.stopFlag[].store(false)
|
|
|
|
|