Files
InfoRSI/tests/rules-order.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

271 lines
11 KiB
TypeScript

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