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
103 changes: 82 additions & 21 deletions moonfish/engines/alpha_beta.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,47 +104,77 @@ 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

# alpha-update
alpha = max(alpha, score)

return alpha
return best_score

def negamax(
self,
Expand All @@ -154,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
Expand Down Expand Up @@ -245,6 +277,8 @@ def negamax(
cache=cache,
alpha=-beta,
beta=-beta + 1,
ply=ply + 1,
killers=killers,
)[0]
board.pop()
if board_score >= beta:
Expand All @@ -254,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)

Expand All @@ -267,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
Expand All @@ -283,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
Expand All @@ -309,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
59 changes: 49 additions & 10 deletions moonfish/move_ordering.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,96 @@
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):
captures.append(move)
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


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),
)
Expand Down