metrics.rs

   1use collections::HashMap;
   2
   3use crate::{
   4    example::ActualCursor,
   5    reorder_patch::{Patch, PatchLine},
   6    word_diff::{DiffOp, diff_tokens, tokenize},
   7};
   8
   9pub type Counts = HashMap<String, usize>;
  10type CountsDelta = HashMap<String, isize>;
  11
  12/// Context characters needed on each side of a change to capture all affected n-grams
  13const CONTEXT_CHARS: usize = CHR_F_CHAR_ORDER - 1;
  14
  15#[derive(Default, Debug, Clone)]
  16pub struct ClassificationMetrics {
  17    pub true_positives: usize,
  18    pub false_positives: usize,
  19    pub false_negatives: usize,
  20}
  21
  22impl ClassificationMetrics {
  23    pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics {
  24        let mut true_positives = 0;
  25        let mut false_positives = 0;
  26        let mut false_negatives = 0;
  27
  28        for (ngram, &expected_count) in expected {
  29            let actual_count = *actual.get(ngram).unwrap_or(&0);
  30            if actual_count > expected_count {
  31                false_positives += actual_count - expected_count;
  32            } else {
  33                false_negatives += expected_count - actual_count;
  34            }
  35            true_positives += expected_count.min(actual_count);
  36        }
  37
  38        for (ngram, &actual_count) in actual {
  39            if !expected.contains_key(ngram) {
  40                false_positives += actual_count;
  41            }
  42        }
  43
  44        ClassificationMetrics {
  45            true_positives,
  46            false_positives,
  47            false_negatives,
  48        }
  49    }
  50
  51    pub fn precision(&self) -> f64 {
  52        if self.true_positives + self.false_positives == 0 {
  53            0.0
  54        } else {
  55            self.true_positives as f64 / (self.true_positives + self.false_positives) as f64
  56        }
  57    }
  58
  59    pub fn recall(&self) -> f64 {
  60        if self.true_positives + self.false_negatives == 0 {
  61            0.0
  62        } else {
  63            self.true_positives as f64 / (self.true_positives + self.false_negatives) as f64
  64        }
  65    }
  66
  67    pub fn f1(&self) -> f64 {
  68        let precision = self.precision();
  69        let recall = self.recall();
  70        if precision + recall == 0.0 {
  71            0.0
  72        } else {
  73            2.0 * precision * recall / (precision + recall)
  74        }
  75    }
  76}
  77
  78enum ChrfWhitespace {
  79    #[allow(unused)]
  80    Unchanged,
  81    Ignore,
  82}
  83
  84const CHR_F_CHAR_ORDER: usize = 6;
  85const CHR_F_BETA: f64 = 2.0;
  86const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore;
  87
  88/// Computes a delta-chrF score that compares two sets of edits.
  89///
  90/// This metric works by:
  91/// 1. Computing n-gram count differences (deltas) between original→expected and original→actual
  92/// 2. Comparing these deltas to measure how well actual edits match expected edits
  93///
  94/// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match
  95/// the expected edits.
  96pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
  97    // Edge case: if all texts are identical, the edits match perfectly
  98    if original == expected && expected == actual {
  99        return 100.0;
 100    }
 101
 102    // Pre-filter whitespace once for all texts
 103    let orig_chars: Vec<char> = filter_whitespace_chars(original);
 104    let exp_chars: Vec<char> = filter_whitespace_chars(expected);
 105    let act_chars: Vec<char> = filter_whitespace_chars(actual);
 106
 107    // Find the changed regions between original→expected and original→actual
 108    // We only need to compute n-grams on these regions (plus context for boundary n-grams)
 109    let (orig_for_exp, exp_region) = extract_changed_regions(&orig_chars, &exp_chars);
 110    let (orig_for_act, act_region) = extract_changed_regions(&orig_chars, &act_chars);
 111
 112    let mut total_precision = 0.0;
 113    let mut total_recall = 0.0;
 114
 115    for order in 1..=CHR_F_CHAR_ORDER {
 116        // Compute n-grams only on the affected regions
 117        let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order);
 118        let exp_ngrams = count_ngrams_from_chars(&exp_region, order);
 119        let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp);
 120
 121        let orig_ngrams_for_act = count_ngrams_from_chars(&orig_for_act, order);
 122        let act_ngrams = count_ngrams_from_chars(&act_region, order);
 123        let actual_delta = compute_ngram_delta(&act_ngrams, &orig_ngrams_for_act);
 124
 125        if expected_delta.is_empty() && actual_delta.is_empty() {
 126            total_precision += 1.0;
 127            total_recall += 1.0;
 128            continue;
 129        }
 130
 131        let expected_counts = ngram_delta_to_counts(&expected_delta);
 132        let actual_counts = ngram_delta_to_counts(&actual_delta);
 133
 134        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
 135        total_precision += score.precision();
 136        total_recall += score.recall();
 137    }
 138
 139    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
 140    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
 141    let f_score = if prec + recall == 0.0 {
 142        0.0
 143    } else {
 144        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
 145    };
 146
 147    f_score * 100.0
 148}
 149
 150/// Reference implementation of delta_chr_f (original, non-optimized version).
 151/// Used for testing that the optimized version produces identical results.
 152#[cfg(test)]
 153fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
 154    if original == expected && expected == actual {
 155        return 100.0;
 156    }
 157
 158    let original_ngrams = chr_f_ngram_counts(original);
 159    let expected_ngrams = chr_f_ngram_counts(expected);
 160    let actual_ngrams = chr_f_ngram_counts(actual);
 161
 162    let mut total_precision = 0.0;
 163    let mut total_recall = 0.0;
 164
 165    for order in 0..CHR_F_CHAR_ORDER {
 166        let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]);
 167        let actual_delta = compute_ngram_delta(&actual_ngrams[order], &original_ngrams[order]);
 168
 169        if expected_delta.is_empty() && actual_delta.is_empty() {
 170            total_precision += 1.0;
 171            total_recall += 1.0;
 172            continue;
 173        }
 174
 175        let expected_counts = ngram_delta_to_counts(&expected_delta);
 176        let actual_counts = ngram_delta_to_counts(&actual_delta);
 177
 178        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
 179        total_precision += score.precision();
 180        total_recall += score.recall();
 181    }
 182
 183    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
 184    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
 185    let f_score = if prec + recall == 0.0 {
 186        0.0
 187    } else {
 188        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
 189    };
 190
 191    f_score * 100.0
 192}
 193
 194/// Filter whitespace from a string and return as Vec<char>
 195fn filter_whitespace_chars(text: &str) -> Vec<char> {
 196    match CHR_F_WHITESPACE {
 197        ChrfWhitespace::Unchanged => text.chars().collect(),
 198        ChrfWhitespace::Ignore => text.chars().filter(|c| !c.is_whitespace()).collect(),
 199    }
 200}
 201
 202/// Extract only the changed regions between two texts, with context for n-gram boundaries.
 203///
 204/// Returns (original_affected_region, modified_affected_region) as Vec<char>.
 205///
 206/// The key insight: when computing n-gram delta between two nearly-identical texts,
 207/// n-grams from unchanged regions cancel out. We only need to process:
 208/// 1. The changed content itself
 209/// 2. CONTEXT_CHARS (n-1) characters before and after, to capture boundary-crossing n-grams
 210fn extract_changed_regions(original: &[char], modified: &[char]) -> (Vec<char>, Vec<char>) {
 211    // Find longest common prefix
 212    let prefix_len = original
 213        .iter()
 214        .zip(modified.iter())
 215        .take_while(|(a, b)| a == b)
 216        .count();
 217
 218    // Find longest common suffix (that doesn't overlap with prefix)
 219    let orig_remaining = original.len().saturating_sub(prefix_len);
 220    let mod_remaining = modified.len().saturating_sub(prefix_len);
 221    let max_suffix = orig_remaining.min(mod_remaining);
 222
 223    let suffix_len = original
 224        .iter()
 225        .rev()
 226        .zip(modified.iter().rev())
 227        .take(max_suffix)
 228        .take_while(|(a, b)| a == b)
 229        .count();
 230
 231    // Calculate the changed region boundaries
 232    let orig_change_start = prefix_len;
 233    let orig_change_end = original.len().saturating_sub(suffix_len);
 234    let mod_change_start = prefix_len;
 235    let mod_change_end = modified.len().saturating_sub(suffix_len);
 236
 237    // If there's no actual change, return empty regions
 238    if orig_change_start >= orig_change_end && mod_change_start >= mod_change_end {
 239        return (Vec::new(), Vec::new());
 240    }
 241
 242    // Expand to include context for n-gram boundaries
 243    let orig_context_start = orig_change_start.saturating_sub(CONTEXT_CHARS);
 244    let orig_context_end = (orig_change_end + CONTEXT_CHARS).min(original.len());
 245    let mod_context_start = mod_change_start.saturating_sub(CONTEXT_CHARS);
 246    let mod_context_end = (mod_change_end + CONTEXT_CHARS).min(modified.len());
 247
 248    let orig_region: Vec<char> = original[orig_context_start..orig_context_end].to_vec();
 249    let mod_region: Vec<char> = modified[mod_context_start..mod_context_end].to_vec();
 250
 251    (orig_region, mod_region)
 252}
 253
 254/// Count n-grams directly from a char slice (avoids String allocation for the full text)
 255fn count_ngrams_from_chars(chars: &[char], n: usize) -> Counts {
 256    let mut counts = Counts::default();
 257
 258    if chars.len() < n {
 259        return counts;
 260    }
 261
 262    for window in chars.windows(n) {
 263        let ngram: String = window.iter().collect();
 264        *counts.entry(ngram).or_insert(0) += 1;
 265    }
 266
 267    counts
 268}
 269
 270#[allow(dead_code)]
 271fn chr_f_ngram_counts(text: &str) -> Vec<Counts> {
 272    // Ignore whitespace. The original chrF implementation skips all
 273    // whitespace. We should consider compressing multiple consecutive
 274    // spaces into one -- this may reflect our task more closely.
 275    let text = match CHR_F_WHITESPACE {
 276        ChrfWhitespace::Unchanged => text.to_string(),
 277        ChrfWhitespace::Ignore => text
 278            .chars()
 279            .filter(|c| !c.is_whitespace())
 280            .collect::<String>(),
 281    };
 282
 283    (1..=CHR_F_CHAR_ORDER)
 284        .map(|order| count_ngrams(&text, order))
 285        .collect()
 286}
 287
 288fn compute_ngram_delta(after: &Counts, before: &Counts) -> CountsDelta {
 289    let mut delta = CountsDelta::default();
 290
 291    for (ngram, &before_count) in before {
 292        let after_count = *after.get(ngram).unwrap_or(&0);
 293        delta.insert(ngram.clone(), after_count as isize - before_count as isize);
 294    }
 295
 296    for (ngram, &after_count) in after {
 297        if !before.contains_key(ngram) {
 298            delta.insert(ngram.clone(), after_count as isize);
 299        }
 300    }
 301
 302    delta
 303}
 304
 305/// Convert negative counts to special deletion tokens.
 306/// For example, if expected delta is {"foo": -1} and actual delta is {"bar": -1},
 307/// we convert it to {"¬foo": +1} and {"¬bar": +1}. This way _not_ deleting "foo"
 308/// will result in a false negative, and mistakenly deleting "bar" will result in a false positive.
 309fn ngram_delta_to_counts(delta: &CountsDelta) -> Counts {
 310    let mut counts = Counts::default();
 311
 312    for (ngram, &delta) in delta {
 313        if delta > 0 {
 314            counts.insert(ngram.clone(), delta as usize);
 315        } else if delta < 0 {
 316            counts.insert(format!("¬{ngram}"), delta.unsigned_abs());
 317        }
 318    }
 319
 320    counts
 321}
 322
 323#[allow(dead_code)]
 324fn count_ngrams(text: &str, n: usize) -> Counts {
 325    let chars: Vec<char> = text.chars().collect();
 326    let mut counts = Counts::default();
 327
 328    for window in chars.windows(n) {
 329        let ngram: String = window.iter().collect();
 330        *counts.entry(ngram).or_insert(0) += 1;
 331    }
 332
 333    counts
 334}
 335
 336pub fn braces_disbalance(text: &str) -> usize {
 337    let mut disbalance = 0isize;
 338
 339    let a = text.chars().filter(|&c| c == '{').count() as isize;
 340    let b = text.chars().filter(|&c| c == '}').count() as isize;
 341    disbalance += (a - b).abs();
 342
 343    let a = text.chars().filter(|&c| c == '(').count() as isize;
 344    let b = text.chars().filter(|&c| c == ')').count() as isize;
 345    disbalance += (a - b).abs();
 346
 347    let a = text.chars().filter(|&c| c == '[').count() as isize;
 348    let b = text.chars().filter(|&c| c == ']').count() as isize;
 349    disbalance += (a - b).abs();
 350
 351    disbalance as usize
 352}
 353
 354/// Extracts changed lines from a unified diff string.
 355/// Returns a bag (multiset) of lines that were added (+) or removed (-).
 356/// The +/- prefix is included in the line to distinguish additions from deletions.
 357pub fn extract_changed_lines_from_diff(diff: &str) -> Counts {
 358    let mut counts = Counts::default();
 359
 360    for line in diff.lines() {
 361        // Skip file headers (--- and +++)
 362        if line.starts_with("---") || line.starts_with("+++") {
 363            continue;
 364        }
 365        // Skip hunk headers (@@)
 366        if line.starts_with("@@") {
 367            continue;
 368        }
 369        // Skip diff header lines (diff --git, index, etc.)
 370        if line.starts_with("diff ") || line.starts_with("index ") {
 371            continue;
 372        }
 373        // Include added and removed lines (with their prefix)
 374        if line.starts_with('+') || line.starts_with('-') {
 375            *counts.entry(line.to_string()).or_insert(0) += 1;
 376        }
 377    }
 378
 379    counts
 380}
 381
 382/// Computes exact lines match metrics between expected and actual patches.
 383/// Treats changed lines as a bag (multiset) - order is discarded but count matters.
 384/// Returns ClassificationMetrics with TP/FP/FN counts.
 385pub fn exact_lines_match(expected_patch: &str, actual_patch: &str) -> ClassificationMetrics {
 386    let expected_lines = extract_changed_lines_from_diff(expected_patch);
 387    let actual_lines = extract_changed_lines_from_diff(actual_patch);
 388    ClassificationMetrics::from_counts(&expected_lines, &actual_lines)
 389}
 390
 391/// Returns whether the patch contains any isolated whitespace-only changes.
 392///
 393/// A whitespace-only change is an added or deleted line whose content is empty or
 394/// contains only whitespace. It is "isolated" when it is not adjacent to any
 395/// substantive (non-whitespace) change within the same contiguous change group.
 396pub fn has_isolated_whitespace_changes(patch_str: &str, cursor: Option<&ActualCursor>) -> bool {
 397    let patch = Patch::parse_unified_diff(patch_str);
 398
 399    let cursor_new_file_line = cursor.as_ref().map(|c| (c.row + 1) as usize);
 400
 401    for hunk in &patch.hunks {
 402        let lines = &hunk.lines;
 403        let mut new_text_line = hunk.new_start as usize;
 404
 405        for (i, line) in lines.iter().enumerate() {
 406            let content = match line {
 407                PatchLine::Addition(s) => {
 408                    let addition_line = new_text_line;
 409                    new_text_line += 1;
 410                    if s.trim().is_empty() && cursor_new_file_line == Some(addition_line) {
 411                        continue;
 412                    }
 413                    s.as_str()
 414                }
 415                PatchLine::Deletion(s) => s.as_str(),
 416                PatchLine::Context(_) => {
 417                    new_text_line += 1;
 418                    continue;
 419                }
 420                _ => continue,
 421            };
 422
 423            if !content.trim().is_empty() {
 424                continue;
 425            }
 426
 427            if is_whitespace_change_isolated(lines, i) {
 428                return true;
 429            }
 430        }
 431    }
 432
 433    false
 434}
 435
 436fn is_whitespace_change_isolated(lines: &[PatchLine], index: usize) -> bool {
 437    // Look backward for a non-whitespace change before hitting a context line
 438    for line in lines[..index].iter().rev() {
 439        match line {
 440            PatchLine::Addition(s) | PatchLine::Deletion(s) => {
 441                if !s.trim().is_empty() {
 442                    return false;
 443                }
 444            }
 445            _ => break,
 446        }
 447    }
 448
 449    // Look forward for a non-whitespace change before hitting a context line
 450    for line in &lines[index + 1..] {
 451        match line {
 452            PatchLine::Addition(s) | PatchLine::Deletion(s) => {
 453                if !s.trim().is_empty() {
 454                    return false;
 455                }
 456            }
 457            _ => break,
 458        }
 459    }
 460
 461    true
 462}
 463
 464/// A simple proxy for whether the prediction respects editable region.
 465pub fn is_editable_region_correct(actual_patch: &str) -> bool {
 466    // A typical sign of a wrong editable region: a bunch of lines deletion
 467    // at the beginning or end of the patch.
 468    let patch = Patch::parse_unified_diff(actual_patch);
 469    if patch.hunks.is_empty() {
 470        return true;
 471    }
 472
 473    let hunk = &patch.hunks[0];
 474    let mut deletions_at_start = 0;
 475
 476    for line in hunk.lines.iter() {
 477        match line {
 478            PatchLine::Deletion(_) => deletions_at_start += 1,
 479            _ => break,
 480        }
 481    }
 482
 483    if deletions_at_start >= 3 {
 484        return false;
 485    }
 486
 487    true
 488}
 489
 490#[derive(Debug, Default, Clone)]
 491pub struct TokenChangeCounts {
 492    pub inserted_tokens: usize,
 493    pub deleted_tokens: usize,
 494}
 495
 496/// Counts the number of inserted and deleted tokens in a unified diff patch.
 497///
 498/// Tokens are words and whitespace sequences (as defined by `word_diff::tokenize`).
 499/// Within each hunk, the old (`-`) and new (`+`) lines are compared at the token level
 500/// using an LCS-based diff, so modified lines only count the actually changed tokens
 501/// rather than the entire line.
 502pub fn count_patch_token_changes(patch: &str) -> TokenChangeCounts {
 503    let mut counts = TokenChangeCounts::default();
 504    let mut old_lines: Vec<&str> = Vec::new();
 505    let mut new_lines: Vec<&str> = Vec::new();
 506
 507    let flush =
 508        |old_lines: &mut Vec<&str>, new_lines: &mut Vec<&str>, counts: &mut TokenChangeCounts| {
 509            if old_lines.is_empty() && new_lines.is_empty() {
 510                return;
 511            }
 512
 513            let old_text: String = old_lines
 514                .iter()
 515                .map(|line| if line.len() > 1 { &line[1..] } else { "" })
 516                .collect::<Vec<_>>()
 517                .join("\n");
 518
 519            let new_text: String = new_lines
 520                .iter()
 521                .map(|line| if line.len() > 1 { &line[1..] } else { "" })
 522                .collect::<Vec<_>>()
 523                .join("\n");
 524
 525            let old_tokens = tokenize(&old_text);
 526            let new_tokens = tokenize(&new_text);
 527            let ops = diff_tokens(&old_tokens, &new_tokens);
 528
 529            for op in ops {
 530                match op {
 531                    DiffOp::Equal(..) => {}
 532                    DiffOp::Delete(start, end) => {
 533                        counts.deleted_tokens += end - start;
 534                    }
 535                    DiffOp::Insert(start, end) => {
 536                        counts.inserted_tokens += end - start;
 537                    }
 538                    DiffOp::Replace {
 539                        old_start,
 540                        old_end,
 541                        new_start,
 542                        new_end,
 543                    } => {
 544                        counts.deleted_tokens += old_end - old_start;
 545                        counts.inserted_tokens += new_end - new_start;
 546                    }
 547                }
 548            }
 549
 550            old_lines.clear();
 551            new_lines.clear();
 552        };
 553
 554    for line in patch.lines() {
 555        if line.starts_with("---")
 556            || line.starts_with("+++")
 557            || line.starts_with("@@")
 558            || line.starts_with("diff ")
 559            || line.starts_with("index ")
 560        {
 561            flush(&mut old_lines, &mut new_lines, &mut counts);
 562        } else if line.starts_with('-') {
 563            old_lines.push(line);
 564        } else if line.starts_with('+') {
 565            new_lines.push(line);
 566        } else {
 567            flush(&mut old_lines, &mut new_lines, &mut counts);
 568        }
 569    }
 570
 571    flush(&mut old_lines, &mut new_lines, &mut counts);
 572    counts
 573}
 574
 575#[cfg(test)]
 576mod test_optimization {
 577    use super::*;
 578
 579    #[test]
 580    fn test_extract_changed_regions_simple() {
 581        let original: Vec<char> = "hello world".chars().collect();
 582        let modified: Vec<char> = "hello there".chars().collect();
 583
 584        let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
 585
 586        // "world" vs "there" - with 5 chars context, we get "ello world" vs "ello there"
 587        // (or less if not enough chars available)
 588        assert!(orig_region.len() < original.len());
 589        assert!(mod_region.len() < modified.len());
 590    }
 591
 592    #[test]
 593    fn test_extract_changed_regions_insertion() {
 594        let original: Vec<char> = "abcdef".chars().collect();
 595        let modified: Vec<char> = "abcXYZdef".chars().collect();
 596
 597        let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
 598
 599        // The insertion is between c and d, so we need context around that point
 600        assert!(orig_region.len() <= original.len());
 601        assert!(mod_region.iter().collect::<String>().contains("XYZ"));
 602    }
 603
 604    #[test]
 605    fn test_extract_changed_regions_identical() {
 606        let text: Vec<char> = "identical text".chars().collect();
 607
 608        let (orig_region, mod_region) = extract_changed_regions(&text, &text);
 609
 610        // When texts are identical, regions should be empty
 611        assert!(orig_region.is_empty());
 612        assert!(mod_region.is_empty());
 613    }
 614
 615    #[test]
 616    fn test_optimized_matches_original_score() {
 617        // Test that our optimized version produces the same results
 618        let test_cases = vec![
 619            ("hello world", "hello there", "hello world"),
 620            (
 621                "fn main() {}",
 622                "fn main() { println!(); }",
 623                "fn main() { print!(); }",
 624            ),
 625            ("abcdefghij", "abcXXXghij", "abcYYghij"),
 626            ("unchanged", "unchanged", "unchanged"),
 627            (
 628                "prefix middle suffix",
 629                "prefix CHANGED suffix",
 630                "prefix middle suffix",
 631            ),
 632        ];
 633
 634        for (original, expected, actual) in test_cases {
 635            let score = delta_chr_f(original, expected, actual);
 636            // Just verify it produces a reasonable score (0-100)
 637            assert!(
 638                score >= 0.0 && score <= 100.0,
 639                "Score {} out of range for ({}, {}, {})",
 640                score,
 641                original,
 642                expected,
 643                actual
 644            );
 645        }
 646    }
 647
 648    #[test]
 649    fn test_optimized_equals_reference() {
 650        // Comprehensive test that optimized version matches reference implementation exactly
 651        let test_cases = vec![
 652            // Basic cases
 653            ("hello world", "hello there", "hello world"),
 654            ("hello world", "hello there", "hello there"),
 655            ("unchanged", "unchanged", "unchanged"),
 656            // Code-like cases
 657            (
 658                "fn main() { println!(\"Hello\"); }",
 659                "fn main() { println!(\"Hello, World!\"); }",
 660                "fn main() { println!(\"Hello, World!\"); }",
 661            ),
 662            (
 663                "fn main() { println!(\"Hello\"); }",
 664                "fn main() { println!(\"Hello, World!\"); }",
 665                "fn main() { println!(\"Goodbye\"); }",
 666            ),
 667            // Insertion
 668            ("abcdef", "abcXYZdef", "abcdef"),
 669            ("abcdef", "abcXYZdef", "abcXYZdef"),
 670            ("abcdef", "abcXYZdef", "abcABCdef"),
 671            // Deletion
 672            ("abcXYZdef", "abcdef", "abcXYZdef"),
 673            ("abcXYZdef", "abcdef", "abcdef"),
 674            // Multiple changes (simulated by different expected/actual)
 675            ("one two three four", "one THREE four", "one two FOUR"),
 676            // Edge cases
 677            ("a", "b", "c"),
 678            ("", "abc", ""),
 679            ("abc", "", "abc"),
 680            // Longer text with small change
 681            (
 682                "This is a longer piece of text that contains many words and characters to process",
 683                "This is a longer piece of TEXT that contains many words and characters to process",
 684                "This is a longer piece of text that contains many words and characters to process",
 685            ),
 686            // Change at the beginning
 687            (
 688                "ORIGINAL start of text",
 689                "NEW start of text",
 690                "DIFFERENT start of text",
 691            ),
 692            // Change at the end
 693            (
 694                "text ending ORIGINAL",
 695                "text ending NEW",
 696                "text ending DIFFERENT",
 697            ),
 698            // Whitespace (should be ignored)
 699            ("hello   world", "hello   there", "hello   world"),
 700            ("a b c d", "a X c d", "a Y c d"),
 701        ];
 702
 703        for (original, expected, actual) in test_cases {
 704            let optimized_score = delta_chr_f(original, expected, actual);
 705            let reference_score = delta_chr_f_reference(original, expected, actual);
 706
 707            assert!(
 708                (optimized_score - reference_score).abs() < 1e-10,
 709                "Mismatch for ({:?}, {:?}, {:?}):\n  optimized: {}\n  reference: {}",
 710                original,
 711                expected,
 712                actual,
 713                optimized_score,
 714                reference_score
 715            );
 716        }
 717    }
 718}
 719
 720#[cfg(test)]
 721mod test {
 722    use super::*;
 723    use crate::example::ActualCursor;
 724    use indoc::indoc;
 725
 726    fn cursor_on_line(one_based_line: u32) -> ActualCursor {
 727        ActualCursor {
 728            path: String::new(),
 729            row: one_based_line - 1,
 730            column: 0,
 731            offset: 0,
 732            editable_region_offset: None,
 733        }
 734    }
 735
 736    #[test]
 737    fn test_delta_chr_f_perfect_match() {
 738        let original = "fn main() {    println!(\"Hello\");}";
 739        let expected = "fn main() {    println!(\"Hello, World!\");}";
 740
 741        let score = delta_chr_f(original, expected, expected);
 742        assert!((score - 100.0).abs() < 1e-2);
 743    }
 744
 745    #[test]
 746    fn test_delta_chr_f_wrong_edit() {
 747        // When the edit is wrong
 748        let original = "one two three";
 749        let expected = "one three"; // deleted "two "
 750        let actual = "one two four"; // deleted "three", added "four"
 751
 752        // Then the score should be low
 753        let score = delta_chr_f(original, expected, actual);
 754        assert!(score > 20.0 && score < 40.0);
 755    }
 756
 757    #[test]
 758    fn test_delta_chr_f_partial_match() {
 759        let original = "let x = 42;";
 760        let expected = "let x = 100;";
 761        let actual = "let x = 99;";
 762
 763        // We got the edit location right, but the replacement text is wrong.
 764        // Deleted ngrams will match, bringing the score somewhere in the middle.
 765        let score = delta_chr_f(original, expected, actual);
 766        assert!(score > 40.0 && score < 60.0);
 767    }
 768
 769    #[test]
 770    fn test_delta_chr_f_missed_edit() {
 771        // When predictions makes no changes
 772        let original = "prefix old suffix";
 773        let expected = "prefix new suffix";
 774        let actual = "prefix old suffix"; // no change
 775
 776        // Then the score should be low (all expected changes are false negatives)
 777        let score = delta_chr_f(original, expected, actual);
 778        assert!(score < 20.0);
 779    }
 780
 781    #[test]
 782    fn test_delta_chr_f_extra_edit() {
 783        // When adding unexpected content
 784        let original = "helloworld";
 785        let expected = "helloworld"; // no change expected
 786        let actual = "helloextraworld"; // added "extra"
 787
 788        // Then the score should be low (all actual changes are false positives)
 789        let score = delta_chr_f(original, expected, actual);
 790        assert!(score < 20.0);
 791    }
 792
 793    #[test]
 794    fn test_delta_chr_f_no_changes() {
 795        let text = "unchanged text";
 796        let score = delta_chr_f(text, text, text);
 797        assert!((score - 100.0).abs() < 1e-2);
 798    }
 799
 800    #[test]
 801    fn test_braces_disbalance() {
 802        let text = "let x = { 1 + 2 };";
 803        assert_eq!(braces_disbalance(text), 0);
 804
 805        let text = "let x = { 1 + 2";
 806        assert_eq!(braces_disbalance(text), 1);
 807
 808        let text = "let x = { 1 + 2 )";
 809        assert_eq!(braces_disbalance(text), 2);
 810    }
 811
 812    #[test]
 813    fn test_extract_changed_lines_from_diff() {
 814        let diff = r#"--- a/file.rs
 815+++ b/file.rs
 816@@ -1,3 +1,3 @@
 817 fn main() {
 818-    println!("hello");
 819+    println!("world");
 820 }"#;
 821
 822        let counts = extract_changed_lines_from_diff(diff);
 823        assert_eq!(counts.get("-    println!(\"hello\");"), Some(&1));
 824        assert_eq!(counts.get("+    println!(\"world\");"), Some(&1));
 825        assert_eq!(counts.len(), 2);
 826    }
 827
 828    #[test]
 829    fn test_extract_changed_lines_skips_headers() {
 830        let diff = r#"diff --git a/file.rs b/file.rs
 831index abc123..def456 100644
 832--- a/file.rs
 833+++ b/file.rs
 834@@ -1,2 +1,2 @@
 835-old line
 836+new line"#;
 837
 838        let counts = extract_changed_lines_from_diff(diff);
 839        assert_eq!(counts.get("-old line"), Some(&1));
 840        assert_eq!(counts.get("+new line"), Some(&1));
 841        assert_eq!(counts.len(), 2);
 842    }
 843
 844    #[test]
 845    fn test_exact_lines_match_perfect() {
 846        let expected = r#"--- a/file.rs
 847+++ b/file.rs
 848@@ -1,3 +1,3 @@
 849-old line 1
 850-old line 2
 851+new line 1
 852+new line 2"#;
 853
 854        let actual = r#"--- a/file.rs
 855+++ b/file.rs
 856@@ -1,3 +1,3 @@
 857-old line 1
 858-old line 2
 859+new line 1
 860+new line 2"#;
 861
 862        let metrics = exact_lines_match(expected, actual);
 863        assert_eq!(metrics.true_positives, 4);
 864        assert_eq!(metrics.false_positives, 0);
 865        assert_eq!(metrics.false_negatives, 0);
 866        assert!((metrics.precision() - 1.0).abs() < 1e-6);
 867        assert!((metrics.recall() - 1.0).abs() < 1e-6);
 868        assert!((metrics.f1() - 1.0).abs() < 1e-6);
 869    }
 870
 871    #[test]
 872    fn test_exact_lines_match_partial() {
 873        let expected = r#"-old line 1
 874-old line 2
 875+new line 1
 876+new line 2"#;
 877
 878        let actual = r#"-old line 1
 879+new line 1
 880+extra line"#;
 881
 882        let metrics = exact_lines_match(expected, actual);
 883        // TP: "-old line 1" and "+new line 1" (2)
 884        // FP: "+extra line" (1)
 885        // FN: "-old line 2" and "+new line 2" (2)
 886        assert_eq!(metrics.true_positives, 2);
 887        assert_eq!(metrics.false_positives, 1);
 888        assert_eq!(metrics.false_negatives, 2);
 889    }
 890
 891    #[test]
 892    fn test_exact_lines_match_no_overlap() {
 893        let expected = r#"-line a
 894+line b"#;
 895
 896        let actual = r#"-line x
 897+line y"#;
 898
 899        let metrics = exact_lines_match(expected, actual);
 900        assert_eq!(metrics.true_positives, 0);
 901        assert_eq!(metrics.false_positives, 2);
 902        assert_eq!(metrics.false_negatives, 2);
 903        assert!((metrics.precision()).abs() < 1e-6);
 904        assert!((metrics.recall()).abs() < 1e-6);
 905    }
 906
 907    #[test]
 908    fn test_exact_lines_match_duplicate_lines() {
 909        let expected = r#"+line a
 910+line a
 911+line a"#;
 912
 913        let actual = r#"+line a
 914+line a"#;
 915
 916        let metrics = exact_lines_match(expected, actual);
 917        // Expected has 3 "+line a", actual has 2
 918        // TP: 2, FN: 1, FP: 0
 919        assert_eq!(metrics.true_positives, 2);
 920        assert_eq!(metrics.false_positives, 0);
 921        assert_eq!(metrics.false_negatives, 1);
 922    }
 923
 924    #[test]
 925    fn test_exact_lines_match_empty_patches() {
 926        let metrics = exact_lines_match("", "");
 927        assert_eq!(metrics.true_positives, 0);
 928        assert_eq!(metrics.false_positives, 0);
 929        assert_eq!(metrics.false_negatives, 0);
 930    }
 931
 932    #[test]
 933    fn test_is_editable_region_correct() {
 934        let patch = indoc! {"
 935            @@ -1,1 +1,1 @@
 936            -context
 937            -removed
 938            -from the beginning of the file
 939            import sys
 940            +sys.exit(0)
 941
 942            "};
 943        assert!(!is_editable_region_correct(patch));
 944
 945        let patch = indoc! {"
 946            @@ -1,1 +1,1 @@
 947            "};
 948        assert!(is_editable_region_correct(patch));
 949    }
 950
 951    #[test]
 952    fn test_isolated_whitespace_purely_whitespace_patch() {
 953        let patch = indoc! {"
 954            @@ -1,3 +1,4 @@
 955             fn main() {
 956            +
 957                 println!(\"hello\");
 958             }
 959        "};
 960        assert!(has_isolated_whitespace_changes(patch, None));
 961    }
 962
 963    #[test]
 964    fn test_isolated_whitespace_adjacent_to_real_change() {
 965        let patch = indoc! {"
 966            @@ -1,3 +1,4 @@
 967             fn main() {
 968            +
 969            +    let x = 1;
 970                 println!(\"hello\");
 971             }
 972        "};
 973        assert!(!has_isolated_whitespace_changes(patch, None));
 974    }
 975
 976    #[test]
 977    fn test_isolated_whitespace_no_whitespace_changes() {
 978        let patch = indoc! {"
 979            @@ -1,3 +1,3 @@
 980             fn main() {
 981            -    println!(\"hello\");
 982            +    println!(\"world\");
 983             }
 984        "};
 985        assert!(!has_isolated_whitespace_changes(patch, None));
 986    }
 987
 988    #[test]
 989    fn test_isolated_whitespace_deletion() {
 990        let patch = indoc! {"
 991            @@ -1,4 +1,3 @@
 992             fn main() {
 993            -
 994                 println!(\"hello\");
 995             }
 996        "};
 997        assert!(has_isolated_whitespace_changes(patch, None));
 998    }
 999
1000    #[test]
1001    fn test_isolated_whitespace_mixed_groups() {
1002        let patch = indoc! {"
1003            @@ -1,7 +1,8 @@
1004             fn main() {
1005            +
1006                 let x = 1;
1007            -    let y = 2;
1008            +    let y = 3;
1009
1010            +
1011                 println!(\"hello\");
1012             }
1013        "};
1014        assert!(has_isolated_whitespace_changes(patch, None));
1015    }
1016
1017    #[test]
1018    fn test_isolated_whitespace_empty_patch() {
1019        let patch = "";
1020        assert!(!has_isolated_whitespace_changes(patch, None));
1021    }
1022
1023    #[test]
1024    fn test_isolated_whitespace_skipped_on_cursor_line() {
1025        // The addition of a blank line at new-file line 2 should be skipped
1026        // because the cursor is on that line.
1027        let patch = indoc! {"
1028            @@ -1,3 +1,4 @@
1029             fn main() {
1030            +
1031                 println!(\"hello\");
1032             }
1033        "};
1034        // New-file line 2 is the added blank line
1035        let cursor = cursor_on_line(2);
1036        assert!(!has_isolated_whitespace_changes(patch, Some(&cursor)));
1037    }
1038
1039    #[test]
1040    fn test_isolated_whitespace_not_skipped_when_cursor_on_different_line() {
1041        // The blank line is at new-file line 2, but the cursor is on line 1.
1042        let patch = indoc! {"
1043            @@ -1,3 +1,4 @@
1044             fn main() {
1045            +
1046                 println!(\"hello\");
1047             }
1048        "};
1049        let cursor = cursor_on_line(1);
1050        assert!(has_isolated_whitespace_changes(patch, Some(&cursor)));
1051    }
1052
1053    #[test]
1054    fn test_isolated_whitespace_deletion_not_skipped_by_cursor() {
1055        // Deletions don't have a new-file line, so cursor can't suppress them.
1056        let patch = indoc! {"
1057            @@ -1,4 +1,3 @@
1058             fn main() {
1059            -
1060                 println!(\"hello\");
1061             }
1062        "};
1063        let cursor = cursor_on_line(2);
1064        assert!(has_isolated_whitespace_changes(patch, Some(&cursor)));
1065    }
1066
1067    #[test]
1068    fn test_count_patch_token_changes_real_world_rename() {
1069        // Real-world patch that was reported as returning 0 tokens
1070        let patch = "--- a/sip_call\\README.md\n+++ b/sip_call\\README.md\n@@ -1,1 +1,1 @@\n-# \n+# SIP Call\n";
1071        let counts = count_patch_token_changes(patch);
1072        // "# " vs "# SIP Call" — the "SIP" and "Call" tokens (and a whitespace token) are inserted
1073        assert!(
1074            counts.inserted_tokens > 0,
1075            "expected inserted tokens > 0, got {}",
1076            counts.inserted_tokens
1077        );
1078        assert_eq!(counts.deleted_tokens, 0);
1079    }
1080
1081    #[test]
1082    fn test_count_patch_token_changes_real_world_expansion() {
1083        // Real-world patch: single token expanded to multiple lines
1084        let patch = "--- a/task1/src/app/app.html\n+++ b/task1/src/app/app.html\n@@ -1,7 +1,9 @@\n <style>\n-  m\n+  main {\n+    \n+  }\n </style>\n \n <main>\n   \n </main>\n";
1085        let counts = count_patch_token_changes(patch);
1086        assert!(
1087            counts.inserted_tokens > 0,
1088            "expected inserted tokens > 0, got {}",
1089            counts.inserted_tokens
1090        );
1091        assert!(
1092            counts.deleted_tokens > 0,
1093            "expected deleted tokens > 0, got {}",
1094            counts.deleted_tokens
1095        );
1096    }
1097
1098    #[test]
1099    fn test_count_patch_token_changes_simple_replacement() {
1100        let patch = indoc! {"
1101            @@ -1,3 +1,3 @@
1102             fn main() {
1103            -    println!(\"hello\");
1104            +    println!(\"world\");
1105             }
1106        "};
1107        let counts = count_patch_token_changes(patch);
1108        assert_eq!(counts.deleted_tokens, 1, "deleted: \"hello\"");
1109        assert_eq!(counts.inserted_tokens, 1, "inserted: \"world\"");
1110    }
1111
1112    #[test]
1113    fn test_count_patch_token_changes_insertion_only() {
1114        let patch = indoc! {"
1115            @@ -1,2 +1,3 @@
1116             fn main() {
1117            +    println!(\"hello\");
1118             }
1119        "};
1120        let counts = count_patch_token_changes(patch);
1121        assert_eq!(counts.deleted_tokens, 0);
1122        assert!(counts.inserted_tokens > 0);
1123    }
1124
1125    #[test]
1126    fn test_count_patch_token_changes_deletion_only() {
1127        let patch = indoc! {"
1128            @@ -1,3 +1,2 @@
1129             fn main() {
1130            -    println!(\"hello\");
1131             }
1132        "};
1133        let counts = count_patch_token_changes(patch);
1134        assert!(counts.deleted_tokens > 0);
1135        assert_eq!(counts.inserted_tokens, 0);
1136    }
1137
1138    #[test]
1139    fn test_count_patch_token_changes_empty_patch() {
1140        let patch = "";
1141        let counts = count_patch_token_changes(patch);
1142        assert_eq!(counts.deleted_tokens, 0);
1143        assert_eq!(counts.inserted_tokens, 0);
1144    }
1145
1146    #[test]
1147    fn test_count_patch_token_changes_multiple_hunks() {
1148        let patch = indoc! {"
1149            @@ -1,3 +1,3 @@
1150             fn main() {
1151            -    let x = 1;
1152            +    let x = 2;
1153             }
1154            @@ -10,3 +10,3 @@
1155             fn other() {
1156            -    let y = 3;
1157            +    let y = 4;
1158             }
1159        "};
1160        let counts = count_patch_token_changes(patch);
1161        assert_eq!(counts.deleted_tokens, 2, "deleted: \"1\" and \"3\"");
1162        assert_eq!(counts.inserted_tokens, 2, "inserted: \"2\" and \"4\"");
1163    }
1164
1165    #[test]
1166    fn test_count_patch_token_changes_multiword_change() {
1167        let patch = indoc! {"
1168            @@ -1,1 +1,1 @@
1169            -hello world foo
1170            +hello bar baz
1171        "};
1172        let counts = count_patch_token_changes(patch);
1173        // "world" and "foo" deleted, "bar" and "baz" inserted
1174        // (whitespace tokens between them may also count)
1175        assert!(counts.deleted_tokens >= 2);
1176        assert!(counts.inserted_tokens >= 2);
1177    }
1178}