rukh · lab

// M1 · lección 06

Los tres vocabularios

El vocabulario UCI enumerado sin mirar un dato, el alfabeto de 32 símbolos sobre SAN y el BPE aprendido de 200 000 partidas. Con el fichero de tests que fija la enumeración token a token y la hace reproducible en otro lenguaje.

  • tiempo de trabajo120 min
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a construir

Las tres respuestas a la pregunta de la primera lección, qué es un tokenTokenUnidad mínima que el modelo lee y escribe. En Rukh, por defecto, un token es una jugada completa en notación UCI (e2e4); en otras tokenizaciones puede ser un carácter o un trozo de texto aprendido por BPE. El modelo nunca ve letras ni tableros: ve identificadores enteros de tokens., escritas en código: un vocabularioVocabularioLa lista de todos los tokens que el modelo conoce, cada uno con un id entero fijo. En Rukh el vocabulario UCI tiene 2 030 entradas: 8 tokens especiales, 54 tramos de Elo y las 1 968 jugadas posibles, enumeradas sin mirar datos. Su tamaño fija el de la capa de embeddings y el de la capa de salida. de 2 030 entradas que se enumera sin mirar un dato, un alfabeto de 32 símbolos que no sabe nada de ajedrez y un BPEBPE (byte-pair encoding)Tokenizador aprendido de los datos: parte de los caracteres, fusiona una y otra vez el par de tokens adyacentes más frecuente y guarda cada fusión en orden hasta llegar al tamaño de vocabulario pedido (4 096 en Rukh). Sobre texto UCI redescubre primero las casillas, luego las jugadas y al final trozos de apertura. Nada queda fuera de vocabulario: lo raro se parte en trozos más cortos. de 4 096 entradas aprendido de doscientas mil partidas.

Los tres tienen la misma interfaz (encode, decode, un vocab), así que el empaquetador de la lección siguiente los usa sin saber cuál tiene delante. El primero, además, no depende de los datos: otra implementación en otro lenguaje, sin compartir una línea, produce la misma tabla. Esa propiedad hace posible el tokenizador del navegador, y un fichero de tests la vigila token a token. Son cuatro ficheros.

src/rukh/tokenize/__init__.py
"""Tokenization schemes for chess games: fixed UCI vocabulary, char-level SAN and BPE."""

src/rukh/tokenize/__init__.pylínea 1 · p1

Sin reexportes, para que importar rukh.tokenize no arrastre tokenizers ni polars.

uci_vocab.py: una tabla que se calcula, no se guarda

src/rukh/tokenize/uci_vocab.py
"""Fixed UCI vocabulary: a deterministic enumeration shared with the TypeScript twin.
The vocabulary does not depend on data. It is built by enumerating squares in file-major order
(``a1, a2, ..., a8, b1, ..., h8``), every ``from``/``to`` pair reachable by a queen or a knight
(1 792 moves), every promotion (176) and a fixed block of special tokens in front. Any change to
this enumeration breaks parity with ``rukh-web/src/lib/chess-lm/tokenizer.ts`` and with
``artifacts/tokenizer/fixtures/games.json``.
"""
from __future__ import annotations
import json
from pathlib import Path
FILES = "abcdefgh"
PROMOTION_PIECES = "qrbn"
RESULT_TOKENS: dict[str, str] = {"1-0": "<1-0>", "0-1": "<0-1>", "1/2-1/2": "<1/2>"}
ELO_MIN = 600
ELO_MAX = 3200
ELO_STEP = 100
SPECIALS: list[str] = ["<pad>", "<bos>", "<eos>", "<mask>", "<unk>", "<1-0>", "<0-1>", "<1/2>"]
PAD_ID = 0
BOS_ID = 1
EOS_ID = 2
MASK_ID = 3
UNK_ID = 4

src/rukh/tokenize/uci_vocab.pylíneas 1-27 · p1

El docstring es la advertencia más importante del módulo: cualquier cambio en esta enumeración rompe la paridad con el gemelo de TypeScript y con la fixture, y obliga a reentrenar todo, porque los pesos del modelo están indexados por estos ids. Le pasa a cualquier LLM: el tokenizador se publica con los pesos y no se cambia por separado. Los ids especiales son constantes a la vista porque se usan en sitios que ni importan este fichero, como el ignore_index=0 de la pérdida de M2.

src/rukh/tokenize/uci_vocab.py
def squares() -> list[str]:
"""The 64 squares in file-major order (index ``file * 8 + rank``)."""
return [f"{FILES[f]}{r + 1}" for f in range(8) for r in range(8)]
def _reachable(df: int, dr: int) -> bool:
"""Whether a queen (line or diagonal) or a knight can go from one square to another."""
if df == 0 and dr == 0:
return False
if df == 0 or dr == 0 or abs(df) == abs(dr):
return True
return {abs(df), abs(dr)} == {1, 2}

src/rukh/tokenize/uci_vocab.pylíneas 30-41 · p1

squares() enumera por columnas (a1, a2, …, a8, b1, …). El orden por filas sería igual de natural y daría una tabla distinta con los mismos tokens, así que el orden tiene que estar escrito y probado.

