diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index bc97538..cb3e70b 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -7,9 +7,13 @@ from moonfish.move_ordering import organize_moves, organize_moves_quiescence from moonfish.psqt import board_evaluation, count_pieces -CACHE_KEY = dict[ - tuple[object, int, bool, float, float], tuple[float | int, Move | None] -] +# Transposition table entry types +TT_EXACT = 0 # Exact score (PV node) +TT_LOWER = 1 # Score is a lower bound (beta cutoff / fail-high) +TT_UPPER = 2 # Score is an upper bound (fail-low / all-node) + +# TT entry: (score, best_move, depth, entry_type) +TT_TYPE = dict[object, tuple[float | int, Move | None, int, int]] INF = float("inf") NEG_INF = float("-inf") @@ -18,7 +22,8 @@ class AlphaBeta: """ - A class that implements alpha-beta search algorithm. + A class that implements alpha-beta search algorithm with iterative deepening + and a proper transposition table. """ def __init__(self, config: Config): @@ -173,7 +178,7 @@ def negamax( board: Board, depth: int, null_move: bool, - cache: DictProxy | CACHE_KEY, + cache: DictProxy | TT_TYPE, alpha: float = NEG_INF, beta: float = INF, ) -> tuple[float | int, Move | None]: @@ -196,30 +201,38 @@ def negamax( - board: chess board state - depth: how many depths we want to calculate for this board - null_move: if we want to use null move pruning - - cache: a shared hash table to store the best - move for each board state and depth. - - alpha: best score for the maximizing player (best choice - (highest value) we've found along the path for max) - - beta: best score for the minimizing player (best choice - (lowest value) we've found along the path for min). + - cache: transposition table storing (score, best_move, depth, entry_type) + - alpha: best score for the maximizing player + - beta: best score for the minimizing player Returns: - best_score, best_move: returns best move that it found and its value. """ - cache_key = (board._transposition_key(), depth, null_move, alpha, beta) + tt_key = board._transposition_key() + original_alpha = alpha self.nodes += 1 - # check if board was already evaluated - if cache_key in cache: - return cache[cache_key] + # Probe transposition table + tt_move = None + if tt_key in cache: + tt_score, tt_move, tt_depth, tt_type = cache[tt_key] + if tt_depth >= depth: + if tt_type == TT_EXACT: + return tt_score, tt_move + elif tt_type == TT_LOWER: + alpha = max(alpha, tt_score) + elif tt_type == TT_UPPER: + beta = min(beta, tt_score) + if alpha >= beta: + return tt_score, tt_move if board.is_checkmate(): - cache[cache_key] = (-self.config.checkmate_score, None) + cache[tt_key] = (-self.config.checkmate_score, None, depth, TT_EXACT) return (-self.config.checkmate_score, None) if board.is_stalemate(): - cache[cache_key] = (0, None) + cache[tt_key] = (0, None, depth, TT_EXACT) return (0, None) # recursion base case @@ -231,7 +244,7 @@ def negamax( alpha, beta, ) - cache[cache_key] = (board_score, None) + cache[tt_key] = (board_score, None, 0, TT_EXACT) return board_score, None # null move prunning @@ -253,14 +266,16 @@ def negamax( )[0] board.pop() if board_score >= beta: - cache[cache_key] = (beta, None) + cache[tt_key] = (beta, None, depth, TT_LOWER) return beta, None best_move = None # initializing best_score best_score = NEG_INF - moves = organize_moves(board) + + # Get move list, placing TT move first if available + moves = organize_moves(board, tt_move=tt_move) for move in moves: # make the move @@ -284,7 +299,7 @@ def negamax( # beta-cutoff if board_score >= beta: - cache[cache_key] = (board_score, move) + cache[tt_key] = (board_score, move, depth, TT_LOWER) return board_score, move # update best move @@ -305,17 +320,33 @@ def negamax( if not best_move: best_move = self.random_move(board) + # Determine TT entry type based on the score relative to original bounds + if best_score <= original_alpha: + tt_type = TT_UPPER # Failed low: score is an upper bound + elif best_score >= beta: + tt_type = TT_LOWER # Failed high: score is a lower bound + else: + tt_type = TT_EXACT # Exact score + # save result before returning - cache[cache_key] = (best_score, best_move) + cache[tt_key] = (best_score, best_move, depth, tt_type) return best_score, best_move def search_move(self, board: Board) -> Move: self.nodes = 0 - # create shared cache - cache: CACHE_KEY = {} + # create shared transposition table (persists across iterations) + cache: TT_TYPE = {} + + best_move = None + + # Iterative deepening: search from depth 1 up to target depth + # Each iteration populates the TT, which helps order moves in the next + for d in range(1, self.config.negamax_depth + 1): + score, move = self.negamax( + board, d, self.config.null_move, cache + ) + if move is not None: + best_move = move - best_move = self.negamax( - board, self.config.negamax_depth, self.config.null_move, cache - )[1] assert best_move is not None, "Best move from root should not be None" return best_move diff --git a/moonfish/engines/lazy_smp.py b/moonfish/engines/lazy_smp.py index 315c282..791bdbd 100644 --- a/moonfish/engines/lazy_smp.py +++ b/moonfish/engines/lazy_smp.py @@ -1,7 +1,7 @@ from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move -from moonfish.engines.alpha_beta import AlphaBeta, INF, NEG_INF +from moonfish.engines.alpha_beta import AlphaBeta class LazySMP(AlphaBeta): @@ -27,13 +27,6 @@ def search_move(self, board: Board) -> Move: ], ) - # return best move for our original board - return shared_cache[ - ( - board._transposition_key(), - self.config.negamax_depth, - self.config.null_move, - NEG_INF, - INF, - ) - ][1] + # return best move from the transposition table + tt_key = board._transposition_key() + return shared_cache[tt_key][1] diff --git a/moonfish/move_ordering.py b/moonfish/move_ordering.py index 630ec81..9799eac 100644 --- a/moonfish/move_ordering.py +++ b/moonfish/move_ordering.py @@ -5,15 +5,16 @@ from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase -def organize_moves(board: Board): +def organize_moves(board: Board, tt_move: "Move | None" = None): """ This function receives a board and it returns a list of all the possible moves for the current player, sorted by importance. - It sends capturing moves at the starting positions in - the array (to try to increase pruning and do so earlier). + The TT move (from the transposition table) is placed first, + then captures, then non-captures. Arguments: - board: chess board state + - tt_move: best move from transposition table (searched first) Returns: - legal_moves: list of all the possible moves for the current player. @@ -22,6 +23,9 @@ def organize_moves(board: Board): captures = [] for move in board.legal_moves: + # Skip TT move — it will be placed at the front + if tt_move is not None and move == tt_move: + continue if board.is_capture(move): captures.append(move) else: @@ -29,7 +33,12 @@ def organize_moves(board: Board): random.shuffle(captures) random.shuffle(non_captures) - return captures + non_captures + + result = captures + non_captures + # Place TT move first if it's a legal move + if tt_move is not None: + result.insert(0, tt_move) + return result def is_tactical_move(board: Board, move: Move) -> bool: