rukh · lab

// M2 · lección 04

Muestrear jugadas

De 2 030 logits a `e2e4`: la máscara de legalidad, la temperatura y el top-k en el único orden que funciona, el recorte del prompt que conserva la cabecera, y la partida entera con su contador de propuestas ilegales, que es la señal cruda sobre la que se monta toda la evaluación.

  • tiempo de trabajo190 min
  • nivel base
  • actualizado el22 de septiembre de 2026

Qué vas a construir

src/rukh/infer/: 483 líneas que convierten un vector de 2 030 números en una jugada legal y, de ahí, en una partida terminada. Es el puente entre el modelo y todo lo que viene después —la evaluación, el Elo, la demo—, y tiene dentro la decisión que hace medible el módulo entero: el muestreador devuelve dos cosas, la jugada y qué propuso la red.

Teoría justa: tres perillas y un orden

El modelo da 2 030 logits. Convertirlos en una jugada es una decisión de diseño con tres perillas.

Antes de las perillas, una advertencia sobre la palabra que todo el mundo usa para la temperatura: creatividad. Es falsa, y entenderlo ahorra disgustos. Le preguntas a alguien que se conoce el restaurante qué pedir. A temperatura cero te dice siempre su plato favorito, el mismo cada vez; subiéndola empieza a nombrarte el segundo y el tercero de su lista; muy arriba te acaba proponiendo la ensalada que no le gusta a nadie. No ha aprendido platos nuevos. Solo está dispuesto a bajar más por la lista que ya tenía. La temperatura no añade opciones, redistribuye confianza entre las que el modelo ya había ordenado —y por eso bajar por la lista cuesta, en este proyecto, unos 180 puntos de Elo—.

TemperaturaTemperaturaDivisor que se aplica a los logits antes del softmax. Por debajo de 1 concentra la probabilidad en las jugadas que el modelo prefiere (con temperatura 0 es directamente el argmax) y por encima de 1 la reparte. En Rukh el valor por defecto es 0,6: juego variado pero no temerario.. Se dividen los logits por T antes del softmax. Con T = 1 la distribución es la que el modelo aprendió; con T pequeña las diferencias se amplifican y la mejor jugada se lleva casi toda la probabilidad (en el límite, T → 0, es el argmax); con T grande todo se aplana hacia el azar. Rukh usa 0,6 por defecto: suficiente para que dos partidas no sean idénticas, poco para que el modelo se tire a la piscina.

Top-kTop-kTruncamiento del muestreo: solo se conservan los k tokens de mayor probabilidad y el resto pasa a probabilidad cero antes de sortear. Con k = 20 el modelo nunca juega una ocurrencia de la cola de la distribución, que es de donde salen casi todas las jugadas absurdas.. Se conservan solo los k logits mayores y el resto pasa a menos infinito. Con k = 20 de 2 030 jugadas, la cola —donde hay mil jugadas con probabilidad 0,0001 cada una, que sumadas son un 10 % de probabilidad de hacer una tontería— desaparece. Temperatura y top-k no son alternativas sino complementarias: la temperatura reescala, el top-k recorta. Sin top-k, una temperatura alta muestrea basura; sin temperatura, el top-k solo limita cuánta basura.

La máscara de legalidad. Antes de nada, se calcula con python-chess la lista de jugadas legales en la posición y se ponen a menos infinito todos los demás logits. Con la máscara puesta, el modelo no puede proponer una jugada ilegal. Eso hace la demo jugable y es lo que se usa en las partidas contra Stockfish. Y aquí está el argumento honesto de todo el módulo:

sampler.py: una jugada

src/rukh/infer/sampler.py
"""Sampling one move from a ``MoveDecoder``, with or without the legality mask.
The mask is the honest part of the demo: with ``mask_illegal`` the logits of every token that
is not a legal move become ``-inf``, so an illegal move is impossible; without it the raw token
is returned as it came out of the network and reported with ``legal=False``, which is how the
legality rate (the "understanding" metric of ``docs/spec/02``) is measured.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import chess
import torch
from pydantic import model_validator
from rukh.config import BaseConfig
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
TOP_N = 5
HEADER_TOKENS = 3
"""``<bos> <wXXXX> <bXXXX>``: the control prefix every training sequence starts with."""

src/rukh/infer/sampler.pylíneas 1-24 · p2

La cabecera dice para qué existe el fichero y HEADER_TOKENS = 3 es la constante que aparece en tres sitios más del proyecto: el recorte del prompt de aquí abajo, el límite de plies de una partida y la construcción de prefijos de la evaluación. Es el <bos> más los dos tokens de Elo que M1 puso al principio de cada secuencia de entrenamiento.

src/rukh/infer/sampler.py
class SampleConfig(BaseConfig):
"""How a move is drawn: temperature, top-k truncation and the legality mask."""
temperature: float = 0.6
top_k: int | None = 20
mask_illegal: bool = True
seed: int | None = None
@model_validator(mode="after")
def _check(self) -> SampleConfig:
if self.temperature < 0:
raise ValueError(f"temperature must be non-negative, got {self.temperature}")
if self.top_k is not None and self.top_k < 1:
raise ValueError(f"top_k must be at least 1, got {self.top_k}")
return self
def generator(self, device: torch.device | str | None = None) -> torch.Generator | None:
"""A seeded generator on ``device``, or None when the run is not reproducible.
The device matters: ``torch.multinomial`` refuses a CPU generator when the
probabilities live on CUDA, so the generator has to be built where the model is.
``model_generator`` does that for a given model.
"""
if self.seed is None:
return None
return torch.Generator(device=device or "cpu").manual_seed(self.seed)

src/rukh/infer/sampler.pylíneas 27-52 · p2

SampleConfig es BaseConfig, así que se puede leer de un YAML y una clave desconocida es un error. Los valores por defecto son los de la demo: 0,6 y 20.

temperature=0 es un valor legal y significa argmax, que es el motivo de que el validador acepte cero y rechace solo los negativos. top_k=None es «sin recorte» y top_k=0 es un error, porque quedarse con las cero mejores jugadas no es una intención que nadie tenga.

src/rukh/infer/sampler.py
def model_generator(model: MoveDecoder, cfg: SampleConfig) -> torch.Generator | None:
"""The sampling generator of ``cfg`` built on the device the model's weights live on."""
return cfg.generator(next(model.parameters()).device)

src/rukh/infer/sampler.pylíneas 55-57 · p2

Tres líneas, y son el arreglo del callout de arriba: el generador se construye en el dispositivo donde viven los pesos, preguntándoselo al propio modelo. Ninguno de los cinco sitios que muestrean tiene que acordarse.

src/rukh/infer/sampler.py
def prompt_ids(history: Sequence[int], block: int) -> list[int]:
"""Crop ``history`` to ``block`` ids **keeping the header**: ``header + moves[-(block - 3):]``.
A plain left crop (``history[-block:]``) silently drops ``<bos> <wXXXX> <bXXXX>`` as soon as a
game is longer than the context, so the model loses the Elo conditioning it was trained with
and every learned position is shifted by three. Keeping the header costs three move tokens and
keeps the prompt shaped like the training data.
"""
ids = list(history)
if len(ids) <= block:
return ids
if block <= HEADER_TOKENS:
return ids[:block]
return ids[:HEADER_TOKENS] + ids[len(ids) - (block - HEADER_TOKENS) :]

