rukh · lab

// M6 · lección 06

int8 y el jugador ONNX: un grafo exportado en la escalera

El objeto más pequeño que puede jugar en lugar de un checkpoint, para responder por fin cuánto cuesta el int8 en Elo; los nombres de ejecución estables que hacen que un comando de una lección funcione tanto si descargaste el modelo como si lo entrenaste; y el muestreo de pares que mete el brazo de control de M5 dentro de su configuración.

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

Qué vas a construir

Tres cambios pequeños sin relación aparente, con algo en común: los tres convierten en código lo que hasta ahora era un script suelto o una pregunta abierta.

  • infer/onnx_player.py pone un grafo ONNXONNXFormato abierto para describir el grafo de una red y sus pesos, independiente del framework que la entrenó. Es lo que permite entrenar en PyTorch y ejecutar en el navegador con onnxruntime-web, sin Python ni backend. dentro del mismo jugador que usa un checkpoint, para que una exportación pueda subir a la escalera. La paridadParidadEn cuántas posiciones la jugada elegida por un modelo exportado coincide con la del modelo original. Mide si la exportación está bien hecha, no si el modelo exportado juega igual de bien: un 95,40 % (int8 de small, M2) es un modelo que juega otra cosa una vez de cada veinte, y su Elo hay que medirlo jugando. de M2 dice que el int8 elige otra jugada una de cada veinte veces; esto permite medir cuánto cuesta eso en fuerza.
  • resolve_run en train/checkpoint.py hace que checkpoints/medium-v4/best.pt encuentre también el checkpoints/medium-v4-20260919-174623/best.pt de un entrenamiento propio.
  • limit_pairs en train/dpo.py mete en la configuración el muestreo que M5 hacía con un script fuera del repositorio.

El objeto más pequeño que puede jugar

src/rukh/infer/onnx_player.py
"""An exported ONNX graph standing in for the decoder, so an export can sit on the Elo ladder.
Parity (M2) says in how many positions the exported model picks the same move as the checkpoint.
It does not say what the other positions cost: an int8 export that agrees 95 % of the time is a
different player, and the only way to learn how different is to let it play the same ladder the
checkpoint played. ``OnnxDecoder`` is the smallest object that ``pick_move`` accepts in place of a
``MoveDecoder`` -- ``next_logits``, ``parameters`` (for the device) and ``cfg.block`` -- so the
export goes through ``DecoderPlayer`` and the very same masking, sampling and rescue as every
published number. A comparison whose two halves went through different code is not a comparison.
"""

src/rukh/infer/onnx_player.pylíneas 1-10 · p6

«El objeto más pequeño que pick_move acepta» es la frase de diseño. Nadie hereda de MoveDecoder ni define una interfaz abstracta: se mira qué tres cosas usa el muestreador y se escribe un objeto que las tenga. Es tipado patotipado patoAceptar un objeto por los métodos que tiene y no por la clase de la que hereda: «si grazna como un pato…». En Rukh es lo que permite que un grafo ONNX de 68 líneas (OnnxDecoder) juegue en la escalera de Stockfish por el mismo camino que un checkpoint, implementando solo las tres cosas que el muestreador mira: next_logits, parameters y cfg.block. (duck typing) a propósito, y la exportación recorre el mismo código que el checkpoint: la misma máscara de legalidad, el mismo muestreo con su semilla, el mismo rescate. Un segundo bucle de partida para ONNX habría dado dos números que no se pueden restar. Es lo que harías para comparar un LLM servido en local con el mismo modelo detrás de una API: un solo arnés y dos objetos que responden a la misma llamada.

src/rukh/infer/onnx_player.py
METADATA_BLOCK = "rukh_block"
def _session(onnx_path: Path) -> Any:
"""An ONNX Runtime session on the CPU provider (tests monkeypatch this)."""
import onnxruntime as ort
return ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
class _Config:
"""The one field of ``DecoderConfig`` the sampler reads."""
def __init__(self, block: int) -> None:
self.block = block

src/rukh/infer/onnx_player.pylíneas 23-37 · p6

