// 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.
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
"""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; theuniversal 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 argparseimport ioimport osimport platformimport shutilimport statimport sysimport tarfileimport urllib.requestimport zipfilefrom 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",}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.
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()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.
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 == ""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.
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)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.
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())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.
uv run python scripts/get_stockfish.pySalida real en la máquina de referencia (RTX 5090, 2026-09-18):
$ uv run python scripts/get_stockfish.pydownloading https://github.com/official-stockfish/Stockfish/releases/download/sf_19/stockfish-windows-x86-64-universal.zipreceived 68.6 MBinstalled <repo>\tools\stockfish\stockfish.exe// FontaneríaCódigo completo: tests/unit/test_get_stockfish.py
"""Tests for scripts/get_stockfish.py: archive member selection."""
from __future__ import annotations
import importlib.utilimport ioimport tarfileimport zipfilefrom pathlib import Pathfrom 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")engine.py: encontrar el motor y comprobar que es Stockfish
"""Stockfish: locate the binary and play a short sanity game at a limited ``UCI_Elo``."""
from __future__ import annotations
import osimport randomimport shutilfrom pathlib import Path
import chessimport chess.enginefrom 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``)."""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.
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 | NoneEl 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.
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 NoneEl 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.
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})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».
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, )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.
uv run rukh engine check --elo 1400 --plies 40Salida 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 40path <repo>\tools\stockfish\stockfish.exename Stockfish 19uci_elo_min 1320uci_elo_max 3190elo 1400plies_played 35result 1-0nodes_hint 40736plies_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.
"""Local MLflow tracking: SQLite store under ``mlruns/`` and a run helper."""
from __future__ import annotations
import subprocessimport sysfrom collections.abc import Iterator, Mappingfrom contextlib import contextmanagerfrom 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()}"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.
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 flatMLflow 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.
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 NoneAquí 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.
@contextmanagerdef 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 runEs 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.
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))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.
uv run rukh mlflow ui# abre http://127.0.0.1:5000 sobre sqlite:///<repo>/mlruns/mlflow.dbenv.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.
"""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 pathsfrom rukh.engine import find_stockfishfrom 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: strtorch, 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.
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, gpuSe 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.
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()), )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.
uv run rukh infoSalida real en la máquina de referencia (RTX 5090, 2026-09-18):
$ uv run rukh infopython 3.12.11platform Windows-11-AMD64torch 2.11.0+cu128cuda Truegpu NVIDIA GeForce RTX 5090stockfish <repo>\tools\stockfish\stockfish.exemlflow_uri sqlite:///<repo>/mlruns/mlflow.dbdata_dir <repo>\datamlruns_dir <repo>\mlrunsSi 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?
RUKH_STOCKFISH=no-existe.exe uv run rukh info$env:RUKH_STOCKFISH = 'no-existe.exe'; uv run rukh infoRemove-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 for rukh.engine: Stockfish detection and the engine check."""
from __future__ import annotations
import jsonfrom pathlib import Path
import pytestfrom typer.testing import CliRunner
from rukh import enginefrom rukh.cli import appfrom 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.unitEs 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.
@unitdef 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
@unitdef 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()
@unitdef 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
@unitdef 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"
@unitdef 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()
@unitdef 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.outputEl 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.
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
@unitdef 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()
@unitdef test_engine_error_is_a_value_error() -> None: assert issubclass(EngineError, ValueError)
@unitdef 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_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.
@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"] > 0Los 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 for rukh.env and the `rukh info` command."""
from __future__ import annotations
import jsonfrom pathlib import Path
import pytestfrom typer.testing import CliRunner
from rukh import pathsfrom rukh.cli import appfrom rukh.env import EnvReport, collect
pytestmark = pytest.mark.unitdef 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 NoneEl 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.
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_rootLos 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.
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.outputset(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 for rukh.tracking: local MLflow store and run helper."""
from __future__ import annotations
import reimport shutilimport sysfrom pathlib import Path
import mlflowimport pytest
from rukh import __version__from rukh.tracking import flatten, git_sha, start_run, tracking_uri, ui_command
pytestmark = pytest.mark.unitdef 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"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.
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"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.
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.outputSe 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.