From d2bfb971ab466541b7168a8a15912501fb73104c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 2 Oct 2026 08:08:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20config=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=88=E4=BB=BB=E5=8A=A1=E4=B9=A6=2004=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/config:research.json 的字段定义(TypeBox,全部必填、不认识的字段报错,每个字段带中文说明和类别)、一次列出全部错误的校验、步骤清单纯函数。 - 流程写成完整流程 / 朴素循环两选一,第零轮、消融写成开 / 关两选一,冲突组合写不出来,不另写检查。 - 规则名取自 rules 登记表,config 改为依赖 rules(结构.md 第二部分、.dependency-cruiser.cjs)。 - ledger 顺手两处(结构.md 第四部分):消融结算按轮次唯一、去掉消融编号;私人笔记去掉版本号;表结构版本号升到 2。 - 结构.md 第八部分补 config 对外内容、更新 ledger 一节;WORK_LOG 更新。 Co-Authored-By: Claude Opus 5.5 --- .dependency-cruiser.cjs | 2 +- WORK_LOG.md | 9 +- docs/结构.md | 30 ++++- src/config/check.ts | 125 +++++++++++++++++ src/config/index.ts | 13 ++ src/config/schema.ts | 179 +++++++++++++++++++++++++ src/config/steps.ts | 136 +++++++++++++++++++ src/ledger/kinds.ts | 8 +- src/ledger/schema.ts | 5 +- tests/config-check.test.ts | 216 ++++++++++++++++++++++++++++++ tests/config-helpers.ts | 41 ++++++ tests/config-steps.test.ts | 266 +++++++++++++++++++++++++++++++++++++ tests/ledger-store.test.ts | 35 ++++- 13 files changed, 1045 insertions(+), 20 deletions(-) create mode 100644 src/config/check.ts create mode 100644 src/config/index.ts create mode 100644 src/config/schema.ts create mode 100644 src/config/steps.ts create mode 100644 tests/config-check.test.ts create mode 100644 tests/config-helpers.ts create mode 100644 tests/config-steps.test.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index fdcd834..66dbc92 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -12,7 +12,7 @@ const ALLOWED = { "runner", "tasks", "workspace", "config", "jev", ], export: ["ledger", "rules", "materials"], - config: [], + config: ["rules"], materials: ["ledger", "rules"], submissions: ["jev"], ledger: [], diff --git a/WORK_LOG.md b/WORK_LOG.md index 2e182b1..6e81f58 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -14,6 +14,7 @@ - 任务书 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 对外内容。 +- 任务书 04(config):src/config 4 个文件、453 行,`npm test` 246 条通过(config 19 条)。流程用联合类型(完整流程 / 朴素循环),规则名取自 rules 登记表,config 改为依赖 rules。ledger 顺手两处:结算按轮次唯一、笔记去掉版本号,表结构版本号升到 2,全新上下文审阅一轮无问题。结构.md 第八部分补了 config 对外内容。 ## 关键决定(用户原话见各提交说明) @@ -56,14 +57,16 @@ 14. 消融核验覆盖每个因子。 - 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。 - 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。 -- 给 config:每批上限必须 ≥ 1(为 0 时 R̂ 是无穷大,选格会卡住)。 - 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。 - 给 materials:被退回的提案原样存在退回记录里,作者自评没有拆成封存,所以不能把别人的退回记录给研究者看。方法.md 第三部分"作者自评从交上来那一刻起就封存"和"被退回的提交原样记"之间怎么处理,待用户定。 - 给 flow:确认启动前要先登记运行;模型调用要先开始再结束;阶段要先开再关;ledger 都不查。 -- ledger 待顺手改(下一份涉及 ledger 的任务书带上):结算的唯一约束改按轮次(每轮最多一次消融,结构.md 第四部分已改),去掉 `ablation` 编号;私人笔记去掉版本号,以最后写入的一条为准(序号就是先后,版本号是程序推得出的栏目)。 - 给 materials:别人的"退回"记录不放进任何研究者的材料(退回的提案原样带着作者自评,不另拆封存)。 - 给 agents:模型的思考档位必须写明,不用参考项目"写 null 表示用 Pi 自己的默认"那种写法。 - 给 agents、runner:"整个环境的用量"(实验员会话的 CPU、内存等)写这两个模块时再定记录种类。 +- 任务书 04 留下、待用户定的:(1) 关掉消融时"解释"做不做:结构.md 第三部分把它放在〔开关:消融〕下,方法.md 第十一部分说照样写文字,代码按方法.md 写(照做);(2) 挑刺与回应关掉、或朴素循环里,判重合并后的候选由谁写统一的协议(方法.md 4.3 ⑥ 的统一在回应阶段);(3) u、衰减只在消融开、且选格规则用信息价值时才用到,要不要挪进"消融开"那一支。 +- 给 rules / flow:`selectUniformRandom` 的输入 `BatchInput` 要 λ 和每个候选的第二次预测范围,朴素循环两样都没有,配置里也写不进 λ。 +- 给 submissions / vocab:朴素循环"不预测",提案的作者自评(必填的三个数)要不要交,待定。 +- 给 flow:步骤清单没有单列"轮末动作"(解封作者自评、入选未跑的候选出池),朴素循环里收尾跳过时 flow 也要做这些。 - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 ## 工作方式(用户 2026-10-01 定) @@ -77,4 +80,4 @@ ## 下一步 -任务书 03(ledger)已核对通过(716f4b8)。`任务书/04-config.md` 已写好(含 ledger 两处顺手改),等用户另开 Agent 执行,监督者核对。 +任务书 04(config)已完成并提交,等监督者核对;"未完成"里任务书 04 留下的三个问题待用户定。 diff --git a/docs/结构.md b/docs/结构.md index dd35f72..f65fc1a 100644 --- a/docs/结构.md +++ b/docs/结构.md @@ -34,7 +34,7 @@ | 入口 | plugin(插件入口) | 用户在 Pi 里怎么操作:命令、状态显示、配置对话;文件锁;把各模块装配起来 | 装配时用到各模块 | | 组织 | flow(流程) | 按步骤清单推进研究;每次启动走同一条恢复路径 | ledger、rules、materials、submissions、agents、runner、tasks、workspace、config、jev | | 组织 | export(导出) | 导出给人看的格式 | ledger、rules、materials | -| 组织 | config(配置) | research.json 的字段和校验(每个字段必填,不补默认值);把配置翻译成步骤清单 | 无 | +| 组织 | config(配置) | research.json 的字段和校验(每个字段必填,不补默认值);把配置翻译成步骤清单 | rules | | 组织 | materials(材料) | 每个任务能看到哪些材料(方法.md 第三部分);第一条消息放哪些、什么顺序、怎么写;在这些材料范围内回答模型的查询 | ledger、rules | | 组织 | submissions(收件) | 一份提交收下还是退回:先程序检查,再问 Jev(字段定义在 vocab) | jev | | 外部 | ledger(账本) | 记录怎么存、怎么保证不可改、怎么从记录算出当前状态;花钱动作的登记 | 无 | @@ -392,7 +392,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 - `Ledger.open(path)`:打开一份研究的账本,新文件就建表,旧文件核对表结构版本号,对不上就报错。 - `ledger.close()`:关闭数据库。 -- `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的四种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算;其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。 +- `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的四种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算(按轮次,一轮一条);其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。 - `ledger.transaction(fn)`:里面的几次追加要么全部写进去,要么一条都不写;fn 必须是同步函数,返回值类型排除了 Promise,异步函数在类型检查时就被挡住;不能嵌套。 **读取** @@ -405,10 +405,34 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 **类型** -- `Kind`:记录的种类,共 19 种:`config`(研究配置)、`stageOpen` / `stageClose`(阶段开、关)、`submission`(提交的每一版)、`rejection`(退回,带原样内容和理由,理由来自 Jev 的带原始概率和结论)、`note`(私人笔记的每一版)、`protocol`(冻结协议)、`seal` / `unseal`(作者自评的封存、解封,都用记录序号指明是哪一条)、`code`(冻结代码)、`runPlanned` / `runStarted` / `runResult`(运行的登记要跑、确认启动、结果)、`decision`(决定)、`settlement`(一次消融的结算)、`callStarted` / `callUsage` / `callEnded`(模型调用的开始,附这次任务的全部材料;每次回复的用量;结束)、`event`(事件)。 +- `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,被替换的就是上一版。 + +### config + +下面是 `src/config/index.ts` 导出的全部内容。config 依赖 rules,只为从规则登记表取规则名和版本。账本里"研究配置"那条记录只存 Json(ledger 在更下层,不能引用 config),flow 从账本读回配置时,再用这里的 `checkConfig` 检查一遍。 + +**读取与校验** + +- `checkConfig(value)`:按字段定义检查一份已经解析好的配置,合格就原样返回带类型的配置;不合格就报错,一次列出全部错误,每条写成"字段路径:原因"。读 research.json 后、从账本读回冻结的配置时都用它。 +- `loadConfig(path)`:读 research.json,解析 JSON(不是合法 JSON 时报错带文件路径),再交给 `checkConfig`。 + +**字段定义** + +- `ResearchConfig`(字段定义):research.json 的全部字段,全部必填,不认识的字段报错。每个字段带中文说明(`description`)和类别(`category`:用户配置或方法参数),配置对话照着它问,不另写一份。流程是两选一:完整流程写四个开关、选批规则、它才用得到的 λ、u、衰减;朴素循环只写 `preset`,别的都写不进去。 +- `FIELD_CATEGORIES`、`FieldCategory`:字段的两种类别,用户配置、方法参数。 +- `THINKING_LEVELS`、`ThinkingLevel`:Pi 0.85.1 接受的七个思考档位,出处写在代码注释里;config 不能引用 Pi,所以照抄一份。 + +**步骤清单** + +- `stepList(config)`:纯函数,把校验过的配置翻译成整份研究和每一轮的步骤清单。每一步标"执行"或"按配置跳过"(带原因),被跳过的也列出;选批、选格两步带规则名和版本。 +- `StepList`(类型):`preset`(完整流程还是朴素循环,materials 据此决定给什么材料)、整份研究的步骤、每一轮的步骤。 +- `Step`(类型):一步:编号、规则(只有选批、选格有,其余为空)、执行或跳过及原因。 +- `RuleRef`(类型):决策点用的规则,名字和版本。 +- `STUDY_STEPS`、`StudyStepId`:整份研究的步骤,按先后排。 +- `ROUND_STEPS`、`RoundStepId`:每一轮的步骤,按先后排;比第三部分拆得更细(例如"回应与统一"和"协议冻结"分成两步,挑刺与回应关掉时协议照样冻结)。 diff --git a/src/config/check.ts b/src/config/check.ts new file mode 100644 index 0000000..224acf7 --- /dev/null +++ b/src/config/check.ts @@ -0,0 +1,125 @@ +import { readFileSync } from "node:fs"; +import type { TSchema } from "typebox"; +import { Settings } from "typebox/system"; +import { Value } from "typebox/value"; +import { ResearchConfig } from "./schema.ts"; + +// research.json 是用户写的配置,属于系统边界(CLAUDE.md"罕见情况怎么办"),所以一次列出全部错误, +// 每条写明字段路径和错在哪里。 + +type SchemaError = ReturnType[number]; + +const TYPE_TEXT: Readonly> = { + string: "字符串", + number: "数字", + integer: "整数", + boolean: "true 或 false", + object: "对象", + array: "数组", + null: "null", +}; + +function reasonOf(e: SchemaError): string { + const p = e.params as Record; + switch (e.keyword) { + case "type": + return `应为${TYPE_TEXT[String(p.type)] ?? String(p.type)}`; + case "enum": + return `只能是 ${(p.allowedValues as unknown[]).map((v) => JSON.stringify(v)).join("、")} 之一`; + case "const": + return `只能是 ${JSON.stringify(p.allowedValue)}`; + case "minimum": + return `不能小于 ${String(p.limit)}`; + case "maximum": + return `不能大于 ${String(p.limit)}`; + case "exclusiveMinimum": + return `必须大于 ${String(p.limit)}`; + case "exclusiveMaximum": + return `必须小于 ${String(p.limit)}`; + case "minItems": + return `至少要有 ${String(p.limit)} 项`; + case "pattern": + return p.pattern === "\\S" ? "不能为空,也不能只有空白" : `格式不对,应符合 ${String(p.pattern)}`; + default: + return e.message; + } +} + +/** 按 JSON 指针("#/properties/flow/anyOf/0"、"/flow/ablation")往下走。 */ +function walk(root: unknown, pointer: string): unknown { + let node = root; + for (const key of pointer.split("/").filter((k) => k !== "" && k !== "#")) { + node = typeof node === "object" && node !== null ? (node as Record)[key] : undefined; + } + return node; +} + +/** + * 几选一(Union)报错时,typebox 会把每个分支的错误都报一遍。这里按区分字段(每个分支都有、取值是常量的那个字段, + * 如 preset、enabled)认出配置想写的是哪一支,只报那一支的错误;一支都对不上,就报区分字段该写什么。 + */ +function unionErrors(u: SchemaError, errors: SchemaError[], value: unknown): string[] { + const branches = (walk(ResearchConfig, u.schemaPath) as { anyOf: TSchema[] }).anyOf; + const props = branches.map((b) => (b as { properties: Record }).properties); + const key = Object.keys(props[0]!).find((k) => props.every((p) => p[k] !== undefined && "const" in p[k]))!; + const written = walk(value, u.instancePath); + if (typeof written !== "object" || written === null || Array.isArray(written)) return [`${u.instancePath}:应为对象`]; + const k = props.findIndex((p) => p[key]!.const === (written as Record)[key]); + if (k < 0) { + const allowed = props.map((p) => JSON.stringify(p[key]!.const)).join("、"); + return [`${u.instancePath}/${key}:缺少或取值不对,只能是 ${allowed} 之一`]; + } + const prefix = `${u.schemaPath}/anyOf/${k}`; + const inside = errors.filter((e) => e.schemaPath === prefix || e.schemaPath.startsWith(`${prefix}/`)); + return errorLines(inside, prefix, value); +} + +/** scope 以内的错误逐条写成"字段路径:原因";更深一层分支里的错误交给那个 Union 去挑。 */ +function errorLines(errors: SchemaError[], scope: string, value: unknown): string[] { + const lines: string[] = []; + for (const e of errors) { + if (e.schemaPath.slice(scope.length).includes("/anyOf/")) continue; + if (e.keyword === "anyOf") { + lines.push(...unionErrors(e, errors, value)); + } else if (e.keyword === "required") { + for (const name of e.params.requiredProperties as string[]) { + lines.push(`${e.instancePath}/${name}:缺少这一项(每个字段都必填,不补默认值)`); + } + } else if (e.keyword === "additionalProperties") { + for (const name of e.params.additionalProperties as string[]) { + lines.push(`${e.instancePath}/${name}:不认识的字段`); + } + } else { + lines.push(`${e.instancePath || "/"}:${reasonOf(e)}`); + } + } + return lines; +} + +/** 校验一份配置(读 research.json 后、从账本读回冻结的配置时都用它);不合格就报错,列出全部错误。 */ +export function checkConfig(value: unknown): ResearchConfig { + if (Value.Check(ResearchConfig, value)) return value; + // typebox 默认最多收 8 条错误,Union 的每个分支又各报一遍,不放开就会漏报;放开只在这一次调用里。 + const cap = Settings.Get().maxErrors; + Settings.Set({ maxErrors: Infinity }); + let errors: SchemaError[]; + try { + errors = [...Value.Errors(ResearchConfig, value)]; + } finally { + Settings.Set({ maxErrors: cap }); + } + const lines = errorLines(errors, "#", value); + throw new Error(`研究配置不合格,共 ${lines.length} 处:\n${lines.join("\n")}`); +} + +/** 读取并校验 research.json。 */ +export function loadConfig(path: string): ResearchConfig { + const text = readFileSync(path, "utf8"); + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new Error(`${path} 不是合法的 JSON:${(error as Error).message}`); + } + return checkConfig(value); +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..3566f74 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,13 @@ +// config:research.json 的字段和校验(每个字段必填,不补默认值),以及把配置翻译成步骤清单(docs/结构.md 第二、三部分)。 +export { checkConfig, loadConfig } from "./check.ts"; +export { FIELD_CATEGORIES, ResearchConfig, THINKING_LEVELS, type FieldCategory, type ThinkingLevel } from "./schema.ts"; +export { + ROUND_STEPS, + STUDY_STEPS, + stepList, + type RoundStepId, + type RuleRef, + type Step, + type StepList, + type StudyStepId, +} from "./steps.ts"; diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..17100ed --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,179 @@ +import { Type, type Static, type TSchema } from "typebox"; +import { BATCH_RULES, CELL_RULES, type BatchRuleName, type CellRuleName } from "../rules/index.ts"; +import { Id } from "../vocab/index.ts"; + +// research.json 的字段定义(docs/结构.md 第二部分、第五部分"方法参数""步骤开关")。 +// 每个字段都必填,不写默认值;不认识的字段报错。可以没有的值写 null(目前没有这样的字段)。 +// 每个字段带两样给配置对话读的东西:description 是中文说明,category 标明它是"用户配置"(配置对话逐项问) +// 还是"方法参数"(配置对话按方法.md 6.11 的现行值写进文件,给用户看过,不逐项问)。 +// 任务本身的事实(最终指标、方向、ε、已知最好、时限、核数、镜像)不在这里,归 tasks、放在任务目录里。 + +/** 字段属于哪一类,写在每个字段定义的 category 属性里。 */ +export const FIELD_CATEGORIES = ["用户配置", "方法参数"] as const; +export type FieldCategory = (typeof FIELD_CATEGORIES)[number]; + +const CLOSED = { additionalProperties: false } as const; +/** 文字字段:不能为空,也不能只有空白。 */ +const TEXT = { pattern: "\\S" }; + +function user(schema: T, description: string): T { + return { ...schema, description, category: "用户配置" }; +} +function method(schema: T, description: string): T { + return { ...schema, description, category: "方法参数" }; +} + +/** + * Pi 接受的思考档位。config 不能引用 Pi(只有 agents、plugin 能),所以在这里照抄一份,出处是本机 Pi 0.85.1: + * - @earendil-works/pi-agent-core dist/types.d.ts 第 261 行 `type ThinkingLevel`; + * - @earendil-works/pi-coding-agent dist/cli/args.js 第 6 行 `VALID_THINKING_LEVELS`。 + * 两处都是 off、minimal、low、medium、high、xhigh、max。xhigh、max 只有部分模型支持(同一处注释)。 + */ +export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +/** 规则名从 rules 的登记表取,不另抄名单。 */ +const BATCH_RULE_NAMES = Object.keys(BATCH_RULES) as BatchRuleName[]; +const CELL_RULE_NAMES = Object.keys(CELL_RULES) as CellRuleName[]; + +/** 一个角色用的模型:provider、model 与 Pi 的模型服务配置里的写法相同。 */ +const Model = Type.Object( + { + provider: user(Type.String(TEXT), "模型服务的名字,与 Pi 的模型服务配置里的写法相同"), + model: user(Type.String(TEXT), "模型名,与 Pi 的模型服务配置里的写法相同,例如 glm-5.3"), + thinking: user( + Type.Enum(THINKING_LEVELS), + `思考档位,必须写明,不能写 null 去用 Pi 自己的默认:${THINKING_LEVELS.join("、")} 之一;xhigh、max 只有部分模型支持`, + ), + }, + CLOSED, +); + +/** 第零轮(方法.md 4.2):关,或开并写讨论次数。 */ +const RoundZero = Type.Union([ + Type.Object({ enabled: method(Type.Literal(false), "第零轮关") }, CLOSED), + Type.Object( + { + enabled: method(Type.Literal(true), "第零轮开"), + discussions: method(Type.Enum([1, 2]), "第零轮讨论几次,1 或 2;现行值 2(方法.md 4.2)"), + }, + CLOSED, + ), +]); + +/** 消融(方法.md 第十一部分"步骤开关"):关,或开并写选格规则。 */ +const Ablation = Type.Union([ + Type.Object( + { enabled: method(Type.Literal(false), "消融关:不列消融格;解释照样写文字,但不给每格的三个数") }, + CLOSED, + ), + Type.Object( + { + enabled: method(Type.Literal(true), "消融开"), + cellRule: method( + Type.Enum(CELL_RULE_NAMES), + `选格规则名,取自 rules 的选格登记表:${CELL_RULE_NAMES.join("、")};现行值 valueMinusCost(信息价值减成本)`, + ), + }, + CLOSED, + ), +]); + +/** + * 两种流程(方法.md 第十一部分)。完整流程写开关、规则和它才用得到的方法参数; + * 朴素循环整套照搬,不能写任何开关、规则,也不写它用不到的 λ、u、衰减(它不预测、不消融)。 + */ +const FullFlow = Type.Object( + { + preset: method(Type.Literal("full"), "完整流程"), + roundZero: method(RoundZero, "第零轮:写入口、讨论、认领、分配(方法.md 4.2)"), + firstPrediction: method(Type.Boolean(), "第一次预测(看原始提案)开不开(方法.md 4.3 ④);现行值 true"), + critique: method( + Type.Boolean(), + "挑刺与回应开不开(方法.md 4.3 ⑤ ⑥);关掉时不挑刺、不回应,协议照样冻结;现行值 true", + ), + ablation: method(Ablation, "消融(方法.md 4.3 ⑬–⑰)"), + batchRule: method( + Type.Enum(BATCH_RULE_NAMES), + `选批规则名,取自 rules 的选批登记表:${BATCH_RULE_NAMES.join("、")};现行值 bestExpected(批内最好期望)`, + ), + openReserve: method( + Type.Number({ exclusiveMinimum: 0, exclusiveMaximum: 1 }), + "开放预留 λ:合并时给\"大家都没想到的结果\"留的份额,大于 0、小于 1;现行值 0.10(方法.md 6.3、6.11)", + ), + knowledgeUse: method( + Type.Number({ minimum: 0, maximum: 1 }), + "u:测出的知识会被用上的概率,0 到 1;现行值 0.5(方法.md 6.7、6.11)", + ), + decay: method( + Type.Number({ minimum: 0, maximum: 1 }), + "衰减 r:知识以后每多用一次打的折扣,0 到 1;现行值 0.5(方法.md 6.7、6.11)", + ), + }, + CLOSED, +); + +const NaiveFlow = Type.Object( + { + preset: method( + Type.Literal("naive"), + "朴素循环:方法.md 第十一部分的对照流程整套照搬,不挑刺、不回应、不预测、均匀随机选批、不消融", + ), + }, + CLOSED, +); + +const PositiveInteger = Type.Integer({ minimum: 1 }); + +export const ResearchConfig = Type.Object( + { + studyId: user(Id, "研究编号:决定的种子由它和轮次、版本算出;只许字母、数字和 ._-,首字符是字母或数字"), + task: user(Type.String(TEXT), "用哪个任务:任务目录的路径。指标、方向、ε、时限等任务事实写在任务目录里"), + models: user( + Type.Object( + { + researchers: user( + Type.Array(Model, { minItems: 1 }), + "研究者:每一项是一位研究者用的模型,列表长度就是研究者人数 N", + ), + experimenter: user(Model, "实验员用的模型"), + verifier: user(Model, "核验员用的模型"), + }, + CLOSED, + ), + "各角色用的模型", + ), + proposalLimit: user(PositiveInteger, "每人每轮提案上限 M(方法.md 4.3 ②)"), + batchLimit: user(PositiveInteger, "每批上限:一轮正向最多跑几个候选,本轮批量 = min(每批上限, 余额)(方法.md 第七部分)"), + runs: user(PositiveInteger, "正式运行次数:唯一的预算,基线运行也计入,用完研究结束(方法.md 第七部分)"), + factorLimit: method(PositiveInteger, "因子数上限:一条提案最多几个因子;现行值 3(方法.md 6.11)"), + maxRepairs: method( + Type.Integer({ minimum: 0 }), + "修复次数上限:核验不通过时最多退回实验员修几次(方法.md 4.3 ⑨)", + ), + lightChecks: method( + Type.Object( + { + maxCount: method( + Type.Integer({ minimum: 0 }), + "实验员在一个实现任务里最多做几次轻量检查(方法.md 第七部分)", + ), + seconds: method( + Type.Number({ exclusiveMinimum: 0 }), + "一次轻量检查最长几秒(方法.md 第七部分)", + ), + }, + CLOSED, + ), + "轻量检查的次数与时长上限:只用来检查语法、接口和极小的样例,不能借它跑正式规模的求解", + ), + promptDir: method(Type.String(TEXT), "提示词目录:各角色的角色提示词和各类任务消息放在这里"), + devEntry: method( + Type.Boolean(), + "开发入口(方法.md 第九部分):正式研究写 false;开发调试时才写 true,打开过的研究不能当验收证据", + ), + flow: method(Type.Union([FullFlow, NaiveFlow]), "流程:完整流程或朴素循环(方法.md 第十一部分)"), + }, + CLOSED, +); +export type ResearchConfig = Static; diff --git a/src/config/steps.ts b/src/config/steps.ts new file mode 100644 index 0000000..02716da --- /dev/null +++ b/src/config/steps.ts @@ -0,0 +1,136 @@ +import { BATCH_RULES, CELL_RULES, type BatchRuleName, type CellRuleName } from "../rules/index.ts"; +import type { ResearchConfig } from "./schema.ts"; + +// 步骤清单(docs/结构.md 第三部分,方法.md 第十一部分):把配置里的流程、开关、规则翻译成这份研究要走的步骤。 +// flow、导出、恢复都只读这份清单,不再读配置里的开关。被关掉的步骤也列出来,标"按配置跳过"并写明原因, +// flow 据此在账本里记一条"按配置跳过"。步骤里的先后、"若采纳的改法有 2 个以上因子"这类运行时条件归 flow。 + +/** 整份研究的步骤,按先后排。 */ +export const STUDY_STEPS = [ + "freezeConfig", // 1. 冻结配置 + "baselineRun", // 2. 基线运行 + "entryDraft", // 3. 第零轮:闭门写入口 + "discussion1", // 3. 第零轮:第 1 次讨论 + "discussion2", // 3. 第零轮:第 2 次讨论 + "claim", // 3. 第零轮:认领 + "assignEntries", // 3. 第零轮:分配(程序) + "rounds", // 4. 一轮接一轮,直到余额用完;每一轮走 ROUND_STEPS + "export", // 5. 导出 +] as const; +export type StudyStepId = (typeof STUDY_STEPS)[number]; + +/** 每一轮的步骤,按先后排;注释里的编号是结构.md 第三部分"每一轮"的编号。 */ +export const ROUND_STEPS = [ + "openRound", // 1. 开轮:冻结本轮名单和提案上限 + "carryOverPool", // 1. 开轮:处理留池候选 + "proposal", // 2. 提案 + "dedupe", // 3. 判重合并 + "firstPrediction", // 4. 第一次预测(看原始提案) + "critique", // 5. 挑刺 + "response", // 6. 回应与统一 + "freezeProtocol", // 6. 协议冻结:挑刺与回应关掉时也要冻结 + "secondPrediction", // 7. 第二次预测(看冻结协议) + "selectBatch", // 8. 选批 + "implementation", // 9. 实现 + "verification", // 9. 核验 + "repair", // 9. 核验不通过:修复,再核验 + "forwardRun", // 9. 正式运行 + "publish", // 10. 整批公开,记分数账 + "informationAccount", // 10. 记信息账(世界比特、惊讶、落点) + "adopt", // 11. 采纳 + "listCells", // 12. 列消融格 + "explanation", // 12. 解释 + "selectCells", // 12. 选格 + "ablationImplementation", // 12. 消融实现 + "ablationVerification", // 12. 消融核验 + "ablationRun", // 12. 消融运行 + "settle", // 12. 结算(含押的因子、消融中发现) + "wrapUp", // 13. 收尾 +] as const; +export type RoundStepId = (typeof ROUND_STEPS)[number]; + +/** 决策点用哪条规则:规则名和版本,从 rules 的登记表取。 */ +export type RuleRef = { name: BatchRuleName | CellRuleName; version: number }; + +type Status = { status: "execute" } | { status: "skip"; reason: string }; + +/** 一步:执行,或按配置跳过(带原因);只有选批、选格两个决策点带规则,其余为 null。 */ +export type Step = { id: S; rule: RuleRef | null } & Status; + +export interface StepList { + /** 完整流程还是朴素循环;materials 据此决定给什么材料(朴素循环没有信念本和私人笔记)。 */ + preset: ResearchConfig["flow"]["preset"]; + study: Step[]; + round: Step[]; +} + +const EXECUTE: Status = { status: "execute" }; +const skip = (reason: string): Status => ({ status: "skip", reason }); + +/** + * 朴素循环照常做的步骤(方法.md 第十一部分):提案、判重、提案直接冻结、均匀随机选批、实现到正式运行、公开分数、采纳。 + * 其余都不做:不预测、不挑刺不回应、不消融,没有第零轮、收尾,候选不留池;没有预测,也就没有信息账。 + */ +const NAIVE_STEPS: ReadonlySet = new Set([ + "freezeConfig", "baselineRun", "rounds", "export", + "openRound", "proposal", "dedupe", "freezeProtocol", "selectBatch", + "implementation", "verification", "repair", "forwardRun", "publish", "adopt", +] as const); + +/** 把配置翻译成步骤清单。纯函数:同一份配置永远得到同一份清单。 */ +export function stepList(config: ResearchConfig): StepList { + const flow = config.flow; + if (flow.preset === "naive") { + const naive = skip("朴素循环不做这一步(方法.md 第十一部分)"); + return assemble("naive", (id) => (NAIVE_STEPS.has(id) ? EXECUTE : naive), batchRule("uniformRandom"), null); + } + + // 完整流程:只有开关关掉的步骤跳过。 + const skipped: Partial> = {}; + const off = (ids: Array, reason: string) => { + for (const id of ids) skipped[id] = skip(reason); + }; + const zero = flow.roundZero; + if (!zero.enabled) { + off(["entryDraft", "discussion1", "discussion2", "claim", "assignEntries"], "配置关掉了第零轮"); + } else if (config.models.researchers.length === 1) { + // N = 1 时不讨论,直接把第一志愿分给他(方法.md 4.2"分配规则")。 + off(["discussion1", "discussion2"], "只有一位研究者,第零轮不讨论(方法.md 4.2)"); + } else if (zero.discussions === 1) { + off(["discussion2"], "第零轮讨论次数配置为 1"); + } + if (!flow.firstPrediction) off(["firstPrediction"], "配置关掉了第一次预测"); + // 挑刺与回应关掉时,协议照样冻结(freezeProtocol 不跟着关)。 + if (!flow.critique) off(["critique", "response"], "配置关掉了挑刺与回应"); + // 消融关掉时解释照样写文字,只是不给每格的三个数(方法.md 第十一部分"步骤开关"),所以 explanation 不跟着关。 + if (!flow.ablation.enabled) { + off( + ["listCells", "selectCells", "ablationImplementation", "ablationVerification", "ablationRun", "settle"], + "配置关掉了消融", + ); + } + const cells = flow.ablation.enabled ? cellRule(flow.ablation.cellRule) : null; + return assemble("full", (id) => skipped[id] ?? EXECUTE, batchRule(flow.batchRule), cells); +} + +function batchRule(name: BatchRuleName): RuleRef { + return { name, version: BATCH_RULES[name].version }; +} + +function cellRule(name: CellRuleName): RuleRef { + return { name, version: CELL_RULES[name].version }; +} + +function assemble( + preset: StepList["preset"], + status: (id: StudyStepId | RoundStepId) => Status, + batch: RuleRef, + cells: RuleRef | null, +): StepList { + const ruleOf = (id: RoundStepId) => (id === "selectBatch" ? batch : id === "selectCells" ? cells : null); + return { + preset, + study: STUDY_STEPS.map((id) => ({ id, rule: null, ...status(id) })), + round: ROUND_STEPS.map((id) => ({ id, rule: ruleOf(id), ...status(id) })), + }; +} diff --git a/src/ledger/kinds.ts b/src/ledger/kinds.ts index 2c484e4..41fda23 100644 --- a/src/ledger/kinds.ts +++ b/src/ledger/kinds.ts @@ -128,8 +128,8 @@ export const KINDS = { }, CLOSED, ), - /** 3 私人笔记的每一版:每次修改追加一版,以版本号最大的为准。 */ - note: Type.Object({ author: Id, version: Version, body: Text }, CLOSED), + /** 3 私人笔记:不属于任何阶段,每次修改追加一条,以最后写入的一条(序号最大)为准。 */ + note: Type.Object({ author: Id, body: Text }, CLOSED), /** 4 冻结协议:每个候选一份。内容由 flow 合成,先存 Json。 */ protocol: Type.Object({ candidate: Id, protocol: AnyJson }, CLOSED), @@ -176,8 +176,8 @@ export const KINDS = { CLOSED, ), - /** 9 结算:一次消融一条,各格结局、配合关系、解释的确认或否定等先存 Json。 */ - settlement: Type.Object({ ablation: Id, result: AnyJson }, CLOSED), + /** 9 结算:每轮最多采纳一个改法,所以最多一次消融,结算按轮次一轮一条。各格结局、配合关系、解释的确认或否定等先存 Json。 */ + settlement: Type.Object({ round: Round, result: AnyJson }, CLOSED), /** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */ callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED), diff --git a/src/ledger/schema.ts b/src/ledger/schema.ts index da73f66..a97f81b 100644 --- a/src/ledger/schema.ts +++ b/src/ledger/schema.ts @@ -3,7 +3,7 @@ import type { DatabaseSync } from "node:sqlite"; // 账本的表结构(docs/结构.md 第四部分"全部只追加"):只有一张表,每一行是序号、种类、内容、内容的哈希。 // 表结构一改就把版本号加 1;旧文件版本号对不上时打开报错,不迁移,也不逐字核对建表语句。 -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; /** 新文件要执行的建表语句,由 Ledger 在一个事务里执行,最后写入版本号。 */ export const CREATE_SCHEMA = [ @@ -31,7 +31,8 @@ export const CREATE_SCHEMA = [ `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'`, + // 结算以轮次为编号:每轮最多采纳一个改法,所以最多一次消融。 + `CREATE UNIQUE INDEX settlement_once ON records (json_extract(content, '$.round')) WHERE kind = 'settlement'`, `PRAGMA user_version=${SCHEMA_VERSION}`, ]; diff --git a/tests/config-check.test.ts b/tests/config-check.test.ts new file mode 100644 index 0000000..0a8579a --- /dev/null +++ b/tests/config-check.test.ts @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { checkConfig, loadConfig, ResearchConfig } from "../src/config/index.ts"; +import { fullConfig, naiveConfig } from "./config-helpers.ts"; + +type Obj = Record; + +/** 配置里每个对象字段的路径(JSON 指针),数组里的对象也走进去。 */ +function fieldPaths(value: unknown, at = ""): string[] { + if (Array.isArray(value)) return value.flatMap((item, i) => fieldPaths(item, `${at}/${i}`)); + if (typeof value !== "object" || value === null) return []; + return Object.entries(value).flatMap(([k, v]) => [`${at}/${k}`, ...fieldPaths(v, `${at}/${k}`)]); +} + +function parentAndKey(root: unknown, path: string): [Obj, string] { + const keys = path.split("/").slice(1); + let node = root as Obj; + for (const k of keys.slice(0, -1)) node = node[k] as Obj; + return [node, keys[keys.length - 1]!]; +} + +/** 校验应失败,返回错误消息。 */ +function errorOf(value: unknown): string { + try { + checkConfig(value); + } catch (error) { + return (error as Error).message; + } + assert.fail("应当报错,却通过了校验"); +} + +test("两份写全的合法配置能通过,返回的就是原内容", () => { + for (const config of [fullConfig(), naiveConfig()]) { + assert.deepEqual(checkConfig(structuredClone(config)), config); + } +}); + +test("逐个删掉每个字段都报错,报错里有这个字段的路径", () => { + for (const make of [fullConfig, naiveConfig]) { + const paths = fieldPaths(make()); + assert.ok(paths.length > 10); + for (const path of paths) { + const config = make(); + const [parent, key] = parentAndKey(config, path); + delete parent[key]; + const message = errorOf(config); + assert.ok(message.includes(`${path}:`), `删掉 ${path},报错里应有这个路径:\n${message}`); + } + } +}); + +test("不认识的字段报错:顶层、模型里、两种流程里", () => { + const top = { ...fullConfig(), seed: "s" }; + assert.match(errorOf(top), /^\/seed:不认识的字段$/m); + + const model = fullConfig(); + (model.models.researchers[1] as Obj).temperature = 0.2; + assert.match(errorOf(model), /^\/models\/researchers\/1\/temperature:不认识的字段$/m); + + const full = fullConfig(); + (full.flow as Obj).researcherNotes = false; + assert.match(errorOf(full), /^\/flow\/researcherNotes:不认识的字段$/m); +}); + +test("类型或取值不对报错,带字段路径", () => { + const cases: Array<[(c: Obj) => void, RegExp]> = [ + [(c) => (c.runs = "13"), /^\/runs:应为整数$/m], + [(c) => (c.runs = 0), /^\/runs:不能小于 1$/m], + [(c) => (c.batchLimit = 1.5), /^\/batchLimit:应为整数$/m], + [(c) => (c.studyId = "../x"), /^\/studyId:格式不对/m], + [(c) => (c.task = " "), /^\/task:不能为空,也不能只有空白$/m], + [(c) => (c.devEntry = "false"), /^\/devEntry:应为true 或 false$/m], + [(c) => ((c.models as Obj).researchers = []), /^\/models\/researchers:至少要有 1 项$/m], + [(c) => (((c.models as Obj).verifier as Obj).thinking = null), /^\/models\/verifier\/thinking:只能是 "off"、"minimal"、"low"、"medium"、"high"、"xhigh"、"max" 之一$/m], + [(c) => (((c.models as Obj).verifier as Obj).thinking = "auto"), /^\/models\/verifier\/thinking:只能是/m], + [(c) => ((c.flow as Obj).openReserve = 0), /^\/flow\/openReserve:必须大于 0$/m], + [(c) => ((c.flow as Obj).decay = 1.5), /^\/flow\/decay:不能大于 1$/m], + [(c) => ((c.flow as Obj).roundZero = { enabled: true, discussions: 3 }), /^\/flow\/roundZero\/discussions:只能是 1、2 之一$/m], + [(c) => ((c.flow as Obj).preset = "fast"), /^\/flow\/preset:缺少或取值不对,只能是 "full"、"naive" 之一$/m], + [(c) => ((c.lightChecks as Obj).seconds = 0), /^\/lightChecks\/seconds:必须大于 0$/m], + ]; + for (const [change, expected] of cases) { + const config = fullConfig() as unknown as Obj; + change(config); + const message = errorOf(config); + assert.match(message, expected); + assert.match(message, /共 1 处/, `只改了一处,应只报一处:\n${message}`); + } +}); + +test("一次列出多处错误,超过 typebox 默认的 8 条、错在 Union 分支里的也都列出", () => { + const config = fullConfig() as unknown as Obj; + config.runs = -1; + delete config.promptDir; + config.extra = 1; + ((config.models as Obj).experimenter as Obj).model = ""; + ((config.models as Obj).researchers as Obj[])[2]!.thinking = null; + config.devEntry = 0; + config.factorLimit = 0; + (config.lightChecks as Obj).maxCount = -1; + const flow = config.flow as Obj; + flow.batchRule = "best"; + delete (flow.roundZero as Obj).enabled; + (flow.ablation as Obj).cellRule = "x"; + flow.decay = -1; + const message = errorOf(config); + const paths = [ + "/runs:", + "/promptDir:", + "/extra:", + "/models/experimenter/model:", + "/models/researchers/2/thinking:", + "/devEntry:", + "/factorLimit:", + "/lightChecks/maxCount:", + "/flow/batchRule:", + "/flow/roundZero/enabled:", + "/flow/ablation/cellRule:", + "/flow/decay:", + ]; + assert.match(message, new RegExp(`共 ${paths.length} 处`), message); + for (const path of paths) assert.ok(message.includes(path), `应报出 ${path}:\n${message}`); +}); + +test("流程写成了不是对象的值:报在流程这一项上", () => { + const config = fullConfig() as unknown as Obj; + config.flow = "full"; + assert.match(errorOf(config), /^\/flow:应为对象$/m); +}); + +test("写不出来的组合:朴素循环里写开关、规则或它用不到的方法参数", () => { + const extras: Obj = { + critique: false, + firstPrediction: false, + roundZero: { enabled: false }, + ablation: { enabled: false }, + batchRule: "uniformRandom", + openReserve: 0.1, + knowledgeUse: 0.5, + decay: 0.5, + }; + for (const [key, value] of Object.entries(extras)) { + const config = naiveConfig() as unknown as Obj; + (config.flow as Obj)[key] = value; + assert.match(errorOf(config), new RegExp(`^/flow/${key}:不认识的字段$`, "m")); + } +}); + +test("写不出来的组合:关掉的第零轮写讨论次数、关掉的消融写选格规则", () => { + const zero = fullConfig(); + (zero.flow as Obj).roundZero = { enabled: false, discussions: 2 }; + assert.match(errorOf(zero), /^\/flow\/roundZero\/discussions:不认识的字段$/m); + + const ablation = fullConfig(); + (ablation.flow as Obj).ablation = { enabled: false, cellRule: "all" }; + assert.match(errorOf(ablation), /^\/flow\/ablation\/cellRule:不认识的字段$/m); +}); + +test("写不出来的组合:规则名不在 rules 登记表里", () => { + const batch = fullConfig(); + (batch.flow as Obj).batchRule = "expected-best"; + assert.match(errorOf(batch), /^\/flow\/batchRule:只能是 "bestExpected"、"uniformRandom" 之一$/m); + + const cell = fullConfig(); + (cell.flow as Obj).ablation = { enabled: true, cellRule: "voi" }; + assert.match( + errorOf(cell), + /^\/flow\/ablation\/cellRule:只能是 "valueMinusCost"、"all"、"randomSameCount"、"none" 之一$/m, + ); +}); + +test("字段定义里每个字段都有中文说明,并标明用户配置还是方法参数", () => { + const seen: string[] = []; + const visit = (schema: Obj, at: string): void => { + const props = schema.properties as Record | undefined; + for (const [key, field] of Object.entries(props ?? {})) { + assert.equal(typeof field.description, "string", `${at}/${key} 缺说明`); + assert.ok(["用户配置", "方法参数"].includes(field.category as string), `${at}/${key} 没标类别`); + seen.push(`${at}/${key}`); + visit(field, `${at}/${key}`); + } + for (const branch of (schema.anyOf as Obj[] | undefined) ?? []) visit(branch, at); + if (schema.items) visit(schema.items as Obj, `${at}/0`); + }; + visit(ResearchConfig as unknown as Obj, ""); + // 抽查几项的类别。 + const category = (path: string[]) => { + let node = ResearchConfig as unknown as Obj; + for (const k of path) node = (node.properties as Record)[k]!; + return node.category; + }; + assert.equal(category(["studyId"]), "用户配置"); + assert.equal(category(["runs"]), "用户配置"); + assert.equal(category(["factorLimit"]), "方法参数"); + assert.equal(category(["flow"]), "方法参数"); + assert.ok(seen.includes("/models/researchers/0/thinking")); + assert.ok(seen.includes("/flow/ablation/cellRule")); +}); + +test("loadConfig:读文件并校验;不是 JSON 时报错带文件路径", () => { + const dir = mkdtempSync(join(tmpdir(), "inforsi-config-")); + const good = join(dir, "research.json"); + writeFileSync(good, JSON.stringify(fullConfig(), null, 2)); + assert.deepEqual(loadConfig(good), fullConfig()); + + const bad = join(dir, "bad.json"); + writeFileSync(bad, "{ runs: 13 }"); + assert.throws(() => loadConfig(bad), (e: Error) => e.message.startsWith(`${bad} 不是合法的 JSON`)); + + const invalid = join(dir, "invalid.json"); + writeFileSync(invalid, JSON.stringify({ ...fullConfig(), runs: 0 })); + assert.throws(() => loadConfig(invalid), /\/runs:不能小于 1/); +}); diff --git a/tests/config-helpers.ts b/tests/config-helpers.ts new file mode 100644 index 0000000..6427cfa --- /dev/null +++ b/tests/config-helpers.ts @@ -0,0 +1,41 @@ +import type { ResearchConfig } from "../src/config/index.ts"; + +// 两份写全的合法配置:完整流程一份、朴素循环一份。数值取方法.md 6.11 的现行值和参考项目真实用过的配置 +// (/home/iomgaa/rb-acceptance/15-real-1/research.json:运行 13 次、每批 2、M = 1、修复 1 次、轻量检查 2 次各 5 秒)。 + +const glm = { provider: "iomgaa", model: "glm-5.3", thinking: "medium" } as const; + +export function fullConfig(): ResearchConfig { + return { + studyId: "circle-full-1", + task: "tasks/circle_packing", + models: { + researchers: [{ ...glm }, { ...glm }, { ...glm }], + experimenter: { ...glm }, + verifier: { ...glm }, + }, + proposalLimit: 1, + batchLimit: 2, + runs: 13, + factorLimit: 3, + maxRepairs: 1, + lightChecks: { maxCount: 2, seconds: 5 }, + promptDir: "prompts", + devEntry: false, + flow: { + preset: "full", + roundZero: { enabled: true, discussions: 2 }, + firstPrediction: true, + critique: true, + ablation: { enabled: true, cellRule: "valueMinusCost" }, + batchRule: "bestExpected", + openReserve: 0.1, + knowledgeUse: 0.5, + decay: 0.5, + }, + }; +} + +export function naiveConfig(): ResearchConfig { + return { ...fullConfig(), studyId: "circle-naive-1", flow: { preset: "naive" } }; +} diff --git a/tests/config-steps.test.ts b/tests/config-steps.test.ts new file mode 100644 index 0000000..e3fbeca --- /dev/null +++ b/tests/config-steps.test.ts @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { checkConfig, stepList, type ResearchConfig, type StepList } from "../src/config/index.ts"; +import { fullConfig, naiveConfig } from "./config-helpers.ts"; + +// 每份预期都对照 docs/结构.md 第三部分"步骤清单"和 docs/方法.md 第十一部分逐条手写,不从实现里推。 +// 记法:[步骤, 执行 / 跳过, 规则];规则写名字和版本,版本取 rules 登记表里写的 1。 + +type Row = [string, "execute" | "skip", { name: string; version: number } | null]; +const E = "execute" as const; +const S = "skip" as const; + +function rows(steps: StepList["study"] | StepList["round"]): Row[] { + return steps.map((s) => [s.id, s.status, s.rule]); +} + +function listOf(change: (flow: Extract) => void): StepList { + const config = fullConfig(); + change(config.flow as Extract); + return stepList(checkConfig(config)); +} + +/** 跳过的步骤都写了原因。 */ +function assertReasons(list: StepList): void { + for (const s of [...list.study, ...list.round]) { + if (s.status === "skip") assert.ok(s.reason.length > 0, `${s.id} 跳过却没写原因`); + } +} + +test("步骤清单:完整流程全开", () => { + const list = stepList(fullConfig()); + assert.equal(list.preset, "full"); + assert.deepEqual(rows(list.study), [ + ["freezeConfig", E, null], + ["baselineRun", E, null], + ["entryDraft", E, null], + ["discussion1", E, null], + ["discussion2", E, null], + ["claim", E, null], + ["assignEntries", E, null], + ["rounds", E, null], + ["export", E, null], + ]); + assert.deepEqual(rows(list.round), [ + ["openRound", E, null], + ["carryOverPool", E, null], + ["proposal", E, null], + ["dedupe", E, null], + ["firstPrediction", E, null], + ["critique", E, null], + ["response", E, null], + ["freezeProtocol", E, null], + ["secondPrediction", E, null], + ["selectBatch", E, { name: "bestExpected", version: 1 }], + ["implementation", E, null], + ["verification", E, null], + ["repair", E, null], + ["forwardRun", E, null], + ["publish", E, null], + ["informationAccount", E, null], + ["adopt", E, null], + ["listCells", E, null], + ["explanation", E, null], + ["selectCells", E, { name: "valueMinusCost", version: 1 }], + ["ablationImplementation", E, null], + ["ablationVerification", E, null], + ["ablationRun", E, null], + ["settle", E, null], + ["wrapUp", E, null], + ]); +}); + +test("步骤清单:只关第零轮", () => { + const list = listOf((f) => (f.roundZero = { enabled: false })); + assertReasons(list); + assert.equal(list.preset, "full"); + assert.deepEqual(rows(list.study), [ + ["freezeConfig", E, null], + ["baselineRun", E, null], + ["entryDraft", S, null], + ["discussion1", S, null], + ["discussion2", S, null], + ["claim", S, null], + ["assignEntries", S, null], + ["rounds", E, null], + ["export", E, null], + ]); + assert.deepEqual(rows(list.round), rows(stepList(fullConfig()).round)); +}); + +test("步骤清单:只关第一次预测", () => { + const list = listOf((f) => (f.firstPrediction = false)); + assertReasons(list); + assert.deepEqual(rows(list.study), rows(stepList(fullConfig()).study)); + assert.deepEqual(rows(list.round), [ + ["openRound", E, null], + ["carryOverPool", E, null], + ["proposal", E, null], + ["dedupe", E, null], + ["firstPrediction", S, null], + ["critique", E, null], + ["response", E, null], + ["freezeProtocol", E, null], + ["secondPrediction", E, null], + ["selectBatch", E, { name: "bestExpected", version: 1 }], + ["implementation", E, null], + ["verification", E, null], + ["repair", E, null], + ["forwardRun", E, null], + ["publish", E, null], + ["informationAccount", E, null], + ["adopt", E, null], + ["listCells", E, null], + ["explanation", E, null], + ["selectCells", E, { name: "valueMinusCost", version: 1 }], + ["ablationImplementation", E, null], + ["ablationVerification", E, null], + ["ablationRun", E, null], + ["settle", E, null], + ["wrapUp", E, null], + ]); +}); + +test("步骤清单:只关挑刺与回应,协议照样冻结", () => { + const list = listOf((f) => (f.critique = false)); + assertReasons(list); + assert.deepEqual(rows(list.study), rows(stepList(fullConfig()).study)); + assert.deepEqual(rows(list.round), [ + ["openRound", E, null], + ["carryOverPool", E, null], + ["proposal", E, null], + ["dedupe", E, null], + ["firstPrediction", E, null], + ["critique", S, null], + ["response", S, null], + ["freezeProtocol", E, null], + ["secondPrediction", E, null], + ["selectBatch", E, { name: "bestExpected", version: 1 }], + ["implementation", E, null], + ["verification", E, null], + ["repair", E, null], + ["forwardRun", E, null], + ["publish", E, null], + ["informationAccount", E, null], + ["adopt", E, null], + ["listCells", E, null], + ["explanation", E, null], + ["selectCells", E, { name: "valueMinusCost", version: 1 }], + ["ablationImplementation", E, null], + ["ablationVerification", E, null], + ["ablationRun", E, null], + ["settle", E, null], + ["wrapUp", E, null], + ]); +}); + +test("步骤清单:只关消融,解释照样写,选格没有规则", () => { + const list = listOf((f) => (f.ablation = { enabled: false })); + assertReasons(list); + assert.deepEqual(rows(list.study), rows(stepList(fullConfig()).study)); + assert.deepEqual(rows(list.round), [ + ["openRound", E, null], + ["carryOverPool", E, null], + ["proposal", E, null], + ["dedupe", E, null], + ["firstPrediction", E, null], + ["critique", E, null], + ["response", E, null], + ["freezeProtocol", E, null], + ["secondPrediction", E, null], + ["selectBatch", E, { name: "bestExpected", version: 1 }], + ["implementation", E, null], + ["verification", E, null], + ["repair", E, null], + ["forwardRun", E, null], + ["publish", E, null], + ["informationAccount", E, null], + ["adopt", E, null], + ["listCells", S, null], + ["explanation", E, null], + ["selectCells", S, null], + ["ablationImplementation", S, null], + ["ablationVerification", S, null], + ["ablationRun", S, null], + ["settle", S, null], + ["wrapUp", E, null], + ]); +}); + +test("步骤清单:朴素循环", () => { + const list = stepList(naiveConfig()); + assertReasons(list); + assert.equal(list.preset, "naive"); + assert.deepEqual(rows(list.study), [ + ["freezeConfig", E, null], + ["baselineRun", E, null], + ["entryDraft", S, null], + ["discussion1", S, null], + ["discussion2", S, null], + ["claim", S, null], + ["assignEntries", S, null], + ["rounds", E, null], + ["export", E, null], + ]); + assert.deepEqual(rows(list.round), [ + ["openRound", E, null], + ["carryOverPool", S, null], // 候选不留池 + ["proposal", E, null], + ["dedupe", E, null], // 判重照常 + ["firstPrediction", S, null], // 不预测 + ["critique", S, null], // 不挑刺 + ["response", S, null], // 不回应 + ["freezeProtocol", E, null], // 提案直接冻结 + ["secondPrediction", S, null], // 不预测 + ["selectBatch", E, { name: "uniformRandom", version: 1 }], // 均匀随机选批 + ["implementation", E, null], + ["verification", E, null], + ["repair", E, null], + ["forwardRun", E, null], + ["publish", E, null], + ["informationAccount", S, null], // 没有预测,记不了信息账 + ["adopt", E, null], // 采纳规则和完整流程相同 + ["listCells", S, null], // 不消融 + ["explanation", S, null], + ["selectCells", S, null], + ["ablationImplementation", S, null], + ["ablationVerification", S, null], + ["ablationRun", S, null], + ["settle", S, null], + ["wrapUp", S, null], // 没有收尾 + ]); +}); + +test("步骤清单:第零轮讨论 1 次时跳过第 2 次;只有一位研究者时不讨论", () => { + const once = listOf((f) => (f.roundZero = { enabled: true, discussions: 1 })); + assertReasons(once); + assert.deepEqual(rows(once.study).slice(2, 7), [ + ["entryDraft", E, null], + ["discussion1", E, null], + ["discussion2", S, null], + ["claim", E, null], + ["assignEntries", E, null], + ]); + + const config = fullConfig(); + config.models.researchers = [config.models.researchers[0]!]; + const single = stepList(checkConfig(config)); + assertReasons(single); + assert.deepEqual(rows(single.study).slice(2, 7), [ + ["entryDraft", E, null], + ["discussion1", S, null], + ["discussion2", S, null], + ["claim", E, null], + ["assignEntries", E, null], + ]); +}); + +test("步骤清单:换规则后清单里的规则跟着换", () => { + const list = listOf((f) => { + f.batchRule = "uniformRandom"; + f.ablation = { enabled: true, cellRule: "randomSameCount" }; + }); + const rule = (id: string) => list.round.find((s) => s.id === id)!.rule; + assert.deepEqual(rule("selectBatch"), { name: "uniformRandom", version: 1 }); + assert.deepEqual(rule("selectCells"), { name: "randomSameCount", version: 1 }); +}); diff --git a/tests/ledger-store.test.ts b/tests/ledger-store.test.ts index 47374d0..ae10520 100644 --- a/tests/ledger-store.test.ts +++ b/tests/ledger-store.test.ts @@ -2,7 +2,7 @@ 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 { Ledger, type LedgerRecord } from "../src/ledger/index.ts"; import { hash } from "../src/vocab/index.ts"; import { callStart, @@ -116,12 +116,33 @@ test("唯一约束:同一次模型调用结束两次报错", (t) => { assert.equal(countRows(path), 2); }); -test("唯一约束:同一次消融结算两次报错", (t) => { +test("唯一约束:同一轮结算两次报错,不同轮各一次照写;结算不带消融编号", (t) => { + // 每轮最多采纳一个改法,所以最多一次消融(docs/结构.md 第四部分):结算按轮次只发生一次。 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); + ledger.append("settlement", { round: 1, result: { cells: { p1: "valid" } } }); + assert.throws(() => ledger.append("settlement", { round: 1, result: { cells: { p1: "insufficient" } } }), /settlement_once/); + ledger.append("settlement", { round: 2, result: { cells: { p1: "insufficient" } } }); + ledger.append("settlement", { round: 3, result: { cells: { p1: "valid" } } }); + assert.throws(() => ledger.append("settlement", { ablation: "a1", result: {} } as never), /settlement/); + assert.throws(() => ledger.append("settlement", { round: 4, ablation: "a4", result: {} } as never), /settlement/); + assert.equal(countRows(path), 3); +}); + +test("私人笔记:同一人写两条都存下,以后写的(序号大的)为准;笔记不带版本号", (t) => { + // 笔记不属于任何阶段,以最后写入的一条为准,序号就是先后(docs/结构.md 第四部分)。 + // 后写的正文按字典序排在前面,中间夹着别人的笔记:取"序号最大的那条"才得到后写的。 + const { ledger, path } = freshLedger(t); + ledger.append("note", { author: "r1", body: "z 先写的" }); + ledger.append("note", { author: "r2", body: "别人的笔记" }); + ledger.append("note", { author: "r1", body: "a 后写的" }); + const mine = ledger + .records() + .filter((r): r is LedgerRecord<"note"> => r.kind === "note") + .filter((r) => r.content.author === "r1"); + assert.deepEqual(mine.map((r) => [r.seq, r.content.body]), [[1, "z 先写的"], [3, "a 后写的"]]); + assert.equal(mine.at(-1)!.content.body, "a 后写的"); + assert.throws(() => ledger.append("note", { author: "r1", version: 2, body: "带版本号" } as never), /note/); + assert.equal(countRows(path), 3); }); test("没有唯一约束的记录照写:同样的事件、决定写两次就是两条", (t) => { @@ -233,7 +254,7 @@ test("关掉再打开:读出的记录不变,序号接着往下排", (t) => { 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: "笔记" }); + ledger.append("note", { author: "r2", body: "笔记" }); closeStage(ledger, "s1"); planRun(ledger, "run1", "forward", 1); ledger.append("callStarted", callStart("k1"));