src/rukh/infer/sampler.pylíneas 60-73 · p2

La función corta de la que depende que el modelo juegue como se entrenó. Un recorte por la izquierda a secas —history[-block:], que es lo que hace next_logits— se lleva <bos> <wXXXX> <bXXXX> en cuanto una partida es más larga que el contexto. El modelo pierde el condicionamiento por Elo con el que se entrenó y, además, todas sus posiciones aprendidas quedan desplazadas tres lugares: la jugada que él cree que es la primera de la partida es en realidad la cuarta.

Conservar la cabecera cuesta tres tokens de jugada —de doscientos— y mantiene el prompt con la forma de los datos de entrenamiento. La guarda block <= HEADER_TOKENS es el caso absurdo que solo aparece en un test, y devuelve algo en vez de un índice negativo.

src/rukh/infer/sampler.py
def legal_token_ids(board: chess.Board, tok: UciTokenizer) -> list[int]:
"""Sorted vocabulary ids of every legal move in ``board``.
Castling is the king's two-square move (``e1g1``), the same string the tokenizer enumerates;
a legal move that is somehow missing from the vocabulary is skipped rather than guessed.
"""
ids = {tok.vocab[uci] for move in board.legal_moves if (uci := move.uci()) in tok.vocab}
return sorted(ids)

src/rukh/infer/sampler.pylíneas 76-83 · p2

Las jugadas legales, traducidas a ids del vocabulario. Dos decisiones en cinco líneas:

  • El enroque es e1g1, el movimiento de dos casillas del rey, que es la cadena que enumera el tokenizador de M1 y la que devuelve Move.uci() de python-chess en el modo por defecto. Si el tokenizador hubiera enumerado la notación de Chess960 (e1h1, rey captura torre) el vocabulario y el motor hablarían idiomas distintos y el enroque sería literalmente imposible.
  • Una jugada legal que faltara del vocabulario se salta, no se adivina. Con la enumeración de M1 no puede pasar; y si algún día pasa, una conjetura silenciosa sería peor que una jugada que el muestreador simplemente no propone.

El sorted(set(...)) no es cosmético: el conjunto de ids tiene que ser determinista para que dos ejecuciones con la misma semilla den la misma partida.

src/rukh/infer/sampler.py
def _filtered_logits(logits: torch.Tensor, legal: list[int], cfg: SampleConfig) -> torch.Tensor:
"""Apply the legality mask, the temperature and the top-k truncation, in that order."""
out = logits.float()
if cfg.mask_illegal:
keep = torch.zeros_like(out, dtype=torch.bool)
keep[torch.tensor(legal, dtype=torch.long, device=out.device)] = True
out = out.masked_fill(~keep, float("-inf"))
if cfg.temperature > 0:
out = out / cfg.temperature
if cfg.top_k is not None:
k = min(cfg.top_k, out.numel())
threshold = torch.topk(out, k).values[-1]
out = out.masked_fill(out < threshold, float("-inf"))
return out

src/rukh/infer/sampler.pylíneas 86-99 · p2

El orden de estas tres operaciones no es libre, y es la mitad del diseño del muestreador.

Hacer el top-k antes de la máscara se quedaría con los veinte tokens más probables y después descartaría los ilegales, lo que puede dejar dos candidatos o ninguno. Poner la temperatura después del top-k reescalaría una distribución a la que ya le han quitado la cola, así que la perilla significaría algo distinto con cada k.

Dos detalles más. El logits.float() de la primera línea sube a fp32 lo que puede venir en bf16: las divisiones por una temperatura pequeña y los -inf merecen el rango entero. Y torch.topk(out, k).values[-1] es el umbral —el valor del k-ésimo— y se compara con <, no con <=: un empate en el umbral conserva las dos jugadas empatadas en vez de elegir una por el orden del tensor.

src/rukh/infer/sampler.py
def pick_move(
model: MoveDecoder,
tok: UciTokenizer,
board: chess.Board,
history_ids: list[int],
cfg: SampleConfig,
generator: torch.Generator | None = None,
) -> tuple[chess.Move | None, dict[str, Any]]:
"""Draw the next move token and report what the network proposed.
Returns the move (``None`` when the proposed token is not a legal move) and a report with
``legal``, ``raw_token``, ``top5`` (token, probability of the distribution actually sampled)
and ``masked``. ``generator`` lets a whole game advance one seeded stream; build it with
``model_generator`` so it lives on the same device as the weights.
The history is cropped with ``prompt_ids``, so a game longer than the context keeps its
``<bos>`` and Elo tokens instead of being cut off the left edge.
"""
legal = legal_token_ids(board, tok)
report: dict[str, Any] = {
"legal": False,
"raw_token": None,
"top5": [],
"masked": cfg.mask_illegal,
}
if cfg.mask_illegal and not legal:
return None, report

src/rukh/infer/sampler.pylíneas 102-128 · p2

src/rukh/infer/sampler.py
device = next(model.parameters()).device
ids = prompt_ids(history_ids, model.cfg.block)
idx = torch.tensor([ids], dtype=torch.long, device=device)
logits = _filtered_logits(model.next_logits(idx)[0], legal, cfg)
probs = torch.softmax(logits, dim=-1)
if cfg.temperature == 0:
token_id = int(torch.argmax(logits))
else:
token_id = int(torch.multinomial(probs, 1, generator=generator))
top = torch.topk(probs, min(TOP_N, probs.numel()))
report["top5"] = [
(tok.ids[int(i)], float(p)) for p, i in zip(top.values, top.indices, strict=True) if p > 0
]
report["raw_token"] = tok.ids[token_id]
move = _as_move(report["raw_token"])
if move is not None and board.is_legal(move):
report["legal"] = True
return move, report
return None, report

src/rukh/infer/sampler.pylíneas 130-149 · p2

pick_move devuelve dos cosas, y esa es la decisión que hace medible todo el módulo: la jugada (None cuando el token propuesto no es legal aquí) y un informe con legal, raw_token, las cinco primeras y si se enmascaró. Un muestreador que solo devolviera la jugada haría imposible medir la tasa de legalidad sin máscara, que es la primera columna de la tabla única.

Lo demás son consecuencias. El if cfg.mask_illegal and not legal sale antes de gastar una pasada por el modelo en una posición terminada. El argmax se toma sobre logits y no sobre probs, que da lo mismo pero se lee mejor. top5 se calcula sobre la distribución realmente muestreada —después de la máscara, la temperatura y el top-k—, así que sus probabilidades suman uno y no son las del modelo crudo; es lo que le interesa a quien depura una partida, y hay un test que lo fija. Y el if p > 0 deja fuera los ceros que el top-k acaba de producir cuando hay menos de cinco candidatos.

src/rukh/infer/sampler.py
def _as_move(token: str) -> chess.Move | None:
"""A move token as a ``chess.Move``; special tokens such as ``<eos>`` give None."""
try:
return chess.Move.from_uci(token)
except ValueError:
return None

src/rukh/infer/sampler.pylíneas 152-157 · p2

Seis líneas para el detalle que nadie recuerda: el vocabulario tiene tokens que no son jugadas —<bos>, <eos>, <1-0>, los de Elo—, y sin máscara el modelo puede proponerlos. Move.from_uci lanza ValueError con ellos, así que la conversión devuelve None y el informe lo cuenta como ilegal, que es exactamente lo que es.

game.py: una partida entera

src/rukh/infer/game.py
"""Playing a whole game: the model against an opponent, counting illegal proposals.
An opponent is anything with ``choose(board) -> chess.Move``: the seeded ``RandomOpponent``
used by the unit tests, ``StockfishOpponent`` for the Elo harness, or the browser in the demo.
"""
from __future__ import annotations
import random
from pathlib import Path
from types import TracebackType
from typing import Protocol
import chess
import chess.engine
from pydantic import BaseModel, ConfigDict
from rukh.infer.sampler import (
HEADER_TOKENS,
SampleConfig,
legal_token_ids,
model_generator,
pick_move,
)
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import RESULT_TOKENS, UciTokenizer, elo_token
MOVE_TIME_SECONDS = 0.1
"""Default seconds per move for the Stockfish opponent (``docs/decisiones-de-ejecucion`` D-025)."""
REPETITION_CLOCK = 8
"""A threefold repetition needs at least this many reversible plies, so the check is gated on it."""
FIFTY_MOVE_PLIES = 100

src/rukh/infer/game.pylíneas 1-32 · p2

Un oponente es cualquier cosa con choose(board) -> chess.Move: un Protocol, no una clase base. Eso es lo que permite que el mismo play_game sirva para el oponente aleatorio de los tests, para Stockfish en el harness de Elo y para lo que haga falta después, sin que ninguno de los tres tenga que heredar de nada.

src/rukh/infer/game.py
class Opponent(Protocol):
"""Whatever plays the other side."""
def choose(self, board: chess.Board) -> chess.Move: ...
class RandomOpponent:
"""A seeded random legal mover: the cheap baseline every test can afford."""
def __init__(self, seed: int = 0) -> None:
self.rng = random.Random(seed)
def choose(self, board: chess.Board) -> chess.Move:
return self.rng.choice(list(board.legal_moves))

src/rukh/infer/game.pylíneas 35-48 · p2

src/rukh/infer/game.py
class StockfishOpponent:
"""Stockfish limited to ``elo``; close it (or use it as a context manager) when done.
``UCI_Elo`` bottoms out at 1320, so a ``skill`` between 0 and 20 may be given instead: it
selects ``Skill Level``, the only way to get rungs below that floor for the Elo harness.
"""
def __init__(
self,
elo: int = 1400,
move_time: float = MOVE_TIME_SECONDS,
path: Path | None = None,
skill: int | None = None,
) -> None:
from rukh.engine import EngineNotFound, find_stockfish
binary = path or find_stockfish()
if binary is None:
raise EngineNotFound("Stockfish not found; set RUKH_STOCKFISH or run get_stockfish.py")
self.move_time = move_time
self.engine = chess.engine.SimpleEngine.popen_uci(str(binary))
if skill is None:
self.engine.configure({"UCI_LimitStrength": True, "UCI_Elo": elo})
else:
self.engine.configure({"UCI_LimitStrength": False, "Skill Level": skill})
def choose(self, board: chess.Board) -> chess.Move:
played = self.engine.play(board, chess.engine.Limit(time=self.move_time))
if played.move is None:
raise chess.engine.EngineError("Stockfish returned no move")
return played.move
def close(self) -> None:
self.engine.quit()
def __enter__(self) -> StockfishOpponent:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.close()

src/rukh/infer/game.pylíneas 51-95 · p2

El oponente de verdad. UCI_Elo de Stockfish no baja de 1320, así que para tener escalones por debajo hay que usar Skill Level, que es otro mecanismo —introduce errores deliberados— y cuya equivalencia en Elo es una convención y no una medida. El constructor acepta uno u otro y configura el motor en consecuencia, con UCI_LimitStrength encendido o apagado según cuál.

Esa distinción, que aquí parecen dos ramas de un if, es la que dos lecciones más adelante resulta estar detrás de un error de 350 puntos de Elo en la tabla publicada del módulo.

El gestor de contexto (__enter__/__exit__ más close) existe porque SimpleEngine.popen_uci arranca un proceso. Sin cerrarlo, una suite que juega ocho escalones deja ocho Stockfish vivos.

src/rukh/infer/game.py
class GameResult(BaseModel):
"""One finished (or cut) game, from the point of view of the board."""
model_config = ConfigDict(extra="forbid")
result: str # 1-0, 0-1, 1/2-1/2 or * when the game was cut short
plies: int
illegal_proposals: int
moves: list[str]
termination: str
fen: str = chess.STARTING_FEN
"""The final position, so a game cut by the context limit can still be adjudicated."""
@property
def cut(self) -> bool:
"""True when the game ran out of context instead of ending on the board."""
return self.result == "*"

src/rukh/infer/game.pylíneas 98-114 · p2

GameResult con result="*" es una partida que se quedó sin contexto, no unas tablas, y por eso lleva el fen final: la lección del Elo la va a adjudicar en vez de apuntarle medio punto a cada uno.

src/rukh/infer/game.py
def is_over(board: chess.Board) -> bool:
"""Whether the game is finished, including the claimable draws, without the O(n^2) check.
``board.is_game_over(claim_draw=True)`` tries every legal move looking for a claimable
repetition, which is quadratic over a long game and is called once per ply. The claimable
draws are covered here by the fifty-move clock and by ``is_repetition`` (a scan of the move
stack) gated on a halfmove clock that makes a repetition possible at all.
"""
if board.is_game_over():
return True
if board.halfmove_clock >= FIFTY_MOVE_PLIES:
return True
return board.halfmove_clock >= REPETITION_CLOCK and board.is_repetition(3)

src/rukh/infer/game.pylíneas 117-129 · p2

Trece líneas de optimización que valen una suite entera. board.is_game_over(claim_draw=True) prueba todas las jugadas legales buscando una repetición reclamable; eso es cuadrático sobre una partida larga y se llama una vez por ply. Aquí se cubren las mismas tablas reclamables con el reloj de las cincuenta jugadas y con is_repetition(3), que recorre la pila, y esa segunda comprobación va detrás de una guarda barata: hacen falta al menos ocho plies reversibles para que una repetición triple sea posible.

src/rukh/infer/game.py
def _history(tok: UciTokenizer, white_elo: int, black_elo: int) -> list[int]:
return [
tok.bos_id,
tok.vocab[elo_token(white_elo, "w")],
tok.vocab[elo_token(black_elo, "b")],
]

src/rukh/infer/game.pylíneas 132-137 · p2

src/rukh/infer/game.py
def play_game(
model: MoveDecoder,
tok: UciTokenizer,
opponent: Opponent,
cfg: SampleConfig,
model_color: chess.Color = chess.WHITE,
white_elo: int = 1800,
black_elo: int = 1800,
max_plies: int | None = None,
board: chess.Board | None = None,
) -> GameResult:
"""Play until the game ends or ``max_plies`` is reached and report what happened.
The model's illegal proposals are counted and then rescued with a masked draw, so a game
always terminates: ``illegal_proposals`` is the raw legality signal, not a failure.
A game that hits ``max_plies`` comes back with ``result="*"`` and the final ``fen``: scoring
it as a draw would flatter (or punish) the model, so the Elo harness adjudicates it instead.
"""
board = board if board is not None else chess.Board()
limit = max_plies if max_plies is not None else model.cfg.block - HEADER_TOKENS - 1
generator = model_generator(model, cfg)
masked = cfg.model_copy(update={"mask_illegal": True})
history = _history(tok, white_elo, black_elo)
moves: list[str] = []
illegal = 0
while len(moves) < limit and not is_over(board):
if board.turn == model_color:
move, report = pick_move(model, tok, board, history, cfg, generator)
if move is None:
illegal += 1
move, _ = pick_move(model, tok, board, history, masked, generator)
if move is None:
break
else:
move = opponent.choose(board)
uci = move.uci()
moves.append(uci)
history.append(tok.vocab.get(uci, tok.unk_id))
board.push(move)
outcome = board.outcome(claim_draw=True)
return GameResult(
result=outcome.result() if outcome else "*",
plies=len(moves),
illegal_proposals=illegal,
moves=moves,
termination=outcome.termination.name.lower() if outcome else "cut_short",
fen=board.fen(),
)

src/rukh/infer/game.pylíneas 140-190 · p2

La partida. Tres decisiones que la tabla de resultados hereda:

  1. Una propuesta ilegal se cuenta y se rescata. illegal += 1 y después un segundo pick_move con una copia de la configuración que tiene la máscara puesta. La partida tiene que terminar, así que hay que darle una jugada; pero el rescate no puede esconder que ocurrió. Ese contador es la señal cruda de legalidad sobre la que está montado el harness entero.
  2. El rescate no le vuelve a preguntar lo mismo. No es un reintento: es un muestreo enmascarado. Insistir hasta que el modelo conteste algo legal mediría un modelo distinto del que describe la tasa de legalidad.
  3. El límite por defecto es block - HEADER_TOKENS - 1. El contexto menos la cabecera menos el token que se está prediciendo: el número exacto de jugadas que caben en el prompt sin que el recorte tenga que empezar a tirar jugadas.

Y el history.append(tok.vocab.get(uci, tok.unk_id)) de cada ply incluye las jugadas del oponente: el prompt del modelo es la partida entera, no sus propias jugadas.

src/rukh/infer/game.py
PIECE_VALUES = {
chess.PAWN: 1.0,
chess.KNIGHT: 3.0,
chess.BISHOP: 3.0,
chess.ROOK: 5.0,
chess.QUEEN: 9.0,
}
ADJUDICATION_DEPTH = 8
ADJUDICATION_CP = 200
"""Centipawn margin above which an adjudicated position counts as a win."""
ADJUDICATION_PAWNS = ADJUDICATION_CP / 100.0
MATE_SCORE = 10_000

src/rukh/infer/game.pylíneas 193-204 · p2

src/rukh/infer/game.py
def material_balance(board: chess.Board) -> float:
"""Material of the position in pawns, from White's point of view."""
total = 0.0
for piece_type, value in PIECE_VALUES.items():
total += value * len(board.pieces(piece_type, chess.WHITE))
total -= value * len(board.pieces(piece_type, chess.BLACK))
return total

src/rukh/infer/game.pylíneas 207-213 · p2

src/rukh/infer/game.py
def adjudicate(
board: chess.Board | str,
engine: chess.engine.SimpleEngine | None = None,
depth: int = ADJUDICATION_DEPTH,
margin_cp: int = ADJUDICATION_CP,
) -> tuple[str, str]:
"""Decide a game that was cut short; returns ``(result, how)``.
With an engine the final position is analysed to a shallow depth and a score of at least
``margin_cp`` for one side is a win for that side; without one (or when the analysis gives no
centipawn score) the same margin is applied to the material count, which is crude but is
still an answer, and is far better than booking every cut game as half a point.
"""
position = chess.Board(board) if isinstance(board, str) else board
if engine is not None:
try:
info = engine.analyse(position, chess.engine.Limit(depth=depth))
score = info["score"].white().score(mate_score=MATE_SCORE)
except (chess.engine.EngineError, chess.engine.EngineTerminatedError, KeyError):
score = None
if score is not None:
return _verdict(float(score), float(margin_cp)), f"engine depth {depth}"
pawns = material_balance(position)
return _verdict(pawns * 100.0, float(margin_cp)), "material count"
def _verdict(centipawns: float, margin: float) -> str:
"""``1-0``/``0-1``/``1/2-1/2`` from a White-relative score in centipawns."""
if centipawns >= margin:
return "1-0"
if centipawns <= -margin:
return "0-1"
return "1/2-1/2"

src/rukh/infer/game.pylíneas 216-248 · p2

La adjudicaciónadjudicaciónDecidir el resultado de una partida que se cortó sin terminar en el tablero —en Rukh, porque el modelo se quedó sin contexto— juzgando la posición final con una búsqueda corta del motor o, si no hay motor, con el recuento de material. Anotarla como tablas no sería neutral: regalaría medio punto al bando que iba perdiendo, y ese sesgo apunta siempre para el mismo lado. de una partida cortada existe porque anotarla como tablas no es neutral: le regala medio punto al bando que iba perdiendo, y con un modelo que se queda sin contexto en las partidas largas ese sesgo apunta siempre para el mismo lado. Así que la posición final se juzga: con una búsqueda corta del motor si lo hay, contando material si no, y el informe dice cuántas se adjudicaron y cómo.

El margen de 200 centipeones (dos peones) es lo que separa «va ganando» de «está igualado», y se aplica igual a la evaluación del motor y al recuento de material. Ahí está el detalle honesto: el try captura los tres fallos del motor y cae al material, así que una adjudicación siempre devuelve algo. Y devuelve dos cosas, el resultado y cómo se decidió, porque un 1-0 por recuento de material y uno por análisis a profundidad 8 no valen lo mismo y el informe los cuenta por separado.

El rey no está en PIECE_VALUES: sumarle un valor no cambiaría ninguna diferencia (siempre hay uno de cada) y elegir cuál sería una conversación sin final.

src/rukh/infer/game.py
def result_token(tok: UciTokenizer, result: str) -> int | None:
"""Vocabulary id of a finished game's result token, or None for an unfinished game."""
name = RESULT_TOKENS.get(result)
return tok.vocab[name] if name else None

src/rukh/infer/game.pylíneas 251-254 · p2

