Files
InfoRSI/src/flow/next-step.ts
T
iomgaa f98d664a16 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>
2026-10-03 13:59:54 -04:00

279 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { KindOf } from "../agents/index.ts";
import type { RoundStepId, StudyStepId } from "../config/index.ts";
import type { Content, LedgerRecord } from "../ledger/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";
// "下一步做什么"(docs/结构.md 第二部分"几条边界"):步骤的先后只写在这里。
// 纯函数:输入账本记录(冻结的步骤清单就在其中的研究配置记录里),输出下一步要做的一件事;
// 不读文件、不调模型,同样的记录永远得到同样的下一步。账本不存"进行到第几步",每次从记录现算。
//
// 每一步做完以哪条记录为准,见 docs/结构.md 第八部分 flow 一节的表;下面每个步骤的分支各注一行。
// 没写成那条记录的步骤,下次会被重做或报错,不靠"这条记录公开了没有"这类隐含约定去猜。
/** 第零轮的研究者阶段:步骤 → 阶段名(vocab STAGES)和这个阶段交的提交种类。 */
const ROUND_ZERO_STAGES = {
entryDraft: { stage: "entryDraft", kind: "entry" },
discussion1: { stage: "discussion", kind: "discussion" },
discussion2: { stage: "discussion", kind: "discussion" },
claim: { stage: "claim", kind: "claim" },
} as const satisfies Partial<Record<StudyStepId, { stage: Stage; kind: string }>>;
type RoundZeroStep = keyof typeof ROUND_ZERO_STAGES;
/** 第 r 轮的研究者阶段:步骤名就是阶段名(vocab STAGES)。 */
type RoundStageStep = Extract<RoundStepId, "proposal" | "firstPrediction" | "critique" | "response" | "secondPrediction">;
/** 阶段编号由 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 是步骤编号(第 r 轮的步骤写 r<轮>-<步骤>),reason 是 config 给的原因。 */
skipped: "skippedByConfig",
/** 研究停下,reason 是停下的原因,objects 是相关的调用或运行。 */
halted: "halted",
/** 恢复时问不到 Docker,不知道一次运行有没有启动(方法.md 第七部分"状态未知"),objects 是运行编号。 */
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: string; reason: string }
| { do: "openStage"; stageId: string; stage: Stage; round: number; roster: string[] }
| { do: "closeStage"; stageId: 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 {
// 冻结配置 → 研究配置记录。
const frozen = readFrozen(records);
if (frozen === null) return { do: "freezeConfig" };
const view = new View(records);
for (const step of frozen.steps.study) {
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 一步应当停下");
}
/** 按配置跳过的步骤 → 一条"按配置跳过"事件,写明步骤和原因。 */
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<StudyStepId, "rounds">, view: View, frozen: Frozen): Next | null {
switch (step) {
case "freezeConfig":
return null;
case "baselineRun":
return baselineNext(view);
case "entryDraft":
case "discussion1":
case "discussion2":
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.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)。
*/
function baselineNext(view: View): Next | null {
if (!view.of("code").some((c) => c.object === ORIGINAL_BASELINE)) return { do: "saveBaseline" };
const plans = view.of("runPlanned").filter((p) => p.purpose === "baseline");
const last = plans.at(-1);
if (last === undefined) return { do: "baselineRun", run: baselineRunId(1) };
const result = view.of("runResult").find((r) => r.run === last.run);
if (result === undefined) throw new Error(`基线运行 ${last.run} 登记了还没有结果:恢复入口应当已经核对过`);
if (result.process.ending === "notStarted") return { do: "baselineRun", run: baselineRunId(plans.length + 1) };
if (result.judged?.verdict === "valid") return null;
const why = result.judged === null ? result.process.ending : result.judged.verdictReason;
return { do: "halt", reason: `基线运行 ${last.run} 判为不算数(${why}),不能当基线`, objects: [last.run] };
}
/**
* 研究者阶段 → 这个阶段的关闭记录(stageClose)。没开就开,名单是全部研究者(本轮名单就是冻结的配置);
* 每个任务(一个人对一个对象)都有被收下的提交才关;没做完的任务按它最近一次调用怎么结束的来定派不派
* (任务书 12 第 2.3 节的表)。能派的先全派,派完还有任务停在"没交"才停下。
*/
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, round, roster: frozen.config.models.researchers.map((_, i) => researcherId(i)) };
}
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 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 tasks.push(t);
}
if (tasks.length > 0) return { do: "dispatch", stageId, tasks };
return {
do: "halt",
reason: `交不出合格提交(方法.md 4.3 ②:按内部错误停下):${stuck.map((s) => `调用 ${s.call},${s.reason}`).join(";")}`,
objects: stuck.map((s) => s.call),
};
}
/** 按种类取记录内容的小工具,加上 materials 读账本的那一份(Board)。 */
class View {
readonly #records: readonly LedgerRecord[];
readonly board: Board;
constructor(records: readonly LedgerRecord[]) {
this.#records = records;
this.board = new Board(records);
}
of<K extends LedgerRecord["kind"]>(kind: K): Array<Content<K>> {
return (this.#records.filter((r) => r.kind === kind) as Array<LedgerRecord<K>>).map((r) => r.content);
}
events(event: string): Array<Content<"event">> {
return this.of("event").filter((e) => e.event === event);
}
/** 一个任务做完了:这个人在这个阶段对这个对象有一份被收下的提交(任务书 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} 开始了还没有结束记录:恢复入口应当已经补写`);
return ended;
}
}