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}