rukh · lab

// M4 · lección 08

El selector en la demo: seis condiciones y 1,6 MB

El código de la demo en el commit del hito: el registro que ofrece las seis condiciones medidas y ninguna más, el contrato que lee del propio fichero ONNX cuánto mide un adaptador válido, el worker que sube 1,6 MB en vez de descargar 231, y la prueba de navegador que lo comprueba pidiendo un solo .onnx.

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

Lección 8 de 10 del módulo «Fine-tuning e instrucción». Viene de «Diversidad y barridos» y sigue en «Lo que salió». Todo el código de esta lección es de rukh-web, en el commit 8210ff1.

Qué vas a construir

Los dos controles que este hito enciende en la demo. El selector «Elo objetivo» llevaba en pantalla y desactivado desde M2: encenderlo es una línea, y lo que hay que decidir es qué ofrece. El de estilo es nuevo: carga 1,6 MB sobre el modelo ya descargado y cambia lo que el modelo quiere jugar sin pedir otro .onnx. Y una prueba de navegador comprueba esa promesa contando los ficheros de modelo descargados.

El registro: seis, no veintisiete

src/lib/registry.ts
/**
* Elo headers the "juega como" selector offers, and the reason it is a short list.
*
* These are the conditions that were actually *measured*, one full run of the suite each
* (`rukh eval sweep`, M4). The vocabulary has twenty-seven of them and the model will answer any,
* but offering twenty-seven would promise twenty-seven measurements and there are six. A control
* a player turns is a claim that turning it does something, and the claim is only as wide as the
* evidence behind it.
*/
export const ELO_TARGETS = [1200, 1500, 1800, 2000, 2100, 2400] as const;
/** The header a conditioned stage starts at: the one every published number was read at. */
export const DEFAULT_ELO = 1800;

rukh-web/src/lib/registry.tslíneas 72-84 · 8210ff1

El vocabulario tiene veintisiete cabeceras de Elo y el modelo contesta a todas. El selector ofrece seis: las que se midieron. Una carta de restaurante solo lista los platos que la cocina ha probado; una con veintisiete entradas de las que se han cocinado seis miente en veintiuna líneas.

DEFAULT_ELO = 1800 es la cabecera con la que se leyó cada número publicado.

src/lib/registry.ts
/**
* True when the stage's ONNX takes its LoRA factors as inputs, so styles can be swapped.
*
* It is not a different model: fed an adapter of zeros the graph computes exactly what the
* plain export computes, which is checked on both sides (`tests/unit/test_export_adapter.py`
* in `rukh`, `readAdapterShape` here). What the flag really says is that the file declares
* `lora_a` and `lora_b`, so the worker has to feed them on every call.
*/
adaptable?: boolean;

rukh-web/src/lib/registry.tslíneas 17-25 · 8210ff1

Una etapa adaptable no es otro modelo: con un adaptador de ceros calcula lo mismo que la exportación normal. La bandera dice que el fichero declara lora_a y lora_b, y que el worker tiene que alimentarlos en cada llamada.

src/lib/registry.ts
/**
* A style adapter: 1.6 MB of LoRA factors that change how the model opens and nothing else.
*
* They are offered only on stages marked `adaptable`, and they are tied to the base model they
* were trained on — an adapter is a correction to specific weights, so the same file over another
* checkpoint is noise. The worker refuses one whose size does not match what the graph declares,
* which is the cheap half of that check; the other half is that this list only ever names
* adapters of the stage they sit next to.
*/
export interface Adapter {
id: string;
label: string;
/** What it does, in the words a player would use. */
hint: string;
repo: string;
/** Path of the flat float32 file inside the repo (`web/adapter.bin`). */
file: string;
/** Measured, like `STAGE_SIZE_MB`: the worker checks the real length against the graph. */
sizeBytes: number;
/** Stage ids this adapter may be loaded into. */
stages: readonly string[];
}
/** Adapter sizes in bytes, **measured** on the published files. */
export const ADAPTER_BYTES = 1_572_864;
export const NO_ADAPTER = 'none';
export const ADAPTERS: Adapter[] = [
{
id: 'lora-e4',
label: 'Abre 1. e4',
hint: 'Abre con el peón de rey el 99,9 % de las veces',
repo: 'chorcat/rukh-lora-e4',
file: 'web/adapter.bin',
sizeBytes: ADAPTER_BYTES,
stages: ['medium-lora-fp16', 'medium-lora-int8'],
},
{
id: 'lora-d4',
label: 'Abre 1. d4',
hint: 'Abre con el peón de dama el 99,9 % de las veces',
repo: 'chorcat/rukh-lora-d4',
file: 'web/adapter.bin',
sizeBytes: ADAPTER_BYTES,
stages: ['medium-lora-fp16', 'medium-lora-int8'],
},
];

