// 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.
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.
uv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-time.yaml --stage ladder-time-a --no-cacheuv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-time.yaml --stage ladder-time-b --no-cacheuv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-nodes.yaml --stage ladder-nodes-a --no-cacheuv run rukh eval --model checkpoints/medium-v4/best.pt --config configs/eval/ladder-nodes.yaml --stage ladder-nodes-b --no-cacheuv run python labs/m6/ladder_floor_export.py --decision timeLas 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.
uv run python labs/m6/ladder_check.py --games 40anchor uci-1320 (1320) · 40 games per rung · 0.1 s per moverung label score measured IC 95 % deltaskill-0 1381 0.588 1381 1276-1488 +0skill-1 1467 0.762 1523 1418-1669 +56uci-1500 1500 0.725 1488 1381-1621 -12skill-2 1589 0.762 1523 1418-1658 -66skill-3 1678 0.938 1790 1658-2520 +112uci-1800 1800 0.900 1702 1561-1956 -98uci-2000 2000 0.950 1832 1679-2520 -1682126 scontrol: 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:
uv run rukh eval nightly --dry-runstage measure status seconds checkpointtiny-greedy decoder planned 0.0 checkpoints/tiny/best.ptsmall-v3-greedy decoder planned 0.0 checkpoints/small-v3/best.ptmedium-v4-greedy decoder planned 0.0 checkpoints/medium-v4/best.ptencoder-v4 encoder planned 0.0 checkpoints/encoder-heads-v4/step-4000.ptmedium-elo decoder planned 0.0 checkpoints/medium-elo/step-3800.ptmedium-masters decoder planned 0.0 checkpoints/medium-masters/step-3800.ptlora-e4 decoder planned 0.0 checkpoints/lora-e4lora-d4 decoder planned 0.0 checkpoints/lora-d4qwen3-pgn-qlora qwen planned 0.0 checkpoints/qwen3-pgn-qloramedium-v4-dpo-onpolicy-greedy decoder planned 0.0 checkpoints/medium-v4-dpo-onpolicy/dpo.ptmedium-v4-grpo-greedy decoder planned 0.0 checkpoints/medium-v4-grpo/grpo.ptkarvonen-8l karvonen planned 0.0 checkpoints/karvonen-8l/lichess_8layers_ckpt_no_optimizer.ptDoce 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.
uv run rukh eval nightlystage measure status secondstiny-greedy decoder measured 698.1small-v3-greedy decoder measured 825.3medium-v4-greedy decoder measured 866.4encoder-v4 encoder measured 7.1medium-elo decoder measured 864.8medium-masters decoder measured 872.7lora-e4 decoder measured 849.5lora-d4 decoder measured 913.6qwen3-pgn-qlora qwen measured 2506.2medium-v4-dpo-onpolicy-greedy decoder measured 858.4medium-v4-grpo-greedy decoder measured 843.4karvonen-8l karvonen failed 0.1 (ValueError: … holds no model_state to hash)seconds: 10106Dos 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.
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.
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.
uv run python labs/m6/parity_cost.py --onnx artifacts/onnx/medium-v4 --config configs/eval/ladder-nodes.yamlmedium-v4 · 20 games per rung · 8 rungs · 200000 nodes
fp32 parity 1.000 elo 1472 (1415-1527) 160 games 816 sfp16 parity 0.999 elo 1535 (1484-1600) 160 games 837 sint8 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.
uv run rukh publish cards --dry-runchorcat/rukh-tiny staged artifacts/publish/chorcat/rukh-tiny/README.mdchorcat/rukh-small staged artifacts/publish/chorcat/rukh-small/README.mdchorcat/rukh-medium staged artifacts/publish/chorcat/rukh-medium/README.md…chorcat/rukh-tokenizer staged data/publish/rukh-tokenizer/README.mdLa 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ó.
uv run rukh publish cardsuv run rukh publish collectionLa 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.
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, elmedium-v4cuantizado 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.