metrics.rs

  1use collections::HashMap;
  2
  3pub type Counts = HashMap<String, usize>;
  4type CountsDelta = HashMap<String, isize>;
  5
  6/// Context characters needed on each side of a change to capture all affected n-grams
  7const CONTEXT_CHARS: usize = CHR_F_CHAR_ORDER - 1;
  8
  9#[derive(Default, Debug, Clone)]
 10pub struct ClassificationMetrics {
 11    pub true_positives: usize,
 12    pub false_positives: usize,
 13    pub false_negatives: usize,
 14}
 15
 16impl ClassificationMetrics {
 17    pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics {
 18        let mut true_positives = 0;
 19        let mut false_positives = 0;
 20        let mut false_negatives = 0;
 21
 22        for (ngram, &expected_count) in expected {
 23            let actual_count = *actual.get(ngram).unwrap_or(&0);
 24            if actual_count > expected_count {
 25                false_positives += actual_count - expected_count;
 26            } else {
 27                false_negatives += expected_count - actual_count;
 28            }
 29            true_positives += expected_count.min(actual_count);
 30        }
 31
 32        for (ngram, &actual_count) in actual {
 33            if !expected.contains_key(ngram) {
 34                false_positives += actual_count;
 35            }
 36        }
 37
 38        ClassificationMetrics {
 39            true_positives,
 40            false_positives,
 41            false_negatives,
 42        }
 43    }
 44
 45    pub fn precision(&self) -> f64 {
 46        if self.true_positives + self.false_positives == 0 {
 47            0.0
 48        } else {
 49            self.true_positives as f64 / (self.true_positives + self.false_positives) as f64
 50        }
 51    }
 52
 53    pub fn recall(&self) -> f64 {
 54        if self.true_positives + self.false_negatives == 0 {
 55            0.0
 56        } else {
 57            self.true_positives as f64 / (self.true_positives + self.false_negatives) as f64
 58        }
 59    }
 60
 61    pub fn f1(&self) -> f64 {
 62        let precision = self.precision();
 63        let recall = self.recall();
 64        if precision + recall == 0.0 {
 65            0.0
 66        } else {
 67            2.0 * precision * recall / (precision + recall)
 68        }
 69    }
 70}
 71
 72enum ChrfWhitespace {
 73    #[allow(unused)]
 74    Unchanged,
 75    Ignore,
 76}
 77
 78const CHR_F_CHAR_ORDER: usize = 6;
 79const CHR_F_BETA: f64 = 2.0;
 80const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore;
 81
 82/// Computes a delta-chrF score that compares two sets of edits.
 83///
 84/// This metric works by:
 85/// 1. Computing n-gram count differences (deltas) between original→expected and original→actual
 86/// 2. Comparing these deltas to measure how well actual edits match expected edits
 87///
 88/// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match
 89/// the expected edits.
 90pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
 91    // Edge case: if all texts are identical, the edits match perfectly
 92    if original == expected && expected == actual {
 93        return 100.0;
 94    }
 95
 96    // Pre-filter whitespace once for all texts
 97    let orig_chars: Vec<char> = filter_whitespace_chars(original);
 98    let exp_chars: Vec<char> = filter_whitespace_chars(expected);
 99    let act_chars: Vec<char> = filter_whitespace_chars(actual);