_session es una función aparte para que los tests la sustituyan; construida en línea, probar el fichero exigiría un .onnx en disco y onnxruntime instalado. _Config solo tiene block, y su docstring documenta de qué parte de DecoderConfig depende el muestreador. Y providers=["CPUExecutionProvider"] fija la CPU: por eso el lab de la lección 9 usa el rival por nodos, porque con jugador y motor en CPU un rival por reloj mediría además cuánto se estorban.

src/rukh/infer/onnx_player.py
class OnnxDecoder:
"""``model.onnx`` (or its fp16/int8 sibling) with the surface ``pick_move`` needs.
``block`` comes from the metadata ``rukh export`` writes; pass it when the file has none.
"""
def __init__(self, onnx_path: Path, block: int | None = None) -> None:
self.path = Path(onnx_path)
self.session = _session(self.path)
if block is None:
meta = self.session.get_modelmeta().custom_metadata_map
if METADATA_BLOCK not in meta:
raise ValueError(f"{self.path.as_posix()} carries no {METADATA_BLOCK}; pass block=")
block = int(meta[METADATA_BLOCK])
self.cfg = _Config(block)
self._anchor = torch.zeros(1)
def parameters(self): # type: ignore[no-untyped-def]
"""One CPU tensor: the sampler asks where the weights live, and ORT runs on the CPU."""
yield self._anchor
def next_logits(self, idx: Tensor) -> Tensor:
"""Logits of the last step only, ``(B, vocab_size)``, cropped to the block size."""
cropped = idx[:, -self.cfg.block :].detach().cpu().numpy().astype(np.int64)
logits = self.session.run(None, {INPUT_NAME: cropped})[0]
return torch.from_numpy(np.asarray(logits, dtype=np.float32))
def eval(self) -> OnnxDecoder:
return self

src/rukh/infer/onnx_player.pylíneas 40-68 · p6

Cinco decisiones:

  • El block viaja dentro del .onnx: rukh export escribe rukh_block en los metadatos del grafo. Un artefacto que lleva dentro lo que hace falta para usarlo se puede pasar a otra persona sin una nota. Si falta, el error dice qué hacer (carries no rukh_block; pass block=).
  • parameters() devuelve un generador con un tensor de un elemento. El muestreador solo lo llama para preguntar next(...).device, y la respuesta correcta es cualquier tensor en CPU; torch.zeros(1) es el más barato, guardado en self._anchor para no asignar memoria en cada jugada.
  • astype(np.int64) explícito: el grafo declara su entrada int64, y si le llega int32 ONNX Runtime lo rechaza con un error que habla de nombres de tensores y no de tipos.
  • El recorte a block (idx[:, -self.cfg.block:]) está aquí, como en el decoder de PyTorch, para que el jugador no tenga que saber con cuál de los dos habla.
  • eval() devuelve self y no hace nada, porque quien carga un modelo escribe model.eval() por costumbre y un grafo exportado no tiene modos.

Con eso, el int8 puede subir a la misma escalera que el checkpoint, que es lo que pedía la lección 1.

Los dos tests que hacen esto comprobable sin ONNX

Los tests comparan el jugador ONNX con el del checkpoint sin exportar nada. Llegaron con el git checkout del principio: se leen, no se teclean.

// Teststests/unit/test_onnx_player.py: una sesión falsa con los logits del propio checkpoint
tests/unit/test_onnx_player.py
"""An ONNX export plays through the same player as the checkpoint it came from."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import chess
import numpy as np
import pytest
import torch
from rukh.export.onnx import INPUT_NAME, LastStepLogits
from rukh.infer import DecoderPlayer, SampleConfig
from rukh.infer import onnx_player as module
from rukh.infer.onnx_player import OnnxDecoder
from rukh.models.decoder import DecoderConfig, MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
pytestmark = pytest.mark.unit
TOY = DecoderConfig(vocab_size=2030, n_layer=2, n_head=2, d_model=32, block=64)
class FakeSession:
"""Stands in for ``onnxruntime.InferenceSession`` with the checkpoint's own logits."""
def __init__(self, model: MoveDecoder, block: int | None = TOY.block) -> None:
self.wrapper = LastStepLogits(model).eval()
self.block = block
self.seen: list[int] = []
def get_modelmeta(self) -> Any:
meta = {} if self.block is None else {module.METADATA_BLOCK: str(self.block)}
return type("Meta", (), {"custom_metadata_map": meta})()
def run(self, _outputs: Any, feed: dict[str, np.ndarray]) -> list[np.ndarray]:
idx = feed[INPUT_NAME]
assert idx.dtype == np.int64
self.seen.append(idx.shape[1])
with torch.no_grad():
return [self.wrapper(torch.from_numpy(idx)).numpy()]