_reachable es la única regla de ajedrez del fichero: un par de casillas entra si una dama o un caballo podrían cubrirlo en un tablero vacío. Eso cubre todas las piezas y descarta pares como a1b4, que ninguna puede hacer. De 4 096 pares quedan 1 792.

src/rukh/tokenize/uci_vocab.py
def build_moves() -> list[str]:
"""1 792 ``fromto`` strings, ``from`` outer and ``to`` inner, both in file-major order."""
moves: list[str] = []
for ff in range(8):
for fr in range(8):
for tf in range(8):
for tr in range(8):
if _reachable(tf - ff, tr - fr):
moves.append(f"{FILES[ff]}{fr + 1}{FILES[tf]}{tr + 1}")
return moves
def build_promotions() -> list[str]:
"""176 ``fromto+piece`` strings: rank 7 to 8 (White) then rank 2 to 1 (Black)."""
promotions: list[str] = []
for from_rank, to_rank in ((7, 8), (2, 1)):
for ff in range(8):
for tf in range(8):
if abs(tf - ff) <= 1:
for piece in PROMOTION_PIECES:
promotions.append(f"{FILES[ff]}{from_rank}{FILES[tf]}{to_rank}{piece}")
return promotions

src/rukh/tokenize/uci_vocab.pylíneas 44-65 · p1

Los bucles cuádruples son literales a propósito: con itertools.product sería más corto y más difícil de reproducir en otro lenguaje, que es lo único que se le pide a este código. El orden de las piezas de promoción ("qrbn") es otra convención que el otro lado copia tal cual, y las promociones van al final para que las jugadas comunes ocupen un bloque contiguo de ids.

src/rukh/tokenize/uci_vocab.py
def elo_tokens(side: str) -> list[str]:
"""``<w0600>`` ... ``<w3200>`` (or ``b``): 27 bins of 100 Elo."""
return [f"<{side}{elo:04d}>" for elo in range(ELO_MIN, ELO_MAX + 1, ELO_STEP)]
def build_vocab() -> list[str]:
"""The full token list in id order: specials, Elo bins, moves, promotions (2 030 tokens)."""
return SPECIALS + elo_tokens("w") + elo_tokens("b") + build_moves() + build_promotions()
def elo_bin(elo: int) -> int:
"""Lower edge of the 100-Elo bin, clamped to ``[600, 3200]``."""
return min(max(int(elo), ELO_MIN), ELO_MAX + ELO_STEP - 1) // ELO_STEP * ELO_STEP
def elo_token(elo: int, side: str) -> str:
"""``<w1800>``-style token for ``elo`` and side ``w`` or ``b``."""
if side not in ("w", "b"):
raise ValueError(f"side must be 'w' or 'b', got {side!r}")
return f"<{side}{elo_bin(elo):04d}>"

src/rukh/tokenize/uci_vocab.pylíneas 68-87 · p1

elo_bin recorta antes de truncar, con 3299 de techo: un 3250 y un 9999 caen en <w3200>. Sin el recorte, un Elo fuera de rango daría un KeyError en medio del empaquetado de seis millones de partidas. elo_token valida el bando para que una errata no produzca un <x1800> que falle tres llamadas más allá.

El orden del vocabulario deja los tokens de control como prefijo: los 62 primeros ids son control y del 62 en adelante todo son jugadas. El encoder de M3 lo aprovecha para sortear una jugada al azar con un randint(62, vocab_size).

src/rukh/tokenize/uci_vocab.py
class UciTokenizer:
"""Encode/decode games as ``[<bos>, <wXXXX>, <bXXXX>, moves..., <result>, <eos>]``."""
specials: list[str] = SPECIALS
pad_id = PAD_ID
bos_id = BOS_ID
eos_id = EOS_ID
mask_id = MASK_ID
unk_id = UNK_ID
def __init__(self) -> None:
self.ids: list[str] = build_vocab()
self.vocab: dict[str, int] = {token: i for i, token in enumerate(self.ids)}
if len(self.vocab) != len(self.ids):
raise RuntimeError("duplicate tokens in the UCI vocabulary")
def __len__(self) -> int:
return len(self.ids)

src/rukh/tokenize/uci_vocab.pylíneas 90-107 · p1

La comprobación de duplicados es barata y paranoica: si una edición descuidada generara un token dos veces, el diccionario se quedaría con el último y todo lo de detrás se desplazaría en silencio.

src/rukh/tokenize/uci_vocab.py
def encode_game(
self,
uci: str,
white_elo: int,
black_elo: int,
result: str,
max_len: int = 200,
) -> list[int]:
"""Encode one game; unknown moves map to ``<unk>``; the sequence is cut at ``max_len``.
When the whole game fits in ``max_len`` the sequence ends with ``<eos>``; otherwise it
is truncated to the first ``max_len`` ids (no result, no ``<eos>``).
"""
if result not in RESULT_TOKENS:
raise ValueError(f"unknown result {result!r}; expected one of {list(RESULT_TOKENS)}")
ids = [
self.bos_id,
self.vocab[elo_token(white_elo, "w")],
self.vocab[elo_token(black_elo, "b")],
]
ids.extend(self.vocab.get(move, self.unk_id) for move in uci.split())
ids.append(self.vocab[RESULT_TOKENS[result]])
ids.append(self.eos_id)
return ids[:max_len]

