Files
InfoRSI/tests/config-check.test.ts
T
iomgaa 0ac1e01613 feat: 任务书 08,jev 模块与账本记 Jev 判断
src/jev 3 个文件 515 行:判重、查冻结条款、查代码改动三个函数,九问问法和阈值照参考项目,只改说法和字段名,阈值注明未在新项目材料上校准,等真实数据。
新增 npm run test:jev(真实 Jev 三处各问一次)。vocab 加 JevJudgment。
ledger 表结构版本 3:callEnded 四种结局对上 agents,墙钟时间用 Measured(恢复时补写出错、原因"进程中断"、测不到);退回、提交记录都带 jev。
config 加必填的 models.jev。

用户原话:"这次就给提交记录加一个和退回记录同样的 Jev 判断字段";"墙钟时间改用 Measured,恢复时写出错加测不到(原因'进程中断')"。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:11:20 -04:00

233 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("Jev 模型必填:少了、写 null、写空白都报错,不补 jev-latest", () => {
const missing = fullConfig() as unknown as Obj;
delete (missing.models as Obj).jev;
assert.match(errorOf(missing), /^\/models\/jev:缺少这一项/m);
const nulled = fullConfig() as unknown as Obj;
(nulled.models as Obj).jev = null;
assert.match(errorOf(nulled), /^\/models\/jev:应为字符串$/m);
const blank = fullConfig() as unknown as Obj;
(blank.models as Obj).jev = " ";
assert.match(errorOf(blank), /^\/models\/jev:不能为空,也不能只有空白$/m);
const naive = naiveConfig() as unknown as Obj;
delete (naive.models as Obj).jev;
assert.match(errorOf(naive), /^\/models\/jev:缺少这一项/m);
});
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.equal(category(["models", "jev"]), "用户配置");
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/);
});