diff --git a/Chess/nimfish/nimfishpkg/movegen.nim b/Chess/nimfish/nimfishpkg/movegen.nim index 7f72909..b1f8197 100644 --- a/Chess/nimfish/nimfishpkg/movegen.nim +++ b/Chess/nimfish/nimfishpkg/movegen.nim @@ -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() diff --git a/Chess/nimfish/nimfishpkg/search.nim b/Chess/nimfish/nimfishpkg/search.nim index e373f07..c64bbe4 100644 --- a/Chess/nimfish/nimfishpkg/search.nim +++ b/Chess/nimfish/nimfishpkg/search.nim @@ -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