rukh · lab

// M2 · lección 09

Publicar el modelo

Los pesos, los tres ONNX, el vocabulario y una model card generada de las métricas que se midieron: cómo se empaqueta un modelo para que alguien más lo pueda usar, por qué safetensors rechaza los tied embeddings y qué tiene que decir una tarjeta para no ser publicidad.

  • tiempo de trabajo145 min
  • nivel medio
  • actualizado el22 de septiembre de 2026

Qué vas a construir

rukh publish model: 423 líneas de Python y una plantilla de 143 que convierten un checkpoint en un repositorio del Hub que alguien que no eres tú puede usar. Los pesos, los tres .onnx, el vocabulario exacto con el que se entrenó, un config.json con la procedencia y una model cardModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde `run.json` y `results.json`, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. `rukh publish cards` las regenera todas desde la tabla y sube solo el README. en inglés generada de las métricas medidas y no escrita a mano.

Esa última parte es la que hace la lección. Una tarjeta escrita a mano es una tarjeta que se queda vieja en la primera reevaluación, y una tarjeta que solo dice lo bueno es un folleto.

publish/model.py

src/rukh/publish/model.py
"""Publish a trained decoder to the Hugging Face Hub: weights, ONNX, tokenizer and card.
One repository per stage holds everything somebody needs to reproduce or run the model: the
PyTorch weights, the fp16 and int8 ONNX files the demo loads, the exact vocabulary the model was
trained on, and an English card written from the MLflow run and the evaluation ``results.json``
rather than by hand, so the numbers in the card are the numbers that were measured.
The folder is always staged locally first (under ``artifacts/publish/<repo>/``) and uploaded in
one ``upload_folder`` call; ``dry_run`` stops after staging and touches no network. Every
``HfApi`` call goes through ``_api`` so the tests can replace it, exactly as ``data/publish``
does.
The head is tied to the token embedding, so ``lm_head.weight`` and ``tokens.weight`` are one
tensor under two names. ``safetensors`` refuses to serialise that (it stores tensors, not
aliases) and counting both would inflate the parameter count by a whole embedding table, so the
tied name is dropped from what is written and the loader re-ties it: ``MoveDecoder`` builds the
tie in its constructor and ``rukh.train.load_state`` accepts the missing name.
"""

src/rukh/publish/model.pylíneas 1-18 · p2

Cuatro párrafos y cuatro decisiones.

Un repositorio por etapa, con todo lo que hace falta para reproducir o ejecutar el modelo. No solo los pesos: también los ONNX que carga la demo y el vocabulario, porque un modelo cuyo tokenizador vive en otro sitio es un modelo que alguien va a ejecutar con el tokenizador equivocado.

La carpeta se prepara en local y se sube en una sola llamada. dry_run para después de prepararla y no toca la red, lo que significa que puedes ver exactamente qué se va a publicar antes de publicarlo.

Todas las llamadas al Hub pasan por _api, igual que en el publicador de datasets de M1, así que los tests lo sustituyen sin fingir la red.

Y la cuarta es la buena, y es una consecuencia directa de la lección 2: la cabeza está atada al embedding, así que lm_head.weight y tokens.weight son un tensor con dos nombres. safetensorssafetensorsFormato de serialización de tensores que el Hub de Hugging Face espera y previsualiza, y que no ejecuta código al leerse. Guarda tensores y no alias, así que un modelo con tied embeddings —donde `lm_head.weight` y `tokens.weight` son un tensor con dos nombres— tiene que publicar el nombre atado fuera del fichero y volver a atarlo al cargar. se niega a serializar eso —guarda tensores, no alias— y contar los dos inflaría el número de parámetros en una tabla de embeddings entera. Así que el nombre atado se quita de lo que se escribe y el cargador lo vuelve a atar. Las dos mitades de ese acuerdo ya las has visto: el constructor de MoveDecoder crea el atado y rukh.train.load_state acepta que ese nombre falte.

src/rukh/publish/model.py
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from pydantic import BaseModel, ConfigDict
from rukh import __version__
from rukh.config import BaseConfig
from rukh.data.publish import CARDS_DIR
from rukh.paths import resolve
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train.checkpoint import TIED_HEAD
log = logging.getLogger(__name__)
CARD_TEMPLATE = "model.md.jinja"
CONFIG_NAME = "config.json"
README_NAME = "README.md"
SAFETENSORS_NAME = "model.safetensors"
TORCH_NAME = "pytorch_model.bin"
VOCAB_PATH = "tokenizer/vocab.json"
ONNX_DIR = "onnx"
ONNX_FILES = ("model-fp16.onnx", "model-int8.onnx", "model.onnx")
REPO_TYPE = "model"

src/rukh/publish/model.pylíneas 20-48 · p2

