1use 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#[derive(Debug, Clone)]
19pub struct CalibrationReport {
20 pub n_pairs: usize,
23 pub conformal: ConformalResult,
25 pub empirical_served_failure: f64,
29 pub n_served: usize,
31}
32
33impl CalibrationReport {
34 #[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#[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
77pub(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
97fn attempt_score(attempt: &Attempt) -> f64 {
99 gate_score(&attempt.gates, attempt.verdict)
100}
101
102fn 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
112pub 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 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 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 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 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 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 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}