rukh · lab

// M6 · lección 04

La tabla cada noche: doce etapas, un comando, una fecha

El orquestador que reconstruye la tabla única: recorre el catálogo de modelos publicados, trae de Hugging Face lo que falte, funde cada adaptador LoRA sobre su base, mide cada etapa con la misma configuración, deja constancia de qué fichero midió con qué hash y cuánto tardó, y sobrevive a que una etapa falle.

  • tiempo de trabajo110 min
  • nivel medio
  • actualizado el23 de septiembre de 2026

Qué vas a construir

rukh eval nightly: un comando que produce la tabla entera del proyecto, todas las etapas medidas con la misma configuración el mismo día.

Hasta aquí cada módulo midió lo suyo el día que cerró, con el harness de aquel día: doce filas de decoder y cuatro de encoder, cada una con su fecha y su configuración. Llamar a eso tabla es generoso, porque dos filas medidas con instrumentos distintos no se comparan.

El bucle es lo fácil. Lo difícil es que cada etapa se mide de forma distinta: el decoder con su suite, el encoder con otra, el Qwen3 afinado con la suya, Karvonen con la de la lección anterior, y dos filas son adaptadores de 1,6 MB que hay que fundir sobre su base antes de medirlos. Todo eso lo sabe el catálogo, y el nightly se limita a recorrerlo.

Lo que el fichero promete

src/rukh/eval/nightly.py
"""``rukh eval nightly``: the whole results table from the published models, with one command.
Twelve decoder rows and four encoder rows were written one ``rukh eval`` at a time over five
milestones, each with the config of its day. This walks the catalogue of ``rukh.hub`` -- every
model the course published, with the stage name its row carries and the suite that measures it
-- and evaluates each one under the **same** config, pulling from the Hub whatever is not on
disk. What the caches already hold is not replayed, so a nightly after a nightly is minutes;
the first one is hours of engine.
It writes three things: the rows of ``artifacts/web/results.json`` it measured (upserted by
stage, the rows it did not measure are left alone), ``docs/benchmarks.md`` rendered from the
whole table, and ``artifacts/eval/nightly.json``: which stage came from which checkpoint with
which sha, how long each took and what went wrong. A table is reproducible when the last file
says how it was made.
"""

src/rukh/eval/nightly.pylíneas 1-15 · p6

Una cuenta antes de seguir, porque el título y el docstring no dicen lo mismo. Las «doce filas de decoder y cuatro de encoder» del docstring son las que había antes de este hito, cada una de su día. Las doce etapas del título son las que el nightly mide cada vez: once filas de la tabla del decoder (las nueve del proyecto que juegan, el Qwen3 afinado y Karvonen) más una del encoder, encoder-v4. La tabla de la lección 8 tiene trece filas de decoder porque dos, small v1 y el DPO fuera de política, no están en el catálogo y se midieron a mano el mismo día.

La última frase del docstring es la tesis. Una tabla es reproducible porque existe el fichero que dice qué fichero exacto produjo cada fila, con qué hash, cuánto tardó y qué salió mal. El comando dice cómo se haría; nightly.json dice cómo se hizo, y las dos cosas dejan de coincidir en cuanto una etapa falla y se relanza a mano. Es el cuaderno de bitácora de M0 aplicado a la evaluación, y vale igual para la batería de pruebas que se lanza con cada versión de un LLM.

La otra decisión es un upsert por nombre de etapa: el nightly escribe las filas que midió y no toca las demás. Así se puede lanzar con --only sobre una etapa sin perder las otras once, y así se relanzó el baseline de Karvonen después de que fallara sin volver a jugar 1 760 partidas.

src/rukh/eval/nightly.py
NIGHTLY_FILE = "artifacts/eval/nightly.json"
BENCHMARKS_FILE = "docs/benchmarks.md"
MERGED_DIR = "artifacts/eval/merged"
KARVONEN = Artefact(
"karvonen-8l",
HF_REPO,
"decoder",
f"{LOCAL_DIR}/{CHECKPOINT_FILE}",
(),
"Adam Karvonen's 8-layer chess nanoGPT: the public baseline of the table",
stage="karvonen-8l",
measure="karvonen",
)
"""The one row of the table that is not the course's. It lives here and not in ``rukh.hub``
because the catalogue is what ``rukh pull``, the model cards and the Hub collection walk, and a
repository that is not ours must never be pulled as ours, carded or collected; ``nightly`` only
needs to measure it, so it is the only place that lists it."""

src/rukh/eval/nightly.pylíneas 33-50 · p6

Artefact es el tipo del catálogo de la lección 5: un modelo publicado con su repositorio, su ruta en disco, su nombre de etapa y la suite que lo mide.

