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

262 lines
12 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 { createHash } from "node:crypto";
import { test } from "node:test";
import {
adopt,
adoptionLine,
batchValue,
runBatchRule,
selectBestExpected,
selectUniformRandom,
type BatchInput,
personKnots,
type Mixture,
} from "../src/rules/index.ts";
// 预期值来源写在每条测试旁边。数取二进制能精确表示的数(0.25、0.5 等),避免浮点末位干扰"正好相等"的边界。
function close(actual: number, expected: number, tol = 1e-12): void {
assert.ok(Math.abs(actual - expected) <= tol, `实际 ${actual},预期 ${expected}`);
}
/** 很窄的范围 (y − 0.01, y, y + 0.01),连尾巴 [y − 0.02, y + 0.02]。 */
const narrow = (y: number) => ({ q1: y - 0.01, q5: y, q9: y + 0.01 });
/** 一个人交的一份范围(研究者 r1)。 */
const one = (range: { q1: number; q5: number; q9: number }) => [{ researcher: "r1", range }];
test("6.4 示例:X 一半涨 0、一半涨 0.02,Y 一定涨 0.01;Y 单跑 0.010,一起跑 0.015", () => {
// 来源:方法.md 6.4 示例,把"一定涨"换成很窄的范围(三个数严格递增)。改进量上直接给:
// X = 一半 (−0.001, 0, 0.001)、一半 (0.019, 0.02, 0.021);Y = (0.009, 0.01, 0.011)。
// 预期值由 tests/fixtures/rules/rules_ref.py 的 V(有理数精确)算出:V(X) = 407/40000 = 0.010175(窄范围在 0 以下的
// 一点被截成 0,比 0.010 多 0.5 × 0.00035);V(Y) = 0.01;V(X, Y) = 3/200 = 0.015。
const tri = (y: number, h: number) => personKnots({ q1: y - h, q5: y, q9: y + h });
const X: Mixture = [
{ weight: 0.5, knots: tri(0, 0.001) },
{ weight: 0.5, knots: tri(0.02, 0.001) },
];
const Y: Mixture = [{ weight: 1, knots: tri(0.01, 0.001) }];
close(batchValue([X]), 407 / 40000);
close(batchValue([Y]), 0.01);
close(batchValue([X, Y]), 0.015);
assert.equal(batchValue([]), 0);
});
function input(over: Partial<BatchInput>): BatchInput {
return {
candidates: [],
baseline: 0,
direction: "maximize",
epsilon: 0.25,
lambda: 0,
batchCap: 2,
balance: 10,
seed: "seed-1",
...over,
};
}
test("6.4 入池:P(Y < −ε) 正好一半不入池,小于一半入池", () => {
// 手算:b = 1,ε = 0.25,λ = 0。甲交 (0.5, 0.75, 1):P(分数 < 0.75) = 0.5,不入池。
// 乙交 (0.5, 0.8125, 1):P(分数 < 0.75) = 0.1 + 0.4 × 0.25/0.3125 = 0.42,入池。
const d = selectBestExpected(
input({
baseline: 1,
candidates: [
{ id: "a", ranges: one({ q1: 0.5, q5: 0.75, q9: 1 }) },
{ id: "b", ranges: one({ q1: 0.5, q5: 0.8125, q9: 1 }) },
],
}),
);
assert.equal(d.candidates[0]!.lossProbability, 0.5);
assert.equal(d.candidates[0]!.inPool, false);
close(d.candidates[1]!.lossProbability, 0.42);
assert.deepEqual(d.pool, ["b"]);
});
test("6.4 入池(越小越好):交 (1, 1.25, 1.5)、b = 1、ε = 0.25 时 P(Y < −ε) = 0.5,不入池", () => {
// 手算:越小越好时 Y = b − 分数,Y < −0.25 即分数 > 1.25,把握 0.5。
const d = selectBestExpected(
input({ baseline: 1, direction: "minimize", candidates: [{ id: "a", ranges: one({ q1: 1, q5: 1.25, q9: 1.5 }) }] }),
);
close(d.candidates[0]!.lossProbability, 0.5);
assert.equal(d.candidates[0]!.lossProbability >= 0.5, true);
assert.deepEqual(d.pool, []);
assert.deepEqual(d.chosen, []);
assert.equal(d.chosenValue, 0);
assert.equal(d.scaleReading, 0);
});
test("6.4 选批:列出全部组合;V 完全相等时按编号定;池外候选不进组合", () => {
// b = 0,λ = 0,k = 1。四个窄范围:a 在 0.25,b、c 都在 0.75(输入完全相同,V 逐位相等),
// d 在 −0.5(P(Y < −ε) = 1,不入池)。手算:V(a) = 0.25,V(b) = V(c) = 0.75(对称窄范围的期望,都在 0 以上)。
// b、c 相等,取编号小的 b。V(池) = E[max(a, b, c)] = 11323/15000,分数尺读数 = 11323/15000 − 0.75 = 73/15000
// (rules_ref.py 的 V,有理数精确)。
const d = selectBestExpected(
input({
batchCap: 1,
candidates: [
{ id: "d", ranges: one(narrow(-0.5)) },
{ id: "c", ranges: one(narrow(0.75)) },
{ id: "b", ranges: one(narrow(0.75)) },
{ id: "a", ranges: one(narrow(0.25)) },
],
}),
);
assert.equal(d.k, 1);
assert.deepEqual(d.pool, ["a", "b", "c"]);
assert.deepEqual(
d.combos.map((c) => c.ids.join("")),
["a", "b", "c"],
);
close(d.combos[0]!.value, 0.25);
close(d.combos[1]!.value, 0.75);
assert.equal(d.combos[1]!.value, d.combos[2]!.value);
assert.deepEqual(d.chosen, ["b"]);
close(d.poolValue, 11323 / 15000);
close(d.scaleReading, 73 / 15000);
assert.deepEqual(d.candidates.map((c) => c.id), ["a", "b", "c", "d"]);
const m = d.candidates[1]!.merged;
close(m.q1, 0.74);
close(m.q5, 0.75);
close(m.q9, 0.76);
});
test("6.4 分数尺读数 = V(候选池) − V(S*)", () => {
// 手算:b = 0,λ = 0,k = 1。a 交 (0, 0.5, 1):E[max(0, Y)] = ∫₀^1.5 (1 − F)
// = 0.5 × (1 − 0.3) + 0.5 × (1 − 0.7) + 0.5 × (1 − 0.95) = 0.35 + 0.15 + 0.025 = 0.525。
// b 交 (0.49, 0.5, 0.51):V = 0.5。选 a。V(池) = E[max(a, b)] = 31642/46875,读数 31642/46875 − 0.525
// (rules_ref.py 的 V,有理数精确;b 换成 0.5 处的定值时是 0.675 和 0.15)。
const d = selectBestExpected(
input({
batchCap: 1,
candidates: [
{ id: "a", ranges: one({ q1: 0, q5: 0.5, q9: 1 }) },
{ id: "b", ranges: one(narrow(0.5)) },
],
}),
);
assert.deepEqual(d.chosen, ["a"]);
close(d.chosenValue, 0.525);
close(d.poolValue, 31642 / 46875);
close(d.scaleReading, 31642 / 46875 - 0.525);
});
test("6.4 批量 k = min(每批上限, 余额);池里不足 k 个时全选", () => {
// 手算:余额 1 时 k = 1;余额 5、上限 2 时 k = 2,但池里只有 1 个,全选。
const cands = [
{ id: "a", ranges: one(narrow(0.25)) },
{ id: "b", ranges: one(narrow(0.5)) },
];
assert.equal(selectBestExpected(input({ balance: 1, candidates: cands })).k, 1);
assert.deepEqual(selectBestExpected(input({ balance: 1, candidates: cands })).chosen, ["b"]);
const single = selectBestExpected(input({ balance: 5, candidates: [{ id: "a", ranges: one(narrow(0.25)) }] }));
assert.equal(single.k, 2);
assert.deepEqual(single.chosen, ["a"]);
});
// 约定表第 40 条的独立写法:sha256(`${seed}:${counter}`) 前 6 字节,拒绝采样,部分 Fisher–Yates。
function referenceSample(ids: string[], size: number, seed: string): string[] {
const xs = [...ids].sort();
let counter = 0;
const draw = (n: number): number => {
const limit = 2 ** 48 - (2 ** 48 % n);
for (;;) {
const hex = createHash("sha256").update(`${seed}:${counter}`).digest("hex").slice(0, 12);
counter += 1;
const v = parseInt(hex, 16);
if (v < limit) return v % n;
}
};
for (let i = 0; i < size; i++) {
const j = i + draw(xs.length - i);
const t = xs[i]!;
xs[i] = xs[j]!;
xs[j] = t;
}
return xs.slice(0, size).sort();
}
test("均匀随机选批:不看入池门槛;同一个种子得到同一组;与约定表第 40 条的独立写法一致", () => {
const cands = ["e", "a", "d", "b", "c"].map((id, i) => ({ id, ranges: one(narrow(i === 0 ? -1 : 0.25 * i)) }));
for (const seed of ["s1", "s2", "s3", "s4"]) {
const d = selectUniformRandom(input({ seed, candidates: cands }));
assert.deepEqual(d.chosen, referenceSample(["a", "b", "c", "d", "e"], 2, seed));
assert.deepEqual(selectUniformRandom(input({ seed, candidates: [...cands].reverse() })).chosen, d.chosen);
assert.deepEqual(d.combos, []);
}
// 批量不小于候选数时全部抽中,包括池外的 e(窄范围在 −1)。
const all = selectUniformRandom(input({ batchCap: 9, candidates: cands }));
assert.deepEqual(all.chosen, ["a", "b", "c", "d", "e"]);
assert.equal(all.candidates.find((c) => c.id === "e")!.inPool, false);
});
test("登记表:两条选批规则按名字跑,结果带规则名和版本", () => {
const cands = [
{ id: "a", ranges: one(narrow(0.25)) },
{ id: "b", ranges: one(narrow(0.5)) },
];
const best = runBatchRule("bestExpected", input({ batchCap: 1, candidates: cands }));
assert.deepEqual(best.rule, { name: "bestExpected", version: 1 });
assert.deepEqual(best.chosen, ["b"]);
const rnd = runBatchRule("uniformRandom", input({ batchCap: 1, candidates: cands, seed: "x" }));
assert.deepEqual(rnd.rule, { name: "uniformRandom", version: 1 });
assert.deepEqual(rnd.chosen, referenceSample(["a", "b"], 1, "x"));
assert.equal(rnd.seed, "x");
});
test("同样的输入算两次,选批结果逐位相同", () => {
const two = (r1: { q1: number; q5: number; q9: number }, r2: { q1: number; q5: number; q9: number }) => [
{ researcher: "r1", range: r1 },
{ researcher: "r2", range: r2 },
];
const i = input({
lambda: 0.1,
candidates: [
{ id: "a", ranges: two({ q1: 0.1, q5: 0.3, q9: 0.7 }, { q1: -0.2, q5: 0.1, q9: 0.2 }) },
{ id: "b", ranges: two({ q1: 0, q5: 0.2, q9: 0.9 }, { q1: 0.05, q5: 0.15, q9: 0.25 }) },
{ id: "c", ranges: two({ q1: -0.3, q5: 0.25, q9: 0.6 }, narrow(0.1)) },
],
});
assert.deepEqual(selectBestExpected(i), selectBestExpected(i));
});
test("6.5 采纳线:没有已知最好是 ε;有时 min(ε, (K* − B)/4),下限 ε/100,B 超过 K* 也取 ε/100", () => {
// 手算:ε = 0.01,K* = 2.635983。
assert.deepEqual(adoptionLine({ baseline: 2.5, knownBest: null, epsilon: 0.01, direction: "maximize" }), {
line: 0.01,
quarter: null,
});
// B = 2.5:(0.135983)/4 = 0.03399575 > ε,取 ε。
assert.equal(adoptionLine({ baseline: 2.5, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.01);
// B = 2.6:0.035983/4 = 0.00899575。
close(adoptionLine({ baseline: 2.6, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.00899575);
// B = 2.6359:0.000083/4 = 0.00002075 < 0.0001,取 ε/100 = 0.0001。
assert.equal(adoptionLine({ baseline: 2.6359, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.01 / 100);
// B = 2.64 超过 K*:取 0.0001。
assert.equal(adoptionLine({ baseline: 2.64, knownBest: 2.635983, epsilon: 0.01, direction: "maximize" }).line, 0.01 / 100);
// ε 换成 1 时下限是 0.01,跟随 ε。
assert.equal(adoptionLine({ baseline: 3, knownBest: 2, epsilon: 1, direction: "maximize" }).line, 0.01);
// 越小越好:K* = 1,B = 1.02:(1.02 − 1)/4 = 0.005。
close(adoptionLine({ baseline: 1.02, knownBest: 1, epsilon: 0.01, direction: "minimize" }).line, 0.005);
});
test("4.3 ⑫ 采纳:最好的有效候选严格高出采纳线才采纳;正好等于不采纳;同分取编号小的", () => {
// 手算:B = 1,ε = 0.25,没有已知最好,采纳线 0.25。
const base = { baseline: 1, knownBest: null, epsilon: 0.25, direction: "maximize" as const };
assert.equal(adopt({ ...base, candidates: [{ id: "a", score: 1.25 }] }).adopted, null);
assert.equal(adopt({ ...base, candidates: [{ id: "a", score: 1.5 }] }).adopted, "a");
const tie = adopt({
...base,
candidates: [
{ id: "b", score: 1.5 },
{ id: "a", score: 1.5 },
{ id: "c", score: 1.25 },
],
});
assert.deepEqual(tie.best, { id: "a", score: 1.5, improvement: 0.5 });
assert.equal(tie.adopted, "a");
assert.equal(adopt({ ...base, candidates: [] }).adopted, null);
// 越小越好:分数 0.5 比 B = 1 改进 0.5。
assert.equal(adopt({ ...base, direction: "minimize", candidates: [{ id: "a", score: 0.5 }] }).adopted, "a");
});