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::threshold_learning::record_signal(
134 norm_path,
135 crate::core::threshold_learning::QualitySignal::Bounce,
136 );
137 let scent_path = norm_path.to_string();
140 std::thread::spawn(move || {
141 crate::core::scent_field::deposit(
142 crate::core::scent_field::scent_agent_id(),
143 crate::core::scent_field::ScentKind::Stuck,
144 &scent_path,
145 0.5,
146 );
147 });
148 }
149 }
150 }
151 }
152
153 pub fn record_shell_file_access(&mut self, path: &str) {
154 let norm = crate::core::pathutil::normalize_tool_path(path);
155 let seq = self.seq_counter;
156 self.detect_bounce(&norm, seq);
157 }
158
159 pub fn record_edit(&mut self, path: &str) {
160 let norm = crate::core::pathutil::normalize_tool_path(path);
161 self.recently_edited.insert(norm, self.seq_counter);
162 self.prune_stale_paths();
163 }
164
165 fn prune_stale_paths(&mut self) {
169 let seq = self.seq_counter;
170 self.recent_reads.retain(|_, events| {
171 events
172 .last()
173 .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
174 });
175 self.recently_edited
176 .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
177 }
178
179 pub fn should_force_full(&self, path: &str) -> bool {
180 let norm = crate::core::pathutil::normalize_tool_path(path);
181
182 if let Some(&edit_seq) = self.recently_edited.get(&norm) {
183 if self.seq_counter.saturating_sub(edit_seq) <= 10 {
184 return true;
185 }
186 }
187
188 let ext = extension_of(path);
189 if !ext.is_empty() {
190 if let Some(stats) = self.per_extension.get(&ext) {
191 if stats.total_reads >= 3 {
192 let rate = stats.bounces as f64 / stats.total_reads as f64;
193 if rate >= BOUNCE_RATE_THRESHOLD {
194 return true;
195 }
196 }
197 }
198 }
199
200 false
201 }
202
203 pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
204 let ext = extension_of(path);
205 self.per_extension.get(&ext).and_then(|s| {
206 if s.total_reads >= 3 {
207 Some(s.bounces as f64 / s.total_reads as f64)
208 } else {
209 None
210 }
211 })
212 }
213
214 pub fn total_bounces(&self) -> u64 {
215 self.total_bounces
216 }
217
218 pub fn total_wasted_tokens(&self) -> usize {
219 self.total_wasted_tokens
220 }
221
222 pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
223 raw_savings as isize - self.total_wasted_tokens as isize
224 }
225
226 pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
227 let mut exts: Vec<_> = self
228 .per_extension
229 .iter()
230 .filter(|(_, s)| s.total_reads > 0)
231 .collect();
232 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
233 exts.iter()
234 .take(10)
235 .map(|(ext, stats)| {
236 let rate = if stats.total_reads > 0 {
237 stats.bounces as f64 / stats.total_reads as f64
238 } else {
239 0.0
240 };
241 serde_json::json!({
242 "ext": ext,
243 "reads": stats.total_reads,
244 "bounces": stats.bounces,
245 "wasted_tokens": stats.wasted_tokens,
246 "rate": (rate * 1000.0).round() / 1000.0,
247 })
248 })
249 .collect()
250 }
251
252 pub fn format_summary(&self) -> String {
253 if self.total_bounces == 0 {
254 return "Bounces: 0".to_string();
255 }
256 let mut lines = vec![format!(
257 "Bounces: {} ({} wasted tokens)",
258 self.total_bounces, self.total_wasted_tokens
259 )];
260 let mut exts: Vec<_> = self
261 .per_extension
262 .iter()
263 .filter(|(_, s)| s.bounces > 0)
264 .collect();
265 exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
266 for (ext, stats) in exts.iter().take(5) {
267 let rate = if stats.total_reads > 0 {
268 stats.bounces as f64 / stats.total_reads as f64 * 100.0
269 } else {
270 0.0
271 };
272 lines.push(format!(
273 " {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
274 stats.bounces, stats.total_reads, stats.wasted_tokens,
275 ));
276 }
277 lines.join("\n")
278 }
279}
280
281static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
282
283pub fn global() -> &'static Mutex<BounceTracker> {
284 GLOBAL_TRACKER.get_or_init(|| {
285 let summary = crate::core::savings_ledger::summary();
288 let mut bt = BounceTracker::new();
289 bt.total_wasted_tokens = summary.bounce_tokens as usize;
290 bt.total_bounces = summary.bounce_events as u64;
291 bt.persist = true;
292 Mutex::new(bt)
293 })
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn no_bounce_when_first_read_is_full() {
302 let mut bt = BounceTracker::new();
303 bt.seq_counter = 1;
304 bt.record_read("src/main.rs", "full", 500, 500);
305 assert_eq!(bt.total_bounces(), 0);
306 assert_eq!(bt.total_wasted_tokens(), 0);
307 }
308
309 #[test]
310 fn bounce_detected_on_compressed_then_full() {
311 let mut bt = BounceTracker::new();
312 bt.seq_counter = 1;
313 bt.record_read("src/main.rs", "map", 50, 500);
314 bt.seq_counter = 2;
315 bt.record_read("src/main.rs", "full", 500, 500);
316 assert_eq!(bt.total_bounces(), 1);
317 assert_eq!(bt.total_wasted_tokens(), 50);
318 }
319
320 #[test]
321 fn no_bounce_outside_window() {
322 let mut bt = BounceTracker::new();
323 bt.seq_counter = 1;
324 bt.record_read("src/main.rs", "map", 50, 500);
325 bt.seq_counter = 10;
326 bt.record_read("src/main.rs", "full", 500, 500);
327 assert_eq!(bt.total_bounces(), 0);
328 }
329
330 #[test]
331 fn shell_access_triggers_bounce() {
332 let mut bt = BounceTracker::new();
333 bt.seq_counter = 1;
334 bt.record_read("config.yml", "signatures", 30, 400);
335 bt.seq_counter = 3;
336 bt.record_shell_file_access("config.yml");
337 assert_eq!(bt.total_bounces(), 1);
338 assert_eq!(bt.total_wasted_tokens(), 30);
339 }
340
341 #[test]
342 fn should_force_full_after_edit() {
343 let mut bt = BounceTracker::new();
344 bt.seq_counter = 5;
345 bt.record_edit("src/lib.rs");
346 bt.seq_counter = 8;
347 assert!(bt.should_force_full("src/lib.rs"));
348 bt.seq_counter = 20;
349 assert!(!bt.should_force_full("src/lib.rs"));
350 }
351
352 #[test]
353 fn should_force_full_by_extension_bounce_rate() {
354 let mut bt = BounceTracker::new();
355 for i in 1..=6 {
356 bt.seq_counter = i * 2 - 1;
357 bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
358 bt.seq_counter = i * 2;
359 bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
360 }
361 assert!(bt.should_force_full("new.yml"));
362 }
363
364 #[test]
365 fn adjusted_savings_subtracts_waste() {
366 let mut bt = BounceTracker::new();
367 bt.seq_counter = 1;
368 bt.record_read("a.rs", "map", 50, 500);
369 bt.seq_counter = 2;
370 bt.record_read("a.rs", "full", 500, 500);
371 assert_eq!(bt.adjusted_savings(1000), 950);
372 }
373
374 #[test]
375 fn bounce_rate_for_extension_below_minimum() {
376 let bt = BounceTracker::new();
377 assert!(bt.bounce_rate_for_extension("test.rs").is_none());
378 }
379
380 #[test]
381 fn format_summary_empty() {
382 let bt = BounceTracker::new();
383 assert_eq!(bt.format_summary(), "Bounces: 0");
384 }
385}