/* CAIN Studio Suite, in-browser AI: semantic search, a semantic map and "similar products" for the suite listing, computed by a sentence-embedding model (Xenova/all-MiniLM-L6-v2, 384 dimensions) that runs entirely in the visitor's browser through Transformers.js (WebGPU when the browser has it, WebAssembly otherwise). No query and no product text is sent to any server for this; the model weights come from the Hugging Face Hub once and are then cached by the browser. Also: a session receipt chain. Every live call made from a suite page is recorded as {product, method, path, status, ms, sha256(response)} and linked to the previous record by SHA-256, so the visitor can re-check that the log of what they called and what came back has not been changed. The chain is computed in the browser and is NOT signed by CAIN-42; it is a tamper-evident log of this visit, not a CAIN evidence record. The model runs in a Web Worker (suite-ai-worker.js), so the page never freezes while it loads or computes. Loaded on demand by app.js with import("./suite-ai.js"). */ const MODEL = "Xenova/all-MiniLM-L6-v2"; let worker = null, loading = null, device = "wasm", seq = 0; const pending = new Map(); const vecs = new Map(); // product id -> Float32Array (unit length) let corpusKey = null; function text(p) { const ops = (p.operations || []).slice(0, 12).map(o => (o.summary || o.path)).join("; "); return `${p.title}. ${p.description || ""} Operations: ${ops}`.slice(0, 1200); } function spawn() { const w = new Worker(new URL("./suite-ai-worker.js", import.meta.url), { type: "module" }); w.onmessage = ({ data: m }) => { const p = pending.get(m.id); if (!p) return; if (m.progress) { p.onStatus && p.onStatus(m.progress); return; } pending.delete(m.id); m.error ? p.no(new Error(m.error)) : p.ok(m); }; w.onerror = (e) => { pending.forEach(p => p.no(new Error(e.message || "worker error"))); pending.clear(); }; return w; } function ask(w, msg, onStatus, ms) { return new Promise((ok, no) => { const id = ++seq; pending.set(id, { ok, no, onStatus }); w.postMessage({ id, ...msg }); if (ms) setTimeout(() => { if (pending.delete(id)) no(new Error(msg.op + " timed out")); }, ms); }); } /** Start the model in a worker: WebGPU when the browser offers an adapter and it answers in time, else WebAssembly. */ export async function load(onStatus) { if (!loading) loading = (async () => { onStatus && onStatus("Starting the in-browser model…"); const hasGPU = !!(navigator.gpu && await navigator.gpu.requestAdapter().catch(() => null)); if (hasGPU) { const w = spawn(); try { await ask(w, { op: "load", device: "webgpu" }, onStatus, 60000); worker = w; device = "webgpu"; return { device }; } catch (e) { w.terminate(); pending.clear(); } } const w = spawn(); await ask(w, { op: "load", device: "wasm" }, onStatus, 180000); worker = w; device = "wasm"; return { device }; })(); try { return await loading; } catch (e) { loading = null; throw e; } } async function embed(texts) { const m = await ask(worker, { op: "embed", texts }, null, 120000); const [n, d] = m.dims, rows = []; for (let i = 0; i < n; i++) rows.push(m.data.subarray(i * d, (i + 1) * d)); return rows; } function storeGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } function storeSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* quota or blocked: recompute next time */ } } /** Embed every product once per listing version. Cached in localStorage (rounded) keyed by the listing's timestamp. */ export async function index(products, generatedAt, onStatus) { await load(onStatus); const key = "cain-suite-emb:" + MODEL + ":" + generatedAt + ":" + products.length; if (corpusKey === key) return { device, count: vecs.size, cached: true }; vecs.clear(); const saved = storeGet(key); if (saved) { try { const obj = JSON.parse(saved); products.forEach(p => { if (obj[p.product]) vecs.set(p.product, Float32Array.from(obj[p.product])); }); } catch (e) { vecs.clear(); } } const todo = products.filter(p => !vecs.has(p.product)); for (let i = 0; i < todo.length; i += 16) { onStatus && onStatus(`Embedding products in your browser (${device}): ${Math.min(i + 16, todo.length)} / ${todo.length}`); const batch = todo.slice(i, i + 16), rows = await embed(batch.map(text)); batch.forEach((p, j) => vecs.set(p.product, rows[j])); } if (todo.length) { const obj = {}; vecs.forEach((v, k) => { obj[k] = Array.from(v, x => Math.round(x * 1e4) / 1e4); }); storeSet(key, JSON.stringify(obj)); } corpusKey = key; return { device, count: vecs.size, cached: !todo.length }; } function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; } /** Products ranked by cosine similarity to a natural-language query. */ export async function search(query, k) { const [q] = await embed([query]); return [...vecs].map(([id, v]) => ({ id, score: dot(q, v) })).sort((a, b) => b.score - a.score).slice(0, k || 24); } /** Products closest in meaning to one product. */ export function similar(id, k) { const v = vecs.get(id); if (!v) return []; return [...vecs].filter(([o]) => o !== id).map(([o, w]) => ({ id: o, score: dot(v, w) })) .sort((a, b) => b.score - a.score).slice(0, k || 6); } export function ready() { return vecs.size > 0; } /** 2-D projection of every product's embedding (first two principal components, power iteration). */ export function project() { const ids = [...vecs.keys()], X = ids.map(i => vecs.get(i)), n = X.length, d = n ? X[0].length : 0; const mean = new Float32Array(d); X.forEach(x => { for (let j = 0; j < d; j++) mean[j] += x[j] / n; }); const C = X.map(x => Float32Array.from(x, (v, j) => v - mean[j])); const comps = []; for (let c = 0; c < 2; c++) { let w = Float32Array.from({ length: d }, (_, j) => Math.sin(j * 12.9898 + c * 78.233)); for (let it = 0; it < 60; it++) { const s = C.map(x => dot(x, w)), nw = new Float32Array(d); C.forEach((x, i) => { for (let j = 0; j < d; j++) nw[j] += s[i] * x[j]; }); comps.forEach(u => { const p = dot(nw, u); for (let j = 0; j < d; j++) nw[j] -= p * u[j]; }); const norm = Math.hypot(...nw) || 1; w = nw.map(v => v / norm); } comps.push(w); } return ids.map((id, i) => ({ id, x: dot(C[i], comps[0]), y: dot(C[i], comps[1]) })); } // ---------- session receipt chain ---------- const RKEY = "cain-suite-receipts"; async function sha256(s) { const b = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, "0")).join(""); } function canon(o) { return JSON.stringify(o, Object.keys(o).sort()); } export function receipts() { try { return JSON.parse(sessionStorage.getItem(RKEY) || "[]"); } catch (e) { return []; } } export async function record(entry, responseText) { const chain = receipts(), prev = chain.length ? chain[chain.length - 1].hash : "0".repeat(64); const body = { n: chain.length + 1, at: new Date().toISOString(), product: entry.product, method: entry.method, path: entry.path, status: entry.status, ms: entry.ms, response_sha256: await sha256(responseText || ""), prev }; const rec = { ...body, hash: await sha256(canon(body)) }; chain.push(rec); try { sessionStorage.setItem(RKEY, JSON.stringify(chain)); } catch (e) { /* private mode: the chain lives for this page only */ } return rec; } /** Recompute every link; returns the index of the first broken record, or -1 when the chain holds. */ export async function verify(chain) { let prev = "0".repeat(64); for (let i = 0; i < chain.length; i++) { const { hash, ...body } = chain[i]; if (body.prev !== prev || await sha256(canon(body)) !== hash) return i; prev = hash; } return -1; }