Files
InfoRSI/tests/rules-calc15.test.ts
iomgaa 60c21ae346 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>
2026-10-02 01:50:21 -04:00

280 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});