#!/usr/bin/env tsx /** * Canonical BEAM archive builder — Track A (manifest-v8.2-final.md). * * BEAM data ships inside the mohammadtavakoli78/BEAM GitHub repo under chats/. * No separate download step is required if you have cloned the repo. * * Actual directory layout (discovered by inspection): * /chats// 100K | 500K | 1M | 10M * / numbered conversation directories (1-based) * chat.json [{batch_number, time_anchor, turns: [[{role,id,time_anchor,index,question_type,content}, ...], ...]}] * probing_questions/ * probing_questions.json {: [{question, , difficulty, ...}, ...], ...} * topic.json {topic, description, ...} * * Chat-size alias: repo uses "100K" for what we call "128K" (~130K tokens each). * * Reads: // * Writes: benchmarks/data/beam/beam-.jsonl * benchmarks/data/beam/beam-.meta.json * * Canonicalisation guarantees (required for dataset_version hash determinism): * 1. Include every (conversation × memory_ability × question) triple. * 2. Sort by instance_id ascending. * 3. JSON.stringify each row (no spaces, explicit key order) + '\n'. No BOM. * 4. SHA-256 of the final byte stream. * * Per-instance JSONL schema: * { * "instance_id": "beam____q", * "conversation_id": "beam_", * "question": "", * "expected": [""], * "context": "", * "memory_ability": "", * "chat_size": "<128K|500K|1M|10M>", * "conversation_index": * } * * Source: mohammadtavakoli78/BEAM (GitHub) * Paper: Tavakoli et al. 2024 "Beyond a Million Tokens: Benchmarking and * Enhancing Long-Term Memory in LLMs" (arXiv:2510.27246, ICLR 2026). * * Zero LLM calls. Zero npm packages beyond Node.js built-ins. * * Usage: * tsx build-beam-canonical.ts --beam-chats-path /path/to/BEAM/chats [--chat-size 128K] * # --beam-chats-path defaults to /benchmarks/harness/scripts/../../../BEAM/chats * # i.e. a sibling BEAM clone next to waggle-os */ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import url from 'node:url'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const VALID_CHAT_SIZES = ['128K', '500K', '1M', '10M'] as const; type ChatSize = (typeof VALID_CHAT_SIZES)[number]; /** * Map our canonical chat-size names to the directory names used in the BEAM repo. * 128K ≈ 100K (actual token count ~130K). */ const CHAT_SIZE_DIR_MAP: Record = { '128K': ['100K'], '500K': ['500K'], '1M': ['1M'], '10M': ['10M'], }; /** * All 10 BEAM memory ability categories. * Source: Table 1 in arXiv:2510.27246 and repo README. */ const MEMORY_ABILITIES = [ 'abstention', 'contradiction_resolution', 'event_ordering', 'information_extraction', 'instruction_following', 'knowledge_update', 'multi_session_reasoning', 'preference_following', 'summarization', 'temporal_reasoning', ] as const; type MemoryAbility = (typeof MEMORY_ABILITIES)[number]; // --------------------------------------------------------------------------- // BEAM data schema (verified by inspection of actual BEAM repo files) // --------------------------------------------------------------------------- /** One message inside a BEAM conversation turn. */ interface BeamMessage { role: string; content: string; id?: number; time_anchor?: string | null; index?: string; question_type?: string; } /** One batch (session) in chat.json. */ interface BeamBatch { batch_number?: number; time_anchor?: string | null; turns: BeamMessage[][]; // list of turn groups; each group is list of msgs } /** * Per-category probing question. * Answer field varies by category — we try all known variants. */ interface BeamProbingQuestion { question?: string; // Category-specific answer fields (verified by inspection): answer?: string; // event_ordering, information_extraction, knowledge_update, multi_session_reasoning, temporal_reasoning ideal_response?: string; // abstention ideal_answer?: string; // contradiction_resolution expected_compliance?: string; // instruction_following, preference_following ideal_summary?: string; // summarization // Extra fields (stored for provenance, not used in eval directly): difficulty?: string; rubric?: string[]; [key: string]: unknown; } // --------------------------------------------------------------------------- // Output schema // --------------------------------------------------------------------------- interface CanonicalInstance { instance_id: string; conversation_id: string; question: string; expected: string[]; context: string; memory_ability: string; chat_size: string; conversation_index: number; /** * schema v2 (2026-07-06): the ordered list of rubric "nuggets" for this * probing question. This is the criterion set the OFFICIAL BEAM metric * scores — each nugget is judged 0 / 0.5 / 1 by the graded LLM judge and * the per-question score is their mean (see beam-nugget-judge.ts). Carried * additively; v1 archives (e.g. the committed beam-128K.jsonl, dataset * hash 9311bba4…) do not have it. Appended LAST in FIELD_ORDER so the * leading columns are byte-identical to v1 for a human diff — note that * ANY added field changes the SHA-256 dataset_version, so a v1 archive * rebuilt with this code becomes a v2 hash. We do not rebuild 128K here. */ rubric: string[]; } const FIELD_ORDER: readonly (keyof CanonicalInstance)[] = [ 'instance_id', 'conversation_id', 'question', 'expected', 'context', 'memory_ability', 'chat_size', 'conversation_index', 'rubric', ]; /** Canonical schema version. Bumped to 2 when `rubric` nuggets were added. */ const SCHEMA_VERSION = 2; // --------------------------------------------------------------------------- // Turn flattening // --------------------------------------------------------------------------- /** * Flatten BEAM's nested batch/turn-group structure into a flat list of * {role, content} messages, preserving temporal order. * * chat.json structure: * [ {batch_number, time_anchor, turns: [ [msg, msg, ...], [msg, ...] ]} ] * * We flatten: batches → turn groups → individual messages. * We only emit messages with non-empty content. */ function flattenBeamTurns(batches: BeamBatch[]): Array<{ role: string; content: string }> { const out: Array<{ role: string; content: string }> = []; for (const batch of batches) { if (!Array.isArray(batch.turns)) continue; for (const turnGroup of batch.turns) { if (!Array.isArray(turnGroup)) continue; for (const msg of turnGroup) { const role = String(msg.role ?? 'unknown').toLowerCase(); const content = String(msg.content ?? '').trim(); if (content) { out.push({ role, content }); } } } } return out; } /** * Convert flat message list to context string. * Format: "user: ...\nassistant: ...\n" */ function buildContext(msgs: Array<{ role: string; content: string }>): string { return msgs.map(m => `${m.role}: ${m.content}`).join('\n'); } // --------------------------------------------------------------------------- // Answer normalisation // --------------------------------------------------------------------------- /** * Extract the reference answer from a probing question, trying all known * per-category answer field names. * Returns null if no answer field is found. */ function normaliseAnswer(pq: BeamProbingQuestion): string | null { return ( pq.answer ?? pq.ideal_response ?? pq.ideal_answer ?? pq.expected_compliance ?? pq.ideal_summary ?? null ); } /** * Extract the ordered list of rubric "nuggets" from a probing question. * Ported verbatim from mem0's `extract_rubric_nuggets` (benchmarks/beam/run.py): * the `rubric` field may be a list[str] (the BEAM 1M/10M shape), a dict with a * `nuggets` list, or a bare scalar. Empty/whitespace nuggets are dropped. */ function extractRubricNuggets(pq: BeamProbingQuestion): string[] { const raw = (pq as Record).rubric; const clean = (arr: unknown[]): string[] => arr .map(n => n !== null && typeof n === 'object' ? String((n as Record).description ?? (n as Record).text ?? JSON.stringify(n)) : String(n), ) .map(s => s.trim()) .filter(s => s.length > 0); if (Array.isArray(raw)) return clean(raw); if (raw !== null && typeof raw === 'object') { const nuggets = (raw as Record).nuggets; if (Array.isArray(nuggets)) return clean(nuggets); } if (raw !== undefined && raw !== null && String(raw).trim().length > 0) { return [String(raw).trim()]; } return []; } // --------------------------------------------------------------------------- // Serialisation // --------------------------------------------------------------------------- function serializeCanonical(inst: CanonicalInstance): string { const ordered: Record = {}; for (const key of FIELD_ORDER) { ordered[key] = inst[key]; } return JSON.stringify(ordered); } // --------------------------------------------------------------------------- // Directory discovery // --------------------------------------------------------------------------- /** * Locate the chat-size directory under beamChatsPath. * Maps our canonical size name to the BEAM repo directory name. */ function locateChatSizeDir(beamChatsPath: string, chatSize: ChatSize): string | null { const candidates = CHAT_SIZE_DIR_MAP[chatSize] ?? [chatSize]; for (const dirName of candidates) { const full = path.join(beamChatsPath, dirName); if (fs.existsSync(full) && fs.statSync(full).isDirectory()) { return full; } } return null; } /** * Return all numbered conversation directories inside chatSizeDir. * These are directories whose names are numeric strings (1, 2, 3, ...). */ function discoverConversationDirs(chatSizeDir: string): string[] { const entries = fs.readdirSync(chatSizeDir, { withFileTypes: true }); return entries .filter(e => e.isDirectory() && /^\d+$/.test(e.name)) .sort((a, b) => Number(a.name) - Number(b.name)) .map(e => path.join(chatSizeDir, e.name)); } // --------------------------------------------------------------------------- // CLI arg parsing // --------------------------------------------------------------------------- function parseArgs(): { beamChatsPath: string; chatSize: ChatSize } { const argv = process.argv.slice(2); let beamChatsPath = ''; let chatSize: ChatSize = '128K'; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; const next = argv[i + 1]; if ((flag === '--beam-chats-path' || flag === '--beam-data-path') && next) { // Accept both --beam-chats-path (new) and --beam-data-path (old compat) // If user passes BEAM root (contains chats/ subdir), auto-append chats/ let p = next; if (fs.existsSync(path.join(p, 'chats'))) { p = path.join(p, 'chats'); } beamChatsPath = p; i++; } else if (flag === '--chat-size' && next) { const val = next as ChatSize; if (!VALID_CHAT_SIZES.includes(val)) { console.error( `[build-beam-canonical] unknown --chat-size "${val}". Valid: ${VALID_CHAT_SIZES.join(', ')}`, ); process.exit(1); } chatSize = val; i++; } } return { beamChatsPath, chatSize }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main(): void { const { beamChatsPath: beamChatsPathArg, chatSize } = parseArgs(); const here = url.fileURLToPath(import.meta.url); // Script lives at benchmarks/harness/scripts/build-beam-canonical.ts // repo root = 3 levels up: scripts/ → harness/ → benchmarks/ → repo root const scriptDir = path.dirname(here); const repoRoot = path.resolve(scriptDir, '..', '..', '..'); const dataDir = path.resolve(repoRoot, 'benchmarks', 'data'); // Auto-discover beamChatsPath if not provided: // Try /../BEAM/chats (sibling clone convention) let beamChatsPath = beamChatsPathArg; if (!beamChatsPath) { const siblingGuess = path.resolve(repoRoot, '..', 'BEAM', 'chats'); if (fs.existsSync(siblingGuess)) { beamChatsPath = siblingGuess; console.log(`[build-beam-canonical] auto-discovered BEAM chats at ${beamChatsPath}`); } else { console.error('[build-beam-canonical] --beam-chats-path is required (or clone BEAM as sibling of waggle-os).\n'); console.error('Clone with: git clone https://github.com/mohammadtavakoli78/BEAM.git'); console.error('Then re-run: tsx build-beam-canonical.ts --beam-chats-path /path/to/BEAM/chats'); process.exit(2); } } if (!fs.existsSync(beamChatsPath)) { console.error(`[build-beam-canonical] BEAM chats path not found: ${beamChatsPath}`); process.exit(2); } // ------------------------------------------------------------------ // Step 1: locate chat-size directory // ------------------------------------------------------------------ const chatSizeDir = locateChatSizeDir(beamChatsPath, chatSize); if (!chatSizeDir) { const tried = (CHAT_SIZE_DIR_MAP[chatSize] ?? [chatSize]).map(d => path.join(beamChatsPath, d)); console.error( `[build-beam-canonical] could not find chat-size directory for "${chatSize}" under ${beamChatsPath}.`, ); console.error(`Tried: ${tried.join(', ')}`); process.exit(2); } console.log(`[build-beam-canonical] chat-size directory: ${chatSizeDir}`); // ------------------------------------------------------------------ // Step 2: scan conversation directories // ------------------------------------------------------------------ const convDirs = discoverConversationDirs(chatSizeDir); if (convDirs.length === 0) { console.error( `[build-beam-canonical] no numbered conversation directories found under ${chatSizeDir}.`, ); process.exit(2); } console.log(`[build-beam-canonical] found ${convDirs.length} conversation directories`); const all: CanonicalInstance[] = []; const skipStats = { missingChat: 0, missingProbing: 0, missingQuestion: 0, missingAnswer: 0, noTurns: 0, }; for (let convIdx = 0; convIdx < convDirs.length; convIdx++) { const convDir = convDirs[convIdx]; const convId = path.basename(convDir); // e.g. "1", "2", ... // ── Load chat.json ────────────────────────────────────────────── const chatJsonPath = path.join(convDir, 'chat.json'); if (!fs.existsSync(chatJsonPath)) { console.warn(`[build-beam-canonical] skipping ${convDir}: no chat.json`); skipStats.missingChat++; continue; } let batches: BeamBatch[]; try { batches = JSON.parse(fs.readFileSync(chatJsonPath, 'utf-8')) as BeamBatch[]; } catch (err) { console.warn(`[build-beam-canonical] skipping ${chatJsonPath}: ${String(err)}`); skipStats.missingChat++; continue; } const flatMsgs = flattenBeamTurns(batches); if (flatMsgs.length === 0) { console.warn(`[build-beam-canonical] skipping ${convDir}: 0 messages after flatten`); skipStats.noTurns++; continue; } const context = buildContext(flatMsgs); // ── Load probing_questions/probing_questions.json ─────────────── const pqPath = path.join(convDir, 'probing_questions', 'probing_questions.json'); if (!fs.existsSync(pqPath)) { console.warn(`[build-beam-canonical] skipping ${convDir}: no probing_questions.json`); skipStats.missingProbing++; continue; } let pqData: Record; try { pqData = JSON.parse(fs.readFileSync(pqPath, 'utf-8')) as Record; } catch (err) { console.warn(`[build-beam-canonical] skipping ${pqPath}: ${String(err)}`); skipStats.missingProbing++; continue; } const conversationId = `beam_${convId}`; const safeChatSize = chatSize.replace(/[^a-zA-Z0-9]/g, ''); // ── Iterate categories ────────────────────────────────────────── for (const [category, questions] of Object.entries(pqData)) { if (!Array.isArray(questions)) continue; for (let qi = 0; qi < questions.length; qi++) { const pq = questions[qi]; const questionText = pq.question ?? null; if (!questionText) { skipStats.missingQuestion++; continue; } const rubric = extractRubricNuggets(pq); // `expected` keeps the single normalised reference answer for the // legacy substring scorer. If a question has no single-answer field // but does carry rubric nuggets, fall back to the joined rubric // (mem0's ground_truth_answer convention) rather than dropping it. let answerText = normaliseAnswer(pq); if (answerText === null) { if (rubric.length > 0) { answerText = rubric.join(' | '); } else { skipStats.missingAnswer++; continue; } } const instanceId = `beam_${safeChatSize}_${convId}_${category}_q${qi}`; all.push({ instance_id: instanceId, conversation_id: conversationId, question: questionText, expected: [answerText], context, memory_ability: category, chat_size: chatSize, conversation_index: convIdx, rubric, }); } } } // ------------------------------------------------------------------ // Step 3: validate // ------------------------------------------------------------------ if (all.length === 0) { console.error('[build-beam-canonical] extracted 0 instances.'); console.error(`Scanned ${convDirs.length} conversation dirs. Skip stats: ${JSON.stringify(skipStats)}`); process.exit(2); } // ------------------------------------------------------------------ // Step 4: sort + distribution // ------------------------------------------------------------------ all.sort((a, b) => a.instance_id.localeCompare(b.instance_id)); const byAbility: Record = {}; for (const ma of MEMORY_ABILITIES) byAbility[ma] = 0; for (const inst of all) { byAbility[inst.memory_ability] = (byAbility[inst.memory_ability] ?? 0) + 1; } // ------------------------------------------------------------------ // Step 5: write outputs // ------------------------------------------------------------------ const outDir = path.join(dataDir, 'beam'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const safeChatSize = chatSize.replace(/[^a-zA-Z0-9]/g, ''); const jsonlPath = path.join(outDir, `beam-${safeChatSize}.jsonl`); // Stream the write + hash incrementally, one line at a time. The full body // for the 1M/10M tracks (~3 GB for 1M, since the ~4 MB context is repeated // per question) exceeds V8's max string length (~512 MB), so it can never // be materialised as a single `join()`ed string. Writing `line + '\n'` for // each row in sorted order produces byte-identical output to the old // `all.map(serializeCanonical).join('\n') + '\n'` (a trailing newline after // the final row), so the SHA-256 dataset_version stays deterministic and // matches what the join-based path would have produced. const hasher = crypto.createHash('sha256'); const fd = fs.openSync(jsonlPath, 'w'); try { for (const inst of all) { const line = serializeCanonical(inst) + '\n'; fs.writeSync(fd, line, null, 'utf-8'); hasher.update(line, 'utf-8'); } } finally { fs.closeSync(fd); } const hash = hasher.digest('hex'); const metaPath = path.join(outDir, `beam-${safeChatSize}.meta.json`); const withRubric = all.filter(i => i.rubric.length > 0).length; const totalNuggets = all.reduce((s, i) => s + i.rubric.length, 0); const meta = { dataset_version: hash, schema_version: SCHEMA_VERSION, instances_with_rubric: withRubric, total_nuggets: totalNuggets, instance_count: all.length, chat_size: chatSize, built_at: new Date().toISOString(), source: 'mohammadtavakoli78/BEAM (GitHub)', source_reference: 'Tavakoli, Salemi, Ye, Abdalla, Zamani, Mitchell 2024, "Beyond a Million Tokens: ' + 'Benchmarking and Enhancing Long-Term Memory in LLMs" (arXiv:2510.27246, ICLR 2026)', beam_chats_path: beamChatsPath, conversations_processed: convDirs.length, chat_size_dir_alias: CHAT_SIZE_DIR_MAP[chatSize]?.[0] ?? chatSize, canonicalisation: { sort_order: 'instance_id ascending', field_order: FIELD_ORDER, line_terminator: '\\n', trailing_newline: true, encoding: 'utf-8', no_bom: true, }, distribution_by_memory_ability: byAbility, skip_stats: skipStats, }; fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); // ------------------------------------------------------------------ // Step 6: report // ------------------------------------------------------------------ console.log('[build-beam-canonical] distribution by memory_ability:'); for (const [k, v] of Object.entries(byAbility)) { console.log(` ${k}: ${v}`); } console.log('[build-beam-canonical] skip_stats:', skipStats); console.log(`[build-beam-canonical] wrote ${jsonlPath} (${all.length} instances)`); console.log(`[build-beam-canonical] wrote ${metaPath}`); console.log(`[build-beam-canonical] dataset_version (SHA-256): ${hash}`); } main();