// M4 · lección 07
Diversidad y barridos: dos veredictos en vez de uno
eval/diversity.py —la entropía de aperturas que llevaba tres hitos saliendo n/a, en dos números que fallan de maneras distintas— y eval/sweep.py —la suite ejecutada una vez por condición con un solo número cambiado—, más la recalibración de la escalera de Stockfish, el force_header que hace legible la tabla y las ocho configuraciones de evaluación del hito.
Lección 7 de 10 del módulo «Fine-tuning e instrucción». Viene de «Adaptadores y publicación» y sigue en «El selector en la demo».
Qué vas a construir
Los dos instrumentos con los que se mide este hito. src/rukh/eval/diversity.py mide cuántas
partidas distintas está dispuesto a jugar un modelo, con dos números que fallan de maneras
distintas. src/rukh/eval/sweep.py corre la suite entera una vez por condición cambiando un solo
número, y emite dos veredictos: si las estimaciones suben y si los intervalos se separan. Por el
camino, la escalera de Stockfish se recalibra, y eso mueve el Elo de todas las etapas publicadas.
Cómo se mide y contra qué
«Monótono» y «separado» no son lo mismo
El plan del hito pide Elo por condición monótono (1500 < 2000 < 2400 con IC). Esa frase esconde dos afirmaciones, una barata y otra que vale.
Monótono: las estimaciones puntuales suben con la condición. Con tres números y ruido, salen ordenados por azar una vez de cada seis.
Separado: los intervalos de confianzaIntervalo de confianzaRango dentro del que cabría la medida si se repitiera; Rukh publica el del 95 % por bootstrap (500 remuestreos) junto a cada Elo. Dos estimaciones solo se pueden declarar distintas si sus intervalos no se solapan: que los puntos estén ordenados («monótono») es barato y sale por azar; que los intervalos se separen es lo que sobrevive al ruido. Cuando se solapan, la aritmética n > 3,84 / (Δp)² dice si faltan partidas o si no hay diferencia, y el suelo de reproducibilidad de la escalera pone un límite que más partidas no bajan: M4 lo estimó en unos ±40 Elo de una sigma con dos tiradas, y M6 lo revisó con cuatro (con la máquina parada, el reloj repite al nivel del ruido de muestreo, D-137). consecutivos no se
solapan, es decir, la diferencia sobrevive al ruido de la medición.
Tres equipos con 40, 41 y 42 puntos en una clasificación que se mide con ±3 de error están ordenados, y no se sabe cuál es mejor. Pasa igual al comparar dos versiones de un LLM en un benchmark de cien preguntas: 71 aciertos contra 68 ordenan, pero no separan.
Y si no están separados, queda una tercera pregunta: ¿faltan partidas o no hay diferencia? En el informe se escriben igual. Se contesta con aritmética, no con más máquina, y el lab 6 lo hace con los números delante.
Un solo número cambia entre filas
El barrido corre la misma suite una vez por condición: mismos rivales, posiciones, puzles, semilla y
temperatura. Solo cambian los dos tokens de cabecera, así que cualquier diferencia entre filas es la
condición. Un detalle casi rompe esa propiedad: los puzles llevan el Elo real de su partida y la
suite lo usa, así que la columna de puzles habría salido idéntica en todas las filas, como si la
condición no hiciera nada. De ahí force_header, más abajo.
¿Juega peor o juega ilegal?
La legalidadTasa de legalidadPorcentaje de jugadas propuestas por el modelo que son legales cuando no se le enmascaran las ilegales. Es la primera métrica de la tabla única: mide si el modelo ha aprendido las reglas solo viendo partidas. En la demo, la legalidad la decide chess.js; el modelo solo propone. sin máscara vuelve al barrido como columna propia. Si el modelo condicionado a 1200 empeora, puede ser eligiendo jugadas legales peores, como un humano débil, o proponiendo jugadas que no existen, como un modelo que ha dejado de entender el tablero. Lo primero es un dial que funciona. Lo segundo es un dial roto con aspecto de funcionar, porque la máscara de la demo esconde las jugadas ilegales y el jugador solo vería un rival más flojo.
Entropía de aperturas: la métrica que llevaba tres hitos vacía
El spec del proyecto lista desde el primer día una columna de diversidad, entropía de las aperturas jugadas en 200 partidas propias, que hasta ahora ninguna afirmación necesitaba. Este módulo sí: el spec predice del afinado con maestros que sube el Elo y baja la diversidad, y una tabla que solo mide la primera mitad no puede comprobar la frase.
diversity.py: dos números y una razón
El docstring del módulo repite lo que cuentan los dos párrafos de debajo; se puede saltar. Casi todo
el fichero va plegado, así que si vas a saltar los pliegues, trae diversity.py antes de
teclear nada de él y lee el bloque visible contra él: git checkout p4 -- src/rukh/eval/diversity.py.
// FontaneríaEl docstring de diversity.py y sus importaciones
"""Opening diversity: how many different games the model is willing to play.
``docs/spec/02`` lists it in the evaluation table -- "entropy of the openings played in 200 gamesof its own" -- and it has been ``n/a`` in every report so far. M4 needs it, because the claim themasters fine-tune makes is precisely a trade: *the Elo goes up and the diversity goes down*. Anumber that only measures the first half of that sentence cannot check it.
Two numbers, because they answer different questions and fail in different ways.
**The line entropy** plays ``games`` openings of the model against itself and measures theentropy of the distribution of distinct opening lines. It is the metric the spec asks for and itdepends on the sampling: read at the deterministic setting the demo plays at, a decoder producesone single line and the entropy is exactly zero, which is true but says more about the samplerthan about the weights. So it is reported together with the temperature it was read at, likeevery other number in this project (D-047).
**The first-move entropy** is analytic: the entropy of the model's own distribution over thetwenty legal first moves, with no sampling at all. It is a property of the weights, it has novariance between runs, and it is the one to compare across stages. A model that has beenfine-tuned onto a narrow repertoire collapses it whatever the temperature.
Self-play is batched: every game advances one ply at a time, all of them in one forward pass, so200 games of 12 plies are 12 passes of batch 200 rather than 2 400 passes of batch 1."""
from __future__ import annotations
import mathfrom collections import Counterfrom typing import TYPE_CHECKING
import chessimport torchfrom pydantic import BaseModel, ConfigDict, Field
from rukh.eval.legality import headerfrom rukh.infer.sampler import SampleConfig, legal_token_ids, prompt_ids
if TYPE_CHECKING: from rukh.models import MoveDecoder from rukh.tokenize.uci_vocab import UciTokenizer
__all__ = [ "DiversityResult", "first_move_distribution", "first_move_entropy", "opening_diversity", "shannon_entropy",]La entropía de líneas juega N aperturas del modelo contra sí mismo y mide la entropía de Shannon de las líneas distintas. Es la que pide el spec y depende del muestreo: al ajuste casi determinista con el que se comparan las etapas, cualquier decoder juega una sola partida y saca 0, lo que dice más del muestreador que de los pesos. Por eso se lee a su propia temperatura y se publica con ella al lado.
La entropía de la primera jugada es analítica: la distribución del modelo sobre las veinte
primeras jugadas legales, sin muestreo. Es una propiedad de los pesos y se puede comparar entre
etapas tal cual. Las auto-partidas van en lote, un 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. a la vez: 200 aperturas de 12 plies son 12 pasadas de lote 200.
La configuración y la función de entropía son fontanería; se pueden saltar. Lo que importa es la
medición analítica que sigue. Si las saltas sin haber traído el fichero, git diff p4 -- src/rukh/eval/diversity.py te enseña las líneas que te faltan.
// FontaneríaDiversityConfig y la entropía de Shannon de un recuento
OPENING_PLIES = 12"""Six moves a side: long enough to have left the book, short enough that two games sharing itreally are the same opening."""
class DiversityResult(BaseModel): """What ``opening_diversity`` measured, with the sampling it was measured at."""
model_config = ConfigDict(extra="forbid")
games: int plies: int distinct_lines: int entropy_bits: float """Entropy of the distribution over opening lines, in bits.""" max_entropy_bits: float """``log2(games)``: what the entropy would be if every game opened differently.""" normalised: float = Field(ge=0.0, le=1.0) """``entropy_bits / max_entropy_bits``: 1 is a different opening every game, 0 is always the same one. It is what makes two runs with different ``games`` comparable.""" first_move_entropy_bits: float """Entropy of the model's distribution over the legal first moves; no sampling involved.""" temperature: float top_k: int | None top_lines: list[tuple[str, int]] """The most played lines and how often, for the report; a collapsed model shows it here."""Doce plies: a dos, todo el mundo juega lo mismo; a cuarenta, nadie repite nada. Y normalised
divide la entropía por su techo, log2(games), porque «4,2 bits» significa cosas distintas con 200
partidas que con 1 000.
def shannon_entropy(counts: dict[str, int] | Counter[str]) -> float: """Entropy of a distribution given as counts, in bits. Empty or single-valued gives 0.""" total = sum(counts.values()) if total <= 0: return 0.0 bits = -sum((n / total) * math.log2(n / total) for n in counts.values() if n > 0) # A single outcome gives `-1 * log2(1)`, which is negative zero and renders as "-0.000". return 0.0 if bits == 0 else bitsEl comentario evita el -0.000 que daría un solo resultado en coma flotante: nadie lo confunde con
un error, y todo el mundo lo lee dos veces.
@torch.no_grad()def first_move_distribution( model: MoveDecoder, tok: UciTokenizer, header_elo: int = 1800) -> dict[str, float]: """The model's own probability for each of the twenty legal first moves, renormalised.
No temperature, no top-k, no sampling: the softmax as the weights produce it. That makes it the right instrument for measuring what a style adapter *did* -- the share it moved towards ``e2e4`` is a property of the weights, with no seed in it and no variance between runs, which self-play openings could not give without playing hundreds of games to average the noise out. """ board = chess.Board() device = next(model.parameters()).device idx = torch.tensor([header(tok, header_elo, header_elo)], dtype=torch.long, device=device) logits = model.next_logits(idx)[0].float() legal = legal_token_ids(board, tok) kept = logits[torch.tensor(legal, dtype=torch.long, device=device)] probs = torch.softmax(kept, dim=-1) return {tok.ids[token]: float(p) for token, p in zip(legal, probs.tolist(), strict=True)}
def first_move_entropy(model: MoveDecoder, tok: UciTokenizer, header_elo: int = 1800) -> float: """Entropy in bits of the model's own distribution over the legal first moves.
No temperature, no top-k, no sampling: the softmax as the weights produce it, restricted to the twenty legal moves and renormalised. The ceiling is ``log2(20) = 4.32`` bits for a model that has no opinion at all; a strong human-imitating model sits well below that, and one fine-tuned onto a single repertoire collapses towards zero. """ probs = [p for p in first_move_distribution(model, tok, header_elo).values() if p > 0] return float(-sum(p * math.log2(p) for p in probs))La medición más barata y limpia del módulo: una pasada hacia delante sobre tres tokens y una softmax sobre veinte logits, sin temperatura ni semilla. Es el instrumento correcto para medir qué hizo un adaptador de estilo.
El techo es log2(20) = 4,32 bits, un modelo sin ninguna opinión. Un bit es una pregunta de sí o
no: 1,6 bits dicen que con algo menos de dos preguntas bien elegidas («¿es de peón de rey?», «¿es
1. e4?») adivinas la primera jugada del modelo.
// FontaneríaEl lote de auto-partidas y su comprobación de forma rectangular
@torch.no_grad()def opening_diversity( model: MoveDecoder, tok: UciTokenizer, games: int = 200, plies: int = OPENING_PLIES, sampling: SampleConfig | None = None, header_elo: int = 1800, seed: int = 42,) -> DiversityResult: """Play ``games`` openings of the model against itself and measure how varied they are.""" cfg = sampling or SampleConfig() device = next(model.parameters()).device generator = torch.Generator(device=device).manual_seed(seed) start = header(tok, header_elo, header_elo)
boards = [chess.Board() for _ in range(games)] histories = [list(start) for _ in range(games)] lines: list[list[str]] = [[] for _ in range(games)] alive = list(range(games))
for _ in range(plies): if not alive: break batch = [prompt_ids(histories[i], model.cfg.block) for i in alive] # Every live game is at the same ply, so the batch is rectangular and needs no padding. # That is worth an assertion rather than a pad: this model has *learned* position # embeddings, so padding on the left would shift every real token to a position it was # never trained at, and padding on the right would leave the last column holding a # `<pad>` for the shorter rows. Both read the wrong distribution, silently. width = len(batch[0]) if any(len(ids) != width for ids in batch): raise RuntimeError("self-play games fell out of step; the batch cannot be padded") padded = torch.tensor(batch, dtype=torch.long, device=device) logits = model(padded)[0][:, -1, :].float()Todas las partidas vivas están en el mismo ply, así que el lote es rectangular y no necesita relleno.
Eso hay que comprobarlo, porque este modelo tiene posiciones aprendidas: rellenar por la
izquierda desplazaría cada token a una posición en la que nunca se entrenó, y por la derecha dejaría
un <pad> en la columna que predice. Las dos leen la distribución equivocada en silencio, así que el
código prefiere un RuntimeError.
El resto de diversity.py juega las auto-partidas y escribe el informe; se puede saltar en la
primera lectura. Los tests de diversidad lo usan: si lo saltas y no trajiste el fichero, tráelo
ahora con git checkout p4 -- src/rukh/eval/diversity.py (sustituye lo que llevas tecleado por la
versión de referencia).
// FontaneríaEl bucle de auto-partidas, top_lines y la sección del informe
still: list[int] = [] for row, index in enumerate(alive): board = boards[index] legal = legal_token_ids(board, tok) if not legal: continue filtered = _mask(logits[row], legal, cfg) probs = torch.softmax(filtered, dim=-1) if cfg.temperature == 0: token = int(torch.argmax(filtered)) else: token = int(torch.multinomial(probs, 1, generator=generator)) uci = tok.ids[token] move = chess.Move.from_uci(uci) board.push(move) histories[index].append(token) lines[index].append(uci) if not board.is_game_over(): still.append(index) alive = still
counts = Counter(" ".join(line) for line in lines if line) entropy = shannon_entropy(counts) ceiling = math.log2(games) if games > 1 else 0.0 return DiversityResult( games=games, plies=plies, distinct_lines=len(counts), entropy_bits=entropy, max_entropy_bits=ceiling, normalised=min(entropy / ceiling, 1.0) if ceiling > 0 else 0.0, first_move_entropy_bits=first_move_entropy(model, tok, header_elo), temperature=cfg.temperature, top_k=cfg.top_k, top_lines=counts.most_common(5), )Una partida que acaba en mate al ply 8 sale del lote, y las demás siguen en el mismo ply. Y
top_lines es lo que hace visible un modelo colapsado en el informe: cinco líneas con 40 partidas
cada una se lee de un golpe donde «entropía 2,3 bits» no.
def _mask(logits: torch.Tensor, legal: list[int], cfg: SampleConfig) -> torch.Tensor: """The legality mask, the temperature and the top-k of ``SampleConfig``, on one row.
Always masked, whatever ``cfg.mask_illegal`` says: this measures which *openings* the model chooses, and an illegal token is not an opening. Legality has its own metric. """ out = logits.clone() keep = torch.zeros_like(out, dtype=torch.bool) keep[torch.tensor(legal, dtype=torch.long, device=out.device)] = True out = out.masked_fill(~keep, float("-inf")) if cfg.temperature > 0: out = out / cfg.temperature if cfg.top_k is not None: k = min(cfg.top_k, len(legal)) threshold = torch.topk(out, k).values[-1] out = out.masked_fill(out < threshold, float("-inf")) return outAquí siempre se enmascara: un token ilegal no es una apertura, y la legalidad tiene su propia métrica.
def _diversity_section(detail: DiversityResult | None) -> list[str]: """How varied the model's own openings are, and the two numbers that say it.
Split in two because they fail differently. The line entropy depends on the sampler -- at the near-deterministic setting the stages are compared at, every decoder plays one opening and scores 0 -- so it is read at its own temperature and always reported with it. The first-move entropy has no sampler in it at all: it is the model's own distribution over the twenty legal first moves, so it is the one to compare across stages. """ if detail is None: return [] top_k = "none" if detail.top_k is None else str(detail.top_k) lines = [ "## Opening diversity", "", f"{detail.games} self-play openings of {detail.plies} plies, drawn at temperature " f"{detail.temperature} with top-k {top_k}. **Not** the suite's sampling: at the " "near-deterministic setting every stage plays one single opening and scores 0, which " "measures the sampler and not the weights (D-047).", "", "| Metric | Value |", "|---|---:|", f"| Distinct opening lines | {detail.distinct_lines} of {detail.games} |", f"| Line entropy | {detail.entropy_bits:.3f} bits of {detail.max_entropy_bits:.3f} |", f"| Normalised | {detail.normalised:.3f} |", f"| First-move entropy (no sampling) | {detail.first_move_entropy_bits:.3f} bits |", "", "Most played lines:", "", "| Line | Games |", "|---|---:|", ] lines.extend(f"| `{line}` | {count} |" for line, count in detail.top_lines) lines.append("") return linesEl informe dice dentro del Markdown a qué muestreo se leyó el número: publicado sin esa condición, invita a compararlo con otro leído de otra manera. Y la entropía de aperturas dice si el modelo varía, no si varía bien: uno que jugara al azar sacaría una entropía magnífica. Por eso viaja siempre junto al Elo.
sweep.py: la suite una vez por condición
La mecánica del barrido está plegada; a la vista quedan los dos veredictos, que son lo que decide si
el hito se cumple. Si vas a saltar los pliegues, trae sweep.py antes de teclear nada de él y
lee los veredictos contra él: git checkout p4 -- src/rukh/eval/sweep.py.
// FontaneríaEl docstring de sweep.py y la fila de una condición
"""Measure one model once per Elo header: does asking it to play weaker actually work?
``GOAL.md`` asks P4 for a monotonic Elo by condition -- 1500 < 2000 < 2400, with intervals -- andnothing in the harness could answer that, because ``header_elo`` was one number in a config and asuite run produced one row. This runs the very same suite once per condition, changing thatnumber and **nothing else**: same opponents, same positions, same puzzles, same seed, sametemperature. Anything that differs between the rows is the header.
Two claims are checked separately and reported separately, because they are not the same claim.A **monotonic** sweep is one whose point estimates go up with the condition; a **separated** oneis a sweep whose consecutive confidence intervals do not overlap. Point estimates rise by chanceall the time -- that is exactly how D-069 first read a 32-game trial as a win for `<2600>` andhad to take it back at 160 games -- so the sweep prints both and the milestone is judged on thesecond.
The cache makes this much cheaper than it sounds: a game is keyed by the weights, the config andthe header, so re-running a sweep after adding one condition only plays the new one."""
from __future__ import annotations
import jsonimport loggingfrom pathlib import Pathfrom typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict
from rukh import pathsfrom rukh.eval.suite import EvalConfig, run_suite
if TYPE_CHECKING: from rukh.eval.suite import SuiteResult
log = logging.getLogger(__name__)
__all__ = ["SweepResult", "SweepRow", "elo_summary", "run_sweep", "sweep_row", "write_sweep"]
WEB_FILE = "artifacts/web/elo-conditioning.json"Lo que lo hace asequible es la caché: indexa cada partida por pesos, configuración y cabecera, así que añadir una condición solo juega la nueva. Seis condiciones a 26 minutos son dos horas y media la primera vez.
class SweepRow(BaseModel): """One condition: what the model does when it is told to play at this rating."""
model_config = ConfigDict(extra="forbid")
header_elo: int elo: float | None = None elo_ci: list[float] | None = None elo_lower: float | None = None elo_upper: float | None = None score: float | None = None """Points per game over the whole ladder; the raw number the Elo is fitted from.""" legality: float | None = None """Asked to play badly, does it play badly or does it play *illegally*? Not the same thing, and the difference is the whole question of whether the header is understood.""" top1: float | None = None puzzles: float | None = None first_move_entropy: float | None = None stage: strscore está aunque el Elo se derive de él, porque la aritmética de partidas necesarias del lab 6
trabaja sobre la tasa de puntos: una proporción tiene una fórmula para su error típico, la que la
estadística justa de M2 deriva con una moneda.
class SweepResult(BaseModel): """The whole sweep and the two verdicts it supports."""
model_config = ConfigDict(extra="forbid")
stage: str checkpoint: str params: int date: str rows: list[SweepRow] monotonic: bool """The point estimates rise with the condition. Cheap, and not on its own evidence.""" separated: bool """No two consecutive intervals overlap. This is the one the acceptance bar is read on.""" span: float | None = None """Elo between the weakest and the strongest condition: how much the dial is worth."""
def table(self) -> str: """The sweep as a Markdown table, in the shape the ledger and the lesson quote.""" lines = [ "| Asked for | Elo | 95 % CI | legal argmax | top-1 | puzzles | first-move entropy |", "|---|---:|---|---:|---:|---:|---:|", ] for row in self.rows: ci = _ci_text(row) legality = "n/a" if row.legality is None else f"{row.legality * 100:.2f} %" top1 = "n/a" if row.top1 is None else f"{row.top1 * 100:.2f} %" puzzles = "n/a" if row.puzzles is None else f"{row.puzzles * 100:.2f} %" entropy = ( "n/a" if row.first_move_entropy is None else f"{row.first_move_entropy:.3f} bits" ) elo = "n/a" if row.elo is None else f"{row.elo:.0f}" lines.append( f"| `<w{row.header_elo:04d}>` | {elo} | {ci} | {legality} | {top1} | " f"{puzzles} | {entropy} |" ) return "\n".join(lines)Los dos veredictos son dos campos: monotonic es barato; separated es donde se lee el criterio de
aceptación. span dice cuánto Elo hay entre la condición más débil y la más fuerte, es decir, cuánto
vale el dial.
// FontaneríaCómo se arma una fila a partir del resultado de la suite
def _ci_text(row: SweepRow) -> str: if row.elo_ci: return f"{row.elo_ci[0]:.0f}-{row.elo_ci[1]:.0f}" if row.elo_lower is not None: return f"> {row.elo_lower:.0f}" if row.elo_upper is not None: return f"< {row.elo_upper:.0f}" return "n/a"
def sweep_row(header_elo: int, result: SuiteResult) -> SweepRow: """Pull the columns of one condition out of a full suite result.""" elo = result.elo return SweepRow( header_elo=header_elo, elo=elo.elo if elo else None, elo_ci=[elo.ci_low, elo.ci_high] if elo and not elo.separated else None, elo_lower=elo.elo_lower if elo else None, elo_upper=elo.elo_upper if elo else None, score=elo.score if elo else None, legality=result.legality_argmax.rate if result.legality_argmax else None, top1=result.accuracy.top1 if result.accuracy else None, puzzles=result.puzzles.rate if result.puzzles else None, first_move_entropy=( result.diversity_detail.first_move_entropy_bits if result.diversity_detail else None ), stage=result.stage, )def is_monotonic(rows: list[SweepRow]) -> bool: """Whether the point estimates rise with the condition, ignoring rows without an Elo.""" measured = [row.elo for row in rows if row.elo is not None] return all(a < b for a, b in zip(measured, measured[1:], strict=False))
def is_separated(rows: list[SweepRow]) -> bool: """Whether every consecutive pair of intervals is disjoint, in the right order.
A one-sided bound (every game won or every game lost) counts as separated only when the bound itself proves it, which is the honest reading of "the interval does not overlap". """ usable = [row for row in rows if row.elo is not None] if len(usable) < 2: return False for low, high in zip(usable, usable[1:], strict=False): top_of_low = low.elo_ci[1] if low.elo_ci else low.elo_upper bottom_of_high = high.elo_ci[0] if high.elo_ci else high.elo_lower if top_of_low is None or bottom_of_high is None: return False if top_of_low >= bottom_of_high: return False return TrueLas dos funciones de los veredictos son cortas a propósito, porque de ellas depende si un hito se
declara cumplido. is_separated tiene una guarda que importa: len(usable) < 2 devuelve falso.
Devolver True por vacuidad convertiría un barrido roto en un criterio cumplido.
Lo que queda del fichero corre la suite por condición y escribe el informe; se puede saltar. Si lo
saltas y no trajiste el fichero, tráelo ahora con git checkout p4 -- src/rukh/eval/sweep.py, que
sustituye lo que llevas tecleado por la versión de referencia.
// Fontaneríarun_sweep y el informe en Markdown
def run_sweep( ckpt: Path | str, cfg: EvalConfig, elos: list[int], suite: str = "full", use_cache: bool = True, device: str | None = None, stage: str | None = None,) -> SweepResult: """Run the suite once per condition and collect the rows into one verdict.""" if len(elos) < 2: raise ValueError("a sweep needs at least two conditions to compare") if sorted(elos) != elos: raise ValueError(f"conditions must be given in increasing order, got {elos}") base_stage = stage or cfg.stage or Path(ckpt).parent.name rows: list[SweepRow] = [] last: SuiteResult | None = None for header_elo in elos: name = f"{base_stage}@{header_elo}" log.info("condition %s of %s: %s", len(rows) + 1, len(elos), name) per_condition = cfg.model_copy(update={"header_elo": header_elo, "stage": name}) result, _ = run_suite(ckpt, per_condition, suite=suite, use_cache=use_cache, device=device) rows.append(sweep_row(header_elo, result)) last = result assert last is not None measured = [row.elo for row in rows if row.elo is not None] return SweepResult( stage=base_stage, checkpoint=Path(ckpt).as_posix(), params=last.params, date=last.date, rows=rows, monotonic=is_monotonic(rows), separated=is_separated(rows), span=(max(measured) - min(measured)) if len(measured) >= 2 else None, )Antes de gastar una hora de máquina, run_sweep exige al menos dos condiciones y en orden creciente,
porque is_monotonic compara filas en el orden en que llegan. Y cfg.model_copy(update=...)
garantiza que entre filas cambie una sola cosa: seis configuraciones escritas a mano podrían
diferenciarse en algo más.
def write_sweep(result: SweepResult, out_dir: Path | str, web: Path | str | None = None) -> Path: """Write the sweep's table and JSON, and the file the course reads.""" directory = Path(out_dir) / f"{result.stage}-elo-sweep" directory.mkdir(parents=True, exist_ok=True) markdown = directory / "report.md" markdown.write_text(_render(result), encoding="utf-8", newline="\n") payload = json.loads(result.model_dump_json()) (directory / "results.json").write_text( json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n" ) if web is not None: target = paths.resolve(str(web)) target.parent.mkdir(parents=True, exist_ok=True) target.write_text( json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n", ) return markdown
def _render(result: SweepResult) -> str: verdict = "monotonic" if result.monotonic else "**not** monotonic" separation = "separated" if result.separated else "**not** separated" span = "n/a" if result.span is None else f"{result.span:.0f} Elo" return "\n".join( [ f"# Elo by condition of `{result.stage}`", "", f"- Checkpoint: `{result.checkpoint}`", f"- Parameters: {result.params:,}", f"- Date: {result.date}", "", "Every row is the same suite with one number changed: the `<wXXXX> <bXXXX>` header " "the model is prompted with. Same opponents, same positions, same puzzles, same " "seed, same temperature.", "", result.table(), "", "## Verdict", "", f"- Point estimates: {verdict}.", f"- Confidence intervals: {separation}.", f"- Span between the weakest and the strongest condition: {span}.", "", "The two are not the same claim. Point estimates rise by chance often enough that a " "monotonic row of numbers is not evidence on its own; the acceptance bar of `GOAL.md` " "is read on the intervals.", "", ] )_render escribe en el propio informe que los dos veredictos no son la misma afirmación y que el
criterio se lee en los intervalos.
def elo_summary(rows: list[SweepRow]) -> str: """One line per condition for the CLI.""" lines = [ f"<w{row.header_elo:04d}>: " + ("n/a" if row.elo is None else f"{row.elo:.0f} ({_ci_text(row)})") for row in rows ] return "\n".join(lines)force_header: la bandera que existe para una sola medición
Es la bandera de la sección «Un solo número cambia entre filas»: con ella, los puzles y las partidas
leen la cabecera del barrido y no la de su partida. La implementación, en tres ficheros, se puede
saltar. elo.py ya lo trajiste al empezar; los otros dos se teclean, así que si los saltas,
tráelos: git checkout p4 -- src/rukh/eval/legality.py src/rukh/eval/puzzles.py.
// Fontaneríaforce_header en legality.py, puzzles.py y elo.py, con su clave de caché
def sample_positions( games: Path, n: int, tok: UciTokenizer, seed: int = 0, pool: int = 50_000, block: int = 200, header_elo: int | None = None,) -> 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``.
``header_elo`` replaces the ratings the game really had. It is off by default, because the honest way to score next-move accuracy is to show the model the header the game actually carried. It is on for the Elo sweep, and for the same reason the puzzles need it: every position is prompted with the *condition* being tested, so "asked to play at 1200, does it play worse or does it play illegally?" becomes a question the table can answer. Without it those two columns are identical in every row -- true, and indistinguishable from evidence that the condition does nothing. """Apagado por defecto: lo honesto para puntuar la exactitud es mostrarle al modelo la cabecera que la partida llevaba de verdad. Solo el barrido la cambia, porque ahí la pregunta es otra.
def start_history( tok: UciTokenizer, item: PuzzleItem, header_elo: int = HEADER_ELO, force_header: bool = False,) -> list[int]: """The ids the model sees before its first move: header, then the real game if there is one.
The header is the ``<bos> <wXXXX> <bXXXX>`` of ``rukh.infer.sampler.prompt_ids``, with the players' own ratings when the parquet carries them; ``header_elo`` stands in otherwise.
``force_header`` overrides the real ratings, and exists for exactly one measurement: the Elo sweep of M4, which asks the same model to play at several strengths and needs *every* column to answer that question. Almost all puzzles carry their players' ratings, so without this the puzzle rate would be identical in every row of the sweep and would look like evidence that the condition does nothing. """ if force_header: white = black = header_elo else: white = item.white_elo if item.white_elo is not None else header_elo black = item.black_elo if item.black_elo is not None else header_elo history = header(tok, white, black) history.extend(tok.vocab.get(uci, tok.unk_id) for uci in item.prefix) return history items: Sequence[PuzzleItem], cache: EvalCache | None = None, header_elo: int = HEADER_ELO, force_header: bool = False,) -> PuzzleResult: """Attempt every puzzle, reusing the cached attempts of a previous run of the same weights.""" attempts: list[PuzzleAttempt] = [] for item in items: # A puzzle whose parquet carries the players' own ratings ignores ``header_elo``, so its # key stays as it was; one that falls back to the header is a different attempt per # header and must not read back the 1800 run's answer. uses_header = force_header or item.white_elo is None or item.black_elo is None key = item.puzzle_id if uses_header and header_elo != HEADER_ELO: key = f"{key}:e{header_elo}" cached = cache.get(SUITE, key) if cache is not None else None if cached is not None: attempt = PuzzleAttempt.model_validate(cached) # An attempt played with the other prompt answers a different question. if attempt.prompt_style == item.prompt_style: attempts.append(attempt) continue attempt = solve_puzzle(source, tok, item, header_elo=header_elo, force_header=force_header) if cache is not None: cache.put(SUITE, key, attempt.model_dump()) attempts.append(attempt) return summarize(attempts)La parte que se olvida es la caché: un puzle con otra cabecera es otro intento, y no debe leer de
vuelta la respuesta de la ejecución a 1800. La clave lleva el sufijo :e<elo> solo cuando la
cabecera no es la de por defecto, así que lo cacheado antes sigue siendo direccionable. Lo mismo en
las partidas:
def item_id(self) -> str: """Cache key. The header is only in it when it is not the default.
Without the header a run at ``<2600>`` would silently read back the games played at ``<1800>``: same rung, same index, completely different game. Leaving the default out keeps every game cached before this existed addressable. """ suffix = "" if self.header_elo == 1800 else f":e{self.header_elo}" return f"{self.rung}:{self.index}{suffix}"La escalera recalibrada
El código solo cambia cuatro números; lo que importa es por qué, y va debajo del pliegue. Es
elo.py, que llegó con el git checkout del principio: el pliegue es para leer.
// Fontaneríaelo.py: los peldaños de la escalera con sus valoraciones medidas
# The four ``Skill Level`` rungs carry **measured** ratings, not the nominal 800/950/1100/1250 they# were given when the harness was written. Those guesses were wrong by 430 to 580 Elo and dragged# every stage's fit down by about 350 points: the contradiction that exposed them was a model# scoring 0.725 against a 1320-rated engine while losing 20-0 to one labelled 1250.## The numbers below come from playing the ladder against itself at the suite's own 0.1 s per move,# 40 games per pair with colours alternated, anchored on ``uci-1320`` (D-070). The method validates# on its own control: ``uci-1500`` measured +179 Elo over ``uci-1320`` against a nominal +180.# ``UCI_Elo`` does compress higher up -- ``uci-1800`` measured +215 over ``uci-1500``, not +300 --# which is a caveat for the top rungs and not for the range these models play in.# Ordered by measured strength. Note what that ordering shows: **no rung is below 1320**. The# four ``skill-*`` opponents were put in the ladder to reach under the engine's ``UCI_Elo`` floor# and they never did, which is why every stage measured lower than it plays.DEFAULT_RUNGS: list[EloRung] = [ EloRung(name="uci-1320", elo=1320, uci_elo=1320), EloRung(name="skill-0", elo=1381, skill=0), EloRung(name="skill-1", elo=1467, skill=1), EloRung(name="uci-1500", elo=1500, uci_elo=1500), EloRung(name="skill-2", elo=1589, skill=2), EloRung(name="skill-3", elo=1678, skill=3), EloRung(name="uci-1800", elo=1800, uci_elo=1800), EloRung(name="uci-2000", elo=2000, uci_elo=2000),]Los cuatro peldaños de Skill Level llevaban valoraciones nominales (800, 950, 1100, 1250)
equivocadas entre 430 y 580 puntos, y arrastraban el ajuste de todas las etapas unos 350 puntos
hacia abajo. Lo destapó una contradicción: un modelo puntuaba 0,725 contra un motor valorado en 1320
mientras perdía 20-0 contra uno etiquetado 1250. Las dos cosas no pueden ser verdad, así que una
etiqueta está mal.
La medición nueva es la escalera contra sí misma, anclada en uci-1320, y valida sobre su propio
control: uci-1500 midió +179 Elo sobre uci-1320 contra un nominal de +180. Y ningún peldaño
resultó estar por debajo de 1320.
El otro cambio de elo.py, también para leer, es el que deja entrar a Qwen en la escalera de la
lección 5.
// Fontaneríaplay_rung acepta un jugador en vez de un modelo
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,) -> list[GameRecord]: """Play ``games`` games against one rung, alternating colours, reusing cached games.
A game that ends with ``*`` (the context ran out) is adjudicated with the rung's own engine before it is scored, so it enters the fit as a win, a loss or a draw on the merits of the final position.
``player`` replaces the decoder, and it is how M4 puts a general language model on this exact ladder -- same opponents, same move time, same colours, same adjudication. A comparison whose two halves went through different code is not a comparison. When it is given, ``model`` is unused and may be ``None``; the seed still varies per game, but a player that cannot be seeded simply ignores it. """play_rung acepta un player en vez de un modelo, y así el Qwen de la lección 5 juega en esta misma
escalera.
Las banderas nuevas de la suite
Las banderas nuevas son configuración; el párrafo de debajo del pliegue cuenta las dos que importan.
suite.py llegó con el git checkout del principio, así que no hay nada que teclear.
// Fontaneríasuite.py: las banderas nuevas de la configuración
accuracy_positions: int = 10_000 position_pool: int = 50_000 puzzles_per_band: int = 2_000 force_header: bool = False """Prompt everything with ``header_elo`` instead of the ratings the game really had.
Off everywhere except the Elo sweep, where it is what makes the table readable. Validation positions carry their own players' ratings and almost every puzzle carries its game's, so with it off the legality, the accuracy and the puzzle rate come out **identical in every row** of a sweep: correct by their own definition, and indistinguishable from evidence that the condition does nothing. The Elo games never had this problem -- they are played from the starting position and the header is the only thing that says who is playing.""" header_elo: int = 1_800 """The Elo the model is asked to play at (``<wXXXX> <bXXXX>``).
It was hardcoded to 1800 while the corpus was 1800+ Lichess and the choice cost nothing. With over half the corpus at 2400+ (D-064) it is a real knob: asking a model trained on masters to imitate an 1800 is asking it for its weaker mode, so the header belongs in the suite config and in the cache key rather than in a default argument. """ elo_games: int = 100 elo_rungs: list[EloRung] = Field(default_factory=lambda: list(DEFAULT_RUNGS)) elo_move_time: float = 0.1 elo_max_plies: int | None = None bootstrap: int = 1_000 diversity_games: int = 200 """Self-play openings for the diversity metric; 0 switches it off.""" diversity_plies: int = 12 diversity_temperature: float = 1.0 """Read at its own temperature, not the suite's. The suite plays near-deterministically so that stages are comparable (D-047), and at that setting a decoder plays one single opening and the entropy is 0 for every model alike. Diversity only says something where there is a choice to make, so it gets the temperature that leaves one.""" diversity_top_k: int | None = 20 """And its own top-k, for the same reason and a sharper one: the suite reads at ``top_k: 1``, which is argmax whatever the temperature says. Overriding the temperature alone would look like a diversity measurement and be a second copy of the deterministic one."""header_elo pasa a la configuración y a la clave de caché: a un modelo entrenado con maestros,
pedirle que imite a un 1800 es pedirle su modo débil. Y la diversidad necesita su propia temperatura
y también su propio top-k: la suite lee a top_k: 1, que es argmax diga lo que diga la temperatura,
así que cambiar solo la temperatura daría una segunda copia de la medición determinista.
uv run rukh eval sweep --model checkpoints/medium-elo-<fecha>/step-3800.pt --elos 1200,1500,1800,2000,2100,2400 --config configs/eval/greedy-sweep.yaml --stage medium-elouv run rukh eval sweep --model checkpoints/medium-v4-<fecha>/best.pt --elos 1200,2100 --config configs/eval/greedy-sweep.yaml --stage medium-v4Seis condiciones a unos 26 minutos cada una, y después el control: el modelo sin afinar en los dos extremos del eje. Es la ejecución que impide cantar victoria; la lección 9 cuenta qué contestó.
Los dos subcomandos de la CLI
Los dos subcomandos son cáscaras sobre la librería y se pueden saltar; el «Ejecútalo» de abajo usa
el segundo. No hay nada que teclear: cli.py llegó entero en la lección 2.
// Fontaneríacli.py: rukh eval sweep y rukh eval openings
def eval_sweep_cmd( model: Annotated[ str, typer.Option("--model", help="Checkpoint path or Hub id to evaluate per condition.") ], elos: Annotated[ str, typer.Option("--elos", help="Increasing Elo headers to ask for, comma separated."), ] = "1200,1500,1800,2100,2400", suite: Annotated[str, typer.Option("--suite", help="Suite name: full or quick.")] = "full", config: Annotated[ Path | None, typer.Option( "--config", exists=True, dir_okay=False, readable=True, help="Suite YAML override." ), ] = None, stage: Annotated[str | None, typer.Option("--stage", help="Name of the sweep.")] = None, no_cache: Annotated[bool, typer.Option("--no-cache", help="Recompute every game.")] = False, device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None, web: Annotated[ bool, typer.Option("--web/--no-web", help="Also write the JSON the course reads.") ] = True,) -> None: """Run the suite once per Elo header and say whether the ratings come out monotonic.
Every row is the same evaluation with one number changed, so anything that differs between them is the condition. The verdict is printed twice on purpose: whether the point estimates rise, and whether the confidence intervals actually separate. Only the second is evidence. """ from rukh.eval import load_suite from rukh.eval.suite import SUITES, is_hub_id from rukh.eval.sweep import WEB_FILE, elo_summary, run_sweep, write_sweep from rukh.paths import resolve
if suite not in SUITES: typer.echo(f"error: --suite must be one of {', '.join(SUITES)}", err=True) raise typer.Exit(code=2) if not Path(model).is_file() and not is_hub_id(model): typer.echo(f"error: --model {model!r} is neither a checkpoint nor a Hub id", err=True) raise typer.Exit(code=2) try: wanted = [int(part) for part in elos.split(",") if part.strip()] except ValueError as exc: typer.echo(f"error: --elos must be a comma-separated list of integers: {exc}", err=True) raise typer.Exit(code=2) from exc logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") cfg = load_suite(suite, config) try: result = run_sweep( model, cfg, wanted, suite=suite, use_cache=not no_cache, device=device, stage=stage ) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc markdown = write_sweep(result, resolve(cfg.out_dir), WEB_FILE if web else None) typer.echo(f"stage: {result.stage} ({result.params:,} parameters)") typer.echo(elo_summary(result.rows)) typer.echo(f"monotonic: {'yes' if result.monotonic else 'no'} (point estimates)") typer.echo(f"separated: {'yes' if result.separated else 'no'} (confidence intervals)") if result.span is not None: typer.echo(f"span: {result.span:.0f} Elo") typer.echo(f"report: {markdown}")Las validaciones baratas van primero y salen con código 2, error de uso, antes de cargar un modelo.
Y la salida imprime los dos veredictos en dos líneas, cada uno con lo que mide entre paréntesis:
monotonic (point estimates) y separated (confidence intervals).
def eval_openings_cmd( model: Annotated[str, typer.Option("--model", help="Checkpoint or Hub id to read.")], against: Annotated[ str | None, typer.Option("--against", help="A second model to compare the distribution with."), ] = None, header_elo: Annotated[ int, typer.Option("--elo", help="Elo header to ask the distribution at.") ] = 1800, top: Annotated[int, typer.Option("--top", help="How many first moves to print.")] = 8, device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,) -> None: """Print what the model would open with, as probabilities rather than as sampled games.
No temperature, no top-k, no seed: the softmax over the twenty legal first moves as the weights produce it. That is what makes it the right instrument for an adapter's effect -- the share it moved is a property of the weights and does not have to be averaged out of hundreds of self-play games. """ from rukh.eval.diversity import first_move_distribution, first_move_entropy from rukh.eval.suite import resolve_model from rukh.tokenize.uci_vocab import UciTokenizer from rukh.train import load_model, pick_device
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") where = device or pick_device() tok = UciTokenizer()
def read(path: str) -> tuple[dict[str, float], float]: loaded, _ = load_model(resolve_model(path), map_location=where) loaded = loaded.to(where).eval() return first_move_distribution(loaded, tok, header_elo), first_move_entropy( loaded, tok, header_elo )
try: probs, entropy = read(model) other = read(against) if against is not None else None except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc
typer.echo(f"header: <w{header_elo:04d}>") typer.echo(f"entropy: {entropy:.4f} bits of {math.log2(20):.4f}") if other is not None: typer.echo(f" {other[1]:.4f} bits for {against}") order = sorted(probs, key=lambda move: probs[move], reverse=True)[:top] header_row = "move share" + (" other delta" if other is not None else "") typer.echo(header_row) for move in order: line = f"{move:<9s} {probs[move] * 100:6.2f} %" if other is not None: mine, theirs = probs[move], other[0].get(move, 0.0) line += f" {theirs * 100:6.2f} % {(theirs - mine) * 100:+6.2f}" typer.echo(line)rukh eval openings imprime con qué abriría el modelo como probabilidades, y con --against compara
dos modelos columna a columna. Los dos pasan por la misma función interna, read: dos copias casi
iguales del código inventarían una diferencia entre modelos en su primera divergencia.
Las ocho configuraciones de evaluación
De las ocho configuraciones que añade el hito, solo una lleva una idea que necesitas ahora: la aritmética de cuántas
partidas hacen falta, que va a la vista. Las demás están plegadas y resumidas en la tabla de debajo.
Todas llegaron con el git checkout del principio salvo greedy.yaml, la de referencia, que va en
el primer pliegue: si lo saltas, tráela, porque la leen las evaluaciones de los labs:
git checkout p4 -- configs/eval/greedy.yaml.
// Fontaneríagreedy.yaml, la configuración de referencia, y greedy-sweep.yaml
# The sampling the acceptance bar is read at: near-deterministic (D-047). Temperature is worth# more than 200 Elo on the same weights, so every comparison between stages has to fix it, and# this file is that fixture -- it reproduces exactly what `small-greedy` and `medium-greedy` were# measured with, so a new stage lands in the same table.## `games` is the month whose first 20,000 rows the position pool reads (`.head(pool)`), and the# first 100,000 games of that month are the held-out validation split (D-062). The pool is# therefore inside the held-out part even now that the rest of the month trains.games: data/uci/year=2025/month=02/games.parquetpuzzles: data/puzzles/puzzles.parquetpuzzle_split: testlegality_positions: 1000accuracy_positions: 1000position_pool: 20000puzzles_per_band: 2000elo_games: 20elo_move_time: 0.1bootstrap: 500temperature: 0.05top_k: 1block: 200seed: 42header_elo: 1800 # the header every published number was measured at; keep it pinnedout_dir: artifacts/eval# The single results table, which this file stopped writing to and should not have. Every number# the project publishes is read at this sampling, so `web_results: null` left the table carrying# the rows of the `full` suite -- measured before D-070 corrected the Elo ladder, and therefore# retracted. `/proyecto/` served them.web_results: artifacts/web/results.jsoncache_db: artifacts/eval/cache-greedy.sqlitetrack: falseLa configuración de referencia va entera porque las demás son ella con una o dos claves cambiadas. Fíjate en el primer comentario: la temperatura está fijada porque vale más de 200 Elo sobre los mismos pesos. La del barrido cambia estas líneas:
# The suite the Elo sweep of M4 runs once per condition.## Identical to `greedy.yaml` -- the sampling every published number was read at (D-047) -- with# one change: `force_header`. Validation positions carry their own players' ratings and almost# every puzzle carries its game's, so with it off the legality, the accuracy and the puzzle rate# come out identical in every row of the sweep. Correct by their own definition, and# indistinguishable from evidence that the condition does nothing, which is the one thing this# table must not be ambiguous about.## Its own cache, for the same reason: these puzzle attempts answer a different question from the# ones `cache-greedy.sqlite` holds and must not be confused with them.configs/eval/greedy-sweep.yaml
force_header: trueconfigs/eval/greedy-sweep.yaml
header_elo: 1800diversity_games: 200diversity_plies: 12diversity_temperature: 1.0diversity_top_k: 20out_dir: artifacts/evalweb_results: nullcache_db: artifacts/eval/cache-sweep.sqlitetrack: falseforce_header: true, las claves de diversidad y una caché propia, porque estos intentos de puzle
contestan a otra pregunta. Hay una tercera variante cuyo comentario es la aritmética del módulo:
# The sweep again with the games the arithmetic asks for, on the conditions the criterion names.## The first pass played 160 games per condition and left every adjacent pair overlapping. Before# spending more machine time, `labs/m4/games_needed.py` turns the measured scores into the number# of games each comparison would need (`n > 3.84 / (delta p)^2` per condition, two proportions# near a half):## <w1500> -> <w2000> 0.466 -> 0.453 -0.013 24 420 games each# <w2000> -> <w2400> 0.453 -> 0.569 +0.116 284 games each# <w1500> -> <w2400> 0.466 -> 0.569 +0.103 357 games each## So the middle of the criterion is not a sample-size problem and no run will fix it, while the# outer pair is one and 400 games clears it with room. 50 games on each of the eight rungs is# 400 per condition, about an hour each; it is run on `<w1500>` and `<w2400>` only, because# paying for `<w2000>` again would buy a tighter interval around a difference that is not there.## Its own cache, and not out of tidiness: `elo_games` is part of the cache key, so this run# cannot read back the games already played even though game `rung:index` is literally the same# game (its seed is `seed + index`, independent of the total). That is a real waste and it is# written down in `docs/backlog.md`; fixing it belongs to P6, not to the middle of a milestone.configs/eval/greedy-sweep-50.yaml
elo_games: 50configs/eval/greedy-sweep-50.yaml
cache_db: artifacts/eval/cache-sweep50.sqliteconfigs/eval/greedy-sweep-50.yaml
La fórmula del comentario, n > 3.84 / (delta p)^2, es la de la
estadística justa de M2: 3,84 es
1,96², el 1,96 de un intervalo del 95 %, y la cuenta vale para dos tasas de puntos cerca de la
mitad; delta p es la diferencia entre ellas. La lección 9 la aplica fila a fila. Dos líneas de
diferencia y veinte de razonamiento: el par de dentro del criterio no es un problema de tamaño de
muestra y ninguna ejecución lo va a arreglar; el par exterior sí lo es, y 400 partidas
lo resuelven. Así que se paga por <w1500> y <w2400> y no se vuelve a pagar por <w2000>. El
último párrafo reconoce por escrito un desperdicio: como elo_games forma parte de la clave de
caché, esta ejecución no reutiliza las partidas ya jugadas.
| Fichero | Para qué sirve | Diferencia con greedy.yaml |
|---|---|---|
greedy.yaml |
el muestreo en el que se lee el criterio de aceptación; escribe la tabla única | arriba, entero |
greedy-sweep.yaml |
la suite que el barrido corre una vez por condición | force_header: true, cuatro claves de diversidad, caché propia |
greedy-sweep-50.yaml |
el mismo barrido con 50 partidas por peldaño, para el par exterior del eje | elo_games: 50 y su propia caché |
greedy-2600.yaml |
qué hace el modelo cuando se le pide su modo fuerte | header_elo: 2600, web_results: null |
greedy-fast.yaml |
«¿esto dañó al modelo?» sin la escalera: minutos en vez de una hora | elo_games: 0, diversidad activada |
greedy-loadtest.yaml |
si la carga de CPU cambia lo que juega Stockfish a 0,1 s por jugada | solo cache_db y web_results: null |
sweep-temperature.yaml |
el barrido muestreando la cola en vez de la moda, para contrastar la hipótesis del argmax | temperature: 1.0, top_k: 20, caché propia |
qwen-smoke.yaml |
el ensayo de rukh eval qwen |
lección 5, entero |
Las cuatro restantes cambian una o dos claves y la tabla ya dice para qué sirve cada una; se pueden saltar, y ya están en tu repositorio.
// Fontaneríagreedy-fast, greedy-2600, greedy-loadtest y sweep-temperature
Cada una cambia una o dos claves de greedy.yaml o de greedy-sweep.yaml, y su cabecera dice
para qué se escribió:
# `greedy.yaml` without the Stockfish ladder: everything that does not need an engine.## For the stages where the question is "did this damage the model?" rather than "how strong is# it?": the style adapters of M4. Legality, next-move accuracy and puzzles all answer the first# question, cost minutes instead of an hour, and puzzles in particular track playing strength# closely enough to catch an adapter that broke something (in D-072 they moved 22 -> 37 % across# a 145-Elo gap).## What it cannot do is replace an Elo. A stage measured with this file has no rating and its card# says so; it does not borrow one from its base model.greedy-fast.yaml se usa con los adaptadores de estilo: la pregunta ahí es «¿esto dañó al modelo?»,
y legalidad, exactitud y puzles la contestan en minutos. No reemplaza a un Elo, y la card de una
etapa medida así lo dice en vez de tomarlo prestado de su modelo base.
# Same as `greedy.yaml` except the Elo header. Reported alongside the 1800 row, never# instead of it: 1800 is what the published stages were measured at, and mixing the two# in one table would compare a model against a different question.# A copy of `greedy.yaml` with its own cache, to replay games that are already measured# without overwriting them. Used once, to find out whether CPU load on the machine changes# what Stockfish plays at 0.1 s per move -- if it does, no Elo number taken under load is# comparable with a number taken on an idle machine.configs/eval/greedy-loadtest.yaml
greedy-2600 se publica junto a la fila de 1800, nunca en su lugar. greedy-loadtest comprobó una
vez si la carga de CPU cambia lo que juega Stockfish a 0,1 s por jugada; si lo cambiara, ningún Elo
medido con la máquina ocupada sería comparable con uno en reposo. Y sweep-temperature.yaml
contesta a una hipótesis:
# El barrido otra vez, muestreando la distribución en vez de su moda.## Toda la suite juega con `temperature: 0.05, top_k: 1`, que es la moda: el modelo hace siempre su# jugada más probable. Y la diferencia entre un 1200 y un 2400 no está en la moda -- los dos hacen# la recaptura obvia -- sino en la cola, en cada cuánto eligen algo malo. Los datos del hito lo# dicen solos: la entropía de la primera jugada, que lee la distribución entera, es monótona en las# seis condiciones; los puzles y el Elo, que leen la moda, son planos.## Así que esta tirada cambia una sola cosa respecto de `greedy-sweep.yaml`: muestrea a temperatura# 1,0 con top-k 20, que es el ajuste "humano" de la demo. Si la brecha entre `<w1200>` y `<w2400>`# se abre, la cabecera controla la fuerza y lo que faltaba era dejar de muestrear el pico. Si sale# plana, es un segundo resultado nulo y también dice algo.## NO sustituye a nada publicado: D-047 fija el muestreo casi determinista como el de todos los# números del proyecto, así que esto es una medición aparte, con su caché aparte, y se publica# diciendo a qué temperatura se leyó.configs/eval/sweep-temperature.yaml
La hipótesis es razonable: toda la suite juega la moda, y la diferencia entre un 1200 y un 2400 no está en la moda (los dos hacen la recaptura obvia) sino en la cola. Es contrastable y cuesta una hora; la lección 9 cuenta qué salió. Es una medición aparte, con su caché aparte, que no sustituye a nada publicado.
Retirar una medición, y escribir la tabla desde lo medido
Dos funciones más de eval/report.py, de publicación, que existen por lo mismo que la escalera
recalibrada.
Es fontanería de publicación y se puede saltar en la primera lectura; report.py y su test llegaron
con el git checkout del principio.
// Fontaneríareport.py: retirar etapas y regenerar docs/benchmarks.md, con sus tests
def drop_rows(path: Path, stages: list[str]) -> tuple[list[str], list[dict[str, Any]]]: """Remove rows from the shared results file; return ``(removed stages, rows that stay)``.
A measurement can be **retracted**, and when it is, the table has to be able to say so by not carrying it any more. It happened once already: D-070 found that four of the eight rungs of the Elo ladder had invented ratings, wrong by about five hundred points, which made every Elo published before it wrong. The corrected runs were written under new stage names, so the old rows stayed in the table and the project page kept serving retracted numbers for a day.
Deliberately explicit and per stage rather than a filter or a wildcard: dropping a row is throwing away a measurement, and it should take saying its name. """ path = Path(path) if not path.is_file(): return [], [] payload = json.loads(path.read_text(encoding="utf-8")) found = payload.get("rows") if isinstance(payload, dict) else payload rows = [item for item in found if isinstance(item, dict)] if isinstance(found, list) else [] wanted = set(stages) removed = sorted({str(item.get("stage")) for item in rows if item.get("stage") in wanted}) kept = [item for item in rows if item.get("stage") not in wanted] document = {"updated_at": datetime.now(UTC).isoformat(timespec="seconds"), "rows": kept} path.write_text( json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n" ) return removed, keptUna medición se puede retirar, y entonces la tabla no debe llevarla. Se hace de forma explícita y por etapa, sin comodines: tirar una fila es tirar una medición, y debería costar decir su nombre.
BENCHMARKS_MARK = "## Resultados""""Heading of ``docs/benchmarks.md`` from which everything is rewritten by ``render_benchmarks``."""
BENCHMARKS_COLUMNS: list[tuple[str, str]] = [ ("Etapa", ""), ("Legalidad sin máscara (%)", "---:"), ("Top-1 (%)", "---:"), ("Top-3 (%)", "---:"), ("Puzles 1000-1500 (%)", "---:"), ("Puzles 1500-2000 (%)", "---:"), ("Puzles 2000+ (%)", "---:"), ("Elo estimado (IC 95 %)", ""), ("Δcp medio", "---:"), ("Entropía 1.ª jugada (bits)", "---:"), ("Fecha", ""),]def render_benchmarks(rows: list[dict[str, Any]]) -> str: """The results table of ``docs/benchmarks.md``, rendered from the measured rows.
Written rather than typed, and for the reason the whole project keeps repeating: a table copied by hand from a report is a second source of truth that starts drifting on the day it is written. The encoder's rows are left out -- they share not one column with these -- and so is every row that has no measurement at all. """ playing = sorted( (row for row in rows if row.get("kind") != "encoder"), key=lambda row: str(row.get("stage")) ) header = "| " + " | ".join(name for name, _ in BENCHMARKS_COLUMNS) + " |" rule = "|" + "|".join(align or "---" for _, align in BENCHMARKS_COLUMNS) + "|" lines = [BENCHMARKS_MARK, "", header, rule] for row in playing: lines.append( "| " + " | ".join( [ f"`{row.get('stage')}`", _percent(row.get("legality")), _percent(row.get("top1")), _percent(row.get("top3")), _band(row, "puzzles", "1000-1500"), _band(row, "puzzles", "1500-2000"), _band(row, "puzzles", "2000+"), _elo_cell(row), "n/a" if row.get("delta_cp") is None else f"{row['delta_cp']:.1f}", "n/a" if row.get("first_move_entropy") is None else f"{row['first_move_entropy']:.4f}", str(row.get("date") or ""), ] ) + " |" ) lines.append("") lines.append( f"{len(playing)} etapas medidas con la misma suite. Las filas del encoder viven aparte " "porque no comparten una sola columna con estas; las que una medición retiró no están " "(`rukh eval drop`)." ) return "\n".join(lines) + "\n"
def write_benchmarks(results: Path | str, out: Path | str) -> int: """Rewrite the results section of ``docs/benchmarks.md``; returns how many rows it wrote.
Everything above ``## Resultados`` is the document's own text -- what each column means and how it is measured -- and is left exactly as it is. Only the table is generated. """ payload = json.loads(Path(results).read_text(encoding="utf-8")) found = payload.get("rows") if isinstance(payload, dict) else payload rows = [item for item in found if isinstance(item, dict)] if isinstance(found, list) else [] target = Path(out) document = target.read_text(encoding="utf-8") head, mark, _ = document.partition(BENCHMARKS_MARK) if not mark: raise ValueError(f"{target} has no `{BENCHMARKS_MARK}` heading to rewrite") target.write_text(head + render_benchmarks(rows), encoding="utf-8", newline="\n") return len([row for row in rows if row.get("kind") != "encoder"])La tabla de docs/benchmarks.md se genera: copiada a mano sería una segunda fuente de verdad que
empieza a derivar el día en que se escribe. write_benchmarks reescribe solo lo que hay debajo de
## Resultados, así que el documento puede estar explicado a mano y actualizado por una máquina.
"""`docs/benchmarks.md` is rendered from the measured rows, never typed."""
from __future__ import annotations
import jsonfrom pathlib import Path
import pytest
from rukh.eval.report import BENCHMARKS_MARK, render_benchmarks, write_benchmarks
pytestmark = pytest.mark.unit
ROWS = [ { "stage": "medium-elo", "kind": None, "legality": 0.998, "top1": 0.541, "top3": 0.83, "puzzles": {"1000-1500": 0.57, "1500-2000": 0.37, "2000+": 0.17}, "elo": 1497.9, "elo_ci": [1435.2, 1552.3], "delta_cp": None, "first_move_entropy": 1.7408, "date": "2026-09-20", }, {"stage": "encoder", "kind": "encoder", "blunder_f1": 0.18, "date": "2026-09-19"}, { "stage": "all-wins", "elo": None, "elo_separated": True, "elo_lower": 2000.0, "date": "2026-09-20", },]Tres filas de prueba: una etapa medida del todo, una del encoder, que no debe salir, y una con cota en vez de intervalo.
Qué has aprendido
La entropía de aperturas va en dos números porque uno depende del muestreador y el otro es una propiedad de los pesos. El barrido cambia un número entre filas y emite dos veredictos, porque «ordenado» y «distinguible» son afirmaciones distintas. Y dos ideas que sirven fuera del ajedrez: una clave de caché tiene que incluir todo lo que cambia la respuesta, y recalibrar un instrumento invalida las medidas anteriores.
Lo siguiente es la parte que un lector puede tocar. Sigue en «El selector en la demo».