src/rukh/infer/game.py
def legality_rate(
model: MoveDecoder,
tok: UciTokenizer,
boards: list[chess.Board],
histories: list[list[int]],
cfg: SampleConfig,
) -> float:
"""Share of the positions actually queried where the unmasked network proposes a legal move.
Positions with no legal move (checkmate, stalemate) are never asked, so they are not counted
in the denominator either: the rate is over the boards the model was really given.
"""
if not boards:
return 0.0
unmasked = cfg.model_copy(update={"mask_illegal": False})
generator = model_generator(model, cfg)
legal = 0
counted = 0
for board, history in zip(boards, histories, strict=True):
if not legal_token_ids(board, tok):
continue
counted += 1
_, report = pick_move(model, tok, board, history, unmasked, generator)
legal += int(report["legal"])
return legal / counted if counted else 0.0

src/rukh/infer/game.pylíneas 257-281 · p2

legality_rate mide lo mismo que la métrica de la lección 5, en una versión que no necesita el harness. Lo que hay que leer es el denominador: las posiciones sin jugada legal —mate, ahogado— no se preguntan, así que tampoco se cuentan. Contarlas como fallo castigaría al modelo por una posición en la que no había nada que proponer; contarlas como acierto lo premiaría. No preguntar es la tercera opción y es la correcta.

src/rukh/infer/__init__.py
"""Inference: masked sampling of one move and self-play against an opponent."""
from rukh.infer.game import (
ADJUDICATION_CP,
ADJUDICATION_DEPTH,
GameResult,
Opponent,
RandomOpponent,
StockfishOpponent,
adjudicate,
is_over,
legality_rate,
material_balance,
play_game,
result_token,
)
from rukh.infer.sampler import (
HEADER_TOKENS,
SampleConfig,
legal_token_ids,
model_generator,
pick_move,
prompt_ids,
)
__all__ = [
"ADJUDICATION_CP",
"ADJUDICATION_DEPTH",
"HEADER_TOKENS",
"GameResult",
"Opponent",
"RandomOpponent",
"SampleConfig",
"StockfishOpponent",
"adjudicate",
"is_over",
"legal_token_ids",
"legality_rate",
"material_balance",
"model_generator",
"pick_move",
"play_game",
"prompt_ids",
"result_token",
]

src/rukh/infer/__init__.pylíneas 1-45 · p2

Los reexportes. Como en models, nadie fuera del paquete importa rukh.infer.sampler.

El comando

src/rukh/cli.py
@app.command("play")
def play_cmd(
ckpt: Annotated[
Path,
typer.Option(
"--ckpt", exists=True, dir_okay=False, readable=True, help="Checkpoint to play with."
),
],
games: Annotated[int, typer.Option("--games", help="Number of games to play.")] = 1,
opponent: Annotated[
str, typer.Option("--opponent", help="Opponent: random or stockfish.")
] = "random",
elo: Annotated[int, typer.Option("--elo", help="UCI_Elo of the Stockfish opponent.")] = 1400,
temperature: Annotated[
float, typer.Option("--temperature", help="Sampling temperature (0 = argmax).")
] = 0.6,
top_k: Annotated[int, typer.Option("--top-k", help="Top-k truncation; 0 disables it.")] = 20,
no_mask: Annotated[
bool, typer.Option("--no-mask", help="Sample without the legality mask (measures it).")
] = False,
seed: Annotated[int, typer.Option("--seed", help="Seed for sampling and the opponent.")] = 0,
as_json: Annotated[bool, typer.Option("--json", help="Print the games as JSON only.")] = False,
) -> None:
"""Play games between a trained decoder and an opponent, with or without the legality mask."""
import json
from rukh.infer import RandomOpponent, SampleConfig, StockfishOpponent, play_game
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train import load_model
if opponent not in ("random", "stockfish"):
typer.echo("error: --opponent must be random or stockfish", err=True)
raise typer.Exit(code=2)
model, _ = load_model(ckpt)
tok = UciTokenizer()
cfg = SampleConfig(
temperature=temperature,
top_k=top_k or None,
mask_illegal=not no_mask,
seed=seed,
)
rival = RandomOpponent(seed) if opponent == "random" else StockfishOpponent(elo=elo)
try:
results = [
play_game(model, tok, rival, cfg, model_color=index % 2 == 0) for index in range(games)
]
finally:
if isinstance(rival, StockfishOpponent):
rival.close()
if as_json:
typer.echo(json.dumps([result.model_dump() for result in results], indent=2))
return
typer.echo(f"opponent: {opponent}")
typer.echo(f"mask: {'on' if cfg.mask_illegal else 'off'}")
for index, result in enumerate(results):
typer.echo(
f" game {index + 1}: {result.result:<7} {result.plies:>3} plies "
f"{result.illegal_proposals} illegal ({result.termination})"
)
typer.echo(f"illegal: {sum(r.illegal_proposals for r in results)} proposals")

src/rukh/cli.pylíneas 369-428 · p2

rukh play es lo que convierte todo lo anterior en algo que se puede mirar. Dos detalles de la traducción de opciones a configuración: top_k=top_k or None hace que --top-k 0 signifique «sin recorte» en la línea de órdenes, donde no se puede escribir None; y mask_illegal=not no_mask invierte la bandera, que se llama --no-mask porque la máscara está puesta por defecto.

El finally que cierra Stockfish está ahí por la misma razón que el gestor de contexto de StockfishOpponent: una excepción a mitad de la quinta partida no puede dejar un proceso colgado.

Y --games alterna colores con model_color=index % 2 == 0, que es la misma regla que usará el harness de Elo: jugar solo con blancas mediría el modelo y la ventaja de salida a la vez.

Una partida, para verlo con tus ojos

Terminal
uv run rukh play --ckpt checkpoints/small/best.pt --games 1

Salida real de la ejecución de referencia (RTX 5090):

opponent: random
mask: on
game 1: 1/2-1/2 117 plies 0 illegal (stalemate)
illegal: 0 proposals

Ciento diecisiete plies contra un oponente que juega al azar, y tablas por ahogado. No es la partida lucida que uno querría enseñar, y por eso está aquí. Con la máscara puesta el contador de ilegales es 0, como tiene que ser: el muestreo enmascarado no puede producir una jugada ilegal, así que ese cero no dice nada del modelo —dice que la máscara funciona, y si alguna vez ves ahí un número distinto de cero, lo que está roto es la máscara—. El ahogado, en cambio, sí es del modelo: llegó a un final ganado contra un rival aleatorio y no supo dar mate.

La misma partida sin máscara enseña el otro lado: cada vez que el modelo propone algo ilegal se cuenta y se rescata con un muestreo enmascarado, así que la partida siempre termina y el contador illegal es la señal cruda.

Terminal
uv run rukh play --ckpt checkpoints/small/best.pt --games 1 --no-mask

Salida real de la ejecución de referencia (RTX 5090):

opponent: random
mask: off
game 1: 1-0 79 plies 1 illegal (checkmate)
illegal: 1 proposals

