diff --git a/src/flow/loop.ts b/src/flow/loop.ts index 27f21e6..eb88661 100644 --- a/src/flow/loop.ts +++ b/src/flow/loop.ts @@ -8,7 +8,9 @@ import { EVENTS, nextStep, type Next } from "./next-step.ts"; import { ledgerFile } from "./paths.ts"; import { recover } from "./recover.ts"; import { baselineRun, saveBaseline } from "./steps/baseline.ts"; -import { assignEntries, dispatchStage } from "./steps/round-zero.ts"; +import { dispatchStage } from "./steps/dispatch.ts"; +import { dedupe, freezeProtocols, selectBatch } from "./steps/round.ts"; +import { assignEntries } from "./steps/round-zero.ts"; import { freezeConfig, halt, openStudy, USER_STOPPED, type Study, type StudyEnding } from "./study.ts"; // 主循环:读账本 → 问下一步 → 做这一步 → 回到开头(任务书 12 第 2.4 节)。每做完一步都重新读账本。 @@ -83,7 +85,7 @@ async function doStep(s: Study, next: Exclude): Pr s.ledger.append("event", { event: EVENTS.skipped, reason: next.reason, objects: [next.step] }); return null; case "openStage": - s.ledger.append("stageOpen", { stageId: next.stageId, stage: next.stage, round: 0, roster: next.roster }); + s.ledger.append("stageOpen", { stageId: next.stageId, stage: next.stage, round: next.round, roster: next.roster }); return null; case "closeStage": s.ledger.append("stageClose", { stageId: next.stageId }); @@ -93,6 +95,15 @@ async function doStep(s: Study, next: Exclude): Pr case "assignEntries": assignEntries(s); return null; + case "dedupe": + await dedupe(s, next.round); + return null; + case "freezeProtocols": + freezeProtocols(s, next.round); + return null; + case "selectBatch": + selectBatch(s, next.round); + return null; case "halt": return halt(s.ledger, next.reason, next.objects); } diff --git a/src/flow/model-task.ts b/src/flow/model-task.ts index 32dc15f..d2c66d2 100644 --- a/src/flow/model-task.ts +++ b/src/flow/model-task.ts @@ -10,13 +10,16 @@ import type { Study } from "./study.ts"; // 最后写"模型调用结束",前后顺序由这个入口保证(方法.md 第九部分"先登记、再执行、再登记结果")。 // 提交收不收只由 submissions 判;形状不合格的由 agents 报上来,这里直接记退回("给 flow")。 -/** 一个研究者任务:哪种提交、谁、哪个阶段、对象;known 是交上来之后才知道的东西(submissions 的 KnownOf)。 */ +/** 一个研究者任务:哪种提交、谁、哪个阶段、对象。 */ export type ModelTask> = { kind: K; actor: string; stageId: string; object: string | null; - known(content: Submission): KnownOf; + /** 一个任务里能交几份的种类(提案、挑刺):第 n 份的编号(materials 的 proposalId、critiqueId),收下时分配;只交一份的为 null。 */ + item: ((n: number) => string) | null; + /** 交上来之后才知道的东西(submissions 的 KnownOf)。others 是本任务已收下、而且不是这次要替换的那几份的份数。 */ + known(content: Submission, others: number): KnownOf; }; /** 派一次任务,等会话结束,交回调用编号和结局。 */ @@ -81,41 +84,58 @@ function researcherModel(s: Study, actor: string) { return models[i]!; } -/** - * 一份提交收不收。这个任务只有一份提交(第零轮每人每阶段一个任务、一份提交): - * 回给模型的提交编号就是这一份的位置,不分版本;替换时写它,接在最新一版后面。 - * 第一次交以后不写 replaces 又交,或 replaces 写的不是这一份,都退回并说明该怎么写。 - */ +/** 一份提交收不收:先定写到哪一份,形状合格的再交给 submissions 判。 */ async function submit>(s: Study, t: ModelTask, material: TaskMaterial, attempt: SubmitAttempt): Promise { const { ledger } = s; - const slot = { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, item: null }; const reject = (submitted: Json, reason: string, jev: JevJudgment[] | null): SubmitReceipt => { ledger.append("rejection", { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, submitted, reason, jev }); return { accepted: false, reason }; }; if (attempt.shape === "invalid") return reject(attempt.raw, attempt.reason, null); - const id = receiptId(t); - const latest = ledger.latestSubmission(slot)?.content.version ?? 0; const content = attempt.content as unknown as Json; - if (attempt.replaces === null && latest > 0) { - return reject(content, `本阶段你已经交过这一份(提交编号 ${id})。要改就在 replaces 里写 ${id} 整份替换,不要另交`, null); - } - if (attempt.replaces !== null && (attempt.replaces !== id || latest === 0)) { - const which = latest === 0 ? "你本阶段还没有收下的提交,第一次交时不要带 replaces" : `要替换就写 ${id}`; - return reject(content, `replaces 写的 ${attempt.replaces} 不是你本阶段交过的提交:${which}`, null); - } + const slot = slotOf(s, t, attempt.replaces); + if ("refuse" in slot) return reject(content, slot.refuse, null); const verdict = await receive({ kind: t.kind, content: attempt.content, checklist: material.checklist, - known: t.known(attempt.content), + known: t.known(attempt.content, slot.others), jevModel: s.frozen.config.models.jev, } as Incoming); if (!verdict.accepted) return reject(content, verdict.reason, verdict.jev); - ledger.append("submission", { ...slot, version: latest + 1, content: attempt.content, jev: verdict.jev } as never); - return { accepted: true, id }; + const { stageId, actor: author, kind, object: target } = t; + ledger.append("submission", { stageId, author, kind, target, item: slot.item, version: slot.version, content: attempt.content, jev: verdict.jev } as never); + return { accepted: true, id: slot.id }; } -/** 回给模型的提交编号:阶段、本人、提交种类、对象。 */ -const receiptId = (t: { stageId: string; actor: string; kind: string; object: string | null }): string => - `${t.stageId}.${t.actor}.${t.kind}.${t.object ?? "none"}`; +/** + * 这次提交写到哪一份:回给模型的提交编号、记录的 item、版本、本任务其余已收下几份。 + * 提交编号:能交几份的种类就是这一份的编号,只交一份的是"阶段.本人.种类.对象"。 + * 不写 replaces 是交新的一份,只交一份的种类已经交过就退回并说明该怎么写;写了 replaces 就必须是本任务已收下的某一份。 + */ +function slotOf>( + s: Study, + t: ModelTask, + replaces: string | null, +): { id: string; item: string | null; version: number; others: number } | { refuse: string } { + const mine = s.ledger + .records() + .flatMap((r) => (r.kind === "submission" && r.content.stageId === t.stageId && r.content.author === t.actor && r.content.kind === t.kind && r.content.target === t.object ? [r.content.item] : [])); + const items = [...new Set(mine)]; + const idOf = (item: string | null): string => item ?? `${t.stageId}.${t.actor}.${t.kind}.${t.object ?? "none"}`; + if (replaces === null) { + if (t.item === null && items.length > 0) { + return { refuse: `本阶段你已经交过这一份(提交编号 ${idOf(null)})。要改就在 replaces 里写 ${idOf(null)} 整份替换,不要另交` }; + } + const item = t.item === null ? null : t.item(items.length + 1); + return { id: idOf(item), item, version: 1, others: items.length }; + } + const at = items.findIndex((item) => idOf(item) === replaces); + if (at < 0) { + const which = items.length === 0 ? "你本阶段还没有收下的提交,第一次交时不要带 replaces" : `能替换的提交编号:${items.map(idOf).join("、")}`; + return { refuse: `replaces 写的 ${replaces} 不是你本阶段交过的提交:${which}` }; + } + const item = items[at] as string | null; + const latest = s.ledger.latestSubmission({ stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, item })!; + return { id: replaces, item, version: latest.content.version + 1, others: items.length - 1 }; +} diff --git a/src/flow/next-step.ts b/src/flow/next-step.ts index c7f24f4..7239567 100644 --- a/src/flow/next-step.ts +++ b/src/flow/next-step.ts @@ -1,6 +1,8 @@ -import type { StudyStepId } from "../config/index.ts"; +import type { KindOf } from "../agents/index.ts"; +import type { RoundStepId, StudyStepId } from "../config/index.ts"; import type { Content, LedgerRecord } from "../ledger/index.ts"; -import { ORIGINAL_BASELINE, researcherId } from "../materials/index.ts"; +import { Board, ORIGINAL_BASELINE, researcherId } from "../materials/index.ts"; +import { compareIds } from "../rules/index.ts"; import type { Stage } from "../vocab/index.ts"; import { readFrozen, type Frozen } from "./frozen.ts"; @@ -19,16 +21,18 @@ const ROUND_ZERO_STAGES = { claim: { stage: "claim", kind: "claim" }, } as const satisfies Partial>; type RoundZeroStep = keyof typeof ROUND_ZERO_STAGES; -export type RoundZeroKind = (typeof ROUND_ZERO_STAGES)[RoundZeroStep]["kind"]; -/** 阶段编号由 flow 起:第零轮是 r0-<步骤>,一个步骤一个阶段。 */ -export const stageIdOf = (step: RoundZeroStep): string => `r0-${step}`; +/** 第 r 轮的研究者阶段:步骤名就是阶段名(vocab STAGES)。 */ +type RoundStageStep = Extract; + +/** 阶段编号由 flow 起:r<轮>-<步骤>,一个步骤一个阶段。第 r 轮按配置跳过的步骤也用这个编号记"按配置跳过"。 */ +export const stageIdOf = (round: number, step: string): string => `r${round}-${step}`; /** 基线运行编号:没启动就起下一个编号重跑(docs/结构.md 第四部分)。 */ export const baselineRunId = (n: number): string => `base-${n}`; /** flow 写的事件种类(账本记录 event)。 */ export const EVENTS = { - /** 按配置跳过的步骤,objects 是步骤编号,reason 是 config 给的原因。 */ + /** 按配置跳过的步骤,objects 是步骤编号(第 r 轮的步骤写 r<轮>-<步骤>),reason 是 config 给的原因。 */ skipped: "skippedByConfig", /** 研究停下,reason 是停下的原因,objects 是相关的调用或运行。 */ halted: "halted", @@ -36,15 +40,21 @@ export const EVENTS = { runStatusUnknown: "runStatusUnknown", } as const; +/** 一个研究者任务:一个人对一个对象(候选、原提案;提案和第零轮的任务没有对象)交一种提交。 */ +export type TaskRef = { actor: string; kind: KindOf<"researcher">; object: string | null }; + export type Next = | { do: "freezeConfig" } | { do: "saveBaseline" } | { do: "baselineRun"; run: string } - | { do: "skip"; step: StudyStepId; reason: string } - | { do: "openStage"; stageId: string; stage: Stage; roster: string[] } + | { do: "skip"; step: string; reason: string } + | { do: "openStage"; stageId: string; stage: Stage; round: number; roster: string[] } | { do: "closeStage"; stageId: string } - | { do: "dispatch"; stageId: string; kind: RoundZeroKind; actors: string[] } + | { do: "dispatch"; stageId: string; tasks: TaskRef[] } | { do: "assignEntries" } + | { do: "dedupe"; round: number } + | { do: "freezeProtocols"; round: number } + | { do: "selectBatch"; round: number } | { do: "halt"; reason: string; objects: string[] }; export function nextStep(records: readonly LedgerRecord[]): Next { @@ -53,19 +63,20 @@ export function nextStep(records: readonly LedgerRecord[]): Next { if (frozen === null) return { do: "freezeConfig" }; const view = new View(records); for (const step of frozen.steps.study) { - if (step.status === "skip") { - // 按配置跳过的步骤 → 一条"按配置跳过"事件,写明步骤和原因。 - if (!view.events(EVENTS.skipped).some((e) => e.objects[0] === step.id)) return { do: "skip", step: step.id, reason: step.reason }; - continue; - } - const next = stepNext(step.id, view, frozen); + const next = + step.status === "skip" ? skipNext(step.id, step.reason, view) : step.id === "rounds" ? roundsNext(view, frozen) : studyStepNext(step.id, view, frozen); if (next !== null) return next; } throw new Error("步骤清单走完了却没有停下:rounds 一步应当停下"); } -/** 一个要执行的步骤还要做什么;做完了交回 null。 */ -function stepNext(step: StudyStepId, view: View, frozen: Frozen): Next | null { +/** 按配置跳过的步骤 → 一条"按配置跳过"事件,写明步骤和原因。 */ +function skipNext(step: string, reason: string, view: View): Next | null { + return view.events(EVENTS.skipped).some((e) => e.objects[0] === step) ? null : { do: "skip", step, reason }; +} + +/** 整份研究的一个要执行的步骤还要做什么;做完了交回 null。 */ +function studyStepNext(step: Exclude, view: View, frozen: Frozen): Next | null { switch (step) { case "freezeConfig": return null; @@ -74,18 +85,107 @@ function stepNext(step: StudyStepId, view: View, frozen: Frozen): Next | null { case "entryDraft": case "discussion1": case "discussion2": - case "claim": - return stageNext(step, view, frozen); + case "claim": { + const { stage, kind } = ROUND_ZERO_STAGES[step]; + return stageNext(stageIdOf(0, step), stage, 0, (roster) => roster.map((actor) => ({ actor, kind, object: null })), view, frozen); + } case "assignEntries": // 分配 → 第 0 轮的入口分配决定(decision,种类 entries)。 - return view.of("decision").some((d) => d.decision === "entries" && d.round === 0) ? null : { do: "assignEntries" }; - case "rounds": - return { do: "halt", reason: "第 1 轮还没写(任务书 13)", objects: [] }; + return view.board.decision("entries", 0) === undefined ? { do: "assignEntries" } : null; case "export": throw new Error("走到了导出:rounds 一步应当先停下"); } } +/** + * 一轮接一轮(结构.md 第三部分"每一轮"):第一个没做完的轮次里第一个没做完的步骤。 + * 每一步做完的记录都带轮次,做完的轮次整轮跳过;现在走到实现一步就停下,所以循环一定会返回。 + */ +function roundsNext(view: View, frozen: Frozen): Next { + for (let round = 1; ; round++) { + 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; + } + } +} + +/** 第 round 轮的一个要执行的步骤还要做什么;做完了交回 null。 */ +function roundStepNext(step: RoundStepId, round: number, view: View, frozen: Frozen): Next | null { + switch (step) { + case "openRound": + case "carryOverPool": + // 不写记录:本轮名单和提案上限就是冻结的配置(方法.md 4.3 ①),留池候选由 materials 从记录算出(Board.carried)。 + return null; + case "proposal": + case "firstPrediction": + case "critique": + case "response": + case "secondPrediction": + return stageNext(stageIdOf(round, step), step, round, (roster) => roundTasks(step, round, roster, view.board), view, frozen); + case "dedupe": + // 判重合并 → 本轮的判重合并决定(decision,种类 dedup)。 + return view.board.decision("dedup", round) === undefined ? { do: "dedupe", round } : null; + case "freezeProtocol": { + // 冻结协议 → 本轮每个新候选都有冻结协议记录(protocol)。全部候选在同一个事务里写; + // 这一步排在回应阶段关闭之后,materials 把冻结协议的出现当作"已公开",早写会泄露。 + if (frozen.steps.round.some((s) => s.id === "response" && s.status === "skip")) { + return { do: "halt", reason: "配置关掉了挑刺与回应时怎么冻结协议还没写:submissions 的 protocolBody 只收回应和统一", objects: [] }; + } + const frozenIds = new Set(view.board.protocols().map((p) => p.candidate)); + return freshOf(view.board, round).every((c) => frozenIds.has(c.candidate)) ? null : { do: "freezeProtocols", round }; + } + case "selectBatch": + // 选批 → 本轮的选批决定(decision,种类 batch)。 + return view.board.decision("batch", round) === undefined ? { do: "selectBatch", round } : null; + case "implementation": + return { do: "halt", reason: "实现还没写(任务书 15)", objects: [] }; + default: + throw new Error(`走到了第 ${round} 轮的 ${step}:implementation 一步应当先停下`); + } +} + +/** 第 round 轮判重合并出的新候选,以及各合并了哪几条原提案。 */ +export function freshOf(board: Board, round: number): Array<{ candidate: string; proposals: string[] }> { + return board.decision("dedup", round)!.output.fresh; +} + +/** 第 round 轮的候选池:新候选加留池候选(方法.md 4.3 ①⑦)。第二次预测和选批都用这一份。 */ +export function poolOf(board: Board, round: number): string[] { + return [...freshOf(board, round).map((c) => c.candidate), ...board.carried(round)]; +} + +/** + * 第 r 轮一个研究者阶段的任务(任务书 14 第 2.3 节)。同一个人的几个任务按这里的先后跑。 + * - 提案:每人一个任务,没有对象; + * - 第一次预测:每人 × 本轮每个新候选(含自己的;留池候选不做,方法.md 4.3 ④); + * - 挑刺:每人 × 本轮每条不是自己写的原提案(监督者定,照参考项目); + * - 回应与统一:每个新候选一个任务,一条原提案的由作者交回应,几条合成的由其中编号最小的作者交统一(方法.md 4.3 ③⑥); + * - 第二次预测:每人 × 候选池里每个候选。 + */ +function roundTasks(step: RoundStageStep, round: number, roster: readonly string[], board: Board): TaskRef[] { + const each = (kind: TaskRef["kind"], objects: readonly string[]): TaskRef[] => + roster.flatMap((actor) => objects.map((object) => ({ actor, kind, object }))); + switch (step) { + case "proposal": + return roster.map((actor) => ({ actor, kind: "proposal", object: null })); + case "firstPrediction": + return each("firstPrediction", freshOf(board, round).map((c) => c.candidate)); + case "critique": { + const proposals = board.published("proposal").filter((p) => p.round === round).sort((a, b) => compareIds(a.item!, b.item!)); + return roster.flatMap((actor) => proposals.filter((p) => p.author !== actor).map((p) => ({ actor, kind: "critique" as const, object: p.item! }))); + } + case "response": + return freshOf(board, round).map((c) => ({ + actor: board.authorsOf(c.candidate)[0]!, + kind: c.proposals.length === 1 ? "response" : "unification", + object: c.candidate, + })); + case "secondPrediction": + return each("secondPrediction", poolOf(board, round)); + } +} + /** * 基线 → 基线代码记录(对象 ORIGINAL_BASELINE),而且最近一次基线运行的结果不是"没启动"。 * 没启动的起新编号重跑;判为不算数的停下(方法.md 4.1)。 @@ -104,28 +204,34 @@ function baselineNext(view: View): Next | null { } /** - * 研究者阶段 → 这个阶段的关闭记录(stageClose)。没开就开;名单上的人都有被收下的提交才关; - * 没交上的人按最近一次调用怎么结束的来定派不派(任务书 12 第 2.3 节的表)。能派的先全派,派完还有人停在"没交"才停下。 + * 研究者阶段 → 这个阶段的关闭记录(stageClose)。没开就开,名单是全部研究者(本轮名单就是冻结的配置); + * 每个任务(一个人对一个对象)都有被收下的提交才关;没做完的任务按它最近一次调用怎么结束的来定派不派 + * (任务书 12 第 2.3 节的表)。能派的先全派,派完还有任务停在"没交"才停下。 */ -function stageNext(step: RoundZeroStep, view: View, frozen: Frozen): Next | null { - const stageId = stageIdOf(step); - const { stage, kind } = ROUND_ZERO_STAGES[step]; - const open = view.of("stageOpen").find((s) => s.stageId === stageId); +function stageNext( + stageId: string, + stage: Stage, + round: number, + tasksOf: (roster: readonly string[]) => TaskRef[], + view: View, + frozen: Frozen, +): Next | null { + const open = view.board.stages.get(stageId); if (open === undefined) { - return { do: "openStage", stageId, stage, roster: frozen.config.models.researchers.map((_, i) => researcherId(i)) }; + return { do: "openStage", stageId, stage, round, roster: frozen.config.models.researchers.map((_, i) => researcherId(i)) }; } - if (view.of("stageClose").some((s) => s.stageId === stageId)) return null; - const missing = open.roster.filter((a) => !view.of("submission").some((s) => s.stageId === stageId && s.author === a)); - if (missing.length === 0) return { do: "closeStage", stageId }; - const actors: string[] = []; + if (open.closed) return null; + const todo = tasksOf(open.roster).filter((t) => !view.done(stageId, t)); + if (todo.length === 0) return { do: "closeStage", stageId }; + const tasks: TaskRef[] = []; const stuck: Array<{ call: string; reason: string }> = []; - for (const actor of missing) { - const last = view.lastCall(stageId, actor); + for (const t of todo) { + const last = view.lastCall(stageId, t); // 没有调用、被用户停下、出错(含恢复时补写的"进程中断")都派;没交的只在开发入口开着时派(方法.md 第九部分"开发入口")。 if (last?.status === "notSubmitted" && !frozen.config.devEntry) stuck.push({ call: last.call, reason: last.reason }); - else actors.push(actor); + else tasks.push(t); } - if (actors.length > 0) return { do: "dispatch", stageId, kind, actors }; + if (tasks.length > 0) return { do: "dispatch", stageId, tasks }; return { do: "halt", reason: `交不出合格提交(方法.md 4.3 ②:按内部错误停下):${stuck.map((s) => `调用 ${s.call},${s.reason}`).join(";")}`, @@ -133,12 +239,14 @@ function stageNext(step: RoundZeroStep, view: View, frozen: Frozen): Next | null }; } -/** 按种类取记录内容的小工具。 */ +/** 按种类取记录内容的小工具,加上 materials 读账本的那一份(Board)。 */ class View { readonly #records: readonly LedgerRecord[]; + readonly board: Board; constructor(records: readonly LedgerRecord[]) { this.#records = records; + this.board = new Board(records); } of(kind: K): Array> { @@ -149,9 +257,19 @@ class View { return this.of("event").filter((e) => e.event === event); } - /** 某人在某阶段最近一次模型调用的结束记录,没有调用为 null。开始了没结束的,恢复入口应当已经补写。 */ - lastCall(stageId: string, actor: string): Content<"callEnded"> | null { - const started = this.of("callStarted").filter((c) => c.stageId === stageId && c.actor === actor).at(-1); + /** 一个任务做完了:这个人在这个阶段对这个对象有一份被收下的提交(任务书 14 第 2.3 节)。 */ + done(stageId: string, t: TaskRef): boolean { + return this.of("submission").some((s) => s.stageId === stageId && s.author === t.actor && s.target === t.object); + } + + /** + * 一个任务最近一次模型调用的结束记录,没有调用为 null。"模型调用开始"里存的材料带着对象,按它认出是哪个任务。 + * 开始了没结束的,恢复入口应当已经补写。 + */ + lastCall(stageId: string, t: TaskRef): Content<"callEnded"> | null { + const started = this.of("callStarted") + .filter((c) => c.stageId === stageId && c.actor === t.actor && (c.materials as { object: string | null }).object === t.object) + .at(-1); if (started === undefined) return null; const ended = this.of("callEnded").find((e) => e.call === started.call); if (ended === undefined) throw new Error(`模型调用 ${started.call} 开始了还没有结束记录:恢复入口应当已经补写`); diff --git a/src/flow/steps/dispatch.ts b/src/flow/steps/dispatch.ts new file mode 100644 index 0000000..09763ae --- /dev/null +++ b/src/flow/steps/dispatch.ts @@ -0,0 +1,65 @@ +import { Board, critiqueId, proposalId } from "../../materials/index.ts"; +import { runModelTask, type ModelTask } from "../model-task.ts"; +import type { Next, TaskRef } from "../next-step.ts"; +import { halt, USER_STOPPED, type Study, type StudyEnding } from "../study.ts"; + +// 派一个研究者阶段里要派的任务(任务书 12 第 2.6 节、任务书 14 第 2.3 节): +// 同一个人的几个任务按先后跑,不同的人同时跑,各自一个会话。 + +/** + * 派这些任务。有任务没交上(没交或出错)、或有会话抛出内部错误后,不再开新的会话; + * 已经在跑的不打断(别人已经在花钱了),等它们全部结束后再停下或把错误抛出去(任务书 12 第 2.4 节)。 + */ +export async function dispatchStage(s: Study, next: Extract): Promise { + const byActor = new Map(); + for (const t of next.tasks) byActor.set(t.actor, [...(byActor.get(t.actor) ?? []), t]); + let halting = false; + const missed: Array<{ call: string; text: string }> = []; + const runActor = async (tasks: readonly TaskRef[]): Promise => { + for (const t of tasks) { + if (halting || s.stop.aborted) return; + const { call, ending } = await runModelTask(s, modelTask(s, next.stageId, t)); + if (ending.status === "notSubmitted" || ending.status === "failed") { + halting = true; + missed.push({ call, text: `${ending.status === "failed" ? "出错" : "没交"}:${ending.reason}` }); + } + } + }; + const settled = await Promise.allSettled( + [...byActor.values()].map((tasks) => + runActor(tasks).catch((error: unknown) => { + halting = true; + throw error; + }), + ), + ); + const failure = settled.find((r) => r.status === "rejected"); + if (failure !== undefined) throw failure.reason; + if (s.stop.aborted) return USER_STOPPED; + if (missed.length === 0) return null; + return halt(s.ledger, `阶段 ${next.stageId} 有任务没交上,等这次派发的会话全部结束后停下:${missed.map((m) => `调用 ${m.call},${m.text}`).join(";")}`, missed.map((m) => m.call)); +} + +/** + * 一个任务交上来之后才知道的东西和分份编号(submissions 的 KnownOf、任务书 14 第 2.3 节): + * 提案是本人本阶段已收下几条,按 proposalId 分份;挑刺按 critiqueId 分份;回应是作者那条原提案;其余没有。 + */ +function modelTask(s: Study, stageId: string, t: TaskRef): ModelTask { + const base = { kind: t.kind, actor: t.actor, stageId, object: t.object }; + const board = new Board(s.ledger.records()); + switch (t.kind) { + case "proposal": { + const round = board.stage(stageId).round; + return { ...base, item: (n) => proposalId(round, t.actor, n), known: (_, others) => ({ accepted: others }) } as ModelTask<"proposal">; + } + case "critique": + return { ...base, item: (n) => critiqueId(t.object!, t.actor, n), known: () => null }; + case "response": { + // 回应的候选只由一条原提案而来(几条合成的交统一)。 + const original = board.proposal(board.proposalsOf(t.object!)[0]!).content; + return { ...base, item: null, known: () => ({ original }) } as ModelTask<"response">; + } + default: + return { ...base, item: null, known: () => null }; + } +} diff --git a/src/flow/steps/round-zero.ts b/src/flow/steps/round-zero.ts index cd79201..0493881 100644 --- a/src/flow/steps/round-zero.ts +++ b/src/flow/steps/round-zero.ts @@ -1,31 +1,9 @@ import { finalEntries } from "../../materials/index.ts"; import { assignEntries as assign } from "../../rules/index.ts"; import type { Json } from "../../vocab/index.ts"; -import { runModelTask } from "../model-task.ts"; -import type { Next } from "../next-step.ts"; -import { halt, USER_STOPPED, type Study, type StudyEnding } from "../study.ts"; +import type { Study } from "../study.ts"; -// 第零轮(方法.md 4.2):三个研究者阶段派任务,认领之后由程序分配入口。 - -/** - * 派一个研究者阶段里要派的人,同时跑,各自一个会话(任务书 12 第 2.6 节)。第零轮的提交种类交上来之后没有要另算的东西。 - * 有人没交上(没交或出错),等这次派发的会话全部结束后停下,不打断别人(第 2.4 节)。 - * 某个会话抛出内部错误时,也等别人结束再把它抛出去。 - */ -export async function dispatchStage(s: Study, next: Extract): Promise { - const settled = await Promise.allSettled( - next.actors.map((actor) => runModelTask(s, { kind: next.kind, actor, stageId: next.stageId, object: null, known: () => null })), - ); - const failure = settled.find((r) => r.status === "rejected"); - if (failure !== undefined) throw failure.reason; - if (s.stop.aborted) return USER_STOPPED; - const results = settled.map((r) => (r as PromiseFulfilledResult>>).value); - const missed = results.flatMap(({ call, ending }) => - ending.status === "notSubmitted" || ending.status === "failed" ? [{ call, text: `${ending.status === "failed" ? "出错" : "没交"}:${ending.reason}` }] : [], - ); - if (missed.length === 0) return null; - return halt(s.ledger, `阶段 ${next.stageId} 有人没交上,等这次派发的会话全部结束后停下:${missed.map((m) => `调用 ${m.call},${m.text}`).join(";")}`, missed.map((m) => m.call)); -} +// 第零轮(方法.md 4.2):三个研究者阶段由 dispatch.ts 派任务,认领之后由程序分配入口。 /** * 分配(方法.md 4.2"分配规则"):每人最后一份认领和最终有效的入口交给 rules 的 assignEntries,写一条入口分配决定。 diff --git a/src/flow/steps/round.ts b/src/flow/steps/round.ts new file mode 100644 index 0000000..9117273 --- /dev/null +++ b/src/flow/steps/round.ts @@ -0,0 +1,138 @@ +import { judgeSameChange, type DedupFactor } from "../../jev/index.ts"; +import { Board, candidateId, freezeProtocol, type DedupOutput } from "../../materials/index.ts"; +import { compareIds, decisionSeed, runBalance, runBatchRule, type BatchInput, type BatchRuleName } from "../../rules/index.ts"; +import { protocolBody } from "../../submissions/index.ts"; +import type { JevJudgment, Json } from "../../vocab/index.ts"; +import { freshOf, poolOf, stageIdOf } from "../next-step.ts"; +import type { Study } from "../study.ts"; + +// 第 r 轮里程序自己做的三步(方法.md 4.3 ③⑥⑧):判重合并、冻结协议、选批。每步写一条记录(冻结协议一个事务), +// 写下之前被打断就从账本重新算一遍;写下之后恢复时照原样用,不重算(方法.md 第九部分)。 + +/** + * 同一轮里几个要随机的决定各用一个"版本"算种子(方法.md 第九部分):选批和选格都用随机规则时, + * 两次抽取不会用同一串随机数。选格由 flow 第 4 部分接上。 + */ +const SEED_VERSIONS = { batch: 1, cells: 2 } as const; + +/** 一对提案(或提案与留池候选)的 Jev 判断,原样存进判重合并决定的输入。 */ +export type SamePair = { a: string; b: string; judgment: JevJudgment }; + +/** + * ③ 判重合并:本轮新提案两两问一次 Jev,每条新提案再和每个留池候选问一次(留池候选看它冻结协议里的因子), + * 按顺序发。全部问完才写决定:中途被打断就整轮重问,判重不扣运行次数。 + */ +export async function dedupe(s: Study, round: number): Promise { + const board = new Board(s.ledger.records()); + const proposals = board.published("proposal").filter((p) => p.round === round).sort((a, b) => compareIds(a.item!, b.item!)); + const carried = board.carried(round); + const model = s.frozen.config.models.jev; + const factorsOf = new Map([ + ...proposals.map((p) => [p.item!, p.content.factors] as const), + ...carried.map((c) => [c, board.protocol(c).factors] as const), + ]); + const asks = [ + ...proposals.flatMap((p, i) => proposals.slice(i + 1).map((q) => [p.item!, q.item!] as const)), + ...proposals.flatMap((p) => carried.map((c) => [p.item!, c] as const)), + ]; + const pairs: SamePair[] = []; + for (const [a, b] of asks) pairs.push({ a, b, judgment: await judgeSameChange(model, factorsOf.get(a)!, factorsOf.get(b)!) }); + const output = groupSameChange(round, proposals.map((p) => p.item!), carried, pairs); + s.ledger.append("decision", { + decision: "dedup", + round, + rule: "jevSameChange", + ruleVersion: 1, + params: { jevModel: model }, + seed: null, + input: { pairs } as unknown as Json, + output: output as unknown as Json, + }); +} + +/** + * 判为相同的连成一组(监督者定:A 和 B 相同、B 和 C 相同,A、B、C 就是一个候选;方法没写这种情况)。 + * 一组里有留池候选,整组并进它(方法.md 4.3 ③);一组连到两个留池候选按内部错误停下(真实运行里没出现过)。 + * 其余每组是一个新候选,按组里编号最小的原提案排序,编号用 candidateId。每个留池候选都列出,没并进提案的是空数组。 + */ +export function groupSameChange(round: number, proposals: readonly string[], carried: readonly string[], pairs: readonly SamePair[]): DedupOutput { + const parent = new Map([...proposals, ...carried].map((x) => [x, x])); + const root = (x: string): string => { + while (parent.get(x) !== x) x = parent.get(x)!; + return x; + }; + for (const p of pairs) if (p.judgment.yes) parent.set(root(p.a), root(p.b)); + const groups = new Map(); + for (const x of [...proposals, ...carried]) groups.set(root(x), [...(groups.get(root(x)) ?? []), x]); + const into = new Map(carried.map((c) => [c, [] as string[]])); + const fresh: string[][] = []; + for (const members of groups.values()) { + const old = members.filter((m) => into.has(m)); + const added = members.filter((m) => !into.has(m)).sort(compareIds); + if (old.length > 1) { + throw new Error(`判重合并:提案 ${added.join("、")} 把留池候选 ${old.join("、")} 连成了一组,方法没写该并进哪一个(真实运行里没出现过),按内部错误停下`); + } + if (old.length === 1) into.get(old[0]!)!.push(...added); + else fresh.push(added); + } + fresh.sort((a, b) => compareIds(a[0]!, b[0]!)); + return { + fresh: fresh.map((ids, i) => ({ candidate: candidateId(round, i + 1), proposals: ids })), + carried: [...into].sort(([a], [b]) => compareIds(a, b)).map(([candidate, ids]) => ({ candidate, proposals: ids })), + }; +} + +/** + * ⑥ 冻结协议:回应阶段关闭后,每个新候选取它最后一份回应或统一,用 protocolBody 拼主体,freezeProtocol 补上候选编号、 + * 轮次、本批基线、合并了哪些原提案。全部候选在同一个事务里写:materials 把冻结协议的出现当作"已公开"。 + * 留池候选沿用原来的冻结协议(方法.md 4.3 ①),不重写。 + */ +export function freezeProtocols(s: Study, round: number): void { + const board = new Board(s.ledger.records()); + const baseline = board.baselineOf(round); + s.ledger.transaction(() => { + for (const { candidate, proposals } of freshOf(board, round)) { + const answer = board.answerOf(candidate); + const body = + answer.kind === "response" + ? protocolBody({ kind: "response", content: answer.content, original: board.proposal(proposals[0]!).content }) + : protocolBody({ kind: "unification", content: answer.content }); + s.ledger.append("protocol", { candidate, protocol: freezeProtocol(body, candidate, round, baseline, proposals) as unknown as Json }); + } + }); +} + +/** + * ⑧ 选批(方法.md 6.4):池里每个候选、各人最后一份第二次预测,加本批基线分数、方向、ε、λ、每批上限、余额和种子, + * 交给步骤清单里 selectBatch 那一步的规则,写一条选批决定。 + */ +export function selectBatch(s: Study, round: number): void { + const board = new Board(s.ledger.records()); + const { config, facts, steps } = s.frozen; + const stageId = stageIdOf(round, "secondPrediction"); + const predictions = board.published("secondPrediction").filter((p) => p.stageId === stageId); + const range = (researcher: string, candidate: string) => predictions.find((p) => p.author === researcher && p.target === candidate)!.content.range; + const seed = decisionSeed({ studyId: config.studyId, round, version: SEED_VERSIONS.batch }); + const input: BatchInput = { + candidates: poolOf(board, round).map((id) => ({ id, ranges: board.stage(stageId).roster.map((researcher) => ({ researcher, range: range(researcher, id) })) })), + baseline: board.baselineScore(board.baselineOf(round)), + direction: facts.finalMetric.direction, + epsilon: facts.epsilon, + lambda: config.flow.openReserve, + batchCap: config.batchLimit, + balance: runBalance(config.runs, board.runStates()).balance, + seed, + }; + const rule = steps.round.find((x) => x.id === "selectBatch")!.rule!.name as BatchRuleName; + const { rule: used, ...output } = runBatchRule(rule, input); + s.ledger.append("decision", { + decision: "batch", + round, + rule: used.name, + ruleVersion: used.version, + params: output.params, + seed, + input: input as unknown as Json, + output: output as unknown as Json, + }); +} diff --git a/src/materials/belief-book.ts b/src/materials/belief-book.ts index 3a633cc..60abb51 100644 --- a/src/materials/belief-book.ts +++ b/src/materials/belief-book.ts @@ -33,7 +33,7 @@ function rangeToBelief(lambda: number): string { /** 信念本全文。round 是读者所在的轮次:之前各轮没选中、现在留在候选池的候选,不附它们之前的合并范围;导出时写 null。 */ export function beliefBook(ledger: Ledger, study: StudyFacts, round: number | null): string { - return beliefBookOf(new Board(ledger), study, round); + return beliefBookOf(new Board(ledger.records()), study, round); } export function beliefBookOf(board: Board, study: StudyFacts, round: number | null): string { diff --git a/src/materials/blocks.ts b/src/materials/blocks.ts index e2b0fea..45dc71a 100644 --- a/src/materials/blocks.ts +++ b/src/materials/blocks.ts @@ -246,7 +246,7 @@ export function validEntries(board: Board): Array<{ id: string; author: string; /** 按账本取的有效入口:flow 分配入口时用,和认领的对号清单是同一份(方法.md 4.2)。 */ export function finalEntries(ledger: Ledger): Array<{ id: string; author: string; name: string; body: string }> { - return validEntries(new Board(ledger)); + return validEntries(new Board(ledger.records())); } // --------------------------------------------------------------------------- diff --git a/src/materials/build.ts b/src/materials/build.ts index 5e0313c..5dc9dd4 100644 --- a/src/materials/build.ts +++ b/src/materials/build.ts @@ -28,7 +28,7 @@ const BUILDERS: { [K in SubmissionKind]: (d: Draft) => Extract(); - readonly #records: LedgerRecord[]; + readonly #records: readonly LedgerRecord[]; readonly #subs: Sub[] = []; readonly #runs = new Map(); - constructor(ledger: Ledger) { - this.#records = ledger.records(); + constructor(records: readonly LedgerRecord[]) { + this.#records = records; for (const r of this.#records) { if (r.kind === "stageOpen") this.stages.set(r.content.stageId, { ...r.content, closed: false }); if (r.kind === "stageClose") this.stage(r.content.stageId).closed = true; diff --git a/src/materials/researcher.ts b/src/materials/researcher.ts index a60c168..55727f4 100644 --- a/src/materials/researcher.ts +++ b/src/materials/researcher.ts @@ -66,14 +66,19 @@ export function entry(d: Draft): ChecklistOf<"entry"> { return { kind: "entry", entries: { min: 2, max: 3 } }; } -/** 入口和之前各次讨论:只读已关阶段的(写入口阶段关了才有讨论)。 */ +/** + * 入口和之前各次讨论:只读已关阶段的(写入口阶段关了才有讨论)。讨论原文里引用的入口编号已经作废的, + * 就地标"(已作废)":讨论时整份替换过清单,原文还引用旧编号,读的人分不清哪些还有效(用户 2026-10-03 同意)。 + */ function entriesAndDiscussions(d: Draft): void { const lines: string[] = []; - for (const e of validEntries(d.board)) { + const valid = validEntries(d.board); + for (const e of valid) { d.record(e.id, `入口 ${e.id}:${e.name}(作者 ${e.author})`, e.body); lines.push(`### 入口 ${e.id}:${e.name}(作者 ${e.author})`, "", e.body, ""); } - for (const s of d.board.published("discussion")) lines.push(`### ${s.author} 的讨论(阶段 ${s.stageId})`, "", s.content.body, ""); + const voided = [...entryVersions(d.board).values()].flatMap((vs) => vs.flatMap((v) => v.ids)).filter((id) => !valid.some((e) => e.id === id)); + for (const s of d.board.published("discussion")) lines.push(`### ${s.author} 的讨论(阶段 ${s.stageId})`, "", markVoided(s.content.body, voided), ""); d.section("本任务要处理的东西", lines); const table: string[] = ['程序给的号;整份替换后的新清单接着往后编,旧编号作废,不会指到新入口。']; for (const [author, versions] of entryVersions(d.board)) { @@ -86,6 +91,12 @@ function entriesAndDiscussions(d: Draft): void { d.section("入口编号对照", table); } +/** 在正文里每个作废的入口编号后面标"(已作废)"。编号前后紧挨字母、数字或"-"的不算(e-x-1 不会匹配到 e-x-12 里)。 */ +function markVoided(body: string, voided: readonly string[]): string { + if (voided.length === 0) return body; + return body.replace(new RegExp(`(? { frame(d, [], () => entriesAndDiscussions(d)); diff --git a/tasks/circle_packing/baseline/SOURCES.md b/tasks/circle_packing/baseline/SOURCES.md index f05f527..888c508 100644 --- a/tasks/circle_packing/baseline/SOURCES.md +++ b/tasks/circle_packing/baseline/SOURCES.md @@ -18,10 +18,6 @@ `evaluate.py` / `LICENSE`,成绩一位不变,别的测试都不会挂。`evaluate.py` 和 `LICENSE` 根本不在运行路径上。 -一个实测过的事实(参考项目里跑出来的):把 `compute_max_radii` 那个白跑 105625 次的冗余双循环删掉, -半径和一位不差(都是 0.9597642169962064),但第 (7, 8) 对圆会重叠 2.7755575615628914e-17, -容差 0 下判不合法,要经合法化才收下。那个冗余循环仍然是本研究的改进起点。 - ## 已知的基线事实(刻意保留,不是缺陷) - `initial.py` 的 `compute_max_radii` 含嵌套的冗余缩半径循环(外层 `for i/for j` diff --git a/tests/flow-dedup.test.ts b/tests/flow-dedup.test.ts new file mode 100644 index 0000000..90020b8 --- /dev/null +++ b/tests/flow-dedup.test.ts @@ -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 连成了一组/); +}); diff --git a/tests/flow-next-step.test.ts b/tests/flow-next-step.test.ts index c1c89bf..95c60a2 100644 --- a/tests/flow-next-step.test.ts +++ b/tests/flow-next-step.test.ts @@ -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 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 }); }); diff --git a/tests/flow-round-steps.test.ts b/tests/flow-round-steps.test.ts new file mode 100644 index 0000000..91be913 --- /dev/null +++ b/tests/flow-round-steps.test.ts @@ -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; + 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 }); +}); diff --git a/tests/materials.test.ts b/tests/materials.test.ts index bc24b6a..8607308 100644 --- a/tests/materials.test.ts +++ b/tests/materials.test.ts @@ -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) // ---------------------------------------------------------------------------