export type Model = { schemaVersion: 1; words: Record; histories: Record>; sources: Record; }; export type Suggestion = { word: string; score: number; order: number; count: number; sources: string[]; }; export function tokenize(text: string): string[] { return ( text .normalize("NFC") .toLowerCase() .match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu) ?? [] ); } export function train(corpus: unknown): Model { const rows = array(corpus, "corpus"), words: Record = Object.create(null), histories: Record> = Object.create(null), sources: Record = Object.create(null), ids = new Set(); for (const row of rows) { const id = nonempty(row.id, "source id"), text = nonempty(row.text, "source text"); if (ids.has(id)) throw Error("Duplicate source id"); ids.add(id); for (const sentence of text.split(/[.!?\n]+/)) { const tokens = tokenize(sentence); tokens.forEach((word, i) => { words[word] = (words[word] ?? 0) + 1; sources[word] = [...new Set([...(sources[word] ?? []), id])]; for (let n = 1; n <= Math.min(2, i); n++) { const key = JSON.stringify(tokens.slice(i - n, i)); histories[key] ??= Object.create(null); histories[key][word] = (histories[key][word] ?? 0) + 1; } }); } } return { schemaVersion: 1, words, histories, sources }; } export function suggest( model: Model, prefix: string, history: string[] = [], limit = 5, ): Suggestion[] { if (!Number.isInteger(limit) || limit < 0 || limit > 50) throw Error("limit must be 0..50"); const p = prefix.normalize("NFC").toLowerCase(), h = history.flatMap(tokenize).slice(-2); return Object.keys(model.words) .filter((w) => w.startsWith(p)) .map((word) => { let order = 0, count = model.words[word]; for (let n = Math.min(2, h.length); n >= 1; n--) { const c = model.histories[JSON.stringify(h.slice(-n))]?.[word]; if (c) { order = n; count = c; break; } } return { word, score: order * 1000000 + count, order, count, sources: [...(model.sources[word] ?? [])], }; }) .sort( (a, b) => b.order - a.order || b.count - a.count || b.score - a.score || a.word.localeCompare(b.word), ) .slice(0, limit); } export function completeText( model: Model, text: string, limit = 5, ): { prefix: string; history: string[]; suggestions: Suggestion[] } { const boundary = text.search(/[^.!?\n]*$/), tail = text.slice(boundary), tokens = tokenize(tail), partial = /[\p{L}\p{N}'’]$/u.test(tail), prefix = partial ? tokens.pop() ?? "" : ""; return { prefix, history: tokens.slice(-2), suggestions: suggest(model, prefix, tokens.slice(-2), limit), }; } export function acceptSuggestion( text: string, prefix: string, word: string, ): string { if ( !tokenize(word).length || tokenize(word).length !== 1 || tokenize(word)[0] !== word ) throw Error("Suggestion must be one normalized token"); if (prefix && !text.toLowerCase().endsWith(prefix.toLowerCase())) throw Error("Prefix does not match text"); return text.slice(0, text.length - prefix.length) + word + " "; } export function importModel(value: unknown): Model { const m = value as Model; if (!m || m.schemaVersion !== 1) throw Error("Unsupported model schema"); for (const field of ["words", "histories", "sources"]) if ( !(m as any)[field] || typeof (m as any)[field] !== "object" || Array.isArray((m as any)[field]) ) throw Error("Invalid model " + field); for (const [w, n] of Object.entries(m.words)) { if ( tokenize(w).length !== 1 || tokenize(w)[0] !== w || !Number.isSafeInteger(n) || n <= 0 ) throw Error("Invalid word count"); if ( !Array.isArray(m.sources[w]) || m.sources[w].some((s) => typeof s !== "string" || !s) ) throw Error("Missing sources"); } for (const [key, counts] of Object.entries(m.histories)) { let h; try { h = JSON.parse(key); } catch { throw Error("Invalid history key"); } if ( !Array.isArray(h) || h.length < 1 || h.length > 2 || h.some((w) => typeof w !== "string" || !Object.hasOwn(m.words, w)) ) throw Error("Invalid history"); if (!counts || typeof counts !== "object" || Array.isArray(counts)) throw Error("Invalid history counts"); for (const [w, n] of Object.entries(counts)) if ( !Object.hasOwn(m.words, w) || !Number.isSafeInteger(n) || n <= 0 || n > m.words[w] ) throw Error("Invalid conditional count"); } return structuredClone(m); } export function renderPad(model: Model, initial = ""): string { return page( "Vocabulary Autocomplete Pad", '

Suggestions use your corpus only. A two-word history outranks a one-word history; unigram counts break the remaining ties.

Suggestions and evidence

', `const model=${embedded(model)};const tokenize=${tokenize.toString()};const suggest=${suggest.toString()};const completeText=${completeText.toString()};const acceptSuggestion=${acceptSuggestion.toString()};const pad=document.querySelector('#pad');pad.value=${embedded(initial)};function update(){const result=completeText(model,pad.value),host=document.querySelector('#suggestions');host.replaceChildren();for(const item of result.suggestions){const b=document.createElement('button');b.textContent=item.word+' · history '+item.order+' · count '+item.count+' · '+item.sources.join(', ');b.onclick=()=>{pad.value=acceptSuggestion(pad.value,result.prefix,item.word);pad.focus();update()};host.append(b)}}pad.addEventListener('input',update);document.querySelector('#model').onclick=()=>download('vocabulary-model.json',model);document.querySelector('#text').onclick=()=>download('writing.txt',pad.value,'text/plain');update();`, ); } export function escapeHTML(value: unknown): string { return String(value).replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ]!, ); } export function embedded(value: unknown): string { return JSON.stringify(value) .replace(/${escapeHTML(title)}

AI Engineering from Scratch · local project

${escapeHTML(title)}

${content}
`; } export function array(value: unknown, label: string): any[] { if (!Array.isArray(value)) throw new Error(label + " must be an array"); return value; } export function nonempty(value: unknown, label: string): string { if (typeof value !== "string" || !value.trim()) throw new Error(label + " must be a nonempty string"); return value; }