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