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
+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 { 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"));