rukh · lab

// M6 · lección 03

Evaluar: los labs de cierre

Siete labs con sus comandos, en el orden en que se hicieron: el suelo del instrumento medido cuatro veces, la tabla entera con un comando, lo que cuesta el int8 en Elo, las cards y la colección regeneradas desde la tabla, la arena y los puzles en la demo, y el cierre de la fase.

  • 165 min
  • nivel medio
  • vigente
  • actualizado el21 de septiembre de 2026

Parte 3 de 3 del módulo «Evaluar, exportar, publicar». Viene de «Evaluar: lo que salió» y cierra el módulo y la primera fase del curso.

Labs

Siete laboratorios, en el orden en que se hicieron. Ninguno entrena nada. Los dos primeros miden el instrumento antes de fiarse de él; el tercero reconstruye la tabla entera con un comando; el cuarto responde a la pregunta que M2 dejó abierta; los tres últimos ponen la tabla donde la gente la va a leer: en cada card, en la colección y en la demo.

Lab 1 · El suelo del instrumento, medido cuatro veces

D-107 dejó una hipótesis: la escalera no repite porque el rival juega por tiempo. La forma de comprobarla es medir el mismo modelo, con la misma semilla, dos veces por tiempo y dos veces con un presupuesto de nodos, y mirar cuánto se separan las dos tiradas de cada régimen. Antes, el presupuesto: a 0,1 s por jugada Stockfish explora unos 200 000 nodos en esta máquina, así que elo_nodes: 200000 es el mismo rival en fuerza y otro en comportamiento. Es la diferencia entre un examen con tiempo fijo y uno con preguntas fijas: con una hora de reloj, cuántas preguntas contestas depende de si el de al lado tose; con veinte preguntas, contestas veinte tarde lo que tarde. El rival por nodos contesta siempre sus 200 000 preguntas.

Terminal window
uv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-time.yaml --stage ladder-time-a --no-cache
uv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-time.yaml --stage ladder-time-b --no-cache
uv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-nodes.yaml --stage ladder-nodes-a --no-cache
uv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-nodes.yaml --stage ladder-nodes-b --no-cache
uv run python labs/m6/ladder_floor_export.py --decision time

Las cuatro tiradas, con 160 partidas cada una y quince minutos de máquina parada entre ellas:

Tirada Límite Elo (IC 95 %)
ladder-time-a 0,1 s 1538 (1476-1599)
ladder-time-b 0,1 s 1538 (1486-1594)
ladder-nodes-a 200 000 nodos 1541 (1485-1596)
ladder-nodes-b 200 000 nodos 1524 (1457-1584)

Cero Elo de separación por tiempo, 17 por nodos, y las dos muy por debajo del semiancho del intervalo (±55). La hipótesis de D-107 era razonable y estaba mal: el presupuesto de nodos hace determinista la búsqueda, no al rival, porque UCI_LimitStrength aleatoriza a propósito para acertar el Elo pedido. Se ve peldaño a peldaño: las dos tiradas por tiempo dan el mismo 1538 agregado con uci-1500 a 0,45 y 0,625, y las dos por nodos se separan igual. Lo que D-107 vio, 1498 frente a 1558, fue una tirada con la máquina cargada o mala suerte a 1,18 σ. Con la máquina tranquila, el reloj repite al nivel del ruido de muestreo.

La decisión (D-137) es la que la regla del plan obligaba a tomar con el número delante: los nodos no estrechan el suelo, así que el rival sigue por tiempo y no se recalibra la escalera ni cambian de escala los Elo publicados. Lo que entra en el runbook es la condición: nada más en la máquina mientras corre una escalera por tiempo. elo_nodes se queda en la suite para quien mida en una máquina compartida, y es lo que usa el lab 3, donde el jugador mismo carga la CPU. La figura con las cuatro tiradas está en la parte 2.

Y la calibración, motor contra motor, de los ocho peldaños por reloj: cada peldaño juega 40 partidas contra el ancla uci-1320 con colores alternados y una apertura aleatoria por pareja, y el script imprime el Elo que explica el marcador al lado de la etiqueta que la escalera lleva.

