rukh · lab

// M3 · lección 09

Exportar el encoder: dos salidas, tres precisiones y una model card

El encoder en ONNX con `value` y `blunder`, los tres problemas de formato que hubo que arreglar, la paridad medida sobre la decisión de error, los embeddings de posición con su sidecar, y la publicación en el Hub con la plantilla de la model card del encoder.

  • tiempo de trabajo210 min
  • además, ejecución sin supervisión+ 5 min de GPU y red
  • nivel avanzado
  • actualizado el23 de septiembre de 2026

Lección 9 de 11 del módulo «El encoder». Viene de «Medir sin engañarse» y sigue en «La barra de evaluación».

Qué vas a construir

Lo que hace falta para que el encoder salga del repositorio: el grafo ONNXONNXFormato abierto para describir el grafo de una red y sus pesos, independiente del framework que la entrenó. Es lo que permite entrenar en PyTorch y ejecutar en el navegador con onnxruntime-web, sin Python ni backend. con dos salidas, su cuantización a fp16 e int8, la comprobación de paridadParidadEn cuántas posiciones la jugada elegida por un modelo exportado coincide con la del modelo original. Mide si la exportación está bien hecha, no si el modelo exportado juega igual de bien: un 95,40 % (int8 de small, M2) es un modelo que juega otra cosa una vez de cada veinte, y su Elo hay que medirlo jugando. sobre la decisión que de verdad importa, los embeddings de posición que la fase 2 usará como índice, y la publicación en el Hub con su propia plantilla de model cardModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde run.json y results.json, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. rukh publish cards las regenera todas desde la tabla y sube solo el README..

Casi todo es código de M2 con un camino paralelo. Lo interesante son tres problemas de formato: una dimensión que el exportador fija sin avisar, unas anotaciones que hacen fallar la cuantización y un tipo mal declarado tras pasar a fp16. Ninguno da un error claro, y te los puedes encontrar al exportar cualquier modelo propio.

El envoltorio: dos salidas y ninguna más

src/rukh/export/onnx.py
The encoder travels the same road with a different wrapper: ``EncoderHeads`` returns the two
outputs the demo needs (``value`` and ``blunder``) and nothing else, the ``squares`` scheme has
no dynamic sequence axis at all (it is always 69 tokens) and the metadata carries ``rukh_kind``
and ``rukh_heads`` so a file can say what it is. Everything else — the exporter, the fallback,
the verification, the metadata, the sidecar — is the same code: the decoder's path is untouched.

src/rukh/export/onnx.pylíneas 14-18 · p3

src/rukh/export/onnx.py
MODEL_NAME = "model.onnx"
INPUT_NAME = "idx"
OUTPUT_NAME = "logits"
VALUE_OUTPUT = "value"
BLUNDER_OUTPUT = "blunder"
ENCODER_OUTPUTS = (VALUE_OUTPUT, BLUNDER_OUTPUT)
"""What the encoder graph returns: the evaluation bar and the blunder alert of the demo."""
BATCH_AXIS = "batch"
SEQUENCE_AXIS = "sequence"
DEFAULT_OPSET = 18
METADATA_PREFIX = "rukh_"

src/rukh/export/onnx.pylíneas 46-56 · p3

Los nombres de las salidas son constantes para que la comprobación de paridad busque value y blunder por nombre en el grafo exportado (más abajo se ve por qué importa).

src/rukh/export/onnx.py
class EncoderHeads(nn.Module):
"""Wraps a fine-tuned encoder so that ``forward(idx)`` returns ``(value, blunder)``.
The third head (``result``) is left out on purpose: the demo draws an evaluation bar and a
blunder alert, and every tensor the graph returns is one the browser has to read back over
the worker boundary. ``blunder`` comes out as a probability rather than a logit, so the page
compares it against 0.5 instead of carrying a sigmoid of its own.
"""
def __init__(self, model: MultiHead) -> None:
super().__init__()
self.model = model
def forward(self, idx: Tensor) -> tuple[Tensor, Tensor]:
pooled = self.model.pooled(idx)
return self.model.value(pooled), torch.sigmoid(self.model.blunder(pooled))

src/rukh/export/onnx.pylíneas 71-86 · p3

Las tres decisiones son sobre qué cruza la frontera al navegador:

  • La cabeza de resultado se queda fuera: la demo no la dibuja, y cada tensor devuelto es uno que el trabajador tiene que copiar al hilo principal.
  • El pooling se hace una vez y las dos cabezas leen el mismo vector. Aquí paga la separación entre forward y from_pooled de la lección 4: sin ella habría dos pasadas por las ocho capas.
  • blunder sale como probabilidad. El sigmoide va dentro del grafo y la página solo compara contra 0,5: una implementación menos en TypeScript con la que discrepar.

Como LastStepLogits con el decoder, EncoderHeads llama a las piezas del MultiHead y no a su forward: un envoltorio de exportación describe lo que el consumidor necesita, igual que una API expone solo los campos que el cliente usa.

El escritor de grafos, compartido

src/rukh/export/onnx.py
def _write_graph(
wrapper: nn.Module,
example: Tensor,
path: Path,
opset: int,
outputs: Sequence[str],
dynamic_batch: bool,
dynamic_seq: bool,
) -> tuple[Literal["dynamo", "legacy"], str | None]:
"""Write one ONNX file with the modern exporter, falling back to the legacy one.
Both graphs of the project (the decoder's next move and the encoder's two heads) are written
here so that the fallback, the console workaround and the axis declarations exist once.
"""
common: dict[str, Any] = {
"input_names": [INPUT_NAME],
"output_names": list(outputs),
"opset_version": opset,
}
try:
with torch.no_grad(), _utf8_console():
torch.onnx.export(
wrapper,
(example,),
str(path),
dynamo=True,
dynamic_shapes=_dynamic_shapes(dynamic_batch, dynamic_seq),
**common,
)
except Exception as exc: # noqa: BLE001 - any exporter failure must fall back, not stop
warning = f"the dynamo exporter failed ({type(exc).__name__}: {exc}); used the legacy one"
log.warning("%s", warning)
with torch.no_grad():
torch.onnx.export(
wrapper,
(example,),
str(path),
dynamo=False,
dynamic_axes=_dynamic_axes(dynamic_batch, dynamic_seq, outputs),
**common,
)
return "legacy", warning
return "dynamo", None

src/rukh/export/onnx.pylíneas 167-209 · p3

Es el refactor de la lección 2 aplicado al exportador: lo que los dos grafos hacen igual (intentar el exportador moderno, caer al antiguo, el arreglo de la consola de Windows, declarar los ejes) va a una sola función. Los dos caminos usan claves distintas para lo mismo, dynamic_shapes en el moderno y dynamic_axes en el antiguo. Cualquier fallo del moderno cae al antiguo con un aviso, porque un fichero peor sirve; lo que no se esconde es cuál corrió, y el ExportResult lo dice.

El exportador del encoder: dos filas de ejemplo, y ninguna de relleno

src/rukh/export/onnx.py
def export_encoder_onnx(
ckpt: Path | MultiHead,
out: Path,
opset: int = DEFAULT_OPSET,
dynamic_batch: bool = True,
) -> ExportResult:
"""Export the ``value`` and ``blunder`` heads of a fine-tuned encoder to ONNX.
The length of the input is the scheme's, not the caller's: ``squares`` is always 69 tokens
and there is no sequence axis to make dynamic, while ``moves`` reads a growing game exactly
as the decoder does. The example the exporter traces is a sequence of real tokens rather
than zeros, because ``<pad>`` everywhere is a position the encoder legitimately refuses.
"""
model = ckpt if isinstance(ckpt, MultiHead) else _load_heads(Path(ckpt))
model = model.eval()
cfg = model.encoder.cfg
seq_len = cfg.seq
dynamic_seq = cfg.input == "moves"
wrapper = EncoderHeads(model).eval()
# Two rows, not one, and no zeros. `torch.export` specialises a dimension whose example is
# 1 (the 0/1 specialisation), so a batch of one is baked into the graph and the demo's
# second position fails inside a reshape; tracing at two keeps the axis dynamic and the file
# still accepts a batch of one. `<pad>` everywhere would be an entirely masked row, which
# the encoder refuses on purpose, so the example is made of real token ids.
example = torch.ones((2, seq_len), dtype=torch.long)
path = target_path(out)
path.parent.mkdir(parents=True, exist_ok=True)

src/rukh/export/onnx.pylíneas 276-302 · p3

Ese comentario es el primero de los tres problemas. torch.export especializa una dimensión cuyo ejemplo vale 1 (la especialización 0/1): como una dimensión de tamaño 1 se comporta distinto —se difunde, se puede colapsar—, el capturador la fija en vez de tratarla como simbólica. Sale un grafo que dice tener un lote dinámico y revienta en un reshape la primera vez que recibe dos filas. Trazar con dos filas deja el eje simbólico, y el grafo sigue aceptando una. El ejemplo es torch.ones y no torch.zeros porque una fila entera de <pad> es justo lo que el encoder de la lección 3 rechaza.

src/rukh/export/onnx.py
exporter, warning = _write_graph(
wrapper, example, path, opset, list(ENCODER_OUTPUTS), dynamic_batch, dynamic_seq
)
works = verify_dynamic_seq(path, seq_len, cfg.seq) if dynamic_seq else None
if dynamic_seq and works is False:
raise ValueError(
f"{path} was exported with a dynamic sequence axis but only runs at length "
f"{seq_len}: the {exporter} exporter baked the length in."
)
really_dynamic = dynamic_seq if works is None else works
clear_value_info(path)
metadata = write_metadata(
path,
{
"kind": "encoder",
"heads": ",".join(ENCODER_OUTPUTS),
"input": cfg.input,
"pooling": model.pooling,
"blunder": "probability",
"block": seq_len,
"vocab_size": cfg.tokens,
"seq_len": seq_len,
"dynamic_batch": dynamic_batch,
"dynamic_seq": really_dynamic,
"exporter": exporter,
"version": __version__,
},
)

src/rukh/export/onnx.pylíneas 304-331 · p3

src/rukh/export/onnx.py
return ExportResult(
path=path.as_posix(),
kind="encoder",
outputs=list(ENCODER_OUTPUTS),
exporter=exporter,
opset=opset,
seq_len=seq_len,
block=seq_len,
dynamic_batch=dynamic_batch,
dynamic_seq=really_dynamic,
dynamic_seq_verified=works is not None,
metadata=metadata,
vocab_size=cfg.tokens,
params=sum(parameter.numel() for parameter in model.parameters()),
bytes=path.stat().st_size,
warning=warning,
)

src/rukh/export/onnx.pylíneas 332-348 · p3

En un encoder la longitud trazada y la admitida coinciden (seq_len=seq_len, block=seq_len); el resultado conserva los dos campos para que el consumidor no tenga que saber de qué modelo vino. bytes se lee después de escribir los metadatos, así que es el tamaño que se sube.

El eje de secuencia solo es dinámico en el esquema de jugadas; en casillas siempre son 69 tokens y works es None. Cuando lo es, verify_dynamic_seq ejecuta el fichero a dos longitudes, porque el exportador antiguo acepta la petición de un eje dinámico y luego fija la longitud trazada.

Los metadatos (rukh_kind=encoder, rukh_heads=value,blunder, rukh_blunder=probability, el esquema) hacen que un .onnx suelto sepa decir qué es, qué devuelve y en qué escala.

src/rukh/export/onnx.py
def clear_value_info(path: Path) -> int:
"""Drop the graph's annotations for intermediate values; returns how many were removed.
The modern exporter records a shape for every intermediate tensor, and some of those are the
shape of the traced example rather than of the dynamic graph. Nothing runs them:
onnxruntime executes the file regardless. But ``quantize_dynamic`` re-runs shape inference
in strict mode first and refuses a file whose annotations disagree with what it infers, so
the int8 build of the encoder dies on an annotation instead of on a weight. They are
optional by the specification, so the encoder's graph goes out without them.
"""
try:
import onnx
except ImportError: # pragma: no cover - onnx is a hard dependency of the exporter
return 0
model = onnx.load(str(path))
removed = len(model.graph.value_info)
del model.graph.value_info[:]
onnx.save(model, str(path))
return removed

src/rukh/export/onnx.pylíneas 410-428 · p3

El segundo problema. Un grafo ONNX puede llevar una anotación de forma opcional para cada tensor intermedio (value_info). El exportador moderno las escribe todas, y algunas guardan la forma del ejemplo —dos filas de 69— en vez de la del grafo dinámico. onnxruntime ni las mira, pero quantize_dynamic reinfiere formas en modo estricto antes de cuantizar y rechaza el fichero si no coinciden. El int8 muere con un mensaje sobre formas que no menciona la cuantización. Como son opcionales, se borran.

src/rukh/export/onnx.py
def _load_heads(ckpt: Path) -> MultiHead:
from rukh.train import load_heads
model, _payload = load_heads(ckpt)
return model

src/rukh/export/onnx.pylíneas 446-450 · p3

El gemelo del _load del decoder, con el import dentro por la razón de siempre: rukh.train arrastra mlflow.

El bug de fp16, que es un tipo mal declarado

src/rukh/export/quantize.py
def _align_cast_outputs(model: object) -> int:
"""Make every ``Cast`` say the type its own output is declared to be; count the repairs.
``convert_float_to_float16`` retypes the values of the graph but does not touch the ``to``
attribute of a ``Cast`` node, so a cast that used to produce float32 keeps saying so while
its declared output is now float16. onnxruntime refuses to load that file — the symptom is a
``Type Error: Type (tensor(float16)) of output arg ... does not match expected type
(tensor(float))`` — and the encoder hits it because pooling casts its padding mask to the
hidden dtype. Only intermediate values are considered: the graph's own inputs and outputs
keep the types ``keep_io_types`` promised the caller.
"""
from onnx import TensorProto
graph = model.graph # type: ignore[attr-defined]
declared = {value.name: value.type.tensor_type.elem_type for value in graph.value_info}
repaired = 0
for node in graph.node:
if node.op_type != "Cast" or not node.output:
continue
wanted = declared.get(node.output[0])
if wanted not in (TensorProto.FLOAT, TensorProto.FLOAT16):
continue
for attribute in node.attribute:
if attribute.name == "to" and attribute.i != wanted:
attribute.i = int(wanted)
repaired += 1
return repaired

src/rukh/export/quantize.pylíneas 84-110 · p3

El tercer problema viene de un Cast que el decoder no tiene: en pool (lección 3), mask.unsqueeze(-1).to(hidden.dtype) convierte la máscara booleana al tipo de los estados ocultos. convert_float_to_float16 cambia a float16 el tipo declarado de la salida de ese nodo, pero no su atributo to, que sigue diciendo float32, y onnxruntime se niega a cargar el fichero. El docstring pega el mensaje de error tal cual, una costumbre que merece copiarse: buscar ese texto lleva a esta función.

src/rukh/export/quantize.py
repaired = _align_cast_outputs(converted)
if repaired:
log.info(
"retyped %d Cast node(s) the fp16 conversion left disagreeing with the graph", repaired
)
onnx.save(converted, str(target))
return QuantizeResult(
path=target.as_posix(),
kind="fp16",
method=method,
bytes=target.stat().st_size,
source_bytes=source.stat().st_size,
)

src/rukh/export/quantize.pylíneas 69-81 · p3

El arreglo se aplica siempre y registra cuántos nodos tocó: así se nota cuando un modelo nuevo trae un Cast donde antes no había.

src/rukh/export/quantize.py
def quantize_int8(path: Path, out: Path | None = None) -> QuantizeResult:
"""Dynamically quantize the MatMul and Gemm weights of an ONNX model to int8."""
from onnxruntime.quantization import QuantType, quantize_dynamic
source = Path(path)
target = _sibling(source, INT8_NAME, out)
target.parent.mkdir(parents=True, exist_ok=True)
quantize_dynamic(
model_input=str(source),
model_output=str(target),
weight_type=QuantType.QInt8,
op_types_to_quantize=QUANTIZED_OPS,
)
return QuantizeResult(
path=target.as_posix(),
kind="int8",
method="onnxruntime.quantization.quantize_dynamic",
bytes=target.stat().st_size,
source_bytes=source.stat().st_size,
)

src/rukh/export/quantize.pylíneas 152-171 · p3

La cuantización a int8 es la de M2; lo que el encoder necesitaba ya lo hizo clear_value_info.

La paridad del encoder: la decisión, no el número

src/rukh/export/parity.py
BLUNDER_THRESHOLD = 0.5
"""The exported graph returns a probability; this is where the demo's alert switches on."""

src/rukh/export/parity.pylíneas 46-47 · p3

src/rukh/export/parity.py
class EncoderParityResult(BaseModel):
"""How well an exported encoder reproduces the checkpoint's two heads."""
model_config = ConfigDict(extra="forbid")
positions: int
agreement: float
"""Share of positions where the ``blunder`` decision (at 0.5) is the same."""
max_abs_value_delta: float
max_abs_blunder_delta: float
mismatches: list[int]
"""Indices of the first few positions where the decision differed."""

