rukh · lab

// M1 · lección 03

El pipeline: un YAML, nueve pasos, siete subcomandos

El orquestador del módulo: la configuración única con su sección de DuckDB compartida, los siete subcomandos nuevos de la CLI, la base Elite que se descarga en zip y se convierte con el mismo código, y el muestreo por tramos de Elo que M4 necesita.

  • tiempo de trabajo130 min
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a construir

Nueve pasos de datos, cada uno con su configuración, sus rutas y su manifiesto. Con un YAML por paso tendrías nueve ficheros que repiten data/uci y nueve sitios donde cambiar el límite de memoria de DuckDBDuckDBBase de datos analítica embebida que ejecuta SQL sobre ficheros parquet locales o remotos (hf://). Es la herramienta con la que rukh data fetch aplica los filtros del recorte antes de materializar nada en disco.. Esta lección es el pegamento: un solo pipeline.yaml, un modelo pydantic que lo valida entero y siete subcomandos nuevos de la CLI con la misma forma. Cualquier sistema con muchas piezas configurables, de un ETL a un agente con diez herramientas, gana lo mismo con una sola fuente de configuración validada al cargar.

Con el pegamento vienen dos pasos completos: elite, que descarga la base Elite de Lichess y la convierte con el código de la lección anterior, y elo-bins, que muestrea hasta 50 000 partidas por tramo de 100 puntos de EloEloEscala de fuerza de juego: una diferencia de 200 puntos implica que el mejor gana unas tres de cada cuatro veces. Lichess usa Glicko-2, compatible en la práctica. Rukh estima el Elo de cada modelo jugando contra Stockfish limitado y publica el intervalo de confianza. para que M4 pueda entrenar los tokens de cabecera.

Un modelo para todo el pipeline

El modelo pydantic que valida el YAML entero es pegamento, no ML: en la primera lectura basta con saber que PipelineConfig junta una sección por paso y que un error de configuración salta al cargar, no a mitad de una tirada. Despliégalo cuando vayas a escribirlo. Si prefieres no teclear pipeline.py ni pipeline.yaml, tráelos antes de empezar, con git checkout p1 -- src/rukh/data/pipeline.py configs/data/pipeline.yaml, y comenta sobre ellos lo que pide la caja de debajo: sin eso, nada que cargue la configuración arranca todavía.

// Fontaneríapipeline.py, primera parte: las importaciones de las nueve configuraciones
src/rukh/data/pipeline.py
"""``PipelineConfig``: every data step's config under one YAML (``configs/data/pipeline.yaml``)."""
from __future__ import annotations
from pathlib import Path
from pydantic import Field, model_validator
from rukh import paths
from rukh.config import BaseConfig, load_yaml
from rukh.data.db import DuckDbConfig
from rukh.data.elite import EliteConfig
from rukh.data.elo_bins import EloBinsConfig
from rukh.data.evals import EvalsConfig
from rukh.data.pairs import PairsConfig
from rukh.data.positions import PositionsConfig
from rukh.data.publish import PublishConfig
from rukh.data.puzzles import PuzzlesConfig
from rukh.data.uci import UciConfig

src/rukh/data/pipeline.pylíneas 1-19 · p1

Es el único fichero del paquete que conoce a todos los pasos, y los pasos no se conocen entre sí. Si mañana uno desaparece, se borra una línea aquí y una sección del YAML.

// Fontaneríapipeline.py, el resto: TokenizeConfig, el validador que reparte la sección de DuckDB y load_pipeline
src/rukh/data/pipeline.py
class TokenizeConfig(BaseConfig):
"""Where games come from, where packed tokens and tokenizer artifacts go."""
uci_dir: str = "data/uci"
out_dir: str = "data/tokens"
artifacts_dir: str = "artifacts/tokenizer"
web_artifacts_dir: str = "artifacts/web"
fixture_pgn: str = "tests/fixtures/games.pgn"
train_month: str = "2025-01"
val_month: str = "2025-02"
stats_games: str = "data/uci/year=2025/month=01/games.parquet"
bpe_vocab_size: int = Field(default=4096, ge=100)
bpe_train_games: int = Field(default=200_000, ge=1)
stats_n_games: int = Field(default=20_000, ge=1)
max_len: int = Field(default=200, ge=8)

src/rukh/data/pipeline.pylíneas 22-36 · p1

TokenizeConfig vive aquí y no en src/rukh/tokenize/ para evitar una importación circular. Sus valores por defecto son los que se usaron de verdad: enero entrena, febrero valida, BPE de 4 096 sobre 200 000 partidas y bloques de 200 tokens.

src/rukh/data/pipeline.py
DUCKDB_STEPS = ("positions", "evals", "puzzles", "elo_bins")
class PipelineConfig(BaseConfig):
"""All step configs; each step reads only its own section.
``duckdb:`` is shared: the top-level settings are copied into every step that opens a
DuckDB connection unless that step declares its own.
"""
duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)
uci: UciConfig = Field(default_factory=UciConfig)
tokenize: TokenizeConfig = Field(default_factory=TokenizeConfig)
positions: PositionsConfig = Field(default_factory=PositionsConfig)
evals: EvalsConfig = Field(default_factory=EvalsConfig)
puzzles: PuzzlesConfig = Field(default_factory=PuzzlesConfig)
pairs: PairsConfig = Field(default_factory=PairsConfig)
elite: EliteConfig = Field(default_factory=EliteConfig)
elo_bins: EloBinsConfig = Field(default_factory=EloBinsConfig)
publish: PublishConfig = Field(default_factory=PublishConfig)
@model_validator(mode="after")
def _share_duckdb(self) -> PipelineConfig:
default = DuckDbConfig()
for name in DUCKDB_STEPS:
step = getattr(self, name)
if step.duckdb == default:
setattr(self, name, step.model_copy(update={"duckdb": self.duckdb}))
return self

src/rukh/data/pipeline.pylíneas 39-67 · p1

Esta es la única pieza con lógica del fichero. Cuatro pasos abren una conexión de DuckDB y cada uno tiene su campo duckdb. Sin el validador, subir el límite de memoria obligaría a escribirlo cuatro veces, y olvidar una dejaría un paso con el valor viejo sin que nada lo dijera.