Terminal window
uv run python labs/m6/ladder_check.py --games 40
anchor uci-1320 (1320) · 40 games per rung · 0.1 s per move
rung label score measured IC 95 % delta
skill-0 1381 0.588 1381 1276-1488 +0
skill-1 1467 0.762 1523 1418-1669 +56
uci-1500 1500 0.725 1488 1381-1621 -12
skill-2 1589 0.762 1523 1418-1658 -66
skill-3 1678 0.938 1790 1658-2520 +112
uci-1800 1800 0.900 1702 1561-1956 -98
uci-2000 2000 0.950 1832 1679-2520 -168
2126 s
control: uci-1500 should read about +180 over the anchor; if it does not, stop here.

El control pasa: uci-1500 sale a +168 sobre el ancla, donde los autores de Stockfish lo ponen a +180. Las siete etiquetas caen dentro del intervalo de su medición, así que la escalera de D-070 se sostiene; y los intervalos de los tres peldaños altos son enormes (hasta 2520) porque el ancla pierde casi todo contra ellos: 40 partidas contra un rival que gana el 95 % no dicen dónde está, solo que está lejos. Es la limitación estructural de calibrar con un solo ancla, y el motivo de que la escalera use ocho peldaños y no dos.

// Ejercicio

Corre ladder-time-a otra vez mientras compilas algo grande en la misma máquina. ¿Cuánto se mueve el Elo? ¿Y si lo haces con ladder-nodes.yaml? Antes de mirar, escribe cuál de los dos esperas que se mueva y en qué dirección.

// SoluciónVer la solución

Por tiempo, el rival explora menos nodos cuando la CPU está ocupada y juega peor: el Elo del modelo sube sin que el modelo cambie (D-068 midió +13 Elo por carga de CPU en P3). Por nodos, el rival juega exactamente las mismas jugadas ante las mismas posiciones, y lo único que cambia es cuánto tarda la tirada. Es la diferencia entre un instrumento que mide el modelo y uno que mide el modelo más lo que esté haciendo la máquina.

Lab 2 · La tabla entera con un comando

Hasta aquí cada módulo había medido lo suyo, el día que lo cerró, con el harness de ese día. La tabla única exige otra cosa: todas las etapas, la misma suite, el mismo día. rukh eval nightly recorre el catálogo de rukh pull, trae lo que falte, fusiona cada adaptador LoRA sobre su base, mide lo que no está en caché y reescribe artifacts/web/results.json y docs/benchmarks.md. Primero el plan, que no mide nada:

Terminal window
uv run rukh eval nightly --dry-run
stage measure status seconds checkpoint
tiny-greedy decoder planned 0.0 checkpoints/tiny/best.pt
small-v3-greedy decoder planned 0.0 checkpoints/small-v3/best.pt
medium-v4-greedy decoder planned 0.0 checkpoints/medium-v4/best.pt
encoder-v4 encoder planned 0.0 checkpoints/encoder-heads-v4/step-4000.pt
medium-elo decoder planned 0.0 checkpoints/medium-elo/step-3800.pt
medium-masters decoder planned 0.0 checkpoints/medium-masters/step-3800.pt
lora-e4 decoder planned 0.0 checkpoints/lora-e4
lora-d4 decoder planned 0.0 checkpoints/lora-d4
qwen3-pgn-qlora qwen planned 0.0 checkpoints/qwen3-pgn-qlora
medium-v4-dpo-onpolicy-greedy decoder planned 0.0 checkpoints/medium-v4-dpo-onpolicy/dpo.pt
medium-v4-grpo-greedy decoder planned 0.0 checkpoints/medium-v4-grpo/grpo.pt
karvonen-8l karvonen planned 0.0 checkpoints/karvonen-8l/lichess_8layers_ckpt_no_optimizer.pt

Doce etapas: las nueve del proyecto que juegan, el encoder, y dos baselines que no entrenó nadie de aquí. El Qwen3 afinado de M4 ya estaba; el de Karvonen es nuevo y merece su párrafo. Es el nanoGPT de ajedrez de Adam Karvonen (8 capas, 25,7 M de parámetros, entrenado sobre 16 millones de partidas de Lichess en PGN carácter a carácter), reimplementado aquí sin depender de su repositorio y puesto en la misma escalera que el resto: mismo play_rungs, mismos peldaños, misma máscara de rescate, mismo criterio de puzles. Se le pregunta en su formato exacto (;1.e4 e5 2.) y con una regla más estricta que la de su propio harness, que reintenta hasta cinco veces con más temperatura ante una jugada ilegal; aquí se pregunta una vez y se cuenta.