rukh-web/src/lib/registry.tslíneas 86-133 · 8210ff1

Un adaptador es una corrección a unos pesos concretos, así que el mismo fichero sobre otro checkpoint es ruido. Por eso stages enumera en qué etapas se puede cargar y sizeBytes guarda el tamaño real del fichero publicado, que el worker comprueba.

El resto del registro es fontanería de la demo y se puede saltar en la primera lectura.

// FontaneríaEl resto del registro: la URL del adaptador, las etapas de Elo y los tamaños prometidos
src/lib/registry.ts
/** The adapters offered for one stage; empty for a stage that cannot take them. */
export function adaptersFor(stage: Stage): Adapter[] {
if (!stage.adaptable) return [];
const offered = stage.id === 'test-lora' ? [TEST_ADAPTER] : ADAPTERS;
return offered.filter((adapter) => adapter.stages.includes(stage.id));
}
/** One adapter by id, or `null` for `NO_ADAPTER` and for anything unknown. */
export function findAdapter(id: string): Adapter | null {
if (id === TEST_ADAPTER.id) return TEST_ADAPTER;
return ADAPTERS.find((adapter) => adapter.id === id) ?? null;
}
/** Hub URL of an adapter's browser file, or the same-origin path of the toy one. */
export function adapterUrl(adapter: Adapter): string {
if (!adapter.repo || !adapter.file) return '/test/toy-lora.bin';
return hubUrl(adapter.repo, adapter.file);
}

rukh-web/src/lib/registry.tslíneas 135-152 · 8210ff1

adapterUrl cae al fichero de juguete del mismo origen cuando el adaptador no tiene repositorio, y así el E2E recorre el camino entero sin red.

src/lib/registry.ts
// `medium` fine-tuned on an Elo-balanced corpus (M4). The only stages that answer the "juega
// como" selector: the twelve headers below 1800 had never received a gradient until this run,
// so on every other stage the control would be asking the model to read noise.
{
id: 'medium-elo-fp16',
label: 'Rukh medium + Elo (fp16)',
kind: 'onnx',
repo: 'chorcat/rukh-medium-elo',
file: 'onnx/model-fp16.onnx',
sizeMb: STAGE_SIZE_MB['medium-elo-fp16'],
block: DEFAULT_BLOCK,
eloConditioned: true,
},
{
id: 'medium-elo-int8',
label: 'Rukh medium + Elo (int8)',
kind: 'onnx',
repo: 'chorcat/rukh-medium-elo',
file: 'onnx/model-int8.onnx',
sizeMb: STAGE_SIZE_MB['medium-elo-int8'],
block: DEFAULT_BLOCK,
eloConditioned: true,
},
// The same weights as `medium`, exported with the LoRA factors as inputs of the graph, in a
// repository of its own. A separate stage and not a replacement because the file is a separate
// download: somebody who already has `medium-fp16` cached should not lose it to a feature they
// may never use, and fed an adapter of zeros this graph computes exactly what that one does.
{
id: 'medium-lora-fp16',
label: 'Rukh medium + estilo (fp16)',
kind: 'onnx',
repo: 'chorcat/rukh-medium-lora',
file: 'onnx/model-fp16.onnx',
sizeMb: STAGE_SIZE_MB['medium-lora-fp16'],
block: DEFAULT_BLOCK,
adaptable: true,
},
{
id: 'medium-lora-int8',
label: 'Rukh medium + estilo (int8)',
kind: 'onnx',
repo: 'chorcat/rukh-medium-lora',
file: 'onnx/model-int8.onnx',
sizeMb: STAGE_SIZE_MB['medium-lora-int8'],
block: DEFAULT_BLOCK,
adaptable: true,
},
];

rukh-web/src/lib/registry.tslíneas 214-261 · 8210ff1

Solo las etapas de Elo contestan al selector, porque en cualquier otra los tokens por debajo de 1800 siguen sin gradiente: es la lección 2 dicha en la interfaz. Y medium-lora es una etapa aparte para que quien ya tiene medium-fp16 en caché no lo pierda por una función que puede no usar nunca.

