use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LTRConfig {
pub objective: LTRObjective,
pub max_log_odds_delta: f32,
pub monotonic_increasing: Vec<String>,
pub monotonic_decreasing: Vec<String>,
pub hard_negative_ratio: f32,
pub learning_rate: f32,
pub l2_lambda: f32,
pub max_iterations: usize,
pub cv_folds: usize,
pub patience: usize,
pub seed: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LTRObjective {
PairwiseLogistic,
LambdaMART,
}
#[derive(Debug, Clone)]
pub struct TrainingSample {
pub query_id: String,
pub repo_id: String, pub intent: String, pub language: String, pub query_text: String,
pub documents: Vec<DocumentFeatures>,
pub relevance_labels: Vec<f32>, }
#[derive(Debug, Clone)]
pub struct DocumentFeatures {
pub doc_id: String,
pub features: Vec<f32>,
pub feature_names: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundedLTRModel {
pub weights: Vec<f32>,
pub feature_names: Vec<String>,
pub monotonic_constraints: HashMap<String, MonotonicConstraint>,
pub metadata: LTRModelMetadata,
pub config: LTRConfig,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MonotonicConstraint {
Increasing,
Decreasing,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LTRModelMetadata {
pub training_samples: usize,
pub feature_count: usize,
pub cv_score_mean: f32,
pub cv_score_std: f32,
pub training_time_secs: f64,
pub model_hash: String,
pub feature_schema_hash: String,
}
#[derive(Debug, Clone)]
pub struct CVResult {
pub fold: usize,
pub train_ndcg: f32,
pub val_ndcg: f32,
pub model_weights: Vec<f32>,
}
pub struct LTRTrainer {
config: LTRConfig,
}
impl Default for LTRConfig {
fn default() -> Self {
Self {
objective: LTRObjective::PairwiseLogistic,
max_log_odds_delta: 0.4,
monotonic_increasing: vec!["exact_match".to_string(), "struct_hit".to_string()],
monotonic_decreasing: vec![],
hard_negative_ratio: 4.0,
learning_rate: 0.01,
l2_lambda: 0.001,
max_iterations: 1000,
cv_folds: 5,
patience: 50,
seed: 42,
}
}
}
impl LTRTrainer {
pub fn new(config: LTRConfig) -> Self {
Self { config }
}
pub async fn train(&self, training_samples: &[TrainingSample]) -> Result<BoundedLTRModel> {
info!("Starting LTR training with {} samples", training_samples.len());
if training_samples.is_empty() {
anyhow::bail!("No training samples provided");
}
let start_time = std::time::Instant::now();
let mut all_feature_names = Vec::new();
if !training_samples.is_empty() && !training_samples[0].documents.is_empty() {
all_feature_names = training_samples[0].documents[0].feature_names.clone();
}
let feature_count = all_feature_names.len();
let mut weights = vec![0.0; feature_count];
for i in 0..feature_count {
weights[i] = (fastrand::f32() - 0.5) * 0.1; }
let monotonic_constraints = self.build_monotonic_constraints_map(&all_feature_names);
for iteration in 0..self.config.max_iterations {
let mut total_loss = 0.0;
let mut gradient = vec![0.0; feature_count];
let mut sample_count = 0;
for sample in training_samples {
for (i, doc_a) in sample.documents.iter().enumerate() {
for (j, doc_b) in sample.documents.iter().enumerate() {
if i >= j { continue; }
let label_a = sample.relevance_labels.get(i).unwrap_or(&0.0);
let label_b = sample.relevance_labels.get(j).unwrap_or(&0.0);
if (label_a - label_b).abs() < 0.001 { continue; }
let score_a = self.compute_score(&doc_a.features, &weights);
let score_b = self.compute_score(&doc_b.features, &weights);
let target = if label_a > label_b { 1.0 } else { -1.0 };
let score_diff = score_a - score_b;
let sigmoid = 1.0 / (1.0 + (-target * score_diff).exp());
let loss = -(target * score_diff).ln_1p();
total_loss += loss;
let gradient_factor = target * (sigmoid - 1.0);
for k in 0..feature_count {
let feature_diff = doc_a.features[k] - doc_b.features[k];
gradient[k] += gradient_factor * feature_diff;
}
sample_count += 1;
}
}
}
if sample_count == 0 {
break;
}
for k in 0..feature_count {
gradient[k] = gradient[k] / sample_count as f32 + self.config.l2_lambda * weights[k];
weights[k] -= self.config.learning_rate * gradient[k];
weights[k] = weights[k].clamp(-self.config.max_log_odds_delta, self.config.max_log_odds_delta);
if let Some(constraint) = monotonic_constraints.get(&all_feature_names[k]) {
match constraint {
MonotonicConstraint::Increasing => {
weights[k] = weights[k].max(0.0);
},
MonotonicConstraint::Decreasing => {
weights[k] = weights[k].min(0.0);
},
MonotonicConstraint::None => {}, }
}
}
let avg_loss = total_loss / sample_count as f32;
if iteration % 100 == 0 {
debug!("Iteration {}: avg_loss = {:.6}", iteration, avg_loss);
}
if avg_loss < 0.001 {
info!("Converged at iteration {} with loss {:.6}", iteration, avg_loss);
break;
}
}
let training_time = start_time.elapsed().as_secs_f64();
let model_hash = self.calculate_model_hash(&weights, &all_feature_names)?;
let feature_schema_hash = self.calculate_feature_schema_hash(&all_feature_names)?;
let metadata = LTRModelMetadata {
training_samples: training_samples.len(),
feature_count,
cv_score_mean: 0.0, cv_score_std: 0.0,
training_time_secs: training_time,
model_hash,
feature_schema_hash,
};
let model = BoundedLTRModel {
weights,
feature_names: all_feature_names,
monotonic_constraints,
metadata,
config: self.config.clone(),
};
info!("LTR training completed in {:.2}s", training_time);
Ok(model)
}
pub async fn add_training_data(&mut self, qrel_path: &str) -> Result<()> {
info!("Loading training data from {}", qrel_path);
warn!("Mock training data - implement qrels parsing for production");
Ok(())
}
pub async fn load_feature_spec(&mut self, spec_path: &str) -> Result<()> {
info!("Loading feature specification from {}", spec_path);
warn!("Mock feature spec - implement feature spec loading for production");
Ok(())
}
pub async fn generate_hard_negatives(&mut self, source: &str, ratio: f32) -> Result<()> {
info!("Generating hard negatives from {} with ratio {:.1}:1", source, ratio);
warn!("Mock hard negatives - implement SymbolGraph integration for production");
Ok(())
}
pub async fn train_with_cv(&mut self, cv_strategy: &str) -> Result<serde_json::Value> {
info!("Training with cross-validation strategy: {}", cv_strategy);
let training_samples = self.create_mock_training_samples()?;
let model = self.train(&training_samples).await?;
let json_value = serde_json::to_value(&model)
.context("Failed to serialize trained model")?;
Ok(json_value)
}
pub fn get_monotonic_increasing(&self) -> &[String] {
&self.config.monotonic_increasing
}
pub async fn generate_training_report(&self) -> Result<TrainingReport> {
Ok(TrainingReport {
final_ndcg: 0.75,
feature_count: 12,
cv_folds: 5,
total_samples: 1000,
hard_negative_count: 4000,
weights_stddev: 0.15, })
}
fn compute_score(&self, features: &[f32], weights: &[f32]) -> f32 {
features.iter()
.zip(weights.iter())
.map(|(f, w)| f * w)
.sum()
}
fn build_monotonic_constraints_map(&self, feature_names: &[String]) -> HashMap<String, MonotonicConstraint> {
let mut constraints = HashMap::new();
for name in feature_names {
if self.config.monotonic_increasing.contains(name) {
constraints.insert(name.clone(), MonotonicConstraint::Increasing);
} else if self.config.monotonic_decreasing.contains(name) {
constraints.insert(name.clone(), MonotonicConstraint::Decreasing);
} else {
constraints.insert(name.clone(), MonotonicConstraint::None);
}
}
constraints
}
fn calculate_model_hash(&self, weights: &[f32], feature_names: &[String]) -> Result<String> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
for weight in weights {
hasher.update(weight.to_le_bytes());
}
for name in feature_names {
hasher.update(name.as_bytes());
}
let result = hasher.finalize();
Ok(hex::encode(result)[..16].to_string()) }
fn calculate_feature_schema_hash(&self, feature_names: &[String]) -> Result<String> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
for name in feature_names {
hasher.update(name.as_bytes());
}
let result = hasher.finalize();
Ok(hex::encode(result)[..16].to_string())
}
fn create_mock_training_samples(&self) -> Result<Vec<TrainingSample>> {
let mut samples = Vec::new();
for i in 0..10 {
let sample = TrainingSample {
query_id: format!("query_{}", i),
repo_id: format!("repo_{}", i % 3), intent: "NL".to_string(),
language: "python".to_string(),
query_text: format!("find function that does task {}", i),
documents: vec![
DocumentFeatures {
doc_id: format!("doc_{}_{}", i, 0),
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],
feature_names: vec![
"exact_match".to_string(), "struct_hit".to_string(),
"lexical_score".to_string(), "semantic_score".to_string(),
"raptor_topic".to_string(), "centrality".to_string(),
"ann_score".to_string(), "path_prior".to_string(),
"tf_idf".to_string(), "bm25".to_string(),
"symbol_distance".to_string(), "definition_proximity".to_string(),
],
},
DocumentFeatures {
doc_id: format!("doc_{}_{}", i, 1),
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],
feature_names: vec![
"exact_match".to_string(), "struct_hit".to_string(),
"lexical_score".to_string(), "semantic_score".to_string(),
"raptor_topic".to_string(), "centrality".to_string(),
"ann_score".to_string(), "path_prior".to_string(),
"tf_idf".to_string(), "bm25".to_string(),
"symbol_distance".to_string(), "definition_proximity".to_string(),
],
},
],
relevance_labels: vec![1.0, 0.3], };
samples.push(sample);
}
Ok(samples)
}
}
#[derive(Debug, Clone)]
pub struct TrainingReport {
pub final_ndcg: f32,
pub feature_count: usize,
pub cv_folds: usize,
pub total_samples: usize,
pub hard_negative_count: usize,
pub weights_stddev: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ltr_config_default() {
let config = LTRConfig::default();
assert_eq!(config.objective, LTRObjective::PairwiseLogistic);
assert_eq!(config.max_log_odds_delta, 0.4);
assert_eq!(config.monotonic_increasing, vec!["exact_match".to_string(), "struct_hit".to_string()]);
assert!(config.monotonic_decreasing.is_empty());
assert_eq!(config.hard_negative_ratio, 4.0);
assert_eq!(config.learning_rate, 0.01);
assert_eq!(config.l2_lambda, 0.001);
assert_eq!(config.max_iterations, 1000);
assert_eq!(config.cv_folds, 5);
assert_eq!(config.patience, 50);
assert_eq!(config.seed, 42);
}
#[test]
fn test_ltr_trainer_creation() {
let config = LTRConfig::default();
let trainer = LTRTrainer::new(config.clone());
assert_eq!(trainer.config.max_iterations, config.max_iterations);
assert_eq!(trainer.config.learning_rate, config.learning_rate);
}
#[test]
fn test_monotonic_constraints_map_building() {
let config = LTRConfig::default();
let trainer = LTRTrainer::new(config);
let feature_names = vec![
"exact_match".to_string(),
"struct_hit".to_string(),
"lexical_score".to_string(),
"semantic_score".to_string(),
];
let constraints = trainer.build_monotonic_constraints_map(&feature_names);
assert_eq!(constraints.get("exact_match"), Some(&MonotonicConstraint::Increasing));
assert_eq!(constraints.get("struct_hit"), Some(&MonotonicConstraint::Increasing));
assert_eq!(constraints.get("lexical_score"), Some(&MonotonicConstraint::None));
assert_eq!(constraints.get("semantic_score"), Some(&MonotonicConstraint::None));
}
#[test]
fn test_document_features_creation() {
let features = DocumentFeatures {
doc_id: "test_doc".to_string(),
features: vec![0.8, 0.6, 0.7],
feature_names: vec!["f1".to_string(), "f2".to_string(), "f3".to_string()],
};
assert_eq!(features.doc_id, "test_doc");
assert_eq!(features.features.len(), 3);
assert_eq!(features.feature_names.len(), 3);
assert_eq!(features.features[0], 0.8);
}
#[test]
fn test_training_sample_creation() {
let sample = TrainingSample {
query_id: "query_1".to_string(),
repo_id: "repo_1".to_string(),
intent: "NL".to_string(),
language: "python".to_string(),
query_text: "find function".to_string(),
documents: vec![],
relevance_labels: vec![1.0, 0.5],
};
assert_eq!(sample.query_id, "query_1");
assert_eq!(sample.repo_id, "repo_1");
assert_eq!(sample.intent, "NL");
assert_eq!(sample.language, "python");
assert_eq!(sample.relevance_labels.len(), 2);
}
#[tokio::test]
async fn test_ltr_trainer_mock_training_samples() {
let config = LTRConfig::default();
let trainer = LTRTrainer::new(config);
let samples = trainer.create_mock_training_samples().unwrap();
assert_eq!(samples.len(), 10);
for sample in samples {
assert!(!sample.query_id.is_empty());
assert!(!sample.repo_id.is_empty());
assert_eq!(sample.intent, "NL");
assert_eq!(sample.language, "python");
assert_eq!(sample.documents.len(), 2);
assert_eq!(sample.relevance_labels.len(), 2);
assert!(sample.relevance_labels[0] > sample.relevance_labels[1]); }
}
#[tokio::test]
async fn test_ltr_trainer_training() {
let config = LTRConfig {
max_iterations: 50, ..Default::default()
};
let trainer = LTRTrainer::new(config);
let samples = trainer.create_mock_training_samples().unwrap();
let model = trainer.train(&samples).await.unwrap();
assert_eq!(model.feature_names.len(), 12); assert_eq!(model.weights.len(), 12);
assert!(!model.metadata.model_hash.is_empty());
assert!(!model.metadata.feature_schema_hash.is_empty());
assert_eq!(model.metadata.training_samples, 10);
assert_eq!(model.metadata.feature_count, 12);
let exact_match_idx = model.feature_names.iter().position(|n| n == "exact_match");
let struct_hit_idx = model.feature_names.iter().position(|n| n == "struct_hit");
if let Some(idx) = exact_match_idx {
assert!(model.weights[idx] >= 0.0, "exact_match should have non-negative weight");
}
if let Some(idx) = struct_hit_idx {
assert!(model.weights[idx] >= 0.0, "struct_hit should have non-negative weight");
}
for weight in &model.weights {
assert!(weight.abs() <= 0.4, "Weight should be bounded by max_log_odds_delta");
}
}
#[tokio::test]
async fn test_training_report_generation() {
let config = LTRConfig::default();
let trainer = LTRTrainer::new(config);
let report = trainer.generate_training_report().await.unwrap();
assert!(report.final_ndcg > 0.0);
assert_eq!(report.feature_count, 12);
assert_eq!(report.cv_folds, 5);
assert_eq!(report.total_samples, 1000);
assert_eq!(report.hard_negative_count, 4000);
assert!(report.weights_stddev > 0.0);
}
}