rukh · lab

// M1 · lección 08

Publicar el dataset

El registro de los siete artefactos publicables, la card de Jinja que se rellena con el manifiesto, la subida en un commit con reanudación para los ficheros grandes, y el --dry-run que te deja verlo todo sin tocar la red.

  • tiempo de trabajo105 min
  • nivel medio
  • actualizado el23 de septiembre de 2026

Qué vas a construir

El último paso del pipeline, y el único que sale del disco. rukh data publish coge uno de los siete artefactos del módulo, genera su card a partir del manifiesto y lo sube a Hugging Face, a la cuenta que diga la configuración: chorcat en la del curso, la tuya cuando la cambies al final de la lección.

La subida son tres llamadas a una librería; lo interesante es la card. Los filtros, conteos, hashes y la versión de rukh que registraron los pasos anteriores se vuelcan tal cual en el README del dataset. Es la procedenciaManifiesto de datosFichero JSON que acompaña a cada recorte con los filtros exactos, los meses, los conteos y el hash de cada fichero. Sin manifiesto no hay reproducibilidad: es lo que permite decir qué datos vio un modelo. convertida en documentación que nadie escribe a mano y que por eso no se queda vieja, igual que valdría para la ficha de un modelo en un registro interno. Son un fichero de Python, una plantilla y el comando que los junta.

Tres dependencias nuevas

pyproject.toml
dependencies = [
"typer>=0.27",
"pydantic>=2.13",
"pyyaml>=6",
"mlflow>=3.16",
"python-chess>=1.999",
"duckdb>=1.5",
"polars>=1.44",
"pyarrow>=25",
"zstandard>=0.25",
"numpy>=2",
"huggingface_hub>=1.32",
"datasets>=5",
"tokenizers>=0.22",
"jinja2>=3.1",
"requests>=2.32",
]

pyproject.tomllíneas 9-25 · p1

Las tres últimas son de M1: tokenizers para el BPE, jinja2 para la plantilla y requests para los zips de la base Elite. Si ya añadiste tokenizers, faltan dos: uv add --no-sync "jinja2>=3.1" "requests>=2.32" y después el uv sync con tu extra de siempre.

publish.py: el registro

