Skip to main content

alimentar/quality/
decontaminate.rs

1//! N-gram decontamination for benchmark safety.
2//!
3//! Detects overlap between training data and evaluation benchmarks
4//! using n-gram fingerprinting. Training samples that exceed the
5//! overlap threshold are flagged for removal.
6//!
7//! # Algorithm
8//!
9//! 1. Build n-gram set from each reference benchmark sample
10//! 2. For each training sample, compute n-gram overlap ratio
11//! 3. Flag samples exceeding threshold (default 50%)
12//!
13//! # References
14//!
15//! - Spec §12.1: Decontamination Protocol
16//! - AC-016: <1% n-gram overlap between training and eval data
17//! - GH-9: `apr validate --decontaminate`
18
19use std::collections::HashSet;
20
21/// Result of decontamination check on a single sample.
22#[derive(Debug, Clone)]
23pub struct ContaminationResult {
24    /// Index of the training sample
25    pub sample_index: usize,
26    /// Maximum overlap ratio with any reference sample (0.0 to 1.0)
27    pub max_overlap: f64,
28    /// Index of the reference sample with highest overlap
29    pub matched_reference: usize,
30    /// Whether this sample exceeds the contamination threshold
31    pub contaminated: bool,
32}
33
34/// Summary report of decontamination check.
35#[derive(Debug, Clone)]
36pub struct DecontaminationReport {
37    /// N-gram size used
38    pub ngram_size: usize,
39    /// Overlap threshold used
40    pub threshold: f64,
41    /// Total training samples checked
42    pub total_samples: usize,
43    /// Training samples that yielded at least one n-gram and were therefore
44    /// actually compared against the reference set.
45    ///
46    /// Samples shorter than `ngram_size` (and every sample when `ngram_size`
47    /// is 0) produce no n-grams and are silently un-checkable. When this is 0
48    /// but `total_samples` is not, the check was **vacuous**: a clean
49    /// `contamination_rate` of 0.0 proves nothing.
50    pub evaluated_samples: usize,
51    /// Reference samples that yielded at least one n-gram. Zero here also
52    /// makes the check vacuous — nothing could ever be matched against.
53    pub evaluated_references: usize,
54    /// Number of contaminated samples
55    pub contaminated_count: usize,
56    /// Contamination rate (0.0 to 1.0)
57    pub contamination_rate: f64,
58    /// Per-sample results (only contaminated samples included)
59    pub flagged: Vec<ContaminationResult>,
60}
61
62impl DecontaminationReport {
63    /// Whether the configuration made the check meaningless.
64    ///
65    /// True when there was input to check but nothing could be compared —
66    /// e.g. `ngram_size` exceeds the length of every sample, or is 0. A
67    /// caller enforcing a contamination gate must treat this as a failure,
68    /// not as a pass.
69    #[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
75/// Extract character-level n-grams from text.
76///
77/// Returns an empty set when `n` is 0 or larger than the whitespace-stripped
78/// text — a zero-width window has no meaning, and this function must remain
79/// total: `n` reaches here from a user-supplied `--ngram` flag.
80fn 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
98/// Compute n-gram overlap ratio between two texts.
99///
100/// Returns the fraction of n-grams in `candidate` that also
101/// appear in `reference`. Range: 0.0 (no overlap) to 1.0 (complete).
102pub 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
114/// Check training data against reference benchmarks for contamination.
115///
116/// # Arguments
117///
118/// * `training_samples` - Training data texts
119/// * `reference_samples` - Benchmark/eval texts to check against
120/// * `ngram_size` - Size of n-grams (default: 10)
121/// * `threshold` - Overlap ratio above which a sample is contaminated
122///
123/// # Returns
124///
125/// `DecontaminationReport` with per-sample results and summary stats.
126pub fn check_contamination(
127    training_samples: &[&str],
128    reference_samples: &[&str],
129    ngram_size: usize,
130    threshold: f64,
131) -> DecontaminationReport {
132    // Pre-compute reference n-gram sets
133    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        // "helloworld" -> "hel", "ell", "llo", "low", "owo", "wor", "orl", "rld"
201        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        // Partial overlap from shared prefix
234        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, // exact copy
259        ];
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        // Strict threshold should catch partial overlap
275        let strict = check_contamination(&training, &reference, 5, 0.3);
276        // Lenient threshold should pass
277        let lenient = check_contamination(&training, &reference, 10, 0.9);
278
279        assert!(strict.contaminated_count >= lenient.contaminated_count);
280    }
281
282    /// A zero n-gram size arrives from a user-supplied `--ngram` flag and used
283    /// to reach `slice::windows(0)`, aborting the process with
284    /// "window size must be non-zero". It must return, and it must not claim
285    /// the corpus is clean.
286    #[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    /// An n-gram larger than every sample also silently compares nothing —
303    /// the same vacuous-pass hazard, reached without a panic.
304    #[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    /// A reference set whose samples are all too short compares nothing
319    /// either, even when the training samples are long enough.
320    #[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    /// The healthy path must NOT be flagged as vacuous, or the gate would
330    /// fail closed on every real corpus.
331    #[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}