rukh · lab

// M2 · lección 06

El Elo y la suite

Ajustar un Elo por Newton sin SciPy, ponerle un intervalo por bootstrap, detectar la separación que produciría un intervalo de anchura cero, y ensamblarlo todo en el comando que escribe una fila de la tabla única. Con la tirada real de small y la escalera torcida que la invalidó.

  • tiempo de trabajo290 min
  • además, ejecución sin supervisión+ 40 min de GPU y red
  • nivel avanzado
  • actualizado el23 de septiembre de 2026

Qué vas a construir

rukh eval: un comando que produce una fila de la tabla única del curso. Debajo hay cuatro ficheros (elo.py, suite.py, report.py y el __init__.py del paquete) y la parte estadística más delicada del proyecto: cómo se convierte «ganó 8 de 20 contra Stockfish a 1500» en un número con un intervalo de confianza que no miente.

Al final tendrás la fila de small y la lección más cara del módulo: un banco de pruebas es código sin tests mientras nadie lo mida, y el de este proyecto estuvo mal calibrado a la vista de todos.

Estadística justa

Desde aquí, cada número del curso lleva un margen al lado, y M3, M4, M5 y M6 comparan márgenes para decidir. Son siete ideas, con cuentas que se hacen a mano; si en un módulo posterior una frase habla de σ, de un bootstrap o de 3,84, remite aquí.

Una moneda: desviación y error típico

Una moneda da 1 (cara) o 0 (cruz), con media 0,5. Cuánto se aparta cada tirada de esa media es su desviación típica, σ = √(p · (1 − p)), que para una moneda justa vale 0,5. La media de n tiradas se mueve mucho menos que una tirada suelta: su error típicoError típicoCuánto se movería una media si se repitiera la medida: la desviación de una sola observación dividida por la raíz del número de observaciones. Una moneda tiene una desviación de 0,5 por tirada, así que la proporción de caras en 100 tiradas tiene un error típico de 0,05 y un intervalo del 95 % de ±0,098 (1,96 errores típicos). En Rukh es lo que convierte «ganó el 60 % de 20 partidas» en un número con su margen, y la razón de que la precisión mejore con la raíz del tamaño: cuatro veces más partidas, la mitad de error. es σ / √n. Con 100 tiradas, 0,5 / 10 = 0,05. Una partida es una moneda trucada que puede caer 1, ½ o 0, y la puntuación media contra un rival es la media de esas tiradas.

El intervalo del 95 %

Un intervalo de confianzaIntervalo de confianzaRango dentro del que cabría la medida si se repitiera; Rukh publica el del 95 % por bootstrap (500 remuestreos) junto a cada Elo. Dos estimaciones solo se pueden declarar distintas si sus intervalos no se solapan: que los puntos estén ordenados («monótono») es barato y sale por azar; que los intervalos se separen es lo que sobrevive al ruido. Cuando se solapan, la aritmética n > 3,84 / (Δp)² dice si faltan partidas o si no hay diferencia, y el suelo de reproducibilidad de la escalera pone un límite que más partidas no bajan: M4 lo estimó en unos ±40 Elo de una sigma con dos tiradas, y M6 lo revisó con cuatro (con la máquina parada, el reloj repite al nivel del ruido de muestreo, D-137). del 95 % es la media ±1,96 errores típicos: para la moneda de 100 tiradas, 0,5 ± 0,098, de 0,40 a 0,60. Leído al revés, un 58 % de caras en 100 tiradas no distingue una moneda justa de una trucada. Y como el error va con la raíz, cuatro veces más tiradas solo reducen el margen a la mitad: 400 tiradas dan ±0,049.

Bootstrap a mano, con cinco partidas

La fórmula de arriba supone que la media se reparte como una campana. El bootstrapBootstrapRemuestrear los datos con reemplazo muchas veces y recalcular el estadístico en cada remuestreo para ver cuánto se mueve. El intervalo del Elo de Rukh sale así: 500 remuestreos de las 160 partidas (bootstrap: 500 en la suite), un ajuste por remuestreo, y los percentiles 2,5 y 97,5. Cubre el ruido del muestreo de partidas y nada más: no cubre que el rival cambie entre ejecuciones ni que los peldaños estén mal colocados. no supone nada: mete las partidas jugadas en un saco, saca tantas como había devolviendo cada una (así alguna sale dos veces y otra ninguna), calcula la media, y repite miles de veces. Cuánto bailan esas medias es cuánto bailaría la tuya si repitieras la tanda:

import numpy as np
results = np.array([1, 0, 1, 0.5, 1]) # cinco partidas: 3,5 de 5
rng = np.random.default_rng(0)
resamples = results[rng.integers(0, 5, size=(10_000, 5))]
means = resamples.mean(axis=1)
print(resamples[:3])
print(means[:3])
print(np.percentile(means, [2.5, 97.5]))
[[1. 0.5 1. 0. 0. ]
[1. 1. 1. 1. 1. ]
[0.5 1. 1. 0.5 1. ]]
[0.5 1. 0.8]
[0.3 1. ]

Tres remuestreos de cinco, con medias 0,5, 1,0 y 0,8 frente al 0,7 real; el intervalo son los percentiles 2,5 y 97,5 de las diez mil medias, de 0,3 a 1,0. Con cinco partidas el intervalo cubre casi todo lo posible, y es lo honesto. El harness hace esto mismo, pero reajustando el Elo en cada remuestreo en vez de hacer una media.

La curva logística y qué es «ajustar»

La fórmula del Elo, P = 1 / (1 + 10^((rival − modelo) / 400)), es una curva en S que convierte una diferencia de puntos en una probabilidad de puntuar. Ajustarla es elegir el Elo del modelo que hace más probables los resultados que ya pasaron: se calcula la log-verosimilitudLog-verosimilitudCuánto se creen unos parámetros lo que ya pasó: el logaritmo de la probabilidad que el modelo le daba a cada resultado observado, sumado sobre todos ellos. Siempre es negativa, y cuanto más cerca de cero, mejor explica los datos. En el Elo de Rukh es Σ s · log P + (1 − s) · log(1 − P) sobre las partidas, y el Elo publicado es el que la hace máxima; entrenar el decoder es lo mismo con otro signo, porque la entropía cruzada es la log-verosimilitud media cambiada de signo., la suma de s · log P + (1 − s) · log(1 − P) sobre las partidas, y se busca el Elo que la hace máxima. Eso es una regresión logísticaRegresión logísticaAjustar una curva en forma de S que convierte una diferencia en una probabilidad —en el Elo, 1 / (1 + 10^((rival − modelo)/400))— eligiendo el parámetro que hace más probables los resultados observados. El Elo de Rukh es una regresión logística con un único parámetro, el propio Elo, resuelta por Newton en dos o tres pasos; Bradley-Terry, en M5, es la misma curva con σ y puntuaciones aprendidas. con un solo parámetro.

Con tres partidas —gana a un rival de 1500, entabla con otro de 1500 y pierde con uno de 1800— la log-verosimilitud vale −1,550 si el modelo fuera de 1500, −1,455 si fuera de 1600 y −1,571 si fuera de 1700. El máximo está cerca de 1600, y probar Elos de cincuenta en cincuenta sería lento y poco preciso.

Newton, un paso a mano

Newton llega al máximo en dos o tres pasos usando la pendiente y la curvatura de la log-verosimilitud. Con c = ln(10) / 400 = 0,005756, cada paso suma Σ(s − p) / (c · Σ p(1 − p)). Desde 1500, las probabilidades de puntuar son 0,5, 0,5 y 0,151; Σ(s − p) = 0,5 + 0 − 0,151 = 0,349 y Σ p(1 − p) = 0,25 + 0,25 + 0,128 = 0,628. El paso es 0,349 / (0,005756 · 0,628) = 96,5, y el Elo pasa a 1596,5. El segundo paso corrige dos puntos y ya no se mueve:

import math
c = math.log(10) / 400
opponents = [1500, 1500, 1800]
scores = [1, 0.5, 0] # victoria, tablas, derrota
elo = 1500.0
for step in range(3):
p = [1 / (1 + 10 ** ((o - elo) / 400)) for o in opponents]
grad = sum(s - q for s, q in zip(scores, p))
curv = sum(q * (1 - q) for q in p)
elo += grad / (c * curv)
print(step, [round(q, 3) for q in p], round(elo, 1))
0 [0.5, 0.5, 0.151] 1596.5
1 [0.635, 0.635, 0.237] 1594.5
2 [0.633, 0.633, 0.235] 1594.5

Σ(s − p) es «cuánto mejor le fue de lo que la curva esperaba»: positivo, el Elo sube. Σ p(1 − p) es cuánta información aportan las partidas, máxima contra un rival igualado. Es el elo.py de más abajo, sin la vectorización.

Cuántas partidas hacen falta

Para decir que dos modelos puntúan distinto, sus dos intervalos no deberían tocarse. Cerca del 50 %, cada uno mide ±1,96 · 0,5 / √n = ±0,98 / √n con n partidas por modelo, así que dejan de tocarse cuando la diferencia supera el doble, Δp > 1,96 / √n. Despejando:

n > 1,96² / (Δp)² = 3,84 / (Δp)²

partidas por modelo. Para distinguir una moneda del 50 % de una del 55 % (Δp = 0,05) hacen falta 1 536 tiradas de cada una; para una del 60 %, 384. Es la cuenta que M4 y M5 hacen antes de lanzar un experimento, y la que distingue «faltan partidas» de «no hay diferencia» (la potencia estadísticaPotencia estadísticaQué diferencia es capaz de detectar un experimento del tamaño que tiene. Para una tasa de puntos medida en n partidas, dos intervalos del 95 % dejan de tocarse cuando la diferencia supera 1,96 (se₁ + se₂), es decir, hacen falta unas 3,84 / (Δp)² partidas por condición. Sirve en los dos sentidos: antes, para saber si merece la pena medir; después, para distinguir «faltan partidas» de «no hay diferencia». Un resultado negativo sin este número no es un resultado.).

Pearson y Spearman

Las dos miden si un número predicho acompaña al real, de −1 a 1. Pearson mide si los puntos caen en una recta, así que le importa la escala; Spearman es Pearson sobre las posiciones en la clasificación (los rangos), así que solo mide si el orden coincide. Discrepan cuando el orden está bien y la escala mal, que es justo lo que M3 y M5 necesitan detectar:

import numpy as np
pred = np.array([1, 2, 3, 4, 5.0])
real = np.array([1, 2, 3, 4, 50.0]) # el mismo orden, otra escala
ranks = lambda a: a.argsort().argsort()
print(round(np.corrcoef(pred, real)[0, 1], 3)) # Pearson
print(round(np.corrcoef(ranks(pred), ranks(real))[0, 1], 3)) # Spearman
0.743
1.0

Teoría justa: de partidas a un número

El modelo juega contra Stockfish en ocho escalones, cuatro por Skill Level (por debajo de 1320, el suelo de UCI_Elo) y cuatro por UCI_Elo en 1320, 1500, 1800 y 2000, con ambos colores y con máscara de legalidad. Después se ajusta el número que mejor explica los resultados con la logística de EloEloEscala de fuerza de juego: una diferencia de 200 puntos implica que el mejor gana unas tres de cada cuatro veces. Lichess usa Glicko-2, compatible en la práctica. Rukh estima el Elo de cada modelo jugando contra Stockfish limitado y publica el intervalo de confianza. de toda la vida:

P(puntuación) = 1 / (1 + 10 ** ((elo_rival − elo_modelo) / 400))

