60c21ae346
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>
271 lines
11 KiB
TypeScript
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);
|
|
}
|
|
});
|