Mate en 79 plies y una sola propuesta ilegal en toda la partida. Ese 1 es lo que conecta esta partida con la tabla de la lección 6, y merece la pena hacer la cuenta: es la única vez en el módulo en que una métrica agregada y un caso concreto se pueden contrastar a mano. Una legalidad por argmax del 99,4 % son 6 fallos por cada 1 000 posiciones, es decir uno cada 167 jugadas propuestas. En una partida de 79 plies el modelo mueve la mitad de las veces —el otro lado es el oponente aleatorio—, así que propone unas 40 jugadas y el número esperado de ilegales es 0,24: sale 1. Con una sola partida eso es ruido puro y no confirma nada, pero el orden de magnitud coincide, y esa es la comprobación barata que conviene hacerse siempre que tengas una métrica agregada y un caso suelto delante. Si aquí hubieran salido quince ilegales, el 99,4 % de la tabla estaría midiendo otra cosa.

Los tests: veintiún contratos

tests/unit/test_sampler.py es el fichero de tests más largo de la primera mitad del módulo, y el que más cosas fija de una vez. Vale la pena entero.

tests/unit/test_sampler.py
"""Tests for rukh.infer: legality masking, temperature, promotions and whole games."""
from __future__ import annotations
import chess
import pytest
import torch
from rukh.infer import (
GameResult,
RandomOpponent,
SampleConfig,
StockfishOpponent,
adjudicate,
is_over,
legal_token_ids,
legality_rate,
material_balance,
model_generator,
pick_move,
play_game,
prompt_ids,
result_token,
)
from rukh.models import DecoderConfig, MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
# The Stockfish game carries only `engine`, so `-m unit` never needs the binary.
unit = pytest.mark.unit
TOY = DecoderConfig(vocab_size=2030, n_layer=2, n_head=2, d_model=32, block=64)
PROMOTION_FEN = "8/P6k/8/8/8/8/6K1/8 w - - 0 1"
@pytest.fixture(scope="module")
def tok() -> UciTokenizer:
return UciTokenizer()
@pytest.fixture(scope="module")
def model() -> MoveDecoder:
torch.manual_seed(0)
return MoveDecoder(TOY).eval()
def history(tok: UciTokenizer) -> list[int]:
return [tok.bos_id, tok.vocab["<w1800>"], tok.vocab["<b1800>"]]

tests/unit/test_sampler.pylíneas 1-47 · p2

Un modelo de juguete con el vocabulario de verdad (2 030) y dos capas. Es la combinación que importa: las jugadas legales tienen que existir en el vocabulario real, y el resto puede ser minúsculo. Las fixtures son de ámbito module porque construir el tokenizador y el modelo una vez por test multiplicaría por veinte el tiempo del fichero.

Y el comentario de la línea 28 explica una decisión de marcado: la partida contra Stockfish lleva solo engine, no unit, así que pytest -m unit nunca necesita el binario.

