rukh · lab

// M5 · lección 05

La arena: dos modelos jugando entre sí

El instrumento del hito, entero: el código que juega un número par de partidas con los colores espejados sobre un libro sembrado, lo convierte en Elo con su intervalo por bootstrap y escribe un informe que dice si hay que creérselo, más los dos arreglos que encontró el control.

  • tiempo de trabajo115 min
  • además, ejecución sin supervisión+ 10 min de GPU y red
  • nivel medio
  • actualizado el23 de septiembre de 2026

Qué vas a construir

rukh eval match: dos modelos jugando el uno contra el otro, y un número con intervalo que dice cuál es más fuerte. Es lo primero que se escribió del hito y de aquí sale cada fila de la lección 8. Es también, en pequeño, cómo funcionan las arenas que ordenan LLM enfrentándolos por parejas.

Son src/rukh/eval/match.py, dos arreglos en el bucle de partida de M0, el subcomando de la CLI y la parte de los tests que hace de control.

El fichero, y la aritmética que lo justifica

src/rukh/eval/match.py tiene tres pliegues de fontanería (importaciones, el adaptador del segundo modelo y el informe) que el «Ejecútalo» necesita. Si prefieres no teclearlos, trae el fichero entero ahora, antes de empezarlo, con git checkout p5 -- src/rukh/eval/match.py, y lee la lección contra él.

El docstring hace la cuenta de la lección 1 con números.

src/rukh/eval/match.py
"""Two models playing each other, which is how a *difference* in strength gets measured.
Everything published so far measures a model against the Stockfish ladder and reports an absolute
Elo. That is the right instrument for "how strong is this" and the wrong one for "is this stronger
than that", and P5's acceptance criterion asks the second question: **+50 Elo over its base**.
The arithmetic says how wrong. A 50-Elo edge is a 0.5715 expected score head to head, and
separating 0.5715 from 0.5 at 95 % takes **185 games**. The same 50 Elo read off the ladder is
0.0712 of score around a base of 0.45, and separating two such estimates takes **757 games per
side**, 1 514 in total -- before adding the ladder's own irreproducibility, which P4 measured at
about 40 Elo of one sigma (D-107) because Stockfish plays on a clock and randomises on purpose.
Eight times cheaper and one source of noise fewer, for the simple reason that both models play
*the same game*: there is no third party whose mood has to be averaged out.
Two details make the number honest.
**Colours are mirrored.** Every opening is played twice, once with each model on white, so a
repertoire that happens to suit one side cannot decide the match. A match with an odd number of
games is refused rather than silently unbalanced.
**The openings are shared and seeded.** Both games of a pair start from the same position, so the
pair is a comparison and not two samples.
"""

src/rukh/eval/match.pylíneas 1-24 · p5

Una ventaja de 50 Elo es una puntuación esperada de 0,5715, y separarla de 0,5 al 95 % cuesta 185 partidas. En la escalera, esos 50 Elo son 0,0712 de puntuación alrededor de 0,45, y separar dos estimaciones así cuesta unas 375 partidas por lado, 750 en total, antes de sumar la irreproducibilidad de la escalera: unas cuatro veces más. El docstring dice 757 por lado y «Eight times cheaper» porque cuenta dos veces la varianza de la resta (la lección 9, en el lab 1, enseña la línea). Los dos modelos juegan la misma partida: no hay un tercero cuyo humor haya que promediar.

Dos detalles hacen el número honesto: los colores se espejan —cada apertura se juega dos veces, una con cada modelo de blancas— y el libro es compartido y sembrado, así que las dos partidas de un par salen de la misma posición. Es un diseño emparejado: el mismo paciente prueba los dos tratamientos, y lo que varía entre pacientes deja de contar.

Las importaciones y el tipo de una partida se pueden saltar en la primera lectura; al teclear hacen falta (sin ellas el fichero no importa), así que despliégalas, salvo que trajeras el fichero entero al empezarlo.

// FontaneríaImportaciones y MatchGame: una partida, siempre desde el punto de vista de a
src/rukh/eval/match.py
from __future__ import annotations
import json
import math
import random
from pathlib import Path
from typing import TYPE_CHECKING
import chess
import numpy as np
from pydantic import BaseModel, ConfigDict
from rukh.infer.game import DecoderPlayer, play_game_with
if TYPE_CHECKING: # pragma: no cover - typing only
from collections.abc import Sequence
from rukh.infer.game import Player
from rukh.infer.sampler import SampleConfig
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
__all__ = [
"MatchSettings",
"MatchGame",
"MatchResult",
"elo_difference",
"games_for_edge",
"opening_book",
"play_match",
"render_markdown",
"summarise",
"write_match",
]

src/rukh/eval/match.pylíneas 26-59 · p5

src/rukh/eval/match.py
class MatchGame(BaseModel):
"""One game of the match, from ``a``'s point of view."""
model_config = ConfigDict(extra="forbid")
index: int
opening: str
"""The moves both games of this pair started from, in UCI."""
a_white: bool
result: str
score: float
"""1 for a win by ``a``, 0.5 for a draw, 0 for a loss."""
plies: int
a_illegal: int
b_illegal: int

src/rukh/eval/match.pylíneas 62-76 · p5

Una partida, siempre desde el punto de vista de a. Guardar la apertura en cada partida permite comprobar después que las dos direcciones de un par jugaron el mismo libro.

src/rukh/eval/match.py
class MatchSettings(BaseModel):
"""Everything that would change the number, written beside the number.
``masked`` is the one worth a sentence. By default both models propose from the unmasked
distribution and an illegal proposal is rescued with a masked draw -- so a model that proposes
illegally more often gets that second, legal-only draw more often. With ``masked`` the rescue
path does not exist for either side. The two readings answer different questions and the file
has to say which one it is.
"""
model_config = ConfigDict(extra="forbid")
games: int
opening_plies: int
opening_seed: int
header_elo: int
temperature: float
top_k: int | None
masked: bool
bootstrap_samples: int

src/rukh/eval/match.pylíneas 79-98 · p5

Todo lo que cambiaría el número, escrito al lado del número. La clave que hay que entender es masked, y su docstring explica un sesgo que nadie estaba buscando.

Por defecto, una propuesta ilegal se rescata con un segundo sorteo restringido a jugadas legales, que es un sorteo mejor. Así que un modelo que propone más ilegales recibe más segundos sorteos. Es un examen tipo test donde cada respuesta inválida te da derecho a contestar otra vez con las opciones malas tachadas.

Con masked, ningún lado tiene rescate. Sirve de control: si las dos lecturas coinciden, la ventaja era fuerza; si no, era el rescate.

src/rukh/eval/match.py
class MatchResult(BaseModel):
"""What the match measured: a score, an Elo difference and its interval."""
model_config = ConfigDict(extra="forbid")
a: str
b: str
games: int
score: float
"""``a``'s share of the points. 0.5 means the match found no difference."""
wins: int
draws: int
losses: int
elo: float
"""``400 log10(p / (1 - p))``: how much stronger ``a`` is than ``b``, in Elo."""
ci_low: float | None = None
ci_high: float | None = None
separated: bool = False
"""Whether the interval excludes zero, which is the claim the criterion is read on."""
a_illegal: int = 0
b_illegal: int = 0
settings: MatchSettings = None # type: ignore[assignment]
"""How the match was played. A measurement that does not carry its settings cannot be compared
to another one, and D-118 is the entry that says so with numbers: every reward in the gallery
changed when the engine depth did, and nothing in the log would have said why."""
@property
def decisive(self) -> int:
return self.wins + self.losses

src/rukh/eval/match.pylíneas 101-129 · p5

El veredicto, separated, vive junto al intervalo del que se dedujo; más abajo verás un caso en el que la estimación se movió 22 puntos y el veredicto se dio la vuelta. Y settings va dentro porque una medición sin sus ajustes no se puede comparar con otra.

src/rukh/eval/match.py
def elo_difference(score: float, cap: float = 1200.0) -> float:
"""``400 log10(p / (1 - p))``, clamped when one side won everything.
A clean sweep has no finite fit -- the logit runs off -- so it is reported at the cap with the
sign it earned, and ``MatchResult.separated`` is what a reader should look at instead.
"""
if score <= 0.0:
return -cap
if score >= 1.0:
return cap
return max(-cap, min(cap, 400.0 * math.log10(score / (1.0 - score))))

src/rukh/eval/match.pylíneas 132-142 · p5

La conversión a Elo, con su tope: un instrumento que puede devolver infinito acaba metiéndolo en una tabla. En una barrida, lo que hay que mirar es separated, no el número.

src/rukh/eval/match.py
def games_for_edge(elo: float, level: float = 0.95) -> int:
"""How many games it takes to tell an edge of ``elo`` from nothing.
The inverse of the question every match asks, and the reason this module exists: run it
**before** paying for the games, not after looking at them.
"""
if elo == 0:
return 0
from scipy.stats import norm # noqa: PLC0415 - only needed here
p = 1.0 / (1.0 + 10.0 ** (-abs(elo) / 400.0))
z = float(norm.ppf(0.5 + level / 2.0))
return math.ceil(z * z * p * (1.0 - p) / (p - 0.5) ** 2)

src/rukh/eval/match.pylíneas 145-157 · p5

La función que se ejecuta antes de pagar las partidas: ¿cuántas hacen falta para distinguir una ventaja de elo de nada? El abs(elo) deja escrito que +50 y −50 cuestan lo mismo.

src/rukh/eval/match.py
def opening_book(pairs: int, plies: int = 6, seed: int = 0) -> list[list[str]]:
"""``pairs`` random legal openings, each to be played twice with the colours swapped.
Random rather than a curated book on purpose: a book of "sound" openings would measure the two
models inside somebody else's taste, and the question here is which of the two plays better
chess, not which of them knows the Ruy Lopez.
"""
rng = random.Random(seed)
book: list[list[str]] = []
while len(book) < pairs:
board = chess.Board()
moves: list[str] = []
for _ in range(plies):
legal = list(board.legal_moves)
if not legal:
break
move = rng.choice(legal)
moves.append(move.uci())
board.push(move)
if len(moves) == plies and not board.is_game_over():
book.append(moves)
return book

src/rukh/eval/match.pylíneas 160-181 · p5

El libro es aleatorio y sin curar, a propósito: uno de aperturas «sanas» mediría los dos modelos dentro del gusto de otra persona, y la pregunta es cuál juega mejor, no cuál se sabe la Ruy López. La guarda del final descarta aperturas que terminaron antes de tiempo, y el while asegura el número de aperturas pedido.

El adaptador que deja jugar a dos decoders es fontanería; se puede saltar al leer. Al teclear no: sin él, play_match no tiene con qué poner al segundo modelo en el tablero. Despliégalo, salvo que trajeras el fichero entero al empezarlo.

// Fontanería_PlayerOpponent: envolver el segundo modelo para reutilizar el bucle de partida
src/rukh/eval/match.py
def _board_from(opening: Sequence[str]) -> chess.Board:
board = chess.Board()
for uci in opening:
board.push_uci(uci)
return board
class _PlayerOpponent:
"""A ``Player`` seen as an ``Opponent``: what lets two decoders face each other.
``play_game_with`` drives one side through the ``Player`` protocol and asks the other for a
move. Wrapping the second model here means the match reuses the very loop every published game
was played on, instead of a second implementation that could disagree with it.
"""
def __init__(self, player: Player) -> None:
self.player = player
self.illegal = 0
def start(self, white_elo: int, black_elo: int) -> None:
self.illegal = 0
self.player.start(white_elo, black_elo)
def choose(self, board: chess.Board) -> chess.Move:
move, legal = self.player.choose(board)
if not legal:
self.illegal += 1
if move is None:
# The loop has no way to say "I resign"; the first legal move ends the game honestly
# and the illegal count already recorded that the model had nothing to offer.
return next(iter(board.legal_moves))
return move
def observe(self, move: chess.Move) -> None:
self.player.observe(move)

src/rukh/eval/match.pylíneas 184-218 · p5

_PlayerOpponent permite que dos decoders se enfrenten. El bucle de partida conduce un lado por el protocolo Player, que devuelve (jugada, fue_legal), y el otro por Opponent, que devuelve solo la jugada. Envolver el segundo modelo aquí hace que el enfrentamiento reutilice el mismo bucle que jugó cada partida publicada, en vez de una segunda implementación que podría discrepar.

El comentario del choose explica el último recurso: el bucle no sabe rendirse, y un modelo que no propone nada ya quedó contado como ilegal, así que la primera jugada legal termina la partida sin reventar el enfrentamiento.