src/rukh/publish/model.py
class ModelPublishConfig(BaseConfig):
"""Where the staged folder goes and which links the card carries."""
owner: str = "chorcat"
publish_dir: str = "artifacts/publish"
eval_dir: str = "artifacts/eval"
demo_url: str = "https://rukh.borjaglez.com"
course_url: str = "https://lab.rukh.borjaglez.com"
repository_url: str = "https://github.com/borja-glez/rukh"
license: str = "apache-2.0"
datasets: list[str] = ["chorcat/rukh-games-1800", "chorcat/rukh-tokenizer"]

src/rukh/publish/model.pylíneas 51-61 · p2

ModelPublishConfig son once líneas y casi todas son enlaces, porque una tarjeta sin enlace a la demo y al curso es una tarjeta que no lleva a ninguna parte. La licencia por defecto es apache-2.0: el código del proyecto es MIT y los pesos son Apache-2.0, que no es lo mismo y está decidido en el README de M0.

src/rukh/publish/model.py
class RunSummary(BaseModel):
"""The part of an MLflow run a card needs."""
model_config = ConfigDict(extra="forbid")
run_id: str
name: str | None = None
params: dict[str, str] = {}
metrics: dict[str, float] = {}
class ModelPublishResult(BaseModel):
"""What was staged and, unless this was a dry run, uploaded."""
model_config = ConfigDict(extra="forbid")
repo_id: str
repo_type: str = REPO_TYPE
stage: str
dry_run: bool
folder: str
card_path: str
files: list[str]
weights_format: str
"""``safetensors`` or ``torch`` when ``safetensors`` is not installed."""
run_id: str | None = None
params: int
tied_embeddings: bool = False
"""``lm_head.weight`` was left out of the weights file and is re-tied when loading."""

src/rukh/publish/model.pylíneas 64-92 · p2

src/rukh/publish/model.py
def _api(): # type: ignore[no-untyped-def]
"""The ``HfApi`` client (tests monkeypatch this)."""
from huggingface_hub import HfApi
return HfApi()
def _client(): # type: ignore[no-untyped-def]
"""The MLflow client on the local store (tests monkeypatch this)."""
from mlflow.tracking import MlflowClient
from rukh.tracking import tracking_uri
return MlflowClient(tracking_uri=tracking_uri(create=False))

src/rukh/publish/model.pylíneas 95-108 · p2

src/rukh/publish/model.py
def read_run(run_id: str | None = None, run_name: str | None = None) -> RunSummary | None:
"""The MLflow run behind a checkpoint, by id or by the most recent run of that name.
A missing store, a missing run or an MLflow that cannot be reached is not an error: the card
simply falls back to what the checkpoint itself records.
"""
from rukh.tracking import EXPERIMENT
try:
client = _client()
if run_id:
run = client.get_run(run_id)
else:
experiment = client.get_experiment_by_name(EXPERIMENT)
if experiment is None or not run_name:
return None
found = client.search_runs(
[experiment.experiment_id],
filter_string=f"attributes.run_name = '{run_name}'",
order_by=["attributes.start_time DESC"],
max_results=1,
)
if not found:
return None
run = found[0]
except Exception as exc: # noqa: BLE001 - tracking is optional, never fatal for a release
log.warning("could not read the MLflow run (%s: %s)", type(exc).__name__, exc)
return None
return RunSummary(
run_id=run.info.run_id,
name=run.info.run_name,
params={str(k): str(v) for k, v in run.data.params.items()},
metrics={str(k): float(v) for k, v in run.data.metrics.items()},
)

src/rukh/publish/model.pylíneas 111-144 · p2

De dónde sale la receta de la tarjeta: de la ejecución de MLflow, por id o por el nombre más reciente que coincida con el directorio del checkpoint. Y el except Exception con su comentario: una base de MLflow que falta, una ejecución borrada o un MLflow inalcanzable no son un error. La tarjeta se queda con lo que el propio checkpoint registra y se publica igual. Un lanzamiento que se cae porque el servidor de métricas no responde es un lanzamiento con las prioridades cambiadas.

src/rukh/publish/model.py
def read_eval(stage: str, cfg: ModelPublishConfig) -> dict[str, Any] | None:
"""The ``results.json`` the evaluation harness wrote for this stage, if it ran."""
path = resolve(cfg.eval_dir) / stage / "results.json"
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except ValueError:
return None
return payload if isinstance(payload, dict) else None

src/rukh/publish/model.pylíneas 147-156 · p2

src/rukh/publish/model.py
def publish_state(model: Any) -> dict[str, Any]:
"""The state dict as it is published: on the CPU, contiguous and with no aliased tensor.
With tied embeddings ``lm_head.weight`` *is* ``tokens.weight``; it is dropped here so the
file holds every tensor exactly once. Loading re-ties it (see the module docstring).
"""
state = {key: value.detach().cpu().contiguous() for key, value in model.state_dict().items()}
if model.cfg.tie_embeddings:
state.pop(TIED_HEAD, None)
return state

src/rukh/publish/model.pylíneas 159-168 · p2

