// M3 · lección 10
La barra de evaluación: el segundo worker y la alerta que nombra la jugada
El otro lado de la frontera: el tokenizador de casillas en TypeScript con su fixture generado desde Python, el contrato que rechaza un fichero que no es el encoder, el segundo worker con su propio consentimiento, y la regla que decide qué evaluación se queda en pantalla.
Lección 10 de 11 del módulo «El encoder». Viene de «Exportar el encoder» y sigue en «Los labs».
Qué vas a construir
La mitad del módulo que vive en el navegador: la barra de evaluación junto al tablero, la alerta de error y el segundo trabajador que las alimenta mientras el decoder elige su jugada en el primero.
El código es del repositorio rukh-web, en el commit be932c9. Trae cuatro problemas que Python no
tiene: un tokenizador que tiene que dar los mismos ids que el original, un fichero descargado que
hay que comprobar antes de creérselo, dos modelos compitiendo por la GPU y una barra que se tiene
que entender sin ver sus colores. Tendrías los mismos al servir en el navegador un clasificador de
texto o un modelo de embeddings.
Dos modelos en el registro
/** Tokens the `squares` scheme feeds the encoder; `SQUARE_TOKENS` in Python and in TypeScript. */export const ENCODER_BLOCK = 69;
/** * Download sizes in MB of the encoder exports. **Provisional**, exactly like `STAGE_SIZE_MB`: * they are the plan's estimates and the controller updates them here, in this one place, once the * real export reports the file sizes. They only drive the consent copy and the progress bar * fallback, never the download itself. */export const ENCODER_SIZE_MB = { 'encoder-fp16': 30, 'encoder-int8': 15,} as const;
export const ENCODER_STAGES: Stage[] = [ { id: 'encoder-fp16', label: 'Encoder (fp16)', kind: 'encoder', repo: 'chorcat/rukh-encoder', file: 'onnx/model-fp16.onnx', sizeMb: ENCODER_SIZE_MB['encoder-fp16'], block: ENCODER_BLOCK, }, { id: 'encoder-int8', label: 'Encoder (int8)', kind: 'encoder', repo: 'chorcat/rukh-encoder', file: 'onnx/model-int8.onnx', sizeMb: ENCODER_SIZE_MB['encoder-int8'], block: ENCODER_BLOCK, },];El encoder tiene lista propia en el registro porque no es una etapa más del decoder: dentro de
STAGES, el selector ofrecería la barra como si fuera un jugador. Los tamaños son provisionales (la
exportación del lab 5 mide 30,6 MB en fp16 y 18,3 en int8, así que el 15 es optimista), y solo
alimentan el texto del consentimiento y la barra de progreso, nunca la descarga: un número mal
puesto no puede truncar un fichero.
/** * Toy encoder committed under `public/test/`: one layer, `d_model=8`, the real contract * (`idx (B, 69)` int64 -> `value` and `blunder`, the sigmoid inside the graph) and the same * `rukh_*` metadata, written by the very function that writes the published file * (`rukh.export.export_encoder_onnx`). Reached only with `?encoder=test`, which is how the E2E * suite walks the real worker path — contract check included — without downloading 30 MB. */export const TEST_ENCODER_STAGE: Stage = { id: 'test', label: 'Encoder de juguete (pruebas)', kind: 'encoder', sizeMb: 0.1, url: '/test/toy-encoder.onnx', block: ENCODER_BLOCK,};
export function findStage(id: string): Stage | undefined { if (id === TEST_STAGE.id) return TEST_STAGE; return STAGES.find((stage) => stage.id === id);}
/** The encoder stage with this id, or undefined. Separate from `findStage`: separate models. */export function findEncoderStage(id: string): Stage | undefined { if (id === TEST_ENCODER_STAGE.id) return TEST_ENCODER_STAGE; return ENCODER_STAGES.find((stage) => stage.id === id);}
/** The number of tokens an encoder stage is fed; `ENCODER_BLOCK` when a stage omits it. */export function encoderBlock(stage: Stage): number { return stage.block ?? ENCODER_BLOCK;}La idea que más merece copiarse es el encoder de juguete de public/test/: una capa, ocho
dimensiones, unos cien kilobytes, y el contrato de verdad (misma entrada, mismas dos salidas, el
sigmoide dentro, los metadatos rukh_*), porque lo escribe la misma función que el fichero
publicado. Con él, la suite de extremo a extremo recorre el camino real sin descargar treinta
megabytes; un doble que fingiera la respuesta no comprobaría nada del contrato.
El tokenizador, dos veces
// The `squares` input scheme of the encoder: a FEN as 69 fixed token ids. TypeScript twin of// `rukh/src/rukh/models/squares.py` — the ids have to be the *same* ids, so the layout, the// vocabulary order and every edge case are mirrored rather than re-invented://// 0 <cls> pooling anchor// 1..64 the 64 squares piece or <empty>, file-major (a1, a2, ..., a8, b1, ..., h8)// 65 side to move turn:w / turn:b// 66 castling rights one token with the 16 KQkq combinations// 67 en passant ep:none or the file (ep:a ... ep:h)// 68 halfmove clock clock:0 ... clock:3, the bucketed 50-move counter//// `fixtures/squares.json` is generated from the Python module itself and `tests/squares.test.ts`// replays every case: if this file and Python ever disagree, the encoder would be fed a position// it was not trained on and the evaluation bar would be quietly wrong.
/** The pieces, in the order the Python vocabulary lists them. */export const PIECES = 'PNBRQKpnbrqk';
/** Castling rights in the order a FEN field is normalised to. */export const CASTLING_ORDER = 'KQkq';
export const FILES = 'abcdefgh';
const N_SQUARES = 64;
/** Length of every `squares` sequence: 1 + 64 + 4. No padding is ever needed. */export const SQUARE_TOKENS = 69;
/** Upper edges (exclusive) of the halfmove-clock buckets. */export const CLOCK_EDGES = [6, 25, 50] as const;rukh-web/src/lib/chess-lm/squares.ts
El problema estructural de la demo es el que tuvo M1 con el tokenizador de jugadas: el modelo se
entrena en Python y se ejecuta en el navegador, así que la conversión de posición a enteros existe
dos veces. Si divergen en un solo id, la barra se equivoca en silencio, con un número plausible
sobre otra posición. Le pasa igual a cualquier modelo de texto servido con un tokenizador reescrito
en otro lenguaje. La defensa es un fixture generado desde el módulo de Python
(fixtures/squares.json) y un test que lo reproduce: la divergencia pasa a ser un test rojo.
/** The 16 castling combinations in a fixed order: `-`, `K`, `Q`, `KQ`, `k`, ... */export function castlingStrings(): string[] { const out: string[] = []; for (let mask = 0; mask < 16; mask += 1) { let rights = ''; for (let bit = 0; bit < CASTLING_ORDER.length; bit += 1) { if ((mask >> bit) & 1) rights += CASTLING_ORDER[bit]; } out.push(rights || '-'); } return out;}
/** The token list in id order; see the module header for the layout it serves. */export function buildSquareVocab(): string[] { return [ '<pad>', '<mask>', '<cls>', '<empty>', ...PIECES, 'turn:w', 'turn:b', ...castlingStrings().map((rights) => `castle:${rights}`), 'ep:none', ...FILES.split('').map((file) => `ep:${file}`), ...Array.from({ length: CLOCK_EDGES.length + 1 }, (_, index) => `clock:${index}`), ];}
export const SQUARE_VOCAB: string[] = buildSquareVocab();
export const SQUARE_IDS: ReadonlyMap<string, number> = new Map( SQUARE_VOCAB.map((token, index) => [token, index]),);
export const SQUARE_VOCAB_SIZE = SQUARE_VOCAB.length;
function idOf(token: string): number { const id = SQUARE_IDS.get(token); if (id === undefined) throw new Error(`unknown square token ${token}`); return id;}
export const PAD_ID = idOf('<pad>');export const MASK_ID = idOf('<mask>');export const CLS_ID = idOf('<cls>');export const EMPTY_ID = idOf('<empty>');rukh-web/src/lib/chess-lm/squares.ts
El vocabulario se construye línea a línea como en Python, para poder comparar las dos a ojo. Las
cuatro constantes se calculan al importar con idOf, que lanza si el token no existe: un
vocabulario roto hace fallar la página al arrancar, no en la primera evaluación.
/** Index of the halfmove-clock bucket of `halfmove` (see `CLOCK_EDGES`). */export function clockBucket(halfmove: number): number { if (!Number.isInteger(halfmove) || halfmove < 0) { throw new Error(`the halfmove clock cannot be negative, got ${halfmove}`); } return CLOCK_EDGES.reduce((count, edge) => count + (halfmove >= edge ? 1 : 0), 0);}
/** The 64 square ids, file-major, from the piece-placement field of a FEN. */function placementIds(placement: string): number[] { const ranks = placement.split('/'); if (ranks.length !== 8) { throw new Error(`a FEN placement needs 8 ranks, got ${ranks.length}: ${placement}`); } const ids = new Array<number>(N_SQUARES).fill(EMPTY_ID); for (let row = 0; row < 8; row += 1) { const rank = 7 - row; // the placement is written from rank 8 down to rank 1 let file = 0; for (const char of ranks[row]) { // Divergence from Python, on malformed input only: `str.isdigit()` there also says yes to // '0' and to non-ASCII digits ('٣', '²'), where this says no and reports an unknown piece. // Python then raises too ('0' leaves the rank short, `int('²')` throws), so no FEN is // accepted on one side and refused on the other — only the message differs. if (char >= '1' && char <= '9') { file += Number(char); } else if (PIECES.includes(char)) { if (file >= 8) throw new Error(`rank ${rank + 1} of ${placement} is too long`); ids[file * 8 + rank] = idOf(char); file += 1; } else { throw new Error(`unknown piece ${char} in ${placement}`); } } if (file !== 8) { throw new Error(`rank ${rank + 1} of ${placement} covers ${file} files, not 8`); } } return ids;}rukh-web/src/lib/chess-lm/squares.ts
Ese comentario es buena documentación: declara una divergencia con Python, delimita dónde ocurre y
demuestra que no importa. str.isdigit() acepta '0' y dígitos no ASCII como '²', y la
comparación de TypeScript no, pero los dos lados acaban lanzando, así que ningún FEN se acepta en uno
y se rechaza en el otro. Cuando dos lenguajes no tienen las mismas primitivas, se documenta la
equivalencia observable en vez de prometer identidad. (ids[file * 8 + rank] es la transposición
de la lección 4, con el mismo peligro.)
/** Token id of a FEN castling field, normalised to the `KQkq` order. */function castlingId(field: string): number { if (field === '-' || field === '') return idOf('castle:-'); for (const char of field) { if (!CASTLING_ORDER.includes(char)) { throw new Error(`unsupported castling field ${field} (Chess960 is out of scope)`); } } const rights = [...CASTLING_ORDER].filter((right) => field.includes(right)).join(''); return idOf(`castle:${rights}`);}
/** * The `SQUARE_TOKENS` ids of a FEN; four fields (`fen4`) or the full six are accepted. * * Throws on a malformed FEN: the encoder must never be fed a position that was silently * repaired into a different one. */export function fenToTokens(fen: string): number[] { const fields = fen.trim().split(/\s+/).filter(Boolean); if (fields.length < 4) { throw new Error(`a FEN needs at least 4 fields, got ${fields.length}: ${fen}`); } const [placement, turn, castling, ep] = fields; if (turn !== 'w' && turn !== 'b') { throw new Error(`the side to move must be 'w' or 'b', got ${turn}`); } // Second divergence from Python, again only where no real FEN goes. `Number()` is not `int()`: // the `Number.isInteger` guard below keeps them in step on '1.5' (refused on both sides), but // '1e2' and '0x10' are read here as 100 and 16 while `int()` raises on them. A FEN whose // halfmove clock is written like that is malformed either way; it buckets instead of throwing. const halfmove = fields.length > 4 ? Number(fields[4]) : 0; if (!Number.isInteger(halfmove)) { throw new Error(`the halfmove clock must be an integer, got ${fields[4]}`); } let epToken: string; if (ep === '-' || ep === '') { epToken = 'ep:none'; } else if (ep.length === 2 && FILES.includes(ep[0]) && (ep[1] === '3' || ep[1] === '6')) { epToken = `ep:${ep[0]}`; } else { throw new Error(`unknown en-passant square ${ep}`); } return [ CLS_ID, ...placementIds(placement), idOf(`turn:${turn}`), castlingId(castling), idOf(epToken), idOf(`clock:${clockBucket(halfmove)}`), ];}rukh-web/src/lib/chess-lm/squares.ts
La segunda divergencia sigue el mismo patrón: Number('1e2') da 100 e int('1e2') lanza, pero
solo un FEN con el reloj en notación científica, que no existe, se comporta distinto.
split(/\s+/) colapsa espacios como el split() de Python.
El contrato: comprobar el fichero antes de dibujar
export const VALUE_OUTPUT = 'value';export const BLUNDER_OUTPUT = 'blunder';
/** The two outputs the demo reads, in order. */export const ENCODER_OUTPUTS = [VALUE_OUTPUT, BLUNDER_OUTPUT] as const;
/** The contract a loaded encoder session is playing under. */export interface EncoderContract { /** Tokens the graph is fed, from the registry (`squares`: 69). */ block: number; /** Output names, in the order `evaluate` reads them. */ outputs: readonly [string, string];}
/** Range of each head's output: `tanh` for the value, a probability for the blunder. */export const VALUE_RANGE = [-1, 1] as const;export const BLUNDER_RANGE = [0, 1] as const;Los rangos son la parte que casi nadie escribe: cada cabeza tiene el rango que su activación puede
producir. Un número fuera de [-1, 1] en un tanh no indica un modelo malo sino un fichero
equivocado o una salida leída del sitio equivocado.
/** * The contract of a freshly created encoder session: its single input and how many tokens that * input takes, the two outputs by name, their declared width when ORT gives a fixed one, and the * `block` the registry declares for the stage. * * The input side matters as much as the output side. `inputFeeds` builds one tensor and hands it * to `inputNames[0]`, so a graph with two inputs would be fed one and left to guess the other; * and `squares` is not a context window that can be cropped but a fixed layout of 69 slots, so a * file that declares another length is not the encoder this bar tokenizes for — the registry's * number is what we *asked* for, the file says what it *takes*. The exporter writes the length * as a fixed axis (`rukh_dynamic_seq=False`), so it is usually there to be read; when it is * symbolic the registry's number stands and the worker still checks every sequence it sends. */export function readEncoderContract(session: SessionLike, block: number): EncoderContract { if (!Number.isInteger(block) || block < MIN_BLOCK) { throw new Error(`El contexto declarado para el encoder (${block}) no es utilizable.`); } const missing = ENCODER_OUTPUTS.filter((name) => !session.outputNames.includes(name)); if (missing.length > 0 || session.outputNames.length !== ENCODER_OUTPUTS.length) { throw new Error( `Este fichero no es el encoder de la barra: se esperaban las salidas ` + `${ENCODER_OUTPUTS.join(' y ')} y trae ${session.outputNames.join(', ') || 'ninguna'}. ` + `Borra los modelos descargados y vuelve a intentarlo.`, ); } for (const name of ENCODER_OUTPUTS) { const declared = declaredLastDim(session, name); if (declared !== null) assertScalarOutput(declared, name, DECLARED_SOURCE); } const inputs = session.inputNames; if (!inputs || inputs.length !== 1) { throw new Error( `Este fichero no es el encoder de la barra: se esperaba una única entrada y trae ` + `${inputs?.length ?? 0} (${inputs?.join(', ') || 'ninguna'}). Borra los modelos ` + `descargados y vuelve a intentarlo.`, ); } const tokens = lastFixedDim(session.inputMetadata, inputs[0]); if (tokens !== null && tokens !== block) { throw new Error( `La entrada ${inputs[0]} del encoder toma ${tokens} tokens y la barra le da ${block}: el ` + `fichero no es el que espera la barra. Borra los modelos descargados y vuelve a ` + `intentarlo.`, ); } return { block, outputs: [VALUE_OUTPUT, BLUNDER_OUTPUT] };}Cuatro comprobaciones sobre el fichero recién descargado, porque el registro dice lo que pedimos y el fichero, lo que acepta:
- dos salidas y ninguna más: un fichero con la cabeza de resultado funcionaría, pero no sería el que se midió;
- una sola entrada, porque
inputFeedsentrega un tensor ainputNames[0]y un grafo con dos fallaría dentro de la inferencia con un mensaje ilegible; - la longitud, porque
squaresno es una ventana que se pueda recortar sino 69 ranuras fijas; - un
nulldedeclaredLastDimolastFixedDimsignifica «no se sabe»: la dimensión simbólica se deja pasar y el trabajador comprueba cada secuencia.
Los mensajes acaban con la acción que resuelve el problema, «Borra los modelos descargados y vuelve a intentarlo», porque el caso más probable es un fichero viejo en la Cache API tras una exportación nueva.
/** Throws when an encoder output carries more than one number for the position being evaluated. */export function assertScalarOutput(width: number, name: string, source: string): void { if (width === 1) return; throw new Error( `La salida ${name} del encoder no es un único valor: ${source} da ${width}. No se dibuja la ` + `barra con este fichero; borra los modelos descargados y vuelve a intentarlo.`, );}
/** Throws when a head answers outside the range its own activation can produce. */export function assertInRange(value: number, name: string, [low, high]: readonly [number, number]) { if (Number.isFinite(value) && value >= low && value <= high) return; throw new Error( `La salida ${name} del encoder (${value}) se sale del rango [${low}, ${high}] que su cabeza ` + `puede producir: el fichero no es el que espera la barra.`, );}Number.isFinite(value) sobra en rigor (un NaN ya falla las comparaciones), pero deja escrito
que el NaN de una cuantización fallida se rechaza. source deja usar el mismo mensaje para la
forma declarada y para la primera respuesta real.
El segundo worker, y por qué es un segundo worker
// Dedicated worker for the position encoder: it downloads the fine-tuned encoder, keeps it in the// Cache API and answers `evaluate` with the two numbers the bar draws — the value from White's// point of view and the probability that the last move threw the game away.//// It is a worker of its own, never the decoder's, and that is the whole point: ORT creates// sessions one at a time and cannot re-enter `run`, so sharing a worker would mean the evaluation// waits for the model's move and the model's move waits for the evaluation. Two workers, two// queues, two sessions; the browser schedules them.//// The contract is checked before anything is drawn (`readEncoderContract`) and again on every// answer: two outputs called `value` and `blunder`, one number each, inside the range their heads// can produce. `rukh.export.export_encoder_onnx` puts the sigmoid inside the graph// (`rukh_blunder=probability`), so what comes out of `blunder` is already a probability and the// page compares it against 0.5 without applying anything of its own.import type * as ort from 'onnxruntime-web/webgpu';import { BLUNDER_RANGE, RUN_SOURCE, VALUE_RANGE, assertInRange, assertScalarOutput, readEncoderContract, type EncoderContract,} from '../lib/contract';import type { EncoderRequest, WorkerResponse } from '../lib/worker-protocol';import { configureOrt, createSerial, createSession, fetchModel, inputFeeds } from './ort-runtime';rukh-web/src/workers/encoder.worker.ts
ONNX Runtime no puede reentrar en run y crea sesiones de una en una, así que con un solo
trabajador cada evaluación esperaría a la jugada del modelo y cada jugada a la evaluación. Dos
trabajadores son dos colas: algo más de memoria a cambio de quitar una clase entera de bloqueos. Por
lo mismo, un servicio que combina un recuperador y un generador suele darles procesos distintos.
const ctx = self as unknown as WorkerScope;
configureOrt();
let session: ort.InferenceSession | null = null;/** What the live session was accepted under; null while there is no session. */let contract: EncoderContract | null = null;
/** This worker's own chain: sessions created in series and `run` calls never overlapping. */const serial = createSerial();
function reply(message: WorkerResponse): void { ctx.postMessage(message);}
async function init(request: Extract<EncoderRequest, { type: 'encoder-init' }>): Promise<void> { const started = performance.now(); const ready = await serial(async () => { await session?.release(); session = null; contract = null; const bytes = await fetchModel(request.url, request.sizeBytes, (loaded, total) => reply({ type: 'progress', id: request.id, loaded, total }), ); const created = await createSession(bytes); const checked = readEncoderContract(created.session, request.block); session = created.session; contract = checked; return { backend: created.backend, reason: created.reason, contract: checked }; });rukh-web/src/workers/encoder.worker.ts
El cuidado está en el orden. Sesión y contrato se ponen a null antes de descargar, así que si la
descarga falla no queda una sesión vieja para una evaluación en vuelo, y se asignan juntos al final,
cuando el contrato ya ha pasado: nunca hay sesión sin contrato. serial es la cola del trabajador:
un cambio de etapa en medio de una evaluación espera en vez de liberar una sesión en uso.
/** The single number one head answered for this position, checked on the way out. */function scalar(output: ort.InferenceSession.OnnxValueMapType, name: string): number { const tensor = output[name]; if (!tensor) throw new Error(`el encoder no ha devuelto la salida ${name}`); const raw = tensor.data; if (!(raw instanceof Float32Array)) { throw new Error(`la salida ${name} del encoder no es float32 (${tensor.type})`); } // The declared shape is `[batch]` and often symbolic, so the real width is only knowable here. assertScalarOutput(raw.length, name, RUN_SOURCE); return raw[0];}
async function evaluate(request: Extract<EncoderRequest, { type: 'evaluate' }>): Promise<void> { const answer = await serial(async () => { const current = session; const live = contract; if (!current || !live) throw new Error('el encoder todavía no está cargado'); if (request.ids.length !== live.block) { throw new Error( `la posición trae ${request.ids.length} tokens y el encoder espera ${live.block}`, ); } const feeds = inputFeeds(current, request.ids); const started = performance.now(); const output = await current.run(feeds); const inferMs = performance.now() - started; const value = scalar(output, live.outputs[0]); const blunder = scalar(output, live.outputs[1]); assertInRange(value, live.outputs[0], VALUE_RANGE); assertInRange(blunder, live.outputs[1], BLUNDER_RANGE); return { value, blunder, inferMs }; });rukh-web/src/workers/encoder.worker.ts
Las salidas se leen por nombre, como en la paridad de la lección 9: con las cabezas intercambiadas,
la barra dibujaría la probabilidad de error como evaluación. El ancho se comprueba otra vez porque
la forma declarada suele ser simbólica y el real solo se sabe con el primer resultado. Y el rango
importa porque un fp16 mal convertido puede devolver NaN, y el navegador ignora un relleno de
anchura NaN%: sin la comprobación, la barra se quedaría congelada sin decir nada.
ctx.addEventListener('message', (event: MessageEvent) => { const request = event.data as EncoderRequest; const run = async () => { switch (request.type) { case 'encoder-init': return init(request); case 'evaluate': return evaluate(request); case 'dispose': return dispose(request); default: return undefined; } }; void run().catch((error: unknown) => { reply({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error), }); });});rukh-web/src/workers/encoder.worker.ts
Un solo catch convierte cualquier fallo en un mensaje con el id de la petición, para que el lado
principal rechace la promesa correcta.
// Main-thread side of the encoder worker, built exactly like the decoder's: the `new Worker(new// URL(...), { type: 'module' })` literal at the call site so Vite emits it as its own chunk, and// the shared `rpc.ts` for the request/answer plumbing.//// It is a second worker, never the decoder's: the bar must be able to evaluate while the model// thinks. `evaluate` is still serialised *inside* the worker (ORT cannot re-enter `run`), and the// caller drops stale answers by comparing the position it asked about with the one on the board.rukh-web/src/workers/encoder-client.ts
/** The real worker; replaced by a fake in the tests. */function spawn(): WorkerLike { return new Worker(new URL('./encoder.worker.ts', import.meta.url), { type: 'module', }) as unknown as WorkerLike;}
export function createEncoder(worker: WorkerLike = spawn()): Encoder { const rpc = createRpc(worker, 'el worker del encoder ha fallado');
return { async init(request, onProgress) { const ready = await rpc.send<EncoderReadyMessage>( { type: 'encoder-init', ...request }, onProgress, ); return { backend: ready.backend, fallbackReason: ready.fallbackReason, loadMs: ready.loadMs, block: ready.block, outputs: ready.outputs, }; }, async evaluate(ids) { const answer = await rpc.send<EvaluationMessage>({ type: 'evaluate', ids }); return { value: answer.value, blunder: answer.blunder, inferMs: answer.inferMs }; }, async dispose() { if (rpc.closed) return; try { await rpc.send<unknown>({ type: 'dispose' }); } catch { /* the worker may already be gone; terminating below is enough */ } rpc.close(DEAD); }, };}rukh-web/src/workers/encoder-client.ts
El new Worker(new URL(…), { type: 'module' }) va escrito en el sitio de la llamada porque Vite
reconoce ese patrón al analizar el código y emite el trabajador como su propio fragmento; con la URL
en una variable no lo empaqueta. El trabajador como parámetro por defecto hace el módulo comprobable
sin navegador, y el try/catch vacío del dispose es de los pocos casos en que tragarse la
excepción es correcto (el comentario lo dice).
La orquestación: un segundo consentimiento
/** * The encoder's own lifecycle. It is a second model with a second consent: having accepted the * 40 MB of the decoder says nothing about accepting the 30 MB of the evaluation bar, so the bar * asks for itself and stays at `consent` until it is told otherwise. */export type EncoderStatus = 'consent' | 'loading' | 'ready' | 'error';Aceptar los cuarenta megabytes del decoder no dice nada de los treinta de la barra, y la demo funciona entera sin ella, así que la barra pide su propio consentimiento.
/** The evaluation bar: its own stage, its own consent, its own worker. */const encoderStage = signal<string>(ENCODER_STAGES[0].id);const encoderStatus = signal<EncoderStatus>('consent');const encoderProgress = signal<ProgressMessage | null>(null);const encoderBackend = signal<Backend | null>(null);const encoderError = signal<string | null>(null);const evaluation = signal<Evaluation | null>(null);/** * Bumped every time the game on the board is replaced (new game, undo, colour or stage change). * An evaluation that was asked for under an older generation belongs to a game that no longer * exists, so it is dropped: the bar deliberately survives the opponent's replies (see * `supersedes`), which means the position it belongs to is usually *not* the one on the board and * a `fen` comparison can no longer tell "stale" from "the move the player is being told about". */let generation = 0;
/** Forgets the current evaluation and refuses every answer already in flight. */function dropEvaluation(): void { generation += 1; evaluation.value = null;}El contador de generación es la pieza de concurrencia del fichero. Lo habitual para descartar
una respuesta obsoleta es comparar la posición preguntada con la del tablero, y aquí no vale: por la
regla de supersedes (más abajo) la barra sobrevive a propósito a las respuestas del rival, así que
lo que muestra casi nunca es la posición del tablero. Un entero que sube cada vez que la partida se
reemplaza sí distingue lo viejo: lo pedido en la generación 3 que llega en la 4 se tira.
/** * Turns the bar off again: releases the ORT session, terminates the worker and goes back to the * consent step. Without this the encoder is load-once for the life of the page — a second worker, * a second session and 30 MB of weights that a player who only wanted to look at the bar once has * no way of giving back. The weights stay in the Cache API (that is what "Borrar modelos * descargados" is for), so turning it on again costs no download. */async function stopEncoder() { const live = encoder; encoder = null; encoderStatus.value = 'consent'; encoderProgress.value = null; encoderBackend.value = null; encoderError.value = null; dropEvaluation(); await live?.dispose();}Sin poder apagarla, el trabajador, la sesión y los treinta megabytes se quedarían toda la vida de la
página. encoder = null va primero, para que ninguna evaluación posterior encuentre a quién
preguntar, y el dispose al final: el usuario ve el cambio al instante y la sesión se libera por
detrás.
/** * Evaluates a position the board settled on. The worker serialises its own runs, so a burst of * moves only queues; what is decided here is *which* answer the bar ends up drawing, and that is * not simply the newest one — `supersedes` explains why the player's own move wins over the reply * that follows it milliseconds later. * * A failed `evaluate` is a failed **run**, not a failed session: the bar is emptied (a frozen * number about a position nobody is looking at any more is worse than no number) and the reason * is shown in the panel, but the session stays `ready`, so the next position simply tries again. */async function evaluatePosition(state: GameState) { const source = encoder; if (!source || encoderStatus.value !== 'ready') return; const asked = generation; const fen = state.fen; const ply = state.history.length; const move = ply > 0 ? state.history[ply - 1] : null; // The side that has just moved is the one that is *not* to move now. const byHuman = ply > 0 && state.turn !== human.value; try { const answer = await source.evaluate(fenToTokens(fen)); if (asked !== generation) return; const next: Evaluation = { value: answer.value, blunder: answer.blunder, move, ply, byHuman }; if (!supersedes(next, evaluation.value)) return; encoderError.value = null; evaluation.value = next; } catch (cause) { if (asked !== generation) return; encoderError.value = cause instanceof Error ? cause.message : String(cause); evaluation.value = null; }}Una inferencia que falla vacía la barra y muestra el motivo, pero la sesión sigue lista y la
posición siguiente lo vuelve a intentar, sin descargar otra vez treinta megabytes por un NaN. Se
vacía en vez de dejarla como estaba por la política de la lección 8: un hueco se ve, un dato viejo
no. El asked !== generation también está en el catch, para que un error de una partida vieja no
aparezca en la nueva, y byHuman lleva comentario porque es fácil escribirlo al revés.
La regla que decide qué se queda en pantalla
import type { Signal } from '@preact/signals';
/** What the encoder answered for the position the bar is drawing. */export interface Evaluation { /** `tanh(cp / 400)` from White's point of view, in [-1, 1]. */ value: number; /** Probability that the move that led here threw the game away (already a probability). */ blunder: number; /** SAN of that move, or null in the starting position. The alert has to be able to name it. */ move: string | null; /** Half-moves played to reach the position; how two evaluations are ordered. */ ply: number; /** True when `move` was the player's own, false when it was the opponent's (or there is none). */ byHuman: boolean;}
/** * Whether `next` should replace `current` on the bar. * * The rule is deliberately **not** "the newest answer wins". Playing a move hands the turn to the * opponent within milliseconds, so the newest answer is always about the opponent's reply, and a * bar that always drew the newest one could structurally only ever accuse the opponent: the * player would never be told about their own blunder, which is the one thing a learning demo is * for. So an evaluation of a position the player created stays up until the player creates a * newer one, and a position the *opponent* created only ever replaces another one of its kind — * the opening position, or the opponent's first move when the human plays black. * * Positions from an older game never arrive here: `App` drops them by generation before asking. */export function supersedes(next: Evaluation, current: Evaluation | null): boolean { if (!current) return true; if (next.ply < current.ply) return false; return next.byHuman || !current.byHuman;}rukh-web/src/islands/EvalBar.tsx
Cuatro líneas de código y trece de explicación, y hacen falta las trece: es una decisión de producto
que no se ve en el código. Con «gana la respuesta más nueva», el jugador mueve, el modelo contesta en
milisegundos y la evaluación del rival reemplaza al instante la suya: la barra solo podría acusar
al rival, y al jugador nunca se le diría que acaba de meter la pata. Por eso la evaluación de una
posición que creó el jugador se queda hasta que cree otra, y la del rival solo reemplaza a otra del
rival. La comparación de ply impide que una respuesta tardía haga retroceder la barra, y esta regla
es la que obliga al contador de generación: las dos decisiones van encadenadas.
/** Above this the encoder is calling the last move a blunder; the head answers a probability. */export const BLUNDER_THRESHOLD = 0.5;
/** Share of the bar that belongs to White, as a percentage string. */export function whiteShare(value: number): string { const clamped = Math.min(1, Math.max(-1, value)); return `${Math.round(((clamped + 1) / 2) * 1000) / 10}%`;}
/** The evaluation as the bar prints it: always signed, so the sign is never only a colour. */export function formatValue(value: number): string { const rounded = Math.round(value * 100) / 100; // `-0.00` would claim a side that does not exist. const shown = Object.is(rounded, -0) ? 0 : rounded; return `${shown > 0 ? '+' : ''}${shown.toFixed(2)}`;}
/** Who the evaluation favours, in words: the bar must be readable without seeing its colours. */export function advantage(value: number): string { if (Math.abs(value) < 0.05) return 'igualada'; return value > 0 ? 'ventaja de las blancas' : 'ventaja de las negras';}rukh-web/src/islands/EvalBar.tsx
BLUNDER_THRESHOLD = 0.5 es el umbral de fábrica, y la lección 8 explicó lo que le hace a una clase
del 3,7 %: la cabeza publicada no pasa de 0,31, así que la alerta no se enciende nunca. La demo
lo publica tal cual en vez de bajar el umbral a mano hasta que salgan alertas, que sería enseñar un
detector sin medir.
-0.001 redondeado es -0, y (-0).toFixed(2) da "-0.00", una ventaja para un bando que no la
tiene; Object.is es la única comparación que distingue -0 de 0. advantage hace la barra
legible sin ver los colores: si una interfaz dibuja un signo con un color, el DOM tiene que decirlo
también con palabras.
/** * The encoder's evaluation bar: vertical beside the board on a desktop, horizontal under the * board below 900 px (`src/styles/base.css`; the component draws the same markup either way and * the media query decides). Nothing is rendered until the encoder has answered, so the single * screen of `docs/05-web-demo.md` is untouched for a player who never turns the bar on. * * Three rules it follows on purpose: * * * the number is printed with its sign and the label says who is ahead, because a bar whose * only cue is which end is filled is unreadable for someone who cannot tell the colours * apart (and for anyone on a screen reader); * * the alert **names the move it refers to**: the blunder head answers about the move that led * to this position, not about the position, and an alert that does not say which move is an * accusation with no defendant. Without a move played there is nothing to name and no alert. * Which move that is follows `supersedes` above: normally the player's own last move; * * the fill is animated with a 140 ms transition, and `prefers-reduced-motion: reduce` turns * every transition off globally in `base.css`. * * The fill percentage travels in a CSS custom property set as a `style` attribute. The CSP allows * style *attributes* (`style-src-attr 'unsafe-inline'`, which cm-chessboard already needs to drag * a piece) while `style-src` itself stays hashed, so nothing is relaxed for this bar. */export default function EvalBar({ evaluation }: Props) { const current = evaluation.value; if (!current) return null; const alert = current.blunder > BLUNDER_THRESHOLD && current.move !== null; const reading = `${formatValue(current.value)} · ${advantage(current.value)}`; const about = current.move ? ` tras ${current.move}` : '';rukh-web/src/islands/EvalBar.tsx
De las tres reglas, la que hay que llevarse es la segunda: la alerta nombra la jugada. La cabeza de errores no dice «esta posición es mala» sino «la jugada que llevó aquí tiró la partida», y una alerta que no dice cuál es una acusación sin acusado. Cualquier clasificador que se enseña a un usuario debe decir sobre qué se pronuncia.
return ( <> <section class="evalbar" data-testid="evalbar" data-blunder={alert ? 'true' : 'false'} aria-label="Barra de evaluación del encoder" > <div class="evalbar__track" role="img" aria-label={`Evaluación del encoder: ${reading}${about}`} data-testid="eval-track" > <div class="evalbar__fill" style={{ '--eval-fill': whiteShare(current.value) }} /> </div> <p class="evalbar__value" data-testid="eval-value" data-raw={current.value.toFixed(4)}> {formatValue(current.value)} </p> </section> {/* The `role="img"` label above is read once, when the bar is focused or walked: nothing re-announces it when the number changes. This is the announcement path for the value — the same pattern the move list uses for the last move played. */} <p class="visually-hidden" aria-live="polite" data-testid="eval-announce"> {`Evaluación ${reading}${about}`} </p> {/* Always in the DOM, empty when there is nothing to say: a live region has to pre-exist the text it announces, and one that is created together with its content is routinely missed. `.evalbar__alert:empty` collapses the frame so an empty one draws nothing. */} <p class="evalbar__alert" role="status" data-testid="blunder-alert" data-alert={alert ? 'true' : 'false'} > {alert ? ( <> <span class="evalbar__alert-tag">Posible error</span> en {current.move} ·{' '} {Math.round(current.blunder * 100)} % según el encoder </> ) : null} </p> </> );}rukh-web/src/islands/EvalBar.tsx
El marcado lleva tres decisiones de accesibilidad y pruebas:
- Dos caminos para el mismo número: el
aria-labeldelrole="img"se lee al enfocar la barra y no se repite cuando cambia; el<p>conaria-live="polite"anuncia el cambio. - La región viva existe siempre, vacía si no hay nada que decir, porque una región
aria-livetiene que existir antes que el texto que anuncia: el lector de pantalla no observa un nodo recién creado. Es uno de los errores de accesibilidad más comunes. data-blunder,data-alertydata-rawdejan a la suite de extremo a extremo comprobar las dos ramas y el número sin depender del modelo cargado, del color ni del redondeo.
// Ejercicio 01Por qué la alerta no se enciende nunca en la demo publicada
La barra compara blunder > 0.5 y la cabeza publicada no pasa de 0,31. Así que nadie va a ver una
alerta jamás. Enumera tres formas de «arreglarlo» y di cuál es legítima y por qué las otras dos no.
// SoluciónVer la solución
Bajar BLUNDER_THRESHOLD a 0,0663 en la demo. Es la tentadora y la peor: ese umbral lo eligió
la mitad tune para un checkpoint concreto (el de casillas salió en 0,04459). Escrito en la demo,
la ataría a un modelo y dejaría de valer en cuanto se publicara otro, sin que nada fallara.
Calibrar la cabeza (una temperatura o una regresión isotónica sobre la mitad tune) para que
sus probabilidades signifiquen lo que dicen, y dejar el 0,5. Es lo correcto y es trabajo de otro
hito: el 0,5 de una cabeza calibrada se puede interpretar sin saber nada del checkpoint.
Que el umbral viaje en los metadatos del ONNX, como rukh_kind y rukh_heads, y la barra lo
lea de ahí. Es legítimo y barato: el umbral es una propiedad del modelo exportado, así que su sitio
es el modelo.
En ningún caso vale tocar el umbral sin publicar cuál es: un punto de operación que no se declara no se puede reproducir.
Qué has aprendido
Servir un modelo en el navegador trae cuatro problemas que el entrenamiento no tiene, cada uno con su técnica: un tokenizador gemelo defendido con un fixture generado desde Python; un fichero descargado que se comprueba contra un contrato antes de dibujar nada; dos modelos que no pueden compartir cola y van en dos trabajadores; y una barra legible sin colores, con una región viva que existe antes que su texto. Y una lección de producto que sirve en cualquier interfaz: «gana el último» puede ser incapaz de mostrar lo que importa, como una barra que solo acusara al rival.
Lo siguiente son los labs, con las salidas reales de las seis tiradas.