rukh · lab

// M2 · lección 05

Legalidad, exactitud y puzles

Las tres métricas que se calculan sobre posiciones y no sobre partidas: la legalidad sin máscara con sus dos definiciones, el top-1 y el top-3 por tramo de Elo, los puzles con la secuencia completa como criterio, y la caché SQLite que hace que repetir una suite sea casi gratis.

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

Qué vas a construir

La mitad barata del harness de evaluación: legality.py, accuracy.py, puzzles.py y cache.py, que producen cuatro columnas de la tabla única sin jugar una sola partida. Y una idea que te llevarás a cualquier evaluación de un LLM: una métrica no vale nada hasta que sabes qué pregunta responde, y dos métricas que responden preguntas distintas no se pueden publicar como una.

legality.py: la métrica de comprensión

El fichero más importante del harness, porque contiene la única pregunta del módulo que no tiene una respuesta cómoda.

src/rukh/eval/legality.py
"""Legality without the mask: the honest "does it understand chess" metric.
A position is a prefix of a validation game: the moves up to a random ply, encoded exactly as
training encodes them (``<bos> <wXXXX> <bXXXX> moves...``). The model is asked for one move with
``mask_illegal=False``, so the raw token comes out as the network produced it, and the share of
tokens that happen to be legal moves in that position is the legality rate of ``docs/spec/02``.
It is measured twice, because the two numbers answer different questions:
``argmax``
the single most likely token, with no temperature and no top-k. This is the headline rate
and the one the "at least 99 % legal" bar of ``GOAL.md`` refers to: it is a property of the
weights, not of a sampler setting.
``sampled``
a token drawn exactly as the demo draws it (the suite's temperature and top-k). It is
always the lower of the two and is what a player would actually experience without a mask.
The sampler of validation prefixes lives here because legality is what defines it; ``accuracy``
reuses the same ``Position`` objects (it only needs the human move on top).
"""

src/rukh/eval/legality.pylíneas 1-20 · p2

src/rukh/eval/legality.py
from __future__ import annotations
import random
from collections.abc import Sequence
from pathlib import Path
import chess
from pydantic import BaseModel, ConfigDict
from rukh.infer import (
SampleConfig,
legal_token_ids,
model_generator,
pick_move,
prompt_ids,
)
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer, elo_token
LEGALITY_MODES = ("argmax", "sampled")
GAME_COLUMNS = ["game_id", "uci", "white_elo", "black_elo"]
MIN_PLY = 1

src/rukh/eval/legality.pylíneas 22-44 · p2

src/rukh/eval/legality.py
class Position(BaseModel):
"""One validation prefix: the board, the ids the model would have seen and the human reply."""
model_config = ConfigDict(extra="forbid")
game_id: int
ply: int
"""Number of half-moves already played (the model must produce half-move ``ply``)."""
moves: list[str]
"""The prefix in UCI, so the board can be rebuilt without carrying a FEN."""
history: list[int]
"""Token ids of the prefix, header included."""
target: str | None
"""The move the human played next, or None at the end of the game."""
elo: int
"""Rating of the side to move (what the Elo bands of ``accuracy`` are cut on)."""

src/rukh/eval/legality.pylíneas 47-62 · p2

Position es el objeto central del harness:

  • moves guarda el prefijo en UCI y no un FEN, porque el modelo razona sobre secuencias y un FEN no permitiría reconstruir el prompt.
  • history son los ids ya recortados, cabecera incluida, para que legalidad, exactitud y paridad ONNX le enseñen al modelo el mismo prompt.
  • elo es el del bando que mueve, no el de las blancas; confundirlo metería la mitad de las posiciones en el tramo equivocado.
  • target es None al final de la partida, y la exactitud se salta esas posiciones.
src/rukh/eval/legality.py
def board_of(position: Position) -> chess.Board:
"""Replay the prefix into a board."""
board = chess.Board()
for uci in position.moves:
board.push(chess.Move.from_uci(uci))
return board
def header(tok: UciTokenizer, white_elo: int, black_elo: int) -> list[int]:
"""``[<bos>, <wXXXX>, <bXXXX>]``, the three tokens every training sequence starts with."""
return [
tok.bos_id,
tok.vocab[elo_token(white_elo, "w")],
tok.vocab[elo_token(black_elo, "b")],
]

src/rukh/eval/legality.pylíneas 65-79 · p2

src/rukh/eval/legality.py
def position_at(
tok: UciTokenizer,
game_id: int,
moves: Sequence[str],
ply: int,
white_elo: int,
black_elo: int,
block: int = 200,
) -> Position:
"""Build the prefix of ``moves`` at ``ply``, cropped to ``block`` ids header-first.
The crop keeps ``<bos> <wXXXX> <bXXXX>`` and drops the oldest moves (``prompt_ids``): a plain
left crop would take the Elo conditioning away from every long prefix.
"""
prefix = list(moves[:ply])
ids = header(tok, white_elo, black_elo)
ids.extend(tok.vocab.get(uci, tok.unk_id) for uci in prefix)
return Position(
game_id=game_id,
ply=ply,
moves=prefix,
history=prompt_ids(ids, block),
target=moves[ply] if ply < len(moves) else None,
elo=white_elo if ply % 2 == 0 else black_elo,
)

src/rukh/eval/legality.pylíneas 82-106 · p2

src/rukh/eval/legality.py
def sample_positions(
games: Path,
n: int,
tok: UciTokenizer,
seed: int = 0,
pool: int = 50_000,
block: int = 200,
) -> list[Position]:
"""Sample ``n`` prefixes from a UCI games parquet, one per game, at a random ply.
Only the first ``pool`` rows are read (a validation month is millions of games and the
harness never needs more than a few tens of thousands); which of them are used, and at which
ply, is decided by ``seed``.
"""
import polars as pl
frame = pl.scan_parquet(Path(games).as_posix()).select(GAME_COLUMNS).head(pool).collect()
rows = frame.rows(named=True)
if not rows:
return []
rng = random.Random(seed)
order = list(range(len(rows)))
rng.shuffle(order)
positions: list[Position] = []
for index in order:
if len(positions) >= n:
break
row = rows[index]
moves = str(row["uci"]).split()
if len(moves) <= MIN_PLY:
continue
ply = rng.randrange(MIN_PLY, len(moves))
positions.append(
position_at(
tok,
int(row["game_id"]),
moves,
ply,
int(row["white_elo"]),
int(row["black_elo"]),
block=block,
)
)
return positions

src/rukh/eval/legality.pylíneas 109-152 · p2