src/rukh/tokenize/uci_vocab.pylíneas 109-132 · p1

La forma de la secuencia es la decidida en la primera lección. El get con <unk> evita que una fila corrupta tumbe el empaquetado de un mes. Y el corte es un ids[:max_len] a secas: cuando la partida no cabe, la secuencia se queda sin resultado y sin <eos>.

src/rukh/tokenize/uci_vocab.py
def decode(self, ids: list[int]) -> list[str]:
"""Map ids back to their token strings (raises ``IndexError`` on unknown ids)."""
return [self.ids[i] for i in ids]
def to_dict(self) -> dict[str, object]:
return {
"version": 1,
"size": len(self.ids),
"specials": list(self.specials),
"elo_bins": {"min": ELO_MIN, "max": ELO_MAX, "step": ELO_STEP},
"tokens": list(self.ids),
}
def export(self, path: Path) -> None:
"""Write ``vocab.json`` deterministically (``indent=0``, ASCII, trailing newline)."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(self.to_dict(), indent=0, ensure_ascii=True) + "\n"
path.write_text(text, encoding="utf-8", newline="\n")
@classmethod
def from_file(cls, path: Path) -> UciTokenizer:
"""Load a ``vocab.json`` and check it matches the built-in enumeration."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
tokenizer = cls()
if data.get("tokens") != tokenizer.ids:
raise ValueError(f"{path} does not match the built-in UCI vocabulary")
return tokenizer

src/rukh/tokenize/uci_vocab.pylíneas 134-161 · p1

Los tres ajustes de export buscan lo mismo: que el fichero salga byte a byte igual en cualquier máquina (sin newline="\n", Windows escribiría \r\n). Un test comprueba su hash exacto. from_file carga el JSON y comprueba que coincide con la enumeración: el vocab.json es una copia exportada para que otros la lean, no la fuente de la verdad.

san_chars.py: el que menos sabe

src/rukh/tokenize/san_chars.py
"""Char-level SAN tokenizer: a fixed 32-symbol alphabet plus ``<pad>``, ``<bos>``, ``<eos>``.
The text is the numbered SAN movetext without comments followed by the result:
``1.e4 e5 2.Nf3 Nc6 ... 1-0``. Every character must belong to the alphabet.
"""
from __future__ import annotations
ALPHABET = " #+-.0123456789=BKNOQRabcdefghx/"
SPECIALS: list[str] = ["<pad>", "<bos>", "<eos>"]
PAD_ID = 0
BOS_ID = 1
EOS_ID = 2

src/rukh/tokenize/san_chars.pylíneas 1-13 · p1

Treinta y dos símbolos más tres especiales: 35 tokens. Fíjate en lo que no está: tokens de Elo. Este esquema no se puede condicionar por nivel como el UCI, y eso también forma parte de la comparación. El / sí está, porque el texto incluye el resultado 1/2-1/2.

src/rukh/tokenize/san_chars.py
class SanCharTokenizer:
"""Encode text as ``[<bos>, char..., <eos>]`` and decode ids back to text."""
specials: list[str] = SPECIALS
pad_id = PAD_ID
bos_id = BOS_ID
eos_id = EOS_ID
def __init__(self) -> None:
self.ids: list[str] = SPECIALS + list(ALPHABET)
self.vocab: dict[str, int] = {token: i for i, token in enumerate(self.ids)}
if len(self.vocab) != len(self.ids):
raise RuntimeError("duplicate symbols in the SAN alphabet")
def __len__(self) -> int:
return len(self.ids)
def encode(self, text: str) -> list[int]:
"""Encode ``text``; raises ``ValueError`` on a character outside the alphabet."""
ids = [self.bos_id]
for ch in text:
try:
ids.append(self.vocab[ch])
except KeyError:
raise ValueError(f"character {ch!r} is not in the SAN alphabet") from None
ids.append(self.eos_id)
return ids
def decode(self, ids: list[int]) -> str:
"""Join the characters of ``ids``, skipping special tokens."""
return "".join(self.ids[i] for i in ids if i >= len(SPECIALS))

src/rukh/tokenize/san_chars.pylíneas 16-46 · p1

encode lanza ante un carácter desconocido, al revés que el tokenizador UCI. El alfabeto SAN es cerrado por construcción, así que un carácter de fuera significa que la reconstrucción del SAN está rota, y un <unk> silencioso lo escondería detrás de una pérdida un poco peor. decode salta los especiales con el mismo truco del prefijo de control.

src/rukh/tokenize/san_chars.py
def san_text(san_movetext: str, result: str) -> str:
"""The exact text this scheme encodes: numbered SAN plus a space and the result."""
return f"{san_movetext} {result}"
def uci_to_san(uci: str) -> str:
"""Numbered SAN movetext (``1.e4 e5 2.Nf3 ...``) for a space-separated UCI game.
Raises ``ValueError`` on an illegal move, like the rest of the pipeline.
"""
import chess
board = chess.Board()
out: list[str] = []
for token in uci.split():
move = chess.Move.from_uci(token)
if move not in board.legal_moves:
raise ValueError(f"illegal move {token} in {board.fen()}")
san = board.san(move)
out.append(f"{board.fullmove_number}.{san}" if board.turn == chess.WHITE else san)
board.push(move)
return " ".join(out)

