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:
2026-10-03 10:58:52 -04:00
parent 8491191a2b
commit 75d7cffd27
36 changed files with 2456 additions and 14 deletions
+89
View File
@@ -0,0 +1,89 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { Ledger, type LedgerRecord } from "../../src/ledger/index.ts";
import { entryId, type DecisionShapes } from "../../src/materials/index.ts";
// 读账本的小工具:只经 src/ledger/index.ts 打开、只读记录,不写。
// 入口编号用 materials 的 entryId,和正式代码同一条编号规则。
/** 研究目录里账本的全部记录。账本还没有时报错:Ledger.open 会新建一个空账本,检验不能替 flow 建。 */
export function readLedger(studyDir: string): LedgerRecord[] {
const path = join(studyDir, "ledger.sqlite");
if (!existsSync(path)) throw new Error(`${path} 还不存在`);
const ledger = Ledger.open(path);
try {
return ledger.records();
} finally {
ledger.close();
}
}
export type Of<K extends LedgerRecord["kind"]> = Extract<LedgerRecord, { kind: K }>;
export function ofKind<K extends LedgerRecord["kind"]>(records: LedgerRecord[], kind: K): Array<Of<K>> {
return records.filter((r): r is Of<K> => r.kind === kind);
}
export interface StageInfo {
stageId: string;
stage: string;
round: number;
roster: string[];
closed: boolean;
}
export function stages(records: LedgerRecord[]): StageInfo[] {
const closed = new Set(ofKind(records, "stageClose").map((r) => r.content.stageId));
return ofKind(records, "stageOpen").map(({ content: c }) => ({ ...c, closed: closed.has(c.stageId) }));
}
/** 每份提交(阶段、作者、种类、对象、编号)的最新一版,按这一版的序号排。 */
export function finalSubmissions(records: LedgerRecord[]): Array<Of<"submission">> {
const latest = new Map<string, Of<"submission">>();
for (const r of ofKind(records, "submission")) {
const c = r.content;
const slot = JSON.stringify([c.stageId, c.author, c.kind, c.target, c.item]);
const seen = latest.get(slot);
if (seen === undefined || seen.content.version < c.version) latest.set(slot, r);
}
return [...latest.values()].sort((a, b) => a.seq - b.seq);
}
export interface Entry {
id: string;
author: string;
name: string;
body: string;
}
/**
* 只看给定阶段里的提交,各人最后一版入口清单里的入口(方法.md 4.2:讨论时整份替换的新清单接着往后编号)。
* 给第零轮全部阶段就是分配时的"入口全体";只给写入口阶段就是闭门写完时的入口全体。
*/
export function entriesIn(records: LedgerRecord[], stageIds: ReadonlySet<string>): Entry[] {
const lists = new Map<string, Entry[]>();
const used = new Map<string, number>();
for (const s of finalSubmissions(records)) {
const c = s.content;
if (!stageIds.has(c.stageId) || (c.kind !== "entry" && c.kind !== "discussion")) continue;
const entries = (c.content as { entries?: Array<{ name: string; body: string }> }).entries;
if (entries === undefined) continue;
const n = used.get(c.author) ?? 0;
lists.set(c.author, entries.map((e, i) => ({ id: entryId(c.author, n + i + 1), author: c.author, ...e })));
used.set(c.author, n + entries.length);
}
return [...lists.values()].flat();
}
export type Assignment = DecisionShapes["entries"]["output"][number];
/** 第 0 轮的入口分配决定:输入(全部志愿和入口)和输出(分配结果);没有就是 undefined。 */
export function entryDecision(records: LedgerRecord[]): { input: { entries: Array<{ id: string; author: string }> }; output: Assignment[] } | undefined {
const d = ofKind(records, "decision").find((r) => r.content.decision === "entries" && r.content.round === 0);
return d === undefined ? undefined : (d.content as unknown as { input: { entries: Array<{ id: string; author: string }> }; output: Assignment[] });
}
/** 两份分配是否相同:每人分到的入口和第几志愿都一样,不看先后和对象里键的顺序。 */
export function sameAssignment(a: Assignment[], b: Assignment[]): boolean {
const key = (xs: Assignment[]) => xs.map((x) => `${x.researcher} ${x.entry} ${x.rank}`).sort().join("\n");
return key(a) === key(b);
}