Se lee así: 400 puntos de diferencia son una probabilidad de ganar de 10 a 1; 0 puntos, 50 %. El ajuste es encontrar el elo_modelo que hace más verosímiles los resultados observados, y se resuelve por Newton en un par de líneas, sin SciPy. Con c = ln(10)/400 = 0,005756, el gradiente de la log-verosimilitud es c · Σ(s − p) y la curvatura −c² · Σ p(1−p), así que cada paso es Σ(s − p) / (c · Σ p(1−p)). Las tablas entran como puntuación 0,5.

10,50-800-600-400-2000+200+400+600+800Elo del modelo − Elo del rival9 %50 %91 %puntuación esperadacuánto informa una partida
fig. 01La logística del Elo y lo que aporta cada partida al ajuste. Contra un rival igual se puntúa la mitad y cada partida informa al máximo; a 600 puntos de distancia, por arriba o por abajo, el resultado se sabe de antemano y la partida apenas enseña nada.

Lo que se suele contar menos es cuánto puedes fiarte del resultado. La misma curvatura da la varianza: 1 / (c² · Σ p(1−p)). Haz la cuenta para el caso más favorable, rendir al 50 % en todas las partidas (p(1−p) = 0,25, el máximo; cuanto más lejos del 50 %, menos informa cada partida). La tirada de small jugó 20 partidas contra cada escalón, 160 en total:

Partidas Σ p(1−p) a p = 0,5 Varianza σ (Elo) IC 95 %
20 (un escalón) 5 6 036 78 ±152
100 25 1 207 35 ±68
160 (la tirada: 20 × 8) 40 754 27 ±54
800 (full.yaml entero) 200 151 12 ±24
1 000 250 121 11 ±22

En la práctica el harness usa un bootstrapBootstrapRemuestrear los datos con reemplazo muchas veces y recalcular el estadístico en cada remuestreo para ver cuánto se mueve. El intervalo del Elo de Rukh sale así: 500 remuestreos de las 160 partidas (bootstrap: 500 en la suite), un ajuste por remuestreo, y los percentiles 2,5 y 97,5. Cubre el ruido del muestreo de partidas y nada más: no cubre que el rival cambie entre ejecuciones ni que los peldaños estén mal colocados. percentil: remuestrea con reemplazo las partidas jugadas, reajusta el Elo en cada remuestreo y se queda con los percentiles 2,5 y 97,5. No supone que la verosimilitud sea gaussiana. Con las 160 partidas de small, una vez corregida la escalera (1359, IC 1293-1429, al final de la lección), da ±68, no ±54, porque el modelo real no rinde al 50 % en todos los escalones: la fórmula da el suelo del ruido; el bootstrap, el ruido que hay. Con la escalera sin corregir, las mismas partidas dan ±90 (920-1101): el ancho también depende de lo lejos que caen los rivales del nivel del modelo.

Una encuesta electoral a 100 personas sale con un margen de ±10 puntos, y nadie serio titula «sube dos puntos» con ella; para bajar a ±1 harían falta 10 000, porque la precisión va con la raíz del tamaño de la muestra. La escalera de Stockfish es esa encuesta, y el ±68 es el «±10» que va impreso al lado de cada Elo de este curso.

Así que con 20 partidas por escalón no puedes presumir de 10 puntos de Elo, ni de 50. Una mejora de M4 o M5 tiene que superar el ancho del intervalo; si no, lo honesto es escribir «no medimos una diferencia». Es el error de los benchmarks de LLM que celebran medio punto sobre 200 ejemplos, donde el margen de un porcentaje de aciertos ronda ±7 puntos.

elo.py: el ajuste

src/rukh/eval/elo.py
"""Elo against Stockfish: play the rungs, then fit the rating that explains the results.
Rungs are Stockfish at ``UCI_Elo`` 1320/1500/1800/2000 (1320 is the engine's floor) plus four
``Skill Level`` rungs below it, so a weak model still has opponents it can score against: with
only losses there is nothing for the fit to latch onto. Both colours are played at every rung.
The fit is the standard logistic (Elo) model with one free parameter,
P(score) = 1 / (1 + 10 ** ((opponent - elo) / 400)),
maximised by Newton's method: with ``c = ln(10) / 400`` the gradient is ``c * sum(s - p)`` and
the Hessian ``-c**2 * sum(p * (1 - p))``, so each step is ``sum(s - p) / (c * sum(p * (1 - p)))``.
Draws enter as a score of 0.5, which is the usual quasi-likelihood treatment. The 95 % interval
is a percentile bootstrap over the game results, fitted in a single vectorised pass so a
thousand resamples cost milliseconds and no SciPy is needed.
Two honesty notes are built into the numbers rather than left to the reader:
* a game cut short by the context limit is **adjudicated** (shallow engine analysis of the final
position, or the material count when no engine is around) instead of being booked as a draw,
because half a point per cut game biases the fit towards the middle of the rungs;
* when every game is a win (or every game is a loss) the likelihood has no maximum inside the
range of opponents and the bootstrap collapses to a zero-width interval. That is reported as
``separated`` with a one-sided likelihood bound instead of a symmetric interval that would be
a lie.
"""

src/rukh/eval/elo.pylíneas 1-26 · p2

Las dos «notas de honestidad» del final, la adjudicación de las partidas cortadas y la detección de la separación, aparecen en el código más abajo.

src/rukh/eval/elo.py
from __future__ import annotations
import math
from collections.abc import Sequence
import chess
import numpy as np
from pydantic import BaseModel, ConfigDict, model_validator
from rukh.config import BaseConfig
from rukh.eval.cache import EvalCache
from rukh.infer import GameResult, SampleConfig, StockfishOpponent, adjudicate, play_game
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
LOG10_400 = math.log(10.0) / 400.0
SUITE = "elo"
MAX_STEP = 400.0
SLACK = 1000.0
"""How far outside the range of opponents the fit is allowed to wander (separation guard)."""
BOUND_SLACK = 4000.0
"""Search range of the one-sided bound when the results are separated."""
MOVE_TIME = 0.1
"""Seconds per move for the engine; below this ``UCI_Elo`` means very little (D-025)."""

src/rukh/eval/elo.pylíneas 28-51 · p2

SLACK y MAX_STEP son guardas contra dos formas de publicar un número falso; las verás en _fit_batch. MOVE_TIME = 0.1 queda muy por debajo del régimen para el que se calibró UCI_Elo. Es una decisión de presupuesto (800 partidas a un segundo por jugada son horas), aceptable porque va escrita en las notas del informe y no solo en el código.

src/rukh/eval/elo.py
class EloRung(BaseConfig):
"""One Stockfish setting and the rating it is assumed to play at."""
name: str
elo: int
uci_elo: int | None = None
skill: int | None = None
@model_validator(mode="after")
def _check(self) -> EloRung:
if (self.uci_elo is None) == (self.skill is None):
raise ValueError(f"rung {self.name}: set exactly one of uci_elo and skill")
return self

src/rukh/eval/elo.pylíneas 54-66 · p2

Un escalón es un ajuste de Stockfish más el Elo al que se supone que juega; el validador exige uno solo de uci_elo y skill. Fíjate en el campo elo, sin adjetivo: el código no distingue entre un Elo medido y uno supuesto.

src/rukh/eval/elo.py
# ``Skill Level`` ratings are nominal anchors for the rungs below the engine's 1320 floor: they
# are not measured strengths, so a model whose fit leans on them is reported with that caveat.
DEFAULT_RUNGS: list[EloRung] = [
EloRung(name="skill-0", elo=800, skill=0),
EloRung(name="skill-1", elo=950, skill=1),
EloRung(name="skill-2", elo=1100, skill=2),
EloRung(name="skill-3", elo=1250, skill=3),
EloRung(name="uci-1320", elo=1320, uci_elo=1320),
EloRung(name="uci-1500", elo=1500, uci_elo=1500),
EloRung(name="uci-1800", elo=1800, uci_elo=1800),
EloRung(name="uci-2000", elo=2000, uci_elo=2000),
]

src/rukh/eval/elo.pylíneas 69-80 · p2

El comentario lo admite: los Skill Level son anclas nominales, no fuerzas medidas. La advertencia está; la medida, no.

src/rukh/eval/elo.py
class GameRecord(BaseModel):
"""One played game, from the model's point of view."""
model_config = ConfigDict(extra="forbid")
rung: str
opponent_elo: int
index: int
model_white: bool
result: str
score: float
plies: int
illegal_proposals: int
cut: bool = False
"""The game ran out of context instead of ending on the board."""
adjudicated: str | None = None
"""How a cut game was decided (``engine depth 8``, ``material count``), or None."""
def item_id(self) -> str:
return f"{self.rung}:{self.index}"

src/rukh/eval/elo.pylíneas 83-102 · p2

src/rukh/eval/elo.py
class RungResult(BaseModel):
"""Aggregated results at one rung."""
model_config = ConfigDict(extra="forbid")
name: str
opponent_elo: int
games: int
wins: int
draws: int
losses: int
score: float
"""Average score in [0, 1]."""
cut: int = 0
"""Games that hit the context limit instead of ending on the board."""
adjudicated: int = 0
"""Cut games whose result was decided by adjudication."""

src/rukh/eval/elo.pylíneas 105-121 · p2

src/rukh/eval/elo.py
class EloResult(BaseModel):
"""The fitted rating with its interval (or one-sided bound), plus the rung breakdown."""
model_config = ConfigDict(extra="forbid")
elo: float
ci_low: float | None = None
ci_high: float | None = None
games: int
score: float
rungs: list[RungResult]
cut: int = 0
adjudicated: int = 0
separated: bool = False
"""Every game was a win (or every one a loss): the fit is not identified."""
elo_lower: float | None = None
"""One-sided 95 % lower bound; set when the model won every game."""
elo_upper: float | None = None
"""One-sided 95 % upper bound; set when the model lost every game."""

src/rukh/eval/elo.pylíneas 124-142 · p2

GameRecord guarda por partida si se cortó y cómo se adjudicó, y RungResult separa victorias, tablas y derrotas. EloResult tiene tres formas excluyentes de expresar la incertidumbre (ci_low/ci_high, elo_lower, elo_upper) porque hay tres situaciones, y meterlas en dos números sería mentir en una.

src/rukh/eval/elo.py
def score_of(result: str, model_white: bool) -> float:
"""Score of a finished game from the model's point of view.
A cut game (``*``) that could not be adjudicated is the only case left at 0.5 by default;
``play_rung`` adjudicates cut games before calling this, so that branch is a fallback.
"""
if result == "1-0":
return 1.0 if model_white else 0.0
if result == "0-1":
return 0.0 if model_white else 1.0
return 0.5

src/rukh/eval/elo.pylíneas 145-155 · p2

El 0,5 final cubre las tablas y, como respaldo, la partida cortada que no se pudo adjudicar.

src/rukh/eval/elo.py
def _fit_batch(opponents: np.ndarray, scores: np.ndarray, iterations: int = 60) -> np.ndarray:
"""Newton's method on ``(B, n)`` batches of results; returns one rating per row."""
opponents = np.asarray(opponents, dtype=np.float64)
scores = np.asarray(scores, dtype=np.float64)
low = opponents.min(axis=1) - SLACK
high = opponents.max(axis=1) + SLACK
elo = opponents.mean(axis=1)
for _ in range(iterations):
p = 1.0 / (1.0 + np.exp(-LOG10_400 * (elo[:, None] - opponents)))
gradient = (scores - p).sum(axis=1)
curvature = (p * (1.0 - p)).sum(axis=1)
safe = np.maximum(curvature, 1e-12)
step = np.where(curvature > 1e-12, gradient / (LOG10_400 * safe), 0.0)
step = np.clip(step, -MAX_STEP, MAX_STEP)
elo = np.clip(elo + step, low, high)
if np.max(np.abs(step)) < 1e-6:
break
return elo

src/rukh/eval/elo.pylíneas 158-175 · p2