src/lib/registry.ts
/**
* Download sizes in MB, **measured** on the published files rather than estimated. They drive
* the consent copy, which is the last thing a reader sees before the browser fetches tens of
* megabytes, so an estimate that drifts from the file is a promise the page does not keep. Read
* them back with `python -c "import os; print(os.path.getsize(...))"` whenever a stage is
* re-exported: `small` and the encoder both changed size when their weights did.
*
* Every number here was re-read from the staged `onnx` folders on 2026-09-20 and every
* one of them was wrong -- by 6 to 10 MB, always low, because they had been carried over from the
* plan's estimates and never corrected. The consent step had been under-promising every download
* since P2.
*/
export const STAGE_SIZE_MB = {
'tiny-int8': 8,
'small-fp16': 79,
'small-int8': 43,
'medium-fp16': 231,
'medium-int8': 122,
'medium-dpo-fp16': 231,
'medium-elo-fp16': 231,
'medium-elo-int8': 122,
// The swappable export is 438 KB bigger than the ordinary one -- the cost of a graph that takes
// its LoRA factors as inputs is 0.19 % of the file, which is the whole argument for doing it.
'medium-lora-fp16': 232,
'medium-lora-int8': 122,
} as const;

rukh-web/src/lib/registry.tslíneas 42-67 · 8210ff1

Los tamaños que la página promete antes de descargar se leen de los ficheros publicados con os.path.getsize, no de estimaciones. Y la línea que resume el hito: la exportación intercambiable es 438 KB más grande que la normal, el 0,19 % del fichero.

El contrato: el fichero dice cuánto mide su adaptador

src/lib/contract.ts
adapter: AdapterShape | null;
}
/**
* The two tensors a style-swappable graph is fed on every call, and how many floats they are.
*
* This is the one piece of the adapter format the browser does **not** have to be told: the
* exporter declares `lora_a` and `lora_b` with fixed shapes, so the file itself says what an
* adapter for it must look like. A downloaded buffer of the wrong length is then a mismatch the
* page can name instead of a silent misread of somebody else's adapter.
*/
export interface AdapterShape {
a: number[];
b: number[];
/** `prod(a) + prod(b)`: the float32 count of a valid adapter file for this graph. */
floats: number;
}
/** Inputs a graph exported with `rukh export --adapter-inputs` adds, in the order it takes them. */
export const ADAPTER_INPUTS = ['lora_a', 'lora_b'] as const;

rukh-web/src/lib/contract.tslíneas 26-45 · 8210ff1

La parte elegante del diseño: el exportador declara lora_a y lora_b con formas fijas, así que el propio fichero dice qué forma debe tener un adaptador para él. Un búfer de la longitud equivocada es un desajuste que la página puede nombrar, en vez de una lectura silenciosa del adaptador de otro modelo. Es la cerradura que dice qué llave le entra.

src/lib/contract.ts
/**
* The shape of the LoRA factors this graph takes, or `null` when it takes none.
*
* A graph that declares the inputs but not their size cannot be fed at all — there is nothing to
* build the zero adapter from — so that case throws rather than pretending the stage is ordinary.
*/
export function readAdapterShape(session: SessionLike): AdapterShape | null {
const inputs = session.inputNames ?? [];
const present = ADAPTER_INPUTS.filter((name) => inputs.includes(name));
if (present.length === 0) return null;
if (present.length !== ADAPTER_INPUTS.length) {
throw new Error(
`El modelo declara ${present.join(' y ')} pero no ${ADAPTER_INPUTS.filter(
(name) => !present.includes(name),
).join(' y ')}: no es un fichero con adaptadores intercambiables.`,
);
}
const shapes = ADAPTER_INPUTS.map((name) => fixedShape(session.inputMetadata, name));
if (shapes.some((shape) => shape === null)) {
throw new Error(
'El modelo toma los factores de LoRA pero no declara su tamaño, así que no se puede ' +
'construir el adaptador vacío. Borra los modelos descargados y vuelve a intentarlo.',
);
}
const [a, b] = shapes as [number[], number[]];
return { a, b, floats: product(a) + product(b) };
}
/** A fully numeric shape for one named value, or `null` when any axis is symbolic or missing. */
export function fixedShape(
metadata: readonly OutputMetadata[] | undefined,
name: string,
): number[] | null {
const shape = metadata?.find((entry) => entry.name === name)?.shape;
if (!shape || shape.length === 0) return null;
const dims = shape.map((dim) => (typeof dim === 'number' && Number.isInteger(dim) ? dim : -1));
return dims.some((dim) => dim <= 0) ? null : dims;
}
function product(dims: readonly number[]): number {
return dims.reduce((total, dim) => total * dim, 1);
}

rukh-web/src/lib/contract.tslíneas 102-143 · 8210ff1

Tres casos y ninguna rama ambigua: sin las dos entradas, la etapa es ordinaria; con una sola, el fichero está corrupto o es de otra versión, y se lanza nombrando la que falta; con las dos pero sin tamaño declarado, también se lanza, porque fingir que la etapa es normal produciría un fallo más adelante y más lejos. El mensaje le dice al jugador qué hacer.

El worker: 1,6 MB por estilo, reutilizados

