def _probe_endgame_tablebase( self, board: SearchBoard) -> Tuple[Optional[chess.Move], Optional[int]]: with chess.gaviota.PythonTablebase() as tablebase: tablebase.add_directory(self.endgame_tablebase_filepath) curr_dtm = tablebase.get_dtm(board) # If playing for a stalemate then no point in probing if curr_dtm is not None and curr_dtm != 0: max_dtm = -1_000_000 max_dtm_move = None for move in board.legal_moves: board.push(move) outcome = board.outcome() considering_dtm = tablebase.get_dtm(board) no_draw = outcome is None or outcome.termination == chess.Termination.CHECKMATE mate_move = outcome is not None and outcome.termination == chess.Termination.CHECKMATE # The case where a DTM of 0 means that we are mating if 0 <= curr_dtm <= 3 and mate_move and considering_dtm == 0: board.pop() return move, considering_dtm else: # Otherwise, DTM of 0 means draw if curr_dtm > 0: optimizes = max_dtm < considering_dtm < 0 else: optimizes = considering_dtm > max_dtm and considering_dtm > 0 if considering_dtm is not None and no_draw and optimizes: max_dtm = considering_dtm max_dtm_move = move board.pop() return max_dtm_move, max_dtm return None, None
def _quiescence(self, alpha: int, beta: int, board: SearchBoard) -> int: self.search_info.nodes += 1 evaluation = self.evaluator.evaluate_board(board) if self._terminal_condition(board): return 0 score = evaluation.board_evaluation if board.searching_ply > self.max_depth - 1: return score if score >= beta: # Beta cutoff return beta if score > alpha: # Standing pattern alpha = score # From here, it is basically minimax alpha beta using only capture moves num_legal_moves = 0 for move in self.move_orderer(board, None, captures_only=True): if move in self.search_info.excluded_moves: continue num_legal_moves += 1 board.push(move) score = -self._quiescence(-beta, -alpha, board) board.pop() if self.search_info.stop: return 0 if score > alpha: if score >= beta: self.search_info.fail_high_first += 1 if num_legal_moves == 1 else 0 self.search_info.fail_high += 1 return beta alpha = score return alpha
def search(self, board: SearchBoard, fixed_time: Optional[float], target_depth: int, target_nodes: Optional[int], on_depth_completed: Callable[[int, int, List[chess.Move], str], None] = None, log_func: Callable[[str], None] = None, use_opening_book: bool = True, use_endgame_tablebase: bool = True) -> List[chess.Move]: """ Performs iterative deepening minimax (negamax) search with alpha-beta pruning. Also handles checking opening and closing books. :param board: Board to search :param fixed_time: Time to use for the search, in seconds :param target_depth: Depth to search to :param target_nodes: Number of nodes to search :param on_depth_completed: A callback that is run after each successive depth is completed :param log_func: Logging function to use :param use_opening_book: Whether to use the opening book :param use_endgame_tablebase: Whether to use the endgame tablebase :return: The PV line found """ # Parse parameters if target_depth is None: target_depth = self.max_depth if log_func is None: log_func = lambda s: None board = self._init_search_info(board, time_to_think=fixed_time, nodes=target_nodes) # Check for opening book entry if use_opening_book: move = self._probe_opening_book(board) if move is not None: log_func("opening book hit") return [move] # Check endgame tables if use_endgame_tablebase: move, dtm = self._probe_endgame_tablebase(board) if move is not None: log_func(f"endgame table hit, DTM: {dtm}") return [move] # Clear the transposition table when there is a transition to endgame if len(board.move_stack) > 0: move = board.pop() prev_evaluation = self.evaluator.evaluate_board(board) board.push(move) evaluation = self.evaluator.evaluate_board(board) if evaluation.white_endgame != prev_evaluation.white_endgame or \ evaluation.black_endgame != prev_evaluation.black_endgame: board.clear_transposition_table() log_func( "transposition table cleared due to transition to endgame") # Set up the checkup daemon thread timer = call_repeatedly(0.5, self._checkup) # Iterative deepening prev_elapsed_time = 0 best_score = None max_depth = 0 err_log = dict() for curr_depth in range(1, target_depth + 1): st = time.time() self.search_info.curr_depth = curr_depth best_score = self._minimax_alpha_beta(-self.inf, self.inf, board, curr_depth, True) self._checkup() if self.search_info.stop: break max_depth = curr_depth ordering = self.search_info.fail_high_first / self.search_info.fail_high \ if self.search_info.fail_high > 0 else 0.0 # elapsed_time = time.time() - st # log_func(f"Depth {curr_depth} took time {elapsed_time}") # if curr_depth > 2: # err = abs(predicted_time_next_iteration - elapsed_time) # err_log[curr_depth] = err # if curr_depth > 1: # # Effective branching factor # time_ebf = elapsed_time / prev_elapsed_time # predicted_time_next_iteration = time_ebf * elapsed_time # log_func(f"Predicting that depth {curr_depth + 1} will take {round(predicted_time_next_iteration, 2)}s") # # # TODO finish up the timecontrol classes and delegate to that via callbacks # # # TODO this doesn't work when coming off of the transposition table, or maybe the error calculation doesn't work?? # # if self.search_info.end_time is not None: # # time_remaining = self.search_info.end_time - time.time() # # if time_remaining < 0.75 * predicted_time_next_iteration: # # break # # prev_elapsed_time = elapsed_time on_depth_completed(curr_depth, best_score, board.generate_pv_line(curr_depth)) # Stop the timer timer() log_func("Errors:") for d in range(1, target_depth + 1): if d in err_log: log_func(f"Depth {d}: {round(err_log[d], 2)}") return board.generate_pv_line(max_depth)
def _minimax_alpha_beta(self, alpha: int, beta: int, board: SearchBoard, depth: int, null_pruning: bool) -> int: # Check depth terminal condition if depth <= 0: return self._quiescence(alpha, beta, board) self.search_info.nodes += 1 if self._terminal_condition( board) and board.searching_ply > 0: # Stalemate/draw condition return 0 # Evaluate evaluation = self.evaluator.evaluate_board(board) if board.searching_ply > self.max_depth - 1: # Max depth return evaluation.board_evaluation # In-check test to "get out of check" in_check = board.is_check() depth += 1 if in_check else 0 # Prove transposition table and return early if we have a hit pv_move, score = board.probe_transposition_table( alpha, beta, depth, self.mate_value) if pv_move: return score # Null move pruning (must also verify that the side isn't in check and not in zugzwang scenario) if null_pruning and not in_check and board.searching_ply > 0 \ and not self._zugzwang(board) and depth >= self.reduction_factor + 1: board.push(chess.Move.null()) score = -self._minimax_alpha_beta( -beta, -beta + 1, board, depth - self.reduction_factor - 1, False) board.pop() if self.search_info.stop: return 0 # If the null move option improves on beta and isn't a mate then return it if score >= beta and abs(score) < self.mate_value: return beta old_alpha = alpha best_move = None best_score = -self.inf num_legal_moves = 0 found_pv = False for move in self.move_orderer(board, pv_move): if move in self.search_info.excluded_moves: continue num_legal_moves += 1 board.push(move) if found_pv: # PVS (principal variation search) score = -self._minimax_alpha_beta(-alpha - 1, -alpha, board, depth - 1, True) if alpha < score < beta: score = -self._minimax_alpha_beta(-beta, -alpha, board, depth - 1, True) else: score = -self._minimax_alpha_beta(-beta, -alpha, board, depth - 1, True) board.pop() if self.search_info.stop: return 0 capture = board.is_capture(move) if score > best_score: best_score = score best_move = move if score > alpha: # Alpha cutoff if score >= beta: # Beta cutoff self.search_info.fail_high_first += 1 if num_legal_moves == 1 else 0 self.search_info.fail_high += 1 if not capture: # Killer move (beta cutoff but not capture) self.search_killers[1][ board.searching_ply] = self.search_killers[0][ board.searching_ply] self.search_killers[0][board.searching_ply] = move board.store_transposition_table_entry( best_move, beta, depth, TranspositionTableFlag.BETA, self.mate_value) return beta found_pv = True alpha = score if not capture: # Alpha cutoff that isn't a capture self.search_history[best_move.from_square][ best_move.to_square] += depth if num_legal_moves == 0: # Checkmate cases if in_check: return -self.inf + board.searching_ply else: # Draw return 0 if alpha != old_alpha: # Principal variation board.store_transposition_table_entry(best_move, best_score, depth, TranspositionTableFlag.EXACT, self.mate_value) else: board.store_transposition_table_entry(best_move, alpha, depth, TranspositionTableFlag.ALPHA, self.mate_value) return alpha
class CoeusEngine: """ The engine largely just wraps the searcher to provide proper UCI protocol control and time control. This includes managing pondering. In order to use the engine, follow these steps: `engine.clear_mode()` `# set relevant engine parameters such as fixed_time, winc, etc.` `engine.set_mode(relevant mode booleans)` `engine.go(on_completed)` """ def __init__(self, config_filepath: str, log_func: Callable[[str], None]): """ Creates an engine using the given searcher. :param config_filepath: File path to the config file :param log_func: The function to use to log data """ self.config_filepath = config_filepath self.board: SearchBoard = SearchBoard() with open(self.config_filepath, "r+") as config_f: self.config = json.load(config_f) self._parse_config() # Stop flag (for pondering) self._stop = False # Logging information self.log_func: Callable[[str], None] = log_func self._time_last_log: Optional[float] = None self._nodes_last_log: Optional[int] = None def _parse_config(self): self.name = self.config["name"] self.version = self.config["version"] # Evaluator and searcher self.evaluator = evaluation.evaluator_from_config( self.config["evaluator_config"]) self.searcher = AlphaBetaMinimaxSearcher( self.config["searcher_config"], self.evaluator) # Ponder settings self._base_ponder_depth = self.config["base_ponder_depth"] self._ponder_pv_depth_offset = self.config["ponder_pv_depth_offset"] def _reset_logging(self): self._time_last_log = None self._nodes_last_log = None def _log_info(self): # Depth, nodes, and time elapsed_search_time_ms = int( 1000 * (time.time() - self.searcher.search_info.start_time)) self.log_func(f"info depth {self.searcher.search_info.curr_depth} " f"nodes {self.searcher.search_info.nodes} " f"time {elapsed_search_time_ms}") # Nodes per second if self._time_last_log and self._nodes_last_log: elapsed_time = time.time() - self._time_last_log nodes_since_last_log = self.searcher.search_info.nodes - self._nodes_last_log nps = nodes_since_last_log / elapsed_time self.log_func(f"info nps {nps}") self._time_last_log = time.time() self._nodes_last_log = self.searcher.search_info.nodes def _log_completed_depth(self, depth_completed: Optional[int], best_score: Optional[int], pv_line: Optional[List[chess.Move]], info_str: str = None): if depth_completed is not None and best_score is not None and pv_line is not None and len( pv_line) > 0: # Log the best score, depth, nodes, time, and PV line elapsed_search_time_ms = int( 1000 * (time.time() - self.searcher.search_info.start_time)) pv_line_str = " ".join(str(m) for m in pv_line) mate_in = self.searcher.mate_in(best_score) score_str = f"mate {mate_in}" if mate_in else f"cp {best_score}" self.log_func(f"info score {score_str} " f"depth {depth_completed} " f"nodes {self.searcher.search_info.nodes} " f"time {elapsed_search_time_ms} " f"pv {pv_line_str}") if info_str: self.log_func(f"info string {info_str}") def set_position(self, lag: List[str], starting_fen=chess.STARTING_FEN): """ Sets the internal board state to that specified by the series of long algebraic notation moves given (starting from the specified start position). :param lag: List of long algebraic notation moves :param starting_fen: The FEN to start the board at :return: None """ self.board = SearchBoard(fen=starting_fen) for move in lag: self.board.push(chess.Move.from_uci(move)) def new_game(self): """ Resets everything to be ready for a new game. :return: None """ self.board = SearchBoard() def stop(self): """ Stops the engine from the current search. :return: None """ self._stop = True self.searcher.search_info.stop = True def go(self, params: EngineGoParams, on_completed: Callable[[List[chess.Move]], Any] = None, log_time_quantum: float = 0.25, ponder: bool = False) -> List[chess.Move]: """ Finds the best move considering the time constraints and returns it. :param params: All parameters associated with a call to go, including the proper mode information :param on_completed: The completion callback (argument is the output) :param log_time_quantum: The time quantum that searching info will be logged every, in seconds :param ponder: Whether to ponder :return: The principal variation line found """ logger.debug(f"Searching from board:\n") logger.debug(f"\n{str(self.board)}") logger.debug(f"FEN: {self.board.fen()}") logger.debug(f"mode={params.mode}") self.log_func( f"info string {self.board.transposition_table_size()} transposition table entries" ) if params.mode == _TimeMode.INFINITE or ponder: fixed_time = None depth = None nodes = None elif params.mode == _TimeMode.TIME_CONTROL: moves_to_go = 30 if params.moves_to_go is None else params.moves_to_go time_left = params.wtime if self.board.turn == chess.WHITE else params.btime inc = params.winc if self.board.turn == chess.WHITE else params.binc if inc is None: inc = 0 # Time control calculation fixed_time = ((time_left // moves_to_go) + inc) / 1_000 depth = self.searcher.max_depth nodes = None elif params.mode == _TimeMode.FIXED_TIME: fixed_time = params.fixed_time / 1_000 depth = self.searcher.max_depth nodes = None elif params.mode == _TimeMode.DEPTH: fixed_time = None depth = params.target_depth nodes = None elif params.mode == _TimeMode.NODES: fixed_time = None depth = None nodes = params.target_nodes else: raise ValueError("The mode is not set properly!") # Set a separate timer to log information self._reset_logging() if ponder and abs(log_time_quantum - 0.25) < 1e-3: log_time_quantum = 10.0 timer = call_repeatedly(log_time_quantum, self._log_info) if ponder: # In ponder mode, use a kind of iterative deepening, namely: # - Search to a base depth plus an offset for the opponent's PV move # - Search to a base depth for all other of the opponent's moves # - Increase the base depth and repeat pondering_board: SearchBoard = self.board.copy_transposition_table_referenced( ) # Recall that this PV line is dynamically updated so we simply take the 1st element pv_line = pondering_board.generate_pv_line(depth=1) # Note that in theory the PV line could be messed up so verify that the entry is actual a legal move suspected_opponent_pv_move = pv_line[0] if len( pv_line) > 0 else None opponent_pv_move = None other_opponent_moves = [] for move in pondering_board.legal_moves: if suspected_opponent_pv_move == move: opponent_pv_move = suspected_opponent_pv_move else: other_opponent_moves.append(move) ponder_depth = self._base_ponder_depth kwargs = { "on_depth_completed": self._log_completed_depth, "log_func": lambda s: self.log_func(f"info string {s}"), "use_opening_book": False, "use_endgame_tablebase": False } while not self._stop and ponder_depth + self._ponder_pv_depth_offset < self.searcher.max_depth: if opponent_pv_move: if self._stop: self.stop() break self._reset_logging() pondering_board.push(opponent_pv_move) self.searcher.search( pondering_board, None, ponder_depth + self._ponder_pv_depth_offset, None, **kwargs) pondering_board.pop() for move in other_opponent_moves: if self._stop: self.stop() break self._reset_logging() pondering_board.push(move) self.searcher.search(pondering_board, None, ponder_depth, None, **kwargs) pondering_board.pop() ponder_depth += 1 # Simply set for returning pv_line = None else: pv_line = self.searcher.search( self.board, fixed_time, depth, nodes, on_depth_completed=self._log_completed_depth, log_func=lambda s: self.log_func(f"info string {s}"), use_opening_book=params.use_opening_book, use_endgame_tablebase=params.use_endgame_tablebase) # Stop the logging timer timer() # Reset stop flag self._stop = False if on_completed: on_completed(pv_line) return pv_line