Files
InfoRSI/tests/rules-fixtures-ablation.test.ts
T
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

385 lines
17 KiB
TypeScript
Raw 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 { 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(), []);
});