Nueve líneas donde vive el cuarto párrafo de la cabecera. .contiguous() porque safetensors guarda memoria plana y un tensor transpuesto no lo es; y el state.pop(TIED_HEAD) solo si tie_embeddings, porque un modelo sin atar sí tiene que publicar las dos matrices.

src/rukh/publish/model.py
def write_weights(state: dict[str, Any], folder: Path) -> tuple[str, str]:
"""Write the state dict; returns ``(file name, format)``.
``safetensors`` is the format the Hub expects and the only one it will preview, so it wins
when it is installed; otherwise the weights go out as a torch pickle under its own name
rather than pretending to be something they are not. The state dict must already be free of
aliases (``publish_state``): ``safetensors.save_file`` raises on two names for one storage.
"""
import torch
tensors = {key: value.detach().cpu().contiguous() for key, value in state.items()}
try:
from safetensors.torch import save_file
except ImportError:
log.warning("safetensors is not installed; the weights go out as %s", TORCH_NAME)
torch.save(tensors, folder / TORCH_NAME)
return TORCH_NAME, "torch"
save_file(tensors, str(folder / SAFETENSORS_NAME))
return SAFETENSORS_NAME, "safetensors"

src/rukh/publish/model.pylíneas 171-189 · p2

safetensors gana cuando está instalado, porque es el formato que el Hub espera y el único del que hace vista previa. Cuando no está, los pesos salen como un pickle de torch con su propio nombre, pytorch_model.bin, en vez de fingir ser algo que no son. Esa es la regla del proyecto entera en una línea: degradar está bien, mentir sobre lo que se degradó no.

src/rukh/publish/model.py
def write_config(payload: dict[str, Any], stage: str, params: int, folder: Path) -> dict[str, Any]:
"""Write ``config.json``: the decoder shape plus the provenance of the checkpoint."""
model_cfg = dict(payload.get("model_cfg") or {})
config = {
"architectures": ["MoveDecoder"],
"model_type": "rukh-move-decoder",
"library_name": "rukh",
"rukh_version": __version__,
"stage": stage,
"step": payload.get("step"),
"params": params,
"tokenizer": "uci",
"vocab_hash": payload.get("vocab_hash"),
"data_manifest_sha": payload.get("data_manifest_sha"),
"git_sha": payload.get("git_sha"),
**model_cfg,
}
(folder / CONFIG_NAME).write_text(
json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n"
)
return config

src/rukh/publish/model.pylíneas 192-212 · p2

El config.json es la forma del decoder más la procedencia: el paso, el hash del vocabulario, el SHA del manifiesto de datos y el SHA de git. Los cuatro salen del checkpoint, y son lo que permite a alguien —o a ti, en M4— reconstruir el modelo exacto y saber con qué se entrenó.

El **model_cfg va al final a propósito: la forma del modelo gana sobre cualquier clave que se solape. Y ese config.json es el que eval.suite.hub_checkpoint vuelve a leer para evaluar un modelo publicado, así que el formato es un contrato con el propio harness.

src/rukh/publish/model.py
def copy_onnx(onnx_dir: Path | None, folder: Path) -> list[str]:
"""Copy the exported ONNX files into ``onnx/`` and return what was copied."""
if onnx_dir is None:
return []
source = Path(onnx_dir)
if not source.is_dir():
raise FileNotFoundError(f"no ONNX directory at {source}")
target = folder / ONNX_DIR
target.mkdir(parents=True, exist_ok=True)
copied: list[str] = []
for name in ONNX_FILES:
candidate = source / name
if candidate.is_file():
shutil.copy2(candidate, target / name)
copied.append(f"{ONNX_DIR}/{name}")
if not copied:
raise FileNotFoundError(f"{source} holds none of {', '.join(ONNX_FILES)}")
return copied

src/rukh/publish/model.pylíneas 215-232 · p2

Copiar los ONNX, y dos errores que se lanzan en vez de tragarse: un directorio que no existe y un directorio que no tiene ninguno de los tres ficheros. Publicar un repositorio «con ONNX» que no lleva ONNX es el fallo que nadie detecta hasta que la demo intenta cargarlo.

El orden de ONNX_FILES pone el fp16 y el int8 antes del fp32. No es indiferente: es el orden en que se listan en la tarjeta, y los dos primeros son los que la demo usa.

src/rukh/publish/model.py
def _percent(value: Any) -> str:
return "n/a" if value is None else f"{float(value) * 100:.1f} %"
def _sampled_label(sampled: dict[str, Any]) -> str:
"""The row name of the sampled legality rate, with the setting it was drawn under."""
if not sampled or sampled.get("temperature") is None:
return "Legality without the mask, sampled"
top_k = sampled.get("top_k")
tail = "" if top_k is None else f", top-k {top_k}"
return f"Legality without the mask, sampled (T={float(sampled['temperature']):g}{tail})"
def _elo_cell(elo: dict[str, Any]) -> str:
"""The Elo row: an interval, or the one-sided bound of a separated fit."""
if not elo:
return "n/a"
if elo.get("ci_low") is not None and elo.get("ci_high") is not None:
return f"{elo['elo']:.0f} (95 % CI {elo['ci_low']:.0f}-{elo['ci_high']:.0f})"
if elo.get("elo_lower") is not None:
return f"> {elo['elo_lower']:.0f} (one-sided 95 % bound; every game won)"
if elo.get("elo_upper") is not None:
return f"< {elo['elo_upper']:.0f} (one-sided 95 % bound; every game lost)"
return f"{elo['elo']:.0f} (no interval)"

