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
+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,
);
});