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