src/rukh/publish/model.pylíneas 235-258 · p2

Tres funciones de formato y una de ellas es la lección de la lección 6 repetida aquí: el nombre de la fila de la legalidad muestreada incluye la temperatura y el top-k con los que se midió. En la tabla de la tarjeta se lee «Legality without the mask, sampled (T=0.05, top-k 1)», así que nadie puede confundir ese número con el de la demo.

Y _elo_cell es la misma lógica de tres ramas de report.py: intervalo, cota inferior, cota superior. Está duplicada porque la tarjeta lee el results.json ya serializado —diccionarios, no modelos— y unificarlas obligaría a la tarjeta a importar el harness entero.

src/rukh/publish/model.py
def card_context(
repo_id: str,
stage: str,
cfg: ModelPublishConfig,
config: dict[str, Any],
evaluation: dict[str, Any] | None,
run: RunSummary | None,
files: list[str],
) -> dict[str, Any]:
"""Everything the Jinja card needs, with every absent metric spelled ``n/a``."""
elo = (evaluation or {}).get("elo") or {}
accuracy = (evaluation or {}).get("accuracy") or {}
argmax = (evaluation or {}).get("legality_argmax") or {}
sampled = (evaluation or {}).get("legality_sampled") or {}
puzzles = (evaluation or {}).get("puzzles") or {}
metrics = [
("Legality without the mask, argmax", _percent(argmax.get("rate"))),
(_sampled_label(sampled), _percent(sampled.get("rate"))),
("Top-1 next move", _percent(accuracy.get("top1"))),
("Top-3 next move", _percent(accuracy.get("top3"))),
("Puzzles solved", _percent(puzzles.get("rate"))),
("Estimated Elo", _elo_cell(elo)),
]
bands = [
(band["band"], _percent(band["rate"]))
for band in puzzles.get("bands", [])
if isinstance(band, dict)
]
recipe = run.params if run else {}
notes = [str(note) for note in (evaluation or {}).get("notes", []) if str(note).strip()]
return {
"notes": notes,
"tied_embeddings": bool(config.get("tie_embeddings")),
"repo_id": repo_id,
"stage": stage,
"license": cfg.license,
"datasets": cfg.datasets,
"demo_url": f"{cfg.demo_url}/?stage={stage}",
"course_url": cfg.course_url,
"repository_url": cfg.repository_url,
"params": config.get("params", 0),
"config": config,
"config_json": json.dumps(config, indent=2, ensure_ascii=False),
"metrics": metrics,
"puzzle_bands": bands,
"evaluated_on": (evaluation or {}).get("date"),
"suite": (evaluation or {}).get("suite"),
"run_id": run.run_id if run else None,
"recipe": sorted(recipe.items()),
"files": files,
"has_onnx": any(name.startswith(f"{ONNX_DIR}/") for name in files),
"rukh_version": __version__,
}

src/rukh/publish/model.pylíneas 261-313 · p2

El contexto de la plantilla, y el patrón que lo hace honesto: cada métrica ausente se escribe n/a, nunca se omite la fila y nunca se pone un cero. Compáralo con lo que hacía el informe: la misma decisión, tomada dos veces, porque las dos salidas se leen sin el contexto de la otra.

Las notes son las advertencias que el harness escribió —los escalones nominales, el tiempo por jugada, las partidas adjudicadas, los puzles que no se midieron— y van a la tarjeta tal cual. Ahí está la diferencia entre una model card y un folleto: las limitaciones las escribe el que midió, no el que publica.

src/rukh/publish/model.py
def render_card(context: dict[str, Any]) -> str:
"""Render the English model card."""
env = Environment(
loader=FileSystemLoader(str(CARDS_DIR)),
undefined=StrictUndefined,
autoescape=False,
keep_trailing_newline=True,
)
return env.get_template(CARD_TEMPLATE).render(**context)

src/rukh/publish/model.pylíneas 316-324 · p2

StrictUndefined es la decisión importante de estas nueve líneas: una variable que la plantilla usa y el contexto no trae lanza en vez de renderizar una cadena vacía. Sin eso, renombrar una clave del contexto produce una tarjeta con un hueco donde debería estar el Elo, publicada, en internet.

Y autoescape=False porque la salida es Markdown, no HTML: escapar < y & llenaría la tarjeta de entidades.