tests/unit/test_onnx_player.pylíneas 1-42 · p6

FakeSession es la idea que lo hace comprobable: una sesión falsa que devuelve los logits del propio checkpoint, envuelto en el mismo LastStepLogits que usa la exportación. Así el test comprueba que DecoderPlayer sobre el modelo y sobre OnnxDecoder producen las mismas jugadas, sin exportar nada. Dentro de run, el doble comprueba que le llega int64 y apunta la longitud de cada secuencia para comprobar fuera que nunca se pasó del block.

tests/unit/test_onnx_player.py
def test_the_export_picks_the_checkpoints_move_greedily(
model: MoveDecoder, monkeypatch: pytest.MonkeyPatch
) -> None:
session = FakeSession(model)
monkeypatch.setattr(module, "_session", lambda _path: session)
tok = UciTokenizer()
cfg = SampleConfig(temperature=0, top_k=None, mask_illegal=False, seed=0)
reference = DecoderPlayer(model, tok, cfg)
exported = DecoderPlayer(OnnxDecoder(Path("model.onnx")), tok, cfg) # type: ignore[arg-type]
assert exported.limit == reference.limit
board = chess.Board()
for player in (reference, exported):
player.start(1800, 1800)
for _ in range(6):
a, legal_a = reference.choose(board)
b, legal_b = exported.choose(board)
assert a == b and legal_a == legal_b
if a is None:
break
board.push(a)
reference.observe(a)
exported.observe(a)
assert session.seen and max(session.seen) <= TOY.block

tests/unit/test_onnx_player.pylíneas 51-75 · p6

temperature=0 y mask_illegal=False, porque el test compara la elección del modelo: con máscara, los dos jugadores coincidirían aunque sus logits difirieran en las jugadas ilegales. El bucle mueve los dos jugadores sobre el mismo tablero, empujando la jugada de reference, para que el test señale la primera discrepancia y no una partida que divergió veinte jugadas antes. La última aserción comprueba que el límite de 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. deducido del block es el mismo por los dos caminos.

tests/unit/test_onnx_player.py
def test_the_block_comes_from_the_metadata_or_the_caller(
model: MoveDecoder, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module, "_session", lambda _path: FakeSession(model, block=None))
with pytest.raises(ValueError, match="rukh_block"):
OnnxDecoder(Path("model.onnx"))
assert OnnxDecoder(Path("model.onnx"), block=32).cfg.block == 32
monkeypatch.setattr(module, "_session", lambda _path: FakeSession(model, block=48))
assert OnnxDecoder(Path("model.onnx")).cfg.block == 48

tests/unit/test_onnx_player.pylíneas 78-86 · p6

Los tres caminos del constructor: sin metadatos ni argumento, error; sin metadatos y con argumento, el argumento; con metadatos, los metadatos. El tercero usa block=48 y no el valor por defecto, para que un constructor que ignorara los metadatos no pasara.

Un nombre de ejecución que sirve a los dos lectores

Cuando entrenas, unique_run_name le pone una marca de tiempo a la carpeta: medium-v4-20260919-174623. Así dos entrenamientos del mismo nombre no se pisan, pero las configuraciones del repositorio llevaban dentro la marca de tiempo de la máquina de referencia.

configs/train/medium-masters.yaml
init_from: checkpoints/medium-v4/best.pt

configs/train/medium-masters.yamllínea 13 · p6

Esa línea decía antes checkpoints/medium-v4-20260919-174623/best.pt. Quien entrenara medium-v4 en su máquina tendría otra fecha, y quien lo descargara con rukh pull lo tendría en checkpoints/medium-v4/: la configuración solo funcionaba en la máquina donde se escribió. El mismo cambio está en once ficheros (los dos DPO, GRPO, los dos LoRA, medium-elo, medium-masters, los tres de cabezas del encoder y configs/data/onpolicy.yaml), y esta es la pieza que lo hace funcionar:

