// 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.
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
"""``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 pathsfrom rukh.config import BaseConfig, load_yamlfrom rukh.data.db import DuckDbConfigfrom rukh.data.elite import EliteConfigfrom rukh.data.elo_bins import EloBinsConfigfrom rukh.data.evals import EvalsConfigfrom rukh.data.pairs import PairsConfigfrom rukh.data.positions import PositionsConfigfrom rukh.data.publish import PublishConfigfrom rukh.data.puzzles import PuzzlesConfigfrom rukh.data.uci import UciConfigEs 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
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)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.
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 selfEsta 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.
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)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
# 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() - 1tokenize: 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: 200positions: uci_dir: data/uci month: "2025-01" out_dir: data/positions n_games: 300000 max_positions: 5000000 workers: 0evals: dataset: Lichess/chess-position-evaluations remote_pattern: "hf://datasets/{dataset}/data/data_{index:04d}.parquet" n_files: 20 positions: data/positions/positions.parquet out_dir: data/evalspuzzles: 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: 42pairs: positions_eval: data/evals/positions-eval.parquet out_dir: data/pairs min_delta_cp: 100 max_per_phase: 200000 seed: 42elite: base_url: https://database.nikonoel.fr months: ["2025-01", "2025-02"] out_dir: data/elite min_plies: 20 max_plies: 300 workers: 0elo_bins: uci_dir: data/uci out_dir: data/elo-bins n_per_bin: 50000 seed: 42publish: owner: chorcat publish_dir: data/publishCada 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,pairsyelo_binshace que los tres conjuntos se regeneren idénticos; el test de los puzles sostiene la tabla única de M2 a M6. elite.base_urlapunta adatabase.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:
# Two recent months of rated standard games, filtered remotely (see docs/spec/01-datos.md).dataset: Lichess/standard-chess-gamesmonths: ["2025-01", "2025-02"]min_elo: 1800min_base_seconds: 180terminations: ["Normal", "Time forfeit"]min_plies: 20exclude_variants: trueout_dir: data/rawlimit: 3000000configs/data/lichess-2025-01-02.yaml
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
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}")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.
@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")--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.
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)_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.
@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)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
"""Lichess Elite Database (2500+ vs 2300+, no bullet): download, extract, convert to UCI."""
from __future__ import annotations
import reimport zipfilefrom collections.abc import Iteratorfrom pathlib import Path
import pyarrow.parquet as pqfrom pydantic import Field
from rukh.config import BaseConfigfrom rukh.data.manifest import FileHash, Manifestfrom rukh.data.parallel import run_batchesfrom 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)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.
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]_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.
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 rowEste 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.
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_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.
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 totalsconvert_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.
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 manifestCon 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.
"""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 BaseConfigfrom rukh.data.db import DuckDbConfig, connectfrom rukh.data.manifest import FileHash, Manifestfrom rukh.data.uci import month_dirs, sha256_filefrom 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)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}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.
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 manifestLos 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 for rukh.data.elo_bins: bin formula, per-bin cap and determinism."""
from __future__ import annotations
import shutilfrom pathlib import Path
import polars as plimport 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"] == 2El 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.
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()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 for rukh.data.elite: monkeypatched download, PGN parsing and UCI conversion."""
from __future__ import annotations
import zipfilefrom pathlib import Path
import polars as plimport pytest
from rukh.data import elite as elite_modulefrom rukh.data.elite import EliteConfig, extract_pgn, iter_pgn_rows, run, zip_urlfrom 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 Bxc39. 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 Nb810. 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 Bxc39. 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 Bd69. 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 Bxc39. 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"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 for rukh.data.db: every connection gets the spill directory, memory limit and threads."""
from __future__ import annotations
import osfrom pathlib import Path
import pytest
from rukh.data.db import TMP_DIRNAME, DuckDbConfig, connect, temp_directoryfrom 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_DIRNAMELos 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.
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.duckdbEstos 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.