// M6 · lección 03
El baseline público: el nanoGPT de Karvonen en nuestra escalera
Una fila de la tabla que no entrenó nadie de aquí: nanoGPT reimplementado para no depender del repositorio de entrenamiento de otro, su formato de prompt reproducido a la letra, un jugador que escribe SAN carácter a carácter con la misma contabilidad de fallos que Qwen, y la suite prestada entera para que la comparación sea del modelo y no del harness.
Qué vas a construir
Dos ficheros nuevos con un solo producto: una fila de la tabla única que mide un modelo que no entrenó nadie de este curso, con el mismo instrumento que todas las demás.
El modelo es el nanoGPT de ajedrez de Adam Karvonen: 8 capas, 25,7 M de parámetros, entrenado sobre 16 millones de partidas de Lichess escritas en PGN carácter a carácter. Tiene números publicados por su autor, así que si nuestra fila cae donde esos números dicen, el harness es creíble; si no, lo primero que hay que explicar es la diferencia.
eval/karvonen.py reimplementa la red y la convierte en un Player del proyecto;
eval/karvonen_suite.py le pasa la suite entera (posiciones, puzles, escalera) sin cambiar una
regla. Todo el trabajo está en sostener dos exigencias a la vez: reproducir su modelo tal cual lo
entrenó su autor y medirlo con nuestro código, sin excepciones.
Por qué una fila que no es nuestra
Una tabla de modelos propios medidos con un harness propio no demuestra nada sobre el harness. Es lo que hace cualquier equipo que evalúa un LLM: antes de creerse su banco de pruebas, le pasa un modelo público con números conocidos y comprueba que salen donde deben. El docstring lo dice en su primer párrafo.
"""Adam Karvonen's chess nanoGPT as a ``Player``: the public baseline of the results table.
A table of our own models measured by our own harness proves nothing about the harness. One rowhas to be a model somebody else built, published and described, put through *exactly* the sameladder, positions and puzzles as ours -- if that row lands where its author's own numbers say itshould, the harness is credible; if it does not, the difference is the first thing to explain.
The baseline is the 8-layer, 25 M parameter character-level GPT of ``adamkarvonen/chess_llms``(``lichess_8layers_ckpt_no_optimizer.pt``), trained with Karpathy's nanoGPT on 16 M Lichess gameswritten as ``;1.e4 e5 2.Nf3 Nc6`` -- one character per token, a ``;`` before every game, no spaceafter the move number. That format is reproduced here to the character, because the author'sown README warns that performance drops without the ``;``; the model has no Elo header and noresult, so it is asked the same question as ours with less information, which the reader shouldkeep in mind when comparing rows.
The network is nanoGPT's ``GPT`` reimplemented in a hundred lines rather than imported: thecheckpoint is a ``torch.compile`` state dict with ``_orig_mod.`` prefixes and nothing else, anda baseline that pulls in a training repository to run inference is a baseline nobody rebuilds.The vocabulary (``meta.pkl`` in his ``chess_llm_interpretability`` repository) is 32 characters,pinned here by SHA-256 and by value."""Tres decisiones anunciadas ahí:
- Se reimplementa la red en vez de importarla. nanoGPT es un repositorio de entrenamiento, no una
librería, y un baseline públicoBaseline públicoUn modelo que entrenó otra persona, medido con exactamente el mismo harness que los propios: los mismos peldaños, la misma máscara de legalidad, el mismo día. Sin eso, la comparación es entre dos instrumentos y no entre dos modelos. En la tabla de Rukh son el Qwen3 afinado (M4) y el nanoGPT de ajedrez de Karvonen; se juegan aquí, no se copian de su paper. que solo corre mientras otro
repositorio siga siendo compatible es una promesa que caduca. Cuatro clases de
nn.Modulecon los mismos nombres de parámetros pesan menos. - El formato del prompt se copia a la letra. El README del autor avisa de que el rendimiento cae sin
el
;inicial, y un modelo mal preguntado es la forma más fácil de que un baseline salga bajo. - Se dice lo que el modelo no tiene. No lleva cabecera de Elo ni resultado, así que se le pregunta lo mismo que a los nuestros con menos información, y eso se escribe en el docstring, en las notas del resultado y en la card.
El vocabulario, clavado por hash y por valor
HF_REPO = "adamkarvonen/chess_llms"CHECKPOINT_FILE = "lichess_8layers_ckpt_no_optimizer.pt"LOCAL_DIR = "checkpoints/karvonen-8l"META_URL = ( "https://raw.githubusercontent.com/adamkarvonen/chess_llm_interpretability/main/models/meta.pkl")META_SHA256 = "f1121191e401988851de5744fe27fe463c3a086fc8c9a5538ef7fc12162bfb09""""The vocabulary file is not on the Hub, so it is fetched from GitHub and pinned by hash."""VOCAB = " #+-.0123456789;=BKNOQRabcdefghx""""``meta.pkl`` by value: character ``i`` of this string is token ``i``. Every SAN movepython-chess writes (``exd5``, ``Nbd2``, ``O-O-O``, ``e8=Q+``, ``Qxf7#``) spells with these."""STOI = {char: index for index, char in enumerate(VOCAB)}MAX_NEW_CHARS = 10"""A SAN move is at most seven characters (``exd8=Q+``); the author's harness also stops at 10."""COMPILED_PREFIX = "_orig_mod."El vocabularioVocabularioLa lista de todos los tokens que el modelo conoce, cada uno con un id entero fijo. En Rukh el vocabulario UCI tiene 2 030 entradas: 8 tokens especiales, 54 tramos de Elo y las 1 968 jugadas posibles, enumeradas sin mirar datos. Su tamaño fija el de la capa de embeddings y el de la capa de salida. está escrito dos veces: como los 32 caracteres de
VOCAB y como el SHA-256 del meta.pkl del autor. La constante permite correr sin red, que es lo
que necesitan los tests; el hash comprueba, cuando el fichero está, que la constante no se ha
desincronizado del original.
Con esos 32 caracteres (espacio, dígitos, punto, punto y coma, letras de piezas y casillas, y los
signos de captura, jaque, mate, promoción y enroque) se escribe cualquier SANSANNotación algebraica estándar, la de los libros: Nf3, O-O, exd5+. Es compacta para humanos pero ambigua sin el tablero (hay que saber qué caballo puede ir a f3). Lichess publica las partidas en SAN; Rukh las convierte a UCI con python-chess.
que python-chess pueda producir, y nada más: el modelo no puede escribir un token de Elo aunque
quisiera. MAX_NEW_CHARS = 10 es un tope de seguridad: una jugada SAN ocupa como mucho siete
caracteres (exd8=Q+), y el tope evita que un modelo que olvide el espacio final genere para
siempre.
nanoGPT, solo lo que el diccionario de estado necesita
La red es la de Karpathy, escrita hasta donde el diccionario de estado lo necesita: los mismos nombres, las mismas formas, ni un módulo de más.
Son las cuatro clases del decoder de M2 con otros nombres de parámetros, porque load_state_dict
compara claves. Si ya escribiste el decoder, puedes saltarlas en la primera lectura; si no las
tecleas, trae el fichero entero: git checkout p6 -- src/rukh/eval/karvonen.py.
// FontaneríaGPTConfig, el bloque de atención y GPT: el decoder de M2 con los nombres de nanoGPT
@dataclass(frozen=True)class GPTConfig: """nanoGPT's ``model_args`` as the checkpoint carries them; ``dropout`` is unused here."""
n_layer: int = 8 n_head: int = 8 n_embd: int = 512 block_size: int = 1023 bias: bool = False vocab_size: int = 32 dropout: float = 0.0Un dataclass congelado y no pydantic: este objeto no se valida contra un YAML escrito por una
persona, se construye desde el model_args que el checkpoint lleva dentro. Los valores por defecto
son los de su modelo de 8 capas, y block_size: 1023 (no 1024) es el de su entrenamiento: copiar el
número raro es reproducir, redondearlo es aproximar.
class _Attention(nn.Module): def __init__(self, cfg: GPTConfig) -> None: super().__init__() self.n_head, self.n_embd = cfg.n_head, cfg.n_embd self.c_attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd, bias=cfg.bias) self.c_proj = nn.Linear(cfg.n_embd, cfg.n_embd, bias=cfg.bias)
def forward(self, x: Tensor) -> Tensor: batch, length, width = x.shape q, k, v = self.c_attn(x).split(self.n_embd, dim=2) shape = (batch, length, self.n_head, width // self.n_head) q, k, v = (t.view(shape).transpose(1, 2) for t in (q, k, v)) y = F.scaled_dot_product_attention(q, k, v, is_causal=True) return self.c_proj(y.transpose(1, 2).contiguous().view(batch, length, width))
class _MLP(nn.Module): def __init__(self, cfg: GPTConfig) -> None: super().__init__() self.c_fc = nn.Linear(cfg.n_embd, 4 * cfg.n_embd, bias=cfg.bias) self.c_proj = nn.Linear(4 * cfg.n_embd, cfg.n_embd, bias=cfg.bias)
def forward(self, x: Tensor) -> Tensor: return self.c_proj(F.gelu(self.c_fc(x)))
class _Block(nn.Module): def __init__(self, cfg: GPTConfig) -> None: super().__init__() self.ln_1 = nn.LayerNorm(cfg.n_embd, bias=cfg.bias) self.attn = _Attention(cfg) self.ln_2 = nn.LayerNorm(cfg.n_embd, bias=cfg.bias) self.mlp = _MLP(cfg)
def forward(self, x: Tensor) -> Tensor: x = x + self.attn(self.ln_1(x)) return x + self.mlp(self.ln_2(x))Si vienes de M2, esto te resultará familiar. Las diferencias son las que separan un decoder de 2022 del nuestro:
- Los nombres son los de nanoGPT (
c_attn,c_proj,c_fc,ln_1,ln_2).load_state_dictcompara claves, así que al reimplementar un modelo ajeno los nombres son parte del formato del fichero. c_attnproduce las tres proyecciones de golpe (3 * n_embd) y se parten consplit: una multiplicación grande en vez de tres pequeñas.- Posición absoluta aprendida (
wpe, en el bloque siguiente), no RoPERoPECodificación de posición rotatoria (rotary position embedding): en vez de sumar un vector de posición, rota por pares las dimensiones de las consultas y las claves con un ángulo proporcional a la posición. El producto escalar entre dos posiciones depende entonces solo de su distancia, y no hace falta una tabla de posiciones. En Rukh es la alternativa a las posiciones aprendidas (pos: rope).. Por esoblock_sizees un límite duro. - Pre-normPre-normOrden de un bloque Transformer en el que la normalización va antes de la subcapa y su salida se suma a la entrada:
x = x + attn(ln1(x)). Deja un camino sin obstáculos entre la pérdida y las primeras capas, y es lo que permite apilar doce bloques y entrenarlos sin trucos. La variante contraria (post-norm) es la del artículo original y necesita mucho más cuidado. conLayerNormsin sesgo y GELU, la receta de GPT-2. Nada de eso cambia lo que aquí se mide.
F.scaled_dot_product_attention(..., is_causal=True) delega la máscara
causalMáscara causalRestricción que impide a cada posición mirar hacia delante: antes del softmax, los pesos de las claves futuras se ponen a menos infinito. Sin ella el modelo vería la jugada que tiene que predecir y la pérdida bajaría sin que aprendiese nada. En Rukh la aplica F.scaled_dot_product_attention(..., is_causal=True). Es lo que hace «causal» a un modelo: en M3 el encoder es el mismo bloque con causal=False, y cada posición ve la secuencia entera. a PyTorch en vez de construirla a mano como el nanoGPT original. Es de los pocos sitios
donde el código no es el del autor línea a línea, y es seguro porque la operación está especificada.
class GPT(nn.Module): """Karpathy's nanoGPT, exactly as far as the state dict needs: same names, same shapes."""
def __init__(self, cfg: GPTConfig) -> None: super().__init__() self.cfg = cfg self.transformer = nn.ModuleDict( { "wte": nn.Embedding(cfg.vocab_size, cfg.n_embd), "wpe": nn.Embedding(cfg.block_size, cfg.n_embd), "h": nn.ModuleList([_Block(cfg) for _ in range(cfg.n_layer)]), "ln_f": nn.LayerNorm(cfg.n_embd, bias=cfg.bias), } ) self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False) self.transformer.wte.weight = self.lm_head.weight # tied, as nanoGPT ties them
def forward(self, idx: Tensor) -> Tensor: """Logits over the vocabulary at every position of ``idx`` (``batch, length``).""" length = idx.shape[1] if length > self.cfg.block_size: raise ValueError(f"sequence of {length} exceeds block size {self.cfg.block_size}") pos = torch.arange(length, device=idx.device) x = self.transformer.wte(idx) + self.transformer.wpe(pos) for block in self.transformer.h: x = block(x) return self.lm_head(self.transformer.ln_f(x))El nn.ModuleDict llamado transformer con wte, wpe, h y ln_f es, otra vez, el formato del
fichero. self.transformer.wte.weight = self.lm_head.weight ata
los tied embeddingsTied embeddingsReutilizar la misma matriz para leer tokens (embedding de entrada) y para escribirlos (capa de salida sobre el vocabulario). En rukh-small ahorra 1 039 360 parámetros y fuerza a que la representación con la que se lee una jugada sea la misma con la que se apuesta por ella. asignando el mismo objeto, y el test lo comprueba
con data_ptr(). El forward no acepta etiquetas ni devuelve pérdida: la mitad del original es
entrenamiento, y cada línea que no está es una línea que no puede estar mal.
Cargar un checkpoint que pasó por torch.compile
Primero, la fontanería de la carga: un checkpoint compilado guarda sus claves con un prefijo que hay
que quitar. Se puede saltar (si no lo tecleas, git checkout p6 -- src/rukh/eval/karvonen.py trae el fichero entero); la decisión de
seguridad viene justo después.
// Fontaneríaload_state: quitar el prefijo _orig_mod que deja torch.compile y filtrar la configuración
def strip_compiled(state: dict[str, Any]) -> dict[str, Any]: """The state dict without the ``_orig_mod.`` that ``torch.compile`` leaves on every key.""" return {key.removeprefix(COMPILED_PREFIX): value for key, value in state.items()}
def load_karvonen(checkpoint: Path | str, device: str | None = None) -> GPT: """The model of a nanoGPT checkpoint (``model_args`` plus ``model``), in eval mode.""" payload = torch.load(Path(checkpoint), map_location="cpu", weights_only=True) args = {k: v for k, v in payload["model_args"].items() if k in GPTConfig.__dataclass_fields__} model = GPT(GPTConfig(**args)) model.load_state_dict(strip_compiled(payload["model"])) return model.to(device or "cpu").eval()torch.compile envuelve el modelo en un objeto llamado _orig_mod, y ese nombre se cuela delante de
todas las claves al guardar. Un checkpoint compilado no carga en un modelo sin compilar, y el error
es una lista de doscientas claves inesperadas que no dice por qué. removeprefix, que no hace nada
si el prefijo no está, hace al cargador indiferente a cómo se entrenó.
El filtro if k in GPTConfig.__dataclass_fields__ tira las claves que la configuración no tiene: lo
contrario de extra="forbid" de M0, y aquí es lo correcto, porque model_args es un fichero de otro
proyecto que puede llevar claves de entrenamiento que no describen la arquitectura. Y .eval() antes
de devolver, siempre: un modelo en modo entrenamiento da números ligeramente distintos sin fallar
nunca.
def read_meta(path: Path | str) -> dict[str, int]: """``stoi`` of ``meta.pkl``, refused unless it is byte for byte the pinned file.""" raw = Path(path).read_bytes() digest = hashlib.sha256(raw).hexdigest() if digest != META_SHA256: raise ValueError(f"{path}: sha256 {digest} is not the pinned {META_SHA256}") stoi = dict(pickle.loads(raw)["stoi"]) # noqa: S301 - the hash above pins the bytes if stoi != STOI: raise ValueError(f"{path}: vocabulary differs from the one this player writes") return stoiEl orden es toda la seguridad de esta función: el hash se comprueba antes de deserializar.
pickle.loads sobre un fichero descargado ejecuta código arbitrario (ruff lo marca con S301), y el
noqa lleva su razón al lado: los bytes ya están fijados por el hash. La segunda comprobación, que
el stoi del fichero sea igual a la constante, atrapa a quien edite VOCAB en el código.
La descarga de los dos ficheros es fontanería de red y se puede saltar en la primera lectura. Sin
ella el «Ejecútalo» del final no encuentra el checkpoint: si no la tecleas, git checkout p6 -- src/rukh/eval/karvonen.py.
// Fontaneríaensure_checkpoint: descargar el checkpoint y el meta.pkl, o fallar con el comando que los trae
def ensure_karvonen(download: bool = True, local_dir: Path | str | None = None) -> Path: """The checkpoint on disk (downloading it and ``meta.pkl`` when allowed), and its path.
The Hub repository is somebody else's, so this is not a ``rukh pull`` artefact -- nothing here is published by the course, nothing can be. ``huggingface_hub`` caches the 100 MB file once. """ directory = Path(local_dir) if local_dir is not None else paths.resolve(LOCAL_DIR) checkpoint = directory / CHECKPOINT_FILE meta = directory / "meta.pkl" if not checkpoint.is_file(): if not download: raise FileNotFoundError(f"{checkpoint} is not on disk (rukh eval karvonen pulls it)") import huggingface_hub
log.info("downloading %s/%s", HF_REPO, CHECKPOINT_FILE) directory.mkdir(parents=True, exist_ok=True) huggingface_hub.hf_hub_download(HF_REPO, CHECKPOINT_FILE, local_dir=str(directory)) if not meta.is_file() and download: import urllib.request
log.info("downloading %s", META_URL) with urllib.request.urlopen(META_URL, timeout=60) as response: # noqa: S310 - https, pinned meta.write_bytes(response.read()) if meta.is_file(): read_meta(meta) return checkpointdownload=False existe para el nightly de la lección siguiente: con --no-pull, una etapa sin su
fichero falla sola con un mensaje que dice qué comando lo trae, en vez de arrancar una descarga de 100
MB en medio de una tirada de tres horas.
El meta.pkl no está en el Hub y se baja de GitHub con urllib (el otro noqa, S310), con la
misma justificación: URL constante y contenido validado contra el hash. Y read_meta se llama
siempre que el fichero esté, aunque no se haya descargado en esta llamada, para que un meta.pkl
dejado a mano también pase por la guarda.
La transcripción, a la letra
def transcript(board: chess.Board) -> str: """The game on ``board`` in the author's format: ``;1.e4 e5 2.Nf3`` and, for White, ``3.``.
When Black is to move the text ends on White's move with no trailing space: the model writes the space itself, as it did in training, and a prompt that carried it would be a spelling it never saw. Replayed from the root because SAN depends on the position it is written in. """ replay = board.root() parts: list[str] = [] for move in board.move_stack: san = replay.san(move) parts.append(f"{replay.fullmove_number}.{san}" if replay.turn == chess.WHITE else san) replay.push(move) if replay.turn == chess.WHITE: parts.append(f"{replay.fullmove_number}.") return ";" + " ".join(parts)La función tiene tres trampas, y ninguna falla: producen un prompt razonable que el modelo responde peor.
- El espacio final. Cuando le toca a las negras, el texto acaba en la jugada de las blancas sin
espacio: el modelo lo escribe él mismo, porque así lo vio al entrenar. Por eso
writeacepta un espacio como primer carácter. - El número de jugada solo delante de las blancas y pegado:
1.e4, no1. e4. - El SANSANNotación algebraica estándar, la de los libros:
Nf3,O-O,exd5+. Es compacta para humanos pero ambigua sin el tablero (hay que saber qué caballo puede ir a f3). Lichess publica las partidas en SAN; Rukh las convierte a UCI conpython-chess. se calcula con el tablero anterior a la jugada. SAN depende del contexto (Nbd2solo existe donde dos caballos llegan a d2), y calcularlo después daría notaciones que el modelo no reconoce.
board.root() y no chess.Board(), porque si el tablero empezó en un FEN la raíz es esa.
El jugador
KarvonenPlayer implementa la misma interfaz Player que usa el decoder para jugar contra
Stockfish, así que entra en play_rungs sin adaptadores.
El esqueleto del jugador es la interfaz de siempre; lo que enseña algo es cómo decide cuándo ha
terminado de escribir, más abajo. Puedes saltarlo en la primera lectura; si no lo tecleas,
git checkout p6 -- src/rukh/eval/karvonen.py trae el fichero entero.
// FontaneríaKarvonenPlayer: la interfaz Player, el límite de plies y el generador en CPU
class KarvonenPlayer: """A ``rukh.infer.Player`` that writes SAN one character at a time from the transcript.
Same accounting as ``QwenPlayer`` (``QwenStats``, ``parse_san_answer``, first legal move as the fallback and never a second sample), so its failure breakdown is comparable with that row. """
def __init__( self, model: GPT, stats: QwenStats | None = None, temperature: float = 0.0, top_k: int | None = None, seed: int | None = None, max_new_chars: int = MAX_NEW_CHARS, max_plies: int = DECODER_PLIES, ) -> None: self.model = model self.stats = stats if stats is not None else QwenStats() self.temperature, self.top_k, self.max_new_chars = temperature, top_k, max_new_chars self.max_plies = max_plies self.device = next(model.parameters()).device self.generator: torch.Generator | None = None if seed is not None: self.generator = torch.Generator(device="cpu").manual_seed(seed)
@property def limit(self) -> int: """Plies this player can carry: the decoder's number, so every row plays the same game.""" return self.max_plies
def start(self, white_elo: int, black_elo: int) -> None: """Nothing to set: this model has no rating header, which is part of what it is."""
def observe(self, move: chess.Move) -> None: """Nothing to record: the board carries the history and the transcript is built from it."""start y observe existen porque el protocolo los pide y aquí no hacen nada: no hay cabecera de Elo
que configurar, y la partida se reconstruye del tablero cada vez. Un método vacío con un docstring
que dice por qué está vacío es información; sin él parece un olvido.
max_plies = DECODER_PLIES importa 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. del decoder: una partida
que se corta antes se adjudica, y adjudicar más veces cambia el resultado. Y el generador va en CPU
aunque el modelo esté en GPU, porque da la misma secuencia en cualquier máquina.
def _next(self, logits: Tensor) -> int: if self.temperature <= 0 or self.top_k == 1: return int(logits.argmax()) logits = logits / self.temperature if self.top_k is not None and self.top_k < logits.numel(): cutoff = torch.topk(logits, self.top_k).values[-1] logits = logits.masked_fill(logits < cutoff, -math.inf) probs = F.softmax(logits, dim=-1).cpu() return int(torch.multinomial(probs, 1, generator=self.generator))
def write(self, prompt: str) -> str: """Characters the model appends to ``prompt``, up to the space that ends the move.""" ids = [STOI[char] for char in prompt] written: list[str] = [] with torch.no_grad(): for _ in range(self.max_new_chars): window = ids[-self.model.cfg.block_size :] logits = self.model(torch.tensor([window], device=self.device))[0, -1] char = VOCAB[self._next(logits.float())] if char == ";" or (char == " " and "".join(written).strip()): break # a new game, or the end of the move written.append(char) ids.append(STOI[char]) return "".join(written)_next es el muestreo de M2: 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.
y 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., con el atajo de argmax cuando la temperatura es cero o top_k es
1, que además evita dividir por cero. La condición de parada de write merece leerse dos veces:
if char == ";" or (char == " " and "".join(written).strip()):Para en el punto y coma porque eso significa que el modelo ha decidido empezar otra partida. Y para
en el espacio solo si ya ha escrito algo que no es espacio: ese strip() permite el turno de las
negras, cuyo primer carácter es el espacio que el modelo escribe él mismo. Sin él, las negras
devolverían siempre la cadena vacía.
logits.float() antes de muestrear, porque multinomial sobre fp16 pierde resolución en la cola.
window = ids[-block_size:] recorta por la izquierda las partidas que no caben en 1023 caracteres,
lo único que se puede hacer con posición absoluta aprendida.
def answer(self, board: chess.Board) -> str: """The raw continuation the model writes for this position.""" return self.write(transcript(board))
def propose(self, board: chess.Board) -> tuple[chess.Move | None, str]: """What the model wrote, parsed, with the outcome named. No mask, no rescue.""" move, outcome = parse_san_answer(board, self.answer(board)) self.stats.record(outcome) return move, outcome
def choose(self, board: chess.Board) -> tuple[chess.Move | None, bool]: """The move to play and whether its own answer was usable; ``QwenPlayer``'s rule exactly.""" move, _ = self.propose(board) if move is not None: return move, True legal = list(board.legal_moves) return (legal[0] if legal else None), FalseTres métodos en capas. propose mide: devuelve lo que el modelo propuso y lo apunta en las
estadísticas. choose juega: si la propuesta no sirve, toma la primera jugada legal y devuelve
False para que quien cuente sepa que esa no la eligió el modelo. El rescate es la primera legal, no
un sorteo por logits como en el decoder, porque es lo que hace QwenPlayer: así las dos filas
externas tienen el mismo rescate y su desglose de fallos se lee en la misma escala.
Los puzles: reconstruir la partida que el modelo no puede leer
Un puzle llega como una posición FEN, y este modelo no sabe leer un FEN: una transcripción que empieza en la jugada 23 sin nada delante es un texto que nunca vio.
def prefix_board(board: chess.Board, history: list[int], tok: Any) -> chess.Board: """``board`` with the real game in front of it, rebuilt from the decoder's prompt ids.
A puzzle board starts from a FEN and its history lives only in the tokens the decoder reads. This model cannot read a FEN at all -- a transcript that begins at move 23 with no moves is text it never saw -- so the game is replayed from the UCI tokens and used when it reaches the puzzle's position. When it does not (a line-only parquet), the board is returned as it is and the caller counts that the model played blind. """ replay = chess.Board() for token in tok.decode(history): if token.startswith("<"): continue try: replay.push_uci(token) except ValueError: return board same = replay.fen().split(" ")[:4] == board.fen().split(" ")[:4] return replay if same else board
class KarvonenSource: """The ``MoveSource`` the puzzle suite expects; ``blind`` counts the prompts with no game."""
def __init__(self, player: KarvonenPlayer, tok: Any) -> None: self.player, self.tok = player, tok self.blind = 0
def __call__(self, board: chess.Board, history: list[int]) -> chess.Move | None: prompted = prefix_board(board, history, self.tok) if prompted is board: self.blind += 1 move, _ = self.player.choose(prompted) return move
def karvonen_source(player: KarvonenPlayer, tok: Any) -> KarvonenSource: """Puzzles are scored on the move the model *chooses*, fallback included, as for Qwen.""" return KarvonenSource(player, tok)prefix_board traduce entre los dos mundos: decodifica a UCI los tokens del prompt del decoder, los
reproduce desde el principio y comprueba que la posición a la que llega es la del puzle.
La comprobación es lo importante. Compara los cuatro primeros campos del FEN (piezas, turno,
enroques y al paso) y deja fuera los dos contadores, que pueden diferir sin que la posición sea otra.
Si falla, devuelve el tablero original sin inventarse nada, y prompted is board es la señal: la
identidad del objeto distingue «se reconstruyó» de «no se pudo» sin un segundo valor de retorno.
Esos casos, blind, se cuentan y se publican. Un puzle respondido a ciegas es una pregunta casi
imposible, y si el parquet no trae el prefijo de la partida, el porcentaje de este modelo es un
suelo, no una medición. Esconder el conteo sería publicar una tasa baja como si midiera al modelo.
La suite prestada, entera
El segundo fichero pasa la suite del decoder a este jugador. Su docstring es la política.
"""The decoder's suite on the public baseline, so the table has one row nobody here trained.
Everything is borrowed, on purpose: the validation positions of ``rukh.eval.legality``, thepuzzle split of ``rukh.eval.puzzles``, the Stockfish ladder (node budget included) and thebootstrap of ``rukh.eval.elo``, and the failure accounting of ``rukh.eval.qwen_source``. Onlythe player differs. A baseline measured by different code from the models it is compared withwould be a second harness, and the point of the row is to check the first one.
Two things this model cannot be given, and the report says so rather than papering over them.It has no rating header, so ``header_elo`` is silently not a condition for this row. And itcannot read a FEN, so a puzzle whose parquet carries no game prefix is answered blind -- from atranscript that starts at move 23 with no moves in it -- and the number of such prompts iswritten next to the puzzle rate."""src/rukh/eval/karvonen_suite.py
«Solo el jugador cambia» es la frase que hay que poder decir de cualquier baseline. Si cambia también el muestreo, el corte de partidas o la definición de puzle resuelto, ya no comparas modelos.
El resto del fichero es la suite de siempre recorrida con este jugador, y se puede saltar en la
primera lectura. Sin él no hay suite que ejecutar: si no lo tecleas, git checkout p6 -- src/rukh/eval/karvonen_suite.py.
// Fontaneríakarvonen_suite.py: el resultado, el bucle de posiciones, los puzles y la escalera con el jugador prestado
class KarvonenResult(BaseModel): """One evaluation of the public baseline on the decoder's own suite."""
model_config = ConfigDict(extra="forbid")
stage: str source: str """Where the weights come from: the Hub repository and file of their author.""" checkpoint: str model_sha: str params: int date: str device: str positions: int written: QwenStats """How its answers came out, by failure mode, over the validation positions only.""" top1: float | None = None puzzles: PuzzleResult | None = None puzzles_blind: int = 0 """Puzzle prompts that carried no game prefix and were answered from the FEN position alone, which this model cannot read. A rate over many of these is a floor, not a measurement.""" elo: EloResult | None = None notes: list[str] = [] config: dict[str, Any] = {}src/rukh/eval/karvonen_suite.py
Dos campos que el resultado del decoder no tiene: source, porque de un modelo ajeno hay que decir
de dónde salió, y puzzles_blind. config guarda la configuración entera con la que se midió. Lo
que no se midió es None, no 0.0: un cero entra en una media, un hueco no.
def evaluate_karvonen( cfg: EvalConfig, stage: str = DEFAULT_STAGE, use_cache: bool = True, device: str | None = None, checkpoint: Path | str | None = None,) -> KarvonenResult: """Run legality, next-move accuracy, puzzles and Elo on the published nanoGPT.""" from rukh.engine import EngineNotFound from rukh.eval.cache import EvalCache, config_sha, file_sha from rukh.train import pick_device
ckpt = Path(checkpoint) if checkpoint is not None else ensure_karvonen() where = device or pick_device() model = load_karvonen(ckpt, device=where) sha = file_sha(ckpt) tok = UciTokenizer() notes: list[str] = [ "this model has no rating header: header_elo does not condition this row", ] positions: list[Position] = _positions(cfg, tok, notes) player = KarvonenPlayer(model, temperature=cfg.temperature, top_k=cfg.top_k, seed=cfg.seed)
log.info("legality and accuracy over %s positions", min(len(positions), cfg.accuracy_positions)) scored = positions[: cfg.accuracy_positions] hits = 0 for position in scored: move, _ = player.propose(_board_of(position)) if move is not None and position.target is not None and move.uci() == position.target: hits += 1 written = player.stats.model_copy(deep=True)src/rukh/eval/karvonen_suite.py
La nota sobre la cabecera de Elo se añade antes de medir nada, así que no hay rama por la que un resultado salga sin ella.
_positions es una función privada de suite.py importada con su guion bajo. Normalmente eso es mal
diseño; aquí la alternativa sería duplicar la selección de posiciones de validación, y dos selecciones
que puedan divergir son lo que este fichero existe para evitar.
player.stats.model_copy(deep=True) congela el desglose de fallos cuando solo se han hecho las
posiciones de validación: el jugador sigue contando durante puzles y partidas, y mezclarlos pondría
dos poblaciones bajo el mismo porcentaje. La legalidad de esta fila sale de ese bucle
(stats.legal / stats.asked) y no de rukh.eval.legality, que mira logits sobre un vocabulario de
jugadas que este modelo no tiene. La pregunta de la columna es la misma.
cache_path = paths.resolve(cfg.cache_db) if use_cache else None games_cache = EvalCache( cache_path, sha, enabled=use_cache, config_sha=config_sha(cfg.cache_fields("games")) ) puzzles_cache = EvalCache( cache_path, sha, enabled=use_cache, config_sha=config_sha(cfg.cache_fields("puzzles")) ) try: puzzle_path = paths.resolve(cfg.puzzles) if puzzle_path.is_file(): items = load_puzzles( puzzle_path, cfg.puzzles_per_band, seed=cfg.seed, split=cfg.puzzle_split ) log.info("puzzles: %s", len(items)) source = karvonen_source(player, tok) result.puzzles = run_puzzles( source, tok, items, cache=puzzles_cache, header_elo=cfg.header_elo, force_header=cfg.force_header, ) result.puzzles_blind = source.blind if source.blind: notes.append( f"{source.blind} puzzle prompts had no game prefix and were answered from " "the position alone, which this model cannot read: the rate is a floor" ) else: notes.append(f"puzzles not found at {puzzle_path}: puzzle suite skipped")src/rukh/eval/karvonen_suite.py
Las cachés por familia de la lección anterior se usan tal cual, sin que este fichero sepa nada de
ellas. Y un parquet de puzles que falta es una nota y un None, no un error: una tirada de tres horas
que muere en el minuto dos por un fichero opcional es una tarde perdida.
if cfg.elo_games: log.info("elo: %s games per rung", cfg.elo_games) try: records = play_rungs( None, tok, cfg.elo_rungs, cfg.elo_games, cfg.sampling(), move_time=cfg.elo_move_time, max_plies=cfg.elo_max_plies, cache=games_cache, header_elo=cfg.header_elo, player=player, nodes=cfg.elo_nodes, ) except EngineNotFound as exc: notes.append(f"Elo skipped: {exc}") else: result.elo = estimate(records, samples=cfg.bootstrap, seed=cfg.seed) finally: games_cache.close() puzzles_cache.close()src/rukh/eval/karvonen_suite.py
play_rungs(None, tok, ...) con player=player: el primer argumento es el decoder y aquí no hay
ninguno. Esa ruta la abrió M4 para Qwen3 y se reutiliza sin tocarla, porque un Player es todo lo
que la escalera necesita saber. nodes=cfg.elo_nodes llega hasta aquí, y la misma línea es todo lo
que cambió en eval/qwen_suite.py:
max_plies=cfg.elo_max_plies, cache=cache, header_elo=cfg.header_elo, nodes=cfg.elo_nodes, player=player, )Con esa línea, las tres suites (decoder, Qwen y Karvonen) se pueden medir con el mismo límite, y el baseline entra en los experimentos por nodos de la lección 8.
La fila, marcada como externa
Cómo la fila queda marcada como externa en la tabla; fontanería del informe, que se puede saltar.
Si no la tecleas, trae los dos ficheros que toca:
git checkout p6 -- src/rukh/eval/report.py src/rukh/eval/karvonen_suite.py (report.py llega
con el cambio de la lección 4 ya hecho).
// FontaneríaLa marca baseline en la fila y la conversión del resultado a la tabla
date: str run_id: str | None = None baseline: bool = False """A model somebody else trained, measured here as a control on the harness itself. Off for every model of the course, and for every row written before the flag existed."""False por defecto, para que las filas anteriores al campo se sigan leyendo. La marca dice quién
entrenó el modelo, no si es bueno.
def row_of(result: KarvonenResult) -> WebRow: """The row this model contributes to the single results table, flagged as a baseline.""" elo = result.elo interval = ( [elo.ci_low, elo.ci_high] if elo and not elo.separated and elo.ci_low is not None and elo.ci_high is not None else None ) return WebRow( stage=result.stage, params=result.params, legality=result.written.legal_rate or None, top1=result.top1, puzzles=result.puzzles.by_band() if result.puzzles else {}, elo=elo.elo if elo else None, elo_ci=interval, elo_lower=elo.elo_lower if elo else None, elo_upper=elo.elo_upper if elo else None, elo_separated=bool(elo.separated) if elo else False, date=result.date, baseline=True, )src/rukh/eval/karvonen_suite.py
El interval solo se rellena si el resultado no está separado, es decir, si no todas las partidas
fueron en la misma dirección. Cuando lo están, el ajuste logístico no tiene un punto (la
verosimilitud crece sin límite), y la tabla lleva una cota (< 807 en la fila de Qwen3). Poner
percentiles ahí sería inventarse una precisión.
legality=result.written.legal_rate or None convierte un 0,0 en None. Es discutible (un modelo con
legalidad cero pasaría por «no medido»), y se sostiene porque en la práctica el cero aparece cuando
no se preguntó nada.
El informe, con la columna que el decoder no tiene
def render_markdown(result: KarvonenResult) -> str: """The report, with the failure breakdown the decoder's report has no column for.""" stats = result.written
def share(count: int) -> str: return f"{100 * count / stats.asked:.2f} %" if stats.asked else "n/a"
lines = [ f"# Evaluation of `{result.stage}` (public baseline)", "", f"- Source: `{result.source}`", f"- Checkpoint: `{result.checkpoint}`", f"- Weights SHA-256: `{result.model_sha}`", f"- Parameters: {result.params:,}", f"- Device: `{result.device}`", f"- Date: {result.date}", "", "A character-level nanoGPT trained by Adam Karvonen on 16 M Lichess games written as " "`;1.e4 e5 2.Nf3`, measured here with the same positions, puzzles and Stockfish ladder " "as every other row. It was not trained by this course and nothing about it was tuned.", "", "## Headline", "", "| Metric | Value |", "|---|---|", f"| Legal moves written, no mask | {share(stats.legal)} |", f"| Top-1 next move | {'n/a' if result.top1 is None else f'{result.top1 * 100:.1f} %'} |", f"| Puzzles solved | " f"{'n/a' if result.puzzles is None else f'{result.puzzles.rate * 100:.1f} %'} |", f"| Estimated Elo | {elo_line(result.elo)} |", "", "## How the answers failed", "", "| Outcome | Count | Share |", "|---|---:|---:|", f"| legal | {stats.legal} | {share(stats.legal)} |", f"| illegal here | {stats.illegal} | {share(stats.illegal)} |", f"| not a move at all | {stats.unparseable} | {share(stats.unparseable)} |", f"| ambiguous SAN | {stats.ambiguous} | {share(stats.ambiguous)} |", f"| nothing written | {stats.empty} | {share(stats.empty)} |", f"| **asked** | **{stats.asked}** | |", "", ]src/rukh/eval/karvonen_suite.py
Un modelo que escribe texto puede fallar de cuatro maneras, y son diagnósticos distintos. «Ilegal
aquí» es un modelo que entiende la notación y se equivoca de posición; «no es una jugada», uno que ha
perdido el formato; «SAN ambiguo», Nd2 donde llegan dos caballos, un fallo de precisión; «no
escribió nada», el modelo decidiendo que la partida acabó. Un solo porcentaje los mete en el mismo
saco. share() devuelve "n/a" cuando no se preguntó nada.
Lo que sigue del informe es formato, y se puede saltar en la primera lectura; si no lo tecleas,
git checkout p6 -- src/rukh/eval/karvonen_suite.py.
// FontaneríaEl resto del informe: los puzles a ciegas encima de los tramos y la escritura de los ficheros
if result.puzzles is not None: lines += [ "## Puzzles by band", "", f"Prompts answered without a game prefix: {result.puzzles_blind}.", "", "| Band | Attempted | Solved | Rate |", "|---|---:|---:|---:|", ] lines += [ f"| {band.band} | {band.attempted} | {band.solved} | {band.rate * 100:.1f} % |" for band in result.puzzles.bands ] lines.append("") if result.elo is not None: lines += ["## Games against Stockfish", "", "| Rung | Games | Score |", "|---|---:|---:|"] lines += [f"| {rung.name} | {rung.games} | {rung.score:.3f} |" for rung in result.elo.rungs] lines.append("") if result.notes: lines += ["## Notes", ""] + [f"- {note}" for note in result.notes] + [""] return "\n".join(lines)src/rukh/eval/karvonen_suite.py
El conteo de puzles a ciegas va encima de la tabla de tramos, no en una nota al pie, porque cambia cómo se lee lo que viene debajo.
def run_karvonen_suite( cfg: EvalConfig, stage: str = DEFAULT_STAGE, use_cache: bool = True, device: str | None = None, checkpoint: Path | str | None = None,) -> tuple[KarvonenResult, ReportPaths]: """Evaluate and report, so the row lands in the same table as every other stage.""" result = evaluate_karvonen( cfg, stage=stage, use_cache=use_cache, device=device, checkpoint=checkpoint ) report = write_report_files( result.stage, render_markdown(result), json.loads(result.model_dump_json()), paths.resolve(cfg.out_dir), paths.resolve(cfg.web_results) if cfg.web_results else None, row_of(result), ) return result, reportsrc/rukh/eval/karvonen_suite.py
write_report_files es la misma función que escribe el informe de cualquier etapa: report.md,
results.json y la fila de la tabla salen del mismo objeto, así que no pueden contradecirse porque
alguien copió un número a mano.
El subcomando
El subcomando es la CLI de siempre. Puedes saltar su lectura, pero no el código: sin él, el
«Ejecútalo» de abajo no tiene comando. Si no lo tecleas, copia la función eval_karvonen_cmd, con
su decorador, de la salida de git show p6:src/rukh/cli.py a tu cli.py.
// Fontaneríarukh eval karvonen: el subcomando y sus opciones
@eval_app.command("karvonen")def eval_karvonen_cmd( suite: Annotated[str, typer.Option("--suite", help="Suite name: full or quick.")] = "full", config: Annotated[ Path | None, typer.Option( "--config", exists=True, dir_okay=False, readable=True, help="Suite YAML override." ), ] = None, stage: Annotated[str, typer.Option("--stage", help="Row name in the results table.")] = ( "karvonen-8l" ), checkpoint: Annotated[ Path | None, typer.Option( "--checkpoint", exists=True, dir_okay=False, help="A nanoGPT checkpoint to measure instead of the published 8-layer one.", ), ] = None, no_cache: Annotated[bool, typer.Option("--no-cache", help="Recompute everything.")] = False, device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,) -> None: """Put Adam Karvonen's public chess nanoGPT through the decoder's own suite.
The one row of the table nobody here trained: same positions, same puzzles, same Stockfish ladder as every other stage, so a reader can check the harness against a model with published numbers of its own. The 100 MB checkpoint is downloaded from his Hub repository on first use; nothing of it is published back. """ from rukh.eval import load_suite from rukh.eval.karvonen_suite import run_karvonen_suite from rukh.eval.report import elo_line from rukh.eval.suite import SUITES
if suite not in SUITES: typer.echo(f"error: --suite must be one of {', '.join(SUITES)}", err=True) raise typer.Exit(code=2) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") cfg = load_suite(suite, config) try: result, report = run_karvonen_suite( cfg, stage=stage, use_cache=not no_cache, device=device, checkpoint=checkpoint ) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc stats = result.written typer.echo(f"stage: {result.stage} ({result.params:,} parameters, baseline)") typer.echo(f"source: {result.source}") typer.echo(f"legal: {stats.legal_rate:.4f} of {stats.asked} answers") typer.echo( f"failures: {stats.illegal} illegal, {stats.unparseable} not a move, " f"{stats.ambiguous} ambiguous, {stats.empty} empty" ) if result.top1 is not None: typer.echo(f"accuracy: top1 {result.top1:.4f}") if result.puzzles is not None: typer.echo( f"puzzles: {result.puzzles.rate:.4f} solved ({result.puzzles_blind} without a prefix)" ) if result.elo is not None: typer.echo(f"elo: {elo_line(result.elo)} over {result.elo.games} games") typer.echo(f"report: {report.markdown}")Las opciones son las mismas que las de rukh eval qwen menos --adapter, y un test lo comprueba:
dos baselines con banderas distintas son dos formas de equivocarse al lanzarlos. --checkpoint
permite medir otro nanoGPT (el autor publicó varios tamaños) y --stage mandarlo a otra fila.
Los tests que impiden que la fila esté mal en silencio
El fichero de tests empieza diciendo qué merece un test.
Los tests fijan el prompt carácter a carácter y el cargador. Ya los tienes: llegaron con el
git checkout del principio, así que se leen y no se teclean; no hacen falta para seguir la
lección.
// Teststests/unit/test_karvonen.py: el checkpoint compilado, el prompt a la letra, la parada y los puzles
"""Tests for rukh.eval.karvonen: the public baseline goes through the harness unchanged.
Nothing here downloads anything. What is worth testing is the part that could silently make therow wrong: that the checkpoint format loads (the ``_orig_mod.`` prefix of ``torch.compile``),that the prompt is the author's spelling to the character, that what the model writes is readthe same way Qwen's answers are, and that a failed proposal is rescued *and* counted."""«Nada aquí descarga nada»: un test que necesita 100 MB de internet no corre en la CI y acaba desactivado.
TOY = GPTConfig(n_layer=2, n_head=2, n_embd=32, block_size=64, bias=False, vocab_size=32)
def _board(*sans: str) -> chess.Board: board = chess.Board() for san in sans: board.push_san(san) return board
def test_the_vocabulary_is_the_32_characters_of_meta_pkl() -> None: assert len(VOCAB) == 32 and len(STOI) == 32 assert STOI[" "] == 0 and STOI[";"] == 15 and STOI["x"] == 31 # Every SAN python-chess can write spells with it: captures, promotions, checks, castling. for san in ("exd5", "Nbd2", "O-O-O", "e8=Q+", "Qxf7#", "R1a3"): assert all(char in STOI for char in san), san
def test_a_compiled_checkpoint_loads_with_its_prefix_stripped(tmp_path: Path) -> None: torch.manual_seed(0) original = GPT(TOY) compiled = {f"_orig_mod.{key}": value for key, value in original.state_dict().items()} assert all(key.startswith("_orig_mod.") for key in compiled) torch.save( {"model_args": {**asdict(TOY), "dropout": 0.0}, "model": compiled}, tmp_path / "ckpt.pt" )
loaded = load_karvonen(tmp_path / "ckpt.pt") assert set(strip_compiled(compiled)) == set(loaded.state_dict()) weight = loaded.transformer.h[1].mlp.c_fc.weight assert torch.equal(weight, original.transformer.h[1].mlp.c_fc.weight) # The head is tied to the embedding, as nanoGPT ties it: one tensor, not two copies. assert loaded.lm_head.weight.data_ptr() == loaded.transformer.wte.weight.data_ptr() assert not loaded.training idx = torch.tensor([[STOI[c] for c in ";1.e4"]]) assert loaded(idx).shape == (1, 5, 32)TOY es el mismo modelo con dos capas y 32 dimensiones, y todo el fichero se apoya en él. El test
del checkpoint compilado fabrica el problema: guarda un estado con los prefijos puestos a mano y
comprueba que carga. Sus cuatro aserciones finales son cuatro formas de que el cargador esté mal:
una clave que falta, un peso en otro sitio, la cabeza desatada, el modelo en modo entrenamiento.
@pytest.mark.parametrize( ("sans", "expected"), [ ((), ";1."), (("e4",), ";1.e4"), (("e4", "e5"), ";1.e4 e5 2."), (("e4", "e5", "Nf3"), ";1.e4 e5 2.Nf3"), (("e4", "e5", "Nf3", "Nc6", "Bb5"), ";1.e4 e5 2.Nf3 Nc6 3.Bb5"), ], ids=["start", "black-to-move", "white-to-move", "three-plies", "five-plies"],)def test_the_transcript_is_the_authors_format_to_the_character( sans: tuple[str, ...], expected: str) -> None: """``;`` first, no space after the move number, no trailing space when Black is to move.""" assert transcript(_board(*sans)) == expected
def test_the_transcript_writes_san_in_the_position_it_belongs_to() -> None: """``Nbd2`` only reads as such where both knights reach d2; a replay from the root knows.""" board = _board("Nf3", "d5", "d4", "Nf6", "Nbd2") assert transcript(board) == ";1.Nf3 d5 2.d4 Nf6 3.Nbd2"Cinco casos con nombre, que es lo que sale cuando uno falla. Es un test de cadena literal porque cada carácter del prompt es una decisión del autor del modelo y no hay nada que abstraer.
class _Scripted(nn.Module): """A stand-in for the network that writes a fixed string, one character per call."""
def __init__(self, text: str) -> None: super().__init__() self.cfg = TOY self.script = list(text) self.dummy = nn.Parameter(torch.zeros(1))
def forward(self, idx: Tensor) -> Tensor: logits = torch.full((1, idx.shape[1], 32), -10.0) char = self.script.pop(0) if self.script else " " logits[0, -1, STOI[char]] = 10.0 return logits
def test_writing_stops_at_the_space_that_ends_the_move() -> None: assert KarvonenPlayer(_Scripted("Nf3 Nc6 3.")).write(";1.e4 e5 2.") == "Nf3" # Black's move starts with the space the model writes itself; the second space ends it. assert KarvonenPlayer(_Scripted(" e5 2.Nf3")).write(";1.e4") == " e5" # A new game delimiter means the model thinks the game is over: nothing was written. assert KarvonenPlayer(_Scripted(";1.e4")).write(";1.e4 e5 2.") == "" # A cap, so a model that never writes a space cannot run for ever. assert KarvonenPlayer(_Scripted("a" * 40), max_new_chars=5).write(";1.") == "aaaaa"_Scripted es una red falsa que devuelve el siguiente carácter de un guion, y con ella la condición
de parada se prueba en sus cuatro ramas (jugada normal, turno de negras, punto y coma, tope) sin
pesos. self.dummy está ahí solo para que next(model.parameters()) encuentre un dispositivo.
def test_an_illegal_proposal_falls_back_and_is_counted(monkeypatch: pytest.MonkeyPatch) -> None: """Same rule as the decoder and Qwen: the game goes on, the count does not hide it.
The queen is boxed in at the start, so ``Qh5`` is well formed and illegal; the rescue is the first legal move in the position's own order, and the model is not asked again. """ player = KarvonenPlayer(_Scripted("")) monkeypatch.setattr(player, "write", lambda prompt: "Qh5") move, was_legal = player.choose(chess.Board()) assert was_legal is False assert move in chess.Board().legal_moves assert player.stats.illegal == 1 and player.stats.asked == 1
monkeypatch.setattr(player, "write", lambda prompt: "Nf9") _, was_legal = player.choose(chess.Board()) assert was_legal is False and player.stats.unparseable == 1
mate = chess.Board("7k/5KQ1/8/8/8/8/8/8 b - - 0 1") assert player.choose(mate) == (None, False)Tres casos que se cuentan distinto: una jugada bien escrita e ilegal (Qh5 al empezar), un texto que
no es una jugada (Nf9) y una posición sin jugadas legales, donde se devuelve None en vez de un
IndexError en medio de una partida.
def test_a_puzzle_prompt_is_rebuilt_from_the_games_own_moves() -> None: """The model cannot read a FEN, so the puzzle gets the game in front of it when there is one.""" tok = UciTokenizer() header = [tok.bos_id, tok.vocab[elo_token(1800, "w")], tok.vocab[elo_token(1800, "b")]] history = header + [tok.vocab[uci] for uci in ("e2e4", "e7e5", "g1f3")] position = chess.Board(_board("e4", "e5", "Nf3").fen()) # a FEN board: no move stack assert not position.move_stack
rebuilt = prefix_board(position, history, tok) assert transcript(rebuilt) == ";1.e4 e5 2.Nf3" # A prefix that does not lead to this position is not this game: the board is kept as is. elsewhere = chess.Board(_board("d4", "d5").fen()) assert prefix_board(elsewhere, history, tok) is elsewhere
source = karvonen_source(KarvonenPlayer(_Scripted(" Nc6")), tok) assert source(position, history) == chess.Move.from_uci("b8c6") assert source.blind == 0 source(elsewhere, history) assert source.blind == 1chess.Board(board.fen()) construye un tablero sin historial, como llega un puzle, y el is de la
tercera aserción comprueba la señal con la que KarvonenSource decide si el modelo jugó a ciegas.
def test_the_row_is_marked_as_a_baseline_and_older_rows_are_not() -> None: row = row_of(_result()) assert row.baseline is True assert row.stage == "karvonen-8l" and row.legality == pytest.approx(0.75) # Rows written before the flag existed carry no key and read as the course's own models. assert WebRow.model_validate({"stage": "tiny", "params": 1, "date": "2026-09-19"}).baseline is ( False )
def test_the_benchmarks_table_marks_the_external_row_and_still_renders() -> None: rows = [ row_of(_result()).model_dump(), {"stage": "tiny-greedy", "legality": 0.945, "date": "2026-09-20"}, ] table = render_benchmarks(rows) assert "| `karvonen-8l` (externo) | 75.0 %" in table assert "| `tiny-greedy` | 94.5 %" in table assert "2 etapas medidas con la misma suite, 1 de ellas externas" in tableLa segunda aserción protege la compatibilidad hacia atrás: una fila antigua, sin la clave, se lee
como False. Sin ella, un baseline: bool obligatorio rompería artifacts/web/results.json y nadie
lo vería hasta desplegar la web.
El fichero tiene además el rechazo de un meta.pkl que no es el fijado, el informe, el plan del
nightly y la comparación de banderas con rukh eval qwen. Como importa rukh.eval.nightly, que
necesita rukh.hub (lección 5) y resolve_run (lección 6), se pasa al final de la lección 6.
// Ejercicio 01Rompe el prompt de una forma que no falla
Quita el ; inicial de transcript (la última línea pasa a ser return " ".join(parts)) y
repite la tirada de humo del «Ejecútalo» con otro nombre de fila y sin caché, para que no reutilice
las respuestas de la tirada buena:
uv run rukh eval karvonen --config configs/eval/qwen-smoke.yaml --stage karvonen-sin-punto --no-cacheCompara su línea legal: con la de karvonen-smoke. El código no falla; los tests de formato sí,
aunque eso solo lo verás al terminar la lección 6, cuando test_karvonen.py se puede importar:
uv run pytest -m unit -q tests/unit/test_karvonen.py -k transcript. Antes de medir, escribe qué
columna esperas que caiga más: legalidad, top-1 o puzles. Al acabar, vuelve a poner el ;:
git diff p6 -- src/rukh/eval/karvonen.py no debe enseñar ninguna línea de transcript.
// SoluciónVer la solución
La legalidad, con diferencia. El ; es el delimitador que el modelo vio al principio de los 16
millones de partidas de su entrenamiento, así que sin él lo que recibe es el final de una partida
anterior y responde en un contexto que no es el suyo. El autor lo avisa en su README, y es la
razón de que la primera frase del docstring de karvonen.py hable del formato antes que de la
red. La lección general: un baseline mal preguntado sale bajo y no se queja, y por eso el
formato del prompt se prueba con cadenas literales.
// Ejercicio 02Qué haría falta para que Maia-2 fuera una fila
Maia-2 quedó fuera de este hito porque fija torch 2.8 frente al 2.11 del proyecto. Con
KarvonenPlayer delante, enumera qué piezas habría que escribir para meterlo en la tabla y cuál es
la que el conflicto de versiones hace imposible resolver dentro del mismo proceso.
// SoluciónVer la solución
Harían falta cuatro: cargar sus pesos, traducir la posición a su entrada, convertir su salida en
una chess.Move, y contar los fallos con QwenStats para que el desglose sea comparable. Las
cuatro son las mismas que aquí. Lo que no se puede resolver es la primera: dos versiones de
torch no conviven en un intérprete, así que el modelo tendría que correr en un proceso hijo con
su propio entorno y hablar por una tubería. El Player sería un cliente de ese proceso, que es
la forma del diseño que quedó en el backlog.
Qué has aprendido
Un baseline público es un modelo ajeno traído a tu instrumento, no un número copiado de un paper. Traerlo bien exige reproducir su formato a la letra y medirlo con tu código sin excepciones.
Tres decisiones sirven en cualquier comparación contra un modelo de otros: reimplementar en vez de importar cuando lo que importarías es un repositorio de entrenamiento; fijar por hash lo que se descarga y validarlo antes de deserializarlo; y contar lo que el modelo ajeno no pudo responder, porque un porcentaje sin ese conteo mide otra cosa.
La lección siguiente junta en un solo comando y una sola fecha las doce etapas que se miden cada vez: once filas de la tabla del decoder, con los dos baselines, y el encoder.