El ajuste está vectorizado sobre un lote de filas: así mil remuestreos de bootstrap cuestan milisegundos, y el intervalo se publica siempre y no «cuando da tiempo». Los recortes son guardas:

  • MAX_STEP impide que una curvatura casi plana (el modelo gana o pierde casi todo) mande la estimación al otro extremo del universo en un paso.
  • SLACK acota la respuesta al rango de rivales más mil puntos, para que una muestra separada dé un valor reconocible y no un infinito.
  • El np.where evita dividir por cero cuando todas las probabilidades están pegadas a 0 o a 1.
src/rukh/eval/elo.py
def fit_elo(opponents: Sequence[float], scores: Sequence[float]) -> float:
"""Maximum-likelihood rating for one set of games."""
if not len(opponents):
raise ValueError("cannot fit an Elo without games")
return float(_fit_batch(np.asarray([opponents]), np.asarray([scores]))[0])

src/rukh/eval/elo.pylíneas 178-182 · p2

src/rukh/eval/elo.py
def bootstrap_ci(
opponents: Sequence[float],
scores: Sequence[float],
samples: int = 1_000,
seed: int = 0,
level: float = 0.95,
) -> tuple[float, float]:
"""Percentile bootstrap interval over the games themselves (one fit per resample)."""
opponents = np.asarray(opponents, dtype=np.float64)
scores = np.asarray(scores, dtype=np.float64)
n = len(opponents)
if n == 0:
raise ValueError("cannot bootstrap without games")
rng = np.random.default_rng(seed)
index = rng.integers(0, n, size=(samples, n))
fitted = _fit_batch(opponents[index], scores[index])
tail = (1.0 - level) / 2.0 * 100.0
return float(np.percentile(fitted, tail)), float(np.percentile(fitted, 100.0 - tail))

src/rukh/eval/elo.pylíneas 185-202 · p2

rng.integers(0, n, size=(samples, n)) construye los mil remuestreos de golpe como una matriz de índices, y _fit_batch los ajusta a la vez.

Lo que ese intervalo no cubre es el sesgo del instrumento. Cubre el ruido de haber jugado estas partidas y no otras; si los escalones están mal etiquetados, los mil remuestreos lo están igual y el intervalo no se entera. Guárdate la frase: explica el final de la lección.

src/rukh/eval/elo.py
def separation(scores: Sequence[float]) -> str | None:
"""``"wins"``, ``"losses"`` or None: whether every game went the same way.
With no counter-example the likelihood keeps rising as the rating goes to infinity, the fit
stops at the clamp and every bootstrap resample returns that same clamp, which is where the
zero-width "95 % CI" came from.
"""
values = np.asarray(scores, dtype=np.float64)
if values.size == 0:
return None
if np.all(values >= 1.0):
return "wins"
if np.all(values <= 0.0):
return "losses"
return None

src/rukh/eval/elo.pylíneas 205-219 · p2

Si todas las partidas fueron en la misma dirección, ningún contraejemplo frena la verosimilitud: el ajuste se para en el recorte de SLACK y todos los remuestreos devuelven ese mismo recorte. Sale un «IC del 95 %» de anchura cero, que parece precisísimo y significa «no hay información».

src/rukh/eval/elo.py
def one_sided_bound(
opponents: Sequence[float], scores: Sequence[float], level: float = 0.95, lower: bool = True
) -> float:
"""Likelihood bound for separated results: the rating at which the run stops being plausible.
For an all-win run the bound is the lowest rating under which winning every game still has
probability ``1 - level``; for an all-loss run it is the highest rating under which losing
every game does. Solved by bisection on a monotone log-likelihood, so no SciPy is needed.
"""
rungs = np.asarray(opponents, dtype=np.float64)
if rungs.size == 0:
raise ValueError("cannot bound an Elo without games")
target = math.log(1.0 - level)
def loglik(elo: float) -> float:
p = 1.0 / (1.0 + np.exp(-LOG10_400 * (elo - rungs)))
p = np.clip(p if lower else 1.0 - p, 1e-300, 1.0)
return float(np.sum(np.log(p)))
low = float(rungs.min()) - BOUND_SLACK
high = float(rungs.max()) + BOUND_SLACK
# ``loglik`` rises with the rating when bounding from below and falls when bounding from
# above; bisect for the crossing of ``target`` either way.
for _ in range(200):
middle = 0.5 * (low + high)
if (loglik(middle) < target) == lower:
low = middle
else:
high = middle
return 0.5 * (low + high)

src/rukh/eval/elo.pylíneas 222-251 · p2

La alternativa honesta es una cota de un solo lado: el rating por debajo del cual ganar todas las partidas ya no sería plausible, resuelto por bisección. El np.clip(p, 1e-300, 1.0) evita un -inf cuando la probabilidad se hace indistinguible de cero.

src/rukh/eval/elo.py
def summarize_rungs(records: Sequence[GameRecord]) -> list[RungResult]:
"""Wins, draws, losses and average score per rung, ordered by opponent rating."""
by_rung: dict[str, list[GameRecord]] = {}
for record in records:
by_rung.setdefault(record.rung, []).append(record)
results = [
RungResult(
name=name,
opponent_elo=games[0].opponent_elo,
games=len(games),
wins=sum(g.score == 1.0 for g in games),
draws=sum(g.score == 0.5 for g in games),
losses=sum(g.score == 0.0 for g in games),
score=sum(g.score for g in games) / len(games),
cut=sum(g.cut for g in games),
adjudicated=sum(g.adjudicated is not None for g in games),
)
for name, games in by_rung.items()
]
return sorted(results, key=lambda r: (r.opponent_elo, r.name))

src/rukh/eval/elo.pylíneas 254-273 · p2

src/rukh/eval/elo.py
def estimate(
records: Sequence[GameRecord], samples: int = 1_000, seed: int = 0, level: float = 0.95
) -> EloResult:
"""Fit the rating and its interval (or its one-sided bound) from played games."""
if not records:
raise ValueError("cannot estimate an Elo without games")
opponents = [float(record.opponent_elo) for record in records]
scores = [record.score for record in records]
elo = fit_elo(opponents, scores)
rungs = summarize_rungs(records)
result = EloResult(
elo=elo,
games=len(records),
score=float(np.mean(scores)),
rungs=rungs,
cut=sum(rung.cut for rung in rungs),
adjudicated=sum(rung.adjudicated for rung in rungs),
)
apart = separation(scores)
if apart is None:
result.ci_low, result.ci_high = bootstrap_ci(
opponents, scores, samples=samples, seed=seed, level=level
)
return result
result.separated = True
bound = one_sided_bound(opponents, scores, level=level, lower=apart == "wins")
if apart == "wins":
result.elo_lower = bound
else:
result.elo_upper = bound
return result

src/rukh/eval/elo.pylíneas 276-306 · p2

estimate ajusta, agrega y decide qué clase de incertidumbre publicar: bootstrap si hay contraejemplos, cota de un lado si no. Las dos ramas devuelven el mismo tipo, y el informe lee los campos que haya.

src/rukh/eval/elo.py
def record_of(
outcome: GameResult,
rung: EloRung,
index: int,
model_white: bool,
engine: chess.engine.SimpleEngine | None = None,
) -> GameRecord:
"""One played game as a record, adjudicating it first when it was cut short."""
result = outcome.result
how: str | None = None
if outcome.cut:
result, how = adjudicate(outcome.fen, engine=engine)
return GameRecord(
rung=rung.name,
opponent_elo=rung.elo,
index=index,
model_white=model_white,
result=result,
score=score_of(result, model_white),
plies=outcome.plies,
illegal_proposals=outcome.illegal_proposals,
cut=outcome.cut,
adjudicated=how,
)

src/rukh/eval/elo.pylíneas 309-332 · p2

Si la partida se cortó, se adjudica antes de puntuarla. Anotarla como tablas le regala medio punto al bando que iba perdiendo, y con un modelo que se queda sin contexto en las partidas largas ese sesgo apunta siempre al mismo lado. El registro guarda cómo se adjudicó, para que el informe diga cuántas partidas no terminaron en el tablero.

Jugar las partidas es orquestación alrededor del motor; el ajuste de arriba es lo que produce el número. Puedes saltarlo en la primera lectura, pero es parte de elo.py: tecléalo igual, porque suite.py lo llama y el test con motor del «Ejecútalo» del harness juega con él.

// Fontaneríaplay_rung: jugar un escalón contra Stockfish, con caché, colores alternos y semilla por partida
src/rukh/eval/elo.py
def play_rung(
model: MoveDecoder,
tok: UciTokenizer,
rung: EloRung,
games: int,
cfg: SampleConfig,
move_time: float = MOVE_TIME,
max_plies: int | None = None,
cache: EvalCache | None = None,
) -> list[GameRecord]:
"""Play ``games`` games against one rung, alternating colours, reusing cached games.
A game that ends with ``*`` (the context ran out) is adjudicated with the rung's own engine
before it is scored, so it enters the fit as a win, a loss or a draw on the merits of the
final position.
"""
records: list[GameRecord] = []
for index in range(games):
cached = cache.get(SUITE, f"{rung.name}:{index}") if cache is not None else None
if cached is not None:
records.append(GameRecord.model_validate(cached))
done = {record.index for record in records}
missing = [index for index in range(games) if index not in done]
if not missing:
return sorted(records, key=lambda r: r.index)
opponent = StockfishOpponent(elo=rung.uci_elo or 1320, skill=rung.skill, move_time=move_time)
try:
for index in missing:
model_white = index % 2 == 0
outcome = play_game(
model,
tok,
opponent,
cfg.model_copy(update={"seed": None if cfg.seed is None else cfg.seed + index}),
model_color=chess.WHITE if model_white else chess.BLACK,
max_plies=max_plies,
)
record = record_of(outcome, rung, index, model_white, engine=opponent.engine)
if cache is not None:
cache.put(SUITE, record.item_id(), record.model_dump())
records.append(record)
finally:
opponent.close()
return sorted(records, key=lambda r: r.index)

src/rukh/eval/elo.pylíneas 335-379 · p2

Cuatro decisiones al jugar un escalón:

  1. La caché se consulta partida a partida y solo se arranca Stockfish si falta alguna: añadir un escalón cuesta ese escalón.
  2. Los colores se alternan; solo con blancas mediría el modelo y la ventaja de salida a la vez.
  3. La semilla avanza con el índice. Con la misma en las veinte partidas, contra un motor determinista saldría veinte veces la misma partida.
  4. El motor se cierra en un finally, y el mismo motor adjudica en record_of.

Queda el bucle que recorre los escalones, sin ninguna decisión nueva; se lee por encima y se teclea, porque es lo que evaluate llama:

// Fontaneríaplay_rungs: recorrer todos los escalones y juntar sus partidas
src/rukh/eval/elo.py
def play_rungs(
model: MoveDecoder,
tok: UciTokenizer,
rungs: Sequence[EloRung],
games: int,
cfg: SampleConfig,
move_time: float = MOVE_TIME,
max_plies: int | None = None,
cache: EvalCache | None = None,
) -> list[GameRecord]:
"""Play every rung and return all the game records."""
records: list[GameRecord] = []
for rung in rungs:
records.extend(
play_rung(
model,
tok,
rung,
games,
cfg,
move_time=move_time,
max_plies=max_plies,
cache=cache,
)
)
return records

src/rukh/eval/elo.pylíneas 382-407 · p2

suite.py: ensamblarlo todo

