diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..fdcd834 --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,129 @@ +// 模块之间的依赖规则,来源是 docs/结构.md 第二部分(模块表)和第六部分(检查规则)。 +// 模块按 src/<模块名>/ 认;新建模块目录后自动受这些规则检查。规则只管 src 下的模块,tests/ 不受"只有某模块能用某外部包"的限制。 + +// 模块表"依赖"一列:每个模块除了自己和 vocab 以外,还能引用哪些模块。plugin 装配时可用各模块。 +const ALLOWED = { + plugin: [ + "flow", "export", "config", "materials", "submissions", + "ledger", "agents", "runner", "tasks", "workspace", "jev", "rules", + ], + flow: [ + "ledger", "rules", "materials", "submissions", "agents", + "runner", "tasks", "workspace", "config", "jev", + ], + export: ["ledger", "rules", "materials"], + config: [], + materials: ["ledger", "rules"], + submissions: ["jev"], + ledger: [], + agents: [], + runner: [], + tasks: [], + workspace: [], + jev: [], + rules: [], + vocab: [], +}; +const MODULES = Object.keys(ALLOWED); +const anyOf = (names) => `(${names.join("|")})`; + +// 外部的东西各由哪些名字认出来。核心模块带不带 node: 前缀都认; +// npm 包装好后解析成 node_modules/ 下的路径,没装时是包名本身,两种都认。 +const npm = (names) => `(^|node_modules/)${anyOf(names)}(/|$)`; +const SUBPROCESS = [ + "^(node:)?(child_process|cluster)$", + npm(["cross-spawn", "execa"]), +]; +const SQLITE = ["^node:sqlite$", npm(["better-sqlite3", "sqlite3"])]; +const PI = npm(["@earendil-works/[^/]+"]); + +const directionRules = MODULES.map((name) => ({ + name: `direction-${name}`, + comment: `${name} 只能引用自己、vocab 和模块表里列出的模块:${ALLOWED[name].join("、") || "无"}`, + severity: "error", + from: { path: `^src/${name}/` }, + to: { path: "^src/", pathNot: `^src/${anyOf([name, "vocab", ...ALLOWED[name]])}/` }, +})); + +module.exports = { + forbidden: [ + { + name: "no-circular", + comment: "不许循环引用", + severity: "error", + from: {}, + to: { circular: true }, + }, + { + name: "unknown-module", + comment: `src 下只有模块表里的模块目录:${MODULES.join("、")}`, + severity: "error", + from: { path: "^src/", pathNot: `^src/${anyOf(MODULES)}/` }, + to: {}, + }, + ...directionRules, + { + name: "through-index", + comment: "别的模块只能通过目录下的 index.ts 引用它", + severity: "error", + from: { path: "^src/([^/]+)/" }, + to: { path: "^src/[^/]+/", pathNot: ["^src/$1/", "^src/[^/]+/index\\.ts$"] }, + }, + { + name: "outside-src", + comment: "src 下的文件只许引用 src/、node_modules/ 与 Node 内置模块,不能借 src 外的文件绕开规则", + severity: "error", + from: { path: "^src/" }, + to: { pathNot: ["^src/", "^node_modules/"], dependencyTypesNot: ["core"] }, + }, + { + name: "pure-allowlist", + comment: + "rules 和 vocab 不碰文件、数据库、网络、子进程和 Pi:除了 src 下允许的模块," + + "只许引用 typebox 和 node:crypto", + severity: "error", + from: { path: "^src/(rules|vocab)/" }, + to: { pathNot: ["^src/", "^node_modules/typebox/", "^(node:)?crypto$"] }, + }, + { + name: "sqlite-only-ledger", + comment: "只有 ledger 能用 SQLite", + severity: "error", + from: { path: "^src/", pathNot: "^src/ledger/" }, + to: { path: SQLITE }, + }, + { + name: "pi-only-agents-plugin", + comment: "只有 agents 和 plugin 能用 Pi", + severity: "error", + from: { path: "^src/", pathNot: "^src/(agents|plugin)/" }, + to: { path: PI }, + }, + { + name: "subprocess-only-runner-workspace", + comment: "只有 runner 和 workspace 能起子进程", + severity: "error", + from: { path: "^src/", pathNot: "^src/(runner|workspace)/" }, + to: { path: SUBPROCESS }, + }, + { + name: "tasks-not-runner", + comment: "tasks 不许引用 runner:runner 只管怎么跑,tasks 只管怎么判,由 flow 串起来", + severity: "error", + from: { path: "^src/tasks/" }, + to: { path: "^src/runner/" }, + }, + ], + options: { + doNotFollow: { path: "node_modules" }, + tsPreCompilationDeps: true, + tsConfig: { fileName: "tsconfig.json" }, + // 按 Node 的 ESM 规则读 npm 包的 exports(dependency-cruiser 默认不读), + // 否则 typebox 这类只在 exports 里给入口的包解析不到。 + enhancedResolveOptions: { + extensions: [".ts", ".js"], + exportsFields: ["exports"], + conditionNames: ["import", "node", "default"], + }, + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/WORK_LOG.md b/WORK_LOG.md index 47464f2..d7ed0b8 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -11,6 +11,7 @@ - CLAUDE.md:按官方写法写成(提交 3861fd8 及之后)。 - `docs/方法.md`:由参考项目的研究方案 v3、设计-15、设计-16 合成(f32c0bd)。之后按用户决定改过:消融只测信息价值选中的格(5e4f8c2);保障的定义借用"控制变量"和"重构"来说明(141494f)。 - `docs/结构.md`:13 个模块加 vocab、步骤清单、账本 12 种记录、可替换点、检查规则、粗估规模(6cf9f0c)。 +- 任务书 01(工具链 + vocab):npm 包、TypeScript、`node --test`、dependency-cruiser 规则全写,临时违规实测 14 条都报错;vocab 1030 行,`npm run check` 通过,`npm test` 24 条通过。结构.md 第八部分补了 vocab 对外内容。 ## 关键决定(用户原话见各提交说明) @@ -19,13 +20,34 @@ - 不设轮数上限,只限运行次数。 - 第一次预测放在挑刺之前、看原始提案;第二次预测在冻结之后、看冻结协议。 - 子 Agent 调研时不写 md 文件,结论直接回报。 -- 提交的字段定义放 vocab,submissions 只判收不收;vocab 只放两个函数(规范序列化、哈希),上限 600 行。用户原话:"我都同意"。 +- 提交的字段定义放 vocab,submissions 只判收不收;vocab 只放两个函数(规范序列化、哈希)。用户原话:"我都同意"。 +- vocab 上限改为 1100 行(多出来的主要是发给模型的字段说明)。用户原话:"同意 A,vocab 上限改为 1100 行。结构.md 由监督者改,你不用动。" +- 每份预测只对一个候选、每份挑刺只针对一个目标;入口、因子、实现条款的编号由程序按位置分配;实现提交不设代码字段(程序从工作目录冻结);运行结果用"没启动 / 正常退出 / 超时 / 被取消"一个字段表示启动与结束;四种 token 数都可记测不到。用户原话:"3、4、5、6:同意保留你的写法。方法.md 第五部分的表(入口编号、实现的代码字段)由监督者改。" +- 解释的每格三个数改为可选,关掉消融时不交。用户原话:"同意 A:解释的 cells 去掉 minItems,改为可选"。 +- 答案路径、保障动手次数只记读到的原始内容或读不到的原因;在不在位置表里,由以后拿得到位置表的模块现算,不存。用户原话:"按'能现算的不存',tasks 只记读到的原始内容……对表由以后有位置表的模块现算,把这条记进 WORK_LOG。" ## 未完成 - `inbox/调研.md`:要迁进 `docs/调研/` 的调研结论。 - `docs/方法.md` 里还有 7 条标〔自定〕的规则,待用户过目。 -- 还没有代码、工具链和测试入口。 +- 监督者待改:结构.md 第七部分 vocab 上限改 1100;第二部分"同一层之间不互相依赖"改为"图中同一行的模块之间不互相依赖";方法.md 第五部分的表(入口编号由程序分配、实现不设代码字段)。 +- 留给 submissions 的跨字段规则(vocab 字段定义不管): + 1. q1 ≤ q5 ≤ q9:作者自评、两次预测、解释的每一格。 + 2. 因子数不超过上限,回应改过的因子清单同样适用。 + 3. 同一提案里两个因子不能延续同一个因子;延续的编号要在信念本里看得到。 + 4. 保障短名不能是 `main`,不能写成 `p<数字>`,同一清单里不重复。 + 5. 开着消融时每条解释必须交 `cells`,并恰好覆盖材料列出的格;关掉消融时不交。 + 6. 解释最多 M 条。 + 7. 志愿至少 min(N, 入口总数) 个、不重复、编号都在入口清单里。 + 8. 挑刺针对的因子编号在被挑提案的因子范围内。 + 9. 回应的逐条答复恰好覆盖针对本候选的全部挑刺;统一的 `sources` 恰好覆盖被合并的原提案。 + 10. `reused` 引用的指标存在且是假设专用指标;新登记的指标短名不重复。 + 11. 实现和修复:位置表覆盖每个因子和每条保障;代码位置起始行不大于结束行。 + 12. 核验:条款、因子、保障逐项全覆盖;没有归属的改动与材料列出的逐条对应。 + 13. 消融实现:写了 `cannotRun` 时 `alsoOff` 为空;`alsoOff` 不含本格要关的因子,且都在因子清单里。 + 14. 消融核验覆盖每个因子。 +- 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。 +- 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 ## 工作方式(用户 2026-10-01 定) @@ -38,4 +60,4 @@ ## 下一步 -`任务书/01-vocab.md` 已写好(vocab 加工具链),等用户另开 Agent 执行,监督者核对。之后依次是 rules → ledger → …。 +任务书 01 已执行完,等监督者核对。之后依次是 rules → ledger → …。 diff --git a/docs/结构.md b/docs/结构.md index 0a3a73a..1d5a0f6 100644 --- a/docs/结构.md +++ b/docs/结构.md @@ -196,3 +196,50 @@ ## 八、各模块对外的函数与类型 写到哪个模块时,再把它对外的函数和类型补在这里。 + +### vocab + +下面是 `src/vocab/index.ts` 导出的全部内容。标"字段定义"的,是一份 TypeBox 定义,同名的 TypeScript 类型也一起导出:同一份东西既当类型用,也能在运行时检查数据(`Value.Check`),还能直接当发给模型的工具参数。 + +**两个函数** + +- `canonical(value)`:规范序列化。对象的键排序后再拼成字符串,数字必须是有限数,只接受纯 JSON;内容相同就一定得到同一个字符串。 +- `hash(value)`:对规范序列化的结果算 sha256,得到 64 位十六进制的指纹。 + +**基础** + +- `Json`(类型):任意纯 JSON 值。 +- `Id`(字段定义):编号的格式,只许字母、数字和 `._-`,首字符是字母或数字,最长 128 个字符,因为编号会被拼进文件路径和命令行。 +- `DIRECTIONS`、`Direction`(字段定义):指标方向,只有越大越好(maximize)和越小越好(minimize)两种。 +- `Measured`(字段定义):测到 / 测不到。测到带值,测不到带原因,所有可能测不到的量都用它,不拿 0 或 null 冒充。 + +**名单** + +- `ROLES`、`Role`:三个角色,研究者、实验员、核验员。 +- `STAGES`、`Stage`:有人交提交的各个阶段,按流程先后排;程序自己做的步骤不在里面。 +- `SUBMISSION_KINDS`、`SubmissionKind`:每种提交属于哪个阶段、由哪个角色交,各模块都从这张表取。 + +**指标** + +- `METRIC_LAYERS`、`MetricLayer`:指标的三个层次,最终、常规辅助、假设专用。 +- `MISSING_POLICIES`、`MissingPolicy`:测不到时怎么办的两档,缺了只记缺失、缺了整次作废。 +- `MISSING_POLICY_EFFECTS`:两档各自后果的白话说明,字段说明和给模型的材料共用这一份。 +- `HypothesisMetric`(字段定义):研究者在提案里登记的一条假设专用指标。 +- `MetricDefinition`(字段定义):一条完整的指标定义,带编号和层次。 + +**运行** + +- `RunDefinition`(字段定义):tasks 交给 runner 的运行定义,写明命令、工作目录、按 sha256 钉死的镜像和运行时、时限、核数和环境变量;候选进程不继承宿主的环境变量。 +- `ProcessResult`(字段定义):runner 交出的进程层面结果,分没启动、正常退出、超时、被取消四种;启动过的带资源用量。 +- `VERDICTS`、`Verdict`:一次运行的判定,算数、不算数、判不了三选一。 +- `ANSWER_PATH_KEY`、`SAFEGUARD_TRIGGERS_KEY`:候选在指标文件里多写的两个保留键名,`answer-path` 和 `safeguard-triggers`。 +- `AnswerPath`(字段定义):读到的答案路径原样字符串,或读不到及原因;在不在位置表里由拿得到位置表的模块现算。 +- `SafeguardTriggers`(字段定义):读到的"保障短名 → 动手次数"原样内容,或读不到及原因。 +- `JudgedResult`(字段定义):tasks 从原始产物独立判分的结果,包括判定和理由、分数、各指标取值、答案路径、保障动手次数和任务自己的附加记录。 +- `ModelUsage`(字段定义):一次模型调用的用量,四种 token 数和价格,每一项都可能测不到。 + +**提交** + +- `Range`(字段定义):三个数 q1、q5、q9,即一个分数有一成、一半、九成可能低于多少。 +- `SUBMISSION_SCHEMAS`:每种提交的字段定义,键与 `SUBMISSION_KINDS` 相同;同一份定义是类型、收件格式检查和提交工具参数。 +- `Submission`(类型):某种提交的内容类型,例如 `Submission<"proposal">`。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28f36dd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,609 @@ +{ + "name": "inforsi", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inforsi", + "version": "0.0.0", + "dependencies": { + "typebox": "1.3.7" + }, + "devDependencies": { + "@types/node": "22.19.19", + "dependency-cruiser": "18.5.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-jsx-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz", + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/dependency-cruiser": { + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-18.5.0.tgz", + "integrity": "sha512-LFvJOvufjQ7XTucSI40NHqse0gcQBevDGkZabLAKcGwO+VWOywTqZ5EyFRYWSokNxvLOO7v6EXyNi+9I72oWUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "8.18.0", + "acorn-jsx": "5.3.2", + "acorn-jsx-walk": "2.0.0", + "acorn-loose": "8.5.2", + "acorn-walk": "8.3.5", + "commander": "15.0.0", + "enhanced-resolve": "5.26.0", + "ignore": "7.0.11", + "interpret": "3.1.1", + "is-installed-globally": "1.0.1", + "json5": "2.2.3", + "picomatch": "4.0.7", + "prompts": "2.4.2", + "rechoir": "0.8.0", + "safe-regex": "2.1.1", + "semver": "7.8.5", + "tsconfig-paths-webpack-plugin": "4.2.0", + "watskeburt": "6.0.0" + }, + "bin": { + "depcruise": "bin/dependency-cruiser.mjs", + "depcruise-baseline": "bin/depcruise-baseline.mjs", + "depcruise-fmt": "bin/depcruise-fmt.mjs", + "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs", + "dependency-cruise": "bin/dependency-cruiser.mjs", + "dependency-cruiser": "bin/dependency-cruiser.mjs" + }, + "engines": { + "node": "^22||^24||>=26" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", + "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.1.tgz", + "integrity": "sha512-li88H4QGRoNMX27jEmY8nR515oJVspAgC/WFMlhHfFV8RJ4QyGpHhXnwAe9gH5RIgKmdYRUmrOhQobcgEjIdSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/watskeburt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-6.0.0.tgz", + "integrity": "sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==", + "dev": true, + "license": "MIT", + "bin": { + "watskeburt": "dist/run-cli.js" + }, + "engines": { + "node": "^22.13||^24||>=26" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..36eae9b --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "inforsi", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "check": "tsc --noEmit -p tsconfig.json && depcruise src tests --config .dependency-cruiser.cjs", + "test": "node --test \"tests/**/*.test.ts\"" + }, + "dependencies": { + "typebox": "1.3.7" + }, + "devDependencies": { + "@types/node": "22.19.19", + "dependency-cruiser": "18.5.0", + "typescript": "5.9.3" + } +} diff --git a/src/vocab/basics.ts b/src/vocab/basics.ts new file mode 100644 index 0000000..3f4da8a --- /dev/null +++ b/src/vocab/basics.ts @@ -0,0 +1,75 @@ +import { createHash } from "node:crypto"; +import { Type, type Static } from "typebox"; + +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +/** + * 编号:会被拼进文件路径和命令行,所以只许字母、数字和 `._-`,首字符必须是字母或数字 + * (挡住 `..` 这类路径和 `-x` 这类会被当成命令行选项的写法),最长 128 个字符。 + */ +export const Id = Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }); + +/** 指标方向:越大越好、越小越好,只有这两种。 */ +export const DIRECTIONS = ["maximize", "minimize"] as const; +export const Direction = Type.Enum(DIRECTIONS, { type: "string" }); +export type Direction = Static; + +/** + * 测到 / 测不到。测到就带值,测不到就带原因。 + * 凡是可能测不到的量(资源用量、分数、指标、价格)都用它,不用 0 或 null 冒充测不到: + * 崩溃的运行若把 CPU 秒记成 0,看起来就和一次很省的运行一样,没人会发现。 + */ +export const Measured = Type.Union([ + Type.Object( + { + status: Type.Literal("measured"), + value: Type.Number(), + }, + { additionalProperties: false }, + ), + Type.Object( + { + status: Type.Literal("unmeasured"), + reason: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, + ), +]); +export type Measured = Static; + +/** + * 规范序列化:内容相同就一定得到同一个字符串。 + * - 对象的键按 JavaScript 默认的字符串顺序(UTF-16 码元)排序后再拼; + * - 数字必须是有限数(JSON.stringify 会把 NaN 悄悄写成 null);-0 和 JSON.stringify 一样写成 0; + * - 字符串的转义与 JSON.stringify 相同; + * - 只接受纯 JSON:对象的原型必须是 Object.prototype,undefined、函数、bigint 等一律报错。 + */ +export function canonical(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error(`规范序列化只接受有限数,收到 ${value}`); + return JSON.stringify(value); + } + if (Array.isArray(value)) { + const items: string[] = []; + // for...of 会把稀疏数组的空位读成 undefined,从而报错,不会拼出不合法的 JSON。 + for (const item of value) items.push(canonical(item)); + return `[${items.join(",")}]`; + } + if (typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) { + const what = typeof value === "object" ? "原型不是 Object.prototype 的对象" : typeof value; + throw new Error(`规范序列化只接受纯 JSON 值,收到 ${what}`); + } + const record = value as Record; + const fields = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`); + return `{${fields.join(",")}}`; +} + +/** 哈希:规范序列化结果(UTF-8)的 sha256,64 位小写十六进制。 */ +export function hash(value: unknown): string { + return createHash("sha256").update(canonical(value), "utf8").digest("hex"); +} diff --git a/src/vocab/index.ts b/src/vocab/index.ts new file mode 100644 index 0000000..563948c --- /dev/null +++ b/src/vocab/index.ts @@ -0,0 +1,30 @@ +// vocab:各模块共用的类型、名单、提交字段定义,以及规范序列化与哈希两个函数(docs/结构.md 第二部分)。 +export { canonical, hash, Direction, DIRECTIONS, Id, Measured, type Json } from "./basics.ts"; +export { + ROLES, + STAGES, + SUBMISSION_KINDS, + type Role, + type Stage, + type SubmissionKind, +} from "./roster.ts"; +export { + ANSWER_PATH_KEY, + AnswerPath, + HypothesisMetric, + JudgedResult, + METRIC_LAYERS, + MetricDefinition, + MISSING_POLICIES, + MISSING_POLICY_EFFECTS, + ModelUsage, + ProcessResult, + RunDefinition, + SAFEGUARD_TRIGGERS_KEY, + SafeguardTriggers, + VERDICTS, + type MetricLayer, + type MissingPolicy, + type Verdict, +} from "./runs.ts"; +export { Range, SUBMISSION_SCHEMAS, type Submission } from "./submissions.ts"; diff --git a/src/vocab/roster.ts b/src/vocab/roster.ts new file mode 100644 index 0000000..d44ac94 --- /dev/null +++ b/src/vocab/roster.ts @@ -0,0 +1,50 @@ +/** 三个角色(方法.md 第三部分):研究者、实验员、核验员。 */ +export const ROLES = ["researcher", "experimenter", "verifier"] as const; +export type Role = (typeof ROLES)[number]; + +/** + * 有人交提交的阶段,按方法.md 4.2、4.3 的先后排。 + * 程序自己做的步骤(分配、判重、选批、采纳、选格、结算等)不在这里,步骤清单归 config。 + */ +export const STAGES = [ + "entryDraft", // 0.1 闭门写入口 + "discussion", // 0.2 讨论,可配置为 1 或 2 次,每次都是这个阶段 + "claim", // 0.3 认领 + "proposal", // ② 提案 + "firstPrediction", // ④ 第一次预测 + "critique", // ⑤ 挑刺 + "response", // ⑥ 回应与统一 + "secondPrediction", // ⑦ 第二次预测 + "implementation", // ⑨ 实现,以及核验不通过后的修复 + "verification", // ⑨ 核验 + "explanation", // ⑭ 解释 + "ablationImplementation", // ⑯ 消融实现 + "ablationVerification", // ⑯ 消融核验 + "wrapUp", // ⑱ 收尾 +] as const; +export type Stage = (typeof STAGES)[number]; + +/** + * 每种提交属于哪个阶段、由哪个角色交(方法.md 第五部分)。 + * ledger、materials、submissions、flow、agents 都从这张表取,不在别处另写。 + * 每种提交的字段定义在 submissions.ts 的 SUBMISSION_SCHEMAS,键与这张表相同。 + */ +export const SUBMISSION_KINDS = { + entry: { stage: "entryDraft", role: "researcher" }, + discussion: { stage: "discussion", role: "researcher" }, + claim: { stage: "claim", role: "researcher" }, + proposal: { stage: "proposal", role: "researcher" }, + firstPrediction: { stage: "firstPrediction", role: "researcher" }, + critique: { stage: "critique", role: "researcher" }, + response: { stage: "response", role: "researcher" }, + unification: { stage: "response", role: "researcher" }, + secondPrediction: { stage: "secondPrediction", role: "researcher" }, + implementation: { stage: "implementation", role: "experimenter" }, + repair: { stage: "implementation", role: "experimenter" }, + verification: { stage: "verification", role: "verifier" }, + explanation: { stage: "explanation", role: "researcher" }, + ablationImplementation: { stage: "ablationImplementation", role: "experimenter" }, + ablationVerification: { stage: "ablationVerification", role: "verifier" }, + wrapUp: { stage: "wrapUp", role: "researcher" }, +} as const satisfies Record; +export type SubmissionKind = keyof typeof SUBMISSION_KINDS; diff --git a/src/vocab/runs.ts b/src/vocab/runs.ts new file mode 100644 index 0000000..c9f9b4a --- /dev/null +++ b/src/vocab/runs.ts @@ -0,0 +1,260 @@ +import { Type, type Static } from "typebox"; +import { DIRECTIONS, Id, Measured, type Json } from "./basics.ts"; + +/** 不认识的字段、不合格式的键一律不收。 */ +const CLOSED = { additionalProperties: false } as const; +const REASON = Type.String({ minLength: 1 }); + +// --------------------------------------------------------------------------- +// 指标(方法.md 第五部分"指标") +// --------------------------------------------------------------------------- + +/** 指标层次:最终指标由任务规定;常规辅助指标由程序统一采集;假设专用指标由研究者登记。 */ +export const METRIC_LAYERS = ["final", "auxiliary", "hypothesis"] as const; +export type MetricLayer = (typeof METRIC_LAYERS)[number]; + +/** "测不到时怎么办"只有两档。"只记缺失"排在前面:它是诊断量的常规选择。 */ +export const MISSING_POLICIES = ["recordMissing", "invalidateRun"] as const; +export type MissingPolicy = (typeof MISSING_POLICIES)[number]; + +/** 每一档的后果,字段说明和给模型看的材料都用这一份。 */ +export const MISSING_POLICY_EFFECTS: Readonly> = { + recordMissing: "缺了只记缺失:这次运行的成绩照常算。诊断用的量一般选这一种。", + invalidateRun: + "缺了整次作废:这次运行不能被采纳,运行次数照扣。只有没有这个量成绩就不可信时,才选这一种。", +}; + +/** 一条指标除编号和层次以外的内容。研究者登记假设专用指标时填的就是这些,所以说明写给模型看。 */ +const METRIC_FIELDS = { + meaning: Type.String({ + minLength: 1, + description: "含义:这个量测的是什么", + }), + unit: Type.String({ + minLength: 1, + description: "单位;没有单位(计数、比例)写\"无\"", + }), + direction: Type.Enum(DIRECTIONS, { + type: "string", + description: "maximize:越大越好;minimize:越小越好", + }), + howMeasured: Type.String({ + minLength: 1, + description: + "怎么采样和汇总:在什么时刻测、测几次、怎么合成一个数,写到换一个人照着写代码能测出同一个数", + }), + missing: Type.Enum(MISSING_POLICIES, { + type: "string", + description: + `测不到时怎么办。recordMissing——${MISSING_POLICY_EFFECTS.recordMissing}` + + `invalidateRun——${MISSING_POLICY_EFFECTS.invalidateRun}` + + "消融格里一律只记缺失。引用别人登记过的指标时,沿用原登记的选法", + }), +}; + +/** 研究者在协议里登记的一条假设专用指标。编号由研究者起,候选代码按这个编号写出取值。 */ +export const HypothesisMetric = Type.Object( + { + id: Type.String({ + ...Id, + description: "指标短名,只用字母、数字和 ._-,候选代码按它写出取值", + }), + ...METRIC_FIELDS, + }, + CLOSED, +); +export type HypothesisMetric = Static; + +/** 一条完整的指标定义:任务给的最终指标、常规辅助指标,以及冻结后的假设专用指标。 */ +export const MetricDefinition = Type.Object( + { + id: Id, + layer: Type.Enum(METRIC_LAYERS, { type: "string" }), + ...METRIC_FIELDS, + }, + CLOSED, +); +export type MetricDefinition = Static; + +// --------------------------------------------------------------------------- +// 运行定义:tasks 交给 runner,告诉它怎么起这个进程 +// --------------------------------------------------------------------------- + +export const RunDefinition = Type.Object( + { + /** 命令行,按参数切好,不经过 shell。 */ + command: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), + /** 进程在容器里的工作目录,绝对路径。 */ + workdir: Type.String({ pattern: "^/" }), + /** + * 镜像按 sha256 钉死,不接受 tag:tag 可以被重新指向另一份内容。 + * digest 填本机镜像 ID,即 `docker image inspect -f '{{.Id}}'` 的值; + * 不是仓库清单摘要(RepoDigests),本地构建的镜像没有那个值。 + * 运行时必须写明:本机 Docker 的默认运行时不一定是 runc,跟着默认走,换台机器环境就悄悄变了。 + */ + image: Type.Object( + { + digest: Type.String({ pattern: "^sha256:[0-9a-f]{64}$" }), + runtime: Type.String({ minLength: 1 }), + }, + CLOSED, + ), + /** 单次运行的墙钟时限(秒)和核数,由任务规定,是同批候选共用的比较条件。 */ + wallSeconds: Type.Number({ exclusiveMinimum: 0 }), + cores: Type.Integer({ minimum: 1 }), + /** + * 候选进程不继承宿主的环境变量,类型上只允许 false。参考项目实测过:宿主环境被污染时, + * 同一份代码的分数从 0.9598 变成 0.8849,退出码是 0,没有任何报错。 + */ + inheritEnv: Type.Literal(false), + /** 进程拿到的全部环境变量。 */ + env: Type.Record(Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }), Type.String(), CLOSED), + }, + CLOSED, +); +export type RunDefinition = Static; + +// --------------------------------------------------------------------------- +// 运行结果第一层:runner 交出的进程层面结果 +// --------------------------------------------------------------------------- + +/** 资源用量,每一项都可能测不到。 */ +const RESOURCE_USAGE = { + cpuSeconds: Measured, + wallSeconds: Measured, + peakMemoryBytes: Measured, +}; + +/** + * 以什么方式结束。有没有启动由它决定:只有 notStarted 是没启动, + * 其余三种都启动过、要扣次数(方法.md 第七部分)。 + * 启动前就取消的记 notStarted;启动后取消的记 cancelled。 + * 退出码只有正常退出才有,资源用量只有启动过才有,所以分成四种形状。 + */ +export const ProcessResult = Type.Union([ + Type.Object( + { + ending: Type.Literal("notStarted"), + reason: REASON, + }, + CLOSED, + ), + Type.Object( + { + ending: Type.Literal("exited"), + exitCode: Type.Integer(), + ...RESOURCE_USAGE, + }, + CLOSED, + ), + Type.Object( + { + ending: Type.Literal("timeout"), + ...RESOURCE_USAGE, + }, + CLOSED, + ), + Type.Object( + { + ending: Type.Literal("cancelled"), + ...RESOURCE_USAGE, + }, + CLOSED, + ), +]); +export type ProcessResult = Static; + +// --------------------------------------------------------------------------- +// 运行结果第二层:tasks 从原始产物独立判分 +// --------------------------------------------------------------------------- + +/** 判定三选一:算数、不算数、判不了(判定所需的证据没拿到)。 */ +export const VERDICTS = ["valid", "invalid", "undecidable"] as const; +export type Verdict = (typeof VERDICTS)[number]; + +/** + * 候选在写最终指标的同一个文件里多写的两个保留键(方法.md 第五部分"答案路径")。 + * 实现的字段说明(tasks 一侧写)和给模型的材料(materials 一侧读)都用这两个常量。 + */ +export const ANSWER_PATH_KEY = "answer-path"; +export const SAFEGUARD_TRIGGERS_KEY = "safeguard-triggers"; + +/** + * 答案路径:候选写的最终答案出自哪里(main、某个因子编号或某条保障的短名)。 + * tasks 只记读到的原始内容:读到了就原样存字符串;没写或写的不是字符串,记读不到并附原因。 + * 取值在不在位置表里不在这里判断,由以后拿得到位置表的模块现算。 + */ +export const AnswerPath = Type.Union([ + Type.Object( + { + status: Type.Literal("read"), + value: Type.String(), + }, + CLOSED, + ), + Type.Object( + { + status: Type.Literal("unreadable"), + reason: REASON, + }, + CLOSED, + ), +]); +export type AnswerPath = Static; + +/** + * 各条保障这次动手的次数。tasks 只记读到的原始内容:读到"名字 → 非负整数"的对象就原样存; + * 没写,或者写的不是对象、值不是非负整数,记读不到并附原因。 + * 名字和位置表对不对得上不在这里判断,由以后拿得到位置表的模块现算。 + */ +export const SafeguardTriggers = Type.Union([ + Type.Object( + { + status: Type.Literal("read"), + counts: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), + }, + CLOSED, + ), + Type.Object( + { + status: Type.Literal("unreadable"), + reason: REASON, + }, + CLOSED, + ), +]); +export type SafeguardTriggers = Static; + +export const JudgedResult = Type.Object( + { + verdict: Type.Enum(VERDICTS, { type: "string" }), + verdictReason: REASON, + /** 最终指标的分数(合法化之后的)。 */ + score: Measured, + /** 各指标的取值,键是指标编号。 */ + metrics: Type.Record(Id, Measured, CLOSED), + answerPath: AnswerPath, + safeguardTriggers: SafeguardTriggers, + /** 任务自己的附加记录,任务想记什么就记什么,例如填圆的合法化。 */ + extra: Type.Unsafe({}), + }, + CLOSED, +); +export type JudgedResult = Static; + +// --------------------------------------------------------------------------- +// 模型用量(方法.md 第七部分:拿不到用量时记"未知",不记 0) +// --------------------------------------------------------------------------- + +/** 一次模型调用的用量。四种 token 互不重叠;价格单位为美元,与 Pi 的用量报告一致。 */ +export const ModelUsage = Type.Object( + { + inputTokens: Measured, + cacheReadTokens: Measured, + cacheWriteTokens: Measured, + outputTokens: Measured, + cost: Measured, + }, + CLOSED, +); +export type ModelUsage = Static; diff --git a/src/vocab/submissions.ts b/src/vocab/submissions.ts new file mode 100644 index 0000000..955d1b7 --- /dev/null +++ b/src/vocab/submissions.ts @@ -0,0 +1,615 @@ +import { Type, type Static, type TSchema } from "typebox"; +import { Id } from "./basics.ts"; +import type { SubmissionKind } from "./roster.ts"; +import { HypothesisMetric } from "./runs.ts"; + +/** + * 每种提交的字段定义(方法.md 第五部分)。同一份定义同时是类型、收件时的格式检查、 + * 发给模型的提交工具参数,所以字段说明写给模型看,每个字段都写明出口。 + * 这里只管单个字段长什么样;跨字段的规则(q₁ ≤ q₅ ≤ q₉、因子数上限、覆盖是否齐全等) + * 在 submissions 模块。 + * 程序分配的编号(候选、挑刺、入口编号)不让模型填;因子、实现条款的编号就是它在清单里的位置。 + */ + +/** 文字字段:不能为空,也不能只有空白。 */ +const TEXT = { pattern: "\\S" }; +/** 不认识的字段一律不收。 */ +const CLOSED = { additionalProperties: false } as const; + +// --------------------------------------------------------------------------- +// 共用字段 +// --------------------------------------------------------------------------- + +/** 三个数:一个分数的事前判断(方法.md 第二部分"范围")。 */ +export const Range = Type.Object( + { + q1: Type.Number({ description: "有一成可能低于这个数" }), + q5: Type.Number({ description: "有一半可能低于这个数" }), + q9: Type.Number({ description: "有九成可能低于这个数" }), + }, + { + ...CLOSED, + description: + "三个数,按分数原值写,不管越大越好还是越小越好。要满足 q1 ≤ q5 ≤ q9,可以相等。" + + "没有\"不知道\"这个选项:拿不准就把 q1 和 q9 拉开", + }, +); +export type Range = Static; + +const FactorKey = Type.String({ + pattern: "^p[1-9][0-9]*$", + description: "因子编号:它在冻结协议因子清单里的位置,第 1 个是 p1", +}); + +const SafeguardName = Type.String({ + pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", + description: + "保障短名:字母、数字和 ._-。不能叫 main,也不能写成 p1、p2 这种因子编号的样子," + + "因为答案路径里 main、因子编号和保障短名放在一起;位置表也按它对号", +}); + +const ClauseKey = Type.String({ + pattern: "^c[1-9][0-9]*$", + description: "实现条款编号:它在冻结协议实现条款里的位置,第 1 条是 c1", +}); + +const CodeLocation = Type.String({ + pattern: "^[^:\\s]+(:[1-9][0-9]*(-[1-9][0-9]*)?)?$", + description: "代码位置:\"文件\"、\"文件:行号\"或\"文件:起-止\",行号是候选代码里的行号", +}); + +const CONCERNS = Type.String({ + ...TEXT, + description: + "照冻结原文实现、但你认为会出问题的地方,尤其是某条保障会挡掉或改变什么。" + + "它会转给研究者,不阻止运行,也不改动协议。没有就写\"无\"", +}); + +// --------------------------------------------------------------------------- +// 第零轮:入口、讨论、志愿(方法.md 4.2) +// --------------------------------------------------------------------------- + +const Entry = Type.Object( + { + name: Type.String({ + ...TEXT, + description: "名称:离开这份清单单独看也读得懂", + }), + body: Type.String({ + ...TEXT, + description: + "Markdown:从哪里开始看(先读哪段代码、先问哪个问题、先质疑哪个设定)、为什么值得有人去走。" + + "只写起点,不写\"应该改成什么、能到多少分\"", + }), + }, + CLOSED, +); + +const ENTRIES = Type.Array(Entry, { + minItems: 2, + maxItems: 3, + description: "2–3 个入口,彼此尽量不重叠;想不出第三个就交两个。编号由程序按顺序分配", +}); + +export const EntrySubmission = Type.Object( + { + entries: ENTRIES, + }, + CLOSED, +); + +export const DiscussionSubmission = Type.Object( + { + body: Type.String({ + ...TEXT, + description: + "讨论正文,Markdown。目标是分工,不是达成共识:哪些入口其实是同一条路," + + "哪条路没人站但值得走,你自己想走哪条", + }), + entries: Type.Optional({ + ...ENTRIES, + description: "可选:整份替换你自己的入口清单(2–3 个)。不换就不交这个字段", + }), + }, + CLOSED, +); + +export const ClaimSubmission = Type.Object( + { + choices: Type.Array(Id, { + minItems: 1, + description: + "志愿:按你想走的先后排入口编号,照材料里列的写,可以排别人的入口;至少排材料里写明的个数", + }), + }, + CLOSED, +); + +// --------------------------------------------------------------------------- +// 提案(方法.md 4.3 ②、第五部分"提案") +// --------------------------------------------------------------------------- + +const Factor = Type.Object( + { + name: Type.String({ + ...TEXT, + description: "名称:离开这份提案也读得懂", + }), + method: Type.String({ + ...TEXT, + description: + "做法:改什么、怎么改。配套设定(离开这个因子就没有意义的设定值)写在这里,跟着它一起开、一起关", + }), + keySettings: Type.String({ + ...TEXT, + description: "关键设定:决定这个因子效果的设定值,例如\"幅度 0.004、窗口 15 秒\";没有写\"无\"", + }), + offBehavior: Type.Optional( + Type.String({ + ...TEXT, + description: "关掉这个因子时换成什么。省略就回到基线在这件事上的做法", + }), + ), + continues: Type.Union([Id, Type.Null()], { + description: + "延续信念本里的哪个因子:写它的因子编号。只用于同一个想法的新版本(换了设定、换了基线);" + + "受它启发想出来的新想法不算延续。新因子写 null", + }), + }, + CLOSED, +); + +const FACTORS = Type.Array(Factor, { + minItems: 1, + description: + "因子清单:每个因子是单独放到基线上也说得通的改动,编号就是位置(第 1 个是 p1)。" + + "只有一个因子就写一个;拿不准一处改动算因子还是保障,就算因子。" + + "超过因子数上限时把多出来的留到下一轮,不要几个捆成一个", +}); + +const SAFEGUARDS = Type.Array(SafeguardName, { + description: + "保障清单,每条一个短名。保障在任何一格里都不改变评测打出来的分数,只在评测本来不收的情形下动手," + + "动手时只修到刚好合法、退回基线做法或直接报错;做不到的、拿不准的都是因子。没有保障交空数组", +}); + +const METRICS = Type.Object( + { + defined: Type.Array(HypothesisMetric, { + description: "新登记的假设专用指标;没有就交空数组", + }), + reused: Type.Array(Id, { + description: "引用别人登记过的假设专用指标,照材料里已登记指标清单的编号写;没有就交空数组", + }), + }, + { + ...CLOSED, + description: + "指标登记:这个候选要采集哪些假设专用指标。最终指标和常规辅助指标程序每次都会采集,不用在这里写", + }, +); + +const PROPOSAL_BODY = Type.String({ + ...TEXT, + description: + "提案正文,Markdown,带小标题:每个因子为什么可能有效;" + + "每条保障做什么、属于拦 / 补 / 改哪一种、自检时怎么想的;" + + "协议(不许改什么、随机性怎么处理、何时停、什么样的运行不算数);因子之间的关系。" + + "某方面不适用写\"不适用:原因\"。不写自己估的分数或提升量;引用已测到的分数要写明出自哪次运行;" + + "文献和别人报告的数值只当起点,说明与本任务的区别;从第 2 轮起说明哪些新的观测影响了这条提案", +}); + +export const ProposalSubmission = Type.Object( + { + factors: FACTORS, + safeguards: SAFEGUARDS, + metrics: METRICS, + authorEstimate: Range, + body: PROPOSAL_BODY, + }, + { + ...CLOSED, + description: + "一条提案。authorEstimate 是你自己估的这个改法跑出来的最终指标,交上来就封存," + + "到本轮结束前不给任何人看。交不出合格提案时不要凑,在回复里写清卡在哪里", + }, +); + +// --------------------------------------------------------------------------- +// 两次预测、挑刺(方法.md 4.3 ④⑤⑦) +// --------------------------------------------------------------------------- + +/** 第一次、第二次预测用同一个定义:对一个候选的最终指标给三个数。 */ +export const PredictionSubmission = Type.Object( + { + range: Range, + body: Type.String({ + ...TEXT, + description: "理由,Markdown", + }), + }, + { + ...CLOSED, + description: "对这个候选跑出来的最终指标给三个数", + }, +); + +export const CritiqueSubmission = Type.Object( + { + target: Type.String({ + pattern: "^(whole|p[1-9][0-9]*)$", + description: "针对谁:整条提案写 whole,针对某个因子写它的编号(p1、p2……)", + }), + body: Type.String({ + ...TEXT, + description: + "挑刺内容,Markdown:混杂因素、写得不清楚没法照着做、值不值得花一次运行、因子划得对不对;" + + "对每条保障按拦、补、改三问追问。没有对照代码或已测运行核实过的判断," + + "写成\"(推测)\"并写出依据", + }), + }, + CLOSED, +); + +// --------------------------------------------------------------------------- +// 回应与统一(方法.md 4.3 ⑥) +// --------------------------------------------------------------------------- + +const REPLIES = Type.Array( + Type.Object( + { + critique: Type.String({ + ...Id, + description: "挑刺编号,照材料里列的写", + }), + reply: Type.String({ + ...TEXT, + description: "怎么答复:改了哪里,或者为什么不改", + }), + }, + CLOSED, + ), + { + description: "逐条答复针对这个候选的每一条挑刺;没有挑刺交空数组", + }, +); + +const CLAUSES = Type.Array( + Type.String({ + ...TEXT, + description: "一条实现条款:要实现什么、落在哪个文件的哪里", + }), + { + minItems: 1, + description: "实现条款,编号就是位置(第 1 条是 c1)。实验员照它实现,核验员逐条对号", + }, +); + +export const ResponseSubmission = Type.Object( + { + replies: REPLIES, + changes: Type.Object( + { + factors: Type.Optional(FACTORS), + safeguards: Type.Optional(SAFEGUARDS), + metrics: Type.Optional(METRICS), + body: Type.Optional(PROPOSAL_BODY), + }, + { + ...CLOSED, + description: + "改动了的部分:写上的一项整项替换原提案的对应部分,没写的沿用原提案。" + + "什么都没改就交空对象。作者自评不能改", + }, + ), + clauses: CLAUSES, + body: Type.String({ + ...TEXT, + description: "答复正文,Markdown:逐条答复之外要补充的话。没有挑刺、也没有要补充的,写\"无\"", + }), + }, + CLOSED, +); + +export const UnificationSubmission = Type.Object( + { + factors: FACTORS, + safeguards: SAFEGUARDS, + metrics: METRICS, + body: PROPOSAL_BODY, + replies: REPLIES, + clauses: CLAUSES, + sources: Type.Array( + Type.Object( + { + proposal: Type.String({ + ...Id, + description: "被合并的原提案编号,照材料里列的写", + }), + coverage: Type.String({ + ...TEXT, + description: "这份原提案在统一后的协议里哪里体现;没采纳的部分写明为什么", + }), + }, + CLOSED, + ), + { + minItems: 1, + description: "逐条写明每份原提案在哪里体现", + }, + ), + }, + { + ...CLOSED, + description: + "几条提案合成的候选,由你写成一份完整的冻结协议。只能用原提案和挑刺里有的改动,不能另加新改动", + }, +); + +// --------------------------------------------------------------------------- +// 实现、修复、核验(方法.md 4.3 ⑨) +// --------------------------------------------------------------------------- + +const LOCATIONS = Type.Array(CodeLocation, { minItems: 1 }); + +/** 实现和修复用同一个定义。代码本身由程序从实验员的工作目录冻结,不作为字段交。 */ +export const ImplementationSubmission = Type.Object( + { + factorLocations: Type.Array( + Type.Object( + { + factor: FactorKey, + locations: LOCATIONS, + }, + CLOSED, + ), + { + description: + "因子位置表:冻结协议里每个因子在哪几行,每个因子一项。一处代码服务两个因子,两边都写", + }, + ), + safeguardLocations: Type.Array( + Type.Object( + { + safeguard: SafeguardName, + locations: LOCATIONS, + }, + CLOSED, + ), + { + description: + "保障位置表:每条保障在哪几行。同一行既属于某个因子又属于某条保障时,两边都写。" + + "冻结协议没有保障就交空数组", + }, + ), + unassigned: Type.Array( + Type.Object( + { + location: CodeLocation, + note: Type.String({ + ...TEXT, + description: "为什么不属于任何因子或保障,一句说明", + }), + }, + CLOSED, + ), + { + description: "不属于任何因子或保障的改动;没有就交空数组", + }, + ), + concerns: CONCERNS, + body: Type.String({ + ...TEXT, + description: "实现说明,Markdown:位置表之外需要说明的事。没有要补充的就写\"无\"", + }), + }, + CLOSED, +); + +const FOUND = Type.Boolean({ + description: "找到对应实现写 true,找不到写 false(记为没落实)", +}); + +const WHERE = Type.String({ + ...TEXT, + description: "在哪里;没找到就写看了哪里", +}); + +export const VerificationSubmission = Type.Object( + { + clauses: Type.Array( + Type.Object( + { + clause: ClauseKey, + found: FOUND, + where: WHERE, + }, + CLOSED, + ), + { + description: "每条实现条款一项", + }, + ), + factors: Type.Array( + Type.Object( + { + factor: FactorKey, + found: FOUND, + where: WHERE, + }, + CLOSED, + ), + { + description: "每个因子一项", + }, + ), + safeguards: Type.Array( + Type.Object( + { + safeguard: SafeguardName, + found: FOUND, + where: WHERE, + }, + CLOSED, + ), + { + description: "每条保障一项;冻结协议没有保障就交空数组", + }, + ), + unassigned: Type.Array( + Type.Object( + { + location: Type.String({ + ...CodeLocation, + description: "材料里列出的那处改动,照材料的写法抄", + }), + outOfScope: Type.Boolean({ + description: "超出冻结协议、碰了测量或做了协议没写的事写 true", + }), + reason: Type.String({ + ...TEXT, + description: "判断依据", + }), + }, + CLOSED, + ), + { + description: "材料里列出的没有归属的改动,逐条判断是否越界;材料说没有就交空数组", + }, + ), + body: Type.String({ + ...TEXT, + description: "说明,Markdown:逐项结果之外需要说明的事。没有要补充的就写\"无\"", + }), + }, + { + ...CLOSED, + description: "只交逐项结果,通过与否由程序推出:任何一项没落实或判为越界,就退回实验员修复", + }, +); + +// --------------------------------------------------------------------------- +// 解释、消融、收尾(方法.md 4.3 ⑭⑯⑱) +// --------------------------------------------------------------------------- + +const Explanation = Type.Object( + { + body: Type.String({ + ...TEXT, + description: + "Markdown:机制是什么、哪个因子在出力、什么情况下不成立、因子之间在效果上怎么互相依赖" + + "(例如\"没有 p2,p3 就没用\",当作预测交给消融检验)、结论覆盖哪些设定、没覆盖哪些。" + + "没有具体的机制判断就直说", + }), + cells: Type.Optional( + Type.Array( + Type.Object( + { + factor: FactorKey, + range: Range, + }, + CLOSED, + ), + { + description: + "每一格三个数:假如这条解释是对的,关掉这个因子后最终指标的范围。" + + "材料说要给每格的数时(开着消融),材料里列出的每一格都要有;" + + "没有具体的机制判断时,照实把范围写宽。材料说不给每格的数时(关掉了消融),不交这个字段", + }, + ), + ), + }, + CLOSED, +); + +export const ExplanationSubmission = Type.Object( + { + explanations: Type.Array(Explanation, { + minItems: 1, + description: "一般交一条就够,真有第二个机制判断时再交第二条,条数上限见材料", + }), + }, + CLOSED, +); + +export const AblationImplementationSubmission = Type.Object( + { + alsoOff: Type.Array(FactorKey, { + description: + "需要一起关的因子:只有关掉指定因子后代码真的跑不起来、必须连带关掉别的因子时才写。" + + "只关指定因子就交空数组", + }), + cannotRun: Type.Union([Type.Null(), Type.String(TEXT)], { + description: + "这一格跑不起来、也给不出一起关的方案时写原因,这一格不启动、不扣次数。跑得起来写 null", + }), + concerns: CONCERNS, + }, + { + ...CLOSED, + description: "从完整代码出发,把指定的因子换回关掉时的做法,其余因子和全部保障照留", + }, +); + +export const AblationVerificationSubmission = Type.Object( + { + factors: Type.Array( + Type.Object( + { + factor: FactorKey, + off: Type.Boolean({ + description: "这份代码里这个因子是关着的写 true,照完整版本保留写 false", + }), + where: Type.String({ + ...TEXT, + description: "依据:在哪几行看到的", + }), + }, + CLOSED, + ), + { + description: "冻结协议里每个因子一项", + }, + ), + }, + { + ...CLOSED, + description: "只查一件事:是不是只关了指定的因子", + }, +); + +export const WrapUpSubmission = Type.Object( + { + body: Type.String({ + ...TEXT, + description: + "收尾,Markdown:什么被支持了、什么被挑战了、下一步想在哪个因子上继续。" + + "推广结论时写清它覆盖哪些设定。某方面没有写\"没有:为什么\"。" + + "原样放进下一轮的提案材料,所有人都读得到", + }), + }, + CLOSED, +); + +/** 每种提交用哪个字段定义,键与 roster.ts 的 SUBMISSION_KINDS 相同。 */ +export const SUBMISSION_SCHEMAS = { + entry: EntrySubmission, + discussion: DiscussionSubmission, + claim: ClaimSubmission, + proposal: ProposalSubmission, + firstPrediction: PredictionSubmission, + critique: CritiqueSubmission, + response: ResponseSubmission, + unification: UnificationSubmission, + secondPrediction: PredictionSubmission, + implementation: ImplementationSubmission, + repair: ImplementationSubmission, + verification: VerificationSubmission, + explanation: ExplanationSubmission, + ablationImplementation: AblationImplementationSubmission, + ablationVerification: AblationVerificationSubmission, + wrapUp: WrapUpSubmission, +} as const satisfies Record; + +/** 某种提交的内容类型,例如 Submission<"proposal">。 */ +export type Submission = Static<(typeof SUBMISSION_SCHEMAS)[K]>; diff --git a/tests/vocab.test.ts b/tests/vocab.test.ts new file mode 100644 index 0000000..0434677 --- /dev/null +++ b/tests/vocab.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Value } from "typebox/value"; +import { + canonical, + hash, + JudgedResult, + RunDefinition, + SUBMISSION_KINDS, + SUBMISSION_SCHEMAS, + type Submission, + type SubmissionKind, +} from "../src/vocab/index.ts"; + +// 预期字符串手写;预期哈希用命令行 `printf '%s' '<预期字符串>' | sha256sum` 算出。 + +test("规范序列化:键的顺序不同,结果相同", () => { + const expected = '{"a":[true,null,"x"],"b":1,"c":{"y":"中","z":2.5}}'; + const one = { b: 1, a: [true, null, "x"], c: { z: 2.5, y: "中" } }; + const two = { c: { y: "中", z: 2.5 }, a: [true, null, "x"], b: 1 }; + assert.equal(canonical(one), expected); + assert.equal(canonical(two), expected); + assert.equal(hash(one), "668eb8c3a097ac33abf5f690ab18c5f753b911ff1c9bbfd6163deb5939b68e5d"); + assert.equal(hash(two), "668eb8c3a097ac33abf5f690ab18c5f753b911ff1c9bbfd6163deb5939b68e5d"); +}); + +test("规范序列化:嵌套数组保持顺序,数组里的对象也排键", () => { + const value = [{ b: 0, a: [{ d: 1, c: -1 }] }, [1, [2, []]], {}, "s"]; + const expected = '[{"a":[{"c":-1,"d":1}],"b":0},[1,[2,[]]],{},"s"]'; + assert.equal(canonical(value), expected); + assert.equal(hash(value), "521ca7249ce6c90890b5645dd2f8d46b42f6e000df5ad86aff42415035c7e8cb"); + assert.equal(hash({}), "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"); +}); + +test("规范序列化:字符串转义(引号、换行、制表、控制字符、反斜杠、中文)与 -0", () => { + const value = { "中": [-0], z: -0, s: 'a"b\n\t\u0001中\\' }; + const expected = String.raw`{"s":"a\"b\n\t\u0001中\\","z":0,"中":[0]}`; + assert.equal(canonical(value), expected); + assert.equal(hash(value), "d4605ee7ceddf1373f60f837ae86ce24de7319705bbcbce4706309fc4415c79a"); +}); + +test("规范序列化:NaN 和 Infinity 报错,嵌套在里面也报错", () => { + for (const bad of [NaN, Infinity, -Infinity, { a: [1, NaN] }, [{ x: Infinity }]]) { + assert.throws(() => canonical(bad), /有限数/); + assert.throws(() => hash(bad), /有限数/); + } +}); + +test("规范序列化:带自定义原型的对象和非 JSON 值报错", () => { + class Point { + x = 1; + } + const bad: unknown[] = [ + new Point(), + Object.create(null), + new Date(0), + new Map(), + { a: new Point() }, + { a: undefined }, + () => 1, + 1n, + ]; + for (const value of bad) assert.throws(() => canonical(value), /纯 JSON/); +}); + +// 每种提交一份合法样例,同时用来核对字段写的是不是方法.md 第五部分的意思。 +const range = { q1: 2.6, q5: 2.62, q9: 2.63 }; +const factors = [ + { name: "多起点局部优化", method: "从 8 个随机起点各跑一次 SLSQP,取最好的", keySettings: "起点数 8", continues: null }, + { + name: "停滞期扰动", + method: "连续 20 步没有改进时扰动半径", + keySettings: "幅度 0.004", + offBehavior: "不扰动", + continues: "r1-c2.p1", + }, +]; +const metrics = { + defined: [ + { + id: "first-feasible-seconds", + meaning: "第一次得到合法摆法的时间", + unit: "秒", + direction: "minimize" as const, + howMeasured: "从进程启动计时,到第一个通过校验的摆法写出为止,每次运行记一次", + missing: "recordMissing" as const, + }, + ], + reused: ["wall-seconds"], +}; +const body = "## 为什么可能有效\n……"; + +const samples: { [K in SubmissionKind]: Submission } = { + entry: { entries: [{ name: "起点分布", body: body }, { name: "收尾修复", body: body }] }, + discussion: { + body, + entries: [ + { name: "a", body }, + { name: "b", body }, + { name: "c", body }, + ], + }, + claim: { choices: ["researcher-2-e1", "researcher-1-e2"] }, + proposal: { factors, safeguards: ["final-shrink"], metrics, authorEstimate: range, body }, + firstPrediction: { range, body: "理由" }, + critique: { target: "p2", body: "(推测)扰动可能和起点数混杂" }, + response: { + replies: [{ critique: "cr-1", reply: "改了 p2 的幅度" }], + changes: { factors: [factors[0]!] }, + clauses: ["在 solve.py 的 main 里加多起点循环"], + body: "答复", + }, + unification: { + factors, + safeguards: [], + metrics: { defined: [], reused: [] }, + body, + replies: [], + clauses: ["c1 的内容", "c2 的内容"], + sources: [{ proposal: "r2-p3", coverage: "体现在 p1" }], + }, + secondPrediction: { range: { q1: 2.6, q5: 2.6, q9: 2.6 }, body: "理由" }, + implementation: { + factorLocations: [{ factor: "p1", locations: ["solve.py:10-40"] }], + safeguardLocations: [{ safeguard: "final-shrink", locations: ["solve.py:88", "util.py"] }], + unassigned: [{ location: "solve.py:5", note: "补了一个 import" }], + concerns: "无", + body: "说明", + }, + repair: { + factorLocations: [], + safeguardLocations: [], + unassigned: [], + concerns: "无", + body: "按核验意见补上 c2", + }, + verification: { + clauses: [{ clause: "c1", found: true, where: "solve.py:10-40" }], + factors: [{ factor: "p1", found: false, where: "看了 solve.py 全文" }], + safeguards: [{ safeguard: "final-shrink", found: true, where: "solve.py:88" }], + unassigned: [{ location: "solve.py:5", outOfScope: false, reason: "只是 import" }], + body: "说明", + }, + explanation: { + explanations: [ + { body: "p1 在出力", cells: [{ factor: "p1", range }, { factor: "p2", range }] }, + { body: "我没有具体的机制判断", cells: [{ factor: "p1", range: { q1: 2.3, q5: 2.55, q9: 2.64 } }] }, + ], + }, + ablationImplementation: { alsoOff: [], cannotRun: null, concerns: "无" }, + ablationVerification: { factors: [{ factor: "p1", off: true, where: "solve.py:10-40" }] }, + wrapUp: { body: "被支持的:……" }, +}; + +test("每种提交都有阶段、角色和字段定义", () => { + assert.deepEqual(Object.keys(SUBMISSION_SCHEMAS).sort(), Object.keys(SUBMISSION_KINDS).sort()); +}); + +for (const kind of Object.keys(SUBMISSION_KINDS) as SubmissionKind[]) { + test(`合法样例通过字段定义检查:${kind}`, () => { + const schema = SUBMISSION_SCHEMAS[kind]; + const errors = [...Value.Errors(schema, samples[kind])].map( + (e) => `${e.instancePath} ${e.message}`, + ); + assert.deepEqual(errors, []); + }); +} + +test("关掉消融时,不带 cells 的解释通过字段定义检查", () => { + const submission = { explanations: [{ body: "机制:p1 在出力" }] }; + assert.equal(Value.Check(SUBMISSION_SCHEMAS.explanation, submission), true); +}); + +// 复现:TypeBox 的 Record 对带格式的键只生成 patternProperties,不合格式的键原先完全不检查。 +test("环境变量和指标取值里,不合格式的键被拒收", () => { + const run = { + command: ["python3", "solve.py"], + workdir: "/work", + image: { digest: `sha256:${"a".repeat(64)}`, runtime: "runc" }, + wallSeconds: 120, + cores: 1, + inheritEnv: false, + env: { OMP_NUM_THREADS: "1" }, + }; + assert.equal(Value.Check(RunDefinition, run), true); + assert.equal(Value.Check(RunDefinition, { ...run, env: { "1BAD-KEY": "x" } }), false); + assert.equal(Value.Check(RunDefinition, { ...run, env: { "x-y": 5 } }), false); + const judged = { + verdict: "valid", + verdictReason: "合法", + score: { status: "measured", value: 2.6 }, + metrics: { "wall-seconds": { status: "measured", value: 3 } }, + answerPath: { status: "read", value: "main" }, + safeguardTriggers: { status: "unreadable", reason: "没写" }, + extra: {}, + }; + assert.equal(Value.Check(JudgedResult, judged), true); + const badMetrics = { "../x": { status: "measured", value: 1 } }; + assert.equal(Value.Check(JudgedResult, { ...judged, metrics: badMetrics }), false); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a751c57 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +}