diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index bc97538..2fd9b56 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -15,6 +15,9 @@ NEG_INF = float("-inf") NULL_MOVE = Move.null() +# Number of killer moves to store per ply +NUM_KILLERS = 2 + class AlphaBeta: """ @@ -168,6 +171,19 @@ def quiescence_search( return best_score + def _store_killer( + self, killers: list[list[Move | None]], ply: int, move: Move + ) -> None: + """Store a killer move at the given ply, shifting the existing one.""" + if ply >= len(killers): + return + # Don't store duplicates + if killers[ply][0] == move: + return + # Shift: slot 1 gets old slot 0, slot 0 gets new move + killers[ply][1] = killers[ply][0] + killers[ply][0] = move + def negamax( self, board: Board, @@ -176,6 +192,8 @@ def negamax( cache: DictProxy | CACHE_KEY, alpha: float = NEG_INF, beta: float = INF, + ply: int = 0, + killers: list[list[Move | None]] | None = None, ) -> tuple[float | int, Move | None]: """ This functions receives a board, depth and a player; and it returns @@ -198,10 +216,10 @@ def negamax( - 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). + - alpha: best score for the maximizing player + - beta: best score for the minimizing player + - ply: current ply from root (for killer move indexing) + - killers: killer move table [ply][slot] -> Move Returns: - best_score, best_move: returns best move that it found and its value. @@ -250,6 +268,8 @@ def negamax( cache, -beta, -beta + 1, + ply + 1, + killers, )[0] board.pop() if board_score >= beta: @@ -260,7 +280,10 @@ def negamax( # initializing best_score best_score = NEG_INF - moves = organize_moves(board) + + # Get killer moves for this ply + ply_killers = killers[ply] if killers is not None and ply < len(killers) else None + moves = organize_moves(board, killers=ply_killers) for move in moves: # make the move @@ -273,6 +296,8 @@ def negamax( cache, -beta, -alpha, + ply + 1, + killers, )[0] if board_score > self.config.checkmate_threshold: board_score -= 1 @@ -284,6 +309,9 @@ def negamax( # beta-cutoff if board_score >= beta: + # Store killer move for quiet moves that cause cutoff + if killers is not None and not board.is_capture(move): + self._store_killer(killers, ply, move) cache[cache_key] = (board_score, move) return board_score, move @@ -314,8 +342,13 @@ def search_move(self, board: Board) -> Move: # create shared cache cache: CACHE_KEY = {} + # Initialize killer move table: max_depth + some margin for extensions + max_ply = self.config.negamax_depth + 20 + killers: list[list[Move | None]] = [[None, None] for _ in range(max_ply)] + best_move = self.negamax( - board, self.config.negamax_depth, self.config.null_move, cache + board, self.config.negamax_depth, self.config.null_move, cache, + killers=killers, )[1] assert best_move is not None, "Best move from root should not be None" return best_move diff --git a/moonfish/move_ordering.py b/moonfish/move_ordering.py index 630ec81..9963ad6 100644 --- a/moonfish/move_ordering.py +++ b/moonfish/move_ordering.py @@ -5,31 +5,53 @@ from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase -def organize_moves(board: Board): +def organize_moves( + board: Board, + killers: "list[Move | None] | 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). + Order: captures first, then killer moves, then remaining quiet moves. Arguments: - board: chess board state + - killers: list of killer moves for the current ply (tried after captures) Returns: - legal_moves: list of all the possible moves for the current player. """ non_captures = [] captures = [] + killer_set = set() + + # Build set of killer moves for fast lookup + if killers is not None: + for k in killers: + if k is not None: + killer_set.add(k) for move in board.legal_moves: if board.is_capture(move): captures.append(move) + elif move in killer_set: + # Skip killers from non_captures; we'll insert them after captures + continue else: non_captures.append(move) random.shuffle(captures) random.shuffle(non_captures) - return captures + non_captures + + # Insert legal killer moves between captures and quiet moves + killer_moves = [] + if killers is not None: + legal_moves_set = set(board.legal_moves) + for k in killers: + if k is not None and k in legal_moves_set and not board.is_capture(k): + killer_moves.append(k) + + return captures + killer_moves + non_captures def is_tactical_move(board: Board, move: Move) -> bool: