Skip to main content

codehelion_core/engine/
group.rs

1//! Clone-pair grouping and noise scoring.
2//!
3//! Pairs whose matched content has the same collision-resistant identity form
4//! one clone group; instances are deduplicated and the canonical instance is
5//! chosen by a deterministic tie-break. Exact-content equivalence classes
6//! trivially satisfy the constraint that every member match the canonical
7//! instance, so this interface can later be re-implemented with medoid-based
8//! grouping for near-match clones without changing callers.
9//!
10//! Each group carries two noise signals instead of being silently dropped:
11//! low content entropy (degenerate repetition such as long literal tables) and
12//! high instance degree (idiomatic boilerplate that recurs all over a
13//! codebase). Thresholds only set a suppression marker; reporting stays
14//! honest about what was found.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use super::fingerprint::{ContentDigest, norm_token_hash};
19use super::normalize::normalize;
20use super::{
21    CloneClass, CloneGroup, ClonePair, EngineConfig, InputFile, Instance, LiteralNorm,
22    SuppressReason,
23};
24use crate::frontend::Token;
25
26/// Shannon entropy, in bits, of a token slice's normalized-token
27/// distribution.
28///
29/// Low entropy marks degenerate repetition — a long literal table, a run of
30/// near-identical accessors — which is a noise signal rather than a finding.
31/// Any mode that reports clone groups scores its content the same way, so the
32/// signal means the same thing across modes.
33#[must_use]
34#[allow(clippy::cast_precision_loss)] // token counts are far below 2^52
35pub fn content_entropy_bits(tokens: &[Token], literals: LiteralNorm) -> f64 {
36    let normalized = normalize(tokens, literals);
37    let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
38    for token in &normalized {
39        *counts.entry(norm_token_hash(token)).or_insert(0) += 1;
40    }
41    let total = normalized.len();
42    if total == 0 {
43        return 0.0;
44    }
45    counts
46        .values()
47        .map(|&c| {
48            let p = c as f64 / total as f64;
49            -p * p.log2()
50        })
51        .sum()
52}
53
54/// Entropy as a share of the largest value a token sequence of this length
55/// could have.
56///
57/// Absolute entropy grows simply because there are more positions to fill.
58/// Dividing by `log2(token_count)` makes the suppression floor describe
59/// diversity rather than clone length. Empty and one-token sequences carry no
60/// diversity evidence and return `0.0`.
61#[must_use]
62#[allow(clippy::cast_precision_loss)] // token counts are far below 2^52
63pub fn entropy_ratio(entropy_bits: f64, token_count: usize) -> f64 {
64    if token_count <= 1 {
65        0.0
66    } else {
67        entropy_bits / (token_count as f64).log2()
68    }
69}
70
71/// Entropy of one instance's matched content, under the run's literal
72/// strategy.
73fn entropy_bits(files: &[InputFile<'_>], instance: &Instance, config: &EngineConfig) -> f64 {
74    let slice = &files[instance.file].tokens[instance.token_start..instance.token_end];
75    content_entropy_bits(slice, config.literals)
76}
77
78/// Group clone pairs into clone groups by matched content.
79///
80/// Instances are deduplicated across pairs, members are sorted, and the
81/// canonical instance is the first member under `(file, token range)` order.
82/// Groups come back sorted by their canonical instance, so output order does
83/// not depend on input order.
84#[must_use]
85pub fn group_pairs(
86    pairs: &[ClonePair],
87    files: &[InputFile<'_>],
88    config: &EngineConfig,
89) -> Vec<CloneGroup> {
90    let mut by_key: BTreeMap<(u64, ContentDigest), Vec<&ClonePair>> = BTreeMap::new();
91    for pair in pairs {
92        by_key
93            .entry((pair.content_key, pair.content_digest))
94            .or_default()
95            .push(pair);
96    }
97
98    let mut groups: Vec<CloneGroup> = by_key
99        .into_iter()
100        .map(|((content_key, _), pairs)| {
101            let mut members: Vec<Instance> = Vec::new();
102            let mut seen: BTreeSet<(usize, usize, usize)> = BTreeSet::new();
103            for pair in &pairs {
104                for candidate in [&pair.a, &pair.b] {
105                    if seen.insert((candidate.file, candidate.token_start, candidate.token_end)) {
106                        members.push(candidate.clone());
107                    }
108                }
109            }
110            members.sort_by_key(|m| (m.file, m.token_start, m.token_end));
111
112            let clone_type = if pairs.iter().any(|p| p.clone_type == CloneClass::Type2) {
113                CloneClass::Type2
114            } else {
115                CloneClass::Type1
116            };
117            let score = pairs.iter().map(|p| p.score).fold(f64::INFINITY, f64::min);
118            let entropy = entropy_bits(files, &members[0], config);
119            let token_count = members[0].token_end - members[0].token_start;
120            let entropy_ratio = entropy_ratio(entropy, token_count);
121            let degree = members.len();
122            let suppressed = if degree > config.degree_cap {
123                Some(SuppressReason::HighFrequency)
124            } else if entropy_ratio < config.entropy_ratio_floor {
125                Some(SuppressReason::LowEntropy)
126            } else {
127                None
128            };
129            CloneGroup {
130                content_key,
131                clone_type,
132                score,
133                members,
134                entropy_bits: entropy,
135                suppressed,
136            }
137        })
138        .collect();
139
140    groups.sort_by_key(|g| {
141        let c = &g.members[0];
142        (c.file, c.token_start, c.token_end, g.content_key)
143    });
144    groups
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used, clippy::expect_used)]
149mod tests {
150    use super::*;
151    use crate::engine::fingerprint::raw_sequence_digest;
152    use crate::frontend::{Lexeme, SourceSpan, TokenKind};
153
154    fn tokens(texts: &[&str]) -> Vec<Token> {
155        texts
156            .iter()
157            .enumerate()
158            .map(|(index, text)| Token {
159                kind: TokenKind::Identifier,
160                text: Lexeme::from(*text),
161                span: SourceSpan {
162                    start_byte: index,
163                    end_byte: index + text.len(),
164                    start_line: 1,
165                    start_column: 1,
166                },
167            })
168            .collect()
169    }
170
171    fn instance(file: usize) -> Instance {
172        Instance {
173            file,
174            token_start: 0,
175            token_end: 2,
176            start_line: 1,
177            end_line: 1,
178            unit: None,
179        }
180    }
181
182    #[test]
183    fn a_shared_64_bit_key_cannot_merge_distinct_verified_content() {
184        // Model an attacker-controlled FNV collision without relying on a
185        // particular construction: candidate keys are intentionally small,
186        // but the grouping relation must use the independent BLAKE3 digest.
187        let first = tokens(&["first", "content"]);
188        let second = tokens(&["first", "content"]);
189        let third = tokens(&["other", "tokens"]);
190        let fourth = tokens(&["other", "tokens"]);
191        let files = [
192            InputFile {
193                tokens: &first,
194                units: &[],
195            },
196            InputFile {
197                tokens: &second,
198                units: &[],
199            },
200            InputFile {
201                tokens: &third,
202                units: &[],
203            },
204            InputFile {
205                tokens: &fourth,
206                units: &[],
207            },
208        ];
209        let shared_candidate_key = 0x4b1d_fa11_u64;
210        let pairs = [
211            ClonePair {
212                content_key: shared_candidate_key,
213                content_digest: raw_sequence_digest(&first),
214                clone_type: CloneClass::Type1,
215                score: 1.0,
216                a: instance(0),
217                b: instance(1),
218            },
219            ClonePair {
220                content_key: shared_candidate_key,
221                content_digest: raw_sequence_digest(&third),
222                clone_type: CloneClass::Type1,
223                score: 1.0,
224                a: instance(2),
225                b: instance(3),
226            },
227        ];
228
229        let groups = group_pairs(&pairs, &files, &EngineConfig::default());
230
231        assert_eq!(groups.len(), 2);
232        assert!(groups.iter().all(|group| group.members.len() == 2));
233        assert!(
234            groups
235                .iter()
236                .all(|group| group.content_key == shared_candidate_key)
237        );
238    }
239
240    #[test]
241    fn entropy_separates_repetition_from_variety() {
242        let empty = content_entropy_bits(&[], LiteralNorm::Full);
243        assert!(empty.abs() < 1e-12);
244
245        // Identifiers normalize scope-locally, so repetition shows up as a
246        // single symbol: no information, zero bits.
247        let repeated = content_entropy_bits(&tokens(&["a", "a", "a", "a"]), LiteralNorm::Full);
248        assert!(repeated.abs() < 1e-12);
249
250        // Four equally frequent distinct symbols carry exactly two bits.
251        let varied = content_entropy_bits(&tokens(&["a", "b", "c", "d"]), LiteralNorm::Full);
252        assert!(varied > repeated);
253        assert!((varied - 2.0).abs() < 1e-9, "expected 2 bits, got {varied}");
254
255        assert!(entropy_ratio(repeated, 4).abs() < 1e-12);
256        assert!((entropy_ratio(varied, 4) - 1.0).abs() < 1e-12);
257    }
258
259    #[test]
260    fn entropy_ratio_is_not_an_absolute_clone_length_floor() {
261        // Both slices are maximally diverse at their own length. Their bits
262        // differ because one is longer, but their normalized evidence is the
263        // same and neither can be hidden by a ratio floor below one.
264        let short = tokens(&["a", "b", "c", "d"]);
265        let long = tokens(&["a", "b", "c", "d", "e", "f", "g", "h"]);
266        let short_bits = content_entropy_bits(&short, LiteralNorm::Full);
267        let long_bits = content_entropy_bits(&long, LiteralNorm::Full);
268        assert!(long_bits > short_bits);
269        assert!((entropy_ratio(short_bits, short.len()) - 1.0).abs() < 1e-12);
270        assert!((entropy_ratio(long_bits, long.len()) - 1.0).abs() < 1e-12);
271    }
272}