#!/usr/bin/env tsx /** * BEAM 1M — answer + graded-judge runner for the two memory cells. * * --cell retrieval : HybridSearch top-k over the per-conversation mind, * answer with gpt-4o (mem0 answer prompt), nugget judge. * --cell hive_mind_ipb : same retrieval, plus our structured I/P/B frame * writes + a lightweight contradiction check, run on a * COPY of the mind so the base ingest stays pristine * and the cell is re-runnable. * * PROTOCOL: gpt-4o answerer + validated nugget judge (beam-nugget-judge.ts) → * Avg Score (micro) + Pass Rate (>=0.5), overall + per-ability. This is the * comparable-to-mem0 pipeline. Retrieval reads the resumable minds built by * beam-ingest-1m.ts. * * RESUMABLE: results are appended to a per-cell, per-prompt JSONL as each * question finishes (default name carries the `prompt` variant so a v1 and a v2 * run of the same cell/top-k never collide); on restart, instance_ids already * present are skipped, and a run refuses to append to a file whose rows carry a * different `prompt` tag. Cost is hard-capped (--budget) with a budgetStopped * guard (same as beam-smoke.ts). * * COST-SAFE PREP: `--estimate` runs retrieval ONLY (no gpt-4o spend) over the * already-ingested minds, measures the real assembled-prompt token size at the * chosen top-k, and projects full-700 answer + judge cost. Use this to choose * top-k before firing a paid run. `--estimate` needs at least one ingested mind. * * Questions + rubric are read directly from the BEAM repo probing_questions.json * (fast; identical instance_id scheme to the canonical), avoiding a 3 GB parse. * * Usage: * # cost estimate (no spend), using whatever convs are already ingested: * tsx benchmarks/harness/scripts/beam-run-1m.ts --cell retrieval --top-k 30 --estimate * # real run (STOP-gated — only on explicit go): * tsx benchmarks/harness/scripts/beam-run-1m.ts --cell hive_mind_ipb --top-k 30 \ * --budget 50 --resume */ import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; import process from 'node:process'; import { createOllamaEmbedder } from '@waggle/core'; import type { SearchResult, MemoryFrame } from '@waggle/core'; import { createSubstrate } from '../src/substrate.js'; import type { Substrate } from '../src/substrate.js'; import { createBeamOpenAiClient, OPENAI_PRICING } from '../src/beam-openai-client.js'; import type { BeamOpenAiClient } from '../src/beam-openai-client.js'; import { buildAnswerGenerationPrompt, buildAnswerGenerationPromptV2, buildAnswerGenerationPromptV3, buildAnswerGenerationPromptV4, buildAnswerGenerationPromptV5, judgeQuestion } from '../src/beam-nugget-judge.js'; import type { BeamLlmResult } from '../src/beam-nugget-judge.js'; import { buildConvDateMap, renderMemories, computeDateHitRate } from '../src/beam-date-map.js'; import { mergeHybrid } from '../src/beam-hybrid.js'; import type { HybridMerge } from '../src/beam-hybrid.js'; import { computeBeamMetrics, formatBeamMetrics } from '../src/beam-metrics.js'; import type { BeamQuestionResult } from '../src/beam-metrics.js'; const CATEGORY_ANSWER_FIELDS = [ 'answer', 'ideal_response', 'ideal_answer', 'expected_compliance', 'ideal_summary', ]; type Cell = 'retrieval' | 'hive_mind_ipb' | 'distill' | 'hybrid'; interface Question { instanceId: string; conv: number; gopId: string; memoryAbility: string; question: string; rubric: string[]; expected: string; } interface Args { cell: Cell; topK: number; /** hybrid only: raw turns retrieved from minds-1M (detail). */ kRaw: number; /** hybrid only: distilled facts retrieved from minds-1M-obs (coverage). */ kFact: number; model: string; budget: number; prompt: 'v1' | 'v2' | 'v3' | 'v4' | 'v5'; /** when set, restrict to questions whose memory_ability is in this list. */ abilities: string[] | null; outlineDir: string | null; directivesDir: string | null; shapeRoute: boolean; promptExplicit: boolean; estimate: boolean; resume: boolean; convs: number[]; beamChats: string; mindsDir: string; mindsDirExplicit: boolean; /** hybrid only: fixed raw-turn + distilled-fact mind dirs (no mindsDir switch). */ rawMindsDir: string; obsMindsDir: string; computeTau: boolean; perAbility?: number; outPath?: string; /** gold-blind routing: classify each question and dispatch per the route table. */ route: boolean; /** path to the predicted-ability → {cell,prompt,k...} route table json. */ routeTablePath: string; /** classifier-only mode: predict abilities + report accuracy, no answer/judge. */ classifyOnly: boolean; } function parseConvSpec(spec: string): number[] { const out = new Set(); for (const part of spec.split(',')) { const m = part.match(/^(\d+)-(\d+)$/); if (m) { for (let i = +m[1]; i <= +m[2]; i++) out.add(i); } else if (/^\d+$/.test(part.trim())) out.add(+part.trim()); } return [...out].sort((a, b) => a - b); } function parseArgs(): Args { const argv = process.argv.slice(2); const here = url.fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(here), '..', '..', '..'); const a: Args = { cell: 'retrieval', topK: 30, kRaw: 15, kFact: 60, model: 'gpt-4o', budget: 50, prompt: 'v1', abilities: null, outlineDir: null, directivesDir: null, shapeRoute: false, promptExplicit: false, estimate: false, resume: false, convs: parseConvSpec('1-35'), beamChats: path.resolve(repoRoot, '..', 'BEAM', 'chats'), mindsDir: path.join(repoRoot, 'benchmarks', 'data', 'beam', 'minds-1M'), mindsDirExplicit: false, rawMindsDir: path.join(repoRoot, 'benchmarks', 'data', 'beam', 'minds-1M'), obsMindsDir: path.join(repoRoot, 'benchmarks', 'data', 'beam', 'minds-1M-obs'), computeTau: false, route: false, routeTablePath: path.join(repoRoot, 'benchmarks', 'results', 'beam', 'route-table-v1.json'), classifyOnly: false, }; for (let i = 0; i < argv.length; i++) { const f = argv[i]; const next = argv[i + 1]; if (f === '--cell' && next) { a.cell = next as Cell; i++; } else if (f === '--top-k' && next) { a.topK = parseInt(next, 10); i++; } else if (f === '--k-raw' && next) { a.kRaw = parseInt(next, 10); i++; } else if (f === '--k-fact' && next) { a.kFact = parseInt(next, 10); i++; } else if (f === '--model' && next) { a.model = next; i++; } else if (f === '--budget' && next) { a.budget = parseFloat(next); i++; } else if (f === '--prompt' && next) { const v = next.toLowerCase(); if (v !== 'v1' && v !== 'v2' && v !== 'v3' && v !== 'v4' && v !== 'v5') { console.error(`[beam-run-1m] --prompt must be v1, v2, v3, v4 or v5, got "${next}"`); process.exit(2); } a.prompt = v; a.promptExplicit = true; i++; } else if (f === '--abilities' && next) { a.abilities = next.split(',').map(s => s.trim()).filter(Boolean); i++; } else if (f === '--outline') { // Optional value: a directory of beam_1M_.outline.json files. if (next && !next.startsWith('--')) { a.outlineDir = path.resolve(next); i++; } else { a.outlineDir = path.join(repoRoot, 'benchmarks', 'data', 'beam', 'outlines-1M'); } } else if (f === '--directives') { if (next && !next.startsWith('--')) { a.directivesDir = path.resolve(next); i++; } else { a.directivesDir = path.join(repoRoot, 'benchmarks', 'data', 'beam', 'directives-1M'); } } else if (f === '--shape-route') { a.shapeRoute = true; } else if (f === '--estimate') { a.estimate = true; } else if (f === '--route') { a.route = true; } else if (f === '--route-table' && next) { a.routeTablePath = path.resolve(next); i++; } else if (f === '--classify-only') { a.classifyOnly = true; } else if (f === '--resume') { a.resume = true; } else if (f === '--tau') { a.computeTau = true; } else if (f === '--per-ability' && next) { a.perAbility = parseInt(next, 10); i++; } else if (f === '--convs' && next) { a.convs = parseConvSpec(next); i++; } else if (f === '--minds-dir' && next) { a.mindsDir = path.resolve(next); a.mindsDirExplicit = true; i++; } else if (f === '--beam-chats' && next) { a.beamChats = path.resolve(next); i++; } else if (f === '--out' && next) { a.outPath = path.resolve(next); i++; } } // The distill cell reads the observation (facts) minds unless overridden. if (a.cell === 'distill' && !a.mindsDirExplicit) { a.mindsDir = path.join(repoRoot, 'benchmarks', 'data', 'beam', 'minds-1M-obs'); } // Hybrid merges dated raw turns + dated facts and renders them chronologically: // v1 (undated) makes no sense here. Error if v1 was asked for explicitly; // otherwise default hybrid to v2. Hybrid always opens BOTH mind sets directly // (rawMindsDir + obsMindsDir), never via mindsDir switching. if (a.cell === 'hybrid') { if (a.promptExplicit && a.prompt === 'v1') { console.error('[beam-run-1m] --cell hybrid requires --prompt v2 (undated hybrid makes no sense); drop --prompt v1.'); process.exit(2); } a.prompt = 'v2'; } return a; } /** Deterministically take the first `perAbility` questions per memory_ability, * in instance_id sort order — reproduces the no-context smoke's 50-instance * sample when convs 1/10/11 are present, for a matched per-ability comparison. */ function sampleByAbility(questions: Question[], perAbility: number): Set { const sorted = [...questions].sort((a, b) => a.instanceId.localeCompare(b.instanceId)); const counts = new Map(); const keep = new Set(); for (const q of sorted) { const n = counts.get(q.memoryAbility) ?? 0; if (n < perAbility) { keep.add(q.instanceId); counts.set(q.memoryAbility, n + 1); } } return keep; } // ── Question loading (from BEAM probing_questions.json) ────────────────────── function normaliseAnswer(pq: Record): string { for (const k of CATEGORY_ANSWER_FIELDS) { if (typeof pq[k] === 'string' && pq[k]) return pq[k] as string; } const rub = extractRubric(pq); return rub.join(' | '); } function extractRubric(pq: Record): string[] { const raw = pq.rubric; if (Array.isArray(raw)) return raw.map(String).map(s => s.trim()).filter(Boolean); if (raw && typeof raw === 'object') { const n = (raw as Record).nuggets; if (Array.isArray(n)) return n.map(String).map(s => s.trim()).filter(Boolean); } if (raw) return [String(raw).trim()]; return []; } function loadConvQuestions(beamChats: string, conv: number): Question[] { const pqPath = path.join(beamChats, '1M', String(conv), 'probing_questions', 'probing_questions.json'); if (!fs.existsSync(pqPath)) return []; const data = JSON.parse(fs.readFileSync(pqPath, 'utf-8')) as Record[]>; const out: Question[] = []; for (const [category, questions] of Object.entries(data)) { if (!Array.isArray(questions)) continue; questions.forEach((pq, qi) => { const q = typeof pq.question === 'string' ? pq.question : ''; if (!q) return; out.push({ instanceId: `beam_1M_${conv}_${category}_q${qi}`, conv, gopId: `beam_${conv}`, memoryAbility: category, question: q, rubric: extractRubric(pq), expected: normaliseAnswer(pq), }); }); } return out; } // ── Mind helpers ───────────────────────────────────────────────────────────── function mindPath(mindsDir: string, conv: number): string { return path.join(mindsDir, `beam_1M_${conv}.mind`); } function isIngested(mindsDir: string, conv: number): boolean { return fs.existsSync(path.join(mindsDir, `beam_1M_${conv}.done.json`)) && fs.existsSync(mindPath(mindsDir, conv)); } /** Copy a mind (+ WAL/SHM sidecars) to a scratch path for mutating ipb runs. */ function copyMind(src: string, dst: string): void { for (const suffix of ['', '-wal', '-shm']) { const s = src + suffix; if (fs.existsSync(s)) fs.copyFileSync(s, dst + suffix); } } /** Sort retrieved results oldest-first (chronological proxy = frame id asc), * mirroring mem0's created_at sort before answer generation. */ function memoriesFromResults(results: readonly SearchResult[]): string[] { return [...results] .sort((a, b) => a.frame.id - b.frame.id) .map(r => r.frame.content); } function approxTokens(s: string): number { return Math.max(1, Math.ceil(s.length / 4)); } function chatJsonPath(beamChats: string, conv: number): string { return path.join(beamChats, '1M', String(conv), 'chat.json'); } /** Build the answer-generation prompt for the selected variant. v1 leaves the * memories untouched (byte-identical to the original); v2 date-stamps them and * uses the Option-A prompt. */ function buildAnswerPrompt( args: Args, question: string, memories: string[], dateMap: Map | null, outline?: string | null, ): string { // v3/v4 date-stamp exactly like v2; renderMemories only distinguishes v1. const display = renderMemories(memories, dateMap, args.prompt === 'v1' ? 'v1' : 'v2'); if (args.prompt === 'v5') return buildAnswerGenerationPromptV5(question, display, outline ?? undefined); if (args.prompt === 'v4') return buildAnswerGenerationPromptV4(question, display, outline ?? undefined); if (args.prompt === 'v3') return buildAnswerGenerationPromptV3(question, display, outline ?? undefined); if (args.prompt === 'v2') return buildAnswerGenerationPromptV2(question, display, outline ?? undefined); return buildAnswerGenerationPrompt(question, display); } /** Load the per-conv conversation outline (session synopses built by * beam-build-outlines.ts) and render it as the prompt's timeline block. * Returns null when --outline is off or the file is missing (logged once). */ function loadOutline(args: Args, conv: number): string | null { if (!args.outlineDir) return null; const p = path.join(args.outlineDir, `beam_1M_${conv}.outline.json`); if (!fs.existsSync(p)) { console.warn(`[run][conv ${conv}] --outline set but ${p} missing — continuing without timeline`); return null; } const data = JSON.parse(fs.readFileSync(p, 'utf-8')) as { sessions: Array<{ date: string; synopsis: string }> }; return data.sessions.map(s => `[${s.date}]\n${s.synopsis}`).join('\n'); } /** Load the per-conv standing directives (built by beam-build-directives.ts). * Small verbatim dated preference/instruction lines — the "personal mind" lane. */ function loadDirectives(args: Args, conv: number): string | null { if (!args.directivesDir) return null; const p = path.join(args.directivesDir, `beam_1M_${conv}.json`); if (!fs.existsSync(p)) { console.warn(`[run][conv ${conv}] --directives set but ${p} missing — continuing without directives`); return null; } const data = JSON.parse(fs.readFileSync(p, 'utf-8')) as { directives: Array<{ date: string; text: string }> }; if (!data.directives.length) return null; return data.directives.map(d => `[${d.date}] ${d.text}`).join('\n'); } /** Compose the labeled prompt preamble from the enabled lanes. */ function buildPreamble(directives: string | null, outline: string | null): string | null { const blocks: string[] = []; if (directives) { blocks.push( `USER'S STANDING PREFERENCES AND INSTRUCTIONS (verbatim, dated — honour these in every answer; ` + `when two conflict, the most recent wins):\n${directives}`, ); } if (outline) { blocks.push( `CONVERSATION TIMELINE (one synopsis per session, oldest first — use for overview, ordering, and coverage; ` + `the retrieved memories below carry the exact details):\n${outline}`, ); } return blocks.length ? blocks.join('\n\n') : null; } /** Coverage-shaped retrieval for summarization / event_ordering questions: * search wide, then stratify (≤2 turns per date) and order chronologically — * raw verbatim turns arranged for breadth/arc instead of similarity density. */ async function fetchCoverageShaped( substrate: Substrate, q: Question, dateMap: Map | null, wide: number, cap: number, ): Promise { const results = await substrate.search.search(q.question, { limit: wide, gopId: q.gopId }); const dated = results.map(r => ({ date: dateMap?.get(r.frame.content) ?? '', content: r.frame.content, id: r.frame.id, })); const perDate = new Map(); const kept: typeof dated = []; for (const e of dated) { const n = perDate.get(e.date) ?? 0; if (n >= 2) continue; perDate.set(e.date, n + 1); kept.push(e); if (kept.length >= cap) break; } kept.sort((a, b) => a.date === b.date ? a.id - b.id : a.date < b.date ? -1 : 1); return kept.map(e => e.content); } /** For v2 only: build the per-conv date map (raw-turn minds only — the distill * cell reads pre-dated distilled facts) and log the stamping hit-rate once. */ function prepareDateMap(args: Args, substrate: Substrate, conv: number): Map | null { if (args.prompt === 'v1') return null; const dateMap = args.cell === 'distill' ? new Map() : buildConvDateMap(chatJsonPath(args.beamChats, conv)); const contents = substrate.frames.getGopFrames(`beam_${conv}`).map(f => f.content); const { dated, total } = computeDateHitRate(contents, dateMap); const pct = total ? ((100 * dated) / total).toFixed(1) + '%' : 'n/a (pre-dated)'; console.log(` [conv ${conv}] v2 date-stamp: dated ${dated}/${total} frames (${pct}), map=${dateMap.size} entries`); return dateMap; } /** hybrid: retrieve kRaw raw turns + kFact distilled facts (two minds, ONE * embedder) and merge them into one chronologically-sorted, singly-dated list. */ async function fetchHybrid( args: Args, q: Question, rawSub: Substrate, obsSub: Substrate, dateMap: Map | null, ): Promise { const rawResults = await rawSub.search.search(q.question, { limit: args.kRaw, gopId: q.gopId }); const factResults = await obsSub.search.search(q.question, { limit: args.kFact, gopId: q.gopId }); return mergeHybrid(rawResults, factResults, dateMap); } // ── ipb frame writes (ported from cells-ipb.ts) ────────────────────────────── const SYSTEM_CONTRADICTION_CHECK = 'You are a consistency checker. You are given a list of prior prediction statements and a list ' + 'of retrieved memory excerpts. Respond with EXACTLY one line in the format: CONFLICT: OR NO_CONFLICT — no other text. A conflict exists only when a retrieved excerpt ' + 'directly contradicts a specific factual claim in a prior prediction (same entity, incompatible ' + 'values). Superficial overlap or topic similarity is NOT a conflict.'; function buildContradictionCheckPrompt(priors: readonly MemoryFrame[], retrieved: readonly SearchResult[]): string { const p = priors.map((f, i) => `[prior_${i + 1}] ${f.content}`).join('\n'); const r = retrieved.slice(0, 10).map((x, i) => `[retrieved_${i + 1}] ${x.frame.content}`).join('\n'); return `## Prior predictions\n${p}\n\n## Retrieved memories\n${r}\n\nDo any retrieved memories directly contradict any prior prediction? Respond with CONFLICT: or NO_CONFLICT.`; } // ── Estimate mode (no gpt-4o spend) ────────────────────────────────────────── /** Is a reasoning model (gpt-5 / o-series)? Reasoning tokens are billed as * output, so a hybrid cost projection must assume a bigger output budget. */ function isReasoningModel(model: string): boolean { return /^(gpt-5|o\d)/.test(model.toLowerCase()); } /** Hybrid estimate: open BOTH minds per conv, build the merged v2 answer prompt * for every question, measure its token size, and project full-700 answer + * judge cost using the SELECTED model's pricing. No LLM calls (retrieval is * local ollama; token sizing is char/4). */ async function runHybridEstimate(args: Args): Promise { const convs = args.convs.filter(c => isIngested(args.rawMindsDir, c) && isIngested(args.obsMindsDir, c)); if (convs.length === 0) { console.error('[beam-run-1m estimate] hybrid needs BOTH minds ingested (minds-1M + minds-1M-obs). Found none.'); process.exit(2); } console.log(`[estimate] cell=hybrid prompt=${args.prompt} kRaw=${args.kRaw} kFact=${args.kFact} model=${args.model} using ${convs.length} conv(s): ${convs.join(',')}`); const embedder = createOllamaEmbedder(); const promptToks: number[] = []; const mergedCounts: number[] = []; let rawDatedAll = 0, rawTotalAll = 0, nuggetTotal = 0, qCount = 0; for (const conv of convs) { const rawSub = createSubstrate({ dbPath: mindPath(args.rawMindsDir, conv), embedder }); const obsSub = createSubstrate({ dbPath: mindPath(args.obsMindsDir, conv), embedder }); try { const dateMap = buildConvDateMap(chatJsonPath(args.beamChats, conv)); const questions = loadConvQuestions(args.beamChats, conv); let cRawDated = 0, cRawTotal = 0, cMerged = 0; const cTok: number[] = []; for (const q of questions) { const merged = await fetchHybrid(args, q, rawSub, obsSub, dateMap); const prompt = buildAnswerGenerationPromptV2(q.question, merged.displayStrings); const t = approxTokens(prompt); promptToks.push(t); cTok.push(t); mergedCounts.push(merged.entries.length); cMerged += merged.entries.length; rawDatedAll += merged.rawDated; rawTotalAll += merged.rawTotal; cRawDated += merged.rawDated; cRawTotal += merged.rawTotal; nuggetTotal += q.rubric.length; qCount++; } const hit = cRawTotal ? ((100 * cRawDated) / cRawTotal).toFixed(1) + '%' : 'n/a'; const meanTok = cTok.length ? Math.round(cTok.reduce((s, x) => s + x, 0) / cTok.length) : 0; console.log(` [conv ${conv}] hybrid kRaw=${args.kRaw} kFact=${args.kFact} raw-date-hit=${hit} merged(mean)=${(cMerged / (questions.length || 1)).toFixed(1)} meanPromptTok=${meanTok}`); } finally { rawSub.close(); obsSub.close(); } } const mean = (xs: number[]): number => xs.reduce((s, x) => s + x, 0) / (xs.length || 1); const meanPromptTok = mean(promptToks); const meanNuggets = nuggetTotal / (qCount || 1); const meanMerged = mean(mergedCounts); const overallHit = rawTotalAll ? ((100 * rawDatedAll) / rawTotalAll).toFixed(1) + '%' : 'n/a'; // Project with the SELECTED model's pricing (answerer == judge == args.model). const pricing = OPENAI_PRICING[args.model] ?? OPENAI_PRICING['gpt-5']; const IN = pricing.inputPerMillion / 1e6, OUT = pricing.outputPerMillion / 1e6; const reasoning = isReasoningModel(args.model); const answerOutTok = reasoning ? 800 : 200; // gpt-5 spends hidden reasoning tokens (billed as output) const judgeBoilerplateTok = 750; // nugget judge prompt scaffold const judgeOutTok = reasoning ? 400 : 45; const perQAnswerUsd = meanPromptTok * IN + answerOutTok * OUT; // judge sees question + answer (~15% of the answer prompt) per nugget. const perQJudgeUsd = meanNuggets * ((judgeBoilerplateTok + meanPromptTok * 0.15) * IN + judgeOutTok * OUT); const perQ = perQAnswerUsd + perQJudgeUsd; const full700 = perQ * 700; console.log(`\n════════ COST ESTIMATE (hybrid, ${args.model}, 700 questions) ════════`); console.log(`sampled questions: ${qCount}`); console.log(`kRaw/kFact: ${args.kRaw}/${args.kFact}`); console.log(`overall raw-date hit-rate: ${overallHit}`); console.log(`mean merged entries: ${meanMerged.toFixed(1)} (raw+facts)`); console.log(`mean merged prompt tokens: ${meanPromptTok.toFixed(0)}`); console.log(`mean nuggets/question: ${meanNuggets.toFixed(2)}`); console.log(`pricing: $${pricing.inputPerMillion}/M in, $${pricing.outputPerMillion}/M out (answerOut≈${answerOutTok}, judgeOut≈${judgeOutTok})`); console.log(`per-question answer: $${perQAnswerUsd.toFixed(4)}`); console.log(`per-question judge: $${perQJudgeUsd.toFixed(4)}`); console.log(`per-question TOTAL: $${perQ.toFixed(4)}`); console.log(`──────────────────────────────────────────────────────`); console.log(`FULL 700 cell cost: $${full700.toFixed(2)}`); } async function runEstimate(args: Args): Promise { if (args.cell === 'hybrid') { await runHybridEstimate(args); return; } const ingestedConvs = args.convs.filter(c => isIngested(args.mindsDir, c)); if (ingestedConvs.length === 0) { console.error('[beam-run-1m estimate] no ingested minds found. Ingest at least one conversation first.'); process.exit(2); } console.log(`[estimate] cell=${args.cell} prompt=${args.prompt} top-k=${args.topK} using ${ingestedConvs.length} ingested conv(s): ${ingestedConvs.join(',')}`); const embedder = createOllamaEmbedder(); const promptToks: number[] = []; let nuggetTotal = 0; let qCount = 0; const abilitySet = args.abilities ? new Set(args.abilities) : null; if (abilitySet) console.log(`[estimate] abilities filter → {${[...abilitySet].join(', ')}}`); for (const conv of ingestedConvs) { const substrate = createSubstrate({ dbPath: mindPath(args.mindsDir, conv), embedder }); try { const dateMap = prepareDateMap(args, substrate, conv); const outline = buildPreamble(loadDirectives(args, conv), loadOutline(args, conv)); const questions = loadConvQuestions(args.beamChats, conv) .filter(q => !abilitySet || abilitySet.has(q.memoryAbility)); for (const q of questions) { const results = await substrate.search.search(q.question, { limit: args.topK, gopId: q.gopId }); const memories = memoriesFromResults(results); const prompt = buildAnswerPrompt(args, q.question, memories, dateMap, outline); promptToks.push(approxTokens(prompt)); nuggetTotal += q.rubric.length; qCount++; } } finally { substrate.close(); } } const mean = (xs: number[]): number => xs.reduce((s, x) => s + x, 0) / (xs.length || 1); const meanPromptTok = mean(promptToks); const meanNuggets = nuggetTotal / (qCount || 1); // gpt-4o pricing. const IN = 2.5 / 1e6, OUT = 10 / 1e6; const answerOutTok = 200; // mem0-style full-sentence answers const judgeBoilerplateTok = 750; // nugget judge prompt scaffold const judgeOutTok = 45; const perQAnswerUsd = meanPromptTok * IN + answerOutTok * OUT; const perQJudgeUsd = meanNuggets * ((judgeBoilerplateTok + meanPromptTok * 0.15) * IN + judgeOutTok * OUT); const perQIpbUsd = args.cell === 'hive_mind_ipb' ? (meanPromptTok * 0.5 * IN + 30 * OUT) : 0; // ~contradiction check on q2..20 const perQ = perQAnswerUsd + perQJudgeUsd + perQIpbUsd; const full700 = perQ * 700; console.log('\n════════ COST ESTIMATE (gpt-4o, 700 questions) ════════'); console.log(`sampled questions: ${qCount}`); console.log(`mean answer-prompt tokens: ${meanPromptTok.toFixed(0)} (top-k=${args.topK} raw turns)`); console.log(`mean nuggets/question: ${meanNuggets.toFixed(2)}`); console.log(`per-question answer: $${perQAnswerUsd.toFixed(4)}`); console.log(`per-question judge: $${perQJudgeUsd.toFixed(4)}`); if (perQIpbUsd) console.log(`per-question ipb chk: $${perQIpbUsd.toFixed(4)}`); console.log(`per-question TOTAL: $${perQ.toFixed(4)}`); console.log(`──────────────────────────────────────────────────────`); console.log(`FULL 700 cell cost: $${full700.toFixed(2)}`); console.log(`(extrapolate other top-k linearly on the answer-prompt token term)`); } // ── Real run ───────────────────────────────────────────────────────────────── async function runCell(args: Args): Promise { const here = url.fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(here), '..', '..', '..'); const outDir = path.join(repoRoot, 'benchmarks', 'results', 'beam'); fs.mkdirSync(outDir, { recursive: true }); // The answer-prompt variant is part of the identity of a run: v1 and v2 must // NEVER share a JSONL (or its derived .summary.json), or a paid v1-vs-v2 // comparison is corrupted. Keep `prompt` in the default filename. // Hybrid's retrieval budget is (kRaw, kFact), not top-k → name the file by both // so a hybrid run never collides with a top-k retrieval/distill file. const outPath = args.outPath ?? path.join( outDir, args.cell === 'hybrid' ? `beam-1m-hybrid-${args.prompt}-kraw${args.kRaw}-kfact${args.kFact}.jsonl` : `beam-1m-${args.cell}-${args.prompt}-topk${args.topK}.jsonl`, ); // Resume / append safety. When the target file already exists (default path, // or an explicit --out), refuse to mix answer-prompt variants into one file: // the per-row `prompt` tag makes each row self-describing, but the summary // metrics and any whole-file consumer would be silently contaminated. On a // matching prompt, collect already-answered instance ids for --resume. const doneIds = new Set(); if (fs.existsSync(outPath)) { const existingIds = new Set(); let existingPrompt: string | null = null; for (const line of fs.readFileSync(outPath, 'utf-8').split('\n')) { const t = line.trim(); if (!t) continue; let row: { instance_id?: string; prompt?: string }; try { row = JSON.parse(t) as { instance_id?: string; prompt?: string }; } catch { continue; } if (row.prompt && row.prompt !== args.prompt) { console.error( `[beam-run-1m] refusing to write ${path.basename(outPath)}: it already contains ` + `prompt="${row.prompt}" rows but this run is prompt="${args.prompt}". ` + `Pass a fresh --out path (or delete the file) so the v1-vs-v2 comparison stays clean.`, ); process.exit(2); } if (row.prompt) existingPrompt = row.prompt; if (row.instance_id) existingIds.add(row.instance_id); } if (args.resume) { for (const id of existingIds) doneIds.add(id); console.log(`[run] resume: ${doneIds.size} questions already answered in ${path.basename(outPath)}`); } else if (existingIds.size > 0) { console.warn( `[run] WARNING: appending to existing ${path.basename(outPath)} ` + `(${existingIds.size} rows, prompt=${existingPrompt ?? 'untagged'}) without --resume; ` + `rows will be duplicated. Use --resume or a fresh --out.`, ); } } const client = createBeamOpenAiClient({ model: args.model }); const embedder = createOllamaEmbedder(); const perQuestion: BeamQuestionResult[] = []; const answerPromptToks: number[] = []; let costUsd = 0; let budgetStopped = false; const acc = (r: BeamLlmResult): void => { costUsd += r.costUsd; }; const outStream = fs.createWriteStream(outPath, { flags: 'a' }); const convs = args.cell === 'hybrid' ? args.convs.filter(c => isIngested(args.rawMindsDir, c) && isIngested(args.obsMindsDir, c)) : args.convs.filter(c => isIngested(args.mindsDir, c)); // Optional memory_ability restriction (e.g. --abilities summarization,event_ordering). const abilitySet = args.abilities ? new Set(args.abilities) : null; if (abilitySet) console.log(`[run] abilities filter → {${[...abilitySet].join(', ')}}`); // Optional per-ability sampling (for the matched-to-smoke pilot): the first // N per memory_ability in instance_id order, drawn from the ingested convs. let allowed: Set | null = null; if (args.perAbility) { const allQ = convs.flatMap(c => loadConvQuestions(args.beamChats, c)) .filter(q => !abilitySet || abilitySet.has(q.memoryAbility)); allowed = sampleByAbility(allQ, args.perAbility); console.log(`[run] per-ability=${args.perAbility} → ${allowed.size} sampled questions`); } const mindsLabel = args.cell === 'hybrid' ? 'minds-1M+obs' : path.basename(args.mindsDir); const budgetLabel = args.cell === 'hybrid' ? `kRaw=${args.kRaw} kFact=${args.kFact}` : `top-k=${args.topK}`; console.log(`[run] cell=${args.cell} prompt=${args.prompt} ${budgetLabel} model=${args.model} budget=$${args.budget} minds=${mindsLabel} convs=${convs.length}`); for (const conv of convs) { if (budgetStopped) break; const questions = loadConvQuestions(args.beamChats, conv) .filter(q => !doneIds.has(q.instanceId) && (!allowed || allowed.has(q.instanceId)) && (!abilitySet || abilitySet.has(q.memoryAbility))); if (questions.length === 0) continue; // ── hybrid: open BOTH minds (raw detail + distilled facts), merge each // question's retrieval into one chronologically-dated list, answer with v2. ── if (args.cell === 'hybrid') { const rawSub = createSubstrate({ dbPath: mindPath(args.rawMindsDir, conv), embedder }); const obsSub = createSubstrate({ dbPath: mindPath(args.obsMindsDir, conv), embedder }); const dateMap = buildConvDateMap(chatJsonPath(args.beamChats, conv)); let cRawDated = 0, cRawTotal = 0, cMerged = 0, cQ = 0; const cTok: number[] = []; try { for (const q of questions) { if (costUsd >= args.budget) { budgetStopped = true; console.warn(`[run] budget $${args.budget} hit`); break; } const { answer, promptTokens, merged } = await answerQuestionHybrid(args, q, rawSub, obsSub, client, acc, dateMap); answerPromptToks.push(promptTokens); cTok.push(promptTokens); cRawDated += merged.rawDated; cRawTotal += merged.rawTotal; cMerged += merged.entries.length; cQ++; const { judgement, llmResults } = await judgeQuestion( client, { question: q.question, rubric: q.rubric, memoryAbility: q.memoryAbility, answer }, { computeTau: args.computeTau }, ); for (const r of llmResults) acc(r); perQuestion.push({ instanceId: q.instanceId, memoryAbility: q.memoryAbility, score: judgement.score, ...(judgement.error ? { error: judgement.error } : {}) }); outStream.write(JSON.stringify({ instance_id: q.instanceId, conv, memory_ability: q.memoryAbility, question: q.question, answer, score: judgement.score, judgment: judgement.judgment, nugget_scores: judgement.nuggetScores, ...(judgement.scoreWithTau !== undefined ? { score_with_tau: judgement.scoreWithTau } : {}), n_nuggets: q.rubric.length, cell: args.cell, prompt: args.prompt, k_raw: args.kRaw, k_fact: args.kFact, merged_entries: merged.entries.length, }) + '\n'); process.stdout.write(` [conv ${conv}] ${q.memoryAbility.padEnd(24)} score=${judgement.score.toFixed(2)} $${costUsd.toFixed(3)}\n`); } } finally { rawSub.close(); obsSub.close(); } const hit = cRawTotal ? ((100 * cRawDated) / cRawTotal).toFixed(1) + '%' : 'n/a'; const meanTok = cTok.length ? Math.round(cTok.reduce((s, x) => s + x, 0) / cTok.length) : 0; console.log(` [conv ${conv}] hybrid kRaw=${args.kRaw} kFact=${args.kFact} raw-date-hit=${hit} merged(mean)=${(cMerged / (cQ || 1)).toFixed(1)} meanPromptTok=${meanTok}`); continue; } // ipb mutates the mind → run on a scratch copy so the base ingest stays pristine. let dbPath = mindPath(args.mindsDir, conv); let scratch: string | null = null; if (args.cell === 'hive_mind_ipb') { scratch = path.join(args.mindsDir, `_scratch_ipb_${conv}.mind`); for (const s of ['', '-wal', '-shm']) if (fs.existsSync(scratch + s)) fs.rmSync(scratch + s, { force: true }); copyMind(dbPath, scratch); dbPath = scratch; } const substrate = createSubstrate({ dbPath, embedder }); try { const dateMap = prepareDateMap(args, substrate, conv); const preamble = buildPreamble(loadDirectives(args, conv), loadOutline(args, conv)); for (const q of questions) { if (costUsd >= args.budget) { budgetStopped = true; console.warn(`[run] budget $${args.budget} hit`); break; } const { answer, promptTokens, shaped } = await answerQuestion(args, q, substrate, client, acc, dateMap, preamble); answerPromptToks.push(promptTokens); const { judgement, llmResults } = await judgeQuestion( client, { question: q.question, rubric: q.rubric, memoryAbility: q.memoryAbility, answer }, { computeTau: args.computeTau }, ); for (const r of llmResults) acc(r); perQuestion.push({ instanceId: q.instanceId, memoryAbility: q.memoryAbility, score: judgement.score, ...(judgement.error ? { error: judgement.error } : {}) }); outStream.write(JSON.stringify({ instance_id: q.instanceId, conv, memory_ability: q.memoryAbility, question: q.question, answer, score: judgement.score, judgment: judgement.judgment, nugget_scores: judgement.nuggetScores, ...(judgement.scoreWithTau !== undefined ? { score_with_tau: judgement.scoreWithTau } : {}), n_nuggets: q.rubric.length, cell: args.cell, prompt: args.prompt, top_k: args.topK, ...(shaped ? { shaped } : {}), ...(args.directivesDir ? { directives: true } : {}), }) + '\n'); process.stdout.write(` [conv ${conv}] ${q.memoryAbility.padEnd(24)} score=${judgement.score.toFixed(2)} $${costUsd.toFixed(3)}\n`); } } finally { substrate.close(); if (scratch) for (const s of ['', '-wal', '-shm']) if (fs.existsSync(scratch + s)) fs.rmSync(scratch + s, { force: true }); } } outStream.end(); const metrics = computeBeamMetrics(perQuestion); const meanAnsPromptTok = answerPromptToks.length ? Math.round(answerPromptToks.reduce((s, x) => s + x, 0) / answerPromptToks.length) : 0; const summaryPath = outPath.replace(/\.jsonl$/, '.summary.json'); fs.writeFileSync(summaryPath, JSON.stringify({ run: { cell: args.cell, dataset: 'beam-1m', model: args.model, judge_model: args.model, prompt: args.prompt, outline: args.outlineDir ? path.basename(args.outlineDir) : null, directives: args.directivesDir ? path.basename(args.directivesDir) : null, shape_route: args.shapeRoute || undefined, // Retrieval budget disclosure (mem0 discloses top-200; we disclose unit + token size). // Hybrid discloses (kRaw, kFact) instead of a single top-k. ...(args.cell === 'hybrid' ? { k_raw: args.kRaw, k_fact: args.kFact } : { top_k: args.topK }), retrieval_unit: args.cell === 'hybrid' ? 'raw_turns+distilled_facts' : args.cell === 'distill' ? 'distilled_facts' : 'raw_turns', mean_answer_prompt_tokens: meanAnsPromptTok, minds_dir: args.cell === 'hybrid' ? 'minds-1M+minds-1M-obs' : path.basename(args.mindsDir), per_ability: args.perAbility ?? null, budgetStopped, answered_now: perQuestion.length, }, metrics: { overall_avg_score: metrics.overall.avgScore, overall_pass_rate_pct: metrics.overall.accuracy, by_ability: metrics.byAbility }, cost: { total_usd: costUsd }, }, null, 2) + '\n', 'utf-8'); console.log('\n════════ BEAM 1M — ' + args.cell + ' ════════'); console.log(formatBeamMetrics(metrics)); console.log(`cost=$${costUsd.toFixed(4)} answered_now=${perQuestion.length} budgetStopped=${budgetStopped}`); console.log(`jsonl: ${outPath}`); console.log(`summary: ${summaryPath}`); } interface AnswerOut { answer: string; promptTokens: number; retrieved: number; shaped?: string } function stripAns(text: string): string { return text.includes('ANSWER:') ? text.split('ANSWER:').pop()!.trim() : text.trim(); } async function answerQuestion( args: Args, q: Question, substrate: Substrate, client: BeamOpenAiClient, acc: (r: BeamLlmResult) => void, dateMap: Map | null, outline: string | null = null, ): Promise { // Confidence-gated shape routing: ONLY summarization / event_ordering are // reliably text-detectable (measured recall 5/5, precision 0.62-0.83); for // those, swap similarity-dense top-k for coverage-shaped chronological // retrieval. Everything else falls through to the default path unchanged. if (args.shapeRoute && args.cell === 'retrieval') { const { predicted } = await classifyAbility(client, q.question, acc); if (predicted === 'summarization' || predicted === 'event_ordering') { const memories = await fetchCoverageShaped(substrate, q, dateMap, 100, 40); const ans = await client.chat({ system: '', user: buildAnswerPrompt(args, q.question, memories, dateMap, outline), maxTokens: 4096 }); acc(ans); return { answer: stripAns(ans.text), promptTokens: ans.inputTokens, retrieved: memories.length, shaped: predicted }; } } if (args.cell === 'hive_mind_ipb') { // P-frame (query intent). const latestI = substrate.frames.getLatestIFrame(q.gopId); const pFrame = latestI ? substrate.frames.createPFrame(q.gopId, `Retrieving to answer: ${q.question}`, latestI.id, 'normal', 'agent_inferred') : substrate.frames.createIFrame(q.gopId, `Retrieving to answer: ${q.question}`, 'normal', 'agent_inferred'); const results = await substrate.search.search(q.question, { limit: args.topK, gopId: q.gopId }); // Contradiction check when prior P-frames exist. const priors = substrate.frames.getGopFrames(q.gopId).filter(f => f.frame_type === 'P' && f.id !== pFrame.id && f.t < pFrame.t); if (priors.length > 0 && results.length > 0) { const chk = await client.chat({ system: SYSTEM_CONTRADICTION_CHECK, user: buildContradictionCheckPrompt(priors, results), maxTokens: 60 }); acc(chk); if (chk.text.trim().startsWith('CONFLICT:')) { substrate.frames.createBFrame(q.gopId, chk.text.trim().slice('CONFLICT:'.length).trim(), pFrame.id, results.slice(0, 5).map(r => r.frame.id)); } } const memories = memoriesFromResults(results); const ans = await client.chat({ system: '', user: buildAnswerPrompt(args, q.question, memories, dateMap, outline), maxTokens: 4096 }); acc(ans); const answer = stripAns(ans.text); substrate.frames.createIFrame(q.gopId, `Q: ${q.question} A: ${answer}`, 'normal', 'import'); return { answer, promptTokens: ans.inputTokens, retrieved: results.length }; } // retrieval / distill cell (read-only; distill retrieves facts from minds-1M-obs). const results = await substrate.search.search(q.question, { limit: args.topK, gopId: q.gopId }); const memories = memoriesFromResults(results); const ans = await client.chat({ system: '', user: buildAnswerPrompt(args, q.question, memories, dateMap, outline), maxTokens: 4096 }); acc(ans); return { answer: stripAns(ans.text), promptTokens: ans.inputTokens, retrieved: results.length }; } /** hybrid: shared answer step (dual-mind retrieve → merge → v2 answer). Extracted * so BOTH the plain `--cell hybrid` run and the router dispatch the SAME code * path — the caller keeps ownership of judging/logging. Byte-identical to the * former inline block. Uses args.kRaw/args.kFact (route passes an effArgs). */ async function answerQuestionHybrid( args: Args, q: Question, rawSub: Substrate, obsSub: Substrate, client: BeamOpenAiClient, acc: (r: BeamLlmResult) => void, dateMap: Map | null, ): Promise<{ answer: string; promptTokens: number; merged: HybridMerge }> { const merged = await fetchHybrid(args, q, rawSub, obsSub, dateMap); const ans = await client.chat({ system: '', user: buildAnswerGenerationPromptV2(q.question, merged.displayStrings), maxTokens: 4096 }); acc(ans); return { answer: stripAns(ans.text), promptTokens: ans.inputTokens, merged }; } // ── Gold-blind router (classifier + route table) ───────────────────────────── /** The 10 BEAM abilities — the classifier's closed label set AND the true-label * space. Kept identical to the dataset `memory_ability` category keys. */ const ROUTE_ABILITIES = [ 'abstention', 'contradiction_resolution', 'event_ordering', 'information_extraction', 'instruction_following', 'knowledge_update', 'multi_session_reasoning', 'preference_following', 'summarization', 'temporal_reasoning', ] as const; const ABILITY_SET = new Set(ROUTE_ABILITIES); /** Classifier system prompt: one-line definition per ability. The router sees * ONLY the question text (never the stored memory_ability / gold), so routing is * gold-blind. It must return strict JSON: {"ability":""}. */ const CLASSIFIER_SYSTEM = [ 'You are a query router for a long-term-memory benchmark. Read ONLY the user question below and decide which SINGLE memory ability it primarily tests.', 'Respond with STRICT JSON and nothing else: {"ability":"