feat: flow 第 2 部分,第 r 轮开轮到选批(任务书 14 第二块)

- next-step 按"第 r 轮"列出提案、第一次预测、挑刺、回应与统一、第二次预测的任务
  (一人对一个对象),判重、冻结协议、选批三步,走到 implementation 停下(任务书 15)
- steps/dispatch.ts:同阶段一人任务先后跑、不同人同时跑;提案、挑刺按 proposalId、critiqueId 分份
- steps/round.ts:判重两两问 Jev、连通成组、并进留池候选;冻结协议在回应阶段关闭后一个事务写全;
  选批照 rules 的 BatchInput 拼,种子用 decisionSeed 版本 1(选格预留版本 2)
- materials 导出 Board(构造改收记录数组,给纯函数用);讨论原文里已作废的入口编号就地标"(已作废)"
- 删 SOURCES.md 里参考项目实测事实那一段(用户 2026-10-03 同意)
- 测试:下一步做什么 21 条、判重连组 4 条、冻结与选批 2 条、作废标注 1 条;npm test 437 通过

全新上下文审阅一轮(只看三个程序步骤与恢复):没有会污染账本的问题。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:59:54 -04:00
parent 2c6fdce1ca
commit f98d664a16
17 changed files with 835 additions and 133 deletions
+66
View File
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { groupSameChange, type SamePair } from "../src/flow/steps/round.ts";
// 判重合并怎么连组(任务书 14 第 2.4 节):判为相同的连成一组,组里有留池候选就并进它,
// 新候选按组里编号最小的原提案编号。预期值按规则手写。
const pair = (a: string, b: string, yes: boolean): SamePair => ({
a,
b,
judgment: { question: "same_change", questionHash: "h", threshold: 0.5, probability: yes ? 0.9 : 0.1, yes, requestId: "req" },
});
test("A 与 B 相同、B 与 C 相同:A、B、C 连成一个候选,即使 A 与 C 判为不同;新候选按组里最小的原提案编号", () => {
const ps = ["r1-researcher-1-1", "r1-researcher-1-2", "r1-researcher-2-1", "r1-researcher-3-1"];
const pairs = [
pair("r1-researcher-1-1", "r1-researcher-1-2", false),
pair("r1-researcher-1-1", "r1-researcher-2-1", false),
pair("r1-researcher-1-1", "r1-researcher-3-1", false),
pair("r1-researcher-1-2", "r1-researcher-2-1", false),
pair("r1-researcher-1-2", "r1-researcher-3-1", true),
pair("r1-researcher-2-1", "r1-researcher-3-1", true),
];
assert.deepEqual(groupSameChange(1, ps, [], pairs), {
fresh: [
{ candidate: "r1-c1", proposals: ["r1-researcher-1-1"] },
{ candidate: "r1-c2", proposals: ["r1-researcher-1-2", "r1-researcher-2-1", "r1-researcher-3-1"] },
],
carried: [],
});
});
test("编号按数字大小比:researcher-10 排在 researcher-2 后面", () => {
const ps = ["r1-researcher-10-1", "r1-researcher-2-1"];
assert.deepEqual(groupSameChange(1, ps, [], [pair(ps[0]!, ps[1]!, false)]).fresh, [
{ candidate: "r1-c1", proposals: ["r1-researcher-2-1"] },
{ candidate: "r1-c2", proposals: ["r1-researcher-10-1"] },
]);
});
test("和留池候选相同的提案并进它,连带和这条提案相同的别的提案;没并进提案的留池候选列空数组", () => {
const ps = ["r2-researcher-1-1", "r2-researcher-2-1", "r2-researcher-3-1"];
const pairs = [
pair("r2-researcher-1-1", "r2-researcher-2-1", true),
pair("r2-researcher-1-1", "r2-researcher-3-1", false),
pair("r2-researcher-2-1", "r2-researcher-3-1", false),
pair("r2-researcher-1-1", "r1-c1", false),
pair("r2-researcher-1-1", "r1-c3", false),
pair("r2-researcher-2-1", "r1-c1", false),
pair("r2-researcher-2-1", "r1-c3", true),
pair("r2-researcher-3-1", "r1-c1", false),
pair("r2-researcher-3-1", "r1-c3", false),
];
assert.deepEqual(groupSameChange(2, ps, ["r1-c3", "r1-c1"], pairs), {
fresh: [{ candidate: "r2-c1", proposals: ["r2-researcher-3-1"] }],
carried: [
{ candidate: "r1-c1", proposals: [] },
{ candidate: "r1-c3", proposals: ["r2-researcher-1-1", "r2-researcher-2-1"] },
],
});
});
test("一组连到两个留池候选:按内部错误停下", () => {
const pairs = [pair("r2-researcher-1-1", "r1-c1", true), pair("r2-researcher-1-1", "r1-c2", true)];
assert.throws(() => groupSameChange(2, ["r2-researcher-1-1"], ["r1-c1", "r1-c2"], pairs), /留池候选 r1-c1、r1-c2 连成了一组/);
});
+180 -29
View File
@@ -6,7 +6,7 @@ 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 type { Json, SubmissionKind } from "../src/vocab/index.ts";
import { fullConfig } from "./config-helpers.ts";
// flow 的"下一步做什么"(任务书 12 第六部分):纯函数,手写账本记录,看它给出的下一步。
@@ -73,8 +73,8 @@ class Book {
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 });
open(stageId: string, stage: Content<"stageOpen">["stage"], round: number, roster: string[]): this {
return this.add("stageOpen", { stageId, stage, round, roster });
}
/** 一份被收下的提交。nextStep 只看这个人在这个阶段有没有被收下的提交,种类一律写成入口。 */
@@ -83,15 +83,21 @@ class Book {
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 });
/** 一份被收下的提交,内容 nextStep 不读。 */
sub(stageId: string, author: string, kind: SubmissionKind, target: string | null, item: string | null): this {
return this.add("submission", { stageId, author, target, item, version: 1, kind, content: {}, jev: null } as unknown as Content<"submission">);
}
/** 一次模型调用:开始和结束。"模型调用开始"里的材料只写 nextStep 认任务用的种类和对象。 */
call(stageId: string, actor: string, kind: SubmissionKind, object: string | null, n: number, ended: Ended): this {
const call = `${stageId}.${actor}.${kind}.${object ?? "none"}.${n}`;
this.add("callStarted", { call, actor, stageId, model: "iomgaa/glm-5.3", materials: { kind, object } });
return this.add("callEnded", { call, ...ended } as Content<"callEnded">);
}
}
const R = ["researcher-1", "researcher-2", "researcher-3"];
const entryTasks = (actors: string[]) => actors.map((actor) => ({ actor, kind: "entry", object: null }));
/** 冻结了配置、基线跑完算数的账本。 */
function afterBaseline(config: ResearchConfig = fullConfig()): Book {
@@ -124,20 +130,20 @@ test("基线判为不算数:停下,原因写明", () => {
});
test("基线算数之后:开写入口阶段,名单是全部研究者", () => {
assert.deepEqual(nextStep(afterBaseline().records), { do: "openStage", stageId: "r0-entryDraft", stage: "entryDraft", roster: R });
assert.deepEqual(nextStep(afterBaseline().records), { do: "openStage", stageId: "r0-entryDraft", stage: "entryDraft", round: 0, 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"] });
const book = afterBaseline().open("r0-entryDraft", "entryDraft", 0, R).entry("r0-entryDraft", "researcher-1");
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-2", "researcher-3"]) });
});
test("都交了:关阶段;关了以后开第 1 次讨论", () => {
const book = afterBaseline().open("r0-entryDraft", "entryDraft", R);
const book = afterBaseline().open("r0-entryDraft", "entryDraft", 0, 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 });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-discussion1", stage: "discussion", round: 0, roster: R });
});
test("有人最近一次是\"没交\":开发入口关着先派能派的、再停下;开着重新派", () => {
@@ -146,11 +152,11 @@ test("有人最近一次是\"没交\":开发入口关着先派能派的、再
.config({ ...fullConfig(), devEntry })
.baselineCode()
.baselineRun("base-1", "valid")
.open("r0-entryDraft", "entryDraft", R)
.open("r0-entryDraft", "entryDraft", 0, R)
.entry("r0-entryDraft", "researcher-1")
.call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" });
.call("r0-entryDraft", "researcher-2", "entry", null, 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"] });
assert.deepEqual(nextStep(stuck(false).records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-3"]) });
// researcher-3 交上以后只剩"没交"的 researcher-2:停下,附调用编号。
const closed = stuck(false).entry("r0-entryDraft", "researcher-3");
const next = nextStep(closed.records);
@@ -159,34 +165,35 @@ test("有人最近一次是\"没交\":开发入口关着先派能派的、再
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"] });
assert.deepEqual(nextStep(dev.records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-2"]) });
});
test("有人最近一次是\"出错\"或\"进程中断\":重新派;只看最近一次调用", () => {
const book = afterBaseline()
.open("r0-entryDraft", "entryDraft", R)
.open("r0-entryDraft", "entryDraft", 0, 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"] });
.call("r0-entryDraft", "researcher-2", "entry", null, 1, { wallSeconds: measured(5), status: "notSubmitted", reason: "没交" })
.call("r0-entryDraft", "researcher-2", "entry", null, 2, { wallSeconds: measured(5), status: "failed", reason: "模型服务报错" })
.call("r0-entryDraft", "researcher-3", "entry", null, 1, { wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断" });
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["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");
const book = afterBaseline(one).open("r0-entryDraft", "entryDraft", 0, ["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"] });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-claim", stage: "claim", round: 0, roster: ["researcher-1"] });
});
test("认领关了:分配;分配做完:停下,原因\"第 1 轮还没写\"", () => {
const book = afterBaseline();
/** 第零轮做完:四个阶段都交齐关闭,分配决定已写。 */
function afterRoundZero(config: ResearchConfig): Book {
const book = afterBaseline(config);
const stages = [
["r0-entryDraft", "entryDraft"],
["r0-discussion1", "discussion"],
@@ -194,12 +201,156 @@ test("认领关了:分配;分配做完:停下,原因\"第 1 轮还没写
["r0-claim", "claim"],
] as const;
for (const [stageId, stage] of stages) {
book.open(stageId, stage, R);
book.open(stageId, stage, 0, R);
for (const r of R) book.entry(stageId, r);
book.add("stageClose", { stageId });
}
assert.deepEqual(nextStep(book.records), { do: "assignEntries" });
return book;
}
/** 分配决定。 */
function assigned(book: Book): Book {
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: [] });
return book.add("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input: null, output });
}
test("认领关了:分配;分配做完:开第 1 轮的提案阶段,名单是全部研究者", () => {
const book = afterRoundZero(fullConfig());
assert.deepEqual(nextStep(book.records), { do: "assignEntries" });
assigned(book);
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-proposal", stage: "proposal", round: 1, roster: R });
});
// ---------------------------------------------------------------------------
// 第 1 轮:开轮到选批(任务书 14 第 2.2、2.3 节)
// 三条原提案:researcher-1 的单独成候选 r1-c1;researcher-2、researcher-3 的判为同一个改法,合成 r1-c2。
// ---------------------------------------------------------------------------
const P1 = "r1-researcher-1-1";
const P2 = "r1-researcher-2-1";
const P3 = "r1-researcher-3-1";
const C = ["r1-c1", "r1-c2"];
/** 一个研究者阶段:开、每个任务交一份、关。tasks 是 [作者, 种类, 对象, 编号]。 */
function stage(book: Book, stageId: string, kind: Content<"stageOpen">["stage"], tasks: Array<[string, SubmissionKind, string | null, string | null]>): Book {
book.open(stageId, kind, 1, R);
for (const [author, k, target, item] of tasks) book.sub(stageId, author, k, target, item);
return book.add("stageClose", { stageId });
}
/** 每人 × 每个对象的任务。 */
const each = (kind: SubmissionKind, objects: string[]) => R.flatMap((actor) => objects.map((object) => ({ actor, kind, object })));
/** 第 1 轮写到 last 这一步做完为止。 */
function roundOne(last: "proposal" | "dedupe" | "firstPrediction" | "critique" | "response" | "freezeProtocol" | "secondPrediction" | "selectBatch"): Book {
const book = assigned(afterRoundZero(fullConfig()));
const order = ["proposal", "dedupe", "firstPrediction", "critique", "response", "freezeProtocol", "secondPrediction", "selectBatch"];
const steps: Record<string, () => void> = {
proposal: () => stage(book, "r1-proposal", "proposal", [["researcher-1", "proposal", null, P1], ["researcher-2", "proposal", null, P2], ["researcher-3", "proposal", null, P3]]),
dedupe: () => {
const output = { fresh: [{ candidate: "r1-c1", proposals: [P1] }, { candidate: "r1-c2", proposals: [P2, P3] }], carried: [] };
book.add("decision", { decision: "dedup", round: 1, rule: "jevSameChange", ruleVersion: 1, params: null, seed: null, input: null, output });
},
firstPrediction: () => stage(book, "r1-firstPrediction", "firstPrediction", R.flatMap((a) => C.map((c) => [a, "firstPrediction", c, null] as [string, SubmissionKind, string, null]))),
critique: () =>
stage(book, "r1-critique", "critique", [
["researcher-1", "critique", P2, `${P2}.researcher-1.1`],
["researcher-1", "critique", P3, `${P3}.researcher-1.1`],
["researcher-2", "critique", P1, `${P1}.researcher-2.1`],
["researcher-2", "critique", P3, `${P3}.researcher-2.1`],
["researcher-3", "critique", P1, `${P1}.researcher-3.1`],
["researcher-3", "critique", P2, `${P2}.researcher-3.1`],
]),
response: () => stage(book, "r1-response", "response", [["researcher-1", "response", "r1-c1", null], ["researcher-2", "unification", "r1-c2", null]]),
freezeProtocol: () => {
for (const candidate of C) book.add("protocol", { candidate, protocol: { round: 1, baseline: "base", proposals: [] } });
},
secondPrediction: () => stage(book, "r1-secondPrediction", "secondPrediction", R.flatMap((a) => C.map((c) => [a, "secondPrediction", c, null] as [string, SubmissionKind, string, null]))),
selectBatch: () => book.add("decision", { decision: "batch", round: 1, rule: "bestExpected", ruleVersion: 1, params: null, seed: "s", input: null, output: { chosen: ["r1-c1"] } }),
};
for (const step of order.slice(0, order.indexOf(last) + 1)) steps[step]!();
return book;
}
test("提案阶段一人交了两条、两人没交:只派没交的两人,提案没有对象", () => {
const book = assigned(afterRoundZero(fullConfig())).open("r1-proposal", "proposal", 1, R).sub("r1-proposal", "researcher-1", "proposal", null, P1);
book.sub("r1-proposal", "researcher-1", "proposal", null, "r1-researcher-1-2");
const tasks = ["researcher-2", "researcher-3"].map((actor) => ({ actor, kind: "proposal", object: null }));
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-proposal", tasks });
});
test("提案交齐关闭后:判重合并", () => {
assert.deepEqual(nextStep(roundOne("proposal").records), { do: "dedupe", round: 1 });
});
test("判重做完:开第一次预测;任务是每人 × 每个新候选(含自己的),同一个人的按候选编号排", () => {
const book = roundOne("dedupe");
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-firstPrediction", stage: "firstPrediction", round: 1, roster: R });
book.open("r1-firstPrediction", "firstPrediction", 1, R);
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-firstPrediction", tasks: each("firstPrediction", C) });
});
test("同一个人的任务按对象分开算:对 r1-c1 没交的停住,r1-c2 照派;能派的派完才停下", () => {
const notSubmitted = { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" } as const;
const book = roundOne("dedupe").open("r1-firstPrediction", "firstPrediction", 1, R);
for (const a of ["researcher-1", "researcher-3"]) for (const c of C) book.sub("r1-firstPrediction", a, "firstPrediction", c, null);
book.call("r1-firstPrediction", "researcher-2", "firstPrediction", "r1-c1", 1, notSubmitted);
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-firstPrediction", tasks: [{ actor: "researcher-2", kind: "firstPrediction", object: "r1-c2" }] });
book.sub("r1-firstPrediction", "researcher-2", "firstPrediction", "r1-c2", null);
const next = nextStep(book.records);
assert.equal(next.do, "halt");
assert.deepEqual((next as { objects: string[] }).objects, ["r1-firstPrediction.researcher-2.firstPrediction.r1-c1.1"]);
});
test("挑刺阶段:每人 × 每条不是自己写的原提案,没有挑自己的", () => {
const book = roundOne("firstPrediction").open("r1-critique", "critique", 1, R);
const expected = [
{ actor: "researcher-1", kind: "critique", object: P2 },
{ actor: "researcher-1", kind: "critique", object: P3 },
{ actor: "researcher-2", kind: "critique", object: P1 },
{ actor: "researcher-2", kind: "critique", object: P3 },
{ actor: "researcher-3", kind: "critique", object: P1 },
{ actor: "researcher-3", kind: "critique", object: P2 },
];
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-critique", tasks: expected });
});
test("回应阶段:单条原提案的候选派作者交回应,合并而成的派编号最小的作者交统一", () => {
const book = roundOne("critique").open("r1-response", "response", 1, R);
const expected = [
{ actor: "researcher-1", kind: "response", object: "r1-c1" },
{ actor: "researcher-2", kind: "unification", object: "r1-c2" },
];
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-response", tasks: expected });
});
test("回应阶段关闭、还没有冻结协议:写冻结协议;写完开第二次预测,任务是每人 × 池里每个候选", () => {
const book = roundOne("response");
assert.deepEqual(nextStep(book.records), { do: "freezeProtocols", round: 1 });
const frozen = roundOne("freezeProtocol");
assert.deepEqual(nextStep(frozen.records), { do: "openStage", stageId: "r1-secondPrediction", stage: "secondPrediction", round: 1, roster: R });
frozen.open("r1-secondPrediction", "secondPrediction", 1, R);
assert.deepEqual(nextStep(frozen.records), { do: "dispatch", stageId: "r1-secondPrediction", tasks: each("secondPrediction", C) });
});
test("第二次预测关闭:选批", () => {
assert.deepEqual(nextStep(roundOne("secondPrediction").records), { do: "selectBatch", round: 1 });
});
test("选批做完:停下,原因\"实现还没写\"", () => {
assert.deepEqual(nextStep(roundOne("selectBatch").records), { do: "halt", reason: "实现还没写(任务书 15)", objects: [] });
});
test("关掉第一次预测:第 1 轮写一条按配置跳过(对象 r1-firstPrediction),接着开挑刺", () => {
const config = fullConfig();
if (config.flow.preset !== "full") throw new Error("测试配置应是完整流程");
config.flow.firstPrediction = false;
const book = assigned(afterRoundZero(config));
stage(book, "r1-proposal", "proposal", [["researcher-1", "proposal", null, P1], ["researcher-2", "proposal", null, P2], ["researcher-3", "proposal", null, P3]]);
const output = { fresh: [{ candidate: "r1-c1", proposals: [P1] }, { candidate: "r1-c2", proposals: [P2, P3] }], carried: [] };
book.add("decision", { decision: "dedup", round: 1, rule: "jevSameChange", ruleVersion: 1, params: null, seed: null, input: null, output });
const reason = "配置关掉了第一次预测";
assert.deepEqual(nextStep(book.records), { do: "skip", step: "r1-firstPrediction", reason });
book.add("event", { event: "skippedByConfig", reason, objects: ["r1-firstPrediction"] });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-critique", stage: "critique", round: 1, roster: R });
});
+131
View File
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { stepList } from "../src/config/index.ts";
import { fullConfig as checkedFull } from "../src/flow/frozen.ts";
import { freezeProtocols, selectBatch } from "../src/flow/steps/round.ts";
import type { Study } from "../src/flow/study.ts";
import { Ledger, type LedgerRecord } from "../src/ledger/index.ts";
import { decisionSeed, runBatchRule } from "../src/rules/index.ts";
import { checkTaskFacts } from "../src/tasks/index.ts";
import { fullConfig } from "./config-helpers.ts";
import { tempDir } from "./ledger-helpers.ts";
import { judged, P_A1, P_A2, r, Writer } from "./materials-helpers.ts";
// 冻结协议和选批两步(任务书 14 第 2.4 节):在真实账本里写到回应阶段关闭、第二次预测关闭,跑这一步,看写下的记录。
// 预期值按条文手写:协议主体取回应叠原提案、指标改全名、基线和合并的原提案;选批输入照 rules 的 BatchInput 逐项手拼。
// 这两步只读账本和冻结的配置,Study 里别的几样(Docker、提示词)用不到。
const config = fullConfig();
const facts = checkTaskFacts(JSON.parse(readFileSync(join(import.meta.dirname, "..", "tasks", "circle_packing", "task.json"), "utf8")));
const CRITIQUE = "r1-a1-1.a2.1";
function studyAfterResponse(t: TestContext): Study {
const ledger = Ledger.open(join(tempDir(t), "ledger.db"));
t.after(() => ledger.close());
const w = new Writer(ledger);
ledger.append("code", { object: "base", version: "v-base", locations: null });
w.run("base-1", "baseline", 0, null, judged(0.96, "main"));
w.open("r1-proposal", "proposal", 1, ["a1", "a2"]);
w.sub("r1-proposal", "a1", "proposal", null, "r1-a1-1", P_A1);
w.sub("r1-proposal", "a2", "proposal", null, "r1-a2-1", P_A2);
w.close("r1-proposal");
w.decision("dedup", 1, null, { fresh: [{ candidate: "r1-c1", proposals: ["r1-a1-1"] }, { candidate: "r1-c2", proposals: ["r1-a2-1"] }], carried: [] });
w.open("r1-critique", "critique", 1, ["a1", "a2"]);
w.sub("r1-critique", "a2", "critique", "r1-a1-1", CRITIQUE, { target: "p2", body: "乙的挑刺" });
w.close("r1-critique");
w.open("r1-response", "response", 1, ["a1", "a2"]);
// a1 第一版只答复,第二版改了正文:冻结取最后一版。
w.sub("r1-response", "a1", "response", "r1-c1", null, { replies: [{ critique: CRITIQUE, reply: "保留" }], changes: {}, clauses: ["旧条款"], body: "无" });
ledger.append("submission", {
stageId: "r1-response",
author: "a1",
kind: "response",
target: "r1-c1",
item: null,
version: 2,
content: { replies: [{ critique: CRITIQUE, reply: "改了正文" }], changes: { body: "甲改过的正文" }, clauses: ["加多起点", "加扰动"], body: "答复正文" },
jev: null,
});
w.sub("r1-response", "a2", "response", "r1-c2", null, { replies: [], changes: {}, clauses: ["加修复"], body: "无" });
w.close("r1-response");
const frozen = { config: checkedFull(config), steps: stepList(config), facts, prompts: null, image: "", seed: 1 };
return { ledger, frozen } as unknown as Study;
}
const protocols = (records: readonly LedgerRecord[]) => records.filter((x) => x.kind === "protocol").map((x) => x.content);
test("冻结协议:回应阶段关闭后,每个新候选取最后一版回应叠在原提案上,指标改全名,写明基线和合并的原提案;同一个事务", (t) => {
const s = studyAfterResponse(t);
freezeProtocols(s, 1);
assert.deepEqual(protocols(s.ledger.records()), [
{
candidate: "r1-c1",
protocol: {
factors: P_A1.factors,
safeguards: ["guard"],
metrics: { defined: [{ ...P_A1.metrics.defined[0]!, id: "r1-c1.restart-count" }], reused: [] },
body: "甲改过的正文",
clauses: ["加多起点", "加扰动"],
round: 1,
baseline: "base",
proposals: ["r1-a1-1"],
},
},
{
candidate: "r1-c2",
protocol: {
factors: P_A2.factors,
safeguards: [],
metrics: { defined: [{ ...P_A2.metrics.defined[0]!, id: "r1-c2.restart-count" }], reused: [] },
body: "乙的提案正文",
clauses: ["加修复"],
round: 1,
baseline: "base",
proposals: ["r1-a2-1"],
},
},
]);
});
test("选批:输入照 BatchInput 拼(各人最后一版第二次预测、本批基线分数、余额从运行记录算、种子用选批的版本),规则取步骤清单", (t) => {
const s = studyAfterResponse(t);
freezeProtocols(s, 1);
const w = new Writer(s.ledger);
w.open("r1-secondPrediction", "secondPrediction", 1, ["a1", "a2"]);
w.sub("r1-secondPrediction", "a1", "secondPrediction", "r1-c1", null, { range: r(1, 1.1, 1.2), body: "第一版" });
s.ledger.append("submission", {
stageId: "r1-secondPrediction",
author: "a1",
kind: "secondPrediction",
target: "r1-c1",
item: null,
version: 2,
content: { range: r(1.05, 1.15, 1.25), body: "改过" },
jev: null,
});
w.sub("r1-secondPrediction", "a2", "secondPrediction", "r1-c1", null, { range: r(1, 1.1, 1.2), body: "理由" });
w.sub("r1-secondPrediction", "a1", "secondPrediction", "r1-c2", null, { range: r(0.9, 0.97, 1), body: "理由" });
w.sub("r1-secondPrediction", "a2", "secondPrediction", "r1-c2", null, { range: r(0.9, 0.96, 1), body: "理由" });
w.close("r1-secondPrediction");
selectBatch(s, 1);
const decision = s.ledger.records().find((x) => x.kind === "decision" && x.content.decision === "batch")!.content as Record<string, unknown>;
const seed = decisionSeed({ studyId: "circle-full-1", round: 1, version: 1 });
const input = {
candidates: [
{ id: "r1-c1", ranges: [{ researcher: "a1", range: r(1.05, 1.15, 1.25) }, { researcher: "a2", range: r(1, 1.1, 1.2) }] },
{ id: "r1-c2", ranges: [{ researcher: "a1", range: r(0.9, 0.97, 1) }, { researcher: "a2", range: r(0.9, 0.96, 1) }] },
],
baseline: 0.96,
direction: "maximize",
epsilon: facts.epsilon,
lambda: 0.1,
batchCap: 2,
// 13 次里基线运行确认启动、扣了 1 次。
balance: 12,
seed,
} as const;
const { rule: _rule, ...output } = runBatchRule("bestExpected", input);
assert.deepEqual(decision, { decision: "batch", round: 1, rule: "bestExpected", ruleVersion: 1, params: { epsilon: facts.epsilon, lambda: 0.1, batchCap: 2, balance: 12 }, seed, input, output });
});
+15
View File
@@ -46,6 +46,21 @@ test("认领:整份替换后新清单接着编号,旧编号作废;至少
has(m, "乙的讨论");
});
test("讨论原文里引用的已作废入口编号就地标\"(已作废)\",有效编号和相近的编号不标(用户 2026-10-03 同意)", (t) => {
// a1 在第 1 次讨论里把 e-a1-1、e-a1-2 整份换成 e-a1-3、e-a1-4;a2 在同一次讨论里还引用旧编号。
const l = studyUpTo(t, "entryClose");
l.append("stageOpen", { stageId: "s0-d1", stage: "discussion", round: 0, roster: ["a1", "a2"] });
const entries = [{ name: "入口甲三", body: "甲三起点" }, { name: "入口甲四", body: "甲四起点" }];
l.append("submission", { stageId: "s0-d1", author: "a1", kind: "discussion", target: null, item: null, version: 1, content: { body: "换掉 e-a1-1", entries }, jev: null });
const body = "我支持e-a1-1,也看好 e-a1-3 和 e-a2-1;e-a1-12 不存在。(e-a1-2)";
l.append("submission", { stageId: "s0-d1", author: "a2", kind: "discussion", target: null, item: null, version: 1, content: { body }, jev: null });
l.append("stageClose", { stageId: "s0-d1" });
l.append("stageOpen", { stageId: "s0-claim", stage: "claim", round: 0, roster: ["a1", "a2"] });
const m = material(l, "claim", "a1", "s0-claim", null, NONE);
has(m, "我支持e-a1-1(已作废),也看好 e-a1-3 和 e-a2-1;e-a1-12 不存在。(e-a1-2(已作废))");
has(m, "换掉 e-a1-1(已作废)");
});
// ---------------------------------------------------------------------------
// 第 r 轮(方法.md 4.3)
// ---------------------------------------------------------------------------