Skip to content

Commit 944a0e0

Browse files
luccabbclaude
andcommitted
[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 <[email protected]>
1 parent df8a32b commit 944a0e0

File tree

2 files changed

+57
-6
lines changed

2 files changed

+57
-6
lines changed

moonfish/engines/alpha_beta.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ def negamax(
184184
cache: DictProxy | CACHE_TYPE,
185185
alpha: float = float("-inf"),
186186
beta: float = float("inf"),
187+
ply: int = 0,
188+
killers: Optional[list] = None,
187189
) -> Tuple[float | int, Optional[Move]]:
188190
"""
189191
This functions receives a board, depth and a player; and it returns
@@ -275,6 +277,8 @@ def negamax(
275277
cache=cache,
276278
alpha=-beta,
277279
beta=-beta + 1,
280+
ply=ply + 1,
281+
killers=killers,
278282
)[0]
279283
board.pop()
280284
if board_score >= beta:
@@ -284,9 +288,12 @@ def negamax(
284288

285289
best_move = None
286290
best_score = float("-inf")
287-
moves = organize_moves(board)
291+
ply_killers = killers[ply] if killers and ply < len(killers) else None
292+
moves = organize_moves(board, ply_killers)
288293

289294
for move in moves:
295+
is_capture = board.is_capture(move)
296+
290297
# make the move
291298
board.push(move)
292299

@@ -297,6 +304,8 @@ def negamax(
297304
cache=cache,
298305
alpha=-beta,
299306
beta=-alpha,
307+
ply=ply + 1,
308+
killers=killers,
300309
)[0]
301310
if board_score > self.config.checkmate_threshold:
302311
board_score -= 1
@@ -313,6 +322,18 @@ def negamax(
313322

314323
# beta-cutoff: opponent won't allow this position
315324
if best_score >= beta:
325+
# Update killer moves for quiet moves that cause beta cutoff
326+
# Add to killers if not already there (keep 2 killers per ply)
327+
if (
328+
killers
329+
and not is_capture
330+
and ply < len(killers)
331+
and move not in killers[ply]
332+
):
333+
killers[ply].insert(0, move)
334+
if len(killers[ply]) > 2:
335+
killers[ply].pop()
336+
316337
# LOWER_BOUND: true score is at least best_score
317338
cache[cache_key] = (best_score, best_move, Bound.LOWER_BOUND, depth)
318339
return best_score, best_move
@@ -339,8 +360,18 @@ def search_move(self, board: Board) -> Move:
339360
# create shared cache
340361
cache: CACHE_TYPE = {}
341362

363+
# Killer moves table: 2 killers per ply
364+
# Max ply is roughly target_depth + quiescence_depth + some buffer
365+
max_ply = self.config.negamax_depth + self.config.quiescence_search_depth + 10
366+
killers: list = [[] for _ in range(max_ply)]
367+
342368
best_move = self.negamax(
343-
board, copy(self.config.negamax_depth), self.config.null_move, cache
369+
board,
370+
copy(self.config.negamax_depth),
371+
self.config.null_move,
372+
cache,
373+
ply=0,
374+
killers=killers,
344375
)[1]
345376
assert best_move is not None, "Best move from root should not be None"
346377
return best_move

moonfish/move_ordering.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,54 @@
11
import random
2+
from typing import List, Optional
23

34
from chess import BLACK, Board, Move
45

56
from moonfish.psqt import evaluate_capture, evaluate_piece, get_phase
67

78

8-
def organize_moves(board: Board):
9+
def organize_moves(board: Board, killers: Optional[List[Move]] = None):
910
"""
1011
This function receives a board and it returns a list of all the
1112
possible moves for the current player, sorted by importance.
12-
It sends capturing moves at the starting positions in
13-
the array (to try to increase pruning and do so earlier).
13+
14+
Order: captures (sorted by MVV-LVA) -> killer moves -> other quiet moves
1415
1516
Arguments:
1617
- board: chess board state
18+
- killers: optional list of killer moves for this ply
1719
1820
Returns:
1921
- legal_moves: list of all the possible moves for the current player.
2022
"""
2123
non_captures = []
2224
captures = []
25+
phase = get_phase(board)
2326

2427
for move in board.legal_moves:
2528
if board.is_capture(move):
2629
captures.append(move)
2730
else:
2831
non_captures.append(move)
2932

30-
random.shuffle(captures)
33+
# Sort captures by MVV-LVA (best captures first)
34+
captures.sort(
35+
key=lambda move: mvv_lva(board, move, phase), reverse=(board.turn != BLACK)
36+
)
37+
38+
# Shuffle non-captures for variety, then we'll extract killers
3139
random.shuffle(non_captures)
40+
41+
# Extract killer moves from non-captures and put them first
42+
if killers:
43+
killer_moves = []
44+
remaining_quiet = []
45+
for move in non_captures:
46+
if move in killers:
47+
killer_moves.append(move)
48+
else:
49+
remaining_quiet.append(move)
50+
non_captures = killer_moves + remaining_quiet
51+
3252
return captures + non_captures
3353

3454

0 commit comments

Comments
 (0)