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>
262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
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");
|
||
});
|