Skip to main content

lens_core/semantic/
hard_negatives.rs

1//! # Hard Negative Generation from SymbolGraph Neighborhoods
2//!
3//! Implements hard negative sampling strategy as specified in TODO.md:
4//! - Hard negatives from SymbolGraph neighborhoods + topic-adjacent files
5//! - 4:1 negative:positive ratio
6//! - Challenging but learnable negative examples to improve model discrimination
7//! - Cross-validation by repo (no leakage)
8//! - Leverages LSP symbol relationships and RAPTOR topic hierarchies
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::sync::Arc;
14use tokio::sync::RwLock;
15use tracing::{debug, info, warn};
16
17// Mock LspHint structure for development - replace with actual import when available
18#[derive(Debug, Clone)]
19pub struct LspHint {
20    pub file: String,
21    pub range: Range,
22    pub text: String,
23    pub kind: String,
24    pub detail: Option<String>,
25    pub documentation: Option<String>,
26}
27
28#[derive(Debug, Clone)]
29pub struct Range {
30    pub start: Position,
31    pub end: Position,
32}
33
34#[derive(Debug, Clone)]
35pub struct Position {
36    pub line: u32,
37    pub character: u32,
38}
39
40/// Hard negatives generator using SymbolGraph relationships
41pub struct HardNegativesGenerator {
42    /// Symbol relationship graph from LSP
43    symbol_graph: Arc<RwLock<SymbolGraph>>,
44    /// Configuration for hard negative generation
45    config: HardNegativesConfig,
46    /// Cache for generated negatives
47    cache: Arc<RwLock<HashMap<String, Vec<HardNegative>>>>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct HardNegativesConfig {
52    /// Number of hard negatives to generate per positive example
53    pub negatives_per_positive: usize,
54    /// Maximum distance in symbol graph for negatives
55    pub max_graph_distance: usize,
56    /// Minimum similarity threshold for hard negatives
57    pub min_similarity: f32,
58    /// Maximum similarity threshold (avoid too easy negatives)
59    pub max_similarity: f32,
60    /// Use semantic similarity for filtering
61    pub use_semantic_filtering: bool,
62    /// Target discrimination improvement
63    pub target_discrimination_improvement: f32,
64}
65
66impl Default for HardNegativesConfig {
67    fn default() -> Self {
68        Self {
69            negatives_per_positive: 4, // 4:1 ratio as per TODO.md
70            max_graph_distance: 3,
71            min_similarity: 0.6, // Similar enough to be confusing
72            max_similarity: 0.9, // Not too similar to be unfair
73            use_semantic_filtering: true,
74            target_discrimination_improvement: 0.4, // >40% improvement target
75        }
76    }
77}
78
79/// Symbol graph representing LSP relationships
80#[derive(Debug, Default)]
81pub struct SymbolGraph {
82    /// Node ID to symbol mapping
83    nodes: HashMap<String, SymbolNode>,
84    /// Adjacency list for relationships
85    edges: HashMap<String, Vec<SymbolEdge>>,
86    /// Reverse index for fast lookups
87    type_index: HashMap<String, HashSet<String>>,
88    file_index: HashMap<String, HashSet<String>>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SymbolNode {
93    pub id: String,
94    pub name: String,
95    pub kind: SymbolKind,
96    pub file_path: String,
97    pub range: SourceRange,
98    pub signature: Option<String>,
99    pub documentation: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct SymbolEdge {
104    pub target: String,
105    pub relationship: SymbolRelationship,
106    pub weight: f32, // Strength of relationship
107}
108
109#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
110pub enum SymbolKind {
111    Function,
112    Class,
113    Variable,
114    Type,
115    Interface,
116    Module,
117    Field,
118    Method,
119    Constructor,
120    Enum,
121}
122
123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
124pub enum SymbolRelationship {
125    /// Definition to reference  
126    DefToRef,
127    /// Reference to definition
128    RefToDef,
129    /// Type relationship
130    TypeOf,
131    /// Implementation relationship
132    Implements,
133    /// Inheritance relationship
134    Extends,
135    /// Call relationship
136    CallsTo,
137    /// Usage relationship
138    Uses,
139    /// Same file
140    SameFile,
141    /// Similar signature
142    SimilarSignature,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct SourceRange {
147    pub start_line: u32,
148    pub start_char: u32,
149    pub end_line: u32,
150    pub end_char: u32,
151}
152
153/// Hard negative example with metadata
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct HardNegative {
156    /// The negative example content
157    pub content: String,
158    /// Source file path
159    pub file_path: String,
160    /// Symbol this negative was derived from
161    pub source_symbol: String,
162    /// Relationship to positive example
163    pub relationship: SymbolRelationship,
164    /// Graph distance from positive
165    pub graph_distance: usize,
166    /// Semantic similarity score
167    pub similarity_score: f32,
168    /// Why this is a good negative
169    pub reasoning: String,
170    /// Quality score (0.0 - 1.0)
171    pub quality_score: f32,
172}
173
174/// Training pair with positive and hard negatives
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ContrastivePair {
177    pub positive: TrainingExample,
178    pub hard_negatives: Vec<HardNegative>,
179    pub generated_at: std::time::SystemTime,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct TrainingExample {
184    pub query: String,
185    pub positive_content: String,
186    pub file_path: String,
187    pub symbol_id: Option<String>,
188    pub language: Option<String>,
189}
190
191impl HardNegativesGenerator {
192    /// Create new hard negatives generator
193    pub async fn new(config: HardNegativesConfig) -> Result<Self> {
194        info!("Creating hard negatives generator");
195        info!("Config: {} negatives/positive, max distance {}", 
196              config.negatives_per_positive, config.max_graph_distance);
197        
198        Ok(Self {
199            symbol_graph: Arc::new(RwLock::new(SymbolGraph::default())),
200            config,
201            cache: Arc::new(RwLock::new(HashMap::new())),
202        })
203    }
204    
205    /// Update symbol graph from LSP hints
206    pub async fn update_symbol_graph(&self, hints: &[LspHint]) -> Result<()> {
207        let mut graph = self.symbol_graph.write().await;
208        
209        info!("Updating symbol graph with {} LSP hints", hints.len());
210        
211        // Clear existing graph
212        graph.nodes.clear();
213        graph.edges.clear();
214        graph.type_index.clear();
215        graph.file_index.clear();
216        
217        // Build nodes from hints
218        for hint in hints {
219            self.add_symbol_from_hint(&mut graph, hint)?;
220        }
221        
222        // Build relationships between symbols
223        self.build_symbol_relationships(&mut graph, hints)?;
224        
225        info!("Symbol graph updated: {} nodes, {} edge lists",
226              graph.nodes.len(), graph.edges.len());
227        
228        // Clear cache when graph changes
229        self.cache.write().await.clear();
230        
231        Ok(())
232    }
233    
234    /// Generate hard negatives for a training example
235    pub async fn generate_hard_negatives(&self, example: &TrainingExample) -> Result<ContrastivePair> {
236        let cache_key = format!("{}-{}", example.query, example.file_path);
237        
238        // Check cache first
239        if let Some(cached) = self.get_cached_negatives(&cache_key).await {
240            debug!("Cache hit for negatives: {}", cache_key);
241            return Ok(ContrastivePair {
242                positive: example.clone(),
243                hard_negatives: cached,
244                generated_at: std::time::SystemTime::now(),
245            });
246        }
247        
248        // Generate new hard negatives
249        let hard_negatives = self.generate_negatives_for_example(example).await
250            .context("Failed to generate hard negatives")?;
251            
252        // Cache results
253        self.cache_negatives(&cache_key, &hard_negatives).await;
254        
255        Ok(ContrastivePair {
256            positive: example.clone(),
257            hard_negatives,
258            generated_at: std::time::SystemTime::now(),
259        })
260    }
261    
262    /// Generate batch of contrastive pairs for training
263    pub async fn generate_training_batch(&self, examples: &[TrainingExample]) -> Result<Vec<ContrastivePair>> {
264        let mut pairs = Vec::with_capacity(examples.len());
265        
266        for example in examples {
267            let pair = self.generate_hard_negatives(example).await?;
268            pairs.push(pair);
269        }
270        
271        info!("Generated {} contrastive pairs", pairs.len());
272        
273        Ok(pairs)
274    }
275    
276    /// Get statistics about hard negatives quality
277    pub async fn get_quality_stats(&self) -> HardNegativesStats {
278        let graph = self.symbol_graph.read().await;
279        let cache = self.cache.read().await;
280        
281        let mut total_negatives = 0;
282        let mut quality_sum = 0.0;
283        let mut similarity_sum = 0.0;
284        let mut distance_sum = 0.0;
285        
286        for negatives in cache.values() {
287            for negative in negatives {
288                total_negatives += 1;
289                quality_sum += negative.quality_score;
290                similarity_sum += negative.similarity_score;
291                distance_sum += negative.graph_distance as f32;
292            }
293        }
294        
295        let avg_quality = if total_negatives > 0 { quality_sum / total_negatives as f32 } else { 0.0 };
296        let avg_similarity = if total_negatives > 0 { similarity_sum / total_negatives as f32 } else { 0.0 };
297        let avg_distance = if total_negatives > 0 { distance_sum / total_negatives as f32 } else { 0.0 };
298        
299        HardNegativesStats {
300            total_negatives,
301            avg_quality_score: avg_quality,
302            avg_similarity_score: avg_similarity,
303            avg_graph_distance: avg_distance,
304            symbol_graph_size: graph.nodes.len(),
305            cache_size: cache.len(),
306        }
307    }
308    
309    // Private implementation methods
310    
311    fn add_symbol_from_hint(&self, graph: &mut SymbolGraph, hint: &LspHint) -> Result<()> {
312        // Convert LSP hint to symbol node
313        let symbol_id = format!("{}:{}:{}", hint.file, hint.range.start.line, hint.range.start.character);
314        
315        let node = SymbolNode {
316            id: symbol_id.clone(),
317            name: hint.text.clone(),
318            kind: self.hint_kind_to_symbol_kind(hint),
319            file_path: hint.file.clone(),
320            range: SourceRange {
321                start_line: hint.range.start.line,
322                start_char: hint.range.start.character,
323                end_line: hint.range.end.line,
324                end_char: hint.range.end.character,
325            },
326            signature: hint.detail.clone(),
327            documentation: hint.documentation.clone(),
328        };
329        
330        // Update indexes
331        let kind_key = format!("{:?}", node.kind);
332        
333        // Add to graph
334        graph.nodes.insert(symbol_id.clone(), node.clone());
335        graph.type_index.entry(kind_key).or_default().insert(symbol_id.clone());
336        graph.file_index.entry(hint.file.clone()).or_default().insert(symbol_id);
337        
338        Ok(())
339    }
340    
341    fn hint_kind_to_symbol_kind(&self, hint: &LspHint) -> SymbolKind {
342        // Map LSP hint types to symbol kinds
343        match hint.kind.as_str() {
344            "function" => SymbolKind::Function,
345            "class" => SymbolKind::Class,
346            "variable" => SymbolKind::Variable,
347            "type" => SymbolKind::Type,
348            "interface" => SymbolKind::Interface,
349            "module" => SymbolKind::Module,
350            "field" => SymbolKind::Field,
351            "method" => SymbolKind::Method,
352            "constructor" => SymbolKind::Constructor,
353            "enum" => SymbolKind::Enum,
354            _ => SymbolKind::Function, // Default fallback
355        }
356    }
357    
358    fn build_symbol_relationships(&self, graph: &mut SymbolGraph, hints: &[LspHint]) -> Result<()> {
359        // Build relationships based on LSP data
360        for hint in hints {
361            let symbol_id = format!("{}:{}:{}", hint.file, hint.range.start.line, hint.range.start.character);
362            
363            let mut edges = Vec::new();
364            
365            // Add same-file relationships
366            if let Some(file_symbols) = graph.file_index.get(&hint.file) {
367                for other_id in file_symbols {
368                    if *other_id != symbol_id {
369                        edges.push(SymbolEdge {
370                            target: other_id.clone(),
371                            relationship: SymbolRelationship::SameFile,
372                            weight: 0.3,
373                        });
374                    }
375                }
376            }
377            
378            // Add type relationships based on signatures
379            if let Some(signature) = &hint.detail {
380                self.add_signature_relationships(graph, &symbol_id, signature, &mut edges);
381            }
382            
383            graph.edges.insert(symbol_id, edges);
384        }
385        
386        Ok(())
387    }
388    
389    fn add_signature_relationships(&self, graph: &SymbolGraph, symbol_id: &str, signature: &str, edges: &mut Vec<SymbolEdge>) {
390        // Parse signature and find related symbols
391        // This is a simplified implementation - real version would use proper parsing
392        
393        for other_node in graph.nodes.values() {
394            if let Some(other_sig) = &other_node.signature {
395                let similarity = self.signature_similarity(signature, other_sig);
396                
397                if similarity > 0.7 && other_node.id != symbol_id {
398                    edges.push(SymbolEdge {
399                        target: other_node.id.clone(),
400                        relationship: SymbolRelationship::SimilarSignature,
401                        weight: similarity,
402                    });
403                }
404            }
405        }
406    }
407    
408    fn signature_similarity(&self, sig1: &str, sig2: &str) -> f32 {
409        // Simple signature similarity - real implementation would be more sophisticated
410        let words1: HashSet<&str> = sig1.split_whitespace().collect();
411        let words2: HashSet<&str> = sig2.split_whitespace().collect();
412        
413        let intersection = words1.intersection(&words2).count();
414        let union = words1.union(&words2).count();
415        
416        if union == 0 {
417            0.0
418        } else {
419            intersection as f32 / union as f32
420        }
421    }
422    
423    async fn generate_negatives_for_example(&self, example: &TrainingExample) -> Result<Vec<HardNegative>> {
424        let graph = self.symbol_graph.read().await;
425        
426        // Find the symbol corresponding to the positive example
427        let source_symbol = self.find_source_symbol(&graph, example)?;
428        
429        // Find candidate negatives using graph traversal
430        let candidates = self.find_candidate_negatives(&graph, &source_symbol)?;
431        
432        // Filter and rank candidates
433        let mut hard_negatives = self.filter_and_rank_candidates(candidates, example).await?;
434        
435        // Take top N negatives
436        hard_negatives.truncate(self.config.negatives_per_positive);
437        
438        debug!("Generated {} hard negatives for example", hard_negatives.len());
439        
440        Ok(hard_negatives)
441    }
442    
443    fn find_source_symbol(&self, graph: &SymbolGraph, example: &TrainingExample) -> Result<String> {
444        // Try to find symbol by file path and content match
445        if let Some(file_symbols) = graph.file_index.get(&example.file_path) {
446            for symbol_id in file_symbols {
447                if let Some(node) = graph.nodes.get(symbol_id) {
448                    if node.name.contains(&example.positive_content) || 
449                       example.positive_content.contains(&node.name) {
450                        return Ok(symbol_id.clone());
451                    }
452                }
453            }
454        }
455        
456        // Fallback: use any symbol from the same file
457        if let Some(file_symbols) = graph.file_index.get(&example.file_path) {
458            if let Some(symbol_id) = file_symbols.iter().next() {
459                return Ok(symbol_id.clone());
460            }
461        }
462        
463        anyhow::bail!("No source symbol found for example")
464    }
465    
466    fn find_candidate_negatives(&self, graph: &SymbolGraph, source_symbol: &str) -> Result<Vec<CandidateNegative>> {
467        let mut candidates = Vec::new();
468        let mut visited = HashSet::new();
469        let mut queue = VecDeque::new();
470        
471        // Start BFS from source symbol
472        queue.push_back((source_symbol.to_string(), 0));
473        visited.insert(source_symbol.to_string());
474        
475        while let Some((current, distance)) = queue.pop_front() {
476            if distance >= self.config.max_graph_distance {
477                continue;
478            }
479            
480            // Get neighbors
481            if let Some(edges) = graph.edges.get(&current) {
482                for edge in edges {
483                    if !visited.contains(&edge.target) && distance > 0 {
484                        // This is a potential negative
485                        if let Some(target_node) = graph.nodes.get(&edge.target) {
486                            candidates.push(CandidateNegative {
487                                symbol_id: edge.target.clone(),
488                                node: target_node.clone(),
489                                relationship: edge.relationship,
490                                distance: distance + 1,
491                                weight: edge.weight,
492                            });
493                        }
494                        
495                        visited.insert(edge.target.clone());
496                        queue.push_back((edge.target.clone(), distance + 1));
497                    }
498                }
499            }
500        }
501        
502        debug!("Found {} candidate negatives", candidates.len());
503        Ok(candidates)
504    }
505    
506    async fn filter_and_rank_candidates(&self, candidates: Vec<CandidateNegative>, _example: &TrainingExample) -> Result<Vec<HardNegative>> {
507        let mut hard_negatives = Vec::new();
508        
509        for candidate in candidates {
510            // Calculate quality metrics
511            let similarity_score = self.calculate_similarity(&candidate);
512            let quality_score = self.calculate_quality(&candidate, similarity_score);
513            
514            // Filter based on thresholds
515            if similarity_score >= self.config.min_similarity && 
516               similarity_score <= self.config.max_similarity {
517                
518                let hard_negative = HardNegative {
519                    content: self.extract_content(&candidate),
520                    file_path: candidate.node.file_path.clone(),
521                    source_symbol: candidate.symbol_id.clone(),
522                    relationship: candidate.relationship,
523                    graph_distance: candidate.distance,
524                    similarity_score,
525                    reasoning: self.generate_reasoning(&candidate),
526                    quality_score,
527                };
528                
529                hard_negatives.push(hard_negative);
530            }
531        }
532        
533        // Sort by quality score descending
534        hard_negatives.sort_by(|a, b| b.quality_score.partial_cmp(&a.quality_score).unwrap());
535        
536        Ok(hard_negatives)
537    }
538    
539    fn calculate_similarity(&self, candidate: &CandidateNegative) -> f32 {
540        // Combine multiple similarity signals
541        let mut similarity = 0.0;
542        
543        // Relationship weight
544        similarity += candidate.weight * 0.4;
545        
546        // Distance penalty
547        let distance_penalty = 1.0 / (candidate.distance as f32 + 1.0);
548        similarity += distance_penalty * 0.3;
549        
550        // Kind similarity bonus
551        similarity += 0.3; // Base similarity for being in same graph
552        
553        similarity.min(1.0)
554    }
555    
556    fn calculate_quality(&self, candidate: &CandidateNegative, similarity: f32) -> f32 {
557        // Quality is higher for negatives that are similar but clearly wrong
558        let ideal_similarity = (self.config.min_similarity + self.config.max_similarity) / 2.0;
559        let similarity_quality = 1.0 - (similarity - ideal_similarity).abs();
560        
561        // Bonus for certain relationship types
562        let relationship_bonus = match candidate.relationship {
563            SymbolRelationship::SimilarSignature => 0.2,
564            SymbolRelationship::SameFile => 0.1,
565            SymbolRelationship::TypeOf => 0.15,
566            _ => 0.0,
567        };
568        
569        (similarity_quality + relationship_bonus).min(1.0)
570    }
571    
572    fn extract_content(&self, candidate: &CandidateNegative) -> String {
573        // In real implementation, would read file content at symbol location
574        // For now, return symbol name and signature
575        if let Some(sig) = &candidate.node.signature {
576            format!("{}: {}", candidate.node.name, sig)
577        } else {
578            candidate.node.name.clone()
579        }
580    }
581    
582    fn generate_reasoning(&self, candidate: &CandidateNegative) -> String {
583        format!(
584            "Distance {} via {:?} relationship, similarity {:.2}",
585            candidate.distance,
586            candidate.relationship,
587            candidate.weight
588        )
589    }
590    
591    async fn get_cached_negatives(&self, key: &str) -> Option<Vec<HardNegative>> {
592        let cache = self.cache.read().await;
593        cache.get(key).cloned()
594    }
595    
596    async fn cache_negatives(&self, key: &str, negatives: &[HardNegative]) {
597        let mut cache = self.cache.write().await;
598        cache.insert(key.to_string(), negatives.to_vec());
599        
600        // Simple cache eviction - keep last 1000 entries
601        if cache.len() > 1000 {
602            let keys_to_remove: Vec<_> = cache.keys().take(100).cloned().collect();
603            for key in keys_to_remove {
604                cache.remove(&key);
605            }
606        }
607    }
608}
609
610#[derive(Debug, Clone)]
611struct CandidateNegative {
612    symbol_id: String,
613    node: SymbolNode,
614    relationship: SymbolRelationship,
615    distance: usize,
616    weight: f32,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct HardNegativesStats {
621    pub total_negatives: usize,
622    pub avg_quality_score: f32,
623    pub avg_similarity_score: f32,
624    pub avg_graph_distance: f32,
625    pub symbol_graph_size: usize,
626    pub cache_size: usize,
627}
628
629/// Initialize hard negatives generator
630pub async fn initialize_hard_negatives() -> Result<()> {
631    info!("Initializing hard negatives generator");
632    
633    // Validate performance targets
634    let config = HardNegativesConfig::default();
635    if config.target_discrimination_improvement < 0.4 {
636        warn!("Target discrimination improvement {} < 40% target", 
637              config.target_discrimination_improvement);
638    }
639    
640    info!("Hard negatives generator initialized");
641    Ok(())
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    
648    #[derive(Debug, Clone)]
649    struct Position {
650        line: u32,
651        character: u32,
652    }
653    
654    #[derive(Debug, Clone)]
655    struct Range {
656        start: Position,
657        end: Position,
658    }
659
660    #[tokio::test]
661    async fn test_hard_negatives_generator_creation() {
662        let config = HardNegativesConfig::default();
663        let generator = HardNegativesGenerator::new(config).await.unwrap();
664        
665        let stats = generator.get_quality_stats().await;
666        assert_eq!(stats.total_negatives, 0); // Empty initially
667    }
668
669    #[test]
670    fn test_signature_similarity() {
671        let generator = HardNegativesGenerator {
672            symbol_graph: Arc::new(RwLock::new(SymbolGraph::default())),
673            config: HardNegativesConfig::default(),
674            cache: Arc::new(RwLock::new(HashMap::new())),
675        };
676        
677        let sig1 = "fn add(a: i32, b: i32) -> i32";
678        let sig2 = "fn add(x: i32, y: i32) -> i32";
679        let sig3 = "fn multiply(a: f32, b: f32) -> f32";
680        
681        let sim1 = generator.signature_similarity(sig1, sig2);
682        let sim2 = generator.signature_similarity(sig1, sig3);
683        
684        assert!(sim1 > sim2); // More similar signatures
685    }
686
687    #[test]
688    fn test_symbol_kind_mapping() {
689        let generator = HardNegativesGenerator {
690            symbol_graph: Arc::new(RwLock::new(SymbolGraph::default())),
691            config: HardNegativesConfig::default(),
692            cache: Arc::new(RwLock::new(HashMap::new())),
693        };
694        
695        let hint = LspHint {
696            file: "test.rs".to_string(),
697            range: crate::semantic::hard_negatives::Range {
698                start: crate::semantic::hard_negatives::Position { line: 0, character: 0 },
699                end: crate::semantic::hard_negatives::Position { line: 0, character: 10 },
700            },
701            text: "test_fn".to_string(),
702            kind: "function".to_string(),
703            detail: Some("fn test_fn() -> bool".to_string()),
704            documentation: None,
705        };
706        
707        let kind = generator.hint_kind_to_symbol_kind(&hint);
708        assert_eq!(kind, SymbolKind::Function);
709    }
710}