src/rukh/eval/match.py
def play_match(
a: Player,
b: Player,
games: int = 200,
opening_plies: int = 6,
seed: int = 0,
header_elo: int = 1800,
max_plies: int | None = None,
) -> list[MatchGame]:
"""Play ``games`` games between two players, colours mirrored over shared openings."""
if games < 2 or games % 2:
raise ValueError(f"a mirrored match needs an even number of games, got {games}")
book = opening_book(games // 2, plies=opening_plies, seed=seed)
played: list[MatchGame] = []
for opening in book:
for a_white in (True, False):
opponent = _PlayerOpponent(b)
# `play_game_with` starts the player and replays the board's opening into both sides;
# the opponent is started here because the loop does not know it is a model.
opponent.start(header_elo, header_elo)
board = _board_from(opening)
result = play_game_with(
a,
opponent,
model_color=chess.WHITE if a_white else chess.BLACK,
white_elo=header_elo,
black_elo=header_elo,
max_plies=max_plies,
board=board,
)
score = _score_for_a(result.result, a_white)
played.append(
MatchGame(
index=len(played),
opening=" ".join(opening),
a_white=a_white,
result=result.result,
score=score,
plies=result.plies,
a_illegal=result.illegal_proposals,
b_illegal=opponent.illegal,
)
)
return played

src/rukh/eval/match.pylíneas 221-264 · p5

La guarda de la primera línea es la que más se agradece: un número impar de partidas se rechaza en vez de redondearse en silencio. Un reparto de colores torcido es un sesgo que no aparece en ninguna métrica, así que tiene que doler al escribir el comando.

El opponent.start(...) explícito es parte del arreglo que encontró el control: el bucle arranca al Player, pero no sabe que su rival también es un modelo.

src/rukh/eval/match.py
def _score_for_a(result: str, a_white: bool) -> float:
if result == "1/2-1/2":
return 0.5
if result == "1-0":
return 1.0 if a_white else 0.0
if result == "0-1":
return 0.0 if a_white else 1.0
return 0.5 # a game that was cut short is a draw, the same rule the ladder uses

src/rukh/eval/match.pylíneas 267-274 · p5

src/rukh/eval/match.py
def summarise(
played: Sequence[MatchGame],
names: tuple[str, str] = ("a", "b"),
samples: int = 2_000,
seed: int = 0,
level: float = 0.95,
settings: MatchSettings | None = None,
) -> MatchResult:
"""The match as one number with its interval, bootstrapped over games."""
if not played:
raise ValueError("cannot summarise a match with no games")
scores = np.array([game.score for game in played], dtype=float)
score = float(scores.mean())
rng = np.random.default_rng(seed)
draws = rng.choice(scores, size=(samples, len(scores)), replace=True).mean(axis=1)
elos = np.array([elo_difference(float(value)) for value in draws])
low, high = (np.quantile(elos, (1 - level) / 2), np.quantile(elos, 1 - (1 - level) / 2))
return MatchResult(
a=names[0],
b=names[1],
games=len(played),
score=score,
wins=int(sum(1 for game in played if game.score == 1.0)),
draws=int(sum(1 for game in played if game.score == 0.5)),
losses=int(sum(1 for game in played if game.score == 0.0)),
elo=elo_difference(score),
ci_low=float(low),
ci_high=float(high),
separated=bool(low > 0 or high < 0),
a_illegal=int(sum(game.a_illegal for game in played)),
b_illegal=int(sum(game.b_illegal for game in played)),
settings=settings
or MatchSettings(
games=len(played),
opening_plies=len(played[0].opening.split()),
opening_seed=-1,
header_elo=-1,
temperature=float("nan"),
top_k=None,
masked=False,
bootstrap_samples=samples,
),
)

src/rukh/eval/match.pylíneas 277-319 · p5

El bootstrapBootstrapRemuestrear los datos con reemplazo muchas veces y recalcular el estadístico en cada remuestreo para ver cuánto se mueve. El intervalo del Elo de Rukh sale así: 500 remuestreos de las 160 partidas (bootstrap: 500 en la suite), un ajuste por remuestreo, y los percentiles 2,5 y 97,5. Cubre el ruido del muestreo de partidas y nada más: no cubre que el rival cambie entre ejecuciones ni que los peldaños estén mal colocados. se hace sobre las puntuaciones de las partidas: se remuestrean 2 000 veces con reemplazo, cada media se convierte a Elo, y los cuantiles de esa distribución son el intervalo. Es preguntar dos mil veces «¿y si hubieran salido otras partidas parecidas a estas?» con las que tienes, lo mismo que la estadística justa de M2 hace a mano con cinco partidas. separated es low > 0 or high < 0, la única afirmación que admite el criterio del hito. Los -1 y nan del settings por defecto son feos a propósito, para que nadie los confunda con una medición.

src/rukh/eval/match.py
def decoder_player(model: MoveDecoder, tok: UciTokenizer, cfg: SampleConfig) -> DecoderPlayer:
"""The same player the ladder uses, so a match and a ladder run measure the same model."""
return DecoderPlayer(model, tok, cfg)

src/rukh/eval/match.pylíneas 322-324 · p5

El jugador del enfrentamiento es el mismo que el de la escalera; si fueran dos, estarían midiendo dos modelos distintos con el mismo nombre.

El informe y su escritor se pueden saltar en la primera lectura: por color, si hay partidas suficientes y cómo se jugó. Al teclear hacen falta, porque el subcomando escribe el informe con ellos: despliégalos, salvo que trajeras el fichero entero al empezarlo.

// Fontaneríarender_markdown y write_match: el informe report.md y el results.json
src/rukh/eval/match.py
def render_markdown(result: MatchResult, played: Sequence[MatchGame]) -> str:
"""The match as a report, with the arithmetic that says whether to believe it."""
sign = "+" if result.elo >= 0 else ""
interval = (
f"{result.ci_low:.0f} to {result.ci_high:.0f}"
if result.ci_low is not None and result.ci_high is not None
else "n/a"
)
needed = games_for_edge(result.elo) if result.elo else 0
white = [game for game in played if game.a_white]
black = [game for game in played if not game.a_white]
settings = result.settings
rescue = (
"masked from the first draw"
if settings.masked
else "proposed unmasked, rescued with a masked draw"
)
lines = [
f"# `{result.a}` against `{result.b}`",
"",
"Two models playing each other over the same openings with the colours mirrored. This is",
"the instrument for a *difference* in strength: both models play the same game, so there",
"is no third party whose mood has to be averaged out.",
"",
"| Metric | Value |",
"|---|---|",
f"| Games | {result.games} |",
f"| Score for `{result.a}` | {result.score:.4f} |",
f"| Won / drawn / lost | {result.wins} / {result.draws} / {result.losses} |",
f"| Elo difference | **{sign}{result.elo:.0f}** |",
f"| 95 % CI | {interval} |",
f"| Separated from zero | {'yes' if result.separated else 'no'} |",
f"| Illegal proposals | {result.a_illegal} by `{result.a}`, "
f"{result.b_illegal} by `{result.b}` |",
"",
"## By colour",
"",
"A model that only wins with one colour has an opening repertoire, not an edge.",
"",
f"| `{result.a}` plays | Games | Score |",
"|---|---:|---:|",
f"| white | {len(white)} | {sum(g.score for g in white) / max(len(white), 1):.4f} |",
f"| black | {len(black)} | {sum(g.score for g in black) / max(len(black), 1):.4f} |",
"",
"## Is this enough games?",
"",
f"An edge of {abs(result.elo):.0f} Elo takes **{needed}** games to tell from nothing at "
f"95 %, and this match played **{result.games}**. "
+ (
"So the number above is a measurement."
if result.games >= needed
else "So the number above is a hint, not a measurement: play more games or say so."
),
"",
"## How it was played",
"",
"Two numbers from different settings are two numbers, not a comparison.",
"",
"| Setting | Value |",
"|---|---|",
f"| Opening book | {settings.opening_plies} plies, seed {settings.opening_seed} |",
f"| Elo header | `<w{settings.header_elo}>` for both |",
f"| Sampling | temperature {settings.temperature}, top-k {settings.top_k} |",
f"| Illegal moves | {rescue} |",
f"| Bootstrap | {settings.bootstrap_samples} resamples |",
"",
]
return "\n".join(lines) + "\n"

src/rukh/eval/match.pylíneas 327-394 · p5

El informe es Markdown generado, con tres secciones:

  • Por color: un modelo que solo gana con un color tiene un repertorio, no una ventaja.
  • ¿Son suficientes partidas?: llama a games_for_edge con la ventaja que acaba de medir y, si no llegan, escribe que el número es una pista. Un informe que se corrige a sí mismo vale más que un número que hay que acordarse de cuestionar.
  • Cómo se jugó: libro, semilla, muestreo y si las ilegales se rescataron o se enmascararon.
src/rukh/eval/match.py
def write_match(result: MatchResult, played: Sequence[MatchGame], out_dir: Path | str) -> Path:
"""Write ``report.md`` and ``results.json`` for one match; returns the report."""
directory = Path(out_dir) / f"{result.a}-vs-{result.b}"
directory.mkdir(parents=True, exist_ok=True)
report = directory / "report.md"
report.write_text(render_markdown(result, played), encoding="utf-8", newline="\n")
payload = result.model_dump(mode="json")
payload["played"] = [game.model_dump(mode="json") for game in played]
(directory / "results.json").write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n"
)
return report