La resolución de nombres es fontanería del proyecto, no del modelo: en la primera lectura basta con la idea del recuadro de abajo. El código, en cambio, no se puede saltar: sin él no pasa ningún «Ejecútalo» de aquí en adelante. Si no lo tecleas, trae los tres ficheros de train/ que toca, git checkout p6 -- src/rukh/train/checkpoint.py src/rukh/train/common.py src/rukh/train/__init__.py, y copia las funciones _run_path y _run_exists de la salida de git show p6:src/rukh/cli.py a tu cli.py.

// Fontaneríaresolve_run y sus tres usuarios: el cargador, init_from y las opciones de la CLI
src/rukh/train/checkpoint.py
RUN_STAMP = re.compile(r"^(?P<name>.+)-(?P<stamp>\d{8}-\d{6})$")
"""What ``unique_run_name`` appends to a run folder: ``medium-v4`` becomes
``medium-v4-20260919-174623``. The stamp sorts as text, so the newest run of a name is the last."""
def resolve_run(path: str | Path) -> Path:
"""The path as given when it exists; otherwise the same thing inside the newest stamped run.
Configs and lessons name a checkpoint by its run: ``checkpoints/medium-v4/best.pt``. That is
the spelling ``rukh pull`` writes, and it is not the one a reader who trained the run has,
because ``unique_run_name`` stamps every folder (``medium-v4-20260919-174623``). Both mean
"the run called medium-v4", so when the stable folder is missing this looks for the stamped
ones beside it and takes the newest **by name**, never by modification time: copying a
folder must not change which run a config points at. A folder spec (``checkpoints/lora-e4``)
resolves the same way. Anything with no candidate comes back unchanged, and whoever opens it
reports the missing file with the path the user wrote.
"""
path = Path(path)
if path.exists():
return path
is_file = bool(path.suffix)
run_dir = path.parent if is_file else path
parent, name = run_dir.parent, run_dir.name
if not parent.is_dir():
return path
stamped = sorted(
candidate
for candidate in parent.iterdir()
if candidate.is_dir()
and (match := RUN_STAMP.match(candidate.name)) is not None
and match.group("name") == name
and (not is_file or (candidate / path.name).is_file())
)
if not stamped:
return path
found = stamped[-1] / path.name if is_file else stamped[-1]
log.info("%s is not there; using the newest run of that name, %s", path, found)
return found

src/rukh/train/checkpoint.pylíneas 30-67 · p6

Casi todas las decisiones son sobre no adivinar de más:

  • Lo que existe gana. Una carpeta estable, porque la escribió rukh pull o porque la renombraste, tiene prioridad sobre cualquier ejecución con marca; si no, un entrenamiento viejo taparía un modelo descargado.
  • El más nuevo por nombre, no por fecha de modificación. La marca AAAAMMDD-HHMMSS ordena igual como texto que como fecha, y st_mtime cambia al copiar una carpeta, restaurar una copia de seguridad o sincronizarla con la nube.
  • bool(path.suffix) distingue un fichero de una carpeta de adaptador. Fallaría con una carpeta con un punto en el nombre; no hay ninguna.
  • El candidato tiene que contener el fichero: una ejecución que acaba de empezar tiene carpeta y aún no tiene best.pt.
  • El prefijo tiene que ser el nombre entero (match.group("name") == name): con startswith, small encontraría small-v3-20260921-000000, que es otra ejecución.
  • Lo que no se resuelve vuelve intacto, para que el error lo dé quien lo abra con el nombre que escribiste, no con una carpeta con marca que nunca escribiste.

El log.info cuando sí resuelve avisa de que el comando está usando otro fichero. Una resolución silenciosa es magia, y la magia se paga el día que dos ejecuciones del mismo nombre dan números distintos.

src/rukh/train/checkpoint.py
def load_checkpoint(path: Path, map_location: str | torch.device = "cpu") -> dict[str, Any]:
"""Read a checkpoint written by ``save_checkpoint`` (see ``resolve_run`` for the path)."""
payload = torch.load(resolve_run(path), map_location=map_location, weights_only=True)
if not isinstance(payload, dict) or "model_state" not in payload:
raise ValueError(f"{path} is not a rukh checkpoint")
return payload