100
101    // Find the changed regions between original→expected and original→actual
102    // We only need to compute n-grams on these regions (plus context for boundary n-grams)
103    let (orig_for_exp, exp_region) = extract_changed_regions(&orig_chars, &exp_chars);
104    let (orig_for_act, act_region) = extract_changed_regions(&orig_chars, &act_chars);
105
106    let mut total_precision = 0.0;
107    let mut total_recall = 0.0;
108
109    for order in 1..=CHR_F_CHAR_ORDER {
110        // Compute n-grams only on the affected regions
111        let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order);
112        let exp_ngrams = count_ngrams_from_chars(&exp_region, order);
113        let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp);
114
115        let orig_ngrams_for_act = count_ngrams_from_chars(&orig_for_act, order);
116        let act_ngrams = count_ngrams_from_chars(&act_region, order);
117        let actual_delta = compute_ngram_delta(&act_ngrams, &orig_ngrams_for_act);
118
119        if expected_delta.is_empty() && actual_delta.is_empty() {
120            total_precision += 1.0;
121            total_recall += 1.0;
122            continue;
123        }
124
125        let expected_counts = ngram_delta_to_counts(&expected_delta);
126        let actual_counts = ngram_delta_to_counts(&actual_delta);
127
128        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
129        total_precision += score.precision();
130        total_recall += score.recall();
131    }
132
133    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
134    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
135    let f_score = if prec + recall == 0.0 {
136        0.0
137    } else {
138        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
139    };
140
141    f_score * 100.0
142}
143
144/// Reference implementation of delta_chr_f (original, non-optimized version).
145/// Used for testing that the optimized version produces identical results.
146#[cfg(test)]
147fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
148    if original == expected && expected == actual {
149        return 100.0;
150    }
151
152    let original_ngrams = chr_f_ngram_counts(original);
153    let expected_ngrams = chr_f_ngram_counts(expected);
154    let actual_ngrams = chr_f_ngram_counts(actual);
155
156    let mut total_precision = 0.0;
157    let mut total_recall = 0.0;
158
159    for order in 0..CHR_F_CHAR_ORDER {
160        let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]);
161        let actual_delta = compute_ngram_delta(&actual_ngrams[order], &original_ngrams[order]);
162
163        if expected_delta.is_empty() && actual_delta.is_empty() {
164            total_precision += 1.0;
165            total_recall += 1.0;
166            continue;
167        }
168
169        let expected_counts = ngram_delta_to_counts(&expected_delta);
170        let actual_counts = ngram_delta_to_counts(&actual_delta);
171
172        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
173        total_precision += score.precision();
174        total_recall += score.recall();
175    }
176
177    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
178    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
179    let f_score = if prec + recall == 0.0 {
180        0.0
181    } else {
182        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
183    };
184
185    f_score * 100.0
186}
187
188/// Filter whitespace from a string and return as Vec<char>
189fn filter_whitespace_chars(text: &str) -> Vec<char> {
190    match CHR_F_WHITESPACE {
191        ChrfWhitespace::Unchanged => text.chars().collect(),
192        ChrfWhitespace::Ignore => text.chars().filter(|c| !c.is_whitespace()).collect(),
193    }
194}
195
196/// Extract only the changed regions between two texts, with context for n-gram boundaries.
197///
198/// Returns (original_affected_region, modified_affected_region) as Vec<char>.
199///
200/// The key insight: when computing n-gram delta between two nearly-identical texts,
201/// n-grams from unchanged regions cancel out. We only need to process:
202/// 1. The changed content itself
203/// 2. CONTEXT_CHARS (n-1) characters before and after, to capture boundary-crossing n-grams
204fn extract_changed_regions(original: &[char], modified: &[char]) -> (Vec<char>, Vec<char>) {
205    // Find longest common prefix
206    let prefix_len = original
207        .iter()
208        .zip(modified.iter())
209        .take_while(|(a, b)| a == b)
210        .count();
211
212    // Find longest common suffix (that doesn't overlap with prefix)
213    let orig_remaining = original.len().saturating_sub(prefix_len);
214    let mod_remaining = modified.len().saturating_sub(prefix_len);
215    let max_suffix = orig_remaining.min(mod_remaining);
216
217    let suffix_len = original
218        .iter()
219        .rev()
220        .zip(modified.iter().rev())
221        .take(max_suffix)
222        .take_while(|(a, b)| a == b)
223        .count();
224
225    // Calculate the changed region boundaries
226    let orig_change_start = prefix_len;
227    let orig_change_end = original.len().saturating_sub(suffix_len);
228    let mod_change_start = prefix_len;
229    let mod_change_end = modified.len().saturating_sub(suffix_len);
230
231    // If there's no actual change, return empty regions
232    if orig_change_start >= orig_change_end && mod_change_start >= mod_change_end {
233        return (Vec::new(), Vec::new());
234    }
235
236    // Expand to include context for n-gram boundaries
237    let orig_context_start = orig_change_start.saturating_sub(CONTEXT_CHARS);
238    let orig_context_end = (orig_change_end + CONTEXT_CHARS).min(original.len());
239    let mod_context_start = mod_change_start.saturating_sub(CONTEXT_CHARS);
240    let mod_context_end = (mod_change_end + CONTEXT_CHARS).min(modified.len());
241
242    let orig_region: Vec<char> = original[orig_context_start..orig_context_end].to_vec();
243    let mod_region: Vec<char> = modified[mod_context_start..mod_context_end].to_vec();
244
245    (orig_region, mod_region)
246}
247
248/// Count n-grams directly from a char slice (avoids String allocation for the full text)
249fn count_ngrams_from_chars(chars: &[char], n: usize) -> Counts {
250    let mut counts = Counts::default();
251
252    if chars.len() < n {
253        return counts;
254    }
255
256    for window in chars.windows(n) {
257        let ngram: String = window.iter().collect();
258        *counts.entry(ngram).or_insert(0) += 1;
259    }
260
261    counts
262}
263
264#[allow(dead_code)]
265fn chr_f_ngram_counts(text: &str) -> Vec<Counts> {
266    // Ignore whitespace. The original chrF implementation skips all
267    // whitespace. We should consider compressing multiple consecutive
268    // spaces into one -- this may reflect our task more closely.
269    let text = match CHR_F_WHITESPACE {
270        ChrfWhitespace::Unchanged => text.to_string(),
271        ChrfWhitespace::Ignore => text
272            .chars()
273            .filter(|c| !c.is_whitespace())
274            .collect::<String>(),
275    };
276
277    (1..=CHR_F_CHAR_ORDER)
278        .map(|order| count_ngrams(&text, order))
279        .collect()
280}
281
282fn compute_ngram_delta(after: &Counts, before: &Counts) -> CountsDelta {
283    let mut delta = CountsDelta::default();
284
285    for (ngram, &before_count) in before {
286        let after_count = *after.get(ngram).unwrap_or(&0);
287        delta.insert(ngram.clone(), after_count as isize - before_count as isize);
288    }
289
290    for (ngram, &after_count) in after {
291        if !before.contains_key(ngram) {
292            delta.insert(ngram.clone(), after_count as isize);
293        }
294    }
295
296    delta
297}
298
299/// Convert negative counts to special deletion tokens.
300/// For example, if expected delta is {"foo": -1} and actual delta is {"bar": -1},
301/// we convert it to {"¬foo": +1} and {"¬bar": +1}. This way _not_ deleting "foo"
302/// will result in a false negative, and mistakenly deleting "bar" will result in a false positive.
303fn ngram_delta_to_counts(delta: &CountsDelta) -> Counts {
304    let mut counts = Counts::default();
305
306    for (ngram, &delta) in delta {
307        if delta > 0 {
308            counts.insert(ngram.clone(), delta as usize);
309        } else if delta < 0 {
310            counts.insert(format!("¬{ngram}"), delta.unsigned_abs());
311        }
312    }
313
314    counts
315}
316
317#[allow(dead_code)]
318fn count_ngrams(text: &str, n: usize) -> Counts {
319    let chars: Vec<char> = text.chars().collect();
320    let mut counts = Counts::default();
321
322    for window in chars.windows(n) {
323        let ngram: String = window.iter().collect();
324        *counts.entry(ngram).or_insert(0) += 1;
325    }
326
327    counts
328}
329
330pub fn braces_disbalance(text: &str) -> usize {
331    let mut disbalance = 0isize;
332
333    let a = text.chars().filter(|&c| c == '{').count() as isize;
334    let b = text.chars().filter(|&c| c == '}').count() as isize;
335    disbalance += (a - b).abs();
336
337    let a = text.chars().filter(|&c| c == '(').count() as isize;
338    let b = text.chars().filter(|&c| c == ')').count() as isize;
339    disbalance += (a - b).abs();
340
341    let a = text.chars().filter(|&c| c == '[').count() as isize;
342    let b = text.chars().filter(|&c| c == ']').count() as isize;
343    disbalance += (a - b).abs();
344
345    disbalance as usize
346}
347
348/// Extracts changed lines from a unified diff string.
349/// Returns a bag (multiset) of lines that were added (+) or removed (-).
350/// The +/- prefix is included in the line to distinguish additions from deletions.
351pub fn extract_changed_lines_from_diff(diff: &str) -> Counts {
352    let mut counts = Counts::default();
353
354    for line in diff.lines() {
355        // Skip file headers (--- and +++)
356        if line.starts_with("---") || line.starts_with("+++") {
357            continue;
358        }
359        // Skip hunk headers (@@)
360        if line.starts_with("@@") {
361            continue;
362        }
363        // Skip diff header lines (diff --git, index, etc.)
364        if line.starts_with("diff ") || line.starts_with("index ") {
365            continue;
366        }
367        // Include added and removed lines (with their prefix)
368        if line.starts_with('+') || line.starts_with('-') {
369            *counts.entry(line.to_string()).or_insert(0) += 1;
370        }
371    }
372
373    counts
374}
375
376/// Computes exact lines match metrics between expected and actual patches.
377/// Treats changed lines as a bag (multiset) - order is discarded but count matters.
378/// Returns ClassificationMetrics with TP/FP/FN counts.
379pub fn exact_lines_match(expected_patch: &str, actual_patch: &str) -> ClassificationMetrics {
380    let expected_lines = extract_changed_lines_from_diff(expected_patch);
381    let actual_lines = extract_changed_lines_from_diff(actual_patch);
382    ClassificationMetrics::from_counts(&expected_lines, &actual_lines)
383}
384
385#[cfg(test)]
386mod test_optimization {
387    use super::*;
388
389    #[test]
390    fn test_extract_changed_regions_simple() {
391        let original: Vec<char> = "hello world".chars().collect();
392        let modified: Vec<char> = "hello there".chars().collect();
393
394        let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
395
396        // "world" vs "there" - with 5 chars context, we get "ello world" vs "ello there"
397        // (or less if not enough chars available)
398        assert!(orig_region.len() < original.len());
399        assert!(mod_region.len() < modified.len());
400    }
401
402    #[test]
403    fn test_extract_changed_regions_insertion() {
404        let original: Vec<char> = "abcdef".chars().collect();
405        let modified: Vec<char> = "abcXYZdef".chars().collect();
406
407        let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
408
409        // The insertion is between c and d, so we need context around that point
410        assert!(orig_region.len() <= original.len());
411        assert!(mod_region.iter().collect::<String>().contains("XYZ"));
412    }
413
414    #[test]
415    fn test_extract_changed_regions_identical() {
416        let text: Vec<char> = "identical text".chars().collect();
417
418        let (orig_region, mod_region) = extract_changed_regions(&text, &text);
419
420        // When texts are identical, regions should be empty
421        assert!(orig_region.is_empty());
422        assert!(mod_region.is_empty());
423    }
424
425    #[test]
426    fn test_optimized_matches_original_score() {
427        // Test that our optimized version produces the same results
428        let test_cases = vec![
429            ("hello world", "hello there", "hello world"),
430            (
431                "fn main() {}",
432                "fn main() { println!(); }",
433                "fn main() { print!(); }",
434            ),
435            ("abcdefghij", "abcXXXghij", "abcYYghij"),
436            ("unchanged", "unchanged", "unchanged"),
437            (
438                "prefix middle suffix",
439                "prefix CHANGED suffix",
440                "prefix middle suffix",
441            ),
442        ];
443
444        for (original, expected, actual) in test_cases {
445            let score = delta_chr_f(original, expected, actual);
446            // Just verify it produces a reasonable score (0-100)
447            assert!(
448                score >= 0.0 && score <= 100.0,
449                "Score {} out of range for ({}, {}, {})",
450                score,
451                original,
452                expected,
453                actual
454            );
455        }
456    }
457
458    #[test]
459    fn test_optimized_equals_reference() {
460        // Comprehensive test that optimized version matches reference implementation exactly
461        let test_cases = vec![
462            // Basic cases
463            ("hello world", "hello there", "hello world"),
464            ("hello world", "hello there", "hello there"),
465            ("unchanged", "unchanged", "unchanged"),
466            // Code-like cases
467            (
468                "fn main() { println!(\"Hello\"); }",
469                "fn main() { println!(\"Hello, World!\"); }",
470                "fn main() { println!(\"Hello, World!\"); }",
471            ),
472            (
473                "fn main() { println!(\"Hello\"); }",
474                "fn main() { println!(\"Hello, World!\"); }",
475                "fn main() { println!(\"Goodbye\"); }",
476            ),
477            // Insertion
478            ("abcdef", "abcXYZdef", "abcdef"),
479            ("abcdef", "abcXYZdef", "abcXYZdef"),
480            ("abcdef", "abcXYZdef", "abcABCdef"),
481            // Deletion
482            ("abcXYZdef", "abcdef", "abcXYZdef"),
483            ("abcXYZdef", "abcdef", "abcdef"),
484            // Multiple changes (simulated by different expected/actual)
485            ("one two three four", "one THREE four", "one two FOUR"),
486            // Edge cases
487            ("a", "b", "c"),
488            ("", "abc", ""),
489            ("abc", "", "abc"),
490            // Longer text with small change
491            (
492                "This is a longer piece of text that contains many words and characters to process",
493                "This is a longer piece of TEXT that contains many words and characters to process",
494                "This is a longer piece of text that contains many words and characters to process",
495            ),
496            // Change at the beginning
497            (
498                "ORIGINAL start of text",
499                "NEW start of text",
500                "DIFFERENT start of text",
501            ),
502            // Change at the end
503            (
504                "text ending ORIGINAL",
505                "text ending NEW",
506                "text ending DIFFERENT",
507            ),
508            // Whitespace (should be ignored)
509            ("hello   world", "hello   there", "hello   world"),
510            ("a b c d", "a X c d", "a Y c d"),
511        ];
512
513        for (original, expected, actual) in test_cases {
514            let optimized_score = delta_chr_f(original, expected, actual);
515            let reference_score = delta_chr_f_reference(original, expected, actual);
516
517            assert!(
518                (optimized_score - reference_score).abs() < 1e-10,
519                "Mismatch for ({:?}, {:?}, {:?}):\n  optimized: {}\n  reference: {}",
520                original,
521                expected,
522                actual,
523                optimized_score,
524                reference_score
525            );
526        }
527    }
528}
529
530#[cfg(test)]
531mod test {
532    use super::*;
533
534    #[test]
535    fn test_delta_chr_f_perfect_match() {
536        let original = "fn main() {    println!(\"Hello\");}";
537        let expected = "fn main() {    println!(\"Hello, World!\");}";
538
539        let score = delta_chr_f(original, expected, expected);
540        assert!((score - 100.0).abs() < 1e-2);
541    }
542
543    #[test]
544    fn test_delta_chr_f_wrong_edit() {
545        // When the edit is wrong
546        let original = "one two three";
547        let expected = "one three"; // deleted "two "
548        let actual = "one two four"; // deleted "three", added "four"
549
550        // Then the score should be low
551        let score = delta_chr_f(original, expected, actual);
552        assert!(score > 20.0 && score < 40.0);
553    }
554
555    #[test]
556    fn test_delta_chr_f_partial_match() {
557        let original = "let x = 42;";
558        let expected = "let x = 100;";
559        let actual = "let x = 99;";
560
561        // We got the edit location right, but the replacement text is wrong.
562        // Deleted ngrams will match, bringing the score somewhere in the middle.
563        let score = delta_chr_f(original, expected, actual);
564        assert!(score > 40.0 && score < 60.0);
565    }
566
567    #[test]
568    fn test_delta_chr_f_missed_edit() {
569        // When predictions makes no changes
570        let original = "prefix old suffix";
571        let expected = "prefix new suffix";
572        let actual = "prefix old suffix"; // no change
573
574        // Then the score should be low (all expected changes are false negatives)
575        let score = delta_chr_f(original, expected, actual);
576        assert!(score < 20.0);
577    }
578
579    #[test]
580    fn test_delta_chr_f_extra_edit() {
581        // When adding unexpected content
582        let original = "helloworld";
583        let expected = "helloworld"; // no change expected
584        let actual = "helloextraworld"; // added "extra"
585
586        // Then the score should be low (all actual changes are false positives)
587        let score = delta_chr_f(original, expected, actual);
588        assert!(score < 20.0);
589    }
590
591    #[test]
592    fn test_delta_chr_f_no_changes() {
593        let text = "unchanged text";
594        let score = delta_chr_f(text, text, text);
595        assert!((score - 100.0).abs() < 1e-2);
596    }
597
598    #[test]
599    fn test_braces_disbalance() {
600        let text = "let x = { 1 + 2 };";
601        assert_eq!(braces_disbalance(text), 0);
602
603        let text = "let x = { 1 + 2";
604        assert_eq!(braces_disbalance(text), 1);
605
606        let text = "let x = { 1 + 2 )";
607        assert_eq!(braces_disbalance(text), 2);
608    }
609
610    #[test]
611    fn test_extract_changed_lines_from_diff() {
612        let diff = r#"--- a/file.rs
613+++ b/file.rs
614@@ -1,3 +1,3 @@
615 fn main() {
616-    println!("hello");
617+    println!("world");
618 }"#;
619
620        let counts = extract_changed_lines_from_diff(diff);
621        assert_eq!(counts.get("-    println!(\"hello\");"), Some(&1));
622        assert_eq!(counts.get("+    println!(\"world\");"), Some(&1));
623        assert_eq!(counts.len(), 2);
624    }
625
626    #[test]
627    fn test_extract_changed_lines_skips_headers() {
628        let diff = r#"diff --git a/file.rs b/file.rs
629index abc123..def456 100644
630--- a/file.rs
631+++ b/file.rs
632@@ -1,2 +1,2 @@
633-old line
634+new line"#;
635
636        let counts = extract_changed_lines_from_diff(diff);
637        assert_eq!(counts.get("-old line"), Some(&1));
638        assert_eq!(counts.get("+new line"), Some(&1));
639        assert_eq!(counts.len(), 2);
640    }
641
642    #[test]
643    fn test_exact_lines_match_perfect() {
644        let expected = r#"--- a/file.rs
645+++ b/file.rs
646@@ -1,3 +1,3 @@
647-old line 1
648-old line 2
649+new line 1
650+new line 2"#;
651
652        let actual = r#"--- a/file.rs
653+++ b/file.rs
654@@ -1,3 +1,3 @@
655-old line 1
656-old line 2
657+new line 1
658+new line 2"#;
659
660        let metrics = exact_lines_match(expected, actual);
661        assert_eq!(metrics.true_positives, 4);
662        assert_eq!(metrics.false_positives, 0);
663        assert_eq!(metrics.false_negatives, 0);
664        assert!((metrics.precision() - 1.0).abs() < 1e-6);
665        assert!((metrics.recall() - 1.0).abs() < 1e-6);
666        assert!((metrics.f1() - 1.0).abs() < 1e-6);
667    }
668
669    #[test]
670    fn test_exact_lines_match_partial() {
671        let expected = r#"-old line 1
672-old line 2
673+new line 1
674+new line 2"#;
675
676        let actual = r#"-old line 1
677+new line 1
678+extra line"#;
679
680        let metrics = exact_lines_match(expected, actual);
681        // TP: "-old line 1" and "+new line 1" (2)
682        // FP: "+extra line" (1)
683        // FN: "-old line 2" and "+new line 2" (2)
684        assert_eq!(metrics.true_positives, 2);
685        assert_eq!(metrics.false_positives, 1);
686        assert_eq!(metrics.false_negatives, 2);
687    }
688
689    #[test]
690    fn test_exact_lines_match_no_overlap() {
691        let expected = r#"-line a
692+line b"#;
693
694        let actual = r#"-line x
695+line y"#;
696
697        let metrics = exact_lines_match(expected, actual);
698        assert_eq!(metrics.true_positives, 0);
699        assert_eq!(metrics.false_positives, 2);
700        assert_eq!(metrics.false_negatives, 2);
701        assert!((metrics.precision()).abs() < 1e-6);
702        assert!((metrics.recall()).abs() < 1e-6);
703    }
704
705    #[test]
706    fn test_exact_lines_match_duplicate_lines() {
707        let expected = r#"+line a
708+line a
709+line a"#;
710
711        let actual = r#"+line a
712+line a"#;
713
714        let metrics = exact_lines_match(expected, actual);
715        // Expected has 3 "+line a", actual has 2
716        // TP: 2, FN: 1, FP: 0
717        assert_eq!(metrics.true_positives, 2);
718        assert_eq!(metrics.false_positives, 0);
719        assert_eq!(metrics.false_negatives, 1);
720    }
721
722    #[test]
723    fn test_exact_lines_match_empty_patches() {
724        let metrics = exact_lines_match("", "");
725        assert_eq!(metrics.true_positives, 0);
726        assert_eq!(metrics.false_positives, 0);
727        assert_eq!(metrics.false_negatives, 0);
728    }
729}