src/rukh/eval/match.pylíneas 397-408 · p5

El escritor deja un report.md y un results.json con todas las partidas dentro; el agrupador de la lección 9 necesita las puntuaciones partida a partida.

Los dos arreglos que el control encontró

El control de la lección 1: un modelo contra una copia exacta de sí mismo tiene que dar 0,5000.

Terminal
uv run rukh eval match --a checkpoints/medium-v4-<fecha>/best.pt --b checkpoints/medium-v4-<fecha>/best.pt --games 100

Salió 0,975 a favor de uno de los dos lados, con 999 jugadas ilegales. Los dos errores estaban en src/rukh/infer/game.py, el bucle de partida de M0, y los dos se arreglan en pocas líneas.

src/rukh/infer/game.py
def _tell(opponent: Opponent, move: chess.Move) -> None:
"""Show a move to an opponent that keeps state; a no-op for one that does not."""
observe = getattr(opponent, "observe", None)
if callable(observe):
observe(move)

src/rukh/infer/game.pylíneas 229-233 · p5

El primero es esta función, nueva en p5: le enseña una jugada a un rival si el rival sabe recibirla. Opponent no tiene observe en su protocolo —Stockfish y el jugador aleatorio no guardan estado—, así que un rival con estado, como un segundo modelo, iba a ciegas toda la partida. El getattr con callable lo resuelve sin tocar el protocolo.

