rukh · lab

// M1 · lección 09

El tokenizador en el navegador

La segunda implementación de los tres tokenizadores, en TypeScript, sin compartir una línea con la de Python. Por qué hay dos, qué las mantiene iguales y qué subconjunto del BPE de Hugging Face hace falta reproducir para que los ids coincidan.

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

Qué vas a construir

Los mismos tres tokenizadores, otra vez, en TypeScript. Corren en el navegador de quien juega contra el modelo, no comparten una línea con los de Python y tienen que producir los mismos ids.

Duplicar código incomoda, pero aquí no hay alternativa, y es la situación de cualquiera que sirva un modelo en un sitio distinto de donde lo entrenó: un LLM en un móvil, un clasificador en el navegador, un modelo exportado a otro runtime. Son cuatro ficheros en rukh-web y el test que los ata a la fixture de Python.

La estrategia: una fixture y dos suites

El vocabulario UCI se reimplementa copiando el orden de la enumeración. El BPE depende de los datos, así que no se reimplementa el entrenamiento sino el codificador, que lee el bpe.json que entrenó Python. Y un solo artefacto comprueba que las dos mitades dicen lo mismo: fixtures/games.json, las veinte partidas que Python codificó con los tres esquemas. TypeScript las codifica por su cuenta y compara.

Los tres JSON (vocab.json, bpe.json y la fixture) los copia a src/lib/chess-lm/ scripts/sync-tokenizer.mjs, que antes de escribir comprueba que cada fichero es el artefacto que espera.

tokenizer.ts: la enumeración, otra vez

src/lib/chess-lm/tokenizer.ts
// Fixed-vocabulary UCI tokenizer. The vocabulary is a deterministic enumeration (it does not
// depend on any data) implemented identically in Python (`rukh/src/rukh/tokenize/uci_vocab.py`)
// and here; `tests/parity.test.ts` checks both against the fixture exported from Python.
const FILES = 'abcdefgh';
const RANKS = '12345678';
/** The 64 squares in file-major order: a1, a2, ..., a8, b1, ..., h8 (index `file * 8 + rank`). */
export const SQUARES: readonly string[] = Array.from({ length: 64 }, (_, i) => {
const file = FILES[Math.floor(i / 8)];
const rank = RANKS[i % 8];
return `${file}${rank}`;
});
/** Special tokens, in id order (0-7). Elo bins follow (8-61) and moves start at id 62. */
export const SPECIALS: readonly string[] = [
'<pad>',
'<bos>',
'<eos>',
'<mask>',
'<unk>',
'<1-0>',
'<0-1>',
'<1/2>',
];
export const PAD_ID = 0;
export const BOS_ID = 1;
export const EOS_ID = 2;
export const MASK_ID = 3;
export const UNK_ID = 4;
export const ELO_MIN = 600;
/** Upper clamp before binning, so 3200+ lands in `<w3200>`; it is not the highest bin (3200). */
export const ELO_CLAMP_MAX = 3299;
export const ELO_BIN_WIDTH = 100;
/** Number of Elo bins per side: 600, 700, ..., 3200. */
export const ELO_BIN_COUNT = 27;
const PROMOTION_PIECES = ['q', 'r', 'b', 'n'] as const;
/** Result strings accepted by `encodeGame`, mapped to their special token. */
const RESULT_TOKENS: Record<string, string> = {
'1-0': '<1-0>',
'0-1': '<0-1>',
'1/2-1/2': '<1/2>',
};

rukh-web/src/lib/chess-lm/tokenizer.tslíneas 1-47 · 9be992c

SQUARES llega a la misma lista que Python por otro camino, un índice en vez de un doble bucle, y eso es sano: con el mismo truco en los dos lados, un error de razonamiento se cometería dos veces.

ELO_CLAMP_MAX = 3299 es el ELO_MAX + ELO_STEP - 1 de Python sacado a una constante, con la advertencia de que no es el tramo más alto. Poner 3300 rompería la paridad: un 3300 caería en un <w3300> que no existe.

