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).
133                    crate::core::threshold_learning::record_signal(
134                        norm_path,
135                        crate::core::threshold_learning::QualitySignal::Bounce,
136                    );
137                    // Stigmergy (#540): a bounce marks this path as Stuck so
138                    // other agents see friction here. Background: lock may block.
139                    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    /// Evict outer-map entries whose newest seq is older than the detection windows —
166    /// they can no longer affect bounce detection or `should_force_full`. Bounds the
167    /// `recent_reads` / `recently_edited` maps on a long-lived process.
168    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        // Seed from the persistent ledger so every process (including a fresh `gain`)
286        // accounts for historical bounce, then mark this tracker as the persisting one.
287        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}