src/workers/decoder.worker.ts
/**
* The style adapter fed on every call, and the zeros that stand in for "no style".
*
* Both are allocated once per session and reused: they are the same 1.6 MB on every move, and a
* fresh `Float32Array` per inference would be a megabyte and a half of garbage per move for
* nothing. `zeros` is not a special case in the graph — the export is built so that an adapter of
* zeros computes the base model exactly — so the plain stage and the styled one run the same code.
*/
let adapter: { a: Float32Array; b: Float32Array; id: string } | null = null;
let zeros: { a: Float32Array; b: Float32Array } | null = null;

rukh-web/src/workers/decoder.worker.tslíneas 41-50 · 8210ff1

Los búferes se asignan una vez por sesión y se reutilizan en cada jugada. Y zeros no es un caso especial: un adaptador de ceros es el modelo base, así que «sin estilo» corre el mismo código y no es una rama que pueda desviarse.

src/workers/decoder.worker.ts
function sizeOf(shape: readonly number[]): number {
return shape.reduce((total, dim) => total * dim, 1);
}
/** The two tensors the graph wants this call, whether a style is loaded or not. */
function adapterFeeds(live: ModelContract) {
if (!live.adapter || !zeros) return undefined;
const factors = adapter ?? zeros;
return {
lora_a: floatTensor(factors.a, live.adapter.a),
lora_b: floatTensor(factors.b, live.adapter.b),
};
}

rukh-web/src/workers/decoder.worker.tslíneas 104-116 · 8210ff1

adapter ?? zeros es toda la lógica de «sin estilo».

src/workers/decoder.worker.ts
/**
* Downloads a style adapter and installs it, or clears it when there is no url.
*
* The length check is the whole validation: the graph declares how many floats an adapter for it
* has, so a file of any other size belongs to another model and would be read as garbage laid
* over the right weights — a model that plays legally and badly, which is the worst kind of wrong.
*/
async function setAdapter(request: Extract<DecoderRequest, { type: 'adapter' }>): Promise<void> {
const started = performance.now();
const floats = await serial(async () => {
const live = contract;
if (!live) throw new Error('el modelo todavía no está cargado');
if (!live.adapter) {
throw new Error('esta etapa no admite adaptadores de estilo');
}
if (request.url === null) {
adapter = null;
return 0;
}
const bytes = await fetchModel(request.url, request.sizeBytes, (loaded, total) =>
reply({ type: 'progress', id: request.id, loaded, total }),
);
const expected = live.adapter.floats * 4;
if (bytes.byteLength !== expected) {
throw new Error(
`El adaptador no encaja con este modelo: trae ${bytes.byteLength} bytes y el fichero ` +
`espera ${expected}. No se cambia de estilo con él.`,
);
}
// `fetchModel` returns a fresh buffer, but not necessarily one aligned for Float32Array.
const aligned = bytes.byteOffset % 4 === 0 ? bytes : new Uint8Array(bytes.slice().buffer);
const all = new Float32Array(aligned.buffer, aligned.byteOffset, live.adapter.floats);
const sizeA = sizeOf(live.adapter.a);
adapter = { a: all.slice(0, sizeA), b: all.slice(sizeA), id: request.adapter };
return live.adapter.floats;
});
reply({
type: 'adapter-ready',
id: request.id,
adapter: request.url === null ? '' : request.adapter,
floats,
loadMs: Math.round(performance.now() - started),
});

rukh-web/src/workers/decoder.worker.tslíneas 118-160 · 8210ff1

La comprobación de longitud es toda la validación: un fichero de otro tamaño pertenece a otro modelo y se leería como basura encima de los pesos correctos, un modelo que juega legal y mal. El serial(...) pone todas las llamadas a ORT en cola: cargar un adaptador mientras se calcula una jugada dejaría al modelo respondiendo con la mitad de un estilo. Y la copia por alineamiento existe porque un Float32Array sobre un byteOffset que no es múltiplo de cuatro lanza.

La cola de llamadas a ORT y los dos mensajes nuevos del protocolo se pueden saltar.

// Fontaneríaort-runtime.ts y worker-protocol.ts: la cola de llamadas y los mensajes del adaptador
src/workers/ort-runtime.ts
/** One `(1, T)` int64 tensor under the name the session gives its only input. */
export function inputFeeds(
session: ort.InferenceSession,
ids: readonly number[],
extra?: Record<string, ort.Tensor>,
): Record<string, ort.Tensor> {
const data = BigInt64Array.from(ids, (id) => BigInt(id));
return {
[session.inputNames[0]]: new ort.Tensor('int64', data, [1, ids.length]),
...(extra ?? {}),
};
}
/** A float32 tensor for one of the graph's fixed-shape inputs (the LoRA factors). */
export function floatTensor(data: Float32Array, shape: readonly number[]): ort.Tensor {
return new ort.Tensor('float32', data, shape.slice());
}

rukh-web/src/workers/ort-runtime.tslíneas 119-135 · 8210ff1

src/lib/worker-protocol.ts
/**
* Loads (or clears) the style adapter of a session whose graph takes its LoRA factors as inputs.
*
* `url: null` clears it, which feeds an adapter of zeros — and an adapter of zeros is exactly the
* base model, by construction and by test (`tests/unit/test_export_adapter.py`). So "no style" is
* not a different session or a different file; it is the same graph with nothing added.
*/
export interface AdapterRequest {
type: 'adapter';
id: number;
/** Adapter id from the registry, for diagnostics; empty when clearing. */
adapter: string;
url: string | null;
sizeBytes: number;
}

rukh-web/src/lib/worker-protocol.tslíneas 56-70 · 8210ff1

src/lib/worker-protocol.ts
export interface AdapterMessage {
type: 'adapter-ready';
id: number;
/** Which adapter is live now; empty string when the session is back to the plain model. */
adapter: string;
/** Floats fed on every call, so the panel can say what a style costs per move. */
floats: number;
loadMs: number;
}

rukh-web/src/lib/worker-protocol.tslíneas 150-158 · 8210ff1

url: null limpia el adaptador, y la respuesta dice qué adaptador está vivo ahora.

La interfaz

La interfaz es Preact sin nada de modelos; se puede saltar. La única decisión que conviene retener: la señal del selector se pone a lo que de verdad prendió, no a lo que se pulsó.

// FontaneríaApp.tsx y ModelPanel.tsx: los dos selectores y su señal
src/islands/App.tsx
/**
* The style adapter loaded into the live session, and whether one is on its way.
*
* `NO_ADAPTER` is not "the feature is off": the graph still takes its two factors and is fed
* zeros, which is the base model exactly. So switching styles never rebuilds the session and
* never re-downloads the model — it uploads 1.6 MB and the next move comes out different.
*/
const adapter = signal<string>(NO_ADAPTER);
const adapterBusy = signal(false);

rukh-web/src/islands/App.tsxlíneas 100-108 · 8210ff1

src/islands/App.tsx
/**
* Loads a style adapter into the live session, or clears it.
*
* The signal is set to what actually took, not to what was clicked: a failed download has to
* leave the selector showing the style the model is really playing with, or the page would be
* claiming a style the weights know nothing about.
*/
async function chooseAdapter(id: string): Promise<void> {
if (id === adapter.value || adapterBusy.value) return;
const entry = findAdapter(id);
if (!decoder || status.value !== 'ready') return;
adapterBusy.value = true;
error.value = null;
const previous = adapter.value;
try {
await decoder.adapter(
{
adapter: entry?.id ?? '',
url: entry ? adapterUrl(entry) : null,
sizeBytes: entry?.sizeBytes ?? 0,
},
(update) => (progress.value = update),
);
adapter.value = entry ? entry.id : NO_ADAPTER;
} catch (cause) {
adapter.value = previous;
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
adapterBusy.value = false;
progress.value = null;
}
}

rukh-web/src/islands/App.tsxlíneas 386-417 · 8210ff1

Una decisión de las que se olvidan: la señal se pone a lo que de verdad prendió, no a lo que se pulsó. Si la descarga falla, el catch restaura el estilo anterior, y el finally reactiva el control pase lo que pase: un selector desactivado para siempre tras un error de red es peor que el error.

src/islands/ModelPanel.tsx
<label class="model__field">
<span class="caption">Elo objetivo</span>
<select
class="select"
value={String(elo.value)}
disabled={!current.eloConditioned}
title={current.eloConditioned ? undefined : 'Esta etapa no está condicionada por Elo'}
onChange={(event) =>
(elo.value = Number((event.currentTarget as HTMLSelectElement).value))
}
>
{ELO_TARGETS.map((target) => (
<option key={target} value={String(target)}>
{target}
</option>
))}
</select>
{!current.eloConditioned ? (
<span class="caption" data-testid="elo-hint">
Solo la etapa condicionada responde al Elo objetivo
</span>
) : null}
</label>

rukh-web/src/islands/ModelPanel.tsxlíneas 170-192 · 8210ff1

ELO_TARGETS viene del registro y no del componente, para que el test del registro compruebe que las seis condiciones son las seis medidas.

src/islands/ModelPanel.tsx
<label class="model__field">
<span class="caption">Estilo</span>
<select
class="select"
data-testid="adapter"
value={adapter.value}
disabled={styles.length === 0 || phase !== 'ready' || adapterBusy.value}
title={styles.length === 0 ? 'Esta etapa no admite adaptadores de estilo' : undefined}
onChange={(event) => onAdapter((event.currentTarget as HTMLSelectElement).value)}
>
<option value={NO_ADAPTER}>Sin estilo</option>
{styles.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label} · {megabytes(entry.sizeBytes)} MB
</option>
))}
</select>
<span class="caption" data-testid="adapter-hint">
{styles.length === 0
? 'Solo la etapa con adaptadores intercambiables cambia de estilo'
: phase !== 'ready'
? 'Carga el modelo y podrás cambiarle el estilo sin descargar nada más'
: (styles.find((entry) => entry.id === adapter.value)?.hint ??
'Los pesos no se tocan: el estilo son 1,6 MB aparte')}
</span>
</label>