src/rukh/infer/game.py
board = board if board is not None else chess.Board()
limit = max_plies if max_plies is not None else player.limit
player.start(white_elo, black_elo)
# A board handed in with moves already on it is a position the player has to *know about*:
# its prompt is the game so far, not the game from here. Without this a match that starts
# from an opening book plays every move blind to the opening, which reads as a model that
# forgot how to play. The match harness of P5 found it with a model against itself.
for played in board.move_stack:
player.observe(played)
_tell(opponent, played)
moves: list[str] = []
illegal = 0

src/rukh/infer/game.pylíneas 246-257 · p5

El segundo, dentro de play_game_with: el prompt de un jugador es la partida hasta aquí, así que un tablero que llega con jugadas puestas se le tiene que contar. Sin este bucle, el modelo veía un prompt vacío y un tablero con seis jugadas del libro, y proponía jugadas de la posición inicial. En un agente o un chat es el mismo fallo: arrancar a mitad de conversación sin pasarle el historial.

_tell(opponent, move) dentro del bucle cuenta cada jugada a los dos lados a partir de ahí. Después de los dos arreglos: 0,5000 exacto, 11 ilegales por bando.

El subcomando

El docstring de rukh eval match es documentación de uso: explica cuándo no usar la bandera que trae.

Es fontanería de CLI y se puede saltar al leer; al teclear no, porque el «Ejecútalo» de debajo lo usa. Si lo saltas, tráelo con git checkout p5 -- src/rukh/cli.py (si ya lo trajiste en una lección anterior del módulo, ya lo tienes).

