Implement null move pruning (+34.5 +/- 19.6), minor changes and fixes
This commit is contained in:
parent
887e2a64a3
commit
b9fbb9eb3f
|
@ -516,6 +516,7 @@ proc makeNullMove*(self: var Chessboard) =
|
||||||
## search. The move can be undone via unmakeMove
|
## search. The move can be undone via unmakeMove
|
||||||
self.positions.add(self.position)
|
self.positions.add(self.position)
|
||||||
self.position.sideToMove = self.position.sideToMove.opposite()
|
self.position.sideToMove = self.position.sideToMove.opposite()
|
||||||
|
self.position.enPassantSquare = nullSquare()
|
||||||
self.position.fromNull = true
|
self.position.fromNull = true
|
||||||
self.position.updateChecksAndPins()
|
self.position.updateChecksAndPins()
|
||||||
self.position.hash()
|
self.position.hash()
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
## Implementation of negamax with a/b pruning
|
## Search routines for Nimfish
|
||||||
import board
|
import board
|
||||||
import movegen
|
import movegen
|
||||||
import eval
|
import eval
|
||||||
|
@ -28,8 +28,18 @@ 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
|
NUM_KILLERS* = 2
|
||||||
MAX_DEPTH* = 255
|
MAX_DEPTH* = 255
|
||||||
|
# Constants used during move ordering
|
||||||
MVV_LVA_MULTIPLIER = 10
|
MVV_LVA_MULTIPLIER = 10
|
||||||
PROMOTION_MULTIPLIER = 2
|
PROMOTION_MULTIPLIER = 2
|
||||||
# These offsets are used in the move
|
# These offsets are used in the move
|
||||||
|
@ -120,8 +130,9 @@ proc storeHistoryScore(self: var SearchManager, sideToMove: PieceColor, move: Mo
|
||||||
## table
|
## table
|
||||||
|
|
||||||
# We use this formula to evenly spread the improvement the more we increase it while
|
# 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
|
# keeping it constrained to a maximum value so it doesn't overflow. It's also helpful
|
||||||
self.history[][sideToMove][move.startSquare][move.targetSquare] += Score(bonus) - abs(score.int32) * score div highestEval()
|
# 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 =
|
func isTactical(self: Move): bool =
|
||||||
|
@ -135,6 +146,9 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): int =
|
||||||
## during move ordering
|
## during move ordering
|
||||||
let sideToMove = self.board.position.sideToMove
|
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)
|
let query = self.transpositionTable[].get(self.board.position.zobristKey)
|
||||||
if query.success and query.entry.bestMove != nullMove() and query.entry.bestMove == move:
|
if query.success and query.entry.bestMove != nullMove() and query.entry.bestMove == move:
|
||||||
return TTMOVE_OFFSET
|
return TTMOVE_OFFSET
|
||||||
|
@ -145,14 +159,15 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move, ply: int): int =
|
||||||
|
|
||||||
if move.isTactical():
|
if move.isTactical():
|
||||||
if move.isCapture():
|
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
|
# 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
|
# 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)
|
# it priority)
|
||||||
let capturedScore = MVV_LVA_MULTIPLIER * self.board.position.getPieceScore(move.targetSquare)
|
let capturedScore = MVV_LVA_MULTIPLIER * self.board.position.getPieceScore(move.targetSquare)
|
||||||
result = capturedScore - self.board.position.getPieceScore(move.startSquare)
|
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():
|
if move.isPromotion():
|
||||||
var piece: Piece
|
var piece: Piece
|
||||||
case move.getPromotionType():
|
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
|
# In practice this should not be a problem
|
||||||
return
|
return
|
||||||
self.selectiveDepth = max(self.selectiveDepth, ply)
|
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:
|
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)
|
let query = self.transpositionTable[].get(self.board.position.zobristKey, depth.uint8)
|
||||||
if query.success:
|
if query.success:
|
||||||
case query.entry.flag:
|
case query.entry.flag:
|
||||||
|
@ -348,11 +369,46 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
|
||||||
of UpperBound:
|
of UpperBound:
|
||||||
if query.entry.score <= alpha:
|
if query.entry.score <= alpha:
|
||||||
return query.entry.score
|
return query.entry.score
|
||||||
if self.board.isDrawn():
|
if not isPV and depth > 2 and self.board.canNullMove() and self.board.position.evaluate() >= beta:
|
||||||
return Score(0)
|
# Null move pruning: it is reasonable to assume that
|
||||||
if depth == 0:
|
# it is always better to make a move than not to do
|
||||||
# Quiescent search gain: 264.8 +/- 71.6
|
# so (with some exceptions noted below). To take advantage
|
||||||
return self.qsearch(0, alpha, beta)
|
# 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
|
var
|
||||||
moves = newMoveList()
|
moves = newMoveList()
|
||||||
depth = depth
|
depth = depth
|
||||||
|
@ -373,7 +429,6 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
|
||||||
var alpha = alpha
|
var alpha = alpha
|
||||||
let
|
let
|
||||||
sideToMove = self.board.position.sideToMove
|
sideToMove = self.board.position.sideToMove
|
||||||
nonSideToMove {.used.} = sideToMove.opposite()
|
|
||||||
for i, move in moves:
|
for i, move in moves:
|
||||||
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
|
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
|
||||||
continue
|
continue
|
||||||
|
@ -448,6 +503,7 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV:
|
||||||
break
|
break
|
||||||
self.pvMoves[ply][i + 1] = pv
|
self.pvMoves[ply][i + 1] = pv
|
||||||
self.pvMoves[ply][0] = move
|
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
|
let nodeType = if bestScore >= beta: LowerBound elif bestScore <= alpha: UpperBound else: Exact
|
||||||
self.transpositionTable[].store(depth.uint8, bestScore, self.board.position.zobristKey, bestMove, nodeType)
|
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
|
# alpha-beta bounds and widen them as needed (i.e. when the score
|
||||||
# goes beyond the window) to increase the number of cutoffs
|
# goes beyond the window) to increase the number of cutoffs
|
||||||
var
|
var
|
||||||
delta = Score(20)
|
delta = Score(25)
|
||||||
alpha = max(lowestEval(), score - delta)
|
alpha = max(lowestEval(), score - delta)
|
||||||
beta = min(highestEval(), score + delta)
|
beta = min(highestEval(), score + delta)
|
||||||
searchDepth = depth
|
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
|
# Score is outside window bounds, widen the one that
|
||||||
# we got past to get a better result
|
# we got past to get a better result
|
||||||
if score <= alpha:
|
if score <= alpha:
|
||||||
beta = (alpha + beta) div 2
|
# beta = (alpha + beta) div 2
|
||||||
alpha = max(lowestEval(), alpha - delta)
|
alpha = max(lowestEval(), alpha - delta)
|
||||||
searchDepth = depth
|
# searchDepth = depth
|
||||||
elif score >= beta:
|
elif score >= beta:
|
||||||
beta = min(highestEval(), beta + delta)
|
beta = min(highestEval(), beta + delta)
|
||||||
if searchDepth > 1:
|
# if searchDepth > 1:
|
||||||
searchDepth = searchDepth - 1
|
# searchDepth = searchDepth - 1
|
||||||
else:
|
else:
|
||||||
# Value was within the alpha-beta bounds, we're done
|
# Value was within the alpha-beta bounds, we're done
|
||||||
break
|
break
|
||||||
# Try again with larger window
|
# Try again with larger window
|
||||||
delta += delta div 2
|
delta += delta
|
||||||
# TODO: Tune this
|
# TODO: Tune this
|
||||||
if delta >= Score(500):
|
if delta >= Score(500):
|
||||||
# Window got too wide, give up and search with the full range
|
# Window got too wide, give up and search with the full range
|
||||||
|
|
Loading…
Reference in New Issue