// 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.
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.
"""Tokenization schemes for chess games: fixed UCI vocabulary, char-level SAN and BPE."""Sin reexportes, para que importar rukh.tokenize no arrastre tokenizers ni polars.
uci_vocab.py: una tabla que se calcula, no se guarda
"""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 tothis enumeration breaks parity with ``rukh-web/src/lib/chess-lm/tokenizer.ts`` and with``artifacts/tokenizer/fixtures/games.json``."""
from __future__ import annotations
import jsonfrom 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 = 600ELO_MAX = 3200ELO_STEP = 100
SPECIALS: list[str] = ["<pad>", "<bos>", "<eos>", "<mask>", "<unk>", "<1-0>", "<0-1>", "<1/2>"]PAD_ID = 0BOS_ID = 1EOS_ID = 2MASK_ID = 3UNK_ID = 4src/rukh/tokenize/uci_vocab.py
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.
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.py
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.
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 promotionssrc/rukh/tokenize/uci_vocab.py
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.
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.py
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).
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.py
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.
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.py
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>.
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 tokenizersrc/rukh/tokenize/uci_vocab.py
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
"""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 = 0BOS_ID = 1EOS_ID = 2src/rukh/tokenize/san_chars.py
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.
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.py
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.
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.py
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 for rukh.tokenize.san_chars: alphabet, round trip and UCI to SAN conversion."""
from __future__ import annotations
import jsonfrom 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"]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.
"""BPE over UCI text with Hugging Face ``tokenizers``.
The text of one game is its UCI moves concatenated without spaces (``e2e4e7e5g1f3...``), sothat BPE can merge whole openings into one token. ``WhitespaceSplit`` keeps games apart whenseveral are fed as one text. The model has no normalizer, no continuing-subword prefix and noend-of-word suffix: the TypeScript twin re-applies ``model.merges`` by rank over eachwhitespace-split word and must reproduce ``Tokenizer.encode(text).ids`` exactly."""
from __future__ import annotations
from collections.abc import Iterable, Iteratorfrom pathlib import Pathfrom 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(" ", "")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í.
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 movessplit_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.
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 movesSin 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.
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))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.
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]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 for rukh.tokenize.uci_vocab: enumeration, encode/decode, export and the fixture."""
from __future__ import annotations
import hashlibimport jsonfrom pathlib import Path
import pytestfrom typer.testing import CliRunner
from rukh.cli import appfrom rukh.tokenize.fixture import build_fixture, read_pgn_gamesfrom 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()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.
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 + 1792Estos 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.
@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")Los casos de arriba (3199 baja a 3100; 3200, 3299 y 9999 se quedan en 3200) son los que prueban el techo del recorte.
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, "*")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.
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_SHA256Leer 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.
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] == 1El 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.
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 == 2Los 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.