use std::collections::{BTreeMap, BTreeSet};
use super::fingerprint::{ContentDigest, norm_token_hash};
use super::normalize::normalize;
use super::{
CloneClass, CloneGroup, ClonePair, EngineConfig, InputFile, Instance, LiteralNorm,
SuppressReason,
};
use crate::frontend::Token;
#[must_use]
#[allow(clippy::cast_precision_loss)] pub fn content_entropy_bits(tokens: &[Token], literals: LiteralNorm) -> f64 {
let normalized = normalize(tokens, literals);
let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
for token in &normalized {
*counts.entry(norm_token_hash(token)).or_insert(0) += 1;
}
let total = normalized.len();
if total == 0 {
return 0.0;
}
counts
.values()
.map(|&c| {
let p = c as f64 / total as f64;
-p * p.log2()
})
.sum()
}
#[must_use]
#[allow(clippy::cast_precision_loss)] pub fn entropy_ratio(entropy_bits: f64, token_count: usize) -> f64 {
if token_count <= 1 {
0.0
} else {
entropy_bits / (token_count as f64).log2()
}
}
fn entropy_bits(files: &[InputFile<'_>], instance: &Instance, config: &EngineConfig) -> f64 {
let slice = &files[instance.file].tokens[instance.token_start..instance.token_end];
content_entropy_bits(slice, config.literals)
}
#[must_use]
pub fn group_pairs(
pairs: &[ClonePair],
files: &[InputFile<'_>],
config: &EngineConfig,
) -> Vec<CloneGroup> {
let mut by_key: BTreeMap<(u64, ContentDigest), Vec<&ClonePair>> = BTreeMap::new();
for pair in pairs {
by_key
.entry((pair.content_key, pair.content_digest))
.or_default()
.push(pair);
}
let mut groups: Vec<CloneGroup> = by_key
.into_iter()
.map(|((content_key, _), pairs)| {
let mut members: Vec<Instance> = Vec::new();
let mut seen: BTreeSet<(usize, usize, usize)> = BTreeSet::new();
for pair in &pairs {
for candidate in [&pair.a, &pair.b] {
if seen.insert((candidate.file, candidate.token_start, candidate.token_end)) {
members.push(candidate.clone());
}
}
}
members.sort_by_key(|m| (m.file, m.token_start, m.token_end));
let clone_type = if pairs.iter().any(|p| p.clone_type == CloneClass::Type2) {
CloneClass::Type2
} else {
CloneClass::Type1
};
let score = pairs.iter().map(|p| p.score).fold(f64::INFINITY, f64::min);
let entropy = entropy_bits(files, &members[0], config);
let token_count = members[0].token_end - members[0].token_start;
let entropy_ratio = entropy_ratio(entropy, token_count);
let degree = members.len();
let suppressed = if degree > config.degree_cap {
Some(SuppressReason::HighFrequency)
} else if entropy_ratio < config.entropy_ratio_floor {
Some(SuppressReason::LowEntropy)
} else {
None
};
CloneGroup {
content_key,
clone_type,
score,
members,
entropy_bits: entropy,
suppressed,
}
})
.collect();
groups.sort_by_key(|g| {
let c = &g.members[0];
(c.file, c.token_start, c.token_end, g.content_key)
});
groups
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::engine::fingerprint::raw_sequence_digest;
use crate::frontend::{Lexeme, SourceSpan, TokenKind};
fn tokens(texts: &[&str]) -> Vec<Token> {
texts
.iter()
.enumerate()
.map(|(index, text)| Token {
kind: TokenKind::Identifier,
text: Lexeme::from(*text),
span: SourceSpan {
start_byte: index,
end_byte: index + text.len(),
start_line: 1,
start_column: 1,
},
})
.collect()
}
fn instance(file: usize) -> Instance {
Instance {
file,
token_start: 0,
token_end: 2,
start_line: 1,
end_line: 1,
unit: None,
}
}
#[test]
fn a_shared_64_bit_key_cannot_merge_distinct_verified_content() {
let first = tokens(&["first", "content"]);
let second = tokens(&["first", "content"]);
let third = tokens(&["other", "tokens"]);
let fourth = tokens(&["other", "tokens"]);
let files = [
InputFile {
tokens: &first,
units: &[],
},
InputFile {
tokens: &second,
units: &[],
},
InputFile {
tokens: &third,
units: &[],
},
InputFile {
tokens: &fourth,
units: &[],
},
];
let shared_candidate_key = 0x4b1d_fa11_u64;
let pairs = [
ClonePair {
content_key: shared_candidate_key,
content_digest: raw_sequence_digest(&first),
clone_type: CloneClass::Type1,
score: 1.0,
a: instance(0),
b: instance(1),
},
ClonePair {
content_key: shared_candidate_key,
content_digest: raw_sequence_digest(&third),
clone_type: CloneClass::Type1,
score: 1.0,
a: instance(2),
b: instance(3),
},
];
let groups = group_pairs(&pairs, &files, &EngineConfig::default());
assert_eq!(groups.len(), 2);
assert!(groups.iter().all(|group| group.members.len() == 2));
assert!(
groups
.iter()
.all(|group| group.content_key == shared_candidate_key)
);
}
#[test]
fn entropy_separates_repetition_from_variety() {
let empty = content_entropy_bits(&[], LiteralNorm::Full);
assert!(empty.abs() < 1e-12);
let repeated = content_entropy_bits(&tokens(&["a", "a", "a", "a"]), LiteralNorm::Full);
assert!(repeated.abs() < 1e-12);
let varied = content_entropy_bits(&tokens(&["a", "b", "c", "d"]), LiteralNorm::Full);
assert!(varied > repeated);
assert!((varied - 2.0).abs() < 1e-9, "expected 2 bits, got {varied}");
assert!(entropy_ratio(repeated, 4).abs() < 1e-12);
assert!((entropy_ratio(varied, 4) - 1.0).abs() < 1e-12);
}
#[test]
fn entropy_ratio_is_not_an_absolute_clone_length_floor() {
let short = tokens(&["a", "b", "c", "d"]);
let long = tokens(&["a", "b", "c", "d", "e", "f", "g", "h"]);
let short_bits = content_entropy_bits(&short, LiteralNorm::Full);
let long_bits = content_entropy_bits(&long, LiteralNorm::Full);
assert!(long_bits > short_bits);
assert!((entropy_ratio(short_bits, short.len()) - 1.0).abs() < 1e-12);
assert!((entropy_ratio(long_bits, long.len()) - 1.0).abs() < 1e-12);
}
}