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 trabajo130 min
  • nivel medio
  • actualizado el23 de septiembre de 2026

Qué vas a construir

rukh publish model: un módulo y una plantilla que convierten un checkpoint en un repositorio del Hub que otra persona puede usar. Los pesos, los tres .onnx, el vocabulario exacto, 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.

Esa última parte es la que hace la lección. Una tarjeta escrita a mano se queda vieja en la primera reevaluación, y una que solo dice lo bueno es un folleto. Nada de esto es propio del ajedrez: es lo que harías para publicar un LLM afinado, un clasificador o un modelo de embeddings.

publish/model.py

El módulo reúne pesos, ficheros ONNX, vocabulario y métricas en una carpeta y la sube al Hub. No hay nada de ML en él y puedes saltarlo en la primera lectura; lo que importa es que la tarjeta se escribe con los números de la evaluación, no a mano. Si lo saltas, tráelo: git checkout p2 -- src/rukh/publish/model.py src/rukh/publish/__init__.py; sin ellos no existe rukh publish y el ensayo del final no arranca.

// Fontaneríapublish/model.py y publish/__init__.py: leer la ejecución y la evaluación, escribir pesos, config.json y tarjeta, y subir la carpeta al Hub
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 decisiones:

  • Un repositorio por etapa con todo lo necesario para ejecutar el modelo, vocabulario incluido: un modelo cuyo tokenizador vive en otro sitio acabará ejecutándose con el equivocado.
  • La carpeta se prepara en local y se sube de una vez; dry_run para antes de tocar la red, así que ves qué se va a publicar antes de publicarlo.
  • Todas las llamadas al Hub pasan por _api, que los tests sustituyen.
  • La cabeza atada es un tensor con dos nombres, y 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 alias. El nombre atado se quita al escribir y el cargador lo vuelve a atar: ya has visto las dos mitades del acuerdo en MoveDecoder y en rukh.train.load_state.
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

La mitad de los campos son enlaces (demo, curso, repositorio, datasets), porque una tarjeta sin enlaces no lleva a ninguna parte. La licencia por defecto es la de los pesos, apache-2.0; el código del proyecto es MIT.

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

La receta de la tarjeta sale de MLflow. Si la base falta o la ejecución se borró, la tarjeta se queda con lo que registra el checkpoint y se publica igual: un lanzamiento que se cae porque el servidor de métricas no responde tiene 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

.contiguous() porque safetensors guarda memoria plana, y el pop(TIED_HEAD) solo si el modelo está atado.

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

Sin safetensors instalado, los pesos salen como pytorch_model.bin, con su propio nombre, en vez de fingir ser lo que no son. 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 (paso, hash del vocabulario, SHA de los datos y de git), lo que permite reconstruir el modelo exacto. Y es el fichero que eval.suite.hub_checkpoint vuelve a leer para evaluar un modelo publicado: 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

Un directorio de ONNX que no existe o que no tiene ninguno de los tres ficheros es un error: un repositorio «con ONNX» sin ONNX solo se descubre cuando la demo intenta cargarlo.

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

El nombre de la fila de legalidad muestreada incluye la temperatura y el top-k con que se midió: nadie puede confundir «Legality without the mask, sampled (T=0.05, top-k 1)» con la cifra de la demo. _elo_cell repite la lógica de report.py porque la tarjeta lee el results.json ya serializado, y unificarlas obligaría 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

Cada métrica ausente se escribe n/a, nunca se omite ni se pone a cero. Y las notes del harness van a la tarjeta tal cual: las limitaciones las escribe el que midió, no el que publica, y esa es la diferencia entre una model card y un folleto.

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 hace que una variable que falta lance en vez de renderizar vacía; sin eso, renombrar una clave produce una tarjeta con un hueco donde debería estar el Elo, publicada en internet. autoescape=False porque la salida es Markdown.

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

De arriba abajo: cargar, quitar el alias, escribir pesos y configuración, exportar el vocabulario, copiar los ONNX, leer ejecución y evaluación, renderizar la tarjeta y, si no es un ensayo, subir. La etapa se deduce del nombre del repositorio, así que chorcat/rukh-small encuentra artifacts/eval/small/results.json solo, y exist_ok=True hace que republicar tras una reevaluación sea idempotente.

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

El primer .jinja del proyecto que no es de datos, y el que copiará cada modelo del curso. Por partes.

La plantilla se lee por partes al teclearla; en la primera lectura basta con saber que cada cifra de la tarjeta sale de results.json. Si la saltas, tráela: git checkout p2 -- src/rukh/data/cards/model.md.jinja; sin ella, el ensayo del final falla al escribir la tarjeta.

// Fontaneríacards/model.md.jinja: la plantilla de la model card, 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. El guion de {%- for %} recorta el espacio anterior, que en YAML importa. Y datasets: apunta a los de M1, así que el Hub enlaza el modelo con sus datos 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

Si no se midió, la tabla dice qué comando la llenaría: mucho más útil que una fila 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

Las dos definiciones de legalidad van en la tarjeta pública, con el listón y la frase de que la demo enmascara antes de muestrear. Las advertencias del harness van debajo bajo «How to read these numbers»: instrucciones de lectura, no notas al pie.

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, y la frase que evita el error de bulto: el vocabulario es una enumeración fija 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

Cada rareza del formato se explica donde alguien la va a encontrar: qué falta en los pesos atados y cómo volver a atarlos, y qué fichero ONNX es para cada backend.

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

Si la ejecución de MLflow no estaba, la tarjeta lo dice con una frase en vez de una tabla vacía.

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