src/rukh/eval/suite.py
"""The evaluation suite: one command that produces one row of the results table.
``run_suite`` loads a checkpoint, measures legality, next-move accuracy, puzzles and Elo, and
hands the result to ``report``. Every part is optional at run time: a missing validation
parquet, a missing puzzle parquet or a missing Stockfish binary becomes a note in the report
instead of a crash, because a partial evaluation that says what it could not measure is more
useful than no evaluation at all.
The suite runs on the same device the model was trained on (``pick_device`` by default, so CUDA
when it is there): a harness pinned to the CPU turns ten thousand forward passes and a few
hundred games into hours. The device that was used is recorded in the report.
"""

src/rukh/eval/suite.pylíneas 1-12 · p2

Dos principios. Nada es obligatorio en tiempo de ejecución: lo que falta (un parquet, los puzles, Stockfish) se convierte en una nota del informe, porque una evaluación parcial que dice lo que no pudo medir es más útil que ninguna. Y la suite corre en la GPU si la hay, o diez mil pasadas y unos cientos de partidas se van a horas.

Lo que sigue hasta evaluate es la configuración y el ensamblaje de la suite. No cambia ninguna métrica y puedes saltarlo en la primera lectura; la función que las llama todas está justo debajo. Tecléalo igual: es el principio de suite.py, y evaluate usa cada pieza.

// Fontaneríasuite.py, las piezas: advertencias, EvalConfig, SuiteResult, las muestras y la carga de un modelo local o del Hub
src/rukh/eval/suite.py
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from rukh import paths
from rukh.config import BaseConfig
from rukh.eval.accuracy import AccuracyResult, accuracy
from rukh.eval.cache import EvalCache, config_sha, file_sha
from rukh.eval.elo import DEFAULT_RUNGS, EloResult, EloRung, estimate, play_rungs
from rukh.eval.legality import LegalityResult, legality, sample_positions
from rukh.eval.puzzles import PuzzleResult, load_puzzles, model_source, run_puzzles
from rukh.eval.report import ReportPaths, write_report
from rukh.infer import SampleConfig
from rukh.models import DecoderConfig
from rukh.tokenize.uci_vocab import UciTokenizer
log = logging.getLogger(__name__)
SUITES = ("full", "quick")
HUB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$")
HUB_WEIGHTS = ("model.safetensors", "pytorch_model.bin")
ELO_CAVEAT = (
"the Elo interval covers sampling noise only: the four ``skill-*`` rungs are nominal "
"``Skill Level`` anchors rather than measured ratings, and Stockfish plays at {move_time:g} s "
"per move, far below any setting ``UCI_Elo`` is calibrated for"
)
LEGALITY_DEFINITIONS = (
"legality_argmax is the share of validation positions where the single most likely token is "
"a legal move (no temperature, no top-k, no mask): this is the >= 99 % bar of GOAL.md. "
"legality_sampled draws the token the way the demo does (temperature {temperature:g}"
"{top_k}) and is always the lower of the two."
)

src/rukh/eval/suite.pylíneas 14-52 · p2

ELO_CAVEAT y LEGALITY_DEFINITIONS son las advertencias que acaban en el informe y en la model card. Viven junto al código que mide, no en una plantilla, para no desincronizarse del número al que acompañan.

src/rukh/eval/suite.py
class EvalConfig(BaseConfig):
"""What a suite measures and where it reads and writes."""
stage: str | None = None
"""Name of the row in the results table; defaults to the checkpoint's run directory."""
games: str = "data/uci/year=2025/month=02/games.parquet"
puzzles: str = "data/puzzles/puzzles.parquet"
puzzle_split: str = "test"
legality_positions: int = 10_000
accuracy_positions: int = 10_000
position_pool: int = 50_000
puzzles_per_band: int = 2_000
elo_games: int = 100
elo_rungs: list[EloRung] = Field(default_factory=lambda: list(DEFAULT_RUNGS))
elo_move_time: float = 0.1
elo_max_plies: int | None = None
bootstrap: int = 1_000
temperature: float = 0.6
top_k: int | None = 20
block: int = 200
seed: int = 42
out_dir: str = "artifacts/eval"
web_results: str | None = "artifacts/web/results.json"
cache_db: str = "artifacts/eval/cache.sqlite"
device: str | None = None
"""Where the model runs; ``None`` means ``rukh.train.pick_device()`` (CUDA when present)."""
track: bool = True
"""Log the suite to the local MLflow store (the model card reads those metrics back)."""
def cache_fields(self) -> dict[str, Any]:
"""The settings that change what a cached game or puzzle means."""
return {
"temperature": self.temperature,
"top_k": self.top_k,
"elo_move_time": self.elo_move_time,
"elo_max_plies": self.elo_max_plies,
"elo_games": self.elo_games,
"rungs": [rung.model_dump(mode="json") for rung in self.elo_rungs],
"seed": self.seed,
"block": self.block,
}
def sampling(self) -> SampleConfig:
"""The sampler the Elo games use: masked, seeded, as the demo plays."""
return SampleConfig(
temperature=self.temperature,
top_k=self.top_k,
mask_illegal=True,
seed=self.seed,
)

src/rukh/eval/suite.pylíneas 55-104 · p2

EvalConfig es la suite entera. cache_fields enumera lo que cambia el significado de una partida o un puzle guardado (temperatura, top-k, tiempo por jugada, límite de pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply., escalones, semilla, bloque): la lista que la lección anterior veía desde la caché. sampling da el muestreador de las partidas de Elo, enmascarado porque una partida tiene que ser jugable; la legalidad se mide aparte y sin máscara.

src/rukh/eval/suite.py
class SuiteResult(BaseModel):
"""Everything one evaluation produced; serialised verbatim as ``results.json``."""
model_config = ConfigDict(extra="forbid")
stage: str
suite: str
checkpoint: str
model_sha: str
params: int
date: str
device: str = "cpu"
run_id: str | None = None
legality_argmax: LegalityResult | None = None
"""The headline legality rate: the most likely token, unmasked."""
legality_sampled: LegalityResult | None = None
"""Legality of a token drawn the way the demo draws it (temperature and top-k)."""
accuracy: AccuracyResult | None = None
puzzles: PuzzleResult | None = None
elo: EloResult | None = None
delta_cp: float | None = None
"""Mean centipawn loss; measured by a later milestone, ``null`` until then."""
diversity: float | None = None
"""Opening entropy over self-play games; measured by a later milestone."""
notes: list[str] = []
config: dict[str, Any] = {}

src/rukh/eval/suite.pylíneas 107-132 · p2

SuiteResult se serializa tal cual como results.json. delta_cp y diversity están declarados y valen None hasta un hito posterior: un hueco explícito es información; un cero inventado, no.

src/rukh/eval/suite.py
def config_path(suite: str) -> Path:
"""Where the YAML of a named suite lives inside the source tree."""
if suite not in SUITES:
raise ValueError(f"unknown suite {suite!r}; expected one of {', '.join(SUITES)}")
return paths.package_root() / "configs" / "eval" / f"{suite}.yaml"
def load_suite(suite: str, config: Path | None = None) -> EvalConfig:
"""Load the suite config: an explicit path wins over the named one."""
from rukh.config import load_yaml
return load_yaml(config if config is not None else config_path(suite), EvalConfig)

src/rukh/eval/suite.pylíneas 135-146 · p2

src/rukh/eval/suite.py
def _positions(cfg: EvalConfig, tok: UciTokenizer, notes: list[str]) -> list[Any]:
games = paths.resolve(cfg.games)
if not games.is_file():
notes.append(f"validation games not found at {games}: legality and accuracy skipped")
return []
return sample_positions(
games,
max(cfg.legality_positions, cfg.accuracy_positions),
tok,
seed=cfg.seed,
pool=cfg.position_pool,
block=cfg.block,
)
def _elo(
model: Any, tok: UciTokenizer, cfg: EvalConfig, cache: EvalCache | None, notes: list[str]
) -> EloResult | None:
from rukh.engine import EngineNotFound
try:
records = play_rungs(
model,
tok,
cfg.elo_rungs,
cfg.elo_games,
cfg.sampling(),
move_time=cfg.elo_move_time,
max_plies=cfg.elo_max_plies,
cache=cache,
)
except EngineNotFound as exc:
notes.append(f"Elo skipped: {exc}")
return None
if not records:
notes.append("Elo skipped: no games were played")
return None
return estimate(records, samples=cfg.bootstrap, seed=cfg.seed)

src/rukh/eval/suite.pylíneas 149-186 · p2

src/rukh/eval/suite.py
def _metrics(result: SuiteResult) -> dict[str, float]:
"""Flat metrics for MLflow (only what was actually measured)."""
metrics: dict[str, float] = {}
if result.legality_argmax is not None:
metrics["legality_argmax"] = result.legality_argmax.rate
if result.legality_sampled is not None:
metrics["legality_sampled"] = result.legality_sampled.rate
if result.accuracy is not None:
metrics["top1"] = result.accuracy.top1
metrics["top3"] = result.accuracy.top3
for band in result.accuracy.bands:
metrics[f"top1/{band.band}"] = band.top1
metrics[f"top3/{band.band}"] = band.top3
if result.puzzles is not None:
metrics["puzzles"] = result.puzzles.rate
for band in result.puzzles.bands:
metrics[f"puzzles/{band.band}"] = band.rate
if result.elo is not None:
metrics["elo"] = result.elo.elo
if result.elo.ci_low is not None and result.elo.ci_high is not None:
metrics["elo_ci_low"] = result.elo.ci_low
metrics["elo_ci_high"] = result.elo.ci_high
if result.elo.elo_lower is not None:
metrics["elo_lower_bound"] = result.elo.elo_lower
if result.elo.elo_upper is not None:
metrics["elo_upper_bound"] = result.elo.elo_upper
return metrics

src/rukh/eval/suite.pylíneas 189-215 · p2

_positions saca una sola muestra y legalidad y exactitud usan sus primeras posiciones: comparten prefijos y se pueden leer juntas. _metrics solo aplana lo que se midió; un None no aparece en MLflow, así que los hitos donde una métrica no existía no hunden su serie histórica.

src/rukh/eval/suite.py
def is_hub_id(spec: str) -> bool:
"""Whether ``spec`` looks like a Hub repository id (``owner/name``) rather than a path."""
text = str(spec)
if Path(text).expanduser().is_file() or text.endswith(".pt"):
return False
return HUB_ID.fullmatch(text) is not None
def hub_checkpoint(repo_id: str, out_dir: Path | None = None) -> Path:
"""Download a published model and write it back as a checkpoint the harness can read.
A Hub repository holds ``config.json`` and ``model.safetensors`` (or the torch pickle when
safetensors was not installed at publication time), not a training checkpoint, so the two are
folded into the ``{model_state, model_cfg, step}`` payload every other entry point expects.
Nothing here runs while the command line is being parsed: a Hub id is validated by its shape.
"""
import torch
from huggingface_hub import hf_hub_download
directory = out_dir if out_dir is not None else paths.resolve("artifacts/hub")
directory.mkdir(parents=True, exist_ok=True)
target = directory / (repo_id.replace("/", "__") + ".pt")
config = json.loads(Path(hf_hub_download(repo_id, "config.json")).read_text(encoding="utf-8"))
state: dict[str, Any] = {}
for name in HUB_WEIGHTS:
try:
weights = Path(hf_hub_download(repo_id, name))
except Exception as exc: # noqa: BLE001 - a missing file only means "try the next one"
log.debug("%s has no %s (%s)", repo_id, name, exc)
continue
if name.endswith(".safetensors"):
from safetensors.torch import load_file
state = dict(load_file(str(weights)))
else:
state = dict(torch.load(weights, map_location="cpu", weights_only=True))
break
if not state:
raise FileNotFoundError(f"{repo_id} holds none of {', '.join(HUB_WEIGHTS)}")
model_cfg = {key: value for key, value in config.items() if key in DecoderConfig.model_fields}
torch.save(
{
"step": int(config.get("step") or 0),
"model_state": state,
"opt_state": None,
"cfg": {},
"model_cfg": model_cfg,
"vocab_hash": config.get("vocab_hash"),
"data_manifest_sha": config.get("data_manifest_sha"),
"git_sha": config.get("git_sha"),
"best_val": None,
},
target,
)
return target

