From a57219613a628c8d90bea24185f7ed9e0117e37c Mon Sep 17 00:00:00 2001 From: Lucca Bertoncini Date: Mon, 16 Feb 2026 00:12:47 -0800 Subject: [PATCH] Add MVV-LVA capture ordering, check extensions, and delta pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three search improvements that compound together: 1. MVV-LVA capture ordering in main search: Replace random shuffle of captures with Most Valuable Victim - Least Valuable Attacker sorting. Uses fast integer lookups instead of PST-based evaluation. Promotions are also prioritized. 2. Check extensions: When a move puts the opponent in check, extend the search by 1 ply. This resolves tactical check sequences that would otherwise be cut off by the horizon effect. 3. Delta pruning in quiescence: If the stand-pat score plus the maximum possible capture gain (queen value + margin) is still below alpha, prune the entire quiescence node. Benchmark at depth 4: - Nodes: 4,760,507 → 3,382,748 (−29.0%) - NPS: 22,634 → 24,444 (+8.0%) - Total time: 210.32s → 138.38s (−34.2%) --- moonfish/engines/alpha_beta.py | 17 +++++++++++++-- moonfish/move_ordering.py | 40 ++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index bc97538..a5e08b7 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -5,7 +5,7 @@ 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 +from moonfish.psqt import MG_PIECE_VALUES, board_evaluation, count_pieces CACHE_KEY = dict[ tuple[object, int, bool, float, float], tuple[float | int, Move | None] @@ -15,6 +15,10 @@ NEG_INF = float("-inf") NULL_MOVE = Move.null() +# Maximum possible capture gain for delta pruning in quiescence +# Queen value is the largest possible single capture +DELTA_MARGIN = MG_PIECE_VALUES[5] + 200 # queen value + safety margin + class AlphaBeta: """ @@ -132,6 +136,11 @@ def quiescence_search( if stand_pat >= beta: return beta + # Delta pruning: if even the best possible capture can't improve alpha, + # there's no point searching further + if stand_pat + DELTA_MARGIN < alpha: + return alpha + # Use stand-pat as baseline (we can always choose not to capture) best_score = stand_pat alpha = max(alpha, stand_pat) @@ -266,9 +275,13 @@ def negamax( # make the move board.push(move) + # Check extension: if the move puts the opponent in check, + # extend the search by 1 ply to resolve the tactical situation + extension = 1 if board.is_check() else 0 + board_score = -self.negamax( board, - depth - 1, + depth - 1 + extension, null_move, cache, -beta, diff --git a/moonfish/move_ordering.py b/moonfish/move_ordering.py index 630ec81..9355d8c 100644 --- a/moonfish/move_ordering.py +++ b/moonfish/move_ordering.py @@ -2,15 +2,24 @@ from chess import BLACK, Board, Move -from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase +from moonfish.psqt import ( + MG_PIECE_VALUES, + evaluate_capture, + evaluate_piece, + get_phase, +) + +# Simple integer piece values for fast MVV-LVA ordering in main search +# Index by piece type: 0=None, 1=PAWN, 2=KNIGHT, 3=BISHOP, 4=ROOK, 5=QUEEN, 6=KING +_MVV_LVA_VALUES = (0, 100, 300, 300, 500, 900, 10000) def organize_moves(board: Board): """ This function receives a board and it returns a list of all the possible moves for the current player, sorted by importance. - It sends capturing moves at the starting positions in - the array (to try to increase pruning and do so earlier). + Captures are sorted by MVV-LVA (Most Valuable Victim - Least Valuable Attacker). + Promotions are prioritized. Non-captures are shuffled. Arguments: - board: chess board state @@ -24,14 +33,37 @@ def organize_moves(board: Board): for move in board.legal_moves: if board.is_capture(move): captures.append(move) + elif move.promotion is not None: + # Promotions without capture — prioritize them + captures.append(move) else: non_captures.append(move) - random.shuffle(captures) + # Sort captures by MVV-LVA: highest victim value first, then lowest attacker + captures.sort(key=lambda m: _mvv_lva_score(board, m), reverse=True) random.shuffle(non_captures) return captures + non_captures +def _mvv_lva_score(board: Board, move: Move) -> int: + """Fast integer MVV-LVA score for move ordering.""" + if move.promotion is not None: + # Promotions get high score; queen promotion highest + return _MVV_LVA_VALUES[move.promotion] + 10000 + + # Victim value - attacker value (we want high victim, low attacker) + attacker = board.piece_type_at(move.from_square) + victim = board.piece_type_at(move.to_square) + + if victim is None: + # En passant + return _MVV_LVA_VALUES[1] - _MVV_LVA_VALUES[1] # pawn captures pawn + + attacker_val = _MVV_LVA_VALUES[attacker] if attacker else 0 + victim_val = _MVV_LVA_VALUES[victim] if victim else 0 + return victim_val * 10 - attacker_val + + def is_tactical_move(board: Board, move: Move) -> bool: """ Check if a move is tactical (should be searched in quiescence).