// M6 · lección 05
El Hub y las cards: el catálogo, la vuelta y la afirmación pública
El catálogo que dice dónde vive cada artefacto publicado y dónde lo esperan las configuraciones, el comando que lo trae de vuelta convirtiendo el formato del Hub en un checkpoint del proyecto, la regeneración de las veintiuna cards desde la tabla sin republicar un solo peso, y la colección que no puede separarse de lo que se puede descargar.
Qué vas a construir
Cuatro ficheros nuevos y un puñado de cambios que cierran el círculo: lo que se entrena se publica, lo que se publica se puede volver a bajar donde las configuraciones lo esperan, y lo que dice cada card se puede contradecir con lo que hay dentro.
hub.py es el catálogo: la lista de todo lo que el curso publicó, con su repositorio, su ruta
local, los módulos que empiezan desde ahí, su fila en la tabla y cómo se mide. De ella tiran
rukh pull, rukh eval nightly, rukh publish cards y rukh publish collection, y que sea una
sola lista impide que las cuatro se separen. publish/cards.py regenera las veintiuna cards y sube
solo el README.md; publish/collection.py monta la colección del Hub; publish/links.py enlaza
cada card con su lección, su fila y la demo.
El catálogo: una lista, cuatro consumidores
Antes del código, la forma: cuatro subcomandos recorren la misma lista, y una fila que no es nuestra se queda fuera a propósito.
"""``rukh pull``: the published artefacts of a module, written where the configs expect them.
Every model of the course is on the Hub in the Hub's own layout -- ``config.json`` plus``model.safetensors``, or a ``peft`` folder -- and every dataset under its own name. Theconfigs and the lessons, on the other hand, read files at fixed local paths:``checkpoints/medium-v4/best.pt``, ``data/pairs/dpo-prompts.parquet``. This module maps oneonto the other, so a reader who skipped a module, or a machine that never trained anything, canstart the next module from exactly what was published, with one command:
uv run rukh pull --module m4 # everything M4 starts from uv run rukh pull medium-v4 rukh-pairs-dpo
The local path of a model is its **run name** (``checkpoints/<run>/<file>``), the same spelling``rukh.train.checkpoint.resolve_run`` accepts for a run the reader trained themselves. A pulledmodel is therefore indistinguishable, to every config, from one trained locally.
Nothing here decides what is worth publishing; ``rukh publish`` does that. This is the way back."""La frase que carga con todo es la penúltima: un modelo descargado es indistinguible, para cualquier
configuración, de uno entrenado en local. Por eso el curso se puede seguir por partes: quien se saltó
M2 empieza M4 con un comando, y escribe los mismos comandos que si hubiera entrenado el modelo. La
otra mitad es resolve_run, en la lección siguiente, que hace que la ruta estable también encuentre
la carpeta con marca de tiempo de un entrenamiento propio.
Kind = Literal["decoder", "encoder", "reward", "adapter", "peft", "dataset"]OWNER = "chorcat"
@dataclass(frozen=True)class Artefact: """One thing ``rukh pull`` knows how to bring back, and where it lands."""
name: str """What the reader types: the run or stage name of a model, the Hub name of a dataset.""" repo: str kind: Kind target: str """Path under the repository root: the checkpoint file, the adapter folder or the data dir.""" modules: tuple[str, ...] """The modules that start from this artefact when the reader skipped what produced it.""" note: str stage: str | None = None """Its row in the single results table, when it has one; ``rukh eval nightly`` measures it under that name so the row it rewrites is the row the cards and the course already cite.""" measure: Literal["decoder", "encoder", "qwen", "karvonen", "none"] = "none" """How ``nightly`` measures it: the decoder suite (adapters are merged onto their base first), the encoder suite, the general-model suite, the public-baseline suite (an entry ``nightly`` adds itself, since the weights are not the course's to pull or publish), or not at all (datasets, the reward model, the pretraining encoder).""" base: str | None = None """For an adapter: the checkpoint it is merged onto before it is measured.""" lesson: str | None = None """Path of the course lesson that builds it, for the card's "In the course" section."""
@property def repo_id(self) -> str: return self.repo if "/" in self.repo else f"{OWNER}/{self.repo}"Diez campos, cada uno para un consumidor: name y repo de todos; target y kind de pull;
modules de --module; stage, measure y base del nightly; lesson de las cards; note de
--list. Van en el mismo objeto porque cuatro diccionarios paralelos se desincronizan el día que se
añade un modelo y se olvida uno, y el síntoma es una card sin lección o una etapa sin medir.
frozen=True impide que un consumidor mute una entrada y se la cambie al siguiente.
repo_id completa "rukh-tiny" a "chorcat/rukh-tiny", salvo que ya lleve barra, como el
Artefact de Karvonen en nightly.py (adamkarvonen/chess_llms). Así el mismo tipo describe un
repositorio nuestro y uno ajeno.
Las trece entradas
Las entradas son datos escritos a mano: cada una dice qué repositorio, qué carpeta, qué módulos y
qué fila. Se pueden saltar en la primera lectura; lo que importa es que son una sola lista. Sin
ellas rukh pull no tiene nada que bajar: si no las tecleas, trae el fichero entero con git checkout p6 -- src/rukh/hub.py.
// Fontaneríahub.py: las trece entradas de modelos, los ocho datasets y lookup
MODELS: tuple[Artefact, ...] = ( Artefact( "tiny", "rukh-tiny", "decoder", "checkpoints/tiny/best.pt", ("m2", "m6", "m6"), "the 5 M decoder M2 iterates on (`configs/train/tiny.yaml`)", stage="tiny-greedy", measure="decoder", lesson="/curso/m2/02-entrenar-y-medir/", ), Artefact( "small", "rukh-small", "decoder", "checkpoints/small-v3/best.pt", ("m2", "m6", "m6"), "the 39 M course decoder, `small-v3` on the corpus with 24 months of Elite games", stage="small-v3-greedy", measure="decoder", lesson="/curso/m2/02-entrenar-y-medir/", ), Artefact( "medium-v4", "rukh-medium", "decoder", "checkpoints/medium-v4/best.pt", ("m4", "m5", "m6"), "the 115 M decoder every M4 fine-tune and every M5 alignment run starts from", stage="medium-v4-greedy", measure="decoder", lesson="/curso/m2/04-mas-datos-no-mas-red/", ), Artefact( "encoder-mmm-v4", "rukh-encoder-mmm", "encoder", "checkpoints/encoder-mmm-v4/best.pt", ("m3", "m6", "m6"), "the masked-move pretraining the published encoder's heads were fine-tuned on", lesson="/curso/m3/03-labs-del-encoder/", ), Artefact( "encoder-v4", "rukh-encoder", "encoder", "checkpoints/encoder-heads-v4/step-4000.pt", ("m3", "m6", "m6"), "the encoder with its three heads at its last step, as published and served by the demo", stage="encoder-v4", measure="encoder", lesson="/curso/m3/03-labs-del-encoder/", ),Tres cosas no obvias en estas cinco entradas:
name,repoy ruta casi nunca coinciden:smallvive enrukh-smally su checkpoint encheckpoints/small-v3/best.pt. El nombre corto es el del comando, el del repositorio el que se anunció, y la carpeta lleva la versión porque hubo tres.encoder-v4apunta astep-4000.pt, la decisión de M3: la cabeza de blunder seguía mejorando cuando la pérdida agregada ya no.encoder-mmm-v4no tienestagenimeasure: se publica porque de él parten las cabezas, pero no rellena ninguna columna, ymeasurevale"none"por defecto.
m6 aparece dos veces en la tupla de módulos de casi todas las entradas: un despiste del tag p6
que no rompe nada (for_module pregunta con in), y un recordatorio de que una lista de datos
escrita a mano se revisa igual que el código.
Artefact( "medium-elo", "rukh-medium-elo", "decoder", "checkpoints/medium-elo/step-3800.pt", ("m4", "m6", "m6"), "`medium-v4` fine-tuned on the Elo-balanced corpus (the last step, not `best.pt`)", stage="medium-elo", measure="decoder", lesson="/curso/m4/03-labs-de-afinado/", ), Artefact( "medium-masters", "rukh-medium-masters", "decoder", "checkpoints/medium-masters/step-3800.pt", ("m4", "m6", "m6"), "`medium-v4` fine-tuned on Elite games (the last step, not `best.pt`)", stage="medium-masters", measure="decoder", lesson="/curso/m4/03-labs-de-afinado/", ), Artefact( "lora-e4", "rukh-lora-e4", "adapter", "checkpoints/lora-e4", ("m4", "m6", "m6"), "the 1.6 MB style adapter that opens 1. e4", stage="lora-e4", measure="decoder", base="checkpoints/medium-v4/best.pt", lesson="/curso/m4/03-labs-de-afinado/", ), Artefact( "lora-d4", "rukh-lora-d4", "adapter", "checkpoints/lora-d4", ("m4", "m6", "m6"), "the 1.6 MB style adapter that opens 1. d4", stage="lora-d4", measure="decoder", base="checkpoints/medium-v4/best.pt", lesson="/curso/m4/03-labs-de-afinado/", ), Artefact( "qwen3-pgn-qlora", "rukh-qwen3-pgn-qlora", "peft", "checkpoints/qwen3-pgn-qlora", ("m4", "m6", "m6"), "the `peft` adapter of Qwen3-0.6B over PGN text, as `trl` wrote it", stage="qwen3-pgn-qlora", measure="qwen", lesson="/curso/m4/03-labs-de-afinado/", ), Artefact( "rm", "rukh-rm", "reward", "checkpoints/rm/reward.pt", ("m5", "m6", "m6"), "the Bradley-Terry reward model over 69 square tokens", lesson="/curso/m5/03-labs-de-alineamiento/", ), Artefact( "medium-v4-dpo-onpolicy", "rukh-medium-dpo", "decoder", "checkpoints/medium-v4-dpo-onpolicy/dpo.pt", ("m5", "m6", "m6"), "`medium-v4` after DPO on its own pairs, the aligned decoder the demo serves", stage="medium-v4-dpo-onpolicy-greedy", measure="decoder", lesson="/curso/m5/03-labs-de-alineamiento/", ), Artefact( "medium-v4-grpo", "rukh-medium-grpo", "decoder", "checkpoints/medium-v4-grpo/grpo.pt", ("m5", "m6", "m6"), "`medium-v4` after GRPO against the verifiable reward", stage="medium-v4-grpo-greedy", measure="decoder", lesson="/curso/m5/03-labs-de-alineamiento/", ),)Las ocho restantes:
kinddistingueadapterdepeft: los dos son 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 —enmedium, 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., pero el nuestro son dos ficheros y el depeftuna carpeta con la convención de esa librería, ypulllos trae distinto.basesolo lo tienen nuestros dos adaptadores, que el nightly funde sobremedium-v4; el de Qwen3 lo mide su propia suite.rmno tienestage: el reward model juzga pares, no juega, y elmeasure="none"por defecto permite publicarlo sin fila.
DATASET_MODULES: dict[str, tuple[str, ...]] = { "rukh-games-1800": ("m1", "m2", "m3", "m4", "m5", "m6"), "rukh-tokenizer": ("m2", "m3", "m4", "m5", "m6"), "rukh-games-elite": ("m2", "m4"), "rukh-positions-eval": ("m2", "m3", "m5", "m6"), "rukh-puzzles-split": ("m2", "m3", "m4", "m5", "m6"), "rukh-elo-bins": (), "rukh-pairs-dpo": ("m5",), "rukh-pairs-onpolicy": ("m5",),}"""Which modules start from each dataset. ``rukh-elo-bins`` is M1's 100-Elo sample of the1800+ corpus and nothing downstream reads it: M4 builds its own, from the club games it fetches."""
DATASET_LESSONS: dict[str, str] = { "rukh-games-1800": "/curso/m1/02-labs-del-pipeline/", "rukh-tokenizer": "/curso/m1/02-labs-del-pipeline/", "rukh-games-elite": "/curso/m2/04-mas-datos-no-mas-red/", "rukh-positions-eval": "/curso/m1/02-labs-del-pipeline/", "rukh-puzzles-split": "/curso/m1/02-labs-del-pipeline/", "rukh-elo-bins": "/curso/m1/02-labs-del-pipeline/", "rukh-pairs-dpo": "/curso/m1/02-labs-del-pipeline/", "rukh-pairs-onpolicy": "/curso/m5/03-labs-de-alineamiento/",}"""The lesson each dataset is built in."""
def _dataset_artefact(spec: DatasetSpec) -> Artefact: return Artefact( spec.name, spec.name, "dataset", spec.local_dir, DATASET_MODULES.get(spec.name, ()), spec.description.split(". ")[0].rstrip("."), lesson=DATASET_LESSONS.get(spec.name), )Aquí sí hay dos diccionarios paralelos, y es la excepción que confirma la regla. Los datasets ya
tienen su descripción en DATASETS, el registro de M1 en data/publish.py, y repetirla sería tener
dos verdades. Lo que falta, a qué módulos sirven y en qué lección se construyen, va en dos mapas por
nombre que _dataset_artefact junta.
rukh-elo-bins lleva una tupla vacía: es el muestreo de M1 por franjas de Elo que nadie aguas abajo
lee, porque M4 construyó el suyo. Se deja publicado con cero módulos en vez de borrarlo.
spec.description.split(". ")[0] coge la primera frase de la descripción, una heurística que
fallaría con «Aprox. 5 M de partidas» y aguanta porque las ocho descripciones están revisadas.
def catalogue() -> list[Artefact]: """Every artefact ``rukh pull`` knows, models first and then the datasets.""" return [*MODELS, *(_dataset_artefact(spec) for spec in DATASETS.values())]
def lookup(name: str) -> Artefact: """The artefact called ``name``: a run name, a dataset name or a Hub id.""" for artefact in catalogue(): if name in (artefact.name, artefact.repo, artefact.repo_id): return artefact known = ", ".join(a.name for a in catalogue()) raise KeyError(f"nothing to pull called {name!r}; known: {known}")
def for_module(module: str) -> list[Artefact]: """What a reader who skipped everything before ``module`` needs on disk to start it.""" return [artefact for artefact in catalogue() if module in artefact.modules]lookup acepta tres formas del mismo nombre (la corta, la del repositorio y la completa con el
propietario), por la misma razón que el --only del nightly. Y el KeyError lista todo lo conocido:
con veintiún artefactos cabe en pantalla y ahorra un --list.
La vuelta: del formato del Hub al del proyecto
La traducción de formatos y la descarga atómica son fontanería: en la primera lectura basta con
saber que un modelo bajado queda donde las configuraciones lo esperan, con la forma de uno entrenado
en local. Sin este código el «Ejecútalo» de rukh pull falla: si no lo tecleas, git checkout p6 -- src/rukh/hub.py.
// Fontaneríahub.py: descargar del Hub y traducir el formato del Hub al checkpoint del proyecto, sin pisar lo que hay
def _download(repo_id: str, filename: str) -> Path: import huggingface_hub
return Path(huggingface_hub.hf_hub_download(repo_id, filename))
def _load_state(repo_id: str) -> dict[str, Any]: """The weights of a Hub model, whichever of the two names they were published under.""" import torch
for name in ("model.safetensors", "pytorch_model.bin"): try: weights = _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
return dict(load_file(str(weights))) return dict(torch.load(weights, map_location="cpu", weights_only=True)) raise FileNotFoundError(f"{repo_id} holds neither model.safetensors nor pytorch_model.bin")safetensors primero y pytorch_model.bin después: el primero no ejecuta código al cargarse, el
segundo es un pickle que solo se acepta por si un repositorio antiguo lo tiene. El orden es la
política de seguridad escrita como una tupla. El except Exception es el caso menos dudoso del
módulo: hf_hub_download levanta excepciones distintas según la versión y el fallo, y el bucle solo
quiere saber «no está, prueba el siguiente». log.debug deja rastro por si era la red.
def _model_payload(artefact: Artefact, config: dict[str, Any], state: dict[str, Any]) -> dict: """Fold a Hub layout back into the checkpoint payload every loader of this project reads.
``rukh publish`` writes the model's shape and the run's provenance into ``config.json``; this puts them back under the keys ``save_checkpoint`` uses. The heads of an encoder need the pooling they were trained with, which the card's config carries and ``build_heads`` reads from the run config. """ from rukh.models import DecoderConfig, EncoderConfig
if artefact.kind == "reward": return { "model": state, "encoder_config": config["encoder"], "pooling": config.get("pooling"), "config": {"pulled_from": artefact.repo_id}, } shape = EncoderConfig if artefact.kind == "encoder" else DecoderConfig model_cfg = {key: value for key, value in config.items() if key in shape.model_fields} run_cfg: dict[str, Any] = {"pulled_from": artefact.repo_id} if artefact.kind == "encoder" and config.get("pooling"): run_cfg["pooling"] = config["pooling"] return { "step": int(config.get("step") or 0), "model_state": state, "opt_state": None, "cfg": run_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, }Esta función cumple la promesa del docstring. El Hub guarda un modelo como configuración más pesos;
el proyecto, como el payload de save_checkpoint. Traducir de uno a otro permite que load_model
abra un fichero descargado sin saber que lo es.
La pieza clave es el filtro por shape.model_fields. El config.json publicado mezcla la forma del
modelo con la procedencia de la ejecución, porque en una card se leen juntas; aquí lo que es campo de
DecoderConfig va a model_cfg, y publicar un campo nuevo en la card no rompe la vuelta.
opt_state: None y best_val: None son explícitos porque no estaban (el publicador quita el
optimizador), y así el payload tiene siempre la misma forma. cfg = {"pulled_from": repo_id} deja
dentro del fichero de dónde vino, como merged_from en el nightly. Y el pooling del encoder vive en
la configuración de la ejecución, no en la del modelo: sin esas dos líneas, un encoder descargado
carga y da números distintos.
def pull(artefact: Artefact, force: bool = False) -> Path: """Bring ``artefact`` to its local path and return that path.
An artefact already on disk is left alone unless ``force``: a reader's own training run, sitting at the same stable path, must never be replaced silently by the published one. """ import torch
target = paths.resolve(artefact.target) if target.exists() and not force: log.info("%s is already there; --force replaces it", target) return target if artefact.kind == "dataset": import huggingface_hub
spec = DATASETS[artefact.name] target.mkdir(parents=True, exist_ok=True) huggingface_hub.snapshot_download( artefact.repo_id, repo_type=spec.repo_type, local_dir=str(target), allow_patterns=[*spec.patterns, "manifest.json"], ) return target if artefact.kind == "peft": import huggingface_hub
target.mkdir(parents=True, exist_ok=True) huggingface_hub.snapshot_download(artefact.repo_id, local_dir=str(target)) return target if artefact.kind == "adapter": target.mkdir(parents=True, exist_ok=True) for name in ("adapter.safetensors", "adapter_config.json"): source = _download(artefact.repo_id, name) (target / name).write_bytes(source.read_bytes()) return target config = json.loads(_download(artefact.repo_id, "config.json").read_text(encoding="utf-8")) state = _load_state(artefact.repo_id) target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(target.suffix + ".tmp") torch.save(_model_payload(artefact, config, state), tmp) tmp.replace(target) return targetTres decisiones de peso:
- Lo que ya está en disco no se toca: en
checkpoints/medium-v4/best.ptpuede estar tu entrenamiento, y unpullde más no debe destruir cuarenta minutos de GPU.--forceexiste para cuando quieres eso. - Escritura atómica: se guarda en
best.pt.tmpy se renombra contmp.replace(target), atómico dentro del mismo volumen. Si el proceso muere a mitad, queda un.tmpinservible y no unbest.pttruncado que falla una hora después con un error incomprensible. allow_patternsen los datasets: sin filtro,snapshot_downloadtrae también vistas previas y.gitattributes. Con los patrones deDatasetSpecmás elmanifest.json, la carpeta es la que habría producidorukh data.
Ese manifest.json permite comprobar los hashes de lo descargado, incluido el dpo-prompts.parquet
de la lección 2, que entra en el dataset publicado con una línea:
patterns=["pairs.parquet", "dpo-prompts.parquet", "manifest.json"],Sin ella, rukh pull rukh-pairs-dpo traería los pares sin los prompts y el DPO de M5 fallaría al
abrir un fichero que existe en el Hub.
El comando
El subcomando es la CLI de siempre. Puedes saltar su lectura, pero no el código: sin él, el
«Ejecútalo» de abajo no tiene comando. Si no lo tecleas, copia la función pull_cmd, con su
decorador, de la salida de git show p6:src/rukh/cli.py a tu cli.py.
// Fontaneríarukh pull: el subcomando, sus opciones y sus códigos de salida
@app.command("pull")def pull_cmd( names: Annotated[ list[str] | None, typer.Argument(help="Run names, dataset names or Hub ids (see --list)."), ] = None, module: Annotated[ str | None, typer.Option("--module", help="Pull everything a module starts from, e.g. m4."), ] = None, list_all: Annotated[ bool, typer.Option("--list", help="Print the catalogue, by module, and exit.") ] = False, force: Annotated[ bool, typer.Option("--force", help="Replace what is already on disk.") ] = False,) -> None: """Bring published models and datasets to the paths the configs and the lessons read.
A pulled model lands under its run name (`checkpoints/medium-v4/best.pt`), which is the spelling every config uses and the one a run you trained yourself also answers to. """ from rukh.hub import catalogue, for_module, lookup, pull
if list_all: for artefact in catalogue(): modules = ",".join(artefact.modules) or "-" typer.echo(f"{artefact.name:<24} {modules:<15} {artefact.target}") typer.echo(f"{'':<24} {artefact.repo_id:<15} {artefact.note}") return wanted = [] if module is not None: wanted.extend(for_module(module)) if not wanted: typer.echo(f"error: nothing is catalogued for module {module!r}", err=True) raise typer.Exit(code=2) try: wanted.extend(lookup(name) for name in names or []) except KeyError as exc: typer.echo(f"error: {exc.args[0]}", err=True) raise typer.Exit(code=2) from exc if not wanted: typer.echo("error: give a name, --module or --list (see rukh pull --help)", err=True) raise typer.Exit(code=2) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") for chatty in ("httpx", "huggingface_hub", "urllib3"): logging.getLogger(chatty).setLevel(logging.WARNING) # one line per artefact, not per GET for artefact in wanted: try: path = pull(artefact, force=force) except (FileNotFoundError, OSError) as exc: typer.echo(f"error: {artefact.name}: {exc}", err=True) raise typer.Exit(code=1) from exc typer.echo(f"{artefact.name:<24} {artefact.repo_id:<28} -> {path}")rukh pull cuelga directamente de app: es el primer comando de quien llega al curso por la mitad, y
rukh hub pull --artefact medium-v4 sería demasiada ceremonia. Bajar el registro de httpx,
huggingface_hub y urllib3 a WARNING evita una línea por petición HTTP. Los códigos de salida
siguen la convención de Unix: 2 para un uso incorrecto (un nombre que no existe, ningún argumento)
y 1 para un fallo de ejecución. Si pasas --module m6 y nombres sueltos se acumulan sin deduplicar;
la segunda descarga del mismo artefacto es instantánea porque ya está en disco.
Los enlaces que devuelven cada card al curso
Los enlaces salen del catálogo; el fichero es fontanería y se puede saltar en la primera lectura.
Las cards lo necesitan: si no lo tecleas, git checkout p6 -- src/rukh/publish/links.py.
// Fontaneríapublish/links.py: la lección, la fila y la demo de cada repositorio, y el ciclo de importación
"""The links every card carries back to the course: its lesson, its row in the table, the demo.
A model card that does not say where its number came from is a number without a method. Thesingle results table (`/proyecto/` on the course site) is where every stage is measured the sameway, and the lesson is where it was built; both are looked up in the catalogue of ``rukh.hub`` soa repo id is enough and nothing is typed twice."""
from __future__ import annotations
from typing import Any
from rukh.hub import catalogue
TABLE_PATH = "/proyecto/"
LESSON_TITLES: dict[str, str] = { "/curso/m1/02-labs-del-pipeline/": "M1 · Del PGN al tensor: los labs", "/curso/m2/02-entrenar-y-medir/": "M2 · El decoder: entrenarlo y medirlo", "/curso/m2/04-mas-datos-no-mas-red/": "M2 · Más datos, no más red: de small a medium-v4", "/curso/m3/03-labs-del-encoder/": "M3 · El encoder: los labs", "/curso/m4/03-labs-de-afinado/": "M4 · Fine-tuning: los labs", "/curso/m5/03-labs-de-alineamiento/": "M5 · Alineamiento: los labs",}
def course_links( repo_id: str, course_url: str = "https://lab.rukh.borjaglez.com", demo_url: str = "https://rukh.borjaglez.com",) -> dict[str, Any]: """What the card's "In the course" section needs for ``repo_id``.
``lesson_url`` and ``stage`` are ``None`` for a repository the catalogue does not know, and the section then names only the table: a card never claims a lesson it was not built in. """ found = next((a for a in catalogue() if a.repo_id == repo_id), None) lesson = found.lesson if found else None from rukh.publish.model import DEMO_STAGES
name = repo_id.split("/")[-1].removeprefix("rukh-") demo_stage = DEMO_STAGES.get(name) return { "table_url": f"{course_url}{TABLE_PATH}", "lesson_url": f"{course_url}{lesson}" if lesson else None, "lesson_title": LESSON_TITLES.get(lesson or "", lesson), "stage": found.stage if found else None, "demo_url": f"{demo_url}/?stage={demo_stage}" if demo_stage else None, "reproduce": f"uv run rukh pull {found.name}" if found else None, }El diccionario de títulos escritos a mano es la deuda de este fichero: el repositorio de ML no puede
leer el frontmatter del repositorio del curso, así que los títulos se copian y envejecen. Las rutas
envejecieron también: las del catálogo (/curso/m5/03-labs-de-alineamiento/ y compañía) son las de
antes de que el curso partiera cada módulo en lecciones, y siguen funcionando porque el sitio las
redirige a las de ahora (/curso/m5/09-labs-de-alineamiento/). Lo que no se
copia es lo que importa: la ruta de la lección y el nombre de la etapa salen del catálogo, y un
repositorio desconocido devuelve None en los dos. Así una card nunca reclama una lección en la que
no se construyó.
from rukh.publish.model import DEMO_STAGES va dentro de la función por un ciclo: publish/model.py
importa course_links arriba del todo. El mismo ciclo aparece en data/publish.py, con su
comentario:
# Lazy: `rukh.publish.links` reads the catalogue of `rukh.hub`, which imports this module. from rukh.publish.links import course_linksUn ciclo de importación suele ser la señal de que dos módulos se conocen demasiado. Este es real y no se evita sin un tercer módulo: el catálogo necesita los datasets y los datasets, los enlaces del catálogo.
La sección que entra en las veintiuna cards
Las plantillas Jinja son el formato de la card, no la idea; se pueden saltar en la primera lectura.
Las demás plantillas la incluyen: si no la tecleas,
git checkout p6 -- src/rukh/data/cards/_course.md.jinja.
// Fontanería_course.md.jinja: la sección de enlaces común a las siete plantillas
## In the course
{% if course.lesson_url -%}- Built in [{{ course.lesson_title }}]({{ course.lesson_url }}), the lesson that runs every command behind this repository.{% endif -%}- Measured in the [single results table]({{ course.table_url }}){% if course.stage %} as the stage `{{ course.stage }}`{% endif %}: every stage of the course, the same suite, the same day.{% if course.demo_url -%}- Play it in the browser: [{{ course.demo_url }}]({{ course.demo_url }}).{% endif -%}{% if course.reproduce -%}- Bring it to the paths the configs read: `{{ course.reproduce }}`.{% endif %}src/rukh/data/cards/_course.md.jinja
Desde cualquier repositorio del Hub se llega a la lección que lo construyó, a la fila que lo mide, a
la demo y al comando que lo trae. El guion bajo del nombre es la convención de Jinja para una
plantilla parcial. Las secciones son condicionales porque los enlaces pueden faltar (un dataset no
tiene demo, el reward model no tiene fila), salvo la de la tabla. Los -%} recortan el salto de
línea siguiente; sin ellos, cada condición falsa dejaría una línea en blanco y partiría la lista.
La inclusión es la misma línea en las siete plantillas:
{% include '_course.md.jinja' %}src/rukh/data/cards/model.md.jinja
Va encima de ## License en todas, y el test de más abajo lo comprueba: los enlaces son parte de lo
que la card afirma.
La card del encoder preentrenado
La plantilla entera se puede saltar; la idea que deja está en el recuadro de abajo. Si no la
tecleas, git checkout p6 -- src/rukh/data/cards/encoder-mmm.md.jinja.
// Fontaneríaencoder-mmm.md.jinja: la card de un modelo que no se mide en la tabla
El hito añadió una plantilla para el único modelo publicado que no encajaba en ninguna.
---license: {{ license }}library_name: rukhpipeline_tag: feature-extractionlanguage: - endatasets:{%- for dataset in datasets %} - {{ dataset }}{%- endfor %}tags: - chess - rukh - encoder - masked-move-modeling---
# {{ repo_id }}
A bidirectional transformer written from scratch and **pretrained only**: it has no value,blunder or result head yet. It read games written in UCI with 15 % of the moves hidden andlearned to put them back by looking at both sides of the hole, which is masked languagemodeling with moves for words. This is the `{{ stage }}` stage of [Rukh]({{ repository_url }}),a course that builds a chess language model end to end: {{ "{:,}".format(params) }} parametersover {{ config.get("n_layer", 8) }} layers of width {{ config.get("d_model", 384) }}, on the`{{ config.get("input", "moves") }}` input scheme.
It exists on the Hub for one reason: it is where the encoder with heads([`{{ heads_repo }}`](https://huggingface.co/{{ heads_repo }})) starts from, and a reader of thecourse who skips the pretraining lab can put it where the fine-tuning configs look for it:
```shuv run rukh pull {{ stage }} # -> checkpoints/{{ stage }}/best.ptuv run rukh train heads --config configs/train/encoder-heads-v4.yaml --mode last-n```
Read how it was built: [{{ course_url }}]({{ course_url }})
## Results
The only thing a pretraining run measures is how well it fills the holes it was given.
| Metric | Value ||---|---|| Masked-move loss on held-out games | {{ best_val }} || Masked-move top-1 on held-out games | {{ val_top1 }} || Training step of this checkpoint | {{ step }} |
These are not the numbers of the model that goes into the demo: the heads are fine-tuned onStockfish labels afterwards, and their F1, correlation and result accuracy live on the card of[`{{ heads_repo }}`](https://huggingface.co/{{ heads_repo }}).src/rukh/data/cards/encoder-mmm.md.jinja
Si el encoder de preentrenamiento usara la plantilla del encoder con cabezas, su card diría «entrenado sobre etiquetas de Stockfish» de un modelo que nunca vio una etiqueta, con huecos donde van la F1 de blunder y la correlación con el valor. Los tres números que sí tiene son los de un preentrenamiento: la pérdida de jugada enmascarada sobre partidas reservadas, su top-1 y el paso del checkpoint, con un aviso de que no son los del modelo de la demo. Y «It exists on the Hub for one reason» evita que alguien lo descargue esperando otra cosa.
## Training recipe
{% if recipe %}| Parameter | Value ||---|---|{%- for name, value in recipe %}| `{{ name }}` | `{{ value }}` |{%- endfor %}{% else %}The MLflow run for this checkpoint was not available when the card was generated; the shape ofthe model is in `config.json`.{% endif %}{% if run_id %}MLflow run: `{{ run_id }}`.{% endif %}
```json{{ config_json }}```
## Data
Trained on {% for dataset in datasets %}[`{{ dataset }}`](https://huggingface.co/datasets/{{ dataset }}){% if not loop.last %}, {% endif %}{% endfor %},derived from the [Lichess open database](https://database.lichess.org) (CC0), on the samepacked token stream the project's decoder trains on, so the two models saw the same games.Validation uses a held-out slice of a month the training split does not read.
## Limitations
- It predicts hidden moves; it does not evaluate positions. Ask it how good a position is and the answer is whatever an untrained linear head returns.- Its masked-move accuracy is not a measure of chess strength: a move is easy to fill in when the moves around it leave one legal option.- Fine-tuning the whole network on the labels afterwards made it *worse* than fine-tuning the last two blocks; the course records that measurement, and the heads configs default to it.
{% include '_course.md.jinja' %}
## License
Weights: Apache-2.0. Data: CC0 (Lichess). Code: MIT, at [{{ repository_url }}]({{ repository_url }}).src/rukh/data/cards/encoder-mmm.md.jinja
La rama {% else %} de la receta aplica a una card la regla de los huecos: si la ejecución de MLflow
no está accesible, se dice y se apunta a dónde está la forma del modelo, sin inventarse
hiperparámetros.
Las tres limitaciones separan una card de un folleto. La tercera es una medición del curso: afinar la red entera fue peor que afinar los dos últimos bloques. Publicar un resultado negativo propio en la card de tu modelo es lo que hace creíble el resto.
Regenerar las cards sin republicar los pesos
"""``rukh publish cards``: every card regenerated from what is on disk, and only the card uploaded.
A card is a rendering of the run, the evaluation and the links of the day it was written. Whenthe table changes -- a nightly, a corrected ladder, a new lesson URL -- the card is stale, andrepublishing 460 MB of weights to change a paragraph is the wrong tool. This stages everyrepository of the catalogue the way its own publisher does (``dry_run``: nothing leaves themachine) and then uploads ``README.md`` alone with ``upload_file``.
It needs the checkpoints on disk, which is what ``rukh pull`` is for, and the evaluations in``artifacts/eval``, which is what ``rukh eval nightly`` leaves behind."""La idea está en «the way its own publisher does»: no hay un segundo renderizador. Se llama en seco al
publicador de cada tipo, que prepara el repositorio en local, y se coge el README.md que deja. Un
renderizador aparte sería un segundo sitio donde arreglar una plantilla.
El resto del fichero reparte cada artefacto a su publicador y sube la card sin tocar los pesos.
Fontanería: se puede saltar en la primera lectura. Si no la tecleas, trae el fichero entero:
git checkout p6 -- src/rukh/publish/cards.py.
// Fontaneríapublish/cards.py: los cinco publicadores detrás de una función y la subida de solo el README
ONNX_DIRS: dict[str, str] = { "tiny": "artifacts/onnx/tiny", "small": "artifacts/onnx/small-v3", "medium-v4": "artifacts/onnx/medium-v4", "encoder-v4": "artifacts/onnx/encoder-v4", "medium-elo": "artifacts/onnx/medium-elo", "medium-masters": "artifacts/onnx/medium-masters", "medium-v4-dpo-onpolicy": "artifacts/onnx/medium-dpo", "medium-v4-grpo": "artifacts/onnx/medium-grpo",}"""The ONNX export each model's card reports parity from, when it is on disk."""
EFFECTS_DIR = "artifacts/publish/effects"
class CardRefresh(BaseModel): """One repository's card: where it was staged and whether it went up."""
model_config = ConfigDict(extra="forbid")
name: str repo_id: str repo_type: str card: str | None = None status: str """``staged`` (dry run), ``uploaded`` or ``failed``.""" detail: str | None = NoneONNX_DIRS es un campo más del catálogo que vive fuera de él. A favor: las exportaciones no se
publican por sí mismas y solo este fichero las necesita. En contra: es otro mapa por nombre que se
puede desincronizar. CardRefresh tiene la forma de NightlyRecord (un registro por elemento, con
estado y detalle) por la misma razón: un elemento puede fallar sin llevarse a los demás.
def stage_card(artefact: Artefact, cfg: ModelPublishConfig) -> Path: """Render ``artefact``'s card through its own publisher, in dry-run mode; returns the card.""" target = resolve_run(paths.resolve(artefact.target)) if artefact.kind == "dataset": from rukh.data.publish import PublishConfig, publish
result = publish(artefact.name, PublishConfig(owner=cfg.owner), dry_run=True) return Path(result.card_path) if artefact.kind == "adapter": from rukh.publish.adapter import AdapterEffect, publish_adapter
effect_path = paths.resolve(EFFECTS_DIR) / f"{artefact.name}.json" effect = ( AdapterEffect.model_validate_json(effect_path.read_text(encoding="utf-8")) if effect_path.is_file() else None ) result = publish_adapter( target, artefact.repo_id, f"{cfg.owner}/rukh-medium", cfg, effect=effect, dry_run=True ) return Path(result.card_path) if artefact.kind == "peft": from rukh.publish.adapter import publish_qwen_adapter
result = publish_qwen_adapter(target, artefact.repo_id, cfg, dry_run=True) return Path(result.card_path) if artefact.kind == "reward": from rukh.publish.reward import publish_reward
result = publish_reward(target, artefact.repo_id, cfg, dry_run=True) return Path(result.folder) / README_NAME from rukh.publish.model import publish_model
onnx = ONNX_DIRS.get(artefact.name) onnx_dir = paths.resolve(onnx) if onnx and paths.resolve(onnx).is_dir() else None result = publish_model( target, artefact.repo_id, cfg, onnx_dir=onnx_dir, stage=artefact.stage, dry_run=True ) return Path(result.card_path)Cinco publicadores detrás de una función: la misma capa de adaptación que las cuatro suites del
nightly. La rama del reward model construye aquí la ruta del README.md en vez de cambiar la firma
de un publicador de M5 ya probado; es deuda visible. Y si falta el JSON de AdapterEffect (cuánto
cambia el repertorio un adaptador, medido en M4), la card sale sin esa sección.
def refresh_cards( only: set[str] | None = None, dry_run: bool = False, cfg: ModelPublishConfig | None = None, api: Any | None = None,) -> list[CardRefresh]: """Regenerate the card of every repository (or of ``only``); upload it unless ``dry_run``.""" cfg = cfg or ModelPublishConfig() wanted = [a for a in catalogue() if not only or a.name in only or a.repo_id in only] client = None outcomes: list[CardRefresh] = [] for artefact in wanted: repo_type = ( "dataset" if artefact.kind == "dataset" and artefact.name != "rukh-tokenizer" else "model" ) outcome = CardRefresh( name=artefact.name, repo_id=artefact.repo_id, repo_type=repo_type, status="staged" ) outcomes.append(outcome) try: card = stage_card(artefact, cfg) outcome.card = card.as_posix() if not dry_run: client = client or api or _api() client.upload_file( path_or_fileobj=str(card), path_in_repo=README_NAME, repo_id=artefact.repo_id, repo_type=repo_type, commit_message="docs(card): regenerated from the current table and course", ) outcome.status = "uploaded" except Exception as exc: # noqa: BLE001 - one card failing must not stop the rest log.exception("card of %s failed", artefact.name) outcome.status = "failed" outcome.detail = f"{type(exc).__name__}: {exc}" return outcomesupload_file y no upload_folder: sube el README.md y nada más, sin tocar los pesos, y el
commit_message deja escrito en el historial del Hub que ese commit es solo la card.
client = client or api or _api() crea el cliente la primera vez que hace falta; en seco no se crea
nunca, así que --dry-run funciona sin login. El parámetro api es la inyección que hace
testeable todo esto sin red. Y repo_type tiene su excepción escrita: rukh-tokenizer es un
dataset del proyecto que vive en el Hub como modelo. Esa condición está escrita igual aquí y en
collection.py, una duplicación pequeña que un día puede convertirse en discrepancia.
La colección
La colección y sus subcomandos son fontanería del Hub, y se pueden saltar en la primera lectura.
El «Ejecútalo» del final los necesita: si no los tecleas, git checkout p6 -- src/rukh/publish/collection.py
y copia las funciones publish_cards_cmd y publish_collection_cmd, con sus decoradores, de la
salida de git show p6:src/rukh/cli.py a tu cli.py.
// Fontaneríapublish/collection.py y rukh publish: montar la colección del Hub y los dos subcomandos
"""``rukh publish collection``: the ``Rukh`` collection on the Hub, kept equal to the catalogue.
A collection is the one page that lists everything the course trained, in the order the coursebuilt it. It is derived from ``rukh.hub`` so it cannot drift from what ``rukh pull`` can bringback: adding a model to the catalogue and running this is what "publishing the collection" is.Idempotent on purpose -- the Hub refuses duplicates when asked (``exists_ok``), and a second runchanges nothing."""
from __future__ import annotations
import loggingfrom typing import Any
from pydantic import BaseModel, ConfigDict
from rukh.hub import cataloguefrom rukh.publish.model import ModelPublishConfig, _api
log = logging.getLogger(__name__)
TITLE = "Rukh"DESCRIPTION = ( "A chess language model built from scratch as a course on generative and agentic AI: every " "model, adapter and dataset, in course order.")"""The Hub caps a collection's description at 150 characters."""src/rukh/publish/collection.py
def collection_items() -> list[tuple[str, str]]: """``(repo id, item type)`` for every catalogued artefact, models first, in course order.""" items: list[tuple[str, str]] = [] for artefact in catalogue(): item_type = ( "dataset" if artefact.kind == "dataset" and artefact.name != "rukh-tokenizer" else "model" ) if (artefact.repo_id, item_type) not in items: items.append((artefact.repo_id, item_type)) return items
def sync_collection( cfg: ModelPublishConfig | None = None, dry_run: bool = False, api: Any | None = None) -> CollectionSync: """Create the collection if it does not exist and add every catalogued repository to it.""" cfg = cfg or ModelPublishConfig() items = collection_items() if dry_run: return CollectionSync(slug=None, created=False, items=[r for r, _ in items], dry_run=True) client = api or _api() existing = next((c for c in client.list_collections(owner=cfg.owner) if c.title == TITLE), None) created = existing is None collection = existing or client.create_collection( TITLE, namespace=cfg.owner, description=DESCRIPTION, private=False, exists_ok=True ) for repo_id, item_type in items: client.add_collection_item(collection.slug, repo_id, item_type, exists_ok=True) log.info("collection %s: %s (%s)", collection.slug, repo_id, item_type) return CollectionSync( slug=collection.slug, created=created, items=[r for r, _ in items], dry_run=False )src/rukh/publish/collection.py
La deduplicación va por lista y no por conjunto: con veintiún elementos, el O(n²) da igual, y se
conserva el orden del curso. La idempotencia va por exists_ok en vez de listar lo que ya hay: menos
código, una petición menos y ninguna carrera si dos ejecuciones se solapan.
La función no quita nada: un repositorio que desaparezca del catálogo sigue en la colección hasta que alguien lo borre a mano. Es una asimetría deliberada, porque borrar del Hub automáticamente da mucho más miedo que añadir.
@publish_app.command("cards")def publish_cards_cmd( only: Annotated[ str | None, typer.Option("--only", help="Comma-separated names or repo ids.") ] = None, dry_run: Annotated[ bool, typer.Option("--dry-run", help="Stage every card locally; upload nothing.") ] = False,) -> None: """Regenerate every card from the current table and course, and upload only the card.
Each repository is staged through its own publisher (nothing leaves the machine), then `README.md` alone is uploaded: changing a paragraph must not republish 460 MB of weights. Needs the checkpoints on disk (`rukh pull`) and the evaluations (`rukh eval nightly`). """ from rukh.publish.cards import refresh_cards
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") wanted = {part.strip() for part in (only or "").split(",") if part.strip()} or None outcomes = refresh_cards(only=wanted, dry_run=dry_run) for outcome in outcomes: typer.echo(f"{outcome.repo_id:<34} {outcome.status:<9} {outcome.card or outcome.detail}") if any(outcome.status == "failed" for outcome in outcomes): raise typer.Exit(code=1)
@publish_app.command("collection")def publish_collection_cmd( dry_run: Annotated[ bool, typer.Option("--dry-run", help="List the items; touch no network.") ] = False,) -> None: """Create or update the `Rukh` collection on the Hub with every catalogued repository.""" from rukh.publish.collection import sync_collection
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") result = sync_collection(dry_run=dry_run) typer.echo(f"collection: {result.slug or '(dry run)'}{' · created' if result.created else ''}") for item in result.items: typer.echo(f" {item}")outcome.card or outcome.detail en la misma columna: si la card se generó, dónde está; si falló, por
qué. En una salida de terminal eso es lo que buscas.
La guarda que compara pesos, no ficheros
La primera vez que se lanzó rukh publish cards, rukh-tiny falló: el fichero que trae
rukh pull no es byte a byte el que se evaluó. Aquí está el otro lado del hash de tensores de la
lección 2.
La guarda compara primero el hash del fichero y, si no coincide, el de los tensores. El código se
puede saltar en la primera lectura; si no lo tecleas, git checkout p6 -- src/rukh/publish/model.py
trae el fichero entero.
// Fontaneríapublish/model.py: el hash de tensores como segunda puerta, las métricas de M3 y la tercera plantilla
The evaluation stores ``sha256`` of the checkpoint file and, since P6, of its tensors alone (``weights_sha``). A different file with the same weights -- the copy ``rukh pull`` brings back, which ``rukh publish`` wrote without the optimizer -- passes on the second; a ``step-*.pt`` whose weights differ from ``best.pt`` is still refused (D-135). """ if not evaluation: return measured = str(evaluation.get("model_sha") or "") if not measured: return from rukh.eval.cache import file_sha, weights_sha
actual = file_sha(Path(ckpt)) if measured == actual: return measured_weights = str(evaluation.get("weights_sha") or "") if measured_weights and measured_weights == weights_sha(Path(ckpt)): return raise ValueError( f"stage {stage!r} was measured on a different checkpoint: its results.json records " f"model_sha {measured[:12]} and {Path(ckpt).as_posix()} hashes to {actual[:12]}" + (f" (weights {measured_weights[:12]} vs the file's own)" if measured_weights else "") + ". Evaluate this checkpoint under this stage, or publish the one that was evaluated." )Las salidas tempranas van en orden de coste: sin evaluación, se pasa; sin hash registrado, se pasa;
si el hash de fichero coincide, se pasa sin cargar nada; y solo si no coincide se abre el .pt y se
hashean los tensores. Publicar el fichero que se midió no paga nada.
Una evaluación antigua, sin el campo nuevo, se sigue rechazando igual que antes: la guarda no se aflojó, ganó una segunda forma de aceptar. Y el mensaje de error da las dos salidas: volver a evaluar este checkpoint bajo esta etapa, o publicar el que se evaluó.
def pretrained_encoder_card_context( repo_id: str, stage: str, cfg: ModelPublishConfig, config: dict[str, Any], payload: dict[str, Any], run: RunSummary | None, files: list[str],) -> dict[str, Any]: """The card of a masked-move pretraining run: what it measured, and what starts from it.
A pretraining checkpoint has no blunder F1 and no value correlation to report; rendering it through the heads' card would say "trained from scratch on Stockfish labels" about a model that never saw a label. What it has is the held-out masked-move loss the run selected ``best.pt`` on, which the checkpoint carries, and the top-1 of the same evaluation when the MLflow run is reachable. """ metrics = run.metrics if run else {} top1 = next((metrics[key] for key in ("val_top1", "val/top1") if key in metrics), None) best_val = payload.get("best_val")El generador prueba dos nombres de métrica porque las ejecuciones de M3 usaron los dos según el mes: una línea con valor por defecto en vez de una migración que nadie iba a correr.
from rukh.models import PositionEncoder
if isinstance(model, PositionEncoder): # Pretraining only: no heads on it, so none of the heads' numbers apply. card = render_card( pretrained_encoder_card_context(repo_id, name, cfg, config, payload, run, files), PRETRAINED_ENCODER_CARD_TEMPLATE, ) elif kind == "encoder": card = render_card( encoder_card_context( repo_id, name, cfg, config, evaluation, run, files, measured_parity ), ENCODER_CARD_TEMPLATE, )Con tres plantillas, la expresión condicional anidada que elegía entre dos habría sido ilegible, así
que pasa a if/elif/else antes de añadir el caso. La condición es
isinstance(model, PositionEncoder) y no kind == "encoder": un encoder con cabezas es un
MultiHead que envuelve al PositionEncoder, y el tipo del objeto cargado lo sabe; la cadena del
catálogo, no.
Los tests que atan lo que se publica
Los tests atan las plantillas y el catálogo a lo que piden las configuraciones. Llegaron con el
git checkout del principio: se leen, no se teclean.
// Teststests/unit/test_publish_cards.py y test_hub.py: los enlaces antes de la licencia y todo lo que una configuración pide, descargable
def test_every_card_template_carries_the_course_section() -> None: templates = [p for p in CARDS_DIR.glob("*.md.jinja") if not p.name.startswith("_")] assert len(templates) >= 7 for template in templates: text = template.read_text(encoding="utf-8") assert "{% include '_course.md.jinja' %}" in text, template.name # Before the licence: the links are part of the card, not an afterthought under it. assert text.index("_course.md.jinja") < text.index("## Licen"), template.nametests/unit/test_publish_cards.py
El test recorre el directorio de plantillas, no una lista escrita, así que encoder-mmm.md.jinja
entró en él el día que se creó. La segunda aserción comprueba con un index sobre el texto que los
enlaces van antes de la licencia: humilde, y la única forma de probarlo sin renderizar las siete
plantillas.
def test_the_links_name_the_lesson_the_table_and_the_demo_when_they_apply() -> None: medium = course_links("chorcat/rukh-medium") assert ( medium["lesson_url"] == "https://lab.rukh.borjaglez.com/curso/m2/04-mas-datos-no-mas-red/" ) assert medium["lesson_title"].startswith("M2") assert medium["table_url"] == "https://lab.rukh.borjaglez.com/proyecto/" assert medium["stage"] == "medium-v4-greedy" assert medium["reproduce"] == "uv run rukh pull medium-v4" pairs = course_links("chorcat/rukh-pairs-dpo") assert pairs["lesson_url"].endswith("/curso/m1/02-labs-del-pipeline/") assert pairs["stage"] is None and pairs["demo_url"] is None # A repository the catalogue does not know gets the table and nothing it cannot back. unknown = course_links("chorcat/rukh-nope", course_url="https://x", demo_url="https://d") assert unknown == { "table_url": "https://x/proyecto/", "lesson_url": None, "lesson_title": None, "stage": None, "demo_url": None, "reproduce": None, }tests/unit/test_publish_cards.py
El tercer caso compara con igualdad completa el diccionario de un repositorio desconocido (None en
todo menos la tabla), así que un campo nuevo que olvide ese caso rompe el test.
def test_model_targets_are_run_names_the_configs_use() -> None: """A pulled model must sit exactly where the shipped configs look for it.""" from rukh.paths import package_root
configs = (path for path in (package_root() / "configs").rglob("*.yaml") if path.name[0] != ".") referenced = set() for config in configs: for line in config.read_text(encoding="utf-8").splitlines(): if "checkpoints/" in line and not line.strip().startswith("#"): referenced.add(line.split("checkpoints/", 1)[1].split()[0].strip("'\"")) targets = {a.target.removeprefix("checkpoints/") for a in MODELS} # Runs the reader always trains themselves, because they take minutes and the lesson is in # running them: the 15 M masked-move encoder of M3 and the off-policy DPO arm of M5. local_only = {"encoder-mmm/best.pt", "medium-v4-dpo-offpolicy/dpo.pt"} # Every other checkpoint a shipped config starts from is something `rukh pull` can provide. missing = referenced - targets - local_only assert not missing, missingEste es el mejor test del módulo. Lee todas las configuraciones del repositorio, saca cada ruta de
checkpoints/ que mencionan y comprueba que el catálogo puede proveerlas todas menos dos, escritas
con su razón (el lector siempre las entrena: tardan minutos y la lección está en correrlas). Es un
test sobre una propiedad que nadie mantiene a mano: todo lo que una configuración pide se puede
descargar. El día que aparezca una tercera excepción habrá que escribir aquí su razón, que es el
momento de pensarla.
// Ejercicio 01Publica un modelo que no se midió
Intenta publicar bajo la etapa medium-v4-greedy, que se midió sobre el best.pt de
medium-v4, un checkpoint con otros pesos, y después la copia que trae rukh pull medium-v4, que
es un fichero distinto con los mismos pesos. ¿Qué error sale y qué dos hashes se comparan?
Se hace al terminar la lección 6 (la opción --ckpt resuelve la ruta con resolve_run), siempre
en seco y hacia un repositorio tuyo, porque en chorcat no tienes permiso. Hace falta la
evaluación de esa etapa: la escribe el lab 2, o la traes del hito. Como checkpoint con otros pesos
sirve el de medium-elo, que rukh pull --module m6 ya dejó en disco:
git checkout p6 -- artifacts/eval/medium-v4-greedyuv run rukh pull medium-v4 medium-elouv run rukh publish model --ckpt checkpoints/medium-elo/step-3800.pt --repo <tu-usuario>/rukh-prueba --stage medium-v4-greedy --dry-runuv run rukh publish model --ckpt checkpoints/medium-v4/best.pt --repo <tu-usuario>/rukh-prueba --stage medium-v4-greedy --dry-runSi medium-v4 lo entrenaste tú, rukh pull no toca tu best.pt (sin --force, que lo
borraría) y el segundo comando también falla, con razón: son tus pesos, no los que se midieron.
// SoluciónVer la solución
El primero falla con stage 'medium-v4-greedy' was measured on a different checkpoint: el
hash de fichero no coincide y el de tensores tampoco, porque son otros pesos. El segundo pasa por la segunda puerta: el hash de fichero
no coincide (el publicador quitó el optimizador) y el de tensores sí. Es la distinción del hash
de pesos de la lección 2, y la primera mitad del ejercicio es la que comprueba que la guarda no
se aflojó: otros pesos, como un step-*.pt intermedio, siguen rechazados.
// Ejercicio 02El catálogo como única lista
Cuenta cuántos consumidores distintos recorren catalogue() en este hito, y qué pasaría con cada
uno si el baseline de Karvonen estuviera dentro de la lista en vez de en nightly.py.
// SoluciónVer la solución
Cuatro: rukh pull (por lookup y for_module), rukh eval nightly (por plan),
rukh publish cards (por refresh_cards) y rukh publish collection (por collection_items).
Con el baseline dentro: pull intentaría bajarlo con el formato de nuestros checkpoints y
fallaría; cards generaría una card nuestra y la subiría al repositorio de otra persona, que
es el fallo grave; collection lo metería en nuestra colección como si lo hubiéramos entrenado.
Solo el nightly lo quiere, y por eso solo el nightly lo tiene.
Qué has aprendido
Publicar es dejar escrita una afirmación que otra persona pueda contradecir y montar el camino de vuelta para que lo intente. Vale para un modelo, para un dataset o para el índice de un RAG que otro equipo va a consumir.
Lo consiguen tres piezas: una lista única de lo que existe, de la que tiran todos los consumidores; un camino de vuelta que deja cada artefacto donde las configuraciones lo esperan, de forma atómica y sin pisar lo que hay; y unas fichas generadas desde lo que se midió, que se regeneran solas y suben un fichero de texto en vez de medio giga de pesos.
La lección siguiente vuelve al modelo: el grafo ONNX convertido en un jugador de pleno derecho, y lo que la resolución de nombres de ejecución cambió en el entrenamiento.