1use 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#[derive(Debug, Clone)]
24pub struct CalibrationReport {
25 pub n_pairs: usize,
28 pub conformal: ConformalResult,
30 pub empirical_served_failure: f64,
34 pub n_served: usize,
36}
37
38impl CalibrationReport {
39 #[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#[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
82pub(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
102fn attempt_score(attempt: &Attempt) -> f64 {
104 gate_score(&attempt.gates, attempt.verdict)
105}
106
107fn 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#[derive(Debug, Clone)]
119pub struct LttReport {
120 pub n_pairs: usize,
122 pub ltt: LttResult,
124}
125
126impl LttReport {
127 #[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#[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
169pub 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
193pub 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 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 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 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 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 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 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 #[test]
418 fn calibrate_pairs_ltt_feasible_on_clean_pairs() {
419 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 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}