El baseline de Karvonen no está en el catálogo; se declara aquí, el único sitio que necesita conocerlo. El catálogo es la lista de lo que rukh pull descarga como nuestro, rukh publish cards documenta y rukh publish collection mete en la colección del Hub. Un repositorio ajeno ahí dentro acabaría, tres funciones más allá, con una card nuestra encima del modelo de otro.

El registro de lo que pasó

El registro es un modelo de datos de cinco campos útiles; se puede saltar en la primera lectura. Si no lo tecleas, trae el fichero entero antes de seguir: git checkout p6 -- src/rukh/eval/nightly.py.

// FontaneríaNightlyRecord: el registro de cada etapa, con sus dos hashes y su estado
src/rukh/eval/nightly.py
class NightlyRecord(BaseModel):
"""One stage of the run: what was measured, from what, and how it went."""
model_config = ConfigDict(extra="forbid")
name: str
stage: str
measure: str
checkpoint: str | None = None
model_sha: str | None = None
weights_sha: str | None = None
seconds: float = 0.0
status: str = "planned"
"""``planned`` (dry run), ``measured``, ``skipped`` or ``failed``."""
detail: str | None = None
class NightlyReport(BaseModel):
"""What one nightly did; serialised as ``artifacts/eval/nightly.json``."""
model_config = ConfigDict(extra="forbid")
date: str
config: str
encoder_config: str | None
records: list[NightlyRecord]
results: str
benchmarks: str | None
seconds: float

src/rukh/eval/nightly.pylíneas 53-81 · p6

name es como se llama el artefacto en el catálogo (tiny, lora-e4), lo que se escribe en rukh pull; stage es el nombre de la fila (tiny-greedy), con el sufijo del muestreo porque un Elo es del par modelo y muestreo. Los dos hashes van juntos: model_sha identifica el fichero que se abrió en esta máquina y weights_sha los pesos, que es lo que la card afirma.

status es una cadena con cuatro valores documentados y no un Enum, porque el JSON se lee desde un notebook y desde la web, y un Enum obligaría a sincronizar el vocabulario en dos sitios.

El plan

src/rukh/eval/nightly.py
def plan(only: set[str] | None = None) -> list[Artefact]:
"""The catalogue entries a nightly measures, in course order."""
wanted = [artefact for artefact in (*catalogue(), KARVONEN) if artefact.measure != "none"]
if only:
wanted = [a for a in wanted if a.name in only or (a.stage or "") in only]
return wanted

src/rukh/eval/nightly.pylíneas 84-89 · p6

Tres decisiones:

  • measure != "none" deja fuera lo que esta suite no mide: los datasets, el tokenizador, el reward model y el encoder de preentrenamiento. Como esa propiedad vive en el catálogo, añadir un artefacto obliga a decir cómo se mide o que no se mide.
  • --only acepta el nombre del artefacto o el de la etapa, porque en la terminal escribes lo que acabas de leer, y en la tabla pone tiny-greedy y en rukh pull, tiny.
  • El orden es el del catálogo, que es el del curso.

Cuatro suites detrás de una firma

Cuatro adaptadores de una línea cada uno, para que el bucle no sepa qué suite mide; fontanería que se puede saltar (si no la tecleas, git checkout p6 -- src/rukh/eval/nightly.py).

// FontaneríaLas cuatro suites (decoder, encoder, Qwen, Karvonen) detrás de una misma firma
src/rukh/eval/nightly.py
def _run_decoder(checkpoint: Path, config: Path | None, stage: str, use_cache: bool, device):
from rukh.eval.suite import load_suite, run_suite
cfg = load_suite("full", config).model_copy(update={"stage": stage})
return run_suite(checkpoint, cfg, suite="full", use_cache=use_cache, device=device)
def _run_encoder(checkpoint: Path, config: Path | None, stage: str, use_cache: bool, device):
from rukh.eval.encoder import load_encoder_suite, run_encoder_suite
cfg = load_encoder_suite(config).model_copy(update={"stage": stage})
return run_encoder_suite(checkpoint, cfg, use_cache=use_cache, device=device)
def _run_qwen(adapter_dir: Path, config: Path | None, stage: str, use_cache: bool, device):
from rukh.eval.qwen_suite import run_qwen_suite
from rukh.eval.suite import load_suite
cfg = load_suite("full", config)
return run_qwen_suite(adapter_dir, cfg, stage=stage, use_cache=use_cache, device=device)
def _run_karvonen(checkpoint: Path, config: Path | None, stage: str, use_cache: bool, device):
from rukh.eval.karvonen_suite import run_karvonen_suite
from rukh.eval.suite import load_suite
cfg = load_suite("full", config)
return run_karvonen_suite(
cfg, stage=stage, use_cache=use_cache, device=device, checkpoint=checkpoint
)