Tres decisiones al elegir las mil posiciones:

  1. Una posición por partida. Diez prefijos de la misma partida serían diez medidas correlacionadas contadas como independientes, y el intervalo saldría más estrecho de lo que le corresponde. Es la encuesta en la que preguntas diez veces a la misma persona: diez respuestas y una sola opinión.
  2. El plyPly (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. se sortea a lo largo de toda la partida, no entre las primeras jugadas. La apertura es fácil y los finales difíciles, y una legalidad global del 95 % puede esconder un 99 % en apertura y un 70 % en finales.
  3. Solo se leen las primeras pool filas del parquet, barajadas con seed, así que la muestra es reproducible.
src/rukh/eval/legality.py
class LegalityResult(BaseModel):
"""Share of unmasked proposals that were legal moves, and how they were drawn."""
model_config = ConfigDict(extra="forbid")
positions: int
legal: int
rate: float
mode: str = "sampled"
"""``argmax`` (the headline definition) or ``sampled`` (the demo's temperature and top-k)."""
temperature: float | None = None
top_k: int | None = None

src/rukh/eval/legality.pylíneas 155-166 · p2

El resultado lleva su definición dentro (el modo y, si procede, la temperatura y el top-k), y esos supuestos viajan pegados al número hasta la model card.

src/rukh/eval/legality.py
def legality(
model: MoveDecoder,
tok: UciTokenizer,
positions: Sequence[Position],
cfg: SampleConfig | None = None,
mode: str = "sampled",
) -> LegalityResult:
"""Ask the model for one unmasked move per position and count the legal ones.
``mode="argmax"`` ignores the temperature and the top-k of ``cfg`` and takes the single most
likely token, which is the definition behind the 99 % bar; ``mode="sampled"`` draws the way
the demo does.
"""
if mode not in LEGALITY_MODES:
raise ValueError(f"unknown legality mode {mode!r}; expected one of {LEGALITY_MODES}")
update: dict[str, object] = {"mask_illegal": False}
if mode == "argmax":
update |= {"temperature": 0.0, "top_k": None}
sample = (cfg or SampleConfig()).model_copy(update=update)
generator = model_generator(model, sample)
legal = 0
counted = 0
for position in positions:
board = board_of(position)
if not legal_token_ids(board, tok):
continue
counted += 1
_, report = pick_move(model, tok, board, position.history, sample, generator)
legal += int(report["legal"])
return LegalityResult(
positions=counted,
legal=legal,
rate=legal / counted if counted else 0.0,
mode=mode,
temperature=None if mode == "argmax" else sample.temperature,
top_k=None if mode == "argmax" else sample.top_k,
)

src/rukh/eval/legality.pylíneas 169-205 · p2

Cuatro decisiones:

  • mode="argmax" fuerza temperature=0 y top_k=None: la definición del titular no puede depender de qué había en el YAML.
  • mask_illegal=False siempre, en los dos modos. Es el punto entero de la métrica.
  • Las posiciones sin jugada legal no se cuentan, así que positions puede ser menor que las que le pasaste.
  • Un solo generador para todo el bucle hace reproducible la tirada muestreada.

El harness llama a legality dos veces con las mismas posiciones, para que los dos números se puedan restar.

Los tests de legality.py fijan su contrato con modelos falsos; léelos cuando los teclees, no hacen falta para seguir la idea de la métrica. Si los saltas, tráelos: git checkout p2 -- tests/unit/test_legality.py, porque el «Ejecútalo» «La métrica de comprensión, probada» los ejecuta.

// Teststests/unit/test_legality.py: modelos falsos que comprueban que la métrica pregunta sin máscara
tests/unit/test_legality.py
"""Tests for rukh.eval.legality: sampling validation prefixes and the unmasked legality rate."""
from __future__ import annotations
import importlib
from pathlib import Path
from typing import Any
import chess
import pytest
import torch
from rukh.eval.legality import (
Position,
board_of,
header,
legality,
position_at,
sample_positions,
)
from rukh.infer import SampleConfig
from rukh.models import DecoderConfig, MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
pytestmark = pytest.mark.unit
# ``rukh.eval`` re-exports the function under the module's own name, so the module object for
# monkeypatching has to be asked for explicitly.
legality_module = importlib.import_module("rukh.eval.legality")
TOY = DecoderConfig(vocab_size=2030, n_layer=2, n_head=2, d_model=32, block=64)
GAME = "e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 b5a4 g8f6"

tests/unit/test_legality.pylíneas 1-32 · p2

rukh.eval reexporta la función legality, así que para sustituir pick_move dentro del módulo el test pide el objeto módulo por su nombre con importlib.

tests/unit/test_legality.py
@pytest.fixture(scope="module")
def tok() -> UciTokenizer:
return UciTokenizer()
@pytest.fixture(scope="module")
def model() -> MoveDecoder:
torch.manual_seed(0)
return MoveDecoder(TOY).eval()
@pytest.fixture
def games(tmp_path: Path) -> Path:
import polars as pl
path = tmp_path / "games.parquet"
pl.DataFrame(
{
"game_id": [1, 2, 3],
"uci": [GAME, GAME, "e2e4"],
"white_elo": [1850, 2150, 1900],
"black_elo": [1950, 2050, 1900],
"n_plies": [8, 8, 1],
}
).write_parquet(path)
return path

tests/unit/test_legality.pylíneas 35-60 · p2

Un parquet de tres partidas en un directorio temporal: el harness se prueba entero sin descargar nada.

tests/unit/test_legality.py
def test_position_at_keeps_the_prefix_the_history_and_the_human_move(tok: UciTokenizer) -> None:
moves = GAME.split()
position = position_at(tok, 7, moves, 4, 1850, 1950)
assert position.moves == moves[:4]
assert position.target == moves[4]
assert position.history[:3] == header(tok, 1850, 1950)
assert len(position.history) == 3 + 4
assert position.elo == 1850 # ply 4 is White's turn
def test_the_elo_of_a_position_is_the_side_to_move(tok: UciTokenizer) -> None:
moves = GAME.split()
assert position_at(tok, 1, moves, 3, 1850, 1950).elo == 1950
def test_the_crop_keeps_the_header_and_drops_the_oldest_moves(tok: UciTokenizer) -> None:
moves = GAME.split()
position = position_at(tok, 1, moves, 8, 1850, 1950, block=5)
assert len(position.history) == 5
# The Elo conditioning survives: a plain ``history[-block:]`` would have thrown it away.
assert position.history[:3] == header(tok, 1850, 1950)
assert position.history[3:] == [tok.vocab[uci] for uci in moves[6:8]]
def test_a_short_history_is_left_alone(tok: UciTokenizer) -> None:
position = position_at(tok, 1, GAME.split(), 4, 1850, 1950, block=200)
assert len(position.history) == 7
assert position.history[:3] == header(tok, 1850, 1950)

tests/unit/test_legality.pylíneas 63-90 · p2

El tercero es el que vale: con block=5, el historial recortado sigue empezando por la cabecera y se queda con las dos últimas jugadas. El segundo fija la regla del Elo: en el ply 3 mueven las negras, así que la posición va bajo 1950 y no bajo 1850.

tests/unit/test_legality.py
def test_legality_is_measured_twice_with_different_definitions(
tok: UciTokenizer, model: MoveDecoder
) -> None:
positions = [position_at(tok, 1, GAME.split(), ply, 1850, 1950) for ply in range(1, 6)]
cfg = SampleConfig(temperature=0.6, top_k=20, seed=0)
argmax = legality(model, tok, positions, cfg, mode="argmax")
sampled = legality(model, tok, positions, cfg, mode="sampled")
assert argmax.mode == "argmax" and argmax.temperature is None and argmax.top_k is None
assert sampled.mode == "sampled" and sampled.temperature == 0.6 and sampled.top_k == 20
# argmax is deterministic: the same call twice gives the same rate.
assert legality(model, tok, positions, cfg, mode="argmax").legal == argmax.legal
def test_an_unknown_legality_mode_is_an_error(tok: UciTokenizer, model: MoveDecoder) -> None:
with pytest.raises(ValueError, match="unknown legality mode"):
legality(model, tok, [], mode="greedy")

tests/unit/test_legality.pylíneas 93-108 · p2

Con un modelo de juguete las tasas no significan nada, así que el test comprueba que cada resultado lleva su definición y que el argmax es determinista.

tests/unit/test_legality.py
def test_board_of_replays_the_prefix(tok: UciTokenizer) -> None:
position = position_at(tok, 1, GAME.split(), 4, 1850, 1950)
board = board_of(position)
assert board.turn == chess.WHITE
assert board.fullmove_number == 3
def test_sample_positions_returns_one_prefix_per_game(tok: UciTokenizer, games: Path) -> None:
positions = sample_positions(games, 10, tok, seed=0)
assert len(positions) == 2 # the one-move game is too short to sample from
for position in positions:
assert 1 <= position.ply < 8
assert position.target == GAME.split()[position.ply]
board_of(position)
def test_sample_positions_is_deterministic(tok: UciTokenizer, games: Path) -> None:
first = sample_positions(games, 10, tok, seed=3)
second = sample_positions(games, 10, tok, seed=3)
assert [p.model_dump() for p in first] == [p.model_dump() for p in second]
def test_sample_positions_stops_at_n(tok: UciTokenizer, games: Path) -> None:
assert len(sample_positions(games, 1, tok, seed=0)) == 1

tests/unit/test_legality.pylíneas 111-134 · p2

tests/unit/test_legality.py
def test_legality_counts_the_legal_proposals(
tok: UciTokenizer, model: MoveDecoder, monkeypatch: pytest.MonkeyPatch
) -> None:
answers = iter([True, False, True, True])
def fake_pick(*_args: Any, **_kwargs: Any) -> tuple[None, dict[str, Any]]:
return None, {"legal": next(answers), "raw_token": "e2e4", "top5": [], "masked": False}
monkeypatch.setattr(legality_module, "pick_move", fake_pick)
positions = [position_at(tok, 1, GAME.split(), ply, 1850, 1950) for ply in range(1, 5)]
result = legality(model, tok, positions)
assert (result.positions, result.legal) == (4, 3)
assert result.rate == pytest.approx(0.75)
def test_legality_asks_the_model_without_the_mask(
tok: UciTokenizer, model: MoveDecoder, monkeypatch: pytest.MonkeyPatch
) -> None:
seen: list[bool] = []
def fake_pick(_model, _tok, _board, _history, cfg, _generator=None): # type: ignore[no-untyped-def]
seen.append(cfg.mask_illegal)
return None, {"legal": True, "raw_token": "e2e4", "top5": [], "masked": False}
monkeypatch.setattr(legality_module, "pick_move", fake_pick)
legality(model, tok, [position_at(tok, 1, GAME.split(), 2, 1850, 1950)])
assert seen == [False]

tests/unit/test_legality.pylíneas 137-163 · p2

Los dos con monkeypatch prueban la lógica sin el modelo: cuatro respuestas guionizadas tienen que dar 0,75, y el cfg capturado tiene que llegar con mask_illegal=False. Ese test de una línea impide que alguien «arregle» la legalidad poniéndole la máscara y la suba al 100 %.

tests/unit/test_legality.py
def test_legality_of_a_real_toy_model_is_a_rate(tok: UciTokenizer, model: MoveDecoder) -> None:
positions = [position_at(tok, 1, GAME.split(), ply, 1850, 1950) for ply in range(1, 6)]
result = legality(model, tok, positions)
assert result.positions == 5
assert 0.0 <= result.rate <= 1.0
assert result.legal == int(result.rate * result.positions)
def test_legality_without_positions_is_zero(tok: UciTokenizer, model: MoveDecoder) -> None:
empty: list[Position] = []
assert legality(model, tok, empty).rate == 0.0

tests/unit/test_legality.pylíneas 166-176 · p2

accuracy.py: el top-1, y por qué se desglosa

src/rukh/eval/accuracy.py
"""Next-move accuracy against the human move, overall and per Elo band.
The raw logits are used, not the masked ones: this metric asks "would the model have played
what the human played", and hiding the illegal tokens would flatter it. Bands are 200 Elo wide
starting at 1800 (the floor of ``rukh-games-1800``), with ``<1800`` and ``2600+`` catch-alls,
and a position is filed under the rating of the side to move.
"""
from __future__ import annotations
from collections.abc import Sequence
import torch
from pydantic import BaseModel, ConfigDict
from rukh.eval.legality import Position
from rukh.infer import prompt_ids
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
BAND_START = 1800
BAND_WIDTH = 200
BAND_TOP = 2600
TOP_K = 3

src/rukh/eval/accuracy.pylíneas 1-24 · p2

Los logits crudos, no los enmascarados: la pregunta es «¿habría jugado el modelo lo que jugó el humano?», y esconder los ilegales la halagaría. Los tramos son de 200 puntos desde 1800, el suelo del corpus de M1.

src/rukh/eval/accuracy.py
def elo_band(elo: int) -> str:
"""Name of the 200-Elo band ``elo`` falls in."""
if elo < BAND_START:
return f"<{BAND_START}"
if elo >= BAND_TOP:
return f"{BAND_TOP}+"
low = (elo - BAND_START) // BAND_WIDTH * BAND_WIDTH + BAND_START
return f"{low}-{low + BAND_WIDTH}"

src/rukh/eval/accuracy.pylíneas 27-34 · p2

src/rukh/eval/accuracy.py
class BandAccuracy(BaseModel):
"""Accuracy inside one Elo band."""
model_config = ConfigDict(extra="forbid")
band: str
positions: int
top1: float
top3: float
class AccuracyResult(BaseModel):
"""Top-1 and top-3 agreement with the human move."""
model_config = ConfigDict(extra="forbid")
positions: int
top1: float
top3: float
bands: list[BandAccuracy]

src/rukh/eval/accuracy.pylíneas 37-56 · p2

src/rukh/eval/accuracy.py
def _ranked(model: MoveDecoder, history: list[int], k: int) -> list[int]:
"""Ids of the ``k`` highest logits at the next step (prompt cropped header-first)."""
device = next(model.parameters()).device
ids = prompt_ids(history, model.cfg.block)
idx = torch.tensor([ids], dtype=torch.long, device=device)
logits = model.next_logits(idx)[0]
return [int(i) for i in torch.topk(logits, min(k, logits.numel())).indices]

src/rukh/eval/accuracy.pylíneas 59-65 · p2

src/rukh/eval/accuracy.py
def accuracy(
model: MoveDecoder,
tok: UciTokenizer,
positions: Sequence[Position],
top_k: int = TOP_K,
) -> AccuracyResult:
"""Compare the model's ``top_k`` tokens with the move the human actually played."""
hits1: dict[str, int] = {}
hits3: dict[str, int] = {}
counts: dict[str, int] = {}
for position in positions:
if position.target is None or position.target not in tok.vocab:
continue
band = elo_band(position.elo)
counts[band] = counts.get(band, 0) + 1
target = tok.vocab[position.target]
ranked = _ranked(model, position.history, top_k)
hits1[band] = hits1.get(band, 0) + int(bool(ranked) and ranked[0] == target)
hits3[band] = hits3.get(band, 0) + int(target in ranked)
total = sum(counts.values())
bands = [
BandAccuracy(
band=band,
positions=counts[band],
top1=hits1.get(band, 0) / counts[band],
top3=hits3.get(band, 0) / counts[band],
)
for band in sorted(counts)
]
return AccuracyResult(
positions=total,
top1=sum(hits1.values()) / total if total else 0.0,
top3=sum(hits3.values()) / total if total else 0.0,
bands=bands,
)

src/rukh/eval/accuracy.pylíneas 68-102 · p2

El techo de esta métrica no es el 100 %: en muchas posiciones hay tres jugadas razonables y el humano eligió una, así que acertar siempre significaría haber memorizado el corpus. El desglose por tramo es lo que hace posible el condicionamiento por Elo de M4: si el modelo predice mejor a los 1800 que a los 2400, ha aprendido el nivel medio de sus datos, y condicionar tiene algo que mover.

El global suma los aciertos de todos los tramos y divide por el total, sin promediar tasas: un tramo con cuatro posiciones no puede pesar lo mismo que uno con quinientas.

Los tests de la exactitud se pueden saltar en la primera lectura: comprueban los tramos y el top-1/top-3 con un modelo falso. Si los saltas, tráelos: git checkout p2 -- tests/unit/test_accuracy.py, que el «Ejecútalo» «Exactitud, puzles y caché» ejecuta.

// Teststests/unit/test_accuracy.py: un modelo con logits a la carta, las fronteras de tramo y el bando que mueve
tests/unit/test_accuracy.py
"""Tests for rukh.eval.accuracy: Elo bands and top-1/top-3 agreement with the human move."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
import torch
from rukh.eval.accuracy import accuracy, elo_band
from rukh.eval.legality import position_at
from rukh.models 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)
GAME = "e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 b5a4 g8f6"
@pytest.fixture(scope="module")
def tok() -> UciTokenizer:
return UciTokenizer()
class RankedModel:
"""A stand-in for the decoder whose logits rank a fixed list of tokens first."""
def __init__(self, tok: UciTokenizer, ranking: list[str]) -> None:
self.ids = [tok.vocab[token] for token in ranking]
self.vocab_size = len(tok)
self.cfg = TOY # the prompt is cropped to the model's block, so the stub needs one
def parameters(self) -> Iterator[torch.Tensor]:
return iter([torch.zeros(1)])
def next_logits(self, idx: torch.Tensor) -> torch.Tensor:
logits = torch.full((idx.shape[0], self.vocab_size), -10.0)
for rank, token_id in enumerate(self.ids):
logits[:, token_id] = 10.0 - rank
return logits

tests/unit/test_accuracy.pylíneas 1-41 · p2

RankedModel es un modelo falso que devuelve los logits que tú le digas: con el modelo real, un test de exactitud solo podría comprobar que el número está entre 0 y 1. Necesita un cfg porque la métrica recorta el prompt al bloque del modelo: un falso tiene que implementar la superficie que su llamante usa, no la que uno cree que usa.

tests/unit/test_accuracy.py
@pytest.mark.parametrize(
("elo", "expected"),
[
(1200, "<1800"),
(1799, "<1800"),
(1800, "1800-2000"),
(1999, "1800-2000"),
(2000, "2000-2200"),
(2599, "2400-2600"),
(2600, "2600+"),
(3000, "2600+"),
],
)
def test_elo_band_cuts_every_two_hundred_points(elo: int, expected: str) -> None:
assert elo_band(elo) == expected
def test_top1_and_top3_see_the_ranking(tok: UciTokenizer) -> None:
moves = GAME.split()
positions = [position_at(tok, 1, moves, 1, 1850, 1950)] # the human played e7e5
exact = accuracy(RankedModel(tok, ["e7e5", "a7a6", "b8c6"]), tok, positions) # type: ignore[arg-type]
assert (exact.top1, exact.top3) == (1.0, 1.0)
third = accuracy(RankedModel(tok, ["a7a6", "b8c6", "e7e5"]), tok, positions) # type: ignore[arg-type]
assert (third.top1, third.top3) == (0.0, 1.0)
missed = accuracy(RankedModel(tok, ["a7a6", "b8c6", "g8f6"]), tok, positions) # type: ignore[arg-type]
assert (missed.top1, missed.top3) == (0.0, 0.0)

tests/unit/test_accuracy.pylíneas 44-69 · p2

Fronteras de tramo parametrizadas y los tres casos del ranking; que el segundo dé (0.0, 1.0) es la definición ejecutable de la diferencia entre top-1 y top-3.

tests/unit/test_accuracy.py
def test_positions_are_filed_under_the_band_of_the_side_to_move(tok: UciTokenizer) -> None:
moves = GAME.split()
positions = [
position_at(tok, 1, moves, 1, 1850, 2150), # Black to move: 2150 -> 2000-2200
position_at(tok, 1, moves, 2, 1850, 2150), # White to move: 1850 -> 1800-2000
]
result = accuracy(RankedModel(tok, ["e7e5"]), tok, positions) # type: ignore[arg-type]
bands = {band.band: band for band in result.bands}
assert set(bands) == {"1800-2000", "2000-2200"}
assert bands["2000-2200"].top1 == 1.0
assert bands["1800-2000"].top1 == 0.0
assert result.top1 == pytest.approx(0.5)
def test_positions_without_a_human_move_are_skipped(tok: UciTokenizer) -> None:
moves = GAME.split()
end = position_at(tok, 1, moves, len(moves), 1850, 1950)
assert end.target is None
assert accuracy(RankedModel(tok, ["e7e5"]), tok, [end]).positions == 0 # type: ignore[arg-type]
def test_accuracy_of_nothing_is_zero(tok: UciTokenizer) -> None:
result = accuracy(RankedModel(tok, ["e7e5"]), tok, []) # type: ignore[arg-type]
assert (result.positions, result.top1, result.top3) == (0, 0.0, 0.0)
def test_accuracy_runs_on_the_real_decoder(tok: UciTokenizer) -> None:
torch.manual_seed(0)
model = MoveDecoder(TOY).eval()
moves = GAME.split()
positions = [position_at(tok, 1, moves, ply, 1850, 1950) for ply in range(1, 6)]
result = accuracy(model, tok, positions)
assert result.positions == 5
assert result.top1 <= result.top3

tests/unit/test_accuracy.pylíneas 72-105 · p2

Dos posiciones de la misma partida, una con cada bando en juego, tienen que caer en tramos distintos. Si la métrica archivara por el Elo de las blancas, el test fallaría.

puzzles.py: el criterio duro

src/rukh/eval/puzzles.py
"""Tactical puzzles: a puzzle counts only when the whole solution line is played.
Lichess puzzles are ``fen`` (the position *before* the opponent's last move) plus ``moves``:
the opponent's move first, then the solution, alternating. The opponent's replies are forced, so
they are simply pushed; every move at an odd index is the model's turn and must match exactly.
A wrong move ends the attempt, and ``correct`` records how far down the line it got, which is
what makes a partially solved puzzle distinguishable from a first-move miss.
The model only ever sees the moves of the puzzle line, never the game that produced the
position: a move-sequence model cannot be handed a FEN. The header is a fixed Elo pair, so the
prompt looks like the start of a game between two players of that strength. It is a handicap the
number has to be read with, not a bug.
"""

src/rukh/eval/puzzles.pylíneas 1-13 · p2

El criterio: un puzle de Lichess es un FEN y una lista de jugadas alternadas. Las del rival se empujan sin preguntar y las del modelo tiene que acertarlas; un fallo termina el intento. Contar un puzle solo con la línea entera correcta mide encontrar la jugada que gana, no parecerse a un humano.

Y una limitación declarada: el modelo solo ve las jugadas del puzle, nunca la partida de la que salió, porque no puede recibir un FEN. La cabecera es un par de Elo fijo, así que el prompt parece el principio de una partida que en realidad lleva treinta jugadas. El número que sale es bajo, y bajo es creíble para un modelo pequeño: justo lo que hace peligrosa a una métrica, porque nadie tira del hilo de un número razonable. La lección siguiente mide cuánto cuesta.

src/rukh/eval/puzzles.py
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from typing import Protocol
import chess
from pydantic import BaseModel, ConfigDict
from rukh.eval.cache import EvalCache
from rukh.eval.legality import header
from rukh.infer import SampleConfig, model_generator, pick_move, prompt_ids
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
PUZZLE_COLUMNS = ["puzzle_id", "fen", "moves", "rating", "band", "split"]
HEADER_ELO = 1800
SUITE = "puzzles"

src/rukh/eval/puzzles.pylíneas 15-32 · p2

src/rukh/eval/puzzles.py
class PuzzleItem(BaseModel):
"""One puzzle as the harness needs it."""
model_config = ConfigDict(extra="forbid")
puzzle_id: str
fen: str
moves: list[str]
rating: int
band: str
class MoveSource(Protocol):
"""Anything that answers one move for a position: the model, or a script in the tests."""
def __call__(self, board: chess.Board, history: list[int]) -> chess.Move | None: ...

src/rukh/eval/puzzles.pylíneas 35-50 · p2

MoveSource es otro Protocol: los tests guionizan respuestas sin construir un modelo.

src/rukh/eval/puzzles.py
def model_source(
model: MoveDecoder, tok: UciTokenizer, cfg: SampleConfig | None = None
) -> MoveSource:
"""A ``MoveSource`` backed by the masked sampler (temperature 0 by default: argmax)."""
sample = (cfg or SampleConfig(temperature=0.0)).model_copy(update={"mask_illegal": True})
generator = model_generator(model, sample)
def choose(board: chess.Board, history: list[int]) -> chess.Move | None:
move, _ = pick_move(model, tok, board, history, sample, generator)
return move
return choose

src/rukh/eval/puzzles.pylíneas 53-64 · p2

El muestreador de puzles es enmascarado y a temperatura 0. Aquí no se pregunta si el modelo conoce las reglas, sino si encuentra la jugada que gana, y dejar que la temperatura le haga fallar mediría el muestreador.

src/rukh/eval/puzzles.py
class PuzzleAttempt(BaseModel):
"""What happened on one puzzle."""
model_config = ConfigDict(extra="forbid")
puzzle_id: str
band: str
rating: int
solved: bool
correct: int
"""Model moves played correctly before the first mistake (or the whole line)."""
total: int
"""Model moves the line asks for."""

src/rukh/eval/puzzles.pylíneas 67-79 · p2

correct, las jugadas acertadas antes del primer fallo, no entra en la tasa, pero deja medido si el modelo falla al principio o al final de las líneas.

src/rukh/eval/puzzles.py
def solve_puzzle(
source: MoveSource,
tok: UciTokenizer,
item: PuzzleItem,
header_elo: int = HEADER_ELO,
block: int = 200,
) -> PuzzleAttempt:
"""Play the puzzle line; stop at the first move that is not the expected one."""
board = chess.Board(item.fen)
history = header(tok, header_elo, header_elo)
expected = [uci for index, uci in enumerate(item.moves) if index % 2 == 1]
correct = 0
for index, uci in enumerate(item.moves):
move = chess.Move.from_uci(uci)
if index % 2 == 1:
played = source(board, prompt_ids(history, block))
if played is None or played.uci() != uci:
break
correct += 1
board.push(move)
history.append(tok.vocab.get(uci, tok.unk_id))
return PuzzleAttempt(
puzzle_id=item.puzzle_id,
band=item.band,
rating=item.rating,
solved=correct == len(expected) and bool(expected),
correct=correct,
total=len(expected),
)

src/rukh/eval/puzzles.pylíneas 82-110 · p2

La jugada del modelo se compara como cadena UCI, y el break corta la línea en el primer fallo, porque una línea táctica desviada deja de tener sentido. El and bool(expected) evita contar como resuelto un puzle que no le pide nada al modelo. Y el historial crece con todas las jugadas, las del rival incluidas.

src/rukh/eval/puzzles.py
class BandPuzzles(BaseModel):
"""Solved rate inside one difficulty band."""
model_config = ConfigDict(extra="forbid")
band: str
attempted: int
solved: int
rate: float
class PuzzleResult(BaseModel):
"""Solved rate overall and per band."""
model_config = ConfigDict(extra="forbid")
attempted: int
solved: int
rate: float
bands: list[BandPuzzles]
def by_band(self) -> dict[str, float]:
return {band.band: band.rate for band in self.bands}

src/rukh/eval/puzzles.pylíneas 113-135 · p2

Lo que queda de puzzles.py es recorrido, resumen y carga del parquet; el criterio ya está arriba, así que puedes saltarlo al leer y quedarte con el párrafo de debajo. Tecléalo igual: run_puzzles está ahí, y los tests de la caché y de los puzles lo llaman.

// Fontaneríapuzzles.py, el resto: recorrer los puzles con la caché, resumir por banda y cargar la partición test
src/rukh/eval/puzzles.py
def run_puzzles(
source: MoveSource,
tok: UciTokenizer,
items: Sequence[PuzzleItem],
cache: EvalCache | None = None,
header_elo: int = HEADER_ELO,
) -> PuzzleResult:
"""Attempt every puzzle, reusing the cached attempts of a previous run of the same weights."""
attempts: list[PuzzleAttempt] = []
for item in items:
cached = cache.get(SUITE, item.puzzle_id) if cache is not None else None
if cached is not None:
attempts.append(PuzzleAttempt.model_validate(cached))
continue
attempt = solve_puzzle(source, tok, item, header_elo=header_elo)
if cache is not None:
cache.put(SUITE, item.puzzle_id, attempt.model_dump())
attempts.append(attempt)
return summarize(attempts)

src/rukh/eval/puzzles.pylíneas 138-156 · p2

src/rukh/eval/puzzles.py
def summarize(attempts: Sequence[PuzzleAttempt]) -> PuzzleResult:
"""Fold attempts into overall and per-band rates."""
counts: dict[str, int] = {}
solved: dict[str, int] = {}
for attempt in attempts:
counts[attempt.band] = counts.get(attempt.band, 0) + 1
solved[attempt.band] = solved.get(attempt.band, 0) + int(attempt.solved)
total = sum(counts.values())
return PuzzleResult(
attempted=total,
solved=sum(solved.values()),
rate=sum(solved.values()) / total if total else 0.0,
bands=[
BandPuzzles(
band=band,
attempted=counts[band],
solved=solved.get(band, 0),
rate=solved.get(band, 0) / counts[band],
)
for band in sorted(counts)
],
)

src/rukh/eval/puzzles.pylíneas 159-180 · p2

src/rukh/eval/puzzles.py
def load_puzzles(path: Path, per_band: int, seed: int = 0, split: str = "test") -> list[PuzzleItem]:
"""Read up to ``per_band`` puzzles of the given split from the P1 puzzle parquet."""
import polars as pl
frame = (
pl.scan_parquet(Path(path).as_posix())
.select(PUZZLE_COLUMNS)
.filter(pl.col("split") == split)
.collect()
)
items: list[PuzzleItem] = []
for band in sorted(frame["band"].unique().to_list()):
rows = (
frame.filter(pl.col("band") == band)
.sample(n=min(per_band, frame.filter(pl.col("band") == band).height), seed=seed)
.rows(named=True)
)
items.extend(
PuzzleItem(
puzzle_id=str(row["puzzle_id"]),
fen=str(row["fen"]),
moves=str(row["moves"]).split(),
rating=int(row["rating"]),
band=str(row["band"]),
)
for row in rows
)
return items

src/rukh/eval/puzzles.pylíneas 183-210 · p2

La carga muestrea por banda de dificultad: del total saldría la mezcla del parquet, y la pendiente por dificultad, la señal de que la métrica mide algo real, tendría tamaños dispares. Y lee la partición test que M1 hizo con un hash del identificador, para que un futuro entrenamiento con puzles no se evalúe sobre lo que ha visto.

cache.py: repetir una suite sin volver a jugarla

La caché no cambia ninguna métrica: solo evita repetir partidas y puzles ya resueltos al relanzar una suite. Es fontanería y puedes saltarla en la primera lectura. Si la saltas, tráela: git checkout p2 -- src/rukh/eval/cache.py tests/unit/test_cache.py; puzzles.py la importa, así que sin cache.py ni los tests de puzles arrancan.

// Fontaneríacache.py y sus tests: SQLite con clave (model_sha, suite, item_id) para no volver a jugar lo ya jugado
src/rukh/eval/cache.py
"""SQLite cache of finished evaluation items, keyed by ``(model_sha, suite, item_id)``.
Games against Stockfish and puzzle lines are the expensive part of the harness: a full suite is
hundreds of games and thousands of puzzles. Every finished item is stored as JSON under the SHA
of the weights that produced it, so re-running a suite after a crash (or after adding one rung)
only pays for what is missing. ``--no-cache`` builds a disabled cache: it reads nothing and
writes nothing, which is what a benchmark of the harness itself wants.
The key is the weights **and** the settings that change what an item means: temperature, top-k,
the engine's move time, the ply limit, the rung definitions, the number of games and the seed.
Without them, lowering the temperature or raising the move time would silently reuse games
played under the old settings and report them as the new ones.
"""

src/rukh/eval/cache.pylíneas 1-13 · p2

Los puzles y las partidas son la parte cara: la caché guarda cada ítem terminado, y repetir la suite tras un fallo solo paga lo que falta. Pero es correcta solo porque la clave son los pesos y los ajustes que cambian lo que un ítem significa; sin ellos, bajar la temperatura reutilizaría en silencio partidas jugadas con la anterior. En una caché, acertar la clave es todo el diseño.

src/rukh/eval/cache.py
from __future__ import annotations
import hashlib
import json
import sqlite3
from collections.abc import Mapping
from pathlib import Path
from types import TracebackType
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS items (
model_sha TEXT NOT NULL,
suite TEXT NOT NULL,
item_id TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (model_sha, suite, item_id)
)
"""
CHUNK = 1 << 20
SHA_PREFIX = 16
"""Characters of each SHA kept in the key: enough to identify, short enough to read."""

src/rukh/eval/cache.pylíneas 15-36 · p2

src/rukh/eval/cache.py
def config_sha(fields: Mapping[str, Any]) -> str:
"""SHA-256 of the evaluation-relevant settings, as canonical JSON."""
payload = json.dumps(dict(fields), sort_keys=True, default=str, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def file_sha(path: Path) -> str:
"""SHA-256 of a file, read in chunks (a checkpoint does not fit comfortably in memory)."""
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
while chunk := handle.read(CHUNK):
digest.update(chunk)
return digest.hexdigest()

src/rukh/eval/cache.pylíneas 39-51 · p2

sort_keys=True hace que el orden del diccionario no cambie el hash; file_sha lee por trozos porque un checkpoint de medium son 460 MB.

src/rukh/eval/cache.py
class EvalCache:
"""Key-value store of evaluation items; ``enabled=False`` turns every call into a no-op."""
def __init__(
self,
path: Path | None,
model_sha: str,
enabled: bool = True,
config_sha: str | None = None,
) -> None:
self.model_sha = model_sha
self.config_sha = config_sha
self.key = (
model_sha if not config_sha else f"{model_sha[:SHA_PREFIX]}:{config_sha[:SHA_PREFIX]}"
)
self.enabled = enabled and path is not None
self.path = Path(path) if path is not None else None
self._conn: sqlite3.Connection | None = None
if self.enabled and self.path is not None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(self.path)
self._conn.execute(SCHEMA)
self._conn.commit()

src/rukh/eval/cache.pylíneas 54-76 · p2

enabled=False no abre la base y convierte las operaciones en no-ops: una forma limpia de implementar --no-cache sin llenar el harness de if. La clave corta cada hash a 16 caracteres, 64 bits, de sobra para no colisionar.

src/rukh/eval/cache.py
def get(self, suite: str, item_id: str) -> dict[str, Any] | None:
"""The stored payload of one item, or None when it has not been computed yet."""
if self._conn is None:
return None
row = self._conn.execute(
"SELECT payload FROM items WHERE model_sha = ? AND suite = ? AND item_id = ?",
(self.key, suite, item_id),
).fetchone()
if row is None:
return None
payload = json.loads(row[0])
return payload if isinstance(payload, dict) else None
def put(self, suite: str, item_id: str, payload: Mapping[str, Any]) -> None:
"""Store (or replace) one finished item."""
if self._conn is None:
return
self._conn.execute(
"INSERT OR REPLACE INTO items (model_sha, suite, item_id, payload) VALUES (?, ?, ?, ?)",
(self.key, suite, item_id, json.dumps(dict(payload), default=str)),
)
self._conn.commit()

src/rukh/eval/cache.pylíneas 78-99 · p2

src/rukh/eval/cache.py
def count(self, suite: str | None = None) -> int:
"""How many items are stored for this model (optionally for one suite only)."""
if self._conn is None:
return 0
if suite is None:
query = "SELECT COUNT(*) FROM items WHERE model_sha = ?"
args: tuple[str, ...] = (self.key,)
else:
query = "SELECT COUNT(*) FROM items WHERE model_sha = ? AND suite = ?"
args = (self.key, suite)
return int(self._conn.execute(query, args).fetchone()[0])

src/rukh/eval/cache.pylíneas 101-111 · p2

src/rukh/eval/cache.py
def close(self) -> None:
if self._conn is not None:
self._conn.close()
self._conn = None
def __enter__(self) -> EvalCache:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.close()

src/rukh/eval/cache.pylíneas 113-127 · p2

INSERT OR REPLACE sobrescribe en vez de fallar, y el commit por escritura hace que una suite cortada a las dos horas conserve todo lo hecho.

tests/unit/test_cache.py
"""Tests for rukh.eval.cache: keying by weights, isolation and skipping finished work."""
from __future__ import annotations
from pathlib import Path
import chess
import pytest
from rukh.eval.cache import EvalCache, config_sha, file_sha
from rukh.eval.puzzles import PuzzleItem, run_puzzles
from rukh.tokenize.uci_vocab import UciTokenizer
pytestmark = pytest.mark.unit
SCHOLAR = PuzzleItem(
puzzle_id="p1",
fen="r1bqkbnr/pppp1ppp/2n5/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 3 3",
moves=["g8f6", "h5f7"],
rating=1100,
band="1000-1500",
)
@pytest.fixture
def tok() -> UciTokenizer:
return UciTokenizer()
class CountingSource:
"""A scripted move source that records how many times it was asked."""
def __init__(self, moves: list[str]) -> None:
self.moves = moves
self.calls = 0
def __call__(self, board: chess.Board, history: list[int]) -> chess.Move | None:
move = self.moves[min(self.calls, len(self.moves) - 1)]
self.calls += 1
return chess.Move.from_uci(move)

tests/unit/test_cache.pylíneas 1-40 · p2

tests/unit/test_cache.py
def test_put_and_get_round_trip(tmp_path: Path) -> None:
with EvalCache(tmp_path / "cache.sqlite", "sha-a") as cache:
cache.put("elo", "uci-1500:0", {"score": 1.0, "plies": 42})
assert cache.get("elo", "uci-1500:0") == {"score": 1.0, "plies": 42}
assert cache.get("elo", "uci-1500:1") is None
assert cache.count("elo") == 1
def test_different_weights_do_not_share_results(tmp_path: Path) -> None:
path = tmp_path / "cache.sqlite"
with EvalCache(path, "sha-a") as first:
first.put("puzzles", "p1", {"solved": True})
with EvalCache(path, "sha-b") as second:
assert second.get("puzzles", "p1") is None
with EvalCache(path, "sha-a") as again:
assert again.get("puzzles", "p1") == {"solved": True}
def test_a_disabled_cache_never_reads_or_writes(tmp_path: Path) -> None:
path = tmp_path / "cache.sqlite"
with EvalCache(path, "sha-a") as warm:
warm.put("puzzles", "p1", {"solved": True})
with EvalCache(path, "sha-a", enabled=False) as cold:
assert cold.get("puzzles", "p1") is None
cold.put("puzzles", "p2", {"solved": False})
assert cold.count() == 0
with EvalCache(path, "sha-a") as check:
assert check.get("puzzles", "p2") is None

tests/unit/test_cache.pylíneas 43-70 · p2

Ida y vuelta, aislamiento por pesos y la caché desactivada, que ni lee ni escribe.

tests/unit/test_cache.py
def test_a_cache_without_a_path_is_a_no_op() -> None:
with EvalCache(None, "sha-a") as cache:
cache.put("puzzles", "p1", {"solved": True})
assert cache.get("puzzles", "p1") is None
def test_the_cache_stops_a_puzzle_from_being_replayed(tmp_path: Path, tok: UciTokenizer) -> None:
source = CountingSource(["h5f7"])
with EvalCache(tmp_path / "cache.sqlite", "sha-a") as cache:
first = run_puzzles(source, tok, [SCHOLAR], cache=cache)
assert source.calls == 1
second = run_puzzles(source, tok, [SCHOLAR], cache=cache)
assert source.calls == 1
assert second.model_dump() == first.model_dump()
def test_without_a_cache_the_puzzle_is_replayed(tok: UciTokenizer) -> None:
source = CountingSource(["h5f7"])
run_puzzles(source, tok, [SCHOLAR])
run_puzzles(source, tok, [SCHOLAR])
assert source.calls == 2

tests/unit/test_cache.pylíneas 73-93 · p2

El test que prueba que la caché sirve para algo: CountingSource cuenta las jugadas pedidas, el mismo puzle se resuelve dos veces y el contador se queda en 1. Medir el ahorro en vez de suponerlo.

tests/unit/test_cache.py
def test_different_settings_do_not_share_results(tmp_path: Path) -> None:
path = tmp_path / "cache.sqlite"
slow = config_sha({"temperature": 0.6, "elo_move_time": 0.1})
fast = config_sha({"temperature": 0.6, "elo_move_time": 0.05})
assert slow != fast
with EvalCache(path, "sha-a", config_sha=slow) as first:
first.put("elo", "uci-1500:0", {"score": 1.0})
with EvalCache(path, "sha-a", config_sha=fast) as second:
assert second.get("elo", "uci-1500:0") is None # the move time changed the games
assert second.count("elo") == 0
with EvalCache(path, "sha-a", config_sha=slow) as again:
assert again.get("elo", "uci-1500:0") == {"score": 1.0}
def test_the_config_hash_ignores_key_order_and_reads_every_field() -> None:
fields = {"temperature": 0.6, "top_k": 20, "seed": 42}
assert config_sha(fields) == config_sha(dict(reversed(list(fields.items()))))
assert config_sha(fields) != config_sha({**fields, "top_k": 10})

tests/unit/test_cache.pylíneas 96-113 · p2

El que protege la corrección: dos configuraciones que solo difieren en el tiempo por jugada no comparten partidas. Sin eso, bajar elo_move_time para una prueba y volver a subirlo contaminaría la tabla publicada.

tests/unit/test_cache.py
def test_the_weights_sha_is_still_what_the_report_shows(tmp_path: Path) -> None:
cache = EvalCache(tmp_path / "cache.sqlite", "sha-a", config_sha="deadbeef")
assert cache.model_sha == "sha-a"
assert cache.key.startswith("sha-a:")
cache.close()
def test_file_sha_identifies_the_weights(tmp_path: Path) -> None:
first = tmp_path / "a.pt"
second = tmp_path / "b.pt"
first.write_bytes(b"weights")
second.write_bytes(b"weights")
assert file_sha(first) == file_sha(second)
second.write_bytes(b"other")
assert file_sha(first) != file_sha(second)

tests/unit/test_cache.pylíneas 117-131 · p2

Y que model_sha siga siendo el de los pesos, y que el hash dependa del contenido y no del nombre.

Un aparte fuera de la evaluación: data/db.py

M2 toca un fichero de M1 por un error de memoria de los que no se ven venir. No tiene que ver con las métricas: lo trajiste con el comando del principio y aquí solo se explica qué cambió.

// Fontaneríadata/db.py: preserve_insertion_order apagado en la conexión de DuckDB
src/rukh/data/db.py
It also turns off ``preserve_insertion_order``. With it on (the default) a ``COPY`` of a large
streaming scan buffers the whole result so the output rows keep the input order; the first real
fetch of a month grew to 20 GB of resident memory for a 2.5 GB file. Nothing downstream cares
about the order of the games, so the buffering buys nothing and costs the machine.
"""

src/rukh/data/db.pylíneas 9-13 · p2

src/rukh/data/db.py
memory_limit: str = "32GB"
threads: int = Field(default=0, ge=0)
"""``0`` = ``cpu_count()``."""
preserve_insertion_order: bool = False
"""Keep the input order in the output. Off: it buffers the whole result of a ``COPY``."""

src/rukh/data/db.pylíneas 35-39 · p2

src/rukh/data/db.py
con.execute("SET temp_directory = ?", [temp_directory().as_posix()])
con.execute("SET memory_limit = ?", [settings.memory_limit])
con.execute("SET threads = ?", [settings.threads or (os.cpu_count() or 4)])
con.execute("SET preserve_insertion_order = ?", [settings.preserve_insertion_order])

src/rukh/data/db.pylíneas 55-58 · p2

Una línea y un campo, y detrás hay 20 GB. Con preserve_insertion_order encendido, el valor por defecto de DuckDB, un COPY de un escaneo grande almacena el resultado entero para conservar el orden de las filas: la primera lectura de un mes de partidas llegó a 20 GB de memoria para un fichero de 2,5 GB. A nadie le importa aquí el orden, así que ese búfer no compra nada. Un valor por defecto razonable puede ser carísimo en tu caso, y se anota en la configuración que lo controla.

Qué has aprendido

Las tres métricas que se miden sobre posiciones, con sus supuestos pegados al número: la legalidad en sus dos definiciones, la exactitud por tramo sobre logits crudos y los puzles con la línea completa y su handicap declarado. Y una caché cuya clave incluye todo lo que cambia el significado de lo que guarda.

Los tests de las cuatro piezas en verde. Las cifras de small llegan en la lección siguiente, que ensambla estas piezas con las partidas contra Stockfish: ajustar un Elo, ponerle un intervalo honesto y comprobar la escalera de rivales contra la que se mide.