Skip to main content

srcgraph_metrics/
clone_detection.rs

1//! Clone detection via n-gram Jaccard similarity on per-method fingerprint
2//! token streams.
3//!
4//! Each class node carries a `methodFingerprints` JSON blob with shape
5//!
6//!   `{"methods": [{"name": str, "tokens": "KW_IF BINOP INVOKE …", "line": int, …}, …]}`
7//!
8//! where `tokens` is a space-separated string of normalised token classes
9//! emitted by the extractor. We:
10//!
11//! 1. Build the n-gram set (default `n = 3`) of each method's token stream.
12//! 2. Score every cross-class method pair by Jaccard similarity
13//!    `|A ∩ B| / |A ∪ B|`. Pairs with `sim ≥ threshold` (default 0.7) are
14//!    emitted as clone pairs.
15//! 3. Run union-find over the clone-pair graph to collapse pairs into
16//!    transitive clone families.
17//!
18//! Two practical filters mirror the Python reference (`analysis/clone_detection.py`):
19//!
20//! - **token-count ratio gate** — skip pairs whose token counts differ by
21//!   more than 2× before computing n-grams (cheap O(1) prune).
22//! - **same-class same-name skip** — partial-class siblings would otherwise
23//!   self-match.
24//!
25//! See `DESIGN.md` (Phase 3) at the workspace root.
26
27use petgraph::Graph;
28use serde::{Deserialize, Serialize};
29use std::collections::{HashMap, HashSet};
30
31use srcgraph_core::{ClassNode, EdgeKind};
32
33/// One method's clone-detection record, harvested from a class's
34/// `methodFingerprints` blob and tagged with the owning class.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct MethodFingerprint {
37    pub node_id: String,
38    pub class_name: String,
39    pub name: String,
40    /// Space-separated token-class string from the extractor.
41    pub tokens: String,
42    /// Source line (best-effort; 0 when absent).
43    pub line: u32,
44    pub end_line: u32,
45    pub params: u32,
46}
47
48/// A similarity edge between two methods.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClonePair {
51    pub m1: MethodFingerprint,
52    pub m2: MethodFingerprint,
53    pub similarity: f64,
54}
55
56/// A connected component over the clone-pair graph.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CloneFamily {
59    pub id: usize,
60    pub size: usize,
61    pub members: Vec<MethodFingerprint>,
62    /// Mean similarity across in-family pairs.
63    pub avg_similarity: f64,
64}
65
66/// Whole-graph clone-detection readout.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CloneAnalysis {
69    pub pairs: Vec<ClonePair>,
70    pub families: Vec<CloneFamily>,
71    pub total_methods: usize,
72    /// Distinct (`node_id`, `name`) keys that participate in any clone pair.
73    pub cloned_methods: usize,
74    pub clone_ratio: f64,
75}
76
77/// Parse the `{"methods": [...]}` fingerprint blob attached to a single class.
78///
79/// Returns `None` if the blob isn't an object with a `methods` array; missing
80/// per-field values default (e.g. `line = 0`, `tokens = ""`).
81pub fn parse_method_fingerprints(
82    blob: &serde_json::Value,
83    node_id: &str,
84    class_name: &str,
85) -> Option<Vec<MethodFingerprint>> {
86    let methods = blob.get("methods")?.as_array()?;
87    let mut out = Vec::with_capacity(methods.len());
88    for m in methods {
89        let Some(obj) = m.as_object() else { continue };
90        let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
91        let tokens = obj.get("tokens").and_then(|v| v.as_str()).unwrap_or("").to_owned();
92        let line = obj.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
93        let end_line = obj.get("endLine").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
94        let params = obj.get("params").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
95        out.push(MethodFingerprint {
96            node_id: node_id.to_owned(),
97            class_name: class_name.to_owned(),
98            name,
99            tokens,
100            line,
101            end_line,
102            params,
103        });
104    }
105    Some(out)
106}
107
108/// Build the set of n-grams of a space-separated token string.
109///
110/// Mirrors the Python reference: a stream shorter than `n` collapses to the
111/// single full-stream tuple (or the empty set when the stream is empty).
112/// N-grams are joined back into a `String` with `'\u{1f}'` (unit separator) as
113/// a delimiter — chosen because it cannot appear inside a normalised token
114/// class — so hashing / equality stay cheap.
115pub fn extract_ngrams(token_str: &str, n: usize) -> HashSet<String> {
116    let tokens: Vec<&str> = token_str.split_whitespace().collect();
117    let mut out = HashSet::new();
118    if tokens.is_empty() {
119        return out;
120    }
121    if tokens.len() < n {
122        out.insert(tokens.join("\u{1f}"));
123        return out;
124    }
125    for win in tokens.windows(n) {
126        out.insert(win.join("\u{1f}"));
127    }
128    out
129}
130
131/// Jaccard similarity on n-gram sets of two token strings. Empty-on-both
132/// yields `0.0` (matches Python).
133pub fn ngram_jaccard(a_tokens: &str, b_tokens: &str, n: usize) -> f64 {
134    let a = extract_ngrams(a_tokens, n);
135    let b = extract_ngrams(b_tokens, n);
136    jaccard(&a, &b)
137}
138
139fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
140    if a.is_empty() && b.is_empty() {
141        return 0.0;
142    }
143    let inter = a.intersection(b).count();
144    let union = a.len() + b.len() - inter;
145    if union == 0 {
146        0.0
147    } else {
148        inter as f64 / union as f64
149    }
150}
151
152/// Detect clone pairs among methods by n-gram Jaccard similarity. O(n²) over
153/// the method list — caller is responsible for capping graph size (the Python
154/// reference skips graphs above 500 nodes).
155pub fn detect_clone_pairs(
156    methods: &[MethodFingerprint],
157    threshold: f64,
158    n: usize,
159) -> Vec<ClonePair> {
160    let ngram_sets: Vec<HashSet<String>> = methods
161        .iter()
162        .map(|m| extract_ngrams(&m.tokens, n))
163        .collect();
164    let token_counts: Vec<usize> = methods
165        .iter()
166        .map(|m| m.tokens.split_whitespace().count())
167        .collect();
168
169    let mut pairs = Vec::new();
170    for i in 0..methods.len() {
171        for j in (i + 1)..methods.len() {
172            // Cheap O(1) prune: skip if token-count ratio > 2:1.
173            let (ci, cj) = (token_counts[i], token_counts[j]);
174            if ci > 0 && cj > 0 {
175                let (lo, hi) = if ci < cj { (ci, cj) } else { (cj, ci) };
176                if (hi as f64) / (lo as f64) > 2.0 {
177                    continue;
178                }
179            }
180            // Skip same-class same-name (partial-class sibling, not a clone).
181            if methods[i].node_id == methods[j].node_id
182                && methods[i].name == methods[j].name
183            {
184                continue;
185            }
186            let a = &ngram_sets[i];
187            let b = &ngram_sets[j];
188            if a.is_empty() || b.is_empty() {
189                continue;
190            }
191            let sim = jaccard(a, b);
192            if sim >= threshold {
193                pairs.push(ClonePair {
194                    m1: methods[i].clone(),
195                    m2: methods[j].clone(),
196                    similarity: (sim * 10_000.0).round() / 10_000.0,
197                });
198            }
199        }
200    }
201    pairs
202}
203
204fn method_key(m: &MethodFingerprint) -> String {
205    format!("{}::{}:{}", m.node_id, m.name, m.line)
206}
207
208/// Group clone pairs into transitive families via union-find. Returns families
209/// sorted by descending member count.
210pub fn group_clone_families(pairs: &[ClonePair]) -> Vec<CloneFamily> {
211    if pairs.is_empty() {
212        return Vec::new();
213    }
214
215    // Compact integer ids for union-find.
216    let mut key_to_idx: HashMap<String, usize> = HashMap::new();
217    let mut methods_by_idx: Vec<MethodFingerprint> = Vec::new();
218    let mut pair_idx: Vec<(usize, usize)> = Vec::with_capacity(pairs.len());
219
220    for p in pairs {
221        let k1 = method_key(&p.m1);
222        let k2 = method_key(&p.m2);
223        let i1 = *key_to_idx.entry(k1).or_insert_with(|| {
224            methods_by_idx.push(p.m1.clone());
225            methods_by_idx.len() - 1
226        });
227        let i2 = *key_to_idx.entry(k2).or_insert_with(|| {
228            methods_by_idx.push(p.m2.clone());
229            methods_by_idx.len() - 1
230        });
231        pair_idx.push((i1, i2));
232    }
233
234    let mut parent: Vec<usize> = (0..methods_by_idx.len()).collect();
235    fn find(parent: &mut [usize], mut x: usize) -> usize {
236        while parent[x] != x {
237            parent[x] = parent[parent[x]];
238            x = parent[x];
239        }
240        x
241    }
242    for &(a, b) in &pair_idx {
243        let ra = find(&mut parent, a);
244        let rb = find(&mut parent, b);
245        if ra != rb {
246            parent[ra] = rb;
247        }
248    }
249
250    // Bucket members by root.
251    let mut buckets: HashMap<usize, Vec<usize>> = HashMap::new();
252    for i in 0..methods_by_idx.len() {
253        let r = find(&mut parent, i);
254        buckets.entry(r).or_default().push(i);
255    }
256
257    // Pair-similarity lookup keyed on sorted index pair.
258    let mut pair_sim: HashMap<(usize, usize), f64> = HashMap::new();
259    for (idx, p) in pairs.iter().enumerate() {
260        let (a, b) = pair_idx[idx];
261        let k = if a < b { (a, b) } else { (b, a) };
262        pair_sim.insert(k, p.similarity);
263    }
264
265    let mut families: Vec<CloneFamily> = buckets
266        .into_iter()
267        .map(|(_root, members)| {
268            // Average similarity across in-family pairs.
269            let mut sum = 0.0;
270            let mut count = 0usize;
271            let mset: HashSet<usize> = members.iter().copied().collect();
272            for (&(a, b), &sim) in &pair_sim {
273                if mset.contains(&a) && mset.contains(&b) {
274                    sum += sim;
275                    count += 1;
276                }
277            }
278            let avg = if count > 0 { sum / count as f64 } else { 0.0 };
279            CloneFamily {
280                id: 0,
281                size: members.len(),
282                members: members.into_iter().map(|i| methods_by_idx[i].clone()).collect(),
283                avg_similarity: (avg * 10_000.0).round() / 10_000.0,
284            }
285        })
286        .collect();
287
288    // Sort by size desc, then assign deterministic family ids.
289    families.sort_by(|a, b| b.size.cmp(&a.size));
290    for (i, f) in families.iter_mut().enumerate() {
291        f.id = i;
292    }
293    families
294}
295
296/// Run clone detection across every node's `methodFingerprints` blob.
297///
298/// `threshold`/`n` follow the Python reference defaults of 0.7 / 3 when
299/// callers pass those; pure-Rust callers can tune them.
300pub fn compute_clone_analysis<N, E>(
301    graph: &Graph<N, E>,
302    threshold: f64,
303    n: usize,
304) -> CloneAnalysis
305where
306    N: ClassNode,
307    E: EdgeKind,
308{
309    let mut all_methods: Vec<MethodFingerprint> = Vec::new();
310    for nx in graph.node_indices() {
311        let node = &graph[nx];
312        let Some(blob) = node.method_fingerprints() else {
313            continue;
314        };
315        // The blob can be either the {methods: [...]} object or a JSON string
316        // when stored verbatim from GraphML. Handle both.
317        let parsed = if let Some(s) = blob.as_str() {
318            serde_json::from_str::<serde_json::Value>(s)
319                .ok()
320                .and_then(|v| parse_method_fingerprints(&v, node.id(), node.id()))
321        } else {
322            parse_method_fingerprints(blob, node.id(), node.id())
323        };
324        if let Some(mut ms) = parsed {
325            all_methods.append(&mut ms);
326        }
327    }
328
329    let pairs = detect_clone_pairs(&all_methods, threshold, n);
330    let families = group_clone_families(&pairs);
331
332    let mut cloned_keys: HashSet<String> = HashSet::new();
333    for p in &pairs {
334        cloned_keys.insert(format!("{}::{}", p.m1.node_id, p.m1.name));
335        cloned_keys.insert(format!("{}::{}", p.m2.node_id, p.m2.name));
336    }
337    let total = all_methods.len();
338    let clone_ratio = if total > 0 {
339        (cloned_keys.len() as f64 / total as f64 * 10_000.0).round() / 10_000.0
340    } else {
341        0.0
342    };
343
344    CloneAnalysis {
345        pairs,
346        families,
347        total_methods: total,
348        cloned_methods: cloned_keys.len(),
349        clone_ratio,
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use srcgraph_core::{OwnedClassNode, OwnedGraph};
357    use petgraph::Graph;
358    use serde_json::json;
359
360    fn class(id: &str, fingerprints: Option<serde_json::Value>) -> OwnedClassNode {
361        OwnedClassNode {
362            id: id.to_owned(),
363            name: id.to_owned(),
364            namespace: "test".to_owned(),
365            line_count: 10,
366            method_count: 1,
367            halstead_eta1: 0,
368            halstead_eta2: 0,
369            halstead_n1: 0,
370            halstead_n2: 0,
371            method_connectivity: None,
372            method_fingerprints: fingerprints,
373            method_tokens: None,
374            call_sequences: None,
375            cyclomatic_complexity: None,
376            path_conditions: None,
377            invariants: None,
378            error_messages: None,
379            magic_numbers: None,
380            dead_code: None,
381            tenant_branches: None,
382            state_transitions: None,
383        }
384    }
385
386    fn fp(name: &str, tokens: &str, line: u32) -> serde_json::Value {
387        json!({"name": name, "tokens": tokens, "line": line, "endLine": line + 10, "params": 0})
388    }
389
390    #[test]
391    fn ngrams_short_stream_collapses_to_one_tuple() {
392        let ng = extract_ngrams("A B", 3);
393        assert_eq!(ng.len(), 1);
394        assert!(ng.contains(&"A\u{1f}B".to_owned()));
395    }
396
397    #[test]
398    fn ngrams_basic_trigrams() {
399        let ng = extract_ngrams("A B C D E", 3);
400        assert_eq!(ng.len(), 3);
401        assert!(ng.contains(&"A\u{1f}B\u{1f}C".to_owned()));
402        assert!(ng.contains(&"C\u{1f}D\u{1f}E".to_owned()));
403    }
404
405    #[test]
406    fn ngrams_empty_yields_empty() {
407        assert!(extract_ngrams("", 3).is_empty());
408    }
409
410    #[test]
411    fn jaccard_identical_is_one() {
412        let s = "KW_IF BINOP INVOKE KW_RETURN";
413        assert!((ngram_jaccard(s, s, 3) - 1.0).abs() < 1e-12);
414    }
415
416    #[test]
417    fn jaccard_disjoint_is_zero() {
418        let a = "X Y Z W V";
419        let b = "P Q R S T";
420        assert_eq!(ngram_jaccard(a, b, 3), 0.0);
421    }
422
423    #[test]
424    fn detect_pairs_identical_methods_match() {
425        let toks = "KW_IF BINOP INVOKE KW_RETURN KW_ELSE";
426        let methods = vec![
427            MethodFingerprint {
428                node_id: "A".into(),
429                class_name: "A".into(),
430                name: "foo".into(),
431                tokens: toks.into(),
432                line: 1,
433                end_line: 10,
434                params: 0,
435            },
436            MethodFingerprint {
437                node_id: "B".into(),
438                class_name: "B".into(),
439                name: "bar".into(),
440                tokens: toks.into(),
441                line: 1,
442                end_line: 10,
443                params: 0,
444            },
445        ];
446        let pairs = detect_clone_pairs(&methods, 0.7, 3);
447        assert_eq!(pairs.len(), 1);
448        assert!((pairs[0].similarity - 1.0).abs() < 1e-12);
449    }
450
451    #[test]
452    fn detect_pairs_skips_token_count_outliers() {
453        // 2 tokens vs 10 tokens — ratio 5:1, pruned regardless of overlap.
454        let methods = vec![
455            MethodFingerprint {
456                node_id: "A".into(),
457                class_name: "A".into(),
458                name: "small".into(),
459                tokens: "KW_IF BINOP".into(),
460                line: 1,
461                end_line: 2,
462                params: 0,
463            },
464            MethodFingerprint {
465                node_id: "B".into(),
466                class_name: "B".into(),
467                name: "big".into(),
468                tokens: "KW_IF BINOP INVOKE KW_RETURN KW_ELSE KW_FOR KW_WHILE KW_DO KW_TRY KW_CATCH".into(),
469                line: 1,
470                end_line: 20,
471                params: 0,
472            },
473        ];
474        let pairs = detect_clone_pairs(&methods, 0.0, 3);
475        assert!(pairs.is_empty());
476    }
477
478    #[test]
479    fn detect_pairs_skips_same_class_same_name() {
480        // Same node_id and name: partial-class sibling, not a clone.
481        let toks = "KW_IF BINOP INVOKE";
482        let methods = vec![
483            MethodFingerprint {
484                node_id: "A".into(),
485                class_name: "A".into(),
486                name: "foo".into(),
487                tokens: toks.into(),
488                line: 1,
489                end_line: 5,
490                params: 0,
491            },
492            MethodFingerprint {
493                node_id: "A".into(),
494                class_name: "A".into(),
495                name: "foo".into(),
496                tokens: toks.into(),
497                line: 100,
498                end_line: 105,
499                params: 0,
500            },
501        ];
502        assert!(detect_clone_pairs(&methods, 0.5, 3).is_empty());
503    }
504
505    #[test]
506    fn families_union_transitive_chain() {
507        // foo ~ bar, bar ~ baz → one family of size 3.
508        let mk = |n: &str, line: u32| MethodFingerprint {
509            node_id: n.into(),
510            class_name: n.into(),
511            name: n.into(),
512            tokens: String::new(),
513            line,
514            end_line: line + 1,
515            params: 0,
516        };
517        let foo = mk("foo", 1);
518        let bar = mk("bar", 1);
519        let baz = mk("baz", 1);
520        let pairs = vec![
521            ClonePair {
522                m1: foo.clone(),
523                m2: bar.clone(),
524                similarity: 0.9,
525            },
526            ClonePair {
527                m1: bar.clone(),
528                m2: baz.clone(),
529                similarity: 0.8,
530            },
531        ];
532        let fams = group_clone_families(&pairs);
533        assert_eq!(fams.len(), 1);
534        assert_eq!(fams[0].size, 3);
535        assert!((fams[0].avg_similarity - 0.85).abs() < 1e-6);
536    }
537
538    #[test]
539    fn families_sorted_by_size_desc() {
540        let mk = |n: &str| MethodFingerprint {
541            node_id: n.into(),
542            class_name: n.into(),
543            name: n.into(),
544            tokens: String::new(),
545            line: 1,
546            end_line: 2,
547            params: 0,
548        };
549        let pairs = vec![
550            // Small family of 2.
551            ClonePair {
552                m1: mk("a"),
553                m2: mk("b"),
554                similarity: 0.8,
555            },
556            // Large family of 3.
557            ClonePair {
558                m1: mk("p"),
559                m2: mk("q"),
560                similarity: 0.9,
561            },
562            ClonePair {
563                m1: mk("q"),
564                m2: mk("r"),
565                similarity: 0.9,
566            },
567        ];
568        let fams = group_clone_families(&pairs);
569        assert_eq!(fams.len(), 2);
570        assert_eq!(fams[0].size, 3);
571        assert_eq!(fams[0].id, 0);
572        assert_eq!(fams[1].size, 2);
573        assert_eq!(fams[1].id, 1);
574    }
575
576    #[test]
577    fn compute_clone_analysis_walks_graph() {
578        let mut g: OwnedGraph = Graph::new();
579        let toks = "KW_IF BINOP INVOKE KW_RETURN KW_ELSE";
580        g.add_node(class("A", Some(json!({"methods": [fp("foo", toks, 1)]}))));
581        g.add_node(class("B", Some(json!({"methods": [fp("bar", toks, 1)]}))));
582        // Distinct stream — should not match either.
583        g.add_node(class("C", Some(json!({"methods": [fp("baz", "X Y Z W V", 1)]}))));
584        // Missing blob — silently skipped.
585        g.add_node(class("D", None));
586
587        let r = compute_clone_analysis(&g, 0.7, 3);
588        assert_eq!(r.total_methods, 3);
589        assert_eq!(r.pairs.len(), 1);
590        assert_eq!(r.families.len(), 1);
591        assert_eq!(r.families[0].size, 2);
592        assert_eq!(r.cloned_methods, 2);
593        let expected = (2.0_f64 / 3.0 * 10_000.0).round() / 10_000.0;
594        assert!((r.clone_ratio - expected).abs() < 1e-6);
595    }
596
597    #[test]
598    fn compute_clone_analysis_accepts_string_encoded_blob() {
599        // GraphML round-trip can land the blob as a JSON-string-of-JSON-string.
600        let toks = "KW_IF BINOP INVOKE KW_RETURN KW_ELSE";
601        let inner = json!({"methods": [fp("foo", toks, 1)]});
602        let mut g: OwnedGraph = Graph::new();
603        g.add_node(class("A", Some(serde_json::Value::String(inner.to_string()))));
604        g.add_node(class("B", Some(json!({"methods": [fp("bar", toks, 1)]}))));
605
606        let r = compute_clone_analysis(&g, 0.7, 3);
607        assert_eq!(r.total_methods, 2);
608        assert_eq!(r.pairs.len(), 1);
609    }
610}