src/rukh/train/checkpoint.pylíneas 191-196 · p6

Un solo resolve_run en el cargador central y lo heredan todos los caminos. El mensaje de error sigue usando path, la ruta que pidieron: habla el lenguaje del usuario.

src/rukh/train/common.py
if init_from is None:
return None
path = resolve_run(paths.resolve(init_from))
if not path.is_file():
raise FileNotFoundError(f"init_from checkpoint not found: {path}")

src/rukh/train/common.pylíneas 215-219 · p6

Aquí hace falta una segunda llamada, porque load_init_weights comprueba que el fichero existe antes de abrirlo para dar un error con el nombre de la clave (init_from). Este mensaje sí usa la ruta resuelta, porque lo que necesitas saber es qué fichero se buscó.

src/rukh/train/__init__.py
read_vocab_hash,
resolve_run,
restore,

src/rukh/train/__init__.pylíneas 17-19 · p6

Y el reexporte, para importarla desde rukh.train.

src/rukh/cli.py
def _run_path(value: Path | None) -> Path | None:
"""A checkpoint or run folder; a missing stable name resolves to the newest stamped run.
A path is validated here instead of with ``exists=True`` so that a command written against
the stable spelling of a run (``checkpoints/small/best.pt``, what ``rukh pull`` writes) also
works for the reader whose own training left ``checkpoints/small-20260919-062911/`` behind.
"""
if value is None:
return None
from rukh.train.checkpoint import resolve_run
found = resolve_run(value)
if not found.exists():
run = value.parent.name if value.suffix else value.name
raise typer.BadParameter(f"{value} does not exist, and no stamped run `{run}-*` holds it")
return found
def _run_exists(spec: str) -> bool:
"""Whether ``spec`` names a checkpoint on disk, directly or through ``resolve_run``."""
from rukh.train.checkpoint import resolve_run
return resolve_run(Path(spec)).is_file()

src/rukh/cli.pylíneas 45-67 · p6

En la CLI, exists=True se sustituye por callback=_run_path en diez opciones (--resume, --ckpt, --adapter, --model, --run…). exists=True comprueba la ruta literal antes de que nadie pueda resolverla; un callback corre después y puede devolver otro valor. El BadParameter dice que la ruta no existe y que tampoco hay ninguna ejecución con marca que la contenga (no stamped run \medium-v4-*` holds it`).

Los tests de resolve_run también llegaron con el git checkout del principio; se leen.

// Teststests/unit/test_checkpoint.py: cuatro carpetas colocadas para que cada aserción falle con un error plausible
tests/unit/test_checkpoint.py
def test_a_stable_run_name_resolves_to_the_newest_stamped_run(tmp_path: Path) -> None:
"""``checkpoints/small/best.pt`` finds ``checkpoints/small-<newest stamp>/best.pt``.
This is what lets a config or a lesson name a run without its date: the reader who trained
it has the stamped folder, the reader who pulled it from the Hub has the stable one, and the
same spelling serves both.
"""
from rukh.train.checkpoint import resolve_run
root = tmp_path / "checkpoints"
older = root / "small-20260919-062911"
newer = root / "small-20260920-150000"
decoy = root / "small-v3-20260921-000000" # another run whose name merely starts the same
for folder in (older, newer, decoy):
folder.mkdir(parents=True)
(folder / "best.pt").write_bytes(b"x")
(root / "small-20260921-000000").mkdir() # newest stamp, but it holds no best.pt yet
assert resolve_run(root / "small" / "best.pt") == newer / "best.pt"
assert resolve_run(root / "small") == root / "small-20260921-000000"
assert resolve_run(root / "small-v3" / "best.pt") == decoy / "best.pt"
# A stable folder wins over any stamped one, and an unknown name comes back untouched.
(root / "small").mkdir()
(root / "small" / "best.pt").write_bytes(b"y")
assert resolve_run(root / "small" / "best.pt") == root / "small" / "best.pt"
assert resolve_run(root / "medium" / "best.pt") == root / "medium" / "best.pt"
assert resolve_run(tmp_path / "nowhere" / "run" / "best.pt") == (
tmp_path / "nowhere" / "run" / "best.pt"
)
def test_load_checkpoint_reads_through_the_stable_name(tmp_path: Path) -> None:
model, optimizer = make_pair()
run = tmp_path / "checkpoints" / "tiny-20260919-060101"
save_checkpoint(
run / "best.pt",
step=5,
model=model,
optimizer=optimizer,
cfg={},
model_cfg=TOY.model_dump(),
)
payload = load_checkpoint(tmp_path / "checkpoints" / "tiny" / "best.pt")
assert payload["step"] == 5