rukh-web/src/islands/ModelPanel.tsxlíneas 193-218 · 8210ff1

Cada opción del selector de estilo muestra su tamaño, así que el jugador ve 1,6 MB al lado del nombre antes de elegir.

La prueba que se lee como la promesa

e2e/adapter.spec.ts
/**
* Swapping a style adapter in the browser, against the toy files committed in `public/test/`.
*
* `toy-decoder-lora.onnx` is the same one-layer decoder as `toy-decoder.onnx`, exported with its
* LoRA factors as **inputs of the graph** (`rukh export --adapter-inputs`), and `toy-lora.bin` is
* an adapter of exactly the size that graph declares. Both are written by
* `rukh/scripts/make_toy_web_models.py` with the same functions that write the published files.
*
* What is under test here is the claim the whole design rests on: one model file, several styles,
* and changing style costs a 256-byte upload rather than another download. The proof is that the
* distribution the model publishes for the *same position* is different with the adapter loaded —
* "same position" being why the comparison is made from a fresh game both times, and why it reads
* the top-five panel (the model's own softmax) instead of the move it happened to sample.
*/
const STAGE = '/?stage=test-lora';
const LOAD_TIMEOUT = 120_000;

rukh-web/e2e/adapter.spec.tslíneas 5-20 · 8210ff1

La prueba compara la distribución que el modelo publica para la misma posición, con y sin adaptador: desde una partida nueva las dos veces, y leyendo el panel de las cinco mejores (la softmax del modelo) en vez de la jugada que le tocó muestrear.

e2e/adapter.spec.ts
test.describe('style adapters in the browser', () => {
test('the style selector is off for a stage whose graph takes no factors', async ({ page }) => {
await page.goto('/?stage=test');
await waitIdle(page);
const style = page.getByTestId('adapter');
await expect(style).toBeDisabled();
await expect(page.getByTestId('adapter-hint')).toContainText('intercambiables');
});
test('changing the adapter changes what the model wants to play', async ({ page, hasTouch }) => {
const downloads: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (/\.onnx$|\.bin$/.test(url)) downloads.push(url);
});
await load(page);
const before = await answerOnce(page, hasTouch);
// One model file so far, and the style selector is live.
expect(downloads.filter((url) => url.endsWith('.onnx'))).toHaveLength(1);
const style = page.getByTestId('adapter');
await expect(style).toBeEnabled();
await expect(style).toHaveValue('none');
await page.getByRole('button', { name: 'Nueva partida' }).click();
await waitIdle(page);
await style.selectOption('test-lora');
await expect(style).toHaveValue('test-lora', { timeout: LOAD_TIMEOUT });
const after = await answerOnce(page, hasTouch);
expect(after).not.toBe(before);
// The style cost one small file and **no** second copy of the model: that is the whole point.
expect(downloads.filter((url) => url.endsWith('.onnx'))).toHaveLength(1);
expect(downloads.filter((url) => url.endsWith('toy-lora.bin'))).toHaveLength(1);
});

rukh-web/e2e/adapter.spec.tslíneas 66-102 · 8210ff1

Las dos aserciones que son el hito entero: un solo .onnx descargado antes del cambio de estilo y el mismo único .onnx después, y un solo toy-lora.bin.

e2e/adapter.spec.ts
test('clearing the adapter puts the plain model back', async ({ page, hasTouch }) => {
await load(page);
const before = await answerOnce(page, hasTouch);
const style = page.getByTestId('adapter');
await page.getByRole('button', { name: 'Nueva partida' }).click();
await waitIdle(page);
await style.selectOption('test-lora');
await expect(style).toHaveValue('test-lora', { timeout: LOAD_TIMEOUT });
await page.getByRole('button', { name: 'Nueva partida' }).click();
await waitIdle(page);
await style.selectOption('none');
await expect(style).toHaveValue('none');
// An adapter of zeros is the base model by construction, so this has to be the same number
// again — not merely close. If it drifts, the graph is not what the export claims.
expect(await answerOnce(page, hasTouch)).toBe(before);
});
});

rukh-web/e2e/adapter.spec.tslíneas 104-123 · 8210ff1

