From 75d7cffd278c0197dc7aabb58cc29b49e8556950 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 3 Oct 2026 10:58:52 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20flow=20=E7=AC=AC=201=20=E9=83=A8?= =?UTF-8?q?=E5=88=86=EF=BC=88=E9=AA=A8=E6=9E=B6=E3=80=81=E7=A0=94=E7=A9=B6?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E3=80=81=E7=AC=AC=E9=9B=B6=E8=BD=AE=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E7=AC=AC=E9=9B=B6=E8=BD=AE=E6=A3=80=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按任务书 12 实现 src/flow:从账本现算下一步的纯函数、主循环、恢复入口、派模型任务和跑正式运行两个共用动作、冻结配置、基线、第零轮三个研究者阶段和入口分配,走到第 1 轮时停下。materials 导出 finalEntries、加 researcherId 和 StudyRecord。 审阅采纳两条:研究编号本机已用过时冻结前报错;docker run 没启动时写完结果就停下,不在本进程里无限起新编号。 eval/ 新增第零轮检验:起研究命令行、回放假模型、检验用 Jev 客户端、六问质量评估、16-real-1 回放、杀进程与两份同时跑。tsc 与 depcruise 也检查 eval/,新增 eval 只经 index.ts 用正式代码的规则。 Co-Authored-By: Claude Opus 5.5 --- .dependency-cruiser.cjs | 11 +- WORK_LOG.md | 10 +- docs/结构.md | 40 ++++- eval/lib/cli.ts | 27 ++++ eval/lib/jev.ts | 71 +++++++++ eval/lib/ledger.ts | 89 +++++++++++ eval/lib/replay.ts | 100 ++++++++++++ eval/lib/study.ts | 82 ++++++++++ eval/round-zero/concurrent.ts | 54 +++++++ eval/round-zero/extract-16.ts | 71 +++++++++ eval/round-zero/ideas.json | 49 ++++++ eval/round-zero/kill.ts | 121 ++++++++++++++ eval/round-zero/quality.ts | 148 ++++++++++++++++++ eval/round-zero/questions.ts | 176 +++++++++++++++++++++ eval/round-zero/replay-16-real-1.json | 217 ++++++++++++++++++++++++++ eval/round-zero/replay.ts | 51 ++++++ eval/round-zero/research.json | 33 ++++ eval/round-zero/run.ts | 16 ++ package.json | 11 +- src/flow/formal-run.ts | 90 +++++++++++ src/flow/frozen.ts | 41 +++++ src/flow/index.ts | 4 + src/flow/loop.ts | 99 ++++++++++++ src/flow/model-task.ts | 121 ++++++++++++++ src/flow/next-step.ts | 160 +++++++++++++++++++ src/flow/paths.ts | 35 +++++ src/flow/recover.ts | 48 ++++++ src/flow/steps/baseline.ts | 21 +++ src/flow/steps/round-zero.ts | 44 ++++++ src/flow/study.ts | 112 +++++++++++++ src/materials/blocks.ts | 6 + src/materials/index.ts | 4 +- src/materials/shapes.ts | 23 ++- tests/flow-docker.test.ts | 78 +++++++++ tests/flow-next-step.test.ts | 205 ++++++++++++++++++++++++ tsconfig.json | 2 +- 36 files changed, 2456 insertions(+), 14 deletions(-) create mode 100644 eval/lib/cli.ts create mode 100644 eval/lib/jev.ts create mode 100644 eval/lib/ledger.ts create mode 100644 eval/lib/replay.ts create mode 100644 eval/lib/study.ts create mode 100644 eval/round-zero/concurrent.ts create mode 100644 eval/round-zero/extract-16.ts create mode 100644 eval/round-zero/ideas.json create mode 100644 eval/round-zero/kill.ts create mode 100644 eval/round-zero/quality.ts create mode 100644 eval/round-zero/questions.ts create mode 100644 eval/round-zero/replay-16-real-1.json create mode 100644 eval/round-zero/replay.ts create mode 100644 eval/round-zero/research.json create mode 100644 eval/round-zero/run.ts create mode 100644 src/flow/formal-run.ts create mode 100644 src/flow/frozen.ts create mode 100644 src/flow/index.ts create mode 100644 src/flow/loop.ts create mode 100644 src/flow/model-task.ts create mode 100644 src/flow/next-step.ts create mode 100644 src/flow/paths.ts create mode 100644 src/flow/recover.ts create mode 100644 src/flow/steps/baseline.ts create mode 100644 src/flow/steps/round-zero.ts create mode 100644 src/flow/study.ts create mode 100644 tests/flow-docker.test.ts create mode 100644 tests/flow-next-step.test.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 66dbc92..3d03ac7 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -1,5 +1,6 @@ // 模块之间的依赖规则,来源是 docs/结构.md 第二部分(模块表)和第六部分(检查规则)。 -// 模块按 src/<模块名>/ 认;新建模块目录后自动受这些规则检查。规则只管 src 下的模块,tests/ 不受"只有某模块能用某外部包"的限制。 +// 模块按 src/<模块名>/ 认;新建模块目录后自动受这些规则检查。规则主要管 src 下的模块,tests/、eval/ 不受"只有某模块能用某外部包"的限制; +// eval/ 另有一条:只能经 src/<模块>/index.ts 用正式代码(任务书 12 第三部分第 4 条)。 // 模块表"依赖"一列:每个模块除了自己和 vocab 以外,还能引用哪些模块。plugin 装配时可用各模块。 const ALLOWED = { @@ -76,6 +77,14 @@ module.exports = { from: { path: "^src/" }, to: { pathNot: ["^src/", "^node_modules/"], dependencyTypesNot: ["core"] }, }, + { + name: "eval-through-index", + comment: + "eval/ 像用户一样用正式代码,只能经 src/<模块>/index.ts 引用(CLAUDE.md\"测试\":正式代码和检验代码完全分开)", + severity: "error", + from: { path: "^eval/" }, + to: { path: "^src/", pathNot: "^src/[^/]+/index\\.ts$" }, + }, { name: "pure-allowlist", comment: diff --git a/WORK_LOG.md b/WORK_LOG.md index 5eb19c3..4f031de 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -22,6 +22,7 @@ - 任务书 09(materials):src/materials 12 个文件、1831 行(预期 1200–1800;超出部分主要是用户决定 2 新加的 shapes.ts 116 行,复审判定没有专门路径增多),vocab 加 `Checklist`(1105 行,上限 1150)。19 份提示词以参考项目为底稿只做必要改动搬进 `prompts/`;TASK.md 补了 `unversionedNames` 不存进版本。`npm test` 356 条通过(materials 39 条)。全新上下文审阅三轮(可见性一轮、规模与正确性一轮、修复复审一轮),materials 内部没有发现泄露;要靠 flow 写入时机保证的几条记进"未完成"的"给 flow"。 - 任务书 10(submissions):先改 materials 第一部分 7 处,另加两处小改动(jev 判重材料带关键设定、核验 `body` 字段说明补同行提醒)。src/submissions 6 个文件、391 行(预期 400–600);materials 1850 行;vocab 1107 行;ledger 退回记录加对象字段,表结构版本升到 4。`npm test` 400 条通过;`npm run test:jev` 新增三个真实用例:写得好的回应收下(三问 0.06 / 0.04 / 0.04),改评测的回应退回(changes_measurement 0.91,阈值 0.21),实现 3 块 15 条判断全否。全新上下文审阅一轮:采纳消融实现退回文字前后矛盾、两处测试加强;另发现同轮指标重名,用户定的改法见"未完成"。结构.md 第七、八部分已更新。 - 任务书 11(收尾,提交 13a7e60):假设专用指标冻结时加候选前缀,全名"候选编号.短名",只在 materials 的 `freezeProtocol` 加;短名恢复不能含 `.`(最长 64),已占用清单只剩任务的指标和两个保留键。消融格核验不通过重做时,材料写"工作目录是上一次交的这一格代码的副本",能读的版本加上它,完整代码照旧列出;消融实现提示词同步改。jev 判重的材料说明补 `keySettings` 一句。materials 1868 行(上限 1850,多出主要是 `freezeProtocol`),submissions 391 行,vocab 1118 行。`npm test` 405 条通过;`npm run test:jev -- jev-latest` 判重一问照常拿回概率。结构.md 第八部分 vocab、materials、jev 已更新。 +- 任务书 12(flow 第 1 部分:骨架、研究开始、第零轮):src/flow 11 个文件、775 行(预期 900–1200;实验员、核验员会话接法和多份提交的编号要用到第 2、3 部分才写),materials 1897 行(+29:`finalEntries`、`researcherId`、`StudyRecord`),eval/ 1006 行。`npm test` 418 条通过(新增 flow 下一步 11 条、真 Docker 2 条:研究编号重复报错、docker run 没启动后停下不在本进程重跑)。全新上下文审阅 2.5、2.7 一轮,采纳两条(同上两条测试对应的修复)。真实检验:16-real-1 回放分配与当年完全相同、0 退回;三处杀进程、两份同时跑全部通过。**研究质量未通过**:3 次真实运行(`/home/iomgaa/inforsi-eval/2026-10-03-r0-real-1`、`-2`、`-3`,2、3 被内存回收杀过一次、按正式恢复路径续跑完)第 1、3 问都不通过,入口全体没有 K1(联合优化);材料核对符合方法.md 4.2,交用户判断。 ## 关键决定(用户原话见各提交说明) @@ -69,15 +70,12 @@ - `inbox/调研.md`:要迁进 `docs/调研/` 的调研结论。 - `docs/方法.md` 里还有 7 条标〔自定〕的规则,待用户过目。 - 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。 -- 给 flow:确认启动前要先登记运行;模型调用要先开始再结束;阶段要先开再关;ledger 都不查。 - 给 flow:步骤清单没有单列"轮末动作"(解封作者自评、入选未跑的候选出池),flow 要做这些。 - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 -- 给 flow:容器名要在本机所有研究之间唯一(例如带研究编号);`docker run` 因重名失败时 runner 按"启动了"处理。日志目录每次运行一个新的。 -- 给 flow:取出时目标目录必须是空的,workspace 不查。 -- 给 flow(materials 审阅):冻结协议只在回应阶段关闭后写进账本,阶段内给 Jev 查的协议只放内存;解封只在本轮收尾阶段关闭后写;阶段关闭只在名单上的人交齐后写。materials 把这几条记录的出现当作"已公开",提前写会泄露(审阅者在临时账本里实测过)。 +- 给 flow(materials 审阅):冻结协议只在回应阶段关闭后写进账本,阶段内给 Jev 查的协议只放内存;解封只在本轮收尾阶段关闭后写。materials 把这几条记录的出现当作"已公开",提前写会泄露(审阅者在临时账本里实测过)。 - 给 flow:没采纳时也写一条采纳决定(adopted 为 null),本轮正向运行靠它公开;修复任务要在核验阶段关闭后再发。 - 给 flow:消融格核验不通过、修复次数用完时写 `cellNotRunEvent(格, 原因)` 事件,结算记 insufficient。flow 重做时,把上一次这一格的提交取出来当工作目录(materials 的消融实现材料就是这样写的)。 -- 给 flow:修复的差异块按本批基线算(任务书 10 第二部分第 2 步),切好交给 submissions;形状不合格的提交由 agents 报给提交回调,flow 直接记退回,不经 submissions。 +- 给 flow:修复的差异块按本批基线算(任务书 10 第二部分第 2 步),切好交给 submissions。 - 给 flow:消融实现交了 `cannotRun` 时 `known.code` 传 null,否则传存版本的结果。 - 给 flow:问 Jev 查代码改动时,冻结协议里引用的指标要换成原登记的定义(materials 的实验员材料里已有这一步,写 flow 时把它导出共用,不另写一份)。 - 给以后换任务镜像:镜像里必须有 `setpriv`(runner 用它把命令降到当前用户);容器里不要用 `python -I`,它会忽略 `PYTHON_CPU_COUNT`。 @@ -93,4 +91,4 @@ ## 下一步 -已完成模块收尾完毕,现在由用户和监督者一起过 flow。flow 分四部分写、各自验证,拆分、验证办法和对照用的记录见 `docs/结构.md` 第三部分"flow 分四部分写,各自验证"。用户还要求每部分也拿参考项目真实验收运行的记录(`/home/iomgaa/rb-acceptance/*/board.sqlite`)当对照,原话:"我们也可以把我们的记录作为一个标杆,然后去匹配"。部分 1 已写成任务书 12(`任务书/12-flow-1-骨架与第零轮.md`),等用户另开会话执行,之后监督者核对。 +任务书 12 已做完并提交,等监督者核对。第零轮研究质量 3 次都没通过(入口里没有联合优化),是否改提示词或方法由用户和监督者定;定之前不写任务书 13。 diff --git a/docs/结构.md b/docs/结构.md index 455ad6b..42a4571 100644 --- a/docs/结构.md +++ b/docs/结构.md @@ -525,15 +525,16 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 - 核验:不放 Jev 对这份实现的判断。能走到核验说明 Jev 已判过,Jev 是判别条件,不是给核验员的提示。 - 消融实现:这一格已有公开的消融核验(核验不通过、重做)时,和修复一样从上一次交的这一格代码接着改(用户 2026-10-03 定):工作目录写"上一次交的这一格代码的副本",能读的版本里加上它,完整代码照旧列出作对照;另放"退回意见":最近一次核验里关错了的因子和核验员写的依据,写法同修复的退回意见。 - 指标:冻结之后一律用全名"候选编号.短名":已登记指标清单、对号清单的 `reusableMetrics`、实验员材料的"要实现的指标定义"(候选代码在结果文件里也用全名当键)。提案、回应、统一的对号清单里,已占用的指标编号只有任务的指标、`answer-path` 和 `safeguard-triggers` 两个保留键(用户 2026-10-03 定);已登记的全名带 `.`,新登记的短名不带,不用列进来。 +- `finalEntries(ledger)`:按账本取的最终有效的入口(各人最后一版入口清单里的每个入口,带作者和正文);flow 分配入口时用,和认领的对号清单是同一份(任务书 12 第三部分)。 - `queryMaterial(material, id)`:只在这份材料的记录里按编号找,交回全文;找不到交回 null(agents 的查询回调据此答"看不到")。 - `readPrompts(dir)`:读 `roles/<角色>.md` 和 `tasks/<提交种类>.md`,交回文字和每个文件原始字节的 sha256;研究开始时 flow 把哈希冻结进研究配置记录(方法.md 4.1)。 - `beliefBook(ledger, study, round)`:信念本全文(方法.md 6.10 五条),数字全部用 rules 现算,只写已公开的记录;`round` 是读者所在轮次,导出时写 null。选批历史的每次运行后面原样写出任务的附加记录(判分结果的 `extra`,合法化扣掉的分在里面,字段由任务定)。消融格记"证据不足"时,有运行的原因取判分,没有运行的(核验不通过、重做次数用完)原因取这一格的 `CELL_NOT_RUN` 事件。 - `TaskMaterial`、`MaterialSection`、`MaterialRecord`、`ReadableVersion`(类型):任务材料,以及其中的一节、一条可查询记录(编号、标题、全文)、一个能读的代码版本(版本号、说明、取出目录)。 - `TaskRequest`、`StudyFacts`、`CodeChanges`、`Prompts`、`PromptFile`(类型):flow 传进来的东西,全部必填;`CodeChanges` 与 workspace 的比较结果同形(照 jev `codeHunks` 的先例,不引用 workspace)。 -- 账本里只存成 Json 的内容的形状(用户 2026-10-03 决定 2:materials 定义,flow 照着写,ledger 不改):`FrozenProtocol`(冻结协议:方法.md 4.3 ⑥ 列的因子、保障、指标、正文、实现条款,加轮次、出发的基线、合并了哪些原提案;不带答复,主体由 submissions 的 `protocolBody` 拼,指标编号是全名)、`LocationTable`(代码记录的位置表)、`DecisionKind`、`DECISION_KINDS`、`DecisionShapes`(六种决定:入口分配、判重合并、选批、采纳、选格、押的因子,输入输出用 rules 的类型;没采纳时也写一条采纳决定)、`DedupOutput`(判重合并的输出)、`Settlement`(结算)。 +- 账本里只存成 Json 的内容的形状(用户 2026-10-03 决定 2:materials 定义,flow 照着写,ledger 不改):`StudyRecord`(研究配置记录:配置、步骤清单、提示词每个文件的哈希、镜像编号、任务事实、种子;配置和任务事实存 Json,flow 读回时用 `checkConfig`、`checkTaskFacts` 再检查,任务书 12 第三部分)、`FrozenProtocol`(冻结协议:方法.md 4.3 ⑥ 列的因子、保障、指标、正文、实现条款,加轮次、出发的基线、合并了哪些原提案;不带答复,主体由 submissions 的 `protocolBody` 拼,指标编号是全名)、`LocationTable`(代码记录的位置表)、`DecisionKind`、`DECISION_KINDS`、`DecisionShapes`(六种决定:入口分配、判重合并、选批、采纳、选格、押的因子,输入输出用 rules 的类型;没采纳时也写一条采纳决定)、`DedupOutput`(判重合并的输出)、`Settlement`(结算)。 - `freezeProtocol(body, candidate, round, baseline, proposals)`:flow 写冻结协议时调用。输入 submissions 交回的冻结协议主体、候选编号、轮次、基线、合并了哪些原提案,交回 `FrozenProtocol`;新登记的假设专用指标改用全名"候选编号.短名"(例如 `r2-c1.restart-gain`),引用的本来就是全名,照抄。加前缀只在这一处:同一轮的研究者互相看不到提案,会起同一个短名、定义却不同,候选编号判重合并之后才有,所以只能在冻结时加(用户 2026-10-03 定)。 - 事件:`CELL_NOT_RUN`、`cellNotRunEvent(格, 原因)`:一格消融核验不通过、重做次数用完而不跑时,flow 用它写事件记录(`objects` 只有这一格),结算记证据不足(方法.md 4.3 ⑯、6.6)。 -- 编号规则:`ORIGINAL_BASELINE`、`entryId`、`proposalId`、`critiqueId`、`candidateId`、`factorId`、`cellId`、`splitCell`、`explanationId`、`callId`,以及协议内的局部编号 `factorKey`(p1…)、`clauseKey`(c1…)。入口编号按作者一直往后编,讨论时整份替换也不复用旧编号。 +- 编号规则:`ORIGINAL_BASELINE`、`researcherId`(第 i 位研究者,从 0 数,编号 `researcher-`,任务书 12 第三部分)、`entryId`、`proposalId`、`critiqueId`、`candidateId`、`factorId`、`cellId`、`splitCell`、`explanationId`、`callId`,以及协议内的局部编号 `factorKey`(p1…)、`clauseKey`(c1…)。入口编号按作者一直往后编,讨论时整份替换也不复用旧编号。 ### agents @@ -573,3 +574,38 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 - `OriginalProposal`(类型):回应拼协议时叠在下面的原提案:因子、保障、指标、正文,作者自评不进协议。 - `ProtocolBody`(类型):冻结协议的主体,即方法.md 4.3 ⑥ 列的因子、保障、指标、正文、实现条款;flow 用 materials 的 `freezeProtocol` 补上轮次、基线、合并了哪些原提案,写成 `FrozenProtocol`。 - `Verdict`(类型):判的结论:收下(带用到的全部 Jev 判断,没问 Jev 为 null;回应、统一另带拼好的协议主体,其余为 null),或退回(带原因和用到的 Jev 判断)。 + +### flow + +下面是 `src/flow/index.ts` 导出的全部内容,以后 plugin 也只用它。flow 只经各模块的 `index.ts` 用它们;正式代码里没有为检验写的参数、环境变量、回放模式或假实现(CLAUDE.md"测试")。这一节对应 flow 第 1 部分(任务书 12):骨架、研究开始、第零轮,走到"开第 1 轮"停下。 + +- `runStudy(input)`:推进一份研究,直到停下。输入三样,都必填:研究目录;配置(新研究交 `checkConfig` 检查过的配置,接着跑一份已有的研究写 null,以账本里冻结的那份为准;两者对不上就报错);"停下"信号。每次启动走同一条路:打开账本;新研究先冻结配置,本机已有 `inforsi-<研究编号>` 的镜像就说明研究编号用过了,报错停下,要换一个编号(编号重复的新研究会把旧研究遗留的同名容器当成自己的运行);已有研究的,核对冻结的配置、镜像还在、提示词哈希没变(开发入口开着时提示词可以变);给开始了没结束的模型调用补写结束(出错、"进程中断"、墙钟时间测不到);按容器名问 Docker 核对登记了没结果的正式运行(没启动的写"没启动"退还名额,启动了的补写确认启动、等它、判分、写结果,问不到的写"状态未知"事件并停下);然后进主循环:读账本 → 问"下一步做什么" → 做这一步 → 回到开头。主循环里 `docker run` 没启动的,写完"没启动"结果(退还名额、记下 Docker 报错原文)就停下,停下原因带上报错原文,本进程不重跑;下次启动时照旧起新编号重跑。恢复时补写的"没启动"不停下。 +- `StudyInput`(类型):上面三样输入。 +- `StudyEnding`(类型):两种结局之一:停下(附原因,同时写一条停下事件),或被用户停下(正在跑的模型会话立即停,正在跑的容器不管,下次启动由恢复入口接着等)。"研究结束"第 4 部分才有。 + +研究目录里放:`ledger.sqlite` 账本;`repo.git` 代码版本的裸仓库;`runs/<运行编号>/` 一次正式运行(取出的代码、运行目录、日志、判分目录、校验器日志);`calls/<调用编号>/` 一次模型调用(工作目录、会话文件、能读的代码版本)。取出代码、建运行目录和判分目录时目标都是新建的空目录。容器名 `inforsi-<研究编号>-<运行编号>`,判分容器另加 `-judge`。同一份研究的全部运行用同一个种子:研究编号经 vocab 的 `hash` 取前 8 位十六进制转成整数,冻结进研究配置记录。 + +**每一步以哪条记录为准算做完**(任务书 12 第 2.2 节)。步骤的先后只写在 flow 的"下一步做什么"一个纯函数里;后面三部分每加一步,这张表加一行。 + +| 步骤(config 的 `STUDY_STEPS`) | 看到哪条记录算做完 | +|---|---| +| `freezeConfig` 冻结配置 | 研究配置记录(`config`) | +| `baselineRun` 基线 | 基线代码记录(`code`,对象是 materials 的 `ORIGINAL_BASELINE`),而且最近一次基线运行的结果不是"没启动";"没启动"的下次启动时起新编号(`base-2`……)重跑,判为不算数的停下 | +| `entryDraft`、`discussion1`、`discussion2`、`claim` | 这个阶段的关闭记录(`stageClose`);阶段编号 `r0-<步骤>`,只在名单上的人都有被收下的提交之后才关 | +| `assignEntries` 分配 | 第 0 轮的入口分配决定(`decision`,种类 `entries`) | +| 按配置跳过的步骤 | 一条"按配置跳过"事件(`skippedByConfig`),写明步骤和 config 给的原因 | + +研究者阶段里没交上的人,按他在这个阶段最近一次调用怎么结束的来定:没有调用、被用户停下、出错(含"进程中断")都派;没交的,开发入口关着就停下(交不出合格提交按内部错误,方法.md 4.3 ②),开着就再派。一次派发里有人没交上,等这次派发的会话全部结束后停下。 + +检验命令(`eval/`,真实运行,要 Docker;带 `*` 的会花模型或 Jev 的钱。输出都在仓库外 `/home/iomgaa/inforsi-eval/<日期-名字>/`,名字也当研究编号用,只能小写、不能重复): + +| 命令 | 用途 | +|---|---| +| `npm run eval:study -- --config <文件> --dir <目录>`、`-- --resume --dir <目录>` * | 起一份研究或接着跑,Ctrl-C 发停下信号 | +| `npm run eval:r0:run -- <名字>` * | 用 `eval/round-zero/research.json` 真实跑一次第零轮 | +| `npm run eval:r0:quality -- <检验目录>` * | 对第零轮产出问六问(任务书 12 第 5.2 节),原始概率写进 `<检验目录>/quality.json` | +| `npm run eval:r0:extract` | 从参考项目 16-real-1 的账本重新提取第零轮提交,生成回放数据 | +| `npm run eval:r0:replay -- <名字>` | 回放 16-real-1 的第零轮,比对分配结果 | +| `npm run eval:r0:kill -- baseline\|drafting\|stageFull` | 在三处之一杀控制进程、重启跑完,逐条检查(用回放,不花钱) | +| `npm run eval:r0:concurrent` | 两份研究同时跑到基线结束,检查各扣各的、容器不撞名 | + diff --git a/eval/lib/cli.ts b/eval/lib/cli.ts new file mode 100644 index 0000000..17873ca --- /dev/null +++ b/eval/lib/cli.ts @@ -0,0 +1,27 @@ +import { parseArgs } from "node:util"; +import { loadConfig } from "../../src/config/index.ts"; +import { runStudy } from "../../src/flow/index.ts"; + +// 起研究的命令行入口:像用户一样给配置、指定研究目录、调 runStudy,按 Ctrl-C(SIGINT)时发"停下"信号。 +// 新研究:node eval/lib/cli.ts --config --dir <研究目录> +// 接着跑:node eval/lib/cli.ts --resume --dir <研究目录>(配置写 null,以账本里冻结的那份为准) +// 结局打印成一行 "RESULT ",检验脚本按这一行读。 + +const { values } = parseArgs({ + options: { config: { type: "string" }, resume: { type: "boolean" }, dir: { type: "string" } }, + strict: true, +}); +if (values.dir === undefined) throw new Error("要写 --dir <研究目录>"); +if ((values.config === undefined) === (values.resume !== true)) { + throw new Error("--config 和 --resume 二选一,必须写一个"); +} + +const config = values.config === undefined ? null : loadConfig(values.config); +const stop = new AbortController(); +process.on("SIGINT", () => { + console.error("收到 SIGINT,发停下信号"); + stop.abort(); +}); + +const outcome = await runStudy({ studyDir: values.dir, config, stop: stop.signal }); +console.log(`RESULT ${JSON.stringify(outcome)}`); diff --git a/eval/lib/jev.ts b/eval/lib/jev.ts new file mode 100644 index 0000000..2f4912e --- /dev/null +++ b/eval/lib/jev.ts @@ -0,0 +1,71 @@ +// 检验自己用的小 Jev 客户端(任务书 12 第四部分第 6 条):不经 src/jev,也不改它。 +// 请求格式参照 src/jev/jev.ts 和 RivalBits research-wiki/参考-Jev使用指南.md 第二部分: +// POST {model, state, questions},回答里 answers.<问题名>.noul 是 0 到 1 的概率。 +// 检验的问题都没有校准过阈值,一律以概率大于 0.5 判"是",原始概率照存(任务书 12 第 5.2 节)。 + +const ENDPOINT = "https://api.typesafe.ai/v1/systemone"; +/** 检验用的 Jev 模型名,和研究配置的 models.jev 同一个。 */ +export const JEV_MODEL = "jev-latest"; +export const THRESHOLD = 0.5; +export const THRESHOLD_NOTE = "阈值未校准:一律以概率大于 0.5 判\"是\""; + +/** 一条是非题,写法照 src/jev/questions.ts:问题、关注点、判"是"判"否"的说明和反例。 */ +export interface Noul { + type: "noul"; + instructions: { question: string; focus: string }; + criteria: { + true: { what: string; examples: string[] }; + false: { what: string; not_for: string; examples: string[] }; + }; +} + +export interface Answer { + question: string; + probability: number; + yes: boolean; + requestId: string; +} + +/** 调不通时重试前等多久:只重试连不上、超时、429 和 5xx(原因在我们控制之外),一共最多 4 次。 */ +const RETRY_WAITS_MS = [2_000, 10_000, 30_000]; + +/** 就一份材料问几问,一次请求。 */ +export async function ask(state: Record, questions: Record): Promise { + const key = process.env["TYPESAFE_API_KEY"]; + if (key === undefined || key.trim() === "") throw new Error("环境变量 TYPESAFE_API_KEY 没有设置"); + const body = JSON.stringify({ model: JEV_MODEL, state, questions }); + const failures: string[] = []; + for (let attempt = 0; attempt <= RETRY_WAITS_MS.length; attempt++) { + if (attempt > 0) await new Promise((ok) => setTimeout(ok, RETRY_WAITS_MS[attempt - 1])); + let response: Response; + let text: string; + try { + response = await fetch(ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, + body, + signal: AbortSignal.timeout(60_000), + }); + text = await response.text(); + } catch (error) { + failures.push((error as Error).message); + continue; + } + const requestId = response.headers.get("x-typesafe-request-id") ?? "无"; + if (!response.ok) { + const what = `HTTP ${response.status}(request-id: ${requestId}):${text.slice(0, 300)}`; + if (response.status !== 429 && response.status < 500) throw new Error(`Jev 拒绝了请求:${what}`); + failures.push(what); + continue; + } + const answers = (JSON.parse(text) as { answers?: Record }).answers; + return Object.keys(questions).map((question) => { + const probability = answers?.[question]?.noul; + if (typeof probability !== "number" || !(probability >= 0 && probability <= 1)) { + throw new Error(`Jev 的回答里问题 ${question} 没有 0 到 1 之间的概率(request-id: ${requestId}):${text.slice(0, 300)}`); + } + return { question, probability, yes: probability > THRESHOLD, requestId }; + }); + } + throw new Error(`Jev 调不通,发了 ${failures.length} 次:${failures.join(";")}`); +} diff --git a/eval/lib/ledger.ts b/eval/lib/ledger.ts new file mode 100644 index 0000000..748bed3 --- /dev/null +++ b/eval/lib/ledger.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { Ledger, type LedgerRecord } from "../../src/ledger/index.ts"; +import { entryId, type DecisionShapes } from "../../src/materials/index.ts"; + +// 读账本的小工具:只经 src/ledger/index.ts 打开、只读记录,不写。 +// 入口编号用 materials 的 entryId,和正式代码同一条编号规则。 + +/** 研究目录里账本的全部记录。账本还没有时报错:Ledger.open 会新建一个空账本,检验不能替 flow 建。 */ +export function readLedger(studyDir: string): LedgerRecord[] { + const path = join(studyDir, "ledger.sqlite"); + if (!existsSync(path)) throw new Error(`${path} 还不存在`); + const ledger = Ledger.open(path); + try { + return ledger.records(); + } finally { + ledger.close(); + } +} + +export type Of = Extract; +export function ofKind(records: LedgerRecord[], kind: K): Array> { + return records.filter((r): r is Of => r.kind === kind); +} + +export interface StageInfo { + stageId: string; + stage: string; + round: number; + roster: string[]; + closed: boolean; +} + +export function stages(records: LedgerRecord[]): StageInfo[] { + const closed = new Set(ofKind(records, "stageClose").map((r) => r.content.stageId)); + return ofKind(records, "stageOpen").map(({ content: c }) => ({ ...c, closed: closed.has(c.stageId) })); +} + +/** 每份提交(阶段、作者、种类、对象、编号)的最新一版,按这一版的序号排。 */ +export function finalSubmissions(records: LedgerRecord[]): Array> { + const latest = new Map>(); + for (const r of ofKind(records, "submission")) { + const c = r.content; + const slot = JSON.stringify([c.stageId, c.author, c.kind, c.target, c.item]); + const seen = latest.get(slot); + if (seen === undefined || seen.content.version < c.version) latest.set(slot, r); + } + return [...latest.values()].sort((a, b) => a.seq - b.seq); +} + +export interface Entry { + id: string; + author: string; + name: string; + body: string; +} + +/** + * 只看给定阶段里的提交,各人最后一版入口清单里的入口(方法.md 4.2:讨论时整份替换的新清单接着往后编号)。 + * 给第零轮全部阶段就是分配时的"入口全体";只给写入口阶段就是闭门写完时的入口全体。 + */ +export function entriesIn(records: LedgerRecord[], stageIds: ReadonlySet): Entry[] { + const lists = new Map(); + const used = new Map(); + for (const s of finalSubmissions(records)) { + const c = s.content; + if (!stageIds.has(c.stageId) || (c.kind !== "entry" && c.kind !== "discussion")) continue; + const entries = (c.content as { entries?: Array<{ name: string; body: string }> }).entries; + if (entries === undefined) continue; + const n = used.get(c.author) ?? 0; + lists.set(c.author, entries.map((e, i) => ({ id: entryId(c.author, n + i + 1), author: c.author, ...e }))); + used.set(c.author, n + entries.length); + } + return [...lists.values()].flat(); +} + +export type Assignment = DecisionShapes["entries"]["output"][number]; + +/** 第 0 轮的入口分配决定:输入(全部志愿和入口)和输出(分配结果);没有就是 undefined。 */ +export function entryDecision(records: LedgerRecord[]): { input: { entries: Array<{ id: string; author: string }> }; output: Assignment[] } | undefined { + const d = ofKind(records, "decision").find((r) => r.content.decision === "entries" && r.content.round === 0); + return d === undefined ? undefined : (d.content as unknown as { input: { entries: Array<{ id: string; author: string }> }; output: Assignment[] }); +} + +/** 两份分配是否相同:每人分到的入口和第几志愿都一样,不看先后和对象里键的顺序。 */ +export function sameAssignment(a: Assignment[], b: Assignment[]): boolean { + const key = (xs: Assignment[]) => xs.map((x) => `${x.researcher} ${x.entry} ${x.rank}`).sort().join("\n"); + return key(a) === key(b); +} diff --git a/eval/lib/replay.ts b/eval/lib/replay.ts new file mode 100644 index 0000000..0797ceb --- /dev/null +++ b/eval/lib/replay.ts @@ -0,0 +1,100 @@ +import { createServer, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// 回放用的假模型服务(任务书 12 第四部分第 3 条):本机起一个兼容 OpenAI 的流式接口, +// 用 Pi 自己的 PI_CODING_AGENT_DIR 指一份 models.json 过来,正式代码不知道对面是假模型。 +// 每个模型名一份按顺序交出的提交清单:每次被派任务(会话的第一次请求),交出下一份;之后的请求回一句话结束。 +// 交完了就不再交,只回一句话:不为凑结果改内容。被退回时模型没有别的可交,会话就以"没交"结束。 + +/** 一份要交的提交:research_submit 的参数原样(payload,以及可能的 replaces),from 写它从哪来,只用于报告。 */ +export interface ReplayItem { + from: string; + arguments: { payload: unknown; replaces?: string }; +} + +/** 回放脚本:模型名 → 按顺序交出的提交。没有要交的模型(实验员、核验员)写空数组。 */ +export interface ReplayScript { + source: string; + models: Record; +} + +export interface Replay { + /** 起研究的子进程要带的环境变量。 */ + env: Record; + /** 每个模型名已经交出的提交(按交出先后)。 */ + delivered: Record; + /** 每个模型名还没交出的提交。 */ + remaining: () => Record; + close: () => Promise; +} + +export const REPLAY_PROVIDER = "replay"; + +/** + * 起服务并写好 Pi 的 models.json、auth.json。delayMs 是每次回复前等多久(杀进程检验要一段"正在写"的窗口,0 就是不等)。 + * 一份提交只有在回复完整写出后才算交出:回复前对面断开(控制进程被杀),这一份放回清单最前面,下次被派任务时还交它。 + */ +export async function startReplay(script: ReplayScript, agentDir: string, delayMs: number): Promise { + const queues = new Map(Object.entries(script.models).map(([model, items]) => [model, [...items]])); + const delivered: Record = Object.fromEntries([...queues.keys()].map((m) => [m, []])); + + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => void answer(JSON.parse(Buffer.concat(chunks).toString()), res)); + }); + + async function answer(body: { model: string; messages: Array<{ role: string }> }, res: ServerResponse): Promise { + const queue = queues.get(body.model); + if (queue === undefined) throw new Error(`回放脚本里没有模型 ${body.model}`); + // 会话的第一次请求里还没有助手的回复:这是新派的任务。 + const item = body.messages.some((m) => m.role === "assistant") ? undefined : queue.shift(); + res.on("close", () => { + if (!res.writableFinished && item !== undefined) queue.unshift(item); + }); + await new Promise((ok) => setTimeout(ok, delayMs)); + if (res.destroyed) return; + const send = (delta: unknown, finish: string | null) => + res.write(`data: ${JSON.stringify({ id: "r", object: "chat.completion.chunk", created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`); + res.writeHead(200, { "content-type": "text/event-stream" }); + if (item === undefined) { + send({ role: "assistant", content: "好的,我这次没有别的要交了。" }, null); + send({}, "stop"); + } else { + const args = JSON.stringify(item.arguments); + send({ role: "assistant", tool_calls: [{ index: 0, id: "call-1", type: "function", function: { name: "research_submit", arguments: args } }] }, null); + send({}, "tool_calls"); + } + res.write(`data: ${JSON.stringify({ id: "r", object: "chat.completion.chunk", created: 1, model: body.model, choices: [], usage: { prompt_tokens: 100, completion_tokens: 10 } })}\n\n`); + res.end("data: [DONE]\n\n"); + if (item !== undefined) delivered[body.model]!.push(item.from); + } + + await new Promise((ok) => server.listen(0, "127.0.0.1", ok)); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`; + const models = [...queues.keys()].map((id) => ({ + id, + reasoning: false, + input: ["text"], + contextWindow: 1_000_000, + maxTokens: 32_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + })); + mkdirSync(agentDir, { recursive: true }); + writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { [REPLAY_PROVIDER]: { baseUrl, api: "openai-completions", apiKey: "k", models } } }, null, 2)); + writeFileSync(join(agentDir, "auth.json"), "{}"); + return { + env: { PI_CODING_AGENT_DIR: agentDir }, + delivered, + remaining: () => Object.fromEntries([...queues].map(([m, q]) => [m, q.map((i) => i.from)])), + close: () => new Promise((ok) => server.close(() => ok())), + }; +} + +/** 回放用的各角色模型:研究者按脚本里的模型名,实验员、核验员各一个空清单的名字。 */ +export function replayModels(researchers: string[], jev: string): unknown { + const m = (model: string) => ({ provider: REPLAY_PROVIDER, model, thinking: "off" }); + return { researchers: researchers.map(m), experimenter: m("experimenter"), verifier: m("verifier"), jev }; +} diff --git a/eval/lib/study.ts b/eval/lib/study.ts new file mode 100644 index 0000000..e49c86c --- /dev/null +++ b/eval/lib/study.ts @@ -0,0 +1,82 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// 检验脚本共用的起研究工具:检验目录放在仓库外,配置照用户的写法写一份,研究在子进程里跑(要能被 SIGKILL)。 + +/** 检验输出一律放在仓库外(任务书 12 第四部分第 4 条),不提交。 */ +export const EVAL_ROOT = "/home/iomgaa/inforsi-eval"; + +/** 新建一个检验目录 <日期-名字>;已经有了就报错,不把两次检验的产物混在一起。 */ +export function newEvalDir(name: string): string { + const dir = join(EVAL_ROOT, `${new Date().toISOString().slice(0, 10)}-${name}`); + if (existsSync(dir)) throw new Error(`检验目录 ${dir} 已经有了,换个名字或先挪走`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * 照一份底稿写这次检验用的 research.json:换研究编号(容器名、镜像标签都带它),需要时换各角色模型; + * 任务目录、提示词目录写成绝对路径(底稿里相对仓库根目录),这样配置文件放在仓库外也指得对。 + */ +export function writeConfig(base: string, path: string, studyId: string, models: unknown): string { + const config = JSON.parse(readFileSync(base, "utf8")) as Record; + config["studyId"] = studyId; + config["task"] = resolve(config["task"] as string); + config["promptDir"] = resolve(config["promptDir"] as string); + if (models !== null) config["models"] = models; + writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); + return path; +} + +export interface StudyProcess { + child: ChildProcess; + /** 子进程退出:退出码、信号,以及它打印的结局(被杀掉时没有,是 null)。 */ + done: Promise<{ code: number | null; signal: NodeJS.Signals | null; outcome: unknown }>; +} + +/** 在子进程里起 eval/lib/cli.ts。config 为 null 是接着跑。子进程的输出原样转到本进程,并记进 <研究目录>.log。 */ +export function launch(config: string | null, studyDir: string, env: Record): StudyProcess { + const args = ["eval/lib/cli.ts", ...(config === null ? ["--resume"] : ["--config", config]), "--dir", studyDir]; + const child = spawn(process.execPath, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }); + const log: string[] = []; + let stdout = ""; + child.stdout!.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + log.push(chunk.toString()); + process.stdout.write(chunk); + }); + child.stderr!.on("data", (chunk: Buffer) => { + log.push(chunk.toString()); + process.stderr.write(chunk); + }); + const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null; outcome: unknown }>((ok) => + child.on("close", (code, signal) => { + writeFileSync(`${studyDir}.log`, log.join(""), { flag: "a" }); + const line = stdout.split("\n").find((l) => l.startsWith("RESULT ")); + ok({ code, signal, outcome: line === undefined ? null : JSON.parse(line.slice("RESULT ".length)) }); + }), + ); + return { child, done }; +} + +export function sleep(ms: number): Promise { + return new Promise((ok) => setTimeout(ok, ms)); +} + +/** 本机名字以 prefix 开头的容器(含已停止的)。检验直接问 Docker,不经正式代码。 */ +export function containers(prefix: string): string[] { + const names = execFileSync("docker", ["ps", "-a", "--format", "{{.Names}}"], { encoding: "utf8" }); + return names.split("\n").filter((n) => n.startsWith(prefix)); +} + +/** + * 等 flow 把账本的表建好,检验才第一次打开账本:Ledger.open 遇到空文件会自己建表,和 flow 同时建会冲突。 + * 信号是研究目录里出现 repo.git:flow 先 Ledger.open(建表),冻结配置时才建裸仓库(src/flow/loop.ts、study.ts 的 freezeConfig)。 + */ +export async function waitForLedger(studyDir: string, child: ChildProcess): Promise { + while (!existsSync(join(studyDir, "repo.git"))) { + if (child.exitCode !== null) throw new Error(`研究进程没建出 repo.git 就退出了,退出码 ${child.exitCode}`); + await sleep(50); + } +} diff --git a/eval/round-zero/concurrent.ts b/eval/round-zero/concurrent.ts new file mode 100644 index 0000000..719f566 --- /dev/null +++ b/eval/round-zero/concurrent.ts @@ -0,0 +1,54 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ofKind, readLedger } from "../lib/ledger.ts"; +import { replayModels, startReplay } from "../lib/replay.ts"; +import { containers, launch, newEvalDir, sleep, writeConfig } from "../lib/study.ts"; + +// 两份研究同时跑(任务书 12 第 5.4 节):两个研究编号同时启动,都跑到基线结束,查两边各扣各的、容器不撞名。 +// node eval/round-zero/concurrent.ts +// 研究者用一份空清单的回放:基线跑完、派写入口任务时模型一份不交,flow 按"交不出合格提交"停下,不花钱。 + +const out = newEvalDir("r0-concurrent"); +const ids = ["r0-conc-a", "r0-conc-b"]; +const researchers = ["researcher-1", "researcher-2", "researcher-3"]; +const replay = await startReplay({ source: "空清单", models: Object.fromEntries([...researchers, "experimenter", "verifier"].map((m) => [m, []])) }, join(out, "pi-agent"), 0); + +const studies = ids.map((id) => { + const dir = join(out, id); + const config = writeConfig("eval/round-zero/research.json", join(out, `${id}.research.json`), id, replayModels(researchers, "jev-latest")); + return { id, dir, run: launch(config, dir, replay.env) }; +}); + +// 跑的时候每 100 毫秒看一次本机 inforsi- 开头的容器,记下见过的名字。 +const seen = new Set(); +let running = true; +const watcher = (async () => { + while (running) { + for (const n of containers("inforsi-")) seen.add(n); + await sleep(100); + } +})(); +const results = await Promise.all(studies.map((s) => s.run.done)); +running = false; +await watcher; +await replay.close(); + +const report = studies.map((s, i) => { + const r = readLedger(s.dir); + const base = new Set(ofKind(r, "runPlanned").filter((p) => p.content.purpose === "baseline").map((p) => p.content.run)); + return { + id: s.id, + outcome: results[i]!.outcome, + baseStarted: ofKind(r, "runStarted").filter((x) => base.has(x.content.run)).length, + baseResults: ofKind(r, "runResult").filter((x) => base.has(x.content.run)).map((x) => x.content), + containersSeen: [...seen].filter((n) => n.startsWith(`inforsi-${s.id}-`)), + }; +}); +const shared = report[0]!.containersSeen.filter((n) => report[1]!.containersSeen.includes(n)); +const pass = report.every((s) => s.baseStarted === 1 && s.baseResults.length === 1 && s.baseResults[0]!.judged !== null) && shared.length === 0; +writeFileSync(join(out, "concurrent-report.json"), `${JSON.stringify({ pass, report, allContainersSeen: [...seen], shared }, null, 2)}\n`); +for (const s of report) { + console.log(`${s.id}:基线确认启动 ${s.baseStarted} 次;结果 ${JSON.stringify(s.baseResults.map((x) => x.judged))};见过的容器 ${s.containersSeen.join("、") || "(没看到)"};结局 ${JSON.stringify(s.outcome)}`); +} +console.log(`两边共用的容器名:${shared.join("、") || "没有"};${pass ? "通过" : "不通过"}`); +console.log(`报告:${join(out, "concurrent-report.json")}`); diff --git a/eval/round-zero/extract-16.ts b/eval/round-zero/extract-16.ts new file mode 100644 index 0000000..5615bbf --- /dev/null +++ b/eval/round-zero/extract-16.ts @@ -0,0 +1,71 @@ +import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { entryId } from "../../src/materials/index.ts"; +import type { ReplayItem, ReplayScript } from "../lib/replay.ts"; + +// 从参考项目 16-real-1 的账本取第零轮的提交,按任务书 12 第 5.3 节转成新项目的格式,写成回放脚本。 +// 原账本只读:先复制到临时目录,再以只读方式打开复制件,原目录里不写任何东西。 +// node eval/round-zero/extract-16.ts + +const SOURCE = "/home/iomgaa/rb-acceptance/16-real-1/board.sqlite"; +export const REPLAY_FILE = "eval/round-zero/replay-16-real-1.json"; +const PHASE_STAGE: Record = { "entry-draft": "entryDraft", "entry-discuss": "discussion", "entry-claim": "claim" }; + +const copy = join(mkdtempSync(join(tmpdir(), "inforsi-rb16-")), "board.sqlite"); +copyFileSync(SOURCE, copy); +const db = new DatabaseSync(copy, { readOnly: true }); + +type OldEntry = { name: string; startFrom: string; whyWorth: string }; +const rows = db + .prepare( + `SELECT st.phase, st.id AS stage, s.actor, s.version, s.created_seq AS seq, s.payload + FROM submissions s JOIN stages st ON st.id = s.stage_id + WHERE st.phase IN ('entry-draft', 'entry-discuss', 'entry-claim') ORDER BY s.created_seq`, + ) + .all() as Array<{ phase: string; stage: string; actor: string; version: number; seq: number; payload: string }>; +const fact = db.prepare("SELECT payload FROM facts WHERE kind = 'entry-assignment'").all() as Array<{ payload: string }>; +db.close(); +if (fact.length !== 1) throw new Error(`entry-assignment 应有 1 条,实有 ${fact.length} 条`); + +/** 当年入口 {name, startFrom, whyWorth} → 新项目 {name, body}。 */ +const entry = (e: OldEntry) => ({ name: e.name, body: `从哪里开始看:${e.startFrom}\n\n为什么值得走:${e.whyWorth}` }); +/** 当年入口编号 researcher-1-e4 → 新项目 e-researcher-1-4。 */ +function newId(old: string): string { + const m = /^(researcher-\d+)-e(\d+)$/.exec(old); + if (m === null) throw new Error(`入口编号 ${old} 不是 researcher--e 的写法`); + return entryId(m[1]!, Number(m[2])); +} + +const models: Record = {}; +for (const r of rows) { + const p = JSON.parse(r.payload) as { entries?: OldEntry[]; body?: string; choices?: string[] }; + const payload = + r.phase === "entry-draft" + ? { entries: p.entries!.map(entry) } + : r.phase === "entry-discuss" + ? { body: p.body!, ...(p.entries === undefined ? {} : { entries: p.entries.map(entry) }) } + : { choices: p.choices!.map(newId) }; + (models[r.actor] ??= []).push({ from: `${r.stage} ${PHASE_STAGE[r.phase]} seq ${r.seq} v${r.version}`, arguments: { payload } }); +} + +const old = JSON.parse(fact[0]!.payload) as { assignments: Array<{ researcher: string; entryId: string; rank: number | null }>; entries: Array<{ entryId: string; author: string }> }; +const expected = old.assignments.map((a) => ({ researcher: a.researcher, entry: newId(a.entryId), rank: a.rank })); +const script: ReplayScript & { expected: typeof expected; expectedEntries: string[] } = { + source: `${SOURCE}(第零轮 submissions,created_seq ${rows[0]!.seq}–${rows.at(-1)!.seq})`, + models, + expected, + expectedEntries: old.entries.map((e) => newId(e.entryId)), +}; +writeFileSync(REPLAY_FILE, `${JSON.stringify(script, null, 2)}\n`); + +// 核对:三阶段各几份、作者、版本,以及当年的分配。 +console.log(`共 ${rows.length} 份提交,序号 ${rows[0]!.seq}–${rows.at(-1)!.seq}`); +for (const phase of Object.keys(PHASE_STAGE)) { + const these = rows.filter((r) => r.phase === phase); + console.log(`${phase}:${these.length} 份;${these.map((r) => `${r.actor}@${r.seq}(v${r.version}${JSON.parse(r.payload).entries ? `,${JSON.parse(r.payload).entries.length} 个入口` : ""})`).join("、")}`); +} +console.log(`当年分配:${expected.map((a) => `${a.researcher} → ${a.entry}(第 ${a.rank} 志愿)`).join(";")}`); +console.log(`当年最终入口:${script.expectedEntries.join("、")}`); +console.log(`已写 ${REPLAY_FILE}`); diff --git a/eval/round-zero/ideas.json b/eval/round-zero/ideas.json new file mode 100644 index 0000000..1670047 --- /dev/null +++ b/eval/round-zero/ideas.json @@ -0,0 +1,49 @@ +{ + "note": "任务书 12 第 5.1 节的关键想法清单和死路清单,出处照抄。英文说明给 Jev 看。", + "key": [ + { + "id": "K1", + "name": "联合优化", + "description": "Optimize the centers and radii of all 26 circles together (78 numbers) with a constrained continuous optimizer such as scipy SLSQP, with constraints that every circle stays inside the square and no two circles overlap.", + "source": "最大的一跳:OpenEvolve 2.377→2.634;AdaEvolve 2.541→2.610;参考项目 12-real-1、12-real-2 第 1 轮采纳;16-real-1 关掉它少 0.132" + }, + { + "id": "K2", + "name": "扰动重优化", + "description": "Starting from the current best solution, perturb the circle centers by a moderate amount (about 0.01 to 0.06), run the joint optimization again, and keep the result only if it is better; this includes annealing-style acceptance and larger perturbations after the search stagnates.", + "source": "AlphaEvolve、ShinkaEvolve、Discovery Loop;参考项目 12-real-2 第 2 轮靠它从 2.6155 到 2.635983,15-tight-2 也到了 2.635983" + }, + { + "id": "K3", + "name": "结构化起点与多起点", + "description": "Start from structured layouts such as hexagonal or staggered rows, or from many random starting layouts, run the optimization from each start, and keep the best result.", + "source": "ThetaEvolve、TTT-Discover 只靠\"错排起点 + 一次 SLSQP\"就到 2.635983;CodeEvolve 10 次重启" + }, + { + "id": "K4", + "name": "固定圆心求最优半径", + "description": "Keep the circle centers fixed and solve a linear program for the radii that maximizes their sum.", + "source": "ShinkaEvolve 容差 0 那次的第一步、Discovery Loop 的种子;16-real-1 消融撑 28%" + }, + { + "id": "K5", + "name": "严格合法收尾", + "description": "As a final step, shrink all radii by a tiny amount or refine the solution so that it is strictly valid under zero tolerance.", + "source": "ShinkaEvolve、ThetaEvolve 严格版;本任务容差为 0(TASK.md)" + } + ], + "deadEnds": [ + { + "id": "D1", + "name": "只做几何构造", + "description": "Place the circle centers by a hand-designed rule (hexagonal grid, square grid, concentric rings) and only tune the arrangement and its parameters, without using continuous optimization to move the centers.", + "source": "各家卡在 2.2–2.54;等半径六角的上限是 2.438966;OpenEvolve 第一阶段提示词要求只写构造,卡在 2.377;参考项目 9a、9b-real-2 卡在 2.517、2.541" + }, + { + "id": "D2", + "name": "只优化半径", + "description": "Keep the circle centers fixed and only change how the radii are allocated, as the whole route rather than as a helper step.", + "source": "上限受布局限制,2.5414 是反复出现的平台" + } + ] +} diff --git a/eval/round-zero/kill.ts b/eval/round-zero/kill.ts new file mode 100644 index 0000000..e221ce9 --- /dev/null +++ b/eval/round-zero/kill.ts @@ -0,0 +1,121 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { LedgerRecord } from "../../src/ledger/index.ts"; +import { entryDecision, finalSubmissions, ofKind, readLedger, sameAssignment, stages, type Assignment } from "../lib/ledger.ts"; +import { replayModels, startReplay, type ReplayScript } from "../lib/replay.ts"; +import { containers, launch, newEvalDir, sleep, waitForLedger, writeConfig } from "../lib/study.ts"; + +// 杀进程检验(任务书 12 第 5.4 节):在一处 SIGKILL 控制进程,再启动让它跑完第零轮,逐条查五件事。 +// node eval/round-zero/kill.ts baseline|drafting|stageFull +// 模型一侧用 16-real-1 的回放(不花钱、结果确定,分配应和当年相同);基线照样用真 Docker。 +// 回放每次回复前等 DELAY_MS,留出"研究者正在写""最后一份刚收下、阶段还没关"的窗口。 + +const DELAY_MS = 3000; +type Point = "baseline" | "drafting" | "stageFull"; +const point = process.argv[2] as Point; + +/** 三处杀点:看到账本里出现这种状态就杀。 */ +const HITS: Record boolean> = { + // 基线容器正在跑:基线运行确认启动了、还没有结果。 + baseline: (r) => { + const base = new Set(ofKind(r, "runPlanned").filter((p) => p.content.purpose === "baseline").map((p) => p.content.run)); + const done = new Set(ofKind(r, "runResult").map((x) => x.content.run)); + return ofKind(r, "runStarted").some((s) => base.has(s.content.run) && !done.has(s.content.run)); + }, + // 研究者 2 正在写入口:他在写入口阶段的调用开始了、还没结束。 + drafting: (r) => { + const draft = new Set(stages(r).filter((s) => s.stage === "entryDraft").map((s) => s.stageId)); + return openCalls(r).some((c) => c.content.actor === "researcher-2" && draft.has(c.content.stageId ?? "")); + }, + // 某阶段最后一份提交刚收下、阶段还没关:名单上的人都交了,还没有关闭记录。 + stageFull: (r) => stages(r).some((s) => !s.closed && s.roster.every((a) => submitted(r, s.stageId, a))), +}; +if (!(point in HITS)) throw new Error(`杀点只能是 ${Object.keys(HITS).join("、")} 之一`); + +function openCalls(r: LedgerRecord[]): Array> { + const ended = new Set(ofKind(r, "callEnded").map((e) => e.content.call)); + return ofKind(r, "callStarted").filter((c) => !ended.has(c.content.call)); +} +const submitted = (r: LedgerRecord[], stageId: string, author: string) => + ofKind(r, "submission").some((s) => s.content.stageId === stageId && s.content.author === author); + +const script = JSON.parse(readFileSync("eval/round-zero/replay-16-real-1.json", "utf8")) as ReplayScript & { expected: Assignment[] }; +// 研究编号也是镜像标签的一部分,Docker 镜像名只许小写,所以杀点名转成小写。 +const name = `r0-kill-${point.toLowerCase()}`; +const out = newEvalDir(name); +const replay = await startReplay(script, join(out, "pi-agent"), DELAY_MS); +const config = writeConfig("eval/round-zero/research.json", join(out, "research.json"), name, replayModels(Object.keys(script.models).sort(), "jev-latest")); +const studyDir = join(out, "study"); + +// --------------------------------------------------------------------------- +// 第一次启动,打中就杀 +// --------------------------------------------------------------------------- + +const first = launch(config, studyDir, replay.env); +await waitForLedger(studyDir, first.child); +let triggered: LedgerRecord | undefined; +while (first.child.exitCode === null) { + const r = readLedger(studyDir); + if (HITS[point](r)) { + first.child.kill("SIGKILL"); + triggered = r.at(-1); + break; + } + await sleep(20); +} +await first.done; +if (triggered === undefined) throw new Error(`没打中 ${point}:研究进程自己结束了,账本最后一条是 ${JSON.stringify(readLedger(studyDir).at(-1))}`); +// 进程已死,此刻的账本就是被杀时的样子。 +const atKill = readLedger(studyDir); +const killSeq = atKill.at(-1)!.seq; +const killedCalls = openCalls(atKill); +const openStages = stages(atKill).filter((s) => !s.closed); + +// --------------------------------------------------------------------------- +// 再启动,跑完第零轮 +// --------------------------------------------------------------------------- + +const second = await launch(null, studyDir, replay.env).done; +await replay.close(); +const r = readLedger(studyDir); +const studyId = name; + +const baseRuns = new Set(ofKind(r, "runPlanned").filter((p) => p.content.purpose === "baseline").map((p) => p.content.run)); +const baseStarted = ofKind(r, "runStarted").filter((s) => baseRuns.has(s.content.run)); +const leftover = containers(`inforsi-${studyId}-`); +const callEnds = killedCalls.map((c) => ({ call: c.content.call, end: ofKind(r, "callEnded").find((e) => e.content.call === c.content.call)?.content ?? null })); +const redispatch = openStages.flatMap((s) => + s.roster.map((actor) => { + const before = submitted(atKill, s.stageId, actor); + const again = ofKind(r, "callStarted").some((c) => c.seq > killSeq && c.content.stageId === s.stageId && c.content.actor === actor); + return { stageId: s.stageId, actor, submittedBeforeKill: before, dispatchedAfterRestart: again, ok: before !== again }; + }), +); +const finals = finalSubmissions(r); +const perStage = stages(r).map((s) => ({ + stageId: s.stageId, + closed: s.closed, + finalPerPerson: Object.fromEntries(s.roster.map((a) => [a, finals.filter((f) => f.content.stageId === s.stageId && f.content.author === a).length])), +})); +const decision = entryDecision(r); +const assignment = decision === undefined ? null : decision.output; + +const checks = { + "1 基线只扣了 1 次,Docker 里没有留下基线容器": { pass: baseStarted.length === 1 && leftover.length === 0, baseRunsPlanned: [...baseRuns], baseStarted: baseStarted.length, leftoverContainers: leftover }, + "2 被杀那次调用有结束记录,结局出错、原因进程中断": { + pass: callEnds.every((c) => c.end !== null && c.end.status === "failed" && c.end.reason.includes("进程中断")), + calls: callEnds, + }, + "3 没交上的人被重新派了,交过的没被重派": { pass: redispatch.every((x) => x.ok), detail: redispatch }, + "4 每个阶段都关上了,每人每阶段只有一份最终收下的提交": { + pass: perStage.every((s) => s.closed && Object.values(s.finalPerPerson).every((n) => n === 1)), + detail: perStage, + }, + "5 分配照常写出": { pass: assignment !== null, assignment, sameAsReference: assignment !== null && sameAssignment(assignment, script.expected) }, +}; +const report = { point, triggeredAt: triggered, lastRecordAtKill: atKill.at(-1), outcomeAfterRestart: second.outcome, checks }; +writeFileSync(join(out, "kill-report.json"), `${JSON.stringify(report, null, 2)}\n`); +console.log(`\n杀点 ${point}:触发时账本最后一条 #${triggered.seq} ${triggered.kind};被杀时最后一条 #${killSeq} ${atKill.at(-1)!.kind} ${JSON.stringify(atKill.at(-1)!.content).slice(0, 200)}`); +console.log(`再启动后的结局:${JSON.stringify(second.outcome)}`); +for (const [k, v] of Object.entries(checks)) console.log(`${v.pass ? "通过" : "不通过"} ${k}`); +console.log(`报告:${join(out, "kill-report.json")}`); diff --git a/eval/round-zero/quality.ts b/eval/round-zero/quality.ts new file mode 100644 index 0000000..7a481a4 --- /dev/null +++ b/eval/round-zero/quality.ts @@ -0,0 +1,148 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ask, THRESHOLD_NOTE, type Answer, type Noul } from "../lib/jev.ts"; +import { entriesIn, entryDecision, readLedger, stages, type Entry } from "../lib/ledger.ts"; +import { ENTRY_ONLY, LEADS_TO_IDEA, SAME_KIND, STOPS_AT_DEAD_END, TASKS } from "./questions.ts"; + +// 第零轮质量评估(任务书 12 第 5.2 节六问)。只读账本,问 Jev,原始概率全部存进 <检验目录>/quality.json。 +// node eval/round-zero/quality.ts <检验目录>(研究在 <检验目录>/study) +// "入口全体"是分配时最终有效的全部入口,"分到的入口"是分配结果里每人分到的那个。 + +type Idea = { id: string; name: string; description: string }; +const ideas = JSON.parse(readFileSync("eval/round-zero/ideas.json", "utf8")) as { key: Idea[]; deadEnds: Idea[] }; + +const out = process.argv[2]; +if (out === undefined) throw new Error("要写检验目录,研究在它下面的 study 里"); +const records = readLedger(join(out, "study")); +const decision = entryDecision(records); +if (decision === undefined) throw new Error("账本里还没有第 0 轮的入口分配决定,第零轮没跑完"); +const round0 = stages(records).filter((s) => s.round === 0); +const final = entriesIn(records, new Set(round0.map((s) => s.stageId))); +const draft = entriesIn(records, new Set(round0.filter((s) => s.stage === "entryDraft").map((s) => s.stageId))); +// 自己按编号规则算出的入口全体,要和 flow 分配时用的那份一致;对不上说明 flow 或这里有一边取错了。 +const decided = decision.input.entries; +if (JSON.stringify(decided.map((e) => e.id).sort()) !== JSON.stringify(final.map((e) => e.id).sort())) { + throw new Error(`分配决定里的入口 ${decided.map((e) => e.id).join("、")} 和从提交算出的入口全体 ${final.map((e) => e.id).join("、")} 对不上`); +} +const assignment = decision.output; +const assigned = assignment.map((a) => final.find((e) => e.id === a.entry)!); + +// --------------------------------------------------------------------------- +// 问 Jev:同样的材料只问一次;每次请求的材料、问题、原始概率都记下 +// --------------------------------------------------------------------------- + +const raw: Array<{ site: string; ids: string[]; answers: Answer[] }> = []; +const cache = new Map>(); +/** 同时最多几个请求在路上:一下发上百个容易被限流。 */ +const PARALLEL = 6; +let running = 0; +const waiting: Array<() => void> = []; +async function limited(fn: () => Promise): Promise { + if (running >= PARALLEL) await new Promise((ok) => waiting.push(ok)); + running++; + try { + return await fn(); + } finally { + running--; + waiting.shift()?.(); + } +} +const show = (e: Entry) => ({ name: e.name, body: e.body }); +/** 同样的材料、同样的问题只问一次(写入口阶段没换的入口在讨论后还是同一段文字)。 */ +function query(site: string, ids: string[], state: Record, questions: Record): Promise { + const key = JSON.stringify([site, state]); + if (!cache.has(key)) { + cache.set(key, limited(() => ask(state, questions)).then((answers) => (raw.push({ site, ids, answers }), answers))); + } + return cache.get(key)!; +} +const yes = async (p: Promise, question: string) => (await p).find((a) => a.question === question)!.yes; + +const ideaState = (e: Entry, idea: Idea) => ({ task: TASKS.idea, entry: show(e), idea: { description: idea.description } }); +const leadsTo = (e: Entry, idea: Idea) => yes(query("leads", [e.id, idea.id], ideaState(e, idea), LEADS_TO_IDEA), "leads_to_idea"); +const staysIn = (e: Entry, idea: Idea) => yes(query("stays", [e.id, idea.id], ideaState(e, idea), STOPS_AT_DEAD_END), "stays_within_idea"); +const entryOnly = (e: Entry, question: string) => yes(query("entry", [e.id], { task: TASKS.entry, entry: show(e) }, ENTRY_ONLY), question); +const sameKind = (a: Entry, b: Entry) => + yes(query("pair", [a.id, b.id], { task: TASKS.pair, entry_a: show(a), entry_b: show(b) }, SAME_KIND), "same_kind_of_change"); + +async function coverage(list: Entry[]): Promise> { + const out: Record = {}; + for (const idea of ideas.key) { + const hits = await Promise.all(list.map((e) => leadsTo(e, idea))); + out[idea.id] = list.filter((_, i) => hits[i]).map((e) => e.id); + } + return out; +} +async function overlap(list: Entry[]): Promise<{ pairs: number; same: string[][]; ratio: number | null }> { + const pairs = list.flatMap((a, i) => list.slice(i + 1).filter((b) => b.author !== a.author).map((b) => [a, b] as const)); + const hits = await Promise.all(pairs.map(([a, b]) => sameKind(a, b))); + const same = pairs.filter((_, i) => hits[i]).map(([a, b]) => [a.id, b.id]); + return { pairs: pairs.length, same, ratio: pairs.length === 0 ? null : same.length / pairs.length }; +} + +// --------------------------------------------------------------------------- +// 六问 +// --------------------------------------------------------------------------- + +const k1 = ideas.key.find((i) => i.id === "K1")!; +const covFinal = await coverage(final); +const q1 = { coverage: covFinal, pass: covFinal["K1"]!.length > 0 }; + +const assignedPairs = assigned.flatMap((a, i) => assigned.slice(i + 1).map((b) => [a, b] as const)); +const pairHits = await Promise.all(assignedPairs.map(([a, b]) => sameKind(a, b))); +const q2 = { same: assignedPairs.filter((_, i) => pairHits[i]).map(([a, b]) => [a.id, b.id]), pass: !pairHits.some(Boolean) }; + +const assignedK1 = await Promise.all(assigned.map((e) => leadsTo(e, k1))); +const deadEnd = await Promise.all( + assigned.map(async (e) => { + const hits = await Promise.all(ideas.deadEnds.map((d) => staysIn(e, d))); + return ideas.deadEnds.filter((_, i) => hits[i]).map((d) => d.id); + }), +); +const q3 = { + k1: assigned.filter((_, i) => assignedK1[i]).map((e) => e.id), + deadEnds: Object.fromEntries(assigned.map((e, i) => [e.id, deadEnd[i]])), + pass: assignedK1.some(Boolean) && deadEnd.every((d) => d.length === 0), +}; + +const perEntry = async (list: Entry[], question: string) => (await Promise.all(list.map((e) => entryOnly(e, question)))); +const changesCode = await perEntry(assigned, "changes_code"); +const q4 = { notCodeRoute: assigned.filter((_, i) => !changesCode[i]).map((e) => e.id), pass: changesCode.every(Boolean) }; + +const writesAnswer = async (list: Entry[]) => { + const [code, score] = [await perEntry(list, "names_code_change"), await perEntry(list, "states_expected_score")]; + return list.filter((_, i) => code[i] || score[i]).map((e) => e.id); +}; +const q5 = { assignedWritingAnswer: await writesAnswer(assigned), allWritingAnswer: await writesAnswer(final) }; +const q5pass = q5.assignedWritingAnswer.length === 0; + +const q6 = { + beforeDiscussion: { overlap: await overlap(draft), coverage: await coverage(draft) }, + afterDiscussion: { overlap: await overlap(final), coverage: covFinal }, +}; + +const report = { + study: join(out, "study"), + thresholdNote: THRESHOLD_NOTE, + entries: final.map((e) => ({ id: e.id, author: e.author, name: e.name })), + draftEntries: draft.map((e) => ({ id: e.id, author: e.author, name: e.name })), + assignment: assignment.map((a, i) => ({ ...a, name: assigned[i]!.name })), + results: { q1, q2, q3, q4, q5: { ...q5, pass: q5pass }, q6 }, + passed: { q1: q1.pass, q2: q2.pass, q3: q3.pass, q4: q4.pass, q5: q5pass }, + raw, +}; +writeFileSync(join(out, "quality.json"), `${JSON.stringify(report, null, 2)}\n`); + +const mark = (b: boolean) => (b ? "通过" : "不通过"); +const ratio = (o: { pairs: number; same: string[][]; ratio: number | null }) => `${o.same.length}/${o.pairs}${o.ratio === null ? "" : `(${(o.ratio * 100).toFixed(0)}%)`}`; +const count = (c: Record) => Object.entries(c).map(([k, v]) => `${k}:${v.length}`).join(" "); +console.log(`(${THRESHOLD_NOTE})`); +console.log(`入口全体:${final.map((e) => `${e.id} ${e.name}`).join("\n ")}`); +console.log(`分到的入口:${report.assignment.map((a) => `${a.researcher} → ${a.entry}(第 ${a.rank} 志愿)${a.name}`).join("\n ")}`); +console.log(`1 方向对不对:${mark(q1.pass)};覆盖 ${count(covFinal)}`); +console.log(`2 分没分开:${mark(q2.pass)};判"是"的对 ${JSON.stringify(q2.same)}`); +console.log(`3 好方向分出去没有:${mark(q3.pass)};指向 K1 的 ${JSON.stringify(q3.k1)},停在死路的 ${JSON.stringify(q3.deadEnds)}`); +console.log(`4 改代码的路线:${mark(q4.pass)};不是的 ${JSON.stringify(q4.notCodeRoute)}`); +console.log(`5 有没有写答案:${mark(q5pass)};分到的里写了的 ${JSON.stringify(q5.assignedWritingAnswer)},入口全体里写了的 ${JSON.stringify(q5.allWritingAnswer)}`); +console.log(`6 讨论有没有用:讨论前重合 ${ratio(q6.beforeDiscussion.overlap)}、覆盖 ${count(q6.beforeDiscussion.coverage)};讨论后重合 ${ratio(q6.afterDiscussion.overlap)}、覆盖 ${count(covFinal)}`); +console.log(`原始概率:${join(out, "quality.json")}`); diff --git a/eval/round-zero/questions.ts b/eval/round-zero/questions.ts new file mode 100644 index 0000000..84e8d46 --- /dev/null +++ b/eval/round-zero/questions.ts @@ -0,0 +1,176 @@ +import type { Noul } from "../lib/jev.ts"; + +// 第零轮质量评估问 Jev 的问题(任务书 12 第 5.2 节"问 Jev 的规矩"):英文、一问只问一件事、 +// 带材料说明、判"是"判"否"的说明和反例;写法照 src/jev/questions.ts。这些是检验自己的问题,不进 src/jev。 +// 任务书里两处一句话里有两个条件(第 3 问"D1 或 D2"、第 5 问"写出改成什么,或者预计分数"),按"一问一事"拆成两问, +// 在 quality.ts 里用"任一判是"合起来。 + +const TASK = + "Round zero of a study that improves a program for the circle packing task: place 26 non-overlapping circles inside the unit square " + + "so that the sum of their radii is as large as possible (no tolerance: any overlap or circle outside the square is invalid). " + + "The baseline program puts the circles on fixed rings around the center and then shrinks radii pair by pair to remove overlaps; " + + "it scores about 0.96 and the best known score is about 2.636. " + + "In round zero each researcher writes entries: starting points for a line of work that one researcher will follow in the next rounds. " + + "An entry has a `name` and a `body` (in Chinese) saying where to start looking and why the route is worth walking. " + + "Entries are meant to name a starting point only, not the finished code change or an expected score."; + +export const TASKS = { + idea: `${TASK} \`idea\` is a technique from the literature on this task, described in English.`, + entry: TASK, + pair: `${TASK} \`entry_a\` and \`entry_b\` were written by two different researchers.`, +}; + +/** 第 1 问:按这个入口走,会直接走到这个想法吗(idea 是 K1–K5 之一)。 */ +export const LEADS_TO_IDEA: Record = { + leads_to_idea: { + type: "noul", + instructions: { + question: "Will a researcher who follows `entry` arrive directly at the technique described in `idea.description`?", + focus: + "Read `entry.body` for what the researcher will look at first and what they intend to try or measure. " + + "Answer yes if the entry names this technique, or if this technique is the natural first code change on the route the entry points to. " + + "The entry may describe the technique in other words, in Chinese. Sharing the goal of a higher score, or reading the same function, is not enough.", + }, + criteria: { + true: { + what: "The entry names this technique, or the route it points to leads straight to this technique as its first code change", + examples: [ + "idea: optimize centers and radii together with a constrained optimizer; entry: 把 26 个圆心和半径一起交给 scipy 的约束优化器", + "idea: solve a linear program for the radii with fixed centers; entry: 先问给定圆心下半径和的最大值怎么算,它是不是一个线性规划", + ], + }, + false: { + what: "The entry heads somewhere else: a different technique, only reading rules, or only measuring, so this technique would not be its next step", + not_for: "Entries that only share the goal of raising the score, or only read the code this technique would change, without heading for the technique", + examples: [ + "idea: optimize centers and radii together with a constrained optimizer; entry: 只读评测脚本,弄清容差和合法化规则", + "idea: solve a linear program for the radii with fixed centers; entry: 把三环布局换成六角网格", + ], + }, + }, + }, +}; + +/** 第 3 问后半:这个入口的路线是不是只停在这条死路上(idea 是 D1 或 D2 之一)。 */ +export const STOPS_AT_DEAD_END: Record = { + stays_within_idea: { + type: "noul", + instructions: { + question: "Does the whole route in `entry` stay within the approach described in `idea.description`, with no plan to go beyond it?", + focus: + "Answer yes only if everything the entry plans to change falls inside this approach. " + + "If the entry also plans a continuous optimization that moves the circle centers, or uses the approach only as a helper step " + + "(a starting layout, or a way to score layouts inside a larger search), answer no.", + }, + criteria: { + true: { + what: "Everything the entry plans to change is inside this approach; it is the whole route", + examples: [ + "idea: hand-designed placement of centers only; entry: 把三环布局换成六角网格,调行数和间距", + "idea: change only the radii with fixed centers; entry: 圆心不动,只改半径的分配方法,这就是整条路线", + ], + }, + false: { + what: "The entry goes beyond this approach, or uses it only as one step of a larger route", + not_for: "Entries that use this approach as a starting layout or as a scoring helper inside a search that moves the centers", + examples: [ + "idea: hand-designed placement of centers only; entry: 从六角起点出发,再用约束优化同时移动圆心和半径", + "idea: change only the radii with fixed centers; entry: 先用精确的半径求解当打分器,再在外层搜索圆心位置", + ], + }, + }, + }, +}; + +/** 第 4、5 问:只看一个入口。第 5 问拆成两问。 */ +export const ENTRY_ONLY: Record = { + changes_code: { + type: "noul", + instructions: { + question: "Is `entry` a route that changes the program's code to raise the score, rather than one that only reads rules, measures, or analyzes?", + focus: + "A route may begin by reading code or measuring something, as long as the reading or measuring is meant to lead to a code change that raises the score. " + + "Answer no only if the entry's purpose ends at understanding, measuring, or checking rules, with no plan to change the code.", + }, + criteria: { + true: { + what: "The entry intends to change the program, possibly after a first step of reading or measuring", + examples: ["先读半径的计算方法,量它离最优差多少,然后把它换掉", "从错排起点出发,跑一次约束优化"], + }, + false: { + what: "The entry only reads rules, measures, or analyzes, and does not plan any change to the code", + not_for: "Entries that begin with a diagnosis but clearly intend to change the code afterwards", + examples: ["只读评测脚本,列出评测接受和拒绝哪些解", "统计基线产物里有多少个圆贴着边,写成一份报告"], + }, + }, + }, + names_code_change: { + type: "noul", + instructions: { + question: "Does `entry` spell out the finished code change to make, such as the exact algorithm with its settings or the replacement code, instead of only a starting point?", + focus: + "Entries are meant to say where to start looking and why. Mentioning a technique as a question to explore, or as a direction to look in, is a starting point. " + + "Stating the change to make with the method and its settings already decided spells out the answer.", + }, + criteria: { + true: { + what: "The entry states what the code should be changed into, with the method and its settings decided", + examples: [ + "把布局函数换成 SLSQP,同时优化 78 个变量,起点用 5 行错排,迭代 500 次", + "半径计算改成用 scipy linprog 求解,约束是 r_i + r_j ≤ d_ij 和到四边的距离", + ], + }, + false: { + what: "The entry names where to look and what to question, leaving the change itself to be worked out", + not_for: "Entries that name a technique only as a question to check or a direction to explore", + examples: ["先读半径的计算方法,问它离给定圆心下的最优半径和差多少", "从基线的布局出发,看哪些圆被挤到了边上"], + }, + }, + }, + states_expected_score: { + type: "noul", + instructions: { + question: "Does `entry` state a score, or an improvement of the score, that following this route is expected to reach?", + focus: + "Look for predictions of the result of this route. Quoting the baseline score, the best known score, or measured facts about the current program is not a prediction.", + }, + criteria: { + true: { + what: "The entry predicts the score or the gain that this route will bring", + examples: ["这条路预计能到 2.6 以上", "改完能提升约 0.5"], + }, + false: { + what: "The entry predicts no score or gain for this route", + not_for: "Quoting the current score, the best known score, or the size of the current gap without predicting what the route reaches", + examples: ["基线现在是 0.96,已知最好是 2.636", "现在离已知最好还差 1.68"], + }, + }, + }, +}; + +/** 第 2、6 问:两位研究者从这两个入口出发,多半会做同一类代码改动吗。 */ +export const SAME_KIND: Record = { + same_kind_of_change: { + type: "noul", + instructions: { + question: "Would two researchers, one starting from `entry_a` and one from `entry_b`, most likely make the same kind of code change?", + focus: + "Imagine each researcher follows their entry and writes one code change. Compare which part of the program they would change and by what technique. " + + "Different wording, a different first file to read, or a different motivation does not make the changes different if both would change the same thing in the same way.", + }, + criteria: { + true: { + what: "Both would most likely change the same part of the program with the same technique", + examples: [ + "a: 用约束优化器同时调整圆心和半径; b: 把 78 个变量一起交给 SLSQP", + "a: 换成六角网格布局; b: 把圆心改成错排的行,调行数", + ], + }, + false: { + what: "They would most likely change different parts of the program, or the same part with a different technique", + not_for: "Entries that merely share the goal of a higher score, or read the same function, but would change different things", + examples: ["a: 圆心固定,用线性规划求半径; b: 用约束优化器同时调整圆心和半径", "a: 换成六角网格布局; b: 只读评测规则,弄清容差"], + }, + }, + }, +}; diff --git a/eval/round-zero/replay-16-real-1.json b/eval/round-zero/replay-16-real-1.json new file mode 100644 index 0000000..e7bf73b --- /dev/null +++ b/eval/round-zero/replay-16-real-1.json @@ -0,0 +1,217 @@ +{ + "source": "/home/iomgaa/rb-acceptance/16-real-1/board.sqlite(第零轮 submissions,created_seq 21–77)", + "models": { + "researcher-3": [ + { + "from": "16-real-1.r0.v1.entry-draft entryDraft seq 21 v1", + "arguments": { + "payload": { + "entries": [ + { + "name": "给定圆心下的最优半径:逐对等比缩放离它有多远", + "body": "从哪里开始看:读冻结基线 initial.py 的 compute_max_radii:半径先被\"圆心到四边的最小距离\"封顶,然后进一段外层 for i/for j 里又嵌了一整份 for i/for j 的重复循环(O(n^4)),每遇到一对冲突就把两个半径按相同比例缩小。从这里问三个问题:同一份圆心,先处理哪一对冲突会不会改变最终半径(把遍历顺序反过来结果一样吗);两个圆心重合的圆(b0 产物里就有一对)在这套缩放下会发生什么;\"给定圆心、半径和最大\"这个量本身怎么算才准——它是不是一个 26 个变量、每对一个约束再加边界约束的线性规划,环境里的 scipy 直接解一次要多久。\n\n为什么值得走:后面所有布局搜索都要拿\"这份圆心能得几分\"当打分器,打分器偏多少决定搜索的地基斜不斜:逐对等比缩放是一个顺序敏感的贪心近似,它和真最优的差不量出来,布局实验量到的改进就分不清是布局变好还是打分器变准。那段嵌套重复循环还是 O(n^4) 的,任何重写前都得先知道它现在占多少时间。别的入口盯布局、盯预算,都默认这一步是准的,只有这条路专门审它。" + }, + { + "name": "基线的 0.96 是从哪来的:读 b0 产物里贴墙的圆心", + "body": "从哪里开始看:读 /home/iomgaa/rb-acceptance/16-real-1/work/runs/b0/solution.json(基线产物),对照 initial.py 里的 np.clip(centers, 0.01, 0.99) 那一行:26 个圆心里有哪些落在 x=0.99/0.01 或 y=0.99/0.01 的边上,它们的半径被\"到边距离\"压到了多少;有没有圆心完全重合的圆对,这对的半径是多少。顺着问:把基线分数按\"贴墙小圆 / 中环八圆 / 中心圆\"拆开各占多少,也就是 0.96 对已知最好 2.636 的亏空里,\"圆心位置烂\"(贴墙、重合)和\"半径分配烂\"各占几成。\n\n为什么值得走:它决定第 1 轮的先后手:先修布局还是先修半径分配,取决于哪一头占的亏空大,猜错就把首轮运行花在不撑分的那半边。这个证据只存在于已跑运行的产物文件里,读代码读不出来——np.clip 那一行看不出它把多少圆心压到了墙上,solution.json 里一眼数得出来;而且\"从产物文件读答案实际长什么样\"这条路,往后每一轮判断一个改动有没有真生效都要走,现在先踩一遍。" + }, + { + "name": "120 秒怎么花:多起点、扰动深度与编译开销的交换曲线", + "body": "从哪里开始看:从 run_packing 的执行路径问起:基线是纯构造、无迭代、无随机,120 秒墙钟基本没用掉;而 harness.py 在 import 候选前就用 RIVALBITS_SEED 把 random 和 numpy 的种子钉死,同一候选反复运行拿到的随机数完全相同,解的多样性只能来自算法自己显式的种子流或结构化起点。再对着 TASK.md 的执行限额问:numba 首次编译的几秒、scipy 每次调用的开销都算进 120 秒和单核,哪些算法族因此根本进不了门;同一份预算花在\"很多起点各评一次\"还是\"少数起点做圆心扰动爬山\",从多大规模起收益开始反转。\n\n为什么值得走:量级参照(已知最好 2.636 对基线 0.96)说明有大分可拿,但\"多花一分搜索换多少分\"在这个任务上还没有任何一次运行的证据,花法选错会把整轮预算烧在没收益的一头。这条入口不问\"单步怎么做对\",问\"预算怎么花才值\"——这个问题只有盯着时限、单核、种子被钉死、编译时间计入这些执行约束才看得见;读求解代码的入口都默认预算随便用,看不到它。" + } + ] + } + } + }, + { + "from": "16-real-1.r0.v1.entry-discuss.1 discussion seq 40 v1", + "arguments": { + "payload": { + "body": "## 撞车地图:9 个入口实际是 4 条路\n\n三份清单摆在一起,重复度比各自闭门写作时以为的高:\n\n- **路 A|半径打分器**:r1-e1、r2-e1、我的 e1。三人读同一段 `compute_max_radii`,问同一个问题(顺序贪心离\"给定圆心下的最大半径和\"差多少),参照都是同一个小 LP(26 变量、每对一个线性约束)。\n- **路 B|产物读证 / 布局病**:r1-e2、r2-e3、我的 e2。三人都从 b0 的 solution.json 反读:贴墙、重合、零半径。\n- **路 C|运行契约与预算**:r1-e3、我的 e3。都从 harness.py 的时限/种子/容差出发;r1 偏\"别把运行跑废\"(超时、numba 编译、指标键),我偏\"预算怎么花才值\"(多起点 vs 深抛光)。\n- **路 D|评测收下什么**:r2-e2 独有,没人撞。\n\n## A 和 B 是同一条路的两个半边,C 也是\n\n拆 0.96 的亏空必须同时做两件事:读产物圆心(布局半边),再拿**同一组圆心**跑 LP 参照(分配半边)。只走 A 的人要下\"布局占大头\"的结论就得去读 solution.json;只走 B 的人要下\"分配占大头\"的结论就得去解 LP——分开走,两个人最后读同一批文件、写同一张账。所以我把自己的 e1+e2 合并成一条\"差距分解\"路(新清单 e4),建议这条路人一个坑位就够。r2-e1 里那个独特视角(拿贪心缩放当记分器,比较两个布局时会不会系统性偏向某一类)我并进了 e4,它属于这条路的下游问题。\n\n同理,C 的两个半边(防跑废 / 花法)都从 harness.py 同几行出发,由一人走更划算。\n\n## D 是唯一没人撞的路,也是我最不希望被挤掉的\n\n它完全不在 initial.py 里,只读基线代码的入口天然看不到。它决定两件影响所有人的事:搜索真正该优化的目标(纯 Σr,还是 Σr 减去预期合法化扣分、留多大裕量);以及往后每一轮判\"这段修复代码算点还是算保障\"的标尺——比评测更严、做在修正之前的检查会挡掉评测本来会收的答案,这个坑第 1 轮写圆心扰动和可行性判断时一定踩到。b0 本身就是活证据:零半径、重合圆心评测照收照计分。r2-e2 写得最细,我希望 researcher-2 继续走这条。\n\n## 一个核实\n\nr2-e3 说\"编号 25 从未被构造代码摆过\",我重读了 initial.py:三个循环填 centers[0]、centers[1..8]、centers[9..24],1+8+16=25 个位置,centers[25] 留在 np.zeros 初值 (0,0)、被 clip 推到 (0.01,0.01) 与编号 19 重合——属实。这是我写入口时没追到的一层(我只记到\"有一对重合\")。顺带确认了零半径的机制:compute_max_radii 对重合对算出 scale=0/(r_i+r_j)=0,两半径一起归零。产物读证路的增量是真实的:同一份文件还能读出更深的东西。\n\n## 我自己想走哪条\n\n我想走合并后的差距分解路(新 e4)。理由:(1) 产物我已经读了一遍,LP 参照是自然延伸;(2) 我押布局占亏空大头、圆心不动只换 LP 打分器大概率涨不过 0.01 的采纳线——走这条路正好把这个押注变成第 1 轮可检验的东西:若 LP 单独(圆心不动)就过线,我改先修打分器;(3) LP 打分器做出来本身就是第 1 轮\"结构化起点 + 精确打分 + 圆心扰动\"这条最可能兑现的链的地基,谁走布局路都要用它。\n\n第 2 志愿是 e5(预算形状),但它和 r1-e3 是同一条路的两个半边,谁认领都行;我更希望 researcher-1 走 C,因为他三条里真正没和我重的只有 e3。\n\n总结成一张认领地图:4 条路 3 个人,最亏的分配是三人挤在 A/B 上而 C 没人走;我建议一人走 A+B(我,e4)、一人走 D(r2-e2)、一人走 C(r1-e3,可带上我的 e5 合并走)。这是建议不是共识——入口好不好由实测说话,谁不服照自己的判断排志愿。", + "entries": [ + { + "name": "差距分解:同一组圆心下 LP 最优对贪心缩放,加上产物圆心读证,把 0.96 的亏空拆成“布局”和“分配”两半", + "body": "从哪里开始看:两份材料对照读:b0 的 solution.json(26 个圆心里哪些坐标贴在 0.01/0.99 上、哪对重合、编号 25 的来历)和 initial.py 的 construct_packing(三个循环只填索引 0–24,centers[25] 留在 (0,0) 被 clip 推到 (0.01,0.01) 与编号 19 重合;重合对在 compute_max_radii 里 scale=0,两半径一起归零)。再用这同一组圆心跑两种打分:基线的 compute_max_radii(半径只减不增、处理顺序敏感、外层循环体内嵌一整份逐对重扫)和 scipy 的线性规划(26 个变量 r,约束 r_i+r_j≤d_ij、r_i≤到四边距离、r≥0,最大化 Σr)。从这里问:0.96 到 2.636 的缺口里“圆心位置烂”和“半径分配次优”各占多少;LP 解一次要多久、能不能进搜索内环;拿贪心缩放当记分器会不会系统性偏向某一类布局。\n\n为什么值得走:第 1 轮先修布局还是先修分配,取决于哪头占亏空大,猜错就把首轮运行花在不撑分的那半边;LP 参照可精确复算,是这题里少有的干净的一格证据。LP 打分器本身是所有布局搜索的地基:打分器偏多少决定搜索的地基斜不斜,它不量出来,布局实验量到的改进就分不清是布局变好还是打分变准。产物读证那半只存在于已跑运行的产物文件里——代码里读不出 np.clip 把多少圆心压到了墙上;而且“从产物文件读答案实际长什么样”这条路,往后每一轮判断改动有没有真生效都要走,现在先踩一遍。" + }, + { + "name": "120 秒预算的形状:多起点浅评 vs 少起点深抛光,编译开销和被钉死的种子", + "body": "从哪里开始看:从 run_packing 的执行路径问起:基线纯构造无迭代,120 秒基本没用掉;harness.py 在 import 候选前就用 RIVALBITS_SEED 把 random/numpy 种子钉死,同一候选反复运行拿到的随机数完全相同,解的多样性只能来自算法自己显式的种子流或结构化起点。对着 TASK.md 的执行限额问:numba 首次编译的几秒、scipy 每次调用开销都算进 120 秒和单核,哪些算法族因此进不了门;同一份预算花在“很多起点各评一次”还是“少数起点做圆心扰动爬山”,从多大规模起收益开始反转。\n\n为什么值得走:量级参照(已知最好 2.636 对基线 0.96)说明有大分可拿,但“多花一分搜索换多少分”在这个任务上还没有任何一次运行的证据,花法选错会把整轮预算烧在没收益的一头。这个问题只有盯着时限、单核、种子钉死、编译计入这些执行约束才看得见;读求解代码的入口默认预算随便用,看不到它。与 r1-e3 是同一条路的两个半边(他防跑废、我问花法),合并走更划算。" + } + ] + } + } + }, + { + "from": "16-real-1.r0.v1.entry-discuss.2 discussion seq 61 v1", + "arguments": { + "payload": { + "body": "## 三个首志愿互不撞,分工已经自然解开\n\nr1 首志愿 r1-e2(布局构造+搜索)、r2 首志愿 r2-e2(评测规则)、我的差距分解路没人排。三人三坑,不需要说服谁让位。这一轮我只做三件事:标清我的路和 r1 的路之间的缝,撤掉我的 e5(理由在下面),把 r2 点出的连续优化空位立成可认领的坑。\n\n## 差距分解(原 e4,编号前进为 e6,内容一字未动)和 r1-e2 的缝\n\nr1 说“拆账是半天量级、不该占一个人整轮”——那个数字确实快,但我这条路的持久产出是三样,都不是一次性的:\n\n1. **能进搜索内环的打分器**。LP 单次求解多快、能不能当内环记分器,只能在正式运行里量(候选自己计时写成指标),读代码读不出来。\n2. **贪心当记分器的系统偏差**(r2-e1 的下游问题,我已并进这条路):它斜了,r1 搜索量到的改进就分不清是布局变好还是分算得准。\n3. **一个只动分配层的探针提案**:construct_packing 一字不动,只把 compute_max_radii 换成 LP。这一次运行的实测分差就是“分配层独自撑多少”的干净一格证据,顺带量出 LP 计时。我押它过不了 0.01 的采纳线:两头都被圆心钉死——15 个贴墙圆的半径上界就是到墙距离,两个零被重合圆心(d=0 ⇒ r_i+r_j≤0)钉死,LP 只能在内侧 9 个圆里重分配。若实测过线,我的“布局占大头”就错了,第 1 轮优先级该翻——这正是这条路的可证伪内容。\n\n所以第 1 轮提案空间建议这么切:**布局族、构造、量级锚归 r1**(量级锚当他的第一次运行,纯结构化摆法、只动 construct_packing,给所有人估范围一个公共锚);**打分器与分配探针归我**。r1 提案里要用 LP 打分器尽管自己写——scipy 十行的事;要避免的只是两个几乎相同的候选各烧一次运行名额。\n\n## e5 撤销:花法不是一条路,是搜索提案里的一个点\n\nr1 说交换曲线没有可跑的搜索就测不出、r2 说它天然属于布局路的提案设计,我被说服:**多起点 vs 深挖应该设计成第 1 轮搜索提案里两个可分辨的点,让消融去裁决**,而不是一条第 1 轮空转的路。e5 的几个观察作为 checklist 留在这里,免得随编号蒸发(前两条我自己读到过,后两条来自 r1-e3 的读路径、未复核):\n\n- 种子在 import 候选前被 RIVALBITS_SEED 钉死,任何用随机性的提案必须自带显式种子流或结构化起点,否则同一候选反复运行完全相同;\n- numba 首次编译秒数算进 120 秒墙钟、单核不加速,内层选 numba 还是向量化 numpy 按量到的数定,不按习惯定;\n- reported_sum 非有限会让整份 solution.json 写不出来;分数来自校验器的重算,不看候选自己报的数;\n- 指标键按规范写,丢的是整轮诊断。\n\n## r2 点出的空位,我立成 e7 并排第二志愿\n\n构造式 vs 连续优化式确实是九个入口集体漏掉的武器族之分。我第 1 轮不改主张(构造式+精确打分更可控、合法性好维护,固定圆心的 LP 本来就是“把圆心放开”前的特例),但这条路该作为可认领的坑存在——r1、r2 的清单里都没有,所以加进我的新清单:把 (x,y,r)×26 一起当变量、非凸成对约束交给 scipy 约束局部优化的那条。认领时我排差距分解第一、连续优化第二(照各自判断排,不必齐)。\n\n## 两句收尾\n\n- 三方各自独立读出的产物事实一致(15 个 0.01 + 2 个 0、编号 25 从未被摆、嵌套循环 325 遍只有第一遍起作用),可以放心当公共地基用。\n- r2 那条“各减自己参与的最大违反量、无回补、总扣可超 δ”(未运行验证)正落在 D 路的核心问题上:搜索把可行性当硬约束还是留微违反给合法化、留多大裕量——这决定每个提案目标函数的口径,等他的产出。上界侧不立,同意 r1:它只能靠手推数值,正是我们被要求停下的活,2.636 已经给了量级。", + "entries": [ + { + "name": "差距分解:同一组圆心下 LP 最优对贪心缩放,加上产物圆心读证,把 0.96 的亏空拆成“布局”和“分配”两半", + "body": "从哪里开始看:两份材料对照读:b0 的 solution.json(26 个圆心里哪些坐标贴在 0.01/0.99 上、哪对重合、编号 25 的来历)和 initial.py 的 construct_packing(三个循环只填索引 0–24,centers[25] 留在 (0,0) 被 clip 推到 (0.01,0.01) 与编号 19 重合;重合对在 compute_max_radii 里 scale=0,两半径一起归零)。再用这同一组圆心跑两种打分:基线的 compute_max_radii(半径只减不增、处理顺序敏感、外层循环体内嵌一整份逐对重扫)和 scipy 的线性规划(26 个变量 r,约束 r_i+r_j≤d_ij、r_i≤到四边距离、r≥0,最大化 Σr)。从这里问:0.96 到 2.636 的缺口里“圆心位置烂”和“半径分配次优”各占多少;LP 解一次要多久、能不能进搜索内环;拿贪心缩放当记分器会不会系统性偏向某一类布局。\n\n为什么值得走:第 1 轮先修布局还是先修分配,取决于哪头占亏空大,猜错就把首轮运行花在不撑分的那半边;LP 参照可精确复算,是这题里少有的干净的一格证据。LP 打分器本身是所有布局搜索的地基:打分器偏多少决定搜索的地基斜不斜,它不量出来,布局实验量到的改进就分不清是布局变好还是打分变准。产物读证那半只存在于已跑运行的产物文件里——代码里读不出 np.clip 把多少圆心压到了墙上;而且“从产物文件读答案实际长什么样”这条路,往后每一轮判断改动有没有真生效都要走,现在先踩一遍。" + }, + { + "name": "把圆心和半径一起当变量:非凸连续优化在这道题里能走多远", + "body": "从哪里开始看:从 TASK.md 的库清单与执行限额、initial.py 的输出接口问起:26 个圆 = 78 个变量(x_i, y_i, r_i),约束 r_i ≥ 0、r_i ≤ x_i ≤ 1−r_i(y 同),加 325 条非凸的 (x_i−x_j)²+(y_i−y_j)² ≥ (r_i+r_j)²,目标 max Σr,交给 scipy 的约束局部优化(SLSQP / trust-constr)。先问三个问题:从 b0 那组圆心(现有半径已可行)出发做局部优化,第一步会把哪些圆往哪边推、贴墙的圆会不会被推离墙;非凸约束下局部解会不会停在不可行点、可行性要不要自己再修;78 个变量、325 条成对约束加逐圆边界约束的规模,在单核 120 秒(scipy 每次调用开销在内)里容得下几个起点、每起点几步。\n\n为什么值得走:九个入口全部偏构造式(先摆圆心、再算半径),没有一个以“圆心半径一起当变量直接优化”为主视角——这是 r2 在讨论里点出的空位,r1、r2 的清单都没立它。这条路能看到构造式看不到的东西:半径与圆心的交换在一次优化里同时发生、局部最优的盆地有多窄(决定该多起点还是深挖)、梯度会不会自动把圆推离墙壁。它和打分器路天然衔接——固定圆心的 LP 正是它把圆心放开前的特例——走这条路的人站在差距分解路的正下游。" + } + ] + } + } + }, + { + "from": "16-real-1.r0.v1.entry-claim claim seq 74 v1", + "arguments": { + "payload": { + "choices": [ + "e-researcher-3-6", + "e-researcher-3-7", + "e-researcher-2-1" + ] + } + } + } + ], + "researcher-1": [ + { + "from": "16-real-1.r0.v1.entry-draft entryDraft seq 24 v1", + "arguments": { + "payload": { + "entries": [ + { + "name": "半径分配层:compute_max_radii 的逐对等比缩放,离\"给定圆心下的最大半径和\"差多少", + "body": "从哪里开始看:读 initial.py 的 compute_max_radii:半径初值取到各边界的距离,之后每遇到一对违反的圆就按 dist/(r_i+r_j) 把两个半径同时等比缩小。注意两个代码事实:循环体内部还嵌着一份对全部点对的完整重扫(内层把外层的 i、j 覆盖掉了);半径从头到尾只减不增,结果依赖点对处理顺序。第一步拿 b0 运行产物 solution.json 里那 26 个圆心当现成测试数据,问:同一组圆心下,\"最大半径和\"这个参照(约束 r_i+r_j≤d_ij、r_i≤到边距离、r≥0,最大化 Σr——是线性规划,环境里有 scipy)比逐对缩出来的结果高多少。\n\n为什么值得走:这是\"圆心→分数\"的唯一接口,任何改布局的改法最后都经它打分;而这个参照值可以精确求解、可以复算,所以这条路能先拿到一个干净的差距分解(布局亏多少、半径分配亏多少)。只盯布局的入口会把它当成既定的打分器,量不出这一层的损失。" + }, + { + "name": "布局层:三环构造加边界裁剪,塌成 17 个 0.01 半径的边界圆", + "body": "从哪里开始看:读 initial.py 的 construct_packing 和 b0 的 solution.json:半径 0.7 的外环大半落在正方形外面,被 np.clip(centers, 0.01, 0.99) 抹到边界上;产物里 17 个圆的半径停在 0.01,两个圆心重合在 (0.01, 0.01)、半径缩成 0,只有 9 个内圈圆在 0.055–0.13。先问这笔账怎么拆:基线 0.96 到量级参照 2.636 的缺口里,多少来自这组圆心本身、多少来自半径分配(同一组圆心配上最优分配就能拆开,见我另一条入口);再问什么样的初始化布局族(均匀铺开、大小分级、贴边贴角)和什么样的局部移动/多起点搜索,在单核 120 秒里装得下。\n\n为什么值得走:缺口的大头几乎必然在圆心布置而不在数值零头,这条路口子最宽:初始化、局部移动、搜索策略、可行性维护都在里面。把打分器当既定的入口不会碰这个问题,而它才是\"分数从哪里来\"的主战场。" + }, + { + "name": "运行契约层:120 秒墙钟、容差 0、冻结种子——分数和运行名额在接口处怎么丢", + "body": "从哪里开始看:读 harness.py 的 main()(按 RIVALBITS_SEED 播种 random/numpy 之后才 import 候选、只调用一次 run_packing、原子落盘;reported_sum 非有限数会让整份 solution.json 写不出来)和 validate.py 的 legalize_packing(成绩=math.fsum(radii),不看候选报的数;容差 0 下违反量必须严格大于 0 才算违规,差一点不合法只按违反量缩半径、扣分在 1e-16 量级)。先问三件事:numba 首次编译时间算进墙钟、到点强杀且这次运行没有成绩,内层循环选 numba 还是向量化 numpy/scipy,固定开销和每次迭代各吃预算多少;精确相切在浮点下会不会翻成 1e-17 级的违反、留多小的裕量是免费的;冻结种子下随机多起点可不可复现。\n\n为什么值得走:超时白扣一次运行名额、numba 编译吃掉大半预算、指标键写错丢掉整轮诊断——这三种失败都不在求解器里,只在契约里。盯布局和算法的入口默认契约没问题;没人走这条路的话,这些坑要等跑废一次正式运行才会被发现。" + } + ] + } + } + }, + { + "from": "16-real-1.r0.v1.entry-discuss.1 discussion seq 44 v1", + "arguments": { + "payload": { + "body": "**格局:9 个入口其实只有 3 条路。** 三份清单叠起来看,每人在同三层上各站了一个入口:半径分配层(1-e1 / 2-e1 / 3-e1)、布局层(1-e2 / 2-e3 / 3-e2)、契约层(1-e3 / 2-e2 / 3-e3)。三人 × 三路意味着每条路最多需要一个人,我建议认领按路分、不按入口分。\n\n**先报几个我在写讨论前核实的代码事实**(重读了 initial.py 和 b0/solution.json):\n\n- r2-e3 说\"编号 25 从未被摆过\"——属实。construct_packing 只填了索引 0(中心)+ 1–8(内环)+ 9–24(外环),索引 25 停在 np.zeros 的 (0,0),被 clip 推到 (0.01,0.01) 与角圆 19 重合;这对 dist=0,等比缩放把两个半径都乘到 0,产物里 radii[19]=radii[25]=0.0。\n- b0 的半径账(从 solution.json 直接数,不是推算):15 个 0.01 + 2 个 0——这 17 个全是外环圆和那个没摆的圆;9 个内圈圆 0.055–0.130,扛了剩下的 ~0.81。0.96 对 2.636 的缺口 1.68,和这 17 个圆现在几乎不贡献分是同一量级的事。\n- 嵌套循环确认:外层 for i/for j 的循环体里内嵌一份完整 for i/for j,内层变量遮蔽外层,外层算的 dist 从未被使用;半径只减不增,第一遍内层全扫后已无违反,余下 324 遍是空转。325×325 次对检查对 26 个圆是毫秒级——这层的痛点是准确性(顺序敏感的贪心),不是性能。\n\n**逐路说。**\n\n*分配层(三人同写,无差异点)*:三个入口的起点、要问的问题、参照物(26 变量 LP:r_i+r_j≤d_ij、r_i≤到边距离、max Σr)完全一致;我的差异点(嵌套空转、只减不增、顺序依赖)另外两位也都看到了。这条路的产出是两样公共品:\"贪心缩放离 LP 最优差多少\"这个数,和一个可复用的 LP 打分器——它决定第 1 轮先修布局还是先修分配,也决定布局搜索拿什么记分(2-e1 问的\"记分器会不会系统性偏袒某类布局\",本身就是布局搜索的设计问题)。工作量小,建议一人走、速战,产出直接并入布局搜索。我没有别人看不到的视角,主动让出这条。\n\n*布局层(主战场,三人同写)*:口子最宽,要拆成两件事。一是\"读账\":缺口怎么拆、废圆在哪——b0 产物加一次 LP 对照就能出结论,半天量级,不该占一个人整轮。二是\"构造+搜索\":什么布局族、什么局部移动在 120 秒单核里装得下——这才是提案的主体。r2-e3 把\"量级锚\"当子问题带了一句,我认为它值得明确成走这条路的人的第一步:一个不搜索的纯结构化摆法(26 圆按六角/网格平铺)处在什么量级,一次正式运行就能回答,给所有人估范围一个公共参照,而且它本身就是最便宜的第 1 轮提案候选(只动 construct_packing,不动 compute_max_radii)。\"编号 25 未摆\"这个事实意味着哪怕只把一个圆摆进空处都是白捡的分(多少由实验说),走这条路的人第一步几乎不会空手。\n\n*契约层(三入口侧重不同)*:1-e3 偏\"名额与诊断怎么不丢\"(超时强杀无成绩、numba 编译进墙钟、metrics 键写错丢诊断、reported_sum 非有限写不出文件);2-e2 偏\"评测收下什么、搜索该优化什么目标、点/保障的标尺\";3-e3 偏\"预算花法交换曲线\"。前两块是一次性读完、写进所有人协议的注意事项;其中 2-e2 的两个问题——搜索目标是纯半径和还是半径和减预期合法化扣分、判一段代码算点还是算保障的标尺在评测规则里——是这里面最有长期价值的视角,建议走契约层的人以它为核心,把 1-e3 的坑清单当交付物顺手带上。3-e3 的交换曲线没有代码可读:多起点对爬山从多大规模起收益反转,只能靠第 1 轮之后的实测回答,第零轮走它会空转,建议留到有实测后并入布局层的提案设计;但它指出的\"冻结种子下解的多样性只能来自算法自己的种子流\"这个约束,该写进每一个用随机性的提案协议。\n\n**没人站但值得走的路**:我看了一圈,真正缺位的是把\"量级锚\"明确当一条路——上面说了,建议并进布局层当第一步,不必新开。另一条我想过的是上界侧(2.636 离最优上界还有多远),但在没有运行环境时它只能靠手推数值,正是我们被要求停下的那种活,不立。\n\n**我的志愿**:布局层(researcher-1-e2)> 契约层(researcher-1-e3)> 分配层(让出)。理由:缺口大头在圆心布置,产物里数得出来;LP 打分器我写入口时就当作布局层的配套,两件事天然一起做;第 1 轮的大提案最可能从这条路出。若分到契约层,我会以 2-e2 的评测目标与点/保障标尺为核心,把超时、编译、诊断键这些失败模式整理成 checklist 交给大家;若分到分配层,我第一步就把贪心 vs LP 的差距和 LP 打分器做出来给大家共用。" + } + } + }, + { + "from": "16-real-1.r0.v1.entry-discuss.2 discussion seq 58 v1", + "arguments": { + "payload": { + "body": "**这一轮我换清单,并且不去契约层——和 r3 的建议有一处正面分歧。**\n\n三份第一志愿已经错开:我走布局、r2 走评测规则、r3 走差距分解。这个大分配我支持。分歧在 r3 建议我走 C(契约层)、认为\"C 没人走最亏\"。我把 C 的内容全部拆开看归属,结论相反:**C 拆完之后没有需要一个人整轮专走的活,而布局的\"构造+搜索\"执行段反而没人整轮走。**\n\n契约层拆账:\n- 超时强杀无成绩、numba 编译进墙钟、metrics 键写错丢诊断、reported_sum 非有限写不出文件——写协议时的自查项,一页 checklist,见下,我直接交出来,不需要入口承载。\n- 容差 0 下留多大裕量、搜索目标算纯 Σr 还是 Σr 减预期合法化扣分——归 r2-e2,他已经在问。\n- 预算形状(多起点浅评 vs 少起点深抛光、编译开销换迭代)——有搜索可跑才测得出,是布局路的实验设计问题,r3-e5 的问题清单并进去即可。\n\n反过来数布局路的工作量:缺口 1.68 里 17 个废圆只出 0.15、9 个内圈圆扛 ~0.81(b0/solution.json 直接数出);r3-e4 做到\"拆账 + LP 打分器(含标定)\"为止,打分器之后的大活——布局族构造、局部移动、多起点框架——现在没有任何一条有效入口以它为主体。三人同押布局占大头,却把执行段空着,不合理。r3-e4 的下游和我的上游是接力不是撞车:他交打分器,我的搜索拿它记分;他没交之前,量级锚只动 construct_packing、不动 compute_max_radii,第 1 轮第一天两边并行、互不相等。\n\n**契约 checklist(兑现上面的\"分发\",谁写协议都请带上):**\n1. 墙钟从 import 起算、到点强杀且这次运行无成绩——协议里留自查余量,别贴 120 跑。\n2. numba 首次编译计入墙钟——先写向量化 numpy/scipy 版,要上 numba 先量过向量化版的开销再决定。\n3. reported_sum 非有限会让整份 solution.json 写不出——落盘前过滤。\n4. metrics 键名照任务说明全文写,写错丢整轮诊断。\n5. harness 在 import 候选前就钉死 random/numpy 种子——用随机性的算法必须自带显式种子流,否则\"多起点\"是同一个起点跑 N 遍。\n6. 容差 0 下违反量严格大于 0 才算违规——留多大裕量、目标口径,等 r2-e2 的结论,别各自拍。\n\n**我的新清单(旧 e1、e3 作废)。** e1 三人同路、我无差异点,r3 的 e4 连读账半边都并走了,第一轮我就让出,现在正式作废;e3 的内容按上面拆账分发,作废。新立两条:e4 布局构造+搜索(第一志愿)、e5 连续优化式布局——r2 指出的空位,我立成正式入口。他建议\"不必新开、第 1 轮自然会出现\",我立户的理由是认领即第 1 轮起点:立了户,这个视角才有人从第一天带着走;不立户,它只是讨论里的一句提醒,最容易被构造式惯性和 LP 打分器的方便吃掉。e4/e5 若落在同一个人手里可以合流成一条提案(构造起点 + NLP 抛光),但作为起点它们读的代码、问的问题不同,分开立。\n\n两处小回应:\n- r2、r3、我都押\"布局占亏空大头\"——提醒一句:这是同一份 solution.json 数出来的同感,不是三份独立证据。真正的裁决是 r3-e4 的拆账(同圆心 LP 对贪心)和我的量级锚,都落在第 1 轮。r3 那个\"若 LP 单独(圆心不动)就过采纳线,我改先修打分器\"的可检验押注很好,照此执行。\n- r3 对编号 25 未摆、scale=0 双归零的重核实与我的读法一致,两轮互证,这条事实链(三循环只填 0–24 → clip 推到角点与 19 重合 → dist=0 双归零)可以当公共事实用了。\n\n**志愿:researcher-1-e4 > researcher-1-e5 > researcher-3-e5。** 前两条是我的新清单;布局两族若都轮不到我,预算形状是旧契约路剩下的活半边,我从 checklist + 编译计时入手,把它并进第一次有搜索可跑的实验设计。我不再把 r2-e2 列 fallback(第一轮说过若分到契约层以它为核心)——r2 自己要走这条路,我不悬占位。", + "entries": [ + { + "name": "布局构造+搜索:从 17 个废圆的账面出发,布局族、局部移动、多起点在单核 120 秒里的取舍", + "body": "从哪里开始看:从 b0/solution.json 和 initial.py 的 construct_packing 对照读起:三个循环只填索引 0–24,外环 16 个圆心摆在半径 0.7 的圆上被 clip 钉到边界,编号 25 从未被摆过——17 个圆的半径停在 0.01 或 0(产物里直接数得出)。第一步跑量级锚:一个不搜索的纯结构化摆法(六角/网格/大小分级,只动 construct_packing、不动 compute_max_radii)处在什么量级,一次正式运行回答,给所有人估范围一个公共参照。然后问正题:什么样的局部移动(挪圆心进空处、成对交换、半径感知挤压)和多起点结构在 120 秒单核里装得下;用随机性必须自带显式种子流(harness 在 import 前钉死了 random/numpy 种子)。记分器优先用 LP(researcher-3 的 e4 交付),到手前先用现有贪心缩放。\n\n为什么值得走:缺口 1.68 的大头在这 17 个几乎不贡献分的圆上(三人从同一份产物数出),但构造+搜索这段现在没有任何一条有效入口以它为主体:r3-e4 到打分器和拆账为止,r2 走评测规则。这条路的产出直接是第 1 轮最可能过采纳线的提案——量级锚本身就是最便宜的第一个候选,编号 25 连摆都没摆、贡献 0,是最显眼的单点改动入口(收益多少由实验说)。契约层的坑(超时强杀、numba 编译、种子、metrics 键)最先在这条路上踩到,走的人顺手维护 checklist 交给大家。" + }, + { + "name": "连续优化式布局:圆心和半径一起当变量进 scipy 约束求解器,非凸靠多起点", + "body": "从哪里开始看:从把问题直译成 NLP 开始:变量 (cx_i, cy_i, r_i) 共 78 维,约束是每圆四条边界线性约束加 325 对 (cx_i−cx_j)²+(cy_i−cy_j)²≥(r_i+r_j)² 的非凸约束,目标 max Σr_i,环境里有 scipy.optimize 的 SLSQP/trust-constr。先问:约束和梯度向量化后一次求解占多少墙钟、120 秒能跑几个起点;从三环构造或六角平铺的解 warm start 会不会立刻掉进 b0 那类局部最优、要不要配随机重启;它和构造式怎么分工——构造式起点当 NLP 的初值是一条自然的链。\n\n为什么值得走:r2 在第一轮讨论指出的空位:布局侧现有入口全是构造式,没人以连续直解为主视角。这个武器族在单核 120 秒上的真实成绩和失效模式(太慢、掉局部最优、约束规模)在这道题上还没有任何一次运行的证据,只有走过的人能给答案;若它可用,它与 r3-e4 的 LP 打分器、构造式起点正好拼成\"起点—抛光—记分\"三件套。认领即第 1 轮起点,立成入口这个视角才有人从第一天带着走。" + } + ] + } + } + }, + { + "from": "16-real-1.r0.v1.entry-claim claim seq 72 v1", + "arguments": { + "payload": { + "choices": [ + "e-researcher-1-4", + "e-researcher-1-5", + "e-researcher-2-3", + "e-researcher-3-7", + "e-researcher-2-1" + ] + } + } + } + ], + "researcher-2": [ + { + "from": "16-real-1.r0.v1.entry-draft entryDraft seq 26 v1", + "arguments": { + "payload": { + "entries": [ + { + "name": "半径分配先问\"同样圆心下最多是多少\":compute_max_radii 是顺序贪心,不是最优", + "body": "从哪里开始看:读 initial.py 的 compute_max_radii。注意外层逐对循环的循环体里又嵌了一整遍逐对循环(内层的 i、j 遮蔽外层同名变量),全段实际把所有圆对扫描 325 遍;而半径全程只减不增,第一遍之后已无违反,后面 324 遍是空转。先问:这种\"违反时把两个半径按当前比例缩小\"的顺序分配,给出的半径和离\"同样这组圆心下真正可行的最大半径和\"差多少、差落在哪些圆上(是大的被削还是小的被饿死)。\n\n为什么值得走:这是分数被直接算出来的地方,所有对布局的改动最后都经过它。\"固定圆心下的最大半径和\"有可精确求解的参照(26 个变量,贴边与成对不重叠都是线性约束,环境里有 scipy),偏差是可以量出来的一格证据,不是品味之争。它同时回答一个搜索设计问题:任何布局搜索都需要一个给布局打分的函数,若拿这个贪心缩放当记分器,比较两个布局时会不会系统性偏向某一类布局。只从布局侧或评测规则侧入手的入口量不到这层偏差。" + }, + { + "name": "先读评测会收下什么:容差 0、只缩不移合法化、零半径与重合圆心都合法", + "body": "从哪里开始看:从 TASK.md \"评测会收下什么\"一节和 validate.py 的 legalize_packing 读起。收下次序是:形状/有限/非负/圆心在正方形内 → 原产物按容差 0 判 → 不合法时只缩半径、不挪圆心,且只缩参与违反的圆、每个圆减它参与的最大违反量,修不好才不收。先问三个问题:一对重叠量为 δ 的圆,这套规则最后从半径和里扣掉的是 δ 还是更多(两个圆各减 δ 之后没有回补步骤);搜索过程中可行性要不要当硬约束,还是可以留着微小违反交给合法化、把裕量大小当成搜索的一个设定;候选代码里比评测更严的检查(例如自己按 0 容差把\"差一点\"的起点整个丢掉或换掉)挡掉的会不会正是评测本来会收的产物。\n\n为什么值得走:这一层完全不在 initial.py 里,只读基线代码的入口看不到它。它决定搜索真正该优化的目标(纯半径和,还是半径和减去预期的合法化扣分),也决定\"留多大裕量\"这类设定的方向;往后判一段修复代码算点还是算保障,标尺也在这里。b0 本身就是活证据:两个圆心重合在 (0.01, 0.01)、两个半径被缩成 0,评测照收照计分——零半径、重合圆心都是评测会收的产物。" + }, + { + "name": "从 b0 的解反读布局的病:外环被 clip 钉死在边上,还有一个圆从未被摆过", + "body": "从哪里开始看:对照读 construct_packing 与 runs/b0/solution.json。外环 16 个圆心摆在半径 0.7 的圆上,越界部分被 np.clip(centers, 0.01, 0.99) 直接钉到边界;对照解文件:这 16 个圆心每个都至少有一个坐标贴在 0.01 或 0.99 上,其中编号 19 落在角点 (0.01, 0.01);而编号 25 的圆从未被构造代码摆过(三个循环只填了编号 0–24),留在原点、被 clip 推到 (0.01, 0.01),恰好与编号 19 重合。结果 17 个圆的半径停在 0.01 或 0(15 个贴地板、2 个为 0),全部分数由内侧 9 个圆扛。先问:0.96 到 2.636 的差距里,多少来自圆心本身被浪费(重合、贴边、内外环密度失衡),多少来自半径分配的次优;再问一个量级锚问题——一个不搜索的纯结构化摆法(例如把 26 个圆按网格或六角平铺开)大约处在什么量级。\n\n为什么值得走:另两条路读的是算法和评测规则,这条路读的是解本身:分数具体丢在哪些圆上,只有从这里看得到。它决定第 1 轮该先动布局还是先动分配——如果大头在圆心被浪费,先修分配只是把同一个坏布局算得更准。量级锚的问题一次正式运行就能回答,给后面所有人估范围一个公共参照,不靠谁脑算。" + } + ] + } + } + }, + { + "from": "16-real-1.r0.v1.entry-discuss.1 discussion seq 38 v1", + "arguments": { + "payload": { + "body": "## 重叠地图:九个入口实际是四条路\n\n**同路一:分配层参照——r1-e1、r2-e1、r3-e1 是同一条路。** 三人问的是同一个问题:同一组圆心下,compute_max_radii 的逐对等比缩放离\"真正的最大半径和\"差多少。三个版本各带一个侧窗:r1 的\"拿 b0 圆心当现成测试数据\"、r3 的\"顺序敏感性+重合对退化+LP 计时\"、我的\"325 遍扫描只有第一遍起作用+当记分器用时的系统偏差\"。这些侧窗一次走访就能全带上,不值得三个人各走一遍。建议合并成一条,认领时另外两人挪走。顺带一个读路径就能确认的细节,回应 r3-e1 问的\"重合圆心在这套缩放下会发生什么\":dist=0 时比例因子 dist/(r_i+r_j)=0,两个半径直接乘 0——b0 产物里那对重合圆半径为 0 正是这么来的,答案已经在解文件里。\n\n**同路二:布局与 b0 产物——r1-e2、r2-e3、r3-e2 大幅同路,但内部其实是两段。** 三人都在对照 construct_packing 读 solution.json、都想把 0.96 的亏空在\"圆心烂\"与\"分配烂\"之间拆账。拆账是一次性的诊断,做完不必再占一个人的轮次(走分配参照的人顺手就能做:LP 参照本身就是拆账的另一半)。真正开阔的是后半段——新布局构造、局部移动、多起点搜索,r1-e2 已经在往这个方向问问题了。我的建议:把\"布局+搜索\"当成一条主路,拆账并进去当第一个上午的事。\n\n**部分重叠:契约/预算——r1-e3 与 r3-e3。** 两人都在执行约束层,侧重确实不同:r1-e3 偏\"分数和名额在接口处怎么丢\"(超时强杀、numba 编译吃墙钟、指标键、精确相切的浮点违反),r3-e3 偏\"预算怎么花才值\"(多起点各评一次 vs 少数起点深挖、种子被钉死后多样性从哪来)。我的看法:前者更像开跑前每人自查的清单,不必单独占一个轮次;后者的\"交换曲线\"只有在有搜索可跑时才测得出,天然属于布局+搜索那条路。\n\n## 没人站、但值得有人走的\n\n- **评测规则深读(r2-e2)基本独占。** r1-e3 只盖到\"容差 0、违反量严格大于 0 才算违规\"一角。legalize 的完整后果——一对重叠 δ 的圆各减自己参与的最大违反量、没有回补步骤、总扣可以超过 δ;搜索该优化的目标到底是纯半径和还是半径和减预期合法化扣分;候选代码里比评测更严的检查会不会挡掉评测本来会收的产物——只有这条路在问。它决定第 1 轮每一个提案的目标函数口径,杠杆最高。\n- 布局主战场内部有两个很不一样的武器族:**构造式**(布局族+离散移动)和**连续优化式**(圆心+半径一起当变量丢给 scipy 的约束求解器,非凸、靠多起点弥补局部最优)。现在所有布局侧入口都偏构造式,没有人以连续 NLP 直解为主视角。不必为它新开入口——第 1 轮提案自然会出现——但认领布局路的人值得知道这个空位存在。\n- **量级锚**:一个不搜索的纯结构化平铺摆法值多少分,一次正式运行就能回答,给所有人往后估范围一个公共参照。谁先开跑谁顺手做。\n\n## 我更想走哪条\n\n我想走 **r2-e2(评测规则)**。三个理由:三人里只有我在看它;我已经在这条路上读出了\"无回补、总扣可超 δ\"这个路径结论(读 validate.py 的 legalize_packing 得出,未运行验证过分数影响);它不受任何人挤。如果被分到 e1,我的第一步也是三人共同的那一步——b0 圆心上的 LP 参照对贪心,量化偏差、看它落在哪些圆上;如果被分到 e3,我先拿量级锚,不纠结。\n\n顺带交代我的押注(推测,供分工参考,不供争论):0.96 到 2.636 的差距大头在圆心布局——依据是 b0 有 17 个圆塌在边界或重合、全部分数由内侧 9 个圆扛——半径分配次之。所以按口子宽窄排,布局是主战场;但布局实验要可信,先得有干净的记分器(e1 路)和明确的目标口径(e2 路)。这两条不是次要,是地基。谁对谁错,第 1 轮的实测会裁决。\n\n我的入口清单不改:e1、e3 虽与别人重,但认领靠志愿排,现在抢先换路反而把分工搅乱;我也没有想出一条比现有四条更值得新站的路。" + } + } + }, + { + "from": "16-real-1.r0.v1.entry-discuss.2 discussion seq 55 v1", + "arguments": { + "payload": { + "body": "## 三人的第一志愿正好互不撞,分工其实已经收敛\n\n把三份讨论的志愿排起来看:r1 是布局+搜索(r1-e2)>契约(r1-e3)>分配让出;我是评测规则(r2-e2);r3 是差距分解(r3-e4)>预算(r3-e5)。三条第一志愿各占一条路,没有竞争,而且没人把契约层排第一。我支持就按第一志愿分,下面只处理两个还需要划清的地方。\n\n## e4 成立,但要和 r1-e2 划清第 1 轮的界\n\nr3 把\"LP 参照\"和\"产物读证\"合并成 e4,我同意——拆账本来就是一件事的两半。要防的是另一种撞车:r1-e2 的第一步也是拆账,而且 r1 明说了\"LP 打分器我写入口时就当作布局层的配套\"。两条路第 1 轮是并行的,现在划清楚各自做什么:\n\n- **e4 的第 1 轮实验格**:圆心不动(就用 b0 那组圆心),只把 compute_max_radii 换成 LP 最优分配。这格干净地回答\"分配层独立撑多少\",顺带量出 LP 打分器在 26 变量规模上的耗时、能不能进搜索内环。\n- **r1 布局路的打分器**:不必等 e4 的实测数字。LP 的公式(26 变量、r_i+r_j≤d_ij、r_i≤到边距离、max Σr)是公共知识,直接写进自己的提案协议就是精确打分器;e4 的数字回来后用来校准\"贪心记分器的偏差多大、哪些场景能省掉这次 LP\"。\n\n这样两条路互不等待、互不重复。反过来,如果 r1 第 1 轮也去做拆账,就和 e4 完全同路了。\n\n## 我不同意让 r1 去走契约层\n\nr3 建议 r1 走 C(r1-e3 带上 e5),理由是\"他三条里真正没和我重的只有 e3\"。这个理由讲的是入口覆盖,不是人岗匹配:r1 对布局路的论证是他自己读产物得出的判断(缺口大头在圆心布置、第 1 轮大提案从这出),他对契约层列的是坑不是问题。让最想走主战场的人去走 checklist,浪费的是分工里最值钱的信息——谁在哪条路上有真判断。\n\nC 层的处置我维持上轮看法,r1 这轮自己也说了:防跑废(超时强杀无成绩、numba 编译进墙钟、指标键、reported_sum 非有限写不出文件)是每个写协议的人对照的清单,r1-e3 整理出来给大家就是交付;预算花法(e5)在没有任何搜索运行之前读不出任何东西,等布局路第 1 轮跑出第一份\"搜索花多少时间换多少分\"的实测再并进提案设计。它不该占一个整轮的坑位。顺带一个核实过的空缺:b0 运行目录里只有任务文件和 solution.json,没有时长记录,所以\"基线用了多少秒\"现在没有公开证据,这也支持预算形状只能从第一次带搜索的实测开始量。\n\n## 我的押注和 r3 在一个具体数上分歧,留给第 1 轮裁决\n\nr3 押\"圆心不动、只换 LP,大概率涨不过 0.01 采纳线\";我押\"可能过线(0.01 门槛很小),但远不够\"。方向上我们一致(大头在布局),分歧只在 LP 那格的量级。有一个读路径就能定的边界先说清:b0 圆心下,15 个贴墙圆的半径被\"到边距离\"钉在 0.01,重合对被 r_i+r_j≤d=0 钉成双零——这两类 LP 都救不了,增量只能来自内侧 9 个圆的再分配。增量是 0.005 还是 0.05,我说不出,这正是 e4 第 1 轮那格要回答的,谁押错谁修正,不用现在争。\n\n## 布局路内部的一个视角空位,再提一次\n\n构造式(布局族+离散移动)和连续优化式(圆心半径一起当变量丢给 scipy 约束求解、非凸靠多起点)是两类很不一样的武器,现在布局侧的入口都偏前者。不是要新开坑——但认领布局路的人在第 1 轮选武器族时,值得知道这个空位摆在明面上。\n\n## 我的位置\n\n志愿不变:r2-e2(评测规则)第一。它独占,杠杆在\"第 1 轮所有提案的目标口径\"上。我的第 1 轮产出计划是两样:给所有人写协议用的口径(优化纯 Σr 还是 Σr−预期合法化扣分、留多大裕量)与点/保障判别标尺;以及至少一个自己站得住的提案。清单不换:e1、e3 与 r3 的 e4 重了大半,但它们只是备选志愿——若被迫分到,e1 从\"b0 圆心上 LP 对贪心\"起步、e3 从\"结构化平铺量级锚\"起步,起点仍然有效;现在换清单只会搅乱已经收敛的分工。" + } + } + }, + { + "from": "16-real-1.r0.v1.entry-claim claim seq 77 v1", + "arguments": { + "payload": { + "choices": [ + "e-researcher-2-2", + "e-researcher-3-7", + "e-researcher-1-5", + "e-researcher-2-1" + ] + } + } + } + ] + }, + "expected": [ + { + "researcher": "researcher-1", + "entry": "e-researcher-1-4", + "rank": 1 + }, + { + "researcher": "researcher-2", + "entry": "e-researcher-2-2", + "rank": 1 + }, + { + "researcher": "researcher-3", + "entry": "e-researcher-3-6", + "rank": 1 + } + ], + "expectedEntries": [ + "e-researcher-1-4", + "e-researcher-1-5", + "e-researcher-2-1", + "e-researcher-2-2", + "e-researcher-2-3", + "e-researcher-3-6", + "e-researcher-3-7" + ] +} diff --git a/eval/round-zero/replay.ts b/eval/round-zero/replay.ts new file mode 100644 index 0000000..717481d --- /dev/null +++ b/eval/round-zero/replay.ts @@ -0,0 +1,51 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { entriesIn, entryDecision, ofKind, readLedger, sameAssignment, stages, type Assignment } from "../lib/ledger.ts"; +import { replayModels, startReplay, type ReplayScript } from "../lib/replay.ts"; +import { launch, newEvalDir, writeConfig } from "../lib/study.ts"; + +// 把参考项目 16-real-1 的第零轮原样交给新 flow 回放一遍(任务书 12 第 5.3 节):基线照样用真 Docker 跑, +// 三位研究者由回放假模型按当年的先后交提交。跑完核对分配是否和当年完全相同,列出被退回的提交和没交出去的提交。 +// node eval/round-zero/replay.ts <名字> +// 研究在 <检验目录>/study;之后用 quality.ts 打分。 + +type Script = ReplayScript & { expected: Assignment[]; expectedEntries: string[] }; + +const name = process.argv[2]; +if (name === undefined) throw new Error("要写检验名字,例如 r0-replay-16"); +const script = JSON.parse(readFileSync("eval/round-zero/replay-16-real-1.json", "utf8")) as Script; +const out = newEvalDir(name); +const replay = await startReplay(script, join(out, "pi-agent"), 0); +const researchers = Object.keys(script.models).sort(); +const config = writeConfig("eval/round-zero/research.json", join(out, "research.json"), name, replayModels(researchers, "jev-latest")); +const studyDir = join(out, "study"); +const { outcome } = await launch(config, studyDir, replay.env).done; +await replay.close(); + +const records = readLedger(studyDir); +const decision = entryDecision(records); +const got = decision === undefined ? null : decision.output; +const entries = entriesIn(records, new Set(stages(records).filter((s) => s.round === 0).map((s) => s.stageId))).map((e) => e.id); +// 入口全体按编号比(账本里按各人最后一版清单的先后排,当年的清单按编号排);分配按研究者、入口、志愿三样比,不看对象里键的先后。 +const sameEntries = JSON.stringify([...entries].sort()) === JSON.stringify([...script.expectedEntries].sort()); +const same = got !== null && sameAssignment(got, script.expected); +const report = { + outcome, + assignment: got, + expected: script.expected, + sameAsReference: same, + entries, + expectedEntries: script.expectedEntries, + sameEntriesAsReference: sameEntries, + rejections: ofKind(records, "rejection").map((r) => ({ seq: r.seq, stageId: r.content.stageId, author: r.content.author, kind: r.content.kind, reason: r.content.reason })), + delivered: replay.delivered, + notDelivered: replay.remaining(), +}; +writeFileSync(join(out, "replay-report.json"), `${JSON.stringify(report, null, 2)}\n`); +console.log(`\n结局:${JSON.stringify(outcome)}`); +console.log(`分配:${got === null ? "没有写出分配决定" : got.map((a) => `${a.researcher} → ${a.entry}(第 ${a.rank} 志愿)`).join(";")}`); +console.log(`当年:${script.expected.map((a) => `${a.researcher} → ${a.entry}(第 ${a.rank} 志愿)`).join(";")}`); +console.log(`分配和当年相同:${same ? "是" : "否"};入口全体和当年相同:${sameEntries ? "是" : "否"}(${entries.join("、")})`); +console.log(`退回 ${report.rejections.length} 份${report.rejections.map((r) => `\n ${r.author} ${r.stageId} ${r.kind}:${r.reason}`).join("")}`); +console.log(`没交出去的:${JSON.stringify(report.notDelivered)}`); +console.log(`报告:${join(out, "replay-report.json")};研究目录:${studyDir}`); diff --git a/eval/round-zero/research.json b/eval/round-zero/research.json new file mode 100644 index 0000000..5a1f2aa --- /dev/null +++ b/eval/round-zero/research.json @@ -0,0 +1,33 @@ +{ + "studyId": "r0-eval", + "task": "tasks/circle_packing", + "models": { + "researchers": [ + { "provider": "iomgaa", "model": "glm-5.3", "thinking": "medium" }, + { "provider": "iomgaa", "model": "glm-5.3", "thinking": "medium" }, + { "provider": "iomgaa", "model": "glm-5.3", "thinking": "medium" } + ], + "experimenter": { "provider": "iomgaa", "model": "glm-5.3", "thinking": "medium" }, + "verifier": { "provider": "iomgaa", "model": "glm-5.3", "thinking": "medium" }, + "jev": "jev-latest" + }, + "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 + } +} diff --git a/eval/round-zero/run.ts b/eval/round-zero/run.ts new file mode 100644 index 0000000..c5014d0 --- /dev/null +++ b/eval/round-zero/run.ts @@ -0,0 +1,16 @@ +import { join } from "node:path"; +import { launch, newEvalDir, writeConfig } from "../lib/study.ts"; + +// 用真模型、真 Docker 跑一次第零轮(任务书 12 第 5.2 节"同样的配置真实跑 3 次"):配置照 eval/round-zero/research.json, +// 研究编号换成检验名字。研究在 <检验目录>/study,跑完用 quality.ts 打分。要花钱。 +// node eval/round-zero/run.ts <名字> +// 中途按 Ctrl-C 会把停下信号传给研究进程;接着跑用 node eval/lib/cli.ts --resume --dir <检验目录>/study。 + +const name = process.argv[2]; +if (name === undefined) throw new Error("要写检验名字,例如 r0-real-1"); +const out = newEvalDir(name); +const config = writeConfig("eval/round-zero/research.json", join(out, "research.json"), name, null); +const study = launch(config, join(out, "study"), {}); +process.on("SIGINT", () => study.child.kill("SIGINT")); +const { outcome } = await study.done; +console.log(`\n结局:${JSON.stringify(outcome)}\n研究目录:${join(out, "study")}`); diff --git a/package.json b/package.json index db01d73..ff9d883 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,17 @@ "node": ">=22.19.0" }, "scripts": { - "check": "tsc --noEmit -p tsconfig.json && depcruise src tests --config .dependency-cruiser.cjs", + "check": "tsc --noEmit -p tsconfig.json && depcruise src tests eval --config .dependency-cruiser.cjs", "test": "node --test \"tests/**/*.test.ts\"", "test:pi": "node tests/agents-pi.ts", - "test:jev": "node tests/jev-real.ts" + "test:jev": "node tests/jev-real.ts", + "eval:study": "set -a && . /home/iomgaa/Projects/RivalBits/.env && set +a && node eval/lib/cli.ts", + "eval:r0:run": "set -a && . /home/iomgaa/Projects/RivalBits/.env && set +a && node eval/round-zero/run.ts", + "eval:r0:quality": "set -a && . /home/iomgaa/Projects/RivalBits/.env && set +a && node eval/round-zero/quality.ts", + "eval:r0:extract": "node eval/round-zero/extract-16.ts", + "eval:r0:replay": "node eval/round-zero/replay.ts", + "eval:r0:kill": "node eval/round-zero/kill.ts", + "eval:r0:concurrent": "node eval/round-zero/concurrent.ts" }, "dependencies": { "@earendil-works/pi-ai": "0.85.1", diff --git a/src/flow/formal-run.ts b/src/flow/formal-run.ts new file mode 100644 index 0000000..c1676b3 --- /dev/null +++ b/src/flow/formal-run.ts @@ -0,0 +1,90 @@ +import { rmSync } from "node:fs"; +import type { Content } from "../ledger/index.ts"; +import { formalRun as runDefinition, judge, prepareJudging, prepareRun } from "../tasks/index.ts"; +import type { HypothesisMetric, JudgedResult, ProcessResult } from "../vocab/index.ts"; +import { checkoutVersion } from "../workspace/index.ts"; +import { containerName, freshDir, judgeContainerName, runPaths, workspaceRepo } from "./paths.ts"; +import type { Study } from "./study.ts"; + +// 共用动作二:跑一次正式运行(任务书 12 第 2.7 节)。先登记、再启动、再写结果,前后顺序由这个入口保证 +// (方法.md 第七、九部分):登记占名额,确认启动才扣次,确认没启动的写结果"没启动"、退还名额。 +// 判分不扣次数,不登记。恢复时接着等一次已经启动的运行,走同一个 finishRun。 + +export type RunPlan = Omit, "definition">; + +/** + * 一次正式运行怎么结束:写完结果;被用户停下;或 docker run 没启动,附 Docker 的报错原文。 + * 没启动是外部错误,本进程不重试(CLAUDE.md"错误怎么处理",重试 0 次):调用方写停下事件, + * 下次启动时"下一步做什么"照旧起新编号重跑。 + */ +export type RunOutcome = "done" | "stopped" | { notStarted: string }; + +/** 跑一次正式运行:取出 version 的代码,在任务镜像里跑,判分,写结果。用户停下时不等容器,交回 "stopped"。 */ +export async function formalRun(s: Study, plan: RunPlan, version: string, metrics: HypothesisMetric[]): Promise { + const definition = runDefinition(s.task, s.frozen.image, String(s.frozen.seed)); + s.ledger.append("runPlanned", { ...plan, definition }); + const paths = runPaths(s.dir, plan.run); + freshDir(paths.base); + checkoutVersion(workspaceRepo(s.dir), version, freshDir(paths.code)); + prepareRun(s.task, paths.code, freshDir(paths.run), metrics); + const started = await s.runner.start(containerName(s.frozen.config.studyId, plan.run), definition, paths.run); + if (!started.started) { + s.ledger.append("runResult", { run: plan.run, process: { ending: "notStarted", reason: started.reason }, judged: null }); + return { notStarted: started.reason }; + } + s.ledger.append("runStarted", { run: plan.run }); + return finishRun(s, plan.run, metrics); +} + +/** + * 等一次已经确认启动的运行结束,判分,写结果,删掉容器。日志写进这次运行自己的日志目录。 + * 收到"停下"信号就不等了,直接交回 "stopped";容器不管它,下次启动时恢复入口再来接着等。 + */ +export async function finishRun(s: Study, run: string, metrics: HypothesisMetric[]): Promise<"done" | "stopped"> { + const name = containerName(s.frozen.config.studyId, run); + const paths = runPaths(s.dir, run); + const waited = await untilStopped(s.stop, s.runner.wait(name, emptyDir(paths.logs))); + if (waited === "stopped") return "stopped"; + const judged = await judgeRun(s, run, waited, metrics); + s.ledger.append("runResult", { run, process: waited, judged }); + await s.runner.remove(name); + return "done"; +} + +/** + * 判分:tasks 的 prepareJudging,要跑校验器就让 runner 在镜像里跑,再交给 judge。 + * 判分目录每次重新建空的(恢复时会再判一次);判分容器还在就先删掉再用。 + */ +async function judgeRun(s: Study, run: string, formal: ProcessResult, metrics: HypothesisMetric[]): Promise { + const paths = runPaths(s.dir, run); + const step = prepareJudging(s.task, paths.run, emptyDir(paths.judge), s.frozen.image, formal, metrics); + if (step.status === "judged") return step.result; + const name = judgeContainerName(s.frozen.config.studyId, run); + if ((await s.runner.status(name)) !== "absent") await s.runner.remove(name); + const started = await s.runner.start(name, step.definition, paths.judge); + if (!started.started) throw new Error(`运行 ${run} 的校验器容器没有启动:${started.reason}`); + const validator = await s.runner.wait(name, emptyDir(paths.judgeLogs)); + await s.runner.remove(name); + return judge(s.task, paths.run, paths.judge, formal, validator, metrics); +} + +/** 清空重建一个目录:恢复时会再等一次、再判一次,上一次没写完的日志和判分目录不留。 */ +function emptyDir(dir: string): string { + rmSync(dir, { recursive: true, force: true }); + return freshDir(dir); +} + +/** 等一件事做完,或者收到"停下"信号先交回 "stopped"。被放下的那个 Promise 之后的结果没人用。 */ +async function untilStopped(stop: AbortSignal, work: Promise): Promise { + let onStop = (): void => undefined; + const stopped = new Promise<"stopped">((resolve) => { + onStop = () => resolve("stopped"); + if (stop.aborted) onStop(); + else stop.addEventListener("abort", onStop, { once: true }); + }); + try { + return await Promise.race([work, stopped]); + } finally { + stop.removeEventListener("abort", onStop); + } +} diff --git a/src/flow/frozen.ts b/src/flow/frozen.ts new file mode 100644 index 0000000..32cc1d4 --- /dev/null +++ b/src/flow/frozen.ts @@ -0,0 +1,41 @@ +import { checkConfig, type ResearchConfig, type StepList } from "../config/index.ts"; +import type { LedgerRecord } from "../ledger/index.ts"; +import type { StudyRecord } from "../materials/index.ts"; +import { checkTaskFacts, type TaskFacts } from "../tasks/index.ts"; + +// 从账本读回冻结的研究配置(方法.md 4.1:之后的流程只读这份冻结的配置)。纯函数,"下一步做什么"也用它。 + +/** 完整流程的配置。朴素循环和步骤开关以后再做(docs/结构.md 第五部分),flow 现在只走完整流程。 */ +export type FullConfig = ResearchConfig & { flow: Extract }; + +export function fullConfig(config: ResearchConfig): FullConfig { + if (config.flow.preset !== "full") { + throw new Error("flow 现在只实现完整流程,朴素循环还没写(docs/结构.md 第五部分\"步骤开关和对照组\")"); + } + return config as FullConfig; +} + +/** 冻结的研究配置:配置、任务事实再检查过一遍;步骤清单是 config 的 stepList 写下的,照原样用。 */ +export type Frozen = { + config: FullConfig; + steps: StepList; + facts: TaskFacts; + prompts: StudyRecord["prompts"]; + image: string; + seed: number; +}; + +/** 账本里还没有研究配置记录就是新研究,交回 null。 */ +export function readFrozen(records: readonly LedgerRecord[]): Frozen | null { + const record = records.find((r): r is LedgerRecord<"config"> => r.kind === "config"); + if (record === undefined) return null; + const c = record.content as unknown as StudyRecord; + return { + config: fullConfig(checkConfig(c.config)), + steps: c.steps as unknown as StepList, + facts: checkTaskFacts(c.task), + prompts: c.prompts, + image: c.image, + seed: c.seed, + }; +} diff --git a/src/flow/index.ts b/src/flow/index.ts new file mode 100644 index 0000000..93c4df2 --- /dev/null +++ b/src/flow/index.ts @@ -0,0 +1,4 @@ +// flow:按冻结的步骤清单推进一份研究;每次启动走同一条恢复路径(docs/结构.md 第二部分)。 +// 对外只有 runStudy,以后 plugin 也用它。 +export { runStudy, type StudyInput } from "./loop.ts"; +export type { StudyEnding } from "./study.ts"; diff --git a/src/flow/loop.ts b/src/flow/loop.ts new file mode 100644 index 0000000..27f21e6 --- /dev/null +++ b/src/flow/loop.ts @@ -0,0 +1,99 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import type { ResearchConfig } from "../config/index.ts"; +import { Ledger } from "../ledger/index.ts"; +import { Runner } from "../runner/index.ts"; +import { fullConfig, readFrozen } from "./frozen.ts"; +import { EVENTS, nextStep, type Next } from "./next-step.ts"; +import { ledgerFile } from "./paths.ts"; +import { recover } from "./recover.ts"; +import { baselineRun, saveBaseline } from "./steps/baseline.ts"; +import { assignEntries, dispatchStage } from "./steps/round-zero.ts"; +import { freezeConfig, halt, openStudy, USER_STOPPED, type Study, type StudyEnding } from "./study.ts"; + +// 主循环:读账本 → 问下一步 → 做这一步 → 回到开头(任务书 12 第 2.4 节)。每做完一步都重新读账本。 +// 每次启动走同一条路:新研究先冻结配置;之后把研究接上(核对镜像和提示词)、核对未完成的动作,再进入主循环。 + +export type StudyInput = { + /** 研究目录:账本、代码仓库、每次运行和每次模型调用的目录都在这里(任务书 12 第 2.1 节)。 */ + studyDir: string; + /** 新研究交 checkConfig 检查过的配置;接着跑一份已有的研究写 null,以账本里冻结的那份为准。 */ + config: ResearchConfig | null; + /** 用户停下研究:正在跑的模型会话立即停,正在跑的容器不管它,下次启动时由恢复入口接着等。 */ + stop: AbortSignal; +}; + +export async function runStudy(input: StudyInput): Promise { + const dir = resolve(input.studyDir); + const config = input.config === null ? null : fullConfig(input.config); + mkdirSync(dir, { recursive: true }); + const ledger = Ledger.open(ledgerFile(dir)); + try { + const frozen = readFrozen(ledger.records()) !== null; + if (!frozen && config === null) throw new Error(`研究目录 ${dir} 里还没有研究,配置不能写 null`); + if (frozen && config !== null) throw new Error(`研究目录 ${dir} 里已经有冻结的配置:接着跑时配置写 null,以账本里冻结的那份为准`); + // Docker 守护进程的地址交给 docker 命令按自己的环境变量和 context 决定。 + const runner = new Runner(null); + // 恢复入口:把研究接上,核对未完成的动作,之后才问下一步(任务书 12 第 2.5 节)。新研究冻结配置之后走同一条路。 + const begin = async (): Promise => { + const opened = await openStudy(dir, ledger, runner, input.stop); + if ("halt" in opened) return halt(ledger, opened.halt, []); + const recovered = await recover(opened); + if (recovered === "stopped") return USER_STOPPED; + if (recovered !== null) return { status: "halted", reason: recovered.halt }; + return opened; + }; + let study: Study | null = null; + if (frozen) { + const begun = await begin(); + if ("status" in begun) return begun; + study = begun; + } + for (;;) { + if (input.stop.aborted) return USER_STOPPED; + const next = nextStep(ledger.records()); + if (next.do === "freezeConfig") { + await freezeConfig(dir, ledger, runner, config!); + const begun = await begin(); + if ("status" in begun) return begun; + study = begun; + continue; + } + const ended = await doStep(study!, next); + if (ended !== null) return ended; + } + } finally { + ledger.close(); + } +} + +/** 做一步。交回 null 表示接着走下一步。 */ +async function doStep(s: Study, next: Exclude): Promise { + switch (next.do) { + case "saveBaseline": + saveBaseline(s); + return null; + case "baselineRun": { + const ran = await baselineRun(s, next.run); + if (ran === "stopped") return USER_STOPPED; + if (ran === "done") return null; + return halt(s.ledger, `运行 ${next.run} 没有启动,名额已退还;本进程不重跑,下次启动时起新编号重跑。Docker 的报错:${ran.notStarted}`, [next.run]); + } + case "skip": + s.ledger.append("event", { event: EVENTS.skipped, reason: next.reason, objects: [next.step] }); + return null; + case "openStage": + s.ledger.append("stageOpen", { stageId: next.stageId, stage: next.stage, round: 0, roster: next.roster }); + return null; + case "closeStage": + s.ledger.append("stageClose", { stageId: next.stageId }); + return null; + case "dispatch": + return dispatchStage(s, next); + case "assignEntries": + assignEntries(s); + return null; + case "halt": + return halt(s.ledger, next.reason, next.objects); + } +} diff --git a/src/flow/model-task.ts b/src/flow/model-task.ts new file mode 100644 index 0000000..32dc15f --- /dev/null +++ b/src/flow/model-task.ts @@ -0,0 +1,121 @@ +import { runSession, type KindOf, type SessionEnding, type SubmitAttempt, type SubmitReceipt } from "../agents/index.ts"; +import { buildMaterial, callId, queryMaterial, researcherId, type TaskMaterial } from "../materials/index.ts"; +import { receive, type Incoming, type KnownOf } from "../submissions/index.ts"; +import type { JevJudgment, Json, Submission } from "../vocab/index.ts"; +import { checkoutVersion } from "../workspace/index.ts"; +import { callPaths, freshDir, workspaceRepo } from "./paths.ts"; +import type { Study } from "./study.ts"; + +// 共用动作一:派一个模型任务(任务书 12 第 2.6 节)。先写"模型调用开始"(材料整份存进去),再开会话, +// 最后写"模型调用结束",前后顺序由这个入口保证(方法.md 第九部分"先登记、再执行、再登记结果")。 +// 提交收不收只由 submissions 判;形状不合格的由 agents 报上来,这里直接记退回("给 flow")。 + +/** 一个研究者任务:哪种提交、谁、哪个阶段、对象;known 是交上来之后才知道的东西(submissions 的 KnownOf)。 */ +export type ModelTask> = { + kind: K; + actor: string; + stageId: string; + object: string | null; + known(content: Submission): KnownOf; +}; + +/** 派一次任务,等会话结束,交回调用编号和结局。 */ +export async function runModelTask>(s: Study, t: ModelTask): Promise<{ call: string; ending: SessionEnding }> { + const { ledger } = s; + const started = new Set(ledger.records().flatMap((r) => (r.kind === "callStarted" ? [r.content.call] : []))); + let attempt = 1; + while (started.has(callId(t.stageId, t.actor, t.kind, t.object, attempt))) attempt++; + const call = callId(t.stageId, t.actor, t.kind, t.object, attempt); + const paths = callPaths(s.dir, call); + const material = buildMaterial({ + kind: t.kind, + actor: t.actor, + stageId: t.stageId, + object: t.object, + ledger, + study: s.facts, + prompts: s.prompts, + codeRoot: paths.code, + changes: null, + artifacts: null, + }); + const model = researcherModel(s, t.actor); + // 先登记、后建目录:调用编号只在登记之后才算用掉,被杀在两者之间时下次的编号不会撞上旧目录。 + ledger.append("callStarted", { + call, + actor: t.actor, + stageId: t.stageId, + model: `${model.provider}/${model.model}(思考档位 ${model.thinking})`, + materials: material as unknown as Json, + }); + freshDir(paths.base); + freshDir(paths.code); + for (const r of material.readable) checkoutVersion(workspaceRepo(s.dir), r.version, freshDir(r.dir)); + let reply = 0; + const outcome = await runSession({ + role: "researcher", + model, + systemPrompt: s.prompts.roles.researcher.text, + firstMessage: material.firstMessage, + workDir: freshDir(paths.work), + sessionDir: freshDir(paths.sessions), + kind: t.kind, + readableDirs: material.readable.map((r) => r.dir), + query: (ref) => queryMaterial(material, ref), + submit: (attempt) => submit(s, t, material, attempt as SubmitAttempt), + usage: (usage) => void ledger.append("callUsage", { call, reply: ++reply, usage }), + writeNote: (body) => void ledger.append("note", { author: t.actor, body }), + stop: s.stop, + }); + // 会话文件就在这次调用的 sessions 目录里,不另记。 + const { wallSeconds, sessionFile: _sessionFile, ...ending } = outcome; + ledger.append("callEnded", { call, wallSeconds: { status: "measured", value: wallSeconds }, ...ending }); + return { call, ending }; +} + +/** 研究者用的模型:第 i 位研究者(researcherId(i))用配置里第 i 个。 */ +function researcherModel(s: Study, actor: string) { + const models = s.frozen.config.models.researchers; + const i = models.findIndex((_, k) => researcherId(k) === actor); + if (i < 0) throw new Error(`${actor} 不是这份研究的研究者`); + return models[i]!; +} + +/** + * 一份提交收不收。这个任务只有一份提交(第零轮每人每阶段一个任务、一份提交): + * 回给模型的提交编号就是这一份的位置,不分版本;替换时写它,接在最新一版后面。 + * 第一次交以后不写 replaces 又交,或 replaces 写的不是这一份,都退回并说明该怎么写。 + */ +async function submit>(s: Study, t: ModelTask, material: TaskMaterial, attempt: SubmitAttempt): Promise { + const { ledger } = s; + const slot = { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, item: null }; + const reject = (submitted: Json, reason: string, jev: JevJudgment[] | null): SubmitReceipt => { + ledger.append("rejection", { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, submitted, reason, jev }); + return { accepted: false, reason }; + }; + if (attempt.shape === "invalid") return reject(attempt.raw, attempt.reason, null); + const id = receiptId(t); + const latest = ledger.latestSubmission(slot)?.content.version ?? 0; + const content = attempt.content as unknown as Json; + if (attempt.replaces === null && latest > 0) { + return reject(content, `本阶段你已经交过这一份(提交编号 ${id})。要改就在 replaces 里写 ${id} 整份替换,不要另交`, null); + } + if (attempt.replaces !== null && (attempt.replaces !== id || latest === 0)) { + const which = latest === 0 ? "你本阶段还没有收下的提交,第一次交时不要带 replaces" : `要替换就写 ${id}`; + return reject(content, `replaces 写的 ${attempt.replaces} 不是你本阶段交过的提交:${which}`, null); + } + const verdict = await receive({ + kind: t.kind, + content: attempt.content, + checklist: material.checklist, + known: t.known(attempt.content), + jevModel: s.frozen.config.models.jev, + } as Incoming); + if (!verdict.accepted) return reject(content, verdict.reason, verdict.jev); + ledger.append("submission", { ...slot, version: latest + 1, content: attempt.content, jev: verdict.jev } as never); + return { accepted: true, id }; +} + +/** 回给模型的提交编号:阶段、本人、提交种类、对象。 */ +const receiptId = (t: { stageId: string; actor: string; kind: string; object: string | null }): string => + `${t.stageId}.${t.actor}.${t.kind}.${t.object ?? "none"}`; diff --git a/src/flow/next-step.ts b/src/flow/next-step.ts new file mode 100644 index 0000000..c7f24f4 --- /dev/null +++ b/src/flow/next-step.ts @@ -0,0 +1,160 @@ +import type { StudyStepId } from "../config/index.ts"; +import type { Content, LedgerRecord } from "../ledger/index.ts"; +import { ORIGINAL_BASELINE, researcherId } from "../materials/index.ts"; +import type { Stage } from "../vocab/index.ts"; +import { readFrozen, type Frozen } from "./frozen.ts"; + +// "下一步做什么"(docs/结构.md 第二部分"几条边界"):步骤的先后只写在这里。 +// 纯函数:输入账本记录(冻结的步骤清单就在其中的研究配置记录里),输出下一步要做的一件事; +// 不读文件、不调模型,同样的记录永远得到同样的下一步。账本不存"进行到第几步",每次从记录现算。 +// +// 每一步做完以哪条记录为准,见 docs/结构.md 第八部分 flow 一节的表;下面每个步骤的分支各注一行。 +// 没写成那条记录的步骤,下次会被重做或报错,不靠"这条记录公开了没有"这类隐含约定去猜。 + +/** 第零轮的研究者阶段:步骤 → 阶段名(vocab STAGES)和这个阶段交的提交种类。 */ +const ROUND_ZERO_STAGES = { + entryDraft: { stage: "entryDraft", kind: "entry" }, + discussion1: { stage: "discussion", kind: "discussion" }, + discussion2: { stage: "discussion", kind: "discussion" }, + claim: { stage: "claim", kind: "claim" }, +} as const satisfies Partial>; +type RoundZeroStep = keyof typeof ROUND_ZERO_STAGES; +export type RoundZeroKind = (typeof ROUND_ZERO_STAGES)[RoundZeroStep]["kind"]; + +/** 阶段编号由 flow 起:第零轮是 r0-<步骤>,一个步骤一个阶段。 */ +export const stageIdOf = (step: RoundZeroStep): string => `r0-${step}`; +/** 基线运行编号:没启动就起下一个编号重跑(docs/结构.md 第四部分)。 */ +export const baselineRunId = (n: number): string => `base-${n}`; + +/** flow 写的事件种类(账本记录 event)。 */ +export const EVENTS = { + /** 按配置跳过的步骤,objects 是步骤编号,reason 是 config 给的原因。 */ + skipped: "skippedByConfig", + /** 研究停下,reason 是停下的原因,objects 是相关的调用或运行。 */ + halted: "halted", + /** 恢复时问不到 Docker,不知道一次运行有没有启动(方法.md 第七部分"状态未知"),objects 是运行编号。 */ + runStatusUnknown: "runStatusUnknown", +} as const; + +export type Next = + | { do: "freezeConfig" } + | { do: "saveBaseline" } + | { do: "baselineRun"; run: string } + | { do: "skip"; step: StudyStepId; reason: string } + | { do: "openStage"; stageId: string; stage: Stage; roster: string[] } + | { do: "closeStage"; stageId: string } + | { do: "dispatch"; stageId: string; kind: RoundZeroKind; actors: string[] } + | { do: "assignEntries" } + | { do: "halt"; reason: string; objects: string[] }; + +export function nextStep(records: readonly LedgerRecord[]): Next { + // 冻结配置 → 研究配置记录。 + const frozen = readFrozen(records); + if (frozen === null) return { do: "freezeConfig" }; + const view = new View(records); + for (const step of frozen.steps.study) { + if (step.status === "skip") { + // 按配置跳过的步骤 → 一条"按配置跳过"事件,写明步骤和原因。 + if (!view.events(EVENTS.skipped).some((e) => e.objects[0] === step.id)) return { do: "skip", step: step.id, reason: step.reason }; + continue; + } + const next = stepNext(step.id, view, frozen); + if (next !== null) return next; + } + throw new Error("步骤清单走完了却没有停下:rounds 一步应当停下"); +} + +/** 一个要执行的步骤还要做什么;做完了交回 null。 */ +function stepNext(step: StudyStepId, view: View, frozen: Frozen): Next | null { + switch (step) { + case "freezeConfig": + return null; + case "baselineRun": + return baselineNext(view); + case "entryDraft": + case "discussion1": + case "discussion2": + case "claim": + return stageNext(step, view, frozen); + case "assignEntries": + // 分配 → 第 0 轮的入口分配决定(decision,种类 entries)。 + return view.of("decision").some((d) => d.decision === "entries" && d.round === 0) ? null : { do: "assignEntries" }; + case "rounds": + return { do: "halt", reason: "第 1 轮还没写(任务书 13)", objects: [] }; + case "export": + throw new Error("走到了导出:rounds 一步应当先停下"); + } +} + +/** + * 基线 → 基线代码记录(对象 ORIGINAL_BASELINE),而且最近一次基线运行的结果不是"没启动"。 + * 没启动的起新编号重跑;判为不算数的停下(方法.md 4.1)。 + */ +function baselineNext(view: View): Next | null { + if (!view.of("code").some((c) => c.object === ORIGINAL_BASELINE)) return { do: "saveBaseline" }; + const plans = view.of("runPlanned").filter((p) => p.purpose === "baseline"); + const last = plans.at(-1); + if (last === undefined) return { do: "baselineRun", run: baselineRunId(1) }; + const result = view.of("runResult").find((r) => r.run === last.run); + if (result === undefined) throw new Error(`基线运行 ${last.run} 登记了还没有结果:恢复入口应当已经核对过`); + if (result.process.ending === "notStarted") return { do: "baselineRun", run: baselineRunId(plans.length + 1) }; + if (result.judged?.verdict === "valid") return null; + const why = result.judged === null ? result.process.ending : result.judged.verdictReason; + return { do: "halt", reason: `基线运行 ${last.run} 判为不算数(${why}),不能当基线`, objects: [last.run] }; +} + +/** + * 研究者阶段 → 这个阶段的关闭记录(stageClose)。没开就开;名单上的人都有被收下的提交才关; + * 没交上的人按最近一次调用怎么结束的来定派不派(任务书 12 第 2.3 节的表)。能派的先全派,派完还有人停在"没交"才停下。 + */ +function stageNext(step: RoundZeroStep, view: View, frozen: Frozen): Next | null { + const stageId = stageIdOf(step); + const { stage, kind } = ROUND_ZERO_STAGES[step]; + const open = view.of("stageOpen").find((s) => s.stageId === stageId); + if (open === undefined) { + return { do: "openStage", stageId, stage, roster: frozen.config.models.researchers.map((_, i) => researcherId(i)) }; + } + if (view.of("stageClose").some((s) => s.stageId === stageId)) return null; + const missing = open.roster.filter((a) => !view.of("submission").some((s) => s.stageId === stageId && s.author === a)); + if (missing.length === 0) return { do: "closeStage", stageId }; + const actors: string[] = []; + const stuck: Array<{ call: string; reason: string }> = []; + for (const actor of missing) { + const last = view.lastCall(stageId, actor); + // 没有调用、被用户停下、出错(含恢复时补写的"进程中断")都派;没交的只在开发入口开着时派(方法.md 第九部分"开发入口")。 + if (last?.status === "notSubmitted" && !frozen.config.devEntry) stuck.push({ call: last.call, reason: last.reason }); + else actors.push(actor); + } + if (actors.length > 0) return { do: "dispatch", stageId, kind, actors }; + return { + do: "halt", + reason: `交不出合格提交(方法.md 4.3 ②:按内部错误停下):${stuck.map((s) => `调用 ${s.call},${s.reason}`).join(";")}`, + objects: stuck.map((s) => s.call), + }; +} + +/** 按种类取记录内容的小工具。 */ +class View { + readonly #records: readonly LedgerRecord[]; + + constructor(records: readonly LedgerRecord[]) { + this.#records = records; + } + + of(kind: K): Array> { + return (this.#records.filter((r) => r.kind === kind) as Array>).map((r) => r.content); + } + + events(event: string): Array> { + return this.of("event").filter((e) => e.event === event); + } + + /** 某人在某阶段最近一次模型调用的结束记录,没有调用为 null。开始了没结束的,恢复入口应当已经补写。 */ + lastCall(stageId: string, actor: string): Content<"callEnded"> | null { + const started = this.of("callStarted").filter((c) => c.stageId === stageId && c.actor === actor).at(-1); + if (started === undefined) return null; + const ended = this.of("callEnded").find((e) => e.call === started.call); + if (ended === undefined) throw new Error(`模型调用 ${started.call} 开始了还没有结束记录:恢复入口应当已经补写`); + return ended; + } +} diff --git a/src/flow/paths.ts b/src/flow/paths.ts new file mode 100644 index 0000000..9f36de2 --- /dev/null +++ b/src/flow/paths.ts @@ -0,0 +1,35 @@ +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// 研究目录里放什么(任务书 12 第 2.1 节),全由 flow 管: +// ledger.sqlite 账本 +// repo.git workspace 的裸仓库 +// runs/<运行编号>/ 一次正式运行:code 取出的代码、run 运行目录、logs 日志、judge 判分目录、judge-logs 校验器日志 +// calls/<调用编号>/ 一次模型调用:work 工作目录、sessions 会话文件、code 能读的代码版本(materials 的 codeRoot) + +export const ledgerFile = (dir: string): string => join(dir, "ledger.sqlite"); +export const workspaceRepo = (dir: string): string => join(dir, "repo.git"); + +export function runPaths(dir: string, run: string) { + const base = join(dir, "runs", run); + return { base, code: join(base, "code"), run: join(base, "run"), logs: join(base, "logs"), judge: join(base, "judge"), judgeLogs: join(base, "judge-logs") }; +} + +export function callPaths(dir: string, call: string) { + const base = join(dir, "calls", call); + return { base, work: join(base, "work"), sessions: join(base, "sessions"), code: join(base, "code") }; +} + +/** 容器名带研究编号,在本机所有研究之间唯一(参考项目 9b 两份研究撞过名);判分容器另起名字。 */ +export const containerName = (studyId: string, run: string): string => `inforsi-${studyId}-${run}`; +export const judgeContainerName = (studyId: string, run: string): string => `inforsi-${studyId}-${run}-judge`; + +/** + * 新建一个空目录:上一层没有就建,目录本身已经存在就报错。取出代码、建运行目录、判分目录时目标都必须是空的 + * (workspace、tasks 不查);每次运行、每次调用都有新编号,所以不会撞上旧目录。 + */ +export function freshDir(path: string): string { + mkdirSync(dirname(path), { recursive: true }); + mkdirSync(path); + return path; +} diff --git a/src/flow/recover.ts b/src/flow/recover.ts new file mode 100644 index 0000000..1017894 --- /dev/null +++ b/src/flow/recover.ts @@ -0,0 +1,48 @@ +import type { Content } from "../ledger/index.ts"; +import type { HypothesisMetric } from "../vocab/index.ts"; +import { finishRun } from "./formal-run.ts"; +import { EVENTS } from "./next-step.ts"; +import { containerName } from "./paths.ts"; +import type { Study } from "./study.ts"; + +// 恢复入口第 3、4 步(任务书 12 第 2.5 节):每次启动都走这里,不分第一次启动、正常重启还是被杀后重启。 +// 从账本找出已登记要做、还没有结果的动作,逐个核对,只核对不重做(CLAUDE.md"中断后怎么恢复")。 + +/** 核对完交回 null;问不到 Docker 时写"状态未知"事件,交回停下的原因;等运行时被用户停下交回 "stopped"。 */ +export async function recover(s: Study): Promise<{ halt: string } | "stopped" | null> { + const pending = s.ledger.pending(); + // 开始了还没结束的模型调用:控制进程中断了,补写结束记录,墙钟时间测不到(docs/结构.md 第四部分第 10 种)。 + for (const c of pending.calls) { + const unmeasured = { status: "unmeasured", reason: "进程中断" } as const; + s.ledger.append("callEnded", { call: c.content.call, wallSeconds: unmeasured, status: "failed", reason: "进程中断" }); + } + // 登记了还没结果的正式运行:按容器名问 Docker(方法.md 第七部分"不确定有没有启动时,先核对")。 + for (const { plan, started } of pending.runs) { + const { run } = plan.content; + const name = containerName(s.frozen.config.studyId, run); + const status = await s.runner.status(name); + if (status === "unreachable") { + const reason = `恢复时问不到 Docker,不知道运行 ${run}(容器 ${name})有没有启动:状态未知,等用户核对(方法.md 第七部分)`; + s.ledger.append("event", { event: EVENTS.runStatusUnknown, reason, objects: [run] }); + return { halt: reason }; + } + if (!started && (status === "absent" || status === "created")) { + // 确认没启动:写结果"没启动",退还名额;建了没启动的删掉。 + if (status === "created") await s.runner.remove(name); + const reason = `恢复时核对:容器 ${name} ${status === "absent" ? "不存在" : "建了没有启动,已删掉"},这次运行没有启动`; + s.ledger.append("runResult", { run, process: { ending: "notStarted", reason }, judged: null }); + continue; + } + // 在跑或已结束:没写"确认启动"的补写(扣次),然后等它、判分、写结果。 + // 已经确认启动、容器却不见了,真实运行里没出现过,不专门写:runner.wait 会报错,按内部错误停下。 + if (!started) s.ledger.append("runStarted", { run }); + if ((await finishRun(s, run, metricsOf(plan.content))) === "stopped") return "stopped"; + } + return null; +} + +/** 一次运行要测的假设专用指标。第 1 部分只有基线运行,它不测假设专用指标;正向、消融运行由第 3、4 部分补。 */ +function metricsOf(plan: Content<"runPlanned">): HypothesisMetric[] { + if (plan.purpose !== "baseline") throw new Error(`运行 ${plan.run} 是 ${plan.purpose} 运行,flow 第 1 部分还不会恢复它`); + return []; +} diff --git a/src/flow/steps/baseline.ts b/src/flow/steps/baseline.ts new file mode 100644 index 0000000..897d36a --- /dev/null +++ b/src/flow/steps/baseline.ts @@ -0,0 +1,21 @@ +import { ORIGINAL_BASELINE } from "../../materials/index.ts"; +import { saveVersion } from "../../workspace/index.ts"; +import { formalRun, type RunOutcome } from "../formal-run.ts"; +import { workspaceRepo } from "../paths.ts"; +import type { Study } from "../study.ts"; + +// 基线(方法.md 4.1):存任务的基线代码,写代码记录,再用共用动作二跑一次;每份研究都跑,计入运行次数。 + +/** 存任务的基线目录("不存"清单取冻结的任务事实),写代码记录:对象 ORIGINAL_BASELINE,位置表 null。 */ +export function saveBaseline(s: Study): void { + const saved = saveVersion(workspaceRepo(s.dir), s.task.baselineDir, s.frozen.facts.unversionedNames); + if (!saved.saved) throw new Error(`任务的基线目录 ${s.task.baselineDir} 存不成版本:${saved.path},${saved.reason}`); + s.ledger.append("code", { object: ORIGINAL_BASELINE, version: saved.version, locations: null }); +} + +/** 跑一次基线运行。基线不测假设专用指标。 */ +export function baselineRun(s: Study, run: string): Promise { + const code = s.ledger.records().findLast((r) => r.kind === "code" && r.content.object === ORIGINAL_BASELINE); + const version = (code!.content as { version: string }).version; + return formalRun(s, { run, purpose: "baseline", round: 0, object: null }, version, []); +} diff --git a/src/flow/steps/round-zero.ts b/src/flow/steps/round-zero.ts new file mode 100644 index 0000000..cd79201 --- /dev/null +++ b/src/flow/steps/round-zero.ts @@ -0,0 +1,44 @@ +import { finalEntries } from "../../materials/index.ts"; +import { assignEntries as assign } from "../../rules/index.ts"; +import type { Json } from "../../vocab/index.ts"; +import { runModelTask } from "../model-task.ts"; +import type { Next } from "../next-step.ts"; +import { halt, USER_STOPPED, type Study, type StudyEnding } from "../study.ts"; + +// 第零轮(方法.md 4.2):三个研究者阶段派任务,认领之后由程序分配入口。 + +/** + * 派一个研究者阶段里要派的人,同时跑,各自一个会话(任务书 12 第 2.6 节)。第零轮的提交种类交上来之后没有要另算的东西。 + * 有人没交上(没交或出错),等这次派发的会话全部结束后停下,不打断别人(第 2.4 节)。 + * 某个会话抛出内部错误时,也等别人结束再把它抛出去。 + */ +export async function dispatchStage(s: Study, next: Extract): Promise { + const settled = await Promise.allSettled( + next.actors.map((actor) => runModelTask(s, { kind: next.kind, actor, stageId: next.stageId, object: null, known: () => null })), + ); + const failure = settled.find((r) => r.status === "rejected"); + if (failure !== undefined) throw failure.reason; + if (s.stop.aborted) return USER_STOPPED; + const results = settled.map((r) => (r as PromiseFulfilledResult>>).value); + const missed = results.flatMap(({ call, ending }) => + ending.status === "notSubmitted" || ending.status === "failed" ? [{ call, text: `${ending.status === "failed" ? "出错" : "没交"}:${ending.reason}` }] : [], + ); + if (missed.length === 0) return null; + return halt(s.ledger, `阶段 ${next.stageId} 有人没交上,等这次派发的会话全部结束后停下:${missed.map((m) => `调用 ${m.call},${m.text}`).join(";")}`, missed.map((m) => m.call)); +} + +/** + * 分配(方法.md 4.2"分配规则"):每人最后一份认领和最终有效的入口交给 rules 的 assignEntries,写一条入口分配决定。 + * assignEntries 不在规则登记表里,规则名就写它,版本 1;没有方法参数,也不用随机。 + */ +export function assignEntries(s: Study): void { + const roster = s.ledger.records().find((r) => r.kind === "stageOpen" && r.content.stage === "claim" && r.content.round === 0); + const claim = roster!.content as { stageId: string; roster: string[] }; + const researchers = claim.roster.map((id) => { + const last = s.ledger.latestSubmission({ stageId: claim.stageId, author: id, kind: "claim", target: null, item: null }); + return { id, choices: (last!.content.content as { choices: string[] }).choices }; + }); + const entries = finalEntries(s.ledger).map((e) => ({ id: e.id, author: e.author })); + const input = { researchers, entries }; + s.ledger.append("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input, output: assign(input) as unknown as Json }); +} diff --git a/src/flow/study.ts b/src/flow/study.ts new file mode 100644 index 0000000..95952d8 --- /dev/null +++ b/src/flow/study.ts @@ -0,0 +1,112 @@ +import { stepList } from "../config/index.ts"; +import type { Ledger } from "../ledger/index.ts"; +import { readPrompts, type Prompts, type StudyFacts, type StudyRecord } from "../materials/index.ts"; +import type { Runner } from "../runner/index.ts"; +import { loadTask, metricDefinitions, type Task } from "../tasks/index.ts"; +import { hash, ROLES, SUBMISSION_KINDS, type Json, type SubmissionKind } from "../vocab/index.ts"; +import { createRepository } from "../workspace/index.ts"; +import { readFrozen, type FullConfig, type Frozen } from "./frozen.ts"; +import { EVENTS } from "./next-step.ts"; +import { workspaceRepo } from "./paths.ts"; + +// 一份研究跑起来要的东西:研究目录、账本、Docker、冻结的配置,以及按冻结配置读进来的任务和提示词。 +// 研究开始时冻结配置(方法.md 4.1);之后每次启动都从账本里那份冻结的配置重建,不再读配置文件和 task.json。 + +export type Study = { + dir: string; + ledger: Ledger; + runner: Runner; + stop: AbortSignal; + frozen: Frozen; + task: Task; + prompts: Prompts; + facts: StudyFacts; +}; + +/** + * 研究开始:核对研究编号没被用过,读任务和提示词、建镜像、建裸仓库,一次写进研究配置记录(任务书 12 第 2.8 节)。 + * 研究编号来自用户配置,是边界:容器名是 inforsi-<研究编号>-<运行编号>,编号重复的新研究会把旧研究遗留的容器 + * 当成自己的运行(docker run 重名失败后按名字查到它,算作启动了),扣次并记下别人的结果。 + * 每份研究冻结配置时都建 inforsi-<研究编号> 的镜像,本机已有这个标签就说明编号用过了,报错停下。 + */ +export async function freezeConfig(dir: string, ledger: Ledger, runner: Runner, config: FullConfig): Promise { + const tag = `inforsi-${config.studyId}`; + if (await runner.hasImage(tag)) { + throw new Error(`研究编号 ${config.studyId} 已被本机用过(已有镜像 ${tag}),换一个研究编号再开新研究`); + } + const task = loadTask(config.task); + const prompts = readPrompts(config.promptDir); + // 冻结的是镜像编号,标签以后被别的构建挪走也不影响(docs/结构.md"依赖跟着任务走")。 + const image = await runner.buildImage(task.dir, tag); + createRepository(workspaceRepo(dir)); + const record: StudyRecord = { + config: config as unknown as Json, + steps: stepList(config) as unknown as Json, + prompts: promptHashes(prompts), + image, + task: task.facts as unknown as Json, + // 同一份研究的全部运行用同一个种子:研究编号的哈希取前 8 位十六进制(任务书 12 第 2.7 节)。 + seed: Number.parseInt(hash(config.studyId).slice(0, 8), 16), + }; + ledger.append("config", record as unknown as Json); +} + +/** + * 按账本里冻结的配置把研究接上(恢复入口第 2 步):镜像还在、提示词没变,才往下走。 + * 镜像不在就停下,不重建(docs/结构.md"依赖跟着任务走");提示词变了而开发入口关着也停下(方法.md 第九部分)。 + * 交回研究,或停下的原因。 + */ +export async function openStudy(dir: string, ledger: Ledger, runner: Runner, stop: AbortSignal): Promise { + const frozen = readFrozen(ledger.records())!; + if (!(await runner.hasImage(frozen.image))) { + return { halt: `本机没有冻结的任务镜像 ${frozen.image}:一份研究不能自己改依赖,不重建(docs/结构.md 第二部分"依赖跟着任务走")` }; + } + const prompts = readPrompts(frozen.config.promptDir); + const now = promptHashes(prompts); + const changed = [ + ...ROLES.filter((r) => now.roles[r] !== frozen.prompts.roles[r]).map((r) => `roles/${r}.md`), + ...KINDS.filter((k) => now.tasks[k] !== frozen.prompts.tasks[k]).map((k) => `tasks/${k}.md`), + ]; + if (changed.length > 0 && !frozen.config.devEntry) { + return { halt: `提示词和冻结时不一样:${changed.join("、")};开发入口关着,不能换提示词(方法.md 第九部分)` }; + } + // 任务事实只认冻结的那份;任务目录里的基线、harness、校验器和 TASK.md 照目录读。 + const task: Task = { ...loadTask(frozen.config.task), facts: frozen.facts }; + return { dir, ledger, runner, stop, frozen, task, prompts, facts: studyFacts(frozen, task) }; +} + +const KINDS = Object.keys(SUBMISSION_KINDS) as SubmissionKind[]; + +function promptHashes(prompts: Prompts): StudyRecord["prompts"] { + const pick = (files: Record) => + Object.fromEntries(Object.entries<{ hash: string }>(files).map(([k, f]) => [k, f.hash])) as Record; + return { roles: pick(prompts.roles), tasks: pick(prompts.tasks) }; +} + +/** materials 要的研究事实,取自冻结的配置和任务。 */ +function studyFacts(frozen: Frozen, task: Task): StudyFacts { + const { config, facts } = frozen; + return { + finalMetric: facts.finalMetric.id, + direction: facts.finalMetric.direction, + epsilon: facts.epsilon, + knownBest: facts.knownBest, + lambda: config.flow.openReserve, + totalRuns: config.runs, + proposalLimit: config.proposalLimit, + factorLimit: config.factorLimit, + metrics: metricDefinitions(task), + taskText: task.guide, + wallSeconds: facts.run.wallSeconds, + }; +} + +/** runStudy 的两种结局:停下(附原因),或被用户停下。"研究结束"第 4 部分才走得到。 */ +export type StudyEnding = { status: "halted"; reason: string } | { status: "userStopped" }; +export const USER_STOPPED: StudyEnding = { status: "userStopped" }; + +/** 停下:写一条停下事件,交回停下的结局。 */ +export function halt(ledger: Ledger, reason: string, objects: string[]): StudyEnding { + ledger.append("event", { event: EVENTS.halted, reason, objects }); + return { status: "halted", reason }; +} diff --git a/src/materials/blocks.ts b/src/materials/blocks.ts index af41573..e2b0fea 100644 --- a/src/materials/blocks.ts +++ b/src/materials/blocks.ts @@ -1,3 +1,4 @@ +import type { Ledger } from "../ledger/index.ts"; import { adoptionLine, compareIds, runBalance } from "../rules/index.ts"; import type { Checklist, SubmissionKind } from "../vocab/index.ts"; import { beliefBookOf } from "./belief-book.ts"; @@ -243,6 +244,11 @@ export function validEntries(board: Board): Array<{ id: string; author: string; }); } +/** 按账本取的有效入口:flow 分配入口时用,和认领的对号清单是同一份(方法.md 4.2)。 */ +export function finalEntries(ledger: Ledger): Array<{ id: string; author: string; name: string; body: string }> { + return validEntries(new Board(ledger)); +} + // --------------------------------------------------------------------------- // "你自己的"(方法.md 第三部分"给研究者的材料顺序"第 2 条) // --------------------------------------------------------------------------- diff --git a/src/materials/index.ts b/src/materials/index.ts index 5278686..f3edaaf 100644 --- a/src/materials/index.ts +++ b/src/materials/index.ts @@ -1,7 +1,7 @@ // materials:每个任务能看到哪些材料、第一条消息怎么写、查询在材料范围内回答什么(docs/结构.md 第二部分), // 以及账本里只存成 Json 的几样内容的形状和编号规则(用户 2026-10-03 决定 2)。 export { beliefBook } from "./belief-book.ts"; -export { type MaterialRecord, type MaterialSection, type ReadableVersion, type TaskMaterial } from "./blocks.ts"; +export { finalEntries, type MaterialRecord, type MaterialSection, type ReadableVersion, type TaskMaterial } from "./blocks.ts"; export { buildMaterial } from "./build.ts"; export { type CodeChanges, type PromptFile, type Prompts, type StudyFacts, type TaskRequest } from "./inputs.ts"; export { queryMaterial, readPrompts } from "./prompts.ts"; @@ -21,6 +21,7 @@ export { freezeProtocol, ORIGINAL_BASELINE, proposalId, + researcherId, splitCell, type DecisionKind, type DecisionShapes, @@ -28,4 +29,5 @@ export { type FrozenProtocol, type LocationTable, type Settlement, + type StudyRecord, } from "./shapes.ts"; diff --git a/src/materials/shapes.ts b/src/materials/shapes.ts index 17b3729..ec5f205 100644 --- a/src/materials/shapes.ts +++ b/src/materials/shapes.ts @@ -11,7 +11,7 @@ import type { settleAblation, } from "../rules/index.ts"; import type { Content } from "../ledger/index.ts"; -import type { Submission } from "../vocab/index.ts"; +import type { Json, Role, Submission, SubmissionKind } from "../vocab/index.ts"; // 账本里只存成 Json 的几样内容长什么样、编号怎么起(用户 2026-10-03 决定 2):materials 读它们,flow 照着写。 // 决定和结算的输出直接用 rules 的返回类型;ledger 不认识这些类型,只存 Json。 @@ -23,6 +23,8 @@ import type { Submission } from "../vocab/index.ts"; /** 原始基线(任务给的代码)的对象编号:它的冻结代码记录、基线运行都挂在这个名字下。 */ export const ORIGINAL_BASELINE = "base"; +/** 研究者:第 i 位(从 0 数)是 researcher-(i+1),与参考项目的写法相同,回放时不用改编号。 */ +export const researcherId = (i: number): string => `researcher-${i + 1}`; /** 入口:作者在第零轮交的第 n 个入口,讨论时整份替换的新清单接着往后编(方法.md 4.2)。 */ export const entryId = (author: string, n: number): string => `e-${author}-${n}`; /** 提案:第 round 轮作者交的第 n 条,也是提交记录的 item。 */ @@ -50,6 +52,25 @@ export const explanationId = (round: number, author: string, i: number): string export const callId = (stageId: string, actor: string, kind: string, object: string | null, attempt: number): string => `${stageId}.${actor}.${kind}.${object ?? "none"}.${attempt}`; +// --------------------------------------------------------------------------- +// 研究配置(账本记录 config,方法.md 4.1) +// --------------------------------------------------------------------------- + +/** + * 研究配置记录:研究开始时 flow 一次写入,之后只认这一份,不再读配置文件和 task.json。 + * materials 不能引用 config、tasks,所以配置(checkConfig 过的)、步骤清单(config 的 stepList)、任务事实(task.json) + * 存成 Json,flow 读回时用 checkConfig、checkTaskFacts 再检查。prompts 是提示词目录里每个文件原始字节的 sha256, + * image 是任务镜像编号,seed 是这份研究全部运行共用的种子。 + */ +export type StudyRecord = { + config: Json; + steps: Json; + prompts: { roles: Record; tasks: Record }; + image: string; + task: Json; + seed: number; +}; + // --------------------------------------------------------------------------- // 冻结协议(账本记录 protocol.protocol,方法.md 4.3 ⑥) // --------------------------------------------------------------------------- diff --git a/tests/flow-docker.test.ts b/tests/flow-docker.test.ts new file mode 100644 index 0000000..bf8767c --- /dev/null +++ b/tests/flow-docker.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { runStudy } from "../src/flow/index.ts"; +import { Ledger } from "../src/ledger/index.ts"; +import { Runner } from "../src/runner/index.ts"; +import { fullConfig } from "./config-helpers.ts"; + +// flow 碰到 Docker 的两处边界,用真实 Docker 测,不花模型的钱:走不到派模型任务就停下。 +// 镜像一律 inforsi-test- 开头的固定名字;本机还有别的项目在建删容器和镜像,只动自己前缀的。 + +const TASK_DIR = join(import.meta.dirname, "..", "tasks", "circle_packing"); + +function docker(args: string[]): { status: number | null; stdout: string; stderr: string } { + return spawnSync("docker", args, { encoding: "utf8" }); +} + +/** 删掉测试自己的镜像标签;不存在不算错,别的错误照样报。 */ +function removeImage(tag: string): void { + const removed = docker(["rmi", tag]); + if (removed.status !== 0 && !removed.stderr.includes("No such image")) throw new Error(removed.stderr); +} + +/** 一个测试用的研究编号:先清掉上次没删干净的同名镜像,测试结束后再删。 */ +function studyId(t: TestContext, suffix: string): string { + const id = `test-flow-${suffix}`; + removeImage(`inforsi-${id}`); + t.after(() => removeImage(`inforsi-${id}`)); + return id; +} + +function tempDir(t: TestContext): string { + const dir = mkdtempSync(join(tmpdir(), "inforsi-flow-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function ledgerKinds(studyDir: string): string[] { + const ledger = Ledger.open(join(studyDir, "ledger.sqlite")); + try { + return ledger.records().map((r) => r.kind); + } finally { + ledger.close(); + } +} + +test("研究编号本机用过(已有 inforsi-<研究编号> 镜像):冻结配置前报错停下,不建镜像、不写研究配置记录", async (t) => { + const id = studyId(t, "dup"); + // 一份旧研究冻结配置时建下的镜像。 + await new Runner(null).buildImage(TASK_DIR, `inforsi-${id}`); + const dir = tempDir(t); + await assert.rejects( + runStudy({ studyDir: dir, config: { ...fullConfig(), studyId: id }, stop: new AbortController().signal }), + new RegExp(`研究编号 ${id} 已被本机用过(已有镜像 inforsi-${id})`), + ); + assert.deepEqual(ledgerKinds(dir), []); +}); + +test("docker run 没启动:写结果\"没启动\"后停下,原因带 Docker 报错原文,不在本进程里起新编号重跑", { timeout: 120_000 }, async (t) => { + // 任务事实里写一个本机没有的 Docker 运行时,docker run 每次都失败,也不建容器。 + const task = join(tempDir(t), "task"); + cpSync(TASK_DIR, task, { recursive: true }); + const facts = JSON.parse(readFileSync(join(task, "task.json"), "utf8")) as { run: { runtime: string } }; + facts.run.runtime = "inforsi-test-no-such-runtime"; + writeFileSync(join(task, "task.json"), JSON.stringify(facts)); + const dir = tempDir(t); + const ending = await runStudy({ + studyDir: dir, + config: { ...fullConfig(), studyId: studyId(t, "not-started"), task }, + stop: new AbortController().signal, + }); + assert.equal(ending.status, "halted"); + assert.match((ending as { reason: string }).reason, /运行 base-1 没有启动.*unknown or invalid runtime name: inforsi-test-no-such-runtime/); + assert.deepEqual(ledgerKinds(dir), ["config", "code", "runPlanned", "runResult", "event"]); +}); diff --git a/tests/flow-next-step.test.ts b/tests/flow-next-step.test.ts new file mode 100644 index 0000000..c1c89bf --- /dev/null +++ b/tests/flow-next-step.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { stepList, type ResearchConfig } from "../src/config/index.ts"; +import type { AppendKind, Content, LedgerRecord } from "../src/ledger/index.ts"; +import type { StudyRecord } from "../src/materials/index.ts"; +import { nextStep } from "../src/flow/next-step.ts"; +import type { Json } from "../src/vocab/index.ts"; +import { fullConfig } from "./config-helpers.ts"; + +// flow 的"下一步做什么"(任务书 12 第六部分):纯函数,手写账本记录,看它给出的下一步。 +// 其余都由真实运行证明(任务书 12 第五部分),这里不写假模型、假 Docker。 + +const TASK_FACTS = JSON.parse(readFileSync(join(import.meta.dirname, "..", "tasks", "circle_packing", "task.json"), "utf8")) as Json; +const HASH = "0".repeat(64); +const measured = (value: number) => ({ status: "measured", value }) as const; + +/** 调用结束记录去掉调用编号(四种结局各自去掉)。 */ +type Ended = Content<"callEnded"> extends infer E ? (E extends unknown ? Omit : never) : never; + +/** 一本手写的账本:按写入先后编序号。内容照账本的形状写,nextStep 不再检查。 */ +class Book { + readonly records: LedgerRecord[] = []; + + add(kind: K, content: Content): this { + this.records.push({ seq: this.records.length + 1, kind, hash: HASH, content } as LedgerRecord); + return this; + } + + config(config: ResearchConfig): this { + const record: StudyRecord = { + config: config as unknown as Json, + steps: stepList(config) as unknown as Json, + prompts: { roles: {} as StudyRecord["prompts"]["roles"], tasks: {} as StudyRecord["prompts"]["tasks"] }, + image: `sha256:${HASH}`, + task: TASK_FACTS, + seed: 1, + }; + return this.add("config", record as unknown as Json); + } + + baselineCode(): this { + return this.add("code", { object: "base", version: "v-base", locations: null }); + } + + /** 一次基线运行:没启动,或启动了、判分算数或不算数。 */ + baselineRun(run: string, outcome: "notStarted" | "valid" | "invalid"): this { + const definition: Content<"runPlanned">["definition"] = { + command: ["python", "harness.py"], + workdir: "/work", + image: { digest: `sha256:${HASH}`, runtime: "runc" }, + wallSeconds: 120, + cores: 1, + inheritEnv: false, + env: {}, + }; + this.add("runPlanned", { run, purpose: "baseline", round: 0, object: null, definition }); + if (outcome === "notStarted") { + return this.add("runResult", { run, process: { ending: "notStarted", reason: "docker run 失败" }, judged: null }); + } + this.add("runStarted", { run }); + const process = { ending: "exited", exitCode: 0, cpuSeconds: measured(10), wallSeconds: measured(10), peakMemoryBytes: measured(1) } as const; + const judged = { + verdict: outcome, + verdictReason: outcome === "valid" ? "合法" : "不合法:圆重叠", + score: measured(0.9597642169962064), + metrics: {}, + answerPath: { status: "unmeasured", reason: "基线不写" }, + safeguardTriggers: { status: "unmeasured", reason: "基线不写" }, + extra: null, + } as unknown as Content<"runResult">["judged"]; + return this.add("runResult", { run, process, judged }); + } + + open(stageId: string, stage: Content<"stageOpen">["stage"], roster: string[]): this { + return this.add("stageOpen", { stageId, stage, round: 0, roster }); + } + + /** 一份被收下的提交。nextStep 只看这个人在这个阶段有没有被收下的提交,种类一律写成入口。 */ + entry(stageId: string, author: string): this { + const content = { entries: [{ name: "入口一", body: "从这里开始看" }, { name: "入口二", body: "从那里开始看" }] }; + return this.add("submission", { stageId, author, target: null, item: null, version: 1, kind: "entry", content, jev: null }); + } + + /** 一次模型调用:开始和结束。 */ + call(stageId: string, actor: string, n: number, ended: Ended): this { + const call = `${stageId}.${actor}.entry.none.${n}`; + this.add("callStarted", { call, actor, stageId, model: "iomgaa/glm-5.3", materials: null }); + return this.add("callEnded", { call, ...ended } as Content<"callEnded">); + } +} + +const R = ["researcher-1", "researcher-2", "researcher-3"]; + +/** 冻结了配置、基线跑完算数的账本。 */ +function afterBaseline(config: ResearchConfig = fullConfig()): Book { + return new Book().config(config).baselineCode().baselineRun("base-1", "valid"); +} + +test("空账本:冻结配置", () => { + assert.deepEqual(nextStep([]), { do: "freezeConfig" }); +}); + +test("有配置、没有基线:先存基线代码,再用 base-1 跑基线", () => { + const book = new Book().config(fullConfig()); + assert.deepEqual(nextStep(book.records), { do: "saveBaseline" }); + book.baselineCode(); + assert.deepEqual(nextStep(book.records), { do: "baselineRun", run: "base-1" }); +}); + +test("基线最近一次是\"没启动\":起新编号再跑", () => { + const book = new Book().config(fullConfig()).baselineCode().baselineRun("base-1", "notStarted"); + assert.deepEqual(nextStep(book.records), { do: "baselineRun", run: "base-2" }); + book.baselineRun("base-2", "notStarted"); + assert.deepEqual(nextStep(book.records), { do: "baselineRun", run: "base-3" }); +}); + +test("基线判为不算数:停下,原因写明", () => { + const book = new Book().config(fullConfig()).baselineCode().baselineRun("base-1", "invalid"); + const next = nextStep(book.records); + assert.equal(next.do, "halt"); + assert.match((next as { reason: string }).reason, /base-1 判为不算数(不合法:圆重叠)/); +}); + +test("基线算数之后:开写入口阶段,名单是全部研究者", () => { + assert.deepEqual(nextStep(afterBaseline().records), { do: "openStage", stageId: "r0-entryDraft", stage: "entryDraft", roster: R }); +}); + +test("阶段开着、一人已交、两人没交:派那两人", () => { + const book = afterBaseline().open("r0-entryDraft", "entryDraft", R).entry("r0-entryDraft", "researcher-1"); + assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2", "researcher-3"] }); +}); + +test("都交了:关阶段;关了以后开第 1 次讨论", () => { + const book = afterBaseline().open("r0-entryDraft", "entryDraft", R); + for (const r of R) book.entry("r0-entryDraft", r); + assert.deepEqual(nextStep(book.records), { do: "closeStage", stageId: "r0-entryDraft" }); + book.add("stageClose", { stageId: "r0-entryDraft" }); + assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-discussion1", stage: "discussion", roster: R }); +}); + +test("有人最近一次是\"没交\":开发入口关着先派能派的、再停下;开着重新派", () => { + const stuck = (devEntry: boolean) => + new Book() + .config({ ...fullConfig(), devEntry }) + .baselineCode() + .baselineRun("base-1", "valid") + .open("r0-entryDraft", "entryDraft", R) + .entry("r0-entryDraft", "researcher-1") + .call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" }); + // researcher-3 还没派过,先派他。 + assert.deepEqual(nextStep(stuck(false).records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-3"] }); + // researcher-3 交上以后只剩"没交"的 researcher-2:停下,附调用编号。 + const closed = stuck(false).entry("r0-entryDraft", "researcher-3"); + const next = nextStep(closed.records); + assert.equal(next.do, "halt"); + assert.match((next as { reason: string }).reason, /^交不出合格提交/); + assert.deepEqual((next as { objects: string[] }).objects, ["r0-entryDraft.researcher-2.entry.none.1"]); + // 开发入口开着:重新派。 + const dev = stuck(true).entry("r0-entryDraft", "researcher-3"); + assert.deepEqual(nextStep(dev.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2"] }); +}); + +test("有人最近一次是\"出错\"或\"进程中断\":重新派;只看最近一次调用", () => { + const book = afterBaseline() + .open("r0-entryDraft", "entryDraft", R) + .entry("r0-entryDraft", "researcher-1") + .call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(5), status: "notSubmitted", reason: "没交" }) + .call("r0-entryDraft", "researcher-2", 2, { wallSeconds: measured(5), status: "failed", reason: "模型服务报错" }) + .call("r0-entryDraft", "researcher-3", 1, { wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断" }); + assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2", "researcher-3"] }); +}); + +test("只有一位研究者:两次讨论各写一条按配置跳过,接着开认领", () => { + const one = fullConfig(); + one.models.researchers = [one.models.researchers[0]!]; + const book = afterBaseline(one).open("r0-entryDraft", "entryDraft", ["researcher-1"]).entry("r0-entryDraft", "researcher-1"); + book.add("stageClose", { stageId: "r0-entryDraft" }); + const reason = "只有一位研究者,第零轮不讨论(方法.md 4.2)"; + assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion1", reason }); + book.add("event", { event: "skippedByConfig", reason, objects: ["discussion1"] }); + assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion2", reason }); + book.add("event", { event: "skippedByConfig", reason, objects: ["discussion2"] }); + assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-claim", stage: "claim", roster: ["researcher-1"] }); +}); + +test("认领关了:分配;分配做完:停下,原因\"第 1 轮还没写\"", () => { + const book = afterBaseline(); + const stages = [ + ["r0-entryDraft", "entryDraft"], + ["r0-discussion1", "discussion"], + ["r0-discussion2", "discussion"], + ["r0-claim", "claim"], + ] as const; + for (const [stageId, stage] of stages) { + book.open(stageId, stage, R); + for (const r of R) book.entry(stageId, r); + book.add("stageClose", { stageId }); + } + assert.deepEqual(nextStep(book.records), { do: "assignEntries" }); + const output = R.map((researcher, i) => ({ researcher, entry: `e-${researcher}-1`, rank: i + 1 })); + book.add("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input: null, output }); + assert.deepEqual(nextStep(book.records), { do: "halt", reason: "第 1 轮还没写(任务书 13)", objects: [] }); +}); diff --git a/tsconfig.json b/tsconfig.json index a751c57..bf3362f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "verbatimModuleSyntax": true, "types": ["node"] }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "eval/**/*.ts"] }