diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index a2c82d9..857c15d 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -87,7 +87,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dependencies run: make install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da6b73d..ec9a0ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.12', '3.13'] env: UV_SYSTEM_PYTHON: 1 @@ -31,25 +31,21 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: ${{ matrix.python-version }} - name: Install dev requirements - run: | - make install + run: uv pip install -e . - name: Test moonfish import - shell: bash -l {0} run: | python -c "import moonfish" python -c "import moonfish; import chess; board = chess.Board(); move = moonfish.search_move(board)" - name: Test CLI functionality - shell: bash -l {0} run: | moonfish --help - name: Run unit tests - shell: bash -l {0} run: | python -m unittest tests/test.py @@ -72,12 +68,11 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dev requirements - run: | - make install - + run: uv pip install -e . + - name: Run ufmt run: ufmt check moonfish tests @@ -100,12 +95,11 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dev requirements - run: | - make install - + run: uv pip install -e . + - name: Run Flake8 run: flake8 moonfish tests @@ -128,11 +122,10 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dev requirements - run: | - make install + run: uv pip install -e . - name: Run mypy run: mypy moonfish tests diff --git a/.github/workflows/nps-benchmark.yml b/.github/workflows/nps-benchmark.yml index 771a055..bb3d47e 100644 --- a/.github/workflows/nps-benchmark.yml +++ b/.github/workflows/nps-benchmark.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dependencies run: make install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e448add..cdbdc75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: environment: pypi-publish strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/moonfish/config.py b/moonfish/config.py index eacdf0e..5e2b227 100644 --- a/moonfish/config.py +++ b/moonfish/config.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional # Score for checkmate. CHECKMATE_SCORE = 10**8 @@ -7,7 +6,7 @@ CHECKMATE_THRESHOLD = 999 * (10**4) -@dataclass +@dataclass(slots=True) class Config: """ Configuration for the engine. @@ -19,7 +18,7 @@ class Config: null_move: bool null_move_r: int quiescence_search_depth: int - syzygy_path: Optional[str] + syzygy_path: str | None syzygy_pieces: int checkmate_score: int = CHECKMATE_SCORE checkmate_threshold: int = CHECKMATE_THRESHOLD diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index dd7ab06..bc97538 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -1,6 +1,4 @@ -from copy import copy from multiprocessing.managers import DictProxy -from typing import Dict, Optional, Tuple import chess.syzygy from chess import Board, Move @@ -9,10 +7,14 @@ from moonfish.move_ordering import organize_moves, organize_moves_quiescence from moonfish.psqt import board_evaluation, count_pieces -CACHE_KEY = Dict[ - Tuple[str, int, bool, float, float], Tuple[float | int, Optional[Move]] +CACHE_KEY = dict[ + tuple[object, int, bool, float, float], tuple[float | int, Move | None] ] +INF = float("inf") +NEG_INF = float("-inf") +NULL_MOVE = Move.null() + class AlphaBeta: """ @@ -53,16 +55,16 @@ def eval_board(self, board: Board) -> float: Returns: - score: the score for the current board """ - pieces = sum(count_pieces(board)) - - # Use pre-opened tablebase for endgame positions - if pieces <= self.config.syzygy_pieces and self.tablebase is not None: - try: - dtz = self.tablebase.probe_dtz(board) - return dtz - except (chess.syzygy.MissingTableError, KeyError): - # Position not in tablebase, fall through to normal evaluation - pass + # Short-circuit: only count pieces if a tablebase is loaded + if self.tablebase is not None: + pieces = sum(count_pieces(board)) + if pieces <= self.config.syzygy_pieces: + try: + dtz = self.tablebase.probe_dtz(board) + return dtz + except (chess.syzygy.MissingTableError, KeyError): + # Position not in tablebase, fall through to normal evaluation + pass return board_evaluation(board) @@ -118,7 +120,7 @@ def quiescence_search( # (not ideal but prevents infinite recursion) return stand_pat - best_score = float("-inf") + best_score = NEG_INF moves = list(board.legal_moves) # All evasions else: # Not in check: normal quiescence behavior @@ -146,10 +148,10 @@ def quiescence_search( score = 0.0 # Draw score else: score = -self.quiescence_search( - board=board, - depth=depth - 1, - alpha=-beta, - beta=-alpha, + board, + depth - 1, + -beta, + -alpha, ) board.pop() @@ -172,9 +174,9 @@ def negamax( depth: int, null_move: bool, cache: DictProxy | CACHE_KEY, - alpha: float = float("-inf"), - beta: float = float("inf"), - ) -> Tuple[float | int, Optional[Move]]: + alpha: float = NEG_INF, + beta: float = INF, + ) -> tuple[float | int, Move | None]: """ This functions receives a board, depth and a player; and it returns the best move for the current board based on how many depths we're looking ahead @@ -204,7 +206,7 @@ def negamax( Returns: - best_score, best_move: returns best move that it found and its value. """ - cache_key = (board.fen(), depth, null_move, alpha, beta) + cache_key = (board._transposition_key(), depth, null_move, alpha, beta) self.nodes += 1 @@ -224,10 +226,10 @@ def negamax( if depth <= 0: # evaluate current board board_score = self.quiescence_search( - board=board, - depth=copy(self.config.quiescence_search_depth), - alpha=alpha, - beta=beta, + board, + self.config.quiescence_search_depth, + alpha, + beta, ) cache[cache_key] = (board_score, None) return board_score, None @@ -240,14 +242,14 @@ def negamax( ): board_score = self.eval_board(board) if board_score >= beta: - board.push(Move.null()) + board.push(NULL_MOVE) board_score = -self.negamax( - board=board, - depth=depth - 1 - self.config.null_move_r, - null_move=False, - cache=cache, - alpha=-beta, - beta=-beta + 1, + board, + depth - 1 - self.config.null_move_r, + False, + cache, + -beta, + -beta + 1, )[0] board.pop() if board_score >= beta: @@ -257,7 +259,7 @@ def negamax( best_move = None # initializing best_score - best_score = float("-inf") + best_score = NEG_INF moves = organize_moves(board) for move in moves: @@ -265,12 +267,12 @@ def negamax( board.push(move) board_score = -self.negamax( - board=board, - depth=depth - 1, - null_move=null_move, - cache=cache, - alpha=-beta, - beta=-alpha, + board, + depth - 1, + null_move, + cache, + -beta, + -alpha, )[0] if board_score > self.config.checkmate_threshold: board_score -= 1 @@ -313,7 +315,7 @@ def search_move(self, board: Board) -> Move: cache: CACHE_KEY = {} best_move = self.negamax( - board, copy(self.config.negamax_depth), self.config.null_move, cache + 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/l1p_alpha_beta.py b/moonfish/engines/l1p_alpha_beta.py index 2890255..f2620e8 100644 --- a/moonfish/engines/l1p_alpha_beta.py +++ b/moonfish/engines/l1p_alpha_beta.py @@ -1,4 +1,3 @@ -from copy import copy from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move @@ -25,8 +24,8 @@ def search_move(self, board: Board) -> Move: board.push(move) arguments.append( ( - copy(board), - copy(self.config.negamax_depth) - 1, + board.copy(), + self.config.negamax_depth - 1, self.config.null_move, shared_cache, ) diff --git a/moonfish/engines/l2p_alpha_beta.py b/moonfish/engines/l2p_alpha_beta.py index 61f9444..010b8f8 100644 --- a/moonfish/engines/l2p_alpha_beta.py +++ b/moonfish/engines/l2p_alpha_beta.py @@ -1,9 +1,7 @@ from collections import defaultdict -from copy import copy from functools import partial from multiprocessing import cpu_count, Manager, Pool from multiprocessing.managers import DictProxy -from typing import List, Tuple from chess import Board, Move from moonfish.config import Config @@ -32,7 +30,7 @@ def __init__(self, config: Config): def generate_board_and_moves( self, og_board: Board, board_to_move_that_generates_it: DictProxy, layer: int - ) -> List[Tuple[Board, Board, int]]: + ) -> list[tuple[Board, Board, int]]: """ Generate all possible boards with their layer depth for each board. @@ -48,7 +46,7 @@ def generate_board_and_moves( - layer depth """ boards_and_moves = [] - board = copy(og_board) + board = og_board.copy() # if board has no legal moves, we leave it as is # we need to run this board through negamax to get its value @@ -69,7 +67,7 @@ def generate_board_and_moves( else: board_to_move_that_generates_it[board.fen()] = move # add new board, original board, and current layer to our output - boards_and_moves.append((copy(board), og_board, layer + 1)) + boards_and_moves.append((board.copy(), og_board, layer + 1)) board.pop() @@ -101,7 +99,7 @@ def search_move(self, board: Board) -> Move: negamax_arguments = [ ( board, - copy(self.config.negamax_depth) - START_LAYER, + self.config.negamax_depth - START_LAYER, self.config.null_move, shared_cache, ) diff --git a/moonfish/engines/lazy_smp.py b/moonfish/engines/lazy_smp.py index 8451771..315c282 100644 --- a/moonfish/engines/lazy_smp.py +++ b/moonfish/engines/lazy_smp.py @@ -1,8 +1,7 @@ -from copy import copy from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move -from moonfish.engines.alpha_beta import AlphaBeta +from moonfish.engines.alpha_beta import AlphaBeta, INF, NEG_INF class LazySMP(AlphaBeta): @@ -20,7 +19,7 @@ def search_move(self, board: Board) -> Move: [ ( board, - copy(self.config.negamax_depth), + self.config.negamax_depth, self.config.null_move, shared_cache, ) @@ -31,10 +30,10 @@ def search_move(self, board: Board) -> Move: # return best move for our original board return shared_cache[ ( - board.fen(), + board._transposition_key(), self.config.negamax_depth, self.config.null_move, - float("-inf"), - float("inf"), + NEG_INF, + INF, ) ][1] diff --git a/moonfish/main.py b/moonfish/main.py index 39f5466..a2a36cf 100644 --- a/moonfish/main.py +++ b/moonfish/main.py @@ -1,5 +1,4 @@ import multiprocessing -from typing import Optional import click @@ -15,7 +14,7 @@ def run(config: Config): elif config.mode == "api": api_main() elif config.mode == "bench": - run_bench(depth=5) + run_bench(depth=config.negamax_depth) else: raise ValueError("mode not supported, type --help to see supported modes.") @@ -70,7 +69,7 @@ def main( null_move: bool, null_move_r: int, quiescence_search_depth: int, - syzygy_path: Optional[str], + syzygy_path: str | None, syzygy_pieces: int, ): """ diff --git a/moonfish/psqt.py b/moonfish/psqt.py index e2c1cb5..fb43714 100644 --- a/moonfish/psqt.py +++ b/moonfish/psqt.py @@ -1,34 +1,18 @@ # flake8: noqa -from typing import List, Tuple import chess import chess.syzygy -from moonfish.config import Config - ############ # I'm using Pesto Evaluation function: # https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function # values for Piece-Square Tables from Rofchade: # http://www.talkchess.com/forum3/viewtopic.php?f=2&t=68311&start=19 ############ -MG_PIECE_VALUES = { - chess.PAWN: 82, - chess.KNIGHT: 337, - chess.BISHOP: 365, - chess.ROOK: 477, - chess.QUEEN: 1025, - chess.KING: 24000, -} - -EG_PIECE_VALUES = { - chess.PAWN: 94, - chess.KNIGHT: 281, - chess.BISHOP: 297, - chess.ROOK: 512, - chess.QUEEN: 936, - chess.KING: 24000, -} +# Piece values indexed by piece type (0=unused, 1=PAWN, 2=KNIGHT, ..., 6=KING) +MG_PIECE_VALUES = (0, 82, 337, 365, 477, 1025, 24000) + +EG_PIECE_VALUES = (0, 94, 281, 297, 512, 936, 24000) # fmt: off MG_PAWN = [ @@ -152,23 +136,26 @@ -53, -34, -21, -11, -28, -14, -24, -43] # fmt: on -MG_PESTO = { - chess.PAWN: MG_PAWN, - chess.KNIGHT: MG_KNIGHT, - chess.BISHOP: MG_BISHOP, - chess.ROOK: MG_ROOK, - chess.QUEEN: MG_QUEEN, - chess.KING: MG_KING, -} - -EG_PESTO = { - chess.PAWN: EG_PAWN, - chess.KNIGHT: EG_KNIGHT, - chess.BISHOP: EG_BISHOP, - chess.ROOK: EG_ROOK, - chess.QUEEN: EG_QUEEN, - chess.KING: EG_KING, -} +# Indexed by piece type (0=unused, 1=PAWN, 2=KNIGHT, ..., 6=KING) +MG_PESTO: tuple[list[int], ...] = ( + [0], + MG_PAWN, + MG_KNIGHT, + MG_BISHOP, + MG_ROOK, + MG_QUEEN, + MG_KING, +) + +EG_PESTO: tuple[list[int], ...] = ( + [0], + EG_PAWN, + EG_KNIGHT, + EG_BISHOP, + EG_ROOK, + EG_QUEEN, + EG_KING, +) ############ # Tapered Evaluation: https://www.chessprogramming.org/Tapered_Eval @@ -202,7 +189,7 @@ ] -def count_pieces(board: chess.Board) -> List[int]: +def count_pieces(board: chess.Board) -> list[int]: """ Counts the number of each piece on the board. @@ -265,7 +252,7 @@ def get_phase(board: chess.Board) -> float: def board_evaluation_cache(fun): def inner(board: chess.Board): - key = board.fen() + key = board._transposition_key() if key not in BOARD_EVALUATION_CACHE: BOARD_EVALUATION_CACHE[key] = fun(board) return BOARD_EVALUATION_CACHE[key] @@ -290,41 +277,28 @@ def board_evaluation(board: chess.Board) -> float: phase = get_phase(board) - mg = { - chess.WHITE: 0, - chess.BLACK: 0, - } - eg = { - chess.WHITE: 0, - chess.BLACK: 0, - } - - # loop through all squares and sum their piece values for both sides - for square in range(64): - piece = board.piece_at(square) - if piece is None: - continue + mg_white = 0 + mg_black = 0 + eg_white = 0 + eg_black = 0 + # iterate only occupied squares via piece_map() + for square, piece in board.piece_map().items(): + pt = piece.piece_type if piece.color == chess.WHITE: - mg[piece.color] += ( - MG_PESTO[piece.piece_type][square ^ 56] - + MG_PIECE_VALUES[piece.piece_type] - ) - eg[piece.color] += ( - EG_PESTO[piece.piece_type][square ^ 56] - + EG_PIECE_VALUES[piece.piece_type] - ) - if piece.color == chess.BLACK: - mg[piece.color] += ( - MG_PESTO[piece.piece_type][square] + MG_PIECE_VALUES[piece.piece_type] - ) - eg[piece.color] += ( - EG_PESTO[piece.piece_type][square] + EG_PIECE_VALUES[piece.piece_type] - ) + mg_white += MG_PESTO[pt][square ^ 56] + MG_PIECE_VALUES[pt] + eg_white += EG_PESTO[pt][square ^ 56] + EG_PIECE_VALUES[pt] + else: + mg_black += MG_PESTO[pt][square] + MG_PIECE_VALUES[pt] + eg_black += EG_PESTO[pt][square] + EG_PIECE_VALUES[pt] # calculate board score based on phase - mg_score = mg[board.turn] - mg[not board.turn] - eg_score = eg[board.turn] - eg[not board.turn] + if board.turn == chess.WHITE: + mg_score = mg_white - mg_black + eg_score = eg_white - eg_black + else: + mg_score = mg_black - mg_white + eg_score = eg_black - eg_white eval = ((mg_score * (256 - phase)) + (eg_score * phase)) / 256 return eval diff --git a/pyproject.toml b/pyproject.toml index b3da0ea..0fbf7c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "moonfish" dynamic = ["version"] readme = "README.md" description = "Moonfish is a didactic Python chess engine showcasing parallel search algorithms and modern chess programming techniques." -requires-python = ">=3.10" +requires-python = ">=3.12" license = {file = "LICENSE"} authors = [ {name = "Lucca Bertoncini", email = "luccabazzo@gmail.com"},