// 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.
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
"""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 thatis not a legal move become ``-inf``, so an illegal move is impossible; without it the raw tokenis returned as it came out of the network and reported with ``legal=False``, which is how thelegality rate (the "understanding" metric of ``docs/spec/02``) is measured."""
from __future__ import annotations
from collections.abc import Sequencefrom typing import Any
import chessimport torchfrom pydantic import model_validator
from rukh.config import BaseConfigfrom rukh.models import MoveDecoderfrom rukh.tokenize.uci_vocab import UciTokenizer
TOP_N = 5HEADER_TOKENS = 3"""``<bos> <wXXXX> <bXXXX>``: the control prefix every training sequence starts with."""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.
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)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.
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)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.
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) :]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.
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)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 devuelveMove.uci()depython-chessen 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.
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 outEl 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.
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 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, reportpick_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.
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 NoneSeis 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
"""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 randomfrom pathlib import Pathfrom types import TracebackTypefrom typing import Protocol
import chessimport chess.enginefrom pydantic import BaseModel, ConfigDict
from rukh.infer.sampler import ( HEADER_TOKENS, SampleConfig, legal_token_ids, model_generator, pick_move,)from rukh.models import MoveDecoderfrom 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 = 100Un 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.
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))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()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.
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 == "*"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.
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)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.
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")], ]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(), )La partida. Tres decisiones que la tabla de resultados hereda:
- Una propuesta ilegal se cuenta y se rescata.
illegal += 1y después un segundopick_movecon 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. - 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.
- 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.
PIECE_VALUES = { chess.PAWN: 1.0, chess.KNIGHT: 3.0, chess.BISHOP: 3.0, chess.ROOK: 5.0, chess.QUEEN: 9.0,}ADJUDICATION_DEPTH = 8ADJUDICATION_CP = 200"""Centipawn margin above which an adjudicated position counts as a win."""ADJUDICATION_PAWNS = ADJUDICATION_CP / 100.0MATE_SCORE = 10_000def 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 totaldef 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"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.
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 Nonedef 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.0legality_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.
"""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",]Los reexportes. Como en models, nadie fuera del paquete importa rukh.infer.sampler.
El comando
@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")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
uv run rukh play --ckpt checkpoints/small/best.pt --games 1Salida real de la ejecución de referencia (RTX 5090):
opponent: randommask: on game 1: 1/2-1/2 117 plies 0 illegal (stalemate)illegal: 0 proposalsCiento 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.
uv run rukh play --ckpt checkpoints/small/best.pt --games 1 --no-maskSalida real de la ejecución de referencia (RTX 5090):
opponent: randommask: off game 1: 1-0 79 plies 1 illegal (checkmate)illegal: 1 proposalsMate 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 for rukh.infer: legality masking, temperature, promotions and whole games."""
from __future__ import annotations
import chessimport pytestimport 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, MoveDecoderfrom 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>"]]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.
@unitdef 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}
@unitdef 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"} <= tokensVeinte 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.
@unitdef 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
@unitdef 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 ownLos 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.
@unitdef 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
@unitdef 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) <= 3El 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.
@unitdef 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
@unitdef 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"] == []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.
@unitdef 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)
@unitdef 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 != "*"
@unitdef 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
@unitdef 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.movesLas 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.
@unitdef 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
@unitdef 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
@unitdef 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@pytest.mark.gpudef 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 > 0El 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.
@unitdef 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
@unitdef 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 blockLos 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.
@unitdef 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)
@unitdef 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 inclusiveEl 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.
@unitdef 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")
@unitdef 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"
@unitdef 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.outputUna 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.
@pytest.mark.enginedef 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 == 0Y 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.