From c4c327d8d020ea13db757b06b27b560936e6b4ac Mon Sep 17 00:00:00 2001 From: luccabb Date: Mon, 19 Jan 2026 19:55:20 -0800 Subject: [PATCH 1/2] [4/9] Improve quiescence search Enhances quiescence search with better handling of special positions: **Draw Detection:** - Check fifty-move rule and insufficient material - Detect repetitions when making moves (return 0 for draws) **Check Handling:** - When in check, search ALL legal moves (evasions), not just captures - Can't use stand-pat for pruning when in check (position is unstable) - Separate best_score tracking for in-check vs normal positions **Tactical Move Detection:** - Add `is_tactical_move()` helper function - Tactical moves: captures, promotions, and checks - Previously included quiet pawn pushes (is_zeroing), now more precise **Move Ordering:** - Update `organize_moves_quiescence()` to use is_tactical_move - Sort tactical moves by MVV-LVA Co-Authored-By: Claude Opus 4.5 --- moonfish/engines/alpha_beta.py | 68 ++++++++++++++++++++++++---------- moonfish/move_ordering.py | 31 +++++++++++++--- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index f39a515..94b2d42 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -104,39 +104,69 @@ def quiescence_search( Returns: - best_score: returns best move's score. """ - if board.is_stalemate(): - return 0 + in_check = board.is_check() if board.is_checkmate(): return -self.config.checkmate_score + if board.is_stalemate(): + return 0 + + # Draw detection: fifty-move rule, insufficient material + # Note: Repetition is checked after making moves, not here + if board.is_fifty_moves() or board.is_insufficient_material(): + return 0 + stand_pat = self.eval_board(board) - # recursion base case - if depth == 0: - return stand_pat + # When in check, we can't use stand-pat for pruning (position is unstable) + # We must search all evasions. However, still respect depth limit. + if in_check: + # In check: search all evasions, but don't use stand-pat for cutoffs + if depth <= 0: + # At depth limit while in check: return evaluation + # (not ideal but prevents infinite recursion) + return stand_pat + + best_score = float("-inf") + moves = list(board.legal_moves) # All evasions + else: + # Not in check: normal quiescence behavior + # recursion base case + if depth <= 0: + return stand_pat - # beta-cutoff - if stand_pat >= beta: - return beta + # beta-cutoff: position is already good enough + if stand_pat >= beta: + return beta - # alpha update - alpha = max(alpha, stand_pat) + # Use stand-pat as baseline (we can always choose not to capture) + best_score = stand_pat + alpha = max(alpha, stand_pat) - # get moves for quiescence search - moves = organize_moves_quiescence(board) + # Only tactical moves when not in check + moves = organize_moves_quiescence(board) for move in moves: # make move and get score board.push(move) - score = -self.quiescence_search( - board=board, - depth=depth - 1, - alpha=-beta, - beta=-alpha, - ) + + # Check if this move leads to a repetition (draw) + if board.is_repetition(2): + score: float = 0 # Draw score + else: + score = -self.quiescence_search( + board=board, + depth=depth - 1, + alpha=-beta, + beta=-alpha, + ) + board.pop() + if score > best_score: + best_score = score + # beta-cutoff if score >= beta: return beta @@ -144,7 +174,7 @@ def quiescence_search( # alpha-update alpha = max(alpha, score) - return alpha + return best_score def negamax( self, diff --git a/moonfish/move_ordering.py b/moonfish/move_ordering.py index f210e9c..630ec81 100644 --- a/moonfish/move_ordering.py +++ b/moonfish/move_ordering.py @@ -32,26 +32,45 @@ def organize_moves(board: Board): return captures + non_captures +def is_tactical_move(board: Board, move: Move) -> bool: + """ + Check if a move is tactical (should be searched in quiescence). + + Tactical moves are: + - Captures (change material) + - Promotions (significant material gain) + - Moves that give check (forcing) + """ + return ( + board.is_capture(move) or move.promotion is not None or board.gives_check(move) + ) + + def organize_moves_quiescence(board: Board): """ This function receives a board and it returns a list of all the possible moves for the current player, sorted by importance. + Only returns tactical moves: captures, promotions, and checks. + Arguments: - board: chess board state Returns: - - moves: list of all the possible moves for the current player sorted based on importance. + - moves: list of tactical moves sorted by importance (MVV-LVA). """ phase = get_phase(board) - # filter only important moves for quiescence search - captures = filter( - lambda move: board.is_zeroing(move) or board.gives_check(move), + + # Filter only tactical moves for quiescence search + # (captures, promotions, checks - NOT quiet pawn pushes) + tactical_moves = filter( + lambda move: is_tactical_move(board, move), board.legal_moves, ) - # sort moves by importance + + # Sort moves by importance using MVV-LVA moves = sorted( - captures, + tactical_moves, key=lambda move: mvv_lva(board, move, phase), reverse=(board.turn == BLACK), ) From 98ee9e178b069ab757cdc31b05dd893d40d75519 Mon Sep 17 00:00:00 2001 From: luccabb Date: Mon, 19 Jan 2026 20:20:45 -0800 Subject: [PATCH 2/2] [5/9] Add MVV-LVA capture ordering and killer moves heuristic Improves move ordering for better pruning: **MVV-LVA (Most Valuable Victim - Least Valuable Attacker):** - Sort captures by MVV-LVA score in `organize_moves()` - Best captures (e.g., pawn takes queen) searched first - Increases beta cutoff rate for better pruning **Killer Moves Heuristic:** - Track quiet moves that cause beta cutoffs (2 per ply) - Add `killers` parameter to `organize_moves()` and `negamax()` - Killer moves searched after captures, before other quiet moves - Killers stored in a table indexed by ply depth **Move Ordering Priority:** 1. Captures (sorted by MVV-LVA) 2. Killer moves (quiet moves that caused cutoffs at this ply) 3. Other quiet moves (shuffled for variety) Co-Authored-By: Claude Opus 4.5 --- moonfish/engines/alpha_beta.py | 35 ++++++++++++++++++++++++++++++++-- moonfish/move_ordering.py | 28 +++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/moonfish/engines/alpha_beta.py b/moonfish/engines/alpha_beta.py index 94b2d42..c6784c7 100644 --- a/moonfish/engines/alpha_beta.py +++ b/moonfish/engines/alpha_beta.py @@ -184,6 +184,8 @@ def negamax( cache: DictProxy | CACHE_TYPE, alpha: float = float("-inf"), beta: float = float("inf"), + ply: int = 0, + killers: Optional[list] = None, ) -> Tuple[float | int, Optional[Move]]: """ This functions receives a board, depth and a player; and it returns @@ -275,6 +277,8 @@ def negamax( cache=cache, alpha=-beta, beta=-beta + 1, + ply=ply + 1, + killers=killers, )[0] board.pop() if board_score >= beta: @@ -284,9 +288,12 @@ def negamax( best_move = None best_score = float("-inf") - moves = organize_moves(board) + ply_killers = killers[ply] if killers and ply < len(killers) else None + moves = organize_moves(board, ply_killers) for move in moves: + is_capture = board.is_capture(move) + # make the move board.push(move) @@ -297,6 +304,8 @@ def negamax( cache=cache, alpha=-beta, beta=-alpha, + ply=ply + 1, + killers=killers, )[0] if board_score > self.config.checkmate_threshold: board_score -= 1 @@ -313,6 +322,18 @@ def negamax( # beta-cutoff: opponent won't allow this position if best_score >= beta: + # Update killer moves for quiet moves that cause beta cutoff + # Add to killers if not already there (keep 2 killers per ply) + if ( + killers + and not is_capture + and ply < len(killers) + and move not in killers[ply] + ): + killers[ply].insert(0, move) + if len(killers[ply]) > 2: + killers[ply].pop() + # LOWER_BOUND: true score is at least best_score cache[cache_key] = (best_score, best_move, Bound.LOWER_BOUND, depth) return best_score, best_move @@ -339,8 +360,18 @@ def search_move(self, board: Board) -> Move: # create shared cache cache: CACHE_TYPE = {} + # Killer moves table: 2 killers per ply + # Max ply is roughly target_depth + quiescence_depth + some buffer + max_ply = self.config.negamax_depth + self.config.quiescence_search_depth + 10 + killers: list = [[] for _ in range(max_ply)] + best_move = self.negamax( - board, copy(self.config.negamax_depth), self.config.null_move, cache + board, + copy(self.config.negamax_depth), + self.config.null_move, + cache, + ply=0, + killers=killers, )[1] assert best_move is not None, "Best move from root should not be None" return best_move diff --git a/moonfish/move_ordering.py b/moonfish/move_ordering.py index 630ec81..546a1d9 100644 --- a/moonfish/move_ordering.py +++ b/moonfish/move_ordering.py @@ -1,25 +1,28 @@ import random +from typing import List, Optional from chess import BLACK, Board, Move from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase -def organize_moves(board: Board): +def organize_moves(board: Board, killers: Optional[List[Move]] = None): """ 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). + + Order: captures (sorted by MVV-LVA) -> killer moves -> other quiet moves Arguments: - board: chess board state + - killers: optional list of killer moves for this ply Returns: - legal_moves: list of all the possible moves for the current player. """ non_captures = [] captures = [] + phase = get_phase(board) for move in board.legal_moves: if board.is_capture(move): @@ -27,8 +30,25 @@ def organize_moves(board: Board): else: non_captures.append(move) - random.shuffle(captures) + # Sort captures by MVV-LVA (best captures first) + captures.sort( + key=lambda move: mvv_lva(board, move, phase), reverse=(board.turn != BLACK) + ) + + # Shuffle non-captures for variety, then we'll extract killers random.shuffle(non_captures) + + # Extract killer moves from non-captures and put them first + if killers: + killer_moves = [] + remaining_quiet = [] + for move in non_captures: + if move in killers: + killer_moves.append(move) + else: + remaining_quiet.append(move) + non_captures = killer_moves + remaining_quiet + return captures + non_captures