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
|
||||
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()
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue