// 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/: 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.
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
"""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."""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.
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)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.
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)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) :]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.
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)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.
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. 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.
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 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.
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 NoneSin 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
"""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 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.
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()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.
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 == "*"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.
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)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.
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(), )Tres decisiones que hereda la tabla de resultados:
- 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.
- El rescate no es un reintento. Insistir hasta que el modelo conteste algo legal mediría otro modelo.
- 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
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. 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.
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.0Mide 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.
"""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: 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
@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 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
uv run rukh play --ckpt checkpoints/small-<fecha>/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 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:
uv run rukh play --ckpt checkpoints/small-<fecha>/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. 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 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: 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.
@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"} <= tokensLa última aserción, el mismo conjunto que board.legal_moves, convierte «no propone basura» en «no
se deja ninguna».
@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. 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.
@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 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.
@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"] == []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.
@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.movesEl 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.
@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 y se salta sin GPU. Es el que caza el fallo del generador, que 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 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.
@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 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.
@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.outputtest_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.
@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.
// 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.