from __future__ import annotations from collections.abc import Callable from typing import NamedTuple import chess EvalFn = Callable[[chess.Board], int] MATE_SCORE = 100_000 TT_EXACT = "exact" TT_LOWER = "lower" TT_UPPER = "upper" MAX_TT_ENTRIES = 50_000 CAPTURE_ORDER = { chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0, } ENDGAME_PHASE_THRESHOLD = 6 LOW_BRANCHING_THRESHOLD = 12 ENDGAME_BRANCHING_THRESHOLD = 18 OPENING_FULLMOVE_LIMIT = 2 OPENING_BRANCHING_THRESHOLD = 24 NULL_MOVE_DEPTH_REDUCTION = 2 NULL_MOVE_MIN_DEPTH = 3 LMR_MIN_DEPTH = 3 LMR_MIN_MOVE_INDEX = 3 ASPIRATION_WINDOW = 60 class TTEntry(NamedTuple): depth: int score: int bound: str best_move: chess.Move | None _GLOBAL_TT: dict[tuple[object, ...], TTEntry] = {} _GLOBAL_HISTORY: dict[tuple[int, int], int] = {} def _terminal_score(board: chess.Board) -> int: if board.is_checkmate(): return -MATE_SCORE return 0 def _phase(board: chess.Board) -> int: phase = 0 phase += 4 * (len(board.pieces(chess.QUEEN, chess.WHITE)) + len(board.pieces(chess.QUEEN, chess.BLACK))) phase += 2 * (len(board.pieces(chess.ROOK, chess.WHITE)) + len(board.pieces(chess.ROOK, chess.BLACK))) phase += len(board.pieces(chess.BISHOP, chess.WHITE)) + len(board.pieces(chess.BISHOP, chess.BLACK)) phase += len(board.pieces(chess.KNIGHT, chess.WHITE)) + len(board.pieces(chess.KNIGHT, chess.BLACK)) return min(phase, 24) def _selective_root_depth(board: chess.Board, depth: int, move_count: int) -> int: if depth < 2: return depth if board.fullmove_number <= OPENING_FULLMOVE_LIMIT and move_count <= OPENING_BRANCHING_THRESHOLD: return depth + 1 if board.is_check() or move_count <= LOW_BRANCHING_THRESHOLD: return depth + 1 if _phase(board) <= ENDGAME_PHASE_THRESHOLD and move_count <= ENDGAME_BRANCHING_THRESHOLD: return depth + 1 return depth def _score_for_turn(board: chess.Board, eval_fn: EvalFn) -> int: score = eval_fn(board) return score if board.turn == chess.WHITE else -score def _move_order_score( board: chess.Board, move: chess.Move, *, tt_move: chess.Move | None = None, killer_moves: tuple[chess.Move, ...] = (), history: dict[tuple[int, int], int] | None = None, ) -> int: if tt_move is not None and move == tt_move: return 1_000_000 score = 0 if board.is_capture(move): victim = board.piece_at(move.to_square) attacker = board.piece_at(move.from_square) if victim is not None: score += 100 * CAPTURE_ORDER[victim.piece_type] if attacker is not None: score -= 10 * CAPTURE_ORDER[attacker.piece_type] if move.promotion is not None: score += 800 + CAPTURE_ORDER.get(move.promotion, 0) if board.gives_check(move): score += 50 if board.is_castling(move): score += 25 if not board.is_capture(move) and move.promotion is None: for index, killer in enumerate(killer_moves): if move == killer: score += 90_000 - index * 10_000 break if history is not None: piece_type = board.piece_type_at(move.from_square) if piece_type is not None: score += history.get((piece_type, move.to_square), 0) return score def _ordered_moves( board: chess.Board, *, tt_move: chess.Move | None = None, killer_moves: tuple[chess.Move, ...] = (), history: dict[tuple[int, int], int] | None = None, ) -> list[chess.Move]: return sorted( board.legal_moves, key=lambda move: _move_order_score( board, move, tt_move=tt_move, killer_moves=killer_moves, history=history, ), reverse=True, ) def _tactical_moves(board: chess.Board) -> list[chess.Move]: return [ move for move in _ordered_moves(board) if board.is_capture(move) or move.promotion is not None ] def _record_killer(killers: dict[int, tuple[chess.Move, ...]], ply: int, move: chess.Move) -> None: existing = tuple(candidate for candidate in killers.get(ply, ()) if candidate != move) killers[ply] = (move, *existing[:1]) def _record_history( history: dict[tuple[int, int], int], board: chess.Board, move: chess.Move, depth: int, ) -> None: piece_type = board.piece_type_at(move.from_square) if piece_type is None: return key = (piece_type, move.to_square) history[key] = history.get(key, 0) + depth * depth def _quiescence(board: chess.Board, alpha: int, beta: int, eval_fn: EvalFn) -> int: if board.is_game_over(claim_draw=True): return _terminal_score(board) in_check = board.is_check() if not in_check: stand_pat = _score_for_turn(board, eval_fn) if stand_pat >= beta: return stand_pat if stand_pat > alpha: alpha = stand_pat moves = _ordered_moves(board) if in_check else _tactical_moves(board) for move in moves: board.push(move) score = -_quiescence(board, -beta, -alpha, eval_fn) board.pop() if score >= beta: return score if score > alpha: alpha = score return alpha def negamax( board: chess.Board, depth: int, alpha: int, beta: int, eval_fn: EvalFn, tt: dict[tuple[object, ...], TTEntry], killers: dict[int, tuple[chess.Move, ...]], history: dict[tuple[int, int], int], ply: int = 0, ) -> int: if board.is_game_over(claim_draw=True): return _terminal_score(board) if depth == 0: return _quiescence(board, alpha, beta, eval_fn) alpha_orig = alpha key = board._transposition_key() entry = tt.get(key) tt_move = entry.best_move if entry is not None else None if entry is not None and entry.depth >= depth: if entry.bound == TT_EXACT: return entry.score if entry.bound == TT_LOWER: alpha = max(alpha, entry.score) elif entry.bound == TT_UPPER: beta = min(beta, entry.score) if alpha >= beta: return entry.score if ( depth >= NULL_MOVE_MIN_DEPTH and not board.is_check() and _phase(board) > ENDGAME_PHASE_THRESHOLD and beta < MATE_SCORE ): board.push(chess.Move.null()) null_score = -negamax( board, depth - 1 - NULL_MOVE_DEPTH_REDUCTION, -beta, -beta + 1, eval_fn, tt, killers, history, ply + 1, ) board.pop() if null_score >= beta: return beta best_score = -MATE_SCORE best_move: chess.Move | None = None killer_moves = killers.get(ply, ()) for move_index, move in enumerate(_ordered_moves(board, tt_move=tt_move, killer_moves=killer_moves, history=history)): board.push(move) if move_index == 0: score = -negamax(board, depth - 1, -beta, -alpha, eval_fn, tt, killers, history, ply + 1) else: reduced_depth = depth - 1 if ( depth >= LMR_MIN_DEPTH and move_index >= LMR_MIN_MOVE_INDEX and not board.is_check() and not board.is_capture(move) and move.promotion is None ): reduced_depth -= 1 score = -negamax(board, reduced_depth, -alpha - 1, -alpha, eval_fn, tt, killers, history, ply + 1) if alpha < score < beta: score = -negamax(board, depth - 1, -beta, -alpha, eval_fn, tt, killers, history, ply + 1) board.pop() if score > best_score: best_score = score best_move = move if best_score > alpha: alpha = best_score if alpha >= beta: if not board.is_capture(move) and move.promotion is None: _record_killer(killers, ply, move) _record_history(history, board, move, depth) break bound = TT_EXACT if best_score <= alpha_orig: bound = TT_UPPER elif best_score >= beta: bound = TT_LOWER if len(tt) >= MAX_TT_ENTRIES: tt.clear() tt[key] = TTEntry(depth=depth, score=best_score, bound=bound, best_move=best_move) return best_score def select_move(board: chess.Board, depth: int, eval_fn: EvalFn) -> chess.Move: best_move: chess.Move | None = None best_score = -MATE_SCORE alpha = -MATE_SCORE beta = MATE_SCORE killers: dict[int, tuple[chess.Move, ...]] = {} root_entry = _GLOBAL_TT.get(board._transposition_key()) if root_entry is not None and abs(root_entry.score) < MATE_SCORE // 2: alpha = max(-MATE_SCORE, root_entry.score - ASPIRATION_WINDOW) beta = min(MATE_SCORE, root_entry.score + ASPIRATION_WINDOW) root_moves = _ordered_moves( board, tt_move=root_entry.best_move if root_entry is not None else None, history=_GLOBAL_HISTORY, ) search_depth = _selective_root_depth(board, depth, len(root_moves)) use_full_window = False for move_index, move in enumerate(root_moves): board.push(move) if move_index == 0: score = -negamax(board, search_depth - 1, -beta, -alpha, eval_fn, _GLOBAL_TT, killers, _GLOBAL_HISTORY, 1) else: reduced_depth = search_depth - 1 if ( search_depth >= LMR_MIN_DEPTH and move_index >= LMR_MIN_MOVE_INDEX and not board.is_check() and not board.is_capture(move) and move.promotion is None ): reduced_depth -= 1 score = -negamax( board, reduced_depth, -alpha - 1, -alpha, eval_fn, _GLOBAL_TT, killers, _GLOBAL_HISTORY, 1, ) if alpha < score < beta: score = -negamax(board, search_depth - 1, -beta, -alpha, eval_fn, _GLOBAL_TT, killers, _GLOBAL_HISTORY, 1) if not use_full_window and (score <= alpha or score >= beta): score = -negamax( board, search_depth - 1, -MATE_SCORE, MATE_SCORE, eval_fn, _GLOBAL_TT, killers, _GLOBAL_HISTORY, 1, ) alpha = -MATE_SCORE beta = MATE_SCORE use_full_window = True board.pop() if best_move is None or score > best_score: best_move = move best_score = score if best_score > alpha: alpha = best_score if best_move is None: raise RuntimeError("no legal move available") return best_move