Spaces:
Running
Running
Download app.js from moravianai/demo: direct link, hf CLI and curl.
- Browser
- Download file 45.5 kB
-
https://huggingface.co/spaces/moravianai/demo/resolve/main/app.js
- Command line
-
hf download hf://spaces/moravianai/demo/app.js
-
curl -L -o app.js https://huggingface.co/spaces/moravianai/demo/resolve/main/app.js
45.5 kB
| // Thinletter demo: the query encoder of harrier-0.6b runs in the browser and searches the UNCHANGED document index of the | |
| // original model (fp16 on disk, fp32 in memory). Two kinds of client per corpus (M5): | |
| // runtime "wllama": the scalar Q2_K GPTQ GGUF, llama.cpp -> WebAssembly via wllama 3.6.1 (WebGPU through llama.cpp's own | |
| // backend where the browser offers shader-f16, CPU WebAssembly otherwise); | |
| // runtime "vqweb": the vector-quantised .vqw container through the @thinletterio/vqweb package (packages/vqweb/dist/vqweb.js, served at ./vqweb/: | |
| // WGSL kernels, no WebAssembly; needs navigator.gpu with shader-f16). | |
| // | |
| // Protocol (identical to client/browser/bench.js, client/vqweb/bench.js and the native reference scripts/quant_eval_queries.py): | |
| // wllama: text = meta.prompt + query (generic E5 web-search instruction; the tokenizer adds EOS, no BOS: GGUF metadata); | |
| // vqweb: the RAW query goes to VqwTokenizer.encode(), which applies the container header's prompt / add_eos and the | |
| // trimmed-vocabulary byte fallback (the header prompt is checked against meta.prompt at load); | |
| // pooling "last", L2-normalised embedding, cosine = dot product against unit-norm document rows, | |
| // nDCG@10 with linear gains, discount 1/log2(rank+1), IDCG from the sorted grades (eq.graph_metrics.ndcg_at_k). | |
| // | |
| // Corpora: data/index.json lists them; ds=<id> (URL parameter or the dropdown) selects data/<id>/meta.json, whose | |
| // index is fp16 or row-scaled int8 shards (x = q * scale[row]) and whose documents come in JSON shards. | |
| // Clients: meta.clients = [{id, label, runtime, model_url, size_bytes, bpw, ndcg_test, ndcg_browser, note, ...}] (written by | |
| // demo/vq_clients.py; zero or more vqweb entries per corpus); client=<id> (URL parameter or the dropdown) selects one. | |
| // Without `clients` (older export) the scalar client is synthesised from the top-level model_file / model_url fields. | |
| // URL parameters: ds=<corpus id> (default index.json "default"), client=<client id> (default meta.default_client or the first | |
| // entry), model=<url .gguf | .vqw | url .chunks.json> (overrides the selected client's model_url), threads=<n>, autoload=1, | |
| // autoquery=<text>, verify_n=<n>, autoverify=1, smoke=1 (headless test: autoload + autoquery + verify_n queries, | |
| // report to console as [smoke] lines and POSTed to ./smoke; nothing else changes), | |
| // backend=auto|webgpu|wasm (wllama only; default auto: llama.cpp's WebGPU backend inside the same wasm when navigator.gpu | |
| // gives an adapter with shader-f16, else CPU WebAssembly; n_gpu_layers 99999 vs 0, see client/webgpu/README.md). | |
| // Shared, DOM-free pieces (fetch, maths, OPFS assembly, WebGPU probe, wllama load) live in loader.js: also used by legal.js. | |
| import { Wllama, WLLAMA_VERSION, fmtMiB, fmtMs, fmtS, sleep, percentile, l2n, fetchJSON, fetchBuf, measureMemory, opfsRoot, | |
| clearModelCache, probeWebGPU, adapterLabel, parseGpuLog, newWllama, loadModelSource, assembleFromManifest, LOAD_OPTS } from './loader.js'; | |
| const params = new URLSearchParams(location.search); | |
| const opt = { | |
| ds: params.get('ds'), | |
| client: params.get('client'), | |
| model: params.get('model'), | |
| threads: params.has('threads') ? Math.max(1, parseInt(params.get('threads'), 10) || 1) : null, | |
| autoload: params.get('autoload') === '1' || params.get('smoke') === '1', | |
| autoquery: params.get('autoquery'), | |
| autoverify: params.get('autoverify') === '1' || params.get('smoke') === '1', | |
| verifyN: params.has('verify_n') ? parseInt(params.get('verify_n'), 10) : (params.get('smoke') === '1' ? 20 : 300), | |
| smoke: params.get('smoke') === '1', | |
| backend: ['webgpu', 'wasm', 'auto'].includes(params.get('backend')) ? params.get('backend') : 'auto', | |
| }; | |
| // the VQ runtime as the @thinletterio/vqweb package bundle (packages/vqweb/dist/vqweb.js, built from client/vqweb/), served by serve.py's | |
| // /vqweb/ mount (or a copy at demo/vqweb/ on a static host); imported lazily, so the scalar client never depends on it | |
| const VQWEB_SDK = new URL('./vqweb/vqweb.js', location.href); | |
| // where data/ and model/ live: this origin by default; a mirror of the page (e.g. the Hugging Face Space) sets | |
| // window.DEMO_ASSET_BASE = 'https://thinletter.io/' before this script and loads the index and the model chunks from there (CORS + CORP headers on that host) | |
| const ASSET_BASE = new URL(window.DEMO_ASSET_BASE || './', location.href); | |
| const $ = (id) => document.getElementById(id); | |
| const smokeLog = (msg, data) => { | |
| if (!opt.smoke) return; | |
| console.log(`[smoke] ${msg}${data !== undefined ? ' ' + JSON.stringify(data) : ''}`); | |
| }; | |
| function status(msg, warn = false) { | |
| $('load-status').textContent = msg; | |
| $('load-status').className = warn ? 'warn-text' : 'muted'; | |
| console.log(msg); | |
| } | |
| const signed = (x) => x.toFixed(4).replace(/^(?!-)/, '+'); | |
| // ---- maths (same code as client/browser/bench.js) ------------------------------------------------------------- | |
| function dot(a, aOff, b, bOff, d) { | |
| let s = 0; | |
| for (let i = 0; i < d; i++) s += a[aOff + i] * b[bOff + i]; | |
| return s; | |
| } | |
| function topk(scores, k) { // indices sorted by descending score (ties: lower index first) | |
| const idx = new Int32Array(k).fill(-1); | |
| const val = new Float64Array(k).fill(-Infinity); | |
| for (let j = 0; j < scores.length; j++) { | |
| const s = scores[j]; | |
| if (s <= val[k - 1]) continue; | |
| let p = k - 1; | |
| while (p > 0 && val[p - 1] < s) { val[p] = val[p - 1]; idx[p] = idx[p - 1]; p--; } | |
| val[p] = s; idx[p] = j; | |
| } | |
| return idx; | |
| } | |
| function ndcgAtK(top, relByIdx, k) { | |
| let dcg = 0; | |
| for (let i = 0; i < k; i++) dcg += (relByIdx.get(top[i]) || 0) / Math.log2(i + 2); | |
| const grades = [...relByIdx.values()].sort((a, b) => b - a).slice(0, k); | |
| let idcg = 0; | |
| for (let i = 0; i < grades.length; i++) idcg += grades[i] / Math.log2(i + 2); | |
| return idcg > 0 ? dcg / idcg : NaN; | |
| } | |
| function nanmean(a) { const v = a.filter((x) => !Number.isNaN(x)); return v.length ? v.reduce((s, x) => s + x, 0) / v.length : NaN; } | |
| function f16ToF32(u16) { | |
| if (typeof Float16Array !== 'undefined') return new Float32Array(new Float16Array(u16.buffer, u16.byteOffset, u16.length)); | |
| const table = new Float32Array(65536); | |
| for (let h = 0; h < 65536; h++) { | |
| const s = h & 0x8000 ? -1 : 1, e = (h >> 10) & 0x1f, m = h & 0x3ff; | |
| table[h] = e === 0 ? s * 2 ** -14 * (m / 1024) : e === 31 ? (m ? NaN : s * Infinity) : s * 2 ** (e - 15) * (1 + m / 1024); | |
| } | |
| const out = new Float32Array(u16.length); | |
| for (let i = 0; i < u16.length; i++) out[i] = table[u16[i]]; | |
| return out; | |
| } | |
| // ---- state --------------------------------------------------------------------------------------------------- | |
| const S = { | |
| ds: null, dataDir: null, registry: null, | |
| meta: null, docs: null, corpus: null, dim: 0, nDocs: 0, docIndex: null, queries: null, queryByText: null, | |
| clients: [], client: null, ref: null, // client = the selected entry of meta.clients; ref = its reference numbers (refFor) | |
| wllama: null, vq: null, // vq = the VqwClient of @thinletterio/vqweb (info, container, runtime, tokenizer) | |
| loaded: false, loading: false, threads: null, multithread: null, libllama: null, | |
| backend: null, gpuProbe: null, gpu: null, nativeLog: [], | |
| timing: { download_s: null, load_s: null, cached: null, persisted: null }, memory: null, gpuBytes: null, | |
| encodeMs: [], searchMs: [], scores: null, verifying: false, abort: false, lastReport: null, modelSource: null, | |
| }; | |
| // ---- data ---------------------------------------------------------------------------------------------------- | |
| function selectCorpus(id) { const u = new URL(location.href); u.searchParams.set('ds', id); u.searchParams.delete('client'); location.href = u.href; } | |
| function clientsOf(m) { // meta.clients, or (older export without it) the scalar client from the top-level fields | |
| if (Array.isArray(m.clients) && m.clients.length) return m.clients; | |
| const typ = m.quantization?.type || 'scalar'; | |
| return [{ id: 'scalar', label: `${typ} scalar, ${fmtMiB(m.size_bytes || 0)} (llama.cpp WASM/WebGPU)`, runtime: 'wllama', model_url: m.model_url, model_file: m.file, | |
| size_bytes: m.size_bytes, sha256: m.model_sha256, ndcg_test: m.reference?.native_ndcg10 ?? null, ndcg_browser: m.reference?.browser_ndcg10 ?? null, note: null }]; | |
| } | |
| /** The reference numbers of a client for the verify table: the scalar client's are the native llama.cpp run of the same | |
| * file (meta.reference), a VQ client's the torch simulation of the same file (clients[].ndcg_test) and our browser run. */ | |
| function refFor(c) { | |
| const ref = S.meta.reference || {}; | |
| if (c.runtime === 'vqweb') { | |
| return { kind: 'simulation', label: 'Torch simulation, same file', test: c.ndcg_test ?? null, testPQ: c.ndcg_test_per_query || null, testLat: null, | |
| browser: c.ndcg_browser ?? null, browserPQ: c.ndcg_browser_per_query || null, browserNote: c.ndcg_browser_run ? `${adapterLabel(c.ndcg_browser_run.adapter)}, p50 ${Math.round(c.ndcg_browser_run.p50_ms)} ms under load` : 'not measured yet', | |
| fp16: ref.fp16_ndcg10 ?? null, sameNText: 'the torch simulation of this file on the same' }; | |
| } | |
| return { kind: 'native', label: 'Native llama.cpp, same file', test: c.ndcg_test ?? ref.native_ndcg10 ?? null, testPQ: ref.native_ndcg10_per_query || null, testLat: ref.native_latency_ms ?? null, | |
| browser: c.ndcg_browser ?? ref.browser_ndcg10 ?? null, browserPQ: null, browserNote: 'under load, see README', fp16: ref.fp16_ndcg10 ?? null, sameNText: 'native llama.cpp on the same' }; | |
| } | |
| function vqUnavailableReason() { return S.gpuProbe ? (S.gpuProbe.available ? null : (S.gpuProbe.reason || 'WebGPU not available')) : null; } | |
| function renderClientSelect() { | |
| const sel = $('client-select'); | |
| sel.replaceChildren(...S.clients.map((c) => { | |
| const o = document.createElement('option'); o.value = c.id; | |
| const why = c.runtime === 'vqweb' ? vqUnavailableReason() : null; | |
| o.textContent = c.label + (why ? ' — unavailable here' : ''); o.disabled = !!why; o.selected = c.id === S.client.id; o.title = c.note || ''; | |
| return o; | |
| })); | |
| sel.disabled = S.clients.length < 2; | |
| const why = S.client.runtime === 'vqweb' ? vqUnavailableReason() : null; | |
| const note = $('client-note'); | |
| if (why) { note.textContent = `The VQ client needs WebGPU with shader-f16 (standalone runtime, no CPU fallback): ${why}. Choose the scalar client.`; note.className = 'notice warn'; note.hidden = false; } | |
| else if (S.client.note) { note.textContent = S.client.note; note.className = 'muted small'; note.hidden = false; } | |
| else note.hidden = true; | |
| } | |
| function applyClient(c) { // everything on the page that depends on the selected client (before load) | |
| S.client = c; S.ref = refFor(c); | |
| const m = S.meta, r = S.ref; | |
| S.modelSource = new URL(opt.model || c.model_url, ASSET_BASE).href; | |
| const sizeTxt = c.size_bytes ? fmtMiB(c.size_bytes) : '? MiB'; | |
| document.title = `Thinletter demo: ${m.label} search with a ${sizeTxt} query encoder in your browser`; | |
| $('size-btn').textContent = sizeTxt; $('size-lead').textContent = sizeTxt; $('st-size').textContent = `${sizeTxt} (${c.model_file || c.model_url.split('/').pop()})`; | |
| if (c.runtime === 'vqweb') { | |
| $('lead-how').replaceChildren(`compressed by vector quantisation (GPTVQ-lite: small codebooks, ${c.bpw ? c.bpw.toFixed(2) : '?'} bits per weight, input-side rotation) into one `, | |
| Object.assign(document.createElement('span'), { id: 'size-lead', textContent: sizeTxt }), ' file, executed by a standalone WebGPU runtime (WGSL kernels, no WebAssembly) on your GPU'); | |
| $('st-load-label').textContent = 'Load (GPU upload + tokenizer + pipelines)'; | |
| $('ref-native-p').hidden = true; | |
| $('foot-runtime').textContent = '@thinletterio/vqweb (the open WebGPU runtime as an npm package; tokenizer @huggingface/tokenizers 0.2.0)'; | |
| } else { | |
| $('lead-how').replaceChildren(`compressed by GPTQ onto llama.cpp's ${m.quantization?.type || 'K-quant'} grid into one `, | |
| Object.assign(document.createElement('span'), { id: 'size-lead', textContent: sizeTxt }), ' file, executed by llama.cpp compiled to WebAssembly, on your GPU through WebGPU where the browser offers it and on the CPU otherwise'); | |
| $('st-load-label').textContent = 'Load (wasm + weights)'; | |
| $('ref-native-p').hidden = false; | |
| } | |
| $('v-native-label').textContent = r.label; | |
| $('v-native').textContent = r.test?.toFixed(4) ?? '–'; | |
| $('v-native-lat').textContent = r.testLat ? `${r.testLat} ms` : '–'; | |
| $('v-browser').textContent = r.browser?.toFixed(4) ?? '–'; | |
| $('v-browser-note').textContent = r.browserNote; | |
| $('v-fp16').textContent = r.fp16?.toFixed(4) ?? '–'; | |
| $('v-browser-delta').textContent = r.browser != null && r.test != null ? signed(r.browser - r.test) : '–'; | |
| $('v-fp16-delta').textContent = r.fp16 != null && r.test != null ? signed(r.fp16 - r.test) : '–'; | |
| renderClientSelect(); | |
| if (S.gpuProbe) renderBackendChoice(); | |
| } | |
| function onClientChange(id) { | |
| const c = S.clients.find((x) => x.id === id); | |
| if (!c || c.id === S.client.id) return; | |
| const u = new URL(location.href); u.searchParams.set('client', id); | |
| if (S.loaded || S.loading) { location.href = u.href; return; } // a loaded runtime is not swapped in place: reload with client= | |
| history.replaceState(null, '', u.href); | |
| applyClient(c); | |
| status(`index ready (${S.nDocs} documents). Click "Load model" to fetch the ${fmtMiB(c.size_bytes || 0)} query encoder.`); | |
| cachedHint(); | |
| } | |
| async function cachedHint() { | |
| try { const root = await opfsRoot(); if (root && S.client.model_file) { const f = await (await root.getFileHandle(S.client.model_file)).getFile(); if (f.size === S.client.size_bytes) status($('load-status').textContent.replace(/\.?$/, '') + ' (a cached copy is in this browser: no download).'); } } catch (e) { /* not cached */ } | |
| } | |
| async function loadMeta() { | |
| S.registry = await fetchJSON(new URL('./data/index.json', ASSET_BASE).href); | |
| const corpora = S.registry.corpora || []; | |
| S.ds = opt.ds || S.registry.default || (corpora[0] && corpora[0].id); | |
| if (!corpora.some((c) => c.id === S.ds)) throw new Error(`unknown corpus "${S.ds}" (known: ${corpora.map((c) => c.id).join(', ')})`); | |
| S.dataDir = new URL(`./data/${S.ds}/`, ASSET_BASE).href; | |
| const sel = $('ds-select'); | |
| sel.replaceChildren(...corpora.map((c) => { const o = document.createElement('option'); o.value = c.id; o.textContent = `${c.label} (${c.n_docs.toLocaleString('en-US')} docs, ${fmtMiB(c.size_bytes || 0)} client${c.clients && c.clients.length > 1 ? `, ${c.clients.length} clients` : ''})`; o.selected = c.id === S.ds; return o; })); | |
| sel.disabled = corpora.length < 2; sel.onchange = () => { if (sel.value !== S.ds) selectCorpus(sel.value); }; | |
| S.meta = await fetchJSON(S.dataDir + 'meta.json'); | |
| const m = S.meta, ref = m.reference || {}; | |
| const nDocs = m.index.n_docs.toLocaleString('en-US'), nQ = String(m.n_queries); | |
| $('h1-ds').textContent = m.label; $('lead-index').textContent = `${nDocs} ${m.corpus_description || m.label + ' documents'}`; | |
| $('st-ndocs').textContent = nDocs; $('v-n').textContent = nQ; $('v-n2').textContent = nQ; $('v-n-btn').textContent = nQ; $('v-ds').textContent = m.label; | |
| $('foot-license').textContent = m.dataset_license || '?'; | |
| if (m.dataset_url) { $('foot-data').href = m.dataset_url; $('foot-data').textContent = m.dataset_name || m.label; } | |
| else if (m.dataset_name) { $('foot-data').removeAttribute('href'); $('foot-data').textContent = m.dataset_name; } | |
| // per-teacher base model + licence (meta.json base_model_url / base_model_license) and the per-corpus notice (meta.json corpus_notice) | |
| if (m.base_model_url) { $('foot-base').href = m.base_model_url; $('foot-base').textContent = m.base_model_url.replace(/^https?:\/\/huggingface\.co\//, ''); } | |
| if (m.base_model_license) { $('foot-base-license').textContent = m.base_model_license.replace(/\s*\(.*\)\s*$/, ''); } | |
| if (m.corpus_notice) { $('foot-corpus').textContent = `Corpus: ${m.corpus_notice}`; $('foot-corpus').hidden = false; } | |
| if (m.model_short) { $('lead-model').textContent = m.model_short; } | |
| if (ref.native_latency_ms) { $('ref-native').textContent = `${ref.native_latency_ms} ms per query, ${ref.native_peak_rss_mib ?? '?'} MiB peak RSS`; } | |
| $('meta-date').textContent = m.date ? `Data exported ${m.date}.` : ''; | |
| $('st-coi').textContent = self.crossOriginIsolated ? 'yes (multi-threaded wasm possible)' : 'no'; | |
| $('warn-threads').hidden = !!self.crossOriginIsolated; | |
| try { $('libllama').textContent = Wllama.getLibllamaVersion(); } catch (e) { /* keep */ } | |
| // the client: client=<id>, else meta.default_client, else the first entry | |
| S.clients = clientsOf(m); | |
| const want = opt.client || m.default_client; | |
| const c = S.clients.find((x) => x.id === want) || S.clients[0]; | |
| if (opt.client && c.id !== opt.client) console.warn(`unknown client "${opt.client}" (known: ${S.clients.map((x) => x.id).join(', ')}); using ${c.id}`); | |
| $('client-select').onchange = (e) => onClientChange(e.target.value); | |
| applyClient(c); | |
| } | |
| async function loadIndex() { | |
| const m = S.meta, D = S.dataDir, idx = m.index; | |
| const docFiles = m.docs_files ? m.docs_files.map((f) => f.url) : ['docs.json']; | |
| const idxFiles = idx.files ? idx.files.map((f) => f.url) : ['corpus.f16']; | |
| const [docParts, queries, bufs, scaleBuf] = await Promise.all([ | |
| Promise.all(docFiles.map((f) => fetchJSON(D + f))), fetchJSON(D + 'test_queries.json'), | |
| Promise.all(idxFiles.map((f) => fetchBuf(D + f))), idx.dtype_on_disk === 'int8_rowscale' ? fetchBuf(D + idx.scale_file) : null, | |
| ]); | |
| const docs = docParts.flat(); | |
| S.docs = docs; S.queries = queries; S.dim = idx.dim; S.nDocs = docs.length; | |
| const t = performance.now(); | |
| const total = bufs.reduce((n, b) => n + b.byteLength, 0); | |
| if (idx.dtype_on_disk === 'int8_rowscale') { // x = q * scale[row] | |
| const q = new Int8Array(total); let o = 0; for (const b of bufs) { q.set(new Int8Array(b), o); o += b.byteLength; } | |
| const scale = new Float32Array(scaleBuf), d = S.dim, out = new Float32Array(q.length); | |
| for (let j = 0, k = 0; j < scale.length; j++) { const sc = scale[j]; for (let i = 0; i < d; i++, k++) out[k] = q[k] * sc; } | |
| S.corpus = out; | |
| } else { | |
| const u16 = new Uint16Array(total / 2); let o = 0; for (const b of bufs) { u16.set(new Uint16Array(b), o); o += b.byteLength / 2; } | |
| S.corpus = f16ToF32(u16); | |
| } | |
| if (S.corpus.length !== S.nDocs * S.dim) throw new Error(`index size mismatch: ${S.corpus.length} != ${S.nDocs}x${S.dim}`); | |
| S.scores = new Float64Array(S.nDocs); | |
| S.docIndex = new Map(docs.map((d, i) => [d.id, i])); | |
| S.queryByText = new Map(queries.map((q) => [q.text, q])); | |
| console.log(`index ${S.ds}: ${S.nDocs} docs x ${S.dim}, ${idx.dtype_on_disk} -> fp32 in ${(performance.now() - t).toFixed(0)} ms; ${queries.length} test queries`); | |
| // three example queries (deterministic pick) | |
| const ex = [7, 42, 133].filter((i) => i < queries.length).map((i) => queries[i]); | |
| $('examples').replaceChildren(...ex.map((q) => { const a = document.createElement('a'); a.href = '#'; a.textContent = `"${q.text}"`; a.onclick = (e) => { e.preventDefault(); $('q').value = q.text; if (S.loaded) search(q.text); }; return a; })); | |
| } | |
| async function clearCache() { | |
| await clearModelCache(S.client?.model_file, S.wllama || undefined); | |
| status('cached model removed; reload the page to download again'); | |
| } | |
| // ---- backend: WebGPU (llama.cpp ggml-webgpu inside the same wasm) or CPU WebAssembly; vqweb: WebGPU only ------------- | |
| function backendUrl(b) { const u = new URL(location.href); u.searchParams.set('backend', b); return u.href; } | |
| function renderBackendChoice() { // before load: what auto will pick, plus the link to reload with the other backend | |
| const g = S.gpuProbe, el = $('backend-choice'); | |
| const avail = g?.available ? `WebGPU available: ${adapterLabel(g)} (shader-f16)` : `WebGPU not available: ${g?.reason || 'unknown'}`; | |
| if (S.client.runtime === 'vqweb') { | |
| el.replaceChildren(`${avail}. The VQ client runs on the GPU through the standalone vqweb runtime (no WebAssembly, no CPU fallback).`); | |
| $('btn-load').disabled = !g?.available; | |
| return g?.available ? 'webgpu' : null; | |
| } | |
| const pick = opt.backend === 'wasm' ? 'wasm' : opt.backend === 'webgpu' ? 'webgpu' : (g?.available ? 'webgpu' : 'wasm'); | |
| const chosen = pick === 'webgpu' ? 'the GPU through WebGPU' : `CPU WebAssembly (${Math.min(8, navigator.hardwareConcurrency || 1)} threads)`; | |
| el.replaceChildren(`${avail}. Will run on ${chosen}${opt.backend === 'auto' ? ' (auto)' : ` (backend=${opt.backend})`}. `); | |
| const other = pick === 'webgpu' ? 'wasm' : 'webgpu'; | |
| const a = document.createElement('a'); a.href = backendUrl(other); a.textContent = other === 'wasm' ? 'Use CPU WebAssembly instead' : 'Try WebGPU instead'; | |
| el.append(a); | |
| $('btn-load').disabled = S.loaded || S.loading; | |
| return pick; | |
| } | |
| function renderBackendStats() { | |
| const g = S.gpu, el = $('st-backend'); | |
| const tog = document.createElement('a'); tog.className = 'small'; | |
| if (S.client.runtime === 'vqweb') { | |
| const i = S.vq.info; | |
| el.textContent = `WebGPU (@thinletterio/vqweb ${i.version}): ${adapterLabel(i.adapter)}; ${i.model.layers} blocks on the GPU, K=${i.quant.k} dim ${i.quant.dim} (${i.quant.bpwBlocks?.toFixed(2)} bpw), ` + | |
| `upload ${fmtS(i.timing.upload_s)} + tokenizer ${fmtS(i.timing.tokenizer_s)} + pipelines ${fmtS(i.timing.pipelines_s)}`; | |
| $('st-threads').textContent = 'n/a (GPU runtime; tokenizer and token table on the main thread)'; | |
| $('warn-gpu-fallback').hidden = true; | |
| return; | |
| } | |
| if (g.active) { | |
| const ad = /vendor: ([^|]+)\|\s*architecture: ([^|]+)/.exec(g.adapter_info || ''); | |
| const name = ad ? `${ad[1].trim()} / ${ad[2].trim()}` : adapterLabel(S.gpuProbe); | |
| const splits = /graph splits = (\d+)/.exec(g.graph_splits || ''); | |
| el.textContent = `WebGPU: ${name}, ${g.layers} layers offloaded${splits ? `, graph splits ${splits[1]}` : ''}; CPU ${S.threads} thread${S.threads > 1 ? 's' : ''} for the rest `; | |
| tog.href = backendUrl('wasm'); tog.textContent = 'reload with CPU WebAssembly'; | |
| } else { | |
| el.textContent = `${g.fallback ? 'WebGPU requested, fell back to CPU: ' : ''}WebAssembly, ${S.threads} thread${S.threads > 1 ? 's' : ''} (${S.multithread ? 'multi-threaded' : 'single-threaded'}) `; | |
| tog.href = backendUrl('webgpu'); tog.textContent = g.fallback ? 'retry WebGPU' : 'reload with WebGPU'; | |
| } | |
| el.append(tog); | |
| $('warn-gpu-fallback').hidden = !g.fallback; | |
| } | |
| // ---- model load ---------------------------------------------------------------------------------------------- | |
| function progressUI() { | |
| const bar = $('progress-bar'), txt = $('progress-text'); $('progress-wrap').hidden = false; | |
| const t0 = performance.now(); let lastPaint = 0; | |
| return { | |
| onProgress: (loaded, total) => { | |
| const now = performance.now(); if (now - lastPaint < 100 && loaded < total) return; lastPaint = now; | |
| const dt = (now - t0) / 1000, speed = dt > 0 ? loaded / dt : 0; | |
| bar.style.width = `${(100 * loaded / total).toFixed(1)}%`; | |
| txt.textContent = `${fmtMiB(loaded)} / ${fmtMiB(total)} (${(speed / 2 ** 20).toFixed(1)} MiB/s)`; | |
| }, | |
| onCached: (size) => { bar.style.width = '100%'; txt.textContent = `${fmtMiB(size)} from cache`; }, | |
| }; | |
| } | |
| async function loadModel() { | |
| if (S.loaded || S.loading || !S.meta) return; | |
| const lowMem = navigator.deviceMemory && navigator.deviceMemory < 4; | |
| if (lowMem && S.client.runtime !== 'vqweb' && !opt.autoload && !window.confirm('This device reports less than 4 GB of memory; the model needs about 1.2 GB in this tab. Load anyway?')) return; | |
| S.loading = true; | |
| $('btn-load').disabled = true; $('client-select').disabled = true; | |
| if (!S.gpuProbe) S.gpuProbe = await probeWebGPU(); | |
| try { | |
| if (S.client.runtime === 'vqweb') await loadVqweb(); else await loadWllama(); | |
| S.loaded = true; | |
| $('q').disabled = false; $('btn-search').disabled = false; $('btn-verify').disabled = false; $('q').placeholder = S.meta.query_hint || 'Type a query'; | |
| $('q').focus(); | |
| $('st-memory').textContent = 'measuring …'; | |
| S.memoryPromise = measureMemory().then((m) => { | |
| S.memory = m; | |
| const js = m.bytes != null ? `${fmtMiB(m.bytes)} (${m.api})` : 'n/a (API not available)'; | |
| $('st-memory').textContent = S.client.runtime === 'vqweb' ? `${js} + ${fmtMiB(S.gpuBytes)} GPU buffers (weights ${fmtMiB(S.vq.info.weightBytes)} + activations ${fmtMiB(S.vq.info.activationBytes)}; not visible to the tab's measurement)` | |
| : js + (S.gpu.active ? '; with WebGPU the weights live in GPU memory: the tab measures lower, the whole process uses more' : ''); | |
| }); | |
| smokeLog('loaded', { client: S.client.id, runtime: S.client.runtime, backend: S.backend, backend_requested: opt.backend, gpu: S.gpu, page_adapter: S.gpuProbe, threads: S.threads, multithread: S.multithread, | |
| crossOriginIsolated: !!self.crossOriginIsolated, cached: S.timing.cached, download_s: S.timing.download_s, load_s: S.timing.load_s, gpu_bytes: S.gpuBytes, vq: S.vq ? vqInfo() : null }); | |
| } catch (e) { | |
| console.error(e); | |
| status(`load failed: ${e && e.message ? e.message : e}`, true); | |
| $('btn-load').disabled = false; | |
| smokeLog('load_failed', { client: S.client.id, error: String(e && e.message ? e.message : e) }); | |
| throw e; | |
| } finally { S.loading = false; $('client-select').disabled = S.clients.length < 2; } | |
| } | |
| async function loadWllama() { // the scalar client: chunk manifest -> OPFS -> wllama (loader.loadModelSource); unchanged from before M5 | |
| const threads = opt.threads || Math.min(8, navigator.hardwareConcurrency || 1); | |
| S.backend = opt.backend === 'wasm' ? 'wasm' : opt.backend === 'webgpu' ? 'webgpu' : (S.gpuProbe.available ? 'webgpu' : 'wasm'); | |
| const ngl = S.backend === 'webgpu' ? 99999 : 0; // 0 = wllama stubs navigator.gpu in the worker (CPU); anything else lets ggml-webgpu request the adapter | |
| const wllama = newWllama(S.nativeLog); | |
| S.wllama = wllama; | |
| const loadOpts = { ...LOAD_OPTS, n_threads: threads, n_gpu_layers: ngl }; | |
| console.log(`backend=${S.backend} (requested ${opt.backend}; page adapter: ${JSON.stringify(S.gpuProbe)}) n_gpu_layers=${ngl} threads=${threads}`); | |
| const ui = progressUI(); | |
| const r = await loadModelSource(wllama, S.modelSource, loadOpts, { onProgress: ui.onProgress, onStatus: status, backend: S.backend, onCached: ui.onCached }); | |
| const cached = r.cached; | |
| S.timing = { download_s: r.download_s, load_s: r.load_s, cached, persisted: r.persisted }; | |
| S.threads = wllama.getNumThreads(); S.multithread = wllama.isMultithread(); | |
| const info = wllama.getLoadedContextInfo(); | |
| $('st-download').textContent = cached ? `0 s (cached${S.timing.persisted ? ', OPFS' : ''})` : `${fmtS(S.timing.download_s)}${S.timing.persisted ? ' (now cached for the next visit)' : ' (not persisted in this browser)'}`; | |
| $('st-load').textContent = fmtS(S.timing.load_s); | |
| $('st-threads').textContent = `${S.threads} (${S.multithread ? 'multi-threaded' : 'single-threaded'}, ${navigator.hardwareConcurrency || '?'} logical cores)`; | |
| $('warn-threads').hidden = !!S.multithread; | |
| S.gpu = parseGpuLog(S.nativeLog, S.backend); | |
| renderBackendStats(); | |
| for (const l of S.nativeLog) if (/ggml_webgpu|offload|graph splits|buffer size|Flash Attention/.test(l)) console.log('llama.cpp: ' + l.trim()); | |
| console.log(`loaded: n_ctx=${info.n_ctx} n_batch=${info.n_batch} n_embd=${info.n_embd} add_bos=${wllama.mustAddBosToken()} add_eos=${wllama.mustAddEosToken()} threads=${S.threads} mt=${S.multithread} gpu=${JSON.stringify({ active: S.gpu.active, adapter: S.gpu.adapter_info, layers: S.gpu.layers, splits: S.gpu.graph_splits })}`); | |
| if (S.gpu.fallback) console.warn('WebGPU requested but no ggml_webgpu adapter_info / offload line in the llama.cpp log: this is a CPU run'); | |
| status('warming up …'); | |
| await embedQuery('warm-up'); // untimed: primes the graph and the instruction-prefix KV cache | |
| status(`ready on ${S.gpu.active ? 'WebGPU' : `${S.threads} CPU thread${S.threads > 1 ? 's' : ''}`}${S.gpu.fallback ? ' (WebGPU requested, fell back to CPU)' : ''}, download ${cached ? 'from cache' : fmtS(S.timing.download_s)}, load ${fmtS(S.timing.load_s)}`); | |
| } | |
| async function loadVqweb() { // the VQ client through the @thinletterio/vqweb package: chunk manifest -> OPFS -> GPU upload -> tokenizer -> pipelines -> warm-up | |
| const { loadVqwClient } = await import(VQWEB_SDK.href); | |
| S.backend = 'webgpu'; | |
| const ui = progressUI(); | |
| const name = S.modelSource.split('/').pop(); | |
| const phaseText = { upload: 'parsing the container and uploading the codebooks, indices and scales to the GPU …', tokenizer: 'loading the tokenizer …', | |
| pipelines: 'compiling the WebGPU pipelines …', warmup: 'warming up …' }; | |
| status(`requesting a WebGPU device with shader-f16, then fetching ${name} …`); | |
| const t0 = performance.now(); | |
| const client = await loadVqwClient(S.modelSource, { cache: 'opfs', onProgress: ({ phase, loaded, total }) => { | |
| if (phase === 'download') ui.onProgress(loaded, total); else if (phaseText[phase]) status(phaseText[phase]); | |
| } }); | |
| const t1 = performance.now(); | |
| client.device.addEventListener('uncapturederror', (e) => console.warn('WebGPU error:', e.error && e.error.message)); | |
| const i = client.info, { cached, persisted } = i.file; | |
| if (cached) { status('model found in this browser\'s storage (OPFS), no download'); ui.onCached(i.file.bytes); } | |
| S.vq = client; | |
| S.gpuBytes = i.gpuBytes; | |
| S.timing = { download_s: i.timing.download_s, load_s: (t1 - t0) / 1000 - i.timing.download_s, cached, persisted }; | |
| S.threads = null; S.multithread = null; | |
| const info = i.adapter || {}; | |
| S.gpu = { requested: 'webgpu', active: true, fallback: false, runtime: 'vqweb', layers: `${i.model.layers}/${i.model.layers}`, graph_splits: null, | |
| adapter_info: `vendor: ${info.vendor} | architecture: ${info.architecture} | device: ${info.device || ''} | description: ${info.description || ''}`, page_adapter: info }; | |
| const m = client.container.model; | |
| if (m.prompt !== S.meta.prompt) console.warn(`container prompt ${JSON.stringify(m.prompt)} differs from the corpus prompt ${JSON.stringify(S.meta.prompt)}: the tokenizer applies the container's`); | |
| if (m.truncated) console.warn('TRUNCATED dev container: nDCG is meaningless'); | |
| console.log(`vqweb loaded (@thinletterio/vqweb ${i.version}): ${i.model.teacher} ${i.model.layers} blocks, K=${i.quant.k} dim ${i.quant.dim} (${i.quant.bits} b, ${i.quant.bpwBlocks?.toFixed(3)} bpw), ${fmtMiB(i.weightBytes)} weights + ${fmtMiB(i.activationBytes)} activations on the GPU, ` + | |
| `table ${i.tokenizer.rows} rows, prompt ${JSON.stringify(m.prompt)} add_eos=${m.addEos} eos=${m.eosId}, adapter ${JSON.stringify(info)}`); | |
| $('st-download').textContent = cached ? `0 s (cached${persisted ? ', OPFS' : ''})` : `${fmtS(S.timing.download_s)}${persisted ? ' (now cached for the next visit)' : ' (not persisted in this browser)'}`; | |
| $('st-load').textContent = `${fmtS(S.timing.load_s)} (upload ${fmtS(i.timing.upload_s)}, tokenizer ${fmtS(i.timing.tokenizer_s)}, pipelines ${fmtS(i.timing.pipelines_s)}, warm-up ${fmtS(i.timing.warmup_s)})`; | |
| $('warn-threads').hidden = true; | |
| renderBackendStats(); | |
| status(`ready on WebGPU (${adapterLabel(info)}, @thinletterio/vqweb ${i.version}), download ${cached ? 'from cache' : fmtS(S.timing.download_s)}, load ${fmtS(S.timing.load_s)}`); | |
| } | |
| function vqInfo() { | |
| const v = S.vq, i = v.info, c = v.container; | |
| return { sdk: i.version, adapter: i.adapter, layers: i.model.layers, k: i.quant.k, dim: i.quant.dim, bits: i.quant.bits, bpw: i.quant.bpwBlocks, prompt: c.model.prompt, add_eos: c.model.addEos, prompt_match: c.model.prompt === S.meta.prompt, | |
| table_rows: i.tokenizer.rows, gpu_weight_bytes: i.weightBytes, act_bytes: i.activationBytes, upload_s: i.timing.upload_s, tokenizer_s: i.timing.tokenizer_s, pipelines_s: i.timing.pipelines_s, pipelines: v.runtime.pipelines?.size ?? null, tmax: v.runtime.tmax ?? null }; | |
| } | |
| // ---- encode + search ----------------------------------------------------------------------------------------- | |
| /** query text WITHOUT the prompt -> L2-normalised Float32Array. wllama: meta.prompt + text through llama.cpp; | |
| * vqweb: VqwTokenizer.encode(text) applies the container header's prompt / add_eos, then the GPU forward pass. */ | |
| async function embedQuery(queryText) { | |
| if (S.client.runtime === 'vqweb') { | |
| const r = await S.vq.embedWithStats(queryText); // the package applies the container's prompt / EOS / byte fallback and L2-normalises | |
| return { vec: r.embedding, tokens: r.tokens, gpu_ms: r.gpuMs }; | |
| } | |
| const r = await S.wllama.createEmbedding({ input: S.meta.prompt + queryText }); | |
| return { vec: l2n(Float32Array.from(r.data[0].embedding)), tokens: r.usage?.prompt_tokens }; | |
| } | |
| async function encodeAndRank(queryText) { | |
| const t0 = performance.now(); | |
| const e = await embedQuery(queryText); | |
| const t1 = performance.now(); | |
| const v = e.vec, corpus = S.corpus, d = S.dim, scores = S.scores; | |
| if (v.length !== d) throw new Error(`embedding dim ${v.length} != index dim ${d}`); | |
| for (let j = 0; j < S.nDocs; j++) scores[j] = dot(v, 0, corpus, j * d, d); | |
| const top = topk(scores, 10); | |
| const t2 = performance.now(); | |
| return { top, scores: Array.from(top, (j) => scores[j]), encodeMs: t1 - t0, searchMs: t2 - t1, tokens: e.tokens, gpuMs: e.gpu_ms }; | |
| } | |
| function relMap(q) { | |
| const relByIdx = new Map(); | |
| if (q) for (const [d, g] of Object.entries(q.rel)) { const j = S.docIndex.get(d); if (j != null && g > 0) relByIdx.set(j, g); } | |
| return relByIdx; | |
| } | |
| function updateLatencyStats(r) { | |
| S.encodeMs.push(r.encodeMs); S.searchMs.push(r.searchMs); | |
| $('st-encode').textContent = `${fmtMs(r.encodeMs)} / ${fmtMs(percentile(S.encodeMs, 0.5))}`; | |
| $('st-search').textContent = `${fmtMs(r.searchMs)} / ${fmtMs(percentile(S.searchMs, 0.5))}`; | |
| $('st-count').textContent = String(S.encodeMs.length); | |
| } | |
| async function search(text) { | |
| text = (text || '').trim(); | |
| if (!text || !S.loaded || S.verifying) return null; | |
| $('btn-search').disabled = true; | |
| try { | |
| const r = await encodeAndRank(text); | |
| updateLatencyStats(r); | |
| const q = S.queryByText.get(text) || null; | |
| const rel = relMap(q); | |
| renderResults(r, rel); | |
| const out = { query: text, qid: q ? q.qid : null, encode_ms: r.encodeMs, search_ms: r.searchMs, tokens: r.tokens, gpu_ms: r.gpuMs ?? null, | |
| top: Array.from(r.top, (j, i) => ({ id: S.docs[j].id, score: r.scores[i] })), ndcg10: q ? ndcgAtK(r.top, rel, 10) : null }; | |
| smokeLog('search', out); | |
| return out; | |
| } finally { $('btn-search').disabled = false; } | |
| } | |
| function renderResults(r, rel) { | |
| const ol = $('results'); | |
| ol.replaceChildren(...Array.from(r.top, (j, i) => { | |
| const d = S.docs[j]; | |
| const li = document.createElement('li'); if (rel.has(j)) li.classList.add('rel'); | |
| const head = document.createElement('div'); head.className = 'head'; | |
| const rank = document.createElement('span'); rank.className = 'rank'; rank.textContent = `${i + 1}.`; | |
| const title = document.createElement('span'); title.className = 'title'; title.textContent = d.title || '(untitled)'; | |
| const score = document.createElement('span'); score.className = 'score'; score.textContent = r.scores[i].toFixed(4); | |
| head.append(rank, title, score); | |
| const id = document.createElement('div'); id.className = 'docid'; id.textContent = `doc ${d.id}`; | |
| const abs = document.createElement('p'); abs.className = 'abstract'; abs.textContent = d.text; abs.hidden = true; | |
| head.addEventListener('click', () => { abs.hidden = !abs.hidden; }); | |
| li.append(head, id, abs); | |
| return li; | |
| })); | |
| } | |
| // ---- verification: the 300 test queries ----------------------------------------------------------------------- | |
| async function verify(n) { | |
| if (!S.loaded || S.verifying) return null; | |
| n = Math.min(n || S.queries.length, S.queries.length); | |
| S.verifying = true; S.abort = false; | |
| $('btn-verify').disabled = true; $('btn-abort').hidden = false; $('btn-copy').hidden = true; $('verify-details').open = true; | |
| $('verify-progress-wrap').hidden = false; $('verify-table').hidden = false; $('verify-note').hidden = true; | |
| $('q').disabled = true; $('btn-search').disabled = true; | |
| const ref = S.ref, refPQ = ref.testPQ; // the selected client's own reference (native llama.cpp, or the torch simulation of the VQ file), per query | |
| const lat = [], ndcg = [], perQuery = []; | |
| const tRun = performance.now(); | |
| try { | |
| for (let i = 0; i < n; i++) { | |
| if (S.abort) break; | |
| const q = S.queries[i]; | |
| const r = await encodeAndRank(q.text); | |
| const rel = relMap(q); | |
| const v = ndcgAtK(r.top, rel, 10); | |
| lat.push(r.encodeMs); ndcg.push(v); | |
| perQuery.push({ qid: q.qid, ndcg10: Number.isNaN(v) ? null : v, encode_ms: r.encodeMs, search_ms: r.searchMs, tokens: r.tokens, top1: S.docs[r.top[0]].id, | |
| native_ndcg10: refPQ ? refPQ[i] : undefined, browser_ndcg10: ref.browserPQ ? ref.browserPQ[i] : undefined }); | |
| updateLatencyStats(r); | |
| $('verify-bar').style.width = `${(100 * (i + 1) / n).toFixed(1)}%`; | |
| $('v-ndcg').textContent = nanmean(ndcg).toFixed(4); | |
| $('v-delta').textContent = ref.test != null ? signed(nanmean(ndcg) - ref.test) : '–'; | |
| $('v-p50').textContent = fmtMs(percentile(lat, 0.5)); $('v-p95').textContent = fmtMs(percentile(lat, 0.95)); | |
| $('verify-status').textContent = `${i + 1} / ${n} queries, ${fmtS((performance.now() - tRun) / 1000)} elapsed`; | |
| if (i % 5 === 4) await sleep(0); | |
| } | |
| } finally { | |
| S.verifying = false; $('btn-verify').disabled = false; $('btn-abort').hidden = true; $('q').disabled = false; $('btn-search').disabled = false; | |
| } | |
| const done = perQuery.length, aborted = S.abort; | |
| const report = { | |
| demo: 'thinletter', dataset: S.ds, client: S.client.id, runtime: S.client.runtime, date: new Date().toISOString(), | |
| model_file: S.client.model_file, model_size_bytes: S.client.size_bytes, model_sha256: S.client.sha256 || null, bpw: S.client.bpw ?? null, | |
| prompt: S.client.runtime === 'vqweb' ? S.vq.container.model.prompt : S.meta.prompt, prompt_source: S.client.runtime === 'vqweb' ? 'container header' : 'meta.json', | |
| wllama_version: S.wllama ? WLLAMA_VERSION : null, libllama: (() => { try { return S.wllama ? Wllama.getLibllamaVersion() : null; } catch (e) { return null; } })(), | |
| userAgent: navigator.userAgent, hardwareConcurrency: navigator.hardwareConcurrency, deviceMemory: navigator.deviceMemory ?? null, | |
| crossOriginIsolated: !!self.crossOriginIsolated, threads: S.threads, multithread: S.multithread, | |
| backend: S.backend, backend_requested: opt.backend, gpu: S.gpu, page_adapter: S.gpuProbe, vq: S.vq ? vqInfo() : null, | |
| download_s: S.timing.download_s, load_s: S.timing.load_s, model_cached: S.timing.cached, memory_after_load: S.memory, gpu_bytes: S.gpuBytes, | |
| n: done, aborted, run_s: (performance.now() - tRun) / 1000, | |
| reference_kind: ref.kind, // "native" = llama.cpp on the same GGUF; "simulation" = torch simulation of the same .vqw (the ndcg10_native* fields then hold that) | |
| ndcg10_mean: nanmean(ndcg), ndcg10_native: ref.test ?? null, ndcg10_browser_ours: ref.browser ?? null, ndcg10_fp16: ref.fp16 ?? null, | |
| ndcg10_native_same_n: refPQ ? nanmean(refPQ.slice(0, done).map((x) => (x == null ? NaN : x))) : null, | |
| ndcg10_browser_ours_same_n: ref.browserPQ ? nanmean(ref.browserPQ.slice(0, done).map((x) => (x == null ? NaN : x))) : null, | |
| latency_ms: { p50: percentile(lat, 0.5), p95: percentile(lat, 0.95), mean: lat.reduce((a, b) => a + b, 0) / Math.max(1, lat.length) }, | |
| per_query: perQuery, | |
| }; | |
| S.lastReport = report; | |
| const nat = report.ndcg10_native_same_n; | |
| const dq = refPQ ? perQuery.filter((p) => p.native_ndcg10 != null && p.ndcg10 != null && Math.abs(p.native_ndcg10 - p.ndcg10) > 1e-3).length : null; | |
| $('verify-status').textContent = `${aborted ? 'aborted after' : 'done:'} ${done} queries in ${fmtS(report.run_s)}`; | |
| $('verify-note').hidden = false; | |
| $('verify-note').textContent = `nDCG@10 over ${done} queries: ${report.ndcg10_mean.toFixed(4)}` + | |
| (nat != null ? ` (${ref.sameNText} ${done} queries: ${nat.toFixed(4)}${dq != null ? `, ${dq} queries differ by more than 0.001` : ''})` : '') + | |
| `. Encode latency p50 ${fmtMs(report.latency_ms.p50)}, p95 ${fmtMs(report.latency_ms.p95)} on ` + | |
| (S.client.runtime === 'vqweb' ? `WebGPU (${adapterLabel(S.vq.info.adapter)}, @thinletterio/vqweb)` : | |
| S.gpu?.active ? `WebGPU (${S.gpu.layers} layers offloaded)` : `${S.threads} CPU thread${S.threads > 1 ? 's' : ''}${self.crossOriginIsolated ? '' : ' (single-threaded fallback)'}${S.gpu?.fallback ? ', WebGPU requested but fell back to CPU' : ''}`) + | |
| (S.client.runtime === 'vqweb' ? '. Small differences from the simulation are expected: f16 matmul inputs on the GPU, and ties in the top-10.' | |
| : '. Small differences from the native number are expected: a different kernel order in WebAssembly or on the GPU, and ties in the top-10.'); | |
| $('btn-copy').hidden = false; | |
| smokeLog('verify', { client: S.client.id, n: done, aborted, ndcg10_mean: report.ndcg10_mean, ndcg10_native_same_n: nat, reference_kind: ref.kind, p50: report.latency_ms.p50, p95: report.latency_ms.p95, | |
| per_query: perQuery.map((p) => ({ qid: p.qid, ndcg10: p.ndcg10, top1: p.top1 })) }); | |
| return report; | |
| } | |
| async function copyReport() { | |
| if (!S.lastReport) return; | |
| const text = JSON.stringify(S.lastReport, null, 1); | |
| try { await navigator.clipboard.writeText(text); $('verify-status').textContent = 'report copied to the clipboard'; } | |
| catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.style.width = '100%'; ta.rows = 12; $('panel-verify').appendChild(ta); ta.select(); $('verify-status').textContent = 'clipboard unavailable: report shown below'; } | |
| } | |
| // ---- smoke test (headless) ------------------------------------------------------------------------------------- | |
| async function smoke() { | |
| const report = { ok: false, dataset: S.ds, client: S.client.id, runtime: S.client.runtime, started: new Date().toISOString(), crossOriginIsolated: !!self.crossOriginIsolated, userAgent: navigator.userAgent }; | |
| try { | |
| await loadModel(); | |
| report.load = { client: S.client.id, runtime: S.client.runtime, backend: S.backend, backend_requested: opt.backend, gpu: S.gpu, page_adapter: S.gpuProbe, threads: S.threads, multithread: S.multithread, | |
| download_s: S.timing.download_s, load_s: S.timing.load_s, cached: S.timing.cached, gpu_bytes: S.gpuBytes, vq: S.vq ? vqInfo() : null }; | |
| report.native_log = S.nativeLog.filter((l) => /ggml_webgpu|offload|graph splits|buffer size|Flash Attention|Multithread/.test(l)).slice(0, 60); | |
| if (opt.autoquery) { $('q').value = opt.autoquery; report.search = await search(opt.autoquery); } | |
| report.verify = await verify(opt.verifyN); | |
| try { await S.memoryPromise; } catch (e) { /* measured or not */ } | |
| report.memory_after_load = S.memory; | |
| report.ok = true; | |
| } catch (e) { report.error = String(e && e.message ? e.message : e); report.stack = e && e.stack; } | |
| report.finished = new Date().toISOString(); | |
| window.__smokeReport = report; | |
| smokeLog('report', { ok: report.ok, error: report.error || null, client: S.client.id, backend: S.backend, gpu_active: S.gpu?.active ?? null, n_results: report.search ? report.search.top.length : null, verify_n: report.verify ? report.verify.n : null, ndcg10_mean: report.verify ? report.verify.ndcg10_mean : null }); | |
| try { await fetch(new URL('./smoke', location.href).href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(report) }); } catch (e) { /* no endpoint: fine */ } | |
| smokeLog('done'); | |
| } | |
| // ---- wiring -------------------------------------------------------------------------------------------------- | |
| (async function main() { | |
| try { | |
| await loadMeta(); | |
| status('loading the document index …'); | |
| await loadIndex(); | |
| if (navigator.deviceMemory && navigator.deviceMemory < 4) $('warn-memory').hidden = false; | |
| S.gpuProbe = await probeWebGPU(); | |
| renderClientSelect(); // VQ entries are disabled when the probe finds no WebGPU / shader-f16 | |
| renderBackendChoice(); | |
| status(`index ready (${S.nDocs} documents). Click "Load model" to fetch the ${fmtMiB(S.client.size_bytes || 0)} query encoder.`); | |
| await cachedHint(); | |
| $('btn-load').addEventListener('click', () => loadModel().catch(() => {})); | |
| $('form-search').addEventListener('submit', (e) => { e.preventDefault(); search($('q').value).catch((err) => status(`search failed: ${err.message}`, true)); }); | |
| $('btn-verify').addEventListener('click', () => verify(opt.verifyN).catch((err) => status(`verification failed: ${err.message}`, true))); | |
| $('btn-abort').addEventListener('click', () => { S.abort = true; }); | |
| $('btn-copy').addEventListener('click', copyReport); | |
| const clr = document.createElement('a'); clr.href = '#'; clr.textContent = 'Remove the cached copy'; clr.className = 'small'; | |
| clr.onclick = (e) => { e.preventDefault(); clearCache(); }; $('panel-load').querySelector('p.muted').append(' ', clr); | |
| if (opt.smoke) await smoke(); | |
| else if (opt.autoload) { await loadModel(); if (opt.autoquery) { $('q').value = opt.autoquery; await search(opt.autoquery); } if (opt.autoverify) await verify(opt.verifyN); } | |
| } catch (e) { | |
| console.error(e); | |
| status(`startup failed: ${e && e.message ? e.message : e}`, true); | |
| smokeLog('startup_failed', { error: String(e && e.message ? e.message : e) }); | |
| } | |
| })(); | |