tests/unit/test_sampler.py
@unit
def test_the_initial_position_has_twenty_legal_tokens(tok: UciTokenizer) -> None:
board = chess.Board()
ids = legal_token_ids(board, tok)
assert len(ids) == 20
assert ids == sorted(set(ids))
for token_id in ids:
assert board.is_legal(chess.Move.from_uci(tok.ids[token_id]))
assert {tok.ids[i] for i in ids} == {move.uci() for move in board.legal_moves}
@unit
def test_castling_is_the_two_square_king_move(tok: UciTokenizer) -> None:
board = chess.Board("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
tokens = {tok.ids[i] for i in legal_token_ids(board, tok)}
assert {"e1g1", "e1c1"} <= tokens

tests/unit/test_sampler.pylíneas 50-65 · p2

Veinte jugadas legales en la posición inicial —dieciséis de peón y cuatro de caballo—, ordenadas, sin repetidos, todas legales y exactamente el mismo conjunto que board.legal_moves. Esa última aserción es la que convierte el test de «no propone basura» en «no se deja ninguna». Y el del enroque fija el detalle que rompería el vocabulario entero.

tests/unit/test_sampler.py
@unit
def test_masked_sampling_never_proposes_an_illegal_move(
model: MoveDecoder, tok: UciTokenizer
) -> None:
board = chess.Board()
cfg = SampleConfig(temperature=1.5, top_k=None, mask_illegal=True, seed=0)
generator = cfg.generator()
seen = set()
for _ in range(1000):
move, report = pick_move(model, tok, board, history(tok), cfg, generator)
assert move is not None
assert report["legal"] is True and report["masked"] is True
assert board.is_legal(move)
seen.add(move.uci())
assert len(seen) > 1 # the mask does not collapse the distribution
@unit
def test_without_the_mask_an_illegal_token_is_reported_as_such(
model: MoveDecoder, tok: UciTokenizer
) -> None:
board = chess.Board()
cfg = SampleConfig(temperature=1.0, top_k=None, mask_illegal=False, seed=1)
generator = cfg.generator()
illegal = 0
for _ in range(50):
move, report = pick_move(model, tok, board, history(tok), cfg, generator)
assert report["masked"] is False
assert report["raw_token"] in tok.vocab
if move is None:
illegal += 1
assert report["legal"] is False
assert illegal > 0 # an untrained model is almost never legal on its own

tests/unit/test_sampler.pylíneas 68-100 · p2

Los dos lados de la máscara, y los dos son necesarios.

Con máscara: mil muestreos a temperatura 1,5 y sin top-k —es decir, en las condiciones más favorables para que salga una tontería— y ni una sola jugada ilegal. El len(seen) > 1 del final impide la versión degenerada del test, un muestreador que devolviera siempre la misma jugada.

Sin máscara: se exige que aparezcan ilegales. «Un modelo sin entrenar casi nunca es legal por su cuenta», dice el comentario, y ese assert illegal > 0 es lo que garantiza que la ruta sin máscara existe de verdad y no es la misma con otro nombre.

tests/unit/test_sampler.py
@unit
def test_zero_temperature_is_argmax(model: MoveDecoder, tok: UciTokenizer) -> None:
board = chess.Board()
cfg = SampleConfig(temperature=0.0, top_k=None, mask_illegal=True)
ids = legal_token_ids(board, tok)
idx = torch.tensor([history(tok)], dtype=torch.long)
logits = model.next_logits(idx)[0]
best = tok.ids[ids[int(torch.argmax(logits[torch.tensor(ids)]))]]
for _ in range(5):
move, report = pick_move(model, tok, board, history(tok), cfg)
assert move is not None and move.uci() == best
assert report["raw_token"] == best
@unit
def test_top_k_restricts_the_reported_distribution(model: MoveDecoder, tok: UciTokenizer) -> None:
board = chess.Board()
cfg = SampleConfig(temperature=1.0, top_k=3, mask_illegal=True, seed=2)
drawn = set()
generator = cfg.generator()
for _ in range(200):
move, report = pick_move(model, tok, board, history(tok), cfg, generator)
assert move is not None
drawn.add(move.uci())
assert len(report["top5"]) <= 3
assert sum(probability for _, probability in report["top5"]) == pytest.approx(1.0, abs=1e-5)
assert len(drawn) <= 3

tests/unit/test_sampler.pylíneas 103-129 · p2

El de temperatura cero no compara con una constante: calcula el argmax a mano, sobre los logits restringidos a los ids legales, y exige que el muestreador dé eso mismo cinco veces seguidas. Y el de top-k comprueba tres cosas a la vez: que en 200 muestreos no salgan más de 3 jugadas distintas, que top5 nunca tenga más de k entradas y que sus probabilidades sumen uno, que es la forma de fijar que el informe describe la distribución muestreada y no la cruda.

tests/unit/test_sampler.py
@unit
def test_a_forced_promotion_yields_a_promotion_token(model: MoveDecoder, tok: UciTokenizer) -> None:
board = chess.Board(PROMOTION_FEN)
tokens = {tok.ids[i] for i in legal_token_ids(board, tok)}
assert {"a7a8q", "a7a8r", "a7a8b", "a7a8n"} <= tokens
cfg = SampleConfig(temperature=1.0, top_k=None, mask_illegal=True, seed=3)
generator = cfg.generator()
for _ in range(20):
move, report = pick_move(model, tok, board, history(tok), cfg, generator)
assert move is not None
if move.from_square == chess.A7:
assert move.promotion is not None
assert len(report["raw_token"]) == 5
@unit
def test_a_finished_position_has_no_legal_tokens(model: MoveDecoder, tok: UciTokenizer) -> None:
board = chess.Board("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1") # checkmate
assert legal_token_ids(board, tok) == []
move, report = pick_move(model, tok, board, history(tok), SampleConfig(seed=0))
assert move is None and report["legal"] is False and report["top5"] == []

tests/unit/test_sampler.pylíneas 132-152 · p2

La promoción y la posición terminada. El de la promoción no exige que el modelo promocione —no ha aprendido nada—, exige que si mueve el peón de a7, la jugada tenga promoción y su token mida cinco caracteres. El de mate comprueba la salida temprana de pick_move: sin jugadas legales, None, legal=False y un top5 vacío, sin gastar una pasada por el modelo.

tests/unit/test_sampler.py
@unit
def test_a_full_game_against_a_random_opponent(model: MoveDecoder, tok: UciTokenizer) -> None:
cfg = SampleConfig(temperature=1.0, top_k=20, mask_illegal=True, seed=7)
result = play_game(model, tok, RandomOpponent(seed=7), cfg, max_plies=40)
assert isinstance(result, GameResult)
assert result.result in ("1-0", "0-1", "1/2-1/2", "*")
assert result.plies == len(result.moves)
assert result.illegal_proposals == 0 # the mask makes them impossible
assert result.termination
board = chess.Board()
for uci in result.moves:
move = chess.Move.from_uci(uci)
assert board.is_legal(move)
board.push(move)
@unit
def test_an_unmasked_game_counts_illegal_proposals_and_still_ends(
model: MoveDecoder, tok: UciTokenizer
) -> None:
cfg = SampleConfig(temperature=1.0, top_k=None, mask_illegal=False, seed=5)
result = play_game(
model, tok, RandomOpponent(seed=5), cfg, model_color=chess.BLACK, max_plies=20
)
assert result.illegal_proposals > 0
assert result.plies == 20 or result.result != "*"
@unit
def test_the_game_stops_at_the_block_size_by_default(model: MoveDecoder, tok: UciTokenizer) -> None:
cfg = SampleConfig(temperature=1.0, top_k=20, seed=11)
result = play_game(model, tok, RandomOpponent(seed=11), cfg)
assert result.plies <= TOY.block - 4
@unit
def test_the_same_seed_replays_the_same_game(model: MoveDecoder, tok: UciTokenizer) -> None:
cfg = SampleConfig(temperature=1.0, top_k=20, seed=13)
first = play_game(model, tok, RandomOpponent(seed=13), cfg, max_plies=30)
second = play_game(model, tok, RandomOpponent(seed=13), cfg, max_plies=30)
assert first.moves == second.moves

tests/unit/test_sampler.pylíneas 155-196 · p2

Las partidas. El primero replica la partida jugada sobre un tablero limpio y comprueba que cada jugada es legal en su momento: si play_game mezclara el orden o empujara una jugada en el tablero equivocado, el test lo ve. El segundo exige que una partida sin máscara tenga propuestas ilegales y aun así termine, que es la propiedad del rescate. El tercero fija el límite por defecto y el cuarto, el determinismo: dos partidas con la misma semilla son la misma partida, jugada a jugada.

tests/unit/test_sampler.py
@unit
def test_legality_rate_is_a_share(model: MoveDecoder, tok: UciTokenizer) -> None:
boards = [chess.Board() for _ in range(5)]
histories = [history(tok) for _ in boards]
rate = legality_rate(model, tok, boards, histories, SampleConfig(seed=0))
assert 0.0 <= rate <= 1.0
assert legality_rate(model, tok, [], [], SampleConfig()) == 0.0
@unit
def test_result_tokens_map_to_the_vocabulary(tok: UciTokenizer) -> None:
assert result_token(tok, "1-0") == tok.vocab["<1-0>"]
assert result_token(tok, "1/2-1/2") == tok.vocab["<1/2>"]
assert result_token(tok, "*") is None
@unit
def test_the_generator_follows_the_model_device(model: MoveDecoder) -> None:
cfg = SampleConfig(seed=0)
generator = model_generator(model, cfg)
assert generator is not None
assert generator.device.type == next(model.parameters()).device.type
assert model_generator(model, SampleConfig(seed=None)) is None

tests/unit/test_sampler.pylíneas 199-221 · p2

tests/unit/test_sampler.py
@pytest.mark.gpu
def test_sampling_runs_on_cuda(tok: UciTokenizer) -> None:
if not torch.cuda.is_available():
pytest.skip("CUDA is not available")
torch.manual_seed(0)
on_gpu = MoveDecoder(TOY).eval().to("cuda")
cfg = SampleConfig(temperature=1.0, top_k=20, mask_illegal=True, seed=0)
generator = model_generator(on_gpu, cfg)
assert generator is not None and generator.device.type == "cuda"
board = chess.Board()
for _ in range(20):
move, report = pick_move(on_gpu, tok, board, history(tok), cfg, generator)
assert move is not None and board.is_legal(move)
assert report["legal"] is True
result = play_game(on_gpu, tok, RandomOpponent(seed=0), cfg, max_plies=20)
assert result.plies > 0

tests/unit/test_sampler.pylíneas 224-239 · p2

El de CUDA lleva la marca gpu, así que no corre en la CI —que no tiene GPU— y se salta solo si torch.cuda.is_available() dice que no. Es el test que habría cazado el fallo del generador del callout de arriba, y por eso existe: ese error solo aparece en la máquina con GPU.

tests/unit/test_sampler.py
@unit
def test_the_prompt_keeps_the_header_when_it_has_to_be_cropped(tok: UciTokenizer) -> None:
ids = history(tok) + list(range(100, 200))
cropped = prompt_ids(ids, 10)
assert len(cropped) == 10
assert cropped[:3] == history(tok)
assert cropped[3:] == list(range(193, 200))
assert prompt_ids(ids, 500) == ids
@unit
def test_a_long_game_still_carries_its_elo_tokens(model: MoveDecoder, tok: UciTokenizer) -> None:
board = chess.Board()
long_history = history(tok) + [tok.vocab["e2e4"]] * (TOY.block * 2)
cfg = SampleConfig(temperature=0.0, top_k=None, mask_illegal=True)
move, _ = pick_move(model, tok, board, long_history, cfg)
assert move is not None # the decoder would have refused a prompt longer than the block

tests/unit/test_sampler.pylíneas 242-258 · p2

Los dos del prompt. El primero es aritmético: con block=10 y una cabecera de 3, quedan 7 jugadas, y tienen que ser las siete últimas. El segundo lo comprueba de verdad: una partida con 128 jugadas en un modelo de contexto 64 muestrea sin explotar, lo que solo es posible si el recorte ocurrió antes de llegar al decoder.

tests/unit/test_sampler.py
@unit
def test_is_over_sees_the_claimable_draws() -> None:
assert is_over(chess.Board("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1")) # checkmate
assert not is_over(chess.Board())
fifty = chess.Board("7k/8/6K1/8/8/8/8/R7 w - - 100 80")
assert is_over(fifty)
@unit
def test_adjudication_reads_material_when_there_is_no_engine() -> None:
assert material_balance(chess.Board()) == 0.0
a_queen_up = chess.Board("7k/8/8/8/8/8/8/Q5K1 w - - 0 1")
assert material_balance(a_queen_up) == 9.0
assert adjudicate(a_queen_up) == ("1-0", "material count")
assert adjudicate("7k/8/8/8/8/8/8/q5K1 w - - 0 1") == ("0-1", "material count")
assert adjudicate(chess.Board())[0] == "1/2-1/2"
one_pawn_up = chess.Board("4k3/p7/8/8/8/8/PP6/4K3 w - - 0 1")
assert adjudicate(one_pawn_up) == ("1/2-1/2", "material count") # under the two-pawn margin
two_pawns_up = chess.Board("4k3/8/8/8/8/8/PP6/4K3 w - - 0 1")
assert adjudicate(two_pawns_up) == ("1-0", "material count") # the margin is inclusive

tests/unit/test_sampler.pylíneas 261-280 · p2

El de la adjudicación fija el margen en los dos lados: con un peón de ventaja, tablas; con dos, victoria. «El margen es inclusivo», dice el comentario, y es el tipo de decisión que se toma una vez y se olvida si no está escrita en un test.

tests/unit/test_sampler.py
@unit
def test_a_cut_game_reports_its_final_position(model: MoveDecoder, tok: UciTokenizer) -> None:
cfg = SampleConfig(temperature=1.0, top_k=20, seed=3)
result = play_game(model, tok, RandomOpponent(seed=3), cfg, max_plies=6)
assert result.result == "*" and result.cut is True
board = chess.Board(result.fen)
assert board.fullmove_number == 4
assert adjudicate(result.fen)[0] in ("1-0", "0-1", "1/2-1/2")
@unit
def test_sample_config_rejects_impossible_values() -> None:
with pytest.raises(ValueError, match="non-negative"):
SampleConfig(temperature=-0.1)
with pytest.raises(ValueError, match="top_k"):
SampleConfig(top_k=0)
with pytest.raises(ValueError):
SampleConfig(temp=0.5) # type: ignore[call-arg]
assert SampleConfig(seed=None).generator() is None
assert SampleConfig(seed=1).generator().device.type == "cpu"
@unit
def test_cli_play_is_registered() -> None:
from typer.testing import CliRunner
from rukh.cli import app
result = CliRunner().invoke(app, ["play", "--help"])
assert result.exit_code == 0, result.output
for option in ("--ckpt", "--games", "--opponent", "--no-mask"):
assert option in result.output

tests/unit/test_sampler.pylíneas 283-314 · p2

Una partida cortada tiene result="*", cut=True, un FEN que se puede reconstruir y se puede adjudicar. Y test_cli_play_is_registered comprueba que las cuatro opciones siguen en el --help: es un test de treinta segundos de escritura que caza el renombrado de una opción antes de que lo haga un lector del curso.

tests/unit/test_sampler.py
@pytest.mark.engine
def test_a_game_against_stockfish(model: MoveDecoder, tok: UciTokenizer) -> None:
from rukh.engine import find_stockfish
if find_stockfish() is None:
pytest.skip("Stockfish is not available")
cfg = SampleConfig(temperature=1.0, top_k=20, seed=17)
with StockfishOpponent(elo=1400, move_time=0.02) as rival:
result = play_game(model, tok, rival, cfg, model_color=chess.WHITE, max_plies=20)
assert result.plies > 0 and result.illegal_proposals == 0

tests/unit/test_sampler.pylíneas 317-326 · p2

Y el de Stockfish, marcado engine, que se salta cuando no hay binario.

// Ejercicio 01Cambia el orden de las tres perillas y mide el daño

En una copia del repositorio, mueve el bloque del top_k de _filtered_logits antes del de la máscara. Ejecuta uv run pytest -m unit -q tests/unit/test_sampler.py. ¿Qué test falla y con qué síntoma? Y en la posición inicial, con top_k=3, ¿cuántas jugadas puede proponer el muestreador como mucho?

// SoluciónVer la solución

Falla test_masked_sampling_never_proposes_an_illegal_move, pero no siempre en el mismo sitio: lo normal es que reviente el assert move is not None en alguna de las mil iteraciones, porque con el top-k aplicado primero puede ocurrir que ninguno de los tres tokens supervivientes sea legal y la máscara los ponga todos a -inf. Un softmax de todo -inf da NaN y multinomial protesta, o devuelve un índice arbitrario: el síntoma exacto depende de la versión de torch, que es la peor propiedad que puede tener un error.

En la posición inicial hay 20 jugadas legales de 2 030 tokens. Con el orden correcto, el top-k elige 3 de esas 20. Con el orden invertido, elige 3 de las 2 030 y después tacha las ilegales: el número de candidatos es entre 0 y 3, y el valor esperado, con un modelo sin entrenar, es 3 × 20/2030 ≈ 0,03. Es decir, casi siempre cero.

Qué has aprendido

Cómo se convierte un vector de 2 030 números en una partida terminada, y las cuatro decisiones que lleva dentro: el orden máscara → temperatura → top-k, el recorte que conserva la cabecera, el informe que sale junto a la jugada, y la adjudicación de lo que se quedó sin contexto.

Cómo se mide: uv run pytest -m unit -q tests/unit/test_sampler.py pasa los diecinueve tests de CPU (los de gpu y engine van aparte), y uv run rukh play --ckpt checkpoints/small/best.pt juega una partida entera con cero propuestas ilegales con máscara y una sola sin ella.

Lo siguiente es medir en serio: mil posiciones de validación, seis mil puzles y las dos definiciones de legalidad que no se pueden publicar por separado.