1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4const BOUNCE_WINDOW: u64 = 5;
5const BOUNCE_RATE_THRESHOLD: f64 = 0.30;
6const EDIT_FORCE_WINDOW: u64 = 10;
11const TRACKED_PATH_TTL_SEQ: u64 = 64;
15
16#[derive(Debug, Clone)]
17struct ReadEvent {
18 _mode: String,
19 tokens_sent: usize,
20 _original_tokens: usize,
21 seq: u64,
22 view: ReadView,
23 mtime: Option<std::time::SystemTime>,
28 origin: Option<crate::core::context_store::task_signals::Origin>,
30}
31
32fn file_mtime(path: &str) -> Option<std::time::SystemTime> {
33 std::fs::metadata(path).and_then(|m| m.modified()).ok()
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ReadView {
46 Compressed,
47 Full,
48 Partial,
49}
50
51impl ReadView {
52 #[must_use]
59 pub fn of_mode(mode: &str) -> Self {
60 use crate::core::read_mode::ReadMode;
61 match mode.parse::<ReadMode>() {
62 Ok(
63 ReadMode::Full | ReadMode::FullCompact | ReadMode::Raw | ReadMode::Anchored(None),
64 ) => ReadView::Full,
65 Ok(
66 ReadMode::Anchored(Some(_))
67 | ReadMode::Lines(_)
68 | ReadMode::LinesMulti(_)
69 | ReadMode::LinesTail(_)
70 | ReadMode::Diff,
71 )
72 | Err(_) => ReadView::Partial,
73 Ok(
74 ReadMode::Signatures
75 | ReadMode::Map
76 | ReadMode::Aggressive
77 | ReadMode::Entropy
78 | ReadMode::Cognitive
79 | ReadMode::Mdl
80 | ReadMode::Task
81 | ReadMode::Reference
82 | ReadMode::Auto
83 | ReadMode::Density(_),
84 ) => ReadView::Compressed,
85 }
86 }
87}
88
89#[derive(Debug, Default)]
90struct BounceStats {
91 total_reads: u64,
92 bounces: u64,
93 wasted_tokens: usize,
94}
95
96#[derive(Debug, Clone)]
104struct PendingBounce {
105 path: String,
106 seq: u64,
108 wasted: usize,
109 origin: Option<crate::core::context_store::task_signals::Origin>,
110 detected_unix_ms: u64,
113 project_root: Option<String>,
116 mode: String,
118}
119
120#[derive(Debug, Default)]
121pub struct BounceTracker {
122 recent_reads: HashMap<String, Vec<ReadEvent>>,
123 per_extension: HashMap<String, BounceStats>,
124 recently_edited: HashMap<String, u64>,
125 pending: Vec<PendingBounce>,
126 seq_counter: u64,
127 total_bounces: u64,
128 total_wasted_tokens: usize,
129 persist: bool,
133}
134
135const MAX_PENDING_BOUNCES: usize = 256;
138
139fn extension_of(path: &str) -> String {
140 path.rsplit('.')
141 .next()
142 .map(|e| format!(".{}", e.to_ascii_lowercase()))
143 .unwrap_or_default()
144}
145
146impl BounceTracker {
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 pub fn next_seq(&mut self) -> u64 {
152 self.seq_counter += 1;
153 self.commit_due();
154 self.seq_counter
155 }
156
157 pub fn set_seq(&mut self, seq: u64) {
158 self.seq_counter = seq;
159 self.commit_due();
160 }
161
162 pub fn record_read(
163 &mut self,
164 path: &str,
165 mode: &str,
166 tokens_sent: usize,
167 original_tokens: usize,
168 ) {
169 let norm = crate::core::pathutil::normalize_tool_path(path);
170 self.commit_due();
171 let seq = self.seq_counter;
172 let view = ReadView::of_mode(mode);
173 let compressed = view == ReadView::Compressed;
174
175 if view == ReadView::Full {
176 self.detect_bounce(&norm, path, seq);
177 }
178 if self.persist && compressed {
179 {
181 use crate::core::hive_signals as signals;
182 signals::truncated(&signals::read_mode_filter(mode));
183 }
184 crate::core::path_mode_memory::record_compressed_read_if_tracked(&norm);
189 }
190
191 let current = if self.persist {
192 crate::core::context_store::task_signals::current_origin()
193 } else {
194 None
195 };
196 if let Some(current) = ¤t {
197 crate::core::context_store::task_signals::observe(current);
200 }
201 let origin = current.filter(|_| compressed);
202 self.push_event(
203 norm,
204 ReadEvent {
205 _mode: mode.to_string(),
206 tokens_sent,
207 _original_tokens: original_tokens,
208 seq,
209 view,
210 mtime: if compressed { file_mtime(path) } else { None },
211 origin,
212 },
213 );
214
215 let ext = extension_of(path);
216 if !ext.is_empty() {
217 let stats = self.per_extension.entry(ext).or_default();
218 stats.total_reads += 1;
219 }
220
221 self.prune_stale_paths();
222 }
223
224 fn push_event(&mut self, norm: String, event: ReadEvent) {
225 let events = self.recent_reads.entry(norm).or_default();
226 events.push(event);
227 if events.len() > 10 {
228 events.drain(..events.len() - 10);
229 }
230 }
231
232 fn detect_bounce(&mut self, norm_path: &str, fs_path: &str, full_seq: u64) {
236 if let Some(&edit_seq) = self.recently_edited.get(norm_path)
243 && full_seq.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
244 {
245 return;
246 }
247
248 let Some(events) = self.recent_reads.get(norm_path) else {
249 return;
250 };
251
252 if let Some(ev) = events.iter().next_back()
253 && ev.view == ReadView::Compressed
254 && full_seq.saturating_sub(ev.seq) <= BOUNCE_WINDOW
255 && (ev.mtime.is_none() || file_mtime(fs_path) == ev.mtime)
260 {
261 let pending = PendingBounce {
264 path: norm_path.to_string(),
265 seq: full_seq,
266 wasted: ev.tokens_sent,
267 origin: ev.origin.clone(),
268 detected_unix_ms: now_unix_ms(),
269 project_root: crate::core::scent_field::current_project(),
270 mode: ev._mode.clone(),
271 };
272 self.pending.push(pending);
273 if self.pending.len() > MAX_PENDING_BOUNCES {
274 let oldest = self.pending.remove(0);
275 self.commit(oldest);
276 }
277 }
278 }
279
280 fn commit_due(&mut self) {
283 if self.pending.is_empty() {
284 return;
285 }
286 let now = self.seq_counter;
287 let (due, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
288 .into_iter()
289 .partition(|p| now.saturating_sub(p.seq) > EDIT_FORCE_WINDOW);
290 self.pending = keep;
291 for pending in due {
292 self.commit(pending);
293 }
294 }
295
296 pub fn flush_pending(&mut self) {
299 for pending in std::mem::take(&mut self.pending) {
300 self.commit(pending);
301 }
302 }
303
304 pub fn pending_bounces(&self) -> usize {
306 self.pending.len()
307 }
308
309 fn commit(&mut self, pending: PendingBounce) {
314 if self.persist && host_edited_since(&pending.path, pending.detected_unix_ms) {
315 return;
316 }
317 let PendingBounce {
318 path: norm_path,
319 wasted,
320 origin,
321 project_root,
322 mode,
323 ..
324 } = pending;
325 self.total_bounces += 1;
326 self.total_wasted_tokens += wasted;
327
328 let ext = extension_of(&norm_path);
329 if !ext.is_empty() {
330 let stats = self.per_extension.entry(ext).or_default();
331 stats.bounces += 1;
332 stats.wasted_tokens += wasted;
333 }
334
335 if self.persist {
336 if let Some(origin) = origin {
337 std::thread::spawn(move || {
339 crate::core::context_store::task_signals::record(
340 &origin,
341 crate::core::context_store::task_signals::Signal::Bounce,
342 );
343 });
344 }
345 crate::core::savings_ledger::record_bounce_event(wasted);
346 {
348 use crate::core::hive_signals as signals;
349 signals::refetched(&signals::read_mode_filter(&mode));
350 }
351 crate::core::path_mode_memory::record_bounce(&norm_path);
354 if let Some(project_root) = project_root {
358 std::thread::spawn(move || {
359 crate::core::scent_field::deposit_for(
360 &project_root,
361 crate::core::scent_field::scent_agent_id(),
362 crate::core::scent_field::ScentKind::Stuck,
363 &norm_path,
364 0.5,
365 );
366 });
367 }
368 }
369 }
370
371 pub fn record_shell_read(&mut self, path: &str, view: ReadView) {
376 let norm = crate::core::pathutil::normalize_tool_path(path);
377 self.commit_due();
378 let seq = self.seq_counter;
379 if view == ReadView::Full {
380 self.detect_bounce(&norm, path, seq);
381 }
382 self.push_event(
383 norm,
384 ReadEvent {
385 _mode: "shell".to_string(),
386 tokens_sent: 0,
387 _original_tokens: 0,
388 seq,
389 view: if view == ReadView::Compressed {
391 ReadView::Partial
392 } else {
393 view
394 },
395 mtime: None,
396 origin: None,
397 },
398 );
399 self.prune_stale_paths();
400 }
401
402 pub fn record_expansion(&mut self, source: Option<&str>, wasted_tokens: usize) {
409 self.total_bounces = self.total_bounces.saturating_add(1);
410 self.total_wasted_tokens = self.total_wasted_tokens.saturating_add(wasted_tokens);
411
412 if let Some(path) = source {
413 let ext = extension_of(path);
414 if !ext.is_empty() {
415 let stats = self.per_extension.entry(ext).or_default();
416 stats.bounces = stats.bounces.saturating_add(1);
417 stats.wasted_tokens = stats.wasted_tokens.saturating_add(wasted_tokens);
418 }
419 }
420 }
421
422 pub fn last_read(
428 &self,
429 path: &str,
430 ) -> Option<(
431 bool,
432 Option<crate::core::context_store::task_signals::Origin>,
433 )> {
434 let norm = crate::core::pathutil::normalize_tool_path(path);
435 let event = self.recent_reads.get(&norm)?.last()?;
436 Some((event.view == ReadView::Compressed, event.origin.clone()))
437 }
438
439 pub fn record_edit(&mut self, path: &str) {
444 let norm = crate::core::pathutil::normalize_tool_path(path);
445 self.commit_due();
446 self.pending.retain(|p| p.path != norm);
447 self.recently_edited.insert(norm, self.seq_counter);
448 self.prune_stale_paths();
449 }
450
451 fn prune_stale_paths(&mut self) {
455 let seq = self.seq_counter;
456 self.recent_reads.retain(|_, events| {
457 events
458 .last()
459 .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
460 });
461 self.recently_edited
462 .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
463 }
464
465 pub fn should_force_full(&self, path: &str) -> bool {
466 let norm = crate::core::pathutil::normalize_tool_path(path);
467
468 if let Some(&edit_seq) = self.recently_edited.get(&norm)
469 && self.seq_counter.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
470 {
471 return true;
472 }
473
474 let ext = extension_of(path);
475 if !ext.is_empty()
476 && let Some(stats) = self.per_extension.get(&ext)
477 && stats.total_reads >= 3
478 {
479 let rate = stats.bounces as f64 / stats.total_reads as f64;
480 if rate >= BOUNCE_RATE_THRESHOLD {
481 return true;
482 }
483 }
484
485 false
486 }
487
488 pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
489 let ext = extension_of(path);
490 self.per_extension.get(&ext).and_then(|s| {
491 if s.total_reads >= 3 {
492 Some(s.bounces as f64 / s.total_reads as f64)
493 } else {
494 None
495 }
496 })
497 }
498
499 pub fn total_bounces(&self) -> u64 {
500 self.total_bounces
501 }
502
503 pub fn total_wasted_tokens(&self) -> usize {
504 self.total_wasted_tokens
505 }
506
507 pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
508 raw_savings as isize - self.total_wasted_tokens as isize
509 }
510
511 pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
512 let mut exts: Vec<_> = self
513 .per_extension
514 .iter()
515 .filter(|(_, s)| s.total_reads > 0)
516 .collect();
517 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
518 exts.iter()
519 .take(10)
520 .map(|(ext, stats)| {
521 let rate = if stats.total_reads > 0 {
522 stats.bounces as f64 / stats.total_reads as f64
523 } else {
524 0.0
525 };
526 serde_json::json!({
527 "ext": ext,
528 "reads": stats.total_reads,
529 "bounces": stats.bounces,
530 "wasted_tokens": stats.wasted_tokens,
531 "rate": (rate * 1000.0).round() / 1000.0,
532 })
533 })
534 .collect()
535 }
536
537 pub fn format_summary(&self) -> String {
538 if self.total_bounces == 0 {
539 return "Bounces: 0".to_string();
540 }
541 let mut lines = vec![format!(
542 "Bounces: {} ({} wasted tokens)",
543 self.total_bounces, self.total_wasted_tokens
544 )];
545 let mut exts: Vec<_> = self
546 .per_extension
547 .iter()
548 .filter(|(_, s)| s.bounces > 0)
549 .collect();
550 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
551 for (ext, stats) in exts.iter().take(5) {
552 let rate = if stats.total_reads > 0 {
553 stats.bounces as f64 / stats.total_reads as f64 * 100.0
554 } else {
555 0.0
556 };
557 lines.push(format!(
558 " {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
559 stats.bounces, stats.total_reads, stats.wasted_tokens,
560 ));
561 }
562 lines.join("\n")
563 }
564}
565
566static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
567
568pub fn global() -> &'static Mutex<BounceTracker> {
569 GLOBAL_TRACKER.get_or_init(|| {
570 let summary = crate::core::savings_ledger::summary();
573 let mut bt = BounceTracker::new();
574 bt.total_wasted_tokens = summary.bounce_tokens as usize;
575 bt.total_bounces = summary.bounce_events as u64;
576 bt.persist = true;
577 Mutex::new(bt)
578 })
579}
580
581pub fn flush_pending() {
584 if let Some(tracker) = GLOBAL_TRACKER.get()
585 && let Ok(mut bt) = tracker.lock()
586 {
587 bt.flush_pending();
588 }
589}
590
591fn now_unix_ms() -> u64 {
592 std::time::SystemTime::now()
593 .duration_since(std::time::UNIX_EPOCH)
594 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
595}
596
597const EDIT_BEACON_FILE: &str = "edit_beacon.log";
609const EDIT_BEACON_MAX_BYTES: u64 = 128 * 1024;
610
611fn edit_beacon_path() -> Option<std::path::PathBuf> {
612 crate::core::data_dir::lean_ctx_data_dir()
613 .ok()
614 .map(|dir| dir.join(EDIT_BEACON_FILE))
615}
616
617fn rotated_beacon_path(path: &std::path::Path) -> std::path::PathBuf {
618 path.with_extension("log.1")
619}
620
621pub fn note_host_edit(path: &str) {
623 let Some(beacon) = edit_beacon_path() else {
624 return;
625 };
626 append_edit_beacon(&beacon, path, now_unix_ms());
627}
628
629pub fn note_host_shell_edits(command: &str, cwd: &str) {
633 for path in crate::core::shell_file_effects::analyze(command).edits {
634 note_host_edit(&resolve_against(cwd, &path));
635 }
636}
637
638pub fn note_host_patch_edits(patch: &str, cwd: &str) {
640 for path in crate::core::shell_file_effects::apply_patch_targets(patch) {
641 note_host_edit(&resolve_against(cwd, &path));
642 }
643}
644
645fn resolve_against(cwd: &str, path: &str) -> String {
648 if std::path::Path::new(path).is_absolute() {
649 return path.to_string();
650 }
651 let base = if cwd.trim().is_empty() {
652 std::env::current_dir().ok()
653 } else {
654 Some(std::path::PathBuf::from(cwd))
655 };
656 base.map_or_else(
657 || path.to_string(),
658 |dir| dir.join(path).to_string_lossy().into_owned(),
659 )
660}
661
662fn append_edit_beacon(beacon: &std::path::Path, path: &str, unix_ms: u64) {
663 use std::io::Write;
664 let norm = crate::core::pathutil::normalize_tool_path(path);
665 if norm.is_empty() || norm.contains('\n') {
666 return;
667 }
668 if std::fs::metadata(beacon).is_ok_and(|m| m.len() > EDIT_BEACON_MAX_BYTES) {
669 let _ = std::fs::rename(beacon, rotated_beacon_path(beacon));
670 }
671 if let Some(parent) = beacon.parent() {
672 let _ = std::fs::create_dir_all(parent);
673 }
674 if let Ok(mut file) = std::fs::OpenOptions::new()
675 .create(true)
676 .append(true)
677 .open(beacon)
678 {
679 let _ = file.write_all(format!("{unix_ms}\t{norm}\n").as_bytes());
680 }
681}
682
683fn host_edited_since(norm_path: &str, since_unix_ms: u64) -> bool {
684 edit_beacon_path().is_some_and(|beacon| beacon_has_edit(&beacon, norm_path, since_unix_ms))
685}
686
687fn beacon_has_edit(beacon: &std::path::Path, norm_path: &str, since_unix_ms: u64) -> bool {
688 use std::io::Read;
689 [rotated_beacon_path(beacon), beacon.to_path_buf()]
690 .iter()
691 .any(|file| {
692 let Ok(handle) = std::fs::File::open(file) else {
693 return false;
694 };
695 let mut raw = String::new();
696 if handle
698 .take(EDIT_BEACON_MAX_BYTES * 2)
699 .read_to_string(&mut raw)
700 .is_err()
701 {
702 return false;
703 }
704 raw.lines().any(|line| {
705 line.split_once('\t').is_some_and(|(ts, path)| {
706 path == norm_path && ts.parse::<u64>().is_ok_and(|ts| ts >= since_unix_ms)
707 })
708 })
709 })
710}
711
712#[cfg(test)]
713pub mod tests {
714 use super::*;
715
716 #[test]
717 fn no_bounce_when_first_read_is_full() {
718 let mut bt = BounceTracker::new();
719 bt.seq_counter = 1;
720 bt.record_read("src/main.rs", "full", 500, 500);
721 assert_eq!(bt.total_bounces(), 0);
722 assert_eq!(bt.total_wasted_tokens(), 0);
723 }
724
725 #[test]
726 fn bounce_detected_on_compressed_then_full() {
727 let mut bt = BounceTracker::new();
728 bt.seq_counter = 1;
729 bt.record_read("src/main.rs", "map", 50, 500);
730 bt.seq_counter = 2;
731 bt.record_read("src/main.rs", "full", 500, 500);
732 assert_eq!(
733 bt.pending_bounces(),
734 1,
735 "provisional until the edit window passes"
736 );
737 bt.flush_pending();
738 assert_eq!(bt.total_bounces(), 1);
739 assert_eq!(bt.total_wasted_tokens(), 50);
740 }
741
742 #[test]
743 fn full_reread_of_externally_edited_file_is_not_a_bounce() {
744 let dir = tempfile::tempdir().unwrap();
747 let file = dir.path().join("edited.rs");
748 std::fs::write(&file, "fn a() {}\n").unwrap();
749 let path = file.to_str().unwrap();
750 let old = std::time::SystemTime::now() - std::time::Duration::from_mins(2);
751 std::fs::File::options()
752 .write(true)
753 .open(&file)
754 .unwrap()
755 .set_times(std::fs::FileTimes::new().set_modified(old))
756 .unwrap();
757
758 let mut bt = BounceTracker::new();
759 bt.seq_counter = 1;
760 bt.record_read(path, "map", 40, 500);
761 std::fs::write(&file, "fn a() { changed() }\n").unwrap();
762 bt.seq_counter = 2;
763 bt.record_read(path, "full", 500, 500);
764 bt.flush_pending();
765 assert_eq!(bt.total_bounces(), 0, "re-read after an edit is no bounce");
766
767 bt.seq_counter = 10;
769 bt.record_read(path, "map", 40, 500);
770 bt.seq_counter = 11;
771 bt.record_read(path, "full", 500, 500);
772 bt.flush_pending();
773 assert_eq!(bt.total_bounces(), 1);
774 }
775
776 #[test]
777 fn no_bounce_outside_window() {
778 let mut bt = BounceTracker::new();
779 bt.seq_counter = 1;
780 bt.record_read("src/main.rs", "map", 50, 500);
781 bt.seq_counter = 10;
782 bt.record_read("src/main.rs", "full", 500, 500);
783 assert_eq!(bt.total_bounces(), 0);
784 }
785
786 #[test]
787 fn shell_access_triggers_bounce() {
788 let mut bt = BounceTracker::new();
789 bt.seq_counter = 1;
790 bt.record_read("config.yml", "signatures", 30, 400);
791 bt.seq_counter = 3;
792 bt.record_shell_read("config.yml", ReadView::Full);
793 bt.seq_counter = 4;
795 bt.record_shell_read("config.yml", ReadView::Full);
796 bt.flush_pending();
797 assert_eq!(bt.total_bounces(), 1);
798 assert_eq!(bt.total_wasted_tokens(), 30);
799 }
800
801 #[test]
802 fn read_view_classification() {
803 for mode in ["full", "full-compact", "raw", "anchored"] {
804 assert_eq!(ReadView::of_mode(mode), ReadView::Full, "{mode}");
805 }
806 for mode in [
807 "lines:10-40",
808 "lines:5,10-20",
809 "lines:-30",
810 "anchored:10-20",
811 "diff",
812 "bogus",
813 ] {
814 assert_eq!(ReadView::of_mode(mode), ReadView::Partial, "{mode}");
815 }
816 for mode in [
817 "map",
818 "signatures",
819 "aggressive",
820 "entropy",
821 "task",
822 "reference",
823 "cognitive",
824 "mdl",
825 "auto",
826 "density:0.40",
827 ] {
828 assert_eq!(ReadView::of_mode(mode), ReadView::Compressed, "{mode}");
829 }
830 }
831
832 #[test]
835 fn lines_then_full_is_not_a_bounce() {
836 let mut bt = BounceTracker::new();
837 bt.next_seq();
838 bt.record_read("docs/guide.md", "lines:1-80", 300, 2000);
839 bt.next_seq();
840 bt.record_read("docs/guide.md", "lines:81-160", 300, 2000);
841 bt.next_seq();
842 bt.record_read("docs/guide.md", "full", 2000, 2000);
843 bt.next_seq();
844 bt.record_shell_read("docs/guide.md", ReadView::Full);
845 bt.flush_pending();
846 assert_eq!(bt.total_bounces(), 0);
847 }
848
849 #[test]
850 fn map_then_head_is_not_a_bounce() {
851 let mut bt = BounceTracker::new();
852 bt.next_seq();
853 bt.record_read("src/a.rs", "map", 40, 500);
854 bt.next_seq();
855 bt.record_shell_read("src/a.rs", ReadView::Partial);
856 assert_eq!(bt.pending_bounces(), 0);
857 bt.next_seq();
859 bt.record_shell_read("src/a.rs", ReadView::Full);
860 bt.flush_pending();
861 assert_eq!(bt.total_bounces(), 0);
862 }
863
864 #[test]
865 fn map_then_lines_window_is_not_a_bounce() {
866 let mut bt = BounceTracker::new();
867 bt.next_seq();
868 bt.record_read("src/a.rs", "map", 40, 500);
869 bt.next_seq();
870 bt.record_read("src/a.rs", "lines:20-60", 120, 500);
871 bt.flush_pending();
872 assert_eq!(bt.total_bounces(), 0);
873 }
874
875 #[test]
876 fn map_then_cat_is_a_bounce_unless_edited() {
877 let mut bt = BounceTracker::new();
878 bt.next_seq();
879 bt.record_read("src/a.rs", "map", 40, 500);
880 bt.next_seq();
881 bt.record_shell_read("src/a.rs", ReadView::Full);
882 assert_eq!(bt.pending_bounces(), 1);
883 bt.flush_pending();
884 assert_eq!(bt.total_bounces(), 1);
885 assert_eq!(bt.total_wasted_tokens(), 40);
886
887 let mut edited = BounceTracker::new();
888 edited.next_seq();
889 edited.record_read("src/b.rs", "map", 40, 500);
890 edited.next_seq();
891 edited.record_shell_read("src/b.rs", ReadView::Full);
892 edited.next_seq();
893 edited.record_edit("src/b.rs");
895 edited.flush_pending();
896 assert_eq!(edited.total_bounces(), 0);
897 }
898
899 #[test]
902 fn anchored_is_full_content() {
903 let mut bt = BounceTracker::new();
904 bt.next_seq();
905 bt.record_read("src/a.rs", "anchored", 500, 500);
906 bt.next_seq();
907 bt.record_read("src/a.rs", "full", 500, 500);
908 assert_eq!(bt.pending_bounces(), 0, "anchored→full is no correction");
909
910 bt.next_seq();
911 bt.record_read("src/a.rs", "map", 40, 500);
912 bt.next_seq();
913 bt.record_read("src/a.rs", "anchored", 500, 500);
914 assert_eq!(bt.pending_bounces(), 1);
915 bt.next_seq();
916 bt.record_edit("src/a.rs");
917 bt.flush_pending();
918 assert_eq!(bt.total_bounces(), 0);
919 }
920
921 #[test]
922 fn diff_never_completes_a_correction() {
923 let mut bt = BounceTracker::new();
924 bt.next_seq();
925 bt.record_read("src/a.rs", "map", 40, 500);
926 bt.next_seq();
927 bt.record_read("src/a.rs", "diff", 20, 500);
928 bt.flush_pending();
929 assert_eq!(bt.total_bounces(), 0);
930 }
931
932 #[test]
933 fn partial_read_is_not_charged_as_compressed() {
934 let mut bt = BounceTracker::new();
935 bt.next_seq();
936 bt.record_read("src/a.rs", "lines:1-20", 100, 500);
937 assert_eq!(bt.last_read("src/a.rs").map(|(c, _)| c), Some(false));
938 bt.next_seq();
939 bt.record_read("src/a.rs", "map", 40, 500);
940 assert_eq!(bt.last_read("src/a.rs").map(|(c, _)| c), Some(true));
941 }
942
943 #[test]
944 fn explicit_expansion_tracks_quality_and_waste() {
945 let mut bt = BounceTracker::new();
946 bt.record_expansion(Some("src/main.rs"), 125);
947 assert_eq!(bt.total_bounces(), 1);
948 assert_eq!(bt.total_wasted_tokens(), 125);
949 }
950
951 #[test]
952 fn edit_forced_full_read_is_not_a_bounce() {
953 let mut bt = BounceTracker::new();
957 bt.seq_counter = 1;
958 bt.record_read("src/lib.rs", "map", 40, 500);
959 bt.seq_counter = 2;
960 bt.record_edit("src/lib.rs");
961 bt.seq_counter = 4;
962 bt.record_read("src/lib.rs", "full", 500, 500);
963 assert_eq!(
964 bt.total_bounces(),
965 0,
966 "edit-forced full read must not count as a compression bounce"
967 );
968 }
969
970 #[test]
971 fn full_read_without_edit_still_bounces() {
972 let mut bt = BounceTracker::new();
975 bt.seq_counter = 1;
976 bt.record_read("src/lib.rs", "map", 40, 500);
977 bt.seq_counter = 3;
978 bt.record_read("src/lib.rs", "full", 500, 500);
979 bt.flush_pending();
980 assert_eq!(bt.total_bounces(), 1);
981 }
982
983 #[test]
984 fn should_force_full_after_edit() {
985 let mut bt = BounceTracker::new();
986 bt.seq_counter = 5;
987 bt.record_edit("src/lib.rs");
988 bt.seq_counter = 8;
989 assert!(bt.should_force_full("src/lib.rs"));
990 bt.seq_counter = 20;
991 assert!(!bt.should_force_full("src/lib.rs"));
992 }
993
994 #[test]
995 fn should_force_full_by_extension_bounce_rate() {
996 let mut bt = BounceTracker::new();
997 for i in 1..=6 {
998 bt.seq_counter = i * 2 - 1;
999 bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
1000 bt.seq_counter = i * 2;
1001 bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
1002 }
1003 bt.flush_pending();
1004 assert!(bt.should_force_full("new.yml"));
1005 }
1006
1007 #[test]
1008 fn adjusted_savings_subtracts_waste() {
1009 let mut bt = BounceTracker::new();
1010 bt.seq_counter = 1;
1011 bt.record_read("a.rs", "map", 50, 500);
1012 bt.seq_counter = 2;
1013 bt.record_read("a.rs", "full", 500, 500);
1014 bt.flush_pending();
1015 assert_eq!(bt.adjusted_savings(1000), 950);
1016 }
1017
1018 #[test]
1019 fn bounce_rate_for_extension_below_minimum() {
1020 let bt = BounceTracker::new();
1021 assert!(bt.bounce_rate_for_extension("test.rs").is_none());
1022 }
1023
1024 #[test]
1025 fn format_summary_empty() {
1026 let bt = BounceTracker::new();
1027 assert_eq!(bt.format_summary(), "Bounces: 0");
1028 }
1029
1030 #[test]
1031 fn map_full_edit_is_not_a_bounce() {
1032 let mut bt = BounceTracker::new();
1035 bt.next_seq();
1036 bt.record_read("src/edit_me.rs", "map", 40, 500);
1037 bt.next_seq();
1038 bt.record_read("src/edit_me.rs", "full", 500, 500);
1039 assert_eq!(bt.pending_bounces(), 1);
1040 for _ in 0..3 {
1041 bt.next_seq();
1042 }
1043 bt.record_edit("src/edit_me.rs");
1044 assert_eq!(
1045 bt.pending_bounces(),
1046 0,
1047 "the edit cancels the provisional bounce"
1048 );
1049 for _ in 0..(EDIT_FORCE_WINDOW + 5) {
1050 bt.next_seq();
1051 }
1052 bt.flush_pending();
1053 assert_eq!(bt.total_bounces(), 0);
1054 assert_eq!(bt.total_wasted_tokens(), 0);
1055 }
1056
1057 #[test]
1058 fn map_full_without_edit_commits_when_window_passes() {
1059 let mut bt = BounceTracker::new();
1060 bt.next_seq();
1061 bt.record_read("src/only_read.rs", "map", 40, 500);
1062 bt.next_seq();
1063 bt.record_read("src/only_read.rs", "full", 500, 500);
1064 for _ in 0..EDIT_FORCE_WINDOW {
1065 bt.next_seq();
1066 }
1067 assert_eq!(bt.total_bounces(), 0, "still inside the edit window");
1068 bt.next_seq();
1069 assert_eq!(bt.pending_bounces(), 0);
1070 assert_eq!(bt.total_bounces(), 1, "committed once the window passed");
1071 assert_eq!(bt.total_wasted_tokens(), 40);
1072 bt.record_edit("src/only_read.rs");
1074 assert_eq!(bt.total_bounces(), 1);
1075 }
1076
1077 #[test]
1078 fn edit_of_another_path_keeps_the_bounce() {
1079 let mut bt = BounceTracker::new();
1080 bt.next_seq();
1081 bt.record_read("src/a.rs", "map", 40, 500);
1082 bt.next_seq();
1083 bt.record_read("src/a.rs", "full", 500, 500);
1084 bt.record_edit("src/b.rs");
1085 bt.flush_pending();
1086 assert_eq!(bt.total_bounces(), 1);
1087 }
1088
1089 #[test]
1090 fn flush_pending_commits_at_shutdown() {
1091 let mut bt = BounceTracker::new();
1092 bt.next_seq();
1093 bt.record_read("src/late.rs", "signatures", 25, 300);
1094 bt.next_seq();
1095 bt.record_read("src/late.rs", "full", 300, 300);
1096 assert_eq!(bt.total_bounces(), 0);
1097 bt.flush_pending();
1098 assert_eq!(bt.pending_bounces(), 0);
1099 assert_eq!(bt.total_bounces(), 1);
1100 assert_eq!(bt.total_wasted_tokens(), 25);
1101 }
1102
1103 #[test]
1104 fn pending_queue_is_bounded() {
1105 let mut bt = BounceTracker::new();
1106 for i in 0..(MAX_PENDING_BOUNCES + 4) {
1107 let path = format!("src/burst{i}.rs");
1108 bt.record_read(&path, "map", 10, 100);
1109 bt.record_read(&path, "full", 100, 100);
1110 }
1111 assert_eq!(bt.pending_bounces(), MAX_PENDING_BOUNCES);
1112 assert_eq!(bt.total_bounces(), 4, "overflow commits the oldest early");
1113 }
1114
1115 #[test]
1116 fn edit_beacon_reports_host_edits_after_detection() {
1117 let dir = tempfile::tempdir().unwrap();
1118 let beacon = dir.path().join(EDIT_BEACON_FILE);
1119 let file = dir.path().join("edited.rs");
1120 std::fs::write(&file, "fn main() {}\n").unwrap();
1121 let path = file.to_string_lossy().to_string();
1122 let norm = crate::core::pathutil::normalize_tool_path(&path);
1123
1124 assert!(!beacon_has_edit(&beacon, &norm, 0), "no beacon yet");
1125 append_edit_beacon(&beacon, &path, 2_000);
1126 assert!(
1127 beacon_has_edit(&beacon, &norm, 1_500),
1128 "edit after detection"
1129 );
1130 assert!(
1131 !beacon_has_edit(&beacon, &norm, 2_500),
1132 "edit before detection"
1133 );
1134 assert!(!beacon_has_edit(&beacon, "/other/path.rs", 0));
1135 }
1136
1137 #[test]
1138 fn edit_beacon_rotation_keeps_the_previous_generation_readable() {
1139 let dir = tempfile::tempdir().unwrap();
1140 let beacon = dir.path().join(EDIT_BEACON_FILE);
1141 append_edit_beacon(&beacon, "/rotated/first.rs", 10);
1142 let filler = "x".repeat(usize::try_from(EDIT_BEACON_MAX_BYTES).unwrap() + 1);
1143 std::fs::OpenOptions::new()
1144 .append(true)
1145 .open(&beacon)
1146 .and_then(|mut f| std::io::Write::write_all(&mut f, filler.as_bytes()))
1147 .unwrap();
1148 append_edit_beacon(&beacon, "/rotated/second.rs", 20);
1149 assert!(rotated_beacon_path(&beacon).exists());
1150 assert!(beacon_has_edit(&beacon, "/rotated/first.rs", 0));
1151 assert!(beacon_has_edit(&beacon, "/rotated/second.rs", 0));
1152 }
1153}