rukh · lab

// M0 · lección 02

El juez y el cuaderno: Stockfish y MLflow

Descargar Stockfish y verificarlo por UCI, el informe de entorno que dice qué tiene la máquina antes de lanzar nada, y el registro local de MLflow donde queda cada ejecución con su configuración y su SHA de git. Con los tests que prueban un binario externo sin necesitar el binario.

  • tiempo de trabajo150 min
  • además, ejecución sin supervisión+ 5 min de GPU y red
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a construir

Las dos piezas sin las que ningún número de este curso significaría nada: el oráculoOráculoLo que decide si una salida del modelo estuvo bien. En ajedrez es Stockfish: para cualquier jugada dice si es legal y cómo de buena, en milisegundos y gratis. Casi ningún dominio tiene uno así, y la primera pregunta de cualquier proyecto de IA es cuál es su oráculo, cuánto cuesta preguntarle y cuánto se parece su juicio al que de verdad importa. que dice si una jugada fue legal y cómo de buena, y el cuaderno donde queda anotado con qué código y qué configuración salió cada medida. Son cuatro ficheros; al terminar, rukh info funciona y rukh engine check juega una partida contra Stockfish con la fuerza limitada.

Van antes que cualquier entrenamiento porque, sin ellas, acabas con un modelo y ninguna forma de decir si es mejor que el anterior.

Traer el motor: scripts/get_stockfish.py

Stockfish no es una dependencia de Python: es un ejecutable. El script lo descarga de las releases oficiales de GitHub y lo deja en tools/stockfish/, que está en el .gitignore.

El script es fontanería de descarga y extracción: puedes saltarte el pliegue en la primera lectura, pero el comando de justo debajo lo ejecuta, así que tiene que estar en disco. Si no lo tecleas, tráelo: git checkout p0 -- scripts/get_stockfish.py.

// Fontaneríascripts/get_stockfish.py: elegir el binario de la plataforma, descargarlo y extraerlo
scripts/get_stockfish.py
"""Download Stockfish 19 into ``tools/stockfish/`` (gitignored). Idempotent.
Usage: ``uv run python scripts/get_stockfish.py [--force]``
Windows gets ``stockfish-windows-x86-64-universal.zip`` (sf_19 ships no separate avx2 build; the
universal binary picks the best code path at runtime). Linux gets
``stockfish-ubuntu-x86-64-avx2.tar``. The executable ends up as ``tools/stockfish/stockfish.exe``
or ``tools/stockfish/stockfish``.
"""
from __future__ import annotations
import argparse
import io
import os
import platform
import shutil
import stat
import sys
import tarfile
import urllib.request
import zipfile
from pathlib import Path
RELEASE = "sf_19"
BASE_URL = f"https://github.com/official-stockfish/Stockfish/releases/download/{RELEASE}"
ASSETS = {
"Windows": "stockfish-windows-x86-64-universal.zip",
"Linux": "stockfish-ubuntu-x86-64-avx2.tar",
}

scripts/get_stockfish.pylíneas 1-30 · p0

Todo con la biblioteca estándar (urllib.request, zipfile, tarfile): un script que se ejecuta una vez por máquina no merece una dependencia.

La versión está clavada en RELEASE = "sf_19". Stockfish gana decenas de Elo por versión, así que la misma escalera de UCI_Elo medida con sf_19 y con sf_20 no da lo mismo: un juez que cambia de versión entre dos medidas convierte la comparación en un misterio.

scripts/get_stockfish.py
def repo_root() -> Path:
home = os.environ.get("RUKH_HOME")
return Path(home).expanduser().resolve() if home else Path(__file__).resolve().parents[1]
def target_path(dest: Path) -> Path:
return dest / ("stockfish.exe" if platform.system() == "Windows" else "stockfish")
def download(url: str) -> bytes:
print(f"downloading {url}")
request = urllib.request.Request(url, headers={"User-Agent": "rukh/get_stockfish"})
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()

scripts/get_stockfish.pylíneas 31-46 · p0

repo_root() repite la lógica de paths.root() en vez de importarla: un script que prepara el entorno no debería depender del paquete que ese entorno instala. El User-Agent explícito está porque GitHub rechaza peticiones sin él.

scripts/get_stockfish.py
def is_executable_member(name: str) -> bool:
"""True for the engine binary inside the release archive, never for a ``src/`` file.
Windows archives ship ``stockfish-<variant>.exe``; the others ship a plain binary with no
extension. Both archives also contain the sources under ``src/``, which must be skipped.
"""
path = Path(name)
if "src" in path.parts or not path.name.startswith("stockfish"):
return False
if platform.system() == "Windows":
return path.suffix == ".exe"
return path.suffix == ""

scripts/get_stockfish.pylíneas 47-60 · p0

El archivo de Stockfish trae el ejecutable y las fuentes, y dentro de src/ hay un stockfish.cpp que un filtro por prefijo a secas se lleva. En Linux, además, el ejecutable no tiene extensión y stockfish.cpp sí: de ahí las dos ramas. Sin esta función, rukh engine check falla con un error de UCI porque intenta lanzar un fichero de C++ como si fuera un motor.

scripts/get_stockfish.py
def extract_executable(asset: str, payload: bytes, target: Path) -> None:
"""Pull the single ``stockfish*`` executable out of the archive into ``target``."""
target.parent.mkdir(parents=True, exist_ok=True)
if asset.endswith(".zip"):
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
names = [
n for n in archive.namelist() if not n.endswith("/") and is_executable_member(n)
]
if not names:
raise SystemExit(f"no stockfish executable inside {asset}")
with archive.open(names[0]) as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
else:
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
members = [
m for m in archive.getmembers() if m.isfile() and is_executable_member(m.name)
]
if not members:
raise SystemExit(f"no stockfish executable inside {asset}")
src = archive.extractfile(members[0])
if src is None:
raise SystemExit(f"cannot read {members[0].name} from {asset}")
with src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
if platform.system() != "Windows":
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