src/rukh/export/parity.pylíneas 62-73 · p3

La paridad del decoder mide si coincide la jugada elegida; la del encoder, si coincide la decisión de error y cuánto se desvía el valor como máximo. Es la misma pregunta («¿el fichero hace lo mismo que el checkpoint?») sobre lo que cada modelo decide. Un int8 que mueva el valor 0,024 no se ve en la barra; uno que cambie el 5 % de las alertas sería inaceptable aunque el valor apenas se moviera.

src/rukh/export/parity.py
def label_positions(
model: MultiHead,
cfg: LabelsConfig | None = None,
n: int = DEFAULT_N,
split: str = "val",
) -> list[list[int]]:
"""Token sequences of ``n`` held-out labelled positions, in the encoder's own scheme.
Empty when the labelled table has not been built: an encoder parity check has no honest
fallback (a random board is not a position the demo will ever evaluate), so the caller
reports that it could not be measured instead of measuring something else.
"""
import polars as pl
from rukh.data.labels import LabelsConfig as Labels
from rukh.data.labels import build_labels, game_moves
from rukh.train.heads import LabelledPositions
labels = cfg if cfg is not None else Labels()
try:
frame = build_labels(labels)
except FileNotFoundError as exc:
log.warning("no labelled positions for the parity check: %s", exc)
return []
rows = frame.filter(pl.col("split") == split).sort(["order", "game_id", "ply"]).head(n)
if not rows.height:
return []
# The scheme is the model's, not a constant: a ``moves`` encoder tokenized with
# ``fen_to_tokens`` would be compared against its export on ids from another vocabulary, and
# the parity number would be meaningless rather than wrong-looking.
scheme = str(model.encoder.cfg.input)
moves = game_moves(rows, labels.games_dir) if scheme == "moves" else None
dataset = LabelledPositions(rows, scheme, moves, model.encoder.cfg.block)
return [dataset.tokens(index) for index in range(len(dataset))]

src/rukh/export/parity.pylíneas 146-179 · p3

Sin partidas de validación, la paridad del decoder cae a paseos aleatorios legales con un aviso, porque siguen siendo secuencias de jugadas legales. Un tablero aleatorio, en cambio, no es una posición que la demo vaya a evaluar nunca, así que el encoder no tiene respaldo: la lista sale vacía y quien llama informa de que no se pudo medir. Un número medido sobre lo que no importa es peor que un hueco, porque el hueco se ve. (El esquema lo dice el modelo, como en la lección 7.)

src/rukh/export/parity.py
def encoder_parity_positions(
model: MultiHead, cfg: LabelsConfig | None = None, n: int = DEFAULT_N, split: str = "val"
) -> tuple[list[list[int]], str, str | None]:
"""``(positions, source, warning)`` for the encoder: validation labels, or nothing.
``model`` is here to say which scheme the positions have to be tokenized in; it is never
run."""
positions = label_positions(model, cfg, n=n, split=split)
if positions:
return positions, "validation-labels", None
warning = (
"no labelled validation positions (build them with `rukh data evals`): the encoder "
"parity check was skipped rather than measured on positions nobody will evaluate"
)
log.warning("%s", warning)
return [], "none", warning

src/rukh/export/parity.pylíneas 182-197 · p3

El aviso dice la orden que hay que ejecutar para que deje de faltar.

src/rukh/export/parity.py
def encoder_parity(
model: MultiHead,
onnx_path: Path,
positions: Sequence[Sequence[int]],
n: int = DEFAULT_N,
threshold: float = BLUNDER_THRESHOLD,
) -> EncoderParityResult:
"""Compare the blunder decision and the value of the checkpoint and the exported file."""
from rukh.export.onnx import BLUNDER_OUTPUT, VALUE_OUTPUT, EncoderHeads
used = list(positions)[:n]
if not used:
raise ValueError("parity needs at least one position")
wrapper = EncoderHeads(model.eval()).eval()
session = _session(Path(onnx_path))
agreed = 0
worst_value = worst_blunder = 0.0
mismatches: list[int] = []
for index, tokens in enumerate(used):
idx = np.asarray([list(tokens)], dtype=np.int64)
with torch.no_grad():
value, blunder = wrapper(torch.from_numpy(idx))
reference = {
VALUE_OUTPUT: float(value[0]),
BLUNDER_OUTPUT: float(blunder[0]),
}
outputs = session.run(None, {INPUT_NAME: idx})
# Named by the graph itself, not by position: zipping against ``ENCODER_OUTPUTS`` would
# keep comparing happily if the exporter ever swapped the two heads, and the parity
# check would then be measuring `value` against `blunder` and calling it agreement.
exported = {
output.name: float(np.asarray(array).reshape(-1)[0])
for output, array in zip(session.get_outputs(), outputs, strict=True)
}
missing = [name for name in (VALUE_OUTPUT, BLUNDER_OUTPUT) if name not in exported]
if missing:
raise ValueError(f"the exported graph has no {missing} output: {sorted(exported)}")
worst_value = max(worst_value, abs(reference[VALUE_OUTPUT] - exported[VALUE_OUTPUT]))
worst_blunder = max(
worst_blunder, abs(reference[BLUNDER_OUTPUT] - exported[BLUNDER_OUTPUT])
)
same = (reference[BLUNDER_OUTPUT] >= threshold) == (exported[BLUNDER_OUTPUT] >= threshold)
if same:
agreed += 1
elif len(mismatches) < MAX_MISMATCHES:
mismatches.append(index)
return EncoderParityResult(
positions=len(used),
agreement=agreed / len(used),
max_abs_value_delta=worst_value,
max_abs_blunder_delta=worst_blunder,
mismatches=mismatches,
)

src/rukh/export/parity.pylíneas 200-253 · p3

Las salidas se leen por su nombre, preguntándole a la sesión, y no por posición. Si el exportador intercambiara las dos cabezas, emparejar por posición compararía el valor con la probabilidad de error; el número saldría malo pero no absurdo, y alguien culparía a la cuantización. Si falta una salida, missing dice cuál. El reshape(-1)[0] aplana porque un escalar puede llegar como (1,) o (1, 1) según la versión del exportador.

El paquete de exportación

src/rukh/export/__init__.py
KINDS = ("decoder", "encoder")
"""What ``rukh export --kind`` accepts."""
class ExportBundle(BaseModel):
"""Everything one ``rukh export`` produced."""
model_config = ConfigDict(extra="forbid")
onnx: ExportResult
fp16: QuantizeResult | None = None
int8: QuantizeResult | None = None
parity: dict[str, ParityResult] = {}
"""Parity per file kind: ``fp32``, ``fp16``, ``int8``."""
heads_parity: dict[str, EncoderParityResult] = {}
"""The encoder's parity per file kind: the blunder decision and the drift of ``value``."""
parity_source: str | None = None
"""``validation`` (the positions of ``docs/spec/02`` §6), ``random-walk``, or, for the
encoder, ``validation-labels``."""
parity_warning: str | None = None

src/rukh/export/__init__.pylíneas 59-78 · p3

Dos diccionarios de paridad, parity y heads_parity, en vez de uno con una unión dentro: la misma decisión que las dos filas de la tabla de resultados de la lección 7.

src/rukh/export/__init__.py
def _quantize(bundle: ExportBundle, fp16: bool, int8: bool) -> dict[str, str]:
"""Derive the browser's files from the fp32 graph and return every file to check."""
if fp16:
bundle.fp16 = to_fp16(Path(bundle.onnx.path))
if int8:
bundle.int8 = quantize_int8(Path(bundle.onnx.path))
# The demo loads the fp16 or the int8 file, not the fp32 one, so the context length has to
# travel with them too; neither converter promises to keep the metadata of its input.
for quantized in (bundle.fp16, bundle.int8):
if quantized is not None and bundle.onnx.metadata:
set_metadata(Path(quantized.path), bundle.onnx.metadata)
checks = {"fp32": bundle.onnx.path}
if bundle.fp16 is not None:
checks["fp16"] = bundle.fp16.path
if bundle.int8 is not None:
checks["int8"] = bundle.int8.path
return checks

src/rukh/export/__init__.pylíneas 81-97 · p3

El comentario recoge un fallo de los que solo aparecen en producción: ningún convertidor promete conservar los metadatos de su entrada. La demo carga el fp16 o el int8, nunca el fp32, así que sin copiar los rukh_* el fichero que descarga el navegador no sabría decir qué es.