src/rukh/publish/model.py
def publish_model(
ckpt: Path,
repo: str,
cfg: ModelPublishConfig | None = None,
onnx_dir: Path | None = None,
stage: str | None = None,
run_id: str | None = None,
dry_run: bool = False,
) -> ModelPublishResult:
"""Stage (and unless ``dry_run``, upload) one trained decoder as a Hub model repository."""
from rukh.train import load_model
cfg = cfg or ModelPublishConfig()
ckpt = Path(ckpt)
repo_id = repo if "/" in repo else f"{cfg.owner}/{repo}"
name = stage or repo_id.split("/")[-1].removeprefix("rukh-")
model, payload = load_model(ckpt)
state = publish_state(model)
params = model.num_params(non_embedding=False)
folder = resolve(cfg.publish_dir) / repo_id
folder.mkdir(parents=True, exist_ok=True)
weights_name, weights_format = write_weights(state, folder)
config = write_config(payload, name, params, folder)
UciTokenizer().export(folder / VOCAB_PATH)
files = [weights_name, CONFIG_NAME, VOCAB_PATH, *copy_onnx(onnx_dir, folder)]
run = read_run(run_id, run_name=ckpt.parent.name)
evaluation = read_eval(name, cfg)
card = render_card(card_context(repo_id, name, cfg, config, evaluation, run, files))
card_path = folder / README_NAME
card_path.write_text(card, encoding="utf-8", newline="\n")
if not dry_run:
api = _api()
api.create_repo(repo_id, repo_type=REPO_TYPE, exist_ok=True)
api.upload_folder(
repo_id=repo_id,
folder_path=str(folder),
repo_type=REPO_TYPE,
commit_message=f"Publish {name}",
)
return ModelPublishResult(
repo_id=repo_id,
stage=name,
dry_run=dry_run,
folder=folder.as_posix(),
card_path=card_path.as_posix(),
files=[*files, README_NAME],
weights_format=weights_format,
run_id=run.run_id if run else None,
params=params,
tied_embeddings=model.cfg.tie_embeddings,
)

src/rukh/publish/model.pylíneas 327-380 · p2

El comando entero, de arriba abajo: cargar el modelo, quitar el alias, escribir los pesos, escribir la configuración, exportar el vocabulario, copiar los ONNX, leer la ejecución y la evaluación, renderizar la tarjeta y —si no es un ensayo— crear el repositorio y subir la carpeta.

Dos detalles de usabilidad. repo if "/" in repo else f"{cfg.owner}/{repo}" deja escribir --repo rukh-small sin el dueño. Y name = stage or repo_id.split("/")[-1].removeprefix("rukh-") deduce la etapa del nombre del repositorio, que es lo que hace que chorcat/rukh-small encuentre artifacts/eval/small/results.json sin que se lo digas.

exist_ok=True en create_repo hace que republicar sea idempotente, que es lo que quieres cuando acabas de reevaluar y solo cambia la tarjeta.

src/rukh/publish/__init__.py
"""Publishing trained models to the Hugging Face Hub with cards generated from MLflow."""
from rukh.publish.model import (
CONFIG_NAME,
ONNX_FILES,
README_NAME,
SAFETENSORS_NAME,
TORCH_NAME,
VOCAB_PATH,
ModelPublishConfig,
ModelPublishResult,
RunSummary,
card_context,
copy_onnx,
publish_model,
publish_state,
read_eval,
read_run,
render_card,
write_config,
write_weights,
)
__all__ = [
"CONFIG_NAME",
"ONNX_FILES",
"README_NAME",
"SAFETENSORS_NAME",
"TORCH_NAME",
"VOCAB_PATH",
"ModelPublishConfig",
"ModelPublishResult",
"RunSummary",
"card_context",
"copy_onnx",
"publish_model",
"publish_state",
"read_eval",
"read_run",
"render_card",
"write_config",
"write_weights",
]

src/rukh/publish/__init__.pylíneas 1-43 · p2

La plantilla de la tarjeta

Es el primer .jinja del proyecto que no es de datos, y el que va a copiar cada modelo del curso a partir de aquí. Por partes.

src/rukh/data/cards/model.md.jinja
---
license: {{ license }}
library_name: rukh
pipeline_tag: text-generation
language:
- en
datasets:
{%- for dataset in datasets %}
- {{ dataset }}
{%- endfor %}
tags:
- chess
- rukh
- decoder
- onnx
---

src/rukh/data/cards/model.md.jinjalíneas 1-16 · p2

El front matter que el Hub lee para indexar: la licencia, la biblioteca, la tarea, los datasets de origen y las etiquetas. Los {%- for %} con el guion recortan el espacio en blanco anterior, que en YAML no es cosmético: una línea en blanco de más rompe la lista.

datasets: apunta a los datasets publicados en M1, así que el Hub enlaza el modelo con sus datos automáticamente. Es lo más cerca que se está de una cadena de procedencia sin montar nada.

