rukh · lab

// 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.

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

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

src/rukh/data/positions.py
"""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 from
different games collapses into one row with ``n_seen`` occurrences. The first occurrence wins
the ``game_id``/``ply``/``last_move``/``result`` columns.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
from pydantic import Field
from rukh.config import BaseConfig
from rukh.data.db import DuckDbConfig
from rukh.data.db import connect as db_connect
from rukh.data.manifest import FileHash, Manifest
from rukh.data.parallel import run_batches
from rukh.data.uci import resolve_workers, sha256_file
from rukh.paths import resolve
OPENING_MAX_PLY = 10
MIDDLEGAME_MIN_PIECES = 14
BATCH_GAMES = 5_000
POSITIONS_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()),
]
)

src/rukh/data/positions.pylíneas 1-41 · p1

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.

src/rukh/data/positions.py
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)

src/rukh/data/positions.pylíneas 44-53 · p1

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.

src/rukh/data/positions.py
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"

src/rukh/data/positions.pylíneas 56-73 · p1

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.

src/rukh/data/positions.py
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 out

src/rukh/data/positions.pylíneas 76-98 · p1

walk_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.

src/rukh/data/positions.py
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 rows

src/rukh/data/positions.pylíneas 101-117 · p1

iter_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.

src/rukh/data/positions.py
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 seq

src/rukh/data/positions.pylíneas 120-136 · p1

write_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.

src/rukh/data/positions.py
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 0

src/rukh/data/positions.pylíneas 139-166 · p1

arg_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.

src/rukh/data/positions.py
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 manifest

src/rukh/data/positions.pylíneas 169-197 · p1

El 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.

src/rukh/data/scoring.py
"""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 judged
from 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 for
the side to move, then the move string as a tie-break. ``evals`` consolidates with the SQL
expressions below and ``pairs`` ranks with ``line_rank_key``; both orderings must stay
identical, which is why they live in one module.
"""
from __future__ import annotations
MATE_SCORE = 10_000
BEST_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"
)

src/rukh/data/scoring.pylíneas 1-26 · p1

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.

src/rukh/data/scoring.py
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)

src/rukh/data/scoring.pylíneas 29-57 · p1

line_rank_key reproduce RANK_ORDER_SQL término a término:

  1. Mate a favor del que mueve, primero (sign * mate > 0). En Python va como not mates_for_mover porque el orden es ascendente y False < True.
  2. Profundidad, de mayor a menor: una línea a profundidad 40 es más fiable que una a 12 aunque prometa menos. El -1 imita a DuckDB, que pone los NULL al final.
  3. La puntuación desde el que mueve, de mayor a menor.
  4. 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

src/rukh/data/evals.py
"""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`` and
written as ``data/evals/part-NN.parquet``; an existing part is skipped, so an interrupted run
resumes where it stopped. ``consolidate`` then keeps one row per FEN with the best line and
every 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 BaseConfig
from rukh.data.db import DuckDbConfig, connect
from rukh.data.manifest import FileHash, Manifest
from rukh.data.scoring import BEST_LINE_DOC, MATE_SCORE, RANK_ORDER_SQL, SCORE_WHITE_SQL
from rukh.data.uci import sha256_file
from 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)

src/rukh/data/evals.pylíneas 1-36 · p1

remote_pattern usa hf://, el esquema de Hugging Face que DuckDB lee como un fichero local, con lecturas parciales.

src/rukh/data/evals.py
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"

src/rukh/data/evals.pylíneas 39-63 · p1

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.

src/rukh/data/evals.py
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, True

src/rukh/data/evals.pylíneas 66-86 · p1

Esta 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.

src/rukh/data/evals.py
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,
}

src/rukh/data/evals.pylíneas 89-148 · p1

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.

src/rukh/data/evals.py
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 manifest

src/rukh/data/evals.pylíneas 151-183 · p1

files_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/unit/test_positions.py
"""Tests for rukh.data.positions: fen4 normalization, phase labels and deduplication."""
from __future__ import annotations
import shutil
from pathlib import Path
import chess
import polars as pl
import pytest
from typer.testing import CliRunner
from rukh.cli import app
from 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"

tests/unit/test_positions.pylíneas 1-54 · p1

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.

tests/unit/test_positions.py
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 + 7

tests/unit/test_positions.pylíneas 57-99 · p1

test_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.

tests/unit/test_positions.py
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/unit/test_positions.pylíneas 102-108 · p1

tests/unit/test_scoring.py
"""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 None

tests/unit/test_scoring.pylíneas 1-28 · p1

Los 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.

tests/unit/test_scoring.py
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")

tests/unit/test_scoring.pylíneas 31-55 · p1

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/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 chess
import polars as pl
import pytest
from rukh.data import evals as evals_module
from 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.fixture
def 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
)

tests/unit/test_evals.pylíneas 1-155 · p1

// 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.