Files
waggle-os/benchmarks/harness/scripts/_merge-pilot.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

295 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ANSWER-MERGE self-ensembling pilot (BEAM 1M).
*
* Hypothesis: BEAM per-question scores have high re-run variance and compound
* rubric nuggets reward content UNION. Merging two INDEPENDENT answers to the
* same question (same v2 config) into one, then re-judging, may beat the single
* headline answer — for free at inference time when the two answers already
* exist.
*
* PAIR SOURCES (all in benchmarks/results/beam/):
* - HEADLINE (canonical): beam-1m-FULL700-gpt5-retv2.jsonl, first row per
* instance_id → 700 canonical answers+scores+rubric.
* - ALT (independent 2nd answer under the same config):
* (a) beam-1m-FULL700-gpt5-retv2.backup.jsonl — for ids with 2+ rows whose
* answer TEXTS DIFFER, the LATER row is an independent re-answer.
* (b) beam-1m-cal-gpt5-retrieval-v2.jsonl — 50 questions answered again
* under the same config.
* A pair is kept iff both answers are non-empty, materially differ (normalized
* inequality), and are not BOTH the exact IDK sentence.
*
* MERGE (gpt-5) → JUDGE merged answer with the official nugget judge (gpt-5),
* computeTau:false so we compare the merged plain nugget-mean against the
* headline plain nugget-mean `score` (apples-to-apples; tau is a non-headline
* diagnostic and adds many costly calls).
*
* Budget-guarded ($8 hard stop). Pair order is deterministically shuffled
* (seed 42) so a partial run is still a fair sample. Resumable: already-written
* instance_ids are skipped.
*
* Run: npx tsx benchmarks/harness/scripts/_merge-pilot.ts [--budget 8]
*/
import fs from 'node:fs';
import path from 'node:path';
import { createBeamOpenAiClient, OPENAI_PRICING } from '../src/beam-openai-client.js';
import { judgeQuestion } from '../src/beam-nugget-judge.js';
const RESULTS_DIR = path.resolve('benchmarks/results/beam');
const HEADLINE = path.join(RESULTS_DIR, 'beam-1m-FULL700-gpt5-retv2.jsonl');
const BACKUP = path.join(RESULTS_DIR, 'beam-1m-FULL700-gpt5-retv2.backup.jsonl');
const CAL = path.join(RESULTS_DIR, 'beam-1m-cal-gpt5-retrieval-v2.jsonl');
const OUT = path.join(RESULTS_DIR, 'beam-1m-merge-pilot.jsonl');
const MERGE_SYSTEM =
'You are combining two draft answers to the same question, both produced from ' +
'the same memory context. Produce ONE final answer that includes ALL specific, ' +
'non-contradictory content from both drafts (names, dates, numbers, versions, ' +
'events, causes, outcomes), organized clearly, with no meta-commentary about ' +
'drafts. If the drafts disagree on a fact, state the contradiction explicitly ' +
'and present both values. If BOTH drafts say there is not enough information, ' +
"output exactly: I don't have enough information to answer this question. " +
"If only one draft has substantive content, use that draft's content.";
interface BeamRow {
instance_id: string;
conv?: number;
memory_ability: string;
question: string;
answer: string;
score: number;
nugget_scores: Array<{ nugget: string; score: number; reason: string }>;
}
function readJsonl(p: string): BeamRow[] {
return fs
.readFileSync(p, 'utf-8')
.split('\n')
.filter(l => l.trim())
.map(l => {
try {
return JSON.parse(l) as BeamRow;
} catch {
return null;
}
})
.filter((r): r is BeamRow => r !== null);
}
const norm = (t: string): string =>
(t ?? '')
.replace(/[]/g, "'")
.replace(/[“”]/g, '"')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const IDK = "i don't have enough information to answer this question";
const isExactIDK = (t: string): boolean => {
const n = norm(t);
return n === IDK || n === `${IDK}.`;
};
const looksIDK = (t: string): boolean => norm(t).startsWith(IDK);
const isEmpty = (t: string): boolean => !t || !t.trim();
/** Deterministic PRNG (mulberry32) + Fisher-Yates for a seed-42 shuffle. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle<T>(arr: T[], seed: number): T[] {
const rnd = mulberry32(seed);
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
interface Pair {
instanceId: string;
memoryAbility: string;
question: string;
headline: BeamRow;
alt: BeamRow;
altSource: 'backup' | 'cal';
}
function buildPairs(): Pair[] {
const headline = readJsonl(HEADLINE);
const headMap = new Map<string, BeamRow>();
for (const r of headline) if (!headMap.has(r.instance_id)) headMap.set(r.instance_id, r);
const usable = (head: BeamRow, alt: BeamRow): boolean => {
if (isEmpty(alt.answer) || isEmpty(head.answer)) return false;
if (norm(alt.answer) === norm(head.answer)) return false; // not materially different
if (isExactIDK(alt.answer) && isExactIDK(head.answer)) return false;
return true;
};
// (a) backup: later row among 2+ distinct-answer rows.
const backup = readJsonl(BACKUP);
const byId = new Map<string, BeamRow[]>();
for (const r of backup) {
if (!byId.has(r.instance_id)) byId.set(r.instance_id, []);
byId.get(r.instance_id)!.push(r);
}
const pairs = new Map<string, Pair>();
for (const [id, rows] of byId) {
if (rows.length < 2) continue;
if (new Set(rows.map(r => norm(r.answer))).size < 2) continue; // all identical
const head = headMap.get(id);
if (!head) continue;
const alt = rows[rows.length - 1];
if (!usable(head, alt)) continue;
pairs.set(id, {
instanceId: id,
memoryAbility: head.memory_ability,
question: head.question,
headline: head,
alt,
altSource: 'backup',
});
}
// (b) cal: independent re-answers; add ids not already covered by backup.
const cal = readJsonl(CAL);
const calMap = new Map<string, BeamRow>();
for (const r of cal) if (!calMap.has(r.instance_id)) calMap.set(r.instance_id, r);
for (const [id, alt] of calMap) {
if (pairs.has(id)) continue;
const head = headMap.get(id);
if (!head) continue;
if (!usable(head, alt)) continue;
pairs.set(id, {
instanceId: id,
memoryAbility: head.memory_ability,
question: head.question,
headline: head,
alt,
altSource: 'cal',
});
}
// Canonical sort (by instance_id) then deterministic seed-42 shuffle.
const sorted = [...pairs.values()].sort((a, b) => a.instanceId.localeCompare(b.instanceId));
return shuffle(sorted, 42);
}
function mergeUser(question: string, draftA: string, draftB: string): string {
return `QUESTION:\n${question}\n\nDRAFT A:\n${draftA}\n\nDRAFT B:\n${draftB}`;
}
async function main(): Promise<void> {
const budgetArg = process.argv.indexOf('--budget');
const BUDGET = budgetArg >= 0 ? Number(process.argv[budgetArg + 1]) : 8;
const client = createBeamOpenAiClient({ model: 'gpt-5', pricing: OPENAI_PRICING['gpt-5'] });
const allPairs = buildPairs();
// Resume: skip instance_ids already written.
const done = new Set<string>();
if (fs.existsSync(OUT)) {
for (const line of fs.readFileSync(OUT, 'utf-8').split('\n')) {
if (!line.trim()) continue;
try {
done.add((JSON.parse(line) as { instance_id: string }).instance_id);
} catch {
/* skip */
}
}
}
const abilityDist: Record<string, number> = {};
for (const p of allPairs) abilityDist[p.memoryAbility] = (abilityDist[p.memoryAbility] ?? 0) + 1;
console.log(`[merge-pilot] ${allPairs.length} pairs total | already done: ${done.size} | budget $${BUDGET}`);
console.log(`[merge-pilot] ability dist: ${JSON.stringify(abilityDist)}`);
const outStream = fs.createWriteStream(OUT, { flags: 'a' });
let spend = 0;
let processed = 0;
let mergeFails = 0;
for (const pair of allPairs) {
if (spend >= BUDGET) {
console.warn(`[merge-pilot] budget $${BUDGET} reached — stopping (spend=$${spend.toFixed(3)})`);
break;
}
if (done.has(pair.instanceId)) continue;
const rubric = pair.headline.nugget_scores.map(n => n.nugget);
if (rubric.length === 0) continue;
// ── MERGE (gpt-5) ──
const mergeRes = await client.chat({
system: MERGE_SYSTEM,
user: mergeUser(pair.question, pair.headline.answer, pair.alt.answer),
maxTokens: 2000,
});
spend += mergeRes.costUsd;
if (mergeRes.failureMode || isEmpty(mergeRes.text)) {
mergeFails++;
console.warn(` [${pair.instanceId}] merge failed (${mergeRes.failureMode ?? 'empty'}) — skipping`);
continue;
}
const merged = mergeRes.text.trim();
// ── JUDGE merged answer (gpt-5, plain nugget-mean) ──
const { judgement, llmResults } = await judgeQuestion(
client,
{ question: pair.question, rubric, memoryAbility: pair.memoryAbility, answer: merged },
{ computeTau: false },
);
const judgeCost = llmResults.reduce((s, r) => s + r.costUsd, 0);
spend += judgeCost;
const headlineNug = pair.headline.nugget_scores.map(n => n.score);
const mergedNug = judgement.nuggetScores.map(n => n.score);
const row = {
instance_id: pair.instanceId,
memory_ability: pair.memoryAbility,
alt_source: pair.altSource,
question: pair.question,
score_headline: pair.headline.score,
score_alt: pair.alt.score,
score_merged: judgement.score,
nuggets: rubric,
headline_nugget_scores: headlineNug,
merged_nugget_scores: mergedNug,
headline_idk: looksIDK(pair.headline.answer),
alt_idk: looksIDK(pair.alt.answer),
merged_idk: looksIDK(merged),
answer_merged: merged,
merge_cost_usd: round4(mergeRes.costUsd),
judge_cost_usd: round4(judgeCost),
};
outStream.write(`${JSON.stringify(row)}\n`);
processed++;
const delta = pair.headline.score === judgement.score ? 'TIE' : judgement.score > pair.headline.score ? 'WIN' : 'LOSS';
process.stdout.write(
` [${pair.memoryAbility.padEnd(24)}] H=${pair.headline.score.toFixed(2)} A=${pair.alt.score.toFixed(2)} M=${judgement.score.toFixed(2)} ${delta} $${spend.toFixed(3)}\n`,
);
}
outStream.end();
console.log(`\n[merge-pilot] DONE. processed=${processed} mergeFails=${mergeFails} spend=$${spend.toFixed(3)}${OUT}`);
}
function round4(x: number): number {
return Math.round(x * 1e4) / 1e4;
}
main().catch(err => {
console.error(err);
process.exit(1);
});