src/rukh/data/cards/model.md.jinja
# {{ repo_id }}
A GPT decoder written from scratch that plays chess by predicting the next move of a game
written in UCI. This is the `{{ stage }}` stage of [Rukh]({{ repository_url }}), a course
that builds a chess language model end to end: {{ "{:,}".format(params) }} parameters, a
vocabulary of {{ config.get("vocab_size", 2030) }} fixed tokens and a context of
{{ config.get("block", 200) }} moves.
Play against it in the browser: [{{ demo_url }}]({{ demo_url }}) · read how it was built:
[{{ course_url }}]({{ course_url }})

src/rukh/data/cards/model.md.jinjalíneas 18-27 · p2

src/rukh/data/cards/model.md.jinja
## Results
{% if evaluated_on %}Measured with `rukh eval --suite {{ suite }}` on {{ evaluated_on }}.{% else %}
Not evaluated yet: run `rukh eval --model <checkpoint>` to fill this table.{% endif %}
| Metric | Value |
|---|---|
{%- for name, value in metrics %}
| {{ name }} | {{ value }} |
{%- endfor %}
{%- if puzzle_bands %}
Puzzles by difficulty band:
| Band | Solved |
|---|---|
{%- for band, value in puzzle_bands %}
| {{ band }} | {{ value }} |
{%- endfor %}
{%- endif %}

src/rukh/data/cards/model.md.jinjalíneas 29-48 · p2

La tabla de resultados, con un condicional que dice qué se hizo para medirla o, si no se midió, qué comando la llenaría. Un «no evaluado todavía» con la orden al lado es mucho más útil que una tabla de guiones.

src/rukh/data/cards/model.md.jinja
Legality is measured **without** the legality mask, twice, because the two numbers answer
different questions:
- **argmax** is the share of validation positions whose single most likely token is a legal
move, with no temperature and no top-k. It is a property of the weights and it is the
definition behind the "at least 99 % legal" bar of the project.
- **sampled** draws the token exactly as the demo draws it, so it is what a player would meet
with the mask switched off. It is always the lower of the two.
The demo masks illegal moves before sampling, so it never plays one.
{% if notes %}
How to read these numbers:
{% for note in notes %}- {{ note }}
{% endfor %}{% endif %}

src/rukh/data/cards/model.md.jinjalíneas 50-64 · p2

Y aquí está el párrafo que justifica la mitad del módulo, en la tarjeta pública y no en un documento interno: las dos definiciones de legalidad, cuál es el listón, y la frase que cierra —«la demo enmascara las jugadas ilegales antes de muestrear, así que nunca juega una»—. Quien descargue estos pesos sabe qué está midiendo cada número antes de usarlo.

El {% if notes %} de debajo imprime las advertencias del harness bajo el título «How to read these numbers». Ese título es deliberado: no son notas al pie, son instrucciones de lectura.

