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
85 changes: 58 additions & 27 deletions moonfish/engines/alpha_beta.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
from moonfish.move_ordering import organize_moves, organize_moves_quiescence
from moonfish.psqt import board_evaluation, count_pieces

CACHE_KEY = dict[
tuple[object, int, bool, float, float], tuple[float | int, Move | None]
]
# Transposition table entry types
TT_EXACT = 0 # Exact score (PV node)
TT_LOWER = 1 # Score is a lower bound (beta cutoff / fail-high)
TT_UPPER = 2 # Score is an upper bound (fail-low / all-node)

# TT entry: (score, best_move, depth, entry_type)
TT_TYPE = dict[object, tuple[float | int, Move | None, int, int]]

INF = float("inf")
NEG_INF = float("-inf")
Expand All @@ -18,7 +22,8 @@

class AlphaBeta:
"""
A class that implements alpha-beta search algorithm.
A class that implements alpha-beta search algorithm with iterative deepening
and a proper transposition table.
"""

def __init__(self, config: Config):
Expand Down Expand Up @@ -173,7 +178,7 @@ def negamax(
board: Board,
depth: int,
null_move: bool,
cache: DictProxy | CACHE_KEY,
cache: DictProxy | TT_TYPE,
alpha: float = NEG_INF,
beta: float = INF,
) -> tuple[float | int, Move | None]:
Expand All @@ -196,30 +201,38 @@ def negamax(
- board: chess board state
- depth: how many depths we want to calculate for this board
- null_move: if we want to use null move pruning
- cache: a shared hash table to store the best
move for each board state and depth.
- alpha: best score for the maximizing player (best choice
(highest value) we've found along the path for max)
- beta: best score for the minimizing player (best choice
(lowest value) we've found along the path for min).
- cache: transposition table storing (score, best_move, depth, entry_type)
- alpha: best score for the maximizing player
- beta: best score for the minimizing player

Returns:
- best_score, best_move: returns best move that it found and its value.
"""
cache_key = (board._transposition_key(), depth, null_move, alpha, beta)
tt_key = board._transposition_key()
original_alpha = alpha

self.nodes += 1

# check if board was already evaluated
if cache_key in cache:
return cache[cache_key]
# Probe transposition table
tt_move = None
if tt_key in cache:
tt_score, tt_move, tt_depth, tt_type = cache[tt_key]
if tt_depth >= depth:
if tt_type == TT_EXACT:
return tt_score, tt_move
elif tt_type == TT_LOWER:
alpha = max(alpha, tt_score)
elif tt_type == TT_UPPER:
beta = min(beta, tt_score)
if alpha >= beta:
return tt_score, tt_move

if board.is_checkmate():
cache[cache_key] = (-self.config.checkmate_score, None)
cache[tt_key] = (-self.config.checkmate_score, None, depth, TT_EXACT)
return (-self.config.checkmate_score, None)

if board.is_stalemate():
cache[cache_key] = (0, None)
cache[tt_key] = (0, None, depth, TT_EXACT)
return (0, None)
Comment on lines 230 to 236
Copy link

Choose a reason for hiding this comment

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

missing draw detection: should check is_repetition(), is_fifty_moves(), and is_insufficient_material() before checkmate/stalemate (like in quiescence_search:114)


# recursion base case
Expand All @@ -231,7 +244,7 @@ def negamax(
alpha,
beta,
)
cache[cache_key] = (board_score, None)
cache[tt_key] = (board_score, None, 0, TT_EXACT)
return board_score, None

# null move prunning
Expand All @@ -253,14 +266,16 @@ def negamax(
)[0]
board.pop()
if board_score >= beta:
cache[cache_key] = (beta, None)
cache[tt_key] = (beta, None, depth, TT_LOWER)
return beta, None

best_move = None

# initializing best_score
best_score = NEG_INF
moves = organize_moves(board)

# Get move list, placing TT move first if available
moves = organize_moves(board, tt_move=tt_move)

for move in moves:
# make the move
Expand All @@ -284,7 +299,7 @@ def negamax(

# beta-cutoff
if board_score >= beta:
cache[cache_key] = (board_score, move)
cache[tt_key] = (board_score, move, depth, TT_LOWER)
return board_score, move

# update best move
Expand All @@ -305,17 +320,33 @@ def negamax(
if not best_move:
best_move = self.random_move(board)

# Determine TT entry type based on the score relative to original bounds
if best_score <= original_alpha:
tt_type = TT_UPPER # Failed low: score is an upper bound
elif best_score >= beta:
Copy link

Choose a reason for hiding this comment

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

unreachable: if best_score >= beta, would have returned at line 302

Suggested change
elif best_score >= beta:
elif best_score >= beta:
# This branch is unreachable - beta cutoffs return early at line 302
tt_type = TT_LOWER # Failed high: score is a lower bound

tt_type = TT_LOWER # Failed high: score is a lower bound
else:
tt_type = TT_EXACT # Exact score

# save result before returning
cache[cache_key] = (best_score, best_move)
cache[tt_key] = (best_score, best_move, depth, tt_type)
return best_score, best_move

def search_move(self, board: Board) -> Move:
self.nodes = 0
# create shared cache
cache: CACHE_KEY = {}
# create shared transposition table (persists across iterations)
cache: TT_TYPE = {}

best_move = None

# Iterative deepening: search from depth 1 up to target depth
# Each iteration populates the TT, which helps order moves in the next
for d in range(1, self.config.negamax_depth + 1):
score, move = self.negamax(
board, d, self.config.null_move, cache
)
if move is not None:
best_move = move

best_move = self.negamax(
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
15 changes: 4 additions & 11 deletions moonfish/engines/lazy_smp.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from multiprocessing import cpu_count, Manager, Pool

from chess import Board, Move
from moonfish.engines.alpha_beta import AlphaBeta, INF, NEG_INF
from moonfish.engines.alpha_beta import AlphaBeta


class LazySMP(AlphaBeta):
Expand All @@ -27,13 +27,6 @@ def search_move(self, board: Board) -> Move:
],
)

# return best move for our original board
return shared_cache[
(
board._transposition_key(),
self.config.negamax_depth,
self.config.null_move,
NEG_INF,
INF,
)
][1]
# return best move from the transposition table
tt_key = board._transposition_key()
return shared_cache[tt_key][1]
17 changes: 13 additions & 4 deletions moonfish/move_ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase


def organize_moves(board: Board):
def organize_moves(board: Board, tt_move: "Move | None" = 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).
The TT move (from the transposition table) is placed first,
then captures, then non-captures.

Arguments:
- board: chess board state
- tt_move: best move from transposition table (searched first)

Returns:
- legal_moves: list of all the possible moves for the current player.
Expand All @@ -22,14 +23,22 @@ def organize_moves(board: Board):
captures = []

for move in board.legal_moves:
# Skip TT move — it will be placed at the front
if tt_move is not None and move == tt_move:
continue
if board.is_capture(move):
captures.append(move)
else:
non_captures.append(move)

random.shuffle(captures)
random.shuffle(non_captures)
return captures + non_captures

result = captures + non_captures
# Place TT move first if it's a legal move
if tt_move is not None:
result.insert(0, tt_move)
Comment on lines +39 to +40
Copy link

Choose a reason for hiding this comment

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

TT move legality not checked: hash collision could insert illegal move. Verify tt_move in board.legal_moves before inserting

return result


def is_tactical_move(board: Board, move: Move) -> bool:
Expand Down
Loading