src/rukh/eval/nightly.pylíneas 92-121 · p6

Cuatro funciones con la misma firma y cuerpos distintos: la capa de adaptación que permite que el bucle no sepa nada de suites. Cada una importa lo suyo dentro de la función, así que rukh eval nightly --dry-run no importa torch ni transformers e imprime el plan en un segundo.

El decoder y el encoder toman el nombre de la etapa de la configuración (model_copy(update={"stage": stage})), como desde M2; Qwen y Karvonen lo reciben como parámetro. Igualarlo exigiría cambiar una convención en cuatro ficheros, y la capa de adaptación está para absorber esta clase de diferencia. El retorno va sin anotar porque cada suite devuelve su tipo y el bucle tira el valor.

Fundir un adaptador antes de medirlo

Dos filas de la tabla son adaptadores LoRALoRALow-Rank Adaptation: en vez de modificar una matriz de pesos W, se aprende una corrección B·A de rango r (A de r × d, B de d × r, con B inicializada a cero para que el modelo adaptado sea el base en el paso 0) y se suma a W escalada por alpha/r. Entrena pocos parámetros —en medium, 393 216 con r = 8 sobre consulta y valor, el 0,34 %—, se guarda como un adaptador de 1,6 MB y se puede cambiar en caliente. QLoRA hace lo mismo sobre un modelo base cuantizado a 4 bits. de M4: 1,6 MB de factores que no miden nada por sí solos.

src/rukh/eval/nightly.py
def merged_checkpoint(base: Path, adapter_dir: Path, out: Path) -> Path:
"""The base with the adapter folded in, as an ordinary checkpoint the suite can open.
A style adapter is 1.6 MB of factors and measures nothing on its own; the row of the table
is the base *with* it. ``load_adapter`` wraps the adapted matrices and reads the factors,
``merge_lora`` folds them back into plain ``nn.Linear`` weights, and what is saved has the
state dict of a normal decoder -- the same thing the LoRA training run wrote as ``best.pt``.
"""
from rukh.models.lora import ADAPTER_FILE, load_adapter, merge_lora
from rukh.train.checkpoint import load_model, save_checkpoint
model, payload = load_model(base)
load_adapter(model, adapter_dir / ADAPTER_FILE)
merge_lora(model)
out.parent.mkdir(parents=True, exist_ok=True)
return save_checkpoint(
out,
step=int(payload.get("step") or 0),
model=model,
optimizer=None,
cfg={"merged_from": base.as_posix(), "adapter": adapter_dir.as_posix()},
model_cfg=payload["model_cfg"],
vocab_hash=payload.get("vocab_hash"),
data_manifest_sha=payload.get("data_manifest_sha"),
git_sha=payload.get("git_sha"),
)

src/rukh/eval/nightly.pylíneas 124-149 · p6

Se funde y se guarda en vez de enseñar a la suite a cargar un adaptador, por dos motivos. El camino con adaptador calcula Wx + BAx en dos multiplicaciones, que no da los mismos bits que la matriz fundida: la fila mediría una ruta de código que nadie usa en producción. Y el checkpoint fundido es un fichero que se puede abrir después para investigar una fila rara.

cfg={"merged_from": ..., "adapter": ...} deja escrito dentro del fichero de dónde salió, y vocab_hash, data_manifest_sha y git_sha se copian de la base: sin esa cadena, artifacts/eval/merged/lora-e4.pt sería un checkpoint anónimo de 150 MB.

Dónde está cada cosa

Encontrar o descargar el checkpoint de cada etapa es fontanería; se puede saltar en la primera lectura. Sin ella el bucle no tiene qué medir: si no la tecleas, git checkout p6 -- src/rukh/eval/nightly.py.

// Fontanería_target: el baseline, resolve_run y la descarga que falta, en ese orden
src/rukh/eval/nightly.py
def _checkpoint_of(artefact: Artefact, pull_missing: bool) -> Path:
"""Where the artefact is on disk, pulling it from the Hub when it is not and allowed."""
if artefact.measure == "karvonen":
from rukh.eval.karvonen import ensure_karvonen
return ensure_karvonen(download=pull_missing)
target = resolve_run(paths.resolve(artefact.target))
if not target.exists() and pull_missing:
target = pull(artefact)
if not target.exists():
raise FileNotFoundError(
f"{artefact.name}: {target} is not on disk (rukh pull {artefact.name})"
)
if artefact.base is not None:
base = resolve_run(paths.resolve(artefact.base))
if not base.exists() and pull_missing:
from rukh.hub import lookup
base = pull(lookup("medium-v4"))
if not base.exists():
raise FileNotFoundError(f"{artefact.name}: base {base} is not on disk")
return merged_checkpoint(base, target, paths.resolve(MERGED_DIR) / f"{artefact.name}.pt")
return target

