Skip to repository content2182 lines · 72.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:04:04.202Z 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
reversal.rs
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, ¤t_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(¤t_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