// M5 · lección 06
Publicar lo alineado: un reward model no es una política
El publicador propio del reward model y su plantilla de card, que lleva la tabla de bandas y las dos correlaciones porque una exactitud sola es la mitad fácil de lo que se midió; más las entradas de registro que publican el dataset on-policy y la etapa nueva de la demo.
Qué vas a construir
Tres cosas que este hito tiene que publicar y que no encajaban en lo que había: un reward model, que no es una política; un dataset construido por el propio modelo; y una etapa nueva que la demo tiene que poder servir.
La primera necesita un publicador y una plantilla de card. Las otras dos son una entrada en un registro y otra en un mapa, que es como suele ser el trabajo de publicar: añadir una entrada a una lista y que todo lo demás la recoja.
Por qué el reward model necesita su propio publicador
rukh publish model prepara pesos, tres exportaciones a ONNXONNXFormato abierto para describir el grafo de una red y sus pesos, independiente del framework que la entrenó. Es lo que permite entrenar en PyTorch y ejecutar en el navegador con onnxruntime-web, sin Python ni backend., un
vocabulario y una card cuyos números salen de rukh eval. Un reward model no tiene nada de eso.
src/rukh/publish/reward.py tiene dos pliegues, y los dos hacen falta para publicar. Si prefieres
no teclearlos, trae el fichero entero ahora, antes de empezarlo, con
git checkout p5 -- src/rukh/publish/reward.py, y lee la sección contra él.
"""Publishing the reward model, which is not a policy and must not be published as one.
`rukh publish model` stages weights, three ONNX exports, a vocabulary and a card whose numbers comefrom `rukh eval`. A reward model has none of that. It has no ONNX because nothing serves it in abrowser; it has no Elo because it does not play; and the suite that measures every other stage ofthis project has no row it belongs in.
What it does have is a number that means something narrow and precise -- how often it orders twomoves the way an engine did -- and three caveats that have to travel with it or the number ismisleading:
* the scale is meaningless, because Bradley-Terry only ever sees differences;* the headline accuracy depends on the **split**, since the seed decides how many mate pairs land in validation and that is the band the model is worst at;* the two correlations it reports do not share a sign, and the reason is that same band.
So the card is written from the run's own `run.json` and leads with all three. A reward modelpublished with one accuracy and no band table would be publishing the easy half of what wasmeasured."""No hay ONNX porque el reward model no juega y la demo no tiene dónde ponerlo; no hay Elo por lo mismo, ni fila en la suite canónica. Lo que tiene es un número estrecho y preciso —con qué frecuencia ordena dos jugadas como un motor— y tres advertencias que tienen que viajar con él: la escala no significa nada, el titular depende del reparto y las dos correlaciones no comparten signo.
Casi toda esta lección es publicación: puedes desplegar los pliegues al escribirlos y quedarte en la primera lectura con la prosa que queda a la vista. Al teclear no se saltan, porque sin ellos el publicador no importa ni escribe nada: despliégalos, salvo que trajeras el fichero entero al empezarlo.
// FontaneríaImportaciones, constantes y _load: el publicador exige el run.json del entrenamiento
from __future__ import annotations
import jsonimport loggingfrom pathlib import Pathfrom typing import Any
from pydantic import BaseModel, ConfigDict
from rukh.models.squares import SQUARE_TOKENSfrom rukh.paths import resolvefrom rukh.publish.model import CONFIG_NAME, README_NAME, ModelPublishConfig, _api, render_card
log = logging.getLogger(__name__)
__all__ = ["RewardPublishResult", "publish_reward"]
REPO_TYPE = "model"REWARD_CARD_TEMPLATE = "reward.md.jinja"SAFETENSORS_NAME = "model.safetensors"El import desde rukh.publish.model reutiliza la fontanería del publicador de políticas; lo que
cambia es qué se sube y qué dice.
class RewardPublishResult(BaseModel): """Where the folder was staged, what went in it, and whether it was uploaded."""
model_config = ConfigDict(extra="forbid")
repo_id: str folder: str files: list[str] uploaded: bool accuracy: floatdef _load(run_dir: Path) -> tuple[dict[str, Any], dict[str, Any]]: """``(checkpoint payload, run result)`` for a reward run directory.""" import torch
checkpoint = run_dir / "reward.pt" if run_dir.is_dir() else run_dir payload = torch.load(checkpoint, map_location="cpu", weights_only=False) result_path = checkpoint.parent / "run.json" if not result_path.is_file(): raise FileNotFoundError( f"{result_path} is missing; the card is written from it, so the run has to have " "been produced by `rukh train reward`" ) return payload, json.loads(result_path.read_text(encoding="utf-8"))_load exige el run.json que hay junto al checkpoint, y su mensaje de error dice qué hacer. Sin
esa guarda, una card generada desde un checkpoint suelto saldría con los valores por defecto: una
card de ceros que parece una card.
def _seed_spread(result: dict[str, Any]) -> str: """The sentence the card uses to say how little the headline deserves to be trusted.
Hard-coded from the runs M5 actually did rather than computed, because the card is about *this* checkpoint while the spread is a property of the configuration. Two numbers matter and the second is the one nobody expects: four seeds spread over two and a half points, **and two runs of the same seed spread over 1.3** -- same split, same hyperparameters, different GPU kernels. """ return ( "72.91 %, 74.21 %, 74.71 %, 74.76 % and 75.19 %. The first two are the **same seed**, " "so 1.3 of those points are not the split: they are what this measurement repeats to" )Una frase fija, escrita a mano porque la dispersión es una propiedad de la configuración, que solo
se conoce corriéndola varias veces. (Recibe result y no lo usa.) El número que nadie espera es el
segundo: dos ejecuciones de la misma semilla se separan 1,3 puntos, por kernels de GPU que no
son deterministas. El criterio del hito está por debajo del ruido de la medición que lo comprueba,
y eso tiene que estar en la card.
// Fontaneríastage y publish: pesos en safetensors, config.json, la card y la ejecución en seco
def publish_reward( run: Path, repo: str, cfg: ModelPublishConfig | None = None, stage: str | None = None, dry_run: bool = False,) -> RewardPublishResult: """Stage (and unless ``dry_run``, upload) a reward model as its own Hub repository.""" cfg = cfg or ModelPublishConfig() run = Path(run) payload, result = _load(run) repo_id = repo if "/" in repo else f"{cfg.owner}/{repo}" name = stage or repo_id.split("/")[-1].removeprefix("rukh-")
encoder_config = payload["encoder_config"] state = payload["model"] params = sum(int(tensor.numel()) for tensor in state.values())
folder = resolve(cfg.publish_dir) / repo_id folder.mkdir(parents=True, exist_ok=True)
from safetensors.torch import save_file
save_file( {key: value.detach().cpu().contiguous() for key, value in state.items()}, str(folder / SAFETENSORS_NAME), ) config = { "model_type": "rukh-reward", "stage": name, "params": params, "pooling": payload.get("pooling"), "encoder": encoder_config, "square_tokens": SQUARE_TOKENS, "objective": "bradley-terry", "training": result.get("checkpoint"), } (folder / CONFIG_NAME).write_text( json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n" )La primera mitad de la publicación. Sin --stage, la etapa se deduce del repositorio
(chorcat/rukh-rm publica rm). Los pesos van en safetensors y no como .pt, porque un .pt
es un pickle y abrirlo ejecuta código en la máquina de quien descarga; por eso cualquier modelo
serio del Hub se publica así. Y el config.json dice model_type: "rukh-reward" y los 69 tokens
de casilla, para que quien descargue sepa qué darle de comer sin leer el curso.
training = payload.get("config") or {} card = render_card( { "license": cfg.license, "repo_id": repo_id, "stage": name, "repository_url": cfg.repository_url, "course_url": cfg.course_url, "datasets": [f"{cfg.owner}/rukh-pairs-dpo"], "params": params, "n_layer": encoder_config.get("n_layer"), "d_model": encoder_config.get("d_model"), "square_tokens": SQUARE_TOKENS, "pairs": result["pairs"], "train_pairs": result["train_pairs"], "val_pairs": result["val_pairs"], "accuracy": result["accuracy"], "accuracy_without_mates": result.get("accuracy_without_mates"), "loss": result["loss"], "pearson": result.get("pearson"), "pearson_without_mates": result.get("pearson_without_mates"), "bands": result.get("bands") or [], "epochs": result.get("epochs"), "batch_size": training.get("batch_size"), "lr": training.get("lr"), "seed": training.get("seed"), "from_scratch": result.get("from_scratch", True), "seed_spread": _seed_spread(result), }, REWARD_CARD_TEMPLATE, ) (folder / README_NAME).write_text(card, encoding="utf-8", newline="\n")Las claves opcionales se leen con result.get(...): a una ejecución antigua sin
accuracy_without_mates le sale una card sin esa fila. Las que van con corchetes son las que, si
faltan, significan que el fichero no es lo que dice ser.
files = [SAFETENSORS_NAME, CONFIG_NAME, README_NAME] uploaded = False 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) uploaded = True log.info("subido %s", repo_id)
return RewardPublishResult( repo_id=repo_id, folder=str(folder), files=files, uploaded=uploaded, accuracy=float(result["accuracy"]), )dry_run deja la carpeta preparada sin tocar la red, para mirar lo que se va a subir. Una vez
apareció ahí un model.safetensors de 25 KB donde debían estar 152 MB: un test mal aislado había
escrito su modelo de juguete en la carpeta real. Contar bytes es la comprobación más barata que
existe, y la única que lo habría pillado.
La plantilla de la card, entera
src/rukh/data/cards/reward.md.jinja es Markdown con Jinja, y el orden de sus secciones es el
argumento: primero qué significa el número, después el número. Va plegada entera; si la saltas,
tráela, porque sin ella el «Ejecútalo» de publicar no tiene card que rellenar:
git checkout p5 -- src/rukh/data/cards/reward.md.jinja.
// Fontaneríareward.md.jinja: la plantilla de la card, sección a sección
---license: {{ license }}library_name: rukhpipeline_tag: feature-extractionlanguage: - endatasets:{%- for dataset in datasets %} - {{ dataset }}{%- endfor %}tags: - chess - rukh - reward-model - preference---src/rukh/data/cards/reward.md.jinja
No existe un pipeline_tag para reward model; feature-extraction es lo más parecido a «esto
puntúa, no genera», y text-generation haría que el widget del Hub intentara generar texto.
# {{ repo_id }}
A **reward model**: it reads a chess position and answers with one number, trained on{{ "{:,}".format(pairs) }} pairs of moves that a chess engine had already ranked. This is the`{{ stage }}` stage of [Rukh]({{ repository_url }}), a course that builds a chess language modelend to end: {{ "{:,}".format(params) }} parameters over {{ n_layer }} layers of width{{ d_model }}, reading the {{ square_tokens }} square tokens of a board.
Read how it was built: [{{ course_url }}]({{ course_url }})
## What the number means, and what it does not
The training objective is Bradley-Terry: `-log sigma(r(chosen) - r(rejected))`. Only**differences** appear in it, so adding a constant to every score changes nothing. The scale hasno units and no origin, and comparing this model's `0.8` with another training run's `1.4` iscomparing two rulers with no zero. What the model is for is **ordering two positions**, and thenumber below is how often it gets that order right.src/rukh/data/cards/reward.md.jinja
«What the number means, and what it does not» va antes que los resultados porque quien llega a una card desde un enlace lee las dos primeras pantallas y nada más.
## Results
Measured on {{ "{:,}".format(val_pairs) }} held-out pairs, split **by game** so that two pairs afew plies apart cannot straddle the split.
| Metric | Value ||---|---|| Accuracy on held-out pairs | {{ "%.2f"|format(accuracy * 100) }} % |{%- if accuracy_without_mates %}| Accuracy over pairs an evaluation can decide | {{ "%.2f"|format(accuracy_without_mates * 100) }} % |{%- endif %}| Preference loss | {{ "%.4f"|format(loss) }} |{%- if pearson is not none %}| Margin vs engine gap (Pearson) | {{ "%+.3f"|format(pearson) }} |{%- endif %}{%- if pearson_without_mates is not none %}| Margin vs engine gap, without mates (Pearson) | {{ "%+.3f"|format(pearson_without_mates) }} |{%- endif %}src/rukh/data/cards/reward.md.jinja
Las dos correlaciones van en la misma tabla, una debajo de otra, porque no comparten signo: separarlas permitiría citar una sola.
### Where it is right and where it is wrong
Accuracy by how far apart the engine scored the two moves. The **mate** band is the one to readfirst: it is the largest slice after the narrowest band, and it is where the model is worst.
| Engine gap | Pairs | Accuracy ||---|---:|---:|{%- for band in bands %}| {% if band.band == "mate" %}mate{% else %}{{ band.band }} cp{% endif %} | {{ band.pairs|int }} | {{ "%.2f"|format(band.accuracy * 100) }} % |{%- endfor %}
Read the `Pairs` column before the `Accuracy` one. A band of a dozen pairs reports whatever thosedozen did: across five runs of this configuration the `800-2000` band gave 90 %, 68 %, 90 %, 50 %and 57 %, which is the same absence of data wearing five faces.
A mate is a tactical fact, and what this model reads is a static view of the position the moveleads to: it cannot see one. That band is exactly what a **verifiable** reward answers withouterror, which is why the same milestone that trained this model also wrote one.
The two correlations above do not share a sign, and that is not a typo. The mate pairs carry thelargest engine gap by construction and the smallest learned margin, so a third of the points bendsthe line the other way. One correlation over two regimes describes neither.src/rukh/data/cards/reward.md.jinja
El desglose por bandas lleva escrito cómo leerlo: primero la columna de pares, después la de acierto. En cinco ejecuciones de esta configuración, la banda de 800-2 000 dio 90 %, 68 %, 90 %, 50 % y 57 %: la misma ausencia de dato con cinco caras. Y la banda de mate es justo lo que una recompensa verificable contesta sin error, que es por qué el mismo hito escribió también la otra.
## What it is not for
**DPO does not need this model.** Direct Preference Optimization has an implicit reference -- afrozen copy of the starting policy -- and trains on the pairs directly. A reward model is what PPOneeds, what a learned reward is compared against, and what makes the accuracy above a number worthpublishing at all.
## Training
{{ "{:,}".format(pairs) }} pairs, {{ "{:,}".format(train_pairs) }} for training and{{ "{:,}".format(val_pairs) }} held out, {{ epochs }} epochs, batch {{ batch_size }}, learning rate{{ lr }}, seed {{ seed }}.
{% if from_scratch %}Trained **from a random initialisation**, and that was measured rather thanassumed: starting from the project's pretrained position encoder scored about 6.5 points *worse*.That encoder learned to fill in masked moves; this task is ordering positions by how good theyare, and what it brought did not help.{% else %}Started from a pretrained position encoder.{% endif %}
The seed also decides the split, so it decides how many mate pairs land in validation -- and theheadline number moves with that. Four seeds of this exact configuration gave{{ seed_spread }}. Compare two runs only if they shared a seed.
## Reproducing
```bashuv run rukh train reward --config configs/train/rm.yaml```
## Licence
{{ license }}. Trained on engine evaluations of public Lichess games.src/rukh/data/cards/reward.md.jinja
El resto dice para qué no sirve (DPO no lo necesita), los hiperparámetros, la dispersión entre
semillas y el comando para reproducirlo. En la card publicada verás «Four seeds» seguido de cinco
cifras: dos son la misma semilla ejecutada dos veces, y la frase de la plantilla no se actualizó.
Tampoco es exacta la de que el encoder «learned to fill in masked moves»: el único encoder de
casillas, el que se probó como punto de partida, es el de cabezas de M3, afinado para valor, error
grave y resultado (rm.yaml lo cuenta bien, lección 2).
El dataset y la demo: una entrada en cada registro
Publicar el conjunto on-policy es una entrada en el registro de datasets; el registro se encarga de los patrones de fichero, la card y los recuentos.
Las dos entradas de registro son fontanería; se pueden saltar al leer. Al teclear no: sin la
primera, rukh data publish --name rukh-pairs-onpolicy no conoce el dataset. Si las saltas,
tráelas con los tres ficheros que tocan, que en esta lección no cambian en nada más:
git checkout p5 -- src/rukh/data/publish.py src/rukh/publish/model.py .gitignore.
// FontaneríaEl registro de datasets, el mapa de etapas de la demo y el .gitignore de la caché
DatasetSpec( name="rukh-pairs-onpolicy", repo_type="dataset", local_dir="data/pairs-onpolicy", patterns=["pairs.parquet", "manifest.json"], command="onpolicy", pretty_name="Rukh on-policy pairs", description=( "Preference pairs built from the moves the model itself proposes. The positions " "are exactly those of `rukh-pairs-dpo`, so the only thing that differs between the " "two datasets is where the two moves came from -- which is what makes a DPO run on " "each of them a comparison. Four moves sampled per position at temperature 1.0, " "scored by Stockfish at a fixed depth of 10, keeping the best and the worst when " "they are at least 100 centipawns apart.\n\n" "Of the 13 838 positions, 6 386 produced a pair. The rest is the interesting half: " "3 743 (27 %) because the model proposed a single distinct legal move, and 3 709 " "(27 %) because its best and worst candidate were less than a pawn apart. And only " "2.9 % of these pairs involve a mate, against a third of the off-policy ones: a " "multi-PV search finds mates the model was never going to propose, so the two " "datasets are two distributions of difficulty and not two sources of one thing." ), columns=[ ("game_id", "game the position came from, for splitting without leakage"), ("ply", "half-move index of the position"), ("prefix", "moves played up to the position (UCI, space separated)"), ("chosen", "best of the sampled moves (UCI)"), ("rejected", "worst of the sampled moves (UCI)"), ("cp_chosen", "score of `chosen` for the side to move (mate = +/-10000)"), ("cp_rejected", "score of `rejected` for the side to move"), ("phase", "`opening`, `middlegame` or `endgame`"), ("white_elo", "Elo header the model was prompted with"), ("black_elo", "Elo header the model was prompted with"), ("candidates", "distinct legal moves the model proposed here (2 to 4)"), ], source="`rukh-pairs-dpo` for the positions, `chorcat/rukh-medium` for the moves", source_datasets=["original"], task_categories=["text-generation"], tags=["dpo", "preferences", "on-policy"], # `positions` counts what was *visited*, not what was written: 13 838 against 6 386 # rows. Summing it into the row count would double the size of the dataset on the card. exclude_counts=["positions"], ),Casi todo es prosa de la card. La descripción dice que los pares salen de las posiciones de
rukh-pairs-dpo y las jugadas de chorcat/rukh-medium, que es lo que hace el dataset reproducible
sin leer el curso. Y exclude_counts=["positions"] evita que el generador de cards sume pairs
(6 386) y positions (13 838) como filas y anuncie más del doble del tamaño real.
DEMO_STAGES = { "tiny": "tiny-int8", "small": "small-fp16", "medium": "medium-fp16", "medium-dpo": "medium-dpo-fp16", "medium-grpo": "medium-grpo-fp16", "medium-elo": "medium-elo-fp16", "medium-lora": "medium-lora-fp16",}Para la demo basta una línea, "medium-grpo": "medium-grpo-fp16", en el mapa que traduce un
repositorio del Hub al identificador de etapa que la demo entiende. Sin ella, el enlace de la card
caería al modelo por defecto y prometería una partida contra un modelo que la demo nunca carga.
# The GRPO engine cache is the same kind of thing: a `(FEN, move) -> centipawns` dictionary that# any run rebuilds from Stockfish at a fixed depth. Megabytes of it, and reproducible by# definition, since the depth limit is what makes the analysis deterministic (D-118)./artifacts/grpo-engine-cache.json/artifacts/grpo-cache-*.jsonLa caché de motor de GRPO son megabytes que cualquier ejecución reconstruye desde Stockfish a profundidad fija, así que no se versiona, como las cachés de evaluación de encima.
El subcomando
Fontanería de CLI que se puede saltar al leer; al teclear no, porque el «Ejecútalo» de debajo la
usa. Si la saltas, tráela con git checkout p5 -- src/rukh/cli.py (si ya lo trajiste en una
lección anterior del módulo, ya la tienes).
// Fontaneríarukh publish reward: el subcomando
@publish_app.command("reward")def publish_reward_cmd( run: Annotated[ Path, typer.Option("--run", exists=True, help="Reward run directory or `reward.pt` checkpoint."), ], repo: Annotated[str, typer.Option("--repo", help="Hub repository, e.g. chorcat/rukh-rm.")], stage: Annotated[str | None, typer.Option("--stage", help="Stage name for the card.")] = 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: """Publish the reward model, with the band table and both correlations on the card.
It gets its own command because it is not a policy: no ONNX, because nothing serves it in a browser, and no Elo, because it does not play. What it has is an ordering accuracy, and that number is only honest next to the split it was measured on. """ from rukh.publish.reward import publish_reward
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") result = publish_reward(run, repo, stage=stage, dry_run=dry_run) if as_json: typer.echo(result.model_dump_json(indent=2)) return where = "uploaded to" if result.uploaded else "staged for" typer.echo(f"{where} {result.repo_id}: {', '.join(result.files)}") typer.echo(f"accuracy: {result.accuracy * 100:.2f} % on held-out pairs") typer.echo(f"folder: {result.folder}")Su docstring resume la lección: sin ONNX, sin Elo, y un número que solo es honesto al lado del reparto sobre el que se midió.
Para la política de GRPO es rukh publish model, el de M2, con la misma bandera. Antes necesita su
exportación a ONNX y su evaluación canónica, que están en el lab 11 de la lección 9:
uv run rukh publish model --ckpt checkpoints/medium-v4-grpo/grpo.pt --repo chorcat/rukh-medium-grpo --stage medium-v4-grpo-greedy --onnx artifacts/onnx/medium-grpo --dry-runEl --stage tiene que ser el mismo de la evaluación canónica, o la card sale sin números. Y si el
checkpoint no es el que se midió, el publicador se niega a publicar los números de otro modelo.
El test que comprueba que la card no se vacía
Lo trajiste con la caja del principio: aquí se lee, no se teclea.
// Teststests/unit/test_publish_reward.py: la card no se vacía y la ejecución en seco no toca la red
def test_the_card_carries_the_bands_and_both_correlations(rukh_home, tmp_path): result = publish_reward(_run_dir(tmp_path), "chorcat/rukh-rm", dry_run=True) assert result.uploaded is False # The env var is RUKH_HOME, not RUKH_ROOT. Getting it wrong is silent -- the test passes and # stages into the real `artifacts/publish/`, quietly replacing a published folder with toy # weights. It happened once; this line is why it cannot happen twice. assert Path(result.folder).is_relative_to(rukh_home) card = (Path(result.folder) / "README.md").read_text(encoding="utf-8")
# The number, and the number that is not the same number. assert "72.91 %" in card assert "73.62 %" in card # Both correlations, with their signs, because they do not share one. assert "-0.034" in card assert "+0.058" in card # The band table, including the one the model is worst at. assert "| mate | 484 | 71.49 % |" in card # And the sentence that stops the headline being read as stable. assert "75.19 %" in card, "the spread across seeds has to travel with the number" assert "same seed" in card, "and so does the part of it that is not the split"
def test_the_run_has_to_have_been_measured(rukh_home, tmp_path): """A card written from a missing file would be a card of defaults.""" folder = _run_dir(tmp_path) (folder / "run.json").unlink() with pytest.raises(FileNotFoundError): publish_reward(folder, "chorcat/rukh-rm", dry_run=True)
def test_nothing_reaches_the_network_on_a_dry_run(rukh_home, tmp_path, monkeypatch): def _boom(): raise AssertionError("a dry run must not build an API client")
monkeypatch.setattr("rukh.publish.reward._api", _boom) result = publish_reward(_run_dir(tmp_path), "chorcat/rukh-rm", dry_run=True) assert set(result.files) == {"model.safetensors", "config.json", "README.md"}tests/unit/test_publish_reward.py
El primero comprueba sobre una card generada de verdad que están los dos números de exactitud, las dos correlaciones con su signo, la fila de mate y las frases sobre la dispersión entre semillas. Lo primero que pierde quien edita una plantilla es lo que este test fija.
El comentario del medio explica aquel model.safetensors de 25 KB: la variable de entorno es
RUKH_HOME y no RUKH_ROOT, y equivocarse es silencioso; el test pasaba y escribía pesos de
juguete en el artifacts/publish/ de verdad. El assert impide que vuelva a pasar.
El tercero prohíbe la red en una ejecución en secoEjecución en seco (dry run)Recorrer entero el camino de un comando sin el efecto que la hace irreversible —sin red, sin subir nada, sin escribir—; en inglés, dry run, y en Rukh el flag --dry-run. Vale la pena porque ejercita justo lo que suele romperse (qué se iba a subir, con qué nombre y a qué repositorio) sin publicar nada, y los tests lo usan para probar publicadores sin tocar el Hub. de la forma más directa: sustituye el cliente de la API por
una función que revienta.
Qué has aprendido
Un artefacto que no encaja en tu publicador necesita el suyo, reutilizando la fontanería y cambiando lo que se dice. Una card es el sitio donde una cifra pierde el contexto, así que las advertencias van antes que el número y en la misma tabla; vale para un reward model, un clasificador o un embedder. Y casi todo el trabajo de publicar es añadir una entrada a un registro y acordarse de la excepción que necesita.
Lo siguiente es el último sitio donde llegan estos modelos: el navegador de quien juega contra ellos.