tests/unit/test_checkpoint.pylíneas 112-155 · p6

El montaje del primer test es la mitad del valor: cuatro carpetas colocadas para que cada aserción falle con una implementación plausible pero equivocada.

  • Si se ordenara mal, saldría older en vez de newer.
  • Si no se comprobara que el fichero está dentro, saldría small-20260921-000000/best.pt, que no existe. Esa misma carpeta sí es la respuesta cuando se pregunta por la carpeta y no por el fichero: dos aserciones seguidas sobre la misma carpeta, con respuestas distintas y las dos correctas.
  • Si se usara startswith, small-v3 encontraría small-… o al revés.
  • Si lo estable no ganara, la penúltima aserción devolvería la carpeta con marca.
  • La última comprueba que una ruta cuyo padre ni existe vuelve intacta.

El brazo de control, dentro de la configuración

El último cambio es de M5. Su comparación tenía dos brazos: DPO sobre los pares de las evaluaciones de Lichess (fuera de política) y sobre los que propuso el propio modelo (en política). Para medir de dónde salen los pares y no cuántos hay, los dos brazos tenían que ver la misma cantidad, y eso lo hacía un script de una vez que escribía data/pairs-offpolicy-matched/ fuera del repositorio.

src/rukh/train/dpo.py
pairs: str = "data/pairs/dpo-prompts.parquet"
max_pairs: int | None = Field(default=None, ge=1)
"""Subsample the pairs to this many, with ``seed``, before the split. Two arms compared on
two pair sources must see the same amount of data, or the comparison measures the amount."""

src/rukh/train/dpo.pylíneas 39-42 · p6

ge=1: cero pares es un error de escritura, y el test comprueba que no valida.

src/rukh/train/dpo.py
def limit_pairs(frame: Any, cfg: DpoConfig) -> Any:
"""At most ``cfg.max_pairs`` rows, drawn with ``cfg.seed`` from the whole file.
A sample and never the head: the pairs are balanced by phase in blocks, so the first rows
of the file are two thirds openings against a third in the whole (D-114). With no limit the
frame comes back untouched.
"""
if cfg.max_pairs is None or frame.height <= cfg.max_pairs:
return frame
LOGGER.info("dpo: %d pares muestreados de %d", cfg.max_pairs, frame.height)
return frame.sample(n=cfg.max_pairs, seed=cfg.seed, shuffle=True)

src/rukh/train/dpo.pylíneas 194-204 · p6

«Una muestra y nunca la cabeza» es toda la función. El fichero de pares está equilibrado por fase en bloques: aperturas, medio juego, finales. Las primeras 6 386 filas serían dos tercios de aperturas, y el brazo de control entrenaría sobre otra distribución sin que nada fallara. Es el mismo error de quien evalúa un clasificador con las primeras mil filas de un fichero ordenado por fecha o por clase. La semilla es la del entrenamiento, así que dos ejecuciones ven los mismos pares.

configs/train/dpo-offpolicy.yaml
# DPO on the pairs Lichess's evaluations produced: off-policy, and the control arm of M5's
# comparison. The only lines that differ from `dpo-onpolicy.yaml` are `pairs` and `max_pairs`.
pairs: data/pairs/dpo-prompts.parquet
# Matched to the on-policy pair count (6 386), as a fixed-seed sample of the whole file, so the
# two arms see the same amount of data and the comparison measures the source of the pairs and
# not their number. P5 did this with a one-off script that wrote `data/pairs-offpolicy-matched/`;
# the config now says it, so the run can be rebuilt from the config alone.
max_pairs: 6386
checkpoint: checkpoints/medium-v4/best.pt