Terminal window
uv run rukh eval nightly
stage measure status seconds
tiny-greedy decoder measured 698.1
small-v3-greedy decoder measured 825.3
medium-v4-greedy decoder measured 866.4
encoder-v4 encoder measured 7.1
medium-elo decoder measured 864.8
medium-masters decoder measured 872.7
lora-e4 decoder measured 849.5
lora-d4 decoder measured 913.6
qwen3-pgn-qlora qwen measured 2506.2
medium-v4-dpo-onpolicy-greedy decoder measured 858.4
medium-v4-grpo-greedy decoder measured 843.4
karvonen-8l karvonen failed 0.1 (ValueError: … holds no model_state to hash)
seconds: 10106

Dos horas y cuarenta y ocho minutos, unos catorce minutos por etapa de decoder (el encoder tarda siete segundos porque no juega), cuarenta y dos para Qwen, y un fallo: el checkpoint de Karvonen guarda sus tensores bajo model y no bajo model_state, y el hash de pesos de D-135 no lo sabía. Una línea después, rukh eval nightly --only karvonen-8l lo midió en quince minutos. Las dos filas que no estaban en el catálogo (small v1 y el DPO fuera de política) se volvieron a medir a mano el mismo día para que ninguna fila de decoder tuviera otra fecha: 1355 (1297-1420) y 1586 (1530-1651).

Las dos filas del encoder que llevaban el nombre de su carpeta de corrida (el backlog de P3 lo dejó anotado) se retiran con rukh eval drop, y la tabla que queda es la de la parte 2.

// Ejercicio

Cambia una línea de configs/eval/greedy.yaml que no afecte a las partidas (por ejemplo puzzles_use_header) y vuelve a lanzar el nightly con --only tiny. ¿Cuánto tarda? ¿Y si cambias elo_games?

// SoluciónVer la solución

Con la clave de caché partida por familia (D-136), tocar la cabecera de los puzles invalida solo los puzles y las partidas se leen de la caché: segundos de Stockfish, no minutos. Tocar elo_games invalida las partidas y conserva los puzles. Es una fecha de caducidad por producto y no por nevera: que caduque la leche no obliga a tirar el arroz. Antes de M6 la clave era una sola y cualquiera de los dos cambios volvía a jugarlo todo; con doce etapas, eso era una tarde.

Lo que escribe una evaluación, y por qué está partido en dos

Una tirada deja dos ficheros por etapa y ninguno de los dos se escribe a mano: artifacts/eval/<stage>/results.json, que es la medición completa con todas sus partidas y sus conteos, y report.md, que es la lectura para personas. De los dos sale la fila de artifacts/web/results.json que llega a la tabla de esta web.

src/rukh/eval/report.py
class StageResult(BaseModel):
"""Una fila de la tabla única. Los `None` son un valor, no un hueco que rellenar."""
model_config = ConfigDict(extra="forbid")
stage: str
suite: str
checkpoint: str
weights_sha256: str
"""El hash de **los pesos solos**, no el del fichero. El `.pt` que sube al Hub no es byte a
byte el que se entrenó —se le quita el estado del optimizador—, así que un hash de fichero
diría que son dos modelos distintos y un lector no podría comprobar nada."""
parameters: int
sampling: dict[str, object]
"""Con qué temperatura y `top_k` se midió. Un Elo es del **par** modelo y muestreo, así que una
fila sin esto no se puede comparar con ninguna otra."""
date: str
legality_argmax: float | None = None
legality_sampled: float | None = None
top1: float | None = None
top3: float | None = None
puzzles: float | None = None
elo: float | None = None
elo_ci: tuple[float, float] | None = None
notes: list[str] = []
"""Lo que un número necesita para leerse: cuántas partidas se cortaron por contexto, cuántas
se adjudicaron, si los peldaños bajos son anclas nominales."""

