/// Scoring Pipeline: Unified interface for all scoring variants /// /// Phase 2: Multi-Scope TF-IDF Indexing + ScoringPipeline trait /// /// Implements SOLID principles: /// - Single Responsibility: each scorer does one thing /// - Open/Closed: add new scorers without modifying existing /// - Liskov Substitution: all scorers implement DocumentScorer /// - Dependency Inversion: depend on trait, not concrete types use anyhow::Result; use async_trait::async_trait; use std::sync::Arc; /// Single interface: one scorer, one job #[async_trait] pub trait DocumentScorer: Send + Sync { async fn score(&self, query: &str, doc_id: &str) -> Result; fn name(&self) -> &str; } /// Global TF-IDF Scorer: scoring across entire corpus pub struct GlobalTfIdfScorer { vocabulary: Arc>, // term -> IDF } impl GlobalTfIdfScorer { pub fn new(vocabulary: Arc>) -> Self { Self { vocabulary } } fn compute_tfidf(&self, query: &str, _doc_id: &str) -> Result { // Simplified: sum IDF values of query terms let mut score = 0.0; for term in query.split_whitespace() { if let Some(idf) = self.vocabulary.get(term) { score += idf; } } Ok(score.min(1.0)) } } #[async_trait] impl DocumentScorer for GlobalTfIdfScorer { async fn score(&self, query: &str, doc_id: &str) -> Result { self.compute_tfidf(query, doc_id) } fn name(&self) -> &str { "global-tfidf" } } /// Project-scoped TF-IDF Scorer: scoring within project boundaries pub struct ProjectTfIdfScorer { project: String, vocabulary: Arc>, } impl ProjectTfIdfScorer { pub fn new( project: String, vocabulary: Arc>, ) -> Self { Self { project, vocabulary, } } fn compute_project_tfidf(&self, query: &str, _doc_id: &str) -> Result { // Simplified: same as global, but scoped to project let mut score = 0.0; for term in query.split_whitespace() { if let Some(idf) = self.vocabulary.get(term) { score += idf * 1.5; // Boost for project-local matches } } Ok(score.min(1.0)) } } #[async_trait] impl DocumentScorer for ProjectTfIdfScorer { async fn score(&self, query: &str, doc_id: &str) -> Result { self.compute_project_tfidf(query, doc_id) } fn name(&self) -> &str { "project-tfidf" } } /// Semantic Scorer: vector similarity (placeholder) pub struct SemanticScorer { _embeddings_client: Arc<()>, // Placeholder _pgvector: Arc<()>, // Placeholder } impl SemanticScorer { pub fn new() -> Self { Self { _embeddings_client: Arc::new(()), _pgvector: Arc::new(()), } } async fn compute_semantic_sim(&self, _query: &str, _doc_id: &str) -> Result { // TODO: actual vector similarity via pgvector Ok(0.5) } } #[async_trait] impl DocumentScorer for SemanticScorer { async fn score(&self, query: &str, doc_id: &str) -> Result { self.compute_semantic_sim(query, doc_id).await } fn name(&self) -> &str { "semantic" } } /// Metadata-boosting Scorer: wraps base scorer with category boost (Decorator pattern) pub struct MetadataBoostingScorer { base_scorer: Arc, boost_factor: f32, } impl MetadataBoostingScorer { pub fn new(base_scorer: Arc, boost_factor: f32) -> Self { Self { base_scorer, boost_factor, } } } #[async_trait] impl DocumentScorer for MetadataBoostingScorer { async fn score(&self, query: &str, doc_id: &str) -> Result { let base_score = self.base_scorer.score(query, doc_id).await?; // TODO: apply boost if doc metadata matches query intent Ok((base_score * self.boost_factor).min(1.0)) } fn name(&self) -> &str { "metadata-boosted" } } /// Scoring Pipeline Orchestrator: run multiple scorers with RRF fusion pub struct ScoringPipeline { scorers: Vec<(String, f32, Arc)>, // name, weight, scorer } impl ScoringPipeline { pub fn new() -> Self { Self { scorers: Vec::new(), } } pub fn with_scorer( mut self, name: &str, weight: f32, scorer: Arc, ) -> Self { self.scorers.push((name.to_string(), weight, scorer)); self } /// Execute all scorers in parallel, fuse with RRF (Reciprocal Rank Fusion) pub async fn score(&self, query: &str, doc_id: &str) -> Result { // Get scores from all scorers let mut scores = Vec::new(); for (_, _, scorer) in &self.scorers { match scorer.score(query, doc_id).await { Ok(score) => scores.push(score), Err(_) => scores.push(0.0), // Gracefully degrade } } // RRF: weighted sum of normalized scores let weighted_sum: f32 = self .scorers .iter() .zip(scores) .map(|((_, weight, _), score)| weight * score) .sum(); let weight_sum: f32 = self.scorers.iter().map(|(_, w, _)| w).sum(); Ok(if weight_sum > 0.0 { (weighted_sum / weight_sum).min(1.0) } else { 0.0 }) } pub fn scorer_names(&self) -> Vec<&str> { self.scorers.iter().map(|(name, _, _)| name.as_str()).collect() } } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; #[tokio::test] async fn test_global_tfidf_scorer() { let mut vocab = BTreeMap::new(); vocab.insert("kubernetes".to_string(), 0.5); vocab.insert("pod".to_string(), 0.7); let scorer = GlobalTfIdfScorer::new(Arc::new(vocab)); let score = scorer.score("kubernetes pod", "doc1").await.unwrap(); assert!(score > 0.0); assert!(score <= 1.0); assert_eq!(scorer.name(), "global-tfidf"); } #[tokio::test] async fn test_project_tfidf_scorer() { let mut vocab = BTreeMap::new(); vocab.insert("kubernetes".to_string(), 0.5); let scorer = ProjectTfIdfScorer::new("poimen".to_string(), Arc::new(vocab)); let score = scorer.score("kubernetes", "doc1").await.unwrap(); assert!(score > 0.0); assert_eq!(scorer.name(), "project-tfidf"); } #[tokio::test] async fn test_scoring_pipeline() { let mut vocab = BTreeMap::new(); vocab.insert("test".to_string(), 0.6); let scorer1 = Arc::new(GlobalTfIdfScorer::new(Arc::new(vocab.clone()))); let scorer2 = Arc::new(SemanticScorer::new()); let pipeline = ScoringPipeline::new() .with_scorer("global-tfidf", 0.4, scorer1) .with_scorer("semantic", 0.6, scorer2); let score = pipeline.score("test", "doc1").await.unwrap(); assert!(score > 0.0); assert!(score <= 1.0); assert_eq!(pipeline.scorer_names().len(), 2); } #[tokio::test] async fn test_metadata_boosting_scorer() { let mut vocab = BTreeMap::new(); vocab.insert("error".to_string(), 0.8); let base = Arc::new(GlobalTfIdfScorer::new(Arc::new(vocab))); let boosted = Arc::new(MetadataBoostingScorer::new(base, 1.5)); let score = boosted.score("error", "doc1").await.unwrap(); assert!(score > 0.0); assert_eq!(boosted.name(), "metadata-boosted"); } }