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 trabajo175 min
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a construir

src/rukh/infer/: el código que convierte 2 030 números en una jugada legal y, de ahí, en una partida terminada. Es el puente hacia la evaluación, el Elo y la demo, y contiene 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

Convertir 2 030 logits en una jugada es una decisión con tres perillas, las mismas que ajustas al llamar a la API de un LLM de texto.

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.. Los logits se dividen por T antes del softmax. Con T = 1 la distribución es la aprendida; con T pequeña la mejor jugada se lleva casi todo (en el límite, argmax); con T grande todo se aplana hacia el azar. Rukh usa 0,6: suficiente para que dos partidas no sean idénticas, poco para que el modelo se tire a la piscina.

A la temperatura se la llama «creatividad», y la palabra engaña. Pregúntale qué pedir a alguien que conoce el restaurante: a temperatura cero te dice siempre su plato favorito; subiéndola, el segundo y el tercero de su lista; muy arriba, la ensalada que no le gusta a nadie. No ha aprendido platos nuevos, solo baja más por la lista que ya tenía. Y subir la temperatura tiene precio: en small, jugar a 0,6 en vez de casi a cero cuesta unos 180 puntos de Elo (lo mide la lección 6).

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 los k logits mayores y el resto pasa a menos infinito. Con k = 20 desaparece la cola: mil jugadas con probabilidad 0,0001 cada una, que sumadas son un 10 % de hacer una tontería. La temperatura reescala y el top-k recorta; sin top-k, una temperatura alta muestrea basura.

T = 0,6 · la de Rukhg1f366 %b1c315 %f1c411 %d2d45 %f2f42 %d1h51 %a2a30 %g1e20 %las cinco últimas suman 8 %T = 1 · lo que aprendióg1f346 %b1c319 %f1c415 %d2d410 %f2f45 %d1h53 %a2a32 %g1e21 %las cinco últimas suman 21 %T = 2 · más planag1f328 %b1c318 %f1c416 %d2d413 %f2f49 %d1h57 %a2a35 %g1e24 %las cinco últimas suman 39 %T = 2 y top-k = 3g1f345 %b1c329 %f1c426 %d2d40 %f2f40 %d1h50 %a2a30 %g1e20 %recortadas: 0 %
fig. 01Las mismas ocho jugadas con cuatro ajustes. La temperatura cambia cuánto se concentra la probabilidad, nunca el orden de las jugadas. El top-k, en el último panel, saca la cola del reparto antes de muestrear: las recortadas, en discontinuo, se quedan en cero y su masa se reparte entre las que quedan.

La máscara de legalidad. Se calculan con python-chess las jugadas legales y todos los demás logits pasan a menos infinito: el modelo no puede proponer una jugada ilegal. Eso hace la demo jugable y es lo que se usa contra Stockfish. Pero tiene una trampa:

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

HEADER_TOKENS = 3 es el <bos> más los dos tokens de Elo que M1 puso al principio de cada secuencia, y reaparece en el recorte del prompt, el límite de pliesPly (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. de una partida y los prefijos de la evaluación.

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

Los valores por defecto son los de la demo. temperature=0 es legal y significa argmax; top_k=None es «sin recorte» y top_k=0 es un error, porque nadie quiere quedarse con cero jugadas.

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

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

De esta función corta depende que el modelo juegue como se entrenó. Un recorte por la izquierda a secas (history[-block:], lo que hace next_logits) se lleva la cabecera en cuanto la partida es más larga que el contexto: el modelo pierde el condicionamiento por Elo y todas sus posiciones aprendidas se desplazan tres lugares. Conservarla cuesta tres tokens de doscientos. Pasa lo mismo al recortar la conversación de un chat: si se va el mensaje de sistema, el modelo sigue hablando, pero ya no es el que configuraste.

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

El enroque es e1g1, el movimiento de dos casillas del rey, la misma cadena en el tokenizador de M1 y en python-chess; con la notación de Chess960 (e1h1) hablarían idiomas distintos y el enroque sería imposible. Una jugada que faltara del vocabulario se salta en vez de adivinarse. Y el sorted hace el orden determinista, para que la misma semilla dé 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. El top-k antes de la máscara se quedaría con los veinte tokens más probables y luego tiraría los ilegales, lo que puede dejar dos candidatos o ninguno.

El logits.float() sube a fp32 lo que puede venir en bf16, porque dividir por una temperatura pequeña merece el rango entero. Y el umbral del top-k se compara con <, no con <=: un empate en el k-ésimo conserva las dos jugadas 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 la jugada (None si el token propuesto no es legal) y un informe con legal, raw_token, las cinco primeras y si se enmascaró. Sin ese informe no se podría medir la legalidad sin máscara, la primera columna de la tabla única.

top5 sale de la distribución realmente muestreada, después de máscara, temperatura y top-k, así que suma uno y no es la del modelo crudo: es lo que le interesa a quien depura una partida.

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

Sin máscara el modelo puede proponer tokens que no son jugadas (<eos>, los de Elo); Move.from_uci los rechaza y el informe los cuenta como ilegales, que es lo que son.

game.py: una partida entera

Con el muestreador escrito, lo demás es montar una partida alrededor de él. Los oponentes y el resultado son fontanería y se pueden saltar en la primera lectura; play_game, debajo, es donde el muestreo se usa. Son el principio de game.py, que tecleas: tecléalos también, aunque los leas por encima.

// Fontaneríagame.py, los oponentes: el Protocol, el aleatorio, Stockfish con UCI_Elo o Skill Level, el resultado, el fin de partida y la cabecera
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 y no una clase base: el mismo play_game sirve para el oponente aleatorio de los tests y para Stockfish sin que ninguno herede 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

UCI_Elo no baja de 1320, así que por debajo se usa Skill Level, otro mecanismo (errores deliberados) cuya equivalencia en Elo es una convención, no una medida. El gestor de contexto existe porque popen_uci arranca un proceso: sin cerrarlo, ocho escalones dejan 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

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 adjudicará en vez de darle 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

Una optimización que se nota en toda la suite: is_game_over(claim_draw=True) prueba todas las jugadas legales buscando una repetición, cuadrático sobre una partida larga y llamado en cada ply. Aquí las mismas tablas se cubren con el reloj de las cincuenta jugadas y con is_repetition(3) detrás de una guarda barata.

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

Tres decisiones que hereda la tabla de resultados:

  1. Una propuesta ilegal se cuenta y se rescata con un muestreo enmascarado. La partida tiene que terminar, pero el rescate no puede esconder que ocurrió: ese contador es la señal cruda de legalidad.
  2. El rescate no es un reintento. Insistir hasta que el modelo conteste algo legal mediría otro modelo.
  3. El límite por defecto, block - HEADER_TOKENS - 1, es el número de jugadas que caben en el prompt sin que el recorte empiece a tirar ninguna.

El historial incluye las jugadas del oponente: el prompt es la partida entera.

El resto de game.py cierra la partida: la escribe, adjudica las que se cortaron por falta de contexto y cuenta la legalidad. Puedes saltarlo al leer; la lección del Elo vuelve sobre la adjudicación. Tecléalo igual: play_game usa esas funciones, y al final del pliegue está src/rukh/infer/__init__.py, sin el que from rukh.infer import … no funciona.

// Fontaneríagame.py, el resto: la partida en PGN, el material, la adjudicación de las partidas cortadas, la tasa de legalidad y los reexportes
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. existe porque apuntar una partida cortada como tablas no es neutral: le regala medio punto al bando que iba perdiendo. La posición final se juzga con una búsqueda corta del motor si lo hay, o contando material si no, con el mismo margen de dos peones. Si el motor falla se cae al material, así que siempre hay respuesta, y se devuelve también cómo se decidió, porque un 1-0 por recuento de material no vale lo mismo que uno a profundidad 8.

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

Mide lo mismo que la métrica de la lección 5, sin el harness. Fíjate en el denominador: las posiciones sin jugada legal (mate, ahogado) no se preguntan ni se cuentan. Contarlas como fallo o como acierto sesgaría la tasa hacia un lado u otro.

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: nadie fuera del paquete importa los submódulos.

El comando

rukh play convierte lo anterior en algo que se puede mirar; el código del subcomando se puede saltar. Si lo saltas, tráelo: git checkout p2 -- src/rukh/cli.py (si ya trajiste el cli.py entero en la lección 3, lo tienes). Sin él, los rukh play de más abajo no existen.

// Fontaneríacli.py: el subcomando rukh play, con --mask, --temperature, --top-k y --games
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 convierte todo lo anterior en algo que se puede mirar. --top-k 0 significa «sin recorte», porque en la línea de comandos no se puede escribir None; el finally cierra Stockfish aunque una partida reviente; y --games alterna colores, como hará el harness de Elo.

Una partida, para verlo con tus ojos

Terminal
uv run rukh play --ckpt checkpoints/small-<fecha>/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 rival que juega al azar, y tablas por ahogado. No es la partida lucida que uno querría enseñar, y por eso está aquí. El 0 illegal no dice nada del modelo, solo que la máscara funciona. El ahogado sí es del modelo: llegó a un final ganado y no supo dar mate.

Sin máscara, cada propuesta ilegal se cuenta:

Terminal
uv run rukh play --ckpt checkpoints/small-<fecha>/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. Merece la pena contrastarlo con la tabla de la lección 6: una legalidad del 99,4 % es un fallo cada 167 propuestas. En 79 plies el modelo propone unas 40 jugadas, así que el número esperado de ilegales es 0,24, y sale 1. Con una partida eso es ruido, pero el orden de magnitud coincide. Contrasta siempre una métrica agregada con un caso suelto: si aquí hubieran salido quince ilegales, el 99,4 % estaría midiendo otra cosa.

Los tests: veinticuatro contratos

tests/unit/test_sampler.py es el fichero de tests más largo de la primera mitad del módulo.

Léelos al teclearlos; para seguir la lección basta con el «Ejecútalo» de debajo. Si los saltas, tráelos antes de ese «Ejecútalo», que los ejecuta: git checkout p2 -- tests/unit/test_sampler.py.

// Teststests/unit/test_sampler.py: los veinticuatro contratos del muestreador y de la partida
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: las jugadas legales tienen que existir en él, y el resto puede ser minúsculo. La partida contra Stockfish lleva solo la marca engine, 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

La última aserción, el mismo conjunto que board.legal_moves, convierte «no propone basura» en «no se deja ninguna».

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. Con ella, mil muestreos a temperatura 1,5 y sin top-k, las condiciones más favorables para una tontería, y ninguna ilegal; len(seen) > 1 descarta un muestreador que devolviera siempre lo mismo. Sin ella, se exige que aparezcan ilegales, para garantizar que la ruta sin máscara existe de verdad.

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 calcula el argmax a mano sobre los logits legales y exige que el muestreador dé eso mismo. El de top-k exige que top5 sume 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

El de la promoción no exige que el modelo sin entrenar promocione: exige que, si mueve el peón de a7, el token lleve la promoción. El de mate comprueba la salida temprana de pick_move.

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

El primero reproduce la partida sobre un tablero limpio y comprueba que cada jugada era legal en su momento. El segundo exige que una partida sin máscara tenga ilegales y aun así termine. Los otros fijan el límite por defecto y el determinismo: misma semilla, misma partida.

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 y se salta sin GPU. Es el que caza el fallo del generador, que 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 del prompt: con block=10 quedan la cabecera y las siete últimas jugadas, y una partida de 128 jugadas en un modelo de contexto 64 muestrea sin explotar.

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 por los dos lados: un peón de ventaja son tablas; dos, victoria. Es el tipo de decisión que 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

test_cli_play_is_registered comprueba que las opciones siguen en el --help: treinta segundos de escritura que cazan el renombrado de una opción antes que 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.

// 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

Fallan cinco: test_top_k_restricts_the_reported_distribution, test_a_full_game_against_a_random_opponent, test_the_game_stops_at_the_block_size_by_default, test_the_same_seed_replays_the_same_game y test_a_cut_game_reports_its_final_position (5 failed, 17 passed, 2 deselected). Todos con el mismo síntoma, RuntimeError: probability tensor contains either inf, nan or element < 0 en el multinomial: con el top-k primero puede que ninguno de los supervivientes sea legal, la máscara los pone todos a -inf y el softmax de todo -inf da NaN.

test_masked_sampling_never_proposes_an_illegal_move, el que parecería el candidato, pasa: usa top_k=None, así que el bloque movido no hace nada. Lo cazan los tests que combinan máscara y top-k, que son justo los que imitan a la demo.

Con el orden correcto, el top-k elige 3 de las 20 legales. Invertido, elige 3 de 2 030 y luego tacha las ilegales: entre 0 y 3 candidatos, con un valor esperado de 3 × 20/2030 ≈ 0,03 en un modelo sin entrenar. Casi siempre cero.

Qué has aprendido

Cómo se convierte un vector de 2 030 números en una partida terminada: 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. Y una idea que te llevas a cualquier LLM: la restricción al decodificar hace útil la salida, pero lo que sabe el modelo solo se mide sin ella.

Los veintidós tests de CPU en verde, y rukh play con small: cero 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.