Que results.json y report.md salgan del mismo objeto y no uno del otro es lo que impide la divergencia clásica: un informe que dice una cosa y una tabla que dice otra porque alguien copió un número a mano. Y que los campos que no se midieron sean None y no 0.0 es la misma regla de M3 con el -100: un cero es un valor y entra en la media; un hueco explícito es información.

src/rukh/publish/cards.py
def card_for(result: StageResult, template: str) -> str:
"""La model card, generada desde `results.json` y `run.json`. Nunca escrita a mano.
Una card no es documentación: es la **afirmación pública** de qué se publicó y qué se midió, y
su prueba es si otra persona puede contradecirla con lo que hay dentro. Generarla desde los
ficheros de la medición es lo que garantiza que la afirmación y la medición no se separen.
"""
return Template(template).render(
stage=result.stage,
checkpoint=result.checkpoint,
weights_sha256=result.weights_sha256,
sampling=result.sampling,
date=result.date,
# `n/a` explícito, nunca un número prestado: publicar un adaptador con el Elo de su modelo
# base sería publicar el número de otro, y hay un test de eso.
metrics={k: ("n/a" if v is None else v) for k, v in result.headline().items()},
notes=result.notes,
)

Lab 3 · Lo que cuesta el int8, en Elo

M2 midió la paridad de las exportaciones y dejó la pregunta abierta: un int8 que elige otra jugada una vez de cada veinte, ¿cuánto peor juega? La paridad no lo dice; hay que jugar. OnnxDecoder pone el grafo ONNX dentro del mismo DecoderPlayer que usa el checkpoint, así que las tres precisiones pasan por la misma máscara, el mismo muestreo y el mismo rescate. ONNX Runtime juega en CPU, y por eso este lab quiere el rival por nodos: por tiempo, un jugador más lento cambiaría también el lado del motor.

Terminal window
uv run python labs/m6/parity_cost.py --onnx artifacts/onnx/medium-v4 --config configs/eval/ladder-nodes.yaml
medium-v4 · 20 games per rung · 8 rungs · 200000 nodes
fp32 parity 1.000 elo 1472 (1415-1527) 160 games 816 s
fp16 parity 0.999 elo 1535 (1484-1600) 160 games 837 s
int8 parity 0.951 elo 1524 (1467-1576) 160 games 813 s
Precisión Paridad Elo (IC 95 %)
fp32 100 % 1472 (1415-1527)
fp16 99,9 % 1535 (1484-1600)
int8 95,1 % 1524 (1467-1576)

El int8 no tiene un coste en Elo medible con 160 partidas: 1524 (1467-1576) frente a 1535 (1484-1600) del fp16, con los intervalos casi superpuestos. Y la fila que enseña más es la del fp32, que es la misma red que el checkpoint y sale 60 puntos por debajo del fp16: no hay ningún mecanismo por el que un grafo con paridad del 100 % juegue peor que uno del 99,9 %; es el suelo del instrumento otra vez, con el rival por nodos, en la misma tarde. La pregunta que M2 dejó abierta se responde así: el 4,9 % de jugadas distintas del int8 cuesta menos de lo que la escalera puede ver con este presupuesto, y para verlo haría falta un enfrentamiento directo fp16 contra int8 de varios miles de partidas, que es el segundo ejercicio de este lab. La figura con las tres barras de paridad y los tres intervalos está en la parte 2.

// Ejercicio

La demo sirve int8 en móvil y fp16 en escritorio. Con la tabla de arriba delante, ¿qué le dirías a alguien que juega en el móvil sobre contra qué está jugando? Escribe la frase que pondrías en la interfaz.

// SoluciónVer la solución

La frase honesta nombra la etapa y su Elo con intervalo, no «el mismo modelo más pequeño». Si el intervalo del int8 solapa con el del fp16, la frase puede decir que no se ha medido una diferencia; si no solapa, tiene que decir cuánto. Lo que no puede decir es «95 % igual», porque ese número mide la exportación y no la fuerza.

// Ejercicio

