1use 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#[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
40pub struct HardNegativesGenerator {
42 symbol_graph: Arc<RwLock<SymbolGraph>>,
44 config: HardNegativesConfig,
46 cache: Arc<RwLock<HashMap<String, Vec<HardNegative>>>>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct HardNegativesConfig {
52 pub negatives_per_positive: usize,
54 pub max_graph_distance: usize,
56 pub min_similarity: f32,
58 pub max_similarity: f32,
60 pub use_semantic_filtering: bool,
62 pub target_discrimination_improvement: f32,
64}
65
66impl Default for HardNegativesConfig {
67 fn default() -> Self {
68 Self {
69 negatives_per_positive: 4, max_graph_distance: 3,
71 min_similarity: 0.6, max_similarity: 0.9, use_semantic_filtering: true,
74 target_discrimination_improvement: 0.4, }
76 }
77}
78
79#[derive(Debug, Default)]
81pub struct SymbolGraph {
82 nodes: HashMap<String, SymbolNode>,
84 edges: HashMap<String, Vec<SymbolEdge>>,
86 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, }
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 DefToRef,
127 RefToDef,
129 TypeOf,
131 Implements,
133 Extends,
135 CallsTo,
137 Uses,
139 SameFile,
141 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#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct HardNegative {
156 pub content: String,
158 pub file_path: String,
160 pub source_symbol: String,
162 pub relationship: SymbolRelationship,
164 pub graph_distance: usize,
166 pub similarity_score: f32,
168 pub reasoning: String,
170 pub quality_score: f32,
172}
173
174#[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 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 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 graph.nodes.clear();
213 graph.edges.clear();
214 graph.type_index.clear();
215 graph.file_index.clear();
216
217 for hint in hints {
219 self.add_symbol_from_hint(&mut graph, hint)?;
220 }
221
222 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 self.cache.write().await.clear();
230
231 Ok(())
232 }
233
234 pub async fn generate_hard_negatives(&self, example: &TrainingExample) -> Result<ContrastivePair> {
236 let cache_key = format!("{}-{}", example.query, example.file_path);
237
238 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 let hard_negatives = self.generate_negatives_for_example(example).await
250 .context("Failed to generate hard negatives")?;
251
252 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 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 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 fn add_symbol_from_hint(&self, graph: &mut SymbolGraph, hint: &LspHint) -> Result<()> {
312 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 let kind_key = format!("{:?}", node.kind);
332
333 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 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, }
356 }
357
358 fn build_symbol_relationships(&self, graph: &mut SymbolGraph, hints: &[LspHint]) -> Result<()> {
359 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 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 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 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 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 let source_symbol = self.find_source_symbol(&graph, example)?;
428
429 let candidates = self.find_candidate_negatives(&graph, &source_symbol)?;
431
432 let mut hard_negatives = self.filter_and_rank_candidates(candidates, example).await?;
434
435 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 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 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 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 if let Some(edges) = graph.edges.get(¤t) {
482 for edge in edges {
483 if !visited.contains(&edge.target) && distance > 0 {
484 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 let similarity_score = self.calculate_similarity(&candidate);
512 let quality_score = self.calculate_quality(&candidate, similarity_score);
513
514 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 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 let mut similarity = 0.0;
542
543 similarity += candidate.weight * 0.4;
545
546 let distance_penalty = 1.0 / (candidate.distance as f32 + 1.0);
548 similarity += distance_penalty * 0.3;
549
550 similarity += 0.3; similarity.min(1.0)
554 }
555
556 fn calculate_quality(&self, candidate: &CandidateNegative, similarity: f32) -> f32 {
557 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 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 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 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
629pub async fn initialize_hard_negatives() -> Result<()> {
631 info!("Initializing hard negatives generator");
632
633 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); }
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); }
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}