import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; import { stepList, type ResearchConfig } from "../src/config/index.ts"; import type { AppendKind, Content, LedgerRecord } from "../src/ledger/index.ts"; import type { StudyRecord } from "../src/materials/index.ts"; import { nextStep } from "../src/flow/next-step.ts"; import type { Json } from "../src/vocab/index.ts"; import { fullConfig } from "./config-helpers.ts"; // flow 的"下一步做什么"(任务书 12 第六部分):纯函数,手写账本记录,看它给出的下一步。 // 其余都由真实运行证明(任务书 12 第五部分),这里不写假模型、假 Docker。 const TASK_FACTS = JSON.parse(readFileSync(join(import.meta.dirname, "..", "tasks", "circle_packing", "task.json"), "utf8")) as Json; const HASH = "0".repeat(64); const measured = (value: number) => ({ status: "measured", value }) as const; /** 调用结束记录去掉调用编号(四种结局各自去掉)。 */ type Ended = Content<"callEnded"> extends infer E ? (E extends unknown ? Omit : never) : never; /** 一本手写的账本:按写入先后编序号。内容照账本的形状写,nextStep 不再检查。 */ class Book { readonly records: LedgerRecord[] = []; add(kind: K, content: Content): this { this.records.push({ seq: this.records.length + 1, kind, hash: HASH, content } as LedgerRecord); return this; } config(config: ResearchConfig): this { const record: StudyRecord = { config: config as unknown as Json, steps: stepList(config) as unknown as Json, prompts: { roles: {} as StudyRecord["prompts"]["roles"], tasks: {} as StudyRecord["prompts"]["tasks"] }, image: `sha256:${HASH}`, task: TASK_FACTS, seed: 1, }; return this.add("config", record as unknown as Json); } baselineCode(): this { return this.add("code", { object: "base", version: "v-base", locations: null }); } /** 一次基线运行:没启动,或启动了、判分算数或不算数。 */ baselineRun(run: string, outcome: "notStarted" | "valid" | "invalid"): this { const definition: Content<"runPlanned">["definition"] = { command: ["python", "harness.py"], workdir: "/work", image: { digest: `sha256:${HASH}`, runtime: "runc" }, wallSeconds: 120, cores: 1, inheritEnv: false, env: {}, }; this.add("runPlanned", { run, purpose: "baseline", round: 0, object: null, definition }); if (outcome === "notStarted") { return this.add("runResult", { run, process: { ending: "notStarted", reason: "docker run 失败" }, judged: null }); } this.add("runStarted", { run }); const process = { ending: "exited", exitCode: 0, cpuSeconds: measured(10), wallSeconds: measured(10), peakMemoryBytes: measured(1) } as const; const judged = { verdict: outcome, verdictReason: outcome === "valid" ? "合法" : "不合法:圆重叠", score: measured(0.9597642169962064), metrics: {}, answerPath: { status: "unmeasured", reason: "基线不写" }, safeguardTriggers: { status: "unmeasured", reason: "基线不写" }, extra: null, } as unknown as Content<"runResult">["judged"]; return this.add("runResult", { run, process, judged }); } open(stageId: string, stage: Content<"stageOpen">["stage"], roster: string[]): this { return this.add("stageOpen", { stageId, stage, round: 0, roster }); } /** 一份被收下的提交。nextStep 只看这个人在这个阶段有没有被收下的提交,种类一律写成入口。 */ entry(stageId: string, author: string): this { const content = { entries: [{ name: "入口一", body: "从这里开始看" }, { name: "入口二", body: "从那里开始看" }] }; return this.add("submission", { stageId, author, target: null, item: null, version: 1, kind: "entry", content, jev: null }); } /** 一次模型调用:开始和结束。 */ call(stageId: string, actor: string, n: number, ended: Ended): this { const call = `${stageId}.${actor}.entry.none.${n}`; this.add("callStarted", { call, actor, stageId, model: "iomgaa/glm-5.3", materials: null }); return this.add("callEnded", { call, ...ended } as Content<"callEnded">); } } const R = ["researcher-1", "researcher-2", "researcher-3"]; /** 冻结了配置、基线跑完算数的账本。 */ function afterBaseline(config: ResearchConfig = fullConfig()): Book { return new Book().config(config).baselineCode().baselineRun("base-1", "valid"); } test("空账本:冻结配置", () => { assert.deepEqual(nextStep([]), { do: "freezeConfig" }); }); test("有配置、没有基线:先存基线代码,再用 base-1 跑基线", () => { const book = new Book().config(fullConfig()); assert.deepEqual(nextStep(book.records), { do: "saveBaseline" }); book.baselineCode(); assert.deepEqual(nextStep(book.records), { do: "baselineRun", run: "base-1" }); }); test("基线最近一次是\"没启动\":起新编号再跑", () => { const book = new Book().config(fullConfig()).baselineCode().baselineRun("base-1", "notStarted"); assert.deepEqual(nextStep(book.records), { do: "baselineRun", run: "base-2" }); book.baselineRun("base-2", "notStarted"); assert.deepEqual(nextStep(book.records), { do: "baselineRun", run: "base-3" }); }); test("基线判为不算数:停下,原因写明", () => { const book = new Book().config(fullConfig()).baselineCode().baselineRun("base-1", "invalid"); const next = nextStep(book.records); assert.equal(next.do, "halt"); assert.match((next as { reason: string }).reason, /base-1 判为不算数(不合法:圆重叠)/); }); test("基线算数之后:开写入口阶段,名单是全部研究者", () => { assert.deepEqual(nextStep(afterBaseline().records), { do: "openStage", stageId: "r0-entryDraft", stage: "entryDraft", roster: R }); }); test("阶段开着、一人已交、两人没交:派那两人", () => { const book = afterBaseline().open("r0-entryDraft", "entryDraft", R).entry("r0-entryDraft", "researcher-1"); assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2", "researcher-3"] }); }); test("都交了:关阶段;关了以后开第 1 次讨论", () => { const book = afterBaseline().open("r0-entryDraft", "entryDraft", R); for (const r of R) book.entry("r0-entryDraft", r); assert.deepEqual(nextStep(book.records), { do: "closeStage", stageId: "r0-entryDraft" }); book.add("stageClose", { stageId: "r0-entryDraft" }); assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-discussion1", stage: "discussion", roster: R }); }); test("有人最近一次是\"没交\":开发入口关着先派能派的、再停下;开着重新派", () => { const stuck = (devEntry: boolean) => new Book() .config({ ...fullConfig(), devEntry }) .baselineCode() .baselineRun("base-1", "valid") .open("r0-entryDraft", "entryDraft", R) .entry("r0-entryDraft", "researcher-1") .call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" }); // researcher-3 还没派过,先派他。 assert.deepEqual(nextStep(stuck(false).records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-3"] }); // researcher-3 交上以后只剩"没交"的 researcher-2:停下,附调用编号。 const closed = stuck(false).entry("r0-entryDraft", "researcher-3"); const next = nextStep(closed.records); assert.equal(next.do, "halt"); assert.match((next as { reason: string }).reason, /^交不出合格提交/); assert.deepEqual((next as { objects: string[] }).objects, ["r0-entryDraft.researcher-2.entry.none.1"]); // 开发入口开着:重新派。 const dev = stuck(true).entry("r0-entryDraft", "researcher-3"); assert.deepEqual(nextStep(dev.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2"] }); }); test("有人最近一次是\"出错\"或\"进程中断\":重新派;只看最近一次调用", () => { const book = afterBaseline() .open("r0-entryDraft", "entryDraft", R) .entry("r0-entryDraft", "researcher-1") .call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(5), status: "notSubmitted", reason: "没交" }) .call("r0-entryDraft", "researcher-2", 2, { wallSeconds: measured(5), status: "failed", reason: "模型服务报错" }) .call("r0-entryDraft", "researcher-3", 1, { wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断" }); assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2", "researcher-3"] }); }); test("只有一位研究者:两次讨论各写一条按配置跳过,接着开认领", () => { const one = fullConfig(); one.models.researchers = [one.models.researchers[0]!]; const book = afterBaseline(one).open("r0-entryDraft", "entryDraft", ["researcher-1"]).entry("r0-entryDraft", "researcher-1"); book.add("stageClose", { stageId: "r0-entryDraft" }); const reason = "只有一位研究者,第零轮不讨论(方法.md 4.2)"; assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion1", reason }); book.add("event", { event: "skippedByConfig", reason, objects: ["discussion1"] }); assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion2", reason }); book.add("event", { event: "skippedByConfig", reason, objects: ["discussion2"] }); assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-claim", stage: "claim", roster: ["researcher-1"] }); }); test("认领关了:分配;分配做完:停下,原因\"第 1 轮还没写\"", () => { const book = afterBaseline(); const stages = [ ["r0-entryDraft", "entryDraft"], ["r0-discussion1", "discussion"], ["r0-discussion2", "discussion"], ["r0-claim", "claim"], ] as const; for (const [stageId, stage] of stages) { book.open(stageId, stage, R); for (const r of R) book.entry(stageId, r); book.add("stageClose", { stageId }); } assert.deepEqual(nextStep(book.records), { do: "assignEntries" }); const output = R.map((researcher, i) => ({ researcher, entry: `e-${researcher}-1`, rank: i + 1 })); book.add("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input: null, output }); assert.deepEqual(nextStep(book.records), { do: "halt", reason: "第 1 轮还没写(任务书 13)", objects: [] }); });