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 was_compressed: bool,
23}
24
25#[derive(Debug, Default)]
26struct BounceStats {
27 total_reads: u64,
28 bounces: u64,
29 wasted_tokens: usize,
30}
31
32#[derive(Debug, Default)]
33pub struct BounceTracker {
34 recent_reads: HashMap<String, Vec<ReadEvent>>,
35 per_extension: HashMap<String, BounceStats>,
36 recently_edited: HashMap<String, u64>,
37 seq_counter: u64,
38 total_bounces: u64,
39 total_wasted_tokens: usize,
40 persist: bool,
44}
45
46fn is_compressed_mode(mode: &str) -> bool {
47 !matches!(mode, "full" | "diff")
48}
49
50fn extension_of(path: &str) -> String {
51 path.rsplit('.')
52 .next()
53 .map(|e| format!(".{}", e.to_ascii_lowercase()))
54 .unwrap_or_default()
55}
56
57impl BounceTracker {
58 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn next_seq(&mut self) -> u64 {
63 self.seq_counter += 1;
64 self.seq_counter
65 }
66
67 pub fn set_seq(&mut self, seq: u64) {
68 self.seq_counter = seq;
69 }
70
71 pub fn record_read(
72 &mut self,
73 path: &str,
74 mode: &str,
75 tokens_sent: usize,
76 original_tokens: usize,
77 ) {
78 let norm = crate::core::pathutil::normalize_tool_path(path);
79 let seq = self.seq_counter;
80 let compressed = is_compressed_mode(mode);
81
82 if !compressed {
83 self.detect_bounce(&norm, seq);
84 }
85 if self.persist {
86 crate::core::path_mode_memory::record_read_if_tracked(&norm);
89 }
90
91 let events = self.recent_reads.entry(norm).or_default();
92 events.push(ReadEvent {
93 _mode: mode.to_string(),
94 tokens_sent,
95 _original_tokens: original_tokens,
96 seq,
97 was_compressed: compressed,
98 });
99
100 if events.len() > 10 {
101 events.drain(..events.len() - 10);
102 }
103
104 let ext = extension_of(path);
105 if !ext.is_empty() {
106 let stats = self.per_extension.entry(ext).or_default();
107 stats.total_reads += 1;
108 }
109
110 self.prune_stale_paths();
111 }
112
113 fn detect_bounce(&mut self, norm_path: &str, full_seq: u64) {
114 if let Some(&edit_seq) = self.recently_edited.get(norm_path)
121 && full_seq.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
122 {
123 return;
124 }
125
126 let Some(events) = self.recent_reads.get(norm_path) else {
127 return;
128 };
129
130 if let Some(ev) = events.iter().next_back()
131 && ev.was_compressed
132 && full_seq.saturating_sub(ev.seq) <= BOUNCE_WINDOW
133 {
134 let wasted = ev.tokens_sent;
135 self.total_bounces += 1;
136 self.total_wasted_tokens += wasted;
137
138 let ext = extension_of(norm_path);
139 if !ext.is_empty() {
140 let stats = self.per_extension.entry(ext).or_default();
141 stats.bounces += 1;
142 stats.wasted_tokens += wasted;
143 }
144
145 if self.persist {
146 crate::core::savings_ledger::record_bounce_event(wasted);
147 crate::core::path_mode_memory::record_bounce(norm_path);
150 crate::core::adaptive_thresholds::record_quality_signal(
154 norm_path,
155 crate::core::threshold_learning::QualitySignal::Bounce,
156 );
157 let scent_path = norm_path.to_string();
160 std::thread::spawn(move || {
161 crate::core::scent_field::deposit(
162 crate::core::scent_field::scent_agent_id(),
163 crate::core::scent_field::ScentKind::Stuck,
164 &scent_path,
165 0.5,
166 );
167 });
168 }
169 }
170 }
171
172 pub fn record_shell_file_access(&mut self, path: &str) {
173 let norm = crate::core::pathutil::normalize_tool_path(path);
174 let seq = self.seq_counter;
175 self.detect_bounce(&norm, seq);
176 }
177
178 pub fn record_expansion(&mut self, source: Option<&str>, wasted_tokens: usize) {
185 self.total_bounces = self.total_bounces.saturating_add(1);
186 self.total_wasted_tokens = self.total_wasted_tokens.saturating_add(wasted_tokens);
187
188 if let Some(path) = source {
189 let ext = extension_of(path);
190 if !ext.is_empty() {
191 let stats = self.per_extension.entry(ext).or_default();
192 stats.bounces = stats.bounces.saturating_add(1);
193 stats.wasted_tokens = stats.wasted_tokens.saturating_add(wasted_tokens);
194 }
195 }
196 }
197
198 pub fn record_edit(&mut self, path: &str) {
199 let norm = crate::core::pathutil::normalize_tool_path(path);
200 self.recently_edited.insert(norm, self.seq_counter);
201 self.prune_stale_paths();
202 }
203
204 fn prune_stale_paths(&mut self) {
208 let seq = self.seq_counter;
209 self.recent_reads.retain(|_, events| {
210 events
211 .last()
212 .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
213 });
214 self.recently_edited
215 .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
216 }
217
218 pub fn should_force_full(&self, path: &str) -> bool {
219 let norm = crate::core::pathutil::normalize_tool_path(path);
220
221 if let Some(&edit_seq) = self.recently_edited.get(&norm)
222 && self.seq_counter.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
223 {
224 return true;
225 }
226
227 let ext = extension_of(path);
228 if !ext.is_empty()
229 && let Some(stats) = self.per_extension.get(&ext)
230 && stats.total_reads >= 3
231 {
232 let rate = stats.bounces as f64 / stats.total_reads as f64;
233 if rate >= BOUNCE_RATE_THRESHOLD {
234 return true;
235 }
236 }
237
238 false
239 }
240
241 pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
242 let ext = extension_of(path);
243 self.per_extension.get(&ext).and_then(|s| {
244 if s.total_reads >= 3 {
245 Some(s.bounces as f64 / s.total_reads as f64)
246 } else {
247 None
248 }
249 })
250 }
251
252 pub fn total_bounces(&self) -> u64 {
253 self.total_bounces
254 }
255
256 pub fn total_wasted_tokens(&self) -> usize {
257 self.total_wasted_tokens
258 }
259
260 pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
261 raw_savings as isize - self.total_wasted_tokens as isize
262 }
263
264 pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
265 let mut exts: Vec<_> = self
266 .per_extension
267 .iter()
268 .filter(|(_, s)| s.total_reads > 0)
269 .collect();
270 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
271 exts.iter()
272 .take(10)
273 .map(|(ext, stats)| {
274 let rate = if stats.total_reads > 0 {
275 stats.bounces as f64 / stats.total_reads as f64
276 } else {
277 0.0
278 };
279 serde_json::json!({
280 "ext": ext,
281 "reads": stats.total_reads,
282 "bounces": stats.bounces,
283 "wasted_tokens": stats.wasted_tokens,
284 "rate": (rate * 1000.0).round() / 1000.0,
285 })
286 })
287 .collect()
288 }
289
290 pub fn format_summary(&self) -> String {
291 if self.total_bounces == 0 {
292 return "Bounces: 0".to_string();
293 }
294 let mut lines = vec![format!(
295 "Bounces: {} ({} wasted tokens)",
296 self.total_bounces, self.total_wasted_tokens
297 )];
298 let mut exts: Vec<_> = self
299 .per_extension
300 .iter()
301 .filter(|(_, s)| s.bounces > 0)
302 .collect();
303 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
304 for (ext, stats) in exts.iter().take(5) {
305 let rate = if stats.total_reads > 0 {
306 stats.bounces as f64 / stats.total_reads as f64 * 100.0
307 } else {
308 0.0
309 };
310 lines.push(format!(
311 " {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
312 stats.bounces, stats.total_reads, stats.wasted_tokens,
313 ));
314 }
315 lines.join("\n")
316 }
317}
318
319static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
320
321pub fn global() -> &'static Mutex<BounceTracker> {
322 GLOBAL_TRACKER.get_or_init(|| {
323 let summary = crate::core::savings_ledger::summary();
326 let mut bt = BounceTracker::new();
327 bt.total_wasted_tokens = summary.bounce_tokens as usize;
328 bt.total_bounces = summary.bounce_events as u64;
329 bt.persist = true;
330 Mutex::new(bt)
331 })
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn no_bounce_when_first_read_is_full() {
340 let mut bt = BounceTracker::new();
341 bt.seq_counter = 1;
342 bt.record_read("src/main.rs", "full", 500, 500);
343 assert_eq!(bt.total_bounces(), 0);
344 assert_eq!(bt.total_wasted_tokens(), 0);
345 }
346
347 #[test]
348 fn bounce_detected_on_compressed_then_full() {
349 let mut bt = BounceTracker::new();
350 bt.seq_counter = 1;
351 bt.record_read("src/main.rs", "map", 50, 500);
352 bt.seq_counter = 2;
353 bt.record_read("src/main.rs", "full", 500, 500);
354 assert_eq!(bt.total_bounces(), 1);
355 assert_eq!(bt.total_wasted_tokens(), 50);
356 }
357
358 #[test]
359 fn no_bounce_outside_window() {
360 let mut bt = BounceTracker::new();
361 bt.seq_counter = 1;
362 bt.record_read("src/main.rs", "map", 50, 500);
363 bt.seq_counter = 10;
364 bt.record_read("src/main.rs", "full", 500, 500);
365 assert_eq!(bt.total_bounces(), 0);
366 }
367
368 #[test]
369 fn shell_access_triggers_bounce() {
370 let mut bt = BounceTracker::new();
371 bt.seq_counter = 1;
372 bt.record_read("config.yml", "signatures", 30, 400);
373 bt.seq_counter = 3;
374 bt.record_shell_file_access("config.yml");
375 assert_eq!(bt.total_bounces(), 1);
376 assert_eq!(bt.total_wasted_tokens(), 30);
377 }
378
379 #[test]
380 fn explicit_expansion_tracks_quality_and_waste() {
381 let mut bt = BounceTracker::new();
382 bt.record_expansion(Some("src/main.rs"), 125);
383 assert_eq!(bt.total_bounces(), 1);
384 assert_eq!(bt.total_wasted_tokens(), 125);
385 }
386
387 #[test]
388 fn edit_forced_full_read_is_not_a_bounce() {
389 let mut bt = BounceTracker::new();
393 bt.seq_counter = 1;
394 bt.record_read("src/lib.rs", "map", 40, 500);
395 bt.seq_counter = 2;
396 bt.record_edit("src/lib.rs");
397 bt.seq_counter = 4;
398 bt.record_read("src/lib.rs", "full", 500, 500);
399 assert_eq!(
400 bt.total_bounces(),
401 0,
402 "edit-forced full read must not count as a compression bounce"
403 );
404 }
405
406 #[test]
407 fn full_read_without_edit_still_bounces() {
408 let mut bt = BounceTracker::new();
411 bt.seq_counter = 1;
412 bt.record_read("src/lib.rs", "map", 40, 500);
413 bt.seq_counter = 3;
414 bt.record_read("src/lib.rs", "full", 500, 500);
415 assert_eq!(bt.total_bounces(), 1);
416 }
417
418 #[test]
419 fn should_force_full_after_edit() {
420 let mut bt = BounceTracker::new();
421 bt.seq_counter = 5;
422 bt.record_edit("src/lib.rs");
423 bt.seq_counter = 8;
424 assert!(bt.should_force_full("src/lib.rs"));
425 bt.seq_counter = 20;
426 assert!(!bt.should_force_full("src/lib.rs"));
427 }
428
429 #[test]
430 fn should_force_full_by_extension_bounce_rate() {
431 let mut bt = BounceTracker::new();
432 for i in 1..=6 {
433 bt.seq_counter = i * 2 - 1;
434 bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
435 bt.seq_counter = i * 2;
436 bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
437 }
438 assert!(bt.should_force_full("new.yml"));
439 }
440
441 #[test]
442 fn adjusted_savings_subtracts_waste() {
443 let mut bt = BounceTracker::new();
444 bt.seq_counter = 1;
445 bt.record_read("a.rs", "map", 50, 500);
446 bt.seq_counter = 2;
447 bt.record_read("a.rs", "full", 500, 500);
448 assert_eq!(bt.adjusted_savings(1000), 950);
449 }
450
451 #[test]
452 fn bounce_rate_for_extension_below_minimum() {
453 let bt = BounceTracker::new();
454 assert!(bt.bounce_rate_for_extension("test.rs").is_none());
455 }
456
457 #[test]
458 fn format_summary_empty() {
459 let bt = BounceTracker::new();
460 assert_eq!(bt.format_summary(), "Bounces: 0");
461 }
462}