src/lib/chess-lm/tokenizer.ts
function coords(square: string): [number, number] {
return [FILES.indexOf(square[0] ?? ''), RANKS.indexOf(square[1] ?? '')];
}
/** True if `to` is reachable from `from` by a queen (same file, rank or diagonal) or a knight. */
function reachable(from: string, to: string): boolean {
if (from === to) return false;
const [ff, fr] = coords(from);
const [tf, tr] = coords(to);
const df = Math.abs(tf - ff);
const dr = Math.abs(tr - fr);
const queen = df === 0 || dr === 0 || df === dr;
const knight = (df === 1 && dr === 2) || (df === 2 && dr === 1);
return queen || knight;
}
/** Maps an Elo to its bin token: `clamp(elo, 600, 3299)` floored to hundreds, four digits. */
export function eloBin(elo: number, side: 'w' | 'b'): string {
const clamped = Math.min(Math.max(elo, ELO_MIN), ELO_CLAMP_MAX);
const bin = Math.floor(clamped / ELO_BIN_WIDTH) * ELO_BIN_WIDTH;
return `<${side}${String(bin).padStart(4, '0')}>`;
}
function eloBinTokens(side: 'w' | 'b'): string[] {
return Array.from({ length: ELO_BIN_COUNT }, (_, i) => eloBin(ELO_MIN + i * ELO_BIN_WIDTH, side));
}

rukh-web/src/lib/chess-lm/tokenizer.tslíneas 49-74 · 9be992c

El // de Python redondea hacia abajo también con negativos, y su traducción fiel es Math.floor, no Math.trunc. Aquí el Elo ya viene recortado y darían lo mismo, pero es la clase de equivalencia que se resuelve leyendo el código, porque ningún test con datos normales la distinguiría. El padStart(4, '0') reproduce el {elo:04d}: sin él saldría <w600> y los 54 tokens de Elo dejarían de coincidir.

src/lib/chess-lm/tokenizer.ts
/** Enumerates the 1968 pseudo-legal UCI move strings: 1792 plain moves then 176 promotions. */
export function buildMoves(): string[] {
const moves: string[] = [];
for (const from of SQUARES) {
for (const to of SQUARES) {
if (reachable(from, to)) moves.push(`${from}${to}`);
}
}
for (const [fromRank, toRank] of [
['7', '8'],
['2', '1'],
]) {
for (const from of SQUARES) {
if (from[1] !== fromRank) continue;
for (const to of SQUARES) {
if (to[1] !== toRank) continue;
if (Math.abs(coords(to)[0] - coords(from)[0]) > 1) continue;
for (const piece of PROMOTION_PIECES) moves.push(`${from}${to}${piece}`);
}
}
}
return moves;
}
/** The full 2030-token vocabulary: specials, white Elo bins, black Elo bins, moves. */
export function buildVocab(): string[] {
return [...SPECIALS, ...eloBinTokens('w'), ...eloBinTokens('b'), ...buildMoves()];
}

rukh-web/src/lib/chess-lm/tokenizer.tslíneas 76-103 · 9be992c

buildMoves hace en una función lo que Python hace en dos, con el mismo orden resultante. buildVocab y build_vocab son, juntas, la definición del contrato.

src/lib/chess-lm/tokenizer.ts
export class UciTokenizer {
readonly tokens: readonly string[];
private readonly ids: Map<string, number>;
constructor(tokens: readonly string[] = buildVocab()) {
this.tokens = tokens;
this.ids = new Map(tokens.map((token, id) => [token, id]));
}
get size(): number {
return this.tokens.length;
}
/** Id of a token, or `<unk>` (4) if it is not in the vocabulary. */
tokenId(token: string): number {
return this.ids.get(token) ?? UNK_ID;
}
/** Encodes a whitespace-separated UCI move string; unknown moves map to `<unk>`. */
encodeMoves(uci: string): number[] {
return uci
.trim()
.split(/\s+/)
.filter((move) => move.length > 0)
.map((move) => this.tokenId(move));
}

rukh-web/src/lib/chess-lm/tokenizer.tslíneas 105-130 · 9be992c

La lista de tokens opcional del constructor permite cargar el vocabulario con el que se entrenó un modelo publicado, en vez de depender de que el código local siga coincidiendo.

src/lib/chess-lm/tokenizer.ts
/**
* `[<bos>, <wXXXX>, <bXXXX>, ...moves, <result>, <eos>]`. When the whole game fits in `maxLen`
* the sequence ends with `<eos>`; otherwise it is cut to the first `maxLen` ids (no result, no
* `<eos>`), exactly as `UciTokenizer.encode_game` does in Python.
*/
encodeGame(
uci: string,
whiteElo: number,
blackElo: number,
result: string,
maxLen = 200,
): number[] {
const resultToken = RESULT_TOKENS[result.trim()];
if (resultToken === undefined) throw new Error(`unknown result: ${result}`);
const ids = [
BOS_ID,
this.tokenId(eloBin(whiteElo, 'w')),
this.tokenId(eloBin(blackElo, 'b')),
...this.encodeMoves(uci),
this.tokenId(resultToken),
EOS_ID,
];
return ids.length <= maxLen ? ids : ids.slice(0, maxLen);
}
/** Token strings for a sequence of ids; out-of-range ids decode to `<unk>`. */
decode(ids: readonly number[]): string[] {
return ids.map((id) => this.tokens[id] ?? '<unk>');
}
}

