| """Thin, fast wrapper around ``chess.Board``.
|
|
|
| Adds three things the rest of the package needs: canonical tensor encoding,
|
| game-outcome values expressed *from the side to move*, and a hand-written
|
| material + piece-square evaluation used by the minimax opponent.
|
| """
|
|
|
| from typing import List, Optional, Tuple
|
|
|
| import chess
|
| import numpy as np
|
|
|
| from encoding import (
|
| board_to_planes,
|
| index_to_move,
|
| legal_move_indices,
|
| move_to_index,
|
| )
|
|
|
|
|
|
|
| PIECE_VALUES = {
|
| chess.PAWN: 100,
|
| chess.KNIGHT: 320,
|
| chess.BISHOP: 330,
|
| chess.ROOK: 500,
|
| chess.QUEEN: 900,
|
| chess.KING: 20000,
|
| }
|
|
|
| MATE_SCORE = 100_000
|
|
|
|
|
|
|
| PAWN_TABLE = [
|
| 0, 0, 0, 0, 0, 0, 0, 0,
|
| 50, 50, 50, 50, 50, 50, 50, 50,
|
| 10, 10, 20, 30, 30, 20, 10, 10,
|
| 5, 5, 10, 25, 25, 10, 5, 5,
|
| 0, 0, 0, 20, 20, 0, 0, 0,
|
| 5, -5, -10, 0, 0, -10, -5, 5,
|
| 5, 10, 10, -20, -20, 10, 10, 5,
|
| 0, 0, 0, 0, 0, 0, 0, 0,
|
| ]
|
|
|
| KNIGHT_TABLE = [
|
| -50, -40, -30, -30, -30, -30, -40, -50,
|
| -40, -20, 0, 0, 0, 0, -20, -40,
|
| -30, 0, 10, 15, 15, 10, 0, -30,
|
| -30, 5, 15, 20, 20, 15, 5, -30,
|
| -30, 0, 15, 20, 20, 15, 0, -30,
|
| -30, 5, 10, 15, 15, 10, 5, -30,
|
| -40, -20, 0, 5, 5, 0, -20, -40,
|
| -50, -40, -30, -30, -30, -30, -40, -50,
|
| ]
|
|
|
| BISHOP_TABLE = [
|
| -20, -10, -10, -10, -10, -10, -10, -20,
|
| -10, 0, 0, 0, 0, 0, 0, -10,
|
| -10, 0, 5, 10, 10, 5, 0, -10,
|
| -10, 5, 5, 10, 10, 5, 5, -10,
|
| -10, 0, 10, 10, 10, 10, 0, -10,
|
| -10, 10, 10, 10, 10, 10, 10, -10,
|
| -10, 5, 0, 0, 0, 0, 5, -10,
|
| -20, -10, -10, -10, -10, -10, -10, -20,
|
| ]
|
|
|
| ROOK_TABLE = [
|
| 0, 0, 0, 0, 0, 0, 0, 0,
|
| 5, 10, 10, 10, 10, 10, 10, 5,
|
| -5, 0, 0, 0, 0, 0, 0, -5,
|
| -5, 0, 0, 0, 0, 0, 0, -5,
|
| -5, 0, 0, 0, 0, 0, 0, -5,
|
| -5, 0, 0, 0, 0, 0, 0, -5,
|
| -5, 0, 0, 0, 0, 0, 0, -5,
|
| 0, 0, 0, 5, 5, 0, 0, 0,
|
| ]
|
|
|
|
|
|
|
| QUEEN_TABLE = [
|
| -20, -10, -10, -5, -5, -10, -10, -20,
|
| -10, 0, 0, 0, 0, 0, 0, -10,
|
| -10, 0, 5, 5, 5, 5, 0, -10,
|
| -5, 0, 5, 5, 5, 5, 0, -5,
|
| -5, 0, 5, 5, 5, 5, 0, -5,
|
| -10, 0, 5, 5, 5, 5, 0, -10,
|
| -10, 0, 0, 0, 0, 0, 0, -10,
|
| -20, -10, -10, -5, -5, -10, -10, -20,
|
| ]
|
|
|
| KING_MIDDLEGAME_TABLE = [
|
| -30, -40, -40, -50, -50, -40, -40, -30,
|
| -30, -40, -40, -50, -50, -40, -40, -30,
|
| -30, -40, -40, -50, -50, -40, -40, -30,
|
| -30, -40, -40, -50, -50, -40, -40, -30,
|
| -20, -30, -30, -40, -40, -30, -30, -20,
|
| -10, -20, -20, -20, -20, -20, -20, -10,
|
| 20, 20, 0, 0, 0, 0, 20, 20,
|
| 20, 30, 10, 0, 0, 10, 30, 20,
|
| ]
|
|
|
|
|
|
|
| KING_ENDGAME_TABLE = [
|
| -50, -40, -30, -20, -20, -30, -40, -50,
|
| -30, -20, -10, 0, 0, -10, -20, -30,
|
| -30, -10, 20, 30, 30, 20, -10, -30,
|
| -30, -10, 30, 40, 40, 30, -10, -30,
|
| -30, -10, 30, 40, 40, 30, -10, -30,
|
| -30, -10, 20, 30, 30, 20, -10, -30,
|
| -30, -30, 0, 0, 0, 0, -30, -30,
|
| -50, -30, -30, -30, -30, -30, -30, -50,
|
| ]
|
|
|
| PIECE_SQUARE_TABLES = {
|
| chess.PAWN: PAWN_TABLE,
|
| chess.KNIGHT: KNIGHT_TABLE,
|
| chess.BISHOP: BISHOP_TABLE,
|
| chess.ROOK: ROOK_TABLE,
|
| chess.QUEEN: QUEEN_TABLE,
|
| }
|
|
|
|
|
| ENDGAME_MATERIAL_THRESHOLD = 1300
|
|
|
|
|
| MOP_UP_THRESHOLD = 500
|
| MOP_UP_WEIGHT = 250
|
|
|
|
|
| class ChessEngine:
|
| """A chess position plus the encodings the learner needs."""
|
|
|
| __slots__ = ("board",)
|
|
|
| def __init__(self, board: Optional[chess.Board] = None):
|
| self.board = board if board is not None else chess.Board()
|
|
|
|
|
|
|
| def copy(self) -> "ChessEngine":
|
| return ChessEngine(self.board.copy())
|
|
|
| def get_turn(self) -> bool:
|
| return self.board.turn
|
|
|
| def get_move_count(self) -> int:
|
| return len(self.board.move_stack)
|
|
|
| def get_board_ascii(self) -> str:
|
| return str(self.board)
|
|
|
| def make_move(self, move: chess.Move) -> None:
|
| self.board.push(move)
|
|
|
|
|
|
|
| def get_state_planes(self) -> np.ndarray:
|
| return board_to_planes(self.board)
|
|
|
| def legal_move_indices(self) -> Tuple[List[chess.Move], np.ndarray]:
|
| return legal_move_indices(self.board)
|
|
|
| def legal_or_terminal(self) -> Tuple[Optional[List[chess.Move]], Optional[np.ndarray]]:
|
| """Legal moves and their indices, or ``(None, None)`` if the game ended.
|
|
|
| Generating moves is the most expensive board operation, and
|
| ``is_game_over`` generates them internally -- asking it first and then
|
| asking for the move list did the work two or three times per position.
|
| An empty move list already means checkmate or stalemate, so one
|
| generation answers both questions.
|
| """
|
| moves, indices = legal_move_indices(self.board)
|
| if not moves:
|
| return None, None
|
|
|
|
|
| if self.board.is_insufficient_material() or self._is_claimed_draw():
|
| return None, None
|
| return moves, indices
|
|
|
| def move_to_index(self, move: chess.Move) -> int:
|
| return move_to_index(move, self.board.turn)
|
|
|
| def index_to_move(self, index: int) -> chess.Move:
|
| return index_to_move(index, self.board)
|
|
|
|
|
|
|
| def _is_claimed_draw(self) -> bool:
|
| """Threefold repetition or the fifty-move rule.
|
|
|
| Deliberately not ``claim_draw=True``: python-chess implements that by
|
| replaying the whole move stack *and* pushing every legal move to see
|
| whether a claim becomes available next ply. It costs 296 us per call
|
| against 2 us here, which made it the single most expensive operation in
|
| self-play -- more than move generation and the network combined.
|
| """
|
| return self.board.is_repetition(3) or self.board.halfmove_clock >= 100
|
|
|
| def is_game_over(self) -> bool:
|
| """Includes claimable draws.
|
|
|
| Without them, self-play games degenerate into 200 plies of shuffling
|
| and every training target ends up being 0.
|
| """
|
| return self.board.is_game_over() or self._is_claimed_draw()
|
|
|
| def get_result(self) -> Optional[str]:
|
| """``"white"``, ``"black"``, ``"draw"``, or ``None`` if still running."""
|
| outcome = self.board.outcome()
|
| if outcome is not None:
|
| if outcome.winner is None:
|
| return "draw"
|
| return "white" if outcome.winner == chess.WHITE else "black"
|
| return "draw" if self._is_claimed_draw() else None
|
|
|
| def terminal_value(self) -> Optional[float]:
|
| """Game value in ``[-1, 1]`` **from the side to move**.
|
|
|
| A checkmated side is the side to move, so this is ``-1.0`` there. The
|
| old code had this backwards and the search actively walked into mate.
|
| """
|
| outcome = self.board.outcome()
|
| if outcome is not None:
|
| if outcome.winner is None:
|
| return 0.0
|
| return 1.0 if outcome.winner == self.board.turn else -1.0
|
| return 0.0 if self._is_claimed_draw() else None
|
|
|
|
|
|
|
| def mating_progress(self) -> float:
|
| """Progress towards forcing mate, White's side, in ``[-1, 1]``.
|
|
|
| Zero until one side is decisively ahead. Past that, material stops
|
| being informative -- an agent up thirty pawns is up thirty pawns
|
| whatever it plays -- and the only thing that still distinguishes moves
|
| is the mating technique itself: drive the bare king to the edge, and
|
| walk your own king up to it.
|
|
|
| Without this, a policy trained on material alone reaches +3000 and then
|
| shuffles, because every legal move looks exactly as good as every other.
|
| """
|
| material = self.evaluate(mop_up=False)
|
| if abs(material) < MOP_UP_THRESHOLD:
|
| return 0.0
|
|
|
| board = self.board
|
| winner = chess.WHITE if material > 0 else chess.BLACK
|
| winner_king = board.king(winner)
|
| loser_king = board.king(not winner)
|
| if winner_king is None or loser_king is None:
|
| return 0.0
|
|
|
|
|
| file_gap = max(3 - chess.square_file(loser_king), chess.square_file(loser_king) - 4)
|
| rank_gap = max(3 - chess.square_rank(loser_king), chess.square_rank(loser_king) - 4)
|
| cornered = (file_gap + rank_gap) / 6.0
|
|
|
|
|
| closed_in = (7 - chess.square_distance(winner_king, loser_king)) / 6.0
|
|
|
| progress = 0.7 * cornered + 0.3 * closed_in
|
| return progress if winner == chess.WHITE else -progress
|
|
|
| def _forces_mate(self, move: chess.Move, plies: int) -> bool:
|
| """True when `move` forces mate within `plies`, whatever the defence.
|
|
|
| Only ever called on bare-king endgames, where the defender has at most
|
| eight replies, so the tree stays small enough to enumerate honestly.
|
| """
|
| board = self.board
|
| board.push(move)
|
| try:
|
| if board.is_checkmate():
|
| return True
|
| if plies < 3 or board.is_stalemate() or board.is_insufficient_material():
|
| return False
|
| for reply in list(board.legal_moves):
|
| board.push(reply)
|
| try:
|
| escaped = not any(
|
| self._forces_mate(follow_up, plies - 2)
|
| for follow_up in list(board.legal_moves)
|
| )
|
| finally:
|
| board.pop()
|
| if escaped:
|
| return False
|
| return True
|
| finally:
|
| board.pop()
|
|
|
| def finishing_moves(
|
| self, plies: int = 1
|
| ) -> Optional[Tuple[List[chess.Move], List[chess.Move]]]:
|
| """``(mates, stalemates)`` among the legal moves, or None if not worth it.
|
|
|
| A policy network gets no lookahead, and it shows: measured over 120
|
| games, 51 ended in stalemate and in 43 of them a mate in one was
|
| available and simply not seen. Detecting that from the board alone
|
| would mean internally simulating sixty-odd moves, which is what search
|
| is for.
|
|
|
| Only computed once the defender is down to a bare king -- the endgames
|
| where these two outcomes are one move away and the whole game hangs on
|
| telling them apart. Everywhere else this returns None and costs a
|
| popcount.
|
| """
|
| board = self.board
|
| defender = not board.turn
|
| if board.occupied_co[defender] != board.kings & board.occupied_co[defender]:
|
| return None
|
|
|
| mates: List[chess.Move] = []
|
| stalemates: List[chess.Move] = []
|
| for move in list(board.legal_moves):
|
| board.push(move)
|
|
|
|
|
| immediate_mate = board.is_checkmate()
|
| stalemated = board.is_stalemate()
|
| board.pop()
|
|
|
| if immediate_mate:
|
| mates.append(move)
|
| elif stalemated:
|
| stalemates.append(move)
|
| elif plies >= 3 and self._forces_mate(move, plies):
|
| mates.append(move)
|
| return mates, stalemates
|
|
|
| def steer_to_finish(
|
| self, moves: List[chess.Move], probabilities: np.ndarray, plies: int = 1
|
| ) -> np.ndarray:
|
| """Reweight a move distribution to take mates and refuse stalemates.
|
|
|
| One ply of search, used as a policy improvement operator: the network
|
| keeps choosing, but it no longer gets to throw a won game away on a
|
| move whose consequence is decided and visible. In self-play the choice
|
| is recorded as the behaviour distribution, so PPO's ratio accounts for
|
| it and the network is pulled towards making the same call unaided.
|
| """
|
| finishing = self.finishing_moves(plies)
|
| if finishing is None:
|
| return probabilities
|
| mates, stalemates = finishing
|
| if not mates and not stalemates:
|
| return probabilities
|
|
|
| adjusted = probabilities.astype(np.float64, copy=True)
|
| if mates:
|
| wanted = {move.uci() for move in mates}
|
| mask = np.array([move.uci() in wanted for move in moves])
|
| else:
|
| refused = {move.uci() for move in stalemates}
|
| mask = np.array([move.uci() not in refused for move in moves])
|
|
|
| adjusted[~mask] = 0.0
|
| total = adjusted.sum()
|
| if total <= 0:
|
|
|
|
|
| return probabilities
|
| return adjusted / total
|
|
|
| def evaluate(self, mop_up: bool = True) -> float:
|
| """Static evaluation in centipawns, **from White's point of view**.
|
|
|
| `mop_up` adds the endgame mating term; pass False for plain material,
|
| which is what `mating_progress` needs to avoid recursing.
|
| """
|
| board = self.board
|
|
|
| if board.is_checkmate():
|
| return -MATE_SCORE if board.turn == chess.WHITE else MATE_SCORE
|
| if board.is_stalemate() or board.is_insufficient_material():
|
| return 0.0
|
|
|
| score = 0
|
| non_pawn_material = 0
|
|
|
| for piece_type in (chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN):
|
| value = PIECE_VALUES[piece_type]
|
| table = PIECE_SQUARE_TABLES[piece_type]
|
|
|
| for square in board.pieces(piece_type, chess.WHITE):
|
| score += value + table[square ^ 56]
|
| for square in board.pieces(piece_type, chess.BLACK):
|
| score -= value + table[square]
|
|
|
| if piece_type != chess.PAWN:
|
| count = len(board.pieces(piece_type, chess.WHITE)) + len(
|
| board.pieces(piece_type, chess.BLACK)
|
| )
|
| non_pawn_material += value * count
|
|
|
| king_table = (
|
| KING_ENDGAME_TABLE
|
| if non_pawn_material <= ENDGAME_MATERIAL_THRESHOLD
|
| else KING_MIDDLEGAME_TABLE
|
| )
|
| white_king = board.king(chess.WHITE)
|
| black_king = board.king(chess.BLACK)
|
| if white_king is not None:
|
| score += king_table[white_king ^ 56]
|
| if black_king is not None:
|
| score -= king_table[black_king]
|
|
|
| score = float(score)
|
| if mop_up and abs(score) >= MOP_UP_THRESHOLD:
|
|
|
|
|
| score += MOP_UP_WEIGHT * self.mating_progress()
|
|
|
| return score
|
|
|
| def evaluate_for_side_to_move(self) -> float:
|
| score = self.evaluate()
|
| return score if self.board.turn == chess.WHITE else -score
|
|
|