import { Type, type Static, type TSchema } from "typebox"; import { Value } from "typebox/value"; import { Id, JevJudgment, JudgedResult, Measured, ModelUsage, ProcessResult, Range, RunDefinition, STAGES, SUBMISSION_KINDS, SUBMISSION_SCHEMAS, type Json, type Submission, type SubmissionKind, } from "../vocab/index.ts"; // 账本记录的种类(docs/结构.md 第四部分的 11 种记录,细分成下面 20 种),每种一份内容定义(TypeBox)。 // 下面写"只有一条""只一次"的,由 schema.ts 的唯一约束保证。 // 配置、冻结协议、决定、结算的内容由别的模块产生,这里只检查最少的标识字段,其余只检查是 Json。 const CLOSED = { additionalProperties: false } as const; const Text = Type.String({ minLength: 1 }); const AnyJson = Type.Unsafe({}); const Round = Type.Integer({ minimum: 0 }); const Version = Type.Integer({ minimum: 1 }); const Seq = Type.Integer({ minimum: 1 }); const OptionalId = Type.Union([Id, Type.Null()]); /** * 这件事用到的 Jev 判断,原样存(docs/结构.md 第四部分:Jev 不单独成记录,记在它判断的那件事里)。 * 一次可能问好几问(如冻结条款三问、代码改动五问),所以是数组,至少一条;没用到 Jev 的写 null。 */ const JevUsed = Type.Union([Type.Array(JevJudgment, { minItems: 1 }), Type.Null()]); // --------------------------------------------------------------------------- // 提交(记录 3) // --------------------------------------------------------------------------- /** * 一人在同一阶段对同一对象能交好几份的提交种类:提案(每人 1 至 M 条,方法.md 4.3 ②)、 * 挑刺(回应时按挑刺编号逐条答复,方法.md 第五部分)。这两种的每一份带一个由程序分配的编号 item, * 替换时按 SubmissionSlot 找同一份的最新一版(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况")。 * 其余种类一人对一个对象只交一份,item 在字段定义里就只能为空,所以不能另起一个编号绕开"不能替换"。 */ const MULTI_ITEM_KINDS: ReadonlySet = new Set(["proposal", "critique"]); /** * 一份提交不论第几版:哪个阶段、谁交的、哪种提交、针对谁(候选或格,没有就空)、这一份的编号(见 MULTI_ITEM_KINDS)。 * 提交种类在位置里:版本号只在同一种提交里往下接,别的种类顶替不了不能替换的实现,提案和挑刺用了同一个编号也接不上。 */ export interface SubmissionSlot { stageId: string; author: string; kind: SubmissionKind; target: string | null; item: string | null; } /** * 账本里存的提案不带作者自评:写入时 ledger 把它拆出来另存成一条封存(方法.md 第三部分"作者自评从交上来那一刻起就封存")。 * 这样给别人看提案时不用去掉什么。 */ const STORED_BODY: Record = { ...SUBMISSION_SCHEMAS, proposal: Type.Omit(SUBMISSION_SCHEMAS.proposal, ["authorEstimate"]), }; type StoredBody = K extends "proposal" ? Omit, "authorEstimate"> : Submission; /** * 一份提交的一版。version 从 1 起,替换时加 1,被替换的就是上一版。 * jev 是收下这一版时用到的 Jev 判断(判"否"才收下,原始概率也存),没用到 Jev 的为 null。 */ type SubmissionOf> = { [K in SubmissionKind]: Omit & { version: number; kind: K; content: Body[K]; jev: Static; }; }[SubmissionKind]; export type StoredSubmission = SubmissionOf<{ [K in SubmissionKind]: StoredBody }>; /** 写入时交给 ledger 的提交:提案带着作者自评。 */ export type SubmissionInput = SubmissionOf<{ [K in SubmissionKind]: Submission }>; /** 某种提交一版的完整字段定义:外层的位置、版本号和 Jev 判断,加上按提交种类检查的 content。 */ function submissionSchema(kind: SubmissionKind, body: TSchema): TSchema { return Type.Object( { stageId: Id, author: Id, target: OptionalId, item: MULTI_ITEM_KINDS.has(kind) ? Id : Type.Null(), version: Version, kind: Type.Literal(kind), content: body, jev: JevUsed, }, CLOSED, ); } const SUBMISSION_KIND_NAMES = Object.keys(SUBMISSION_KINDS) as SubmissionKind[]; const SUBMISSION_FORMS = { input: Object.fromEntries(SUBMISSION_KIND_NAMES.map((k) => [k, submissionSchema(k, SUBMISSION_SCHEMAS[k])])), stored: Object.fromEntries(SUBMISSION_KIND_NAMES.map((k) => [k, submissionSchema(k, STORED_BODY[k])])), } as Record<"input" | "stored", Record>; // --------------------------------------------------------------------------- // 种类表 // --------------------------------------------------------------------------- export const KINDS = { /** 1 研究配置:冻结的配置、步骤清单、规则、方法参数、提示词哈希。内容由配置模块产生,这里只检查是 Json。 */ config: AnyJson, /** 2 阶段开:阶段名、第几轮(第零轮是 0)、这个阶段的应交名单。阶段编号由 flow 起,一个编号只开一次;同一个阶段名可以开好几次(如讨论)。 */ stageOpen: Type.Object( { stageId: Id, stage: Type.Enum(STAGES, { type: "string" }), round: Round, roster: Type.Array(Id, { minItems: 1 }) }, CLOSED, ), /** 2 阶段关:关闭即公开,此后这个阶段不再收提交。一个阶段只关一次。 */ stageClose: Type.Object({ stageId: Id }, CLOSED), /** 3 提交的每一版(经 submissions 收下后由 flow 写入)。字段定义按提交种类选,见 checkContent。 */ submission: Type.Unsafe({}), /** * 3 退回:被退回的那份内容原样存(它可能正是因为不合格式才被退回),连同退回理由。 * target 是这次任务的对象,与提交的 target 同义(候选、原提案、消融格、本轮诊断,没有对象的写 null): * 同一人在同一阶段可能对几个对象各做一次同种任务,重新发起时按它找本人这个对象的上一次退回。 * jev 是这次退回用到的 Jev 判断(见 JevUsed),判"否"的几问也一起存;没用到 Jev 的写 null。 */ rejection: Type.Object( { stageId: Id, author: Id, kind: Type.Enum(SUBMISSION_KIND_NAMES, { type: "string" }), target: OptionalId, submitted: AnyJson, reason: Text, jev: JevUsed, }, CLOSED, ), /** 3 私人笔记:不属于任何阶段,每次修改追加一条,以最后写入的一条(序号最大)为准。 */ note: Type.Object({ author: Id, body: Text }, CLOSED), /** 4 冻结协议:每个候选一份。内容由 flow 合成,先存 Json。 */ protocol: Type.Object({ candidate: Id, protocol: AnyJson }, CLOSED), /** 5 作者自评的封存:proposal 是那一版提案记录的序号。只由 ledger 在写提案时一并写入。 */ seal: Type.Object({ proposal: Seq, range: Range }, CLOSED), /** 5 解封:放出哪几条封存(封存记录的序号)。合并而成的候选有几位作者就有几条。 */ unseal: Type.Object({ candidate: Id, seals: Type.Array(Seq, { minItems: 1 }) }, CLOSED), /** 6 冻结代码:候选或消融格的代码版本号(由 workspace 给出)和因子位置表。位置表先存 Json。 */ code: Type.Object({ object: Id, version: Text, locations: AnyJson }, CLOSED), /** 7 运行·登记要跑(占名额):运行编号由 flow 起,一个编号只登记一次;用途、轮次、对象(候选、格或本轮诊断;基线没有)、运行定义。 */ runPlanned: Type.Object( { run: Id, purpose: Type.Enum(["baseline", "forward", "ablation", "diagnosis"], { type: "string" }), round: Round, object: OptionalId, definition: RunDefinition, }, CLOSED, ), /** 7 运行·确认启动(扣次)。一次运行只有一条,而且和"结果是没启动"只能有一样。 */ runStarted: Type.Object({ run: Id }, CLOSED), /** 7 运行·结果:进程层面的结果,以及拿到产物时的独立判分(没有就空)。一次运行只有一条。 */ runResult: Type.Object({ run: Id, process: ProcessResult, judged: Type.Union([JudgedResult, Type.Null()]) }, CLOSED), /** * 8 决定:选诊断实验、选批、采纳、选格、押的因子、入口分配、判重合并(提案和诊断实验)。decision 是决定种类。 * 输入输出存 Json:ledger 不认识 rules。判重合并的输入里带 Jev 的原始概率。 */ decision: Type.Object( { decision: Id, round: Round, rule: Text, ruleVersion: Type.Integer({ minimum: 1 }), params: AnyJson, seed: Type.Union([Text, Type.Null()]), input: AnyJson, output: AnyJson, }, CLOSED, ), /** 9 结算:每轮最多采纳一个改法,所以最多一次消融,结算按轮次一轮一条。各格结局、配合关系、解释的确认或否定等先存 Json。 */ settlement: Type.Object({ round: Round, result: AnyJson }, CLOSED), /** 9 诊断结算:每轮最多一次诊断,结算也按轮次一轮一条(方法.md 6.12)。每个实验的结局与实测、解释的确认或否定等先存 Json。 */ diagnosisSettlement: Type.Object({ round: Round, result: AnyJson }, CLOSED), /** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */ callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED), /** 10 模型调用每次回复的用量,reply 从 1 起。 */ callUsage: Type.Object({ call: Id, reply: Type.Integer({ minimum: 1 }), usage: ModelUsage }, CLOSED), /** * 10 模型调用结束,一次调用只有一条。status 与 agents 的会话结局(SessionEnding)同名同义: * submitted 至少有一份被收下;notSubmitted 模型自己停下、一份都没收下;failed 模型服务报错等;stopped 用户停下。 * wallSeconds 是这次调用的墙钟时间(秒),四种结局都有:正常结束时由 agents 测出; * 控制进程中断、恢复时补写的结束记录量不到,写 failed、原因"进程中断",墙钟时间记测不到(docs/结构.md 第四部分,CLAUDE.md"中断后怎么恢复")。 */ callEnded: Type.Union([ Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("submitted") }, CLOSED), Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("notSubmitted"), reason: Text }, CLOSED), Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("failed"), reason: Text }, CLOSED), Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("stopped") }, CLOSED), ]), /** 11 事件:违规、"按配置跳过"、"没测"及原因、停下的原因、运行状态未知等。event 是事件种类。 */ event: Type.Object({ event: Id, reason: Text, objects: Type.Array(Id) }, CLOSED), } as const; export type Kind = keyof typeof KINDS; export type Content = Static<(typeof KINDS)[K]>; /** 调用方能追加的种类:封存只由 ledger 在写提案时一并写。 */ export type AppendKind = Exclude; /** 写入时交的内容:除了提交(提案带作者自评),都和存下的内容相同。 */ export type Input = K extends "submission" ? SubmissionInput : Content; /** 一条记录。序号就是写入的先后。 */ export type LedgerRecord = { [P in K]: { seq: number; kind: P; hash: string; content: Content

}; }[K]; function errorsOf(schema: TSchema, value: unknown): string { return Value.Errors(schema, value) .slice(0, 3) .map((e) => `${e.instancePath || "/"} ${e.message}`) .join(";"); } /** * 按种类检查内容。提交按提交种类选字段定义:写入时用带作者自评的那份(input),存下和读出时用不带的(stored)。 */ export function checkContent(kind: Kind, content: unknown, form: "input" | "stored"): void { let schema: TSchema = KINDS[kind]; let label = kind as string; if (kind === "submission") { const sub = (content as { kind?: unknown } | null)?.kind; if (typeof sub !== "string" || !Object.hasOwn(SUBMISSION_KINDS, sub)) { throw new Error(`账本记录"submission"的提交种类不认识:${String(sub)}`); } schema = SUBMISSION_FORMS[form][sub as SubmissionKind]; label = `submission(提交种类 ${sub})`; } if (!Value.Check(schema, content)) { throw new Error(`账本记录"${label}"的内容不符合定义:${errorsOf(schema, content)}`); } }