Skip to main content

codesynapse_core/
dedup.rs

1use crate::error::Result;
2use crate::types::{Edge, Node, NodeId};
3use std::collections::HashMap;
4
5pub struct Deduplicator {
6    /// Jaro-Winkler threshold for fuzzy merge (default 0.97).
7    /// Higher = fewer false positives, more false negatives.
8    /// Lower = more aggressive merging, risk of false positives.
9    ///
10    /// Note: JW loses discriminative power on long names because the
11    /// matching-fraction term dominates. `is_variant_pair` blocks prefix-
12    /// extension collisions (e.g. `FactoryImpl` vs `Factory`), and the
13    /// same-source-file constraint keeps comparisons within scope.
14    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    /// Run dedup locally with no LLM calls.
26    /// Pairs below 0.92 are left unmerged (conservative — no false positives).
27    /// Call `llm_tiebreaker` separately if you want an LLM pass for higher recall.
28    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        // Pass 1: exact match dedup within same file
40        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        // Pass 2: fuzzy match (Jaro-Winkler > threshold) within same file
51        // Community boost: +0.05 when both nodes share a community
52        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        // Remap edges
77        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        // Block "M1" / "M1 Pro" style pairs
127        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    /// Optional LLM tiebreaker pass for higher recall.
132    /// Returns (src_id, tgt_id, merge) — caller should inspect and apply.
133    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    // Winkler boost for common prefix
220    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    // --- Gap test #84: community_boost ---
342
343    #[test]
344    fn test_dedup_community_boost() {
345        // "abcXdefg" / "abcYdefh": raw JW=0.8833, boosted=0.9333
346        // Without boost at 0.90: 0.8833 < 0.90 → NOT merged
347        // With    boost at 0.90: 0.9333 > 0.90 → merged
348        // With    boost at 0.97: 0.9333 < 0.97 → NOT merged
349        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        // With community boost, threshold 0.90 → merge
355        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        // With community boost, threshold 0.97 → still below, not merged
362        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        // Without community boost, threshold 0.90 → not merged (raw < 0.90)
369        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    // --- Gap test #86: short_label ---
383
384    #[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    // --- Gap test #87: short_label_blocked ---
400
401    #[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    // --- Gap test #89: cross_project_guard ---
417
418    #[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    // --- Gap test #90: llm_tiebreaker ---
431
432    #[test]
433    fn test_dedup_llm_tiebreaker() {
434        // LLM tiebreaker returns a decision for every ambiguous pair
435        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        // With high threshold (0.99), even near-identical labels stay separate
445        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        // Same-length labels (no variant-pair issue): JW ~0.94
454        // With strict 0.99 → not merged; with loose 0.90 → merged
455        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    // --- Edge case tests ---
469
470    #[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}