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_edit(&mut self, path: &str) {
179 let norm = crate::core::pathutil::normalize_tool_path(path);
180 self.recently_edited.insert(norm, self.seq_counter);
181 self.prune_stale_paths();
182 }
183
184 fn prune_stale_paths(&mut self) {
188 let seq = self.seq_counter;
189 self.recent_reads.retain(|_, events| {
190 events
191 .last()
192 .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
193 });
194 self.recently_edited
195 .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
196 }
197
198 pub fn should_force_full(&self, path: &str) -> bool {
199 let norm = crate::core::pathutil::normalize_tool_path(path);
200
201 if let Some(&edit_seq) = self.recently_edited.get(&norm)
202 && self.seq_counter.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
203 {
204 return true;
205 }
206
207 let ext = extension_of(path);
208 if !ext.is_empty()
209 && let Some(stats) = self.per_extension.get(&ext)
210 && stats.total_reads >= 3
211 {
212 let rate = stats.bounces as f64 / stats.total_reads as f64;
213 if rate >= BOUNCE_RATE_THRESHOLD {
214 return true;
215 }
216 }
217
218 false
219 }
220
221 pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
222 let ext = extension_of(path);
223 self.per_extension.get(&ext).and_then(|s| {
224 if s.total_reads >= 3 {
225 Some(s.bounces as f64 / s.total_reads as f64)
226 } else {
227 None
228 }
229 })
230 }
231
232 pub fn total_bounces(&self) -> u64 {
233 self.total_bounces
234 }
235
236 pub fn total_wasted_tokens(&self) -> usize {
237 self.total_wasted_tokens
238 }
239
240 pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
241 raw_savings as isize - self.total_wasted_tokens as isize
242 }
243
244 pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
245 let mut exts: Vec<_> = self
246 .per_extension
247 .iter()
248 .filter(|(_, s)| s.total_reads > 0)
249 .collect();
250 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
251 exts.iter()
252 .take(10)
253 .map(|(ext, stats)| {
254 let rate = if stats.total_reads > 0 {
255 stats.bounces as f64 / stats.total_reads as f64
256 } else {
257 0.0
258 };
259 serde_json::json!({
260 "ext": ext,
261 "reads": stats.total_reads,
262 "bounces": stats.bounces,
263 "wasted_tokens": stats.wasted_tokens,
264 "rate": (rate * 1000.0).round() / 1000.0,
265 })
266 })
267 .collect()
268 }
269
270 pub fn format_summary(&self) -> String {
271 if self.total_bounces == 0 {
272 return "Bounces: 0".to_string();
273 }
274 let mut lines = vec![format!(
275 "Bounces: {} ({} wasted tokens)",
276 self.total_bounces, self.total_wasted_tokens
277 )];
278 let mut exts: Vec<_> = self
279 .per_extension
280 .iter()
281 .filter(|(_, s)| s.bounces > 0)
282 .collect();
283 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
284 for (ext, stats) in exts.iter().take(5) {
285 let rate = if stats.total_reads > 0 {
286 stats.bounces as f64 / stats.total_reads as f64 * 100.0
287 } else {
288 0.0
289 };
290 lines.push(format!(
291 " {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
292 stats.bounces, stats.total_reads, stats.wasted_tokens,
293 ));
294 }
295 lines.join("\n")
296 }
297}
298
299static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
300
301pub fn global() -> &'static Mutex<BounceTracker> {
302 GLOBAL_TRACKER.get_or_init(|| {
303 let summary = crate::core::savings_ledger::summary();
306 let mut bt = BounceTracker::new();
307 bt.total_wasted_tokens = summary.bounce_tokens as usize;
308 bt.total_bounces = summary.bounce_events as u64;
309 bt.persist = true;
310 Mutex::new(bt)
311 })
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn no_bounce_when_first_read_is_full() {
320 let mut bt = BounceTracker::new();
321 bt.seq_counter = 1;
322 bt.record_read("src/main.rs", "full", 500, 500);
323 assert_eq!(bt.total_bounces(), 0);
324 assert_eq!(bt.total_wasted_tokens(), 0);
325 }
326
327 #[test]
328 fn bounce_detected_on_compressed_then_full() {
329 let mut bt = BounceTracker::new();
330 bt.seq_counter = 1;
331 bt.record_read("src/main.rs", "map", 50, 500);
332 bt.seq_counter = 2;
333 bt.record_read("src/main.rs", "full", 500, 500);
334 assert_eq!(bt.total_bounces(), 1);
335 assert_eq!(bt.total_wasted_tokens(), 50);
336 }
337
338 #[test]
339 fn no_bounce_outside_window() {
340 let mut bt = BounceTracker::new();
341 bt.seq_counter = 1;
342 bt.record_read("src/main.rs", "map", 50, 500);
343 bt.seq_counter = 10;
344 bt.record_read("src/main.rs", "full", 500, 500);
345 assert_eq!(bt.total_bounces(), 0);
346 }
347
348 #[test]
349 fn shell_access_triggers_bounce() {
350 let mut bt = BounceTracker::new();
351 bt.seq_counter = 1;
352 bt.record_read("config.yml", "signatures", 30, 400);
353 bt.seq_counter = 3;
354 bt.record_shell_file_access("config.yml");
355 assert_eq!(bt.total_bounces(), 1);
356 assert_eq!(bt.total_wasted_tokens(), 30);
357 }
358
359 #[test]
360 fn edit_forced_full_read_is_not_a_bounce() {
361 let mut bt = BounceTracker::new();
365 bt.seq_counter = 1;
366 bt.record_read("src/lib.rs", "map", 40, 500);
367 bt.seq_counter = 2;
368 bt.record_edit("src/lib.rs");
369 bt.seq_counter = 4;
370 bt.record_read("src/lib.rs", "full", 500, 500);
371 assert_eq!(
372 bt.total_bounces(),
373 0,
374 "edit-forced full read must not count as a compression bounce"
375 );
376 }
377
378 #[test]
379 fn full_read_without_edit_still_bounces() {
380 let mut bt = BounceTracker::new();
383 bt.seq_counter = 1;
384 bt.record_read("src/lib.rs", "map", 40, 500);
385 bt.seq_counter = 3;
386 bt.record_read("src/lib.rs", "full", 500, 500);
387 assert_eq!(bt.total_bounces(), 1);
388 }
389
390 #[test]
391 fn should_force_full_after_edit() {
392 let mut bt = BounceTracker::new();
393 bt.seq_counter = 5;
394 bt.record_edit("src/lib.rs");
395 bt.seq_counter = 8;
396 assert!(bt.should_force_full("src/lib.rs"));
397 bt.seq_counter = 20;
398 assert!(!bt.should_force_full("src/lib.rs"));
399 }
400
401 #[test]
402 fn should_force_full_by_extension_bounce_rate() {
403 let mut bt = BounceTracker::new();
404 for i in 1..=6 {
405 bt.seq_counter = i * 2 - 1;
406 bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
407 bt.seq_counter = i * 2;
408 bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
409 }
410 assert!(bt.should_force_full("new.yml"));
411 }
412
413 #[test]
414 fn adjusted_savings_subtracts_waste() {
415 let mut bt = BounceTracker::new();
416 bt.seq_counter = 1;
417 bt.record_read("a.rs", "map", 50, 500);
418 bt.seq_counter = 2;
419 bt.record_read("a.rs", "full", 500, 500);
420 assert_eq!(bt.adjusted_savings(1000), 950);
421 }
422
423 #[test]
424 fn bounce_rate_for_extension_below_minimum() {
425 let bt = BounceTracker::new();
426 assert!(bt.bounce_rate_for_extension("test.rs").is_none());
427 }
428
429 #[test]
430 fn format_summary_empty() {
431 let bt = BounceTracker::new();
432 assert_eq!(bt.format_summary(), "Bounces: 0");
433 }
434}