src/rukh/eval/suite.pylíneas 218-272 · p2

Esto permite evaluar un modelo publicado en el Hub, que trae config.json y model.safetensors en vez de un checkpoint: los dos se pliegan en el payload que espera el resto del proyecto. is_hub_id decide por la forma de la cadena, sin tocar la red, para que un fichero que falta se detecte al instante.

src/rukh/eval/suite.py
def resolve_model(spec: str | Path) -> Path:
"""A checkpoint path from either a local file or a Hub id (downloaded on demand)."""
text = str(spec)
if is_hub_id(text):
return hub_checkpoint(text)
path = Path(text)
if not path.is_file():
raise FileNotFoundError(f"{path} is neither a checkpoint nor a Hub id (owner/name)")
return path
def _legality_note(cfg: EvalConfig) -> str:
top_k = "" if cfg.top_k is None else f", top-k {cfg.top_k}"
return LEGALITY_DEFINITIONS.format(temperature=cfg.temperature, top_k=top_k)

src/rukh/eval/suite.pylíneas 275-288 · p2

src/rukh/eval/suite.py
def evaluate(
ckpt: Path | str,
cfg: EvalConfig,
suite: str = "full",
use_cache: bool = True,
device: str | None = None,
) -> SuiteResult:
"""Measure one checkpoint; missing inputs become notes, never exceptions."""
from rukh.train import load_model, pick_device
ckpt = resolve_model(ckpt)
where = device or cfg.device or pick_device()
model, _payload = load_model(ckpt, map_location=where)
model = model.to(where).eval()
log.info("evaluating %s on %s", ckpt, where)
tok = UciTokenizer()
notes: list[str] = [_legality_note(cfg)]
cache = EvalCache(
paths.resolve(cfg.cache_db) if use_cache else None,
file_sha(ckpt),
enabled=use_cache,
config_sha=config_sha(cfg.cache_fields()),
)
try:
positions = _positions(cfg, tok, notes)
for_legality = positions[: cfg.legality_positions]
result = SuiteResult(
stage=cfg.stage or ckpt.parent.name,
suite=suite,
checkpoint=ckpt.as_posix(),
model_sha=cache.model_sha,
params=model.num_params(non_embedding=False),
date=datetime.now(UTC).date().isoformat(),
device=str(where),
legality_argmax=(
legality(model, tok, for_legality, cfg.sampling(), mode="argmax")
if positions
else None
),
legality_sampled=(
legality(model, tok, for_legality, cfg.sampling(), mode="sampled")
if positions
else None
),
accuracy=(
accuracy(model, tok, positions[: cfg.accuracy_positions]) if positions else None
),
notes=notes,
config=cfg.model_dump(mode="json"),
)
puzzle_path = paths.resolve(cfg.puzzles)
if puzzle_path.is_file():
items = load_puzzles(
puzzle_path, cfg.puzzles_per_band, seed=cfg.seed, split=cfg.puzzle_split
)
result.puzzles = run_puzzles(model_source(model, tok), tok, items, cache=cache)
else:
notes.append(f"puzzles not found at {puzzle_path}: puzzle suite skipped")
result.elo = _elo(model, tok, cfg, cache, notes)
_elo_notes(result.elo, cfg, notes)
result.notes = notes # pydantic copied the list at construction time
finally:
cache.close()
return result

src/rukh/eval/suite.pylíneas 291-354 · p2

Cada pieza se salta sola y deja una nota (if positions, if puzzle_path.is_file()). legality se llama dos veces sobre la misma lista, una por modo, para que restar las dos tasas signifique algo. El finally cierra SQLite aunque la evaluación reviente, y con el commit por escritura de la lección anterior se conserva lo hecho.

El result.notes = notes de la penúltima línea no sobra: Pydantic copia la lista al construir el modelo, y sin esa línea las notas de los puzles y del Elo se quedarían fuera. No rompería nada; publicaría un informe sin la mitad de sus advertencias, que es el tipo de fallo que más cuesta ver.

El resto de suite.py escribe las notas y guarda el resultado; se puede saltar al leer, pero dentro está run_suite, la función que llama rukh eval: tecléalo igual.

// Fontaneríasuite.py, el final: las notas del Elo y el guardado de results.json
src/rukh/eval/suite.py
def _elo_notes(elo: EloResult | None, cfg: EvalConfig, notes: list[str]) -> None:
"""Everything the Elo number has to be read with, in words."""
if elo is None:
return
notes.append(ELO_CAVEAT.format(move_time=cfg.elo_move_time))
if elo.cut:
notes.append(
f"{elo.cut} of {elo.games} games hit the context limit and were adjudicated "
f"({elo.adjudicated} of them) instead of being scored as draws"
)
if elo.separated:
notes.append(
"every game went the same way, so the rating is not identified: the report gives a "
"one-sided bound instead of a 95 % interval"
)

src/rukh/eval/suite.pylíneas 357-371 · p2

Todo lo que el Elo tiene que llevar pegado, en palabras: escalones nominales y tiempo por jugada, partidas cortadas y adjudicadas y, si hubo separación, que se publica una cota.

src/rukh/eval/suite.py
def track_result(result: SuiteResult, cfg: EvalConfig) -> str | None:
"""Log the suite to the local MLflow store and return the run id."""
import mlflow
from rukh.tracking import start_run
with start_run(
f"eval-{result.stage}",
{"suite": result.suite, "checkpoint": result.checkpoint, **cfg.model_dump(mode="json")},
tags={"kind": "eval", "stage": result.stage, "model_sha": result.model_sha},
) as run:
metrics = _metrics(result)
if metrics:
mlflow.log_metrics(metrics)
return str(run.info.run_id)

src/rukh/eval/suite.pylíneas 374-388 · p2

src/rukh/eval/suite.py
def run_suite(
ckpt: Path | str,
cfg: EvalConfig,
suite: str = "full",
use_cache: bool = True,
device: str | None = None,
) -> tuple[SuiteResult, ReportPaths]:
"""Evaluate, track and report: the whole of ``rukh eval`` in one call."""
result = evaluate(ckpt, cfg, suite=suite, use_cache=use_cache, device=device)
if cfg.track:
result.run_id = track_result(result, cfg)
report = write_report(
result,
paths.resolve(cfg.out_dir),
paths.resolve(cfg.web_results) if cfg.web_results else None,
)
return result, report

src/rukh/eval/suite.pylíneas 391-407 · p2

report.py: una fila para la web, un informe para leer

report.py convierte el resultado en una fila de la tabla única y en un informe legible. Es presentación, no medida: puedes saltarlo y quedarte con el párrafo de debajo. Si lo saltas, tráelo: git checkout p2 -- src/rukh/eval/report.py, porque suite.py lo importa y sin él no arranca nada del harness.

// Fontaneríareport.py: la fila de la tabla única (upsert por stage) y el informe en Markdown
src/rukh/eval/report.py
"""Reporting: ``report.md`` and ``results.json`` per model, one shared row for the web.
Every stage of the course (base, tiny, small, masters, Elo-conditioned, LoRA, DPO, GRPO, the
external baselines) ends up as one row of the single table of ``docs/spec/02`` §"Componente 5".
``artifacts/web/results.json`` is that table: the course site and the demo read it, so writing a
row is an upsert keyed by ``stage`` rather than an append, and a metric that has not been
measured yet (``delta_cp``, ``diversity``) is written as ``null`` instead of being invented.
Two numbers carry their definition with them rather than a footnote somewhere else: legality is
written twice (``argmax``, the headline, and ``sampled``, the demo's own setting), and an Elo
whose games were all wins or all losses is printed as a one-sided bound, in words, instead of a
zero-width "95 % CI".
"""

src/rukh/eval/report.pylíneas 1-13 · p2

artifacts/web/results.json es la tabla única del curso: una fila por etapa, desde el baseline hasta el GRPO de M5. Escribir una fila es un upsert por stage: volver a evaluar small sustituye su fila en vez de añadir otra.

src/rukh/eval/report.py
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict
from rukh.eval.elo import EloResult
if TYPE_CHECKING: # pragma: no cover - typing only
from rukh.eval.suite import SuiteResult
REPORT_NAME = "report.md"
RESULTS_NAME = "results.json"

src/rukh/eval/report.pylíneas 15-30 · p2

src/rukh/eval/report.py
class WebRow(BaseModel):
"""One row of the single results table, exactly as the web reads it."""
model_config = ConfigDict(extra="forbid")
stage: str
params: int
legality: float | None = None
"""Legality of the most likely token: the headline definition."""
legality_sampled: float | None = None
"""Legality of a token drawn with the suite's temperature and top-k."""
top1: float | None = None
top3: float | None = None
top1_by_band: dict[str, float] = {}
top3_by_band: dict[str, float] = {}
puzzles: dict[str, float] = {}
elo: float | None = None
elo_ci: list[float] | None = None
"""95 % bootstrap interval, or ``null`` when the results were separated."""
elo_lower: float | None = None
elo_upper: float | None = None
elo_separated: bool = False
delta_cp: float | None = None
diversity: float | None = None
date: str
run_id: str | None = None

src/rukh/eval/report.pylíneas 33-58 · p2

src/rukh/eval/report.py
def row_of(result: SuiteResult) -> WebRow:
"""The table row a finished suite produces."""
elo = result.elo
interval = (
[elo.ci_low, elo.ci_high]
if elo is not None and elo.ci_low is not None and elo.ci_high is not None
else None
)
accuracy = result.accuracy
return WebRow(
stage=result.stage,
params=result.params,
legality=result.legality_argmax.rate if result.legality_argmax else None,
legality_sampled=result.legality_sampled.rate if result.legality_sampled else None,
top1=accuracy.top1 if accuracy else None,
top3=accuracy.top3 if accuracy else None,
top1_by_band={band.band: band.top1 for band in accuracy.bands} if accuracy else {},
top3_by_band={band.band: band.top3 for band in accuracy.bands} if accuracy else {},
puzzles=result.puzzles.by_band() if result.puzzles else {},
elo=elo.elo if elo else None,
elo_ci=interval,
elo_lower=elo.elo_lower if elo else None,
elo_upper=elo.elo_upper if elo else None,
elo_separated=bool(elo.separated) if elo else False,
delta_cp=result.delta_cp,
diversity=result.diversity,
date=result.date,
run_id=result.run_id,
)

src/rukh/eval/report.pylíneas 61-89 · p2

WebRow es el contrato con la web. legality, a secas, es la del argmax: el nombre corto se lo lleva la definición del titular y la otra va con apellido, para que nadie lea la columna equivocada dentro de seis meses.

src/rukh/eval/report.py
def upsert_row(path: Path, row: WebRow) -> list[dict[str, Any]]:
"""Insert or replace ``row`` in the shared results file and return every row."""
path = Path(path)
rows: list[dict[str, Any]] = []
if path.is_file():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except ValueError:
payload = {}
found = payload.get("rows") if isinstance(payload, dict) else payload
if isinstance(found, list):
rows = [item for item in found if isinstance(item, dict)]
new = row.model_dump()
rows = [item for item in rows if item.get("stage") != row.stage]
rows.append(new)
rows.sort(key=lambda item: str(item.get("stage")))
path.parent.mkdir(parents=True, exist_ok=True)
document = {"updated_at": datetime.now(UTC).isoformat(timespec="seconds"), "rows": rows}
path.write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n"
)
return rows

src/rukh/eval/report.pylíneas 92-113 · p2