configs/train/dpo-offpolicy.yamllíneas 1-9 · p6

El comentario cierra la deuda: dice el número, de dónde sale y qué había antes. El criterio es que la ejecución se pueda reconstruir solo desde la configuración, sin pasos que estén en la cabeza de quien lo hizo.

El test que lo fija está en tests/unit/test_dpo.py, que ya trajiste; léelo:

tests/unit/test_dpo.py
def test_max_pairs_samples_the_whole_file_with_the_seed() -> None:
"""The off-policy arm is matched to the on-policy count here, not by a script outside git."""
from rukh.train.dpo import limit_pairs
frame = pl.DataFrame({"phase": ["opening"] * 6 + ["endgame"] * 6, "i": list(range(12))})
same = limit_pairs(frame, DpoConfig())
assert same.height == 12
cut = limit_pairs(frame, DpoConfig(max_pairs=4, seed=7))
assert cut.height == 4
assert cut["i"].to_list() == limit_pairs(frame, DpoConfig(max_pairs=4, seed=7))["i"].to_list()
assert cut["i"].to_list() != [0, 1, 2, 3] # not the head of the file
assert limit_pairs(frame, DpoConfig(max_pairs=50)).height == 12
with pytest.raises(ValueError):
DpoConfig(max_pairs=0)

tests/unit/test_dpo.pylíneas 160-173 · p6

El marco de juguete tiene seis aperturas seguidas de seis finales, los bloques del fichero real en miniatura, y las dos aserciones centrales comprueban que la muestra se repite con la misma semilla y que no es la cabeza. Como shuffle=True baraja también el orden, que salga [0, 1, 2, 3] justo en ese orden por azar ocurre una vez de cada 11 880 (12 · 11 · 10 · 9), pero con la semilla 7 fija el test pasa siempre o no pasa nunca; sin semilla sería una bomba de relojería en la CI.

// Ejercicio 01Qué le falta a `rukh eval match` para enfrentar dos exportaciones

OnnxDecoder ya entra en DecoderPlayer. Con eso delante, di qué le falta a rukh eval match --a … --b … para poder enfrentar el fp16 contra el int8 del mismo modelo, y por qué eso no estaba hecho en este hito.

// SoluciónVer la solución

Solo que --a y --b acepten un directorio ONNX con su precisión, y construyan OnnxDecoder en vez de cargar un checkpoint. El jugador ya existe y pasa por el mismo DecoderPlayer que usa el lab de la lección 9. No se hizo porque el enfrentamiento que respondería la pregunta necesita miles de partidas —el lab 3 mide 11 puntos de diferencia, y separar 11 del cero pide unas 3 800 partidas por pareja— y ese gasto no cabía en el hito. El código está; la decisión de pagarlo, no.

// Ejercicio 02Rompe `resolve_run` de la forma tentadora

Cambia sorted(...) por un orden según st_mtime y describe una secuencia de acciones normales —ninguna de ellas un entrenamiento— que hagan que una configuración apunte a otra ejecución.

// SoluciónVer la solución

Copiar el directorio checkpoints/ a otro disco, restaurar una copia de seguridad, o sincronizarlo con una carpeta en la nube: las tres reescriben las fechas de modificación, a menudo en un orden que no es el original. Después de cualquiera de ellas, checkpoints/medium-v4/best.pt podría resolverse a una ejecución de hace tres semanas, el entrenamiento arrancaría sin protestar y la única pista sería la línea de log.info que nadie mira. El nombre, en cambio, viaja con la carpeta.

Qué has aprendido

El adaptador más útil suele ser el más pequeño. OnnxDecoder implementa las tres cosas que mira el muestreador, y basta para que una exportación juegue por el mismo camino que el checkpoint. Cuando quieras comparar dos implementaciones de algo, empieza por preguntarte qué usa de verdad el consumidor.

Las dos deudas se pagaron igual, sacando de la cabeza de quien lo hizo algo que solo estaba ahí: una ruta con la marca de tiempo de una máquina pasó a ser un nombre que se resuelve en todas, y un script de una vez, una clave de configuración con su número y su razón al lado.

La lección siguiente cambia de repositorio: la demo con la que se juega contra todo esto.