rukh · lab

// M4 · lección 04

Estilo y texto PGN: los dos cortes del corpus

data/style.py entero —los cortes por primera jugada con campos tipados en vez de SQL en un YAML— y data/pgn_text.py —las mismas partidas escritas como las lee un humano, con el SAN reconstruido desde la raíz y un prompt que nunca acaba en espacio—, más los tests que sostienen las dos afirmaciones y las cuatro configuraciones que los usan.

  • tiempo de trabajo130 min
  • además, ejecución sin supervisión+ 20 min de GPU y red
  • nivel medio
  • actualizado el23 de septiembre de 2026

Lección 4 de 10 del módulo «Fine-tuning e instrucción». Viene de «LoRA escrita a mano» y sigue en «QLoRA con transformers».

Qué vas a construir

Dos módulos de datos que cortan el mismo corpus de manera que lo que sale sea comparable. src/rukh/data/style.py saca las partidas que empiezan por una jugada concreta, que es lo que un adaptador de estilo aprende a imitar. src/rukh/data/pgn_text.py escribe esas mismas partidas como las leería un humano (1. e4 e5 2. Nf3) para que un modelo general pueda afinarse con ellas. En cada uno hay una decisión de la que depende que la medición posterior signifique algo: el suelo de Elo del corte de estilo y el espacio que el prompt de PGN no lleva al final.

style.py: cortar sin escribir SQL en un YAML

src/rukh/data/style.py
"""Style slices of the corpus: the games one LoRA adapter is meant to sound like.
An adapter is only interesting if you can *see* it work, and the cheapest visible axis in this
data is the opening. A model whose adapter was trained on games that begin ``1. e4`` opens with
``1. e4``; swap the adapter and it opens ``1. d4``. That is two megabytes changing the behaviour
of a 115 M model, in front of the reader, with a number behind it (the share of first moves) and
a cost that can be measured (the Elo of the adapted model against the base).
The slice is defined by **typed fields**, not by a SQL string in the YAML. A predicate written in
a config file is an injection waiting to happen and, worse, it is undocumented: nobody reading
`configs/` would know that `eco` is a column or what shape it has. `first_move` and `eco_prefix`
say what they select, and the manifest records them, so a published adapter can state exactly
which games made it.
"""
from __future__ import annotations
import logging
from pathlib import Path
from pydantic import Field, model_validator
from rukh.config import BaseConfig
from rukh.data.db import DuckDbConfig, connect
from rukh.data.manifest import FileHash, Manifest
from rukh.data.uci import month_dirs, sha256_file
from rukh.paths import resolve
log = logging.getLogger(__name__)
__all__ = ["StyleConfig", "StyleSpec", "build_styles", "predicate_of"]

src/rukh/data/style.pylíneas 1-31 · p4

El eje de estilo es la apertura porque es el más barato de ver: cargas un adaptador y el modelo abre 1. e4; cargas el otro y abre 1. d4. Hay un número detrás (la proporción de primeras jugadas) y un coste medible (el Elo del modelo adaptado frente a la base).

Y el corte se define con campos tipados, no con una cadena de SQL en el YAML. Un predicado en una configuración es una inyección esperando a ocurrir, y además nadie que lea configs/ sabría qué columnas hay ni qué forma tienen.

src/rukh/data/style.py
class StyleSpec(BaseConfig):
"""One adapter's worth of games."""
name: str = Field(min_length=1, pattern=r"^[a-z0-9-]+$")
first_move: str | None = None
"""UCI of White's first move, e.g. ``e2e4``. Matched on the prefix of the ``uci`` column."""
eco_prefix: list[str] = []
"""ECO codes the game may have, by prefix: ``["B2", "B3"]`` is most of the Sicilian."""
min_elo: int | None = None
"""Both players at least this strong, so a style adapter is not also a weakness adapter."""
n_games: int = Field(default=200_000, ge=1)
@model_validator(mode="after")
def _check(self) -> StyleSpec:
if self.first_move is None and not self.eco_prefix:
raise ValueError(f"style {self.name!r} selects nothing: give first_move or eco_prefix")
if self.first_move is not None and len(self.first_move) not in (4, 5):
raise ValueError(f"first_move {self.first_move!r} is not a UCI move")
return self
class StyleConfig(BaseConfig):
"""Where the games come from and which slices to cut out of them."""
sources: list[str] = ["data/uci"]
out_dir: str = "data/style"
seed: int = 42
styles: list[StyleSpec] = []
duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)
@model_validator(mode="after")
def _check(self) -> StyleConfig:
names = [style.name for style in self.styles]
if len(names) != len(set(names)):
raise ValueError(f"duplicate style names: {names}")
return self

src/rukh/data/style.pylíneas 34-69 · p4

El validador impide el error que cuesta una hora: un estilo que no selecciona nada, cuyo WHERE vacío o revienta el SQL o selecciona el corpus entero. Y min_elo separa estilo de debilidad: si las partidas de 1. d4 vinieran de jugadores más flojos, «el adaptador cambió la apertura» y «el adaptador lo empeoró» serían la misma medición. El pattern del nombre está porque el nombre acaba siendo una ruta de fichero, y ../../etc no es SQL pero sí es una ruta.