src/rukh/tokenize/san_chars.pylíneas 49-70 · p1

uci_to_san es el camino de vuelta del primer paso, sobre un tablero real por el mismo motivo: la misma jugada es Nf3 o Ngf3 según dónde esté el otro caballo. El número de jugada va pegado a la de las blancas (1.e4 e5 2.Nf3), y el gemelo de TypeScript tiene que copiarlo exacto: un espacio de más son partidas un 8 % más largas.

Sus tests, plegados, los pasa el «Ejecútalo» de debajo; si no los tecleas, tráelos antes con git checkout p1 -- tests/unit/test_san_chars.py.

// FontaneríaCódigo completo: tests/unit/test_san_chars.py
tests/unit/test_san_chars.py
"""Tests for rukh.tokenize.san_chars: alphabet, round trip and UCI to SAN conversion."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from rukh.tokenize.san_chars import ALPHABET, SanCharTokenizer, san_text, uci_to_san
pytestmark = pytest.mark.unit
def test_alphabet_has_32_symbols_and_specials_first() -> None:
tok = SanCharTokenizer()
assert len(ALPHABET) == 32
assert len(set(ALPHABET)) == 32
assert len(tok) == 35
assert tok.ids[:3] == ["<pad>", "<bos>", "<eos>"]
assert tok.vocab[" "] == 3
assert tok.vocab["/"] == 34
def test_round_trip() -> None:
tok = SanCharTokenizer()
text = san_text(
"1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Bxc6 dxc6 5.O-O f6 6.d4 exd4 7.Nxd4 c5", "1/2-1/2"
)
ids = tok.encode(text)
assert ids[0] == tok.bos_id and ids[-1] == tok.eos_id
assert len(ids) == len(text) + 2
assert tok.decode(ids) == text
def test_rejects_unknown_character() -> None:
with pytest.raises(ValueError):
SanCharTokenizer().encode("1.e4 e5 {comment}")
def test_uci_to_san_numbers_and_notation() -> None:
san = uci_to_san("e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 b5c6 d7c6 e1g1 f7f6")
assert san == "1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Bxc6 dxc6 5.O-O f6"
assert uci_to_san("e2e4 d7d5 e4d5 d8d5 b1c3 d5e5") == "1.e4 d5 2.exd5 Qxd5 3.Nc3 Qe5+"
with pytest.raises(ValueError):
uci_to_san("e2e5")
def test_fixture_san_ids_match(repo_root: Path) -> None:
tok = SanCharTokenizer()
fixture = json.loads(
(repo_root / "artifacts" / "tokenizer" / "fixtures" / "games.json").read_text("utf-8")
)
for entry in fixture:
assert entry["san_ids"] == tok.encode(san_text(entry["san"], entry["result"]))
assert uci_to_san(entry["uci"]) == entry["san"]

tests/unit/test_san_chars.pylíneas 1-56 · p1

bpe.py: dejar que los datos decidan

El algoritmo del BPEBPE (byte-pair encoding)Tokenizador aprendido de los datos: parte de los caracteres, fusiona una y otra vez el par de tokens adyacentes más frecuente y guarda cada fusión en orden hasta llegar al tamaño de vocabulario pedido (4 096 en Rukh). Sobre texto UCI redescubre primero las casillas, luego las jugadas y al final trozos de apertura. Nada queda fuera de vocabulario: lo raro se parte en trozos más cortos. lo viste en la apertura del módulo, y la lista de fusionesFusión de BPE (merge)Cada una de las uniones de un par de tokens que el BPE aprende, guardada en el orden en que la aprendió; en inglés, merge, que es como se llama en el fichero merges.txt y en la variable merges del lab. Tokenizar es aplicarlas en ese mismo orden: aplicar una tardía antes que una temprana da otra segmentación y peor. En Rukh son 4 069 para un vocabulario de 4 096., en orden, es el modelo. El algoritmo lo pone la librería tokenizers de Hugging Face, la de los LLM de texto; lo nuestro son las decisiones de cómo se le da de comer.

src/rukh/tokenize/bpe.py
"""BPE over UCI text with Hugging Face ``tokenizers``.
The text of one game is its UCI moves concatenated without spaces (``e2e4e7e5g1f3...``), so
that BPE can merge whole openings into one token. ``WhitespaceSplit`` keeps games apart when
several are fed as one text. The model has no normalizer, no continuing-subword prefix and no
end-of-word suffix: the TypeScript twin re-applies ``model.merges`` by rank over each
whitespace-split word and must reproduce ``Tokenizer.encode(text).ids`` exactly.
"""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import TYPE_CHECKING
from tokenizers import Tokenizer, models, pre_tokenizers, trainers
from rukh.tokenize.uci_vocab import UciTokenizer
if TYPE_CHECKING:
import chess
PROMOTION_LETTERS = frozenset("qrbn")
def bpe_text(uci: str) -> str:
"""The text BPE sees for one game: moves joined without spaces."""
return uci.replace(" ", "")

src/rukh/tokenize/bpe.pylíneas 1-28 · p1

El texto de una partida son sus jugadas pegadas, sin espacios. Con espacios, el pre-tokenizador cortaría por jugada y BPE nunca fusionaría a través de las fronteras; sin ellos, aprende que e2e4c7c5g1f3 van juntos, y de ahí salen los tokens de apertura entera.

WhitespaceSplit es el único pre-tokenizador, sin normalizador ni prefijos ni sufijos: cada opción sería algo más que el gemelo del navegador tendría que reproducir. Sigue haciendo falta para que dos partidas pasadas en un mismo texto no se fusionen entre sí.

src/rukh/tokenize/bpe.py
def split_moves(text: str) -> list[str]:
"""Split a spaceless UCI string back into moves.
A move is four characters plus a promotion letter, and ``b`` is both a file and a
promotion piece, so the split replays the game: the letter belongs to the move only when
the piece that moves is a pawn reaching the last rank (or, when the position is corrupt
and no piece sits on the origin square, when the ranks are 7 to 8 or 2 to 1).
"""
import chess
board = chess.Board()
moves: list[str] = []
i = 0
n = len(text)
while i < n:
move = text[i : i + 4]
i += 4
if i < n and text[i] in PROMOTION_LETTERS and _is_promotion(board, move):
move += text[i]
i += 1
moves.append(move)
try:
parsed = chess.Move.from_uci(move)
except ValueError:
break
if parsed in board.legal_moves:
board.push(parsed)
else:
break
if i < n:
moves.extend(_split_blind(text[i:]))
return moves

src/rukh/tokenize/bpe.pylíneas 31-62 · p1

split_moves vuelve a partir en jugadas el texto pegado, y es más delicado de lo que parece. Una jugada son cuatro caracteres, salvo las promociones, que son cinco, y la letra de promoción puede ser b, que también es una columna. ¿a7a8b1c3 es a7a8b más 1c3…, o a7a8 más b1c3? Con un tablero delante no hay ambigüedad: la letra es de la jugada solo si la pieza es un peón que llega a la última fila.

src/rukh/tokenize/bpe.py
def _is_promotion(board: chess.Board, move: str) -> bool:
import chess
if len(move) != 4:
return False
try:
from_sq = chess.parse_square(move[:2])
to_sq = chess.parse_square(move[2:])
except ValueError:
return False
piece = board.piece_at(from_sq)
if piece is None:
return move[1] + move[3] in ("78", "21") and abs(ord(move[0]) - ord(move[2])) <= 1
return piece.piece_type == chess.PAWN and chess.square_rank(to_sq) in (0, 7)
def _split_blind(text: str) -> list[str]:
"""Rank-based split for text after an illegal move (no board to consult)."""
moves: list[str] = []
i = 0
n = len(text)
while i < n:
move = text[i : i + 4]
i += 4
if (
i < n
and text[i] in PROMOTION_LETTERS
and len(move) == 4
and move[1] + move[3] in ("78", "21")
and abs(ord(move[0]) - ord(move[2])) <= 1
):
move += text[i]
i += 1
moves.append(move)
return moves

src/rukh/tokenize/bpe.pylíneas 65-99 · p1

Sin pieza en la casilla de origen, _is_promotion cae en la misma regla de filas que _split_blind: las dos degradan el mismo juicio de la misma forma.

src/rukh/tokenize/bpe.py
def train_bpe(
texts: Iterable[str],
vocab_size: int = 4096,
specials: list[str] | None = None,
) -> Tokenizer:
"""Train a BPE model on ``texts`` (one game per text, already spaceless)."""
tokenizer = Tokenizer(models.BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = pre_tokenizers.WhitespaceSplit()
trainer = trainers.BpeTrainer(
vocab_size=vocab_size,
special_tokens=list(UciTokenizer.specials if specials is None else specials),
show_progress=False,
)
tokenizer.train_from_iterator(texts, trainer)
return tokenizer
def save(tokenizer: Tokenizer, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tokenizer.save(str(path), pretty=True)
def load_bpe(path: Path) -> Tokenizer:
return Tokenizer.from_file(str(path))

src/rukh/tokenize/bpe.pylíneas 102-126 · p1

Los tokens especiales entran primero, con los mismos ids que en el UCI (<pad> 0, <bos> 1, <eos> 2), así que un pack de BPE usa el mismo ignore_index=0 y el mismo empaquetador, sin una rama por esquema.

src/rukh/tokenize/bpe.py
def encode(tokenizer: Tokenizer, text: str) -> list[int]:
return tokenizer.encode(text).ids
def decode(tokenizer: Tokenizer, ids: list[int]) -> str:
"""Concatenate the tokens (skipping specials) and re-split into space-separated moves."""
specials = set(UciTokenizer.specials)
tokens = [tokenizer.id_to_token(i) for i in ids]
text = "".join(t for t in tokens if t is not None and t not in specials)
return " ".join(split_moves(text))
def iter_parquet_texts(games: Path, n_games: int) -> Iterator[str]:
"""Yield ``bpe_text`` for the first ``n_games`` rows of a UCI games parquet."""
import polars as pl
frame = pl.scan_parquet(Path(games).as_posix()).select("uci").head(n_games).collect()
for uci in frame["uci"]:
yield bpe_text(uci)
def train_bpe_from_parquet(games: Path, out: Path, vocab_size: int, n_games: int) -> Tokenizer:
tokenizer = train_bpe(iter_parquet_texts(games, n_games), vocab_size=vocab_size)
save(tokenizer, out)
return tokenizer
def longest_tokens(tokenizer: Tokenizer, n: int = 10) -> list[dict[str, object]]:
"""The ``n`` longest learned tokens, with how many whole moves each spans."""
specials = set(UciTokenizer.specials)
vocab = tokenizer.get_vocab()
tokens = sorted((t for t in vocab if t not in specials), key=lambda t: (-len(t), vocab[t]))[:n]
return [{"token": t, "id": vocab[t], "length": len(t), "moves": len(t) // 4} for t in tokens]

src/rukh/tokenize/bpe.pylíneas 129-161 · p1

Sin split_moves, el BPE podría codificar pero no se podría leer lo que produce. El scan_parquet es perezoso, así que solo se materializan las 200 000 filas de una columna. longest_tokens usa el id como desempate, igual que scoring.py, para que las estadísticas sean reproducibles; su moves es una aproximación que basta para decir “este token se traga diez medias jugadasPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply.”.

El fichero de tests que fija la enumeración

tests/unit/test_uci_vocab.py es el test más importante de M1: vigila un contrato entre tres implementaciones y dos repositorios.

No hace falta leerlo para seguir la lección, pero sí tenerlo entero: es el que avisa si la enumeración de Python y la de TypeScript se separan. Si no lo tecleas, tráelo con git checkout p1 -- tests/unit/test_uci_vocab.py; se pasa en la lección siguiente, cuando exista fixture.py.

// Teststests/unit/test_uci_vocab.py: la enumeración, los tokens de encuadre y la exportación
tests/unit/test_uci_vocab.py
"""Tests for rukh.tokenize.uci_vocab: enumeration, encode/decode, export and the fixture."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from typer.testing import CliRunner
from rukh.cli import app
from rukh.tokenize.fixture import build_fixture, read_pgn_games
from rukh.tokenize.uci_vocab import (
SPECIALS,
UciTokenizer,
build_moves,
build_promotions,
build_vocab,
elo_bin,
elo_token,
squares,
)
pytestmark = pytest.mark.unit
VOCAB_SHA256 = "f6aa692bbd581c124dda4db0a088111580df4d311974bb5d4ec29b4d761142f1"
@pytest.fixture(scope="module")
def tok() -> UciTokenizer:
return UciTokenizer()

