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