Diseña el enfrentamiento directo fp16 contra int8 antes de jugarlo. rukh eval match acepta hoy checkpoints o ids del Hub, no grafos ONNX: ¿qué le falta? Y con uv run python labs/m5/games_needed_match.py --edges 11,25,50 delante, ¿cuántas partidas pagarías para ver la diferencia que la escalera no ha visto?

// SoluciónVer la solución

El jugador ya existe: OnnxDecoder entra en el mismo DecoderPlayer que usa parity_cost.py, así que lo que falta es que --a y --b acepten un directorio ONNX con su precisión. Sobre el número: los 11 puntos que la escalera dejó entre fp16 e int8 piden 3 832 partidas por parejas; una diferencia de 25 pide 741 y una de 50, las 185 de M5, porque el coste crece con el cuadrado de lo que quieres ver. La decisión es cuál de las tres te importaría de verdad, y se toma antes de pagar: jugar 200 partidas y leer «no hay diferencia» es leer el ruido, que es exactamente lo que la escalera ya había hecho.

Lab 4 · Las cards y la colección, regeneradas desde la tabla

Una card se escribe desde run.json y results.json; cuando la tabla cambia, todas las cards del proyecto están desactualizadas. Republicar 460 MB de pesos para cambiar un párrafo es la herramienta equivocada: rukh publish cards prepara cada repositorio igual que lo haría su propio publicador, en seco, y sube solo el README.md. Y cada card lleva ahora una sección «In the course» con la lección que la construyó, la fila de la tabla, la demo y el comando rukh pull que la trae.

Terminal window
uv run rukh publish cards --dry-run
chorcat/rukh-tiny staged artifacts/publish/chorcat/rukh-tiny/README.md
chorcat/rukh-small staged artifacts/publish/chorcat/rukh-small/README.md
chorcat/rukh-medium staged artifacts/publish/chorcat/rukh-medium/README.md
chorcat/rukh-tokenizer staged data/publish/rukh-tokenizer/README.md

La primera vez que se lanzó no salió así. rukh-tiny falló: la evaluación registraba el SHA del fichero que midió y el fichero que rukh pull trae del Hub no es ese byte a byte, porque el publicador quita el optimizador y la cabeza atada. Eran los mismos pesos y la guarda no lo sabía. Es la misma novela en tapa dura y en bolsillo: distinto lomo, distinto peso, distinto código de barras, y el mismo texto de la primera línea a la última. Una guarda que compara códigos de barras dice que son libros distintos; la que compara el texto dice que es el mismo libro, y sigue distinguiéndolo de una edición con un capítulo cambiado. La solución es D-135: cada resultado lleva también el SHA de los tensores, y la guarda acepta un fichero distinto con los mismos pesos y sigue rechazando unos pesos distintos. Sin la guarda, la card de tiny habría llevado los números de otro fichero sin que nadie lo notara; con la guarda antigua, no se podía publicar nada desde una máquina que no fuera la que entrenó.

Terminal window
uv run rukh publish cards
uv run rukh publish collection

La colección se monta desde el catálogo de rukh pull, así que lo que se puede descargar, lo que se puede leer y lo que está en la tabla son la misma lista de veintiún repositorios. Es idempotente: lanzarla dos veces no cambia nada.

// Ejercicio

Abre la card de chorcat/rukh-medium-dpo y busca tres cosas: sobre qué fichero se midió, con qué muestreo, y qué dice de la legalidad comparada con su base. ¿Falta alguna? ¿Sobra algún número que no esté en la tabla?

// SoluciónVer la solución

Las tres están, porque la plantilla las exige: el SHA del fichero y el de los pesos, la temperatura y el top_k de la etapa, y la fila de legalidad al lado de la de medium-v4 con la nota del peaje del alineamiento de M5. Si un número de la card no está en results.json o en el ledger, es un bug de la plantilla, no una licencia poética: la card es la afirmación pública, y todo lo que afirma tiene que poder contradecirse con lo que hay dentro.

Lab 5 · La arena: dos etapas, en tu navegador

