// JS runtime for the exported moodring model. Mirrors the sklearn pipeline: // tokenize (runs of >=2 word chars, lowercased), unigrams+bigrams, sublinear // tf, idf, l2 norm, then per-label logistic scores. Works in browser and Node. const TOKEN_RE = /[\p{L}\p{N}_]{2,}/gu; function parseModel(buffer) { const bytes = new Uint8Array(buffer); const magic = new TextDecoder().decode(bytes.subarray(0, 9)); if (magic !== "MOODRING1") throw new Error("bad model file"); const jsonLen = new DataView(buffer).getUint32(9, true); const meta = JSON.parse(new TextDecoder().decode(bytes.subarray(13, 13 + jsonLen))); const n = meta.n_features; let off = 13 + jsonLen; const idf = new Float32Array(buffer.slice(off, off + 4 * n)); off += 4 * n; const coef = new Float32Array(buffer.slice(off, off + 4 * n * meta.emotions.length)); const vocab = new Map(meta.terms.map((t, i) => [t, i])); return { meta, idf, coef, vocab, n }; } function tokenize(text) { return (text.toLowerCase().match(TOKEN_RE) || []); } function vectorize(model, text) { const tokens = tokenize(text); const grams = tokens.slice(); for (let i = 0; i + 1 < tokens.length; i++) grams.push(tokens[i] + " " + tokens[i + 1]); const counts = new Map(); for (const g of grams) { const idx = model.vocab.get(g); if (idx !== undefined) counts.set(idx, (counts.get(idx) || 0) + 1); } let normSq = 0; const entries = []; for (const [idx, tf] of counts) { const w = (1 + Math.log(tf)) * model.idf[idx]; entries.push([idx, w]); normSq += w * w; } const norm = Math.sqrt(normSq) || 1; return entries.map(([idx, w]) => [idx, w / norm]); } function scores(model, text) { const x = vectorize(model, text); const { emotions, intercept } = model.meta; const out = {}; for (let j = 0; j < emotions.length; j++) { let margin = intercept[j]; const row = j * model.n; for (const [idx, w] of x) margin += model.coef[row + idx] * w; out[emotions[j]] = 1 / (1 + Math.exp(-margin)); } return out; } function predict(model, text) { const s = scores(model, text); const { emotions, thresholds } = model.meta; let labels = emotions.filter((e, j) => s[e] >= thresholds[j]); const fallback = labels.length === 0; if (fallback) { labels = [emotions.reduce((a, b) => (s[a] >= s[b] ? a : b))]; } const rounded = {}; for (const e of emotions) rounded[e] = Math.round(s[e] * 10000) / 10000; const thr = {}; emotions.forEach((e, j) => (thr[e] = Math.round(thresholds[j] * 100) / 100)); return { labels, fallback, scores: rounded, thresholds: thr }; } // ---- static-Space shim: answer the app's own API routes client-side ---- (function () { const realFetch = window.fetch.bind(window); let modelPromise = null, testPromise = null; const getModel = () => (modelPromise ||= realFetch("./model.bin") .then((r) => { if (!r.ok) throw new Error("model download failed"); return r.arrayBuffer(); }) .then(parseModel)); const getTest = () => (testPromise ||= realFetch("https://raw.githubusercontent.com/google-research/google-research/master/goemotions/data/test.tsv") .then((r) => { if (!r.ok) throw new Error("sample fetch failed"); return r.text(); }) .then((t) => t.trim().split("\n").map((line) => line.split("\t")))); window.fetch = async function (url, opts) { const u = typeof url === "string" ? url : url.url; if (u.startsWith("/predict")) { const model = await getModel(); const { text } = JSON.parse(opts.body); return Response.json(predict(model, text)); } if (u.startsWith("/sample")) { const [rows, model] = await Promise.all([getTest(), getModel()]); const raw = parseInt(new URLSearchParams(u.split("?")[1]).get("index") || "0", 10); const index = Math.max(0, Math.min(rows.length - 1, raw)); const [text, labels] = rows[index]; const gold = labels.split(",").map((s) => model.meta.emotions[parseInt(s, 10)]); return Response.json({ index, total: rows.length, text, gold }); } return realFetch(url, opts); }; })();