Skip to repository content1361 lines · 44.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:59:11.833Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
patch_metrics.rs
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