|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * SAQT Benchmark — NaturalQuestions / TriviaQA / HotPotQA |
| 4 | + * |
| 5 | + * Sends questions to the SAQT API, compares against gold answers, |
| 6 | + * measures: exact match, F1 token overlap, latency. |
| 7 | + * |
| 8 | + * Usage: |
| 9 | + * node benchmark.mjs [--samples N] [--api URL] [--output DIR] [--concurrency N] |
| 10 | + * |
| 11 | + * Defaults: 100 samples/dataset, https://chat.webmind.sh/api/saqt/query, ~/webmind-research/benchmarks/ |
| 12 | + */ |
| 13 | + |
| 14 | +import { writeFileSync, mkdirSync, existsSync } from 'fs'; |
| 15 | +import { join } from 'path'; |
| 16 | +import { homedir } from 'os'; |
| 17 | + |
| 18 | +// --------------- config --------------- |
| 19 | +const args = process.argv.slice(2); |
| 20 | +function flag(name, fallback) { |
| 21 | + const i = args.indexOf(`--${name}`); |
| 22 | + return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback; |
| 23 | +} |
| 24 | + |
| 25 | +const SAMPLES_PER_DATASET = parseInt(flag('samples', '100'), 10); |
| 26 | +const API_URL = flag('api', 'https://chat.webmind.sh/api/saqt/query'); |
| 27 | +const OUTPUT_DIR = flag('output', join(homedir(), 'webmind-research', 'benchmarks')); |
| 28 | +const CONCURRENCY = parseInt(flag('concurrency', '5'), 10); |
| 29 | +const TIMEOUT_MS = 30_000; |
| 30 | + |
| 31 | +// --------------- dataset fetchers --------------- |
| 32 | + |
| 33 | +async function fetchJSON(url) { |
| 34 | + const r = await fetch(url, { signal: AbortSignal.timeout(60_000) }); |
| 35 | + if (!r.ok) throw new Error(`HTTP ${r.status} from ${url}`); |
| 36 | + return r.json(); |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * HuggingFace datasets API — rows endpoint. |
| 41 | + * Returns [{question, gold_answers}] |
| 42 | + */ |
| 43 | +async function fetchNaturalQuestions(n) { |
| 44 | + // NQ from google-research-datasets/nq_open, validation split |
| 45 | + const url = `https://datasets-server.huggingface.co/rows?dataset=google-research-datasets/nq_open&config=nq_open&split=validation&offset=0&length=${n}`; |
| 46 | + const data = await fetchJSON(url); |
| 47 | + return data.rows.map(r => ({ |
| 48 | + question: r.row.question, |
| 49 | + gold_answers: Array.isArray(r.row.answer) ? r.row.answer : [r.row.answer], |
| 50 | + })); |
| 51 | +} |
| 52 | + |
| 53 | +async function fetchTriviaQA(n) { |
| 54 | + // TriviaQA unfiltered.nocontext, validation split (mandarjoshi namespace) |
| 55 | + const url = `https://datasets-server.huggingface.co/rows?dataset=mandarjoshi/trivia_qa&config=unfiltered.nocontext&split=validation&offset=0&length=${n}`; |
| 56 | + const data = await fetchJSON(url); |
| 57 | + return data.rows.map(r => ({ |
| 58 | + question: r.row.question, |
| 59 | + gold_answers: [ |
| 60 | + ...(r.row.answer?.aliases || []), |
| 61 | + r.row.answer?.value, |
| 62 | + ].filter(Boolean), |
| 63 | + })); |
| 64 | +} |
| 65 | + |
| 66 | +async function fetchHotPotQA(n) { |
| 67 | + // HotpotQA distractor, validation split (hotpotqa namespace) |
| 68 | + const url = `https://datasets-server.huggingface.co/rows?dataset=hotpotqa/hotpot_qa&config=distractor&split=validation&offset=0&length=${n}`; |
| 69 | + const data = await fetchJSON(url); |
| 70 | + return data.rows.map(r => ({ |
| 71 | + question: r.row.question, |
| 72 | + gold_answers: [r.row.answer], |
| 73 | + })); |
| 74 | +} |
| 75 | + |
| 76 | +// --------------- metrics --------------- |
| 77 | + |
| 78 | +function normalize(text) { |
| 79 | + return (text || '') |
| 80 | + .toLowerCase() |
| 81 | + .replace(/\*\*/g, '') // strip markdown bold |
| 82 | + .replace(/[^\w\s]/g, ' ') // strip punctuation |
| 83 | + .replace(/\b(a|an|the|is|are|was|were|of|in|on|at|to|for|and|or|but|not|with|from|by|as|it|its|this|that|these|those)\b/g, ' ') |
| 84 | + .replace(/\s+/g, ' ') |
| 85 | + .trim(); |
| 86 | +} |
| 87 | + |
| 88 | +function tokenize(text) { |
| 89 | + return normalize(text).split(' ').filter(Boolean); |
| 90 | +} |
| 91 | + |
| 92 | +function exactMatch(predicted, golds) { |
| 93 | + const normPred = normalize(predicted); |
| 94 | + return golds.some(g => normalize(g) === normPred) ? 1 : 0; |
| 95 | +} |
| 96 | + |
| 97 | +function f1Score(predicted, golds) { |
| 98 | + const predTokens = tokenize(predicted); |
| 99 | + if (predTokens.length === 0) return 0; |
| 100 | + |
| 101 | + // Take best F1 across all gold answers |
| 102 | + let bestF1 = 0; |
| 103 | + for (const gold of golds) { |
| 104 | + const goldTokens = tokenize(gold); |
| 105 | + if (goldTokens.length === 0) continue; |
| 106 | + |
| 107 | + const goldSet = new Set(goldTokens); |
| 108 | + const common = predTokens.filter(t => goldSet.has(t)).length; |
| 109 | + if (common === 0) continue; |
| 110 | + |
| 111 | + const precision = common / predTokens.length; |
| 112 | + const recall = common / goldTokens.length; |
| 113 | + const f1 = (2 * precision * recall) / (precision + recall); |
| 114 | + if (f1 > bestF1) bestF1 = f1; |
| 115 | + } |
| 116 | + return bestF1; |
| 117 | +} |
| 118 | + |
| 119 | +// --------------- API caller --------------- |
| 120 | + |
| 121 | +const MAX_RETRIES = 3; |
| 122 | +const RETRY_DELAY_MS = 5000; |
| 123 | + |
| 124 | +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } |
| 125 | + |
| 126 | +// Context prompt — tells the engine it's being evaluated for accuracy |
| 127 | +const BENCHMARK_CONTEXT = 'You are being evaluated for factual accuracy. Give precise, direct answers. No formatting, no commentary. Just the fact.'; |
| 128 | + |
| 129 | +async function queryAPI(question, retries = 0) { |
| 130 | + const start = Date.now(); |
| 131 | + try { |
| 132 | + const r = await fetch(API_URL, { |
| 133 | + method: 'POST', |
| 134 | + headers: { 'Content-Type': 'application/json' }, |
| 135 | + body: JSON.stringify({ question, context: BENCHMARK_CONTEXT }), |
| 136 | + signal: AbortSignal.timeout(TIMEOUT_MS), |
| 137 | + }); |
| 138 | + const clientLatency = Date.now() - start; |
| 139 | + if (!r.ok) { |
| 140 | + if (retries < MAX_RETRIES && (r.status === 502 || r.status === 503 || r.status === 429)) { |
| 141 | + process.stderr.write(` [RETRY ${retries+1}/${MAX_RETRIES}] HTTP ${r.status}, waiting ${RETRY_DELAY_MS}ms...\n`); |
| 142 | + await sleep(RETRY_DELAY_MS * (retries + 1)); |
| 143 | + return queryAPI(question, retries + 1); |
| 144 | + } |
| 145 | + return { answer: '', confidence: 0, timeMs: clientLatency, error: `HTTP ${r.status}` }; |
| 146 | + } |
| 147 | + const text = await r.text(); |
| 148 | + let body; |
| 149 | + try { body = JSON.parse(text); } catch { |
| 150 | + return { answer: '', confidence: 0, timeMs: clientLatency, error: 'invalid JSON' }; |
| 151 | + } |
| 152 | + return { |
| 153 | + answer: body.answer || '', |
| 154 | + confidence: body.confidence || 0, |
| 155 | + timeMs: body.timeMs || clientLatency, |
| 156 | + clientLatency, |
| 157 | + }; |
| 158 | + } catch (e) { |
| 159 | + if (retries < MAX_RETRIES) { |
| 160 | + process.stderr.write(` [RETRY ${retries+1}/${MAX_RETRIES}] ${e.message}, waiting ${RETRY_DELAY_MS}ms...\n`); |
| 161 | + await sleep(RETRY_DELAY_MS * (retries + 1)); |
| 162 | + return queryAPI(question, retries + 1); |
| 163 | + } |
| 164 | + return { answer: '', confidence: 0, timeMs: Date.now() - start, error: e.message }; |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +// --------------- runner --------------- |
| 169 | + |
| 170 | +async function runBatch(items, label) { |
| 171 | + const results = []; |
| 172 | + let done = 0; |
| 173 | + |
| 174 | + // Process with limited concurrency |
| 175 | + const queue = [...items]; |
| 176 | + const workers = Array.from({ length: CONCURRENCY }, async () => { |
| 177 | + while (queue.length > 0) { |
| 178 | + const item = queue.shift(); |
| 179 | + const resp = await queryAPI(item.question); |
| 180 | + // Small delay to avoid overwhelming server |
| 181 | + await sleep(200); |
| 182 | + const em = exactMatch(resp.answer, item.gold_answers); |
| 183 | + const f1 = f1Score(resp.answer, item.gold_answers); |
| 184 | + results.push({ |
| 185 | + question: item.question, |
| 186 | + gold_answers: item.gold_answers, |
| 187 | + predicted: resp.answer, |
| 188 | + exact_match: em, |
| 189 | + f1, |
| 190 | + confidence: resp.confidence, |
| 191 | + serverTimeMs: resp.timeMs, |
| 192 | + clientLatencyMs: resp.clientLatency || resp.timeMs, |
| 193 | + error: resp.error || null, |
| 194 | + }); |
| 195 | + |
| 196 | + // RLHF: if answer was wrong, teach the correct answer back to KB |
| 197 | + // This is the self-evolution loop — the benchmark trains the engine |
| 198 | + if (em === 0 && f1 < 0.5 && item.gold_answers[0] && !resp.error) { |
| 199 | + const learnUrl = API_URL.replace('/api/saqt/query', '/api/saqt/learn'); |
| 200 | + fetch(learnUrl, { |
| 201 | + method: 'POST', |
| 202 | + headers: { 'Content-Type': 'application/json' }, |
| 203 | + body: JSON.stringify({ |
| 204 | + question: item.question, |
| 205 | + answer: item.gold_answers[0], |
| 206 | + source: 'benchmark-rlhf', |
| 207 | + weight: 2.0, |
| 208 | + }), |
| 209 | + }).catch(() => {}); // fire-and-forget |
| 210 | + } |
| 211 | + done++; |
| 212 | + if (done % 20 === 0 || done === items.length) { |
| 213 | + process.stderr.write(` [${label}] ${done}/${items.length}\n`); |
| 214 | + } |
| 215 | + } |
| 216 | + }); |
| 217 | + |
| 218 | + await Promise.all(workers); |
| 219 | + return results; |
| 220 | +} |
| 221 | + |
| 222 | +function summarize(results) { |
| 223 | + const n = results.length; |
| 224 | + const errors = results.filter(r => r.error).length; |
| 225 | + const valid = results.filter(r => !r.error); |
| 226 | + const em = valid.reduce((s, r) => s + r.exact_match, 0) / (valid.length || 1); |
| 227 | + const f1 = valid.reduce((s, r) => s + r.f1, 0) / (valid.length || 1); |
| 228 | + const avgLatency = valid.reduce((s, r) => s + r.clientLatencyMs, 0) / (valid.length || 1); |
| 229 | + const p50Latency = percentile(valid.map(r => r.clientLatencyMs).sort((a, b) => a - b), 0.5); |
| 230 | + const p95Latency = percentile(valid.map(r => r.clientLatencyMs).sort((a, b) => a - b), 0.95); |
| 231 | + const avgConf = valid.reduce((s, r) => s + r.confidence, 0) / (valid.length || 1); |
| 232 | + return { n, errors, exactMatch: em, f1, avgLatency, p50Latency, p95Latency, avgConf }; |
| 233 | +} |
| 234 | + |
| 235 | +function percentile(sorted, p) { |
| 236 | + if (sorted.length === 0) return 0; |
| 237 | + const i = Math.floor(sorted.length * p); |
| 238 | + return sorted[Math.min(i, sorted.length - 1)]; |
| 239 | +} |
| 240 | + |
| 241 | +// --------------- main --------------- |
| 242 | + |
| 243 | +async function main() { |
| 244 | + console.log(`SAQT Benchmark — ${SAMPLES_PER_DATASET} questions/dataset, API: ${API_URL}`); |
| 245 | + console.log(`Concurrency: ${CONCURRENCY}, Timeout: ${TIMEOUT_MS}ms\n`); |
| 246 | + |
| 247 | + // Fetch datasets |
| 248 | + console.log('Fetching datasets from HuggingFace...'); |
| 249 | + const [nq, tqa, hpqa] = await Promise.all([ |
| 250 | + fetchNaturalQuestions(SAMPLES_PER_DATASET).catch(e => { console.error('NQ fetch failed:', e.message); return []; }), |
| 251 | + fetchTriviaQA(SAMPLES_PER_DATASET).catch(e => { console.error('TriviaQA fetch failed:', e.message); return []; }), |
| 252 | + fetchHotPotQA(SAMPLES_PER_DATASET).catch(e => { console.error('HotPotQA fetch failed:', e.message); return []; }), |
| 253 | + ]); |
| 254 | + |
| 255 | + console.log(` NaturalQuestions: ${nq.length} questions`); |
| 256 | + console.log(` TriviaQA: ${tqa.length} questions`); |
| 257 | + console.log(` HotPotQA: ${hpqa.length} questions\n`); |
| 258 | + |
| 259 | + if (nq.length + tqa.length + hpqa.length === 0) { |
| 260 | + console.error('No questions fetched. Aborting.'); |
| 261 | + process.exit(1); |
| 262 | + } |
| 263 | + |
| 264 | + // Run benchmarks |
| 265 | + const datasets = [ |
| 266 | + { name: 'NaturalQuestions', items: nq }, |
| 267 | + { name: 'TriviaQA', items: tqa }, |
| 268 | + { name: 'HotPotQA', items: hpqa }, |
| 269 | + ]; |
| 270 | + |
| 271 | + const allResults = {}; |
| 272 | + const allSummaries = {}; |
| 273 | + |
| 274 | + for (const ds of datasets) { |
| 275 | + if (ds.items.length === 0) continue; |
| 276 | + console.log(`Running ${ds.name}...`); |
| 277 | + const results = await runBatch(ds.items, ds.name); |
| 278 | + allResults[ds.name] = results; |
| 279 | + allSummaries[ds.name] = summarize(results); |
| 280 | + } |
| 281 | + |
| 282 | + // Overall summary |
| 283 | + const allFlat = Object.values(allResults).flat(); |
| 284 | + allSummaries['OVERALL'] = summarize(allFlat); |
| 285 | + |
| 286 | + // Print results |
| 287 | + console.log('\n' + '='.repeat(72)); |
| 288 | + console.log('SAQT BENCHMARK RESULTS'); |
| 289 | + console.log('='.repeat(72)); |
| 290 | + console.log(`Date: ${new Date().toISOString()}`); |
| 291 | + console.log(`API: ${API_URL}`); |
| 292 | + console.log(`Samples/dataset: ${SAMPLES_PER_DATASET}\n`); |
| 293 | + |
| 294 | + const header = 'Dataset'.padEnd(22) + |
| 295 | + 'N'.padStart(5) + |
| 296 | + 'EM'.padStart(8) + |
| 297 | + 'F1'.padStart(8) + |
| 298 | + 'AvgMs'.padStart(8) + |
| 299 | + 'P50'.padStart(8) + |
| 300 | + 'P95'.padStart(8) + |
| 301 | + 'Conf'.padStart(8) + |
| 302 | + 'Err'.padStart(5); |
| 303 | + console.log(header); |
| 304 | + console.log('-'.repeat(72)); |
| 305 | + |
| 306 | + for (const [name, s] of Object.entries(allSummaries)) { |
| 307 | + const row = name.padEnd(22) + |
| 308 | + String(s.n).padStart(5) + |
| 309 | + (s.exactMatch * 100).toFixed(1).padStart(7) + '%' + |
| 310 | + (s.f1 * 100).toFixed(1).padStart(7) + '%' + |
| 311 | + Math.round(s.avgLatency).toString().padStart(8) + |
| 312 | + Math.round(s.p50Latency).toString().padStart(8) + |
| 313 | + Math.round(s.p95Latency).toString().padStart(8) + |
| 314 | + s.avgConf.toFixed(2).padStart(8) + |
| 315 | + String(s.errors).padStart(5); |
| 316 | + console.log(row); |
| 317 | + } |
| 318 | + console.log('='.repeat(72)); |
| 319 | + |
| 320 | + // Show some example predictions |
| 321 | + console.log('\nSample predictions (first 5 per dataset):'); |
| 322 | + for (const [name, results] of Object.entries(allResults)) { |
| 323 | + console.log(`\n--- ${name} ---`); |
| 324 | + for (const r of results.slice(0, 5)) { |
| 325 | + const status = r.exact_match ? 'EM' : r.f1 > 0 ? `F1=${(r.f1*100).toFixed(0)}%` : 'MISS'; |
| 326 | + console.log(` Q: ${r.question.slice(0, 80)}`); |
| 327 | + console.log(` Gold: ${r.gold_answers[0]?.slice(0, 60) || '(none)'}`); |
| 328 | + console.log(` Pred: ${r.predicted.slice(0, 60) || '(empty)'}`); |
| 329 | + console.log(` [${status}] ${r.clientLatencyMs}ms`); |
| 330 | + } |
| 331 | + } |
| 332 | + |
| 333 | + // Save results |
| 334 | + if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); |
| 335 | + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); |
| 336 | + const outFile = join(OUTPUT_DIR, `saqt-benchmark-${timestamp}.json`); |
| 337 | + |
| 338 | + const output = { |
| 339 | + meta: { |
| 340 | + date: new Date().toISOString(), |
| 341 | + api: API_URL, |
| 342 | + samplesPerDataset: SAMPLES_PER_DATASET, |
| 343 | + concurrency: CONCURRENCY, |
| 344 | + timeoutMs: TIMEOUT_MS, |
| 345 | + }, |
| 346 | + summaries: allSummaries, |
| 347 | + results: allResults, |
| 348 | + }; |
| 349 | + |
| 350 | + writeFileSync(outFile, JSON.stringify(output, null, 2)); |
| 351 | + console.log(`\nResults saved to: ${outFile}`); |
| 352 | + |
| 353 | + // Also save a human-readable summary |
| 354 | + const summaryFile = join(OUTPUT_DIR, `saqt-benchmark-${timestamp}-summary.txt`); |
| 355 | + const lines = [ |
| 356 | + 'SAQT Benchmark Summary', |
| 357 | + `Date: ${new Date().toISOString()}`, |
| 358 | + `API: ${API_URL}`, |
| 359 | + `Samples/dataset: ${SAMPLES_PER_DATASET}`, |
| 360 | + '', |
| 361 | + header, |
| 362 | + '-'.repeat(72), |
| 363 | + ]; |
| 364 | + for (const [name, s] of Object.entries(allSummaries)) { |
| 365 | + lines.push( |
| 366 | + name.padEnd(22) + |
| 367 | + String(s.n).padStart(5) + |
| 368 | + (s.exactMatch * 100).toFixed(1).padStart(7) + '%' + |
| 369 | + (s.f1 * 100).toFixed(1).padStart(7) + '%' + |
| 370 | + Math.round(s.avgLatency).toString().padStart(8) + |
| 371 | + Math.round(s.p50Latency).toString().padStart(8) + |
| 372 | + Math.round(s.p95Latency).toString().padStart(8) + |
| 373 | + s.avgConf.toFixed(2).padStart(8) + |
| 374 | + String(s.errors).padStart(5) |
| 375 | + ); |
| 376 | + } |
| 377 | + writeFileSync(summaryFile, lines.join('\n') + '\n'); |
| 378 | + console.log(`Summary saved to: ${summaryFile}`); |
| 379 | +} |
| 380 | + |
| 381 | +main().catch(e => { console.error('FATAL:', e); process.exit(1); }); |
0 commit comments