1use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use tracing::{debug, info, warn};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LTRConfig {
18 pub objective: LTRObjective,
20 pub max_log_odds_delta: f32,
22 pub monotonic_increasing: Vec<String>,
24 pub monotonic_decreasing: Vec<String>,
26 pub hard_negative_ratio: f32,
28 pub learning_rate: f32,
30 pub l2_lambda: f32,
32 pub max_iterations: usize,
34 pub cv_folds: usize,
36 pub patience: usize,
38 pub seed: u64,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub enum LTRObjective {
44 PairwiseLogistic,
46 LambdaMART,
48}
49
50#[derive(Debug, Clone)]
52pub struct TrainingSample {
53 pub query_id: String,
54 pub repo_id: String, pub intent: String, pub language: String, pub query_text: String,
58 pub documents: Vec<DocumentFeatures>,
59 pub relevance_labels: Vec<f32>, }
61
62#[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#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct BoundedLTRModel {
73 pub weights: Vec<f32>,
75 pub feature_names: Vec<String>,
77 pub monotonic_constraints: HashMap<String, MonotonicConstraint>,
79 pub metadata: LTRModelMetadata,
81 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#[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
112pub 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 pub fn new(config: LTRConfig) -> Self {
138 Self { config }
139 }
140
141 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 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 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; }
163
164 let monotonic_constraints = self.build_monotonic_constraints_map(&all_feature_names);
166
167 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 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; } 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 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 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 weights[k] = weights[k].clamp(-self.config.max_log_odds_delta, self.config.max_log_odds_delta);
217
218 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 => {}, }
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 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 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, 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 pub async fn add_training_data(&mut self, qrel_path: &str) -> Result<()> {
274 info!("Loading training data from {}", qrel_path);
275 warn!("Mock training data - implement qrels parsing for production");
278 Ok(())
279 }
280
281 pub async fn load_feature_spec(&mut self, spec_path: &str) -> Result<()> {
283 info!("Loading feature specification from {}", spec_path);
284 warn!("Mock feature spec - implement feature spec loading for production");
286 Ok(())
287 }
288
289 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 warn!("Mock hard negatives - implement SymbolGraph integration for production");
294 Ok(())
295 }
296
297 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 let training_samples = self.create_mock_training_samples()?;
303
304 let model = self.train(&training_samples).await?;
306
307 let json_value = serde_json::to_value(&model)
309 .context("Failed to serialize trained model")?;
310
311 Ok(json_value)
312 }
313
314 pub fn get_monotonic_increasing(&self) -> &[String] {
316 &self.config.monotonic_increasing
317 }
318
319 pub async fn generate_training_report(&self) -> Result<TrainingReport> {
321 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, })
330 }
331
332 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 for weight in weights {
364 hasher.update(weight.to_le_bytes());
365 }
366
367 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()) }
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 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), 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], };
428 samples.push(sample);
429 }
430
431 Ok(samples)
432 }
433}
434
435#[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]); }
543 }
544
545 #[tokio::test]
546 async fn test_ltr_trainer_training() {
547 let config = LTRConfig {
548 max_iterations: 50, ..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); 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 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 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}