Skip to main content

lean_ctx/core/
bounce_tracker.rs

1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4const BOUNCE_WINDOW: u64 = 5;
5const BOUNCE_RATE_THRESHOLD: f64 = 0.30;
6/// Outer-map retention: a path whose newest activity is older than this many seq ticks
7/// can no longer satisfy BOUNCE_WINDOW (5) or the edit-force window (10), so it is inert
8/// and safe to evict. Kept well above both windows to never change detection outcomes.
9const 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    /// When true (only for the process-global tracker), detected bounces are appended to
36    /// the persistent savings ledger so a fresh `gain` process sees historical bounce.
37    /// Local trackers in unit tests leave this `false` to avoid touching the real ledger.
38    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            // Keep the long-term majority rule honest: clean reads dilute
82            // historical bounces (#496).
83            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                    // Long-term per-path memory (#496): remember which exact
129                    // files keep bouncing so auto-mode learns across restarts.
130                    crate::core::path_mode_memory::record_bounce(norm_path);
131                    // Quality signal (#538): bounces push the learned entropy
132                    // threshold down for this extension (compress less) and
133                    // penalize the bandit arm that produced the read (#593).
134                    crate::core::adaptive_thresholds::record_quality_signal(
135                        norm_path,
136                        crate::core::threshold_learning::QualitySignal::Bounce,
137                    );
138                    // Stigmergy (#540): a bounce marks this path as Stuck so
139                    // other agents see friction here. Background: lock may block.
140                    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    /// Evict outer-map entries whose newest seq is older than the detection windows —
167    /// they can no longer affect bounce detection or `should_force_full`. Bounds the
168    /// `recent_reads` / `recently_edited` maps on a long-lived process.
169    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        // Seed from the persistent ledger so every process (including a fresh `gain`)
287        // accounts for historical bounce, then mark this tracker as the persisting one.
288        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}