Skip to content

Commit 2defb4a

Browse files
authored
feat(calibration): claimed-confidence reliability curves with derived threshold suggestion (#8252)
Bucket a rule's decided BacktestCases by claimed confidence (metadata.confidence) into fixed 0.05-step edges and report per-bucket empirical precision (null below the sample floor, never 0), then derive the loosest floor whose at-or-above pooled precision meets a target -- never below the hard minimum, null on insufficient pooled density. Engine-exported; no consumer changes (#8226, epic #8211 track E).
1 parent e4708ba commit 2defb4a

4 files changed

Lines changed: 714 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Per-rule reliability curve + derived threshold suggestion (#8226, epic #8211 track E). Knob evaluation
2+
// (src/services/loosening-knobs.ts) steps down hand-picked candidate ladders; the labeled corpus supports
3+
// something strictly better: bucket a rule's decided cases by their CLAIMED confidence (metadata.confidence,
4+
// the same channel buildConfidenceThresholdClassifier reads, #8138), measure each bucket's EMPIRICAL
5+
// precision against the human verdicts, and let the optimal floor fall out of the curve instead of being
6+
// guessed. This module is the pure math only -- no advisor/registry integration (maintainer follow-on).
7+
//
8+
// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads.
9+
10+
import type { BacktestCase } from "./backtest-corpus.js";
11+
12+
/** One claimed-confidence bucket of a {@link ReliabilityCurve}: its `[floor, ceiling)` confidence range
13+
* (the curve's TOP bucket is ceiling-inclusive so a claimed confidence of exactly 1 is bucketable), the
14+
* decided cases whose claimed confidence landed in it, their confirmed/reversed verdict split, and the
15+
* bucket's empirical precision (`confirmed / cases`) -- null, never 0, when `cases` sits below the curve's
16+
* sample floor, the same "unknown stays unknown" discipline as RulePrecisionReport.precision (#8085). */
17+
export type ReliabilityBucket = {
18+
floor: number;
19+
ceiling: number;
20+
cases: number;
21+
confirmed: number;
22+
reversed: number;
23+
precision: number | null;
24+
};
25+
26+
/** A rule's claimed-confidence reliability curve: `buckets` ascending by `floor`, plus the `sampleFloor`
27+
* the per-bucket precisions were computed under -- carried so {@link deriveThresholdSuggestion} can apply
28+
* the SAME never-on-noise floor to its pooled counts. */
29+
export type ReliabilityCurve = {
30+
sampleFloor: number;
31+
buckets: ReliabilityBucket[];
32+
};
33+
34+
/** Default bucket edges: one catch-all below 0.3, then 0.05-wide buckets up to 1 -- the SAME granularity
35+
* the loosenable-knob registry's candidate ladders step at (loosening-knobs.ts: [0.45, 0.4, 0.35, 0.3]
36+
* and [0.9, 0.85]), so every floor the registry could actually adopt, both hard minimums (0.3, 0.85)
37+
* included, is exactly a bucket floor a suggestion can land on. No shipped floor lives below 0.3, hence
38+
* the single catch-all there. Sparse corpora keep their honesty either way: a thin bucket reports null
39+
* precision, and {@link deriveThresholdSuggestion} pools at-or-above buckets before judging density. */
40+
export const DEFAULT_RELIABILITY_BUCKET_EDGES: readonly number[] = [
41+
0, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1,
42+
];
43+
44+
/** Minimum decided cases before a bucket (or a pooled suggestion window) reports a real precision --
45+
* below it the value is null, never 0. 5 mirrors the registry's smallest never-on-noise floor
46+
* (loosening-knobs.ts minHeldOutCases: 5) and MIN_CALIBRATION_SAMPLES (contributor-calibration.ts). */
47+
export const RELIABILITY_BUCKET_SAMPLE_FLOOR = 5;
48+
49+
/** Index of the bucket containing `claimed` under half-open `[floor, ceiling)` edges with a
50+
* ceiling-INCLUSIVE top bucket, or -1 when it lands in none (below the first edge, above the last, or
51+
* NaN -- the negated first guard makes NaN fail closed into -1 rather than landing in a bucket). */
52+
function bucketIndexFor(claimed: number, bucketEdges: readonly number[]): number {
53+
if (!(claimed >= bucketEdges[0]!)) return -1;
54+
for (let i = 1; i < bucketEdges.length; i++) {
55+
if (claimed < bucketEdges[i]!) return i - 1;
56+
}
57+
return claimed === bucketEdges[bucketEdges.length - 1]! ? bucketEdges.length - 2 : -1;
58+
}
59+
60+
/**
61+
* Bucket `cases` by their CLAIMED confidence (`metadata.confidence`) and report each bucket's empirical
62+
* precision against the human verdicts. A case with no numeric claimed confidence contributes to no bucket:
63+
* this deliberately DIVERGES from buildConfidenceThresholdClassifier's degrade-to-1 fallback (#8138) --
64+
* that function must DECIDE every case, this one MEASURES claim reliability, and fabricating a confidence-1
65+
* claim would corrupt the top bucket's evidence (same "drop rather than guess" posture as
66+
* repo-corpus-slice's unparseable-key handling). An out-of-range claim (below the first edge, above the
67+
* last) is likewise dropped, never clamped into a bucket. A bucket below `sampleFloor` reports null
68+
* precision, never 0. Throws on malformed `bucketEdges` (fewer than 2, out of [0, 1], or not strictly
69+
* ascending) or a `sampleFloor` below 1 -- caller bugs, mirroring splitBacktestCorpus's guard; the negated
70+
* compound forms make NaN fail closed into the throw. Pure and deterministic.
71+
*/
72+
export function computeReliabilityCurve(
73+
cases: readonly BacktestCase[],
74+
bucketEdges: readonly number[] = DEFAULT_RELIABILITY_BUCKET_EDGES,
75+
sampleFloor: number = RELIABILITY_BUCKET_SAMPLE_FLOOR,
76+
): ReliabilityCurve {
77+
if (bucketEdges.length < 2) {
78+
throw new Error(`invalid_bucket_edges: need at least 2 edges, got ${bucketEdges.length}`);
79+
}
80+
for (let i = 0; i < bucketEdges.length; i++) {
81+
if (!(bucketEdges[i]! >= 0 && bucketEdges[i]! <= 1)) {
82+
throw new Error(`invalid_bucket_edges: edge outside [0, 1]: ${bucketEdges[i]}`);
83+
}
84+
if (i > 0 && !(bucketEdges[i]! > bucketEdges[i - 1]!)) {
85+
throw new Error(`invalid_bucket_edges: edges must be strictly ascending at index ${i}`);
86+
}
87+
}
88+
if (!(sampleFloor >= 1)) {
89+
throw new Error(`invalid_sample_floor: ${sampleFloor}`);
90+
}
91+
const counts = bucketEdges.slice(0, -1).map(() => ({ cases: 0, confirmed: 0, reversed: 0 }));
92+
for (const backtestCase of cases) {
93+
const claimed = backtestCase.metadata?.confidence;
94+
if (typeof claimed !== "number") continue;
95+
const index = bucketIndexFor(claimed, bucketEdges);
96+
if (index === -1) continue;
97+
const bucket = counts[index]!;
98+
bucket.cases += 1;
99+
if (backtestCase.label === "confirmed") bucket.confirmed += 1;
100+
else bucket.reversed += 1;
101+
}
102+
return {
103+
sampleFloor,
104+
buckets: counts.map((count, i) => ({
105+
floor: bucketEdges[i]!,
106+
ceiling: bucketEdges[i + 1]!,
107+
cases: count.cases,
108+
confirmed: count.confirmed,
109+
reversed: count.reversed,
110+
// sampleFloor >= 1 (validated above), so a passing count.cases is never 0 -- no divide-by-zero arm.
111+
precision: count.cases >= sampleFloor ? count.confirmed / count.cases : null,
112+
})),
113+
};
114+
}
115+
116+
/**
117+
* Derive the LOOSEST confidence floor the curve's evidence supports: the lowest bucket floor at or above
118+
* `hardMinimum` whose at-or-above buckets' POOLED precision (pooled confirmed / pooled cases, raw counts --
119+
* a bucket individually below the sample floor still contributes its cases to the pool) meets
120+
* `targetPrecision`, with the pool itself subject to the curve's own `sampleFloor` (a pooled window below
121+
* it is unknown, not 0, so it can never qualify). Null when no candidate floor qualifies -- including when
122+
* the only precision-meeting floors sit below `hardMinimum` (a suggestion is never clamped UP to a floor
123+
* whose own pooled evidence was not checked) or when pooled density is insufficient everywhere.
124+
* Conservative by construction and deterministic: same curve + parameters, same suggestion. Throws when
125+
* `targetPrecision` or `hardMinimum` is outside [0, 1] (negated compound guards, so NaN fails closed) --
126+
* caller bugs, mirroring splitBacktestCorpus.
127+
*/
128+
export function deriveThresholdSuggestion(
129+
curve: ReliabilityCurve,
130+
targetPrecision: number,
131+
hardMinimum: number,
132+
): number | null {
133+
if (!(targetPrecision >= 0 && targetPrecision <= 1)) {
134+
throw new Error(`invalid_target_precision: ${targetPrecision}`);
135+
}
136+
if (!(hardMinimum >= 0 && hardMinimum <= 1)) {
137+
throw new Error(`invalid_hard_minimum: ${hardMinimum}`);
138+
}
139+
const { buckets, sampleFloor } = curve;
140+
// Suffix-pooled raw counts: pooledCases[i]/pooledConfirmed[i] cover every bucket whose floor is at or
141+
// above buckets[i].floor (buckets ascend by floor, so the pool for candidate i is the suffix from i).
142+
const pooledCases: number[] = new Array<number>(buckets.length).fill(0);
143+
const pooledConfirmed: number[] = new Array<number>(buckets.length).fill(0);
144+
let cases = 0;
145+
let confirmed = 0;
146+
for (let i = buckets.length - 1; i >= 0; i--) {
147+
cases += buckets[i]!.cases;
148+
confirmed += buckets[i]!.confirmed;
149+
pooledCases[i] = cases;
150+
pooledConfirmed[i] = confirmed;
151+
}
152+
for (let i = 0; i < buckets.length; i++) {
153+
if (buckets[i]!.floor < hardMinimum) continue;
154+
// Suffix pools only shrink as the floor tightens, so once density fails here it fails for every later
155+
// candidate too -- the uniform guard just lets the loop run out to the null below.
156+
if (pooledCases[i]! < sampleFloor) continue;
157+
if (pooledConfirmed[i]! / pooledCases[i]! >= targetPrecision) return buckets[i]!.floor;
158+
}
159+
return null;
160+
}

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ export * from "./calibration/backtest-track-record.js";
179179
export * from "./calibration/backtest-split.js";
180180
export * from "./calibration/backtest-threshold.js";
181181
export * from "./calibration/provider-track-record.js";
182+
export * from "./calibration/reliability-curve.js";
182183
export {
183184
GOVERNOR_LEDGER_EVENT_TYPES,
184185
normalizeGovernorLedgerEvent,

0 commit comments

Comments
 (0)