El model_validator(mode="after") corre cuando pydantic ya validó todos los campos. Si el duckdb de un paso es igual al valor por defecto, ese paso no lo declaró y hereda el global; si difiere, se respeta. El límite (escribir a mano el valor por defecto en un paso cuenta como no escribirlo) no importa en la práctica, y model_fields_set lo resolvería a cambio de más código. El model_copy devuelve un modelo nuevo en vez de mutar el que hay, para que dos pasos no compartan por accidente el mismo objeto.

src/rukh/data/pipeline.py
def default_config_path() -> Path:
return paths.package_root() / "configs" / "data" / "pipeline.yaml"
def load_pipeline(path: Path | None = None) -> PipelineConfig:
"""Load ``pipeline.yaml`` (the shipped one when ``path`` is ``None``)."""
return load_yaml(path or default_config_path(), PipelineConfig)

src/rukh/data/pipeline.pylíneas 70-76 · p1

default_config_path() usa package_root() y no root(): el pipeline.yaml es código versionado y no se mueve cuando RUKH_HOME apunta los datos a otro disco.

El fichero que lo configura todo

El YAML es la otra mitad del mismo contrato; puedes saltarlo y quedarte con las tres claves de debajo, pero la CLI lo carga. Si no lo tecleas, tráelo con git checkout p1 -- configs/data/pipeline.yaml y comenta sus cinco secciones pendientes, como dice la caja de arriba.

// Fontaneríaconfigs/data/pipeline.yaml entero: una sección por paso
configs/data/pipeline.yaml
# Every step of `rukh data ...` after `fetch`. Paths are relative to the repo root (RUKH_HOME).
duckdb: # applied to every DuckDB connection of the pipeline
memory_limit: 32GB
threads: 0 # 0 = cpu_count()
uci:
raw_dir: data/raw
out_dir: data/uci
min_plies: 20
max_plies: 300
workers: 0 # 0 = cpu_count() - 1
tokenize:
uci_dir: data/uci
out_dir: data/tokens
artifacts_dir: artifacts/tokenizer
web_artifacts_dir: artifacts/web
fixture_pgn: tests/fixtures/games.pgn
train_month: "2025-01"
val_month: "2025-02"
stats_games: data/uci/year=2025/month=01/games.parquet
bpe_vocab_size: 4096
bpe_train_games: 200000
stats_n_games: 20000
max_len: 200
positions:
uci_dir: data/uci
month: "2025-01"
out_dir: data/positions
n_games: 300000
max_positions: 5000000
workers: 0
evals:
dataset: Lichess/chess-position-evaluations
remote_pattern: "hf://datasets/{dataset}/data/data_{index:04d}.parquet"

configs/data/pipeline.yamllíneas 1-33 · p1

configs/data/pipeline.yaml
n_files: 20
positions: data/positions/positions.parquet
out_dir: data/evals
puzzles:
dataset: Lichess/chess-puzzles
remote_glob: "hf://datasets/{dataset}/**/*.parquet"
out_dir: data/puzzles
max_rating_deviation: 100
min_plays: 100
n_test: 2000
n_train: 50000
seed: 42
pairs:
positions_eval: data/evals/positions-eval.parquet
out_dir: data/pairs
min_delta_cp: 100
max_per_phase: 200000
seed: 42
elite:
base_url: https://database.nikonoel.fr
months: ["2025-01", "2025-02"]
out_dir: data/elite
min_plies: 20
max_plies: 300
workers: 0
elo_bins:
uci_dir: data/uci
out_dir: data/elo-bins
n_per_bin: 50000
seed: 42
publish:
owner: chorcat
publish_dir: data/publish

configs/data/pipeline.yamllíneas 34-66 · p1

Cada clave es una decisión que sale en alguna lección. Tres conviene fijarlas ya:

  • positions.n_games: 300000: no hacen falta los seis millones de partidas para tener cinco millones de posiciones distintas.
  • La semilla 42 en puzzles, pairs y elo_bins hace que los tres conjuntos se regeneren idénticos; el test de los puzles sostiene la tabla única de M2 a M6.
  • elite.base_url apunta a database.nikonoel.fr: la base Elite la mantiene un tercero a partir del volcado público de Lichess.

La descarga de M0 sigue en su propio fichero, con una configuración por recorte y no por paso. M1 le cambia una línea:

configs/data/lichess-2025-01-02.yaml
# Two recent months of rated standard games, filtered remotely (see docs/spec/01-datos.md).
dataset: Lichess/standard-chess-games
months: ["2025-01", "2025-02"]
min_elo: 1800
min_base_seconds: 180
terminations: ["Normal", "Time forfeit"]
min_plies: 20
exclude_variants: true
out_dir: data/raw
limit: 3000000

configs/data/lichess-2025-01-02.yamllíneas 1-10 · p1

limit: 3000000 donde M0 tenía limit: null. Los filtros de M0 dejan pasar el 18 % de las partidas (251 618 de 1 394 617 en un fichero del 1 de enero), unos dieciocho millones por mes, muy por encima de lo que pedía el diseño. Como los ficheros del mes van en orden cronológico, el tope se queda con unos cinco días de enero y cinco de febrero. El manifiesto lo registra como lo que es, un recorte temporal y no una muestra aleatoria del mes.

Siete subcomandos que se parecen

Los subcomandos tienen todos la misma forma (cargar el YAML, llamar a run, imprimir el manifiesto) y se pueden saltar en la primera lectura; el «Ejecútalo» de debajo comprueba que quedaron registrados. Si no los tecleas, trae el cli.py de p1 antes de tocar el tuyo, con git checkout p1 -- src/rukh/cli.py: es el tuyo de M0 más todos los subcomandos de rukh data, incluidos tokenize y publish, que así no tendrás que teclear en sus lecciones. Cada subcomando importa su módulo al ejecutarse, así que los que aún no tienen módulo no rompen nada hasta que los lances.

// Fontaneríacli.py: la opción --config compartida y los subcomandos de rukh data
src/rukh/cli.py
PipelineOption = Annotated[
Path | None,
typer.Option(
"--config",
exists=True,
dir_okay=False,
readable=True,
help="Pipeline YAML (default: configs/data/pipeline.yaml).",
),
]
def _echo_written(written: list[str]) -> None:
typer.echo("written:")
for path in written:
typer.echo(f" {path}")