src/rukh/export/__init__.py
def export_all(
ckpt: Path,
out: Path,
kind: str = "decoder",
opset: int = DEFAULT_OPSET,
seq_len: int = 200,
fp16: bool = False,
int8: bool = False,
check_parity: bool = False,
positions: int = 1_000,
seed: int = 0,
games: Path | None = None,
labels: LabelsConfig | None = None,
split: str = "val",
) -> ExportBundle:
"""Export, quantize and check one checkpoint in a single pass.
For the decoder, parity is measured on validation positions (``games``, or the validation
month of P1 when it is on disk) and falls back to random legal walks with a warning when
there are none. For the encoder it is measured on the held-out labelled positions of
``rukh.data.labels`` and is skipped, with a warning, when those have not been built: a
random board is not a position the demo will ever be asked to evaluate.
"""
if kind not in KINDS:
raise ValueError(f"unknown kind {kind!r}; expected one of {', '.join(KINDS)}")
if kind == "encoder":
return _export_encoder(ckpt, out, opset, fp16, int8, check_parity, positions, labels, split)

src/rukh/export/__init__.pylíneas 100-126 · p3

Con kind="decoder" por defecto, la orden de M2 sigue haciendo lo mismo. La bifurcación es un retorno temprano a una función privada, así que una rama no puede coger por accidente una variable de la otra.

src/rukh/export/__init__.py
def _export_encoder(
ckpt: Path,
out: Path,
opset: int,
fp16: bool,
int8: bool,
check_parity: bool,
positions: int,
labels: LabelsConfig | None,
split: str,
) -> ExportBundle:
"""``export_all(kind="encoder")``: the two heads, the same files, its own parity."""
from rukh.train import load_heads
model, _payload = load_heads(Path(ckpt))
bundle = ExportBundle(onnx=export_encoder_onnx(model, out, opset=opset))
checks = _quantize(bundle, fp16, int8)
if check_parity:
items, source, warning = encoder_parity_positions(model, labels, n=positions, split=split)
bundle.parity_source = source
bundle.parity_warning = warning
if items:
bundle.heads_parity = {
name: encoder_parity(model, Path(path), items, n=positions)
for name, path in checks.items()
}
return bundle

src/rukh/export/__init__.pylíneas 145-171 · p3

Exportar, cuantizar, comprobar: la estructura del decoder. Sin posiciones, heads_parity se queda vacío y el aviso ya está puesto. seq_len falta en la firma a propósito: la longitud la decide el esquema del checkpoint.

src/rukh/export/__init__.py
__all__ = [
"BLUNDER_OUTPUT",
"DEFAULT_GAMES",
"DEFAULT_OPSET",
"ENCODER_OUTPUTS",
"FP16_NAME",
"INPUT_NAME",
"INT8_NAME",
"KINDS",
"MODEL_NAME",
"OUTPUT_NAME",
"QUANTIZED_OPS",
"VALUE_OUTPUT",
"EmbedResult",
"EncoderHeads",
"EncoderParityResult",
"ExportBundle",
"ExportResult",
"LastStepLogits",
"ParityResult",
"QuantizeResult",
"clear_value_info",
"embed_positions",
"encoder_parity",
"encoder_parity_positions",
"export_all",
"export_encoder_onnx",
"export_onnx",
"fen4",
"label_positions",
"parity",
"parity_positions",
"quantize_int8",
"random_prefixes",
"read_metadata",
"sequence_lengths",
"set_metadata",
"target_path",
"to_fp16",
"validation_prefixes",
"verify_dynamic_seq",
"write_metadata",
]

src/rukh/export/__init__.pylíneas 174-216 · p3

La superficie pública es simétrica (export_onnx y export_encoder_onnx, parity y encoder_parity), más embed_positions, que no tiene pareja porque el decoder no produce embeddings.

Los embeddings de posición

src/rukh/export/embed.py
"""Mean-pooled position embeddings: the encoder's secondary output, for phase 2.
``docs/spec/02`` §"Componente 2" asks for one vector per position so that similar positions can
be retrieved later (the A1 agent of phase 2). The vector is ``PositionEncoder.pool(idx, "mean")``
over the ``squares`` tokens of a FEN: the average of the real tokens, which is why padding never
enters it.
A matrix of floats is useless without knowing which row is which position, and a ``.npy`` file
has no room to say so. Every run therefore writes two files: the array, and a parquet sidecar
with ``row`` and ``fen4`` in the array's own order. The four-field FEN is the key P1 uses for
positions and evaluations, so the sidecar joins straight back to them.
"""
from __future__ import annotations
import logging
from pathlib import Path
import polars as pl
from pydantic import BaseModel, ConfigDict
log = logging.getLogger(__name__)
FEN_COLUMN = "fen"
SIDECAR_SUFFIX = ".parquet"
POOLING = "mean"
class EmbedResult(BaseModel):
"""What one embedding run wrote."""
model_config = ConfigDict(extra="forbid")
positions: int
dim: int
array: str
sidecar: str
pooling: str = POOLING
input: str = "squares"
checkpoint: str

src/rukh/export/embed.pylíneas 1-40 · p3

Lo que hay que llevarse es el segundo párrafo del docstring: el índice viaja con los datos. Una matriz de flotantes sin saber qué fila es qué posición no sirve, y un .npy no tiene dónde decirlo. Por eso cada tirada escribe dos ficheros: la matriz y un parquet de acompañamiento (sidecar) con row y fen4 en el mismo orden. Es una caja de fotos con el nombre escrito detrás de cada una.

Cualquier índice vectorial de un RAG guarda lo mismo: cada vector con el identificador del fragmento del que salió. Aquí la clave es el FEN de cuatro campos de las tablas de P1, así que el sidecar se une a ellas directamente.

src/rukh/export/embed.py
def fen4(fen: str) -> str:
"""The four-field FEN: placement, side to move, castling and en passant."""
return " ".join(str(fen).split(" ")[:4])
def _encoder(ckpt: Path, device: str | None = None): # type: ignore[no-untyped-def]
"""The ``PositionEncoder`` inside a checkpoint, whether or not it carries the heads."""
from rukh.models.heads import MultiHead
from rukh.train import load_any
model, _payload, kind = load_any(Path(ckpt), map_location=device or "cpu")
if kind != "encoder":
raise ValueError(f"{ckpt} is a {kind} checkpoint; embeddings need an encoder")
return model.encoder if isinstance(model, MultiHead) else model

src/rukh/export/embed.pylíneas 43-56 · p3

_encoder acepta el checkpoint del preentrenamiento (un PositionEncoder) y el del afinado (un MultiHead): los embeddings solo necesitan el tronco, y load_any (lección 6) distingue los dos.

src/rukh/export/embed.py
def embed_positions(
ckpt: Path,
positions: Path,
out: Path,
batch_size: int = 256,
device: str | None = None,
column: str = FEN_COLUMN,
) -> EmbedResult:
"""Embed every position of a parquet and write the array plus its ``fen4`` sidecar."""
import numpy as np
import torch
from rukh.models.squares import fen_to_tokens
from rukh.train import pick_device
source = Path(positions)
frame = pl.read_parquet(source)
if column not in frame.columns:
raise ValueError(f"{source} has no '{column}' column (found {', '.join(frame.columns)})")
fens = [str(value) for value in frame[column].to_list()]
if not fens:
raise ValueError(f"{source} holds no positions")
where = torch.device(device or pick_device())
encoder = _encoder(Path(ckpt), str(where)).to(where).eval()
if encoder.cfg.input != "squares":
raise ValueError(
f"the checkpoint reads the {encoder.cfg.input!r} scheme; embeddings of a FEN table "
"need the 'squares' scheme, which is the one the labelled positions carry"
)

src/rukh/export/embed.pylíneas 59-88 · p3

Los errores dicen lo que hay además de lo que falta: «falta fen, hay fen4, game_id, ply» se resuelve solo. La tercera validación explica un detalle del lab de la lección 11: los embeddings necesitan el checkpoint de casillas, porque se tokeniza un FEN y un encoder de jugadas recibiría ids de otro vocabulario.

