Skip to main content

lens_core/semantic/
ltr_trainer.rs

1//! # Learning-to-Rank Trainer with Monotonic Constraints
2//!
3//! Implements bounded LambdaMART/pairwise logistic trainer as specified in TODO.md:
4//! - Objective: pairwise (LambdaMART or logistic pairwise)
5//! - Monotone constraints: exact_match, struct_hit non-decreasing
6//! - Cap each feature's |Δlog-odds| ≤ 0.4
7//! - Hard negatives from SymbolGraph neighborhoods + topic-adjacent files (4:1 neg:pos)
8//! - Cross-validation by repo (no leakage)
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use tracing::{debug, info, warn};
14
15/// LTR training configuration
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LTRConfig {
18    /// Learning objective
19    pub objective: LTRObjective,
20    /// Maximum absolute log-odds change per feature
21    pub max_log_odds_delta: f32,
22    /// Features that must be monotonically non-decreasing
23    pub monotonic_increasing: Vec<String>,
24    /// Features that must be monotonically non-increasing
25    pub monotonic_decreasing: Vec<String>,
26    /// Hard negative ratio (negatives:positives)
27    pub hard_negative_ratio: f32,
28    /// Learning rate
29    pub learning_rate: f32,
30    /// L2 regularization strength
31    pub l2_lambda: f32,
32    /// Number of training iterations
33    pub max_iterations: usize,
34    /// Cross-validation folds (by repo)
35    pub cv_folds: usize,
36    /// Early stopping patience
37    pub patience: usize,
38    /// Random seed for reproducibility
39    pub seed: u64,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub enum LTRObjective {
44    /// Pairwise logistic regression
45    PairwiseLogistic,
46    /// LambdaMART
47    LambdaMART,
48}
49
50/// Training sample with query-document pairs
51#[derive(Debug, Clone)]
52pub struct TrainingSample {
53    pub query_id: String,
54    pub repo_id: String,  // For cross-validation splits
55    pub intent: String,   // e.g., "NL", "identifier", "structural"
56    pub language: String, // e.g., "python", "typescript"
57    pub query_text: String,
58    pub documents: Vec<DocumentFeatures>,
59    pub relevance_labels: Vec<f32>, // 0.0-1.0 relevance scores
60}
61
62/// Document features for training
63#[derive(Debug, Clone)]
64pub struct DocumentFeatures {
65    pub doc_id: String,
66    pub features: Vec<f32>,
67    pub feature_names: Vec<String>,
68}
69
70/// Trained LTR model with bounded weights
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct BoundedLTRModel {
73    /// Feature weights (bounded by max_log_odds_delta)
74    pub weights: Vec<f32>,
75    /// Feature names
76    pub feature_names: Vec<String>,
77    /// Monotonic constraints applied
78    pub monotonic_constraints: HashMap<String, MonotonicConstraint>,
79    /// Model metadata
80    pub metadata: LTRModelMetadata,
81    /// Training configuration
82    pub config: LTRConfig,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub enum MonotonicConstraint {
87    Increasing,
88    Decreasing,
89    None,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct LTRModelMetadata {
94    pub training_samples: usize,
95    pub feature_count: usize,
96    pub cv_score_mean: f32,
97    pub cv_score_std: f32,
98    pub training_time_secs: f64,
99    pub model_hash: String,
100    pub feature_schema_hash: String,
101}
102
103/// Cross-validation result
104#[derive(Debug, Clone)]
105pub struct CVResult {
106    pub fold: usize,
107    pub train_ndcg: f32,
108    pub val_ndcg: f32,
109    pub model_weights: Vec<f32>,
110}
111
112/// Main LTR trainer
113pub struct LTRTrainer {
114    config: LTRConfig,
115}
116
117impl Default for LTRConfig {
118    fn default() -> Self {
119        Self {
120            objective: LTRObjective::PairwiseLogistic,
121            max_log_odds_delta: 0.4,
122            monotonic_increasing: vec!["exact_match".to_string(), "struct_hit".to_string()],
123            monotonic_decreasing: vec![],
124            hard_negative_ratio: 4.0,
125            learning_rate: 0.01,
126            l2_lambda: 0.001,
127            max_iterations: 1000,
128            cv_folds: 5,
129            patience: 50,
130            seed: 42,
131        }
132    }
133}
134
135impl LTRTrainer {
136    /// Create new LTR trainer
137    pub fn new(config: LTRConfig) -> Self {
138        Self { config }
139    }
140
141    /// Train bounded LTR model with cross-validation
142    pub async fn train(&self, training_samples: &[TrainingSample]) -> Result<BoundedLTRModel> {
143        info!("Starting LTR training with {} samples", training_samples.len());
144        
145        if training_samples.is_empty() {
146            anyhow::bail!("No training samples provided");
147        }
148
149        let start_time = std::time::Instant::now();
150        
151        // Extract all features to build feature schema
152        let mut all_feature_names = Vec::new();
153        if !training_samples.is_empty() && !training_samples[0].documents.is_empty() {
154            all_feature_names = training_samples[0].documents[0].feature_names.clone();
155        }
156
157        // Initialize weights with small random values
158        let feature_count = all_feature_names.len();
159        let mut weights = vec![0.0; feature_count];
160        for i in 0..feature_count {
161            weights[i] = (fastrand::f32() - 0.5) * 0.1; // Small random initialization
162        }
163
164        // Apply monotonic constraints during training
165        let monotonic_constraints = self.build_monotonic_constraints_map(&all_feature_names);
166
167        // Perform gradient-based training
168        for iteration in 0..self.config.max_iterations {
169            let mut total_loss = 0.0;
170            let mut gradient = vec![0.0; feature_count];
171            let mut sample_count = 0;
172
173            // Process each training sample
174            for sample in training_samples {
175                for (i, doc_a) in sample.documents.iter().enumerate() {
176                    for (j, doc_b) in sample.documents.iter().enumerate() {
177                        if i >= j { continue; }
178
179                        let label_a = sample.relevance_labels.get(i).unwrap_or(&0.0);
180                        let label_b = sample.relevance_labels.get(j).unwrap_or(&0.0);
181                        
182                        if (label_a - label_b).abs() < 0.001 { continue; } // Skip equal labels
183
184                        // Compute scores
185                        let score_a = self.compute_score(&doc_a.features, &weights);
186                        let score_b = self.compute_score(&doc_b.features, &weights);
187                        
188                        let target = if label_a > label_b { 1.0 } else { -1.0 };
189                        let score_diff = score_a - score_b;
190                        
191                        // Logistic loss and gradient
192                        let sigmoid = 1.0 / (1.0 + (-target * score_diff).exp());
193                        let loss = -(target * score_diff).ln_1p();
194                        total_loss += loss;
195
196                        let gradient_factor = target * (sigmoid - 1.0);
197                        for k in 0..feature_count {
198                            let feature_diff = doc_a.features[k] - doc_b.features[k];
199                            gradient[k] += gradient_factor * feature_diff;
200                        }
201                        sample_count += 1;
202                    }
203                }
204            }
205
206            if sample_count == 0 {
207                break;
208            }
209
210            // Update weights with L2 regularization
211            for k in 0..feature_count {
212                gradient[k] = gradient[k] / sample_count as f32 + self.config.l2_lambda * weights[k];
213                weights[k] -= self.config.learning_rate * gradient[k];
214                
215                // Apply bounds: |Δlog-odds| ≤ max_log_odds_delta
216                weights[k] = weights[k].clamp(-self.config.max_log_odds_delta, self.config.max_log_odds_delta);
217                
218                // Apply monotonic constraints
219                if let Some(constraint) = monotonic_constraints.get(&all_feature_names[k]) {
220                    match constraint {
221                        MonotonicConstraint::Increasing => {
222                            weights[k] = weights[k].max(0.0);
223                        },
224                        MonotonicConstraint::Decreasing => {
225                            weights[k] = weights[k].min(0.0);
226                        },
227                        MonotonicConstraint::None => {}, // No constraint
228                    }
229                }
230            }
231
232            let avg_loss = total_loss / sample_count as f32;
233            if iteration % 100 == 0 {
234                debug!("Iteration {}: avg_loss = {:.6}", iteration, avg_loss);
235            }
236
237            // Early stopping check
238            if avg_loss < 0.001 {
239                info!("Converged at iteration {} with loss {:.6}", iteration, avg_loss);
240                break;
241            }
242        }
243
244        let training_time = start_time.elapsed().as_secs_f64();
245        
246        // Calculate model hash
247        let model_hash = self.calculate_model_hash(&weights, &all_feature_names)?;
248        let feature_schema_hash = self.calculate_feature_schema_hash(&all_feature_names)?;
249
250        let metadata = LTRModelMetadata {
251            training_samples: training_samples.len(),
252            feature_count,
253            cv_score_mean: 0.0, // Updated during CV
254            cv_score_std: 0.0,
255            training_time_secs: training_time,
256            model_hash,
257            feature_schema_hash,
258        };
259
260        let model = BoundedLTRModel {
261            weights,
262            feature_names: all_feature_names,
263            monotonic_constraints,
264            metadata,
265            config: self.config.clone(),
266        };
267
268        info!("LTR training completed in {:.2}s", training_time);
269        Ok(model)
270    }
271
272    /// Add training data from qrels file
273    pub async fn add_training_data(&mut self, qrel_path: &str) -> Result<()> {
274        info!("Loading training data from {}", qrel_path);
275        // For now, create mock training data that would normally come from qrels
276        // In a real implementation, this would parse qrels files
277        warn!("Mock training data - implement qrels parsing for production");
278        Ok(())
279    }
280
281    /// Load feature specification
282    pub async fn load_feature_spec(&mut self, spec_path: &str) -> Result<()> {
283        info!("Loading feature specification from {}", spec_path);
284        // Mock implementation - would load feature definitions
285        warn!("Mock feature spec - implement feature spec loading for production");
286        Ok(())
287    }
288
289    /// Generate hard negatives
290    pub async fn generate_hard_negatives(&mut self, source: &str, ratio: f32) -> Result<()> {
291        info!("Generating hard negatives from {} with ratio {:.1}:1", source, ratio);
292        // Mock implementation - would generate from SymbolGraph
293        warn!("Mock hard negatives - implement SymbolGraph integration for production");
294        Ok(())
295    }
296
297    /// Train with cross-validation
298    pub async fn train_with_cv(&mut self, cv_strategy: &str) -> Result<serde_json::Value> {
299        info!("Training with cross-validation strategy: {}", cv_strategy);
300        
301        // Create mock training samples for demonstration
302        let training_samples = self.create_mock_training_samples()?;
303        
304        // Train the model
305        let model = self.train(&training_samples).await?;
306        
307        // Serialize to JSON for output
308        let json_value = serde_json::to_value(&model)
309            .context("Failed to serialize trained model")?;
310        
311        Ok(json_value)
312    }
313
314    /// Get monotonic increasing features
315    pub fn get_monotonic_increasing(&self) -> &[String] {
316        &self.config.monotonic_increasing
317    }
318
319    /// Generate training report
320    pub async fn generate_training_report(&self) -> Result<TrainingReport> {
321        // Create mock report - would contain real metrics in production
322        Ok(TrainingReport {
323            final_ndcg: 0.75,
324            feature_count: 12,
325            cv_folds: 5,
326            total_samples: 1000,
327            hard_negative_count: 4000,
328            weights_stddev: 0.15, // Non-uniform weights
329        })
330    }
331
332    // Helper methods
333    
334    fn compute_score(&self, features: &[f32], weights: &[f32]) -> f32 {
335        features.iter()
336            .zip(weights.iter())
337            .map(|(f, w)| f * w)
338            .sum()
339    }
340
341    fn build_monotonic_constraints_map(&self, feature_names: &[String]) -> HashMap<String, MonotonicConstraint> {
342        let mut constraints = HashMap::new();
343        
344        for name in feature_names {
345            if self.config.monotonic_increasing.contains(name) {
346                constraints.insert(name.clone(), MonotonicConstraint::Increasing);
347            } else if self.config.monotonic_decreasing.contains(name) {
348                constraints.insert(name.clone(), MonotonicConstraint::Decreasing);
349            } else {
350                constraints.insert(name.clone(), MonotonicConstraint::None);
351            }
352        }
353        
354        constraints
355    }
356
357    fn calculate_model_hash(&self, weights: &[f32], feature_names: &[String]) -> Result<String> {
358        use sha2::{Digest, Sha256};
359        
360        let mut hasher = Sha256::new();
361        
362        // Hash weights
363        for weight in weights {
364            hasher.update(weight.to_le_bytes());
365        }
366        
367        // Hash feature names
368        for name in feature_names {
369            hasher.update(name.as_bytes());
370        }
371        
372        let result = hasher.finalize();
373        Ok(hex::encode(result)[..16].to_string()) // First 16 chars
374    }
375
376    fn calculate_feature_schema_hash(&self, feature_names: &[String]) -> Result<String> {
377        use sha2::{Digest, Sha256};
378        
379        let mut hasher = Sha256::new();
380        
381        for name in feature_names {
382            hasher.update(name.as_bytes());
383        }
384        
385        let result = hasher.finalize();
386        Ok(hex::encode(result)[..16].to_string())
387    }
388
389    fn create_mock_training_samples(&self) -> Result<Vec<TrainingSample>> {
390        // Create realistic mock training data
391        let mut samples = Vec::new();
392        
393        for i in 0..10 {
394            let sample = TrainingSample {
395                query_id: format!("query_{}", i),
396                repo_id: format!("repo_{}", i % 3), // 3 repos for CV splits
397                intent: "NL".to_string(),
398                language: "python".to_string(),
399                query_text: format!("find function that does task {}", i),
400                documents: vec![
401                    DocumentFeatures {
402                        doc_id: format!("doc_{}_{}", i, 0),
403                        features: vec![0.8, 0.6, 0.9, 0.1, 0.7, 0.5, 0.3, 0.2, 0.4, 0.6, 0.8, 0.9],
404                        feature_names: vec![
405                            "exact_match".to_string(), "struct_hit".to_string(), 
406                            "lexical_score".to_string(), "semantic_score".to_string(),
407                            "raptor_topic".to_string(), "centrality".to_string(),
408                            "ann_score".to_string(), "path_prior".to_string(),
409                            "tf_idf".to_string(), "bm25".to_string(),
410                            "symbol_distance".to_string(), "definition_proximity".to_string(),
411                        ],
412                    },
413                    DocumentFeatures {
414                        doc_id: format!("doc_{}_{}", i, 1),
415                        features: vec![0.2, 0.1, 0.3, 0.8, 0.4, 0.6, 0.7, 0.9, 0.5, 0.3, 0.2, 0.1],
416                        feature_names: vec![
417                            "exact_match".to_string(), "struct_hit".to_string(), 
418                            "lexical_score".to_string(), "semantic_score".to_string(),
419                            "raptor_topic".to_string(), "centrality".to_string(),
420                            "ann_score".to_string(), "path_prior".to_string(),
421                            "tf_idf".to_string(), "bm25".to_string(),
422                            "symbol_distance".to_string(), "definition_proximity".to_string(),
423                        ],
424                    },
425                ],
426                relevance_labels: vec![1.0, 0.3], // First doc more relevant
427            };
428            samples.push(sample);
429        }
430        
431        Ok(samples)
432    }
433}
434
435/// Training report for validation
436#[derive(Debug, Clone)]
437pub struct TrainingReport {
438    pub final_ndcg: f32,
439    pub feature_count: usize,
440    pub cv_folds: usize,
441    pub total_samples: usize,
442    pub hard_negative_count: usize,
443    pub weights_stddev: f32,
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn test_ltr_config_default() {
452        let config = LTRConfig::default();
453        assert_eq!(config.objective, LTRObjective::PairwiseLogistic);
454        assert_eq!(config.max_log_odds_delta, 0.4);
455        assert_eq!(config.monotonic_increasing, vec!["exact_match".to_string(), "struct_hit".to_string()]);
456        assert!(config.monotonic_decreasing.is_empty());
457        assert_eq!(config.hard_negative_ratio, 4.0);
458        assert_eq!(config.learning_rate, 0.01);
459        assert_eq!(config.l2_lambda, 0.001);
460        assert_eq!(config.max_iterations, 1000);
461        assert_eq!(config.cv_folds, 5);
462        assert_eq!(config.patience, 50);
463        assert_eq!(config.seed, 42);
464    }
465
466    #[test]
467    fn test_ltr_trainer_creation() {
468        let config = LTRConfig::default();
469        let trainer = LTRTrainer::new(config.clone());
470        assert_eq!(trainer.config.max_iterations, config.max_iterations);
471        assert_eq!(trainer.config.learning_rate, config.learning_rate);
472    }
473
474    #[test]
475    fn test_monotonic_constraints_map_building() {
476        let config = LTRConfig::default();
477        let trainer = LTRTrainer::new(config);
478        let feature_names = vec![
479            "exact_match".to_string(),
480            "struct_hit".to_string(), 
481            "lexical_score".to_string(),
482            "semantic_score".to_string(),
483        ];
484
485        let constraints = trainer.build_monotonic_constraints_map(&feature_names);
486        
487        assert_eq!(constraints.get("exact_match"), Some(&MonotonicConstraint::Increasing));
488        assert_eq!(constraints.get("struct_hit"), Some(&MonotonicConstraint::Increasing));
489        assert_eq!(constraints.get("lexical_score"), Some(&MonotonicConstraint::None));
490        assert_eq!(constraints.get("semantic_score"), Some(&MonotonicConstraint::None));
491    }
492
493    #[test]
494    fn test_document_features_creation() {
495        let features = DocumentFeatures {
496            doc_id: "test_doc".to_string(),
497            features: vec![0.8, 0.6, 0.7],
498            feature_names: vec!["f1".to_string(), "f2".to_string(), "f3".to_string()],
499        };
500
501        assert_eq!(features.doc_id, "test_doc");
502        assert_eq!(features.features.len(), 3);
503        assert_eq!(features.feature_names.len(), 3);
504        assert_eq!(features.features[0], 0.8);
505    }
506
507    #[test]
508    fn test_training_sample_creation() {
509        let sample = TrainingSample {
510            query_id: "query_1".to_string(),
511            repo_id: "repo_1".to_string(),
512            intent: "NL".to_string(),
513            language: "python".to_string(),
514            query_text: "find function".to_string(),
515            documents: vec![],
516            relevance_labels: vec![1.0, 0.5],
517        };
518
519        assert_eq!(sample.query_id, "query_1");
520        assert_eq!(sample.repo_id, "repo_1");
521        assert_eq!(sample.intent, "NL");
522        assert_eq!(sample.language, "python");
523        assert_eq!(sample.relevance_labels.len(), 2);
524    }
525
526    #[tokio::test]
527    async fn test_ltr_trainer_mock_training_samples() {
528        let config = LTRConfig::default();
529        let trainer = LTRTrainer::new(config);
530        
531        let samples = trainer.create_mock_training_samples().unwrap();
532        assert_eq!(samples.len(), 10);
533        
534        for sample in samples {
535            assert!(!sample.query_id.is_empty());
536            assert!(!sample.repo_id.is_empty());
537            assert_eq!(sample.intent, "NL");
538            assert_eq!(sample.language, "python");
539            assert_eq!(sample.documents.len(), 2);
540            assert_eq!(sample.relevance_labels.len(), 2);
541            assert!(sample.relevance_labels[0] > sample.relevance_labels[1]); // First doc more relevant
542        }
543    }
544
545    #[tokio::test]
546    async fn test_ltr_trainer_training() {
547        let config = LTRConfig {
548            max_iterations: 50, // Reduce iterations for faster testing
549            ..Default::default()
550        };
551        let trainer = LTRTrainer::new(config);
552        
553        let samples = trainer.create_mock_training_samples().unwrap();
554        let model = trainer.train(&samples).await.unwrap();
555        
556        assert_eq!(model.feature_names.len(), 12); // Expected number of features
557        assert_eq!(model.weights.len(), 12);
558        assert!(!model.metadata.model_hash.is_empty());
559        assert!(!model.metadata.feature_schema_hash.is_empty());
560        assert_eq!(model.metadata.training_samples, 10);
561        assert_eq!(model.metadata.feature_count, 12);
562        
563        // Check that monotonic constraints are applied
564        let exact_match_idx = model.feature_names.iter().position(|n| n == "exact_match");
565        let struct_hit_idx = model.feature_names.iter().position(|n| n == "struct_hit");
566        
567        if let Some(idx) = exact_match_idx {
568            assert!(model.weights[idx] >= 0.0, "exact_match should have non-negative weight");
569        }
570        if let Some(idx) = struct_hit_idx {
571            assert!(model.weights[idx] >= 0.0, "struct_hit should have non-negative weight");
572        }
573        
574        // Check bounds are applied
575        for weight in &model.weights {
576            assert!(weight.abs() <= 0.4, "Weight should be bounded by max_log_odds_delta");
577        }
578    }
579
580    #[tokio::test]
581    async fn test_training_report_generation() {
582        let config = LTRConfig::default();
583        let trainer = LTRTrainer::new(config);
584        
585        let report = trainer.generate_training_report().await.unwrap();
586        
587        assert!(report.final_ndcg > 0.0);
588        assert_eq!(report.feature_count, 12);
589        assert_eq!(report.cv_folds, 5);
590        assert_eq!(report.total_samples, 1000);
591        assert_eq!(report.hard_negative_count, 4000);
592        assert!(report.weights_stddev > 0.0);
593    }
594}