src/rukh/cli.pylíneas 96-111 · p1

PipelineOption es un alias de tipo para la opción --config, que se repite en nueve subcomandos. Con None por defecto, load_pipeline(None) carga el YAML que viene con el paquete.

src/rukh/cli.py
@data_app.command("uci")
def data_uci(
config: PipelineOption = None,
workers: Annotated[
int | None, typer.Option("--workers", help="Override the pool size from the config.")
] = None,
as_json: Annotated[
bool, typer.Option("--json", help="Print the manifest as JSON only.")
] = False,
) -> None:
"""Convert fetched SAN movetext to legal UCI games, one parquet per month."""
from rukh.data.pipeline import load_pipeline
from rukh.data.uci import run
cfg = load_pipeline(config).uci
if workers is not None:
cfg = cfg.model_copy(update={"workers": workers})
manifest = run(cfg)
if as_json:
typer.echo(manifest.model_dump_json(indent=2))
return
typer.echo(f"months: {', '.join(manifest.months)}")
for month, count in manifest.counts.items():
typer.echo(f" {month}: {count} games kept")
typer.echo(f"manifest: {cfg.out_dir}/manifest.json")

src/rukh/cli.pylíneas 114-138 · p1

--workers sobrescribe el YAML sin tocar el fichero, y sirve para depurar: con --workers 1 la conversión corre en el proceso principal, donde un breakpoint() funciona. Los import van dentro de la función, la regla de M0: rukh.data.pipeline arrastra pyarrow, polars y duckdb, y con las importaciones arriba rukh --help tardaría segundos.