src/rukh/eval/nightly.pylíneas 152-174 · p6

Tres caminos, y el orden importa:

  • El baseline sale primero porque no se descarga como los nuestros: ensure_karvonen sabe de qué repositorio ajeno viene.
  • resolve_run (lección 6) convierte checkpoints/medium-v4/best.pt en el medium-v4-20260919-174623/best.pt de quien entrenó el modelo, y se aplica antes de decidir si hay que descargar: quien entrenó tiny no vuelve a bajarlo del Hub.
  • El FileNotFoundError lleva dentro el comando que lo arregla, rukh pull {name}. Un error que alguien leerá al día siguiente en un JSON tiene que decir qué hacer.

La rama de artefact.base es la del adaptador, y tiene un atajo que hay que ver escrito: si la base no está, descarga medium-v4 por su nombre literal. Los dos adaptadores del catálogo van sobre medium-v4, pero el día que haya uno sobre small-v3 esta línea traerá el modelo equivocado y la fila medirá otra cosa sin fallar.

El bucle

src/rukh/eval/nightly.py
def run_nightly(
config: Path | None = None,
encoder_config: Path | None = None,
only: set[str] | None = None,
dry_run: bool = False,
pull_missing: bool = True,
use_cache: bool = True,
device: str | None = None,
results: Path | None = None,
benchmarks: Path | None = None,
out: Path | None = None,
) -> NightlyReport:
"""Measure every catalogued stage under one config and rewrite the table around them."""
from rukh.eval.cache import file_sha, weights_sha
from rukh.eval.report import write_benchmarks
started = time.perf_counter()
records: list[NightlyRecord] = []
for artefact in plan(only):
record = NightlyRecord(
name=artefact.name, stage=artefact.stage or artefact.name, measure=artefact.measure
)
records.append(record)
if dry_run:
record.checkpoint = paths.resolve(artefact.target).as_posix()
continue
tick = time.perf_counter()
try:
checkpoint = _checkpoint_of(artefact, pull_missing)
record.checkpoint = checkpoint.as_posix()
if checkpoint.is_file():
record.model_sha = file_sha(checkpoint)
record.weights_sha = weights_sha(checkpoint)
log.info("nightly: %s as %s from %s", artefact.name, record.stage, checkpoint)
if artefact.measure == "decoder":
_run_decoder(checkpoint, config, record.stage, use_cache, device)
elif artefact.measure == "encoder":
_run_encoder(checkpoint, encoder_config, record.stage, use_cache, device)
elif artefact.measure == "qwen":
_run_qwen(checkpoint, config, record.stage, use_cache, device)
elif artefact.measure == "karvonen":
_run_karvonen(checkpoint, config, record.stage, use_cache, device)
record.status = "measured"
except Exception as exc: # noqa: BLE001 - one stage failing must not lose the others
log.exception("nightly: %s failed", artefact.name)
record.status = "failed"
record.detail = f"{type(exc).__name__}: {exc}"
record.seconds = round(time.perf_counter() - tick, 1)

src/rukh/eval/nightly.pylíneas 177-224 · p6

records.append(record) va antes del try: el registro entra en la lista con estado planned y las ramas de abajo lo mutan, así que ni una excepción ni el continue de --dry-run pueden producir un informe al que le falte una etapa.

El except Exception con su noqa es el correcto aquí. Una tirada de tres horas no puede morir porque la novena etapa tenga un problema: apunta el fallo en su fila y sigue. No se traga nada: log.exception deja la traza entera y detail guarda tipo y mensaje en el JSON. Esa rama se ejecutó en el hito: el baseline de Karvonen falló con ValueError: … holds no model_state to hash (su checkpoint guarda los tensores bajo model), y las once etapas ya medidas, dos horas y media de motor, se salvaron.

El hash solo se calcula si el objetivo es un fichero, porque el Qwen3 afinado es un directorio. Y los segundos por etapa convierten nightly.json en un presupuesto: el encoder tarda siete segundos y cada decoder catorce minutos, y de ahí sale qué se puede relanzar.

Lo que queda del bucle escribe los ficheros y la salida en pantalla; se puede saltar (si no lo tecleas, git checkout p6 -- src/rukh/eval/nightly.py).

