#!/usr/bin/env tsx /** * BEAM 1M — resumable per-conversation substrate ingest (mind-per-conv). * * DESIGN (per build-phase brief): * - One persistent .mind file PER conversation → no cross-conversation lock * contention, so N conversations can ingest concurrently. * - RESUMABLE: a `.done.json` marker is written only after a * conversation fully ingests + indexes. On restart, conversations with a * valid marker are skipped; a .mind file WITHOUT a marker (crash mid-ingest) * is deleted and rebuilt from scratch. This mirrors LongMemEval's * skip-if-complete minds cache. * - Reads turns directly from the BEAM repo's per-conversation chat.json * (~4 MB each), NOT the 3 GB canonical — `extractTurnsFromBeam` does a * readFileSync that would blow V8's string limit on the 1M archive. * * The gop_id written per frame is `beam_`, matching the * `conversation_id` on every canonical instance — so the answer cells scope to * the right conversation without any change to their gopId filter. * * Embedding: local Ollama `nomic-embed-text` (1024-dim) — free, no API spend. * The embedder has a fixed 30 s per-request timeout, so the index batch size is * kept modest (default 48) to avoid timing out on long turns. * * Progress: one stdout line per conversation start/finish, plus a marker file * per completed conversation and an appended progress log — all monitorable * from the filesystem while this runs in the background. * * Usage: * tsx benchmarks/harness/scripts/beam-ingest-1m.ts \ * [--convs 1-35] [--concurrency 1] [--batch-size 48] \ * [--beam-chats D:/Projects/BEAM/chats] [--minds-dir ] [--force] */ 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 { createSubstrate } from '../src/substrate.js'; import { ingestBeamCorpus } from '../src/ingest-beam.js'; import type { BeamTurn } from '../src/ingest-beam.js'; const CHAT_SIZE = '1M'; const CHAT_SIZE_DIR = '1M'; interface Args { convs: number[]; concurrency: number; batchSize: number; beamChats: string; mindsDir: string; force: boolean; } function parseConvSpec(spec: string): number[] { const out = new Set(); for (const part of spec.split(',')) { const m = part.match(/^(\d+)-(\d+)$/); if (m) { const a = parseInt(m[1], 10); const b = parseInt(m[2], 10); for (let i = a; i <= b; i++) out.add(i); } else if (/^\d+$/.test(part.trim())) { out.add(parseInt(part.trim(), 10)); } } 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), '..', '..', '..'); let convs = parseConvSpec('1-35'); let concurrency = 1; let batchSize = 48; let beamChats = path.resolve(repoRoot, '..', 'BEAM', 'chats'); let mindsDir = path.join(repoRoot, 'benchmarks', 'data', 'beam', 'minds-1M'); let force = false; for (let i = 0; i < argv.length; i++) { const f = argv[i]; const next = argv[i + 1]; if (f === '--convs' && next) { convs = parseConvSpec(next); i++; } else if (f === '--concurrency' && next) { concurrency = Math.max(1, parseInt(next, 10)); i++; } else if (f === '--batch-size' && next) { batchSize = Math.max(1, parseInt(next, 10)); i++; } else if (f === '--beam-chats' && next) { beamChats = path.resolve(next); i++; } else if (f === '--minds-dir' && next) { mindsDir = path.resolve(next); i++; } else if (f === '--force') { force = true; } } return { convs, concurrency, batchSize, beamChats, mindsDir, force }; } interface RawMsg { role?: string; content?: string } interface RawBatch { turns?: RawMsg[][] } /** * Flatten a BEAM chat.json into ordered {role, content} messages. * Replicates build-beam-canonical.ts::flattenBeamTurns EXACTLY (batches → * turn-groups → messages, non-empty content only) so the ingested frames match * the canonical's conversation turns. */ function flattenChatJson(chatJsonPath: string): Array<{ role: string; content: string }> { const batches = JSON.parse(fs.readFileSync(chatJsonPath, 'utf-8')) as RawBatch[]; const out: Array<{ role: string; content: string }> = []; for (const batch of batches) { if (!Array.isArray(batch.turns)) continue; for (const group of batch.turns) { if (!Array.isArray(group)) continue; for (const msg of group) { const role = String(msg.role ?? 'unknown').toLowerCase(); const content = String(msg.content ?? '').trim(); if (content) out.push({ role, content }); } } } return out; } interface IngestMarker { conv: number; gop_id: string; chat_size: string; frames: number; turns_seen: number; ingest_ms: number; index_ms: number; batch_size: number; embedder_model: string; embedder_dims: number; built_at: string; } function markerPath(mindsDir: string, conv: number): string { return path.join(mindsDir, `beam_1M_${conv}.done.json`); } function mindPath(mindsDir: string, conv: number): string { return path.join(mindsDir, `beam_1M_${conv}.mind`); } function isComplete(mindsDir: string, conv: number): IngestMarker | null { const mp = markerPath(mindsDir, conv); if (!fs.existsSync(mp)) return null; try { const m = JSON.parse(fs.readFileSync(mp, 'utf-8')) as IngestMarker; if (m && m.frames > 0 && fs.existsSync(mindPath(mindsDir, conv))) return m; } catch { /* fall through */ } return null; } function logProgress(mindsDir: string, line: string): void { const stamped = `${new Date().toISOString()} ${line}`; process.stdout.write(stamped + '\n'); try { fs.appendFileSync(path.join(mindsDir, '_ingest-progress.log'), stamped + '\n'); } catch { /* best-effort */ } } async function ingestOne(args: Args, conv: number): Promise { const gopId = `beam_${conv}`; const chatJsonPath = path.join(args.beamChats, CHAT_SIZE_DIR, String(conv), 'chat.json'); if (!fs.existsSync(chatJsonPath)) { throw new Error(`chat.json not found for conv ${conv}: ${chatJsonPath}`); } // Clean any partial .mind left by a prior crash (no valid marker present). const mp = mindPath(args.mindsDir, conv); for (const suffix of ['', '-wal', '-shm']) { const f = mp + suffix; if (fs.existsSync(f)) fs.rmSync(f, { force: true }); } const messages = flattenChatJson(chatJsonPath); const turns: BeamTurn[] = messages.map((m, i) => ({ gopId, messageIndex: i, role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content, formattedContent: `${m.role}: ${m.content}`, chatSize: CHAT_SIZE, })); logProgress(args.mindsDir, `[ingest][conv ${conv}] START turns=${turns.length} db=${path.basename(mp)}`); const embedder = createOllamaEmbedder(); const substrate = createSubstrate({ dbPath: mp, embedder }); try { const stats = await ingestBeamCorpus( substrate.db, substrate.search, substrate.frames, substrate.sessions, turns, { batchSize: args.batchSize }, ); const marker: IngestMarker = { conv, gop_id: gopId, chat_size: CHAT_SIZE, frames: stats.count, turns_seen: turns.length, ingest_ms: stats.ingestMs, index_ms: stats.indexMs, batch_size: args.batchSize, embedder_model: 'nomic-embed-text', embedder_dims: embedder.dimensions, built_at: new Date().toISOString(), }; fs.writeFileSync(markerPath(args.mindsDir, conv), JSON.stringify(marker, null, 2) + '\n', 'utf-8'); return marker; } finally { substrate.close(); } } async function runPool(args: Args, convs: number[]): Promise { let cursor = 0; let done = 0; const total = convs.length; const startAll = Date.now(); async function worker(): Promise { while (true) { const idx = cursor++; if (idx >= convs.length) return; const conv = convs[idx]; const existing = args.force ? null : isComplete(args.mindsDir, conv); if (existing) { done++; logProgress(args.mindsDir, `[ingest][conv ${conv}] SKIP (already complete, frames=${existing.frames}) [${done}/${total}]`); continue; } const t0 = Date.now(); try { const m = await ingestOne(args, conv); done++; const secs = ((Date.now() - t0) / 1000).toFixed(1); const elapsedMin = ((Date.now() - startAll) / 60000).toFixed(1); const rate = done / ((Date.now() - startAll) / 60000); const etaMin = rate > 0 ? ((total - done) / rate).toFixed(1) : '?'; logProgress( args.mindsDir, `[ingest][conv ${conv}] DONE frames=${m.frames} index_ms=${m.index_ms} took=${secs}s ` + `[${done}/${total}] elapsed=${elapsedMin}m eta=${etaMin}m`, ); } catch (err) { logProgress(args.mindsDir, `[ingest][conv ${conv}] ERROR ${(err as Error).message}`); throw err; } } } const workers = Array.from({ length: Math.min(args.concurrency, convs.length) }, () => worker()); await Promise.all(workers); const totalMin = ((Date.now() - startAll) / 60000).toFixed(1); logProgress(args.mindsDir, `[ingest] ALL DONE ${done}/${total} conversations in ${totalMin}m`); } async function main(): Promise { const args = parseArgs(); fs.mkdirSync(args.mindsDir, { recursive: true }); logProgress( args.mindsDir, `[ingest] start convs=${args.convs[0]}..${args.convs[args.convs.length - 1]} (n=${args.convs.length}) ` + `concurrency=${args.concurrency} batch=${args.batchSize} chats=${args.beamChats}`, ); await runPool(args, args.convs); } main().catch(err => { console.error('[beam-ingest-1m] FATAL:', err); process.exit(1); });