// TinyGPT in the browser (M6): the tokenizer, the model's forward pass, the samplers and the // parser, rewritten in plain JavaScript from the Python code in src/tinygpt. No libraries, no // server: a page downloads the two bundles (scripts/export.py) and runs everything on the // visitor's own processor. tests/test_web.py checks every piece against the Python version. // // normalize src/tinygpt/text.py NFC, control characters, spaces // Tokenizer src/tinygpt/bpe.py byte-level BPE with the Devanagari-aware regex // readSafetensors src/tinygpt/bundle.py the weights file: a JSON header + raw float32 // GPT src/tinygpt/models/gpt.py Llama recipe: RoPE, RMSNorm, SwiGLU, KV-cache // filterLogits src/tinygpt/sampling.py repetition penalty, temperature, top-k/p, min-p // stream src/tinygpt/serve/engine.py TextGenerator.stream // Parser, parseTree src/tinygpt/top.py, serve/engine.py // ------------------------------------------------------------------ text (text.py) // Python's str.isspace(): what str.strip() removes const PY_SPACE = "\\t\\n\\v\\f\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000"; const STRIP = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, "gu"); export const pyStrip = (s) => s.replace(STRIP, ""); /** NFC, \r\n -> \n, control/format characters removed (except \n, ZWNJ, ZWJ), runs of spaces -> one, * every line stripped, at most one blank line in a row. Same as text.normalize. */ export function normalize(text) { text = text.normalize("NFC").replace(/\r\n?/g, "\n"); text = text.replace(/\p{C}/gu, (ch) => (ch === "\n" || ch === "\u200c" || ch === "\u200d" ? ch : "")); text = text.replace(/[ \t\u00a0\u2000-\u200a\u202f\u205f\u3000]+/g, " "); text = text.split("\n").map(pyStrip).join("\n"); return pyStrip(text.replace(/\n{3,}/g, "\n\n")); } // ------------------------------------------------------------------ tokenizer (bpe.py) // The "indic" pre-tokenisation pattern, written for JavaScript's regex engine: // * (?i:[sdmt]|ll|ve|re) -> spelled-out case variants (ſ folds to s in Python's matcher); // * possessive ?+ and ++ -> plain ? and +: here nothing after them could match what they // give back, so backtracking never changes a match; // * \s -> the exact 25 code points Python's `regex` module calls whitespace (JavaScript's \s // adds U+FEFF and lacks U+0085). const RX_SPACE = "\\t\\n\\v\\f\\r \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000"; const W = "\\p{L}\\p{M}\\u200c\\u200d"; const INDIC = new RegExp( "'(?:[sdmtSDMTſ]|[lL][lL]|[vV][eE]|[rR][eE])" + `|[^\\r\\n${W}\\p{N}]?[${W}]+` + "|\\p{N}{1,3}" + `| ?[^${RX_SPACE}${W}\\p{N}]+[\\r\\n]*` + `|[${RX_SPACE}]*[\\r\\n]` + `|[${RX_SPACE}]+(?![^${RX_SPACE}])` + `|[${RX_SPACE}]+`, "gu"); const utf8 = new TextEncoder(); export class Tokenizer { /** `data`: the parsed tokenizer.json (type "tinygpt-bpe"). */ constructor(data) { if (data.type !== "tinygpt-bpe" || data.pattern_name !== "indic") { throw new Error("expected a tinygpt-bpe tokenizer with the indic pattern"); } this.merges = data.merges; this.ranks = new Map(data.merges.map(([a, b], r) => [a * 65536 + b, r])); // pair -> rank this.vocab = []; // id -> bytes for (let i = 0; i < 256; i++) this.vocab.push(Uint8Array.of(i)); for (const [a, b] of data.merges) { const x = this.vocab[a], y = this.vocab[b], m = new Uint8Array(x.length + y.length); m.set(x); m.set(y, x.length); this.vocab.push(m); } this.special = new Map(data.special_tokens.map((s, i) => [s, 256 + data.merges.length + i])); for (const [s] of this.special) this.vocab.push(utf8.encode(s)); this.eotId = this.special.get("<|endoftext|>"); this.vocabSize = this.vocab.length; this.cache = new Map(); } encodeChunk(chunk) { const hit = this.cache.get(chunk); if (hit) return hit; let ids = Array.from(utf8.encode(chunk)); while (ids.length >= 2) { // merge the pair learned earliest, everywhere, until none is left let best = -1, bestRank = Infinity; for (let i = 0; i < ids.length - 1; i++) { const r = this.ranks.get(ids[i] * 65536 + ids[i + 1]); if (r !== undefined && r < bestRank) { bestRank = r; best = i; } } if (best < 0) break; const a = ids[best], b = ids[best + 1], merged = []; for (let i = 0; i < ids.length;) { if (i < ids.length - 1 && ids[i] === a && ids[i + 1] === b) { merged.push(256 + bestRank); i += 2; } else { merged.push(ids[i]); i += 1; } } ids = merged; } if (this.cache.size > 100000) this.cache.clear(); this.cache.set(chunk, ids); return ids; } /** Text -> token ids; special-token strings are treated as plain text (encode_ordinary). */ encode(text) { const out = []; for (const m of text.matchAll(INDIC)) for (const id of this.encodeChunk(m[0])) out.push(id); return out; } decodeBytes(ids) { let n = 0; for (const i of ids) n += this.vocab[i].length; const out = new Uint8Array(n); let o = 0; for (const i of ids) { out.set(this.vocab[i], o); o += this.vocab[i].length; } return out; } decode(ids) { return new TextDecoder().decode(this.decodeBytes(ids)); } } // ------------------------------------------------------------------ weights (bundle.py) /** A .safetensors file: 8 bytes of header length, a JSON header, then the raw tensors. */ export function readSafetensors(buffer) { const n = Number(new DataView(buffer).getBigUint64(0, true)); const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 8, n))); const start = 8 + n, tensors = {}; for (const [name, info] of Object.entries(header)) { if (name === "__metadata__") continue; if (info.dtype !== "F32") throw new Error(`${name}: ${info.dtype}, expected F32`); const [a, b] = info.data_offsets, at = start + a, count = (b - a) / 4; tensors[name] = (at % 4 === 0) ? new Float32Array(buffer, at, count) : new Float32Array(buffer.slice(at, start + b)); // unaligned: copy } // a tied matrix is stored once; the metadata names the copy that was dropped for (const [dropped, kept] of Object.entries(header.__metadata__ || {})) { if (tensors[kept] && !tensors[dropped]) tensors[dropped] = tensors[kept]; } return tensors; } // ------------------------------------------------------------------ the model (models/gpt.py) /** out[r] = W[r, :] · x for a row-major (rows × cols) matrix: one output per row, as nn.Linear. */ function matvec(W, x, out, rows, cols) { for (let r = 0, o = 0; r < rows; r++, o += cols) { let s0 = 0, s1 = 0, s2 = 0, s3 = 0, c = 0; for (; c + 3 < cols; c += 4) { s0 += W[o + c] * x[c]; s1 += W[o + c + 1] * x[c + 1]; s2 += W[o + c + 2] * x[c + 2]; s3 += W[o + c + 3] * x[c + 3]; } for (; c < cols; c++) s0 += W[o + c] * x[c]; out[r] = (s0 + s1) + (s2 + s3); } } function rmsnorm(x, weight, out, eps = 1e-6) { let ss = 0; for (let i = 0; i < x.length; i++) ss += x[i] * x[i]; const scale = 1 / Math.sqrt(ss / x.length + eps); for (let i = 0; i < x.length; i++) out[i] = x[i] * scale * weight[i]; } export class GPT { /** `config`: model_config from the bundle's config.json; `tensors`: readSafetensors(...). */ constructor(config, tensors) { if (config.pos !== "rope" || config.norm !== "rmsnorm" || config.mlp !== "swiglu" || !config.tie_weights) { throw new Error("the browser version runs the Llama recipe (RoPE, RMSNorm, SwiGLU, tied weights)"); } const d = this.d = config.d_model; this.H = config.n_heads; this.hd = d / this.H; this.L = config.n_layers; this.V = config.vocab_size; this.T = config.block_size; const t = (name) => { if (!tensors[name]) throw new Error(`missing ${name}`); return tensors[name]; }; this.emb = t("head.weight"); // (V, d): embedding = output layer this.blocks = []; for (let i = 0; i < this.L; i++) { const p = `blocks.${i}.`; this.blocks.push({ ln1: t(p + "ln1.weight"), qkv: t(p + "attn.qkv.weight"), proj: t(p + "attn.proj.weight"), ln2: t(p + "ln2.weight"), gate: t(p + "mlp.gate.weight"), up: t(p + "mlp.up.weight"), down: t(p + "mlp.proj.weight") }); } this.hidden = this.blocks[0].gate.length / d; this.lnf = t("ln_f.weight"); // RoPE angles in float32, as rope_tables computes them: position p turns pair i by p * base^(-2i/hd) const half = this.hd / 2, f = Math.fround; this.cos = new Float32Array(this.T * half); this.sin = new Float32Array(this.T * half); for (let i = 0; i < half; i++) { const inv = f(1 / f(Math.pow(config.rope_base, f(f(2 * i) / this.hd)))); for (let p = 0; p < this.T; p++) { const a = f(p * inv); this.cos[p * half + i] = Math.cos(a); this.sin[p * half + i] = Math.sin(a); } } this.x = new Float32Array(d); this.h = new Float32Array(d); this.qkvOut = new Float32Array(3 * d); this.att = new Float32Array(d); this.tmp = new Float32Array(d); this.g = new Float32Array(this.hidden); this.u = new Float32Array(this.hidden); this.scores = new Float64Array(this.T); this.logits = new Float32Array(this.V); } newCache() { const size = this.T * this.d; return { pos: 0, k: this.blocks.map(() => new Float32Array(size)), v: this.blocks.map(() => new Float32Array(size)) }; } rope(vec, offset, pos) { // rotate pairs (0,1), (2,3), ... of one head in place const half = this.hd / 2, base = pos * half; for (let i = 0; i < half; i++) { const c = this.cos[base + i], s = this.sin[base + i]; const a = vec[offset + 2 * i], b = vec[offset + 2 * i + 1]; vec[offset + 2 * i] = a * c - b * s; vec[offset + 2 * i + 1] = a * s + b * c; } } /** Feed one token at the next position; returns the logits for the token after it (or null). */ step(token, cache, wantLogits = true) { const { d, H, hd, x, h, qkvOut, att, tmp, g, u, scores } = this; const pos = cache.pos; if (pos >= this.T) throw new Error("the context window is full"); x.set(this.emb.subarray(token * d, token * d + d)); const scale = 1 / Math.sqrt(hd); for (let l = 0; l < this.L; l++) { const B = this.blocks[l], K = cache.k[l], Vc = cache.v[l]; rmsnorm(x, B.ln1, h); matvec(B.qkv, h, qkvOut, 3 * d, d); // queries, keys, values at once for (let hh = 0; hh < H; hh++) { this.rope(qkvOut, hh * hd, pos); // q this.rope(qkvOut, d + hh * hd, pos); // k } K.set(qkvOut.subarray(d, 2 * d), pos * d); // the cache keeps this token's k, v Vc.set(qkvOut.subarray(2 * d, 3 * d), pos * d); for (let hh = 0; hh < H; hh++) { // each head attends to positions 0..pos const qo = hh * hd; let max = -Infinity; for (let j = 0; j <= pos; j++) { let s = 0; const ko = j * d + qo; for (let c = 0; c < hd; c++) s += qkvOut[qo + c] * K[ko + c]; scores[j] = s * scale; if (scores[j] > max) max = scores[j]; } let sum = 0; for (let j = 0; j <= pos; j++) { scores[j] = Math.exp(scores[j] - max); sum += scores[j]; } for (let c = 0; c < hd; c++) { let s = 0; for (let j = 0; j <= pos; j++) s += scores[j] * Vc[j * d + qo + c]; att[qo + c] = s / sum; } } matvec(B.proj, att, tmp, d, d); for (let i = 0; i < d; i++) x[i] += tmp[i]; rmsnorm(x, B.ln2, h); // SwiGLU: silu(gate(h)) * up(h), then down matvec(B.gate, h, g, this.hidden, d); matvec(B.up, h, u, this.hidden, d); for (let i = 0; i < this.hidden; i++) g[i] = (g[i] / (1 + Math.exp(-g[i]))) * u[i]; matvec(B.down, g, tmp, d, this.hidden); for (let i = 0; i < d; i++) x[i] += tmp[i]; } cache.pos = pos + 1; if (!wantLogits) return null; rmsnorm(x, this.lnf, h); matvec(this.emb, h, this.logits, this.V, d); return this.logits; } } // ------------------------------------------------------------------ sampling (sampling.py) /** A seeded generator of numbers in [0, 1) (mulberry32): the same seed, the same text. */ export function rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function softmax(logits) { let max = -Infinity; for (const v of logits) if (v > max) max = v; const p = new Float64Array(logits.length); let sum = 0; for (let i = 0; i < logits.length; i++) { p[i] = Math.exp(logits[i] - max); sum += p[i]; } for (let i = 0; i < p.length; i++) p[i] /= sum; return p; } /** Every strategy except the final draw, in sampling.py's order: repetition penalty, temperature, * top-k, top-p, min-p. Removed tokens get -Infinity. `history`: the token ids so far. */ export function filterLogits(logits, { temperature = 1, topK = null, topP = null, minP = null, repetitionPenalty = 1, history = [] } = {}) { const out = Float32Array.from(logits); if (repetitionPenalty !== 1 && history.length) { for (const id of new Set(history)) { const v = logits[id]; out[id] = v > 0 ? v / repetitionPenalty : v * repetitionPenalty; } } for (let i = 0; i < out.length; i++) out[i] /= temperature; if (topK && topK < out.length) { const kth = Float32Array.from(out).sort()[out.length - topK]; for (let i = 0; i < out.length; i++) if (out[i] < kth) out[i] = -Infinity; } if (topP !== null && topP < 1) { const p = softmax(out), order = Array.from(p.keys()).sort((a, b) => p[b] - p[a]); let before = 0; for (const i of order) { if (before > topP) out[i] = -Infinity; before += p[i]; } } if (minP) { const p = softmax(out); let max = 0; for (const v of p) if (v > max) max = v; for (let i = 0; i < out.length; i++) if (p[i] < minP * max) out[i] = -Infinity; } return out; } function argmax(a) { let best = 0; for (let i = 1; i < a.length; i++) if (a[i] > a[best]) best = i; return best; } /** The next token: greedy at temperature 0 (the filters can't change the argmax), else a draw. */ export function sampleNext(logits, params, random) { if (params.temperature === 0) { return argmax(filterLogits(logits, { repetitionPenalty: params.repetitionPenalty, history: params.history })); } const p = softmax(filterLogits(logits, params)); let u = random(), last = 0; for (let i = 0; i < p.length; i++) { if (p[i] > 0) { last = i; u -= p[i]; if (u < 0) return i; } } return last; // rounding left a sliver: the last possible token } /** Continue `ids`, yielding each new token (models/gpt.py iter_tokens, with its window restart: * when the window is full, the cache restarts from the last half of the text). */ export function* iterTokens(model, ids, maxNewTokens, params, random) { const history = ids.slice(); let cache = model.newCache(), feed = ids.slice(-model.T); for (let n = 0; n < maxNewTokens; n++) { let logits = null; for (let i = 0; i < feed.length; i++) logits = model.step(feed[i], cache, i === feed.length - 1); const next = sampleNext(logits, { ...params, history }, random); history.push(next); yield next; if (params.eotId !== undefined && next === params.eotId) return; if (cache.pos < model.T) feed = [next]; else { cache = model.newCache(); feed = history.slice(-(model.T >> 1)); } } } /** Generation as the server streams it (engine.TextGenerator.stream): {text} pieces of whole * characters, then {done: {...}}. `cancelled()` is asked after every token. */ export function* stream(model, tok, prompt, params, cancelled = () => false, now = () => performance.now()) { const t0 = now(); const seed = params.seed ?? Math.floor(Math.random() * 2 ** 31); const random = rng(seed); const ids = [tok.eotId, ...tok.encode(prompt)]; const decoder = new TextDecoder("utf-8"); let n = 0, first = null, reason = "length"; // as in the API: a setting left out takes its default, null turns it off (so topK: null is no top-k) const settings = { temperature: params.temperature ?? 0.8, topK: "topK" in params ? params.topK : 50, topP: params.topP ?? null, minP: params.minP ?? null, repetitionPenalty: params.repetitionPenalty ?? 1, eotId: tok.eotId }; for (const t of iterTokens(model, ids, params.maxNewTokens ?? 120, settings, random)) { if (first === null) first = (now() - t0) / 1000; if (t === tok.eotId) { reason = "stop"; break; } n += 1; const text = decoder.decode(tok.vocab[t], { stream: true }); // holds back half a character if (text) yield { text }; if (cancelled()) { reason = "cancelled"; break; } } const tail = decoder.decode(); if (tail) yield { text: tail }; yield { done: { tokens: n, finishReason: reason, seed, seconds: (now() - t0) / 1000, firstTokenSeconds: first } }; } // ------------------------------------------------------------------ parse trees (top.py) const splitWords = (s) => s.split(new RegExp(`[${PY_SPACE}]+`, "u")).filter(Boolean); /** '[IN:X word [SL:Y word ] ]' -> {label, children}; throws for anything that is not a valid tree. */ export function parseTree(s) { const stack = []; let root = null; for (const tok of splitWords(s)) { if (tok.startsWith("[") && tok.length > 1) { const node = { label: tok.slice(1), children: [] }, kind = node.label.slice(0, 3); if ((kind !== "IN:" && kind !== "SL:") || node.label.length === 3) throw new Error(`bad label ${tok}`); if (stack.length) { if (stack[stack.length - 1].label.slice(0, 3) === kind) throw new Error(`${node.label} directly inside its kind`); stack[stack.length - 1].children.push(node); } else if (root === null) { if (kind !== "IN:") throw new Error("the root must be an intent"); root = node; } else throw new Error("more than one root"); stack.push(node); } else if (tok === "]") { if (!stack.length) throw new Error("unbalanced ]"); stack.pop(); } else { if (!stack.length) throw new Error(`word ${tok} outside the tree`); stack[stack.length - 1].children.push(tok); } } if (root === null || stack.length) throw new Error("empty or unclosed tree"); return root; } export function treeToString(node) { return ["[" + node.label, ...node.children.map((c) => (typeof c === "string" ? c : treeToString(c))), "]"].join(" "); } export function decouple(node) { if (node.label.startsWith("IN:")) { return { label: node.label, children: node.children.filter((c) => typeof c !== "string").map(decouple) }; } return { label: node.label, children: node.children.map((c) => (typeof c === "string" ? c : decouple(c))) }; } export function scoredForm(pred) { try { return treeToString(decouple(parseTree(pred))); } catch { return pred; } } function words(node) { return node.children.flatMap((c) => (typeof c === "string" ? [c] : words(c))); } export function slotItems(node) { const out = []; for (const c of node.children) { if (typeof c === "string") continue; if (c.label.startsWith("SL:")) out.push([c.label, words(c).join(" ")]); out.push(...slotItems(c)); } return out; } export function topIntent(parse) { const first = splitWords(parse)[0]; return first && first.startsWith("[IN:") ? first.slice(1) : null; } /** Every word of an annotated tree with its innermost slot, neighbours with the same slot merged. */ export function wordSpans(tree) { const flat = []; const walk = (node, slot) => { const here = node.label.startsWith("SL:") ? node.label.slice(3) : slot; for (const c of node.children) { if (typeof c === "string") flat.push([c, here]); else walk(c, here); } }; walk(tree, null); const spans = []; for (const [word, label] of flat) { if (spans.length && spans[spans.length - 1].label === label) spans[spans.length - 1].text += " " + word; else spans.push({ text: word, label }); } return spans; } /** Labels back to upper case: "[in:create_alarm" -> "[IN:CREATE_ALARM" (top.from_model). */ export function fromModel(text, labels = "lower") { if (labels === "upper") return text; return splitWords(text).map((t) => (t.startsWith("[") ? t.toUpperCase() : t)).join(" "); } /** M5's parser (engine.Parser), one request at a time: greedy decoding, the request as the prompt. */ export class Parser { constructor(model, tok, task, { maxRequestTokens = 64, maxNewTokens = 192 } = {}) { if (task.name !== "hinglish-top") throw new Error("not a parser bundle"); Object.assign(this, { model, tok, labels: task.labels, target: task.target, maxRequestTokens, maxNewTokens }); } prompt(text) { text = normalize(text); if (!text) throw new Error("the request is empty"); const ids = [this.tok.eotId, ...this.tok.encode(text), ...this.tok.encode(" =>")]; if (ids.length > this.maxRequestTokens) { throw new Error(`the request is ${ids.length} tokens long; the limit is ${this.maxRequestTokens}`); } return ids; } parse(text) { const request = normalize(text); let ids; try { ids = this.prompt(text); } catch (e) { return { text: request, written: "", parse: "", valid: false, intent: null, slots: [], spans: [], tokens: 0, error: e.message }; } const n = Math.max(1, Math.min(this.maxNewTokens, this.model.T - ids.length + 1)); const out = []; for (const t of iterTokens(this.model, ids, n, { temperature: 0, repetitionPenalty: 1, eotId: this.tok.eotId })) { if (t === this.tok.eotId) break; out.push(t); } const written = fromModel(pyStrip(this.tok.decode(out)), this.labels); const annotated = this.target === "annotated"; const parse = annotated ? scoredForm(written) : written; const result = { text: request, written, parse, valid: false, intent: null, slots: [], spans: [], tokens: out.length, error: null }; let tree; try { tree = parseTree(parse); } catch { return result; } result.valid = true; result.intent = topIntent(parse).slice(3); result.slots = slotItems(tree).map(([label, w]) => ({ label: label.slice(3), text: w })); if (annotated) result.spans = wordSpans(parseTree(written)); return result; } }