Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:59:11.833Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

patch_metrics.rs

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