Skip to main content

firstpass_proxy/
calibrate.rs

1//! Recalibrate the conformal serving threshold from real deferred feedback (SPEC §10.1, run
2//! against live traffic instead of a static benchmark suite) — the "learns your quality bar"
3//! loop.
4//!
5//! Enumerates stored traces, pairs each trace that has a deferred outcome with the score of the
6//! attempt actually served, and hands the pairs to [`firstpass_core::conformal`]. This produces
7//! a recommended threshold and its feasibility; it does not (yet) feed back into the request hot
8//! path — that wiring is a deliberate follow-on once an operator has reviewed a report.
9
10use std::path::Path;
11
12use firstpass_core::conformal::{self, ConformalResult};
13use firstpass_core::{Attempt, DeferredVerdict, GateResult, Score, Trace, Verdict};
14
15use crate::store::{self, StoreError};
16
17/// The result of calibrating a conformal threshold against real deferred feedback.
18#[derive(Debug, Clone)]
19pub struct CalibrationReport {
20    /// Number of `(score, correct)` pairs calibration ran on — one per trace with at least one
21    /// deferred verdict recorded.
22    pub n_pairs: usize,
23    /// The conformal calibration result (threshold, feasibility, calibration risk).
24    pub conformal: ConformalResult,
25    /// Empirical served-failure rate at `conformal.threshold`, measured on the same pairs used
26    /// to calibrate (a sanity check, not a held-out estimate — the proxy doesn't yet split
27    /// feedback into separate calibration/test batches).
28    pub empirical_served_failure: f64,
29    /// How many pairs would be served at the calibrated threshold.
30    pub n_served: usize,
31}
32
33impl CalibrationReport {
34    /// Render the report as human-readable lines for `firstpass calibrate`.
35    #[must_use]
36    pub fn render(&self) -> String {
37        format!(
38            "pairs: {n_pairs} ({n_served} served at threshold)\n\
39             threshold: {threshold:.4}\n\
40             feasible: {feasible}\n\
41             target alpha: {alpha:.4} (delta {delta:.4})\n\
42             calibration risk: {calib_risk:.4}\n\
43             empirical served-failure: {empirical:.4}\n",
44            n_pairs = self.n_pairs,
45            n_served = self.n_served,
46            threshold = self.conformal.threshold,
47            feasible = self.conformal.feasible,
48            alpha = self.conformal.alpha,
49            delta = self.conformal.delta,
50            calib_risk = self.conformal.calib_risk,
51            empirical = self.empirical_served_failure,
52        )
53    }
54}
55
56/// Calibrate a conformal threshold from `(score, correct)` pairs — a thin wrapper over
57/// [`firstpass_core::conformal`] that also reports the empirical served-failure at the chosen
58/// threshold.
59#[must_use]
60pub fn calibrate_pairs(
61    pairs: &[(f64, bool)],
62    alpha: f64,
63    delta: f64,
64    min_n: usize,
65) -> CalibrationReport {
66    let result = conformal::calibrate(pairs, alpha, delta, min_n);
67    let (empirical_served_failure, n_served) =
68        conformal::served_failure_rate(pairs, result.threshold);
69    CalibrationReport {
70        n_pairs: pairs.len(),
71        conformal: result,
72        empirical_served_failure,
73        n_served,
74    }
75}
76
77/// The aggregate score for a set of gate results at a given verdict: the mean of the numeric gate
78/// scores, or — when no gate reported a numeric score at all — `1.0` if it passed and `0.0` if it
79/// didn't. A bare pass/fail with no score still needs to sit somewhere on the `[0, 1]` axis
80/// conformal thresholds against; treating a scoreless pass as maximally confident and a scoreless
81/// fail as minimally confident keeps "higher score = more servable" true either way.
82///
83/// Shared by [`attempt_score`] (calibration, offline) and the router's `serve_threshold` decision
84/// (serving, online) so the two agree on what "the score" means.
85pub(crate) fn gate_score(gates: &[GateResult], verdict: Verdict) -> f64 {
86    let numeric: Vec<f64> = gates
87        .iter()
88        .filter_map(|g| g.score.map(Score::value))
89        .collect();
90    if numeric.is_empty() {
91        f64::from(verdict == Verdict::Pass)
92    } else {
93        numeric.iter().sum::<f64>() / numeric.len() as f64
94    }
95}
96
97/// The aggregate score for a served attempt (see [`gate_score`]).
98fn attempt_score(attempt: &Attempt) -> f64 {
99    gate_score(&attempt.gates, attempt.verdict)
100}
101
102/// Build a `(score, correct)` pair for one trace, if it has deferred feedback and a served
103/// attempt. `correct` is whether the MOST RECENT deferred verdict for the trace is `Pass` (later
104/// feedback supersedes earlier — e.g. a flaky CI run retried).
105fn trace_pair(trace: &Trace, deferred: &[DeferredVerdict]) -> Option<(f64, bool)> {
106    let last = deferred.last()?;
107    let served_rung = trace.final_.served_rung?;
108    let attempt = trace.attempts.iter().find(|a| a.rung == served_rung)?;
109    Some((attempt_score(attempt), last.verdict == Verdict::Pass))
110}
111
112/// Calibrate a conformal threshold from every trace in the store that has a deferred outcome
113/// recorded.
114///
115/// # Errors
116/// Returns [`StoreError`] if a stored trace's deferred verdicts cannot be read. An unreadable or
117/// not-yet-initialized store is treated as zero traces (a 0-pair, infeasible report), matching the
118/// forgiving behaviour of `firstpass trace` — calibrating before any traffic is a valid state, not
119/// an error.
120pub fn calibrate_from_store(
121    db_path: impl AsRef<Path>,
122    tenant: &str,
123    alpha: f64,
124    delta: f64,
125    min_n: usize,
126) -> Result<CalibrationReport, StoreError> {
127    // Tenant-scoped (ADR 0004 §D3): a tenant only ever calibrates against its own feedback. The
128    // per-trace `load_deferred` below is safe unscoped because every `trace` here already belongs
129    // to `tenant`.
130    let traces = store::load_tenant_traces(&db_path, tenant).unwrap_or_default();
131    let mut pairs = Vec::with_capacity(traces.len());
132    for trace in &traces {
133        let deferred = store::load_deferred(&db_path, &trace.trace_id.to_string())?;
134        if let Some(pair) = trace_pair(trace, &deferred) {
135            pairs.push(pair);
136        }
137    }
138    Ok(calibrate_pairs(&pairs, alpha, delta, min_n))
139}
140
141#[cfg(test)]
142mod tests {
143    use firstpass_core::{
144        Features, FinalOutcome, GENESIS_HASH, GateResult, Mode, PolicyRef, RequestInfo, ServedFrom,
145        TaskKind,
146    };
147
148    use super::*;
149    use crate::store;
150
151    /// A minimal trace serving rung 0 with a single deterministic gate score, mirroring
152    /// `store::sample_trace` but with a caller-chosen score.
153    fn trace_with_score(score: f64) -> Trace {
154        let verdict = if score >= 0.5 {
155            Verdict::Pass
156        } else {
157            Verdict::Fail
158        };
159        let attempt = Attempt {
160            rung: 0,
161            model: "claude-haiku-4-5".to_owned(),
162            provider: "anthropic".to_owned(),
163            in_tokens: 10,
164            out_tokens: 5,
165            cost_usd: 0.001,
166            latency_ms: 12,
167            gates: vec![GateResult {
168                gate_id: "gate@v1".to_owned(),
169                verdict,
170                score: Some(Score::clamped(score)),
171                cost_usd: 0.0,
172                ms: 10,
173                reason: None,
174                evidence_ref: None,
175            }],
176            verdict,
177        };
178        let mut trace = Trace {
179            trace_id: uuid::Uuid::now_v7(),
180            prev_hash: GENESIS_HASH.to_owned(),
181            tenant_id: "tenant-a".to_owned(),
182            session_id: "session-1".to_owned(),
183            ts: jiff::Timestamp::now(),
184            mode: Mode::Enforce,
185            policy: PolicyRef {
186                id: "test@v0".to_owned(),
187                explore: false,
188            },
189            request: RequestInfo {
190                api: "anthropic.messages".to_owned(),
191                prompt_hash: "deadbeef".to_owned(),
192                features: Features::new(TaskKind::Other),
193            },
194            attempts: vec![attempt],
195            deferred: Vec::new(),
196            final_: FinalOutcome {
197                served_rung: Some(0),
198                served_from: ServedFrom::Attempt,
199                total_cost_usd: 0.001,
200                gate_cost_usd: 0.0,
201                total_latency_ms: 12,
202                escalations: 0,
203                counterfactual_baseline_usd: 0.001,
204                savings_usd: 0.0,
205            },
206        };
207        trace.recompute_savings();
208        trace
209    }
210
211    #[test]
212    fn calibrate_pairs_finds_a_feasible_threshold_on_clean_pairs() {
213        // Scores cleanly separate correct (>=0.7) from incorrect (<0.3). alpha=0.2 tolerates
214        // some incorrect items being served, so conformal maximizes coverage — not just
215        // separation — up to that budget; alpha=0.2 also keeps the Hoeffding slack satisfiable
216        // at this sample size (min_n=30 wants a workable n, not the hundreds needed to certify
217        // alpha=0.1 at zero observed failures).
218        let mut pairs = Vec::new();
219        for i in 0..60u32 {
220            pairs.push((0.7 + f64::from(i % 10) * 0.01, true));
221        }
222        for i in 0..60u32 {
223            pairs.push((0.2 + f64::from(i % 10) * 0.01, false));
224        }
225        let report = calibrate_pairs(&pairs, 0.2, 0.1, 30);
226        assert!(
227            report.conformal.feasible,
228            "clean separation must be feasible"
229        );
230        assert!(
231            report.conformal.threshold >= 0.2 && report.conformal.threshold <= 0.79,
232            "threshold {} must land inside the observed score range",
233            report.conformal.threshold
234        );
235        assert_eq!(report.n_pairs, 120);
236        assert!(
237            report.empirical_served_failure <= 0.2 + 1e-9,
238            "empirical served-failure {} must respect alpha — the conformal guarantee",
239            report.empirical_served_failure
240        );
241    }
242
243    #[test]
244    fn calibrate_pairs_infeasible_below_min_n() {
245        let pairs = vec![(0.9, true), (0.9, true), (0.1, false)];
246        let report = calibrate_pairs(&pairs, 0.1, 0.1, 30);
247        assert!(
248            !report.conformal.feasible,
249            "too few pairs must be infeasible"
250        );
251    }
252
253    #[tokio::test]
254    async fn calibrate_from_store_pairs_only_traces_with_deferred_feedback() {
255        let db_path = std::env::temp_dir().join(format!(
256            "firstpass-calibrate-test-{}.db",
257            uuid::Uuid::now_v7()
258        ));
259        let (tx, handle) = store::open(&db_path).unwrap();
260
261        // 40 high-score traces confirmed correct, 40 low-score traces confirmed incorrect, and
262        // 5 traces with no deferred verdict at all (must be excluded from calibration).
263        let mut correct_ids = Vec::new();
264        let mut incorrect_ids = Vec::new();
265        for i in 0..40u32 {
266            let t = trace_with_score(0.7 + f64::from(i % 10) * 0.01);
267            correct_ids.push(t.trace_id.to_string());
268            tx.try_send(t).unwrap();
269        }
270        for i in 0..40u32 {
271            let t = trace_with_score(0.2 + f64::from(i % 10) * 0.01);
272            incorrect_ids.push(t.trace_id.to_string());
273            tx.try_send(t).unwrap();
274        }
275        for i in 0..5u32 {
276            tx.try_send(trace_with_score(0.5 + f64::from(i) * 0.01))
277                .unwrap();
278        }
279        drop(tx);
280        handle.await.unwrap();
281
282        for trace_id in &correct_ids {
283            let dv = DeferredVerdict {
284                gate_id: "outcome".to_owned(),
285                verdict: Verdict::Pass,
286                score: None,
287                reported_at: jiff::Timestamp::now(),
288                reporter: "unit-test".to_owned(),
289            };
290            store::append_deferred(&db_path, trace_id, &dv).unwrap();
291        }
292        for trace_id in &incorrect_ids {
293            let dv = DeferredVerdict {
294                gate_id: "outcome".to_owned(),
295                verdict: Verdict::Fail,
296                score: None,
297                reported_at: jiff::Timestamp::now(),
298                reporter: "unit-test".to_owned(),
299            };
300            store::append_deferred(&db_path, trace_id, &dv).unwrap();
301        }
302
303        // alpha=0.2 for the same Hoeffding-slack reason as the calibrate_pairs test above.
304        let report = calibrate_from_store(&db_path, "tenant-a", 0.2, 0.1, 30).unwrap();
305        assert_eq!(
306            report.n_pairs, 80,
307            "only the 80 traces with deferred feedback pair up"
308        );
309        assert!(report.conformal.feasible);
310        assert!(
311            report.empirical_served_failure <= 0.2 + 1e-9,
312            "empirical served-failure {} must respect alpha on clean synthetic data",
313            report.empirical_served_failure
314        );
315
316        // D7 isolation: a different tenant sees none of tenant-a's pairs — calibration is empty.
317        let other = calibrate_from_store(&db_path, "tenant-b", 0.2, 0.1, 30).unwrap();
318        assert_eq!(
319            other.n_pairs, 0,
320            "tenant-b must not see tenant-a's feedback"
321        );
322
323        let _ = std::fs::remove_file(&db_path);
324    }
325}