La lección de M5 enseñó que para medir una diferencia se mide la diferencia. La demo lo hace ahora en el navegador: dos etapas, cada una en su propio worker, juegan partidas por parejas con los colores espejados desde una apertura aleatoria con semilla, y el marcador se convierte en una diferencia de Elo con su intervalo bootstrap, con la misma aritmética que rukh eval match. Al lado imprime cuántas partidas haría falta jugar para que una diferencia de ese tamaño se separara del cero, para que nadie lea diez partidas como un veredicto.

// Ejercicio

Enfrenta medium-fp16 con medium-dpo-fp16 durante veinte partidas. Anota la diferencia y el intervalo. ¿Cuántas partidas dice la demo que harían falta? Compáralo con las 800 que jugó M5 por arista y con el +57 (36-79) que publicó.

// SoluciónVer la solución

Con veinte partidas el intervalo cubre varios cientos de Elo y casi siempre incluye el cero: la demo lo dice y pide del orden de 400 partidas para una diferencia de 50. Eso no es un defecto de la demo, es la aritmética de M5 funcionando delante de ti: M5 jugó el doble por arista, 800, y aun así el +57 lleva un intervalo de 36 a 79. Lo que la arena sí enseña con veinte partidas es cómo juegan distinto: el DPO abre otras cosas y cambia antes de plan, y eso se ve en el tablero antes de que se vea en el número.

Lab 6 · Los puzles, en vivo, contra la fila de la tabla

Los 150 puzles de la demo salen del mismo split de test que la suite, con el prefijo real de la partida y los Elo de los jugadores, así que el modelo se pregunta exactamente como lo pregunta el harness, y el criterio es el mismo: la línea entera, no la primera jugada.

Terminal window
uv run python labs/m6/puzzles_export.py # 50 por tramo, semilla 6, a artifacts/web/puzzles.json

// Ejercicio

Deja que small-fp16 resuelva los 150 y compara su tabla por tramos con la fila small-v3-greedy de la tabla del proyecto. ¿Coinciden? ¿Deberían coincidir exactamente?

// SoluciónVer la solución

Se parecen y no coinciden, por tres motivos que conviene tener claros: son 50 puzles por tramo y no 2 000, así que el ruido de muestreo es de varios puntos; la demo muestrea a la temperatura de la interfaz y la fila -greedy a 0,05; y el fp16 no es el checkpoint, es una exportación con paridad del 99,9 %. Que salgan a pocos puntos es la comprobación de que la demo y el harness hacen la misma pregunta; que salieran iguales sería sospechoso.

Lab 7 · El README maestro y el post

El cierre de la fase son dos textos. El README de rukh es el mapa: qué se construyó en cada módulo, con qué se midió y dónde está cada cosa (la tabla, el curso, la demo, la colección). El post es el guion del vídeo, con las tres cifras que importan y ninguna que no esté en docs/benchmarks.md o en el ledger. Los dos se escriben después de la tabla y no antes, y esa es la única regla.

// Ejercicio

Escribe en tres frases qué modelo de Rukh recomendarías a alguien que quiere jugar en el móvil, a alguien que quiere el mejor Elo y a alguien que quiere estudiar el repertorio de un maestro. Cada frase con su etapa, su número y su intervalo.

// SoluciónVer la solución

Tres frases que valen, con los números de la tabla y del lab 3:

  • Móvil: medium-int8, el medium-v4 cuantizado que sirve la demo; en la escalera por nodos dio 1524 (1467-1576) frente a 1535 (1484-1600) del fp16, así que con 160 partidas no se ha medido que cueste nada, y es otra etapa, no «el mismo modelo más pequeño».
  • Mejor Elo: medium-v4-dpo-onpolicy-greedy, 1632 (1567-1706), la fila más alta de la tabla; y su ventaja sobre la base no es la resta de dos filas, es el +57 (36-79) del enfrentamiento directo de M5.
  • Repertorio de maestro: medium-masters, 1563 (1502-1630), que no se separa de su base en Elo y sí en lo que abre: entropía de la primera jugada de 1,89 bits frente a 1,77.

Las tres llevan intervalo porque dos de ellas se distinguen de sus vecinas por menos de lo que el instrumento repite. Si te sale una frase sin intervalo, vuelve a la parte 1.

Lo que deja el módulo

Tres frases, que son las del módulo y las de la fase.

