Skip to content

Commit cda6ab5

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 944a0e0 commit cda6ab5

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
@@ -218,11 +218,15 @@ def negamax(
218218
"""
219219
original_alpha = alpha
220220
cache_key = chess.polyglot.zobrist_hash(board)
221+
tt_move = None # Best move from transposition table (for move ordering)
221222

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

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

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

@@ -357,21 +366,86 @@ def negamax(
357366
return best_score, best_move
358367

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

363-
# Killer moves table: 2 killers per ply
385+
# Killer moves table: 2 killers per ply, persists across iterations
364386
# Max ply is roughly target_depth + quiescence_depth + some buffer
365-
max_ply = self.config.negamax_depth + self.config.quiescence_search_depth + 10
387+
max_ply = target_depth + self.config.quiescence_search_depth + 10
366388
killers: list = [[] for _ in range(max_ply)]
367389

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

0 commit comments

Comments
 (0)