| """Board <-> tensor and move <-> index conversions. |
| |
| Everything here lives in the **canonical frame**: the side to move is always |
| "white, playing up the board". When black is to move, every square is mirrored |
| vertically (``square ^ 56``) and the piece colours are swapped. |
| |
| Two consequences, both wanted: |
| |
| * the value head always predicts "how good is this for the player to move", |
| which is a well defined question -- unlike the previous absolute encoding |
| which did not even tell the network whose turn it was; |
| * a position and its colour-reversed twin share the same representation, so |
| every self-play game trains both colours at once. |
| """ |
|
|
| from typing import List, Tuple |
|
|
| import chess |
| import numpy as np |
|
|
| |
| N_PLANES = 19 |
| ACTION_SIZE = 64 * 64 |
|
|
| PIECE_ORDER: Tuple[int, ...] = ( |
| chess.PAWN, |
| chess.KNIGHT, |
| chess.BISHOP, |
| chess.ROOK, |
| chess.QUEEN, |
| chess.KING, |
| ) |
|
|
| |
| PLANE_NAMES: Tuple[str, ...] = ( |
| "us_pawn", "us_knight", "us_bishop", "us_rook", "us_queen", "us_king", |
| "them_pawn", "them_knight", "them_bishop", "them_rook", "them_queen", "them_king", |
| "us_castle_k", "us_castle_q", "them_castle_k", "them_castle_q", |
| "en_passant", "fifty_move", "repetition", |
| ) |
|
|
|
|
| def canonical_square(square: int, turn: bool) -> int: |
| """Map a board square into the frame of the side to move. |
| |
| ``x ^ 56`` flips the rank and keeps the file, and is its own inverse, so the |
| same function encodes and decodes. |
| """ |
| return square if turn == chess.WHITE else square ^ 56 |
|
|
|
|
| def move_to_index(move: chess.Move, turn: bool) -> int: |
| """Flatten a move to ``[0, 4096)`` in the canonical frame. |
| |
| Promotions collapse onto the same index as the plain from/to move; decoding |
| resolves the ambiguity in favour of the queen (see :func:`index_to_move`). |
| Under-promotions are therefore not representable -- a deliberate trade-off, |
| they are worth well under 0.1% of moves in practice. |
| """ |
| return canonical_square(move.from_square, turn) * 64 + canonical_square(move.to_square, turn) |
|
|
|
|
| def index_to_squares(index: int, turn: bool) -> Tuple[int, int]: |
| """Inverse of :func:`move_to_index`, returning real (non-canonical) squares.""" |
| from_canonical, to_canonical = divmod(int(index), 64) |
| return canonical_square(from_canonical, turn), canonical_square(to_canonical, turn) |
|
|
|
|
| def index_to_move(index: int, board: chess.Board) -> chess.Move: |
| """Resolve a policy index against the legal moves of `board`. |
| |
| Returns ``chess.Move.null()`` when the index matches no legal move, which |
| lets callers fail loudly instead of silently playing something else. |
| """ |
| from_square, to_square = index_to_squares(index, board.turn) |
|
|
| |
| |
| for promotion in (None, chess.QUEEN): |
| move = chess.Move(from_square, to_square, promotion=promotion) |
| if board.is_legal(move): |
| return move |
|
|
| for move in board.legal_moves: |
| if move.from_square == from_square and move.to_square == to_square: |
| return move |
|
|
| return chess.Move.null() |
|
|
|
|
| def legal_move_indices(board: chess.Board) -> Tuple[List[chess.Move], np.ndarray]: |
| """Return the legal moves and their canonical policy indices. |
| |
| Duplicate indices (under-promotions sharing a from/to pair) are dropped so |
| that the index array can be used directly as a legality mask. The common |
| case -- no promotion available -- takes the fast path and never builds the |
| deduplication set. |
| """ |
| moves = list(board.legal_moves) |
| if board.turn == chess.WHITE: |
| indices = [m.from_square * 64 + m.to_square for m in moves] |
| else: |
| indices = [(m.from_square ^ 56) * 64 + (m.to_square ^ 56) for m in moves] |
|
|
| if len(set(indices)) != len(indices): |
| unique: dict = {} |
| for move, index in zip(moves, indices): |
| |
| if index not in unique or move.promotion == chess.QUEEN: |
| unique[index] = move |
| indices = list(unique.keys()) |
| moves = list(unique.values()) |
|
|
| return moves, np.asarray(indices, dtype=np.int16) |
|
|
|
|
| def board_to_planes(board: chess.Board) -> np.ndarray: |
| """Encode `board` as a ``(N_PLANES, 8, 8)`` float32 tensor.""" |
| planes = np.zeros((N_PLANES, 8, 8), dtype=np.float32) |
| turn = board.turn |
|
|
| |
| |
| |
| raw = ( |
| np.array( |
| [board.pieces_mask(piece, color) for color in (turn, not turn) for piece in PIECE_ORDER], |
| dtype=">u8", |
| ) |
| .view(np.uint8) |
| .reshape(12, 8) |
| ) |
| bits = np.unpackbits(raw, axis=1).reshape(12, 8, 8) |
| |
| |
| |
| planes[:12] = bits[:, ::-1, ::-1] if turn == chess.WHITE else bits[:, :, ::-1] |
|
|
| planes[12] = float(board.has_kingside_castling_rights(turn)) |
| planes[13] = float(board.has_queenside_castling_rights(turn)) |
| planes[14] = float(board.has_kingside_castling_rights(not turn)) |
| planes[15] = float(board.has_queenside_castling_rights(not turn)) |
|
|
| if board.ep_square is not None: |
| canonical = canonical_square(board.ep_square, turn) |
| planes[16, canonical >> 3, canonical & 7] = 1.0 |
|
|
| planes[17] = min(board.halfmove_clock, 100) / 100.0 |
| planes[18] = 1.0 if board.is_repetition(2) else 0.0 |
|
|
| return planes |
|
|