File size: 5,933 Bytes
3ee44d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""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

# 12 piece planes + 4 castling + 1 en-passant + 1 fifty-move + 1 repetition
N_PLANES = 19
ACTION_SIZE = 64 * 64  # from-square * 64 + to-square, canonical frame

PIECE_ORDER: Tuple[int, ...] = (
    chess.PAWN,
    chess.KNIGHT,
    chess.BISHOP,
    chess.ROOK,
    chess.QUEEN,
    chess.KING,
)

# Plane layout, for reference / debugging.
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)

    # Try the queen promotion first, then the plain move: both are cheap
    # membership tests and cover every legal case except under-promotions.
    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):
            # Keep the queen promotion, which is what index_to_move decodes to.
            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

    # Unpacking the twelve bitboards in one go is about four times faster than
    # walking the occupied squares in Python, and this runs once per position
    # of every game.
    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)
    # Big-endian bytes come out rank 7 first, and file 7 first within a rank.
    # Reversing both axes gives White's frame; for Black the rank reversal is
    # exactly the ``square ^ 56`` mirror, so it cancels out.
    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