scripts/get_stockfish.pylíneas 61-88 · p0

El archivo se descomprime en memoria (io.BytesIO(payload)) y se copia solo el miembro que interesa. En Linux hay que poner el bit de ejecución a mano, porque ni zipfile ni tarfile conservan permisos al copiar un miembro a un fichero nuevo; sin esa última línea, popen_uci falla con un PermissionError que no dice nada.

scripts/get_stockfish.py
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--force", action="store_true", help="Re-download even if present.")
parser.add_argument(
"--dest",
type=Path,
default=repo_root() / "tools" / "stockfish",
help="Destination directory (default: <repo>/tools/stockfish).",
)
args = parser.parse_args(argv)
system = platform.system()
asset = ASSETS.get(system)
if asset is None:
print(f"unsupported platform {system!r}; download Stockfish manually", file=sys.stderr)
return 2
target = target_path(args.dest)
if target.is_file() and not args.force:
print(f"already present: {target}")
return 0
payload = download(f"{BASE_URL}/{asset}")
print(f"received {len(payload) / 1e6:.1f} MB")
extract_executable(asset, payload, target)
print(f"installed {target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

scripts/get_stockfish.pylíneas 89-121 · p0

main(argv=None) recibe los argumentos como parámetro para que el test pueda ejecutar el script entero en proceso, sin subproceso ni red. Es idempotente: si el binario ya está, no descarga, así que se puede poner en cualquier script de arranque.

Terminal
uv run python scripts/get_stockfish.py

Salida real en la máquina de referencia (RTX 5090, 2026-09-18):

$ uv run python scripts/get_stockfish.py
downloading https://github.com/official-stockfish/Stockfish/releases/download/sf_19/stockfish-windows-x86-64-universal.zip
received 68.6 MB
installed <repo>\tools\stockfish\stockfish.exe
// FontaneríaCódigo completo: tests/unit/test_get_stockfish.py
tests/unit/test_get_stockfish.py
"""Tests for scripts/get_stockfish.py: archive member selection."""
from __future__ import annotations
import importlib.util
import io
import tarfile
import zipfile
from pathlib import Path
from types import ModuleType
import pytest
pytestmark = pytest.mark.unit
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "get_stockfish.py"
@pytest.fixture(scope="module")
def script() -> ModuleType:
spec = importlib.util.spec_from_file_location("get_stockfish", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _zip(entries: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, payload in entries.items():
archive.writestr(name, payload)
return buffer.getvalue()
def _tar(entries: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, payload in entries.items():
info = tarfile.TarInfo(name)
info.size = len(payload)
archive.addfile(info, io.BytesIO(payload))
return buffer.getvalue()
def test_windows_zip_picks_the_exe_not_the_sources(
script: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(script.platform, "system", lambda: "Windows")
payload = _zip(
{
"stockfish/src/stockfish.cpp": b"// source",
"stockfish/src/stockfish.h": b"// header",
"stockfish/stockfish-windows-x86-64-universal.exe": b"MZ-binary",
}
)
target = tmp_path / "stockfish.exe"
script.extract_executable("stockfish-windows-x86-64-universal.zip", payload, target)
assert target.read_bytes() == b"MZ-binary"
def test_linux_tar_picks_the_plain_binary_not_the_sources(
script: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(script.platform, "system", lambda: "Linux")
payload = _tar(
{
"stockfish/src/stockfish.cpp": b"// source",
"stockfish/src/stockfish": b"// source dir file without extension",
"stockfish/stockfish-ubuntu-x86-64-avx2": b"ELF-binary",
}
)
target = tmp_path / "stockfish"
script.extract_executable("stockfish-ubuntu-x86-64-avx2.tar", payload, target)
assert target.read_bytes() == b"ELF-binary"
def test_archive_with_only_sources_fails(
script: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(script.platform, "system", lambda: "Windows")
payload = _zip({"stockfish/src/stockfish.cpp": b"// source"})
with pytest.raises(SystemExit, match="no stockfish executable"):
script.extract_executable("stockfish.zip", payload, tmp_path / "stockfish.exe")

tests/unit/test_get_stockfish.pylíneas 1-84 · p0

engine.py: encontrar el motor y comprobar que es Stockfish

src/rukh/engine.py
"""Stockfish: locate the binary and play a short sanity game at a limited ``UCI_Elo``."""
from __future__ import annotations
import os
import random
import shutil
from pathlib import Path
import chess
import chess.engine
from pydantic import BaseModel, ConfigDict
from rukh import paths
MOVE_TIME_SECONDS = 0.05
class EngineNotFound(RuntimeError):
"""No Stockfish binary could be located."""
class EngineError(ValueError):
"""The engine started but does not behave like Stockfish (no usable ``UCI_Elo``)."""

src/rukh/engine.pylíneas 1-24 · p0

Dos excepciones con padres distintos: EngineNotFound hereda de RuntimeError (falta algo en la máquina, se arregla instalando) y EngineError de ValueError (lo que hay no vale, se arregla cambiando la configuración).

MOVE_TIME_SECONDS = 0.05 es una constante del módulo porque la evaluación de M2 la reutiliza. El tiempo por jugada es parte de la fuerza del motor; repetirlo como número mágico en dos sitios es una forma silenciosa de medir con dos jueces distintos.

src/rukh/engine.py
class EngineCheckResult(BaseModel):
"""Outcome of ``engine_check``: what engine answered and how the test game went."""
model_config = ConfigDict(extra="forbid")
path: str
name: str
uci_elo_min: int
uci_elo_max: int
elo: int
plies_played: int
result: str
nodes_hint: int | None

src/rukh/engine.pylíneas 25-39 · p0

El resultado es un modelo pydantic y no un diccionario: --json sale de model_dump_json() y la CLI calcula el ancho de columna recorriendo model_fields.

src/rukh/engine.py
def find_stockfish() -> Path | None:
"""Locate Stockfish: ``RUKH_STOCKFISH``, then ``tools/stockfish/``, then ``PATH``.
When ``RUKH_STOCKFISH`` is set it wins outright and must point at an existing file;
otherwise ``EngineNotFound`` is raised instead of silently falling through.
"""
env = os.environ.get("RUKH_STOCKFISH")
if env:
candidate = Path(env)
if not candidate.is_file():
raise EngineNotFound(f"RUKH_STOCKFISH={env} is not a file")
return candidate
tools = paths.tools_dir() / "stockfish"
for pattern in ("stockfish*.exe", "stockfish"):
for candidate in sorted(tools.glob(pattern)):
if candidate.is_file():
return candidate
which = shutil.which("stockfish")
return Path(which) if which else None

src/rukh/engine.pylíneas 40-60 · p0

El orden de búsqueda es parte del contrato: la variable de entorno gana, después el binario del repositorio y al final el del sistema. Si el motor del sistema ganara al del repositorio, dos ordenadores medirían con dos jueces distintos.

Si RUKH_STOCKFISH está puesta y no apunta a un fichero, la función lanza en vez de seguir buscando. Devolver None trataría una errata como “no hay motor”, y acabarías midiendo con el Stockfish del sistema creyendo que usas el que elegiste.

sorted(...) porque glob no garantiza orden, y con stockfish-avx2.exe y stockfish.exe en la misma carpeta querrías siempre el mismo.

src/rukh/engine.py
def engine_check(elo: int = 1400, plies: int = 40, seed: int = 0) -> EngineCheckResult:
"""Open Stockfish, limit it to ``elo`` and play ``plies`` half-moves against a random mover.
Stockfish plays White, the seeded random player plays Black. Raises ``EngineNotFound`` when
no binary is available, ``EngineError`` when the binary does not expose ``UCI_Elo`` and
``ValueError`` when ``elo`` is outside the engine's range.
"""
path = find_stockfish()
if path is None:
raise EngineNotFound(
"Stockfish not found: run `uv run python scripts/get_stockfish.py` "
"or set RUKH_STOCKFISH"
)
rng = random.Random(seed)
board = chess.Board()
nodes_hint: int | None = None
plies_played = 0
with chess.engine.SimpleEngine.popen_uci(str(path)) as engine:
name = str(engine.id.get("name", "unknown"))
try:
option = engine.options["UCI_Elo"]
uci_elo_min, uci_elo_max = int(option.min), int(option.max)
except (
KeyError,
TypeError,
chess.engine.EngineError,
chess.engine.EngineTerminatedError,
) as exc:
raise EngineError(f"{path} ({name}) does not expose UCI_Elo; is it Stockfish?") from exc
if not uci_elo_min <= elo <= uci_elo_max:
raise ValueError(f"UCI_Elo {elo} outside [{uci_elo_min}, {uci_elo_max}]")
engine.configure({"UCI_LimitStrength": True, "UCI_Elo": elo})

src/rukh/engine.pylíneas 61-96 · p0

Aquí están los dos números que sostienen toda la evaluación del curso. uci_elo_min y uci_elo_max se leen de la tabla de opciones que el motor declara al arrancar, por el protocolo UCIUCIDos cosas con el mismo nombre. La notación UCI escribe una jugada como casilla de origen, casilla de destino y promoción opcional (e7e8q): no depende del contexto, por eso es la tokenización por defecto. El protocolo UCI es la forma en que hablamos con Stockfish desde python-chess.; en sf_19 salen 1320 y 3190. Es el rango en el que se le puede pedir a Stockfish que juegue peor de lo que sabe, y con él se estima el EloElo estimadoFuerza del modelo obtenida ajustando la fórmula logística del Elo a los resultados de partidas contra Stockfish limitado a varios niveles. No es un Elo de la FIDE ni de Lichess: es el número que mejor explica esos resultados, y solo significa algo acompañado de su intervalo de confianza y de la lista de rivales. de cada modelo en M2. Al leerlo en vez de suponerlo, la escalera de rivales queda calibrada contra el motor que de verdad tienes.

Las cuatro excepciones del except son las cuatro formas en que falla preguntar por UCI_Elo a algo que no es Stockfish: sin esa opción, KeyError; declarada sin rango, TypeError; UCI mal hablado, EngineError; un ejecutable que no habla UCI, EngineTerminatedError. Para quien llama, las cuatro significan «esto no es Stockfish».

src/rukh/engine.py
while plies_played < plies and not board.is_game_over(claim_draw=True):
if board.turn == chess.WHITE:
played = engine.play(
board, chess.engine.Limit(time=MOVE_TIME_SECONDS), info=chess.engine.INFO_BASIC
)
if played.move is None:
break
move = played.move
nodes = played.info.get("nodes")
if isinstance(nodes, int):
nodes_hint = nodes
else:
move = rng.choice(list(board.legal_moves))
board.push(move)
plies_played += 1
return EngineCheckResult(
path=str(path),
name=name,
uci_elo_min=uci_elo_min,
uci_elo_max=uci_elo_max,
elo=elo,
plies_played=plies_played,
result=board.result(claim_draw=True),
nodes_hint=nodes_hint,
)

src/rukh/engine.pylíneas 97-123 · p0

La partida de prueba enfrenta a Stockfish, con blancas, con un jugador aleatorio con semilla. No mide nada, pero recorre el camino completo: abrir el motor, configurarlo, pedirle jugadas, aplicarlas, detectar el final. Si algo de eso está roto, lo sabes aquí y no a mitad de una evaluación de 160 partidas.

claim_draw=True va en los dos sitios, is_game_over y result. Incluye las tablas que hay que reclamar (triple repetición, cincuenta jugadas); si el bucle parara por repetición y el resultado se leyera sin el argumento, la partida saldría como *, sin resultado.

random.Random(seed) es un generador propio: la misma semilla da la misma partida y no altera el estado aleatorio del resto del proceso.

La partida se mide en 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., medias jugadas: 1. e4 e5 son dos. --plies 40 es un tope; plies_played dice cuántas se jugaron de verdad.

Terminal
uv run rukh engine check --elo 1400 --plies 40

Salida real en la máquina de referencia (RTX 5090, 2026-09-18); <repo> es la carpeta donde hayas clonado rukh:

$ uv run rukh engine check --elo 1400 --plies 40
path <repo>\tools\stockfish\stockfish.exe
name Stockfish 19
uci_elo_min 1320
uci_elo_max 3190
elo 1400
plies_played 35
result 1-0
nodes_hint 40736

plies_played, result y nodes_hint cambian de una ejecución a otra, porque la búsqueda está limitada por tiempo (50 ms por jugada) y el reloj no es determinista. uci_elo_min y uci_elo_max solo cambian con la versión del motor.

tracking.py: el cuaderno de laboratorio

El cuaderno es MLflowMLflowRegistro de experimentos: cada entrenamiento guarda su configuración, sus métricas por paso y sus artefactos en una base SQLite local (rukh mlflow ui la abre en el navegador). Las model cards del curso se generan desde ahí para que ningún número se escriba a mano., que guarda cada ejecución con su configuración, sus métricas por paso y sus artefactos. Aquí se usa en su modo más simple: una base SQLite en mlruns/mlflow.db, sin servidor.

src/rukh/tracking.py
"""Local MLflow tracking: SQLite store under ``mlruns/`` and a run helper."""
from __future__ import annotations
import subprocess
import sys
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from typing import Any
from rukh import __version__, paths
EXPERIMENT = "rukh"
def tracking_uri(create: bool = True) -> str:
"""SQLite tracking URI ``sqlite:///<MLRUNS_DIR>/mlflow.db``; creates the directory if asked."""
mlruns = paths.mlruns_dir()
if create:
mlruns.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{(mlruns / 'mlflow.db').as_posix()}"

src/rukh/tracking.pylíneas 1-21 · p0

Un solo experimento (EXPERIMENT = "rukh") para todo el proyecto; los modelos y etapas se distinguen por el nombre de la ejecución y los tags. Un experimento por módulo parece más ordenado, pero comparar rukh-small con rukh-medium-v4-dpo obligaría a cruzar dos, y la tabla única compara justo eso.

create=False existe para rukh info, que informa de dónde está la base sin crear el directorio: un diagnóstico no debe modificar el disco. as_posix() porque SQLite quiere barras hacia delante también en Windows.

src/rukh/tracking.py
def flatten(config: Mapping[str, Any], prefix: str = "") -> dict[str, str]:
"""Flatten nested mappings into ``a.b=value`` string params (lists become their repr)."""
flat: dict[str, str] = {}
for key, value in config.items():
name = f"{prefix}{key}"
if isinstance(value, Mapping):
flat.update(flatten(value, prefix=f"{name}."))
else:
flat[name] = str(value)
return flat

src/rukh/tracking.pylíneas 22-33 · p0

MLflow solo acepta parámetros planos, clave y cadena, y las configuraciones del proyecto son YAML anidado (model.layers, optim.lr). Una lista se guarda como su repr ("[1, 2]"): no es bonito, pero es fiel.

Se registra toda la configuración, no una selección de hiperparámetros «importantes»: seis semanas después, la diferencia entre dos ejecuciones siempre está en el campo que no parecía importante. En una aplicación con LLMs pasa lo mismo con la versión del prompt, del modelo o del índice de un RAG: si no quedan anotadas junto a cada evaluación, no sabrás qué cambió entre la que iba bien y la que no.

src/rukh/tracking.py
def git_sha() -> str | None:
"""Full SHA of the source tree (``paths.package_root()``), or None when it is not a git repo.
The source tree is used on purpose: ``RUKH_HOME`` may point at a data directory that is
not a checkout, and the tag must identify the code that produced the run.
"""
try:
out = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=paths.package_root(),
capture_output=True,
text=True,
check=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
sha = out.stdout.strip()
return sha or None

src/rukh/tracking.pylíneas 34-54 · p0

Aquí se cobra la separación de las dos raíces de paths.py: el SHA se pide en package_root(), porque RUKH_HOME puede apuntar a un disco de datos que no es un repositorio, y el tag tiene que identificar el código que produjo la ejecución.

Sin git (OSError) o fuera de un repositorio (SubprocessError, que cubre también el timeout), devuelve None en vez de fallar: una ejecución sin SHA es información, y exigir git para entrenar sería absurdo.

src/rukh/tracking.py
@contextmanager
def start_run(
name: str,
config: Mapping[str, Any],
tags: Mapping[str, str] | None = None,
) -> Iterator[Any]:
"""Start an MLflow run in the local store with ``config`` logged as flattened params.
Tags always include ``rukh_version`` and, when the project lives in a git repo, ``git_sha``.
Yields the active ``mlflow.ActiveRun``.
"""
import mlflow
mlflow.set_tracking_uri(tracking_uri())
mlflow.set_experiment(EXPERIMENT)
run_tags: dict[str, str] = {"rukh_version": __version__}
sha = git_sha()
if sha:
run_tags["git_sha"] = sha
if tags:
run_tags.update(tags)
with mlflow.start_run(run_name=name, tags=run_tags) as run:
params = flatten(config)
if params:
mlflow.log_params(params)
yield run

src/rukh/tracking.pylíneas 55-82 · p0

Es el único sitio por el que se abre una ejecución en todo el proyecto, y su firma obliga a pasar la configuración: no hay forma de entrenar algo sin registrar con qué se entrenó. Las model cardsModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde run.json y results.json, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. rukh publish cards las regenera todas desde la tabla y sube solo el README. de M6 se generan leyendo de aquí, así que “ningún número se escribe a mano” empieza en esta función.

import mlflow va dentro, como en la CLI: rukh info importa este módulo solo para construir una cadena. Los tags de quien llama se aplican después de los dos automáticos (rukh_version y git_sha), así que pueden sobrescribirlos.

src/rukh/tracking.py
def ui_command(host: str = "127.0.0.1", port: int = 5000) -> list[str]:
"""Argv for ``mlflow ui`` against the local store, run through the current interpreter."""
return [
sys.executable,
"-m",
"mlflow",
"ui",
"--backend-store-uri",
tracking_uri(),
"--host",
host,
"--port",
str(port),
]
def serve_ui(host: str = "127.0.0.1", port: int = 5000) -> int:
"""Run ``mlflow ui`` in the foreground and return its exit code."""
return subprocess.call(ui_command(host=host, port=port))

src/rukh/tracking.pylíneas 83-103 · p0

El comando se construye en una función que solo devuelve una lista, y el efecto (lanzarlo) está en otra de una línea: así el test comprueba el comando sin arrancar un servidor. sys.executable -m mlflow y no mlflow a secas, porque el mlflow del PATH puede ser de otro entorno.

Terminal
uv run rukh mlflow ui
# abre http://127.0.0.1:5000 sobre sqlite:///<repo>/mlruns/mlflow.db

env.py: qué tiene esta máquina, antes de lanzar nada

env.py es lo que hay detrás de rukh info: una foto de la máquina con qué Python, qué torch y si ve la GPU, dónde está Stockfish, cuál es la base de MLflow y dónde están los datos.

Existe porque los peores fallos de un entorno no dan error: un torch de CPU en una máquina con GPU, o un Stockfish distinto del que crees. rukh info los enseña en un segundo, y es lo primero que se ejecuta en una máquina nueva o después de un uv sync.

src/rukh/env.py
"""Environment report: what Python, torch, GPU and tools this machine has."""
from __future__ import annotations
import platform
from pydantic import BaseModel, ConfigDict
from rukh import paths
from rukh.engine import find_stockfish
from rukh.tracking import tracking_uri
class EnvReport(BaseModel):
"""Snapshot of the runtime environment as seen by ``rukh info``."""
model_config = ConfigDict(extra="forbid")
python: str
platform: str
torch: str | None
cuda: bool
gpu: str | None
stockfish: str | None
mlflow_uri: str
data_dir: str
mlruns_dir: str

src/rukh/env.pylíneas 1-27 · p0

torch, gpu y stockfish son str | None porque pueden faltar legítimamente; cuda es bool porque la respuesta a “¿hay CUDA?” siempre es sí o no.

src/rukh/env.py
def _torch_info() -> tuple[str | None, bool, str | None]:
"""Return (torch version, cuda available, gpu name); all empty if torch is missing."""
try:
import torch
except ImportError:
return None, False, None
cuda = bool(torch.cuda.is_available())
gpu = torch.cuda.get_device_name(0) if cuda else None
return torch.__version__, cuda, gpu

src/rukh/env.pylíneas 28-38 · p0

Se captura ImportError y nada más. Un torch que falta es información; un torch que revienta al importarse es un problema real que debe verse con su traza. Capturar Exception convertiría una instalación corrupta en un torch: null silencioso. get_device_name(0) solo se llama si hay CUDA, porque sin GPU lanza.

src/rukh/env.py
def collect() -> EnvReport:
"""Collect the environment report. Never raises because torch or Stockfish are absent."""
torch_version, cuda, gpu = _torch_info()
stockfish = find_stockfish()
return EnvReport(
python=platform.python_version(),
platform=f"{platform.system()}-{platform.release()}-{platform.machine()}",
torch=torch_version,
cuda=cuda,
gpu=gpu,
stockfish=str(stockfish) if stockfish else None,
mlflow_uri=tracking_uri(create=False),
data_dir=str(paths.data_dir()),
mlruns_dir=str(paths.mlruns_dir()),
)

src/rukh/env.pylíneas 39-55 · p0

collect pregunta a cada pieza y lo ordena en un EnvReport. No falla porque falte algo: sin torch o sin Stockfish el campo sale vacío y el resto del informe sigue siendo útil. Y usa tracking_uri(create=False): mirar la máquina no debe dejar ficheros en ella.

Terminal
uv run rukh info

Salida real en la máquina de referencia (RTX 5090, 2026-09-18):

$ uv run rukh info
python 3.12.11
platform Windows-11-AMD64
torch 2.11.0+cu128
cuda True
gpu NVIDIA GeForce RTX 5090
stockfish <repo>\tools\stockfish\stockfish.exe
mlflow_uri sqlite:///<repo>/mlruns/mlflow.db
data_dir <repo>\data
mlruns_dir <repo>\mlruns

Si cuda sale False, lo primero que hay que mirar es que el extra instalado sea cu128 y no cpu; el sufijo de torch lo dice. Si stockfish sale null, falta el script de descarga.

// Ejercicio 01Comprueba tu entorno

Ejecuta uv run rukh info --json y responde: ¿qué versión de torch tienes, con qué índice, y qué ruta de Stockfish detecta? Si stockfish es null, ejecuta el script de descarga y vuelve a mirar. Después pon RUKH_STOCKFISH a una ruta que no existe y vuelve a ejecutar rukh info: ¿qué pasa, y por qué no es lo mismo que no tener motor?

Terminal window
RUKH_STOCKFISH=no-existe.exe uv run rukh info
Terminal window
$env:RUKH_STOCKFISH = 'no-existe.exe'; uv run rukh info
Remove-Item Env:RUKH_STOCKFISH
// SoluciónVer la solución

La clave torch termina en +cu128 cuando el extra correcto está instalado (+cpu en la CI). stockfish se resuelve en el orden RUKH_STOCKFISH → tools/stockfish/ → PATH.

Con RUKH_STOCKFISH apuntando a un fichero que no existe, rukh info no dice stockfish: null: se cae con una traza de Python que termina en rukh.engine.EngineNotFound: RUKH_STOCKFISH=... is not a file. find_stockfish lanza la excepción en ese caso, collect no la captura (su docstring promete no fallar cuando falta el motor, no cuando está mal configurado) e info, a diferencia de engine check, tampoco la convierte en un mensaje limpio, así que la traza sale entera. Fea, pero el comportamiento es el correcto: una ruta explícita y equivocada es un error del usuario, y tratarla como “no hay motor” acabaría midiendo con el Stockfish del sistema mientras tú crees que usas otro.

Los tests: probar un binario externo sin el binario

Estos tests usan técnicas avanzadas de pytest (un motor de mentira con __enter__/__exit__, monkeypatch sobre popen_uci). Puedes leerlos en diagonal la primera vez: lo que importa es que prueban el binario externo sin tenerlo. El «Ejecútalo» del final los pasa todos, pero solo si están en disco: los que no teclees, tráelos antes con git checkout p0 -- tests/unit/test_engine.py tests/unit/test_env.py tests/unit/test_tracking.py (quita de la lista los que sí hayas tecleado).

// Teststests/unit/test_engine.py: un _FakeEngine que se hace pasar por Stockfish, y los dos tests con el motor real
tests/unit/test_engine.py
"""Tests for rukh.engine: Stockfish detection and the engine check."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from typer.testing import CliRunner
from rukh import engine
from rukh.cli import app
from rukh.engine import (
EngineCheckResult,
EngineError,
EngineNotFound,
engine_check,
find_stockfish,
)
# The two live tests carry only `engine`, so `-m unit` never needs the Stockfish binary.
unit = pytest.mark.unit

tests/unit/test_engine.pylíneas 1-22 · p0

Es el único fichero del repositorio sin pytestmark al nivel del módulo: mezcla tests unit con tests engine (que necesitan el binario), así que la marca va test por test, con un alias corto.

tests/unit/test_engine.py
@unit
def test_find_stockfish_honours_env(rukh_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
fake = rukh_home / "sf.exe"
fake.write_bytes(b"")
monkeypatch.setenv("RUKH_STOCKFISH", str(fake))
assert find_stockfish() == fake
@unit
def test_find_stockfish_env_missing_file_raises(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
missing = rukh_home / "missing.exe"
monkeypatch.setenv("RUKH_STOCKFISH", str(missing))
monkeypatch.setattr(engine.shutil, "which", lambda _name: None)
with pytest.raises(EngineNotFound, match=r"RUKH_STOCKFISH=.*missing\.exe is not a file"):
find_stockfish()
@unit
def test_find_stockfish_looks_in_tools(rukh_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RUKH_STOCKFISH", raising=False)
monkeypatch.setattr(engine.shutil, "which", lambda _name: None)
assert find_stockfish() is None
tools = rukh_home / "tools" / "stockfish"
tools.mkdir(parents=True)
binary = tools / "stockfish.exe"
binary.write_bytes(b"")
assert find_stockfish() == binary
@unit
def test_find_stockfish_falls_back_to_path(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("RUKH_STOCKFISH", raising=False)
monkeypatch.setattr(engine.shutil, "which", lambda _name: str(rukh_home / "on-path"))
assert find_stockfish() == rukh_home / "on-path"
@unit
def test_engine_check_without_binary_raises(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("RUKH_STOCKFISH", raising=False)
monkeypatch.setattr(engine.shutil, "which", lambda _name: None)
with pytest.raises(EngineNotFound):
engine_check()
@unit
def test_cli_engine_check_without_binary_fails_cleanly(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("RUKH_STOCKFISH", raising=False)
monkeypatch.setattr(engine.shutil, "which", lambda _name: None)
result = CliRunner().invoke(app, ["engine", "check"])
assert result.exit_code == 1
assert "Stockfish" in result.output

tests/unit/test_engine.pylíneas 23-83 · p0

El truco está en dos líneas que se repiten. Un fichero vacío hace de binario (fake.write_bytes(b"")), porque find_stockfish solo comprueba que exista. Y monkeypatch.setattr(engine.shutil, "which", lambda _name: None) corta la última rama de búsqueda: sin ella, el test pasaría en tu máquina y fallaría en la de quien tenga Stockfish en el PATH.

Se parchea engine.shutil, el shutil tal como lo ve el módulo. Si mañana engine.py importara which directamente, el parche fallaría ruidosamente en vez de dejar de aplicarse sin avisar. Los cuatro primeros tests recorren las cuatro ramas de la función en orden.

tests/unit/test_engine.py
class _FakeEngine:
"""Stand-in for ``SimpleEngine``: speaks UCI but is not Stockfish (no ``UCI_Elo``)."""
def __init__(self) -> None:
self.id = {"name": "not-stockfish"}
self.options: dict[str, object] = {}
def __enter__(self) -> _FakeEngine:
return self
def __exit__(self, *exc: object) -> None:
return None
def _use_fake_engine(rukh_home: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
binary = rukh_home / "not-stockfish.exe"
binary.write_bytes(b"")
monkeypatch.setenv("RUKH_STOCKFISH", str(binary))
monkeypatch.setattr(
engine.chess.engine.SimpleEngine, "popen_uci", staticmethod(lambda *_a, **_k: _FakeEngine())
)
return binary
@unit
def test_engine_check_without_uci_elo_raises_engine_error(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_use_fake_engine(rukh_home, monkeypatch)
with pytest.raises(EngineError, match="does not expose UCI_Elo; is it Stockfish?"):
engine_check()
@unit
def test_engine_error_is_a_value_error() -> None:
assert issubclass(EngineError, ValueError)
@unit
def test_cli_engine_check_without_uci_elo_fails_cleanly(
rukh_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_use_fake_engine(rukh_home, monkeypatch)
result = CliRunner().invoke(app, ["engine", "check"])
assert result.exit_code == 1
assert "does not expose UCI_Elo" in result.output
assert "Traceback" not in result.output

tests/unit/test_engine.pylíneas 84-132 · p0

_FakeEngine es un motor de mentira mínimo: un id, un diccionario de opciones vacío y el protocolo de gestor de contexto. Cubre el caso “arranca pero no es Stockfish”, el que produce el error más confuso en la vida real.

El assert que más vale, y el que casi nadie escribe, es assert "Traceback" not in result.output: comprueba que el error llega como mensaje y no como traza. Con él, “la CLI falla limpiamente” deja de ser una intención.

test_engine_error_is_a_value_error protege la jerarquía de excepciones, que es parte del contrato: quien capture ValueError recoge las dos formas de “esto no vale”, y un refactor que la cambie le rompería sin que nada más lo note.

tests/unit/test_engine.py
@pytest.mark.engine
@pytest.mark.skipif(find_stockfish() is None, reason="Stockfish binary not found")
def test_engine_check_plays_a_short_game() -> None:
result = engine_check(elo=1400, plies=10, seed=0)
assert isinstance(result, EngineCheckResult)
assert result.uci_elo_min == 1320
assert result.uci_elo_max == 3190
assert result.elo == 1400
assert 0 < result.plies_played <= 10
assert result.name.lower().startswith("stockfish")
assert result.result in {"1-0", "0-1", "1/2-1/2", "*"}
@pytest.mark.engine
@pytest.mark.skipif(find_stockfish() is None, reason="Stockfish binary not found")
def test_cli_engine_check_json() -> None:
args = ["engine", "check", "--elo", "1400", "--plies", "6", "--json"]
result = CliRunner().invoke(app, args)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["uci_elo_min"] == 1320
assert payload["plies_played"] > 0

tests/unit/test_engine.pylíneas 133-156 · p0

Los dos tests que necesitan el motor de verdad lo dicen dos veces: la marca engine elige la suite, y el skipif hace que, sin binario, salgan “saltados” en vez de “rotos”.

Comprueban los números 1320 y 3190: son los que avisarían de que la versión de Stockfish ha cambiado, y con ella toda la escalera de Elo de M2. Lo demás se comprueba por forma (plies_played en un rango, result en un conjunto), porque una partida limitada por tiempo no es determinista.

// Teststests/unit/test_env.py: simular una máquina sin torch parcheando builtins.__import__
tests/unit/test_env.py
"""Tests for rukh.env and the `rukh info` command."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from typer.testing import CliRunner
from rukh import paths
from rukh.cli import app
from rukh.env import EnvReport, collect
pytestmark = pytest.mark.unit

tests/unit/test_env.pylíneas 1-15 · p0

tests/unit/test_env.py
def test_collect_returns_report_with_python_and_mlflow(rukh_home: Path) -> None:
report = collect()
assert isinstance(report, EnvReport)
assert report.python.startswith("3.12")
assert report.mlflow_uri.startswith("sqlite:///")
assert report.data_dir == str(rukh_home / "data")
assert report.mlruns_dir == str(rukh_home / "mlruns")
assert isinstance(report.cuda, bool)
def test_collect_survives_missing_torch(rukh_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
real_import = builtins.__import__
def fake_import(name: str, *args: object, **kwargs: object) -> object:
if name == "torch" or name.startswith("torch."):
raise ImportError("torch is not installed")
return real_import(name, *args, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(builtins, "__import__", fake_import)
report = collect()
assert report.torch is None
assert report.cuda is False
assert report.gpu is None

tests/unit/test_env.pylíneas 16-42 · p0

El segundo test enseña una técnica que conviene conocer: para probar el camino “no hay torch” en una máquina que sí lo tiene, se sustituye builtins.__import__ por una función que lanza ImportError solo para torch. Es intrusivo, pero está acotado a un test y es lo único que ejercita la rama que verá alguien el primer día en una máquina sin torch.

El primer test comprueba startswith("3.12"), la tercera vez que 3.12 aparece en el repositorio: un intérprete distinto lo rompe, que es lo que se quiere.

tests/unit/test_env.py
def test_paths_follow_rukh_home(rukh_home: Path) -> None:
assert paths.root() == rukh_home
assert paths.data_dir() == rukh_home / "data"
assert paths.mlruns_dir() == rukh_home / "mlruns"
assert paths.tools_dir() == rukh_home / "tools"
def test_paths_default_to_repo_root(monkeypatch: pytest.MonkeyPatch, repo_root: Path) -> None:
monkeypatch.delenv("RUKH_HOME", raising=False)
assert paths.root() == repo_root

tests/unit/test_env.pylíneas 43-54 · p0

Los tests de paths.py están aquí porque prueban lo mismo que el informe: que RUKH_HOME mueve el proyecto entero. El segundo comprueba que la variable se lee en cada llamada; si paths.py la hubiera cacheado al importarse, delenv no tendría efecto.

tests/unit/test_env.py
def test_info_json_is_parseable(rukh_home: Path) -> None:
result = CliRunner().invoke(app, ["info", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert set(EnvReport.model_fields) <= set(payload)
assert payload["python"].startswith("3.12")
assert payload["mlflow_uri"].startswith("sqlite:///")
def test_info_human_output_mentions_python(rukh_home: Path) -> None:
result = CliRunner().invoke(app, ["info"])
assert result.exit_code == 0, result.output
assert "python" in result.output
assert "3.12" in result.output

tests/unit/test_env.pylíneas 55-70 · p0

set(EnvReport.model_fields) <= set(payload) comprueba que el JSON trae todos los campos sin enumerarlos. Y assert result.exit_code == 0, result.output lleva la salida como mensaje: si falla, pytest enseña el error de la CLI en vez de assert 1 == 0.

// Teststests/unit/test_tracking.py: MLflow contra un directorio temporal
tests/unit/test_tracking.py
"""Tests for rukh.tracking: local MLflow store and run helper."""
from __future__ import annotations
import re
import shutil
import sys
from pathlib import Path
import mlflow
import pytest
from rukh import __version__
from rukh.tracking import flatten, git_sha, start_run, tracking_uri, ui_command
pytestmark = pytest.mark.unit

tests/unit/test_tracking.pylíneas 1-16 · p0

tests/unit/test_tracking.py
def test_tracking_uri_points_to_sqlite_under_mlruns(rukh_home: Path) -> None:
uri = tracking_uri(create=False)
assert uri == f"sqlite:///{(rukh_home / 'mlruns' / 'mlflow.db').as_posix()}"
assert not (rukh_home / "mlruns").exists()
tracking_uri()
assert (rukh_home / "mlruns").is_dir()
def test_flatten_nested_dict() -> None:
assert flatten({"a": {"b": 1, "c": {"d": "x"}}, "e": [1, 2]}) == {
"a.b": "1",
"a.c.d": "x",
"e": "[1, 2]",
}
def test_start_run_logs_flattened_params_and_tags(rukh_home: Path) -> None:
with start_run("t", {"a": {"b": 1}}, tags={"purpose": "test"}) as run:
run_id = run.info.run_id
assert (rukh_home / "mlruns" / "mlflow.db").is_file()
mlflow.set_tracking_uri(tracking_uri())
runs = mlflow.search_runs(experiment_names=["rukh"], output_format="list")
found = [r for r in runs if r.info.run_id == run_id]
assert len(found) == 1
logged = found[0]
assert logged.info.run_name == "t"
assert logged.data.params["a.b"] == "1"
assert logged.data.tags["rukh_version"] == __version__
assert logged.data.tags["purpose"] == "test"
assert logged.info.status == "FINISHED"

tests/unit/test_tracking.pylíneas 17-49 · p0

El primero comprueba las dos mitades de create: con create=False el directorio no existe; con la llamada por defecto, sí.

El tercero es el más caro y el que más cubre: abre una ejecución contra una base SQLite de verdad (en el directorio temporal) y la lee con MLflow para comprobar parámetros, tags y estado. El FINISHED detecta un gestor de contexto que no cierra bien: una ejecución que se queda en RUNNING para siempre no falla nada, solo ensucia el cuaderno.

tests/unit/test_tracking.py
def test_git_sha_is_the_source_tree_sha_even_with_rukh_home_elsewhere(
rukh_home: Path, repo_root: Path
) -> None:
if shutil.which("git") is None:
pytest.skip("git is not installed")
if not (repo_root / ".git").exists():
pytest.skip("source tree is not a git checkout")
assert not (rukh_home / ".git").exists()
sha = git_sha()
assert sha is not None and re.fullmatch(r"[0-9a-f]{40}", sha), sha
def test_ui_command_uses_the_current_interpreter() -> None:
argv = ui_command(host="0.0.0.0", port=5001)
assert argv[:4] == [sys.executable, "-m", "mlflow", "ui"]
assert argv[argv.index("--host") + 1] == "0.0.0.0"
assert argv[argv.index("--port") + 1] == "5001"

tests/unit/test_tracking.pylíneas 50-68 · p0

El nombre del primero lo dice todo: con RUKH_HOME en un directorio que no es un repositorio, git_sha() sigue devolviendo el SHA del árbol de fuentes. Sin git o sin .git, la propiedad no se puede comprobar, y saltar es mejor que fallar por el motivo equivocado.

tests/unit/test_tracking.py
def test_cli_has_mlflow_ui_command() -> None:
from typer.testing import CliRunner
from rukh.cli import app
result = CliRunner().invoke(app, ["mlflow", "ui", "--help"])
assert result.exit_code == 0, result.output
assert "--port" in result.output

tests/unit/test_tracking.pylíneas 69-78 · p0

Se invoca --help y no el comando, porque rukh mlflow ui bloquea hasta que lo mates. Lo que el comando hace ya lo cubre test_ui_command_uses_the_current_interpreter.

// Ejercicio 02Registra tu primera ejecución

Desde uv run python, abre una ejecución a mano y míralo en la interfaz:

from rukh.tracking import start_run
with start_run("hola", {"modelo": {"capas": 4}, "lr": 3e-4}) as run:
print(run.info.run_id)

Después uv run rukh mlflow ui y busca la ejecución. ¿Qué parámetros tiene y con qué nombres? ¿Qué tags? ¿Y qué pasa si el bloque with lanza una excepción a mitad?

// SoluciónVer la solución

Los parámetros son modelo.capas = "4" y lr = "0.0003": aplanados con punto y convertidos a cadena, que es lo único que MLflow acepta. Los tags incluyen rukh_version (0.0.1) y git_sha con el commit de tu checkout.

Si el bloque lanza, mlflow.start_run marca la ejecución como FAILED al salir del gestor de contexto y la excepción se propaga. Eso es justo lo que se quiere: una ejecución interrumpida se distingue de una terminada en la interfaz, y por eso el test comprueba status == "FINISHED" en el camino bueno.

Qué has aprendido

El proyecto ya tiene juez y cuaderno. Stockfish está en disco, verificado por UCI, y el rango en el que se le puede pedir que juegue peor se lee del motor en vez de suponerse: 1320-3190. Cada ejecución que se abra a partir de ahora deja su configuración completa y el SHA del código que la produjo en una base local, y las model cards de M6 se generarán desde ahí.

Cómo se mide: uv run rukh info muestra una ruta en stockfish y, si tienes GPU con el extra cu128, cuda: True (sin GPU, con el extra cpu, sale False y M0 funciona igual); uv run rukh engine check devuelve uci_elo_min 1320 y uci_elo_max 3190; uv run pytest -m unit -q pasa (los dos tests con marca engine quedan fuera del filtro) y uv run pytest -m engine -q pasa con el binario instalado.

Lo siguiente son los datos: seis mil millones de partidas de las que hay que traerse unos pocos gigabytes, con cada filtro justificado y un manifiesto que permita comprobar después qué entró de verdad.