feat: 账本 ledger 模块(任务书 03)

一张只追加的表(序号、种类、内容、哈希),禁改禁删两条触发器。
有自己编号、只发生一次的 8 种记录加部分唯一索引,重复就报错:阶段开、阶段关、运行登记、
确认启动(确认没启动的结果也算这一条,防止同一次运行既扣次又退还)、运行结果、模型调用开始、结束、消融结算。
写入时只查四种拒收:只收开着的阶段的提交;替换必须接本人同一阶段、同一对象、同一种提交的最新一版,实现与核验类不能替换;
结果先于确认启动(没启动除外);本轮消融已有格启动后不能关解释阶段。
写提案时作者自评拆成封存。提供打开(核对表结构版本,非账本库不改动)、追加、同步事务、读取、未完成的动作。
第一版的可见性、截止序号、通用唯一键、jev 记录和多余的前提检查已按新版任务书清掉。
三轮全新上下文审阅;flow 传错参数一类的意见按 CLAUDE.md"罕见情况怎么办"第 2 条只记录不加代码。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 06:37:47 -04:00
parent 493ea6d89d
commit 716f4b88a5
11 changed files with 1236 additions and 1 deletions
+232
View File
@@ -0,0 +1,232 @@
import { Type, type Static, type TSchema } from "typebox";
import { Value } from "typebox/value";
import {
Id,
JudgedResult,
ModelUsage,
ProcessResult,
Range,
RunDefinition,
STAGES,
SUBMISSION_KINDS,
SUBMISSION_SCHEMAS,
type Json,
type Submission,
type SubmissionKind,
} from "../vocab/index.ts";
// 账本记录的种类(docs/结构.md 第四部分的 11 种记录,细分成下面 19 种),每种一份内容定义(TypeBox)。
// 下面写"只有一条""只一次"的,由 schema.ts 的唯一约束保证。
// 配置、冻结协议、决定、结算的内容由别的模块产生,这里只检查最少的标识字段,其余只检查是 Json。
const CLOSED = { additionalProperties: false } as const;
const Text = Type.String({ minLength: 1 });
const AnyJson = Type.Unsafe<Json>({});
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()]);
// ---------------------------------------------------------------------------
// 提交(记录 3)
// ---------------------------------------------------------------------------
/**
* 一人在同一阶段对同一对象能交好几份的提交种类:提案(每人 1 至 M 条,方法.md 4.3 ②)、
* 挑刺(回应时按挑刺编号逐条答复,方法.md 第五部分)。这两种的每一份带一个由程序分配的编号 item,
* 替换时按 SubmissionSlot 找同一份的最新一版(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况")。
* 其余种类一人对一个对象只交一份,item 在字段定义里就只能为空,所以不能另起一个编号绕开"不能替换"。
*/
const MULTI_ITEM_KINDS: ReadonlySet<SubmissionKind> = 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<SubmissionKind, TSchema> = {
...SUBMISSION_SCHEMAS,
proposal: Type.Omit(SUBMISSION_SCHEMAS.proposal, ["authorEstimate"]),
};
type StoredBody<K extends SubmissionKind> = K extends "proposal"
? Omit<Submission<"proposal">, "authorEstimate">
: Submission<K>;
/** 一份提交的一版。version 从 1 起,替换时加 1,被替换的就是上一版。 */
type SubmissionOf<Body extends Record<SubmissionKind, unknown>> = {
[K in SubmissionKind]: Omit<SubmissionSlot, "kind"> & { version: number; kind: K; content: Body[K] };
}[SubmissionKind];
export type StoredSubmission = SubmissionOf<{ [K in SubmissionKind]: StoredBody<K> }>;
/** 写入时交给 ledger 的提交:提案带着作者自评。 */
export type SubmissionInput = SubmissionOf<{ [K in SubmissionKind]: Submission<K> }>;
/** 某种提交一版的完整字段定义:外层的位置和版本号,加上按提交种类检查的 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,
},
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<SubmissionKind, TSchema>>;
// ---------------------------------------------------------------------------
// 种类表
// ---------------------------------------------------------------------------
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<StoredSubmission>({}),
/**
* 3 退回:被退回的那份内容原样存(它可能正是因为不合格式才被退回),连同退回理由。
* 理由来自 Jev 的,jev 里带原始概率和结论;程序检查退回的,jev 为空。
*/
rejection: Type.Object(
{
stageId: Id,
author: Id,
kind: Type.Enum(SUBMISSION_KIND_NAMES, { type: "string" }),
submitted: AnyJson,
reason: Text,
jev: Type.Union([Type.Object({ probabilities: AnyJson, conclusion: AnyJson }, CLOSED), Type.Null()]),
},
CLOSED,
),
/** 3 私人笔记的每一版:每次修改追加一版,以版本号最大的为准。 */
note: Type.Object({ author: Id, version: Version, 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"], { 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({ ablation: Id, 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 模型调用结束:正常结束,或失败并写明原因。一次调用只有一条。 */
callEnded: Type.Union([
Type.Object({ call: Id, ending: Type.Literal("completed") }, CLOSED),
Type.Object({ call: Id, ending: Type.Literal("failed"), reason: Text }, 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<K extends Kind> = Static<(typeof KINDS)[K]>;
/** 调用方能追加的种类:封存只由 ledger 在写提案时一并写。 */
export type AppendKind = Exclude<Kind, "seal">;
/** 写入时交的内容:除了提交(提案带作者自评),都和存下的内容相同。 */
export type Input<K extends AppendKind> = K extends "submission" ? SubmissionInput : Content<K>;
/** 一条记录。序号就是写入的先后。 */
export type LedgerRecord<K extends Kind = Kind> = {
[P in K]: { seq: number; kind: P; hash: string; content: Content<P> };
}[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)}`);
}
}