tests/unit/test_uci_vocab.pylíneas 1-32 · p1

Un valor literal en un test suele ser mala señal; VOCAB_SHA256 es la excepción, porque lo que se afirma es “la tabla es esta”, y no hay forma más compacta de decirlo.

tests/unit/test_uci_vocab.py
def test_squares_are_file_major() -> None:
sq = squares()
assert sq[:9] == ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "b1"]
assert sq[-1] == "h8"
assert sq.index("c5") == 2 * 8 + 4
def test_enumeration_sizes(tok: UciTokenizer) -> None:
assert len(build_moves()) == 1792
assert len(build_promotions()) == 176
assert len(build_vocab()) == 2030
assert len(tok) == 2030
assert len(tok.vocab) == 2030
def test_special_and_elo_ids(tok: UciTokenizer) -> None:
assert tok.ids[:8] == SPECIALS
assert tok.vocab["<pad>"] == 0 and tok.vocab["<bos>"] == 1 and tok.vocab["<eos>"] == 2
assert tok.vocab["<mask>"] == 3 and tok.vocab["<unk>"] == 4
assert tok.vocab["<1-0>"] == 5 and tok.vocab["<0-1>"] == 6 and tok.vocab["<1/2>"] == 7
assert tok.vocab["<w0600>"] == 8 and tok.vocab["<w3200>"] == 34
assert tok.vocab["<b0600>"] == 35 and tok.vocab["<b3200>"] == 61
assert tok.ids[62] == "a1a2"
assert tok.ids[-1] == "h2h1n"
def test_moves_and_promotions_present(tok: UciTokenizer) -> None:
for move in ("e2e4", "e7e8q", "g1f3", "a1a8", "a1h8", "e1g1", "e1c1", "a1b3"):
assert move in tok.vocab, move
# Neither a queen nor a knight reaches these.
for move in ("a1b4", "a1c4", "e2e2", "e7e8k", "e6e7q", "a7c8q"):
assert move not in tok.vocab, move
def test_promotion_order(tok: UciTokenizer) -> None:
promos = build_promotions()
assert promos[:8] == ["a7a8q", "a7a8r", "a7a8b", "a7a8n", "a7b8q", "a7b8r", "a7b8b", "a7b8n"]
assert promos[88] == "a2a1q"
assert tok.vocab["a7a8q"] == 62 + 1792

