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/// Seq-tick window during which a full read is treated as *edit-forced* rather
7/// than a compression bounce. Must match the window `should_force_full` uses to
8/// escalate post-edit reads to `full`, so the read we forced is never blamed on
9/// the compression arm (GL #622).
10const EDIT_FORCE_WINDOW: u64 = 10;
11/// Outer-map retention: a path whose newest activity is older than this many seq ticks
12/// can no longer satisfy BOUNCE_WINDOW (5) or the edit-force window (10), so it is inert
13/// and safe to evict. Kept well above both windows to never change detection outcomes.
14const 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    /// When true (only for the process-global tracker), detected bounces are appended to
41    /// the persistent savings ledger so a fresh `gain` process sees historical bounce.
42    /// Local trackers in unit tests leave this `false` to avoid touching the real ledger.
43    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            // Keep the long-term majority rule honest: clean reads dilute
87            // historical bounces (#496).
88            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        // A full re-read the system itself forced after an edit is not a
115        // compression failure (GL #622): `should_force_full` returns `full` for
116        // EDIT_FORCE_WINDOW ticks post-edit, so the agent had no choice. Counting
117        // it as a bounce would penalize the compression arm for the edit and
118        // inflate the per-extension bounce rate until `should_force_full` pins the
119        // whole extension to `full` — a self-reinforcing loss of compression.
120        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                // Long-term per-path memory (#496): remember which exact
148                // files keep bouncing so auto-mode learns across restarts.
149                crate::core::path_mode_memory::record_bounce(norm_path);
150                // Quality signal (#538): bounces push the learned entropy
151                // threshold down for this extension (compress less) and
152                // penalize the bandit arm that produced the read (#593).
153                crate::core::adaptive_thresholds::record_quality_signal(
154                    norm_path,
155                    crate::core::threshold_learning::QualitySignal::Bounce,
156                );
157                // Stigmergy (#540): a bounce marks this path as Stuck so
158                // other agents see friction here. Background: lock may block.
159                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    /// Evict outer-map entries whose newest seq is older than the detection windows —
185    /// they can no longer affect bounce detection or `should_force_full`. Bounds the
186    /// `recent_reads` / `recently_edited` maps on a long-lived process.
187    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        // Seed from the persistent ledger so every process (including a fresh `gain`)
304        // accounts for historical bounce, then mark this tracker as the persisting one.
305        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        // GL #622: a compressed overview read, then an edit, then the `full`
362        // re-read that `should_force_full` mandates must NOT register as a bounce
363        // — the edit forced it, the compression did not fail.
364        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        // The guard is scoped to edit-forced reads only: an unprompted full
381        // re-read after a compressed read is still a real bounce.
382        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}