// FontaneríaReescribir benchmarks.md y la salida legible del nightly
src/rukh/eval/nightly.py
results_path = results or paths.resolve("artifacts/web/results.json")
benchmarks_path: Path | None = None
if not dry_run and results_path.is_file():
benchmarks_path = benchmarks or paths.resolve(BENCHMARKS_FILE)
if benchmarks_path.is_file():
write_benchmarks(results_path, benchmarks_path)
else:
benchmarks_path = None
report = NightlyReport(
date=datetime.now(UTC).date().isoformat(),
config=(config or Path("configs/eval/full.yaml")).as_posix(),
encoder_config=encoder_config.as_posix() if encoder_config else None,
records=records,
results=results_path.as_posix(),
benchmarks=benchmarks_path.as_posix() if benchmarks_path else None,
seconds=round(time.perf_counter() - started, 1),
)
if not dry_run:
target = out or paths.resolve(NIGHTLY_FILE)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8", newline="\n")
return report

src/rukh/eval/nightly.pylíneas 226-247 · p6

docs/benchmarks.md se reescribe solo si ya existe; si no, el informe dice benchmarks: not rewritten. Ese documento tiene una cabecera escrita a mano y una sección marcada para la tabla, y crearlo daría una tabla sin nada alrededor. newline="\n" evita que Windows escriba CRLF y el JSON cambie de bytes en cada máquina.

src/rukh/eval/nightly.py
def render_plan(report: NightlyReport) -> str:
"""One line per stage, for the terminal."""
lines = [f"{'stage':<32}{'measure':<9}{'status':<10}{'seconds':>8} checkpoint"]
for record in report.records:
lines.append(
f"{record.stage:<32}{record.measure:<9}{record.status:<10}{record.seconds:>8.1f} "
f"{record.checkpoint or '-'}" + (f" ({record.detail})" if record.detail else "")
)
return "\n".join(lines)

src/rukh/eval/nightly.pylíneas 250-258 · p6

La salida legible sale del mismo objeto que se serializa, así que no puede decir otra cosa que el JSON. El detalle del fallo va en la misma línea de la etapa, entre paréntesis: doce líneas se leen de una pasada, y un bloque de errores al final obliga a cruzar dos listas.

La marca de «externo» en el documento

docs/benchmarks.md se renderiza desde la tabla entera, y este hito le añadió que se vea qué filas no son nuestras.

El cambio en el renderizador del documento es formato, y se puede saltar. Si no lo tecleas, git checkout p6 -- src/rukh/eval/report.py (si ya lo trajiste en la lección 3, ya lo tienes).

// Fontaneríareport.py: la marca de externo junto al nombre de la etapa y su pie
src/rukh/eval/report.py
for row in playing:
lines.append(
"| "
+ " | ".join(
[
f"`{row.get('stage')}`" + (" (externo)" if row.get("baseline") else ""),
_percent(row.get("legality")),
_percent(row.get("top1")),
_percent(row.get("top3")),
_band(row, "puzzles", "1000-1500"),
_band(row, "puzzles", "1500-2000"),
_band(row, "puzzles", "2000+"),
_elo_cell(row),
"n/a" if row.get("delta_cp") is None else f"{row['delta_cp']:.1f}",
"n/a"
if row.get("first_move_entropy") is None
else f"{row['first_move_entropy']:.4f}",
str(row.get("date") or ""),
]
)
+ " |"
)

src/rukh/eval/report.pylíneas 320-341 · p6

La marca va pegada al nombre de la etapa, no en una columna propia: con once columnas ya, sobraría una más, y la marca tiene que viajar con el nombre cuando alguien copie una fila. row.get('baseline') devuelve None en las filas antiguas, que no tienen la clave.

src/rukh/eval/report.py
lines.append("")
external = sum(1 for row in playing if row.get("baseline"))
lines.append(
f"{len(playing)} etapas medidas con la misma suite"
+ (
f", {external} de ellas externas (modelos de otros autores, marcados como "
"`(externo)`, medidos con este mismo harness como control)"
if external
else ""
)
+ ". Las filas del encoder viven aparte porque no comparten una sola columna con "
"estas; las que una medición retiró no están (`rukh eval drop`)."
)

src/rukh/eval/report.pylíneas 342-354 · p6

El pie explica la marca donde está la marca, y la frase sobre las externas solo aparece si hay alguna. Está en español porque docs/benchmarks.md se lee en español; el resto del repositorio está en inglés.

El subcomando

El subcomando es la CLI de siempre; se puede saltar en la primera lectura, pero sin él no hay rukh eval nightly. Si no lo tecleas, copia la función eval_nightly_cmd, con su decorador, de la salida de git show p6:src/rukh/cli.py a tu cli.py.

