1use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::time::{Duration, Instant};
17use rand::seq::SliceRandom;
18use rand::Rng;
19use anyhow::{Result, Context};
20use tracing::{info, warn, error};
21
22pub const DEFAULT_SLA_MS: u64 = 150;
24pub const MAX_ECE_THRESHOLD: f32 = 0.02;
25pub const BOOTSTRAP_SAMPLES: usize = 10_000;
26pub const SIGNIFICANCE_ALPHA: f32 = 0.05;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SLABoundedQueryResult {
31 pub query_id: String,
32 pub execution_time_ms: u64,
33 pub within_sla: bool,
34 pub rankings: Vec<RankedResult>,
35 pub ground_truth: Vec<GroundTruthItem>,
36 pub ndcg_at_10: Option<f32>,
37 pub calibration_scores: Vec<CalibrationPoint>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RankedResult {
43 pub document_id: String,
44 pub score: f32,
45 pub calibrated_probability: Option<f32>,
46 pub rank: usize,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct GroundTruthItem {
52 pub document_id: String,
53 pub relevance: f32, pub intent_category: String,
55 pub language: String,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct CalibrationPoint {
61 pub predicted_probability: f32,
62 pub actual_relevance: f32,
63 pub confidence_bin: usize,
64}
65
66#[derive(Debug, Serialize, Deserialize)]
68pub struct SLABoundedEvaluationResult {
69 pub slice_name: String,
70 pub total_queries: usize,
71 pub within_sla_queries: usize,
72 pub sla_recall: f32, pub mean_ndcg_at_10: f32,
74 pub std_ndcg_at_10: f32,
75 pub expected_calibration_error: f32,
76 pub calibration_bins: Vec<CalibrationBin>,
77 pub bootstrap_confidence_interval: Option<BootstrapCI>,
78 pub baseline_comparison: Option<BaselineComparison>,
79 pub execution_time_stats: ExecutionTimeStats,
80 pub artifact_path: String,
81}
82
83#[derive(Debug, Serialize, Deserialize)]
85pub struct CalibrationBin {
86 pub bin_id: usize,
87 pub confidence_range: (f32, f32),
88 pub count: usize,
89 pub avg_confidence: f32,
90 pub avg_accuracy: f32,
91 pub bin_ece: f32,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
96pub struct BootstrapCI {
97 pub metric_name: String,
98 pub point_estimate: f32,
99 pub lower_bound: f32, pub upper_bound: f32, pub p_value: Option<f32>,
102}
103
104#[derive(Debug, Serialize, Deserialize)]
106pub struct BaselineComparison {
107 pub baseline_policy: String,
108 pub baseline_ndcg: f32,
109 pub candidate_ndcg: f32,
110 pub delta_ndcg: f32,
111 pub statistical_significance: bool,
112 pub p_value: f32,
113}
114
115#[derive(Debug, Serialize, Deserialize)]
117pub struct ExecutionTimeStats {
118 pub mean_ms: f32,
119 pub median_ms: f32,
120 pub p95_ms: f32,
121 pub p99_ms: f32,
122 pub max_ms: f32,
123 pub timeout_count: usize,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct SLAEvaluationConfig {
129 pub sla_timeout_ms: u64,
130 pub max_ece_threshold: f32,
131 pub bootstrap_samples: usize,
132 pub significance_alpha: f32,
133 pub ndcg_cutoff: usize,
134 pub calibration_bins: usize,
135 pub baseline_policy_fingerprint: String,
136}
137
138impl Default for SLAEvaluationConfig {
139 fn default() -> Self {
140 Self {
141 sla_timeout_ms: DEFAULT_SLA_MS,
142 max_ece_threshold: MAX_ECE_THRESHOLD,
143 bootstrap_samples: BOOTSTRAP_SAMPLES,
144 significance_alpha: SIGNIFICANCE_ALPHA,
145 ndcg_cutoff: 10,
146 calibration_bins: 10,
147 baseline_policy_fingerprint: "lexical_struct_only".to_string(),
148 }
149 }
150}
151
152pub struct SLABoundedEvaluator {
154 config: SLAEvaluationConfig,
155 rng: rand::rngs::StdRng,
156}
157
158impl SLABoundedEvaluator {
159 pub fn new(config: SLAEvaluationConfig) -> Self {
160 use rand::SeedableRng;
161 Self {
162 config,
163 rng: rand::rngs::StdRng::from_entropy(),
164 }
165 }
166
167 pub async fn evaluate_query_bounded<F, Fut>(
169 &self,
170 query_id: &str,
171 query: &str,
172 ground_truth: Vec<GroundTruthItem>,
173 search_fn: F,
174 ) -> Result<SLABoundedQueryResult>
175 where
176 F: FnOnce(&str) -> Fut,
177 Fut: std::future::Future<Output = Result<Vec<RankedResult>>>,
178 {
179 let start_time = Instant::now();
180 let timeout_duration = Duration::from_millis(self.config.sla_timeout_ms);
181
182 let search_result = tokio::time::timeout(timeout_duration, search_fn(query)).await;
184
185 let execution_time = start_time.elapsed();
186 let execution_time_ms = execution_time.as_millis() as u64;
187 let within_sla = execution_time_ms <= self.config.sla_timeout_ms;
188
189 match search_result {
190 Ok(Ok(rankings)) => {
191 let ndcg_at_10 = if within_sla {
193 Some(self.calculate_ndcg_at_k(&rankings, &ground_truth, self.config.ndcg_cutoff)?)
194 } else {
195 None
196 };
197
198 let calibration_scores = self.extract_calibration_points(&rankings, &ground_truth)?;
200
201 Ok(SLABoundedQueryResult {
202 query_id: query_id.to_string(),
203 execution_time_ms,
204 within_sla,
205 rankings,
206 ground_truth,
207 ndcg_at_10,
208 calibration_scores,
209 })
210 }
211 Ok(Err(e)) => {
212 warn!("Search failed for query {}: {}", query_id, e);
213 Ok(SLABoundedQueryResult {
214 query_id: query_id.to_string(),
215 execution_time_ms,
216 within_sla,
217 rankings: vec![],
218 ground_truth,
219 ndcg_at_10: None,
220 calibration_scores: vec![],
221 })
222 }
223 Err(_) => {
224 warn!("Query {} timed out after {}ms", query_id, self.config.sla_timeout_ms);
225 Ok(SLABoundedQueryResult {
226 query_id: query_id.to_string(),
227 execution_time_ms,
228 within_sla: false,
229 rankings: vec![],
230 ground_truth,
231 ndcg_at_10: None,
232 calibration_scores: vec![],
233 })
234 }
235 }
236 }
237
238 pub async fn evaluate_slice<F, Fut>(
240 &mut self,
241 slice_name: &str,
242 queries: Vec<(String, String, Vec<GroundTruthItem>)>, search_fn: F,
244 baseline_results: Option<Vec<SLABoundedQueryResult>>,
245 ) -> Result<SLABoundedEvaluationResult>
246 where
247 F: Fn(&str) -> Fut + Send + Sync + 'static,
248 Fut: std::future::Future<Output = Result<Vec<RankedResult>>> + Send,
249 {
250 info!("Starting SLA-bounded evaluation for slice: {}", slice_name);
251
252 let mut query_results = Vec::new();
253 let total_queries = queries.len();
254
255 for (query_id, query, ground_truth) in queries {
257 let result = self.evaluate_query_bounded(
258 &query_id,
259 &query,
260 ground_truth,
261 &search_fn
262 ).await?;
263
264 query_results.push(result);
265 }
266
267 let within_sla_results: Vec<_> = query_results
269 .iter()
270 .filter(|r| r.within_sla && r.ndcg_at_10.is_some())
271 .collect();
272
273 let within_sla_queries = within_sla_results.len();
274 let sla_recall = within_sla_queries as f32 / total_queries as f32;
275
276 let ndcg_values: Vec<f32> = within_sla_results
278 .iter()
279 .filter_map(|r| r.ndcg_at_10)
280 .collect();
281
282 let (mean_ndcg, std_ndcg) = if !ndcg_values.is_empty() {
283 let mean = ndcg_values.iter().sum::<f32>() / ndcg_values.len() as f32;
284 let variance = ndcg_values.iter()
285 .map(|x| (x - mean).powi(2))
286 .sum::<f32>() / ndcg_values.len() as f32;
287 (mean, variance.sqrt())
288 } else {
289 (0.0, 0.0)
290 };
291
292 let all_calibration_points: Vec<_> = query_results
294 .iter()
295 .flat_map(|r| r.calibration_scores.iter())
296 .cloned()
297 .collect();
298
299 let (ece, calibration_bins) = self.calculate_expected_calibration_error(&all_calibration_points)?;
300
301 let bootstrap_ci = if !ndcg_values.is_empty() {
303 Some(self.calculate_bootstrap_confidence_interval(&ndcg_values, "nDCG@10")?)
304 } else {
305 None
306 };
307
308 let baseline_comparison = if let Some(baseline_results) = baseline_results {
310 Some(self.compare_with_baseline(&query_results, &baseline_results)?)
311 } else {
312 None
313 };
314
315 let execution_times: Vec<u64> = query_results.iter().map(|r| r.execution_time_ms).collect();
317 let execution_time_stats = self.calculate_execution_time_stats(&execution_times);
318
319 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
321 let artifact_path = format!("artifact://eval/sla_eval_{}_{}.json", slice_name, timestamp);
322
323 let result = SLABoundedEvaluationResult {
324 slice_name: slice_name.to_string(),
325 total_queries,
326 within_sla_queries,
327 sla_recall,
328 mean_ndcg_at_10: mean_ndcg,
329 std_ndcg_at_10: std_ndcg,
330 expected_calibration_error: ece,
331 calibration_bins,
332 bootstrap_confidence_interval: bootstrap_ci,
333 baseline_comparison,
334 execution_time_stats,
335 artifact_path: artifact_path.clone(),
336 };
337
338 self.save_evaluation_artifact(&result).await?;
340
341 info!(
342 "SLA-bounded evaluation complete. SLA Recall: {:.3}, Mean nDCG@10: {:.4}, ECE: {:.4}",
343 sla_recall, mean_ndcg, ece
344 );
345
346 Ok(result)
347 }
348
349 fn calculate_ndcg_at_k(
351 &self,
352 rankings: &[RankedResult],
353 ground_truth: &[GroundTruthItem],
354 k: usize,
355 ) -> Result<f32> {
356 let relevance_map: HashMap<String, f32> = ground_truth
358 .iter()
359 .map(|gt| (gt.document_id.clone(), gt.relevance))
360 .collect();
361
362 let dcg = rankings
364 .iter()
365 .take(k)
366 .enumerate()
367 .map(|(i, result)| {
368 let relevance = relevance_map.get(&result.document_id).unwrap_or(&0.0);
369 let discount = (i + 2) as f32; relevance / discount.log2()
371 })
372 .sum::<f32>();
373
374 let mut ideal_relevances: Vec<f32> = ground_truth.iter().map(|gt| gt.relevance).collect();
376 ideal_relevances.sort_by(|a, b| b.partial_cmp(a).unwrap());
377
378 let idcg = ideal_relevances
379 .iter()
380 .take(k)
381 .enumerate()
382 .map(|(i, relevance)| {
383 let discount = (i + 2) as f32;
384 relevance / discount.log2()
385 })
386 .sum::<f32>();
387
388 let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 };
390 Ok(ndcg)
391 }
392
393 fn extract_calibration_points(
395 &self,
396 rankings: &[RankedResult],
397 ground_truth: &[GroundTruthItem],
398 ) -> Result<Vec<CalibrationPoint>> {
399 let relevance_map: HashMap<String, f32> = ground_truth
400 .iter()
401 .map(|gt| (gt.document_id.clone(), gt.relevance))
402 .collect();
403
404 let calibration_points = rankings
405 .iter()
406 .filter_map(|result| {
407 if let Some(predicted_prob) = result.calibrated_probability {
408 let actual_relevance = relevance_map.get(&result.document_id).unwrap_or(&0.0);
409 let confidence_bin = (predicted_prob * self.config.calibration_bins as f32).floor() as usize;
410 let confidence_bin = confidence_bin.min(self.config.calibration_bins - 1);
411
412 Some(CalibrationPoint {
413 predicted_probability: predicted_prob,
414 actual_relevance: *actual_relevance,
415 confidence_bin,
416 })
417 } else {
418 None
419 }
420 })
421 .collect();
422
423 Ok(calibration_points)
424 }
425
426 fn calculate_expected_calibration_error(
428 &self,
429 calibration_points: &[CalibrationPoint],
430 ) -> Result<(f32, Vec<CalibrationBin>)> {
431 let mut bins: Vec<CalibrationBin> = (0..self.config.calibration_bins)
432 .map(|i| {
433 let bin_start = i as f32 / self.config.calibration_bins as f32;
434 let bin_end = (i + 1) as f32 / self.config.calibration_bins as f32;
435 CalibrationBin {
436 bin_id: i,
437 confidence_range: (bin_start, bin_end),
438 count: 0,
439 avg_confidence: 0.0,
440 avg_accuracy: 0.0,
441 bin_ece: 0.0,
442 }
443 })
444 .collect();
445
446 for point in calibration_points {
448 let bin = &mut bins[point.confidence_bin];
449 bin.count += 1;
450 bin.avg_confidence += point.predicted_probability;
451 bin.avg_accuracy += point.actual_relevance;
452 }
453
454 let total_points = calibration_points.len() as f32;
456 let mut total_ece = 0.0;
457
458 for bin in &mut bins {
459 if bin.count > 0 {
460 bin.avg_confidence /= bin.count as f32;
461 bin.avg_accuracy /= bin.count as f32;
462 bin.bin_ece = (bin.avg_confidence - bin.avg_accuracy).abs();
463
464 let bin_weight = bin.count as f32 / total_points;
466 total_ece += bin_weight * bin.bin_ece;
467 }
468 }
469
470 Ok((total_ece, bins))
471 }
472
473 fn calculate_bootstrap_confidence_interval(
475 &mut self,
476 values: &[f32],
477 metric_name: &str,
478 ) -> Result<BootstrapCI> {
479 let point_estimate = values.iter().sum::<f32>() / values.len() as f32;
480 let mut bootstrap_means = Vec::with_capacity(self.config.bootstrap_samples);
481
482 for _ in 0..self.config.bootstrap_samples {
483 let bootstrap_sample: Vec<f32> = (0..values.len())
484 .map(|_| {
485 let idx = self.rng.gen_range(0..values.len());
486 values[idx]
487 })
488 .collect();
489
490 let bootstrap_mean = bootstrap_sample.iter().sum::<f32>() / bootstrap_sample.len() as f32;
491 bootstrap_means.push(bootstrap_mean);
492 }
493
494 bootstrap_means.sort_by(|a, b| a.partial_cmp(b).unwrap());
495
496 let lower_idx = (self.config.bootstrap_samples as f32 * 0.025) as usize;
497 let upper_idx = (self.config.bootstrap_samples as f32 * 0.975) as usize;
498
499 Ok(BootstrapCI {
500 metric_name: metric_name.to_string(),
501 point_estimate,
502 lower_bound: bootstrap_means[lower_idx],
503 upper_bound: bootstrap_means[upper_idx],
504 p_value: None,
505 })
506 }
507
508 fn compare_with_baseline(
510 &mut self,
511 candidate_results: &[SLABoundedQueryResult],
512 baseline_results: &[SLABoundedQueryResult],
513 ) -> Result<BaselineComparison> {
514 let candidate_ndcg: Vec<f32> = candidate_results
516 .iter()
517 .filter_map(|r| r.ndcg_at_10)
518 .collect();
519
520 let baseline_ndcg: Vec<f32> = baseline_results
521 .iter()
522 .filter_map(|r| r.ndcg_at_10)
523 .collect();
524
525 let candidate_mean = candidate_ndcg.iter().sum::<f32>() / candidate_ndcg.len() as f32;
526 let baseline_mean = baseline_ndcg.iter().sum::<f32>() / baseline_ndcg.len() as f32;
527 let delta_ndcg = candidate_mean - baseline_mean;
528
529 let mut delta_samples = Vec::with_capacity(self.config.bootstrap_samples);
531
532 for _ in 0..self.config.bootstrap_samples {
533 let sample_size = candidate_ndcg.len().min(baseline_ndcg.len());
534 let mut candidate_sample = 0.0;
535 let mut baseline_sample = 0.0;
536
537 for _ in 0..sample_size {
538 let idx = self.rng.gen_range(0..sample_size);
539 candidate_sample += candidate_ndcg[idx];
540 baseline_sample += baseline_ndcg[idx];
541 }
542
543 let delta = (candidate_sample / sample_size as f32) - (baseline_sample / sample_size as f32);
544 delta_samples.push(delta);
545 }
546
547 delta_samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
548
549 let negative_deltas = delta_samples.iter().filter(|&&d| d <= 0.0).count();
551 let p_value = 2.0 * (negative_deltas as f32 / delta_samples.len() as f32).min(0.5);
552 let statistical_significance = p_value < self.config.significance_alpha;
553
554 Ok(BaselineComparison {
555 baseline_policy: format!("policy://lexical_struct_only@{}", self.config.baseline_policy_fingerprint),
556 baseline_ndcg: baseline_mean,
557 candidate_ndcg: candidate_mean,
558 delta_ndcg,
559 statistical_significance,
560 p_value,
561 })
562 }
563
564 fn calculate_execution_time_stats(&self, execution_times: &[u64]) -> ExecutionTimeStats {
566 let mut sorted_times = execution_times.to_vec();
567 sorted_times.sort();
568
569 let mean = sorted_times.iter().sum::<u64>() as f32 / sorted_times.len() as f32;
570 let median = sorted_times[sorted_times.len() / 2] as f32;
571 let p95_idx = (sorted_times.len() as f32 * 0.95) as usize;
572 let p99_idx = (sorted_times.len() as f32 * 0.99) as usize;
573 let p95 = sorted_times[p95_idx.min(sorted_times.len() - 1)] as f32;
574 let p99 = sorted_times[p99_idx.min(sorted_times.len() - 1)] as f32;
575 let max = *sorted_times.last().unwrap_or(&0) as f32;
576 let timeout_count = execution_times.iter().filter(|&&t| t > self.config.sla_timeout_ms).count();
577
578 ExecutionTimeStats {
579 mean_ms: mean,
580 median_ms: median,
581 p95_ms: p95,
582 p99_ms: p99,
583 max_ms: max,
584 timeout_count,
585 }
586 }
587
588 async fn save_evaluation_artifact(&self, result: &SLABoundedEvaluationResult) -> Result<()> {
590 let artifact_content = serde_json::to_string_pretty(result)
591 .context("Failed to serialize evaluation result")?;
592
593 let filename = result.artifact_path
595 .split("://")
596 .nth(1)
597 .unwrap_or("sla_eval_result.json")
598 .replace('/', "_");
599
600 let artifact_dir = std::path::Path::new("artifact").join("eval");
601 tokio::fs::create_dir_all(&artifact_dir).await
602 .context("Failed to create artifact directory")?;
603
604 let filepath = artifact_dir.join(&filename);
605 tokio::fs::write(&filepath, artifact_content).await
606 .context("Failed to write evaluation artifact")?;
607
608 info!("Saved SLA-bounded evaluation artifact: {}", filepath.display());
609 Ok(())
610 }
611
612 pub fn validate_gates(&self, result: &SLABoundedEvaluationResult) -> Result<GateValidationResult> {
614 let mut passed = true;
615 let mut violations = Vec::new();
616
617 if result.sla_recall < 0.0 {
619 passed = false;
620 violations.push(format!("SLA-Recall below 0: {:.3}", result.sla_recall));
621 }
622
623 if result.expected_calibration_error > self.config.max_ece_threshold {
625 passed = false;
626 violations.push(format!(
627 "ECE exceeds threshold: {:.4} > {:.4}",
628 result.expected_calibration_error,
629 self.config.max_ece_threshold
630 ));
631 }
632
633 if let Some(baseline_comp) = &result.baseline_comparison {
635 let delta_pp = baseline_comp.delta_ndcg * 100.0; if delta_pp < 4.0 {
637 passed = false;
638 violations.push(format!(
639 "Semantic lift insufficient: +{:.1}pp < +4.0pp required",
640 delta_pp
641 ));
642 }
643 }
644
645 Ok(GateValidationResult {
646 passed,
647 violations,
648 sla_recall: result.sla_recall,
649 ece: result.expected_calibration_error,
650 delta_ndcg_pp: result.baseline_comparison.as_ref().map(|b| b.delta_ndcg * 100.0),
651 })
652 }
653}
654
655#[derive(Debug, Serialize, Deserialize)]
657pub struct GateValidationResult {
658 pub passed: bool,
659 pub violations: Vec<String>,
660 pub sla_recall: f32,
661 pub ece: f32,
662 pub delta_ndcg_pp: Option<f32>,
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[tokio::test]
670 async fn test_sla_bounded_evaluation() {
671 let config = SLAEvaluationConfig::default();
672 let evaluator = SLABoundedEvaluator::new(config);
673
674 let search_fn = |_query: &str| async { Ok(vec![]) };
676
677 let ground_truth = vec![GroundTruthItem {
678 document_id: "doc1".to_string(),
679 relevance: 1.0,
680 intent_category: "exact_match".to_string(),
681 language: "rust".to_string(),
682 }];
683
684 let result = evaluator.evaluate_query_bounded(
685 "test_query",
686 "test query",
687 ground_truth,
688 search_fn,
689 ).await.unwrap();
690
691 assert_eq!(result.query_id, "test_query");
692 assert!(result.within_sla);
693 assert!(result.execution_time_ms < 150);
694 }
695
696 #[test]
697 fn test_ndcg_calculation() {
698 let evaluator = SLABoundedEvaluator::new(SLAEvaluationConfig::default());
699
700 let rankings = vec![
701 RankedResult {
702 document_id: "doc1".to_string(),
703 score: 0.9,
704 calibrated_probability: Some(0.8),
705 rank: 0,
706 },
707 RankedResult {
708 document_id: "doc2".to_string(),
709 score: 0.7,
710 calibrated_probability: Some(0.6),
711 rank: 1,
712 },
713 ];
714
715 let ground_truth = vec![
716 GroundTruthItem {
717 document_id: "doc1".to_string(),
718 relevance: 1.0,
719 intent_category: "exact_match".to_string(),
720 language: "rust".to_string(),
721 },
722 GroundTruthItem {
723 document_id: "doc2".to_string(),
724 relevance: 0.5,
725 intent_category: "structural".to_string(),
726 language: "rust".to_string(),
727 },
728 ];
729
730 let ndcg = evaluator.calculate_ndcg_at_k(&rankings, &ground_truth, 10).unwrap();
731 assert!(ndcg > 0.0 && ndcg <= 1.0);
732 }
733
734 #[test]
735 fn test_ece_calculation() {
736 let evaluator = SLABoundedEvaluator::new(SLAEvaluationConfig::default());
737
738 let calibration_points = vec![
739 CalibrationPoint {
740 predicted_probability: 0.8,
741 actual_relevance: 1.0,
742 confidence_bin: 7,
743 },
744 CalibrationPoint {
745 predicted_probability: 0.3,
746 actual_relevance: 0.0,
747 confidence_bin: 2,
748 },
749 ];
750
751 let (ece, bins) = evaluator.calculate_expected_calibration_error(&calibration_points).unwrap();
752 assert!(ece >= 0.0 && ece <= 1.0);
753 assert_eq!(bins.len(), 10);
754 }
755}