src/rukh/data/style.py
def _sql_string(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def predicate_of(spec: StyleSpec) -> str:
"""The WHERE clause of one style, built from its typed fields and nothing else."""
parts: list[str] = []
if spec.first_move is not None:
# `uci` is space-separated moves, so the first one is everything up to the first space.
parts.append(f"split_part(uci, ' ', 1) = {_sql_string(spec.first_move)}")
if spec.eco_prefix:
matches = " OR ".join(
f"eco LIKE {_sql_string(prefix + '%')}" for prefix in sorted(spec.eco_prefix)
)
parts.append(f"({matches})")
if spec.min_elo is not None:
parts.append(f"white_elo >= {int(spec.min_elo)} AND black_elo >= {int(spec.min_elo)}")
return " AND ".join(parts)

src/rukh/data/style.pylíneas 72-89 · p4

Este es todo el lenguaje de consulta que la configuración puede expresar: tres condiciones unidas por AND, y ninguna manera de escribir una cuarta desde un fichero. Se compara el primer token (split_part) en vez de un prefijo con LIKE, que podría colar jugadas que empiezan igual. Y _sql_string, que duplica las comillas, es la red de seguridad; la defensa es que los campos sean tipados.

src/rukh/data/style.py
def build_styles(cfg: StyleConfig) -> dict[str, Manifest]:
"""Write one parquet per style under ``out_dir`` and return their manifests."""
if not cfg.styles:
raise ValueError("no styles configured")
months: list[tuple[str, Path]] = []
for source in cfg.sources:
found = month_dirs(resolve(source))
if not found:
raise FileNotFoundError(f"no games under {resolve(source)}")
months.extend(found)
files = ", ".join(f"'{path.as_posix()}'" for _, path in months)
out_dir = resolve(cfg.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
manifests: dict[str, Manifest] = {}
con = connect(cfg.duckdb)
try:
for spec in cfg.styles:
target = out_dir / f"{spec.name}.parquet"
where = predicate_of(spec)
log.info("style %s: %s", spec.name, where)
con.execute(
f"""
COPY (
SELECT * FROM read_parquet([{files}], hive_partitioning = false,
union_by_name = true)
WHERE {where}
ORDER BY hash(game_id) + {cfg.seed}
LIMIT {int(spec.n_games)}
) TO '{target.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD)
"""
)
row = con.execute(
f"SELECT count(*) FROM read_parquet('{target.as_posix()}')"
).fetchone()
count = int(row[0]) if row else 0
manifests[spec.name] = Manifest(
dataset="Lichess/standard-chess-games",
months=sorted({month for month, _ in months}),
filters={
"style": spec.model_dump(mode="json"),
"sources": list(cfg.sources),
"predicate": where,
"seed": cfg.seed,
},
counts={spec.name: count},
files=[
FileHash(
path=target.name,
sha256=sha256_file(target),
bytes=target.stat().st_size,
)
],
)
(out_dir / f"{spec.name}.manifest.json").write_text(
manifests[spec.name].model_dump_json(indent=2) + "\n", "utf-8"
)
finally:
con.close()
return manifests

src/rukh/data/style.pylíneas 92-151 · p4

ORDER BY hash(game_id) + seed con LIMIT es el mismo muestreo determinista de elo_bins; un LIMIT a secas daría las partidas del principio del mes. Y el manifiesto guarda el predicado ya construido y la semilla, así que una card puede decir «entrenado con las 200 000 partidas de data/uci que cumplen split_part(uci, ' ', 1) = 'e2e4' AND white_elo >= 1800 AND black_elo >= 1800», una afirmación comprobable, en vez de «entrenado con partidas de 1. e4».

Los tests del corte

Los tests fijan el contrato del corte; léelos al teclearlos, no hacen falta para seguir la lección. El «Ejecútalo» de la sección los corre, así que si no los tecleas, tráelos: git checkout p4 -- tests/unit/test_style.py.

// Teststests/unit/test_style.py: un mes de seis partidas y un test por decisión del predicado
tests/unit/test_style.py
"""Tests for rukh.data.style: the game slices a LoRA adapter is trained to sound like."""
from __future__ import annotations
from pathlib import Path
import polars as pl
import pydantic
import pytest
from rukh.data.style import StyleConfig, StyleSpec, build_styles, predicate_of
pytestmark = pytest.mark.unit
def _stage(rukh_home: Path) -> None:
"""One month with three openings and two rating levels, all replayable."""
rows = [
("e2e4 e7e5 g1f3", "C40", 2000),
("e2e4 c7c5 g1f3", "B20", 2100),
("e2e4 e7e6 d2d4", "C00", 1850),
("d2d4 d7d5 c2c4", "D06", 2000),
("d2d4 g8f6 c2c4", "E00", 1900),
("c2c4 e7e5", "A20", 2200),
]
frame = pl.DataFrame(
{
"game_id": list(range(len(rows))),
"uci": [uci for uci, _, _ in rows],
"n_plies": [len(uci.split()) for uci, _, _ in rows],
"white_elo": [elo for _, _, elo in rows],
"black_elo": [elo for _, _, elo in rows],
"result": ["1-0"] * len(rows),
"time_control": ["300+0"] * len(rows),
"eco": [eco for _, eco, _ in rows],
"month": ["2025-01"] * len(rows),
}
)
target = rukh_home / "data" / "uci" / "year=2025" / "month=01" / "games.parquet"
target.parent.mkdir(parents=True)
frame.write_parquet(target.as_posix())
def _read(rukh_home: Path, name: str) -> pl.DataFrame:
return pl.read_parquet((rukh_home / "data" / "style" / f"{name}.parquet").as_posix())

tests/unit/test_style.pylíneas 1-45 · p4

El montaje es un mes de seis partidas escritas a mano, con el mismo esquema que el corpus real. Seis filas bastan porque lo que se comprueba son predicados, no estadística.

tests/unit/test_style.py
def test_the_first_move_slice_only_holds_games_that_open_with_it(rukh_home: Path) -> None:
"""The axis the demo shows: swap the adapter, the model opens differently."""
_stage(rukh_home)
manifests = build_styles(
StyleConfig(styles=[StyleSpec(name="e4", first_move="e2e4", n_games=10)])
)
frame = _read(rukh_home, "e4")
assert frame.height == 3
assert all(uci.startswith("e2e4 ") for uci in frame["uci"])
assert manifests["e4"].counts == {"e4": 3}
def test_a_first_move_prefix_does_not_leak_into_another_move(rukh_home: Path) -> None:
"""`d2d4` must not also collect `d2d4` games that began `d2d3`; the match is on the token."""
_stage(rukh_home)
build_styles(StyleConfig(styles=[StyleSpec(name="d4", first_move="d2d4", n_games=10)]))
frame = _read(rukh_home, "d4")
assert sorted(frame["eco"].to_list()) == ["D06", "E00"]
def test_eco_prefixes_select_a_family_of_openings(rukh_home: Path) -> None:
_stage(rukh_home)
build_styles(StyleConfig(styles=[StyleSpec(name="sicilian", eco_prefix=["B2"], n_games=10)]))
assert _read(rukh_home, "sicilian")["eco"].to_list() == ["B20"]
def test_a_rating_floor_keeps_a_style_adapter_from_being_a_weakness_adapter(
rukh_home: Path,
) -> None:
_stage(rukh_home)
build_styles(
StyleConfig(styles=[StyleSpec(name="e4", first_move="e2e4", min_elo=1900, n_games=10)])
)
assert _read(rukh_home, "e4").height == 2 # the 1850 game is out
def test_the_cap_limits_how_many_games_a_style_gets(rukh_home: Path) -> None:
_stage(rukh_home)
manifests = build_styles(
StyleConfig(styles=[StyleSpec(name="e4", first_move="e2e4", n_games=2)])
)
assert manifests["e4"].counts == {"e4": 2}
def test_the_manifest_records_the_predicate_a_published_adapter_has_to_state(
rukh_home: Path,
) -> None:
_stage(rukh_home)
manifests = build_styles(
StyleConfig(styles=[StyleSpec(name="e4", first_move="e2e4", n_games=10)])
)
filters = manifests["e4"].filters
assert "split_part(uci, ' ', 1) = 'e2e4'" in str(filters["predicate"])
assert filters["style"]["first_move"] == "e2e4" # type: ignore[index]

tests/unit/test_style.pylíneas 48-101 · p4

Un test por decisión del predicado. El del prefijo afirma sobre los ECO, no sobre el recuento, porque así se ve qué partidas entraron.

tests/unit/test_style.py
def test_several_styles_are_cut_in_one_pass(rukh_home: Path) -> None:
_stage(rukh_home)
manifests = build_styles(
StyleConfig(
styles=[
StyleSpec(name="e4", first_move="e2e4", n_games=10),
StyleSpec(name="d4", first_move="d2d4", n_games=10),
]
)
)
assert set(manifests) == {"e4", "d4"}
assert (rukh_home / "data" / "style" / "d4.manifest.json").is_file()
def test_the_slice_is_deterministic_for_a_seed(rukh_home: Path) -> None:
_stage(rukh_home)
spec = StyleSpec(name="e4", first_move="e2e4", n_games=2)
build_styles(StyleConfig(styles=[spec], seed=1))
first = _read(rukh_home, "e4")
build_styles(StyleConfig(styles=[spec], seed=1))
assert _read(rukh_home, "e4").equals(first)
def test_a_style_that_selects_nothing_is_refused() -> None:
with pytest.raises(pydantic.ValidationError, match="selects nothing"):
StyleSpec(name="empty")
def test_a_first_move_that_is_not_a_uci_move_is_refused() -> None:
with pytest.raises(pydantic.ValidationError, match="not a UCI move"):
StyleSpec(name="bad", first_move="e4")
def test_duplicate_style_names_are_refused() -> None:
with pytest.raises(pydantic.ValidationError, match="duplicate style names"):
StyleConfig(
styles=[
StyleSpec(name="e4", first_move="e2e4"),
StyleSpec(name="e4", first_move="d2d4"),
]
)
def test_a_quote_in_a_field_cannot_break_out_of_the_predicate() -> None:
"""Typed fields are the reason the config cannot carry SQL; the escaping is the backstop."""
spec = StyleSpec(name="odd", eco_prefix=["B'2"])
assert "'B''2%'" in predicate_of(spec)
def test_no_styles_is_refused(rukh_home: Path) -> None:
with pytest.raises(ValueError, match="no styles configured"):
build_styles(StyleConfig())

tests/unit/test_style.pylíneas 104-155 · p4

Los siete que cierran los bordes incluyen el de la comilla: nadie escribirá eco_prefix=["B'2"], pero si alguien lo hace, el literal no se rompe.

Las configuraciones de los cortes

Las configuraciones se pueden saltar en la primera lectura; la única clave con decisión dentro se explica debajo. Los comandos del final de la lección las leen, así que si las saltas, trae las dos que no trajiste al empezar: git checkout p4 -- configs/data/pipeline-style.yaml configs/data/pipeline-style-e4.yaml.

// FontaneríaLas tres configuraciones de datos del estilo
configs/data/pipeline-style.yaml
# The two style slices the LoRA adapters of M4 are trained on, and their token packs.
#
# The axis is White's first move, for one reason above the others: it is *visible*. A reader can
# load the demo, pick an adapter and watch the model open `1. e4`, then pick the other and watch
# it open `1. d4`. An adapter that shifted something real but invisible would be a better piece of
# machine learning and a worse piece of teaching.
#
# `min_elo: 1800` on both sides so a style adapter is not secretly a weakness adapter: if the
# `1. d4` games came from weaker players, "the adapter changed the opening" and "the adapter made
# it worse" would be the same measurement.
duckdb:
memory_limit: 32GB
threads: 0
style:
sources: [data/uci]
out_dir: data/style
seed: 42
styles:
- name: e4
first_move: e2e4
min_elo: 1800
n_games: 200000
- name: d4
first_move: d2d4
min_elo: 1800
n_games: 200000

configs/data/pipeline-style.yamllíneas 1-26 · p4

Después hacen falta dos packs de tokens, uno por corte:

configs/data/pipeline-style-e4.yaml
# Token pack of the `e4` style slice, for `configs/train/lora-e4.yaml`.
#
# The validation split is the project's own -- the first 100 000 games of 2025-02 -- and its
# remainder does **not** join training: an adapter trained on one opening has to be measured
# against the same held-out games as everything else, and topping it up with a general corpus
# would dilute the very slice that defines the style.
tokenize:
uci_dir: data/uci
out_dir: data/tokens-style-e4
train_months: []
extra_train_parquets: ['data/style/e4.parquet']
val_month: '2025-02'
val_games: 100000
val_remainder_trains: false
stats_games: data/uci/year=2025/month=01/games.parquet
max_len: 200

configs/data/pipeline-style-e4.yamllíneas 1-16 · p4

configs/data/pipeline-style-d4.yaml
# Token pack of the `d4` style slice, for `configs/train/lora-d4.yaml`.

configs/data/pipeline-style-d4.yamllínea 1 · p4

configs/data/pipeline-style-d4.yaml
out_dir: data/tokens-style-d4
train_months: []
extra_train_parquets: ['data/style/d4.parquet']

configs/data/pipeline-style-d4.yamllíneas 9-11 · p4

val_remainder_trains: false está en los dos por la misma razón que en el corpus plano: rellenar el entrenamiento con un corpus general diluiría la rebanada que define el estilo. Se ejecutan al final de la lección.

pgn_text.py: las mismas partidas, en otro idioma

src/rukh/data/pgn_text.py
"""Games as plain text, the way a general language model would be shown them.
The comparison M4 owes the reader is: a 115 M decoder built for this one job against a 0.6 B
general model fine-tuned on the same games. For that to mean anything the *data* has to be the
same games and the *metric* has to be the same harness; only the representation may differ, and
here it differs as much as it can. The decoder reads a fixed vocabulary where one token is one
legal move. Qwen reads the movetext a human would paste into a chess site -- `1. e4 e5 2. Nf3` --
tokenised into whatever pieces its BPE happens to cut it into, with nothing stopping it from
writing `Nf9` or a sentence.
Two consequences are the lesson, not an accident of the setup:
- The move numbers and the SAN spelling are tokens the model has to spend capacity predicting,
and they carry no chess. The specialist never sees them.
- SAN is *contextual*: `Nf3` names a different move depending on where the knights are, and a
legal-looking string can be illegal or ambiguous. That is why the evaluation counts moves it
could not parse as its own number rather than folding them into "illegal".
The header keeps the two Elo tags because that is exactly what the decoder's `<wXXXX> <bXXXX>`
tokens are, so the conditioning question can be asked of both models in their own language.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import chess
from pydantic import Field
from rukh.config import BaseConfig
from rukh.data.manifest import FileHash, Manifest
from rukh.data.uci import sha256_file
from rukh.paths import resolve
if TYPE_CHECKING:
from collections.abc import Iterator
log = logging.getLogger(__name__)
TRAIN_FILE = "train.jsonl"
VAL_FILE = "val.jsonl"

src/rukh/data/pgn_text.pylíneas 1-44 · p4

Ten delante las dos consecuencias del docstring al leer la tabla de la lección 9. Los números de jugada y la grafía de SANSANNotación algebraica estándar, la de los libros: Nf3, O-O, exd5+. Es compacta para humanos pero ambigua sin el tablero (hay que saber qué caballo puede ir a f3). Lichess publica las partidas en SAN; Rukh las convierte a UCI con python-chess. son tokens que el modelo gasta capacidad en predecir y no llevan ajedrez dentro. Y SAN es contextual: Nf3 nombra una jugada distinta según dónde estén los caballos, así que la evaluación cuenta aparte las jugadas que no pudo interpretar. La cabecera conserva los dos tags de Elo, para poder hacerles a los dos modelos la pregunta del condicionamiento, cada uno en su idioma.

src/rukh/data/pgn_text.py
class PgnTextConfig(BaseConfig):
"""Which games become text, how many, and how long each one is allowed to be."""
train_games: str = "data/uci/year=2025/month=01/games.parquet"
val_games: str = "data/uci/year=2025/month=02/games.parquet"
out_dir: str = "data/pgn-text"
n_train: int = Field(default=100_000, ge=1)
n_val: int = Field(default=2_000, ge=1)
max_plies: int = Field(default=120, ge=2)
"""Sixty moves a side. Past that the text runs longer than the context is worth spending."""
val_offset: int = Field(default=0, ge=0)
"""Rows to skip in the validation month, so the split can avoid the evaluation pool."""
seed: int = 42

src/rukh/data/pgn_text.pylíneas 47-59 · p4

Las partidas de más de max_plies se cortan en vez de descartarse: un corpus que excluye las largas en silencio enseña partidas cortas. val_offset permite que la validación se salte las filas sobre las que evalúa el harness.

src/rukh/data/pgn_text.py
def movetext(board: chess.Board, max_plies: int | None = None) -> str:
"""The moves played on ``board`` as SAN movetext with numbers: ``1. e4 e5 2. Nf3``.
Replayed from the board's root rather than read off the stack, because SAN depends on the
position it is written in: the same move is ``Nf3`` or ``Ngf3`` depending on where the other
knight is, and only the position at that moment knows which.
"""
replay = board.root()
parts: list[str] = []
moves = list(board.move_stack)
if max_plies is not None:
moves = moves[:max_plies]
for move in moves:
if replay.turn == chess.WHITE:
parts.append(f"{replay.fullmove_number}.")
elif not parts:
parts.append(f"{replay.fullmove_number}...")
parts.append(replay.san(move))
replay.push(move)
return " ".join(parts)
def header(white_elo: int, black_elo: int, fen: str | None = None) -> str:
"""The PGN tags the prompt carries: the two ratings, and a FEN when the game is not a game.
The ratings are here for the same reason the decoder has `<wXXXX> <bXXXX>`: so the
conditioning question can be put to both models in the language each one reads.
"""
tags = [f'[WhiteElo "{white_elo}"]', f'[BlackElo "{black_elo}"]']
if fen is not None:
tags.append(f'[FEN "{fen}"]')
return " ".join(tags)

src/rukh/data/pgn_text.pylíneas 62-93 · p4

movetext vuelve a jugar la partida desde la raíz, porque la misma jugada es Nf3 o Ngf3 según dónde esté el otro caballo, y solo la posición de ese momento lo sabe. El elif not parts cubre una partida que empieza con las negras (desde un FEN), que necesita el marcador 17....

src/rukh/data/pgn_text.py
def prompt_for(board: chess.Board, white_elo: int = 1800, black_elo: int = 1800) -> str:
"""The text a model is asked to continue: the header, the movetext so far, and a space.
A position reached from a FEN (a puzzle) carries the FEN tag, so the model is not asked to
guess a history that never happened.
"""
root = board.root()
fen = None if root.fen() == chess.STARTING_FEN else root.fen()
played = movetext(board)
line = f"{header(white_elo, black_elo, fen)}\n{played}".rstrip()
if board.turn == chess.WHITE:
line = f"{line} {board.fullmove_number}."
elif not played:
# Black to move at the very start of a FEN position needs the "17..." marker; after a
# white move the number is already in the text and the model only has to answer.
line = f"{line} {board.fullmove_number}..."
# Never end on a space. A BPE writes moves as " Nc6" with the space attached, so a prompt
# that already carries it forces the model onto the spelling it almost never saw in training.
return line.rstrip()

src/rukh/data/pgn_text.pylíneas 96-114 · p4

Un puzle lleva su tag [FEN ...], para no pedirle al modelo que continúe una historia que nunca ocurrió. Y, lo que más fácilmente se publica mal: el prompt nunca termina en espacio. Un BPE escribe una jugada como " Nc6", con el espacio pegado delante; un prompt que ya lo lleva obliga al modelo a continuar con un token que casi nunca vio en esa posición, y en la tabla sale un modelo peor cuando lo que es peor es el prompt.

src/rukh/data/pgn_text.py
def game_text(uci: str, white_elo: int, black_elo: int, result: str, max_plies: int) -> str | None:
"""One whole game as the training sample: header, movetext, result. None if it will not replay.
A game that does not replay is dropped rather than repaired: the UCI corpus was validated
with python-chess in P1, so anything that fails here is a bug worth seeing in the counts and
not a row worth guessing at.
"""
board = chess.Board()
try:
for index, token in enumerate(uci.split()):
if index >= max_plies:
break
board.push(chess.Move.from_uci(token))
except (ValueError, AssertionError):
return None
if not board.move_stack:
return None
body = movetext(board)
return f"{header(white_elo, black_elo)}\n{body} {result}"

src/rukh/data/pgn_text.pylíneas 117-135 · p4

Una partida que no se puede volver a jugar se descarta, no se repara: el corpus UCI ya se validó en M1, así que un fallo aquí tiene que verse en los recuentos.

Lo que queda del fichero escribe los JSONL y el manifiesto; es fontanería y se puede saltar en la primera lectura. Los tests de integración lo usan, así que si lo saltas, git diff p4 -- src/rukh/data/pgn_text.py te enseña exactamente las líneas que faltan (las de este pliegue), o trae el fichero entero con git checkout p4 -- src/rukh/data/pgn_text.py, que sustituye lo que llevas tecleado por la versión de referencia.

// FontaneríaEscribir los dos JSONL y el manifiesto con los descartes
src/rukh/data/pgn_text.py
def _rows(path: Path, limit: int, offset: int = 0) -> Iterator[dict[str, object]]:
import polars as pl
frame = (
pl.scan_parquet(path.as_posix())
.select(["uci", "white_elo", "black_elo", "result"])
.slice(offset, limit)
.collect()
)
yield from frame.rows(named=True)
def _write_split(
source: Path, target: Path, limit: int, max_plies: int, offset: int = 0
) -> tuple[int, int]:
"""Write one JSONL split; return ``(written, dropped)``."""
written = dropped = 0
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8", newline="\n") as fh:
for row in _rows(source, limit, offset):
text = game_text(
str(row["uci"]),
int(row["white_elo"] or 0),
int(row["black_elo"] or 0),
str(row["result"]),
max_plies,
)
if text is None:
dropped += 1
continue
fh.write(json.dumps({"text": text}, ensure_ascii=False) + "\n")
written += 1
return written, dropped

src/rukh/data/pgn_text.pylíneas 138-170 · p4

_write_split devuelve las filas escritas y las descartadas, porque un corpus que no dice cuántas perdió por el camino esconde el número que avisaría de un problema aguas arriba. Y newline="\n" va explícito porque en Windows el modo texto escribiría \r\n, y algunos lectores parten mal un JSONL así.

src/rukh/data/pgn_text.py
def build(cfg: PgnTextConfig) -> Manifest:
"""Write ``train.jsonl`` and ``val.jsonl`` plus the manifest that says what went in."""
out_dir = resolve(cfg.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
train_source = resolve(cfg.train_games)
val_source = resolve(cfg.val_games)
for path in (train_source, val_source):
if not path.is_file():
raise FileNotFoundError(f"games not found at {path}")
train_written, train_dropped = _write_split(
train_source, out_dir / TRAIN_FILE, cfg.n_train, cfg.max_plies
)
val_written, val_dropped = _write_split(
val_source, out_dir / VAL_FILE, cfg.n_val, cfg.max_plies, cfg.val_offset
)
log.info("wrote %s train and %s val samples", train_written, val_written)
files = [
FileHash(
path=name, sha256=sha256_file(out_dir / name), bytes=(out_dir / name).stat().st_size
)
for name in (TRAIN_FILE, VAL_FILE)
]
manifest = Manifest(
dataset="rukh-pgn-text",
months=[],
filters={
"train_games": cfg.train_games,
"val_games": cfg.val_games,
"max_plies": cfg.max_plies,
"val_offset": cfg.val_offset,
"dropped": {"train": train_dropped, "val": val_dropped},
},
counts={"train": train_written, "val": val_written},
files=files,
)
(out_dir / "manifest.json").write_text(manifest.model_dump_json(indent=2) + "\n", "utf-8")
return manifest

src/rukh/data/pgn_text.pylíneas 173-209 · p4

Que los dos parquet existan se comprueba antes de escribir nada, y los descartes van en el manifiesto y no en el log: un log se pierde, un manifiesto viaja con los datos.

Los tests del formato

Catorce tests, plegados. El «Ejecútalo» de debajo los corre, así que si no los tecleas, tráelos: git checkout p4 -- tests/unit/test_pgn_text.py.

// Teststests/unit/test_pgn_text.py: Nbd2, el prompt sin espacio final y la integración
tests/unit/test_pgn_text.py
"""Tests for rukh.data.pgn_text: the same games, spelled the way a general LLM reads them."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import chess
import polars as pl
import pytest
from rukh.data.pgn_text import PgnTextConfig, build, game_text, movetext, prompt_for
pytestmark = pytest.mark.unit
RUY_LOPEZ = ["e2e4", "e7e5", "g1f3", "b8c6", "f1b5"]
def _board(moves: list[str]) -> chess.Board:
board = chess.Board()
for uci in moves:
board.push(chess.Move.from_uci(uci))
return board
def test_movetext_is_numbered_san() -> None:
assert movetext(_board(RUY_LOPEZ)) == "1. e4 e5 2. Nf3 Nc6 3. Bb5"
def test_san_is_disambiguated_by_the_position_it_is_written_in() -> None:
"""The whole reason the movetext is replayed instead of read off the move stack.
With both knights able to reach the square, the same move is `Nbd2`, not `Nd2`; only the
position at that moment knows which letter is needed.
"""
board = _board(["g1f3", "d7d5", "d2d4", "g8f6", "b1d2"])
assert movetext(board).endswith("Nbd2")

tests/unit/test_pgn_text.pylíneas 1-38 · p4

La Española de cinco pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. como partida de prueba, y el test que justifica la decisión más cara del módulo: con los dos caballos capaces de llegar a la casilla, la jugada es Nbd2 y no Nd2.

tests/unit/test_pgn_text.py
def test_a_game_carries_its_ratings_and_its_result() -> None:
text = game_text(" ".join(RUY_LOPEZ), 1800, 1900, "1-0", 120)
assert text is not None
assert text.startswith('[WhiteElo "1800"] [BlackElo "1900"]\n')
assert text.endswith(" 1-0")
assert "1. e4 e5" in text
def test_a_game_longer_than_the_cap_is_cut_not_dropped() -> None:
text = game_text(" ".join(RUY_LOPEZ), 1800, 1800, "1-0", 4)
assert text is not None
assert text.rstrip(" 1-0").endswith("Nc6")
def test_an_unplayable_game_is_dropped_rather_than_repaired() -> None:
assert game_text("e2e4 e2e4", 1800, 1800, "1-0", 120) is None
assert game_text("", 1800, 1800, "1-0", 120) is None
def test_the_prompt_never_ends_on_a_space() -> None:
"""A BPE writes a move as " Nc6" with the space attached.
A prompt that already carries the space forces the model onto the spelling it almost never
saw in training, which would show up as a worse model rather than as a worse prompt.
"""
for plies in range(len(RUY_LOPEZ) + 1):
prompt = prompt_for(_board(RUY_LOPEZ[:plies]))
assert prompt == prompt.rstrip()
def test_the_prompt_ends_with_the_move_number_when_white_is_to_move() -> None:
assert prompt_for(_board(RUY_LOPEZ[:4])).endswith("Nc6 3.")
def test_the_prompt_ends_after_the_white_move_when_black_is_to_move() -> None:
assert prompt_for(_board(RUY_LOPEZ)).endswith("3. Bb5")
def test_a_position_from_a_fen_carries_the_fen_and_the_ellipsis() -> None:
"""A puzzle has no history; asking the model to invent one is asking a different question."""
fen = "r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 5 4"
prompt = prompt_for(chess.Board(fen), 2000, 2000)
assert f'[FEN "{fen}"]' in prompt
assert prompt.endswith("4...")
def test_the_start_position_carries_no_fen_tag() -> None:
assert "FEN" not in prompt_for(chess.Board())
def test_the_ratings_asked_for_are_the_ratings_written() -> None:
assert '[WhiteElo "1200"] [BlackElo "2400"]' in prompt_for(chess.Board(), 1200, 2400)

tests/unit/test_pgn_text.pylíneas 41-92 · p4

El test del espacio recorre todos los prefijos de la partida, porque el caso que falla depende de a quién le toque mover y de si ya hay movetext. Con las blancas a mover, el prompt termina en el número (3.); con las negras, en la jugada blanca; desde un FEN, en 4....

tests/unit/test_pgn_text.py
def test_build_writes_both_splits_and_counts_what_it_dropped(
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)
rows = pl.read_parquet(src.as_posix()).height
manifest = build(PgnTextConfig(n_train=rows, n_val=3))
out = rukh_home / "data" / "pgn-text"
train = [json.loads(line) for line in (out / "train.jsonl").read_text("utf-8").splitlines()]
val = [json.loads(line) for line in (out / "val.jsonl").read_text("utf-8").splitlines()]
assert manifest.counts == {"train": len(train), "val": len(val)}
assert len(val) <= 3
assert all(set(sample) == {"text"} for sample in train)
assert all(sample["text"].startswith("[WhiteElo ") for sample in train)
assert manifest.filters["dropped"] == {"train": 0, "val": 0}
def test_the_validation_split_can_skip_the_rows_the_harness_evaluates_on(
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)
plain = build(PgnTextConfig(n_train=2, n_val=2)).counts["val"]
out = rukh_home / "data" / "pgn-text"
first = (out / "val.jsonl").read_text("utf-8")
build(PgnTextConfig(n_train=2, n_val=2, val_offset=2))
assert plain == 2
assert (out / "val.jsonl").read_text("utf-8") != first
def test_missing_games_say_where_they_were_looked_for(rukh_home: Path) -> None:
with pytest.raises(FileNotFoundError, match="games not found"):
build(PgnTextConfig())

tests/unit/test_pgn_text.pylíneas 95-134 · p4

Los tres de integración comprueban la forma del JSONL y que el manifiesto cuadra con lo escrito, que val_offset cambia de verdad lo que sale y que un fichero ausente dice dónde se buscó.

configs/data/pipeline-pgn.yaml
# The same games as PGN text, for the general-model side of the M4 comparison.
#
# Training reads 2025-01 and the harness measures on 2025-02, which is the split every other run
# in the project uses -- so Qwen is trained on the same games the decoder was and evaluated on
# positions neither of them has seen. `val.jsonl` exists for a validation loss and is not what
# the comparison is read from: that is `rukh eval qwen`, on the harness's own positions.
#
# 120 plies covers the great majority of the games whole; the longest few are cut rather than
# dropped, because a corpus that quietly excludes long games is a corpus that teaches short ones.
pgn_text:
train_games: data/uci/year=2025/month=01/games.parquet
val_games: data/uci/year=2025/month=02/games.parquet
out_dir: data/pgn-text
n_train: 100000
n_val: 2000
max_plies: 120
val_offset: 20000
seed: 42

configs/data/pipeline-pgn.yamllíneas 1-18 · p4

El reparto es el de todo el proyecto: Qwen se entrena con las mismas partidas que el decoder (2025-01) y se evalúa sobre posiciones que ninguno de los dos ha visto. La comparación no se lee del val.jsonl sino de rukh eval qwen, sobre las posiciones del propio harness. Cada línea de los JSONL es una partida con esta forma:

[WhiteElo "2015"] [BlackElo "2028"]
1. e4 c6 2. d4 d5 3. exd5 cxd5 4. Bd3 Nf6 5. h3 Nc6 6. c3 e5 ... 1-0

Colgar los dos pasos del pipeline

Engarzar los dos pasos en el pipeline es fontanería y se puede saltar, pero sin ella los dos subcomandos del final no tienen a quién llamar: si la saltas, trae el fichero, git checkout p4 -- src/rukh/data/pipeline.py.

// Fontaneríapipeline.py: los dos pasos nuevos colgados del orquestador
src/rukh/data/pipeline.py
DUCKDB_STEPS = ("positions", "evals", "puzzles", "elo_bins", "style")
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)
style: StyleConfig = Field(default_factory=StyleConfig)
pgn_text: PgnTextConfig = Field(default_factory=PgnTextConfig)
publish: PublishConfig = Field(default_factory=PublishConfig)

src/rukh/data/pipeline.pylíneas 51-72 · p4

Añadir un paso al pipeline son dos líneas: style entra en DUCKDB_STEPS, para heredar la sección duckdb: común, y las dos configuraciones nuevas se cuelgan de PipelineConfig.

Los pares de preferencia recuerdan su partida

Un cambio que solo prepara M5; se puede saltar hasta entonces. Si lo saltas, tráelo antes de empezar M5: git checkout p4 -- src/rukh/data/pairs.py.

// Fontaneríapairs.py: cada par de preferencia gana game_id y ply para M5
src/rukh/data/pairs.py
def build_pairs(evals: Path, min_delta_cp: int) -> pl.DataFrame:
"""One pair per FEN, reading the evaluations parquet batch by batch."""
reader = pq.ParquetFile(evals)
rows: list[dict[str, object]] = []
columns = ["fen", "phase", "pvs", "game_id", "ply"]
for batch in reader.iter_batches(batch_size=BATCH_ROWS, columns=columns):
records = zip(
batch.column("fen").to_pylist(),
batch.column("phase").to_pylist(),
batch.column("pvs").to_pylist(),
batch.column("game_id").to_pylist(),
batch.column("ply").to_pylist(),
strict=True,
)
for fen, ph, pvs, game_id, ply in records:
pair = make_pair(fen, pvs, min_delta_cp)
if pair is not None:
pair["phase"] = ph
# Provenance, and the only way the decoder can use a pair at all: it reads a move
# sequence, not a board, so a pair is useless to it without the prefix that led
# there. ``game_id`` and ``ply`` are what join a pair back to its game (D-070).
pair["game_id"] = game_id
pair["ply"] = ply
rows.append(pair)
schema = {
"fen": pl.String,
"chosen": pl.String,
"rejected": pl.String,
"cp_chosen": pl.Int32,
"cp_rejected": pl.Int32,
"phase": pl.String,
"game_id": pl.String,
"ply": pl.Int32,
}
return pl.DataFrame(rows, schema=schema)

src/rukh/data/pairs.pylíneas 95-129 · p4

Este cambio prepara M5: cada par de preferencia gana game_id y ply. El decoder lee una secuencia de jugadas, no un tablero, así que un par sin el prefijo que llevó hasta esa posición no le sirve de nada.

Los dos subcomandos de la CLI

Cada subcomando de rukh es una cáscara fina sobre la librería: parsear opciones, llamar a una función, imprimir. No hay nada que teclear: cli.py llegó entero en la lección 2, y el pliegue enseña lo que ya tienes.

// Fontaneríacli.py: rukh data style y rukh data pgn-text
src/rukh/cli.py
@data_app.command("pgn-text")
def data_pgn_text(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Render the same games as PGN text, for the general-model comparison of M4."""
from rukh.data.pgn_text import PgnTextConfig, build
from rukh.data.pipeline import load_pipeline
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
cfg = load_pipeline(config).pgn_text if config is not None else PgnTextConfig()
try:
manifest = build(cfg)
except FileNotFoundError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
_echo_manifest(manifest, as_json, cfg.out_dir)
@data_app.command("style")
def data_style(config: PipelineOption = None, as_json: JsonOption = False) -> None:
"""Cut one parquet per style: the games a LoRA adapter is trained to sound like."""
from rukh.data.pipeline import load_pipeline
from rukh.data.style import build_styles
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
cfg = load_pipeline(config).style
try:
manifests = build_styles(cfg)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if as_json:
typer.echo("[" + ", ".join(m.model_dump_json(indent=2) for m in manifests.values()) + "]")
return
typer.echo(f"out_dir: {cfg.out_dir}")
typer.echo("styles:")
for name, manifest in manifests.items():
typer.echo(f" {name}: {manifest.counts[name]:,} games")

src/rukh/cli.pylíneas 300-335 · p4

Los import van dentro de las funciones para que rukh --help responda sin cargar polars, chess ni duckdb. Y el try/except captura solo FileNotFoundError y ValueError: un fallo que nadie previó tiene que salir con su traza, porque «error: algo pasó» no es información.

Qué has aprendido

Dos maneras de cortar el mismo corpus, y en cada una la decisión que hace comparable el resultado. En el corte de estilo, campos tipados en vez de SQL y un suelo de Elo que impide confundir estilo con debilidad. En el texto PGN, el SAN reconstruido desde la raíz y un prompt que no termina en espacio, porque el BPE escribe el espacio pegado a la jugada.

Lo siguiente es el modelo general que lee ese texto. Sigue en «QLoRA con transformers».