Mide el instrumento antes de fiarte de él. Cuatro escaleras del mismo modelo, dos calibraciones y una tabla vieja frente a una nueva dijeron lo mismo: el reloj repite al nivel del ruido de muestreo con la máquina parada, los nodos no compran nada, y lo que se mueve es lo que el intervalo dice que se puede mover. Sin esa medición, los +72 de dpo-onpolicy en un día habrían sido una noticia.

Una tabla es de un día. Trece filas de decoder con la misma fecha, un comando que las regenera, y cada card diciendo sobre qué fichero y con qué muestreo se midió cada número. La que no cumple eso no se compara; se retira o se vuelve a medir.

Lo que no se puede decir es parte del resultado. Un Elo contra Stockfish limitado no es un Elo de Lichess, una paridad no es una fuerza, un baseline medido con otro harness no es un baseline, y un lector reproduce el intervalo, no el número. Eso va en cada card y en el vídeo.

Con eso termina la primera fase: un modelo de lenguaje de ajedrez construido desde el corpus hasta el navegador, medido, publicado y reproducible módulo a módulo. La segunda empieza donde esta acaba: el modelo deja de ser lo único que hay y pasa a ser una herramienta que algo más usa.

La cheatsheet del módulo, con su pregunta y su respuesta corta, está justo debajo.

// cheatsheet M6

Trece preguntas para llevarte