tests/unit/test_uci_vocab.pylíneas 35-73 · p1

Estos cinco tests son la enumeración entera escrita en aserciones. sq.index("c5") == 2 * 8 + 4 escribe la fórmula en vez del número para que el test documente la convención. Comprobar len(tok) y len(tok.vocab) no es redundante: si hubiera un duplicado, el diccionario sería menor. Los ids concretos (<w0600> es el 8, el 62 es a1a2, el último h2h1n) son los que TypeScript tiene que reproducir, y tenerlos escritos convierte una divergencia en un fallo con nombre. La segunda lista de test_moves_and_promotions_present es la interesante: cada jugada ausente prueba una regla distinta de la enumeración.

tests/unit/test_uci_vocab.py
@pytest.mark.parametrize(
("elo", "expected"),
[
(0, 600),
(599, 600),
(600, 600),
(1850, 1800),
(1899, 1800),
(3199, 3100),
(3200, 3200),
(3299, 3200),
(3300, 3200),
(9999, 3200),
],
)
def test_elo_bin_clamps(elo: int, expected: int) -> None:
assert elo_bin(elo) == expected
def test_elo_token_format() -> None:
assert elo_token(650, "w") == "<w0600>"
assert elo_token(2412, "b") == "<b2400>"
with pytest.raises(ValueError):
elo_token(1800, "x")

