|
|
""" |
|
|
Transposition Table with Zobrist Hashing |
|
|
Research: Stockfish uses 2GB TT, we use 400MB for Colab constraints |
|
|
|
|
|
References: |
|
|
- Zobrist (1970) - Hash functions for chess positions |
|
|
- Stockfish TT - Replacement strategies |
|
|
- AlphaBeta enhancements - Exact/Lower/Upper bounds |
|
|
""" |
|
|
|
|
|
import chess |
|
|
import numpy as np |
|
|
from typing import Optional, Dict, Tuple |
|
|
from enum import Enum |
|
|
|
|
|
|
|
|
class NodeType(Enum): |
|
|
"""Type of transposition table entry""" |
|
|
EXACT = 0 |
|
|
LOWER_BOUND = 1 |
|
|
UPPER_BOUND = 2 |
|
|
|
|
|
|
|
|
class TTEntry: |
|
|
"""Single transposition table entry""" |
|
|
|
|
|
__slots__ = ['zobrist_key', 'depth', 'score', 'node_type', 'best_move', 'age'] |
|
|
|
|
|
def __init__( |
|
|
self, |
|
|
zobrist_key: int, |
|
|
depth: int, |
|
|
score: float, |
|
|
node_type: NodeType, |
|
|
best_move: Optional[chess.Move], |
|
|
age: int |
|
|
): |
|
|
self.zobrist_key = zobrist_key |
|
|
self.depth = depth |
|
|
self.score = score |
|
|
self.node_type = node_type |
|
|
self.best_move = best_move |
|
|
self.age = age |
|
|
|
|
|
|
|
|
class TranspositionTable: |
|
|
""" |
|
|
Zobrist-hashed transposition table |
|
|
Replacement strategy: Always replace if deeper or newer |
|
|
""" |
|
|
|
|
|
def __init__(self, size_mb: int = 256): |
|
|
""" |
|
|
Initialize transposition table |
|
|
|
|
|
Args: |
|
|
size_mb: Table size in megabytes (default 256MB) |
|
|
""" |
|
|
|
|
|
bytes_per_entry = 64 |
|
|
self.max_entries = (size_mb * 1024 * 1024) // bytes_per_entry |
|
|
|
|
|
|
|
|
self.table: Dict[int, TTEntry] = {} |
|
|
|
|
|
|
|
|
self.hits = 0 |
|
|
self.misses = 0 |
|
|
self.collisions = 0 |
|
|
self.current_age = 0 |
|
|
|
|
|
|
|
|
self._init_zobrist_keys() |
|
|
|
|
|
def _init_zobrist_keys(self): |
|
|
""" |
|
|
Initialize Zobrist random keys |
|
|
One key per (piece_type, color, square) combination |
|
|
""" |
|
|
np.random.seed(42) |
|
|
|
|
|
self.zobrist_pieces = np.random.randint( |
|
|
0, 2**63, size=(12, 64), dtype=np.int64 |
|
|
) |
|
|
|
|
|
|
|
|
self.zobrist_turn = np.random.randint(0, 2**63, dtype=np.int64) |
|
|
self.zobrist_castling = np.random.randint(0, 2**63, size=4, dtype=np.int64) |
|
|
self.zobrist_ep = np.random.randint(0, 2**63, size=8, dtype=np.int64) |
|
|
|
|
|
def compute_zobrist_key(self, board: chess.Board) -> int: |
|
|
""" |
|
|
Compute Zobrist hash for position |
|
|
|
|
|
Args: |
|
|
board: chess.Board |
|
|
|
|
|
Returns: |
|
|
64-bit Zobrist key |
|
|
""" |
|
|
key = 0 |
|
|
|
|
|
|
|
|
piece_to_index = { |
|
|
(chess.PAWN, chess.WHITE): 0, |
|
|
(chess.KNIGHT, chess.WHITE): 1, |
|
|
(chess.BISHOP, chess.WHITE): 2, |
|
|
(chess.ROOK, chess.WHITE): 3, |
|
|
(chess.QUEEN, chess.WHITE): 4, |
|
|
(chess.KING, chess.WHITE): 5, |
|
|
(chess.PAWN, chess.BLACK): 6, |
|
|
(chess.KNIGHT, chess.BLACK): 7, |
|
|
(chess.BISHOP, chess.BLACK): 8, |
|
|
(chess.ROOK, chess.BLACK): 9, |
|
|
(chess.QUEEN, chess.BLACK): 10, |
|
|
(chess.KING, chess.BLACK): 11, |
|
|
} |
|
|
|
|
|
for square, piece in board.piece_map().items(): |
|
|
piece_idx = piece_to_index[(piece.piece_type, piece.color)] |
|
|
key ^= self.zobrist_pieces[piece_idx, square] |
|
|
|
|
|
|
|
|
if board.turn == chess.BLACK: |
|
|
key ^= self.zobrist_turn |
|
|
|
|
|
|
|
|
if board.has_kingside_castling_rights(chess.WHITE): |
|
|
key ^= self.zobrist_castling[0] |
|
|
if board.has_queenside_castling_rights(chess.WHITE): |
|
|
key ^= self.zobrist_castling[1] |
|
|
if board.has_kingside_castling_rights(chess.BLACK): |
|
|
key ^= self.zobrist_castling[2] |
|
|
if board.has_queenside_castling_rights(chess.BLACK): |
|
|
key ^= self.zobrist_castling[3] |
|
|
|
|
|
|
|
|
if board.ep_square is not None: |
|
|
ep_file = board.ep_square % 8 |
|
|
key ^= self.zobrist_ep[ep_file] |
|
|
|
|
|
return key |
|
|
|
|
|
def probe( |
|
|
self, |
|
|
zobrist_key: int, |
|
|
depth: int, |
|
|
alpha: float, |
|
|
beta: float |
|
|
) -> Optional[Tuple[float, Optional[chess.Move]]]: |
|
|
""" |
|
|
Probe transposition table |
|
|
|
|
|
Args: |
|
|
zobrist_key: Zobrist hash of position |
|
|
depth: Current search depth |
|
|
alpha: Alpha value |
|
|
beta: Beta value |
|
|
|
|
|
Returns: |
|
|
(score, best_move) if usable entry found, else None |
|
|
""" |
|
|
entry = self.table.get(zobrist_key) |
|
|
|
|
|
if entry is None: |
|
|
self.misses += 1 |
|
|
return None |
|
|
|
|
|
|
|
|
if entry.zobrist_key != zobrist_key: |
|
|
self.collisions += 1 |
|
|
return None |
|
|
|
|
|
|
|
|
if entry.depth < depth: |
|
|
self.misses += 1 |
|
|
return None |
|
|
|
|
|
self.hits += 1 |
|
|
|
|
|
|
|
|
score = entry.score |
|
|
|
|
|
if entry.node_type == NodeType.EXACT: |
|
|
return (score, entry.best_move) |
|
|
|
|
|
elif entry.node_type == NodeType.LOWER_BOUND: |
|
|
if score >= beta: |
|
|
return (score, entry.best_move) |
|
|
|
|
|
elif entry.node_type == NodeType.UPPER_BOUND: |
|
|
if score <= alpha: |
|
|
return (score, entry.best_move) |
|
|
|
|
|
|
|
|
|
|
|
return (None, entry.best_move) |
|
|
|
|
|
def store( |
|
|
self, |
|
|
zobrist_key: int, |
|
|
depth: int, |
|
|
score: float, |
|
|
node_type: NodeType, |
|
|
best_move: Optional[chess.Move] |
|
|
): |
|
|
""" |
|
|
Store entry in transposition table |
|
|
|
|
|
Args: |
|
|
zobrist_key: Zobrist hash |
|
|
depth: Search depth |
|
|
score: Position score |
|
|
node_type: Type of node (exact/lower/upper) |
|
|
best_move: Best move found |
|
|
""" |
|
|
|
|
|
existing = self.table.get(zobrist_key) |
|
|
|
|
|
if existing is not None: |
|
|
|
|
|
|
|
|
|
|
|
if depth < existing.depth and existing.age == self.current_age: |
|
|
return |
|
|
|
|
|
|
|
|
self.table[zobrist_key] = TTEntry( |
|
|
zobrist_key=zobrist_key, |
|
|
depth=depth, |
|
|
score=score, |
|
|
node_type=node_type, |
|
|
best_move=best_move, |
|
|
age=self.current_age |
|
|
) |
|
|
|
|
|
|
|
|
if len(self.table) > self.max_entries: |
|
|
self._cleanup_old_entries() |
|
|
|
|
|
def _cleanup_old_entries(self): |
|
|
"""Remove oldest 10% of entries""" |
|
|
entries_to_remove = self.max_entries // 10 |
|
|
|
|
|
|
|
|
old_keys = sorted( |
|
|
self.table.keys(), |
|
|
key=lambda k: self.table[k].age |
|
|
)[:entries_to_remove] |
|
|
|
|
|
for key in old_keys: |
|
|
del self.table[key] |
|
|
|
|
|
def increment_age(self): |
|
|
"""Increment generation counter (call at search start)""" |
|
|
self.current_age += 1 |
|
|
|
|
|
def clear(self): |
|
|
"""Clear all entries""" |
|
|
self.table.clear() |
|
|
self.hits = 0 |
|
|
self.misses = 0 |
|
|
self.collisions = 0 |
|
|
|
|
|
def get_stats(self) -> Dict: |
|
|
"""Get table statistics""" |
|
|
total_probes = self.hits + self.misses |
|
|
hit_rate = (self.hits / total_probes * 100) if total_probes > 0 else 0 |
|
|
|
|
|
return { |
|
|
'entries': len(self.table), |
|
|
'max_entries': self.max_entries, |
|
|
'usage_percent': len(self.table) / self.max_entries * 100, |
|
|
'hits': self.hits, |
|
|
'misses': self.misses, |
|
|
'hit_rate': hit_rate, |
|
|
'collisions': self.collisions |
|
|
} |