use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimilarityMode {
Style,
Structure,
}
fn prepare(source: &str, mode: SimilarityMode) -> String {
match mode {
SimilarityMode::Style => {
source
.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string()
}
SimilarityMode::Structure => normalize_python(source),
}
}
fn normalize_python(source: &str) -> String {
let mut lines = Vec::new();
let mut in_docstring = false;
for line in source.lines() {
let trimmed = line.trim();
let triple_count = trimmed.matches("\"\"\"").count() + trimmed.matches("'''").count();
if triple_count > 0 {
if in_docstring {
in_docstring = false;
continue;
} else if triple_count == 1 {
in_docstring = true;
continue;
}
continue;
}
if in_docstring {
continue;
}
let line = if let Some(pos) = trimmed.find('#') {
&trimmed[..pos]
} else {
trimmed
};
let line = line.trim();
if line.is_empty() {
continue;
}
lines.push(line.to_lowercase());
}
lines.join("\n")
}
fn ngrams(text: &str, n: usize) -> HashSet<u64> {
use std::hash::{Hash, Hasher};
if text.len() < n {
return HashSet::new();
}
let bytes = text.as_bytes();
let mut grams = HashSet::new();
for window in bytes.windows(n) {
let mut hasher = std::hash::DefaultHasher::new();
window.hash(&mut hasher);
grams.insert(hasher.finish());
}
grams
}
fn jaccard(a: &HashSet<u64>, b: &HashSet<u64>) -> f64 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let intersection = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64;
if union == 0.0 {
return 0.0;
}
intersection / union
}
#[derive(Debug, Clone)]
pub struct SimilarityPair {
pub student_a: String,
pub student_b: String,
pub style_score: f64,
pub structure_score: f64,
pub score: f64,
}
pub fn compare_submissions(
submissions: &HashMap<String, Vec<PathBuf>>,
ngram_size: usize,
threshold: f64,
) -> Vec<SimilarityPair> {
let mut style_fps: HashMap<String, HashSet<u64>> = HashMap::new();
let mut struct_fps: HashMap<String, HashSet<u64>> = HashMap::new();
for (sid, files) in submissions {
let mut raw = String::new();
for file in files {
if let Ok(source) = std::fs::read_to_string(file) {
raw.push_str(&source);
raw.push('\n');
}
}
style_fps.insert(
sid.clone(),
ngrams(&prepare(&raw, SimilarityMode::Style), ngram_size),
);
struct_fps.insert(
sid.clone(),
ngrams(&prepare(&raw, SimilarityMode::Structure), ngram_size),
);
}
let mut sids: Vec<&String> = style_fps.keys().collect();
sids.sort();
let mut pairs = Vec::new();
for i in 0..sids.len() {
for j in (i + 1)..sids.len() {
let style = jaccard(&style_fps[sids[i]], &style_fps[sids[j]]);
let structure = jaccard(&struct_fps[sids[i]], &struct_fps[sids[j]]);
let combined = style.max(structure);
if combined >= threshold {
pairs.push(SimilarityPair {
student_a: sids[i].clone(),
student_b: sids[j].clone(),
style_score: style,
structure_score: structure,
score: combined,
});
}
}
}
pairs.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
pairs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_style_mode_preserves_details() {
let code = "x = 1 # my comment\nY = 2\n";
let prepared = prepare(code, SimilarityMode::Style);
assert!(prepared.contains("# my comment"));
assert!(prepared.contains("Y = 2")); }
#[test]
fn test_structure_mode_strips_details() {
let code = "X = 1 # my comment\nY = 2\n";
let prepared = prepare(code, SimilarityMode::Structure);
assert!(!prepared.contains("# my comment"));
assert!(prepared.contains("x = 1")); }
#[test]
fn test_identical_code_high_similarity() {
let code = "def f(x):\n return x + 1\n";
let a = ngrams(&prepare(code, SimilarityMode::Style), 5);
let b = ngrams(&prepare(code, SimilarityMode::Style), 5);
assert_eq!(jaccard(&a, &b), 1.0);
}
#[test]
fn test_variable_rename_high_structure_low_style() {
let code_a = "def find_max(a, b):\n if a > b:\n return a\n return b\n";
let code_b = "def find_max(x, y):\n if x > y:\n return x\n return y\n";
let style_a = ngrams(&prepare(code_a, SimilarityMode::Style), 5);
let style_b = ngrams(&prepare(code_b, SimilarityMode::Style), 5);
let style_sim = jaccard(&style_a, &style_b);
let struct_a = ngrams(&prepare(code_a, SimilarityMode::Structure), 5);
let struct_b = ngrams(&prepare(code_b, SimilarityMode::Structure), 5);
let struct_sim = jaccard(&struct_a, &struct_b);
assert!(struct_sim > 0.0);
assert!(style_sim > 0.0);
}
}