feat: 配置 config 模块(任务书 04)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 08:08:18 -04:00
parent 4dc7ac9d62
commit d2bfb971ab
13 changed files with 1045 additions and 20 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ const ALLOWED = {
"runner", "tasks", "workspace", "config", "jev", "runner", "tasks", "workspace", "config", "jev",
], ],
export: ["ledger", "rules", "materials"], export: ["ledger", "rules", "materials"],
config: [], config: ["rules"],
materials: ["ledger", "rules"], materials: ["ledger", "rules"],
submissions: ["jev"], submissions: ["jev"],
ledger: [], ledger: [],
+6 -3
View File
@@ -14,6 +14,7 @@
- 任务书 01(工具链 + vocab):npm 包、TypeScript、`node --test`、dependency-cruiser 规则全写,临时违规实测 14 条都报错;vocab 1030 行,`npm run check` 通过,`npm test` 24 条通过。结构.md 第八部分补了 vocab 对外内容。 - 任务书 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 对外内容。 - 任务书 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 对外内容。 - 任务书 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. 消融核验覆盖每个因子。 14. 消融核验覆盖每个因子。
- 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。 - 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。
- 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。 - 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。
- 给 config:每批上限必须 ≥ 1(为 0 时 R̂ 是无穷大,选格会卡住)。
- 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。 - 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。
- 给 materials:被退回的提案原样存在退回记录里,作者自评没有拆成封存,所以不能把别人的退回记录给研究者看。方法.md 第三部分"作者自评从交上来那一刻起就封存"和"被退回的提交原样记"之间怎么处理,待用户定。 - 给 materials:被退回的提案原样存在退回记录里,作者自评没有拆成封存,所以不能把别人的退回记录给研究者看。方法.md 第三部分"作者自评从交上来那一刻起就封存"和"被退回的提交原样记"之间怎么处理,待用户定。
- 给 flow:确认启动前要先登记运行;模型调用要先开始再结束;阶段要先开再关;ledger 都不查。 - 给 flow:确认启动前要先登记运行;模型调用要先开始再结束;阶段要先开再关;ledger 都不查。
- ledger 待顺手改(下一份涉及 ledger 的任务书带上):结算的唯一约束改按轮次(每轮最多一次消融,结构.md 第四部分已改),去掉 `ablation` 编号;私人笔记去掉版本号,以最后写入的一条为准(序号就是先后,版本号是程序推得出的栏目)。
- 给 materials:别人的"退回"记录不放进任何研究者的材料(退回的提案原样带着作者自评,不另拆封存)。 - 给 materials:别人的"退回"记录不放进任何研究者的材料(退回的提案原样带着作者自评,不另拆封存)。
- 给 agents:模型的思考档位必须写明,不用参考项目"写 null 表示用 Pi 自己的默认"那种写法。 - 给 agents:模型的思考档位必须写明,不用参考项目"写 null 表示用 Pi 自己的默认"那种写法。
- 给 agents、runner:"整个环境的用量"(实验员会话的 CPU、内存等)写这两个模块时再定记录种类。 - 给 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 加载插件能否接受。 - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。
## 工作方式(用户 2026-10-01 定) ## 工作方式(用户 2026-10-01 定)
@@ -77,4 +80,4 @@
## 下一步 ## 下一步
任务书 03(ledger)已核对通过(716f4b8)。`任务书/04-config.md` 已写好(含 ledger 两处顺手改),等用户另开 Agent 执行,监督者核对。 任务书 04(config)已完成并提交,等监督者核对;"未完成"里任务书 04 留下的三个问题待用户定。
+27 -3
View File
@@ -34,7 +34,7 @@
| 入口 | plugin(插件入口) | 用户在 Pi 里怎么操作:命令、状态显示、配置对话;文件锁;把各模块装配起来 | 装配时用到各模块 | | 入口 | plugin(插件入口) | 用户在 Pi 里怎么操作:命令、状态显示、配置对话;文件锁;把各模块装配起来 | 装配时用到各模块 |
| 组织 | flow(流程) | 按步骤清单推进研究;每次启动走同一条恢复路径 | ledger、rules、materials、submissions、agents、runner、tasks、workspace、config、jev | | 组织 | flow(流程) | 按步骤清单推进研究;每次启动走同一条恢复路径 | ledger、rules、materials、submissions、agents、runner、tasks、workspace、config、jev |
| 组织 | export(导出) | 导出给人看的格式 | ledger、rules、materials | | 组织 | export(导出) | 导出给人看的格式 | ledger、rules、materials |
| 组织 | config(配置) | research.json 的字段和校验(每个字段必填,不补默认值);把配置翻译成步骤清单 | 无 | | 组织 | config(配置) | research.json 的字段和校验(每个字段必填,不补默认值);把配置翻译成步骤清单 | rules |
| 组织 | materials(材料) | 每个任务能看到哪些材料(方法.md 第三部分);第一条消息放哪些、什么顺序、怎么写;在这些材料范围内回答模型的查询 | ledger、rules | | 组织 | materials(材料) | 每个任务能看到哪些材料(方法.md 第三部分);第一条消息放哪些、什么顺序、怎么写;在这些材料范围内回答模型的查询 | ledger、rules |
| 组织 | submissions(收件) | 一份提交收下还是退回:先程序检查,再问 Jev(字段定义在 vocab) | jev | | 组织 | submissions(收件) | 一份提交收下还是退回:先程序检查,再问 Jev(字段定义在 vocab) | jev |
| 外部 | ledger(账本) | 记录怎么存、怎么保证不可改、怎么从记录算出当前状态;花钱动作的登记 | 无 | | 外部 | ledger(账本) | 记录怎么存、怎么保证不可改、怎么从记录算出当前状态;花钱动作的登记 | 无 |
@@ -392,7 +392,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `Ledger.open(path)`:打开一份研究的账本,新文件就建表,旧文件核对表结构版本号,对不上就报错。 - `Ledger.open(path)`:打开一份研究的账本,新文件就建表,旧文件核对表结构版本号,对不上就报错。
- `ledger.close()`:关闭数据库。 - `ledger.close()`:关闭数据库。
- `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的四种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算;其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。 - `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的四种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算(按轮次,一轮一条);其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。
- `ledger.transaction(fn)`:里面的几次追加要么全部写进去,要么一条都不写;fn 必须是同步函数,返回值类型排除了 Promise,异步函数在类型检查时就被挡住;不能嵌套。 - `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 在写提案时一并写)。 - `AppendKind`:调用方能追加的种类,除了 `seal`(封存只由 ledger 在写提案时一并写)。
- `Content<K>`:某种记录存下的内容。 - `Content<K>`:某种记录存下的内容。
- `Input<K>`:追加时交的内容;只有提交和存下的不同:提案带着作者自评。 - `Input<K>`:追加时交的内容;只有提交和存下的不同:提案带着作者自评。
- `LedgerRecord<K>`:一条记录:序号、种类、内容的哈希、内容。 - `LedgerRecord<K>`:一条记录:序号、种类、内容的哈希、内容。
- `SubmissionSlot`(类型):一份提交不论第几版:阶段、作者、提交种类(版本号只在同一种提交里往下接)、针对谁(候选或格,没有就空)、这一份的编号(只有提案、挑刺这两种一人能交好几份的才有,其余必须为空)。 - `SubmissionSlot`(类型):一份提交不论第几版:阶段、作者、提交种类(版本号只在同一种提交里往下接)、针对谁(候选或格,没有就空)、这一份的编号(只有提案、挑刺这两种一人能交好几份的才有,其余必须为空)。
- `StoredSubmission`、`SubmissionInput`(类型):存下的提交和追加时交的提交,按提交种类区分内容类型;版本号从 1 起,替换时加 1,被替换的就是上一版。 - `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<S>`(类型):一步:编号、规则(只有选批、选格有,其余为空)、执行或跳过及原因。
- `RuleRef`(类型):决策点用的规则,名字和版本。
- `STUDY_STEPS`、`StudyStepId`:整份研究的步骤,按先后排。
- `ROUND_STEPS`、`RoundStepId`:每一轮的步骤,按先后排;比第三部分拆得更细(例如"回应与统一"和"协议冻结"分成两步,挑刺与回应关掉时协议照样冻结)。
+125
View File
@@ -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<typeof Value.Errors>[number];
const TYPE_TEXT: Readonly<Record<string, string>> = {
string: "字符串",
number: "数字",
integer: "整数",
boolean: "true 或 false",
object: "对象",
array: "数组",
null: "null",
};
function reasonOf(e: SchemaError): string {
const p = e.params as Record<string, unknown>;
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<string, unknown>)[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<string, { const?: unknown }> }).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<string, unknown>)[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);
}
+13
View File
@@ -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";
+179
View File
@@ -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<T extends TSchema>(schema: T, description: string): T {
return { ...schema, description, category: "用户配置" };
}
function method<T extends TSchema>(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<typeof ResearchConfig>;
+136
View File
@@ -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<S extends string> = { id: S; rule: RuleRef | null } & Status;
export interface StepList {
/** 完整流程还是朴素循环;materials 据此决定给什么材料(朴素循环没有信念本和私人笔记)。 */
preset: ResearchConfig["flow"]["preset"];
study: Step<StudyStepId>[];
round: Step<RoundStepId>[];
}
const EXECUTE: Status = { status: "execute" };
const skip = (reason: string): Status => ({ status: "skip", reason });
/**
* 朴素循环照常做的步骤(方法.md 第十一部分):提案、判重、提案直接冻结、均匀随机选批、实现到正式运行、公开分数、采纳。
* 其余都不做:不预测、不挑刺不回应、不消融,没有第零轮、收尾,候选不留池;没有预测,也就没有信息账。
*/
const NAIVE_STEPS: ReadonlySet<StudyStepId | RoundStepId> = 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<Record<StudyStepId | RoundStepId, Status>> = {};
const off = (ids: Array<StudyStepId | RoundStepId>, 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) })),
};
}
+4 -4
View File
@@ -128,8 +128,8 @@ export const KINDS = {
}, },
CLOSED, CLOSED,
), ),
/** 3 私人笔记的每一版:每次修改追加一版,以版本号最大的为准。 */ /** 3 私人笔记:不属于任何阶段,每次修改追加一条,以最后写入的一条(序号最大)为准。 */
note: Type.Object({ author: Id, version: Version, body: Text }, CLOSED), note: Type.Object({ author: Id, body: Text }, CLOSED),
/** 4 冻结协议:每个候选一份。内容由 flow 合成,先存 Json。 */ /** 4 冻结协议:每个候选一份。内容由 flow 合成,先存 Json。 */
protocol: Type.Object({ candidate: Id, protocol: AnyJson }, CLOSED), protocol: Type.Object({ candidate: Id, protocol: AnyJson }, CLOSED),
@@ -176,8 +176,8 @@ export const KINDS = {
CLOSED, CLOSED,
), ),
/** 9 结算:一次消融一条,各格结局、配合关系、解释的确认或否定等先存 Json。 */ /** 9 结算:每轮最多采纳一个改法,所以最多一次消融,结算按轮次一轮一条。各格结局、配合关系、解释的确认或否定等先存 Json。 */
settlement: Type.Object({ ablation: Id, result: AnyJson }, CLOSED), settlement: Type.Object({ round: Round, result: AnyJson }, CLOSED),
/** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */ /** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */
callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED), callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED),
+3 -2
View File
@@ -3,7 +3,7 @@ import type { DatabaseSync } from "node:sqlite";
// 账本的表结构(docs/结构.md 第四部分"全部只追加"):只有一张表,每一行是序号、种类、内容、内容的哈希。 // 账本的表结构(docs/结构.md 第四部分"全部只追加"):只有一张表,每一行是序号、种类、内容、内容的哈希。
// 表结构一改就把版本号加 1;旧文件版本号对不上时打开报错,不迁移,也不逐字核对建表语句。 // 表结构一改就把版本号加 1;旧文件版本号对不上时打开报错,不迁移,也不逐字核对建表语句。
export const SCHEMA_VERSION = 1; export const SCHEMA_VERSION = 2;
/** 新文件要执行的建表语句,由 Ledger 在一个事务里执行,最后写入版本号。 */ /** 新文件要执行的建表语句,由 Ledger 在一个事务里执行,最后写入版本号。 */
export const CREATE_SCHEMA = [ 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 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_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 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}`, `PRAGMA user_version=${SCHEMA_VERSION}`,
]; ];
+216
View File
@@ -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<string, unknown>;
/** 配置里每个对象字段的路径(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<string, Obj> | 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<string, Obj>)[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/);
});
+41
View File
@@ -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" } };
}
+266
View File
@@ -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<ResearchConfig["flow"], { preset: "full" }>) => void): StepList {
const config = fullConfig();
change(config.flow as Extract<ResearchConfig["flow"], { preset: "full" }>);
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 });
});
+28 -7
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import { test } from "node:test"; 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 { hash } from "../src/vocab/index.ts";
import { import {
callStart, callStart,
@@ -116,12 +116,33 @@ test("唯一约束:同一次模型调用结束两次报错", (t) => {
assert.equal(countRows(path), 2); assert.equal(countRows(path), 2);
}); });
test("唯一约束:同一次消融结算两次报错", (t) => { test("唯一约束:同一轮结算两次报错,不同轮各一次照写;结算不带消融编号", (t) => {
// 每轮最多采纳一个改法,所以最多一次消融(docs/结构.md 第四部分):结算按轮次只发生一次。
const { ledger, path } = freshLedger(t); const { ledger, path } = freshLedger(t);
ledger.append("settlement", { ablation: "a1", result: { cells: { p1: "valid" } } }); ledger.append("settlement", { round: 1, result: { cells: { p1: "valid" } } });
assert.throws(() => ledger.append("settlement", { ablation: "a1", result: { cells: { p1: "valid" } } }), /settlement_once/); assert.throws(() => ledger.append("settlement", { round: 1, result: { cells: { p1: "insufficient" } } }), /settlement_once/);
ledger.append("settlement", { ablation: "a2", result: { cells: { p1: "insufficient" } } }); ledger.append("settlement", { round: 2, result: { cells: { p1: "insufficient" } } });
assert.equal(countRows(path), 2); 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) => { test("没有唯一约束的记录照写:同样的事件、决定写两次就是两条", (t) => {
@@ -233,7 +254,7 @@ test("关掉再打开:读出的记录不变,序号接着往下排", (t) => {
ledger.append("config", { researchers: ["r1", "r2"] }); ledger.append("config", { researchers: ["r1", "r2"] });
openStage(ledger, "s1", "proposal", 1); 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("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"); closeStage(ledger, "s1");
planRun(ledger, "run1", "forward", 1); planRun(ledger, "run1", "forward", 1);
ledger.append("callStarted", callStart("k1")); ledger.append("callStarted", callStart("k1"));