spb/autoscholar
Public
1import { mkdir, writeFile } from "node:fs/promises";2import { join } from "node:path";3import { CONTRACT_PATHS as P } from "@autoscholar/schemas";4import { PDFDocument, StandardFonts } from "pdf-lib";5import { syncArtifacts, writeChecksums } from "./artifacts.js";6import type { RunnerHost, RunnerJob, RunOutcome } from "./runner.js";7import { STAGE_SPECS } from "./stages.js";89/**10 * TEST-ONLY simulated runner: produces a contract-shaped artifact set WITHOUT any model call. Every artifact is uploaded11 * with synthetic=true and the worker reports itself as simulated, so the quality gates can never publish its output.12 */13export async function simulateResearch(14 job: RunnerJob,15 host: RunnerHost,16 opts: { failAtStage?: string; delayMs?: number; hang?: boolean } = {},17): Promise<RunOutcome> {18 const ws = join(host.workspaceRoot, "runs", job.runId);19 for (const d of [20 "input",21 "plan",22 "sources",23 "data",24 "code",25 "results/tables",26 "results/figures",27 "manuscript",28 "review",29 "provenance",30 ])31 await mkdir(join(ws, d), { recursive: true });32 await writeFile(join(ws, "CLAUDE.md"), job.instructionText);33 await writeFile(join(ws, P.instruction), job.instructionText);34 await writeFile(join(ws, P.manifest), JSON.stringify(job.manifest, null, 2));35 const upload = (p: string, k: Parameters<RunnerHost["upload"]>[1], ct: string, b: Buffer) =>36 host.upload(p, k, ct, b);37 let cost = 0;38 const completed: string[] = [];39 const started = Date.now();40 const startIdx = job.resumeFromStage ? STAGE_SPECS.findIndex((s) => s.key === job.resumeFromStage) + 1 : 0;41 for (let i = startIdx; i < STAGE_SPECS.length; i++) {42 const s = STAGE_SPECS[i]!;43 if (host.isCancelled())44 return {45 outcome: "cancelled",46 error: "cancelled (simulated)",47 costUsd: cost,48 turns: i,49 stagesCompleted: completed as never,50 resourceUsage: { wallSeconds: (Date.now() - started) / 1000, diskBytes: 0, peakRssBytes: 0 },51 };52 await host.stage({ stage: s.key, status: "running", costUsd: 0, turns: 0 });53 host.emit({54 kind: "sim.stage",55 message: `[SIMULATED] ${s.key}`,56 at: new Date().toISOString(),57 stage: s.key,58 level: "info",59 data: { synthetic: true },60 });61 if (opts.hang) await new Promise(() => {});62 if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));63 if (opts.failAtStage === s.key) {64 await host.stage({65 stage: s.key,66 status: "failed",67 costUsd: 0.01,68 turns: 1,69 error: "simulated failure",70 });71 return {72 outcome: "failed",73 error: `simulated failure at ${s.key}`,74 costUsd: cost,75 turns: i,76 stagesCompleted: completed as never,77 resourceUsage: { wallSeconds: (Date.now() - started) / 1000, diskBytes: 0, peakRssBytes: 0 },78 };79 }80 await writeStageFiles(ws, s.key, job);81 await syncArtifacts(82 ws,83 s.key,84 upload,85 (k, m, d) =>86 host.emit({87 kind: k,88 message: m,89 data: d,90 at: new Date().toISOString(),91 level: "info",92 stage: s.key,93 }),94 { synthetic: true },95 );96 cost += 0.01;97 completed.push(s.key);98 await host.stage({99 stage: s.key,100 status: "completed",101 costUsd: 0.01,102 turns: 1,103 usage: { model: "simulated" },104 checkpoint: { stage: s.key, completedStages: completed, spentUsd: cost },105 });106 }107 await writeFile(108 join(ws, P.runSummary),109 JSON.stringify(110 {111 runId: job.runId,112 attemptNo: job.attemptNo,113 researchMode: job.manifest.researchMode,114 stagesCompleted: completed,115 costUsd: cost,116 turns: 9,117 model: "simulated",118 reviewerModel: "simulated",119 workerName: host.workerName,120 startedAt: new Date(started).toISOString(),121 endedAt: new Date().toISOString(),122 sdkVersion: "simulated",123 toolchain: {},124 synthetic: true,125 },126 null,127 2,128 ),129 );130 await writeChecksums(ws);131 await syncArtifacts(ws, "reproduction", upload, () => {}, { synthetic: true });132 return {133 outcome: "completed",134 costUsd: cost,135 turns: 9,136 stagesCompleted: completed as never,137 resourceUsage: { wallSeconds: (Date.now() - started) / 1000, diskBytes: 1000, peakRssBytes: 0 },138 };139}140141async function writeStageFiles(ws: string, stage: string, job: RunnerJob) {142 const w = (p: string, c: string) => writeFile(join(ws, p), c);143 switch (stage) {144 case "preflight":145 await w(146 P.feasibility,147 JSON.stringify({148 feasible: true,149 researchMode: job.manifest.researchMode,150 summary: "simulated",151 restrictedActivities: [],152 requiredApprovals: [],153 missingInputs: [],154 risks: [],155 }),156 );157 await w(158 P.plan,159 "# Research plan\n## Question\nsim\n## Evaluation plan\nsim\n## Failure modes\nsim\n## Pre-registration\nsim\n## Log\n",160 );161 break;162 case "literature":163 await w(164 P.sources,165 JSON.stringify({166 sources: [167 {168 key: "sim2020",169 kind: "doi",170 identifier: "10.0000/sim",171 title: "Simulated",172 verified: true,173 verifiedAt: new Date().toISOString(),174 verificationMethod: "simulated",175 },176 ],177 }),178 );179 await w(180 P.sourceMap,181 JSON.stringify({182 claims: [183 { claim: "a", sourceKeys: ["sim2020"] },184 { claim: "b", sourceKeys: ["sim2020"] },185 { claim: "c", sourceKeys: ["sim2020"] },186 ],187 }),188 );189 await w(P.referencesBib, "@article{sim2020, title={Simulated}, year={2020}, doi={10.0000/sim}}\n");190 break;191 case "design":192 await w(193 P.dataManifest,194 JSON.stringify({195 datasets: [196 { name: "sim", source: "simulated", license: "none", acquiredAt: "now", synthetic: true },197 ],198 }),199 );200 await w(P.dataReadme, "simulated");201 await w(P.codeReadme, "simulated");202 await w("code/analysis.py", "print('simulated')\n");203 break;204 case "execute":205 await w(206 P.metrics,207 JSON.stringify({208 metrics: { x: 1 },209 reportedValues: [210 { label: "x", value: 1 },211 { label: "y", value: 2 },212 { label: "z", value: 3 },213 ],214 synthetic: true,215 }),216 );217 await w(218 P.experimentLog,219 `${JSON.stringify({ name: "sim", command: "python analysis.py", startedAt: "a", endedAt: "b", exitCode: 0, synthetic: true })}\n`,220 );221 break;222 case "robustness":223 await w(P.robustness, "simulated robustness");224 break;225 case "manuscript": {226 const pdf = await PDFDocument.create();227 const page = pdf.addPage();228 const font = await pdf.embedFont(StandardFonts.Helvetica);229 page.drawText(230 "[SIMULATED] AutoScholar Working Paper — AI-generated, not formally peer reviewed, MacLustr",231 { x: 40, y: 700, size: 10, font },232 );233 await writeFile(join(ws, P.mainPdf), Buffer.from(await pdf.save()));234 await w(235 P.mainTex,236 "\\documentclass{article}\\title{Simulated}\\begin{document}\\maketitle x=1, y=2, z=3 \\cite{sim2020}\\bibliography{references}\\end{document}",237 );238 await w(P.manuscriptBib, "@article{sim2020, title={Simulated}, year={2020}, doi={10.0000/sim}}\n");239 await w(P.abstract, "Simulated abstract.");240 await w(241 P.metadata,242 JSON.stringify({243 title: "Simulated paper",244 abstract: "This is a simulated abstract used only by the orchestration tests. ".repeat(3),245 keywords: ["simulated", "test"],246 discipline: job.manifest.discipline,247 researchMode: job.manifest.researchMode,248 contributions: ["none"],249 methods: "none",250 mainResults: "none",251 limitations: "synthetic simulation only",252 synthetic: true,253 }),254 );255 break;256 }257 case "review":258 await w(P.review1, "# Review\nsimulated");259 await w(260 "review/review-1.json",261 JSON.stringify({262 round: 1,263 reviewerModel: "simulated",264 summary: "sim",265 rubric: [],266 findings: [],267 recommendation: "accept",268 limitationsOfThisReview: "simulated",269 }),270 );271 break;272 case "reproduction":273 await w(P.revisions, "simulated revisions");274 break;275 }276}277