Skip to main content

firstpass_proxy/
calibrate.rs

1//! Recalibrate the serving threshold from real deferred feedback (SPEC §10.1, run against live
2//! traffic instead of a static benchmark suite) — the "learns your quality bar" loop.
3//!
4//! Two calibration methods are available:
5//! - **conformal** (default): split-conformal with Hoeffding bound — [`calibrate_from_store`].
6//! - **ltt**: Learn-then-Test / RCPS with exact-binomial fixed-sequence testing —
7//!   [`calibrate_from_store_ltt`].
8//!
9//! Both enumerate stored traces, pair each trace that has a deferred outcome with the score of
10//! the attempt actually served, and hand the pairs to the respective core module. Neither feeds
11//! back into the request hot path — that wiring is a deliberate follow-on once an operator has
12//! reviewed a report.
13
14use std::path::Path;
15
16use firstpass_core::conformal::{self, ConformalResult};
17use firstpass_core::ltt::{self, LttResult};
18use firstpass_core::{Attempt, DeferredVerdict, GateResult, Score, Trace, Verdict};
19
20use crate::store::{self, StoreError};
21
22/// The result of calibrating a conformal threshold against real deferred feedback.
23#[derive(Debug, Clone)]
24pub struct CalibrationReport {
25    /// Number of `(score, correct)` pairs calibration ran on — one per trace with at least one
26    /// deferred verdict recorded.
27    pub n_pairs: usize,
28    /// The conformal calibration result (threshold, feasibility, calibration risk).
29    pub conformal: ConformalResult,
30    /// Empirical served-failure rate at `conformal.threshold`, measured on the same pairs used
31    /// to calibrate (a sanity check, not a held-out estimate — the proxy doesn't yet split
32    /// feedback into separate calibration/test batches).
33    pub empirical_served_failure: f64,
34    /// How many pairs would be served at the calibrated threshold.
35    pub n_served: usize,
36}
37
38impl CalibrationReport {
39    /// Render the report as human-readable lines for `firstpass calibrate`.
40    #[must_use]
41    pub fn render(&self) -> String {
42        format!(
43            "pairs: {n_pairs} ({n_served} served at threshold)\n\
44             threshold: {threshold:.4}\n\
45             feasible: {feasible}\n\
46             target alpha: {alpha:.4} (delta {delta:.4})\n\
47             calibration risk: {calib_risk:.4}\n\
48             empirical served-failure: {empirical:.4}\n",
49            n_pairs = self.n_pairs,
50            n_served = self.n_served,
51            threshold = self.conformal.threshold,
52            feasible = self.conformal.feasible,
53            alpha = self.conformal.alpha,
54            delta = self.conformal.delta,
55            calib_risk = self.conformal.calib_risk,
56            empirical = self.empirical_served_failure,
57        )
58    }
59}
60
61/// Calibrate a conformal threshold from `(score, correct)` pairs — a thin wrapper over
62/// [`firstpass_core::conformal`] that also reports the empirical served-failure at the chosen
63/// threshold.
64#[must_use]
65pub fn calibrate_pairs(
66    pairs: &[(f64, bool)],
67    alpha: f64,
68    delta: f64,
69    min_n: usize,
70) -> CalibrationReport {
71    let result = conformal::calibrate(pairs, alpha, delta, min_n);
72    let (empirical_served_failure, n_served) =
73        conformal::served_failure_rate(pairs, result.threshold);
74    CalibrationReport {
75        n_pairs: pairs.len(),
76        conformal: result,
77        empirical_served_failure,
78        n_served,
79    }
80}
81
82/// The aggregate score for a set of gate results at a given verdict: the mean of the numeric gate
83/// scores, or — when no gate reported a numeric score at all — `1.0` if it passed and `0.0` if it
84/// didn't. A bare pass/fail with no score still needs to sit somewhere on the `[0, 1]` axis
85/// conformal thresholds against; treating a scoreless pass as maximally confident and a scoreless
86/// fail as minimally confident keeps "higher score = more servable" true either way.
87///
88/// Shared by [`attempt_score`] (calibration, offline) and the router's `serve_threshold` decision
89/// (serving, online) so the two agree on what "the score" means.
90pub(crate) fn gate_score(gates: &[GateResult], verdict: Verdict) -> f64 {
91    let numeric: Vec<f64> = gates
92        .iter()
93        .filter_map(|g| g.score.map(Score::value))
94        .collect();
95    if numeric.is_empty() {
96        f64::from(verdict == Verdict::Pass)
97    } else {
98        numeric.iter().sum::<f64>() / numeric.len() as f64
99    }
100}
101
102/// The aggregate score for a served attempt (see [`gate_score`]).
103fn attempt_score(attempt: &Attempt) -> f64 {
104    gate_score(&attempt.gates, attempt.verdict)
105}
106
107/// Build a `(score, correct)` pair for one trace, if it has deferred feedback and a served
108/// attempt. `correct` is whether the MOST RECENT deferred verdict for the trace is `Pass` (later
109/// feedback supersedes earlier — e.g. a flaky CI run retried).
110fn trace_pair(trace: &Trace, deferred: &[DeferredVerdict]) -> Option<(f64, bool)> {
111    let last = deferred.last()?;
112    let served_rung = trace.final_.served_rung?;
113    let attempt = trace.attempts.iter().find(|a| a.rung == served_rung)?;
114    Some((attempt_score(attempt), last.verdict == Verdict::Pass))
115}
116
117/// The result of LTT calibration against real deferred feedback.
118#[derive(Debug, Clone)]
119pub struct LttReport {
120    /// Number of `(score, correct)` pairs — one per trace with a deferred verdict.
121    pub n_pairs: usize,
122    /// The LTT calibration result (threshold, feasibility, empirical risk, diagnostics).
123    pub ltt: LttResult,
124}
125
126impl LttReport {
127    /// Render the report as human-readable lines for `firstpass calibrate --method ltt`.
128    /// Format mirrors [`CalibrationReport::render`] with an added verifier ROC note.
129    #[must_use]
130    pub fn render(&self) -> String {
131        let far = match self.ltt.false_accept_rate {
132            Some(r) => format!("{r:.4}"),
133            None => "N/A (no incorrect items in calibration set)".to_owned(),
134        };
135        format!(
136            "method: ltt\n\
137             pairs: {n_pairs} ({n_served} served at threshold)\n\
138             threshold: {threshold:.4}\n\
139             feasible: {feasible}\n\
140             target alpha: {alpha:.4} (delta {delta:.4})\n\
141             empirical risk: {risk:.4}\n\
142             false-accept rate: {far}  (P(score >= lambda | incorrect); verifier ROC point)\n",
143            n_pairs = self.n_pairs,
144            n_served = self.ltt.n_served,
145            threshold = self.ltt.threshold,
146            feasible = self.ltt.feasible,
147            alpha = self.ltt.alpha,
148            delta = self.ltt.delta,
149            risk = self.ltt.empirical_risk,
150        )
151    }
152}
153
154/// Calibrate an LTT threshold from `(score, correct)` pairs — thin wrapper over
155/// [`firstpass_core::ltt`].
156#[must_use]
157pub fn calibrate_pairs_ltt(
158    pairs: &[(f64, bool)],
159    alpha: f64,
160    delta: f64,
161    min_n: usize,
162) -> LttReport {
163    LttReport {
164        n_pairs: pairs.len(),
165        ltt: ltt::calibrate(pairs, alpha, delta, min_n),
166    }
167}
168
169/// Calibrate an LTT threshold from every trace in the store that has a deferred outcome.
170///
171/// Error handling and tenant scoping match [`calibrate_from_store`] exactly.
172///
173/// # Errors
174/// Returns [`StoreError`] if a stored trace's deferred verdicts cannot be read.
175pub fn calibrate_from_store_ltt(
176    db_path: impl AsRef<Path>,
177    tenant: &str,
178    alpha: f64,
179    delta: f64,
180    min_n: usize,
181) -> Result<LttReport, StoreError> {
182    let traces = store::load_tenant_traces(&db_path, tenant).unwrap_or_default();
183    let mut pairs = Vec::with_capacity(traces.len());
184    for trace in &traces {
185        let deferred = store::load_deferred(&db_path, &trace.trace_id.to_string())?;
186        if let Some(pair) = trace_pair(trace, &deferred) {
187            pairs.push(pair);
188        }
189    }
190    Ok(calibrate_pairs_ltt(&pairs, alpha, delta, min_n))
191}
192
193/// Calibrate a conformal threshold from every trace in the store that has a deferred outcome
194/// recorded.
195///
196/// # Errors
197/// Returns [`StoreError`] if a stored trace's deferred verdicts cannot be read. An unreadable or
198/// not-yet-initialized store is treated as zero traces (a 0-pair, infeasible report), matching the
199/// forgiving behaviour of `firstpass trace` — calibrating before any traffic is a valid state, not
200/// an error.
201pub fn calibrate_from_store(
202    db_path: impl AsRef<Path>,
203    tenant: &str,
204    alpha: f64,
205    delta: f64,
206    min_n: usize,
207) -> Result<CalibrationReport, StoreError> {
208    // Tenant-scoped (ADR 0004 §D3): a tenant only ever calibrates against its own feedback. The
209    // per-trace `load_deferred` below is safe unscoped because every `trace` here already belongs
210    // to `tenant`.
211    let traces = store::load_tenant_traces(&db_path, tenant).unwrap_or_default();
212    let mut pairs = Vec::with_capacity(traces.len());
213    for trace in &traces {
214        let deferred = store::load_deferred(&db_path, &trace.trace_id.to_string())?;
215        if let Some(pair) = trace_pair(trace, &deferred) {
216            pairs.push(pair);
217        }
218    }
219    Ok(calibrate_pairs(&pairs, alpha, delta, min_n))
220}
221
222#[cfg(test)]
223mod tests {
224    use firstpass_core::{
225        Features, FinalOutcome, GENESIS_HASH, GateResult, Mode, PolicyRef, RequestInfo, ServedFrom,
226        TaskKind,
227    };
228
229    use super::*;
230    use crate::store;
231
232    /// A minimal trace serving rung 0 with a single deterministic gate score, mirroring
233    /// `store::sample_trace` but with a caller-chosen score.
234    fn trace_with_score(score: f64) -> Trace {
235        let verdict = if score >= 0.5 {
236            Verdict::Pass
237        } else {
238            Verdict::Fail
239        };
240        let attempt = Attempt {
241            rung: 0,
242            model: "claude-haiku-4-5".to_owned(),
243            provider: "anthropic".to_owned(),
244            in_tokens: 10,
245            out_tokens: 5,
246            cost_usd: 0.001,
247            latency_ms: 12,
248            gates: vec![GateResult {
249                gate_id: "gate@v1".to_owned(),
250                verdict,
251                score: Some(Score::clamped(score)),
252                cost_usd: 0.0,
253                ms: 10,
254                reason: None,
255                evidence_ref: None,
256            }],
257            verdict,
258        };
259        let mut trace = Trace {
260            trace_id: uuid::Uuid::now_v7(),
261            prev_hash: GENESIS_HASH.to_owned(),
262            tenant_id: "tenant-a".to_owned(),
263            session_id: "session-1".to_owned(),
264            ts: jiff::Timestamp::now(),
265            mode: Mode::Enforce,
266            policy: PolicyRef {
267                id: "test@v0".to_owned(),
268                explore: false,
269                propensity: None,
270                mode_profile: None,
271            },
272            request: RequestInfo {
273                api: "anthropic.messages".to_owned(),
274                prompt_hash: "deadbeef".to_owned(),
275                features: Features::new(TaskKind::Other),
276            },
277            attempts: vec![attempt],
278            deferred: Vec::new(),
279            final_: FinalOutcome {
280                served_rung: Some(0),
281                served_from: ServedFrom::Attempt,
282                total_cost_usd: 0.001,
283                gate_cost_usd: 0.0,
284                total_latency_ms: 12,
285                escalations: 0,
286                counterfactual_baseline_usd: 0.001,
287                savings_usd: 0.0,
288            },
289            probe: None,
290            rollout: None,
291            shadow: None,
292            route_ix: None,
293            predicted_pass: None,
294            elastic: None,
295        };
296        trace.recompute_savings();
297        trace
298    }
299
300    #[test]
301    fn calibrate_pairs_finds_a_feasible_threshold_on_clean_pairs() {
302        // Scores cleanly separate correct (>=0.7) from incorrect (<0.3). alpha=0.2 tolerates
303        // some incorrect items being served, so conformal maximizes coverage — not just
304        // separation — up to that budget; alpha=0.2 also keeps the Hoeffding slack satisfiable
305        // at this sample size (min_n=30 wants a workable n, not the hundreds needed to certify
306        // alpha=0.1 at zero observed failures).
307        let mut pairs = Vec::new();
308        for i in 0..60u32 {
309            pairs.push((0.7 + f64::from(i % 10) * 0.01, true));
310        }
311        for i in 0..60u32 {
312            pairs.push((0.2 + f64::from(i % 10) * 0.01, false));
313        }
314        let report = calibrate_pairs(&pairs, 0.2, 0.1, 30);
315        assert!(
316            report.conformal.feasible,
317            "clean separation must be feasible"
318        );
319        assert!(
320            report.conformal.threshold >= 0.2 && report.conformal.threshold <= 0.79,
321            "threshold {} must land inside the observed score range",
322            report.conformal.threshold
323        );
324        assert_eq!(report.n_pairs, 120);
325        assert!(
326            report.empirical_served_failure <= 0.2 + 1e-9,
327            "empirical served-failure {} must respect alpha — the conformal guarantee",
328            report.empirical_served_failure
329        );
330    }
331
332    #[test]
333    fn calibrate_pairs_infeasible_below_min_n() {
334        let pairs = vec![(0.9, true), (0.9, true), (0.1, false)];
335        let report = calibrate_pairs(&pairs, 0.1, 0.1, 30);
336        assert!(
337            !report.conformal.feasible,
338            "too few pairs must be infeasible"
339        );
340    }
341
342    #[tokio::test]
343    async fn calibrate_from_store_pairs_only_traces_with_deferred_feedback() {
344        let db_path = std::env::temp_dir().join(format!(
345            "firstpass-calibrate-test-{}.db",
346            uuid::Uuid::now_v7()
347        ));
348        let (tx, handle) = store::open(&db_path).unwrap();
349
350        // 40 high-score traces confirmed correct, 40 low-score traces confirmed incorrect, and
351        // 5 traces with no deferred verdict at all (must be excluded from calibration).
352        let mut correct_ids = Vec::new();
353        let mut incorrect_ids = Vec::new();
354        for i in 0..40u32 {
355            let t = trace_with_score(0.7 + f64::from(i % 10) * 0.01);
356            correct_ids.push(t.trace_id.to_string());
357            tx.try_send(t).unwrap();
358        }
359        for i in 0..40u32 {
360            let t = trace_with_score(0.2 + f64::from(i % 10) * 0.01);
361            incorrect_ids.push(t.trace_id.to_string());
362            tx.try_send(t).unwrap();
363        }
364        for i in 0..5u32 {
365            tx.try_send(trace_with_score(0.5 + f64::from(i) * 0.01))
366                .unwrap();
367        }
368        drop(tx);
369        handle.await.unwrap();
370
371        for trace_id in &correct_ids {
372            let dv = DeferredVerdict {
373                gate_id: "outcome".to_owned(),
374                verdict: Verdict::Pass,
375                score: None,
376                reported_at: jiff::Timestamp::now(),
377                reporter: "unit-test".to_owned(),
378            };
379            store::append_deferred(&db_path, trace_id, &dv).unwrap();
380        }
381        for trace_id in &incorrect_ids {
382            let dv = DeferredVerdict {
383                gate_id: "outcome".to_owned(),
384                verdict: Verdict::Fail,
385                score: None,
386                reported_at: jiff::Timestamp::now(),
387                reporter: "unit-test".to_owned(),
388            };
389            store::append_deferred(&db_path, trace_id, &dv).unwrap();
390        }
391
392        // alpha=0.2 for the same Hoeffding-slack reason as the calibrate_pairs test above.
393        let report = calibrate_from_store(&db_path, "tenant-a", 0.2, 0.1, 30).unwrap();
394        assert_eq!(
395            report.n_pairs, 80,
396            "only the 80 traces with deferred feedback pair up"
397        );
398        assert!(report.conformal.feasible);
399        assert!(
400            report.empirical_served_failure <= 0.2 + 1e-9,
401            "empirical served-failure {} must respect alpha on clean synthetic data",
402            report.empirical_served_failure
403        );
404
405        // D7 isolation: a different tenant sees none of tenant-a's pairs — calibration is empty.
406        let other = calibrate_from_store(&db_path, "tenant-b", 0.2, 0.1, 30).unwrap();
407        assert_eq!(
408            other.n_pairs, 0,
409            "tenant-b must not see tenant-a's feedback"
410        );
411
412        let _ = std::fs::remove_file(&db_path);
413    }
414
415    // ── LTT wiring tests ─────────────────────────────────────────────────────────────────────
416
417    #[test]
418    fn calibrate_pairs_ltt_feasible_on_clean_pairs() {
419        // Same synthetic data as the conformal test — clean score separation, alpha=0.2.
420        let mut pairs = Vec::new();
421        for i in 0..60u32 {
422            pairs.push((0.7 + f64::from(i % 10) * 0.01, true));
423        }
424        for i in 0..60u32 {
425            pairs.push((0.2 + f64::from(i % 10) * 0.01, false));
426        }
427        let report = calibrate_pairs_ltt(&pairs, 0.2, 0.1, 30);
428        assert!(
429            report.ltt.feasible,
430            "clean separation must be feasible with LTT"
431        );
432        assert!(
433            report.ltt.threshold >= 0.2 && report.ltt.threshold <= 0.79,
434            "threshold {} must land inside the observed score range",
435            report.ltt.threshold
436        );
437        assert_eq!(report.n_pairs, 120);
438        assert!(
439            report.ltt.empirical_risk <= 0.2 + 1e-9,
440            "empirical risk {} must respect alpha",
441            report.ltt.empirical_risk
442        );
443    }
444
445    #[test]
446    fn calibrate_pairs_ltt_infeasible_below_min_n() {
447        let pairs = vec![(0.9, true), (0.9, true), (0.1, false)];
448        let report = calibrate_pairs_ltt(&pairs, 0.1, 0.05, 30);
449        assert!(
450            !report.ltt.feasible,
451            "too few pairs must be infeasible with LTT"
452        );
453    }
454
455    #[test]
456    fn ltt_report_render_includes_method_and_far() {
457        // Smoke-test that render() produces the expected key fields without panicking.
458        let mut pairs: Vec<(f64, bool)> = Vec::new();
459        for _ in 0..200 {
460            pairs.push((0.9, true));
461        }
462        for _ in 0..5 {
463            pairs.push((0.9, false));
464        }
465        for _ in 0..15 {
466            pairs.push((0.2, false));
467        }
468        let report = calibrate_pairs_ltt(&pairs, 0.10, 0.05, 30);
469        let rendered = report.render();
470        assert!(
471            rendered.contains("method: ltt"),
472            "render must tag the method"
473        );
474        assert!(
475            rendered.contains("false-accept rate:"),
476            "render must include verifier ROC note"
477        );
478        assert!(
479            rendered.contains("feasible:"),
480            "render must include feasibility"
481        );
482    }
483}