// M1 · lección 04
Posiciones y evaluaciones: el join que no descarga 42 GB
De partidas a posiciones: el FEN de cuatro campos que sirve de clave, las fases, la deduplicación con conteo, y el semi join resumible contra los 395 millones de posiciones evaluadas que Lichess publica. Con el módulo de puntuación que ordena las líneas.
Qué vas a construir
Hasta aquí el pipeline ha tratado las partidas como cadenas. Este paso las abre: recorre 300 000
partidas jugada a jugada, se queda con cada posición, las deduplica contando cuántas veces se vio
cada una y cruza el resultado con las 395 millones de posiciones que Lichess publica evaluadas por StockfishStockfishMotor de ajedrez libre (GPL-3) y el más fuerte disponible. En Rukh no juega por el modelo: es el juez. Con UCI_LimitStrength y UCI_Elo (1320-3190) se convierte en un rival calibrado para estimar Elo, y a poca profundidad da recompensas baratas para el alineamiento..
Ese cruce es contra 42 GB de parquet remoto que no vamos a descargar: se empuja el filtro al origen y solo viajan las filas que coinciden. De aquí salen las etiquetas del encoder de M3 y, gratis, los pares de preferencia para el DPO de M5. Son tres ficheros.
positions.py: de partidas a posiciones
"""Positions from the UCI games: normalized 4-field FEN, phase, deduplicated with a count.
``fen4`` is ``pieces turn castling en-passant`` (the key of``Lichess/chess-position-evaluations``); move counters are dropped so the same position fromdifferent games collapses into one row with ``n_seen`` occurrences. The first occurrence winsthe ``game_id``/``ply``/``last_move``/``result`` columns."""
from __future__ import annotations
from collections.abc import Iteratorfrom pathlib import Path
import pyarrow as paimport pyarrow.parquet as pqfrom pydantic import Field
from rukh.config import BaseConfigfrom rukh.data.db import DuckDbConfigfrom rukh.data.db import connect as db_connectfrom rukh.data.manifest import FileHash, Manifestfrom rukh.data.parallel import run_batchesfrom rukh.data.uci import resolve_workers, sha256_filefrom rukh.paths import resolve
OPENING_MAX_PLY = 10MIDDLEGAME_MIN_PIECES = 14BATCH_GAMES = 5_000POSITIONS_FILE = "positions.parquet"PARTS_DIR = "parts"POSITION_SCHEMA = pa.schema( [ ("seq", pa.int64()), ("fen4", pa.string()), ("game_id", pa.int64()), ("ply", pa.int16()), ("last_move", pa.string()), ("result", pa.string()), ("phase", pa.string()), ])El docstring define fen4, el concepto de toda la lección. Un FENFENCadena de texto que describe una posición completa: piezas por fila, turno, derechos de enroque, casilla al paso y contadores de jugadas. Es la clave con la que se cruzan las posiciones de las partidas con las evaluaciones públicas de Stockfish.
completo tiene seis campos: piezas, turno, enroques, casilla al paso, el contador de la regla de las
cincuenta jugadas y el número de jugada. Los dos últimos son contadores, no posición: el mismo
tablero en la jugada 20 de una partida y en la 24 de otra tiene distinto FEN completo.
Quitarlos colapsa en una fila las partidas que llegan a la misma posición, con n_seen contando
cuántas veces se vio, y el ahorro es todo el coste del paso siguiente. Además, fen4 es la clave con
la que Lichess publica sus evaluaciones: sin esta normalización no habría join.
Las constantes de fase son convenciones del proyecto, por eso van al manifiesto. Lo que se les pide es que “final” signifique lo mismo en los pares de preferencia y en las tablas por fase de M3 y M5.
class PositionsConfig(BaseConfig): """Which games to walk and how many distinct positions to keep."""
uci_dir: str = "data/uci" month: str = "2025-01" out_dir: str = "data/positions" n_games: int = Field(default=300_000, ge=1) max_positions: int = Field(default=5_000_000, ge=1) workers: int = Field(default=0, ge=0) duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)300 000 de los casi tres millones de partidas de enero bastan: las posiciones de apertura se repiten tanto que diez veces más partidas no dan diez veces más posiciones distintas.
def fen4(board: object) -> str: """First four FEN fields, built without the move counters python-chess would format.
The en-passant square is written only when the capture is legal, which is the convention of ``Lichess/chess-position-evaluations``. """ import chess
ep = chess.SQUARE_NAMES[board.ep_square] if board.has_legal_en_passant() else "-" # type: ignore[attr-defined] turn = "w" if board.turn else "b" # type: ignore[attr-defined] return f"{board.board_fen()} {turn} {board.castling_xfen()} {ep}" # type: ignore[attr-defined]
def phase(ply: int, n_pieces: int) -> str: """``opening`` up to ply 10, ``middlegame`` with 14+ pieces afterwards, else ``endgame``.""" if ply <= OPENING_MAX_PLY: return "opening" return "middlegame" if n_pieces >= MIDDLEGAME_MIN_PIECES else "endgame"La línea que hay que leer dos veces es la del ep. board.ep_square está puesto siempre que el
último movimiento fue un peón dos casillas, aunque ninguna captura al paso sea legal;
has_legal_en_passant() solo es cierto cuando la captura se puede hacer de verdad.
La cadena se construye a mano en vez de cortar board.fen().split()[:4] porque esto corre
veintitrés millones de veces, y por lo mismo las piezas se cuentan con chess.popcount sobre el
bitboard y no construyendo un diccionario por posición.
def walk_game(game_id: int, uci: str, result: str) -> Iterator[tuple[str, int, int, str, str, str]]: """Yield ``(fen4, game_id, ply, last_move, result, phase)`` after every move.""" import chess
board = chess.Board() for ply, token in enumerate(uci.split(), start=1): move = chess.Move.from_uci(token) board.push(move) yield fen4(board), game_id, ply, token, result, phase(ply, chess.popcount(board.occupied))
def walk_rows(rows: list[tuple[int, str, str]]) -> dict[str, list[object]]: """Walk a batch of ``(game_id, uci, result)``; module-level for the spawn pool.""" out: dict[str, list[object]] = {name: [] for name in POSITION_SCHEMA.names if name != "seq"} for game_id, uci, result in rows: for fen, gid, ply, last_move, res, ph in walk_game(game_id, uci, result): out["fen4"].append(fen) out["game_id"].append(gid) out["ply"].append(ply) out["last_move"].append(last_move) out["result"].append(res) out["phase"].append(ph) return outwalk_rows es otra vez una función de módulo, el punto de entrada del pool de spawn, y construye
un diccionario de columnas porque esa es la forma que Arrow guarda en memoria. La columna seq (la
posición global de cada fila) no la puede calcular el worker, que no sabe cuántos lotes le
precedieron: se añade al escribir.
def iter_games(games: Path, n_games: int) -> Iterator[list[tuple[int, str, str]]]: """Batches of ``(game_id, uci, result)``, read from the parquet in row groups.""" reader = pq.ParquetFile(games) remaining = n_games for batch in reader.iter_batches(batch_size=BATCH_GAMES, columns=["game_id", "uci", "result"]): if remaining <= 0: return rows = list( zip( batch.column("game_id").to_pylist(), batch.column("uci").to_pylist(), batch.column("result").to_pylist(), strict=True, ) )[:remaining] remaining -= len(rows) yield rowsiter_games pide tres columnas de las diez, y como el parquet es columnar solo lee esas tres del
disco. Al llegar a n_games, el return corta el generador y no se lee un grupo de filas más.
def write_parts(games: Path, parts_dir: Path, cfg: PositionsConfig) -> int: """Walk the games into ``parts_dir/part-NNNN.parquet`` (all positions, not deduplicated).""" parts_dir.mkdir(parents=True, exist_ok=True) for old in parts_dir.glob("part-*.parquet"): old.unlink() workers = resolve_workers(cfg.workers) batches = iter_games(games, cfg.n_games) return _write_parts(parts_dir, run_batches(walk_rows, batches, workers), 0)
def _write_parts(parts_dir: Path, results: Iterator[dict[str, list[object]]], seq: int) -> int: for index, columns in enumerate(results): n = len(columns["fen4"]) table = pa.table({"seq": list(range(seq, seq + n)), **columns}, schema=POSITION_SCHEMA) pq.write_table(table, parts_dir / f"part-{index:04d}.parquet", compression="zstd") seq += n return seqwrite_parts borra los trozos viejos antes de empezar, para que una ejecución corta después de una
larga no herede los sobrantes.
La decisión central es escribir en trozos y deduplicar después. Deduplicar sobre la marcha pediría un diccionario con veintitrés millones de FEN en el proceso padre, varios gigas en un solo hilo; dejar que DuckDB agrupe desde disco no gasta memoria y usa todos los núcleos.
def dedupe( parts_dir: Path, out: Path, max_positions: int, duckdb_cfg: DuckDbConfig | None = None) -> int: """Group the parts by ``fen4`` (first occurrence wins, ``n_seen`` counted) with DuckDB.""" glob = (parts_dir / "part-*.parquet").as_posix() con = db_connect(duckdb_cfg) try: con.execute( f""" COPY ( SELECT fen4, arg_min(game_id, seq) AS game_id, arg_min(ply, seq) AS ply, arg_min(last_move, seq) AS last_move, arg_min(result, seq) AS result, arg_min(phase, seq) AS phase, count(*) AS n_seen FROM read_parquet('{glob}') GROUP BY fen4 ORDER BY min(seq) LIMIT {int(max_positions)} ) TO '{out.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD) """ ) row = con.execute(f"SELECT count(*) FROM read_parquet('{out.as_posix()}')").fetchone() finally: con.close() return int(row[0]) if row else 0arg_min(game_id, seq) es “el game_id de la fila con el seq más pequeño del grupo”: la primera
aparición gana. La posición tras 1.e4 la vieron cien mil partidas, y se guardan los datos de la
primera; lo que importa es el FEN, y n_seen dice cuántas eran. El ORDER BY min(seq) antes del
LIMIT hace determinista el tope: sin él, dos ejecuciones darían dos conjuntos distintos.
def month_parquet(uci_dir: Path, month: str) -> Path: year, mm = month.split("-") return uci_dir / f"year={year}" / f"month={mm}" / "games.parquet"
def run(cfg: PositionsConfig) -> Manifest: """Walk up to ``n_games`` of ``month`` and write ``out_dir/positions.parquet`` + manifest.""" games = month_parquet(resolve(cfg.uci_dir), cfg.month) out_dir = resolve(cfg.out_dir) parts_dir = out_dir / PARTS_DIR n_positions = write_parts(games, parts_dir, cfg) out = out_dir / POSITIONS_FILE n_distinct = dedupe(parts_dir, out, cfg.max_positions, cfg.duckdb) manifest = Manifest( dataset="Lichess/standard-chess-games", months=[cfg.month], filters={ "n_games": cfg.n_games, "max_positions": cfg.max_positions, "phase": { "opening_max_ply": OPENING_MAX_PLY, "middlegame_min_pieces": MIDDLEGAME_MIN_PIECES, }, }, counts={"positions": n_positions, "distinct": n_distinct}, files=[FileHash(path=POSITIONS_FILE, sha256=sha256_file(out), bytes=out.stat().st_size)], ) (out_dir / "manifest.json").write_text(manifest.model_dump_json(indent=2) + "\n", "utf-8") return manifestEl cociente entre los dos contadores, positions y distinct, es la tasa de repetición del corpus.
scoring.py: una sola convención para ordenar líneas
Las evaluaciones de Lichess vienen en formato multi-PVmulti-PVModo de análisis en el que un motor devuelve varias líneas candidatas para la misma posición, ordenadas de mejor a peor, en vez de solo la mejor. Las evaluaciones publicadas por Lichess son multi-PV, y en Rukh eso da dos cosas gratis: el valor de la posición (la mejor línea) y los pares de preferencia de M5 (la mejor contra una suficientemente peor).: varias filas por
posición, una por línea analizada, y hay que decidir cuál es la mejor. Esa decisión la toman dos
consumidores, la consolidación de evals.py en SQL y los pares de la lección siguiente en Python, y
tienen que tomarla igual.
"""One scoring convention for every multi-PV consumer (consolidation and DPO pairs).
``cp`` and ``mate`` in ``Lichess/chess-position-evaluations`` are from White's point of view.A mate is worth ``MATE_SCORE - mate`` (so a mate in 1 beats a mate in 5) and a line is judgedfrom the side to move, which negates the score for Black.
Lines are ranked best first by: a mate *for the side to move*, then depth, then the score forthe side to move, then the move string as a tie-break. ``evals`` consolidates with the SQLexpressions below and ``pairs`` ranks with ``line_rank_key``; both orderings must stayidentical, which is why they live in one module."""
from __future__ import annotations
MATE_SCORE = 10_000BEST_LINE_DOC = "deepest; mate for the mover first; then best cp for the side to move"
# SQL counterparts used by ``rukh.data.evals`` (``sign`` is ``mover_sign`` of the FEN turn).SCORE_WHITE_SQL = f"""CASE WHEN mate IS NOT NULL THEN CASE WHEN mate > 0 THEN {MATE_SCORE} - mate ELSE -{MATE_SCORE} - mate END ELSE cp END"""RANK_ORDER_SQL = ( "(mate IS NOT NULL AND sign * mate > 0) DESC, depth DESC, sign * score_white DESC, move")Dos convenciones. cp y mate vienen siempre desde el punto de vista de las blancas: un
cp: -300 es ventaja de tres peones para las negras, mueva quien mueva. Y un mate en n vale
MATE_SCORE - mate, así que un mate en 1 (9 999) supera a uno en 5 (9 995) y ambos a cualquier
ventaja material. Es 10 000 y no infinito para que la columna siga siendo entera.
Lo raro del módulo es que las cadenas de SQL viven junto a sus equivalentes en Python. Con el CASE
en evals.py y el sorted en pairs.py, la misma regla quedaría en dos lenguajes y dos ficheros,
sin nada que avise cuando una cambie.
def mover_sign(turn: str) -> int: """``+1`` when White is to move, ``-1`` for Black.""" return 1 if turn == "w" else -1
def score_white(cp: int | None, mate: int | None) -> int | None: """Centipawns from White's point of view; a mate in ``n`` is ``+/-(10000 - n)``.""" if mate is not None: return MATE_SCORE - mate if mate > 0 else -MATE_SCORE - mate return None if cp is None else int(cp)
def score_mover(cp: int | None, mate: int | None, turn: str) -> int | None: """``score_white`` seen from the side to move: unchanged for White, negated for Black.""" white = score_white(cp, mate) return None if white is None else mover_sign(turn) * white
def line_rank_key( cp: int | None, mate: int | None, depth: int | None, move: str, turn: str) -> tuple[bool, int, int, str]: """Sort key (ascending) reproducing ``RANK_ORDER_SQL``: the best line sorts first.""" sign = mover_sign(turn) white = score_white(cp, mate) if white is None: raise ValueError("a line without cp and without mate cannot be ranked") mates_for_mover = mate is not None and sign * mate > 0 # DuckDB orders NULL depths last, so an unknown depth ranks below any known one. return (not mates_for_mover, -(depth if depth is not None else -1), -sign * white, move)line_rank_key reproduce RANK_ORDER_SQL término a término:
- Mate a favor del que mueve, primero (
sign * mate > 0). En Python va comonot mates_for_moverporque el orden es ascendente yFalse < True. - Profundidad, de mayor a menor: una línea a profundidad 40 es más fiable que una a 12 aunque
prometa menos. El
-1imita a DuckDB, que pone losNULLal final. - La puntuación desde el que mueve, de mayor a menor.
- La cadena de la jugada, como desempate.
El cuarto término da un orden total. Sin él, dos líneas empatadas quedan como el motor quiera, y dos ejecuciones pueden elegir distinta “mejor línea” sin que nadie se entere.
evals.py: el join viaja a los datos
"""Join the sampled positions with ``Lichess/chess-position-evaluations`` (streaming, resumable).
Each remote file ``data_00NN.parquet`` is semi-joined with the local positions by ``fen`` andwritten as ``data/evals/part-NN.parquet``; an existing part is skipped, so an interrupted runresumes where it stopped. ``consolidate`` then keeps one row per FEN with the best line andevery line as ``pvs``; the ordering and the mate convention come from ``rukh.data.scoring``,shared with ``rukh.data.pairs``."""
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.scoring import BEST_LINE_DOC, MATE_SCORE, RANK_ORDER_SQL, SCORE_WHITE_SQLfrom rukh.data.uci import sha256_filefrom rukh.paths import resolve
EVAL_FILE = "positions-eval.parquet"
__all__ = ["MATE_SCORE", "EvalsConfig", "consolidate", "fetch_part", "parse_files", "run"]
class EvalsConfig(BaseConfig): """Remote dataset, local positions and the output directory."""
dataset: str = "Lichess/chess-position-evaluations" remote_pattern: str = "hf://datasets/{dataset}/data/data_{index:04d}.parquet" n_files: int = Field(default=20, ge=1) positions: str = "data/positions/positions.parquet" out_dir: str = "data/evals" duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)remote_pattern usa hf://, el esquema de Hugging Face que DuckDB lee como un fichero local, con
lecturas parciales.
def parse_files(spec: str, n_files: int) -> list[int]: """``"0-19"``, ``"3"`` or ``"0,2,5-7"`` into a sorted list of file indices.""" indices: set[int] = set() for part in spec.split(","): part = part.strip() if not part: continue if "-" in part: lo, hi = part.split("-", 1) indices.update(range(int(lo), int(hi) + 1)) else: indices.add(int(part)) bad = [i for i in indices if i < 0 or i >= n_files] if bad: raise ValueError(f"file index out of range 0-{n_files - 1}: {bad}") return sorted(indices)
def _source(cfg: EvalsConfig, index: int) -> str: """Remote parquet for file ``index`` (tests monkeypatch this to a local file).""" return cfg.remote_pattern.format(dataset=cfg.dataset, index=index)
def part_path(out_dir: Path, index: int) -> Path: return out_dir / f"part-{index:02d}.parquet"parse_files sirve a --files: un índice fuera de rango da un ValueError antes de lanzar nada,
no un 404 a los diez minutos. _source aísla la red, como _download en elite.py, para que el
test la sustituya por una ruta local.
def fetch_part(cfg: EvalsConfig, index: int, positions: Path, out_dir: Path) -> tuple[Path, bool]: """Semi-join one remote file with the positions; ``(path, fetched)`` (False = existed).""" target = part_path(out_dir, index) if target.is_file(): return target, False tmp = target.with_suffix(".parquet.tmp") con = connect(cfg.duckdb) try: con.execute( f""" COPY ( SELECT e.fen, e.line, e.depth, e.knodes, e.cp, e.mate FROM read_parquet('{_source(cfg, index)}') e SEMI JOIN read_parquet('{positions.as_posix()}') p ON e.fen = p.fen4 ) TO '{tmp.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD) """ ) finally: con.close() tmp.replace(target) return target, TrueEsta consulta es la lección. Un SEMI JOIN devuelve las filas de la izquierda que tienen alguna
coincidencia a la derecha, sin traer columnas de la derecha ni duplicar filas: “de este fichero
remoto, dame las evaluaciones cuyo FEN tenemos”.
Lo barato es lo que el motor hace con eso. Un parquet está partido en grupos de filas, y cada grupo
guarda el mínimo y el máximo de cada columna. DuckDB lee primero solo la columna fen, descarta los
grupos que no pueden contener ninguna coincidencia (el predicado empujado al origenPredicate pushdownEmpujar el filtro hasta el almacén de datos para que solo viaje lo que pasa el filtro, en vez de traerlo todo y descartar en local. Es lo que permite que rukh data fetch saque un recorte de dos meses de Lichess (73 GB por mes en parquet) descargando solo los pocos GB de partidas que cumplen los filtros.) y solo entonces baja las filas que quedan. Es como
buscar en un archivo de cajas etiquetadas “de la A a la F”: no abres las que no pueden tener lo que
buscas.
Dos líneas lo hacen resumible. Una parte ya escrita no se vuelve a pedir, y cada parte se escribe a
un .tmp que se renombra al terminar. El renombrado es atómico, así que el nombre definitivo solo
existe si la parte está completa: un Ctrl-C a mitad de la parte 7 no deja un parquet truncado que la
siguiente ejecución dé por hecho.
def consolidate( out_dir: Path, positions: Path, duckdb_cfg: DuckDbConfig | None = None) -> dict[str, int]: """Best line per FEN plus ``pvs``, joined back with the positions' metadata.""" parts = (out_dir / "part-*.parquet").as_posix() target = out_dir / EVAL_FILE con = connect(duckdb_cfg) try: con.execute( f""" CREATE TEMP TABLE lines AS SELECT fen, split_part(line, ' ', 1) AS move, depth, knodes, cp, mate, CASE WHEN split_part(fen, ' ', 2) = 'w' THEN 1 ELSE -1 END AS sign, {SCORE_WHITE_SQL} AS score_white FROM read_parquet('{parts}') WHERE line IS NOT NULL AND line <> '' """ ) con.execute( f""" CREATE TEMP TABLE ranked AS SELECT *, sign * score_white AS score_mover, row_number() OVER (PARTITION BY fen ORDER BY {RANK_ORDER_SQL}) AS rk FROM lines """ ) con.execute( f""" COPY ( SELECT p.fen4 AS fen, p.game_id, p.ply, p.last_move, p.result, p.phase, p.n_seen, b.move AS best_move, b.cp, b.mate, b.depth, b.knodes, pv.pvs FROM read_parquet('{positions.as_posix()}') p JOIN ranked b ON b.fen = p.fen4 AND b.rk = 1 JOIN ( SELECT fen, list(struct_pack(move := move, cp := cp, mate := mate, depth := depth) ORDER BY rk) AS pvs FROM ranked GROUP BY fen ) pv ON pv.fen = p.fen4 ORDER BY p.game_id, p.ply ) TO '{target.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD) """ ) total = con.execute( f"SELECT count(*) FROM read_parquet('{positions.as_posix()}')" ).fetchone() covered = con.execute( f"SELECT count(*) FROM read_parquet('{target.as_posix()}')" ).fetchone() n_lines = con.execute("SELECT count(*) FROM lines").fetchone() finally: con.close() return { "positions": int(total[0]) if total else 0, "evaluated": int(covered[0]) if covered else 0, "lines": int(n_lines[0]) if n_lines else 0, }La consolidación son tres sentencias. La primera crea lines: una fila por línea analizada, con su
primera jugada, el bando que mueve leído del propio FEN y la puntuación de scoring.py. La segunda
numera las líneas de cada FEN en el orden de scoring.py, así que rk = 1 es la mejor.
La tercera escribe el fichero: despliega la mejor línea en columnas (best_move, cp, mate…) y
agrupa todas las líneas en una columna pvs, de mejor a peor. Esa columna ya está descargada, no
cuesta casi nada y contiene un dataset entero de preferencias. El JOIN con nuestras posiciones
añade game_id, ply, phase y demás, que M3 y la lección siguiente necesitan.
Los conteos dan la coverage del manifiesto, la fracción de nuestras posiciones con evaluación. Es
el número que hay que mirar tras la primera ejecución, y el que baja si el fen4 no casa.
def run(cfg: EvalsConfig, files: str | None = None) -> Manifest: """Fetch the requested parts (skipping existing ones), consolidate and write the manifest.""" positions = resolve(cfg.positions) out_dir = resolve(cfg.out_dir) out_dir.mkdir(parents=True, exist_ok=True) indices = parse_files(files or f"0-{cfg.n_files - 1}", cfg.n_files) fetched: list[int] = [] for index in indices: _, was_fetched = fetch_part(cfg, index, positions, out_dir) if was_fetched: fetched.append(index) counts = consolidate(out_dir, positions, cfg.duckdb) present = sorted(int(p.stem.split("-")[1]) for p in out_dir.glob("part-*.parquet")) target = out_dir / EVAL_FILE manifest = Manifest( dataset=cfg.dataset, months=[], filters={ "positions": Path(cfg.positions).as_posix(), "files_present": present, "files_fetched_now": fetched, "n_files": cfg.n_files, "coverage": round(counts["evaluated"] / counts["positions"], 4) if counts["positions"] else 0.0, "best_line": BEST_LINE_DOC, "mate_score": MATE_SCORE, }, counts=counts, files=[FileHash(path=EVAL_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 manifestfiles_present dice con qué partes se construyó el fichero y files_fetched_now, cuáles se bajaron
en esta ejecución; en un paso que se completa en cuatro sesiones, solo el primero describe el
resultado. El criterio de ordenación y MATE_SCORE van también al manifiesto.
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» del final los pasa, con test_evals.py, plegado después: si no los tecleas,
tráelos antes con
git checkout p1 -- tests/unit/test_positions.py tests/unit/test_scoring.py tests/unit/test_evals.py.
// Teststests/unit/test_positions.py y test_scoring.py: posiciones únicas, claves y la convención del mejor movimiento
"""Tests for rukh.data.positions: fen4 normalization, phase labels and deduplication."""
from __future__ import annotations
import shutilfrom pathlib import Path
import chessimport polars as plimport pytestfrom typer.testing import CliRunner
from rukh.cli import appfrom rukh.data.positions import PositionsConfig, fen4, phase, run, walk_game
pytestmark = pytest.mark.unit
def test_fen4_drops_counters_and_keeps_castling_and_ep() -> None: board = chess.Board() for move in ("e2e4", "a7a6", "e4e5", "d7d5"): board.push_uci(move) assert board.fen().split()[4:] == ["0", "3"] assert fen4(board) == "rnbqkbnr/1pp1pppp/p7/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6" board.push_uci("e5d6") assert fen4(board).endswith(" b KQkq -")
def test_fen4_matches_the_first_four_fen_fields() -> None: board = chess.Board() moves = "e2e4 c7c5 g1f3 d7d6 d2d4 c5d4 f3d4 g8f6 b1c3 a7a6 f1e2 e7e5 d4b3 f8e7 e1g1 e8g8" for move in moves.split(): board.push_uci(move) assert fen4(board) == " ".join(board.fen().split()[:4]) endgame = chess.Board("8/5k2/8/8/3pP3/8/5K2/8 b - e3 0 40") assert fen4(endgame) == " ".join(endgame.fen().split()[:4])
def test_phase_thresholds() -> None: assert phase(1, 32) == "opening" assert phase(10, 32) == "opening" assert phase(11, 32) == "middlegame" assert phase(11, 14) == "middlegame" assert phase(11, 13) == "endgame" assert phase(80, 5) == "endgame"
def test_walk_game_rows() -> None: rows = list(walk_game(7, "e2e4 e7e5 g1f3", "1-0")) assert len(rows) == 3 fen, game_id, ply, last_move, result, ph = rows[0] assert fen == "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq -" assert (game_id, ply, last_move, result, ph) == (7, 1, "e2e4", "1-0", "opening") assert rows[2][2] == 3 and rows[2][3] == "g1f3"El primer test juega hasta una captura al paso legal y comprueba que el fen4 termina en d6, y
que vuelve a - tras capturar. El segundo es la comprobación barata de la caja: recorre una
siciliana y exige que fen4 coincida, posición a posición, con los cuatro primeros campos del FEN
de python-chess. test_phase_thresholds prueba cada umbral por sus dos lados (10/11, 14/13), que es
la única forma de probar un umbral.
def _stage_uci(rukh_home: Path, repo_root: Path) -> None: target = rukh_home / "data" / "uci" / "year=2025" / "month=01" / "games.parquet" target.parent.mkdir(parents=True) shutil.copy(repo_root / "tests" / "fixtures" / "games.parquet", target)
def test_run_dedupes_with_counts(rukh_home: Path, repo_root: Path) -> None: _stage_uci(rukh_home, repo_root) manifest = run(PositionsConfig(workers=1)) out = rukh_home / "data" / "positions" / "positions.parquet" frame = pl.read_parquet(out.as_posix()) assert frame.columns == ["fen4", "game_id", "ply", "last_move", "result", "phase", "n_seen"] total = int( pl.read_parquet((repo_root / "tests" / "fixtures" / "games.parquet").as_posix())[ "n_plies" ].sum() ) assert manifest.counts["positions"] == total assert manifest.counts["distinct"] == frame.height < total assert frame["fen4"].n_unique() == frame.height after_e4 = frame.filter( pl.col("fen4") == "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq -" ) assert after_e4.height == 1 and int(after_e4["n_seen"][0]) > 1 assert after_e4["ply"][0] == 1 and after_e4["last_move"][0] == "e2e4" assert set(frame["phase"].unique().to_list()) == {"opening", "middlegame", "endgame"} assert manifest.files[0].path == "positions.parquet"
def test_run_with_two_workers(rukh_home: Path, repo_root: Path) -> None: """The spawn pool path, exercised end to end on the fixture month.""" _stage_uci(rukh_home, repo_root) manifest = run(PositionsConfig(workers=2, n_games=4)) frame = pl.read_parquet((rukh_home / "data" / "positions" / "positions.parquet").as_posix()) assert manifest.counts["distinct"] == frame.height > 0 assert frame["fen4"].n_unique() == frame.height
def test_run_respects_caps(rukh_home: Path, repo_root: Path) -> None: _stage_uci(rukh_home, repo_root) manifest = run(PositionsConfig(workers=1, n_games=2, max_positions=30)) assert manifest.counts["distinct"] == 30 assert manifest.counts["positions"] == 33 + 7test_run_dedupes_with_counts calcula el total esperado sumando n_plies de la fixture (una
posición por ply, ni una más), comprueba en una comparación encadenada que el manifiesto no miente y
que la deduplicación hizo algo, y exige que la posición tras 1.e4 aparezca una vez con los datos de
la primera aparición. El 33 + 7 de test_run_respects_caps va como suma para que se vea de dónde
sale: dos partidas de 33 y 7 plies.
def test_cli_positions(rukh_home: Path, repo_root: Path) -> None: _stage_uci(rukh_home, repo_root) cfg = rukh_home / "pipeline.yaml" cfg.write_text("positions:\n workers: 1\n n_games: 3\n", encoding="utf-8") result = CliRunner().invoke(app, ["data", "positions", "--config", str(cfg)]) assert result.exit_code == 0, result.output assert "distinct:" in result.output"""Tests for rukh.data.scoring: the mate convention and the shared line ordering."""
from __future__ import annotations
import pytest
from rukh.data.scoring import MATE_SCORE, line_rank_key, mover_sign, score_mover, score_white
pytestmark = pytest.mark.unit
def test_score_white_prefers_the_shorter_mate() -> None: assert score_white(120, None) == 120 assert score_white(None, 1) == MATE_SCORE - 1 assert score_white(None, 5) == MATE_SCORE - 5 assert score_white(None, -1) == -MATE_SCORE + 1 assert score_white(None, -5) == -MATE_SCORE + 5 assert score_white(999, 2) == MATE_SCORE - 2 # mate wins over cp assert score_white(None, None) is None
def test_score_mover_flips_for_black() -> None: assert mover_sign("w") == 1 and mover_sign("b") == -1 assert score_mover(150, None, "w") == 150 assert score_mover(150, None, "b") == -150 assert score_mover(None, 2, "w") == MATE_SCORE - 2 assert score_mover(None, -2, "b") == MATE_SCORE - 2 assert score_mover(None, None, "b") is NoneLos dos primeros fijan la convención con todos sus casos. El que más se escribe mal es
score_mover(None, -2, "b") == MATE_SCORE - 2: un mate en 2 para las negras, visto desde las
negras, es una ventaja.
def test_line_rank_key_orders_mate_then_depth_then_cp() -> None: lines = [ ("deep_cp", dict(cp=400, mate=None, depth=40)), ("shallow_mate", dict(cp=None, mate=4, depth=12)), ("shallow_cp", dict(cp=900, mate=None, depth=12)), ("faster_mate", dict(cp=None, mate=2, depth=12)), ("getting_mated", dict(cp=None, mate=-3, depth=40)), ] order = [ name for name, kw in sorted( lines, key=lambda item: line_rank_key(move=item[0], turn="w", **item[1]) ) ] assert order[:2] == ["faster_mate", "shallow_mate"] # Depth outranks the score, so the two depth-40 lines come before the depth-12 one. assert order[2:] == ["deep_cp", "getting_mated", "shallow_cp"] # For Black the signs flip: a mate for Black comes first. black = sorted(lines, key=lambda item: line_rank_key(move=item[0], turn="b", **item[1])) assert black[0][0] == "getting_mated"
def test_line_rank_key_rejects_an_unscored_line() -> None: with pytest.raises(ValueError): line_rank_key(None, None, 30, "e2e4", "w")El test del orden ordena cinco líneas con nombre y comprueba la lista de nombres, que se lee como
una frase: primero el mate más rápido, luego el más lento, y después la profundidad manda aunque la
línea de profundidad 12 prometa +900. Con turno de negras, getting_mated (mate para las negras)
pasa a ser la mejor.
// FontaneríaCódigo completo: tests/unit/test_evals.py
"""Tests for rukh.data.evals: file spec parsing, resumable parts and best-line consolidation."""
from __future__ import annotations
from pathlib import Path
import chessimport polars as plimport pytest
from rukh.data import evals as evals_modulefrom rukh.data.evals import EvalsConfig, consolidate, fetch_part, parse_files, run
pytestmark = pytest.mark.unit
def test_parse_files() -> None: assert parse_files("0-19", 20) == list(range(20)) assert parse_files("3", 20) == [3] assert parse_files("0,2,5-7", 20) == [0, 2, 5, 6, 7] with pytest.raises(ValueError): parse_files("20", 20)
def _fen(moves: str) -> str: board = chess.Board() for move in moves.split(): board.push_uci(move) return " ".join(board.fen().split()[:4])
def _legal(fen: str, n: int) -> list[str]: board = chess.Board(fen + " 0 1") return [m.uci() for m in list(board.legal_moves)[:n]]
# Six positions: A, C, E White to move; B, D, F Black to move.FEN_A = _fen("e2e4 e7e5")FEN_B = _fen("e2e4")FEN_C = _fen("e2e4 e7e5 g1f3 b8c6")FEN_D = _fen("d2d4")FEN_E = _fen("d2d4 d7d5")FEN_F = _fen("c2c4")
def _lines() -> list[dict[str, object]]: a, b, c, d, e, f = (_legal(x, 3) for x in (FEN_A, FEN_B, FEN_C, FEN_D, FEN_E, FEN_F)) rows = [ # A (white): deepest lines first, then the highest cp -> a[1] (FEN_A, a[0], 30, 50, None), (FEN_A, a[1], 30, 120, None), (FEN_A, a[2], 20, 200, None), # B (black): lowest cp among the deepest -> b[1] (FEN_B, b[0], 30, 50, None), (FEN_B, b[1], 30, -80, None), (FEN_B, b[2], 20, -300, None), # C (white): mate for the mover beats a big cp -> c[0] (FEN_C, c[0], 30, None, 3), (FEN_C, c[1], 30, 500, None), # D (black): mate for the mover at lower depth still first -> d[0] (FEN_D, d[0], 25, None, -2), (FEN_D, d[1], 30, -900, None), # E (white): getting mated is worse than -700 -> e[1] (FEN_E, e[0], 30, None, -4), (FEN_E, e[1], 30, -700, None), # F: a single line (FEN_F, f[0], 18, 10, None), ] return [ {"fen": fen, "line": f"{move} a7a6", "depth": depth, "knodes": 1000, "cp": cp, "mate": mate} for fen, move, depth, cp, mate in rows ]
def _positions_parquet(path: Path) -> None: fens = [FEN_A, FEN_B, FEN_C, FEN_D, FEN_E, FEN_F, _fen("g1f3")] pl.DataFrame( { "fen4": fens, "game_id": list(range(len(fens))), "ply": [2, 1, 4, 1, 2, 1, 1], "last_move": ["e7e5", "e2e4", "b8c6", "d2d4", "d7d5", "c2c4", "g1f3"], "result": ["1-0"] * len(fens), "phase": ["opening"] * len(fens), "n_seen": [5, 9, 2, 3, 1, 1, 4], } ).write_parquet(path.as_posix())
@pytest.fixturedef staged(rukh_home: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[EvalsConfig, Path]: positions = rukh_home / "data" / "positions" / "positions.parquet" positions.parent.mkdir(parents=True) _positions_parquet(positions) lines = _lines() remote = rukh_home / "remote" remote.mkdir() # Split the lines over two "remote" files plus a stranger FEN that must not survive. stranger = {"fen": _fen("a2a3"), "line": "a7a6", "depth": 9, "knodes": 1, "cp": 0, "mate": None} pl.DataFrame(lines[:7] + [stranger]).write_parquet((remote / "0.parquet").as_posix()) pl.DataFrame(lines[7:]).write_parquet((remote / "1.parquet").as_posix()) calls: list[int] = []
def fake_source(cfg: EvalsConfig, index: int) -> str: calls.append(index) return (remote / f"{index}.parquet").as_posix()
monkeypatch.setattr(evals_module, "_source", fake_source) monkeypatch.setattr(evals_module, "_calls", calls, raising=False) return EvalsConfig(n_files=2), rukh_home / "data" / "evals"
def test_fetch_part_is_resumable(staged: tuple[EvalsConfig, Path], rukh_home: Path) -> None: cfg, out_dir = staged out_dir.mkdir(parents=True) positions = rukh_home / "data" / "positions" / "positions.parquet" path, fetched = fetch_part(cfg, 0, positions, out_dir) assert fetched and path.name == "part-00.parquet" part = pl.read_parquet(path.as_posix()) assert part.height == 7 # the stranger FEN is dropped by the semi join assert not list(out_dir.glob("*.tmp")) again, fetched = fetch_part(cfg, 0, positions, out_dir) assert not fetched and again == path assert evals_module._calls == [0] # type: ignore[attr-defined]
def test_consolidate_picks_best_line_by_turn(staged: tuple[EvalsConfig, Path]) -> None: cfg, out_dir = staged manifest = run(cfg, files="0-1") assert manifest.counts == {"positions": 7, "evaluated": 6, "lines": 13} assert manifest.filters["files_present"] == [0, 1] assert manifest.filters["coverage"] == round(6 / 7, 4) frame = pl.read_parquet((out_dir / "positions-eval.parquet").as_posix()) by_fen = {row["fen"]: row for row in frame.to_dicts()} a, b, c, d, e = (_legal(x, 3) for x in (FEN_A, FEN_B, FEN_C, FEN_D, FEN_E)) assert by_fen[FEN_A]["best_move"] == a[1] and by_fen[FEN_A]["cp"] == 120 assert by_fen[FEN_B]["best_move"] == b[1] and by_fen[FEN_B]["cp"] == -80 assert by_fen[FEN_C]["best_move"] == c[0] and by_fen[FEN_C]["mate"] == 3 assert by_fen[FEN_D]["best_move"] == d[0] and by_fen[FEN_D]["mate"] == -2 assert by_fen[FEN_E]["best_move"] == e[1] and by_fen[FEN_E]["cp"] == -700 assert len(by_fen[FEN_A]["pvs"]) == 3 assert by_fen[FEN_A]["pvs"][0]["move"] == a[1] assert by_fen[FEN_A]["n_seen"] == 5 and by_fen[FEN_A]["phase"] == "opening" assert _fen("g1f3") not in by_fen
def test_run_skips_existing_parts(staged: tuple[EvalsConfig, Path]) -> None: cfg, out_dir = staged run(cfg, files="0") manifest = run(cfg, files="0-1") assert manifest.filters["files_fetched_now"] == [1] assert evals_module._calls == [0, 1] # type: ignore[attr-defined] assert ( consolidate(out_dir, out_dir.parent / "positions" / "positions.parquet")["evaluated"] == 6 )// Ejercicio 01Mide la cobertura del join y rompe la clave
Después de ejecutar las posiciones y la parte 0 de las evaluaciones (la caja de arriba), abre
data/evals/manifest.json y mira filters.coverage. Después, en una copia del repositorio,
cambia fen4 para que use board.ep_square directamente en vez de has_legal_en_passant(),
borra data/positions/ y data/evals/part-00.parquet, y repite. ¿Cuánto baja la cobertura, y
qué error salta?
// SoluciónVer la solución
No salta ningún error, y ese es el punto del ejercicio. La cobertura baja varios puntos y el pipeline termina en verde. Las posiciones que se pierden son todas las que siguen a un avance de peón de dos casillas sin captura al paso posible, que empieza por casi todas las primeras jugadas de casi todas las partidas.
Para verlo, lo que hay que hacer es lo que hace el segundo test: comparar tu clave con la del otro
lado en unos cuantos casos. SELECT fen FROM read_parquet('hf://...data_0000.parquet') LIMIT 20
y buscar esos veinte FEN en tu positions.parquet es una consulta de dos minutos que atrapa el fallo
antes de la ejecución de tres horas.
Qué has aprendido
Has convertido un corpus de partidas en un catálogo de posiciones con una clave que casa con la de un tercero, y lo has cruzado con 42 GB que nunca se descargan. Y has escrito una regla de ordenación en dos lenguajes, en el mismo fichero y con un test que las compara.
Cómo se mide: uv run rukh data positions deja data/positions/positions.parquet con un
manifiesto que dice cuántas posiciones se recorrieron y cuántas distintas quedaron;
uv run rukh data evals deja data/evals/positions-eval.parquet con una coverage en su manifiesto
que es el número a vigilar. uv run pytest -m unit -q tests/unit/test_positions.py tests/unit/test_scoring.py tests/unit/test_evals.py pasa en verde.
Lo siguiente sale casi gratis: la columna pvs tiene, para cada posición, la jugada buena y la mala
con su diferencia en centipeonesCentipeón (cp)Unidad de la evaluación de un motor: 100 centipeones equivalen a un peón de ventaja. Es la etiqueta que Rukh cruzó en M1 desde las evaluaciones de Lichess y la que el encoder aprende a predecir, acotada con tanh(cp/400) para que un mate valga ±1 en vez de diez mil y no domine la pérdida. Un error se define como perder ≥ 100 cp respecto a la mejor línea de la posición anterior.. Eso es un par de preferencia, y hay
decenas de miles.