// Fontaneríarukh eval match: el subcomando, con la guarda del número par y el muestreo de la suite
src/rukh/cli.py
@eval_app.command("match")
def eval_match_cmd(
a: Annotated[str, typer.Option("--a", help="Checkpoint or Hub id of the first model.")],
b: Annotated[str, typer.Option("--b", help="Checkpoint or Hub id of the second model.")],
games: Annotated[
int, typer.Option("--games", help="Games to play; must be even (colours are mirrored).")
] = 200,
opening_plies: Annotated[
int, typer.Option("--opening-plies", help="Random plies both games of a pair start from.")
] = 6,
header_elo: Annotated[
int, typer.Option("--elo", help="Elo header both models are prompted with.")
] = 1800,
config: Annotated[
Path | None,
typer.Option("--config", exists=True, dir_okay=False, help="Suite YAML for the sampling."),
] = None,
seed: Annotated[int, typer.Option("--seed", help="Seed of the opening book.")] = 0,
mask: Annotated[
bool,
typer.Option("--mask/--no-mask", help="Mask illegal moves for both sides from the start."),
] = False,
out: Annotated[
Path | None, typer.Option("--out", help="Where to write the report (default: artifacts).")
] = None,
device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,
) -> None:
"""Play two models against each other and report the Elo difference with its interval.
This is the instrument for "is A stronger than B", and it is not the ladder. A 50-Elo edge
takes 185 games head to head and 750 games *per side* off the ladder, before adding the
ladder's own irreproducibility (about 40 Elo of one sigma, D-107). To measure a difference,
measure the difference -- do not measure two absolutes and subtract them.
Run `uv run python labs/m5/games_needed_match.py <elo>` first: it says how many games the edge
you expect would take, which is a decision to make before paying for them rather than after.
`--mask` exists as a **control**, not as a setting. By default both models propose from the
unmasked distribution and an illegal proposal is rescued with a masked draw, which is how every
published number of this project is read. But the rescue is a second draw restricted to legal
moves, so a model that proposes illegally more often gets that second draw more often -- and
the two DPO models of M5 propose about twice as many illegal moves as their base. `--mask`
removes the rescue path entirely by masking from the first draw, so the edge can be read again
with that confound gone. If the two numbers agree, the edge was strength; if they do not, the
edge was the rescue.
"""
from rukh.config import load_yaml
from rukh.eval.match import MatchSettings, games_for_edge, play_match, summarise
from rukh.eval.suite import EvalConfig, resolve_model
from rukh.infer import SampleConfig
from rukh.infer.game import DecoderPlayer
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")
if games % 2 or games < 2:
typer.echo("error: --games must be even and at least 2 (colours are mirrored)", err=True)
raise typer.Exit(code=2)
suite = load_yaml(config, EvalConfig) if config else EvalConfig()
where = device or pick_device()
tok = UciTokenizer()
try:
model_a, _ = load_model(resolve_model(a), map_location=where)
model_b, _ = load_model(resolve_model(b), map_location=where)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
sampling = SampleConfig(
temperature=suite.temperature, top_k=suite.top_k, mask_illegal=mask, seed=suite.seed
)
names = (Path(a).parent.name or a, Path(b).parent.name or b)
player_a = DecoderPlayer(model_a.to(where).eval(), tok, sampling)
player_b = DecoderPlayer(model_b.to(where).eval(), tok, sampling)
typer.echo(f"match: {names[0]} vs {names[1]}, {games} games, header <w{header_elo:04d}>")
played = play_match(
player_a,
player_b,
games=games,
opening_plies=opening_plies,
seed=seed,
header_elo=header_elo,
)
result = summarise(
played,
names=names,
seed=suite.seed,
settings=MatchSettings(
games=games,
opening_plies=opening_plies,
opening_seed=seed,
header_elo=header_elo,
temperature=suite.temperature,
top_k=suite.top_k,
masked=mask,
bootstrap_samples=2_000,
),
)
from rukh.eval.match import write_match
path = write_match(result, played, out or Path(suite.out_dir) / "matches")
sign = "+" if result.elo >= 0 else ""
typer.echo(f"score: {result.score:.4f} ({result.wins}W {result.draws}D {result.losses}L)")
typer.echo(
f"elo: {sign}{result.elo:.0f} (95 % CI {result.ci_low:.0f} to {result.ci_high:.0f})"
if result.ci_low is not None and result.ci_high is not None
else f"elo: {sign}{result.elo:.0f}"
)
typer.echo(f"verdict: {'separated from zero' if result.separated else 'includes zero'}")
typer.echo(f"illegal: {result.a_illegal} by {names[0]}, {result.b_illegal} by {names[1]}")
typer.echo(f"would need {games_for_edge(result.elo)} games to call this edge, played {games}")
typer.echo(f"report: {path}")

src/rukh/cli.pylíneas 955-1069 · p5

