// 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.
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
"""``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 fivemilestones, each with the config of its day. This walks the catalogue of ``rukh.hub`` -- everymodel 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 ondisk. 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 bystage, the rows it did not measure are left alone), ``docs/benchmarks.md`` rendered from thewhole table, and ``artifacts/eval/nightly.json``: which stage came from which checkpoint withwhich sha, how long each took and what went wrong. A table is reproducible when the last filesays how it was made."""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.
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 arepository that is not ours must never be pulled as ours, carded or collected; ``nightly`` onlyneeds to measure it, so it is the only place that lists it."""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
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: floatname 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
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 wantedTres 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.--onlyacepta el nombre del artefacto o el de la etapa, porque en la terminal escribes lo que acabas de leer, y en la tabla ponetiny-greedyy enrukh 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
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 )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.
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"), )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
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 targetTres caminos, y el orden importa:
- El baseline sale primero porque no se descarga como los nuestros:
ensure_karvonensabe de qué repositorio ajeno viene. resolve_run(lección 6) conviertecheckpoints/medium-v4/best.pten elmedium-v4-20260919-174623/best.ptde quien entrenó el modelo, y se aplica antes de decidir si hay que descargar: quien entrenótinyno vuelve a bajarlo del Hub.- El
FileNotFoundErrorlleva 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
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)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
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 reportdocs/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.
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)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
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 ""), ] ) + " |" )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.
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`)." )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
@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)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 for rukh.eval.nightly: the catalogue drives the run, and the run says what it did."""
from __future__ import annotations
import jsonfrom pathlib import Path
import pytestimport torchfrom typer.testing import CliRunner
from rukh.cli import appfrom rukh.eval import nightlyfrom rukh.hub import cataloguefrom rukh.models import DecoderConfig, MoveDecoderfrom 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"]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.
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«No escribe nada» se comprueba mirando que el fichero no existe, no que no ha cambiado.
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", ]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.
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())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.
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", "")--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.