feat: flow 第 1 部分(骨架、研究开始、第零轮)与第零轮检验
按任务书 12 实现 src/flow:从账本现算下一步的纯函数、主循环、恢复入口、派模型任务和跑正式运行两个共用动作、冻结配置、基线、第零轮三个研究者阶段和入口分配,走到第 1 轮时停下。materials 导出 finalEntries、加 researcherId 和 StudyRecord。 审阅采纳两条:研究编号本机已用过时冻结前报错;docker run 没启动时写完结果就停下,不在本进程里无限起新编号。 eval/ 新增第零轮检验:起研究命令行、回放假模型、检验用 Jev 客户端、六问质量评估、16-real-1 回放、杀进程与两份同时跑。tsc 与 depcruise 也检查 eval/,新增 eval 只经 index.ts 用正式代码的规则。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import type { StudyStepId } from "../config/index.ts";
|
||||
import type { Content, LedgerRecord } from "../ledger/index.ts";
|
||||
import { ORIGINAL_BASELINE, researcherId } from "../materials/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;
|
||||
export type RoundZeroKind = (typeof ROUND_ZERO_STAGES)[RoundZeroStep]["kind"];
|
||||
|
||||
/** 阶段编号由 flow 起:第零轮是 r0-<步骤>,一个步骤一个阶段。 */
|
||||
export const stageIdOf = (step: RoundZeroStep): string => `r0-${step}`;
|
||||
/** 基线运行编号:没启动就起下一个编号重跑(docs/结构.md 第四部分)。 */
|
||||
export const baselineRunId = (n: number): string => `base-${n}`;
|
||||
|
||||
/** flow 写的事件种类(账本记录 event)。 */
|
||||
export const EVENTS = {
|
||||
/** 按配置跳过的步骤,objects 是步骤编号,reason 是 config 给的原因。 */
|
||||
skipped: "skippedByConfig",
|
||||
/** 研究停下,reason 是停下的原因,objects 是相关的调用或运行。 */
|
||||
halted: "halted",
|
||||
/** 恢复时问不到 Docker,不知道一次运行有没有启动(方法.md 第七部分"状态未知"),objects 是运行编号。 */
|
||||
runStatusUnknown: "runStatusUnknown",
|
||||
} as const;
|
||||
|
||||
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: "closeStage"; stageId: string }
|
||||
| { do: "dispatch"; stageId: string; kind: RoundZeroKind; actors: string[] }
|
||||
| { do: "assignEntries" }
|
||||
| { 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) {
|
||||
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);
|
||||
if (next !== null) return next;
|
||||
}
|
||||
throw new Error("步骤清单走完了却没有停下:rounds 一步应当停下");
|
||||
}
|
||||
|
||||
/** 一个要执行的步骤还要做什么;做完了交回 null。 */
|
||||
function stepNext(step: StudyStepId, 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":
|
||||
return stageNext(step, 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: [] };
|
||||
case "export":
|
||||
throw new Error("走到了导出:rounds 一步应当先停下");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基线 → 基线代码记录(对象 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(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);
|
||||
if (open === undefined) {
|
||||
return { do: "openStage", stageId, stage, 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[] = [];
|
||||
const stuck: Array<{ call: string; reason: string }> = [];
|
||||
for (const actor of missing) {
|
||||
const last = view.lastCall(stageId, actor);
|
||||
// 没有调用、被用户停下、出错(含恢复时补写的"进程中断")都派;没交的只在开发入口开着时派(方法.md 第九部分"开发入口")。
|
||||
if (last?.status === "notSubmitted" && !frozen.config.devEntry) stuck.push({ call: last.call, reason: last.reason });
|
||||
else actors.push(actor);
|
||||
}
|
||||
if (actors.length > 0) return { do: "dispatch", stageId, kind, actors };
|
||||
return {
|
||||
do: "halt",
|
||||
reason: `交不出合格提交(方法.md 4.3 ②:按内部错误停下):${stuck.map((s) => `调用 ${s.call},${s.reason}`).join(";")}`,
|
||||
objects: stuck.map((s) => s.call),
|
||||
};
|
||||
}
|
||||
|
||||
/** 按种类取记录内容的小工具。 */
|
||||
class View {
|
||||
readonly #records: readonly LedgerRecord[];
|
||||
|
||||
constructor(records: readonly LedgerRecord[]) {
|
||||
this.#records = 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);
|
||||
}
|
||||
|
||||
/** 某人在某阶段最近一次模型调用的结束记录,没有调用为 null。开始了没结束的,恢复入口应当已经补写。 */
|
||||
lastCall(stageId: string, actor: string): Content<"callEnded"> | null {
|
||||
const started = this.of("callStarted").filter((c) => c.stageId === stageId && c.actor === actor).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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user