From 60c21ae346fc18c545c12115d292a09bb621a93b Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 2 Oct 2026 01:50:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A0=94=E7=A9=B6=E8=A7=84=E5=88=99=20?= =?UTF-8?q?rules=20=E6=A8=A1=E5=9D=97=EF=BC=88=E4=BB=BB=E5=8A=A1=E4=B9=A6?= =?UTF-8?q?=2002=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rules 是纯计算,src/rules 共 1761 行:方法.md 第六部分的全部公式、第七部分的余额与扣次、4.2 的入口分配,以及种子与规则登记表。 预期值有三个来源:方法.md 的手算例;tests/fixtures/rules/ 下不看实现写的 Python 脚本;参考项目 calc15、calc14b 里与现行方法一致的部分。审阅做了两轮,每轮都换一个全新上下文的子 Agent,提出的问题都已修复或写明不修的理由。 用户确认的约定(原话"1–8 都同意"):入池、采纳、消融中发现都用严格不等号;成本用 max(0, ĝ₀);没测的格附"含它的最好组合"的净值;战绩在分数原值上判。三个数改为严格递增后,删掉了尖峰的全部专门处理(bfb96ba)。vocab 里 Range 的说明同步改为严格递增。 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + WORK_LOG.md | 10 +- docs/结构.md | 136 + src/rules/ablation.ts | 261 ++ src/rules/basics.ts | 80 + src/rules/bits.ts | 126 + src/rules/budget.ts | 30 + src/rules/cells.ts | 242 ++ src/rules/distribution.ts | 226 ++ src/rules/entries.ts | 51 + src/rules/forward.ts | 181 + src/rules/index.ts | 117 + src/rules/random.ts | 42 + src/rules/record.ts | 85 + src/rules/registry.ts | 44 + src/rules/value.ts | 276 ++ src/vocab/submissions.ts | 2 +- tests/fixtures/calc15/expected.json | 282 ++ tests/fixtures/rules/ablation.json | 3862 ++++++++++++++++++++++ tests/fixtures/rules/forward.json | 1658 ++++++++++ tests/fixtures/rules/gen_ablation.py | 494 +++ tests/fixtures/rules/gen_forward_info.py | 653 ++++ tests/fixtures/rules/info.json | 580 ++++ tests/fixtures/rules/misc.json | 943 ++++++ tests/fixtures/rules/rules_ref.py | 784 +++++ tests/rules-ablation.test.ts | 286 ++ tests/rules-bits.test.ts | 166 + tests/rules-budget.test.ts | 161 + tests/rules-calc15.test.ts | 279 ++ tests/rules-distribution.test.ts | 133 + tests/rules-fixtures-ablation.test.ts | 384 +++ tests/rules-fixtures-forward.test.ts | 228 ++ tests/rules-fixtures-helpers.ts | 65 + tests/rules-fixtures-info.test.ts | 278 ++ tests/rules-forward.test.ts | 261 ++ tests/rules-order.test.ts | 270 ++ tests/rules-value.test.ts | 393 +++ 37 files changed, 14067 insertions(+), 3 deletions(-) create mode 100644 src/rules/ablation.ts create mode 100644 src/rules/basics.ts create mode 100644 src/rules/bits.ts create mode 100644 src/rules/budget.ts create mode 100644 src/rules/cells.ts create mode 100644 src/rules/distribution.ts create mode 100644 src/rules/entries.ts create mode 100644 src/rules/forward.ts create mode 100644 src/rules/index.ts create mode 100644 src/rules/random.ts create mode 100644 src/rules/record.ts create mode 100644 src/rules/registry.ts create mode 100644 src/rules/value.ts create mode 100644 tests/fixtures/calc15/expected.json create mode 100644 tests/fixtures/rules/ablation.json create mode 100644 tests/fixtures/rules/forward.json create mode 100644 tests/fixtures/rules/gen_ablation.py create mode 100644 tests/fixtures/rules/gen_forward_info.py create mode 100644 tests/fixtures/rules/info.json create mode 100644 tests/fixtures/rules/misc.json create mode 100644 tests/fixtures/rules/rules_ref.py create mode 100644 tests/rules-ablation.test.ts create mode 100644 tests/rules-bits.test.ts create mode 100644 tests/rules-budget.test.ts create mode 100644 tests/rules-calc15.test.ts create mode 100644 tests/rules-distribution.test.ts create mode 100644 tests/rules-fixtures-ablation.test.ts create mode 100644 tests/rules-fixtures-forward.test.ts create mode 100644 tests/rules-fixtures-helpers.ts create mode 100644 tests/rules-fixtures-info.test.ts create mode 100644 tests/rules-forward.test.ts create mode 100644 tests/rules-order.test.ts create mode 100644 tests/rules-value.test.ts diff --git a/.gitignore b/.gitignore index f4e2c6d..c1622dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tsbuildinfo +__pycache__/ diff --git a/WORK_LOG.md b/WORK_LOG.md index a4a759d..0045398 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -12,6 +12,7 @@ - `docs/方法.md`:由参考项目的研究方案 v3、设计-15、设计-16 合成(f32c0bd)。之后按用户决定改过:消融只测信息价值选中的格(5e4f8c2);保障的定义借用"控制变量"和"重构"来说明(141494f)。 - `docs/结构.md`:13 个模块加 vocab、步骤清单、账本 12 种记录、可替换点、检查规则、粗估规模(6cf9f0c)。 - 任务书 01(工具链 + vocab):npm 包、TypeScript、`node --test`、dependency-cruiser 规则全写,临时违规实测 14 条都报错;vocab 1030 行,`npm run check` 通过,`npm test` 24 条通过。结构.md 第八部分补了 vocab 对外内容。 +- 任务书 02(rules):src/rules 13 个文件、1761 行,`npm test` 192 条通过。预期值来源有三个:方法.md 的手算例;`tests/fixtures/rules/` 下不看实现写的 Python 脚本(重跑后 JSON 逐字节相同);参考项目 calc15、calc14b 里与现行方法一致的部分。审阅做了两轮,每轮都换一个全新上下文的子 Agent。结构.md 第八部分补了 rules 对外内容。 ## 关键决定(用户原话见各提交说明) @@ -25,6 +26,8 @@ - 每份预测只对一个候选、每份挑刺只针对一个目标;入口、因子、实现条款的编号由程序按位置分配;实现提交不设代码字段(程序从工作目录冻结);运行结果用"没启动 / 正常退出 / 超时 / 被取消"一个字段表示启动与结束;四种 token 数都可记测不到。用户原话:"3、4、5、6:同意保留你的写法。方法.md 第五部分的表(入口编号、实现的代码字段)由监督者改。" - 计算时间不再是预算,只记录;预算只有运行次数(42347e4)。用户原话:"CPU 秒不当预算但是要记录"。 - 解释的每格三个数改为可选,关掉消融时不交。用户原话:"同意 A:解释的 cells 去掉 minItems,改为可选"。 +- rules 的约定(方法.md 没写死的地方):入池 P < 0.5 严格;采纳和消融中发现都要严格大于采纳线;成本用 max(0, ĝ₀);没测的格附"含它的最好组合"的净值;战绩在分数原值上判;编号一律按自然序比较。用户原话:"1–8 都同意"。消融成分权重跟随 λ、分歧量不掺开放预留,由用户改进方法.md(a050771)。 +- 三个数改为严格递增,去掉尖峰(bfb96ba)。用户原话:"凡是为'三个数相等''宽度为 0''开放预留缩成一个点'写的专门处理,全部删掉";"以后再提'某种情况需要专门处理'时,请附上它在参考项目真实运行里出现过的证据……查不到就默认不写"。 - 答案路径、保障动手次数只记读到的原始内容或读不到的原因;在不在位置表里,由以后拿得到位置表的模块现算,不存。用户原话:"按'能现算的不存',tasks 只记读到的原始内容……对表由以后有位置表的模块现算,把这条记进 WORK_LOG。" ## 未完成 @@ -32,7 +35,7 @@ - `inbox/调研.md`:要迁进 `docs/调研/` 的调研结论。 - `docs/方法.md` 里还有 7 条标〔自定〕的规则,待用户过目。 - 留给 submissions 的跨字段规则(vocab 字段定义不管): - 1. q1 ≤ q5 ≤ q9:作者自评、两次预测、解释的每一格。 + 1. q1 < q5 < q9(严格递增,方法.md 第五部分,提交 bfb96ba):作者自评、两次预测、解释的每一格。 2. 因子数不超过上限,回应改过的因子清单同样适用。 3. 同一提案里两个因子不能延续同一个因子;延续的编号要在信念本里看得到。 4. 保障短名不能是 `main`,不能写成 `p<数字>`,同一清单里不重复。 @@ -48,6 +51,9 @@ 14. 消融核验覆盖每个因子。 - 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。 - 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。 +- 上面 1–8 条约定里,除了用户已改进方法.md 的两条,其余还没写进方法.md 第六部分(由监督者决定是否补)。 +- 给 config:每批上限必须 ≥ 1(为 0 时 R̂ 是无穷大,选格会卡住)。 +- 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。 - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 ## 工作方式(用户 2026-10-01 定) @@ -61,4 +67,4 @@ ## 下一步 -任务书 01 已核对通过(97a4359)。`任务书/02-rules.md` 已写好,等用户另开 Agent 执行,监督者核对。之后是 ledger → …。 +任务书 02(rules)已完成并提交,等监督者核对。之后是 ledger → …。 diff --git a/docs/结构.md b/docs/结构.md index beef63f..b66215d 100644 --- a/docs/结构.md +++ b/docs/结构.md @@ -244,3 +244,139 @@ - `Range`(字段定义):三个数 q1、q5、q9,即一个分数有一成、一半、九成可能低于多少。 - `SUBMISSION_SCHEMAS`:每种提交的字段定义,键与 `SUBMISSION_KINDS` 相同;同一份定义是类型、收件格式检查和提交工具参数。 - `Submission`(类型):某种提交的内容类型,例如 `Submission<"proposal">`。 + +### rules + +下面是 `src/rules/index.ts` 导出的全部内容。rules 是纯计算:输入数字,输出数字,不碰文件、数据库和模型,也不用全局随机数。λ、ε、已知最好、每批上限、余额这些参数都由调用方传进来,rules 里只写死方法.md 6.11 标"固定"的几个常数。三个数一律用 vocab 的 `Range`。编号(候选、研究者、入口、因子、解释)一律按自然序比较:数字部分按数值大小比,所以 c2 排在 c10 前面。 + +几个反复出现的词:**折线**是把一个人的三个数画成"分数低于 x 的把握"的分段直线(6.2);**合并分布**是几条折线按权重加起来的结果(6.3);**改进量**是分数比基线好了多少,正数表示变好,越小越好的任务也换算成正数为好(6.1)。决定类函数的返回值里带着写决定记录要用的中间数,不只返回"选了谁"。 + +**常数与基础** + +- `INTERACTION_CUT`:配合关系标签里相对交互量的界限 0.2(6.6"配合关系标签")。 +- `INDISPENSABLE_SUPPORT`:判"缺一不可"的撑几成界限 0.9(6.6)。 +- `NET_TOLERANCE`:选格时比较净值的容差 1e-12,差距在它以内的组合当作一样好(6.7"选格")。 +- `GRID_COMBINATION_CAP`:只测一部分格时,网格组合总数的上限 4,000,000(6.7"信息价值")。 +- `GRID_SUBDIVISIONS`:网格里每段最多再等分 20 份(6.7"信息价值")。 +- `epsilonFloor(ε)`:算 ε/100;采纳线下限、剩余空间下限、比特格宽三处共用这一个函数,保证末位一致(6.11"跟随 ε")。 +- `improvement(from, to, direction)`:从 from 到 to 按指标方向好了多少,越大越好时是 to − from,越小越好时是 from − to(6.1)。 +- `compareIds(a, b)`:按自然序比较两个编号,供所有排序和"编号最小"的判断使用;p01 和 p1 这类逐段相同的编号再按整串定先后,不会判成相等。 +- `combinations(n, k)`:列出从 0..n−1 里选 k 个的全部组合,按字典序排,选批和选格列组合时用。 + +**范围与合并分布(6.1–6.3)** + +- `Point`、`Knots`(类型):折线上的一个点(横坐标,低于它的把握),以及按横坐标严格递增排列的一串点。 +- `Component`、`Mixture`(类型):合并分布的一个成分(一条折线加它的权重),以及成分的列表,权重加起来是 1。 +- `personKnots(range)`:把一个人的三个数变成五个点的折线,两端各补一段与相邻半宽相同的尾巴(6.2)。 +- `below(knots, x)`:读折线在 x 处"低于 x 的把握"。 +- `knotsMean(knots)`:折线所代表的分数的平均值(期望)。 +- `density(knots, x)`:折线在 x 处的斜率,也就是把握在这一点有多集中(密度);押的因子更新权重时用(6.8)。 +- `mapKnots(knots, f, decreasing)`:把折线的横坐标换到另一把刻度上;换算是反向时点序倒过来、把握换成 1 减原把握。 +- `reserveKnots(lines)`:开放预留,即给"大家都没想到的结果"留的那一份:取所有折线的最低点到最高点,左右各再放宽一半,在上面均匀铺开(6.3)。 +- `authorShares(authors)`:每条解释的占比,研究者之间等权,同一人的几条平分(6.3"合并"、6.7"成分与权重")。 +- `mixtureBelow(m, x)`:合并分布在 x 处"低于 x 的把握"。 +- `breakpoints(m)`:合并分布全部折点的横坐标,去重、升序。 +- `mapMixture(m, f, decreasing)`:把合并分布的每个成分都换到另一把刻度上,权重不变。 +- `forwardMixtures(candidates, λ)`:正向每个候选的合并分布,(1 − λ) × 各人折线平均 + λ × 开放预留,开放预留由这次选批的全部候选、全部人共用(6.3"正向")。 +- `ablationCellMixture(entries, λ)`:消融一格的合并分布,各条解释按 `authorShares` 分 1 − λ,这一格自己的开放预留占 λ(6.3"消融")。 +- `toImprovement(m, b, direction)`:把合并分布从分数原值换算成相对基线 b 的改进量;越小越好时刻度反向,一成和九成随之对调(6.1)。 +- `mixtureQuantile(m, p)`:合并分布的分位点,即"有 p 的把握低于多少";只给人看,不参与决定。 +- `mixtureRange(m)`:合并分布的一成、一半、九成,按分数原值,消融格显示"大家合起来的范围"用。 +- `forwardMergedRange(imp, b, direction)`:正向候选"大家合起来的范围",在改进量上读三个数再换回分数原值。 +- `integrateUpperTail(ds, from)`:按段精确算 ∫[1 − ∏F] dt,正向的批内最好期望和消融全测时的信息价值共用这一个积分。 + +**正向:入池、选批、采纳(6.4、6.5,4.3 ⑫)** + +- `lossProbability(imp, ε)`:候选"比基线低 ε 以上"的把握;严格小于一半才入池(6.4"入池")。 +- `batchValue(imps)`:一组候选的批内最好期望 V(S),即这批里最好的那个预计比基线好多少,低于基线按 0 算(6.4)。 +- `BatchInput`(类型):两条选批规则共用的输入:候选及各人的三个数、基线、方向、ε、λ、每批上限、余额、种子。 +- `BatchDecision`(类型):选批结果,含 6.4"选批记录写入"的全部内容:参数、批量 k、每个候选的合并范围和是否入池、各组合的 V、选中的组合及其 V、候选池的 V、分数尺读数。 +- `selectBestExpected(input)`:选批规则"批内最好期望",在候选池里列出全部组合取 V 最大的,V 完全相等时取编号小的组合(6.4)。 +- `selectUniformRandom(input)`:选批规则"均匀随机",用种子从全部候选里抽,不看入池门槛;入池、合并范围、分数尺读数照样算出来记下(第十一部分)。 +- `AdoptionLineInput`(类型):算采纳线要的基线 B、已知最好 K\*(没有时为 null)、ε 和方向。 +- `adoptionLine(input)`:采纳线(6.5),返回线的值和 (K\* − B) ÷ 4 的原值;没有已知最好时是 ε,结果不低于 ε/100。 +- `adopt(input)`:采纳判断,只看有有效分数的候选,最好的那个比基线高出采纳线以上才采纳;返回采纳线、最好的候选及其改进量和采纳结果,够写采纳记录。 + +**消融结算(6.6,4.3 ⑬ ⑰)** + +- `CellOutcome`(类型):一格的五种结局:有效分数、证据不足、没测、跑不起来、一起关(带实际关掉的因子和可能的分数)。 +- `AblationCell`(类型):一格,即一个因子和它的结局。 +- `AblationExplanation`(类型):一条解释,带作者和它对每一格给的三个数。 +- `listCells(factors)`:列格,被采纳改法的每个因子各一格,按因子编号排(4.3 ⑬)。 +- `totalGain(baseline, full, direction)`:总提升 G = f(完整) − f(基线);G ≤ 0 说明上游选错了改法,直接报错(6.6"撑几成")。 +- `supportOf(baseline, full, off, direction)`:某个因子撑几成,即关掉它后损失的分数占总提升的比例,不截断(6.6)。 +- `SynergyLabel`(类型):配合关系标签的代号,六种:关掉反而更好、不全、缺一不可、互补、替代、各管各;中文说明以后由 materials 写。 +- `synergyLabel(supports)`:按 6.6 那张表从上往下判出标签,同时返回各格撑几成之和与相对交互量(有格没测出有效分数时这两个数为 null)。 +- `sharedSettings(cells)`:判断哪些"一起关"的格其实是同一种设置(关的因子集合相同),给出该沿用哪一格的结果(6.6)。 +- `ExplanationVerdict`(类型):一条解释在一格上的判定,确认或否定。 +- `judgeExplanations(explanations, cells)`:每条解释在每个有效格上,实测落在它的一成到九成之间(含两端)记确认,否则记否定(6.6)。 +- `inseparableGroups(judgments)`:找出在所有有效格上确认与否定完全相同的解释,归成"现有测法分不开"的组(6.6)。 +- `ablationFinding(input)`:消融中发现,找出比当前基线高出采纳线以上的格,几格都过线时取分数最好的、同分取因子编号小的;返回采纳线、(K\* − B) ÷ 4、全部过线的格和选中的格(4.3 ⑰)。 +- `SettleInput`(类型):一次消融结算的输入:基线、完整分数、方向、ε、已知最好、λ、被采纳改法的因子清单、各格结局、各条解释。清单里每个因子都要有一格,缺了直接报错。 +- `settleAblation(input)`:一次消融的完整结算,给出 G、每格的撑几成和合并范围、有效格的世界比特、惊讶和分歧量、配合关系标签、确认与否定、分不开的组、消融中发现(4.3 ⑰、6.6、6.9);押的因子另由 `chooseBet` 算。 + +**信息价值与选格(6.7、6.8)** + +- `OPEN_RESERVE`:成分名单里开放预留的名字,排在最后。 +- `AblationModelInput`(类型):建成分模型要的输入:基线、完整分数、方向、已知最好、ε、λ、因子编号、各条解释。 +- `AblationModel`(类型):成分模型:因子、成分(各条解释加开放预留)、起始权重、G、剩余空间 h、每格每成分的折线(分数原值和撑几成两种刻度)及期望、每格的事前期望撑几成。 +- `remainingSpace(full, knownBest, ε, direction)`:剩余空间 h,有已知最好时是 max(K\* − f(完整), ε/100),没有时是 1(6.7"方向收益")。 +- `ablationModel(input)`:建成分模型,每条解释一个成分、开放预留一个成分,解释合起来占 1 − λ(研究者等权、同一人平分),开放预留占 λ(6.7"成分与权重")。 +- `gridSize(k)`:测 k 格时每格的小段数 m,即满足 m^k ≤ 4,000,000 的最大整数(6.7"信息价值")。 +- `cellGrid(knots, k)`:把一格按折点切成小段,返回各小段中点和每个成分在各小段上的把握;小段数的规则按 6.7 原文。 +- `measuredGrid(model, c, k)`:模型里第 c 格的网格,在分数原值上切段,再把中点换算成撑几成。 +- `informationValueExact(model)`:全部格都测时的信息价值,逐段精确积分。 +- `informationValueGrid(model, S)`:用网格算的信息价值,不论测几格都走网格,主要用来和精确算法互相对照。 +- `informationValue(model, S)`:信息价值 VOI(S),即测了 S 这几格之后,挑出最该押的因子能比现在多挣多少(6.7);全测走精确积分,只测一部分走网格,空集是 0。 +- `ComboParams`(类型):算价值与成本要的余额、每批上限、知识被用上的概率 u、衰减 r。 +- `ComboValue`(类型):一个测格组合的 VOI、R̂、m′、价值、成本、净值(6.7"价值减成本")。 +- `evaluateCombos(model, params)`:列出格数不超过"余额 − 1"的全部非空组合并算价值、成本、净值,另给 ĝ₀ 的原值。 +- `CellRuleInput`(类型):四条选格规则共用的输入,即成分模型的输入加 `ComboParams` 和种子。 +- `UntestedReason`(类型):一格没测的原因:剩余次数不够、信息价值不足以抵过成本、随机没抽中、规则是"都不测"。 +- `CellDecision`(类型):选格结果,含 6.7 要求附上的全部数字:参数、种子、G、h、成分与起始权重、各因子的事前期望收益、ĝ₀、全部组合、选中的格及其净值,以及每一格测不测、没测的原因和含它的净值最大组合。 +- `topCombo(combos)`:在给定的组合里挑净值最大的,差在 1e-12 以内算一样好,再先取格数少的、后按因子编号比(6.7"选格"第 2 条)。它不管净值是否大于 1e-12,所以也用来挑"含这一格的最好组合"。 +- `pickByNet(combos)`:按净值挑要测的组合。先只留净值大于 1e-12 的组合,一个都没有就一格都不测;再在留下的组合里用 `topCombo` 挑。 +- `selectByValue(input)`:选格规则"信息价值减成本"(6.7"选格")。 +- `selectAll(input)`:选格规则"全测",按因子编号测前"余额 − 1"格(第十一部分)。 +- `selectRandomSameCount(input)`:选格规则"随机抽同样多的格",格数和"信息价值减成本"会选的一样,用种子抽(第十一部分)。 +- `selectNone(input)`:选格规则"都不测"(第十一部分)。 +- `BetResult`(类型):押的因子的结果:更新后的成分权重、权重是否无从更新、各因子的方向收益(实测换算的或估计的)、挑出的因子及其预测收益、押或不押的两种情况、关掉反而更好的格。 +- `chooseBet(input)`:押的因子,用有效格的实测更新各成分的权重,再挑方向收益最大的因子;有格关掉反而更好或估计收益不为正时不押,实测落在所有成分之外时保持起始权重(6.8)。 + +**信息账(6.9)** + +这一组是方法里"唯一的尺子",不随选批、选格规则变,一律在分数原值上从本批基线 b 起按格宽切小格。 + +- `bitWidth(ε)`:格宽 Δ = ε/100。 +- `entropyBits(ps)`:一组把握的拿不准程度 −Σ p log₂ p,单位是比特。 +- `worldBits(m, origin, width)`:世界比特,即合并分布在宽 Δ 的小格上有多拿不准。 +- `cellIndex(origin, width, v)`:实测 v 落在第几个小格,和切格用同一套边界。 +- `surprise(m, origin, width, value)`:惊讶 = −log₂(事前给实测所在小格的把握),返回格号、事前把握和惊讶值;事前把握为 0 时惊讶记"测不到"并写明原因。 +- `disagreement(entries, origin, width)`:分歧量,大家平均后的世界比特减去各人世界比特的平均,量的是人与人之间的差别,不掺开放预留,只记录。 +- `Placement`(类型):落点四段:低于一成、一成到一半、一半到九成、高于九成。 +- `placement(range, v)`:实测落在一个人范围的哪一段。 +- `covers(range, v)`:实测是否落在一成到九成之间(含两端),也是 6.6 里"确认"的判据。 +- `coverage(samples)`:落点四段的计数和覆盖率;一条样本都没有时覆盖率记"测不到",不记 0。 + +**余额与战绩(第七部分、6.10 第 4 条)** + +- `RunState`(类型):一次正式运行在账上的三种状态:已预留还没核对出结果、确认启动、确认没启动。 +- `runBalance(total, runs)`:从运行记录数出已扣次数、占用名额和余额;确认启动的扣一次,已预留的先占着名额,确认没启动的退还。只数运行次数,不算计算时间。 +- `forwardBatchSize(batchCap, balance)`:本轮正向批量 k = min(每批上限, 余额)。 +- `ablationCellLimit(balance)`:消融最多测"余额 − 1"格,余额为 0 或 1 时是 0。 +- `Phase`(类型):正向或消融。 +- `Tally`(类型):一个人的战绩:总数、中位数偏高几次、偏低几次、被穿过上沿几次、下沿几次、包住几次、落点四段计数。 +- `trackRecord(samples)`:按研究者、按正向与消融分开数战绩,只用来显示,不改任何权重(6.9、6.10)。 +- `authorVsMerged(items, direction)`:"作者自评 vs 大家合并",给出作者比大家乐观多少,以及实测落在两个范围的哪一段;只收已经跑过、有有效成绩的候选(6.10 第 4 条)。 + +**入口分配、种子与规则登记表(4.2、第九部分、第十一部分)** + +- `EntryAssignment`(类型):一个研究者分到的入口,以及它是第几志愿(按剩余入口分到的为 null)。 +- `assignEntries(input)`:第零轮的入口分配,按志愿一轮一轮分,争同一个入口时先给作者、再给编号最小的人,志愿用完的人按编号拿剩下的入口;结果里带志愿序号,够写分配记录(4.2"分配规则")。 +- `decisionSeed(input)`:决定的种子,由研究编号、轮次、版本经 vocab 的 `hash` 算出(第九部分)。 +- `seededRandom(seed)`:从种子确定地展开一串随机整数,同一个种子永远得到同一串数,不用 `Math.random`。 +- `seededSample(items, size, seed)`:用种子从一组编号里不放回地均匀抽 size 个,结果只取决于种子和编号集合,与传入次序无关;候选和因子都这样抽。 +- `BATCH_RULES`、`BatchRuleName`:选批规则登记表,名字 → 函数和版本号,两条:批内最好期望、均匀随机。 +- `CELL_RULES`、`CellRuleName`:选格规则登记表,四条:信息价值减成本、全测、随机抽同样多的格、都不测。 +- `runBatchRule(name, input)`:按名字跑一条选批规则,结果带上规则名和版本,flow 写决定记录时从这里取。 +- `runCellRule(name, input)`:按名字跑一条选格规则,结果带上规则名和版本。 diff --git a/src/rules/ablation.ts b/src/rules/ablation.ts new file mode 100644 index 0000000..4b7ac90 --- /dev/null +++ b/src/rules/ablation.ts @@ -0,0 +1,261 @@ +import type { Direction, Measured, Range } from "../vocab/index.ts"; +import { compareIds, improvement, INDISPENSABLE_SUPPORT, INTERACTION_CUT } from "./basics.ts"; +import { bitWidth, covers, disagreement, surprise, worldBits } from "./bits.ts"; +import { ablationCellMixture, mixtureRange } from "./distribution.ts"; +import { adoptionLine } from "./forward.ts"; + +// 方法.md 6.6 消融结算,4.3 ⑬ 列格、⑰ 结算与"消融中发现"。 + +/** + * 一格的结局(6.6"一格的几种结局"): + * - valid:有效分数(含合法化后的分数),只有它进标签的求和、判确认与否定、记比特和惊讶; + * - insufficient:证据不足(启动了,但跑崩、超时、没有产物、修不了或判为违规); + * - untested:没测; + * - cannotRun:跑不起来(不启动、不扣次); + * - joint:一起关,off 是实际关掉的全部因子;score 是那次运行的有效分数,没有时为 null。 + */ +export type CellOutcome = + | { kind: "valid"; score: number } + | { kind: "insufficient" } + | { kind: "untested" } + | { kind: "cannotRun" } + | { kind: "joint"; off: readonly string[]; score: number | null }; + +export interface AblationCell { + factor: string; + outcome: CellOutcome; +} + +/** 一条解释:作者,以及对每一格给的三个数(关掉这个因子后的分数,原值)。 */ +export interface AblationExplanation { + id: string; + author: string; + cells: ReadonlyArray<{ factor: string; range: Range }>; +} + +/** 列格(4.3 ⑬):被采纳改法的每个因子各一格"只关掉它",按因子编号排。 */ +export function listCells(factors: readonly string[]): string[] { + return [...factors].sort(compareIds); +} + +/** + * 总提升 G = f(完整) − f(基线)(6.6"撑几成",按方向折算)。被消融的一定是被采纳的改法, + * 所以 G 不小于采纳线;G ≤ 0 说明上游选错了改法,直接报错。 + */ +export function totalGain(baseline: number, full: number, direction: Direction): number { + const gain = improvement(baseline, full, direction); + if (!(gain > 0)) { + throw new Error( + `消融结算(方法.md 6.6"撑几成"):总提升 G = ${gain} ≤ 0(基线 ${baseline},完整 ${full},方向 ${direction});` + + `被消融的必须是被采纳的改法`, + ); + } + return gain; +} + +/** 撑几成(6.6)= [f(完整) − f(关掉 p)] ÷ G,按方向折算,不截断。 */ +export function supportOf(baseline: number, full: number, off: number, direction: Direction): number { + return improvement(off, full, direction) / totalGain(baseline, full, direction); +} + +/** + * 配合关系标签代号(6.6 表,从上往下判,命中即停): + * offBetter 有因子关掉反而更好;incomplete 不全;indispensable 缺一不可;complementary 互补; + * substitute 替代;independent 各管各。中文和"给下一轮的话"由 materials 写。 + */ +export type SynergyLabel = "offBetter" | "incomplete" | "indispensable" | "complementary" | "substitute" | "independent"; + +/** + * 配合关系标签(6.6)。supports 是每个因子"只关它、有效分数"的撑几成,没有这样的格时为 null。 + * 每个因子都有有效格时给出 Σ撑 和相对交互量 i = Σ撑 − 1,否则这两个数为 null。 + */ +export function synergyLabel(supports: ReadonlyArray): { + label: SynergyLabel; + supportSum: number | null; + interaction: number | null; +} { + const complete = supports.every((s) => s !== null); + const supportSum = complete ? (supports as number[]).reduce((t, s) => t + s, 0) : null; + const interaction = supportSum === null ? null : supportSum - 1; + const label: SynergyLabel = supports.some((s) => s !== null && s < 0) + ? "offBetter" + : !complete + ? "incomplete" + : (supports as number[]).every((s) => s > INDISPENSABLE_SUPPORT) + ? "indispensable" + : interaction! > INTERACTION_CUT + ? "complementary" + : interaction! < -INTERACTION_CUT + ? "substitute" + : "independent"; + return { label, supportSum, interaction }; +} + +/** + * "一起关"是对称的(6.6):设置由实际关掉的因子集合决定,关 p 连带关 q 和关 q 连带关 p 是同一种设置。 + * 给出每一格的设置键,以及同一次消融里因子编号更小、设置相同的第一格(有它就沿用它的结果,不重跑、不重复扣次)。 + */ +export function sharedSettings( + cells: ReadonlyArray<{ factor: string; off: readonly string[] }>, +): Array<{ factor: string; key: string; sameAs: string | null }> { + const first = new Map(); + return [...cells] + .sort((a, b) => compareIds(a.factor, b.factor)) + .map((c) => { + const key = [...new Set(c.off)].sort(compareIds).join("+"); + const sameAs = first.get(key) ?? null; + if (sameAs === null) first.set(key, c.factor); + return { factor: c.factor, key, sameAs }; + }); +} + +export type ExplanationVerdict = "confirmed" | "denied"; + +/** + * 确认与否定(6.6):每条解释在每个有效格上,实测落在它自己的 [q1, q9] 里(含两端)记确认,否则记否定。 + * 解释按编号排,格按因子编号排,只列有效格。 + */ +export function judgeExplanations( + explanations: readonly AblationExplanation[], + cells: readonly AblationCell[], +): Array<{ id: string; verdicts: Array<{ factor: string; verdict: ExplanationVerdict }> }> { + const valid = [...cells].sort((a, b) => compareIds(a.factor, b.factor)).filter((c) => c.outcome.kind === "valid"); + return [...explanations] + .sort((a, b) => compareIds(a.id, b.id)) + .map((e) => ({ + id: e.id, + verdicts: valid.map((c) => { + const range = e.cells.find((x) => x.factor === c.factor)!.range; + const score = (c.outcome as { score: number }).score; + return { factor: c.factor, verdict: covers(range, score) ? ("confirmed" as const) : ("denied" as const) }; + }), + })); +} + +/** + * 现有测法分不开(6.6):在所有有效格上确认与否定序列完全相同的解释归为一组,只列两条以上的组。 + * 没有有效格时不标。组内按解释编号排,组之间按第一条的编号排。 + */ +export function inseparableGroups(judgments: ReturnType): string[][] { + if (judgments.length === 0 || judgments[0]!.verdicts.length === 0) return []; + const groups = new Map(); + for (const j of judgments) { + const key = j.verdicts.map((v) => v.verdict).join(","); + groups.set(key, [...(groups.get(key) ?? []), j.id]); + } + return [...groups.values()] + .filter((g) => g.length > 1) + .map((g) => g.sort(compareIds)) + .sort((a, b) => compareIds(a[0]!, b[0]!)); +} + +/** + * 消融中发现(4.3 ⑰):有效格和有分数的"一起关"格里,比当前基线(刚被采纳的完整代码,B = f(完整)) + * 的改进严格大于采纳线的格过线;几格过线取分数最好的,同分取因子编号小的。 + */ +export function ablationFinding(input: { + full: number; + knownBest: number | null; + epsilon: number; + direction: Direction; + cells: readonly AblationCell[]; +}): { + line: number; + quarter: number | null; + passed: Array<{ factor: string; score: number; improvement: number }>; + chosen: string | null; +} { + const { line, quarter } = adoptionLine({ ...input, baseline: input.full }); + const passed = [...input.cells] + .sort((a, b) => compareIds(a.factor, b.factor)) + .flatMap((c) => { + const o = c.outcome; + const score = o.kind === "valid" || (o.kind === "joint" && o.score !== null) ? o.score : null; + if (score === null) return []; + const gain = improvement(input.full, score, input.direction); + return gain > line ? [{ factor: c.factor, score, improvement: gain }] : []; + }); + let chosen: (typeof passed)[number] | null = null; + for (const p of passed) if (chosen === null || p.improvement > chosen.improvement) chosen = p; + return { line, quarter, passed, chosen: chosen?.factor ?? null }; +} + +/** 一次消融结算的输入(4.3 ⑰)。baseline 是本批基线 b,full 是被采纳的完整代码的分数。 */ +export interface SettleInput { + baseline: number; + full: number; + direction: Direction; + epsilon: number; + knownBest: number | null; + lambda: number; + /** 被采纳改法的因子编号:按它列格、贴标签,每个因子都要有一格。 */ + factors: readonly string[]; + cells: readonly AblationCell[]; + explanations: readonly AblationExplanation[]; +} + +/** + * 一次消融的结算(4.3 ⑰、6.6、6.9):每格的撑几成、合并范围,有效格的世界比特、惊讶、分歧量(格宽 ε/100, + * 从 b 起切);配合关系标签;每条解释的确认与否定;分不开的组;消融中发现。押的因子另由 chooseBet 算。 + * 格按因子清单列(4.3 ⑬ 每个因子一格):清单里的因子没有格、或者有清单外的格,都是上游的错,直接报错, + * 不把漏掉的因子当成"不全"。 + */ +export function settleAblation(input: SettleInput): { + gain: number; + cells: Array<{ + factor: string; + outcome: CellOutcome["kind"]; + support: number | null; + merged: Range; + worldBits: number | null; + surprise: { cell: number; prior: number; bits: Measured } | null; + disagreement: number | null; + }>; + label: ReturnType; + judgments: ReturnType; + inseparable: string[][]; + finding: ReturnType; +} { + const gain = totalGain(input.baseline, input.full, input.direction); + const width = bitWidth(input.epsilon); + const factors = listCells(input.factors); + for (const c of input.cells) { + if (!factors.includes(c.factor)) { + throw new Error(`消融结算(方法.md 4.3 ⑬ 列格):格 ${c.factor} 不在被采纳改法的因子清单 ${factors.join("、")} 里`); + } + } + const cells = factors + .map((f) => { + const c = input.cells.find((x) => x.factor === f); + if (c === undefined) throw new Error(`消融结算(方法.md 4.3 ⑬ 列格):因子 ${f} 没有对应的格`); + return c; + }) + .map((c) => { + const entries = input.explanations.map((e) => ({ + id: e.id, + author: e.author, + range: e.cells.find((x) => x.factor === c.factor)!.range, + })); + const mixture = ablationCellMixture(entries, input.lambda); + const o = c.outcome; + const valid = o.kind === "valid"; + return { + factor: c.factor, + outcome: o.kind, + support: valid ? improvement(o.score, input.full, input.direction) / gain : null, + merged: mixtureRange(mixture), + worldBits: valid ? worldBits(mixture, input.baseline, width) : null, + surprise: valid ? surprise(mixture, input.baseline, width, o.score) : null, + disagreement: valid ? disagreement(entries, input.baseline, width) : null, + }; + }); + const judgments = judgeExplanations(input.explanations, input.cells); + return { + gain, + cells, + label: synergyLabel(cells.map((c) => c.support)), + judgments, + inseparable: inseparableGroups(judgments), + finding: ablationFinding(input), + }; +} diff --git a/src/rules/basics.ts b/src/rules/basics.ts new file mode 100644 index 0000000..2fa6ccc --- /dev/null +++ b/src/rules/basics.ts @@ -0,0 +1,80 @@ +import type { Direction } from "../vocab/index.ts"; + +// 方法.md 6.11"常数与参数"里标"固定"的常数。标"跟随 ε"的量不写成常数,由 epsilonFloor 从传进来的 ε 算。 + +/** 配合关系标签的相对交互量界限 ±0.2(6.6"配合关系标签")。 */ +export const INTERACTION_CUT = 0.2; +/** "缺一不可"的撑几成界限 0.9(6.6"配合关系标签")。 */ +export const INDISPENSABLE_SUPPORT = 0.9; +/** 净值容差 1e-12(6.7"选格"第 2 条)。 */ +export const NET_TOLERANCE = 1e-12; +/** 部分格积分的组合总数上限:测 k 格时每格小段数 m 满足 m^k ≤ 4,000,000(6.7"信息价值")。 */ +export const GRID_COMBINATION_CAP = 4_000_000; +/** 部分格积分里每段最多等分几份(6.7"信息价值",s ≤ 20)。 */ +export const GRID_SUBDIVISIONS = 20; + +/** + * ε/100:采纳线下限、剩余空间下限、比特格宽 Δ 都是它(6.11"跟随 ε")。 + * 三处共用这一个函数,免得哪一处改写成别的写法后末位不同。 + */ +export function epsilonFloor(epsilon: number): number { + return epsilon / 100; +} + +/** + * 改进量:从 from 到 to 按方向好了多少,正数表示变好(6.1"方向")。 + * 越大越好时是 to − from,越小越好时是 from − to。 + */ +export function improvement(from: number, to: number, direction: Direction): number { + return direction === "maximize" ? to - from : from - to; +} + +/** + * 编号比较(自然序):候选、研究者、入口、因子、解释的编号都用它。方法说的"编号最小"是数字意义上的大小, + * 所以 c2 < c10、researcher-2 < researcher-10、p2 < p10。编号切成数字段和非数字段逐段比:两段都是数字时按数值比 + * (先比去掉前导零后的位数,再比字符串,不转成浮点数,免得长编号丢精度),否则按 JavaScript 字符串顺序比; + * 一方的段先用完的排前面。逐段都相同(如 p01 与 p1)时再按整串的字符串顺序定先后,保证不同编号不会相等。 + */ +export function compareIds(a: string, b: string): number { + const sa = a.match(SEGMENT) ?? []; + const sb = b.match(SEGMENT) ?? []; + for (let i = 0; i < Math.min(sa.length, sb.length); i++) { + const c = compareSegment(sa[i]!, sb[i]!); + if (c !== 0) return c; + } + if (sa.length !== sb.length) return sa.length - sb.length; + return stringOrder(a, b); +} + +const SEGMENT = /\d+|\D+/g; +const DIGITS = /^\d/; + +function compareSegment(x: string, y: string): number { + if (!DIGITS.test(x) || !DIGITS.test(y)) return stringOrder(x, y); + const tx = x.replace(/^0+(?=\d)/, ""); + const ty = y.replace(/^0+(?=\d)/, ""); + return tx.length !== ty.length ? tx.length - ty.length : stringOrder(tx, ty); +} + +function stringOrder(x: string, y: string): number { + return x < y ? -1 : x > y ? 1 : 0; +} + +/** 从 0..n−1 里选 k 个的全部组合,组合内升序,组合之间按字典序列出。 */ +export function combinations(n: number, k: number): number[][] { + const out: number[][] = []; + const pick: number[] = []; + const walk = (start: number): void => { + if (pick.length === k) { + out.push([...pick]); + return; + } + for (let i = start; i < n; i++) { + pick.push(i); + walk(i + 1); + pick.pop(); + } + }; + walk(0); + return out; +} diff --git a/src/rules/bits.ts b/src/rules/bits.ts new file mode 100644 index 0000000..6b8d137 --- /dev/null +++ b/src/rules/bits.ts @@ -0,0 +1,126 @@ +import type { Measured, Range } from "../vocab/index.ts"; +import { compareIds, epsilonFloor } from "./basics.ts"; +import { breakpoints, mixtureBelow, personKnots, type Knots, type Mixture } from "./distribution.ts"; + +// 方法.md 6.9 比特、惊讶、核对范围准不准。这是"唯一的尺子":不随选批、选格规则变, +// 一律在分数原值上、从本批基线 b 起按格宽 Δ 切。 + +/** 格宽 Δ = ε/100(6.9)。 */ +export function bitWidth(epsilon: number): number { + return epsilonFloor(epsilon); +} + +/** 第 j 小格的左端 b + jΔ。切格和找实测所在格都用这一个式子,边界上的值两边归格一致。 */ +function edge(origin: number, width: number, j: number): number { + return origin + j * width; +} + +/** 从 lo 到 hi 全部覆盖的小格把握:第 j 格 [b + jΔ, b + (j+1)Δ) 的把握 = F(右端) − F(左端)。 */ +function massesOver(cdf: (x: number) => number, origin: number, width: number, lo: number, hi: number): number[] { + const j0 = Math.floor((lo - origin) / width) - 1; + const j1 = Math.ceil((hi - origin) / width) + 1; + const out: number[] = []; + for (let j = j0; j < j1; j++) out.push(cdf(edge(origin, width, j + 1)) - cdf(edge(origin, width, j))); + return out; +} + +/** −Σ p log₂ p,只算 p > 0 的项。 */ +export function entropyBits(ps: readonly number[]): number { + let h = 0; + for (const p of ps) if (p > 0) h -= p * Math.log2(p); + return h; +} + +/** + * 世界比特(6.9):合并分布在宽 Δ 的小格上的拿不准,−Σ p_j log₂ p_j。正向传候选的合并分布(分数原值), + * 消融格传这一格的成分混合;origin 是本批基线 b。 + */ +export function worldBits(m: Mixture, origin: number, width: number): number { + const xs = breakpoints(m); + return entropyBits(massesOver((x) => mixtureBelow(m, x), origin, width, xs[0]!, xs[xs.length - 1]!)); +} + +/** 实测 v 所在小格的编号 j:b + jΔ ≤ v < b + (j+1)Δ,用和切格同一套边界核对,避免除法舍入把边界上的值归错格。 */ +export function cellIndex(origin: number, width: number, v: number): number { + let j = Math.floor((v - origin) / width); + while (v < edge(origin, width, j)) j -= 1; + while (v >= edge(origin, width, j + 1)) j += 1; + return j; +} + +/** + * 惊讶(6.9)= −log₂ p,p 是合并分布给实测所在那一小格的事前把握。 + * p 为 0 时 bits 是"测不到"并写明原因,不拿 0 或无穷大顶替。 + */ +export function surprise( + m: Mixture, + origin: number, + width: number, + value: number, +): { cell: number; prior: number; bits: Measured } { + const j = cellIndex(origin, width, value); + const p = mixtureBelow(m, edge(origin, width, j + 1)) - mixtureBelow(m, edge(origin, width, j)); + const bits: Measured = + p > 0 ? { status: "measured", value: -Math.log2(p) } : { status: "unmeasured", reason: "事前把握为 0,惊讶算不出" }; + return { cell: j, prior: p, bits }; +} + +/** + * 分歧量(6.9"比特、惊讶、核对范围准不准",只记录)= 各人折线等权平均后的世界比特 − 各人世界比特的平均,不掺开放预留。 + * 量的是人与人之间的差别:一个人交了几条解释(消融格)时,先把他的几条等权平均成"这个人的折线"再算他的比特, + * 同一人几条解释之间的差别不算进来;然后在研究者之间等权平均。正向一人一个范围(id 用研究者编号), + * "这个人的折线"就是他的那一条。研究者按编号排、每人的几条按 id 排后再求和,结果与传入次序无关。 + * 大家交的范围完全一样时为 0。 + */ +export function disagreement( + entries: ReadonlyArray<{ id: string; author: string; range: Range }>, + origin: number, + width: number, +): number { + const sorted = [...entries].sort((a, b) => compareIds(a.author, b.author) || compareIds(a.id, b.id)); + const lines: Knots[] = sorted.map((e) => personKnots(e.range)); + const xs = lines.flatMap((k) => k.map(([x]) => x)); + const lo = Math.min(...xs); + const hi = Math.max(...xs); + const byAuthor = new Map(); + sorted.forEach((e, i) => byAuthor.set(e.author, [...(byAuthor.get(e.author) ?? []), lines[i]!])); + // 每个人的小格把握:他的几条折线等权平均。 + const people = [...byAuthor.values()].map((own) => { + const person: Mixture = own.map((knots) => ({ weight: 1 / own.length, knots })); + return massesOver((x) => mixtureBelow(person, x), origin, width, lo, hi); + }); + const mean = people[0]!.map((_, j) => people.reduce((s, ms) => s + ms[j]!, 0) / people.length); + return entropyBits(mean) - people.reduce((s, ms) => s + entropyBits(ms), 0) / people.length; +} + +/** 落点四段(6.9):实测落在一个人范围的哪一段。 */ +export type Placement = "belowQ1" | "q1ToQ5" | "q5ToQ9" | "aboveQ9"; + +/** v < q1 低于一成;q1 ≤ v < q5 一成到一半;q5 ≤ v ≤ q9 一半到九成;v > q9 高于九成。 */ +export function placement(r: Range, v: number): Placement { + return v < r.q1 ? "belowQ1" : v < r.q5 ? "q1ToQ5" : v <= r.q9 ? "q5ToQ9" : "aboveQ9"; +} + +/** 实测落在 [q1, q9](含两端)就算包住;也是 6.6 里"确认"的判据。 */ +export function covers(r: Range, v: number): boolean { + return r.q1 <= v && v <= r.q9; +} + +/** 落点四段的计数和覆盖率(6.9)。一条样本都没有时覆盖率"测不到",不记 0。 */ +export function coverage(samples: ReadonlyArray<{ range: Range; value: number }>): { + counts: Record; + covered: number; + total: number; + rate: Measured; +} { + const counts: Record = { belowQ1: 0, q1ToQ5: 0, q5ToQ9: 0, aboveQ9: 0 }; + let covered = 0; + for (const s of samples) { + counts[placement(s.range, s.value)] += 1; + if (covers(s.range, s.value)) covered += 1; + } + const total = samples.length; + const rate: Measured = + total === 0 ? { status: "unmeasured", reason: "没有样本" } : { status: "measured", value: covered / total }; + return { counts, covered, total, rate }; +} diff --git a/src/rules/budget.ts b/src/rules/budget.ts new file mode 100644 index 0000000..b53641c --- /dev/null +++ b/src/rules/budget.ts @@ -0,0 +1,30 @@ +// 方法.md 第七部分"什么时候扣次数""什么时候开下一轮":余额从运行记录数出来,不单独存。 +// 计算时间不是预算,这里只数运行次数。 + +/** + * 一次正式运行在账上的状态: + * - reserved:已经预留了名额、还没有核对出结果(含"状态未知"),先占着名额; + * - started:确认启动,扣一次(之后正常结束、失败、超时、被取消都算); + * - notStarted:确认没有启动,退还名额。 + */ +export type RunState = "reserved" | "started" | "notStarted"; + +/** 余额:已扣 = started 的个数;占用 = started + reserved;余额 = 运行次数总数 − 占用。 */ +export function runBalance( + total: number, + runs: readonly RunState[], +): { charged: number; reserved: number; balance: number } { + const charged = runs.filter((r) => r === "started").length; + const reserved = runs.filter((r) => r === "reserved").length; + return { charged, reserved, balance: total - charged - reserved }; +} + +/** 本轮正向的批量 k = min(每批上限, 余额)。 */ +export function forwardBatchSize(batchCap: number, balance: number): number { + return Math.min(batchCap, balance); +} + +/** 消融最多测"余额 − 1"格,至少留 1 次给下一轮正向;余额为 0 或 1 时是 0。 */ +export function ablationCellLimit(balance: number): number { + return Math.max(0, balance - 1); +} diff --git a/src/rules/cells.ts b/src/rules/cells.ts new file mode 100644 index 0000000..6fc9338 --- /dev/null +++ b/src/rules/cells.ts @@ -0,0 +1,242 @@ +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"; + +// 方法.md 6.7"选格"、第十一部分的四条选格规则,以及 6.8 押的因子。 + +/** 选格规则的统一输入:同一份输入可以换选格规则重算。 */ +export interface CellRuleInput extends AblationModelInput, ComboParams { + /** 本次决定的种子(decisionSeed)。只有随机规则用它,照样记进记录。 */ + seed: string; +} + +/** + * 一格没测的原因:budget 剩余次数不够;value 信息价值不足以抵过成本(6.7"选格"第 3 条); + * notDrawn 随机规则没抽中;ruleNone 规则是"都不测"。后两种只出现在对照规则里。 + */ +export type UntestedReason = "budget" | "value" | "notDrawn" | "ruleNone"; + +/** 选格规则的统一输出,含 6.7 要求附上的全部数字;规则名和版本由登记表补上。 */ +export interface CellDecision { + seed: string; + params: { + balance: number; + batchCap: number; + u: number; + decay: number; + lambda: number; + epsilon: number; + knownBest: number | null; + }; + /** 总提升 G。 */ + gain: number; + /** 剩余空间 h。 */ + space: number; + components: string[]; + /** 起始权重,与 components 对齐。 */ + weights: number[]; + /** 各因子的事前期望方向收益 E(收益_p),按因子编号排。 */ + priorGains: Array<{ factor: string; gain: number }>; + /** ĝ₀ = max_p E(收益_p) 的原值(成本里取 max(0, ĝ₀))。 */ + g0: number; + /** 可选的全部组合及其价值、成本、净值(余额 ≤ 1 时为空)。 */ + combos: ComboValue[]; + /** 选中要测的格,按因子编号排。 */ + chosen: string[]; + /** 选中组合的净值;一格都不测时是空组合的 0。 */ + chosenNet: number; + /** + * 每一格:测不测;没测的写原因,并附"含这一格的可选组合里净值最大的那个"(按 topCombo 挑); + * 没有任何含它的可选组合(余额 ≤ 1)时 bestCombo 为 null。 + */ + cells: Array<{ + factor: string; + tested: boolean; + reason: UntestedReason | null; + bestCombo: { factors: string[]; net: number } | null; + }>; +} + +/** 四条选格规则共用:建模型、列组合,再按 pick 选出的格和没测原因拼成决定。 */ +function decide( + input: CellRuleInput, + pick: (ctx: { factors: string[]; combos: ComboValue[]; limit: number }) => { + chosen: string[]; + reasonFor: (factor: string) => UntestedReason; + }, +): CellDecision { + const model = ablationModel(input); + const { g0, combos } = evaluateCombos(model, input); + const { chosen, reasonFor } = pick({ factors: model.factors, combos, limit: ablationCellLimit(input.balance) }); + const key = chosen.join(","); + const bestComboOf = (factor: string): { factors: string[]; net: number } | null => { + const best = topCombo(combos.filter((c) => c.factors.includes(factor))); + return best === null ? null : { factors: [...best.factors], net: best.net }; + }; + return { + seed: input.seed, + params: { + balance: input.balance, + batchCap: input.batchCap, + u: input.u, + decay: input.decay, + lambda: input.lambda, + epsilon: input.epsilon, + knownBest: input.knownBest, + }, + gain: model.gain, + space: model.space, + components: model.components, + weights: model.weights, + priorGains: model.factors.map((factor, c) => ({ factor, gain: model.space * model.prior[c]! })), + g0, + combos, + chosen, + chosenNet: chosen.length === 0 ? 0 : netOf(combos, key), + cells: model.factors.map((factor) => { + const tested = chosen.includes(factor); + return { + factor, + tested, + reason: tested ? null : reasonFor(factor), + bestCombo: tested ? null : bestComboOf(factor), + }; + }), + }; +} + +/** 选中组合的净值。选中的组合一定在组合表里,找不到是内部错误。 */ +function netOf(combos: readonly ComboValue[], key: string): number { + const c = combos.find((x) => x.factors.join(",") === key); + if (c === undefined) throw new Error(`选格(方法.md 6.7"选格"):选中的组合 {${key}} 不在可选组合表里`); + return c.net; +} + +/** + * 净值最大的组合(6.7"选格"第 2 条的比较规则):求最大净值 M;净值 ≥ M − 1e-12 的组合视为和 M 相同, + * 在它们里先取格数少的,再按因子编号逐个比、取小的。没有组合时为 null。 + * "含这一格的净值最大组合"直接按这条挑(只是记录,不要求净值 > 1e-12)。 + */ +export function topCombo(combos: readonly C[]): 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)); +} + +/** 格数少的在前;格数相同时按因子编号逐个比。 */ +function compareCombos(a: readonly string[], b: readonly string[]): number { + if (a.length !== b.length) return a.length - b.length; + for (let i = 0; i < a.length; i++) { + const c = compareIds(a[i]!, b[i]!); + if (c !== 0) return c; + } + return 0; +} + +/** + * 按净值挑要测的组合(6.7"选格"第 2 条):先只留净值 > 1e-12 的组合,一个都没有就不测; + * 在留下的组合里按 topCombo 挑。组合里的因子按编号排好传进来。 + */ +export function pickByNet(combos: ReadonlyArray<{ factors: readonly string[]; net: number }>): string[] { + const best = topCombo(combos.filter((c) => c.net > NET_TOLERANCE)); + return best === null ? [] : [...best.factors]; +} + +/** + * 选格规则"信息价值减成本"(6.7"选格"):按 pickByNet 选。没选中的格:已选的再加这一格超过"余额 − 1" + * 记"剩余次数不够",否则记"信息价值不足以抵过成本"。 + */ +export function selectByValue(input: CellRuleInput): CellDecision { + return decide(input, ({ combos, limit }) => { + const chosen = pickByNet(combos); + return { chosen, reasonFor: () => (chosen.length + 1 > limit ? "budget" : "value") }; + }); +} + +/** 选格规则"全测"(第十一部分):按因子编号测前"余额 − 1"格,放不下的记"剩余次数不够"。 */ +export function selectAll(input: CellRuleInput): CellDecision { + return decide(input, ({ factors, limit }) => ({ chosen: factors.slice(0, limit), reasonFor: () => "budget" })); +} + +/** + * 选格规则"随机抽同样多的格"(第十一部分):格数 = "信息价值减成本"对同一输入会选的格数, + * 从全部格里用种子不放回地均匀抽。没抽中的记 notDrawn。 + */ +export function selectRandomSameCount(input: CellRuleInput): CellDecision { + const count = selectByValue(input).chosen.length; + return decide(input, ({ factors }) => ({ + chosen: seededSample(factors, count, input.seed), + reasonFor: () => "notDrawn", + })); +} + +/** 选格规则"都不测"(第十一部分):全部记没测,原因 ruleNone。 */ +export function selectNone(input: CellRuleInput): CellDecision { + return decide(input, () => ({ chosen: [], reasonFor: () => "ruleNone" })); +} + +/** 押的因子(6.8)的结果。 */ +export interface BetResult { + /** 更新后的成分权重(与模型的成分对齐);无从更新时等于起始权重。 */ + weights: number[]; + /** 实测落在所有成分的范围之外、权重之和为 0 时为 true,此时保持起始权重。 */ + weightsKept: boolean; + /** 各因子的方向收益(按因子编号排):measured 为 true 的是有效格实测换算的,其余是按更新后的权重估的。 */ + gains: Array<{ factor: string; gain: number; measured: boolean }>; + /** 方向收益最大的因子(严格更大才替换,平局取编号小的),以及它的预测收益。不押时照样记。 */ + factor: string; + predictedGain: number; + /** bet 押;offBetter 任一有效格撑几成 < 0;notPositive 挑出的因子估计收益不为正。 */ + decision: "bet" | "offBetter" | "notPositive"; + /** 撑几成 < 0 的有效格:关掉它比完整代码好多少(按方向折算,正数),按因子编号排。 */ + offBetter: Array<{ factor: string; better: number }>; +} + +/** + * 押的因子(6.8):成分新权重 ∝ 起始权重 × 各有效格(只关一个因子)上这个成分在实测处的密度(分数原值上,连乘); + * 有效格的收益用实测换算,其余格(没测、证据不足、跑不起来、一起关)用新权重对各成分的期望收益加权平均; + * "一起关"的分数不参与更新。权重之和为 0 时保持起始权重。 + */ +export function chooseBet(input: AblationModelInput & { cells: readonly AblationCell[] }): BetResult { + const model = ablationModel(input); + const scores = model.factors.map((f) => { + 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((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 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 }; + }); + let best = 0; + for (let c = 1; c < gains.length; c++) if (gains[c]!.gain > gains[best]!.gain) best = c; + const offBetter = model.factors.flatMap((factor, c) => { + const x = scores[c]; + if (x === null || x === undefined) return []; + const better = improvement(input.full, x, input.direction); + return better > 0 ? [{ factor, better }] : []; + }); + const predictedGain = gains[best]!.gain; + return { + weights, + weightsKept, + gains, + factor: gains[best]!.factor, + predictedGain, + decision: offBetter.length > 0 ? "offBetter" : predictedGain > 0 ? "bet" : "notPositive", + offBetter, + }; +} diff --git a/src/rules/distribution.ts b/src/rules/distribution.ts new file mode 100644 index 0000000..7b9cf8f --- /dev/null +++ b/src/rules/distribution.ts @@ -0,0 +1,226 @@ +import type { Direction, Range } from "../vocab/index.ts"; +import { compareIds, improvement } from "./basics.ts"; + +// 方法.md 6.1–6.3:三个数 → 一个人的折线;开放预留;合并分布;从合并分布读一成、一半、九成。 +// 另有积分 ∫[1 − ∏F] 的逐段多项式精确算法,6.4 的批内最好期望和 6.7 全测时的信息价值共用。 + +/** 折线上的一个点:(横坐标 x, "低于 x 的把握" y)。 */ +export type Point = readonly [number, number]; +/** 一条折线:点按横坐标严格递增排列,纵坐标从 0 升到 1。折线连续,每一段宽度都大于 0。 */ +export type Knots = readonly Point[]; +/** 合并分布的一个成分:一条折线和它的权重。 */ +export interface Component { + weight: number; + knots: Knots; +} +/** 合并分布:成分的加权和,权重加起来是 1。开放预留也是一个成分(它的折线是两点的均匀段)。 */ +export type Mixture = readonly Component[]; + +/** + * 一个人的折线(6.2):(q1 − (q5 − q1), 0)、(q1, 0.1)、(q5, 0.5)、(q9, 0.9)、(q9 + (q9 − q5), 1)。 + * 三个数严格递增(方法.md 第五部分),五个点的横坐标也严格递增。 + */ +export function personKnots(r: Range): Knots { + return [ + [r.q1 - (r.q5 - r.q1), 0], + [r.q1, 0.1], + [r.q5, 0.5], + [r.q9, 0.9], + [r.q9 + (r.q9 - r.q5), 1], + ]; +} + +/** + * "低于 x 的把握" F(x)。折线连续,低于和不高于 x 的把握相同。取最后一个横坐标 < x 的点 i:没有就是 0,是末点就是 1, + * 否则在 i、i+1 之间线性插值。 + */ +export function below(k: Knots, x: number): number { + let i = -1; + for (let j = 0; j < k.length; j++) if (k[j]![0] < x) i = j; + if (i < 0) return 0; + if (i === k.length - 1) return 1; + const [x0, y0] = k[i]!; + const [x1, y1] = k[i + 1]!; + return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0); +} + +/** 折线的期望 Σ (y_{i+1} − y_i)(x_i + x_{i+1})/2:每段把握均匀摊开,期望是段中点。 */ +export function knotsMean(k: Knots): number { + let m = 0; + for (let i = 0; i + 1 < k.length; i++) m += (k[i + 1]![1] - k[i]![1]) * ((k[i]![0] + k[i + 1]![0]) / 2); + return m; +} + +/** 折线在 x 处的密度:所在段的斜率,段取左闭右开 [x0, x1),落在折点上时取右边那一段;范围外是 0。 */ +export function density(k: Knots, x: number): number { + for (let i = 0; i + 1 < k.length; i++) { + const [x0, y0] = k[i]!; + const [x1, y1] = k[i + 1]!; + if (x0 <= x && x < x1) return (y1 - y0) / (x1 - x0); + } + return 0; +} + +/** + * 把折线搬到另一把刻度上:每个横坐标经 f 换算。f 递减时点的次序倒过来,把握换成 1 − 原把握 + * (P(f(X) < t) = P(X > f⁻¹(t)))。 + */ +export function mapKnots(k: Knots, f: (x: number) => number, decreasing: boolean): Knots { + const mapped = k.map(([x, y]) => [f(x), y] as const); + return decreasing ? mapped.reverse().map(([x, y]) => [x, 1 - y] as const) : mapped; +} + +/** 开放预留(6.3):取给定全部折线(含尾巴)的最低点 L、最高点 U,W = U − L,在 [L − W/2, U + W/2] 上均匀铺开。 */ +export function reserveKnots(lines: readonly Knots[]): Knots { + const L = Math.min(...lines.map((k) => k[0]![0])); + const U = Math.max(...lines.map((k) => k[k.length - 1]![0])); + const W = U - L; + return [ + [L - W / 2, 0], + [U + W / 2, 1], + ]; +} + +/** + * 研究者之间等权、同一个人的几条平分(6.3"合并"、6.7"成分与权重")。 + * authors 是每条解释(或每个范围)的作者,返回的占比与之对齐,加起来是 1;分母是不同作者的人数。 + */ +export function authorShares(authors: readonly string[]): number[] { + const people = new Set(authors).size; + const count = new Map(); + for (const a of authors) count.set(a, (count.get(a) ?? 0) + 1); + return authors.map((a) => 1 / people / count.get(a)!); +} + +export function mixtureBelow(m: Mixture, x: number): number { + let s = 0; + for (const c of m) s += c.weight * below(c.knots, x); + return s; +} + +/** 合并分布全部折点的横坐标(含开放预留两端),去重、升序。 */ +export function breakpoints(m: Mixture): number[] { + return [...new Set(m.flatMap((c) => c.knots.map(([x]) => x)))].sort((a, b) => a - b); +} + +/** 每个成分的折线都搬到另一把刻度上,权重不变(见 mapKnots)。 */ +export function mapMixture(m: Mixture, f: (x: number) => number, decreasing: boolean): Mixture { + return m.map((c) => ({ weight: c.weight, knots: mapKnots(c.knots, f, decreasing) })); +} + +/** + * 正向的合并分布(6.3"正向"、"合并"),按分数原值:每个候选 F = (1 − λ) × 各人折线的平均 + λ × 开放预留。 + * 开放预留由这次选批里全部候选、全部人的折线一起定,所有候选共用。各人按研究者编号排好再合并, + * 求和次序与传入次序无关,重算时逐位相同。返回值与 candidates 对齐。 + */ +export function forwardMixtures( + candidates: ReadonlyArray<{ ranges: ReadonlyArray<{ researcher: string; range: Range }> }>, + lambda: number, +): Mixture[] { + const lines = candidates.map((c) => + [...c.ranges].sort((a, b) => compareIds(a.researcher, b.researcher)).map((r) => personKnots(r.range)), + ); + const reserve = reserveKnots(lines.flat()); + return lines.map((people) => [ + ...people.map((knots) => ({ weight: (1 - lambda) / people.length, knots })), + { weight: lambda, knots: reserve }, + ]); +} + +/** + * 消融一格的合并分布(6.3"消融"、"合并"),按分数原值:这一格的全部解释按 authorShares 加权占 1 − λ, + * 这一格自己的开放预留占 λ(L、U 只取这一格的解释)。成分按解释编号排(求和次序由编号定,与传入次序无关, + * 重算时逐位相同),开放预留在最后。 + */ +export function ablationCellMixture( + entries: ReadonlyArray<{ id: string; author: string; range: Range }>, + lambda: number, +): Mixture { + const sorted = [...entries].sort((a, b) => compareIds(a.id, b.id)); + const lines = sorted.map((e) => personKnots(e.range)); + const shares = authorShares(sorted.map((e) => e.author)); + return [ + ...lines.map((knots, i) => ({ weight: (1 - lambda) * shares[i]!, knots })), + { weight: lambda, knots: reserveKnots(lines) }, + ]; +} + +/** + * 换算成改进量 Y = improvement(b, 分数)(6.1)。越小越好时 Y = b − 分数,刻度反向,一成和九成随之对调。 + * 先补尾巴、定预留再换算,和先换算三个数再补尾巴结果相同(尾巴两侧按同侧半宽补,是镜像)。 + */ +export function toImprovement(m: Mixture, b: number, direction: Direction): Mixture { + return mapMixture(m, (x) => improvement(b, x, direction), direction === "minimize"); +} + +/** + * 合并分布的分位点 inf{x : F(x) ≥ p},0 < p < 1。F 在相邻折点之间是一次函数,找到第一个 F ≥ p 的折点, + * 在它和前一个折点之间反解;几条折线之间有空当时 F 走平,取平段的左端。 + * 只给人看"大家合起来的范围",不参与任何决定。 + */ +export function mixtureQuantile(m: Mixture, p: number): number { + const xs = breakpoints(m); + let prevX = xs[0]!; + let prevF = 0; + for (let i = 1; i < xs.length; i++) { + const x = xs[i]!; + const f = mixtureBelow(m, x); + if (f >= p) return prevX + ((p - prevF) / (f - prevF)) * (x - prevX); + prevX = x; + prevF = f; + } + return prevX; +} + +/** 合并分布的一成、一半、九成,按分数原值(消融格用)。 */ +export function mixtureRange(m: Mixture): Range { + return { q1: mixtureQuantile(m, 0.1), q5: mixtureQuantile(m, 0.5), q9: mixtureQuantile(m, 0.9) }; +} + +/** + * 正向候选"大家合起来的范围"(6.1、6.3):在改进量上求一成、一半、九成,再换回分数原值; + * 越小越好时换回后一成和九成对调。 + */ +export function forwardMergedRange(imp: Mixture, b: number, direction: Direction): Range { + const y1 = mixtureQuantile(imp, 0.1); + const y5 = mixtureQuantile(imp, 0.5); + const y9 = mixtureQuantile(imp, 0.9); + return direction === "maximize" + ? { q1: b + y1, q5: b + y5, q9: b + y9 } + : { q1: b - y9, q5: b - y5, q9: b - y1 }; +} + +/** 多项式相乘,系数从常数项开始。 */ +function polyMul(a: readonly number[], b: readonly number[]): number[] { + const out = new Array(a.length + b.length - 1).fill(0); + for (let i = 0; i < a.length; i++) for (let j = 0; j < b.length; j++) out[i + j]! += a[i]! * b[j]!; + return out; +} + +/** + * ∫_from^top [1 − ∏_d F_d(t)] dt,F_d 是各分布"低于 t 的把握",top 是全部折点里最大的(再往上被积函数是 0)。 + * 切点是全部分布的折点(含开放预留两端)里大于 from 的部分,加上 from 本身。每段内各 F_d 是一次函数, + * 由两端的值定出;乘积是多项式,按系数精确积分。 + * 用在 6.4 的批内最好期望(from = 0,改进量上)和 6.7 全测时一个成分下各格最大值的期望。 + */ +export function integrateUpperTail(ds: readonly Mixture[], from: number): number { + const xs = [...new Set(ds.flatMap(breakpoints))].sort((a, b) => a - b); + const edges = [from, ...xs.filter((x) => x > from)]; + let total = 0; + for (let i = 0; i + 1 < edges.length; i++) { + const a = edges[i]!; + const b = edges[i + 1]!; + const h = b - a; + let poly = [1]; + for (const d of ds) { + const left = mixtureBelow(d, a); + const right = mixtureBelow(d, b); + poly = polyMul(poly, [left, (right - left) / h]); + } + // ∫₀ʰ (1 − Σ c_k t^k) dt + let integral = h; + for (let k = 0; k < poly.length; k++) integral -= (poly[k]! * h ** (k + 1)) / (k + 1); + total += integral; + } + return total; +} diff --git a/src/rules/entries.ts b/src/rules/entries.ts new file mode 100644 index 0000000..02d0b59 --- /dev/null +++ b/src/rules/entries.ts @@ -0,0 +1,51 @@ +import { compareIds } from "./basics.ts"; + +// 方法.md 4.2"分配规则":第零轮按志愿一轮一轮分入口。 + +export interface EntryAssignment { + researcher: string; + entry: string; + /** 第几志愿(从 1 数);按第 3 条分到剩下入口的人为 null。 */ + rank: number | null; +} + +/** + * 入口分配(4.2)。研究者和入口都先按编号(compareIds,自然序)排好,"编号最小"按编号判,与传入的数组次序无关。 + * 1. 第 k 轮里还没分到的人看自己的第 k 志愿:入口还空着、只有他一个人选,就分给他; + * 2. 几个人同时选了同一个空着的入口:作者在其中就分给作者,否则分给编号最小的人; + * 3. 志愿用完还没分到的人,按编号从小到大,各拿剩下入口里编号最小的一个。 + * N = 1 时第 1 轮就把第一志愿分给他,不需要另写分支。结果按研究者编号排。 + */ +export function assignEntries(input: { + researchers: ReadonlyArray<{ id: string; choices: readonly string[] }>; + entries: ReadonlyArray<{ id: string; author: string }>; +}): EntryAssignment[] { + const researchers = [...input.researchers].sort((a, b) => compareIds(a.id, b.id)); + const entries = [...input.entries].sort((a, b) => compareIds(a.id, b.id)); + const authorOf = new Map(entries.map((e) => [e.id, e.author] as const)); + const taken = new Set(); + const result = new Map(); + const rounds = Math.max(0, ...researchers.map((r) => r.choices.length)); + for (let k = 0; k < rounds; k++) { + // 本轮各个空着的入口被谁选了;选的人按研究者编号次序加入,所以 who[0] 是编号最小的。 + const wants = new Map(); + for (const r of researchers) { + const choice = r.choices[k]; + if (result.has(r.id) || choice === undefined || taken.has(choice)) continue; + wants.set(choice, [...(wants.get(choice) ?? []), r.id]); + } + for (const [entry, who] of wants) { + const author = authorOf.get(entry)!; + const winner = who.includes(author) ? author : who[0]!; + taken.add(entry); + result.set(winner, { researcher: winner, entry, rank: k + 1 }); + } + } + const left = entries.filter((e) => !taken.has(e.id)); + for (const r of researchers) { + if (result.has(r.id)) continue; + const e = left.shift()!; + result.set(r.id, { researcher: r.id, entry: e.id, rank: null }); + } + return researchers.map((r) => result.get(r.id)!); +} diff --git a/src/rules/forward.ts b/src/rules/forward.ts new file mode 100644 index 0000000..5bb0e49 --- /dev/null +++ b/src/rules/forward.ts @@ -0,0 +1,181 @@ +import type { Direction, Range } from "../vocab/index.ts"; +import { combinations, compareIds, epsilonFloor, improvement } from "./basics.ts"; +import { forwardBatchSize } from "./budget.ts"; +import { + forwardMergedRange, + forwardMixtures, + integrateUpperTail, + mixtureBelow, + toImprovement, + type Mixture, +} from "./distribution.ts"; +import { seededSample } from "./random.ts"; + +// 方法.md 6.4 入池、选批、分数尺读数;6.5 采纳线;4.3 ⑫ 采纳。 + +/** 选批规则的统一输入:同一份输入可以换选批规则重算。 */ +export interface BatchInput { + /** 本轮的全部候选(入池在规则里判);ranges 是每位研究者第二次预测的三个数(分数原值),一人一份,带研究者编号。 */ + candidates: ReadonlyArray<{ id: string; ranges: ReadonlyArray<{ researcher: string; range: Range }> }>; + /** 本批基线分数 b。 */ + baseline: number; + direction: Direction; + epsilon: number; + /** 开放预留 λ。 */ + lambda: number; + batchCap: number; + /** 运行次数余额。 */ + balance: number; + /** 本次决定的种子(decisionSeed)。批内最好期望规则不用它,照样记进记录。 */ + seed: string; +} + +/** 选批规则的统一输出,含 6.4"选批记录写入"的全部内容;规则名和版本由登记表补上。 */ +export interface BatchDecision { + seed: string; + params: { epsilon: number; lambda: number; batchCap: number; balance: number }; + /** 批量 k = min(每批上限, 余额)。 */ + k: number; + /** 每个候选(按编号排):大家合起来的范围(分数原值)、"比 b 低 ε 以上"的把握、是否入池。 */ + candidates: Array<{ id: string; merged: Range; lossProbability: number; inPool: boolean }>; + /** 入池的候选编号(按编号排)。 */ + pool: string[]; + /** 列出的每个组合及其批内最好期望 V(均匀随机规则不列组合,是空数组)。 */ + combos: Array<{ ids: string[]; value: number }>; + /** 选中的组合(按编号排)。 */ + chosen: string[]; + /** V(S*)。 */ + chosenValue: number; + /** E[max(0, max_{c∈池} Y_c)] = V(候选池)。 */ + poolValue: number; + /** 分数尺读数 = V(候选池) − V(S*),只记录。 */ + scaleReading: number; +} + +/** + * 批内最好期望 V(S) = ∫_0^∞ [1 − ∏_{c∈S} F_c(t)] dt(6.4),F_c 是改进量上的合并分布,候选之间按互不相关, + * 低于 b 的部分按 0 算。空组合是 0。 + */ +export function batchValue(imps: readonly Mixture[]): number { + return imps.length === 0 ? 0 : integrateUpperTail(imps, 0); +} + +/** 入池(6.4):合并分布里"比 b 低 ε 以上"的把握 P(Y < −ε) 严格小于一半才入池;正好低 ε 不算"低 ε 以上"。 */ +export function lossProbability(imp: Mixture, epsilon: number): number { + return mixtureBelow(imp, -epsilon); +} + +/** 两条选批规则共用的部分:合并、入池、合并范围、批量、候选池的 V。 */ +function assess(input: BatchInput) { + const sorted = [...input.candidates].sort((a, b) => compareIds(a.id, b.id)); + const imps = forwardMixtures(sorted, input.lambda).map((m) => toImprovement(m, input.baseline, input.direction)); + const candidates = sorted.map((c, i) => { + const loss = lossProbability(imps[i]!, input.epsilon); + return { + id: c.id, + merged: forwardMergedRange(imps[i]!, input.baseline, input.direction), + lossProbability: loss, + inPool: loss < 0.5, + }; + }); + const poolIdx = candidates.flatMap((c, i) => (c.inPool ? [i] : [])); + return { + imps, + candidates, + poolIdx, + k: forwardBatchSize(input.batchCap, input.balance), + poolValue: batchValue(poolIdx.map((i) => imps[i]!)), + params: { epsilon: input.epsilon, lambda: input.lambda, batchCap: input.batchCap, balance: input.balance }, + }; +} + +/** + * 选批规则"批内最好期望"(6.4):列出候选池里所有 min(k, 池里个数) 个候选的组合,取 V 最大的; + * V 完全相等(不带容差)时取编号字典序小的组合(组合按字典序列出,后来者严格更大才替换)。 + */ +export function selectBestExpected(input: BatchInput): BatchDecision { + const a = assess(input); + const size = Math.min(a.k, a.poolIdx.length); + const picks = size === 0 ? [] : combinations(a.poolIdx.length, size); + const combos = picks.map((pick) => { + const idx = pick.map((p) => a.poolIdx[p]!); + return { ids: idx.map((i) => a.candidates[i]!.id), value: batchValue(idx.map((i) => a.imps[i]!)) }; + }); + // 池空或批量为 0 时一个组合都没有,S* 是空组合,V = 0。 + let best = combos[0] ?? { ids: [], value: 0 }; + for (const c of combos) if (c.value > best.value) best = c; + return { + seed: input.seed, + params: a.params, + k: a.k, + candidates: a.candidates, + pool: a.poolIdx.map((i) => a.candidates[i]!.id), + combos, + chosen: best.ids, + chosenValue: best.value, + poolValue: a.poolValue, + scaleReading: a.poolValue - best.value, + }; +} + +/** + * 选批规则"均匀随机"(第十一部分):从全部候选里(不看入池门槛)不放回地均匀抽 min(k, 候选数) 个。 + * 入池、合并范围、分数尺读数照算照记;S* 是抽中的组合,可能含池外候选。 + */ +export function selectUniformRandom(input: BatchInput): BatchDecision { + const a = assess(input); + const ids = a.candidates.map((c) => c.id); + const chosen = seededSample(ids, Math.min(a.k, ids.length), input.seed); + const chosenValue = batchValue(chosen.map((id) => a.imps[ids.indexOf(id)]!)); + return { + seed: input.seed, + params: a.params, + k: a.k, + candidates: a.candidates, + pool: a.poolIdx.map((i) => a.candidates[i]!.id), + combos: [], + chosen, + chosenValue, + poolValue: a.poolValue, + scaleReading: a.poolValue - chosenValue, + }; +} + +/** 采纳线的输入(6.5):B 是当前基线分数,K* 是已知最好(没有时 null)。 */ +export interface AdoptionLineInput { + baseline: number; + knownBest: number | null; + epsilon: number; + direction: Direction; +} + +/** + * 采纳线(6.5):没有已知最好时是 ε;有时是 min(ε, (K* − B) ÷ 4)(按方向折算),结果低于 ε/100 时取 ε/100, + * B 已经超过 K*((K* − B) 为负)时同样取 ε/100。quarter 是 (K* − B) ÷ 4 的原值,没有已知最好时为 null。 + */ +export function adoptionLine(input: AdoptionLineInput): { line: number; quarter: number | null } { + if (input.knownBest === null) return { line: input.epsilon, quarter: null }; + const quarter = improvement(input.baseline, input.knownBest, input.direction) / 4; + return { line: Math.max(epsilonFloor(input.epsilon), Math.min(input.epsilon, quarter)), quarter }; +} + +/** + * 采纳(6.5、4.3 ⑫):只看实测分数。candidates 是有有效分数的正向候选;最好的那个(按方向,同分取编号小的) + * 比基线的改进严格大于采纳线才采纳。 + */ +export function adopt( + input: AdoptionLineInput & { candidates: ReadonlyArray<{ id: string; score: number }> }, +): { + line: number; + quarter: number | null; + best: { id: string; score: number; improvement: number } | null; + adopted: string | null; +} { + const { line, quarter } = adoptionLine(input); + let best: { id: string; score: number; improvement: number } | null = null; + for (const c of [...input.candidates].sort((x, y) => compareIds(x.id, y.id))) { + const gain = improvement(input.baseline, c.score, input.direction); + if (best === null || gain > best.improvement) best = { id: c.id, score: c.score, improvement: gain }; + } + return { line, quarter, best, adopted: best !== null && best.improvement > line ? best.id : null }; +} diff --git a/src/rules/index.ts b/src/rules/index.ts new file mode 100644 index 0000000..667041f --- /dev/null +++ b/src/rules/index.ts @@ -0,0 +1,117 @@ +// rules:方法.md 第六部分的全部公式和常数、第七部分的余额、4.2 的入口分配(docs/结构.md 第二部分)。 +// 纯计算:输入数字,输出数字,不碰文件、数据库、模型,也不用全局随机数。 +export { + combinations, + compareIds, + epsilonFloor, + GRID_COMBINATION_CAP, + GRID_SUBDIVISIONS, + improvement, + INDISPENSABLE_SUPPORT, + INTERACTION_CUT, + NET_TOLERANCE, +} from "./basics.ts"; +export { + ablationCellMixture, + authorShares, + below, + breakpoints, + density, + forwardMergedRange, + forwardMixtures, + integrateUpperTail, + knotsMean, + mapKnots, + mapMixture, + mixtureBelow, + mixtureQuantile, + mixtureRange, + personKnots, + reserveKnots, + toImprovement, + type Component, + type Knots, + type Mixture, + type Point, +} from "./distribution.ts"; +export { + adopt, + adoptionLine, + batchValue, + lossProbability, + selectBestExpected, + selectUniformRandom, + type AdoptionLineInput, + type BatchDecision, + type BatchInput, +} from "./forward.ts"; +export { + ablationFinding, + inseparableGroups, + judgeExplanations, + listCells, + settleAblation, + sharedSettings, + supportOf, + synergyLabel, + totalGain, + type AblationCell, + type AblationExplanation, + type CellOutcome, + type SettleInput, + type SynergyLabel, + type ExplanationVerdict, +} from "./ablation.ts"; +export { + ablationModel, + cellGrid, + evaluateCombos, + gridSize, + informationValue, + informationValueExact, + informationValueGrid, + measuredGrid, + OPEN_RESERVE, + remainingSpace, + type AblationModel, + type AblationModelInput, + type ComboParams, + type ComboValue, +} from "./value.ts"; +export { + chooseBet, + pickByNet, + selectAll, + selectByValue, + selectNone, + selectRandomSameCount, + topCombo, + type BetResult, + type CellDecision, + type CellRuleInput, + type UntestedReason, +} from "./cells.ts"; +export { + bitWidth, + cellIndex, + coverage, + covers, + disagreement, + entropyBits, + placement, + surprise, + worldBits, + type Placement, +} from "./bits.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"; +export { decisionSeed, seededRandom, seededSample } from "./random.ts"; +export { + BATCH_RULES, + CELL_RULES, + runBatchRule, + runCellRule, + type BatchRuleName, + type CellRuleName, +} from "./registry.ts"; diff --git a/src/rules/random.ts b/src/rules/random.ts new file mode 100644 index 0000000..6f2b201 --- /dev/null +++ b/src/rules/random.ts @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; +import { hash } from "../vocab/index.ts"; +import { compareIds } from "./basics.ts"; + +// 方法.md 第九部分:需要随机时,种子由研究编号、轮次、版本算出;不用全局随机数,同一个种子永远得到同一串数。 + +/** 决定的种子:vocab hash({ studyId, round, version }),64 位十六进制。 */ +export function decisionSeed(input: { studyId: string; round: number; version: number }): string { + return hash({ studyId: input.studyId, round: input.round, version: input.version }); +} + +/** + * 从种子确定地展开的随机整数。第 counter 个数取 sha256(`${seed}:${counter}`) 的前 6 字节(大端 48 位整数)v, + * counter 从 0 起每取一次加 1;v 落在 n 的最大整数倍以内才收下并返回 v mod n,否则换下一个 counter(没有取模偏差)。 + */ +export function seededRandom(seed: string): { uniformInt: (n: number) => number } { + let counter = 0; + const span = 2 ** 48; + return { + uniformInt(n: number): number { + const limit = span - (span % n); + for (;;) { + const v = createHash("sha256").update(`${seed}:${counter++}`).digest().readUIntBE(0, 6); + if (v < limit) return v % n; + } + }, + }; +} + +/** + * 不放回地均匀抽 size 个:先按编号(compareIds,自然序)排序,再做部分 Fisher–Yates(第 i 步与 i + uniformInt(n − i) 交换), + * 取前 size 个再按编号排序。结果只取决于种子和编号集合,跟调用方给的顺序无关。候选和因子都这样抽。 + */ +export function seededSample(items: readonly string[], size: number, seed: string): string[] { + const xs = [...items].sort(compareIds); + const random = seededRandom(seed); + for (let i = 0; i < size; i++) { + const j = i + random.uniformInt(xs.length - i); + [xs[i], xs[j]] = [xs[j]!, xs[i]!]; + } + return xs.slice(0, size).sort(compareIds); +} diff --git a/src/rules/record.ts b/src/rules/record.ts new file mode 100644 index 0000000..d698b74 --- /dev/null +++ b/src/rules/record.ts @@ -0,0 +1,85 @@ +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"按人分开、按正向与消融分开"。只用来显示,不改任何权重。 +// 一律按分数原值比,不按方向折算。 + +export type Phase = "forward" | "ablation"; + +/** 一个人的战绩。 */ +export interface Tally { + total: number; + /** 中位数偏高:q5 > 实测。 */ + medianHigh: number; + /** 中位数偏低:q5 < 实测。 */ + medianLow: number; + /** 被穿过上沿:实测 > q9。 */ + aboveTop: number; + /** 被穿过下沿:实测 < q1。 */ + belowBottom: number; + /** 包住:q1 ≤ 实测 ≤ q9。 */ + covered: number; + /** 落点四段的计数。 */ + placements: Record; +} + +/** + * 战绩:按研究者、按正向与消融分开数(6.9、6.10)。samples 是每人每次的范围(第二次预测或解释的每格)和有效实测。 + * 结果按研究者编号、再按 forward、ablation 排,只列有样本的组合。 + */ +export function trackRecord( + samples: ReadonlyArray<{ researcher: string; phase: Phase; range: Range; value: number }>, +): Array<{ researcher: string; phase: Phase; tally: Tally }> { + const groups = new Map(); + for (const s of samples) { + const key = `${s.researcher}\u0000${s.phase}`; + let g = groups.get(key); + if (g === undefined) { + g = { + researcher: s.researcher, + phase: s.phase, + tally: { + total: 0, + medianHigh: 0, + medianLow: 0, + aboveTop: 0, + belowBottom: 0, + covered: 0, + placements: { belowQ1: 0, q1ToQ5: 0, q5ToQ9: 0, aboveQ9: 0 }, + }, + }; + groups.set(key, g); + } + const t = g.tally; + const { range: r, value: v } = s; + t.total += 1; + if (r.q5 > v) t.medianHigh += 1; + if (r.q5 < v) t.medianLow += 1; + if (v > r.q9) t.aboveTop += 1; + if (v < r.q1) t.belowBottom += 1; + if (covers(r, v)) t.covered += 1; + 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), + ); +} + +/** + * "作者自评 vs 大家合并"(6.10 第 4 条):只收已经跑过、有有效成绩的候选(由调用方筛)。 + * optimism = 作者一半比合并一半按方向好多少,正数表示作者更乐观;另给实测落在两个范围的哪一段。 + * 结果按候选编号(自然序)排。 + */ +export function authorVsMerged( + items: ReadonlyArray<{ candidate: string; author: string; authorRange: Range; mergedRange: Range; value: number }>, + direction: Direction, +): Array<{ candidate: string; author: string; optimism: number; authorPlacement: Placement; mergedPlacement: Placement }> { + return [...items].sort((a, b) => compareIds(a.candidate, b.candidate)).map((i) => ({ + candidate: i.candidate, + author: i.author, + optimism: improvement(i.mergedRange.q5, i.authorRange.q5, direction), + authorPlacement: placement(i.authorRange, i.value), + mergedPlacement: placement(i.mergedRange, i.value), + })); +} diff --git a/src/rules/registry.ts b/src/rules/registry.ts new file mode 100644 index 0000000..76a6a55 --- /dev/null +++ b/src/rules/registry.ts @@ -0,0 +1,44 @@ +import { selectAll, selectByValue, selectNone, selectRandomSameCount, type CellDecision, type CellRuleInput } from "./cells.ts"; +import { selectBestExpected, selectUniformRandom, type BatchDecision, type BatchInput } from "./forward.ts"; + +// 规则登记表(结构.md 第五部分"决策规则"、方法.md 第十一部分):名字 → 函数,带版本号。 +// 配置写规则名;决定记录里的规则名、版本从这里取。规则的算法改了就把版本加 1。 + +export const BATCH_RULES = { + /** 批内最好期望(6.4)。 */ + bestExpected: { version: 1, run: selectBestExpected }, + /** 均匀随机(第十一部分)。 */ + uniformRandom: { version: 1, run: selectUniformRandom }, +} as const satisfies Record BatchDecision }>; + +export const CELL_RULES = { + /** 信息价值减成本(6.7)。 */ + valueMinusCost: { version: 1, run: selectByValue }, + /** 全测。 */ + all: { version: 1, run: selectAll }, + /** 随机抽同样多的格。 */ + randomSameCount: { version: 1, run: selectRandomSameCount }, + /** 都不测。 */ + none: { version: 1, run: selectNone }, +} as const satisfies Record CellDecision }>; + +export type BatchRuleName = keyof typeof BATCH_RULES; +export type CellRuleName = keyof typeof CELL_RULES; + +/** 按名字跑一条选批规则,结果带上规则名和版本。 */ +export function runBatchRule( + name: BatchRuleName, + input: BatchInput, +): BatchDecision & { rule: { name: BatchRuleName; version: number } } { + const entry = BATCH_RULES[name]; + return { rule: { name, version: entry.version }, ...entry.run(input) }; +} + +/** 按名字跑一条选格规则,结果带上规则名和版本。 */ +export function runCellRule( + name: CellRuleName, + input: CellRuleInput, +): CellDecision & { rule: { name: CellRuleName; version: number } } { + const entry = CELL_RULES[name]; + return { rule: { name, version: entry.version }, ...entry.run(input) }; +} diff --git a/src/rules/value.ts b/src/rules/value.ts new file mode 100644 index 0000000..55506c4 --- /dev/null +++ b/src/rules/value.ts @@ -0,0 +1,276 @@ +import type { Direction } from "../vocab/index.ts"; +import type { AblationExplanation } from "./ablation.ts"; +import { totalGain } from "./ablation.ts"; +import { + combinations, + compareIds, + epsilonFloor, + GRID_COMBINATION_CAP, + GRID_SUBDIVISIONS, + improvement, +} from "./basics.ts"; +import { ablationCellMixture, below, 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)。 + +/** 开放预留成分的名字(成分名单里排在最后)。 */ +export const OPEN_RESERVE = "openReserve"; + +/** 建成分模型要的输入(6.7)。baseline 是本批基线 b,full 是被采纳的完整代码的分数。 */ +export interface AblationModelInput { + baseline: number; + full: number; + direction: Direction; + knownBest: number | null; + epsilon: number; + lambda: number; + /** 被采纳改法的因子编号。 */ + factors: readonly string[]; + /** 每条解释对每一格都给了三个数。 */ + explanations: readonly AblationExplanation[]; +} + +export interface AblationModel { + /** 因子编号,按编号排;下文"格的下标"都按这个次序。 */ + factors: string[]; + /** 成分:各条解释的编号(按编号排),最后是开放预留。 */ + components: string[]; + /** 起始权重(6.7"成分与权重"):解释合起来 1 − λ(研究者等权、同一人平分),开放预留 λ。 */ + weights: number[]; + /** 被采纳的完整代码的分数 f(完整) 和方向:分数原值换算成撑几成要用。 */ + full: number; + direction: Direction; + /** 总提升 G。 */ + gain: number; + /** 剩余空间 h。 */ + space: number; + /** rawKnots[格][成分]:分数原值上的折线(开放预留是这一格自己的均匀段)。 */ + rawKnots: Knots[][]; + /** supportKnots[格][成分]:撑几成刻度上的折线。 */ + supportKnots: Knots[][]; + /** expected[格][成分]:这个成分下这一格撑几成的期望。 */ + expected: number[][]; + /** prior[格]:按起始权重平均的期望撑几成;乘 h 就是 E(收益_p)。 */ + prior: number[]; +} + +/** + * 剩余空间 h(6.7"方向收益"):有已知最好时 max(K* − f(完整), ε/100)(按方向折算),没有时 1。 + */ +export function remainingSpace(full: number, knownBest: number | null, epsilon: number, direction: Direction): number { + return knownBest === null ? 1 : Math.max(improvement(full, knownBest, direction), epsilonFloor(epsilon)); +} + +/** 分数原值 x → 撑几成 [f(完整) − x] ÷ G(按方向折算)。 */ +function supportScale(full: number, direction: Direction, gain: number): (x: number) => number { + return (x) => improvement(x, full, direction) / gain; +} + +/** + * 成分模型(6.7"成分与权重"):每条解释一个成分,开放预留一个成分;每个成分对每一格有一个分布, + * 解释用它自己的三个数补尾巴,开放预留用这一格的均匀分布(6.3"消融")。同一成分内各格互相独立。 + */ +export function ablationModel(input: AblationModelInput): AblationModel { + const gain = totalGain(input.baseline, input.full, input.direction); + const factors = [...input.factors].sort(compareIds); + const explanations = [...input.explanations].sort((a, b) => compareIds(a.id, b.id)); + const mixtures = factors.map((f) => + ablationCellMixture( + explanations.map((e) => ({ id: e.id, author: e.author, range: e.cells.find((c) => c.factor === f)!.range })), + input.lambda, + ), + ); + const toSupport = supportScale(input.full, input.direction, gain); + const rawKnots = mixtures.map((m) => m.map((c) => c.knots)); + const supportKnots = rawKnots.map((cell) => cell.map((k) => mapKnots(k, toSupport, input.direction === "maximize"))); + const weights = mixtures[0]!.map((c) => c.weight); + const expected = supportKnots.map((cell) => cell.map(knotsMean)); + return { + factors, + components: [...explanations.map((e) => e.id), OPEN_RESERVE], + weights, + full: input.full, + direction: input.direction, + gain, + space: remainingSpace(input.full, input.knownBest, input.epsilon, input.direction), + rawKnots, + supportKnots, + expected, + prior: expected.map((row) => row.reduce((s, e, j) => s + weights[j]! * e, 0)), + }; +} + +/** 测 k 格时每格的小段数 m:满足 m^k ≤ 4,000,000 的最大整数(6.7"信息价值"),用整数运算核对。 */ +export function gridSize(k: number): number { + let m = Math.floor(GRID_COMBINATION_CAP ** (1 / k)); + while (m ** k > GRID_COMBINATION_CAP) m -= 1; + while ((m + 1) ** k <= GRID_COMBINATION_CAP) m += 1; + return m; +} + +/** + * 一格的网格(6.7"只测一部分格"):取这一格全部成分的折点(含开放预留两端)去重排序,段数 = 折点数 − 1; + * s = min(20, ⌊m ÷ 段数⌋),s ≥ 1 时每段等分 s 份,段数超过 m 时整格等宽切 m 份。 + * 每个小段的代表点是中点;成分 j 在小段 [左, 右] 的把握 = F_j(右) − F_j(左),各小段把握加起来是 1。 + */ +export function cellGrid(knots: readonly Knots[], k: number): { mids: number[]; mass: number[][] } { + const brk = [...new Set(knots.flatMap((kk) => kk.map(([x]) => x)))].sort((a, b) => a - b); + const segs = brk.length - 1; + const m = gridSize(k); + const s = Math.min(GRID_SUBDIVISIONS, Math.floor(m / segs)); + const edges: number[] = []; + if (s >= 1) { + for (let i = 0; i < segs; i++) { + const a = brk[i]!; + const step = (brk[i + 1]! - a) / s; + for (let j = 0; j < s; j++) edges.push(a + j * step); + } + } else { + const step = (brk[segs]! - brk[0]!) / m; + for (let j = 0; j < m; j++) edges.push(brk[0]! + j * step); + } + edges.push(brk[segs]!); + const mids: number[] = []; + for (let i = 0; i + 1 < edges.length; i++) mids.push((edges[i]! + edges[i + 1]!) / 2); + const mass = knots.map((kk) => { + const cdf = edges.map((x) => below(kk, x)); + return mids.map((_, i) => cdf[i + 1]! - cdf[i]!); + }); + return { mids, mass }; +} + +/** + * 模型里第 c 格的网格(6.7"只测一部分格"),测 k 格时用。在分数原值上取折点、切小段:方法说"每格在自己的开放预留范围内 + * 按折点切段",开放预留和折点都是在分数原值上定的(尾巴按 q1 − (q5 − q1)、q9 + (q9 − q5),预留按 L − W/2、U + W/2 + * 的浮点原式),去重只合并浮点上完全相等的值。先换到撑几成刻度再去重,相差一个末位的两个原值折点可能换算后变成同一个数, + * 段数和 s 就会跟原值上的不一样。切好后只把小段中点换算成撑几成;把握与刻度无关。 + */ +export function measuredGrid(model: AblationModel, 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 }; +} + +/** + * 只测 S 里的格时的 E_{S 的结果}[max_p E(撑_p | S 的结果)](6.7"信息价值"),在撑几成刻度上。 + * 对测到的格的全部小段组合求和:成分 j 的联合把握 = 起始权重 × 各测到格在所选小段的把握;它们的和 marg 是这个组合的把握, + * 按它归一就是更新后的权重。网格见 measuredGrid。测到的格取小段中点(已换成撑几成),没测的格用更新后的权重平均各成分的期望,取各因子里最大的。 + */ +function expectedBestGrid(model: AblationModel, 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 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; + const walk = (level: number, seen: number): void => { + const cur = partial[level]!; + if (level === k) { + let marg = 0; + for (let j = 0; j < J; j++) marg += cur[j]!; + if (marg === 0) return; + let best = seen; + for (const c of rest) { + const e = model.expected[c]!; + let num = 0; + for (let j = 0; j < J; j++) num += cur[j]! * e[j]!; + if (num / marg > best) best = num / marg; + } + total += best * marg; + return; + } + const g = grids[level]!; + const next = partial[level + 1]!; + for (let i = 0; i < g.mids.length; i++) { + for (let j = 0; j < J; j++) next[j] = cur[j]! * g.mass[j]![i]!; + walk(level + 1, Math.max(seen, g.mids[i]!)); + } + }; + walk(0, -Infinity); + return total; +} + +/** + * 全部格都测时的 E[max_p 撑_p](6.7"信息价值"):同一成分内"各格撑几成的最大值低于 t"的把握 = 各格把握连乘, + * 期望 = 下端 + ∫(1 − 连乘) dt,逐段多项式精确积分;再按起始权重相加。 + */ +function expectedBestAll(model: AblationModel): 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])); + v += model.weights[j]! * (lo + integrateUpperTail(lines.map((knots) => [{ weight: 1, knots }]), lo)); + } + return v; +} + +/** 网格算法算的信息价值,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)); +} + +/** 全部格都测时的信息价值(一维精确积分)。 */ +export function informationValueExact(model: AblationModel): number { + return model.space * (expectedBestAll(model) - Math.max(...model.prior)); +} + +/** + * 信息价值 VOI(S) = E_{S 的结果}[max_p E(收益_p | S 的结果)] − max_p E(收益_p)(6.7),S 是格的下标; + * 全部格都测走一维精确积分,只测一部分走网格,空集是 0。不截断。 + */ +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); +} + +/** 一个测格组合的价值与成本(6.7"价值减成本")。 */ +export interface ComboValue { + /** 组合里的因子,按编号排。 */ + factors: string[]; + voi: number; + /** R̂ = ⌈(余额 − |S|) ÷ 每批上限⌉。 */ + rHat: number; + /** m′ = 1 + r + … + r^(R̂−1),R̂ = 0 时是 0。 */ + mPrime: number; + /** 价值 = u × m′ × VOI。 */ + value: number; + /** 成本 = 2 max(0, ĝ₀) × [(K + |S|)/(K + |S| + 1) − K/(K + 1)]。 */ + cost: number; + /** 净值 = 价值 − 成本。 */ + net: number; +} + +export interface ComboParams { + balance: number; + batchCap: number; + /** 测出的知识会被用上的概率 u。 */ + u: number; + /** 衰减 r。 */ + decay: number; +} + +/** + * 列出格数 1..min(格数, 余额 − 1) 的全部非空组合并算价值、成本、净值(6.7"选格"第 1 条)。余额为 0 或 1 时一个都没有。 + * 组合按格数从少到多、同格数按因子编号字典序列出。ĝ₀ = h × max_p 期望撑几成,原值另给,成本里取 max(0, ĝ₀)。 + */ +export function evaluateCombos(model: AblationModel, params: ComboParams): { g0: number; combos: ComboValue[] } { + const g0 = model.space * Math.max(...model.prior); + const K = params.batchCap; + const combos: ComboValue[] = []; + for (let size = 1; size <= Math.min(model.factors.length, params.balance - 1); size++) { + 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 value = params.u * mPrime * voi; + const cost = 2 * Math.max(0, g0) * ((K + size) / (K + size + 1) - K / (K + 1)); + combos.push({ factors: S.map((c) => model.factors[c]!), voi, rHat, mPrime, value, cost, net: value - cost }); + } + } + return { g0, combos }; +} diff --git a/src/vocab/submissions.ts b/src/vocab/submissions.ts index 955d1b7..34905e4 100644 --- a/src/vocab/submissions.ts +++ b/src/vocab/submissions.ts @@ -30,7 +30,7 @@ export const Range = Type.Object( { ...CLOSED, description: - "三个数,按分数原值写,不管越大越好还是越小越好。要满足 q1 ≤ q5 ≤ q9,可以相等。" + + "三个数,按分数原值写,不管越大越好还是越小越好。必须严格递增 q1 < q5 < q9,认为几乎不变就写一个很窄的范围。" + "没有\"不知道\"这个选项:拿不准就把 q1 和 q9 拉开", }, ); diff --git a/tests/fixtures/calc15/expected.json b/tests/fixtures/calc15/expected.json new file mode 100644 index 0000000..bd1dba0 --- /dev/null +++ b/tests/fixtures/calc15/expected.json @@ -0,0 +1,282 @@ +{ + "source": "research-wiki/设计-15-计算/calc15.py(14-real-1 账本副本)", + "input": { + "baseline": 0.9597642169962064, + "full": 2.6276819772628, + "knownBest": 2.635, + "offGrid": 2.5989997592071172, + "offSolverLegalized": 1.7602769492805097, + "explanations": [ + { + "id": "e1", + "author": "researcher-1", + "cells": [ + [ + 1.4, + 1.75, + 2.1 + ], + [ + 1.9, + 2.05, + 2.18 + ] + ] + }, + { + "id": "e2", + "author": "researcher-1", + "cells": [ + [ + 1.9, + 2.3, + 2.55 + ], + [ + 1.8, + 1.95, + 2.1 + ] + ] + }, + { + "id": "e3", + "author": "researcher-1", + "cells": [ + [ + 1.65, + 2.05, + 2.4 + ], + [ + 1.8, + 1.98, + 2.15 + ] + ] + }, + { + "id": "e4", + "author": "researcher-2", + "cells": [ + [ + 2.3, + 2.45, + 2.58 + ], + [ + 1.85, + 2.02, + 2.18 + ] + ] + }, + { + "id": "e5", + "author": "researcher-2", + "cells": [ + [ + 1.5, + 1.95, + 2.35 + ], + [ + 1.9, + 2.06, + 2.2 + ] + ] + }, + { + "id": "e6", + "author": "researcher-2", + "cells": [ + [ + 1.8, + 2.05, + 2.3 + ], + [ + 1.85, + 2, + 2.15 + ] + ] + }, + { + "id": "e7", + "author": "researcher-3", + "cells": [ + [ + 1.9, + 2.15, + 2.4 + ], + [ + 1.95, + 2.1, + 2.2 + ] + ] + }, + { + "id": "e8", + "author": "researcher-3", + "cells": [ + [ + 2.25, + 2.45, + 2.55 + ], + [ + 1.7, + 1.9, + 2.05 + ] + ] + }, + { + "id": "e9", + "author": "researcher-3", + "cells": [ + [ + 1.7, + 1.95, + 2.2 + ], + [ + 1.85, + 2, + 2.15 + ] + ] + } + ] + }, + "G": 1.6679177602665936, + "h": 0.007318022737199836, + "supportOffGrid": 0.017196422233131125, + "supportOffSolver": 0.5200526360746033, + "priorSupport2": [ + 0.3226369969085209, + 0.3808832739818412 + ], + "priorSupport3": [ + 0.3226369969085209, + 0.3808832739818412, + 0.34891227321051016 + ], + "voi2": { + "1": 0.0005820672232162948, + "2": 0.0002053645817553188, + "1+2": 0.0006313506372333821 + }, + "voi2GridAll": 0.0006313460547053311, + "voi3": { + "1": 0.0005820672232162948, + "2": 0.00023468479202717538, + "3": 0.00037866484827430574, + "1+2": 0.0007066366436911577, + "1+3": 0.0008066824756162668, + "2+3": 0.0005046379680289518, + "1+2+3": 0.0008773803854093963 + }, + "voi3GridAll": 0.0008771372131003269, + "g0": 0.0027873124592182287, + "opp": { + "1": 0.00046455207653637166, + "2": 0.0007432833224581948 + }, + "hand": { + "reservePrior": 0.1, + "reservePosterior": 0.08426511100308286, + "explanationsPosterior": 0.9157348889969171, + "gainOffGrid": 0.00012584380890054235, + "solverGainPrior": 0.0027873124592182287, + "solverGainPosterior": 0.002893886609905632 + }, + "gridCap": { + "1": 4000000, + "2": 2000, + "3": 158, + "4": 44 + }, + "cellMiniSegments": { + "k1": [ + 620, + 600 + ], + "k2": [ + 620, + 600 + ], + "k3": [ + 155, + 150, + 126 + ] + }, + "labels": { + "[0.95, 0.93, 0.91]": "缺一不可", + "[0.95, 0.93, 0.9]": "互补", + "[0.6, 0.4, 0.3]": "互补", + "[0.6, 0.4, 0.1]": "各管各", + "[0.5, 0.3, 0.1]": "各管各", + "[0.5, null, 0.3]": "不全,不贴标签" + }, + "label2": "替代", + "choose": { + "2cells_bal5": { + "chosen": [ + 1 + ], + "net": { + "none": 0.0, + "1": 0.00043655041741222107, + "2": 0.0001540234363164891, + "1+2": -0.0002697703445331582 + } + }, + "2cells_bal7": { + "chosen": [ + 1, + 2 + ], + "net": { + "none": 0.0, + "1": 0.00043655041741222107, + "2": 0.0001540234363164891, + "1+2": 0.00047351297792503657 + } + }, + "2cells_bal10": { + "chosen": [ + 1, + 2 + ], + "net": { + "none": 0.0, + "1": 0.00043655041741222107, + "2": 0.0001540234363164891, + "1+2": 0.00047351297792503657 + } + }, + "3cells_bal10": { + "chosen": [ + 1, + 2, + 3 + ], + "net": { + "none": 0.0, + "1": 0.00043655041741222107, + "2": 0.00017601359402038153, + "3": 0.0002839986362057293, + "1+2": 0.0005299774827683683, + "1+3": 0.0006050118567122001, + "2+3": 0.0003784784760217138, + "1+2+3": 0.0006580352890570472 + } + } + } +} diff --git a/tests/fixtures/rules/ablation.json b/tests/fixtures/rules/ablation.json new file mode 100644 index 0000000..2ccc771 --- /dev/null +++ b/tests/fixtures/rules/ablation.json @@ -0,0 +1,3862 @@ +{ + "generator": "gen_ablation.py", + "tolerance": { + "exact": "有理数精确算出的量(G、撑、i、h、权重、成分期望收益、ĝ₀、voiFullExact、exactK1VOI、成本、m′):绝对误差 1e-12", + "grid": "网格 VOI 及由它得到的价值、净值:|差| ≤ 1e-12 + 1e-9·|值|(逐项浮点累加顺序不同,约 1e-13 量级)", + "discrete": "标签、选中组合、原因、押的因子、状态:必须完全相同", + "gridVsExact": "同一输入全测时 gridFullVOI 与 voiFullExact 的对照(两种算法之间,不是实现与本脚本之间):k ≤ 3 时 |差| ≤ 5e-4·|精确值|,k = 4 时 ≤ 3e-3·|精确值|;本脚本实测相对差 7.3e-6(calc15,k=2)、4.7e-5(minimize,k=2)、1.05e-4(r1real,k=3,s=5–7)、1.0e-3(fourCells,k=4,s=1 和 0)。理由:网格用小段中点代表整段,误差随小段宽度平方缩小、随格数增多(m 变小)变大;s ≥ 1 时各成分在小段内是均匀分布,max 是凸函数,按 Jensen 不等式网格值不会超过精确值,所以还可以断言 gridFullVOI ≤ voiFullExact + 1e-12(fourCells 有 s = 0 的格,不适用)。" + }, + "models": { + "calc15": { + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "B": 0.9597642169962064, + "full": 2.6276819772628, + "knownBest": 2.635, + "direction": "maximize", + "cells": [ + "p1", + "p2" + ], + "explanations": [ + { + "id": "e1", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.4, + "q5": 1.75, + "q9": 2.1 + }, + "p2": { + "q1": 1.9, + "q5": 2.05, + "q9": 2.18 + } + } + }, + { + "id": "e2", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.9, + "q5": 2.3, + "q9": 2.55 + }, + "p2": { + "q1": 1.8, + "q5": 1.95, + "q9": 2.1 + } + } + }, + { + "id": "e3", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.65, + "q5": 2.05, + "q9": 2.4 + }, + "p2": { + "q1": 1.8, + "q5": 1.98, + "q9": 2.15 + } + } + }, + { + "id": "e4", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 2.3, + "q5": 2.45, + "q9": 2.58 + }, + "p2": { + "q1": 1.85, + "q5": 2.02, + "q9": 2.18 + } + } + }, + { + "id": "e5", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 1.5, + "q5": 1.95, + "q9": 2.35 + }, + "p2": { + "q1": 1.9, + "q5": 2.06, + "q9": 2.2 + } + } + }, + { + "id": "e6", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 1.8, + "q5": 2.05, + "q9": 2.3 + }, + "p2": { + "q1": 1.85, + "q5": 2, + "q9": 2.15 + } + } + }, + { + "id": "e7", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 1.9, + "q5": 2.15, + "q9": 2.4 + }, + "p2": { + "q1": 1.95, + "q5": 2.1, + "q9": 2.2 + } + } + }, + { + "id": "e8", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 2.25, + "q5": 2.45, + "q9": 2.55 + }, + "p2": { + "q1": 1.7, + "q5": 1.9, + "q9": 2.05 + } + } + }, + { + "id": "e9", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 1.7, + "q5": 1.95, + "q9": 2.2 + }, + "p2": { + "q1": 1.85, + "q5": 2, + "q9": 2.15 + } + } + } + ] + } + }, + "r1real": { + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "B": 0.9597642169962064, + "full": 1.552176048093269, + "knownBest": 2.635983, + "direction": "maximize", + "cells": [ + "p1", + "p2", + "p3" + ], + "explanations": [ + { + "id": "r1-e1-1", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.2, + "q5": 2, + "q9": 2.44 + }, + "p2": { + "q1": 1.49, + "q5": 1.54, + "q9": 1.58 + }, + "p3": { + "q1": 2.3, + "q5": 2.44, + "q9": 2.45 + } + } + }, + { + "id": "r1-e1-2", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.22, + "q5": 1.4, + "q9": 1.52 + }, + "p2": { + "q1": 1.49, + "q5": 1.54, + "q9": 1.58 + }, + "p3": { + "q1": 1.2, + "q5": 1.42, + "q9": 1.55 + } + } + }, + { + "id": "r1-e2-1", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 1.05, + "q5": 1.35, + "q9": 1.95 + }, + "p2": { + "q1": 1.44, + "q5": 1.53, + "q9": 1.59 + }, + "p3": { + "q1": 0.85, + "q5": 2.4, + "q9": 2.45 + } + } + }, + { + "id": "r1-e2-2", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 0.95, + "q5": 1.3, + "q9": 2.1 + }, + "p2": { + "q1": 1.4, + "q5": 1.52, + "q9": 1.62 + }, + "p3": { + "q1": 0.6, + "q5": 1.45, + "q9": 2.45 + } + } + }, + { + "id": "r1-e3-1", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 1.15, + "q5": 1.36, + "q9": 1.55 + }, + "p2": { + "q1": 1.49, + "q5": 1.535, + "q9": 1.58 + }, + "p3": { + "q1": 1.8, + "q5": 2.42, + "q9": 2.58 + } + } + }, + { + "id": "r1-e3-2", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 1.2, + "q5": 1.42, + "q9": 2.46 + }, + "p2": { + "q1": 1.5, + "q5": 1.53, + "q9": 1.58 + }, + "p3": { + "q1": 1.9, + "q5": 2.44, + "q9": 2.57 + } + } + } + ] + } + }, + "minimize": { + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "B": 0.4, + "full": 0.35, + "knownBest": 0.33, + "direction": "minimize", + "cells": [ + "p1", + "p2" + ], + "explanations": [ + { + "id": "e1", + "author": "ra", + "cells": { + "p1": { + "q1": 0.36, + "q5": 0.37, + "q9": 0.39 + }, + "p2": { + "q1": 0.35, + "q5": 0.36, + "q9": 0.37 + } + } + }, + { + "id": "e2", + "author": "ra", + "cells": { + "p1": { + "q1": 0.35, + "q5": 0.355, + "q9": 0.36 + }, + "p2": { + "q1": 0.36, + "q5": 0.38, + "q9": 0.4 + } + } + }, + { + "id": "e3", + "author": "rb", + "cells": { + "p1": { + "q1": 0.37, + "q5": 0.38, + "q9": 0.4 + }, + "p2": { + "q1": 0.355, + "q5": 0.365, + "q9": 0.38 + } + } + } + ] + } + }, + "fourCells": { + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "B": 0.9597642169962064, + "full": 2.6276819772628, + "knownBest": 2.635, + "direction": "maximize", + "cells": [ + "p1", + "p2", + "p3", + "p4" + ], + "explanations": [ + { + "id": "e1", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.4, + "q5": 1.75, + "q9": 2.1 + }, + "p2": { + "q1": 1.9, + "q5": 2.05, + "q9": 2.18 + }, + "p3": { + "q1": 1.1, + "q5": 1.45, + "q9": 1.8 + }, + "p4": { + "q1": 2.0, + "q5": 2.15, + "q9": 2.28 + } + } + }, + { + "id": "e2", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.9, + "q5": 2.3, + "q9": 2.55 + }, + "p2": { + "q1": 1.8, + "q5": 1.95, + "q9": 2.1 + }, + "p3": { + "q1": 1.6013, + "q5": 2.0013, + "q9": 2.2513 + }, + "p4": { + "q1": 1.9007, + "q5": 2.0507, + "q9": 2.2007 + } + } + }, + { + "id": "e3", + "author": "researcher-1", + "cells": { + "p1": { + "q1": 1.65, + "q5": 2.05, + "q9": 2.4 + }, + "p2": { + "q1": 1.8, + "q5": 1.98, + "q9": 2.15 + }, + "p3": { + "q1": 1.3526, + "q5": 1.7526, + "q9": 2.1026 + }, + "p4": { + "q1": 1.9014, + "q5": 2.0814, + "q9": 2.2514 + } + } + }, + { + "id": "e4", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 2.3, + "q5": 2.45, + "q9": 2.58 + }, + "p2": { + "q1": 1.85, + "q5": 2.02, + "q9": 2.18 + }, + "p3": { + "q1": 2.0039, + "q5": 2.1539, + "q9": 2.2839 + }, + "p4": { + "q1": 1.9521, + "q5": 2.1221, + "q9": 2.2821 + } + } + }, + { + "id": "e5", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 1.5, + "q5": 1.95, + "q9": 2.35 + }, + "p2": { + "q1": 1.9, + "q5": 2.06, + "q9": 2.2 + }, + "p3": { + "q1": 1.2052, + "q5": 1.6552, + "q9": 2.0552 + }, + "p4": { + "q1": 2.0028, + "q5": 2.1628, + "q9": 2.3028 + } + } + }, + { + "id": "e6", + "author": "researcher-2", + "cells": { + "p1": { + "q1": 1.8, + "q5": 2.05, + "q9": 2.3 + }, + "p2": { + "q1": 1.85, + "q5": 2, + "q9": 2.15 + }, + "p3": { + "q1": 1.5065, + "q5": 1.7565, + "q9": 2.0065 + }, + "p4": { + "q1": 1.9535, + "q5": 2.1035, + "q9": 2.2535 + } + } + }, + { + "id": "e7", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 1.9, + "q5": 2.15, + "q9": 2.4 + }, + "p2": { + "q1": 1.95, + "q5": 2.1, + "q9": 2.2 + }, + "p3": { + "q1": 1.6078, + "q5": 1.8578, + "q9": 2.1078 + }, + "p4": { + "q1": 2.0542, + "q5": 2.2042, + "q9": 2.3042 + } + } + }, + { + "id": "e8", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 2.25, + "q5": 2.45, + "q9": 2.55 + }, + "p2": { + "q1": 1.7, + "q5": 1.9, + "q9": 2.05 + }, + "p3": { + "q1": 1.9591, + "q5": 2.1591, + "q9": 2.2591 + }, + "p4": { + "q1": 1.8049, + "q5": 2.0049, + "q9": 2.1549 + } + } + }, + { + "id": "e9", + "author": "researcher-3", + "cells": { + "p1": { + "q1": 1.7, + "q5": 1.95, + "q9": 2.2 + }, + "p2": { + "q1": 1.85, + "q5": 2, + "q9": 2.15 + }, + "p3": { + "q1": 1.4104, + "q5": 1.6604, + "q9": 1.9104 + }, + "p4": { + "q1": 1.9556, + "q5": 2.1056, + "q9": 2.2556 + } + } + } + ] + } + }, + "gainNotPositive": { + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "B": 1.0, + "full": 2.0, + "knownBest": null, + "direction": "maximize", + "cells": [ + "p1", + "p2" + ], + "explanations": [ + { + "id": "e1", + "author": "ra", + "cells": { + "p1": { + "q1": 1.9, + "q5": 2.0, + "q9": 2.1 + }, + "p2": { + "q1": 2.1, + "q5": 2.2, + "q9": 2.3 + } + } + }, + { + "id": "e2", + "author": "rb", + "cells": { + "p1": { + "q1": 1.95, + "q5": 2.0, + "q9": 2.05 + }, + "p2": { + "q1": 2.05, + "q5": 2.15, + "q9": 2.25 + } + } + } + ] + } + }, + "negativeG0": { + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "B": 1.0, + "full": 2.0, + "knownBest": null, + "direction": "maximize", + "cells": [ + "p1", + "p2" + ], + "explanations": [ + { + "id": "e1", + "author": "ra", + "cells": { + "p1": { + "q1": 2.05, + "q5": 2.15, + "q9": 2.3 + }, + "p2": { + "q1": 1.9, + "q5": 2.1, + "q9": 2.2 + } + } + }, + { + "id": "e2", + "author": "rb", + "cells": { + "p1": { + "q1": 2.0, + "q5": 2.1, + "q9": 2.2 + }, + "p2": { + "q1": 2.02, + "q5": 2.08, + "q9": 2.15 + } + } + }, + { + "id": "e3", + "author": "rb", + "cells": { + "p1": { + "q1": 1.95, + "q5": 2.05, + "q9": 2.1 + }, + "p2": { + "q1": 1.85, + "q5": 2.0, + "q9": 2.2 + } + } + } + ] + } + } + }, + "scenarios": [ + { + "name": "label_method_example_independent", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.0, + "full": 1.5, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.45 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.1 + } + ] + }, + "expected": { + "G": 0.5, + "supports": { + "p1": 0.10000000000000009, + "p2": 0.7999999999999998 + }, + "interaction": -0.10000000000000009, + "label": "independent" + }, + "note": "方法 6.6 示例:撑 0.10、0.80,i = −0.10,各管各(independent)。" + }, + { + "name": "label_calc15_substitute", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 0.9597642169962064, + "full": 2.6276819772628, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 2.5989997592071172 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.7602769492805097 + } + ] + }, + "expected": { + "G": 1.6679177602665936, + "supports": { + "p1": 0.017196422233131125, + "p2": 0.5200526360746033 + }, + "interaction": -0.46275094169226555, + "label": "substitute" + }, + "note": "calc15 实测:撑 0.017、0.518,i ≈ −0.465 < −0.2,替代(substitute)。" + }, + { + "name": "label_r1_negative", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 0.9597642169962064, + "full": 1.552176048093269, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 2.250011787062861 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.9535929459502979 + }, + { + "factor": "p3", + "kind": "valid", + "value": 2.4389662413796342 + } + ] + }, + "expected": { + "G": 0.5924118310970625, + "supports": { + "p1": -1.1779571276915577, + "p2": -0.6775977061661005, + "p3": -1.4969150626923773 + }, + "interaction": null, + "label": "negative" + }, + "note": "真实第 1 轮:三格关掉后分数都比完整高,撑全 < 0,negative(不贴标签,i 不给)。" + }, + { + "name": "label_incomplete_with_other_outcomes", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.0, + "full": 1.5, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.45 + }, + { + "factor": "p2", + "kind": "insufficient" + }, + { + "factor": "p3", + "kind": "joint", + "value": 1.2 + }, + { + "factor": "p4", + "kind": "untested" + }, + { + "factor": "p5", + "kind": "cannotRun" + } + ] + }, + "expected": { + "G": 0.5, + "supports": { + "p1": 0.10000000000000009, + "p2": null, + "p3": null, + "p4": null, + "p5": null + }, + "interaction": null, + "label": "incomplete" + }, + "note": "只有有效分数进标签:p2–p5 都不是 valid(joint 有分数也不算),有因子没有有效格 → incomplete。" + }, + { + "name": "label_negative_beats_incomplete", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.0, + "full": 1.5, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.6 + }, + { + "factor": "p2", + "kind": "insufficient" + } + ] + }, + "expected": { + "G": 0.5, + "supports": { + "p1": -0.20000000000000018, + "p2": null + }, + "interaction": null, + "label": "negative" + }, + "note": "表从上往下判:任一有效格撑 < 0 先命中 negative,即使另一格没有有效分数。" + }, + { + "name": "label_indispensable", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.0, + "full": 2.0, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.05 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.0 + } + ] + }, + "expected": { + "G": 1.0, + "supports": { + "p1": 0.95, + "p2": 1.0 + }, + "interaction": 0.95, + "label": "indispensable" + }, + "note": "撑 0.95、1.0,都 > 0.9:缺一不可(indispensable),虽然 i = 0.95 > 0.2 也先命中这一条。" + }, + { + "name": "label_complementary", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.0, + "full": 2.0, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.3 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.4 + } + ] + }, + "expected": { + "G": 1.0, + "supports": { + "p1": 0.7, + "p2": 0.6000000000000001 + }, + "interaction": 0.30000000000000004, + "label": "complementary" + }, + "note": "撑 0.7、0.6,i = 0.3 > 0.2:互补(complementary)。" + }, + { + "name": "label_boundary_exact_0.9", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.0, + "full": 2.0, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.0 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.125 + } + ] + }, + "expected": { + "G": 1.0, + "supports": { + "p1": 1.0, + "p2": 0.875 + }, + "interaction": 0.875, + "label": "complementary" + }, + "note": "撑 1.0、0.875;0.875 不 > 0.9,不是缺一不可;i = 0.875 > 0.2 → 互补。用二进制精确数避免舍入。" + }, + { + "name": "label_minimize_substitute", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 0.4, + "full": 0.35, + "direction": "minimize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 0.372 + }, + { + "factor": "p2", + "kind": "valid", + "value": 0.361 + } + ] + }, + "expected": { + "G": 0.050000000000000044, + "supports": { + "p1": 0.44, + "p2": 0.22 + }, + "interaction": -0.34, + "label": "substitute" + }, + "note": "越小越好:G = 0.40−0.35;撑(p) = (x − 0.35)/G = 0.44、0.22;i = −0.34 → 替代。" + }, + { + "name": "label_G_not_positive", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 1.5, + "full": 1.5, + "direction": "maximize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 1.45 + } + ] + }, + "expected": { + "error": "totalGainNotPositive" + }, + "note": "G = 0 时报错。" + }, + { + "name": "label_G_negative_minimize", + "section": "6.6;约定 17–19", + "params": {}, + "input": { + "B": 0.4, + "full": 0.41, + "direction": "minimize", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 0.405 + } + ] + }, + "expected": { + "error": "totalGainNotPositive" + }, + "note": "越小越好时完整比基线还大,G < 0,报错。" + }, + { + "name": "confirm_r1_real", + "section": "6.6 确认与否定、现有测法分不开;约定 21–22", + "params": {}, + "input": { + "model": "r1real", + "valid": { + "p1": 2.250011787062861, + "p2": 1.9535929459502979, + "p3": 2.4389662413796342 + } + }, + "expected": { + "verdicts": { + "r1-e1-1": { + "p1": "confirmed", + "p2": "refuted", + "p3": "confirmed" + }, + "r1-e1-2": { + "p1": "refuted", + "p2": "refuted", + "p3": "refuted" + }, + "r1-e2-1": { + "p1": "refuted", + "p2": "refuted", + "p3": "confirmed" + }, + "r1-e2-2": { + "p1": "refuted", + "p2": "refuted", + "p3": "confirmed" + }, + "r1-e3-1": { + "p1": "refuted", + "p2": "refuted", + "p3": "confirmed" + }, + "r1-e3-2": { + "p1": "confirmed", + "p2": "refuted", + "p3": "confirmed" + } + }, + "inseparableGroups": [ + [ + "r1-e1-1", + "r1-e3-2" + ], + [ + "r1-e2-1", + "r1-e2-2", + "r1-e3-1" + ] + ] + }, + "note": "落在 [q1,q9](含两端)记确认。分不开:在全部 valid 格上确认/否定序列相同的两条以上解释为一组(组内按解释顺序)。" + }, + { + "name": "confirm_calc15", + "section": "6.6 确认与否定、现有测法分不开;约定 21–22", + "params": {}, + "input": { + "model": "calc15", + "valid": { + "p1": 2.5989997592071172, + "p2": 1.7602769492805097 + } + }, + "expected": { + "verdicts": { + "e1": { + "p1": "refuted", + "p2": "refuted" + }, + "e2": { + "p1": "refuted", + "p2": "refuted" + }, + "e3": { + "p1": "refuted", + "p2": "refuted" + }, + "e4": { + "p1": "refuted", + "p2": "refuted" + }, + "e5": { + "p1": "refuted", + "p2": "refuted" + }, + "e6": { + "p1": "refuted", + "p2": "refuted" + }, + "e7": { + "p1": "refuted", + "p2": "refuted" + }, + "e8": { + "p1": "refuted", + "p2": "confirmed" + }, + "e9": { + "p1": "refuted", + "p2": "refuted" + } + }, + "inseparableGroups": [ + [ + "e1", + "e2", + "e3", + "e4", + "e5", + "e6", + "e7", + "e9" + ] + ] + }, + "note": "calc15 两格都有效。" + }, + { + "name": "confirm_endpoint_inclusive", + "section": "6.6 确认与否定、现有测法分不开;约定 21–22", + "params": {}, + "input": { + "model": "gainNotPositive", + "valid": { + "p1": 2.1, + "p2": 2.05 + } + }, + "expected": { + "verdicts": { + "e1": { + "p1": "confirmed", + "p2": "refuted" + }, + "e2": { + "p1": "refuted", + "p2": "confirmed" + } + }, + "inseparableGroups": [] + }, + "note": "端点:e1 的 p1 上沿正好 2.1、e2 的 p2 下沿正好 2.05,都算确认;e2 在 p1(上沿 2.05)否定,e1 在 p2(下沿 2.1)否定。" + }, + { + "name": "discovery_r1real", + "section": "4.3 ⑰、6.5;约定 23", + "params": { + "eps": 0.01 + }, + "input": { + "model": "r1real", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 2.250011787062861 + }, + { + "factor": "p2", + "kind": "valid", + "value": 1.9535929459502979 + }, + { + "factor": "p3", + "kind": "valid", + "value": 2.4389662413796342 + } + ] + }, + "expected": { + "line": 0.01, + "overLine": [ + "p1", + "p2", + "p3" + ], + "newBaseline": "p3" + }, + "note": "B 取 f(完整)=1.5522,采纳线 min(0.01,(2.635983−1.5522)/4)=0.01。三格都过线,取分数最好的 p3。" + }, + { + "name": "discovery_calc15", + "section": "4.3 ⑰、6.5;约定 23", + "params": { + "eps": 0.01 + }, + "input": { + "model": "calc15", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 2.5989997592071172 + }, + { + "factor": "p2", + "kind": "joint", + "value": 2.64 + } + ] + }, + "expected": { + "line": 0.001829505684299959, + "overLine": [ + "p2" + ], + "newBaseline": "p2" + }, + "note": "采纳线 = (2.635−2.62768)/4 ≈ 0.00183;p1 低于完整不过线;p2 是构造的一起关格、分数 2.64(不是真实数据),参与并过线。" + }, + { + "name": "discovery_gainNotPositive", + "section": "4.3 ⑰、6.5;约定 23", + "params": { + "eps": 0.01 + }, + "input": { + "model": "gainNotPositive", + "cells": [ + { + "factor": "p1", + "kind": "valid", + "value": 2.5 + }, + { + "factor": "p2", + "kind": "valid", + "value": 2.5 + } + ] + }, + "expected": { + "line": 0.01, + "overLine": [ + "p1", + "p2" + ], + "newBaseline": "p1" + }, + "note": "无已知最好,采纳线 ε;两格同分 2.5 都过线,取因子编号小的 p1。" + }, + { + "name": "voi_calc15", + "section": "6.7 方向收益、成分与权重、信息价值;约定 24–27", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "calc15" + }, + "expected": { + "G": 1.6679177602665936, + "h": 0.007318022737199836, + "componentNames": [ + "e1", + "e2", + "e3", + "e4", + "e5", + "e6", + "e7", + "e8", + "e9", + "reserve" + ], + "weights": [ + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1 + ], + "reserve": { + "p1": [ + 0.17499999999999982, + 3.675 + ], + "p2": [ + 1.0799999999999998, + 2.7600000000000007 + ] + }, + "componentExpectedPayoff": { + "e1": { + "p1": 0.0038508473371091563, + "p2": 0.0025653039409786215 + }, + "e2": { + "p1": 0.001668056075761235, + "p2": 0.002973343312446675 + }, + "e3": { + "p1": 0.002611372902273402, + "p2": 0.0028570740291788965 + }, + "e4": { + "p1": 0.0008102958916536589, + "p2": 0.0026815732242463997 + }, + "e5": { + "p1": 0.003050124914604642, + "p2": 0.002521428739745497 + }, + "e6": { + "p1": 0.0025345913001154348, + "p2": 0.0027539673062810547 + }, + "e7": { + "p1": 0.0020958392877841944, + "p2": 0.002391996896107781 + }, + "e8": { + "p1": 0.0009331464551064064, + "p2": 0.003269500920770263 + }, + "e9": { + "p1": 0.002973343312446675, + "p2": 0.0027539673062810547 + }, + "reserve": { + "p1": 0.003083031315529486, + "p2": 0.0031049689161460467 + } + }, + "priorExpectedPayoff": { + "p1": 0.002361064879238429, + "p2": 0.002787312459218229 + }, + "g0": 0.002787312459218229, + "voiFullExact": 0.0006313506372333815, + "partial": [ + { + "cells": [ + "p1" + ], + "voiGrid": 0.0005820672232162904, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p1": { + "breakpoints": 32, + "segments": 31, + "s": 20, + "subsegments": 620 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.0005820672330401876, + "gridMinusExact": -9.823897229553624e-12 + }, + { + "cells": [ + "p2" + ], + "voiGrid": 0.00020536458175531578, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p2": { + "breakpoints": 31, + "segments": 30, + "s": 20, + "subsegments": 600 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.00020536666249124859, + "gridMinusExact": -2.0807359328090577e-09 + } + ], + "gridFullVOI": 0.0006313460547053657, + "gridFullInfo": { + "k": 2, + "m": 2000, + "cells": { + "p1": { + "breakpoints": 32, + "segments": 31, + "s": 20, + "subsegments": 620 + }, + "p2": { + "breakpoints": 31, + "segments": 30, + "s": 20, + "subsegments": 600 + } + } + }, + "gridFullMinusExact": -4.582528015796387e-09 + }, + "note": "weights/componentExpectedPayoff 的成分顺序 = explanations 顺序 + reserve。voiFullExact:全部格都测,一维精确(有理数 Stieltjes 积分)。partial[].voiGrid:照约定 27 的网格。partial[].exactK1VOI:只测一格时的精确值,只用来看网格误差,实现不需要算。gridFullVOI:全测时也用网格算一份,与 voiFullExact 对照(gridFullMinusExact)。grid.cells 给出每格折点数、段数、s、小段数:网格折点在浮点上按 q1-(q5-q1)、q9+(q9-q5)、L-W/2、U+W/2 计算后去重,实现应先核对这几个数一致(差一个 ulp 就会多出一段宽约 2e-16 的段,例如 calc15 p1 里 1.0499999999999998 与 1.05 并存)。" + }, + { + "name": "voi_r1real", + "section": "6.7 方向收益、成分与权重、信息价值;约定 24–27", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "r1real" + }, + "expected": { + "G": 0.5924118310970625, + "h": 1.083806951906731, + "componentNames": [ + "r1-e1-1", + "r1-e1-2", + "r1-e2-1", + "r1-e2-2", + "r1-e3-1", + "r1-e3-2", + "reserve" + ], + "weights": [ + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.1 + ], + "reserve": { + "p1": [ + -1.1500000000000001, + 5.05 + ], + "p2": [ + 1.0599999999999996, + 1.9400000000000004 + ], + "p3": [ + -2.7750000000000004, + 5.525 + ] + }, + "componentExpectedPayoff": { + "r1-e1-1": { + "p1": -0.5887712197116215, + "p2": 0.028679052324877385, + "p3": -1.5410167503634666 + }, + "r1-e1-2": { + "p1": 0.316822512608577, + "p2": 0.028679052324877385, + "p3": 0.29944243087717926 + }, + "r1-e2-1": { + "p1": 0.1777818587573949, + "p2": 0.059780251212641806, + "p3": -0.5906007019991372 + }, + "r1-e2-2": { + "p1": 0.17320815303860604, + "p2": 0.0716718860814929, + "p3": 0.09088145010040616 + }, + "r1-e3-1": { + "p1": 0.3643890520839813, + "p2": 0.031423275756150774, + "p3": -1.2931219004051093 + }, + "r1-e3-2": { + "p1": -0.28324767769652415, + "p2": 0.02776431118111961, + "p3": -1.3617274861869422 + }, + "reserve": { + "p1": -0.7278118735628034, + "p2": 0.09545515581919511, + "p3": 0.32414044175863876 + } + }, + "priorExpectedPayoff": { + "p1": -0.0487537854942183, + "p2": 0.04674518991409349, + "p3": -0.6270073995206966 + }, + "g0": 0.04674518991409349, + "voiFullExact": 0.6768856765570694, + "partial": [ + { + "cells": [ + "p1" + ], + "voiGrid": 0.3932819565009106, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p1": { + "breakpoints": 31, + "segments": 30, + "s": 20, + "subsegments": 600 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.39328196654270475, + "gridMinusExact": -1.0041794173965002e-08 + }, + { + "cells": [ + "p2" + ], + "voiGrid": 0.04469391965086199, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p2": { + "breakpoints": 21, + "segments": 20, + "s": 20, + "subsegments": 400 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.04469959609954742, + "gridMinusExact": -5.676448685432156e-06 + }, + { + "cells": [ + "p3" + ], + "voiGrid": 0.41273577238295406, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p3": { + "breakpoints": 29, + "segments": 28, + "s": 20, + "subsegments": 560 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.41273577238295456, + "gridMinusExact": -4.996003610813204e-16 + }, + { + "cells": [ + "p1", + "p2" + ], + "voiGrid": 0.40801451007605366, + "grid": { + "k": 2, + "m": 2000, + "cells": { + "p1": { + "breakpoints": 31, + "segments": 30, + "s": 20, + "subsegments": 600 + }, + "p2": { + "breakpoints": 21, + "segments": 20, + "s": 20, + "subsegments": 400 + } + }, + "method": "grid" + } + }, + { + "cells": [ + "p1", + "p3" + ], + "voiGrid": 0.6745232756112371, + "grid": { + "k": 2, + "m": 2000, + "cells": { + "p1": { + "breakpoints": 31, + "segments": 30, + "s": 20, + "subsegments": 600 + }, + "p3": { + "breakpoints": 29, + "segments": 28, + "s": 20, + "subsegments": 560 + } + }, + "method": "grid" + } + }, + { + "cells": [ + "p2", + "p3" + ], + "voiGrid": 0.43115260873837397, + "grid": { + "k": 2, + "m": 2000, + "cells": { + "p2": { + "breakpoints": 21, + "segments": 20, + "s": 20, + "subsegments": 400 + }, + "p3": { + "breakpoints": 29, + "segments": 28, + "s": 20, + "subsegments": 560 + } + }, + "method": "grid" + } + } + ], + "gridFullVOI": 0.6768144593327583, + "gridFullInfo": { + "k": 3, + "m": 158, + "cells": { + "p1": { + "breakpoints": 31, + "segments": 30, + "s": 5, + "subsegments": 150 + }, + "p2": { + "breakpoints": 21, + "segments": 20, + "s": 7, + "subsegments": 140 + }, + "p3": { + "breakpoints": 29, + "segments": 28, + "s": 5, + "subsegments": 140 + } + } + }, + "gridFullMinusExact": -7.121722431113753e-05 + }, + "note": "weights/componentExpectedPayoff 的成分顺序 = explanations 顺序 + reserve。voiFullExact:全部格都测,一维精确(有理数 Stieltjes 积分)。partial[].voiGrid:照约定 27 的网格。partial[].exactK1VOI:只测一格时的精确值,只用来看网格误差,实现不需要算。gridFullVOI:全测时也用网格算一份,与 voiFullExact 对照(gridFullMinusExact)。grid.cells 给出每格折点数、段数、s、小段数:网格折点在浮点上按 q1-(q5-q1)、q9+(q9-q5)、L-W/2、U+W/2 计算后去重,实现应先核对这几个数一致(差一个 ulp 就会多出一段宽约 2e-16 的段,例如 calc15 p1 里 1.0499999999999998 与 1.05 并存)。" + }, + { + "name": "voi_minimize", + "section": "6.7 方向收益、成分与权重、信息价值;约定 24–27", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "minimize" + }, + "expected": { + "G": 0.050000000000000044, + "h": 0.019999999999999962, + "componentNames": [ + "e1", + "e2", + "e3", + "reserve" + ], + "weights": [ + 0.225, + 0.225, + 0.45, + 0.1 + ], + "reserve": { + "p1": [ + 0.30749999999999994, + 0.4575000000000001 + ], + "p2": [ + 0.29999999999999993, + 0.4600000000000001 + ] + }, + "componentExpectedPayoff": { + "e1": { + "p1": 0.009399999999999983, + "p2": 0.003999999999999992 + }, + "e2": { + "p1": 0.001999999999999996, + "p2": 0.011999999999999978 + }, + "e3": { + "p1": 0.013399999999999974, + "p2": 0.006699999999999987 + }, + "reserve": { + "p1": 0.012999999999999975, + "p2": 0.011999999999999978 + } + }, + "priorExpectedPayoff": { + "p1": 0.009894999999999982, + "p2": 0.007814999999999985 + }, + "g0": 0.009894999999999982, + "voiFullExact": 0.003645968749999993, + "partial": [ + { + "cells": [ + "p1" + ], + "voiGrid": 0.0029590833333333205, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p1": { + "breakpoints": 13, + "segments": 12, + "s": 20, + "subsegments": 240 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.002959083333333328, + "gridMinusExact": -7.37257477290143e-18 + }, + { + "cells": [ + "p2" + ], + "voiGrid": 0.0026489437499999935, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p2": { + "breakpoints": 13, + "segments": 12, + "s": 20, + "subsegments": 240 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.0026490793951612856, + "gridMinusExact": -1.3564516129207713e-07 + } + ], + "gridFullVOI": 0.0036457966536462376, + "gridFullInfo": { + "k": 2, + "m": 2000, + "cells": { + "p1": { + "breakpoints": 13, + "segments": 12, + "s": 20, + "subsegments": 240 + }, + "p2": { + "breakpoints": 13, + "segments": 12, + "s": 20, + "subsegments": 240 + } + } + }, + "gridFullMinusExact": -1.7209635375537574e-07 + }, + "note": "weights/componentExpectedPayoff 的成分顺序 = explanations 顺序 + reserve。voiFullExact:全部格都测,一维精确(有理数 Stieltjes 积分)。partial[].voiGrid:照约定 27 的网格。partial[].exactK1VOI:只测一格时的精确值,只用来看网格误差,实现不需要算。gridFullVOI:全测时也用网格算一份,与 voiFullExact 对照(gridFullMinusExact)。grid.cells 给出每格折点数、段数、s、小段数:网格折点在浮点上按 q1-(q5-q1)、q9+(q9-q5)、L-W/2、U+W/2 计算后去重,实现应先核对这几个数一致(差一个 ulp 就会多出一段宽约 2e-16 的段,例如 calc15 p1 里 1.0499999999999998 与 1.05 并存)。" + }, + { + "name": "voi_fourCells", + "section": "6.7 方向收益、成分与权重、信息价值;约定 24–27", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "fourCells" + }, + "expected": { + "G": 1.6679177602665936, + "h": 0.007318022737199836, + "componentNames": [ + "e1", + "e2", + "e3", + "e4", + "e5", + "e6", + "e7", + "e8", + "e9", + "reserve" + ], + "weights": [ + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1 + ], + "reserve": { + "p1": [ + 0.17499999999999982, + 3.675 + ], + "p2": [ + 1.0799999999999998, + 2.7600000000000007 + ], + "p3": [ + -0.1256499999999997, + 3.37695 + ], + "p4": [ + 1.1859499999999996, + 2.8617500000000002 + ] + }, + "componentExpectedPayoff": { + "e1": { + "p1": 0.0038508473371091563, + "p2": 0.0025653039409786215, + "p3": 0.005167103374102878, + "p4": 0.002126551928647381 + }, + "e2": { + "p1": 0.001668056075761235, + "p2": 0.002973343312446675, + "p3": 0.00297860833659465, + "p4": 0.002531520036029116 + }, + "e3": { + "p1": 0.002611372902273402, + "p2": 0.0028570740291788965, + "p3": 0.003916221386946511, + "p4": 0.002412179488675019 + }, + "e4": { + "p1": 0.0008102958916536589, + "p2": 0.0026815732242463997, + "p3": 0.0021094406001664623, + "p4": 0.0022336074196562038 + }, + "e5": { + "p1": 0.003050124914604642, + "p2": 0.002521428739745497, + "p3": 0.004343565846957139, + "p4": 0.002070391671068982 + }, + "e6": { + "p1": 0.0025345913001154348, + "p2": 0.0027539673062810547, + "p3": 0.0038223284563076256, + "p4": 0.002299858973518221 + }, + "e7": { + "p1": 0.0020958392877841944, + "p2": 0.002391996896107781, + "p3": 0.003377872667816079, + "p4": 0.001934817299258629 + }, + "e8": { + "p1": 0.0009331464551064064, + "p2": 0.003269500920770263, + "p3": 0.002209476058977985, + "p4": 0.002809250059834791 + }, + "e9": { + "p1": 0.002973343312446675, + "p2": 0.0027539673062810547, + "p3": 0.004243969140157947, + "p4": 0.002290645181259265 + }, + "reserve": { + "p1": 0.003083031315529486, + "p2": 0.0031049689161460467, + "p3": 0.004396435464443053, + "p4": 0.0026493249513400543 + } + }, + "priorExpectedPayoff": { + "p1": 0.002361064879238429, + "p2": 0.002787312459218229, + "p3": 0.003656502133247033, + "p4": 0.0023358147009287663 + }, + "g0": 0.003656502133247033, + "voiFullExact": 0.0007241714702604439, + "partial": [ + { + "cells": [ + "p1", + "p2", + "p3" + ], + "voiGrid": 0.0006802215037563838, + "grid": { + "k": 3, + "m": 158, + "cells": { + "p1": { + "breakpoints": 32, + "segments": 31, + "s": 5, + "subsegments": 155 + }, + "p2": { + "breakpoints": 31, + "segments": 30, + "s": 5, + "subsegments": 150 + }, + "p3": { + "breakpoints": 47, + "segments": 46, + "s": 3, + "subsegments": 138 + } + }, + "method": "grid" + } + } + ], + "gridFullVOI": 0.0007234478512161647, + "gridFullInfo": { + "k": 4, + "m": 44, + "cells": { + "p1": { + "breakpoints": 32, + "segments": 31, + "s": 1, + "subsegments": 31 + }, + "p2": { + "breakpoints": 31, + "segments": 30, + "s": 1, + "subsegments": 30 + }, + "p3": { + "breakpoints": 47, + "segments": 46, + "s": 0, + "subsegments": 44 + }, + "p4": { + "breakpoints": 47, + "segments": 46, + "s": 0, + "subsegments": 44 + } + } + }, + "gridFullMinusExact": -7.236190442791619e-07 + }, + "note": "weights/componentExpectedPayoff 的成分顺序 = explanations 顺序 + reserve。voiFullExact:全部格都测,一维精确(有理数 Stieltjes 积分)。partial[].voiGrid:照约定 27 的网格。partial[].exactK1VOI:只测一格时的精确值,只用来看网格误差,实现不需要算。gridFullVOI:全测时也用网格算一份,与 voiFullExact 对照(gridFullMinusExact)。grid.cells 给出每格折点数、段数、s、小段数:网格折点在浮点上按 q1-(q5-q1)、q9+(q9-q5)、L-W/2、U+W/2 计算后去重,实现应先核对这几个数一致(差一个 ulp 就会多出一段宽约 2e-16 的段,例如 calc15 p1 里 1.0499999999999998 与 1.05 并存)。" + }, + { + "name": "voi_negativeG0", + "section": "6.7 方向收益、成分与权重、信息价值;约定 24–27", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "negativeG0" + }, + "expected": { + "G": 1.0, + "h": 1.0, + "componentNames": [ + "e1", + "e2", + "e3", + "reserve" + ], + "weights": [ + 0.45, + 0.225, + 0.225, + 0.1 + ], + "reserve": { + "p1": [ + 1.5500000000000003, + 2.7499999999999996 + ], + "p2": [ + 1.3499999999999994, + 2.750000000000001 + ] + }, + "componentExpectedPayoff": { + "e1": { + "p1": -0.16749999999999984, + "p2": -0.06500000000000006 + }, + "e2": { + "p1": -0.10000000000000009, + "p2": -0.08349999999999999 + }, + "e3": { + "p1": -0.03249999999999996, + "p2": -0.017500000000000092 + }, + "reserve": { + "p1": -0.1499999999999999, + "p2": -0.050000000000000044 + } + }, + "priorExpectedPayoff": { + "p1": -0.12018749999999993, + "p2": -0.05697500000000005 + }, + "g0": -0.05697500000000005, + "voiFullExact": 0.049642821428571494, + "partial": [ + { + "cells": [ + "p1" + ], + "voiGrid": 0.023196666666666692, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p1": { + "breakpoints": 15, + "segments": 14, + "s": 20, + "subsegments": 280 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.02319666666666669, + "gridMinusExact": 3.469446951953614e-18 + }, + { + "cells": [ + "p2" + ], + "voiGrid": 0.029396450892857223, + "grid": { + "k": 1, + "m": 4000000, + "cells": { + "p2": { + "breakpoints": 16, + "segments": 15, + "s": 20, + "subsegments": 300 + } + }, + "method": "grid" + }, + "exactK1VOI": 0.02939925782355029, + "gridMinusExact": -2.8069306930653126e-06 + } + ], + "gridFullVOI": 0.04964022946875037, + "gridFullInfo": { + "k": 2, + "m": 2000, + "cells": { + "p1": { + "breakpoints": 15, + "segments": 14, + "s": 20, + "subsegments": 280 + }, + "p2": { + "breakpoints": 16, + "segments": 15, + "s": 20, + "subsegments": 300 + } + } + }, + "gridFullMinusExact": -2.591959821121592e-06 + }, + "note": "weights/componentExpectedPayoff 的成分顺序 = explanations 顺序 + reserve。voiFullExact:全部格都测,一维精确(有理数 Stieltjes 积分)。partial[].voiGrid:照约定 27 的网格。partial[].exactK1VOI:只测一格时的精确值,只用来看网格误差,实现不需要算。gridFullVOI:全测时也用网格算一份,与 voiFullExact 对照(gridFullMinusExact)。grid.cells 给出每格折点数、段数、s、小段数:网格折点在浮点上按 q1-(q5-q1)、q9+(q9-q5)、L-W/2、U+W/2 计算后去重,实现应先核对这几个数一致(差一个 ulp 就会多出一段宽约 2e-16 的段,例如 calc15 p1 里 1.0499999999999998 与 1.05 并存)。" + }, + { + "name": "select_calc15", + "section": "6.7 价值减成本、选格;约定 28–29、文末", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "calc15", + "balances": [ + 0, + 1, + 2, + 3, + 10 + ] + }, + "expected": { + "g0": 0.002787312459218229, + "byBalance": [ + { + "balance": 0, + "combos": [], + "selected": [], + "selectedNet": 0.0, + "unselected": { + "p1": { + "reason": "insufficientBudget", + "bestCombo": null, + "bestNet": null + }, + "p2": { + "reason": "insufficientBudget", + "bestCombo": null, + "bestNet": null + } + }, + "fullRule": { + "selected": [], + "insufficientBudget": [ + "p1", + "p2" + ] + } + }, + { + "balance": 1, + "combos": [], + "selected": [], + "selectedNet": 0.0, + "unselected": { + "p1": { + "reason": "insufficientBudget", + "bestCombo": null, + "bestNet": null + }, + "p2": { + "reason": "insufficientBudget", + "bestCombo": null, + "bestNet": null + } + }, + "fullRule": { + "selected": [], + "insufficientBudget": [ + "p1", + "p2" + ] + } + }, + { + "balance": 2, + "combos": [ + { + "combo": [ + "p1" + ], + "voi": 0.0005820672232162904, + "Rhat": 1, + "mPrime": 1.0, + "value": 0.0002910336116081452, + "cost": 0.0004645520765363715, + "net": -0.0001735184649282263 + }, + { + "combo": [ + "p2" + ], + "voi": 0.00020536458175531578, + "Rhat": 1, + "mPrime": 1.0, + "value": 0.00010268229087765789, + "cost": 0.0004645520765363715, + "net": -0.0003618697856587136 + } + ], + "selected": [], + "selectedNet": 0.0, + "unselected": { + "p1": { + "reason": "notWorthCost", + "bestCombo": [ + "p1" + ], + "bestNet": -0.0001735184649282263 + }, + "p2": { + "reason": "notWorthCost", + "bestCombo": [ + "p2" + ], + "bestNet": -0.0003618697856587136 + } + }, + "fullRule": { + "selected": [ + "p1" + ], + "insufficientBudget": [ + "p2" + ] + } + }, + { + "balance": 3, + "combos": [ + { + "combo": [ + "p1" + ], + "voi": 0.0005820672232162904, + "Rhat": 1, + "mPrime": 1.0, + "value": 0.0002910336116081452, + "cost": 0.0004645520765363715, + "net": -0.0001735184649282263 + }, + { + "combo": [ + "p2" + ], + "voi": 0.00020536458175531578, + "Rhat": 1, + "mPrime": 1.0, + "value": 0.00010268229087765789, + "cost": 0.0004645520765363715, + "net": -0.0003618697856587136 + }, + { + "combo": [ + "p1", + "p2" + ], + "voi": 0.0006313506372333815, + "Rhat": 1, + "mPrime": 1.0, + "value": 0.00031567531861669077, + "cost": 0.0007432833224581943, + "net": -0.00042760800384150356 + } + ], + "selected": [], + "selectedNet": 0.0, + "unselected": { + "p1": { + "reason": "notWorthCost", + "bestCombo": [ + "p1" + ], + "bestNet": -0.0001735184649282263 + }, + "p2": { + "reason": "notWorthCost", + "bestCombo": [ + "p2" + ], + "bestNet": -0.0003618697856587136 + } + }, + "fullRule": { + "selected": [ + "p1", + "p2" + ], + "insufficientBudget": [] + } + }, + { + "balance": 10, + "combos": [ + { + "combo": [ + "p1" + ], + "voi": 0.0005820672232162904, + "Rhat": 5, + "mPrime": 1.9375, + "value": 0.0005638776224907813, + "cost": 0.0004645520765363715, + "net": 9.932554595440982e-05 + }, + { + "combo": [ + "p2" + ], + "voi": 0.00020536458175531578, + "Rhat": 5, + "mPrime": 1.9375, + "value": 0.00019894693857546217, + "cost": 0.0004645520765363715, + "net": -0.00026560513796090933 + }, + { + "combo": [ + "p1", + "p2" + ], + "voi": 0.0006313506372333815, + "Rhat": 4, + "mPrime": 1.875, + "value": 0.0005918912224062952, + "cost": 0.0007432833224581943, + "net": -0.00015139210005189914 + } + ], + "selected": [ + "p1" + ], + "selectedNet": 9.932554595440982e-05, + "unselected": { + "p2": { + "reason": "notWorthCost", + "bestCombo": [ + "p1", + "p2" + ], + "bestNet": -0.00015139210005189914 + } + }, + "fullRule": { + "selected": [ + "p1", + "p2" + ], + "insufficientBudget": [] + } + } + ] + }, + "note": "余额 0、1 不做消融,各格 reason=insufficientBudget、bestCombo=null;余额 2 只能选单格;余额 3 起可以两格全测(VOI 用一维精确值),部分格用网格。R̂ = ⌈(余额−|S|)/每批上限⌉,m′ = Σ_{j 1e-12,入选;{p1,p2} = 3.0e-12 只比当前最好多 0.5e-12(不超过 1e-12),视为相同,保留格数少的 p1。" + }, + { + "name": "select_pure_tie_chain", + "section": "6.7 选格第 2–3 条;约定 29", + "params": { + "tolerance": 1e-12 + }, + "input": { + "cells": [ + "p1", + "p2", + "p3" + ], + "balance": 4, + "nets": { + "p1": 0.01, + "p2": 0.010000000000800001, + "p3": 0.0100000000016, + "p1,p2": 0.0, + "p1,p3": 0.0, + "p2,p3": 0.0, + "p1,p2,p3": -1.0 + } + }, + "expected": { + "selected": [ + "p2" + ], + "selectedNet": 0.010000000000800001, + "unselected": { + "p1": { + "reason": "notWorthCost", + "bestCombo": [ + "p1" + ], + "bestNet": 0.01 + }, + "p3": { + "reason": "notWorthCost", + "bestCombo": [ + "p3" + ], + "bestNet": 0.0100000000016 + } + } + }, + "note": "最大净值 M = p3 = 0.01+1.6e-12;净值 ≥ M − 1e-12 的 p2、p3 视为相同,同格数按编号取小的 p2。(预期值曾按“逐个扫描、比当前最好多 1e-12 才替换”的接力写法选 p3;审阅指出方法 6.7 是先求最大净值、再在差不到 1e-12 的组合里按格数和编号挑,改后选 p2。)" + }, + { + "name": "select_pure_filter_before_max", + "section": "6.7 选格第 2–3 条;约定 29", + "params": { + "tolerance": 1e-12 + }, + "input": { + "cells": [ + "p1", + "p2" + ], + "balance": 3, + "nets": { + "p1": 6e-13, + "p2": -1.0, + "p1,p2": 1.5e-12 + } + }, + "expected": { + "selected": [ + "p1", + "p2" + ], + "selectedNet": 1.5e-12, + "unselected": {} + }, + "note": "第二轮审阅的反例:先只留净值 > 1e-12 的组合(只剩 {p1,p2}),再在其中求最大值和容差窗口,选 {p1,p2}。若先在全部组合里求 M = 1.5e-12、窗口 ≥ 0.5e-12,{p1}(0.6e-12)会因格数少被选中,而它的净值并不 > 1e-12。p1、p2 都被选中,没有没测的格。" + }, + { + "name": "select_pure_tolerance_relay", + "section": "6.7 选格第 2–3 条;约定 29", + "params": { + "tolerance": 1e-12 + }, + "input": { + "cells": [ + "p1", + "p2" + ], + "balance": 3, + "nets": { + "p1": 1.0, + "p2": 1.0000000000009, + "p1,p2": 1.0000000000018 + } + }, + "expected": { + "selected": [ + "p2" + ], + "selectedNet": 1.0000000000009, + "unselected": { + "p1": { + "reason": "notWorthCost", + "bestCombo": [ + "p1", + "p2" + ], + "bestNet": 1.0000000000018 + } + } + }, + "note": "审阅给的接力反例:M = {p1,p2} = 1+1.8e-12,窗口 ≥ 1+0.8e-12 内有 {p2} 和 {p1,p2},先取格数少的 {p2}。接力写法会因 {p2} 比 {p1} 只多 0.9e-12 不替换、{p1,p2} 比 {p1} 多 1.8e-12 替换而选 {p1,p2}。p1 没选中,含它的组合 {p1}、{p1,p2} 里同样按这条挑:M = 1+1.8e-12,窗口内只有 {p1,p2}。" + }, + { + "name": "select_pure_budget_reason", + "section": "6.7 选格第 2–3 条;约定 29", + "params": { + "tolerance": 1e-12 + }, + "input": { + "cells": [ + "p1", + "p2", + "p3" + ], + "balance": 2, + "nets": { + "p1": 0.002, + "p2": 0.003, + "p3": -0.001 + } + }, + "expected": { + "selected": [ + "p2" + ], + "selectedNet": 0.003, + "unselected": { + "p1": { + "reason": "insufficientBudget", + "bestCombo": [ + "p1" + ], + "bestNet": 0.002 + }, + "p3": { + "reason": "insufficientBudget", + "bestCombo": [ + "p3" + ], + "bestNet": -0.001 + } + } + }, + "note": "余额 2 只能测 1 格:选 p2;p1、p3 再加进来就超过余额−1,reason = insufficientBudget。" + }, + { + "name": "bet_calc15_all_valid", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "calc15", + "outcomes": { + "p1": { + "kind": "valid", + "value": 2.5989997592071172 + }, + "p2": { + "kind": "valid", + "value": 1.7602769492805097 + } + } + }, + "expected": { + "weightsUpdated": true, + "weights": { + "e1": 0.0, + "e2": 0.08322400974382729, + "e3": 0.049538101037992416, + "e4": 0.14121721110377944, + "e5": 0.04876406820927372, + "e6": 0.0, + "e7": 0.0, + "e8": 0.6241800730787067, + "e9": 0.0, + "reserve": 0.053076536826420426 + }, + "payoffs": { + "p1": 0.00012584380890054235, + "p2": 0.0038057570153346588 + }, + "picked": "p2", + "predictedGain": 0.0038057570153346588, + "status": "bet", + "negativeFactors": [] + }, + "note": "两格都有效:收益用实测换算,押 p2。weights 是按两格密度更新后的成分权重(只用于没有有效分数的格,这里只是记录)。" + }, + { + "name": "bet_calc15_p2_insufficient", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "calc15", + "outcomes": { + "p1": { + "kind": "valid", + "value": 2.5989997592071172 + }, + "p2": { + "kind": "insufficient" + } + } + }, + "expected": { + "weightsUpdated": true, + "weights": { + "e1": 0.0, + "e2": 0.11797115540431034, + "e3": 0.08426511100307879, + "e4": 0.22686760654675084, + "e5": 0.07373197212769395, + "e6": 0.0, + "e7": 0.11797115540431034, + "e8": 0.2949278885107769, + "e9": 0.0, + "reserve": 0.08426511100307882 + }, + "payoffs": { + "p1": 0.00012584380890054235, + "p2": 0.002893886609905632 + }, + "picked": "p2", + "predictedGain": 0.002893886609905632, + "status": "bet", + "negativeFactors": [] + }, + "note": "p2 证据不足:权重 ∝ 起始权重 × 各成分在 p1 实测处的密度,再用新权重估 p2 的期望收益。" + }, + { + "name": "bet_calc15_joint_ignored", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "calc15", + "outcomes": { + "p1": { + "kind": "valid", + "value": 2.5989997592071172 + }, + "p2": { + "kind": "joint", + "value": 1.76 + } + } + }, + "expected": { + "weightsUpdated": true, + "weights": { + "e1": 0.0, + "e2": 0.11797115540431034, + "e3": 0.08426511100307879, + "e4": 0.22686760654675084, + "e5": 0.07373197212769395, + "e6": 0.0, + "e7": 0.11797115540431034, + "e8": 0.2949278885107769, + "e9": 0.0, + "reserve": 0.08426511100307882 + }, + "payoffs": { + "p1": 0.00012584380890054235, + "p2": 0.002893886609905632 + }, + "picked": "p2", + "predictedGain": 0.002893886609905632, + "status": "bet", + "negativeFactors": [] + }, + "note": "p2 是一起关,它的分数不参与权重更新,也不当实测用;结果应与 p2 证据不足那一例完全相同。" + }, + { + "name": "bet_r1_negative_support", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "r1real", + "outcomes": { + "p1": { + "kind": "valid", + "value": 2.250011787062861 + }, + "p2": { + "kind": "valid", + "value": 1.9535929459502979 + }, + "p3": { + "kind": "valid", + "value": 2.4389662413796342 + } + } + }, + "expected": { + "weightsUpdated": false, + "weights": { + "r1-e1-1": 0.15, + "r1-e1-2": 0.15, + "r1-e2-1": 0.15, + "r1-e2-2": 0.15, + "r1-e3-1": 0.15, + "r1-e3-2": 0.15, + "reserve": 0.1 + }, + "payoffs": { + "p1": -1.276678124040195, + "p2": -0.7343851045388742, + "p3": -1.6223669513598986 + }, + "picked": "p2", + "predictedGain": -0.7343851045388742, + "status": "noBetNegativeSupport", + "negativeFactors": [ + { + "factor": "p1", + "offBetterBy": 0.697835738969592 + }, + { + "factor": "p2", + "offBetterBy": 0.40141689785702894 + }, + { + "factor": "p3", + "offBetterBy": 0.8867901932863653 + } + ] + }, + "note": "三格撑都 < 0:不押,negativeFactors 记“关掉反而更好”的分数(x − 完整,按方向,正数)。挑出的因子与预测收益照记。另外:p2 实测 1.9536 落在 p2 所有成分(含预留 [1.06,1.94])之外,各成分在三格密度连乘全为 0,权重无从更新、保持起始权重(weightsUpdated=false)——真实数据里就出现了这种情况。" + }, + { + "name": "bet_gain_not_positive", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "gainNotPositive", + "outcomes": { + "p1": { + "kind": "valid", + "value": 2.0 + }, + "p2": { + "kind": "untested" + } + } + }, + "expected": { + "weightsUpdated": true, + "weights": { + "e1": 0.325791855203619, + "e2": 0.6515837104072408, + "reserve": 0.02262443438914021 + }, + "payoffs": { + "p1": 0.0, + "p2": -0.16685520361990938 + }, + "picked": "p1", + "predictedGain": 0.0, + "status": "noBetNonPositiveGain", + "negativeFactors": [] + }, + "note": "p1 实测正好等于完整,收益 0(撑 0,不是负);p2 各成分都预计关掉更好,估计收益为负。最大收益 0 ≤ 0,不押。" + }, + { + "name": "bet_weights_not_updatable", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "gainNotPositive", + "outcomes": { + "p1": { + "kind": "valid", + "value": -5.0 + }, + "p2": { + "kind": "untested" + } + } + }, + "expected": { + "weightsUpdated": false, + "weights": { + "e1": 0.45, + "e2": 0.45, + "reserve": 0.1 + }, + "payoffs": { + "p1": 7.0, + "p2": -0.17499999999999993 + }, + "picked": "p1", + "predictedGain": 7.0, + "status": "bet", + "negativeFactors": [] + }, + "note": "p1 实测 −5 落在所有成分(含预留)之外,密度全为 0,权重无从更新:保持起始权重,weightsUpdated=false。" + }, + { + "name": "bet_minimize", + "section": "6.8;约定 30", + "params": { + "eps": 0.01, + "lambda": 0.1, + "u": 0.5, + "r": 0.5, + "batchCap": 2 + }, + "input": { + "model": "minimize", + "outcomes": { + "p1": { + "kind": "valid", + "value": 0.372 + }, + "p2": { + "kind": "cannotRun" + } + } + }, + "expected": { + "weightsUpdated": true, + "weights": { + "e1": 0.19424460431654675, + "e2": 0.0, + "e3": 0.776978417266187, + "reserve": 0.02877697841726619 + }, + "payoffs": { + "p1": 0.008799999999999983, + "p2": 0.006328057553956823 + }, + "picked": "p1", + "predictedGain": 0.008799999999999983, + "status": "bet", + "negativeFactors": [] + }, + "note": "越小越好:收益 = h·(x − 完整)/G,h = max(完整 − K*, ε/100) = 0.02。" + } + ] +} diff --git a/tests/fixtures/rules/forward.json b/tests/fixtures/rules/forward.json new file mode 100644 index 0000000..23b21f1 --- /dev/null +++ b/tests/fixtures/rules/forward.json @@ -0,0 +1,1658 @@ +{ + "generator": "gen_forward_info.py", + "tolerance": { + "abs": 1e-12, + "note": "预期值由有理数精确算出;与浮点实现比较用绝对误差 1e-12。" + }, + "scenarios": [ + { + "name": "cdf_method_example_2.44_2.52_2.62", + "section": "6.2;约定 1–4", + "params": {}, + "input": { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "x": [ + 2.3, + 2.36, + 2.4, + 2.44, + 2.48, + 2.52, + 2.57, + 2.62, + 2.67, + 2.72, + 2.8 + ], + "densityAt": [ + 2.3, + 2.36, + 2.4, + 2.44, + 2.48, + 2.52, + 2.57, + 2.62, + 2.67, + 2.72, + 2.8 + ] + }, + "expected": { + "points": [ + [ + 2.36, + 0.0 + ], + [ + 2.44, + 0.1 + ], + [ + 2.52, + 0.5 + ], + [ + 2.62, + 0.9 + ], + [ + 2.72, + 1.0 + ] + ], + "cdf": [ + 0.0, + 0.0, + 0.05, + 0.1, + 0.3, + 0.5, + 0.6999999999999991, + 0.9, + 0.9499999999999997, + 1.0, + 1.0 + ], + "density": [ + 0.0, + 1.249999999999999, + 1.249999999999999, + 4.999999999999996, + 4.999999999999996, + 3.9999999999999964, + 3.9999999999999964, + 0.9999999999999991, + 0.9999999999999991, + 0.0, + 0.0 + ], + "mean": 2.527 + }, + "note": "方法 6.2 示例:低于 2.57 的把握 0.70;尾巴 2.36–2.44、2.62–2.72。" + }, + { + "name": "cdf_asymmetric_narrow", + "section": "6.2;约定 1–4", + "params": {}, + "input": { + "range": { + "q1": 1.2999, + "q5": 1.3, + "q9": 1.3005 + }, + "x": [ + 1.2997, + 1.2998, + 1.29995, + 1.3, + 1.3002, + 1.3005, + 1.30075, + 1.301 + ], + "densityAt": [ + 1.2997, + 1.2998, + 1.29995, + 1.3, + 1.3002, + 1.3005, + 1.30075, + 1.301 + ] + }, + "expected": { + "points": [ + [ + 1.2998, + 0.0 + ], + [ + 1.2999, + 0.1 + ], + [ + 1.3, + 0.5 + ], + [ + 1.3005, + 0.9 + ], + [ + 1.301, + 1.0 + ] + ], + "cdf": [ + 0.0, + 0.0, + 0.2999999999995559, + 0.5, + 0.66, + 0.9, + 0.9500000000000222, + 1.0 + ], + "density": [ + 0.0, + 1000.0000000001102, + 4000.0000000004407, + 800.0000000000881, + 800.0000000000881, + 200.00000000002203, + 200.00000000002203, + 0.0 + ], + "mean": 1.30014 + }, + "note": "方法第五部分的窄范围写法(不对称):低端尾巴 1.2998–1.2999,高端尾巴 1.3005–1.3010。折点上密度取右边那段。" + }, + { + "name": "minimize_to_improvement", + "section": "6.1;约定 5", + "params": { + "direction": "minimize" + }, + "input": { + "b": 0.4, + "range": { + "q1": 0.3, + "q5": 0.35, + "q9": 0.45 + }, + "y": [ + -0.2, + -0.05, + 0.0, + 0.05, + 0.08, + 0.1, + 0.25 + ] + }, + "expected": { + "Yrange": { + "q1": -0.04999999999999999, + "q5": 0.050000000000000044, + "q9": 0.10000000000000003 + }, + "Ypoints": [ + [ + -0.15000000000000002, + 0.0 + ], + [ + -0.04999999999999999, + 0.1 + ], + [ + 0.050000000000000044, + 0.5 + ], + [ + 0.10000000000000003, + 0.9 + ], + [ + 0.15000000000000002, + 1.0 + ] + ], + "Ycdf": [ + 0.0, + 0.09999999999999999, + 0.2999999999999999, + 0.49999999999999983, + 0.7399999999999997, + 0.8999999999999998, + 1.0 + ] + }, + "note": "Y = b − 分数,一成九成对调:Y 的三个数 = (b−q9, b−q5, b−q1) = (−0.05, 0.05, 0.10);低端尾巴宽 = 原 q9−q5 = 0.10,高端尾巴宽 = 原 q5−q1 = 0.05。" + }, + { + "name": "forward_merge_r1_real", + "section": "6.3、6.1;约定 6–8", + "params": { + "lambda": 0.1, + "direction": "maximize", + "eps": 0.01 + }, + "input": { + "b": 0.9597642169962064, + "candidates": { + "r1-v1-c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.0, + "q5": 2.45, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.63 + } + } + ], + "r1-v1-c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 1.85, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 1.6, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 1.8, + "q5": 2.4, + "q9": 2.62 + } + } + ], + "r1-v1-c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.3, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.42, + "q5": 2.52, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.1, + "q5": 2.5, + "q9": 2.6 + } + } + ] + }, + "Yprobe": [ + -1.0, + -0.01, + 0.0, + 0.5, + 1.0, + 1.4, + 1.5, + 1.6, + 1.7, + 2.0 + ] + }, + "expected": { + "reserveY": [ + -1.1097642169962063, + 2.890235783003794 + ], + "reserveRaw": [ + -0.1499999999999999, + 3.85 + ], + "candidates": { + "r1-v1-c1": { + "Ycdf": [ + 0.002744105424905158, + 0.02749410542490516, + 0.027744105424905158, + 0.04024410542490516, + 0.09201456329056014, + 0.3764926035541918, + 0.4908668526188352, + 0.7204866655530365, + 0.909006668533861, + 0.9777441054249052 + ], + "Yquantiles": { + "q1": 1.027378640146651, + "q5": 1.505620398388409, + "q9": 1.6872385077994339 + }, + "rawQuantiles": { + "q1": 1.9871428571428573, + "q5": 2.4653846153846155, + "q9": 2.6470027247956405 + }, + "Ymean": 1.3919857830037936 + }, + "r1-v1-c2": { + "Ycdf": [ + 0.002744105424905158, + 0.03148467410475341, + 0.03213467410475341, + 0.0842087379743361, + 0.258602635622629, + 0.5140705971405044, + 0.6532164175573758, + 0.8062618721028304, + 0.9000099107307501, + 0.9777441054249052 + ], + "Yquantiles": { + "q1": 0.5902357830037936, + "q5": 1.3862357830037937, + "q9": 1.6999753073526045 + }, + "rawQuantiles": { + "q1": 1.55, + "q5": 2.346, + "q9": 2.659739524348811 + }, + "Ymean": 1.2365857830037936 + }, + "r1-v1-c3": { + "Ycdf": [ + 0.002744105424905158, + 0.02749410542490516, + 0.027744105424905158, + 0.04024410542490516, + 0.07222642169962064, + 0.24846116582035283, + 0.40674896111693865, + 0.708871708310869, + 0.9305260061463959, + 0.9777441054249052 + ], + "Yquantiles": { + "q1": 1.169183151424846, + "q5": 1.5424985567264213, + "q9": 1.6678673619511621 + }, + "rawQuantiles": { + "q1": 2.1289473684210525, + "q5": 2.5022627737226277, + "q9": 2.6276315789473683 + }, + "Ymean": 1.4392357830037936 + } + } + }, + "note": "真实第 1 轮三候选×三人(每人一份,等权)。开放预留取三候选九条折线(含尾巴)的最低首点和最高末点。合并分位数在改进量上用广义逆 inf{y: P(Y≤y) ≥ p} 求,再加回 b。" + }, + { + "name": "forward_merge_minimize", + "section": "6.3、6.1;约定 6–8", + "params": { + "lambda": 0.1, + "direction": "minimize", + "eps": 0.01 + }, + "input": { + "b": 0.4, + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.36, + "q5": 0.38, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.35, + "q5": 0.37, + "q9": 0.39 + } + } + ], + "c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.33, + "q5": 0.36, + "q9": 0.41 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.42 + } + } + ], + "c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.41, + "q5": 0.43, + "q9": 0.46 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.4, + "q5": 0.42, + "q9": 0.45 + } + } + ], + "c4": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.38, + "q5": 0.395, + "q9": 0.41 + } + } + ] + }, + "Yprobe": [ + -0.1, + -0.02, + -0.01, + 0.0, + 0.01, + 0.02, + 0.05 + ] + }, + "expected": { + "reserveY": [ + -0.18500000000000003, + 0.19499999999999998 + ], + "reserveRaw": [ + 0.20500000000000004, + 0.5850000000000001 + ], + "candidates": { + "c1": { + "Ycdf": [ + 0.022368421052631586, + 0.04342105263157899, + 0.06855263157894742, + 0.11618421052631579, + 0.2313157894736841, + 0.41394736842105234, + 0.8943421052631574 + ], + "Yquantiles": { + "q1": -0.0033977900552486233, + "q5": 0.024711815561959675, + "q9": 0.05118784530386745 + }, + "rawQuantiles": { + "q1": 0.3488121546961326, + "q5": 0.37528818443804035, + "q9": 0.4033977900552487 + }, + "Ymean": 0.023000000000000017 + }, + "c2": { + "Ycdf": [ + 0.022368421052631586, + 0.12442105263157885, + 0.19605263157894717, + 0.29468421052631555, + 0.393315789473684, + 0.5219473684210524, + 0.7968421052631577 + ], + "Yquantiles": { + "q1": -0.02916996047430826, + "q5": 0.01829378068739773, + "q9": 0.06647058823529413 + }, + "rawQuantiles": { + "q1": 0.3335294117647059, + "q5": 0.3817062193126023, + "q9": 0.4291699604743083 + }, + "Ymean": 0.01827500000000002 + }, + "c3": { + "Ycdf": [ + 0.022368421052631586, + 0.5834210526315784, + 0.7660526315789468, + 0.8811842105263157, + 0.9288157894736842, + 0.9539473684210527, + 0.9618421052631579 + ], + "Yquantiles": { + "q1": -0.06241935483870968, + "q5": -0.025465517241379278, + "q9": 0.003950276243093946 + }, + "rawQuantiles": { + "q1": 0.39604972375690606, + "q5": 0.4254655172413793, + "q9": 0.4624193548387097 + }, + "Ymean": -0.025149999999999985 + }, + "c4": { + "Ycdf": [ + 0.022368421052631586, + 0.05842105263157873, + 0.09105263157894727, + 0.25868421052631557, + 0.561315789473684, + 0.7739473684210523, + 0.9618421052631578 + ], + "Yquantiles": { + "q1": -0.009466248037676579, + "q5": 0.007973913043478267, + "q9": 0.030620525059665896 + }, + "rawQuantiles": { + "q1": 0.36937947494033413, + "q5": 0.39202608695652175, + "q9": 0.4094662480376766 + }, + "Ymean": 0.008825000000000013 + } + } + }, + "note": "越小越好:逐人换成 Y = b − 分数再合并;原值分位数 = b − Y 的分位数,一成九成对调。" + }, + { + "name": "ablation_cell_merge_r1_p1", + "section": "6.3、6.7 成分与权重;约定 6–8、24", + "params": { + "lambda": 0.1 + }, + "input": { + "explanations": [ + { + "id": "r1-e1-1", + "author": "researcher-1", + "range": { + "q1": 1.2, + "q5": 2, + "q9": 2.44 + } + }, + { + "id": "r1-e1-2", + "author": "researcher-1", + "range": { + "q1": 1.22, + "q5": 1.4, + "q9": 1.52 + } + }, + { + "id": "r1-e2-1", + "author": "researcher-2", + "range": { + "q1": 1.05, + "q5": 1.35, + "q9": 1.95 + } + }, + { + "id": "r1-e2-2", + "author": "researcher-2", + "range": { + "q1": 0.95, + "q5": 1.3, + "q9": 2.1 + } + }, + { + "id": "r1-e3-1", + "author": "researcher-3", + "range": { + "q1": 1.15, + "q5": 1.36, + "q9": 1.55 + } + }, + { + "id": "r1-e3-2", + "author": "researcher-3", + "range": { + "q1": 1.2, + "q5": 1.42, + "q9": 2.46 + } + } + ], + "x": [ + 0.5, + 1.0, + 1.3, + 1.5, + 2.0, + 2.5, + 3.0 + ] + }, + "expected": { + "reserve": [ + -1.1500000000000001, + 5.05 + ], + "weights": [ + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.1 + ], + "cdf": [ + 0.028487903225806455, + 0.08764819857561795, + 0.34381266582879494, + 0.584067846415045, + 0.7980179900744416, + 0.9227433453643131, + 0.9597239454094293 + ], + "quantiles": { + "q1": 1.031197878433162, + "q5": 1.4262310909924365, + "q9": 2.3794554469188443 + } + }, + "note": "真实第 1 轮消融 p1 格(关掉 p1)。三人各两条解释:每条 0.9/3/2 = 0.15,预留 0.1(权重最后一项)。开放预留只看这一格六条折线。分位数在原值上求。" + }, + { + "name": "quantile_flat_left_end", + "section": "约定 8", + "params": {}, + "input": { + "mixture": [ + { + "weight": 0.5, + "range": { + "q1": 0.99, + "q5": 1.0, + "q9": 1.01 + } + }, + { + "weight": 0.5, + "range": { + "q1": 1.99, + "q5": 2.0, + "q9": 2.01 + } + } + ], + "p": [ + 0.1, + 0.5, + 0.9 + ] + }, + "expected": { + "quantiles": [ + 0.9924999999999999, + 1.02, + 2.0075 + ] + }, + "note": "两条窄折线各占一半,中间 [1.02, 1.98] 把握走平在 0.5:一半取平段左端 1.02;一成落在第一条的[0.99, 1.0] 段里(第一条自己的 0.2 分位),九成落在第二条的 [2.0, 2.01] 段里。" + }, + { + "name": "pool_r1_real", + "section": "6.4 入池;约定 9", + "params": { + "eps": 0.01, + "lambda": 0.1, + "direction": "maximize" + }, + "input": { + "b": 0.9597642169962064, + "candidates": { + "r1-v1-c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.0, + "q5": 2.45, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.63 + } + } + ], + "r1-v1-c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 1.85, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 1.6, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 1.8, + "q5": 2.4, + "q9": 2.62 + } + } + ], + "r1-v1-c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.3, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.42, + "q5": 2.52, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.1, + "q5": 2.5, + "q9": 2.6 + } + } + ] + } + }, + "expected": { + "r1-v1-c1": { + "probBelowMinusEps": 0.02749410542490516, + "inPool": true + }, + "r1-v1-c2": { + "probBelowMinusEps": 0.03148467410475341, + "inPool": true + }, + "r1-v1-c3": { + "probBelowMinusEps": 0.02749410542490516, + "inPool": true + } + }, + "note": "真实第 1 轮:三个候选都远高于 b,都入池。" + }, + { + "name": "pool_minimize", + "section": "6.4 入池;约定 9", + "params": { + "eps": 0.01, + "lambda": 0.1, + "direction": "minimize" + }, + "input": { + "b": 0.4, + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.36, + "q5": 0.38, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.35, + "q5": 0.37, + "q9": 0.39 + } + } + ], + "c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.33, + "q5": 0.36, + "q9": 0.41 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.42 + } + } + ], + "c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.41, + "q5": 0.43, + "q9": 0.46 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.4, + "q5": 0.42, + "q9": 0.45 + } + } + ], + "c4": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.38, + "q5": 0.395, + "q9": 0.41 + } + } + ] + } + }, + "expected": { + "c1": { + "probBelowMinusEps": 0.06855263157894742, + "inPool": true + }, + "c2": { + "probBelowMinusEps": 0.19605263157894717, + "inPool": true + }, + "c3": { + "probBelowMinusEps": 0.7660526315789468, + "inPool": false + }, + "c4": { + "probBelowMinusEps": 0.09105263157894727, + "inPool": true + } + }, + "note": "越小越好:P(Y < −ε) = P(分数 > b+ε)。c3 的大部分把握在 0.41 以上,不入池。" + }, + { + "name": "pool_exactly_half", + "section": "6.4 入池;约定 9", + "params": { + "eps": 0.25, + "lambda": 0.1, + "direction": "maximize" + }, + "input": { + "b": 1.0, + "cases": [ + { + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.5, + "q5": 0.75, + "q9": 1.0 + } + } + ] + } + }, + { + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.5, + "q5": 0.7509765625, + "q9": 1.0 + } + } + ] + } + } + ] + }, + "expected": { + "cases": [ + { + "probBelowMinusEps": 0.5, + "inPool": false + }, + { + "probBelowMinusEps": 0.49864804991488326, + "inPool": true + } + ] + }, + "note": "第一例:Y 三个数 (−0.5,−0.25,0),预留 [−1.25,0.75],都以 −ε=−0.25 为中点,P(Y<−ε) = 0.9×0.5+0.1×0.5 = 0.5(浮点下也正好 0.5),不严格小于一半,不入池。第二例把 q5 往上挪 2⁻¹⁰,把握略小于 0.5,入池。ε 取 0.25 是为了全部是二进制精确数。" + }, + { + "name": "V_method_example_X_Y", + "section": "6.4 批内最好期望", + "params": {}, + "input": { + "Ymixtures": { + "X": [ + { + "weight": 0.5, + "range": { + "q1": -0.001, + "q5": 0.0, + "q9": 0.001 + } + }, + { + "weight": 0.5, + "range": { + "q1": 0.019, + "q5": 0.02, + "q9": 0.021 + } + } + ], + "Y": [ + { + "weight": 1, + "range": { + "q1": 0.009, + "q5": 0.01, + "q9": 0.011 + } + } + ] + } + }, + "expected": { + "V": { + "X": 0.010175, + "Y": 0.01, + "X,Y": 0.015 + } + }, + "note": "方法示例换成窄范围(三个数严格递增):X 一半在 0 附近、一半在 0.02 附近;Y 在 0.01 附近。输入是改进量上的混合(每个成分一个三个数)。V(X) ≈ 0.010175(0 附近那一半有一点低于 0、被截成 0),V(Y) ≈ 0.01,V(X,Y) ≈ 0.015。" + }, + { + "name": "batch_r1_real_k2", + "section": "6.4 选批、分数尺读数;约定 10–14", + "params": { + "eps": 0.01, + "lambda": 0.1, + "direction": "maximize", + "batchCap": 2 + }, + "input": { + "b": 0.9597642169962064, + "balance": 100, + "candidates": { + "r1-v1-c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.0, + "q5": 2.45, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.63 + } + } + ], + "r1-v1-c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 1.85, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 1.6, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 1.8, + "q5": 2.4, + "q9": 2.62 + } + } + ], + "r1-v1-c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.3, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.42, + "q5": 2.52, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.1, + "q5": 2.5, + "q9": 2.6 + } + } + ] + } + }, + "expected": { + "pool": [ + "r1-v1-c1", + "r1-v1-c2", + "r1-v1-c3" + ], + "k": 2, + "combos": [ + { + "combo": [ + "r1-v1-c1", + "r1-v1-c2" + ], + "V": 1.563610390779633 + }, + { + "combo": [ + "r1-v1-c1", + "r1-v1-c3" + ], + "V": 1.606671885433043 + }, + { + "combo": [ + "r1-v1-c2", + "r1-v1-c3" + ], + "V": 1.5843489675642095 + } + ], + "selected": [ + "r1-v1-c1", + "r1-v1-c3" + ], + "selectedV": 1.606671885433043, + "poolAllV": 1.6614292532574937, + "rulerReading": 0.0547573678244507 + }, + "note": "真实第 1 轮,每批上限 2:三选二,列出三个组合的 V。分数尺读数 = E[max(0, max_池 Y)] − V(S*)。" + }, + { + "name": "batch_minimize_out_of_pool", + "section": "6.4 选批、分数尺读数;约定 10–14", + "params": { + "eps": 0.01, + "lambda": 0.1, + "direction": "minimize", + "batchCap": 2 + }, + "input": { + "b": 0.4, + "balance": 100, + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.36, + "q5": 0.38, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.35, + "q5": 0.37, + "q9": 0.39 + } + } + ], + "c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.33, + "q5": 0.36, + "q9": 0.41 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.42 + } + } + ], + "c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.41, + "q5": 0.43, + "q9": 0.46 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.4, + "q5": 0.42, + "q9": 0.45 + } + } + ], + "c4": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.38, + "q5": 0.395, + "q9": 0.41 + } + } + ] + } + }, + "expected": { + "pool": [ + "c1", + "c2", + "c4" + ], + "k": 2, + "combos": [ + { + "combo": [ + "c1", + "c2" + ], + "V": 0.042511208189058194 + }, + { + "combo": [ + "c1", + "c4" + ], + "V": 0.03455455687326872 + }, + { + "combo": [ + "c2", + "c4" + ], + "V": 0.03528487545879503 + } + ], + "selected": [ + "c1", + "c2" + ], + "selectedV": 0.042511208189058194, + "poolAllV": 0.04700708419008786, + "rulerReading": 0.004495876001029669 + }, + "note": "越小越好,c3 在池外:只在池内 {c1,c2,c4} 里选二;分数尺读数只按池内候选算(约定 13),池外的 c3 不进。" + }, + { + "name": "batch_tie_by_id", + "section": "6.4 选批、分数尺读数;约定 10–14", + "params": { + "eps": 0.01, + "lambda": 0.1, + "direction": "maximize", + "batchCap": 1 + }, + "input": { + "b": 2.45, + "balance": 100, + "candidates": { + "c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.4, + "q5": 2.5, + "q9": 2.6 + } + } + ], + "c10": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.4, + "q5": 2.5, + "q9": 2.6 + } + } + ] + } + }, + "expected": { + "pool": [ + "c2", + "c10" + ], + "k": 1, + "combos": [ + { + "combo": [ + "c2" + ], + "V": 0.07115624999999991 + }, + { + "combo": [ + "c10" + ], + "V": 0.07115624999999991 + } + ], + "selected": [ + "c2" + ], + "selectedV": 0.07115624999999991, + "poolAllV": 0.11286726562499991, + "rulerReading": 0.041711015624999986 + }, + "note": "两个候选的范围完全相同,V 完全相等;按编号的自然序,c2 排在 c10 前面,选 c2。" + }, + { + "name": "batch_uniform_random_minimize", + "section": "第十一部分 均匀随机;约定 12–14", + "params": { + "eps": 0.01, + "lambda": 0.1, + "direction": "minimize", + "batchCap": 2 + }, + "input": { + "b": 0.4, + "balance": 100, + "seed": "b05ff00c97f5436a04ba877196edf1e1dc5a25ede85acd190ec55eb4647a45e3", + "seedFrom": { + "studyId": "uniform-batch", + "round": 1, + "version": 1 + }, + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.36, + "q5": 0.38, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.35, + "q5": 0.37, + "q9": 0.39 + } + } + ], + "c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.33, + "q5": 0.36, + "q9": 0.41 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.42 + } + } + ], + "c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.41, + "q5": 0.43, + "q9": 0.46 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.4, + "q5": 0.42, + "q9": 0.45 + } + } + ], + "c4": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.38, + "q5": 0.395, + "q9": 0.41 + } + } + ] + } + }, + "expected": { + "pool": [ + "c1", + "c2", + "c4" + ], + "k": 2, + "selected": [ + "c1", + "c3" + ], + "selectedV": 0.03193200753116345, + "poolAllV": 0.04700708419008786, + "rulerReading": 0.015075076658924408 + }, + "note": "越小越好四候选,c3 在池外。均匀随机从全部四个候选里抽两个(编号按 JS 字符串序排好再做部分 Fisher–Yates),这个种子抽中了池外的 c3。selectedV = V(S*);分数尺读数 = V(候选池 c1,c2,c4) − V(S*)。不列组合。" + }, + { + "name": "adoption_line_cases", + "section": "6.5;约定 15", + "params": { + "eps": 0.01 + }, + "input": { + "cases": [ + { + "B": 2.6, + "knownBest": null, + "direction": "maximize", + "note": "没有已知最好:ε" + }, + { + "B": 2.6, + "knownBest": 2.635983, + "direction": "maximize", + "note": "正常:min(0.01, 0.035983/4=0.00899575)" + }, + { + "B": 2.6359, + "knownBest": 2.635983, + "direction": "maximize", + "note": "下限:(K*−B)/4 < ε/100,取 ε/100 = 0.0001" + }, + { + "B": 2.64, + "knownBest": 2.635983, + "direction": "maximize", + "note": "B 超过 K*:取 ε/100" + }, + { + "B": 2.5, + "knownBest": 2.635983, + "direction": "maximize", + "note": "差距大:取上限 ε" + }, + { + "B": 0.4, + "knownBest": 0.38, + "direction": "minimize", + "note": "越小越好:(B−K*)/4 = 0.005" + }, + { + "B": 0.37, + "knownBest": 0.38, + "direction": "minimize", + "note": "越小越好、B 已比 K* 好:ε/100" + } + ] + }, + "expected": { + "lines": [ + 0.01, + 0.008995749999999969, + 0.0001, + 0.0001, + 0.01, + 0.0050000000000000044, + 0.0001 + ] + }, + "note": "逐例对应 input.cases。" + }, + { + "name": "adoption_decision_cases", + "section": "6.5;约定 16", + "params": { + "eps": 0.01 + }, + "input": { + "cases": [ + { + "B": 2.6, + "knownBest": 2.635983, + "direction": "maximize", + "results": { + "c1": 2.605, + "c2": 2.6185, + "c3": null + } + }, + { + "B": 2.6, + "knownBest": null, + "direction": "maximize", + "results": { + "c1": 2.61, + "c2": 2.605 + } + }, + { + "B": 2.6, + "knownBest": null, + "direction": "maximize", + "results": { + "c1": 2.6125, + "c2": 2.6125 + } + }, + { + "B": 0.4, + "knownBest": null, + "direction": "minimize", + "results": { + "c1": 0.385, + "c2": 0.395 + } + } + ] + }, + "expected": { + "cases": [ + { + "line": 0.008995749999999969, + "best": "c2", + "gain": 0.01849999999999996, + "adopt": true + }, + { + "line": 0.01, + "best": "c1", + "gain": 0.009999999999999787, + "adopt": false + }, + { + "line": 0.01, + "best": "c1", + "gain": 0.012499999999999734, + "adopt": true + }, + { + "line": 0.01, + "best": "c1", + "gain": 0.015000000000000013, + "adopt": true + } + ] + }, + "note": "改进量 > 采纳线(严格)才采纳;同分取编号小的。第二例是'正好高 ε':浮点输入 2.61、2.60 的精确差 = 0.009999999999999787(浮点减法 2.61-2.6 也是这个数),不大于 0.01,不采纳。" + } + ] +} diff --git a/tests/fixtures/rules/gen_ablation.py b/tests/fixtures/rules/gen_ablation.py new file mode 100644 index 0000000..957ffb1 --- /dev/null +++ b/tests/fixtures/rules/gen_ablation.py @@ -0,0 +1,494 @@ +"""生成消融结算、信息价值、选格、押的因子的预期值 JSON(ablation.json)。 + +用法(在仓库根目录): + python3 tests/fixtures/rules/gen_ablation.py +只用标准库;依赖同目录的 rules_ref.py。网格积分是纯 Python,整份要跑几分钟。重跑结果逐位相同。 + +对应 docs/方法.md: + 6.6 消融结算(撑几成、相对交互量、标签、确认/否定、分不开、五种结局) + 6.7 方向收益、成分与权重、信息价值、价值减成本、选格 + 6.8 押的因子 + 4.3 ⑰ 消融中发现(采纳线以 B = f(完整) 算) + 以及约定表第 17–30 条。 + +信息价值的两种算法: + - 全测:一维精确。本脚本不照约定 26 的"1−∏F 逐段积分",而是在有理数上用 Stieltjes 积分 + E_j[min_p X_p](越小越好时 max)精确求,再换成收益(收益是 x 的同一个仿射函数)。 + - 部分格:约定 27 的网格,是方法规定的确定近似,只能照规定做(浮点)。 + - 另给两个只用于核对的参考:全测时也用网格算一份(gridFullVOI),只测一格时给出精确值 + (exactK1VOI,相邻折点之间后验权重是常数,max(一次函数, 常数) 可以精确积分)。 + +生成的文件:ablation.json,格式: + { + "generator": "gen_ablation.py", + "tolerance": {...}, + "models": {模型名: {params, input}}, # 几个场景共用的消融输入 + "scenarios": [ + {"name", "section", "params", "input", "expected", "note"} + ] + } + - 模型 input:{"B": 基线分数, "full": 完整分数, "knownBest": K* 或 null, "direction", + "cells": ["p1", ...], "explanations": [{"id", "author", "cells": {"p1": {"q1","q5","q9"}, ...}}]} + params:{"eps", "lambda", "u", "r", "batchCap"} + - 场景 input 里的 "model" 字段指向 models 里的名字。 + - 成分顺序 = explanations 的顺序,最后一个是开放预留(名字 "reserve")。 + - 分数一律是原值;收益、VOI 是收益刻度(已乘 h、除以 G)。 +""" + +import json +import math +import os +import sys +import time +from fractions import Fraction as Fr + +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 +EPS, LAM, U, RR, KCAP = 0.01, 0.1, 0.5, 0.5, 2 +PARAMS = {"eps": EPS, "lambda": LAM, "u": U, "r": RR, "batchCap": KCAP} + + +def tri(t): + return {"q1": t[0], "q5": t[1], "q9": t[2]} + + +# ------------------------------------------------------------------ 输入模型 +MODELS = {} + +# A:参考项目 calc15 的输入(只用 input 部分)。p1 = 关掉网格起点,p2 = 关掉求解流程。 +C15 = [("e1", "researcher-1", (1.4, 1.75, 2.1), (1.9, 2.05, 2.18)), + ("e2", "researcher-1", (1.9, 2.3, 2.55), (1.8, 1.95, 2.1)), + ("e3", "researcher-1", (1.65, 2.05, 2.4), (1.8, 1.98, 2.15)), + ("e4", "researcher-2", (2.3, 2.45, 2.58), (1.85, 2.02, 2.18)), + ("e5", "researcher-2", (1.5, 1.95, 2.35), (1.9, 2.06, 2.2)), + ("e6", "researcher-2", (1.8, 2.05, 2.3), (1.85, 2, 2.15)), + ("e7", "researcher-3", (1.9, 2.15, 2.4), (1.95, 2.1, 2.2)), + ("e8", "researcher-3", (2.25, 2.45, 2.55), (1.7, 1.9, 2.05)), + ("e9", "researcher-3", (1.7, 1.95, 2.2), (1.85, 2, 2.15))] +MODELS["calc15"] = { + "B": 0.9597642169962064, "full": 2.6276819772628, "knownBest": 2.635, "direction": "maximize", + "cells": ["p1", "p2"], + "explanations": [{"id": i, "author": a, "cells": {"p1": tri(c1), "p2": tri(c2)}} for i, a, c1, c2 in C15]} +C15_VALID = {"p1": 2.5989997592071172, "p2": 1.7602769492805097} + +# B:15-real-1 第 1 轮真实消融(三格、三人各两条解释) +R1 = { + "p1": [(1.2, 2, 2.44), (1.22, 1.4, 1.52), (1.05, 1.35, 1.95), (0.95, 1.3, 2.1), (1.15, 1.36, 1.55), (1.2, 1.42, 2.46)], + "p2": [(1.49, 1.54, 1.58), (1.49, 1.54, 1.58), (1.44, 1.53, 1.59), (1.4, 1.52, 1.62), (1.49, 1.535, 1.58), (1.5, 1.53, 1.58)], + "p3": [(2.3, 2.44, 2.45), (1.2, 1.42, 1.55), (0.85, 2.4, 2.45), (0.6, 1.45, 2.45), (1.8, 2.42, 2.58), (1.9, 2.44, 2.57)], +} +R1_AUTH = ["researcher-1", "researcher-1", "researcher-2", "researcher-2", "researcher-3", "researcher-3"] +R1_IDS = ["r1-e1-1", "r1-e1-2", "r1-e2-1", "r1-e2-2", "r1-e3-1", "r1-e3-2"] +MODELS["r1real"] = { + "B": 0.9597642169962064, "full": 1.552176048093269, "knownBest": 2.635983, "direction": "maximize", + "cells": ["p1", "p2", "p3"], + "explanations": [{"id": R1_IDS[i], "author": R1_AUTH[i], "cells": {p: tri(R1[p][i]) for p in R1}} for i in range(6)]} +R1_VALID = {"p1": 2.250011787062861, "p2": 1.9535929459502979, "p3": 2.4389662413796342} + +# C:越小越好的合成例(两人、三条解释) +MODELS["minimize"] = { + "B": 0.40, "full": 0.35, "knownBest": 0.33, "direction": "minimize", + "cells": ["p1", "p2"], + "explanations": [ + {"id": "e1", "author": "ra", "cells": {"p1": tri((0.36, 0.37, 0.39)), "p2": tri((0.35, 0.36, 0.37))}}, + {"id": "e2", "author": "ra", "cells": {"p1": tri((0.35, 0.355, 0.36)), "p2": tri((0.36, 0.38, 0.40))}}, + {"id": "e3", "author": "rb", "cells": {"p1": tri((0.37, 0.38, 0.40)), "p2": tri((0.355, 0.365, 0.38))}}]} +MIN_VALID = {"p1": 0.372, "p2": 0.361} + +# D:四格合成例(calc15 的两格,再派生两格:p3 = p1 − 0.3 + 0.0013·n,p4 = p2 + 0.1 + 0.0007·n,n 是解释序号), +# p3、p4 各有 47 个不同折点、46 段 > m₄ = 44,全测网格走到"整格等宽切 m 份"(s = 0)的分支 +MODELS["fourCells"] = { + "B": 0.9597642169962064, "full": 2.6276819772628, "knownBest": 2.635, "direction": "maximize", + "cells": ["p1", "p2", "p3", "p4"], + "explanations": [{"id": i, "author": a, "cells": { + "p1": tri(c1), "p2": tri(c2), + "p3": tri(tuple(round(x - 0.3 + 0.0013 * n, 6) for x in c1)), + "p4": tri(tuple(round(x + 0.1 + 0.0007 * n, 6) for x in c2))}} for n, (i, a, c1, c2) in enumerate(C15)]} + +# E:收益不为正(p1 实测正好等于完整,p2 各成分都预计"关掉更好");F:同一组解释,实测落在全部成分之外 +EF_EXPL = [ + {"id": "e1", "author": "ra", "cells": {"p1": tri((1.9, 2.0, 2.1)), "p2": tri((2.1, 2.2, 2.3))}}, + {"id": "e2", "author": "rb", "cells": {"p1": tri((1.95, 2.0, 2.05)), "p2": tri((2.05, 2.15, 2.25))}}] +MODELS["gainNotPositive"] = {"B": 1.0, "full": 2.0, "knownBest": None, "direction": "maximize", + "cells": ["p1", "p2"], "explanations": EF_EXPL} + +# G:ĝ₀ < 0。多数解释预测关掉因子后分数反而比完整高,各因子事前期望收益都为负;成本按 max(0, ĝ₀) = 0 算。 +MODELS["negativeG0"] = {"B": 1.0, "full": 2.0, "knownBest": None, "direction": "maximize", + "cells": ["p1", "p2"], + "explanations": [ + {"id": "e1", "author": "ra", "cells": {"p1": tri((2.05, 2.15, 2.3)), "p2": tri((1.9, 2.1, 2.2))}}, + {"id": "e2", "author": "rb", "cells": {"p1": tri((2.0, 2.1, 2.2)), "p2": tri((2.02, 2.08, 2.15))}}, + {"id": "e3", "author": "rb", "cells": {"p1": tri((1.95, 2.05, 2.1)), "p2": tri((1.85, 2.0, 2.2))}}]} + + +def build(name): + m = MODELS[name] + expl = [{"id": e["id"], "author": e["author"], + "cells": {p: (c["q1"], c["q5"], c["q9"]) for p, c in e["cells"].items()}} for e in m["explanations"]] + return R.AblationModel(m["B"], m["full"], m["knownBest"], EPS, LAM, m["direction"], m["cells"], expl) + + +S = [] +REPORT = {} + + +def fl(d): + return {k: F(v) for k, v in d.items()} + + +# ------------------------------------------------------------------ 6. 撑几成、i、标签 +def settle_case(name, B, full, direction, cells, note, section="6.6;约定 17–19"): + offs = {c["factor"]: (c["value"] if c["kind"] == "valid" else None) for c in cells} + try: + G, sup = R.supports(B, full, offs, direction) + except ValueError: + S.append({"name": name, "section": section, "params": {}, + "input": {"B": B, "full": full, "direction": direction, "cells": cells}, + "expected": {"error": "totalGainNotPositive"}, "note": note}) + return + lab, i = R.label(sup) + S.append({"name": name, "section": section, "params": {}, + "input": {"B": B, "full": full, "direction": direction, "cells": cells}, + "expected": {"G": F(G), "supports": {p: (None if v is None else F(v)) for p, v in sup.items()}, + "interaction": None if i is None else F(i), "label": lab}, + "note": note}) + + +V = "valid" +settle_case("label_method_example_independent", 1.00, 1.50, "maximize", + [{"factor": "p1", "kind": V, "value": 1.45}, {"factor": "p2", "kind": V, "value": 1.10}], + "方法 6.6 示例:撑 0.10、0.80,i = −0.10,各管各(independent)。") +settle_case("label_calc15_substitute", 0.9597642169962064, 2.6276819772628, "maximize", + [{"factor": "p1", "kind": V, "value": C15_VALID["p1"]}, {"factor": "p2", "kind": V, "value": C15_VALID["p2"]}], + "calc15 实测:撑 0.017、0.518,i ≈ −0.465 < −0.2,替代(substitute)。") +settle_case("label_r1_negative", 0.9597642169962064, 1.552176048093269, "maximize", + [{"factor": p, "kind": V, "value": v} for p, v in R1_VALID.items()], + "真实第 1 轮:三格关掉后分数都比完整高,撑全 < 0,negative(不贴标签,i 不给)。") +settle_case("label_incomplete_with_other_outcomes", 1.00, 1.50, "maximize", + [{"factor": "p1", "kind": V, "value": 1.45}, {"factor": "p2", "kind": "insufficient"}, + {"factor": "p3", "kind": "joint", "value": 1.2}, {"factor": "p4", "kind": "untested"}, + {"factor": "p5", "kind": "cannotRun"}], + "只有有效分数进标签:p2–p5 都不是 valid(joint 有分数也不算),有因子没有有效格 → incomplete。") +settle_case("label_negative_beats_incomplete", 1.00, 1.50, "maximize", + [{"factor": "p1", "kind": V, "value": 1.6}, {"factor": "p2", "kind": "insufficient"}], + "表从上往下判:任一有效格撑 < 0 先命中 negative,即使另一格没有有效分数。") +settle_case("label_indispensable", 1.0, 2.0, "maximize", + [{"factor": "p1", "kind": V, "value": 1.05}, {"factor": "p2", "kind": V, "value": 1.0}], + "撑 0.95、1.0,都 > 0.9:缺一不可(indispensable),虽然 i = 0.95 > 0.2 也先命中这一条。") +settle_case("label_complementary", 1.0, 2.0, "maximize", + [{"factor": "p1", "kind": V, "value": 1.3}, {"factor": "p2", "kind": V, "value": 1.4}], + "撑 0.7、0.6,i = 0.3 > 0.2:互补(complementary)。") +settle_case("label_boundary_exact_0.9", 1.0, 2.0, "maximize", + [{"factor": "p1", "kind": V, "value": 1.0}, {"factor": "p2", "kind": V, "value": 1.125}], + "撑 1.0、0.875;0.875 不 > 0.9,不是缺一不可;i = 0.875 > 0.2 → 互补。用二进制精确数避免舍入。") +settle_case("label_minimize_substitute", 0.40, 0.35, "minimize", + [{"factor": "p1", "kind": V, "value": MIN_VALID["p1"]}, {"factor": "p2", "kind": V, "value": MIN_VALID["p2"]}], + "越小越好:G = 0.40−0.35;撑(p) = (x − 0.35)/G = 0.44、0.22;i = −0.34 → 替代。") +settle_case("label_G_not_positive", 1.50, 1.50, "maximize", + [{"factor": "p1", "kind": V, "value": 1.45}], "G = 0 时报错。") +settle_case("label_G_negative_minimize", 0.40, 0.41, "minimize", + [{"factor": "p1", "kind": V, "value": 0.405}], "越小越好时完整比基线还大,G < 0,报错。") + + +# ------------------------------------------------------------------ 确认/否定、分不开、消融中发现 +def confirm_case(name, mname, valid, note): + m = MODELS[mname] + cells = sorted(valid, key=R.factor_key) + seqs = {} + table = {} + for e in m["explanations"]: + row = {} + for p in cells: + c = e["cells"][p] + row[p] = "confirmed" if c["q1"] <= valid[p] <= c["q9"] else "refuted" + table[e["id"]] = row + seqs.setdefault(tuple(row[p] for p in cells), []).append(e["id"]) + groups = [ids for ids in seqs.values() if len(ids) >= 2] + S.append({"name": name, "section": "6.6 确认与否定、现有测法分不开;约定 21–22", + "params": {}, "input": {"model": mname, "valid": valid}, + "expected": {"verdicts": table, "inseparableGroups": groups}, + "note": note}) + + +confirm_case("confirm_r1_real", "r1real", R1_VALID, + "落在 [q1,q9](含两端)记确认。分不开:在全部 valid 格上确认/否定序列相同的两条以上解释为一组(组内按解释顺序)。") +confirm_case("confirm_calc15", "calc15", C15_VALID, "calc15 两格都有效。") +confirm_case("confirm_endpoint_inclusive", "gainNotPositive", {"p1": 2.1, "p2": 2.05}, + "端点:e1 的 p1 上沿正好 2.1、e2 的 p2 下沿正好 2.05,都算确认;e2 在 p1(上沿 2.05)否定,e1 在 p2(下沿 2.1)否定。") + + +def discovery(mname, cells, note): + m = MODELS[mname] + line = R.adoption_line(EPS, m["full"], m["knownBest"], m["direction"]) + over = [] + for c in cells: + if c["kind"] == "valid" or (c["kind"] == "joint" and c.get("value") is not None): + g = R.improve(c["value"], m["full"], m["direction"]) + if g > line: + over.append(c) + best = None + for c in sorted(over, key=lambda c: R.factor_key(c["factor"])): + if best is None or R.improve(c["value"], best["value"], m["direction"]) > 0: + best = c + S.append({"name": "discovery_" + mname, "section": "4.3 ⑰、6.5;约定 23", + "params": {"eps": EPS}, "input": {"model": mname, "cells": cells}, + "expected": {"line": F(line), "overLine": [c["factor"] for c in over], + "newBaseline": None if best is None else best["factor"]}, + "note": note}) + + +discovery("r1real", [{"factor": p, "kind": "valid", "value": v} for p, v in R1_VALID.items()], + "B 取 f(完整)=1.5522,采纳线 min(0.01,(2.635983−1.5522)/4)=0.01。三格都过线,取分数最好的 p3。") +discovery("calc15", [{"factor": "p1", "kind": "valid", "value": C15_VALID["p1"]}, + {"factor": "p2", "kind": "joint", "value": 2.64}], + "采纳线 = (2.635−2.62768)/4 ≈ 0.00183;p1 低于完整不过线;p2 是构造的一起关格、分数 2.64(不是真实数据),参与并过线。") +discovery("gainNotPositive", [{"factor": "p1", "kind": "valid", "value": 2.5}, {"factor": "p2", "kind": "valid", "value": 2.5}], + "无已知最好,采纳线 ε;两格同分 2.5 都过线,取因子编号小的 p1。") + + +# ------------------------------------------------------------------ 7. 成分、h、先验、ĝ₀、VOI +def model_basics(name, mod): + return { + "G": F(mod.G), "h": F(mod.h), + "componentNames": mod.names, + "weights": [F(w) for w in mod.weights], + "reserve": {p: [F(lo), F(hi)] for p, (lo, hi) in mod.reserve.items()}, + "componentExpectedPayoff": {n: fl(E) for n, E in zip(mod.names, mod.Epay)}, + "priorExpectedPayoff": fl(mod.prior_payoffs()), + "g0": F(mod.g0()), + } + + +def subsets(cells): + from itertools import combinations + out = [] + for n in range(1, len(cells) + 1): + out += [list(c) for c in combinations(cells, n)] + return out + + +VOI_CACHE = {} + + +def voi_of(name, mod, S_): + key = (name, tuple(S_)) + if key in VOI_CACHE: + return VOI_CACHE[key] + if len(S_) == len(mod.cells): + v = (F(mod.voi_full_exact()), {"method": "exact1D"}) + else: + t = time.time() + g, info = mod.voi_grid(S_) + info["method"] = "grid" + print(f" grid {name} {S_}: {time.time() - t:.1f}s", flush=True) + v = (g, info) + VOI_CACHE[key] = v + return v + + +def voi_scenario(name, extra_grid_full=True, partial=None): + mod = build(name) + exp = model_basics(name, mod) + full_exact = mod.voi_full_exact() + exp["voiFullExact"] = F(full_exact) + VOI_CACHE[(name, tuple(mod.cells))] = (F(full_exact), {"method": "exact1D"}) + parts = [] + for S_ in (partial if partial is not None else subsets(mod.cells)[:-1]): + v, info = voi_of(name, mod, S_) + row = {"cells": S_, "voiGrid": v, "grid": info} + if len(S_) == 1: + ex = F(mod.voi_partial_exact_k1(S_[0])) + row["exactK1VOI"] = ex + row["gridMinusExact"] = v - ex + parts.append(row) + exp["partial"] = parts + if extra_grid_full: + t = time.time() + g, info = mod.voi_grid(mod.cells) + print(f" grid-full {name}: {time.time() - t:.1f}s", flush=True) + exp["gridFullVOI"] = g + exp["gridFullInfo"] = info + exp["gridFullMinusExact"] = g - F(full_exact) + REPORT[name] = {"exact": F(full_exact), "grid": g, "diff": g - F(full_exact), "rel": (g - F(full_exact)) / F(full_exact)} + S.append({"name": "voi_" + name, "section": "6.7 方向收益、成分与权重、信息价值;约定 24–27", + "params": PARAMS, "input": {"model": name}, "expected": exp, + "note": "weights/componentExpectedPayoff 的成分顺序 = explanations 顺序 + reserve。" + "voiFullExact:全部格都测,一维精确(有理数 Stieltjes 积分)。partial[].voiGrid:照约定 27 的网格。" + "partial[].exactK1VOI:只测一格时的精确值,只用来看网格误差,实现不需要算。" + "gridFullVOI:全测时也用网格算一份,与 voiFullExact 对照(gridFullMinusExact)。" + "grid.cells 给出每格折点数、段数、s、小段数:网格折点在浮点上按 q1-(q5-q1)、q9+(q9-q5)、" + "L-W/2、U+W/2 计算后去重,实现应先核对这几个数一致(差一个 ulp 就会多出一段宽约 2e-16 的段," + "例如 calc15 p1 里 1.0499999999999998 与 1.05 并存)。"}) + return mod + + +print("VOI ...", flush=True) +mods = {} +mods["calc15"] = voi_scenario("calc15") +mods["r1real"] = voi_scenario("r1real") +mods["minimize"] = voi_scenario("minimize") +mods["fourCells"] = voi_scenario("fourCells", partial=[["p1", "p2", "p3"]]) +mods["negativeG0"] = voi_scenario("negativeG0") +assert mods["negativeG0"].g0() < 0, "negativeG0 的 ĝ₀ 应为负" + + +# ------------------------------------------------------------------ 8. 价值、成本、净值、选格 +def net_table(name, mod, bal): + g0 = mod.g0() + + def net(combo): + combo = list(combo) + v, _ = voi_of(name, mod, combo) + Rh, mp = R.m_prime(bal, len(combo), KCAP, RR) + value = U * F(mp) * v + c = F(R.cost(g0, len(combo), KCAP)) + return value - c + return net + + +def selection_scenario(name, balances, note): + mod = mods[name] + out = [] + for bal in balances: + net = net_table(name, mod, bal) + sel, bestnet, table, uns = R.select_cells(mod.cells, bal, net) + rows = [] + for combo, n in table: + v, _ = voi_of(name, mod, list(combo)) + Rh, mp = R.m_prime(bal, len(combo), KCAP, RR) + rows.append({"combo": list(combo), "voi": v, "Rhat": Rh, "mPrime": F(mp), + "value": U * F(mp) * v, "cost": F(R.cost(mod.g0(), len(combo), KCAP)), "net": n}) + # 全测规则 + lim = max(0, bal - 1) + full_sel = mod.cells[:lim] + out.append({"balance": bal, "combos": rows, "selected": sel, "selectedNet": bestnet if sel else 0.0, + "unselected": uns, + "fullRule": {"selected": full_sel, + "insufficientBudget": [c for c in mod.cells if c not in full_sel]}}) + S.append({"name": "select_" + name, "section": "6.7 价值减成本、选格;约定 28–29、文末", + "params": PARAMS, "input": {"model": name, "balances": balances}, + "expected": {"g0": F(mod.g0()), "byBalance": out}, "note": note}) + + +print("selection ...", flush=True) +selection_scenario("calc15", [0, 1, 2, 3, 10], + "余额 0、1 不做消融,各格 reason=insufficientBudget、bestCombo=null;余额 2 只能选单格;" + "余额 3 起可以两格全测(VOI 用一维精确值),部分格用网格。" + "R̂ = ⌈(余额−|S|)/每批上限⌉,m′ = Σ_{j 1e-12,入选;{p1,p2} = 3.0e-12 只比当前最好多 0.5e-12(不超过 1e-12),视为相同,保留格数少的 p1。") +pure_select("select_pure_tie_chain", ["p1", "p2", "p3"], 4, + {"p1": 0.01, "p2": 0.01 + 0.8e-12, "p3": 0.01 + 1.6e-12, + "p1,p2": 0.0, "p1,p3": 0.0, "p2,p3": 0.0, "p1,p2,p3": -1.0}, + "最大净值 M = p3 = 0.01+1.6e-12;净值 ≥ M − 1e-12 的 p2、p3 视为相同,同格数按编号取小的 p2。" + "(预期值曾按“逐个扫描、比当前最好多 1e-12 才替换”的接力写法选 p3;审阅指出方法 6.7 是先求最大净值、" + "再在差不到 1e-12 的组合里按格数和编号挑,改后选 p2。)") +pure_select("select_pure_filter_before_max", ["p1", "p2"], 3, + {"p1": 0.6e-12, "p2": -1.0, "p1,p2": 1.5e-12}, + "第二轮审阅的反例:先只留净值 > 1e-12 的组合(只剩 {p1,p2}),再在其中求最大值和容差窗口,选 {p1,p2}。" + "若先在全部组合里求 M = 1.5e-12、窗口 ≥ 0.5e-12,{p1}(0.6e-12)会因格数少被选中,而它的净值并不 > 1e-12。" + "p1、p2 都被选中,没有没测的格。") +pure_select("select_pure_tolerance_relay", ["p1", "p2"], 3, + {"p1": 1.0, "p2": 1.0 + 0.9e-12, "p1,p2": 1.0 + 1.8e-12}, + "审阅给的接力反例:M = {p1,p2} = 1+1.8e-12,窗口 ≥ 1+0.8e-12 内有 {p2} 和 {p1,p2},先取格数少的 {p2}。" + "接力写法会因 {p2} 比 {p1} 只多 0.9e-12 不替换、{p1,p2} 比 {p1} 多 1.8e-12 替换而选 {p1,p2}。" + "p1 没选中,含它的组合 {p1}、{p1,p2} 里同样按这条挑:M = 1+1.8e-12,窗口内只有 {p1,p2}。") +pure_select("select_pure_budget_reason", ["p1", "p2", "p3"], 2, + {"p1": 0.002, "p2": 0.003, "p3": -0.001}, + "余额 2 只能测 1 格:选 p2;p1、p3 再加进来就超过余额−1,reason = insufficientBudget。") + + +# ------------------------------------------------------------------ 9. 押的因子 +def bet_case(name, mname, outcomes, note): + mod = mods.get(mname) or build(mname) + res = R.bet(mod, outcomes) + S.append({"name": name, "section": "6.8;约定 30", + "params": PARAMS, "input": {"model": mname, "outcomes": outcomes}, + "expected": res, "note": note}) + + +bet_case("bet_calc15_all_valid", "calc15", + {"p1": {"kind": "valid", "value": C15_VALID["p1"]}, "p2": {"kind": "valid", "value": C15_VALID["p2"]}}, + "两格都有效:收益用实测换算,押 p2。weights 是按两格密度更新后的成分权重(只用于没有有效分数的格,这里只是记录)。") +bet_case("bet_calc15_p2_insufficient", "calc15", + {"p1": {"kind": "valid", "value": C15_VALID["p1"]}, "p2": {"kind": "insufficient"}}, + "p2 证据不足:权重 ∝ 起始权重 × 各成分在 p1 实测处的密度,再用新权重估 p2 的期望收益。") +bet_case("bet_calc15_joint_ignored", "calc15", + {"p1": {"kind": "valid", "value": C15_VALID["p1"]}, "p2": {"kind": "joint", "value": 1.76}}, + "p2 是一起关,它的分数不参与权重更新,也不当实测用;结果应与 p2 证据不足那一例完全相同。") +bet_case("bet_r1_negative_support", "r1real", + {p: {"kind": "valid", "value": v} for p, v in R1_VALID.items()}, + "三格撑都 < 0:不押,negativeFactors 记“关掉反而更好”的分数(x − 完整,按方向,正数)。挑出的因子与预测收益照记。" + "另外:p2 实测 1.9536 落在 p2 所有成分(含预留 [1.06,1.94])之外,各成分在三格密度连乘全为 0," + "权重无从更新、保持起始权重(weightsUpdated=false)——真实数据里就出现了这种情况。") +bet_case("bet_gain_not_positive", "gainNotPositive", + {"p1": {"kind": "valid", "value": 2.0}, "p2": {"kind": "untested"}}, + "p1 实测正好等于完整,收益 0(撑 0,不是负);p2 各成分都预计关掉更好,估计收益为负。最大收益 0 ≤ 0,不押。") +bet_case("bet_weights_not_updatable", "gainNotPositive", + {"p1": {"kind": "valid", "value": -5.0}, "p2": {"kind": "untested"}}, + "p1 实测 −5 落在所有成分(含预留)之外,密度全为 0,权重无从更新:保持起始权重,weightsUpdated=false。") +bet_case("bet_minimize", "minimize", + {"p1": {"kind": "valid", "value": MIN_VALID["p1"]}, "p2": {"kind": "cannotRun"}}, + "越小越好:收益 = h·(x − 完整)/G,h = max(完整 − K*, ε/100) = 0.02。") + +doc = {"generator": "gen_ablation.py", + "tolerance": { + "exact": "有理数精确算出的量(G、撑、i、h、权重、成分期望收益、ĝ₀、voiFullExact、exactK1VOI、成本、m′):绝对误差 1e-12", + "grid": "网格 VOI 及由它得到的价值、净值:|差| ≤ 1e-12 + 1e-9·|值|(逐项浮点累加顺序不同,约 1e-13 量级)", + "discrete": "标签、选中组合、原因、押的因子、状态:必须完全相同", + "gridVsExact": "同一输入全测时 gridFullVOI 与 voiFullExact 的对照(两种算法之间,不是实现与本脚本之间):" + "k ≤ 3 时 |差| ≤ 5e-4·|精确值|,k = 4 时 ≤ 3e-3·|精确值|;本脚本实测相对差 7.3e-6(calc15,k=2)、" + "4.7e-5(minimize,k=2)、1.05e-4(r1real,k=3,s=5–7)、1.0e-3(fourCells,k=4,s=1 和 0)。" + "理由:网格用小段中点代表整段,误差随小段宽度平方缩小、随格数增多(m 变小)变大;" + "s ≥ 1 时各成分在小段内是均匀分布,max 是凸函数,按 Jensen 不等式网格值不会超过精确值," + "所以还可以断言 gridFullVOI ≤ voiFullExact + 1e-12(fourCells 有 s = 0 的格,不适用)。"}, + "models": {k: {"params": PARAMS, "input": v} for k, v in MODELS.items()}, + "scenarios": S} +with open(os.path.join(HERE, "ablation.json"), "w", encoding="utf-8") as f: + json.dump(doc, f, ensure_ascii=False, indent=1) + f.write("\n") +print(json.dumps(REPORT, indent=1)) diff --git a/tests/fixtures/rules/gen_forward_info.py b/tests/fixtures/rules/gen_forward_info.py new file mode 100644 index 0000000..f6053aa --- /dev/null +++ b/tests/fixtures/rules/gen_forward_info.py @@ -0,0 +1,653 @@ +"""生成正向、信息账、入口/余额/种子三份预期值 JSON。 + +用法(在仓库根目录): + python3 tests/fixtures/rules/gen_forward_info.py +只用标准库;依赖同目录的 rules_ref.py。几秒内跑完。重跑结果逐位相同。 + +对应 docs/方法.md: + 6.1 方向、6.2 三个数→折线、6.3 开放预留与合并、6.4 入池/选批/分数尺读数、6.5 采纳线 + 6.9 比特/惊讶/分歧量/落点、6.10 第 4 条战绩、4.2 入口分配、第七部分余额、 + 以及约定表第 1–16、31–40 条。 + +生成的文件(都在本目录): + forward.json —— 折线、换算、合并、合并分位数、入池、V(S)、选批(批内最好期望、均匀随机)、分数尺读数、采纳线、采纳判断 + info.json —— 世界比特、惊讶、分歧量、落点四段、覆盖率、战绩 + misc.json —— 入口分配、余额、种子与随机抽样 + +JSON 格式(三份相同): + { + "generator": "gen_forward_info.py", + "tolerance": {"abs": 1e-12, "note": "..."}, + "scenarios": [ + {"name": 场景名, "section": "方法.md 小节 / 约定 N", + "params": {eps, lambda, direction, ...}, + "input": {...}, "expected": {...}, "note": "说明"} + ] + } + - 分数一律是原值;"Y" 前缀的字段是改进量(越大越好)刻度。 + - 三个数写成 {"q1","q5","q9"},严格递增;点列写成 [[x, y], ...]。 + - 正向候选的各人范围写成 [{"researcher", "range"}]。 + - cdf = 低于 x 的把握 F(x)(折线连续,低于与不高于相同)。 + - 预期数值由有理数精确算出后转成浮点;与实现比较时用绝对误差 tolerance.abs, + 布尔、编号、标签、状态必须完全相同。 +""" + +import hashlib +import json +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 + + +# 种子与随机(约定 39–40):选批的均匀随机场景和 misc.json 都用。 +def canonical(obj): + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def seed_of(study, rnd, ver): + s = canonical({"studyId": study, "round": rnd, "version": ver}) + return s, hashlib.sha256(s.encode("utf-8")).hexdigest() + + +class Rng: + def __init__(self, seed): + self.seed, self.counter = seed, 0 + + def uniform_int(self, n): + limit = 2 ** 48 - (2 ** 48 % n) + while True: + h = hashlib.sha256(f"{self.seed}:{self.counter}".encode("utf-8")).digest() + self.counter += 1 + v = int.from_bytes(h[:6], "big") + if v < limit: + return v % n + + +def string_order(s): + """JS 默认字符串序。只用来核对自然序的场景确实分得出两种排序。""" + return s.encode("utf-16-be") + + +def sample(seed, ids, size, key=R.id_key): + """约定 40 的抽样。编号(候选、因子)一律按自然序排(c2 < c10、p2 < p10)。""" + a = sorted(ids, key=key) + rng = Rng(seed) + n = len(a) + size = min(size, n) + for i in range(size): + j = i + rng.uniform_int(n - i) + a[i], a[j] = a[j], a[i] + return sorted(a[:size], key=key) + + +MANY_C = [f"c{i}" for i in range(1, 13)] +MANY_P = [f"p{i}" for i in range(1, 13)] + + +def pts_json(pts): + return [[F(x), F(y)] for x, y in pts] + + +def tri(t): + return {"q1": t[0], "q5": t[1], "q9": t[2]} + + +def ranges_json(ts): + """正向一个候选的各人范围:[{researcher, range}],研究者按位置编号 researcher-1、researcher-2……""" + return [{"researcher": f"researcher-{i + 1}", "range": tri(t)} for i, t in enumerate(ts)] + + +def write(name, scenarios, note, extra=None): + """tolerance.abs 是默认的绝对误差;个别量另有容差时放在 extra 里,note 只解释,不另写数值。""" + doc = {"generator": "gen_forward_info.py", + "tolerance": {"abs": 1e-12, **(extra or {}), "note": note}, + "scenarios": scenarios} + with open(os.path.join(HERE, name), "w", encoding="utf-8") as f: + json.dump(doc, f, ensure_ascii=False, indent=1) + f.write("\n") + + +# =========================================================== forward.json +S = [] + +# ---- 1. 折线 CDF +def cdf_case(name, t, xs, note, dens_xs=None): + p = R.polyline(*t) + d = R.single(p) + S.append({ + "name": name, "section": "6.2;约定 1–4", + "params": {}, + "input": {"range": tri(t), "x": xs, "densityAt": dens_xs or xs}, + "expected": { + "points": pts_json(p), + "cdf": [F(d.left(x)) for x in xs], + "density": [F(R.pts_density(p, x)) for x in (dens_xs or xs)], + "mean": F(R.mean(d)), + }, + "note": note}) + + +cdf_case("cdf_method_example_2.44_2.52_2.62", (2.44, 2.52, 2.62), + [2.30, 2.36, 2.40, 2.44, 2.48, 2.52, 2.57, 2.62, 2.67, 2.72, 2.80], + "方法 6.2 示例:低于 2.57 的把握 0.70;尾巴 2.36–2.44、2.62–2.72。") +cdf_case("cdf_asymmetric_narrow", (1.2999, 1.30, 1.3005), [1.2997, 1.2998, 1.29995, 1.3, 1.3002, 1.3005, 1.30075, 1.301], + "方法第五部分的窄范围写法(不对称):低端尾巴 1.2998–1.2999,高端尾巴 1.3005–1.3010。折点上密度取右边那段。") + +# ---- 越小越好的换算 +b_min = 0.40 +t_min = (0.30, 0.35, 0.45) +p_raw = R.polyline(*t_min) +p_y = R.improvement_pts(p_raw, b_min, "minimize") +p_y2 = R.polyline(*(R.fr(b_min) - R.fr(t_min[2]), R.fr(b_min) - R.fr(t_min[1]), R.fr(b_min) - R.fr(t_min[0]))) +assert p_y == p_y2 +ys = [-0.2, -0.05, 0.0, 0.05, 0.08, 0.10, 0.25] +dy = R.single(p_y) +S.append({ + "name": "minimize_to_improvement", "section": "6.1;约定 5", + "params": {"direction": "minimize"}, + "input": {"b": b_min, "range": tri(t_min), "y": ys}, + "expected": { + "Yrange": {"q1": F(p_y[1][0]), "q5": F(p_y[2][0]), "q9": F(p_y[3][0])}, + "Ypoints": pts_json(p_y), + "Ycdf": [F(dy.left(y)) for y in ys], + }, + "note": "Y = b − 分数,一成九成对调:Y 的三个数 = (b−q9, b−q5, b−q1) = (−0.05, 0.05, 0.10);" + "低端尾巴宽 = 原 q9−q5 = 0.10,高端尾巴宽 = 原 q5−q1 = 0.05。"}) + +# ---- 2. 合并(正向、消融格) +EPS = 0.01 +LAM = 0.1 +B_R1 = 0.9597642169962064 +R1_FWD = { # 15-real-1 第 1 轮真实的三人预测(只当输入) + "r1-v1-c1": [(2.0, 2.45, 2.6), (2.2, 2.5, 2.62), (2.2, 2.5, 2.63)], + "r1-v1-c2": [(1.85, 2.35, 2.6), (1.6, 2.35, 2.6), (1.8, 2.4, 2.62)], + "r1-v1-c3": [(2.3, 2.5, 2.62), (2.42, 2.52, 2.6), (2.1, 2.5, 2.6)], +} +R1_RESEARCHERS = ["researcher-1", "researcher-2", "researcher-3"] + +# 越小越好的合成正向(b = 0.40,分数越低越好),两位研究者,c3 预期落在池外 +B_MIN = 0.40 +MIN_FWD = { + "c1": [(0.36, 0.38, 0.40), (0.35, 0.37, 0.39)], + "c2": [(0.33, 0.36, 0.41), (0.37, 0.39, 0.42)], + "c3": [(0.41, 0.43, 0.46), (0.40, 0.42, 0.45)], + "c4": [(0.37, 0.39, 0.40), (0.38, 0.395, 0.41)], +} + +PROBE_Y = [-1.0, -0.01, 0.0, 0.5, 1.0, 1.4, 1.5, 1.6, 1.7, 2.0] + + +def forward_merge_case(name, cands, b, direction, probe, note): + dys, draws, (ylo, yhi), (rlo, rhi) = R.forward_dists(cands, b, direction, LAM) + exp = {"reserveY": [F(ylo), F(yhi)], "reserveRaw": [F(rlo), F(rhi)], "candidates": {}} + for c, d in dys.items(): + yq = [R.quantile(d, p) for p in (Fr(1, 10), Fr(1, 2), Fr(9, 10))] + if direction == "maximize": + rawq = [R.fr(b) + q for q in yq] + else: + rawq = [R.fr(b) - q for q in reversed(yq)] + exp["candidates"][c] = { + "Ycdf": [F(d.left(y)) for y in probe], + "Yquantiles": {"q1": F(yq[0]), "q5": F(yq[1]), "q9": F(yq[2])}, + "rawQuantiles": {"q1": F(rawq[0]), "q5": F(rawq[1]), "q9": F(rawq[2])}, + "Ymean": F(R.mean(d)), + } + S.append({ + "name": name, "section": "6.3、6.1;约定 6–8", + "params": {"lambda": LAM, "direction": direction, "eps": EPS}, + "input": {"b": b, "candidates": {c: ranges_json(ts) for c, ts in cands.items()}, + "Yprobe": probe}, + "expected": exp, "note": note}) + return dys + + +dys_r1 = forward_merge_case( + "forward_merge_r1_real", R1_FWD, B_R1, "maximize", PROBE_Y, + "真实第 1 轮三候选×三人(每人一份,等权)。开放预留取三候选九条折线(含尾巴)的最低首点和最高末点。" + "合并分位数在改进量上用广义逆 inf{y: P(Y≤y) ≥ p} 求,再加回 b。") +dys_min = forward_merge_case( + "forward_merge_minimize", MIN_FWD, B_MIN, "minimize", [-0.1, -0.02, -0.01, 0.0, 0.01, 0.02, 0.05], + "越小越好:逐人换成 Y = b − 分数再合并;原值分位数 = b − Y 的分位数,一成九成对调。") + +# 消融一格的合并:真实第 1 轮消融 p1 格 +R1_ABL_AUTH = ["researcher-1", "researcher-1", "researcher-2", "researcher-2", "researcher-3", "researcher-3"] +R1_ABL_IDS = ["r1-e1-1", "r1-e1-2", "r1-e2-1", "r1-e2-2", "r1-e3-1", "r1-e3-2"] +R1_ABL = { + "p1": [(1.2, 2, 2.44), (1.22, 1.4, 1.52), (1.05, 1.35, 1.95), (0.95, 1.3, 2.1), (1.15, 1.36, 1.55), (1.2, 1.42, 2.46)], + "p2": [(1.49, 1.54, 1.58), (1.49, 1.54, 1.58), (1.44, 1.53, 1.59), (1.4, 1.52, 1.62), (1.49, 1.535, 1.58), (1.5, 1.53, 1.58)], + "p3": [(2.3, 2.44, 2.45), (1.2, 1.42, 1.55), (0.85, 2.4, 2.45), (0.6, 1.45, 2.45), (1.8, 2.42, 2.58), (1.9, 2.44, 2.57)], +} +cd, (clo, chi) = R.cell_dist(R1_ABL["p1"], R1_ABL_AUTH, LAM) +probe = [0.5, 1.0, 1.3, 1.5, 2.0, 2.5, 3.0] +S.append({ + "name": "ablation_cell_merge_r1_p1", "section": "6.3、6.7 成分与权重;约定 6–8、24", + "params": {"lambda": LAM}, + "input": {"explanations": [{"id": i, "author": a, "range": tri(t)} for i, a, t in zip(R1_ABL_IDS, R1_ABL_AUTH, R1_ABL["p1"])], + "x": probe}, + "expected": { + "reserve": [F(clo), F(chi)], + "weights": [F(w) for w in R.explanation_weights(R1_ABL_AUTH, LAM)] + [LAM], + "cdf": [F(cd.left(x)) for x in probe], + "quantiles": {k: F(R.quantile(cd, p)) for k, p in (("q1", Fr(1, 10)), ("q5", Fr(1, 2)), ("q9", Fr(9, 10)))}, + }, + "note": "真实第 1 轮消融 p1 格(关掉 p1)。三人各两条解释:每条 0.9/3/2 = 0.15,预留 0.1(权重最后一项)。" + "开放预留只看这一格六条折线。分位数在原值上求。"}) + +# 合并分位数:平段取左端(两条不重叠的窄折线各占一半,用 λ=0 去掉预留) +Q_PARTS = [(0.99, 1.0, 1.01), (1.99, 2.0, 2.01)] +dq = R.Dist([(Fr(1, 2), R.polyline(*Q_PARTS[0])), (Fr(1, 2), R.polyline(*Q_PARTS[1]))]) +S.append({ + "name": "quantile_flat_left_end", "section": "约定 8", + "params": {}, + "input": {"mixture": [{"weight": 0.5, "range": tri(t)} for t in Q_PARTS], "p": [0.1, 0.5, 0.9]}, + "expected": {"quantiles": [F(R.quantile(dq, p)) for p in (Fr(1, 10), Fr(1, 2), Fr(9, 10))]}, + "note": "两条窄折线各占一半,中间 [1.02, 1.98] 把握走平在 0.5:一半取平段左端 1.02;一成落在第一条的" + "[0.99, 1.0] 段里(第一条自己的 0.2 分位),九成落在第二条的 [2.0, 2.01] 段里。"}) + +# ---- 3. 入池 +def pool_case(name, cands, b, direction, note): + dys, _, _, _ = R.forward_dists(cands, b, direction, LAM) + exp = {} + for c, d in dys.items(): + pr = R.pool_prob(d, EPS) + exp[c] = {"probBelowMinusEps": F(pr), "inPool": pr < Fr(1, 2)} + S.append({"name": name, "section": "6.4 入池;约定 9", + "params": {"eps": EPS, "lambda": LAM, "direction": direction}, + "input": {"b": b, "candidates": {c: ranges_json(ts) for c, ts in cands.items()}}, + "expected": exp, "note": note}) + return exp + + +pool_case("pool_r1_real", R1_FWD, B_R1, "maximize", "真实第 1 轮:三个候选都远高于 b,都入池。") +pool_min = pool_case("pool_minimize", MIN_FWD, B_MIN, "minimize", + "越小越好:P(Y < −ε) = P(分数 > b+ε)。c3 的大部分把握在 0.41 以上,不入池。") +assert not pool_min["c3"]["inPool"] + +# 刚好 0.5:一位研究者、一个候选,Y 的中位数正好在 −ε,折线与预留都关于 −ε 对称 +EPS_H = 0.25 +half = {"c1": [(0.5, 0.75, 1.0)]} # b = 1,Y 三个数 = (−0.5, −0.25, 0) +dys_h, _, _, _ = R.forward_dists(half, 1.0, "maximize", LAM) +ph = R.pool_prob(dys_h["c1"], EPS_H) +assert ph == Fr(1, 2) +float_ph = (1 - LAM) * 0.5 + LAM * 0.5 +assert float_ph == 0.5 +half2 = {"c1": [(0.5, 0.75 + 2 ** -10, 1.0)]} +dys_h2, _, _, _ = R.forward_dists(half2, 1.0, "maximize", LAM) +ph2 = R.pool_prob(dys_h2["c1"], EPS_H) +S.append({"name": "pool_exactly_half", "section": "6.4 入池;约定 9", + "params": {"eps": EPS_H, "lambda": LAM, "direction": "maximize"}, + "input": {"b": 1.0, "cases": [{"candidates": {"c1": ranges_json(half["c1"])}}, + {"candidates": {"c1": ranges_json(half2["c1"])}}]}, + "expected": {"cases": [{"probBelowMinusEps": F(ph), "inPool": ph < Fr(1, 2)}, + {"probBelowMinusEps": F(ph2), "inPool": ph2 < Fr(1, 2)}]}, + "note": "第一例:Y 三个数 (−0.5,−0.25,0),预留 [−1.25,0.75],都以 −ε=−0.25 为中点," + "P(Y<−ε) = 0.9×0.5+0.1×0.5 = 0.5(浮点下也正好 0.5),不严格小于一半,不入池。" + "第二例把 q5 往上挪 2⁻¹⁰,把握略小于 0.5,入池。ε 取 0.25 是为了全部是二进制精确数。"}) + +# ---- 4. V(S) +V_IN = {"X": [(0.5, (-0.001, 0.0, 0.001)), (0.5, (0.019, 0.02, 0.021))], + "Y": [(1, (0.009, 0.01, 0.011))]} +VD = {k: R.Dist([(w, R.polyline(*t)) for w, t in comps]) for k, comps in V_IN.items()} +S.append({"name": "V_method_example_X_Y", "section": "6.4 批内最好期望", + "params": {}, + "input": {"Ymixtures": {k: [{"weight": w, "range": tri(t)} for w, t in comps] for k, comps in V_IN.items()}}, + "expected": {"V": {"X": F(R.V([VD["X"]])), "Y": F(R.V([VD["Y"]])), "X,Y": F(R.V([VD["X"], VD["Y"]]))}}, + "note": "方法示例换成窄范围(三个数严格递增):X 一半在 0 附近、一半在 0.02 附近;Y 在 0.01 附近。" + "输入是改进量上的混合(每个成分一个三个数)。V(X) ≈ 0.010175(0 附近那一半有一点低于 0、被截成 0)," + "V(Y) ≈ 0.01,V(X,Y) ≈ 0.015。"}) + + +def batch_case(name, cands, b, direction, K, note, rand=None): + dys, _, _, _ = R.forward_dists(cands, b, direction, LAM) + pool = [c for c, d in dys.items() if R.pool_prob(d, EPS) < Fr(1, 2)] + table, best = R.select_batch(dys, pool, K) + pool_sorted = sorted(pool, key=R.id_key) + full = R.V([dys[c] for c in pool_sorted]) + ruler = full - best[1] if pool else Fr(0) + S.append({"name": name, "section": "6.4 选批、分数尺读数;约定 10–14", + "params": {"eps": EPS, "lambda": LAM, "direction": direction, "batchCap": K}, + "input": {"b": b, "balance": 100, "candidates": {c: ranges_json(ts) for c, ts in cands.items()}}, + "expected": { + "pool": pool_sorted, + "k": min(K, 100), + "combos": [{"combo": c, "V": F(v)} for c, v in table], + "selected": best[0], "selectedV": F(best[1]), + "poolAllV": F(full), + "rulerReading": F(ruler)}, + "note": note}) + + +batch_case("batch_r1_real_k2", R1_FWD, B_R1, "maximize", 2, + "真实第 1 轮,每批上限 2:三选二,列出三个组合的 V。分数尺读数 = E[max(0, max_池 Y)] − V(S*)。") +batch_case("batch_minimize_out_of_pool", MIN_FWD, B_MIN, "minimize", 2, + "越小越好,c3 在池外:只在池内 {c1,c2,c4} 里选二;分数尺读数只按池内候选算(约定 13),池外的 c3 不进。") + +# V 完全相等:两个候选输入完全相同,编号按自然序,"c2" < "c10"。 +# 预期值曾按 JS 字符串序选 c10;审阅指出方法说的"编号最小"是数字意义上的大小,改为自然序后选 c2,组合列表也变成先 c2 后 c10。 +tie = {"c2": [(2.40, 2.50, 2.60)], "c10": [(2.40, 2.50, 2.60)]} +batch_case("batch_tie_by_id", tie, 2.45, "maximize", 1, + "两个候选的范围完全相同,V 完全相等;按编号的自然序,c2 排在 c10 前面,选 c2。") + +# 均匀随机选批(第十一部分;约定 12、13):从全部候选(不看入池)抽 min(k, 候选数) 个;S* 是抽中的组合,V(S*) 照算, +# 分数尺读数仍按候选池 V(池) − V(S*)。挑一个能抽中池外候选 c3 的种子,核对池外候选可以被抽中。 +def uniform_case(name, cands, b, direction, K, must, note): + dys, _, _, _ = R.forward_dists(cands, b, direction, LAM) + pool = sorted([c for c, d in dys.items() if R.pool_prob(d, EPS) < Fr(1, 2)], key=R.id_key) + k = min(K, 100) + for rnd in range(1, 100): + study = "uniform-batch" + _, seed = seed_of(study, rnd, 1) + chosen = sample(seed, list(cands), min(k, len(cands))) + if must in chosen: + break + else: + raise AssertionError("找不到能抽中池外候选的种子") + assert must not in pool + v = R.V([dys[c] for c in chosen]) + full = R.V([dys[c] for c in pool]) + S.append({"name": name, "section": "第十一部分 均匀随机;约定 12–14", + "params": {"eps": EPS, "lambda": LAM, "direction": direction, "batchCap": K}, + "input": {"b": b, "balance": 100, "seed": seed, "seedFrom": {"studyId": study, "round": rnd, "version": 1}, + "candidates": {c: ranges_json(ts) for c, ts in cands.items()}}, + "expected": {"pool": pool, "k": k, "selected": chosen, "selectedV": F(v), + "poolAllV": F(full), "rulerReading": F(full - v)}, + "note": note}) + + +uniform_case("batch_uniform_random_minimize", MIN_FWD, B_MIN, "minimize", 2, "c3", + "越小越好四候选,c3 在池外。均匀随机从全部四个候选里抽两个(编号按 JS 字符串序排好再做部分 Fisher–Yates)," + "这个种子抽中了池外的 c3。selectedV = V(S*);分数尺读数 = V(候选池 c1,c2,c4) − V(S*)。不列组合。") + +# ---- 5. 采纳线与采纳 +adopt = [] +for B, K, d, note in [ + (2.60, None, "maximize", "没有已知最好:ε"), + (2.60, 2.635983, "maximize", "正常:min(0.01, 0.035983/4=0.00899575)"), + (2.6359, 2.635983, "maximize", "下限:(K*−B)/4 < ε/100,取 ε/100 = 0.0001"), + (2.64, 2.635983, "maximize", "B 超过 K*:取 ε/100"), + (2.50, 2.635983, "maximize", "差距大:取上限 ε"), + (0.40, 0.38, "minimize", "越小越好:(B−K*)/4 = 0.005"), + (0.37, 0.38, "minimize", "越小越好、B 已比 K* 好:ε/100"), +]: + adopt.append({"B": B, "knownBest": K, "direction": d, "expected": F(R.adoption_line(EPS, B, K, d)), "note": note}) +S.append({"name": "adoption_line_cases", "section": "6.5;约定 15", + "params": {"eps": EPS}, "input": {"cases": [{k: v for k, v in a.items() if k not in ("expected",)} for a in adopt]}, + "expected": {"lines": [a["expected"] for a in adopt]}, + "note": "逐例对应 input.cases。"}) + + +def adopt_decide(B, K, d, results): + line = R.adoption_line(EPS, B, K, d) + best = None + for cid in sorted(results): + v = results[cid] + if v is None: + continue + if best is None or R.improve(v, results[best], d) > 0: + best = cid + if best is None: + return {"line": F(line), "best": None, "adopt": False} + gain = R.improve(results[best], B, d) + return {"line": F(line), "best": best, "gain": F(gain), "adopt": gain > line} + + +dec = [] +for B, K, d, res, note in [ + (2.60, 2.635983, "maximize", {"c1": 2.605, "c2": 2.6185, "c3": None}, "c2 涨 0.0185 > 0.00899575,采纳;c3 没有有效成绩"), + (2.60, None, "maximize", {"c1": 2.61, "c2": 2.605}, "正好高 ε:不采纳"), + (2.60, None, "maximize", {"c1": 2.6125, "c2": 2.6125}, "同分取编号小的 c1,涨 0.0125 > 0.01"), + (0.40, None, "minimize", {"c1": 0.385, "c2": 0.395}, "越小越好:c1 改进 0.015 > 0.01"), +]: + dec.append({"input": {"B": B, "knownBest": K, "direction": d, "results": res}, "expected": adopt_decide(B, K, d, res), "note": note}) +S.append({"name": "adoption_decision_cases", "section": "6.5;约定 16", + "params": {"eps": EPS}, "input": {"cases": [x["input"] for x in dec]}, + "expected": {"cases": [x["expected"] for x in dec]}, + "note": "改进量 > 采纳线(严格)才采纳;同分取编号小的。第二例是'正好高 ε':浮点输入 2.61、2.60 的精确差 " + "= 0.009999999999999787(浮点减法 2.61-2.6 也是这个数),不大于 0.01,不采纳。"}) + +write("forward.json", S, "预期值由有理数精确算出;与浮点实现比较用绝对误差 1e-12。") + +# =========================================================== info.json +S = [] +DELTA = EPS / 100 + +# 正向:r1 真实三候选,原值上切格(b 起切),惊讶用 c3 的真实成绩 +_, draws_r1, _, _ = R.forward_dists(R1_FWD, B_R1, "maximize", LAM) +fw = {} +for c, d in draws_r1.items(): + fw[c] = {"worldBits": R.world_bits(d, B_R1, DELTA)} +v_c3 = 1.552176048093269 +fw["r1-v1-c3"]["surprise"] = R.surprise(draws_r1["r1-v1-c3"], v_c3, B_R1, DELTA) +far = R.surprise(draws_r1["r1-v1-c3"], 10.0, B_R1, DELTA) +dis, hb, each = R.disagreement([R.single(R.polyline(*t)) for t in R1_FWD["r1-v1-c3"]], B_R1, DELTA) +S.append({"name": "bits_forward_r1_real", "section": "6.9;约定 31–33", + "params": {"eps": EPS, "lambda": LAM, "delta": DELTA, "direction": "maximize"}, + "input": {"b": B_R1, "candidates": {c: ranges_json(ts) for c, ts in R1_FWD.items()}, + "actual": {"r1-v1-c3": v_c3}, "farValue": 10.0}, + "expected": {"candidates": fw, + "surpriseFarValue": far, + "disagreementC3": {"disagreement": dis, "avgBits": hb, "eachBits": each}}, + "note": "原值上从 b 起切 Δ=ε/100 的小格,合并分布 = 三人折线 0.9 + 共同预留 0.1。" + "c3 的实测 1.552176 远低于各人范围,只落在预留里。farValue=10 在预留之外,事前把握为 0," + "状态 priorZero(不是 0 或无穷大)。分歧量只用三人折线(不掺预留)。"}) + +# 消融格:r1 p1,b = 本次消融的基线 +B_ABL = 0.9597642169962064 +v_p1 = 2.250011787062861 +persons = {} +for a, t in zip(R1_ABL_AUTH, R1_ABL["p1"]): + persons.setdefault(a, []).append(R.polyline(*t)) +pd = [R.Dist([(Fr(1, len(ps)), p) for p in ps]) for ps in persons.values()] +dis2, hb2, each2 = R.disagreement(pd, B_ABL, DELTA) +S.append({"name": "bits_ablation_cell_r1_p1", "section": "6.9;约定 31–33", + "params": {"eps": EPS, "lambda": LAM, "delta": DELTA}, + "input": {"b": B_ABL, "explanations": [{"id": i, "author": a, "range": tri(t)} for i, a, t in zip(R1_ABL_IDS, R1_ABL_AUTH, R1_ABL["p1"])], + "actual": v_p1}, + "expected": {"worldBits": R.world_bits(cd, B_ABL, DELTA), + "surprise": R.surprise(cd, v_p1, B_ABL, DELTA), + "disagreement": dis2, "avgBits": hb2, "eachPersonBits": each2}, + "note": "消融格的合并分布 = 6 条解释(每条 0.15)+ 这一格的预留 0.1。分歧量:每人取自己几条解释的等权平均作为" + "'这个人的折线',再比较三人平均的比特与各人比特的平均(不掺预留)。"}) + +# 越小越好的格子边界:原值上从 b 起切,b 以下是负的 j +d_min = R.forward_dists(MIN_FWD, B_MIN, "minimize", LAM)[1]["c1"] +edge_m1 = R.edge(B_MIN, DELTA, -1) +cases = [] +for v in [0.3999, edge_m1, 0.4, 0.38, 0.38005]: + cases.append({"value": v, "result": R.surprise(d_min, v, B_MIN, DELTA)}) +S.append({"name": "bits_minimize_bin_edges", "section": "6.9;约定 31–32", + "params": {"eps": EPS, "lambda": LAM, "delta": DELTA, "direction": "minimize"}, + "input": {"b": B_MIN, "candidates": {c: ranges_json(ts) for c, ts in MIN_FWD.items()}, + "candidate": "c1", "values": [c["value"] for c in cases]}, + "expected": {"worldBits": R.world_bits(d_min, B_MIN, DELTA), "surprise": [c["result"] for c in cases], + "edgeMinus1": edge_m1}, + "note": "越小越好也在分数原值上从 b 起切,第 j 格 [b+jΔ, b+(j+1)Δ),j 可为负。边界一律按浮点 b + j*Δ 算:" + f"b+(-1)Δ = {edge_m1!r},所以字面 0.3999 落在 j=-2 格,而值正好等于该边界时落在 j=-1 格。" + "合并分布是越小越好的那套(四候选共同预留,在原值上等价于原值折线的最低/最高点)。"}) + +# 落点四段、覆盖率 +lc = [(2.44, 2.52, 2.62, v) for v in [2.40, 2.44, 2.50, 2.52, 2.60, 2.62, 2.65]] +S.append({"name": "landing_segments_endpoints", "section": "6.9;约定 34", + "params": {}, "input": {"cases": [{"range": tri(c[:3]), "value": c[3]} for c in lc]}, + "expected": {"segments": [R.landing(*c) for c in lc], + "coverage": sum(1 for c in lc if c[0] <= c[3] <= c[2]) / len(lc)}, + "note": "vq9 above90。端点:v=q1 归一成到一半," + "v=q5 和 v=q9 归一半到九成。覆盖率 = 落在 [q1,q9](含端点)的比例 = 5/7。"}) +S.append({"name": "coverage_no_samples", "section": "6.9;约定 34", + "params": {}, "input": {"cases": []}, + "expected": {"coverage": {"status": "noSamples"}}, + "note": "没有样本时返回明确状态,不是 0 或 NaN。"}) + +# 战绩 +samples = [((2.44, 2.52, 2.62), 2.52), ((2.44, 2.52, 2.62), 2.62), ((2.44, 2.52, 2.62), 2.70), + ((2.44, 2.52, 2.62), 2.44), ((2.44, 2.52, 2.62), 2.30), ((0.99, 1.0, 1.01), 1.0)] + + +def track(ss): + out = {"medianHigh": 0, "medianLow": 0, "pierceUpper": 0, "pierceLower": 0, "covered": 0} + for (q1, q5, q9), v in ss: + if q5 > v: + out["medianHigh"] += 1 + if q5 < v: + out["medianLow"] += 1 + if v > q9: + out["pierceUpper"] += 1 + if v < q1: + out["pierceLower"] += 1 + if q1 <= v <= q9: + out["covered"] += 1 + return out + + +S.append({"name": "track_record_counts", "section": "6.10 第 4 条;约定 37", + "params": {}, + "input": {"forward": [{"range": tri(t), "value": v} for t, v in samples[:3]], + "ablation": [{"range": tri(t), "value": v} for t, v in samples[3:]]}, + "expected": {"forward": track(samples[:3]), "ablation": track(samples[3:])}, + "note": "分数原值、不按方向。q5>v 中位数偏高;q5q9 穿上沿;v0 作者更乐观)。" + "合并范围取 forward.json 里 forward_merge_r1_real 的原值分位数。作者自评三个数是示意输入。"}) + +write("info.json", S, + "abs 用于事前把握、覆盖率、作者乐观差等普通数值。" + "bits 用于世界比特和分歧量:本脚本按有理数算出每格质量后用浮点求熵(fsum),浮点实现每格质量是两个浮点 F 值之差," + "约 2 万到 5 万个小格累加后末位误差积到 1e-12 量级(第二轮审阅实测最大差 3.5e-12),留约 30 倍余量取 1e-10。" + "surprise 是惊讶的容差公式:本脚本的 p 是有理数精确差,浮点实现只能算 F(右端) − F(左端)," + "两个不超过 1 的浮点数相减已有几个 2⁻⁵³ 的绝对误差,p 很小时(如 2.5e-6)换成 −log₂p 就放大成约 1e-12 量级的差。" + "状态、格号、落点、计数必须完全相同。", + {"bits": 1e-10, "surprise": "1e-12 + 2^-50 / (p * ln 2)"}) + +# =========================================================== misc.json +S = [] +ENT = [{"id": "e1", "author": "r1"}, {"id": "e2", "author": "r1"}, {"id": "e3", "author": "r2"}, + {"id": "e4", "author": "r2"}, {"id": "e5", "author": "r3"}, {"id": "e6", "author": "r3"}] + + +def entry_case(name, rs, ents, prefs, note): + res = R.allocate_entries(rs, ents, prefs) + S.append({"name": name, "section": "4.2 分配规则;约定 38", + "params": {}, "input": {"researchers": rs, "entries": ents, "preferences": prefs}, + "expected": {"assignments": res}, "note": note}) + + +for name, rs, prefs, note in [ + ("entries_rule1_unique", ["r1", "r2", "r3"], {"r1": ["e1", "e3", "e5"], "r2": ["e3", "e1", "e5"], "r3": ["e6", "e1", "e2"]}, + "规则 1:第 1 志愿互不相同,各得第 1 志愿。"), + ("entries_rule2_author_wins", ["r1", "r2", "r3"], {"r1": ["e3", "e1", "e2"], "r2": ["e3", "e4", "e1"], "r3": ["e3", "e5", "e1"]}, + "规则 2:三人都选 e3,作者 r2 在其中,归 r2;r1、r3 进入第 2 轮,各得 e1、e5(第 2 志愿)。"), + ("entries_rule2_smallest_index", ["r1", "r2", "r3"], {"r1": ["e5", "e2", "e3"], "r2": ["e1", "e4", "e5"], "r3": ["e1", "e4", "e2"]}, + "规则 2:r2、r3 同选 e1,作者 r1 不在其中,归编号小的 r2;第 2 轮 r3 选 e4 得到。"), + ("entries_identical_prefs", ["r1", "r2", "r3"], {"r1": ["e1", "e2", "e3"], "r2": ["e1", "e2", "e3"], "r3": ["e1", "e2", "e3"]}, + "三人志愿完全相同:第 1 轮 e1 归作者 r1;第 2 轮 r2、r3 选 e2,作者 r1 已分到不在其中,归 r2;" + "第 3 轮 r3 选 e3 得到。"), + ("entries_rule3_exhausted", ["r1", "r2", "r3"], {"r1": ["e3", "e5"], "r2": ["e3", "e5"], "r3": ["e3", "e5"]}, + "志愿用完:第 1 轮 e3 归作者 r2;第 2 轮 r1、r3 选 e5,作者 r3 在其中归 r3;r1 志愿用完," + "按规则 3 拿剩下编号最小的 e1,choice 为 null。"), + ("entries_N1", ["r1"], {"r1": ["e2", "e1"]}, "N=1:直接给第一志愿。"), +]: + ents = ENT if len(rs) > 1 else ENT[:2] + entry_case(name, rs, ents, prefs, note) + +# "编号最小"按自然序判(researcher-2 < researcher-10、e2 < e10),与传入数组的次序无关:输入故意乱序给。 +NAT_ENT = [{"id": "e10", "author": "researcher-2"}, {"id": "e1", "author": "researcher-3"}, + {"id": "e2", "author": "researcher-3"}] +entry_case("entries_natural_rule2", ["researcher-10", "researcher-3", "researcher-2"], NAT_ENT, + {"researcher-10": ["e1"], "researcher-2": ["e1"], "researcher-3": ["e10"]}, + "规则 2:researcher-10 与 researcher-2 同选 e1,作者 researcher-3 不在其中,归编号小的 researcher-2" + "(按字符串序会错给 researcher-10);researcher-3 独选 e10;researcher-10 志愿用完,按规则 3 拿剩下的 e2。") +entry_case("entries_natural_rule3", ["researcher-10", "researcher-3", "researcher-2"], NAT_ENT, + {"researcher-10": ["e1"], "researcher-2": ["e1"], "researcher-3": ["e1"]}, + "三人同选 e1,作者 researcher-3 在其中归他;researcher-2、researcher-10 志愿用完,按编号从小到大拿剩下" + "编号最小的入口:researcher-2 拿 e2,researcher-10 拿 e10(按字符串序会把 e10 排在 e2 前面)。") + +# 余额:第七部分示例表 +rows = [("基线", ["started"]), ("第 1 轮正向", ["started"] * 2), ("第 1 轮消融", ["started"] * 2), + ("第 2 轮正向", ["started"] * 2), ("第 3 轮正向", ["started"] * 2), ("第 3 轮消融", []), + ("第 4 轮正向", ["started"] * 2), ("第 5 轮正向", ["started"] * 2)] +runs, out = [], [] +for label_, add in rows: + runs = runs + add + b = R.balance(13, runs) + out.append({"step": label_, "added": add, **b, "forwardK": min(2, b["balance"]), "ablationMax": max(0, b["balance"] - 1)}) +S.append({"name": "balance_part7_table", "section": "第七部分示例;约定 35–36", + "params": {"totalRuns": 13, "batchCap": 2}, + "input": {"steps": [{"step": l, "added": a} for l, a in rows]}, + "expected": {"afterEachStep": out}, + "note": "每一步追加的运行记录状态,累计后算余额。余额序列 12,10,8,6,4,4,2,0。" + "forwardK = min(每批上限, 余额);ablationMax = 余额−1(余额 ≤ 1 时 0)。"}) +mix = ["started", "reserved", "notStarted", "started", "reserved"] +b = R.balance(6, mix) +S.append({"name": "balance_mixed_states", "section": "第七部分;约定 35–36", + "params": {"totalRuns": 6, "batchCap": 2}, + "input": {"runs": mix}, + "expected": {**b, "forwardK": min(2, b["balance"]), "ablationMax": max(0, b["balance"] - 1)}, + "note": "started 扣次;reserved(含状态未知)占着名额;notStarted 退还。已扣 2,占用 4,余额 2。"}) +for tot, rs in [(3, ["started", "started", "started"]), (3, ["started", "started"]), (3, ["started"])]: + b = R.balance(tot, rs) + S.append({"name": f"balance_{b['balance']}", "section": "第七部分;约定 36", + "params": {"totalRuns": tot, "batchCap": 2}, "input": {"runs": rs}, + "expected": {**b, "forwardK": min(2, b["balance"]), "ablationMax": max(0, b["balance"] - 1)}, + "note": "余额 0、1、2 时的正向批量与消融上限。"}) + + +for study, rnd, ver in [("study-a", 1, 1), ("15-real-1", 3, 1)]: + can, seed = seed_of(study, rnd, ver) + rng = Rng(seed) + draws = [rng.uniform_int(10) for _ in range(5)] + # 十个以上的编号:候选、因子都按自然序排好再抽;和按字符串序排抽出的结果不同,能分出用错排序的实现。 + # 预期值曾让候选按字符串序抽,改为自然序后 batchSampleMany 随之变化。 + many_c = sample(seed, MANY_C, 4) + many_p = sample(seed, MANY_P, 4) + assert many_c != sample(seed, MANY_C, 4, string_order), "这个种子分不出候选的排序" + assert many_p != sample(seed, MANY_P, 4, string_order), "这个种子分不出因子的排序" + first6 = [hashlib.sha256(f"{seed}:{c}".encode()).digest()[:6].hex() for c in range(3)] + S.append({"name": f"seed_{study}_r{rnd}", "section": "约定 39–40", + "params": {}, "input": {"studyId": study, "round": rnd, "version": ver, + "candidates": ["c1", "c2", "c3", "c4", "c5"], "batchSize": 2, + "cells": ["p1", "p2", "p3", "p4"], "cellCount": 2, + "candidatesMany": MANY_C, "cellsMany": MANY_P, "manyCount": 4}, + "expected": {"canonical": can, "seed": seed, + "first6BytesHex": first6, + "uniformInt10First5": draws, + "batchSample": sample(seed, ["c1", "c2", "c3", "c4", "c5"], 2), + "cellSample": sample(seed, ["p1", "p2", "p3", "p4"], 2), + "batchSampleMany": many_c, + "cellSampleMany": many_p}, + "note": "种子 = sha256(规范 JSON {round, studyId, version})。uniformInt:sha256(`${seed}:${counter}`) 前 6 字节大端," + "counter 从 0 起;uniformInt10First5 是同一个生成器连续 5 次。batchSample、cellSample 各自用一个从 counter=0 " + "开始的新生成器,部分 Fisher–Yates 后取前 size 个再排序。候选、因子编号都按自然序(c2 < c10、p2 < p10);" + "*Many 两项用 12 个编号核对这一点。"}) + +write("misc.json", S, "全是离散结果,必须完全相同。") +print("ok") diff --git a/tests/fixtures/rules/info.json b/tests/fixtures/rules/info.json new file mode 100644 index 0000000..42871b2 --- /dev/null +++ b/tests/fixtures/rules/info.json @@ -0,0 +1,580 @@ +{ + "generator": "gen_forward_info.py", + "tolerance": { + "abs": 1e-12, + "bits": 1e-10, + "surprise": "1e-12 + 2^-50 / (p * ln 2)", + "note": "abs 用于事前把握、覆盖率、作者乐观差等普通数值。bits 用于世界比特和分歧量:本脚本按有理数算出每格质量后用浮点求熵(fsum),浮点实现每格质量是两个浮点 F 值之差,约 2 万到 5 万个小格累加后末位误差积到 1e-12 量级(第二轮审阅实测最大差 3.5e-12),留约 30 倍余量取 1e-10。surprise 是惊讶的容差公式:本脚本的 p 是有理数精确差,浮点实现只能算 F(右端) − F(左端),两个不超过 1 的浮点数相减已有几个 2⁻⁵³ 的绝对误差,p 很小时(如 2.5e-6)换成 −log₂p 就放大成约 1e-12 量级的差。状态、格号、落点、计数必须完全相同。" + }, + "scenarios": [ + { + "name": "bits_forward_r1_real", + "section": "6.9;约定 31–33", + "params": { + "eps": 0.01, + "lambda": 0.1, + "delta": 0.0001, + "direction": "maximize" + }, + "input": { + "b": 0.9597642169962064, + "candidates": { + "r1-v1-c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.0, + "q5": 2.45, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.2, + "q5": 2.5, + "q9": 2.63 + } + } + ], + "r1-v1-c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 1.85, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 1.6, + "q5": 2.35, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 1.8, + "q5": 2.4, + "q9": 2.62 + } + } + ], + "r1-v1-c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 2.3, + "q5": 2.5, + "q9": 2.62 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 2.42, + "q5": 2.52, + "q9": 2.6 + } + }, + { + "researcher": "researcher-3", + "range": { + "q1": 2.1, + "q5": 2.5, + "q9": 2.6 + } + } + ] + }, + "actual": { + "r1-v1-c3": 1.552176048093269 + }, + "farValue": 10.0 + }, + "expected": { + "candidates": { + "r1-v1-c1": { + "worldBits": 13.424994765554958 + }, + "r1-v1-c2": { + "worldBits": 14.07304207309006 + }, + "r1-v1-c3": { + "worldBits": 13.018692667257758, + "surprise": { + "status": "ok", + "bin": 5924, + "p": 2.499999999999725e-06, + "surprise": 18.609640474436972 + } + } + }, + "surpriseFarValue": { + "status": "priorZero", + "bin": 90402, + "p": 0.0 + }, + "disagreementC3": { + "disagreement": 0.22345314430460128, + "avgBits": 12.401558575360262, + "eachBits": [ + 12.319392171330211, + 11.527004851853361, + 12.68791926998341 + ] + } + }, + "note": "原值上从 b 起切 Δ=ε/100 的小格,合并分布 = 三人折线 0.9 + 共同预留 0.1。c3 的实测 1.552176 远低于各人范围,只落在预留里。farValue=10 在预留之外,事前把握为 0,状态 priorZero(不是 0 或无穷大)。分歧量只用三人折线(不掺预留)。" + }, + { + "name": "bits_ablation_cell_r1_p1", + "section": "6.9;约定 31–33", + "params": { + "eps": 0.01, + "lambda": 0.1, + "delta": 0.0001 + }, + "input": { + "b": 0.9597642169962064, + "explanations": [ + { + "id": "r1-e1-1", + "author": "researcher-1", + "range": { + "q1": 1.2, + "q5": 2, + "q9": 2.44 + } + }, + { + "id": "r1-e1-2", + "author": "researcher-1", + "range": { + "q1": 1.22, + "q5": 1.4, + "q9": 1.52 + } + }, + { + "id": "r1-e2-1", + "author": "researcher-2", + "range": { + "q1": 1.05, + "q5": 1.35, + "q9": 1.95 + } + }, + { + "id": "r1-e2-2", + "author": "researcher-2", + "range": { + "q1": 0.95, + "q5": 1.3, + "q9": 2.1 + } + }, + { + "id": "r1-e3-1", + "author": "researcher-3", + "range": { + "q1": 1.15, + "q5": 1.36, + "q9": 1.55 + } + }, + { + "id": "r1-e3-2", + "author": "researcher-3", + "range": { + "q1": 1.2, + "q5": 1.42, + "q9": 2.46 + } + } + ], + "actual": 2.250011787062861 + }, + "expected": { + "worldBits": 14.423852532506046, + "surprise": { + "status": "ok", + "bin": 12902, + "p": 2.5393497631454448e-05, + "surprise": 15.265181352865099 + }, + "disagreement": 0.17487769063959568, + "avgBits": 13.988099142237102, + "eachPersonBits": [ + 13.828510269170529, + 14.016547746252732, + 13.594606339369257 + ] + }, + "note": "消融格的合并分布 = 6 条解释(每条 0.15)+ 这一格的预留 0.1。分歧量:每人取自己几条解释的等权平均作为'这个人的折线',再比较三人平均的比特与各人比特的平均(不掺预留)。" + }, + { + "name": "bits_minimize_bin_edges", + "section": "6.9;约定 31–32", + "params": { + "eps": 0.01, + "lambda": 0.1, + "delta": 0.0001, + "direction": "minimize" + }, + "input": { + "b": 0.4, + "candidates": { + "c1": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.36, + "q5": 0.38, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.35, + "q5": 0.37, + "q9": 0.39 + } + } + ], + "c2": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.33, + "q5": 0.36, + "q9": 0.41 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.42 + } + } + ], + "c3": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.41, + "q5": 0.43, + "q9": 0.46 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.4, + "q5": 0.42, + "q9": 0.45 + } + } + ], + "c4": [ + { + "researcher": "researcher-1", + "range": { + "q1": 0.37, + "q5": 0.39, + "q9": 0.4 + } + }, + { + "researcher": "researcher-2", + "range": { + "q1": 0.38, + "q5": 0.395, + "q9": 0.41 + } + } + ] + }, + "candidate": "c1", + "values": [ + 0.3999, + 0.39990000000000003, + 0.4, + 0.38, + 0.38005 + ] + }, + "expected": { + "worldBits": 10.045467864639367, + "surprise": [ + { + "status": "ok", + "bin": -2, + "p": 0.0011513157894735564, + "surprise": 9.762500686273503 + }, + { + "status": "ok", + "bin": -1, + "p": 0.0011513157894735564, + "surprise": 9.762500686273503 + }, + { + "status": "ok", + "bin": 0, + "p": 0.0004763157894736314, + "surprise": 11.03579400590999 + }, + { + "status": "ok", + "bin": -200, + "p": 0.0018263157894734814, + "surprise": 9.096848040414935 + }, + { + "status": "ok", + "bin": -200, + "p": 0.0018263157894734814, + "surprise": 9.096848040414935 + } + ], + "edgeMinus1": 0.39990000000000003 + }, + "note": "越小越好也在分数原值上从 b 起切,第 j 格 [b+jΔ, b+(j+1)Δ),j 可为负。边界一律按浮点 b + j*Δ 算:b+(-1)Δ = 0.39990000000000003,所以字面 0.3999 落在 j=-2 格,而值正好等于该边界时落在 j=-1 格。合并分布是越小越好的那套(四候选共同预留,在原值上等价于原值折线的最低/最高点)。" + }, + { + "name": "landing_segments_endpoints", + "section": "6.9;约定 34", + "params": {}, + "input": { + "cases": [ + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.4 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.44 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.5 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.52 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.6 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.62 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.65 + } + ] + }, + "expected": { + "segments": [ + "below10", + "from10to50", + "from10to50", + "from50to90", + "from50to90", + "from50to90", + "above90" + ], + "coverage": 0.7142857142857143 + }, + "note": "vq9 above90。端点:v=q1 归一成到一半,v=q5 和 v=q9 归一半到九成。覆盖率 = 落在 [q1,q9](含端点)的比例 = 5/7。" + }, + { + "name": "coverage_no_samples", + "section": "6.9;约定 34", + "params": {}, + "input": { + "cases": [] + }, + "expected": { + "coverage": { + "status": "noSamples" + } + }, + "note": "没有样本时返回明确状态,不是 0 或 NaN。" + }, + { + "name": "track_record_counts", + "section": "6.10 第 4 条;约定 37", + "params": {}, + "input": { + "forward": [ + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.52 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.62 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.7 + } + ], + "ablation": [ + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.44 + }, + { + "range": { + "q1": 2.44, + "q5": 2.52, + "q9": 2.62 + }, + "value": 2.3 + }, + { + "range": { + "q1": 0.99, + "q5": 1.0, + "q9": 1.01 + }, + "value": 1.0 + } + ] + }, + "expected": { + "forward": { + "medianHigh": 0, + "medianLow": 2, + "pierceUpper": 1, + "pierceLower": 0, + "covered": 2 + }, + "ablation": { + "medianHigh": 2, + "medianLow": 0, + "pierceUpper": 0, + "pierceLower": 1, + "covered": 2 + } + }, + "note": "分数原值、不按方向。q5>v 中位数偏高;q5q9 穿上沿;v0 作者更乐观)。合并范围取 forward.json 里 forward_merge_r1_real 的原值分位数。作者自评三个数是示意输入。" + } + ] +} diff --git a/tests/fixtures/rules/misc.json b/tests/fixtures/rules/misc.json new file mode 100644 index 0000000..ad26156 --- /dev/null +++ b/tests/fixtures/rules/misc.json @@ -0,0 +1,943 @@ +{ + "generator": "gen_forward_info.py", + "tolerance": { + "abs": 1e-12, + "note": "全是离散结果,必须完全相同。" + }, + "scenarios": [ + { + "name": "entries_rule1_unique", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "r1", + "r2", + "r3" + ], + "entries": [ + { + "id": "e1", + "author": "r1" + }, + { + "id": "e2", + "author": "r1" + }, + { + "id": "e3", + "author": "r2" + }, + { + "id": "e4", + "author": "r2" + }, + { + "id": "e5", + "author": "r3" + }, + { + "id": "e6", + "author": "r3" + } + ], + "preferences": { + "r1": [ + "e1", + "e3", + "e5" + ], + "r2": [ + "e3", + "e1", + "e5" + ], + "r3": [ + "e6", + "e1", + "e2" + ] + } + }, + "expected": { + "assignments": { + "r1": { + "entry": "e1", + "choice": 1 + }, + "r2": { + "entry": "e3", + "choice": 1 + }, + "r3": { + "entry": "e6", + "choice": 1 + } + } + }, + "note": "规则 1:第 1 志愿互不相同,各得第 1 志愿。" + }, + { + "name": "entries_rule2_author_wins", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "r1", + "r2", + "r3" + ], + "entries": [ + { + "id": "e1", + "author": "r1" + }, + { + "id": "e2", + "author": "r1" + }, + { + "id": "e3", + "author": "r2" + }, + { + "id": "e4", + "author": "r2" + }, + { + "id": "e5", + "author": "r3" + }, + { + "id": "e6", + "author": "r3" + } + ], + "preferences": { + "r1": [ + "e3", + "e1", + "e2" + ], + "r2": [ + "e3", + "e4", + "e1" + ], + "r3": [ + "e3", + "e5", + "e1" + ] + } + }, + "expected": { + "assignments": { + "r1": { + "entry": "e1", + "choice": 2 + }, + "r2": { + "entry": "e3", + "choice": 1 + }, + "r3": { + "entry": "e5", + "choice": 2 + } + } + }, + "note": "规则 2:三人都选 e3,作者 r2 在其中,归 r2;r1、r3 进入第 2 轮,各得 e1、e5(第 2 志愿)。" + }, + { + "name": "entries_rule2_smallest_index", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "r1", + "r2", + "r3" + ], + "entries": [ + { + "id": "e1", + "author": "r1" + }, + { + "id": "e2", + "author": "r1" + }, + { + "id": "e3", + "author": "r2" + }, + { + "id": "e4", + "author": "r2" + }, + { + "id": "e5", + "author": "r3" + }, + { + "id": "e6", + "author": "r3" + } + ], + "preferences": { + "r1": [ + "e5", + "e2", + "e3" + ], + "r2": [ + "e1", + "e4", + "e5" + ], + "r3": [ + "e1", + "e4", + "e2" + ] + } + }, + "expected": { + "assignments": { + "r1": { + "entry": "e5", + "choice": 1 + }, + "r2": { + "entry": "e1", + "choice": 1 + }, + "r3": { + "entry": "e4", + "choice": 2 + } + } + }, + "note": "规则 2:r2、r3 同选 e1,作者 r1 不在其中,归编号小的 r2;第 2 轮 r3 选 e4 得到。" + }, + { + "name": "entries_identical_prefs", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "r1", + "r2", + "r3" + ], + "entries": [ + { + "id": "e1", + "author": "r1" + }, + { + "id": "e2", + "author": "r1" + }, + { + "id": "e3", + "author": "r2" + }, + { + "id": "e4", + "author": "r2" + }, + { + "id": "e5", + "author": "r3" + }, + { + "id": "e6", + "author": "r3" + } + ], + "preferences": { + "r1": [ + "e1", + "e2", + "e3" + ], + "r2": [ + "e1", + "e2", + "e3" + ], + "r3": [ + "e1", + "e2", + "e3" + ] + } + }, + "expected": { + "assignments": { + "r1": { + "entry": "e1", + "choice": 1 + }, + "r2": { + "entry": "e2", + "choice": 2 + }, + "r3": { + "entry": "e3", + "choice": 3 + } + } + }, + "note": "三人志愿完全相同:第 1 轮 e1 归作者 r1;第 2 轮 r2、r3 选 e2,作者 r1 已分到不在其中,归 r2;第 3 轮 r3 选 e3 得到。" + }, + { + "name": "entries_rule3_exhausted", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "r1", + "r2", + "r3" + ], + "entries": [ + { + "id": "e1", + "author": "r1" + }, + { + "id": "e2", + "author": "r1" + }, + { + "id": "e3", + "author": "r2" + }, + { + "id": "e4", + "author": "r2" + }, + { + "id": "e5", + "author": "r3" + }, + { + "id": "e6", + "author": "r3" + } + ], + "preferences": { + "r1": [ + "e3", + "e5" + ], + "r2": [ + "e3", + "e5" + ], + "r3": [ + "e3", + "e5" + ] + } + }, + "expected": { + "assignments": { + "r1": { + "entry": "e1", + "choice": null + }, + "r2": { + "entry": "e3", + "choice": 1 + }, + "r3": { + "entry": "e5", + "choice": 2 + } + } + }, + "note": "志愿用完:第 1 轮 e3 归作者 r2;第 2 轮 r1、r3 选 e5,作者 r3 在其中归 r3;r1 志愿用完,按规则 3 拿剩下编号最小的 e1,choice 为 null。" + }, + { + "name": "entries_N1", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "r1" + ], + "entries": [ + { + "id": "e1", + "author": "r1" + }, + { + "id": "e2", + "author": "r1" + } + ], + "preferences": { + "r1": [ + "e2", + "e1" + ] + } + }, + "expected": { + "assignments": { + "r1": { + "entry": "e2", + "choice": 1 + } + } + }, + "note": "N=1:直接给第一志愿。" + }, + { + "name": "entries_natural_rule2", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "researcher-10", + "researcher-3", + "researcher-2" + ], + "entries": [ + { + "id": "e10", + "author": "researcher-2" + }, + { + "id": "e1", + "author": "researcher-3" + }, + { + "id": "e2", + "author": "researcher-3" + } + ], + "preferences": { + "researcher-10": [ + "e1" + ], + "researcher-2": [ + "e1" + ], + "researcher-3": [ + "e10" + ] + } + }, + "expected": { + "assignments": { + "researcher-2": { + "entry": "e1", + "choice": 1 + }, + "researcher-3": { + "entry": "e10", + "choice": 1 + }, + "researcher-10": { + "entry": "e2", + "choice": null + } + } + }, + "note": "规则 2:researcher-10 与 researcher-2 同选 e1,作者 researcher-3 不在其中,归编号小的 researcher-2(按字符串序会错给 researcher-10);researcher-3 独选 e10;researcher-10 志愿用完,按规则 3 拿剩下的 e2。" + }, + { + "name": "entries_natural_rule3", + "section": "4.2 分配规则;约定 38", + "params": {}, + "input": { + "researchers": [ + "researcher-10", + "researcher-3", + "researcher-2" + ], + "entries": [ + { + "id": "e10", + "author": "researcher-2" + }, + { + "id": "e1", + "author": "researcher-3" + }, + { + "id": "e2", + "author": "researcher-3" + } + ], + "preferences": { + "researcher-10": [ + "e1" + ], + "researcher-2": [ + "e1" + ], + "researcher-3": [ + "e1" + ] + } + }, + "expected": { + "assignments": { + "researcher-2": { + "entry": "e2", + "choice": null + }, + "researcher-3": { + "entry": "e1", + "choice": 1 + }, + "researcher-10": { + "entry": "e10", + "choice": null + } + } + }, + "note": "三人同选 e1,作者 researcher-3 在其中归他;researcher-2、researcher-10 志愿用完,按编号从小到大拿剩下编号最小的入口:researcher-2 拿 e2,researcher-10 拿 e10(按字符串序会把 e10 排在 e2 前面)。" + }, + { + "name": "balance_part7_table", + "section": "第七部分示例;约定 35–36", + "params": { + "totalRuns": 13, + "batchCap": 2 + }, + "input": { + "steps": [ + { + "step": "基线", + "added": [ + "started" + ] + }, + { + "step": "第 1 轮正向", + "added": [ + "started", + "started" + ] + }, + { + "step": "第 1 轮消融", + "added": [ + "started", + "started" + ] + }, + { + "step": "第 2 轮正向", + "added": [ + "started", + "started" + ] + }, + { + "step": "第 3 轮正向", + "added": [ + "started", + "started" + ] + }, + { + "step": "第 3 轮消融", + "added": [] + }, + { + "step": "第 4 轮正向", + "added": [ + "started", + "started" + ] + }, + { + "step": "第 5 轮正向", + "added": [ + "started", + "started" + ] + } + ] + }, + "expected": { + "afterEachStep": [ + { + "step": "基线", + "added": [ + "started" + ], + "charged": 1, + "occupied": 1, + "balance": 12, + "forwardK": 2, + "ablationMax": 11 + }, + { + "step": "第 1 轮正向", + "added": [ + "started", + "started" + ], + "charged": 3, + "occupied": 3, + "balance": 10, + "forwardK": 2, + "ablationMax": 9 + }, + { + "step": "第 1 轮消融", + "added": [ + "started", + "started" + ], + "charged": 5, + "occupied": 5, + "balance": 8, + "forwardK": 2, + "ablationMax": 7 + }, + { + "step": "第 2 轮正向", + "added": [ + "started", + "started" + ], + "charged": 7, + "occupied": 7, + "balance": 6, + "forwardK": 2, + "ablationMax": 5 + }, + { + "step": "第 3 轮正向", + "added": [ + "started", + "started" + ], + "charged": 9, + "occupied": 9, + "balance": 4, + "forwardK": 2, + "ablationMax": 3 + }, + { + "step": "第 3 轮消融", + "added": [], + "charged": 9, + "occupied": 9, + "balance": 4, + "forwardK": 2, + "ablationMax": 3 + }, + { + "step": "第 4 轮正向", + "added": [ + "started", + "started" + ], + "charged": 11, + "occupied": 11, + "balance": 2, + "forwardK": 2, + "ablationMax": 1 + }, + { + "step": "第 5 轮正向", + "added": [ + "started", + "started" + ], + "charged": 13, + "occupied": 13, + "balance": 0, + "forwardK": 0, + "ablationMax": 0 + } + ] + }, + "note": "每一步追加的运行记录状态,累计后算余额。余额序列 12,10,8,6,4,4,2,0。forwardK = min(每批上限, 余额);ablationMax = 余额−1(余额 ≤ 1 时 0)。" + }, + { + "name": "balance_mixed_states", + "section": "第七部分;约定 35–36", + "params": { + "totalRuns": 6, + "batchCap": 2 + }, + "input": { + "runs": [ + "started", + "reserved", + "notStarted", + "started", + "reserved" + ] + }, + "expected": { + "charged": 2, + "occupied": 4, + "balance": 2, + "forwardK": 2, + "ablationMax": 1 + }, + "note": "started 扣次;reserved(含状态未知)占着名额;notStarted 退还。已扣 2,占用 4,余额 2。" + }, + { + "name": "balance_0", + "section": "第七部分;约定 36", + "params": { + "totalRuns": 3, + "batchCap": 2 + }, + "input": { + "runs": [ + "started", + "started", + "started" + ] + }, + "expected": { + "charged": 3, + "occupied": 3, + "balance": 0, + "forwardK": 0, + "ablationMax": 0 + }, + "note": "余额 0、1、2 时的正向批量与消融上限。" + }, + { + "name": "balance_1", + "section": "第七部分;约定 36", + "params": { + "totalRuns": 3, + "batchCap": 2 + }, + "input": { + "runs": [ + "started", + "started" + ] + }, + "expected": { + "charged": 2, + "occupied": 2, + "balance": 1, + "forwardK": 1, + "ablationMax": 0 + }, + "note": "余额 0、1、2 时的正向批量与消融上限。" + }, + { + "name": "balance_2", + "section": "第七部分;约定 36", + "params": { + "totalRuns": 3, + "batchCap": 2 + }, + "input": { + "runs": [ + "started" + ] + }, + "expected": { + "charged": 1, + "occupied": 1, + "balance": 2, + "forwardK": 2, + "ablationMax": 1 + }, + "note": "余额 0、1、2 时的正向批量与消融上限。" + }, + { + "name": "seed_study-a_r1", + "section": "约定 39–40", + "params": {}, + "input": { + "studyId": "study-a", + "round": 1, + "version": 1, + "candidates": [ + "c1", + "c2", + "c3", + "c4", + "c5" + ], + "batchSize": 2, + "cells": [ + "p1", + "p2", + "p3", + "p4" + ], + "cellCount": 2, + "candidatesMany": [ + "c1", + "c2", + "c3", + "c4", + "c5", + "c6", + "c7", + "c8", + "c9", + "c10", + "c11", + "c12" + ], + "cellsMany": [ + "p1", + "p2", + "p3", + "p4", + "p5", + "p6", + "p7", + "p8", + "p9", + "p10", + "p11", + "p12" + ], + "manyCount": 4 + }, + "expected": { + "canonical": "{\"round\":1,\"studyId\":\"study-a\",\"version\":1}", + "seed": "39855ea9f581fcad6cb7710ac02151da468f2300ef72fefbbdabb3e191f62ff0", + "first6BytesHex": [ + "d2d2a16d557a", + "c85c9f2d75ac", + "61392fbb6bbb" + ], + "uniformInt10First5": [ + 2, + 0, + 7, + 0, + 2 + ], + "batchSample": [ + "c2", + "c3" + ], + "cellSample": [ + "p3", + "p4" + ], + "batchSampleMany": [ + "c5", + "c7", + "c9", + "c10" + ], + "cellSampleMany": [ + "p5", + "p7", + "p9", + "p10" + ] + }, + "note": "种子 = sha256(规范 JSON {round, studyId, version})。uniformInt:sha256(`${seed}:${counter}`) 前 6 字节大端,counter 从 0 起;uniformInt10First5 是同一个生成器连续 5 次。batchSample、cellSample 各自用一个从 counter=0 开始的新生成器,部分 Fisher–Yates 后取前 size 个再排序。候选、因子编号都按自然序(c2 < c10、p2 < p10);*Many 两项用 12 个编号核对这一点。" + }, + { + "name": "seed_15-real-1_r3", + "section": "约定 39–40", + "params": {}, + "input": { + "studyId": "15-real-1", + "round": 3, + "version": 1, + "candidates": [ + "c1", + "c2", + "c3", + "c4", + "c5" + ], + "batchSize": 2, + "cells": [ + "p1", + "p2", + "p3", + "p4" + ], + "cellCount": 2, + "candidatesMany": [ + "c1", + "c2", + "c3", + "c4", + "c5", + "c6", + "c7", + "c8", + "c9", + "c10", + "c11", + "c12" + ], + "cellsMany": [ + "p1", + "p2", + "p3", + "p4", + "p5", + "p6", + "p7", + "p8", + "p9", + "p10", + "p11", + "p12" + ], + "manyCount": 4 + }, + "expected": { + "canonical": "{\"round\":3,\"studyId\":\"15-real-1\",\"version\":1}", + "seed": "cb9cc4dbd939af39de4852c42806f3471e90bd0e16427ced09087345586506ff", + "first6BytesHex": [ + "f5b64e27d768", + "0b0216f8610e", + "b48a7659e2b5" + ], + "uniformInt10First5": [ + 8, + 4, + 7, + 7, + 6 + ], + "batchSample": [ + "c1", + "c4" + ], + "cellSample": [ + "p1", + "p3" + ], + "batchSampleMany": [ + "c6", + "c9", + "c10", + "c11" + ], + "cellSampleMany": [ + "p6", + "p9", + "p10", + "p11" + ] + }, + "note": "种子 = sha256(规范 JSON {round, studyId, version})。uniformInt:sha256(`${seed}:${counter}`) 前 6 字节大端,counter 从 0 起;uniformInt10First5 是同一个生成器连续 5 次。batchSample、cellSample 各自用一个从 counter=0 开始的新生成器,部分 Fisher–Yates 后取前 size 个再排序。候选、因子编号都按自然序(c2 < c10、p2 < p10);*Many 两项用 12 个编号核对这一点。" + } + ] +} diff --git a/tests/fixtures/rules/rules_ref.py b/tests/fixtures/rules/rules_ref.py new file mode 100644 index 0000000..01e1210 --- /dev/null +++ b/tests/fixtures/rules/rules_ref.py @@ -0,0 +1,784 @@ +"""rules 预期值的独立参考实现(共享库,本身不生成文件)。 + +用途:被同目录的 gen_forward_info.py、gen_ablation.py 导入,算出预期值写成 JSON, +供 TypeScript 的 rules 测试读取。只用 Python 标准库。 + +独立性:本文件只照 docs/方法.md 第二部分、4.2、第六、七部分的文字, +以及用户确认的约定表(scratchpad/conventions.md,下称"约定 N")写成, +没有读过 src/rules/ 和参考项目的实现代码。 + +算法上刻意和"照约定逐段积分 1−∏F"的写法分开: +- 分布全部用有理数(fractions.Fraction,浮点输入先精确转成有理数)表示; +- 期望一律用 Stieltjes 积分 E[g(M)] = ∫ g(t)·F_M'(t)dt 精确求, + 其中 M 是若干独立变量的最大值,F_M = ∏F(不是 ∫[1−∏F]dt 那条公式); +- 最小值通过取负号转成最大值; +- 部分格信息价值的网格算法是方法规定的确定近似(6.7、约定 27),只能照规定做,用浮点。 + +记号:一条"点列"是 [(x, y), ...],x 严格递增、y 从 0 到 1 不减。三个数严格递增(方法.md 第五部分), +折线连续,"低于 x"与"不高于 x"的把握相同。 +""" + +from fractions import Fraction as Fr +from itertools import combinations +import bisect +import functools +import math +import re + +# ---------------------------------------------------------------- 基础 + +def fr(x): + """浮点或整数精确转成有理数。""" + return x if isinstance(x, Fr) else Fr(x) + + +def to_float(x): + return float(x) + + +def polyline(q1, q5, q9): + """6.2:三个数 → 五点折线(有理数)。""" + q1, q5, q9 = fr(q1), fr(q5), fr(q9) + if not (q1 < q5 < q9): + raise ValueError("三个数必须严格递增 q1 < q5 < q9(方法.md 第五部分)") + return [(q1 - (q5 - q1), Fr(0)), (q1, Fr(1, 10)), (q5, Fr(1, 2)), + (q9, Fr(9, 10)), (q9 + (q9 - q5), Fr(1))] + + +def uniform_pts(lo, hi): + """[lo, hi] 上均匀分布的点列。""" + return [(fr(lo), Fr(0)), (fr(hi), Fr(1))] + + +def reflect(pts, b): + """X → b − X 的点列:P(b−X < y) = 1 − P(X ≤ b−y)。点反序,横坐标 b−x,纵坐标 1−y。""" + b = fr(b) + return [(b - x, 1 - y) for (x, y) in reversed(pts)] + + +def shift(pts, b): + """X → X − b。""" + b = fr(b) + return [(x - b, y) for (x, y) in pts] + + +def negate(pts): + """X → −X。""" + return reflect(pts, 0) + + +# 单条点列"低于 x"的把握 F(x)。 +def pts_left(pts, x): + xs = [p[0] for p in pts] + i = bisect.bisect_left(xs, x) - 1 # 最后一个 x_i < x + if i < 0: + return Fr(0) + if i == len(pts) - 1: + return Fr(1) + (x0, y0), (x1, y1) = pts[i], pts[i + 1] + return y0 + (y1 - y0) * (x - x0) / (x1 - x0) # x0 < x ≤ x1 + + +def pts_density(pts, x): + """约定 4:所在段斜率,段左闭右开(落在折点上取右边那段);范围外 0。""" + x = fr(x) + for (x0, y0), (x1, y1) in zip(pts, pts[1:]): + if x0 <= x < x1: + return (y1 - y0) / (x1 - x0) + return Fr(0) + + +class Dist: + """若干点列的加权混合(权重和为 1)。""" + + def __init__(self, comps): + self.comps = [(fr(w), p) for (w, p) in comps if fr(w) != 0] + + def left(self, x): + x = fr(x) + return sum((w * pts_left(p, x) for w, p in self.comps), Fr(0)) + + def breakpoints(self): + s = set() + for _, p in self.comps: + for x, _ in p: + s.add(x) + return s + + def lo(self): + return min(self.breakpoints()) + + def hi(self): + return max(self.breakpoints()) + + +def single(pts): + return Dist([(1, pts)]) + + +# ---------------------------------------------------------------- 多项式(有理系数,低次在前) + +def pmul(a, b): + out = [Fr(0)] * (len(a) + len(b) - 1) + for i, ai in enumerate(a): + if ai == 0: + continue + for j, bj in enumerate(b): + out[i + j] += ai * bj + return out + + +def pderiv(a): + return [i * a[i] for i in range(1, len(a))] or [Fr(0)] + + +def pint(a, lo, hi): + """∫_lo^hi a(t) dt。""" + s = Fr(0) + for i, c in enumerate(a): + s += c * (hi ** (i + 1) - lo ** (i + 1)) / (i + 1) + return s + + +def expect_max(dists, floor0=False): + """E[max_i X_i](各 X_i 独立),floor0=True 时求 E[max(0, max_i X_i)]。 + + Stieltjes 精确积分:F_M = ∏F_i 连续;相邻折点之间各 F_i 都是一次函数,∏ 是多项式,对 t·(∏)' 精确积分。 + floor0 时 0 以下的把握都算在 0 上,贡献 0。 + """ + bps = sorted(set().union(*[d.breakpoints() for d in dists])) + total = Fr(0) + for a, b in zip(bps, bps[1:]): + if floor0 and b <= 0: + continue + # 用平移后的变量 u = t − a 降低有理数的大小 + w = b - a + poly = [Fr(1)] + for d in dists: + fa, fb = d.left(a), d.left(b) + poly = pmul(poly, [fa, (fb - fa) / w]) + dp = pderiv(poly) + lo = Fr(0) + if floor0 and a < 0: + lo = -a + # t = u + a + integrand = pmul([a, Fr(1)], dp) + total += pint(integrand, lo, w) + return total + + +def expect_min(dists): + """E[min_i X_i] = −E[max_i (−X_i)]。""" + neg = [Dist([(w, negate(p)) for w, p in d.comps]) for d in dists] + return -expect_max(neg) + + +def mean(d): + return expect_max([d]) + + +def quantile(d, p): + """约定 8:inf{x : F(x) ≥ p},精确求。F 连续、逐段一次;几条折线之间有空当时 F 走平,取平段左端。""" + p = fr(p) + bps = sorted(d.breakpoints()) + for i, a in enumerate(bps): + if d.left(a) >= p: + return a + b = bps[i + 1] + fa, fb = d.left(a), d.left(b) + if fb >= p: + return a + (p - fa) * (b - a) / (fb - fa) + return bps[-1] + + +# ---------------------------------------------------------------- 方向与合并 + +def improvement_pts(raw_pts, b, direction): + """约定 5:分数原值的点列 → 改进量 Y 的点列。""" + if direction == "maximize": + return shift(raw_pts, b) + if direction == "minimize": + return reflect(raw_pts, b) + raise ValueError(direction) + + +def improve(x, ref, direction): + """x 相对 ref 的改进量(按方向):越大越好时 x − ref。""" + x, ref = fr(x), fr(ref) + return x - ref if direction == "maximize" else ref - x + + +def reserve_bounds(pts_list): + """6.3 / 约定 6:全部点列的最低首点 L、最高末点 U,开放预留铺在 [L−W/2, U+W/2]。""" + L = min(p[0][0] for p in pts_list) + U = max(p[-1][0] for p in pts_list) + W = U - L + return L - W / 2, U + W / 2 + + +def forward_dists(cands, b, direction, lam): + """正向合并:cands = {候选编号: [每位研究者的三个数]},返回 (改进量上的合并分布, 原值上的合并分布, 预留端点)。 + + 每人一份、等权;开放预留取本次选批全部候选、全部人的折线(含池外候选)。 + """ + lam = fr(lam) + raw = {c: [polyline(*t) for t in triples] for c, triples in cands.items()} + allraw = [p for ps in raw.values() for p in ps] + rlo, rhi = reserve_bounds(allraw) + ylist = [improvement_pts(p, b, direction) for p in allraw] + ylo, yhi = reserve_bounds(ylist) + out_y, out_raw = {}, {} + for c, ps in raw.items(): + n = len(ps) + out_y[c] = Dist([((1 - lam) / n, improvement_pts(p, b, direction)) for p in ps] + + [(lam, uniform_pts(ylo, yhi))]) + out_raw[c] = Dist([((1 - lam) / n, p) for p in ps] + [(lam, uniform_pts(rlo, rhi))]) + return out_y, out_raw, (ylo, yhi), (rlo, rhi) + + +def explanation_weights(authors, lam): + """6.7 / 约定 24:解释合起来 1−λ,研究者等权,同一人几条平分。返回与 authors 同序的权重。""" + lam = fr(lam) + n_auth = len(set(authors)) + cnt = {a: authors.count(a) for a in set(authors)} + return [(1 - lam) / n_auth / cnt[a] for a in authors] + + +def cell_dist(triples, authors, lam): + """消融一格的成分混合(原值):各解释折线 + 这一格自己的开放预留。""" + pts = [polyline(*t) for t in triples] + lo, hi = reserve_bounds(pts) + ws = explanation_weights(authors, lam) + return Dist([(w, p) for w, p in zip(ws, pts)] + [(fr(lam), uniform_pts(lo, hi))]), (lo, hi) + + +# ---------------------------------------------------------------- 正向:入池、V、选批、读数 + +def pool_prob(dy, eps): + """约定 9:P(Y < −ε)。""" + return dy.left(-fr(eps)) + + +def V(dys): + """6.4:批内最好期望 E[max(0, max_c Y_c)]。空集为 0。""" + if not dys: + return Fr(0) + return expect_max(dys, floor0=True) + + +def _str_cmp(a, b): + """JS 默认字符串比较(UTF-16 码元)。""" + x, y = a.encode("utf-16-be"), b.encode("utf-16-be") + return -1 if x < y else (1 if x > y else 0) + + +def _seg_cmp(x, y): + if not (x[0].isdigit() and y[0].isdigit()): + return _str_cmp(x, y) + tx, ty = x.lstrip("0") or "0", y.lstrip("0") or "0" + if len(tx) != len(ty): + return -1 if len(tx) < len(ty) else 1 + return _str_cmp(tx, ty) + + +def cmp_ids(a, b): + """编号的自然序(方法"编号最小"指数字意义上的大小):c2 < c10,researcher-2 < researcher-10,p2 < p10。 + + 编号切成数字段和非数字段逐段比:都是数字段时先比去掉前导零后的位数、再比字符串;否则按字符串比; + 段先用完的排前面;逐段都相同时按整串字符串比。候选、研究者、入口、因子、解释编号都用它。 + """ + sa, sb = re.findall(r"\d+|\D+", a), re.findall(r"\d+|\D+", b) + for x, y in zip(sa, sb): + c = _seg_cmp(x, y) + if c: + return c + if len(sa) != len(sb): + return -1 if len(sa) < len(sb) else 1 + return _str_cmp(a, b) + + +id_key = functools.cmp_to_key(cmp_ids) + + +def combo_key(combo): + """组合的先后:格数少的在前,再按编号逐个比。""" + return (len(combo), [id_key(c) for c in combo]) + + +def select_batch(dys, pool, k): + """约定 11:枚举池中所有 min(k,|池|) 组合,V 最大;V 完全相等时按排序后编号逐个比(自然序),小者优先。""" + size = min(k, len(pool)) + ids = sorted(pool, key=id_key) + table = [] + for combo in combinations(ids, size): + table.append((list(combo), V([dys[c] for c in combo]))) + best = None + for combo, v in table: + if best is None or v > best[1]: + best = (combo, v) + elif v == best[1] and combo_key(combo) < combo_key(best[0]): + best = (combo, v) + return table, best + + +def adoption_line(eps, B, Kstar, direction): + """6.5 / 约定 15。""" + eps = fr(eps) + if Kstar is None: + return eps + room = improve(Kstar, B, direction) # 越大越好时 K* − B + line = min(eps, room / 4) + return max(line, eps / 100) + + +# ---------------------------------------------------------------- 消融结算 + +def supports(B, full, offs, direction): + """6.6 / 约定 17:撑(p) = improvement(关掉 p, 完整)/G;G ≤ 0 抛错。offs = {因子: 分数或 None}。""" + G = improve(full, B, direction) + if G <= 0: + raise ValueError("G ≤ 0") + return G, {p: (None if x is None else improve(full, x, direction) / G) for p, x in offs.items()} + + +def label(sup): + """6.6 表,从上往下。sup = {因子: 撑 或 None(没有有效分数)}。""" + vals = [v for v in sup.values() if v is not None] + if any(v < 0 for v in vals): + return "negative", None + if any(v is None for v in sup.values()): + return "incomplete", None + i = sum(vals) - 1 + if all(v > Fr(9, 10) for v in vals): + return "indispensable", i + if i > Fr(1, 5): + return "complementary", i + if i < -Fr(1, 5): + return "substitute", i + return "independent", i + + +def factor_key(p): + """因子编号也按自然序。""" + return id_key(p) + + +# ---------------------------------------------------------------- 信息价值 + +class AblationModel: + """一次消融的成分模型。cells: 因子编号列表(p1…);expl: [{id, author, cells: {p: (q1,q5,q9)}}]。""" + + def __init__(self, B, full, Kstar, eps, lam, direction, cells, expl): + self.B, self.full, self.eps, self.lam, self.dir = fr(B), fr(full), fr(eps), fr(lam), direction + self.Kstar = None if Kstar is None else fr(Kstar) + self.cells = sorted(cells, key=factor_key) + self.G = improve(self.full, self.B, direction) + if self.G <= 0: + raise ValueError("G ≤ 0") + self.h = Fr(1) if self.Kstar is None else max(improve(self.Kstar, self.full, direction), self.eps / 100) + authors = [e["author"] for e in expl] + self.weights = explanation_weights(authors, lam) + [self.lam] + self.names = [e["id"] for e in expl] + ["reserve"] + # comp_pts[j][p]:成分 j 在格 p 的点列 + self.comp_pts = [{p: polyline(*e["cells"][p]) for p in self.cells} for e in expl] + self.triples = {p: [tuple(float(q) for q in e["cells"][p]) for e in expl] for p in self.cells} + res = {} + self.reserve = {} + for p in self.cells: + lo, hi = reserve_bounds([c[p] for c in self.comp_pts]) + self.reserve[p] = (lo, hi) + res[p] = uniform_pts(lo, hi) + self.comp_pts.append(res) + # 成分在每格的期望收益(精确) + self.Epay = [{p: self.payoff(mean(single(c[p]))) for p in self.cells} for c in self.comp_pts] + + def payoff(self, x): + """约定 25:收益 = h·improvement(x, 完整)/G。""" + return self.h * improve(self.full, x, self.dir) / self.G + + def prior_payoffs(self, weights=None): + w = self.weights if weights is None else weights + return {p: sum(wj * E[p] for wj, E in zip(w, self.Epay)) for p in self.cells} + + def g0(self): + return max(self.prior_payoffs().values()) + + def voi_full_exact(self): + """全测 VOI,一维精确:Σ_j w_j E_j[max_p 收益_p] − max_p Σ_j w_j E_j[收益_p]。 + + 收益是 x 的同一个仿射函数,max 收益对应 min x(越大越好)或 max x(越小越好)。 + """ + first = Fr(0) + for wj, c in zip(self.weights, self.comp_pts): + ds = [single(c[p]) for p in self.cells] + ext = expect_min(ds) if self.dir == "maximize" else expect_max(ds) + first += wj * self.payoff(ext) + return first - self.g0() + + # ---- 网格(约定 27),浮点 + @staticmethod + def m_for(k): + m = int(round(4_000_000 ** (1 / k))) # 先取浮点近似,再用整数运算校正 + while m ** k > 4_000_000: + m -= 1 + while (m + 1) ** k <= 4_000_000: + m += 1 + return m + + def cell_grid(self, p, m): + """一格的小段:返回 [(中点, [各成分质量])] 与小段说明。""" + fpts = self.float_pts(p) + bps = sorted(set(x for pts in fpts for x, _ in pts)) + nseg = len(bps) - 1 + s = min(20, m // nseg) + edges = [] + if s >= 1: + for a, b in zip(bps, bps[1:]): + for i in range(s): + edges.append(a + (b - a) * i / s) + edges.append(bps[-1]) + else: + a, b = bps[0], bps[-1] + for i in range(m): + edges.append(a + (b - a) * i / m) + edges.append(b) + out = [] + n = len(edges) - 1 + for i in range(n): + lo, hi = edges[i], edges[i + 1] + mass = [] + for fp in fpts: + mass.append(f_left(fp, hi) - f_left(fp, lo)) + out.append(((lo + hi) / 2, mass)) + return out, {"breakpoints": len(bps), "segments": nseg, "s": s, "subsegments": n} + + def float_pts(self, p): + """网格用的浮点点列:照方法原式在浮点上算尾巴 q1−(q5−q1)、q9+(q9−q5) 与预留 L−W/2、U+W/2。 + + 网格的折点个数决定 s,横坐标差一个 ulp 就可能多出或少掉一段(例如 1.4−(1.75−1.4) 得到 + 1.0499999999999998,与另一条折线的 1.05 并存),所以这里必须和实现在同一套浮点式子上取折点。 + """ + out = [] + for (q1, q5, q9) in self.triples[p]: + out.append([(q1 - (q5 - q1), 0.0), (q1, 0.1), (q5, 0.5), (q9, 0.9), (q9 + (q9 - q5), 1.0)]) + L = min(o[0][0] for o in out) + U = max(o[-1][0] for o in out) + W = U - L + out.append([(L - W / 2, 0.0), (U + W / 2, 1.0)]) + return out + + def voi_grid(self, S): + """约定 27 的网格 VOI(浮点)。S:测到的格(因子编号列表)。""" + S = sorted(S, key=factor_key) + k = len(S) + m = self.m_for(k) + grids, info = [], {} + for p in S: + g, inf = self.cell_grid(p, m) + grids.append(g) + info[p] = inf + J = len(self.weights) + w = [float(x) for x in self.weights] + unmeas = [p for p in self.cells if p not in S] + Eun = [[float(self.Epay[j][q]) for j in range(J)] for q in unmeas] + pay = [[float(self.payoff(fr(mid))) for mid, _ in g] for g in grids] + total = 0.0 + # 逐层展开组合:partial = (各成分联合质量向量, 已测格中点收益的最大值) + def rec(level, joint, bestpay): + nonlocal total + g = grids[level] + pl = pay[level] + if level == k - 1: + for idx, (_, mass) in enumerate(g): + jv = [joint[j] * mass[j] for j in range(J)] + marg = sum(jv) + if marg == 0: + continue + best = max(bestpay, pl[idx]) + for E in Eun: + v = sum(jv[j] * E[j] for j in range(J)) / marg + if v > best: + best = v + total += best * marg + else: + for idx, (_, mass) in enumerate(g): + jv = [joint[j] * mass[j] for j in range(J)] + if not any(jv): + continue + rec(level + 1, jv, max(bestpay, pl[idx])) + rec(0, w, -math.inf) + prior = max(float(v) for v in self.prior_payoffs().values()) + return total - prior, {"k": k, "m": m, "cells": info} + + def voi_partial_exact_k1(self, p): + """只测一格时的精确参考值(不是方法规定的算法,只用来估网格误差)。 + + 相邻折点之间各成分密度是常数,后验权重不随 x 变,没测格的估计收益是常数 c; + 测到的格收益是 x 的一次函数 A(x)。∫ max(A(x), c)·marg(x) dx 精确求。 + """ + J = len(self.weights) + others = [q for q in self.cells if q != p] + bps = sorted(set(x for c in self.comp_pts for x, _ in c[p])) + total = Fr(0) + def best_const(wv): + s = sum(wv) + return max(sum(wv[j] * self.Epay[j][q] for j in range(J)) / s for q in others) if others else None + for a, b in zip(bps, bps[1:]): + wv = [self.weights[j] * pts_density(self.comp_pts[j][p], a) for j in range(J)] + dens = sum(wv) + if dens == 0: + continue + c = best_const(wv) + # A(x) = alpha + beta x + pa, pb = self.payoff(a), self.payoff(b) + beta = (pb - pa) / (b - a) + alpha = pa - beta * a + def integ(lo, hi, poly): + return dens * pint(poly, lo, hi) + if c is None: + total += integ(a, b, [alpha, beta]) + continue + if beta == 0: + total += dens * (b - a) * max(alpha, c) + continue + xc = (c - alpha) / beta # A(xc) = c + cuts = [a] + ([xc] if a < xc < b else []) + [b] + for lo, hi in zip(cuts, cuts[1:]): + midA = alpha + beta * (lo + hi) / 2 + if midA >= c: + total += integ(lo, hi, [alpha, beta]) + else: + total += dens * (hi - lo) * c + return total - self.g0() + + +def f_left(fp, x): + xs = [q[0] for q in fp] + i = bisect.bisect_left(xs, x) - 1 + if i < 0: + return 0.0 + if i == len(fp) - 1: + return 1.0 + (x0, y0), (x1, y1) = fp[i], fp[i + 1] + return y0 + (y1 - y0) * (x - x0) / (x1 - x0) + + +# ---------------------------------------------------------------- 价值、成本、选格 + +def m_prime(balance, size, K, r): + num = balance - size + if num <= 0: + return 0, Fr(0) + R = -(-num // K) + r = fr(r) + return R, sum((r ** j for j in range(R)), Fr(0)) + + +def cost(g0, size, K): + g = max(Fr(0), fr(g0)) + return 2 * g * (Fr(K + size, K + size + 1) - Fr(K, K + 1)) + + +TOL = 1e-12 + + +def top_combo(rows): + """6.7 选格第 2 条:最大净值 M;净值 ≥ M − 1e-12 的视为相同,先取格数少的,再按因子编号逐个比取小的。""" + M = max(n for _, n in rows) + near = [r for r in rows if r[1] >= M - TOL] + return min(near, key=lambda r: combo_key(list(r[0]))) + + +def select_cells(cells, balance, net_of): + """约定 29 / 方法 6.7 选格:信息价值减成本选格。net_of(组合元组) → 净值(浮点)。 + + 先只留净值 > 1e-12 的组合,一个都没有就不测;在留下的组合里按 top_combo 取。 + “含这一格的净值最大组合”只是记录,不受 > 1e-12 的限制,在含这一格的全部组合里按 top_combo 取。 + 返回 (选中组合, 选中净值, 各组合净值表, 没选中格的说明)。 + """ + cells = sorted(cells, key=factor_key) + maxn = min(len(cells), balance - 1) + table = [] + for n in range(1, maxn + 1): + for combo in combinations(cells, n): + table.append((combo, net_of(combo))) + best, bestnet = (), 0.0 + eligible = [r for r in table if r[1] > TOL] + if eligible: + best, bestnet = top_combo(eligible) + unselected = {} + for c in cells: + if c in best: + continue + reason = "insufficientBudget" if len(best) + 1 > balance - 1 else "notWorthCost" + cand = [(combo, net) for combo, net in table if c in combo] + if not cand: + unselected[c] = {"reason": reason, "bestCombo": None, "bestNet": None} + else: + bc, bn = top_combo(cand) + unselected[c] = {"reason": reason, "bestCombo": list(bc), "bestNet": bn} + return list(best), bestnet, table, unselected + + +# ---------------------------------------------------------------- 押的因子 + +def bet(model, outcomes): + """6.8 / 约定 30。outcomes = {因子: {"kind": ..., "value": 分数或缺}}。""" + valid = {p: fr(o["value"]) for p, o in outcomes.items() if o["kind"] == "valid"} + J = len(model.weights) + raw = [] + for j in range(J): + v = model.weights[j] + for p, x in valid.items(): + v *= pts_density(model.comp_pts[j][p], x) + raw.append(v) + s = sum(raw) + updated = s != 0 + w = [v / s for v in raw] if updated else list(model.weights) + est = model.prior_payoffs(w) + pay = {} + for p in model.cells: + pay[p] = model.payoff(valid[p]) if p in valid else est[p] + pick = None + for p in model.cells: + if pick is None or pay[p] > pay[pick]: + pick = p + neg = [] + for p in model.cells: + if p in valid and improve(model.full, valid[p], model.dir) < 0: + neg.append({"factor": p, "offBetterBy": float(improve(model.full, valid[p], model.dir) * -1)}) + if neg: + status = "noBetNegativeSupport" + elif pay[pick] <= 0: + status = "noBetNonPositiveGain" + else: + status = "bet" + return { + "weightsUpdated": updated, + "weights": {n: float(x) for n, x in zip(model.names, w)}, + "payoffs": {p: float(v) for p, v in pay.items()}, + "picked": pick, + "predictedGain": float(pay[pick]), + "status": status, + "negativeFactors": neg, + } + + +# ---------------------------------------------------------------- 信息账 + +def edge(b, delta, j): + """约定 31/32:小格边界一律按浮点 b + j·Δ 算(和实现用同一套边界)。""" + return b + j * delta + + +def bin_of(v, b, delta): + j = math.floor((v - b) / delta) + while edge(b, delta, j) > v: + j -= 1 + while edge(b, delta, j + 1) <= v: + j += 1 + return j + + +def bin_masses(d, b, delta): + """覆盖分布全部折点的小格质量(有理数),返回 [(j, p)]。""" + lo, hi = float(d.lo()), float(d.hi()) + j0 = bin_of(lo, b, delta) + j1 = bin_of(hi, b, delta) + # 浮点的 lo/hi 与有理数端点可能差一点;两侧各多切一格,质量为 0 的格不影响熵 + out = [] + prev = d.left(edge(b, delta, j0 - 1)) + for j in range(j0 - 1, j1 + 2): + cur = d.left(edge(b, delta, j + 1)) + out.append((j, cur - prev)) + prev = cur + assert d.left(edge(b, delta, j0 - 1)) == 0 and prev == 1, "切格范围没有覆盖全部质量" + return out + + +def world_bits(d, b, delta): + terms = [] + for _, p in bin_masses(d, b, delta): + if p > 0: + pf = float(p) + terms.append(-pf * math.log2(pf)) + return math.fsum(terms) + + +def surprise(d, v, b, delta): + j = bin_of(v, b, delta) + p = d.left(edge(b, delta, j + 1)) - d.left(edge(b, delta, j)) + if p == 0: + return {"status": "priorZero", "bin": j, "p": 0.0} + return {"status": "ok", "bin": j, "p": float(p), "surprise": -math.log2(float(p))} + + +def disagreement(person_dists, b, delta): + avg = Dist([(Fr(1, len(person_dists)) * w, p) for d in person_dists for w, p in d.comps]) + hb = world_bits(avg, b, delta) + each = [world_bits(d, b, delta) for d in person_dists] + return hb - math.fsum(each) / len(each), hb, each + + +def landing(q1, q5, q9, v): + """约定 34。""" + if v < q1: + return "below10" + if v < q5: + return "from10to50" + if v <= q9: + return "from50to90" + return "above90" + + +# ---------------------------------------------------------------- 入口分配、余额、种子 + +def allocate_entries(researchers, entries, prefs): + """4.2:researchers 研究者编号;entries = [{id, author}];prefs = {研究者: [入口编号…]}。 + + 研究者和入口都先按编号(自然序)排好,“编号最小”按编号判,与传入次序无关。 + """ + researchers = sorted(researchers, key=id_key) + entries = sorted(entries, key=lambda e: id_key(e["id"])) + author = {e["id"]: e["author"] for e in entries} + taken = {} + result = {} + maxlen = max((len(v) for v in prefs.values()), default=0) + if len(researchers) == 1: + r = researchers[0] + return {r: {"entry": prefs[r][0], "choice": 1}} + for k in range(maxlen): + want = {} + for r in researchers: + if r in result or k >= len(prefs[r]): + continue + e = prefs[r][k] + if e in taken: + continue + want.setdefault(e, []).append(r) + for e in [x["id"] for x in entries]: + if e not in want: + continue + group = want[e] + if len(group) == 1: + win = group[0] + elif author[e] in group: + win = author[e] + else: + win = min(group, key=id_key) + result[win] = {"entry": e, "choice": k + 1} + taken[e] = win + for r in researchers: + if r in result: + continue + left = [x["id"] for x in entries if x["id"] not in taken] + e = left[0] + result[r] = {"entry": e, "choice": None} + taken[e] = r + return {r: result[r] for r in researchers} + + +def balance(total, runs): + started = sum(1 for s in runs if s == "started") + reserved = sum(1 for s in runs if s == "reserved") + return {"charged": started, "occupied": started + reserved, "balance": total - started - reserved} diff --git a/tests/rules-ablation.test.ts b/tests/rules-ablation.test.ts new file mode 100644 index 0000000..c02912a --- /dev/null +++ b/tests/rules-ablation.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ablationFinding, + inseparableGroups, + judgeExplanations, + listCells, + settleAblation, + sharedSettings, + supportOf, + synergyLabel, + totalGain, + type AblationCell, + type AblationExplanation, +} from "../src/rules/index.ts"; + +// 预期值来源写在每条测试旁边。 + +function close(actual: number | null, expected: number, tol = 1e-12): void { + assert.ok(actual !== null && Math.abs(actual - expected) <= tol, `实际 ${actual},预期 ${expected}`); +} + +test("6.6 示例:基线 1.00、完整 1.50,关 p1 得 1.45、关 p2 得 1.10:撑 0.10 与 0.80,i = −0.10,各管各", () => { + // 来源:方法.md 6.6 示例原文。 + assert.equal(totalGain(1, 1.5, "maximize"), 0.5); + const s1 = supportOf(1, 1.5, 1.45, "maximize"); + const s2 = supportOf(1, 1.5, 1.1, "maximize"); + close(s1, 0.1); + close(s2, 0.8); + const l = synergyLabel([s1, s2]); + close(l.interaction, -0.1); + assert.equal(l.label, "independent"); +}); + +test("6.6 越小越好时撑几成按方向折算", () => { + // 手算:基线 2、完整 1(越小越好,G = 1),关掉 p 得 1.5:撑 = (1.5 − 1)/1 = 0.5。 + assert.equal(supportOf(2, 1, 1.5, "minimize"), 0.5); +}); + +test("6.6 G ≤ 0 时直接报错", () => { + // 来源:方法.md 6.6"遇到 G ≤ 0 时直接报错"。 + assert.throws(() => totalGain(1.5, 1.5, "maximize"), /6\.6/); + assert.throws(() => totalGain(1.5, 1.4, "maximize"), /G = .*≤ 0/); + assert.throws(() => totalGain(1, 1.2, "minimize"), /6\.6/); + assert.throws(() => supportOf(1, 1, 0.5, "maximize"), /6\.6/); +}); + +test("6.6 配合关系标签按表从上往下判", () => { + // 来源:方法.md 6.6 的表;数字手取。 + assert.equal(synergyLabel([-0.1, 0.5]).label, "offBetter"); + assert.equal(synergyLabel([-0.1, null]).label, "offBetter"); // 撑为负先于"不全" + assert.equal(synergyLabel([0.5, null]).label, "incomplete"); + assert.equal(synergyLabel([0.5, null]).interaction, null); + assert.equal(synergyLabel([0.95, 0.91]).label, "indispensable"); + // 0.9 是严格大于:[0.9, 0.95] 不是缺一不可;i = 0.85 > 0.2,互补。 + assert.equal(synergyLabel([0.9, 0.95]).label, "complementary"); + assert.equal(synergyLabel([0.75, 0.5]).label, "complementary"); // i = 0.25 + assert.equal(synergyLabel([0.25, 0.5]).label, "substitute"); // i = −0.25 + assert.equal(synergyLabel([0.5, 0.5]).label, "independent"); // i = 0 + assert.equal(synergyLabel([0.5, 0.5, 0.25]).supportSum, 1.25); +}); + +test("列格:每个因子一格,按因子编号数字序", () => { + assert.deepEqual(listCells(["p10", "p2", "p1"]), ["p1", "p2", "p10"]); +}); + +const r = (q1: number, q5: number, q9: number) => ({ q1, q5, q9 }); + +function explanation(id: string, author: string, cells: Record): AblationExplanation { + return { id, author, cells: Object.entries(cells).map(([factor, [a, b, c]]) => ({ factor, range: r(a, b, c) })) }; +} + +test("6.6 五种结局里只有有效分数进入标签的求和", () => { + // 手算:基线 1、完整 1.5。p1 有效 1.45(撑 0.1);p2 证据不足、p3 没测、p4 跑不起来、p5 一起关(有分数 1.1)。 + // 只有 p1 有有效格,其余因子没有 → 不全。若把 p5 的 1.1 当成撑 0.8 算进去,会错贴成别的标签。 + const exps = [explanation("e1", "A", { p1: [1.4, 1.45, 1.5], p2: [1, 1.2, 1.4], p3: [1, 1.2, 1.4], p4: [1, 1.2, 1.4], p5: [1, 1.2, 1.4] })]; + const cells: AblationCell[] = [ + { factor: "p1", outcome: { kind: "valid", score: 1.45 } }, + { factor: "p2", outcome: { kind: "insufficient" } }, + { factor: "p3", outcome: { kind: "untested" } }, + { factor: "p4", outcome: { kind: "cannotRun" } }, + { factor: "p5", outcome: { kind: "joint", off: ["p5", "p1"], score: 1.1 } }, + ]; + const s = settleAblation({ + baseline: 1, + full: 1.5, + direction: "maximize", + epsilon: 0.01, + knownBest: null, + lambda: 0.1, + factors: ["p1", "p2", "p3", "p4", "p5"], + cells, + explanations: exps, + }); + assert.equal(s.label.label, "incomplete"); + close(s.cells[0]!.support, 0.1); + assert.deepEqual( + s.cells.slice(1).map((c) => [c.outcome, c.support, c.worldBits, c.surprise]), + [ + ["insufficient", null, null, null], + ["untested", null, null, null], + ["cannotRun", null, null, null], + ["joint", null, null, null], + ], + ); + assert.equal(typeof s.cells[0]!.worldBits, "number"); + // 只有 p1 判确认 / 否定。 + assert.deepEqual(s.judgments, [{ id: "e1", verdicts: [{ factor: "p1", verdict: "confirmed" }] }]); + + // 两个因子,p1 有效、p2 一起关有分数:仍是"不全",不是 0.1 + 0.8 的"各管各"。 + const two = settleAblation({ + baseline: 1, + full: 1.5, + direction: "maximize", + epsilon: 0.01, + knownBest: null, + lambda: 0.1, + factors: ["p1", "p2"], + cells: [ + { factor: "p1", outcome: { kind: "valid", score: 1.45 } }, + { factor: "p2", outcome: { kind: "joint", off: ["p1", "p2"], score: 1.1 } }, + ], + explanations: [explanation("e1", "A", { p1: [1.4, 1.45, 1.5], p2: [1, 1.2, 1.4] })], + }); + assert.equal(two.label.label, "incomplete"); +}); + +test("6.6 一起关是对称的:关 p 连带 q 与关 q 连带 p 是同一种设置,后者沿用前者", () => { + const s = sharedSettings([ + { factor: "p2", off: ["p2", "p1"] }, + { factor: "p3", off: ["p3"] }, + { factor: "p1", off: ["p1", "p2"] }, + ]); + assert.deepEqual(s, [ + { factor: "p1", key: "p1+p2", sameAs: null }, + { factor: "p2", key: "p1+p2", sameAs: "p1" }, + { factor: "p3", key: "p3", sameAs: null }, + ]); + // 不同集合不沿用。 + assert.deepEqual( + sharedSettings([ + { factor: "p1", off: ["p1", "p2"] }, + { factor: "p2", off: ["p2", "p3"] }, + ]).map((x) => x.sameAs), + [null, null], + ); +}); + +test("6.6 确认与否定:落在 [q1, q9] 内(含两端)确认,外面否定;只判有效格", () => { + const exps = [ + explanation("e1", "A", { p1: [1.45, 1.5, 1.55], p2: [1, 1.1, 1.2] }), // p1 实测 1.45 正好在 q1 上 + explanation("e2", "B", { p1: [1.3, 1.4, 1.45], p2: [1.2, 1.3, 1.4] }), // p1 实测正好在 q9 上;p2 1.1 在 q1 下 + ]; + const cells: AblationCell[] = [ + { factor: "p2", outcome: { kind: "valid", score: 1.1 } }, + { factor: "p1", outcome: { kind: "valid", score: 1.45 } }, + { factor: "p3", outcome: { kind: "untested" } }, + ]; + assert.deepEqual(judgeExplanations(exps, cells), [ + { + id: "e1", + verdicts: [ + { factor: "p1", verdict: "confirmed" }, + { factor: "p2", verdict: "confirmed" }, + ], + }, + { + id: "e2", + verdicts: [ + { factor: "p1", verdict: "confirmed" }, + { factor: "p2", verdict: "denied" }, + ], + }, + ]); +}); + +test("6.6 现有测法分不开:所有有效格上确认否定序列完全相同的两条以上解释归一组;没有有效格时不标", () => { + const j = (id: string, vs: Array<"confirmed" | "denied">) => ({ + id, + verdicts: vs.map((verdict, i) => ({ factor: `p${i + 1}`, verdict })), + }); + assert.deepEqual( + inseparableGroups([ + j("e1", ["confirmed", "denied"]), + j("e2", ["denied", "denied"]), + j("e3", ["confirmed", "denied"]), + j("e4", ["denied", "denied"]), + j("e5", ["confirmed", "confirmed"]), + ]), + [ + ["e1", "e3"], + ["e2", "e4"], + ], + ); + assert.deepEqual(inseparableGroups([j("e1", []), j("e2", [])]), []); + assert.deepEqual(inseparableGroups([j("e1", ["confirmed"]), j("e2", ["denied"])]), []); +}); + +test("4.3 ⑰ 消融中发现:比完整代码严格高出采纳线才过线;取分数最好的,同分取编号小的;一起关也算", () => { + // 手算:完整 1,ε = 0.25,没有已知最好,采纳线 0.25。 + const base = { full: 1, knownBest: null, epsilon: 0.25, direction: "maximize" as const }; + // 正好 1.25 不过线。 + assert.equal(ablationFinding({ ...base, cells: [{ factor: "p1", outcome: { kind: "valid", score: 1.25 } }] }).chosen, null); + // p1 有效 1.5,p2 一起关 1.75:取 p2。 + const f = ablationFinding({ + ...base, + cells: [ + { factor: "p1", outcome: { kind: "valid", score: 1.5 } }, + { factor: "p2", outcome: { kind: "joint", off: ["p1", "p2"], score: 1.75 } }, + { factor: "p3", outcome: { kind: "insufficient" } }, + ], + }); + assert.equal(f.line, 0.25); + assert.deepEqual(f.passed, [ + { factor: "p1", score: 1.5, improvement: 0.5 }, + { factor: "p2", score: 1.75, improvement: 0.75 }, + ]); + assert.equal(f.chosen, "p2"); + // 同分取因子编号小的(p2 < p10)。 + const tie = ablationFinding({ + ...base, + cells: [ + { factor: "p10", outcome: { kind: "valid", score: 1.5 } }, + { factor: "p2", outcome: { kind: "valid", score: 1.5 } }, + ], + }); + assert.equal(tie.chosen, "p2"); + // 一起关但没有分数的不算。 + assert.equal( + ablationFinding({ ...base, cells: [{ factor: "p1", outcome: { kind: "joint", off: ["p1", "p2"], score: null } }] }).chosen, + null, + ); + // 采纳线按 B = f(完整) 算:K* = 2,完整 1.5,(2 − 1.5)/4 = 0.125。 + assert.equal(ablationFinding({ ...base, knownBest: 2, full: 1.5, cells: [] }).line, 0.125); +}); + +test("同样的输入算两次,结算结果逐位相同", () => { + const input = { + baseline: 2.5, + full: 2.6, + direction: "maximize" as const, + epsilon: 0.01, + knownBest: 2.635983, + lambda: 0.1, + factors: ["p1", "p2"], + cells: [ + { factor: "p1", outcome: { kind: "valid" as const, score: 2.57 } }, + { factor: "p2", outcome: { kind: "valid" as const, score: 2.52 } }, + ], + explanations: [ + explanation("e1", "A", { p1: [2.55, 2.57, 2.59], p2: [2.5, 2.53, 2.56] }), + explanation("e2", "B", { p1: [2.52, 2.55, 2.6], p2: [2.54, 2.56, 2.58] }), + ], + }; + assert.deepEqual(settleAblation(input), settleAblation(input)); +}); + +test("4.3 ⑬ 消融结算按因子清单列格:漏掉的因子直接报错,不当成不全或缺一不可", () => { + // 失败场景(第二轮审阅):被采纳改法有 p1、p2 两个因子,只传 p1 一格。手算:基线 1、完整 2,p1 关掉得 1.05,撑 0.95。 + // 若只按传进来的格贴标签,唯一的撑 0.95 > 0.9 会错贴成"缺一不可"。 + const input = { + baseline: 1, + full: 2, + direction: "maximize" as const, + epsilon: 0.01, + knownBest: null, + lambda: 0.1, + factors: ["p1", "p2"], + cells: [{ factor: "p1", outcome: { kind: "valid" as const, score: 1.05 } }], + explanations: [explanation("e1", "A", { p1: [1, 1.05, 1.1], p2: [1.4, 1.5, 1.6] })], + }; + assert.throws(() => settleAblation(input), /4\.3 ⑬.*p2 没有对应的格/); + // 清单外的格也报错。 + assert.throws( + () => + settleAblation({ + ...input, + factors: ["p1"], + cells: [...input.cells, { factor: "p2", outcome: { kind: "untested" as const } }], + }), + /格 p2 不在/, + ); + // 两格都给齐时照常结算:撑 0.95 与 0.5(关 p2 得 1.5),i = 0.45 > 0.2,互补。 + const ok = settleAblation({ ...input, cells: [...input.cells, { factor: "p2", outcome: { kind: "valid" as const, score: 1.5 } }] }); + assert.equal(ok.label.label, "complementary"); +}); diff --git a/tests/rules-bits.test.ts b/tests/rules-bits.test.ts new file mode 100644 index 0000000..e089210 --- /dev/null +++ b/tests/rules-bits.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + authorVsMerged, + bitWidth, + cellIndex, + coverage, + disagreement, + placement, + surprise, + trackRecord, + worldBits, + type Mixture, +} from "../src/rules/index.ts"; + +// 预期值来源写在每条测试旁边。比特的例子取 ε = 25(Δ = 0.25),格边界在二进制里精确。 + +function close(actual: number, expected: number, tol = 1e-12): void { + assert.ok(Math.abs(actual - expected) <= tol, `实际 ${actual},预期 ${expected}`); +} + +/** 很窄的范围 (y − 0.01, y, y + 0.01),连尾巴 [y − 0.02, y + 0.02]。 */ +const narrow = (y: number) => ({ q1: y - 0.01, q5: y, q9: y + 0.01 }); + +const uniform = (lo: number, hi: number): Mixture => [ + { + weight: 1, + knots: [ + [lo, 0], + [hi, 1], + ], + }, +]; + +test("6.9 格宽 Δ = ε/100,跟随 ε", () => { + assert.equal(bitWidth(0.01), 0.01 / 100); + assert.equal(bitWidth(25), 0.25); +}); + +test("6.9 世界比特:从 b 起切;[0,1] 均匀切 4 格是 2 比特,错开 1/8 是 2.25 比特;整个落在一格里是 0", () => { + // 手算:[0,1] 均匀、b = 0、Δ = 0.25:四格各 1/4,H = 2。 + close(worldBits(uniform(0, 1), 0, 0.25), 2); + // [0.125, 1.125]:五格 1/8、1/4、1/4、1/4、1/8,H = 2 × (1/8 × 3) + 3 × (1/4 × 2) = 0.75 + 1.5 = 2.25。 + close(worldBits(uniform(0.125, 1.125), 0, 0.25), 2.25); + // 同一分布、b = 0.125:切口对齐,四格各 1/4,又是 2 比特。说明格从 b 起切。 + close(worldBits(uniform(0.125, 1.125), 0.125, 0.25), 2); + // [0.3, 0.31] 整个在 [0.25, 0.5) 这一格里:把握 1,0 比特。 + assert.equal(worldBits(uniform(0.3, 0.31), 0, 0.25), 0); +}); + +test("6.9 惊讶 = −log₂ p;正好在格边界上的实测归右边那格;事前把握为 0 时给明确状态", () => { + // 手算:[0,1] 均匀,实测 0.3 落在 [0.25, 0.5),p = 1/4,惊讶 2 比特。 + assert.deepEqual(surprise(uniform(0, 1), 0, 0.25, 0.3), { cell: 1, prior: 0.25, bits: { status: "measured", value: 2 } }); + assert.equal(surprise(uniform(0, 1), 0, 0.25, 0.25).cell, 1); + assert.deepEqual(surprise(uniform(0, 1), 0, 0.25, 2), { + cell: 8, + prior: 0, + bits: { status: "unmeasured", reason: "事前把握为 0,惊讶算不出" }, + }); +}); + +test("6.9 找实测所在格用和切格同一套边界:b + jΔ 正好落在第 j 格", () => { + // b = 2.5、Δ = 1e-4:(b + 3Δ − b)/Δ 在浮点里可能是 2.999…,按边界核对后仍是 3。 + for (let j = -5; j <= 50; j++) assert.equal(cellIndex(2.5, 1e-4, 2.5 + j * 1e-4), j); + for (let j = -5; j <= 50; j++) assert.equal(cellIndex(2.635983, 1e-4, 2.635983 + j * 1e-4), j); +}); + +test("6.9 分歧量:大家的范围完全一样时为 0;两个窄范围各在一格时为 1 比特", () => { + const r = { q1: 0.2, q5: 0.5, q9: 0.9 }; + close( + disagreement( + [ + { id: "A", author: "A", range: r }, + { id: "B", author: "B", range: r }, + ], + 0, + 0.25, + ), + 0, + ); + // 手算:A 交 (0.09, 0.1, 0.11),连尾巴 [0.08, 0.12],整个在 [0, 0.25) 里;B 交 (0.59, 0.6, 0.61),整个在 [0.5, 0.75) 里。 + // 各自 0 比特;平均后两格各一半,1 比特。 + close( + disagreement( + [ + { id: "A", author: "A", range: narrow(0.1) }, + { id: "B", author: "B", range: narrow(0.6) }, + ], + 0, + 0.25, + ), + 1, + ); +}); + +test("6.9 落点四段与覆盖率:边界归属;没有样本时覆盖率测不到", () => { + const r = { q1: 1, q5: 2, q9: 3 }; + assert.equal(placement(r, 0.5), "belowQ1"); + assert.equal(placement(r, 1), "q1ToQ5"); + assert.equal(placement(r, 2), "q5ToQ9"); + assert.equal(placement(r, 3), "q5ToQ9"); + assert.equal(placement(r, 3.5), "aboveQ9"); + const c = coverage([0.5, 1, 2, 3, 3.5].map((value) => ({ range: r, value }))); + assert.deepEqual(c.counts, { belowQ1: 1, q1ToQ5: 1, q5ToQ9: 2, aboveQ9: 1 }); + assert.equal(c.covered, 3); + assert.deepEqual(c.rate, { status: "measured", value: 0.6 }); + assert.deepEqual(coverage([]).rate, { status: "unmeasured", reason: "没有样本" }); +}); + +test("6.10 战绩:中位数偏高偏低、穿上下沿、包住,按人、按正向与消融分开", () => { + const r = { q1: 1, q5: 2, q9: 3 }; + const t = trackRecord([ + { researcher: "B", phase: "forward", range: r, value: 2.5 }, + { researcher: "A", phase: "ablation", range: r, value: 2 }, + { researcher: "A", phase: "forward", range: r, value: 0.5 }, + { researcher: "A", phase: "forward", range: r, value: 3.5 }, + { researcher: "A", phase: "forward", range: r, value: 2.5 }, + ]); + assert.deepEqual( + t.map((x) => [x.researcher, x.phase]), + [ + ["A", "forward"], + ["A", "ablation"], + ["B", "forward"], + ], + ); + // A 正向:0.5 → 中位数偏高、穿下沿;3.5 → 偏低、穿上沿;2.5 → 偏低、包住。 + assert.deepEqual(t[0]!.tally, { + total: 3, + medianHigh: 1, + medianLow: 2, + aboveTop: 1, + belowBottom: 1, + covered: 1, + placements: { belowQ1: 1, q1ToQ5: 0, q5ToQ9: 1, aboveQ9: 1 }, + }); + // A 消融:实测正好等于 q5,不算偏高也不算偏低。 + assert.equal(t[1]!.tally.medianHigh + t[1]!.tally.medianLow, 0); +}); + +test("6.10 作者自评 vs 大家合并:作者一半按方向比合并一半好多少,正数是作者更乐观", () => { + const item = { candidate: "c1", author: "A", authorRange: { q1: 2, q5: 2.5, q9: 3 }, mergedRange: { q1: 1, q5: 2, q9: 3 }, value: 2.25 }; + assert.deepEqual(authorVsMerged([item], "maximize"), [ + { candidate: "c1", author: "A", optimism: 0.5, authorPlacement: "q1ToQ5", mergedPlacement: "q5ToQ9" }, + ]); + assert.equal(authorVsMerged([item], "minimize")[0]!.optimism, -0.5); +}); + +test("6.9 分歧量只量人与人之间:同一人几条解释先等权平均成他的折线", () => { + // 手算:A、B 各交两条解释,都是 (0.09, 0.1, 0.11) 和 (0.59, 0.6, 0.61),各自整个落在 [0, 0.25)、[0.5, 0.75) 一格里。 + // 每人的折线都是"两格各一半",各 1 比特;平均后仍是两格各一半,1 比特;分歧量 = 1 − 1 = 0。 + // 若把每条解释各算比特(各 0 比特)再平均,会得出 1。 + close( + disagreement( + [ + { id: "e1", author: "A", range: narrow(0.1) }, + { id: "e2", author: "A", range: narrow(0.6) }, + { id: "e3", author: "B", range: narrow(0.1) }, + { id: "e4", author: "B", range: narrow(0.6) }, + ], + 0, + 0.25, + ), + 0, + ); +}); diff --git a/tests/rules-budget.test.ts b/tests/rules-budget.test.ts new file mode 100644 index 0000000..d16baec --- /dev/null +++ b/tests/rules-budget.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ablationCellLimit, + assignEntries, + decisionSeed, + forwardBatchSize, + runBalance, + seededRandom, + seededSample, + type RunState, +} from "../src/rules/index.ts"; + +// 预期值来源写在每条测试旁边。 + +test("第七部分扣次示例:运行次数 13、每批上限 2,逐行余额、每轮 k 与消融上限", () => { + // 来源:方法.md 第七部分示例表。每一行先按当时余额算出 k 或消融上限,再把这些运行记成确认启动。 + const total = 13; + const cap = 2; + const runs: RunState[] = []; + const spend = (n: number): number => { + for (let i = 0; i < n; i++) runs.push("started"); + return runBalance(total, runs).balance; + }; + assert.equal(spend(1), 12); // 基线 + assert.equal(forwardBatchSize(cap, 12), 2); + assert.equal(spend(2), 10); // 第 1 轮正向 + assert.equal(ablationCellLimit(10), 9); + assert.equal(spend(2), 8); // 第 1 轮消融选中 2 格 + assert.equal(forwardBatchSize(cap, 8), 2); + assert.equal(spend(2), 6); // 第 2 轮正向 + assert.equal(forwardBatchSize(cap, 6), 2); + assert.equal(spend(2), 4); // 第 3 轮正向 + assert.equal(ablationCellLimit(4), 3); + assert.equal(spend(0), 4); // 第 3 轮消融一格都不测 + assert.equal(forwardBatchSize(cap, 4), 2); + assert.equal(spend(2), 2); // 第 4 轮正向 + assert.equal(forwardBatchSize(cap, 2), 2); + assert.equal(spend(2), 0); // 第 5 轮正向,余额用完 + assert.equal(runBalance(total, runs).charged, 13); +}); + +test("第七部分:确认启动的扣,确认没启动的退还,已预留还没结果的先占着名额", () => { + assert.deepEqual(runBalance(5, ["started", "reserved", "notStarted", "started"]), { + charged: 2, + reserved: 1, + balance: 2, + }); + assert.deepEqual(runBalance(3, []), { charged: 0, reserved: 0, balance: 3 }); +}); + +test("第七部分:余额 0、1、2 时的正向批量与消融上限", () => { + // 手算:k = min(2, 余额);消融上限 = 余额 − 1,余额为 0 或 1 时是 0。 + assert.deepEqual([0, 1, 2].map((b) => forwardBatchSize(2, b)), [0, 1, 2]); + assert.deepEqual([0, 1, 2].map(ablationCellLimit), [0, 0, 1]); +}); + +test("4.2 入口分配第 1、2 条:只有一个人选就给他;几人同选时作者优先", () => { + // 手算:e2 被 r1、r2 同时选,作者 r2 在其中 → r2;e3 只有 r3 选 → r3;第 2 轮 r1 选 e1 → r1,第 2 志愿。 + assert.deepEqual( + assignEntries({ + researchers: [ + { id: "r1", choices: ["e2", "e1"] }, + { id: "r2", choices: ["e2", "e3"] }, + { id: "r3", choices: ["e3", "e1"] }, + ], + entries: [ + { id: "e1", author: "r1" }, + { id: "e2", author: "r2" }, + { id: "e3", author: "r3" }, + { id: "e4", author: "r1" }, + ], + }), + [ + { researcher: "r1", entry: "e1", rank: 2 }, + { researcher: "r2", entry: "e2", rank: 1 }, + { researcher: "r3", entry: "e3", rank: 1 }, + ], + ); +}); + +test("4.2 入口分配第 2、3 条:作者不在其中给编号最小的;志愿用完的人按编号拿剩下编号最小的入口", () => { + // 手算:e3(作者 r3)被 r1、r2 同选,r3 不在其中 → r1。r3 选 e1 → r3。 + // r2 志愿用完;剩下 e2、e4 → r2 拿 e2,rank 为 null。 + assert.deepEqual( + assignEntries({ + researchers: [ + { id: "r1", choices: ["e3"] }, + { id: "r2", choices: ["e3"] }, + { id: "r3", choices: ["e1"] }, + ], + entries: [ + { id: "e1", author: "r1" }, + { id: "e2", author: "r2" }, + { id: "e3", author: "r3" }, + { id: "e4", author: "r1" }, + ], + }), + [ + { researcher: "r1", entry: "e3", rank: 1 }, + { researcher: "r2", entry: "e2", rank: null }, + { researcher: "r3", entry: "e1", rank: 1 }, + ], + ); + // 两个人都落空:按编号各拿剩下的编号最小的。 + assert.deepEqual( + assignEntries({ + researchers: [ + { id: "r1", choices: ["e1"] }, + { id: "r2", choices: ["e1"] }, + { id: "r3", choices: ["e1"] }, + ], + entries: [ + { id: "e1", author: "r9" }, + { id: "e2", author: "r9" }, + { id: "e3", author: "r9" }, + ], + }).map((a) => [a.researcher, a.entry, a.rank]), + [ + ["r1", "e1", 1], + ["r2", "e2", null], + ["r3", "e3", null], + ], + ); +}); + +test("4.2 N = 1 时直接分第一志愿", () => { + assert.deepEqual( + assignEntries({ + researchers: [{ id: "r1", choices: ["e3", "e1"] }], + entries: [ + { id: "e1", author: "r1" }, + { id: "e2", author: "r1" }, + { id: "e3", author: "r1" }, + ], + }), + [{ researcher: "r1", entry: "e3", rank: 1 }], + ); +}); + +test("第九部分:种子 = hash({ studyId, round, version })", () => { + // 预期哈希用命令行 printf '%s' '{"round":1,"studyId":"s1","version":1}' | sha256sum 算出。 + assert.equal( + decisionSeed({ studyId: "s1", round: 1, version: 1 }), + "0fcf4530be51b098065a8f4a8774afc2c0be8f3a6de9b3b670bd26396d2c18ea", + ); +}); + +test("第九部分:同一个种子永远得到同一串数;抽样只取决于种子和编号集合", () => { + const a = seededRandom("seed"); + const b = seededRandom("seed"); + const xs = Array.from({ length: 50 }, () => a.uniformInt(7)); + assert.deepEqual( + Array.from({ length: 50 }, () => b.uniformInt(7)), + xs, + ); + assert.ok(xs.every((x) => Number.isInteger(x) && x >= 0 && x < 7)); + assert.deepEqual(seededSample(["c", "a", "b", "d"], 2, "k"), seededSample(["d", "b", "a", "c"], 2, "k")); + assert.deepEqual(seededSample(["c", "a", "b"], 3, "k"), ["a", "b", "c"]); + assert.deepEqual(seededSample(["c", "a", "b"], 0, "k"), []); +}); diff --git a/tests/rules-calc15.test.ts b/tests/rules-calc15.test.ts new file mode 100644 index 0000000..d3edcff --- /dev/null +++ b/tests/rules-calc15.test.ts @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { + ablationModel, + bitWidth, + cellGrid, + chooseBet, + evaluateCombos, + forwardMixtures, + gridSize, + informationValue, + informationValueExact, + informationValueGrid, + selectBestExpected, + supportOf, + surprise, + synergyLabel, + totalGain, + worldBits, + type AblationExplanation, + type AblationModelInput, + type BatchInput, +} from "../src/rules/index.ts"; +import type { Range } from "../src/vocab/index.ts"; + +// 用参考项目 RivalBits 的独立计算(numpy 脚本)当预期值,对照 src/rules。只收口径与 docs/方法.md 第六部分一致的数。 +// +// 一、消融算例:tests/fixtures/calc15/expected.json,原样复制自 RivalBits tests/fixtures/calc15/expected.json, +// 由 RivalBits research-wiki/设计-15-计算/calc15.py 在 14-real-1 账本副本上算出(dump.py 写成全精度 JSON)。 +// 口径核对(逐项读 calc15.py): +// - 格 0 = onlyB = 关掉网格起点 = p1;格 1 = onlyA = 关掉求解流程 = p2(calc15.py 第 128–129 行)。 +// - 成分权重 (1 − λ)/人数/同人条数,预留 λ(第 131 行)= 方法 6.7"成分与权重"。 +// - 每格自己的预留 [L − W/2, U + W/2](第 122–125、142–145 行)= 方法 6.3"消融"。 +// - h = max(K* − f(完整), 0.0001),0.0001 = ε/100(第 132 行)= 方法 6.7"方向收益"。 +// - gain(x) = h·(完整 − x)/G(第 133 行):voi、g0、opp、hand.* 都已乘 h(收益刻度); +// priorSupport* 是 prior/h(dump.py),在撑几成刻度上。 +// - 网格:折点含预留两端,s = min(20, ⌊m_k/段数⌋),s = 0 时整格切 m_k 份,小段取中点(第 147–157 行)= 方法 6.7"信息价值"。 +// - 全测:每成分各格把握连乘、逐段高斯-勒让德(k 点,对 k 次多项式精确)积分,再按权重相加(第 159–180 行)= 方法 6.7。 +// - 成本 opp(s, K) = 2ĝ₀[(K+s)/(K+s+1) − K/(K+1)](第 218–220 行)= 方法 6.7"价值减成本"。 +// 不用的部分:choose(旧规则:tight 时才计成本、R − r 固定剩余轮数)、simulate、fwd_after_testing(旧的份额规则)。 +// 3 格例的第三格是构造的:每条解释取它对前两格三个数的逐项平均(calc15.py 第 249 行;RivalBits tests/controller-ablation.test.ts 第 96 行附近)。 +// +// 二、正向算例:12-real-2 第 1、2 轮三位研究者的范围(RivalBits tests/info.test.ts ROUND1 第 64 行起、第 2 轮第 280 行附近, +// 与 research-wiki/设计-14-计算/calc14b.py 第 50–52、185–187 行相同),b = 0.9597642169962064。 +// - 世界比特、惊讶:RivalBits tests/fixtures/calc15/forward_bits.py。每个候选的合并分布 = 0.9 × 三人折线平均 + 0.1 × 预留, +// 预留由全部候选全部人的折线定(= 方法 6.3"正向"),从 b 起按 Δ = 0.0001 切格(在改进量上从 0 切,等价),F 是连续折线。 +// - 选批 V:calc14b.py 的 V = ∫_b^4.0(第 2 轮 ∫_b^2.8)[1 − ∏F] dt,40 万段梯形积分;积分上限高于预留上端(3.45、2.669), +// 低于 b 的部分不算 = 方法 6.4。 +// - 分数尺读数:calc14b 用全部三个候选取 max(第 64–66、194 行),方法用候选池。两轮三个候选都入池(下面断言),两者相同,可以用。 +// 不用的部分:calc14b 的 bits@eps(格宽 ε,不是 ε/100)、"== split" 整段(旧的解释分格做法)。 + +interface Calc15 { + input: { + baseline: number; + full: number; + knownBest: number; + offGrid: number; + offSolverLegalized: number; + explanations: Array<{ id: string; author: string; cells: [number, number, number][] }>; + }; + G: number; + h: number; + supportOffGrid: number; + supportOffSolver: number; + priorSupport2: number[]; + priorSupport3: number[]; + voi2: Record; + voi3: Record; + g0: number; + opp: Record; + hand: { + reservePrior: number; + reservePosterior: number; + explanationsPosterior: number; + gainOffGrid: number; + solverGainPrior: number; + solverGainPosterior: number; + }; + gridCap: Record; + cellMiniSegments: { k1: number[]; k2: number[]; k3: number[] }; + labels: Record; + label2: string; +} + +const X = JSON.parse(readFileSync(new URL("./fixtures/calc15/expected.json", import.meta.url), "utf8")) as Calc15; +const IN = X.input; + +function close(actual: number, expected: number, tol: number, what: string): void { + assert.ok(Math.abs(actual - expected) <= tol, `${what}:实际 ${actual},预期 ${expected},容差 ${tol}`); +} + +const range = ([q1, q5, q9]: readonly number[]): Range => ({ q1: q1!, q5: q5!, q9: q9! }); + +/** calc15 的九条解释;n = 3 时补上构造的第三格(前两格三个数逐项平均,calc15.py 第 249 行)。 */ +function explanations(n: 2 | 3): AblationExplanation[] { + return IN.explanations.map((e) => { + const cells = n === 2 ? e.cells : [...e.cells, e.cells[0]!.map((a, i) => (a + e.cells[1]![i]!) / 2)]; + return { id: e.id, author: e.author, cells: cells.map((c, i) => ({ factor: `p${i + 1}`, range: range(c) })) }; + }); +} + +function modelInput(n: 2 | 3): AblationModelInput { + return { + baseline: IN.baseline, + full: IN.full, + direction: "maximize", + knownBest: IN.knownBest, // 2.635:calc15.py 第 132 行写死的已知最好 + epsilon: 0.01, + lambda: 0.1, + factors: n === 2 ? ["p1", "p2"] : ["p1", "p2", "p3"], + explanations: explanations(n), + }; +} + +const M2 = ablationModel(modelInput(2)); +const M3 = ablationModel(modelInput(3)); + +// 容差:下面的量 calc15 和 rules 都是同一组浮点运算的不同次序(求和次序、高斯-勒让德 vs 多项式系数积分), +// 实测差在 1e-15 量级;取 1e-12 只容浮点舍入,不容算法差异(信息价值本身在 1e-4 量级)。 +const FP = 1e-12; + +test("calc15:总提升 G、剩余空间 h、两格撑几成", () => { + close(totalGain(IN.baseline, IN.full, "maximize"), X.G, FP, "G(expected.json G)"); + close(M2.gain, X.G, FP, "模型里的 G(expected.json G)"); + close(M2.space, X.h, FP, "h(expected.json h,= 2.635 − 完整)"); + close(supportOf(IN.baseline, IN.full, IN.offGrid, "maximize"), X.supportOffGrid, FP, "撑(p1)(supportOffGrid)"); + close(supportOf(IN.baseline, IN.full, IN.offSolverLegalized, "maximize"), X.supportOffSolver, FP, "撑(p2)(supportOffSolver)"); +}); + +test("calc15:起始权重与各因子事前期望撑几成", () => { + // 预留起始权重 λ = 0.1(expected.json hand.reservePrior);三人各 0.3,每人三条各 0.1。 + close(M2.weights[M2.weights.length - 1]!, X.hand.reservePrior, FP, "预留起始权重(hand.reservePrior)"); + for (let j = 0; j < 9; j++) close(M2.weights[j]!, 0.1, FP, `解释 e${j + 1} 起始权重(手算 0.9/3/3)`); + // priorSupport2/3 = prior/h(dump.py),撑几成刻度。 + X.priorSupport2.forEach((p, c) => close(M2.prior[c]!, p, FP, `两格例 p${c + 1} 事前期望撑几成(priorSupport2[${c}])`)); + X.priorSupport3.forEach((p, c) => close(M3.prior[c]!, p, FP, `三格例 p${c + 1} 事前期望撑几成(priorSupport3[${c}])`)); + // ĝ₀ = h × max 期望撑几成,收益刻度(expected.json g0);p2 的事前期望收益就是 ĝ₀(hand.solverGainPrior)。 + close(M2.space * M2.prior[1]!, X.hand.solverGainPrior, FP, "p2 事前期望收益(hand.solverGainPrior)"); + close(evaluateCombos(M2, { balance: 5, batchCap: 2, u: 0.5, decay: 0.5 }).g0, X.g0, FP, "ĝ₀(g0)"); +}); + +test("calc15:网格规模 m_k 与每格小段数", () => { + for (const k of [1, 2, 3, 4]) assert.equal(gridSize(k), X.gridCap[String(k)], `m_${k}(gridCap)`); + // 两格例:p1 31 段、p2 30 段,m_1、m_2 都够每段 20 份 → 620、600(cellMiniSegments.k1/k2)。 + assert.deepEqual([0, 1].map((c) => cellGrid(M2.supportKnots[c]!, 1).mids.length), X.cellMiniSegments.k1); + assert.deepEqual([0, 1].map((c) => cellGrid(M2.supportKnots[c]!, 2).mids.length), X.cellMiniSegments.k2); + // 三格例 m_3 = 158:s = ⌊158/段数⌋ → 155、150、126(cellMiniSegments.k3)。 + assert.deepEqual([0, 1, 2].map((c) => cellGrid(M3.supportKnots[c]!, 3).mids.length), X.cellMiniSegments.k3); +}); + +test("calc15:信息价值(收益刻度,已乘 h)——只测 p1 走网格,全测走一维精确积分", () => { + // voi2["1"]:两格例只测 p1,calc15 voi_grid,k = 1。 + close(informationValueGrid(M2, [0]), X.voi2["1"]!, FP, "两格例只测 p1(voi2['1'])"); + close(informationValue(M2, [0]), X.voi2["1"]!, FP, "两格例只测 p1,经 informationValue(voi2['1'])"); + // voi2["1+2"]:两格都测,calc15 emax_all(高斯-勒让德逐段精确)。 + close(informationValueExact(M2), X.voi2["1+2"]!, FP, "两格例全测(voi2['1+2'])"); + close(informationValue(M2, [0, 1]), X.voi2["1+2"]!, FP, "两格例全测,经 informationValue(voi2['1+2'])"); + // voi3["1+2+3"]:三格例三格都测。 + close(informationValue(M3, [0, 1, 2]), X.voi3["1+2+3"]!, FP, "三格例全测(voi3['1+2+3'])"); +}); + +test("calc15:机会成本(K = 2)", () => { + // opp(s) = 2ĝ₀[(2+s)/(3+s) − 2/3](calc15.py 第 218–220 行),和余额无关;余额取 5 让两格组合可选。 + const { combos } = evaluateCombos(M2, { balance: 5, batchCap: 2, u: 0.5, decay: 0.5 }); + const cost = (key: string): number => combos.find((c) => c.factors.join("+") === key)!.cost; + close(cost("p1"), X.opp["1"]!, FP, "测 1 格的成本(opp['1'])"); + close(cost("p2"), X.opp["1"]!, FP, "测 1 格的成本(opp['1'])"); + close(cost("p1+p2"), X.opp["2"]!, FP, "测 2 格的成本(opp['2'])"); +}); + +test("calc15:测到 p1 = 2.599 后权重更新与押的因子(押 p2,预测收益约 0.0028939)", () => { + // calc15 手算例(第 233–240 行)用 k = 1 网格里那一小段的把握更新权重;小段落在每个成分同一折线段内时, + // 小段把握 = 密度 × 小段宽,与方法 6.8"新权重 ∝ 旧权重 × 密度"只差一个公共因子,归一后相同。 + const bet = chooseBet({ + ...modelInput(2), + cells: [ + { factor: "p1", outcome: { kind: "valid", score: IN.offGrid } }, + { factor: "p2", outcome: { kind: "untested" } }, + ], + }); + assert.equal(bet.weightsKept, false); + close(bet.weights[9]!, X.hand.reservePosterior, FP, "预留更新后权重(hand.reservePosterior)"); + close( + bet.weights.slice(0, 9).reduce((s, w) => s + w, 0), + X.hand.explanationsPosterior, + FP, + "九条解释更新后权重和(hand.explanationsPosterior)", + ); + close(bet.gains[0]!.gain, X.hand.gainOffGrid, FP, "p1 实测收益(hand.gainOffGrid)"); + assert.equal(bet.gains[0]!.measured, true); + close(bet.gains[1]!.gain, X.hand.solverGainPosterior, FP, "p2 更新后期望收益(hand.solverGainPosterior)"); + assert.equal(bet.factor, "p2"); + assert.equal(bet.decision, "bet"); + close(bet.predictedGain, X.hand.solverGainPosterior, FP, "预测收益(hand.solverGainPosterior)"); +}); + +test("calc15:配合关系标签(示例与 14-real-1 两格实测)", () => { + // calc15 label 只返回中文,这里按方法 6.6 表对成代号;calc15 不判"有因子关掉反而更好",这些示例里没有负数,不受影响。 + const code: Record = { + 缺一不可: "indispensable", + 互补: "complementary", + 替代: "substitute", + 各管各: "independent", + "不全,不贴标签": "incomplete", + }; + for (const [supports, name] of Object.entries(X.labels)) { + assert.equal(synergyLabel(JSON.parse(supports) as Array).label, code[name], `labels ${supports}`); + } + assert.equal(synergyLabel([X.supportOffGrid, X.supportOffSolver]).label, code[X.label2], "label2"); +}); + +// --------------------------------------------------------------------------- +// 正向:12-real-2 + +const R = (q1: number, q5: number, q9: number): Range => ({ q1, q5, q9 }); +const B0 = 0.9597642169962064; +const ROUND1 = [ + { id: "c1", ranges: [{ researcher: "r1", range: R(2.44, 2.52, 2.62) }, { researcher: "r2", range: R(2.44, 2.5, 2.6) }, { researcher: "r3", range: R(2.44, 2.52, 2.61) }] }, + { id: "c2", ranges: [{ researcher: "r1", range: R(2.0, 2.3, 2.55) }, { researcher: "r2", range: R(2.0, 2.3, 2.5) }, { researcher: "r3", range: R(2.1, 2.35, 2.6) }] }, + { id: "c3", ranges: [{ researcher: "r1", range: R(2.0, 2.25, 2.5) }, { researcher: "r2", range: R(2.2, 2.35, 2.6) }, { researcher: "r3", range: R(1.9, 2.15, 2.45) }] }, +]; +const B2 = 2.61549458171637; +const ROUND2 = [ + { id: "c1", ranges: [{ researcher: "r1", range: R(2.6, 2.615, 2.632) }, { researcher: "r2", range: R(2.608, 2.6155, 2.625) }, { researcher: "r3", range: R(2.605, 2.618, 2.628) }] }, + { id: "c2", ranges: [{ researcher: "r1", range: R(2.618, 2.624, 2.632) }, { researcher: "r2", range: R(2.617, 2.625, 2.633) }, { researcher: "r3", range: R(2.617, 2.622, 2.63) }] }, + { id: "c3", ranges: [{ researcher: "r1", range: R(2.617, 2.623, 2.631) }, { researcher: "r2", range: R(2.617, 2.625, 2.632) }, { researcher: "r3", range: R(2.616, 2.621, 2.628) }] }, +]; + +function batch(candidates: BatchInput["candidates"], baseline: number): BatchInput { + return { candidates, baseline, direction: "maximize", epsilon: 0.01, lambda: 0.1, batchCap: 2, balance: 10, seed: "calc14b" }; +} + +test("12-real-2 第 1 轮:世界比特与惊讶按 Δ = 0.0001 从 b 起切", () => { + // 预期值:forward_bits.py 输出 c1 bits 12.1453096896 surprise 11.7641504235;c2 bits 13.4884609551 surprise 14.4997227824 + // (RivalBits tests/info.test.ts 第 255 行附近)。打印到小数第 10 位,容差 1e-9 只容打印截断。 + const width = bitWidth(0.01); + assert.equal(width, 0.0001); + const [c1, c2] = forwardMixtures(ROUND1, 0.1); + close(worldBits(c1!, B0, width), 12.1453096896, 1e-9, "c1 世界比特"); + close(worldBits(c2!, B0, width), 13.4884609551, 1e-9, "c2 世界比特"); + const s1 = surprise(c1!, B0, width, 2.6086313936944947).bits; + const s2 = surprise(c2!, B0, width, 2.61549458171637).bits; + assert.ok(s1.status === "measured" && s2.status === "measured"); + close(s1.value, 11.7641504235, 1e-9, "c1 惊讶"); + close(s2.value, 14.4997227824, 1e-9, "c2 惊讶"); +}); + +// 选批 V 的容差 1e-6:calc14b 用 40 万段梯形积分(第 1 轮步长约 7.6e-6,第 2 轮约 4.6e-7),rules 是逐段多项式精确积分; +// 两者差来自梯形误差,实测在 1e-9 量级。预期值写到小数第 7 位(RivalBits tests/info.test.ts 第 228、286–291 行)。 +const QUAD = 1e-6; + +test("12-real-2 第 1 轮选批:c1 + c2 1.6009991 最好,分数尺读数 0.03471002", () => { + const d = selectBestExpected(batch(ROUND1, B0)); + // 三个候选都远高于 b − ε,全部入池,所以 calc14b 对全部候选取 max 与方法 6.4 对候选池取 max 相同。 + assert.deepEqual(d.pool, ["c1", "c2", "c3"]); + const v = (ids: string): number => d.combos.find((c) => c.ids.join("+") === ids)!.value; + close(v("c1+c2"), 1.6009991, QUAD, "calc14b ('c1','c2')"); + close(v("c1+c3"), 1.5974972, QUAD, "calc14b ('c1','c3')"); + close(v("c2+c3"), 1.4845937, QUAD, "calc14b ('c2','c3')"); + assert.deepEqual(d.chosen, ["c1", "c2"]); + close(d.scaleReading, 0.03471002, QUAD, "calc14b EVPI batch"); +}); + +test("12-real-2 第 2 轮选批:c2 + c3 0.0136813 最好,分数尺读数 0.0020944", () => { + const d = selectBestExpected(batch(ROUND2, B2)); + // 入池把握 P(Y < −ε):本 Agent 用 calc14b.py 的 Combined.F 在 b − ε 处复算,c1 0.16951174225193、c2 c3 0.04101139196591, + // 都低于一半,全部入池(读数口径与方法一致的前提)。numpy 插值与 rules 同式,容差 1e-12。 + assert.deepEqual(d.pool, ["c1", "c2", "c3"]); + close(d.candidates[0]!.lossProbability, 0.1695117422519303, FP, "c1 入池把握(calc14b F(b − ε))"); + close(d.candidates[1]!.lossProbability, 0.04101139196591438, FP, "c2 入池把握(calc14b F(b − ε))"); + close(d.candidates[2]!.lossProbability, 0.04101139196591438, FP, "c3 入池把握(calc14b F(b − ε))"); + const v = (ids: string): number => d.combos.find((c) => c.ids.join("+") === ids)!.value; + close(v("c2+c3"), 0.0136813, QUAD, "calc14b ('c2','c3')"); + close(v("c1+c2"), 0.0126357, QUAD, "calc14b ('c1','c2')"); + close(v("c1+c3"), 0.0120484, QUAD, "calc14b ('c1','c3')"); + assert.deepEqual(d.chosen, ["c2", "c3"]); + close(d.scaleReading, 0.0020944, QUAD, "calc14b EVPI"); +}); diff --git a/tests/rules-distribution.test.ts b/tests/rules-distribution.test.ts new file mode 100644 index 0000000..c93f439 --- /dev/null +++ b/tests/rules-distribution.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ablationCellMixture, + authorShares, + below, + forwardMergedRange, + forwardMixtures, + knotsMean, + mixtureBelow, + mixtureQuantile, + personKnots, + reserveKnots, + toImprovement, +} from "../src/rules/index.ts"; + +// 预期值来源写在每条测试旁边。"手算"指按方法.md 的文字用纸笔算出,不调用实现里的公式。 + +function close(actual: number, expected: number, tol = 1e-12): void { + assert.ok(Math.abs(actual - expected) <= tol, `实际 ${actual},预期 ${expected}`); +} + +test("6.2 示例:交 2.44 / 2.52 / 2.62,低于 2.57 的把握是 0.70,尾巴铺在 2.36–2.44 与 2.62–2.72", () => { + // 来源:方法.md 6.2 的示例原文。 + const k = personKnots({ q1: 2.44, q5: 2.52, q9: 2.62 }); + close(below(k, 2.57), 0.7); + close(k[0]![0], 2.36); + close(k[4]![0], 2.72); + assert.equal(below(k, 2.36), 0); + assert.equal(below(k, 2.73), 1); +}); + +test("折线期望:对称范围的期望是一半那个数;不对称时手算", () => { + // 手算:(0,1,2) 的五个点 (−1,0)(0,.1)(1,.5)(2,.9)(3,1),各段中点 × 把握: + // .1×(−.5) + .4×.5 + .4×1.5 + .1×2.5 = −.05 + .2 + .6 + .25 = 1.0 + close(knotsMean(personKnots({ q1: 0, q5: 1, q9: 2 })), 1); + // (0,1,3):(−1,0)(0,.1)(1,.5)(3,.9)(5,1):.1×(−.5) + .4×.5 + .4×2 + .1×4 = −.05+.2+.8+.4 = 1.35 + close(knotsMean(personKnots({ q1: 0, q5: 1, q9: 3 })), 1.35); +}); + +test("6.3 开放预留:[L − W/2, U + W/2]", () => { + // 手算:(0,1,2) 连尾巴 [−1, 3],(2,2.5,3) 连尾巴 [1.5, 3.5];L = −1,U = 3.5,W = 4.5,预留 [−3.25, 5.75]。 + const r = reserveKnots([personKnots({ q1: 0, q5: 1, q9: 2 }), personKnots({ q1: 2, q5: 2.5, q9: 3 })]); + assert.deepEqual(r, [ + [-3.25, 0], + [5.75, 1], + ]); +}); + +test("6.3 合并:(1 − λ) × 各人平均 + λ × 开放预留", () => { + // 手算:两人 (0,1,2)、(2,3,4),λ = 0.1。L = −1,U = 5,W = 6,预留 [−4, 8]。 + // 在 x = 2:第一人 0.9,第二人 0.1,平均 0.5;预留 (2 + 4)/12 = 0.5;合并 0.9×0.5 + 0.1×0.5 = 0.5。 + // 在 x = 0:第一人 0.1,第二人 0(尾巴从 1 开始);预留 4/12;合并 0.9×0.05 + 0.1/3 = 0.045 + 0.0333… + const [m] = forwardMixtures( + [ + { + ranges: [ + { researcher: "r1", range: { q1: 0, q5: 1, q9: 2 } }, + { researcher: "r2", range: { q1: 2, q5: 3, q9: 4 } }, + ], + }, + ], + 0.1, + ); + close(mixtureBelow(m!, 2), 0.5); + close(mixtureBelow(m!, 0), 0.045 + 0.1 / 3); +}); + +test("6.3 正向的开放预留由同一次选批里全部候选、全部人的折线一起定", () => { + // 手算:候选甲 (0,1,2),候选乙 (9,10,11) 连尾巴 [8, 12]。L = −1,U = 12,W = 13,预留 [−7.5, 18.5],两个候选共用。 + const ms = forwardMixtures( + [ + { ranges: [{ researcher: "r1", range: { q1: 0, q5: 1, q9: 2 } }] }, + { ranges: [{ researcher: "r1", range: { q1: 9, q5: 10, q9: 11 } }] }, + ], + 0.5, + ); + for (const m of ms) { + assert.deepEqual(m[m.length - 1]!.knots, [ + [-7.5, 0], + [18.5, 1], + ]); + assert.equal(m[m.length - 1]!.weight, 0.5); + } +}); + +test("6.3 同一个人交了几条解释时,他那一份在几条之间平分", () => { + // 手算:A 两条、B 一条:A 的每条 1/4,B 的那条 1/2。 + assert.deepEqual(authorShares(["A", "A", "B"]), [0.25, 0.25, 0.5]); + // λ = 0.1 时成分权重 0.9×0.25、0.9×0.25、0.9×0.5、0.1。 + const r = { q1: 1, q5: 2, q9: 3 }; + const m = ablationCellMixture( + [ + { id: "e1", author: "A", range: r }, + { id: "e2", author: "A", range: r }, + { id: "e3", author: "B", range: r }, + ], + 0.1, + ); + assert.deepEqual( + m.map((c) => c.weight), + [0.9 * 0.25, 0.9 * 0.25, 0.9 * 0.5, 0.1], + ); +}); + +test("合并分布的一成、一半、九成(广义逆)", () => { + // 手算:一人 (0,1,2),λ = 0.5,预留 [−3, 5]。F(x) = 0.5 F₁(x) + 0.5 (x + 3)/8。 + // 一成:在 [−3, −1] 上 F₁ = 0,(x + 3)/16 = 0.1 → x = −1.4(在 [−1, 0] 上解出 −1.22 < −1,不在段内)。 + // 对称性:一半是 1,九成是 3.4。 + const [m] = forwardMixtures([{ ranges: [{ researcher: "r1", range: { q1: 0, q5: 1, q9: 2 } }] }], 0.5); + close(mixtureQuantile(m!, 0.1), -1.4); + close(mixtureQuantile(m!, 0.5), 1); + close(mixtureQuantile(m!, 0.9), 3.4); +}); + +test("6.1 越小越好:换成改进量后一成和九成对调,合并范围换回原值后与原范围相同", () => { + // 手算:b = 1,一人交 (1, 1.25, 1.5),λ = 0。改进量 Y = 1 − 分数:(−0.5, −0.25, 0) 依次是一成、一半、九成。 + const [m] = forwardMixtures([{ ranges: [{ researcher: "r1", range: { q1: 1, q5: 1.25, q9: 1.5 } }] }], 0); + const imp = toImprovement(m!, 1, "minimize"); + // 反向时把握换成 1 − 原把握,1 − 0.9 在浮点里差末位,用容差比。 + close(mixtureBelow(imp, -0.5), 0.1); + close(mixtureBelow(imp, -0.25), 0.5); + close(mixtureBelow(imp, 0), 0.9); + const merged = forwardMergedRange(imp, 1, "minimize"); + close(merged.q1, 1); + close(merged.q5, 1.25); + close(merged.q9, 1.5); + // 越大越好:b = 1,交 (0.5, 0.75, 1),Y = 分数 − 1。 + const [n] = forwardMixtures([{ ranges: [{ researcher: "r1", range: { q1: 0.5, q5: 0.75, q9: 1 } }] }], 0); + const impMax = toImprovement(n!, 1, "maximize"); + assert.equal(mixtureBelow(impMax, -0.25), 0.5); + assert.deepEqual(forwardMergedRange(impMax, 1, "maximize"), { q1: 0.5, q5: 0.75, q9: 1 }); +}); diff --git a/tests/rules-fixtures-ablation.test.ts b/tests/rules-fixtures-ablation.test.ts new file mode 100644 index 0000000..eb18b86 --- /dev/null +++ b/tests/rules-fixtures-ablation.test.ts @@ -0,0 +1,384 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ablationFinding, + ablationModel, + chooseBet, + evaluateCombos, + gridSize, + inseparableGroups, + informationValue, + informationValueExact, + informationValueGrid, + judgeExplanations, + measuredGrid, + OPEN_RESERVE, + pickByNet, + topCombo, + selectAll, + selectByValue, + supportOf, + synergyLabel, + totalGain, + type AblationCell, + type AblationModel, + type AblationModelInput, + type CellOutcome, + type CellRuleInput, + type SynergyLabel, + type UntestedReason, +} from "../src/rules/index.ts"; +import { close, closeArray, closeGrid, loadFixture, range, scenarioPicker } from "./rules-fixtures-helpers.ts"; + +// ablation.json:方法.md 6.6–6.8、4.3 ⑰ 与约定 17–30,对照独立 Python 参考实现算出的预期值。 +// 容差见 ablation.json tolerance:精确量 1e-12;网格值 1e-12 + 1e-9·|值|;离散结果完全相同; +// 网格对一维精确按 tolerance.gridVsExact。 + +const fixture = loadFixture("ablation.json"); +const pick = scenarioPicker(fixture); +const TOL = 1e-12; +const SEED = "fixture-seed"; + +/** Python 的标签、结论、原因名 → rules 的代号。 */ +const LABEL: Record = { + negative: "offBetter", + incomplete: "incomplete", + indispensable: "indispensable", + complementary: "complementary", + substitute: "substitute", + independent: "independent", +}; +const VERDICT: Record = { confirmed: "confirmed", refuted: "denied" }; +const REASON: Record = { insufficientBudget: "budget", notWorthCost: "value" }; +const BET: Record = { bet: "bet", noBetNegativeSupport: "offBetter", noBetNonPositiveGain: "notPositive" }; +const componentName = (n: string): string => (n === "reserve" ? OPEN_RESERVE : n); + +function modelInput(name: string): AblationModelInput & { params: Record } { + const m = fixture.models![name]!; + const i = m.input; + return { + baseline: i.B, + full: i.full, + direction: i.direction, + knownBest: i.knownBest, + epsilon: m.params.eps!, + lambda: m.params.lambda!, + factors: i.cells, + explanations: i.explanations.map((e: any) => ({ + id: e.id, + author: e.author, + cells: Object.entries(e.cells).map(([factor, r]) => ({ factor, range: range(r as any) })), + })), + params: m.params, + }; +} + +/** Python 的一格 {factor, kind, value?} → rules 的 AblationCell。一起关的 off 只用于沿用(sharedSettings),这里用到的函数不读它。 */ +function cellOf(factor: string, o: { kind: string; value?: number }): AblationCell { + const outcome: CellOutcome = + o.kind === "valid" + ? { kind: "valid", score: o.value! } + : o.kind === "joint" + ? { kind: "joint", off: [factor], score: o.value ?? null } + : { kind: o.kind as "insufficient" | "untested" | "cannotRun" }; + return { factor, outcome }; +} + +const MODEL_CACHE = new Map(); +function modelOf(name: string): AblationModel { + if (!MODEL_CACHE.has(name)) MODEL_CACHE.set(name, ablationModel(modelInput(name))); + return MODEL_CACHE.get(name)!; +} + +// ---------------------------------------------------------------- 撑几成、标签(6.6) + +for (const name of [ + "label_method_example_independent", + "label_calc15_substitute", + "label_r1_negative", + "label_incomplete_with_other_outcomes", + "label_negative_beats_incomplete", + "label_indispensable", + "label_complementary", + "label_boundary_exact_0.9", + "label_minimize_substitute", + "label_G_not_positive", + "label_G_negative_minimize", +]) { + test(`ablation.json ${name}`, () => { + const s = pick.get(name); + const { B, full, direction, cells } = s.input; + if (s.expected.error !== undefined) { + assert.equal(s.expected.error, "totalGainNotPositive"); + assert.throws(() => totalGain(B, full, direction), /G = /); + return; + } + close(totalGain(B, full, direction), s.expected.G, TOL, "G"); + const supports = cells.map((c: any) => (c.kind === "valid" ? supportOf(B, full, c.value, direction) : null)); + cells.forEach((c: any, i: number) => { + const e = s.expected.supports[c.factor]; + if (e === null) assert.equal(supports[i], null, `${c.factor} 没有撑几成`); + else close(supports[i]!, e, TOL, `${c.factor} 撑几成`); + }); + const got = synergyLabel(supports); + assert.equal(got.label, LABEL[s.expected.label], "标签"); + // Python 在"关掉反而更好""不全"两种标签下不给 i;rules 在"不全"时给 null,在"关掉反而更好"且每格都有效时照给数。 + if (s.expected.interaction !== null) close(got.interaction!, s.expected.interaction, TOL, "相对交互量 i"); + if (got.label === "incomplete") assert.equal(got.interaction, null); + }); +} + +// ---------------------------------------------------------------- 确认与否定、分不开(6.6) + +for (const name of ["confirm_r1_real", "confirm_calc15", "confirm_endpoint_inclusive"]) { + test(`ablation.json ${name}`, () => { + const s = pick.get(name); + const input = modelInput(s.input.model); + const cells = Object.entries(s.input.valid as Record).map(([f, v]) => cellOf(f, { kind: "valid", value: v })); + const judgments = judgeExplanations(input.explanations, cells); + assert.deepEqual( + Object.fromEntries(judgments.map((j) => [j.id, Object.fromEntries(j.verdicts.map((v) => [v.factor, v.verdict]))])), + Object.fromEntries( + Object.entries(s.expected.verdicts as Record>).map(([id, row]) => [ + id, + Object.fromEntries(Object.entries(row).map(([f, v]) => [f, VERDICT[v]])), + ]), + ), + ); + assert.deepEqual(inseparableGroups(judgments), s.expected.inseparableGroups); + }); +} + +// ---------------------------------------------------------------- 消融中发现(4.3 ⑰) + +for (const name of ["discovery_r1real", "discovery_calc15", "discovery_gainNotPositive"]) { + test(`ablation.json ${name}`, () => { + const s = pick.get(name); + const input = modelInput(s.input.model); + const got = ablationFinding({ + full: input.full, + knownBest: input.knownBest, + epsilon: s.params.eps, + direction: input.direction, + cells: s.input.cells.map((c: any) => cellOf(c.factor, c)), + }); + close(got.line, s.expected.line, TOL, "采纳线"); + assert.deepEqual(got.passed.map((p) => p.factor), s.expected.overLine, "过线的格"); + assert.equal(got.chosen, s.expected.newBaseline, "新基线"); + }); +} + +// ---------------------------------------------------------------- 成分、先验、信息价值(6.7) + +function gridVsExactTol(k: number): number { + // tolerance.gridVsExact:k ≤ 3 时 5e-4·|精确值|,k = 4 时 3e-3·|精确值|。 + return k <= 3 ? 5e-4 : 3e-3; +} + +for (const name of ["calc15", "r1real", "minimize", "fourCells", "negativeG0"]) { + test(`ablation.json voi_${name}`, () => { + const s = pick.get(`voi_${name}`); + const e = s.expected; + const model = modelOf(name); + const idx = (f: string): number => model.factors.indexOf(f); + close(model.gain, e.G, TOL, "G"); + close(model.space, e.h, TOL, "h"); + assert.deepEqual(model.components, e.componentNames.map(componentName), "成分名单"); + closeArray(model.weights, e.weights, TOL, "起始权重"); + for (const [f, ends] of Object.entries(e.reserve as Record)) { + const k = model.rawKnots[idx(f)]!.at(-1)!; + closeArray([k[0]![0], k.at(-1)![0]], ends, TOL, `${f} 的开放预留`); + } + e.componentNames.forEach((n: string, j: number) => { + for (const [f, v] of Object.entries(e.componentExpectedPayoff[n] as Record)) { + close(model.space * model.expected[idx(f)]![j]!, v, TOL, `成分 ${n} 在 ${f} 的期望收益`); + } + }); + for (const [f, v] of Object.entries(e.priorExpectedPayoff as Record)) { + close(model.space * model.prior[idx(f)]!, v, TOL, `${f} 的事前期望收益`); + } + close(evaluateCombos(model, { balance: 0, batchCap: 2, u: 0.5, decay: 0.5 }).g0, e.g0, TOL, "ĝ₀"); + + const all = model.factors.map((_, c) => c); + const exact = informationValueExact(model); + close(exact, e.voiFullExact, TOL, "全测 VOI(一维精确)"); + close(informationValue(model, all), e.voiFullExact, TOL, "全测 VOI(informationValue 走精确)"); + + // 网格:先核对每格折点数、小段数与 Python 一致(差一个 ulp 会多出一段),再比 VOI。 + const checkGrid = (S: string[], info: any, what: string): void => { + assert.equal(gridSize(S.length), info.m, `${what}:m`); + for (const f of S) { + // 折点在分数原值上取(measuredGrid),两边都按浮点原式算、只合并完全相等的值。 + const brk = new Set(model.rawKnots[idx(f)]!.flatMap((k) => k.map(([x]) => x))).size; + assert.equal(brk, info.cells[f].breakpoints, `${what} ${f}:折点数`); + assert.equal(measuredGrid(model, idx(f), S.length).mids.length, info.cells[f].subsegments, `${what} ${f}:小段数`); + } + }; + for (const row of e.partial) { + const S = row.cells as string[]; + checkGrid(S, row.grid, `测 ${S.join(",")}`); + const v = informationValueGrid(model, S.map(idx)); + closeGrid(v, row.voiGrid, `测 ${S.join(",")} 的网格 VOI`); + closeGrid(informationValue(model, S.map(idx)), row.voiGrid, `测 ${S.join(",")} 的 informationValue`); + if (row.exactK1VOI !== undefined) { + // 只测一格的精确参考值:rules 没有这个算法,只核对网格与它的差在 gridVsExact 的 k ≤ 3 界内。 + close(v, row.exactK1VOI, gridVsExactTol(1) * Math.abs(row.exactK1VOI), `测 ${S[0]} 的网格对精确`); + } + } + checkGrid(model.factors, e.gridFullInfo, "全测走网格"); + const gridFull = informationValueGrid(model, all); + closeGrid(gridFull, e.gridFullVOI, "全测走网格的 VOI"); + close(gridFull, exact, gridVsExactTol(all.length) * Math.abs(exact), "全测:网格对一维精确"); + // tolerance.gridVsExact:每格 s ≥ 1 时网格值不超过精确值(Jensen);fourCells 有 s = 0 的格,不适用。 + if (name !== "fourCells") assert.ok(gridFull <= exact + 1e-12, `全测网格 ${gridFull} 不应超过精确 ${exact}`); + }); +} + +// ---------------------------------------------------------------- 价值、成本、选格(6.7) + +for (const name of ["calc15", "r1real", "minimize", "negativeG0"]) { + test(`ablation.json select_${name}`, () => { + const s = pick.get(`select_${name}`); + const { params, ...input } = modelInput(name); + const model = modelOf(name); + for (const e of s.expected.byBalance) { + const tag = `余额 ${e.balance}`; + const ruleInput: CellRuleInput = { + ...input, + balance: e.balance, + batchCap: params.batchCap!, + u: params.u!, + decay: params.r!, + seed: SEED, + }; + const { g0, combos } = evaluateCombos(model, ruleInput); + close(g0, s.expected.g0, TOL, `${tag} ĝ₀`); + if (name === "negativeG0") { + // ĝ₀ < 0:原始值照记,成本按 max(0, ĝ₀) = 0 算(6.7"价值减成本"、约定 28)。 + assert.ok(g0 < 0, `${tag} ĝ₀ 应为负`); + assert.ok(combos.length > 0 && combos.every((c) => c.cost === 0 && c.net === c.value), `${tag} 成本为 0、净值 = 价值`); + assert.equal(selectByValue(ruleInput).g0, g0, `${tag} 选格记录里的 ĝ₀ 是原始负值`); + } + assert.deepEqual(combos.map((c) => c.factors), e.combos.map((c: any) => c.combo), `${tag} 列出的组合`); + combos.forEach((c, i) => { + const r = e.combos[i]; + const what = `${tag} {${c.factors.join(",")}}`; + // 全测用一维精确(精确量容差),部分格用网格(网格容差)。 + const cmp = c.factors.length === model.factors.length ? (a: number, b: number, w: string) => close(a, b, TOL, w) : closeGrid; + cmp(c.voi, r.voi, `${what} VOI`); + assert.equal(c.rHat, r.Rhat, `${what} R̂`); + close(c.mPrime, r.mPrime, TOL, `${what} m′`); + cmp(c.value, r.value, `${what} 价值`); + close(c.cost, r.cost, TOL, `${what} 成本`); + closeGrid(c.net, r.net, `${what} 净值`); + }); + const d = selectByValue(ruleInput); + assert.deepEqual(d.chosen, e.selected, `${tag} 选中的格`); + closeGrid(d.chosenNet, e.selectedNet, `${tag} 选中组合的净值`); + for (const cell of d.cells) { + const u = e.unselected[cell.factor]; + if (cell.tested) { + assert.equal(u, undefined, `${tag} ${cell.factor} 应选中`); + continue; + } + assert.equal(cell.reason, REASON[u.reason], `${tag} ${cell.factor} 没测的原因`); + if (u.bestCombo === null) assert.equal(cell.bestCombo, null, `${tag} ${cell.factor} 没有可选组合`); + else { + assert.deepEqual(cell.bestCombo!.factors, u.bestCombo, `${tag} ${cell.factor} 含它的最好组合`); + closeGrid(cell.bestCombo!.net, u.bestNet, `${tag} ${cell.factor} 含它的最好组合的净值`); + } + } + const a = selectAll(ruleInput); + assert.deepEqual(a.chosen, e.fullRule.selected, `${tag} 全测规则选中的格`); + assert.deepEqual( + a.cells.filter((c) => !c.tested).map((c) => [c.factor, c.reason]), + e.fullRule.insufficientBudget.map((f: string) => [f, "budget"]), + `${tag} 全测规则放不下的格`, + ); + } + }); +} + +// 纯选格:给定净值表,核对 1e-12 容差与平局(6.7"选格"第 2 条、约定 29):pickByNet 选组合,topCombo 挑 +// "含这一格的最好组合"。没选中的原因在 selectByValue 里由模型算出的净值表决定,这里覆盖不到。 +for (const name of [ + "select_pure_net_at_tolerance", + "select_pure_just_above_tolerance", + "select_pure_tie_chain", + "select_pure_budget_reason", + "select_pure_tolerance_relay", + "select_pure_filter_before_max", +]) { + test(`ablation.json ${name}`, () => { + const s = pick.get(name); + const cells = s.input.cells as string[]; + const limit = Math.min(cells.length, s.input.balance - 1); + // 组合按格数从少到多、同格数按因子编号字典序(约定 29),只列格数 ≤ 余额 − 1 的。 + const combos: Array<{ factors: string[]; net: number }> = []; + for (let n = 1; n <= limit; n++) { + const walk = (start: number, acc: string[]): void => { + if (acc.length === n) { + combos.push({ factors: acc, net: s.input.nets[acc.join(",")] }); + return; + } + for (let i = start; i < cells.length; i++) walk(i + 1, [...acc, cells[i]!]); + }; + walk(0, []); + } + const chosen = pickByNet(combos); + assert.deepEqual(chosen, s.expected.selected, "选中的格"); + const net = chosen.length === 0 ? 0 : s.input.nets[chosen.join(",")]; + assert.equal(net, s.expected.selectedNet, "选中组合的净值"); + // 没选中的格附"含这一格的净值最大组合",按同一条规则挑(topCombo)。 + for (const [f, u] of Object.entries(s.expected.unselected as Record)) { + assert.ok(!chosen.includes(f)); + const best = topCombo(combos.filter((c) => c.factors.includes(f))); + if (u.bestCombo === null) assert.equal(best, null, `${f} 没有可选组合`); + else { + assert.deepEqual(best!.factors, u.bestCombo, `${f} 含它的最好组合`); + assert.equal(best!.net, u.bestNet, `${f} 含它的最好组合的净值`); + } + } + }); +} + +// ---------------------------------------------------------------- 押的因子(6.8) + +for (const name of [ + "bet_calc15_all_valid", + "bet_calc15_p2_insufficient", + "bet_calc15_joint_ignored", + "bet_r1_negative_support", + "bet_gain_not_positive", + "bet_weights_not_updatable", + "bet_minimize", +]) { + test(`ablation.json ${name}`, () => { + const s = pick.get(name); + const { params: _, ...input } = modelInput(s.input.model); + const e = s.expected; + const got = chooseBet({ + ...input, + cells: Object.entries(s.input.outcomes as Record).map(([f, o]) => cellOf(f, o)), + }); + assert.equal(!got.weightsKept, e.weightsUpdated, "权重是否更新"); + const names = Object.keys(e.weights); + assert.deepEqual(modelOf(s.input.model).components, names.map(componentName), "成分名单"); + closeArray(got.weights, names.map((n) => e.weights[n]), TOL, "更新后的权重"); + assert.deepEqual(got.gains.map((g) => g.factor), Object.keys(e.payoffs), "因子"); + got.gains.forEach((g) => close(g.gain, e.payoffs[g.factor], TOL, `${g.factor} 方向收益`)); + assert.equal(got.factor, e.picked, "押的因子"); + close(got.predictedGain, e.predictedGain, TOL, "预测收益"); + assert.equal(got.decision, BET[e.status], "押不押"); + assert.deepEqual(got.offBetter.map((o) => o.factor), e.negativeFactors.map((n: any) => n.factor), "关掉反而更好的格"); + got.offBetter.forEach((o, i) => close(o.better, e.negativeFactors[i].offBetterBy, TOL, `${o.factor} 关掉后好多少`)); + }); +} + +test("ablation.json 每个场景都已覆盖(value_cost_pure 除外,见下)", () => { + // value_cost_pure 直接给 ĝ₀ 和 VOI 算 R̂、m′、价值、成本;rules 只在 evaluateCombos 里从模型算这些, + // 没有接受 (ĝ₀, VOI) 的对外函数,无法映射。R̂、m′、价值、成本已在 select_* 场景里逐组合核对, + // ĝ₀ < 0 时成本为 0 由 select_negativeG0 核对。 + pick.get("value_cost_pure"); + assert.deepEqual(pick.unused(), []); +}); diff --git a/tests/rules-fixtures-forward.test.ts b/tests/rules-fixtures-forward.test.ts new file mode 100644 index 0000000..e041167 --- /dev/null +++ b/tests/rules-fixtures-forward.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + adopt, + adoptionLine, + batchValue, + decisionSeed, + below, + density, + forwardMergedRange, + forwardMixtures, + knotsMean, + ablationCellMixture, + mixtureBelow, + mixtureQuantile, + mixtureRange, + personKnots, + selectBestExpected, + selectUniformRandom, + toImprovement, + type Knots, + type Mixture, +} from "../src/rules/index.ts"; +import { close, closeArray, loadFixture, range, scenarioPicker } from "./rules-fixtures-helpers.ts"; + +// forward.json:方法.md 6.1–6.5 与约定 1–16,对照独立 Python 参考实现算出的预期值。 +const fixture = loadFixture("forward.json"); +const pick = scenarioPicker(fixture); +const TOL: number = fixture.tolerance.abs; +const SEED = "fixture-seed"; + +const xs = (k: Knots): number[] => k.map(([x]) => x); +const ys = (k: Knots): number[] => k.map(([, y]) => y); +const mean = (m: Mixture): number => m.reduce((s, c) => s + c.weight * knotsMean(c.knots), 0); +/** 正向一个候选的各人范围:[{researcher, range}]。 */ +const rangesOf = (rs: any[]) => rs.map((r) => ({ researcher: r.researcher as string, range: range(r.range) })); +const candidatesOf = (input: Record) => + Object.entries(input.candidates as Record).map(([id, rs]) => ({ id, ranges: rangesOf(rs) })); + +for (const name of ["cdf_method_example_2.44_2.52_2.62", "cdf_asymmetric_narrow"]) { + test(`forward.json ${name}`, () => { + const s = pick.get(name); + const k = personKnots(range(s.input.range)); + closeArray(xs(k), s.expected.points.map((p: number[]) => p[0]), TOL, "折点横坐标"); + closeArray(ys(k), s.expected.points.map((p: number[]) => p[1]), TOL, "折点纵坐标"); + closeArray(s.input.x.map((x: number) => below(k, x)), s.expected.cdf, TOL, "F"); + closeArray(s.input.densityAt.map((x: number) => density(k, x)), s.expected.density, TOL, "密度"); + close(knotsMean(k), s.expected.mean, TOL, "期望"); + }); +} + +test("forward.json minimize_to_improvement", () => { + const s = pick.get("minimize_to_improvement"); + const imp = toImprovement([{ weight: 1, knots: personKnots(range(s.input.range)) }], s.input.b, "minimize"); + const k = imp[0]!.knots; + closeArray(xs(k), s.expected.Ypoints.map((p: number[]) => p[0]), TOL, "Y 折点横坐标"); + closeArray(ys(k), s.expected.Ypoints.map((p: number[]) => p[1]), TOL, "Y 折点纵坐标"); + closeArray([k[1]![0], k[2]![0], k[3]![0]], [s.expected.Yrange.q1, s.expected.Yrange.q5, s.expected.Yrange.q9], TOL, "Y 三个数"); + closeArray(s.input.y.map((y: number) => mixtureBelow(imp, y)), s.expected.Ycdf, TOL, "Y 的 F"); +}); + +for (const name of ["forward_merge_r1_real", "forward_merge_minimize"]) { + test(`forward.json ${name}`, () => { + const s = pick.get(name); + const { b } = s.input; + const direction = s.params.direction; + const cands = candidatesOf(s.input); + const raws = forwardMixtures(cands, s.params.lambda); + const imps = raws.map((m) => toImprovement(m, b, direction)); + closeArray(xs(raws[0]!.at(-1)!.knots), s.expected.reserveRaw, TOL, "原值上的开放预留"); + closeArray(xs(imps[0]!.at(-1)!.knots), s.expected.reserveY, TOL, "改进量上的开放预留"); + cands.forEach((c, i) => { + const e = s.expected.candidates[c.id]; + const imp = imps[i]!; + closeArray(s.input.Yprobe.map((y: number) => mixtureBelow(imp, y)), e.Ycdf, TOL, `${c.id} Y 的 F`); + const yq = [0.1, 0.5, 0.9].map((p) => mixtureQuantile(imp, p)); + closeArray(yq, [e.Yquantiles.q1, e.Yquantiles.q5, e.Yquantiles.q9], TOL, `${c.id} Y 分位数`); + const r = forwardMergedRange(imp, b, direction); + closeArray([r.q1, r.q5, r.q9], [e.rawQuantiles.q1, e.rawQuantiles.q5, e.rawQuantiles.q9], TOL, `${c.id} 原值分位数`); + close(mean(imp), e.Ymean, TOL, `${c.id} Y 期望`); + }); + }); +} + +test("forward.json ablation_cell_merge_r1_p1", () => { + const s = pick.get("ablation_cell_merge_r1_p1"); + const m = ablationCellMixture( + s.input.explanations.map((e: any) => ({ id: e.id, author: e.author, range: range(e.range) })), + s.params.lambda, + ); + closeArray(xs(m.at(-1)!.knots), s.expected.reserve, TOL, "这一格的开放预留"); + closeArray(m.map((c) => c.weight), s.expected.weights, TOL, "成分权重"); + closeArray(s.input.x.map((x: number) => mixtureBelow(m, x)), s.expected.cdf, TOL, "F"); + const q = mixtureRange(m); + closeArray([q.q1, q.q5, q.q9], [s.expected.quantiles.q1, s.expected.quantiles.q5, s.expected.quantiles.q9], TOL, "分位数"); +}); + +test("forward.json quantile_flat_left_end", () => { + const s = pick.get("quantile_flat_left_end"); + const m: Mixture = s.input.mixture.map((c: any) => ({ weight: c.weight, knots: personKnots(range(c.range)) })); + closeArray(s.input.p.map((p: number) => mixtureQuantile(m, p)), s.expected.quantiles, TOL, "分位数"); +}); + +/** 入池走选批规则的正式路径:候选的入池把握和是否入池都在 BatchDecision.candidates 里。 */ +function poolOf(s: { params: Record; input: Record }, candidates: Record) { + return selectBestExpected({ + candidates: Object.entries(candidates).map(([id, rs]) => ({ id, ranges: rangesOf(rs) })), + baseline: s.input.b, + direction: s.params.direction, + epsilon: s.params.eps, + lambda: s.params.lambda, + batchCap: 1, + balance: 1, + seed: SEED, + }).candidates; +} + +for (const name of ["pool_r1_real", "pool_minimize"]) { + test(`forward.json ${name}`, () => { + const s = pick.get(name); + const got = poolOf(s, s.input.candidates); + assert.deepEqual(got.map((c) => c.id), Object.keys(s.expected).sort()); + for (const c of got) { + close(c.lossProbability, s.expected[c.id].probBelowMinusEps, TOL, `${c.id} P(Y < −ε)`); + assert.equal(c.inPool, s.expected[c.id].inPool, `${c.id} 是否入池`); + } + }); +} + +test("forward.json pool_exactly_half", () => { + const s = pick.get("pool_exactly_half"); + s.input.cases.forEach((cs: any, i: number) => { + const [c] = poolOf(s, cs.candidates); + close(c!.lossProbability, s.expected.cases[i].probBelowMinusEps, TOL, `第 ${i} 例 P(Y < −ε)`); + assert.equal(c!.inPool, s.expected.cases[i].inPool, `第 ${i} 例是否入池`); + }); +}); + +test("forward.json V_method_example_X_Y", () => { + const s = pick.get("V_method_example_X_Y"); + const d = (name: string): Mixture => + s.input.Ymixtures[name].map((c: any) => ({ weight: c.weight, knots: personKnots(range(c.range)) })); + close(batchValue([d("X")]), s.expected.V.X, TOL, "V(X)"); + close(batchValue([d("Y")]), s.expected.V.Y, TOL, "V(Y)"); + close(batchValue([d("X"), d("Y")]), s.expected.V["X,Y"], TOL, "V(X,Y)"); +}); + +for (const name of ["batch_r1_real_k2", "batch_minimize_out_of_pool", "batch_tie_by_id"]) { + test(`forward.json ${name}`, () => { + const s = pick.get(name); + const d = selectBestExpected({ + candidates: candidatesOf(s.input), + baseline: s.input.b, + direction: s.params.direction, + epsilon: s.params.eps, + lambda: s.params.lambda, + batchCap: s.params.batchCap, + balance: s.input.balance, + seed: SEED, + }); + assert.deepEqual(d.pool, s.expected.pool, "候选池"); + assert.equal(d.k, s.expected.k, "批量"); + assert.deepEqual(d.combos.map((c) => c.ids), s.expected.combos.map((c: any) => c.combo), "列出的组合"); + closeArray(d.combos.map((c) => c.value), s.expected.combos.map((c: any) => c.V), TOL, "各组合的 V"); + assert.deepEqual(d.chosen, s.expected.selected, "选中的组合"); + close(d.chosenValue, s.expected.selectedV, TOL, "V(S*)"); + close(d.poolValue, s.expected.poolAllV, TOL, "V(候选池)"); + close(d.scaleReading, s.expected.rulerReading, TOL, "分数尺读数"); + }); +} + +test("forward.json batch_uniform_random_minimize", () => { + const s = pick.get("batch_uniform_random_minimize"); + const { studyId, round, version } = s.input.seedFrom; + assert.equal(decisionSeed({ studyId, round, version }), s.input.seed, "种子"); + const d = selectUniformRandom({ + candidates: candidatesOf(s.input), + baseline: s.input.b, + direction: s.params.direction, + epsilon: s.params.eps, + lambda: s.params.lambda, + batchCap: s.params.batchCap, + balance: s.input.balance, + seed: s.input.seed, + }); + assert.deepEqual(d.pool, s.expected.pool, "候选池"); + assert.equal(d.k, s.expected.k, "批量"); + assert.deepEqual(d.combos, [], "均匀随机不列组合"); + assert.deepEqual(d.chosen, s.expected.selected, "抽中的组合"); + assert.ok(d.chosen.some((id) => !d.pool.includes(id)), "池外候选可以被抽中"); + close(d.chosenValue, s.expected.selectedV, TOL, "V(S*)"); + close(d.poolValue, s.expected.poolAllV, TOL, "V(候选池)"); + close(d.scaleReading, s.expected.rulerReading, TOL, "分数尺读数(按候选池)"); +}); + +test("forward.json adoption_line_cases", () => { + const s = pick.get("adoption_line_cases"); + s.input.cases.forEach((c: any, i: number) => { + const { line } = adoptionLine({ baseline: c.B, knownBest: c.knownBest, epsilon: s.params.eps, direction: c.direction }); + close(line, s.expected.lines[i], TOL, `第 ${i} 例采纳线(${c.note})`); + }); +}); + +test("forward.json adoption_decision_cases", () => { + const s = pick.get("adoption_decision_cases"); + s.input.cases.forEach((c: any, i: number) => { + const e = s.expected.cases[i]; + const r = adopt({ + baseline: c.B, + knownBest: c.knownBest, + epsilon: s.params.eps, + direction: c.direction, + // 只收有有效成绩的候选(由调用方筛)。 + candidates: Object.entries(c.results as Record).flatMap(([id, score]) => + score === null ? [] : [{ id, score }], + ), + }); + close(r.line, e.line, TOL, `第 ${i} 例采纳线`); + assert.equal(r.best?.id ?? null, e.best, `第 ${i} 例最好的候选`); + if (e.best !== null) close(r.best!.improvement, e.gain, TOL, `第 ${i} 例改进`); + assert.equal(r.adopted !== null, e.adopt, `第 ${i} 例是否采纳`); + if (e.adopt) assert.equal(r.adopted, e.best); + }); +}); + +test("forward.json 每个场景都已覆盖", () => { + assert.deepEqual(pick.unused(), []); +}); diff --git a/tests/rules-fixtures-helpers.ts b/tests/rules-fixtures-helpers.ts new file mode 100644 index 0000000..a666a96 --- /dev/null +++ b/tests/rules-fixtures-helpers.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import type { Range } from "../src/vocab/index.ts"; + +// tests/fixtures/rules/ 下的预期值由独立的 Python 脚本照 docs/方法.md 和约定表算出(脚本开头注释写了格式)。 +// 这里只负责读文件和比较数字;把场景映射到 rules 的函数上,在各个 rules-fixtures-*.test.ts 里做。 + +export interface Scenario { + name: string; + section: string; + params: Record; + input: Record; + expected: any; + note: string; +} + +export interface FixtureFile { + generator: string; + tolerance: Record; + models?: Record; input: Record }>; + scenarios: Scenario[]; +} + +export function loadFixture(file: string): FixtureFile { + return JSON.parse(readFileSync(new URL(`./fixtures/rules/${file}`, import.meta.url), "utf8")) as FixtureFile; +} + +/** 按名字取场景,并登记"已覆盖";每个测试文件最后核对 JSON 里的场景都被取过。 */ +export function scenarioPicker(fixture: FixtureFile): { + get: (name: string) => Scenario; + unused: () => string[]; +} { + const used = new Set(); + return { + get(name) { + const s = fixture.scenarios.find((x) => x.name === name); + assert.ok(s, `JSON 里没有场景 ${name}`); + used.add(name); + return s; + }, + unused: () => fixture.scenarios.map((s) => s.name).filter((n) => !used.has(n)), + }; +} + +/** 绝对误差 tol 以内算相同。 */ +export function close(actual: number, expected: number, tol: number, what: string): void { + assert.ok( + Number.isFinite(actual) && Math.abs(actual - expected) <= tol, + `${what}:实现 ${actual},预期 ${expected},差 ${actual - expected},容差 ${tol}`, + ); +} + +/** 网格值:|差| ≤ 1e-12 + 1e-9·|值|(ablation.json tolerance.grid)。 */ +export function closeGrid(actual: number, expected: number, what: string): void { + close(actual, expected, 1e-12 + 1e-9 * Math.abs(expected), what); +} + +export function closeArray(actual: readonly number[], expected: readonly number[], tol: number, what: string): void { + assert.equal(actual.length, expected.length, `${what}:长度`); + actual.forEach((a, i) => close(a, expected[i]!, tol, `${what}[${i}]`)); +} + +export function range(r: { q1: number; q5: number; q9: number }): Range { + return { q1: r.q1, q5: r.q5, q9: r.q9 }; +} diff --git a/tests/rules-fixtures-info.test.ts b/tests/rules-fixtures-info.test.ts new file mode 100644 index 0000000..23b1dcc --- /dev/null +++ b/tests/rules-fixtures-info.test.ts @@ -0,0 +1,278 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { test } from "node:test"; +import { + ablationCellLimit, + ablationCellMixture, + assignEntries, + authorShares, + authorVsMerged, + bitWidth, + cellIndex, + coverage, + decisionSeed, + disagreement, + forwardBatchSize, + forwardMixtures, + personKnots, + placement, + runBalance, + seededRandom, + seededSample, + surprise, + trackRecord, + worldBits, + type Mixture, + type Placement, + type RunState, +} from "../src/rules/index.ts"; +import { canonical } from "../src/vocab/index.ts"; +import { close, closeArray, loadFixture, range, scenarioPicker } from "./rules-fixtures-helpers.ts"; + +// info.json(方法.md 6.9、6.10 第 4 条,约定 31–34、37)与 misc.json(4.2、第七部分、约定 35–40), +// 对照独立 Python 参考实现算出的预期值。 + +const info = loadFixture("info.json"); +const misc = loadFixture("misc.json"); +const pickInfo = scenarioPicker(info); +const pickMisc = scenarioPicker(misc); +/** 世界比特、分歧量:info.json tolerance.bits(理由见 tolerance.note)。 */ +const BITS_TOL: number = info.tolerance.bits; +/** 事前把握、覆盖率等普通数值:info.json tolerance.abs。惊讶另按 tolerance.surprise 的公式(见 checkSurprise)。 */ +const TOL: number = info.tolerance.abs; + +/** Python 用的落点、状态名 → rules 的代号。 */ +/** 正向一个候选的各人范围:[{researcher, range}]。 */ +const rangesOf = (rs: any[]) => rs.map((r) => ({ researcher: r.researcher as string, range: range(r.range) })); + +const PLACEMENT: Record = { + below10: "belowQ1", + from10to50: "q1ToQ5", + from50to90: "q5ToQ9", + above90: "aboveQ9", +}; + +function checkSurprise(m: Mixture, b: number, width: number, v: number, e: any, what: string): void { + const got = surprise(m, b, width, v); + assert.equal(got.cell, e.bin, `${what}:格号`); + close(got.prior, e.p, TOL, `${what}:事前把握`); + if (e.status === "ok") { + assert.equal(got.bits.status, "measured", `${what}:状态`); + // 惊讶 = −log₂p;p 是两个浮点 F 值之差,带几个 2⁻⁵³ 的绝对误差,除以 p 后放大(info.json tolerance.surprise)。 + close((got.bits as { value: number }).value, e.surprise, TOL + 2 ** -50 / (e.p * Math.LN2), `${what}:惊讶`); + } else { + assert.equal(e.status, "priorZero"); + assert.equal(got.bits.status, "unmeasured", `${what}:事前把握为 0 时是明确状态`); + } +} + +test("info.json bits_forward_r1_real", () => { + const s = pickInfo.get("bits_forward_r1_real"); + const width = bitWidth(s.params.eps); + close(width, s.params.delta, 0, "格宽 Δ"); + const ids = Object.keys(s.input.candidates); + const raws = forwardMixtures( + ids.map((id) => ({ ranges: rangesOf(s.input.candidates[id]) })), + s.params.lambda, + ); + ids.forEach((id, i) => close(worldBits(raws[i]!, s.input.b, width), s.expected.candidates[id].worldBits, BITS_TOL, `${id} 世界比特`)); + const c3 = ids.indexOf("r1-v1-c3"); + checkSurprise(raws[c3]!, s.input.b, width, s.input.actual["r1-v1-c3"], s.expected.candidates["r1-v1-c3"].surprise, "c3 惊讶"); + checkSurprise(raws[c3]!, s.input.b, width, s.input.farValue, s.expected.surpriseFarValue, "预留之外的实测"); + // 分歧量:正向一人一份范围,作者各不相同。 + const people = rangesOf(s.input.candidates["r1-v1-c3"]); + const ranges = people.map((p) => p.range); + const entries = people.map((p) => ({ id: p.researcher, author: p.researcher, range: p.range })); + const e = s.expected.disagreementC3; + close(disagreement(entries, s.input.b, width), e.disagreement, BITS_TOL, "c3 分歧量"); + // 分歧量的两项:等权平均后的比特、各人的比特(用 worldBits 在不掺预留的混合上算)。 + const avg: Mixture = ranges.map((r: any) => ({ weight: 1 / ranges.length, knots: personKnots(r) })); + close(worldBits(avg, s.input.b, width), e.avgBits, BITS_TOL, "c3 平均后的比特"); + ranges.forEach((r: any, i: number) => + close(worldBits([{ weight: 1, knots: personKnots(r) }], s.input.b, width), e.eachBits[i], BITS_TOL, `c3 第 ${i} 人比特`), + ); +}); + +test("info.json bits_ablation_cell_r1_p1", () => { + const s = pickInfo.get("bits_ablation_cell_r1_p1"); + const width = bitWidth(s.params.eps); + const entries = s.input.explanations.map((e: any) => ({ id: e.id as string, author: e.author as string, range: range(e.range) })); + const m = ablationCellMixture(entries, s.params.lambda); + close(worldBits(m, s.input.b, width), s.expected.worldBits, BITS_TOL, "世界比特"); + checkSurprise(m, s.input.b, width, s.input.actual, s.expected.surprise, "惊讶"); + // 平均后的比特:研究者等权、同人几条平分,不掺预留(两边读法相同)。 + const shares = authorShares(entries.map((e: any) => e.author)); + const avg: Mixture = entries.map((e: any, i: number) => ({ weight: shares[i]!, knots: personKnots(e.range) })); + close(worldBits(avg, s.input.b, width), s.expected.avgBits, BITS_TOL, "平均后的比特"); + // 各人的比特:每人几条解释等权平均成"这个人的折线"(6.9,只量人与人之间的差别)。 + const authors = [...new Set(entries.map((e: any) => e.author as string))]; + authors.forEach((a, i) => { + const own = entries.filter((e: any) => e.author === a); + const person: Mixture = own.map((e: any) => ({ weight: 1 / own.length, knots: personKnots(e.range) })); + close(worldBits(person, s.input.b, width), s.expected.eachPersonBits[i], BITS_TOL, `${a} 的比特`); + }); + close(disagreement(entries, s.input.b, width), s.expected.disagreement, BITS_TOL, "分歧量"); +}); + +test("info.json bits_minimize_bin_edges", () => { + const s = pickInfo.get("bits_minimize_bin_edges"); + const width = bitWidth(s.params.eps); + const ids = Object.keys(s.input.candidates); + const raws = forwardMixtures( + ids.map((id) => ({ ranges: rangesOf(s.input.candidates[id]) })), + s.params.lambda, + ); + const m = raws[ids.indexOf(s.input.candidate)]!; + close(worldBits(m, s.input.b, width), s.expected.worldBits, BITS_TOL, "世界比特"); + assert.equal(cellIndex(s.input.b, width, s.expected.edgeMinus1), -1, "正好在 b − Δ 边界上的值归第 −1 格"); + s.input.values.forEach((v: number, i: number) => checkSurprise(m, s.input.b, width, v, s.expected.surprise[i], `实测 ${v}`)); +}); + +test("info.json landing_segments_endpoints", () => { + const s = pickInfo.get("landing_segments_endpoints"); + const samples = s.input.cases.map((c: any) => ({ range: range(c.range), value: c.value as number })); + assert.deepEqual( + samples.map((c: any) => placement(c.range, c.value)), + s.expected.segments.map((x: string) => PLACEMENT[x]), + ); + const cov = coverage(samples); + assert.equal(cov.rate.status, "measured"); + close((cov.rate as { value: number }).value, s.expected.coverage, TOL, "覆盖率"); +}); + +test("info.json coverage_no_samples", () => { + const s = pickInfo.get("coverage_no_samples"); + assert.equal(s.expected.coverage.status, "noSamples"); + assert.equal(coverage(s.input.cases).rate.status, "unmeasured", "没有样本时是明确状态"); +}); + +test("info.json track_record_counts", () => { + const s = pickInfo.get("track_record_counts"); + const samples = (["forward", "ablation"] as const).flatMap((phase) => + s.input[phase].map((x: any) => ({ researcher: "r", phase, range: range(x.range), value: x.value as number })), + ); + const got = trackRecord(samples); + assert.deepEqual(got.map((g) => g.phase), ["forward", "ablation"]); + for (const g of got) { + const e = s.expected[g.phase]; + assert.deepEqual( + { + medianHigh: g.tally.medianHigh, + medianLow: g.tally.medianLow, + pierceUpper: g.tally.aboveTop, + pierceLower: g.tally.belowBottom, + covered: g.tally.covered, + }, + e, + `${g.phase} 战绩`, + ); + } +}); + +test("info.json author_vs_merged", () => { + const s = pickInfo.get("author_vs_merged"); + // 只收跑过、有有效成绩的候选(由调用方筛)。 + const items = s.input.items + .filter((i: any) => i.ran && i.value !== null) + .map((i: any) => ({ + candidate: i.candidate, + author: "author", + authorRange: range(i.author), + mergedRange: range(i.merged), + value: i.value, + })); + const got = authorVsMerged(items, s.params.direction); + assert.equal(got.length, s.expected.rows.length); + got.forEach((g, i) => { + const e = s.expected.rows[i]; + assert.equal(g.candidate, e.candidate); + close(g.optimism, e.q5Diff, TOL, `${g.candidate} 作者比合并乐观多少`); + assert.equal(g.authorPlacement, PLACEMENT[e.authorSegment]); + assert.equal(g.mergedPlacement, PLACEMENT[e.mergedSegment]); + }); +}); + +for (const name of [ + "entries_rule1_unique", + "entries_rule2_author_wins", + "entries_rule2_smallest_index", + "entries_identical_prefs", + "entries_rule3_exhausted", + "entries_N1", + "entries_natural_rule2", + "entries_natural_rule3", +]) { + test(`misc.json ${name}`, () => { + const s = pickMisc.get(name); + const got = assignEntries({ + researchers: s.input.researchers.map((id: string) => ({ id, choices: s.input.preferences[id] })), + entries: s.input.entries, + }); + assert.deepEqual( + Object.fromEntries(got.map((a) => [a.researcher, { entry: a.entry, choice: a.rank }])), + s.expected.assignments, + ); + // 结果按研究者编号(自然序)排,与 Python 的输出次序一致。 + assert.deepEqual(got.map((a) => a.researcher), Object.keys(s.expected.assignments)); + }); +} + +function balanceRow(total: number, runs: RunState[], batchCap: number) { + const b = runBalance(total, runs); + return { + charged: b.charged, + occupied: b.charged + b.reserved, + balance: b.balance, + forwardK: forwardBatchSize(batchCap, b.balance), + ablationMax: ablationCellLimit(b.balance), + }; +} + +test("misc.json balance_part7_table", () => { + const s = pickMisc.get("balance_part7_table"); + let runs: RunState[] = []; + s.input.steps.forEach((st: any, i: number) => { + runs = [...runs, ...st.added]; + const { step, added, ...e } = s.expected.afterEachStep[i]; + assert.equal(step, st.step); + assert.deepEqual(balanceRow(s.params.totalRuns, runs, s.params.batchCap), e, `第 ${i} 步 ${step}`); + }); +}); + +for (const name of ["balance_mixed_states", "balance_0", "balance_1", "balance_2"]) { + test(`misc.json ${name}`, () => { + const s = pickMisc.get(name); + assert.deepEqual(balanceRow(s.params.totalRuns, s.input.runs, s.params.batchCap), s.expected); + }); +} + +for (const name of ["seed_study-a_r1", "seed_15-real-1_r3"]) { + test(`misc.json ${name}`, () => { + const s = pickMisc.get(name); + const { studyId, round, version } = s.input; + assert.equal(canonical({ studyId, round, version }), s.expected.canonical, "规范 JSON"); + const seed = decisionSeed({ studyId, round, version }); + assert.equal(seed, s.expected.seed, "种子"); + // 第 counter 个数取 sha256(`${seed}:${counter}`) 的前 6 字节(约定 40),这里只核对两边对这个式子的理解相同。 + assert.deepEqual( + [0, 1, 2].map((c) => createHash("sha256").update(`${seed}:${c}`).digest().subarray(0, 6).toString("hex")), + s.expected.first6BytesHex, + ); + const rng = seededRandom(seed); + assert.deepEqual([0, 1, 2, 3, 4].map(() => rng.uniformInt(10)), s.expected.uniformInt10First5); + assert.deepEqual(seededSample(s.input.candidates, s.input.batchSize, seed), s.expected.batchSample, "抽候选"); + assert.deepEqual(seededSample(s.input.cells, s.input.cellCount, seed), s.expected.cellSample, "抽格"); + // 十二个编号:候选和因子都按自然序(c2 < c10、p2 < p10)排好再抽。 + assert.deepEqual(seededSample(s.input.candidatesMany, s.input.manyCount, seed), s.expected.batchSampleMany, "抽候选(12 个)"); + assert.deepEqual( + seededSample(s.input.cellsMany, s.input.manyCount, seed), + s.expected.cellSampleMany, + "抽格(12 个)", + ); + }); +} + +test("info.json、misc.json 每个场景都已覆盖", () => { + assert.deepEqual(pickInfo.unused(), []); + assert.deepEqual(pickMisc.unused(), []); +}); diff --git a/tests/rules-forward.test.ts b/tests/rules-forward.test.ts new file mode 100644 index 0000000..62ea3ef --- /dev/null +++ b/tests/rules-forward.test.ts @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { test } from "node:test"; +import { + adopt, + adoptionLine, + batchValue, + runBatchRule, + selectBestExpected, + selectUniformRandom, + type BatchInput, + personKnots, + type Mixture, +} from "../src/rules/index.ts"; + +// 预期值来源写在每条测试旁边。数取二进制能精确表示的数(0.25、0.5 等),避免浮点末位干扰"正好相等"的边界。 + +function close(actual: number, expected: number, tol = 1e-12): void { + assert.ok(Math.abs(actual - expected) <= tol, `实际 ${actual},预期 ${expected}`); +} + +/** 很窄的范围 (y − 0.01, y, y + 0.01),连尾巴 [y − 0.02, y + 0.02]。 */ +const narrow = (y: number) => ({ q1: y - 0.01, q5: y, q9: y + 0.01 }); +/** 一个人交的一份范围(研究者 r1)。 */ +const one = (range: { q1: number; q5: number; q9: number }) => [{ researcher: "r1", range }]; + +test("6.4 示例:X 一半涨 0、一半涨 0.02,Y 一定涨 0.01;Y 单跑 0.010,一起跑 0.015", () => { + // 来源:方法.md 6.4 示例,把"一定涨"换成很窄的范围(三个数严格递增)。改进量上直接给: + // X = 一半 (−0.001, 0, 0.001)、一半 (0.019, 0.02, 0.021);Y = (0.009, 0.01, 0.011)。 + // 预期值由 tests/fixtures/rules/rules_ref.py 的 V(有理数精确)算出:V(X) = 407/40000 = 0.010175(窄范围在 0 以下的 + // 一点被截成 0,比 0.010 多 0.5 × 0.00035);V(Y) = 0.01;V(X, Y) = 3/200 = 0.015。 + const tri = (y: number, h: number) => personKnots({ q1: y - h, q5: y, q9: y + h }); + const X: Mixture = [ + { weight: 0.5, knots: tri(0, 0.001) }, + { weight: 0.5, knots: tri(0.02, 0.001) }, + ]; + const Y: Mixture = [{ weight: 1, knots: tri(0.01, 0.001) }]; + close(batchValue([X]), 407 / 40000); + close(batchValue([Y]), 0.01); + close(batchValue([X, Y]), 0.015); + assert.equal(batchValue([]), 0); +}); + +function input(over: Partial): BatchInput { + return { + candidates: [], + baseline: 0, + direction: "maximize", + epsilon: 0.25, + lambda: 0, + batchCap: 2, + balance: 10, + seed: "seed-1", + ...over, + }; +} + +test("6.4 入池:P(Y < −ε) 正好一半不入池,小于一半入池", () => { + // 手算:b = 1,ε = 0.25,λ = 0。甲交 (0.5, 0.75, 1):P(分数 < 0.75) = 0.5,不入池。 + // 乙交 (0.5, 0.8125, 1):P(分数 < 0.75) = 0.1 + 0.4 × 0.25/0.3125 = 0.42,入池。 + const d = selectBestExpected( + input({ + baseline: 1, + candidates: [ + { id: "a", ranges: one({ q1: 0.5, q5: 0.75, q9: 1 }) }, + { id: "b", ranges: one({ q1: 0.5, q5: 0.8125, q9: 1 }) }, + ], + }), + ); + assert.equal(d.candidates[0]!.lossProbability, 0.5); + assert.equal(d.candidates[0]!.inPool, false); + close(d.candidates[1]!.lossProbability, 0.42); + assert.deepEqual(d.pool, ["b"]); +}); + +test("6.4 入池(越小越好):交 (1, 1.25, 1.5)、b = 1、ε = 0.25 时 P(Y < −ε) = 0.5,不入池", () => { + // 手算:越小越好时 Y = b − 分数,Y < −0.25 即分数 > 1.25,把握 0.5。 + const d = selectBestExpected( + input({ baseline: 1, direction: "minimize", candidates: [{ id: "a", ranges: one({ q1: 1, q5: 1.25, q9: 1.5 }) }] }), + ); + close(d.candidates[0]!.lossProbability, 0.5); + assert.equal(d.candidates[0]!.lossProbability >= 0.5, true); + assert.deepEqual(d.pool, []); + assert.deepEqual(d.chosen, []); + assert.equal(d.chosenValue, 0); + assert.equal(d.scaleReading, 0); +}); + +test("6.4 选批:列出全部组合;V 完全相等时按编号定;池外候选不进组合", () => { + // b = 0,λ = 0,k = 1。四个窄范围:a 在 0.25,b、c 都在 0.75(输入完全相同,V 逐位相等), + // d 在 −0.5(P(Y < −ε) = 1,不入池)。手算:V(a) = 0.25,V(b) = V(c) = 0.75(对称窄范围的期望,都在 0 以上)。 + // b、c 相等,取编号小的 b。V(池) = E[max(a, b, c)] = 11323/15000,分数尺读数 = 11323/15000 − 0.75 = 73/15000 + // (rules_ref.py 的 V,有理数精确)。 + const d = selectBestExpected( + input({ + batchCap: 1, + candidates: [ + { id: "d", ranges: one(narrow(-0.5)) }, + { id: "c", ranges: one(narrow(0.75)) }, + { id: "b", ranges: one(narrow(0.75)) }, + { id: "a", ranges: one(narrow(0.25)) }, + ], + }), + ); + assert.equal(d.k, 1); + assert.deepEqual(d.pool, ["a", "b", "c"]); + assert.deepEqual( + d.combos.map((c) => c.ids.join("")), + ["a", "b", "c"], + ); + close(d.combos[0]!.value, 0.25); + close(d.combos[1]!.value, 0.75); + assert.equal(d.combos[1]!.value, d.combos[2]!.value); + assert.deepEqual(d.chosen, ["b"]); + close(d.poolValue, 11323 / 15000); + close(d.scaleReading, 73 / 15000); + assert.deepEqual(d.candidates.map((c) => c.id), ["a", "b", "c", "d"]); + const m = d.candidates[1]!.merged; + close(m.q1, 0.74); + close(m.q5, 0.75); + close(m.q9, 0.76); +}); + +test("6.4 分数尺读数 = V(候选池) − V(S*)", () => { + // 手算:b = 0,λ = 0,k = 1。a 交 (0, 0.5, 1):E[max(0, Y)] = ∫₀^1.5 (1 − F) + // = 0.5 × (1 − 0.3) + 0.5 × (1 − 0.7) + 0.5 × (1 − 0.95) = 0.35 + 0.15 + 0.025 = 0.525。 + // b 交 (0.49, 0.5, 0.51):V = 0.5。选 a。V(池) = E[max(a, b)] = 31642/46875,读数 31642/46875 − 0.525 + // (rules_ref.py 的 V,有理数精确;b 换成 0.5 处的定值时是 0.675 和 0.15)。 + const d = selectBestExpected( + input({ + batchCap: 1, + candidates: [ + { id: "a", ranges: one({ q1: 0, q5: 0.5, q9: 1 }) }, + { id: "b", ranges: one(narrow(0.5)) }, + ], + }), + ); + assert.deepEqual(d.chosen, ["a"]); + close(d.chosenValue, 0.525); + close(d.poolValue, 31642 / 46875); + close(d.scaleReading, 31642 / 46875 - 0.525); +}); + +test("6.4 批量 k = min(每批上限, 余额);池里不足 k 个时全选", () => { + // 手算:余额 1 时 k = 1;余额 5、上限 2 时 k = 2,但池里只有 1 个,全选。 + const cands = [ + { id: "a", ranges: one(narrow(0.25)) }, + { id: "b", ranges: one(narrow(0.5)) }, + ]; + assert.equal(selectBestExpected(input({ balance: 1, candidates: cands })).k, 1); + assert.deepEqual(selectBestExpected(input({ balance: 1, candidates: cands })).chosen, ["b"]); + const single = selectBestExpected(input({ balance: 5, candidates: [{ id: "a", ranges: one(narrow(0.25)) }] })); + assert.equal(single.k, 2); + assert.deepEqual(single.chosen, ["a"]); +}); + +// 约定表第 40 条的独立写法:sha256(`${seed}:${counter}`) 前 6 字节,拒绝采样,部分 Fisher–Yates。 +function referenceSample(ids: string[], size: number, seed: string): string[] { + const xs = [...ids].sort(); + let counter = 0; + const draw = (n: number): number => { + const limit = 2 ** 48 - (2 ** 48 % n); + for (;;) { + const hex = createHash("sha256").update(`${seed}:${counter}`).digest("hex").slice(0, 12); + counter += 1; + const v = parseInt(hex, 16); + if (v < limit) return v % n; + } + }; + for (let i = 0; i < size; i++) { + const j = i + draw(xs.length - i); + const t = xs[i]!; + xs[i] = xs[j]!; + xs[j] = t; + } + return xs.slice(0, size).sort(); +} + +test("均匀随机选批:不看入池门槛;同一个种子得到同一组;与约定表第 40 条的独立写法一致", () => { + const cands = ["e", "a", "d", "b", "c"].map((id, i) => ({ id, ranges: one(narrow(i === 0 ? -1 : 0.25 * i)) })); + for (const seed of ["s1", "s2", "s3", "s4"]) { + const d = selectUniformRandom(input({ seed, candidates: cands })); + assert.deepEqual(d.chosen, referenceSample(["a", "b", "c", "d", "e"], 2, seed)); + assert.deepEqual(selectUniformRandom(input({ seed, candidates: [...cands].reverse() })).chosen, d.chosen); + assert.deepEqual(d.combos, []); + } + // 批量不小于候选数时全部抽中,包括池外的 e(窄范围在 −1)。 + const all = selectUniformRandom(input({ batchCap: 9, candidates: cands })); + assert.deepEqual(all.chosen, ["a", "b", "c", "d", "e"]); + assert.equal(all.candidates.find((c) => c.id === "e")!.inPool, false); +}); + +test("登记表:两条选批规则按名字跑,结果带规则名和版本", () => { + const cands = [ + { id: "a", ranges: one(narrow(0.25)) }, + { id: "b", ranges: one(narrow(0.5)) }, + ]; + const best = runBatchRule("bestExpected", input({ batchCap: 1, candidates: cands })); + assert.deepEqual(best.rule, { name: "bestExpected", version: 1 }); + assert.deepEqual(best.chosen, ["b"]); + const rnd = runBatchRule("uniformRandom", input({ batchCap: 1, candidates: cands, seed: "x" })); + assert.deepEqual(rnd.rule, { name: "uniformRandom", version: 1 }); + assert.deepEqual(rnd.chosen, referenceSample(["a", "b"], 1, "x")); + assert.equal(rnd.seed, "x"); +}); + +test("同样的输入算两次,选批结果逐位相同", () => { + const two = (r1: { q1: number; q5: number; q9: number }, r2: { q1: number; q5: number; q9: number }) => [ + { researcher: "r1", range: r1 }, + { researcher: "r2", range: r2 }, + ]; + const i = input({ + lambda: 0.1, + candidates: [ + { id: "a", ranges: two({ q1: 0.1, q5: 0.3, q9: 0.7 }, { q1: -0.2, q5: 0.1, q9: 0.2 }) }, + { id: "b", ranges: two({ q1: 0, q5: 0.2, q9: 0.9 }, { q1: 0.05, q5: 0.15, q9: 0.25 }) }, + { id: "c", ranges: two({ q1: -0.3, q5: 0.25, q9: 0.6 }, narrow(0.1)) }, + ], + }); + assert.deepEqual(selectBestExpected(i), selectBestExpected(i)); +}); + +test("6.5 采纳线:没有已知最好是 ε;有时 min(ε, (K* − B)/4),下限 ε/100,B 超过 K* 也取 ε/100", () => { + // 手算:ε = 0.01,K* = 2.635983。 + assert.deepEqual(adoptionLine({ baseline: 2.5, knownBest: null, epsilon: 0.01, direction: "maximize" }), { + line: 0.01, + quarter: null, + }); + // B = 2.5:(0.135983)/4 = 0.03399575 > ε,取 ε。 + assert.equal(adoptionLine({ baseline: 2.5, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.01); + // B = 2.6:0.035983/4 = 0.00899575。 + close(adoptionLine({ baseline: 2.6, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.00899575); + // B = 2.6359:0.000083/4 = 0.00002075 < 0.0001,取 ε/100 = 0.0001。 + assert.equal(adoptionLine({ baseline: 2.6359, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.01 / 100); + // B = 2.64 超过 K*:取 0.0001。 + assert.equal(adoptionLine({ baseline: 2.64, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.01 / 100); + // ε 换成 1 时下限是 0.01,跟随 ε。 + assert.equal(adoptionLine({ baseline: 3, knownBest: 2, epsilon: 1, direction: "maximize" }).line, 0.01); + // 越小越好:K* = 1,B = 1.02:(1.02 − 1)/4 = 0.005。 + close(adoptionLine({ baseline: 1.02, knownBest: 1, epsilon: 0.01, direction: "minimize" }).line, 0.005); +}); + +test("4.3 ⑫ 采纳:最好的有效候选严格高出采纳线才采纳;正好等于不采纳;同分取编号小的", () => { + // 手算:B = 1,ε = 0.25,没有已知最好,采纳线 0.25。 + const base = { baseline: 1, knownBest: null, epsilon: 0.25, direction: "maximize" as const }; + assert.equal(adopt({ ...base, candidates: [{ id: "a", score: 1.25 }] }).adopted, null); + assert.equal(adopt({ ...base, candidates: [{ id: "a", score: 1.5 }] }).adopted, "a"); + const tie = adopt({ + ...base, + candidates: [ + { id: "b", score: 1.5 }, + { id: "a", score: 1.5 }, + { id: "c", score: 1.25 }, + ], + }); + assert.deepEqual(tie.best, { id: "a", score: 1.5, improvement: 0.5 }); + assert.equal(tie.adopted, "a"); + assert.equal(adopt({ ...base, candidates: [] }).adopted, null); + // 越小越好:分数 0.5 比 B = 1 改进 0.5。 + assert.equal(adopt({ ...base, direction: "minimize", candidates: [{ id: "a", score: 0.5 }] }).adopted, "a"); +}); diff --git a/tests/rules-order.test.ts b/tests/rules-order.test.ts new file mode 100644 index 0000000..65d3ba0 --- /dev/null +++ b/tests/rules-order.test.ts @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ablationModel, + assignEntries, + authorVsMerged, + chooseBet, + compareIds, + disagreement, + evaluateCombos, + forwardMixtures, + pickByNet, + selectBestExpected, + selectByValue, + settleAblation, + topCombo, + worldBits, + type AblationCell, + type AblationExplanation, + type CellRuleInput, +} from "../src/rules/index.ts"; +import { canonical } from "../src/vocab/index.ts"; +import { loadFixture, range } from "./rules-fixtures-helpers.ts"; + +// 审阅提出的四个问题各一组复现测试: +// 一、结果不随传入次序变(6.7"重算结果逐位相同"):求和次序由编号定。打乱输入后用 canonical 比整个返回值,逐位相同。 +// 二、编号按自然序比("编号最小"是数字意义上的大小)。 +// 三、入口分配与数组次序无关。 +// 四、选格不做容差接力(6.7"选格"第 2 条)。 + +/** 真实第 1 轮消融(三格、三人各两条解释),各条解释和各格交叉着出现,求和次序一变末位就会变。 */ +const R1 = loadFixture("ablation.json").models!.r1real!.input; +const EXPLANATIONS: AblationExplanation[] = R1.explanations.map((e: any) => ({ + id: e.id, + author: e.author, + cells: Object.entries(e.cells).map(([factor, r]) => ({ factor, range: range(r as any) })), +})); +const CELLS: AblationCell[] = [ + { factor: "p1", outcome: { kind: "valid", score: 2.250011787062861 } }, + { factor: "p2", outcome: { kind: "insufficient" } }, + { factor: "p3", outcome: { kind: "valid", score: 2.4389662413796342 } }, +]; +const BASE = { + baseline: R1.B as number, + full: R1.full as number, + direction: "maximize" as const, + knownBest: R1.knownBest as number, + epsilon: 0.01, + lambda: 0.1, +}; + +/** 几种打乱:倒序、轮转、奇偶交错,以及解释内各格也倒序。 */ +function shuffles(xs: readonly T[]): T[][] { + const rot = [...xs.slice(1), ...xs.slice(0, 1)]; + const evenOdd = [...xs.filter((_, i) => i % 2 === 0), ...xs.filter((_, i) => i % 2 === 1)]; + return [[...xs].reverse(), rot, evenOdd]; +} +const explanationShuffles = (): AblationExplanation[][] => + shuffles(EXPLANATIONS).map((es) => es.map((e) => ({ ...e, cells: [...e.cells].reverse() }))); + +test("一、消融结算:打乱解释和格的次序,结果逐位相同", () => { + const factors = ["p1", "p2", "p3"]; + const ref = canonical(settleAblation({ ...BASE, factors, cells: CELLS, explanations: EXPLANATIONS })); + for (const es of explanationShuffles()) { + for (const cs of shuffles(CELLS)) { + for (const fs of shuffles(factors)) { + assert.equal(canonical(settleAblation({ ...BASE, factors: fs, cells: cs, explanations: es })), ref); + } + } + } +}); + +test("一、信息价值与选格:打乱解释和因子的次序,组合表和选格决定逐位相同", () => { + const input = (es: AblationExplanation[], factors: string[]): CellRuleInput => ({ + ...BASE, + factors, + explanations: es, + balance: 4, + batchCap: 2, + u: 0.5, + decay: 0.5, + seed: "seed", + }); + const factors = ["p1", "p2", "p3"]; + const refModel = ablationModel(input(EXPLANATIONS, factors)); + const refCombos = canonical(evaluateCombos(refModel, input(EXPLANATIONS, factors))); + const refDecision = canonical(selectByValue(input(EXPLANATIONS, factors))); + for (const es of explanationShuffles()) { + for (const fs of shuffles(factors)) { + const i = input(es, fs); + assert.equal(canonical(evaluateCombos(ablationModel(i), i)), refCombos); + assert.equal(canonical(selectByValue(i)), refDecision); + } + } +}); + +test("一、押的因子:打乱解释、因子和格的次序,结果逐位相同", () => { + const factors = ["p1", "p2", "p3"]; + const ref = canonical(chooseBet({ ...BASE, factors, explanations: EXPLANATIONS, cells: CELLS })); + for (const es of explanationShuffles()) { + for (const fs of shuffles(factors)) { + for (const cs of shuffles(CELLS)) { + assert.equal(canonical(chooseBet({ ...BASE, factors: fs, explanations: es, cells: cs })), ref); + } + } + } +}); + +test("一、分歧量:打乱条目次序,结果逐位相同", () => { + for (const f of ["p1", "p2", "p3"]) { + const entries = EXPLANATIONS.map((e) => ({ + id: e.id, + author: e.author, + range: e.cells.find((c) => c.factor === f)!.range, + })); + const ref = disagreement(entries, BASE.baseline, 1e-4); + for (const es of shuffles(entries)) assert.equal(disagreement(es, BASE.baseline, 1e-4), ref, `${f}`); + } +}); + +/** 正向三位研究者(researcher-2、researcher-10、researcher-3,故意让字符串序和自然序不同)对三个候选的范围。 */ +const FORWARD_PEOPLE = ["researcher-2", "researcher-10", "researcher-3"]; +const forwardRanges = (a: number[][]) => + a.map(([q1, q5, q9], i) => ({ researcher: FORWARD_PEOPLE[i]!, range: { q1: q1!, q5: q5!, q9: q9! } })); +const FORWARD = [ + { id: "c1", ranges: forwardRanges([[2.0, 2.45, 2.6], [2.2, 2.5, 2.62], [2.2, 2.5, 2.63]]) }, + { id: "c2", ranges: forwardRanges([[1.85, 2.35, 2.6], [1.6, 2.35, 2.6], [1.8, 2.4, 2.62]]) }, + { id: "c10", ranges: forwardRanges([[2.3, 2.5, 2.62], [2.42, 2.52, 2.6], [2.1, 2.5, 2.6]]) }, +]; + +test("一、正向:打乱候选的次序和每个候选里各人范围的次序,选批结果、合并分布的比特逐位相同", () => { + const run = (cs: typeof FORWARD) => + canonical( + selectBestExpected({ + candidates: cs, + baseline: BASE.baseline, + direction: "maximize", + epsilon: 0.01, + lambda: 0.1, + batchCap: 2, + balance: 10, + seed: "seed", + }), + ); + const bits = (cs: typeof FORWARD) => + forwardMixtures(cs, 0.1).map((m) => worldBits(m, BASE.baseline, 1e-4)); + const ref = run(FORWARD); + const refBits = bits(FORWARD); + for (const cs of shuffles(FORWARD)) { + for (const k of [0, 1, 2]) { + // 每个候选里各人范围也打乱(三种打乱轮着用)。 + const mixed = cs.map((c) => ({ ...c, ranges: shuffles(c.ranges)[k]! })); + assert.equal(run(mixed), ref); + const byId = new Map(mixed.map((c, i) => [c.id, bits(mixed)[i]!])); + assert.deepEqual(FORWARD.map((c) => byId.get(c.id)), refBits); + } + } +}); + +test("一、正向分歧量:打乱各人次序,结果逐位相同", () => { + for (const c of FORWARD) { + const entries = c.ranges.map((r) => ({ id: r.researcher, author: r.researcher, range: r.range })); + const ref = disagreement(entries, BASE.baseline, 1e-4); + for (const es of shuffles(entries)) assert.equal(disagreement(es, BASE.baseline, 1e-4), ref, c.id); + } +}); + +test("二、编号按自然序:c2 < c10、researcher-2 < researcher-10、p2 < p10", () => { + assert.ok(compareIds("c2", "c10") < 0); + assert.ok(compareIds("researcher-2", "researcher-10") < 0); + assert.ok(compareIds("p2", "p10") < 0); + assert.ok(compareIds("r1-e2-1", "r1-e10-1") < 0); + assert.ok(compareIds("c9", "d1") < 0, "非数字段按字符串比"); + assert.ok(compareIds("c", "c1") < 0, "段先用完的排前面"); + // 数字段不转成浮点数:超出 2^53 的长编号也按数值比。 + assert.ok(compareIds("c9007199254740993", "c9007199254740992") > 0); + assert.ok(compareIds("c010", "c9") > 0, "去掉前导零后比位数"); + assert.ok(compareIds("p01", "p1") !== 0, "数值相同、写法不同的编号不相等"); + assert.equal(compareIds("c10", "c10"), 0); + assert.deepEqual(["c10", "c2", "c1", "c11"].sort(compareIds), ["c1", "c2", "c10", "c11"]); +}); + +test("二、批内最好期望完全相等时,c2 排在 c10 前面", () => { + const r = [{ researcher: "r1", range: { q1: 2.4, q5: 2.5, q9: 2.6 } }]; + const d = selectBestExpected({ + candidates: [ + { id: "c10", ranges: r }, + { id: "c2", ranges: r }, + ], + baseline: 2.45, + direction: "maximize", + epsilon: 0.01, + lambda: 0.1, + batchCap: 1, + balance: 10, + seed: "seed", + }); + assert.equal(d.combos[0]!.value, d.combos[1]!.value); + assert.deepEqual(d.chosen, ["c2"]); +}); + +test("二、三、researcher-2 与 researcher-10 争同一个入口:归编号小的 researcher-2,与数组次序无关", () => { + const researchers = [ + { id: "researcher-10", choices: ["e1"] }, + { id: "researcher-2", choices: ["e1"] }, + { id: "researcher-3", choices: ["e10"] }, + ]; + const entries = [ + { id: "e10", author: "researcher-2" }, + { id: "e1", author: "researcher-3" }, + { id: "e2", author: "researcher-3" }, + ]; + const expected = [ + { researcher: "researcher-2", entry: "e1", rank: 1 }, + { researcher: "researcher-3", entry: "e10", rank: 1 }, + { researcher: "researcher-10", entry: "e2", rank: null }, + ]; + assert.deepEqual(assignEntries({ researchers, entries }), expected); + for (const rs of shuffles(researchers)) { + for (const es of shuffles(entries)) assert.deepEqual(assignEntries({ researchers: rs, entries: es }), expected); + } +}); + +test("四、选格不做容差接力:{p1}=1、{p2}=1+0.9e-12、{p1,p2}=1+1.8e-12 选 {p2}", () => { + const combos = [ + { factors: ["p1"], net: 1 }, + { factors: ["p2"], net: 1 + 0.9e-12 }, + { factors: ["p1", "p2"], net: 1 + 1.8e-12 }, + ]; + assert.deepEqual(pickByNet(combos), ["p2"]); + // 与列出的次序无关。 + assert.deepEqual(pickByNet([...combos].reverse()), ["p2"]); + // "含这一格的净值最大组合"同一条规则:含 p1 的里最大 1+1.8e-12,窗口内只有 {p1,p2}。 + assert.deepEqual(topCombo(combos.filter((c) => c.factors.includes("p1")))!.factors, ["p1", "p2"]); + // 窗口内格数相同时按因子编号比,p2 < p10。 + assert.deepEqual( + pickByNet([ + { factors: ["p10"], net: 1 + 0.5e-12 }, + { factors: ["p2"], net: 1 }, + ]), + ["p2"], + ); + // 最大净值不大于 1e-12 时一格不测。 + assert.deepEqual(pickByNet([{ factors: ["p1"], net: 1e-12 }]), []); +}); + +test("四、选格先只留净值 > 1e-12 的组合再求最大:{p1}=0.6e-12、{p1,p2}=1.5e-12 选 {p1,p2}", () => { + // 第二轮审阅的反例。先在全部组合里求最大再开窗口,{p1} 落在窗口里、格数少,会被选中,但它的净值不 > 1e-12。 + const combos = [ + { factors: ["p1"], net: 0.6e-12 }, + { factors: ["p1", "p2"], net: 1.5e-12 }, + ]; + assert.deepEqual(pickByNet(combos), ["p1", "p2"]); + // "含这一格的最好组合"只是记录,不受 > 1e-12 限制:只在 {p1} 一个组合里挑,就是它。 + assert.deepEqual(topCombo([{ factors: ["p1"], net: 0.6e-12 }])!.factors, ["p1"]); +}); + +test("一、作者自评 vs 合并:输出按候选编号的自然序排,不跟着输入次序", () => { + // 手算(越大越好):c10 作者一半 2.5、合并一半 2.4,乐观 0.1;c2 作者一半 2.3、合并一半 2.4,乐观 −0.1。 + const r = (q5: number) => ({ q1: q5 - 0.1, q5, q9: q5 + 0.1 }); + const items = [ + { candidate: "c10", author: "r1", authorRange: r(2.5), mergedRange: r(2.4), value: 2.45 }, + { candidate: "c2", author: "r2", authorRange: r(2.3), mergedRange: r(2.4), value: 2.45 }, + ]; + for (const xs of [items, [...items].reverse()]) { + const got = authorVsMerged(xs, "maximize"); + assert.deepEqual(got.map((g) => g.candidate), ["c2", "c10"]); + assert.ok(Math.abs(got[0]!.optimism - -0.1) <= 1e-12 && Math.abs(got[1]!.optimism - 0.1) <= 1e-12); + } +}); diff --git a/tests/rules-value.test.ts b/tests/rules-value.test.ts new file mode 100644 index 0000000..7a00d02 --- /dev/null +++ b/tests/rules-value.test.ts @@ -0,0 +1,393 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ablationModel, + cellGrid, + chooseBet, + evaluateCombos, + gridSize, + informationValue, + informationValueExact, + informationValueGrid, + measuredGrid, + pickByNet, + remainingSpace, + runCellRule, + selectAll, + selectByValue, + selectNone, + selectRandomSameCount, + type AblationExplanation, + type CellRuleInput, +} from "../src/rules/index.ts"; + +// 预期值来源写在每条测试旁边。手算用的模型(下称"模型甲"): +// 基线 0、完整 1、越大越好,G = 1,撑几成 s = 1 − 分数;没有已知最好,h = 1;λ = 0(开放预留权重 0)。 +// 一条解释:p1 交很窄的 (0.49, 0.5, 0.51),s₁ 的折线是 (.48,0)(.49,.1)(.5,.5)(.51,.9)(.52,1); +// p2 交 (0.25, 0.5, 0.75),s₂ 的折线是 (0,0)(.25,.1)(.5,.5)(.75,.9)(1,1)。E s₁ = E s₂ = 0.5,ĝ₀ = 0.5。 +// VOI({p2}) = E[max(0.5, s₂)] − 0.5 = ∫_{0.5}^{1} (1 − F) = 0.25 × 0.3 + 0.25 × 0.05 = 0.0875。 +// VOI({p1}) = E[max(s₁, 0.5)] − 0.5 = 0.4 × 0.005 + 0.1 × 0.015 = 0.0035([.5,.51] 上把握 0.4、平均超出 0.005; +// [.51,.52] 上把握 0.1、平均超出 0.015)。 +// VOI({p1, p2}) = E[max(s₁, s₂)] − 0.5 = 65669/750000 = 0.0875586666…(tests/fixtures/rules/rules_ref.py 的 +// AblationModel.voi_full_exact,有理数精确算)。 + +function close(actual: number, expected: number, tol = 1e-12): void { + assert.ok(Math.abs(actual - expected) <= tol, `实际 ${actual},预期 ${expected}`); +} + +function explanation(id: string, author: string, cells: Record): AblationExplanation { + return { + id, + author, + cells: Object.entries(cells).map(([factor, [q1, q5, q9]]) => ({ factor, range: { q1, q5, q9 } })), + }; +} + +const modelA = { + baseline: 0, + full: 1, + direction: "maximize" as const, + knownBest: null, + epsilon: 0.01, + lambda: 0, + factors: ["p2", "p1"], + explanations: [explanation("e1", "A", { p1: [0.49, 0.5, 0.51], p2: [0.25, 0.5, 0.75] })], +}; +const VOI_P1 = 0.0035; +const VOI_BOTH = 65669 / 750000; + +function ruleInput(over: Partial): CellRuleInput { + return { ...modelA, balance: 21, batchCap: 2, u: 1, decay: 1, seed: "seed", ...over }; +} + +test("6.7 剩余空间 h:有已知最好 max(K* − f(完整), ε/100),没有时 1", () => { + close(remainingSpace(2.6, 2.635983, 0.01, "maximize"), 0.035983); + assert.equal(remainingSpace(2.64, 2.635983, 0.01, "maximize"), 0.0001); + assert.equal(remainingSpace(2.6, null, 0.01, "maximize"), 1); + close(remainingSpace(1.2, 1, 0.01, "minimize"), 0.2); +}); + +test("6.7 起始权重:解释合起来 1 − λ,研究者等权、同一人平分;开放预留 λ", () => { + // 手算:A 两条、B 一条,λ = 0.1:A 各 0.9 × 0.25 = 0.225,B 0.9 × 0.5 = 0.45,预留 0.1。 + const m = ablationModel({ + ...modelA, + lambda: 0.1, + explanations: [ + explanation("a2", "A", { p1: [0.4, 0.5, 0.6], p2: [0.4, 0.5, 0.6] }), + explanation("b1", "B", { p1: [0.4, 0.5, 0.6], p2: [0.4, 0.5, 0.6] }), + explanation("a1", "A", { p1: [0.4, 0.5, 0.6], p2: [0.4, 0.5, 0.6] }), + ], + }); + assert.deepEqual(m.components, ["a1", "a2", "b1", "openReserve"]); + assert.deepEqual(m.weights, [0.9 * 0.25, 0.9 * 0.25, 0.9 * 0.5, 0.1]); +}); + +test("6.7 部分格网格的每格小段数 m:m^k ≤ 4,000,000 的最大整数", () => { + // 手算:158³ = 3,944,312 ≤ 4e6 < 159³ = 4,019,679;44⁴ = 3,748,096 ≤ 4e6 < 45⁴ = 4,100,625。 + assert.deepEqual([1, 2, 3, 4].map(gridSize), [4_000_000, 2000, 158, 44]); +}); + +test("6.7 网格:每个成分在一格上的小段把握加起来是 1", () => { + // 手算:一个成分 [0, 1] 均匀,一个成分 [0.5, 1] 均匀。折点 0、0.5、1 两段,k = 1 时 s = 20,共 40 个小段,宽 0.025。 + // 第一个成分每个小段 0.025;第二个成分前 20 段 0,后 20 段各 0.05。 + const g = cellGrid( + [ + [ + [0, 0], + [1, 1], + ], + [ + [0.5, 0], + [1, 1], + ], + ], + 1, + ); + assert.equal(g.mids.length, 40); + close(g.mids[0]!, 0.0125); + close(g.mass[0]!.reduce((s, x) => s + x, 0), 1); + close(g.mass[1]!.reduce((s, x) => s + x, 0), 1); + close(g.mass[0]![3]!, 0.025); + assert.equal(g.mass[1]![19], 0); + close(g.mass[1]![20]!, 0.05); +}); + +test("6.7 段数超过 m 时整格等宽切 m 份", () => { + // 手算:k = 4 时 m = 44;45 个折点有 44 段 → s = 1;46 个折点有 45 段 > 44 → 整格等宽切 44 份。 + const line = (n: number): [number, number][] => Array.from({ length: n }, (_, i) => [i, i / (n - 1)]); + assert.equal(cellGrid([line(45)], 4).mids.length, 44); + const g = cellGrid([line(46)], 4); + assert.equal(g.mids.length, 44); + close(g.mids[0]!, 45 / 44 / 2); +}); + +test("6.7 信息价值:模型甲的手算值;全测的精确算法与网格互相对照", () => { + const m = ablationModel(modelA); + assert.deepEqual(m.factors, ["p1", "p2"]); + close(m.prior[0]!, 0.5); + close(m.prior[1]!, 0.5); + close(informationValue(m, [1]), 0.0875); + // 只测 p1:s₁ 每个小段内均匀,0.5 正好是折点,max(s, 0.5) 在每个小段上是一次函数,中点法在这里是精确的。 + close(informationValue(m, [0]), VOI_P1); + close(informationValue(m, [0, 1]), VOI_BOTH); + close(informationValueExact(m), VOI_BOTH); + // 全测时网格是近似,按 ablation.json tolerance.gridVsExact 的 k ≤ 3 界(5e-4 相对)对照。 + close(informationValueGrid(m, [0, 1]), VOI_BOTH, 5e-4 * VOI_BOTH); + assert.equal(informationValue(m, []), 0); +}); + +test("6.7 价值、成本、净值:u、m′、R̂、ĝ₀ 按原文", () => { + // 手算:模型甲,余额 21、每批上限 K = 2、u = 1、r = 1。 + // 一格:R̂ = ⌈20/2⌉ = 10,m′ = 10;成本 = 2 × 0.5 × (3/4 − 2/3) = 1/12。 + // 两格:R̂ = ⌈19/2⌉ = 10,m′ = 10;成本 = 2 × 0.5 × (4/5 − 2/3) = 2/15。 + // [p1]:价值 10 × 0.0035 = 0.035,净值 0.035 − 1/12;[p2]:价值 0.875,净值 0.875 − 1/12; + // [p1,p2]:价值 10 × VOI_BOTH,净值 10 × VOI_BOTH − 2/15。 + const { g0, combos } = evaluateCombos(ablationModel(modelA), { balance: 21, batchCap: 2, u: 1, decay: 1 }); + close(g0, 0.5); + assert.deepEqual( + combos.map((c) => [c.factors.join(","), c.rHat, c.mPrime]), + [ + ["p1", 10, 10], + ["p2", 10, 10], + ["p1,p2", 10, 10], + ], + ); + close(combos[0]!.net, 10 * VOI_P1 - 1 / 12); + close(combos[1]!.value, 0.875); + close(combos[1]!.net, 0.875 - 1 / 12); + close(combos[2]!.cost, 2 / 15); + close(combos[2]!.net, 10 * VOI_BOTH - 2 / 15); + // u = 0.5、r = 0.5、余额 5:一格 R̂ = ⌈4/2⌉ = 2,m′ = 1 + 0.5 = 1.5,价值 0.5 × 1.5 × 0.0875 = 0.065625。 + const half = evaluateCombos(ablationModel(modelA), { balance: 5, batchCap: 2, u: 0.5, decay: 0.5 }); + close(half.combos[1]!.mPrime, 1.5); + close(half.combos[1]!.value, 0.065625); +}); + +test("6.7 选格(信息价值减成本):选净值最大的;没选中的附原因和含它的最好组合", () => { + const d = selectByValue(ruleInput({})); + assert.deepEqual(d.chosen, ["p2"]); + close(d.chosenNet, 0.875 - 1 / 12); + assert.equal(d.cells[0]!.factor, "p1"); + assert.equal(d.cells[0]!.tested, false); + assert.equal(d.cells[0]!.reason, "value"); + // 含 p1 的组合:[p1] 0.035 − 1/12 < 0,[p1,p2] 10 × VOI_BOTH − 2/15 ≈ 0.742,最好的是后者。 + assert.deepEqual(d.cells[0]!.bestCombo?.factors, ["p1", "p2"]); + close(d.cells[0]!.bestCombo!.net, 10 * VOI_BOTH - 2 / 15); + assert.deepEqual(d.cells[1], { factor: "p2", tested: true, reason: null, bestCombo: null }); +}); + +test("6.7 选格:余额 0、1 时不做消融,原因是剩余次数不够、没有可选组合;余额 2 时只列一格的组合", () => { + for (const balance of [0, 1]) { + const d = selectByValue(ruleInput({ balance })); + assert.deepEqual(d.combos, []); + assert.deepEqual(d.chosen, []); + assert.equal(d.chosenNet, 0); + for (const c of d.cells) assert.deepEqual([c.tested, c.reason, c.bestCombo], [false, "budget", null]); + } + // 余额 2:只能测 1 格。u = 1、r = 1:R̂ = ⌈1/2⌉ = 1,m′ = 1,[p2] 价值 0.0875 − 成本 1/12 = 0.00416…> 0,选 p2; + // p1 再加进来就是 2 格 > 余额 − 1 = 1,记"剩余次数不够"。 + const two = selectByValue(ruleInput({ balance: 2 })); + assert.deepEqual( + two.combos.map((c) => c.factors.join(",")), + ["p1", "p2"], + ); + assert.deepEqual(two.chosen, ["p2"]); + close(two.chosenNet, 0.0875 - 1 / 12); + assert.equal(two.cells[0]!.reason, "budget"); +}); + +test("6.7 选格的净值容差 1e-12:不大于 1e-12 不选;相差不到 1e-12 时留先列出的(格数少、编号小)", () => { + assert.deepEqual(pickByNet([{ factors: ["p1"], net: 1e-12 }]), []); + assert.deepEqual(pickByNet([{ factors: ["p1"], net: 0 }]), []); + assert.deepEqual(pickByNet([{ factors: ["p1"], net: 2e-12 }]), ["p1"]); + assert.deepEqual( + pickByNet([ + { factors: ["p1"], net: 0.5 }, + { factors: ["p2"], net: 0.5 + 5e-13 }, + { factors: ["p1", "p2"], net: 0.5 + 9e-13 }, + ]), + ["p1"], + ); + assert.deepEqual( + pickByNet([ + { factors: ["p1"], net: 0.5 }, + { factors: ["p1", "p2"], net: 0.5 + 2e-12 }, + ]), + ["p1", "p2"], + ); +}); + +test("12 局限第 7 条:大家都押同一个因子、没有不确定时信息价值为 0,一格都不测", () => { + // 手算:p1 交 (0.24, 0.25, 0.26),s₁ 在 [0.73, 0.77];p2 交 (0.49, 0.5, 0.51),s₂ 在 [0.48, 0.52]。 + // 两格的范围不重叠,不论测到什么都押 p1,VOI 为 0;成本 > 0,净值都 < 0。 + const d = selectByValue( + ruleInput({ explanations: [explanation("e1", "A", { p1: [0.24, 0.25, 0.26], p2: [0.49, 0.5, 0.51] })] }), + ); + assert.deepEqual(d.chosen, []); + for (const c of d.combos) close(c.voi, 0); + assert.deepEqual( + d.cells.map((c) => c.reason), + ["value", "value"], + ); +}); + +test("登记表:四条选格规则", () => { + const three = ruleInput({ + factors: ["p1", "p2", "p3"], + balance: 3, + explanations: [explanation("e1", "A", { p1: [0.49, 0.5, 0.51], p2: [0.25, 0.5, 0.75], p3: [0.25, 0.5, 0.75] })], + }); + // 全测:余额 3,最多 2 格,按编号测 p1、p2,p3 剩余次数不够。 + const all = runCellRule("all", three); + assert.deepEqual(all.rule, { name: "all", version: 1 }); + assert.deepEqual(all.chosen, ["p1", "p2"]); + assert.deepEqual( + all.cells.map((c) => c.reason), + [null, null, "budget"], + ); + assert.deepEqual(selectAll(three).chosen, ["p1", "p2"]); + // 都不测。 + const none = runCellRule("none", three); + assert.deepEqual(none.chosen, []); + assert.deepEqual( + none.cells.map((c) => c.reason), + ["ruleNone", "ruleNone", "ruleNone"], + ); + assert.deepEqual(selectNone(three).chosen, []); + // 随机同格数:格数等于信息价值减成本选的格数;同一个种子得到同一组。 + const n = runCellRule("valueMinusCost", three).chosen.length; + for (const seed of ["a", "b", "c"]) { + const r1 = selectRandomSameCount({ ...three, seed }); + assert.equal(r1.chosen.length, n); + assert.deepEqual(runCellRule("randomSameCount", { ...three, seed }).chosen, r1.chosen); + for (const c of r1.cells) assert.equal(c.reason, c.tested ? null : "notDrawn"); + } +}); + +test("同样的输入算两次,选格结果逐位相同", () => { + const i = ruleInput({ + lambda: 0.1, + knownBest: 1.2, + explanations: [ + explanation("e1", "A", { p1: [0.3, 0.5, 0.6], p2: [0.2, 0.4, 0.75] }), + explanation("e2", "B", { p1: [0.6, 0.7, 0.9], p2: [0.1, 0.15, 0.3] }), + ], + }); + assert.deepEqual(selectByValue(i), selectByValue(i)); +}); + +test("6.8 押的因子:有效格用实测,其余格用更新后的权重估", () => { + // 手算:基线 0、完整 1、λ = 0。e1(A):p1 (0.19, 0.2, 0.21)(对称,E s = 0.8),p2 (0.25, 0.5, 0.75); + // e2(B):p1 (0.79, 0.8, 0.81)(E s = 0.2),p2 (2, 2.25, 2.5)。p2 实测 0.5:e1 在 0.5 处密度取 [0.5, 0.75) 段 0.4/0.25 = 1.6, + // e2 为 0 → 新权重 e1 = 1。 + // 收益:p1 = 0.8(估),p2 = 1 − 0.5 = 0.5(实测)。押 p1,预测收益 0.8。 + const input = { + ...modelA, + explanations: [ + explanation("e1", "A", { p1: [0.19, 0.2, 0.21], p2: [0.25, 0.5, 0.75] }), + explanation("e2", "B", { p1: [0.79, 0.8, 0.81], p2: [2, 2.25, 2.5] }), + ], + cells: [ + { factor: "p1", outcome: { kind: "untested" as const } }, + { factor: "p2", outcome: { kind: "valid" as const, score: 0.5 } }, + ], + }; + const b = chooseBet(input); + assert.deepEqual(b.weights, [1, 0, 0]); + assert.equal(b.weightsKept, false); + close(b.gains[0]!.gain, 0.8); + assert.equal(b.gains[0]!.measured, false); + close(b.gains[1]!.gain, 0.5); + assert.equal(b.gains[1]!.measured, true); + assert.equal(b.factor, "p1"); + assert.equal(b.decision, "bet"); + // 没有有效格时保持起始权重:p1 = 0.5 × 0.8 + 0.5 × 0.2 = 0.5。 + const prior = chooseBet({ ...input, cells: [] }); + assert.deepEqual(prior.weights, [0.5, 0.5, 0]); + close(prior.gains[0]!.gain, 0.5); + // "一起关"的分数不参与更新:p2 一起关得 0.5,权重仍是起始权重。 + const joint = chooseBet({ ...input, cells: [{ factor: "p2", outcome: { kind: "joint", off: ["p1", "p2"], score: 0.5 } }] }); + assert.deepEqual(joint.weights, [0.5, 0.5, 0]); + assert.equal(joint.gains[1]!.measured, false); +}); + +test("6.8 权重无从更新时保持起始权重;撑几成 < 0 时不押并记关掉反而更好多少", () => { + // 手算:p2 实测 5,落在两条解释和开放预留之外(λ = 0 时预留权重 0,密度连乘全为 0)→ 保持起始权重。 + // 撑 = 1 − 5 = −4 < 0:不押,关掉 p2 比完整好 5 − 1 = 4。 + const b = chooseBet({ + ...modelA, + explanations: [ + explanation("e1", "A", { p1: [0.19, 0.2, 0.21], p2: [0.25, 0.5, 0.75] }), + explanation("e2", "B", { p1: [0.79, 0.8, 0.81], p2: [2, 2.25, 2.5] }), + ], + cells: [{ factor: "p2", outcome: { kind: "valid", score: 5 } }], + }); + assert.equal(b.weightsKept, true); + assert.deepEqual(b.weights, [0.5, 0.5, 0]); + assert.equal(b.decision, "offBetter"); + assert.deepEqual(b.offBetter, [{ factor: "p2", better: 4 }]); + close(b.predictedGain, 0.5); + assert.equal(b.factor, "p1"); +}); + +test("6.8 挑出的因子估计收益不为正时不押;平局取编号小的", () => { + // 手算:p1 (1.24, 1.25, 1.26)(E s = −0.25),p2 (1.115, 1.125, 1.135)(E s = −0.125):押 p2,收益 −0.125,不为正。 + const neg = chooseBet({ + ...modelA, + explanations: [explanation("e1", "A", { p1: [1.24, 1.25, 1.26], p2: [1.115, 1.125, 1.135] })], + cells: [], + }); + assert.equal(neg.factor, "p2"); + close(neg.predictedGain, -0.125); + assert.equal(neg.decision, "notPositive"); + // 两格都交 (0.75, 1, 1.25),关于完整 1 对称,收益正好是 0(这几个数在二进制里精确):取 p1,不押。 + const zero = chooseBet({ + ...modelA, + explanations: [explanation("e1", "A", { p1: [0.75, 1, 1.25], p2: [0.75, 1, 1.25] })], + cells: [], + }); + assert.equal(zero.predictedGain, 0); + assert.equal(zero.factor, "p1"); + assert.equal(zero.decision, "notPositive"); + // 两格收益相等且为正:取 p1。 + const tie = chooseBet({ + ...modelA, + explanations: [explanation("e1", "A", { p1: [0.49, 0.5, 0.51], p2: [0.49, 0.5, 0.51] })], + cells: [], + }); + assert.equal(tie.gains[0]!.gain, tie.gains[1]!.gain); + assert.equal(tie.factor, "p1"); + assert.equal(tie.decision, "bet"); +}); + +test("6.7 部分格网格在分数原值上取折点:刻度换算后才合并的两个折点仍算两段", () => { + // 反例来自随机压测:第二条的低端尾巴 0.6819999999999999 − (0.6869999999999999 − 0.6819999999999999) = 0.6769999999999999, + // 与第三条的 q1 = 0.677 只差一个末位,原值上是两个折点;换成撑几成 (2.6276819772628 − x) ÷ G 后变成同一个数。 + // 方法 6.7"每格在自己的开放预留范围内按折点切段",开放预留和折点都在分数原值上定,所以按原值算:17 个折点、16 段。 + const model = ablationModel({ + baseline: 0.9597642169962064, + full: 2.6276819772628, + direction: "maximize", + knownBest: null, + epsilon: 0.01, + lambda: 0.1, + factors: ["p1"], + explanations: [ + explanation("e1", "A", { p1: [1.1840000000000002, 1.5510000000000002, 2.0010000000000003] }), + explanation("e2", "B", { p1: [0.6819999999999999, 0.6869999999999999, 1.168] }), + explanation("e3", "C", { p1: [0.677, 1.099, 1.3519999999999999] }), + ], + }); + const distinct = (lines: ReadonlyArray>) => + new Set(lines.flatMap((k) => k.map(([x]) => x))).size; + assert.equal(distinct(model.rawKnots[0]!), 17, "原值上 17 个折点"); + assert.equal(distinct(model.supportKnots[0]!), 16, "撑几成刻度上只剩 16 个(这是反例的前提)"); + // k = 1:m = 4,000,000,s = 20,16 段 × 20 = 320 个小段(按撑几成刻度切会是 15 × 20 = 300)。 + assert.equal(measuredGrid(model, 0, 1).mids.length, 320); + // k = 3:m = 158,s = ⌊158 ÷ 16⌋ = 9,144 个小段(按撑几成刻度切会是 ⌊158 ÷ 15⌋ = 10,150 个)。 + assert.equal(measuredGrid(model, 0, 3).mids.length, 144); + // 小段中点换成了撑几成:第一个小段在原值最低处,撑几成最大。 + const g = measuredGrid(model, 0, 1); + const raw = cellGrid(model.rawKnots[0]!, 1); + g.mids.forEach((m, i) => close(m, (2.6276819772628 - raw.mids[i]!) / model.gain)); +});