// M6 · lección 02
La escalera calibrada: el rival por nodos y la identidad de unos pesos
El código del instrumento antes de fiarse de él: las dos configuraciones que miden cuánto se mueve la escalera cuando no cambias nada, el presupuesto de nodos que sustituye al reloj dentro de Stockfish, la caché partida en dos familias para que subir las partidas no tire los puzles, y el hash de los tensores que reconoce dos ficheros con los mismos pesos.
Qué vas a construir
Antes de fiarte de un instrumento, lo revisas. Esta lección trae cuatro cambios pequeños al de Rukh. Ninguno produce una fila de la tabla, pero deciden si las filas se pueden leer:
- dos configuraciones que miden el propio montaje, no un modelo;
- un presupuesto de nodos en
StockfishOpponent, para que el rival deje de depender de la carga de la máquina; - una caché partida por familias, para que subir el número de partidas no tire las ya jugadas;
- un hash de los pesos, no del fichero, para reconocer una copia del Hub como el modelo que se midió.
Y una deuda de M5: el fichero de prompts del DPO, que nadie podía regenerar.
Dos configuraciones que no miden un modelo
La lección 1 contó que medium-elo, medido dos veces con la misma semilla, dio 1498 y 1558. El
sospechoso natural es el reloj: a 0,1 segundos por jugada, cuántas posiciones explora Stockfish
depende de lo que esté haciendo la máquina. Para comprobarlo hay que correr la escalera cuatro
veces, dos con cada límite, y eso pide dos ficheros de configuración cuyo único trabajo es medir el
instrumento.
# The ladder on the clock, run for the instrument and not for the table.## M6 measures how well the ladder repeats itself before rebuilding the results table with it.# Two runs of this file on the same weights, with the cache off, are two independent draws of# the instrument as P4 knew it: Stockfish on 0.1 s per move with `UCI_LimitStrength`, which# gave 1498 and 1558 for the same model and seed (D-107). Everything else is `greedy.yaml`.## `web_results: null` on purpose: these runs are the instrument being measured, not a stage, and# a row for them in the single table would be a number about nothing.games: data/uci/year=2025/month=02/games.parquetpuzzles: data/puzzles/puzzles.parquetpuzzle_split: testlegality_positions: 0accuracy_positions: 0position_pool: 20000puzzles_per_band: 0elo_games: 20elo_move_time: 0.1bootstrap: 500diversity_games: 0temperature: 0.05top_k: 1block: 200seed: 42header_elo: 1800out_dir: artifacts/evalweb_results: nullcache_db: artifacts/eval/cache-ladder.sqlitetrack: falseLos ceros son la mitad del fichero y son la decisión: legality_positions, accuracy_positions,
puzzles_per_band y diversity_games a cero apagan todo lo que no juega contra el motor. A
temperatura 0,05 la legalidad sale idéntica hasta el tercer decimal, y cada minuto gastado en otra
cosa se paga cuatro veces.
web_results: null impide un accidente. Todas las configuraciones de evaluación escriben su fila
en artifacts/web/results.json, que alimenta la tabla de esta web; una fila ladder-time-a en la
tabla pública sería un número sobre nada. Y cache_db apunta a una base propia,
cache-ladder.sqlite, para que el día que alguien lo lance sin --no-cache no envenene la caché de
la tabla.
La segunda configuración cambia una línea.
# The ladder on a node budget, run for the instrument and not for the table.## The same eight rungs as `ladder-time.yaml`, but every opponent searches exactly 200 000 nodes# per move instead of 0.1 s: that is what 0.1 s bought on the reference machine at these# settings (median over three positions at `UCI_Elo` 1320, 2000 and `Skill Level` 0), so the# opponents are meant to be about as strong, and now indifferent to what else the machine is# doing. Two runs of this file with the cache off say how much of D-107's 60-Elo gap was the# clock. The Elo scale of a node-limited ladder is its own: the rungs are recalibrated with# `labs/m6/ladder_check.py` before any number from it is compared with a published one.games: data/uci/year=2025/month=02/games.parquetpuzzles: data/puzzles/puzzles.parquetpuzzle_split: testlegality_positions: 0accuracy_positions: 0position_pool: 20000puzzles_per_band: 0elo_games: 20elo_move_time: 0.1elo_nodes: 200000bootstrap: 500diversity_games: 0temperature: 0.05top_k: 1block: 200seed: 42header_elo: 1800out_dir: artifacts/evalweb_results: nullcache_db: artifacts/eval/cache-ladder.sqlitetrack: falseconfigs/eval/ladder-nodes.yaml
La clave nueva es elo_nodes: 200000, lo que 0,1 segundos compraban en la máquina de referencia
(mediana sobre tres posiciones a UCI_Elo 1320, a 2000 y a Skill Level 0). Así los ocho peldaños
son aproximadamente igual de fuertes que en el fichero anterior, y la comparación entre las dos
parejas de tiradas habla de la varianza y no de la fuerza del rival. elo_move_time: 0.1 se queda
aunque no se use, para que los dos ficheros se lean en paralelo.
El rival, con presupuesto de nodos
Toda la mecánica del límite por nodosLímite por nodosDarle al motor un presupuesto de nodos de búsqueda por jugada en vez de un tiempo. Con tiempo, lo que Stockfish explora en 0,1 s depende de lo que esté haciendo la máquina (D-068: la carga de CPU vale unos +13 Elo). Con nodos la búsqueda es determinista, pero el rival no: UCI_LimitStrength aleatoriza a propósito con los dos límites. M6 lo midió con el mismo modelo y la misma semilla: 17 Elo entre dos tiradas por nodos y 0 entre dos por tiempo con la máquina parada, así que el rival se quedó por reloj (D-137) y elo_nodes queda en la suite para máquinas compartidas y para el lab en que el propio jugador carga la CPU. es un argumento nuevo y
una propiedad en StockfishOpponent, la clase que M2 escribió para hablar con el motor por 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..
def __init__( self, elo: int = 1400, move_time: float = MOVE_TIME_SECONDS, path: Path | None = None, skill: int | None = None, nodes: int | None = None, ) -> None: from rukh.engine import EngineNotFound, find_stockfish
binary = path or find_stockfish() if binary is None: raise EngineNotFound("Stockfish not found; set RUKH_STOCKFISH or run get_stockfish.py") self.move_time = move_time # A node budget instead of a clock: the search stops after ``nodes`` positions whatever # the machine is doing, so the opponent no longer weakens under CPU load (D-068) and two # identical runs see the same opponent. 0.1 s is about 200 000 nodes on the reference # machine at these settings, which is where `elo_nodes` in the suite starts from. self.nodes = nodes self.engine = chess.engine.SimpleEngine.popen_uci(str(binary)) if skill is None: self.engine.configure({"UCI_LimitStrength": True, "UCI_Elo": elo}) else: self.engine.configure({"UCI_LimitStrength": False, "Skill Level": skill})
@property def limit(self) -> chess.engine.Limit: """How far the opponent searches: a node budget when given, the clock otherwise.""" if self.nodes is not None: return chess.engine.Limit(nodes=self.nodes) return chess.engine.Limit(time=self.move_time)
def choose(self, board: chess.Board) -> chess.Move: played = self.engine.play(board, self.limit) if played.move is None: raise chess.engine.EngineError("Stockfish returned no move") return played.moveAntes, choose construía su límite en línea: chess.engine.Limit(time=self.move_time). Ahora el
límite es una propiedad y choose no sabe cuál es. Dos decisiones van dentro.
nodes=None por omisión: todas las partidas de M2 a M5 se jugaron por reloj, y un límite por
defecto distinto habría recalibrado en silencio los módulos anteriores. Y la propiedad devuelve uno
u otro, nunca los dos, porque Limit acepta ambos campos a la vez y entonces el motor para con lo
primero que se cumpla: un presupuesto de nodos con el reloj puesto seguiría dependiendo del reloj.
Que sea una propiedad y no un campo calculado en __init__ permite el test de más abajo sin
Stockfish instalado: StockfishOpponent.__new__ construye el objeto sin ejecutar el constructor, se
le ponen los dos atributos a mano y se comprueba qué Limit sale. Así corre en la CI.
Cómo llega el presupuesto hasta el motor
Desde la configuración hasta StockfishOpponent hay dos funciones, y en las dos el cambio es un
argumento más que se pasa hacia abajo.
def play_rung( model: MoveDecoder | None, tok: UciTokenizer, rung: EloRung, games: int, cfg: SampleConfig, move_time: float = MOVE_TIME, max_plies: int | None = None, cache: EvalCache | None = None, header_elo: int = 1800, player: Player | None = None, nodes: int | None = None,) -> list[GameRecord]:El argumento va al final de la firma: todos se pasan por nombre, así que añadirlo al final no puede romper a ningún llamador.
opponent = StockfishOpponent( elo=rung.uci_elo or 1320, skill=rung.skill, move_time=move_time, nodes=nodes )move_time se sigue pasando aunque haya nodos: decide la propiedad limit, y el objeto sabe a qué
tiempo por jugada se pidió el rival aunque haya jugado por nodos.
def play_rungs( model: MoveDecoder | None, tok: UciTokenizer, rungs: Sequence[EloRung], games: int, cfg: SampleConfig, move_time: float = MOVE_TIME, max_plies: int | None = None, cache: EvalCache | None = None, header_elo: int = 1800, player: Player | None = None, nodes: int | None = None,) -> list[GameRecord]: """Play every rung and return all the game records.""" records: list[GameRecord] = [] for rung in rungs: records.extend( play_rung( model, tok, rung, games, cfg, move_time=move_time, max_plies=max_plies, cache=cache, header_elo=header_elo, player=player, nodes=nodes, ) ) return recordsplay_rungs solo recorre los peldaños. Lo que importa es que no hay una segunda ruta: la suite del
decoder, la de Qwen3 de la lección 3 y la de Karvonen entran todas por aquí, así que el presupuesto
de nodos llega a las tres o a ninguna.
El aviso que viaja con el número
La configuración de la suite gana la clave, y el informe gana la frase que la explica.
elo_games: int = 100 elo_rungs: list[EloRung] = Field(default_factory=lambda: list(DEFAULT_RUNGS)) elo_move_time: float = 0.1 elo_nodes: int | None = None """Search budget of the opponent in nodes. When set, the clock (``elo_move_time``) is not used: the rung searches the same number of positions on any machine under any load, which is what makes two runs of the ladder comparable (D-107 measured 1498 and 1558 on the clock).""" elo_max_plies: int | None = NoneUn int | None y no un int con un cero mágico: «no hay presupuesto» es un valor distinto de «un
presupuesto de cero nodos», que es un motor que no juega.
ELO_CAVEAT_NODES = ( "the Elo interval covers sampling noise only: the four ``skill-*`` rungs are nominal " "``Skill Level`` anchors rather than measured ratings, and Stockfish searches {nodes} nodes " "per move (what 0.1 s bought on the reference machine), far below any setting ``UCI_Elo`` " "is calibrated for; the same position always gets the same reply (D-137)")Es la gemela de ELO_CAVEAT, que está justo encima y dice lo mismo con el reloj. Parece una
duplicación que pide refactor, pero la nota es prosa que alguien leerá en una card dentro de un año,
y la versión por nodos añade una promesa (the same position always gets the same reply) que la del
reloj ni intenta; si se cumple con UCI_LimitStrength activo es lo que mide la lección 8. Una
plantilla con condicionales daría una frase que no dice ni una cosa ni la otra.
def _elo_notes(elo: EloResult | None, cfg: EvalConfig, notes: list[str]) -> None: """Everything the Elo number has to be read with, in words.""" if elo is None: return if cfg.elo_nodes: notes.append(ELO_CAVEAT_NODES.format(nodes=f"{cfg.elo_nodes:,}".replace(",", " "))) else: notes.append(ELO_CAVEAT.format(move_time=cfg.elo_move_time))f"{cfg.elo_nodes:,}".replace(",", " ") escribe 200 000 con espacio, porque la coma de millares
es ambigua entre español e inglés.
move_time=cfg.elo_move_time, max_plies=cfg.elo_max_plies, cache=cache, header_elo=cfg.header_elo, nodes=cfg.elo_nodes, )Y eso es todo lo que hacía falta dentro de _elo: la configuración ya viajaba entera hasta aquí.
La caché, partida en dos familias
El segundo cambio sale de un apunte del backlog de P4.
La clave de cachéclave de cachéEn Rukh, el hash de los ajustes que cambian lo que significa un elemento guardado de una evaluación. Es la promesa de que dos ejecuciones con la misma clave midieron lo mismo: si la clave incluye un campo que no cambia el elemento, invalida trabajo ya pagado; si le falta uno que sí lo cambia, devuelve como nuevo un resultado viejo. M6 la partió en dos familias, partidas y puzles, para que subir el número de partidas no tirara los puzles. de la suite promete que dos ejecuciones con la
misma clave midieron lo mismo. Había una sola clave para las partidas y los puzles, y con doce
etapas eso salía caro: subir elo_games de 20 a 40 tiraba las veinte partidas ya jugadas, aunque la
partida uci-1500:0 sigue siendo la misma (su semilla es seed + index); y forzar la cabecera de
los puzles invalidaba todas las partidas.
def cache_fields(self, family: str = "games") -> dict[str, Any]: """The settings that change what a cached item of ``family`` means.
One key per family, not one for the suite: a game is the same game whatever the puzzle settings are, and a puzzle attempt does not care how long the engine thinks. Under one shared key, raising ``elo_games`` from 20 to 40 threw away the twenty games already played although game ``rung:index`` is exactly the same game (its seed is ``seed + index``), and forcing the header for a sweep invalidated every game (P4 backlog). The number of games is deliberately absent: it changes how many items are read, not what any of them is. """ shared: dict[str, Any] = { "temperature": self.temperature, "top_k": self.top_k, "seed": self.seed, "block": self.block, } if family == "games": fields = { **shared, "elo_max_plies": self.elo_max_plies, "rungs": [rung.model_dump(mode="json") for rung in self.elo_rungs], } # The clock and the node budget are two different opponents; only the one in use # is in the key, so a run on the clock keeps its games when ``elo_nodes`` appears # in the config later with ``null``. if self.elo_nodes is not None: fields["elo_nodes"] = self.elo_nodes else: fields["elo_move_time"] = self.elo_move_time return fields if family == "puzzles": fields = dict(shared) # Only when it is on: a flag that is off *is* the behaviour every cached attempt was # played under, and the key keeps the old name so a sweep's paid-for attempts survive. if self.force_header: fields["puzzles_use_header"] = True return fields raise ValueError(f"unknown cache family {family!r}: games or puzzles")Las tres decisiones son sobre qué no entra en la clave:
elo_gamesno está: el número de partidas cambia cuántos elementos se leen, no qué es cada uno.- El reloj entra solo si no hay nodos, y los nodos solo si los hay. Si la clave llevara siempre los
dos campos, el día que
elo_nodesapareció connullen todas las configuraciones la clave habría cambiado y las partidas ya pagadas se habrían tirado. puzzles_use_headerconserva el nombre antiguo aunque el campo se llameforce_headerdesde M4. Renombrar una clave de caché es invalidarla.
Ante una familia que no conoce, la función levanta ValueError en vez de devolver la clave
compartida: una familia mal escrita tiene que ser un error, no una caché vacía que se llena en
silencio.
El resto del cambio es construir dos cachés en vez de una.
weights_sha = file_sha(ckpt) cache_path = paths.resolve(cfg.cache_db) if use_cache else None games_cache = EvalCache( cache_path, weights_sha, enabled=use_cache, config_sha=config_sha(cfg.cache_fields("games")) ) puzzles_cache = EvalCache( cache_path, weights_sha, enabled=use_cache, config_sha=config_sha(cfg.cache_fields("puzzles")), )Las dos apuntan al mismo SQLite y se distinguen por su config_sha, que forma parte de la clave
primaria: una base de datos, dos vistas con distinta promesa.
Ojo con el nombre de la primera línea: la variable local weights_sha guarda el hash del
fichero (file_sha), mientras que la función weights_sha de la sección siguiente devuelve el
hash de los tensores, y el módulo la importa como tensor_sha para que las dos convivan.
Funciona, pero es de esos nombres que obligan a leer el bloque dos veces.
La identidad de unos pesos
La evaluación registraba el SHA-256 del fichero que midió. Pero rukh publish quita al checkpoint
el estado del optimizador y la cabeza atada antes de subirlo, porque nadie que descargue un modelo
para jugar necesita cientos de megas de momentos de AdamW. Mismos pesos, otro fichero, otro hash, y
la guarda que comprobaba que la card hablaba del fichero medido decía que eran dos modelos.
def weights_sha(path: Path) -> str: """SHA-256 of the tensors under ``model_state``, independent of how the file was written.
Two files can hold the same weights and differ byte for byte: ``best.pt`` keeps the optimizer and the run's provenance, the copy ``rukh publish`` uploads (and ``rukh pull`` brings back) does not, and ``torch.save`` is not even stable across versions. What an evaluation measured is the weights, so this is the identity a pulled checkpoint shares with the one that was evaluated. Names, dtypes, shapes and bytes, in name order; a tied head (``lm_head.weight`` when it is the embedding under another name) is left out, because the publisher drops it and the loader re-ties it, so both files hold the same model. """ import torch
from rukh.train.checkpoint import TIED_SOURCES
try: payload = torch.load(Path(path), map_location="cpu", weights_only=True) except Exception: # noqa: BLE001 - reward checkpoints carry objects the safe loader refuses payload = torch.load(Path(path), map_location="cpu", weights_only=False) state = None if isinstance(payload, dict): # ``model_state`` is ours; ``model`` is nanoGPT's (the Karvonen baseline). state = payload.get("model_state") or payload.get("model") if not isinstance(state, dict) or not state: raise ValueError(f"{Path(path).as_posix()} holds no model_state to hash") names = sorted(state) for head, embedding in TIED_SOURCES.items(): if head in state and embedding in state and torch.equal(state[head], state[embedding]): names.remove(head) digest = hashlib.sha256() for name in names: tensor = state[name].detach().cpu().contiguous() digest.update(f"{name}|{tensor.dtype}|{tuple(tensor.shape)}|".encode()) raw = tensor.view(torch.int16) if tensor.dtype == torch.bfloat16 else tensor digest.update(raw.numpy().tobytes()) return digest.hexdigest()Es el hash de pesoshash de pesosSHA-256 de los tensores de un modelo —nombre, tipo, forma y bytes, en orden de nombre—, en vez del hash del fichero que los contiene. Es la identidad que comparten dos ficheros con el mismo modelo escritos de forma distinta: el best.pt del entrenamiento lleva el estado del optimizador y la copia que sube al Hub no. En Rukh (D-135) es lo que permite publicar una card sobre un fichero descargado y seguir rechazando unos pesos distintos.. Cada línea tapa una forma de que dos ficheros
con el mismo modelo den hashes distintos, o de que dos modelos distintos den el mismo:
sorted(state): el orden de inserción del diccionario depende de cómo se construyó el modelo.- Nombre,
dtypey forma entran en el digest antes de los bytes; si no, el digest solo vería un chorro de bytes, y dos estados que lo comparten serían «el mismo modelo» aunque corten ese chorro por otro sitio (dónde acaba un tensor y empieza el siguiente) o lo lean de otra forma (otra forma, otrodtype). - La cabeza atada se salta solo cuando está atada de verdad. El decoder de M2 comparte la matriz de
embeddings con la capa de salida (tied embeddingsTied embeddingsReutilizar la misma matriz para leer tokens (embedding de entrada) y para escribirlos (capa de salida sobre el vocabulario). En
rukh-smallahorra 1 039 360 parámetros y fuerza a que la representación con la que se lee una jugada sea la misma con la que se apuesta por ella.); el publicador borra la copia y el cargador la vuelve a atar.torch.equalcomprueba que los dos tensores son iguales, para que una cabeza independiente no desaparezca del hash. view(torch.int16)para bfloat16:numpyno conoce ese tipo, yviewreinterpreta los mismos bytes sin copiar ni cambiar un bit.- El
except Exceptioncon sunoqa:weights_only=Truese niega a deserializar objetos de Python arbitrarios, y los checkpoints del reward model de M5 llevan uno. El comentario explica por qué aquí unexceptdesnudo no es un error.
El resultado guarda las dos identidades.
stage: str suite: str checkpoint: str model_sha: str weights_sha: str | None = None """SHA-256 of the tensors alone, so a pulled copy of the same weights is recognised.""" params: intstr | None con valor por omisión: los resultados que P2 a P5 dejaron en disco no tienen el campo,
y un campo obligatorio los rechazaría. Volver a medir doce etapas para rellenarlo habría costado
tres horas.
model_sha=weights_sha, weights_sha=tensor_sha(ckpt),Aquí se ven las dos juntas: model_sha recibe el hash del fichero y weights_sha el de los
tensores, calculado por la función importada como tensor_sha.
El último cambio de suite.py es una línea que importará en la lección 5.
def resolve_model(spec: str | Path) -> Path: """A checkpoint path from either a local file or a Hub id (downloaded on demand).""" from rukh.train.checkpoint import resolve_run
text = str(spec) if is_hub_id(text): return hub_checkpoint(text) path = resolve_run(text) if not path.is_file(): raise FileNotFoundError(f"{path} is neither a checkpoint nor a Hub id (owner/name)") return pathAntes decía path = Path(text). Ahora pasa por resolve_run, que construye la lección 6: un
checkpoints/medium-v4/best.pt que no existe se resuelve al medium-v4-20260919-174623/best.pt que
sí, y un comando de una lección funciona igual para quien descargó el modelo que para quien lo
entrenó.
El fichero de prompts que nadie podía regenerar
El último cambio es una deuda de M5. configs/train/dpo-*.yaml, grpo.yaml y la configuración del
reward model leen data/pairs/dpo-prompts.parquet, pero ese fichero lo había producido un cruce de
una sola vez que no estaba en el repositorio. El par es un FENFENCadena de texto que describe una posición completa: piezas por fila, turno, derechos de enroque, casilla al paso y contadores de jugadas. Es la clave con la que se cruzan las posiciones de las partidas con las evaluaciones públicas de Stockfish., y el decoder
no sabe leer un FEN: necesita la secuencia de jugadas que llegó a la posición.
El cruce es ingeniería de datos con polars y no toca la escalera; se puede saltar en la primera
lectura y volver a él cuando lo teclees. Si lo saltas del todo, tráelo:
git checkout p6 -- src/rukh/data/pairs.py. Sin él, el «Ejecútalo» de abajo falla, porque
test_pairs.py ya pide dpo-prompts.parquet.
// Fontaneríadata/pairs.py: cruzar los pares con las partidas para escribir dpo-prompts.parquet
LOGGER = logging.getLogger(__name__)PAIRS_FILE = "pairs.parquet"PROMPTS_FILE = "dpo-prompts.parquet"PHASES = ("opening", "middlegame", "endgame")BATCH_ROWS = 50_000PROMPT_COLUMNS = [ "game_id", "ply", "chosen", "rejected", "cp_chosen", "cp_rejected", "phase", "white_elo", "black_elo", "prefix",]Las columnas van en una constante con nombre para que el test compare la lista entera que espera el cargador del DPO.
positions_eval: str = "data/evals/positions-eval.parquet" out_dir: str = "data/pairs" games_dir: str = "data/uci" """The converted games the pairs came from, to recover the moves that led to each position."""Un campo más, con valor por omisión igual a donde escribe rukh data uci, para que las
configuraciones de M5 sigan cargando sin tocarlas.
def build_prompts(pairs: pl.DataFrame, games_dir: Path, block: int = 200) -> pl.DataFrame: """Every pair joined back to its game: the moves that led to the position, and the ratings.
A pair is a FEN, and the decoder cannot read a FEN: it reads the move sequence that reached the position, behind the three header tokens. ``game_id`` and ``ply`` say which game and how far into it, so the prompt is the first ``ply`` moves of that game and the two Elo columns fill the header. This is the file every DPO, GRPO, reward-model and on-policy config reads; until it was written here it existed only as a one-off join nobody could rerun.
A prompt has to fit the decoder's context whole -- three header tokens plus ``ply`` moves, at most ``block`` -- because truncating its front would hand the model a position that never happened. Pairs deeper than that are dropped and counted, and so is a pair whose game is not under ``games_dir``; neither is ever invented. """ parquets = sorted(games_dir.glob("year=*/month=*/games.parquet")) if not parquets: raise FileNotFoundError(f"no converted games under {games_dir} (run `rukh data uci`)") games = ( pl.scan_parquet([p.as_posix() for p in parquets]) .select("game_id", "uci", "white_elo", "black_elo") # The games carry the id as a signed 64-bit hash; the evaluations, and therefore the # pairs, carry it as text. Matching on text loses nothing and refuses nothing. .with_columns(pl.col("game_id").cast(pl.String)) ) header = 3 # <bos> and the two Elo tokens, exactly what `rukh.infer.game._history` builds joined = ( pairs.lazy() .filter(pl.col("ply") + header <= block) .join(games, on="game_id", how="inner") .collect() ) prefixes = [ " ".join(str(uci).split()[:ply]) for uci, ply in zip(joined["uci"], joined["ply"], strict=True) ] return joined.with_columns(pl.Series("prefix", prefixes, dtype=pl.String)).select( PROMPT_COLUMNS )Cinco decisiones:
scan_parquety noread_parquet: el corpus son varios gigabytes por meses, y el plan perezoso empuja filtro y columnas hasta el lector (predicate pushdownPredicate pushdownEmpujar el filtro hasta el almacén de datos para que solo viaje lo que pasa el filtro, en vez de traerlo todo y descartar en local. Es lo que permite querukh data fetchsaque un recorte de dos meses de Lichess (73 GB por mes en parquet) descargando solo los pocos GB de partidas que cumplen los filtros.), así que de cada fichero solo se leen cuatro columnas.- El filtro
ply + header <= blockva en el lado de los pares, el pequeño, antes de cruzar. - El
cast(pl.String): las partidas guardangame_idcomo entero de 64 bits y los pares como texto. Enpolars, un cruce entreInt64yStringno falla: devuelve cero filas, y tendrías un fichero vacío que parece correcto. header = 3con nombre y comentario (<bos>y los dos tokens de Elo): el día que la cabecera cambie hay un sitio donde cambiarlo.- Los pares demasiado profundos se tiran, no se cortan. Un prompt recortado por delante es una
posición que nunca se jugó. Mejor menos datos honestos que más datos inventados, la misma regla
que el
-100de M3 para las etiquetas que no se sabían.
balanced = balance(raw, cfg.max_per_phase, cfg.seed) target = out_dir / PAIRS_FILE balanced.write_parquet(target.as_posix(), compression="zstd") prompts = build_prompts(balanced, resolve(cfg.games_dir)) prompts_target = out_dir / PROMPTS_FILE prompts.write_parquet(prompts_target.as_posix(), compression="zstd") dropped = int(balanced.height - prompts.height) if dropped: LOGGER.info( "%d pairs got no prompt: deeper than the context or with no game under %s", dropped, cfg.games_dir, ) counts = phase_counts(balanced) counts["candidates"] = int(raw.height) counts["prompts"] = int(prompts.height) counts["prompts_dropped"] = droppedLos prompts salen de balanced, el marco que se acaba de escribir, así que las filas de los dos
ficheros se corresponden por construcción. prompts_dropped va
al manifiestoManifiesto de datosFichero JSON que acompaña a cada recorte con los filtros exactos, los meses, los conteos y el hash de cada fichero. Sin manifiesto no hay reproducibilidad: es lo que permite decir qué datos vio un modelo. aunque valga cero, para que «no se tiró nada» no se confunda
con «esta versión no lo contaba».
files=[ FileHash(path=PAIRS_FILE, sha256=sha256_file(target), bytes=target.stat().st_size), FileHash( path=PROMPTS_FILE, sha256=sha256_file(prompts_target), bytes=prompts_target.stat().st_size, ), ],El fichero nuevo entra en la lista de hashes del manifiesto; la lección 5 lo añade a los patrones que se suben al Hub.
Los dos tests que fijan las decisiones
Dos tests fijan las decisiones de la caché y del rival. Ya los tienes: llegaron con el
git checkout del principio, así que no se teclean. Puedes saltarte el pliegue en la primera
lectura; el «Ejecútalo» de debajo funciona igual.
// Teststests/unit/test_cache.py: la tabla de verdad de la clave y el rival sin motor
def test_one_key_per_family_so_more_games_reuse_the_ones_already_played() -> None: """The P4 backlog item: raising ``elo_games`` must not throw away the games on disk.
A game is ``rung:index`` with seed ``seed + index``, the same game whatever the total is, and the same game whatever the puzzle settings are. So the games key ignores the count and the puzzle flags, and the puzzles key ignores the clock and the rungs. """ from rukh.eval.suite import EvalConfig
base = EvalConfig(elo_games=20) games_key = config_sha(base.cache_fields("games")) puzzles_key = config_sha(base.cache_fields("puzzles")) assert config_sha(EvalConfig(elo_games=40).cache_fields("games")) == games_key assert config_sha(EvalConfig(elo_games=40).cache_fields("puzzles")) == puzzles_key # What changes a game: the sampler, the clock (or the node budget), the plies, the rungs. assert config_sha(EvalConfig(temperature=0.05).cache_fields("games")) != games_key assert config_sha(EvalConfig(elo_move_time=0.05).cache_fields("games")) != games_key assert config_sha(EvalConfig(elo_nodes=200_000).cache_fields("games")) != games_key assert config_sha(EvalConfig(elo_max_plies=100).cache_fields("games")) != games_key # What does not: the puzzle header flag. assert config_sha(EvalConfig(force_header=True).cache_fields("games")) == games_key # And the mirror image for puzzles. assert config_sha(EvalConfig(elo_move_time=0.05).cache_fields("puzzles")) == puzzles_key assert config_sha(EvalConfig(elo_nodes=200_000).cache_fields("puzzles")) == puzzles_key assert config_sha(EvalConfig(force_header=True).cache_fields("puzzles")) != puzzles_key assert config_sha(EvalConfig(temperature=0.05).cache_fields("puzzles")) != puzzles_key with pytest.raises(ValueError, match="unknown cache family"): base.cache_fields("weather")
def test_the_node_budget_replaces_the_clock_in_the_opponent() -> None: """With ``nodes`` the opponent's limit is a node count; without it, the clock.""" from rukh.infer.game import StockfishOpponent
on_clock = StockfishOpponent.__new__(StockfishOpponent) on_clock.move_time, on_clock.nodes = 0.1, None assert on_clock.limit.time == 0.1 and on_clock.limit.nodes is None on_nodes = StockfishOpponent.__new__(StockfishOpponent) on_nodes.move_time, on_nodes.nodes = 0.1, 200_000 assert on_nodes.limit.nodes == 200_000 and on_nodes.limit.time is NoneEl primero es la tabla de verdad de la clave escrita como aserciones: cada línea es una frase de la
política («subir las partidas no invalida nada»), y la que falle dice qué promesa se rompió. Un test
que solo comprobara que configuraciones distintas dan claves distintas no habría visto el problema,
porque el error era que sí las daban. El segundo es el de __new__ sin __init__, que corre en la
CI sin motor.
// Ejercicio 01¿Qué clave cambia?
Con la tabla de cache_fields delante, di para cada cambio si invalida las partidas, los puzles,
las dos cosas o ninguna: (a) temperature: 0.05 → 0.6; (b) elo_games: 20 → 160; (c)
bootstrap: 500 → 1000; (d) añadir un peldaño a elo_rungs; (e) elo_nodes: null → 200000.
// SoluciónVer la solución
(a) las dos, porque temperature está en shared: a otra temperatura el modelo juega otras
partidas y responde otros puzles. (b) ninguna: elo_games no entra en ninguna clave, que es
justo el arreglo de esta sección. (c) ninguna: el bootstrap se calcula sobre los resultados
guardados y no cambia ninguna partida. (d) las partidas; los puzles no llevan rungs. (e) las
partidas, y además cambian de escala, así que sus Elo no se comparan con los anteriores.
// Ejercicio 02El hash que no distinguiría dos modelos
Quita del digest de weights_sha la línea que mete el nombre, el dtype y la forma, dejando solo
los bytes de cada tensor. Construye dos diccionarios de estado que a partir de ese momento darían
el mismo hash y que son modelos distintos. ¿Cuál es el más plausible en este proyecto?
// SoluciónVer la solución
Intercambiar dos tensores de la misma forma no sirve: {"a": t1, "b": t2} y
{"a": t2, "b": t1} recorridos en orden alfabético dan t1 seguido de t2 frente a t2
seguido de t1, dos chorros distintos (salvo que t1 y t2 sean iguales). Sin la cabecera, lo
que colisiona es lo que produce el mismo chorro de bytes: (1) mover la frontera entre dos
tensores consecutivos, {"a": t[:6], "b": t[6:]} frente a {"a": t[:4], "b": t[4:]}; (2) los
mismos bytes con otra forma, una matriz [2, 3] frente a una [3, 2]; (3) los mismos bytes con
otro dtype del mismo tamaño. El tercero es el más plausible en este proyecto, que entrena en
bfloat16 y exporta en float16: los dos ocupan dos bytes por número, y un view donde tocaba un
to deja los mismos bits leídos como otros valores. Es otro modelo, y sin el dtype en el
digest tendría el mismo hash.
Qué has aprendido
El instrumento tiene código, y ese código tiene decisiones tan defendibles como las del modelo: dos configuraciones que solo miden la escalera, un límite por nodos que no cambia nada a quien no lo pida, una clave de caché por familias y un hash de contenido que mantiene comprobable «este es el modelo que medí» después de publicarlo. Si el presupuesto de nodos sirve para lo que se puso lo dicen las cuatro tiradas de la lección 8.
La lección siguiente mete en esta misma escalera un modelo que no entrenó nadie de aquí.