Si el fichero está corrupto, el except ValueError lo reemplaza en vez de tirar una evaluación que has pagado en horas de partidas. El newline="\n" evita que el fichero cambie de CRLF a LF según quién lo generó, que en Windows es un diff entero por nada.

src/rukh/eval/report.py
def _percent(value: float | None) -> str:
return "n/a" if value is None else f"{value * 100:.1f} %"
def elo_line(elo: EloResult | None) -> str:
"""The Elo cell: a point estimate with an interval, or with a one-sided bound."""
if elo is None:
return "n/a"
if not elo.separated and elo.ci_low is not None and elo.ci_high is not None:
return f"{elo.elo:.0f} (95 % CI {elo.ci_low:.0f}-{elo.ci_high:.0f})"
if elo.elo_lower is not None:
return f"> {elo.elo_lower:.0f} (one-sided 95 % bound; every game won)"
if elo.elo_upper is not None:
return f"< {elo.elo_upper:.0f} (one-sided 95 % bound; every game lost)"
return f"{elo.elo:.0f} (no interval)"

src/rukh/eval/report.pylíneas 116-130 · p2

elo_line escribe las tres situaciones (intervalo, cota inferior, cota superior) en palabras dentro de la celda, no en una leyenda al pie que nadie lee.

src/rukh/eval/report.py
def render_markdown(result: SuiteResult) -> str:
"""The human-readable report: headline numbers first, then every breakdown."""
argmax = result.legality_argmax.rate if result.legality_argmax else None
sampled = result.legality_sampled.rate if result.legality_sampled else None
sampling = result.legality_sampled
drawn = (
""
if sampling is None or sampling.temperature is None
else f" (T={sampling.temperature:g}"
+ ("" if sampling.top_k is None else f", top-k {sampling.top_k}")
+ ")"
)
lines: list[str] = [
f"# Evaluation of `{result.stage}`",
"",
f"- Suite: `{result.suite}`",
f"- Checkpoint: `{result.checkpoint}`",
f"- Weights SHA-256: `{result.model_sha}`",
f"- Parameters: {result.params:,}",
f"- Device: `{result.device}`",
f"- Date: {result.date}",
f"- MLflow run: {result.run_id or 'not tracked'}",
"",
"## Headline",
"",
"| Metric | Value |",
"|---|---|",
f"| Legality without the mask, argmax | {_percent(argmax)} |",
f"| Legality without the mask, sampled{drawn} | {_percent(sampled)} |",
f"| Top-1 next move | {_percent(result.accuracy.top1 if result.accuracy else None)} |",
f"| Top-3 next move | {_percent(result.accuracy.top3 if result.accuracy else None)} |",
f"| Puzzles solved | {_percent(result.puzzles.rate if result.puzzles else None)} |",
f"| Estimated Elo | {elo_line(result.elo)} |",
]
delta_cp = "n/a" if result.delta_cp is None else f"{result.delta_cp:.1f}"
diversity = "n/a" if result.diversity is None else f"{result.diversity:.3f}"
lines.extend(
[
f"| Mean centipawn loss | {delta_cp} |",
f"| Opening diversity | {diversity} |",
"",
]
)

src/rukh/eval/report.pylíneas 133-175 · p2

src/rukh/eval/report.py
if result.legality_argmax is not None or result.legality_sampled is not None:
lines.extend(
[
"## Legality",
"",
"Two rates, because they answer different questions. **argmax** is the share of "
"validation positions whose single most likely token is a legal move, with no "
"temperature, no top-k and no mask: it is a property of the weights and it is "
"the definition behind the \u2265 99 % bar of `GOAL.md`. **sampled** draws the "
"token exactly as the demo does" + drawn.strip() + ", so it is what a player "
"would meet with the mask switched off, and it is always the lower of the two.",
"",
"| Definition | Positions | Legal | Rate |",
"|---|---:|---:|---:|",
]
)
for measured in (result.legality_argmax, result.legality_sampled):
if measured is not None:
lines.append(
f"| {measured.mode} | {measured.positions} | {measured.legal} | "
f"{_percent(measured.rate)} |"
)
lines.append("")

src/rukh/eval/report.pylíneas 177-199 · p2

src/rukh/eval/report.py
if result.accuracy is not None and result.accuracy.bands:
lines.extend(
[
"## Next-move accuracy by Elo band",
"",
"| Band | Positions | Top-1 | Top-3 |",
"|---|---:|---:|---:|",
]
)
lines.extend(
f"| {band.band} | {band.positions} | {_percent(band.top1)} | {_percent(band.top3)} |"
for band in result.accuracy.bands
)
lines.append("")
if result.puzzles is not None and result.puzzles.bands:
lines.extend(
[
"## Puzzles by difficulty band",
"",
"| Band | Attempted | Solved | Rate |",
"|---|---:|---:|---:|",
]
)
lines.extend(
f"| {band.band} | {band.attempted} | {band.solved} | {_percent(band.rate)} |"
for band in result.puzzles.bands
)
lines.append("")

src/rukh/eval/report.pylíneas 200-227 · p2

src/rukh/eval/report.py
if result.elo is not None:
lines.extend(["## Games against Stockfish", ""])
if result.elo.rungs:
lines.extend(
[
"| Rung | Opponent Elo | Games | W | D | L | Score | Cut | Adjudicated |",
"|---|---:|---:|---:|---:|---:|---:|---:|---:|",
]
)
lines.extend(
f"| {rung.name} | {rung.opponent_elo} | {rung.games} | {rung.wins} | "
f"{rung.draws} | {rung.losses} | {rung.score:.3f} | {rung.cut} | "
f"{rung.adjudicated} |"
for rung in result.elo.rungs
)
lines.append("")
lines.extend(
[
f"{result.elo.cut} of {result.elo.games} games hit the context limit; "
f"{result.elo.adjudicated} of those were adjudicated on the final position "
"(shallow engine analysis, or the material count when no engine was available) "
"rather than scored as draws.",
"",
]
)
if result.elo.separated:
lines.extend(
[
"Every game went the same way, so the logistic fit has no maximum inside the "
"range of opponents and a bootstrap interval would be zero wide. The table "
"gives a one-sided 95 % likelihood bound instead: the rating is on that side "
"of the bound, and the games say nothing about how far.",
"",
]
)
if result.notes:
lines.extend(["## Notes", ""])
lines.extend(f"- {note}" for note in result.notes)
lines.append("")
return "\n".join(lines)

src/rukh/eval/report.pylíneas 228-267 · p2