Las cinco limitaciones son lo que el módulo ha medido:

  1. No es un motor de búsqueda: una pasada hacia delante, sin explorar variantes, así que falla tácticas que cualquier motor ve al instante.
  2. Sin historial (un FEN de un puzle) juega desde un prompt mucho más corto que el de su entrenamiento y es más débil: el handicap de los puzles, escrito en la tarjeta.
  3. Sin máscara propone jugadas ilegales a la tasa de la tabla.
  4. Imita a humanos de 1800+ de Lichess, errores incluidos.
  5. El Elo es un ajuste a unos cientos de partidas contra un Stockfish limitado, con un intervalo que solo cubre el ruido de muestreo.
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

Una errata que conviene ver: la primera frase dice que el código también es Apache-2.0, y el repositorio rukh es MIT según su LICENSE. Los pesos, que es lo que la tarjeta publica, sí son Apache-2.0. La última línea hace la tarjeta rastreable: la versión de rukh que la generó.

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

El comando es una cáscara sobre la biblioteca; se puede saltar. Si lo saltas, tráelo: git checkout p2 -- src/rukh/cli.py (si ya trajiste el cli.py entero en la lección 3, lo tienes, con el subgrupo publish incluido).

// Fontaneríacli.py: el subcomando rukh publish model y el subgrupo publish
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 imprime la carpeta y la lista de ficheros sin tocar la red. --onnx apunta a la exportación de la lección 7; sin él, la tarjeta dice que no hay ONNX.

Con esto cli.py cierra el módulo, de 350 líneas en M1 a 667. Faltan la cabecera y un import logging; el resto de 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 que cuelga publish model del árbol de subcomandos:

src/rukh/cli.py
publish_app = typer.Typer(help="Publish trained models to the Hub.", no_args_is_help=True)
app.add_typer(publish_app, name="publish")

src/rukh/cli.pylíneas 28-29 · p2

Ninguna de las 318 líneas que M2 añadió a cli.py calcula nada. Por eso los labs, los tests y el curso pueden usar la biblioteca directamente.

Publicarlo

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

Con --dry-run la carpeta queda en artifacts/publish/chorcat/rukh-small/ para leerla entera; quita la bandera y sube.

Subir, eso sí, a tu cuenta. publish model no tiene opción de owner: ModelPublishConfig lo fija en chorcat y la CLI construye la configuración sin leer ningún YAML. Pero el nombre del repositorio manda: si --repo lleva una barra, se usa tal cual (repo_id = repo if "/" in repo else …), y el owner por defecto solo se pone delante de un nombre sin ella. Con la cuenta, el token y el uv run hf auth login de «Publicar el dataset»:

Terminal
uv run rukh publish model --ckpt checkpoints/small-<fecha>/best.pt --repo <tu-usuario>/rukh-small --onnx artifacts/onnx/small

La etapa se sigue deduciendo del final del nombre (small), así que la tarjeta encuentra su results.json igual, y la carpeta local pasa a ser artifacts/publish/<tu-usuario>/rukh-small/. Lo único que no cambia es la lista datasets: de la cabecera de la tarjeta, que sigue citando chorcat/rukh-games-1800 y chorcat/rukh-tokenizer: son los datasets del curso, y es verdad que el modelo se entrenó con datos iguales a esos. Con --repo rukh-small a secas y tu token, la subida falla por permisos.

Los dos modelos del hito están publicados, modelchorcat/rukh-tiny y modelchorcat/rukh-small. La card que generas tú trae los números de tu evaluación, pero solo de la fila que se llama como la etapa: --repo rukh-small lee artifacts/eval/small/results.json, la fila small con el muestreo de la demo que la lección 6 saca con --suite quick --stage small. Si solo evaluaste small-greedy, la tarjeta dirá «no evaluado todavía». Tienes dos salidas: evaluar la fila small (el comando está en la lección 6) o publicar con --stage small-greedy, y entonces el paquete se llama así en config.json y en la tarjeta, con los números de la lección 6. La tarjeta que ves hoy en el Hub tampoco trae los números de la lección 6: chorcat/rukh-small se volvió a publicar con small v3, el de la lección 11, así que su tabla es la de esa corrida.

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

Con --dry-run, publica tiny. Evalúa el mismo checkpoint con --suite quick --stage tiny y temperatura 0,05 en una copia de configs/eval/quick.yaml que pasas con --config, y vuelve a publicar. ¿Qué cambia en el README.md? ¿Y en config.json?

// SoluciónVer la solución

En el README.md cambian la etiqueta de la fila muestreada, «(T=0.05, top-k 20)», sus cifras y las notas, porque todo se lee del results.json.

En config.json no cambia nada: describe la forma y la procedencia, no cómo se evaluó, y por eso puedes reevaluar cien veces sin tocar el fichero de pesos. Y si evalúas con otro --stage, la tarjeta no encuentra su results.json y sale «no evaluado todavía»: el acoplamiento por nombre haciendo su trabajo.

Qué has aprendido

Cómo se empaqueta un modelo para que exista fuera de tu disco: pesos sin alias en el formato que el Hub entiende, la procedencia en config.json y una tarjeta generada de las métricas, con sus limitaciones escritas por quien las midió. Vale igual para el primer LLM que afines.

La prueba de que el paquete está completo es evaluar lo publicado: uv run rukh eval --model <tu-usuario>/rukh-small --suite quick --stage small-hub descarga tu paquete del Hub y lo mide (sin --stage, la fila se llamaría hub). Con chorcat/rukh-small mediría el del curso, que es la v3 de la lección 11, no el tuyo.

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