Skip to content

Commit 3b56f89

Browse files
tejasphatakNexus
andcommitted
feat: benchmark as RLHF training loop
- Context prompt: tells engine it's being evaluated for accuracy - RLHF: wrong answers teach gold answer back to KB (weight 2.0) - Each benchmark run trains the engine for the next run - The benchmark IS the self-evolution experiment Co-Authored-By: Nexus <synapse@webmind.sh>
1 parent f80375c commit 3b56f89

1 file changed

Lines changed: 381 additions & 0 deletions

File tree

synapse-src/saqt/benchmark.mjs

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)