Skip to content

Commit f205ab8

Browse files
luccabbclaude
andcommitted
[6/9] Add iterative deepening with aspiration windows and TT move ordering
Implements iterative deepening for better move ordering and future time management: **Iterative Deepening:** - Search depth 1, then 2, then 3, ... up to target depth - Cache persists across all iterations (TT entries reused) - Killer moves persist across iterations - Best move from depth N-1 is tried first at depth N (via TT) **Aspiration Windows:** - After depth 1, use narrow window (±50 centipawns) around previous score - If search fails outside window, re-search with doubled window - Falls back to full window after 500cp expansion - Reduces nodes searched when score is stable **TT Move Ordering:** - Save best move from TT lookup even if score can't be used - Put TT move first in move list before searching - Significantly improves move ordering at all depths Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0b53062 commit f205ab8

1 file changed

Lines changed: 85 additions & 11 deletions

File tree

moonfish/engines/alpha_beta.py

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,15 @@ def negamax(
219219
"""
220220
original_alpha = alpha
221221
cache_key = chess.polyglot.zobrist_hash(board)
222+
tt_move = None # Best move from transposition table (for move ordering)
222223

223224
# Check transposition table
224225
if cache_key in cache:
225226
cached_score, cached_move, cached_bound, cached_depth = cache[cache_key]
226227

228+
# Save TT move for ordering even if we can't use the score
229+
tt_move = cached_move
230+
227231
# Only use score if cached search was at least as deep as we need
228232
# Use cached result if:
229233
# - EXACT: score is exact
@@ -292,6 +296,11 @@ def negamax(
292296
ply_killers = killers[ply] if killers and ply < len(killers) else None
293297
moves = organize_moves(board, ply_killers)
294298

299+
# Put TT move first if available (best move from previous search)
300+
if tt_move is not None and tt_move in moves:
301+
moves.remove(tt_move)
302+
moves.insert(0, tt_move)
303+
295304
for move in moves:
296305
is_capture = board.is_capture(move)
297306

@@ -358,21 +367,86 @@ def negamax(
358367
return best_score, best_move
359368

360369
def search_move(self, board: Board) -> Move:
361-
# create shared cache
370+
"""
371+
Search for the best move using iterative deepening with aspiration windows.
372+
373+
Iterative deepening searches depth 1, then 2, then 3, etc.
374+
This improves move ordering (TT move from depth N-1 is tried first at depth N)
375+
and allows for future time management (can stop early if time runs out).
376+
377+
Aspiration windows: after depth 1, use a narrow window around the previous
378+
score. If the search fails outside the window, re-search with a wider window.
379+
"""
380+
# Create shared cache - persists across all depths
362381
cache: CACHE_TYPE = {}
382+
best_move = None
383+
target_depth = self.config.negamax_depth
384+
prev_score = None
363385

364-
# Killer moves table: 2 killers per ply
386+
# Killer moves table: 2 killers per ply, persists across iterations
365387
# Max ply is roughly target_depth + quiescence_depth + some buffer
366-
max_ply = self.config.negamax_depth + self.config.quiescence_search_depth + 10
388+
max_ply = target_depth + self.config.quiescence_search_depth + 10
367389
killers: list = [[] for _ in range(max_ply)]
368390

369-
best_move = self.negamax(
370-
board,
371-
copy(self.config.negamax_depth),
372-
self.config.null_move,
373-
cache,
374-
ply=0,
375-
killers=killers,
376-
)[1]
391+
# Aspiration window parameters
392+
INITIAL_WINDOW = 50 # Initial window size (centipawns)
393+
394+
# Iterative deepening: search depth 1, 2, 3, ... up to target
395+
for depth in range(1, target_depth + 1):
396+
# Use aspiration windows after first iteration
397+
if prev_score is None or depth <= 1:
398+
# First iteration: full window
399+
alpha = float("-inf")
400+
beta = float("inf")
401+
else:
402+
# Subsequent iterations: narrow window around previous score
403+
alpha = prev_score - INITIAL_WINDOW
404+
beta = prev_score + INITIAL_WINDOW
405+
406+
# Aspiration window loop: widen window on fail high/low
407+
window = INITIAL_WINDOW
408+
while True:
409+
score, move = self.negamax(
410+
board,
411+
depth,
412+
self.config.null_move,
413+
cache,
414+
alpha=alpha,
415+
beta=beta,
416+
ply=0,
417+
killers=killers,
418+
)
419+
420+
# Check if we need to re-search with wider window
421+
if score <= alpha:
422+
# Failed low: widen window on the low side
423+
window *= 2
424+
# prev_score is guaranteed non-None after depth 1
425+
assert prev_score is not None
426+
alpha = prev_score - window
427+
if window > 500: # Give up and use full window
428+
alpha = float("-inf")
429+
elif score >= beta:
430+
# Failed high: widen window on the high side
431+
window *= 2
432+
# prev_score is guaranteed non-None after depth 1
433+
assert prev_score is not None
434+
beta = prev_score + window
435+
if window > 500: # Give up and use full window
436+
beta = float("inf")
437+
else:
438+
# Score is within window, we're done
439+
break
440+
441+
# Safety: if window is fully open, we must accept the result
442+
if alpha == float("-inf") and beta == float("inf"):
443+
break
444+
445+
prev_score = score
446+
447+
# Update best move from completed search
448+
if move is not None:
449+
best_move = move
450+
377451
assert best_move is not None, "Best move from root should not be None"
378452
return best_move

0 commit comments

Comments
 (0)