src/rukh/data/cards/model.md.jinja
## Input and output
The model reads a game as a sequence of tokens and predicts the next one:
```
<bos> <w1800> <b1800> e2e4 e7e5 g1f3 ... <1-0> <eos>
```
The two header tokens are the 100-Elo bins of White and Black. Moves are UCI strings
(`e2e4`, `e7e8q`, and castling as the king's two-square move `e1g1`). The vocabulary is a fixed
enumeration, not learned from data, and ships in `tokenizer/vocab.json`.

src/rukh/data/cards/model.md.jinjalíneas 66-76 · p2

La forma de la entrada, con un ejemplo literal de la secuencia de tokens. Es lo primero que necesita alguien que quiera ejecutar el modelo, y la última frase es la que evita el error de bulto: el vocabulario es una enumeración fija, no aprendida de los datos, y viaja en el repositorio.

src/rukh/data/cards/model.md.jinja
## Files
{% for file in files %}- `{{ file }}`
{% endfor %}
{% if tied_embeddings %}
The language-modelling head is tied to the token embedding, so the weights file holds
`tokens.weight` and **not** `lm_head.weight`: the two are one tensor, and storing it twice would
both break `safetensors` and double-count the embedding in the parameter count. Re-tie it after
loading (`model.lm_head.weight = model.tokens.weight`); `rukh` does it in the constructor.
{% endif %}
{%- if has_onnx %}
The ONNX graph returns only the logits of the **last** step, `(batch, vocab)`: that is all the
demo needs and it keeps the output tensor two hundred times smaller. `model-fp16.onnx` is for
WebGPU and `model-int8.onnx` for the WASM fallback.
{%- else %}
This release carries the PyTorch weights only; the ONNX files the browser demo loads are built
with `rukh export --ckpt <checkpoint> --out <dir> --fp16 --int8`.
{%- endif %}

src/rukh/data/cards/model.md.jinjalíneas 78-95 · p2

Los ficheros, y los dos condicionales que explican lo raro. El de los tied embeddings dice qué falta en el fichero de pesos, por qué, y cómo volver a atarlo; el de los ONNX dice que el grafo devuelve solo el último paso y cuál de los dos ficheros es para cada backend, o —si no se publicaron— el comando que los genera.

Es el patrón de toda la plantilla: cada rareza del formato se explica donde alguien la va a encontrar, no en un documento de diseño.

src/rukh/data/cards/model.md.jinja
## 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 of
the model is in `config.json`.
{% endif %}
{% if run_id %}MLflow run: `{{ run_id }}`.{% endif %}
```json
{{ config_json }}
```

src/rukh/data/cards/model.md.jinjalíneas 97-113 · p2

La receta, sacada de los parámetros de MLflow, y el config.json entero al final. Si la ejecución no estaba disponible, lo dice y remite a config.json. Fíjate en el {% if recipe %}: la alternativa —una tabla vacía— sería peor que una frase.

src/rukh/data/cards/model.md.jinja
## 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): rated standard
games with both players at 1800+ Elo, at least 180 seconds of base time, 20 to 300 plies,
converted from SAN to legal UCI. Validation uses a month the model never saw.
## Limitations
- It is a move-sequence model, not a search engine: it has no lookahead and no evaluation
function, so it blunders tactics that any engine sees instantly.
- It has no board state of its own. Given a position without its move history (a puzzle FEN,
say) it plays from a much shorter prompt than it was trained on and is markedly weaker.
- Without the legality mask it proposes illegal moves at the rate in the table above. Any
application must mask, as the demo does.
- It was trained on games between 1800+ humans on Lichess and imitates them, blunders included.
It is not an oracle of good play and is not conditioned to be one.
- The estimated Elo is a fit to a few hundred games against a limited Stockfish, with a
bootstrap interval: it is an estimate with a confidence interval, not a rating. The interval
covers sampling noise only. The rungs below Stockfish's 1320 floor are nominal `Skill Level`
anchors rather than measured ratings, and games that run out of context are adjudicated on the
final position instead of being scored as draws.

src/rukh/data/cards/model.md.jinjalíneas 115-136 · p2

Los datos y las limitaciones. Las cinco limitaciones no son un formalismo y merecen leerse una a una, porque son exactamente lo que el módulo ha medido:

  1. No es un motor de búsqueda: una pasada hacia delante, sin exploración de variantes y sin función de evaluación, así que falla tácticas que cualquier motor ve al instante.
  2. No tiene estado del tablero propio. Dada una posición sin su historial —un FEN de un puzle— juega desde un prompt mucho más corto que el de su entrenamiento y es notablemente más débil. Es el handicap de los puzles de la lección 5, escrito en la tarjeta.
  3. Sin la máscara propone jugadas ilegales a la tasa de la tabla, y cualquier aplicación tiene que enmascarar.
  4. Imita a humanos de 1800+ de Lichess, errores incluidos. No es un oráculo de buen juego y no está condicionado para serlo.
  5. El Elo es un ajuste a unos cientos de partidas contra un Stockfish limitado, con su intervalo, que cubre solo el ruido de muestreo; los escalones por debajo de 1320 son anclas nominales; y las partidas que se quedan sin contexto se adjudican.

La quinta es la que este módulo aprendió a base de equivocarse, y está en la tarjeta antes de que nadie pregunte.

src/rukh/data/cards/model.md.jinja
## License
{{ license | upper }}. The code and the weights are released under the Apache License 2.0;
the training data comes from Lichess under CC0. Please credit Lichess when you use them.
Generated with `rukh` {{ rukh_version }}.

src/rukh/data/cards/model.md.jinjalíneas 138-143 · p2

Las cuatro licencias del proyecto resumidas en dos frases, y la petición de citar a Lichess. La última línea es la que hace la tarjeta rastreable: la versión de rukh que la generó.

El comando, y los últimos cambios de cli.py

src/rukh/cli.py
@publish_app.command("model")
def publish_model_cmd(
ckpt: Annotated[
Path,
typer.Option(
"--ckpt", exists=True, dir_okay=False, readable=True, help="Checkpoint to publish."
),
],
repo: Annotated[str, typer.Option("--repo", help="Hub repository, e.g. chorcat/rukh-small.")],
onnx: Annotated[
Path | None,
typer.Option("--onnx", exists=True, file_okay=False, help="Directory with the ONNX files."),
] = None,
stage: Annotated[
str | None,
typer.Option("--stage", help="Stage name; default: the repo without `rukh-`."),
] = None,
run_id: Annotated[
str | None, typer.Option("--run-id", help="MLflow run to take the recipe from.")
] = None,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Stage the folder locally; touch no network.")
] = False,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,
) -> None:
"""Stage weights, ONNX, tokenizer and a generated card, and upload them to the Hub."""
from rukh.publish import ModelPublishConfig, publish_model
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
try:
result = publish_model(
ckpt,
repo,
ModelPublishConfig(),
onnx_dir=onnx,
stage=stage,
run_id=run_id,
dry_run=dry_run,
)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if as_json:
typer.echo(result.model_dump_json(indent=2))
return
typer.echo(f"repo: {result.repo_id} ({result.repo_type})")
typer.echo(f"stage: {result.stage} ({result.params:,} parameters)")
typer.echo(f"mode: {'dry-run (staged, nothing uploaded)' if dry_run else 'uploaded'}")
typer.echo(f"weights: {result.weights_format}")
typer.echo(f"run: {result.run_id or 'not found in MLflow'}")
typer.echo(f"folder: {result.folder}")
typer.echo("files:")
for path in result.files:
typer.echo(f" {path}")

