feat: 诊断(任务书 18 第一块):诊断数学、四种提交、一次运行跑多个实验、账本与材料、flow 诊断各步

消融默认关,关掉时解释阶段一并跳过;选诊断实验用信息价值减成本。
eval/diagnosis/research.json 为第三块配置。vocab 字段说明的出口文字随提示词一起改,见下一次提交的 prompts/ 与同文件改动(本次含在内)。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:36:20 -04:00
parent f39f8410e7
commit 852600df02
69 changed files with 6607 additions and 214 deletions
+64 -19
View File
@@ -158,7 +158,7 @@
Jev 是一个判别器,和程序检查地位相同,不单独成一种记录:它的原始概率和结论,记在它判断的那件事里(第 3 种的提交和退回、第 8 种的判重合并)。
**写入时由 ledger 拒绝的几种情况**:阶段已关闭还要写提交;要替换的那一份不是本人在这个阶段刚交的;没有"确认启动"就写运行结果("确认没启动"这种结果除外);这次消融已经有格启动了才冻结解释。
**写入时由 ledger 拒绝的几种情况**:阶段已关闭还要写提交;要替换的那一份不是本人在这个阶段刚交的;没有"确认启动"就写运行结果("确认没启动"这种结果除外);这次消融已经有格启动了才冻结解释;这次诊断运行已经确认启动了才冻结诊断预测。
---
@@ -245,8 +245,8 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
**名单**
- `ROLES`、`Role`:三个角色,研究者、实验员、核验员。
- `STAGES`、`Stage`:有人交提交的各个阶段,按流程先后排;程序自己做的步骤不在里面。
- `SUBMISSION_KINDS`、`SubmissionKind`:每种提交属于哪个阶段、由哪个角色交,各模块都从这张表取。
- `STAGES`、`Stage`:有人交提交的各个阶段,按流程先后排;程序自己做的步骤不在里面。诊断有四个:诊断解释与实验、诊断预测、诊断实现(含修复)、诊断核验(任务书 18)。
- `SUBMISSION_KINDS`、`SubmissionKind`:每种提交属于哪个阶段、由哪个角色交,各模块都从这张表取。诊断加五种:`diagnosisProposal`、`diagnosisPrediction`、`diagnosisImplementation`、`diagnosisRepair`、`diagnosisVerification`;实现和修复同一个字段定义,写法同正向的 `implementation`、`repair`。
**指标**
@@ -276,7 +276,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
**Jev**
- `JevJudgment`(字段定义):Jev 对一个问题的一次判断:问题名、问题全文的哈希(措辞、示例、阈值一起算)、阈值、原始概率、结论(概率是否超过阈值)、Jev 返回的请求编号。jev 交出它,ledger 在提交和退回记录里原样存它,两个模块互不引用,所以放在这里。
- `Checklist`、`ChecklistOf<K>`(类型):对号清单,materials 为一次任务算出、submissions 拿来判收不收;按提交种类区分,每种只放它要用的(入口和讨论的入口个数范围;认领的入口编号和至少排几个;提案的 M、因子数上限、能延续的因子、能引用的假设专用指标(全名)、已占用的指标编号(任务的指标和两个保留键);两次预测的候选;挑刺的原提案和它的因子;回应、统一的挑刺编号(统一另有被合并的原提案);实现、修复、核验的条款、因子、保障(核验另有没有归属的改动);解释的格和 M;消融实现的格、要关的因子、全部因子;消融核验的格和全部因子;收尾无)。用户 2026-10-03 决定:vocab 只放这一个,任务材料的其余类型放 materials。
- `Checklist`、`ChecklistOf<K>`(类型):对号清单,materials 为一次任务算出、submissions 拿来判收不收;按提交种类区分,每种只放它要用的(入口和讨论的入口个数范围;认领的入口编号和至少排几个;提案的 M、因子数上限、能延续的因子、能引用的假设专用指标(全名)、已占用的指标编号(任务的指标和两个保留键);两次预测的候选;挑刺的原提案和它的因子;回应、统一的挑刺编号(统一另有被合并的原提案);实现、修复、核验的条款、因子、保障(核验另有没有归属的改动);解释的格和 M;消融实现的格、要关的因子、全部因子;消融核验的格和全部因子;收尾无;诊断解释与实验的解释上限 M、实验上限 A 和已占用的指标编号;诊断预测的自己的解释编号和合并后的实验编号;诊断实现、修复、核验的选中实验编号)。用户 2026-10-03 决定:vocab 只放这一个,任务材料的其余类型放 materials。
### rules
@@ -376,6 +376,21 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `BetResult`(类型):押的因子的结果:更新后的成分权重、权重是否无从更新、各因子的方向收益(实测换算的或估计的)、挑出的因子及其预测收益、押或不押的两种情况、关掉反而更好的格。
- `chooseBet(input)`:押的因子,用有效格的实测更新各成分的权重,再挑方向收益最大的因子;有格关掉反而更好或估计收益不为正时不押,实测落在所有成分之外时保持起始权重(6.8)。
**诊断(6.12,4.3 诊 3、诊 4)**
成分、权重更新、精确积分、部分格网格、价值减成本的公式只在 `value.ts` 写一份,消融和诊断共用,差别只在"实测 → 收益"的换算:消融是撑几成乘剩余空间 h,没有"都不走";诊断是改进量 x − b,h 取 1,另有收益为 0 的"都不走"(`floor = 0`)。消融的数字逐位不变(任务书 18 第 1.2 节)。
- `DiagnosisExplanation`(类型):一条诊断解释,带作者和它对每个合并后的实验给的三个数。
- `DiagnosisModelInput`、`DiagnosisModel`(类型):成分模型的输入和结果(因子换成实验、撑几成换成改进量)。
- `diagnosisModel(input)`:建成分模型,每条解释一个成分、开放预留一个成分,每个实验自己的分布和开放预留(6.3"诊断"、6.12"成分与权重")。
- `DiagnosisParams`、`DiagnosisCombo`(类型):余额、每批上限、u、衰减和 D;一个实验组合的 VOI、R̂、m′、价值、成本、净值。
- `evaluateDiagnosisCombos(model, params)`:列出可选组合并算价值、成本、净值:余额不到 2 次没有组合;实验数不超过 D 时只有"全做",超过 D 时是所有恰好 D 个实验的组合。R̂ = ⌈(余额 − 1) ÷ 每批上限⌉,成本按一次运行算,ĝ₀ = max(0, max_d E(g_d))。
- `DiagnosisRuleInput`、`DiagnosisDecision`、`SkippedReason`(类型):选诊断实验的输入和结果,结果含 6.12 要求附上的全部数字:参数、成分与起始权重、各实验的事前期望收益、ĝ₀、全部组合的价值成本净值、做不做、选中的实验及其净值、每个实验做不做和没做的原因(放不下、信息价值不足以抵过成本)以及含它的净值最大组合。
- `selectDiagnosis(input)`:选诊断实验规则"信息价值减成本":取净值最大且 > 1e-12 的组合,差不到 1e-12 的按实验编号比(和选格同一条挑法),都不大于 1e-12 时不诊断。
- `ExperimentOutcome`(类型):一个实验的结局:有效分数、证据不足、没做(和消融一格同形)。
- `SettleDiagnosisInput`、`DiagnosisDirection`(类型):一次诊断结算的输入;诊断推出的方向(更新后的权重、各实验的收益、收益最大的实验、有没有指出方向)。
- `settleDiagnosis(input)`:一次诊断的结算:每个实验的收益和合并范围,有效实验的世界比特、惊讶、分歧量;每条解释在每个有效实验上的确认或否定(用 `judgeExplanations`);诊断推出的方向,最大收益不为正时 `found` 为 false。证据不足和没做的实验不判、不记账。
**信息账(6.9)**
这一组是方法里"唯一的尺子",不随选批、选格规则变,一律在分数原值上从本批基线 b 起按格宽切小格。
@@ -397,9 +412,9 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `runBalance(total, runs)`:从运行记录数出已扣次数、占用名额和余额;确认启动的扣一次,已预留的先占着名额,确认没启动的退还。只数运行次数,不算计算时间。
- `forwardBatchSize(batchCap, balance)`:本轮正向批量 k = min(每批上限, 余额)。
- `ablationCellLimit(balance)`:消融最多测"余额 − 1"格,余额为 0 或 1 时是 0。
- `Phase`(类型):正向或消融。
- `Phase`(类型):正向、诊断或消融。
- `Tally`(类型):一个人的战绩:总数、中位数偏高几次、偏低几次、被穿过上沿几次、下沿几次、包住几次、落点四段计数。
- `trackRecord(samples)`:按研究者、按正向与消融分开数战绩,只用来显示,不改任何权重(6.9、6.10)。
- `trackRecord(samples)`:按研究者、按正向、诊断与消融分开数战绩,结果按研究者编号、正向、诊断、消融排,只用来显示,不改任何权重(6.9、6.10)。
- `authorVsMerged(items, direction)`:"作者自评 vs 大家合并",给出作者比大家乐观多少,以及实测落在两个范围的哪一段;只收已经跑过、有有效成绩的候选(6.10 第 4 条)。
**入口分配、种子与规则登记表(4.2、第九部分、第十一部分)**
@@ -411,8 +426,10 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `seededSample(items, size, seed)`:用种子从一组编号里不放回地均匀抽 size 个,结果只取决于种子和编号集合,与传入次序无关;候选和因子都这样抽。
- `BATCH_RULES`、`BatchRuleName`:选批规则登记表,名字 → 函数和版本号,两条:批内最好期望、均匀随机。
- `CELL_RULES`、`CellRuleName`:选格规则登记表,四条:信息价值减成本、全测、随机抽同样多的格、都不测。
- `DIAGNOSIS_RULES`、`DiagnosisRuleName`:选诊断实验规则登记表,只有一条:信息价值减成本(6.12),配置里不选,步骤清单的 `selectDiagnosis` 一步带它的名字和版本。
- `runBatchRule(name, input)`:按名字跑一条选批规则,结果带上规则名和版本,flow 写决定记录时从这里取。
- `runCellRule(name, input)`:按名字跑一条选格规则,结果带上规则名和版本。
- `runDiagnosisRule(name, input)`:按名字跑选诊断实验规则,结果带上规则名和版本。
### ledger
@@ -420,9 +437,9 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
**打开与写入**
- `Ledger.open(path)`:打开一份研究的账本,新文件就建表,旧文件核对表结构版本号(现为 4),对不上就报错。
- `Ledger.open(path)`:打开一份研究的账本,新文件就建表,旧文件核对表结构版本号(现为 5),对不上就报错。
- `ledger.close()`:关闭数据库。
- `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的四种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算(按轮次,一轮一条);其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。
- `ledger.append(kind, content)`:追加一条记录并返回它。先按种类检查内容,再做第四部分说的五种拒收检查(第 1 条是只收开着的阶段的提交,即有开阶段记录、没有关阶段记录;第 5 条是这一轮的诊断运行已经确认启动,就不能再关这一轮的诊断预测阶段),都在同一个事务里。有自己编号、按定义只发生一次的记录按编号加了唯一约束,重复写就报错:阶段开、阶段关、运行登记、确认启动("结果是没启动"也算在这一条里,一次运行两样只能有一样)、运行结果、模型调用开始、模型调用结束、消融结算、诊断结算(都按轮次,一轮一条);其余记录照写。记录之间的先后(例如结果前要先有登记)不查。写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存。
- `ledger.transaction(fn)`:里面的几次追加要么全部写进去,要么一条都不写;fn 必须是同步函数,返回值类型排除了 Promise,异步函数在类型检查时就被挡住;不能嵌套。
**读取**
@@ -435,7 +452,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
**类型**
- `Kind`:记录的种类,共 19 种:`config`(研究配置)、`stageOpen` / `stageClose`(阶段开、关)、`submission`(提交的每一版,外层带收下时用到的 `JevJudgment` 数组,没用到 Jev 的为 null)、`rejection`(退回,带对象 `target`(与提交的 target 同义,没有对象的写 null;同一人在同一阶段可能对几个对象各做一次同种任务,materials 按它找本人这个对象的上一次退回)、原样内容和理由;Jev 退回的,原样存这次用到的一条或几条 `JevJudgment`,程序检查退回的这一项写 null)、`note`(私人笔记,每次修改追加一条,以最后写入的一条为准,不带版本号)、`protocol`(冻结协议)、`seal` / `unseal`(作者自评的封存、解封,都用记录序号指明是哪一条)、`code`(冻结代码)、`runPlanned` / `runStarted` / `runResult`(运行的登记要跑、确认启动、结果)、`decision`(决定)、`settlement`(一轮的消融结算,按轮次唯一)、`callStarted` / `callUsage` / `callEnded`(模型调用的开始,附这次任务的全部材料;每次回复的用量;结束,带墙钟时间(秒,`Measured`:中断后恢复时补写的结束记录,结局记出错、原因写"进程中断",墙钟时间记测不到),结局与 agents 的会话结局同名:交了、没交(带原因)、出错(带原因)、被停下)、`event`(事件)。
- `Kind`:记录的种类,共 20 种:`config`(研究配置)、`stageOpen` / `stageClose`(阶段开、关)、`submission`(提交的每一版,外层带收下时用到的 `JevJudgment` 数组,没用到 Jev 的为 null)、`rejection`(退回,带对象 `target`(与提交的 target 同义,没有对象的写 null;同一人在同一阶段可能对几个对象各做一次同种任务,materials 按它找本人这个对象的上一次退回)、原样内容和理由;Jev 退回的,原样存这次用到的一条或几条 `JevJudgment`,程序检查退回的这一项写 null)、`note`(私人笔记,每次修改追加一条,以最后写入的一条为准,不带版本号)、`protocol`(冻结协议)、`seal` / `unseal`(作者自评的封存、解封,都用记录序号指明是哪一条)、`code`(冻结代码)、`runPlanned` / `runStarted` / `runResult`(运行的登记要跑、确认启动、结果;登记的用途有基线、正向、消融、诊断四种)、`decision`(决定)、`settlement`(一轮的消融结算,按轮次唯一)、`diagnosisSettlement`(一轮的诊断结算,按轮次唯一,和消融结算各管各的)、`callStarted` / `callUsage` / `callEnded`(模型调用的开始,附这次任务的全部材料;每次回复的用量;结束,带墙钟时间(秒,`Measured`:中断后恢复时补写的结束记录,结局记出错、原因写"进程中断",墙钟时间记测不到),结局与 agents 的会话结局同名:交了、没交(带原因)、出错(带原因)、被停下)、`event`(事件)。
- `AppendKind`:调用方能追加的种类,除了 `seal`(封存只由 ledger 在写提案时一并写)。
- `Content<K>`:某种记录存下的内容。
- `Input<K>`:追加时交的内容;只有提交和存下的不同:提案带着作者自评。
@@ -454,7 +471,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
**字段定义**
- `ResearchConfig`(字段定义):research.json 的全部字段,全部必填,不认识的字段报错。每个字段带中文说明(`description`)和类别(`category`:用户配置或方法参数),配置对话照着它问,不另写一份。`models` 里除了各角色的模型,还有 Jev 用的模型名(`models.jev`,用户配置);Jev 的密钥不进配置,从环境变量读。流程是两选一:完整流程写四个开关、选批规则、它才用得到的 λ、u、衰减;朴素循环只写 `preset`,别的都写不进去。
- `ResearchConfig`(字段定义):research.json 的全部字段,全部必填,不认识的字段报错。每个字段带中文说明(`description`)和类别(`category`:用户配置或方法参数),配置对话照着它问,不另写一份。`models` 里除了各角色的模型,还有 Jev 用的模型名(`models.jev`,用户配置);Jev 的密钥不进配置,从环境变量读。流程是两选一:完整流程写开关(含诊断:关,或开并写每人每轮诊断实验上限 A 和一次诊断最多做几个实验 D,两个都是方法参数)、选批规则、它才用得到的 λ、u、衰减;朴素循环只写 `preset`,别的都写不进去。
- 研究编号 `studyId` 只许小写字母、数字和 `._-`,首字符是小写字母或数字:它会进 Docker 镜像名,镜像名只认小写。(来源:用户 2026-10-03 同意,任务书 13)
- `task`、`promptDir` 必须是绝对路径:从别的目录接着跑一份研究时,相对路径会读到别处,而任务目录没有哈希核对挡着。(来源:用户 2026-10-03 同意,任务书 13)
- `FIELD_CATEGORIES`、`FieldCategory`:字段的两种类别,用户配置、方法参数。
@@ -467,7 +484,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `Step<S>`(类型):一步:编号、规则(只有选批、选格有,其余为空)、执行或跳过及原因。
- `RuleRef`(类型):决策点用的规则,名字和版本。
- `STUDY_STEPS`、`StudyStepId`:整份研究的步骤,按先后排。
- `ROUND_STEPS`、`RoundStepId`:每一轮的步骤,按先后排;比第三部分拆得更细(例如"回应与统一"和"协议冻结"分成两步,挑刺与回应关掉时协议照样冻结)。
- `ROUND_STEPS`、`RoundStepId`:每一轮的步骤,按先后排;诊断七步(`diagnosisProposal`、`diagnosisDedupe`、`diagnosisPrediction`、`selectDiagnosis`、`diagnosisImplementation`、`diagnosisRun`、`diagnosisSettle`)排在 `carryOverPool` 与 `proposal` 之间,`selectDiagnosis` 带规则名和版本;诊断关掉时七步都按配置跳过,消融关掉时 `explanation` 也跳过;比第三部分拆得更细(例如"回应与统一"和"协议冻结"分成两步,挑刺与回应关掉时协议照样冻结)。
### runner
@@ -528,13 +545,14 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
下面是 `src/materials/index.ts` 导出的全部内容。谁能看什么每种任务一个函数,对着方法.md 第三部分、4.2、4.3 写,不写通用判定(用户 2026-10-02)。materials 只读账本,不写;任务材料由 flow 整份存进"模型调用开始"记录。依赖只有 ledger、rules、vocab;代码比较结果、运行产物、提示词由 flow 传进来。
- `buildMaterial(req)`:生成一份任务材料:给模型看的各节、可查询的记录、拼好的第一条消息、对号清单、能读的代码版本。研究者的材料按"任务说明 → 你自己的 → 公共材料 → 现在交什么"排;实验员、核验员的按"任务说明 → 分给你的材料 → 现在交什么"排,不给信念本和成绩。同一人重新发起同一任务时,最前面放上一次被退回的原因:按阶段、作者、提交种类、对象找本人最后一条退回记录。"现在交什么"由 vocab `SUBMISSION_SCHEMAS` 的字段说明生成。几种任务的取法:
- `buildMaterial(req)`:生成一份任务材料:给模型看的各节、可查询的记录、拼好的第一条消息、对号清单、能读的代码版本。研究者的材料按"任务说明 → 你自己的 → 公共材料 → 现在交什么"排;实验员、核验员的按"任务说明 → 分给你的材料 → 现在交什么"排,不给信念本和成绩。同一人重新发起同一任务时,最前面放上一次被退回的原因:按阶段、作者、提交种类、对象找本人最后一条退回记录。"现在交什么"由 vocab `SUBMISSION_SCHEMAS` 的字段说明生成。`StudyFacts` 多一个 `diagnosisExperimentLimit`(每人每轮诊断实验上限 A,诊断关掉为 null),诊 1 的对号清单用它。几种任务的取法:
- 讨论、认领:展示讨论原文时,原文引用的入口编号已经作废的(讨论时整份替换过清单),在它后面就地标"(已作废)",读的人才分得清哪些编号还有效(用户 2026-10-03 同意,任务书 14 第 2.5 节)。前后紧挨字母、数字或"-"的不算,`e-x-1` 不会匹配到 `e-x-12` 里。
- 第二次预测:冻结协议不带答复,"挑刺与答复"从这个候选已公开的最后一份回应或统一里读(回应另给答复正文);留池候选读它冻结那一轮的(用户 2026-10-03 定)。
- 核验:不放 Jev 对这份实现的判断。能走到核验说明 Jev 已判过,Jev 是判别条件,不是给核验员的提示。
- 消融实现:这一格已有公开的消融核验(核验不通过、重做)时,和修复一样从上一次交的这一格代码接着改(用户 2026-10-03 定):工作目录写"上一次交的这一格代码的副本",能读的版本里加上它,完整代码照旧列出作对照;另放"退回意见":最近一次核验里关错了的因子和核验员写的依据,写法同修复的退回意见。
- 诊断(方法.md 第三部分、4.3 诊 1–诊 4):诊 1 放当前基线的代码、产出它的那次运行的结果和原始答案(产物放进查询,flow 经 `artifacts` 传进来)、信念本、上一轮收尾,第 1 轮的第零轮记录在研究记录里;诊 2 放合并后的诊断实验(标明作者)和全部诊断解释,自己的解释另列,别人在这一步交的数一概不读;诊断实现、修复、核验只看选中的实验原文和当前基线代码(实现、修复另告诉原始产物已只读放进工作目录,位置在任务说明里;修复另放退回意见,只修没通过的实验;核验放当前基线到诊断程序的改动和位置表),不给信念本和成绩。从 ② 提案起的全部研究者任务,公共材料里在"基线"之后加"本轮诊断结果"一节(每个实验的做法、实测、用时、结局,每条解释确认或否定,推出的方向;本轮没诊断写原因:余额不到 2 次、配置关掉、信息价值不抵成本、选中的实验一个都没剩下);诊断运行在诊断结算写入后公开,诊断程序进"能读的代码",各人给的三个数不进任何记录或查询。实现、修复正向候选的实验员,本轮有诊断时多一个只读的诊断程序目录,注明"冻结协议引用诊断实验时作参考"。信念本加"诊断历史",战绩加"诊断"一档;诊断实验的实测不算"手里最好"(诊断运行的运行记录不带判分,逐个实验的结果只在诊断结算里)。
- 指标:冻结之后一律用全名"候选编号.短名":已登记指标清单、对号清单的 `reusableMetrics`、实验员材料的"要实现的指标定义"(候选代码在结果文件里也用全名当键)。提案、回应、统一的对号清单里,已占用的指标编号只有任务的指标、`answer-path` 和 `safeguard-triggers` 两个保留键(用户 2026-10-03 定);已登记的全名带 `.`,新登记的短名不带,不用列进来。
- `Board`(类):materials 读账本的那一份,构造参数是账本记录数组。flow 的"下一步做什么"是只拿得到记录数组的纯函数,它和 flow 的三个程序步骤都经这一份读账本,不另写一份(任务书 14 第 2.4 节)。flow 用到的:`stages`、`stage`(阶段的轮次、名单、关没关)、`published`(已公开的某种提交,最后一版为准)、`decision`(某轮某种决定)、`proposal`、`proposalsOf`、`authorsOf`(候选合并了哪些原提案、作者按编号排)、`answerOf`(冻结出候选的最后一份回应或统一)、`protocol`、`protocols`、`carried`(留池候选)、`baselineOf`(第 r 轮的本批基线)、`baselineScore`、`runStates`。任务书 14 写的是导出 `baselineOf`、`baselineScore` 两个函数,实际导出了整个类,原因是纯函数只拿得到记录数组,要用的远不止这两样(这一处偏离待用户确认)。
- `Board`(类):materials 读账本的那一份,构造参数是账本记录数组。flow 的"下一步做什么"是只拿得到记录数组的纯函数,它和 flow 的三个程序步骤都经这一份读账本,不另写一份(任务书 14 第 2.4 节)。flow 用到的:`stages`、`stage`(阶段的轮次、名单、关没关)、`published`(已公开的某种提交,最后一版为准)、`decision`(某轮某种决定)、`proposal`、`proposalsOf`、`authorsOf`(候选合并了哪些原提案、作者按编号排)、`answerOf`(冻结出候选的最后一份回应或统一)、`protocol`、`protocols`、`carried`(留池候选)、`baselineOf`(第 r 轮的本批基线)、`baselineScore`、`runStates`;诊断另有:`runStatesBefore(round)`(这一轮之前各次运行的状态,算诊断开始时的余额)、`diagnosisSettlement(round)`、`diagnosisSettlements()`、`diagnosisDone(round)`(提案阶段开了,就是诊断这一段走完了或本轮不诊断)、`diagnosisExplanations(round)`(已公开的诊断解释:编号、作者、正文)、`diagnosisExperiments(round)`(合并后的诊断实验:`MergedExperiment`,编号、全部作者、正文取作者里编号最小的那一份、登记的指标已改全名"实验编号.短名")。任务书 14 写的是导出 `baselineOf`、`baselineScore` 两个函数,实际导出了整个类,原因是纯函数只拿得到记录数组,要用的远不止这两样(这一处偏离待用户确认)。
- `protocolMetrics(board, protocol)`:把冻结协议自定义及引用的指标合成原登记定义,实验员材料与 flow 问 Jev、正式运行共用(来源:任务书 16 第 2.2、2.4 节)。
- `finalEntries(ledger)`:按账本取的最终有效的入口(各人最后一版入口清单里的每个入口,带作者和正文);flow 分配入口时用,和认领的对号清单是同一份(任务书 12 第三部分)。
- `queryMaterial(material, id)`:只在这份材料的记录里按编号找,交回全文;找不到交回 null(agents 的查询回调据此答"看不到")。
@@ -542,12 +560,13 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `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 不改):`StudyRecord`(研究配置记录:配置、步骤清单、提示词每个文件的哈希、镜像编号、任务事实、种子;配置和任务事实存 Json,flow 读回时用 `checkConfig`、`checkTaskFacts` 再检查,任务书 12 第三部分)、`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`(八种决定:入口分配、判重合并、选批、采纳、选格、押的因子、诊断实验判重合并(`diagnosisDedup`)、选诊断实验(`selectDiagnosis`),输入输出用 rules 的类型;没采纳时也写一条采纳决定)、`DedupOutput`(判重合并的输出)、`DiagnosisDedupOutput`(诊断实验判重合并的输出:合并后的每个实验由哪几个作者的哪几个实验合成,第一个的正文是合并后的正文)、`Settlement`(消融结算)、`DiagnosisSettlement`(诊断结算:运行编号、每个跑过的实验单独判分的结果(与 tasks 的 `DiagnosisJudged` 同形)、核验不通过而拿掉的实验和原因、`settleDiagnosis` 的输入输出)、`DiagnosisLocationTable`(诊断程序的位置表,每个选中的实验一项)。诊断运行的运行记录里 `judged` 写 null。
- `freezeProtocol(body, candidate, round, baseline, proposals)`:flow 写冻结协议时调用。输入 submissions 交回的冻结协议主体、候选编号、轮次、基线、合并了哪些原提案,交回 `FrozenProtocol`;新登记的假设专用指标改用全名"候选编号.短名"(例如 `r2-c1.restart-gain`),引用的本来就是全名,照抄。加前缀只在这一处:同一轮的研究者互相看不到提案,会起同一个短名、定义却不同,候选编号判重合并之后才有,所以只能在冻结时加(用户 2026-10-03 定)。
- `experimenterId(candidate)`、`verifierId(candidate)`:每个候选固定一位实验员、一位核验员,修复及再次核验沿用编号(来源:任务书 16 第 2.4 节)。
- `FORWARD_NOT_RUN`、`forwardNotRunEvent(候选, 原因)`:正向候选修复用完仍未通过核验时的“不跑”事件,`objects` 只有该候选;不扣次(来源:任务书 16 第 2.2 节)。
- 事件:`CELL_NOT_RUN`、`cellNotRunEvent(格, 原因)`:一格消融核验不通过、重做次数用完而不跑时,flow 用它写事件记录(`objects` 只有这一格),结算记证据不足(方法.md 4.3 ⑯、6.6)。
- 编号规则:`ORIGINAL_BASELINE`、`researcherId`(第 i 位研究者,从 0 数,编号 `researcher-<i+1>`,任务书 12 第三部分)、`entryId`、`proposalId`、`critiqueId`、`candidateId`、`factorId`、`cellId`、`splitCell`、`explanationId`、`callId`,以及协议内的局部编号 `factorKey`(p1…)、`clauseKey`(c1…)。入口编号按作者一直往后编,讨论时整份替换也不复用旧编号。
- 事件:`DIAGNOSIS_NOT_RUN`、`diagnosisNotRunEvent(轮次, 原因)`:选中的诊断实验核验都没通过、修复用完而一个都不剩时,flow 写这条事件(`objects` 只有本轮诊断的编号),不跑、不扣次;材料里"本轮没有诊断"的原因从它读。
- 编号规则:`ORIGINAL_BASELINE`、`researcherId`(第 i 位研究者,从 0 数,编号 `researcher-<i+1>`,任务书 12 第三部分)、`entryId`、`proposalId`、`critiqueId`、`candidateId`、`factorId`、`cellId`、`splitCell`、`explanationId`,诊断的 `diagnosisId(轮次)`(本轮诊断:诊断程序的冻结代码、诊断运行、诊断实现与核验的对象)、`diagnosisKey(i)`(合并后第 i 个诊断实验,d1…)、`diagnosisExplanationId(轮次, 作者, i)`(诊断解释,`r1-researcher-1-dx1`),`callId`,以及协议内的局部编号 `factorKey`(p1…)、`clauseKey`(c1…)。入口编号按作者一直往后编,讨论时整份替换也不复用旧编号。
### agents
@@ -563,12 +582,15 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
### jev
下面是 `src/jev/index.ts` 导出的全部内容。九问的问法和阈值照参考项目,只改了说法和字段名;阈值没有在新项目的材料上校准过,等真实研究的数据(任务书 08 第三部分)。模型名由调用方从 config 的 `models.jev` 传入,密钥从环境变量 `TYPESAFE_API_KEY` 读进请求头。调不通时在发请求那一处有限次重试,仍不通就报错;回答读不懂报错并带请求编号。
下面是 `src/jev/index.ts` 导出的全部内容。九问的问法和阈值照参考项目,只改了说法和字段名;诊断实验另有四问(判重一问,冻结检查三问),照判重、冻结条款的写法,阈值暂用它们的值;阈值都没有在新项目的材料上校准过,等真实研究的数据(任务书 08 第三部分、任务书 18 第三块)。模型名由调用方从 config 的 `models.jev` 传入,密钥从环境变量 `TYPESAFE_API_KEY` 读进请求头。调不通时在发请求那一处有限次重试,仍不通就报错;回答读不懂报错并带请求编号。
- `judgeSameChange(model, a, b)`:③ 判重,两条提案的因子清单是不是同一个改法,一对一次请求,交回一条 `JevJudgment`。
- `checkProtocol(model, protocol)`:⑥ 查冻结条款,三问(改动了评测、自相矛盾、太含糊)各一次请求,交回 `JevCheck`。
- `checkHunk(model, hunk, protocol)`:⑨ 查一段代码改动,五问(不固定种子的随机数、没登记就读时钟、算登记指标的方式与定义不符、比登记的停止条件更早结束、测不到的量写成 0)同一份材料一次请求,交回 `JevCheck`。对照的指标取自冻结协议,编号是全名。
- `judgeSameExperiment(model, a, b)`:诊 2 诊断实验判重,两个实验(名称、动哪一处、怎么动)是不是同一个实验,一对一次请求,交回一条 `JevJudgment`;阈值暂用判重的 0.3,待校准。
- `checkExperiment(model, experiment)`:诊 1 查一个诊断实验,改动了评测、自相矛盾、太含糊三问各一次请求,用冻结三问的诊断变体(材料是"一个诊断实验",不是冻结协议),交回 `JevCheck`;阈值暂用冻结三问的值,待校准。诊断程序的代码改动直接用 `checkHunk` 的五问。
- `codeHunks(comparison)`:把 workspace 的比较结果(文件清单和差异原文)切成差异块;没有 `@@` 行的改动(二进制、只改可执行位、空文件增删)不出块,由程序检查。
- `DedupExperiment`、`DiagnosisExperiment`(类型):诊断实验判重和冻结检查收的输入。
- `DedupFactor`、`FrozenProtocol`、`ProtocolMetric`、`ProtocolRules`、`CodeHunk`(类型):三个函数收的带类型输入,取自 vocab 的字段定义;材料由 jev 自己拼。`DedupFactor` 是因子的名称、做法和关键设定:问题写着"设定不同就不算同一个改法",所以关键设定要给 Jev 看,判重的材料说明里也用一句话讲了 `keySettings` 是什么。
- `JevCheck`(类型):每一问的判断,以及有没有任何一问判"是"。
@@ -576,12 +598,13 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
下面是 `src/submissions/index.ts` 导出的全部内容。submissions 判一份提交收下还是退回:先做程序检查(vocab 字段定义管不到的跨字段规则,问题一次列全),有一项不过就退回、不问 Jev;全过了再问 Jev,几问问完、有一问判"是"就退回。它不读也不写账本,只依赖 jev 和 vocab;要 workspace、tasks、materials 算的东西由 flow 算好传进来。Jev 调不通、回答读不懂时 jev 报错,这里不接住,flow 按内部错误停下。
- `receive(incoming)`:判一份提交,交回 `Verdict`。回应、统一先拼好冻结协议主体,程序检查、问 Jev、交回都用这同一份;回应、统一问冻结条款三问,实现、修复对每个差异块问代码改动五问,其余种类不问 Jev。Jev 退回的原因只列判"是"的几问,用 jev 的英文问题名(用户 2026-10-03 定),差异块附原文。
- `receive(incoming)`:判一份提交,交回 `Verdict`。回应、统一先拼好冻结协议主体,程序检查、问 Jev、交回都用这同一份;回应、统一问冻结条款三问,实现、修复(含诊断程序)对每个差异块问代码改动五问,诊断解释与实验对每个实验问诊断变体的冻结三问,其余种类不问 Jev。诊断另有四条程序检查:解释 1 至 M 条、实验 1 至 A 个;诊断预测覆盖自己每条解释 × 每个合并后的实验、不多不少、三个数严格递增;诊断实验登记的指标只能是"缺了只记缺失";诊断核验的逐个实验结果覆盖全部选中的实验。Jev 退回的原因只列判"是"的几问,用 jev 的英文问题名(用户 2026-10-03 定),差异块附原文。
- `protocolBody(from)`:拼冻结协议主体,只在这一处拼。回应:因子、保障、指标、正文四项,`changes` 写了哪项就整项换新,没写的用原提案的,实现条款取回应自己的;统一:直接用统一提交自己的字段。答复不进协议(用户 2026-10-03 定)。
- `verificationOutcome(v)`:一份已收下的核验通过没有:任何条款、因子、保障没落实,或任何没有归属的改动判为越界,就不通过;四类分开列(方法.md 4.3 ⑨)。
- `ablationOutcome(v, off, alsoOff)`:一份已收下的消融核验通过没有:要关的因子和实验员报的一起关的因子都写了"关着"、其余都写了"照留"才通过;分开列该关却照留的和该留却关了的(方法.md 4.3 ⑯)。
- `diagnosisOutcome(v)`:一份已收下的诊断核验逐个实验通过没有:`followed` 或 `nothingElse` 任一为 false 的实验不通过,分开列没照写法做的和动了写法以外地方的(方法.md 4.3 诊 4)。
- `Incoming`(类型):交给 `receive` 的一份提交,按提交种类区分:内容、这次任务的对号清单、提交之后才知道的东西(`KnownOf`)、Jev 的模型名。
- `KnownOf<K>`(类型):提交之后才知道的东西,没有的种类为 null:提案是本人本阶段已收下几条,回应是作者那条原提案,实现和修复是 `ForwardCode`,消融实现是 `AblationCode`(交了"跑不起来"时为 null)。
- `KnownOf<K>`(类型):提交之后才知道的东西,没有的种类为 null:提案是本人本阶段已收下几条,回应是作者那条原提案,实现和修复是 `ForwardCode`,消融实现是 `AblationCode`(交了"跑不起来"时为 null),诊断实现和修复也是 `ForwardCode`。
- `ForwardCode`(类型):实现、修复的代码:存不成版本时带路径和原因;存成了带改了的不许改的文件、和本批基线比出的差异块、给 Jev 对照的协议条款。
- `AblationCode`(类型):消融实现的代码:存不成版本时带路径和原因;存成了只带改了的不许改的文件。
- `OriginalProposal`(类型):回应拼协议时叠在下面的原提案:因子、保障、指标、正文,作者自评不进协议。
@@ -590,7 +613,7 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
### flow
下面是 `src/flow/index.ts` 导出的全部内容,以后 plugin 也只用它。flow 只经各模块的 `index.ts` 用它们;正式代码里没有为检验写的参数、环境变量、回放模式或假实现(CLAUDE.md"测试")。这一节对应 flow 第 1–4 部分(任务书 12、14、16、17):骨架、研究开始、第零轮、第 r 轮从开轮到收尾、一轮接一轮直到余额用完;余额为 0 时走到 `export` 停下,原因"导出还没写"(导出还没写)。轮次按"第 r 轮"写,不写死轮数。
下面是 `src/flow/index.ts` 导出的全部内容,以后 plugin 也只用它。flow 只经各模块的 `index.ts` 用它们;正式代码里没有为检验写的参数、环境变量、回放模式或假实现(CLAUDE.md"测试")。这一节对应 flow 第 1–5 部分(任务书 12、14、16、17、18):骨架、研究开始、第零轮、第 r 轮从开轮(含诊断)到收尾、一轮接一轮直到余额用完;余额为 0 时走到 `export` 停下,原因"导出还没写"(导出还没写)。轮次按"第 r 轮"写,不写死轮数。
- `runStudy(input)`:推进一份研究,直到停下。输入三样,都必填:研究目录;配置(新研究交 `checkConfig` 检查过的配置,接着跑一份已有的研究写 null,以账本里冻结的那份为准;两者对不上就报错);"停下"信号。每次启动走同一条路:打开账本;新研究先冻结配置,本机已有 `inforsi-<研究编号>` 的镜像就说明研究编号用过了,报错停下,要换一个编号(编号重复的新研究会把旧研究遗留的同名容器当成自己的运行);已有研究的,核对冻结的配置、镜像还在、提示词哈希没变(开发入口开着时提示词可以变);给开始了没结束的模型调用补写结束(出错、"进程中断"、墙钟时间测不到);按容器名问 Docker 核对登记了没结果的正式运行(没启动的写"没启动"退还名额,启动了的补写确认启动、等它、判分、写结果,问不到的写"状态未知"事件并停下);然后进主循环:读账本 → 问"下一步做什么" → 做这一步 → 回到开头。主循环里 `docker run` 没启动的,写完"没启动"结果(退还名额、记下 Docker 报错原文)就停下,停下原因带上报错原文,本进程不重跑;下次启动时照旧起新编号重跑。恢复时补写的"没启动"不停下。
- `StudyInput`(类型):上面三样输入。
@@ -607,6 +630,12 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
| `entryDraft`、`discussion1`、`discussion2`、`claim` | 这个阶段的关闭记录(`stageClose`);阶段编号 `r0-<步骤>` |
| `assignEntries` 分配 | 第 0 轮的入口分配决定(`decision`,种类 `entries`) |
| `openRound`、`carryOverPool` | 不写记录:本轮名单和提案上限就是冻结的配置(方法.md 4.3 ①),留池候选由 materials 从记录算出(`Board.carried`) |
| `diagnosisProposal`、`diagnosisPrediction` 诊 1、诊 2 | 这一轮这个阶段的关闭记录;阶段编号 `r<轮>-diagnosisProposal`、`r<轮>-diagnosisPrediction`。余额只算本轮开始之前的运行,不到 2 次时本轮不诊断:诊 1–诊 4 不开阶段、不写任何记录,材料里的"本轮没有诊断:余额只剩……"从余额现算 |
| `diagnosisDedupe` 诊 2 判重合并 | 本轮的诊断实验判重合并决定(`decision`,种类 `diagnosisDedup`) |
| `selectDiagnosis` 诊 3 | 本轮的选诊断实验决定(`decision`,种类 `selectDiagnosis`);定了不诊断也写,之后诊 4 的几步都不做 |
| `diagnosisImplementation` 诊 4 实现、核验、修复 | 最近一次核验里至少有一个实验通过(不通过的拿掉);或修复次数用完、一个都不剩而且有"不跑"事件(`diagnosisNotRun`,`objects` 是本轮诊断)。阶段编号 `r<轮>-diagnosisImplementation-1`、`r<轮>-diagnosisVerification-1`、`r<轮>-diagnosisRepair-1`、`r<轮>-diagnosisVerification-2`……第几次从账本数,不另存 |
| `diagnosisRun` 诊 4 运行 | 本轮诊断运行最近一次有结果,而且不是"没启动";没有诊断、或实验一个都没剩下时不登记 |
| `diagnosisSettle` 诊 4 结算 | 本轮的诊断结算记录(`diagnosisSettlement`,按轮次唯一);没有诊断、或实验一个都没剩下时不写 |
| `proposal`、`firstPrediction`、`critique`、`response`、`secondPrediction` | 这一轮这个阶段的关闭记录(`stageClose`);阶段编号 `r<轮>-<阶段>` |
| `dedupe` 判重合并 | 本轮的判重合并决定(`decision`,种类 `dedup`) |
| `freezeProtocol` 冻结协议 | 本轮每个新候选都有冻结协议记录(`protocol`);全部候选在同一个事务里写 |
@@ -642,6 +671,10 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
| 实现 | 每个入选候选一个实验员,从本批基线取工作目录,不同候选同时实现 | 候选 |
| 核验 | 每个待核验候选一个核验员;首次核验本批全部,之后只核验刚修复的候选 | 候选 |
| 修复 | 只给核验不通过的候选,交原实验员,从上次提交的代码接着改 | 候选 |
| 诊断解释与实验、诊断预测 | 每位研究者一个任务 | 无 |
| 诊断实现 | 一位实验员(`experimenter-<本轮诊断>`),把选中的全部实验写进一份诊断程序,从当前基线取工作目录,再放进只读的原始产物 | 本轮诊断(`r<轮>-diagnosis`) |
| 诊断核验 | 一位核验员(`verifier-<本轮诊断>`),逐个实验交结果;每次核验都覆盖全部选中的实验 | 本轮诊断 |
| 诊断修复 | 只在最近一次核验有实验不通过时,交原实验员,从上次提交的诊断程序接着改,只修不通过的实验;修复后再核验,次数用同一个 `maxRepairs` | 本轮诊断 |
| 解释 | 每位研究者一个任务 | 无 |
| 消融实现 | 每个选中的格一个实验员;第一次从被采纳的完整代码取工作目录,重做时从上一次交的这一格代码接着改;不同格同时实现 | 消融格 |
| 消融核验 | 每个交了代码的格一个核验员;首次核验全部,之后只核验刚重做的格 | 消融格 |
@@ -680,6 +713,18 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- 通过核验的候选用既有 `formalRun`,用途 `forward`;先登记占名额,确认启动才扣次,再独立判分记结果。**一批内一个接一个跑**,避免抢 CPU 改变按秒停下的程序成绩。没启动的处理同基线:本进程停下,下次启动起新编号。
- 本批全部有结果或“不跑”事件后,用 rules 的 `adopt`(内部用 `adoptionLine`),只传有效实测分数、本批基线分数和任务的方向、ε、已知最好;写种类 `adopt` 的决定,即使 `adopted` 为 null 也写。materials 据此公开本轮正向运行。
**诊断**(来源:任务书 18 第 1.6 节,方法.md 4.3 诊 1–诊 4、6.12):
- 做不做:余额(`runBalance`,只算本轮开始之前的运行,用 `Board.runStatesBefore`)不到 2 次时,诊 1–诊 4 都不做。余额不算本轮诊断运行自己扣的次数,所以诊断这一段的后几步不会因为扣次后余额变小被误算成"不诊断"。诊断关着时这几步按配置跳过,和此前一致。
- 诊 2 判重合并:本轮已公开的诊断实验按作者编号、第几个排好,两两问一次 Jev(jev 的 `judgeSameExperiment`),全部问完才写决定(种类 `diagnosisDedup`,规则 `jevSameExperiment` 版本 1,输入是每一对的 Jev 判断)。判为相同的连通成组(同判重合并),每组一个实验,编号 `d1`、`d2`……,组内第一个(作者编号最小、再按第几个)的正文就是合并后的正文。
- 诊 3 选实验:输入照 rules 的 `DiagnosisRuleInput` 拼(余额取本轮开始之前,种子不用,写 `null`),规则取步骤清单里 `selectDiagnosis` 那一步的,调 `runDiagnosisRule`;定了不诊断也写一条决定。
- 诊 4 实现、核验、修复:对象都是本轮诊断(`diagnosisId`)。实验员从当前基线取工作目录,tasks 的 `placeBaselineArtifact` 把产出当前基线那次运行的原始产物只读地放进去;存版本时用 tasks 的 `diagnosisUnversionedNames`(多跳过这个目录)。提交时相对当前基线比较、切块,Jev 对照的是选中实验的原文和全名指标;收下的提交和代码版本、位置表在同一事务写入。核验员逐个实验交结果,用 submissions 的 `diagnosisOutcome` 判;核验、修复的材料差异都是当前基线到诊断程序。
- 拿掉实验:修复次数用完后最近一次核验仍不通过的实验拿掉,结算里记"证据不足:核验不通过"和原因。诊断程序的代码不动(核验员核过的就是这一份),运行时只调通过的实验的编号,每个实验分到总时限 ÷ 跑的实验数;拿掉的实验的代码留在程序里不被调用。判"通过"以最近一次核验为准,修复前通过过、修复后又不通过的实验也拿掉。一个都不剩时用 `diagnosisNotRunEvent` 写事件,不登记运行、不扣次、不结算。
- 诊断运行:用 `formalRun` 同一个登记、启动、扣次入口(formal-run.ts 的 `diagnosisFormalRun`,用途 `diagnosis`,对象本轮诊断),一次运行一次扣次;没启动的处理同正向。运行记录的 `judged` 写 null,逐个实验的判分在结算前由 `judgeDiagnosisRun` 从运行目录做。诊断运行编号 `r<轮>-diagnosis-<第几次>`。
- 诊断结算:逐个实验判分,再照 `SettleDiagnosisInput` 调 `settleDiagnosis` 写一条诊断结算(运行编号、逐个实验的判分、拿掉的实验和原因、输入输出)。每个合并后的实验的结局:跑了且有效分数是有效;跑了没有效分数、或核验不通过拿掉的是证据不足;没选中的是没做。诊断结算写下、诊断运行公开之后才开 ② 提案。
- 恢复:和其他运行走同一条恢复路径。登记了还没结果的诊断运行,按容器名问 Docker:没启动的写"没启动"退还名额,已启动的补写确认启动(扣次)、等它、写结果(`judged` 为 null);只核对、不重跑。结算前被打断,恢复后从运行目录重新判分。
- 诊 1 的材料要产出当前基线那次运行的产物(`metrics.json`、`solution.json`、日志),flow 读运行目录传给 materials。
**消融、收尾与研究结束**(来源:任务书 17 第 2.2、2.3 节,方法.md 4.3 ⑬–⑱、4.4、第七部分):
- 采纳了而且被采纳改法有 2 个以上因子才走消融;否则 ⑬–⑰ 不写记录(从采纳决定和冻结协议算得出),直接到收尾。
+34
View File
@@ -0,0 +1,34 @@
{
"studyId": "diag-eval",
"task": "/home/iomgaa/Projects/InfoRSI/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": "/home/iomgaa/Projects/InfoRSI/prompts",
"devEntry": false,
"flow": {
"preset": "full",
"roundZero": { "enabled": true, "discussions": 2 },
"firstPrediction": true,
"critique": true,
"diagnosis": { "enabled": true, "perResearcher": 2, "maxExperiments": 4 },
"ablation": { "enabled": false },
"batchRule": "bestExpected",
"openReserve": 0.1,
"knowledgeUse": 0.5,
"decay": 0.5
}
}
+1
View File
@@ -24,6 +24,7 @@
"roundZero": { "enabled": true, "discussions": 2 },
"firstPrediction": true,
"critique": true,
"diagnosis": { "enabled": false },
"ablation": { "enabled": true, "cellRule": "valueMinusCost" },
"batchRule": "bestExpected",
"openReserve": 0.1,
+17 -4
View File
@@ -12,6 +12,7 @@ export const FIELD_CATEGORIES = ["用户配置", "方法参数"] as const;
export type FieldCategory = (typeof FIELD_CATEGORIES)[number];
const CLOSED = { additionalProperties: false } as const;
const PositiveInteger = Type.Integer({ minimum: 1 });
/** 文字字段:不能为空,也不能只有空白。 */
const TEXT = { pattern: "\\S" };
/**
@@ -68,7 +69,7 @@ const RoundZero = Type.Union([
/** 消融(方法.md 第十一部分"步骤开关"):关,或开并写选格规则。 */
const Ablation = Type.Union([
Type.Object(
{ enabled: method(Type.Literal(false), "消融关:不列消融格;解释照样写文字,但不给每格的三个数") },
{ enabled: method(Type.Literal(false), "消融关:不列消融格,也不写解释(解释就是对消融格的预测,方法.md 4.3 ⑫)") },
CLOSED,
),
Type.Object(
@@ -83,6 +84,19 @@ const Ablation = Type.Union([
),
]);
/** 诊断(方法.md 4.3 诊 1–诊 4、第十一部分"步骤开关"):关,或开并写两个方法参数(6.11)。 */
const Diagnosis = Type.Union([
Type.Object({ enabled: method(Type.Literal(false), "诊断关:每轮提案之前不做诊断实验") }, CLOSED),
Type.Object(
{
enabled: method(Type.Literal(true), "诊断开"),
perResearcher: method(PositiveInteger, "每人每轮诊断实验上限 A;现行值 2(方法.md 6.11)"),
maxExperiments: method(PositiveInteger, "一次诊断最多做几个实验 D;现行值 4(方法.md 6.11)"),
},
CLOSED,
),
]);
/**
* 两种流程(方法.md 第十一部分)。完整流程写开关、规则和它才用得到的方法参数;
* 朴素循环整套照搬,不能写任何开关、规则,也不写它用不到的 λ、u、衰减(它不预测、不消融)。
@@ -96,6 +110,7 @@ const FullFlow = Type.Object(
Type.Boolean(),
"挑刺与回应开不开(方法.md 4.3 ⑤ ⑥);关掉时不挑刺、不回应,协议照样冻结;现行值 true",
),
diagnosis: method(Diagnosis, "诊断(方法.md 4.3 诊 1–诊 4)"),
ablation: method(Ablation, "消融(方法.md 4.3 ⑬–⑰)"),
batchRule: method(
Type.Enum(BATCH_RULE_NAMES),
@@ -121,14 +136,12 @@ const NaiveFlow = Type.Object(
{
preset: method(
Type.Literal("naive"),
"朴素循环:方法.md 第十一部分的对照流程整套照搬,不挑刺、不回应、不预测、均匀随机选批、不消融",
"朴素循环:方法.md 第十一部分的对照流程整套照搬,不挑刺、不回应、不预测、均匀随机选批、不诊断、不消融",
),
},
CLOSED,
);
const PositiveInteger = Type.Integer({ minimum: 1 });
export const ResearchConfig = Type.Object(
{
studyId: user(
+41 -8
View File
@@ -1,4 +1,11 @@
import { BATCH_RULES, CELL_RULES, type BatchRuleName, type CellRuleName } from "../rules/index.ts";
import {
BATCH_RULES,
CELL_RULES,
DIAGNOSIS_RULES,
type BatchRuleName,
type CellRuleName,
type DiagnosisRuleName,
} from "../rules/index.ts";
import type { ResearchConfig } from "./schema.ts";
// 步骤清单(docs/结构.md 第三部分,方法.md 第十一部分):把配置里的流程、开关、规则翻译成这份研究要走的步骤。
@@ -23,6 +30,13 @@ export type StudyStepId = (typeof STUDY_STEPS)[number];
export const ROUND_STEPS = [
"openRound", // 1. 开轮:冻结本轮名单和提案上限
"carryOverPool", // 1. 开轮:处理留池候选
"diagnosisProposal", // 诊 1. 诊断解释与诊断实验
"diagnosisDedupe", // 诊 2. 判重合并
"diagnosisPrediction", // 诊 2. 诊断预测
"selectDiagnosis", // 诊 3. 选诊断实验(程序)
"diagnosisImplementation", // 诊 4. 实现、核验、修复
"diagnosisRun", // 诊 4. 诊断运行
"diagnosisSettle", // 诊 4. 诊断结算
"proposal", // 2. 提案
"dedupe", // 3. 判重合并
"firstPrediction", // 4. 第一次预测(看原始提案)
@@ -50,11 +64,11 @@ export const ROUND_STEPS = [
export type RoundStepId = (typeof ROUND_STEPS)[number];
/** 决策点用哪条规则:规则名和版本,从 rules 的登记表取。 */
export type RuleRef = { name: BatchRuleName | CellRuleName; version: number };
export type RuleRef = { name: BatchRuleName | CellRuleName | DiagnosisRuleName; version: number };
type Status = { status: "execute" } | { status: "skip"; reason: string };
/** 一步:执行,或按配置跳过(带原因);只有选批、选格两个决策点带规则,其余为 null。 */
/** 一步:执行,或按配置跳过(带原因);只有选批、选格、选诊断实验三个决策点带规则,其余为 null。 */
export type Step<S extends string> = { id: S; rule: RuleRef | null } & Status;
export interface StepList {
@@ -82,7 +96,7 @@ export function stepList(config: ResearchConfig): StepList {
const flow = config.flow;
if (flow.preset === "naive") {
const naive = skip("朴素循环不做这一步(方法.md 第十一部分)");
return assemble("naive", (id) => (NAIVE_STEPS.has(id) ? EXECUTE : naive), batchRule("uniformRandom"), null);
return assemble("naive", (id) => (NAIVE_STEPS.has(id) ? EXECUTE : naive), batchRule("uniformRandom"), null, null);
}
// 完整流程:只有开关关掉的步骤跳过。
@@ -102,15 +116,27 @@ export function stepList(config: ResearchConfig): StepList {
if (!flow.firstPrediction) off(["firstPrediction"], "配置关掉了第一次预测");
// 挑刺与回应关掉时,协议照样冻结(freezeProtocol 不跟着关)。
if (!flow.critique) off(["critique", "response"], "配置关掉了挑刺与回应");
// 消融关掉时解释照样写文字,只是不给每格的三个数(方法.md 第十一部分"步骤开关"),所以 explanation 不跟着关。
// 消融关掉时,解释也不做:解释就是对消融格的预测,⑬–⑰ 整段都不做(方法.md 4.3 ⑫)。
if (!flow.ablation.enabled) {
off(
["listCells", "selectCells", "ablationImplementation", "ablationVerification", "ablationRun", "settle"],
[
"listCells", "explanation", "selectCells", "ablationImplementation",
"ablationVerification", "ablationRun", "settle",
],
"配置关掉了消融",
);
}
if (!flow.diagnosis.enabled) {
off(
[
"diagnosisProposal", "diagnosisDedupe", "diagnosisPrediction", "selectDiagnosis",
"diagnosisImplementation", "diagnosisRun", "diagnosisSettle",
],
"配置关掉了诊断",
);
}
const cells = flow.ablation.enabled ? cellRule(flow.ablation.cellRule) : null;
return assemble("full", (id) => skipped[id] ?? EXECUTE, batchRule(flow.batchRule), cells);
return assemble("full", (id) => skipped[id] ?? EXECUTE, batchRule(flow.batchRule), cells, flow.diagnosis.enabled ? diagnosisRule() : null);
}
function batchRule(name: BatchRuleName): RuleRef {
@@ -121,13 +147,20 @@ function cellRule(name: CellRuleName): RuleRef {
return { name, version: CELL_RULES[name].version };
}
/** 选诊断实验只有一条规则:信息价值减成本(方法.md 6.12),配置里不选。 */
function diagnosisRule(): RuleRef {
return { name: "valueMinusCost", version: DIAGNOSIS_RULES.valueMinusCost.version };
}
function assemble(
preset: StepList["preset"],
status: (id: StudyStepId | RoundStepId) => Status,
batch: RuleRef,
cells: RuleRef | null,
diagnosis: RuleRef | null,
): StepList {
const ruleOf = (id: RoundStepId) => (id === "selectBatch" ? batch : id === "selectCells" ? cells : null);
const ruleOf = (id: RoundStepId) =>
id === "selectBatch" ? batch : id === "selectCells" ? cells : id === "selectDiagnosis" ? diagnosis : null;
return {
preset,
study: STUDY_STEPS.map((id) => ({ id, rule: null, ...status(id) })),
+56 -6
View File
@@ -1,7 +1,18 @@
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 {
diagnosisRun as diagnosisDefinition,
formalRun as runDefinition,
judge,
judgeDiagnosis,
prepareDiagnosisJudging,
prepareDiagnosisRun,
prepareJudging,
prepareRun,
type DiagnosisExperiment,
type DiagnosisJudged,
} from "../tasks/index.ts";
import type { HypothesisMetric, JudgedResult, ProcessResult, RunDefinition } 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";
@@ -9,6 +20,8 @@ import type { Study } from "./study.ts";
// 共用动作二:跑一次正式运行(任务书 12 第 2.7 节)。先登记、再启动、再写结果,前后顺序由这个入口保证
// (方法.md 第七、九部分):登记占名额,确认启动才扣次,确认没启动的写结果"没启动"、退还名额。
// 判分不扣次数,不登记。恢复时接着等一次已经启动的运行,走同一个 finishRun。
// 诊断运行(一次运行跑几个实验,用途 diagnosis)走同一个登记、启动、等待的入口,只是准备目录和判分不同:
// 运行记录不带判分(judged 为 null),逐个实验的判分在诊断结算前由 judgeDiagnosisRun 从运行目录做。
export type RunPlan = Omit<Content<"runPlanned">, "definition">;
@@ -22,11 +35,30 @@ export type RunOutcome = "done" | "stopped" | { notStarted: string };
/** 跑一次正式运行:取出 version 的代码,在任务镜像里跑,判分,写结果。用户停下时不等容器,交回 "stopped"。 */
export async function formalRun(s: Study, plan: RunPlan, version: string, metrics: HypothesisMetric[]): Promise<RunOutcome> {
const definition = runDefinition(s.task, s.frozen.image, String(s.frozen.seed));
return launch(s, plan, definition, metrics, (paths) => {
checkoutVersion(workspaceRepo(s.dir), version, freshDir(paths.code));
prepareRun(s.task, paths.code, freshDir(paths.run), metrics);
});
}
/**
* 跑一次诊断运行:一个容器、一次登记、一次扣次,里面按顺序跑 experiments 里的实验(总时限平分)。
* version 是诊断程序,baselineRunDir 是产出当前基线的那次运行的运行目录(原始产物只读地交给每个实验)。
*/
export async function diagnosisFormalRun(s: Study, plan: RunPlan, version: string, baselineRunDir: string, experiments: DiagnosisExperiment[]): Promise<RunOutcome> {
const definition = diagnosisDefinition(s.task, s.frozen.image, String(s.frozen.seed), experiments.map((e) => e.id));
return launch(s, plan, definition, null, (paths) => {
checkoutVersion(workspaceRepo(s.dir), version, freshDir(paths.code));
prepareDiagnosisRun(s.task, paths.code, baselineRunDir, freshDir(paths.run), experiments);
});
}
/** 先登记、准备运行目录、启动,确认启动才写"确认启动";metrics 为 null 的是诊断运行(运行记录不带判分)。 */
async function launch(s: Study, plan: RunPlan, definition: RunDefinition, metrics: HypothesisMetric[] | null, prepare: (paths: ReturnType<typeof runPaths>) => void): Promise<RunOutcome> {
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);
prepare(paths);
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 });
@@ -39,13 +71,14 @@ export async function formalRun(s: Study, plan: RunPlan, version: string, metric
/**
* 等一次已经确认启动的运行结束,判分,写结果,删掉容器。日志写进这次运行自己的日志目录。
* 收到"停下"信号就不等了,直接交回 "stopped";容器不管它,下次启动时恢复入口再来接着等。
* metrics 为 null 的是诊断运行:这里不判分,结果的 judged 写 null。
*/
export async function finishRun(s: Study, run: string, metrics: HypothesisMetric[]): Promise<"done" | "stopped"> {
export async function finishRun(s: Study, run: string, metrics: HypothesisMetric[] | null): 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);
const judged = metrics === null ? null : await judgeRun(s, run, waited, metrics);
s.ledger.append("runResult", { run, process: waited, judged });
await s.runner.remove(name);
return "done";
@@ -68,6 +101,23 @@ async function judgeRun(s: Study, run: string, formal: ProcessResult, metrics: H
return judge(s.task, paths.run, paths.judge, formal, validator, metrics);
}
/**
* 诊断运行的判分(诊断结算前做):每个交出答案的实验单独判分。同 judgeRun:判分目录每次重新建空的,
* 判分容器还在就先删掉再用;恢复时从运行目录重新判,不存半截结果。experiments 是这次运行跑的那几个实验,次序同运行。
*/
export async function judgeDiagnosisRun(s: Study, run: string, process: ProcessResult, experiments: DiagnosisExperiment[]): Promise<DiagnosisJudged> {
const paths = runPaths(s.dir, run);
const step = prepareDiagnosisJudging(s.task, paths.run, emptyDir(paths.judge), s.frozen.image, process, experiments);
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 judgeDiagnosis(s.task, paths.run, paths.judge, process, validator, experiments);
}
/** 清空重建一个目录:恢复时会再等一次、再判一次,上一次没写完的日志和判分目录不留。 */
function emptyDir(dir: string): string {
rmSync(dir, { recursive: true, force: true });
+17 -2
View File
@@ -1,7 +1,7 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import type { ResearchConfig } from "../config/index.ts";
import { forwardNotRunEvent, cellNotRunEvent } from "../materials/index.ts";
import { cellNotRunEvent, diagnosisNotRunEvent, forwardNotRunEvent } from "../materials/index.ts";
import { Ledger } from "../ledger/index.ts";
import { Runner } from "../runner/index.ts";
import { fullConfig, readFrozen } from "./frozen.ts";
@@ -10,6 +10,7 @@ import { ledgerFile } from "./paths.ts";
import { recover } from "./recover.ts";
import { baselineRun, saveBaseline } from "./steps/baseline.ts";
import { ablationRun, selectCells, settle, unsealChosen } from "./steps/ablation.ts";
import { diagnosisDedupe, diagnosisRun, diagnosisSettle, selectDiagnosisExperiments } from "./steps/diagnosis.ts";
import { forwardRun, adoptBatch } from "./steps/forward.ts";
import { dispatchStage } from "./steps/dispatch.ts";
import { dedupe, freezeProtocols, selectBatch } from "./steps/round.ts";
@@ -80,10 +81,12 @@ async function doStep(s: Study, next: Exclude<Next, { do: "freezeConfig" }>): Pr
return null;
case "baselineRun":
case "forwardRun":
case "ablationRun": {
case "ablationRun":
case "diagnosisRun": {
const ran =
next.do === "baselineRun" ? await baselineRun(s, next.run)
: next.do === "forwardRun" ? await forwardRun(s, next.round, next.candidate, next.run)
: next.do === "diagnosisRun" ? await diagnosisRun(s, next.round, next.run)
: await ablationRun(s, next.round, next.cell, next.run);
if (ran === "stopped") return USER_STOPPED;
if (ran === "done") return null;
@@ -103,6 +106,18 @@ async function doStep(s: Study, next: Exclude<Next, { do: "freezeConfig" }>): Pr
case "assignEntries":
assignEntries(s);
return null;
case "diagnosisDedupe":
await diagnosisDedupe(s, next.round);
return null;
case "selectDiagnosis":
selectDiagnosisExperiments(s, next.round);
return null;
case "diagnosisNotRun":
s.ledger.append("event", diagnosisNotRunEvent(next.round, next.reason));
return null;
case "diagnosisSettle":
await diagnosisSettle(s, next.round);
return null;
case "dedupe":
await dedupe(s, next.round);
return null;
+22 -7
View File
@@ -4,8 +4,9 @@ import { runSession, type SessionTask, type SessionEnding, type SubmitAttempt, t
import { Board, buildMaterial, callId, queryMaterial, researcherId, type TaskMaterial } from "../materials/index.ts";
import { receive, type Incoming } from "../submissions/index.ts";
import { SUBMISSION_KINDS, type JevJudgment, type Json, type SubmissionKind } from "../vocab/index.ts";
import { placeBaselineArtifact } from "../tasks/index.ts";
import { checkoutVersion } from "../workspace/index.ts";
import { prepareSubmission, taskCode, lightChecker } from "./task-code.ts";
import { prepareSubmission, taskArtifact, taskCode, lightChecker } from "./task-code.ts";
import { callPaths, freshDir, runPaths, workspaceRepo } from "./paths.ts";
import type { Study } from "./study.ts";
@@ -43,7 +44,7 @@ export async function runModelTask<K extends SubmissionKind>(s: Study, t: ModelT
prompts: s.prompts,
codeRoot: paths.code,
changes,
artifacts: t.kind === "explanation" ? runArtifacts(s, t.stageId) : null,
artifacts: t.kind === "explanation" ? roundArtifacts(s, t.stageId) : t.kind === "diagnosisProposal" ? baselineArtifacts(s, t.stageId) : null,
});
const model = role === "researcher" ? researcherModel(s, t.actor) : s.frozen.config.models[role];
// 先登记、后建目录:调用编号只在登记之后才算用掉,被杀在两者之间时下次的编号不会撞上旧目录。
@@ -60,6 +61,8 @@ export async function runModelTask<K extends SubmissionKind>(s: Study, t: ModelT
for (const r of material.readable) checkoutVersion(workspaceRepo(s.dir), r.version, freshDir(r.dir));
const workDir = freshDir(paths.work);
if (startVersion !== null) checkoutVersion(workspaceRepo(s.dir), startVersion, workDir);
const artifactFrom = taskArtifact(s, t);
if (artifactFrom !== null) placeBaselineArtifact(s.task, artifactFrom, workDir);
let reply = 0;
const common = {
model,
@@ -90,20 +93,32 @@ export async function runModelTask<K extends SubmissionKind>(s: Study, t: ModelT
* 解释任务要的产物(materials 的 TaskRequest.artifacts):本轮每次已公开正向运行的 metrics.json、solution.json
* 和标准输出、标准错误日志(运行编号 → 文件名 → 文字)。完整代码那次的答案出自哪里,就靠这些产物判断。
*/
function runArtifacts(s: Study, stageId: string): Record<string, Record<string, string>> {
function roundArtifacts(s: Study, stageId: string): Record<string, Record<string, string>> {
const board = new Board(s.ledger.records());
const round = board.stage(stageId).round;
const artifacts: Record<string, Record<string, string>> = {};
for (const r of board.runs()) {
if (r.plan.purpose !== "forward" || r.plan.round !== round || !board.runPublic(r)) continue;
const paths = runPaths(s.dir, r.plan.run);
artifacts[r.plan.run] = runFiles(s, r.plan.run);
}
return artifacts;
}
/** 诊 1 要的产物:产出本轮当前基线的那次运行,同样的几个文件(运行编号 → 文件名 → 文字)。 */
function baselineArtifacts(s: Study, stageId: string): Record<string, Record<string, string>> {
const board = new Board(s.ledger.records());
const run = board.runOf(board.baselineOf(board.stage(stageId).round))!.plan.run;
return { [run]: runFiles(s, run) };
}
/** 一次运行留下的产物:metrics.json、solution.json 和两份日志,有哪个读哪个。 */
function runFiles(s: Study, run: string): Record<string, string> {
const paths = runPaths(s.dir, run);
const files: Record<string, string> = {};
for (const [dir, name] of [[paths.run, "metrics.json"], [paths.run, "solution.json"], [paths.logs, "stdout.log"], [paths.logs, "stderr.log"]] as const) {
if (existsSync(join(dir, name))) files[name] = readFileSync(join(dir, name), "utf8");
}
artifacts[r.plan.run] = files;
}
return artifacts;
return files;
}
/** 研究者用的模型:第 i 位研究者(researcherId(i))用配置里第 i 个。 */
+111 -7
View File
@@ -1,8 +1,8 @@
import type { RoundStepId, StudyStepId } from "../config/index.ts";
import type { Content, LedgerRecord } from "../ledger/index.ts";
import { Board, CELL_NOT_RUN, cellId, DECISION_KINDS, experimenterId, factorKey, FORWARD_NOT_RUN, ORIGINAL_BASELINE, researcherId, splitCell, verifierId } from "../materials/index.ts";
import { Board, CELL_NOT_RUN, cellId, DECISION_KINDS, DIAGNOSIS_NOT_RUN, diagnosisId, experimenterId, factorKey, FORWARD_NOT_RUN, ORIGINAL_BASELINE, researcherId, splitCell, verifierId } from "../materials/index.ts";
import { compareIds, listCells, runBalance, sharedSettings } from "../rules/index.ts";
import { ablationOutcome, verificationOutcome } from "../submissions/index.ts";
import { ablationOutcome, diagnosisOutcome, verificationOutcome } from "../submissions/index.ts";
import { SUBMISSION_KINDS, type Stage, type SubmissionKind } from "../vocab/index.ts";
import { readFrozen, type Frozen } from "./frozen.ts";
@@ -23,7 +23,7 @@ const ROUND_ZERO_STAGES = {
type RoundZeroStep = keyof typeof ROUND_ZERO_STAGES;
/** 第 r 轮的研究者阶段:步骤名就是阶段名(vocab STAGES)。 */
type RoundStageStep = Extract<RoundStepId, "proposal" | "firstPrediction" | "critique" | "response" | "secondPrediction">;
type RoundStageStep = Extract<RoundStepId, "diagnosisProposal" | "diagnosisPrediction" | "proposal" | "firstPrediction" | "critique" | "response" | "secondPrediction">;
/** 阶段编号由 flow 起:r<轮>-<步骤>,一个步骤一个阶段。第 r 轮按配置跳过的步骤也用这个编号记"按配置跳过"。 */
export const stageIdOf = (round: number, step: string): string => `r${round}-${step}`;
@@ -52,6 +52,11 @@ export type Next =
| { do: "closeStage"; stageId: string }
| { do: "dispatch"; stageId: string; tasks: TaskRef[] }
| { do: "assignEntries" }
| { do: "diagnosisDedupe"; round: number }
| { do: "selectDiagnosis"; round: number }
| { do: "diagnosisNotRun"; round: number; reason: string }
| { do: "diagnosisRun"; round: number; run: string }
| { do: "diagnosisSettle"; round: number }
| { do: "dedupe"; round: number }
| { do: "freezeProtocols"; round: number }
| { do: "selectBatch"; round: number }
@@ -139,6 +144,28 @@ function roundStepNext(step: RoundStepId, round: number, view: View, frozen: Fro
case "carryOverPool":
// 不写记录:本轮名单和提案上限就是冻结的配置(方法.md 4.3 ①),留池候选由 materials 从记录算出(Board.carried)。
return null;
case "diagnosisProposal":
case "diagnosisPrediction":
// 诊 1、诊 2 → 这个阶段的关闭记录。余额不到 2 次(算到本轮开始前)本轮不诊断:不开阶段、不写记录。
return diagnosing(view, round, frozen) ? stageNext(stageIdOf(round, step), step, round, (roster) => roundTasks(step, round, roster, view.board), view, frozen, researchers(frozen)) : null;
case "diagnosisDedupe":
// 诊 2 判重合并 → 本轮的诊断实验判重合并决定(decision,种类 diagnosisDedup)。
if (!diagnosing(view, round, frozen)) return null;
return view.board.decision("diagnosisDedup", round) === undefined ? { do: "diagnosisDedupe", round } : null;
case "selectDiagnosis":
// 诊 3 → 本轮的选诊断实验决定(decision,种类 selectDiagnosis),定了不诊断也写。
if (!diagnosing(view, round, frozen)) return null;
return view.board.decision("selectDiagnosis", round) === undefined ? { do: "selectDiagnosis", round } : null;
case "diagnosisImplementation":
// 诊 4 实现与核验 → 最近一次核验里至少有一个实验通过,或修复次数用完而一个都不剩、有"不跑"事件。
return diagnosisCodeNext(round, view, frozen);
case "diagnosisRun":
// 诊 4 运行 → 本轮诊断运行最近一次有结果,而且不是"没启动"。
return diagnosisProgramOf(view.board, round, frozen) === null ? null : diagnosisRunNext(round, view);
case "diagnosisSettle":
// 诊 4 结算 → 本轮的诊断结算记录(diagnosisSettlement,按轮次唯一)。
if (diagnosisProgramOf(view.board, round, frozen) === null) return null;
return view.board.diagnosisSettlement(round) === undefined ? { do: "diagnosisSettle", round } : null;
case "proposal":
case "firstPrediction":
case "critique":
@@ -183,9 +210,6 @@ function roundStepNext(step: RoundStepId, round: number, view: View, frozen: Fro
// 解释 → 这一轮解释阶段的关闭记录。
const adopted = view.board.decision("adopt", round)?.output.adopted ?? null;
if (adopted === null || view.board.protocol(adopted).factors.length < 2) return null;
if (frozen.steps.round.some((s) => s.id === "listCells" && s.status === "skip")) {
return { do: "halt", reason: "配置关掉了消融、又采纳了多因子改法:不交三个数的解释还没写(cells 只按完整流程全开实现,做步骤开关时再补)", objects: [] };
}
return stageNext(stageIdOf(round, "explanation"), "explanation", round, (roster) => roster.map((actor) => ({ actor, kind: "explanation" as const, object: null })), view, frozen, researchers(frozen));
}
case "selectCells":
@@ -228,6 +252,7 @@ export function poolOf(board: Board, round: number): string[] {
/**
* 第 r 轮一个研究者阶段的任务(任务书 14 第 2.3 节)。同一个人的几个任务按这里的先后跑。
* - 诊断解释与实验、诊断预测:每人一个任务,没有对象;
* - 提案:每人一个任务,没有对象;
* - 第一次预测:每人 × 本轮每个新候选(含自己的;留池候选不做,方法.md 4.3 ④);
* - 挑刺:每人 × 本轮每条不是自己写的原提案(监督者定,照参考项目);
@@ -238,6 +263,9 @@ function roundTasks(step: RoundStageStep, round: number, roster: readonly string
const each = (kind: TaskRef["kind"], objects: readonly string[]): TaskRef[] =>
roster.flatMap((actor) => objects.map((object) => ({ actor, kind, object })));
switch (step) {
case "diagnosisProposal":
case "diagnosisPrediction":
return roster.map((actor) => ({ actor, kind: step, object: null }));
case "proposal":
return roster.map((actor) => ({ actor, kind: "proposal", object: null }));
case "firstPrediction":
@@ -318,7 +346,7 @@ const researchers = (frozen: Frozen): string[] => frozen.config.models.researche
function assignedStage(
step: string,
round: number,
kind: "implementation" | "repair" | "verification" | "ablationImplementation" | "ablationVerification",
kind: "implementation" | "repair" | "verification" | "ablationImplementation" | "ablationVerification" | "diagnosisImplementation" | "diagnosisRepair" | "diagnosisVerification",
candidates: readonly string[],
view: View,
frozen: Frozen,
@@ -355,6 +383,82 @@ function verificationNext(round: number, view: View, frozen: Frozen): Next | nul
return null;
}
/**
* 第 round 轮诊不诊断的前提(方法.md 4.3 诊断一段):余额至少 2 次,保证本轮至少还能跑 1 次正向。
* 余额只算本轮开始前的运行:本轮诊断运行自己扣了次数以后,诊断这一段的后几步不会反过来算成"不诊断"。
* 诊断关着的步骤清单把这几步都标了跳过,不会走到这里。
*/
function diagnosing(view: View, round: number, frozen: Frozen): boolean {
return runBalance(frozen.config.runs, view.board.runStatesBefore(round)).balance >= 2;
}
/**
* 诊 4 实现、核验与修复(方法.md 4.3 诊 4):一位实验员把选中的全部实验写进一份诊断程序,一位核验员逐个实验核对。
* 阶段编号按"第几次"排:诊断实现-1、核验-1,不通过的退回修复-1、再核验-2……第几次从已开关的阶段数出来,不另存;
* 每次核验都覆盖全部选中的实验。最近一次核验里还有实验不通过、修复次数又用完时,不通过的实验拿掉(diagnosisProgramOf);
* 一个都不剩才写"不跑"事件(方法.md 4.3 诊 4)。
*/
function diagnosisCodeNext(round: number, view: View, frozen: Frozen): Next | null {
if (chosenExperiments(view.board, round) === null) return null;
const object = diagnosisId(round);
for (let n = 1; ; n++) {
const work = n === 1
? assignedStage("diagnosisImplementation-1", round, "diagnosisImplementation", [object], view, frozen)
: assignedStage(`diagnosisRepair-${n - 1}`, round, "diagnosisRepair", [object], view, frozen);
if (work !== null) return work;
const verify = assignedStage(`diagnosisVerification-${n}`, round, "diagnosisVerification", [object], view, frozen);
if (verify !== null) return verify;
const verification = view.board.published("diagnosisVerification").filter((v) => v.round === round).at(-1)!;
if (diagnosisOutcome(verification.content).passed) return null;
if (n > frozen.config.maxRepairs) {
const { experiments, dropped } = diagnosisProgram(view.board, round, frozen.config.maxRepairs);
if (experiments.length > 0 || view.events(DIAGNOSIS_NOT_RUN).some((e) => e.objects.includes(object))) return null;
return { do: "diagnosisNotRun", round, reason: `选中的实验第 ${n} 次核验都不通过,修复次数上限 ${frozen.config.maxRepairs} 已用完:${dropped.map((d) => `${d.experiment}:${d.reason}`).join(";")}` };
}
}
}
/** 本轮诊断选中的实验(诊 3 的决定,按实验编号排);本轮没有诊断(余额不到 2 次、定了不诊断)为 null。 */
function chosenExperiments(board: Board, round: number): string[] | null {
const decision = board.decision("selectDiagnosis", round);
return decision === undefined || !decision.output.diagnose ? null : decision.output.chosen;
}
/**
* 诊 4 核验做完之后本轮诊断实际要跑的实验:选中的实验里最近一次核验通过的;不通过的实验拿掉,各带原因
* (记"证据不足:核验不通过")。拿掉实验后诊断程序的代码不动:核验员核过的就是这一份,只是运行时不调不通过的实验的编号。
* 只在诊断实现与核验这一步做完以后调用。
*/
export function diagnosisProgram(board: Board, round: number, maxRepairs: number): { experiments: string[]; dropped: Array<{ experiment: string; reason: string }> } {
const verifications = board.published("diagnosisVerification").filter((s) => s.round === round);
const last = verifications.at(-1)!.content;
const { failing } = diagnosisOutcome(last);
return {
experiments: board.decision("selectDiagnosis", round)!.output.chosen.filter((e) => !failing.includes(e)),
dropped: failing.map((experiment) => {
const x = last.experiments.find((y) => y.experiment === experiment)!;
const what = `${x.followed ? "" : "没照写法做;"}${x.nothingElse ? "" : "动了写法以外的地方;"}${x.where}`;
return { experiment, reason: `第 ${verifications.length} 次核验仍不通过,修复次数上限 ${maxRepairs} 已用完:${what}` };
}),
};
}
/** 本轮要跑的诊断实验:本轮没有诊断、或一个都没剩下为 null。 */
export function diagnosisProgramOf(board: Board, round: number, frozen: Frozen): string[] | null {
if (chosenExperiments(board, round) === null) return null;
const { experiments } = diagnosisProgram(board, round, frozen.config.maxRepairs);
return experiments.length === 0 ? null : experiments;
}
/** 诊 4 运行:和正向同一套,没启动的下次启动起新编号,已登记没结果的必须先由恢复入口核对。 */
function diagnosisRunNext(round: number, view: View): Next | null {
const runs = view.board.runs().filter((r) => r.plan.purpose === "diagnosis" && r.plan.round === round);
const last = runs.at(-1);
if (last !== undefined && last.result === null) throw new Error(`诊断运行 ${last.plan.run} 登记了还没有结果:恢复入口应当已经核对过`);
if (last !== undefined && last.result!.process.ending !== "notStarted") return null;
return { do: "diagnosisRun", round, run: `r${round}-diagnosis-${runs.length + 1}` };
}
/** ⑩:按选批顺序一次只交回一个运行;没启动的下次启动起新编号,不会把半批提前公开。 */
function forwardNext(round: number, view: View): Next | null {
for (const candidate of view.board.decision("batch", round)!.output.chosen) {
+6 -2
View File
@@ -43,9 +43,13 @@ export async function recover(s: Study): Promise<{ halt: string } | "stopped" |
return null;
}
/** 恢复和第一次运行共用原登记指标定义(引用指标也保留原来的缺失规则);消融格缺失一律只记缺失(方法.md 第五部分"指标")。 */
function metricsOf(s: Study, plan: Content<"runPlanned">): HypothesisMetric[] {
/**
* 恢复和第一次运行共用原登记指标定义(引用指标也保留原来的缺失规则);消融格缺失一律只记缺失(方法.md 第五部分"指标")。
* 诊断运行为 null:运行记录不带判分,逐个实验的判分在诊断结算前做。
*/
function metricsOf(s: Study, plan: Content<"runPlanned">): HypothesisMetric[] | null {
if (plan.purpose === "baseline") return [];
if (plan.purpose === "diagnosis") return null;
const board = new Board(s.ledger.records());
if (plan.purpose === "forward") return protocolMetrics(board, board.protocol(plan.object!));
return ablationMetrics(board, splitCell(plan.object!).candidate);
+165
View File
@@ -0,0 +1,165 @@
import { judgeSameExperiment, type DedupExperiment } from "../../jev/index.ts";
import { Board, diagnosisId, diagnosisKey, type DiagnosisDedupOutput } from "../../materials/index.ts";
import {
compareIds,
runBalance,
runDiagnosisRule,
settleDiagnosis,
type DiagnosisExplanation,
type DiagnosisRuleInput,
type DiagnosisRuleName,
type ExperimentOutcome,
type SettleDiagnosisInput,
} from "../../rules/index.ts";
import type { DiagnosisExperiment, DiagnosisJudged } from "../../tasks/index.ts";
import type { Json } from "../../vocab/index.ts";
import { diagnosisFormalRun, judgeDiagnosisRun, type RunOutcome } from "../formal-run.ts";
import { diagnosisProgram, diagnosisProgramOf } from "../next-step.ts";
import { baselineRunDir } from "../task-code.ts";
import type { Study } from "../study.ts";
import type { SamePair } from "./round.ts";
// 诊断这一段里程序自己做的四步(方法.md 4.3 诊 2–诊 4):判重合并、选诊断实验、诊断运行、诊断结算。
// 判重合并和选实验各写一条决定,结算写一条诊断结算,写下之前被打断就从账本重新算一遍;写下之后恢复时照原样用,不重算。
/** 一个作者交的一个诊断实验:标签 "<作者>.e<第几个>",只在判重合并的输入里认实验用。 */
type Item = { author: string; index: number; label: string; experiment: DedupExperiment };
/**
* 诊 2 判重合并(方法.md 4.3):本轮已公开的诊断实验两两问一次 Jev,按作者编号、再按第几个排好顺序发,
* 全部问完才写决定:中途被打断就整轮重问,判重不扣运行次数。
*/
export async function diagnosisDedupe(s: Study, round: number): Promise<void> {
const board = new Board(s.ledger.records());
const model = s.frozen.config.models.jev;
const items: Item[] = board
.published("diagnosisProposal")
.filter((p) => p.round === round)
.sort((a, b) => compareIds(a.author, b.author))
.flatMap((p) => p.content.experiments.map((experiment, index) => ({ author: p.author, index, label: `${p.author}.e${index + 1}`, experiment })));
const pairs: SamePair[] = [];
for (const [i, a] of items.entries()) {
for (const b of items.slice(i + 1)) pairs.push({ a: a.label, b: b.label, judgment: await judgeSameExperiment(model, a.experiment, b.experiment) });
}
// SAFETY: JevJudgment 和 DiagnosisDedupOutput 都是纯 Json 值;账本在写入时校验 Json。
s.ledger.append("decision", {
decision: "diagnosisDedup",
round,
rule: "jevSameExperiment",
ruleVersion: 1,
params: { jevModel: model },
seed: null,
input: { pairs } as unknown as Json,
output: groupSameExperiment(items, pairs) as unknown as Json,
});
}
/**
* 判为相同的连成一组(同判重合并:A 和 B 相同、B 和 C 相同,A、B、C 就是一个实验)。每组合成一个实验,
* 按组里排最前的那一个排序,编号用 materials 的 diagnosisKey(d1…);组内按作者编号、再按第几个排,第一个的正文就是合并后的正文。
* items 要先按作者编号、第几个排好。
*/
export function groupSameExperiment(items: readonly Pick<Item, "author" | "index" | "label">[], pairs: readonly SamePair[]): DiagnosisDedupOutput {
const parent = new Map(items.map((x) => [x.label, x.label]));
const root = (x: string): string => {
while (parent.get(x) !== x) x = parent.get(x)!;
return x;
};
for (const p of pairs) if (p.judgment.yes) parent.set(root(p.a), root(p.b));
const groups = new Map<string, Array<{ author: string; index: number }>>();
for (const x of items) groups.set(root(x.label), [...(groups.get(root(x.label)) ?? []), { author: x.author, index: x.index }]);
return { experiments: [...groups.values()].map((sources, i) => ({ experiment: diagnosisKey(i), sources })) };
}
/**
* 诊 3 选诊断实验(方法.md 6.12):基线、方向、λ、合并后的全部实验、每条解释对每个实验的三个数,加余额(本轮开始前)、
* 每批上限、一次诊断最多几个实验 D、u、衰减,交给步骤清单里 selectDiagnosis 那一步的规则,写一条决定。
* 规则没有随机,种子写 null;定了不诊断也写。
*/
export function selectDiagnosisExperiments(s: Study, round: number): void {
const board = new Board(s.ledger.records());
const { config, facts, steps } = s.frozen;
const { diagnosis } = config.flow;
if (!diagnosis.enabled) throw new Error("配置关掉了诊断,步骤清单里选诊断实验一步应当标了跳过");
const input: DiagnosisRuleInput = {
baseline: board.baselineScore(board.baselineOf(round)),
direction: facts.finalMetric.direction,
lambda: config.flow.openReserve,
experiments: board.diagnosisExperiments(round).map((e) => e.id),
explanations: predictionsOf(board, round),
balance: runBalance(config.runs, board.runStatesBefore(round)).balance,
batchCap: config.batchLimit,
maxExperiments: diagnosis.maxExperiments,
u: config.flow.knowledgeUse,
decay: config.flow.decay,
};
const rule = steps.round.find((x) => x.id === "selectDiagnosis")!.rule!.name as DiagnosisRuleName;
const { rule: used, ...output } = runDiagnosisRule(rule, input);
// SAFETY: rules 是纯计算,输入输出都是纯 Json 值;账本在写入时校验 Json。
s.ledger.append("decision", {
decision: "selectDiagnosis",
round,
rule: used.name,
ruleVersion: used.version,
params: output.params,
seed: null,
input: input as unknown as Json,
output: output as unknown as Json,
});
}
/** 这一轮已冻结的各条诊断解释:作者的第 i 条(materials 的 diagnosisExplanationId),每条对每个合并后的实验给的三个数。选实验和结算共用。 */
function predictionsOf(board: Board, round: number): DiagnosisExplanation[] {
const predictions = board.published("diagnosisPrediction").filter((p) => p.round === round);
return board.diagnosisExplanations(round).map((x) => ({
id: x.id,
author: x.author,
experiments: predictions.find((p) => p.author === x.author)!.content.predictions.filter((y) => y.explanation === x.id).map((y) => ({ experiment: y.experiment, range: y.range })),
}));
}
/** 要跑的实验的运行输入:编号加它登记的假设专用指标(全名),次序同 ids,也就是运行的次序。 */
function experimentsOf(board: Board, round: number, ids: readonly string[]): DiagnosisExperiment[] {
const merged = board.diagnosisExperiments(round);
return ids.map((id) => ({ id, metrics: merged.find((e) => e.id === id)!.metrics }));
}
/**
* 诊 4 诊断运行:用核验过的诊断程序,复用正式运行的登记、扣次入口,对象是本轮诊断。跑的只是核验里通过的实验:
* 不通过而拿掉的实验,代码留在程序里不动(核验员核过的就是这一份),运行时不调它的编号;每个实验分到总时限 ÷ 跑的实验数。
*/
export function diagnosisRun(s: Study, round: number, run: string): Promise<RunOutcome> {
const board = new Board(s.ledger.records());
const ran = diagnosisProgramOf(board, round, s.frozen)!;
const version = board.code(diagnosisId(round)).version;
return diagnosisFormalRun(s, { run, purpose: "diagnosis", round, object: diagnosisId(round) }, version, baselineRunDir(s, board, round), experimentsOf(board, round, ran));
}
/**
* 诊 4 结算(方法.md 4.3 诊 4、6.12):先从运行目录给每个交出答案的实验单独判分,再照 SettleDiagnosisInput 拼输入,
* 写一条诊断结算。每个合并后的实验的结局:跑了且有效分数是有效;跑了没有效分数、或核验不通过拿掉的是证据不足;没选中的是没做。
* 运行记录不带判分,所以被打断在判分和写结算之间时,恢复后从运行目录重新判。
*/
export async function diagnosisSettle(s: Study, round: number): Promise<void> {
const board = new Board(s.ledger.records());
const { experiments: ran, dropped } = diagnosisProgram(board, round, s.frozen.config.maxRepairs);
const run = board.runs().filter((r) => r.plan.purpose === "diagnosis" && r.plan.round === round).at(-1)!;
const judged = await judgeDiagnosisRun(s, run.plan.run, run.result!.process, experimentsOf(board, round, ran));
const input: SettleDiagnosisInput = {
baseline: board.baselineScore(board.baselineOf(round)),
direction: s.frozen.facts.finalMetric.direction,
epsilon: s.frozen.facts.epsilon,
lambda: s.frozen.config.flow.openReserve,
experiments: board.diagnosisExperiments(round).map((e) => ({ experiment: e.id, outcome: outcomeOf(e.id, ran, dropped.map((d) => d.experiment), judged) })),
explanations: predictionsOf(board, round),
};
// SAFETY: 判分结果、结算的输入输出都是纯 Json 值;账本在写入时校验 Json。
s.ledger.append("diagnosisSettlement", { round, result: { run: run.plan.run, judged, dropped, input, output: settleDiagnosis(input) } as unknown as Json });
}
function outcomeOf(experiment: string, ran: readonly string[], dropped: readonly string[], judged: DiagnosisJudged): ExperimentOutcome {
if (dropped.includes(experiment)) return { kind: "insufficient" };
if (!ran.includes(experiment)) return { kind: "untested" };
const j = judged.experiments.find((x) => x.id === experiment)!;
return j.verdict === "valid" && j.score.status === "measured" ? { kind: "valid", score: j.score.value } : { kind: "insufficient" };
}
+1
View File
@@ -94,6 +94,7 @@ function studyFacts(frozen: Frozen, task: Task): StudyFacts {
lambda: config.flow.openReserve,
totalRuns: config.runs,
proposalLimit: config.proposalLimit,
diagnosisExperimentLimit: config.flow.diagnosis.enabled ? config.flow.diagnosis.perResearcher : null,
factorLimit: config.factorLimit,
metrics: metricDefinitions(task),
taskText: task.guide,
+41 -3
View File
@@ -1,19 +1,20 @@
import { join } from "node:path";
import type { Content } from "../ledger/index.ts";
import { codeHunks } from "../jev/index.ts";
import { Board, protocolMetrics, splitCell, type CodeChanges } from "../materials/index.ts";
import { Board, diagnosisId, protocolMetrics, splitCell, type CodeChanges } from "../materials/index.ts";
import type { KnownOf } from "../submissions/index.ts";
import { lightCheck, protectedChanges, readLightCheck } from "../tasks/index.ts";
import { diagnosisUnversionedNames, lightCheck, protectedChanges, readLightCheck } from "../tasks/index.ts";
import type { Json, Submission, SubmissionKind } from "../vocab/index.ts";
import { compareVersions, saveVersion } from "../workspace/index.ts";
import type { TaskRef } from "./next-step.ts";
import { callPaths, containerName, freshDir, workspaceRepo } from "./paths.ts";
import { callPaths, containerName, freshDir, runPaths, workspaceRepo } from "./paths.ts";
import type { Study } from "./study.ts";
/**
* 实现取本批基线;修复取上次提交。核验、修复的材料差异始终相对本批基线。
* 消融实现第一次取被采纳的完整代码,重做取上一次交的这一格代码(materials 的消融实现材料就是这样写的);
* 消融核验的材料差异是完整代码到这一格代码(方法.md 4.3 ⑯)。
* 诊断实现取当前基线;诊断修复取上次提交的诊断程序;诊断核验、修复的材料差异是当前基线到诊断程序(方法.md 4.3 诊 4)。
*/
export function taskCode(s: Study, t: TaskRef & { stageId: string }): { changes: CodeChanges | null; startVersion: string | null } {
const board = new Board(s.ledger.records());
@@ -26,6 +27,13 @@ export function taskCode(s: Study, t: TaskRef & { stageId: string }): { changes:
const { candidate } = splitCell(t.object!);
return { changes: compareVersions(workspaceRepo(s.dir), board.code(candidate).version, board.code(t.object!).version), startVersion: null };
}
if (t.kind === "diagnosisImplementation" || t.kind === "diagnosisRepair" || t.kind === "diagnosisVerification") {
const round = board.stage(t.stageId).round;
const baseline = board.code(board.baselineOf(round)).version;
if (t.kind === "diagnosisImplementation") return { changes: null, startVersion: baseline };
const program = board.code(t.object!).version;
return { changes: compareVersions(workspaceRepo(s.dir), baseline, program), startVersion: t.kind === "diagnosisRepair" ? program : null };
}
if (t.kind !== "implementation" && t.kind !== "repair" && t.kind !== "verification") return { changes: null, startVersion: null };
const baseline = board.code(board.baselineOf(board.stage(t.stageId).round)).version;
if (t.kind === "implementation") return { changes: null, startVersion: baseline };
@@ -33,6 +41,18 @@ export function taskCode(s: Study, t: TaskRef & { stageId: string }): { changes:
return { changes: compareVersions(workspaceRepo(s.dir), baseline, version), startVersion: t.kind === "repair" ? version : null };
}
/** 产出第 round 轮当前基线的那次运行的运行目录:诊断实验要接着算的原始产物就在里面。 */
export function baselineRunDir(s: Study, board: Board, round: number): string {
return runPaths(s.dir, board.runOf(board.baselineOf(round))!.plan.run).run;
}
/** 诊断实现、修复的工作目录要放进的原始产物所在的运行目录(取出代码之后,tasks 的 placeBaselineArtifact 放);别的任务为 null。 */
export function taskArtifact(s: Study, t: TaskRef & { stageId: string }): string | null {
if (t.kind !== "diagnosisImplementation" && t.kind !== "diagnosisRepair") return null;
const board = new Board(s.ledger.records());
return baselineRunDir(s, board, board.stage(t.stageId).round);
}
/**
* 提交时才知道的东西:提案份数、回应原提案,或实验员当前工作目录的快照和相对本批基线的改动。
* 快照写入 workspace 后才问 Jev;收下才把 code 和 submission 在同一个事务写进账本。
@@ -61,6 +81,24 @@ export function prepareSubmission(
code: { object: t.object!, version: saved.version, locations: null },
};
}
if (t.kind === "diagnosisImplementation" || t.kind === "diagnosisRepair") {
// 诊断程序:存的名单多跳过宿主放进来的原始产物目录;Jev 对照的是选中实验的原文和要采集的指标。
const saved = saveVersion(workspaceRepo(s.dir), workDir, diagnosisUnversionedNames(s.task));
if (!saved.saved) return { known: { code: saved }, code: null };
const round = board.stage(t.stageId).round;
const changes = compareVersions(workspaceRepo(s.dir), board.code(board.baselineOf(round)).version, saved.version);
const chosen = board.diagnosisExperiments(round).filter((e) => board.decision("selectDiagnosis", round)!.output.chosen.includes(e.id));
const impl = content as Submission<"diagnosisImplementation">;
return {
known: { code: {
saved: true,
protectedFiles: protectedChanges(s.task, changes.files.map((f) => f.path)),
hunks: codeHunks(changes),
protocol: { body: chosen.map((e) => `### ${e.id} ${e.name}\n\n动哪一处:${e.spot}\n\n${e.body}`).join("\n\n"), metrics: chosen.flatMap((e) => e.metrics) },
} },
code: { object: t.object!, version: saved.version, locations: impl.locations as Json },
};
}
if (t.kind !== "implementation" && t.kind !== "repair") return { known: null, code: null };
const saved = saveVersion(workspaceRepo(s.dir), workDir, s.frozen.facts.unversionedNames);
if (!saved.saved) return { known: { code: saved }, code: null };
+4
View File
@@ -1,11 +1,15 @@
// jev:问 Jev 的问法和阈值(docs/结构.md 第二部分)。判断的类型 JevJudgment 在 vocab。
export {
checkExperiment,
checkHunk,
checkProtocol,
codeHunks,
judgeSameChange,
judgeSameExperiment,
type CodeHunk,
type DedupExperiment,
type DedupFactor,
type DiagnosisExperiment,
type FrozenProtocol,
type JevCheck,
type ProtocolMetric,
+37 -1
View File
@@ -1,9 +1,10 @@
import type { HypothesisMetric, JevJudgment, Json, Submission } from "../vocab/index.ts";
import { questionsOf, taskOf, type Question } from "./questions.ts";
// 问 Jev:从带类型的输入拼材料、发请求、读回答(docs/方法.md 4.3 ③⑥⑨)。
// 问 Jev:从带类型的输入拼材料、发请求、读回答(docs/方法.md 4.3 ③⑥⑨ 和诊 1、诊 2)。
// 材料里放什么、字段叫什么只在这里定。分请求照参考项目 src/judge/judge.ts:
// 判重一对一次请求;冻结条款三问每问单独一次请求,材料相同;代码改动一个差异块一次请求,五问同在。
// 诊断实验的判重、冻结检查照判重、冻结条款的写法;诊断程序的代码改动直接用 checkHunk。
type Factor = Submission<"proposal">["factors"][number];
@@ -13,6 +14,12 @@ type Factor = Submission<"proposal">["factors"][number];
*/
export type DedupFactor = Pick<Factor, "name" | "method" | "keySettings">;
/** 判重看的诊断实验:名称、动哪一处、怎么动。能分开哪几条解释写在正文里,不影响"是不是同一个实验"。 */
export type DedupExperiment = Pick<Submission<"diagnosisProposal">["experiments"][number], "name" | "spot" | "body">;
/** 冻结检查看的诊断实验:名称、动哪一处、怎么动,加登记的假设专用指标(编号是短名)。 */
export type DiagnosisExperiment = DedupExperiment & Pick<Submission<"diagnosisProposal">["experiments"][number], "metrics">;
/** 一份冻结协议的条款(docs/方法.md 4.3 ⑥"冻结协议包括"),字段与统一提交相同。 */
export type FrozenProtocol = Pick<Submission<"unification">, "factors" | "safeguards" | "metrics" | "body" | "clauses">;
@@ -49,6 +56,31 @@ export async function judgeSameChange(model: string, a: readonly DedupFactor[],
return judgment as JevJudgment;
}
/** 诊 2 判重:两个诊断实验是不是同一个实验。 */
export async function judgeSameExperiment(model: string, a: DedupExperiment, b: DedupExperiment): Promise<JevJudgment> {
const state = {
task: taskOf("diagnosisDedup"),
experiment_a: dedupExperiment(a),
experiment_b: dedupExperiment(b),
};
const [judgment] = await ask(model, state, questionsOf("diagnosisDedup"));
return judgment as JevJudgment;
}
/** 诊 1 查一个诊断实验:改动了评测、自相矛盾、写得太含糊。三问逐个发,材料相同。 */
export async function checkExperiment(model: string, experiment: DiagnosisExperiment): Promise<JevCheck> {
const state = {
task: taskOf("diagnosisFreeze"),
experiment: {
...dedupExperiment(experiment),
metrics: experiment.metrics.map((m) => ({ id: m.id, howMeasured: m.howMeasured, missing: m.missing })),
},
};
const judgments: JevJudgment[] = [];
for (const question of questionsOf("diagnosisFreeze")) judgments.push(...(await ask(model, state, [question])));
return check(judgments);
}
/** ⑥ 查一份冻结协议的条款:改动了评测、自相矛盾、写得太含糊。三问逐个发,材料相同。 */
export async function checkProtocol(model: string, protocol: FrozenProtocol): Promise<JevCheck> {
const state = {
@@ -83,6 +115,10 @@ function dedupFactor(f: DedupFactor): { [key: string]: Json } {
return { name: f.name, method: f.method, keySettings: f.keySettings };
}
function dedupExperiment(e: DedupExperiment): { [key: string]: Json } {
return { name: e.name, spot: e.spot, body: e.body };
}
function check(judgments: JevJudgment[]): JevCheck {
return { flagged: judgments.some((j) => j.yes), judgments };
}
+136 -3
View File
@@ -1,13 +1,17 @@
import { hash, type Json } from "../vocab/index.ts";
// Jev 的九问(docs/方法.md 4.3 ③⑥⑨):每一处的材料说明、问题原文、阈值。
// Jev 的十三问(docs/方法.md 4.3 ③⑥⑨ 和诊 1、诊 2):每一处的材料说明、问题原文、阈值。
// 措辞、判"是"判"否"的说明和示例句取自参考项目 src/judge/questions.ts(填圆那一套),
// 只改了必须改的:点 → 因子,字段名对上 vocab 的提案和冻结协议,改动对上 workspace 的比较结果。
// 写法照参考项目 research-wiki/参考-Jev使用指南.md:问题用英文、一问只问一件事、
// 统一问坏的一面(概率高 = 有问题或"是同一个")、判据带 not_for 和例子、问题里用反引号点到材料路径。
/** 用到 Jev 的三处:判重、冻结条款、一段代码改动。 */
export type Site = "dedup" | "freeze" | "diff";
/**
* 用到 Jev 的五处:判重、冻结条款、一段代码改动,以及诊断实验的判重和冻结检查。
* 诊断的两处材料是"一个诊断实验"(动当前基线的一处),不是候选提案和冻结协议,所以各有自己的材料说明和问法
* (方法.md 4.3 诊 1、诊 2);一段代码改动的五问直接沿用 diff(诊 4)。
*/
export type Site = "dedup" | "freeze" | "diff" | "diagnosisDedup" | "diagnosisFreeze";
/** 一条是非题(Jev 的 noul)。 */
export interface Noul {
@@ -24,6 +28,9 @@ const THRESHOLDS: Readonly<Record<Site, Readonly<Record<string, number>>>> = {
dedup: { same_change: 0.3 },
freeze: { self_contradictory: 0.5, changes_measurement: 0.21, too_vague: 0.78 },
diff: { unseeded_randomness: 0.55, reads_clock: 0.55, touches_measurement: 0.65, early_exit: 0.63, zero_for_unmeasured: 0.62 },
// 诊断实验的两处阈值暂用 dedup、freeze 的值,没有在诊断实验的材料上校准过,等真实数据(任务书 18 第三块)再校准。
diagnosisDedup: { same_experiment: 0.3 },
diagnosisFreeze: { self_contradictory: 0.5, changes_measurement: 0.21, too_vague: 0.78 },
};
/** 放进材料 `task` 字段的说明:这是什么材料、各部分是什么。 */
@@ -44,6 +51,18 @@ const TASKS: Readonly<Record<Site, string>> = {
"`protocol.metrics` lists the metrics the protocol requires the implementer " +
"to compute in the candidate code and write to metrics.json (each with its `howMeasured` and `missing` rule; an empty list means none). " +
"Only this hunk is shown; judge only what the hunk itself adds or changes.",
diagnosisDedup:
"Two diagnosis experiments from the same round of a study that improves a program by changing its code. " +
"A diagnosis experiment takes the current baseline program, changes ONE spot of it (`spot`) as `body` describes " +
"(replace a step with a clearly stronger version, replace it with another method, remove it, or relax a restriction), " +
"runs the changed program once and scores the answer it returns. " +
"The experiments were written by different researchers, in Chinese or English.",
diagnosisFreeze:
"One diagnosis experiment in a study that improves a program by changing its code. " +
"An implementer will change the baseline program at `experiment.spot` exactly as `experiment.body` says and nothing else; " +
"the unchanged task harness will then run the changed program once, and the task's validator will score the answer it returns. " +
"The implementer may not edit the task's harness or validator files. " +
"`experiment.metrics` lists extra diagnostic metrics the changed program must record (an empty list means none).",
};
const QUESTIONS: Readonly<Record<Site, Readonly<Record<string, Noul>>>> = {
@@ -292,6 +311,120 @@ const QUESTIONS: Readonly<Record<Site, Readonly<Record<string, Noul>>>> = {
},
},
},
// 诊 2 诊断实验判重:一对实验一次请求,写法照 dedup。
diagnosisDedup: {
same_experiment: {
type: "noul",
instructions: {
question:
"Do `experiment_a` and `experiment_b` change the same spot of the baseline in the same way, so that one run would answer both?",
focus:
"Compare where each experiment changes the baseline (`spot`) and how (`body`): the kind of change (stronger version, other method, removal, relaxed restriction) and its settings. " +
"Ignore wording, language, level of detail, the experiment's name, and which explanation it says it can separate from which. " +
"The same spot changed in a different way, or the same change with different settings or sizes, is NOT the same experiment: each would need its own run.",
},
criteria: {
true: {
what: "Both change the same spot of the baseline in the same way with the same settings; they differ only in wording, language or detail",
examples: [
"'replace the optimizer step with an exact solver, no time limit' vs 'swap the optimizer for an exact solve without any time cap'",
"'remove the final repair pass' vs 'delete the repair pass at the end of the pipeline'",
],
},
false: {
what: "They change different spots, change the same spot in a different way, or use the same change with different settings",
not_for: "Two experiments that merely test the same explanation, or merely touch the same function at different spots",
examples: [
"'remove the repair pass' vs 'replace the repair pass with an exact solver'",
"'run the search for 10x more steps' vs 'run the search for 100x more steps'",
"'relax the iteration limit of the search' vs 'relax the time limit of the whole program'",
],
},
},
},
},
// 诊 1 诊断实验冻结检查:每问单独一次请求,材料相同;问法照 freeze,材料换成"一个诊断实验"。
diagnosisFreeze: {
self_contradictory: {
type: "noul",
instructions: {
question:
"Does `experiment` ask for something that cannot be done as written: do two of its statements exclude each other, or does `experiment.body` change places other than the one `experiment.spot` names?",
focus:
"Take the change described in `experiment.body` and check it against `experiment.spot`: the experiment must change that one spot and keep everything else of the baseline as it is. " +
"A change that cannot be carried out without also changing something else, or statements that require things that cannot both hold, make it contradictory. " +
"A statement that is merely unusual, ambitious or likely to give a worse score is not a contradiction.",
},
criteria: {
true: {
what: "Carrying out the experiment necessarily changes more than the named spot, or two statements require things that cannot both hold",
examples: [
"`spot` names the radius computation while `body` also asks to replace the starting layout",
"`body` says remove the step and also tune its step size",
"`body` says change only the solver and also keep the solver's code unchanged",
],
},
false: {
what: "All statements can hold at the same time in one implementation that changes only the named spot",
not_for: "Experiments that may not improve the score, or statements that are vague but not conflicting",
examples: ["replace the solver with an exact LP and keep everything else as in the baseline", "remove the final repair pass"],
},
},
},
changes_measurement: {
type: "noul",
instructions: {
question:
"Does `experiment` require changing how the answer is scored or checked, rather than changing the solution that gets scored?",
focus:
"The answer of the changed program is scored by the task's validator and harness. " +
"Changing the algorithm that produces the answer is allowed, including relaxing a limit or a check inside the program itself, as long as the returned answer is still scored as before. " +
"Changing how the returned value is summed, scored, validated, rounded or reported, or the validator or harness themselves, is a change of measurement. " +
"Rules for the experiment's own diagnostic metrics (what they count, how they are aggregated) do not change the score.",
},
criteria: {
true: {
what: "The experiment asks to alter the scoring or validation: the formula of the score, what gets counted, tolerances, the harness or validator, or the value reported instead of the real one",
examples: [
"report the sum of diameters instead of the sum of radii",
"count only items above a size threshold when summing",
"raise the overlap tolerance in the validator",
],
},
false: {
what: "The experiment only changes how the answer is built or searched; the score is computed from its output as before",
not_for: "Adding diagnostic metrics that are recorded next to the score without replacing it",
examples: ["replace the greedy step with an exact solve", "remove the repair pass and see what the raw answer scores"],
},
},
},
too_vague: {
type: "noul",
instructions: {
question:
"Does `experiment` leave the implementer to guess what to change, for example which spot of the baseline to edit, what to replace it with, or the settings of the stronger version?",
focus:
"Read `experiment.spot` and `experiment.body` as an implementer who must follow them without asking questions. " +
"Brief text is fine when it names the spot, the kind of change and its settings. " +
"Judge only whether each statement is specific; whether two statements conflict is checked separately.",
},
criteria: {
true: {
what: "An implementer would have to invent a key part of the experiment: the spot to change, what to put there, or its main settings",
examples: ["make the search better somewhere", "replace the optimizer with a stronger one", "relax some of the limits"],
},
false: {
what: "The spot, the kind of change and its settings are stated concretely enough to implement directly",
not_for: "Experiments that leave only minor coding details, such as variable names, to the implementer",
examples: [
"in the radius step, replace the heuristic with an exact LP solved by scipy linprog; no time limit",
"remove the final repair pass and return the raw answer",
],
},
},
},
},
};
/** 一问的全部内容:问题原文、阈值,以及材料说明、问题原文、阈值三者合起来的哈希。 */
+7 -5
View File
@@ -17,7 +17,7 @@ import {
type SubmissionKind,
} from "../vocab/index.ts";
// 账本记录的种类(docs/结构.md 第四部分的 11 种记录,细分成下面 19 种),每种一份内容定义(TypeBox)。
// 账本记录的种类(docs/结构.md 第四部分的 11 种记录,细分成下面 20 种),每种一份内容定义(TypeBox)。
// 下面写"只有一条""只一次"的,由 schema.ts 的唯一约束保证。
// 配置、冻结协议、决定、结算的内容由别的模块产生,这里只检查最少的标识字段,其余只检查是 Json。
@@ -131,7 +131,7 @@ export const KINDS = {
submission: Type.Unsafe<StoredSubmission>({}),
/**
* 3 退回:被退回的那份内容原样存(它可能正是因为不合格式才被退回),连同退回理由。
* target 是这次任务的对象,与提交的 target 同义(候选、原提案、消融格,没有对象的写 null):
* target 是这次任务的对象,与提交的 target 同义(候选、原提案、消融格、本轮诊断,没有对象的写 null):
* 同一人在同一阶段可能对几个对象各做一次同种任务,重新发起时按它找本人这个对象的上一次退回。
* jev 是这次退回用到的 Jev 判断(见 JevUsed),判"否"的几问也一起存;没用到 Jev 的写 null。
*/
@@ -161,11 +161,11 @@ export const KINDS = {
/** 6 冻结代码:候选或消融格的代码版本号(由 workspace 给出)和因子位置表。位置表先存 Json。 */
code: Type.Object({ object: Id, version: Text, locations: AnyJson }, CLOSED),
/** 7 运行·登记要跑(占名额):运行编号由 flow 起,一个编号只登记一次;用途、轮次、对象(候选或格;基线没有)、运行定义。 */
/** 7 运行·登记要跑(占名额):运行编号由 flow 起,一个编号只登记一次;用途、轮次、对象(候选、格或本轮诊断;基线没有)、运行定义。 */
runPlanned: Type.Object(
{
run: Id,
purpose: Type.Enum(["baseline", "forward", "ablation"], { type: "string" }),
purpose: Type.Enum(["baseline", "forward", "ablation", "diagnosis"], { type: "string" }),
round: Round,
object: OptionalId,
definition: RunDefinition,
@@ -178,7 +178,7 @@ export const KINDS = {
runResult: Type.Object({ run: Id, process: ProcessResult, judged: Type.Union([JudgedResult, Type.Null()]) }, CLOSED),
/**
* 8 决定:选批、采纳、选格、押的因子、入口分配、判重合并。decision 是决定种类。
* 8 决定:选诊断实验、选批、采纳、选格、押的因子、入口分配、判重合并(提案和诊断实验)。decision 是决定种类。
* 输入输出存 Json:ledger 不认识 rules。判重合并的输入里带 Jev 的原始概率。
*/
decision: Type.Object(
@@ -197,6 +197,8 @@ export const KINDS = {
/** 9 结算:每轮最多采纳一个改法,所以最多一次消融,结算按轮次一轮一条。各格结局、配合关系、解释的确认或否定等先存 Json。 */
settlement: Type.Object({ round: Round, result: AnyJson }, CLOSED),
/** 9 诊断结算:每轮最多一次诊断,结算也按轮次一轮一条(方法.md 6.12)。每个实验的结局与实测、解释的确认或否定等先存 Json。 */
diagnosisSettlement: Type.Object({ round: Round, result: AnyJson }, CLOSED),
/** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */
callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED),
+13 -5
View File
@@ -83,7 +83,7 @@ export class Ledger {
}
/**
* 追加一条记录,返回存下的那一条。先按种类检查内容,再做写入时的四种拒收检查(rejections.ts)。
* 追加一条记录,返回存下的那一条。先按种类检查内容,再做写入时的五种拒收检查(rejections.ts)。
* 写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存,返回的是提案那一条。
*/
append<K extends AppendKind>(kind: K, content: Input<K>): LedgerRecord<K> {
@@ -117,16 +117,24 @@ export class Ledger {
this.#first("stageOpen", "json_extract(content, '$.stageId') = ?", stageId) as LedgerRecord<"stageOpen"> | undefined,
latest: (slot) => this.latestSubmission(slot),
runStarted: (run) => this.#first("runStarted", "json_extract(content, '$.run') = ?", run) !== undefined,
ablationStarted: (round) =>
ablationStarted: (round) => this.#purposeStarted("ablation", round),
diagnosisStarted: (round) => this.#purposeStarted("diagnosis", round),
};
/** 这一轮有没有某种用途的运行已经确认启动。 */
#purposeStarted(purpose: "ablation" | "diagnosis", round: number): boolean {
return (
this.#one(
`SELECT s.seq FROM records s JOIN records p
ON p.kind = 'runPlanned' AND json_extract(p.content, '$.run') = json_extract(s.content, '$.run')
WHERE s.kind = 'runStarted'
AND json_extract(p.content, '$.purpose') = 'ablation'
AND json_extract(p.content, '$.purpose') = ?
AND json_extract(p.content, '$.round') = ?`,
purpose,
round,
) !== undefined,
};
) !== undefined
);
}
// -------------------------------------------------------------------------
// 读取
+17 -5
View File
@@ -1,8 +1,8 @@
import { SUBMISSION_KINDS, type Stage } from "../vocab/index.ts";
import type { Content, Kind, LedgerRecord, SubmissionSlot } from "./kinds.ts";
// 写入时由 ledger 拒绝的四种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),在写入的同一个事务里查。
// 只有这四种。记录之间别的先后(例如运行结果前要先有登记)和提交种类与阶段对不对得上都不查:
// 写入时由 ledger 拒绝的五种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),在写入的同一个事务里查。
// 只有这五种。记录之间别的先后(例如运行结果前要先有登记)和提交种类与阶段对不对得上都不查:
// flow 调 ledger 是模块之间的调用,不是系统边界。"只发生一次"由 schema.ts 的唯一约束管。
// 提交内容的字段归 submissions,该不该开下一个阶段归 flow。
@@ -16,14 +16,18 @@ export interface WriteState {
runStarted(run: string): boolean;
/** 这一轮有没有消融格的运行已经确认启动。 */
ablationStarted(round: number): boolean;
/** 这一轮的诊断运行有没有已经确认启动。 */
diagnosisStarted(round: number): boolean;
}
/** 实现和核验阶段的提交不能替换(方法.md 第五部分总原则):实现、修复、核验、消融实现、消融核验。 */
/** 实现和核验阶段的提交不能替换(方法.md 第五部分总原则):实现、修复、核验、消融实现、消融核验、诊断实现(含修复)、诊断核验。 */
const NOT_REPLACEABLE: ReadonlySet<Stage> = new Set([
"implementation",
"verification",
"ablationImplementation",
"ablationVerification",
"diagnosisImplementation",
"diagnosisVerification",
]);
export function checkWrite(kind: Kind, content: unknown, state: WriteState): void {
@@ -35,7 +39,7 @@ export function checkWrite(kind: Kind, content: unknown, state: WriteState): voi
/**
* 第 1 条:只收开着的阶段的提交,即有开阶段记录、没有关阶段记录。
* 第 2 条:新的一版必须正好接在本人在这个阶段、对同一个对象的最新一版后面(第一版是 1);
* 实现、修复、核验、消融实现、消融核验只能交第一版。
* 实现、修复、核验、消融实现、消融核验、诊断实现、诊断修复、诊断核验只能交第一版。
*/
function checkSubmission(s: Content<"submission">, state: WriteState): void {
const where = `提交 ${s.kind}(阶段 ${s.stageId},作者 ${s.author},对象 ${s.target ?? "无"},编号 ${s.item ?? "无"},第 ${s.version} 版)`;
@@ -63,7 +67,10 @@ function checkRunResult(r: Content<"runResult">, state: WriteState): void {
}
}
/** 第 4 条:这一轮已经有消融格确认启动了,就不能再关解释阶段(方法.md 4.3 ⑭:解释冻结必须早于任何一格启动)。 */
/**
* 第 4 条:这一轮已经有消融格确认启动了,就不能再关解释阶段(方法.md 4.3 ⑭:解释冻结必须早于任何一格启动)。
* 第 5 条:这一轮的诊断运行已经确认启动了,就不能再关诊断预测阶段(方法.md 4.3 诊 2:冻结必须早于诊断运行启动,范围才算事前预测)。
*/
function checkStageClose(c: Content<"stageClose">, state: WriteState): void {
const open = state.stageOpen(c.stageId);
if (open?.content.stage === "explanation" && state.ablationStarted(open.content.round)) {
@@ -71,4 +78,9 @@ function checkStageClose(c: Content<"stageClose">, state: WriteState): void {
`不能关解释阶段 ${c.stageId}:第 ${open.content.round} 轮已经有消融格确认启动,解释冻结必须早于任何一格启动(方法.md 4.3 ⑭)`,
);
}
if (open?.content.stage === "diagnosisPrediction" && state.diagnosisStarted(open.content.round)) {
throw new Error(
`不能关诊断预测阶段 ${c.stageId}:第 ${open.content.round} 轮的诊断运行已经确认启动,诊断预测冻结必须早于诊断运行启动(方法.md 4.3 诊 2)`,
);
}
}
+3 -1
View File
@@ -3,7 +3,7 @@ import type { DatabaseSync } from "node:sqlite";
// 账本的表结构(docs/结构.md 第四部分"全部只追加"):只有一张表,每一行是序号、种类、内容、内容的哈希。
// 表结构一改就把版本号加 1;旧文件版本号对不上时打开报错,不迁移,也不逐字核对建表语句。
export const SCHEMA_VERSION = 4;
export const SCHEMA_VERSION = 5;
/** 新文件要执行的建表语句,由 Ledger 在一个事务里执行,最后写入版本号。 */
export const CREATE_SCHEMA = [
@@ -33,6 +33,8 @@ export const CREATE_SCHEMA = [
`CREATE UNIQUE INDEX call_ended_once ON records (json_extract(content, '$.call')) WHERE kind = 'callEnded'`,
// 结算以轮次为编号:每轮最多采纳一个改法,所以最多一次消融。
`CREATE UNIQUE INDEX settlement_once ON records (json_extract(content, '$.round')) WHERE kind = 'settlement'`,
// 诊断结算同样以轮次为编号:每轮最多一次诊断。
`CREATE UNIQUE INDEX diagnosis_settlement_once ON records (json_extract(content, '$.round')) WHERE kind = 'diagnosisSettlement'`,
`PRAGMA user_version=${SCHEMA_VERSION}`,
];
+78 -8
View File
@@ -3,7 +3,14 @@ import { type Draft, factorLines, KIND_TEXT, offText, registeredMetrics, taskPoi
import type { Board } from "./read.ts";
import { fenced, json } from "./format.ts";
import { unassignedChanges } from "./locations.ts";
import { clauseKey, factorKey, splitCell, type FrozenProtocol, type LocationTable } from "./shapes.ts";
import {
clauseKey,
diagnosisId,
factorKey,
splitCell,
type FrozenProtocol,
type LocationTable,
} from "./shapes.ts";
// 实验员、核验员的 5 种任务(方法.md 4.3 ⑨ ⑯):只看分给他的那个候选或那一格——冻结协议、起点代码、
// 要实现或核验的东西。不给信念本(用户 2026-10-03 决定 3)、不给任何运行成绩、不给别的候选。
@@ -36,8 +43,7 @@ export function protocolMetrics(board: Board, p: FrozenProtocol): HypothesisMetr
})];
}
function metricSection(d: Draft, p: FrozenProtocol): void {
const all = protocolMetrics(d.board, p);
function metricSection(d: Draft, all: HypothesisMetric[]): void {
d.section("要实现的指标定义", [
all.length === 0 ? "(这份协议没有要实现的假设专用指标)" : json(all),
"",
@@ -58,15 +64,22 @@ function codeLines(d: Draft): string[] {
return [...d.readableLines(), "读这些目录用 read_source(核验员)或 shell(实验员)。"];
}
/** 本轮做过诊断时,诊断程序的代码也能读(只读),冻结协议可以写"照诊断实验 dN 的做法"(方法.md 4.3 ②);只给代码,不给成绩和实验原文。 */
function diagnosisReference(d: Draft): string[] {
if (d.board.diagnosisSettlement(d.round) === undefined) return [];
d.read(diagnosisId(d.round), "本轮诊断程序(只读,冻结协议引用诊断实验时作参考)");
return ["上面还列了本轮诊断程序的代码:冻结协议引用诊断实验时作参考,别把它当成这个候选的起点。"];
}
/** ⑨ 实现:冻结协议、指标定义、要交的位置表、起点代码(本批基线)。 */
export function implementation(d: Draft): ChecklistOf<"implementation"> {
const candidate = d.req.object!;
const { p, items } = frozen(d, candidate);
taskSection(d, "实验员", `候选 ${candidate}`);
d.read(p.baseline, "起点代码(本批基线)");
d.section("基线代码", [...codeLines(d), "你的工作目录是起点代码的副本,改动都落在工作目录里。"]);
d.section("基线代码", [...codeLines(d), "你的工作目录是起点代码的副本,改动都落在工作目录里。", ...diagnosisReference(d)]);
d.section("本任务要处理的东西", [`### 候选 ${candidate} 的冻结协议`, "", json(p)]);
metricSection(d, p);
metricSection(d, protocolMetrics(d.board, p));
locationTableSection(d, p);
return { kind: "implementation", ...items };
}
@@ -78,7 +91,7 @@ export function repair(d: Draft): ChecklistOf<"repair"> {
taskSection(d, "实验员", `候选 ${candidate}`);
d.read(p.baseline, "起点代码(本批基线)");
d.read(candidate, "你上一次提交的代码");
d.section("基线代码", [...codeLines(d), "你的工作目录是上一次提交的代码的副本。"]);
d.section("基线代码", [...codeLines(d), "你的工作目录是上一次提交的代码的副本。", ...diagnosisReference(d)]);
const v = d.board.published("verification").filter((s) => s.target === candidate).at(-1)!.content;
d.section("本任务要处理的东西", [
"### 退回意见",
@@ -102,7 +115,7 @@ export function repair(d: Draft): ChecklistOf<"repair"> {
"",
json(p),
]);
metricSection(d, p);
metricSection(d, protocolMetrics(d.board, p));
locationTableSection(d, p);
return { kind: "repair", ...items };
}
@@ -148,7 +161,7 @@ export function verification(d: Draft): ChecklistOf<"verification"> {
"",
fenced(d.req.changes!.diff, "diff"),
]);
metricSection(d, p);
metricSection(d, protocolMetrics(d.board, p));
return { kind: "verification", unassigned: found.unassigned.map((u) => u.location), ...items };
}
@@ -212,3 +225,60 @@ export function ablationVerification(d: Draft): ChecklistOf<"ablationVerificatio
]);
return { kind: "ablationVerification", cell: a.cell, factors: a.factors };
}
// ---------------------------------------------------------------------------
// 诊断的三种任务(方法.md 4.3 诊 4):对象是本轮诊断,一位实验员把选中的全部实验写进一份诊断程序,一位核验员逐个核对。
// 只看选中的实验原文和当前基线代码,不给信念本、不给任何成绩。
// ---------------------------------------------------------------------------
/** 三种诊断任务共用:任务与本轮、起点代码(当前基线)、选中的实验原文和指标定义;program 是另外要能读的诊断程序的说明。交回选中的实验编号。 */
function diagnosisTask(d: Draft, role: string, program: string | null): string[] {
d.group("分给你的材料", "下面是分给你的本轮诊断的全部材料。");
const chosen = d.board.decision("selectDiagnosis", d.round)!.output.chosen;
const experiments = d.board.diagnosisExperiments(d.round).filter((e) => chosen.includes(e.id));
taskSection(d, role, `本轮诊断(对象 ${diagnosisId(d.round)})`);
d.read(d.board.baselineOf(d.round), "起点代码(当前基线)");
if (program !== null) d.read(diagnosisId(d.round), program);
d.section("基线代码", codeLines(d));
d.section("选中的诊断实验", [
"每个实验都从当前基线的代码出发,只动它写的那一处,其余照基线;每个实验交出自己的答案。",
"",
...experiments.flatMap((e) => [`### ${e.id} ${e.name}`, "", `动哪一处:${e.spot}`, "", e.body, ""]),
]);
metricSection(d, experiments.flatMap((e) => e.metrics));
return chosen;
}
/** 诊断实现,加上原始产物的位置;修复(repair 为 true)另放退回意见(没通过的实验和依据、上一次的位置表和改动),通过的实验不要动。 */
function diagnosisCode(d: Draft, repair: boolean): string[] {
const chosen = diagnosisTask(d, "实验员", repair ? "你上一次提交的诊断程序" : null);
d.section("原始产物", [
`产出当前基线的那次正式运行(${d.board.runOf(d.board.baselineOf(d.round))!.plan.run})的原始产物已经只读地放进你的工作目录,实验要接着算的答案就在里面;位置和怎么读写在任务说明里(research_query 查 task)。`,
"你的工作目录是当前基线代码的副本,加上这份原始产物,改动都落在工作目录里。",
]);
if (!repair) return chosen;
const v = d.board.published("diagnosisVerification").filter((s) => s.round === d.round).at(-1)!.content;
const failed = v.experiments.filter((x) => !x.followed || !x.nothingElse);
d.section("退回意见", [
...failed.map((x) => `- 实验 ${x.experiment}:${x.followed ? "" : "没照写法做;"}${x.nothingElse ? "" : "动了写法以外的地方;"}${x.where}`),
"",
`核验员的说明:${v.body}`,
"只修上面没通过的实验,通过的实验的代码不要动。",
"",
...["### 上一次交的位置表", "", json(d.board.code(diagnosisId(d.round)).locations), "", "### 上一次提交相对起点代码的改动", "", fenced(d.req.changes!.diff, "diff")],
]);
return chosen;
}
export const diagnosisImplementation = (d: Draft): ChecklistOf<"diagnosisImplementation"> => ({ kind: "diagnosisImplementation", experiments: diagnosisCode(d, false) });
export const diagnosisRepair = (d: Draft): ChecklistOf<"diagnosisRepair"> => ({ kind: "diagnosisRepair", experiments: diagnosisCode(d, true) });
/** 诊断核验:选中的实验原文、当前基线到诊断程序的改动、位置表。Jev 的判断不给核验员(同正向核验)。 */
export function diagnosisVerification(d: Draft): ChecklistOf<"diagnosisVerification"> {
const chosen = diagnosisTask(d, "核验员", "要核验的诊断程序");
d.section("本任务要处理的东西", [
...["### 实验员交的位置表", "", json(d.board.code(diagnosisId(d.round)).locations), ""],
...["### 改动全文(诊断程序相对当前基线,每段前后各 3 行上下文)", "", fenced(d.req.changes!.diff, "diff")],
]);
return { kind: "diagnosisVerification", experiments: chosen };
}
+10 -1
View File
@@ -12,6 +12,7 @@ import {
type Phase,
} from "../rules/index.ts";
import type { ProcessResult, Range } from "../vocab/index.ts";
import { diagnosisHistory } from "./diagnosis.ts";
import { measured, num, pct, PLACEMENT_TEXT, rangeText, resourcesText, score, SYNERGY_TEXT, UNTESTED_TEXT } from "./format.ts";
import type { StudyFacts } from "./inputs.ts";
import { answerPathLines } from "./locations.ts";
@@ -21,6 +22,8 @@ import { CELL_NOT_RUN, cellId, factorId, factorKey, ORIGINAL_BASELINE, type Sett
// 信念本(方法.md 6.10):程序从已公开的记录现算,不存。数字全由 rules 的函数算,这里只挑出来写成字。
// 只读这几样:已关阶段的提交、按 Board.runPublic 公开的运行、决定、结算、解封;封存、第一次预测、退回一律不读。
const PHASE_TEXT: Readonly<Record<Phase, string>> = { forward: "正向", diagnosis: "诊断", ablation: "消融" };
/** 范围怎么换算成把握(方法.md 6.2、6.3),写在开头。只说程序怎么用这三个数,不要求研究者照着算。 */
function rangeToBelief(lambda: number): string {
return (
@@ -118,6 +121,9 @@ export function beliefBookOf(board: Board, study: StudyFacts, round: number | nu
}
for (const s of settlements) lines.push(...ablationSummary(board, s));
// 3 诊断历史
lines.push("", "**诊断历史**(每轮的诊断实验与实测、每条解释确认或否定、没做的实验和原因,以及程序推出的方向)", ...diagnosisHistory(board, study, latestRound));
// 4 战绩
lines.push("", "**战绩**(只显示,不改任何权重;偏高偏低都按分数原值判)");
const samples: Array<{ researcher: string; phase: Phase; range: Range; value: number }> = batches.flatMap((d) =>
@@ -127,6 +133,9 @@ export function beliefBookOf(board: Board, study: StudyFacts, round: number | nu
return v === null ? [] : c.ranges.map((r) => ({ researcher: r.researcher, phase: "forward" as const, range: r.range, value: v }));
}),
);
for (const s of board.diagnosisSettlements())
for (const e of s.input.experiments)
if (e.outcome.kind === "valid") for (const x of s.input.explanations) samples.push({ researcher: x.author, phase: "diagnosis", range: x.experiments.find((y) => y.experiment === e.experiment)!.range, value: e.outcome.score });
for (const s of settlements)
for (const c of s.input.cells) {
if (c.outcome.kind !== "valid") continue;
@@ -139,7 +148,7 @@ export function beliefBookOf(board: Board, study: StudyFacts, round: number | nu
if (tallies.length === 0) lines.push("- 还没有实测可以核对");
for (const { researcher, phase, tally: t } of tallies) {
lines.push(
`- ${researcher},${phase === "forward" ? "正向" : "消融"} ${t.total} 次:中位数偏高 ${t.medianHigh}、偏低 ${t.medianLow};` +
`- ${researcher},${PHASE_TEXT[phase]} ${t.total} 次:中位数偏高 ${t.medianHigh}、偏低 ${t.medianLow};` +
`穿过上沿 ${t.aboveTop}、下沿 ${t.belowBottom};包住 ${t.covered};落点 ` +
(Object.keys(PLACEMENT_TEXT) as Array<keyof typeof PLACEMENT_TEXT>).map((k) => `${PLACEMENT_TEXT[k]} ${t.placements[k]}`).join(","),
);
+46 -4
View File
@@ -2,11 +2,12 @@ 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";
import { directionText, json, measured, score } from "./format.ts";
import { diagnosisLines } from "./diagnosis.ts";
import { directionText, json, measured, num, resourcesText, score } from "./format.ts";
import type { TaskRequest } from "./inputs.ts";
import { answerPathLines } from "./locations.ts";
import { Board, type Run } from "./read.ts";
import { entryId, explanationId, factorKey, splitCell, type FrozenProtocol } from "./shapes.ts";
import { entryId, explanationId, factorKey, ORIGINAL_BASELINE, splitCell, type FrozenProtocol } from "./shapes.ts";
// 任务材料的形状,和研究者各任务共用的几块(方法.md 第三部分"给研究者的材料顺序")。
@@ -38,6 +39,11 @@ export const KIND_TEXT: Readonly<Record<SubmissionKind, string>> = {
entry: "第零轮闭门写入口",
discussion: "第零轮讨论",
claim: "第零轮认领入口",
diagnosisProposal: "写诊断解释与诊断实验",
diagnosisPrediction: "诊断预测",
diagnosisImplementation: "实现诊断实验",
diagnosisRepair: "按核验意见修复诊断程序",
diagnosisVerification: "核验诊断程序",
proposal: "提案",
firstPrediction: "第一次预测",
critique: "挑刺",
@@ -142,12 +148,42 @@ export function codeSection(d: Draft): void {
d.read(d.board.baselineOf(d.round), "本批基线");
for (const r of d.board.runs()) {
if (r.plan.object !== null && r.plan.purpose !== "baseline" && d.board.runPublic(r)) {
d.read(r.plan.object, `${r.plan.purpose === "forward" ? "候选" : "消融格"} ${r.plan.object} 的代码`);
d.read(r.plan.object, r.plan.purpose === "diagnosis" ? `第 ${r.plan.round} 轮诊断程序的代码` : `${r.plan.purpose === "forward" ? "候选" : "消融格"} ${r.plan.object} 的代码`);
}
}
d.section("基线", [...baselineLines(d), "", "能读的代码(用 read_source 读):", ...d.readableLines()]);
}
/** 本轮诊断结果(方法.md 第三部分材料顺序第 3 条):从提案起的全部研究者任务都有,没诊断写原因;诊断任务自己读时这一段还没走完,不放。 */
export function diagnosisSection(d: Draft): void {
if (d.board.diagnosisDone(d.round)) d.section("本轮诊断结果", diagnosisLines(d.board, d.req.study, d.round, true));
}
/** 运行的产物只放进查询,这里列编号。 */
export function artifactSection(d: Draft, heading: string): void {
const artifacts = d.req.artifacts!;
const listed = Object.keys(artifacts)
.sort(compareIds)
.flatMap((run) => Object.entries(artifacts[run]!).map(([file, text]) => `- ${d.record(`${run}/${file}`, `运行 ${run} 的产物 ${file}`, text)}`));
d.section(heading, ["用 research_query 按编号查全文:", ...(listed.length === 0 ? ["(没有)"] : listed)]);
}
/** 本批基线那次运行的结果:分数、判分、其余指标、资源、任务的附加记录;原始基线没有位置表,不写答案路径。 */
export function baselineRunSection(d: Draft): void {
const base = d.board.baselineOf(d.round);
const run = d.board.publicRunOf(base)!;
const j = run.result!.judged!;
const others = Object.keys(j.metrics).filter((k) => k !== d.req.study.finalMetric).sort(compareIds);
d.section("本批基线那次运行的结果", [
...(base === ORIGINAL_BASELINE
? [`运行 ${run.plan.run}:任务给的原始代码;${j.verdict === "valid" ? "算数" : "不算数"}(${j.verdictReason});分数 ${measured(j.score, score)}`]
: runLines(d, run)),
resourcesText(run.result!.process, d.board.publicRunOf(ORIGINAL_BASELINE)!.result!.process, d.req.study.wallSeconds),
...(others.length === 0 ? [] : [`其余指标:${others.map((k) => `${k} ${measured(j.metrics[k]!, num)}`).join(";")}`]),
`任务的附加记录(含合法化,原样):${JSON.stringify(j.extra)}`,
]);
}
export function beliefSection(d: Draft): void {
d.section("信念本", [beliefBookOf(d.board, d.req.study, d.round)]);
}
@@ -194,7 +230,13 @@ export function publicRecords(d: Draft): void {
e.content.explanations.forEach((x, i) => d.record(explanationId(e.round, e.author, i + 1), `解释 ${explanationId(e.round, e.author, i + 1)}(${e.author})`, x));
}
for (const w of b.published("wrapUp")) d.record(`${w.stageId}.${w.author}`, `${w.author} 第 ${w.round} 轮的收尾`, w.content.body);
for (const r of b.runs()) if (r.plan.purpose !== "baseline" && b.runPublic(r)) d.record(r.plan.run, `运行 ${r.plan.run}`, [...runLines(d, r), "", json(r.result!.judged)].join("\n"));
for (const r of b.runs()) {
if (r.plan.purpose !== "baseline" && r.plan.purpose !== "diagnosis" && b.runPublic(r)) d.record(r.plan.run, `运行 ${r.plan.run}`, [...runLines(d, r), "", json(r.result!.judged)].join("\n"));
}
// 诊断的完整结果:各人给的三个数(结算输入)不放,只有合并后的范围和逐个实验的判分。
for (const { round, input: _input, ...settled } of b.diagnosisSettlements()) {
d.record(`diagnosis-r${round}`, `第 ${round} 轮诊断的完整结果`, json({ explanations: b.diagnosisExplanations(round), experiments: b.diagnosisExperiments(round), ...settled }));
}
d.section("研究记录", [
"已经公开的记录,一行一条;全文用 research_query 按编号查。",
...d.records.map((r) => `- ${r.id}:${r.title}`),
+5
View File
@@ -12,6 +12,11 @@ const BUILDERS: { [K in SubmissionKind]: (d: Draft) => Extract<Checklist, { kind
entry: researcher.entry,
discussion: researcher.discussion,
claim: researcher.claim,
diagnosisProposal: researcher.diagnosisProposal,
diagnosisPrediction: researcher.diagnosisPrediction,
diagnosisImplementation: assigned.diagnosisImplementation,
diagnosisRepair: assigned.diagnosisRepair,
diagnosisVerification: assigned.diagnosisVerification,
proposal: researcher.proposal,
firstPrediction: researcher.firstPrediction,
critique: researcher.critique,
+78
View File
@@ -0,0 +1,78 @@
import { runBalance } from "../rules/index.ts";
import { measured, num, rangeText, resourcesText, score } from "./format.ts";
import type { StudyFacts } from "./inputs.ts";
import type { Board, MergedExperiment } from "./read.ts";
import { DIAGNOSIS_NOT_RUN, diagnosisId, ORIGINAL_BASELINE, type DiagnosisSettlement } from "./shapes.ts";
// 一轮诊断的结果写成文字(方法.md 4.3 诊 4、6.10 第 3 条),公共材料的"本轮诊断结果"和信念本的"诊断历史"共用。
// 各人对诊断实验给的三个数不放进来,只有合并后的范围。
const ENDING = { finished: "跑完", crashed: "跑崩了", timeout: "超过分到的时间被杀", notRun: "整次运行先结束,没轮到" } as const;
const SKIPPED = { budget: "一次诊断最多做 D 个实验,放不下", value: "信息价值不足以抵过成本" } as const;
/** 第 round 轮没有诊断的原因(方法.md 4.3 诊 3、诊 4)。 */
function noDiagnosisReason(board: Board, study: StudyFacts, round: number): string {
const decision = board.decision("selectDiagnosis", round);
if (decision === undefined) {
const balance = runBalance(study.totalRuns, board.runStatesBefore(round)).balance;
return balance < 2 ? `余额只剩 ${balance} 次,不到 2 次,要留 1 次给本轮正向` : "配置关掉了诊断";
}
if (!decision.output.diagnose) return `信息价值减成本都不大于 0,最好的组合净值 ${num(Math.max(...decision.output.combos.map((c) => c.net)))}`;
return `选中的实验核验都没通过、修复次数用完,一个都没跑:${board.eventReason(DIAGNOSIS_NOT_RUN, diagnosisId(round))}`;
}
const signed = (x: number): string => (x > 0 ? `+${score(x)}` : score(x));
/** 一个合并后的实验:做了什么、实测、结局、测前合并范围、比特、惊讶、用时。 */
function experimentLine(board: Board, study: StudyFacts, round: number, s: DiagnosisSettlement, e: MergedExperiment): string {
const row = s.output.experiments.find((x) => x.experiment === e.id)!;
const judged = s.judged.experiments.find((x) => x.id === e.id);
const dropped = s.dropped.find((x) => x.experiment === e.id);
const skipped = board.decision("selectDiagnosis", round)!.output.experiments.find((y) => y.experiment === e.id)!;
const outcome = {
valid: () => `实测 ${measured(judged!.score, score)},改进量 ${signed(row.gain!)}`,
untested: () => `没做:${SKIPPED[skipped.reason!]}${skipped.bestCombo === null ? "" : `(含它的最好组合净值 ${num(skipped.bestCombo.net)})`}`,
insufficient: () => `证据不足:${dropped === undefined ? `${ENDING[judged!.ending]};${judged!.verdictReason}` : `核验不通过,${dropped.reason}`}`,
}[row.outcome]();
const parts = [outcome, `测前合并范围 ${rangeText(row.merged)}`];
if (row.worldBits !== null) parts.push(`世界比特 ${num(row.worldBits)}`);
if (row.surprise !== null) parts.push(`惊讶 ${measured(row.surprise.bits, (x) => `${num(x)} 比特`)}`);
if (judged !== undefined) {
parts.push(`用时 ${measured(judged.wallSeconds, (x) => `${num(x)} 秒`)}`);
const others = Object.keys(judged.metrics).filter((k) => k !== study.finalMetric).sort();
if (others.length > 0) parts.push(`其余指标:${others.map((k) => `${k} ${measured(judged.metrics[k]!, num)}`).join(";")}`);
}
return `- ${e.id} ${e.name}(作者 ${e.authors.join("、")};动 ${e.spot}):${parts.join(";")}`;
}
/**
* 第 round 轮诊断的结果:每个实验做了什么、实测、用时、结局,每条诊断解释确认或否定,程序推出的方向;没诊断时写原因。
* detail 为 true 时每条解释带正文(公共材料),否则只列确认与否定(信念本的诊断历史)。
*/
export function diagnosisLines(board: Board, study: StudyFacts, round: number, detail: boolean): string[] {
const s = board.diagnosisSettlement(round);
if (s === undefined) return [`本轮没有诊断:${noDiagnosisReason(board, study, round)}`];
const process = board.runs().find((r) => r.plan.run === s.run)!.result!.process;
const lines = [
`诊断运行 ${s.run}:在基线 ${board.baselineOf(round)}(最终指标 ${score(s.input.baseline)})上,一次运行做了 ${s.judged.experiments.length} 个实验,只扣 1 次。` +
"诊断实验的答案不采纳为基线,只用来看这个方向还剩多少空间。",
resourcesText(process, board.publicRunOf(ORIGINAL_BASELINE)!.result!.process, study.wallSeconds),
...["", "诊断实验:", ...board.diagnosisExperiments(round).map((e) => experimentLine(board, study, round, s, e)), "", "诊断解释:"],
];
for (const x of board.diagnosisExplanations(round)) {
const verdicts = s.output.judgments.find((j) => j.id === x.id)!.verdicts;
const of = (v: string) => verdicts.filter((y) => y.verdict === v).map((y) => y.experiment).join("、") || "无";
lines.push(`- ${x.id}(作者 ${x.author}):${verdicts.length === 0 ? "没有有效实验可判" : `确认 ${of("confirmed")};否定 ${of("denied")}`}`);
if (detail) lines.push(...x.body.split("\n").map((l) => ` ${l}`));
}
const d = s.output.direction;
const gain = d.gains.find((g) => g.experiment === d.experiment)!;
const way = `程序按诊断推出的方向:${d.experiment}(${gain.measured ? "实测" : "按更新后的权重估计"} ${signed(d.gain)})。这只是按公式推出来的参考,你可以不跟。`;
return [...lines, "", d.found ? way : "诊断没有指出能涨分的方向:所有实验的收益都不为正。"];
}
/** 信念本的"诊断历史"(方法.md 6.10 第 3 条):每个诊断这一段已走完的轮次一段。 */
export function diagnosisHistory(board: Board, study: StudyFacts, latestRound: number): string[] {
const rounds = Array.from({ length: latestRound }, (_, i) => i + 1).filter((r) => board.diagnosisDone(r));
return rounds.length === 0 ? ["- 还没有"] : rounds.flatMap((r) => [`- 第 ${r} 轮:`, ...diagnosisLines(board, study, r, false).map((l) => (l === "" ? l : ` ${l}`))]);
}
+3 -1
View File
@@ -7,7 +7,7 @@ export { buildMaterial } from "./build.ts";
export type { CodeChanges, PromptFile, Prompts, StudyFacts, TaskRequest } from "./inputs.ts";
export { queryMaterial, readPrompts } from "./prompts.ts";
// flow 推进时读账本也用这一份(本批基线、基线分数、留池候选、各候选合并了哪些原提案),不另写一份。
export { Board, validScore } from "./read.ts";
export { Board, validScore, type MergedExperiment } from "./read.ts";
export {
callId,
candidateId,
@@ -17,6 +17,7 @@ export {
clauseKey,
critiqueId,
DECISION_KINDS,
DIAGNOSIS_NOT_RUN, diagnosisExplanationId, diagnosisId, diagnosisKey, diagnosisNotRunEvent,
entryId,
experimenterId,
verifierId,
@@ -33,6 +34,7 @@ export {
type DecisionKind,
type DecisionShapes,
type DedupOutput,
type DiagnosisDedupOutput, type DiagnosisLocationTable, type DiagnosisSettlement,
type FrozenProtocol,
type LocationTable,
type Settlement,
+6 -4
View File
@@ -17,8 +17,10 @@ export type StudyFacts = {
lambda: number;
/** 正式运行总次数。 */
totalRuns: number;
/** 每人每轮提案上限 M。 */
/** 每人每轮提案上限 M,诊断解释的上限也是它。 */
proposalLimit: number;
/** 每人每轮诊断实验上限 A;诊断关掉时为 null。 */
diagnosisExperimentLimit: number | null;
factorLimit: number;
/** 最终指标和常规辅助指标的定义(tasks 的 metricDefinitions)。 */
metrics: MetricDefinition[];
@@ -50,15 +52,15 @@ export type Prompts = { roles: Record<Role, PromptFile>; tasks: Record<Submissio
/**
* 生成一份任务材料要的全部输入。全部必填,用不到的写 null:
* - changes:核验是起点到候选代码,修复是起点到上一次提交的代码,消融核验是完整代码到这一格的代码;其余为 null;
* - artifacts:解释任务要的本轮各次运行的产物(运行编号 → 文件名 → 文字);其余为 null;
* - changes:核验是起点到候选代码,修复是起点到上一次提交的代码,消融核验是完整代码到这一格的代码;诊断核验是当前基线到诊断程序,诊断修复是当前基线到上一次提交的诊断程序;其余为 null;
* - artifacts:解释任务要的本轮各次运行的产物、诊 1 要的本批基线那次运行的产物(运行编号 → 文件名 → 文字);其余为 null;
* - codeRoot:能读的代码版本取出到 `<codeRoot>/<版本号>`,flow 照材料里的目录取出。
*/
export type TaskRequest = {
kind: SubmissionKind;
actor: string;
stageId: string;
/** 一个任务一个对象:候选、原提案(挑刺)或消融格;入口、讨论、志愿、提案、解释、收尾为 null。 */
/** 一个任务一个对象:候选、原提案(挑刺)、消融格或本轮诊断(diagnosisId);入口、讨论、志愿、提案、诊断解释与预测、解释、收尾为 null。 */
object: string | null;
ledger: Ledger;
study: StudyFacts;
+49 -3
View File
@@ -1,11 +1,13 @@
import type { Content, LedgerRecord, StoredSubmission } from "../ledger/index.ts";
import { compareIds, type RunState } from "../rules/index.ts";
import type { JudgedResult, Range, Stage, SubmissionKind } from "../vocab/index.ts";
import type { HypothesisMetric, JudgedResult, Range, Stage, SubmissionKind } from "../vocab/index.ts";
import {
cellId,
diagnosisExplanationId,
ORIGINAL_BASELINE,
type DecisionKind,
type DecisionShapes,
type DiagnosisSettlement,
type FrozenProtocol,
type LocationTable,
type Settlement,
@@ -22,6 +24,9 @@ export type Run = {
result: Content<"runResult"> | null;
};
/** 本轮合并后的一个诊断实验:编号、全部作者、正文(取作者里编号最小的那一份)、登记的指标(编号已是全名"实验编号.短名")。 */
export type MergedExperiment = { id: string; authors: string[]; name: string; spot: string; body: string; metrics: HypothesisMetric[] };
type StageInfo = { stage: Stage; round: number; roster: string[]; closed: boolean };
export class Board {
@@ -116,12 +121,14 @@ export class Board {
/**
* 运行什么时候公开(任务书 09 设计稿):基线运行有结果即公开;第 r 轮正向运行在第 r 轮的采纳决定写入后
* 整批公开(方法.md 4.3 ⑪);第 r 轮消融运行在第 r 轮结算写入后公开(4.3 ⑰ ⑱)。
* 整批公开(方法.md 4.3 ⑪);第 r 轮消融运行在第 r 轮结算写入后公开(4.3 ⑰ ⑱);
* 第 r 轮诊断运行在第 r 轮诊断结算写入后公开(4.3 诊 4)。
*/
runPublic(run: Run): boolean {
if (run.result === null) return false;
if (run.plan.purpose === "baseline") return true;
if (run.plan.purpose === "forward") return this.decision("adopt", run.plan.round) !== undefined;
if (run.plan.purpose === "diagnosis") return this.diagnosisSettlement(run.plan.round) !== undefined;
return this.settlement(run.plan.round) !== undefined;
}
@@ -140,7 +147,12 @@ export class Board {
/** 每次运行在账上的状态,算余额用(方法.md 第七部分)。 */
runStates(): RunState[] {
return this.runs().map((r) => (r.result?.process.ending === "notStarted" ? "notStarted" : r.started ? "started" : "reserved"));
return this.runs().map(stateOf);
}
/** 第 round 轮开始前各次运行在账上的状态(算诊断开始时的余额)。 */
runStatesBefore(round: number): RunState[] {
return this.runs().filter((r) => r.plan.round < round).map(stateOf);
}
/** 某个候选最新的实现或修复提交(已关阶段)。 */
@@ -169,6 +181,38 @@ export class Board {
return this.#of("settlement").map((r) => ({ round: r.content.round, ...(r.content.result as unknown as Settlement) }));
}
/** 第 round 轮的诊断结算(还没写入为 undefined)。 */
diagnosisSettlement(round: number): DiagnosisSettlement | undefined {
return this.diagnosisSettlements().find((s) => s.round === round);
}
diagnosisSettlements(): Array<{ round: number } & DiagnosisSettlement> {
return this.#of("diagnosisSettlement").map((r) => ({ round: r.content.round, ...(r.content.result as unknown as DiagnosisSettlement) }));
}
/** 第 round 轮诊断这一段走完了没有:提案阶段开了,就是结算写了或本轮不诊断。 */
diagnosisDone(round: number): boolean {
return [...this.stages.values()].some((s) => s.stage === "proposal" && s.round === round);
}
/** 第 round 轮已公开的诊断解释,按作者、条序排。 */
diagnosisExplanations(round: number): Array<{ id: string; author: string; body: string }> {
return this.published("diagnosisProposal")
.filter((s) => s.round === round)
.sort((a, b) => compareIds(a.author, b.author))
.flatMap((s) => s.content.explanations.map((x, i) => ({ id: diagnosisExplanationId(round, s.author, i + 1), author: s.author, body: x.body })));
}
/** 第 round 轮合并后的诊断实验:判重合并的决定指明合成自哪几个作者的哪几个实验,正文取第一个。 */
diagnosisExperiments(round: number): MergedExperiment[] {
const proposals = this.published("diagnosisProposal").filter((s) => s.round === round);
const experimentOf = (src: { author: string; index: number }) => proposals.find((s) => s.author === src.author)!.content.experiments[src.index]!;
return this.decision("diagnosisDedup", round)!.output.experiments.map(({ experiment, sources }) => {
const { name, spot, body, metrics } = experimentOf(sources[0]!);
return { id: experiment, authors: [...new Set(sources.map((x) => x.author))], name, spot, body, metrics: metrics.map((m) => ({ ...m, id: `${experiment}.${m.id}` })) };
});
}
/** 本人最新的私人笔记(方法.md 第三部分"私人笔记"),没写过为 null。 */
note(author: string): string | null {
return this.#of("note").filter((r) => r.content.author === author).at(-1)?.content.body ?? null;
@@ -249,6 +293,8 @@ export class Board {
}
}
const stateOf = (r: Run): RunState => (r.result?.process.ending === "notStarted" ? "notStarted" : r.started ? "started" : "reserved");
/** 判分结果里的有效分数:算数且测到了分数才有,否则 null。 */
export function validScore(judged: JudgedResult | null): number | null {
return judged !== null && judged.verdict === "valid" && judged.score.status === "measured" ? judged.score.value : null;
+38 -12
View File
@@ -1,8 +1,11 @@
import { compareIds } from "../rules/index.ts";
import { ANSWER_PATH_KEY, SAFEGUARD_TRIGGERS_KEY, type ChecklistOf } from "../vocab/index.ts";
import {
artifactSection,
baselineRunSection,
beliefSection,
codeSection,
diagnosisSection,
Draft,
entryVersions,
factorLines,
@@ -33,6 +36,7 @@ function frame(d: Draft, own: Own, middle: () => void): void {
researcherTaskSection(d);
codeSection(d);
if (d.round >= 1) {
diagnosisSection(d);
publicRecords(d);
beliefSection(d);
}
@@ -126,16 +130,42 @@ function concernLines(d: Draft, round: number): string[] {
return lines.length === 0 ? ["(没有)"] : lines;
}
/** 上一轮全部研究者的收尾(提案、诊 1 共用)。 */
function previousWrapSection(d: Draft): void {
const prev = d.round - 1;
const wraps = d.board.published("wrapUp").filter((w) => w.round === prev);
d.section("上一轮的收尾", prev < 1 ? ["(这是第 1 轮,没有上一轮)"] : wraps.flatMap((w) => [`### ${w.author} 的收尾`, "", w.content.body, ""]));
}
/** 诊 1 诊断解释与诊断实验:当前基线的代码,产出它的那次运行的答案、分数、指标,信念本,上一轮收尾;第 1 轮的第零轮全部记录在研究记录里。 */
export function diagnosisProposal(d: Draft): ChecklistOf<"diagnosisProposal"> {
frame(d, [], () => {
baselineRunSection(d);
artifactSection(d, "本批基线那次运行的答案");
previousWrapSection(d);
});
return { kind: "diagnosisProposal", maxExplanations: d.req.study.proposalLimit, maxExperiments: d.req.study.diagnosisExperimentLimit!, takenMetricIds: factorRules(d).takenMetricIds };
}
/** 诊 2 诊断预测:合并后的诊断实验清单(标明作者)、全部诊断解释;自己的解释另列在"你自己的"里。别人在这一步交的数一概不读(只经 Board.mine 读本人的)。 */
export function diagnosisPrediction(d: Draft): ChecklistOf<"diagnosisPrediction"> {
const all = d.board.diagnosisExplanations(d.round);
const mine = all.filter((x) => x.author === d.req.actor);
const experiments = d.board.diagnosisExperiments(d.round);
const text = (x: { id: string; author: string; body: string }) => [`#### ${x.id}(作者 ${x.author})`, "", x.body, ""];
frame(d, [{ heading: "你的诊断解释", lines: ["下面每一条都要对每个合并后的诊断实验各给三个数。", "", ...mine.flatMap(text)] }], () => {
baselineRunSection(d);
const merged = experiments.flatMap((e) => [`#### ${e.id} ${e.name}(作者 ${e.authors.join("、")})`, "", `动哪一处:${e.spot}`, "", e.body, ""]);
d.section("本任务要处理的东西", ["### 合并后的诊断实验", "", ...merged, "### 全部诊断解释(含你自己的)", "", ...all.flatMap(text)]);
});
return { kind: "diagnosisPrediction", explanations: mine.map((x) => x.id), experiments: experiments.map((e) => e.id) };
}
/** ② 提案:公共材料,加上一轮全部收尾、上一轮实验员的提醒、可以写进"延续"的因子。 */
export function proposal(d: Draft): ChecklistOf<"proposal"> {
frame(d, [], () => {
const prev = d.round - 1;
const wraps = d.board.published("wrapUp").filter((w) => w.round === prev);
d.section(
"上一轮的收尾",
prev < 1 ? ["(这是第 1 轮,没有上一轮)"] : wraps.flatMap((w) => [`### ${w.author} 的收尾`, "", w.content.body, ""]),
);
d.section("上一轮实验员实现时的提醒", prev < 1 ? ["(这是第 1 轮,没有上一轮)"] : concernLines(d, prev));
previousWrapSection(d);
d.section("上一轮实验员实现时的提醒", d.round < 2 ? ["(这是第 1 轮,没有上一轮)"] : concernLines(d, d.round - 1));
const lines = d.board.protocols().flatMap((p) =>
p.protocol.factors.map((f, i) => `- ${factorId(p.candidate, factorKey(i))} ${f.name}(关键设定:${f.keySettings};候选 ${p.candidate},基线 ${p.protocol.baseline}${f.continues === null ? "" : `;它自己延续 ${f.continues}`})`),
);
@@ -258,11 +288,7 @@ export function explanation(d: Draft): ChecklistOf<"explanation"> {
"",
...factorLines(p),
]);
const artifacts = d.req.artifacts!;
const listed = Object.keys(artifacts)
.sort(compareIds)
.flatMap((run) => Object.entries(artifacts[run]!).map(([file, text]) => `- ${d.record(`${run}/${file}`, `运行 ${run} 的产物 ${file}`, text)}`));
d.section("本轮运行的产物", ["用 research_query 按编号查全文:", ...(listed.length === 0 ? ["(没有)"] : listed)]);
artifactSection(d, "本轮运行的产物");
});
return { kind: "explanation", cells: p.factors.map((_, i) => factorKey(i)), maxExplanations: d.req.study.proposalLimit };
}
+41 -2
View File
@@ -5,13 +5,17 @@ import type {
BetResult,
CellDecision,
CellRuleInput,
DiagnosisDecision,
DiagnosisRuleInput,
EntryAssignment,
SettleDiagnosisInput,
SettleInput,
adopt,
settleAblation,
settleDiagnosis,
} from "../rules/index.ts";
import type { Content } from "../ledger/index.ts";
import type { Json, Role, Submission, SubmissionKind } from "../vocab/index.ts";
import type { Json, Measured, Role, Submission, SubmissionKind, Verdict } from "../vocab/index.ts";
// 账本里只存成 Json 的几样内容长什么样、编号怎么起(用户 2026-10-03 决定 2):materials 读它们,flow 照着写。
// 决定和结算的输出直接用 rules 的返回类型;ledger 不认识这些类型,只存 Json。
@@ -51,6 +55,12 @@ export function splitCell(cell: string): { candidate: string; key: string } {
}
/** 解释:第 round 轮作者交的第 i 条(从 1 数)。结算里判确认、否定用它。 */
export const explanationId = (round: number, author: string, i: number): string => `r${round}-${author}-x${i}`;
/** 本轮诊断:诊断程序的冻结代码、诊断运行、诊断实现与核验的对象都是它。 */
export const diagnosisId = (round: number): string => `r${round}-diagnosis`;
/** 诊断实验在本轮合并后清单里的编号:第 i 个(从 0 数)是 d(i+1);它登记的指标全名是"实验编号.短名"。 */
export const diagnosisKey = (i: number): string => `d${i + 1}`;
/** 诊断解释:第 round 轮作者交的第 i 条(从 1 数)。 */
export const diagnosisExplanationId = (round: number, author: string, i: number): string => `r${round}-${author}-dx${i}`;
/** 模型调用:同一个任务(阶段、谁、哪种提交、对象)第 attempt 次发起。 */
export const callId = (stageId: string, actor: string, kind: string, object: string | null, attempt: number): string =>
`${stageId}.${actor}.${kind}.${object ?? "none"}.${attempt}`;
@@ -104,11 +114,19 @@ export function freezeProtocol(body: ProtocolBody, candidate: string, round: num
/** 候选代码的位置表,就是实验员交的那三项;基线和消融格的代码记 null(消融格用完整代码的位置表)。 */
export type LocationTable = Pick<Submission<"implementation">, "factorLocations" | "safeguardLocations" | "unassigned">;
/** 诊断程序的位置表:每个选中的实验一项(诊断实现交的 locations)。 */
export type DiagnosisLocationTable = Submission<"diagnosisImplementation">["locations"];
// ---------------------------------------------------------------------------
// 决定(账本记录 decision:decision 是种类,input、output 如下)
// ---------------------------------------------------------------------------
/**
* 诊断实验判重合并的输出(方法.md 4.3 诊 2):合并后的每个实验由哪几个作者的哪几个实验(index 是它在作者那份诊断提案里的位置,从 0 数)合成;
* sources 按作者编号排,第一个就是"作者里编号最小的那一份",合并后的正文取它的。
*/
export type DiagnosisDedupOutput = { experiments: Array<{ experiment: string; sources: Array<{ author: string; index: number }> }> };
/** 判重合并的输出(方法.md 4.3 ③):本轮新候选各合并了哪几条提案;留池候选各并进了哪几条新提案(可以没有)。 */
export type DedupOutput = {
fresh: Array<{ candidate: string; proposals: string[] }>;
@@ -127,9 +145,11 @@ export type DecisionShapes = {
adopt: { input: Parameters<typeof adopt>[0]; output: ReturnType<typeof adopt> };
cells: { input: CellRuleInput; output: CellDecision };
bet: { input: AblationModelInput & { cells: SettleInput["cells"] }; output: BetResult };
diagnosisDedup: { output: DiagnosisDedupOutput };
selectDiagnosis: { input: DiagnosisRuleInput; output: DiagnosisDecision };
};
export type DecisionKind = keyof DecisionShapes;
export const DECISION_KINDS: readonly DecisionKind[] = ["entries", "dedup", "batch", "adopt", "cells", "bet"];
export const DECISION_KINDS: readonly DecisionKind[] = ["entries", "dedup", "batch", "adopt", "cells", "bet", "diagnosisDedup", "selectDiagnosis"];
// ---------------------------------------------------------------------------
// 结算(账本记录 settlement.result,方法.md 4.3 ⑰)
@@ -142,6 +162,25 @@ export type Settlement = {
output: ReturnType<typeof settleAblation>;
};
/**
* 一次诊断的结算(账本记录 diagnosisSettlement.result,方法.md 6.12)。judged 是每个跑过的实验单独判分的结果,与 tasks 的
* DiagnosisJudged 同形(materials 不引用 tasks);诊断运行记录的 judged 为 null,逐个实验的结果只在这里。
* dropped 是核验不通过、修复用完而没跑的实验和原因;input、output 是 settleDiagnosis 的输入输出,没做的实验在里面记 untested。
*/
export type DiagnosisSettlement = {
run: string;
judged: {
experiments: Array<{ id: string; ending: "finished" | "crashed" | "timeout" | "notRun"; wallSeconds: Measured; verdict: Verdict; verdictReason: string; score: Measured; metrics: Record<string, Measured>; extra: Json }>;
};
dropped: Array<{ experiment: string; reason: string }>;
input: SettleDiagnosisInput;
output: ReturnType<typeof settleDiagnosis>;
};
/** 事件(账本记录 event):选中的诊断实验核验都没通过、修复用完,一个都不剩,不跑、不扣次(方法.md 4.3 诊 4)。objects 只有本轮诊断的编号。 */
export const DIAGNOSIS_NOT_RUN = "diagnosisNotRun";
export const diagnosisNotRunEvent = (round: number, reason: string): Content<"event"> => ({ event: DIAGNOSIS_NOT_RUN, reason, objects: [diagnosisId(round)] });
/**
* 事件(账本记录 event):一格消融核验不通过、重做次数用完,这一格不跑、记"证据不足"(方法.md 4.3 ⑯、6.6)。
* 这一格没有运行,信念本从这条事件读原因。objects 只有这一格的编号。
+20 -15
View File
@@ -1,9 +1,17 @@
import type { AblationCell } from "./ablation.ts";
import { compareIds, improvement, NET_TOLERANCE } from "./basics.ts";
import { ablationCellLimit } from "./budget.ts";
import { density } from "./distribution.ts";
import { seededSample } from "./random.ts";
import { ablationModel, evaluateCombos, type AblationModelInput, type ComboParams, type ComboValue } from "./value.ts";
import {
ablationModel,
ablationView,
evaluateCombos,
itemGain,
updatedWeights,
type AblationModelInput,
type ComboParams,
type ComboValue,
} from "./value.ts";
// 方法.md 6.7"选格"、第十一部分的四条选格规则,以及 6.8 押的因子。
@@ -121,10 +129,15 @@ function netOf(combos: readonly ComboValue[], key: string): number {
* "含这一格的净值最大组合"直接按这条挑(只是记录,不要求净值 > 1e-12)。
*/
export function topCombo<C extends { factors: readonly string[]; net: number }>(combos: readonly C[]): C | null {
return topByNet(combos, (c) => c.factors);
}
/** topCombo 的比较规则,组合里的项(因子或实验)怎么取由 items 给;选格和选诊断实验共用(6.7、6.12)。 */
export function topByNet<C extends { net: number }>(combos: readonly C[], items: (c: C) => readonly string[]): C | null {
if (combos.length === 0) return null;
const top = Math.max(...combos.map((c) => c.net));
const near = combos.filter((c) => c.net >= top - NET_TOLERANCE);
return near.reduce((best, c) => (compareCombos(c.factors, best.factors) < 0 ? c : best));
return near.reduce((best, c) => (compareCombos(items(c), items(best)) < 0 ? c : best));
}
/** 格数少的在前;格数相同时按因子编号逐个比。 */
@@ -207,19 +220,11 @@ export function chooseBet(input: AblationModelInput & { cells: readonly Ablation
const o = input.cells.find((c) => c.factor === f)?.outcome;
return o?.kind === "valid" ? o.score : null;
});
const raw = model.weights.map((w, j) =>
scores.reduce<number>((v, x, c) => (x === null ? v : v * density(model.rawKnots[c]![j]!, x)), w),
);
const sum = raw.reduce((t, v) => t + v, 0);
const weightsKept = sum === 0;
const weights = weightsKept ? [...model.weights] : raw.map((v) => v / sum);
const view = ablationView(model);
const { weights, kept: weightsKept } = updatedWeights(view, scores);
const gains = model.factors.map((factor, c) => {
const x = scores[c];
const gain =
x !== null && x !== undefined
? model.space * (improvement(x, input.full, input.direction) / model.gain)
: model.space * model.expected[c]!.reduce((t, e, j) => t + weights[j]! * e, 0);
return { factor, gain, measured: x !== null };
const x = scores[c] ?? null;
return { factor, gain: itemGain(view, c, weights, x), measured: x !== null };
});
let best = 0;
for (let c = 1; c < gains.length; c++) if (gains[c]!.gain > gains[best]!.gain) best = c;
+304
View File
@@ -0,0 +1,304 @@
import type { Direction, Measured, Range } from "../vocab/index.ts";
import { judgeExplanations, type AblationExplanation, type CellOutcome, type ExplanationVerdict } from "./ablation.ts";
import { combinations, compareIds, improvement, NET_TOLERANCE } from "./basics.ts";
import { bitWidth, disagreement, surprise, worldBits } from "./bits.ts";
import { topByNet } from "./cells.ts";
import { ablationCellMixture, knotsMean, mapKnots, mixtureRange } from "./distribution.ts";
import {
itemGain,
OPEN_RESERVE,
runCost,
updatedWeights,
usePotential,
valueOf,
type ComboParams,
type ValueModel,
} from "./value.ts";
// 方法.md 6.12 诊断:成分模型、方向收益、信息价值、价值减成本、选实验、结算。
// 成分、权重更新、精确积分、部分格网格、价值减成本的公式都在 value.ts,这里只给出诊断的"实测 → 收益"换算:
// 收益 g(x) = 改进量 x − b(越小越好的任务按 6.1 换成 b − x),另有收益为 0 的"都不走"。
/** 一条诊断解释:作者,以及对每个合并后的诊断实验给的三个数(实验做完后的分数,原值)。 */
export interface DiagnosisExplanation {
id: string;
author: string;
experiments: ReadonlyArray<{ experiment: string; range: Range }>;
}
export interface DiagnosisModelInput {
/** 本批基线 b。 */
baseline: number;
direction: Direction;
lambda: number;
/** 合并后的全部诊断实验编号(包括没选进组合的)。 */
experiments: readonly string[];
/** 每条解释对每个实验都给了三个数。 */
explanations: readonly DiagnosisExplanation[];
}
export interface DiagnosisModel extends ValueModel {
/** 实验编号,按编号排;ValueModel 里"项的下标"按这个次序。 */
experiments: string[];
/** 成分:各条解释的编号(按编号排),最后是开放预留。 */
components: string[];
}
/**
* 成分模型(6.12"成分与权重"):每条诊断解释一个成分,开放预留一个成分;每个成分对每个实验有一个分布,
* 解释用它自己的三个数补尾巴,开放预留用这个实验自己的均匀分布(6.3"诊断",和消融一格同一个合并函数)。
*/
export function diagnosisModel(input: DiagnosisModelInput): DiagnosisModel {
const experiments = [...input.experiments].sort(compareIds);
const explanations = [...input.explanations].sort((a, b) => compareIds(a.id, b.id));
const mixtures = experiments.map((d) =>
ablationCellMixture(
explanations.map((e) => ({ id: e.id, author: e.author, range: e.experiments.find((x) => x.experiment === d)!.range })),
input.lambda,
),
);
const scale = (x: number): number => improvement(input.baseline, x, input.direction);
const rawKnots = mixtures.map((m) => m.map((c) => c.knots));
const scaledKnots = rawKnots.map((row) => row.map((k) => mapKnots(k, scale, input.direction === "minimize")));
const weights = mixtures[0]!.map((c) => c.weight);
const expected = scaledKnots.map((row) => row.map(knotsMean));
return {
experiments,
components: [...explanations.map((e) => e.id), OPEN_RESERVE],
weights,
rawKnots,
scaledKnots,
expected,
prior: expected.map((row) => row.reduce((s, e, j) => s + weights[j]! * e, 0)),
space: 1,
floor: 0,
scale,
};
}
/** 一个诊断实验组合的价值与成本(6.12"价值减成本")。 */
export interface DiagnosisCombo {
/** 组合里的实验,按编号排。 */
experiments: string[];
voi: number;
/** R̂ = ⌈(余额 − 1) ÷ 每批上限⌉。 */
rHat: number;
mPrime: number;
/** 价值 = u × m′ × VOI。 */
value: number;
/** 成本:诊断只占一次运行,任何组合都一样。 */
cost: number;
net: number;
}
export interface DiagnosisParams extends ComboParams {
/** 一次诊断最多做几个实验 D(6.11)。 */
maxExperiments: number;
}
/**
* 列出可选组合并算价值、成本、净值(6.12"选实验"第 1、2 条):余额不到 2 次一个都没有;实验数不超过 D 时只有"全做",
* 超过 D 时是所有恰好 D 个实验的组合,按实验编号字典序列出。ĝ₀ = max(0, max_d E(g_d))。
*/
export function evaluateDiagnosisCombos(
model: DiagnosisModel,
params: DiagnosisParams,
): { g0: number; combos: DiagnosisCombo[] } {
const g0 = Math.max(0, ...model.prior);
if (params.balance < 2) return { g0, combos: [] };
const rHat = Math.ceil((params.balance - 1) / params.batchCap);
const mPrime = usePotential(rHat, params.decay);
const cost = runCost(g0, params.batchCap, 1);
const size = Math.min(model.experiments.length, params.maxExperiments);
const combos = combinations(model.experiments.length, size).map((S) => {
const voi = valueOf(model, S);
const value = params.u * mPrime * voi;
return { experiments: S.map((d) => model.experiments[d]!), voi, rHat, mPrime, value, cost, net: value - cost };
});
return { g0, combos };
}
export interface DiagnosisRuleInput extends DiagnosisModelInput, DiagnosisParams {}
/**
* 没选中的实验为什么没做(6.12"选实验"第 4 条):budget 放不下(已选的组合装满了 D 个);
* value 信息价值不足以抵过成本。
*/
export type SkippedReason = "budget" | "value";
/** 选诊断实验的结果,含 6.12 要求附上的全部数字;规则名和版本由登记表补上。 */
export interface DiagnosisDecision {
params: { balance: number; batchCap: number; maxExperiments: number; u: number; decay: number; lambda: number };
components: string[];
/** 起始权重,与 components 对齐。 */
weights: number[];
/** 各实验的事前期望收益 E(g_d),按实验编号排。 */
priorGains: Array<{ experiment: string; gain: number }>;
/** ĝ₀ = max(0, max_d E(g_d))。 */
g0: number;
/** 可选的全部组合及其价值、成本、净值(余额不到 2 次时为空)。 */
combos: DiagnosisCombo[];
/** 本轮做不做诊断:选中了净值 > 1e-12 的组合才做。 */
diagnose: boolean;
/** 选中要做的实验,按编号排;不诊断时为空。 */
chosen: string[];
/** 选中组合的净值;不诊断时是 0。 */
chosenNet: number;
/**
* 每个合并后的实验:做不做;没做的写原因,并附"含这个实验的组合里净值最大的那个";
* 没有任何含它的可选组合(余额不到 2 次,或没被任何组合包含)时 bestCombo 为 null。
*/
experiments: Array<{
experiment: string;
done: boolean;
reason: SkippedReason | null;
bestCombo: { experiments: string[]; net: number } | null;
}>;
}
/**
* 选诊断实验,规则"信息价值减成本"(6.12"选实验"):取净值最大、且净值 > 1e-12 的组合,净值相差不到 1e-12 的
* 视为相同、按实验编号从小到大比(和选格同一条挑法,见 topByNet);都不大于 1e-12 时本轮不诊断。
*/
export function selectDiagnosis(input: DiagnosisRuleInput): DiagnosisDecision {
const model = diagnosisModel(input);
const { g0, combos } = evaluateDiagnosisCombos(model, input);
const best = topByNet(combos.filter((c) => c.net > NET_TOLERANCE), (c) => c.experiments);
const chosen = best === null ? [] : [...best.experiments];
return {
params: {
balance: input.balance,
batchCap: input.batchCap,
maxExperiments: input.maxExperiments,
u: input.u,
decay: input.decay,
lambda: input.lambda,
},
components: model.components,
weights: model.weights,
priorGains: model.experiments.map((experiment, d) => ({ experiment, gain: model.prior[d]! })),
g0,
combos,
diagnose: best !== null,
chosen,
chosenNet: best === null ? 0 : best.net,
experiments: model.experiments.map((experiment) => {
const done = chosen.includes(experiment);
const top = topByNet(combos.filter((c) => c.experiments.includes(experiment)), (c) => c.experiments);
return {
experiment,
done,
reason: done ? null : chosen.length > 0 ? "budget" : "value",
bestCombo: done || top === null ? null : { experiments: [...top.experiments], net: top.net },
};
}),
};
}
/**
* 一个实验的结局(6.12"结算"):valid 有效分数;insufficient 证据不足(跑崩、超时、没有产物、修不了、核验不通过);
* untested 没做。和消融一格的三种同形,直接用 ablation.ts 的类型。
*/
export type ExperimentOutcome = Extract<CellOutcome, { kind: "valid" | "insufficient" | "untested" }>;
export interface SettleDiagnosisInput {
/** 本批基线 b。 */
baseline: number;
direction: Direction;
epsilon: number;
lambda: number;
/** 合并后的全部诊断实验及结局,没做的写 untested。 */
experiments: ReadonlyArray<{ experiment: string; outcome: ExperimentOutcome }>;
explanations: readonly DiagnosisExplanation[];
}
/** 诊断推出的方向(6.12"结算")。 */
export interface DiagnosisDirection {
/** 更新后的成分权重(与模型的成分对齐);无从更新时等于起始权重。 */
weights: number[];
/** 实测落在所有成分的范围之外、权重之和为 0 时为 true,此时保持起始权重。 */
weightsKept: boolean;
/** 各实验的收益(按编号排):measured 为 true 的是有效实验实测换算的,其余是按更新后的权重估的。 */
gains: Array<{ experiment: string; gain: number; measured: boolean }>;
/** 收益最大的实验(严格更大才替换,平局取编号小的)及其收益。 */
experiment: string;
gain: number;
/** 最大收益为正才算指出了方向;否则记"诊断没有指出能涨分的方向"。 */
found: boolean;
}
/**
* 一次诊断的结算(6.12"结算"、6.9):每个实验的收益、合并范围,有效实验的世界比特、惊讶、分歧量
* (格宽 ε/100,从 b 起切);每条诊断解释在每个有效实验上的确认或否定(和消融同一个判法);诊断推出的方向。
* 证据不足和没做的实验不判、不记账。
*/
export function settleDiagnosis(input: SettleDiagnosisInput): {
experiments: Array<{
experiment: string;
outcome: ExperimentOutcome["kind"];
gain: number | null;
merged: Range;
worldBits: number | null;
surprise: { cell: number; prior: number; bits: Measured } | null;
disagreement: number | null;
}>;
judgments: Array<{ id: string; verdicts: Array<{ experiment: string; verdict: ExplanationVerdict }> }>;
direction: DiagnosisDirection;
} {
const width = bitWidth(input.epsilon);
const experiments = [...input.experiments].sort((a, b) => compareIds(a.experiment, b.experiment));
const rows = experiments.map(({ experiment, outcome }) => {
const entries = input.explanations.map((e) => ({
id: e.id,
author: e.author,
range: e.experiments.find((x) => x.experiment === experiment)!.range,
}));
const mixture = ablationCellMixture(entries, input.lambda);
const valid = outcome.kind === "valid";
return {
experiment,
outcome: outcome.kind,
gain: valid ? improvement(input.baseline, outcome.score, input.direction) : null,
merged: mixtureRange(mixture),
worldBits: valid ? worldBits(mixture, input.baseline, width) : null,
surprise: valid ? surprise(mixture, input.baseline, width, outcome.score) : null,
disagreement: valid ? disagreement(entries, input.baseline, width) : null,
};
});
// 确认与否定沿用消融的判法:把"实验"当成"格"交给 judgeExplanations,结果再换回实验的叫法。
const asCells: AblationExplanation[] = input.explanations.map((e) => ({
id: e.id,
author: e.author,
cells: e.experiments.map((x) => ({ factor: x.experiment, range: x.range })),
}));
const judgments = judgeExplanations(asCells, experiments.map((e) => ({ factor: e.experiment, outcome: e.outcome }))).map(
(j) => ({ id: j.id, verdicts: j.verdicts.map((v) => ({ experiment: v.factor, verdict: v.verdict })) }),
);
return {
experiments: rows,
judgments,
direction: diagnosisDirection(
diagnosisModel({ ...input, experiments: experiments.map((e) => e.experiment) }),
experiments.map((e) => (e.outcome.kind === "valid" ? e.outcome.score : null)),
),
};
}
/** 诊断推出的方向:按有效实验的实测更新成分权重,有效实验用实测换算收益,其余实验用新权重估,取收益最大的(6.12"结算")。 */
function diagnosisDirection(model: DiagnosisModel, scores: ReadonlyArray<number | null>): DiagnosisDirection {
const { weights, kept } = updatedWeights(model, scores);
const gains = model.experiments.map((experiment, d) => {
const x = scores[d] ?? null;
return { experiment, gain: itemGain(model, d, weights, x), measured: x !== null };
});
let best = 0;
for (let d = 1; d < gains.length; d++) if (gains[d]!.gain > gains[best]!.gain) best = d;
return {
weights,
weightsKept: kept,
gains,
experiment: gains[best]!.experiment,
gain: gains[best]!.gain,
found: gains[best]!.gain > 0,
};
}
+20
View File
@@ -103,6 +103,23 @@ export {
worldBits,
type Placement,
} from "./bits.ts";
export {
diagnosisModel,
evaluateDiagnosisCombos,
selectDiagnosis,
settleDiagnosis,
type DiagnosisCombo,
type DiagnosisDecision,
type DiagnosisDirection,
type DiagnosisExplanation,
type DiagnosisModel,
type DiagnosisModelInput,
type DiagnosisParams,
type DiagnosisRuleInput,
type ExperimentOutcome,
type SettleDiagnosisInput,
type SkippedReason,
} from "./diagnosis.ts";
export { ablationCellLimit, forwardBatchSize, runBalance, type RunState } from "./budget.ts";
export { authorVsMerged, trackRecord, type Phase, type Tally } from "./record.ts";
export { assignEntries, type EntryAssignment } from "./entries.ts";
@@ -110,8 +127,11 @@ export { decisionSeed, seededRandom, seededSample } from "./random.ts";
export {
BATCH_RULES,
CELL_RULES,
DIAGNOSIS_RULES,
runBatchRule,
runCellRule,
runDiagnosisRule,
type BatchRuleName,
type CellRuleName,
type DiagnosisRuleName,
} from "./registry.ts";
+7 -5
View File
@@ -2,10 +2,12 @@ import type { Direction, Range } from "../vocab/index.ts";
import { compareIds, improvement } from "./basics.ts";
import { covers, placement, type Placement } from "./bits.ts";
// 方法.md 6.10 第 4 条"战绩"、6.9"按人分开、按正向与消融分开"。只用来显示,不改任何权重。
// 方法.md 6.10 第 4 条"战绩"、6.9"按人分开、按正向、诊断与消融分开"。只用来显示,不改任何权重。
// 一律按分数原值比,不按方向折算。
export type Phase = "forward" | "ablation";
export type Phase = "forward" | "diagnosis" | "ablation";
const PHASE_ORDER: readonly Phase[] = ["forward", "diagnosis", "ablation"];
/** 一个人的战绩。 */
export interface Tally {
@@ -25,8 +27,8 @@ export interface Tally {
}
/**
* 战绩:按研究者、按正向与消融分开数(6.9、6.10)。samples 是每人每次的范围(第二次预测或解释的每格)和有效实测。
* 结果按研究者编号、再按 forward、ablation 排,只列有样本的组合。
* 战绩:按研究者、按正向、诊断与消融分开数(6.9、6.10)。samples 是每人每次的范围(第二次预测、诊断预测或解释的每格)和有效实测。
* 结果按研究者编号、再按 forward、diagnosis、ablation 排,只列有样本的组合。
*/
export function trackRecord(
samples: ReadonlyArray<{ researcher: string; phase: Phase; range: Range; value: number }>,
@@ -62,7 +64,7 @@ export function trackRecord(
t.placements[placement(r, v)] += 1;
}
return [...groups.values()].sort(
(a, b) => compareIds(a.researcher, b.researcher) || (a.phase === b.phase ? 0 : a.phase === "forward" ? -1 : 1),
(a, b) => compareIds(a.researcher, b.researcher) || PHASE_ORDER.indexOf(a.phase) - PHASE_ORDER.indexOf(b.phase),
);
}
+16
View File
@@ -1,4 +1,5 @@
import { selectAll, selectByValue, selectNone, selectRandomSameCount, type CellDecision, type CellRuleInput } from "./cells.ts";
import { selectDiagnosis, type DiagnosisDecision, type DiagnosisRuleInput } from "./diagnosis.ts";
import { selectBestExpected, selectUniformRandom, type BatchDecision, type BatchInput } from "./forward.ts";
// 规则登记表(结构.md 第五部分"决策规则"、方法.md 第十一部分):名字 → 函数,带版本号。
@@ -22,8 +23,14 @@ export const CELL_RULES = {
none: { version: 1, run: selectNone },
} as const satisfies Record<string, { version: number; run: (input: CellRuleInput) => CellDecision }>;
export const DIAGNOSIS_RULES = {
/** 信息价值减成本(6.12);选诊断实验只有这一条规则,配置里不选。 */
valueMinusCost: { version: 1, run: selectDiagnosis },
} as const satisfies Record<string, { version: number; run: (input: DiagnosisRuleInput) => DiagnosisDecision }>;
export type BatchRuleName = keyof typeof BATCH_RULES;
export type CellRuleName = keyof typeof CELL_RULES;
export type DiagnosisRuleName = keyof typeof DIAGNOSIS_RULES;
/** 按名字跑一条选批规则,结果带上规则名和版本。 */
export function runBatchRule(
@@ -42,3 +49,12 @@ export function runCellRule(
const entry = CELL_RULES[name];
return { rule: { name, version: entry.version }, ...entry.run(input) };
}
/** 按名字跑一条选诊断实验规则,结果带上规则名和版本。 */
export function runDiagnosisRule(
name: DiagnosisRuleName,
input: DiagnosisRuleInput,
): DiagnosisDecision & { rule: { name: DiagnosisRuleName; version: number } } {
const entry = DIAGNOSIS_RULES[name];
return { rule: { name, version: entry.version }, ...entry.run(input) };
}
+139 -31
View File
@@ -9,13 +9,44 @@ import {
GRID_SUBDIVISIONS,
improvement,
} from "./basics.ts";
import { ablationCellMixture, below, integrateUpperTail, knotsMean, mapKnots, type Knots } from "./distribution.ts";
import {
ablationCellMixture,
below,
density,
integrateUpperTail,
knotsMean,
mapKnots,
type Knots,
} from "./distribution.ts";
// 方法.md 6.7:方向收益、成分与权重、信息价值、价值减成本。选格规则和押的因子在 cells.ts。
//
// 一律先在"撑几成"刻度 s(x) = [f(完整) − x] ÷ G(按方向折算)上算,最后乘剩余空间 h:
// 收益_p = h × s_p,h 对所有因子相同且为正,所以信息价值、期望收益、ĝ₀ 都等于 h × (撑几成刻度上的同一个量)。
// 例外是部分格网格的切段:在分数原值上取折点、切小段,再把中点换成撑几成(见 measuredGrid)。
// 成分、权重更新、精确积分、部分格网格、价值减成本这套引擎消融(6.7)和诊断(6.12)共用,只差"实测 → 收益"的换算:
// - 消融:一律先在"撑几成"刻度 s(x) = [f(完整) − x] ÷ G(按方向折算)上算,最后乘剩余空间 h:
// 收益_p = h × s_p,h 对所有因子相同且为正,所以信息价值、期望收益、ĝ₀ 都等于 h × (撑几成刻度上的同一个量);
// 不设"都不走"的选项。
// - 诊断(diagnosis.ts):收益 g(x) = 改进量 x − b,h 取 1,另有收益为 0 的"都不走",即 floor = 0。
// 例外是部分格网格的切段:在分数原值上取折点、切小段,再把中点换成收益刻度(见 gridOf)。
/** 引擎要的成分模型(消融和诊断各自建出来)。下标"项"是因子(消融)或实验(诊断)。 */
export interface ValueModel {
/** 成分起始权重。 */
weights: number[];
/** rawKnots[项][成分]:分数原值上的折线。 */
rawKnots: Knots[][];
/** scaledKnots[项][成分]:收益刻度上的折线。 */
scaledKnots: Knots[][];
/** expected[项][成分]:这个成分下这一项在收益刻度上的期望。 */
expected: number[][];
/** prior[项]:按起始权重平均的期望。 */
prior: number[];
/** 收益 = space × 收益刻度上的值:消融是剩余空间 h,诊断是 1。 */
space: number;
/** "都不走"的收益:诊断是 0,消融没有这个选项,写 null。 */
floor: number | null;
/** 分数原值 → 收益刻度。 */
scale: (x: number) => number;
}
/** 开放预留成分的名字(成分名单里排在最后)。 */
export const OPEN_RESERVE = "openReserve";
@@ -70,6 +101,20 @@ function supportScale(full: number, direction: Direction, gain: number): (x: num
return (x) => improvement(x, full, direction) / gain;
}
/** 消融模型在引擎眼里的样子:收益刻度是撑几成,没有"都不走"。 */
export function ablationView(model: AblationModel): ValueModel {
return {
weights: model.weights,
rawKnots: model.rawKnots,
scaledKnots: model.supportKnots,
expected: model.expected,
prior: model.prior,
space: model.space,
floor: null,
scale: supportScale(model.full, model.direction, model.gain),
};
}
/**
* 成分模型(6.7"成分与权重"):每条解释一个成分,开放预留一个成分;每个成分对每一格有一个分布,
* 解释用它自己的三个数补尾巴,开放预留用这一格的均匀分布(6.3"消融")。同一成分内各格互相独立。
@@ -144,26 +189,33 @@ export function cellGrid(knots: readonly Knots[], k: number): { mids: number[];
}
/**
* 模型里第 c 格的网格(6.7"只测一部分格"),测 k 格时用。在分数原值上取折点、切小段:方法说"每格在自己的开放预留范围内
* 模型里第 c 项的网格(6.7"只测一部分格"),测 k 项时用。在分数原值上取折点、切小段:方法说"每格在自己的开放预留范围内
* 按折点切段",开放预留和折点都是在分数原值上定的(尾巴按 q1 − (q5 − q1)、q9 + (q9 − q5),预留按 L − W/2、U + W/2
* 的浮点原式),去重只合并浮点上完全相等的值。先换到撑几成刻度再去重,相差一个末位的两个原值折点可能换算后变成同一个数,
* 段数和 s 就会跟原值上的不一样。切好后只把小段中点换算成撑几成;把握与刻度无关。
* 的浮点原式),去重只合并浮点上完全相等的值。先换到收益刻度再去重,相差一个末位的两个原值折点可能换算后变成同一个数,
* 段数和 s 就会跟原值上的不一样。切好后只把小段中点换算成收益刻度;把握与刻度无关。
*/
export function measuredGrid(model: AblationModel, c: number, k: number): { mids: number[]; mass: number[][] } {
export function gridOf(model: ValueModel, c: number, k: number): { mids: number[]; mass: number[][] } {
const { mids, mass } = cellGrid(model.rawKnots[c]!, k);
return { mids: mids.map(supportScale(model.full, model.direction, model.gain)), mass };
return { mids: mids.map(model.scale), mass };
}
/** 消融模型里第 c 格的网格,中点换算成撑几成(6.7"只测一部分格")。 */
export function measuredGrid(model: AblationModel, c: number, k: number): { mids: number[]; mass: number[][] } {
return gridOf(ablationView(model), c, k);
}
/**
* 只测 S 里的格时的 E_{S 的结果}[max_p E(撑_p | S 的结果)](6.7"信息价值"),在撑几成刻度上。
* 对测到的格的全部小段组合求和:成分 j 的联合把握 = 起始权重 × 各测到格在所选小段的把握;它们的和 marg 是这个组合的把握,
* 按它归一就是更新后的权重。网格见 measuredGrid。测到的格取小段中点(已换成撑几成),没测的格用更新后的权重平均各成分的期望,取各因子里最大的。
* 只测 S 里的项时的 E_{S 的结果}[max(floor, max_p E(收益_p | S 的结果))](6.7"信息价值"、6.12),在收益刻度上,
* 没有"都不走"(floor 为 null)时不和 floor 取大。
* 对测到的项的全部小段组合求和:成分 j 的联合把握 = 起始权重 × 各测到项在所选小段的把握;它们的和 marg 是这个组合的把握,
* 按它归一就是更新后的权重。网格见 gridOf。测到的项取小段中点(已换成收益刻度),没测的项用更新后的权重平均各成分的期望,取各项里最大的。
*/
function expectedBestGrid(model: AblationModel, S: readonly number[]): number {
function expectedBestGrid(model: ValueModel, S: readonly number[]): number {
const k = S.length;
const J = model.weights.length;
const grids = S.map((c) => measuredGrid(model, c, k));
const rest = model.factors.map((_, c) => c).filter((c) => !S.includes(c));
const floor = model.floor ?? -Infinity;
const grids = S.map((c) => gridOf(model, c, k));
const rest = model.prior.map((_, c) => c).filter((c) => !S.includes(c));
const partial = Array.from({ length: k + 1 }, () => new Float64Array(J));
for (let j = 0; j < J; j++) partial[0]![j] = model.weights[j]!;
let total = 0;
@@ -173,7 +225,7 @@ function expectedBestGrid(model: AblationModel, S: readonly number[]): number {
let marg = 0;
for (let j = 0; j < J; j++) marg += cur[j]!;
if (marg === 0) return;
let best = seen;
let best = Math.max(seen, floor);
for (const c of rest) {
const e = model.expected[c]!;
let num = 0;
@@ -195,36 +247,80 @@ function expectedBestGrid(model: AblationModel, S: readonly number[]): number {
}
/**
* 全部格都测时的 E[max_p 撑_p](6.7"信息价值"):同一成分内"各格撑几成的最大值低于 t"的把握 = 各格把握连乘,
* 期望 = 下端 + ∫(1 − 连乘) dt,逐段多项式精确积分;再按起始权重相加。
* 全部项都测时的 E[max(floor, max_p 收益_p)](6.7"信息价值"、6.12):同一成分内"各项最大值低于 t"的把握 = 各项把握连乘,
* 期望 = 下端 + ∫(1 − 连乘) dt,逐段多项式精确积分;再按起始权重相加。下端:有"都不走"时是 floor(t 低于它时
* max(floor, ·) 一定不低于 t,被积函数是 1),没有时是各项折线的最低点。
*/
function expectedBestAll(model: AblationModel): number {
function expectedBestAll(model: ValueModel): number {
let v = 0;
for (let j = 0; j < model.weights.length; j++) {
const lines = model.supportKnots.map((cell) => cell[j]!);
const lo = Math.min(...lines.map((k) => k[0]![0]));
const lines = model.scaledKnots.map((cell) => cell[j]!);
const lo = model.floor ?? Math.min(...lines.map((k) => k[0]![0]));
v += model.weights[j]! * (lo + integrateUpperTail(lines.map((knots) => [{ weight: 1, knots }]), lo));
}
return v;
}
/** max(floor, max_p E(收益_p)):不测时能拿到的最好期望,在收益刻度上。 */
function bestPrior(model: ValueModel): number {
return Math.max(model.floor ?? -Infinity, ...model.prior);
}
/** 用网格算的信息价值(不论测几项都走网格),S 是项的下标。 */
export function valueByGrid(model: ValueModel, S: readonly number[]): number {
return model.space * (expectedBestGrid(model, [...S].sort((a, b) => a - b)) - bestPrior(model));
}
/** 全部项都测时的信息价值(一维精确积分)。 */
export function valueExact(model: ValueModel): number {
return model.space * (expectedBestAll(model) - bestPrior(model));
}
/**
* 信息价值 VOI(S) = E_{S 的结果}[max(floor, max_p E(收益_p | S 的结果))] − max(floor, max_p E(收益_p))(6.7、6.12),
* S 是项的下标;全部项都测走一维精确积分,只测一部分走网格,空集是 0。不截断。
*/
export function valueOf(model: ValueModel, S: readonly number[]): number {
if (S.length === 0) return 0;
return S.length === model.prior.length ? valueExact(model) : valueByGrid(model, S);
}
/** 网格算法算的信息价值,S 是格的下标(不论测几格都走网格;用来和精确算法互相对照)。 */
export function informationValueGrid(model: AblationModel, S: readonly number[]): number {
return model.space * (expectedBestGrid(model, [...S].sort((a, b) => a - b)) - Math.max(...model.prior));
return valueByGrid(ablationView(model), S);
}
/** 全部格都测时的信息价值(一维精确积分)。 */
export function informationValueExact(model: AblationModel): number {
return model.space * (expectedBestAll(model) - Math.max(...model.prior));
return valueExact(ablationView(model));
}
/** 消融的信息价值 VOI(S)(6.7),S 是格的下标。 */
export function informationValue(model: AblationModel, S: readonly number[]): number {
return valueOf(ablationView(model), S);
}
/**
* 信息价值 VOI(S) = E_{S 的结果}[max_p E(收益_p | S 的结果)] − max_p E(收益_p)(6.7),S 是格的下标;
* 全部格都测走一维精确积分,只测一部分走网格,空集是 0。不截断。
* 成分新权重(6.8、6.12):起始权重 × 各有效项(scores 里不是 null 的)上这个成分在实测处的密度(分数原值上,连乘),
* 再归一;实测落在所有成分之外、权重之和为 0 时保持起始权重。scores 与项的下标对齐,没有有效实测的项为 null。
*/
export function informationValue(model: AblationModel, S: readonly number[]): number {
if (S.length === 0) return 0;
return S.length === model.factors.length ? informationValueExact(model) : informationValueGrid(model, S);
export function updatedWeights(
model: ValueModel,
scores: ReadonlyArray<number | null>,
): { weights: number[]; kept: boolean } {
const raw = model.weights.map((w, j) =>
scores.reduce<number>((v, x, c) => (x === null ? v : v * density(model.rawKnots[c]![j]!, x)), w),
);
const sum = raw.reduce((t, v) => t + v, 0);
const kept = sum === 0;
return { weights: kept ? [...model.weights] : raw.map((v) => v / sum), kept };
}
/** 第 c 项的方向收益(6.8、6.12):有有效实测 x 就用实测换算,否则用 weights 对各成分的期望加权平均。 */
export function itemGain(model: ValueModel, c: number, weights: readonly number[], x: number | null): number {
return x !== null
? model.space * model.scale(x)
: model.space * model.expected[c]!.reduce((t, e, j) => t + weights[j]! * e, 0);
}
/** 一个测格组合的价值与成本(6.7"价值减成本")。 */
@@ -253,6 +349,19 @@ export interface ComboParams {
decay: number;
}
/** m′ = 1 + r + … + r^(R̂−1),R̂ = 0 时是 0(6.7"价值减成本");消融和诊断共用。 */
export function usePotential(rHat: number, decay: number): number {
let mPrime = 0;
for (let j = 0; j < rHat; j++) mPrime += decay ** j;
return mPrime;
}
/** 成本 = 2 max(0, ĝ₀) × [(K + n)/(K + n + 1) − K/(K + 1)],n 是这次占用的运行次数(6.7、6.12);消融和诊断共用。 */
export function runCost(g0: number, batchCap: number, runs: number): number {
const K = batchCap;
return 2 * Math.max(0, g0) * ((K + runs) / (K + runs + 1) - K / (K + 1));
}
/**
* 列出格数 1..min(格数, 余额 − 1) 的全部非空组合并算价值、成本、净值(6.7"选格"第 1 条)。余额为 0 或 1 时一个都没有。
* 组合按格数从少到多、同格数按因子编号字典序列出。ĝ₀ = h × max_p 期望撑几成,原值另给,成本里取 max(0, ĝ₀)。
@@ -265,10 +374,9 @@ export function evaluateCombos(model: AblationModel, params: ComboParams): { g0:
for (const S of combinations(model.factors.length, size)) {
const voi = informationValue(model, S);
const rHat = Math.ceil((params.balance - size) / K);
let mPrime = 0;
for (let j = 0; j < rHat; j++) mPrime += params.decay ** j;
const mPrime = usePotential(rHat, params.decay);
const value = params.u * mPrime * voi;
const cost = 2 * Math.max(0, g0) * ((K + size) / (K + size + 1) - K / (K + 1));
const cost = runCost(g0, K, size);
combos.push({ factors: S.map((c) => model.factors[c]!), voi, rHat, mPrime, value, cost, net: value - cost });
}
}
+48
View File
@@ -23,6 +23,15 @@ export function programProblems(inc: Incoming, protocol: ProtocolBody | null): s
...factorProblems(inc.content, inc.checklist),
...increasing("authorEstimate", inc.content.authorEstimate),
];
case "diagnosisProposal":
return diagnosisProposalProblems(inc.content, inc.checklist);
case "diagnosisPrediction":
return diagnosisPredictionProblems(inc.content, inc.checklist);
case "diagnosisImplementation":
case "diagnosisRepair":
return codeProblems(inc.known.code);
case "diagnosisVerification":
return covers("experiments 的实验", inc.content.experiments.map((x) => x.experiment), inc.checklist.experiments);
case "firstPrediction":
case "secondPrediction":
return increasing("range", inc.content.range);
@@ -63,6 +72,45 @@ function claimProblems(c: Submission<"claim">, k: ChecklistOf<"claim">): string[
];
}
/**
* 诊断解释与诊断实验(方法.md 4.3 诊 1、第五部分"指标"):解释 1 至 M 条、实验 1 至 A 个(下限由字段定义管);
* 诊断实验登记的指标只能选"缺了只记缺失";同一个实验里指标短名不重名、不占用已有编号
* (不同实验的同名短名冻结时各带自己实验的前缀,不会撞)。
*/
function diagnosisProposalProblems(c: Submission<"diagnosisProposal">, k: ChecklistOf<"diagnosisProposal">): string[] {
return [
...(c.explanations.length > k.maxExplanations ? [`交了 ${c.explanations.length} 条诊断解释,最多 ${k.maxExplanations} 条`] : []),
...(c.experiments.length > k.maxExperiments ? [`交了 ${c.experiments.length} 个诊断实验,最多 ${k.maxExperiments} 个`] : []),
...c.experiments.flatMap((e, i) => {
const ids = e.metrics.map((m) => m.id);
return [
...e.metrics
.filter((m) => m.missing !== "recordMissing")
.map((m) => `第 ${i + 1} 个实验的指标 ${m.id} 选了 ${m.missing}:诊断实验里的指标只能选 recordMissing(缺了只记缺失)`),
...repeated(ids).map((x) => `第 ${i + 1} 个实验的 metrics 里编号 ${x} 写了不止一次`),
...unique(ids)
.filter((x) => k.takenMetricIds.includes(x))
.map((x) => `第 ${i + 1} 个实验的指标编号 ${x} 已被占用(已占用的是 ${k.takenMetricIds.join("、")}):换一个编号`),
];
}),
];
}
/**
* 诊断预测(方法.md 4.3 诊 2):覆盖自己每条解释 × 每个合并后的实验,不多不少、每一对一次;每个范围三个数严格递增。
* 一对写成"解释编号:实验编号"(编号里没有冒号)。
*/
function diagnosisPredictionProblems(c: Submission<"diagnosisPrediction">, k: ChecklistOf<"diagnosisPrediction">): string[] {
return [
...covers(
"predictions 的(解释:实验)",
c.predictions.map((p) => `${p.explanation}:${p.experiment}`),
k.explanations.flatMap((e) => k.experiments.map((d) => `${e}:${d}`)),
),
...c.predictions.flatMap((p) => increasing(`${p.explanation}:${p.experiment} 的 range`, p.range)),
];
}
/**
* 提案、回应、统一共用(方法.md 第五部分"延续""指标"、4.3 ②⑥):
* 规则 2 因子数上限;3 两个因子不延续同一个、延续的编号看得到;4 保障短名;10 指标的引用与新登记。
+1 -1
View File
@@ -1,6 +1,6 @@
// submissions:一份提交收下还是退回,先程序检查、再问 Jev(docs/结构.md 第二部分);
// 另有冻结协议的拼法,和核验、消融核验收下之后通过没有。不读也不写账本。
export { ablationOutcome, verificationOutcome } from "./outcomes.ts";
export { ablationOutcome, diagnosisOutcome, verificationOutcome } from "./outcomes.ts";
export { protocolBody } from "./protocol.ts";
export { receive } from "./receive.ts";
export {
+17
View File
@@ -34,3 +34,20 @@ export function ablationOutcome(
const wronglyOff = v.factors.filter((f) => !shouldBeOff.has(f.factor) && f.off).map((f) => f.factor);
return { passed: leftOn.length + wronglyOff.length === 0, leftOn, wronglyOff };
}
/**
* 诊断核验通过没有(方法.md 4.3 诊 4):逐个实验判,任何一项写了 false 的实验不通过。
* notFollowed 是没照写法做的实验,touchedElse 是动了写法以外的地方的实验,failing 是两者的并集,按核验里的次序。
* 一个实验不通过不影响别的实验:通过的实验代码不再改动。
*/
export function diagnosisOutcome(v: Submission<"diagnosisVerification">): {
passed: boolean;
failing: string[];
notFollowed: string[];
touchedElse: string[];
} {
const notFollowed = v.experiments.filter((x) => !x.followed).map((x) => x.experiment);
const touchedElse = v.experiments.filter((x) => !x.nothingElse).map((x) => x.experiment);
const failing = v.experiments.filter((x) => !x.followed || !x.nothingElse).map((x) => x.experiment);
return { passed: failing.length === 0, failing, notFollowed, touchedElse };
}
+16 -3
View File
@@ -1,4 +1,4 @@
import { checkHunk, checkProtocol, type CodeHunk } from "../jev/index.ts";
import { checkExperiment, checkHunk, checkProtocol, type CodeHunk } from "../jev/index.ts";
import type { JevJudgment } from "../vocab/index.ts";
import { programProblems } from "./checks.ts";
import { protocolBody } from "./protocol.ts";
@@ -27,7 +27,8 @@ export async function receive(inc: Incoming): Promise<Verdict> {
}
/**
* 问 Jev(方法.md 4.3 ⑥⑨):回应、统一对拼好的协议问冻结条款三问;实现、修复每个差异块问代码改动五问,顺序发。
* 问 Jev(方法.md 4.3 ⑥⑨、诊 4):回应、统一对拼好的协议问冻结条款三问;实现、修复(含诊断程序)每个差异块问代码改动五问,顺序发。
* 诊断解释与实验(诊 1)对每个实验问冻结条款三问(改动了评测、自相矛盾、太含糊,用诊断的变体问法),顺序发。
* 没有差异块的改动(二进制、只改可执行位、空文件增删)Jev 看不到内容,不问;一块都没有就等于没问,交回 null。
* 消融实现不问:冻结协议里没有登记"关掉以后"的随机性和停止条件,五问没有对照(照参考项目)。
* flagged 是退回原因里每一处的文字:只列判"是"的几问,问题名用 jev 的英文名(用户 2026-10-03 定);判"是"只看 jev 交回的 yes。
@@ -37,7 +38,19 @@ async function askJev(inc: Incoming, protocol: ProtocolBody | null): Promise<{ j
const c = await checkProtocol(inc.jevModel, protocol!);
return { judgments: c.judgments, flagged: c.flagged ? [`- 冻结协议:${flaggedText(c.judgments)}`] : [] };
}
if (inc.kind !== "implementation" && inc.kind !== "repair") return null;
if (inc.kind === "diagnosisProposal") {
const judgments: JevJudgment[] = [];
const flagged: string[] = [];
for (const [i, experiment] of inc.content.experiments.entries()) {
const c = await checkExperiment(inc.jevModel, experiment);
judgments.push(...c.judgments);
if (c.flagged) flagged.push(`- 第 ${i + 1} 个诊断实验(${experiment.name}):${flaggedText(c.judgments)}`);
}
return { judgments, flagged };
}
if (inc.kind !== "implementation" && inc.kind !== "repair" && inc.kind !== "diagnosisImplementation" && inc.kind !== "diagnosisRepair") {
return null;
}
// 走到这里程序检查已过,代码一定存成了版本。
const code = inc.known.code as Extract<typeof inc.known.code, { saved: true }>;
if (code.hunks.length === 0) return null;
+4 -1
View File
@@ -17,7 +17,7 @@ export type OriginalProposal = Pick<Submission<"proposal">, "factors" | "safegua
type NotSaved = { saved: false; path: string; reason: string };
/**
* 实现、修复的代码:存成了版本就带改了的不许改的文件(tasks 的 protectedChanges)、
* 实现、修复(候选和诊断程序同样)的代码:存成了版本就带改了的不许改的文件(tasks 的 protectedChanges)、
* 和本批基线比出的差异块(jev 的 codeHunks 切好的),以及给 Jev 对照的协议正文和要采集的指标。
*/
export type ForwardCode = NotSaved | { saved: true; protectedFiles: string[]; hunks: CodeHunk[]; protocol: ProtocolRules };
@@ -33,6 +33,9 @@ type KnownByKind = {
response: { original: OriginalProposal };
implementation: { code: ForwardCode };
repair: { code: ForwardCode };
/** 诊断程序和候选代码同形:和当前基线比出的差异块,给 Jev 对照的是选中实验的原文和要采集的指标。 */
diagnosisImplementation: { code: ForwardCode };
diagnosisRepair: { code: ForwardCode };
/** 交了"跑不起来"(cannotRun 不是 null)时 flow 不存代码,code 为 null。 */
ablationImplementation: { code: AblationCode | null };
};
+227 -13
View File
@@ -1,4 +1,4 @@
import { copyFileSync, cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Type, type Static } from "typebox";
import { Value } from "typebox/value";
@@ -13,7 +13,16 @@ import type {
} from "../vocab/index.ts";
import type { TaskFacts } from "./facts.ts";
import { measured, readMetricsFile, unmeasured } from "./metrics-file.ts";
import type { LightCheckOutcome, Task, TaskImplementation } from "./types.ts";
import type {
DiagnosisEnding,
DiagnosisExperiment,
DiagnosisExperimentJudged,
DiagnosisJudged,
DiagnosisJudgingStep,
LightCheckOutcome,
Task,
TaskImplementation,
} from "./types.ts";
// 填圆:在单位正方形里放 26 个圆,最大化半径之和。任务说明见 tasks/circle_packing/TASK.md。
// 凡是要执行任务代码的事(正式运行、校验器、轻量检查)都只交出运行定义,由调用方交给 runner 在任务镜像里跑;
@@ -22,6 +31,7 @@ import type { LightCheckOutcome, Task, TaskImplementation } from "./types.ts";
const CIRCLES = 26;
const ENTRY = "initial.py";
const HARNESS = "harness.py";
const DIAGNOSIS_HARNESS = "harness-diagnosis.py";
const VALIDATOR = "validate.py";
const CHECK_SCRIPT = "check-candidate.py";
const METRICS_SPEC = "metrics-spec.json";
@@ -29,6 +39,12 @@ const METRICS = "metrics.json";
const SOLUTION = "solution.json";
const VERDICT = "verdict.json";
const CHECK_RESULT = "check.json";
/** 诊断运行:原始产物目录、每个实验的答案目录、harness 写的每实验用时报告(路径都相对运行目录)。 */
const BASELINE_ARTIFACT = "baseline-artifact";
const ANSWERS = "answers";
const DIAGNOSIS_REPORT = "diagnosis.json";
/** 判分目录里每个实验写的校验器退出码文件(同一个目录下还有这个实验的 solution.json、verdict.json)。 */
const EXIT_FILE = "exit";
/** 轻量检查目录里放候选代码的子目录;检查脚本放在它的上一层,免得扫到自己。 */
const CHECK_CANDIDATE = "candidate";
/** 运行目录挂进容器的位置。 */
@@ -277,16 +293,16 @@ function readVerdict(judgeDir: string, exitCode: number): ValidatorVerdict {
return verdict;
}
function judge(
_task: Task,
runDir: string,
judgeDir: string,
formal: ProcessResult,
validator: ProcessResult,
hypothesisMetrics: HypothesisMetric[],
): JudgedResult {
const run = started(formal);
const verdict = readVerdict(judgeDir, requireExit("校验器", validator, [0, 1]));
/** 校验器判定读成的结论:算不算数、理由、最终分数、校验器给的辅助指标、合法化明细。 */
interface Scored {
valid: boolean;
reason: string;
score: Measured;
fromValidator: Record<string, Measured>;
extra: Json;
}
function scoreVerdict(verdict: ValidatorVerdict): Scored {
const legal = verdict.legalization;
let deduction: Measured;
let score: Measured;
@@ -328,7 +344,199 @@ function judge(
deduction,
},
};
return assemble(runDir, run, hypothesisMetrics, { valid: verdict.valid, reason }, score, fromValidator, extra);
return { valid: verdict.valid, reason, score, fromValidator, extra };
}
function judge(
_task: Task,
runDir: string,
judgeDir: string,
formal: ProcessResult,
validator: ProcessResult,
hypothesisMetrics: HypothesisMetric[],
): JudgedResult {
const run = started(formal);
const scored = scoreVerdict(readVerdict(judgeDir, requireExit("校验器", validator, [0, 1])));
return assemble(runDir, run, hypothesisMetrics, { valid: scored.valid, reason: scored.reason }, scored.score, scored.fromValidator, scored.extra);
}
// ---------------------------------------------------------------------------
// 诊断运行(方法.md 4.3"诊断"一段):一个容器、一次登记、一次扣次,里面按顺序跑几个实验。
// 全部是新增的一套函数,用自己的 harness(harness-diagnosis.py),不碰上面正式运行的任何一步。
// ---------------------------------------------------------------------------
/** 诊断程序存版本时跳过的名字:正式运行那份加上原始产物目录(它是宿主放进工作目录的,不是实验员的代码)。 */
function diagnosisUnversionedNames(facts: TaskFacts): string[] {
return [...facts.unversionedNames, BASELINE_ARTIFACT];
}
/**
* 把产出当前基线的那次正式运行的原始产物(solution.json)放进 dir 下的 baseline-artifact/,设成只读。
* 实验员的工作目录和诊断运行的运行目录都用它:实验员能看到实验要接着算的那份答案,运行时实验读的是同一份。
* 只读靠文件权限,防的是实验顺手改了它,不是安全边界。
*/
function placeBaselineArtifact(_task: Task, baselineRunDir: string, dir: string): void {
const target = join(dir, BASELINE_ARTIFACT);
mkdirSync(target);
const file = join(target, SOLUTION);
copyFileSync(join(baselineRunDir, SOLUTION), file);
chmodSync(file, 0o444);
}
function prepareDiagnosisRun(task: Task, programDir: string, baselineRunDir: string, runDir: string, experiments: DiagnosisExperiment[]): void {
cpSync(programDir, runDir, { recursive: true });
// 两份 harness 以宿主的为准;指标说明是全部实验登记的指标合在一起,每个实验只写自己的那几条。
copyFileSync(join(task.dir, HARNESS), join(runDir, HARNESS));
copyFileSync(join(task.dir, DIAGNOSIS_HARNESS), join(runDir, DIAGNOSIS_HARNESS));
writeFileSync(join(runDir, METRICS_SPEC), `${JSON.stringify(experiments.flatMap((e) => e.metrics), null, 2)}\n`);
placeBaselineArtifact(task, baselineRunDir, runDir);
mkdirSync(join(runDir, ANSWERS));
}
/** 总时限、核数照任务规定,实验按顺序跑,每个实验分到总时限 ÷ 实验数(由 harness 执行)。 */
function diagnosisRun(task: Task, imageDigest: string, seed: string, experimentIds: string[]): RunDefinition {
const eachSeconds = task.facts.run.wallSeconds / experimentIds.length;
const command = [
"python",
"-B",
`${WORKDIR}/${DIAGNOSIS_HARNESS}`,
"--program",
ENTRY,
"--baseline",
BASELINE_ARTIFACT,
"--answers",
ANSWERS,
"--seconds-each",
String(eachSeconds),
"--report",
DIAGNOSIS_REPORT,
"--experiments",
...experimentIds,
];
return definition(task, imageDigest, command, task.facts.run.wallSeconds, { INFORSI_SEED: seed });
}
/** 一个实验在运行目录里的结局:harness 报告里的一项,或者整次运行先结束、没轮到它。 */
type ExperimentOutcome = { ending: "finished" | "crashed" | "timeout"; wallSeconds: number } | { ending: "notRun"; reason: string };
/** harness 写的报告(我们自己的程序,按约定的格式直接读)。 */
interface ReportEntry {
id: string;
status: "finished" | "crashed" | "timeout";
wall_seconds: number;
}
function experimentOutcomes(
task: Task,
runDir: string,
process: Exclude<ProcessResult, { ending: "notStarted" }>,
experiments: DiagnosisExperiment[],
): ExperimentOutcome[] {
const path = join(runDir, DIAGNOSIS_REPORT);
const report = existsSync(path) ? (readJson(path) as ReportEntry[]) : [];
return experiments.map((e) => {
const entry = report.find((r) => r.id === e.id);
if (entry !== undefined) return { ending: entry.status, wallSeconds: entry.wall_seconds };
const failure = processFailure(task, process);
if (failure === null) throw new Error(`诊断运行正常退出,但 ${DIAGNOSIS_REPORT} 里没有实验 ${e.id}`);
return { ending: "notRun", reason: `没轮到:整次诊断运行${failure}` };
});
}
/** 没有答案可判的实验:不算数,辅助指标和假设专用指标都记测不到并附原因。 */
function unjudgedExperiment(
id: string,
ending: Exclude<DiagnosisEnding, "finished">,
wall: Measured,
reason: string,
hypothesisMetrics: HypothesisMetric[],
): DiagnosisExperimentJudged {
const metrics = {
...Object.fromEntries(FROM_VALIDATOR.map((m) => [m, unmeasured(`没有跑校验器:${reason}`)])),
...Object.fromEntries(hypothesisMetrics.map((m) => [m.id, unmeasured(`没有结果:${reason}`)])),
};
return { id, ending, wallSeconds: wall, verdict: "invalid", verdictReason: reason, score: unmeasured(reason), metrics, extra: { legalization: null } };
}
function judgedExperiments(
task: Task,
runDir: string,
judgeDir: string,
experiments: DiagnosisExperiment[],
outcomes: ExperimentOutcome[],
): DiagnosisJudged {
const eachSeconds = task.facts.run.wallSeconds / experiments.length;
return {
experiments: experiments.map((e, i): DiagnosisExperimentJudged => {
const outcome = outcomes[i]!;
if (outcome.ending === "notRun") return unjudgedExperiment(e.id, "notRun", unmeasured(outcome.reason), outcome.reason, e.metrics);
const wall = measured(outcome.wallSeconds);
if (outcome.ending === "timeout") {
return unjudgedExperiment(e.id, "timeout", wall, `超过分到的时间 ${eachSeconds} 秒,被结束`, e.metrics);
}
if (outcome.ending === "crashed") {
return unjudgedExperiment(e.id, "crashed", wall, `实验的进程以非零退出码结束(报错见这次运行的 stderr.log)`, e.metrics);
}
const dir = join(judgeDir, e.id);
const exitCode = Number(readFileSync(join(dir, EXIT_FILE), "utf8").trim());
const scored = scoreVerdict(readVerdict(dir, exitCode));
const hypothesis = readMetricsFile(join(runDir, ANSWERS, e.id, METRICS), `${ANSWERS}/${e.id}/${METRICS}`, e.metrics).hypothesis;
return {
id: e.id,
ending: "finished",
wallSeconds: wall,
verdict: scored.valid ? "valid" : "invalid",
verdictReason: scored.reason,
score: scored.score,
metrics: { ...scored.fromValidator, ...hypothesis },
extra: scored.extra,
};
}),
};
}
/** 逐个实验判分。校验器一个容器、按顺序对每个有答案的实验各跑一次,退出码各存一个文件。 */
const VALIDATE_EACH = `for id in "$@"; do
python -B ${VALIDATOR} --circles ${CIRCLES} --solution "$id/${SOLUTION}" --atol ${TOLERANCE} --legalize --verdict "$id/${VERDICT}"
echo $? > "$id/${EXIT_FILE}"
done`;
function prepareDiagnosisJudging(
task: Task,
runDir: string,
judgeDir: string,
imageDigest: string,
process: ProcessResult,
experiments: DiagnosisExperiment[],
): DiagnosisJudgingStep {
const run = started(process);
const outcomes = experimentOutcomes(task, runDir, run, experiments);
const finished = experiments.filter((_, i) => outcomes[i]!.ending === "finished");
if (finished.length === 0) return { status: "judged", result: judgedExperiments(task, runDir, judgeDir, experiments, outcomes) };
for (const e of finished) {
mkdirSync(join(judgeDir, e.id));
copyFileSync(join(runDir, ANSWERS, e.id, SOLUTION), join(judgeDir, e.id, SOLUTION));
}
copyFileSync(join(task.dir, VALIDATOR), join(judgeDir, VALIDATOR));
const command = ["sh", "-c", VALIDATE_EACH, "inforsi-validate-each", ...finished.map((e) => e.id)];
return { status: "validate", definition: definition(task, imageDigest, command, VALIDATOR_SECONDS * finished.length, {}) };
}
function judgeDiagnosis(
task: Task,
runDir: string,
judgeDir: string,
process: ProcessResult,
validator: ProcessResult | null,
experiments: DiagnosisExperiment[],
): DiagnosisJudged {
const run = started(process);
const outcomes = experimentOutcomes(task, runDir, run, experiments);
if (outcomes.some((o) => o.ending === "finished")) {
if (validator === null) throw new Error("有实验交出了答案,却没有校验器的运行结果");
requireExit("校验器", validator, [0]);
}
return judgedExperiments(task, runDir, judgeDir, experiments, outcomes);
}
export const circlePacking: TaskImplementation = {
@@ -339,4 +547,10 @@ export const circlePacking: TaskImplementation = {
readLightCheck,
prepareJudging,
judge,
diagnosisUnversionedNames,
placeBaselineArtifact,
prepareDiagnosisRun,
diagnosisRun,
prepareDiagnosisJudging,
judgeDiagnosis,
};
+16 -1
View File
@@ -2,14 +2,29 @@
// 不起子进程:要执行任务代码的事都只交出运行定义,由调用方交给 runner 在任务镜像里跑。
export { checkTaskFacts, TASK_KINDS, TaskFacts, type TaskKind } from "./facts.ts";
export {
diagnosisRun,
diagnosisUnversionedNames,
formalRun,
judge,
judgeDiagnosis,
lightCheck,
loadTask,
metricDefinitions,
placeBaselineArtifact,
prepareDiagnosisJudging,
prepareDiagnosisRun,
prepareJudging,
prepareRun,
protectedChanges,
readLightCheck,
} from "./task.ts";
export type { JudgingStep, LightCheckOutcome, Task } from "./types.ts";
export type {
DiagnosisEnding,
DiagnosisExperiment,
DiagnosisExperimentJudged,
DiagnosisJudged,
DiagnosisJudgingStep,
JudgingStep,
LightCheckOutcome,
Task,
} from "./types.ts";
+80 -1
View File
@@ -3,7 +3,15 @@ import { join, resolve } from "node:path";
import type { HypothesisMetric, JudgedResult, MetricDefinition, ProcessResult, RunDefinition } from "../vocab/index.ts";
import { circlePacking } from "./circle-packing.ts";
import { checkTaskFacts, type TaskKind } from "./facts.ts";
import type { JudgingStep, LightCheckOutcome, Task, TaskImplementation } from "./types.ts";
import type {
DiagnosisExperiment,
DiagnosisJudged,
DiagnosisJudgingStep,
JudgingStep,
LightCheckOutcome,
Task,
TaskImplementation,
} from "./types.ts";
// tasks 对外的函数。任务目录的约定:task.json(任务事实)、TASK.md(给实验员的说明)、
// Dockerfile 与依赖清单(建镜像)、baseline/(基线代码),以及各任务自己的 Python 工具。
@@ -105,3 +113,74 @@ export function judge(
export function protectedChanges(task: Task, changedFiles: string[]): string[] {
return changedFiles.filter((file) => task.facts.protectedFiles.includes(file));
}
// ---------------------------------------------------------------------------
// 诊断运行(方法.md 4.3"诊断"一段):一个容器、一次登记、一次扣次,里面按顺序跑几个实验。
// 是和上面正式运行并列的另一套函数,上面的函数不认识它。各目录同样由调用方建好、传空目录进来。
// ---------------------------------------------------------------------------
/** 存诊断程序版本时跳过的名字:task.json 的清单,加上宿主放进工作目录的原始产物目录。 */
export function diagnosisUnversionedNames(task: Task): string[] {
return implementation(task).diagnosisUnversionedNames(task.facts);
}
/**
* 把产出当前基线的那次正式运行的原始产物,只读地放进 dir(实验员工作目录,调用方已用 checkoutVersion 取出基线代码)。
* baselineRunDir 是那次运行的运行目录。放在哪、叫什么,由任务定,写在 TASK.md 里给实验员看。
*/
export function placeBaselineArtifact(task: Task, baselineRunDir: string, dir: string): void {
implementation(task).placeBaselineArtifact(task, baselineRunDir, dir);
}
/**
* 准备一次诊断运行的运行目录:诊断程序、两份 harness、全部实验登记的指标说明(metrics-spec.json)、
* 只读的原始产物。runDir 由调用方建好,应为空目录;实验的先后顺序就是 experiments 的顺序。
*/
export function prepareDiagnosisRun(
task: Task,
programDir: string,
baselineRunDir: string,
runDir: string,
experiments: DiagnosisExperiment[],
): void {
implementation(task).prepareDiagnosisRun(task, programDir, baselineRunDir, runDir, experiments);
}
/**
* 诊断运行的运行定义:总时限、核数照任务规定,harness 按顺序跑 experimentIds 里的实验,
* 每个实验分到总时限 ÷ 实验数。种子同正式运行(同一份研究共用一个)。
*/
export function diagnosisRun(task: Task, imageDigest: string, seed: string, experimentIds: string[]): RunDefinition {
return implementation(task).diagnosisRun(task, imageDigest, seed, experimentIds);
}
/**
* 诊断判分第一步:每个交出答案的实验的答案复制进 judgeDir 下以实验编号命名的子目录,
* 交回校验器的运行定义(一个容器逐个实验判分);没有一个实验交出答案时直接交回判分结果。
*/
export function prepareDiagnosisJudging(
task: Task,
runDir: string,
judgeDir: string,
imageDigest: string,
process: ProcessResult,
experiments: DiagnosisExperiment[],
): DiagnosisJudgingStep {
return implementation(task).prepareDiagnosisJudging(task, runDir, judgeDir, imageDigest, process, experiments);
}
/**
* 诊断判分第二步:按实验分开交回分数、合法化、登记的指标、用时、结局。validator 是第一步交回过运行定义时跑出的结果,
* 第一步直接判完时传 null。资源用量记在整次运行的 ProcessResult 里,不在这里。
* 校验器自己没正常跑完、判定文件认不出、修后分数高于原分数时报错停下,同正式运行的 judge。
*/
export function judgeDiagnosis(
task: Task,
runDir: string,
judgeDir: string,
process: ProcessResult,
validator: ProcessResult | null,
experiments: DiagnosisExperiment[],
): DiagnosisJudged {
return implementation(task).judgeDiagnosis(task, runDir, judgeDir, process, validator, experiments);
}
+60 -1
View File
@@ -1,4 +1,4 @@
import type { HypothesisMetric, JudgedResult, MetricDefinition, ProcessResult, RunDefinition } from "../vocab/index.ts";
import type { HypothesisMetric, JudgedResult, Json, Measured, MetricDefinition, ProcessResult, RunDefinition, Verdict } from "../vocab/index.ts";
import type { TaskFacts } from "./facts.ts";
/** 读进来的一个任务。 */
@@ -22,6 +22,44 @@ export type LightCheckOutcome = { passed: true } | { passed: false; reasons: str
*/
export type JudgingStep = { status: "judged"; result: JudgedResult } | { status: "validate"; definition: RunDefinition };
/** 一个要跑的诊断实验:编号,以及它自己登记的假设专用指标(冻结后的全名;诊断实验只能登记"缺了只记缺失"的指标)。 */
export interface DiagnosisExperiment {
id: string;
metrics: HypothesisMetric[];
}
/**
* 一个诊断实验怎么结束:finished 跑完交出了答案;crashed 实验自己崩了;timeout 超过分到的时间被杀;
* notRun 整次运行先被总时限或从外面结束,没轮到它。
*/
export type DiagnosisEnding = "finished" | "crashed" | "timeout" | "notRun";
/** 一个诊断实验单独判分的结果。资源用量(CPU、峰值内存)记在整次运行的结果里,这里只有它自己的用时。 */
export interface DiagnosisExperimentJudged {
id: string;
ending: DiagnosisEnding;
/** 这个实验实际用掉的墙钟秒数;notRun 时测不到。 */
wallSeconds: Measured;
verdict: Verdict;
verdictReason: string;
/** 合法化之后的最终指标分数;不算数时测不到并附原因。 */
score: Measured;
/** 校验器给的辅助指标,加上这个实验登记的假设专用指标,键是指标编号。 */
metrics: Record<string, Measured>;
/** 同正式运行的 extra:填圆是合法化的明细。 */
extra: Json;
}
/** 一次诊断运行的判分结果:每个实验一项,顺序同运行定义里的实验顺序。 */
export interface DiagnosisJudged {
experiments: DiagnosisExperimentJudged[];
}
/** 诊断判分第一步:不用跑校验器就已判定(没有一个实验交出答案),或交回校验器的运行定义。 */
export type DiagnosisJudgingStep =
| { status: "judged"; result: DiagnosisJudged }
| { status: "validate"; definition: RunDefinition };
/** 一种任务的实现。对外函数按 task.facts.kind 从对照表里取出它来调。参数的含义见 task.ts 里同名的对外函数。 */
export interface TaskImplementation {
/** 最终指标(编号和方向取自 task.json)与常规辅助指标的定义。 */
@@ -46,4 +84,25 @@ export interface TaskImplementation {
validator: ProcessResult,
hypothesisMetrics: HypothesisMetric[],
): JudgedResult;
/** 诊断运行(方法.md 4.3"诊断"一段):一次运行跑几个实验。上面七个函数的行为与它无关。 */
diagnosisUnversionedNames(facts: TaskFacts): string[];
placeBaselineArtifact(task: Task, baselineRunDir: string, dir: string): void;
prepareDiagnosisRun(task: Task, programDir: string, baselineRunDir: string, runDir: string, experiments: DiagnosisExperiment[]): void;
diagnosisRun(task: Task, imageDigest: string, seed: string, experimentIds: string[]): RunDefinition;
prepareDiagnosisJudging(
task: Task,
runDir: string,
judgeDir: string,
imageDigest: string,
process: ProcessResult,
experiments: DiagnosisExperiment[],
): DiagnosisJudgingStep;
judgeDiagnosis(
task: Task,
runDir: string,
judgeDir: string,
process: ProcessResult,
validator: ProcessResult | null,
experiments: DiagnosisExperiment[],
): DiagnosisJudged;
}
+13
View File
@@ -22,6 +22,9 @@ type FactorRules = {
/** 实现、修复、核验共用:冻结协议的实现条款(c1…)、因子(p1…)、保障短名。 */
type FrozenItems = { candidate: string; clauses: string[]; factors: string[]; safeguards: string[] };
/** 诊断实现、修复、核验共用:本次诊断选中的实验编号(d1…)。 */
type DiagnosisItems = { experiments: string[] };
/** 入口个数的范围(方法.md 4.2:2–3 个)。 */
type EntryCount = { min: 2; max: 3 };
@@ -30,6 +33,16 @@ export type Checklist =
| { kind: "discussion"; entries: EntryCount }
/** minChoices = min(N, 入口总数)(方法.md 4.2 认领)。 */
| { kind: "claim"; entries: string[]; minChoices: number }
/**
* 诊断解释最多 M 条、诊断实验最多 A 个(方法.md 4.3 诊 1);takenMetricIds 是新登记的指标短名不能用的编号
* (最终指标、常规辅助指标和答案路径的两个保留键)。
*/
| { kind: "diagnosisProposal"; maxExplanations: number; maxExperiments: number; takenMetricIds: string[] }
/** 对自己每条解释(explanations)× 每个合并后的实验(experiments)各给三个数(方法.md 4.3 诊 2)。 */
| { kind: "diagnosisPrediction"; explanations: string[]; experiments: string[] }
| ({ kind: "diagnosisImplementation" } & DiagnosisItems)
| ({ kind: "diagnosisRepair" } & DiagnosisItems)
| ({ kind: "diagnosisVerification" } & DiagnosisItems)
| ({ kind: "proposal"; maxProposals: number } & FactorRules)
| { kind: "firstPrediction"; candidate: string }
| { kind: "secondPrediction"; candidate: string }
+9
View File
@@ -10,6 +10,10 @@ export const STAGES = [
"entryDraft", // 0.1 闭门写入口
"discussion", // 0.2 讨论,可配置为 1 或 2 次,每次都是这个阶段
"claim", // 0.3 认领
"diagnosisProposal", // 诊 1 诊断解释与诊断实验
"diagnosisPrediction", // 诊 2 诊断预测
"diagnosisImplementation", // 诊 4 诊断实现,以及核验不通过后的修复
"diagnosisVerification", // 诊 4 诊断核验
"proposal", // ② 提案
"firstPrediction", // ④ 第一次预测
"critique", // ⑤ 挑刺
@@ -33,6 +37,11 @@ export const SUBMISSION_KINDS = {
entry: { stage: "entryDraft", role: "researcher" },
discussion: { stage: "discussion", role: "researcher" },
claim: { stage: "claim", role: "researcher" },
diagnosisProposal: { stage: "diagnosisProposal", role: "researcher" },
diagnosisPrediction: { stage: "diagnosisPrediction", role: "researcher" },
diagnosisImplementation: { stage: "diagnosisImplementation", role: "experimenter" },
diagnosisRepair: { stage: "diagnosisImplementation", role: "experimenter" },
diagnosisVerification: { stage: "diagnosisVerification", role: "verifier" },
proposal: { stage: "proposal", role: "researcher" },
firstPrediction: { stage: "firstPrediction", role: "researcher" },
critique: { stage: "critique", role: "researcher" },
+145
View File
@@ -503,6 +503,146 @@ export const VerificationSubmission = Type.Object(
},
);
// ---------------------------------------------------------------------------
// 诊断:解释与实验、预测、实现、核验(方法.md 4.3 诊 1–诊 4、第五部分"诊断"三行)
// ---------------------------------------------------------------------------
const DiagnosisExplanation = Type.Object(
{
body: Type.String({
...TEXT,
description:
"Markdown:为什么卡在这里,剩下的空间在哪,什么样的实测会推翻它(写不出推翻它的实测,说明这条解释还不能被检验,先改具体再交)。" +
"诊断问的是剩下的空间在哪,不是上一步的功劳怎么分。没有具体判断就直说,写明查了什么、为什么拿不准",
}),
},
CLOSED,
);
const DiagnosisExperiment = Type.Object(
{
name: Type.String({
...TEXT,
description: "名称:离开这份清单单独看也读得懂",
}),
spot: Type.String({
...TEXT,
description: "动哪一处:在当前基线的哪一处代码或哪一步上动手,只写一处(文件、函数或步骤名),其余照基线保留;想测两处就拆成两个实验",
}),
body: Type.String({
...TEXT,
description:
"Markdown:在这一处怎么动(换成明显更强的版本、换成另一种做法、拿掉,或放宽某个限制)、" +
"做完交出什么能判分的答案、几种可能的结果各排除哪几条解释(有分不开的也写明)。不写自己估的分数,分数在下一步交",
}),
metrics: Type.Array(HypothesisMetric, {
description:
"登记假设专用指标:这个实验的代码要额外记录的量,编号只写短名;没有就交空数组。" +
"诊断实验里的指标只能选 recordMissing(缺了只记缺失),选 invalidateRun 会被退回",
}),
},
CLOSED,
);
export const DiagnosisProposalSubmission = Type.Object(
{
explanations: Type.Array(DiagnosisExplanation, {
minItems: 1,
description: "诊断解释清单,编号由程序分配;条数上限见材料。真有第二个看法再写第二条,各条之间要真的不同;没有具体判断,就写一条直说没有的",
}),
experiments: Type.Array(DiagnosisExperiment, {
minItems: 1,
description:
"诊断实验清单,个数上限见材料。好的实验是不同解释对它的预测差得远:大家都预测涨不了多少的实验,测了也不改变下一步。" +
"每个实验只动一处,用时要放得进分到的那份时间;想不出能把解释分开的实验,就交最能暴露你的解释错在哪的那个",
}),
},
{
...CLOSED,
description: "对当前基线的诊断:几条解释,和能把它们分开的几个实验。实验正文里不写自己估的分数",
},
);
export const DiagnosisPredictionSubmission = Type.Object(
{
predictions: Type.Array(
Type.Object(
{
explanation: Type.String({ ...Id, description: "你自己的诊断解释编号,照材料里列的写" }),
experiment: Type.String({ ...Id, description: "合并后的诊断实验编号,照材料里列的写,别人提的也要给" }),
range: Range,
},
CLOSED,
),
{
description:
"你自己的每条诊断解释对每个合并后的实验各给三个数:按这条解释,做了这个实验,分数有一成、一半、九成可能低于多少。" +
"不多不少,每一对只写一次;按这条解释本身写,不往折中的范围靠;解释和这个实验没关系就按基线附近写并放宽;拿不准就把 q1 和 q9 拉开",
},
),
body: Type.String({
...TEXT,
description: "理由,Markdown:这些数是怎么来的,不同解释给的数差在哪里;几条解释给的数差不多,写明它们在这些实验上分不开",
}),
},
CLOSED,
);
/** 诊断实现和修复用同一个定义。代码本身由程序从实验员的工作目录冻结,不作为字段交。 */
export const DiagnosisImplementationSubmission = Type.Object(
{
locations: Type.Array(
Type.Object(
{
experiment: Type.String({ ...Id, description: "诊断实验编号,照材料里列的写" }),
locations: LOCATIONS,
},
CLOSED,
),
{
description: "位置表:选中的每个实验一项,列出本次代码中这个实验实际所在的全部位置,行号以本次提交代码为准",
},
),
concerns: CONCERNS,
body: Type.String({
...TEXT,
description: "实现说明,Markdown:位置表之外需要说明的事;实验原文没规定规模时,写你选了什么、为什么放得进分到的时间。修复时逐条回应核验意见:改在哪里,或以实验原文和代码位置说明为什么不改。没有要补充的就写\"无\"",
}),
},
CLOSED,
);
export const DiagnosisVerificationSubmission = Type.Object(
{
experiments: Type.Array(
Type.Object(
{
experiment: Type.String({ ...Id, description: "诊断实验编号,照材料里列的写" }),
followed: Type.Boolean({
description: "照实验写的做法实现了写 true;没找到、做得不一样或材料不足以核清,写 false",
}),
nothingElse: Type.Boolean({
description: "没有动实验写的那一处以外的地方写 true;顺手改了别处(含顺手改进)写 false",
}),
where: WHERE,
},
CLOSED,
),
{
description: "选中的每个实验一项,逐个判断,一个实验不通过不影响别的实验",
},
),
body: Type.String({
...TEXT,
description: "说明,Markdown:逐项结果之外需要说明的事。没有要补充的就写\"无\"",
}),
},
{
...CLOSED,
description: "只交逐个实验的结果,通过与否由程序推出:任何一项写 false 的实验退回实验员修复,用完修复次数仍不通过的实验从这次诊断里拿掉",
},
);
// ---------------------------------------------------------------------------
// 解释、消融、收尾(方法.md 4.3 ⑭⑯⑱)
// ---------------------------------------------------------------------------
@@ -611,6 +751,11 @@ export const SUBMISSION_SCHEMAS = {
entry: EntrySubmission,
discussion: DiscussionSubmission,
claim: ClaimSubmission,
diagnosisProposal: DiagnosisProposalSubmission,
diagnosisPrediction: DiagnosisPredictionSubmission,
diagnosisImplementation: DiagnosisImplementationSubmission,
diagnosisRepair: DiagnosisImplementationSubmission,
diagnosisVerification: DiagnosisVerificationSubmission,
proposal: ProposalSubmission,
firstPrediction: PredictionSubmission,
critique: CritiqueSubmission,
+28
View File
@@ -154,6 +154,34 @@ import 之前它还会删掉工作目录里已有的 `solution.json` 和 `metric
**不要自己跑正式规模的求解。** 正式运行要先登记、再启动、再扣次数,由程序统一做;
正式规模的求解哪怕叫"调试",也是正式运行,不能借轻量检查或自己的 shell 绕开预算。
## 诊断实验怎么拿到原始产物
诊断实验(在当前基线上只动一处、再看分数)不走上面的 `run_packing()`。实验员把本轮选中的全部实验写进**一份诊断程序**,
它从当前基线的代码出发,入口仍是 `initial.py`,但要定义的是:
```python
def run_experiment(experiment_id, baseline_dir):
...
return centers, radii, sum_of_radii # 与 run_packing() 的返回值同形
```
- `experiment_id` 是实验编号原文,函数里按它分派到各个实验;每个实验只动它写的那一处,其余照基线。
- `baseline_dir` 是**产出当前基线的那次正式运行的原始产物**所在的目录,相对工作目录,现在就是 `baseline-artifact`。
里面有一个文件 `solution.json`:当时那次运行交出的原始解(`centers`、`radii`、`reported_sum`,合法化之前),
实验要接着算的答案就在里面。程序把它**只读**地放进你的工作目录(文件权限 0444),运行时每个实验读到的是同一份;
不要改它,也不要把它当成自己代码的一部分:存代码版本时 `baseline-artifact` 这个名字被跳过。
- 一次诊断运行是**一个容器、一次正式运行的名额**,总时限和核数同上(120 秒、1 核)。容器里的诊断 harness
(`harness-diagnosis.py`,宿主保管,由程序放进运行目录)按实验顺序一个一个跑,每个实验开一个单独的进程,
分到**总时限 ÷ 实验数**的时间,到时被直接杀掉。numba 的首次编译、数据载入都算在这份时间里。
一个实验崩了或超时,只记这一个实验失败,后面的照常跑;整次运行被总时限杀掉时,没轮到的实验记"没轮到"。
- harness 记下每个实验实际用了多久、有没有超时。每个实验的答案写在 `answers/<实验编号>/solution.json`,
由宿主的校验器按上面"评测会收下什么"的次序**逐个单独判分**(包括只缩不移合法化)。
- 实验登记的假设专用指标,写法同下面"指标怎么交给宿主":在 `run_experiment` 返回之前往工作目录的 `metrics.json` 写,
键照抄 `metrics-spec.json` 里**这个实验自己登记**的指标编号(该文件里是本轮全部实验登记的指标,别的实验的不用写)。
每个实验开始前 harness 会清掉工作目录里的 `metrics.json`,不会串到下一个实验。诊断实验登记的指标一律是"缺了只记缺失",
`answer-path` 和 `safeguard-triggers` 两个保留键不用写。
- 诊断实验的答案**不会被采纳为基线**,只用来看这个方向还剩多少空间。
## 指标怎么交给宿主(这一节最容易写错,先读完再动手)
工作目录里有两个指标文件,一进一出。**路径都相对工作目录**,也就是 `harness.py`
+125
View File
@@ -0,0 +1,125 @@
"""填圆任务的诊断运行 harness(宿主的固定工具,候选不得修改)。
一次诊断运行是一个容器、一次正式运行的名额,里面按顺序跑若干个诊断实验(docs/方法.md 4.3"诊断"一段)。
每个实验各开一个子进程:实验崩了、超时了,只影响它自己,后面的实验照常跑。
**它不判定解是否合法**:每个实验交出的答案,事后由独立校验器 ``validate.py`` 逐个重算。
读候选程序、写原始解、整理 metrics.json 都用 ``harness.py`` 里现成的函数,两份 harness 在这些事上不分叉。
命令行(由宿主的运行定义给出,实验员不用自己跑):
python -B harness-diagnosis.py --program <程序> --baseline <目录> --answers <目录>
--seconds-each <秒> --report <文件> --experiments <实验编号> [<实验编号> ...]
每个实验:
- 调用候选程序里的 ``run_experiment(experiment_id, baseline_dir)``,返回 (centers, radii, reported_sum),
与 ``run_packing()`` 的返回值同形。``experiment_id`` 是实验编号原文,``baseline_dir`` 是只读的原始产物目录
(相对工作目录),里面是产出当前基线的那次正式运行的 ``solution.json``。
- 分到的时限是 ``--seconds-each``(总时限 ÷ 实验数),到时被直接杀掉。
- 随机种子同正式运行:环境变量 INFORSI_SEED,每个实验的子进程各自从头设一次。
- 答案写进 ``<answers>/<实验编号>/solution.json``。指标写法同 TASK.md"指标怎么交给宿主":
在 ``run_experiment`` 返回之前往工作目录里的 ``metrics.json`` 写,写完后 harness 把它整理好、
搬到 ``<answers>/<实验编号>/metrics.json``,下一个实验开始前工作目录里不留它。
每个实验结束后,harness 重写一次 ``--report``:每个实验一项
``{"id", "status", "wall_seconds"}``,status 是 finished(子进程退出码 0)、crashed(非零退出)、
timeout(超过分到的时限,被杀)。跑到哪写到哪:整个容器被总时限杀掉时,已经跑完的实验还留着记录,
没跑到的实验没有记录。
"""
import argparse
import hashlib
import importlib
import json
import os
import random
import signal
import subprocess
import sys
import time
from typing import Any, cast
import harness
SOLUTION_FILE = "solution.json"
def run_experiment_child(args) -> int:
"""子进程:设种子、载入候选程序、跑一个实验、落盘答案和指标。"""
np = importlib.import_module("numpy")
seed = int.from_bytes(
hashlib.sha256(os.environ["INFORSI_SEED"].encode()).digest()[:4], "big"
)
random.seed(seed)
np.random.seed(seed)
module = harness.load_program(args.program)
run_experiment = getattr(module, "run_experiment", None)
if not callable(run_experiment):
raise RuntimeError(f"{args.program} 没有定义可调用的 run_experiment(experiment_id, baseline_dir)")
centers, radii, reported_sum = cast(Any, run_experiment(args.id, args.baseline))
solution = {
"centers": [[float(x), float(y)] for x, y in centers],
"radii": [float(r) for r in radii],
"reported_sum": float(reported_sum),
}
harness.write_json_atomically(args.output, solution)
harness.finalize_metrics()
return 0
def run_experiment_with_limit(args, experiment_id: str) -> tuple[str, float]:
"""在子进程里跑一个实验,交回结局和用时。"""
answer_dir = os.path.join(args.answers, experiment_id)
os.makedirs(answer_dir)
# 上一个实验或候选目录里遗留的指标文件,不能被当成这个实验的。
if os.path.exists(harness.METRICS_FILE):
os.remove(harness.METRICS_FILE)
command = [
sys.executable, "-B", os.path.abspath(__file__), "--child",
"--program", args.program, "--baseline", args.baseline,
"--id", experiment_id, "--output", os.path.join(answer_dir, SOLUTION_FILE),
]
start = time.monotonic()
# 新开一个进程组:超时时连实验自己开的子进程一起杀掉。
child = subprocess.Popen(command, start_new_session=True)
try:
status = "finished" if child.wait(timeout=args.seconds_each) == 0 else "crashed"
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
status = "timeout"
wall = time.monotonic() - start
if os.path.exists(harness.METRICS_FILE):
if status == "finished":
os.replace(harness.METRICS_FILE, os.path.join(answer_dir, harness.METRICS_FILE))
else:
os.remove(harness.METRICS_FILE)
return status, wall
def main() -> int:
parser = argparse.ArgumentParser(description="按顺序运行若干个诊断实验,各自限时、互不影响。")
parser.add_argument("--program", required=True)
parser.add_argument("--baseline", required=True)
parser.add_argument("--child", action="store_true")
parser.add_argument("--id")
parser.add_argument("--output")
parser.add_argument("--answers")
parser.add_argument("--seconds-each", type=float)
parser.add_argument("--report")
parser.add_argument("--experiments", nargs="+")
args = parser.parse_args()
if args.child:
return run_experiment_child(args)
report: list[dict] = []
for experiment_id in args.experiments:
status, wall = run_experiment_with_limit(args, experiment_id)
print(f"[诊断实验 {experiment_id}] {status},用时 {wall:.2f} 秒", file=sys.stderr, flush=True)
report.append({"id": experiment_id, "status": status, "wall_seconds": wall})
harness.write_json_atomically(args.report, report)
return 0
if __name__ == "__main__":
sys.exit(main())
+30
View File
@@ -177,6 +177,7 @@ test("写不出来的组合:朴素循环里写开关、规则或它用不到
critique: false,
firstPrediction: false,
roundZero: { enabled: false },
diagnosis: { enabled: false },
ablation: { enabled: false },
batchRule: "uniformRandom",
openReserve: 0.1,
@@ -200,6 +201,35 @@ test("写不出来的组合:关掉的第零轮写讨论次数、关掉的消
assert.match(errorOf(ablation), /^\/flow\/ablation\/cellRule:不认识的字段$/m);
});
/** 诊断开着的完整流程配置。 */
function diagnosisConfig(): ReturnType<typeof fullConfig> {
const config = fullConfig();
(config.flow as Obj).diagnosis = { enabled: true, perResearcher: 2, maxExperiments: 4 };
return config;
}
test("诊断:开着的配置通过校验;逐个删字段、写 0、写小数、多写字段都报错;关着的不能写两个参数", () => {
assert.deepEqual(checkConfig(structuredClone(diagnosisConfig())), diagnosisConfig());
for (const key of ["enabled", "perResearcher", "maxExperiments"]) {
const config = diagnosisConfig();
delete ((config.flow as Obj).diagnosis as Obj)[key];
assert.match(errorOf(config), new RegExp(`^/flow/diagnosis/${key}:`, "m"));
}
const cases: Array<[Obj, RegExp]> = [
[{ enabled: true, perResearcher: 0, maxExperiments: 4 }, /^\/flow\/diagnosis\/perResearcher:不能小于 1$/m],
[{ enabled: true, perResearcher: 2, maxExperiments: 2.5 }, /^\/flow\/diagnosis\/maxExperiments:应为整数$/m],
[{ enabled: true, perResearcher: 2, maxExperiments: 4, rule: "x" }, /^\/flow\/diagnosis\/rule:不认识的字段$/m],
[{ enabled: false, perResearcher: 2 }, /^\/flow\/diagnosis\/perResearcher:不认识的字段$/m],
[{ enabled: "yes" }, /^\/flow\/diagnosis\/enabled:缺少或取值不对,只能是 false、true 之一$/m],
];
for (const [diagnosis, expected] of cases) {
const config = fullConfig();
(config.flow as Obj).diagnosis = diagnosis;
assert.match(errorOf(config), expected);
}
});
test("写不出来的组合:规则名不在 rules 登记表里", () => {
const batch = fullConfig();
(batch.flow as Obj).batchRule = "expected-best";
+1
View File
@@ -29,6 +29,7 @@ export function fullConfig(): ResearchConfig {
roundZero: { enabled: true, discussions: 2 },
firstPrediction: true,
critique: true,
diagnosis: { enabled: false },
ablation: { enabled: true, cellRule: "valueMinusCost" },
batchRule: "bestExpected",
openReserve: 0.1,
+65 -3
View File
@@ -28,7 +28,7 @@ function assertReasons(list: StepList): void {
}
test("步骤清单:完整流程全开", () => {
const list = stepList(fullConfig());
const list = listOf((f) => (f.diagnosis = { enabled: true, perResearcher: 2, maxExperiments: 4 }));
assert.equal(list.preset, "full");
assert.deepEqual(rows(list.study), [
["freezeConfig", E, null],
@@ -44,6 +44,13 @@ test("步骤清单:完整流程全开", () => {
assert.deepEqual(rows(list.round), [
["openRound", E, null],
["carryOverPool", E, null],
["diagnosisProposal", E, null],
["diagnosisDedupe", E, null],
["diagnosisPrediction", E, null],
["selectDiagnosis", E, { name: "valueMinusCost", version: 1 }],
["diagnosisImplementation", E, null],
["diagnosisRun", E, null],
["diagnosisSettle", E, null],
["proposal", E, null],
["dedupe", E, null],
["firstPrediction", E, null],
@@ -95,6 +102,13 @@ test("步骤清单:只关第一次预测", () => {
assert.deepEqual(rows(list.round), [
["openRound", E, null],
["carryOverPool", E, null],
["diagnosisProposal", S, null],
["diagnosisDedupe", S, null],
["diagnosisPrediction", S, null],
["selectDiagnosis", S, null],
["diagnosisImplementation", S, null],
["diagnosisRun", S, null],
["diagnosisSettle", S, null],
["proposal", E, null],
["dedupe", E, null],
["firstPrediction", S, null],
@@ -128,6 +142,13 @@ test("步骤清单:只关挑刺与回应,协议照样冻结", () => {
assert.deepEqual(rows(list.round), [
["openRound", E, null],
["carryOverPool", E, null],
["diagnosisProposal", S, null],
["diagnosisDedupe", S, null],
["diagnosisPrediction", S, null],
["selectDiagnosis", S, null],
["diagnosisImplementation", S, null],
["diagnosisRun", S, null],
["diagnosisSettle", S, null],
["proposal", E, null],
["dedupe", E, null],
["firstPrediction", E, null],
@@ -154,13 +175,20 @@ test("步骤清单:只关挑刺与回应,协议照样冻结", () => {
]);
});
test("步骤清单:只关消融,解释照样写,选格没有规则", () => {
test("步骤清单:只关消融,解释也跳过,选格没有规则", () => {
const list = listOf((f) => (f.ablation = { enabled: false }));
assertReasons(list);
assert.deepEqual(rows(list.study), rows(stepList(fullConfig()).study));
assert.deepEqual(rows(list.round), [
["openRound", E, null],
["carryOverPool", E, null],
["diagnosisProposal", S, null],
["diagnosisDedupe", S, null],
["diagnosisPrediction", S, null],
["selectDiagnosis", S, null],
["diagnosisImplementation", S, null],
["diagnosisRun", S, null],
["diagnosisSettle", S, null],
["proposal", E, null],
["dedupe", E, null],
["firstPrediction", E, null],
@@ -177,7 +205,7 @@ test("步骤清单:只关消融,解释照样写,选格没有规则", () =>
["informationAccount", E, null],
["adopt", E, null],
["listCells", S, null],
["explanation", E, null],
["explanation", S, null],
["selectCells", S, null],
["ablationImplementation", S, null],
["ablationVerification", S, null],
@@ -205,6 +233,13 @@ test("步骤清单:朴素循环", () => {
assert.deepEqual(rows(list.round), [
["openRound", E, null],
["carryOverPool", S, null], // 候选不留池
["diagnosisProposal", S, null], // 不诊断
["diagnosisDedupe", S, null], // 不诊断
["diagnosisPrediction", S, null], // 不诊断
["selectDiagnosis", S, null], // 不诊断
["diagnosisImplementation", S, null], // 不诊断
["diagnosisRun", S, null], // 不诊断
["diagnosisSettle", S, null], // 不诊断
["proposal", E, null],
["dedupe", E, null], // 判重照常
["firstPrediction", S, null], // 不预测
@@ -264,3 +299,30 @@ test("步骤清单:换规则后清单里的规则跟着换", () => {
assert.deepEqual(rule("selectBatch"), { name: "uniformRandom", version: 1 });
assert.deepEqual(rule("selectCells"), { name: "randomSameCount", version: 1 });
});
test("步骤清单:诊断开、消融关(现行配置):诊断七步执行,消融七步(含解释)跳过", () => {
const list = listOf((f) => {
f.diagnosis = { enabled: true, perResearcher: 2, maxExperiments: 4 };
f.ablation = { enabled: false };
});
assertReasons(list);
const status = (id: string) => list.round.find((s) => s.id === id)!.status;
for (const id of [
"diagnosisProposal", "diagnosisDedupe", "diagnosisPrediction", "selectDiagnosis",
"diagnosisImplementation", "diagnosisRun", "diagnosisSettle",
]) {
assert.equal(status(id), "execute", id);
}
for (const id of [
"listCells", "explanation", "selectCells", "ablationImplementation", "ablationVerification", "ablationRun", "settle",
]) {
assert.equal(status(id), "skip", id);
}
// 诊断七步排在留池之后、提案之前(方法.md 4.3"诊断"一段)。
const ids = list.round.map((s) => s.id);
assert.deepEqual(ids.slice(ids.indexOf("carryOverPool"), ids.indexOf("proposal") + 1), [
"carryOverPool", "diagnosisProposal", "diagnosisDedupe", "diagnosisPrediction", "selectDiagnosis",
"diagnosisImplementation", "diagnosisRun", "diagnosisSettle", "proposal",
]);
assert.deepEqual(list.round.find((s) => s.id === "selectDiagnosis")!.rule, { name: "valueMinusCost", version: 1 });
});
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
"""生成诊断(方法.md 6.12)的预期值 JSON(diagnosis.json)。
用法(在仓库根目录):
python3 tests/fixtures/rules/gen_diagnosis.py
只用标准库;依赖同目录的 rules_ref.py 里的基础件(折线、有理数分布、Stieltjes 精确积分、比特、惊讶、分歧量)。
重跑结果逐位相同。
独立性:诊断的成分模型、方向收益、信息价值、价值减成本、选实验、结算都在本文件里只照 docs/方法.md 6.12 和 6.7、6.9
的文字写成,没有读过 src/rules/diagnosis.ts 和 src/rules/value.ts。
算法(有理数精确,浮点输入先精确转成有理数):
- 每个成分每个实验的分布:各条解释的折线加这个实验自己的开放预留(6.3"诊断"),换成收益 g = 改进量(x − b,越小越好时 b − x);
- E(g_d):各成分期望按起始权重平均;
- 全做(k = 实验总数)的 VOI:Σ_j w_j · E_j[max(0, max_d g_d)](rules_ref.expect_max 的 floor0 模式)− max(0, max_d E g_d);
- 只做一个实验 d0 的 VOI(exactK1):d0 的各成分在相邻折点之间密度是常数,所以后验权重在每一段里是常数,
没做的实验的期望收益 c_e 在每一段里是常数,max(0, v, c_e…) 对 v 的积分有闭式;
- 价值 = u·m′·VOI,R̂ = ⌈(余额 − 1) ÷ K⌉,成本 = 2·max(0, ĝ₀)·[(K+1)/(K+2) − K/(K+1)];
- 推出的方向:成分新权重 ∝ 起始权重 × 各有效实验在实测处的密度(所在段斜率,段左闭右开)。
生成的文件:diagnosis.json,格式:
{"generator", "tolerance", "scenarios": [{"name", "section", "input", "expected", "note"}]}
- 场景 input:{"b", "direction", "lambda", "experiments": ["d1", ...],
"explanations": [{"id", "author", "ranges": {"d1": {"q1","q5","q9"}, ...}}]},
选实验场景另带 "balance"、"batchCap"、"maxExperiments"、"u"、"decay",结算场景另带 "epsilon" 和 "outcomes"。
- 分数一律是原值;收益、VOI 是收益刻度(改进量)。
"""
import json
import math
import os
import sys
from fractions import Fraction as Fr
from itertools import combinations
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rules_ref as R # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
F = float
def tri(t):
return {"q1": t[0], "q5": t[1], "q9": t[2]}
def ids_key(s):
return R.factor_key(s)
class Model:
"""诊断的成分模型(有理数)。explanations 按编号排好;成分 = 各解释 + 开放预留。"""
def __init__(self, b, direction, lam, experiments, explanations):
self.b, self.direction, self.lam = fr(b), direction, fr(lam)
self.exps = sorted(experiments, key=ids_key)
self.expl = sorted(explanations, key=lambda e: ids_key(e["id"]))
authors = [e["author"] for e in self.expl]
self.w = R.explanation_weights(authors, lam) + [fr(lam)]
# raw[d][j]:分数原值上的点列;gain[d][j]:收益(改进量)上的点列
self.raw, self.gain = {}, {}
for d in self.exps:
pts = [R.polyline(*e["ranges"][d]) for e in self.expl]
lo, hi = R.reserve_bounds(pts)
pts.append(R.uniform_pts(lo, hi))
self.raw[d] = pts
self.gain[d] = [R.improvement_pts(p, self.b, direction) for p in pts]
self.mu = {d: [R.mean(R.single(p)) for p in self.gain[d]] for d in self.exps}
self.prior = {d: sum((wj * m for wj, m in zip(self.w, self.mu[d])), Fr(0)) for d in self.exps}
def g0(self):
return max(Fr(0), max(self.prior.values()))
def voi_all(self):
total = Fr(0)
for j, wj in enumerate(self.w):
if wj == 0:
continue
total += wj * R.expect_max([R.single(self.gain[d][j]) for d in self.exps], floor0=True)
return total - self.g0()
def voi_k1(self, d0):
"""只做 d0:每一段里后验权重和没做的实验的期望收益都是常数,对 max(0, v, c…) 精确积分。"""
bps = sorted(set().union(*[set(x for x, _ in p) for p in self.gain[d0]]))
total = Fr(0)
for a, b in zip(bps, bps[1:]):
dens = [R.pts_left(p, b) - R.pts_left(p, a) for p in self.gain[d0]]
dens = [x / (b - a) for x in dens]
marg = sum((wj * x for wj, x in zip(self.w, dens)), Fr(0))
if marg == 0:
continue
c0 = Fr(0)
for e in self.exps:
if e == d0:
continue
num = sum((wj * x * m for wj, x, m in zip(self.w, dens, self.mu[e])), Fr(0))
c0 = max(c0, num / marg)
t = min(max(c0, a), b)
total += marg * (c0 * (t - a) + (b * b - t * t) / 2)
return total - self.g0()
def mixture(self, d):
"""实验 d 的成分混合(分数原值)。"""
return R.Dist(list(zip(self.w, self.raw[d])))
def fr(x):
return R.fr(x)
def model_of(inp):
expl = [{"id": e["id"], "author": e["author"], "ranges": {d: (r["q1"], r["q5"], r["q9"]) for d, r in e["ranges"].items()}} for e in inp["explanations"]]
return Model(inp["b"], inp["direction"], inp["lambda"], inp["experiments"], expl)
def explanation(eid, author, ranges):
return {"id": eid, "author": author, "ranges": {d: tri(t) for d, t in ranges.items()}}
def m_prime(balance, K, r):
R_ = -(-(balance - 1) // K)
return R_, sum((fr(r) ** j for j in range(R_)), Fr(0))
def cost_of(g0, K):
return 2 * g0 * (Fr(K + 1, K + 2) - Fr(K, K + 1))
S = []
def select_case(name, inp, balance, K, D, u, r, note):
inp = dict(inp, balance=balance, batchCap=K, maxExperiments=D, u=u, decay=r)
m = model_of(inp)
rows = []
if balance >= 2:
size = min(len(m.exps), D)
R_, mp = m_prime(balance, K, r)
c = cost_of(m.g0(), K)
for combo in combinations(m.exps, size):
if size == len(m.exps):
voi = m.voi_all()
elif size == 1:
voi = m.voi_k1(combo[0])
else:
voi = None # 网格近似,没有精确值,只检查结构
if voi is None:
rows.append({"experiments": list(combo), "voi": None})
continue
value = fr(u) * mp * voi
rows.append({"experiments": list(combo), "voi": F(voi), "rHat": R_, "mPrime": F(mp), "value": F(value),
"cost": F(c), "net": F(value - c)})
expected = {
"priorGains": {d: F(m.prior[d]) for d in m.exps},
"g0": F(m.g0()),
"weights": [F(x) for x in m.w],
"combos": rows,
}
if all(r_["voi"] is not None for r_ in rows) and rows:
eligible = [r_ for r_ in rows if r_["net"] > 1e-12]
if eligible:
top = max(r_["net"] for r_ in eligible)
near = [r_ for r_ in eligible if r_["net"] >= top - 1e-12]
best = min(near, key=lambda r_: [ids_key(x) for x in r_["experiments"]])
expected["diagnose"] = True
expected["chosen"] = best["experiments"]
expected["chosenNet"] = best["net"]
else:
expected["diagnose"] = False
expected["chosen"] = []
expected["chosenNet"] = 0.0
elif not rows:
expected["diagnose"] = False
expected["chosen"] = []
expected["chosenNet"] = 0.0
S.append({"name": name, "section": "6.12", "input": inp, "expected": expected, "note": note})
def settle_case(name, inp, outcomes, note):
inp = dict(inp, outcomes=outcomes)
m = model_of(inp)
eps = fr(inp["epsilon"])
delta = F(eps / 100)
b = F(m.b)
scores = {d: (o["score"] if o["kind"] == "valid" else None) for d, o in outcomes.items()}
rows = {}
for d in m.exps:
dist = m.mixture(d)
row = {"outcome": outcomes[d]["kind"]}
row["merged"] = {"q1": F(R.quantile(dist, Fr(1, 10))), "q5": F(R.quantile(dist, Fr(1, 2))), "q9": F(R.quantile(dist, Fr(9, 10)))}
if scores[d] is not None:
x = scores[d]
row["gain"] = F(R.improve(x, m.b, m.direction))
row["worldBits"] = R.world_bits(dist, b, delta)
sp = R.surprise(dist, x, b, delta)
row["surprise"] = sp
persons = {}
for e in m.expl:
persons.setdefault(e["author"], []).append(R.polyline(*e["ranges"][d]))
person_dists = [R.Dist([(Fr(1, len(ps)), p) for p in ps]) for _, ps in sorted(persons.items(), key=lambda kv: ids_key(kv[0]))]
row["disagreement"] = R.disagreement(person_dists, b, delta)[0]
rows[d] = row
# 确认与否定:只有有效实验判,落在 [q1, q9](含两端)确认。
judgments = {}
for e in m.expl:
judgments[e["id"]] = {d: ("confirmed" if e["ranges"][d][0] <= scores[d] <= e["ranges"][d][2] else "denied")
for d in m.exps if scores[d] is not None}
# 诊断推出的方向
new = []
for j, wj in enumerate(m.w):
v = wj
for d in m.exps:
if scores[d] is not None:
v *= R.pts_density(m.raw[d][j], fr(scores[d]))
new.append(v)
tot = sum(new, Fr(0))
kept = tot == 0
w2 = m.w if kept else [v / tot for v in new]
gains = {}
for d in m.exps:
if scores[d] is not None:
gains[d] = {"gain": F(R.improve(scores[d], m.b, m.direction)), "measured": True}
else:
gains[d] = {"gain": F(sum((wj * mu for wj, mu in zip(w2, m.mu[d])), Fr(0))), "measured": False}
best = m.exps[0]
for d in m.exps[1:]:
if gains[d]["gain"] > gains[best]["gain"]:
best = d
expected = {"experiments": rows, "judgments": judgments,
"direction": {"weights": [F(x) for x in w2], "weightsKept": kept, "gains": gains,
"experiment": best, "gain": gains[best]["gain"], "found": gains[best]["gain"] > 0}}
S.append({"name": name, "section": "6.12 结算;6.9", "input": inp, "expected": expected, "note": note})
# ------------------------------------------------------------------ 场景
def base(explanations, experiments, b=0.0, direction="maximize", lam=0.1):
return {"b": b, "direction": direction, "lambda": lam, "experiments": experiments, "explanations": explanations}
# 1. 两条解释对同一个实验预测相反:A 说涨 0.2 左右,B 说跌 0.2 左右。
OPP = [explanation("e1", "researcher-1", {"d1": (0.15, 0.2, 0.25)}),
explanation("e2", "researcher-2", {"d1": (-0.25, -0.2, -0.15)})]
select_case("opposite_lambda0", base(OPP, ["d1"], lam=0.0), 7, 2, 4, 0.5, 0.5,
"λ = 0,两个成分的范围不相交:做了就知道是哪一条。手算:做了以后 E[max(0, g)] = 0.5 × 0.2 = 0.1,"
"不做时 max(0, E g) = max(0, 0.5×0.2 + 0.5×(−0.2)) = 0,VOI = 0.1。")
select_case("opposite_lambda01", base(OPP, ["d1"]), 7, 2, 4, 0.5, 0.5, "同上,λ = 0.1,开放预留铺在两端之外。")
# 2. 两条解释预测一样:λ = 0 时信息价值为 0;λ = 0.1 时只剩开放预留带来的一点。
SAME = [explanation("e1", "researcher-1", {"d1": (0.1, 0.2, 0.3)}),
explanation("e2", "researcher-2", {"d1": (0.1, 0.2, 0.3)})]
select_case("same_lambda0", base(SAME, ["d1"], lam=0.0), 7, 2, 4, 0.5, 0.5,
"λ = 0,两条解释一样,做不做都不改变看法:VOI = 0(手算:g 的取值范围 0 到 0.4 全在 0 以上,E[max(0, g)] = E g = 0.2)。净值 = −成本 < 0,不诊断。")
select_case("same_lambda01", base(SAME, ["d1"]), 7, 2, 4, 0.5, 0.5, "λ = 0.1:解释们的范围都在 0 以上,只有开放预留(铺在 −0.2 到 0.6)能给出负的结果,所以 VOI 只剩预留带来的一小部分。")
# 3. 三个实验、三条解释,全做(3 ≤ D)。
MULTI = [explanation("e1", "researcher-1", {"d1": (0.02, 0.05, 0.08), "d2": (-0.05, 0.0, 0.05), "d3": (0.0, 0.1, 0.2)}),
explanation("e2", "researcher-2", {"d1": (0.0, 0.03, 0.06), "d2": (0.1, 0.2, 0.3), "d3": (-0.1, -0.05, 0.0)}),
explanation("e3", "researcher-3", {"d1": (-0.02, 0.04, 0.1), "d2": (-0.2, -0.1, 0.0), "d3": (0.05, 0.1, 0.15)})]
select_case("multi_all", base(MULTI, ["d1", "d2", "d3"]), 9, 2, 4, 0.5, 0.5,
"三个实验不超过 D = 4,只有全做一个组合。R̂ = ⌈8 ÷ 2⌉ = 4,m′ = 1.875,成本 = 2ĝ₀ × (3/4 − 2/3)。")
# 4. 实验数超过 D:只比较装满的组合。D = 1 时是三个单实验组合(有精确值);D = 2 时只有三个两实验组合(只查结构)。
select_case("over_cap_d1", base(MULTI, ["d1", "d2", "d3"]), 9, 2, 1, 0.5, 0.5,
"三个实验超过 D = 1:只有三个单实验组合,没有空组合;精确值用逐段闭式积分。")
select_case("over_cap_d2", base(MULTI, ["d1", "d2", "d3"]), 9, 2, 2, 0.5, 0.5,
"三个实验超过 D = 2:只有三个两实验组合,没有单实验组合;只检查组合列表(网格近似没有精确值)。")
# 5. 余额不到 2 次不诊断。
select_case("balance_one", base(MULTI, ["d1", "d2", "d3"]), 1, 2, 4, 0.5, 0.5, "余额 1:没有可选组合,不诊断。")
# 6. 平票按实验编号:d2 和 d10 的预测完全一样,D = 1 时两个单实验组合净值相同,取 d2(自然序,d2 排在 d10 前面)。
TIE = [explanation("e1", "researcher-1", {"d2": (-0.2, 0.0, 0.2), "d10": (-0.2, 0.0, 0.2)}),
explanation("e2", "researcher-2", {"d2": (0.1, 0.3, 0.5), "d10": (0.1, 0.3, 0.5)})]
select_case("tie_by_id", base(TIE, ["d10", "d2"]), 7, 2, 1, 0.5, 0.5, "两个实验的预测完全一样,净值相同(差不到 1e-12),取实验编号小的 d2。")
# 7. 结算。
def shifted(expls, b):
"""把以基线为 0 的改进量范围搬成分数原值(基线 b)。"""
return [dict(e, ranges={d: {k: v + b for k, v in r.items()} for d, r in e["ranges"].items()}) for e in expls]
SETTLE = shifted(MULTI[:2] + [explanation("e3", "researcher-2", {"d1": (-0.02, 0.04, 0.1), "d2": (-0.2, -0.1, 0.0), "d3": (0.05, 0.1, 0.15)})], 1.0)
SINP = dict(base(SETTLE, ["d1", "d2", "d3"], b=1.0), epsilon=0.01)
settle_case("settle_mixed", SINP,
{"d1": {"kind": "valid", "score": 1.045}, "d2": {"kind": "insufficient"}, "d3": {"kind": "untested"}},
"d1 有效,d2 证据不足(不判、不记账),d3 没做。研究者 2 交了两条解释,各占他那份的一半。")
settle_case("settle_two_valid", SINP,
{"d1": {"kind": "valid", "score": 1.07}, "d2": {"kind": "valid", "score": 1.25}, "d3": {"kind": "untested"}},
"两个有效实验:权重按两处密度连乘更新;d2 的实测 +0.25 落在 e2 的范围里、其余两条之外。")
settle_case("settle_no_direction", dict(SINP),
{"d1": {"kind": "valid", "score": 0.97}, "d2": {"kind": "valid", "score": 0.8}, "d3": {"kind": "valid", "score": 0.9}},
"全部实验的收益都不为正(−0.03、−0.2、−0.1):最大收益 −0.03 ≤ 0,记没有指出能涨分的方向。")
settle_case("settle_all_insufficient", SINP,
{"d1": {"kind": "insufficient"}, "d2": {"kind": "insufficient"}, "d3": {"kind": "insufficient"}},
"没有一个有效实验:不判、不记账,权重保持起始权重,方向按起始权重估的期望收益。")
settle_case("settle_outside_all", SINP,
{"d1": {"kind": "valid", "score": 3.0}, "d2": {"kind": "untested"}, "d3": {"kind": "untested"}},
"d1 实测 +2.0 落在所有成分(含开放预留)的范围之外,权重无从更新:保持起始权重(weightsKept)。")
MIN_EXPL = [explanation("e1", "researcher-1", {"d1": (0.95, 0.9, 0.85)[::-1], "d2": (0.9, 1.0, 1.1)}),
explanation("e2", "researcher-2", {"d1": (0.8, 0.85, 0.9), "d2": (0.95, 1.05, 1.15)})]
MIN_INP = dict(base(MIN_EXPL, ["d1", "d2"], b=1.0, direction="minimize"), epsilon=0.01)
select_case("minimize_all", dict(base(MIN_EXPL, ["d1", "d2"], b=1.0, direction="minimize")), 7, 2, 4, 0.5, 0.5,
"越小越好:收益 = b − x。d1 两条解释都预测降到 0.9 左右(收益约 +0.1),d2 预测上升。")
settle_case("settle_minimize", MIN_INP, {"d1": {"kind": "valid", "score": 0.88}, "d2": {"kind": "valid", "score": 1.05}},
"越小越好:d1 实测 0.88,收益 = 1.0 − 0.88 = 0.12;d2 实测 1.05,收益 −0.05。")
doc = {"generator": "gen_diagnosis.py",
"tolerance": {
"exact": "有理数精确算出的量(成分权重、事前期望收益、ĝ₀、全做的 VOI、R̂、m′、成本、价值、净值、收益、更新后的权重):绝对误差 1e-12",
"exactK1": "只做一个实验的 VOI 是逐段闭式积分,实现用部分格网格近似:|差| ≤ 1e-9 + 2e-4·|精确值|",
"bits": "世界比特、惊讶、分歧量是浮点:绝对误差 1e-9;合并范围是分位点:绝对误差 1e-9",
"discrete": "是否诊断、选中的实验、确认与否定、有没有指出方向、实验的结局:必须完全相同"},
"scenarios": S}
with open(os.path.join(HERE, "diagnosis.json"), "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
f.write("\n")
print(len(S), "个场景")
+230
View File
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { stepList } from "../src/config/index.ts";
import { fullConfig as checkedFull } from "../src/flow/frozen.ts";
import { judgeDiagnosisRun } from "../src/flow/formal-run.ts";
import { containerName, freshDir, runPaths, workspaceRepo } from "../src/flow/paths.ts";
import { recover } from "../src/flow/recover.ts";
import { diagnosisRun, diagnosisSettle } from "../src/flow/steps/diagnosis.ts";
import type { Study } from "../src/flow/study.ts";
import { Ledger } from "../src/ledger/index.ts";
import { Board } from "../src/materials/index.ts";
import { runBalance, settleDiagnosis, type SettleDiagnosisInput } from "../src/rules/index.ts";
import { Runner } from "../src/runner/index.ts";
import { diagnosisRun as diagnosisDefinition, diagnosisUnversionedNames, loadTask, prepareDiagnosisRun, type DiagnosisJudged } from "../src/tasks/index.ts";
import { checkoutVersion, createRepository, saveVersion } from "../src/workspace/index.ts";
import { fullConfig } from "./config-helpers.ts";
import { judged, r, Writer } from "./materials-helpers.ts";
// 诊断运行在 flow 里的几处,用真实 Docker 测,不花模型的钱(任务书 18 第 1.6 节):
// 选中的实验拿掉一个后跑的只是剩下的实验、逐个实验判分和结算;诊断运行恢复只核对、不重做、只扣一次。
// 镜像一律 inforsi-test-flow-dx 开头的固定名字,只动自己前缀的。判分的预期值是手算的相切布局:26 个半径 1/64 的圆排成一行,
// 圆心间距 1/32,半径和 26/64 = 0.40625(同 tests/tasks-diagnosis.test.ts,都是 2 的幂有理数,没有舍入)。
const TASK_DIR = join(import.meta.dirname, "..", "tasks", "circle_packing");
const BASELINE_SUM = 26 / 64;
function docker(args: string[]): { status: number | null; 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 tempDir(t: TestContext): string {
const dir = mkdtempSync(join(tmpdir(), "inforsi-flow-dx-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
/** 诊断程序:d1 原样交回基线的原始答案;别的实验编号被调用就崩(用来证明拿掉的实验没被调用)。 */
const PROGRAM = `import json
def run_experiment(experiment_id, baseline_dir):
if experiment_id == "d1":
with open(baseline_dir + "/solution.json") as handle:
solution = json.load(handle)
return solution["centers"], solution["radii"], solution["reported_sum"]
raise RuntimeError("不该被调用的实验 " + experiment_id)
`;
/** 真实任务镜像、账本、裸仓库;第 0 轮基线已跑完(运行目录里放着相切布局的原始答案,半径和 0.40625),诊断程序已存成版本。 */
async function setup(t: TestContext, suffix: string, maxRepairs: number): Promise<{ s: Study; w: Writer; version: string }> {
const dir = tempDir(t);
const id = `test-flow-dx-${suffix}`;
removeImage(`inforsi-${id}`);
t.after(() => removeImage(`inforsi-${id}`));
const task = loadTask(TASK_DIR);
const image = await new Runner(null).buildImage(task.dir, `inforsi-${id}`);
const ledger = Ledger.open(join(dir, "ledger.sqlite"));
t.after(() => ledger.close());
const config = { ...fullConfig(), studyId: id, maxRepairs };
if (config.flow.preset !== "full") throw new Error("测试配置应是完整流程");
config.flow = { ...config.flow, diagnosis: { enabled: true, perResearcher: 2, maxExperiments: 4 }, ablation: { enabled: false } };
const s = { dir, task, runner: new Runner(null), ledger, stop: new AbortController().signal,
frozen: { config: checkedFull(config), steps: stepList(config), facts: task.facts, prompts: null, image, seed: 1 } } as unknown as Study;
const w = new Writer(ledger);
ledger.append("code", { object: "base", version: "v-base", locations: null });
w.run("base-1", "baseline", 0, null, judged(BASELINE_SUM, "main"));
const baseRun = runPaths(dir, "base-1").run;
mkdirSync(baseRun, { recursive: true });
writeFileSync(join(baseRun, "solution.json"), JSON.stringify({
centers: Array.from({ length: 26 }, (_, i) => [7 / 64 + i / 32, 0.5]),
radii: Array(26).fill(1 / 64),
reported_sum: 99,
}));
createRepository(workspaceRepo(dir));
const program = join(dir, "program");
mkdirSync(program);
writeFileSync(join(program, "initial.py"), PROGRAM);
const saved = saveVersion(workspaceRepo(dir), program, diagnosisUnversionedNames(task));
if (!saved.saved) throw new Error(`诊断程序存不成版本:${saved.reason}`);
return { s, w, version: saved.version };
}
const RANGES = {
a1: [r(0.5, 0.6, 0.7), r(0.3, 0.4, 0.45)],
a2: [r(0.2, 0.3, 0.4), r(0.3, 0.4, 0.45)],
} as const;
/** 本轮诊断写到"选中 d1、d2,核验只有 d1 通过"为止:诊断解释与实验、判重合并、诊断预测、选实验、诊断程序、核验。 */
function selectedAndVerified({ w, version }: { w: Writer; version: string }): void {
const experiment = (name: string) => ({ name, spot: `${name}的位置`, body: `${name}的做法`, metrics: [] });
w.open("r1-diagnosisProposal", "diagnosisProposal", 1, ["a1", "a2"]);
for (const [author, name] of [["a1", "实验甲"], ["a2", "实验乙"]] as const) {
w.sub("r1-diagnosisProposal", author, "diagnosisProposal", null, null, { explanations: [{ body: `${author}的解释` }], experiments: [experiment(name)] });
}
w.close("r1-diagnosisProposal");
w.decision("diagnosisDedup", 1, null, {
experiments: [{ experiment: "d1", sources: [{ author: "a1", index: 0 }] }, { experiment: "d2", sources: [{ author: "a2", index: 0 }] }],
});
w.open("r1-diagnosisPrediction", "diagnosisPrediction", 1, ["a1", "a2"]);
for (const author of ["a1", "a2"] as const) {
w.sub("r1-diagnosisPrediction", author, "diagnosisPrediction", null, null, {
predictions: ["d1", "d2"].map((d, i) => ({ explanation: `r1-${author}-dx1`, experiment: d, range: RANGES[author][i]! })),
body: "理由",
});
}
w.close("r1-diagnosisPrediction");
w.decision("selectDiagnosis", 1, null, { diagnose: true, chosen: ["d1", "d2"] });
w.l.append("code", { object: "r1-diagnosis", version, locations: [] });
w.open("r1-diagnosisVerification-1", "diagnosisVerification", 1, ["verifier-r1-diagnosis"]);
w.sub("r1-diagnosisVerification-1", "verifier-r1-diagnosis", "diagnosisVerification", "r1-diagnosis", null, {
experiments: [
{ experiment: "d1", followed: true, nothingElse: true, where: "initial.py:4" },
{ experiment: "d2", followed: false, nothingElse: true, where: "没找到" },
],
body: "无",
});
w.close("r1-diagnosisVerification-1");
}
test("诊断运行:核验没通过又没有修复次数的实验拿掉,只跑剩下的实验;逐个实验判分,结算里拿掉的记证据不足", { timeout: 180_000 }, async (t) => {
const fixture = await setup(t, "settle", 0);
const { s } = fixture;
selectedAndVerified(fixture);
t.after(() => { docker(["rm", "-f", containerName(s.frozen.config.studyId, "r1-diagnosis-1")]); });
assert.equal(await diagnosisRun(s, 1, "r1-diagnosis-1"), "done");
const records = s.ledger.records();
const planned = records.find((x) => x.kind === "runPlanned" && x.content.purpose === "diagnosis")!;
if (planned.kind !== "runPlanned") throw new Error("缺登记");
assert.deepEqual({ round: planned.content.round, object: planned.content.object }, { round: 1, object: "r1-diagnosis" });
// 只调 d1,总时限 120 秒由它一个实验独享。
const { command } = planned.content.definition;
assert.deepEqual(command.slice(command.indexOf("--experiments")), ["--experiments", "d1"]);
assert.equal(command[command.indexOf("--seconds-each") + 1], "120");
assert.equal(records.filter((x) => x.kind === "runStarted" && x.content.run === "r1-diagnosis-1").length, 1);
const result = records.findLast((x) => x.kind === "runResult")!;
if (result.kind !== "runResult") throw new Error("缺结果");
assert.equal(result.content.process.ending, "exited");
assert.equal(result.content.judged, null);
assert.deepEqual(runBalance(13, new Board(records).runStates()), { charged: 2, reserved: 0, balance: 11 });
await diagnosisSettle(s, 1);
const settled = s.ledger.records().find((x) => x.kind === "diagnosisSettlement")!;
if (settled.kind !== "diagnosisSettlement") throw new Error("缺结算");
const input: SettleDiagnosisInput = {
baseline: BASELINE_SUM,
direction: "maximize",
epsilon: s.frozen.facts.epsilon,
lambda: 0.1,
experiments: [
{ experiment: "d1", outcome: { kind: "valid", score: BASELINE_SUM } },
{ experiment: "d2", outcome: { kind: "insufficient" } },
],
explanations: (["a1", "a2"] as const).map((author) => ({
id: `r1-${author}-dx1`,
author,
experiments: ["d1", "d2"].map((experiment, i) => ({ experiment, range: RANGES[author][i]! })),
})),
};
const { judged: j, dropped, run, ...rest } = settled.content.result as unknown as { judged: DiagnosisJudged; dropped: Array<{ experiment: string; reason: string }>; run: string };
assert.equal(run, "r1-diagnosis-1");
assert.deepEqual(rest, { input, output: settleDiagnosis(input) });
// 判分只有 d1 一项:拿掉的 d2 没跑、没判。
assert.equal(j.experiments.length, 1);
const d1 = j.experiments[0]!;
assert.deepEqual([d1.id, d1.ending, d1.verdict, d1.score], ["d1", "finished", "valid", { status: "measured", value: BASELINE_SUM }]);
assert.deepEqual(dropped.map((d) => d.experiment), ["d2"]);
assert.match(dropped[0]!.reason, /第 1 次核验仍不通过,修复次数上限 0 已用完:没照写法做;没找到/);
// 结算写进去以后诊断运行公开;每轮最多一条结算。
assert.ok(new Board(s.ledger.records()).runs().filter((x) => x.plan.purpose === "diagnosis").every((x) => new Board(s.ledger.records()).runPublic(x)));
assert.throws(() => s.ledger.append("diagnosisSettlement", { round: 1, result: null }));
});
/** 手工把一次诊断运行做到"登记了":和 diagnosisRun 同样的目录准备,但不启动(启动由各个测试自己决定)。 */
function planned(s: Study, version: string): { run: string; name: string; dir: string } {
const run = "r1-diagnosis-1";
const paths = runPaths(s.dir, run);
const definition = diagnosisDefinition(s.task, s.frozen.image, String(s.frozen.seed), ["d1"]);
s.ledger.append("runPlanned", { run, purpose: "diagnosis", round: 1, object: "r1-diagnosis", definition });
freshDir(paths.base);
checkoutVersion(workspaceRepo(s.dir), version, freshDir(paths.code));
prepareDiagnosisRun(s.task, paths.code, runPaths(s.dir, "base-1").run, freshDir(paths.run), [{ id: "d1", metrics: [] }]);
return { run, name: containerName(s.frozen.config.studyId, run), dir: paths.run };
}
test("诊断运行恢复:容器已启动、账本没写确认启动和结果,接上原容器不重跑,只扣一次,运行记录不带判分,判分从运行目录重做", { timeout: 180_000 }, async (t) => {
const { s, version } = await setup(t, "recover", 0);
const { run, name, dir } = planned(s, version);
t.after(() => { docker(["rm", "-f", name]); });
assert.deepEqual(await s.runner.start(name, diagnosisDefinition(s.task, s.frozen.image, String(s.frozen.seed), ["d1"]), dir), { started: true });
// 中断点:容器已启动,账本只有登记。恢复不调用 diagnosisRun,而是找到原容器等它。
assert.equal(await recover(s), null);
const records = s.ledger.records();
assert.deepEqual(["runPlanned", "runStarted", "runResult"].map((kind) => records.filter((x) => x.kind === kind && (x.content as { run?: string }).run === run).length), [1, 1, 1]);
const result = records.findLast((x) => x.kind === "runResult")!;
if (result.kind !== "runResult") throw new Error("缺结果");
assert.equal(result.content.run, run);
assert.equal(result.content.process.ending, "exited");
assert.equal(result.content.judged, null);
assert.deepEqual(runBalance(13, new Board(records).runStates()), { charged: 2, reserved: 0, balance: 11 });
assert.equal(await s.runner.status(name), "absent");
assert.equal(await recover(s), null);
assert.equal(s.ledger.records().length, records.length);
// 恢复后的判分:从运行目录重判,逐个实验单独判分。
const judgedRun = await judgeDiagnosisRun(s, run, result.content.process, [{ id: "d1", metrics: [] }]);
assert.equal(judgedRun.experiments.length, 1);
const d1 = judgedRun.experiments[0]!;
assert.deepEqual([d1.id, d1.ending, d1.verdict, d1.score], ["d1", "finished", "valid", { status: "measured", value: BASELINE_SUM }]);
});
test("诊断运行恢复:登记了、容器不存在,确认没启动:写结果没启动、退还名额,不补启动", { timeout: 120_000 }, async (t) => {
const { s, version } = await setup(t, "notstarted", 0);
planned(s, version);
assert.equal(await recover(s), null);
const records = s.ledger.records();
const result = records.findLast((x) => x.kind === "runResult")!;
if (result.kind !== "runResult") throw new Error("缺结果");
assert.equal(result.content.process.ending, "notStarted");
assert.equal(result.content.judged, null);
assert.equal(records.filter((x) => x.kind === "runStarted" && x.content.run === "r1-diagnosis-1").length, 0);
assert.deepEqual(runBalance(13, new Board(records).runStates()), { charged: 1, reserved: 0, balance: 12 });
});
+185
View File
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { appendFileSync, cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { stepList } from "../src/config/index.ts";
import { fullConfig as checkedFull } from "../src/flow/frozen.ts";
import { runPaths, workspaceRepo } from "../src/flow/paths.ts";
import { groupSameExperiment, selectDiagnosisExperiments } from "../src/flow/steps/diagnosis.ts";
import { prepareSubmission, taskArtifact, taskCode } from "../src/flow/task-code.ts";
import type { Study } from "../src/flow/study.ts";
import { Ledger } from "../src/ledger/index.ts";
import { runDiagnosisRule, type DiagnosisRuleInput } from "../src/rules/index.ts";
import type { ForwardCode } from "../src/submissions/index.ts";
import { checkTaskFacts, diagnosisUnversionedNames, loadTask, placeBaselineArtifact } from "../src/tasks/index.ts";
import type { JevJudgment } from "../src/vocab/index.ts";
import { compareVersions, createRepository, saveVersion } from "../src/workspace/index.ts";
import { fullConfig } from "./config-helpers.ts";
import { tempDir } from "./ledger-helpers.ts";
import { judged, r, Writer } from "./materials-helpers.ts";
// 诊断里程序自己做的两步(任务书 18 第 1.6 节):判重合并的分组、选诊断实验;加上诊断实现、修复、核验的取材和存版本接线。判重合并的 Jev 问法由 jev 的测试管;
// 诊断运行和结算要真实 Docker,在 flow-diagnosis-docker.test.ts。预期值照条文手拼,输出用 rules 的函数当对照
// (同选格测试的写法,rules 自己有独立预期值的测试)。
const facts = checkTaskFacts(JSON.parse(readFileSync(join(import.meta.dirname, "..", "tasks", "circle_packing", "task.json"), "utf8")));
const yes = (probability: number): JevJudgment => ({ question: "diagnosisDedup", probability, threshold: 0.3, yes: probability > 0.3 }) as unknown as JevJudgment;
const item = (author: string, index: number) => ({ author, index, label: `${author}.e${index + 1}` });
test("诊断实验判重合并:判为相同的连成一组,编号 d1… 按组里排最前的实验排,组内按作者、第几个排", () => {
const items = [item("researcher-1", 0), item("researcher-1", 1), item("researcher-2", 0), item("researcher-3", 0)];
const pairs = [
{ a: "researcher-1.e1", b: "researcher-2.e1", judgment: yes(0.9) },
{ a: "researcher-1.e2", b: "researcher-3.e1", judgment: yes(0.1) },
{ a: "researcher-2.e1", b: "researcher-3.e1", judgment: yes(0.8) },
];
// researcher-1.e1、researcher-2.e1、researcher-3.e1 连成一组(A=B,B=C);researcher-1.e2 自成一个。
assert.deepEqual(groupSameExperiment(items, pairs), {
experiments: [
{ experiment: "d1", sources: [{ author: "researcher-1", index: 0 }, { author: "researcher-2", index: 0 }, { author: "researcher-3", index: 0 }] },
{ experiment: "d2", sources: [{ author: "researcher-1", index: 1 }] },
],
});
});
test("诊断实验判重合并:没有一对判相同时,每个实验各成一个", () => {
const items = [item("researcher-1", 0), item("researcher-2", 0)];
const pairs = [{ a: "researcher-1.e1", b: "researcher-2.e1", judgment: yes(0.1) }];
assert.deepEqual(groupSameExperiment(items, pairs).experiments.map((e) => e.experiment), ["d1", "d2"]);
});
/** 基线 0.96;a1、a2 各一条诊断解释,对合并后的 d1、d2 各给三个数;余额:13 次里基线用了 1 次。 */
function study(t: TestContext, ranges: Record<string, ReturnType<typeof r>[]>): { s: Study; input: DiagnosisRuleInput } {
const ledger = Ledger.open(join(tempDir(t), "ledger.db"));
t.after(() => ledger.close());
const w = new Writer(ledger);
ledger.append("code", { object: "base", version: "v-base", locations: null });
w.run("base-1", "baseline", 0, null, judged(0.96, "main"));
w.open("r1-diagnosisProposal", "diagnosisProposal", 1, ["a1", "a2"]);
const experiment = (name: string) => ({ name, spot: `${name}的位置`, body: `${name}的做法`, metrics: [] });
for (const [author, name] of [["a1", "实验甲"], ["a2", "实验乙"]] as const) {
w.sub("r1-diagnosisProposal", author, "diagnosisProposal", null, null, { explanations: [{ body: `${author}的解释` }], experiments: [experiment(name)] });
}
w.close("r1-diagnosisProposal");
w.decision("diagnosisDedup", 1, null, {
experiments: [{ experiment: "d1", sources: [{ author: "a1", index: 0 }] }, { experiment: "d2", sources: [{ author: "a2", index: 0 }] }],
});
w.open("r1-diagnosisPrediction", "diagnosisPrediction", 1, ["a1", "a2"]);
for (const author of ["a1", "a2"]) {
const mine = ranges[author]!;
w.sub("r1-diagnosisPrediction", author, "diagnosisPrediction", null, null, {
predictions: ["d1", "d2"].map((d, i) => ({ explanation: `r1-${author}-dx1`, experiment: d, range: mine[i]! })),
body: "理由",
});
}
w.close("r1-diagnosisPrediction");
const config = fullConfig();
if (config.flow.preset !== "full") throw new Error("测试配置应是完整流程");
config.flow = { ...config.flow, diagnosis: { enabled: true, perResearcher: 2, maxExperiments: 4 }, ablation: { enabled: false } };
const frozen = { config: checkedFull(config), steps: stepList(config), facts, prompts: null, image: "", seed: 1 };
const input: DiagnosisRuleInput = {
baseline: 0.96,
direction: "maximize",
lambda: 0.1,
experiments: ["d1", "d2"],
explanations: ["a1", "a2"].map((author) => ({
id: `r1-${author}-dx1`,
author,
experiments: ["d1", "d2"].map((experiment, i) => ({ experiment, range: ranges[author]![i]! })),
})),
balance: 12,
batchCap: 2,
maxExperiments: 4,
u: 0.5,
decay: 0.5,
};
return { s: { ledger, frozen } as unknown as Study, input };
}
test("选诊断实验:输入照 DiagnosisRuleInput 拼(基线、每条解释对每个实验的三个数、余额从本轮之前的运行算),规则取步骤清单,种子 null", (t) => {
// 两人对 d1 预测相反(一个说涨到 1.3,一个说跌到 0.9),对 d2 都说涨不了多少。
const { s, input } = study(t, {
a1: [r(1.2, 1.3, 1.4), r(0.95, 0.97, 0.99)],
a2: [r(0.8, 0.9, 1.0), r(0.95, 0.97, 0.99)],
});
selectDiagnosisExperiments(s, 1);
const record = s.ledger.records().find((x) => x.kind === "decision" && x.content.decision === "selectDiagnosis")!;
const { rule: used, ...output } = runDiagnosisRule("valueMinusCost", input);
assert.deepEqual(record.content, { decision: "selectDiagnosis", round: 1, rule: "valueMinusCost", ruleVersion: used.version, params: output.params, seed: null, input, output });
// 分歧大的实验信息价值为正:本轮做诊断,d1 在选中的组合里。
assert.equal(output.diagnose, true);
assert.ok(output.chosen.includes("d1"));
});
/** 诊断实现的代码接线:真实裸仓库里存了基线;工作目录是基线副本加一处改动,再放进只读的原始产物。 */
function codeStudy(t: TestContext): { s: Study; work: string; baseline: string } {
const dir = tempDir(t);
const ledger = Ledger.open(join(dir, "ledger.db"));
t.after(() => ledger.close());
const task = loadTask(join(import.meta.dirname, "..", "tasks", "circle_packing"));
const repo = workspaceRepo(dir);
createRepository(repo);
const names = diagnosisUnversionedNames(task);
const saved = saveVersion(repo, task.baselineDir, names);
if (!saved.saved) throw new Error("基线存不成版本");
const w = new Writer(ledger);
ledger.append("code", { object: "base", version: saved.version, locations: null });
w.run("base-1", "baseline", 0, null, judged(0.96, "main"));
const baseRun = runPaths(dir, "base-1").run;
mkdirSync(baseRun, { recursive: true });
writeFileSync(join(baseRun, "solution.json"), "{}");
w.open("r1-diagnosisProposal", "diagnosisProposal", 1, ["a1"]);
const metric = { id: "note", meaning: "记录", unit: "无", direction: "maximize" as const, howMeasured: "数一数", missing: "recordMissing" as const };
w.sub("r1-diagnosisProposal", "a1", "diagnosisProposal", null, null, { explanations: [{ body: "解释" }], experiments: [{ name: "实验甲", spot: "第一步", body: "换个做法", metrics: [metric] }] });
w.close("r1-diagnosisProposal");
w.decision("diagnosisDedup", 1, null, { experiments: [{ experiment: "d1", sources: [{ author: "a1", index: 0 }] }] });
w.decision("selectDiagnosis", 1, null, { diagnose: true, chosen: ["d1"] });
const work = join(dir, "work");
cpSync(task.baselineDir, work, { recursive: true });
appendFileSync(join(work, "initial.py"), "\n\ndef run_experiment(experiment_id, baseline_dir):\n return [], [], 0.0\n");
placeBaselineArtifact(task, baseRun, work);
const frozen = { config: checkedFull(fullConfig()), steps: stepList(fullConfig()), facts: task.facts, prompts: null, image: "", seed: 1 };
return { s: { dir, task, ledger, frozen } as unknown as Study, work, baseline: saved.version };
}
const dxTask = (kind: "diagnosisImplementation" | "diagnosisRepair" | "diagnosisVerification") =>
({ actor: "x", kind, object: "r1-diagnosis", stageId: `r1-${kind}-1` });
test("诊断实现的接线:存版本时跳过只读的原始产物目录;Jev 对照选中实验的原文和全名指标;工作目录放进原始产物", (t) => {
const { s, work, baseline } = codeStudy(t);
const w = new Writer(s.ledger);
w.open("r1-diagnosisImplementation-1", "diagnosisImplementation", 1, ["x"]);
const content = { locations: [{ experiment: "d1", locations: ["initial.py:90-92"] }], concerns: "无", body: "无" };
const { known, code } = prepareSubmission(s, dxTask("diagnosisImplementation"), content, 0, work);
const saved = (known as { code: ForwardCode }).code;
if (!saved.saved) throw new Error("应存成版本");
assert.deepEqual(saved.protectedFiles, []);
assert.ok(saved.hunks.length > 0);
assert.ok(saved.hunks.every((h) => h.path === "initial.py"));
assert.match(saved.protocol.body, /### d1 实验甲\n\n动哪一处:第一步\n\n换个做法/);
assert.deepEqual(saved.protocol.metrics.map((m) => m.id), ["d1.note"]);
assert.deepEqual(code, { object: "r1-diagnosis", version: code!.version, locations: content.locations });
// 工作目录里放着的原始产物没有进版本:当前基线到诊断程序的改动里只有 initial.py。
const changes = compareVersions(workspaceRepo(s.dir), baseline, code!.version);
assert.deepEqual(changes.files.map((f) => f.path), ["initial.py"]);
});
test("诊断实现、修复、核验取材:实现从当前基线出发,修复从上次提交的诊断程序出发,核验和修复的差异都是当前基线到诊断程序;原始产物只给实现和修复", (t) => {
const { s, work, baseline } = codeStudy(t);
const w = new Writer(s.ledger);
const content = { locations: [{ experiment: "d1", locations: ["initial.py:90-92"] }], concerns: "无", body: "无" };
w.open("r1-diagnosisImplementation-1", "diagnosisImplementation", 1, ["x"]);
w.open("r1-diagnosisRepair-1", "diagnosisImplementation", 1, ["x"]);
w.open("r1-diagnosisVerification-1", "diagnosisVerification", 1, ["v"]);
assert.deepEqual(taskCode(s, dxTask("diagnosisImplementation")), { changes: null, startVersion: baseline });
const { code } = prepareSubmission(s, dxTask("diagnosisImplementation"), content, 0, work);
s.ledger.append("code", code!);
const changes = compareVersions(workspaceRepo(s.dir), baseline, code!.version);
assert.deepEqual(taskCode(s, dxTask("diagnosisRepair")), { changes, startVersion: code!.version });
assert.deepEqual(taskCode(s, dxTask("diagnosisVerification")), { changes, startVersion: null });
const baseRun = runPaths(s.dir, "base-1").run;
assert.equal(taskArtifact(s, dxTask("diagnosisImplementation")), baseRun);
assert.equal(taskArtifact(s, dxTask("diagnosisRepair")), baseRun);
assert.equal(taskArtifact(s, dxTask("diagnosisVerification")), null);
});
+217 -13
View File
@@ -4,11 +4,11 @@ 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 { Board, forwardNotRunEvent, type StudyRecord } from "../src/materials/index.ts";
import { Board, diagnosisNotRunEvent, forwardNotRunEvent, type StudyRecord } from "../src/materials/index.ts";
import { runBalance } from "../src/rules/index.ts";
import { definition, exited } from "./ledger-helpers.ts";
import { judged } from "./materials-helpers.ts";
import { nextStep } from "../src/flow/next-step.ts";
import { diagnosisProgram, nextStep } from "../src/flow/next-step.ts";
import type { Json, Submission, SubmissionKind } from "../src/vocab/index.ts";
import { fullConfig } from "./config-helpers.ts";
@@ -217,10 +217,30 @@ function assigned(book: Book): Book {
return book.add("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input: null, output });
}
test("认领关了:分配;分配做完:开第 1 轮的提案阶段,名单是全部研究者", () => {
const DIAGNOSIS_STEPS = [
"diagnosisProposal", "diagnosisDedupe", "diagnosisPrediction", "selectDiagnosis",
"diagnosisImplementation", "diagnosisRun", "diagnosisSettle",
];
/** 诊断关着(fullConfig 的写法):第 round 轮开头的诊断七步各记一条按配置跳过(方法.md 第十一部分"步骤开关")。 */
function diagnosisSkipped(book: Book, round: number): Book {
for (const step of DIAGNOSIS_STEPS) {
assert.deepEqual(nextStep(book.records), { do: "skip", step: `r${round}-${step}`, reason: "配置关掉了诊断" });
book.add("event", { event: "skippedByConfig", reason: "配置关掉了诊断", objects: [`r${round}-${step}`] });
}
return book;
}
/** 第零轮做完、入口分配好,第 1 轮的诊断七步也记完跳过。 */
function inRoundOne(book: Book): Book {
return diagnosisSkipped(assigned(book), 1);
}
test("认领关了:分配;分配做完:诊断关着,七步各记按配置跳过,再开第 1 轮的提案阶段,名单是全部研究者", () => {
const book = afterRoundZero(fullConfig());
assert.deepEqual(nextStep(book.records), { do: "assignEntries" });
assigned(book);
diagnosisSkipped(book, 1);
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-proposal", stage: "proposal", round: 1, roster: R });
});
@@ -246,7 +266,7 @@ const each = (kind: SubmissionKind, objects: string[]) => R.flatMap((actor) => o
/** 第 1 轮写到 last 这一步做完为止。 */
function roundOne(last: "proposal" | "dedupe" | "firstPrediction" | "critique" | "response" | "freezeProtocol" | "secondPrediction" | "selectBatch"): Book {
const book = assigned(afterRoundZero(fullConfig()));
const book = inRoundOne(afterRoundZero(fullConfig()));
const order = ["proposal", "dedupe", "firstPrediction", "critique", "response", "freezeProtocol", "secondPrediction", "selectBatch"];
const steps: Record<string, () => void> = {
proposal: () => stage(book, "r1-proposal", "proposal", [["researcher-1", "proposal", null, P1], ["researcher-2", "proposal", null, P2], ["researcher-3", "proposal", null, P3]]),
@@ -278,7 +298,7 @@ function roundOne(last: "proposal" | "dedupe" | "firstPrediction" | "critique" |
}
test("提案阶段一人交了两条、两人没交:只派没交的两人,提案没有对象", () => {
const book = assigned(afterRoundZero(fullConfig())).open("r1-proposal", "proposal", 1, R).sub("r1-proposal", "researcher-1", "proposal", null, P1);
const book = inRoundOne(afterRoundZero(fullConfig())).open("r1-proposal", "proposal", 1, R).sub("r1-proposal", "researcher-1", "proposal", null, P1);
book.sub("r1-proposal", "researcher-1", "proposal", null, "r1-researcher-1-2");
const tasks = ["researcher-2", "researcher-3"].map((actor) => ({ actor, kind: "proposal", object: null }));
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-proposal", tasks });
@@ -350,7 +370,7 @@ test("关掉第一次预测:第 1 轮写一条按配置跳过(对象 r1-firs
const config = fullConfig();
if (config.flow.preset !== "full") throw new Error("测试配置应是完整流程");
config.flow.firstPrediction = false;
const book = assigned(afterRoundZero(config));
const book = inRoundOne(afterRoundZero(config));
stage(book, "r1-proposal", "proposal", [["researcher-1", "proposal", null, P1], ["researcher-2", "proposal", null, P2], ["researcher-3", "proposal", null, P3]]);
const output = { fresh: [{ candidate: "r1-c1", proposals: [P1] }, { candidate: "r1-c2", proposals: [P2, P3] }], carried: [] };
book.add("decision", { decision: "dedup", round: 1, rule: "jevSameChange", ruleVersion: 1, params: null, seed: null, input: null, output });
@@ -648,7 +668,8 @@ test("结算和押的因子写完:开收尾;收尾关闭后解封本轮选
book.add("unseal", { candidate: "r1-c1", seals: [1] });
assert.deepEqual(nextStep(book.records), { do: "unsealChosen", round: 1 });
book.add("unseal", { candidate: "r1-c2", seals: [2] });
// 余额还有(已扣 5 次):开第 2 轮。
// 余额还有(已扣 5 次):开第 2 轮,先是诊断七步的按配置跳过。
diagnosisSkipped(book, 2);
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r2-proposal", stage: "proposal", round: 2, roster: R });
});
@@ -679,7 +700,7 @@ test("余额为 0:不开下一轮,走到导出停下,原因写明", () =>
assert.deepEqual(nextStep(book.records), { do: "halt", reason: "导出还没写", objects: [] });
});
test("配置关掉消融、又采纳了多因子改法:先记按配置跳过,再在解释停下", () => {
test("配置关掉消融、又采纳了多因子改法:⑬–⑰ 整段(含解释)各记按配置跳过,再开收尾", () => {
const book = firstVerification(batchTwo(1), true, true);
for (const c of C) recordForward(book, c, 1, "valid");
book.add("decision", { decision: "adopt", round: 1, rule: "adopt", ruleVersion: 1, params: null, seed: null, input: null, output: { adopted: ADOPTED } });
@@ -693,9 +714,192 @@ test("配置关掉消融、又采纳了多因子改法:先记按配置跳过
study.config = config as unknown as Json;
study.steps = stepList(config) as unknown as Json;
const reason = "配置关掉了消融";
assert.deepEqual(nextStep(book.records), { do: "skip", step: "r1-listCells", reason });
book.add("event", { event: "skippedByConfig", reason, objects: ["r1-listCells"] });
const next = nextStep(book.records);
assert.equal(next.do, "halt");
assert.match((next as { reason: string }).reason, /不交三个数的解释还没写/);
// 方法.md 4.3 ⑫:解释是 ⑭,和列格、选格、消融、结算一起不做;没有"解释停下"的情形。
for (const step of ["listCells", "explanation", "selectCells", "ablationImplementation", "ablationVerification", "ablationRun", "settle"]) {
assert.deepEqual(nextStep(book.records), { do: "skip", step: `r1-${step}`, reason });
book.add("event", { event: "skippedByConfig", reason, objects: [`r1-${step}`] });
}
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-wrapUp", stage: "wrapUp", round: 1, roster: R });
});
// ---------------------------------------------------------------------------
// 诊断(任务书 18 第 1.6 节,方法.md 4.3 诊 1–诊 4):预期动作手写,不调模型、Jev 或 Docker。
// ---------------------------------------------------------------------------
const DX = "r1-diagnosis";
const DX_ACTOR = "experimenter-r1-diagnosis";
const DX_VERIFIER = "verifier-r1-diagnosis";
/** 诊断开着的配置:A = 2、D = 4(方法.md 6.11 现行值),消融关;maxRepairs 和运行次数可调。 */
function diagnosisConfig(maxRepairs: number, runs: number): ResearchConfig {
const config = { ...fullConfig(), maxRepairs, runs };
if (config.flow.preset !== "full") throw new Error("测试配置应是完整流程");
config.flow = { ...config.flow, diagnosis: { enabled: true, perResearcher: 2, maxExperiments: 4 }, ablation: { enabled: false } };
return config;
}
/** 第零轮做完、入口分配好;诊断开着,第 1 轮的诊断从 r1-diagnosisProposal 开始。 */
function diagnosisRoundOne(maxRepairs: number, runs: number): Book {
return assigned(afterRoundZero(diagnosisConfig(maxRepairs, runs)));
}
const dxTasks = (kind: SubmissionKind) => R.map((actor) => ({ actor, kind, object: null }));
/** 诊 1、诊 2 阶段关闭,两个实验合并成 d1、d2,诊 3 的决定写下。 */
function selected(book: Book, diagnose: boolean, chosen: string[]): Book {
for (const [stageId, stage, kind] of [["r1-diagnosisProposal", "diagnosisProposal", "diagnosisProposal"], ["r1-diagnosisPrediction", "diagnosisPrediction", "diagnosisPrediction"]] as const) {
book.open(stageId, stage, 1, R);
for (const r of R) book.sub(stageId, r, kind, null, null);
book.add("stageClose", { stageId });
if (kind === "diagnosisProposal") {
book.add("decision", { decision: "diagnosisDedup", round: 1, rule: "jevSameExperiment", ruleVersion: 1, params: null, seed: null, input: null, output: { experiments: [] } });
}
}
return book.add("decision", { decision: "selectDiagnosis", round: 1, rule: "valueMinusCost", ruleVersion: 1, params: null, seed: null, input: null, output: { diagnose, chosen } });
}
/** 诊断实现(n = 1)或修复(n ≥ 2)阶段:实验员交一份,关闭。 */
function dxWork(book: Book, n: number): Book {
const stageId = n === 1 ? "r1-diagnosisImplementation-1" : `r1-diagnosisRepair-${n - 1}`;
book.open(stageId, "diagnosisImplementation", 1, [DX_ACTOR]);
book.sub(stageId, DX_ACTOR, n === 1 ? "diagnosisImplementation" : "diagnosisRepair", DX, null);
return book.add("stageClose", { stageId });
}
/** 第 n 次诊断核验:results 是每个选中实验"是否通过"。 */
function dxVerified(book: Book, n: number, results: Record<string, boolean>): Book {
const stageId = `r1-diagnosisVerification-${n}`;
book.open(stageId, "diagnosisVerification", 1, [DX_VERIFIER]);
const content: Submission<"diagnosisVerification"> = {
experiments: Object.entries(results).map(([experiment, ok]) => ({ experiment, followed: ok, nothingElse: true, where: ok ? "initial.py:10" : "没找到" })),
body: "无",
};
book.add("submission", { stageId, author: DX_VERIFIER, kind: "diagnosisVerification", target: DX, item: null, version: 1, content, jev: null });
return book.add("stageClose", { stageId });
}
/** 一次诊断运行:没启动,或启动了(运行记录不带判分)。 */
function dxRun(book: Book, n: number, ending: "valid" | "notStarted"): Book {
const run = `r1-diagnosis-${n}`;
book.add("runPlanned", { run, purpose: "diagnosis", round: 1, object: DX, definition });
if (ending === "notStarted") return book.add("runResult", { run, process: { ending, reason: "Docker 未启动" }, judged: null });
book.add("runStarted", { run });
return book.add("runResult", { run, process: exited, judged: null });
}
const openProposal = { do: "openStage", stageId: "r1-proposal", stage: "proposal", round: 1, roster: R };
test("诊断开着:诊 1 开阶段,每人一个任务、没有对象;交齐关闭后判重合并,再开诊断预测,同样每人一个任务", () => {
const book = diagnosisRoundOne(1, 13);
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-diagnosisProposal", stage: "diagnosisProposal", round: 1, roster: R });
book.open("r1-diagnosisProposal", "diagnosisProposal", 1, R);
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-diagnosisProposal", tasks: dxTasks("diagnosisProposal") });
for (const r of R) book.sub("r1-diagnosisProposal", r, "diagnosisProposal", null, null);
assert.deepEqual(nextStep(book.records), { do: "closeStage", stageId: "r1-diagnosisProposal" });
book.add("stageClose", { stageId: "r1-diagnosisProposal" });
assert.deepEqual(nextStep(book.records), { do: "diagnosisDedupe", round: 1 });
book.add("decision", { decision: "diagnosisDedup", round: 1, rule: "jevSameExperiment", ruleVersion: 1, params: null, seed: null, input: null, output: { experiments: [] } });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-diagnosisPrediction", stage: "diagnosisPrediction", round: 1, roster: R });
book.open("r1-diagnosisPrediction", "diagnosisPrediction", 1, R);
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-diagnosisPrediction", tasks: dxTasks("diagnosisPrediction") });
for (const r of R) book.sub("r1-diagnosisPrediction", r, "diagnosisPrediction", null, null);
book.add("stageClose", { stageId: "r1-diagnosisPrediction" });
assert.deepEqual(nextStep(book.records), { do: "selectDiagnosis", round: 1 });
});
test("余额不到 2 次:诊 1–诊 4 都不做,不开阶段、不写记录,直接开提案(运行 2 次,基线用掉 1 次,余额 1)", () => {
const book = diagnosisRoundOne(1, 2);
assert.deepEqual(nextStep(book.records), openProposal);
});
test("诊 3 定了不诊断:诊 4 的几步都不做,直接开提案;决定记录照写", () => {
const book = selected(diagnosisRoundOne(1, 13), false, []);
assert.deepEqual(nextStep(book.records), openProposal);
});
test("诊断实现与核验:一位实验员对本轮诊断,一位核验员逐个实验交结果;全通过就运行,没启动起新编号,已登记没结果要先恢复", () => {
const book = selected(diagnosisRoundOne(1, 13), true, ["d1", "d2"]);
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-diagnosisImplementation-1", stage: "diagnosisImplementation", round: 1, roster: [DX_ACTOR] });
book.open("r1-diagnosisImplementation-1", "diagnosisImplementation", 1, [DX_ACTOR]);
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-diagnosisImplementation-1", tasks: [{ actor: DX_ACTOR, kind: "diagnosisImplementation", object: DX }] });
book.sub("r1-diagnosisImplementation-1", DX_ACTOR, "diagnosisImplementation", DX, null);
book.add("stageClose", { stageId: "r1-diagnosisImplementation-1" });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-diagnosisVerification-1", stage: "diagnosisVerification", round: 1, roster: [DX_VERIFIER] });
dxVerified(book, 1, { d1: true, d2: true });
assert.deepEqual(nextStep(book.records), { do: "diagnosisRun", round: 1, run: "r1-diagnosis-1" });
dxRun(book, 1, "notStarted");
assert.deepEqual(nextStep(book.records), { do: "diagnosisRun", round: 1, run: "r1-diagnosis-2" });
assert.deepEqual(runBalance(13, new Board(book.records).runStates()), { charged: 1, reserved: 0, balance: 12 });
book.add("runPlanned", { run: "r1-diagnosis-2", purpose: "diagnosis", round: 1, object: DX, definition });
assert.throws(() => nextStep(book.records), /诊断运行 r1-diagnosis-2 登记了还没有结果:恢复入口应当已经核对过/);
book.add("runStarted", { run: "r1-diagnosis-2" });
book.add("runResult", { run: "r1-diagnosis-2", process: exited, judged: null });
assert.deepEqual(nextStep(book.records), { do: "diagnosisSettle", round: 1 });
// 没启动的那次退还名额:基线 1 次、诊断 1 次,诊断运行只扣一次。
assert.deepEqual(runBalance(13, new Board(book.records).runStates()), { charged: 2, reserved: 0, balance: 11 });
book.add("diagnosisSettlement", { round: 1, result: null });
assert.deepEqual(nextStep(book.records), openProposal);
});
test("核验有实验不通过:只退回修复,只重验一次;用完修复仍不通过的实验拿掉,跑的只是剩下的实验", () => {
const book = selected(diagnosisRoundOne(1, 13), true, ["d1", "d2"]);
dxWork(book, 1);
dxVerified(book, 1, { d1: true, d2: false });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-diagnosisRepair-1", stage: "diagnosisImplementation", round: 1, roster: [DX_ACTOR] });
book.open("r1-diagnosisRepair-1", "diagnosisImplementation", 1, [DX_ACTOR]);
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-diagnosisRepair-1", tasks: [{ actor: DX_ACTOR, kind: "diagnosisRepair", object: DX }] });
book.sub("r1-diagnosisRepair-1", DX_ACTOR, "diagnosisRepair", DX, null);
book.add("stageClose", { stageId: "r1-diagnosisRepair-1" });
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-diagnosisVerification-2", stage: "diagnosisVerification", round: 1, roster: [DX_VERIFIER] });
dxVerified(book, 2, { d1: true, d2: false });
// 修复次数上限 1 已用完:d2 拿掉,d1 照跑;没有"不跑"事件。
assert.deepEqual(nextStep(book.records), { do: "diagnosisRun", round: 1, run: "r1-diagnosis-1" });
const dropped = diagnosisProgram(new Board(book.records), 1, 1);
assert.deepEqual(dropped.experiments, ["d1"]);
assert.deepEqual(dropped.dropped.map((d) => d.experiment), ["d2"]);
assert.match(dropped.dropped[0]!.reason, /第 2 次核验仍不通过,修复次数上限 1 已用完:没照写法做;没找到/);
});
test("修复后上次通过的实验又不通过:以最近一次核验为准,修复前通过过也不能跑", () => {
const book = selected(diagnosisRoundOne(1, 13), true, ["d1", "d2"]);
dxWork(book, 1);
dxVerified(book, 1, { d1: true, d2: false });
dxWork(book, 2);
dxVerified(book, 2, { d1: false, d2: true });
assert.deepEqual(diagnosisProgram(new Board(book.records), 1, 1).experiments, ["d2"]);
assert.deepEqual(nextStep(book.records), { do: "diagnosisRun", round: 1, run: "r1-diagnosis-1" });
});
test("maxRepairs = 0:第一次核验不通过的实验就拿掉,不开修复阶段", () => {
const book = selected(diagnosisRoundOne(0, 13), true, ["d1", "d2"]);
dxWork(book, 1);
dxVerified(book, 1, { d1: false, d2: true });
assert.deepEqual(nextStep(book.records), { do: "diagnosisRun", round: 1, run: "r1-diagnosis-1" });
assert.deepEqual(diagnosisProgram(new Board(book.records), 1, 0).experiments, ["d2"]);
});
test("选中的实验一个都不剩:写不跑事件(一次),不登记运行、不扣次,不结算,直接开提案", () => {
const book = selected(diagnosisRoundOne(1, 13), true, ["d1", "d2"]);
dxWork(book, 1);
dxVerified(book, 1, { d1: false, d2: false });
dxWork(book, 2);
dxVerified(book, 2, { d1: false, d2: false });
const next = nextStep(book.records);
assert.equal(next.do, "diagnosisNotRun");
if (next.do !== "diagnosisNotRun") throw new Error("应写不跑事件");
assert.equal(next.round, 1);
assert.match(next.reason, /选中的实验第 2 次核验都不通过,修复次数上限 1 已用完:d1:.*;d2:/);
book.add("event", diagnosisNotRunEvent(1, next.reason));
assert.deepEqual(nextStep(book.records), openProposal);
assert.deepEqual(runBalance(13, new Board(book.records).runStates()), { charged: 1, reserved: 0, balance: 12 });
});
test("诊断运行扣次以后:余额只算本轮开始之前的运行,诊断这一段的后几步不会算成不诊断", () => {
// 运行 3 次:本轮开始前余额 2,诊断运行扣 1 次后余额 1。诊断这一段的后几步照常走,不会因余额变小算成"不诊断"。
const book = selected(diagnosisRoundOne(1, 3), true, ["d1"]);
dxWork(book, 1);
dxVerified(book, 1, { d1: true });
dxRun(book, 1, "valid");
assert.equal(runBalance(3, new Board(book.records).runStates()).balance, 1);
assert.deepEqual(nextStep(book.records), { do: "diagnosisSettle", round: 1 });
});
+59 -1
View File
@@ -3,7 +3,17 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { checkHunk, checkProtocol, codeHunks, judgeSameChange, type FrozenProtocol, type ProtocolRules } from "../src/jev/index.ts";
import {
checkExperiment,
checkHunk,
checkProtocol,
codeHunks,
judgeSameChange,
judgeSameExperiment,
type DiagnosisExperiment,
type FrozenProtocol,
type ProtocolRules,
} from "../src/jev/index.ts";
import { hash, type Json } from "../src/vocab/index.ts";
import { compareVersions, createRepository, saveVersion } from "../src/workspace/index.ts";
@@ -307,3 +317,51 @@ test("把 workspace 的比较结果切成差异块,每块从 @@ 行起", (t) =
assert.equal(hunks[0]?.text, "@@ -1,5 +1,5 @@\n line 1\n-line 2\n+changed 2\n line 3\n line 4\n line 5");
assert.equal(hunks[2]?.text, "@@ -0,0 +1 @@\n+x = 1");
});
// ---------------------------------------------------------------------------
// 诊断实验的判重和冻结检查(方法.md 4.3 诊 1、诊 2)
// ---------------------------------------------------------------------------
const EXPERIMENT: DiagnosisExperiment = {
name: "拿掉收尾",
spot: "solve.py 的收尾修复",
body: "拿掉收尾修复,直接交出原始答案",
metrics: [{ id: "raw-score", meaning: "收尾之前的分数", unit: "无", direction: "maximize", howMeasured: "收尾之前的分数", missing: "recordMissing" }],
};
test("诊断实验判重:一对一次请求,只给名称、动哪一处、怎么动;阈值暂用判重的 0.3", async (t) => {
const sent = fakeJev(t, answerAll(() => 0.31));
const other = { name: "强起点", spot: "solve.py 的起点", body: "换成更强的起点" };
const j = await judgeSameExperiment("jev-x", EXPERIMENT, other);
assert.equal(sent.length, 1);
assert.deepEqual(Object.keys(sent[0]!.body.questions), ["same_experiment"]);
assert.deepEqual(sent[0]!.body.state.experiment_a, { name: "拿掉收尾", spot: "solve.py 的收尾修复", body: "拿掉收尾修复,直接交出原始答案" });
assert.deepEqual(sent[0]!.body.state.experiment_b, other);
assert.deepEqual([j.question, j.threshold, j.yes, j.requestId], ["same_experiment", 0.3, true, "req_1"]);
});
test("诊断实验判重:概率等于阈值判否", async (t) => {
fakeJev(t, answerAll(() => 0.3));
assert.equal((await judgeSameExperiment("jev-x", EXPERIMENT, EXPERIMENT)).yes, false);
});
test("诊断实验冻结检查:三问各一次请求,材料相同,问法是诊断的变体;有一问判是就标出", async (t) => {
const sent = fakeJev(t, answerAll((name) => (name === "changes_measurement" ? 0.22 : 0.1)));
const result = await checkExperiment("jev-x", EXPERIMENT);
assert.deepEqual(
result.judgments.map((j) => [j.question, j.threshold, j.yes]),
[["self_contradictory", 0.5, false], ["changes_measurement", 0.21, true], ["too_vague", 0.78, false]],
);
assert.equal(result.flagged, true);
assert.deepEqual(sent.map((s) => Object.keys(s.body.questions)), [["self_contradictory"], ["changes_measurement"], ["too_vague"]]);
for (const s of sent) assert.deepEqual(s.body.state, sent[0]!.body.state);
assert.deepEqual(sent[0]!.body.state.experiment, {
name: "拿掉收尾",
spot: "solve.py 的收尾修复",
body: "拿掉收尾修复,直接交出原始答案",
metrics: [{ id: "raw-score", howMeasured: "收尾之前的分数", missing: "recordMissing" }],
});
// 材料说明和问法都是诊断的变体,哈希和冻结协议同名三问的不同。
const protocol = await checkProtocol("jev-x", PROTOCOL);
result.judgments.forEach((j, i) => assert.notEqual(j.questionHash, protocol.judgments[i]!.questionHash, j.question));
});
+1 -1
View File
@@ -80,7 +80,7 @@ export function closeStage(ledger: Ledger, stageId: string): number {
}
/** 登记一次运行。 */
export function planRun(ledger: Ledger, run: string, purpose: "baseline" | "forward" | "ablation", round: number): number {
export function planRun(ledger: Ledger, run: string, purpose: "baseline" | "forward" | "ablation" | "diagnosis", round: number): number {
return ledger.append("runPlanned", { run, purpose, round, object: purpose === "baseline" ? null : `obj-${run}`, definition }).seq;
}
+53 -2
View File
@@ -16,12 +16,13 @@ import {
wrapUp,
} from "./ledger-helpers.ts";
// 写入时由 ledger 拒绝的四种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),每种一条拒收、一条对应的正常情况。
// 写入时由 ledger 拒绝的五种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),每种一条拒收、一条对应的正常情况。
// 预期值来自文字:
// 1. 只收开着的阶段(有开阶段记录、没有关阶段记录)的提交,别的拒收;
// 2. 要替换的那一份不是本人在这个阶段、对同一个对象的最新一版 → 拒收;实现、修复、核验、消融实现、消融核验不能替换(方法.md 第五部分);
// 3. 运行结果写在"确认启动"之前 → 拒收;"没启动"的结果不需要启动记录(方法.md 第七部分);
// 4. 这一轮消融已经有格确认启动了,才来关这一轮的解释阶段 → 拒收(方法.md 4.3 ⑭)。
// 4. 这一轮消融已经有格确认启动了,才来关这一轮的解释阶段 → 拒收(方法.md 4.3 ⑭);
// 5. 这一轮的诊断运行已经确认启动了,才来关这一轮的诊断预测阶段 → 拒收(方法.md 4.3 诊 2);诊断实现、修复、核验和实现、核验一样不能替换。
function submitWrapUp(ledger: Ledger, stageId: string, author: string, version: number, body = "收尾") {
return ledger.append("submission", { stageId, author, target: null, item: null, version, jev: null, kind: "wrapUp", content: wrapUp(body) });
@@ -156,3 +157,53 @@ test("拒收 4 的正常情况:只登记没启动、别的轮的消融启动
closeStage(ledger, "wrap");
assert.equal(ledger.stageClosed("wrap"), true);
});
test("拒收 5:这一轮的诊断运行已经确认启动,就不能关这一轮的诊断预测阶段", (t) => {
const { ledger } = freshLedger(t);
openStage(ledger, "dpred", "diagnosisPrediction", 2);
planRun(ledger, "diagrun", "diagnosis", 2);
ledger.append("runStarted", { run: "diagrun" });
assert.throws(() => closeStage(ledger, "dpred"), /诊断预测冻结必须早于诊断运行启动/);
assert.equal(ledger.stageClosed("dpred"), false);
});
test("拒收 5 的正常情况:只登记没启动、别的轮的诊断启动、本轮消融或正向启动,都不挡关诊断预测阶段;消融启动也不被诊断运行连累", (t) => {
const { ledger } = freshLedger(t);
openStage(ledger, "dpred", "diagnosisPrediction", 2);
planRun(ledger, "diag-planned", "diagnosis", 2); // 只登记,还没确认启动
planRun(ledger, "diag-round1", "diagnosis", 1);
ledger.append("runStarted", { run: "diag-round1" });
planRun(ledger, "fwd-round2", "forward", 2);
ledger.append("runStarted", { run: "fwd-round2" });
closeStage(ledger, "dpred");
assert.equal(ledger.stageClosed("dpred"), true);
// 诊断运行启动后,解释阶段照旧关得掉:两种用途各管各的。
openStage(ledger, "expl", "explanation", 2);
planRun(ledger, "diag-round2", "diagnosis", 2);
ledger.append("runStarted", { run: "diag-round2" });
closeStage(ledger, "expl");
assert.equal(ledger.stageClosed("expl"), true);
});
test("拒收 2:诊断实现、诊断修复、诊断核验只收第一版,不能替换;诊断预测可以替换", (t) => {
const { ledger } = freshLedger(t);
openStage(ledger, "dimpl", "diagnosisImplementation", 1, ["e1"]);
openStage(ledger, "dver", "diagnosisVerification", 1, ["v1"]);
openStage(ledger, "dpred", "diagnosisPrediction", 1);
const base = { author: "e1", target: "r1-diagnosis", item: null };
const code = { locations: [{ experiment: "d1", locations: ["solve.py:3-9"] }], concerns: "无", body: "无" };
const cases = [
{ ...base, stageId: "dimpl", kind: "diagnosisImplementation" as const, content: code },
{ ...base, stageId: "dimpl", author: "e2", kind: "diagnosisRepair" as const, content: code },
{ ...base, author: "v1", stageId: "dver", kind: "diagnosisVerification" as const, content: { experiments: [], body: "无" } },
];
for (const c of cases) {
ledger.append("submission", { ...c, version: 1, jev: null });
assert.throws(() => ledger.append("submission", { ...c, version: 2, jev: null }), /不能替换/, c.kind);
}
const slot = { stageId: "dpred", author: "r1", target: null, item: null, kind: "diagnosisPrediction" as const };
const content = { predictions: [{ explanation: "r1-r1-dx1", experiment: "d1", range: range(1, 2, 3) }], body: "理由" };
ledger.append("submission", { ...slot, version: 1, jev: null, content });
ledger.append("submission", { ...slot, version: 2, jev: null, content });
assert.equal(ledger.latestSubmission(slot)?.content.version, 2);
});
+16 -6
View File
@@ -438,6 +438,16 @@ test("关掉再打开:读出的记录不变,序号接着往下排", (t) => {
assert.equal(reopened.append("runStarted", { run: "run1" }).seq, 9);
});
test("唯一约束:诊断结算按轮次,同一轮两次报错;同一轮的消融结算另算,不受它影响", (t) => {
const { ledger, path } = freshLedger(t);
ledger.append("diagnosisSettlement", { round: 1, result: { experiments: [] } });
assert.throws(() => ledger.append("diagnosisSettlement", { round: 1, result: { experiments: [] } }), /diagnosis_settlement_once/);
ledger.append("diagnosisSettlement", { round: 2, result: { experiments: [] } });
ledger.append("settlement", { round: 1, result: { cells: {} } });
assert.throws(() => ledger.append("diagnosisSettlement", { round: 3, run: "x", result: {} } as never), /diagnosisSettlement/);
assert.equal(countRows(path), 3);
});
test("打开:表结构版本不对报错;不是空文件也不是账本的库报错", (t) => {
const dir = tempDir(t);
const wrongVersion = join(dir, "v99.db");
@@ -446,12 +456,12 @@ test("打开:表结构版本不对报错;不是空文件也不是账本的
db.close();
assert.throws(() => Ledger.open(wrongVersion), /版本是 99/);
// 版本 3 是退回还没有对象字段时的账本,不迁移,打开报错。
const previous = join(dir, "v3.db");
const v3 = new DatabaseSync(previous);
v3.exec("PRAGMA user_version = 3");
v3.close();
assert.throws(() => Ledger.open(previous), /版本是 3.*只认版本 4/s);
// 版本 4 是还没有诊断结算和诊断运行用途时的账本,不迁移,打开报错。
const previous = join(dir, "v4.db");
const v4 = new DatabaseSync(previous);
v4.exec("PRAGMA user_version = 4");
v4.close();
assert.throws(() => Ledger.open(previous), /版本是 4.*只认版本 5/s);
const foreign = join(dir, "foreign.db");
const other = new DatabaseSync(foreign);
+337
View File
@@ -0,0 +1,337 @@
import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";
import { Ledger } from "../src/ledger/index.ts";
import {
Board,
buildMaterial,
type CodeChanges,
diagnosisExplanationId,
diagnosisId,
diagnosisNotRunEvent,
type DiagnosisSettlement,
type StudyFacts,
type TaskMaterial,
} from "../src/materials/index.ts";
import { selectDiagnosis, settleDiagnosis, type DiagnosisRuleInput, type SettleDiagnosisInput } from "../src/rules/index.ts";
import type { SubmissionKind } from "../src/vocab/index.ts";
import { freshLedger } from "./ledger-helpers.ts";
import { batch, CHANGES_C1, everything, judged, material, NONE, P_A1, prompts, protocolOf, r, study, TABLE_C1, Writer } from "./materials-helpers.ts";
// 诊断的材料(任务书 18 第 1.5 节、方法.md 第三部分、4.3 诊 1–诊 4):在真实账本里写一轮诊断,看每个任务该有的有、不该有的没有。
// 研究者 a1、a2,实验员 x1,核验员 v1。基线 0.96。三个合并后的诊断实验:d1(a1、a2 的"强起点"合并)、d2(a1 的)、d3(a2 的第二个)。
// a1 的诊断预测数 7.771…、a2 的 8.881…是好认的数:任何别人的材料里都不该出现。
// 选实验的决定、结算都用 rules 的函数算(不手写数);这里只查材料怎么读它们。
const SEALED = { a1: ["7.771", "7.772", "7.773"], a2: ["8.881", "8.882", "8.883"] };
const D = diagnosisId(1);
const headings = (m: TaskMaterial) => m.sections.map((s) => s.heading);
const has = (m: TaskMaterial, text: string) => assert.ok(everything(m).includes(text), `材料里应有"${text}"`);
const lacks = (m: TaskMaterial, text: string) => assert.ok(!everything(m).includes(text), `材料里不该有"${text}"`);
const experiment = (name: string, spot: string) => ({ name, spot, body: `${name}:${spot}上怎么动`, metrics: [] });
const gapExperiment = { ...experiment("拿掉收尾", "solve.py 收尾"), metrics: [{ id: "gap", meaning: "间隙", unit: "无", direction: "minimize" as const, howMeasured: "最后量一次", missing: "recordMissing" as const }] };
/** 两条解释对三个实验的三个数:a1 说 d1 能涨很多,a2 说 d1 涨不了;d2、d3 两人差不多。 */
const ranges = {
[diagnosisExplanationId(1, "a1", 1)]: { author: "a1", d1: r(1.1, 1.25, 1.3), d2: r(0.95, 1.0, 1.05), d3: r(0.95, 1.0, 1.05) },
[diagnosisExplanationId(1, "a2", 1)]: { author: "a2", d1: r(0.9, 0.95, 0.99), d2: r(0.95, 1.0, 1.05), d3: r(0.95, 1.0, 1.05) },
};
const ruleInput = (balance: number, same: boolean): DiagnosisRuleInput => ({
baseline: 0.96,
direction: "maximize",
lambda: 0.1,
experiments: ["d1", "d2", "d3"],
explanations: Object.entries(ranges).map(([id, x]) => ({
id,
author: x.author,
experiments: (["d1", "d2", "d3"] as const).map((e) => ({ experiment: e, range: same ? ranges[diagnosisExplanationId(1, "a1", 1)]![e] : x[e] })),
})),
balance,
batchCap: 1,
u: 0.5,
decay: 0.5,
maxExperiments: 2,
});
type Step = "diagProposalOpen" | "diagProposalClosed" | "diagPredictionOpen" | "diagPredictionClosed" | "selected" | "implemented" | "verifiedFailed" | "repairOpen" | "run" | "settled";
const ORDER: Step[] = ["diagProposalOpen", "diagProposalClosed", "diagPredictionOpen", "diagPredictionClosed", "selected", "implemented", "verifiedFailed", "repairOpen", "run", "settled"];
/** 第 1 轮诊断写到某一步(含)为止。选中的是净值最大的两个实验;d 里验证不通过的那个在结算里记证据不足。 */
function diagnosisStudy(t: TestContext, upTo: Step): { l: Ledger; w: Writer; chosen: string[] } {
const { ledger: l } = freshLedger(t);
const w = new Writer(l);
const reached = (s: Step) => ORDER.indexOf(upTo) >= ORDER.indexOf(s);
l.append("code", { object: "base", version: "v-base", locations: null });
w.run("run-0", "baseline", 0, null, judged(0.96, "main"));
w.open("s1-dp", "diagnosisProposal", 1, ["a1", "a2"]);
w.sub("s1-dp", "a1", "diagnosisProposal", null, null, {
explanations: [{ body: "甲的诊断解释:卡在起点 UNIQUE-A1" }],
experiments: [experiment("强起点", "solve.py 起点"), gapExperiment],
});
if (!reached("diagProposalClosed")) return { l, w, chosen: [] };
w.sub("s1-dp", "a2", "diagnosisProposal", null, null, {
explanations: [{ body: "乙的诊断解释:卡在收尾 UNIQUE-A2" }],
experiments: [experiment("强起点", "solve.py 起点(乙的写法)"), experiment("放宽间隙", "solve.py 间隙限制")],
});
w.close("s1-dp");
w.decision("diagnosisDedup", 1, null, {
experiments: [
{ experiment: "d1", sources: [{ author: "a1", index: 0 }, { author: "a2", index: 0 }] },
{ experiment: "d2", sources: [{ author: "a1", index: 1 }] },
{ experiment: "d3", sources: [{ author: "a2", index: 1 }] },
],
});
w.open("s1-dpred", "diagnosisPrediction", 1, ["a1", "a2"]);
const pred = (author: "a1" | "a2", numbers: string[]) =>
(["d1", "d2", "d3"] as const).map((e) => ({ explanation: diagnosisExplanationId(1, author, 1), experiment: e, range: e === "d1" ? r(...(numbers.map(Number) as [number, number, number])) : ranges[diagnosisExplanationId(1, author, 1)]![e] }));
w.sub("s1-dpred", "a1", "diagnosisPrediction", null, null, { predictions: pred("a1", SEALED.a1), body: "甲的预测理由" });
if (!reached("diagPredictionClosed")) return { l, w, chosen: [] };
w.sub("s1-dpred", "a2", "diagnosisPrediction", null, null, { predictions: pred("a2", SEALED.a2), body: "乙的预测理由" });
w.close("s1-dpred");
const input = ruleInput(9, false);
const decision = selectDiagnosis(input);
assert.equal(decision.diagnose, true);
const chosen = decision.chosen;
assert.equal(chosen.length, 2);
if (!reached("selected")) return { l, w, chosen };
w.decision("selectDiagnosis", 1, input, decision);
w.open("s1-dimpl", "diagnosisImplementation", 1, ["x1"]);
const table = chosen.map((experiment, i) => ({ experiment, locations: [`solve.py:${10 + 10 * i}-${19 + 10 * i}`] }));
w.sub("s1-dimpl", "x1", "diagnosisImplementation", D, null, { locations: table, concerns: "收尾里有个旧开关", body: "诊断实现说明 UNIQUE-X1" });
w.close("s1-dimpl");
l.append("code", { object: D, version: "v-diag", locations: table as never });
if (!reached("implemented")) return { l, w, chosen };
w.open("s1-dver", "diagnosisVerification", 1, ["v1"]);
w.sub("s1-dver", "v1", "diagnosisVerification", D, null, {
experiments: chosen.map((experiment, i) => ({ experiment, followed: i === 0, nothingElse: true, where: `solve.py:${10 + 10 * i}` })),
body: "核验员:第二个实验没照写法做",
});
w.close("s1-dver");
if (!reached("repairOpen")) return { l, w, chosen };
w.open("s1-dimpl2", "diagnosisImplementation", 1, ["x1"]);
if (!reached("run")) return { l, w, chosen };
w.run("run-1", "diagnosis", 1, D, null); // 诊断运行的记录里没有判分:逐个实验的结果在结算里
if (!reached("settled")) return { l, w, chosen };
const settleInput: SettleDiagnosisInput = {
baseline: 0.96,
direction: "maximize",
epsilon: 0.01,
lambda: 0.1,
experiments: ["d1", "d2", "d3"].map((experiment) => ({
experiment,
outcome: experiment === chosen[0] ? { kind: "valid", score: 1.1 } : experiment === chosen[1] ? { kind: "insufficient" } : { kind: "untested" },
})),
explanations: ruleInput(9, false).explanations,
};
const settlement: DiagnosisSettlement = {
run: "run-1",
judged: {
experiments: [
{
id: chosen[0]!, ending: "finished", wallSeconds: { status: "measured", value: 31.5 }, verdict: "valid", verdictReason: "校验器判合法",
score: { status: "measured", value: 1.1 }, metrics: { "sum-of-radii": { status: "measured", value: 1.1 }, [`${chosen[0]}.gap`]: { status: "measured", value: 0.02 } }, extra: null,
},
],
},
dropped: [{ experiment: chosen[1]!, reason: "第二个实验两次核验都没落实" }],
input: settleInput,
output: settleDiagnosis(settleInput),
};
l.append("diagnosisSettlement", { round: 1, result: settlement as never });
return { l, w, chosen };
}
const mat = (l: Ledger, kind: SubmissionKind, actor: string, stageId: string, object: string | null, extra: { changes: CodeChanges | null; artifacts: Record<string, Record<string, string>> | null } = NONE, facts: StudyFacts = study) =>
buildMaterial({ kind, actor, stageId, object, ledger: l, study: facts, prompts, codeRoot: "/code", ...extra });
test("诊 1:基线代码、基线那次运行的结果和答案、没有别人这一轮的诊断;上限取自配置(A、M)", (t) => {
const { l } = diagnosisStudy(t, "diagProposalOpen");
const m = mat(l, "diagnosisProposal", "a2", "s1-dp", null, { changes: null, artifacts: { "run-0": { "solution.json": "{\"centers\": []}" } } });
assert.deepEqual(m.checklist, {
kind: "diagnosisProposal",
maxExplanations: 2,
maxExperiments: 2,
takenMetricIds: ["sum-of-radii", "wall-seconds", "answer-path", "safeguard-triggers"],
});
assert.deepEqual(m.readable, [{ version: "v-base", label: "本批基线", dir: "/code/v-base" }]);
has(m, "运行 run-0:任务给的原始代码;算数(校验器判合法);分数 0.96");
has(m, "run-0/solution.json");
assert.equal(m.records.find((x) => x.id === "run-0/solution.json")!.text, "{\"centers\": []}");
assert.ok(headings(m).includes("上一轮的收尾"));
assert.ok(!headings(m).includes("本轮诊断结果"), "诊断这一段还没走完,不放本轮诊断结果");
lacks(m, "UNIQUE-A1"); // a1 在同一阶段已经交了,阶段没关,别人读不到
lacks(m, "强起点");
});
test("诊 2:合并后的实验标明作者、全部诊断解释、自己的解释另列并给对号清单;看不到别人在这一步交的数", (t) => {
const { l } = diagnosisStudy(t, "diagPredictionOpen");
const m = mat(l, "diagnosisPrediction", "a2", "s1-dpred", null);
assert.deepEqual(m.checklist, { kind: "diagnosisPrediction", explanations: ["r1-a2-dx1"], experiments: ["d1", "d2", "d3"] });
has(m, "#### d1 强起点(作者 a1、a2)");
has(m, "动哪一处:solve.py 起点\n"); // 合并后的正文取作者里编号最小的那一份(a1)
lacks(m, "solve.py 起点(乙的写法)");
has(m, "#### d3 放宽间隙(作者 a2)");
has(m, "UNIQUE-A1");
has(m, "UNIQUE-A2");
assert.ok(m.sections.some((s) => s.heading === "你的诊断解释" && s.text.includes("r1-a2-dx1")));
for (const x of SEALED.a1) lacks(m, x); // a1 已经交了诊断预测,阶段还开着
});
test("诊断预测的数:关闭之后,也不进任何别人的材料(诊 2 本人、实现、提案都没有)", (t) => {
const { l } = diagnosisStudy(t, "settled");
l.append("stageOpen", { stageId: "s1-prop", stage: "proposal", round: 1, roster: ["a1", "a2"] });
const proposals = [mat(l, "proposal", "a1", "s1-prop", null), mat(l, "proposal", "a2", "s1-prop", null)];
for (const m of proposals) {
for (const x of [...SEALED.a1, ...SEALED.a2]) lacks(m, x);
}
const own = mat(l, "diagnosisPrediction", "a1", "s1-dpred", null);
for (const x of SEALED.a2) lacks(own, x);
});
test("诊断实现:选中的实验原文、基线代码、原始产物的位置;不给信念本和成绩", (t) => {
const { l, chosen } = diagnosisStudy(t, "selected");
const m = mat(l, "diagnosisImplementation", "x1", "s1-dimpl", D);
assert.deepEqual(m.checklist, { kind: "diagnosisImplementation", experiments: chosen });
assert.deepEqual(m.readable, [{ version: "v-base", label: "起点代码(当前基线)", dir: "/code/v-base" }]);
has(m, "产出当前基线的那次正式运行(run-0)的原始产物已经只读地放进你的工作目录");
for (const id of chosen) has(m, `### ${id} `);
assert.ok(!headings(m).includes("信念本") && !headings(m).includes("本批基线那次运行的结果"));
lacks(m, "UNIQUE-A2"); // 诊断解释不给实验员:只给选中的实验
});
test("诊断修复:实现的材料加退回意见(没通过的实验、依据、上一次位置表和改动)、能读上一次的诊断程序;通过的实验不要动", (t) => {
const { l, chosen } = diagnosisStudy(t, "repairOpen");
const m = mat(l, "diagnosisRepair", "x1", "s1-dimpl2", D, { changes: CHANGES_C1, artifacts: null });
assert.deepEqual(m.checklist, { kind: "diagnosisRepair", experiments: chosen });
assert.deepEqual(m.readable.map((x) => x.version), ["v-base", "v-diag"]);
const opinion = m.sections.find((s) => s.heading === "退回意见")!.text;
assert.ok(opinion.includes(`实验 ${chosen[1]}:没照写法做;solve.py:20`));
assert.ok(!opinion.includes(`实验 ${chosen[0]}:`), "通过的实验不列");
assert.ok(opinion.includes("核验员:第二个实验没照写法做"));
assert.ok(opinion.includes(`"experiment": "${chosen[0]}"`) && opinion.includes("多起点循环"));
});
test("诊断核验:选中的实验原文、基线到诊断程序的改动、位置表;不给信念本和实验员的说明", (t) => {
const { l, chosen } = diagnosisStudy(t, "implemented");
const m = mat(l, "diagnosisVerification", "v1", "s1-dver", D, { changes: CHANGES_C1, artifacts: null });
assert.deepEqual(m.checklist, { kind: "diagnosisVerification", experiments: chosen });
assert.deepEqual(m.readable.map((x) => x.version), ["v-base", "v-diag"]);
has(m, "多起点循环");
has(m, "solve.py:10-19");
for (const id of chosen) has(m, `### ${id} `);
assert.ok(!headings(m).includes("信念本"));
lacks(m, "UNIQUE-X1");
});
test("本轮诊断结果:提案材料里每个实验的做法、实测、用时、结局,每条解释确认或否定,推出的方向;排在基线之后、研究记录之前", (t) => {
const { l, chosen } = diagnosisStudy(t, "settled");
l.append("stageOpen", { stageId: "s1-prop", stage: "proposal", round: 1, roster: ["a1", "a2"] });
const m = mat(l, "proposal", "a1", "s1-prop", null);
const h = headings(m);
assert.ok(h.indexOf("基线") < h.indexOf("本轮诊断结果") && h.indexOf("本轮诊断结果") < h.indexOf("研究记录"));
const section = m.sections.find((s) => s.heading === "本轮诊断结果")!.text;
const [first, second] = chosen as [string, string];
assert.ok(section.includes(`诊断运行 run-1:在基线 base(最终指标 0.96)上,一次运行做了 1 个实验,只扣 1 次`));
const line = (id: string) => section.split("\n").find((x) => x.startsWith(`- ${id} `))!;
for (const part of ["实测 1.1,改进量 +0.14", "用时 31.5 秒", "其余指标:d1.gap 0.02", "测前合并范围"]) assert.ok(line(first!).includes(part), part);
assert.ok(line(second!).includes("证据不足:核验不通过,第二个实验两次核验都没落实"));
assert.match(section, /没做:一次诊断最多做 D 个实验,放不下/);
assert.match(section, /- r1-a1-dx1(作者 a1):确认 .*;否定 /);
assert.ok(section.includes("甲的诊断解释:卡在起点 UNIQUE-A1") && section.includes("乙的诊断解释:卡在收尾 UNIQUE-A2"));
assert.match(section, /程序按诊断推出的方向:d\d(实测 \+0\.14)。这只是按公式推出来的参考,你可以不跟。/);
assert.ok(m.records.some((x) => x.id === "diagnosis-r1" && x.text.includes("run-1") && !x.text.includes("7.771")));
assert.ok(m.readable.some((x) => x.version === "v-diag" && x.label === "第 1 轮诊断程序的代码"));
});
/** 只有基线、第 1 轮提案阶段开着的账本:本轮没有诊断记录。 */
function bare(t: TestContext): Ledger {
const { ledger: l } = freshLedger(t);
const w = new Writer(l);
l.append("code", { object: "base", version: "v-base", locations: null });
w.run("run-0", "baseline", 0, null, judged(0.96, "main"));
w.open("s1-prop", "proposal", 1, ["a1", "a2"]);
return l;
}
const diagnosisText = (m: TaskMaterial) => m.sections.find((s) => s.heading === "本轮诊断结果")!.text;
test("本轮没有诊断:写原因。余额不到 2 次、配置关掉(余额够却没有决定)", (t) => {
const l = bare(t);
// 基线占 1 次:运行总数 2 时余额 1(不到 2),总数 10 时余额 9。
assert.equal(diagnosisText(mat(l, "proposal", "a1", "s1-prop", null, NONE, { ...study, totalRuns: 2 })), "本轮没有诊断:余额只剩 1 次,不到 2 次,要留 1 次给本轮正向");
assert.equal(diagnosisText(mat(l, "proposal", "a1", "s1-prop", null)), "本轮没有诊断:配置关掉了诊断");
});
test("本轮没有诊断:信息价值不抵成本时,原因带最好的组合净值;选中的实验一个都没剩下时,原因取事件", (t) => {
const l = bare(t);
const w = new Writer(l);
const input = ruleInput(9, true); // 两条解释对每个实验预测一样:信息价值只剩开放预留那一点,抵不过成本
const decision = selectDiagnosis(input);
assert.equal(decision.diagnose, false);
w.decision("selectDiagnosis", 1, input, decision);
const best = Math.max(...decision.combos.map((c) => c.net));
const text = diagnosisText(mat(l, "proposal", "a1", "s1-prop", null));
assert.equal(text, `本轮没有诊断:信息价值减成本都不大于 0,最好的组合净值 ${Number(best.toPrecision(4))}`);
// 同一轮另一份账本:决定是做,但核验都没通过、修复用完。
const l2 = bare(t);
const w2 = new Writer(l2);
const done = selectDiagnosis(ruleInput(9, false));
w2.decision("selectDiagnosis", 1, ruleInput(9, false), done);
l2.append("event", diagnosisNotRunEvent(1, "d1、d2 都没通过核验"));
assert.equal(diagnosisText(mat(l2, "proposal", "a1", "s1-prop", null)), "本轮没有诊断:选中的实验核验都没通过、修复次数用完,一个都没跑:d1、d2 都没通过核验");
});
test("诊断运行在诊断结算写入之前不公开,写入之后公开(和正向的采纳、消融的结算同一类规则)", (t) => {
const run = (l: Ledger) => new Board(l.records()).runs().find((x) => x.plan.run === "run-1")!;
const running = diagnosisStudy(t, "run").l;
assert.equal(new Board(running.records()).runPublic(run(running)), false);
const settled = diagnosisStudy(t, "settled").l;
assert.equal(new Board(settled.records()).runPublic(run(settled)), true);
});
test("信念本的诊断历史:各轮的实验、确认与否定、方向;战绩按人加一行诊断;诊断实验的实测不算手里最好", (t) => {
const { l, chosen } = diagnosisStudy(t, "settled");
l.append("stageOpen", { stageId: "s1-prop", stage: "proposal", round: 1, roster: ["a1", "a2"] });
const book = mat(l, "proposal", "a1", "s1-prop", null).sections.find((s) => s.heading === "信念本")!.text;
assert.ok(book.includes("**诊断历史**"));
assert.ok(book.includes(`- 第 1 轮:\n 诊断运行 run-1:在基线 base`));
assert.ok(book.includes(` - ${chosen[0]} `) && book.includes("确认 d1;否定 无"));
assert.ok(book.includes("手里最好 0.96"), "诊断实验实测 1.1,但诊断的答案不采纳,手里最好仍是基线");
// 战绩:a1、a2 各一条诊断:只有 d1 一个有效实验,样本各 1 个。a1 的 q5 = 1.25 > 实测 1.1 偏高;a2 的 q5 = 0.95 < 1.1 偏低。
assert.ok(book.includes("- a1,诊断 1 次:中位数偏高 1、偏低 0"));
assert.ok(book.includes("- a2,诊断 1 次:中位数偏高 0、偏低 1"));
});
test("信念本的诊断历史:第 2 轮诊断关着时,第 1 轮的历史还在,第 2 轮没走完不列;第 2 轮诊断任务读不到第 2 轮的诊断结果", (t) => {
const { l } = diagnosisStudy(t, "settled");
l.append("stageOpen", { stageId: "s1-prop", stage: "proposal", round: 1, roster: ["a1", "a2"] });
l.append("stageClose", { stageId: "s1-prop" });
// 第 1 轮采纳 r1-c1 作基线要写一大串记录,这里只看第 2 轮的诊断任务:基线仍是 base,所以直接开第 2 轮的诊断阶段。
l.append("stageOpen", { stageId: "s2-dp", stage: "diagnosisProposal", round: 2, roster: ["a1", "a2"] });
const m = mat(l, "diagnosisProposal", "a1", "s2-dp", null, { changes: null, artifacts: {} });
assert.ok(!headings(m).includes("本轮诊断结果"));
const book = m.sections.find((s) => s.heading === "信念本")!.text;
assert.ok(book.includes("- 第 1 轮:") && !book.includes("- 第 2 轮:"));
});
test("实现正向候选的实验员:本轮有诊断时能读诊断程序(只读,注明冻结协议引用诊断实验时作参考);没有诊断时没有", (t) => {
const { l } = diagnosisStudy(t, "settled");
const w = new Writer(l);
l.append("stageOpen", { stageId: "s1-prop", stage: "proposal", round: 1, roster: ["a1", "a2"] });
w.protocol("r1-c1", protocolOf("r1-c1", P_A1, 1, "base", ["r1-a1-1"], ["加多起点"]));
w.open("s1-impl-c1", "implementation", 1, ["x1"]);
const m = mat(l, "implementation", "x1", "s1-impl-c1", "r1-c1");
assert.deepEqual(m.readable, [
{ version: "v-base", label: "起点代码(本批基线)", dir: "/code/v-base" },
{ version: "v-diag", label: "本轮诊断程序(只读,冻结协议引用诊断实验时作参考)", dir: "/code/v-diag" },
]);
has(m, "冻结协议引用诊断实验时作参考");
lacks(m, "UNIQUE-A1"); // 只给代码,不给诊断解释和成绩
lacks(m, "实测 1.1");
const l2 = bare(t);
const w2 = new Writer(l2);
w2.protocol("r1-c1", protocolOf("r1-c1", P_A1, 1, "base", ["r1-a1-1"], ["加多起点"]));
w2.open("s1-impl-c1", "implementation", 1, ["x1"]);
assert.deepEqual(mat(l2, "implementation", "x1", "s1-impl-c1", "r1-c1").readable.map((x) => x.version), ["v-base"]);
});
+2 -1
View File
@@ -36,6 +36,7 @@ export const study: StudyFacts = {
lambda: 0.1,
totalRuns: 10,
proposalLimit: 2,
diagnosisExperimentLimit: 2,
factorLimit: 3,
metrics: [
{ id: "sum-of-radii", layer: "final", meaning: "半径和", unit: "无", direction: "maximize", howMeasured: "校验器", missing: "invalidateRun" },
@@ -114,7 +115,7 @@ export class Writer {
decision(decision: string, round: number, input: unknown, output: unknown): void {
this.l.append("decision", { decision, round, rule: "test", ruleVersion: 1, params: null, seed: null, input: input as never, output: output as never });
}
run(run: string, purpose: "baseline" | "forward" | "ablation", round: number, object: string | null, j: JudgedResult): void {
run(run: string, purpose: "baseline" | "forward" | "ablation" | "diagnosis", round: number, object: string | null, j: JudgedResult | null): void {
this.l.append("runPlanned", { run, purpose, round, object, definition });
this.l.append("runStarted", { run });
this.l.append("runResult", { run, process: exited, judged: j });
+294
View File
@@ -0,0 +1,294 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
diagnosisModel,
runDiagnosisRule,
selectDiagnosis,
settleDiagnosis,
trackRecord,
type DiagnosisExplanation,
type DiagnosisRuleInput,
type ExperimentOutcome,
} from "../src/rules/index.ts";
import { close, loadFixture, scenarioPicker, type Scenario } from "./rules-fixtures-helpers.ts";
// 预期值来自 tests/fixtures/rules/gen_diagnosis.py:只照方法.md 6.12、6.7、6.9 的文字、用有理数写成的独立参考,
// 没有读过 src/rules/diagnosis.ts 和 value.ts(重新生成:python3 tests/fixtures/rules/gen_diagnosis.py)。
// 场景里有手算的几个(opposite_lambda0 的 VOI = 0.1,same_lambda0 的 VOI = 0,说明写在场景的 note 里)。
const fixture = loadFixture("diagnosis.json");
const pick = scenarioPicker(fixture);
const EXACT = 1e-12;
const BITS = 1e-9;
/** 只做一个实验时的 VOI:实现用网格近似,参考是逐段闭式积分。 */
const closeK1 = (actual: number, expected: number, what: string): void =>
close(actual, expected, 1e-9 + 2e-4 * Math.abs(expected), what);
type Ranges = Record<string, { q1: number; q5: number; q9: number }>;
function explanationsOf(s: Scenario): DiagnosisExplanation[] {
return s.input.explanations.map((e: { id: string; author: string; ranges: Ranges }) => ({
id: e.id,
author: e.author,
experiments: Object.entries(e.ranges).map(([experiment, range]) => ({ experiment, range })),
}));
}
function ruleInput(s: Scenario): DiagnosisRuleInput {
return {
baseline: s.input.b,
direction: s.input.direction,
lambda: s.input.lambda,
experiments: s.input.experiments,
explanations: explanationsOf(s),
balance: s.input.balance,
batchCap: s.input.batchCap,
maxExperiments: s.input.maxExperiments,
u: s.input.u,
decay: s.input.decay,
};
}
/** 选实验场景:事前数字、组合表、选中的实验都和独立参考一致。 */
function checkSelection(name: string): ReturnType<typeof selectDiagnosis> {
const s = pick.get(name);
const got = selectDiagnosis(ruleInput(s));
const e = s.expected;
for (const [d, v] of Object.entries(e.priorGains as Record<string, number>)) {
close(got.priorGains.find((x) => x.experiment === d)!.gain, v, EXACT, `${name} ${d} 事前期望收益`);
}
close(got.g0, e.g0, EXACT, `${name} ĝ₀`);
e.weights.forEach((w: number, j: number) => close(got.weights[j]!, w, EXACT, `${name} 起始权重 ${j}`));
assert.deepEqual(got.combos.map((c) => c.experiments), e.combos.map((c: any) => c.experiments), `${name} 组合列表`);
e.combos.forEach((c: any, i: number) => {
const g = got.combos[i]!;
if (c.voi === null) return;
const what = `${name} {${c.experiments}}`;
if (c.experiments.length === s.input.experiments.length) close(g.voi, c.voi, EXACT, `${what} VOI`);
else closeK1(g.voi, c.voi, `${what} VOI`);
assert.equal(g.rHat, c.rHat, `${what} R̂`);
close(g.mPrime, c.mPrime, EXACT, `${what} m′`);
close(g.cost, c.cost, EXACT, `${what} 成本`);
close(g.value, c.value, 1e-9 + 2e-4 * Math.abs(c.value), `${what} 价值`);
close(g.net, c.net, 1e-9 + 2e-4 * Math.abs(c.value), `${what} 净值`);
});
if ("diagnose" in e) {
assert.equal(got.diagnose, e.diagnose, `${name} 做不做`);
assert.deepEqual(got.chosen, e.chosen, `${name} 选中的实验`);
}
return got;
}
test("6.12 信息价值:两条解释对实验预测相反时为正(手算 0.1),预测一样时为 0", () => {
const opposite = checkSelection("opposite_lambda0");
close(opposite.combos[0]!.voi, 0.1, EXACT, "手算:0.5 × 0.2 − 0");
assert.equal(opposite.diagnose, true);
const same = checkSelection("same_lambda0");
close(same.combos[0]!.voi, 0, EXACT, "预测一样、范围都在 0 以上:做不做都不改变看法");
assert.equal(same.diagnose, false, "净值 = −成本 < 0");
assert.equal(same.experiments[0]!.reason, "value");
assert.equal(same.chosenNet, 0);
// λ = 0.1:预测一样时只剩开放预留带来的一小部分,比预测相反小得多。
const oppositeReserve = checkSelection("opposite_lambda01");
const sameReserve = checkSelection("same_lambda01");
close(sameReserve.combos[0]!.voi, 0.0025, EXACT, "手算:λ × (E[max(0, U)] − E U) = 0.1 × 0.025");
assert.ok(sameReserve.combos[0]!.voi > 0);
assert.ok(oppositeReserve.combos[0]!.voi > 10 * sameReserve.combos[0]!.voi);
});
test("6.12 价值减成本:不管做几个实验成本相同;R̂ = ⌈(余额 − 1) ÷ 每批上限⌉", () => {
const got = checkSelection("multi_all");
// 手算:余额 9、K = 2:R̂ = ⌈8 ÷ 2⌉ = 4,m′ = 1 + 0.5 + 0.25 + 0.125 = 1.875;
// 成本 = 2 × 0.0525 × (3/4 − 2/3) = 0.00875(ĝ₀ = 0.0525 来自参考脚本的事前期望)。
assert.equal(got.combos.length, 1, "实验数 3 ≤ D = 4:只有全做一个组合");
assert.equal(got.combos[0]!.rHat, 4);
close(got.combos[0]!.mPrime, 1.875, EXACT, "m′");
close(got.combos[0]!.cost, 0.00875, EXACT, "成本");
assert.deepEqual(got.chosen, ["d1", "d2", "d3"]);
assert.ok(got.experiments.every((x) => x.done && x.reason === null && x.bestCombo === null));
});
test("6.12 选实验:余额 < 2 时不诊断,没有可选组合", () => {
const got = checkSelection("balance_one");
assert.deepEqual(got.combos, []);
assert.equal(got.diagnose, false);
assert.deepEqual(got.chosen, []);
assert.ok(got.experiments.every((x) => !x.done && x.bestCombo === null));
});
test("6.12 选实验:实验数超过 D 时只比较装满的组合", () => {
const one = checkSelection("over_cap_d1");
assert.equal(one.combos.length, 3, "C(3,1) 个单实验组合");
assert.ok(one.combos.every((c) => c.experiments.length === 1), "没有空组合,也没有更大的组合");
assert.deepEqual(one.chosen, ["d2"]);
// 没选中的实验:放不下,并附上含它的组合里净值最大的那个(这里就是它自己的单实验组合)。
const d3 = one.experiments.find((x) => x.experiment === "d3")!;
assert.equal(d3.done, false);
assert.equal(d3.reason, "budget");
assert.deepEqual(d3.bestCombo!.experiments, ["d3"]);
close(d3.bestCombo!.net, pick.get("over_cap_d1").expected.combos[2].net, 1e-9 + 2e-4 * 0.1, "d3 的净值");
const two = checkSelection("over_cap_d2");
assert.deepEqual(two.combos.map((c) => c.experiments), [["d1", "d2"], ["d1", "d3"], ["d2", "d3"]]);
assert.equal(two.chosen.length, 2);
// 多做一个实验信息价值只会不变或变大:每个两实验组合的 VOI 不小于它包含的任一单实验(参考脚本的精确值)。
const singles = pick.get("over_cap_d1").expected.combos as Array<{ experiments: string[]; voi: number }>;
for (const pair of two.combos) {
for (const d of pair.experiments) {
const single = singles.find((c) => c.experiments[0] === d)!.voi;
assert.ok(pair.voi >= single - (1e-9 + 2e-4 * single), `{${pair.experiments}} 的 VOI ${pair.voi} 小于 {${d}} 的 ${single}`);
}
}
});
test("6.12 选实验:平票按实验编号(自然序,d2 排在 d10 前面);越小越好按改进量算", () => {
const tie = checkSelection("tie_by_id");
assert.deepEqual(tie.chosen, ["d2"]);
assert.deepEqual(tie.experiments.find((x) => x.experiment === "d10")!.bestCombo!.experiments, ["d10"]);
checkSelection("minimize_all");
});
test("登记表:选诊断实验只有一条规则,结果带规则名和版本;同样的输入算两次逐位相同", () => {
const s = pick.get("multi_all");
const run = runDiagnosisRule("valueMinusCost", ruleInput(s));
assert.deepEqual(run.rule, { name: "valueMinusCost", version: 1 });
assert.deepEqual(JSON.parse(JSON.stringify(run)), JSON.parse(JSON.stringify({ rule: run.rule, ...selectDiagnosis(ruleInput(s)) })));
});
test("成分模型:每条解释一个成分加开放预留;解释合起来 1 − λ,同一人平分", () => {
const s = pick.get("settle_mixed");
const m = diagnosisModel({ ...ruleInput({ ...s, input: { ...s.input, balance: 9, batchCap: 2, maxExperiments: 4, u: 0.5, decay: 0.5 } }), });
// 手算:researcher-1 一条 0.9 × 1/2,researcher-2 两条各 0.9 × 1/4,预留 0.1。
assert.deepEqual(m.components, ["e1", "e2", "e3", "openReserve"]);
[0.45, 0.225, 0.225, 0.1].forEach((w, j) => close(m.weights[j]!, w, EXACT, `权重 ${j}`));
});
type Settled = ReturnType<typeof settleDiagnosis>;
function settle(name: string): { got: Settled; e: any } {
const s = pick.get(name);
const outcomes = s.input.outcomes as Record<string, { kind: ExperimentOutcome["kind"]; score?: number }>;
const got = settleDiagnosis({
baseline: s.input.b,
direction: s.input.direction,
epsilon: s.input.epsilon,
lambda: s.input.lambda,
experiments: Object.entries(outcomes).map(([experiment, o]) => ({
experiment,
outcome: o.kind === "valid" ? { kind: "valid", score: o.score! } : ({ kind: o.kind } as ExperimentOutcome),
})),
explanations: explanationsOf(s),
});
const e = s.expected;
assert.deepEqual(got.experiments.map((x) => x.outcome), Object.keys(e.experiments).map((d) => e.experiments[d].outcome), `${name} 结局`);
for (const row of got.experiments) {
const x = e.experiments[row.experiment];
const what = `${name} ${row.experiment}`;
close(row.merged.q1, x.merged.q1, BITS, `${what} 合并一成`);
close(row.merged.q5, x.merged.q5, BITS, `${what} 合并一半`);
close(row.merged.q9, x.merged.q9, BITS, `${what} 合并九成`);
if (x.gain === undefined) {
// 证据不足、没做:不判、不记账。
assert.equal(row.gain, null, `${what} 收益`);
assert.equal(row.worldBits, null, `${what} 世界比特`);
assert.equal(row.surprise, null, `${what} 惊讶`);
assert.equal(row.disagreement, null, `${what} 分歧量`);
} else {
close(row.gain!, x.gain, EXACT, `${what} 收益`);
close(row.worldBits!, x.worldBits, BITS, `${what} 世界比特`);
assert.equal(row.surprise!.cell, x.surprise.bin, `${what} 惊讶所在格`);
close(row.surprise!.prior, x.surprise.p, BITS, `${what} 事前把握`);
assert.equal(row.surprise!.bits.status, x.surprise.status === "ok" ? "measured" : "unmeasured");
if (row.surprise!.bits.status === "measured") close(row.surprise!.bits.value, x.surprise.surprise, BITS, `${what} 惊讶`);
close(row.disagreement!, x.disagreement, BITS, `${what} 分歧量`);
}
}
// 确认与否定:只列有效实验。
assert.deepEqual(
Object.fromEntries(got.judgments.map((j) => [j.id, Object.fromEntries(j.verdicts.map((v) => [v.experiment, v.verdict]))])),
e.judgments,
`${name} 确认与否定`,
);
const dir = got.direction;
assert.equal(dir.weightsKept, e.direction.weightsKept, `${name} 权重是否保持`);
e.direction.weights.forEach((w: number, j: number) => close(dir.weights[j]!, w, EXACT, `${name} 新权重 ${j}`));
for (const g of dir.gains) {
const x = e.direction.gains[g.experiment];
close(g.gain, x.gain, EXACT, `${name} ${g.experiment} 方向收益`);
assert.equal(g.measured, x.measured, `${name} ${g.experiment} 是实测还是估计`);
}
assert.equal(dir.experiment, e.direction.experiment, `${name} 推出的方向`);
close(dir.gain, e.direction.gain, EXACT, `${name} 方向收益`);
assert.equal(dir.found, e.direction.found, `${name} 有没有指出方向`);
return { got, e };
}
test("6.12 结算:证据不足的实验不判、不记账;没做的实验也不判", () => {
const { got } = settle("settle_mixed");
assert.deepEqual(got.experiments.map((x) => x.outcome), ["valid", "insufficient", "untested"]);
// d2、d3 没有有效分数:每条解释只有 d1 一行判定,没有比特。
assert.ok(got.judgments.every((j) => j.verdicts.length === 1 && j.verdicts[0]!.experiment === "d1"));
assert.equal(got.experiments.filter((x) => x.worldBits !== null).length, 1);
// 手算:d1 实测 1.045 = b + 0.045,收益 +0.045;落在 e1 的 [1.02, 1.08] 里确认,e2 的 [1.00, 1.06] 里确认,e3 的 [0.98, 1.10] 里确认。
assert.deepEqual(got.judgments.map((j) => j.verdicts[0]!.verdict), ["confirmed", "confirmed", "confirmed"]);
});
test("6.12 结算:两个有效实验、确认与否定、权重更新", () => {
const { got } = settle("settle_two_valid");
// d2 实测 1.25,e2 的 [1.1, 1.3] 确认;e1 的 [0.95, 1.05]、e3 的 [0.8, 1.0] 否定。
assert.deepEqual(got.judgments.find((j) => j.id === "e2")!.verdicts.find((v) => v.experiment === "d2")!.verdict, "confirmed");
assert.deepEqual(got.judgments.find((j) => j.id === "e1")!.verdicts.find((v) => v.experiment === "d2")!.verdict, "denied");
assert.deepEqual(got.judgments.find((j) => j.id === "e3")!.verdicts.find((v) => v.experiment === "d2")!.verdict, "denied");
});
test("6.12 结算:全部实验收益不为正时,记没有指出能涨分的方向", () => {
const { got } = settle("settle_no_direction");
assert.equal(got.direction.found, false);
assert.ok(got.direction.gains.every((g) => g.gain <= 0));
// 手算:收益分别是 0.97 − 1 = −0.03、0.8 − 1 = −0.2、0.9 − 1 = −0.1,最大的是 d1 的 −0.03。
assert.equal(got.direction.experiment, "d1");
close(got.direction.gain, -0.03, EXACT, "最大收益");
});
test("6.12 结算:没有有效实验、实测落在所有成分之外时保持起始权重", () => {
const none = settle("settle_all_insufficient");
assert.equal(none.got.direction.weightsKept, false, "没有有效实验时没有密度可乘,权重原样(不算'无从更新')");
assert.ok(none.got.judgments.every((j) => j.verdicts.length === 0));
const outside = settle("settle_outside_all");
assert.equal(outside.got.direction.weightsKept, true);
assert.deepEqual(outside.got.direction.weights, diagnosisModel({
baseline: 1, direction: "maximize", lambda: 0.1, experiments: ["d1", "d2", "d3"],
explanations: explanationsOf(pick.get("settle_outside_all")),
}).weights);
});
test("6.12 结算:越小越好按改进量 b − x", () => {
const { got } = settle("settle_minimize");
close(got.experiments[0]!.gain!, 0.12, EXACT, "手算:1.0 − 0.88");
close(got.experiments[1]!.gain!, -0.05, EXACT, "手算:1.0 − 1.05");
assert.equal(got.direction.experiment, "d1");
});
test("战绩:按人、按正向 / 诊断 / 消融分开数,次序是编号、正向、诊断、消融", () => {
// 手算:researcher-2 的诊断样本 (q1, q5, q9) = (0.1, 0.2, 0.3),实测 0.35 在 q9 之上:中位数偏低、被穿过上沿、没包住、落点 aboveQ9。
const range = { q1: 0.1, q5: 0.2, q9: 0.3 };
const rows = trackRecord([
{ researcher: "researcher-2", phase: "ablation", range, value: 0.2 },
{ researcher: "researcher-2", phase: "diagnosis", range, value: 0.35 },
{ researcher: "researcher-1", phase: "diagnosis", range, value: 0.05 },
{ researcher: "researcher-2", phase: "forward", range, value: 0.25 },
]);
assert.deepEqual(rows.map((r) => `${r.researcher}/${r.phase}`), [
"researcher-1/diagnosis", "researcher-2/forward", "researcher-2/diagnosis", "researcher-2/ablation",
]);
const t = rows[2]!.tally;
assert.deepEqual([t.total, t.medianLow, t.medianHigh, t.aboveTop, t.belowBottom, t.covered], [1, 1, 0, 1, 0, 0]);
assert.equal(t.placements.aboveQ9, 1);
});
test("diagnosis.json 里的场景都被测到", () => {
assert.deepEqual(pick.unused(), []);
});
+136
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";
import {
ablationOutcome,
diagnosisOutcome,
protocolBody,
receive,
verificationOutcome,
@@ -442,3 +443,138 @@ test("消融实现的代码存不成版本:退回", async () => {
});
assert.match(reason, /代码存不成版本:bin\/x(符号链接/);
});
// ---------------------------------------------------------------------------
// 诊断(方法.md 4.3 诊 1–诊 4)
// ---------------------------------------------------------------------------
const DX_EXPERIMENT = (name: string, missing: "recordMissing" | "invalidateRun" = "recordMissing", ids: string[] = []) => ({
name,
spot: "solve.py 的起点生成",
body: "换成更强的起点,交出答案",
metrics: ids.map((id) => ({ ...metric(id), missing })),
});
const DX_PROPOSAL = (over: Partial<Submission<"diagnosisProposal">> = {}): Submission<"diagnosisProposal"> => ({
explanations: [{ body: "卡在起点" }, { body: "卡在收尾" }],
experiments: [DX_EXPERIMENT("强起点"), DX_EXPERIMENT("拿掉收尾", "recordMissing", ["raw-score"])],
...over,
});
const DX_LIST: ChecklistOf<"diagnosisProposal"> = { kind: "diagnosisProposal", maxExplanations: 2, maxExperiments: 2, takenMetricIds: ["sum-of-radii", "answer-path"] };
const dxProposal = (content: Submission<"diagnosisProposal">): Incoming => ({ kind: "diagnosisProposal", content, checklist: DX_LIST, known: null, jevModel: MODEL });
test("诊断解释与实验:解释 1 至 M 条、实验 1 至 A 个;超过就退回", async () => {
const many = DX_PROPOSAL({ explanations: [{ body: "a" }, { body: "b" }, { body: "c" }], experiments: [DX_EXPERIMENT("1"), DX_EXPERIMENT("2"), DX_EXPERIMENT("3")] });
const reason = await rejected(dxProposal(many));
assert.match(reason, /交了 3 条诊断解释,最多 2 条/);
assert.match(reason, /交了 3 个诊断实验,最多 2 个/);
});
test("诊断解释与实验:登记的指标只能是 recordMissing;同一个实验里不重名、不占用已有编号", async () => {
const reason = await rejected(
dxProposal(
DX_PROPOSAL({
experiments: [
DX_EXPERIMENT("甲", "invalidateRun", ["raw-score"]),
{ ...DX_EXPERIMENT("乙"), metrics: [metric("a"), metric("a"), metric("answer-path")] },
],
}),
),
);
assert.match(reason, /第 1 个实验的指标 raw-score 选了 invalidateRun:诊断实验里的指标只能选 recordMissing/);
assert.match(reason, /第 2 个实验的 metrics 里编号 a 写了不止一次/);
assert.match(reason, /第 2 个实验的指标编号 answer-path 已被占用/);
});
test("诊断解释与实验:对每个实验问冻结三问;两个实验用同一个指标短名没问题(冻结时各带自己实验的前缀)", async (t) => {
fakeJev(t, () => false);
const v = await receive(dxProposal(DX_PROPOSAL({ experiments: [DX_EXPERIMENT("甲", "recordMissing", ["x"]), DX_EXPERIMENT("乙", "recordMissing", ["x"])] })));
assert.equal(v.accepted, true);
assert.equal(v.jev!.length, 6, "两个实验各三问");
assert.deepEqual(v.jev!.map((j) => j.question), ["self_contradictory", "changes_measurement", "too_vague", "self_contradictory", "changes_measurement", "too_vague"]);
});
test("诊断解释与实验:Jev 判是就退回,原因写明第几个实验和哪一问", async (t) => {
fakeJev(t, (q) => q === "too_vague");
const v = await receive(dxProposal(DX_PROPOSAL()));
assert.equal(v.accepted, false);
const reason = (v as Extract<Verdict, { accepted: false }>).reason;
assert.match(reason, /Jev 判出下面几处有问题/);
assert.match(reason, /- 第 1 个诊断实验(强起点):too_vague 概率 0\.99/);
assert.match(reason, /- 第 2 个诊断实验(拿掉收尾):too_vague 概率 0\.99/);
});
const DX_PRED_LIST: ChecklistOf<"diagnosisPrediction"> = { kind: "diagnosisPrediction", explanations: ["r1-dx1", "r1-dx2"], experiments: ["d1", "d2"] };
const dxPred = (predictions: Array<[string, string, ReturnType<typeof r>]>): Incoming => ({
kind: "diagnosisPrediction",
content: { predictions: predictions.map(([explanation, experiment, range]) => ({ explanation, experiment, range })), body: "理由" },
checklist: DX_PRED_LIST,
known: null,
jevModel: MODEL,
});
test("诊断预测:自己每条解释 × 每个合并后的实验,不多不少;收下时不问 Jev", async () => {
const all: Array<[string, string, ReturnType<typeof r>]> = [
["r1-dx1", "d1", r(1, 1.1, 1.2)], ["r1-dx1", "d2", r(1, 1.1, 1.2)], ["r1-dx2", "d1", r(1, 1.1, 1.2)], ["r1-dx2", "d2", r(1, 1.1, 1.2)],
];
assert.deepEqual(await receive(dxPred(all)), { accepted: true, jev: null, protocol: null });
const reason = await rejected(dxPred([all[0]!, all[0]!, all[1]!, ["r1-dx3", "d1", r(1, 1.1, 1.2)]]));
assert.match(reason, /predictions 的(解释:实验)应当是 r1-dx1:d1、r1-dx1:d2、r1-dx2:d1、r1-dx2:d2/);
assert.match(reason, /你写的 r1-dx3:d1 不在里面/);
assert.match(reason, /还缺 r1-dx2:d1、r1-dx2:d2/);
assert.match(reason, /r1-dx1:d1 写了不止一次/);
});
test("诊断预测:三个数要严格递增", async () => {
const reason = await rejected(dxPred([["r1-dx1", "d1", r(1, 1, 1.2)], ["r1-dx1", "d2", r(1, 1.1, 1.2)], ["r1-dx2", "d1", r(1, 1.1, 1.2)], ["r1-dx2", "d2", r(1.3, 1.2, 1.1)]]));
assert.match(reason, /r1-dx1:d1 的 range 的三个数要严格递增 q1 < q5 < q9,你写的是 1 \/ 1 \/ 1\.2/);
assert.match(reason, /r1-dx2:d2 的 range 的三个数要严格递增/);
});
const DX_CODE_BODY = { locations: [{ experiment: "d1", locations: ["solve.py:3-9"] }], concerns: "无", body: "无" };
type DxCode = Extract<Incoming, { kind: "diagnosisImplementation" }>["known"]["code"];
const dxCode = (kind: "diagnosisImplementation" | "diagnosisRepair", code: DxCode): Incoming =>
kind === "diagnosisImplementation"
? { kind, content: DX_CODE_BODY, checklist: { kind, experiments: ["d1"] }, known: { code }, jevModel: MODEL }
: { kind, content: DX_CODE_BODY, checklist: { kind, experiments: ["d1"] }, known: { code }, jevModel: MODEL };
test("诊断实现、修复:改了不许改的文件、代码存不了就退回;有差异块时每块问一次五问", async (t) => {
for (const kind of ["diagnosisImplementation", "diagnosisRepair"] as const) {
assert.match(await rejected(dxCode(kind, { ...SAVED, protectedFiles: ["validate.py"] })), /改了不许改的文件:validate\.py/);
assert.match(await rejected(dxCode(kind, { saved: false, path: "bin/x", reason: "符号链接" })), /代码存不成版本:bin\/x(符号链接)/);
}
fakeJev(t, () => false);
const hunks = [
{ path: "solve.py", change: "modified" as const, text: "@@ -1 +1 @@\n-a\n+b" },
{ path: "solve.py", change: "modified" as const, text: "@@ -9 +9 @@\n-c\n+d" },
];
const v = await receive(dxCode("diagnosisRepair", { ...SAVED, hunks }));
assert.equal(v.accepted, true);
assert.equal(v.jev!.length, 10, "两块各五问");
});
test("诊断核验:逐个实验的结果覆盖全部选中的实验", async () => {
const verification = (ids: string[]): Incoming => ({
kind: "diagnosisVerification",
content: { experiments: ids.map((experiment) => ({ experiment, followed: true, nothingElse: true, where: "solve.py:3" })), body: "无" },
checklist: { kind: "diagnosisVerification", experiments: ["d1", "d2"] },
known: null,
jevModel: MODEL,
});
assert.deepEqual(await receive(verification(["d2", "d1"])), { accepted: true, jev: null, protocol: null });
const reason = await rejected(verification(["d1", "d3"]));
assert.match(reason, /experiments 的实验应当是 d1、d2;你写的 d3 不在里面;还缺 d2/);
});
test("诊断核验通过没有:逐个实验判,一个不通过不影响别的实验", () => {
const v: Submission<"diagnosisVerification"> = {
experiments: [
{ experiment: "d1", followed: true, nothingElse: true, where: "a" },
{ experiment: "d2", followed: false, nothingElse: true, where: "没找到" },
{ experiment: "d3", followed: true, nothingElse: false, where: "顺手改了别处" },
{ experiment: "d4", followed: false, nothingElse: false, where: "都不对" },
],
body: "无",
};
assert.deepEqual(diagnosisOutcome(v), { passed: false, failing: ["d2", "d3", "d4"], notFollowed: ["d2", "d4"], touchedElse: ["d3", "d4"] });
assert.deepEqual(diagnosisOutcome({ experiments: [v.experiments[0]!], body: "无" }), { passed: true, failing: [], notFollowed: [], touchedElse: [] });
});
+254
View File
@@ -0,0 +1,254 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, test, type TestContext } from "node:test";
import { Runner } from "../src/runner/index.ts";
import {
diagnosisRun,
diagnosisUnversionedNames,
judgeDiagnosis,
loadTask,
placeBaselineArtifact,
prepareDiagnosisJudging,
prepareDiagnosisRun,
type DiagnosisExperiment,
type DiagnosisJudged,
} from "../src/tasks/index.ts";
import type { HypothesisMetric, Measured, ProcessResult, RunDefinition } from "../src/vocab/index.ts";
// 诊断运行的测试(任务书 18 第 1.4 节):一次运行跑几个实验,经真实 Docker 在任务镜像里跑;没有 Docker 时失败,不跳过。
// 镜像和容器用 inforsi-test-tasks-diag 开头的固定名字,与别的测试文件错开。
// 判分的预期值是手算的 2 的幂有理数布局(同 tests/tasks.test.ts):宽松布局半径和 26/128 = 0.203125,
// 相切布局 26/64 = 0.40625。
const TASK_DIR = join(import.meta.dirname, "..", "tasks", "circle_packing");
const IMAGE_TAG = "inforsi-test-tasks-diag";
/** 测试用的总时限:6 个实验各分到 4 秒。 */
const TOTAL_SECONDS = 24;
/** 任务目录抄一份,只把总时限改小,其余(Dockerfile、校验器、harness)原样,镜像编号也就相同。 */
function shortTask() {
const dir = mkdtempSync(join(tmpdir(), "inforsi-tasks-diag-task-"));
cpSync(TASK_DIR, dir, { recursive: true });
const facts = JSON.parse(readFileSync(join(dir, "task.json"), "utf8"));
facts.run.wallSeconds = TOTAL_SECONDS;
writeFileSync(join(dir, "task.json"), JSON.stringify(facts));
return loadTask(dir);
}
const task = shortTask();
const runner = new Runner(null);
const digest = await runner.buildImage(task.dir, IMAGE_TAG);
after(() => {
rmSync(task.dir, { recursive: true, force: true });
docker(["rmi", IMAGE_TAG]);
});
function docker(args: string[]): { status: number | null; stderr: string } {
return spawnSync("docker", args, { encoding: "utf8" });
}
function forceRemove(name: string): void {
const removed = docker(["rm", "-f", name]);
if (removed.status !== 0 && !removed.stderr.includes("No such container")) throw new Error(removed.stderr);
}
function tempDir(t: TestContext, label: string): string {
const dir = mkdtempSync(join(tmpdir(), `inforsi-tasks-diag-${label}-`));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
async function run(t: TestContext, suffix: string, definition: RunDefinition, dir: string): Promise<ProcessResult> {
const name = `inforsi-test-tasks-diag-${suffix}`;
forceRemove(name);
t.after(() => forceRemove(name));
const outcome = await runner.start(name, definition, dir);
assert.deepEqual(outcome, { started: true });
return runner.wait(name, tempDir(t, "log"));
}
const m = (value: number): Measured => ({ status: "measured", value });
function rowLayout(radius: number, spacing: number, firstX: number): object {
const n = 26;
return {
centers: Array.from({ length: n }, (_, i) => [firstX + i * spacing, 0.5]),
radii: Array(n).fill(radius),
reported_sum: 99,
};
}
const metric = (id: string): HypothesisMetric => ({ id, meaning: "测试用", unit: "无", direction: "maximize", howMeasured: "测试用", missing: "recordMissing" });
/** 六个实验:分别测"拿到了原始产物""指标不串""崩了不影响后面""超时""原始产物只读""答案不合法"。 */
const EXPERIMENTS: DiagnosisExperiment[] = [
{ id: "d-loose", metrics: [metric("d-loose.note")] },
{ id: "d-same", metrics: [metric("d-same.note")] },
{ id: "d-crash", metrics: [] },
{ id: "d-slow", metrics: [] },
{ id: "d-tamper", metrics: [] },
{ id: "d-outside", metrics: [] },
];
const PROGRAM = `import json
import time
def layout(radius, spacing, first_x):
return ([[first_x + i * spacing, 0.5] for i in range(26)], [radius] * 26, 99.0)
def run_experiment(experiment_id, baseline_dir):
if experiment_id == "d-loose":
with open("metrics.json", "w") as handle:
json.dump({"d-loose.note": 0.5}, handle)
return layout(1 / 128, 1 / 32, 7 / 64)
if experiment_id == "d-same":
with open(baseline_dir + "/solution.json") as handle:
solution = json.load(handle)
return solution["centers"], solution["radii"], solution["reported_sum"]
if experiment_id == "d-crash":
raise RuntimeError("故意崩")
if experiment_id == "d-slow":
time.sleep(60)
if experiment_id == "d-tamper":
with open(baseline_dir + "/solution.json", "w") as handle:
handle.write("{}")
if experiment_id == "d-outside":
return layout(1 / 128, 1 / 32, 2.0)
raise RuntimeError("不认识的实验 " + experiment_id)
`;
/** 基线那次正式运行的运行目录:只要有 solution.json。放的是相切布局,半径和 0.40625。 */
function baselineRunDir(t: TestContext): string {
const dir = tempDir(t, "baseline-run");
writeFileSync(join(dir, "solution.json"), JSON.stringify(rowLayout(1 / 64, 1 / 32, 7 / 64)));
return dir;
}
function programDir(t: TestContext): string {
const dir = tempDir(t, "program");
writeFileSync(join(dir, "initial.py"), PROGRAM);
return dir;
}
function value(item: Measured | undefined): number {
assert.equal(item?.status, "measured", JSON.stringify(item));
return (item as { value: number }).value;
}
function reason(item: Measured | undefined): string {
assert.equal(item?.status, "unmeasured", JSON.stringify(item));
return (item as { reason: string }).reason;
}
test("运行定义:一个容器,总时限和核数照任务,实验按顺序、每个分到总时限 ÷ 实验数,种子经环境变量交给 harness", () => {
const definition = diagnosisRun(task, digest, "seed-x", ["a", "b", "c"]);
assert.equal(definition.wallSeconds, TOTAL_SECONDS);
assert.equal(definition.cores, 1);
assert.equal(definition.env.INFORSI_SEED, "seed-x");
assert.deepEqual(definition.command, [
"python", "-B", "/work/harness-diagnosis.py", "--program", "initial.py", "--baseline", "baseline-artifact",
"--answers", "answers", "--seconds-each", "8", "--report", "diagnosis.json", "--experiments", "a", "b", "c",
]);
});
test("原始产物:只读地放进实验员工作目录,存版本时被跳过;运行目录里同样有,并带全部实验登记的指标说明", (t) => {
assert.deepEqual(diagnosisUnversionedNames(task), [...task.facts.unversionedNames, "baseline-artifact"]);
const workDir = tempDir(t, "work");
placeBaselineArtifact(task, baselineRunDir(t), workDir);
const placed = join(workDir, "baseline-artifact", "solution.json");
assert.equal(statSync(placed).mode & 0o777, 0o444);
assert.deepEqual(JSON.parse(readFileSync(placed, "utf8")), rowLayout(1 / 64, 1 / 32, 7 / 64));
const runDir = tempDir(t, "run");
prepareDiagnosisRun(task, programDir(t), baselineRunDir(t), runDir, EXPERIMENTS);
for (const name of ["initial.py", "harness.py", "harness-diagnosis.py", "baseline-artifact/solution.json", "metrics-spec.json"]) {
assert.ok(existsSync(join(runDir, name)), name);
}
assert.ok(statSync(join(runDir, "answers")).isDirectory());
const spec = JSON.parse(readFileSync(join(runDir, "metrics-spec.json"), "utf8")) as HypothesisMetric[];
assert.deepEqual(spec.map((s) => s.id), ["d-loose.note", "d-same.note"]);
});
test("从头到尾:六个实验一个容器按顺序跑,崩了、超时、改只读文件的各记各的,不影响别人;逐个单独判分", async (t) => {
const baseline = baselineRunDir(t);
const runDir = tempDir(t, "e2e-run");
prepareDiagnosisRun(task, programDir(t), baseline, runDir, EXPERIMENTS);
const ids = EXPERIMENTS.map((e) => e.id);
const process = await run(t, "e2e", diagnosisRun(task, digest, "inforsi-test-seed", ids), runDir);
assert.equal(process.ending, "exited", JSON.stringify(process));
assert.equal((process as { exitCode: number }).exitCode, 0);
const judgeDir = tempDir(t, "e2e-judge");
const step = prepareDiagnosisJudging(task, runDir, judgeDir, digest, process, EXPERIMENTS);
assert.equal(step.status, "validate");
if (step.status !== "validate") return;
const validator = await run(t, "e2e-validate", step.definition, judgeDir);
const judged = judgeDiagnosis(task, runDir, judgeDir, process, validator, EXPERIMENTS);
const by = Object.fromEntries(judged.experiments.map((e) => [e.id, e]));
assert.deepEqual(judged.experiments.map((e) => e.id), ids);
assert.equal(by["d-loose"]!.ending, "finished");
assert.equal(by["d-loose"]!.verdict, "valid");
assert.equal(value(by["d-loose"]!.score), 0.203125);
assert.equal(value(by["d-loose"]!.metrics["d-loose.note"]), 0.5);
assert.equal(value(by["d-loose"]!.metrics["legalization-deduction"]), 0);
assert.ok(value(by["d-loose"]!.wallSeconds) > 0 && value(by["d-loose"]!.wallSeconds) < 4);
// 读到了原始产物:原样返回,成绩就是那份产物的半径和。指标没写,记测不到,也没有串进上一个实验写的值。
assert.equal(by["d-same"]!.verdict, "valid");
assert.equal(value(by["d-same"]!.score), 0.40625);
assert.match(reason(by["d-same"]!.metrics["d-same.note"]), /metrics\.json 不存在/);
assert.equal(by["d-crash"]!.ending, "crashed");
assert.equal(by["d-crash"]!.verdict, "invalid");
assert.match(reason(by["d-crash"]!.score), /非零退出码/);
assert.match(reason(by["d-crash"]!.metrics["raw-score"]), /没有跑校验器/);
// 超时:被杀在分到的 4 秒上,用时不短于 4 秒;后面的实验照常跑完。
assert.equal(by["d-slow"]!.ending, "timeout");
assert.match(by["d-slow"]!.verdictReason, /超过分到的时间 4 秒/);
assert.ok(value(by["d-slow"]!.wallSeconds) >= 4 && value(by["d-slow"]!.wallSeconds) < 8);
assert.equal(by["d-tamper"]!.ending, "crashed");
assert.deepEqual(JSON.parse(readFileSync(join(runDir, "baseline-artifact", "solution.json"), "utf8")), rowLayout(1 / 64, 1 / 32, 7 / 64));
assert.equal(by["d-outside"]!.ending, "finished");
assert.equal(by["d-outside"]!.verdict, "invalid");
assert.match(by["d-outside"]!.verdictReason, /^校验器判不合法/);
assert.match(reason(by["d-outside"]!.score), /^不算数/);
});
/** 手写一次"只跑了一部分"的运行目录:report 里只有头几项。 */
function partialRun(t: TestContext, report: object[]): string {
const dir = tempDir(t, "partial");
writeFileSync(join(dir, "diagnosis.json"), JSON.stringify(report));
return dir;
}
test("整次运行被总时限杀掉:报告里没有的实验记没轮到,写明整次运行的结局;已有记录的照常", (t) => {
const timeout: ProcessResult = { ending: "timeout", cpuSeconds: m(23), wallSeconds: m(24), peakMemoryBytes: m(5) };
const two = EXPERIMENTS.slice(0, 2);
const runDir = partialRun(t, [{ id: "d-loose", status: "timeout", wall_seconds: 4.01 }]);
const step = prepareDiagnosisJudging(task, runDir, tempDir(t, "judge"), digest, timeout, two);
assert.equal(step.status, "judged");
const judged = (step as { result: DiagnosisJudged }).result;
assert.equal(judged.experiments[0]!.ending, "timeout");
assert.equal(value(judged.experiments[0]!.wallSeconds), 4.01);
assert.equal(judged.experiments[1]!.ending, "notRun");
assert.match(judged.experiments[1]!.verdictReason, /没轮到:整次诊断运行超过单次时限 24 秒/);
assert.match(reason(judged.experiments[1]!.wallSeconds), /没轮到/);
// 没有交出答案的实验,judgeDiagnosis 不要校验器的结果。
assert.deepEqual(judgeDiagnosis(task, runDir, tempDir(t, "judge2"), timeout, null, two), judged);
});
test("运行正常退出、报告里却缺实验:报错停下,不当成候选的失败", (t) => {
const exited: ProcessResult = { ending: "exited", exitCode: 0, cpuSeconds: m(1), wallSeconds: m(2), peakMemoryBytes: m(5) };
const runDir = partialRun(t, []);
mkdirSync(join(runDir, "answers"));
assert.throws(() => prepareDiagnosisJudging(task, runDir, tempDir(t, "judge"), digest, exited, EXPERIMENTS), /没有实验 d-loose/);
});
+8
View File
@@ -102,6 +102,14 @@ const samples: { [K in SubmissionKind]: Submission<K> } = {
],
},
claim: { choices: ["researcher-2-e1", "researcher-1-e2"] },
diagnosisProposal: {
explanations: [{ body: "卡在起点:……;若换成明显更强的起点分数不涨,这条解释就不成立" }],
experiments: [{ name: "更强的起点", spot: "solve.py 的起点生成", body: "换成……,交出答案", metrics: metrics.defined }],
},
diagnosisPrediction: { predictions: [{ explanation: "r2-dx1", experiment: "d1", range }], body: "理由" },
diagnosisImplementation: { locations: [{ experiment: "d1", locations: ["solve.py:10-40"] }], concerns: "无", body: "说明" },
diagnosisRepair: { locations: [{ experiment: "d1", locations: ["solve.py:10-40"] }], concerns: "无", body: "按核验意见补上" },
diagnosisVerification: { experiments: [{ experiment: "d1", followed: true, nothingElse: true, where: "solve.py:10-40" }], body: "无" },
proposal: { factors, safeguards: ["final-shrink"], metrics, authorEstimate: range, body },
firstPrediction: { range, body: "理由" },
critique: { target: "p2", body: "(推测)扰动可能和起点数混杂" },
+20
View File
@@ -260,3 +260,23 @@ test("比较的行号:改一行、加几行、只删不加,新旧两边的
// 给模型看的差异原文带 3 行上下文:三处改动相隔不到 6 行,合成一块。
assert.match(result.diff, /^@@ -1,10 \+1,12 @@\n a\n-b\n\+B\n c\n d\n e\n\+X\n\+Y\n\+Z\n f\n g\n-h\n i\n j\n$/m);
});
test("诊断程序(任务书 18):基线代码取出后放进只读的原始产物、改成诊断程序再存,产物被跳过,比较只看程序的改动,取出再存号不变", (t) => {
const repo = newRepo(t);
const baseline = save(repo, makeDir(t, { "initial.py": "a\nb\n", "evaluate.py": "e\n" }), []);
const workDir = checkout(t, repo, baseline);
// 原始产物放进实验员工作目录,设成只读(0444),与 tasks 的 placeBaselineArtifact 一样。
mkdirSync(join(workDir, "baseline-artifact"));
writeFileSync(join(workDir, "baseline-artifact", "solution.json"), "{}");
chmodSync(join(workDir, "baseline-artifact", "solution.json"), 0o444);
writeFileSync(join(workDir, "initial.py"), "a\nb\nexperiment\n");
const excluded = ["__pycache__", "solution.json", "baseline-artifact"];
const program = save(repo, workDir, excluded);
assert.notEqual(program, baseline);
const comparison = compareVersions(repo, baseline, program);
assert.deepEqual(comparison.files.map((f) => f.path), ["initial.py"]);
assert.deepEqual((comparison.files[0] as Extract<FileChange, { binary: false }>).hunks, [{ from: { after: 2 }, to: { start: 3, end: 3 } }]);
const again = checkout(t, repo, program);
assert.equal(existsSync(join(again, "baseline-artifact")), false);
assert.equal(save(repo, again, excluded), program);
});