1use crate::error::Result;
2use crate::types::{Edge, Node, NodeId};
3use std::collections::HashMap;
4
5pub struct Deduplicator {
6 pub threshold: f64,
15}
16
17impl Deduplicator {
18 pub fn new(threshold: f64) -> Self {
19 Deduplicator { threshold }
20 }
21
22 pub fn default_threshold() -> f64 {
23 0.97
24 }
25 pub fn deduplicate(
29 &self,
30 nodes: Vec<Node>,
31 edges: Vec<Edge>,
32 ) -> Result<(Vec<Node>, Vec<Edge>)> {
33 self.check_cross_project(&nodes)?;
34
35 let mut merged_nodes: HashMap<NodeId, Node> = HashMap::new();
36 let mut exact_index: HashMap<String, NodeId> = HashMap::new();
37 let mut id_mapping: HashMap<NodeId, NodeId> = HashMap::new();
38
39 for node in nodes {
41 let key = self.exact_match_key(&node);
42 if let Some(existing_id) = exact_index.get(&key) {
43 id_mapping.insert(node.id.clone(), existing_id.clone());
44 continue;
45 }
46 exact_index.insert(key, node.id.clone());
47 merged_nodes.insert(node.id.clone(), node);
48 }
49
50 let ids: Vec<NodeId> = merged_nodes.keys().cloned().collect();
53 for i in 0..ids.len() {
54 for j in (i + 1)..ids.len() {
55 let ni = merged_nodes[&ids[i]].clone();
56 let nj = merged_nodes[&ids[j]].clone();
57
58 if ni.source_file != nj.source_file {
59 continue;
60 }
61
62 let mut score = jaro_winkler(&ni.label, &nj.label);
63 if self.same_community(&ni, &nj) {
64 score = (score + 0.05).min(1.0);
65 }
66
67 if score > self.threshold && !self.is_variant_pair(&ni.label, &nj.label) {
68 let target_id = ids[j].clone();
69 let keep_id = ids[i].clone();
70 id_mapping.insert(target_id, keep_id);
71 merged_nodes.remove(&ids[j]);
72 }
73 }
74 }
75
76 let remapped_edges: Vec<Edge> = edges
78 .into_iter()
79 .map(|e| {
80 let new_src = id_mapping
81 .get(&e.source)
82 .cloned()
83 .unwrap_or(e.source.clone());
84 let new_tgt = id_mapping
85 .get(&e.target)
86 .cloned()
87 .unwrap_or(e.target.clone());
88 Edge {
89 source: new_src,
90 target: new_tgt,
91 ..e
92 }
93 })
94 .collect();
95
96 let final_nodes: Vec<Node> = merged_nodes.into_values().collect();
97 Ok((final_nodes, remapped_edges))
98 }
99
100 fn check_cross_project(&self, nodes: &[Node]) -> Result<()> {
101 let repos: std::collections::HashSet<&str> = nodes
102 .iter()
103 .filter_map(|n| n.metadata.get("repo"))
104 .map(|s| s.as_str())
105 .collect();
106 if repos.len() > 1 {
107 return Err(crate::error::CodeSynapseError::Parse(
108 "cross-project dedup not supported: nodes from different repos found".to_string(),
109 ));
110 }
111 Ok(())
112 }
113
114 fn same_community(&self, a: &Node, b: &Node) -> bool {
115 match (a.community, b.community) {
116 (Some(ca), Some(cb)) => ca == cb,
117 _ => false,
118 }
119 }
120
121 fn exact_match_key(&self, node: &Node) -> String {
122 format!("{}:{}", node.source_file, node.label)
123 }
124
125 fn is_variant_pair(&self, a: &str, b: &str) -> bool {
126 let (shorter, longer) = if a.len() <= b.len() { (a, b) } else { (b, a) };
128 longer.starts_with(shorter) && longer.len() > shorter.len()
129 }
130
131 pub fn llm_tiebreaker(&self, pairs: Vec<(Node, Node)>) -> Vec<(String, String, bool)> {
134 pairs
135 .into_iter()
136 .map(|(a, b)| {
137 let score = jaro_winkler(&a.label, &b.label);
138 if score >= self.threshold {
139 (a.id, b.id, true)
140 } else {
141 (a.id, b.id, false)
142 }
143 })
144 .collect()
145 }
146}
147
148impl Default for Deduplicator {
149 fn default() -> Self {
150 Deduplicator::new(Deduplicator::default_threshold())
151 }
152}
153
154fn jaro_winkler(a: &str, b: &str) -> f64 {
155 let a = a.to_lowercase();
156 let b = b.to_lowercase();
157
158 if a == b {
159 return 1.0;
160 }
161
162 let len_a = a.len();
163 let len_b = b.len();
164
165 if len_a == 0 || len_b == 0 {
166 return 0.0;
167 }
168
169 let max_dist = (std::cmp::max(len_a, len_b) / 2).saturating_sub(1);
170 let max_dist = std::cmp::max(max_dist, 0);
171
172 let mut a_matched = vec![false; len_a];
173 let mut b_matched = vec![false; len_b];
174
175 let mut matches = 0;
176 let mut transpositions = 0;
177
178 #[allow(clippy::needless_range_loop)]
179 for i in 0..len_a {
180 let start = i.saturating_sub(max_dist);
181 let end = std::cmp::min(len_b, i + max_dist + 1);
182
183 for j in start..end {
184 if b_matched[j] {
185 continue;
186 }
187 if a.as_bytes()[i] == b.as_bytes()[j] {
188 a_matched[i] = true;
189 b_matched[j] = true;
190 matches += 1;
191 break;
192 }
193 }
194 }
195
196 if matches == 0 {
197 return 0.0;
198 }
199
200 let mut k = 0;
201 #[allow(clippy::needless_range_loop)]
202 for i in 0..len_a {
203 if a_matched[i] {
204 while !b_matched[k] {
205 k += 1;
206 }
207 if a.as_bytes()[i] != b.as_bytes()[k] {
208 transpositions += 1;
209 }
210 k += 1;
211 }
212 }
213
214 let jaro = (matches as f64 / len_a as f64
215 + matches as f64 / len_b as f64
216 + (matches as f64 - transpositions as f64 / 2.0) / matches as f64)
217 / 3.0;
218
219 let prefix_len = a
221 .chars()
222 .zip(b.chars())
223 .take_while(|(ca, cb)| ca == cb)
224 .count()
225 .min(4);
226
227 jaro + prefix_len as f64 * 0.1 * (1.0 - jaro)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use std::collections::HashMap;
234
235 fn make_node(id: &str, label: &str, source_file: &str) -> Node {
236 Node {
237 id: id.to_string(),
238 label: label.to_string(),
239 file_type: "code".to_string(),
240 source_file: source_file.to_string(),
241 source_location: None,
242 community: None,
243 rationale: None,
244 docstring: None,
245 metadata: HashMap::new(),
246 }
247 }
248
249 #[test]
250 fn test_dedup_exact_same_file() {
251 let nodes = vec![
252 make_node("a", "AuthService", "auth.py"),
253 make_node("b", "AuthService", "auth.py"),
254 ];
255 let dedup = Deduplicator::default();
256 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
257 assert_eq!(result.len(), 1);
258 }
259
260 #[test]
261 fn test_dedup_exact_diff_file() {
262 let nodes = vec![
263 make_node("a", "AuthService", "auth.py"),
264 make_node("b", "AuthService", "service.py"),
265 ];
266 let dedup = Deduplicator::default();
267 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
268 assert_eq!(result.len(), 2);
269 }
270
271 #[test]
272 fn test_dedup_fuzzy_high_score() {
273 let nodes = vec![
274 make_node("a", "AuthService", "auth.py"),
275 make_node("b", "AuthService", "auth.py"),
276 ];
277 let dedup = Deduplicator::default();
278 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
279 assert_eq!(result.len(), 1);
280 }
281
282 #[test]
283 fn test_dedup_fuzzy_low_score() {
284 let nodes = vec![
285 make_node("a", "AuthService", "auth.py"),
286 make_node("b", "AuthServiceImpl", "auth.py"),
287 ];
288 let dedup = Deduplicator::default();
289 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
290 assert_eq!(result.len(), 2);
291 }
292
293 #[test]
294 fn test_dedup_variant_pair() {
295 let nodes = vec![
296 make_node("a", "M1", "config.py"),
297 make_node("b", "M1 Pro", "config.py"),
298 ];
299 let dedup = Deduplicator::default();
300 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
301 assert_eq!(result.len(), 2);
302 }
303
304 #[test]
305 fn test_dedup_empty_nodes() {
306 let dedup = Deduplicator::default();
307 let (result, _) = dedup.deduplicate(vec![], vec![]).unwrap();
308 assert!(result.is_empty());
309 }
310
311 #[test]
312 fn test_dedup_remaps_edges() {
313 let nodes = vec![
314 make_node("a", "Service", "auth.py"),
315 make_node("a_dup", "Service", "auth.py"),
316 ];
317 let edges = vec![Edge {
318 source: "a_dup".to_string(),
319 target: "b".to_string(),
320 relation: "calls".to_string(),
321 confidence: "EXTRACTED".to_string(),
322 source_file: Some("auth.py".to_string()),
323 weight: 1.0,
324 context: None,
325 }];
326 let dedup = Deduplicator::default();
327 let (result_nodes, result_edges) = dedup.deduplicate(nodes, edges).unwrap();
328 assert_eq!(result_nodes.len(), 1);
329 assert_eq!(result_edges[0].source, "a");
330 }
331
332 #[test]
333 fn test_jaro_winkler() {
334 let score = jaro_winkler("AuthService", "AuthService");
335 assert!((score - 1.0).abs() < 0.01);
336
337 let score = jaro_winkler("AuthService", "AuthServic");
338 assert!(score > 0.9);
339 }
340
341 #[test]
344 fn test_dedup_community_boost() {
345 let mut no = make_node("a", "abcXdefg", "auth.py");
350 let mut yes = make_node("b", "abcYdefh", "auth.py");
351 no.community = Some(1);
352 yes.community = Some(1);
353
354 let loose = Deduplicator::new(0.90);
356 let (r_loose, _) = loose
357 .deduplicate(vec![no.clone(), yes.clone()], vec![])
358 .unwrap();
359 assert_eq!(r_loose.len(), 1, "community boost pushes over 0.90");
360
361 let high = Deduplicator::new(0.97);
363 let (r_high, _) = high
364 .deduplicate(vec![no.clone(), yes.clone()], vec![])
365 .unwrap();
366 assert_eq!(r_high.len(), 2, "boosted score still below 0.97");
367
368 let no_community = Deduplicator::new(0.90);
370 let (r_nocom, _) = no_community
371 .deduplicate(
372 vec![
373 make_node("c", "abcXdefg", "auth.py"),
374 make_node("d", "abcYdefh", "auth.py"),
375 ],
376 vec![],
377 )
378 .unwrap();
379 assert_eq!(r_nocom.len(), 2, "without boost, raw JW below 0.90");
380 }
381
382 #[test]
385 fn test_dedup_short_label() {
386 let nodes = vec![
387 make_node("a", "Extractor", "utils.py"),
388 make_node("b", "Extractar", "utils.py"),
389 ];
390 let dedup = Deduplicator::new(0.92);
391 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
392 assert_eq!(
393 result.len(),
394 1,
395 "short labels with 1-char diff should merge"
396 );
397 }
398
399 #[test]
402 fn test_dedup_short_label_blocked() {
403 let nodes = vec![
404 make_node("a", "cranel", "utils.py"),
405 make_node("b", "cranelr", "utils.py"),
406 ];
407 let dedup = Deduplicator::default();
408 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
409 assert_eq!(
410 result.len(),
411 2,
412 "variant pair with different length blocked"
413 );
414 }
415
416 #[test]
419 fn test_dedup_cross_project_guard() {
420 let mut a = make_node("a", "Service", "a.py");
421 a.metadata.insert("repo".to_string(), "proj-a".to_string());
422 let mut b = make_node("b", "Service", "b.py");
423 b.metadata.insert("repo".to_string(), "proj-b".to_string());
424 let nodes = vec![a, b];
425 let dedup = Deduplicator::default();
426 let result = dedup.deduplicate(nodes, vec![]);
427 assert!(result.is_err(), "cross-project dedup should error");
428 }
429
430 #[test]
433 fn test_dedup_llm_tiebreaker() {
434 let a = make_node("a", "AuthService", "auth.py");
436 let b = make_node("b", "AuthServic", "auth.py");
437 let dedup = Deduplicator::default();
438 let decisions = dedup.llm_tiebreaker(vec![(a, b)]);
439 assert_eq!(decisions.len(), 1, "should return one decision");
440 }
441
442 #[test]
443 fn test_dedup_custom_threshold() {
444 let nodes = vec![
446 make_node("a", "AuthService", "auth.py"),
447 make_node("b", "AuthService", "auth.py"),
448 ];
449 let loose = Deduplicator::new(0.99);
450 let (result, _) = loose.deduplicate(nodes, vec![]).unwrap();
451 assert_eq!(result.len(), 1);
452
453 let nodes = vec![
456 make_node("a", "AuthServid", "auth.py"),
457 make_node("b", "AuthServic", "auth.py"),
458 ];
459 let strict = Deduplicator::new(0.99);
460 let (result_strict, _) = strict.deduplicate(nodes.clone(), vec![]).unwrap();
461 assert_eq!(result_strict.len(), 2, "high threshold blocks fuzzy merge");
462
463 let loose = Deduplicator::new(0.90);
464 let (result_loose, _) = loose.deduplicate(nodes, vec![]).unwrap();
465 assert_eq!(result_loose.len(), 1, "low threshold allows fuzzy merge");
466 }
467
468 #[test]
471 fn test_dedup_all_unique() {
472 let nodes = vec![
473 make_node("a", "Alpha", "a.py"),
474 make_node("b", "Beta", "b.py"),
475 make_node("c", "Gamma", "c.py"),
476 ];
477 let dedup = Deduplicator::default();
478 let (result, _) = dedup.deduplicate(nodes, vec![]).unwrap();
479 assert_eq!(result.len(), 3, "all unique labels → no dedup");
480 }
481
482 #[test]
483 fn test_dedup_edges_only_no_nodes() {
484 let edges = vec![Edge {
485 source: "a".to_string(),
486 target: "b".to_string(),
487 relation: "calls".to_string(),
488 confidence: "EXTRACTED".to_string(),
489 source_file: Some("test.py".to_string()),
490 weight: 1.0,
491 context: None,
492 }];
493 let dedup = Deduplicator::default();
494 let (result_nodes, result_edges) = dedup.deduplicate(vec![], edges).unwrap();
495 assert!(result_nodes.is_empty());
496 assert_eq!(result_edges.len(), 1, "edges pass through unchanged");
497 }
498
499 #[test]
500 fn test_dedup_default_threshold() {
501 let dedup = Deduplicator::default();
502 let (result, _) = dedup.deduplicate(vec![], vec![]).unwrap();
503 assert!(result.is_empty());
504 }
505
506 #[test]
507 fn test_jaro_winkler_identical() {
508 let score = jaro_winkler("hello", "hello");
509 assert!((score - 1.0).abs() < 0.001);
510 }
511
512 #[test]
513 fn test_jaro_winkler_empty_strings() {
514 let score = jaro_winkler("", "");
515 assert!(
516 (score - 1.0).abs() < 0.001,
517 "empty strings should return 1.0"
518 );
519 }
520
521 #[test]
522 fn test_jaro_winkler_completely_different() {
523 let score = jaro_winkler("abc", "xyz");
524 assert!(score < 0.5, "completely different should be low");
525 }
526}