tests/unit/test_uci_vocab.pylíneas 76-99 · p1

Los casos de arriba (3199 baja a 3100; 3200, 3299 y 9999 se quedan en 3200) son los que prueban el techo del recorte.

tests/unit/test_uci_vocab.py
def test_encode_game_layout(tok: UciTokenizer) -> None:
ids = tok.encode_game("e2e4 e7e5", 1850, 1920, "1-0")
assert ids == [
1,
tok.vocab["<w1800>"],
tok.vocab["<b1900>"],
tok.vocab["e2e4"],
tok.vocab["e7e5"],
5,
2,
]
assert tok.encode_game("", 1000, 1000, "1/2-1/2")[-2:] == [7, 2]
assert tok.encode_game("", 1000, 1000, "0-1")[-2:] == [6, 2]
def test_encode_game_truncates(tok: UciTokenizer) -> None:
moves = " ".join(["g1f3", "g8f6", "f3g1", "f6g8"] * 60)
ids = tok.encode_game(moves, 1800, 1800, "1/2-1/2", max_len=200)
assert len(ids) == 200
assert ids[-1] != tok.eos_id
full = tok.encode_game(moves, 1800, 1800, "1/2-1/2", max_len=10_000)
assert len(full) == 3 + 240 + 2 and full[-1] == tok.eos_id
def test_encode_game_unknown_move_and_result(tok: UciTokenizer) -> None:
assert tok.encode_game("zz99", 1800, 1800, "1-0")[3] == tok.unk_id
with pytest.raises(ValueError):
tok.encode_game("e2e4", 1800, 1800, "*")

tests/unit/test_uci_vocab.pylíneas 102-129 · p1

test_encode_game_layout escribe los ids de control como número, porque son constantes del formato, y los de las jugadas por nombre, porque son consecuencia de la enumeración. test_encode_game_truncates prueba las dos ramas del corte con 240 plies de caballos yendo y viniendo: con 200, el último id no es <eos>; sin límite, salen 3 + 240 + 2 y sí lo es.

tests/unit/test_uci_vocab.py
def test_decode_roundtrip(tok: UciTokenizer) -> None:
ids = tok.encode_game("e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 b5c6 d7c6", 2100, 2050, "0-1")
tokens = tok.decode(ids)
assert tokens == [
"<bos>",
"<w2100>",
"<b2000>",
"e2e4",
"e7e5",
"g1f3",
"b8c6",
"f1b5",
"a7a6",
"b5c6",
"d7c6",
"<0-1>",
"<eos>",
]
assert [tok.vocab[t] for t in tokens] == ids
def test_export_is_stable(tmp_path: Path, tok: UciTokenizer) -> None:
path = tmp_path / "vocab.json"
tok.export(path)
data = path.read_bytes()
assert hashlib.sha256(data).hexdigest() == VOCAB_SHA256
payload = json.loads(data)
assert payload["version"] == 1
assert payload["size"] == 2030
assert payload["specials"] == SPECIALS
assert payload["elo_bins"] == {"min": 600, "max": 3200, "step": 100}
assert payload["tokens"] == tok.ids
assert UciTokenizer.from_file(path).ids == tok.ids
def test_committed_vocab_matches(repo_root: Path) -> None:
path = repo_root / "artifacts" / "tokenizer" / "vocab.json"
assert hashlib.sha256(path.read_bytes()).hexdigest() == VOCAB_SHA256

tests/unit/test_uci_vocab.pylíneas 132-169 · p1

Leer la lista de test_decode_roundtrip es la mejor forma de ver qué es una secuencia de este modelo. Los dos tests de hash, sobre el fichero recién exportado y sobre el versionado, garantizan que lo que se publica y lo que copian las webs es lo que produce este código, y no una versión que alguien olvidó regenerar.

