Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions moonfish/engines/alpha_beta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Comment on lines +280 to +284
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded check extensions risk stack overflow

When a move gives check, the extension keeps depth at the same level (depth - 1 + 1 = depth). In positions with perpetual or long check sequences, this causes the search to never reduce depth, potentially recursing until Python's stack limit is hit.

There is no maximum-depth guard in negamax, and the repetition detection (is_repetition) only exists in quiescence search — not here. While the transposition table might catch some repeated positions, its cache key includes (alpha, beta) which vary across different branches, so there's no guarantee of termination.

A common safeguard is to limit total extensions (e.g., track cumulative ply from root and cap extensions once a maximum ply is reached):

# Example: cap total search depth at 2x the nominal depth
extension = 1 if board.is_check() and (current_ply < 2 * self.config.negamax_depth) else 0

Alternatively, adding board.is_repetition(2) as a draw-return early in negamax (similar to what quiescence already does) would prevent infinite check cycles.

null_move,
cache,
-beta,
Expand Down
40 changes: 36 additions & 4 deletions moonfish/move_ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import MG_PIECE_VALUES

MG_PIECE_VALUES is imported but never used in this file — the new MVV-LVA logic uses _MVV_LVA_VALUES instead.

Suggested change
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
Expand All @@ -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).
Expand Down
Loading