/** * result-packager — assemble result.zip for benchmark deliverables * (Task #176, Wave A). * * Produces a zip with: * - result.csv wide table (task_id, ligand_id, score) for * DUD-E / LIT-PCBA virtual-screening benchmarks. * - result.log human-readable log referencing the Blueprint id + * per-task EF1% (or proxy) numbers. * - manifest.json machine summary (network name, variant id, * blueprint id, totals, evaluator hint). */ import { createWriteStream } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import archiver from "archiver"; export interface ResultRow { taskId: string; ligandId: string; score: number; } export interface TaskSummary { taskId: string; numLigands: number; topScore: number; evaluatorMetric?: number | null; notes?: string; } export interface PackageResultInput { outDir: string; /** Filename of the produced zip (without directory). */ zipName: string; blueprintId: string; networkName: string; variantId: string; rows: ResultRow[]; taskSummaries: TaskSummary[]; /** Aggregate proxy / true EF1% across all tasks if available. */ aggregateMetric?: number | null; /** Free-form notes appended to result.log. */ extraLogLines?: string[]; } function csvEscape(v: string | number): string { const s = String(v); if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; } export async function packageResult( input: PackageResultInput, ): Promise<{ zipPath: string; csvBytes: number; logBytes: number }> { await mkdir(input.outDir, { recursive: true }); const stage = path.join(input.outDir, `_stage_${input.blueprintId}`); await mkdir(stage, { recursive: true }); // result.csv const csvLines: string[] = ["task_id,ligand_id,score"]; for (const r of input.rows) { csvLines.push( [csvEscape(r.taskId), csvEscape(r.ligandId), r.score.toFixed(6)].join(","), ); } const csvBody = csvLines.join("\n") + "\n"; await writeFile(path.join(stage, "result.csv"), csvBody, "utf8"); // result.log const logLines: string[] = []; logLines.push(`# DoAtlas benchmark result`); logLines.push(`blueprint_id: ${input.blueprintId}`); logLines.push(`network: ${input.networkName}`); logLines.push(`variant_id: ${input.variantId}`); logLines.push(`generated_at: ${new Date().toISOString()}`); logLines.push(`total_tasks: ${input.taskSummaries.length}`); logLines.push(`total_rows: ${input.rows.length}`); if (input.aggregateMetric != null) { logLines.push(`aggregate_metric: ${input.aggregateMetric.toFixed(6)}`); } logLines.push(``); logLines.push(`## per-task summary`); for (const s of input.taskSummaries) { logLines.push( `- ${s.taskId} ligands=${s.numLigands} top=${s.topScore.toFixed(6)}` + (s.evaluatorMetric != null ? ` metric=${s.evaluatorMetric.toFixed(6)}` : "") + (s.notes ? ` notes=${s.notes}` : ""), ); } if (input.extraLogLines && input.extraLogLines.length > 0) { logLines.push(``); logLines.push(`## notes`); for (const ln of input.extraLogLines) logLines.push(ln); } const logBody = logLines.join("\n") + "\n"; await writeFile(path.join(stage, "result.log"), logBody, "utf8"); // manifest.json const manifest = { blueprintId: input.blueprintId, networkName: input.networkName, variantId: input.variantId, generatedAt: new Date().toISOString(), totals: { tasks: input.taskSummaries.length, rows: input.rows.length, }, aggregateMetric: input.aggregateMetric ?? null, perTask: input.taskSummaries, }; await writeFile( path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8", ); // zip const zipPath = path.join(input.outDir, input.zipName); await new Promise((resolve, reject) => { const out = createWriteStream(zipPath); const arch = archiver("zip", { zlib: { level: 9 } }); out.on("close", () => resolve()); out.on("error", reject); arch.on("error", reject); arch.pipe(out); arch.file(path.join(stage, "result.csv"), { name: "result.csv" }); arch.file(path.join(stage, "result.log"), { name: "result.log" }); arch.file(path.join(stage, "manifest.json"), { name: "manifest.json" }); void arch.finalize(); }); return { zipPath, csvBytes: Buffer.byteLength(csvBody, "utf8"), logBytes: Buffer.byteLength(logBody, "utf8"), }; }