Implement null move pruning (+34.5 +/- 19.6), minor changes and fixes

This commit is contained in:
Mattia Giambirtone 2024-05-10 11:21:57 +02:00
parent 887e2a64a3
commit b9fbb9eb3f
2 changed files with 75 additions and 18 deletions

View File

@ -516,6 +516,7 @@ proc makeNullMove*(self: var Chessboard) =
## search. The move can be undone via unmakeMove
self.positions.add(self.position)
self.position.sideToMove = self.position.sideToMove.opposite()
self.position.enPassantSquare = nullSquare()
self.position.fromNull = true
self.position.updateChecksAndPins()
self.position.hash()

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
## Implementation of negamax with a/b pruning
## Search routines for Nimfish
import board
import movegen
import eval
@ -27,9 +27,19 @@ import std/monotimes
import std/strformat
const
const
# Constants to configure how aggressively
# NMP reduces the search depth
# Reduce search depth by at least this value
NMP_BASE_REDUCTION = 3
# Reduce search depth proportionally to the
# current depth divided by this value, plus
# the base reduction
NMP_DEPTH_REDUCTION {.used.} = 3
NUM_KILLERS* = 2
MAX_DEPTH* = 255
# Constants used during move ordering
MVV_LVA_MULTIPLIER = 10
PROMOTION_MULTIPLIER = 2
# These offsets are used in the move
@ -120,8 +130,9 @@ proc storeHistoryScore(self: var SearchManager, sideToMove: PieceColor, move: Mo
## table
# We use this formula to evenly spread the improvement the more we increase it while
# keeping it constrained to a maximum value so it doesn't overflow
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(bonus) - abs(score.int32) * score div highestEval()
# keeping it constrained to a maximum value so it doesn't overflow. It's also helpful
# for when we'll eventually implement history malus and use negative bonuses
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(bonus) - abs(bonus.int32) * score div highestEval()
func isTactical(self: Move): bool =
@ -135,6 +146,9 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): int =
## during move ordering
let sideToMove = self.board.position.sideToMove
# Here we don't care that the move from the TT comes from a deeper search
# than ours, but we're interested in the previous best move so that we can
# search that first for our principal variation
let query = self.transpositionTable[].get(self.board.position.zobristKey)
if query.success and query.entry.bestMove != nullMove() and query.entry.bestMove == move:
return TTMOVE_OFFSET
@ -145,14 +159,15 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): int =
if move.isTactical():
if move.isCapture():
# Implementation of MVVLVA: Most Valuable Victim Least Valuable Attacker
# Implementation of MVVLVA: Most Valuable Victim Least Valuable Aggressor.
# We prioritize moves that capture the most valuable pieces, and as a
# second goal we want to use our least valuable pieces to do so (this
# is why we multiply the score of the captured piece by 10, to give
# is why we multiply the score of the captured piece by a constant, to give
# it priority)
let capturedScore = MVV_LVA_MULTIPLIER * self.board.position.getPieceScore(move.targetSquare)
result = capturedScore - self.board.position.getPieceScore(move.startSquare)
# If the capture is also a promotion we want to give it an even bigger bonus
if move.isPromotion():
var piece: Piece
case move.getPromotionType():
@ -336,7 +351,13 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
# In practice this should not be a problem
return
self.selectiveDepth = max(self.selectiveDepth, ply)
if self.board.isDrawn():
return Score(0)
if depth <= 0:
# Quiescent search gain: 264.8 +/- 71.6
return self.qsearch(0, alpha, beta)
if ply > 0:
# Probe the transposition table to see if we can cause an early cutoff
let query = self.transpositionTable[].get(self.board.position.zobristKey, depth.uint8)
if query.success:
case query.entry.flag:
@ -348,11 +369,46 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
of UpperBound:
if query.entry.score <= alpha:
return query.entry.score
if self.board.isDrawn():
return Score(0)
if depth == 0:
# Quiescent search gain: 264.8 +/- 71.6
return self.qsearch(0, alpha, beta)
if not isPV and depth > 2 and self.board.canNullMove() and self.board.position.evaluate() >= beta:
# Null move pruning: it is reasonable to assume that
# it is always better to make a move than not to do
# so (with some exceptions noted below). To take advantage
# of this assumption, we bend the rules a little and perform
# a so-called "null move", basically passing our turn doing
# nothing, and then perform a shallower search for our opponent.
# If the shallow search fails high (i.e. produces a beta cutoff),
# then it is useless for us to search this position any further
# and we can just return the score outright. Since we only care about
# whether the opponent can beat beta and not the actual value, we
# can do a null window search and save some time, too. There are a
# few rules that need to be followed to use NMP properly, though: we
# must not be in check and we also must have not null-moved before
# (that's what board.canNullMove() is checking) and the static
# evaluation of the position needs to already be better than or
# equal to beta
let
friendlyPawns = self.board.position.getBitboard(Pawn, self.board.position.sideToMove)
friendlyKing = self.board.position.getBitboard(King, self.board.position.sideToMove)
friendlyPieces = self.board.position.getOccupancyFor(self.board.position.sideToMove)
if friendlyPieces != (friendlyKing or friendlyPawns):
# NMP is disabled in endgame positions where only kings
# and (friendly) pawns are left because those are the ones
# where it is most likely that the null move assumption will
# not hold true due to zugzwang (fancy engines do zugzwang
# verification, but I literally cba to do that)
self.board.makeNullMove()
# We perform a shallower search because otherwise there would be no point in
# doing NMP at all!
var reduction: int
when defined(NMP2):
# Reduce more based on depth
reduction = NMP_BASE_REDUCTION + depth div NMP_DEPTH_REDUCTION
else:
reduction = NMP_BASE_REDUCTION
let score = -self.search(depth - reduction, ply + 1, -beta + 1, -beta, isPV=false)
self.board.unmakeMove()
if score >= beta:
return score
var
moves = newMoveList()
depth = depth
@ -373,7 +429,6 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
var alpha = alpha
let
sideToMove = self.board.position.sideToMove
nonSideToMove {.used.} = sideToMove.opposite()
for i, move in moves:
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
continue
@ -448,6 +503,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
break
self.pvMoves[ply][i + 1] = pv
self.pvMoves[ply][0] = move
# Store the best move in the transposition table so we can find it later
let nodeType = if bestScore >= beta: LowerBound elif bestScore <= alpha: UpperBound else: Exact
self.transpositionTable[].store(depth.uint8, bestScore, self.board.position.zobristKey, bestMove, nodeType)
@ -459,7 +515,7 @@ proc aspirationWindow(self: var SearchManager, score: Score, depth: int): Score
# alpha-beta bounds and widen them as needed (i.e. when the score
# goes beyond the window) to increase the number of cutoffs
var
delta = Score(20)
delta = Score(25)
alpha = max(lowestEval(), score - delta)
beta = min(highestEval(), score + delta)
searchDepth = depth
@ -468,18 +524,18 @@ proc aspirationWindow(self: var SearchManager, score: Score, depth: int): Score
# Score is outside window bounds, widen the one that
# we got past to get a better result
if score <= alpha:
beta = (alpha + beta) div 2
# beta = (alpha + beta) div 2
alpha = max(lowestEval(), alpha - delta)
searchDepth = depth
# searchDepth = depth
elif score >= beta:
beta = min(highestEval(), beta + delta)
if searchDepth > 1:
searchDepth = searchDepth - 1
# if searchDepth > 1:
# searchDepth = searchDepth - 1
else:
# Value was within the alpha-beta bounds, we're done
break
# Try again with larger window
delta += delta div 2
delta += delta
# TODO: Tune this
if delta >= Score(500):
# Window got too wide, give up and search with the full range