// M6 · lección 07
La demo final: arena, puzles y tu propio modelo desde el disco
Una pantalla con tres modos: jugar, ver a dos etapas enfrentarse con la aritmética de M5 portada línea a línea, y resolver los 150 puzles con el criterio del harness. Más una posición compartible por URL y un campo que acepta el .onnx que acabas de exportar, sin subir un byte a ninguna parte.
Qué vas a construir
La demo es donde el proyecto juega en público. La de M5 tenía una pantalla: cargas un modelo y juegas contra él. La de este hito tiene tres modos sobre la misma pantalla, y deja de ser solo de quien escribió el curso:
- Arena: dos etapas se cargan en dos workers y juegan por parejas con los colores espejados desde
una apertura aleatoria con semilla. El marcador se convierte en una diferencia de Elo con su
intervalo bootstrap, con la misma aritmética que
rukh eval matchde M5, y al lado se imprime cuántas partidas harían falta para separar una diferencia de ese tamaño del cero. - Puzles: los 150 que exporta
labs/m6/puzzles_export.py, preguntados como los pregunta el harness (con el prefijo real de la partida y los Elo) y contados con su criterio: la línea entera. - Posición compartible:
?fen=abre el tablero en una posición, para que una lección enlace a un puesto concreto. - Y tu propio modelo: un campo que acepta el
artifacts/onnx/small/model-int8.onnxque exportaste en el lab 5 de M2, sin subirlo a ninguna parte.
El código vive en el otro repositorio, rukh-web, en el commit
b0bf7af. Las decisiones están en src/lib/;
las islas de Preact (Arena.tsx, Puzzles.tsx) solo orquestan esas funciones y dibujan el
resultado.
Tres modos en la URL
/** The three things the screen can do: play the model, watch two stages play, solve puzzles. */export type Mode = 'play' | 'arena' | 'puzzles';export const MODES: readonly Mode[] = ['play', 'arena', 'puzzles'];
export interface Query { mock: boolean; stage: string; /** Encoder stage the evaluation bar would load; nothing is fetched until its own consent. */ encoder: string; color: Color; mode: Mode; /** A position to start from (`?fen=`), when it parses; null otherwise. */ fen: string | null; /** The second stage of the arena (`?vs=`), when it is a known stage. */ vs: string | null;}Todo el estado que la página puede recibir de fuera sale de la cadena de consulta, así que un enlace
es un estado: ?mode=arena&stage=medium-fp16&vs=medium-dpo-fp16 es una frase completa que una
lección puede pegar en un <DemoEmbed>.
export function parseQuery( search: string, fallback = defaultStageId(detectConditions()), encoderFallback = defaultEncoderId(detectConditions()),): Query { const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); const requested = params.get('stage') ?? fallback; const stage = findStage(requested) ?? findStage(fallback); const mock = params.get('mock') === '1' || stage === undefined || stage.kind === 'mock'; const encoder = findEncoderStage(params.get('encoder') ?? encoderFallback); const mode = params.get('mode'); const vs = params.get('vs'); return { mock, stage: mock ? 'mock' : (stage?.id ?? fallback), encoder: encoder?.id ?? encoderFallback, color: params.get('color') === 'b' ? 'b' : 'w', mode: mode !== null && (MODES as readonly string[]).includes(mode) ? (mode as Mode) : 'play', fen: validFen(params.get('fen')), vs: vs !== null && findStage(vs) ? vs : null, };}
/** `fen` when chess.js accepts it as a position, else null. */export function validFen(fen: string | null): string | null { if (!fen) return null; try { return new Chess(fen).fen(); } catch { return null; }}La forma es la que conviene copiar: cada parámetro se valida contra lo que existe y cae a un valor por
defecto cuando no. ?mode=cocina abre el modo de jugar, ?vs=nada deja la arena sin segunda etapa
y un ?fen= con basura se ignora. Nunca una excepción ni una pantalla en blanco.
validFen valida construyendo: le da la cadena a chess.js y devuelve el FEN normalizado, o null
si el constructor protesta. Una expresión regular para un FENFENCadena de texto que describe una posición completa: piezas por fila, turno, derechos de enroque, casilla al paso y contadores de jugadas. Es la clave con la que se cruzan las posiciones de las partidas con las evaluaciones públicas de Stockfish. es un error
clásico, porque dos de sus seis campos dependen de la posición. Y como lo que entra en el estado es la
salida de chess.js y no el texto del usuario, dos enlaces con la misma posición escrita de dos
formas dan el mismo estado y ?fen= no puede hacer daño.
La arena: la aritmética de M5, portada a TypeScript
/** * Arena: two stages play each other in the browser, and the difference is read off the games. * * The arithmetic is `rukh.eval.match` (M5) ported line for line: every opening is played twice * with the colours swapped, the score is A's points over both games, the Elo difference is * `400·log10(p / (1 - p))` and its interval comes from a bootstrap over the games. A match * measures a **difference** and nothing else: there is no third party here whose mood has to be * averaged (D-110), and the number is not a strength. * * Everything here is pure, so `tests/arena.test.ts` can pin the numbers against the Python * implementation without a model in sight. */«Portada línea a línea» es lo que hace útil la arena: con otra fórmula u otro remuestreo, el número
de la pantalla no se compararía con el +57 (36-79) que publicó M5. Y como todo aquí es puro (nada
toca el DOM, un worker ni una promesa), tests/arena.test.ts fija sus números contra los de Python
sin cargar un modelo.
/** How many plies the random openings have; six, as `rukh eval match` does by default. */export const OPENING_PLIES = 6;/** A game that reaches this many plies without a result counts as a draw, and is marked cut. */export const MAX_PLIES = 300;
/** A small, seeded PRNG so a run with the same seed plays the same openings. */export function mulberry32(seed: number): () => number { let state = seed >>> 0; return () => { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** A random legal opening of `plies` moves in UCI; retried when it ends the game early. */export function randomOpening(random: () => number, plies = OPENING_PLIES): string[] { for (;;) { const chess = new Chess(); const moves: string[] = []; for (let i = 0; i < plies; i += 1) { const legal = chess.moves({ verbose: true }); if (legal.length === 0) break; const pick = legal[Math.floor(random() * legal.length)]; chess.move(pick); moves.push(`${pick.from}${pick.to}${pick.promotion ?? ''}`); } if (moves.length === plies && !chess.isGameOver()) return moves; }}
/** `pairs` openings, each to be played twice with the colours swapped. */export function openingBook(pairs: number, seed: number, plies = OPENING_PLIES): string[][] { const random = mulberry32(seed); return Array.from({ length: pairs }, () => randomOpening(random, plies));}mulberry32 es un generador con semilla, porque una arena con la misma semilla tiene que jugar las
mismas aperturas; si no, ni una lección podría prometer lo que se va a ver. JavaScript no trae uno, y
cabe en diez líneas.
randomOpening es un rechazo-y-reintento: si las seis jugadas al azar acaban la partida (mate del
pastor incluido), se sortea otra. Un contador de intentos protegería de un caso que no va a ocurrir.
openingBook saca todas las aperturas de un solo generador sembrado, así que la quinta apertura de la
semilla 1 es siempre la misma y pedir más parejas no cambia las anteriores.
export interface ArenaGame { /** Index in the schedule: game `2k` has A as White on opening `k`, game `2k+1` swaps. */ index: number; opening: string[]; aWhite: boolean; /** A's points: 1, 0.5 or 0. */ score: number; plies: number; /** True when the game hit `MAX_PLIES` and was scored as a draw. */ cut: boolean;}
export interface ArenaSummary { games: number; /** A's points over all games, as a fraction. */ score: number; wins: number; draws: number; losses: number; cut: number; /** A minus B in Elo, from the score; null until a game has been played. */ elo: number | null; /** 95 % bootstrap interval of `elo`; null with fewer than two games. */ low: number | null; high: number | null; /** True when the interval does not contain zero. */ separated: boolean;}La propiedad del emparejamiento está en el comentario de index: el juego 2k tiene a A con blancas
en la apertura k, y el 2k+1 cambia los colores sobre la misma apertura. Así sale la ventaja de
las blancas de la comparación, y por eso se cuenta por parejas.
cut cuenta aparte las partidas que llegan a 300 pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. sin resultado: para el
marcador son tablas, pero «nadie supo ganar» y «se agotó el contador» no dicen lo mismo del modelo.
separated es la regla de M5 metida en un campo: si el intervalo contiene el cero, no hay diferencia
demostrada, y la pantalla lo dice con palabras y no solo con un color.
/** Elo difference that explains a score, clipped away from 0 and 1 as the harness does. */export function eloDifference(score: number): number { const p = Math.min(Math.max(score, 0.001), 0.999); return 400 * Math.log10(p / (1 - p));}
/** * Percentile bootstrap of the Elo difference over the games (`samples` resamples, seeded). * The same estimator as `rukh.eval.match.bootstrap_interval`: resample games with replacement, * recompute the score, convert. */export function bootstrapElo( scores: readonly number[], samples = 500, seed = 0,): { low: number; high: number } | null { if (scores.length < 2) return null; const random = mulberry32(seed); const draws: number[] = []; for (let s = 0; s < samples; s += 1) { let total = 0; for (let i = 0; i < scores.length; i += 1) { total += scores[Math.floor(random() * scores.length)]; } draws.push(eloDifference(total / scores.length)); } draws.sort((a, b) => a - b); const at = (q: number) => draws[Math.min(draws.length - 1, Math.floor(q * draws.length))]; return { low: at(0.025), high: at(0.975) };}El recorte de p a [0,001, 0,999] merece pararse. El Elo a partir de una proporción es
400·log10(p / (1 − p)), infinito en p = 1: quien gana todas las partidas no tiene una puntuación
identificable, solo una cota. Recortar da un número grande y finito, y es el mismo recorte que hace el
harness de Python; por eso vive aquí y no en la capa de presentación.
El bootstrapBootstrapRemuestrear los datos con reemplazo muchas veces y recalcular el estadístico en cada remuestreo para ver cuánto se mueve. El intervalo del Elo de Rukh sale así: 500 remuestreos de las 160 partidas (bootstrap: 500 en la suite), un ajuste por remuestreo, y los percentiles 2,5 y 97,5. Cubre el ruido del muestreo de partidas y nada más: no cubre que el rival cambie entre ejecuciones ni que los peldaños estén mal colocados. es el de M5: se remuestrean las partidas (no las jugadas)
con reemplazo, se recalcula el marcador y se toman los percentiles 2,5 y 97,5 de 500 remuestreos. Con
veinte partidas, pasar de 500 a 1000 cambia decimales y cuesta el doble en el hilo principal.
export function summarize(games: readonly ArenaGame[], seed = 0): ArenaSummary { const scores = games.map((game) => game.score); const points = scores.reduce((sum, value) => sum + value, 0); const score = games.length > 0 ? points / games.length : 0; const interval = bootstrapElo(scores, 500, seed); return { games: games.length, score, wins: scores.filter((value) => value === 1).length, draws: scores.filter((value) => value === 0.5).length, losses: scores.filter((value) => value === 0).length, cut: games.filter((game) => game.cut).length, elo: games.length > 0 ? eloDifference(score) : null, low: interval?.low ?? null, high: interval?.high ?? null, separated: interval !== null && (interval.low > 0 || interval.high < 0), };}
/** The result of a finished game from A's point of view, given who had White. */export function scoreFor(result: string | null, aWhite: boolean): number { if (result === '1-0') return aWhite ? 1 : 0; if (result === '0-1') return aWhite ? 0 : 1; return 0.5;}
/** How many games the match needs to call an edge of `elo` points at 95 % (`labs/m5`). */export function gamesNeeded(elo: number): number { const p = 1 / (1 + 10 ** (-Math.abs(elo) / 400)); const z = 1.959963985; return Math.ceil((z * z * p * (1 - p)) / (p - 0.5) ** 2);}summarize se llama después de cada partida, así que aguanta listas de cero y de un elemento. La
pantalla muestra el marcador creciendo y el intervalo encogiéndose, que es la mejor forma de ver de
dónde sale un intervalo. scoreFor devuelve 0.5 por defecto, para las tablas y para el null de
una partida cortada, que cuenta media y se cuenta aparte.
gamesNeeded es la función que convierte la demo en una lección:
la potencia estadísticaPotencia estadísticaQué diferencia es capaz de detectar un experimento del tamaño que tiene. Para una tasa de puntos medida en n partidas, dos intervalos del 95 % dejan de tocarse cuando la diferencia supera 1,96 (se₁ + se₂), es decir, hacen falta unas 3,84 / (Δp)² partidas por condición. Sirve en los dos sentidos: antes, para saber si merece la pena medir; después, para distinguir «faltan partidas» de «no hay diferencia». Un resultado negativo sin este número no es un resultado. de labs/m5/games_needed_match.py en el
navegador. Dado un tamaño de efecto en Elo, cuántas partidas hacen falta para que su intervalo del
95 % se separe del cero. Con (p − 0,5)² en el denominador, el coste crece con el cuadrado de la
precisión: ver 50 Elo pide unas 185 partidas; ver 11, 3 832.
Ese número al lado de un marcador de veinte partidas impide leer diez partidas como un veredicto: es el intervalo dicho en la unidad en la que duele. Cualquier panel que compare dos variantes (dos prompts, dos modelos, dos versiones de un producto) gana con esa línea al lado del marcador.
Los puzles, con el criterio del harness
/** * Live puzzles: the harness's puzzle criterion (`rukh.eval.puzzles`), in the browser. * * A puzzle is attempted the way the table measured it (D-053): the model is prompted with the * **real game** that led to the position, with the players' own ratings in the header, and it * has to find the **whole** line, not the first move. `moves[0]` is the opponent's move that * sets the puzzle (Lichess's convention), then the sides alternate; the model answers the odd * indices and the opponent's replies are played from the solution. * * `public/puzzles.json` is written by `labs/m6/puzzles_export.py` in the ML repository from the * `test` split of `chorcat/rukh-puzzles-split`, fifty per band, seeded. */Tres cosas tienen que coincidir con el harness para que el porcentaje se compare con la tabla: cómo
se pregunta (con el prefijo real y los Elo), qué cuenta como resuelto (la línea entera) y de dónde
salen los puzles (el split de test, nunca uno visto al entrenar). Son tres formas de que el número
parezca comparable sin serlo: sin prefijo sale más bajo, contando solo la primera jugada, mucho más
alto, y con puzles de entrenamiento no significa nada.
export interface Puzzle { id: string; fen: string; moves: string[]; rating: number; prefix: string[]; whiteElo: number | null; blackElo: number | null;}
export interface PuzzleSet { schema: string; source: string; seed: number; perBand: number; generated: string; bands: Record<string, Puzzle[]>;}
export const BANDS = ['1000-1500', '1500-2000', '2000+'] as const;export type Band = (typeof BANDS)[number];
export const PUZZLES_URL = '/puzzles.json';
export async function loadPuzzleSet( url = PUZZLES_URL, fetcher: typeof fetch = fetch,): Promise<PuzzleSet> { const response = await fetcher(url); if (!response.ok) throw new Error(`puzzles: ${response.status} ${response.statusText}`); const data = (await response.json()) as PuzzleSet; if (data.schema !== 'rukh-puzzles/1') throw new Error(`puzzles: unknown schema ${data.schema}`); return data;}El JSON lleva su propia versión de esquema y se comprueba al cargarlo: lo genera un script de otro
repositorio, y el día que cambie de formato la demo lo dice en vez de dibujar tableros vacíos.
source, seed, perBand y generated no los usa la lógica; son
un manifiestoManifiesto de datosFichero JSON que acompaña a cada recorte con los filtros exactos, los meses, los conteos y el hash de cada fichero. Sin manifiesto no hay reproducibilidad: es lo que permite decir qué datos vio un modelo. en miniatura. Y fetcher: typeof fetch = fetch es el
parámetro que permite probar las dos ramas de error sin red.
/** UCI `e7e8q` as a `Move`. */export function uciMove(uci: string): Move { return { from: uci.slice(0, 2), to: uci.slice(2, 4), promotion: (uci.slice(4, 5) || undefined) as Move['promotion'], };}
/** * The game state the puzzle starts from: the real game replayed from the initial position, so * the prompt carries the prefix, and the board is the puzzle's FEN. Replaying the prefix must * land on that FEN (the first four fields); when it does not, the puzzle is malformed and is * skipped rather than attempted from a position the model never reached. */export function puzzleStart(puzzle: Puzzle): GameState | null { const chess = new Chess(); try { for (const uci of puzzle.prefix) chess.move(uciMove(uci)); } catch { return null; } const reached = chess.fen().split(' ').slice(0, 4).join(' '); const expected = puzzle.fen.split(' ').slice(0, 4).join(' '); if (reached !== expected) return null; return { start: INITIAL_FEN, fen: chess.fen(), history: chess.history(), turn: chess.turn(), over: chess.isGameOver(), result: null, };}
/** The moves the model has to find: the odd indices of the line. */export function expectedMoves(puzzle: Puzzle): string[] { return puzzle.moves.filter((_, index) => index % 2 === 1);}puzzleStart es la gemela de prefix_board de la lección 3: reproduce la partida desde el principio
y compara los cuatro primeros campos del FEN con los del puzle. Si no coinciden, devuelve null y la
interfaz salta el puzle en vez de intentarlo a ciegas, que contaría como fallo del modelo un fallo
del dato.
expectedMoves filtra los índices impares porque en Lichess moves[0] es la jugada del rival que
plantea el puzle. Un index % 2 === 1 sin comentario parece arbitrario, y es la mitad del criterio.
export interface PuzzleAttempt { id: string; band: Band; rating: number; solved: boolean; /** Model moves that matched before the first miss. */ correct: number; total: number; /** What the model proposed at the first miss (UCI), and what the line expected. */ proposed: string | null; expected: string | null;}
export interface BandProgress { band: Band; attempted: number; solved: number;}
export function progressByBand(attempts: readonly PuzzleAttempt[]): BandProgress[] { return BANDS.map((band) => { const own = attempts.filter((attempt) => attempt.band === band); return { band, attempted: own.length, solved: own.filter((a) => a.solved).length }; });}
/** `12,3 %`-style rate, or an em dash before anything was attempted. */export function rate(solved: number, attempted: number): string { if (attempted === 0) return '—'; return `${((100 * solved) / attempted).toFixed(1).replace('.', ',')} %`;}El criterio es binario, pero correct, proposed y expected guardan cuántas jugadas acertó antes
de fallar y qué propuso, que distingue «no vio la combinación» de «la vio y se equivocó en la
tercera». rate devuelve un guion largo, no un 0,0 %, cuando no se ha intentado nada: la regla del
n/a en la capa de presentación. El .replace('.', ',') pone la coma decimal; con un solo idioma,
toLocaleString arrastra más de lo que resuelve.
tests/puzzles.test.ts lee además public/puzzles.json de disco. Ya lo tienes, porque llegó con
el git checkout del principio, así que el test pasa con pnpm test tests/puzzles.test.ts; el lab 6
de la lección 9 regenera ese fichero desde tu repositorio de ML y lo vuelve a pasar.
Tu propio modelo, sin salir del navegador
Esta es la parte del hito que cambia para quién es la demo.
/** * Playing against a model you trained yourself, from a file on your disk. * * The demo ships the models the course published, which is the wrong half of the point: somebody * who has just finished lab 5 of M2 has their own `artifacts/onnx/small/model-int8.onnx` and no * way to play it. This takes that file and makes a stage out of it. * * Nothing leaves the browser. The bytes go straight from the file picker to the worker as a * transferable, so there is no upload, no `fetch`, no Cache API entry and nothing to hit the * network with. That is also why the local stage is not remembered across reloads: a model the * page could restore by itself is a model a link could ask it to restore. * * What protects the reader from a bad file is the contract in `contract.ts`, which already * refuses a graph whose logits are not the tokenizer's 2030 moves wide. A `.onnx` that is not a * Rukh decoder fails there, by name, before it ever picks a move. The checks here are the cheap * ones that come first: shape of the name, and a ceiling on the size. */Cuatro decisiones, una por párrafo del docstring:
- «La mitad equivocada del objetivo»: la demo servía los modelos del curso, y quien acaba el lab 5
de M2 tiene su propio
.onnxy no podía jugar contra él, que es lo que querría hacer en ese momento. - Nada sale del navegador, por construcción. No hay
fetch, así que no hayconnect-srcque ensanchar; no hay Cache API, así que nada queda en disco; los bytes van del selector de ficheros al worker como transferible. Un test de extremo a extremo comprueba que cargar y jugar un modelo local produce cero peticiones de.onnx. - La etapa local no se recuerda al recargar, por seguridad: un modelo que la página pudiera restaurar sola es uno que un enlace preparado podría pedirle que restaure. El precio es volver a elegir el fichero.
- Lo que rechaza un fichero malo ya estaba escrito: el contrato de M2 exige los logits de las 2030
jugadas del tokenizador, así que un
.onnxque no es un decoder de Rukh falla por su nombre. Aquí solo se añaden las comprobaciones baratas que van delante.
El resto de byo.ts es ingeniería web: comprobaciones baratas del fichero y un lector de protobuf
de cien líneas para sacar el block de los metadatos. Se puede saltar en la primera lectura; si te
interesa el formato binario, el lector está explicado dentro. Si no lo tecleas, trae el fichero
entero en rukh-web: git checkout b0bf7af -- src/lib/byo.ts.
// Fontaneríabyo.ts: el límite de tamaño, el nombre saneado, la etapa local y el lector de protobuf que saca el block
/** The id the local model takes in the stage selector. Never a registry id. */export const LOCAL_STAGE_ID = 'local';
/** * Biggest file the picker accepts, in bytes. * * The largest thing the course ever exports is a 232 MB fp32 `medium`, so 600 MB leaves room for * a bigger model somebody trains on their own without letting a mis-click hand a multi-gigabyte * file to `ArrayBuffer`. The failure this avoids is a tab that freezes and then dies, which * reads as "the demo is broken" rather than "that file is too big". */export const MAX_MODEL_BYTES = 600 * 1_000_000;
/** Smallest file that could possibly be an ONNX graph; below this it is a mistake, not a model. */export const MIN_MODEL_BYTES = 1024;
export interface LocalModel { stage: Stage; /** * The picked file, not its bytes. * * The buffer is transferred to the worker, which detaches it on this side, so keeping a copy * here would keep a dead one. A `File` is a handle to the disk: re-reading it to load the same * model a second time costs a read the OS has almost certainly cached. */ file: File;}El límite de 600 MB tiene su razón escrita: lo más grande que exporta el curso son 232 MB, y el
límite evita que un clic equivocado entregue varios gigas a ArrayBuffer. Sin él, la pestaña se
congela y muere, y el usuario lee «la demo está rota» en vez de «ese fichero es demasiado grande».
LocalModel guarda el File y no los bytes: el búfer se transfiere al worker y queda desprendido de
este lado, así que una copia aquí sería una copia muerta; y releer un File cuesta una lectura que el
sistema operativo casi seguro tiene en caché.
/** `model-int8.onnx` -> `model-int8.onnx`, and a hostile name to something safe to render. */export function safeName(name: string): string { /* Preact escapes what it renders, so this is not about injection: it is about a 300-character name breaking the panel, and about control characters making the label unreadable. Written as a filter rather than a character class of control codes, which is exactly what `no-control-regex` exists to flag and where the rule is right to ask. */ const clean = [...name] .filter((ch) => { const code = ch.codePointAt(0) ?? 0; return code > 0x1f && (code < 0x7f || code > 0x9f); }) .join('') .trim(); const base = clean.split(/[\\/]/).pop() ?? clean; return base.length > 48 ? `${base.slice(0, 45)}…` : base || 'modelo.onnx';}El comentario empieza diciendo de qué no se trata: Preact escapa lo que renderiza, así que esto no defiende de inyecciones. Es contra un nombre de 300 caracteres que rompe la maquetación y contra los caracteres de control. Saber qué problema no resuelves evita escribir una sanitización de HTML que no hace falta y creerte protegido por ella.
[...name] itera por puntos de código, así que un emoji no se parte. Los códigos de control se
filtran con un filtro y no con una expresión regular, porque la regla no-control-regex del linter
tiene razón en preguntar. split(/[\\/]/).pop() quita cualquier ruta delante, y || 'modelo.onnx'
cubre el nombre que se queda vacío.
/** * The stage a local file plays as. * * `sizeMb` is 0 on purpose even though the file has a size: that field drives the consent step, * which exists to warn before the browser spends somebody's data. A file already on the disk * costs no bytes, so there is nothing to consent to. */export function localStage(name: string, block: number): Stage { return { id: LOCAL_STAGE_ID, label: `Tu modelo · ${safeName(name)}`, kind: 'onnx', sizeMb: 0, block, };}
/** True for the stage that came from the reader's disk. */export function isLocalStage(stage: Stage | undefined): boolean { return stage?.id === LOCAL_STAGE_ID;}sizeMb: 0 a propósito: ese campo dispara el paso de consentimiento, que avisa antes de gastar los
datos de alguien, y un fichero que ya está en disco no cuesta bytes. Reutilizar un cero con el
sentido de «no aplica» es discutible; la alternativa, un campo local: true, duplicaría la
condición.
/** * Turns a picked file into a stage plus its bytes, or into the reason it cannot be one. * * The context window is read out of the file when the exporter wrote it there, and falls back to * the 200 every published model uses. Getting it wrong is not silent: the prompt would be cropped * to the wrong length and the contract reports the block it is playing under in the panel. */export async function readLocalModel(file: File): Promise<LocalModel | { error: LocalModelError }> { if (!/\.onnx$/i.test(file.name)) { return { error: 'Tiene que ser un fichero .onnx, el que deja `rukh export`.' }; } if (file.size > MAX_MODEL_BYTES) { return { error: `Ese fichero ocupa ${Math.round(file.size / 1_000_000)} MB y el límite son ${Math.round( MAX_MODEL_BYTES / 1_000_000, )} MB.`, }; } if (file.size < MIN_MODEL_BYTES) { return { error: 'Ese fichero es demasiado pequeño para ser un modelo exportado.' }; }
const block = readBlock(new Uint8Array(await file.arrayBuffer())) ?? DEFAULT_BLOCK; return { stage: localStage(file.name, block), file };}
/** The bytes to hand the worker, read fresh so the transfer can detach them. */export async function modelBytes(file: File): Promise<Uint8Array> { return new Uint8Array(await file.arrayBuffer());}El retorno es LocalModel | { error: string } en vez de una excepción, para que el panel escriba el
error en su sitio y siga. Los errores están en español y dicen qué hacer: el primero nombra
rukh export, el segundo da el tamaño y el límite en la misma frase.
Las comprobaciones van en orden de coste, como la guarda de la lección 5: extensión, tamaño
(file.size es metadato) y solo al final la lectura. Un test de extremo a extremo comprueba que un
fichero de más de 600 MB se rechaza sin leerlo. modelBytes vuelve a leer el File cuando toca
mandar los bytes al worker, porque la primera lectura se consumió al sacar el block.
/* * Reading `rukh_block` out of the file. * * `rukh export` writes the context window into the ONNX `metadata_props`, and ORT Web does not * expose them (see `contract.ts`), which is why registry stages carry `block` by hand. A file * from somebody's disk has no registry entry, so the number has to come from the file. * * An ONNX file is a protobuf `ModelProto`. Walking its top-level fields is enough: every field is * a varint tag followed by either a varint or a length-delimited block, so the graph — the big * one — is skipped by advancing past its length without decoding any of it. No protobuf library, * no allocation, and it stops as soon as it finds the key. */
/** `metadata_props` in `ModelProto`; `key` and `value` in `StringStringEntryProto`. */const METADATA_PROPS_FIELD = 14;const KEY_FIELD = 1;const VALUE_FIELD = 2;const BLOCK_KEY = 'rukh_block';
interface Cursor { bytes: Uint8Array; at: number;}
/** A protobuf varint, or null when it runs off the end or is implausibly long. */function varint(c: Cursor): number | null { let result = 0; let shift = 0; while (c.at < c.bytes.length) { const byte = c.bytes[c.at]; c.at += 1; result += (byte & 0x7f) * 2 ** shift; if ((byte & 0x80) === 0) return result; shift += 7; if (shift > 49) return null; } return null;}Aquí está la pieza más curiosa del hito: un lector que saca un campo de un protobuf sin una librería de protobuf.
rukh export escribe la ventana de contexto en los metadatos del .onnx, y ONNX Runtime Web no los
expone; por eso las etapas publicadas llevan su block escrito en el catálogo de la demo. Un fichero
de tu disco no tiene entrada en ese catálogo, así que el número tiene que salir del fichero.
Un .onnx es un ModelProto de protobuf, y para leer un campo de primer nivel no hace falta entender
el formato entero: cada campo es un tag varint seguido de un valor, y el tipo de cable del tag dice
cuánto ocupa. El grafo, los 232 MB, se salta avanzando su longitud sin decodificar nada. Es hojear
un libro en el que cada capítulo empieza diciendo cuántas páginas tiene: saltas de portada en
portada sin leer nada más hasta dar con el que buscas. La alternativa era meter protobufjs en el bundle, cientos de kilobytes para leer un entero.
varint usa * 2 ** shift y no << shift, y esa línea hay que mirarla dos veces: los
desplazamientos de JavaScript trabajan sobre 32 bits con signo, y a partir de 32 dan la vuelta en
silencio. Multiplicar por una potencia de dos es exacto hasta 2⁵³. shift > 49 corta un varint que no
puede ser un número plausible. Y todo el lector devuelve null en vez de lanzar: si el fichero no es
lo esperado, no se sabe el block y se usa el 200 por defecto, en vez de una pantalla roja.
/** The `StringStringEntryProto` at `[at, end)`, as a key/value pair. */function entry(bytes: Uint8Array, at: number, end: number): { key: string; value: string } | null { const c: Cursor = { bytes, at }; let key = ''; let value = ''; const decoder = new TextDecoder(); while (c.at < end) { const tag = varint(c); if (tag === null) return null; const field = tag >>> 3; const wire = tag & 7; if (wire !== 2) { if (varint(c) === null) return null; continue; } const length = varint(c); if (length === null || c.at + length > end) return null; const text = decoder.decode(bytes.subarray(c.at, c.at + length)); c.at += length; if (field === KEY_FIELD) key = text; else if (field === VALUE_FIELD) value = text; } return { key, value };}tag >>> 3 es el número de campo y tag & 7 el tipo de cable (aquí >>> es seguro, el tag es
pequeño). bytes.subarray(...) es una vista sin copia, que en 232 MB importa. Y c.at + length > end
en cada lectura de longitud impide que un fichero manipulado haga leer fuera del bloque: el peor caso
pasa de una cadena basura a un null.
/** The `rukh_block` the exporter wrote, or null when the file does not carry one. */export function readBlock(bytes: Uint8Array): number | null { const c: Cursor = { bytes, at: 0 }; while (c.at < bytes.length) { const tag = varint(c); if (tag === null) return null; const field = tag >>> 3; const wire = tag & 7;
if (wire === 0) { if (varint(c) === null) return null; continue; } if (wire === 5) { c.at += 4; continue; } if (wire === 1) { c.at += 8; continue; } if (wire !== 2) return null;
const length = varint(c); if (length === null || c.at + length > bytes.length) return null; if (field === METADATA_PROPS_FIELD) { const pair = entry(bytes, c.at, c.at + length); if (pair && pair.key === BLOCK_KEY) { const block = Number.parseInt(pair.value, 10); return Number.isInteger(block) && block > 0 && block <= 8192 ? block : null; } } c.at += length; } return null;}Los cuatro tipos de cable se tratan uno a uno (0 varint, 5 cuatro bytes, 1 ocho, 2 bloque con
longitud); cualquier otro no es protobuf válido. El bucle solo entra en entry para el campo 14,
metadata_props, y para al encontrar la clave. Un rukh_block fuera de (0, 8192] se trata como
ausente: un block de mil millones no recortaría el prompt y el grafo fallaría con un error de forma.
Y el panel imprime bajo qué block juega la etapa, porque un valor por defecto es tolerable cuando se
ve.
El camino de los bytes hasta el worker
Cómo viajan los bytes hasta el worker es fontanería del navegador; la figura de abajo resume los dos
caminos. Sin estos cambios «Tu propio modelo» no carga nada: si no los tecleas, tráelos en
rukh-web con
git checkout b0bf7af -- src/lib/worker-protocol.ts src/workers/rpc.ts src/workers/decoder-client.ts src/workers/decoder.worker.ts.
// Fontaneríaworker-protocol, rpc y el worker: los bytes transferidos, no copiados, hasta el camino de siempre
/** Absolute or same-origin URL of the `.onnx` file; ignored when `bytes` is present. */ url: string; /** * The model itself, when it came from the reader's disk instead of the network. * * Transferred rather than copied, so the buffer is detached on this side the moment it is * sent. A local model takes this path instead of `url` on purpose: no `fetch` means no * `connect-src` to widen, no Cache API entry to leave behind and nothing that could send the * file anywhere. See `lib/byo.ts`. */ bytes?: Uint8Array;rukh-web/src/lib/worker-protocol.ts
Un campo opcional en el mensaje de inicialización, y el url se ignora cuando está. El protocolo del
worker es la frontera entre la página y el hilo que carga el modelo: lo que cruza define lo que el
worker puede hacer.
send<T>( request: Unidentified<WorkerRequest>, onProgress?: (progress: ProgressMessage) => void, /** * Buffers to hand over instead of copying. A 230 MB model posted by value is 230 MB cloned * before the worker sees any of it; transferred, it changes owner and the sender's view is * detached. Callers that transfer must not read the buffer afterwards. */ transfer?: Transferable[], ): Promise<T>;postMessage clona por defecto: mandar un modelo de 230 MB por valor son 460 MB de memoria a la vez,
en un móvil que probablemente no los tenga. Transferirlo cambia el dueño del búfer sin copiar, y el
emisor ya no puede leerlo; por eso LocalModel guarda el File.
async init(request, onProgress) { /* A local model travels as its buffer; transferring hands it over instead of cloning it. */ const transfer = request.bytes ? [request.bytes.buffer] : undefined; const ready = await rpc.send<ReadyMessage>( { type: 'init', ...request }, onProgress, transfer, );rukh-web/src/workers/decoder-client.ts
Lo transferible es el ArrayBuffer de debajo (request.bytes.buffer), no la vista Uint8Array, y
equivocarse se paga con un error en tiempo de ejecución.
async function loadSession(request: Extract<DecoderRequest, { type: 'init' }>) { /* A local model arrives whole: it was read from disk, so there is nothing to download and no progress to report. Everything after this point is identical, including the contract check that decides whether these bytes are a Rukh decoder at all. */ const bytes = request.bytes ?? (await fetchModel(request.url, request.sizeBytes, (loaded, total) => reply({ type: 'progress', id: request.id, loaded, total }), )); return createSession(bytes);}rukh-web/src/workers/decoder.worker.ts
Del lado del worker, todo el cambio es una línea de ??; lo que viene después es idéntico, incluida
la comprobación del contrato. Esa validación existía desde M2 para rechazar una exportación corrupta
del Hub, y protege igual a un fichero del disco: un camino nuevo que se une al viejo lo antes posible
hereda todas sus guardas.
Dos detalles de interfaz que son decisiones
/** * True in the modes where nobody moves by hand (the arena, the puzzles): input is refused * without looking at whose turn it is, and the board says so with `data-locked`. */ locked?: Signal<boolean>;rukh-web/src/islands/Board.tsx
En la arena y en los puzles nadie mueve a mano, y hacía falta decirlo sin la lógica de turnos, con la
que arrastrar una pieza en la arena funcionaría la mitad de las veces. locked rechaza la entrada sin
mirar el turno, y data-locked en la raíz deja comprobar ese estado desde el E2E.
// Keep the current ply visible once the list grows past its fixed height. Only the list // scrolls, never the page: `scrollIntoView` walks every scrollable ancestor, and on a // one-column layout that moved the document by hundreds of pixels *during* a touch, so the // click the browser synthesises after `touchend` landed on whatever had slid under the finger // (the mode switcher, once it existed). A page must not move because a move was played. useEffect(() => { const container = list.current; const current = container?.querySelector<HTMLElement>('.is-current'); if (!container || !current) return; const box = container.getBoundingClientRect(); const ply = current.getBoundingClientRect(); if (ply.top < box.top) container.scrollTop += ply.top - box.top; else if (ply.bottom > box.bottom) container.scrollTop += ply.bottom - box.bottom; }, [history.length]);rukh-web/src/islands/MoveList.tsx
Este es el bloque más instructivo de la lección, y no tiene nada de ajedrez.
scrollIntoView({ block: 'nearest' }) funcionó durante cuatro módulos. Cuando llegó el conmutador de
modos, resultó que la función desplaza todos los ancestros desplazables, y en la maquetación de una
columna movía el documento cientos de píxeles durante un toque. El clic que el navegador sintetiza
tras el touchend caía sobre lo que hubiera resbalado bajo el dedo: el conmutador. Tocar una jugada
en el móvil cambiaba de modo.
El arreglo hace a mano lo mismo pero solo en el contenedor: mide las dos cajas y mueve scrollTop lo
justo. Una página no se mueve porque se haya jugado una jugada.
// Ejercicio 01Qué le pasa al 95 % de paridad en la arena
Carga tu propio model-int8.onnx y enfréntalo en la arena contra… nada, porque no puedes: la
arena solo acepta dos etapas del registro. Con isLocalStage delante, di qué habría que cambiar
para permitirlo y qué problema de la aritmética aparecería con veinte partidas.
// SoluciónVer la solución
La arena carga sus dos etapas por identificador del registro, así que habría que dejarle recibir
también un LocalModel para uno de los dos lados y pasarle los bytes al segundo worker. Es el
mismo camino de init con bytes que ya existe. El problema de la aritmética no es del código:
con veinte partidas el intervalo cubre varios cientos de Elo, y gamesNeeded lo dice en la
pantalla. Enfrentar tu int8 contra el fp16 del curso y leer «no se ha medido una diferencia» es el
resultado correcto, y es el mismo que la escalera dio en el lab 3 con 160 partidas.
// Ejercicio 02Rompe el lector de protobuf de la forma sutil
Cambia result += (byte & 0x7f) * 2 ** shift por result |= (byte & 0x7f) << shift. Los tests
pasan. ¿Con qué fichero falla, y por qué es peor que un error?
// SoluciónVer la solución
Falla con cualquier campo cuya longitud pase de 2³¹, es decir con un grafo de más de 2 GB, y
también con cualquier varint de cinco bytes o más: << opera sobre 32 bits con signo, así que un
shift de 28 en adelante da la vuelta y produce un entero negativo o pequeño. Lo que sale no es
una excepción: es una longitud equivocada, con la que el bucle avanza el cursor a un sitio
arbitrario y sigue leyendo tags que no son tags. El resultado más probable es un null
y un block de 200 por defecto que puede ser el correcto por casualidad, así que el error se
manifiesta como un prompt mal recortado en un modelo concreto. Los tests con ficheros pequeños no
lo ven, y ahí está la trampa.
Qué has aprendido
Una demo es donde se comprueba si lo que mediste significa algo para alguien que no lo midió, y para eso la pantalla tiene que hacer el mismo cálculo: la fórmula del Elo con el mismo recorte, el bootstrap sobre las mismas unidades, el criterio del puzle con el mismo prefijo. Un número del navegador que no se compara con la tabla es un adorno.
Dos decisiones de diseño sirven fuera del ajedrez. Una entrada nueva a un sistema que ya valida se
empalma lo antes posible con el camino existente, y hereda las guardas (una línea de ?? en el
worker). Y la privacidad se construye quitando capacidades: sin fetch no hay nada que ensanchar,
sin almacenamiento nada que restaurar, y un test que cuenta cero peticiones es una afirmación
comprobable.
La lección siguiente pone la tabla entera delante, con todo esto medido el mismo día.