1use std::collections::HashSet;
20
21#[derive(Debug, Clone)]
23pub struct ContaminationResult {
24 pub sample_index: usize,
26 pub max_overlap: f64,
28 pub matched_reference: usize,
30 pub contaminated: bool,
32}
33
34#[derive(Debug, Clone)]
36pub struct DecontaminationReport {
37 pub ngram_size: usize,
39 pub threshold: f64,
41 pub total_samples: usize,
43 pub evaluated_samples: usize,
51 pub evaluated_references: usize,
54 pub contaminated_count: usize,
56 pub contamination_rate: f64,
58 pub flagged: Vec<ContaminationResult>,
60}
61
62impl DecontaminationReport {
63 #[must_use]
70 pub fn is_vacuous(&self) -> bool {
71 (self.total_samples > 0 && self.evaluated_samples == 0) || self.evaluated_references == 0
72 }
73}
74
75fn extract_ngrams(text: &str, n: usize) -> HashSet<Vec<char>> {
81 if n == 0 {
82 return HashSet::new();
83 }
84
85 let chars: Vec<char> = text
86 .chars()
87 .filter(|c| !c.is_whitespace())
88 .flat_map(|c| c.to_lowercase())
89 .collect();
90
91 if chars.len() < n {
92 return HashSet::new();
93 }
94
95 chars.windows(n).map(|w| w.to_vec()).collect()
96}
97
98pub fn ngram_overlap(candidate: &str, reference: &str, n: usize) -> f64 {
103 let cand_ngrams = extract_ngrams(candidate, n);
104 if cand_ngrams.is_empty() {
105 return 0.0;
106 }
107
108 let ref_ngrams = extract_ngrams(reference, n);
109 let intersection = cand_ngrams.intersection(&ref_ngrams).count();
110
111 intersection as f64 / cand_ngrams.len() as f64
112}
113
114pub fn check_contamination(
127 training_samples: &[&str],
128 reference_samples: &[&str],
129 ngram_size: usize,
130 threshold: f64,
131) -> DecontaminationReport {
132 let ref_ngram_sets: Vec<HashSet<Vec<char>>> = reference_samples
134 .iter()
135 .map(|s| extract_ngrams(s, ngram_size))
136 .collect();
137
138 let evaluated_references = ref_ngram_sets.iter().filter(|s| !s.is_empty()).count();
139
140 let mut flagged = Vec::new();
141 let mut evaluated_samples = 0usize;
142
143 for (i, sample) in training_samples.iter().enumerate() {
144 let cand_ngrams = extract_ngrams(sample, ngram_size);
145 if cand_ngrams.is_empty() {
146 continue;
147 }
148 evaluated_samples += 1;
149
150 let mut max_overlap = 0.0_f64;
151 let mut matched_ref = 0;
152
153 for (j, ref_set) in ref_ngram_sets.iter().enumerate() {
154 let intersection = cand_ngrams.intersection(ref_set).count();
155 let overlap = intersection as f64 / cand_ngrams.len() as f64;
156
157 if overlap > max_overlap {
158 max_overlap = overlap;
159 matched_ref = j;
160 }
161 }
162
163 if max_overlap > threshold {
164 flagged.push(ContaminationResult {
165 sample_index: i,
166 max_overlap,
167 matched_reference: matched_ref,
168 contaminated: true,
169 });
170 }
171 }
172
173 let contaminated_count = flagged.len();
174 let total = training_samples.len();
175 let rate = if total > 0 {
176 contaminated_count as f64 / total as f64
177 } else {
178 0.0
179 };
180
181 DecontaminationReport {
182 ngram_size,
183 threshold,
184 total_samples: total,
185 evaluated_samples,
186 evaluated_references,
187 contaminated_count,
188 contamination_rate: rate,
189 flagged,
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn test_extract_ngrams() {
199 let ngrams = extract_ngrams("hello world", 3);
200 assert_eq!(ngrams.len(), 8);
202 }
203
204 #[test]
205 fn test_extract_ngrams_short_text() {
206 let ngrams = extract_ngrams("hi", 10);
207 assert!(ngrams.is_empty());
208 }
209
210 #[test]
211 fn test_ngram_overlap_identical() {
212 let overlap = ngram_overlap("def fibonacci(n):", "def fibonacci(n):", 5);
213 assert!((overlap - 1.0).abs() < f64::EPSILON);
214 }
215
216 #[test]
217 fn test_ngram_overlap_no_match() {
218 let overlap = ngram_overlap(
219 "completely different text about cooking",
220 "def fibonacci(n): return n if n < 2",
221 10,
222 );
223 assert!(overlap < 0.1);
224 }
225
226 #[test]
227 fn test_ngram_overlap_partial() {
228 let overlap = ngram_overlap(
229 "def fibonacci(n): return n if n < 2 else fibonacci(n-1)",
230 "def fibonacci(n): return fib(n-1) + fib(n-2)",
231 5,
232 );
233 assert!(overlap > 0.0);
235 assert!(overlap < 1.0);
236 }
237
238 #[test]
239 fn test_check_contamination_clean() {
240 let training = vec![
241 "def sort_list(lst): return sorted(lst)",
242 "def reverse_string(s): return s[::-1]",
243 ];
244 let reference =
245 vec!["def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)"];
246
247 let report = check_contamination(&training, &reference, 10, 0.5);
248 assert_eq!(report.contaminated_count, 0);
249 assert!(report.contamination_rate < 0.01);
250 }
251
252 #[test]
253 fn test_check_contamination_flagged() {
254 let reference_text =
255 "def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)";
256 let training = vec![
257 "def sort_list(lst): return sorted(lst)",
258 reference_text, ];
260 let reference = vec![reference_text];
261
262 let report = check_contamination(&training, &reference, 10, 0.5);
263 assert_eq!(report.contaminated_count, 1);
264 assert_eq!(report.flagged[0].sample_index, 1);
265 assert!((report.flagged[0].max_overlap - 1.0).abs() < f64::EPSILON);
266 }
267
268 #[test]
269 fn test_check_contamination_threshold() {
270 let training =
271 vec!["def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)"];
272 let reference = vec!["def fibonacci(n): return n if n < 2 else fib(n-1) + fib(n-2)"];
273
274 let strict = check_contamination(&training, &reference, 5, 0.3);
276 let lenient = check_contamination(&training, &reference, 10, 0.9);
278
279 assert!(strict.contaminated_count >= lenient.contaminated_count);
280 }
281
282 #[test]
287 fn test_ngram_size_zero_does_not_panic_and_is_vacuous() {
288 let text = "def fibonacci(n): return n if n < 2 else fibonacci(n-1)";
289 assert!(extract_ngrams(text, 0).is_empty());
290 assert!((ngram_overlap(text, text, 0) - 0.0).abs() < f64::EPSILON);
291
292 let report = check_contamination(&[text, "unrelated gardening text"], &[text], 0, 0.5);
293 assert_eq!(report.total_samples, 2);
294 assert_eq!(report.evaluated_samples, 0);
295 assert_eq!(report.contaminated_count, 0);
296 assert!(
297 report.is_vacuous(),
298 "ngram_size 0 compares nothing; a 0.00% rate must not read as a pass"
299 );
300 }
301
302 #[test]
305 fn test_oversized_ngram_is_vacuous_not_clean() {
306 let leaked = "the capital of france is paris";
307 let report = check_contamination(&[leaked, leaked], &[leaked], 1000, 0.5);
308 assert_eq!(report.total_samples, 2);
309 assert_eq!(report.evaluated_samples, 0);
310 assert_eq!(report.contaminated_count, 0);
311 assert!((report.contamination_rate - 0.0).abs() < f64::EPSILON);
312 assert!(
313 report.is_vacuous(),
314 "two byte-identical copies of the benchmark must never be reported as clean"
315 );
316 }
317
318 #[test]
321 fn test_short_references_only_is_vacuous() {
322 let training = "def fibonacci(n): return n if n < 2 else fibonacci(n-1)";
323 let report = check_contamination(&[training], &["hi"], 10, 0.5);
324 assert_eq!(report.evaluated_samples, 1);
325 assert_eq!(report.evaluated_references, 0);
326 assert!(report.is_vacuous());
327 }
328
329 #[test]
332 fn test_real_check_is_not_vacuous() {
333 let leaked = "def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)";
334 let report = check_contamination(
335 &["def sort_list(lst): return sorted(lst)", leaked],
336 &[leaked],
337 10,
338 0.5,
339 );
340 assert_eq!(report.evaluated_samples, 2);
341 assert_eq!(report.evaluated_references, 1);
342 assert!(!report.is_vacuous());
343 assert_eq!(report.contaminated_count, 1);
344 }
345
346 #[test]
347 fn test_empty_inputs() {
348 let report = check_contamination(&[], &["some reference"], 10, 0.5);
349 assert_eq!(report.total_samples, 0);
350 assert_eq!(report.contaminated_count, 0);
351
352 let report2 = check_contamination(&["some training"], &[], 10, 0.5);
353 assert_eq!(report2.contaminated_count, 0);
354 }
355}