// ghost-typer browser runtime (zero dependencies, no DOM; works on the main thread, // in a Web Worker, or in Bun/Node). // // web/model.bin holds the compiled transformer in compact form (see export_web.py): // the always-on unigram neuron plus, for every MLP neuron, its context tokens and the // (uint8-quantized) logit changes it writes. Evaluating only the neurons whose context // matches gives the same next-token distribution as the dense model, without the zeros. // The confidence policy mirrors suggest.py. // // const ac = await CompiledAutocomplete.load("/model.bin"); // ac.suggest("write a python fun").ghost // "ction to" import { decodePieces, preTokenize } from "./tokenizer.ts"; export type Policy = { tau: number; min_support: number; max_words: number }; export type Step = { text: string; prob: number; support: number }; export type Suggestion = { ghost: string; prob: number; steps: Step[] }; type IdArray = Uint16Array | Uint32Array; type Order = { k: number; n: number; ctx: IdArray; // n * k ids, rows sorted lexicographically offsets: Uint32Array; // n + 1 dids: IdArray; dvals: Float32Array; support: Float32Array | null; }; const MAGIC = "GHST"; const VERSION = 2; function halfToFloat(h: number): number { const s = h & 0x8000 ? -1 : 1; const e = (h >> 10) & 0x1f; const f = h & 0x3ff; if (e === 0) return s * 2 ** -14 * (f / 1024); if (e === 31) return f ? NaN : s * Infinity; return s * 2 ** (e - 15) * (1 + f / 1024); } export class CompiledAutocomplete { readonly vocab: string[]; readonly nPrev: number; readonly order: number; readonly eosId: number; readonly unkId: number; readonly policy: Policy; readonly bytes: number; readonly contexts: number; private text: string[]; private pieceIndex: Map; private base: Float32Array; private orders: Order[]; private oov: Map; private stopAfter: Set; private candCache = new Map(); private distCache = new Map(); /** Fetch (with optional progress callback) or wrap an already-loaded buffer. */ static async load(source: string | ArrayBuffer, onProgress?: (fraction: number) => void): Promise { if (source instanceof ArrayBuffer) return new CompiledAutocomplete(source); // Revalidate so a re-published model.bin at the same URL isn't served stale (304 if unchanged). const resp = await fetch(source, { cache: "no-cache" }); if (!resp.ok || !resp.body) throw new Error(`failed to fetch ${source}: ${resp.status}`); const total = Number(resp.headers.get("content-length")) || 0; const reader = resp.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; if (total) onProgress?.(received / total); } const bytes = new Uint8Array(received); let off = 0; for (const c of chunks) { bytes.set(c, off); off += c.length; } onProgress?.(1); return new CompiledAutocomplete(bytes.buffer); } constructor(buf: ArrayBuffer) { const view = new DataView(buf); const td = new TextDecoder(); if (td.decode(new Uint8Array(buf, 0, 4)) !== MAGIC) throw new Error("not a ghost-typer model.bin"); const version = view.getUint32(4, true); if (version !== VERSION) throw new Error(`model.bin version ${version}, runtime expects ${VERSION}`); const headerLen = view.getUint32(8, true); const header = JSON.parse(td.decode(new Uint8Array(buf, 12, headerLen))); const dataStart = 12 + headerLen + ((8 - ((12 + headerLen) % 8)) % 8); const sections = new Map(); for (const s of header.sections) sections.set(s.name, s); const raw = (name: string) => { const s = sections.get(name); return s ? new Uint8Array(buf, dataStart + s.offset, s.bytes) : null; }; const typed = (name: string, Ctor: { new (b: ArrayBuffer, o: number, n: number): T; BYTES_PER_ELEMENT: number }): T => { const s = sections.get(name)!; return new Ctor(buf, dataStart + s.offset, s.bytes / Ctor.BYTES_PER_ELEMENT); }; const Ids = header.id_bytes === 2 ? Uint16Array : Uint32Array; this.vocab = td.decode(raw("vocab")!).split("\n"); this.text = this.vocab.map((p) => decodePieces(p)); this.pieceIndex = new Map(this.vocab.map((p, i) => [p, i])); const oovKeys = raw("oov_keys")!.length ? td.decode(raw("oov_keys")!).split("\n") : []; const oovVals = raw("oov_vals")!; this.oov = new Map(oovKeys.map((p, i) => [p, oovVals[i] / 255])); this.nPrev = header.n_prev; this.order = this.nPrev + 1; this.eosId = header.eos_id; this.unkId = header.unk_id; this.policy = header.policy; this.stopAfter = new Set(header.stop_after); this.bytes = buf.byteLength; this.base = typed("base", Float32Array); this.orders = header.orders.map((o: { k: number; contexts: number; pairs: number; scale: number }) => { const counts = typed(`count${o.k}`, Uint16Array); const offsets = new Uint32Array(o.contexts + 1); for (let i = 0; i < o.contexts; i++) offsets[i + 1] = offsets[i] + counts[i]; const dvals = new Float32Array(o.pairs); if (header.deltas === "u8") { const q = raw(`dvals${o.k}`)!; for (let i = 0; i < o.pairs; i++) dvals[i] = q[i] * o.scale; } else if (header.deltas === "f32") { dvals.set(typed(`dvals${o.k}`, Float32Array)); } else { const q = typed(`dvals${o.k}`, Uint16Array); for (let i = 0; i < o.pairs; i++) dvals[i] = halfToFloat(q[i]); } return { k: o.k, n: o.contexts, ctx: typed(`ctx${o.k}`, Ids), offsets, dids: typed(`dids${o.k}`, Ids), dvals, support: sections.has(`support${o.k}`) ? typed(`support${o.k}`, Float32Array) : null, }; }); this.contexts = this.orders.reduce((n, o) => n + o.n, 0); } /** Last `order` token ids, oldest first; positions before 0 read BOS like the attention heads. */ private window(ids: number[]): number[] { const t = ids.length - 1; const w: number[] = []; for (let a = this.nPrev; a >= 0; a--) w.push(ids[Math.max(t - a, 0)]); return w; } /** Row index of `key` (length o.k) in the sorted contexts, or -1. */ private find(o: Order, key: number[]): number { let lo = 0; let hi = o.n - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; let cmp = 0; for (let j = 0; j < o.k && cmp === 0; j++) cmp = o.ctx[mid * o.k + j] - key[j]; if (cmp === 0) return mid; if (cmp < 0) lo = mid + 1; else hi = mid - 1; } return -1; } /** Next-token distribution and support of the longest matched context (Infinity if not exported). */ dist(ids: number[]): { probs: Float64Array; support: number } { const window = this.window(ids); const cacheKey = window.join(","); const hit = this.distCache.get(cacheKey); if (hit) return hit; const logits = Float64Array.from(this.base); let support = 0; for (const o of this.orders) { const i = this.find(o, window.slice(-o.k)); if (i < 0) continue; for (let j = o.offsets[i]; j < o.offsets[i + 1]; j++) logits[o.dids[j]] += o.dvals[j]; support = o.support ? o.support[i] : Infinity; } let max = -Infinity; for (const v of logits) if (v > max) max = v; let z = 0; const probs = new Float64Array(logits.length); for (let i = 0; i < logits.length; i++) z += probs[i] = Math.exp(logits[i] - max); for (let i = 0; i < probs.length; i++) probs[i] /= z; if (this.distCache.size > 2000) this.distCache.clear(); const out = { probs, support }; this.distCache.set(cacheKey, out); return out; } private candidates(partial: string): Int32Array { let c = this.candCache.get(partial); if (!c) { const out: number[] = []; for (let i = this.unkId + 1; i < this.vocab.length; i++) if (this.vocab[i].startsWith(partial)) out.push(i); c = Int32Array.from(out); if (this.candCache.size > 5000) this.candCache.clear(); this.candCache.set(partial, c); } return c; } /** Greedy suggestion chain with cumulative probabilities, before thresholding. */ steps(text: string, maxWords = this.policy.max_words): Step[] { const pcs = preTokenize(text); if (!pcs.length) return []; const partial = pcs[pcs.length - 1]; const cands = this.candidates(partial); if (!cands.length) return []; const ids = [this.eosId, ...pcs.slice(0, -1).map((p) => this.pieceIndex.get(p) ?? this.unkId)]; let { probs, support } = this.dist(ids); let best = cands[0]; let sum = 0; for (const c of cands) { sum += probs[c]; if (probs[c] > probs[best]) best = c; } let prob = (1 - (this.oov.get(partial) ?? 0)) * (probs[best] / sum); const out: Step[] = [{ text: this.text[best].slice(decodePieces(partial).length), prob, support }]; ids.push(best); while (!this.stopAfter.has(this.vocab[ids[ids.length - 1]]) && out.length < maxWords) { ({ probs, support } = this.dist(ids)); let nxt = 0; for (let i = 1; i < probs.length; i++) if (probs[i] > probs[nxt]) nxt = i; if (nxt === this.eosId) break; prob *= probs[nxt]; out.push({ text: this.text[nxt], prob, support }); ids.push(nxt); } return out; } static apply(steps: Step[], policy: Policy): { ghost: string; prob: number } { let ghost = ""; let prob = 0; for (const st of steps.slice(0, policy.max_words)) { if (st.prob < policy.tau || st.support < policy.min_support) break; ghost += st.text; prob = st.prob; } return { ghost, prob }; } /** Ghost text to show after `text`, or "" when the model isn't confident. */ suggest(text: string, policy: Policy = this.policy): Suggestion { const steps = this.steps(text, policy.max_words); return { ...CompiledAutocomplete.apply(steps, policy), steps }; } }