// 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.
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
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",]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
"""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 andlicense. ``publish`` renders the card (Jinja, English, HF YAML metadata), creates the repo anduploads the matching files in one commit with ``upload_folder`` (``upload_large_folder`` forthe 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 jsonfrom pathlib import Pathfrom typing import Literal
from jinja2 import Environment, FileSystemLoader, StrictUndefinedfrom pydantic import BaseModel, ConfigDict
from rukh.config import BaseConfigfrom rukh.data.manifest import Manifestfrom 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"),]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
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."""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.
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"], ),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.
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"], ),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.
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"], ),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.
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"], ), ]}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.
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"size_category vive aquí y no en la plantilla porque una plantilla con lógica es una plantilla que
nadie prueba.
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)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.
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)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.
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"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.
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), )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
---license: {{ license }}pretty_name: {{ pretty_name }}language: - entask_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.jinja
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.
# {{ repo_id }}
{{ description }}
Part of [Rukh](https://github.com/borja-glez/rukh), a chess language model built from scratchas a course on generative and agentic AI. Every derived dataset ships with the exact filters andcounts 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.jinja
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.
## 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.jinja
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
@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}")--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 for rukh.data.publish: registry, card rendering and network-free dry runs."""
from __future__ import annotations
import jsonimport shutilfrom datetime import UTC, datetimefrom pathlib import Path
import pytestfrom typer.testing import CliRunner
from rukh.cli import appfrom rukh.data import publish as publish_modulefrom rukh.data.manifest import FileHash, Manifestfrom 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.fixturedef 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.outputPublicar 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:
uv run hf auth loginuv run hf auth whoamiEl 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:
uv run rukh data publish --name rukh-games-1800 --config data/mi-hub.yaml --dry-runuv run rukh data publish --name rukh-games-1800 --config data/mi-hub.yamlEn 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.