// Fontaneríarukh eval nightly: el subcomando, --only y el código de salida
src/rukh/cli.py
@eval_app.command("nightly")
def eval_nightly_cmd(
config: Annotated[
Path | None,
typer.Option(
"--config",
exists=True,
dir_okay=False,
help="Suite YAML every decoder is measured with (default: configs/eval/greedy.yaml).",
),
] = None,
encoder_config: Annotated[
Path | None,
typer.Option("--encoder-config", exists=True, dir_okay=False, help="Encoder suite YAML."),
] = None,
only: Annotated[
str | None, typer.Option("--only", help="Comma-separated names or stages to measure.")
] = None,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="List what would be measured and exit.")
] = False,
no_pull: Annotated[
bool, typer.Option("--no-pull", help="Never download; a missing checkpoint fails.")
] = False,
no_cache: Annotated[bool, typer.Option("--no-cache", help="Replay every game.")] = False,
device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,
) -> None:
"""Measure every published stage under one config and rewrite the whole table.
The catalogue of `rukh pull` says which stage each model is and how it is measured; what the
caches hold is not replayed, so a nightly after a nightly costs minutes. `artifacts/eval/
nightly.json` records what came from where, with which sha, and how long it took.
"""
from rukh.eval.nightly import render_plan, run_nightly
from rukh.paths import resolve
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
wanted = {part.strip() for part in (only or "").split(",") if part.strip()} or None
report = run_nightly(
config=config or resolve("configs/eval/greedy.yaml"),
encoder_config=encoder_config,
only=wanted,
dry_run=dry_run,
pull_missing=not no_pull,
use_cache=not no_cache,
device=device,
)
typer.echo(render_plan(report))
if not dry_run:
typer.echo(f"results: {report.results}")
typer.echo(f"benchmarks: {report.benchmarks or 'not rewritten'}")
typer.echo(f"seconds: {report.seconds:.0f}")
if any(record.status == "failed" for record in report.records):
raise typer.Exit(code=1)

src/rukh/cli.pylíneas 862-915 · p6

La configuración por defecto es greedy.yaml: todas las filas de decoder se comparan a temperatura 0,05 y top_k 1, de ahí el sufijo -greedy. --only separa por comas con strip(), para que --only "tiny, rm" encuentre rm.

El comando sale con código 1 si alguna etapa falló, así que un script o una tarea programada sabe si la tabla está completa sin leer la salida. Y aun así escribe results.json, benchmarks.md y nightly.json con lo que sí midió.

Los tests: el catálogo manda, y el informe lo cuenta

Los tests fijan las promesas del orquestador. Llegaron con el git checkout del principio: se leen, no se teclean.