src/rukh/cli.py
def _echo_manifest(manifest: object, as_json: bool, out_dir: str) -> None:
from rukh.data.manifest import Manifest
assert isinstance(manifest, Manifest)
if as_json:
typer.echo(manifest.model_dump_json(indent=2))
return
typer.echo(f"dataset: {manifest.dataset}")
if manifest.months:
typer.echo(f"months: {', '.join(manifest.months)}")
typer.echo("counts:")
for key, value in manifest.counts.items():
typer.echo(f" {key}: {value}")
typer.echo("files:")
for file in manifest.files:
typer.echo(f" {file.path} ({file.bytes} bytes)")
typer.echo(f"manifest: {out_dir}/manifest.json")
JsonOption = Annotated[bool, typer.Option("--json", help="Print the manifest as JSON.")]
@data_app.command("positions")
def data_positions(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Walk sampled games into deduplicated 4-field FENs with phase labels."""
from rukh.data.pipeline import load_pipeline
from rukh.data.positions import run
cfg = load_pipeline(config).positions
_echo_manifest(run(cfg), as_json, cfg.out_dir)

src/rukh/cli.pylíneas 188-217 · p1

_echo_manifest sirve a los seis subcomandos que devuelven un manifiesto. El parámetro va tipado como object para no importar Manifest al nivel del módulo, y el assert le dice al comprobador de tipos lo que el import local ya sabe.

src/rukh/cli.py
@data_app.command("evals")
def data_evals(
config: PipelineOption = None,
files: Annotated[
str | None,
typer.Option("--files", help="Remote file indices, e.g. 0-19, 3 or 0,2,5-7 (default all)."),
] = None,
as_json: JsonOption = False,
) -> None:
"""Semi-join the positions with the remote Stockfish evaluations (resumable per file)."""
from rukh.data.evals import run
from rukh.data.pipeline import load_pipeline
cfg = load_pipeline(config).evals
_echo_manifest(run(cfg, files=files), as_json, cfg.out_dir)
@data_app.command("puzzles")
def data_puzzles(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Filter Lichess puzzles and split them by difficulty band with a fixed seed."""
from rukh.data.pipeline import load_pipeline
from rukh.data.puzzles import run
cfg = load_pipeline(config).puzzles
_echo_manifest(run(cfg), as_json, cfg.out_dir)
@data_app.command("pairs")
def data_pairs(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Build phase-balanced DPO pairs from the multi-PV evaluations."""
from rukh.data.pairs import run
from rukh.data.pipeline import load_pipeline
cfg = load_pipeline(config).pairs
_echo_manifest(run(cfg), as_json, cfg.out_dir)
@data_app.command("elite")
def data_elite(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Download the Lichess Elite Database months and convert them to UCI."""
from rukh.data.elite import run
from rukh.data.pipeline import load_pipeline
cfg = load_pipeline(config).elite
_echo_manifest(run(cfg), as_json, cfg.out_dir)
@data_app.command("elo-bins")
def data_elo_bins(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Sample up to n_per_bin games per 100-Elo bin of the average rating."""
from rukh.data.elo_bins import run
from rukh.data.pipeline import load_pipeline
cfg = load_pipeline(config).elo_bins
_echo_manifest(run(cfg), as_json, cfg.out_dir)

src/rukh/cli.pylíneas 220-274 · p1

Seis subcomandos con el mismo cuerpo: cargar la sección, ejecutar, imprimir. Cada paso del pipeline es una función run(cfg) -> Manifest, así que la CLI no tiene lógica que se pueda romper, un paso nuevo son cuatro líneas y --json funciona igual en todos. El único con un argumento extra es evals: su --files existe porque ese paso tarda horas y hay que poder pedirle un trozo. Una CLI aburrida es una CLI donde cada subcomando no tiene su propio error.

elite.py: descargar un zip y convertirlo con el código de ayer

La base Elite de Lichess reúne partidas de jugadores de 2500+ contra 2300+, sin bullet, en un zip por mes. M4 la usa para el afinado “como los maestros”. Lo interesante de este fichero es lo poco que tiene: convierte con el mismo convert_batch de uci.py, así que los dos corpus salen con el mismo esquema.

Es descarga y conversión, sin idea nueva: puedes saltarlo en la primera lectura, pero rukh data elite y sus tests lo necesitan. Si no lo tecleas, tráelo con git checkout p1 -- src/rukh/data/elite.py.

// Fontaneríaelite.py: descargar el zip del mes, extraerlo y convertirlo con convert_batch
src/rukh/data/elite.py
"""Lichess Elite Database (2500+ vs 2300+, no bullet): download, extract, convert to UCI."""
from __future__ import annotations
import re
import zipfile
from collections.abc import Iterator
from pathlib import Path
import pyarrow.parquet as pq
from pydantic import Field
from rukh.config import BaseConfig
from rukh.data.manifest import FileHash, Manifest
from rukh.data.parallel import run_batches
from rukh.data.uci import (
OUT_SCHEMA,
RAW_COLUMNS,
convert_batch,
resolve_workers,
sha256_file,
write_results,
)
from rukh.paths import resolve
GAMES_FILE = "games.parquet"
BATCH_GAMES = 20_000
_HEADER_RE = re.compile(r'^\[(\w+)\s+"(.*)"\]\s*$')
class EliteConfig(BaseConfig):
"""Which monthly zips to fetch and the ply bounds of the conversion."""
base_url: str = "https://database.nikonoel.fr"
months: list[str] = Field(default=["2025-01", "2025-02"], min_length=1)
out_dir: str = "data/elite"
min_plies: int = Field(default=20, ge=0)
max_plies: int = Field(default=300, ge=1)
workers: int = Field(default=0, ge=0)

src/rukh/data/elite.pylíneas 1-39 · p1

Importa hasta OUT_SCHEMA: con un esquema propio, los dos corpus podrían divergir sin que nadie lo notase hasta que el tokenizador leyera un int16 en uno y un int32 en el otro.

src/rukh/data/elite.py
def zip_name(month: str) -> str:
return f"lichess_elite_{month}.zip"
def zip_url(cfg: EliteConfig, month: str) -> str:
return f"{cfg.base_url.rstrip('/')}/{zip_name(month)}"
def _download(url: str, dest: Path) -> None:
"""Stream ``url`` to ``dest`` (tests monkeypatch this)."""
import requests
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
with requests.get(url, stream=True, timeout=120) as response:
response.raise_for_status()
with tmp.open("wb") as fh:
for chunk in response.iter_content(chunk_size=1 << 20):
fh.write(chunk)
tmp.replace(dest)
def extract_pgn(zip_path: Path, dest_dir: Path) -> Path:
"""Extract the (single) ``.pgn`` member of the zip; returns its path."""
with zipfile.ZipFile(zip_path) as archive:
members = [m for m in archive.namelist() if m.lower().endswith(".pgn")]
if len(members) != 1:
raise ValueError(f"{zip_path.name}: expected one .pgn member, found {members}")
archive.extract(members[0], dest_dir)
return dest_dir / members[0]

src/rukh/data/elite.pylíneas 42-71 · p1

_download escribe a un .part y lo renombra al final, para que un zip interrumpido no pase por completo: el nombre definitivo solo existe cuando la descarga terminó. Es la única función que toca la red, y el test la sustituye por una que copia un zip de mentira.

extract_pgn falla si el zip no tiene exactamente un .pgn. Coger el primero daría, el día que la recopilación cambie de formato, un dataset a medias en vez de un error que se lee.

src/rukh/data/elite.py
def iter_pgn_rows(pgn_path: Path, month: str) -> Iterator[dict[str, object]]:
"""Yield raw rows (``RAW_COLUMNS``) from a PGN file with a light line parser.
A new game starts when a header follows movetext or repeats a tag of the current block, so
a game with headers but no moves is dropped instead of polluting the next one. Without a
``Site`` tag the game id falls back to ``month:index`` (every game still gets its own id);
a missing or unparsable Elo is recorded as ``None``, never as 0.
"""
headers: dict[str, str] = {}
movetext: list[str] = []
in_moves = False
index = 0
def flush(index: int) -> dict[str, object] | None:
if not headers or not movetext:
return None
return {
"Site": headers.get("Site") or f"{month}:{index}",
"movetext": " ".join(movetext),
"WhiteElo": _int(headers.get("WhiteElo")),
"BlackElo": _int(headers.get("BlackElo")),
"Result": headers.get("Result", "*"),
"TimeControl": headers.get("TimeControl"),
"UTCDate": headers.get("UTCDate", headers.get("Date")),
"ECO": headers.get("ECO"),
"month": month,
}
with pgn_path.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if line.startswith("["):
match = _HEADER_RE.match(line)
if match is None:
continue
if in_moves or match.group(1) in headers:
row = flush(index)
if row is not None:
yield row
index += 1
headers, movetext, in_moves = {}, [], False
headers[match.group(1)] = match.group(2)
elif line:
in_moves = True
movetext.append(line)
row = flush(index)
if row is not None:
yield row

src/rukh/data/elite.pylíneas 74-121 · p1

Este es el trozo con miga. Un PGN es una sucesión de bloques de cabeceras entre corchetes seguidos de líneas de jugadas. Partir por líneas en blanco es un error, porque algunos programas meten líneas en blanco dentro del movetext.

El parser usa otra regla: una partida nueva empieza cuando una cabecera aparece después de jugadas, o cuando repite un tag del bloque actual. La segunda condición cubre las partidas con cabeceras y sin jugadas (abandonadas antes de empezar), que si no se fusionarían con la siguiente y le robarían su Result y su Elo. El index avanza aunque flush descarte el bloque, así que el id de respaldo (month:index) sigue siendo único.

src/rukh/data/elite.py
def _int(value: str | None) -> int | None:
"""``None`` when the header is missing or not a number (never a fabricated 0)."""
try:
return int(value) if value is not None else None
except ValueError:
return None
def _batches(rows: Iterator[dict[str, object]]) -> Iterator[list[dict[str, object]]]:
batch: list[dict[str, object]] = []
for row in rows:
for name in RAW_COLUMNS:
row.setdefault(name, None)
batch.append(row)
if len(batch) >= BATCH_GAMES:
yield batch
batch = []
if batch:
yield batch

src/rukh/data/elite.pylíneas 124-142 · p1

_int devuelve None y nunca un 0, como _parse_elo de uci.py. _batches reagrupa las filas en lotes de 20 000, que es lo que espera convert_batch.

src/rukh/data/elite.py
def convert_pgns(pgns: list[tuple[str, Path]], dst: Path, cfg: EliteConfig) -> dict[str, int]:
"""Convert every ``(month, pgn)`` into one UCI parquet with the ``uci.py`` schema."""
totals = {"rows": 0, "kept": 0, "illegal": 0, "short": 0, "long": 0}
workers = resolve_workers(cfg.workers)
def jobs() -> Iterator[tuple[list[dict[str, object]], int, int]]:
for month, pgn in pgns:
for batch in _batches(iter_pgn_rows(pgn, month)):
yield batch, cfg.min_plies, cfg.max_plies
dst.parent.mkdir(parents=True, exist_ok=True)
with pq.ParquetWriter(dst, OUT_SCHEMA, compression="zstd") as writer:
write_results(writer, run_batches(convert_batch, jobs(), workers), totals)
return totals

src/rukh/data/elite.pylíneas 145-158 · p1

convert_pgns pasa sus lotes al mismo run_batches, con el mismo convert_batch, y escribe con el mismo write_results. La limpieza, el tablero, los contadores y la contrapresión de la lección anterior se reutilizan sin una línea nueva. Aquí jobs puede ser una función anidada porque se queda en el proceso padre: lo que viaja al hijo es convert_batch, que sí es de módulo.

src/rukh/data/elite.py
def run(cfg: EliteConfig) -> Manifest:
"""Download each month's zip (once), extract, convert and write the manifest."""
out_dir = resolve(cfg.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
files: list[FileHash] = []
sources: dict[str, str] = {}
pgns: list[tuple[str, Path]] = []
for month in cfg.months:
zip_path = out_dir / zip_name(month)
url = zip_url(cfg, month)
if not zip_path.is_file():
_download(url, zip_path)
sources[month] = url
files.append(
FileHash(
path=zip_path.name, sha256=sha256_file(zip_path), bytes=zip_path.stat().st_size
)
)
pgns.append((month, extract_pgn(zip_path, out_dir)))
target = out_dir / GAMES_FILE
totals = convert_pgns(pgns, target, cfg)
files.append(FileHash(path=GAMES_FILE, sha256=sha256_file(target), bytes=target.stat().st_size))
manifest = Manifest(
dataset="Lichess Elite Database (database.nikonoel.fr)",
months=list(cfg.months),
filters={
"source_urls": sources,
"min_plies": cfg.min_plies,
"max_plies": cfg.max_plies,
"legal_only": True,
"conversion": totals,
},
counts={"games": totals["kept"]},
files=files,
)
(out_dir / "manifest.json").write_text(manifest.model_dump_json(indent=2) + "\n", "utf-8")
return manifest

src/rukh/data/elite.pylíneas 161-197 · p1

Con el if not zip_path.is_file(), volver a lanzar el comando no vuelve a descargar. El manifiesto guarda las URL de origen para que la card diga de dónde salió el dataset.

elo_bins.py: equilibrar los tramos de Elo con una sola consulta

El primer lab del módulo mide el problema: de las 2 949 514 partidas de enero, el 53,2 % son de un 1800 o un 1900 y solo el 1,5 % de 2500 para arriba. Con esa distribución, el token <w2400> recibe una fracción minúscula de los gradientes.

src/rukh/data/elo_bins.py
"""Elo-balanced sample: up to ``n_per_bin`` games per 100-Elo bin of the average rating."""
from __future__ import annotations
from pathlib import Path
from pydantic import Field
from rukh.config import BaseConfig
from rukh.data.db import DuckDbConfig, connect
from rukh.data.manifest import FileHash, Manifest
from rukh.data.uci import month_dirs, sha256_file
from rukh.paths import resolve
GAMES_FILE = "games.parquet"
class EloBinsConfig(BaseConfig):
"""Source months, per-bin cap and seed."""
uci_dir: str = "data/uci"
out_dir: str = "data/elo-bins"
n_per_bin: int = Field(default=50_000, ge=1)
seed: int = 42
duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)

src/rukh/data/elo_bins.pylíneas 1-25 · p1

src/rukh/data/elo_bins.py
def sample_bins(
sources: list[Path],
target: Path,
n_per_bin: int,
seed: int,
duckdb_cfg: DuckDbConfig | None = None,
) -> dict[str, int]:
"""DuckDB: ``bin = avg(white_elo, black_elo) // 100 * 100``; keep ``n_per_bin`` by hash.
The seed shifts the hash, not the id: ``hash(game_id + seed)`` overflowed BIGINT for ids
near the top of the range.
"""
files = ", ".join(f"'{p.as_posix()}'" for p in sources)
con = connect(duckdb_cfg)
try:
con.execute(
f"""
COPY (
SELECT * EXCLUDE (rk)
FROM (
SELECT *,
row_number() OVER (PARTITION BY bin ORDER BY hash(game_id) + {seed}) AS rk
FROM (
SELECT *, ((white_elo + black_elo) // 2) // 100 * 100 AS bin
FROM read_parquet([{files}], hive_partitioning = false)
)
)
WHERE rk <= {int(n_per_bin)}
ORDER BY bin, rk
) TO '{target.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD)
"""
)
rows = con.execute(
f"SELECT bin, count(*) FROM read_parquet('{target.as_posix()}') "
"GROUP BY bin ORDER BY bin"
).fetchall()
finally:
con.close()
return {str(int(b)): int(n) for b, n in rows}

src/rukh/data/elo_bins.pylíneas 28-66 · p1

La consulta se lee de dentro afuera. El nivel interno calcula el tramo: la media de los dos Elo, truncada a la centena. Es la media y no el Elo de un bando porque lo que determina cómo se juega es el nivel de la mesa.

El de en medio numera las filas de cada tramo ordenando por un hash del id. Es un muestreo determinista: impredecible respecto a cualquier propiedad de la partida, y igual en cada ejecución. El de fuera se queda con las primeras n_per_bin de cada tramo.

src/rukh/data/elo_bins.py
def run(cfg: EloBinsConfig) -> Manifest:
"""Sample every month under ``uci_dir`` and write ``out_dir/games.parquet`` + manifest."""
months = month_dirs(resolve(cfg.uci_dir))
if not months:
raise FileNotFoundError(f"no games under {resolve(cfg.uci_dir)}")
out_dir = resolve(cfg.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / GAMES_FILE
counts = sample_bins([p for _, p in months], target, cfg.n_per_bin, cfg.seed, cfg.duckdb)
manifest = Manifest(
dataset="Lichess/standard-chess-games",
months=[m for m, _ in months],
filters={
"bin": "((white_elo + black_elo) // 2) // 100 * 100",
"n_per_bin": cfg.n_per_bin,
"seed": cfg.seed,
},
counts=counts,
files=[FileHash(path=GAMES_FILE, sha256=sha256_file(target), bytes=target.stat().st_size)],
)
(out_dir / "manifest.json").write_text(manifest.model_dump_json(indent=2) + "\n", "utf-8")
return manifest

src/rukh/data/elo_bins.pylíneas 69-90 · p1

Los conteos del manifiesto van por tramo, y así el manifiesto prueba que el balanceo funcionó: en la ejecución de referencia, {"1800": 50000, …, "2400": 50000, "2500": 44982, …, "3000": 1802, "3100": 102}. Los tramos que no llegan al tope dicen a M4 qué cabeceras de Elo tienen pocos datos detrás. La fórmula del tramo se guarda como texto en filters.bin, para que un manifiesto viejo siga diciendo cuál era la suya.

Los tests

Los tests fijan el contrato de los dos pasos; léelos al teclearlos, no hacen falta para seguir la lección. El «Ejecútalo» de debajo los pasa: si no los tecleas, tráelos antes con git checkout p1 -- tests/unit/test_elo_bins.py tests/unit/test_elite.py.

// Teststests/unit/test_elo_bins.py: tramos equilibrados y parquets idénticos con la misma semilla
tests/unit/test_elo_bins.py
"""Tests for rukh.data.elo_bins: bin formula, per-bin cap and determinism."""
from __future__ import annotations
import shutil
from pathlib import Path
import polars as pl
import pytest
from rukh.data.elo_bins import EloBinsConfig, run
pytestmark = pytest.mark.unit
def _stage(rukh_home: Path, repo_root: Path) -> None:
src = repo_root / "tests" / "fixtures" / "games.parquet"
for month in ("01", "02"):
target = rukh_home / "data" / "uci" / "year=2025" / f"month={month}" / "games.parquet"
target.parent.mkdir(parents=True)
shutil.copy(src, target)
def test_run_caps_each_bin(rukh_home: Path, repo_root: Path) -> None:
_stage(rukh_home, repo_root)
manifest = run(EloBinsConfig(n_per_bin=2, seed=1))
frame = pl.read_parquet((rukh_home / "data" / "elo-bins" / "games.parquet").as_posix())
assert "bin" in frame.columns
per_bin = frame.group_by("bin").len()
assert per_bin["len"].max() <= 2
expected = ((pl.col("white_elo") + pl.col("black_elo")) // 2) // 100 * 100
assert frame.filter(pl.col("bin") != expected).height == 0
assert manifest.months == ["2025-01", "2025-02"]
assert all(v <= 2 for v in manifest.counts.values())
assert manifest.filters["n_per_bin"] == 2

tests/unit/test_elo_bins.pylíneas 1-35 · p1

El primero comprueba el tope, la fórmula y el manifiesto. La fórmula se recalcula con polars en vez de copiar el número esperado, así que el test sigue valiendo si cambia la fixture.

tests/unit/test_elo_bins.py
def test_run_handles_ids_at_the_edge_of_int64(rukh_home: Path, repo_root: Path) -> None:
"""``game_id`` is a signed 64-bit hash: adding the seed to it would overflow."""
src = pl.read_parquet((repo_root / "tests" / "fixtures" / "games.parquet").as_posix())
edges = [2**63 - 1, -(2**63), 2**63 - 2, -(2**63) + 1]
ids = (edges * ((src.height // len(edges)) + 1))[: src.height]
target = rukh_home / "data" / "uci" / "year=2025" / "month=01" / "games.parquet"
target.parent.mkdir(parents=True)
src.with_columns(pl.Series("game_id", ids, dtype=pl.Int64)).write_parquet(target.as_posix())
manifest = run(EloBinsConfig(n_per_bin=1, seed=42))
assert sum(manifest.counts.values()) > 0
def test_run_is_deterministic(rukh_home: Path, repo_root: Path) -> None:
_stage(rukh_home, repo_root)
run(EloBinsConfig(n_per_bin=1, seed=3))
first = pl.read_parquet((rukh_home / "data" / "elo-bins" / "games.parquet").as_posix())
run(EloBinsConfig(n_per_bin=1, seed=3))
second = pl.read_parquet((rukh_home / "data" / "elo-bins" / "games.parquet").as_posix())
assert first.equals(second)
assert first.height == first["bin"].n_unique()

tests/unit/test_elo_bins.pylíneas 38-57 · p1

El test del desbordamiento fabrica ids con los cuatro extremos del int64 y solo comprueba que la consulta termina, porque el fallo era una excepción. El último ejecuta dos veces con la misma semilla y exige parquets idénticos con first.equals(second), no “del mismo tamaño”.

// FontaneríaCódigo completo: tests/unit/test_elite.py
tests/unit/test_elite.py
"""Tests for rukh.data.elite: monkeypatched download, PGN parsing and UCI conversion."""
from __future__ import annotations
import zipfile
from pathlib import Path
import polars as pl
import pytest
from rukh.data import elite as elite_module
from rukh.data.elite import EliteConfig, extract_pgn, iter_pgn_rows, run, zip_url
from rukh.data.uci import convert_rows, game_id
pytestmark = pytest.mark.unit
PGN = """[Event "Rated Blitz game"]
[Site "https://lichess.org/abc1"]
[Date "2025.01.03"]
[White "A"]
[Black "B"]
[Result "1-0"]
[UTCDate "2025.01.03"]
[WhiteElo "2612"]
[BlackElo "2380"]
[ECO "C50"]
[TimeControl "180+2"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4 exd4 6. cxd4 Bb4+ 7. Nc3 Nxe4 8. O-O Bxc3
9. d5 Bf6 10. Re1 Ne7 11. Rxe4 d6 1-0
[Event "Rated Blitz game"]
[Site "https://lichess.org/abc2"]
[Result "0-1"]
[UTCDate "2025.01.04"]
[WhiteElo "2540"]
[BlackElo "2610"]
[ECO "A00"]
[TimeControl "300+0"]
1. f3 e5 2. g4 Qh4# 0-1
[Event "Rated Blitz game"]
[Site "https://lichess.org/abc3"]
[Result "1/2-1/2"]
[UTCDate "2025.01.04"]
[WhiteElo "2500"]
[BlackElo "2400"]
[TimeControl "300+0"]
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8
10. d4 Nbd7 11. Nbd2 Bb7 1/2-1/2
"""
def test_zip_url() -> None:
cfg = EliteConfig()
assert zip_url(cfg, "2025-01") == "https://database.nikonoel.fr/lichess_elite_2025-01.zip"
def test_iter_pgn_rows(tmp_path: Path) -> None:
pgn = tmp_path / "x.pgn"
pgn.write_text(PGN, encoding="utf-8")
rows = list(iter_pgn_rows(pgn, "2025-01"))
assert [r["Site"] for r in rows] == [f"https://lichess.org/abc{i}" for i in (1, 2, 3)]
assert rows[0]["WhiteElo"] == 2612 and rows[0]["Result"] == "1-0"
assert rows[0]["movetext"].startswith("1. e4 e5") and rows[0]["movetext"].endswith("d6 1-0")
assert rows[1]["ECO"] == "A00" and rows[2]["ECO"] is None
assert rows[2]["month"] == "2025-01"
NO_SITE_PGN = """[Event "Rated Blitz game"]
[Result "1-0"]
[WhiteElo "2600"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4 exd4 6. cxd4 Bb4+ 7. Nc3 Nxe4 8. O-O Bxc3
9. d5 Bf6 10. Re1 Ne7 11. Rxe4 d6 1-0
[Event "Rated Blitz game"]
[Result "0-1"]
1. d4 d5 2. Nf3 Nf6 3. Nc3 Nc6 4. Bf4 Bf5 5. Qd2 Qd7 6. O-O-O O-O-O 7. e3 e6 8. Bd3 Bd6
9. Ne5 Ne4 10. Bxe4 Bxe5 0-1
"""
HEADERS_ONLY_PGN = """[Event "Rated Blitz game"]
[Site "https://lichess.org/empty"]
[Result "*"]
[WhiteElo "2700"]
[BlackElo "2650"]
[Event "Rated Blitz game"]
[Site "https://lichess.org/real"]
[Result "1-0"]
[ECO "C50"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4 exd4 6. cxd4 Bb4+ 7. Nc3 Nxe4 8. O-O Bxc3
9. d5 Bf6 10. Re1 Ne7 11. Rxe4 d6 1-0
"""
def test_iter_pgn_rows_without_site_gives_each_game_its_own_id(tmp_path: Path) -> None:
pgn = tmp_path / "no-site.pgn"
pgn.write_text(NO_SITE_PGN, encoding="utf-8")
rows = list(iter_pgn_rows(pgn, "2025-01"))
assert [r["Site"] for r in rows] == ["2025-01:0", "2025-01:1"]
ids = convert_rows(rows, min_plies=0, max_plies=300)["columns"]["game_id"] # type: ignore[index]
assert ids == [game_id("2025-01:0"), game_id("2025-01:1")]
assert len(set(ids)) == 2
def test_iter_pgn_rows_missing_elo_is_none(tmp_path: Path) -> None:
pgn = tmp_path / "no-site.pgn"
pgn.write_text(NO_SITE_PGN, encoding="utf-8")
rows = list(iter_pgn_rows(pgn, "2025-01"))
assert rows[0]["WhiteElo"] == 2600 and rows[0]["BlackElo"] is None
columns = convert_rows(rows, min_plies=0, max_plies=300)["columns"] # type: ignore[index]
assert columns["black_elo"] == [None, None]
def test_iter_pgn_rows_drops_a_game_without_movetext(tmp_path: Path) -> None:
pgn = tmp_path / "headers-only.pgn"
pgn.write_text(HEADERS_ONLY_PGN, encoding="utf-8")
rows = list(iter_pgn_rows(pgn, "2025-01"))
assert len(rows) == 1
assert rows[0]["Site"] == "https://lichess.org/real"
# The dropped game's headers must not leak into the next one.
assert rows[0]["WhiteElo"] is None and rows[0]["ECO"] == "C50"
def _fake_download(pgn_text: str, calls: list[str]): # type: ignore[no-untyped-def]
def download(url: str, dest: Path) -> None:
calls.append(url)
dest.parent.mkdir(parents=True, exist_ok=True)
month = url.rsplit("_", 1)[1].removesuffix(".zip")
with zipfile.ZipFile(dest, "w") as archive:
archive.writestr(f"lichess_elite_{month}.pgn", pgn_text)
return download
def test_run_downloads_once_and_converts(rukh_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
monkeypatch.setattr(elite_module, "_download", _fake_download(PGN, calls))
cfg = EliteConfig(months=["2025-01", "2025-02"], workers=1)
manifest = run(cfg)
assert calls == [zip_url(cfg, "2025-01"), zip_url(cfg, "2025-02")]
out = rukh_home / "data" / "elite"
frame = pl.read_parquet((out / "games.parquet").as_posix())
# The 4-ply game is too short; the other two survive, once per month.
assert frame.height == 4
assert frame["month"].to_list() == ["2025-01", "2025-01", "2025-02", "2025-02"]
assert frame["uci"][0].startswith("e2e4 e7e5 g1f3 b8c6 f1c4 f8c5")
assert frame.schema["white_elo"] == pl.Int16
assert str(frame["utc_date"][0]) == "2025-01-03"
assert manifest.counts == {"games": 4}
assert manifest.filters["conversion"]["short"] == 2
assert [f.path for f in manifest.files] == [
"lichess_elite_2025-01.zip",
"lichess_elite_2025-02.zip",
"games.parquet",
]
assert manifest.filters["source_urls"]["2025-01"].endswith("lichess_elite_2025-01.zip")
# A second run reuses the zips on disk.
run(cfg)
assert len(calls) == 2
assert extract_pgn(out / "lichess_elite_2025-01.zip", out).name == "lichess_elite_2025-01.pgn"

tests/unit/test_elite.pylíneas 1-170 · p1

El test de la conexión compartida

Con el pipeline.yaml explicado, el test de db.py de la lección anterior ya se puede leer entero. Si no lo tecleas, tráelo con git checkout p1 -- tests/unit/test_db.py antes del último «Ejecútalo» de la lección.

// Teststests/unit/test_db.py: la sección global de DuckDB llega a cada paso
tests/unit/test_db.py
"""Tests for rukh.data.db: every connection gets the spill directory, memory limit and threads."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from rukh.data.db import TMP_DIRNAME, DuckDbConfig, connect, temp_directory
from rukh.data.pipeline import DUCKDB_STEPS, PipelineConfig
pytestmark = pytest.mark.unit
def _settings(con: object) -> dict[str, object]:
row = con.execute( # type: ignore[attr-defined]
"SELECT current_setting('temp_directory'), current_setting('memory_limit'), "
"current_setting('threads')"
).fetchone()
return {"temp_directory": row[0], "memory_limit": row[1], "threads": row[2]}
def test_connect_applies_the_settings(rukh_home: Path) -> None:
con = connect(DuckDbConfig(memory_limit="2GB", threads=3))
try:
settings = _settings(con)
finally:
con.close()
assert settings["temp_directory"] == (rukh_home / "data" / TMP_DIRNAME).as_posix()
assert settings["memory_limit"] == "1.8 GiB" # DuckDB reports GiB
assert settings["threads"] == 3
assert (rukh_home / "data" / TMP_DIRNAME).is_dir()
def test_connect_defaults_to_32gb_and_every_core(rukh_home: Path) -> None:
con = connect()
try:
settings = _settings(con)
finally:
con.close()
assert settings["memory_limit"] == "29.8 GiB" # 32 GB
assert settings["threads"] == (os.cpu_count() or 4)
assert temp_directory() == rukh_home / "data" / TMP_DIRNAME

tests/unit/test_db.pylíneas 1-44 · p1

Los dos primeros le preguntan a la propia DuckDB con current_setting en vez de fiarse de que el SET se ejecutó. DuckDB responde en GiB, así que 2GB vuelve como 1.8 GiB.

tests/unit/test_db.py
def test_pipeline_shares_the_duckdb_section() -> None:
cfg = PipelineConfig.model_validate({"duckdb": {"memory_limit": "8GB", "threads": 2}})
for name in DUCKDB_STEPS:
step = getattr(cfg, name)
assert step.duckdb.memory_limit == "8GB" and step.duckdb.threads == 2
# A step may still override it.
cfg = PipelineConfig.model_validate(
{"duckdb": {"memory_limit": "8GB"}, "evals": {"duckdb": {"memory_limit": "1GB"}}}
)
assert cfg.evals.duckdb.memory_limit == "1GB"
assert cfg.positions.duckdb.memory_limit == "8GB"
def test_shipped_pipeline_yaml_declares_duckdb() -> None:
from rukh.data.pipeline import load_pipeline
cfg = load_pipeline()
assert cfg.duckdb.memory_limit == "32GB"
assert cfg.puzzles.duckdb == cfg.duckdb

tests/unit/test_db.pylíneas 47-65 · p1

Estos dos prueban el validador. El primero recorre la propia tupla DUCKDB_STEPS y comprueba la herencia y la excepción. El segundo carga el pipeline.yaml que viaja en el repositorio, como hacía test_lock_guard.py en M0: si una propiedad vive en un YAML y romperla no falla nada, escribe el test que la lee.

// Ejercicio 01Añade un paso al pipeline

Sin escribir el paso de verdad, añade al PipelineConfig una sección openings con un OpeningsConfig de tres campos (un uci_dir, un out_dir y un min_games: int), y añade "openings" a DUCKDB_STEPS (a la provisional, mientras la tengas). Ejecuta uv run pytest -m unit -q tests/unit/test_uci.py -k cli, el test que pasa por rukh data uci y por tanto carga pipeline.py. ¿Qué falla y por qué? Después arréglalo de la forma mínima.

// SoluciónVer la solución

Falla test_cli_data_uci con un AttributeError: 'OpeningsConfig' object has no attribute 'duckdb', porque el validador recorre DUCKDB_STEPS y OpeningsConfig no tiene campo duckdb. Y no falla solo ese test: el validador corre cada vez que se construye un PipelineConfig, así que cualquier subcomando de rukh data que cargue la configuración muere igual (con test_db.py entero, desde la lección de puzles, fallan sus dos tests del pipeline). El arreglo mínimo es darle el campo: duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig), igual que los otros cuatro.

Lo interesante es el orden en que te enteras: el test falla en cuanto añades el nombre a la tupla, antes de que exista una línea del paso nuevo. Esa tupla es el contrato —“estos pasos abren conexiones”— y tenerla escrita en un sitio convierte un olvido en un error. La alternativa, descubrir por qué tu paso nuevo vuelca al disco C: en mitad de una consulta de dos horas, cuesta más.

Qué has aprendido

Tienes el esqueleto del pipeline: una configuración única, validada entera, que reparte su sección de DuckDB; una CLI donde un paso nuevo son cuatro líneas; y dos pasos que reutilizan lo anterior de dos formas, importando el código (elite) o empujando el trabajo al motor (elo-bins).

Cómo se mide: uv run rukh data elite deja data/elite/games.parquet con el esquema de uci.py, y uv run rukh data elo-bins deja data/elo-bins/games.parquet cuyo manifiesto tiene un conteo por tramo de cien puntos. uv run pytest -m unit -q tests/unit/test_elo_bins.py tests/unit/test_elite.py pasa en verde, y tests/unit/test_db.py entero en cuanto descomentes los pasos de puzles, dos lecciones más adelante.

Lo siguiente son las posiciones: abrir las partidas jugada a jugada y cruzarlas con 42 GB de evaluaciones de Stockfish que no vamos a descargar.