rukh-web/src/lib/chess-lm/tokenizer.tslíneas 132-161 · 9be992c

El comentario de encodeGame dice que corta igual que Python, sin añadir <eos>: es el detalle que alguien “arreglaría” al portarlo. Y lanza con un resultado desconocido, como Python, porque fallar en los mismos sitios también es parte de la paridad.

san-chars.ts: un id por carácter de un alfabeto fijo

src/lib/chess-lm/san-chars.ts
// Character-level tokenizer for numbered SAN text plus the result (`1.e4 e5 2.Nf3 ... 1-0`).
// Mirrors `rukh/src/rukh/tokenize/san_chars.py`: `<pad>`, `<bos>`, `<eos>` first, then the fixed
// 32-symbol alphabet in this exact order; `encode` wraps the text in `<bos>` ... `<eos>`.
export const SAN_ALPHABET = ' #+-.0123456789=BKNOQRabcdefghx/';
export const SAN_SPECIALS: readonly string[] = ['<pad>', '<bos>', '<eos>'];
export const SAN_PAD_ID = 0;
export const SAN_BOS_ID = 1;
export const SAN_EOS_ID = 2;
export class SanCharTokenizer {
readonly tokens: readonly string[];
private readonly ids: Map<string, number>;
constructor(alphabet: string = SAN_ALPHABET, specials: readonly string[] = SAN_SPECIALS) {
this.tokens = [...specials, ...alphabet];
this.ids = new Map(this.tokens.map((token, id) => [token, id]));
}

rukh-web/src/lib/chess-lm/san-chars.tslíneas 1-18 · 9be992c

El alfabeto es la misma cadena, en el mismo orden, y ese orden es el vocabulario.

src/lib/chess-lm/san-chars.ts
get size(): number {
return this.tokens.length;
}
/** `[<bos>, one id per character, <eos>]`; characters outside the alphabet throw. */
encode(text: string): number[] {
const ids = Array.from(text, (char) => {
const id = this.ids.get(char);
if (id === undefined)
throw new Error(`character not in SAN alphabet: ${JSON.stringify(char)}`);
return id;
});
return [SAN_BOS_ID, ...ids, SAN_EOS_ID];
}
/** Joins the characters back into text; special tokens are dropped. */
decode(ids: readonly number[]): string {
return ids
.map((id) => this.tokens[id] ?? '')
.filter((token) => !SAN_SPECIALS.includes(token))
.join('');
}
}

rukh-web/src/lib/chess-lm/san-chars.tslíneas 20-42 · 9be992c

Array.from(text, ...) itera por puntos de código, y text.split('') por unidades UTF-16: con el alfabeto SAN da lo mismo, pero con otro texto split('') partiría un emoji por la mitad.

bpe.ts: reproducir el codificador, no el entrenamiento

Este es el fichero difícil, y la cabecera explica por qué.

src/lib/chess-lm/bpe.ts
// Applies a BPE model trained with Hugging Face `tokenizers` (`models.BPE` with a
// `WhitespaceSplit` pre-tokenizer, no continuing-subword prefix and no end-of-word suffix) to
// UCI text. It reads the `tokenizer.json` that `Tokenizer.save()` writes and reproduces the
// encoder: split on whitespace, start from single characters and merge the adjacent pair with
// the lowest merge rank (leftmost on ties) until nothing merges, then map symbols to ids.
// Only that subset is implemented: a file with dropout, `fuse_unk`, `byte_fallback`, an affix or
// another pre-tokenizer is rejected by the constructor instead of being encoded incorrectly.
export interface BpeAddedToken {
id: number;
content: string;
special?: boolean;
}
export interface BpeFile {
model: {
type?: string;
unk_token?: string | null;
ignore_merges?: boolean;
dropout?: number | null;
continuing_subword_prefix?: string | null;
end_of_word_suffix?: string | null;
fuse_unk?: boolean;
byte_fallback?: boolean;
vocab: Record<string, number>;
merges: string[] | [string, string][];
};
pre_tokenizer?: { type?: string } | null;
added_tokens?: BpeAddedToken[];
}
export const DEFAULT_UNK_TOKEN = '<unk>';
/** Pre-tokenizers this loader reproduces; anything else splits words differently. */
const SUPPORTED_PRE_TOKENIZERS = ['WhitespaceSplit'];

rukh-web/src/lib/chess-lm/bpe.tslíneas 1-35 · 9be992c