// Teststests/unit/test_nightly.py: toda etapa publicada tiene fila, el plan en seco y la tirada con fallos
tests/unit/test_nightly.py
"""Tests for rukh.eval.nightly: the catalogue drives the run, and the run says what it did."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import torch
from typer.testing import CliRunner
from rukh.cli import app
from rukh.eval import nightly
from rukh.hub import catalogue
from rukh.models import DecoderConfig, MoveDecoder
from rukh.train import load_model, save_checkpoint
pytestmark = pytest.mark.unit
TOY = DecoderConfig(vocab_size=64, n_layer=1, n_head=2, d_model=16, block=32)
def test_every_published_model_the_table_cites_is_in_the_plan() -> None:
"""The stages of `docs/benchmarks.md` that a reader can pull are all measured by nightly."""
stages = {a.stage for a in nightly.plan()}
assert stages >= {
"tiny-greedy",
"small-v3-greedy",
"medium-v4-greedy",
"medium-elo",
"medium-masters",
"lora-e4",
"lora-d4",
"qwen3-pgn-qlora",
"medium-v4-dpo-onpolicy-greedy",
"medium-v4-grpo-greedy",
"encoder-v4",
}
# Nothing without a way to measure it: datasets, the reward model, the pretraining encoder.
assert all(a.measure != "none" for a in nightly.plan())
assert {a.name for a in catalogue() if a.measure == "none"} >= {"rm", "encoder-mmm-v4"}
assert [a.name for a in nightly.plan({"tiny", "encoder-v4"})] == ["tiny", "encoder-v4"]
assert [a.stage for a in nightly.plan({"medium-elo"})] == ["medium-elo"]

tests/unit/test_nightly.pylíneas 1-43 · p6

Este primer test impide que un modelo publicado se quede sin fila. La aserción es >=, para que añadir una etapa no lo rompa; lo que prohíbe es quitar una. Las dos últimas líneas convierten en contrato que --only acepte nombre y etapa.

tests/unit/test_nightly.py
def _toy(path: Path, seed: int = 0) -> Path:
torch.manual_seed(seed)
return save_checkpoint(
path, step=1, model=MoveDecoder(TOY), optimizer=None, cfg={}, model_cfg=TOY.model_dump()
)
def test_a_dry_run_lists_the_plan_and_writes_nothing(rukh_home: Path) -> None:
report = nightly.run_nightly(dry_run=True, only={"tiny", "encoder-v4"})
assert [r.status for r in report.records] == ["planned", "planned"]
assert not (rukh_home / "artifacts" / "eval" / "nightly.json").exists()
assert nightly.render_plan(report).count("planned") == 2

tests/unit/test_nightly.pylíneas 46-57 · p6

«No escribe nada» se comprueba mirando que el fichero no existe, no que no ha cambiado.

tests/unit/test_nightly.py
def test_the_run_measures_in_catalogue_order_and_records_what_it_did(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
measured: list[tuple[str, str]] = []
def fake_decoder(checkpoint, config, stage, use_cache, device): # noqa: ANN001
measured.append((stage, Path(checkpoint).name))
return None
def fake_encoder(checkpoint, config, stage, use_cache, device): # noqa: ANN001
measured.append((stage, Path(checkpoint).name))
return None
monkeypatch.setattr(nightly, "_run_decoder", fake_decoder)
monkeypatch.setattr(nightly, "_run_encoder", fake_encoder)
monkeypatch.setattr(
nightly, "_run_qwen", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no gpu"))
)
# `tiny` under its stamped run folder: the stable name resolves to it.
_toy(rukh_home / "checkpoints" / "tiny-20260919-060101" / "best.pt")
_toy(rukh_home / "checkpoints" / "encoder-heads-v4" / "step-4000.pt")
(rukh_home / "checkpoints" / "qwen3-pgn-qlora").mkdir(parents=True)
results = rukh_home / "artifacts" / "web" / "results.json"
results.parent.mkdir(parents=True)
results.write_text(json.dumps({"rows": [{"stage": "kept", "params": 1}]}), encoding="utf-8")
benchmarks = rukh_home / "docs" / "benchmarks.md"
benchmarks.parent.mkdir(parents=True)
benchmarks.write_text("# B\n\ntexto\n\n## Resultados\n\nold\n", encoding="utf-8")
report = nightly.run_nightly(
only={"tiny", "encoder-v4", "qwen3-pgn-qlora", "medium-v4"},
pull_missing=False,
results=results,
benchmarks=benchmarks,
)
assert measured == [("tiny-greedy", "best.pt"), ("encoder-v4", "step-4000.pt")]
by_name = {r.name: r for r in report.records}
assert by_name["tiny"].status == "measured" and by_name["tiny"].model_sha
assert by_name["tiny"].checkpoint.endswith("tiny-20260919-060101/best.pt")
assert by_name["qwen3-pgn-qlora"].status == "failed"
assert "no gpu" in (by_name["qwen3-pgn-qlora"].detail or "")
# A model that is not on disk and may not be pulled fails on its own, not the run.
assert by_name["medium-v4"].status == "failed"
assert "rukh pull medium-v4" in (by_name["medium-v4"].detail or "")
# The rows nightly did not measure are untouched; the benchmarks doc was rewritten.
assert json.loads(results.read_text(encoding="utf-8"))["rows"] == [
{"stage": "kept", "params": 1}
]
assert "texto" in benchmarks.read_text(encoding="utf-8")
assert "old" not in benchmarks.read_text(encoding="utf-8")
written = json.loads((rukh_home / "artifacts" / "eval" / "nightly.json").read_text("utf-8"))
assert [r["name"] for r in written["records"]] == [
"tiny",
"medium-v4",
"encoder-v4",
"qwen3-pgn-qlora",
]

tests/unit/test_nightly.pylíneas 60-116 · p6

Es el test más largo y comprueba seis propiedades de la misma ejecución, que es lo que importa: que ocurren juntas. Solo se miden las etapas medibles y en orden de catálogo; tiny se resuelve a su carpeta con marca de tiempo; un fallo de la suite se apunta con su mensaje y no mata la tirada; un modelo que no se puede descargar falla solo con el comando que lo arregla; las filas no medidas siguen ahí; y la sección marcada de benchmarks.md se reescribe sin tocar el texto de alrededor.

La quinta es la más fácil de romper: sin ella, un --only destruiría la tabla. El (_ for _ in ()).throw(...) es el truco para lanzar una excepción desde una lambda; feo, y ahorra una función falsa.

tests/unit/test_nightly.py
def test_an_adapter_is_merged_onto_its_base_before_it_is_measured(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from rukh.models.lora import LoraConfig, apply_lora, save_adapter
base = _toy(rukh_home / "checkpoints" / "medium-v4" / "best.pt")
torch.manual_seed(1)
model = MoveDecoder(TOY)
model.load_state_dict(load_model(base)[0].state_dict())
apply_lora(model, LoraConfig(r=2, alpha=4, targets=["q", "v"]))
with torch.no_grad():
for name, parameter in model.named_parameters():
if ".b." in name:
parameter.add_(0.5)
adapter_dir = rukh_home / "checkpoints" / "lora-e4"
adapter_dir.mkdir(parents=True)
save_adapter(
model, adapter_dir / "adapter.safetensors", LoraConfig(r=2, alpha=4, targets=["q", "v"])
)
seen: dict[str, Path] = {}
monkeypatch.setattr(
nightly, "_run_decoder", lambda ckpt, *a, **k: seen.__setitem__("ckpt", Path(ckpt))
)
report = nightly.run_nightly(only={"lora-e4"}, pull_missing=False)
assert report.records[0].status == "measured"
merged, payload = load_model(seen["ckpt"])
assert payload["cfg"]["adapter"].endswith("lora-e4")
plain, _ = load_model(base)
# The merged weights differ from the base exactly where the adapter pushed.
assert not torch.equal(merged.blocks[0].attn.qkv.weight, plain.blocks[0].attn.qkv.weight)
assert not any(".lora" in k or ".a." in k for k in merged.state_dict())

tests/unit/test_nightly.pylíneas 119-150 · p6

El parameter.add_(0.5) sobre las matrices b hace el test significativo. LoRA inicializa B a ceros, así que un adaptador recién creado no cambia nada al fundirse, y el test pasaría con un merge_lora que no hiciera nada. La última línea comprueba que el checkpoint fundido no conserva ninguna clave de LoRA: la suite abre un decoder normal.

tests/unit/test_nightly.py
def test_the_cli_dry_run_prints_the_plan(rukh_home: Path) -> None:
result = CliRunner().invoke(app, ["eval", "nightly", "--dry-run", "--only", "tiny,rm"])
assert result.exit_code == 0, result.output
assert "tiny-greedy" in result.output and "planned" in result.output
assert "rm" not in result.output.replace("tiny-greedy", "")

tests/unit/test_nightly.pylíneas 153-157 · p6

--only tiny,rm pide dos cosas y solo una es medible; la última aserción comprueba que rm no sale.

Ni este fichero ni rukh eval nightly --dry-run se pueden ejecutar todavía: nightly.py importa rukh.hub (lección 5) y resolve_run (lección 6). La caja «Ejecútalo» del final de la lección 6 los pasa con el resto de tests del hito.

// Ejercicio 01Una etapa nueva en la tabla

Supón que M7 publica medium-v5. Enumera qué hay que tocar para que salga en la tabla del nightly, y qué pasa si te olvidas de cada cosa.

// SoluciónVer la solución

Solo una: la entrada del catálogo en rukh.hub, con su target, su stage y su measure. Si falta la entrada, el modelo no se mide y el primer test del fichero no lo detecta, porque su aserción es >=. Si está la entrada pero sin stage, la fila sale con el nombre del artefacto y la tabla deja de decir con qué muestreo se midió. Si está con measure: "none", se publica y no se mide, que es lo que se quiere para un dataset y nunca para un modelo. Ese es el precio de que el catálogo mande: una sola cosa que tocar, y ninguna red que avise si no la tocas.

// Ejercicio 02El fallo que no se debe capturar

El except Exception del bucle captura todo. Piensa en un caso en el que eso sea lo peor que puede pasar, y en qué lo convertiría en un problema real y no en una molestia.

// SoluciónVer la solución

Un KeyboardInterrupt no entra ahí (hereda de BaseException, no de Exception), así que cortar con Ctrl+C sigue funcionando. El caso malo es un error sistemático, uno que no depende de la etapa: una GPU que no arranca tras actualizar el driver, por ejemplo. Las doce etapas fallan una detrás de otra, cada una gastando su descarga y su carga de modelo, y la tirada tarda veinte minutos en decir doce veces lo mismo. (Stockfish ausente no sirve de ejemplo, y es peor: las suites del decoder y de Karvonen capturan EngineNotFound y escriben la fila sin Elo, con una nota Elo skipped; el encoder no lo usa, y solo la etapa de Qwen falla.) Se vuelve un problema real cuando eso ocurre de noche sin nadie delante. El remedio que el fichero no tiene es una parada temprana: dos fallos consecutivos con el mismo tipo de excepción podrían abortar. Está sin escribir a propósito, porque hasta ahora los fallos han sido de una etapa.

Qué has aprendido

Un orquestador de evaluación es casi todo decisiones sobre qué hacer cuando algo va mal: dónde vive la lista, cómo se resuelve una ruta que puede estar en dos sitios, qué se hace con un adaptador que no es un modelo, qué se escribe cuando una etapa falla y qué se deja intacto.

Eso sirve igual en cualquier pipeline de evaluación, de un clasificador o de un agente: la lista se define por su propósito para que nadie se equivoque por omisión, el fallo de un elemento se apunta sin parar el lote, y el fichero que dice cómo se hizo vale tanto como la tabla.

La lección siguiente construye la lista de la que este comando tira, y convierte cada fila en una card publicada.