예제 #1
0
    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
예제 #2
0
    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
예제 #3
0
    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)
예제 #4
0
    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
예제 #5
0
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