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    /// Records an explicit archive/context expansion as a quality bounce.
179    ///
180    /// Unlike a full file re-read, `ctx_expand` may address a content handle
181    /// rather than a path, so it cannot always be inferred by `detect_bounce`.
182    /// Benchmark replay uses this method to keep the quality proxy on the same
183    /// tracker as ordinary compressed-then-full bounces.
184    pub fn record_expansion(&mut self, source: Option<&str>, wasted_tokens: usize) {
185        self.total_bounces = self.total_bounces.saturating_add(1);
186        self.total_wasted_tokens = self.total_wasted_tokens.saturating_add(wasted_tokens);
187
188        if let Some(path) = source {
189            let ext = extension_of(path);
190            if !ext.is_empty() {
191                let stats = self.per_extension.entry(ext).or_default();
192                stats.bounces = stats.bounces.saturating_add(1);
193                stats.wasted_tokens = stats.wasted_tokens.saturating_add(wasted_tokens);
194            }
195        }
196    }
197
198    pub fn record_edit(&mut self, path: &str) {
199        let norm = crate::core::pathutil::normalize_tool_path(path);
200        self.recently_edited.insert(norm, self.seq_counter);
201        self.prune_stale_paths();
202    }
203
204    /// Evict outer-map entries whose newest seq is older than the detection windows —
205    /// they can no longer affect bounce detection or `should_force_full`. Bounds the
206    /// `recent_reads` / `recently_edited` maps on a long-lived process.
207    fn prune_stale_paths(&mut self) {
208        let seq = self.seq_counter;
209        self.recent_reads.retain(|_, events| {
210            events
211                .last()
212                .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
213        });
214        self.recently_edited
215            .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
216    }
217
218    pub fn should_force_full(&self, path: &str) -> bool {
219        let norm = crate::core::pathutil::normalize_tool_path(path);
220
221        if let Some(&edit_seq) = self.recently_edited.get(&norm)
222            && self.seq_counter.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
223        {
224            return true;
225        }
226
227        let ext = extension_of(path);
228        if !ext.is_empty()
229            && let Some(stats) = self.per_extension.get(&ext)
230            && stats.total_reads >= 3
231        {
232            let rate = stats.bounces as f64 / stats.total_reads as f64;
233            if rate >= BOUNCE_RATE_THRESHOLD {
234                return true;
235            }
236        }
237
238        false
239    }
240
241    pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
242        let ext = extension_of(path);
243        self.per_extension.get(&ext).and_then(|s| {
244            if s.total_reads >= 3 {
245                Some(s.bounces as f64 / s.total_reads as f64)
246            } else {
247                None
248            }
249        })
250    }
251
252    pub fn total_bounces(&self) -> u64 {
253        self.total_bounces
254    }
255
256    pub fn total_wasted_tokens(&self) -> usize {
257        self.total_wasted_tokens
258    }
259
260    pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
261        raw_savings as isize - self.total_wasted_tokens as isize
262    }
263
264    pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
265        let mut exts: Vec<_> = self
266            .per_extension
267            .iter()
268            .filter(|(_, s)| s.total_reads > 0)
269            .collect();
270        exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
271        exts.iter()
272            .take(10)
273            .map(|(ext, stats)| {
274                let rate = if stats.total_reads > 0 {
275                    stats.bounces as f64 / stats.total_reads as f64
276                } else {
277                    0.0
278                };
279                serde_json::json!({
280                    "ext": ext,
281                    "reads": stats.total_reads,
282                    "bounces": stats.bounces,
283                    "wasted_tokens": stats.wasted_tokens,
284                    "rate": (rate * 1000.0).round() / 1000.0,
285                })
286            })
287            .collect()
288    }
289
290    pub fn format_summary(&self) -> String {
291        if self.total_bounces == 0 {
292            return "Bounces: 0".to_string();
293        }
294        let mut lines = vec![format!(
295            "Bounces: {} ({} wasted tokens)",
296            self.total_bounces, self.total_wasted_tokens
297        )];
298        let mut exts: Vec<_> = self
299            .per_extension
300            .iter()
301            .filter(|(_, s)| s.bounces > 0)
302            .collect();
303        exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
304        for (ext, stats) in exts.iter().take(5) {
305            let rate = if stats.total_reads > 0 {
306                stats.bounces as f64 / stats.total_reads as f64 * 100.0
307            } else {
308                0.0
309            };
310            lines.push(format!(
311                "  {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
312                stats.bounces, stats.total_reads, stats.wasted_tokens,
313            ));
314        }
315        lines.join("\n")
316    }
317}
318
319static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
320
321pub fn global() -> &'static Mutex<BounceTracker> {
322    GLOBAL_TRACKER.get_or_init(|| {
323        // Seed from the persistent ledger so every process (including a fresh `gain`)
324        // accounts for historical bounce, then mark this tracker as the persisting one.
325        let summary = crate::core::savings_ledger::summary();
326        let mut bt = BounceTracker::new();
327        bt.total_wasted_tokens = summary.bounce_tokens as usize;
328        bt.total_bounces = summary.bounce_events as u64;
329        bt.persist = true;
330        Mutex::new(bt)
331    })
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn no_bounce_when_first_read_is_full() {
340        let mut bt = BounceTracker::new();
341        bt.seq_counter = 1;
342        bt.record_read("src/main.rs", "full", 500, 500);
343        assert_eq!(bt.total_bounces(), 0);
344        assert_eq!(bt.total_wasted_tokens(), 0);
345    }
346
347    #[test]
348    fn bounce_detected_on_compressed_then_full() {
349        let mut bt = BounceTracker::new();
350        bt.seq_counter = 1;
351        bt.record_read("src/main.rs", "map", 50, 500);
352        bt.seq_counter = 2;
353        bt.record_read("src/main.rs", "full", 500, 500);
354        assert_eq!(bt.total_bounces(), 1);
355        assert_eq!(bt.total_wasted_tokens(), 50);
356    }
357
358    #[test]
359    fn no_bounce_outside_window() {
360        let mut bt = BounceTracker::new();
361        bt.seq_counter = 1;
362        bt.record_read("src/main.rs", "map", 50, 500);
363        bt.seq_counter = 10;
364        bt.record_read("src/main.rs", "full", 500, 500);
365        assert_eq!(bt.total_bounces(), 0);
366    }
367
368    #[test]
369    fn shell_access_triggers_bounce() {
370        let mut bt = BounceTracker::new();
371        bt.seq_counter = 1;
372        bt.record_read("config.yml", "signatures", 30, 400);
373        bt.seq_counter = 3;
374        bt.record_shell_file_access("config.yml");
375        assert_eq!(bt.total_bounces(), 1);
376        assert_eq!(bt.total_wasted_tokens(), 30);
377    }
378
379    #[test]
380    fn explicit_expansion_tracks_quality_and_waste() {
381        let mut bt = BounceTracker::new();
382        bt.record_expansion(Some("src/main.rs"), 125);
383        assert_eq!(bt.total_bounces(), 1);
384        assert_eq!(bt.total_wasted_tokens(), 125);
385    }
386
387    #[test]
388    fn edit_forced_full_read_is_not_a_bounce() {
389        // GL #622: a compressed overview read, then an edit, then the `full`
390        // re-read that `should_force_full` mandates must NOT register as a bounce
391        // — the edit forced it, the compression did not fail.
392        let mut bt = BounceTracker::new();
393        bt.seq_counter = 1;
394        bt.record_read("src/lib.rs", "map", 40, 500);
395        bt.seq_counter = 2;
396        bt.record_edit("src/lib.rs");
397        bt.seq_counter = 4;
398        bt.record_read("src/lib.rs", "full", 500, 500);
399        assert_eq!(
400            bt.total_bounces(),
401            0,
402            "edit-forced full read must not count as a compression bounce"
403        );
404    }
405
406    #[test]
407    fn full_read_without_edit_still_bounces() {
408        // The guard is scoped to edit-forced reads only: an unprompted full
409        // re-read after a compressed read is still a real bounce.
410        let mut bt = BounceTracker::new();
411        bt.seq_counter = 1;
412        bt.record_read("src/lib.rs", "map", 40, 500);
413        bt.seq_counter = 3;
414        bt.record_read("src/lib.rs", "full", 500, 500);
415        assert_eq!(bt.total_bounces(), 1);
416    }
417
418    #[test]
419    fn should_force_full_after_edit() {
420        let mut bt = BounceTracker::new();
421        bt.seq_counter = 5;
422        bt.record_edit("src/lib.rs");
423        bt.seq_counter = 8;
424        assert!(bt.should_force_full("src/lib.rs"));
425        bt.seq_counter = 20;
426        assert!(!bt.should_force_full("src/lib.rs"));
427    }
428
429    #[test]
430    fn should_force_full_by_extension_bounce_rate() {
431        let mut bt = BounceTracker::new();
432        for i in 1..=6 {
433            bt.seq_counter = i * 2 - 1;
434            bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
435            bt.seq_counter = i * 2;
436            bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
437        }
438        assert!(bt.should_force_full("new.yml"));
439    }
440
441    #[test]
442    fn adjusted_savings_subtracts_waste() {
443        let mut bt = BounceTracker::new();
444        bt.seq_counter = 1;
445        bt.record_read("a.rs", "map", 50, 500);
446        bt.seq_counter = 2;
447        bt.record_read("a.rs", "full", 500, 500);
448        assert_eq!(bt.adjusted_savings(1000), 950);
449    }
450
451    #[test]
452    fn bounce_rate_for_extension_below_minimum() {
453        let bt = BounceTracker::new();
454        assert!(bt.bounce_rate_for_extension("test.rs").is_none());
455    }
456
457    #[test]
458    fn format_summary_empty() {
459        let bt = BounceTracker::new();
460        assert_eq!(bt.format_summary(), "Bounces: 0");
461    }
462}