feat: 研究规则 rules 模块(任务书 02)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
__pycache__/
|
||||||
|
|||||||
+8
-2
@@ -12,6 +12,7 @@
|
|||||||
- `docs/方法.md`:由参考项目的研究方案 v3、设计-15、设计-16 合成(f32c0bd)。之后按用户决定改过:消融只测信息价值选中的格(5e4f8c2);保障的定义借用"控制变量"和"重构"来说明(141494f)。
|
- `docs/方法.md`:由参考项目的研究方案 v3、设计-15、设计-16 合成(f32c0bd)。之后按用户决定改过:消融只测信息价值选中的格(5e4f8c2);保障的定义借用"控制变量"和"重构"来说明(141494f)。
|
||||||
- `docs/结构.md`:13 个模块加 vocab、步骤清单、账本 12 种记录、可替换点、检查规则、粗估规模(6cf9f0c)。
|
- `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 对外内容。
|
- 任务书 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 第五部分的表(入口编号、实现的代码字段)由监督者改。"
|
- 每份预测只对一个候选、每份挑刺只针对一个目标;入口、因子、实现条款的编号由程序按位置分配;实现提交不设代码字段(程序从工作目录冻结);运行结果用"没启动 / 正常退出 / 超时 / 被取消"一个字段表示启动与结束;四种 token 数都可记测不到。用户原话:"3、4、5、6:同意保留你的写法。方法.md 第五部分的表(入口编号、实现的代码字段)由监督者改。"
|
||||||
- 计算时间不再是预算,只记录;预算只有运行次数(42347e4)。用户原话:"CPU 秒不当预算但是要记录"。
|
- 计算时间不再是预算,只记录;预算只有运行次数(42347e4)。用户原话:"CPU 秒不当预算但是要记录"。
|
||||||
- 解释的每格三个数改为可选,关掉消融时不交。用户原话:"同意 A:解释的 cells 去掉 minItems,改为可选"。
|
- 解释的每格三个数改为可选,关掉消融时不交。用户原话:"同意 A:解释的 cells 去掉 minItems,改为可选"。
|
||||||
|
- rules 的约定(方法.md 没写死的地方):入池 P < 0.5 严格;采纳和消融中发现都要严格大于采纳线;成本用 max(0, ĝ₀);没测的格附"含它的最好组合"的净值;战绩在分数原值上判;编号一律按自然序比较。用户原话:"1–8 都同意"。消融成分权重跟随 λ、分歧量不掺开放预留,由用户改进方法.md(a050771)。
|
||||||
|
- 三个数改为严格递增,去掉尖峰(bfb96ba)。用户原话:"凡是为'三个数相等''宽度为 0''开放预留缩成一个点'写的专门处理,全部删掉";"以后再提'某种情况需要专门处理'时,请附上它在参考项目真实运行里出现过的证据……查不到就默认不写"。
|
||||||
- 答案路径、保障动手次数只记读到的原始内容或读不到的原因;在不在位置表里,由以后拿得到位置表的模块现算,不存。用户原话:"按'能现算的不存',tasks 只记读到的原始内容……对表由以后有位置表的模块现算,把这条记进 WORK_LOG。"
|
- 答案路径、保障动手次数只记读到的原始内容或读不到的原因;在不在位置表里,由以后拿得到位置表的模块现算,不存。用户原话:"按'能现算的不存',tasks 只记读到的原始内容……对表由以后有位置表的模块现算,把这条记进 WORK_LOG。"
|
||||||
|
|
||||||
## 未完成
|
## 未完成
|
||||||
@@ -32,7 +35,7 @@
|
|||||||
- `inbox/调研.md`:要迁进 `docs/调研/` 的调研结论。
|
- `inbox/调研.md`:要迁进 `docs/调研/` 的调研结论。
|
||||||
- `docs/方法.md` 里还有 7 条标〔自定〕的规则,待用户过目。
|
- `docs/方法.md` 里还有 7 条标〔自定〕的规则,待用户过目。
|
||||||
- 留给 submissions 的跨字段规则(vocab 字段定义不管):
|
- 留给 submissions 的跨字段规则(vocab 字段定义不管):
|
||||||
1. q1 ≤ q5 ≤ q9:作者自评、两次预测、解释的每一格。
|
1. q1 < q5 < q9(严格递增,方法.md 第五部分,提交 bfb96ba):作者自评、两次预测、解释的每一格。
|
||||||
2. 因子数不超过上限,回应改过的因子清单同样适用。
|
2. 因子数不超过上限,回应改过的因子清单同样适用。
|
||||||
3. 同一提案里两个因子不能延续同一个因子;延续的编号要在信念本里看得到。
|
3. 同一提案里两个因子不能延续同一个因子;延续的编号要在信念本里看得到。
|
||||||
4. 保障短名不能是 `main`,不能写成 `p<数字>`,同一清单里不重复。
|
4. 保障短名不能是 `main`,不能写成 `p<数字>`,同一清单里不重复。
|
||||||
@@ -48,6 +51,9 @@
|
|||||||
14. 消融核验覆盖每个因子。
|
14. 消融核验覆盖每个因子。
|
||||||
- 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。
|
- 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。
|
||||||
- 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。
|
- 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。
|
||||||
|
- 上面 1–8 条约定里,除了用户已改进方法.md 的两条,其余还没写进方法.md 第六部分(由监督者决定是否补)。
|
||||||
|
- 给 config:每批上限必须 ≥ 1(为 0 时 R̂ 是无穷大,选格会卡住)。
|
||||||
|
- 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。
|
||||||
- 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。
|
- 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。
|
||||||
|
|
||||||
## 工作方式(用户 2026-10-01 定)
|
## 工作方式(用户 2026-10-01 定)
|
||||||
@@ -61,4 +67,4 @@
|
|||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
任务书 01 已核对通过(97a4359)。`任务书/02-rules.md` 已写好,等用户另开 Agent 执行,监督者核对。之后是 ledger → …。
|
任务书 02(rules)已完成并提交,等监督者核对。之后是 ledger → …。
|
||||||
|
|||||||
+136
@@ -244,3 +244,139 @@
|
|||||||
- `Range`(字段定义):三个数 q1、q5、q9,即一个分数有一成、一半、九成可能低于多少。
|
- `Range`(字段定义):三个数 q1、q5、q9,即一个分数有一成、一半、九成可能低于多少。
|
||||||
- `SUBMISSION_SCHEMAS`:每种提交的字段定义,键与 `SUBMISSION_KINDS` 相同;同一份定义是类型、收件格式检查和提交工具参数。
|
- `SUBMISSION_SCHEMAS`:每种提交的字段定义,键与 `SUBMISSION_KINDS` 相同;同一份定义是类型、收件格式检查和提交工具参数。
|
||||||
- `Submission<K>`(类型):某种提交的内容类型,例如 `Submission<"proposal">`。
|
- `Submission<K>`(类型):某种提交的内容类型,例如 `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)`:按名字跑一条选格规则,结果带上规则名和版本。
|
||||||
|
|||||||
@@ -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<number | null>): {
|
||||||
|
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<string, string>();
|
||||||
|
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<typeof judgeExplanations>): string[][] {
|
||||||
|
if (judgments.length === 0 || judgments[0]!.verdicts.length === 0) return [];
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
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<typeof synergyLabel>;
|
||||||
|
judgments: ReturnType<typeof judgeExplanations>;
|
||||||
|
inseparable: string[][];
|
||||||
|
finding: ReturnType<typeof ablationFinding>;
|
||||||
|
} {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, Knots[]>();
|
||||||
|
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<Placement, number>;
|
||||||
|
covered: number;
|
||||||
|
total: number;
|
||||||
|
rate: Measured;
|
||||||
|
} {
|
||||||
|
const counts: Record<Placement, number> = { 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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<C extends { factors: readonly string[]; net: number }>(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<number>((v, x, c) => (x === null ? v : v * density(model.rawKnots[c]![j]!, x)), w),
|
||||||
|
);
|
||||||
|
const sum = raw.reduce((t, v) => t + v, 0);
|
||||||
|
const weightsKept = sum === 0;
|
||||||
|
const weights = weightsKept ? [...model.weights] : raw.map((v) => v / sum);
|
||||||
|
const 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, number>();
|
||||||
|
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<number>(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;
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
const result = new Map<string, EntryAssignment>();
|
||||||
|
const rounds = Math.max(0, ...researchers.map((r) => r.choices.length));
|
||||||
|
for (let k = 0; k < rounds; k++) {
|
||||||
|
// 本轮各个空着的入口被谁选了;选的人按研究者编号次序加入,所以 who[0] 是编号最小的。
|
||||||
|
const wants = new Map<string, string[]>();
|
||||||
|
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)!);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<Placement, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 战绩:按研究者、按正向与消融分开数(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<string, { researcher: string; phase: Phase; tally: Tally }>();
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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<string, { version: number; run: (input: BatchInput) => 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<string, { version: number; run: (input: CellRuleInput) => 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) };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export const Range = Type.Object(
|
|||||||
{
|
{
|
||||||
...CLOSED,
|
...CLOSED,
|
||||||
description:
|
description:
|
||||||
"三个数,按分数原值写,不管越大越好还是越小越好。要满足 q1 ≤ q5 ≤ q9,可以相等。" +
|
"三个数,按分数原值写,不管越大越好还是越小越好。必须严格递增 q1 < q5 < q9,认为几乎不变就写一个很窄的范围。" +
|
||||||
"没有\"不知道\"这个选项:拿不准就把 q1 和 q9 拉开",
|
"没有\"不知道\"这个选项:拿不准就把 q1 和 q9 拉开",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+282
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3862
File diff suppressed because it is too large
Load Diff
Vendored
+1658
File diff suppressed because it is too large
Load Diff
Vendored
+494
@@ -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<R̂} 0.5^j,成本 = 2·max(0,ĝ₀)·[(K+|S|)/(K+|S|+1) − K/(K+1)]。"
|
||||||
|
"unselected 里附“含这一格的可选组合中净值最大的那个”。fullRule = 全测规则(格数超过余额−1 时按编号取前余额−1 格)。")
|
||||||
|
selection_scenario("r1real", [0, 1, 2, 4, 12],
|
||||||
|
"真实第 1 轮三格。余额 4 时可选到三格全测;12 时 R̂ 更大、价值更高。")
|
||||||
|
selection_scenario("minimize", [2, 3, 6], "越小越好的合成例。")
|
||||||
|
selection_scenario("negativeG0", [3, 5],
|
||||||
|
"ĝ₀ < 0(各因子事前期望收益都为负):g0 照记原始负值,成本 = 2·max(0, ĝ₀)·[…] = 0,净值 = 价值。")
|
||||||
|
|
||||||
|
# 价值、成本的纯算例:R̂、m′、成本(含 余额−|S| = 0 与 ĝ₀ < 0)
|
||||||
|
vc = []
|
||||||
|
for bal, size, K, g0, voi in [(5, 1, 2, 0.01, 0.004), (13, 2, 2, 0.01, 0.004), (3, 3, 2, 0.01, 0.004),
|
||||||
|
(4, 1, 3, 0.02, 0.01), (6, 2, 2, -0.005, 0.003)]:
|
||||||
|
Rh, mp = R.m_prime(bal, size, K, RR)
|
||||||
|
value = Fr(U) * mp * R.fr(voi)
|
||||||
|
c = R.cost(g0, size, K)
|
||||||
|
vc.append({"input": {"balance": bal, "size": size, "batchCap": K, "g0": g0, "voi": voi},
|
||||||
|
"expected": {"Rhat": Rh, "mPrime": F(mp), "value": F(value), "cost": F(c), "net": F(value - c)}})
|
||||||
|
S.append({"name": "value_cost_pure", "section": "6.7 价值减成本;约定 28",
|
||||||
|
"params": {"u": U, "r": RR}, "input": {"cases": [x["input"] for x in vc]},
|
||||||
|
"expected": {"cases": [x["expected"] for x in vc]},
|
||||||
|
"note": "R̂ = ⌈(余额−|S|)/每批上限⌉,余额−|S| = 0 时 R̂ = 0、m′ = 0;m′ = Σ_{j<R̂} r^j;"
|
||||||
|
"成本 = 2·max(0,ĝ₀)·[(K+|S|)/(K+|S|+1) − K/(K+1)],ĝ₀ < 0 时成本 0。例:余额 13、|S| 2、K 2 → R̂ 6、m′ 1.96875。"})
|
||||||
|
|
||||||
|
|
||||||
|
# 纯选格规则:给定净值表(构造),测 1e-12 容差与平局
|
||||||
|
def pure_select(name, cells, bal, nets, note):
|
||||||
|
def net(combo):
|
||||||
|
return nets[",".join(combo)]
|
||||||
|
sel, bestnet, table, uns = R.select_cells(cells, bal, net)
|
||||||
|
S.append({"name": name, "section": "6.7 选格第 2–3 条;约定 29",
|
||||||
|
"params": {"tolerance": 1e-12},
|
||||||
|
"input": {"cells": cells, "balance": bal, "nets": nets},
|
||||||
|
"expected": {"selected": sel, "selectedNet": bestnet if sel else 0.0, "unselected": uns},
|
||||||
|
"note": note})
|
||||||
|
|
||||||
|
|
||||||
|
pure_select("select_pure_net_at_tolerance", ["p1", "p2"], 3,
|
||||||
|
{"p1": 1e-12, "p2": 0.9e-12, "p1,p2": 1e-12},
|
||||||
|
"净值都不大于 1e-12:一格都不测;各格 notWorthCost,附各自含它的最大净值组合(同一条规则:差不到 1e-12 视为相同,"
|
||||||
|
"先取格数少的)。p2 的附带组合曾是 {p1,p2}(1e-12,按“严格更大才替换”);改后 {p2}(0.9e-12)与它差不到 1e-12,"
|
||||||
|
"格数少,取 {p2}。")
|
||||||
|
pure_select("select_pure_just_above_tolerance", ["p1", "p2"], 3,
|
||||||
|
{"p1": 2.5e-12, "p2": 1e-12, "p1,p2": 3.0e-12},
|
||||||
|
"p1 = 2.5e-12 > 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))
|
||||||
+653
@@ -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": "v<q1 below10;q1≤v<q5 from10to50;q5≤v≤q9 from50to90;v>q9 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 中位数偏高;q5<v 偏低;v>q9 穿上沿;v<q1 穿下沿;q1≤v≤q9 包住。"
|
||||||
|
"v=q5 既不偏高也不偏低;v=q9 算包住、不算穿上沿;窄范围 (0.99,1,1.01) 实测 1 只算包住。正向与消融分开数。"})
|
||||||
|
|
||||||
|
# 作者自评 vs 合并
|
||||||
|
mq = {}
|
||||||
|
for c, d in dys_r1.items():
|
||||||
|
yq = [R.quantile(d, p) for p in (Fr(1, 10), Fr(1, 2), Fr(9, 10))]
|
||||||
|
mq[c] = tuple(F(R.fr(B_R1) + q) for q in yq)
|
||||||
|
av = [
|
||||||
|
{"candidate": "r1-v1-c3", "author": (2.1, 2.5, 2.6), "merged": mq["r1-v1-c3"], "value": v_c3, "ran": True},
|
||||||
|
{"candidate": "r1-v1-c1", "author": (2.2, 2.5, 2.63), "merged": mq["r1-v1-c1"], "value": None, "ran": False},
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for a in av:
|
||||||
|
if not a["ran"] or a["value"] is None:
|
||||||
|
continue
|
||||||
|
rows.append({"candidate": a["candidate"],
|
||||||
|
"q5Diff": F(R.fr(a["author"][1]) - R.fr(a["merged"][1])),
|
||||||
|
"authorSegment": R.landing(*a["author"], a["value"]),
|
||||||
|
"mergedSegment": R.landing(*a["merged"], a["value"])})
|
||||||
|
S.append({"name": "author_vs_merged", "section": "6.10 第 4 条;约定 37",
|
||||||
|
"params": {"direction": "maximize"},
|
||||||
|
"input": {"items": [{"candidate": a["candidate"], "author": tri(a["author"]), "merged": tri(a["merged"]),
|
||||||
|
"value": a["value"], "ran": a["ran"]} for a in av]},
|
||||||
|
"expected": {"rows": rows},
|
||||||
|
"note": "只收跑过且有有效成绩的候选(c1 没跑,不进)。q5Diff = 按方向的 作者q5 − 合并q5(>0 作者更乐观)。"
|
||||||
|
"合并范围取 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")
|
||||||
Vendored
+580
@@ -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": "v<q1 below10;q1≤v<q5 from10to50;q5≤v≤q9 from50to90;v>q9 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 中位数偏高;q5<v 偏低;v>q9 穿上沿;v<q1 穿下沿;q1≤v≤q9 包住。v=q5 既不偏高也不偏低;v=q9 算包住、不算穿上沿;窄范围 (0.99,1,1.01) 实测 1 只算包住。正向与消融分开数。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "author_vs_merged",
|
||||||
|
"section": "6.10 第 4 条;约定 37",
|
||||||
|
"params": {
|
||||||
|
"direction": "maximize"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"candidate": "r1-v1-c3",
|
||||||
|
"author": {
|
||||||
|
"q1": 2.1,
|
||||||
|
"q5": 2.5,
|
||||||
|
"q9": 2.6
|
||||||
|
},
|
||||||
|
"merged": {
|
||||||
|
"q1": 2.1289473684210525,
|
||||||
|
"q5": 2.5022627737226277,
|
||||||
|
"q9": 2.6276315789473683
|
||||||
|
},
|
||||||
|
"value": 1.552176048093269,
|
||||||
|
"ran": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"candidate": "r1-v1-c1",
|
||||||
|
"author": {
|
||||||
|
"q1": 2.2,
|
||||||
|
"q5": 2.5,
|
||||||
|
"q9": 2.63
|
||||||
|
},
|
||||||
|
"merged": {
|
||||||
|
"q1": 1.9871428571428573,
|
||||||
|
"q5": 2.4653846153846155,
|
||||||
|
"q9": 2.6470027247956405
|
||||||
|
},
|
||||||
|
"value": null,
|
||||||
|
"ran": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"candidate": "r1-v1-c3",
|
||||||
|
"q5Diff": -0.002262773722627731,
|
||||||
|
"authorSegment": "below10",
|
||||||
|
"mergedSegment": "below10"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"note": "只收跑过且有有效成绩的候选(c1 没跑,不进)。q5Diff = 按方向的 作者q5 − 合并q5(>0 作者更乐观)。合并范围取 forward.json 里 forward_merge_r1_real 的原值分位数。作者自评三个数是示意输入。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+943
@@ -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 个编号核对这一点。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+784
@@ -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}
|
||||||
@@ -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<string, [number, number, number]>): 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");
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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"), []);
|
||||||
|
});
|
||||||
@@ -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<string, number>;
|
||||||
|
voi3: Record<string, number>;
|
||||||
|
g0: number;
|
||||||
|
opp: Record<string, number>;
|
||||||
|
hand: {
|
||||||
|
reservePrior: number;
|
||||||
|
reservePosterior: number;
|
||||||
|
explanationsPosterior: number;
|
||||||
|
gainOffGrid: number;
|
||||||
|
solverGainPrior: number;
|
||||||
|
solverGainPosterior: number;
|
||||||
|
};
|
||||||
|
gridCap: Record<string, number>;
|
||||||
|
cellMiniSegments: { k1: number[]; k2: number[]; k3: number[] };
|
||||||
|
labels: Record<string, string>;
|
||||||
|
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<string, string> = {
|
||||||
|
缺一不可: "indispensable",
|
||||||
|
互补: "complementary",
|
||||||
|
替代: "substitute",
|
||||||
|
各管各: "independent",
|
||||||
|
"不全,不贴标签": "incomplete",
|
||||||
|
};
|
||||||
|
for (const [supports, name] of Object.entries(X.labels)) {
|
||||||
|
assert.equal(synergyLabel(JSON.parse(supports) as Array<number | null>).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");
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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<string, SynergyLabel> = {
|
||||||
|
negative: "offBetter",
|
||||||
|
incomplete: "incomplete",
|
||||||
|
indispensable: "indispensable",
|
||||||
|
complementary: "complementary",
|
||||||
|
substitute: "substitute",
|
||||||
|
independent: "independent",
|
||||||
|
};
|
||||||
|
const VERDICT: Record<string, string> = { confirmed: "confirmed", refuted: "denied" };
|
||||||
|
const REASON: Record<string, UntestedReason> = { insufficientBudget: "budget", notWorthCost: "value" };
|
||||||
|
const BET: Record<string, string> = { bet: "bet", noBetNegativeSupport: "offBetter", noBetNonPositiveGain: "notPositive" };
|
||||||
|
const componentName = (n: string): string => (n === "reserve" ? OPEN_RESERVE : n);
|
||||||
|
|
||||||
|
function modelInput(name: string): AblationModelInput & { params: Record<string, number> } {
|
||||||
|
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<string, AblationModel>();
|
||||||
|
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<string, number>).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<string, Record<string, string>>).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<string, number[]>)) {
|
||||||
|
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<string, number>)) {
|
||||||
|
close(model.space * model.expected[idx(f)]![j]!, v, TOL, `成分 ${n} 在 ${f} 的期望收益`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [f, v] of Object.entries(e.priorExpectedPayoff as Record<string, number>)) {
|
||||||
|
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<string, any>)) {
|
||||||
|
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<string, any>).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(), []);
|
||||||
|
});
|
||||||
@@ -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<string, any>) =>
|
||||||
|
Object.entries(input.candidates as Record<string, any[]>).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<string, any>; input: Record<string, any> }, candidates: Record<string, any[]>) {
|
||||||
|
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<string, number | null>).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(), []);
|
||||||
|
});
|
||||||
@@ -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<string, any>;
|
||||||
|
input: Record<string, any>;
|
||||||
|
expected: any;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixtureFile {
|
||||||
|
generator: string;
|
||||||
|
tolerance: Record<string, any>;
|
||||||
|
models?: Record<string, { params: Record<string, any>; input: Record<string, any> }>;
|
||||||
|
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<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<string, Placement> = {
|
||||||
|
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(), []);
|
||||||
|
});
|
||||||
@@ -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>): 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");
|
||||||
|
});
|
||||||
@@ -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<T>(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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<string, [number, number, number]>): 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>): 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<ReadonlyArray<readonly [number, number]>>) =>
|
||||||
|
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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user