// 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.
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
/** * 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;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.
/** * 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;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.
/** * 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'], },];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
/** 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);}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.
// `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, },];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.
/** * 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;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
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;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.
/** * 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);}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
/** * 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.ts
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.
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.ts
adapter ?? zeros es toda la lógica de «sin estilo».
/** * 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.ts
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
/** 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.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.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.ts
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
/** * 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);/** * 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; }}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.
<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.tsx
ELO_TARGETS viene del registro y no del componente, para que el test del registro compruebe que las
seis condiciones son las seis medidas.
<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.tsx
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
/** * 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;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.
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); });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.
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); });});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
"""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 megabytesfrom the Hub. They are written by the very functions that write the published models, so they carrythe 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 forthe only thing the E2E can check without a real corpus -- that loading it changes the move themodel 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.py
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.
import argparseimport sysfrom pathlib import Path
import torch
from rukh.export import export_onnxfrom rukh.export.adapter import browser_lora, export_adaptable_onnx, write_web_adapterfrom rukh.models import DecoderConfig, MoveDecoderfrom rukh.models.lora import LoraConfig, apply_lora, lora_modulesfrom 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.py
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.
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.py
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.
# 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.py
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:
/** * 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,};Si los pesos detrás están de verdad condicionados es una pregunta para rukh eval sweep; ninguna
prueba de navegador podría contestarla.
/** * 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],};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.
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ó».