src/rukh/cli.pylíneas 579-632 · p2

--dry-run prepara la carpeta y no toca la red, y la salida imprime la carpeta y la lista de ficheros, así que se puede inspeccionar lo que se va a publicar antes de publicarlo. --onnx apunta al directorio de la exportación de la lección 7; sin él, el repositorio sale solo con los pesos de PyTorch y la tarjeta lo dice.

Con esto, cli.py cierra el módulo. Pasó de 350 líneas en M1 a 667, y los cambios que faltaban por ver son tres. La cabecera, que ahora enumera los nueve grupos de órdenes, y un import logging que es el único módulo que las cinco órdenes nuevas necesitan arriba —el resto de sus importaciones van dentro de cada función, para que rukh --help no cargue torch—:

src/rukh/cli.py
"""Command-line interface: info, data, train, play, eval, export, publish, engine, mlflow.
Every command is a thin shell over the library: parse options, call one function, print.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated

src/rukh/cli.pylíneas 1-10 · p2

…y el subgrupo nuevo, que es la única línea de fontanería que hizo falta para colgar publish model del árbol de órdenes:

src/rukh/cli.py
no_args_is_help=True,
add_completion=False,

src/rukh/cli.pylíneas 19-20 · p2

«Cada orden es una cáscara fina sobre la biblioteca: analiza opciones, llama a una función, imprime.» Esa frase de la cabecera es comprobable: de las 318 líneas que M2 añadió a cli.py, ninguna calcula nada. Es la propiedad que hace que los labs, los tests y el curso puedan usar la biblioteca directamente sin pasar por la línea de órdenes.

Publicarlo

Terminal
uv run rukh publish model --ckpt checkpoints/small/best.pt --repo rukh-small \
--onnx artifacts/onnx/small --dry-run

Con --dry-run la carpeta queda en artifacts/publish/chorcat/rukh-small/ y se puede leer entera: model.safetensors, config.json, tokenizer/vocab.json, los tres onnx/*.onnx y el README.md renderizado. Quita la bandera y sube.

Los dos modelos del hito están publicados, modelchorcat/rukh-tiny y modelchorcat/rukh-small. Abre la tarjeta de rukh-small y compara su tabla con la de la lección 6: son los mismos números, generados del mismo results.json, y eso es el punto entero del fichero que acabas de leer.

// Ejercicio 01Publica, vuelve a evaluar y publica otra vez

Con --dry-run, publica tiny. Después evalúa el mismo checkpoint con --suite quick cambiando la temperatura a 0,05 en una copia de la configuración, y vuelve a publicar con --dry-run. ¿Qué cambia en el README.md? ¿Y en config.json?

// SoluciónVer la solución

En el README.md cambian dos cosas: la fila de la legalidad muestreada, que ahora se llama «(T=0.05, top-k 20)» en vez de «(T=0.6, top-k 20)», y sus cifras. _sampled_label lee la temperatura del results.json, así que el nombre de la fila sigue a la medida sin que nadie lo toque. Las notas también cambian, porque LEGALITY_DEFINITIONS interpola la temperatura.

En config.json no cambia nada: describe la forma del modelo y su procedencia, no cómo se evaluó. Esa separación es la que permite reevaluar cien veces sin tocar el contrato del fichero de pesos.

Y hay un tercer efecto que conviene mirar: si evalúas con --stage distinto, read_eval no encuentra el results.json de esa etapa y la tarjeta sale con la tabla de «no evaluado todavía». No es un fallo, es el acoplamiento por nombre haciendo su trabajo, y explica por qué el nombre de etapa se elige una vez y no se toca.

Qué has aprendido

Cómo se empaqueta un modelo para que exista fuera de tu disco, y las tres decisiones que lo hacen utilizable: los pesos sin alias en el formato que el Hub entiende, la procedencia dentro del config.json, y una tarjeta generada de las métricas medidas con sus limitaciones escritas por quien las midió.

Cómo se mide: uv run rukh publish model --ckpt … --repo rukh-small --onnx … --dry-run prepara una carpeta con seis ficheros y un README.md cuya tabla coincide con artifacts/eval/small/results.json, y uv run rukh eval --model chorcat/rukh-small vuelve a evaluar lo publicado descargándolo, que es la comprobación de que el paquete está completo.

Lo siguiente son los labs: los cuatro scripts de labs/m2/ enteros, que son los que producen los dos JSON de las islas y las cuentas que has estado usando todo el módulo.