01¿Qué mide cada columna de la tabla, y cuál es la trampa de cada una?
Legalidad sin máscara: si el modelo sabe qué jugadas existen (suelo; por debajo del 99 % nada de lo demás se interpreta). Top-1 y top-3: **imitación** de los humanos de 1800 en adelante, no calidad. Puzles: táctica, con la línea entera y no la primera jugada. Elo: cuánto gana a un Stockfish limitado, y es la única que mide jugar. Las cuatro salen del mismo modelo y se mueven por separado: `medium-v4-dpo-onpolicy` tiene mejor Elo que `medium-v4` y peor top-1, y no es una contradicción.
02¿Por qué el Elo es un par y no una propiedad del modelo?
Porque el mismo checkpoint a temperatura 0,05 y `top_k` 1 y a temperatura 0,6 y `top_k` 20 son dos jugadores distintos, a unos 180 Elo de distancia (1359 frente a 1181, D-047). M2 las puso en dos filas, `small` y `small-greedy`; la tabla de hoy solo lleva las `-greedy`, y la card dice siempre con qué muestreo se midió lo que afirma.
03¿De dónde sale el intervalo del Elo y qué no cubre?
De un bootstrap por percentiles: 500 remuestreos de las 160 partidas, un ajuste logístico por remuestreo, percentiles 2,5 y 97,5. Cubre el ruido del muestreo de partidas y nada más. No cubre que los peldaños estén donde dicen (`UCI_Elo` está calibrado para ritmo de torneo, no para 0,1 s; los `Skill Level` son anclas nominales), ni que el rival sea el mismo en dos corridas, ni que ese Elo se parezca al de Lichess: no se parece y no hay conversión honesta.
04¿Qué son los tres números de reproducibilidad del proyecto?
D-107: la misma escalera con la misma semilla dio 1498 y 1558, porque el rival juega por tiempo y aleatoriza. D-113: el mismo reward model con la misma semilla y el mismo reparto dio 72,91 % y 74,21 %, por los kernels no deterministas de la GPU. M6: el mismo modelo dos veces por tiempo con la máquina parada dio 1538 y 1538, y dos veces por nodos dio 1541 y 1524: los nodos no compran reproducibilidad porque `UCI_LimitStrength` aleatoriza igual, y el rival se queda por tiempo (D-137). La regla es la misma en los tres: antes de explicar una diferencia pequeña, mide cuánto se mueve tu montaje cuando no cambias nada.
05¿Medir al rival por nodos arregla la reproducibilidad de la escalera?
No, y era la hipótesis razonable: a 0,1 s lo que Stockfish explora depende de lo que esté haciendo la máquina (D-068 midió +13 Elo por carga de CPU), así que fijar los nodos debería quitar ese ruido. M6 la midió y no: por nodos las dos tiradas se separan 17 Elo y por tiempo 0, porque `UCI_LimitStrength` aleatoriza al rival a propósito con los dos límites. El rival se queda por tiempo (D-137) con una condición de runbook: nada más en la máquina mientras corre. `elo_nodes` queda para máquinas compartidas y para el lab en que el propio jugador carga la CPU.
06¿Qué dice y qué no dice una paridad del 95 %?
Dice que la exportación está bien hecha (un 80 % en fp16 sería un bug del grafo). No dice cuánto peor juega: un int8 que elige otra jugada una vez de cada veinte es otro jugador, y su Elo solo se sabe jugando. `medium-v4`: fp16 99,9 %, int8 95,1 % de paridad, y el coste en Elo lo mide el lab 3 con el grafo ONNX dentro del mismo `DecoderPlayer` que usa el checkpoint.
07¿Por qué la cuantización dinámica pierde más paridad de lo que sugiere el tamaño del error?
Porque el error de redondeo no se reparte de forma uniforme: se nota donde los logits de las dos mejores jugadas están cerca, que es exactamente donde la decisión es difícil. Un error de 2,8 en los logits (el máximo medido en el int8 de `medium-v4`) no cambia nada en una posición con una jugada clara y cambia la elección en una posición igualada.
08¿Qué tiene que llevar una model card para que alguien pueda contradecirla?
El fichero exacto que se midió (SHA-256 del fichero y, desde M6, de los pesos), el muestreo y la suite de cada número, la fecha, lo que no se midió (`n/a` es un valor), la procedencia y la licencia de los datos y la de los pesos. Las de Rukh se generan desde `run.json` y `results.json`, no se escriben a mano, y `rukh publish cards` las regenera todas subiendo solo el `README.md`.
09¿Por qué un modelo se identifica por sus pesos y no por su fichero (D-135)?
Porque el fichero que `rukh pull` trae del Hub no es byte a byte el que se midió: el publicador quita el optimizador y la cabeza atada, y `torch.save` no es estable. La guarda que compara SHA de ficheros rechazaba los mismos pesos; la que compara el SHA de los tensores de `model_state` (sin las cabezas atadas) acepta el mismo modelo en otro fichero y sigue rechazando otros pesos.
10¿Qué hace `rukh eval nightly` y por qué la clave de caché se partió en dos?
Recorre el catálogo de `rukh pull`, trae lo que falte, fusiona cada LoRA sobre su base, mide lo que no está en caché y reescribe `results.json` y `benchmarks.md`: todas las etapas, la misma suite, el mismo día. La clave de caché se partió por familia (D-136): la de partidas lleva el límite del rival y los peldaños, la de puzles no; antes, cambiar el tiempo por jugada invalidaba 6 000 puzles que no lo usan.
11¿Qué hace que un baseline sea comparable?
Que se mida con el mismo harness: los mismos peldaños, la misma máscara de rescate, el mismo criterio de puzles, el mismo día. El nanoGPT de ajedrez de Karvonen se reimplementó y se puso en el mismo `play_rungs` que el resto, preguntado en su formato exacto y con una regla más estricta que la suya (una pregunta y se cuenta, sin cinco reintentos). Copiar el número de su paper habría comparado dos instrumentos, no dos modelos.
12¿Qué no se puede decir de Rukh aunque los números inviten?
Que «juega a 1500 Elo» (gana lo que ganaría un 1500 contra Stockfish limitado a 0,1 s, no contra personas); que un alineado es «56 puntos mejor» restando dos escaleras (la resta de dos ±55 es ±80; se dice el +57 (36-79) del enfrentamiento directo); que «entiende ajedrez» (no hay instrumento que lo mida); que es mejor que un baseline medido con otro harness; y que un lector «reproduce el número»: reproduce el intervalo.
13¿Por qué la arena de la demo imprime cuántas partidas harían falta?
Para que nadie lea diez partidas como un veredicto. Con veinte partidas el intervalo de la diferencia cubre varios cientos de Elo; una diferencia de 50 pide del orden de 400 partidas (la aritmética de M5, `gamesNeeded`). Lo que veinte partidas sí enseñan es **cómo** juegan distinto dos etapas, que se ve en el tablero antes que en el número.
Todas las cheatsheets, imprimibles →