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>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
adopt,
|
||||
adoptionLine,
|
||||
batchValue,
|
||||
decisionSeed,
|
||||
below,
|
||||
density,
|
||||
forwardMergedRange,
|
||||
forwardMixtures,
|
||||
knotsMean,
|
||||
ablationCellMixture,
|
||||
mixtureBelow,
|
||||
mixtureQuantile,
|
||||
mixtureRange,
|
||||
personKnots,
|
||||
selectBestExpected,
|
||||
selectUniformRandom,
|
||||
toImprovement,
|
||||
type Knots,
|
||||
type Mixture,
|
||||
} from "../src/rules/index.ts";
|
||||
import { close, closeArray, loadFixture, range, scenarioPicker } from "./rules-fixtures-helpers.ts";
|
||||
|
||||
// forward.json:方法.md 6.1–6.5 与约定 1–16,对照独立 Python 参考实现算出的预期值。
|
||||
const fixture = loadFixture("forward.json");
|
||||
const pick = scenarioPicker(fixture);
|
||||
const TOL: number = fixture.tolerance.abs;
|
||||
const SEED = "fixture-seed";
|
||||
|
||||
const xs = (k: Knots): number[] => k.map(([x]) => x);
|
||||
const ys = (k: Knots): number[] => k.map(([, y]) => y);
|
||||
const mean = (m: Mixture): number => m.reduce((s, c) => s + c.weight * knotsMean(c.knots), 0);
|
||||
/** 正向一个候选的各人范围:[{researcher, range}]。 */
|
||||
const rangesOf = (rs: any[]) => rs.map((r) => ({ researcher: r.researcher as string, range: range(r.range) }));
|
||||
const candidatesOf = (input: Record<string, any>) =>
|
||||
Object.entries(input.candidates as Record<string, any[]>).map(([id, rs]) => ({ id, ranges: rangesOf(rs) }));
|
||||
|
||||
for (const name of ["cdf_method_example_2.44_2.52_2.62", "cdf_asymmetric_narrow"]) {
|
||||
test(`forward.json ${name}`, () => {
|
||||
const s = pick.get(name);
|
||||
const k = personKnots(range(s.input.range));
|
||||
closeArray(xs(k), s.expected.points.map((p: number[]) => p[0]), TOL, "折点横坐标");
|
||||
closeArray(ys(k), s.expected.points.map((p: number[]) => p[1]), TOL, "折点纵坐标");
|
||||
closeArray(s.input.x.map((x: number) => below(k, x)), s.expected.cdf, TOL, "F");
|
||||
closeArray(s.input.densityAt.map((x: number) => density(k, x)), s.expected.density, TOL, "密度");
|
||||
close(knotsMean(k), s.expected.mean, TOL, "期望");
|
||||
});
|
||||
}
|
||||
|
||||
test("forward.json minimize_to_improvement", () => {
|
||||
const s = pick.get("minimize_to_improvement");
|
||||
const imp = toImprovement([{ weight: 1, knots: personKnots(range(s.input.range)) }], s.input.b, "minimize");
|
||||
const k = imp[0]!.knots;
|
||||
closeArray(xs(k), s.expected.Ypoints.map((p: number[]) => p[0]), TOL, "Y 折点横坐标");
|
||||
closeArray(ys(k), s.expected.Ypoints.map((p: number[]) => p[1]), TOL, "Y 折点纵坐标");
|
||||
closeArray([k[1]![0], k[2]![0], k[3]![0]], [s.expected.Yrange.q1, s.expected.Yrange.q5, s.expected.Yrange.q9], TOL, "Y 三个数");
|
||||
closeArray(s.input.y.map((y: number) => mixtureBelow(imp, y)), s.expected.Ycdf, TOL, "Y 的 F");
|
||||
});
|
||||
|
||||
for (const name of ["forward_merge_r1_real", "forward_merge_minimize"]) {
|
||||
test(`forward.json ${name}`, () => {
|
||||
const s = pick.get(name);
|
||||
const { b } = s.input;
|
||||
const direction = s.params.direction;
|
||||
const cands = candidatesOf(s.input);
|
||||
const raws = forwardMixtures(cands, s.params.lambda);
|
||||
const imps = raws.map((m) => toImprovement(m, b, direction));
|
||||
closeArray(xs(raws[0]!.at(-1)!.knots), s.expected.reserveRaw, TOL, "原值上的开放预留");
|
||||
closeArray(xs(imps[0]!.at(-1)!.knots), s.expected.reserveY, TOL, "改进量上的开放预留");
|
||||
cands.forEach((c, i) => {
|
||||
const e = s.expected.candidates[c.id];
|
||||
const imp = imps[i]!;
|
||||
closeArray(s.input.Yprobe.map((y: number) => mixtureBelow(imp, y)), e.Ycdf, TOL, `${c.id} Y 的 F`);
|
||||
const yq = [0.1, 0.5, 0.9].map((p) => mixtureQuantile(imp, p));
|
||||
closeArray(yq, [e.Yquantiles.q1, e.Yquantiles.q5, e.Yquantiles.q9], TOL, `${c.id} Y 分位数`);
|
||||
const r = forwardMergedRange(imp, b, direction);
|
||||
closeArray([r.q1, r.q5, r.q9], [e.rawQuantiles.q1, e.rawQuantiles.q5, e.rawQuantiles.q9], TOL, `${c.id} 原值分位数`);
|
||||
close(mean(imp), e.Ymean, TOL, `${c.id} Y 期望`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("forward.json ablation_cell_merge_r1_p1", () => {
|
||||
const s = pick.get("ablation_cell_merge_r1_p1");
|
||||
const m = ablationCellMixture(
|
||||
s.input.explanations.map((e: any) => ({ id: e.id, author: e.author, range: range(e.range) })),
|
||||
s.params.lambda,
|
||||
);
|
||||
closeArray(xs(m.at(-1)!.knots), s.expected.reserve, TOL, "这一格的开放预留");
|
||||
closeArray(m.map((c) => c.weight), s.expected.weights, TOL, "成分权重");
|
||||
closeArray(s.input.x.map((x: number) => mixtureBelow(m, x)), s.expected.cdf, TOL, "F");
|
||||
const q = mixtureRange(m);
|
||||
closeArray([q.q1, q.q5, q.q9], [s.expected.quantiles.q1, s.expected.quantiles.q5, s.expected.quantiles.q9], TOL, "分位数");
|
||||
});
|
||||
|
||||
test("forward.json quantile_flat_left_end", () => {
|
||||
const s = pick.get("quantile_flat_left_end");
|
||||
const m: Mixture = s.input.mixture.map((c: any) => ({ weight: c.weight, knots: personKnots(range(c.range)) }));
|
||||
closeArray(s.input.p.map((p: number) => mixtureQuantile(m, p)), s.expected.quantiles, TOL, "分位数");
|
||||
});
|
||||
|
||||
/** 入池走选批规则的正式路径:候选的入池把握和是否入池都在 BatchDecision.candidates 里。 */
|
||||
function poolOf(s: { params: Record<string, any>; input: Record<string, any> }, candidates: Record<string, any[]>) {
|
||||
return selectBestExpected({
|
||||
candidates: Object.entries(candidates).map(([id, rs]) => ({ id, ranges: rangesOf(rs) })),
|
||||
baseline: s.input.b,
|
||||
direction: s.params.direction,
|
||||
epsilon: s.params.eps,
|
||||
lambda: s.params.lambda,
|
||||
batchCap: 1,
|
||||
balance: 1,
|
||||
seed: SEED,
|
||||
}).candidates;
|
||||
}
|
||||
|
||||
for (const name of ["pool_r1_real", "pool_minimize"]) {
|
||||
test(`forward.json ${name}`, () => {
|
||||
const s = pick.get(name);
|
||||
const got = poolOf(s, s.input.candidates);
|
||||
assert.deepEqual(got.map((c) => c.id), Object.keys(s.expected).sort());
|
||||
for (const c of got) {
|
||||
close(c.lossProbability, s.expected[c.id].probBelowMinusEps, TOL, `${c.id} P(Y < −ε)`);
|
||||
assert.equal(c.inPool, s.expected[c.id].inPool, `${c.id} 是否入池`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("forward.json pool_exactly_half", () => {
|
||||
const s = pick.get("pool_exactly_half");
|
||||
s.input.cases.forEach((cs: any, i: number) => {
|
||||
const [c] = poolOf(s, cs.candidates);
|
||||
close(c!.lossProbability, s.expected.cases[i].probBelowMinusEps, TOL, `第 ${i} 例 P(Y < −ε)`);
|
||||
assert.equal(c!.inPool, s.expected.cases[i].inPool, `第 ${i} 例是否入池`);
|
||||
});
|
||||
});
|
||||
|
||||
test("forward.json V_method_example_X_Y", () => {
|
||||
const s = pick.get("V_method_example_X_Y");
|
||||
const d = (name: string): Mixture =>
|
||||
s.input.Ymixtures[name].map((c: any) => ({ weight: c.weight, knots: personKnots(range(c.range)) }));
|
||||
close(batchValue([d("X")]), s.expected.V.X, TOL, "V(X)");
|
||||
close(batchValue([d("Y")]), s.expected.V.Y, TOL, "V(Y)");
|
||||
close(batchValue([d("X"), d("Y")]), s.expected.V["X,Y"], TOL, "V(X,Y)");
|
||||
});
|
||||
|
||||
for (const name of ["batch_r1_real_k2", "batch_minimize_out_of_pool", "batch_tie_by_id"]) {
|
||||
test(`forward.json ${name}`, () => {
|
||||
const s = pick.get(name);
|
||||
const d = selectBestExpected({
|
||||
candidates: candidatesOf(s.input),
|
||||
baseline: s.input.b,
|
||||
direction: s.params.direction,
|
||||
epsilon: s.params.eps,
|
||||
lambda: s.params.lambda,
|
||||
batchCap: s.params.batchCap,
|
||||
balance: s.input.balance,
|
||||
seed: SEED,
|
||||
});
|
||||
assert.deepEqual(d.pool, s.expected.pool, "候选池");
|
||||
assert.equal(d.k, s.expected.k, "批量");
|
||||
assert.deepEqual(d.combos.map((c) => c.ids), s.expected.combos.map((c: any) => c.combo), "列出的组合");
|
||||
closeArray(d.combos.map((c) => c.value), s.expected.combos.map((c: any) => c.V), TOL, "各组合的 V");
|
||||
assert.deepEqual(d.chosen, s.expected.selected, "选中的组合");
|
||||
close(d.chosenValue, s.expected.selectedV, TOL, "V(S*)");
|
||||
close(d.poolValue, s.expected.poolAllV, TOL, "V(候选池)");
|
||||
close(d.scaleReading, s.expected.rulerReading, TOL, "分数尺读数");
|
||||
});
|
||||
}
|
||||
|
||||
test("forward.json batch_uniform_random_minimize", () => {
|
||||
const s = pick.get("batch_uniform_random_minimize");
|
||||
const { studyId, round, version } = s.input.seedFrom;
|
||||
assert.equal(decisionSeed({ studyId, round, version }), s.input.seed, "种子");
|
||||
const d = selectUniformRandom({
|
||||
candidates: candidatesOf(s.input),
|
||||
baseline: s.input.b,
|
||||
direction: s.params.direction,
|
||||
epsilon: s.params.eps,
|
||||
lambda: s.params.lambda,
|
||||
batchCap: s.params.batchCap,
|
||||
balance: s.input.balance,
|
||||
seed: s.input.seed,
|
||||
});
|
||||
assert.deepEqual(d.pool, s.expected.pool, "候选池");
|
||||
assert.equal(d.k, s.expected.k, "批量");
|
||||
assert.deepEqual(d.combos, [], "均匀随机不列组合");
|
||||
assert.deepEqual(d.chosen, s.expected.selected, "抽中的组合");
|
||||
assert.ok(d.chosen.some((id) => !d.pool.includes(id)), "池外候选可以被抽中");
|
||||
close(d.chosenValue, s.expected.selectedV, TOL, "V(S*)");
|
||||
close(d.poolValue, s.expected.poolAllV, TOL, "V(候选池)");
|
||||
close(d.scaleReading, s.expected.rulerReading, TOL, "分数尺读数(按候选池)");
|
||||
});
|
||||
|
||||
test("forward.json adoption_line_cases", () => {
|
||||
const s = pick.get("adoption_line_cases");
|
||||
s.input.cases.forEach((c: any, i: number) => {
|
||||
const { line } = adoptionLine({ baseline: c.B, knownBest: c.knownBest, epsilon: s.params.eps, direction: c.direction });
|
||||
close(line, s.expected.lines[i], TOL, `第 ${i} 例采纳线(${c.note})`);
|
||||
});
|
||||
});
|
||||
|
||||
test("forward.json adoption_decision_cases", () => {
|
||||
const s = pick.get("adoption_decision_cases");
|
||||
s.input.cases.forEach((c: any, i: number) => {
|
||||
const e = s.expected.cases[i];
|
||||
const r = adopt({
|
||||
baseline: c.B,
|
||||
knownBest: c.knownBest,
|
||||
epsilon: s.params.eps,
|
||||
direction: c.direction,
|
||||
// 只收有有效成绩的候选(由调用方筛)。
|
||||
candidates: Object.entries(c.results as Record<string, number | null>).flatMap(([id, score]) =>
|
||||
score === null ? [] : [{ id, score }],
|
||||
),
|
||||
});
|
||||
close(r.line, e.line, TOL, `第 ${i} 例采纳线`);
|
||||
assert.equal(r.best?.id ?? null, e.best, `第 ${i} 例最好的候选`);
|
||||
if (e.best !== null) close(r.best!.improvement, e.gain, TOL, `第 ${i} 例改进`);
|
||||
assert.equal(r.adopted !== null, e.adopt, `第 ${i} 例是否采纳`);
|
||||
if (e.adopt) assert.equal(r.adopted, e.best);
|
||||
});
|
||||
});
|
||||
|
||||
test("forward.json 每个场景都已覆盖", () => {
|
||||
assert.deepEqual(pick.unused(), []);
|
||||
});
|
||||
Reference in New Issue
Block a user