Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:04:04.202Z 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

reversal.rs

2182 lines · 72.5 KB · rust
1use std::iter;
2use std::ops::Range;
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::tokenize::tokenize;
7use imara_diff::{Algorithm, Diff, InternedInput, Token, sources::lines};
8use zeta_prompt::udiff::apply_diff_to_string;
9
10fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range<usize>, Arc<str>)> {
11    let empty: Arc<str> = Arc::default();
12    let mut edits = Vec::new();
13    let mut hunk_input = InternedInput::default();
14    let input = InternedInput::new(lines(old_text), lines(new_text));
15
16    diff_internal(&input, &mut |old_byte_range,
17                                new_byte_range,
18                                old_rows,
19                                new_rows| {
20        if should_perform_token_diff_within_hunk(
21            &old_byte_range,
22            &new_byte_range,
23            &old_rows,
24            &new_rows,
25        ) {
26            let old_offset = old_byte_range.start;
27            let new_offset = new_byte_range.start;
28            hunk_input.clear();
29            hunk_input.update_before(tokenize(&old_text[old_byte_range]).into_iter());
30            hunk_input.update_after(tokenize(&new_text[new_byte_range]).into_iter());
31            diff_internal(&hunk_input, &mut |old_byte_range, new_byte_range, _, _| {
32                let old_byte_range =
33                    old_offset + old_byte_range.start..old_offset + old_byte_range.end;
34                let new_byte_range =
35                    new_offset + new_byte_range.start..new_offset + new_byte_range.end;
36                let replacement_text = if new_byte_range.is_empty() {
37                    empty.clone()
38                } else {
39                    new_text[new_byte_range].into()
40                };
41                edits.push((old_byte_range, replacement_text));
42            });
43        } else {
44            let replacement_text = if new_byte_range.is_empty() {
45                empty.clone()
46            } else {
47                new_text[new_byte_range].into()
48            };
49            edits.push((old_byte_range, replacement_text));
50        }
51    });
52
53    edits
54}
55
56fn char_diff<'a>(old_text: &'a str, new_text: &'a str) -> Vec<(Range<usize>, &'a str)> {
57    let mut input: InternedInput<&str> = InternedInput::default();
58    input.update_before(tokenize_chars(old_text));
59    input.update_after(tokenize_chars(new_text));
60    let mut edits = Vec::new();
61
62    diff_internal(&input, &mut |old_byte_range, new_byte_range, _, _| {
63        let replacement = if new_byte_range.is_empty() {
64            ""
65        } else {
66            &new_text[new_byte_range]
67        };
68        edits.push((old_byte_range, replacement));
69    });
70
71    edits
72}
73
74fn should_perform_token_diff_within_hunk(
75    old_byte_range: &Range<usize>,
76    new_byte_range: &Range<usize>,
77    old_row_range: &Range<u32>,
78    new_row_range: &Range<u32>,
79) -> bool {
80    const MAX_TOKEN_DIFF_LEN: usize = 512;
81    const MAX_TOKEN_DIFF_LINE_COUNT: usize = 8;
82
83    !old_byte_range.is_empty()
84        && !new_byte_range.is_empty()
85        && old_byte_range.len() <= MAX_TOKEN_DIFF_LEN
86        && new_byte_range.len() <= MAX_TOKEN_DIFF_LEN
87        && old_row_range.len() <= MAX_TOKEN_DIFF_LINE_COUNT
88        && new_row_range.len() <= MAX_TOKEN_DIFF_LINE_COUNT
89}
90
91fn diff_internal(
92    input: &InternedInput<&str>,
93    on_change: &mut dyn FnMut(Range<usize>, Range<usize>, Range<u32>, Range<u32>),
94) {
95    let mut old_offset = 0;
96    let mut new_offset = 0;
97    let mut old_token_ix = 0;
98    let mut new_token_ix = 0;
99
100    let diff = Diff::compute(Algorithm::Histogram, input);
101    for hunk in diff.hunks() {
102        let old_tokens = hunk.before;
103        let new_tokens = hunk.after;
104        old_offset += token_len(
105            input,
106            &input.before[old_token_ix as usize..old_tokens.start as usize],
107        );
108        new_offset += token_len(
109            input,
110            &input.after[new_token_ix as usize..new_tokens.start as usize],
111        );
112        let old_len = token_len(
113            input,
114            &input.before[old_tokens.start as usize..old_tokens.end as usize],
115        );
116        let new_len = token_len(
117            input,
118            &input.after[new_tokens.start as usize..new_tokens.end as usize],
119        );
120        let old_byte_range = old_offset..old_offset + old_len;
121        let new_byte_range = new_offset..new_offset + new_len;
122        old_token_ix = old_tokens.end;
123        new_token_ix = new_tokens.end;
124        old_offset = old_byte_range.end;
125        new_offset = new_byte_range.end;
126        on_change(old_byte_range, new_byte_range, old_tokens, new_tokens);
127    }
128}
129
130fn tokenize_chars(text: &str) -> impl Iterator<Item = &str> {
131    let mut chars = text.char_indices();
132    iter::from_fn(move || {
133        let (start, character) = chars.next()?;
134        Some(&text[start..start + character.len_utf8()])
135    })
136}
137
138fn token_len(input: &InternedInput<&str>, tokens: &[Token]) -> usize {
139    tokens
140        .iter()
141        .map(|token| input.interner[*token].len())
142        .sum()
143}
144
145fn apply_diff_to_string_lenient(diff_str: &str, text: &str) -> String {
146    let hunks = parse_diff_hunks(diff_str);
147    let mut result = text.to_string();
148
149    for hunk in hunks {
150        let hunk_diff = format!("--- a/file\n+++ b/file\n{}", format_hunk(&hunk));
151        if let Ok(updated) = apply_diff_to_string(&hunk_diff, &result) {
152            result = updated;
153        }
154    }
155
156    result
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160struct ParsedHunk {
161    old_start: u32,
162    old_count: u32,
163    new_start: u32,
164    new_count: u32,
165    lines: Vec<HunkLine>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169enum HunkLine {
170    Context(String),
171    Addition(String),
172    Deletion(String),
173}
174
175fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32, u32)> {
176    let line = line.strip_prefix("@@ -")?;
177    let (old_part, rest) = line.split_once(' ')?;
178    let rest = rest.strip_prefix('+')?;
179    let (new_part, _) = rest.split_once(" @@")?;
180
181    let (old_start, old_count) = if let Some((start, count)) = old_part.split_once(',') {
182        (start.parse().ok()?, count.parse().ok()?)
183    } else {
184        (old_part.parse().ok()?, 1)
185    };
186
187    let (new_start, new_count) = if let Some((start, count)) = new_part.split_once(',') {
188        (start.parse().ok()?, count.parse().ok()?)
189    } else {
190        (new_part.parse().ok()?, 1)
191    };
192
193    Some((old_start, old_count, new_start, new_count))
194}
195
196fn parse_diff_hunks(diff: &str) -> Vec<ParsedHunk> {
197    let mut hunks = Vec::new();
198    let mut current_hunk: Option<ParsedHunk> = None;
199
200    for line in diff.lines() {
201        if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
202            if let Some(hunk) = current_hunk.take() {
203                hunks.push(hunk);
204            }
205            current_hunk = Some(ParsedHunk {
206                old_start,
207                old_count,
208                new_start,
209                new_count,
210                lines: Vec::new(),
211            });
212        } else if let Some(ref mut hunk) = current_hunk {
213            if let Some(stripped) = line.strip_prefix('+') {
214                hunk.lines.push(HunkLine::Addition(stripped.to_string()));
215            } else if let Some(stripped) = line.strip_prefix('-') {
216                hunk.lines.push(HunkLine::Deletion(stripped.to_string()));
217            } else if let Some(stripped) = line.strip_prefix(' ') {
218                hunk.lines.push(HunkLine::Context(stripped.to_string()));
219            } else if line.is_empty() {
220                hunk.lines.push(HunkLine::Context(String::new()));
221            }
222        }
223    }
224
225    if let Some(hunk) = current_hunk {
226        hunks.push(hunk);
227    }
228
229    hunks
230}
231
232fn format_hunk(hunk: &ParsedHunk) -> String {
233    let mut result = format!(
234        "@@ -{},{} +{},{} @@\n",
235        hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
236    );
237    for line in &hunk.lines {
238        match line {
239            HunkLine::Context(text) => {
240                result.push(' ');
241                result.push_str(text);
242                result.push('\n');
243            }
244            HunkLine::Addition(text) => {
245                result.push('+');
246                result.push_str(text);
247                result.push('\n');
248            }
249            HunkLine::Deletion(text) => {
250                result.push('-');
251                result.push_str(text);
252                result.push('\n');
253            }
254        }
255    }
256    result
257}
258
259fn filter_diff_hunks_by_excerpt(
260    diff: &str,
261    excerpt_start_row: u32,
262    excerpt_row_count: u32,
263) -> (String, i32) {
264    let hunks = parse_diff_hunks(diff);
265    let excerpt_start_0based = excerpt_start_row;
266    let excerpt_end_0based = excerpt_start_row + excerpt_row_count;
267
268    let mut filtered_hunks = Vec::new();
269    let mut cumulative_line_offset: i32 = 0;
270
271    for hunk in hunks {
272        let hunk_start_0based = hunk.new_start.saturating_sub(1);
273        let hunk_end_0based = hunk_start_0based + hunk.new_count;
274
275        let additions: i32 = hunk
276            .lines
277            .iter()
278            .filter(|l| matches!(l, HunkLine::Addition(_)))
279            .count() as i32;
280        let deletions: i32 = hunk
281            .lines
282            .iter()
283            .filter(|l| matches!(l, HunkLine::Deletion(_)))
284            .count() as i32;
285        let hunk_line_delta = additions - deletions;
286
287        if hunk_end_0based <= excerpt_start_0based {
288            cumulative_line_offset += hunk_line_delta;
289            continue;
290        }
291
292        if hunk_start_0based >= excerpt_end_0based {
293            continue;
294        }
295
296        let mut filtered_lines = Vec::new();
297        let mut current_row_0based = hunk_start_0based;
298        let mut filtered_old_count = 0u32;
299        let mut filtered_new_count = 0u32;
300        let mut first_included_row: Option<u32> = None;
301
302        for line in &hunk.lines {
303            match line {
304                HunkLine::Context(text) => {
305                    if current_row_0based >= excerpt_start_0based
306                        && current_row_0based < excerpt_end_0based
307                    {
308                        if first_included_row.is_none() {
309                            first_included_row = Some(current_row_0based);
310                        }
311                        filtered_lines.push(HunkLine::Context(text.clone()));
312                        filtered_old_count += 1;
313                        filtered_new_count += 1;
314                    }
315                    current_row_0based += 1;
316                }
317                HunkLine::Addition(text) => {
318                    if current_row_0based >= excerpt_start_0based
319                        && current_row_0based < excerpt_end_0based
320                    {
321                        if first_included_row.is_none() {
322                            first_included_row = Some(current_row_0based);
323                        }
324                        filtered_lines.push(HunkLine::Addition(text.clone()));
325                        filtered_new_count += 1;
326                    }
327                    current_row_0based += 1;
328                }
329                HunkLine::Deletion(text) => {
330                    if current_row_0based >= excerpt_start_0based
331                        && current_row_0based < excerpt_end_0based
332                    {
333                        if first_included_row.is_none() {
334                            first_included_row = Some(current_row_0based);
335                        }
336                        filtered_lines.push(HunkLine::Deletion(text.clone()));
337                        filtered_old_count += 1;
338                    }
339                }
340            }
341        }
342
343        if !filtered_lines.is_empty() {
344            let first_row = first_included_row.unwrap_or(excerpt_start_0based);
345            let new_start_1based = (first_row - excerpt_start_0based) + 1;
346
347            filtered_hunks.push(ParsedHunk {
348                old_start: new_start_1based,
349                old_count: filtered_old_count,
350                new_start: new_start_1based,
351                new_count: filtered_new_count,
352                lines: filtered_lines,
353            });
354        }
355
356        cumulative_line_offset += hunk_line_delta;
357    }
358
359    let mut result = String::new();
360    for hunk in &filtered_hunks {
361        result.push_str(&format_hunk(hunk));
362    }
363
364    (result, cumulative_line_offset)
365}
366
367fn compute_excerpt_aware_reversal_overlap(
368    edit_history_diffs: &[&str],
369    excerpt_content: &str,
370    excerpt_start_row: u32,
371    predicted_content: &str,
372) -> ReversalOverlap {
373    let mut current_content = excerpt_content.to_string();
374    let mut current_excerpt_start_row = excerpt_start_row;
375
376    for diff in edit_history_diffs.iter().rev() {
377        if diff.is_empty() {
378            continue;
379        }
380
381        let current_row_count = current_content.lines().count() as u32;
382        let (filtered_diff, _line_offset) =
383            filter_diff_hunks_by_excerpt(diff, current_excerpt_start_row, current_row_count.max(1));
384
385        if filtered_diff.is_empty() {
386            let hunks = parse_diff_hunks(diff);
387            for hunk in hunks {
388                let hunk_end = hunk.new_start.saturating_sub(1) + hunk.new_count;
389                if hunk_end <= current_excerpt_start_row {
390                    let additions: u32 = hunk
391                        .lines
392                        .iter()
393                        .filter(|l| matches!(l, HunkLine::Addition(_)))
394                        .count() as u32;
395                    let deletions: u32 = hunk
396                        .lines
397                        .iter()
398                        .filter(|l| matches!(l, HunkLine::Deletion(_)))
399                        .count() as u32;
400                    if additions >= deletions {
401                        current_excerpt_start_row =
402                            current_excerpt_start_row.saturating_sub(additions - deletions);
403                    } else {
404                        current_excerpt_start_row += deletions - additions;
405                    }
406                }
407            }
408            continue;
409        }
410
411        let reversed = reverse_diff(&format!("--- a/file\n+++ b/file\n{}", filtered_diff));
412        match apply_diff_to_string(&reversed, &current_content) {
413            Ok(updated) => {
414                current_content = updated;
415            }
416            Err(_) => {
417                continue;
418            }
419        }
420
421        let hunks = parse_diff_hunks(diff);
422        for hunk in hunks {
423            let hunk_end = hunk.new_start.saturating_sub(1) + hunk.new_count;
424            if hunk_end <= current_excerpt_start_row {
425                let additions: u32 = hunk
426                    .lines
427                    .iter()
428                    .filter(|l| matches!(l, HunkLine::Addition(_)))
429                    .count() as u32;
430                let deletions: u32 = hunk
431                    .lines
432                    .iter()
433                    .filter(|l| matches!(l, HunkLine::Deletion(_)))
434                    .count() as u32;
435                if additions >= deletions {
436                    current_excerpt_start_row =
437                        current_excerpt_start_row.saturating_sub(additions - deletions);
438                } else {
439                    current_excerpt_start_row += deletions - additions;
440                }
441            }
442        }
443    }
444
445    compute_reversal_overlap(&current_content, excerpt_content, predicted_content)
446}
447
448fn reverse_diff(diff: &str) -> String {
449    let mut result: String = diff
450        .lines()
451        .map(|line| {
452            if line.starts_with("--- ") {
453                line.replacen("--- ", "+++ ", 1)
454            } else if line.starts_with("+++ ") {
455                line.replacen("+++ ", "--- ", 1)
456            } else if line.starts_with('+') && !line.starts_with("+++") {
457                format!("-{}", &line[1..])
458            } else if line.starts_with('-') && !line.starts_with("---") {
459                format!("+{}", &line[1..])
460            } else {
461                line.to_string()
462            }
463        })
464        .collect::<Vec<_>>()
465        .join("\n");
466    if diff.ends_with('\n') {
467        result.push('\n');
468    }
469    result
470}
471
472#[derive(Debug, Clone, PartialEq, Eq)]
473struct GranularEdit {
474    range: Range<usize>,
475    old_text: String,
476    new_text: String,
477}
478
479fn compute_granular_edits(old_text: &str, new_text: &str) -> Vec<GranularEdit> {
480    text_diff(old_text, new_text)
481        .into_iter()
482        .map(|(range, new_text)| GranularEdit {
483            old_text: old_text[range.clone()].to_string(),
484            range,
485            new_text: new_text.to_string(),
486        })
487        .collect()
488}
489
490#[derive(Debug, Clone)]
491struct HistoryAdditionRange {
492    range_in_current: Range<usize>,
493}
494
495#[derive(Debug, Clone)]
496struct HistoryDeletionRange {
497    deleted_text: String,
498    position_in_current: usize,
499}
500
501fn compute_history_addition_ranges(history_edits: &[GranularEdit]) -> Vec<HistoryAdditionRange> {
502    let mut result = Vec::new();
503    let mut offset_delta: isize = 0;
504
505    for edit in history_edits {
506        if !edit.new_text.is_empty() {
507            let new_start = (edit.range.start as isize + offset_delta) as usize;
508            let new_end = new_start + edit.new_text.len();
509            result.push(HistoryAdditionRange {
510                range_in_current: new_start..new_end,
511            });
512        }
513
514        offset_delta += edit.new_text.len() as isize - edit.old_text.len() as isize;
515    }
516
517    result
518}
519
520fn compute_history_deletion_ranges(history_edits: &[GranularEdit]) -> Vec<HistoryDeletionRange> {
521    let mut result = Vec::new();
522    let mut offset_delta: isize = 0;
523
524    for edit in history_edits {
525        if !edit.old_text.is_empty() {
526            let position_in_current = (edit.range.start as isize + offset_delta) as usize;
527            result.push(HistoryDeletionRange {
528                deleted_text: edit.old_text.clone(),
529                position_in_current,
530            });
531        }
532
533        offset_delta += edit.new_text.len() as isize - edit.old_text.len() as isize;
534    }
535
536    result
537}
538
539#[derive(Debug, Clone, Default, PartialEq, Eq)]
540struct ReversalOverlap {
541    chars_reversing_user_edits: usize,
542    total_chars_in_prediction: usize,
543}
544
545impl ReversalOverlap {
546    fn ratio(&self) -> f32 {
547        if self.total_chars_in_prediction == 0 {
548            0.0
549        } else {
550            self.chars_reversing_user_edits as f32 / self.total_chars_in_prediction as f32
551        }
552    }
553}
554
555/// Normalize edits where `old_text` appears as a subsequence within `new_text` (extension),
556/// or where `new_text` appears as a subsequence within `old_text` (reduction).
557///
558/// For extensions: when the user's text is preserved (in order) within the prediction,
559/// we only count the newly inserted characters, not the preserved ones.
560/// E.g., "epr" → "eprintln!()" becomes 8 inserted chars ("intln!()")
561/// E.g., "test_my_function" → "a_test_for_my_special_function_plz" becomes 18 inserted chars
562///
563/// For reductions: when the prediction's text is preserved (in order) within the original,
564/// we only count the deleted characters, not the preserved ones.
565/// E.g., "ifrom" → "from" becomes 1 deleted char ("i")
566fn normalize_extension_edits(edits: Vec<GranularEdit>) -> Vec<GranularEdit> {
567    edits
568        .into_iter()
569        .flat_map(|edit| {
570            if edit.old_text.is_empty() || edit.new_text.is_empty() {
571                return vec![edit];
572            }
573
574            // Use character-wise diff to find exact byte ranges of changes
575            let char_edits = char_diff(&edit.old_text, &edit.new_text);
576
577            let all_deletions = !char_edits.is_empty()
578                && char_edits
579                    .iter()
580                    .all(|(range, replacement)| !range.is_empty() && replacement.is_empty());
581            let all_insertions = !char_edits.is_empty()
582                && char_edits
583                    .iter()
584                    .all(|(range, replacement)| range.is_empty() && !replacement.is_empty());
585            if all_deletions || all_insertions {
586                return char_edits
587                    .into_iter()
588                    .map(|(range, replacement)| GranularEdit {
589                        range: edit.range.start + range.start..edit.range.start + range.end,
590                        old_text: edit.old_text[range].to_string(),
591                        new_text: replacement.to_string(),
592                    })
593                    .collect();
594            }
595
596            // Otherwise, keep the original edit (mixed changes)
597            vec![edit]
598        })
599        .collect()
600}
601
602fn compute_reversal_overlap(
603    original_content: &str,
604    current_content: &str,
605    predicted_content: &str,
606) -> ReversalOverlap {
607    let history_edits =
608        normalize_extension_edits(compute_granular_edits(original_content, current_content));
609    let prediction_edits =
610        normalize_extension_edits(compute_granular_edits(current_content, predicted_content));
611
612    let history_addition_ranges = compute_history_addition_ranges(&history_edits);
613    let history_deletion_ranges = compute_history_deletion_ranges(&history_edits);
614
615    let reversed_additions =
616        compute_reversed_additions(&history_addition_ranges, &prediction_edits);
617    let restored_deletions =
618        compute_restored_deletions(&history_deletion_ranges, &prediction_edits);
619
620    let total_chars_in_prediction: usize = prediction_edits
621        .iter()
622        .map(|e| e.new_text.chars().count() + e.old_text.chars().count())
623        .sum();
624
625    ReversalOverlap {
626        chars_reversing_user_edits: reversed_additions + restored_deletions,
627        total_chars_in_prediction,
628    }
629}
630
631fn compute_reversed_additions(
632    history_addition_ranges: &[HistoryAdditionRange],
633    prediction_edits: &[GranularEdit],
634) -> usize {
635    let mut reversed_chars = 0;
636
637    for pred_edit in prediction_edits {
638        for history_addition in history_addition_ranges {
639            let overlap_start = pred_edit
640                .range
641                .start
642                .max(history_addition.range_in_current.start);
643            let overlap_end = pred_edit
644                .range
645                .end
646                .min(history_addition.range_in_current.end);
647
648            if overlap_start < overlap_end {
649                let relative_start = overlap_start - pred_edit.range.start;
650                let relative_end = overlap_end - pred_edit.range.start;
651                let overlap_text = &pred_edit.old_text[relative_start..relative_end];
652                reversed_chars += overlap_text.chars().count();
653            }
654        }
655    }
656
657    reversed_chars
658}
659
660fn compute_restored_deletions(
661    history_deletion_ranges: &[HistoryDeletionRange],
662    prediction_edits: &[GranularEdit],
663) -> usize {
664    let mut restored = 0;
665
666    for pred_edit in prediction_edits {
667        if pred_edit.new_text.is_empty() {
668            continue;
669        }
670
671        for deletion in history_deletion_ranges {
672            if pred_edit.range.contains(&deletion.position_in_current)
673                || deletion.position_in_current == pred_edit.range.start
674            {
675                restored += compute_lcs_length(&deletion.deleted_text, &pred_edit.new_text);
676            }
677        }
678    }
679
680    restored
681}
682
683fn compute_lcs_length(a: &str, b: &str) -> usize {
684    let a_chars: Vec<char> = a.chars().collect();
685    let b_chars: Vec<char> = b.chars().collect();
686    let m = a_chars.len();
687    let n = b_chars.len();
688
689    if m == 0 || n == 0 {
690        return 0;
691    }
692
693    let mut prev = vec![0; n + 1];
694    let mut curr = vec![0; n + 1];
695
696    for i in 1..=m {
697        for j in 1..=n {
698            if a_chars[i - 1] == b_chars[j - 1] {
699                curr[j] = prev[j - 1] + 1;
700            } else {
701                curr[j] = prev[j].max(curr[j - 1]);
702            }
703        }
704        std::mem::swap(&mut prev, &mut curr);
705        curr.fill(0);
706    }
707
708    prev[n]
709}
710
711fn filter_edit_history_by_path<'a>(
712    edit_history: &'a [Arc<zeta_prompt::Event>],
713    cursor_path: &std::path::Path,
714) -> Vec<&'a zeta_prompt::Event> {
715    edit_history
716        .iter()
717        .filter(|event| match event.as_ref() {
718            zeta_prompt::Event::BufferChange { path, .. } => {
719                let event_path = path.as_ref();
720                if event_path == cursor_path {
721                    return true;
722                }
723                let stripped = event_path
724                    .components()
725                    .skip(1)
726                    .collect::<std::path::PathBuf>();
727                stripped == cursor_path
728            }
729        })
730        .map(|arc| arc.as_ref())
731        .collect()
732}
733
734fn extract_diff_from_event(event: &zeta_prompt::Event) -> &str {
735    match event {
736        zeta_prompt::Event::BufferChange { diff, .. } => diff.as_str(),
737    }
738}
739
740fn is_predicted_event(event: &zeta_prompt::Event) -> bool {
741    match event {
742        zeta_prompt::Event::BufferChange { predicted, .. } => *predicted,
743    }
744}
745
746pub fn compute_prediction_reversal_ratio_from_history(
747    current_content: &str,
748    edit_history: &[Arc<zeta_prompt::Event>],
749    excerpt_start_row: Option<u32>,
750    predicted_content: &str,
751    cursor_path: &Path,
752) -> f32 {
753    let relevant_events = filter_edit_history_by_path(edit_history, cursor_path);
754
755    let most_recent = match relevant_events.last() {
756        Some(event) if !is_predicted_event(event) => *event,
757        _ => return 0.0,
758    };
759
760    let diff = extract_diff_from_event(most_recent);
761    if diff.is_empty() {
762        return 0.0;
763    }
764
765    if let Some(excerpt_start_row) = excerpt_start_row {
766        let diffs = vec![diff];
767        let overlap = compute_excerpt_aware_reversal_overlap(
768            &diffs,
769            current_content,
770            excerpt_start_row,
771            predicted_content,
772        );
773        return overlap.ratio();
774    }
775
776    let reversed = reverse_diff(diff);
777    let with_headers = format!("--- a/file\n+++ b/file\n{}", reversed);
778    let original_content = match apply_diff_to_string(&with_headers, current_content) {
779        Ok(updated_content) => updated_content,
780        Err(_) => apply_diff_to_string_lenient(&reversed, current_content),
781    };
782
783    let overlap = compute_reversal_overlap(&original_content, current_content, predicted_content);
784    overlap.ratio()
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use indoc::indoc;
791    use zeta_prompt::udiff::{apply_diff_to_string, unified_diff_with_context};
792    use zeta_prompt::{ExcerptRanges, Zeta2PromptInput};
793
794    fn compute_prediction_reversal_ratio(
795        prompt_inputs: &Zeta2PromptInput,
796        predicted_content: &str,
797        cursor_path: &Path,
798    ) -> f32 {
799        compute_prediction_reversal_ratio_from_history(
800            prompt_inputs.cursor_excerpt.as_ref(),
801            &prompt_inputs.events,
802            prompt_inputs.excerpt_start_row,
803            predicted_content,
804            cursor_path,
805        )
806    }
807
808    fn make_test_prompt_inputs(
809        content: &str,
810        events: Vec<Arc<zeta_prompt::Event>>,
811        excerpt_start_row: Option<u32>,
812    ) -> Zeta2PromptInput {
813        Zeta2PromptInput {
814            cursor_path: Arc::from(Path::new("src/test.rs")),
815            cursor_excerpt: content.into(),
816            cursor_offset_in_excerpt: 0,
817            excerpt_start_row,
818            events,
819            related_files: Some(Vec::new()),
820            active_buffer_diagnostics: Vec::new(),
821            excerpt_ranges: ExcerptRanges {
822                editable_150: 0..content.len(),
823                editable_180: 0..content.len(),
824                editable_350: 0..content.len(),
825                editable_150_context_350: 0..content.len(),
826                editable_180_context_350: 0..content.len(),
827                editable_350_context_150: 0..content.len(),
828                ..Default::default()
829            },
830            syntax_ranges: None,
831            in_open_source_repo: false,
832            can_collect_data: false,
833            repo_url: None,
834        }
835    }
836
837    #[test]
838    fn test_reversal_overlap() {
839        struct Case {
840            name: &'static str,
841            original: &'static str,
842            current: &'static str,
843            predicted: &'static str,
844            expected_reversal_chars: usize,
845            expected_total_chars: usize,
846        }
847
848        let cases = [
849            Case {
850                name: "user_adds_line_prediction_removes_it",
851                original: indoc! {"
852                     a
853                     b
854                     c"},
855                current: indoc! {"
856                     a
857                     new line
858                     b
859                     c"},
860                predicted: indoc! {"
861                     a
862                     b
863                     c"},
864                expected_reversal_chars: 9,
865                expected_total_chars: 9,
866            },
867            Case {
868                name: "user_deletes_line_prediction_restores_it",
869                original: indoc! {"
870                     a
871                     deleted
872                     b"},
873                current: indoc! {"
874                     a
875                     b"},
876                predicted: indoc! {"
877                     a
878                     deleted
879                     b"},
880                expected_reversal_chars: 8,
881                expected_total_chars: 8,
882            },
883            Case {
884                name: "user_deletes_text_prediction_restores_partial",
885                original: "hello beautiful world",
886                current: "hello world",
887                predicted: "hello beautiful world",
888                expected_reversal_chars: 10,
889                expected_total_chars: 10,
890            },
891            Case {
892                name: "user_deletes_foo_prediction_adds_bar",
893                original: "foo",
894                current: "",
895                predicted: "bar",
896                expected_reversal_chars: 0,
897                expected_total_chars: 3,
898            },
899            Case {
900                name: "independent_edits_different_locations",
901                original: indoc! {"
902                     line1
903                     line2
904                     line3"},
905                current: indoc! {"
906                     LINE1
907                     line2
908                     line3"},
909                predicted: indoc! {"
910                     LINE1
911                     line2
912                     LINE3"},
913                expected_reversal_chars: 0,
914                expected_total_chars: 10,
915            },
916            Case {
917                name: "no_history_edits",
918                original: "same",
919                current: "same",
920                predicted: "different",
921                expected_reversal_chars: 0,
922                expected_total_chars: 13,
923            },
924            Case {
925                name: "user_replaces_text_prediction_reverses",
926                original: indoc! {"
927                     keep
928                     delete_me
929                     keep2"},
930                current: indoc! {"
931                     keep
932                     added
933                     keep2"},
934                predicted: indoc! {"
935                     keep
936                     delete_me
937                     keep2"},
938                expected_reversal_chars: 14,
939                expected_total_chars: 14,
940            },
941            Case {
942                name: "user_modifies_word_prediction_modifies_differently",
943                original: "the quick brown fox",
944                current: "the slow brown fox",
945                predicted: "the fast brown fox",
946                expected_reversal_chars: 4,
947                expected_total_chars: 8,
948            },
949            Case {
950                name: "user finishes function name (suffix)",
951                original: "",
952                current: "epr",
953                predicted: "eprintln!()",
954                expected_reversal_chars: 0,
955                expected_total_chars: 8,
956            },
957            Case {
958                name: "user starts function name (prefix)",
959                original: "",
960                current: "my_function()",
961                predicted: "test_my_function()",
962                expected_reversal_chars: 0,
963                expected_total_chars: 5,
964            },
965            Case {
966                name: "user types partial, prediction extends in multiple places",
967                original: "",
968                current: "test_my_function",
969                predicted: "a_test_for_my_special_function_plz",
970                expected_reversal_chars: 0,
971                expected_total_chars: 18,
972            },
973            // Edge cases for subsequence matching
974            Case {
975                name: "subsequence with interleaved underscores",
976                original: "",
977                current: "a_b_c",
978                predicted: "_a__b__c__",
979                expected_reversal_chars: 0,
980                expected_total_chars: 5,
981            },
982            Case {
983                name: "not a subsequence - different characters",
984                original: "",
985                current: "abc",
986                predicted: "xyz",
987                expected_reversal_chars: 3,
988                expected_total_chars: 6,
989            },
990            Case {
991                name: "not a subsequence - wrong order",
992                original: "",
993                current: "abc",
994                predicted: "cba",
995                expected_reversal_chars: 3,
996                expected_total_chars: 6,
997            },
998            Case {
999                name: "partial subsequence - only some chars match",
1000                original: "",
1001                current: "abcd",
1002                predicted: "axbx",
1003                expected_reversal_chars: 4,
1004                expected_total_chars: 8,
1005            },
1006            // Common completion patterns
1007            Case {
1008                name: "completing a method call",
1009                original: "",
1010                current: "vec.pu",
1011                predicted: "vec.push(item)",
1012                expected_reversal_chars: 0,
1013                expected_total_chars: 8,
1014            },
1015            Case {
1016                name: "completing an import statement",
1017                original: "",
1018                current: "use std::col",
1019                predicted: "use std::collections::HashMap",
1020                expected_reversal_chars: 0,
1021                expected_total_chars: 17,
1022            },
1023            Case {
1024                name: "completing a struct field",
1025                original: "",
1026                current: "name: St",
1027                predicted: "name: String",
1028                expected_reversal_chars: 0,
1029                expected_total_chars: 4,
1030            },
1031            Case {
1032                name: "prediction replaces with completely different text",
1033                original: "",
1034                current: "hello",
1035                predicted: "world",
1036                expected_reversal_chars: 5,
1037                expected_total_chars: 10,
1038            },
1039            Case {
1040                name: "empty prediction removes user text",
1041                original: "",
1042                current: "mistake",
1043                predicted: "",
1044                expected_reversal_chars: 7,
1045                expected_total_chars: 7,
1046            },
1047            Case {
1048                name: "fixing typo is not reversal",
1049                original: "",
1050                current: "<dv",
1051                predicted: "<div>",
1052                expected_reversal_chars: 0,
1053                expected_total_chars: 2,
1054            },
1055            Case {
1056                name: "infix insertion not reversal",
1057                original: indoc! {"
1058                     from my_project import Foo
1059                 "},
1060                current: indoc! {"
1061                     ifrom my_project import Foo
1062                 "},
1063                predicted: indoc! {"
1064                     import
1065                     from my_project import Foo
1066                 "},
1067                expected_reversal_chars: 0,
1068                expected_total_chars: 6,
1069            },
1070            Case {
1071                name: "non-word based reversal",
1072                original: "from",
1073                current: "ifrom",
1074                predicted: "from",
1075                expected_reversal_chars: 1,
1076                expected_total_chars: 1,
1077            },
1078            Case {
1079                name: "multiple insertions no reversal",
1080                original: "print(\"Hello, World!\")",
1081                current: "sys.(\"Hello, World!\")",
1082                predicted: "sys.stdout.write(\"Hello, World!\\n\")",
1083                expected_reversal_chars: 0,
1084                expected_total_chars: 14,
1085            },
1086        ];
1087
1088        for case in &cases {
1089            let overlap = compute_reversal_overlap(case.original, case.current, case.predicted);
1090            assert_eq!(
1091                overlap.chars_reversing_user_edits, case.expected_reversal_chars,
1092                "Test '{}': expected {} reversal chars, got {}",
1093                case.name, case.expected_reversal_chars, overlap.chars_reversing_user_edits
1094            );
1095            assert_eq!(
1096                overlap.total_chars_in_prediction, case.expected_total_chars,
1097                "Test '{}': expected {} total chars, got {}",
1098                case.name, case.expected_total_chars, overlap.total_chars_in_prediction
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn test_reverse_diff() {
1105        let forward_diff = indoc! {"
1106             --- a/file.rs
1107             +++ b/file.rs
1108             @@ -1,3 +1,4 @@
1109              fn main() {
1110             +    let x = 42;
1111                  println!(\"hello\");
1112             }"};
1113
1114        let reversed = reverse_diff(forward_diff);
1115
1116        assert!(
1117            reversed.contains("+++ a/file.rs"),
1118            "Should have +++ for old path"
1119        );
1120        assert!(
1121            reversed.contains("--- b/file.rs"),
1122            "Should have --- for new path"
1123        );
1124        assert!(
1125            reversed.contains("-    let x = 42;"),
1126            "Added line should become deletion"
1127        );
1128        assert!(
1129            reversed.contains(" fn main()"),
1130            "Context lines should be unchanged"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_reverse_diff_roundtrip() {
1136        // Applying a diff and then its reverse should get back to original
1137        let original = indoc! {"
1138             first line
1139             hello world
1140             last line
1141         "};
1142        let modified = indoc! {"
1143             first line
1144             hello beautiful world
1145             last line
1146         "};
1147
1148        // unified_diff_with_context doesn't include file headers, but apply_diff_to_string needs them
1149        let diff_body = unified_diff_with_context(original, modified, 0, 0, 3);
1150        let forward_diff = format!("--- a/file\n+++ b/file\n{}", diff_body);
1151        let reversed_diff = reverse_diff(&forward_diff);
1152
1153        // Apply forward diff to original
1154        let after_forward = apply_diff_to_string(&forward_diff, original).unwrap();
1155        assert_eq!(after_forward, modified);
1156
1157        // Apply reversed diff to modified
1158        let after_reverse = apply_diff_to_string(&reversed_diff, &after_forward).unwrap();
1159        assert_eq!(after_reverse, original);
1160    }
1161
1162    #[test]
1163    fn test_filter_edit_history_by_path() {
1164        // Test that filter_edit_history_by_path correctly matches paths when
1165        // the edit history has paths with a repo prefix (e.g., "repo/src/file.rs")
1166        // but the cursor_path doesn't have the repo prefix (e.g., "src/file.rs")
1167        let events = vec![
1168            Arc::new(zeta_prompt::Event::BufferChange {
1169                path: Arc::from(Path::new("myrepo/src/file.rs")),
1170                old_path: Arc::from(Path::new("myrepo/src/file.rs")),
1171                diff: indoc! {"
1172                     @@ -1 +1 @@
1173                     -old
1174                     +new"}
1175                .into(),
1176                old_range: 0..0,
1177                new_range: 0..0,
1178                predicted: false,
1179                in_open_source_repo: true,
1180            }),
1181            Arc::new(zeta_prompt::Event::BufferChange {
1182                path: Arc::from(Path::new("myrepo/other.rs")),
1183                old_path: Arc::from(Path::new("myrepo/other.rs")),
1184                diff: indoc! {"
1185                     @@ -1 +1 @@
1186                     -a
1187                     +b"}
1188                .into(),
1189                old_range: 0..0,
1190                new_range: 0..0,
1191                predicted: false,
1192                in_open_source_repo: true,
1193            }),
1194            Arc::new(zeta_prompt::Event::BufferChange {
1195                path: Arc::from(Path::new("src/file.rs")),
1196                old_path: Arc::from(Path::new("src/file.rs")),
1197                diff: indoc! {"
1198                     @@ -1 +1 @@
1199                     -x
1200                     +y"}
1201                .into(),
1202                old_range: 0..0,
1203                new_range: 0..0,
1204                predicted: false,
1205                in_open_source_repo: true,
1206            }),
1207        ];
1208
1209        // "myrepo/src/file.rs" stripped -> "src/file.rs" matches cursor_path
1210        // "src/file.rs" exact match
1211        let cursor_path = Path::new("src/file.rs");
1212        let filtered = filter_edit_history_by_path(&events, cursor_path);
1213        assert_eq!(
1214            filtered.len(),
1215            2,
1216            "Should match myrepo/src/file.rs (stripped) and src/file.rs (exact)"
1217        );
1218
1219        // "myrepo/src/file.rs" stripped -> "src/file.rs" != "file.rs"
1220        // "src/file.rs" stripped -> "file.rs" == "file.rs"
1221        let cursor_path = Path::new("file.rs");
1222        let filtered = filter_edit_history_by_path(&events, cursor_path);
1223        assert_eq!(
1224            filtered.len(),
1225            1,
1226            "Should only match src/file.rs (stripped to file.rs)"
1227        );
1228
1229        // "myrepo/other.rs" stripped -> "other.rs" == "other.rs"
1230        let cursor_path = Path::new("other.rs");
1231        let filtered = filter_edit_history_by_path(&events, cursor_path);
1232        assert_eq!(filtered.len(), 1, "Should match only myrepo/other.rs");
1233    }
1234
1235    #[test]
1236    fn test_reverse_diff_preserves_trailing_newline() {
1237        let diff_with_trailing_newline = indoc! {"
1238             --- a/file
1239             +++ b/file
1240             @@ -1 +1 @@
1241             -old
1242             +new
1243         "};
1244        let reversed = reverse_diff(diff_with_trailing_newline);
1245        assert!(
1246            reversed.ends_with('\n'),
1247            "Reversed diff should preserve trailing newline"
1248        );
1249
1250        let diff_without_trailing_newline = indoc! {"
1251             --- a/file
1252             +++ b/file
1253             @@ -1 +1 @@
1254             -old
1255             +new"};
1256        let reversed = reverse_diff(diff_without_trailing_newline);
1257        assert!(
1258            !reversed.ends_with('\n'),
1259            "Reversed diff should not add trailing newline if original didn't have one"
1260        );
1261    }
1262
1263    #[test]
1264    fn test_filter_hunks_by_excerpt_region() {
1265        struct Case {
1266            name: &'static str,
1267            diff: &'static str,
1268            excerpt_start_row: u32,
1269            excerpt_row_count: u32,
1270            expected_filtered_diff: &'static str,
1271            expected_line_offset: i32,
1272        }
1273
1274        let cases = [
1275            Case {
1276                name: "hunk_entirely_before_excerpt",
1277                diff: indoc! {"
1278                     @@ -1,3 +1,4 @@
1279                      line1
1280                     +inserted
1281                      line2
1282                      line3
1283                 "},
1284                excerpt_start_row: 10,
1285                excerpt_row_count: 5,
1286                expected_filtered_diff: "",
1287                expected_line_offset: 1,
1288            },
1289            Case {
1290                name: "hunk_entirely_inside_excerpt",
1291                diff: indoc! {"
1292                     @@ -12,3 +12,4 @@
1293                      line12
1294                     +inserted
1295                      line13
1296                      line14
1297                 "},
1298                excerpt_start_row: 10,
1299                excerpt_row_count: 10,
1300                expected_filtered_diff: indoc! {"
1301                     @@ -2,3 +2,4 @@
1302                      line12
1303                     +inserted
1304                      line13
1305                      line14
1306                 "},
1307                expected_line_offset: 1,
1308            },
1309            Case {
1310                name: "hunk_entirely_after_excerpt",
1311                diff: indoc! {"
1312                     @@ -50,3 +50,4 @@
1313                      line50
1314                     +inserted
1315                      line51
1316                      line52
1317                 "},
1318                excerpt_start_row: 10,
1319                excerpt_row_count: 5,
1320                expected_filtered_diff: "",
1321                expected_line_offset: 0,
1322            },
1323            Case {
1324                name: "hunk_straddles_excerpt_start",
1325                diff: indoc! {"
1326                     @@ -8,5 +8,6 @@
1327                      line8
1328                      line9
1329                     +inserted
1330                      line10
1331                      line11
1332                      line12
1333                 "},
1334                excerpt_start_row: 10,
1335                excerpt_row_count: 10,
1336                expected_filtered_diff: indoc! {"
1337                     @@ -1,3 +1,3 @@
1338                      line10
1339                      line11
1340                      line12
1341                 "},
1342                expected_line_offset: 1,
1343            },
1344            Case {
1345                name: "hunk_straddles_excerpt_end",
1346                diff: indoc! {"
1347                     @@ -18,5 +18,6 @@
1348                      line18
1349                      line19
1350                     +inserted
1351                      line20
1352                      line21
1353                      line22
1354                 "},
1355                excerpt_start_row: 10,
1356                excerpt_row_count: 10,
1357                expected_filtered_diff: indoc! {"
1358                     @@ -8,2 +8,3 @@
1359                      line18
1360                      line19
1361                     +inserted
1362                 "},
1363                expected_line_offset: 1,
1364            },
1365            Case {
1366                name: "multiple_hunks_mixed",
1367                diff: indoc! {"
1368                     @@ -1,2 +1,3 @@
1369                      line1
1370                     +before_excerpt
1371                      line2
1372                     @@ -12,2 +13,3 @@
1373                      line12
1374                     +inside_excerpt
1375                      line13
1376                     @@ -50,2 +52,3 @@
1377                      line50
1378                     +after_excerpt
1379                      line51
1380                 "},
1381                excerpt_start_row: 10,
1382                excerpt_row_count: 10,
1383                expected_filtered_diff: indoc! {"
1384                     @@ -3,2 +3,3 @@
1385                      line12
1386                     +inside_excerpt
1387                      line13
1388                 "},
1389                expected_line_offset: 2,
1390            },
1391            Case {
1392                name: "deletion_before_excerpt",
1393                diff: indoc! {"
1394                     @@ -1,4 +1,3 @@
1395                      line1
1396                     -deleted
1397                      line2
1398                      line3
1399                 "},
1400                excerpt_start_row: 10,
1401                excerpt_row_count: 5,
1402                expected_filtered_diff: "",
1403                expected_line_offset: -1,
1404            },
1405            Case {
1406                name: "deletion_inside_excerpt",
1407                diff: indoc! {"
1408                     @@ -12,4 +12,3 @@
1409                      line12
1410                     -deleted
1411                      line13
1412                      line14
1413                 "},
1414                excerpt_start_row: 10,
1415                excerpt_row_count: 10,
1416                expected_filtered_diff: indoc! {"
1417                     @@ -2,4 +2,3 @@
1418                      line12
1419                     -deleted
1420                      line13
1421                      line14
1422                 "},
1423                expected_line_offset: -1,
1424            },
1425            Case {
1426                name: "empty_diff",
1427                diff: "",
1428                excerpt_start_row: 10,
1429                excerpt_row_count: 5,
1430                expected_filtered_diff: "",
1431                expected_line_offset: 0,
1432            },
1433            Case {
1434                name: "hunk_spans_entire_excerpt",
1435                diff: indoc! {"
1436                     @@ -8,10 +8,12 @@
1437                      line8
1438                      line9
1439                      line10
1440                      line11
1441                     +inserted1
1442                      line12
1443                      line13
1444                     +inserted2
1445                      line14
1446                      line15
1447                      line16
1448                      line17
1449                 "},
1450                excerpt_start_row: 10,
1451                excerpt_row_count: 5,
1452                expected_filtered_diff: indoc! {"
1453                     @@ -1,3 +1,5 @@
1454                      line11
1455                     +inserted1
1456                      line12
1457                      line13
1458                     +inserted2
1459                 "},
1460                expected_line_offset: 2,
1461            },
1462            Case {
1463                name: "replacement_inside_excerpt",
1464                diff: indoc! {"
1465                     @@ -12,3 +12,3 @@
1466                      line12
1467                     -old_text
1468                     +new_text
1469                      line14
1470                 "},
1471                excerpt_start_row: 10,
1472                excerpt_row_count: 10,
1473                expected_filtered_diff: indoc! {"
1474                     @@ -2,3 +2,3 @@
1475                      line12
1476                     -old_text
1477                     +new_text
1478                      line14
1479                 "},
1480                expected_line_offset: 0,
1481            },
1482        ];
1483
1484        for case in &cases {
1485            let (filtered, line_offset) = filter_diff_hunks_by_excerpt(
1486                case.diff,
1487                case.excerpt_start_row,
1488                case.excerpt_row_count,
1489            );
1490            assert_eq!(
1491                filtered, case.expected_filtered_diff,
1492                "Test '{}': filtered diff mismatch.\nExpected:\n{}\nGot:\n{}",
1493                case.name, case.expected_filtered_diff, filtered
1494            );
1495            assert_eq!(
1496                line_offset, case.expected_line_offset,
1497                "Test '{}': line offset mismatch. Expected {}, got {}",
1498                case.name, case.expected_line_offset, line_offset
1499            );
1500        }
1501    }
1502
1503    #[test]
1504    fn test_excerpt_aware_reversal_tracking() {
1505        struct Case {
1506            name: &'static str,
1507            edit_history_diffs: Vec<&'static str>,
1508            excerpt_content: &'static str,
1509            excerpt_start_row: u32,
1510            predicted_content: &'static str,
1511            expected_reversal_chars: usize,
1512            expected_total_chars: usize,
1513        }
1514
1515        let cases = [
1516            Case {
1517                name: "edit_outside_excerpt_no_reversal",
1518                edit_history_diffs: vec![indoc! {"
1519                     @@ -1,2 +1,3 @@
1520                      line1
1521                     +added_outside
1522                      line2
1523                 "}],
1524                excerpt_content: indoc! {"
1525                     line10
1526                     line11
1527                     line12
1528                 "},
1529                excerpt_start_row: 10,
1530                predicted_content: indoc! {"
1531                     line10
1532                     modified
1533                     line12
1534                 "},
1535                expected_reversal_chars: 0,
1536                expected_total_chars: 14,
1537            },
1538            Case {
1539                name: "edit_inside_excerpt_with_reversal",
1540                edit_history_diffs: vec![indoc! {"
1541                     @@ -10,3 +10,4 @@
1542                      line10
1543                     +user_added
1544                      line11
1545                      line12
1546                 "}],
1547                excerpt_content: indoc! {"
1548                     line10
1549                     user_added
1550                     line11
1551                     line12
1552                 "},
1553                excerpt_start_row: 10,
1554                predicted_content: indoc! {"
1555                     line10
1556                     line11
1557                     line12
1558                 "},
1559                expected_reversal_chars: 11,
1560                expected_total_chars: 11,
1561            },
1562            Case {
1563                name: "straddling_edit_partial_reversal",
1564                edit_history_diffs: vec![indoc! {"
1565                     @@ -8,6 +8,8 @@
1566                      line8
1567                      line9
1568                     +before_excerpt
1569                      line10
1570                     +inside_excerpt
1571                      line11
1572                      line12
1573                      line13
1574                 "}],
1575                excerpt_content: indoc! {"
1576                     line10
1577                     inside_excerpt
1578                     line11
1579                     line12
1580                     line13
1581                 "},
1582                excerpt_start_row: 10,
1583                predicted_content: indoc! {"
1584                     line10
1585                     line11
1586                     line12
1587                     line13
1588                 "},
1589                expected_reversal_chars: 15,
1590                expected_total_chars: 15,
1591            },
1592            Case {
1593                name: "multiple_edits_mixed_locations",
1594                edit_history_diffs: vec![
1595                    indoc! {"
1596                         @@ -1,2 +1,3 @@
1597                          line1
1598                         +outside1
1599                          line2
1600                     "},
1601                    indoc! {"
1602                         @@ -11,2 +12,3 @@
1603                          line11
1604                         +inside1
1605                          line12
1606                     "},
1607                ],
1608                excerpt_content: indoc! {"
1609                     line10
1610                     line11
1611                     inside1
1612                     line12
1613                     line13
1614                 "},
1615                excerpt_start_row: 10,
1616                predicted_content: indoc! {"
1617                     line10
1618                     line11
1619                     line12
1620                     line13
1621                 "},
1622                expected_reversal_chars: 8,
1623                expected_total_chars: 8,
1624            },
1625            Case {
1626                name: "no_edit_history",
1627                edit_history_diffs: vec![],
1628                excerpt_content: indoc! {"
1629                     line10
1630                     line11
1631                     line12
1632                 "},
1633                excerpt_start_row: 10,
1634                predicted_content: indoc! {"
1635                     line10
1636                     modified
1637                     line12
1638                 "},
1639                expected_reversal_chars: 0,
1640                expected_total_chars: 14,
1641            },
1642            Case {
1643                name: "edit_after_excerpt_no_effect",
1644                edit_history_diffs: vec![indoc! {"
1645                     @@ -50,2 +50,3 @@
1646                      line50
1647                     +added_after
1648                      line51
1649                 "}],
1650                excerpt_content: indoc! {"
1651                     line10
1652                     line11
1653                     line12
1654                 "},
1655                excerpt_start_row: 10,
1656                predicted_content: indoc! {"
1657                     line10
1658                     changed
1659                     line12
1660                 "},
1661                expected_reversal_chars: 0,
1662                expected_total_chars: 13,
1663            },
1664            Case {
1665                name: "line_offset_tracking_across_hunks",
1666                edit_history_diffs: vec![
1667                    indoc! {"
1668                         @@ -1,2 +1,4 @@
1669                          line1
1670                         +added1
1671                         +added2
1672                          line2
1673                     "},
1674                    indoc! {"
1675                         @@ -12,2 +14,3 @@
1676                          line12
1677                         +inside_after_offset
1678                          line13
1679                     "},
1680                ],
1681                excerpt_content: indoc! {"
1682                     line10
1683                     line11
1684                     line12
1685                     inside_after_offset
1686                     line13
1687                 "},
1688                excerpt_start_row: 10,
1689                predicted_content: indoc! {"
1690                     line10
1691                     line11
1692                     line12
1693                     line13
1694                 "},
1695                expected_reversal_chars: 20,
1696                expected_total_chars: 20,
1697            },
1698        ];
1699
1700        for case in &cases {
1701            let overlap = compute_excerpt_aware_reversal_overlap(
1702                &case.edit_history_diffs,
1703                case.excerpt_content,
1704                case.excerpt_start_row,
1705                case.predicted_content,
1706            );
1707            assert_eq!(
1708                overlap.chars_reversing_user_edits, case.expected_reversal_chars,
1709                "Test '{}': expected {} reversal chars, got {}",
1710                case.name, case.expected_reversal_chars, overlap.chars_reversing_user_edits
1711            );
1712            assert_eq!(
1713                overlap.total_chars_in_prediction, case.expected_total_chars,
1714                "Test '{}': expected {} total chars, got {}",
1715                case.name, case.expected_total_chars, overlap.total_chars_in_prediction
1716            );
1717        }
1718    }
1719
1720    #[test]
1721    fn test_lenient_diff_application() {
1722        struct Case {
1723            name: &'static str,
1724            diff: &'static str,
1725            content: &'static str,
1726            expected_result: &'static str,
1727        }
1728
1729        let cases = [
1730            Case {
1731                name: "hunk_context_not_found_skipped",
1732                diff: indoc! {"
1733                     @@ -1,3 +1,4 @@
1734                      context_not_in_content
1735                     +added_line
1736                      more_context
1737                      final_context
1738                 "},
1739                content: indoc! {"
1740                     completely
1741                     different
1742                     content
1743                 "},
1744                expected_result: indoc! {"
1745                     completely
1746                     different
1747                     content
1748                 "},
1749            },
1750            Case {
1751                name: "hunk_context_found_applied",
1752                diff: indoc! {"
1753                     @@ -1,3 +1,4 @@
1754                      line1
1755                     +inserted
1756                      line2
1757                      line3
1758                 "},
1759                content: indoc! {"
1760                     line1
1761                     line2
1762                     line3
1763                 "},
1764                expected_result: indoc! {"
1765                     line1
1766                     inserted
1767                     line2
1768                     line3
1769                 "},
1770            },
1771            Case {
1772                name: "multiple_hunks_partial_match",
1773                diff: indoc! {"
1774                     @@ -1,2 +1,3 @@
1775                      not_found
1776                     +skipped
1777                      also_not_found
1778                     @@ -5,2 +6,3 @@
1779                      line5
1780                     +applied
1781                      line6
1782                 "},
1783                content: indoc! {"
1784                     line1
1785                     line2
1786                     line3
1787                     line4
1788                     line5
1789                     line6
1790                 "},
1791                expected_result: indoc! {"
1792                     line1
1793                     line2
1794                     line3
1795                     line4
1796                     line5
1797                     applied
1798                     line6
1799                 "},
1800            },
1801            Case {
1802                name: "empty_diff",
1803                diff: "",
1804                content: indoc! {"
1805                     unchanged
1806                     content
1807                 "},
1808                expected_result: indoc! {"
1809                     unchanged
1810                     content
1811                 "},
1812            },
1813        ];
1814
1815        for case in &cases {
1816            let result = apply_diff_to_string_lenient(case.diff, case.content);
1817            assert_eq!(
1818                result, case.expected_result,
1819                "Test '{}': expected:\n{}\ngot:\n{}",
1820                case.name, case.expected_result, result
1821            );
1822        }
1823    }
1824
1825    #[test]
1826    fn test_unicode_reversal_overlap() {
1827        struct Case {
1828            name: &'static str,
1829            original: &'static str,
1830            current: &'static str,
1831            predicted: &'static str,
1832            expected_reversal_chars: usize,
1833            expected_total_chars: usize,
1834        }
1835
1836        let cases = [
1837            Case {
1838                name: "unicode_extension_cjk",
1839                original: "",
1840                current: "日",       // 1 char
1841                predicted: "日本語", // 3 chars, adds 2 chars
1842                expected_reversal_chars: 0,
1843                expected_total_chars: 2, // "本語" = 2 chars added
1844            },
1845            Case {
1846                name: "unicode_extension_emoji",
1847                original: "",
1848                current: "🎉",       // 1 char
1849                predicted: "🎉🎊🎈", // 3 chars, adds 2 chars
1850                expected_reversal_chars: 0,
1851                expected_total_chars: 2, // "🎊🎈" = 2 chars added
1852            },
1853            Case {
1854                name: "unicode_deletion_restored",
1855                original: "héllo wörld",    // 11 chars
1856                current: "héllo",           // 5 chars
1857                predicted: "héllo wörld",   // restores " wörld" = 6 chars
1858                expected_reversal_chars: 6, // LCS(" wörld", " wörld") = 6 chars
1859                expected_total_chars: 6,
1860            },
1861            Case {
1862                name: "unicode_addition_reversed",
1863                original: "café",           // 4 chars
1864                current: "café latté",      // 10 chars, added " latté" = 6 chars
1865                predicted: "café",          // removes " latté"
1866                expected_reversal_chars: 6, // 6 chars removed
1867                expected_total_chars: 6,
1868            },
1869            Case {
1870                name: "mixed_ascii_unicode",
1871                original: "",
1872                current: "test日本",         // 6 chars
1873                predicted: "test日本語です", // 9 chars
1874                expected_reversal_chars: 0,
1875                expected_total_chars: 3, // 3 new chars after subsequence normalization
1876            },
1877            Case {
1878                name: "unicode_replacement_not_subsequence",
1879                original: "",
1880                current: "日本",            // 2 chars
1881                predicted: "中国",          // 2 chars, different
1882                expected_reversal_chars: 2, // removes "日本" = 2 chars
1883                expected_total_chars: 4,    // 2 removed + 2 added
1884            },
1885        ];
1886
1887        for case in &cases {
1888            let overlap = compute_reversal_overlap(case.original, case.current, case.predicted);
1889            assert_eq!(
1890                overlap.chars_reversing_user_edits, case.expected_reversal_chars,
1891                "Test '{}': expected {} reversal chars, got {}",
1892                case.name, case.expected_reversal_chars, overlap.chars_reversing_user_edits
1893            );
1894            assert_eq!(
1895                overlap.total_chars_in_prediction, case.expected_total_chars,
1896                "Test '{}': expected {} total chars, got {}",
1897                case.name, case.expected_total_chars, overlap.total_chars_in_prediction
1898            );
1899        }
1900    }
1901
1902    #[test]
1903    fn test_compute_lcs_length() {
1904        assert_eq!(compute_lcs_length("", ""), 0);
1905        assert_eq!(compute_lcs_length("abc", ""), 0);
1906        assert_eq!(compute_lcs_length("", "abc"), 0);
1907        assert_eq!(compute_lcs_length("abc", "abc"), 3);
1908        assert_eq!(compute_lcs_length("abc", "def"), 0);
1909        assert_eq!(compute_lcs_length("abcdef", "ace"), 3);
1910        assert_eq!(compute_lcs_length("AGGTAB", "GXTXAYB"), 4);
1911        assert_eq!(compute_lcs_length("日本語", "日語"), 2);
1912    }
1913
1914    #[test]
1915    fn test_compute_prediction_reversal_ratio_full_file() {
1916        let prompt_inputs = make_test_prompt_inputs(
1917            indoc! {"
1918                 line1
1919                 user_added
1920                 line2
1921             "},
1922            vec![Arc::new(zeta_prompt::Event::BufferChange {
1923                path: Arc::from(Path::new("src/test.rs")),
1924                old_path: Arc::from(Path::new("src/test.rs")),
1925                diff: indoc! {"
1926                     @@ -1,2 +1,3 @@
1927                      line1
1928                     +user_added
1929                      line2
1930                 "}
1931                .into(),
1932                old_range: 0..0,
1933                new_range: 0..0,
1934                predicted: false,
1935                in_open_source_repo: false,
1936            })],
1937            None,
1938        );
1939
1940        let predicted = indoc! {"
1941             line1
1942             line2
1943         "};
1944        let ratio =
1945            compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs"));
1946
1947        assert!(
1948            ratio > 0.9,
1949            "Expected high reversal ratio when prediction removes user addition, got {}",
1950            ratio
1951        );
1952    }
1953
1954    #[test]
1955    fn test_compute_prediction_reversal_ratio_with_excerpt() {
1956        let prompt_inputs = make_test_prompt_inputs(
1957            indoc! {"
1958                 line10
1959                 user_added
1960                 line11
1961             "},
1962            vec![Arc::new(zeta_prompt::Event::BufferChange {
1963                path: Arc::from(Path::new("src/test.rs")),
1964                old_path: Arc::from(Path::new("src/test.rs")),
1965                diff: indoc! {"
1966                     @@ -10,2 +10,3 @@
1967                      line10
1968                     +user_added
1969                      line11
1970                 "}
1971                .into(),
1972                old_range: 0..0,
1973                new_range: 0..0,
1974                predicted: false,
1975                in_open_source_repo: false,
1976            })],
1977            Some(10),
1978        );
1979
1980        let predicted = indoc! {"
1981             line10
1982             line11
1983         "};
1984        let ratio =
1985            compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs"));
1986
1987        assert!(
1988            ratio > 0.9,
1989            "Expected high reversal ratio for excerpt-aware computation, got {}",
1990            ratio
1991        );
1992    }
1993
1994    #[test]
1995    fn test_compute_prediction_reversal_ratio_no_history() {
1996        let prompt_inputs = make_test_prompt_inputs(
1997            indoc! {"
1998                 original content
1999             "},
2000            vec![],
2001            None,
2002        );
2003
2004        let predicted = indoc! {"
2005             completely different
2006         "};
2007        let ratio =
2008            compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs"));
2009
2010        assert_eq!(
2011            ratio, 0.0,
2012            "Expected zero reversal ratio with no edit history"
2013        );
2014    }
2015
2016    #[test]
2017    fn test_compute_prediction_reversal_ratio_path_filtering() {
2018        let prompt_inputs = make_test_prompt_inputs(
2019            indoc! {"
2020                 line1
2021                 user_added
2022                 line2
2023             "},
2024            vec![Arc::new(zeta_prompt::Event::BufferChange {
2025                path: Arc::from(Path::new("src/other.rs")),
2026                old_path: Arc::from(Path::new("src/other.rs")),
2027                diff: indoc! {"
2028                     @@ -1,2 +1,3 @@
2029                      line1
2030                     +user_added
2031                      line2
2032                 "}
2033                .into(),
2034                old_range: 0..0,
2035                new_range: 0..0,
2036                predicted: false,
2037                in_open_source_repo: false,
2038            })],
2039            None,
2040        );
2041
2042        let predicted = indoc! {"
2043             line1
2044             line2
2045         "};
2046        let ratio =
2047            compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs"));
2048
2049        assert_eq!(
2050            ratio, 0.0,
2051            "Expected zero reversal when edit history is for different file"
2052        );
2053    }
2054
2055    #[test]
2056    fn test_compute_prediction_reversal_ratio_lenient_fallback() {
2057        let prompt_inputs = make_test_prompt_inputs(
2058            indoc! {"
2059                 actual_line1
2060                 user_added
2061                 actual_line2
2062             "},
2063            vec![Arc::new(zeta_prompt::Event::BufferChange {
2064                path: Arc::from(Path::new("src/test.rs")),
2065                old_path: Arc::from(Path::new("src/test.rs")),
2066                diff: indoc! {"
2067                     @@ -1,2 +1,3 @@
2068                      wrong_context
2069                     +user_added
2070                      more_wrong
2071                 "}
2072                .into(),
2073                old_range: 0..0,
2074                new_range: 0..0,
2075                predicted: false,
2076                in_open_source_repo: false,
2077            })],
2078            None,
2079        );
2080
2081        let predicted = indoc! {"
2082             actual_line1
2083             actual_line2
2084         "};
2085        let ratio =
2086            compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs"));
2087
2088        assert!(
2089            ratio >= 0.0 && ratio <= 1.0,
2090            "Ratio should be valid even with lenient fallback, got {}",
2091            ratio
2092        );
2093    }
2094
2095    #[test]
2096    fn test_excerpt_aware_reversal_error_recovery() {
2097        let diffs = vec![indoc! {"
2098             @@ -1,2 +1,3 @@
2099              nonexistent_context
2100             +added
2101              more_nonexistent
2102         "}];
2103        let excerpt_content = indoc! {"
2104             completely
2105             different
2106             content
2107         "};
2108        let predicted_content = indoc! {"
2109             completely
2110             modified
2111             content
2112         "};
2113
2114        let overlap =
2115            compute_excerpt_aware_reversal_overlap(&diffs, excerpt_content, 0, predicted_content);
2116
2117        assert!(
2118            overlap.ratio() >= 0.0 && overlap.ratio() <= 1.0,
2119            "Should handle failed diff application gracefully"
2120        );
2121    }
2122
2123    #[test]
2124    fn test_only_most_recent_edit_tracked() {
2125        let prompt_inputs = make_test_prompt_inputs(
2126            indoc! {"
2127                 line1
2128                 first_add
2129                 second_add
2130                 line2
2131             "},
2132            vec![
2133                Arc::new(zeta_prompt::Event::BufferChange {
2134                    path: Arc::from(Path::new("src/test.rs")),
2135                    old_path: Arc::from(Path::new("src/test.rs")),
2136                    diff: indoc! {"
2137                         @@ -1,2 +1,3 @@
2138                          line1
2139                         +first_add
2140                          line2
2141                     "}
2142                    .into(),
2143                    old_range: 0..0,
2144                    new_range: 0..0,
2145                    predicted: false,
2146                    in_open_source_repo: false,
2147                }),
2148                Arc::new(zeta_prompt::Event::BufferChange {
2149                    path: Arc::from(Path::new("src/test.rs")),
2150                    old_path: Arc::from(Path::new("src/test.rs")),
2151                    diff: indoc! {"
2152                         @@ -2,2 +2,3 @@
2153                          first_add
2154                         +second_add
2155                          line2
2156                     "}
2157                    .into(),
2158                    old_range: 0..0,
2159                    new_range: 0..0,
2160                    predicted: false,
2161                    in_open_source_repo: false,
2162                }),
2163            ],
2164            None,
2165        );
2166
2167        let predicted = indoc! {"
2168             line1
2169             first_add
2170             line2
2171         "};
2172        let ratio =
2173            compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs"));
2174
2175        assert!(
2176            ratio > 0.9,
2177            "Expected high reversal ratio when prediction exactly reverses the most recent edit, got {}",
2178            ratio
2179        );
2180    }
2181}
2182
Served at tenant.openagents/omega Member data and write actions are omitted.