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:
2026-10-02 01:50:21 -04:00
parent bfb96ba7d7
commit 60c21ae346
37 changed files with 14067 additions and 3 deletions
+181
View File
@@ -0,0 +1,181 @@
import type { Direction, Range } from "../vocab/index.ts";
import { combinations, compareIds, epsilonFloor, improvement } from "./basics.ts";
import { forwardBatchSize } from "./budget.ts";
import {
forwardMergedRange,
forwardMixtures,
integrateUpperTail,
mixtureBelow,
toImprovement,
type Mixture,
} from "./distribution.ts";
import { seededSample } from "./random.ts";
// 方法.md 6.4 入池、选批、分数尺读数;6.5 采纳线;4.3 ⑫ 采纳。
/** 选批规则的统一输入:同一份输入可以换选批规则重算。 */
export interface BatchInput {
/** 本轮的全部候选(入池在规则里判);ranges 是每位研究者第二次预测的三个数(分数原值),一人一份,带研究者编号。 */
candidates: ReadonlyArray<{ id: string; ranges: ReadonlyArray<{ researcher: string; range: Range }> }>;
/** 本批基线分数 b。 */
baseline: number;
direction: Direction;
epsilon: number;
/** 开放预留 λ。 */
lambda: number;
batchCap: number;
/** 运行次数余额。 */
balance: number;
/** 本次决定的种子(decisionSeed)。批内最好期望规则不用它,照样记进记录。 */
seed: string;
}
/** 选批规则的统一输出,含 6.4"选批记录写入"的全部内容;规则名和版本由登记表补上。 */
export interface BatchDecision {
seed: string;
params: { epsilon: number; lambda: number; batchCap: number; balance: number };
/** 批量 k = min(每批上限, 余额)。 */
k: number;
/** 每个候选(按编号排):大家合起来的范围(分数原值)、"比 b 低 ε 以上"的把握、是否入池。 */
candidates: Array<{ id: string; merged: Range; lossProbability: number; inPool: boolean }>;
/** 入池的候选编号(按编号排)。 */
pool: string[];
/** 列出的每个组合及其批内最好期望 V(均匀随机规则不列组合,是空数组)。 */
combos: Array<{ ids: string[]; value: number }>;
/** 选中的组合(按编号排)。 */
chosen: string[];
/** V(S*)。 */
chosenValue: number;
/** E[max(0, max_{c∈池} Y_c)] = V(候选池)。 */
poolValue: number;
/** 分数尺读数 = V(候选池) − V(S*),只记录。 */
scaleReading: number;
}
/**
* 批内最好期望 V(S) = ∫_0^∞ [1 − ∏_{c∈S} F_c(t)] dt(6.4),F_c 是改进量上的合并分布,候选之间按互不相关,
* 低于 b 的部分按 0 算。空组合是 0。
*/
export function batchValue(imps: readonly Mixture[]): number {
return imps.length === 0 ? 0 : integrateUpperTail(imps, 0);
}
/** 入池(6.4):合并分布里"比 b 低 ε 以上"的把握 P(Y < −ε) 严格小于一半才入池;正好低 ε 不算"低 ε 以上"。 */
export function lossProbability(imp: Mixture, epsilon: number): number {
return mixtureBelow(imp, -epsilon);
}
/** 两条选批规则共用的部分:合并、入池、合并范围、批量、候选池的 V。 */
function assess(input: BatchInput) {
const sorted = [...input.candidates].sort((a, b) => compareIds(a.id, b.id));
const imps = forwardMixtures(sorted, input.lambda).map((m) => toImprovement(m, input.baseline, input.direction));
const candidates = sorted.map((c, i) => {
const loss = lossProbability(imps[i]!, input.epsilon);
return {
id: c.id,
merged: forwardMergedRange(imps[i]!, input.baseline, input.direction),
lossProbability: loss,
inPool: loss < 0.5,
};
});
const poolIdx = candidates.flatMap((c, i) => (c.inPool ? [i] : []));
return {
imps,
candidates,
poolIdx,
k: forwardBatchSize(input.batchCap, input.balance),
poolValue: batchValue(poolIdx.map((i) => imps[i]!)),
params: { epsilon: input.epsilon, lambda: input.lambda, batchCap: input.batchCap, balance: input.balance },
};
}
/**
* 选批规则"批内最好期望"(6.4):列出候选池里所有 min(k, 池里个数) 个候选的组合,取 V 最大的;
* V 完全相等(不带容差)时取编号字典序小的组合(组合按字典序列出,后来者严格更大才替换)。
*/
export function selectBestExpected(input: BatchInput): BatchDecision {
const a = assess(input);
const size = Math.min(a.k, a.poolIdx.length);
const picks = size === 0 ? [] : combinations(a.poolIdx.length, size);
const combos = picks.map((pick) => {
const idx = pick.map((p) => a.poolIdx[p]!);
return { ids: idx.map((i) => a.candidates[i]!.id), value: batchValue(idx.map((i) => a.imps[i]!)) };
});
// 池空或批量为 0 时一个组合都没有,S* 是空组合,V = 0。
let best = combos[0] ?? { ids: [], value: 0 };
for (const c of combos) if (c.value > best.value) best = c;
return {
seed: input.seed,
params: a.params,
k: a.k,
candidates: a.candidates,
pool: a.poolIdx.map((i) => a.candidates[i]!.id),
combos,
chosen: best.ids,
chosenValue: best.value,
poolValue: a.poolValue,
scaleReading: a.poolValue - best.value,
};
}
/**
* 选批规则"均匀随机"(第十一部分):从全部候选里(不看入池门槛)不放回地均匀抽 min(k, 候选数) 个。
* 入池、合并范围、分数尺读数照算照记;S* 是抽中的组合,可能含池外候选。
*/
export function selectUniformRandom(input: BatchInput): BatchDecision {
const a = assess(input);
const ids = a.candidates.map((c) => c.id);
const chosen = seededSample(ids, Math.min(a.k, ids.length), input.seed);
const chosenValue = batchValue(chosen.map((id) => a.imps[ids.indexOf(id)]!));
return {
seed: input.seed,
params: a.params,
k: a.k,
candidates: a.candidates,
pool: a.poolIdx.map((i) => a.candidates[i]!.id),
combos: [],
chosen,
chosenValue,
poolValue: a.poolValue,
scaleReading: a.poolValue - chosenValue,
};
}
/** 采纳线的输入(6.5):B 是当前基线分数,K* 是已知最好(没有时 null)。 */
export interface AdoptionLineInput {
baseline: number;
knownBest: number | null;
epsilon: number;
direction: Direction;
}
/**
* 采纳线(6.5):没有已知最好时是 ε;有时是 min(ε, (K* − B) ÷ 4)(按方向折算),结果低于 ε/100 时取 ε/100,
* B 已经超过 K*((K* − B) 为负)时同样取 ε/100。quarter 是 (K* − B) ÷ 4 的原值,没有已知最好时为 null。
*/
export function adoptionLine(input: AdoptionLineInput): { line: number; quarter: number | null } {
if (input.knownBest === null) return { line: input.epsilon, quarter: null };
const quarter = improvement(input.baseline, input.knownBest, input.direction) / 4;
return { line: Math.max(epsilonFloor(input.epsilon), Math.min(input.epsilon, quarter)), quarter };
}
/**
* 采纳(6.5、4.3 ⑫):只看实测分数。candidates 是有有效分数的正向候选;最好的那个(按方向,同分取编号小的)
* 比基线的改进严格大于采纳线才采纳。
*/
export function adopt(
input: AdoptionLineInput & { candidates: ReadonlyArray<{ id: string; score: number }> },
): {
line: number;
quarter: number | null;
best: { id: string; score: number; improvement: number } | null;
adopted: string | null;
} {
const { line, quarter } = adoptionLine(input);
let best: { id: string; score: number; improvement: number } | null = null;
for (const c of [...input.candidates].sort((x, y) => compareIds(x.id, y.id))) {
const gain = improvement(input.baseline, c.score, input.direction);
if (best === null || gain > best.improvement) best = { id: c.id, score: c.score, improvement: gain };
}
return { line, quarter, best, adopted: best !== null && best.improvement > line ? best.id : null };
}