src/rukh/data/publish.py
"""Publish the derived datasets and the tokenizer to the Hugging Face Hub under ``chorcat``.
``DATASETS`` is the registry: repo name, local directory, files, description, columns and
license. ``publish`` renders the card (Jinja, English, HF YAML metadata), creates the repo and
uploads the matching files in one commit with ``upload_folder`` (``upload_large_folder`` for
the two multi-gigabyte datasets, which resumes and uploads in parallel), then the card. With
``dry_run`` nothing touches the network: the card is written under
``data/publish/<name>/README.md`` and the files are listed. All ``HfApi`` calls go through
``_api`` so tests can replace it.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Literal
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from pydantic import BaseModel, ConfigDict
from rukh.config import BaseConfig
from rukh.data.manifest import Manifest
from rukh.paths import resolve
CARDS_DIR = Path(__file__).resolve().parent / "cards"
LICHESS_CC0 = (
"CC0 1.0. The games, puzzles and evaluations come from the Lichess open database "
"(https://database.lichess.org, https://huggingface.co/Lichess), published under CC0; "
"the derived files keep that license. Please credit Lichess when you use them."
)
ELITE_LICENSE = (
"CC0 1.0 de facto. The games come from Lichess (CC0) through the Lichess Elite Database "
"curated by nikonoel (https://database.nikonoel.fr), which publishes no explicit license; "
"credit both Lichess and the Elite Database."
)
UCI_COLUMNS: list[tuple[str, str]] = [
("game_id", "signed 64-bit hash of the Lichess `Site` URL (first 8 bytes of SHA-256)"),
("uci", "space-separated UCI moves of the whole game, verified legal with python-chess"),
("n_plies", "number of half-moves"),
("white_elo", "White's Glicko-2 rating at game time"),
("black_elo", "Black's Glicko-2 rating at game time"),
("result", "`1-0`, `0-1` or `1/2-1/2`"),
("time_control", "Lichess `base+increment` in seconds"),
("utc_date", "date the game was played (UTC)"),
("eco", "ECO opening code"),
("month", "`YYYY-MM` partition the game came from"),
]

src/rukh/data/publish.pylíneas 1-47 · p1

El «under chorcat» de la primera línea del docstring es el valor por defecto: la cuenta sale de PublishConfig.owner, que al final de la lección apuntas a la tuya sin tocar este fichero.

Hay dos licencias. LICHESS_CC0 cubre lo que deriva del volcado público de Lichess. La base Elite no publica una licencia explícita aunque su contenido venga de Lichess, así que ELITE_LICENSE dice “CC0 de facto” y pide citar a los dos: poner “CC0” a secas sería afirmar algo que el tercero no ha dicho. UCI_COLUMNS documenta una sola vez las columnas que comparten tres datasets.

El resto del registro es plomería del Hub: qué ficheros sube cada dataset, cómo se rellena la card y en qué orden van las llamadas de red. Puedes saltarlo en la primera lectura y quedarte con la idea del recuadro de debajo, pero sin él rukh data publish no existe. Si no vas a teclearlo, trae publish.py entero antes de empezarlo, con git checkout p1 -- src/rukh/data/publish.py, y lee sobre tu copia lo que queda a la vista; si ya tecleaste el principio, no lo pises: git diff p1 -- src/rukh/data/publish.py te marca con - lo que falta.

// Fontaneríapublish.py, el resto: el registro de los seis datasets, la card desde el manifiesto y la subida
src/rukh/data/publish.py
class DatasetSpec(BaseModel):
"""One publishable artifact."""
model_config = ConfigDict(extra="forbid")
name: str
repo_type: Literal["dataset", "model"]
local_dir: str
patterns: list[str]
command: str
pretty_name: str
description: str
columns: list[tuple[str, str]]
license: str = "cc0-1.0"
license_text: str = LICHESS_CC0
source: str
source_datasets: list[str]
task_categories: list[str]
tags: list[str] = []
exclude_counts: list[str] = []
large: bool = False
"""Multi-gigabyte: upload with ``upload_large_folder`` when the Hub client has it."""

src/rukh/data/publish.pylíneas 50-71 · p1

command es el comando que regenera el artefacto, y aparece en la card. exclude_counts existe porque algunos manifiestos tienen contadores que no son filas (lines, candidates), y sumarlos inflaría el tamaño declarado. large elige el método de subida.

src/rukh/data/publish.py
DATASETS: dict[str, DatasetSpec] = {
spec.name: spec
for spec in [
DatasetSpec(
name="rukh-games-1800",
repo_type="dataset",
large=True,
local_dir="data/uci",
patterns=["year=*/month=*/games.parquet", "manifest.json"],
command="fetch` and `rukh data uci",
pretty_name="Rukh games 1800+",
description=(
"Rated standard Lichess games with both players at 1800+ Elo, base time of "
"at least 180 seconds, normal or time-forfeit terminations, 20 to 300 plies, "
"converted from SAN to legal UCI. Partitioned by month: train on 2025-01, "
"validate on 2025-02."
),
columns=UCI_COLUMNS,
source="`Lichess/standard-chess-games` on the Hugging Face Hub",
source_datasets=["original"],
task_categories=["text-generation"],
tags=["games", "uci"],
),

src/rukh/data/publish.pylíneas 74-96 · p1

Derivar el diccionario de una lista evita que la clave y el name se contradigan. Y command="fetch and rukh data uci" es feo pero correcto: la plantilla lo mete dentro de rukh data {{ command }} entre acentos graves, así que la cadena los cierra y los reabre para nombrar dos comandos.

src/rukh/data/publish.py
DatasetSpec(
name="rukh-games-elite",
repo_type="dataset",
local_dir="data/elite",
patterns=["games.parquet", "manifest.json"],
command="elite",
pretty_name="Rukh Elite games",
description=(
"Games from the Lichess Elite Database (2500+ against 2300+, no bullet), "
"converted to legal UCI with the same schema as `rukh-games-1800`. Used for "
"supervised fine-tuning on strong play."
),
columns=UCI_COLUMNS,
license_text=ELITE_LICENSE,
source="Lichess Elite Database monthly zips (database.nikonoel.fr)",
source_datasets=["original"],
task_categories=["text-generation"],
tags=["games", "uci", "elite"],
),
DatasetSpec(
name="rukh-elo-bins",
repo_type="dataset",
large=True,
local_dir="data/elo-bins",
patterns=["games.parquet", "manifest.json"],
command="elo-bins",
pretty_name="Rukh Elo-balanced games",
description=(
"A balanced sample of `rukh-games-1800`: up to `n_per_bin` games per 100-Elo "
"bin of the average rating of both players, for Elo conditioning experiments."
),
columns=UCI_COLUMNS + [("bin", "lower edge of the 100-Elo bin of the average rating")],
source="`rukh-games-1800` (from `Lichess/standard-chess-games`)",
source_datasets=["original"],
task_categories=["text-generation"],
tags=["games", "uci", "elo"],
),

src/rukh/data/publish.pylíneas 97-133 · p1

Los dos corpus de partidas comparten esquema y también documentación, y rukh-elo-bins añade su columna a la lista en vez de reescribirla: si el esquema gana una columna, las tres cards la ganan.

src/rukh/data/publish.py
DatasetSpec(
name="rukh-positions-eval",
repo_type="dataset",
local_dir="data/evals",
patterns=["positions-eval.parquet", "manifest.json"],
command="positions` and `rukh data evals",
pretty_name="Rukh positions with Stockfish evaluations",
description=(
"Distinct positions sampled from `rukh-games-1800`, keyed by the four-field FEN "
"and joined with `Lichess/chess-position-evaluations`. One row per position "
"with the best line and every analysed line, for value heads, reward models "
"and preference pairs without running an engine."
),
columns=[
("fen", "pieces, side to move, castling rights and en-passant square"),
("game_id", "game of the first occurrence"),
("ply", "half-move index of the first occurrence"),
("last_move", "UCI move that reached the position"),
("result", "result of that game"),
("phase", "`opening` (ply <= 10), `middlegame` (14+ pieces) or `endgame`"),
("n_seen", "occurrences in the sampled games"),
("best_move", "first move of the best line for the side to move"),
("cp", "centipawns of the best line, from White's point of view"),
("mate", "moves to mate of the best line (positive: White mates)"),
("depth", "search depth of the best line"),
("knodes", "kilonodes searched"),
("pvs", "every line as `{move, cp, mate, depth}`, best first"),
],
source=(
"`Lichess/standard-chess-games` (positions) and "
"`Lichess/chess-position-evaluations` (evaluations)"
),
source_datasets=["original"],
task_categories=["tabular-regression", "text-classification"],
tags=["positions", "stockfish"],
exclude_counts=["lines", "positions"],
),
DatasetSpec(
name="rukh-puzzles-split",
repo_type="dataset",
local_dir="data/puzzles",
patterns=["puzzles.parquet", "manifest.json"],
command="puzzles",
pretty_name="Rukh puzzle splits",
description=(
"Lichess puzzles with rating deviation <= 100 and at least 100 plays, banded by "
"difficulty (1000-1500, 1500-2000, 2000+) and split into test and train by a "
"seeded hash of the puzzle id, for tactical evaluation and fine-tuning."
),
columns=[
("puzzle_id", "Lichess puzzle id"),
("fen", "position before the opponent's last move (Lichess convention)"),
("moves", "UCI moves: the opponent's move first, then the solution"),
("rating", "puzzle rating"),
("themes", "Lichess theme tags"),
("band", "`1000-1500`, `1500-2000` or `2000+`"),
("split", "`test` or `train`"),
],
source="`Lichess/chess-puzzles` on the Hugging Face Hub",
source_datasets=["original"],
task_categories=["text-generation", "question-answering"],
tags=["puzzles"],
),

src/rukh/data/publish.pylíneas 134-196 · p1

Estas descripciones son la única documentación de esos ficheros. La del fen de los puzles vale por media hora de depuración en M2: un puzle de Lichess no empieza en el FEN, sino después de la primera jugada del rival en moves. La de pvs dice que el orden de las líneas significa algo.

src/rukh/data/publish.py
DatasetSpec(
name="rukh-pairs-dpo",
repo_type="dataset",
local_dir="data/pairs",
patterns=["pairs.parquet", "manifest.json"],
command="pairs",
pretty_name="Rukh DPO pairs",
description=(
"Preference pairs from the multi-PV Stockfish lines of `rukh-positions-eval`: "
"the best first move against a legal move at least 100 centipawns worse for the "
"side to move (mates count as 10000). One pair per position, balanced by phase."
),
columns=[
("fen", "four-field FEN of the position"),
("chosen", "best move (UCI)"),
("rejected", "clearly worse legal move (UCI)"),
("cp_chosen", "score of `chosen` from White's point of view (mate = +/-10000)"),
("cp_rejected", "score of `rejected` from White's point of view"),
("phase", "`opening`, `middlegame` or `endgame`"),
],
source="`rukh-positions-eval` (from `Lichess/chess-position-evaluations`)",
source_datasets=["original"],
task_categories=["text-generation"],
tags=["dpo", "preferences"],
exclude_counts=["candidates"],
),
DatasetSpec(
name="rukh-tokenizer",
repo_type="model",
local_dir="artifacts/tokenizer",
patterns=["vocab.json", "bpe.json", "fixtures/games.json", "README.md"],
command="tokenize --export-fixture",
pretty_name="Rukh tokenizer",
description=(
"Fixed UCI vocabulary, BPE and char-level SAN tokenizers with a parity fixture."
),
columns=[],
source="enumeration plus a BPE trained on `rukh-games-1800`",
source_datasets=["original"],
task_categories=[],
tags=["tokenizer"],
),
]
}

src/rukh/data/publish.pylíneas 197-240 · p1

La última entrada es el tokenizador, con repo_type="model": publicarlo es lo mismo salvo el tipo de repositorio y que su card no se genera.

src/rukh/data/publish.py
class PublishConfig(BaseConfig):
"""Hub owner and where dry runs stage their output."""
owner: str = "chorcat"
publish_dir: str = "data/publish"
class PublishResult(BaseModel):
"""What was (or would be) uploaded."""
model_config = ConfigDict(extra="forbid")
name: str
repo_id: str
repo_type: str
dry_run: bool
card_path: str
files: list[str]
rows: int
def size_category(rows: int) -> str:
for limit, label in (
(1_000, "n<1K"),
(10_000, "1K<n<10K"),
(100_000, "10K<n<100K"),
(1_000_000, "100K<n<1M"),
(10_000_000, "1M<n<10M"),
):
if rows < limit:
return label
return "10M<n<100M"

src/rukh/data/publish.pylíneas 243-274 · p1

size_category vive aquí y no en la plantilla porque una plantilla con lógica es una plantilla que nadie prueba.

src/rukh/data/publish.py
def local_files(spec: DatasetSpec) -> list[Path]:
root = resolve(spec.local_dir)
found: list[Path] = []
for pattern in spec.patterns:
found.extend(sorted(root.glob(pattern)))
return found
def read_manifest(spec: DatasetSpec) -> Manifest | None:
path = resolve(spec.local_dir) / "manifest.json"
if not path.is_file():
return None
return Manifest.model_validate_json(path.read_text("utf-8"))
def count_rows(spec: DatasetSpec, manifest: Manifest | None) -> int:
if manifest is None:
return 0
return sum(v for k, v in manifest.counts.items() if k not in spec.exclude_counts)

src/rukh/data/publish.pylíneas 277-295 · p1

El sorted hace que la lista de ficheros salga en el mismo orden en cualquier máquina, porque el de glob depende del sistema de ficheros. count_rows se salta exclude_counts y devuelve 0 sin manifiesto: el artefacto se puede publicar, con una card que dirá menos.

src/rukh/data/publish.py
def render_card(spec: DatasetSpec, manifest: Manifest | None, owner: str) -> str:
"""Render the dataset card; the tokenizer uses its own README as the card."""
if spec.repo_type == "model":
return (resolve(spec.local_dir) / "README.md").read_text("utf-8")
env = Environment(
loader=FileSystemLoader(str(CARDS_DIR)),
undefined=StrictUndefined,
autoescape=False,
keep_trailing_newline=True,
)
rows = count_rows(spec, manifest)
context = {
"repo_id": f"{owner}/{spec.name}",
"license": spec.license,
"license_text": spec.license_text,
"pretty_name": spec.pretty_name,
"task_categories": spec.task_categories,
"size_category": size_category(rows),
"source_datasets": spec.source_datasets,
"tags": spec.tags,
"description": spec.description,
"command": spec.command,
"columns": spec.columns,
"source": spec.source,
"rows": rows,
"files": manifest.files if manifest else [],
"months": manifest.months if manifest else [],
"filters_json": json.dumps(manifest.filters if manifest else {}, indent=2, default=str),
"counts_json": json.dumps(manifest.counts if manifest else {}, indent=2),
"created_at": manifest.created_at.date().isoformat() if manifest else "unknown",
"rukh_version": manifest.rukh_version if manifest else "unknown",
}
return env.get_template("dataset.md.jinja").render(**context)

src/rukh/data/publish.pylíneas 298-330 · p1

El tokenizador usa su propio README como card: no tiene filas, columnas ni filtros, y escribirlo a mano una vez es más honesto que inventar una plantilla para un solo caso.

StrictUndefined es la decisión del fichero. Por defecto, Jinja pinta una variable inexistente como cadena vacía: con la errata {{ licence }}, la card saldría con license: vacía, y el Hub la rechazaría o, peor, la aceptaría. Con StrictUndefined la plantilla lanza al renderizar, antes de tocar la red. Es el extra="forbid" de M0 aplicado a plantillas. autoescape=False porque la salida es Markdown, y default=str para que un tipo raro en los filtros dé una representación fea y no una excepción al publicar.

src/rukh/data/publish.py
def _api(): # type: ignore[no-untyped-def]
"""The ``HfApi`` client (tests monkeypatch this)."""
from huggingface_hub import HfApi
return HfApi()
def upload_folder(api: object, spec: DatasetSpec, repo_id: str) -> str:
"""Upload the whole local directory in one go; returns the method that was used.
The card is always uploaded separately from the staged copy, so ``README.md`` is excluded
here. A ``large`` dataset uses ``upload_large_folder`` (resumable, multi-threaded) when the
installed ``huggingface_hub`` provides it.
"""
large = callable(getattr(api, "upload_large_folder", None))
if spec.large and large:
api.upload_large_folder( # type: ignore[attr-defined]
repo_id=repo_id,
folder_path=str(resolve(spec.local_dir)),
repo_type=spec.repo_type,
allow_patterns=spec.patterns,
ignore_patterns=["README.md"],
)
return "upload_large_folder"
api.upload_folder( # type: ignore[attr-defined]
repo_id=repo_id,
folder_path=str(resolve(spec.local_dir)),
repo_type=spec.repo_type,
allow_patterns=spec.patterns,
ignore_patterns=["README.md"],
commit_message="Upload data",
)
return "upload_folder"

src/rukh/data/publish.pylíneas 333-365 · p1

Todo el fichero se prueba sin red porque el cliente del Hub solo se crea en _api().

Los dos datasets grandes rondan 1 GB cada uno. Con upload_folder serían una sola petición larga que, cortada a los cuarenta minutos, se pierde entera; upload_large_folder trocea, sube en paralelo y reanuda donde se quedó. El getattr pregunta si el método existe en vez de comparar versiones, y eso funciona con cualquier versión y con el doble de los tests. El README se excluye porque la card se sube aparte.

src/rukh/data/publish.py
def publish(name: str, cfg: PublishConfig, dry_run: bool = False) -> PublishResult:
"""Render the card and upload (or, with ``dry_run``, stage) one registry entry."""
if name not in DATASETS:
raise KeyError(f"unknown dataset {name!r}; known: {', '.join(DATASETS)}")
spec = DATASETS[name]
manifest = read_manifest(spec)
files = local_files(spec)
if not files:
raise FileNotFoundError(f"nothing to publish under {resolve(spec.local_dir)}")
repo_id = f"{cfg.owner}/{spec.name}"
card = render_card(spec, manifest, cfg.owner)
stage = resolve(cfg.publish_dir) / spec.name
stage.mkdir(parents=True, exist_ok=True)
card_path = stage / "README.md"
card_path.write_text(card, encoding="utf-8", newline="\n")
rel_files = [p.relative_to(resolve(spec.local_dir)).as_posix() for p in files]
if not dry_run:
api = _api()
api.create_repo(repo_id, repo_type=spec.repo_type, exist_ok=True)
upload_folder(api, spec, repo_id)
api.upload_file(
path_or_fileobj=str(card_path),
path_in_repo="README.md",
repo_id=repo_id,
repo_type=spec.repo_type,
commit_message="Update card",
)
return PublishResult(
name=spec.name,
repo_id=repo_id,
repo_type=spec.repo_type,
dry_run=dry_run,
card_path=card_path.as_posix(),
files=rel_files,
rows=count_rows(spec, manifest),
)

src/rukh/data/publish.pylíneas 368-403 · p1

En publish importa el orden. Primero comprueba que hay ficheros que subir, para no crear un repositorio vacío en el Hub. Después escribe la card en disco siempre, se vaya a subir o no, y eso es lo que hace útil --dry-run. Por último, las tres llamadas de red: crear el repositorio con exist_ok=True (publicar dos veces actualiza), subir los datos y subir la card la última. Si algo se corta, queda a medias la subida de datos, no una documentación de datos que no están.

La plantilla

src/rukh/data/cards/dataset.md.jinja
---
license: {{ license }}
pretty_name: {{ pretty_name }}
language:
- en
task_categories:
{%- for task in task_categories %}
- {{ task }}
{%- endfor %}
size_categories:
- {{ size_category }}
source_datasets:
{%- for source in source_datasets %}
- {{ source }}
{%- endfor %}
tags:
- chess
- rukh
{%- for tag in tags %}
- {{ tag }}
{%- endfor %}
---

src/rukh/data/cards/dataset.md.jinjalíneas 1-22 · p1

La cabecera YAML es lo que el Hub lee para indexar el dataset; si está mal, el dataset existe pero no aparece en las búsquedas. El guion de {%- for %} recorta el espacio anterior, que en YAML rompería la indentación, y los tags chess y rukh hacen que los siete artefactos salgan en una búsqueda.

src/rukh/data/cards/dataset.md.jinja
# {{ repo_id }}
{{ description }}
Part of [Rukh](https://github.com/borja-glez/rukh), a chess language model built from scratch
as a course on generative and agentic AI. Every derived dataset ships with the exact filters and
counts of its `manifest.json`, so it can be regenerated with `rukh data {{ command }}`.
## Files
| File | Bytes | SHA-256 |
|---|---:|---|
{%- for file in files %}
| `{{ file.path }}` | {{ file.bytes }} | `{{ file.sha256 }}` |
{%- endfor %}
Rows: **{{ rows }}**.
## Columns
| Column | Description |
|---|---|
{%- for name, doc in columns %}
| `{{ name }}` | {{ doc }} |
{%- endfor %}

src/rukh/data/cards/dataset.md.jinjalíneas 24-48 · p1

El sha256 de cada fichero permite a quien descargó el dataset hace dos meses comprobar si tiene lo que la card describe, sin preguntar a nadie.

src/rukh/data/cards/dataset.md.jinja
## Provenance and filters
Source: {{ source }}{% if months %} (months {{ months | join(", ") }}){% endif %}.
```json
{{ filters_json }}
```
Counts:
```json
{{ counts_json }}
```
Generated on {{ created_at }} with `rukh` {{ rukh_version }}.
## License
{{ license_text }}

src/rukh/data/cards/dataset.md.jinjalíneas 50-68 · p1

Esta es la parte que hace valer la card: los filtros y los conteos del manifiesto, volcados tal cual como JSON en vez de un resumen en prosa. Para rukh-games-1800 eso incluye los recuentos de la conversión y, bajo raw, los filtros de la descarga de M0. El rukh_version del pie es el __version__ que M0 escribió y que entonces parecía no servir para nada.

El comando

El subcomando tiene la forma de los de la lección del pipeline y se puede saltar en la primera lectura, pero el «Ejecútalo» de abajo lo usa. Si no lo tecleas, cópialo del pliegue a tu cli.py (un git checkout del fichero pisaría lo que llevas escrito); git diff p1 -- src/rukh/cli.py te enseña lo que te falte.

// Fontaneríacli.py: el subcomando rukh data publish con --list y --dry-run
src/rukh/cli.py
@data_app.command("publish")
def data_publish(
name: Annotated[
str | None, typer.Option("--name", help="Registry entry, e.g. rukh-games-1800.")
] = None,
config: PipelineOption = None,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Render the card and list files; no network.")
] = False,
list_names: Annotated[bool, typer.Option("--list", help="List the registry and exit.")] = False,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
) -> None:
"""Render the dataset card and upload one dataset (or the tokenizer) to the Hub."""
from rukh.data.pipeline import load_pipeline
from rukh.data.publish import DATASETS, publish
if list_names:
for spec in DATASETS.values():
typer.echo(f"{spec.name:<22} {spec.repo_type:<8} {spec.local_dir}")
return
if name is None:
typer.echo("error: --name is required (see --list)", err=True)
raise typer.Exit(code=2)
cfg = load_pipeline(config).publish
try:
result = publish(name, cfg, dry_run=dry_run)
except (KeyError, FileNotFoundError) 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"mode: {'dry-run (card staged, nothing uploaded)' if dry_run else 'uploaded'}")
typer.echo(f"rows: {result.rows}")
typer.echo(f"card: {result.card_path}")
typer.echo("files:")
for path in result.files:
typer.echo(f" {path}")

src/rukh/cli.pylíneas 277-315 · p1

--list dice qué nombres acepta el comando sin leer el código, y las dos comprobaciones de publish salen como un mensaje y un código 1, igual que engine check en M0. La línea mode: te recuerda si pusiste --dry-run.

Es el único paso en el que intervienes tú: necesita un HF_TOKEN con permiso de escritura. El flujo normal es --dry-run, abrir data/publish/<nombre>/README.md, comprobar que filtros y conteos son los que esperabas, y repetir sin la bandera.

Los tests de la publicación están plegados; si no los tecleas, tráelos antes del «Ejecútalo» con git checkout p1 -- tests/unit/test_publish.py.

// FontaneríaCódigo completo: tests/unit/test_publish.py
tests/unit/test_publish.py
"""Tests for rukh.data.publish: registry, card rendering and network-free dry runs."""
from __future__ import annotations
import json
import shutil
from datetime import UTC, datetime
from pathlib import Path
import pytest
from typer.testing import CliRunner
from rukh.cli import app
from rukh.data import publish as publish_module
from rukh.data.manifest import FileHash, Manifest
from rukh.data.publish import (
DATASETS,
PublishConfig,
publish,
render_card,
size_category,
)
pytestmark = pytest.mark.unit
EXPECTED = {
"rukh-games-1800",
"rukh-games-elite",
"rukh-elo-bins",
"rukh-positions-eval",
"rukh-puzzles-split",
"rukh-pairs-dpo",
"rukh-tokenizer",
}
def test_registry_covers_six_datasets_and_tokenizer() -> None:
assert set(DATASETS) == EXPECTED
assert DATASETS["rukh-tokenizer"].repo_type == "model"
assert all(s.repo_type == "dataset" for n, s in DATASETS.items() if n != "rukh-tokenizer")
for spec in DATASETS.values():
assert spec.license == "cc0-1.0"
assert "manifest.json" in spec.patterns or spec.repo_type == "model"
# The Hub card spec only accepts `original` and `extended|<id>` here.
assert spec.source_datasets == ["original"]
assert {n for n, s in DATASETS.items() if s.large} == {"rukh-games-1800", "rukh-elo-bins"}
def test_size_category() -> None:
assert size_category(0) == "n<1K"
assert size_category(5_000) == "1K<n<10K"
assert size_category(250_000) == "100K<n<1M"
assert size_category(3_000_000) == "1M<n<10M"
def _manifest(counts: dict[str, int]) -> Manifest:
return Manifest(
dataset="Lichess/standard-chess-games",
months=["2025-01", "2025-02"],
filters={"min_elo": 1800, "terminations": ["Normal", "Time forfeit"]},
counts=counts,
files=[FileHash(path="games.parquet", sha256="ab" * 32, bytes=123)],
created_at=datetime(2026, 9, 18, 12, 0, tzinfo=UTC),
)
def _fail_api(): # type: ignore[no-untyped-def]
raise AssertionError("HfApi must not be used in a dry run")
def test_render_card_has_mandatory_metadata() -> None:
card = render_card(DATASETS["rukh-games-1800"], _manifest({"2025-01": 4, "2025-02": 6}), "x")
head, body = card.split("---\n", 2)[1:]
assert "license: cc0-1.0" in head
assert "task_categories:\n - text-generation" in head
assert "size_categories:\n - n<1K" in head
assert "source_datasets:\n - original" in head
assert "pretty_name: Rukh games 1800+" in head
assert "# x/rukh-games-1800" in body
assert "Rows: **10**" in body
assert "| `game_id` |" in body and "| `uci` |" in body
assert '"min_elo": 1800' in body
assert "months 2025-01, 2025-02" in body
assert "2026-09-18" in body
assert "Lichess" in body
def test_render_card_excludes_helper_counts() -> None:
card = render_card(
DATASETS["rukh-pairs-dpo"],
_manifest({"opening": 2, "middlegame": 2, "endgame": 2, "candidates": 99}),
"chorcat",
)
assert "Rows: **6**" in card
def test_dry_run_writes_card_without_network(
rukh_home: Path, repo_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(publish_module, "_api", _fail_api)
out = rukh_home / "data" / "pairs"
out.mkdir(parents=True)
shutil.copy(repo_root / "tests" / "fixtures" / "games.parquet", out / "pairs.parquet")
(out / "manifest.json").write_text(
_manifest({"opening": 1, "middlegame": 1, "endgame": 1}).model_dump_json(), "utf-8"
)
result = publish("rukh-pairs-dpo", PublishConfig(), dry_run=True)
assert result.dry_run and result.repo_id == "chorcat/rukh-pairs-dpo"
assert result.files == ["pairs.parquet", "manifest.json"]
assert result.rows == 3
card = rukh_home / "data" / "publish" / "rukh-pairs-dpo" / "README.md"
assert card.is_file() and card.as_posix() == result.card_path
assert "chosen" in card.read_text("utf-8")
def test_dry_run_tokenizer_uses_artifact_readme(
rukh_home: Path, repo_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(publish_module, "_api", _fail_api)
shutil.copytree(repo_root / "artifacts" / "tokenizer", rukh_home / "artifacts" / "tokenizer")
result = publish("rukh-tokenizer", PublishConfig(), dry_run=True)
assert result.repo_type == "model"
assert set(result.files) == {"vocab.json", "bpe.json", "fixtures/games.json", "README.md"}
card = Path(result.card_path).read_text("utf-8")
assert card.startswith("---\nlicense: cc0-1.0")
assert "## Scheme 1: fixed UCI vocabulary" in card
class FakeApi:
"""Records the calls ``publish`` makes; ``calls`` is shared by every instance."""
calls: list[tuple[str, dict[str, object]]] = []
has_large: bool = True
def __init__(self) -> None:
if not FakeApi.has_large:
# A huggingface_hub without upload_large_folder must still work.
self.upload_large_folder = None # type: ignore[assignment]
def create_repo(self, repo_id: str, **kwargs: object) -> None:
FakeApi.calls.append(("create_repo", {"repo_id": repo_id, **kwargs}))
def upload_file(self, **kwargs: object) -> None:
FakeApi.calls.append(("upload_file", kwargs))
def upload_folder(self, **kwargs: object) -> None:
FakeApi.calls.append(("upload_folder", kwargs))
def upload_large_folder(self, **kwargs: object) -> None:
FakeApi.calls.append(("upload_large_folder", kwargs))
@pytest.fixture
def fake_api(monkeypatch: pytest.MonkeyPatch) -> type[FakeApi]:
FakeApi.calls = []
FakeApi.has_large = True
monkeypatch.setattr(publish_module, "_api", FakeApi)
return FakeApi
def test_publish_uploads_the_folder_in_one_commit(
rukh_home: Path, repo_root: Path, fake_api: type[FakeApi]
) -> None:
shutil.copytree(repo_root / "artifacts" / "tokenizer", rukh_home / "artifacts" / "tokenizer")
result = publish("rukh-tokenizer", PublishConfig(owner="someone"), dry_run=False)
assert not result.dry_run
assert fake_api.calls[0] == (
"create_repo",
{"repo_id": "someone/rukh-tokenizer", "repo_type": "model", "exist_ok": True},
)
assert [c[0] for c in fake_api.calls] == ["create_repo", "upload_folder", "upload_file"]
folder = fake_api.calls[1][1]
assert folder["folder_path"] == str(rukh_home / "artifacts" / "tokenizer")
assert folder["allow_patterns"] == DATASETS["rukh-tokenizer"].patterns
assert folder["ignore_patterns"] == ["README.md"]
assert folder["repo_type"] == "model"
assert fake_api.calls[2][1]["path_in_repo"] == "README.md"
def test_publish_uses_upload_large_folder_for_the_big_datasets(
rukh_home: Path, repo_root: Path, fake_api: type[FakeApi]
) -> None:
out = rukh_home / "data" / "elo-bins"
out.mkdir(parents=True)
shutil.copy(repo_root / "tests" / "fixtures" / "games.parquet", out / "games.parquet")
(out / "manifest.json").write_text(_manifest({"1800": 4}).model_dump_json(), "utf-8")
publish("rukh-elo-bins", PublishConfig(), dry_run=False)
assert [c[0] for c in fake_api.calls] == ["create_repo", "upload_large_folder", "upload_file"]
assert "commit_message" not in fake_api.calls[1][1]
# Without upload_large_folder in huggingface_hub it falls back to upload_folder.
FakeApi.calls = []
FakeApi.has_large = False
publish("rukh-elo-bins", PublishConfig(), dry_run=False)
assert [c[0] for c in fake_api.calls] == ["create_repo", "upload_folder", "upload_file"]
def test_publish_unknown_or_missing(rukh_home: Path) -> None:
with pytest.raises(KeyError):
publish("nope", PublishConfig(), dry_run=True)
with pytest.raises(FileNotFoundError):
publish("rukh-elo-bins", PublishConfig(), dry_run=True)
def test_cli_publish_dry_run(
rukh_home: Path, repo_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(publish_module, "_api", _fail_api)
shutil.copytree(repo_root / "artifacts" / "tokenizer", rukh_home / "artifacts" / "tokenizer")
result = CliRunner().invoke(
app, ["data", "publish", "--name", "rukh-tokenizer", "--dry-run", "--json"]
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["repo_id"] == "chorcat/rukh-tokenizer" and payload["dry_run"] is True
listed = CliRunner().invoke(app, ["data", "publish", "--list"])
assert listed.exit_code == 0
assert "rukh-pairs-dpo" in listed.output

tests/unit/test_publish.pylíneas 1-218 · p1

Publicar en tu cuenta de Hugging Face

Los datasets del curso viven en chorcat, la cuenta de su autor, y ahí no puedes escribir. Para que los tuyos salgan con tu nombre hacen falta cuatro cosas, y ninguna toca el código.

La cuenta. Crea una gratuita en huggingface.co/join. Tu nombre de usuario es el owner: los repositorios quedarán como <tu-usuario>/rukh-games-1800.

El token. En huggingface.co/settings/tokens, crea uno de tipo Write. Es una contraseña: no va en el repositorio ni en ningún YAML.

El login. hf es la línea de comandos de huggingface_hub, que ya es dependencia del proyecto, así que se lanza con uv run desde la raíz del repositorio, igual en Git Bash que en PowerShell:

Terminal window
uv run hf auth login
uv run hf auth whoami

El primero te pide el token (con --token <token> se lo das directamente) y lo guarda en tu carpeta de usuario, en .cache/huggingface/token, que es donde lo busca el HfApi() de _api. El segundo tiene que imprimir tu usuario. Si prefieres no guardar nada en disco, pon el token en la variable HF_TOKEN solo para la sesión: export HF_TOKEN=hf_… en Git Bash, $env:HF_TOKEN = 'hf_…' en PowerShell.

El owner. rukh data publish no pregunta a quién publica: lo lee de la sección publish: de la configuración, y la de configs/data/pipeline.yaml dice owner: chorcat. Con chorcat y tu token, la subida falla por permisos: el Hub no deja escribir en la cuenta de otro. No cambies esa línea: test_cli_publish_dry_run carga ese YAML y exige chorcat/rukh-tokenizer, así que tu suite se quedaría en rojo para siempre. Escribe tu owner en un YAML aparte y pásaselo a la publicación con --config. Crea con tu editor data/mi-hub.yaml (en data/, que no va a git) con estas dos líneas:

publish:
owner: <tu-usuario>

Basta con eso porque cada sección de PipelineConfig tiene sus valores por defecto: un YAML con solo publish: deja el resto como en el de fábrica, y publish no lee nada más. Con eso, primero en seco y después de verdad:

Terminal window
uv run rukh data publish --name rukh-games-1800 --config data/mi-hub.yaml --dry-run
uv run rukh data publish --name rukh-games-1800 --config data/mi-hub.yaml

En el primero, la línea repo: tiene que decir <tu-usuario>/rukh-games-1800; si dice chorcat, se te olvidó el --config. Los seis datasets son rukh-games-1800, rukh-games-elite, rukh-elo-bins, rukh-positions-eval, rukh-puzzles-split y rukh-pairs-dpo, más el tokenizador, rukh-tokenizer; cada uno se publica con su --name (y tu --config) cuando su paso ha terminado, así que los de los pasos largos (evals, elite) llegan con los labs del módulo. rukh-games-1800 sube casi un giga: upload_large_folder lo reanuda si se corta.

Si no quieres publicar, no pierdes nada: --dry-run deja la card en data/publish/<nombre>/README.md y la lista de ficheros, sin tocar la red, y ningún módulo posterior lee tus datasets del Hub. M2 a M6 trabajan con lo que hay en data/, y lo publicado del curso sigue en chorcat por si necesitas bajarlo.

// Ejercicio 01Lee una card antes de publicarla

Ejecuta uv run rukh data publish --list y después uv run rukh data publish --name rukh-games-1800 --dry-run. Abre la card generada y responde: ¿qué dice la sección de filtros sobre el mes de validación? ¿Cuántas filas declara y de dónde sale ese número? Después ejecuta el --dry-run de rukh-positions-eval y compara: ¿por qué su rows no es la suma de los conteos de su manifiesto?

// SoluciónVer la solución

Los filtros de rukh-games-1800 llevan min_plies, max_plies, legal_only y el desglose conversion por mes, y anidado bajo raw están los de la descarga de M0 con min_plies_deferred. Los meses aparecen en la línea de Source: —2025-01 y 2025-02— y no hay ninguna marca de cuál valida: esa decisión es del entrenamiento, no del dataset, y está en el pipeline.yaml.

Las filas son la suma de manifest.counts, que para este dataset son las partidas conservadas de cada mes: 2 949 514 + 2 946 874 = 5 896 388. La categoría de tamaño sale de ahí: 1M<n<10M.

En rukh-positions-eval, exclude_counts=["lines", "positions"] deja fuera dos de los tres contadores, porque lines son líneas de análisis y positions son las de entrada, no filas del fichero publicado. Solo cuenta evaluated. Si no se excluyeran, la card declararía un dataset varias veces más grande de lo que es.

Qué has aprendido

Tu pipeline de datos ya es algo que otra persona puede descargar y entender: un registro de siete artefactos con sus licencias, una plantilla que se rellena con lo que los pasos registraron y una subida que se ensaya sin red y se reanuda si se corta.

Cómo se mide: uv run rukh data publish --list imprime los siete, y uv run rukh data publish --name <nombre> --dry-run deja en data/publish/<nombre>/README.md una card cuya sección de filtros es el manifest.json del paso, tal cual. Con HF_TOKEN y --config data/mi-hub.yaml, el mismo comando sin --dry-run deja el repositorio en huggingface.co/datasets/<tu-usuario>/<nombre> (o en huggingface.co/<tu-usuario>/rukh-tokenizer, que es un repositorio de modelo).

Lo siguiente es la otra implementación del tokenizador, la de TypeScript del navegador, y la fixture que comprueba que las dos dicen lo mismo.