tests/unit/test_uci_vocab.py
def test_fixture_pgn_has_20_legal_varied_games(repo_root: Path) -> None:
games = read_pgn_games(repo_root / "tests" / "fixtures" / "games.pgn")
assert len(games) == 20
results = {g["result"] for g in games}
assert results == {"1-0", "0-1", "1/2-1/2"}
plies = [len(str(g["uci"]).split()) for g in games]
assert min(plies) < 10 and max(plies) > 200
all_uci = " ".join(str(g["uci"]) for g in games).split()
assert "e1g1" in all_uci and "e1c1" in all_uci and "e8g8" in all_uci
assert any(m.endswith("n") and len(m) == 5 for m in all_uci)
def test_committed_fixture_matches_encoder(repo_root: Path, tok: UciTokenizer) -> None:
fixture = json.loads(
(repo_root / "artifacts" / "tokenizer" / "fixtures" / "games.json").read_text("utf-8")
)
entries = build_fixture(repo_root / "tests" / "fixtures" / "games.pgn", tok)
assert len(fixture) == 20
for got, want in zip(fixture, entries, strict=True):
for key in ("id", "white_elo", "black_elo", "result", "uci", "san", "uci_ids"):
assert got[key] == want[key], (got["id"], key)
assert len(got["uci_ids"]) <= 200
assert got["uci_ids"][0] == 1

tests/unit/test_uci_vocab.pylíneas 172-194 · p1

El primer test de la fixture comprueba que las veinte partidas siguen siendo variadas (los tres resultados, una muy corta y una muy larga, enroques de los dos lados, una promoción a caballo): una fixture de paridad con solo partidas normales no probaría nada de lo que se rompe. El segundo comprueba que el games.json versionado coincide con el codificador actual, porque las webs miden su paridad contra él.

tests/unit/test_uci_vocab.py
def test_cli_tokenize_exports_fixture(rukh_home: Path, repo_root: Path) -> None:
cfg = rukh_home / "pipeline.yaml"
pgn = (repo_root / "tests" / "fixtures" / "games.pgn").as_posix()
cfg.write_text(f"tokenize:\n fixture_pgn: {pgn}\n", encoding="utf-8")
result = CliRunner().invoke(
app, ["data", "tokenize", "--config", str(cfg), "--scheme", "uci", "--export-fixture"]
)
assert result.exit_code == 0, result.output
assert (rukh_home / "artifacts" / "tokenizer" / "vocab.json").is_file()
assert (rukh_home / "artifacts" / "tokenizer" / "fixtures" / "games.json").is_file()
assert "vocab_size: 2030" in result.output
def test_cli_tokenize_rejects_bad_scheme() -> None:
result = CliRunner().invoke(app, ["data", "tokenize", "--scheme", "nope"])
assert result.exit_code == 2

tests/unit/test_uci_vocab.pylíneas 197-212 · p1

Los dos de la CLI comprueban la exportación y que un esquema inexistente sale con el código 2 de “mal uso” de typer en vez de reventar.

// Ejercicio 01Cambia el orden de las casillas y cuenta los daños

En una copia del repositorio, cambia build_moves a orden por filas: intercambia los dos bucles de fuera (for fr fuera y for ff dentro) y los dos de dentro (for tr y luego for tf), y ejecuta uv run pytest -m unit -q tests/unit/test_uci_vocab.py. ¿Cuántos tests fallan? ¿Cuáles no fallan, y por qué es eso lo preocupante?

// SoluciónVer la solución

Fallan tres de 26: test_special_and_elo_ids (el id 62 ya no es a1a2 sino a1b1), test_export_is_stable (el sha256 del vocab.json exportado cambia) y test_committed_fixture_matches_encoder (los ids de la fixture guardada ya no son los que produce el codificador).

Lo interesante es lo que no falla: test_enumeration_sizes, test_promotion_order, test_encode_game_layout y test_decode_roundtrip siguen en verde, porque una enumeración distinta es internamente coherente. Tampoco falla test_squares_are_file_major, porque build_moves no llama a squares(): cada una lleva su propio orden, y el test de una no protege a la otra. Ni test_committed_vocab_matches, que mira el vocab.json guardado y no el código hasta que lo vuelvas a exportar. Solo detecta el cambio lo que compara contra algo de fuera, el hash y la fixture. Con tests de coherencia interna nada más, dos implementaciones que divergen pasarían las dos sus tests.

Qué has aprendido

Tienes tres formas de convertir una partida en números: una enumeración que no mira los datos y por eso se reproduce en otro lenguaje, un alfabeto cerrado que prefiere lanzar antes que inventar un <unk> y un BPE cuyas decisiones están en cómo se le da de comer.

Cómo se mide: uv run pytest -m unit -q tests/unit/test_uci_vocab.py tests/unit/test_san_chars.py tests/unit/test_bpe.py pasa en verde en cuanto la lección siguiente añade fixture.py y exporta los artefactos, y el vocab.json exportado tiene el sha256 que el test fija. Los números del vocabulario son 2 030, 35 y 4 096.

Lo siguiente es convertir eso en tensores: el empaquetador que escribe seis millones de partidas en dos arrays de uint16, uno por mes, y el dataloader que sirve lotes desde él.