Skip to main content

lens_core/semantic/
rerank.rs

1//! # Learned Reranking with Isotonic Regression
2//!
3//! Advanced reranking system for semantic search results:
4//! - Learned reranking on top-K results from initial search
5//! - Isotonic regression for calibrated score mapping
6//! - Balance precision vs recall optimization
7//! - Target: +2-3pp nDCG improvement over baseline ranking
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::Arc;
13use tokio::sync::RwLock;
14use tracing::{debug, info, warn};
15
16/// Learned reranking system
17pub struct LearnedReranker {
18    config: RerankConfig,
19    /// Feature extractors for reranking
20    feature_extractors: Vec<Box<dyn FeatureExtractor + Send + Sync>>,
21    /// Isotonic regression models for score calibration
22    isotonic_models: Arc<RwLock<HashMap<String, IsotonicRegressor>>>,
23    /// Linear model weights
24    model_weights: Arc<RwLock<Option<Vec<f32>>>>,
25    /// Performance metrics
26    metrics: Arc<RwLock<RerankMetrics>>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct RerankConfig {
31    /// Top-K results to rerank
32    pub top_k: usize,
33    /// Use isotonic regression for score calibration
34    pub use_isotonic: bool,
35    /// Learning rate for model training
36    pub learning_rate: f32,
37    /// L2 regularization strength
38    pub l2_regularization: f32,
39    /// Minimum training samples for model update
40    pub min_training_samples: usize,
41    /// Feature combination strategy
42    pub combination_strategy: CombinationStrategy,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub enum CombinationStrategy {
47    /// Linear combination of features
48    Linear,
49    /// Learned weighted combination
50    LearnedWeights,
51    /// Ensemble of models
52    Ensemble,
53}
54
55/// Search result to be reranked
56#[derive(Debug, Clone)]
57pub struct SearchResult {
58    pub id: String,
59    pub content: String,
60    pub file_path: String,
61    pub initial_score: f32,
62    pub lexical_score: f32,
63    pub semantic_score: Option<f32>,
64    pub lsp_score: Option<f32>,
65    pub metadata: HashMap<String, String>,
66}
67
68/// Reranked result with new score
69#[derive(Debug, Clone)]
70pub struct RerankedResult {
71    pub result: SearchResult,
72    pub rerank_score: f32,
73    pub feature_vector: Vec<f32>,
74    pub calibrated_score: f32,
75    pub rank_change: i32, // Change in ranking position
76}
77
78/// Feature extractor interface
79pub trait FeatureExtractor {
80    /// Extract features from query and result
81    fn extract_features(&self, query: &str, result: &SearchResult) -> Result<Vec<f32>>;
82    
83    /// Get feature names for interpretability
84    fn feature_names(&self) -> Vec<String>;
85}
86
87/// Isotonic regression for score calibration
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct IsotonicRegressor {
90    /// Sorted input values
91    x_values: Vec<f32>,
92    /// Corresponding output values (isotonic)
93    y_values: Vec<f32>,
94    /// Number of training samples
95    sample_count: usize,
96}
97
98/// Training sample for reranker
99#[derive(Debug, Clone)]
100pub struct TrainingSample {
101    pub query: String,
102    pub results: Vec<SearchResult>,
103    pub relevance_scores: Vec<f32>, // Ground truth relevance (0.0-1.0)
104    pub ideal_ranking: Vec<usize>, // Ideal ranking order
105}
106
107/// Reranking performance metrics
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RerankMetrics {
110    pub samples_processed: usize,
111    pub avg_ndcg_improvement: f32,
112    pub avg_precision_improvement: f32,
113    pub avg_recall_improvement: f32,
114    pub calibration_error: f32,
115    pub feature_importances: HashMap<String, f32>,
116}
117
118impl LearnedReranker {
119    /// Create new learned reranker
120    pub async fn new(config: RerankConfig) -> Result<Self> {
121        info!("Creating learned reranker");
122        info!("Top-K: {}, isotonic: {}, learning rate: {}", 
123              config.top_k, config.use_isotonic, config.learning_rate);
124        
125        // Initialize feature extractors
126        let mut feature_extractors: Vec<Box<dyn FeatureExtractor + Send + Sync>> = Vec::new();
127        feature_extractors.push(Box::new(LexicalFeatureExtractor::new()));
128        feature_extractors.push(Box::new(SemanticFeatureExtractor::new()));
129        feature_extractors.push(Box::new(StructuralFeatureExtractor::new()));
130        feature_extractors.push(Box::new(LSPFeatureExtractor::new()));
131        
132        Ok(Self {
133            config,
134            feature_extractors,
135            isotonic_models: Arc::new(RwLock::new(HashMap::new())),
136            model_weights: Arc::new(RwLock::new(None)),
137            metrics: Arc::new(RwLock::new(RerankMetrics::default())),
138        })
139    }
140    
141    /// Rerank search results with learned model
142    pub async fn rerank(&self, query: &str, results: Vec<SearchResult>) -> Result<Vec<RerankedResult>> {
143        if results.is_empty() {
144            return Ok(Vec::new());
145        }
146        
147        // Take top-K for reranking
148        let top_k_results = results.into_iter()
149            .take(self.config.top_k)
150            .collect::<Vec<_>>();
151            
152        debug!("Reranking {} results for query: '{}'", top_k_results.len(), query);
153        
154        // Extract features for all results
155        let feature_results = self.extract_all_features(query, &top_k_results).await?;
156        
157        // Apply learned model
158        let scored_results = self.apply_learned_model(feature_results).await?;
159        
160        // Apply isotonic calibration if enabled
161        let calibrated_results = if self.config.use_isotonic {
162            self.apply_isotonic_calibration(scored_results).await?
163        } else {
164            scored_results.into_iter()
165                .map(|mut r| { r.calibrated_score = r.rerank_score; r })
166                .collect()
167        };
168        
169        // Sort by reranked scores
170        let mut final_results = calibrated_results;
171        final_results.sort_by(|a, b| b.calibrated_score.partial_cmp(&a.calibrated_score).unwrap());
172        
173        // Calculate rank changes
174        let original_order: HashMap<String, usize> = top_k_results.iter()
175            .enumerate()
176            .map(|(i, r)| (r.id.clone(), i))
177            .collect();
178            
179        for (new_rank, result) in final_results.iter_mut().enumerate() {
180            let original_rank = original_order.get(&result.result.id).unwrap_or(&0);
181            result.rank_change = *original_rank as i32 - new_rank as i32;
182        }
183        
184        debug!("Reranking complete, {} results reordered", final_results.len());
185        
186        Ok(final_results)
187    }
188    
189    /// Train the reranker on labeled data
190    pub async fn train(&self, training_samples: &[TrainingSample]) -> Result<()> {
191        info!("Training reranker on {} samples", training_samples.len());
192        
193        if training_samples.len() < self.config.min_training_samples {
194            anyhow::bail!("Insufficient training samples: {} < {}", 
195                         training_samples.len(), self.config.min_training_samples);
196        }
197        
198        // Extract features for all training samples
199        let mut feature_vectors = Vec::new();
200        let mut target_scores = Vec::new();
201        
202        for sample in training_samples {
203            let features = self.extract_training_features(sample).await?;
204            feature_vectors.extend(features);
205            target_scores.extend(&sample.relevance_scores);
206        }
207        
208        // Train linear model
209        let weights = self.train_linear_model(&feature_vectors, &target_scores)?;
210        *self.model_weights.write().await = Some(weights);
211        
212        // Train isotonic regressors if enabled
213        if self.config.use_isotonic {
214            self.train_isotonic_regressors(training_samples).await?;
215        }
216        
217        // Update metrics
218        self.update_training_metrics(training_samples).await?;
219        
220        info!("Reranker training complete");
221        Ok(())
222    }
223    
224    /// Evaluate reranker performance
225    pub async fn evaluate(&self, test_samples: &[TrainingSample]) -> Result<RerankMetrics> {
226        info!("Evaluating reranker on {} test samples", test_samples.len());
227        
228        let mut total_ndcg_improvement = 0.0;
229        let mut total_precision_improvement = 0.0;
230        let mut total_recall_improvement = 0.0;
231        let mut valid_samples = 0;
232        
233        for sample in test_samples {
234            // Get original ranking
235            let original_results = sample.results.clone();
236            
237            // Apply reranking
238            let reranked_results = self.rerank(&sample.query, original_results.clone()).await?;
239            
240            // Calculate metrics improvement
241            let ndcg_original = self.calculate_ndcg(&original_results, &sample.relevance_scores);
242            let ndcg_reranked = self.calculate_ndcg_reranked(&reranked_results, &sample.relevance_scores);
243            
244            let precision_original = self.calculate_precision_at_k(&original_results, &sample.relevance_scores, 10);
245            let precision_reranked = self.calculate_precision_at_k_reranked(&reranked_results, &sample.relevance_scores, 10);
246            
247            if ndcg_original > 0.0 {
248                total_ndcg_improvement += (ndcg_reranked - ndcg_original) / ndcg_original;
249                total_precision_improvement += (precision_reranked - precision_original) / precision_original.max(0.001);
250                valid_samples += 1;
251            }
252        }
253        
254        let avg_ndcg_improvement = if valid_samples > 0 {
255            total_ndcg_improvement / valid_samples as f32
256        } else {
257            0.0
258        };
259        
260        let avg_precision_improvement = if valid_samples > 0 {
261            total_precision_improvement / valid_samples as f32  
262        } else {
263            0.0
264        };
265        
266        let metrics = RerankMetrics {
267            samples_processed: valid_samples,
268            avg_ndcg_improvement,
269            avg_precision_improvement,
270            avg_recall_improvement: 0.0, // TODO: implement recall calculation
271            calibration_error: self.calculate_calibration_error(test_samples).await?,
272            feature_importances: self.get_feature_importances().await,
273        };
274        
275        info!("Evaluation complete: nDCG improvement {:.3}, precision improvement {:.3}",
276              metrics.avg_ndcg_improvement, metrics.avg_precision_improvement);
277        
278        Ok(metrics)
279    }
280    
281    /// Get current model status and metrics
282    pub async fn get_metrics(&self) -> RerankMetrics {
283        self.metrics.read().await.clone()
284    }
285    
286    // Private implementation methods
287    
288    async fn extract_all_features(&self, query: &str, results: &[SearchResult]) -> Result<Vec<FeatureResult>> {
289        let mut feature_results = Vec::with_capacity(results.len());
290        
291        for result in results {
292            let mut all_features = Vec::new();
293            
294            // Extract features from all extractors
295            for extractor in &self.feature_extractors {
296                let features = extractor.extract_features(query, result)
297                    .context("Feature extraction failed")?;
298                all_features.extend(features);
299            }
300            
301            feature_results.push(FeatureResult {
302                result: result.clone(),
303                features: all_features,
304            });
305        }
306        
307        Ok(feature_results)
308    }
309    
310    async fn apply_learned_model(&self, feature_results: Vec<FeatureResult>) -> Result<Vec<RerankedResult>> {
311        let weights_guard = self.model_weights.read().await;
312        
313        // Use default weights if model is not trained (for benchmark testing)
314        let default_weights;
315        let weights = if let Some(trained_weights) = weights_guard.as_ref() {
316            trained_weights
317        } else {
318            warn!("Reranker not trained, using default uniform weights for benchmark testing");
319            // Create uniform weights based on feature dimension
320            let feature_dim = if !feature_results.is_empty() {
321                feature_results[0].features.len()
322            } else {
323                12 // Default: 3 + 3 + 3 + 3 features from each extractor
324            };
325            default_weights = vec![1.0 / feature_dim as f32; feature_dim];
326            &default_weights
327        };
328            
329        let mut reranked = Vec::with_capacity(feature_results.len());
330        
331        for feature_result in feature_results {
332            // Calculate weighted score
333            let rerank_score = if feature_result.features.len() == weights.len() {
334                feature_result.features.iter()
335                    .zip(weights.iter())
336                    .map(|(f, w)| f * w)
337                    .sum()
338            } else {
339                warn!("Feature dimension mismatch: {} vs {}", feature_result.features.len(), weights.len());
340                feature_result.result.initial_score // Fall back to initial score
341            };
342            
343            reranked.push(RerankedResult {
344                result: feature_result.result,
345                rerank_score,
346                feature_vector: feature_result.features,
347                calibrated_score: rerank_score, // Will be updated by calibration
348                rank_change: 0, // Will be calculated later
349            });
350        }
351        
352        Ok(reranked)
353    }
354    
355    async fn apply_isotonic_calibration(&self, mut results: Vec<RerankedResult>) -> Result<Vec<RerankedResult>> {
356        let models = self.isotonic_models.read().await;
357        
358        // For simplicity, use a single global model
359        // Real implementation would have query-type specific models
360        if let Some(model) = models.get("global") {
361            for result in &mut results {
362                result.calibrated_score = model.predict(result.rerank_score);
363            }
364        } else {
365            // No calibration available, use raw scores
366            for result in &mut results {
367                result.calibrated_score = result.rerank_score;
368            }
369        }
370        
371        Ok(results)
372    }
373    
374    async fn extract_training_features(&self, sample: &TrainingSample) -> Result<Vec<Vec<f32>>> {
375        let mut all_features = Vec::new();
376        
377        for result in &sample.results {
378            let mut features = Vec::new();
379            
380            for extractor in &self.feature_extractors {
381                let result_features = extractor.extract_features(&sample.query, result)?;
382                features.extend(result_features);
383            }
384            
385            all_features.push(features);
386        }
387        
388        Ok(all_features)
389    }
390    
391    fn train_linear_model(&self, features: &[Vec<f32>], targets: &[f32]) -> Result<Vec<f32>> {
392        if features.is_empty() || features[0].is_empty() {
393            anyhow::bail!("No features provided for training");
394        }
395        
396        let feature_dim = features[0].len();
397        let mut weights = vec![0.0; feature_dim];
398        
399        // Simple gradient descent training
400        let learning_rate = self.config.learning_rate;
401        let l2_reg = self.config.l2_regularization;
402        let epochs = 100;
403        
404        for _epoch in 0..epochs {
405            let mut gradients = vec![0.0; feature_dim];
406            let mut total_loss = 0.0;
407            
408            for (feature_vec, target) in features.iter().zip(targets.iter()) {
409                // Forward pass
410                let prediction: f32 = feature_vec.iter()
411                    .zip(weights.iter())
412                    .map(|(f, w)| f * w)
413                    .sum();
414                    
415                let error = prediction - target;
416                total_loss += error * error;
417                
418                // Backward pass
419                for (i, feature) in feature_vec.iter().enumerate() {
420                    gradients[i] += error * feature;
421                }
422            }
423            
424            // Update weights with L2 regularization
425            for (i, weight) in weights.iter_mut().enumerate() {
426                *weight -= learning_rate * (gradients[i] / features.len() as f32 + l2_reg * *weight);
427            }
428        }
429        
430        Ok(weights)
431    }
432    
433    async fn train_isotonic_regressors(&self, training_samples: &[TrainingSample]) -> Result<()> {
434        // Collect (score, relevance) pairs
435        let mut score_relevance_pairs = Vec::new();
436        
437        for sample in training_samples {
438            let reranked = self.rerank(&sample.query, sample.results.clone()).await?;
439            
440            for (result, relevance) in reranked.iter().zip(sample.relevance_scores.iter()) {
441                score_relevance_pairs.push((result.rerank_score, *relevance));
442            }
443        }
444        
445        // Sort by score for isotonic regression
446        score_relevance_pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
447        
448        // Train isotonic regressor
449        let regressor = IsotonicRegressor::fit(&score_relevance_pairs)?;
450        
451        let mut models = self.isotonic_models.write().await;
452        models.insert("global".to_string(), regressor);
453        
454        Ok(())
455    }
456    
457    async fn update_training_metrics(&self, _training_samples: &[TrainingSample]) -> Result<()> {
458        // Update training metrics
459        let mut metrics = self.metrics.write().await;
460        metrics.samples_processed += _training_samples.len();
461        
462        Ok(())
463    }
464    
465    fn calculate_ndcg(&self, results: &[SearchResult], relevances: &[f32]) -> f32 {
466        if results.is_empty() || relevances.is_empty() {
467            return 0.0;
468        }
469        
470        // Calculate DCG
471        let mut dcg = 0.0;
472        for (i, rel) in relevances.iter().take(results.len()).enumerate() {
473            let discount = (i as f32 + 2.0).log2();
474            dcg += (2.0_f32.powf(*rel) - 1.0) / discount;
475        }
476        
477        // Calculate IDCG (ideal DCG)
478        let mut sorted_relevances = relevances.to_vec();
479        sorted_relevances.sort_by(|a, b| b.partial_cmp(a).unwrap());
480        
481        let mut idcg = 0.0;
482        for (i, rel) in sorted_relevances.iter().take(results.len()).enumerate() {
483            let discount = (i as f32 + 2.0).log2();
484            idcg += (2.0_f32.powf(*rel) - 1.0) / discount;
485        }
486        
487        if idcg > 0.0 { dcg / idcg } else { 0.0 }
488    }
489    
490    fn calculate_ndcg_reranked(&self, results: &[RerankedResult], relevances: &[f32]) -> f32 {
491        if results.is_empty() || relevances.is_empty() {
492            return 0.0;
493        }
494        
495        // Map result IDs to relevances
496        let mut id_to_relevance = HashMap::new();
497        for (i, result) in results.iter().enumerate() {
498            if i < relevances.len() {
499                id_to_relevance.insert(result.result.id.clone(), relevances[i]);
500            }
501        }
502        
503        // Calculate DCG for reranked order
504        let mut dcg = 0.0;
505        for (i, result) in results.iter().enumerate() {
506            if let Some(rel) = id_to_relevance.get(&result.result.id) {
507                let discount = (i as f32 + 2.0).log2();
508                dcg += (2.0_f32.powf(*rel) - 1.0) / discount;
509            }
510        }
511        
512        // Calculate IDCG
513        let mut sorted_relevances = relevances.to_vec();
514        sorted_relevances.sort_by(|a, b| b.partial_cmp(a).unwrap());
515        
516        let mut idcg = 0.0;
517        for (i, rel) in sorted_relevances.iter().take(results.len()).enumerate() {
518            let discount = (i as f32 + 2.0).log2();
519            idcg += (2.0_f32.powf(*rel) - 1.0) / discount;
520        }
521        
522        if idcg > 0.0 { dcg / idcg } else { 0.0 }
523    }
524    
525    fn calculate_precision_at_k(&self, _results: &[SearchResult], relevances: &[f32], k: usize) -> f32 {
526        let relevant_count = relevances.iter()
527            .take(k)
528            .filter(|&&r| r > 0.5) // Threshold for relevance
529            .count();
530            
531        relevant_count as f32 / k.min(relevances.len()) as f32
532    }
533    
534    fn calculate_precision_at_k_reranked(&self, _results: &[RerankedResult], relevances: &[f32], k: usize) -> f32 {
535        // For simplicity, assume same order as input relevances
536        // Real implementation would map reranked results to relevances
537        self.calculate_precision_at_k(&[], relevances, k)
538    }
539    
540    async fn calculate_calibration_error(&self, _test_samples: &[TrainingSample]) -> Result<f32> {
541        // Expected Calibration Error calculation
542        // For now, return mock value
543        Ok(0.05)
544    }
545    
546    async fn get_feature_importances(&self) -> HashMap<String, f32> {
547        let mut importances = HashMap::new();
548        
549        // Get all feature names
550        let mut feature_names = Vec::new();
551        for extractor in &self.feature_extractors {
552            feature_names.extend(extractor.feature_names());
553        }
554        
555        // Get weights if available
556        if let Some(weights) = self.model_weights.read().await.as_ref() {
557            for (name, weight) in feature_names.iter().zip(weights.iter()) {
558                importances.insert(name.clone(), weight.abs());
559            }
560        }
561        
562        importances
563    }
564}
565
566// Feature extractors implementation
567
568struct LexicalFeatureExtractor;
569struct SemanticFeatureExtractor;
570struct StructuralFeatureExtractor;
571struct LSPFeatureExtractor;
572
573impl LexicalFeatureExtractor {
574    fn new() -> Self { Self }
575}
576
577impl FeatureExtractor for LexicalFeatureExtractor {
578    fn extract_features(&self, query: &str, result: &SearchResult) -> Result<Vec<f32>> {
579        let mut features = Vec::new();
580        
581        // BM25-like features
582        features.push(result.lexical_score);
583        
584        // Query term matches
585        let query_terms: Vec<&str> = query.split_whitespace().collect();
586        let content_lower = result.content.to_lowercase();
587        let query_matches = query_terms.iter()
588            .filter(|term| content_lower.contains(&term.to_lowercase()))
589            .count() as f32 / query_terms.len() as f32;
590        features.push(query_matches);
591        
592        // Length features
593        features.push((result.content.len() as f32).log10());
594        
595        Ok(features)
596    }
597    
598    fn feature_names(&self) -> Vec<String> {
599        vec![
600            "lexical_score".to_string(),
601            "query_match_ratio".to_string(), 
602            "log_content_length".to_string(),
603        ]
604    }
605}
606
607impl SemanticFeatureExtractor {
608    fn new() -> Self { Self }
609}
610
611impl FeatureExtractor for SemanticFeatureExtractor {
612    fn extract_features(&self, _query: &str, result: &SearchResult) -> Result<Vec<f32>> {
613        let mut features = Vec::new();
614        
615        // Semantic score if available
616        features.push(result.semantic_score.unwrap_or(0.0));
617        
618        // Placeholder semantic features
619        features.push(0.5); // Semantic similarity placeholder
620        features.push(0.3); // Context relevance placeholder
621        
622        Ok(features)
623    }
624    
625    fn feature_names(&self) -> Vec<String> {
626        vec![
627            "semantic_score".to_string(),
628            "semantic_similarity".to_string(),
629            "context_relevance".to_string(),
630        ]
631    }
632}
633
634impl StructuralFeatureExtractor {
635    fn new() -> Self { Self }
636}
637
638impl FeatureExtractor for StructuralFeatureExtractor {
639    fn extract_features(&self, _query: &str, result: &SearchResult) -> Result<Vec<f32>> {
640        let mut features = Vec::new();
641        
642        // File type features
643        let is_source_file = result.file_path.ends_with(".rs") || 
644                           result.file_path.ends_with(".py") ||
645                           result.file_path.ends_with(".ts");
646        features.push(if is_source_file { 1.0 } else { 0.0 });
647        
648        // Code structure features (placeholder)
649        features.push(0.4); // Function density
650        features.push(0.6); // Comment ratio
651        
652        Ok(features)
653    }
654    
655    fn feature_names(&self) -> Vec<String> {
656        vec![
657            "is_source_file".to_string(),
658            "function_density".to_string(),
659            "comment_ratio".to_string(),
660        ]
661    }
662}
663
664impl LSPFeatureExtractor {
665    fn new() -> Self { Self }
666}
667
668impl FeatureExtractor for LSPFeatureExtractor {
669    fn extract_features(&self, _query: &str, result: &SearchResult) -> Result<Vec<f32>> {
670        let mut features = Vec::new();
671        
672        // LSP score if available
673        features.push(result.lsp_score.unwrap_or(0.0));
674        
675        // Symbol-based features (placeholder)
676        features.push(0.7); // Symbol match strength
677        features.push(0.2); // Reference density
678        
679        Ok(features)
680    }
681    
682    fn feature_names(&self) -> Vec<String> {
683        vec![
684            "lsp_score".to_string(),
685            "symbol_match_strength".to_string(),
686            "reference_density".to_string(),
687        ]
688    }
689}
690
691// Helper structs
692
693struct FeatureResult {
694    result: SearchResult,
695    features: Vec<f32>,
696}
697
698impl IsotonicRegressor {
699    /// Fit isotonic regressor on (x, y) pairs
700    fn fit(data: &[(f32, f32)]) -> Result<Self> {
701        if data.is_empty() {
702            anyhow::bail!("Cannot fit isotonic regressor on empty data");
703        }
704        
705        // For now, use simple binning approach
706        // Real implementation would use proper isotonic regression algorithm
707        let mut x_values = Vec::new();
708        let mut y_values = Vec::new();
709        
710        // Group into bins and average
711        let bin_size = (data.len() / 10).max(1);
712        for chunk in data.chunks(bin_size) {
713            let avg_x = chunk.iter().map(|(x, _)| *x).sum::<f32>() / chunk.len() as f32;
714            let avg_y = chunk.iter().map(|(_, y)| *y).sum::<f32>() / chunk.len() as f32;
715            
716            x_values.push(avg_x);
717            y_values.push(avg_y);
718        }
719        
720        Ok(Self {
721            x_values,
722            y_values,
723            sample_count: data.len(),
724        })
725    }
726    
727    /// Predict calibrated score
728    fn predict(&self, x: f32) -> f32 {
729        if self.x_values.is_empty() {
730            return x; // No calibration available
731        }
732        
733        // Linear interpolation between points
734        for i in 0..self.x_values.len() - 1 {
735            if x >= self.x_values[i] && x <= self.x_values[i + 1] {
736                let t = (x - self.x_values[i]) / (self.x_values[i + 1] - self.x_values[i]);
737                return self.y_values[i] + t * (self.y_values[i + 1] - self.y_values[i]);
738            }
739        }
740        
741        // Extrapolate beyond bounds
742        if x < self.x_values[0] {
743            self.y_values[0]
744        } else {
745            self.y_values[self.y_values.len() - 1]
746        }
747    }
748}
749
750impl Default for RerankMetrics {
751    fn default() -> Self {
752        Self {
753            samples_processed: 0,
754            avg_ndcg_improvement: 0.0,
755            avg_precision_improvement: 0.0,
756            avg_recall_improvement: 0.0,
757            calibration_error: 0.0,
758            feature_importances: HashMap::new(),
759        }
760    }
761}
762
763/// Initialize learned reranker
764pub async fn initialize_reranker(config: &RerankConfig) -> Result<()> {
765    info!("Initializing learned reranker");
766    info!("Config: top-{}, isotonic={}, learning_rate={}", 
767          config.top_k, config.use_isotonic, config.learning_rate);
768    
769    // Validate configuration
770    if config.top_k == 0 {
771        anyhow::bail!("Top-K cannot be zero");
772    }
773    
774    if config.learning_rate <= 0.0 || config.learning_rate > 1.0 {
775        anyhow::bail!("Invalid learning rate: {}", config.learning_rate);
776    }
777    
778    info!("Learned reranker initialized");
779    Ok(())
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[tokio::test]
787    async fn test_reranker_creation() {
788        let config = RerankConfig {
789            top_k: 100,
790            use_isotonic: true,
791            learning_rate: 0.01,
792            l2_regularization: 0.001,
793            min_training_samples: 10,
794            combination_strategy: CombinationStrategy::Linear,
795        };
796        
797        let reranker = LearnedReranker::new(config).await.unwrap();
798        let metrics = reranker.get_metrics().await;
799        assert_eq!(metrics.samples_processed, 0);
800    }
801
802    #[test]
803    fn test_isotonic_regressor() {
804        let data = vec![
805            (0.1, 0.2),
806            (0.3, 0.4), 
807            (0.5, 0.6),
808            (0.7, 0.8),
809        ];
810        
811        let regressor = IsotonicRegressor::fit(&data).unwrap();
812        
813        // Test interpolation
814        let pred = regressor.predict(0.4);
815        assert!(pred > 0.4 && pred < 0.6); // Should interpolate
816        
817        // Test extrapolation
818        let pred_low = regressor.predict(0.0);
819        let pred_high = regressor.predict(1.0);
820        assert!(pred_low >= 0.0);
821        assert!(pred_high >= 0.0);
822    }
823
824    #[test]
825    fn test_feature_extractors() {
826        let extractor = LexicalFeatureExtractor::new();
827        let result = SearchResult {
828            id: "test".to_string(),
829            content: "def hello_world(): return 'hello'".to_string(),
830            file_path: "test.py".to_string(),
831            initial_score: 0.8,
832            lexical_score: 0.9,
833            semantic_score: Some(0.7),
834            lsp_score: Some(0.6),
835            metadata: HashMap::new(),
836        };
837        
838        let features = extractor.extract_features("hello", &result).unwrap();
839        assert_eq!(features.len(), 3); // Based on feature_names count
840        
841        let names = extractor.feature_names();
842        assert_eq!(names.len(), 3);
843    }
844
845    #[test]
846    fn test_ndcg_calculation() {
847        let reranker = LearnedReranker {
848            config: RerankConfig {
849                top_k: 10,
850                use_isotonic: false,
851                learning_rate: 0.01,
852                l2_regularization: 0.001,
853                min_training_samples: 1,
854                combination_strategy: CombinationStrategy::Linear,
855            },
856            feature_extractors: Vec::new(),
857            isotonic_models: Arc::new(RwLock::new(HashMap::new())),
858            model_weights: Arc::new(RwLock::new(None)),
859            metrics: Arc::new(RwLock::new(RerankMetrics::default())),
860        };
861        
862        let results = vec![]; // Mock results
863        let relevances = vec![1.0, 0.8, 0.6, 0.4, 0.2];
864        
865        let ndcg = reranker.calculate_ndcg(&results, &relevances);
866        assert!(ndcg >= 0.0 && ndcg <= 1.0);
867    }
868}