src/rukh/export/embed.py
vectors: list[np.ndarray] = []
with torch.no_grad():
for start in range(0, len(fens), max(1, batch_size)):
chunk = fens[start : start + max(1, batch_size)]
idx = torch.tensor([fen_to_tokens(fen) for fen in chunk], dtype=torch.long)
pooled = encoder.pool(idx.to(where), POOLING)
vectors.append(pooled.detach().float().cpu().numpy())
array = np.concatenate(vectors) if vectors else np.zeros((0, encoder.cfg.d_model), "float32")
target = Path(out)
if target.suffix != ".npy":
target = target / "embeddings.npy"
target.parent.mkdir(parents=True, exist_ok=True)
np.save(target, array.astype("float32"))
sidecar = target.with_suffix(SIDECAR_SUFFIX)
pl.DataFrame(
{
"row": pl.Series("row", range(len(fens)), dtype=pl.UInt32),
"fen4": [fen4(fen) for fen in fens],
}
).write_parquet(sidecar)
log.info("wrote %d embeddings of %d dimensions to %s", array.shape[0], array.shape[1], target)
return EmbedResult(
positions=int(array.shape[0]),
dim=int(array.shape[1]),
array=target.as_posix(),
sidecar=sidecar.as_posix(),
input=encoder.cfg.input,
checkpoint=Path(ckpt).as_posix(),
)

src/rukh/export/embed.pylíneas 89-118 · p3

El sidecar se llama igual que la matriz y vive a su lado, así que no se separan por accidente. float32 explícito porque el pooling puede salir en bfloat16, y no float64 porque un índice de 488 159 × 384 ocuparía 750 MB en vez de 375 por una precisión que nadie usa. La columna row, que parece redundante, mantiene el sidecar válido aunque una herramienta reordene sus filas.

Las órdenes

src/rukh/cli.py
kind: Annotated[
str, typer.Option("--kind", help="What to export: decoder or encoder.")
] = "decoder",

src/rukh/cli.pylíneas 735-737 · p3

src/rukh/cli.py
"""Export a model to ONNX, quantize it and check parity with PyTorch.
The decoder exports its next-move head; ``--kind encoder`` exports the two heads the demo
reads from a position, ``value`` and ``blunder``.
"""
from rukh.export import KINDS, export_all
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
if kind not in KINDS:
typer.echo(f"error: --kind must be one of {', '.join(KINDS)}", err=True)
raise typer.Exit(code=2)

src/rukh/cli.pylíneas 762-772 · p3

La orden de M2 gana --kind, validado a mano con código 2 (uso incorrecto) antes de tocar el disco, y que llega a export_all como una línea nueva:

src/rukh/cli.py
try:
bundle = export_all(
ckpt,
out,
kind=kind,
opset=opset,
seq_len=seq_len,
fp16=fp16,
int8=int8,
check_parity=check_parity,
positions=positions,
games=games,
)
except (ImportError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if as_json:
typer.echo(bundle.model_dump_json(indent=2))
return

src/rukh/cli.pylíneas 773-791 · p3

src/rukh/cli.py
typer.echo(f"exporter: {bundle.onnx.exporter} (opset {bundle.onnx.opset})")
typer.echo(f"kind: {bundle.onnx.kind} -> {', '.join(bundle.onnx.outputs)}")
if bundle.onnx.warning:
typer.echo(f"warning: {bundle.onnx.warning}")
checked = "verified" if bundle.onnx.dynamic_seq_verified else "not verified"
typer.echo(
f"shapes: batch dynamic={bundle.onnx.dynamic_batch}, "
f"sequence dynamic={bundle.onnx.dynamic_seq} ({checked}), block={bundle.onnx.block}"
)
typer.echo(f"fp32: {bundle.onnx.path} ({bundle.onnx.bytes} bytes)")
for quantized in (bundle.fp16, bundle.int8):
if quantized is not None:
typer.echo(
f"{quantized.kind}: {quantized.path} ({quantized.bytes} bytes, "
f"{quantized.ratio:.2f} of fp32, {quantized.method})"
)
for name, result in bundle.parity.items():
typer.echo(
f"parity {name}: {result.agreement:.4f} on {result.positions} "
f"{bundle.parity_source} positions "
f"(max |delta logits| {result.max_abs_logit_delta:.4g})"
)
for name, heads in bundle.heads_parity.items():
typer.echo(
f"parity {name}: {heads.agreement:.4f} of the blunder decisions on "
f"{heads.positions} {bundle.parity_source} positions "
f"(max |delta value| {heads.max_abs_value_delta:.4g})"
)
if bundle.parity_warning:
typer.echo(f"warning: {bundle.parity_warning}")

src/rukh/cli.pylíneas 792-821 · p3

Hay un bucle de paridad por familia y en cada ejecución solo uno tiene entradas. La línea del encoder dice «of the blunder decisions» para que el 1,0000 no se lea como «números idénticos». {checked} distingue un eje dinámico comprobado de uno solo declarado; en casillas imprime not verified porque no había nada que comprobar.

Los embeddings no son de entrenar, evaluar ni exportar, así que estrenan grupo: rukh encoder, con no_args_is_help=True como data_app, porque sin subcomando lo útil es enseñar la ayuda.

src/rukh/cli.py
encoder_app = typer.Typer(help="Encoder utilities: position embeddings.", no_args_is_help=True)
app.add_typer(encoder_app, name="encoder")

src/rukh/cli.pylíneas 40-41 · p3

En rukh encoder embed, Typer comprueba que el parquet y el checkpoint existen (exists=True) antes de cargar nada. --positions pide una columna fen, que tienen la tabla de posiciones de P1 y la de evaluaciones:

src/rukh/cli.py
@encoder_app.command("embed")
def encoder_embed_cmd(
positions: Annotated[
Path,
typer.Option(
"--positions",
exists=True,
dir_okay=False,
readable=True,
help="Parquet with a `fen` column (the P1 positions or evaluations table).",
),
],
out: Annotated[Path, typer.Option("--out", help="Where to write the .npy array.")],
ckpt: Annotated[
Path,
typer.Option(
"--ckpt", exists=True, dir_okay=False, readable=True, help="Encoder checkpoint."
),
],
batch_size: Annotated[
int, typer.Option("--batch-size", help="Positions per forward pass.")
] = 256,
device: Annotated[
str | None, typer.Option("--device", help="Where to run: cuda, cpu... ")
] = None,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,
) -> None:

src/rukh/cli.pylíneas 824-850 · p3

src/rukh/cli.py
"""Write mean-pooled position embeddings plus the `fen4` sidecar that names their rows."""
from rukh.export import embed_positions
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
try:
result = embed_positions(ckpt, positions, out, batch_size=batch_size, device=device)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if as_json:
typer.echo(result.model_dump_json(indent=2))
return
typer.echo(f"positions: {result.positions}")
typer.echo(f"dim: {result.dim} ({result.pooling} pooling, {result.input} scheme)")
typer.echo(f"array: {result.array}")
typer.echo(f"sidecar: {result.sidecar} (row, fen4)")

src/rukh/cli.pylíneas 851-866 · p3

La salida menciona el sidecar y sus columnas, para que nadie se lleve solo el .npy.

Publicar: dos clases de modelo, un camino

src/rukh/publish/model.py
Two kinds of model go out through this one path. A decoder is published with its UCI
vocabulary and the metrics of ``rukh eval``; an encoder (the ``MultiHead`` of M3: the position
encoder plus the value, blunder and result heads) is published with the ``squares`` vocabulary
it reads and the metrics of ``rukh eval encoder``, including the F1 of the material baseline it
has to beat. ``checkpoint_kind`` decides which one a checkpoint is, from the weights themselves,
and the two differ only in the card, the config and the vocabulary file.

src/rukh/publish/model.pylíneas 13-18 · p3

src/rukh/publish/model.py
CARD_TEMPLATE = "model.md.jinja"
ENCODER_CARD_TEMPLATE = "encoder.md.jinja"
CONFIG_NAME = "config.json"
README_NAME = "README.md"
SAFETENSORS_NAME = "model.safetensors"
TORCH_NAME = "pytorch_model.bin"
VOCAB_PATH = "tokenizer/vocab.json"
ONNX_DIR = "onnx"
ONNX_FILES = ("model-fp16.onnx", "model-int8.onnx", "model.onnx")
REPO_TYPE = "model"
ARCHITECTURES = {"decoder": "MoveDecoder", "encoder": "PositionEncoder"}
MODEL_TYPES = {"decoder": "rukh-move-decoder", "encoder": "rukh-position-encoder"}
ENCODER_HEADS = ("value", "blunder", "result")

src/rukh/publish/model.pylíneas 47-59 · p3

ARCHITECTURES y MODEL_TYPES son lo que el Hub lee del config.json; como tablas indexadas por kind, un tercer tipo de modelo sería una entrada más.

src/rukh/publish/model.py
def tied_names(model: Any) -> list[str]:
"""The tied heads of a model: the names whose storage is the embedding's, under another name.
Asked of the tensors rather than of the config, because the encoder's tie lives one level
down (``encoder.mlm_head.weight``) and only exists for the ``moves`` scheme.
"""
state = model.state_dict()
return [
name
for name, source in TIED_SOURCES.items()
if name in state and source in state and state[name].data_ptr() == state[source].data_ptr()
]
def publish_state(model: Any) -> dict[str, Any]:
"""The state dict as it is published: on the CPU, contiguous and with no aliased tensor.
With tied embeddings ``lm_head.weight`` *is* ``tokens.weight``; it is dropped here so the
file holds every tensor exactly once. Loading re-ties it (see the module docstring).
"""
dropped = set(tied_names(model))
return {
key: value.detach().cpu().contiguous()
for key, value in model.state_dict().items()
if key not in dropped
}

src/rukh/publish/model.pylíneas 172-197 · p3

En M2 bastaba leer model.cfg.tie_embeddings para saber si la cabeza estaba atada. En el encoder el atado solo existe en el esquema de jugadas y vive un nivel más abajo, dentro del MultiHead, así que se pregunta a los tensores: dos nombres están atados si apuntan a la misma memoria, y data_ptr() es esa dirección. Hay que detectarlo porque safetensors se niega a serializar un alias y porque contar los dos nombres inflaría los parámetros en una tabla de embeddings entera. El nombre atado se quita y el cargador lo vuelve a atar con TIED_HEADS (lección 6).

src/rukh/publish/model.py
def write_config(
payload: dict[str, Any],
stage: str,
params: int,
folder: Path,
kind: str = "decoder",
) -> dict[str, Any]:
"""Write ``config.json``: the model's shape plus the provenance of the checkpoint."""
model_cfg = dict(payload.get("model_cfg") or {})
scheme = str(model_cfg.get("input") or "moves")
encoder = kind == "encoder"
config = {
"architectures": [ARCHITECTURES.get(kind, ARCHITECTURES["decoder"])],
"model_type": MODEL_TYPES.get(kind, MODEL_TYPES["decoder"]),
"library_name": "rukh",
"rukh_version": __version__,
"stage": stage,
"step": payload.get("step"),
"params": params,
"tokenizer": scheme if encoder else "uci",
"vocab_hash": payload.get("vocab_hash"),
"data_manifest_sha": payload.get("data_manifest_sha"),
"git_sha": payload.get("git_sha"),
**(
{
"heads": list(ENCODER_HEADS),
"pooling": _pooling(payload),
"pretrained_from": _pretrained_from(payload),
}
if encoder
else {}
),
**model_cfg,
}

src/rukh/publish/model.pylíneas 221-254 · p3

Las tres claves del encoder solo aparecen cuando hacen falta, en vez de un null en el config del decoder: una clave ausente y una nula no dicen lo mismo. **model_cfg va al final, así que la forma del modelo entra sin enumerarla y gana en una colisión de nombres, porque es el dato primario.

src/rukh/publish/model.py
def _pooling(payload: dict[str, Any]) -> str:
"""How the published encoder pools its tokens, as its fine-tuning run recorded it."""
return str((payload.get("cfg") or {}).get("pooling") or "mean")
def _pretrained_from(payload: dict[str, Any]) -> str | None:
"""The masked-move checkpoint the heads were fine-tuned from, or ``None`` for from scratch.
``encoder_ckpt`` is ``None`` in a perfectly legitimate run — training the encoder from
scratch is the baseline the pretraining has to beat — so the card cannot claim masked move
modeling unconditionally; this is the fact it gates that claim on.
"""
found = (payload.get("cfg") or {}).get("encoder_ckpt")
return str(found) if found else None

src/rukh/publish/model.pylíneas 261-274 · p3

_pretrained_from existe para que la model card no mienta: la línea de casillas se entrena desde pesos aleatorios, y decir «preentrenado con masked move modeling» sin condición sería falso en la mitad de los casos.

src/rukh/publish/model.py
def write_vocab(folder: Path, kind: str, scheme: str) -> str:
"""Write the vocabulary the model actually reads, and return its path in the repository.
A decoder (and an encoder on the ``moves`` scheme) reads the P1 UCI enumeration; an encoder
on the ``squares`` scheme reads the 47 fixed square tokens, which are just as much part of
the released model: without them the 69 integers of an input mean nothing.
"""
if kind == "encoder" and scheme == "squares":
from rukh.models.squares import SQUARE_TOKENS, SQUARE_VOCAB, vocab_hash
path = folder / VOCAB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": 1,
"scheme": "squares",
"size": len(SQUARE_VOCAB),
"sequence": SQUARE_TOKENS,
"vocab_hash": vocab_hash(),
"tokens": list(SQUARE_VOCAB),
}
path.write_text(
json.dumps(payload, indent=0, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n"
)
return VOCAB_PATH
UciTokenizer().export(folder / VOCAB_PATH)
return VOCAB_PATH

src/rukh/publish/model.pylíneas 277-302 · p3

El vocabulario es tan parte del modelo como los pesos, igual que el tokenizador de cualquier modelo de lenguaje del Hub. Con el vocab_hash() dentro, quien lo descargue puede comprobar que su copia del código produce la misma enumeración.

src/rukh/publish/model.py
def encoder_card_context(
repo_id: str,
stage: str,
cfg: ModelPublishConfig,
config: dict[str, Any],
evaluation: dict[str, Any] | None,
run: RunSummary | None,
files: list[str],
) -> dict[str, Any]:
"""Everything the encoder card needs: its metrics, the baseline's, and the input scheme."""
from rukh.eval.encoder import base_rate_caveat, sentence
measured = evaluation or {}
encoder = measured.get("encoder_blunder") or {}
fixed = measured.get("encoder_blunder_fixed") or {}
ranked = measured.get("encoder_blunder_ranking") or {}
baseline = measured.get("heuristic_blunder") or {}
value = measured.get("encoder_value") or {}
margin = measured.get("f1_margin")
tuned = measured.get("threshold_tuned")
configured = measured.get("threshold_fixed")
metrics = [
(
"Blunder F1" + ("" if tuned is None else f" (tuned, `p >= {float(tuned):.4g}`)"),
_percent(encoder.get("f1")),
),
("Blunder precision", _percent(encoder.get("precision"))),
("Blunder recall", _percent(encoder.get("recall"))),
(
"Blunder F1"
+ ("" if configured is None else f" (fixed, `p >= {float(configured):.4g}`)"),
_percent(fixed.get("f1")),
),
("Blunder ROC AUC", _ratio(ranked.get("roc_auc"))),
("Blunder average precision", _ratio(ranked.get("average_precision"))),
("Blunder base rate", _percent(measured.get("blunder_base_rate"))),
("Blunder F1, material baseline", _percent(baseline.get("f1"))),
(
"Margin over the baseline",
"n/a" if margin is None else f"{float(margin):+.1f} F1 points",
),
("Value vs Stockfish cp, Pearson", _ratio(value.get("pearson"))),
("Value vs Stockfish cp, Spearman", _ratio(value.get("spearman"))),
("Result accuracy", _percent(measured.get("result_accuracy"))),
]

src/rukh/publish/model.pylíneas 410-454 · p3

La tabla se construye leyendo el results.json de la suite, no a mano, y como el informe pone el número malo y la referencia junto al bueno. Importar base_rate_caveat hace que la advertencia de la tasa base sea la misma frase en el informe y en la card.

src/rukh/publish/model.py
curve = [
(f"{float(point.get('fraction', 0)) * 100:.0f} %", point.get("train_labels"))
for point in measured.get("label_curve", [])
if isinstance(point, dict)
]

src/rukh/publish/model.pylíneas 455-459 · p3

src/rukh/publish/model.py
return {
"repo_id": repo_id,
"stage": stage,
"license": cfg.license,
"datasets": cfg.datasets,
"demo_url": f"{cfg.demo_url}/?stage={stage}",
"course_url": cfg.course_url,
"repository_url": cfg.repository_url,
"params": config.get("params", 0),
"config": config,
"config_json": json.dumps(config, indent=2, ensure_ascii=False),
"scheme": config.get("tokenizer", "squares"),
"pooling": config.get("pooling", "mean"),
"pretrained_from": config.get("pretrained_from"),
"heads": list(config.get("heads", ENCODER_HEADS)),
"metrics": metrics,
"curve": curve,
"positions": measured.get("items"),
"blunder_items": measured.get("blunder_items"),
"blunder_caveat": sentence(base_rate_caveat(measured.get("blunder_base_rate"))),
"tune_items": measured.get("tune_items"),
"score_items": measured.get("score_items"),
"tune_games": measured.get("tune_games"),
"score_games": measured.get("score_games"),
"threshold_tuned": tuned,
"evaluated_on": measured.get("date"),
"notes": [str(note) for note in measured.get("notes", []) if str(note).strip()],
"run_id": run.run_id if run else None,
"recipe": sorted((run.params if run else {}).items()),
"files": files,
"has_onnx": any(name.startswith(f"{ONNX_DIR}/") for name in files),
"rukh_version": __version__,
}

src/rukh/publish/model.pylíneas 460-492 · p3

Lo que no es métrica sale de config o de MLflow. "recipe" se ordena para que la card salga igual en dos ejecuciones, y "has_onnx" se calcula de los ficheros copiados, así que la card no puede prometer uno que no está. La curva por etiquetas completa aquí su viaje: del .pt (lección 6) al results.json de rukh eval encoder, y de ahí a la tabla «How many labels it takes».

src/rukh/publish/model.py
"blunder_caveat": sentence(base_rate_caveat(measured.get("blunder_base_rate"))),
"tune_items": measured.get("tune_items"),
"score_items": measured.get("score_items"),
"tune_games": measured.get("tune_games"),
"score_games": measured.get("score_games"),
"threshold_tuned": tuned,
"evaluated_on": measured.get("date"),
"notes": [str(note) for note in measured.get("notes", []) if str(note).strip()],

src/rukh/publish/model.pylíneas 479-486 · p3

Las notas de la suite pasan enteras a la card (dónde se eligió el umbral, que la exactitud no significa nada con esta tasa base…), al lado de las cifras.

src/rukh/publish/model.py
def render_card(context: dict[str, Any], template: str = CARD_TEMPLATE) -> str:
"""Render the English model card."""
env = Environment(
loader=FileSystemLoader(str(CARDS_DIR)),
undefined=StrictUndefined,
autoescape=False,
keep_trailing_newline=True,
)
return env.get_template(template).render(**context)

src/rukh/publish/model.pylíneas 495-503 · p3

src/rukh/publish/model.py
def publish_model(
ckpt: Path,
repo: str,
cfg: ModelPublishConfig | None = None,
onnx_dir: Path | None = None,
stage: str | None = None,
run_id: str | None = None,
dry_run: bool = False,
) -> ModelPublishResult:
"""Stage (and unless ``dry_run``, upload) one trained model as a Hub model repository."""
from rukh.train import load_any
cfg = cfg or ModelPublishConfig()
ckpt = Path(ckpt)
repo_id = repo if "/" in repo else f"{cfg.owner}/{repo}"
name = stage or repo_id.split("/")[-1].removeprefix("rukh-")

src/rukh/publish/model.pylíneas 506-521 · p3

removeprefix("rukh-") convierte chorcat/rukh-encoder en la etapa encoder, el nombre del informe de la suite. En render_card la línea que importa es StrictUndefined: por defecto Jinja pinta como cadena vacía una variable que falta, y la card publicada tendría un hueco en vez de un error. Es el extra="forbid" de M0 aplicado a plantillas.

src/rukh/publish/model.py
model, payload, kind = load_any(ckpt)
state = publish_state(model)
params = (
model.num_params(non_embedding=False)
if hasattr(model, "num_params")
else sum(parameter.numel() for parameter in model.parameters())
)
folder = resolve(cfg.publish_dir) / repo_id
folder.mkdir(parents=True, exist_ok=True)
weights_name, weights_format = write_weights(state, folder)
config = write_config(payload, name, params, folder, kind)
write_vocab(folder, kind, str(config.get("input") or "moves"))
files = [weights_name, CONFIG_NAME, VOCAB_PATH, *copy_onnx(onnx_dir, folder)]
run = read_run(run_id, run_name=ckpt.parent.name)
evaluation = read_eval(name, cfg)
card = (
render_card(
encoder_card_context(repo_id, name, cfg, config, evaluation, run, files),
ENCODER_CARD_TEMPLATE,
)
if kind == "encoder"
else render_card(card_context(repo_id, name, cfg, config, evaluation, run, files))
)

src/rukh/publish/model.pylíneas 522-546 · p3

src/rukh/publish/model.py
if not dry_run:
api = _api()
api.create_repo(repo_id, repo_type=REPO_TYPE, exist_ok=True)
api.upload_folder(
repo_id=repo_id,
folder_path=str(folder),
repo_type=REPO_TYPE,
commit_message=f"Publish {name}",
)
return ModelPublishResult(
repo_id=repo_id,
kind=kind,
stage=name,
dry_run=dry_run,
folder=folder.as_posix(),
card_path=card_path.as_posix(),
files=[*files, README_NAME],
weights_format=weights_format,
run_id=run.run_id if run else None,
params=params,
tied_embeddings=bool(tied_names(model)),
)

src/rukh/publish/model.pylíneas 550-571 · p3

dry_run corta después de escribirlo todo y antes de tocar la red: la carpeta queda completa para revisar la card. tied_embeddings se publica observado, no copiado de la configuración. El hasattr(model, "num_params") está porque MultiHead no tiene ese método (añadírselo sería más limpio).

La plantilla de la model card

La plantilla es lo que la gente va a leer en el Hub, y las decisiones están en sus condicionales.

src/rukh/data/cards/encoder.md.jinja
---
license: {{ license }}
library_name: rukh
pipeline_tag: feature-extraction
language:
- en
datasets:
{%- for dataset in datasets %}
- {{ dataset }}
{%- endfor %}
tags:
- chess
- rukh
- encoder
- onnx
---
# {{ repo_id }}
A bidirectional transformer written from scratch that reads a chess **position** and says three
things about it: how good it is, whether the move that led to it threw the game away, and how
the game is going to end. This is the `{{ stage }}` stage of [Rukh]({{ repository_url }}), a
course that builds a chess language model end to end: {{ "{:,}".format(params) }} parameters
over {{ config.get("n_layer", 8) }} layers of width {{ config.get("d_model", 384) }},
{% if pretrained_from %}pretrained with masked move modeling and fine-tuned on Stockfish
labels{% else %}trained from scratch on Stockfish labels, with no pretraining stage{% endif %}.
See it evaluate a live game: [{{ demo_url }}]({{ demo_url }}) · read how it was built:
[{{ course_url }}]({{ course_url }})
## Results
{% if evaluated_on %}Measured with `rukh eval encoder` on {{ evaluated_on }}, over
{{ positions }} held-out positions ({{ blunder_items }} of them with a blunder label).{% else %}
Not evaluated yet: run `rukh eval encoder --model <checkpoint>` to fill this table.{% endif %}
| Metric | Value |
|---|---|
{%- for name, value in metrics %}
| {{ name }} | {{ value }} |
{%- endfor %}
### How the blunder numbers were measured
{{ blunder_caveat }}
{% if threshold_tuned is not none %}
The labelled rows are cut in two **by game**, never by position: the `tune` half
({{ tune_items }} rows, {{ tune_games }} games) chose the threshold `p >= {{ threshold_tuned }}`
by maximising F1 there, and the `score` half ({{ score_items }} rows, {{ score_games }} games) is
where the F1, the precision and the recall in the table are measured. No game is in both halves,
and the threshold never saw the rows it is scored on. The F1 at the fixed threshold is in the
same table so that the operating point cannot hide anything.
The material baseline is a hard yes/no rule: it has no threshold, so nothing was tuned on its
side and it was given no half to tune on. The margin compares the model at its best operating
point against the rule at its only one.
{% endif %}
### The baseline these numbers are compared against
The blunder head is only interesting if it beats a program that understands nothing. The
baseline in the table is deliberately the dumbest thing that can judge a move: **material**
(pawn 1, knight 3, bishop 3, rook 5, queen 9) plus **mobility** (legal moves, 0.05 of a pawn
each), calling a move a blunder when it loses at least one point of net material after one ply
of the opponent's captures. It is measured on exactly the same rows as the model, and the
project's target is five F1 points above it.
What the baseline cannot see is the point: a positional sacrifice is invisible to it. Given
Fischer's 17...Be6 against Byrne (New York, 1956), it sees a queen hanging and calls one of the
most famous moves in chess a nine-point blunder.
The held-out split is drawn **by game**, never by position: two positions of the same game are
the same game one move apart, and splitting between them would leak.
{% if curve %}
### How many labels it takes
| Labels | Training rows |
|---|---|
{%- for fraction, rows in curve %}
| {{ fraction }} | {{ rows if rows is not none else "n/a" }} |
{%- endfor %}
{% endif %}
{% if notes %}
How to read these numbers:
{% for note in notes %}- {{ note }}
{% endfor %}{% endif %}
## Input and output
The model reads the `{{ scheme }}` scheme.
{% if scheme == "squares" %}
A position is 69 fixed tokens, in this order: `<cls>`, the 64 squares (a piece or `<empty>`,
file-major from a1 to h8), the side to move, the four castling rights as one token of sixteen
combinations, the en-passant file, and the halfmove clock in four buckets. There is never any
padding. The enumeration ships in `tokenizer/vocab.json` and is fixed by construction, not
learned from data.
{% else %}
A position is the game that led to it, in the same UCI vocabulary the decoder was trained on,
which ships in `tokenizer/vocab.json`: `<bos>`, the two Elo tokens, and the moves up to the
position, cropped from the left while keeping those three header tokens.
One caveat that comes with this scheme: the supervised table is deduplicated by four-field FEN,
so the prefix is **a** line that reaches the position, not necessarily the one the labelled game
played. The position, the value and the blunder verdict are the same either way; the history may
not be.
{% endif %}
Tokens are pooled with `{{ pooling }}` pooling into one vector per position, and three linear
heads read that vector: {% for head in heads %}`{{ head }}`{% if not loop.last %}, {% endif %}{% endfor %}.
`value` is `tanh(cp / 400)` from White's point of view, `blunder` is a logit in PyTorch (and a
probability in the exported graph), and `result` is three classes (White, draw, Black).
The pooled vector is itself a released output: it is the position embedding used to retrieve
similar positions (`rukh encoder embed --positions <parquet> --out <npy>`).
## Files
{% for file in files %}- `{{ file }}`
{% endfor %}
{%- if has_onnx %}
The ONNX graph returns **two** outputs, `value` and `blunder`, because that is all the demo's
evaluation bar and blunder alert need; `blunder` comes out as a probability, so the page
compares it against 0.5. `model-fp16.onnx` is for WebGPU and `model-int8.onnx` for the WASM
fallback. The metadata carries `rukh_kind=encoder` and `rukh_heads`.
{%- else %}
This release carries the PyTorch weights only; the ONNX files the browser demo loads are built
with `rukh export --ckpt <checkpoint> --kind encoder --out <dir> --fp16 --int8`.
{%- endif %}
## Training recipe
{% if recipe %}
| Parameter | Value |
|---|---|
{%- for name, value in recipe %}
| `{{ name }}` | `{{ value }}` |
{%- endfor %}
{% else %}
The MLflow run for this checkpoint was not available when the card was generated; the shape of
the model is in `config.json`.
{% endif %}
{% if run_id %}MLflow run: `{{ run_id }}`.{% endif %}
```json
{{ config_json }}
```
## Data
{% if pretrained_from %}Pretrained with masked move modeling on {% for dataset in datasets %}[`{{ dataset }}`](https://huggingface.co/datasets/{{ dataset }}){% if not loop.last %}, {% endif %}{% endfor %},
derived from the [Lichess open database](https://database.lichess.org) (CC0), and fine-tuned on
positions{% else %}The encoder of this release was **not** pretrained: it was initialised at
random and trained directly on the labels, which is the honest baseline a pretrained encoder has
to beat. Its data is the {% for dataset in datasets %}[`{{ dataset }}`](https://huggingface.co/datasets/{{ dataset }}){% if not loop.last %}, {% endif %}{% endfor %} positions{% endif %}
crossed with the Lichess Stockfish evaluations: `value` and `blunder` come from those
scores, `result` from the game the position was played in.
{% if pretrained_from %}
The pretraining checkpoint the heads started from: `{{ pretrained_from }}`.
{% endif %}
## Limitations
- It has no search. It judges a position from the position, so a tactic that needs three moves
to appear is one it can only guess at.
- The blunder label is a threshold on a Stockfish score (100 centipawns lost against the best
line of the previous position), not a human judgement of what counts as a mistake.
- `result` is the noisiest of the three heads: every position of a game carries the same label,
including the ones played before anything was decided.
- The evaluations come from community analysis of varying depth, so the `value` head inherits
whatever bias that has.
- It was trained on games between 1800+ humans on Lichess; the positions it knows best are the
positions those games reach.
## License
{{ license | upper }}. The code and the weights are released under the Apache License 2.0;
the training data comes from Lichess under CC0. Please credit Lichess when you use them.
Generated with `rukh` {{ rukh_version }}.

src/rukh/data/cards/encoder.md.jinjalíneas 1-178 · p3

Lo que en esta plantilla es decisión y no redacción:

  • pipeline_tag: feature-extraction, el tag con el que el Hub clasifica los modelos de embeddings de texto: este modelo no genera, produce una representación.
  • Cada condicional evita una afirmación falsa: preentrenado o desde cero, cifras o «todavía sin evaluar», 69 casillas o línea de jugadas con su asterisco.
  • El baseline va antes de las cifras, con la partida de Fischer como ejemplo de lo que no ve. Es lo que hace que un F1 del 18 % se entienda en vez de descartarse.
  • Las limitaciones son concretas («no tiene búsqueda»), nada de «puede producir resultados inesperados».
  • El config.json entero va dentro, con la versión del código, el SHA de git y el hash del vocabulario.

Los tests de la exportación

tests/unit/test_export_encoder.py exporta, cuantiza y ejecuta un encoder de juguete. Comprueba que:

  • el grafo tiene solo las salidas value y blunder, y los metadatos dicen rukh_kind=encoder;
  • el fichero corre con dos filas y con una (la especialización 0/1);
  • el eje de secuencia es dinámico en jugadas y no en casillas, verificado ejecutándolo;
  • encoder_parity detecta un desacuerdo inventado, y sin etiquetas la paridad se salta con aviso;
  • publish_model escribe la card del encoder, el vocabulario de casillas y architectures: ["PositionEncoder"].

// Ejercicio 01Por qué la paridad del encoder no tiene respaldo y la del decoder sí

El decoder, cuando no encuentra las partidas de validación, mide la paridad sobre paseos legales aleatorios con un aviso. El encoder no mide nada. Argumenta por qué las dos decisiones son correctas, y di qué tendría que existir para que el encoder pudiera tener un respaldo honesto.

// SoluciónVer la solución

La paridad mide si dos implementaciones de la misma función, PyTorch y el grafo ONNX, coinciden sobre unas entradas. Un paseo aleatorio legal sigue siendo una entrada del tipo que el decoder verá, una secuencia de jugadas legales: la medida es peor, y por eso va con aviso, pero no engaña.

En el encoder lo que se compara es una decisión umbralizada en 0,5, y en una posición absurda la cabeza trabaja en un régimen que no vio al entrenar. Si todas esas probabilidades cayeran pegadas al cero, las dos implementaciones coincidirían siempre y la paridad daría 1,0000 sin haber comprobado nada. Un número que no puede fallar no es una medida.

Un respaldo honesto tendría que ser un conjunto de posiciones plausibles y fijas, independiente de la tabla de etiquetas: por ejemplo unas mil posiciones muestreadas de partidas reales y guardadas en tests/fixtures/ o en gold/. No existe en p3, y por eso la decisión es no medir.

Qué has aprendido

Exportar un modelo por un camino que ya existía es sobre todo escribir un envoltorio con lo que el consumidor necesita: dos salidas, el sigmoide dentro, la cabeza que nadie dibuja fuera. Los problemas de verdad estaban en el formato: una dimensión de ejemplo que vale 1 y se especializa, anotaciones opcionales que tumban la cuantización y un Cast cuyo tipo declarado y cuyo atributo dejan de coincidir. Y te llevas dos costumbres para cualquier modelo: el índice viaja con los datos, y una propiedad que se puede observar (como el atado de una cabeza) se observa en vez de leerla de la configuración.

Cómo se mide: uv run rukh export --ckpt <checkpoint> --kind encoder --out artifacts/onnx/encoder --fp16 --int8 --check-parity escribe los tres ficheros e imprime la coincidencia de las decisiones de error en las tres precisiones. uv run rukh publish model --ckpt <checkpoint> --repo rukh-encoder --dry-run deja la carpeta lista con su card, su config.json y su vocabulario sin tocar la red. Las cifras reales —60,7 / 30,6 / 18,3 MB y el 100 % de coincidencia— están en la lección 11.

Lo siguiente es el otro lado de la frontera: qué hace la demo con ese fichero de 30 MB.