La tercera prueba comprueba que al quitar el adaptador vuelve el mismo número, no uno parecido: si deriva, el grafo no es lo que la exportación afirma.

Los ficheros de juguete, escritos por el exportador de verdad

El script que escribe los ficheros de juguete se puede saltar; basta con saber que los escriben las mismas funciones que escriben los modelos publicados. Es el único fichero que la lección te manda teclear, en rukh; si lo saltas, tráelo allí: git checkout p4 -- scripts/make_toy_web_models.py.

// Fontaneríascripts/make_toy_web_models.py: los ficheros de juguete para el E2E
scripts/make_toy_web_models.py
"""Regenerate the toy ONNX files the demo's E2E suite plays against.
They are versioned in `rukh-web/public/test/` so the browser tests walk the real worker path --
download, contract check, session, inference, style swap -- without fetching hundreds of megabytes
from the Hub. They are written by the very functions that write the published models, so they carry
the same `rukh_*` metadata and come out of the same exporter (D-031).
Three files, and the third is what makes the style selector testable:
* `toy-decoder.onnx` one layer, `d_model=8`, the real 2 030-token vocabulary, `block` 200;
* `toy-decoder-lora.onnx` the same decoder exported with its LoRA factors as inputs;
* `toy-lora.bin` an adapter for it, of the size that graph declares.
The adapter is **not** trained: it is a fixed pseudo-random `B` with a seed, which is enough for
the only thing the E2E can check without a real corpus -- that loading it changes the move the
model plays, and that clearing it puts the old move back.
Run from the repository root:
uv run python scripts/make_toy_web_models.py
"""

scripts/make_toy_web_models.pylíneas 1-21 · p4

Los ficheros de juguete permiten que las pruebas de navegador recorran el camino real del worker sin traerse cientos de megabytes del Hub. Y los escriben las mismas funciones que escriben los modelos publicados: un fichero de prueba escrito a mano probaría el fichero de prueba.

scripts/make_toy_web_models.py
import argparse
import sys
from pathlib import Path
import torch
from rukh.export import export_onnx
from rukh.export.adapter import browser_lora, export_adaptable_onnx, write_web_adapter
from rukh.models import DecoderConfig, MoveDecoder
from rukh.models.lora import LoraConfig, apply_lora, lora_modules
from rukh.tokenize.uci_vocab import UciTokenizer
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# The vocabulary and the context are the real ones: those two are what the browser checks.
TOY = DecoderConfig(vocab_size=len(UciTokenizer().ids), n_layer=1, n_head=2, d_model=8, block=200)
LORA = LoraConfig(r=2, alpha=4, targets=("q", "v"))
SEED = 31
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", default="../rukh-web/public/test", type=Path)
parser.add_argument("--seq-len", default=8, type=int, help="Example length the exporter traces.")
parser.add_argument(
"--plain",
action="store_true",
help="Also rewrite `toy-decoder.onnx`. Off by default: the committed one was written with "
"another seed, and replacing it changes the moves every browser test expects.",
)
args = parser.parse_args()
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)

scripts/make_toy_web_models.pylíneas 23-55 · p4

El vocabulario y el contexto no se reducen, porque son lo que el navegador comprueba en el contrato: un juguete con 64 tokens pasaría el contrato por la razón equivocada.

scripts/make_toy_web_models.py
def toy() -> MoveDecoder:
torch.manual_seed(SEED)
return MoveDecoder(TOY).eval()
if args.plain:
plain = export_onnx(toy(), out / "toy-decoder.onnx", seq_len=args.seq_len)
print(f"{plain.path} {plain.bytes:,} bytes ({plain.exporter}, opset {plain.opset})")
# `browser_lora` is what the demo's real exports use: the `alpha / r` lives in the adapter file,
# not in the graph, so a graph can take an adapter of any rank without anyone matching two numbers.
adaptable = export_adaptable_onnx(
toy(), out / "toy-decoder-lora.onnx", browser_lora(LORA), seq_len=args.seq_len
)
print(f"{adaptable.path} {adaptable.bytes:,} bytes")
print(f" lora_a {adaptable.metadata['rukh_adapter_shape_a']}")
print(f" lora_b {adaptable.metadata['rukh_adapter_shape_b']}")
adapted = toy()
apply_lora(adapted, LORA)
torch.manual_seed(SEED + 1)
with torch.no_grad():
for _, module in lora_modules(adapted):
for b in module.b:
# Big enough that the argmax of a one-layer toy actually moves; a style adapter on a
# real model is a correction, here it only has to be visible.
b.normal_(std=1.5)
binary = write_web_adapter(adapted, out / "toy-lora.bin", base_repo=None)
print(f"{binary} {binary.stat().st_size:,} bytes")

