#![doc = include_str!("../docs/duplicate_files.md")]
use std::cmp::{max, min};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use anyhow::{ensure, Context, Result};
use blake3::Hash;
use clap::{Arg, ArgAction, Command};
use indicatif::ProgressBar;
use polars::frame::DataFrame;
use polars::prelude::{DataFrameJoinOps as _, DataType, Field, Schema, UniqueKeepStrategy};
use tracing::{info, warn};
use crate::utils::bow::{Bow, RankedToken, Token};
use crate::utils::dataframes::{self, has_column};
use crate::utils::fs::*;
use crate::utils::logger::{log_output_file, log_write_dataframe, log_write_rows, Logger};
use crate::utils::parallel::parallel_pipeline;
use crate::utils::regex::{KeywordFiles, Matcher};
pub fn cli() -> Command {
Command::new("duplicate_files")
.about("Detects duplicate files in a dataset, returning only unique files.")
.long_about(include_str!("../docs/duplicate_files.md"))
.disable_version_flag(true)
.arg(
Arg::new("input")
.short('i')
.long("input")
.value_name("INPUT_FILE.csv")
.help("Path to the input csv file storing the file paths.")
.required(true),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.value_name("OUTPUT_FILE.csv")
.help("Path to the output csv file to store unique files metadata.")
.required(false),
)
.arg(
Arg::new("map")
.short('m')
.long("map")
.value_name("MAP_FILE.csv")
.help("Path to the map csv file to store the mapping of clones to their originals.")
.required(false),
)
.arg(
Arg::new("force")
.short('f')
.long("force")
.help("Override the output CSV file if it already exists.")
.default_value("false")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("threads")
.short('n')
.long("threads")
.value_name("THREADS")
.help("Number of threads to use.")
.default_value("1")
.value_parser(clap::builder::RangedU64ValueParser::<usize>::new().range(1..)),
)
.arg(
Arg::new("similarity")
.short('s')
.help("Similarity criterion for duplicate detection.")
.default_value("exact")
.value_parser(["exact", "bow", "overlap"]),
)
.arg(
Arg::new("threshold")
.long("threshold")
.help("Similarity threshold for duplicate detection when using overlap similarity.")
.default_value("0.8")
.value_parser(clap::value_parser!(f64)),
)
.arg(
Arg::new("prefix")
.short('p')
.long("prefix")
.value_name("PREFIX_DEPTH")
.help("Maximum prefix depth for the overlap criterion. A depth of 1 filters candidates on the rarest \
tokens alone; deeper prefixes reject more candidates before checking that they are actually clones, at the cost of more token comparisons.")
.default_value("1")
.value_parser(clap::value_parser!(usize)),
)
.arg(
Arg::new("languages")
.short('l')
.long("languages")
.num_args(1..)
.action(ArgAction::Append)
.value_name("LANGUAGES_FILES.json")
.help("List of files mapping file extensions to languages. Only used by the 'overlap' criterion, \
which compares files within a language and never across languages. Files whose extension \
is listed in none of them are left uncompared. The files must be in JSON format.\n\
The files must have the following structure:\n \
{\n\
\"languages\": [\n\
{\n\
\"name\": \"LanguageName\",\n\
\"extensions\": [\".ext1\", \".ext2\", ...]\n\
},\n\
...\n\
]\n\
}")
.required(false)
)
.arg(
Arg::new("header")
.long("header")
.help("Name of column storing file paths in the input CSV file.")
.default_value("name"),
)
}
type FileId = usize;
const MAX_FILE_SIZE: u64 = 1024 * 1024 * 1024;
pub fn run(
input_path: &str,
output_path: Option<&str>,
map_path: Option<&str>,
force: bool,
similarity: &str,
threshold: f64,
prefix_depth: usize,
languages_file_paths: &[&str],
threads: usize,
input_header: &str,
logger: &Logger,
) -> Result<()> {
let default_output_path: String = format!("{input_path}.unique.csv");
let default_map_path: String = format!("{input_path}.duplicates_map.csv");
let output_path: &str = output_path.unwrap_or(&default_output_path);
let map_path: &str = map_path.unwrap_or(&default_map_path);
let criterion: Criterion =
Criterion::parse(similarity, Threshold::new(threshold)?, prefix_depth)?;
check_path(input_path)?;
log_output_file(output_path, false, force)?;
log_output_file(map_path, false, force)?;
let files: DataFrame = open_csv(
input_path,
Some(Schema::from_iter(vec![
Field::new(input_header.into(), DataType::String),
Field::new("extension".into(), DataType::String),
Field::new("loc".into(), DataType::UInt32),
Field::new("words".into(), DataType::UInt32),
])),
None,
)?;
ensure!(
has_column(&files, input_header),
"File {input_path} does not contain column '{input_header}'."
);
let rows_count: usize = files.height();
let files: DataFrame = files.unique_stable(
Some(&[input_header.to_string()]),
UniqueKeepStrategy::First,
None,
)?;
let file_count: usize = files.height();
info!("{} files found.", file_count);
if file_count < rows_count {
info!(
"{} rows repeat the path of an earlier row and are ignored.",
rows_count - file_count
);
}
info!("Starting file processing...\n");
let paths: Vec<&str> = dataframes::str(&files, input_header)?;
let groups: DuplicateGroups = criterion.group(&paths, languages_file_paths, threads, logger)?;
groups.report(file_count);
groups.write(&files, input_header, output_path, map_path, logger)
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Criterion {
Exact,
BagOfWords,
Overlap {
threshold: Threshold,
prefix_depth: usize,
},
}
impl Criterion {
fn parse(name: &str, threshold: Threshold, prefix_depth: usize) -> Result<Self> {
match name {
"exact" => Ok(Criterion::Exact),
"bow" => Ok(Criterion::BagOfWords),
"overlap" => Ok(Criterion::Overlap {
threshold,
prefix_depth,
}),
other => anyhow::bail!("Unknown similarity criterion '{other}'."),
}
}
fn group(
&self,
paths: &[&str],
languages_file_paths: &[&str],
threads: usize,
logger: &Logger,
) -> Result<DuplicateGroups> {
match self {
Criterion::Exact => group_by_hash(paths, false, threads),
Criterion::BagOfWords => group_by_hash(paths, true, threads),
Criterion::Overlap {
threshold,
prefix_depth,
} => group_by_overlap(
paths,
*threshold,
*prefix_depth,
languages_file_paths,
threads,
logger,
),
}
}
}
struct DuplicateGroups {
representative_of: Vec<[String; 2]>,
representatives: Vec<String>,
sizes: Vec<u32>,
unreadable: usize,
}
impl DuplicateGroups {
fn new(capacity: usize) -> Self {
DuplicateGroups {
representative_of: Vec::with_capacity(capacity),
representatives: Vec::new(),
sizes: Vec::new(),
unreadable: 0,
}
}
fn extend_from(&mut self, corpus: &Corpus, clone_map: &CloneMap) {
for file in corpus.ids() {
if corpus.is_too_large(file) {
self.unreadable += 1;
continue;
}
if let Some(size) = clone_map.group_size(file) {
self.representatives.push(corpus.path(file).to_string());
self.sizes.push(size);
}
let representative = clone_map.representative_of(file);
self.representative_of.push([
corpus.path(file).to_string(),
corpus.path(representative).to_string(),
]);
}
}
fn add_singleton(&mut self, path: &str) {
self.representatives.push(path.to_string());
self.sizes.push(1);
self.representative_of
.push([path.to_string(), path.to_string()]);
}
fn report(&self, file_count: usize) {
let considered: usize = file_count - self.unreadable;
if self.unreadable > 0 {
let percentage = (self.unreadable as f64 / file_count as f64) * 100.0;
info!(
"Ignored large files: {} / {:.2} %",
self.unreadable, percentage
);
info!(
"Remaining files: {} / {:.2} %",
considered,
100.0 - percentage
);
}
let unique: usize = self.representatives.len();
let unique_percentage = (unique as f64 / considered as f64) * 100.0;
info!("Unique files: {} / {:.2} %", unique, unique_percentage);
info!(
"Duplicate files: {} / {:.2} %",
considered - unique,
100.0 - unique_percentage
);
let largest_group: u32 = self.sizes.iter().max().copied().unwrap_or_default();
info!(
"Most duplicated file: {} times / {:.2} %",
largest_group,
(largest_group as f64 / considered as f64) * 100.0
);
}
fn write(
self,
files: &DataFrame,
input_header: &str,
output_path: &str,
map_path: &str,
logger: &Logger,
) -> Result<()> {
log_write_rows(
logger,
map_path,
[input_header, "original"],
self.representative_of,
)?;
let clusters = DataFrame::new(vec![
polars::prelude::Column::new(input_header.into(), self.representatives),
polars::prelude::Column::new("count".into(), self.sizes),
])?;
let mut input_rows: DataFrame = files.clone();
if has_column(&input_rows, "count") {
input_rows = input_rows.drop("count")?;
}
let mut output_df = input_rows.join(
&clusters,
[input_header],
[input_header],
polars::prelude::JoinType::Inner.into(),
None,
)?;
log_write_dataframe(logger, output_path, &mut output_df)
}
}
fn group_by_hash(paths: &[&str], bag_of_words: bool, threads: usize) -> Result<DuplicateGroups> {
let workers: Vec<Matcher> = (0..threads).map(|_| Matcher::words_matcher()).collect();
let progress = ProgressBar::new(paths.len() as u64);
progress.set_style(
indicatif::ProgressStyle::default_bar().template("{elapsed} {wide_bar} {percent}%")?,
);
let items: Vec<(usize, &str)> = paths.iter().copied().enumerate().collect();
let mut hashes: Vec<Option<Hash>> = vec![None; paths.len()];
parallel_pipeline(
&items,
workers,
|matcher: &mut Matcher, (index, name): &(usize, &str)| -> Result<(usize, Option<Hash>)> {
match load_file(name, MAX_FILE_SIZE)? {
Ok(contents) => {
let hash: Hash = if bag_of_words {
blake3::hash(&matcher.bag_of_words(&contents, true).serialize())
} else {
blake3::hash(&contents)
};
Ok((*index, Some(hash)))
}
Err(_) => Ok((*index, None)),
}
},
|(index, opt_hash)| {
hashes[index] = opt_hash;
progress.inc(1);
Ok(())
},
)?;
progress.finish();
let mut first_seen: HashMap<Hash, usize> = HashMap::new();
let mut representatives: Vec<String> = Vec::new();
let mut sizes: Vec<u32> = Vec::new();
let mut representative_of: Vec<[String; 2]> = Vec::with_capacity(paths.len());
let mut unreadable: usize = 0;
for (name, opt_hash) in paths.iter().zip(hashes) {
match opt_hash {
None => unreadable += 1,
Some(hash) => {
let group = *first_seen.entry(hash).or_insert_with(|| {
representatives.push(name.to_string());
sizes.push(0);
representatives.len() - 1
});
sizes[group] += 1;
representative_of.push([name.to_string(), representatives[group].clone()]);
}
}
}
Ok(DuplicateGroups {
representative_of,
representatives,
sizes,
unreadable,
})
}
fn group_by_overlap(
paths: &[&str],
threshold: Threshold,
prefix_depth: usize,
languages_file_paths: &[&str],
threads: usize,
logger: &Logger,
) -> Result<DuplicateGroups> {
let keyword_files: KeywordFiles = logger.run_task("Loading languages", || {
KeywordFiles::new(false).add_files(languages_file_paths, true)
})?;
let mut by_language: HashMap<String, Vec<&str>> = HashMap::new();
let mut unclassified: Vec<&str> = Vec::new();
if languages_file_paths.is_empty() {
warn!("No language file given: every file is compared against every other, regardless of language.");
by_language.insert("all".to_string(), paths.to_vec());
} else {
for name in paths {
match keyword_files.file_language(name) {
Some(language) => by_language.entry(language).or_default().push(name),
None => unclassified.push(name),
}
}
}
let progress = ProgressBar::new(paths.len() as u64);
progress.set_style(
indicatif::ProgressStyle::default_bar().template("{elapsed} {wide_bar} {percent}%")?,
);
let mut groups = DuplicateGroups::new(paths.len());
for (language, group_paths) in &by_language {
info!("{}: {} files", language, group_paths.len());
let corpus = Corpus::build(group_paths, threads)?;
let index = DeltaInvertedIndex::new(&corpus, prefix_depth, threshold, threads)?;
let clone_map = detect_clones(&corpus, &index, threshold, &progress)?;
groups.extend_from(&corpus, &clone_map);
}
if !unclassified.is_empty() {
info!("Unknown language, left uncompared: {}", unclassified.len());
}
for name in unclassified {
groups.add_singleton(name);
progress.inc(1);
}
progress.finish();
Ok(groups)
}
struct Corpus {
paths: Vec<Box<str>>,
lengths: Vec<u32>,
rankings: HashMap<Token, usize>,
matcher: Matcher,
too_large: Vec<bool>,
}
impl Corpus {
fn build(paths: &[&str], threads: usize) -> Result<Self> {
let items: Vec<(FileId, &str)> = paths.iter().copied().enumerate().collect();
let mut corpus_bow: Bow = Bow::new(true);
let mut lengths: Vec<u32> = vec![0u32; items.len()];
let mut too_large: Vec<bool> = vec![false; items.len()];
let workers: Vec<Matcher> = (0..threads).map(|_| Matcher::words_matcher()).collect();
parallel_pipeline(
&items,
workers,
|matcher: &mut Matcher,
(file_id, name): &(FileId, &str)|
-> Result<(FileId, Option<Bow>)> {
match load_file(name, MAX_FILE_SIZE)? {
Ok(file_content) => {
let file_bow: Bow = matcher.bag_of_words(&file_content, true);
Ok((*file_id, Some(file_bow)))
}
Err(_) => Ok((*file_id, None)),
}
},
|(file_id, bow_opt)| {
match bow_opt {
Some(file_bow) => {
lengths[file_id] = file_bow.sum();
corpus_bow.extend(file_bow);
}
None => too_large[file_id] = true,
}
Ok(())
},
)?;
Ok(Corpus {
paths: items.iter().map(|(_, path)| (*path).into()).collect(),
lengths,
rankings: corpus_bow.token_rankings(),
matcher: Matcher::words_matcher(),
too_large,
})
}
fn ids(&self) -> impl Iterator<Item = FileId> {
0..self.paths.len()
}
fn length(&self, codeblock: FileId) -> u32 {
self.lengths[codeblock]
}
fn is_too_large(&self, codeblock: FileId) -> bool {
self.too_large[codeblock]
}
fn path(&self, codeblock: FileId) -> &str {
&self.paths[codeblock]
}
fn rank(&self, token: &Token) -> Result<usize> {
self.rankings.get(token).copied().with_context(|| {
format!(
"Token not found in global ranking: {}",
String::from_utf8_lossy(token)
)
})
}
fn sorted_tokens(&self, codeblock: FileId) -> Result<Vec<RankedToken<'_>>> {
let contents = load_file(self.path(codeblock), MAX_FILE_SIZE)?
.map_err(|_| anyhow::anyhow!("File too large at path '{}'", self.path(codeblock)))?;
self.matcher
.bag_of_words(&contents, true)
.sort_by(&self.rankings)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Threshold(f64);
impl Threshold {
fn new(value: f64) -> Result<Self> {
ensure!(
value > 0.0 && value <= 1.0,
"Similarity threshold must be greater than 0 and at most 1, got {value}."
);
Ok(Threshold(value))
}
fn prefix_length(&self, token_count: u32) -> u32 {
token_count - (token_count as f64 * self.0).ceil() as u32 + 1
}
fn required_matches(&self, origin_token_count: u32, candidate_token_count: u32) -> u32 {
(max(origin_token_count, candidate_token_count) as f64 * self.0).ceil() as u32
}
fn shortest_possible_clone(&self, origin_token_count: u32) -> u32 {
self.required_matches(origin_token_count, origin_token_count)
}
}
fn weighted_prefix_end(sorted_bow: &[RankedToken], prefix_length: u32) -> Result<usize> {
for (idx, ranked) in sorted_bow.iter().enumerate() {
if ranked.cumulative >= prefix_length {
return Ok(idx + 1);
}
}
anyhow::bail!(
"Unreachable: Prefix length {} is greater than the total number of tokens in the code block.",
prefix_length
)
}
fn detect_clones(
corpus: &Corpus,
delta_inverted_index: &DeltaInvertedIndex,
threshold: Threshold,
progress: &ProgressBar,
) -> Result<CloneMap> {
let mut clone_map: CloneMap = CloneMap::new();
for origin in corpus.ids() {
progress.inc(1);
if clone_map.contains(origin) {
continue;
}
let origin_token_count: u32 = corpus.length(origin);
if origin_token_count == 0 {
continue;
}
let sorted_tokens: Vec<RankedToken> = corpus.sorted_tokens(origin)?;
let mut candidate_map = CandidateMap::new();
let initial_prefix_end: usize =
weighted_prefix_end(&sorted_tokens, threshold.prefix_length(origin_token_count))?;
let mut filtering_cost: u32 = 0;
let mut total_cost: u32 = u32::MAX;
let mut best_prefix: usize = 0;
for (scheme, _) in delta_inverted_index.iter() {
let prefix_end: usize = initial_prefix_end + scheme - 1;
let scheme_end: usize = min(prefix_end, sorted_tokens.len());
let mut origin_cursor: Cursor = Cursor::new();
for (position, origin_token) in sorted_tokens.iter().enumerate().take(scheme_end) {
origin_cursor.advance(origin_token.frequency);
let new_token: bool = position + 1 == prefix_end;
filtering_cost += delta_inverted_index.token_filtering_cost(
origin_token.token,
scheme,
new_token,
);
for posting in delta_inverted_index
.slices_to_scan(scheme, new_token)
.iter()
.filter_map(|index| index.get(origin_token.token))
.flatten()
{
if clone_map.contains(posting.codeblock) {
continue;
}
candidate_map.consider(
posting,
origin_token,
origin_token_count,
corpus,
threshold,
);
}
}
if scheme == 1 {
candidate_map.apply_pending_updates(corpus);
}
let new_total_cost =
filtering_cost + candidate_map.verification_cost(scheme as u32, origin_token_count);
if new_total_cost > total_cost {
break;
}
total_cost = new_total_cost;
candidate_map.apply_pending_updates(corpus);
best_prefix = scheme;
}
verify_candidates(
origin,
&sorted_tokens,
&mut candidate_map,
&mut clone_map,
best_prefix,
threshold,
corpus,
)?;
}
Ok(clone_map)
}
fn verify_candidates(
origin_codeblock: FileId,
sorted_tokens: &[RankedToken],
candidate_map: &mut CandidateMap,
clone_map: &mut CloneMap,
p_prefix: usize,
threshold: Threshold,
corpus: &Corpus,
) -> Result<()> {
let origin_token_count = corpus.length(origin_codeblock);
let origin_unique_tokens = sorted_tokens.len();
for candidate in candidate_map
.candidates_with_at_least(0)
.collect::<HashSet<FileId>>()
{
if clone_map.contains(candidate) {
continue;
}
if candidate == origin_codeblock {
continue; }
let required: u32 =
threshold.required_matches(origin_token_count, corpus.length(candidate));
if candidate_map.get_token_matches(candidate) < min(p_prefix as u32, required) {
continue;
}
let mut origin_last_seen_token = Cursor::new();
let vectored_candidate_bow = corpus.sorted_tokens(candidate)?;
let candidate_token_count: u32 = corpus.length(candidate);
let candidate_unique_tokens: usize = vectored_candidate_bow.len();
let current_threshold: u32 =
threshold.required_matches(origin_token_count, candidate_token_count);
let mut candidate_last_seen_token: Cursor = candidate_map.last_seen_token(candidate)?;
let mut new_matches: u32 = 0;
let prefix_matches: u32 = candidate_map.get_token_matches(candidate);
while origin_last_seen_token.position < origin_unique_tokens
&& candidate_last_seen_token.position + 1 < candidate_unique_tokens
{
let upper_bound = min(
origin_token_count - origin_last_seen_token.cumulative,
candidate_token_count - candidate_last_seen_token.cumulative,
);
let current_matches: u32 = prefix_matches + new_matches;
let origin_token = sorted_tokens[origin_last_seen_token.position];
let candidate_token = vectored_candidate_bow[candidate_last_seen_token.position + 1];
let origin_rank = corpus.rank(origin_token.token)?;
let candidate_rank = corpus.rank(candidate_token.token)?;
if current_matches >= current_threshold {
break;
} else if upper_bound + current_matches >= current_threshold {
if origin_token.token == candidate_token.token {
new_matches += min(origin_token.frequency, candidate_token.frequency);
candidate_last_seen_token.advance(candidate_token.frequency);
origin_last_seen_token.advance(origin_token.frequency);
} else if origin_rank > candidate_rank {
candidate_last_seen_token.advance(candidate_token.frequency);
} else {
origin_last_seen_token.advance(origin_token.frequency);
}
} else {
break;
}
}
candidate_map.add_candidate(candidate, corpus, new_matches, candidate_last_seen_token);
if candidate_map.get_token_matches(candidate) >= current_threshold {
clone_map.record(origin_codeblock, candidate);
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Membership {
Representative(HashSet<FileId>),
DuplicateOf(FileId),
}
#[derive(Debug, Default)]
struct CloneMap {
membership: HashMap<FileId, Membership>,
}
impl CloneMap {
fn new() -> Self {
CloneMap::default()
}
fn contains(&self, codeblock: FileId) -> bool {
self.membership.contains_key(&codeblock)
}
fn record(&mut self, representative: FileId, duplicate: FileId) {
match self
.membership
.entry(representative)
.or_insert_with(|| Membership::Representative(HashSet::new()))
{
Membership::Representative(duplicates) => {
duplicates.insert(duplicate);
}
entry => *entry = Membership::Representative(HashSet::from([duplicate])),
}
self.membership
.insert(duplicate, Membership::DuplicateOf(representative));
}
fn representative_of(&self, codeblock: FileId) -> FileId {
match self.membership.get(&codeblock) {
Some(Membership::DuplicateOf(representative)) => *representative,
_ => codeblock,
}
}
fn group_size(&self, codeblock: FileId) -> Option<u32> {
match self.membership.get(&codeblock) {
Some(Membership::DuplicateOf(_)) => None,
Some(Membership::Representative(duplicates)) => Some(duplicates.len() as u32 + 1),
None => Some(1),
}
}
#[cfg(test)]
fn membership(&self, codeblock: FileId) -> Option<&Membership> {
self.membership.get(&codeblock)
}
#[cfg(test)]
fn codeblocks(&self) -> impl Iterator<Item = FileId> + '_ {
self.membership.keys().copied()
}
#[cfg(test)]
fn is_empty(&self) -> bool {
self.membership.is_empty()
}
}
#[derive(Debug, Clone, Copy, Default)]
struct Cursor {
position: usize,
cumulative: u32,
}
impl Cursor {
fn new() -> Self {
Cursor {
position: 0,
cumulative: 0,
}
}
fn advance(&mut self, token_freq: u32) {
self.position += 1;
self.cumulative += token_freq;
}
}
struct Posting {
codeblock: FileId,
occurrences: u32,
cursor: Cursor,
}
struct PartialInvertedIndex<'w> {
map: HashMap<&'w Token, Vec<Posting>>,
}
impl<'w> Default for PartialInvertedIndex<'w> {
fn default() -> Self {
PartialInvertedIndex::new()
}
}
impl<'w> PartialInvertedIndex<'w> {
fn new() -> Self {
PartialInvertedIndex {
map: HashMap::default(),
}
}
fn add(&mut self, token: &'w Token, posting: Posting) {
self.map.entry(token).or_default().push(posting);
}
fn get(&self, token: &Token) -> Option<&Vec<Posting>> {
self.map.get(token)
}
fn count(&self, token: &Token) -> u32 {
self.get(token)
.map(|postings| postings.len() as u32)
.unwrap_or_default()
}
}
#[derive(Default)]
struct DeltaInvertedIndex<'w> {
partial_indices: Vec<PartialInvertedIndex<'w>>,
}
impl<'w> DeltaInvertedIndex<'w> {
fn new(
corpus: &'w Corpus,
max_scheme: usize,
threshold: Threshold,
threads: usize,
) -> Result<DeltaInvertedIndex<'w>> {
ensure!(
max_scheme >= 1,
"The delta index needs at least one prefix scheme, got {max_scheme}."
);
let mut res: DeltaInvertedIndex = DeltaInvertedIndex {
partial_indices: (0..max_scheme)
.map(|_| PartialInvertedIndex::new())
.collect(),
};
parallel_pipeline(
&corpus.ids().collect::<Vec<_>>(),
(0..threads).map(|_| ()).collect(),
|_: &mut (), file_id: &FileId| -> Result<Option<(FileId, Vec<RankedToken<'w>>)>> {
if corpus.length(*file_id) == 0 {
return Ok(None);
}
Ok(Some((*file_id, corpus.sorted_tokens(*file_id)?)))
},
|res_opt| {
if let Some((file_id, vector_bow)) = res_opt {
let mut scheme: usize = 1;
let prefix_length: u32 = threshold.prefix_length(corpus.length(file_id));
for (idx, ranked) in vector_bow.into_iter().enumerate() {
res.add(
scheme,
ranked.token,
Posting {
codeblock: file_id,
occurrences: ranked.frequency,
cursor: Cursor {
position: idx,
cumulative: ranked.cumulative,
},
},
);
if ranked.cumulative >= prefix_length {
scheme += 1;
if scheme > max_scheme {
break;
}
}
}
}
Ok(())
},
)?;
Ok(res)
}
fn add(&mut self, scheme: usize, token: &'w Token, posting: Posting) {
self.partial_indices[scheme - 1].add(token, posting);
}
fn slices_to_scan(&self, scheme: usize, new: bool) -> &[PartialInvertedIndex<'w>] {
let first_unread: usize = if new { 0 } else { scheme - 1 };
&self.partial_indices[first_unread..scheme]
}
fn token_filtering_cost(&self, token: &Token, scheme: usize, new: bool) -> u32 {
self.slices_to_scan(scheme, new)
.iter()
.map(|index| index.count(token))
.sum()
}
fn iter(&self) -> impl Iterator<Item = (usize, &PartialInvertedIndex<'w>)> {
self.partial_indices
.iter()
.enumerate()
.map(|(i, index)| (i + 1, index))
}
}
#[derive(Default)]
struct CandidateEntry {
matches: u32,
last_seen_token: Cursor,
}
struct CandidateMap {
entries: HashMap<FileId, CandidateEntry>,
match_histogram: HashMap<u32, HashSet<FileId>>,
pending_updates: Vec<(FileId, CandidateEntry)>,
pending_matches: HashMap<FileId, u32>,
min_length: u32,
max_length: u32,
}
impl Default for CandidateMap {
fn default() -> Self {
Self::new()
}
}
impl CandidateMap {
fn new() -> Self {
Self {
entries: HashMap::new(),
match_histogram: HashMap::new(),
min_length: u32::MAX,
max_length: 0,
pending_updates: Vec::new(),
pending_matches: HashMap::new(),
}
}
fn get_token_matches(&self, codeblock: FileId) -> u32 {
self.entries
.get(&codeblock)
.map(|entry| entry.matches)
.unwrap_or(0)
}
fn add_pending_update(
&mut self,
codeblock: FileId,
new_matches: u32,
last_token_seen_pos: usize,
last_token_seen_cumul_freq: u32,
) {
*self.pending_matches.entry(codeblock).or_default() += new_matches;
self.pending_updates.push((
codeblock,
CandidateEntry {
matches: new_matches,
last_seen_token: Cursor {
position: last_token_seen_pos,
cumulative: last_token_seen_cumul_freq,
},
},
));
}
fn consider(
&mut self,
posting: &Posting,
origin_token: &RankedToken,
origin_token_count: u32,
corpus: &Corpus,
threshold: Threshold,
) {
let candidate_token_count: u32 = corpus.length(posting.codeblock);
if candidate_token_count < threshold.shortest_possible_clone(origin_token_count) {
return;
}
let new_matches: u32 = min(origin_token.frequency, posting.occurrences);
let upper_bound: u32 = min(
origin_token_count - origin_token.cumulative,
candidate_token_count - posting.cursor.cumulative,
);
let required: u32 = threshold.required_matches(origin_token_count, candidate_token_count);
let matched: u32 = self.get_token_matches(posting.codeblock)
+ self
.pending_matches
.get(&posting.codeblock)
.copied()
.unwrap_or(0);
if matched + upper_bound + new_matches >= required {
self.add_pending_update(
posting.codeblock,
new_matches,
posting.cursor.position,
posting.cursor.cumulative,
);
}
}
fn apply_pending_updates(&mut self, corpus: &Corpus) {
self.pending_matches.clear();
let updates = self.pending_updates.drain(..).collect::<Vec<_>>();
for (codeblock, candidate_entry) in updates {
self.add_candidate(
codeblock,
corpus,
candidate_entry.matches,
candidate_entry.last_seen_token,
);
}
}
fn add_candidate(
&mut self,
codeblock: FileId,
corpus: &Corpus,
new_matches: u32,
last_seen_token: Cursor,
) {
let entry = match self.entries.entry(codeblock) {
Entry::Occupied(occupied) => occupied.into_mut(),
Entry::Vacant(vacant) => {
let length: u32 = corpus.length(codeblock);
self.min_length = self.min_length.min(length);
self.max_length = self.max_length.max(length);
vacant.insert(CandidateEntry::default())
}
};
if entry.matches > 0 {
if let Some(bucket) = self.match_histogram.get_mut(&entry.matches) {
bucket.remove(&codeblock);
}
}
entry.matches += new_matches;
entry.last_seen_token = last_seen_token;
self.match_histogram
.entry(entry.matches)
.or_default()
.insert(codeblock);
}
fn mid_length(&self) -> u32 {
if self.entries.is_empty() {
0
} else {
(self.min_length + self.max_length) / 2
}
}
fn candidates_with_at_least(&self, n: u32) -> impl Iterator<Item = FileId> + '_ {
self.match_histogram
.iter()
.filter(move |(&matches, _)| matches >= n)
.flat_map(|(_, bucket)| bucket.iter().copied())
}
fn last_seen_token(&self, codeblock: FileId) -> Result<Cursor> {
Ok(self
.entries
.get(&codeblock)
.with_context(|| {
format!(
"Candidate code block '{}' not found in candidate map.",
codeblock
)
})?
.last_seen_token)
}
fn verification_cost(&self, n: u32, origin_token_count: u32) -> u32 {
let number_of_candidates: u32 = self.candidates_with_at_least(n).count() as u32;
let mut survivors: u32 = 0;
for (codeblock, _) in &self.pending_updates {
let current_matches = self.get_token_matches(*codeblock);
if n > 1 && current_matches == n - 1 {
survivors += 1;
}
}
(number_of_candidates + survivors) * (origin_token_count + self.mid_length())
}
}
#[cfg(test)]
mod tests {
use polars::prelude::SortMultipleOptions;
use crate::utils::logger::test_logger;
use rand::{Rng, SeedableRng};
use super::*;
const TEST_DATA: &str = "tests/data/phases/duplicate_files/";
fn make_posting(
codeblock: FileId,
occurrences: u32,
position: usize,
cumulative: u32,
) -> Posting {
Posting {
codeblock,
occurrences,
cursor: Cursor {
position,
cumulative,
},
}
}
fn ranked(token: &Token, frequency: u32, cumulative: u32) -> RankedToken<'_> {
RankedToken {
token,
frequency,
cumulative,
}
}
fn make_corpus(lengths: Vec<u32>) -> Corpus {
make_corpus_ranked(lengths, HashMap::new())
}
fn make_corpus_ranked(lengths: Vec<u32>, rankings: HashMap<Token, usize>) -> Corpus {
Corpus {
paths: lengths
.iter()
.enumerate()
.map(|(i, _)| format!("file{i}.rs").into_boxed_str())
.collect(),
too_large: vec![false; lengths.len()],
lengths,
rankings,
matcher: Matcher::words_matcher(),
}
}
#[test]
fn cursor_advance_increments_position_and_cumulative() {
let mut c = Cursor::default();
c.advance(5);
assert_eq!(c.position, 1);
assert_eq!(c.cumulative, 5);
c.advance(3);
assert_eq!(c.position, 2);
assert_eq!(c.cumulative, 8);
}
#[test]
fn threshold_rejects_values_outside_its_interval() {
assert!(Threshold::new(0.8).is_ok());
assert!(Threshold::new(1.0).is_ok());
assert!(Threshold::new(0.0).is_err());
assert!(Threshold::new(-0.5).is_err());
assert!(Threshold::new(1.5).is_err());
}
#[test]
fn prefix_length_random() {
let mut rng = rand::rngs::StdRng::seed_from_u64(0);
for _ in 0..10_000 {
let token_count: u32 = rng.gen_range(1..=10_000);
let threshold = Threshold(rng.gen_range(f64::MIN_POSITIVE..=1.0));
let result = threshold.prefix_length(token_count);
assert!(result >= 1);
assert!(result <= token_count);
assert_eq!(Threshold(1.0).prefix_length(token_count), 1);
assert_eq!(Threshold(0.0).prefix_length(token_count), token_count + 1);
assert_eq!(threshold.prefix_length(1), 1);
}
}
#[test]
fn prefix_length_partial_threshold() {
assert_eq!(Threshold(0.8).prefix_length(10), 3);
assert_eq!(Threshold(0.5).prefix_length(10), 6);
}
#[test]
fn required_matches_random() {
let mut rng = rand::rngs::StdRng::seed_from_u64(0);
for _ in 0..10_000 {
let token_count1: u32 = rng.gen_range(1..=10_000);
let token_count2: u32 = rng.gen_range(1..=10_000);
let threshold = Threshold(rng.gen_range(f64::MIN_POSITIVE..=1.0));
let result = threshold.required_matches(token_count1, token_count2);
let result_sym = threshold.required_matches(token_count2, token_count1);
assert!(result >= 1);
assert_eq!(result, result_sym);
assert!(result <= token_count1.max(token_count2));
assert_eq!(
Threshold(1.0).required_matches(token_count1, token_count2),
token_count1.max(token_count2)
);
assert_eq!(
Threshold(0.0).required_matches(token_count1, token_count2),
0
);
assert_eq!(threshold.required_matches(1, 1), 1);
}
}
#[test]
fn required_matches_det() {
assert_eq!(Threshold(0.8).required_matches(10, 10), 8);
assert_eq!(Threshold(0.8).required_matches(10, 8), 8);
assert_eq!(Threshold(0.75).required_matches(10, 10), 8);
}
#[test]
fn shortest_possible_clone_is_the_origin_measured_against_itself() {
let threshold = Threshold(0.8);
assert_eq!(threshold.shortest_possible_clone(10), 8);
assert!(threshold.required_matches(10, 7) > 7);
}
#[test]
fn weighted_prefix_end_first_element() -> Result<()> {
let w1: Token = b"foo".to_vec();
let bow: Vec<RankedToken> = vec![ranked(&w1, 3, 3)];
assert_eq!(weighted_prefix_end(&bow, 3)?, 1);
Ok(())
}
#[test]
fn weighted_prefix_end_second_element() -> Result<()> {
let w1: Token = b"foo".to_vec();
let w2: Token = b"bar".to_vec();
let bow: Vec<RankedToken> = vec![ranked(&w1, 3, 3), ranked(&w2, 2, 5)];
assert_eq!(weighted_prefix_end(&bow, 4)?, 2);
Ok(())
}
#[test]
fn weighted_prefix_end_unreachable_returns_error() {
let w1: Token = b"foo".to_vec();
let bow: Vec<RankedToken> = vec![ranked(&w1, 3, 3)];
assert!(weighted_prefix_end(&bow, 10).is_err());
}
#[test]
fn corpus_rank_found() -> Result<()> {
let token: Token = b"hello".to_vec();
let corpus = make_corpus_ranked(vec![], HashMap::from([(token.clone(), 42)]));
assert_eq!(corpus.rank(&token)?, 42);
Ok(())
}
#[test]
fn corpus_rank_missing() {
let token: Token = b"missing".to_vec();
let corpus = make_corpus_ranked(vec![], HashMap::new());
assert!(corpus.rank(&token).is_err());
}
#[test]
fn inverted_index_new() {
let idx: PartialInvertedIndex = PartialInvertedIndex::new();
let token: Token = b"foo".to_vec();
assert!(idx.get(&token).is_none());
assert_eq!(idx.count(&token), 0);
}
#[test]
fn inverted_index_add_then_get() {
let token: Token = b"foo".to_vec();
let token2: Token = b"bar".to_vec();
let mut idx: PartialInvertedIndex = PartialInvertedIndex::new();
idx.add(&token, make_posting(0, 3, 0, 3));
idx.add(&token2, make_posting(1, 5, 0, 5));
let postings = idx.get(&token).unwrap();
assert_eq!(postings.len(), 1);
assert_eq!(postings[0].codeblock, 0);
assert_eq!(postings[0].occurrences, 3);
}
#[test]
fn inverted_index_frequency() {
let token: Token = b"foo".to_vec();
let token2: Token = b"bar".to_vec();
let mut idx: PartialInvertedIndex = PartialInvertedIndex::new();
idx.add(&token, make_posting(0, 3, 0, 3));
idx.add(&token, make_posting(1, 5, 1, 5));
idx.add(&token2, make_posting(2, 2, 0, 2));
assert_eq!(idx.count(&token), 2);
}
#[test]
fn token_filtering_cost_new_token_sums_all_previous_indices() {
let token: Token = b"foo".to_vec();
let mut idx0: PartialInvertedIndex = PartialInvertedIndex::new();
let mut idx1: PartialInvertedIndex = PartialInvertedIndex::new();
idx0.add(&token, make_posting(0, 1, 0, 1));
idx0.add(&token, make_posting(1, 1, 0, 1));
idx1.add(&token, make_posting(2, 1, 0, 1));
let indices = DeltaInvertedIndex {
partial_indices: vec![idx0, idx1],
};
assert_eq!(indices.token_filtering_cost(&token, 2, true), 3);
}
#[test]
fn token_filtering_cost_existing_token_uses_last_index_only() {
let token: Token = b"foo".to_vec();
let mut idx0: PartialInvertedIndex = PartialInvertedIndex::new();
let mut idx1: PartialInvertedIndex = PartialInvertedIndex::new();
idx0.add(&token, make_posting(0, 1, 0, 1));
idx1.add(&token, make_posting(1, 1, 0, 1));
idx1.add(&token, make_posting(2, 1, 0, 1));
let indices = DeltaInvertedIndex {
partial_indices: vec![idx0, idx1],
};
assert_eq!(indices.token_filtering_cost(&token, 2, false), 2);
}
#[test]
fn slices_to_scan_covers_all_slices_only_for_a_new_token() {
let indices = DeltaInvertedIndex {
partial_indices: (0..4).map(|_| PartialInvertedIndex::new()).collect(),
};
assert_eq!(indices.slices_to_scan(1, true).len(), 1);
assert_eq!(indices.slices_to_scan(3, true).len(), 3);
assert_eq!(indices.slices_to_scan(3, false).len(), 1);
assert_eq!(indices.slices_to_scan(1, false).len(), 1);
}
#[test]
fn filtering_cost_matches_published_worked_example() {
let if_t: Token = b"if".to_vec();
let static_t: Token = b"static".to_vec();
let public_t: Token = b"public".to_vec();
let return_t: Token = b"return".to_vec();
let factorial_t: Token = b"factorial".to_vec();
let one_t: Token = b"1".to_vec();
let placeholder = |cb: FileId| make_posting(cb, 1, 0, 1);
let mut delta_1: PartialInvertedIndex = PartialInvertedIndex::new();
for cb in [1, 3, 5] {
delta_1.add(&if_t, placeholder(cb));
}
for cb in [1, 2, 3] {
delta_1.add(&static_t, placeholder(cb));
}
for cb in [1, 2, 3, 5] {
delta_1.add(&public_t, placeholder(cb));
}
delta_1.add(&return_t, placeholder(1));
let mut delta_2: PartialInvertedIndex = PartialInvertedIndex::new();
for cb in [2, 5] {
delta_2.add(&return_t, placeholder(cb));
}
delta_2.add(&factorial_t, placeholder(1));
let mut delta_3: PartialInvertedIndex = PartialInvertedIndex::new();
for cb in [2, 5] {
delta_3.add(&factorial_t, placeholder(cb));
}
delta_3.add(&return_t, placeholder(3));
delta_3.add(&static_t, placeholder(4));
delta_3.add(&one_t, placeholder(1));
let indices = DeltaInvertedIndex {
partial_indices: vec![delta_1, delta_2, delta_3],
};
let prefix = [&if_t, &static_t, &public_t, &return_t, &factorial_t, &one_t];
let initial_prefix_end: usize = 4;
let mut filtering_cost: u32 = 0;
let mut cost_per_scheme: Vec<u32> = Vec::new();
for scheme in 1..=3 {
let prefix_end: usize = initial_prefix_end + scheme - 1;
for (position, token) in prefix.iter().copied().enumerate().take(prefix_end) {
filtering_cost +=
indices.token_filtering_cost(token, scheme, position + 1 == prefix_end);
}
cost_per_scheme.push(filtering_cost);
}
assert_eq!(cost_per_scheme, vec![11, 14, 19]);
}
#[test]
fn corpus_accessors() {
let corpus = make_corpus(vec![10, 20, 15]);
assert_eq!(corpus.length(0), 10);
assert_eq!(corpus.length(1), 20);
assert_eq!(corpus.length(2), 15);
assert_eq!(corpus.path(0), "file0.rs");
assert_eq!(corpus.path(1), "file1.rs");
assert_eq!(corpus.path(2), "file2.rs");
let ids: Vec<_> = corpus.ids().collect();
assert_eq!(ids, vec![0, 1, 2]);
}
#[test]
fn candidate_map_new_is_empty() {
let cm = CandidateMap::new();
assert_eq!(cm.get_token_matches(0), 0);
assert_eq!(cm.mid_length(), 0);
assert!(cm
.candidates_with_at_least(1)
.collect::<HashSet<FileId>>()
.is_empty());
}
#[test]
fn candidate_map_last_seen_token_missing() {
let cm = CandidateMap::new();
assert!(cm.last_seen_token(99).is_err());
}
#[test]
fn candidate_map_add_candidate_stores_matches_and_cursor() -> Result<()> {
let corpus = make_corpus(vec![10]);
let mut cm = CandidateMap::new();
cm.add_candidate(
0,
&corpus,
3,
Cursor {
position: 2,
cumulative: 3,
},
);
assert_eq!(cm.get_token_matches(0), 3);
let cursor = cm.last_seen_token(0)?;
assert_eq!(cursor.position, 2);
assert_eq!(cursor.cumulative, 3);
Ok(())
}
#[test]
fn candidate_map_add_candidate_accumulates_matches() -> Result<()> {
let corpus = make_corpus(vec![10]);
let mut cm = CandidateMap::new();
cm.add_candidate(
0,
&corpus,
3,
Cursor {
position: 2,
cumulative: 3,
},
);
cm.add_candidate(
0,
&corpus,
2,
Cursor {
position: 4,
cumulative: 5,
},
);
assert_eq!(cm.get_token_matches(0), 5);
let cursor = cm.last_seen_token(0)?;
assert_eq!(cursor.position, 4);
assert_eq!(cursor.cumulative, 5);
Ok(())
}
#[test]
fn candidate_map_mid_length_tracks_min_and_max() -> Result<()> {
let corpus = make_corpus(vec![10, 8, 15]);
let mut cm = CandidateMap::new();
cm.add_candidate(0, &corpus, 1, Cursor::default()); cm.add_candidate(2, &corpus, 1, Cursor::default()); assert_eq!(cm.mid_length(), 12);
Ok(())
}
#[test]
fn candidate_map_candidates_with_at_least() -> Result<()> {
let corpus = make_corpus(vec![10, 10, 10]);
let mut cm = CandidateMap::new();
cm.add_candidate(0, &corpus, 3, Cursor::default());
cm.add_candidate(1, &corpus, 3, Cursor::default());
cm.add_candidate(2, &corpus, 5, Cursor::default());
assert_eq!(
cm.candidates_with_at_least(3).collect::<HashSet<FileId>>(),
HashSet::from([0, 1, 2])
);
assert_eq!(
cm.candidates_with_at_least(5).collect::<HashSet<FileId>>(),
HashSet::from([2])
);
assert_eq!(cm.candidates_with_at_least(6).count(), 0);
Ok(())
}
#[test]
fn candidate_map_histogram_updated_on_accumulation() -> Result<()> {
let corpus = make_corpus(vec![10]);
let mut cm = CandidateMap::new();
cm.add_candidate(0, &corpus, 3, Cursor::default());
assert_eq!(cm.candidates_with_at_least(3).count(), 1);
cm.add_candidate(0, &corpus, 2, Cursor::default());
assert_eq!(cm.candidates_with_at_least(3).count(), 1);
assert_eq!(cm.candidates_with_at_least(5).count(), 1);
assert_eq!(cm.candidates_with_at_least(6).count(), 0);
Ok(())
}
#[test]
fn candidate_map_pending_updates_applied() -> Result<()> {
let corpus = make_corpus(vec![10, 8]);
let mut cm = CandidateMap::new();
cm.add_pending_update(0, 3, 2, 3);
cm.add_pending_update(1, 2, 1, 2);
assert_eq!(cm.get_token_matches(0), 0);
cm.apply_pending_updates(&corpus);
assert_eq!(cm.get_token_matches(0), 3);
assert_eq!(cm.get_token_matches(1), 2);
Ok(())
}
#[test]
fn verification_cost_empty_map_is_zero() {
let cm = CandidateMap::new();
assert_eq!(cm.verification_cost(1, 10), 0);
}
#[test]
fn verification_cost_no_pending_updates() -> Result<()> {
let corpus = make_corpus(vec![10, 20]);
let mut cm = CandidateMap::new();
cm.add_candidate(0, &corpus, 3, Cursor::default()); cm.add_candidate(1, &corpus, 5, Cursor::default()); assert_eq!(cm.verification_cost(3, 10), 50);
Ok(())
}
#[test]
fn verification_cost_counts_survivors_from_pending() -> Result<()> {
let corpus = make_corpus(vec![10]);
let mut cm = CandidateMap::new();
cm.add_candidate(0, &corpus, 2, Cursor::default());
cm.add_pending_update(0, 1, 2, 2);
assert_eq!(cm.verification_cost(3, 10), 20);
Ok(())
}
#[test]
fn verification_cost_n1_never_counts_survivors() -> Result<()> {
let mut cm = CandidateMap::new();
cm.add_pending_update(0, 1, 0, 0);
assert_eq!(cm.verification_cost(1, 5), 0);
Ok(())
}
#[test]
fn clone_map_records_both_sides_of_a_group() {
let mut clone_map = CloneMap::new();
clone_map.record(0, 1);
assert_eq!(
clone_map.membership(0),
Some(&Membership::Representative(HashSet::from([1])))
);
assert_eq!(clone_map.membership(1), Some(&Membership::DuplicateOf(0)));
assert_eq!(clone_map.representative_of(1), 0);
assert_eq!(clone_map.representative_of(0), 0);
assert_eq!(clone_map.group_size(0), Some(2));
assert_eq!(clone_map.group_size(1), None);
assert_eq!(clone_map.group_size(9), Some(1));
assert_eq!(clone_map.representative_of(9), 9);
}
#[test]
fn clone_map_accumulates_duplicates_of_one_representative() {
let mut clone_map = CloneMap::new();
clone_map.record(0, 1);
clone_map.record(0, 2);
match clone_map.membership(0).unwrap() {
Membership::Representative(clones) => {
assert_eq!(clones.len(), 2);
assert!(clones.contains(&1) && clones.contains(&2));
}
Membership::DuplicateOf(_) => panic!("expected a representative"),
}
assert_eq!(clone_map.group_size(0), Some(3));
}
const FILES: &str = "tests/data/phases/duplicate_files/files";
fn path_refs(paths: &[String]) -> Vec<&str> {
paths.iter().map(|p| p.as_str()).collect()
}
#[test]
fn corpus_build_records_correct_lengths() -> Result<()> {
let paths = vec![
format!("{FILES}/foo.java"),
format!("{FILES}/c_float.json"),
format!("{FILES}/empty.java"),
];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
assert_eq!(corpus.ids().count(), 3);
assert_eq!(corpus.path(0), paths[0]);
assert_eq!(corpus.path(1), paths[1]);
assert_eq!(corpus.path(2), paths[2]);
assert!(corpus.length(0) > 0, "foo.java should have tokens");
assert!(corpus.length(1) > 0, "c_float.json should have tokens");
assert_eq!(corpus.length(2), 0, "empty.java should have no tokens");
assert!(
!corpus.rankings.is_empty(),
"the corpus ranking should not be empty"
);
Ok(())
}
#[test]
fn global_bow_identical_files_have_same_length() -> Result<()> {
let paths = vec![
format!("{FILES}/c_float.json"),
format!("{FILES}/c_float.copy"),
];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
assert_eq!(corpus.length(0), corpus.length(1));
Ok(())
}
#[test]
fn sorted_bow_tokens_are_sorted_by_rank() -> Result<()> {
let paths = vec![format!("{FILES}/foo.java")];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let sorted = corpus.sorted_tokens(0)?;
assert!(!sorted.is_empty());
for w in sorted.windows(2) {
assert!(
corpus.rank(w[0].token)? <= corpus.rank(w[1].token)?,
"not sorted by rank"
);
}
Ok(())
}
#[test]
fn sorted_bow_cumulative_counts_are_non_decreasing() -> Result<()> {
let paths = vec![format!("{FILES}/foo.java")];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let sorted = corpus.sorted_tokens(0)?;
for w in sorted.windows(2) {
assert!(
w[0].cumulative <= w[1].cumulative,
"cumulative frequencies not non-decreasing"
);
}
if let Some(last) = sorted.last() {
assert_eq!(last.cumulative, corpus.length(0));
}
Ok(())
}
#[test]
fn index_builder_first_index_is_non_empty() -> Result<()> {
let paths = vec![format!("{FILES}/foo.java")];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 5, Threshold(0.8), 1)?;
let first_has_entries = corpus
.rankings
.keys()
.any(|t| indices.partial_indices[0].get(t).is_some());
assert!(first_has_entries);
Ok(())
}
fn brute_force_representatives(corpus: &Corpus, threshold: Threshold) -> Result<Vec<FileId>> {
let overlap = |a: FileId, b: FileId| -> Result<u32> {
let tokens_a: HashMap<_, u32> = corpus
.sorted_tokens(a)?
.into_iter()
.map(|t| (t.token, t.frequency))
.collect();
Ok(corpus
.sorted_tokens(b)?
.into_iter()
.map(|t| min(t.frequency, tokens_a.get(t.token).copied().unwrap_or(0)))
.sum())
};
let mut representative: Vec<Option<FileId>> = vec![None; corpus.ids().count()];
for origin in corpus.ids() {
if representative[origin].is_some() || corpus.length(origin) == 0 {
continue;
}
for candidate in corpus.ids() {
if candidate == origin
|| representative[candidate].is_some()
|| corpus.length(candidate) == 0
{
continue;
}
let required =
threshold.required_matches(corpus.length(origin), corpus.length(candidate));
if overlap(origin, candidate)? >= required {
representative[origin] = Some(origin);
representative[candidate] = Some(origin);
}
}
}
Ok(corpus
.ids()
.map(|id| representative[id].unwrap_or(id))
.collect())
}
#[test]
fn detect_clones_matches_brute_force_at_every_depth() -> Result<()> {
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let vocabulary = ["foo", "bar", "baz", "qux", "if", "x", "y"];
let dir = "target/tests/overlap_brute_force";
for case in 0..200 {
delete_dir(dir, true)?;
create_dir(dir)?;
let mut contents: Vec<Vec<&str>> = Vec::new();
for _ in 0..rng.gen_range(2..7) {
let content: Vec<&str> = match contents.last() {
Some(previous) if rng.gen_bool(0.5) => {
let mut copy = previous.clone();
copy.push(vocabulary[rng.gen_range(0..vocabulary.len())]);
if copy.len() > 1 && rng.gen_bool(0.5) {
copy.remove(rng.gen_range(0..copy.len()));
}
copy
}
_ => (0..rng.gen_range(1..10))
.map(|_| vocabulary[rng.gen_range(0..vocabulary.len())])
.collect(),
};
contents.push(content);
}
let paths: Vec<String> = (0..contents.len())
.map(|i| format!("{dir}/{i}.txt"))
.collect();
for (path, content) in paths.iter().zip(&contents) {
write_file(path, content.join(" ").as_bytes())?;
}
let threshold = Threshold([0.5, 0.6, 0.75, 0.8, 0.9, 1.0][rng.gen_range(0..6)]);
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let expected = brute_force_representatives(&corpus, threshold)?;
for depth in 1..=4 {
let indices = DeltaInvertedIndex::new(&corpus, depth, threshold, 1)?;
let clone_map =
detect_clones(&corpus, &indices, threshold, &ProgressBar::hidden())?;
let found: Vec<FileId> = corpus
.ids()
.map(|id| clone_map.representative_of(id))
.collect();
assert_eq!(
found, expected,
"case {case}, depth {depth}, threshold {}, files {contents:?}",
threshold.0
);
}
}
delete_dir(dir, true)
}
#[test]
fn detect_clones_identical_files_are_clones() -> Result<()> {
let paths = vec![
format!("{FILES}/c_float.json"),
format!("{FILES}/c_float.copy"),
format!("{FILES}/foo.java"),
];
let threshold = Threshold(0.8);
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 10, threshold, 1)?;
let clone_map = detect_clones(&corpus, &indices, threshold, &ProgressBar::hidden())?;
let in_map = clone_map.contains(0) || clone_map.contains(1);
assert!(in_map, "identical files not detected as clones");
if let Some(entry) = clone_map.membership(0) {
match entry {
Membership::Representative(clones) => assert!(clones.contains(&1)),
Membership::DuplicateOf(orig) => assert_eq!(*orig, 1),
}
}
Ok(())
}
#[test]
fn detect_clones_distinct_files_are_not_clones() -> Result<()> {
let paths = vec![format!("{FILES}/foo.java"), format!("{FILES}/c_float.json")];
let threshold = Threshold(0.95);
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 10, threshold, 1)?;
let clone_map = detect_clones(&corpus, &indices, threshold, &ProgressBar::hidden())?;
let paired = clone_map.contains(0) && clone_map.contains(1) && {
match (clone_map.membership(0), clone_map.membership(1)) {
(Some(Membership::Representative(s)), Some(Membership::DuplicateOf(o))) => {
s.contains(&1) && *o == 0
}
(Some(Membership::DuplicateOf(o)), Some(Membership::Representative(s))) => {
s.contains(&0) && *o == 1
}
_ => false,
}
};
assert!(!paired, "distinct files incorrectly detected as clones");
Ok(())
}
const ND_FILES: &str = "tests/data/phases/duplicate_files/nishi_damevski";
const ND_THRESHOLD: Threshold = Threshold(0.8);
fn nishi_damevski_paths() -> Vec<String> {
(1..=5).map(|n| format!("{ND_FILES}/cb{n}.java")).collect()
}
#[test]
fn nishi_damevski_block_sizes_match_table_2() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let sizes: Vec<u32> = corpus.ids().map(|f| corpus.length(f)).collect();
assert_eq!(sizes, vec![16, 21, 28, 23, 16]);
Ok(())
}
#[test]
fn nishi_damevski_token_order_matches_table_2() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let sorted = corpus.sorted_tokens(0)?;
let tokens: Vec<String> = sorted
.iter()
.map(|ranked| String::from_utf8_lossy(ranked.token).into_owned())
.collect();
assert_eq!(
tokens,
vec![
"if",
"static",
"public",
"return",
"factorial",
"1",
"int",
"result"
]
);
let frequencies: Vec<u32> = sorted.iter().map(|ranked| ranked.frequency).collect();
assert_eq!(frequencies, vec![1, 1, 1, 2, 2, 3, 2, 4]);
assert_eq!(sorted.last().map(|ranked| ranked.cumulative), Some(16));
Ok(())
}
#[test]
fn nishi_damevski_prefix_sizes_match_table_3() {
assert_eq!(ND_THRESHOLD.prefix_length(16), 4);
assert_eq!(ND_THRESHOLD.prefix_length(21), 5);
}
#[test]
fn nishi_damevski_delta_index_matches_figure_1() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 3, ND_THRESHOLD, 1)?;
let blocks = |scheme: usize, token: &str| -> Vec<FileId> {
let token: Token = token.as_bytes().to_vec();
let mut found: Vec<FileId> = indices.partial_indices[scheme - 1]
.get(&token)
.map(|postings| postings.iter().map(|p| p.codeblock).collect())
.unwrap_or_default();
found.sort();
found
};
assert_eq!(blocks(1, "if"), vec![0, 2, 4]);
assert_eq!(blocks(1, "static"), vec![0, 1, 2]);
assert_eq!(blocks(1, "public"), vec![0, 1, 2, 4]);
assert_eq!(blocks(1, "return"), vec![0]);
assert_eq!(blocks(1, "for"), vec![1, 2]);
assert_eq!(blocks(1, "n"), vec![1]);
assert_eq!(blocks(1, "0"), vec![2, 4]);
assert_eq!(blocks(1, "5"), vec![3]);
assert_eq!(blocks(1, "else"), vec![4]);
assert_eq!(blocks(2, "return"), vec![1, 4]);
assert_eq!(blocks(2, "for"), vec![3]);
assert_eq!(blocks(2, "n"), vec![2]);
assert_eq!(blocks(2, "factorial"), vec![0]);
assert_eq!(blocks(3, "return"), vec![2]);
assert_eq!(blocks(3, "factorial"), vec![1, 4]);
assert_eq!(blocks(3, "1"), vec![0]);
assert_eq!(blocks(3, "static"), vec![3]);
Ok(())
}
#[test]
fn nishi_damevski_inverted_lists_are_the_union_of_their_slices() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 3, ND_THRESHOLD, 1)?;
let token: Token = b"return".to_vec();
let union = |scheme: usize| -> Vec<FileId> {
let mut found: Vec<FileId> = indices
.slices_to_scan(scheme, true)
.iter()
.filter_map(|index| index.get(&token))
.flatten()
.map(|posting| posting.codeblock)
.collect();
found.sort();
found
};
assert_eq!(union(1), vec![0]);
assert_eq!(union(2), vec![0, 1, 4]);
assert_eq!(union(3), vec![0, 1, 2, 4]);
Ok(())
}
#[test]
fn nishi_damevski_detects_only_the_cb1_cb5_pair() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 3, ND_THRESHOLD, 1)?;
let clone_map = detect_clones(&corpus, &indices, ND_THRESHOLD, &ProgressBar::hidden())?;
assert_eq!(
clone_map.codeblocks().collect::<HashSet<FileId>>(),
HashSet::from([0, 4]),
"expected CB1 and CB5 to be the only pair, got {clone_map:?}"
);
match (clone_map.membership(0), clone_map.membership(4)) {
(Some(Membership::Representative(clones)), Some(Membership::DuplicateOf(origin))) => {
assert_eq!(clones, &HashSet::from([4]));
assert_eq!(*origin, 0);
}
(Some(Membership::DuplicateOf(origin)), Some(Membership::Representative(clones))) => {
assert_eq!(clones, &HashSet::from([0]));
assert_eq!(*origin, 4);
}
other => panic!("CB1 and CB5 are not linked as a pair: {other:?}"),
}
Ok(())
}
#[test]
fn nishi_damevski_pair_is_claimed_by_the_earlier_block() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 3, ND_THRESHOLD, 1)?;
let clone_map = detect_clones(&corpus, &indices, ND_THRESHOLD, &ProgressBar::hidden())?;
match clone_map.membership(0) {
Some(Membership::Representative(clones)) => assert_eq!(clones, &HashSet::from([4])),
other => panic!("expected CB1 to be the origin of exactly CB5, got {other:?}"),
}
assert_eq!(clone_map.membership(4), Some(&Membership::DuplicateOf(0)));
Ok(())
}
#[test]
fn nishi_damevski_finds_no_pairs_at_full_similarity() -> Result<()> {
let paths = nishi_damevski_paths();
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let indices = DeltaInvertedIndex::new(&corpus, 3, Threshold(1.0), 1)?;
let clone_map = detect_clones(&corpus, &indices, Threshold(1.0), &ProgressBar::hidden())?;
assert!(
clone_map.is_empty(),
"unexpected clone pairs: {clone_map:?}"
);
Ok(())
}
#[test]
fn detect_clones_ignores_files_without_tokens() -> Result<()> {
let paths = vec![
format!("{FILES}/empty.java"),
format!("{FILES}/c_float.json"),
format!("{FILES}/c_float.copy"),
];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
assert_eq!(corpus.length(0), 0, "empty.java should have no tokens");
let indices = DeltaInvertedIndex::new(&corpus, 3, Threshold(0.8), 1)?;
let clone_map = detect_clones(&corpus, &indices, Threshold(0.8), &ProgressBar::hidden())?;
assert!(!clone_map.contains(0));
assert_eq!(
clone_map.codeblocks().collect::<HashSet<FileId>>(),
HashSet::from([1, 2])
);
Ok(())
}
#[test]
fn thresholds_outside_the_unit_interval_are_rejected() {
let input = format!("{TEST_DATA}/duplicate_files.csv");
for threshold in [1.5, 0.0, -0.5] {
let result = run(
&input,
None,
None,
true,
"overlap",
threshold,
1,
&[],
1,
"name",
test_logger(),
);
assert!(
result.is_err(),
"threshold {threshold} should have been rejected"
);
}
}
#[test]
fn delta_index_needs_at_least_one_scheme() -> Result<()> {
let paths = vec![format!("{FILES}/foo.java")];
let corpus = Corpus::build(&path_refs(&paths), 1)?;
assert!(DeltaInvertedIndex::new(&corpus, 0, Threshold(0.8), 1).is_err());
Ok(())
}
#[test]
fn verify_candidates_detects_clone_above_threshold() -> Result<()> {
let paths = vec![
format!("{FILES}/c_float.json"),
format!("{FILES}/c_float.copy"),
];
let threshold = Threshold(0.8);
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let origin_bow = corpus.sorted_tokens(0)?;
let mut candidate_map = CandidateMap::new();
candidate_map.add_candidate(1, &corpus, 0, Cursor::default());
let mut clone_map: CloneMap = CloneMap::new();
verify_candidates(
0,
&origin_bow,
&mut candidate_map,
&mut clone_map,
0,
threshold,
&corpus,
)?;
assert!(
clone_map.contains(0) || clone_map.contains(1),
"identical files not recognised as clones by verify_candidates"
);
Ok(())
}
#[test]
fn verify_candidates_skips_files_already_in_clone_map() -> Result<()> {
let paths = vec![
format!("{FILES}/c_float.json"),
format!("{FILES}/c_float.copy"),
];
let threshold = Threshold(0.8);
let corpus = Corpus::build(&path_refs(&paths), 1)?;
let origin_bow = corpus.sorted_tokens(0)?;
let mut candidate_map = CandidateMap::new();
candidate_map.add_candidate(1, &corpus, 0, Cursor::default());
let mut clone_map: CloneMap = CloneMap::new();
clone_map.record(99, 1);
verify_candidates(
0,
&origin_bow,
&mut candidate_map,
&mut clone_map,
0,
threshold,
&corpus,
)?;
assert!(
matches!(clone_map.membership(1), Some(Membership::DuplicateOf(99))),
"already-claimed clone was re-assigned"
);
Ok(())
}
fn test_duplicate_files(input_path: &str, similarity: &str) -> Result<()> {
let default_output_path = format!("{input_path}.unique.csv");
let default_map_path = format!("{input_path}.duplicates_map.csv");
delete_file(&default_output_path, true)?;
delete_file(&default_map_path, true)?;
run(
input_path,
None,
None,
false,
similarity,
1.0,
1,
&[],
1,
"name",
test_logger(),
)?;
let expected_df = open_csv(&format!("{default_output_path}.expected"), None, None)?;
let output_df = open_csv(&default_output_path, None, None)?;
let sorted_expected_df = expected_df.sort(vec!["name"], SortMultipleOptions::new())?;
let sorted_output_df = output_df.sort(vec!["name"], SortMultipleOptions::new())?;
assert_eq!(sorted_expected_df, sorted_output_df);
delete_file(&default_output_path, false)?;
let expected_map = open_csv(&format!("{default_map_path}.expected"), None, None)?;
let map_df = open_csv(&default_map_path, None, None)?;
let sorted_expected_map = expected_map.sort(vec!["name"], SortMultipleOptions::new())?;
let sorted_map_df = map_df.sort(vec!["name"], SortMultipleOptions::new())?;
ensure!(
sorted_expected_map.equals(&sorted_map_df),
"Duplicate map CSV file does not match expected output."
);
delete_file(&default_map_path, false)
}
#[test]
fn exact_files() -> Result<()> {
test_duplicate_files(&format!("{TEST_DATA}/duplicate_files.csv"), "exact")?;
test_duplicate_files(&format!("{TEST_DATA}/duplicate_files_bow.csv"), "bow")
}
fn exact_run(
dir: &str,
input_csv: &str,
threads: usize,
force: bool,
) -> Result<(DataFrame, DataFrame)> {
let input_path = format!("{dir}/input.csv");
let output_path = format!("{dir}/unique.csv");
let map_path = format!("{dir}/map.csv");
write_file(&input_path, input_csv.as_bytes())?;
run(
&input_path,
Some(&output_path),
Some(&map_path),
force,
"exact",
1.0,
1,
&[],
threads,
"name",
test_logger(),
)?;
Ok((
open_csv(
&output_path,
Some(Schema::from_iter(vec![Field::new(
"count".into(),
DataType::UInt32,
)])),
None,
)?,
open_csv(&map_path, None, None)?,
))
}
#[test]
fn exact_representative_is_the_first_file_of_the_input() -> Result<()> {
let dir = "target/tests/duplicate_files_first_representative";
delete_dir(dir, true)?;
create_dir(dir)?;
let paths: Vec<String> = (0..50).map(|i| format!("{dir}/f{i:02}.txt")).collect();
for path in &paths {
write_file(path, b"same content")?;
}
let input_csv = format!("name\n{}\n", paths.join("\n"));
for _ in 0..3 {
let (unique, map) = exact_run(dir, &input_csv, 8, true)?;
assert_eq!(dataframes::str(&unique, "name")?, vec![paths[0].as_str()]);
ensure!(dataframes::str(&map, "original")?
.iter()
.all(|original| *original == paths[0]));
}
delete_dir(dir, false)
}
#[test]
fn repeated_rows_and_old_counts_are_not_carried_over() -> Result<()> {
let dir = "target/tests/duplicate_files_repeated_rows";
delete_dir(dir, true)?;
create_dir(dir)?;
let (a, b) = (format!("{dir}/a.txt"), format!("{dir}/b.txt"));
write_file(&a, b"same content")?;
write_file(&b, b"same content")?;
let input_csv = format!("name,count\n{a},7\n{a},7\n{b},7\n");
let (unique, _) = exact_run(dir, &input_csv, 1, true)?;
assert_eq!(unique.get_column_names(), ["name", "count"]);
assert_eq!(dataframes::str(&unique, "name")?, vec![a.as_str()]);
assert_eq!(dataframes::u32(&unique, "count")?, vec![2]);
delete_dir(dir, false)
}
#[test]
fn existing_map_file_needs_force() -> Result<()> {
let dir = "target/tests/duplicate_files_map_force";
delete_dir(dir, true)?;
create_dir(dir)?;
let a = format!("{dir}/a.txt");
write_file(&a, b"content")?;
write_file(format!("{dir}/map.csv"), b"keep me")?;
ensure!(exact_run(dir, &format!("name\n{a}\n"), 1, false).is_err());
assert_eq!(
std::fs::read_to_string(format!("{dir}/map.csv"))?,
"keep me"
);
delete_dir(dir, false)
}
#[test]
fn overlap_files() -> Result<()> {
test_duplicate_files(
&format!("{TEST_DATA}/duplicate_files_overlap.csv"),
"overlap",
)
}
#[test]
fn overlap_only_compares_within_a_language() -> Result<()> {
let input = format!("{TEST_DATA}/duplicate_files_overlap.csv");
let output = format!("{TEST_DATA}/out_languages.csv");
let map = format!("{TEST_DATA}/map_languages.csv");
let languages = format!("{TEST_DATA}/languages.json");
delete_file(&output, true)?;
delete_file(&map, true)?;
run(
&input,
Some(&output),
Some(&map),
true,
"overlap",
1.0,
1,
&[&languages],
1,
"name",
test_logger(),
)?;
let map_df = open_csv(&map, None, None)?;
let unique_df = open_csv(&output, None, None)?;
assert_eq!(map_df.height(), 6);
assert_eq!(unique_df.height(), 5);
let names = map_df.column("name")?.str()?;
let originals = map_df.column("original")?.str()?;
let pairs: HashMap<&str, &str> = names
.into_iter()
.flatten()
.zip(originals.into_iter().flatten())
.collect();
let copy = format!("{FILES}/c_float.copy");
let json = format!("{FILES}/c_float.json");
assert_eq!(
pairs.get(copy.as_str()),
Some(©.as_str()),
"c_float.copy should not be a clone of a file in another language"
);
assert_eq!(pairs.get(json.as_str()), Some(&json.as_str()));
assert_eq!(
pairs.get(format!("{FILES}/foo_clone.java").as_str()),
Some(&format!("{FILES}/foo.java").as_str())
);
delete_file(&output, false)?;
delete_file(&map, false)
}
#[test]
fn missing_input_duplicate_files() {
assert!(run(
"nonexistent.csv",
None,
None,
false,
"exact",
1.0,
1,
&[],
1,
"name",
test_logger()
)
.is_err());
}
#[test]
fn output_exists_no_force_duplicate_files() -> Result<()> {
let input = format!("{TEST_DATA}/duplicate_files.csv");
let output = format!("{TEST_DATA}/out_no_force.csv");
let map = format!("{TEST_DATA}/map_no_force.csv");
write_file(&output, b"")?;
let result = run(
&input,
Some(&output),
Some(&map),
false,
"exact",
1.0,
1,
&[],
1,
"name",
test_logger(),
);
delete_file(&output, false)?;
delete_file(&map, true)?;
assert!(result.is_err());
Ok(())
}
#[test]
fn force_overwrites_duplicate_files() -> Result<()> {
let input = format!("{TEST_DATA}/duplicate_files.csv");
let output = format!("{TEST_DATA}/out_force.csv");
let map = format!("{TEST_DATA}/map_force.csv");
write_file(&output, b"")?;
delete_file(&map, true)?;
run(
&input,
Some(&output),
Some(&map),
true,
"exact",
1.0,
1,
&[],
1,
"name",
test_logger(),
)?;
let expected_df = open_csv(&format!("{input}.unique.csv.expected"), None, None)?;
let output_df = open_csv(&output, None, None)?;
let sorted_expected = expected_df.sort(vec!["name"], SortMultipleOptions::new())?;
let sorted_output = output_df.sort(vec!["name"], SortMultipleOptions::new())?;
assert_eq!(sorted_expected, sorted_output);
delete_file(&output, false)?;
delete_file(&map, false)
}
#[test]
fn wrong_header_duplicate_files() -> Result<()> {
let input = format!("{TEST_DATA}/duplicate_files.csv");
let output = format!("{TEST_DATA}/out_wrong_header.csv");
let result = run(
&input,
Some(&output),
None,
false,
"exact",
1.0,
1,
&[],
1,
"wrongcol",
test_logger(),
);
delete_file(&output, true)?;
assert!(result.is_err());
Ok(())
}
}