El JSON de Tokenizer.save() tiene muchas opciones que cambian cómo se codifica, y un cargador que las ignore produce ids distintos sin avisar. La decisión del fichero es no implementar las que no se usan y rechazar un fichero que las declare.

src/lib/chess-lm/bpe.ts
/**
* Rejects a `tokenizer.json` whose options this loader does not implement, instead of encoding
* it as if they held their default value. The supported subset is listed in the README.
*/
function assertSupported(file: BpeFile): void {
const { model } = file;
const unsupported = (option: string, value: unknown, expected: string): Error =>
new Error(
`unsupported BPE model: ${option} is ${JSON.stringify(value) ?? 'undefined'}, ` +
`this loader only supports ${expected}`,
);
if (model.type !== undefined && model.type !== 'BPE') {
throw unsupported('model.type', model.type, '"BPE"');
}
if (model.dropout !== undefined && model.dropout !== null) {
throw unsupported('model.dropout', model.dropout, 'null');
}
for (const option of ['continuing_subword_prefix', 'end_of_word_suffix'] as const) {
const value = model[option];
if (value !== undefined && value !== null && value !== '') {
throw unsupported(`model.${option}`, value, 'null or ""');
}
}
for (const option of ['fuse_unk', 'byte_fallback'] as const) {
if (model[option] === true) throw unsupported(`model.${option}`, true, 'false');
}
const preTokenizer = file.pre_tokenizer;
if (preTokenizer != null && !SUPPORTED_PRE_TOKENIZERS.includes(preTokenizer.type ?? '')) {
throw unsupported(
'pre_tokenizer.type',
preTokenizer.type,
SUPPORTED_PRE_TOKENIZERS.map((type) => `"${type}"`).join(', '),
);
}
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

rukh-web/src/lib/chess-lm/bpe.tslíneas 37-76 · 9be992c

assertSupported es la puerta: lanza con la opción, su valor y lo que acepta. Qué haría cada una si se ignorase:

  • dropout salta fusiones al azar para regularizar, así que da ids distintos en cada pasada.
  • continuing_subword_prefix (el ## de BERT) y end_of_word_suffix (el </w> del BPE clásico) cambian los propios tokens: con prefijo, e4 en medio de palabra es ##e4.
  • fuse_unk junta desconocidos seguidos, y byte_fallback los parte en bytes.
  • Otro pre_tokenizer parte el texto en otros sitios, y con eso cambia todo.
src/lib/chess-lm/bpe.ts
export class BpeTokenizer {
readonly vocab: ReadonlyMap<string, number>;
readonly tokens: readonly string[];
readonly unkId: number;
/** Merge rank by `a\u0000b` key (the pair separator cannot appear in a token). */
private readonly ranks: Map<string, number>;
private readonly addedTokens: Map<string, number>;
private readonly addedPattern: RegExp | null;
private readonly ignoreMerges: boolean;
constructor(file: BpeFile) {
assertSupported(file);
const { model } = file;
this.vocab = new Map(Object.entries(model.vocab));
const size = Math.max(-1, ...this.vocab.values()) + 1;
const tokens: string[] = new Array<string>(size).fill('');
for (const [token, id] of this.vocab) tokens[id] = token;
this.tokens = tokens;
const unkToken = model.unk_token ?? DEFAULT_UNK_TOKEN;
const unkId = this.vocab.get(unkToken);
if (unkId === undefined) throw new Error(`BPE vocabulary has no ${unkToken} token`);
this.unkId = unkId;
this.ignoreMerges = model.ignore_merges ?? false;
this.ranks = new Map();
model.merges.forEach((merge, rank) => {
const [a, b] = typeof merge === 'string' ? splitMerge(merge) : merge;
if (!this.vocab.has(a) || !this.vocab.has(b) || !this.vocab.has(a + b)) return;
this.ranks.set(pairKey(a, b), rank);
});
this.addedTokens = new Map();
for (const added of file.added_tokens ?? []) this.addedTokens.set(added.content, added.id);
const contents = [...this.addedTokens.keys()].sort((x, y) => y.length - x.length);
this.addedPattern =
contents.length > 0 ? new RegExp(`(${contents.map(escapeRegExp).join('|')})`) : null;
}

rukh-web/src/lib/chess-lm/bpe.tslíneas 78-115 · 9be992c

ranks guarda la posición de cada par en la lista de fusiones, con un carácter nulo de separador porque no puede aparecer dentro de un token. El filtro descarta las fusiones cuyo resultado no está en el vocabulario: el entrenador recorta al tamaño pedido y puede dejar fusiones huérfanas, que producirían símbolos sin id. Las dos formas de merges cubren dos versiones del formato de tokenizers.

src/lib/chess-lm/bpe.ts
get size(): number {
return this.tokens.length;
}
/** Ids of the tokens of `text`, applying added tokens first and BPE to the rest. */
encode(text: string): number[] {
return this.tokenize(text).map(
(token) => this.addedTokens.get(token) ?? this.vocab.get(token) ?? this.unkId,
);
}
/** Token strings of `text` (unknown characters stay as themselves). */
tokenize(text: string): string[] {
if (this.addedPattern === null) return this.tokenizeSpan(text);
const out: string[] = [];
for (const span of text.split(this.addedPattern)) {
if (span.length === 0) continue;
if (this.addedTokens.has(span)) out.push(span);
else out.push(...this.tokenizeSpan(span));
}
return out;
}
/** Token strings for a sequence of ids; out-of-range ids decode to the unknown token. */
decode(ids: readonly number[]): string[] {
return ids.map((id) => this.tokens[id] || this.tokens[this.unkId] || DEFAULT_UNK_TOKEN);
}

rukh-web/src/lib/chess-lm/bpe.tslíneas 117-143 · 9be992c

tokenize aparta primero los tokens añadidos, los especiales: pasado por BPE, <bos> saldría como un puñado de ids. La expresión regular los ordena de más largo a más corto para que uno que sea prefijo de otro no gane.

src/lib/chess-lm/bpe.ts
private tokenizeSpan(text: string): string[] {
const out: string[] = [];
for (const word of text.split(/\s+/)) {
if (word.length > 0) out.push(...this.mergeWord(word));
}
return out;
}
private mergeWord(word: string): string[] {
if (this.ignoreMerges && this.vocab.has(word)) return [word];
const symbols = Array.from(word);
for (;;) {
let best = -1;
let bestRank = Number.POSITIVE_INFINITY;
for (let i = 0; i + 1 < symbols.length; i++) {
const rank = this.ranks.get(pairKey(symbols[i]!, symbols[i + 1]!));
if (rank !== undefined && rank < bestRank) {
bestRank = rank;
best = i;
}
}
if (best < 0) return symbols;
symbols.splice(best, 2, symbols[best]! + symbols[best + 1]!);
}
}
}

rukh-web/src/lib/chess-lm/bpe.tslíneas 145-170 · 9be992c

Y aquí está el algoritmo: desde los caracteres sueltos, en cada vuelta se aplica el par adyacente con el rango más bajo, la fusión más temprana, hasta que no queda ninguno. El < estricto decide los empates a favor del par de más a la izquierda, como tokenizers; con <= saldría otra segmentación.

src/lib/chess-lm/bpe.ts
function pairKey(a: string, b: string): string {
return `${a}\u0000${b}`;
}
function splitMerge(merge: string): [string, string] {
const space = merge.indexOf(' ');
if (space < 0) throw new Error(`malformed BPE merge: ${JSON.stringify(merge)}`);
return [merge.slice(0, space), merge.slice(space + 1)];
}
export function loadBpe(json: BpeFile): BpeTokenizer {
return new BpeTokenizer(json);
}

rukh-web/src/lib/chess-lm/bpe.tslíneas 172-184 · 9be992c

El index.ts solo reexporta; puedes saltarlo, pero el test de paridad importa de él. Si no lo tecleas, tráelo dentro de rukh-web con git checkout 9be992c -- src/lib/chess-lm/index.ts.

// Fontaneríasrc/lib/chess-lm/index.ts: la superficie pública del paquete
src/lib/chess-lm/index.ts
export {
BOS_ID,
ELO_BIN_COUNT,
ELO_BIN_WIDTH,
ELO_CLAMP_MAX,
ELO_MIN,
EOS_ID,
MASK_ID,
PAD_ID,
SPECIALS,
SQUARES,
UNK_ID,
UciTokenizer,
buildMoves,
buildVocab,
eloBin,
} from './tokenizer';
export {
SAN_ALPHABET,
SAN_BOS_ID,
SAN_EOS_ID,
SAN_PAD_ID,
SAN_SPECIALS,
SanCharTokenizer,
} from './san-chars';
export { BpeTokenizer, DEFAULT_UNK_TOKEN, loadBpe } from './bpe';
export type { BpeAddedToken, BpeFile } from './bpe';

rukh-web/src/lib/chess-lm/index.tslíneas 1-27 · 9be992c

index.ts enumera a mano la superficie pública, sin export *: lo que no está en la lista es interno, y se puede cambiar sin preguntarse quién lo usa.

El test de paridad

El test compara contra la fixture de Python; léelo al teclearlo, la idea está en el párrafo que lo cierra. Si no lo tecleas, tráelo dentro de rukh-web con git checkout 9be992c -- tests/parity.test.ts antes del «Ejecútalo».

// Teststests/parity.test.ts: los ids de Python contra los de TypeScript, partida a partida
tests/parity.test.ts
// Parity with the Python tokenizers (`rukh/src/rukh/tokenize/`). The JSON files under
// src/lib/chess-lm/ are synced from ../rukh/artifacts/tokenizer with `pnpm sync:tokenizer`; these
// tests fail (never skip) when they are missing.
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildVocab, UciTokenizer } from '../src/lib/chess-lm/tokenizer';
import { SanCharTokenizer } from '../src/lib/chess-lm/san-chars';
import { loadBpe, type BpeFile } from '../src/lib/chess-lm/bpe';
const dir = resolve(import.meta.dirname, '../src/lib/chess-lm');
interface VocabFile {
version: number;
specials: string[];
tokens: string[];
}
interface FixtureGame {
uci: string;
san: string;
white_elo: number;
black_elo: number;
result: string;
uci_ids: number[];
san_ids: number[];
bpe_ids: number[];
}
interface FixtureFile {
games: FixtureGame[];
}
function readJson<T>(name: string): T {
const path = resolve(dir, name);
if (!existsSync(path)) {
throw new Error(`fixture missing: ${path} (run \`pnpm sync:tokenizer\`)`);
}
return JSON.parse(readFileSync(path, 'utf8')) as T;
}
function readGames(): FixtureGame[] {
const fixture = readJson<FixtureFile | FixtureGame[]>('fixtures/games.json');
const games = Array.isArray(fixture) ? fixture : fixture.games;
if (!Array.isArray(games) || games.length === 0) {
throw new Error('fixture missing: fixtures/games.json has no games');
}
return games;
}

rukh-web/tests/parity.test.tslíneas 1-49 · 9be992c

Si los JSON no están, el test falla en vez de saltarse. Un test que se salta cuando falta su fixture desaparece el día que alguien rompe el sync, y la suite se queda verde sin comprobar nada.

tests/parity.test.ts
describe('parity with Python', () => {
it('vocab.json matches buildVocab() one to one', () => {
const vocab = readJson<VocabFile>('vocab.json');
expect(vocab.tokens).toEqual(buildVocab());
expect(vocab.tokens.slice(0, vocab.specials.length)).toEqual(vocab.specials);
});
it('fixtures/games.json has 20 games', () => {
expect(readGames()).toHaveLength(20);
});
it('UciTokenizer.encodeGame reproduces uci_ids', () => {
const tokenizer = new UciTokenizer();
for (const [i, game] of readGames().entries()) {
expect(
tokenizer.encodeGame(game.uci, game.white_elo, game.black_elo, game.result),
`game ${i}`,
).toEqual(game.uci_ids);
}
});
it('SanCharTokenizer.encode reproduces san_ids', () => {
const tokenizer = new SanCharTokenizer();
for (const [i, game] of readGames().entries()) {
expect(tokenizer.encode(`${game.san} ${game.result}`), `game ${i}`).toEqual(game.san_ids);
}
});
// Python encodes the moves concatenated without spaces (`bpe_text` in
// artifacts/tokenizer/README.md), so a whole game is a single BPE "word".
it('loadBpe(bpe.json).encode reproduces bpe_ids', () => {
const bpe = loadBpe(readJson<BpeFile>('bpe.json'));
for (const [i, game] of readGames().entries()) {
expect(bpe.encode(game.uci.replaceAll(' ', '')), `game ${i}`).toEqual(game.bpe_ids);
}
});
});

rukh-web/tests/parity.test.tslíneas 51-87 · 9be992c

La primera comprobación es la más fuerte: los 2 030 tokens que exportó Python contra los que construye TypeScript, en orden. Las tres últimas comparan los ids de cada esquema partida a partida, con game ${i} en el mensaje para que el fallo diga cuál.

Este test no entrena, no descarga y no llama a Python: compara con un fichero que Python escribió y que está versionado. La paridad se comprueba contra un artefacto, no contra un proceso.

// FontaneríaCódigo completo: scripts/sync-tokenizer.mjs
scripts/sync-tokenizer.mjs
// Syncs the tokenizer artifacts (fixed UCI vocabulary, trained BPE model and the Python parity
// fixture) into src/lib/chess-lm/. By default they are copied from the ML repo checked out next
// to this one; `--from <baseUrl>` downloads them instead (e.g. from the Hugging Face Hub).
// Usage: pnpm sync:tokenizer [--from <baseUrl-or-dir>]
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const localSource = '../rukh/artifacts/tokenizer';
const HUB_SOURCE = 'https://huggingface.co/chorcat/rukh-tokenizer/resolve/main';
const files = ['vocab.json', 'bpe.json', 'fixtures/games.json'];
const VOCAB_SIZE = 2030;
const FIXTURE_GAMES = 20;
const FIXTURE_KEYS = [
'id',
'uci',
'san',
'white_elo',
'black_elo',
'result',
'uci_ids',
'san_ids',
'bpe_ids',
];
/**
* Rejects a file that parses as JSON but is not the artifact we expect, so a half-written or
* stale export fails here instead of silently landing in src/lib/chess-lm/.
*/
function validate(name, data) {
const fail = (why) => {
throw new Error(`${name}: ${why}`);
};
if (name === 'vocab.json') {
if (!Array.isArray(data?.tokens)) fail('no `tokens` array');
if (data.tokens.length !== VOCAB_SIZE) {
fail(`tokens.length is ${data.tokens.length}, expected ${VOCAB_SIZE}`);
}
if (!data.tokens.every((token) => typeof token === 'string')) fail('tokens are not strings');
}
if (name === 'bpe.json') {
const model = data?.model;
if (!model) fail('no `model` object');
if (!model.vocab || typeof model.vocab !== 'object') fail('no `model.vocab` object');
if (!Array.isArray(model.merges) || model.merges.length === 0) fail('no `model.merges` list');
}
if (name === 'fixtures/games.json') {
const games = Array.isArray(data) ? data : data?.games;
if (!Array.isArray(games)) fail('not an array of games');
if (games.length !== FIXTURE_GAMES) {
fail(`${games.length} games, expected ${FIXTURE_GAMES}`);
}
games.forEach((game, i) => {
const missing = FIXTURE_KEYS.filter((key) => game?.[key] === undefined);
if (missing.length > 0) fail(`game ${i} is missing ${missing.join(', ')}`);
});
}
}
const args = process.argv.slice(2);
const fromIndex = args.indexOf('--from');
let source = localSource;
if (fromIndex >= 0) {
source = args[fromIndex + 1] ?? HUB_SOURCE;
if (source.startsWith('--')) source = HUB_SOURCE;
}
const isUrl = /^https?:\/\//.test(source);
async function read(name) {
if (isUrl) {
const url = `${source.replace(/\/$/, '')}/${name}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
return response.text();
}
const path = resolve(root, source, name);
if (!existsSync(path)) throw new Error(`${path}: file not found`);
return readFileSync(path, 'utf8');
}
try {
for (const name of files) {
const text = await read(name);
validate(name, JSON.parse(text)); // fail early on a truncated download or a stale export
const target = resolve(root, 'src/lib/chess-lm', name);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, text.endsWith('\n') ? text : `${text}\n`);
console.log(`synced ${name} (${text.length} bytes)`);
}
console.log(`tokenizer synced from ${source}`);
} catch (error) {
console.error(`sync:tokenizer failed: ${error instanceof Error ? error.message : error}`);
console.error(
`Expected ${files.join(', ')} under ${source}. Run \`rukh data tokenize --export-fixture\` ` +
`in ../rukh first, or pass --from ${HUB_SOURCE}.`,
);
process.exit(1);
}

rukh-web/scripts/sync-tokenizer.mjslíneas 1-100 · 9be992c

La visualización

Lo de abajo corre en tu navegador, sin red y sin modelo: los tres tokenizadores de TypeScript que acabas de leer. chess.js convierte el PGN a UCI y a SAN, y cada esquema muestra sus tokens con su id; en la fila de BPE, los que abarcan más de una jugada van resaltados.

13 medias jugadas · bloque de 200 tokens

Vocabulario fijo UCI

18 tokens

una jugada, un token; control y Elo delante, resultado detrás

  • <bos>1
  • <w1800>20
  • <b1900>48
  • e2e4997
  • e7e51164
  • g1f31455
  • b8c6460
  • f1c41207
  • d7d6919
  • b1c3268
  • c8g4710
  • f3e51271
  • g4d11526
  • c4f7590
  • e8e71193
  • c3d5549
  • <1-0>5
  • <eos>2

Carácter (SAN)

73 tokens

un carácter, un token, sobre el SAN numerado más el resultado

  • <bos>1
  • 19
  • .7
  • e29
  • 412
  • ␣3
  • e29
  • 513
  • ␣3
  • 210
  • .7
  • N21
  • f30
  • 311
  • ␣3
  • N21
  • c27
  • 614
  • ␣3
  • 311
  • .7
  • B19
  • c27
  • 412
  • ␣3
  • d28
  • 614
  • ␣3
  • 412
  • .7
  • N21
  • c27
  • 311
  • ␣3
  • B19
  • g31
  • 412
  • ␣3
  • 513
  • .7
  • N21
  • x33
  • e29
  • 513
  • ␣3
  • B19
  • x33
  • d28
  • 19
  • ␣3
  • 614
  • .7
  • B19
  • x33
  • f30
  • 715
  • +5
  • ␣3
  • K20
  • e29
  • 715
  • ␣3
  • 715
  • .7
  • N21
  • d28
  • 513
  • #4
  • ␣3
  • 19
  • -6
  • 08
  • <eos>2

BPE

8 tokens

fusiones aprendidas sobre las jugadas sin espacios; resaltadas las que abarcan varias jugadas

  • e2e4e7e5g1f3b8c6f1c4656
  • d7d6104
  • b1c3c8g42255
  • f3e5117
  • g4d11704
  • c4f7839
  • e8e7290
  • c3d5157

controlElojugada / carácterfusión de varias jugadas· cada ficha muestra token · id

Cuatro PGN que conviene probar:

  1. El de ejemplo, el mate de Légal (siete jugadas). Cuenta los tokens: 13 medias jugadasPly (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. más cinco de control en UCI, y compáralo con los caracteres de SAN. Cambia el Elo de blancas a 2450 y mira qué token cambia y cuál no.
  2. El mate del pastor, 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7#. Fíjate en cómo el # y la x ocupan un carácter cada uno en SAN y ninguno en UCI: la captura y el jaque mate están implícitos en h5f7.
  3. Una partida larga. El botón “Partida larga” genera una partida legal de más de cien jugadas con chess.js; también puedes pegar cualquier PGN exportado desde la demo o desde Lichess. El aviso de “más de 200 tokens” aparece en los esquemas que truncarían, y con SAN por carácter aparece con casi cualquier partida real.
  4. Una siciliana Najdorf, 1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6. En la fila de BPE verás un solo token resaltado tragándose varias jugadas de golpe: el mergeWord de arriba, hecho visible.

Y un PGN inválido (borra una jugada a la mitad, o escribe Nf9) muestra el error en vez de tokens: es lo que hace san_to_uci en Python al devolver None.

// Ejercicio 01Rompe la paridad de dos formas distintas

En una copia de rukh-web, quita el padStart(4, '0') de eloBin (deja String(bin)) y ejecuta pnpm test. ¿Qué tests fallan? Después déjalo como estaba y cambia el orden de PROMOTION_PIECES a ['q', 'b', 'r', 'n']. ¿Qué falla ahora, y cuál de los dos fallos habrías detectado con una fixture de veinte partidas normales?

// SoluciónVer la solución

Sin el padStart, los tokens de Elo pasan a ser <w600> en vez de <w0600>: fallan vocab.json matches buildVocab() one to one —los 54 tokens de Elo, del id 8 al 61, no coinciden— y UciTokenizer.encodeGame reproduces uci_ids en las veinte partidas, porque todas llevan dos tokens de Elo. Es un fallo ruidoso.

Con el orden de las promociones cambiado falla el primer test con un diff en 88 de los 176 últimos tokens (en cada grupo de cuatro promociones, la torre y el alfil cambian de sitio). encodeGame falla en una sola partida, promotion-rook, la única que corona a torre (c2c1r). Las otras tres con promoción no lo notan: coronan a dama o a caballo (f2f1q, b7b8q, h2h1q, b7a8n), y la dama y el caballo no se han movido de su sitio. Si la fixture no tuviera esa partida, el segundo test pasaría y el único que lo detectaría sería el primero.

Una fixture solo cubre lo que contiene. Por eso el primer test compara las dos enumeraciones enteras. El test de Python exige que la fixture tenga enroques por los dos flancos y una promoción a caballo, y esa exigencia no habría cazado este cambio: lo caza una partida que no exige ningún test.

Qué has aprendido

Un modelo servido lejos de donde se entrenó acaba con dos tokenizadores. Para que esa duplicación no se convierta en una divergencia silenciosa has montado una enumeración que no depende de datos, un artefacto exportado desde el lado de referencia y un test en cada copia que lo reproduce token a token.

Cómo se mide: pnpm test en rukh-web pasa las cinco comprobaciones de paridad sobre las veinte partidas; buildVocab().length === 2030 y vocab.tokens coincide uno a uno con la enumeración.

Lo siguiente cierra el módulo: los labs, con las salidas reales de la ejecución de referencia.