From 010352b0f63f19a7e33640e76c8fcf6751d67d8b Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sat, 14 Feb 2026 23:19:26 -0800 Subject: [PATCH 1/9] Add NPS benchmark for search speed regression testing Add a node counter to AlphaBeta and a bench mode that searches 48 positions from Stockfish's bench suite, reporting per-position and total nodes, time, and NPS. Node count is deterministic and serves as the primary signal for detecting search behavior changes. Includes a CI workflow that runs on PRs and posts results as a comment. --- .github/workflows/nps-benchmark.yml | 89 ++++++++++++++++++++ moonfish/bench.py | 123 ++++++++++++++++++++++++++++ moonfish/engines/alpha_beta.py | 7 ++ moonfish/main.py | 5 +- 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/nps-benchmark.yml create mode 100644 moonfish/bench.py diff --git a/.github/workflows/nps-benchmark.yml b/.github/workflows/nps-benchmark.yml new file mode 100644 index 0000000..3959bd7 --- /dev/null +++ b/.github/workflows/nps-benchmark.yml @@ -0,0 +1,89 @@ +name: NPS Benchmark + +on: + pull_request: + paths: + # Only run benchmarks when engine code changes + - 'moonfish/**' + - 'pyproject.toml' + - 'requirements.txt' + +permissions: + contents: read + pull-requests: write + +env: + UV_SYSTEM_PYTHON: 1 + +jobs: + nps-benchmark: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "requirements.txt" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: make install + + - name: Run NPS benchmark + run: | + python -m moonfish.main --mode bench --depth 5 2>&1 | tee bench-output.txt + + - name: Parse results and comment on PR + env: + GH_TOKEN: ${{ github.token }} + run: | + OUTPUT="bench-output.txt" + + TOTAL_TIME=$(grep "^Total time" "$OUTPUT" | awk '{print $NF}') + TOTAL_NODES=$(grep "^Nodes searched" "$OUTPUT" | awk '{print $NF}') + NPS=$(grep "^Nodes/second" "$OUTPUT" | awk '{print $NF}') + NUM_POSITIONS=$(grep -c "^Position" "$OUTPUT") + + # Format numbers with commas + TOTAL_NODES_FMT=$(printf "%'d" "$TOTAL_NODES") + NPS_FMT=$(printf "%'d" "$NPS") + + # Build per-position breakdown + PER_POS=$(grep "^Position" "$OUTPUT") + + cat > pr-comment.md << EOF + ## ⚡ NPS Benchmark Results + + | Metric | Value | + |--------|-------| + | Depth | 5 | + | Positions | $NUM_POSITIONS | + | Total nodes | $TOTAL_NODES_FMT | + | Total time | ${TOTAL_TIME}s | + | Nodes/second | $NPS_FMT | + + > **Node count is the primary signal** — it's deterministic and catches search behavior changes. If the node count changes, the PR changed search behavior. NPS is informational only (CI runner performance varies). + +
Per-position breakdown + + \`\`\` + $PER_POS + \`\`\` + +
+ EOF + + # Remove leading whitespace from heredoc + sed -i 's/^ //' pr-comment.md + + cat pr-comment.md >> $GITHUB_STEP_SUMMARY + + gh pr comment ${{ github.event.pull_request.number }} --body-file pr-comment.md diff --git a/moonfish/bench.py b/moonfish/bench.py new file mode 100644 index 0000000..8240a2d --- /dev/null +++ b/moonfish/bench.py @@ -0,0 +1,123 @@ +import random +import time + +from chess import Board, Move + +from moonfish.config import Config +from moonfish.engines.alpha_beta import AlphaBeta + +# 48 positions from Stockfish's bench command (excluding 2 Chess960 positions). +# Some entries include "moves ..." suffixes that are applied before searching. +BENCH_POSITIONS = [ + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 10", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 11", + "4rrk1/pp1n3p/3q2pQ/2p1pb2/2PP4/2P3N1/P2B2PP/4RRK1 b - - 7 19", + "rq3rk1/ppp2ppp/1bnpb3/3N2B1/3NP3/7P/PPPQ1PP1/2KR3R w - - 7 14 moves d4e6", + "r1bq1r1k/1pp1n1pp/1p1p4/4p2Q/4Pp2/1BNP4/PPP2PPP/3R1RK1 w - - 2 14 moves g2g4", + "r3r1k1/2p2ppp/p1p1bn2/8/1q2P3/2NPQN2/PPP3PP/R4RK1 b - - 2 15", + "r1bbk1nr/pp3p1p/2n5/1N4p1/2Np1B2/8/PPP2PPP/2KR1B1R w kq - 0 13", + "r1bq1rk1/ppp1nppp/4n3/3p3Q/3P4/1BP1B3/PP1N2PP/R4RK1 w - - 1 16", + "4r1k1/r1q2ppp/ppp2n2/4P3/5Rb1/1N1BQ3/PPP3PP/R5K1 w - - 1 17", + "2rqkb1r/ppp2p2/2npb1p1/1N1Nn2p/2P1PP2/8/PP2B1PP/R1BQK2R b KQ - 0 11", + "r1bq1r1k/b1p1npp1/p2p3p/1p6/3PP3/1B2NN2/PP3PPP/R2Q1RK1 w - - 1 16", + "3r1rk1/p5pp/bpp1pp2/8/q1PP1P2/b3P3/P2NQRPP/1R2B1K1 b - - 6 22", + "r1q2rk1/2p1bppp/2Pp4/p6b/Q1PNp3/4B3/PP1R1PPP/2K4R w - - 2 18", + "4k2r/1pb2ppp/1p2p3/1R1p4/3P4/2r1PN2/P4PPP/1R4K1 b - - 3 22", + "3q2k1/pb3p1p/4pbp1/2r5/PpN2N2/1P2P2P/5PP1/Q2R2K1 b - - 4 26", + "6k1/6p1/6Pp/ppp5/3pn2P/1P3K2/1PP2P2/3N4 b - - 0 1", + "3b4/5kp1/1p1p1p1p/pP1PpP1P/P1P1P3/3KN3/8/8 w - - 0 1", + "2K5/p7/7P/5pR1/8/5k2/r7/8 w - - 0 1 moves g5g6 f3e3 g6g5 e3f3", + "8/6pk/1p6/8/PP3p1p/5P2/4KP1q/3Q4 w - - 0 1", + "7k/3p2pp/4q3/8/4Q3/5Kp1/P6b/8 w - - 0 1", + "8/2p5/8/2kPKp1p/2p4P/2P5/3P4/8 w - - 0 1", + "8/1p3pp1/7p/5P1P/2k3P1/8/2K2P2/8 w - - 0 1", + "8/pp2r1k1/2p1p3/3pP2p/1P1P1P1P/P5KR/8/8 w - - 0 1", + "8/3p4/p1bk3p/Pp6/1Kp1PpPp/2P2P1P/2P5/5B2 b - - 0 1", + "5k2/7R/4P2p/5K2/p1r2P1p/8/8/8 b - - 0 1", + "6k1/6p1/P6p/r1N5/5p2/7P/1b3PP1/4R1K1 w - - 0 1", + "1r3k2/4q3/2Pp3b/3Bp3/2Q2p2/1p1P2P1/1P2KP2/3N4 w - - 0 1", + "6k1/4pp1p/3p2p1/P1pPb3/R7/1r2P1PP/3B1P2/6K1 w - - 0 1", + "8/3p3B/5p2/5P2/p7/PP5b/k7/6K1 w - - 0 1", + "5rk1/q6p/2p3bR/1pPp1rP1/1P1Pp3/P3B1Q1/1K3P2/R7 w - - 93 90", + "4rrk1/1p1nq3/p7/2p1P1pp/3P2bp/3Q1Bn1/PPPB4/1K2R1NR w - - 40 21", + "r3k2r/3nnpbp/q2pp1p1/p7/Pp1PPPP1/4BNN1/1P5P/R2Q1RK1 w kq - 0 16", + "3Qb1k1/1r2ppb1/pN1n2q1/Pp1Pp1Pr/4P2p/4BP2/4B1R1/1R5K b - - 11 40", + "4k3/3q1r2/1N2r1b1/3ppN2/2nPP3/1B1R2n1/2R1Q3/3K4 w - - 5 1", + # Positions with high numbers of changed threats + "k7/2n1n3/1nbNbn2/2NbRBn1/1nbRQR2/2NBRBN1/3N1N2/7K w - - 0 1", + "K7/8/8/BNQNQNB1/N5N1/R1Q1q2r/n5n1/bnqnqnbk w - - 0 1", + # 5-man positions + "8/8/8/8/5kp1/P7/8/1K1N4 w - - 0 1", + "8/8/8/5N2/8/p7/8/2NK3k w - - 0 1", + "8/3k4/8/8/8/4B3/4KB2/2B5 w - - 0 1", + # 6-man positions + "8/8/1P6/5pr1/8/4R3/7k/2K5 w - - 0 1", + "8/2p4P/8/kr6/6R1/8/8/1K6 w - - 0 1", + "8/8/3P3k/8/1p6/8/1P6/1K3n2 b - - 0 1", + # 7-man positions + "8/R7/2q5/8/6k1/8/1P5p/K6R w - - 0 124", + # Mate and stalemate positions + "6k1/3b3r/1p1p4/p1n2p2/1PPNpP1q/P3Q1p1/1R1RB1P1/5K2 b - - 0 1", + "r2r1n2/pp2bk2/2p1p2p/3q4/3PN1QP/2P3R1/P4PP1/5RK1 w - - 0 1", + "8/8/8/8/8/6k1/6p1/6K1 w - -", + "7k/7P/6K1/8/3B4/8/8/8 b - -", +] + + +def _make_board(position: str) -> Board: + """Parse a position string into a Board, applying any trailing moves.""" + parts = position.split(" moves ") + board = Board(parts[0]) + if len(parts) > 1: + for uci in parts[1].split(): + board.push(Move.from_uci(uci)) + return board + + +def run_bench(depth: int) -> None: + """Run the benchmark: search all positions at the given depth and report NPS.""" + # Seed RNG for deterministic node counts (move ordering uses random.shuffle) + random.seed(0) + config = Config( + mode="bench", + algorithm="alpha_beta", + negamax_depth=depth, + null_move=True, + null_move_r=2, + quiescence_search_depth=3, + syzygy_path=None, + syzygy_pieces=5, + ) + engine = AlphaBeta(config) + + total_nodes = 0 + total_time = 0.0 + n = len(BENCH_POSITIONS) + + for i, position in enumerate(BENCH_POSITIONS, 1): + board = _make_board(position) + config.negamax_depth = depth + + # Skip terminal positions (checkmate/stalemate) — no moves to search + if not list(board.legal_moves): + print(f"Position {i:>2}/{n}: nodes=0 time=0.00s nps=0 (terminal)") + continue + + start = time.perf_counter() + engine.search_move(board) + elapsed = time.perf_counter() - start + + nodes = engine.nodes + nps = int(nodes / elapsed) if elapsed > 0 else 0 + total_nodes += nodes + total_time += elapsed + + print(f"Position {i:>2}/{n}: nodes={nodes:<10} time={elapsed:.2f}s nps={nps}") + + total_nps = int(total_nodes / total_time) if total_time > 0 else 0 + + print("===========================") + print(f"Total time (s) : {total_time:.2f}") + print(f"Nodes searched : {total_nodes}") + print(f"Nodes/second : {total_nps}") diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index 5244aed..dd7ab06 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -21,6 +21,7 @@ class AlphaBeta: def __init__(self, config: Config): self.config = config + self.nodes: int = 0 # Open Syzygy tablebase once at initialization (not on every eval) self.tablebase = None @@ -93,6 +94,8 @@ def quiescence_search( """ in_check = board.is_check() + self.nodes += 1 + if board.is_checkmate(): return -self.config.checkmate_score @@ -202,6 +205,9 @@ def negamax( - best_score, best_move: returns best move that it found and its value. """ cache_key = (board.fen(), depth, null_move, alpha, beta) + + self.nodes += 1 + # check if board was already evaluated if cache_key in cache: return cache[cache_key] @@ -302,6 +308,7 @@ def negamax( return best_score, best_move def search_move(self, board: Board) -> Move: + self.nodes = 0 # create shared cache cache: CACHE_KEY = {} diff --git a/moonfish/main.py b/moonfish/main.py index 1b85759..3f028de 100644 --- a/moonfish/main.py +++ b/moonfish/main.py @@ -6,6 +6,7 @@ from moonfish.config import Config from moonfish.mode.api import main as api_main from moonfish.mode.uci import main as uci_main +from moonfish.bench import run_bench def run(config: Config): @@ -13,13 +14,15 @@ def run(config: Config): uci_main(config) elif config.mode == "api": api_main() + elif config.mode == "bench": + run_bench(depth=5) else: raise ValueError("mode not supported, type --help to see supported modes.") @click.command() @click.option( - "--mode", type=str, help="Mode to run the engine, one of [uci, api].", default="uci" + "--mode", type=str, help="Mode to run the engine, one of [uci, api, bench].", default="uci" ) @click.option( "--algorithm", From c5bbecdeca709c6680bfb870b4daf38feca758dd Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sat, 14 Feb 2026 23:21:51 -0800 Subject: [PATCH 2/9] Add source link for Stockfish bench positions --- moonfish/bench.py | 1 + 1 file changed, 1 insertion(+) diff --git a/moonfish/bench.py b/moonfish/bench.py index 8240a2d..e529084 100644 --- a/moonfish/bench.py +++ b/moonfish/bench.py @@ -7,6 +7,7 @@ from moonfish.engines.alpha_beta import AlphaBeta # 48 positions from Stockfish's bench command (excluding 2 Chess960 positions). +# Source: https://github.com/official-stockfish/Stockfish/blob/master/src/benchmark.cpp # Some entries include "moves ..." suffixes that are applied before searching. BENCH_POSITIONS = [ "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", From 40ad1d71162d0e50b64a7cd063af9727d51a8e2d Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sat, 14 Feb 2026 23:50:29 -0800 Subject: [PATCH 3/9] Fix formatting and import sorting --- moonfish/bench.py | 4 +++- moonfish/main.py | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/moonfish/bench.py b/moonfish/bench.py index e529084..135c567 100644 --- a/moonfish/bench.py +++ b/moonfish/bench.py @@ -102,7 +102,9 @@ def run_bench(depth: int) -> None: # Skip terminal positions (checkmate/stalemate) — no moves to search if not list(board.legal_moves): - print(f"Position {i:>2}/{n}: nodes=0 time=0.00s nps=0 (terminal)") + print( + f"Position {i:>2}/{n}: nodes=0 time=0.00s nps=0 (terminal)" + ) continue start = time.perf_counter() diff --git a/moonfish/main.py b/moonfish/main.py index 3f028de..39f5466 100644 --- a/moonfish/main.py +++ b/moonfish/main.py @@ -3,10 +3,10 @@ import click +from moonfish.bench import run_bench from moonfish.config import Config from moonfish.mode.api import main as api_main from moonfish.mode.uci import main as uci_main -from moonfish.bench import run_bench def run(config: Config): @@ -22,7 +22,10 @@ def run(config: Config): @click.command() @click.option( - "--mode", type=str, help="Mode to run the engine, one of [uci, api, bench].", default="uci" + "--mode", + type=str, + help="Mode to run the engine, one of [uci, api, bench].", + default="uci", ) @click.option( "--algorithm", From 035811ff4af51b0e13e6d8d119970df096f44c5e Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sun, 15 Feb 2026 11:35:19 -0800 Subject: [PATCH 4/9] Upgrade to Python 3.12+ and optimize hot-path performance Bump minimum Python version to 3.12 for interpreter speedups (PEP 709 comprehension inlining, faster f-strings). Fix CI bug where test matrix python-version was hardcoded instead of using the matrix variable. Optimize the search hot path (~42% NPS improvement at depth 3): - Replace board.fen() with board._transposition_key() in caches - Precompute float("inf"), float("-inf"), Move.null() as module constants - Use positional arguments in recursive negamax/quiescence calls - Use board.piece_map() instead of iterating 64 squares - Convert piece value dicts to tuple indexing - Replace per-eval dict accumulators with plain integer variables - Short-circuit syzygy tablebase check when no tablebase loaded - Add __slots__ to Config dataclass - Remove copy() calls on immutable integers - Replace typing imports with built-in generics --- .github/workflows/benchmark.yml | 2 +- .github/workflows/ci.yml | 10 +-- .github/workflows/nps-benchmark.yml | 2 +- .github/workflows/release.yml | 2 +- moonfish/config.py | 5 +- moonfish/engines/alpha_beta.py | 87 ++++++++++++------------- moonfish/engines/l1p_alpha_beta.py | 8 +-- moonfish/engines/l2p_alpha_beta.py | 13 ++-- moonfish/engines/lazy_smp.py | 14 ++--- moonfish/psqt.py | 98 +++++++++-------------------- pyproject.toml | 2 +- 11 files changed, 101 insertions(+), 142 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 30d54b8..0c118c7 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -86,7 +86,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..6892667 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,7 +31,7 @@ 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: | @@ -72,7 +72,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dev requirements run: | @@ -100,7 +100,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dev requirements run: | @@ -128,7 +128,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dev requirements run: | diff --git a/.github/workflows/nps-benchmark.yml b/.github/workflows/nps-benchmark.yml index 3959bd7..d078027 100644 --- a/.github/workflows/nps-benchmark.yml +++ b/.github/workflows/nps-benchmark.yml @@ -32,7 +32,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..c35a22f 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -1,18 +1,21 @@ -from copy import copy from multiprocessing.managers import DictProxy -from typing import Dict, Optional, Tuple import chess.syzygy from chess import Board, Move + from moonfish.config import Config from moonfish.engines.random import choice 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 +56,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 +121,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 +149,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 +175,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 +207,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 +227,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 +243,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 +260,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 +268,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 +316,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..b50de4d 100644 --- a/moonfish/engines/l1p_alpha_beta.py +++ b/moonfish/engines/l1p_alpha_beta.py @@ -1,7 +1,7 @@ -from copy import copy -from multiprocessing import cpu_count, Manager, Pool +from multiprocessing import Manager, Pool, cpu_count from chess import Board, Move + from moonfish.engines.alpha_beta import AlphaBeta @@ -25,8 +25,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..3a23d8f 100644 --- a/moonfish/engines/l2p_alpha_beta.py +++ b/moonfish/engines/l2p_alpha_beta.py @@ -1,11 +1,10 @@ from collections import defaultdict -from copy import copy from functools import partial -from multiprocessing import cpu_count, Manager, Pool +from multiprocessing import Manager, Pool, cpu_count from multiprocessing.managers import DictProxy -from typing import List, Tuple from chess import Board, Move + from moonfish.config import Config from moonfish.engines.alpha_beta import AlphaBeta @@ -32,7 +31,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 +47,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 +68,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 +100,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..a4cbab2 100644 --- a/moonfish/engines/lazy_smp.py +++ b/moonfish/engines/lazy_smp.py @@ -1,8 +1,8 @@ -from copy import copy -from multiprocessing import cpu_count, Manager, Pool +from multiprocessing import Manager, Pool, cpu_count from chess import Board, Move -from moonfish.engines.alpha_beta import AlphaBeta + +from moonfish.engines.alpha_beta import INF, NEG_INF, AlphaBeta class LazySMP(AlphaBeta): @@ -20,7 +20,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 +31,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/psqt.py b/moonfish/psqt.py index e2c1cb5..4acb8e3 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,10 @@ -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 = (None, MG_PAWN, MG_KNIGHT, MG_BISHOP, MG_ROOK, MG_QUEEN, MG_KING) + +EG_PESTO = (None, EG_PAWN, EG_KNIGHT, EG_BISHOP, EG_ROOK, EG_QUEEN, EG_KING) ############ # Tapered Evaluation: https://www.chessprogramming.org/Tapered_Eval @@ -202,7 +173,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 +236,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 +261,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"}, From 1b657d02f434695cb88f07193d01abb7f37e9652 Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sun, 15 Feb 2026 20:21:29 -0800 Subject: [PATCH 5/9] Fix --depth CLI flag being ignored in bench mode run_bench() was hardcoded to depth=5, ignoring config.negamax_depth from the CLI. Now passes the user-specified depth through. --- moonfish/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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, ): """ From 52f92e8f1f4717a905441942114e34fe48f2fea2 Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sun, 15 Feb 2026 21:54:03 -0800 Subject: [PATCH 6/9] Fix CI failures: formatting, mypy errors, and install - Fix import sorting in engine files (alphabetical order) - Fix mypy errors in psqt.py by replacing None sentinel with empty list in PESTO tables so element type is consistently list[int] - Fix CI install: use `uv pip install -e .` directly instead of `make install` which creates a venv that conflicts with UV_SYSTEM_PYTHON=1 on macOS (packages installed to framework Python while tests run setup-python Python) - Remove bash -l login shell from test steps to avoid PATH issues --- .github/workflows/ci.yml | 19 ++++++------------- moonfish/engines/l1p_alpha_beta.py | 2 +- moonfish/engines/l2p_alpha_beta.py | 2 +- moonfish/engines/lazy_smp.py | 4 ++-- moonfish/psqt.py | 4 ++-- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6892667..ec9a0ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,22 +34,18 @@ jobs: 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 @@ -75,9 +71,8 @@ jobs: python-version: '3.12' - name: Install dev requirements - run: | - make install - + run: uv pip install -e . + - name: Run ufmt run: ufmt check moonfish tests @@ -103,9 +98,8 @@ jobs: python-version: '3.12' - name: Install dev requirements - run: | - make install - + run: uv pip install -e . + - name: Run Flake8 run: flake8 moonfish tests @@ -131,8 +125,7 @@ jobs: 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/moonfish/engines/l1p_alpha_beta.py b/moonfish/engines/l1p_alpha_beta.py index b50de4d..a842b75 100644 --- a/moonfish/engines/l1p_alpha_beta.py +++ b/moonfish/engines/l1p_alpha_beta.py @@ -1,4 +1,4 @@ -from multiprocessing import Manager, Pool, cpu_count +from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move diff --git a/moonfish/engines/l2p_alpha_beta.py b/moonfish/engines/l2p_alpha_beta.py index 3a23d8f..512d46d 100644 --- a/moonfish/engines/l2p_alpha_beta.py +++ b/moonfish/engines/l2p_alpha_beta.py @@ -1,6 +1,6 @@ from collections import defaultdict from functools import partial -from multiprocessing import Manager, Pool, cpu_count +from multiprocessing import cpu_count, Manager, Pool from multiprocessing.managers import DictProxy from chess import Board, Move diff --git a/moonfish/engines/lazy_smp.py b/moonfish/engines/lazy_smp.py index a4cbab2..c1a504b 100644 --- a/moonfish/engines/lazy_smp.py +++ b/moonfish/engines/lazy_smp.py @@ -1,8 +1,8 @@ -from multiprocessing import Manager, Pool, cpu_count +from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move -from moonfish.engines.alpha_beta import INF, NEG_INF, AlphaBeta +from moonfish.engines.alpha_beta import AlphaBeta, INF, NEG_INF class LazySMP(AlphaBeta): diff --git a/moonfish/psqt.py b/moonfish/psqt.py index 4acb8e3..b72b338 100644 --- a/moonfish/psqt.py +++ b/moonfish/psqt.py @@ -137,9 +137,9 @@ # fmt: on # Indexed by piece type (0=unused, 1=PAWN, 2=KNIGHT, ..., 6=KING) -MG_PESTO = (None, MG_PAWN, MG_KNIGHT, MG_BISHOP, MG_ROOK, MG_QUEEN, MG_KING) +MG_PESTO: tuple[list[int], ...] = ([], MG_PAWN, MG_KNIGHT, MG_BISHOP, MG_ROOK, MG_QUEEN, MG_KING) -EG_PESTO = (None, EG_PAWN, EG_KNIGHT, EG_BISHOP, EG_ROOK, EG_QUEEN, EG_KING) +EG_PESTO: tuple[list[int], ...] = ([], EG_PAWN, EG_KNIGHT, EG_BISHOP, EG_ROOK, EG_QUEEN, EG_KING) ############ # Tapered Evaluation: https://www.chessprogramming.org/Tapered_Eval From 1c9b76d502d0ac8e9ee764ca97d2653379be5ee7 Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sun, 15 Feb 2026 21:56:45 -0800 Subject: [PATCH 7/9] Fix remaining import sorting issues Remove blank lines between third-party and local imports (usort treats chess and moonfish as the same category). Reformat PESTO tuple definitions per black line length. --- moonfish/engines/alpha_beta.py | 1 - moonfish/engines/l1p_alpha_beta.py | 1 - moonfish/engines/l2p_alpha_beta.py | 1 - moonfish/engines/lazy_smp.py | 1 - moonfish/psqt.py | 20 ++++++++++++++++++-- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index c35a22f..bc97538 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -2,7 +2,6 @@ import chess.syzygy from chess import Board, Move - from moonfish.config import Config from moonfish.engines.random import choice from moonfish.move_ordering import organize_moves, organize_moves_quiescence diff --git a/moonfish/engines/l1p_alpha_beta.py b/moonfish/engines/l1p_alpha_beta.py index a842b75..f2620e8 100644 --- a/moonfish/engines/l1p_alpha_beta.py +++ b/moonfish/engines/l1p_alpha_beta.py @@ -1,7 +1,6 @@ from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move - from moonfish.engines.alpha_beta import AlphaBeta diff --git a/moonfish/engines/l2p_alpha_beta.py b/moonfish/engines/l2p_alpha_beta.py index 512d46d..010b8f8 100644 --- a/moonfish/engines/l2p_alpha_beta.py +++ b/moonfish/engines/l2p_alpha_beta.py @@ -4,7 +4,6 @@ from multiprocessing.managers import DictProxy from chess import Board, Move - from moonfish.config import Config from moonfish.engines.alpha_beta import AlphaBeta diff --git a/moonfish/engines/lazy_smp.py b/moonfish/engines/lazy_smp.py index c1a504b..315c282 100644 --- a/moonfish/engines/lazy_smp.py +++ b/moonfish/engines/lazy_smp.py @@ -1,7 +1,6 @@ from multiprocessing import cpu_count, Manager, Pool from chess import Board, Move - from moonfish.engines.alpha_beta import AlphaBeta, INF, NEG_INF diff --git a/moonfish/psqt.py b/moonfish/psqt.py index b72b338..c122226 100644 --- a/moonfish/psqt.py +++ b/moonfish/psqt.py @@ -137,9 +137,25 @@ # fmt: on # Indexed by piece type (0=unused, 1=PAWN, 2=KNIGHT, ..., 6=KING) -MG_PESTO: tuple[list[int], ...] = ([], MG_PAWN, MG_KNIGHT, MG_BISHOP, MG_ROOK, MG_QUEEN, MG_KING) +MG_PESTO: tuple[list[int], ...] = ( + [], + MG_PAWN, + MG_KNIGHT, + MG_BISHOP, + MG_ROOK, + MG_QUEEN, + MG_KING, +) -EG_PESTO: tuple[list[int], ...] = ([], EG_PAWN, EG_KNIGHT, EG_BISHOP, EG_ROOK, EG_QUEEN, EG_KING) +EG_PESTO: tuple[list[int], ...] = ( + [], + EG_PAWN, + EG_KNIGHT, + EG_BISHOP, + EG_ROOK, + EG_QUEEN, + EG_KING, +) ############ # Tapered Evaluation: https://www.chessprogramming.org/Tapered_Eval From 0e104b6881a2f3a592936c32da568461d20e9881 Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sun, 15 Feb 2026 22:21:01 -0800 Subject: [PATCH 8/9] Use [0]*64 sentinel in PESTO tables to match piece value convention --- moonfish/psqt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/moonfish/psqt.py b/moonfish/psqt.py index c122226..1aed717 100644 --- a/moonfish/psqt.py +++ b/moonfish/psqt.py @@ -138,7 +138,7 @@ # Indexed by piece type (0=unused, 1=PAWN, 2=KNIGHT, ..., 6=KING) MG_PESTO: tuple[list[int], ...] = ( - [], + [0] * 64, MG_PAWN, MG_KNIGHT, MG_BISHOP, @@ -148,7 +148,7 @@ ) EG_PESTO: tuple[list[int], ...] = ( - [], + [0] * 64, EG_PAWN, EG_KNIGHT, EG_BISHOP, From 7a435bf0d890cd98bf47dca040f0fe9e719bb1a0 Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Sun, 15 Feb 2026 22:22:37 -0800 Subject: [PATCH 9/9] Simplify PESTO sentinel to [0] --- moonfish/psqt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/moonfish/psqt.py b/moonfish/psqt.py index 1aed717..fb43714 100644 --- a/moonfish/psqt.py +++ b/moonfish/psqt.py @@ -138,7 +138,7 @@ # Indexed by piece type (0=unused, 1=PAWN, 2=KNIGHT, ..., 6=KING) MG_PESTO: tuple[list[int], ...] = ( - [0] * 64, + [0], MG_PAWN, MG_KNIGHT, MG_BISHOP, @@ -148,7 +148,7 @@ ) EG_PESTO: tuple[list[int], ...] = ( - [0] * 64, + [0], EG_PAWN, EG_KNIGHT, EG_BISHOP,