// M6 · lección 09
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.
La última lección del módulo y de la fase 1: los siete labs con sus comandos y sus salidas, los
cuatro scripts de labs/m6 enteros, una lista de comprobación que sirve para cualquier modelo y el
balance de la fase.
Labs
Siete laboratorios, en el orden en que se hicieron, y ninguno entrena nada. Los dos primeros miden el instrumento y reconstruyen la tabla; el tercero responde a la pregunta que M2 dejó abierta; los demás ponen la tabla donde la gente la va a leer: en cada card, en la colección y en la demo.
// Antes de empezarQué cuesta cada lab, y cuál puedes saltarte
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| Lab 1El suelo del instrumento, medido cuatro veces | cuesta máquina | ~12 min cada tirada (48 en total) + 35 la calibración, que solo usa CPU | artifacts/web/ladder-floor.json y los ocho peldaños | git checkout p6 -- 'artifacts/eval/ladder-*' y después ladder_floor_export.py --decision time |
| Lab 2La tabla entera con un comando | cuesta máquina | ~3 h las doce etapas (en el hito Karvonen falló y se repitió aparte; en tu tirada no falla) | results.json, benchmarks.md, nightly.json | con --dry-run imprime el plan sin medir nada |
| Lab 3Lo que cuesta el int8, en Elo | cuesta máquina | la exportación de medium-v4 y 41 min: 14 por precisión, ORT en CPU | artifacts/web/parity-cost.json | git checkout p6 -- artifacts/eval/parity-cost artifacts/web/parity-cost.json |
| Lab 4Las cards y la colección, regeneradas desde la tabla | imprescindible | ~3 min, y con --dry-run no toca el Hub | cada README del Hub, regenerado desde la tabla | no hay |
| Lab 5La arena: dos etapas, en tu navegador | solo observar | — | dos modelos del curso jugando en la demo | no hay |
| Lab 6Los puzles, en vivo, contra la fila de la tabla | imprescindible | segundos | artifacts/web/puzzles.json, los 150 de la demo | no hay |
| Lab 7El post del cierre | solo observar | — | el cierre escrito del proyecto | no hay |
- cuesta máquina
- Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.
- imprescindible
- Segundos o pocos minutos, y el lab siguiente da por hecho que lo corriste.
- solo observar
- No se ejecuta nada: se lee la salida y se responde a la pregunta.
Lab 1 · El suelo del instrumento, medido cuatro veces
// Viene delección 2 · La escalera calibrada: el rival por nodos y la identidad de unos pesos
La hipótesis de partida: la escalera no repite porque el rival juega por tiempo. Se mide el mismo
modelo con la misma semilla, dos veces por tiempo y dos con un presupuesto de nodos, y se mira cuánto
se separan las dos tiradas de cada régimen. elo_nodes: 200000 es lo que 0,1 s compra en esta
máquina: el mismo rival en fuerza, otro en comportamiento (lección 2).
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) |
Sin GPU, o sin la hora y media de máquina, la ruta corta trae las cuatro evaluaciones del hito y
regenera solo la figura. ladder_check.py, en cambio, juega motor contra motor y no necesita GPU,
solo unos 35 minutos de CPU:
git checkout p6 -- artifacts/eval/ladder-time-a artifacts/eval/ladder-time-b artifacts/eval/ladder-nodes-a artifacts/eval/ladder-nodes-buv run python labs/m6/ladder_floor_export.py --decision timeDeja el mismo artifacts/web/ladder-floor.json que la ruta larga, con las cifras de la tabla de
arriba.
Cero Elo de separación por tiempo y 17 por nodos, las dos muy por debajo del semiancho del intervalo
(±55). El presupuesto de nodos hace determinista la búsqueda, no al rival, porque UCI_LimitStrength
aleatoriza a propósito; y con la máquina tranquila el reloj ya repite al nivel del ruido de muestreo.
El rival sigue por tiempo, con una condición en el runbook (nada más en la máquina mientras corre una
escalera), y elo_nodes se queda para quien mida en una máquina compartida y para el lab 3, donde el
propio jugador carga la CPU. La lectura completa está en la lección 8.
Falta 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 del peldaño.
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 se sostiene.
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 % solo dicen que está lejos. Es la
limitación de calibrar con un solo ancla, y el motivo de que la escalera use ocho peldaños y no dos.
// Ejercicio 01La escalera bajo carga: ¿se mueve el tiempo o los nodos?
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 (en M3 se midieron +13 Elo con la CPU cargada). Por nodos,
la búsqueda del rival no depende de la carga y lo único que cambia es cuánto tarda la tirada; el
rival sigue aleatorizando por UCI_LimitStrength, pero igual con carga que sin ella. 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
// Viene delección 3 · El baseline público: el nanoGPT de Karvonen en nuestra escalera y lección 4 · La tabla cada noche: doce etapas, un comando, una fecha
La tabla única exige todas las etapas, la misma suite y 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 los dos baselines, el Qwen3 general que afinó M4 y el nanoGPT de Karvonen de la lección 3, el único que no es del curso.
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, siete segundos el
encoder (no juega), cuarenta y dos Qwen, y un fallo: el checkpoint de Karvonen guarda sus tensores
bajo model y no bajo model_state, y el hash de pesos de la lección 2 no lo sabía. Con el hash
arreglado, rukh eval nightly --only karvonen-8l lo midió en quince minutos.
Esa salida es la del hito, no la que vas a ver tú. El weights_sha que tecleaste en la lección 2 es
el arreglado, que ya lee payload.get("model"), así que en tu tirada karvonen-8l sale measured
a la primera y el --only no hace falta; cuenta con unos quince minutos más en el total. Las dos filas que no
están 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 ejecución se retiran con rukh eval drop,
y la tabla que queda es la de la lección 8.
// Ejercicio 02Qué invalida la caché del nightly
Añade force_header: true a configs/eval/greedy.yaml —la cabecera de los puzles, que no afecta
a las partidas— y vuelve a lanzar el nightly con --only tiny. ¿Cuánto tarda? ¿Y si en vez de eso
cambias elo_games? Y una tercera: ¿qué pasa si escribes puzzles_use_header: true, que es el
nombre que esa misma opción tiene dentro de la clave de caché?
// SoluciónVer la solución
Con la clave de caché partida por familia, 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
no invalida nada, porque el número de partidas no entra en ninguna clave (el arreglo que explica
la lección 2). Es una fecha de caducidad por producto y no
por nevera: que caduque la leche no obliga a tirar el arroz.
La tercera es la trampa: puzzles_use_header no es una clave de configuración, sino el nombre con
el que la opción viaja dentro del hash de la caché, conservado para no invalidar los intentos ya
pagados. Escribirla en el YAML da un error de validación de pydantic antes de tocar la GPU,
porque EvalConfig hereda extra="forbid" de M0.
Lab 3 · Lo que cuesta el int8, en Elo
// Viene delección 6 · int8 y el jugador ONNX: un grafo exportado en la escalera
M2 midió la paridad y dejó la pregunta abierta: un int8 que elige otra jugada una vez de cada veinte,
¿cuánto peor juega? OnnxDecoder pone el grafo 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 el rival va por nodos: por tiempo, un jugador más lento
cambiaría también al motor.
El lab lee una exportación de medium-v4 que ninguna lección anterior hizo (M2 exportó tiny y
small, y rukh pull baja los pesos, no los ONNX). Primero se exporta, con el mismo comando de M2;
--check-parity escribe el parity.json que el script lee, con la paridad de cada precisión sobre
1 000 posiciones de validación:
uv run rukh export --ckpt checkpoints/medium-v4/best.pt --out artifacts/onnx/medium-v4 --fp16 --int8 --check-parityuv run python labs/m6/parity_cost.py --onnx artifacts/onnx/medium-v4 --config configs/eval/ladder-nodes.yamlLa exportación deja model.onnx, model-fp16.onnx, model-int8.onnx y parity.json en
artifacts/onnx/medium-v4/. Sin GPU, o sin 41 minutos de máquina, trae las tres medidas del hito y
la figura que salió de ellas en vez de jugarlas:
git checkout p6 -- artifacts/eval/parity-cost artifacts/web/parity-cost.jsonartifacts/eval/parity-cost/ tiene un JSON por precisión, con el EloResult entero, y
artifacts/web/parity-cost.json es el resumen que dibuja la figura de la lección 8.
medium-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 sEl 4,9 % de jugadas distintas del int8 cuesta menos de lo que la escalera puede ver con 160 partidas: su intervalo y el del fp16 casi se superponen. El fp32, la misma red que el checkpoint, sale 60 puntos por debajo del fp16 sin ningún mecanismo que lo explique: el suelo del instrumento otra vez. Ver una diferencia tan pequeña pediría un enfrentamiento directo de miles de partidas, que es el segundo ejercicio de este lab.
// Ejercicio 03Escribe contra qué juega quien entra desde el móvil
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 04Diseña el enfrentamiento fp16 contra int8
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 lo que
la escalera ya había hecho.
Lab 4 · Las cards y la colección, regeneradas desde la tabla
// Viene delección 5 · El Hub y las cards: el catálogo, la vuelta y la afirmación pública
Cuando la tabla cambia, todas las cards quedan desactualizadas, y republicar 460 MB de pesos para
cambiar un párrafo es la herramienta equivocada. rukh publish cards prepara cada repositorio como
su propio publicador, en seco, y sube solo el README.md, que ahora lleva una sección «In the
course» con la lección, la fila, la demo y el rukh pull de cada modelo.
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ó, rukh-tiny falló por la guarda del hash de fichero: es el caso que
resolvió el hash de pesos (lecciones 2 y 5).
Los dos comandos de abajo publican en chorcat, y no hay opción para cambiarlo: el catálogo fija el
owner (lección 5). Con tu cuenta, quédate en el --dry-run de arriba y sube la card que quieras
a un repositorio tuyo con uv run hf upload <tu-usuario>/<repo> <ruta-de-la-card> README.md.
uv run rukh publish cardsuv run rukh publish collectionLa colección se monta desde el mismo catálogo, así que lo que se descarga, lo que se lee y lo que está en la tabla son la misma lista de veintiún repositorios. Lanzarla dos veces no cambia nada.
// Ejercicio 05Audita una card contra la tabla
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
registro de la ejecución, es un bug de la plantilla: todo lo que la card afirma tiene que poder
contradecirse con lo que hay dentro.
Lab 5 · La arena: dos etapas, en tu navegador
// Viene delección 7 · La demo final: arena, puzles y tu propio modelo desde el disco
M5 enseñó que para medir una diferencia se mide la diferencia, y la demo lo hace ahora en el
navegador con la aritmética de rukh eval match (lección 7). Al lado del marcador imprime cuántas
partidas harían falta para separar una diferencia de ese tamaño del cero, para que nadie lea diez
partidas como un veredicto.
// Ejercicio 06Veinte partidas en la arena, contra las 800 de M5
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, y la demo lo dice: para una diferencia de 50 pide unas 185 partidas, y para una de 57 como la de M5, unas 142. Es la aritmética de M5 delante de ti: con 800 partidas por arista, el +57 aún 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 que en el número.
Lab 6 · Los puzles, en vivo, contra la fila de la tabla
// Viene delección 7 · La demo final: arena, puzles y tu propio modelo desde el disco
Los 150 puzles de la demo salen del mismo split de test que la suite, se preguntan con el prefijo
real de la partida y los Elo, y se cuentan con el mismo criterio: la línea entera.
// Ejercicio 07Los 150 puzles de la demo contra la fila de la tabla
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: 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 es una exportación, con paridad del 99,80 % en small (el 99,9 % es el de medium-v4). Que salgan a pocos puntos
comprueba que la demo y el harness hacen la misma pregunta; que salieran iguales sería
sospechoso.
Lab 7 · El post del cierre
// Viene delección 8 · Evaluar: lo que salió
El cierre de la fase es un texto: el guion del vídeo, con las tres cifras que importan y ninguna que
no esté en docs/benchmarks.md o en el registro de decisiones. Se escribe después de la tabla, nunca
antes.
// Ejercicio 08Tres recomendaciones, cada una con su intervalo
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; su ventaja sobre la base es el +57 (36-79) del enfrentamiento directo de M5, no la resta de dos filas. - 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 lección 1.
Los cuatro scripts de labs/m6
Los siete labs se apoyan en cuatro scripts: dos miden el instrumento, uno responde la pregunta de M2 y uno exporta los puzles de la demo. Van enteros porque producen las cifras de la lección 8. Sus docstrings conservan la numeración de labs de antes de reordenarlos («Lab 2 of M6» es el lab 3 de esta página, «Lab 5 of M6» el 6); la buena es la de aquí.
ladder_check.py: calibrar la escalera contra sí misma
"""Measure the ladder against itself: every rung plays the anchor, engine against engine.
Lab 1 of M6, and the method of D-070 turned into a script. The four ``Skill Level`` rungs of theElo ladder were once labelled 800/950/1100/1250 by assumption and measured 1381/1467/1589/1678;the labels of a ladder are hypotheses until the rungs have played each other. This plays eachrung against ``uci-1320`` (the anchor, the floor of ``UCI_Elo``) with colours alternated and aseeded random opening per pair, and prints the rating that explains the score, with itsinterval, next to the label the ladder carries.
It takes the same limit the harness takes -- a clock or a node budget -- so the ladder can becalibrated for the regime it is going to be used in:
uv run python labs/m6/ladder_check.py --games 40 # 0.1 s per move, as P2-P5 uv run python labs/m6/ladder_check.py --games 40 --nodes 200000 # the node budget of M6
The control is built in: ``uci-1500`` against ``uci-1320`` should come out near +180. If itdoes not, the procedure is biased and no other row of the table means anything."""La última frase lleva el control dentro: uci-1500 contra uci-1320 tiene que salir cerca de +180,
lo que los autores de Stockfish dicen de esos dos ajustes. Si no sale, el procedimiento está sesgado
y ninguna otra fila significa nada. El patrón vale para cualquier medición: mete una medida cuyo
valor ya conoces y mírala antes que las que no conoces.
from __future__ import annotations
import argparseimport mathimport sysimport time
import chessimport numpy as np
from rukh.eval.elo import DEFAULT_RUNGS, EloRungfrom rukh.eval.match import opening_bookfrom rukh.infer.game import StockfishOpponent
if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ANCHOR = DEFAULT_RUNGS[0]MAX_PLIES = 300sys.stdout.reconfigure(encoding="utf-8") es por Windows: la consola no es UTF-8, la salida lleva
un ·, y sin esa línea una UnicodeEncodeError mataría treinta y cinco minutos de tirada al
imprimir el resultado. ANCHOR = DEFAULT_RUNGS[0] toma el ancla del propio harness, así que si la
escalera cambiara de peldaño más bajo el script calibraría contra el nuevo.
def elo_of(score: float) -> float: """The rating difference that explains a score, clipped away from 0 and 1.""" p = min(max(score, 1e-3), 1 - 1e-3) return 400.0 * math.log10(p / (1.0 - p))
def play(white: StockfishOpponent, black: StockfishOpponent, opening: list[str]) -> float: """One game from ``opening``; the score from White's point of view.""" board = chess.Board() for uci in opening: board.push_uci(uci) while not board.is_game_over(claim_draw=True) and board.ply() < MAX_PLIES: mover = white if board.turn == chess.WHITE else black board.push(mover.choose(board)) if board.is_game_over(claim_draw=True): result = board.result(claim_draw=True) return {"1-0": 1.0, "0-1": 0.0}.get(result, 0.5) return 0.5 # out of plies: a draw, the same adjudication the harness would not accept but fairelo_of es la misma fórmula con el mismo recorte que la arena de la lección 7 y el harness. Tres
implementaciones de la misma cuenta, en un script, un módulo del paquete y un fichero de TypeScript
que no se pueden importar entre sí; las mantiene iguales que el recorte, 1e-3, tiene el mismo valor
en las tres.
Una partida que se queda sin plies cuenta aquí como tablas, «una adjudicación que el harness no aceptaría pero es justa». El harness adjudica por material, porque una partida cortada con ventaja decisiva no son tablas; aquí los dos jugadores son el mismo motor a fuerzas parecidas, y el sesgo se reparte por igual.
def measure( rung: EloRung, games: int, seed: int, move_time: float, nodes: int | None) -> tuple[float, float, float, float]: """Score of ``rung`` against the anchor, its Elo over the anchor and a bootstrap interval.""" book = opening_book(games // 2, plies=6, seed=seed) scores: list[float] = [] with ( StockfishOpponent( elo=rung.uci_elo or 1320, skill=rung.skill, move_time=move_time, nodes=nodes ) as candidate, StockfishOpponent(elo=ANCHOR.uci_elo or 1320, move_time=move_time, nodes=nodes) as anchor, ): for opening in book: scores.append(play(candidate, anchor, opening)) # rung as White scores.append(1.0 - play(anchor, candidate, opening)) # rung as Black draws = np.asarray(scores) rng = np.random.default_rng(seed) boots = [ elo_of(float(rng.choice(draws, size=len(draws), replace=True).mean())) for _ in range(500) ] lo, hi = np.percentile(boots, [2.5, 97.5]) return float(draws.mean()), elo_of(float(draws.mean())), float(lo), float(hi)opening_book(games // 2, ...) y dos partidas por apertura, una con cada color: el emparejamiento
espejado de la arena y de rukh eval match, que con aperturas aleatorias es la única forma de que la
ventaja de las blancas no se cuele en la calibración. 1.0 - play(anchor, candidate, opening)
invierte el resultado en vez de inventar otra convención, y evita el error de signo más probable del
fichero.
El with abre dos procesos de Stockfish a la vez y los cierra pase lo que pase. Dos motores
compitiendo por la CPU es el escenario en que el rival por reloj no repite, y por eso el script
acepta --nodes. El bootstrap reutiliza la semilla de las aperturas: son dos usos independientes y
ahorra un parámetro.
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--games", type=int, default=40, help="Games per rung (even).") parser.add_argument("--seed", type=int, default=7) parser.add_argument("--move-time", type=float, default=0.1) parser.add_argument("--nodes", type=int, default=None, help="Node budget instead of the clock.") args = parser.parse_args() if args.games % 2: raise SystemExit("--games must be even: every opening is played with both colours") regime = f"{args.nodes} nodes" if args.nodes else f"{args.move_time:g} s per move" print(f"anchor {ANCHOR.name} ({ANCHOR.elo}) · {args.games} games per rung · {regime}\n") print(f"{'rung':<10}{'label':>7}{'score':>8}{'measured':>10}{'IC 95 %':>16}{'delta':>8}") started = time.perf_counter() for rung in DEFAULT_RUNGS[1:]: score, over, lo, hi = measure(rung, args.games, args.seed, args.move_time, args.nodes) measured = ANCHOR.elo + over print( f"{rung.name:<10}{rung.elo:>7}{score:>8.3f}{measured:>10.0f}" f"{ANCHOR.elo + lo:>8.0f}-{ANCHOR.elo + hi:<7.0f}{measured - rung.elo:>+8.0f}" ) print(f"\n{time.perf_counter() - started:.0f} s") print("control: uci-1500 should read about +180 over the anchor; if it does not, stop here.")
if __name__ == "__main__": main()Un número impar de partidas se rechaza antes de jugar, con el motivo en el mensaje («cada apertura se
juega con los dos colores»). La cabecera imprime el régimen (0.1 s per move o 200000 nodes),
porque una calibración sin él acabará comparada con otra de otro régimen. Y la última línea repite el
control, donde queda el cursor. La salida de la tirada está en el lab 1.
ladder_floor_export.py: las cuatro tiradas, en una figura
"""The floor of the ladder, as a figure: four runs of the same model, two on a clock, two on nodes.
D-107 found that the same configuration with the same seed gave 1498 and 1558, because theopponent plays on a clock and a clock is not the same twice. M6 measured it on purpose: the samecheckpoint, the same rungs, games and seed, twice with ``elo_move_time`` and twice with``elo_nodes``. This collects the four ``results.json`` and writes the course figure's``artifacts/web/ladder-floor.json``, with the spread between the two runs of each regime and theregime that was chosen (D-135):
uv run python labs/m6/ladder_floor_export.py --decision nodes"""labs/m6/ladder_floor_export.py
from __future__ import annotations
import argparseimport jsonfrom datetime import UTC, datetime
from rukh.paths import resolve
RUNS = { "time-a": ("ladder-time-a", "time"), "time-b": ("ladder-time-b", "time"), "nodes-a": ("ladder-nodes-a", "nodes"), "nodes-b": ("ladder-nodes-b", "nodes"),}labs/m6/ladder_floor_export.py
El diccionario RUNS es el contrato con los cuatro nombres de etapa que el lab 1 escribió con
--stage. Es un acoplamiento por cadena, y la protección es que un fichero que falta se imprime y se
salta.
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--decision", choices=["time", "nodes"], required=True) parser.add_argument("--out", default="artifacts/web/ladder-floor.json") args = parser.parse_args()
runs: list[dict[str, object]] = [] model = None games = None for name, (stage, limit) in RUNS.items(): path = resolve(f"artifacts/eval/{stage}/results.json") if not path.is_file(): print(f"{name}: {path} missing, skipped") continue result = json.loads(path.read_text(encoding="utf-8")) elo = result["elo"] model = model or result.get("checkpoint") games = games or elo.get("games") runs.append( { "name": name, "limit": limit, "elo": round(elo["elo"]), "lo": None if elo.get("ci_low") is None else round(elo["ci_low"]), "hi": None if elo.get("ci_high") is None else round(elo["ci_high"]), "date": result.get("date"), } ) print(f"{name:<8} {limit:<6} {runs[-1]['elo']} ({runs[-1]['lo']}-{runs[-1]['hi']})")labs/m6/ladder_floor_export.py
model y games se toman de la primera tirada que se encuentre, sin comprobarlos contra las demás.
Si alguien mezclara resultados de dos modelos, la figura diría el nombre del primero; lo que lo
impide es el procedimiento (las cuatro tiradas salen de una sesión), no el código. Y un resultado
separado no tiene intervalo, así que round(None) explotaría sin el ternario.
spread = {} for limit in ("time", "nodes"): pair = [r["elo"] for r in runs if r["limit"] == limit] if len(pair) == 2: spread[limit] = abs(int(pair[0]) - int(pair[1])) print(f"spread {limit}: {spread[limit]} Elo")
out = resolve(args.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text( json.dumps( { "meta": { "generated": datetime.now(UTC).isoformat(timespec="seconds"), "model": model, "games": games, }, "runs": runs, "spread": spread, "decision": args.decision, }, indent=2, ) + "\n", encoding="utf-8", ) print(f"wrote {out}")
if __name__ == "__main__": main()labs/m6/ladder_floor_export.py
spread solo se calcula con dos tiradas del mismo régimen: con una, un cero publicaría «repite
perfectamente» sobre una medición que no se hizo. El campo decision es lo que la figura imprime al
lado de los cuatro números, porque además de la medición hay que publicar qué se decidió con ella:
time, el rival se queda por reloj. El ejemplo del docstring escribe --decision nodes y cita otra
decisión del registro: son dos erratas del tag p6, y el comando que se ejecutó es el del lab 1.
parity_cost.py: lo que cuesta cada precisión, jugando
"""What the export costs in Elo: fp32, fp16 and int8 of the same model on the same ladder.
Lab 2 of M6. M2 measured **parity** -- in how many positions each export picks the checkpoint'smove (fp16 99.9 %, int8 95.1 % for ``medium-v4``) -- and left the question that matters open: amodel that plays another move once in twenty games is another player, so how much weaker is it?Parity cannot answer that; only playing can. This puts each ONNX file on the very ladder everypublished Elo was measured on, through the same ``DecoderPlayer`` (same masking, sampling andrescue), with the same rungs, games and seed, and writes one interval per precision next to theparity that was already known:
uv run python labs/m6/parity_cost.py --onnx artifacts/onnx/medium-v4 \ --config configs/eval/greedy.yaml
ONNX Runtime plays on the CPU here, so the opponent's limit should be a node budget (the config's``elo_nodes``): on a clock, a slower player would make the engine's side of the game different,and the comparison would be between two Stockfishes, not between three exports.
Writes ``artifacts/eval/parity-cost/<name>.json`` (one ``EloResult`` per precision) and``artifacts/web/parity-cost.json`` for the course figure."""El segundo párrafo del docstring deja escrita la razón del rival por nodos: con ONNX Runtime en CPU y
el rival por reloj, un jugador más lento haría jugar distinto al motor, y la comparación sería entre
dos Stockfish y no entre tres exportaciones. Es la única medición del proyecto donde los nodos eran la
opción correcta, y por eso el lab usa ladder-nodes.yaml y no el greedy.yaml del ejemplo.
Un aviso sobre el primer párrafo: las cifras son buenas, la atribución no. M2 midió la paridad de
small (99,80 % en fp16, 95,40 % en int8); las de medium-v4 (99,9 % y 95,1 %) las mide la
exportación del lab 3, con el --check-parity que escribe el parity.json que el script lee.
from __future__ import annotations
import argparseimport jsonimport sysimport timefrom datetime import UTC, datetime
from rukh.config import load_yamlfrom rukh.eval.elo import estimate, play_rungsfrom rukh.eval.suite import EvalConfigfrom rukh.infer import DecoderPlayerfrom rukh.infer.onnx_player import OnnxDecoderfrom rukh.paths import resolvefrom rukh.tokenize.uci_vocab import UciTokenizer
PRECISIONS = {"fp32": "model.onnx", "fp16": "model-fp16.onnx", "int8": "model-int8.onnx"}PRECISIONS mapea el nombre a los tres ficheros que escribe rukh export --fp16 --int8; es un
diccionario porque la salida se ordena por él y --only fp16 int8 valida contra sus claves.
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--onnx", default="artifacts/onnx/medium-v4", help="Folder of the export.") parser.add_argument("--config", default="configs/eval/greedy.yaml") parser.add_argument("--games", type=int, default=None, help="Games per rung (config's).") parser.add_argument("--only", nargs="*", default=list(PRECISIONS), choices=list(PRECISIONS)) parser.add_argument("--out", default="artifacts/eval/parity-cost") parser.add_argument("--web", default="artifacts/web/parity-cost.json") args = parser.parse_args()
cfg = load_yaml(resolve(args.config), EvalConfig) games = args.games or cfg.elo_games folder = resolve(args.onnx) parity = json.loads((folder / "parity.json").read_text(encoding="utf-8")) out = resolve(args.out) out.mkdir(parents=True, exist_ok=True) tok = UciTokenizer() regime = f"{cfg.elo_nodes} nodes" if cfg.elo_nodes else f"{cfg.elo_move_time} s" print(f"{folder.name} · {games} games per rung · {len(cfg.elo_rungs)} rungs · {regime}\n")parity.json se lee de la exportación de M2, no se recalcula: este lab pone la paridad al lado del
Elo, que es el gesto que la lección quiere enseñar. games = args.games or cfg.elo_games permite
recortar para una prueba, y el régimen va en la cabecera, como en el script anterior.
variants: list[dict[str, object]] = [] for name in args.only: path = folder / PRECISIONS[name] if not path.is_file(): print(f"{name:<6} missing ({path.name}), skipped", file=sys.stderr) continue agreement = parity["precisions"].get(name, {}).get("agreement") started = time.perf_counter() player = DecoderPlayer(OnnxDecoder(path), tok, cfg.sampling()) # type: ignore[arg-type] records = play_rungs( None, tok, cfg.elo_rungs, games, cfg.sampling(), move_time=cfg.elo_move_time, max_plies=cfg.elo_max_plies, header_elo=cfg.header_elo, player=player, nodes=cfg.elo_nodes, ) result = estimate(records, samples=cfg.bootstrap, seed=cfg.seed) elapsed = time.perf_counter() - started (out / f"{name}.json").write_text( json.dumps( { "precision": name, "file": path.as_posix(), "parity": agreement, "games": games, "regime": regime, "seed": cfg.seed, "seconds": round(elapsed), "elo": result.model_dump(mode="json"), }, indent=2, ) + "\n", encoding="utf-8", ) variants.append( { "name": name, "parity": agreement, "elo": round(result.elo), "lo": None if result.ci_low is None else round(result.ci_low), "hi": None if result.ci_high is None else round(result.ci_high), "games": result.games, } ) low = "?" if result.ci_low is None else f"{result.ci_low:.0f}" high = "?" if result.ci_high is None else f"{result.ci_high:.0f}" print( f"{name:<6} parity {agreement if agreement is None else f'{agreement:.3f}':>6} " f"elo {result.elo:.0f} ({low}-{high}) {result.games} games {elapsed:.0f} s" )El bucle no reimplementa nada: el grafo entra por el jugador de la lección 6
(DecoderPlayer(OnnxDecoder(path), ...)), la escalera es la de la lección 2 y el estimate es el de
M2. Poco código para responder una pregunta abierta desde hacía cuatro módulos, porque todas las
piezas existían.
Un fichero que falta se salta con un aviso en stderr, así que quien solo exportó fp16 e int8 obtiene
dos filas. Y los JSON por precisión se escriben dentro del bucle: cada uno cuesta catorce minutos de
motor, y perderlos porque falle el tercero sería el error que el nightly evita con su except.
web = resolve(args.web) web.parent.mkdir(parents=True, exist_ok=True) web.write_text( json.dumps( { "meta": { "generated": datetime.now(UTC).isoformat(timespec="seconds"), "games": games, "regime": regime, }, "model": folder.name, "variants": variants, }, indent=2, ) + "\n", encoding="utf-8", ) print(f"\nwrote {web}")
if __name__ == "__main__": main()El JSON de la figura lleva además un meta con la fecha, las partidas y el régimen: la figura no
puede dibujar tres barras sin decir con qué presupuesto se jugaron. La salida está en el lab 3.
puzzles_export.py: los 150 puzles de la demo
"""Export a small, seeded set of test puzzles for the demo's live puzzle mode.
Lab 5 of M6. The demo cannot read a parquet and should not download 22 MB to show fifty puzzles,so this writes `artifacts/web/puzzles.json`: 50 puzzles per difficulty band drawn from the`test` split of `chorcat/rukh-puzzles-split` with the same loader the harness uses(`rukh.eval.puzzles.load_puzzles`), so what the browser attempts is exactly what the tablemeasured -- the real game prefix, the players' own ratings in the header, and the whole line asthe criterion (D-053). The file is copied into `rukh-web/public/puzzles.json` by hand (it isversioned there: the demo must not depend on this repository being around).
uv run python labs/m6/puzzles_export.py # 50 per band, seed 6 uv run python labs/m6/puzzles_export.py --per-band 20 --seed 1"""Dos frases del docstring son decisiones. La demo no puede leer un parquet ni debería descargar 22 MB
para enseñar cincuenta puzles, así que se exporta un JSON de 150. Y el fichero se copia a mano a
rukh-web/public/puzzles.json y se versiona allí: dos repositorios que se despliegan por separado no
comparten artefactos generados.
from __future__ import annotations
import argparseimport jsonimport sysfrom datetime import UTC, datetime
from rukh.eval.puzzles import load_puzzlesfrom rukh.paths import resolve
if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")
SCHEMA = "rukh-puzzles/1"def main() -> None: parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--puzzles", default="data/puzzles/puzzles.parquet") parser.add_argument("--per-band", type=int, default=50) parser.add_argument("--seed", type=int, default=6) parser.add_argument("--split", default="test") parser.add_argument("--out", default="artifacts/web/puzzles.json") args = parser.parse_args()
items = load_puzzles(resolve(args.puzzles), args.per_band, seed=args.seed, split=args.split) without_prefix = sum(1 for item in items if not item.prefix) if without_prefix: raise SystemExit( f"{without_prefix} puzzles carry no game prefix; the demo prompts with the real game " "(D-053), so the parquet must be the `with_games` one" )load_puzzles es el cargador del harness con los mismos argumentos (split, semilla, número por
tramo), así que los 150 puzles son una muestra del mismo conjunto que mide la tabla.
La guarda que sigue es la decisión central: si algún puzle llega sin prefijo de partida, el script se niega a escribir. Un puzle sin prefijo se preguntaría «a ciegas» (lección 3) y la tasa sería un suelo. Negarse es más útil que avisar, porque el fichero acaba en otro repositorio donde nadie leería el aviso.
bands: dict[str, list[dict[str, object]]] = {} for item in items: bands.setdefault(item.band, []).append( { "id": item.puzzle_id, "fen": item.fen, "moves": item.moves, "rating": item.rating, "prefix": item.prefix, "whiteElo": item.white_elo, "blackElo": item.black_elo, } ) payload = { "schema": SCHEMA, "source": f"chorcat/rukh-puzzles-split · split {args.split}", "seed": args.seed, "perBand": args.per_band, "generated": datetime.now(UTC).isoformat(timespec="seconds"), "bands": bands, } out = resolve(args.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", "utf-8") print(f"{out} · {sum(len(v) for v in bands.values())} puzzles · {out.stat().st_size:,} bytes") for band, rows in sorted(bands.items()): ratings = [row["rating"] for row in rows] print(f" {band:<10} {len(rows):>3} puzzles · rating {min(ratings)}-{max(ratings)}")
if __name__ == "__main__": main()El payload lleva los campos que src/lib/puzzles.ts comprueba en la lección 7: los dos lados de la
frontera se escribieron con el mismo contrato delante. separators=(",", ":") quita espacios a un
fichero que se sirve en cada visita, y ensure_ascii=False deja los caracteres tal cual. Las dos
últimas líneas imprimen el tamaño y el rango de dificultad de cada tramo, para ver un tramo raro antes
de copiar el fichero.
La lista de comprobación, sin ajedrez
Todo lo anterior es de este proyecto. Esto es el mismo cierre escrito sin una palabra de ajedrez, para cuando midas y publiques otra cosa. Nueve puntos, y cada uno tapa un error que este módulo estuvo a punto de cometer.
Antes de medir:
- Mide el instrumento contra sí mismo. El mismo modelo contra el mismo modelo tiene que dar empate; la misma suite repetida tiene que repetir dentro de su ruido. Lo que se mueva ahí es suelo.
- Escribe el suelo antes de ver el resultado. Si dos tiradas idénticas se separan 60 puntos, una mejora de 40 no existe por mucho que salga.
Al medir:
- Una suite para todas las filas: misma configuración, mismo muestreo, mismo corte.
- Registra el fichero exacto y el muestreo: el checkpoint, la precisión, la temperatura y la semilla. Sin eso el número no se puede volver a sacar.
- Publica y compara intervalos. Dos que se solapan son una pregunta que aún no has pagado.
Antes de publicar:
- Mide lo que vas a servir. Lo que corre en producción está cuantizado, convertido o recompilado; mide ese fichero, no el que entrenaste.
- Genera la ficha desde la tabla. Una escrita a mano envejece en silencio.
- Escribe qué no puedes afirmar: el proxy que usaste, contra qué se midió y en qué se parece a lo que le importa a quien lo lea.
- Deja el comando que lo reproduce todo. Si el lector no puede sacar tu intervalo, has publicado una cifra, no un resultado.
Lo que deja el módulo
Si te quedas con tres ideas, que sean estas.
Mide el instrumento antes de fiarte de él. Cuatro escaleras del mismo modelo, una calibración y
una tabla vieja frente a una nueva dijeron lo mismo: con la máquina parada el reloj repite al nivel
del ruido de muestreo, los nodos no compran nada, y lo que se mueve es lo que el intervalo permite.
Sin esa medición, los +72 de dpo-onpolicy en un día habrían parecido 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 fila que no cumple eso 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 quien te lea reproduce el intervalo, no el número.
El balance de la fase 1
Cuando empezaste no había aprendiz: había un club por montar, con su tablero, su árbitro y su cuaderno de bitácora. Luego le enseñaste a escribir una jugada y le ordenaste la biblioteca. Leyó millones de partidas y aprendió a adivinar la siguiente, de oído. Aprendió a mirar una posición entera y decir quién iba mejor. Recibió clases particulares y, con unas notas adhesivas de 1,6 MB, cambió de repertorio sin cambiar de fuerza. Tuvo un entrenador que le enseñó a preferir, y tú aprendiste a vigilar que no jugara para agradar al juez. En este módulo salió a competir: un rating medido con las mismas reglas para todos, una ficha que dice sobre qué fichero se midió cada número y una mesa en la que cualquiera puede sentarse a jugarle.
Ahora juega con un número al lado que dice cuánto vale, y sabes cuánto fiarte de ese número. Ese recorrido (infraestructura, datos, preentrenamiento, comprensión, ajuste fino, alineamiento, evaluación y despliegue) es el de cualquier modelo de lenguaje, hecho entero con uno pequeño, en una sola GPU, dejando en cada paso un artefacto que se puede descargar y una cifra que se puede contradecir.
Los tres criterios de aceptación del módulo se cumplieron. La tabla única y reproducible con todas
las etapas y los baselines públicos: trece filas de decoder medidas el mismo día (once con un
comando y dos a mano), dos baselines (Karvonen y el Qwen3 afinado en M4) y el suelo del instrumento publicado con ella. Las model cards en inglés para cada
modelo de la colección: veintiuna, regeneradas desde la tabla, cada una con su lección, su fila, su
demo y su rukh pull. Y la demo final con selector de etapa, arena y puzles en vivo, comprobada en
Chrome con WebGPU.
Y estos no se cumplieron, de este módulo y de los anteriores:
- La paridad del 99,9 % que pedía la especificación de M2 no se alcanzó en
smallni con fp16 (99,80 %), y el int8 se quedó en 95,40 %;medium-v4, medido en el lab 3, sí la toca en fp16 (0,999) y deja el int8 en 95,1 %. M6 midió qué cuesta no alcanzarla: con 160 partidas, menos de lo que la escalera puede ver. - El 75 % de acierto del reward model de M5 tampoco: 72,91 % y 74,21 % en dos ejecuciones idénticas, y con 1,3 puntos entre ejecuciones iguales perseguirlo con semillas habría sido pescar.
- Maia-2 como segundo baseline condicionado por Elo quedó en el backlog: fija
torch 2.8frente al 2.11 del proyecto y descarga sus pesos de Google Drive. Cabe como proceso hijo en su propio entorno, y el diseño está escrito. - El bot de Lichess del plan original, que era opcional y necesita una cuenta de bot.
- La arena solo enfrenta etapas publicadas: puedes cargar tu
.onnx, jugar contra él y resolver los puzles, pero no enfrentarlo a un modelo del curso.
En la fase 2 el aprendiz se convierte en entrenador. El modelo pasa a ser una herramienta que algo más usa: el encoder de M3 como índice para buscar posiciones y pasajes de libros (A1), un agente que analiza tus partidas llamando al motor y al modelo (A2), y la disciplina de este módulo aplicada a algo cuya salida es texto (A3). De una fase a la otra se lleva la costumbre de medir el instrumento antes de fiarse de él, y de escribir lo que no se puede afirmar al lado de lo que sí.
La cheatsheet del módulo, con su pregunta y su respuesta corta, está justo debajo.
// el recorrido · lo conseguido
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(hecho)
- M2 El decoderPreentrenar un GPT(hecho)
- M3 El encoderEmbeddings y clasificar(hecho)
- M4 Fine-tuningSFT y LoRA(hecho)
- M5 AlineamientoPreferencias: DPO, GRPO(hecho)
- M6 Evaluar y publicarMedir y desplegar(hecho)
- FASE 2 El entrenadorAgentes, RAG, MCP
lo que tienes ahora
Todas las etapas medidas el mismo día con un comando, dos rivales externos, el coste real del int8, veintiuna model cards y la demo final con arena y puzles.
siguiente parada · El entrenador
En la fase 2 el aprendiz se convierte en entrenador: busca en libros, usa el motor como herramienta y comenta tus partidas.
// 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). 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 ejecuciones, ni que ese Elo se parezca al de Lichess: no se parece y no hay conversión honesta.
- 04¿Qué tres mediciones de reproducibilidad tiene el proyecto?
- La escalera: la misma configuración con la misma semilla dio 1498 y 1558, porque el rival juega por tiempo y aleatoriza. El reward model de M5: la misma configuración, semilla y reparto dieron 72,91 % y 74,21 %, por los kernels no deterministas de la GPU. Y M6: el mismo modelo dos veces por tiempo con la máquina parada dio 1538 y 1538, y dos veces por nodos 1541 y 1524; los nodos no compran reproducibilidad porque `UCI_LimitStrength` aleatoriza igual, y el rival se queda por tiempo. 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 (en M3 se midieron +13 Elo con la CPU cargada), 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 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?
- 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`: doce etapas (once filas de decoder, con los dos baselines, y el encoder), la misma suite, el mismo día; las dos filas de decoder que no están en el catálogo se miden a mano ese día. La clave de caché se partió por familia: 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 unas 185 partidas y una de 11, unas 3 800 (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.