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:
2026-10-02 01:50:21 -04:00
parent bfb96ba7d7
commit 60c21ae346
37 changed files with 14067 additions and 3 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
__pycache__/
+8 -2
View File
@@ -12,6 +12,7 @@
- `docs/方法.md`:由参考项目的研究方案 v3、设计-15、设计-16 合成(f32c0bd)。之后按用户决定改过:消融只测信息价值选中的格(5e4f8c2);保障的定义借用"控制变量"和"重构"来说明(141494f)。
- `docs/结构.md`:13 个模块加 vocab、步骤清单、账本 12 种记录、可替换点、检查规则、粗估规模(6cf9f0c)。
- 任务书 01(工具链 + vocab):npm 包、TypeScript、`node --test`、dependency-cruiser 规则全写,临时违规实测 14 条都报错;vocab 1030 行,`npm run check` 通过,`npm test` 24 条通过。结构.md 第八部分补了 vocab 对外内容。
- 任务书 02(rules):src/rules 13 个文件、1761 行,`npm test` 192 条通过。预期值来源有三个:方法.md 的手算例;`tests/fixtures/rules/` 下不看实现写的 Python 脚本(重跑后 JSON 逐字节相同);参考项目 calc15、calc14b 里与现行方法一致的部分。审阅做了两轮,每轮都换一个全新上下文的子 Agent。结构.md 第八部分补了 rules 对外内容。
## 关键决定(用户原话见各提交说明)
@@ -25,6 +26,8 @@
- 每份预测只对一个候选、每份挑刺只针对一个目标;入口、因子、实现条款的编号由程序按位置分配;实现提交不设代码字段(程序从工作目录冻结);运行结果用"没启动 / 正常退出 / 超时 / 被取消"一个字段表示启动与结束;四种 token 数都可记测不到。用户原话:"3、4、5、6:同意保留你的写法。方法.md 第五部分的表(入口编号、实现的代码字段)由监督者改。"
- 计算时间不再是预算,只记录;预算只有运行次数(42347e4)。用户原话:"CPU 秒不当预算但是要记录"。
- 解释的每格三个数改为可选,关掉消融时不交。用户原话:"同意 A:解释的 cells 去掉 minItems,改为可选"。
- rules 的约定(方法.md 没写死的地方):入池 P < 0.5 严格;采纳和消融中发现都要严格大于采纳线;成本用 max(0, ĝ₀);没测的格附"含它的最好组合"的净值;战绩在分数原值上判;编号一律按自然序比较。用户原话:"1–8 都同意"。消融成分权重跟随 λ、分歧量不掺开放预留,由用户改进方法.md(a050771)。
- 三个数改为严格递增,去掉尖峰(bfb96ba)。用户原话:"凡是为'三个数相等''宽度为 0''开放预留缩成一个点'写的专门处理,全部删掉";"以后再提'某种情况需要专门处理'时,请附上它在参考项目真实运行里出现过的证据……查不到就默认不写"。
- 答案路径、保障动手次数只记读到的原始内容或读不到的原因;在不在位置表里,由以后拿得到位置表的模块现算,不存。用户原话:"按'能现算的不存',tasks 只记读到的原始内容……对表由以后有位置表的模块现算,把这条记进 WORK_LOG。"
## 未完成
@@ -32,7 +35,7 @@
- `inbox/调研.md`:要迁进 `docs/调研/` 的调研结论。
- `docs/方法.md` 里还有 7 条标〔自定〕的规则,待用户过目。
- 留给 submissions 的跨字段规则(vocab 字段定义不管):
1. q1 ≤ q5 ≤ q9:作者自评、两次预测、解释的每一格。
1. q1 < q5 < q9(严格递增,方法.md 第五部分,提交 bfb96ba):作者自评、两次预测、解释的每一格。
2. 因子数不超过上限,回应改过的因子清单同样适用。
3. 同一提案里两个因子不能延续同一个因子;延续的编号要在信念本里看得到。
4. 保障短名不能是 `main`,不能写成 `p<数字>`,同一清单里不重复。
@@ -48,6 +51,9 @@
14. 消融核验覆盖每个因子。
- 答案路径、保障名字"在不在位置表里"的对表,由以后拿得到位置表的模块现算(多半是 materials 或 flow)。
- 核验提交 `body` 的字段说明要补一句:同一行同时出现在因子和保障里时,在说明里写一句提醒(方法.md 4.3 ⑨)。写 submissions 时一并改。
- 上面 1–8 条约定里,除了用户已改进方法.md 的两条,其余还没写进方法.md 第六部分(由监督者决定是否补)。
- 给 config:每批上限必须 ≥ 1(为 0 时 R̂ 是无穷大,选格会卡住)。
- 给 flow:同一轮里选批和选格都用随机规则时,传不同的"版本"算种子,否则两次抽取用同一串随机数。
- 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。
## 工作方式(用户 2026-10-01 定)
@@ -61,4 +67,4 @@
## 下一步
任务书 01 已核对通过(97a4359)。`任务书/02-rules.md` 已写好,等用户另开 Agent 执行,监督者核对。之后是 ledger → …。
任务书 02(rules)已完成并提交,等监督者核对。之后是 ledger → …。
+136
View File
@@ -244,3 +244,139 @@
- `Range`(字段定义):三个数 q1、q5、q9,即一个分数有一成、一半、九成可能低于多少。
- `SUBMISSION_SCHEMAS`:每种提交的字段定义,键与 `SUBMISSION_KINDS` 相同;同一份定义是类型、收件格式检查和提交工具参数。
- `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)`:按名字跑一条选格规则,结果带上规则名和版本。
+261
View File
@@ -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),
};
}
+80
View File
@@ -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;
}
+126
View File
@@ -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 };
}
+30
View File
@@ -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);
}
+242
View File
@@ -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,
};
}
+226
View File
@@ -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;
}
+51
View File
@@ -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)!);
}
+181
View File
@@ -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 };
}
+117
View File
@@ -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";
+42
View File
@@ -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);
}
+85
View File
@@ -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),
}));
}
+44
View File
@@ -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) };
}
+276
View File
@@ -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 };
}
+1 -1
View File
@@ -30,7 +30,7 @@ export const Range = Type.Object(
{
...CLOSED,
description:
"三个数,按分数原值写,不管越大越好还是越小越好。要满足 q1 ≤ q5 ≤ q9,可以相等。" +
"三个数,按分数原值写,不管越大越好还是越小越好。必须严格递增 q1 < q5 < q9,认为几乎不变就写一个很窄的范围。" +
"没有\"不知道\"这个选项:拿不准就把 q1 和 q9 拉开",
},
);
+282
View File
@@ -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
}
}
}
}
+3862
View File
File diff suppressed because it is too large Load Diff
+1658
View File
File diff suppressed because it is too large Load Diff
+494
View File
@@ -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
View File
@@ -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")
+580
View File
@@ -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 的原值分位数。作者自评三个数是示意输入。"
}
]
}
+943
View File
@@ -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 个编号核对这一点。"
}
]
}
+784
View File
@@ -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}
+286
View File
@@ -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");
});
+166
View File
@@ -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,
);
});
+161
View File
@@ -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"), []);
});
+279
View File
@@ -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");
});
+133
View File
@@ -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 });
});
+384
View File
@@ -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(), []);
});
+228
View File
@@ -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(), []);
});
+65
View File
@@ -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 };
}
+278
View File
@@ -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(), []);
});
+261
View File
@@ -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");
});
+270
View File
@@ -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);
}
});
+393
View File
@@ -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));
});