diff --git a/src/flow/next-step.ts b/src/flow/next-step.ts index 602eadf..696d05f 100644 --- a/src/flow/next-step.ts +++ b/src/flow/next-step.ts @@ -1,6 +1,6 @@ import type { RoundStepId, StudyStepId } from "../config/index.ts"; import type { Content, LedgerRecord } from "../ledger/index.ts"; -import { Board, CELL_NOT_RUN, cellId, experimenterId, factorKey, FORWARD_NOT_RUN, ORIGINAL_BASELINE, researcherId, splitCell, verifierId } from "../materials/index.ts"; +import { Board, CELL_NOT_RUN, cellId, DECISION_KINDS, experimenterId, factorKey, FORWARD_NOT_RUN, ORIGINAL_BASELINE, researcherId, splitCell, verifierId } from "../materials/index.ts"; import { compareIds, listCells, runBalance, sharedSettings } from "../rules/index.ts"; import { ablationOutcome, verificationOutcome } from "../submissions/index.ts"; import { SUBMISSION_KINDS, type Stage, type SubmissionKind } from "../vocab/index.ts"; @@ -113,7 +113,10 @@ function studyStepNext(step: Exclude, view: View, frozen: */ function roundsNext(view: View, frozen: Frozen): Next | null { for (let round = 1; ; round++) { - if (runBalance(frozen.config.runs, view.board.runStates()).balance === 0) return null; + // 余额为 0 不开【下一轮】;已经开始的轮次照常走完——最后一次正向用掉全部余额时,这一轮的采纳、 + // 消融(各格记“没测:剩余次数不够”)、收尾与解封照样做(方法.md 第七部分“什么时候开下一轮”)。 + // 一轮在账本里留下的第一处痕迹是阶段记录(或按配置跳过事件、决定记录),此前这一轮还不算开始。 + if (!roundStarted(view, round) && runBalance(frozen.config.runs, view.board.runStates()).balance === 0) return null; for (const step of frozen.steps.round) { const next = step.status === "skip" ? skipNext(stageIdOf(round, step.id), step.reason, view) : roundStepNext(step.id, round, view, frozen); if (next !== null) return next; @@ -121,6 +124,14 @@ function roundsNext(view: View, frozen: Frozen): Next | null { } } +/** 第 round 轮开始了没有:看账本里有没有这一轮的阶段、跳过事件或决定。 */ +function roundStarted(view: View, round: number): boolean { + const prefix = `r${round}-`; + if ([...view.board.stages.keys()].some((id) => id.startsWith(prefix))) return true; + if (DECISION_KINDS.some((kind) => view.board.decision(kind, round) !== undefined)) return true; + return view.events(EVENTS.skipped).some((e) => typeof e.objects[0] === "string" && e.objects[0].startsWith(prefix)); +} + /** 第 round 轮的一个要执行的步骤还要做什么;做完了交回 null。 */ function roundStepNext(step: RoundStepId, round: number, view: View, frozen: Frozen): Next | null { switch (step) { diff --git a/tests/flow-next-step.test.ts b/tests/flow-next-step.test.ts index 4eebe8b..c1fb41a 100644 --- a/tests/flow-next-step.test.ts +++ b/tests/flow-next-step.test.ts @@ -652,6 +652,22 @@ test("结算和押的因子写完:开收尾;收尾关闭后解封本轮选 assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r2-proposal", stage: "proposal", round: 2, roster: R }); }); +test("最后一次正向用掉全部余额:进行中的这一轮照常走完采纳与收尾,不开下一轮", () => { + // 复现 full17-b 第 5 轮的缺陷:两个正向运行把余额用到 0 后,采纳决定、收尾和解封被整轮跳过, + // 直接走到导出停下。余额为 0 只是不开【下一轮】(方法.md 第七部分“什么时候开下一轮”)。 + const book = firstVerification(batchTwo(1), true, true); + for (const c of C) recordForward(book, c, 1, "valid"); + // 已扣 3 次(基线 1、正向 2):把冻结配置的运行次数改成 3,余额为 0。 + const record = book.records.find((r) => r.kind === "config")!; + const study = record.content as unknown as StudyRecord; + study.config = { ...fullConfig(), runs: 3 } as unknown as Json; + // 这一轮已经开始(r1- 的阶段都在账上):下一步是写采纳决定,不是停下。 + assert.deepEqual(nextStep(book.records), { do: "adopt", round: 1 }); + book.add("decision", { decision: "adopt", round: 1, rule: "adopt", ruleVersion: 1, params: null, seed: null, input: null, output: { adopted: null } }); + // 没有采纳:不做消融,照样开收尾阶段。 + assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-wrapUp", stage: "wrapUp", round: 1, roster: R }); +}); + test("余额为 0:不开下一轮,走到导出停下,原因写明", () => { const book = afterWrapUp(); book.add("unseal", { candidate: "r1-c1", seals: [1] });