La guarda del número par se repite aquí, antes de cargar nada, para que el error cueste dos segundos en vez de los cuarenta que tarda en cargar dos modelos. El muestreo sale de la configuración de la suite (--config), así que un enfrentamiento y una fila de la tabla única usan la misma temperatura. Y la última línea impresa compara las partidas que harían falta con las que se jugaron: con eso delante, «+40 con 100 partidas» y «+40 con 800» dejan de parecerse.

Dos detalles de la ayuda. Donde el docstring dice «750 games per side», con la varianza bien contada son 375 por lado, 750 en total, como arriba. Y donde sugiere games_needed_match.py <elo>, el lab no acepta un argumento posicional; su bandera es --edges, una lista separada por comas, y sin ella imprime la tabla completa.

Salida en la máquina de referencia (RTX 5090), la arista entre los dos brazos de DPO con el de dentro de política pasado como A:

score: 0.5687 (191W 73D 136L)
elo: +48 (95 % CI 19 to 80)
verdict: separated from zero
illegal: 441 by A, 692 by B
would need 200 games to call this edge, played 400

Esa misma comparación, con los lados intercambiados, dio +26 con el intervalo incluyendo el cero. La estimación se movió 22 puntos, ruido normal con 400 partidas, y el veredicto se dio la vuelta. Por eso las dos direcciones se corren siempre y se agrupan (lab 3).

El test que es el control

De tests/unit/test_match.py van aquí los dos tests que sostienen el instrumento. El resto fija la conversión a Elo y su tope, las 185 partidas de games_for_edge(50), el rechazo del número impar, los colores espejados sobre un libro reproducible, las ilegales por bando y el rival sin observe.

Los trajiste con la caja del principio, así que aquí se leen, no se teclean; no hacen falta para seguir la lección.

// Teststests/unit/test_match.py: el control con jugadores de juguete y el libro que se cuenta a los dos lados
tests/unit/test_match.py
def test_the_same_model_on_both_sides_measures_zero():
"""The control the whole instrument rests on: no difference must read as no difference."""
played = play_match(_Scripted(5), _Scripted(5), games=20, opening_plies=4, seed=7)
result = summarise(played, names=("x", "x"))
assert result.games == 20
assert result.wins + result.draws + result.losses == 20
assert result.ci_low is not None and result.ci_high is not None
assert result.ci_low <= 0 <= result.ci_high, "an unbiased instrument includes zero"
assert result.separated is False

tests/unit/test_match.pylíneas 98-106 · p5

El control con un jugador de juguete: el mismo _Scripted en los dos lados, veinte partidas, y el intervalo tiene que contener el cero. Con un juguete corre en milisegundos en cada guardado; con modelos de verdad cuesta un minuto de GPU y se corre una vez.

tests/unit/test_match.py
def test_a_board_handed_in_with_moves_is_replayed_into_both_sides():
"""The first bug the control found: a match from an opening book played it blind.
`play_game_with` used to start the player and then begin at whatever position the board was
in, without telling it how the game got there. The prompt was a game that never happened, and
the model answered with illegal moves -- which read as a broken model rather than a broken
harness.
"""
from rukh.infer.game import play_game_with
opening = ["e2e4", "e7e5", "g1f3"]
board = chess.Board()
for uci in opening:
board.push_uci(uci)
player, opponent = _Counting(), _CountingOpponent()
play_game_with(player, opponent, max_plies=len(opening) + 4, board=board)
assert player.seen[: len(opening)] == opening
assert opponent.seen[: len(opening)] == opening

tests/unit/test_match.pylíneas 184-202 · p5

Y el que fija el fallo del libro: con un tablero que llega con e4 e5 Cf3, los dos lados tienen que haber recibido esas tres jugadas antes de empezar. Sin el bucle, player.seen empieza vacío.

Qué has aprendido

Un instrumento para medir diferencias se construye antes que el experimento y se estrena contra una respuesta conocida. Lo que lo hace honesto —número par de partidas, colores espejados, libro compartido— cabe en unas pocas guardas, y la que más vale es la que se niega a hacer algo en vez de arreglarlo en silencio. El informe lleva dentro la cuenta que dice si hay que creérselo. Todo esto vale igual para comparar dos prompts, dos modelos o dos versiones de un agente.

Lo siguiente es publicar: el reward model no es una política y no se puede publicar como si lo fuera.