1use serde::{Deserialize, Serialize};
22
23use crate::error::{Result, SdkError};
24
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
28pub struct CalibrationSample {
29 pub score: f64,
30 pub is_unsafe: bool,
31}
32
33impl CalibrationSample {
34 pub fn new(score: f64, is_unsafe: bool) -> Self {
35 Self { score, is_unsafe }
36 }
37}
38
39pub fn accepted_unsafe_rate(samples: &[CalibrationSample], theta: f64) -> f64 {
42 if samples.is_empty() {
43 return 0.0;
44 }
45 let unsafe_accepted = samples
46 .iter()
47 .filter(|s| s.score > theta && s.is_unsafe)
48 .count();
49 unsafe_accepted as f64 / samples.len() as f64
50}
51
52pub(crate) fn conformal_threshold(samples: &[CalibrationSample], rho: f64) -> Result<f64> {
62 if !(0.0..=1.0).contains(&rho) {
63 return Err(SdkError::InvalidGate(format!(
64 "rho must be in [0,1]: {rho}"
65 )));
66 }
67 if samples.is_empty() {
68 return Err(SdkError::Domain(
69 "conformal calibration needs samples".into(),
70 ));
71 }
72 let n = samples.len() as f64;
73
74 let mut candidates: Vec<f64> = vec![0.0];
77 let mut scores: Vec<f64> = samples.iter().map(|s| s.score).collect();
78 scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
79 candidates.extend(scores);
80
81 for theta in candidates {
82 let r = accepted_unsafe_rate(samples, theta);
83 let adjusted = (n * r + 1.0) / (n + 1.0);
84 if adjusted <= rho {
85 return Ok(theta);
86 }
87 }
88 Ok(1.0)
89}
90
91pub fn ks_statistic(live: &[f64], calib: &[f64]) -> f64 {
93 if live.is_empty() || calib.is_empty() {
94 return 0.0;
95 }
96 let mut all: Vec<f64> = live.iter().chain(calib.iter()).copied().collect();
97 all.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
98 all.dedup();
99 let cdf =
100 |data: &[f64], x: f64| data.iter().filter(|&&v| v <= x).count() as f64 / data.len() as f64;
101 all.iter()
102 .map(|&x| (cdf(live, x) - cdf(calib, x)).abs())
103 .fold(0.0, f64::max)
104}
105
106pub fn is_drifted(live: &[f64], calib: &[f64], alpha_c: f64) -> bool {
110 if live.is_empty() || calib.is_empty() {
111 return false;
112 }
113 let n = live.len() as f64;
114 let m = calib.len() as f64;
115 let critical = alpha_c * ((n + m) / (n * m)).sqrt();
116 ks_statistic(live, calib) > critical
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub enum CalibrationState {
122 Calibrated { theta_hat: f64, rho: f64 },
124 Stale { backoff_theta: f64, last_theta: f64 },
127}
128
129impl CalibrationState {
130 pub fn calibrate(samples: &[CalibrationSample], rho: f64) -> Result<Self> {
132 match conformal_threshold_checked(samples, rho)? {
133 ThresholdOutcome::Feasible { theta_hat } => {
134 Ok(CalibrationState::Calibrated { theta_hat, rho })
135 }
136 ThresholdOutcome::InsufficientCalibration { have, need } => Err(SdkError::Domain(
137 format!("insufficient calibration: have {have} labeled samples, need {need}"),
138 )),
139 }
140 }
141
142 pub fn mark_stale(&self) -> Self {
145 let last = match self {
146 CalibrationState::Calibrated { theta_hat, .. } => *theta_hat,
147 CalibrationState::Stale { last_theta, .. } => *last_theta,
148 };
149 let backoff = (last + 1.0) / 2.0;
151 CalibrationState::Stale {
152 backoff_theta: backoff,
153 last_theta: last,
154 }
155 }
156
157 pub fn bound_is_asserted(&self) -> bool {
160 matches!(self, CalibrationState::Calibrated { .. })
161 }
162
163 fn active_threshold(&self) -> f64 {
164 match self {
165 CalibrationState::Calibrated { theta_hat, .. } => *theta_hat,
166 CalibrationState::Stale { backoff_theta, .. } => *backoff_theta,
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum AcceptOutcome {
175 CertifiedAccept,
177 RouteToApproval,
179 Reject,
181}
182
183pub fn decide(state: &CalibrationState, score: f64) -> AcceptOutcome {
193 let threshold = state.active_threshold();
194 match state {
195 CalibrationState::Calibrated { .. } => {
196 if score > threshold {
197 AcceptOutcome::CertifiedAccept
198 } else {
199 AcceptOutcome::Reject
200 }
201 }
202 CalibrationState::Stale { .. } => {
203 if score > threshold {
204 AcceptOutcome::RouteToApproval
208 } else {
209 AcceptOutcome::Reject
210 }
211 }
212 }
213}
214
215pub fn sample_floor(rho: f64) -> Result<usize> {
224 if !(0.0..=1.0).contains(&rho) || rho == 0.0 {
225 return Err(SdkError::InvalidGate(format!(
226 "rho must be in (0,1]: {rho}"
227 )));
228 }
229 Ok((1.0 / rho - 1.0).ceil().max(0.0) as usize)
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case", tag = "status")]
235pub enum ThresholdOutcome {
236 Feasible { theta_hat: f64 },
239 InsufficientCalibration { have: usize, need: usize },
243}
244
245pub fn conformal_threshold_checked(
247 samples: &[CalibrationSample],
248 rho: f64,
249) -> Result<ThresholdOutcome> {
250 let need = sample_floor(rho)?;
251 if samples.len() < need {
252 return Ok(ThresholdOutcome::InsufficientCalibration {
253 have: samples.len(),
254 need,
255 });
256 }
257 let theta_hat = conformal_threshold(samples, rho)?;
258 Ok(ThresholdOutcome::Feasible { theta_hat })
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
266pub struct CalibrationStratum {
267 pub domain_package: String,
268 pub domain_version: String,
269 pub effect_kind: String,
270 pub risk_class: String,
271 pub model_route: String,
272 pub verifier_suite_fingerprint: String,
273 pub tool_catalog_fingerprint: String,
274 pub policy_version: String,
275 pub score_definition: String,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283pub enum CalibrationReadiness {
284 InsufficientSamples,
285 Shadow,
287 Active,
288 Stale,
289}
290
291pub fn readiness(samples: usize, rho: f64, stratum_changed: bool) -> Result<CalibrationReadiness> {
293 if stratum_changed {
294 return Ok(CalibrationReadiness::Stale);
295 }
296 let need = sample_floor(rho)?;
297 if samples < need {
298 return Ok(if samples == 0 {
299 CalibrationReadiness::InsufficientSamples
300 } else {
301 CalibrationReadiness::Shadow
302 });
303 }
304 Ok(CalibrationReadiness::Active)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn samples() -> Vec<CalibrationSample> {
312 vec![
314 CalibrationSample::new(0.1, true),
315 CalibrationSample::new(0.2, true),
316 CalibrationSample::new(0.3, true),
317 CalibrationSample::new(0.6, false),
318 CalibrationSample::new(0.7, false),
319 CalibrationSample::new(0.8, false),
320 CalibrationSample::new(0.9, false),
321 CalibrationSample::new(0.95, false),
322 ]
323 }
324
325 #[test]
326 fn threshold_excludes_unsafe_low_scores() {
327 let theta = conformal_threshold(&samples(), 0.2).unwrap();
329 assert!(
330 theta >= 0.3,
331 "theta={theta} should exclude unsafe scores <= 0.3"
332 );
333 assert_eq!(accepted_unsafe_rate(&samples(), theta), 0.0);
335 }
336
337 #[test]
338 fn looser_budget_allows_lower_threshold() {
339 let tight = conformal_threshold(&samples(), 0.15).unwrap();
340 let loose = conformal_threshold(&samples(), 0.5).unwrap();
341 assert!(loose <= tight);
342 }
343
344 #[test]
345 fn impossible_budget_rejects_everything() {
346 let theta = conformal_threshold(&samples(), 0.0).unwrap();
348 assert_eq!(theta, 1.0);
349 }
350
351 #[test]
352 fn rho_out_of_range_is_error() {
353 assert!(conformal_threshold(&samples(), 1.5).is_err());
354 }
355
356 #[test]
357 fn ks_detects_distribution_shift() {
358 let calib: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect();
359 let same: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect();
360 let shifted: Vec<f64> = (0..100).map(|i| 0.5 + i as f64 / 200.0).collect();
361 assert!(!is_drifted(&same, &calib, 1.36));
362 assert!(is_drifted(&shifted, &calib, 1.36));
363 }
364
365 #[test]
366 fn calibrated_state_asserts_bound_stale_does_not() {
367 let state = CalibrationState::calibrate(&samples(), 0.2).unwrap();
368 assert!(state.bound_is_asserted());
369 let stale = state.mark_stale();
370 assert!(!stale.bound_is_asserted());
371 }
372
373 #[test]
374 fn stale_window_backs_off_to_approval_instead_of_claiming_a_bound() {
375 let state = CalibrationState::calibrate(&samples(), 0.3)
376 .unwrap()
377 .mark_stale();
378 assert_eq!(decide(&state, 0.99), AcceptOutcome::RouteToApproval);
380 }
381
382 #[test]
383 fn calibrated_state_cannot_bypass_the_finite_sample_floor() {
384 let too_small = &samples()[..2];
385 assert!(CalibrationState::calibrate(too_small, 0.2).is_err());
386 }
387
388 #[test]
389 fn calibrated_window_certifies_accepts() {
390 let state = CalibrationState::calibrate(&samples(), 0.3).unwrap();
391 let threshold = match state {
392 CalibrationState::Calibrated { theta_hat, .. } => theta_hat,
393 _ => unreachable!(),
394 };
395 assert_eq!(
396 decide(&state, threshold + 0.05),
397 AcceptOutcome::CertifiedAccept
398 );
399 assert_eq!(decide(&state, 0.0), AcceptOutcome::Reject);
400 }
401
402 #[test]
403 fn mc_q_sub_feasible_target_reports_insufficient_calibration() {
404 let samples: Vec<CalibrationSample> = (0..5)
407 .map(|i| CalibrationSample {
408 score: 0.5 + (i as f64) * 0.05,
409 is_unsafe: false,
410 })
411 .collect();
412 match conformal_threshold_checked(&samples, 0.05).unwrap() {
413 ThresholdOutcome::InsufficientCalibration { have, need } => {
414 assert_eq!(have, 5);
415 assert_eq!(need, 19);
416 }
417 other => panic!("expected insufficiency, got {other:?}"),
418 }
419 }
420
421 #[test]
422 fn mc_q_the_sample_floor_matches_the_theorem() {
423 assert_eq!(sample_floor(0.05).unwrap(), 19);
424 assert_eq!(sample_floor(0.5).unwrap(), 1);
425 assert!(sample_floor(0.0).is_err());
426 }
427
428 #[test]
429 fn mc_q_a_score_exactly_at_the_threshold_is_rejected() {
430 let state = CalibrationState::Calibrated {
433 theta_hat: 0.7,
434 rho: 0.05,
435 };
436 assert_eq!(
437 decide(&state, 0.7),
438 AcceptOutcome::Reject,
439 "tie at the threshold must reject"
440 );
441 assert_eq!(decide(&state, 0.7 + 1e-9), AcceptOutcome::CertifiedAccept);
442 }
443
444 #[test]
445 fn mc_q_any_stratum_field_change_marks_stale() {
446 let stratum = CalibrationStratum {
447 domain_package: "coding".into(),
448 domain_version: "0.6.6".into(),
449 effect_kind: "apply_patch".into(),
450 risk_class: "medium".into(),
451 model_route: "anthropic::claude-opus-5".into(),
452 verifier_suite_fingerprint: "vs1".into(),
453 tool_catalog_fingerprint: "tc1".into(),
454 policy_version: "p1".into(),
455 score_definition: "s1".into(),
456 };
457 let mut failover = stratum.clone();
458 failover.model_route = "openai::gpt-5.5".into();
459 assert_ne!(stratum, failover, "automatic failover changes the stratum");
460 assert_eq!(
461 readiness(100, 0.05, stratum != failover).unwrap(),
462 CalibrationReadiness::Stale
463 );
464 }
465
466 #[test]
467 fn readiness_progresses_from_insufficient_through_shadow_to_active() {
468 assert_eq!(
469 readiness(0, 0.05, false).unwrap(),
470 CalibrationReadiness::InsufficientSamples
471 );
472 assert_eq!(
473 readiness(5, 0.05, false).unwrap(),
474 CalibrationReadiness::Shadow
475 );
476 assert_eq!(
477 readiness(19, 0.05, false).unwrap(),
478 CalibrationReadiness::Active
479 );
480 }
481}