Skip to main content

lens_core/semantic/
sla_bounded_evaluation.rs

1//! SLA-Bounded Evaluation System
2//!
3//! Implements evaluation metrics respecting the 150ms SLA constraint per query.
4//! Measures nDCG@10, Expected Calibration Error (ECE), and statistical significance
5//! via paired bootstrap testing.
6//!
7//! **TODO.md Requirements:**
8//! - SLA cap: 150ms per query
9//! - nDCG@10 calculation with proper ranking cutoffs
10//! - ECE ≤ 0.02 validation
11//! - Paired bootstrap for statistical significance (10,000 samples, α=0.05)
12//! - Baseline comparison against `policy://lexical_struct_only@<fingerprint>`
13
14use 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
22/// SLA constraint configuration
23pub 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/// Query evaluation result within SLA bounds
29#[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/// Individual ranked result from search system
41#[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/// Ground truth relevance for evaluation
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct GroundTruthItem {
52    pub document_id: String,
53    pub relevance: f32, // 0.0 to 1.0
54    pub intent_category: String,
55    pub language: String,
56}
57
58/// Calibration point for ECE calculation
59#[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/// Complete evaluation results for a dataset slice
67#[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, // Fraction of queries processed within SLA
73    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/// Calibration bin for ECE calculation
84#[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/// Bootstrap confidence interval
95#[derive(Debug, Serialize, Deserialize)]
96pub struct BootstrapCI {
97    pub metric_name: String,
98    pub point_estimate: f32,
99    pub lower_bound: f32, // 2.5th percentile
100    pub upper_bound: f32, // 97.5th percentile
101    pub p_value: Option<f32>,
102}
103
104/// Baseline policy comparison results
105#[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/// Execution time statistics
116#[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/// Configuration for SLA-bounded evaluation
127#[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
152/// Main SLA-bounded evaluation system
153pub 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    /// Evaluate a query with SLA bounds
168    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        // Execute search with timeout
183        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                // Calculate nDCG@10
192                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                // Extract calibration points
199                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    /// Evaluate a complete dataset slice
239    pub async fn evaluate_slice<F, Fut>(
240        &mut self,
241        slice_name: &str,
242        queries: Vec<(String, String, Vec<GroundTruthItem>)>, // (id, query, ground_truth)
243        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        // Evaluate each query
256        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        // Calculate aggregate metrics
268        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        // nDCG@10 statistics
277        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        // Calculate Expected Calibration Error
293        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        // Bootstrap confidence interval for nDCG
302        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        // Baseline comparison
309        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        // Execution time statistics
316        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        // Generate artifact path
320        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        // Save artifact
339        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    /// Calculate nDCG@k for a query result
350    fn calculate_ndcg_at_k(
351        &self,
352        rankings: &[RankedResult],
353        ground_truth: &[GroundTruthItem],
354        k: usize,
355    ) -> Result<f32> {
356        // Create relevance map
357        let relevance_map: HashMap<String, f32> = ground_truth
358            .iter()
359            .map(|gt| (gt.document_id.clone(), gt.relevance))
360            .collect();
361
362        // Calculate DCG@k
363        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; // i+2 because log2(1) = 0
370                relevance / discount.log2()
371            })
372            .sum::<f32>();
373
374        // Calculate IDCG@k (ideal DCG)
375        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        // Calculate nDCG@k
389        let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 };
390        Ok(ndcg)
391    }
392
393    /// Extract calibration points from results
394    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    /// Calculate Expected Calibration Error
427    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        // Populate bins
447        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        // Calculate bin statistics
455        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                // Weight by bin size for ECE calculation
465                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    /// Calculate bootstrap confidence interval
474    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    /// Compare candidate with baseline policy
509    fn compare_with_baseline(
510        &mut self,
511        candidate_results: &[SLABoundedQueryResult],
512        baseline_results: &[SLABoundedQueryResult],
513    ) -> Result<BaselineComparison> {
514        // Extract nDCG values for both systems
515        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        // Paired bootstrap test
530        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        // Calculate p-value (two-tailed test)
550        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    /// Calculate execution time statistics
565    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    /// Save evaluation artifact
589    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        // Extract filename from artifact path
594        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    /// Validate gate requirements
613    pub fn validate_gates(&self, result: &SLABoundedEvaluationResult) -> Result<GateValidationResult> {
614        let mut passed = true;
615        let mut violations = Vec::new();
616
617        // Check SLA-Recall ≥ 0 (should be automatic, but validate)
618        if result.sla_recall < 0.0 {
619            passed = false;
620            violations.push(format!("SLA-Recall below 0: {:.3}", result.sla_recall));
621        }
622
623        // Check ECE ≤ 0.02
624        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        // Check ΔnDCG ≥ +4.0pp (if baseline comparison available)
634        if let Some(baseline_comp) = &result.baseline_comparison {
635            let delta_pp = baseline_comp.delta_ndcg * 100.0; // Convert to percentage points
636            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/// Gate validation result
656#[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        // Mock search function that always returns empty results
675        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}