src/rukh/eval/report.py
class ReportPaths(BaseModel):
"""Where a report landed."""
model_config = ConfigDict(extra="forbid")
markdown: str
results: str
web: str | None = None
def write_report(
result: SuiteResult, out_dir: Path, web_results: Path | None = None
) -> ReportPaths:
"""Write ``report.md`` and ``results.json`` and upsert the shared web row."""
directory = Path(out_dir) / result.stage
directory.mkdir(parents=True, exist_ok=True)
markdown = directory / REPORT_NAME
markdown.write_text(render_markdown(result), encoding="utf-8", newline="\n")
results = directory / RESULTS_NAME
results.write_text(
result.model_dump_json(indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
web: str | None = None
if web_results is not None:
upsert_row(Path(web_results), row_of(result))
web = Path(web_results).as_posix()
return ReportPaths(markdown=markdown.as_posix(), results=results.as_posix(), web=web)

src/rukh/eval/report.pylíneas 270-298 · p2

Casi todo es construir cadenas; lo que importa es qué se imprime siempre. Los titulares llevan las ocho métricas aunque cinco sean n/a, la legalidad lleva su definición con la temperatura real, y las partidas cortadas se cuentan aunque sean cero. Un informe que omite lo vacío te obliga a recordar qué debería haber; uno que imprime n/a se lee solo.

El paquete y las dos suites

Los reexportes del paquete y las dos suites en YAML son fontanería: quick usa una décima parte de las muestras de full, y la tabla se escribe desde full. Si lo saltas, tráelo: git checkout p2 -- src/rukh/eval/__init__.py configs/eval/full.yaml configs/eval/quick.yaml; sin esos tres ficheros, rukh eval no encuentra ni el paquete ni las suites.

// Fontaneríarukh/eval/__init__.py y las configuraciones full.yaml y quick.yaml
src/rukh/eval/__init__.py
"""Evaluation harness: legality, next-move accuracy, puzzles, Elo, cache and reporting."""
from rukh.eval.accuracy import AccuracyResult, BandAccuracy, accuracy, elo_band
from rukh.eval.cache import EvalCache, config_sha, file_sha
from rukh.eval.elo import (
DEFAULT_RUNGS,
EloResult,
EloRung,
GameRecord,
RungResult,
bootstrap_ci,
estimate,
fit_elo,
one_sided_bound,
play_rung,
play_rungs,
record_of,
score_of,
separation,
)
from rukh.eval.legality import (
LegalityResult,
Position,
board_of,
legality,
position_at,
sample_positions,
)
from rukh.eval.puzzles import (
PuzzleAttempt,
PuzzleItem,
PuzzleResult,
load_puzzles,
model_source,
run_puzzles,
solve_puzzle,
)
from rukh.eval.report import (
ReportPaths,
WebRow,
elo_line,
render_markdown,
row_of,
upsert_row,
write_report,
)
from rukh.eval.suite import (
EvalConfig,
SuiteResult,
evaluate,
hub_checkpoint,
is_hub_id,
load_suite,
resolve_model,
run_suite,
)

src/rukh/eval/__init__.pylíneas 1-56 · p2

src/rukh/eval/__init__.py
__all__ = [
"DEFAULT_RUNGS",
"AccuracyResult",
"BandAccuracy",
"EloResult",
"EloRung",
"EvalCache",
"EvalConfig",
"GameRecord",
"LegalityResult",
"Position",
"PuzzleAttempt",
"PuzzleItem",
"PuzzleResult",
"ReportPaths",
"RungResult",
"SuiteResult",
"WebRow",
"accuracy",
"board_of",
"bootstrap_ci",
"config_sha",
"elo_band",
"elo_line",
"estimate",
"evaluate",
"file_sha",
"fit_elo",
"hub_checkpoint",
"is_hub_id",
"legality",
"load_puzzles",
"load_suite",
"model_source",
"one_sided_bound",
"play_rung",
"play_rungs",
"position_at",
"record_of",
"render_markdown",
"resolve_model",
"row_of",
"run_puzzles",
"run_suite",
"sample_positions",
"score_of",
"separation",
"solve_puzzle",
"upsert_row",
"write_report",
]

src/rukh/eval/__init__.pylíneas 58-108 · p2

Reexportes: el nombre público es rukh.eval, y solo los tests importan los submódulos.

configs/eval/full.yaml
# Full evaluation suite (docs/spec/02, component 5): the numbers that go in the results table.
# Hours of Stockfish games; the SQLite cache makes a re-run cheap.
games: data/uci/year=2025/month=02/games.parquet
puzzles: data/puzzles/puzzles.parquet
puzzle_split: test
legality_positions: 10000
accuracy_positions: 10000
position_pool: 50000
puzzles_per_band: 2000
elo_games: 100
# 0.1 s per move: below that `UCI_Elo` is not calibrated at all (D-025).
elo_move_time: 0.1
bootstrap: 1000
temperature: 0.6
top_k: 20
block: 200
seed: 42
out_dir: artifacts/eval
web_results: artifacts/web/results.json
cache_db: artifacts/eval/cache.sqlite
# device: null -> CUDA when it is available (rukh.train.pick_device)
track: true

configs/eval/full.yamllíneas 1-22 · p2

configs/eval/quick.yaml
# Quick suite: the same metrics with a tenth of the work, for iterating on `tiny`.
# The numbers it produces are noisy on purpose; the table is written from `full`.
games: data/uci/year=2025/month=02/games.parquet
puzzles: data/puzzles/puzzles.parquet
puzzle_split: test
legality_positions: 1000
accuracy_positions: 1000
position_pool: 20000
puzzles_per_band: 200
elo_games: 20
# 0.1 s per move: below that `UCI_Elo` is not calibrated at all (D-025).
elo_move_time: 0.1
bootstrap: 500
temperature: 0.6
top_k: 20
block: 200
seed: 42
out_dir: artifacts/eval
web_results: artifacts/web/results.json
cache_db: artifacts/eval/cache.sqlite
# device: null -> CUDA when it is available (rukh.train.pick_device)
track: true

configs/eval/quick.yamllíneas 1-22 · p2

Las dos suites solo difieren en el tamaño de las muestras (quick usa una décima parte), y el comentario de quick.yaml evita el malentendido: sus números son ruidosos a propósito y la tabla se escribe desde full. Que pide 100 partidas por escalón, 800 en total: horas de partidas, y la razón de que exista la caché.

El comando

El subcomando rukh eval solo carga el modelo, llama a la suite y escribe los ficheros; se puede saltar en la primera lectura. Si lo saltas, tráelo: git checkout p2 -- src/rukh/cli.py (si ya trajiste el cli.py entero en la lección 3, lo tienes).

// Fontaneríacli.py: el subcomando rukh eval, con --model, --stage y --suite
src/rukh/cli.py
@app.command("eval")
def eval_cmd(
model: Annotated[
str,
typer.Option("--model", help="Checkpoint path or Hub id (owner/name) to evaluate."),
],
suite: Annotated[str, typer.Option("--suite", help="Suite name: full or quick.")] = "full",
config: Annotated[
Path | None,
typer.Option(
"--config", exists=True, dir_okay=False, readable=True, help="Suite YAML override."
),
] = None,
stage: Annotated[
str | None, typer.Option("--stage", help="Row name in the results table.")
] = None,
no_cache: Annotated[
bool, typer.Option("--no-cache", help="Recompute every game and puzzle.")
] = False,
device: Annotated[
str | None,
typer.Option("--device", help="Where to run: cuda, cpu... (default: the training device)."),
] = None,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,
) -> None:
"""Measure legality, next-move accuracy, puzzles and Elo, and write the report."""
from rukh.eval import load_suite, run_suite
from rukh.eval.report import elo_line
from rukh.eval.suite import SUITES, is_hub_id
if suite not in SUITES:
typer.echo(f"error: --suite must be one of {', '.join(SUITES)}", err=True)
raise typer.Exit(code=2)
if not Path(model).is_file() and not is_hub_id(model):
typer.echo(
f"error: --model {model!r} is neither an existing checkpoint nor a Hub id "
"of the form owner/name",
err=True,
)
raise typer.Exit(code=2)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
cfg = load_suite(suite, config)
if stage is not None:
cfg = cfg.model_copy(update={"stage": stage})
result, report = run_suite(model, cfg, suite=suite, use_cache=not no_cache, device=device)
if as_json:
typer.echo(result.model_dump_json(indent=2))
return
typer.echo(f"stage: {result.stage} ({result.params:,} parameters)")
typer.echo(f"suite: {result.suite}{' (cache off)' if no_cache else ''} on {result.device}")
if result.legality_argmax:
typer.echo(f"legality: {result.legality_argmax.rate:.4f} argmax, unmasked")
if result.legality_sampled:
typer.echo(f" {result.legality_sampled.rate:.4f} sampled, unmasked")
if result.accuracy:
typer.echo(f"accuracy: top1 {result.accuracy.top1:.4f} top3 {result.accuracy.top3:.4f}")
if result.puzzles:
typer.echo(f"puzzles: {result.puzzles.rate:.4f} solved")
if result.elo:
typer.echo(f"elo: {elo_line(result.elo)} over {result.elo.games} games")
for note in result.notes:
typer.echo(f"note: {note}")
typer.echo(f"report: {report.markdown}")
typer.echo(f"results: {report.results}")
if report.web:
typer.echo(f"table: {report.web}")

src/rukh/cli.pylíneas 431-496 · p2

--model acepta una ruta o un id del Hub y valida la forma antes de cargar nada. --stage nombra la fila, lo que permite evaluar los mismos pesos con dos muestreos y obtener dos filas comparables. Las notas se imprimen también en la consola: una advertencia que solo está en un fichero que nadie abre no avisa a nadie.

Lo que midió small

Primero, la suite rápida sobre tiny, que es el contraejemplo del módulo.

Terminal
uv run rukh eval --model checkpoints/tiny-<fecha>/best.pt --suite quick --stage tiny

Sin --stage, la fila se llamaría como la carpeta del checkpoint (tiny-20260919-061533) y el informe iría a artifacts/eval/tiny-20260919-061533/. El que escribió esa ejecución, artifacts/eval/tiny/report.md, real y sin recortar:

# Evaluation of `tiny`
- Suite: `quick`
- Checkpoint: `checkpoints/tiny-20260919-061533/best.pt`
- Weights SHA-256: `4591cf8cca0b91b38fc3c4969c7bbf18aa5d3b4b79b36558727b981c24df281a`
- Parameters: 5,309,952
- Device: `cuda`
- Date: 2026-09-19
- MLflow run: b1836b7e8934483693df84ff2f090a3d
## Headline
| Metric | Value |
| ---------------------------------------------------- | --------------------- |
| Legality without the mask, argmax | 94.5 % |
| Legality without the mask, sampled (T=0.6, top-k 20) | 93.8 % |
| Top-1 next move | 40.3 % |
| Top-3 next move | 67.1 % |
| Puzzles solved | n/a |
| Estimated Elo | 64 (95 % CI -200-292) |
| Mean centipawn loss | n/a |
| Opening diversity | n/a |
## Legality
Two rates, because they answer different questions. **argmax** is the share of validation positions whose single most likely token is a legal move, with no temperature, no top-k and no mask: it is a property of the weights and it is the definition behind the milestone's ≥ 99 % bar. **sampled** draws the token exactly as the demo does(T=0.6, top-k 20), so it is what a player would meet with the mask switched off, and it is always the lower of the two.
| Definition | Positions | Legal | Rate |
| ---------- | --------: | ----: | -----: |
| argmax | 1000 | 945 | 94.5 % |
| sampled | 1000 | 938 | 93.8 % |
## Next-move accuracy by Elo band
| Band | Positions | Top-1 | Top-3 |
| --------- | --------: | -----: | -----: |
| 1800-2000 | 543 | 38.9 % | 66.1 % |
| 2000-2200 | 328 | 45.4 % | 71.0 % |
| 2200-2400 | 97 | 33.0 % | 59.8 % |
| 2400-2600 | 28 | 32.1 % | 64.3 % |
| 2600+ | 4 | 50.0 % | 75.0 % |
## Games against Stockfish
| Rung | Opponent Elo | Games | W | D | L | Score | Cut | Adjudicated |
| -------- | -----------: | ----: | --: | --: | --: | ----: | --: | ----------: |
| skill-0 | 800 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| skill-1 | 950 | 20 | 0 | 1 | 19 | 0.025 | 0 | 0 |
| skill-2 | 1100 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| skill-3 | 1250 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-1320 | 1320 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-1500 | 1500 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-1800 | 1800 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-2000 | 2000 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
0 of 160 games hit the context limit; 0 of those were adjudicated on the final position (shallow engine analysis, or the material count when no engine was available) rather than scored as draws.
## Notes
- legality_argmax is the share of validation positions where the single most likely token is a legal move (no temperature, no top-k, no mask): this is the milestone's >= 99 % bar. legality_sampled draws the token the way the demo does (temperature 0.6, top-k 20) and is always the lower of the two.
- puzzles not found at …\rukh\data\puzzles\puzzles.parquet: puzzle suite skipped
- the Elo interval covers sampling noise only: the four `skill-*` rungs are nominal `Skill Level` anchors rather than measured ratings, and Stockfish plays at 0.1 s per move, far below any setting `UCI_Elo` is calibrated for

En tres minutos de GPU, tiny aprende bastante: top-1 del 40,3 % y un 94,5 % de argmax legales, muchísimo para un modelo que no ha visto un tablero. Y es insuficiente: una de cada dieciocho jugadas propuestas es imposible. Contra Stockfish, cero victorias en 160 partidas y un Elo de 64 con un intervalo de −200 a 292, tan ancho que solo dice «no gana nunca». Los puzles salen n/a porque el parquet no estaba en disco: el docstring de suite.py en funcionamiento.

tiny existe para eso: para que sepas de dónde viene el 99,4 % de small, y para descubrir un fallo de tubería en tres minutos en vez de en cuarenta.

small, la suite completa

La tirada de referencia no usó full.yaml tal cual, sino una copia recortada con el muestreo casi determinista. En p2 no está en el repo (llega en p3-elo-1200, con un campo más que p2 aún no conoce), así que créala tú en configs/eval/greedy.yaml:

# full.yaml, recortada: 1 000 posiciones, 20 partidas por escalón y muestreo casi determinista.
games: data/uci/year=2025/month=02/games.parquet
puzzles: data/puzzles/puzzles.parquet
puzzle_split: test
legality_positions: 1000
accuracy_positions: 1000
position_pool: 20000
puzzles_per_band: 2000
elo_games: 20
elo_move_time: 0.1
bootstrap: 500
temperature: 0.05
top_k: 1
block: 200
seed: 42
out_dir: artifacts/eval
web_results: artifacts/web/results.json
cache_db: artifacts/eval/cache-greedy.sqlite
track: false

--suite full sigue diciendo qué suite es (y es lo que imprime la salida); --config cambia los números con los que se ejecuta:

Terminal
uv run rukh eval --model checkpoints/small-<fecha>/best.pt --suite full --config configs/eval/greedy.yaml --stage small-greedy

Salida real de la ejecución de referencia (RTX 5090):

2026-09-19 10:00:22,143 evaluating checkpoints\small-20260919-062911\best.pt on cuda
stage: small-greedy (38,971,392 parameters)
suite: full on cuda
legality: 0.9940 argmax, unmasked
0.9940 sampled, unmasked
accuracy: top1 0.5110 top3 0.7940
puzzles: 0.0107 solved
elo: 1007 (95 % CI 920-1101) over 160 games
report: …/rukh/artifacts/eval/small-greedy/report.md
results: …/rukh/artifacts/eval/small-greedy/results.json
table: …/rukh/artifacts/web/results.json

Esa tirada se hizo con el código de unos commits antes de p2, y tu salida tendrá dos diferencias que no cambian ningún número. Entre elo: y report: salen además las líneas note:, que el eval_cmd de arriba imprime siempre (son las mismas notas del final del informe). Y en los dos informes de esta sección, donde pone «the milestone’s ≥ 99 % bar», el tuyo nombra en su lugar el documento de objetivos del autor, que no está en ningún repositorio: es el texto que llevan suite.py y report.py en p2, y lo que cuenta es el listón, el 99 %.

Cuatro cosas de esa salida antes de mirar los números.

stage: small-greedy son los mismos pesos evaluados con muestreo casi determinista (temperatura 0,05, top-k 1); con el muestreo de la demo (0,6 y top-k 20) salen la fila small. Con la escalera ya corregida (la sección siguiente cuenta por qué), la primera da 1359 de Elo y la segunda 1181. La temperatura vale unos 180 puntos de Elo, más que cualquier decisión de arquitectura del módulo: los mismos pesos cumplen el listón de 1200 jugando a lo seguro y no jugando como la demo. Un Elo es del par modelo más muestreo, no del fichero, y en un LLM de texto pasa lo mismo con cualquier benchmark.

La fila small, la del muestreo de la demo, es además la que lee la lección 9 al publicar: --repo rukh-small busca artifacts/eval/small/results.json. La suite rápida de p2 ya trae ese muestreo (temperatura 0,6 y top-k 20) con las mismas 1 000 posiciones y 20 partidas por escalón que greedy.yaml; solo resuelve menos puzles por banda. Evalúa los mismos pesos con ella y con el nombre de fila small:

Terminal
uv run rukh eval --model checkpoints/small-<fecha>/best.pt --suite quick --stage small

Son otras 160 partidas, unos treinta minutos. La caché no las confunde con las de small-greedy: su clave incluye la temperatura y el top-k.

over 160 games, no 800: es la copia recortada de arriba (1 000 posiciones, 20 partidas por escalón), de ahí un ±90 (±68 con la escalera corregida) y no el ±40 que darían 800. Dos evaluaciones solo se comparan con la misma configuración, y por eso el config entero viaja en results.json.

puzzles: 0.0107 es sobre todo el handicap de la lección anterior: en p2 el prompt es la línea del puzle con una cabecera fija de 1800, algo que el modelo no vio nunca. El informe de abajo reevalúa los mismos pesos con un prompt que reconstruye la partida real (Prompt: game-prefix, una línea que el report.py de p2 aún no imprime; llega en M3) y resuelven el 22,1 %. El 1 % medía el prompt más que el modelo.

Y legality sampled es igual que argmax porque temperatura 0,05 con top-k 1 es argmax con otro nombre.

El informe de la tirada con los puzles ya arreglados:

# Evaluation of `small-greedy`
- Suite: `full`
- Checkpoint: `checkpoints/small-20260919-062911/best.pt`
- Weights SHA-256: `7130d64ac1c409332dfd9b753281b38a7b0b434103eb1908533942e30c243f4e`
- Parameters: 38,971,392
- Device: `cuda`
- Date: 2026-09-19
- MLflow run: not tracked
## Headline
| Metric | Value |
| ---------------------------------------------------- | ----------------------- |
| Legality without the mask, argmax | 99.4 % |
| Legality without the mask, sampled (T=0.05, top-k 1) | 99.4 % |
| Top-1 next move | 51.1 % |
| Top-3 next move | 79.4 % |
| Puzzles solved | 22.1 % |
| Estimated Elo | 1007 (95 % CI 920-1101) |
| Mean centipawn loss | n/a |
| Opening diversity | n/a |
## Legality
| Definition | Positions | Legal | Rate |
| ---------- | --------: | ----: | -----: |
| argmax | 1000 | 994 | 99.4 % |
| sampled | 1000 | 994 | 99.4 % |
## Next-move accuracy by Elo band
| Band | Positions | Top-1 | Top-3 |
| --------- | --------: | -----: | ------: |
| 1800-2000 | 543 | 49.9 % | 78.5 % |
| 2000-2200 | 328 | 52.7 % | 79.9 % |
| 2200-2400 | 97 | 52.6 % | 82.5 % |
| 2400-2600 | 28 | 50.0 % | 78.6 % |
| 2600+ | 4 | 50.0 % | 100.0 % |
## Puzzles by difficulty band
Prompt: game-prefix.
| Band | Attempted | Solved | Rate |
| --------- | --------: | -----: | -----: |
| 1000-1500 | 2000 | 694 | 34.7 % |
| 1500-2000 | 2000 | 423 | 21.1 % |
| 2000+ | 2000 | 207 | 10.3 % |
## Games against Stockfish
| Rung | Opponent Elo | Games | W | D | L | Score | Cut | Adjudicated |
| -------- | -----------: | ----: | --: | --: | --: | ----: | --: | ----------: |
| skill-0 | 800 | 20 | 10 | 1 | 9 | 0.525 | 2 | 2 |
| skill-1 | 950 | 20 | 8 | 2 | 10 | 0.450 | 0 | 0 |
| skill-2 | 1100 | 20 | 2 | 0 | 18 | 0.100 | 0 | 0 |
| skill-3 | 1250 | 20 | 1 | 0 | 19 | 0.050 | 0 | 0 |
| uci-1320 | 1320 | 20 | 10 | 1 | 9 | 0.525 | 1 | 1 |
| uci-1500 | 1500 | 20 | 4 | 1 | 15 | 0.225 | 0 | 0 |
| uci-1800 | 1800 | 20 | 4 | 0 | 16 | 0.200 | 1 | 1 |
| uci-2000 | 2000 | 20 | 0 | 2 | 18 | 0.050 | 0 | 0 |
4 of 160 games hit the context limit; 4 of those were adjudicated on the final position (shallow engine analysis, or the material count when no engine was available) rather than scored as draws.

Qué dice:

  • Legalidad 99,4 % por argmax: 994 de 1 000 posiciones, sin máscara. El listón (≥ 99 %) se cumple.
  • Top-1 51,1 % y top-3 79,4 %: acierta la jugada humana una de cada dos veces. Por tramo de Elo apenas se mueve, porque imita la media de un corpus que es sobre todo 1800-2000.
  • Puzles 22,1 %, con la pendiente esperada: 34,7 %, 21,1 % y 10,3 % por dificultad. Hay que acertar la secuencia entera, y esa caída es la señal de que mide algo real.
  • Elo 1007 (IC 920-1101). El objetivo para small es ≥ 1200, así que faltaban casi doscientos puntos.

La escalera estaba torcida

Esa cifra era falsa, y la tabla de partidas llevaba la pista. El modelo puntúa 0,525 contra skill-0 y contra uci-1320, se hunde a 0,050 contra skill-3 y vuelve a 0,225 contra uci-1500. Esa no monotonía es imposible si los ocho escalones están en la misma escala: los skill-* son etiquetas puestas a mano en DEFAULT_RUNGS, no ratings medidos.

Medir el modelo no lo resuelve, porque el modelo es lo que se mide. Motor contra motor sí: se hicieron jugar los escalones entre ellos, 40 partidas por pareja, a 0,1 s por jugada.

Escalón Etiqueta que tenía Medido Error
skill-0 800 1381 +581
skill-1 950 1467 +517
skill-2 1100 1589 +489
skill-3 1250 1678 +428

Los cuatro estaban ahí para llegar por debajo del suelo de 1320, y ninguno llega. Con las etiquetas medidas, el mismo checkpoint, las mismas 160 partidas y el mismo ajuste dan 1359 Elo (IC 1293-1429). El listón de 1200 nunca se falló; la regla estaba torcida. La corrección (cuatro números de DEFAULT_RUNGS) llega con el tag p3-elo-1200 que usa M3; el bloque de arriba es el de p2 a propósito.

Lo que da confianza en el arreglo es un control: uci-1500 midió +179 sobre uci-1320, cuando lo nominal son +180. Si el procedimiento estuviera sesgado, ese número habría fallado. (Más arriba UCI_Elo se comprime: uci-1800 midió +215 sobre uci-1500, no +300, pero eso queda fuera del rango en el que jugamos.)

El intervalo del 95 % nunca habría avisado: cubre el ruido de 160 partidas, no el sesgo del banco de pruebas. Es la frase que dejamos apuntada en bootstrap_ci.

// Ejercicio 01¿Cuántas partidas necesitas para creerte una mejora de 40 Elo?

Con la fórmula de la varianza de la primera sección (var = 1 / (c² · Σ p(1−p)), c = 0,005756), calcula cuántas partidas cercanas al 50 % hacen falta para que la desviación típica del Elo baje a 20 puntos. Después: si comparas dos modelos, ¿basta con que sus intervalos del 95 % no se solapen?

// SoluciónVer la solución

Con p = 0,5, Σ p(1−p) = 0,25·n, así que var = 1 / (0,005756² · 0,25 n) = 1 / (8,28e-6 · n). Para una desviación de 20 hace falta var = 400, es decir n = 1 / (8,28e-6 · 400) ≈ 302 partidas. Para 10 puntos, unas 1 208: la precisión va con la raíz, así que dividir el error por dos cuesta cuatro veces más partidas.

No basta, y además es una prueba demasiado conservadora: dos intervalos pueden solaparse y la diferencia ser significativa. Lo correcto es estimar el intervalo de la diferencia, y mejor aún enfrentar los dos modelos entre sí: comparar A y B contra un tercero acumula el error de dos estimaciones. Por eso las listas de motores se construyen con torneos, y es lo que M5 acaba construyendo.

// Ejercicio 02Provoca una separación y mira qué publica el informe

Evalúa tiny contra un único escalón imposible: copia configs/eval/quick.yaml, déjale un solo elo_rungs con uci-2000 y baja elo_games a 6. tiny perderá las seis. ¿Qué imprime la celda de Elo del informe y qué campos trae results.json?

// SoluciónVer la solución

separation devuelve "losses", así que estimate no llama al bootstrap: pone separated: true y calcula one_sided_bound(..., lower=False). elo_line imprime < <cifra> (one-sided 95 % bound; every game lost) y el informe añade el párrafo que explica que la verosimilitud no tiene máximo dentro del rango de rivales. En results.json, ci_low y ci_high son null, elo_upper trae la cota y elo_separated es true; WebRow.elo_ci también queda null, que es lo que la tabla de la web necesita para no dibujar un intervalo inexistente.

Sin esa rama, el bootstrap habría devuelto mil veces el mismo recorte de SLACK y el informe habría publicado un intervalo de anchura cero, la forma más convincente que tiene un número de mentir.

// Ejercicio 03¿Cuánta legalidad te estás inventando con el muestreo de posiciones?

sample_positions, en src/rukh/eval/legality.py (lección 5), sortea el ply a lo largo de toda la partida. Primero mide la línea base de tu small con la suite rápida:

Terminal window
uv run rukh eval --model checkpoints/small-<fecha>/best.pt --suite quick --stage small-quick

Después cambia rng.randrange(MIN_PLY, len(moves)) por rng.randrange(MIN_PLY, min(21, len(moves))) —solo las diez primeras jugadas— y repite con --stage small-apertura. Compara las líneas legality: y accuracy: de las dos salidas: ¿sube o baja la legalidad? ¿Y el top-1? Deshaz el cambio al terminar. La segunda tirada es rápida: las partidas contra Stockfish no dependen de las posiciones y salen de la caché.

// SoluciónVer la solución

La legalidad sube: en apertura las jugadas legales aparecen a diario en el corpus y el modelo las tiene sobreentrenadas. La cifra no es comparable con el 99,4 % publicado.

El top-1 también sube, por otro motivo: en apertura las partidas humanas son repetitivas, así que adivinar lo que jugó el humano es más fácil. Los dos efectos apuntan al mismo lado, y eso hace que un muestreo sesgado parezca un modelo mejor en todas las columnas a la vez. Si suben todas las métricas de golpe sin tocar el modelo, mira primero la muestra.

Qué has aprendido

Cómo se convierte un puñado de partidas en un número con su incertidumbre, y tres formas de que ese número mienta: una partida cortada apuntada como tablas, una tirada separada con un intervalo simétrico y una escalera de rivales que nadie midió. Las dos primeras las resuelve el código; la tercera costó 350 puntos.

uv run rukh eval --model … --suite full escribe report.md, results.json y una fila de la tabla única. Para small: legalidad 99,4 % por argmax, top-1 51,1 %, top-3 79,4 %, puzles 22,1 % con el prompt arreglado y 1359 de Elo (IC 1293-1429) con los escalones medidos. Y la idea que te llevas a cualquier evaluación: el intervalo mide el ruido de tu muestra, nunca el sesgo de tu regla.

Lo siguiente es sacar el modelo de PyTorch: ONNX, dos cuantizaciones y una prueba de paridad que mide si el fichero que descarga el navegador elige la misma jugada.