1use 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#[must_use]
34#[allow(clippy::cast_precision_loss)] pub 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#[must_use]
62#[allow(clippy::cast_precision_loss)] pub 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
71fn 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#[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 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 let repeated = content_entropy_bits(&tokens(&["a", "a", "a", "a"]), LiteralNorm::Full);
248 assert!(repeated.abs() < 1e-12);
249
250 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 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}