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>
182 lines
7.5 KiB
TypeScript
182 lines
7.5 KiB
TypeScript
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 };
|
||
}
|