scripts/make_toy_web_models.pylíneas 58-86 · p4

browser_lora(LORA) exporta con escala uno, como las exportaciones de la demo; escribir LORA a secas daría un grafo que multiplica dos veces. Y el std=1.5 de la B hace que el argmax del juguete se mueva de verdad: aquí el adaptador solo tiene que ser visible.

scripts/make_toy_web_models.py
# What the E2E asserts: the two files answer differently for the same position.
tok = UciTokenizer()
idx = torch.tensor([[tok.vocab["<bos>"], tok.vocab["<w1800>"], tok.vocab["<b1800>"]]])
with torch.no_grad():
base_move = int(toy()(idx)[0][0, -1].argmax())
adapted_move = int(adapted(idx)[0][0, -1].argmax())
print(f"\nfirst move: base {tok.ids[base_move]}, with the toy adapter {tok.ids[adapted_move]}")
if base_move == adapted_move:
raise SystemExit("the toy adapter does not change the first move; raise the std and re-run")

scripts/make_toy_web_models.pylíneas 88-96 · p4

El propio script comprueba lo que el E2E va a afirmar y falla si no: sin eso, un cambio en el juguete dejaría una prueba de navegador fallando a cuatro capas de distancia de su causa.

Lo que una prueba de navegador no puede comprobar

El E2E de la etapa condicionada comprueba el cableado, no los pesos, y su propio comentario en el registro lo dice:

src/lib/registry.ts
/**
* The same toy graph, flagged as conditioned, so the E2E suite can walk the *enabled* path of the
* Elo selector without downloading 221 MB from the Hub.
*
* The flag says what the registry claims about a stage, and what is under test is the wiring that
* reads it: the control is enabled, the hint disappears, and the chosen header reaches the
* tokenizer. Whether the weights behind it were really trained on the Elo axis is a question for
* `rukh eval sweep`, and no browser test could answer it anyway.
*/
export const TEST_ELO_STAGE: Stage = {
...TEST_STAGE,
id: 'test-elo',
label: 'ONNX de juguete, condicionado (pruebas)',
eloConditioned: true,
};

rukh-web/src/lib/registry.tslíneas 338-352 · 8210ff1

Si los pesos detrás están de verdad condicionados es una pregunta para rukh eval sweep; ninguna prueba de navegador podría contestarla.

src/lib/registry.ts
/**
* A toy decoder exported with its LoRA factors as inputs, and a toy adapter for it.
*
* Both are written by `rukh/scripts/make_toy_web_models.py` with the same functions that write the
* published files, so the graph really does declare `lora_a` and `lora_b` and the buffer really is
* the length that graph asks for. What the E2E can then check is the only thing a browser test
* ever could: that loading the adapter changes the move and clearing it puts the old one back.
* The adapter is not trained — it is a seeded `B` — because a style is a claim about a corpus and
* this file has none.
*/
export const TEST_LORA_STAGE: Stage = {
...TEST_STAGE,
id: 'test-lora',
label: 'ONNX de juguete con adaptadores (pruebas)',
url: '/test/toy-decoder-lora.onnx',
adaptable: true,
};
export const TEST_ADAPTER: Adapter = {
id: 'test-lora',
label: 'Estilo de juguete',
hint: 'Adaptador de prueba: cambia la jugada y no significa nada',
repo: '',
file: '',
sizeBytes: 256,
stages: [TEST_LORA_STAGE.id],
};

rukh-web/src/lib/registry.tslíneas 354-380 · 8210ff1

Para verlo en la demo publicada: el selector «Elo objetivo» solo se enciende con una etapa condicionada, y la demo no puede saber si un fichero tuyo lo está. Para probar el condicionamiento, carga chorcat/rukh-medium-elo desde el desplegable de etapa; para el estilo, chorcat/rukh-medium-lora y después uno de los dos adaptadores. En el segundo caso la interfaz te enseñará 221 MB una vez (los 231 MB decimales de la lección 1, contados en MiB) y 1,6 MB por cada cambio de estilo.

// demoLa etapa con adaptadores intercambiablesCarga el modelo una vez y después cambia el selector «Estilo»: la apertura cambia y no se descarga nada más que 1,6 MB.
Abrir la demo?stage=medium-lora-fp16

Qué has aprendido

Un control que el jugador puede mover es una afirmación, y solo es tan amplia como las mediciones que la respaldan: por eso el de Elo ofrece seis condiciones. El de estilo carga 1,6 MB sobre un modelo ya descargado, y el navegador lee del propio fichero ONNX qué forma debe tener el adaptador. Una línea de Playwright lo defiende: un solo .onnx en toda la prueba, con un cambio de estilo en medio.

Lo siguiente son las cinco mediciones del hito, con los números delante. Sigue en «Lo que salió».