1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4const BOUNCE_WINDOW: u64 = 5;
5const BOUNCE_RATE_THRESHOLD: f64 = 0.30;
6const TRACKED_PATH_TTL_SEQ: u64 = 64;
10
11#[derive(Debug, Clone)]
12struct ReadEvent {
13 _mode: String,
14 tokens_sent: usize,
15 _original_tokens: usize,
16 seq: u64,
17 was_compressed: bool,
18}
19
20#[derive(Debug, Default)]
21struct BounceStats {
22 total_reads: u64,
23 bounces: u64,
24 wasted_tokens: usize,
25}
26
27#[derive(Debug, Default)]
28pub struct BounceTracker {
29 recent_reads: HashMap<String, Vec<ReadEvent>>,
30 per_extension: HashMap<String, BounceStats>,
31 recently_edited: HashMap<String, u64>,
32 seq_counter: u64,
33 total_bounces: u64,
34 total_wasted_tokens: usize,
35 persist: bool,
39}
40
41fn is_compressed_mode(mode: &str) -> bool {
42 !matches!(mode, "full" | "diff")
43}
44
45fn extension_of(path: &str) -> String {
46 path.rsplit('.')
47 .next()
48 .map(|e| format!(".{}", e.to_ascii_lowercase()))
49 .unwrap_or_default()
50}
51
52impl BounceTracker {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn next_seq(&mut self) -> u64 {
58 self.seq_counter += 1;
59 self.seq_counter
60 }
61
62 pub fn set_seq(&mut self, seq: u64) {
63 self.seq_counter = seq;
64 }
65
66 pub fn record_read(
67 &mut self,
68 path: &str,
69 mode: &str,
70 tokens_sent: usize,
71 original_tokens: usize,
72 ) {
73 let norm = crate::core::pathutil::normalize_tool_path(path);
74 let seq = self.seq_counter;
75 let compressed = is_compressed_mode(mode);
76
77 if !compressed {
78 self.detect_bounce(&norm, seq);
79 }
80 if self.persist {
81 crate::core::path_mode_memory::record_read_if_tracked(&norm);
84 }
85
86 let events = self.recent_reads.entry(norm).or_default();
87 events.push(ReadEvent {
88 _mode: mode.to_string(),
89 tokens_sent,
90 _original_tokens: original_tokens,
91 seq,
92 was_compressed: compressed,
93 });
94
95 if events.len() > 10 {
96 events.drain(..events.len() - 10);
97 }
98
99 let ext = extension_of(path);
100 if !ext.is_empty() {
101 let stats = self.per_extension.entry(ext).or_default();
102 stats.total_reads += 1;
103 }
104
105 self.prune_stale_paths();
106 }
107
108 fn detect_bounce(&mut self, norm_path: &str, full_seq: u64) {
109 let Some(events) = self.recent_reads.get(norm_path) else {
110 return;
111 };
112
113 if let Some(ev) = events.iter().next_back() {
114 if ev.was_compressed && full_seq.saturating_sub(ev.seq) <= BOUNCE_WINDOW {
115 let wasted = ev.tokens_sent;
116 self.total_bounces += 1;
117 self.total_wasted_tokens += wasted;
118
119 let ext = extension_of(norm_path);
120 if !ext.is_empty() {
121 let stats = self.per_extension.entry(ext).or_default();
122 stats.bounces += 1;
123 stats.wasted_tokens += wasted;
124 }
125
126 if self.persist {
127 crate::core::savings_ledger::record_bounce_event(wasted);
128 crate::core::path_mode_memory::record_bounce(norm_path);
131 crate::core::adaptive_thresholds::record_quality_signal(
135 norm_path,
136 crate::core::threshold_learning::QualitySignal::Bounce,
137 );
138 let scent_path = norm_path.to_string();
141 std::thread::spawn(move || {
142 crate::core::scent_field::deposit(
143 crate::core::scent_field::scent_agent_id(),
144 crate::core::scent_field::ScentKind::Stuck,
145 &scent_path,
146 0.5,
147 );
148 });
149 }
150 }
151 }
152 }
153
154 pub fn record_shell_file_access(&mut self, path: &str) {
155 let norm = crate::core::pathutil::normalize_tool_path(path);
156 let seq = self.seq_counter;
157 self.detect_bounce(&norm, seq);
158 }
159
160 pub fn record_edit(&mut self, path: &str) {
161 let norm = crate::core::pathutil::normalize_tool_path(path);
162 self.recently_edited.insert(norm, self.seq_counter);
163 self.prune_stale_paths();
164 }
165
166 fn prune_stale_paths(&mut self) {
170 let seq = self.seq_counter;
171 self.recent_reads.retain(|_, events| {
172 events
173 .last()
174 .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
175 });
176 self.recently_edited
177 .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
178 }
179
180 pub fn should_force_full(&self, path: &str) -> bool {
181 let norm = crate::core::pathutil::normalize_tool_path(path);
182
183 if let Some(&edit_seq) = self.recently_edited.get(&norm) {
184 if self.seq_counter.saturating_sub(edit_seq) <= 10 {
185 return true;
186 }
187 }
188
189 let ext = extension_of(path);
190 if !ext.is_empty() {
191 if let Some(stats) = self.per_extension.get(&ext) {
192 if stats.total_reads >= 3 {
193 let rate = stats.bounces as f64 / stats.total_reads as f64;
194 if rate >= BOUNCE_RATE_THRESHOLD {
195 return true;
196 }
197 }
198 }
199 }
200
201 false
202 }
203
204 pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
205 let ext = extension_of(path);
206 self.per_extension.get(&ext).and_then(|s| {
207 if s.total_reads >= 3 {
208 Some(s.bounces as f64 / s.total_reads as f64)
209 } else {
210 None
211 }
212 })
213 }
214
215 pub fn total_bounces(&self) -> u64 {
216 self.total_bounces
217 }
218
219 pub fn total_wasted_tokens(&self) -> usize {
220 self.total_wasted_tokens
221 }
222
223 pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
224 raw_savings as isize - self.total_wasted_tokens as isize
225 }
226
227 pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
228 let mut exts: Vec<_> = self
229 .per_extension
230 .iter()
231 .filter(|(_, s)| s.total_reads > 0)
232 .collect();
233 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
234 exts.iter()
235 .take(10)
236 .map(|(ext, stats)| {
237 let rate = if stats.total_reads > 0 {
238 stats.bounces as f64 / stats.total_reads as f64
239 } else {
240 0.0
241 };
242 serde_json::json!({
243 "ext": ext,
244 "reads": stats.total_reads,
245 "bounces": stats.bounces,
246 "wasted_tokens": stats.wasted_tokens,
247 "rate": (rate * 1000.0).round() / 1000.0,
248 })
249 })
250 .collect()
251 }
252
253 pub fn format_summary(&self) -> String {
254 if self.total_bounces == 0 {
255 return "Bounces: 0".to_string();
256 }
257 let mut lines = vec![format!(
258 "Bounces: {} ({} wasted tokens)",
259 self.total_bounces, self.total_wasted_tokens
260 )];
261 let mut exts: Vec<_> = self
262 .per_extension
263 .iter()
264 .filter(|(_, s)| s.bounces > 0)
265 .collect();
266 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
267 for (ext, stats) in exts.iter().take(5) {
268 let rate = if stats.total_reads > 0 {
269 stats.bounces as f64 / stats.total_reads as f64 * 100.0
270 } else {
271 0.0
272 };
273 lines.push(format!(
274 " {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
275 stats.bounces, stats.total_reads, stats.wasted_tokens,
276 ));
277 }
278 lines.join("\n")
279 }
280}
281
282static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
283
284pub fn global() -> &'static Mutex<BounceTracker> {
285 GLOBAL_TRACKER.get_or_init(|| {
286 let summary = crate::core::savings_ledger::summary();
289 let mut bt = BounceTracker::new();
290 bt.total_wasted_tokens = summary.bounce_tokens as usize;
291 bt.total_bounces = summary.bounce_events as u64;
292 bt.persist = true;
293 Mutex::new(bt)
294 })
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn no_bounce_when_first_read_is_full() {
303 let mut bt = BounceTracker::new();
304 bt.seq_counter = 1;
305 bt.record_read("src/main.rs", "full", 500, 500);
306 assert_eq!(bt.total_bounces(), 0);
307 assert_eq!(bt.total_wasted_tokens(), 0);
308 }
309
310 #[test]
311 fn bounce_detected_on_compressed_then_full() {
312 let mut bt = BounceTracker::new();
313 bt.seq_counter = 1;
314 bt.record_read("src/main.rs", "map", 50, 500);
315 bt.seq_counter = 2;
316 bt.record_read("src/main.rs", "full", 500, 500);
317 assert_eq!(bt.total_bounces(), 1);
318 assert_eq!(bt.total_wasted_tokens(), 50);
319 }
320
321 #[test]
322 fn no_bounce_outside_window() {
323 let mut bt = BounceTracker::new();
324 bt.seq_counter = 1;
325 bt.record_read("src/main.rs", "map", 50, 500);
326 bt.seq_counter = 10;
327 bt.record_read("src/main.rs", "full", 500, 500);
328 assert_eq!(bt.total_bounces(), 0);
329 }
330
331 #[test]
332 fn shell_access_triggers_bounce() {
333 let mut bt = BounceTracker::new();
334 bt.seq_counter = 1;
335 bt.record_read("config.yml", "signatures", 30, 400);
336 bt.seq_counter = 3;
337 bt.record_shell_file_access("config.yml");
338 assert_eq!(bt.total_bounces(), 1);
339 assert_eq!(bt.total_wasted_tokens(), 30);
340 }
341
342 #[test]
343 fn should_force_full_after_edit() {
344 let mut bt = BounceTracker::new();
345 bt.seq_counter = 5;
346 bt.record_edit("src/lib.rs");
347 bt.seq_counter = 8;
348 assert!(bt.should_force_full("src/lib.rs"));
349 bt.seq_counter = 20;
350 assert!(!bt.should_force_full("src/lib.rs"));
351 }
352
353 #[test]
354 fn should_force_full_by_extension_bounce_rate() {
355 let mut bt = BounceTracker::new();
356 for i in 1..=6 {
357 bt.seq_counter = i * 2 - 1;
358 bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
359 bt.seq_counter = i * 2;
360 bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
361 }
362 assert!(bt.should_force_full("new.yml"));
363 }
364
365 #[test]
366 fn adjusted_savings_subtracts_waste() {
367 let mut bt = BounceTracker::new();
368 bt.seq_counter = 1;
369 bt.record_read("a.rs", "map", 50, 500);
370 bt.seq_counter = 2;
371 bt.record_read("a.rs", "full", 500, 500);
372 assert_eq!(bt.adjusted_savings(1000), 950);
373 }
374
375 #[test]
376 fn bounce_rate_for_extension_below_minimum() {
377 let bt = BounceTracker::new();
378 assert!(bt.bounce_rate_for_extension("test.rs").is_none());
379 }
380
381 #[test]
382 fn format_summary_empty() {
383 let bt = BounceTracker::new();
384 assert_eq!(bt.format_summary(), "Bounces: 0");
385 }
386}