From 716f4b88a5b9049849d2efbc1be2d51daf7fc8ad Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 2 Oct 2026 06:37:47 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A6=E6=9C=AC=20ledger=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=88=E4=BB=BB=E5=8A=A1=E4=B9=A6=2003=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一张只追加的表(序号、种类、内容、哈希),禁改禁删两条触发器。 有自己编号、只发生一次的 8 种记录加部分唯一索引,重复就报错:阶段开、阶段关、运行登记、 确认启动(确认没启动的结果也算这一条,防止同一次运行既扣次又退还)、运行结果、模型调用开始、结束、消融结算。 写入时只查四种拒收:只收开着的阶段的提交;替换必须接本人同一阶段、同一对象、同一种提交的最新一版,实现与核验类不能替换; 结果先于确认启动(没启动除外);本轮消融已有格启动后不能关解释阶段。 写提案时作者自评拆成封存。提供打开(核对表结构版本,非账本库不改动)、追加、同步事务、读取、未完成的动作。 第一版的可见性、截止序号、通用唯一键、jev 记录和多余的前提检查已按新版任务书清掉。 三轮全新上下文审阅;flow 传错参数一类的意见按 CLAUDE.md"罕见情况怎么办"第 2 条只记录不加代码。 Co-Authored-By: Claude Opus 5.5 --- WORK_LOG.md | 7 +- docs/结构.md | 29 ++++ src/ledger/index.ts | 13 ++ src/ledger/kinds.ts | 232 +++++++++++++++++++++++++ src/ledger/ledger.ts | 209 ++++++++++++++++++++++ src/ledger/rejections.ts | 74 ++++++++ src/ledger/schema.ts | 56 ++++++ tests/ledger-helpers.ts | 103 +++++++++++ tests/ledger-pending.test.ts | 57 ++++++ tests/ledger-rejections.test.ts | 158 +++++++++++++++++ tests/ledger-store.test.ts | 299 ++++++++++++++++++++++++++++++++ 11 files changed, 1236 insertions(+), 1 deletion(-) create mode 100644 src/ledger/index.ts create mode 100644 src/ledger/kinds.ts create mode 100644 src/ledger/ledger.ts create mode 100644 src/ledger/rejections.ts create mode 100644 src/ledger/schema.ts create mode 100644 tests/ledger-helpers.ts create mode 100644 tests/ledger-pending.test.ts create mode 100644 tests/ledger-rejections.test.ts create mode 100644 tests/ledger-store.test.ts diff --git a/WORK_LOG.md b/WORK_LOG.md index 2af9bba..feb2069 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -13,6 +13,7 @@ - `docs/结构.md`:13 个模块加 vocab、步骤清单、账本记录种类、可替换点、检查规则、粗估规模(6cf9f0c)。 - 任务书 01(工具链 + vocab):npm 包、TypeScript、`node --test`、dependency-cruiser 规则全写,临时违规实测 14 条都报错;vocab 1030 行,`npm run check` 通过,`npm test` 24 条通过。结构.md 第八部分补了 vocab 对外内容。 - 任务书 02(rules):src/rules 13 个文件、1761 行,`npm test` 192 条通过。预期值来源有三个:方法.md 的手算例;`tests/fixtures/rules/` 下不看实现写的 Python 脚本(重跑后 JSON 逐字节相同);参考项目 calc15、calc14b 里与现行方法一致的部分。审阅做了两轮,每轮都换一个全新上下文的子 Agent。结构.md 第八部分补了 rules 对外内容。 +- 任务书 03(ledger):src/ledger 5 个文件、584 行,`npm test` 226 条通过(ledger 34 条)。一张只追加的表,两条禁改禁删触发器,8 处唯一约束(阶段开、阶段关、运行登记、确认启动或确认没启动、运行结果、模型调用开始、结束、消融结算),写入时只查四种拒收,提案的作者自评拆成封存另存。按第一版写过可见性和通用唯一键,按新版全部清掉了。全新上下文的审阅做了三轮。结构.md 第八部分补了 ledger 对外内容。 ## 关键决定(用户原话见各提交说明) @@ -26,6 +27,7 @@ - 每份预测只对一个候选、每份挑刺只针对一个目标;入口、因子、实现条款的编号由程序按位置分配;实现提交不设代码字段(程序从工作目录冻结);运行结果用"没启动 / 正常退出 / 超时 / 被取消"一个字段表示启动与结束;四种 token 数都可记测不到。用户原话:"3、4、5、6:同意保留你的写法。方法.md 第五部分的表(入口编号、实现的代码字段)由监督者改。" - 账本改为一张只追加的表,全部记录都不能改(ca2cd52)。用户原话:"同意全部只追加"。 - 谁能看什么归 materials(按任务定材料,第一条消息和查询工具是同一份材料的两种给法),ledger 只存;只在"只能一次"处加唯一约束;Jev 不单独成记录,记在它判断的那件事里(0b0cbb0)。用户原话:"给到派的材料有哪些"和"一开始灌进去还是主动等派去查"是分开的事情;Jev"本质只是一种可以代替程序的判别器"。 +- 有自己编号、按定义只发生一次的记录加唯一约束,重复就报错;记录之间的前后顺序检查一律不加,flow 调 ledger 是模块之间的调用,不算边界(493ea6d)。用户原话:"前后顺序检查一律不加。flow 调用 ledger 是模块之间的调用,不算系统边界;顺序由 flow 的唯一入口和它的测试保证。" - 计算时间不再是预算,只记录;预算只有运行次数(42347e4)。用户原话:"CPU 秒不当预算但是要记录"。 - 解释的每格三个数改为可选,关掉消融时不交。用户原话:"同意 A:解释的 cells 去掉 minItems,改为可选"。 - rules 的约定(方法.md 没写死的地方):入池 P < 0.5 严格;采纳和消融中发现都要严格大于采纳线;成本用 max(0, ĝ₀);没测的格附"含它的最好组合"的净值;战绩在分数原值上判;编号一律按自然序比较。用户原话:"1–8 都同意"。消融成分权重跟随 λ、分歧量不掺开放预留,由用户改进方法.md(a050771)。 @@ -55,6 +57,9 @@ - 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。 - 给 config:每批上限必须 ≥ 1(为 0 时 R̂ 是无穷大,选格会卡住)。 - 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。 +- 给 materials:被退回的提案原样存在退回记录里,作者自评没有拆成封存,所以不能把别人的退回记录给研究者看。方法.md 第三部分"作者自评从交上来那一刻起就封存"和"被退回的提交原样记"之间怎么处理,待用户定。 +- 给 flow:确认启动前要先登记运行;模型调用要先开始再结束;阶段要先开再关;消融编号要由采纳确定地算出(否则换编号能重复结算);ledger 都不查。 +- 待用户定:结构.md 第 10 种记录的"整个环境的用量"(实验员会话的 CPU、内存等)在 ledger 里还没有对应的种类;私人笔记的版本号不检查。 - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 ## 工作方式(用户 2026-10-01 定) @@ -68,4 +73,4 @@ ## 下一步 -任务书 02(rules)已核对通过(60c21ae)。`任务书/03-ledger.md` 已写好,等用户另开 Agent 执行,监督者核对。 +任务书 03(ledger)已完成并提交,等监督者核对。 diff --git a/docs/结构.md b/docs/结构.md index 45236ca..c3fd069 100644 --- a/docs/结构.md +++ b/docs/结构.md @@ -383,3 +383,32 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 - `CELL_RULES`、`CellRuleName`:选格规则登记表,四条:信息价值减成本、全测、随机抽同样多的格、都不测。 - `runBatchRule(name, input)`:按名字跑一条选批规则,结果带上规则名和版本,flow 写决定记录时从这里取。 - `runCellRule(name, input)`:按名字跑一条选格规则,结果带上规则名和版本。 + +### ledger + +下面是 `src/ledger/index.ts` 导出的全部内容。账本是一个 SQLite 文件里的一张只追加的表;外面拿不到数据库连接,只能经下面这些函数读写。ledger 不判断谁能看到哪些记录,那归 materials。 + +**打开与写入** + +- `Ledger.open(path)`:打开一份研究的账本,新文件就建表,旧文件核对表结构版本号,对不上就报错。 +- `ledger.close()`:关闭数据库。 +- `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的四种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算;其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。 +- `ledger.transaction(fn)`:里面的几次追加要么全部写进去,要么一条都不写;fn 必须是同步函数,返回值类型排除了 Promise,异步函数在类型检查时就被挡住;不能嵌套。 + +**读取** + +- `ledger.records()`:全部记录,按序号排,读出时按种类重新检查内容。 +- `ledger.stageClosed(stageId)`:这个阶段关了没有。 +- `ledger.latestSubmission(slot)`:某份提交(阶段、作者、对象、编号)的最新一版。 +- `ledger.pending()`:未完成的动作,即登记了还没有结果的运行(带着有没有确认启动)和开始了还没有结束的模型调用,恢复时由 flow 逐个核对。 +- `Pending`(类型):`pending()` 的返回值。 + +**类型** + +- `Kind`:记录的种类,共 19 种:`config`(研究配置)、`stageOpen` / `stageClose`(阶段开、关)、`submission`(提交的每一版)、`rejection`(退回,带原样内容和理由,理由来自 Jev 的带原始概率和结论)、`note`(私人笔记的每一版)、`protocol`(冻结协议)、`seal` / `unseal`(作者自评的封存、解封,都用记录序号指明是哪一条)、`code`(冻结代码)、`runPlanned` / `runStarted` / `runResult`(运行的登记要跑、确认启动、结果)、`decision`(决定)、`settlement`(一次消融的结算)、`callStarted` / `callUsage` / `callEnded`(模型调用的开始,附这次任务的全部材料;每次回复的用量;结束)、`event`(事件)。 +- `AppendKind`:调用方能追加的种类,除了 `seal`(封存只由 ledger 在写提案时一并写)。 +- `Content`:某种记录存下的内容。 +- `Input`:追加时交的内容;只有提交和存下的不同:提案带着作者自评。 +- `LedgerRecord`:一条记录:序号、种类、内容的哈希、内容。 +- `SubmissionSlot`(类型):一份提交不论第几版:阶段、作者、提交种类(版本号只在同一种提交里往下接)、针对谁(候选或格,没有就空)、这一份的编号(只有提案、挑刺这两种一人能交好几份的才有,其余必须为空)。 +- `StoredSubmission`、`SubmissionInput`(类型):存下的提交和追加时交的提交,按提交种类区分内容类型;版本号从 1 起,替换时加 1,被替换的就是上一版。 diff --git a/src/ledger/index.ts b/src/ledger/index.ts new file mode 100644 index 0000000..f450c81 --- /dev/null +++ b/src/ledger/index.ts @@ -0,0 +1,13 @@ +// ledger:研究的全部记录怎么存、怎么保证不能改、写入时拒收什么、花钱的动作怎么先登记 +// (docs/结构.md 第二部分、第四部分)。 +export { Ledger, type Pending } from "./ledger.ts"; +export { + type AppendKind, + type Content, + type Input, + type Kind, + type LedgerRecord, + type StoredSubmission, + type SubmissionInput, + type SubmissionSlot, +} from "./kinds.ts"; diff --git a/src/ledger/kinds.ts b/src/ledger/kinds.ts new file mode 100644 index 0000000..2c484e4 --- /dev/null +++ b/src/ledger/kinds.ts @@ -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({}); +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 = 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,被替换的就是上一版。 */ +type SubmissionOf> = { + [K in SubmissionKind]: Omit & { version: number; kind: K; content: Body[K] }; +}[SubmissionKind]; + +export type StoredSubmission = SubmissionOf<{ [K in SubmissionKind]: StoredBody }>; +/** 写入时交给 ledger 的提交:提案带着作者自评。 */ +export type SubmissionInput = SubmissionOf<{ [K in SubmissionKind]: Submission }>; + +/** 某种提交一版的完整字段定义:外层的位置和版本号,加上按提交种类检查的 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>; + +// --------------------------------------------------------------------------- +// 种类表 +// --------------------------------------------------------------------------- + +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 退回:被退回的那份内容原样存(它可能正是因为不合格式才被退回),连同退回理由。 + * 理由来自 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 = 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)}`); + } +} diff --git a/src/ledger/ledger.ts b/src/ledger/ledger.ts new file mode 100644 index 0000000..375e902 --- /dev/null +++ b/src/ledger/ledger.ts @@ -0,0 +1,209 @@ +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; +import { canonical, hash } from "../vocab/index.ts"; +import { + checkContent, + KINDS, + type AppendKind, + type Content, + type Input, + type Kind, + type LedgerRecord, + type SubmissionInput, + type SubmissionSlot, +} from "./kinds.ts"; +import { checkWrite, type WriteState } from "./rejections.ts"; +import { CREATE_SCHEMA, schemaState } from "./schema.ts"; + +// 一份研究的账本:一个 SQLite 文件、一张只追加的表(docs/结构.md 第四部分)。 +// 同一时刻只有一个控制进程打开它,由 plugin 的文件锁保证,这里不另做判断。 +// 谁能看到哪些记录不归这里,由 materials 按任务定;ledger 把记录原样交给程序。 + +interface Row { + seq: number; + kind: string; + content: string; + hash: string; +} + +/** 还没有结果的花钱动作(方法.md 第九部分)。恢复时 flow 逐个核对,怎么核对不归 ledger。 */ +export interface Pending { + /** 登记了、还没有结果的运行,以及它有没有"确认启动"记录。按登记先后排。 */ + runs: Array<{ plan: LedgerRecord<"runPlanned">; started: boolean }>; + /** 开始了、还没有结束的模型调用。按开始先后排。 */ + calls: Array>; +} + +export class Ledger { + readonly path: string; + readonly #db: DatabaseSync; + #inTransaction = false; + + private constructor(path: string) { + this.path = path; + this.#db = new DatabaseSync(path); + } + + /** 打开一份研究的账本:新文件就建表,旧文件核对表结构版本号,对不上就报错。 */ + static open(path: string): Ledger { + const ledger = new Ledger(path); + if (schemaState(ledger.#db, path) === "new") { + ledger.#atomic(() => { + for (const sql of CREATE_SCHEMA) ledger.#db.exec(sql); + }); + } + return ledger; + } + + close(): void { + this.#db.close(); + } + + /** + * 事务:里面的几次追加要么全部写进去,要么一条都不写(方法.md 4.3 ⑱)。 + * fn 必须是同步函数:await 之后的写入发生在事务提交之后,回滚管不到它。 + * 所以返回值类型排除了 Promise,`npm run check` 会挡住 async 函数。不支持嵌套,嵌套时 SQLite 直接报错。 + */ + transaction(fn: () => T extends PromiseLike ? never : T): T { + return this.#atomic(fn as () => T); + } + + #atomic(fn: () => T): T { + this.#db.exec("BEGIN IMMEDIATE"); + this.#inTransaction = true; + try { + const result = fn(); + this.#db.exec("COMMIT"); + return result; + } catch (error) { + this.#db.exec("ROLLBACK"); + throw error; + } finally { + this.#inTransaction = false; + } + } + + /** + * 追加一条记录,返回存下的那一条。先按种类检查内容,再做写入时的四种拒收检查(rejections.ts)。 + * 写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存,返回的是提案那一条。 + */ + append(kind: K, content: Input): LedgerRecord { + const write = (): LedgerRecord => { + if (kind !== "submission") return this.#put(kind, content as Content); + const input = content as SubmissionInput; + checkContent("submission", input, "input"); + if (input.kind !== "proposal") return this.#put(kind, content as Content); + const { authorEstimate, ...proposal } = input.content; + const record = this.#put("submission", { ...input, content: proposal }); + this.#put("seal", { proposal: record.seq, range: authorEstimate }); + return record as LedgerRecord; + }; + return this.#inTransaction ? write() : this.#atomic(write); + } + + #put(kind: K, content: Content): LedgerRecord { + checkContent(kind, content, "stored"); + checkWrite(kind, content, this.#writeState); + const digest = hash(content); + const result = this.#db + .prepare("INSERT INTO records (kind, content, hash) VALUES (?, ?, ?)") + .run(kind, canonical(content), digest); + return { seq: Number(result.lastInsertRowid), kind, hash: digest, content } as LedgerRecord; + } + + /** 写入时拒收检查要读的状态,和写入在同一个事务里读。 */ + readonly #writeState: WriteState = { + stageClosed: (stageId) => this.stageClosed(stageId), + stageOpen: (stageId) => + this.#first("stageOpen", "json_extract(content, '$.stageId') = ?", stageId) as LedgerRecord<"stageOpen"> | undefined, + latest: (slot) => this.latestSubmission(slot), + runStarted: (run) => this.#first("runStarted", "json_extract(content, '$.run') = ?", run) !== undefined, + ablationStarted: (round) => + this.#one( + `SELECT s.seq FROM records s JOIN records p + ON p.kind = 'runPlanned' AND json_extract(p.content, '$.run') = json_extract(s.content, '$.run') + WHERE s.kind = 'runStarted' + AND json_extract(p.content, '$.purpose') = 'ablation' + AND json_extract(p.content, '$.round') = ?`, + round, + ) !== undefined, + }; + + // ------------------------------------------------------------------------- + // 读取 + // ------------------------------------------------------------------------- + + /** 全部记录,按序号排;读出的每条都按种类重新检查内容。 */ + records(): LedgerRecord[] { + return this.#all("SELECT * FROM records ORDER BY seq").map((row) => this.#record(row)); + } + + /** 这个阶段关了没有。 */ + stageClosed(stageId: string): boolean { + return this.#first("stageClose", "json_extract(content, '$.stageId') = ?", stageId) !== undefined; + } + + /** 某份提交的最新一版(本人在这个阶段对这个对象、这个编号交的这种提交里版本号最大的那一版),一版都没有就是 undefined。 */ + latestSubmission(slot: SubmissionSlot): LedgerRecord<"submission"> | undefined { + const row = this.#one( + `SELECT * FROM records + WHERE kind = 'submission' + AND json_extract(content, '$.stageId') = ? + AND json_extract(content, '$.author') = ? + AND json_extract(content, '$.kind') = ? + AND json_extract(content, '$.target') IS ? + AND json_extract(content, '$.item') IS ? + ORDER BY json_extract(content, '$.version') DESC LIMIT 1`, + slot.stageId, + slot.author, + slot.kind, + slot.target, + slot.item, + ); + return row === undefined ? undefined : (this.#record(row) as LedgerRecord<"submission">); + } + + /** 未完成的动作:登记了但没有结果的运行,开始了但没有结束的模型调用。按运行编号、调用编号对上。 */ + pending(): Pending { + const runs = this.#all( + `SELECT * FROM records p WHERE p.kind = 'runPlanned' + AND NOT EXISTS (SELECT 1 FROM records r WHERE r.kind = 'runResult' + AND json_extract(r.content, '$.run') = json_extract(p.content, '$.run')) + ORDER BY p.seq`, + ).map((row) => { + const plan = this.#record(row) as LedgerRecord<"runPlanned">; + return { plan, started: this.#writeState.runStarted(plan.content.run) }; + }); + const calls = this.#all( + `SELECT * FROM records c WHERE c.kind = 'callStarted' + AND NOT EXISTS (SELECT 1 FROM records e WHERE e.kind = 'callEnded' + AND json_extract(e.content, '$.call') = json_extract(c.content, '$.call')) + ORDER BY c.seq`, + ).map((row) => this.#record(row) as LedgerRecord<"callStarted">); + return { runs, calls }; + } + + /** 某种记录里满足条件的第一条(按序号),没有是 undefined。 */ + #first(kind: Kind, where: string, ...params: SQLInputValue[]): LedgerRecord | undefined { + const row = this.#one(`SELECT * FROM records WHERE kind = ? AND ${where} ORDER BY seq LIMIT 1`, kind, ...params); + return row === undefined ? undefined : this.#record(row); + } + + #one(sql: string, ...params: SQLInputValue[]): Row | undefined { + return this.#db.prepare(sql).get(...params) as Row | undefined; + } + + #all(sql: string, ...params: SQLInputValue[]): Row[] { + return this.#db.prepare(sql).all(...params) as unknown as Row[]; + } + + /** 把一行还原成记录,并按种类检查内容;种类不认识或内容不符合定义都直接报错。 */ + #record(row: Row): LedgerRecord { + if (!Object.hasOwn(KINDS, row.kind)) { + throw new Error(`账本第 ${row.seq} 条记录的种类"${row.kind}"不认识`); + } + const kind = row.kind as Kind; + const content: unknown = JSON.parse(row.content); + checkContent(kind, content, "stored"); + return { seq: Number(row.seq), kind, hash: row.hash, content } as LedgerRecord; + } +} diff --git a/src/ledger/rejections.ts b/src/ledger/rejections.ts new file mode 100644 index 0000000..21ad79c --- /dev/null +++ b/src/ledger/rejections.ts @@ -0,0 +1,74 @@ +import { SUBMISSION_KINDS, type Stage } from "../vocab/index.ts"; +import type { Content, Kind, LedgerRecord, SubmissionSlot } from "./kinds.ts"; + +// 写入时由 ledger 拒绝的四种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),在写入的同一个事务里查。 +// 只有这四种。记录之间别的先后(例如运行结果前要先有登记)和提交种类与阶段对不对得上都不查: +// flow 调 ledger 是模块之间的调用,不是系统边界。"只发生一次"由 schema.ts 的唯一约束管。 +// 提交内容的字段归 submissions,该不该开下一个阶段归 flow。 + +/** 检查要读的几样当前状态,由 Ledger 在同一个事务里提供。 */ +export interface WriteState { + stageClosed(stageId: string): boolean; + /** 开这个阶段的那条记录,没有是 undefined。 */ + stageOpen(stageId: string): LedgerRecord<"stageOpen"> | undefined; + /** 某份提交现在的最新一版,一版都没有是 undefined。 */ + latest(slot: SubmissionSlot): LedgerRecord<"submission"> | undefined; + runStarted(run: string): boolean; + /** 这一轮有没有消融格的运行已经确认启动。 */ + ablationStarted(round: number): boolean; +} + +/** 实现和核验阶段的提交不能替换(方法.md 第五部分总原则):实现、修复、核验、消融实现、消融核验。 */ +const NOT_REPLACEABLE: ReadonlySet = new Set([ + "implementation", + "verification", + "ablationImplementation", + "ablationVerification", +]); + +export function checkWrite(kind: Kind, content: unknown, state: WriteState): void { + if (kind === "submission") checkSubmission(content as Content<"submission">, state); + if (kind === "runResult") checkRunResult(content as Content<"runResult">, state); + if (kind === "stageClose") checkStageClose(content as Content<"stageClose">, state); +} + +/** + * 第 1 条:只收开着的阶段的提交,即有开阶段记录、没有关阶段记录。 + * 第 2 条:新的一版必须正好接在本人在这个阶段、对同一个对象的最新一版后面(第一版是 1); + * 实现、修复、核验、消融实现、消融核验只能交第一版。 + */ +function checkSubmission(s: Content<"submission">, state: WriteState): void { + const where = `提交 ${s.kind}(阶段 ${s.stageId},作者 ${s.author},对象 ${s.target ?? "无"},编号 ${s.item ?? "无"},第 ${s.version} 版)`; + if (state.stageOpen(s.stageId) === undefined || state.stageClosed(s.stageId)) { + throw new Error(`拒收${where}:阶段 ${s.stageId} 没有开着,只收开着的阶段的提交`); + } + const stage = SUBMISSION_KINDS[s.kind].stage; + if (s.version > 1 && NOT_REPLACEABLE.has(stage)) { + throw new Error(`拒收${where}:${stage} 阶段的提交不能替换(方法.md 第五部分)`); + } + const latest = state.latest(s)?.content.version ?? 0; + if (s.version !== latest + 1) { + throw new Error( + latest === 0 + ? `拒收${where}:本人在这个阶段对这个对象还没有交过,第一份应是第 1 版` + : `拒收${where}:要替换的第 ${s.version - 1} 版不是本人在这个阶段对这个对象的最新一版(最新是第 ${latest} 版)`, + ); + } +} + +/** 第 3 条:运行结果要写在"确认启动"之后。例外是"没启动":它本来就没有启动记录(方法.md 第七部分)。 */ +function checkRunResult(r: Content<"runResult">, state: WriteState): void { + if (r.process.ending !== "notStarted" && !state.runStarted(r.run)) { + throw new Error(`拒收运行 ${r.run} 的结果(${r.process.ending}):这次运行还没有"确认启动"记录`); + } +} + +/** 第 4 条:这一轮已经有消融格确认启动了,就不能再关解释阶段(方法.md 4.3 ⑭:解释冻结必须早于任何一格启动)。 */ +function checkStageClose(c: Content<"stageClose">, state: WriteState): void { + const open = state.stageOpen(c.stageId); + if (open?.content.stage === "explanation" && state.ablationStarted(open.content.round)) { + throw new Error( + `不能关解释阶段 ${c.stageId}:第 ${open.content.round} 轮已经有消融格确认启动,解释冻结必须早于任何一格启动(方法.md 4.3 ⑭)`, + ); + } +} diff --git a/src/ledger/schema.ts b/src/ledger/schema.ts new file mode 100644 index 0000000..da73f66 --- /dev/null +++ b/src/ledger/schema.ts @@ -0,0 +1,56 @@ +import type { DatabaseSync } from "node:sqlite"; + +// 账本的表结构(docs/结构.md 第四部分"全部只追加"):只有一张表,每一行是序号、种类、内容、内容的哈希。 +// 表结构一改就把版本号加 1;旧文件版本号对不上时打开报错,不迁移,也不逐字核对建表语句。 + +export const SCHEMA_VERSION = 1; + +/** 新文件要执行的建表语句,由 Ledger 在一个事务里执行,最后写入版本号。 */ +export const CREATE_SCHEMA = [ + // 序号用 AUTOINCREMENT:只增不复用,就是写入的先后。STRICT 让列的类型真的生效。 + `CREATE TABLE records ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + content TEXT NOT NULL, + hash TEXT NOT NULL + ) STRICT`, + // 整张表禁止修改、禁止删除。要更正就追加一条新记录。 + `CREATE TRIGGER records_no_update BEFORE UPDATE ON records + BEGIN SELECT RAISE(ABORT, '账本只能追加:禁止修改记录'); END`, + `CREATE TRIGGER records_no_delete BEFORE DELETE ON records + BEGIN SELECT RAISE(ABORT, '账本只能追加:禁止删除记录'); END`, + // 唯一约束:有自己编号、按定义只发生一次的记录,同一个编号只能有一条,重复就报错。其余记录照写,不设唯一键。 + // 记录之间的先后(例如结束前要先有开始)不在这里查。 + `CREATE UNIQUE INDEX stage_open_once ON records (json_extract(content, '$.stageId')) WHERE kind = 'stageOpen'`, + `CREATE UNIQUE INDEX stage_close_once ON records (json_extract(content, '$.stageId')) WHERE kind = 'stageClose'`, + `CREATE UNIQUE INDEX run_planned_once ON records (json_extract(content, '$.run')) WHERE kind = 'runPlanned'`, + // "确认启动"只能有一条,而且"确认没启动"(结果是没启动)也算在这一条里: + // 两样都有,就是同一次运行既扣次又退还(方法.md 第七部分"什么时候扣次数")。 + `CREATE UNIQUE INDEX run_started_or_not_started_once ON records (json_extract(content, '$.run')) + WHERE kind = 'runStarted' OR (kind = 'runResult' AND json_extract(content, '$.process.ending') = 'notStarted')`, + `CREATE UNIQUE INDEX run_result_once ON records (json_extract(content, '$.run')) WHERE kind = 'runResult'`, + `CREATE UNIQUE INDEX call_started_once ON records (json_extract(content, '$.call')) WHERE kind = 'callStarted'`, + `CREATE UNIQUE INDEX call_ended_once ON records (json_extract(content, '$.call')) WHERE kind = 'callEnded'`, + `CREATE UNIQUE INDEX settlement_once ON records (json_extract(content, '$.ablation')) WHERE kind = 'settlement'`, + `PRAGMA user_version=${SCHEMA_VERSION}`, +]; + +/** + * 看这个文件是新文件还是当前版本的账本,别的情况报错;是账本才设连接参数,不改别人的库。 + * synchronous=FULL:提交即落盘。账本存的是"预测冻结在结果之前"的证据,掉电丢掉最后几条等于丢证据。 + * 版本号存在 SQLite 自带的 user_version 里;新文件的 user_version 是 0,而且没有任何表。 + */ +export function schemaState(db: DatabaseSync, path: string): "new" | "current" { + const version = Number((db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version); + const objects = Number((db.prepare("SELECT count(*) AS n FROM sqlite_master").get() as { n: number }).n); + const state = version === SCHEMA_VERSION ? "current" : version === 0 && objects === 0 ? "new" : undefined; + if (state !== undefined) { + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA synchronous=FULL"); + return state; + } + throw new Error( + `账本 ${path} 的表结构版本是 ${version}(库里已有 ${objects} 个表、索引或触发器),本程序只认版本 ${SCHEMA_VERSION}。` + + "旧研究不迁移,请另开一份新研究", + ); +} diff --git a/tests/ledger-helpers.ts b/tests/ledger-helpers.ts new file mode 100644 index 0000000..5598a6f --- /dev/null +++ b/tests/ledger-helpers.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TestContext } from "node:test"; +import { Ledger } from "../src/ledger/index.ts"; +import type { ModelUsage, ProcessResult, Range, RunDefinition, Stage, Submission } from "../src/vocab/index.ts"; + +// ledger 测试共用:每个测试在自己的临时目录里建一个真实的数据库文件,测试结束时连目录一起删掉。 + +export function tempDir(t: TestContext): string { + const dir = mkdtempSync(join(tmpdir(), "inforsi-ledger-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +export function freshLedger(t: TestContext): { ledger: Ledger; path: string } { + const path = join(tempDir(t), "ledger.db"); + const ledger = Ledger.open(path); + t.after(() => ledger.close()); + return { ledger, path }; +} + +export const range = (q1: number, q5: number, q9: number): Range => ({ q1, q5, q9 }); + +export function proposal(estimate: Range, body = "提案正文"): Submission<"proposal"> { + return { + factors: [{ name: "换优化器", method: "把 SGD 换成 Adam", keySettings: "学习率 0.01", continues: null }], + safeguards: [], + metrics: { defined: [], reused: [] }, + authorEstimate: estimate, + body, + }; +} + +export const prediction = (r: Range): Submission<"firstPrediction"> => ({ range: r, body: "理由" }); +export const critique = (body = "挑刺内容"): Submission<"critique"> => ({ target: "whole", body }); +export const wrapUp = (body = "收尾"): Submission<"wrapUp"> => ({ body }); + +export const implementation = (body = "无"): Submission<"implementation"> => ({ + factorLocations: [{ factor: "p1", locations: ["solve.py:10-20"] }], + safeguardLocations: [], + unassigned: [], + concerns: "无", + body, +}); + +export const definition: RunDefinition = { + command: ["python", "solve.py"], + workdir: "/work", + image: { digest: `sha256:${"0".repeat(64)}`, runtime: "runc" }, + wallSeconds: 120, + cores: 1, + inheritEnv: false, + env: {}, +}; + +export const exited: ProcessResult = { + ending: "exited", + exitCode: 0, + cpuSeconds: { status: "measured", value: 100 }, + wallSeconds: { status: "measured", value: 101 }, + peakMemoryBytes: { status: "measured", value: 1e8 }, +}; + +export const notStarted: ProcessResult = { ending: "notStarted", reason: "启动前取消" }; + +/** 开一个阶段,返回开阶段那条记录的序号。 */ +export function openStage( + ledger: Ledger, + stageId: string, + stage: Stage, + round: number, + roster: string[] = ["r1", "r2"], +): number { + return ledger.append("stageOpen", { stageId, stage, round, roster }).seq; +} + +export function closeStage(ledger: Ledger, stageId: string): number { + return ledger.append("stageClose", { stageId }).seq; +} + +/** 登记一次运行。 */ +export function planRun(ledger: Ledger, run: string, purpose: "baseline" | "forward" | "ablation", round: number): number { + return ledger.append("runPlanned", { run, purpose, round, object: purpose === "baseline" ? null : `obj-${run}`, definition }).seq; +} + +/** 一次模型调用的开始记录,materials 是这次任务的全部材料。 */ +export const callStart = (call: string, actor = "r1", stageId: string | null = null) => ({ + call, + actor, + stageId, + model: "glm-5.3", + materials: { firstMessage: "任务说明……", tools: ["read", "submit"] }, +}); + +const unknownCount = { status: "unmeasured" as const, reason: "服务没报" }; +export const usage: ModelUsage = { + inputTokens: unknownCount, + cacheReadTokens: unknownCount, + cacheWriteTokens: unknownCount, + outputTokens: unknownCount, + cost: unknownCount, +}; diff --git a/tests/ledger-pending.test.ts b/tests/ledger-pending.test.ts new file mode 100644 index 0000000..4910909 --- /dev/null +++ b/tests/ledger-pending.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { test } from "node:test"; +import { Ledger } from "../src/ledger/index.ts"; +import { callStart, exited, freshLedger, notStarted, planRun, tempDir, usage } from "./ledger-helpers.ts"; + +// 未完成的动作(方法.md 第九部分"中断与恢复时的记账含义":先登记、再执行、再登记结果): +// 登记了但没有结果的运行、开始了但没有结束的模型调用能被找出来;写了结果之后就不再出现。 + +test("运行:登记了没结果的被找出来,带着有没有确认启动;写了结果就不再出现", (t) => { + const { ledger } = freshLedger(t); + planRun(ledger, "base", "baseline", 0); + planRun(ledger, "run1", "forward", 1); + planRun(ledger, "run2", "forward", 1); + ledger.append("runStarted", { run: "base" }); + ledger.append("runStarted", { run: "run1" }); + ledger.append("runResult", { run: "base", process: exited, judged: null }); + assert.deepEqual( + ledger.pending().runs.map((r) => [r.plan.content.run, r.started]), + [ + ["run1", true], + ["run2", false], + ], + ); + ledger.append("runResult", { run: "run2", process: notStarted, judged: null }); + assert.deepEqual(ledger.pending().runs.map((r) => r.plan.content.run), ["run1"]); + ledger.append("runResult", { run: "run1", process: exited, judged: null }); + assert.deepEqual(ledger.pending().runs, []); +}); + +test("模型调用:开始了没结束的被找出来,带着这次任务的全部材料;有用量也还算没结束;结束后不再出现", (t) => { + const { ledger } = freshLedger(t); + ledger.append("callStarted", callStart("k1", "r1", "s1")); + ledger.append("callStarted", callStart("k2", "e1")); + ledger.append("callUsage", { call: "k1", reply: 1, usage }); + ledger.append("callEnded", { call: "k2", ending: "failed", reason: "接口超时" }); + const calls = ledger.pending().calls; + assert.deepEqual(calls.map((c) => c.content.call), ["k1"]); + assert.deepEqual(calls[0]!.content.materials, callStart("k1").materials); + ledger.append("callEnded", { call: "k1", ending: "completed" }); + assert.deepEqual(ledger.pending(), { runs: [], calls: [] }); +}); + +test("关掉再打开:未完成的动作照样找得出来", (t) => { + const path = join(tempDir(t), "ledger.db"); + const ledger = Ledger.open(path); + planRun(ledger, "run1", "forward", 1); + ledger.append("runStarted", { run: "run1" }); + ledger.append("callStarted", callStart("k1")); + const before = ledger.pending(); + ledger.close(); + const reopened = Ledger.open(path); + t.after(() => reopened.close()); + assert.deepEqual(reopened.pending(), before); + assert.equal(before.runs.length, 1); + assert.equal(before.calls.length, 1); +}); diff --git a/tests/ledger-rejections.test.ts b/tests/ledger-rejections.test.ts new file mode 100644 index 0000000..d15ec51 --- /dev/null +++ b/tests/ledger-rejections.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Ledger } from "../src/ledger/index.ts"; +import { + closeStage, + critique, + exited, + freshLedger, + implementation, + notStarted, + openStage, + planRun, + prediction, + proposal, + range, + wrapUp, +} from "./ledger-helpers.ts"; + +// 写入时由 ledger 拒绝的四种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),每种一条拒收、一条对应的正常情况。 +// 预期值来自文字: +// 1. 只收开着的阶段(有开阶段记录、没有关阶段记录)的提交,别的拒收; +// 2. 要替换的那一份不是本人在这个阶段、对同一个对象的最新一版 → 拒收;实现、修复、核验、消融实现、消融核验不能替换(方法.md 第五部分); +// 3. 运行结果写在"确认启动"之前 → 拒收;"没启动"的结果不需要启动记录(方法.md 第七部分); +// 4. 这一轮消融已经有格确认启动了,才来关这一轮的解释阶段 → 拒收(方法.md 4.3 ⑭)。 + +function submitWrapUp(ledger: Ledger, stageId: string, author: string, version: number, body = "收尾") { + return ledger.append("submission", { stageId, author, target: null, item: null, version, kind: "wrapUp", content: wrapUp(body) }); +} + +test("拒收 1:只收开着的阶段的提交:没开过的不收,关了以后不收、替换也不收;开着时收", (t) => { + const { ledger } = freshLedger(t); + assert.throws(() => submitWrapUp(ledger, "s1", "r1", 1), /没有开着/); + openStage(ledger, "s1", "wrapUp", 1); + submitWrapUp(ledger, "s1", "r1", 1); + closeStage(ledger, "s1"); + assert.throws(() => submitWrapUp(ledger, "s1", "r2", 1), /没有开着/); + assert.throws(() => submitWrapUp(ledger, "s1", "r1", 2, "关闭后想替换"), /没有开着/); + assert.equal(ledger.records().filter((r) => r.kind === "submission").length, 1); +}); + +test("拒收 2 的正常情况:替换本人在这个阶段、对同一个对象的最新一版,版本号加 1", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "s1", "firstPrediction", 1); + const slot = { stageId: "s1", author: "r1", target: "c1", item: null, kind: "firstPrediction" as const }; + ledger.append("submission", { ...slot, version: 1, content: prediction(range(1, 2, 3)) }); + ledger.append("submission", { ...slot, version: 2, content: prediction(range(1, 2, 4)) }); + ledger.append("submission", { ...slot, version: 3, content: prediction(range(1, 2, 5)) }); + assert.equal(ledger.latestSubmission(slot)?.content.version, 3); +}); + +test("拒收 2:要替换的那一份不是最新一版、不是本人的、不是同一个对象的、不在这个阶段,都拒收", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "s1", "firstPrediction", 1); + openStage(ledger, "s2", "firstPrediction", 1); + const slot = { stageId: "s1", author: "r1", target: "c1", item: null, kind: "firstPrediction" as const }; + ledger.append("submission", { ...slot, version: 1, content: prediction(range(1, 2, 3)) }); + ledger.append("submission", { ...slot, version: 2, content: prediction(range(1, 2, 4)) }); + const next = { ...slot, version: 3, content: prediction(range(1, 2, 6)) }; + // 想替换第 1 版,但最新是第 2 版。 + assert.throws(() => ledger.append("submission", { ...slot, version: 2, content: prediction(range(0, 2, 4)) }), /最新是第 2 版/); + // 要替换的第 3 版不存在。 + assert.throws(() => ledger.append("submission", { ...slot, version: 4, content: prediction(range(1, 2, 6)) }), /最新是第 2 版/); + // 不是本人的、不是同一个对象的、不在这个阶段的:本人在那里还没有交过。 + assert.throws(() => ledger.append("submission", { ...next, author: "r2" }), /还没有交过/); + assert.throws(() => ledger.append("submission", { ...next, target: "c2" }), /还没有交过/); + assert.throws(() => ledger.append("submission", { ...next, stageId: "s2" }), /还没有交过/); + assert.equal(ledger.latestSubmission(slot)?.content.version, 2); +}); + +test("拒收 2:同一个人对同一个候选的几份挑刺按各自的编号分开算版本", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "s1", "critique", 1); + const slot = { stageId: "s1", author: "r1", target: "c1", kind: "critique" as const }; + ledger.append("submission", { ...slot, item: "k1", version: 1, content: critique("第一份") }); + ledger.append("submission", { ...slot, item: "k2", version: 1, content: critique("第二份") }); + ledger.append("submission", { ...slot, item: "k1", version: 2, content: critique("第一份改") }); + assert.throws(() => ledger.append("submission", { ...slot, item: "k3", version: 2, content: critique("x") }), /还没有交过/); +}); + +test("拒收 2:实现、修复、核验、消融实现、消融核验只收第一版,不能替换", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "impl", "implementation", 1, ["e1"]); + openStage(ledger, "fix", "implementation", 1, ["e1"]); + openStage(ledger, "ver", "verification", 1, ["v1"]); + openStage(ledger, "aimpl", "ablationImplementation", 1, ["e1"]); + openStage(ledger, "aver", "ablationVerification", 1, ["v1"]); + const base = { author: "e1", target: "c1", item: null }; + const verification = { clauses: [], factors: [], safeguards: [], unassigned: [], body: "无" }; + const ablImpl = { alsoOff: [], cannotRun: null, concerns: "无" }; + const cases = [ + { ...base, stageId: "impl", kind: "implementation" as const, content: implementation() }, + { ...base, stageId: "fix", kind: "repair" as const, content: implementation("修复") }, + { ...base, author: "v1", stageId: "ver", kind: "verification" as const, content: verification }, + { ...base, stageId: "aimpl", target: "cell1", kind: "ablationImplementation" as const, content: ablImpl }, + { ...base, author: "v1", stageId: "aver", target: "cell1", kind: "ablationVerification" as const, content: { factors: [] } }, + ]; + for (const c of cases) { + ledger.append("submission", { ...c, version: 1 }); + assert.throws(() => ledger.append("submission", { ...c, version: 2 }), /不能替换/, c.kind); + } +}); + +test("拒收 2:版本号只在同一种提交里接:不能替换的实现不能被别的种类顶替,提案和挑刺用同一个编号也接不上", (t) => { + // 同一位置上,实现 v1 之后交收尾 v2 拒收,最新一版仍是实现;提案和挑刺用同一个编号,版本号各算各的。 + const { ledger } = freshLedger(t); + openStage(ledger, "impl", "implementation", 1, ["e1"]); + const implSlot = { stageId: "impl", author: "e1", target: "c1", item: null }; + ledger.append("submission", { ...implSlot, version: 1, kind: "implementation", content: implementation() }); + assert.throws(() => ledger.append("submission", { ...implSlot, version: 2, kind: "wrapUp", content: wrapUp() }), /还没有交过/); + assert.equal(ledger.latestSubmission({ ...implSlot, kind: "implementation" })?.content.kind, "implementation"); + + openStage(ledger, "mix", "proposal", 1); + const mixSlot = { stageId: "mix", author: "r1", target: null, item: "x1" }; + ledger.append("submission", { ...mixSlot, version: 1, kind: "proposal", content: proposal(range(1, 2, 3)) }); + assert.throws(() => ledger.append("submission", { ...mixSlot, version: 2, kind: "critique", content: critique() }), /还没有交过/); + assert.equal(ledger.latestSubmission({ ...mixSlot, kind: "critique" }), undefined); +}); + +test("拒收 3:没有确认启动就写运行结果,拒收;确认启动后可以写", (t) => { + const { ledger } = freshLedger(t); + planRun(ledger, "run1", "forward", 1); + assert.throws(() => ledger.append("runResult", { run: "run1", process: exited, judged: null }), /还没有"确认启动"/); + ledger.append("runStarted", { run: "run1" }); + ledger.append("runResult", { run: "run1", process: exited, judged: null }); +}); + +test("拒收 3 的例外:确认没启动的结果不需要启动记录", (t) => { + const { ledger } = freshLedger(t); + planRun(ledger, "run1", "forward", 1); + ledger.append("runResult", { run: "run1", process: notStarted, judged: null }); + assert.deepEqual(ledger.pending().runs, []); +}); + +test("拒收 4:这一轮已经有消融格确认启动,就不能关这一轮的解释阶段", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "expl", "explanation", 2); + planRun(ledger, "cellrun", "ablation", 2); + ledger.append("runStarted", { run: "cellrun" }); + assert.throws(() => closeStage(ledger, "expl"), /解释冻结必须早于任何一格启动/); + assert.equal(ledger.stageClosed("expl"), false); +}); + +test("拒收 4 的正常情况:只登记没启动、别的轮的消融启动、本轮正向启动,都不挡关解释阶段;关别的阶段不受限", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "expl", "explanation", 2); + planRun(ledger, "cell-planned", "ablation", 2); // 只登记,还没确认启动 + planRun(ledger, "cell-round1", "ablation", 1); + ledger.append("runStarted", { run: "cell-round1" }); + planRun(ledger, "fwd-round2", "forward", 2); + ledger.append("runStarted", { run: "fwd-round2" }); + closeStage(ledger, "expl"); + assert.equal(ledger.stageClosed("expl"), true); + openStage(ledger, "wrap", "wrapUp", 2); + planRun(ledger, "cell2", "ablation", 2); + ledger.append("runStarted", { run: "cell2" }); + closeStage(ledger, "wrap"); + assert.equal(ledger.stageClosed("wrap"), true); +}); diff --git a/tests/ledger-store.test.ts b/tests/ledger-store.test.ts new file mode 100644 index 0000000..47374d0 --- /dev/null +++ b/tests/ledger-store.test.ts @@ -0,0 +1,299 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { test } from "node:test"; +import { Ledger } from "../src/ledger/index.ts"; +import { hash } from "../src/vocab/index.ts"; +import { + callStart, + closeStage, + exited, + freshLedger, + implementation, + notStarted, + openStage, + planRun, + proposal, + range, + tempDir, +} from "./ledger-helpers.ts"; + +// 预期值照 docs/结构.md 第四部分"账本记录"、方法.md 第七部分"什么时候扣次数"和第九部分的文字写: +// 整张表禁改禁删;按定义只发生一次的记录重复就报错,其余照写;事务要么全写要么全不写; +// 关掉再打开读出不变;表结构版本不对打开报错。 + +function countRows(path: string): number { + const db = new DatabaseSync(path); + const n = Number((db.prepare("SELECT count(*) AS n FROM records").get() as { n: number }).n); + db.close(); + return n; +} + +test("触发器:直接用 SQL 执行 UPDATE、DELETE 都被拒绝,记录不变", (t) => { + const { ledger, path } = freshLedger(t); + ledger.append("config", { n: 2 }); + const before = ledger.records(); + const db = new DatabaseSync(path); + t.after(() => db.close()); + assert.throws(() => db.exec("UPDATE records SET content = '{\"n\":3}'"), /禁止修改/); + assert.throws(() => db.exec("UPDATE records SET hash = 'x' WHERE seq = 1"), /禁止修改/); + assert.throws(() => db.exec("DELETE FROM records"), /禁止删除/); + assert.throws(() => db.exec("DELETE FROM records WHERE seq = 1"), /禁止删除/); + assert.deepEqual(ledger.records(), before); +}); + +// 唯一约束(schema.ts):有自己编号、按定义只发生一次的记录,同一个编号写第二条就报错,内容完全相同也报错, +// 不多写一条;别的编号照常写。 + +test("唯一约束:同一个阶段开两次报错", (t) => { + const { ledger, path } = freshLedger(t); + openStage(ledger, "s1", "proposal", 1); + assert.throws(() => openStage(ledger, "s1", "proposal", 1), /stage_open_once/); + openStage(ledger, "s2", "proposal", 1); + assert.equal(countRows(path), 2); +}); + +test("唯一约束:同一个阶段关两次报错", (t) => { + const { ledger, path } = freshLedger(t); + openStage(ledger, "s1", "proposal", 1); + closeStage(ledger, "s1"); + assert.throws(() => closeStage(ledger, "s1"), /stage_close_once/); + assert.equal(countRows(path), 2); +}); + +test("唯一约束:同一次运行登记两次报错", (t) => { + const { ledger, path } = freshLedger(t); + planRun(ledger, "run1", "forward", 1); + assert.throws(() => planRun(ledger, "run1", "forward", 1), /run_planned_once/); + planRun(ledger, "run2", "forward", 1); + assert.equal(countRows(path), 2); +}); + +test("唯一约束:同一次运行确认启动两次报错", (t) => { + const { ledger, path } = freshLedger(t); + planRun(ledger, "run1", "forward", 1); + ledger.append("runStarted", { run: "run1" }); + assert.throws(() => ledger.append("runStarted", { run: "run1" }), /run_started_or_not_started_once/); + assert.equal(countRows(path), 2); +}); + +test("唯一约束:确认启动和\"结果是没启动\"算同一条,一次运行只能有一样,两种先后都报错", (t) => { + // 登记→确认启动→结果没启动、登记→结果没启动→确认启动,第三步都报错:一次运行不能既扣次又退还(方法.md 第七部分)。 + const { ledger, path } = freshLedger(t); + planRun(ledger, "run1", "forward", 1); + ledger.append("runStarted", { run: "run1" }); + assert.throws(() => ledger.append("runResult", { run: "run1", process: notStarted, judged: null }), /run_started_or_not_started_once/); + planRun(ledger, "run2", "forward", 1); + ledger.append("runResult", { run: "run2", process: notStarted, judged: null }); + assert.throws(() => ledger.append("runStarted", { run: "run2" }), /run_started_or_not_started_once/); + // 正常情况:确认启动后写启动过的结果。 + ledger.append("runResult", { run: "run1", process: exited, judged: null }); + assert.equal(countRows(path), 5); // 两条登记、一条确认启动、两条结果 +}); + +test("唯一约束:同一次运行写两个结果报错", (t) => { + const { ledger, path } = freshLedger(t); + planRun(ledger, "run1", "forward", 1); + ledger.append("runStarted", { run: "run1" }); + ledger.append("runResult", { run: "run1", process: exited, judged: null }); + assert.throws(() => ledger.append("runResult", { run: "run1", process: exited, judged: null }), /run_result_once/); + assert.equal(countRows(path), 3); +}); + +test("唯一约束:同一次模型调用开始两次报错", (t) => { + const { ledger, path } = freshLedger(t); + ledger.append("callStarted", callStart("k1")); + assert.throws(() => ledger.append("callStarted", callStart("k1")), /call_started_once/); + ledger.append("callStarted", callStart("k2")); + assert.equal(countRows(path), 2); +}); + +test("唯一约束:同一次模型调用结束两次报错", (t) => { + const { ledger, path } = freshLedger(t); + ledger.append("callStarted", callStart("k1")); + ledger.append("callEnded", { call: "k1", ending: "completed" }); + assert.throws(() => ledger.append("callEnded", { call: "k1", ending: "failed", reason: "又报了一次" }), /call_ended_once/); + assert.equal(countRows(path), 2); +}); + +test("唯一约束:同一次消融结算两次报错", (t) => { + const { ledger, path } = freshLedger(t); + ledger.append("settlement", { ablation: "a1", result: { cells: { p1: "valid" } } }); + assert.throws(() => ledger.append("settlement", { ablation: "a1", result: { cells: { p1: "valid" } } }), /settlement_once/); + ledger.append("settlement", { ablation: "a2", result: { cells: { p1: "insufficient" } } }); + assert.equal(countRows(path), 2); +}); + +test("没有唯一约束的记录照写:同样的事件、决定写两次就是两条", (t) => { + const { ledger, path } = freshLedger(t); + const e = { event: "untested", reason: "剩余次数不够", objects: ["cell3"] }; + const first = ledger.append("event", e); + const second = ledger.append("event", e); + assert.equal(second.seq, first.seq + 1); + const d = { decision: "batch", round: 1, rule: "bestExpected", ruleVersion: 1, params: {}, seed: null, input: { x: 1 }, output: ["c1"] }; + ledger.append("decision", d); + ledger.append("decision", d); + assert.equal(countRows(path), 4); +}); + +test("内容不符合定义时不写入:多出的字段、提交内容不合格、提交编号 item 和提交种类对不上", (t) => { + const { ledger, path } = freshLedger(t); + openStage(ledger, "s1", "wrapUp", 1); + assert.throws(() => ledger.append("runStarted", { run: "r1", extra: 1 } as never), /runStarted/); + assert.throws( + () => ledger.append("submission", { stageId: "s1", author: "r1", target: null, item: null, version: 1, kind: "wrapUp", content: { body: "" } }), + /wrapUp/, + ); + // 提案、挑刺一人能交好几份,必须带编号;实现一人对一个候选只交一份,编号必须为空, + // 否则另起一个编号就能绕开"实现不能替换"。 + openStage(ledger, "prop", "proposal", 1); + assert.throws( + () => ledger.append("submission", { stageId: "prop", author: "r1", target: null, item: null as never, version: 1, kind: "proposal", content: proposal(range(1, 2, 3)) }), + /proposal/, + ); + openStage(ledger, "impl", "implementation", 1, ["e1"]); + assert.throws( + () => ledger.append("submission", { stageId: "impl", author: "e1", target: "c1", item: "x" as never, version: 1, kind: "implementation", content: implementation() }), + /implementation/, + ); + assert.equal(countRows(path), 3); // 三条开阶段 +}); + +test("写提案:作者自评拆成一条封存,提案记录里不带它", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "s1", "proposal", 1); + const record = ledger.append("submission", { + stageId: "s1", author: "r1", target: null, item: "r1-1", version: 1, kind: "proposal", content: proposal(range(1, 2, 3)), + }); + const all = ledger.records(); + const stored = all.find((r) => r.kind === "submission")!; + assert.equal(JSON.stringify(stored.content).includes("authorEstimate"), false); + assert.equal(JSON.stringify(record.content).includes("authorEstimate"), false); + const { authorEstimate: _, ...withoutEstimate } = proposal(range(1, 2, 3)); + assert.deepEqual(stored.content.content, withoutEstimate); + const seal = all.find((r) => r.kind === "seal")!; + assert.deepEqual(seal.content, { proposal: record.seq, range: range(1, 2, 3) }); + assert.equal(seal.seq, record.seq + 1); +}); + +test("退回:被退回的内容原样存,带退回理由;来自 Jev 的带原始概率和结论", (t) => { + const { ledger } = freshLedger(t); + const malformed = { factors: "不是数组" }; + ledger.append("rejection", { stageId: "s1", author: "r1", kind: "proposal", submitted: malformed, reason: "因子清单格式不对", jev: null }); + ledger.append("rejection", { + stageId: "s6", author: "r2", kind: "response", submitted: { body: "改了评测" }, reason: "冻结条款改动了评测", + jev: { probabilities: { yes: 0.97, no: 0.03 }, conclusion: "changesEvaluation" }, + }); + const rejections = ledger.records().filter((r) => r.kind === "rejection"); + assert.deepEqual(rejections.map((r) => r.content.submitted), [malformed, { body: "改了评测" }]); + assert.deepEqual(rejections[1]!.content.jev, { probabilities: { yes: 0.97, no: 0.03 }, conclusion: "changesEvaluation" }); +}); + +test("事务:中途出错,一条都没写进去;中途被拒收,前面写的也撤销;正常结束则全部写进去", (t) => { + const { ledger, path } = freshLedger(t); + assert.throws( + () => + ledger.transaction(() => { + planRun(ledger, "run1", "forward", 1); + ledger.append("runStarted", { run: "run1" }); + throw new Error("算到一半出错"); + }), + /算到一半出错/, + ); + assert.equal(countRows(path), 0); + assert.throws( + () => + ledger.transaction(() => { + planRun(ledger, "run9", "forward", 1); + ledger.append("runResult", { run: "run9", process: exited, judged: null }); + }), + /确认启动/, + ); + assert.equal(countRows(path), 0); + ledger.transaction(() => { + planRun(ledger, "run1", "forward", 1); + ledger.append("runStarted", { run: "run1" }); + }); + assert.equal(countRows(path), 2); +}); + +test("事务:异步函数被类型检查挡住", () => { + // 只给 npm run check 看,不执行:await 之后的写入发生在事务提交之后,回滚管不到。 + const typeOnly = (ledger: Ledger) => + // @ts-expect-error 事务函数的返回值类型排除了 Promise。 + ledger.transaction(async () => { + ledger.append("config", { n: 1 }); + }); + assert.equal(typeof typeOnly, "function"); +}); + +test("关掉再打开:读出的记录不变,序号接着往下排", (t) => { + const path = join(tempDir(t), "ledger.db"); + const ledger = Ledger.open(path); + ledger.append("config", { researchers: ["r1", "r2"] }); + openStage(ledger, "s1", "proposal", 1); + ledger.append("submission", { stageId: "s1", author: "r1", target: null, item: "r1-1", version: 1, kind: "proposal", content: proposal(range(1, 2, 3)) }); + ledger.append("note", { author: "r2", version: 1, body: "笔记" }); + closeStage(ledger, "s1"); + planRun(ledger, "run1", "forward", 1); + ledger.append("callStarted", callStart("k1")); + const before = ledger.records(); + ledger.close(); + const reopened = Ledger.open(path); + t.after(() => reopened.close()); + assert.deepEqual(reopened.records(), before); + assert.equal(before.length, 8); // 配置、开阶段、提案、封存、笔记、关阶段、登记运行、调用开始 + assert.deepEqual(before.map((r) => r.hash), before.map((r) => hash(r.content))); + assert.equal(reopened.append("runStarted", { run: "run1" }).seq, 9); +}); + +test("打开:表结构版本不对报错;不是空文件也不是账本的库报错", (t) => { + const dir = tempDir(t); + const wrongVersion = join(dir, "v99.db"); + const db = new DatabaseSync(wrongVersion); + db.exec("PRAGMA user_version = 99"); + db.close(); + assert.throws(() => Ledger.open(wrongVersion), /版本是 99/); + + const foreign = join(dir, "foreign.db"); + const other = new DatabaseSync(foreign); + other.exec("CREATE TABLE t (x INTEGER)"); + other.close(); + assert.throws(() => Ledger.open(foreign), /版本是 0/); +}); + +test("打开:不是账本的库报错后,那个库的 journal_mode 不变", (t) => { + const foreign = join(tempDir(t), "foreign.db"); + const other = new DatabaseSync(foreign); + other.exec("CREATE TABLE t (x INTEGER)"); + const modeOf = (db: DatabaseSync) => (db.prepare("PRAGMA journal_mode").get() as { journal_mode: string }).journal_mode; + const before = modeOf(other); + other.close(); + assert.notEqual(before, "wal"); + assert.throws(() => Ledger.open(foreign), /版本是 0/); + const reopened = new DatabaseSync(foreign); + t.after(() => reopened.close()); + assert.equal(modeOf(reopened), before); +}); + +test("读出时按种类检查内容:绕过 ledger 插进去的坏记录,读取时报错", (t) => { + const { ledger, path } = freshLedger(t); + const db = new DatabaseSync(path); + t.after(() => db.close()); + db.exec(`INSERT INTO records (kind, content, hash) VALUES ('runStarted', '{"run":"run1","x":1}', 'h')`); + assert.throws(() => ledger.records(), /runStarted/); +}); + +test("查询:阶段关了没有、某份提交的最新一版(同一人对同一候选的另一份挑刺不算这一份的版本)", (t) => { + const { ledger } = freshLedger(t); + openStage(ledger, "s1", "critique", 1); + assert.equal(ledger.stageClosed("s1"), false); + const slot = { stageId: "s1", author: "r1", kind: "critique" as const, target: "c1", item: "k1" }; + assert.equal(ledger.latestSubmission(slot), undefined); + ledger.append("submission", { ...slot, version: 1, content: { target: "whole", body: "第一版" } }); + const v2 = ledger.append("submission", { ...slot, version: 2, content: { target: "whole", body: "第二版" } }); + ledger.append("submission", { ...slot, item: "k2", version: 1, content: { target: "p1", body: "另一份" } }); + assert.deepEqual(ledger.latestSubmission(slot), v2); + closeStage(ledger, "s1"); + assert.equal(ledger.stageClosed("s1"), true); +});