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    view: ReadView,
23    /// File mtime when a compressed view was served. A later full read of a
24    /// file whose mtime moved re-reads *changed* content — an edit (a host's
25    /// native Edit/Write included, which never reaches `record_edit`), not a
26    /// compression failure.
27    mtime: Option<std::time::SystemTime>,
28    /// Scope and task the read ran under; a bounce is that task's signal.
29    origin: Option<crate::core::context_store::task_signals::Origin>,
30}
31
32fn file_mtime(path: &str) -> Option<std::time::SystemTime> {
33    std::fs::metadata(path).and_then(|m| m.modified()).ok()
34}
35
36/// What a read showed of the file, for correction (bounce) accounting.
37///
38/// A correction is a lossy [`Compressed`](ReadView::Compressed) view
39/// followed by the [`Full`](ReadView::Full) content. A
40/// [`Partial`](ReadView::Partial) read — a line window, `head`/`tail`, a
41/// `sed -n` range, a `diff` delta — is precise reading: it neither starts nor
42/// completes a correction, and it ends the one a preceding compressed view
43/// could have started (that view did its job: it pointed at the window).
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ReadView {
46    Compressed,
47    Full,
48    Partial,
49}
50
51impl ReadView {
52    /// Classify a resolved `ctx_read` mode string.
53    ///
54    /// `anchored` (whole file) is verbatim plus per-line anchors, so it is
55    /// full content like `full`/`raw`/`full-compact`; `anchored:N-M` is a
56    /// window. Unknown strings are `Partial`: an unrecognised mode must never
57    /// fabricate a correction.
58    #[must_use]
59    pub fn of_mode(mode: &str) -> Self {
60        use crate::core::read_mode::ReadMode;
61        match mode.parse::<ReadMode>() {
62            Ok(
63                ReadMode::Full | ReadMode::FullCompact | ReadMode::Raw | ReadMode::Anchored(None),
64            ) => ReadView::Full,
65            Ok(
66                ReadMode::Anchored(Some(_))
67                | ReadMode::Lines(_)
68                | ReadMode::LinesMulti(_)
69                | ReadMode::LinesTail(_)
70                | ReadMode::Diff,
71            )
72            | Err(_) => ReadView::Partial,
73            Ok(
74                ReadMode::Signatures
75                | ReadMode::Map
76                | ReadMode::Aggressive
77                | ReadMode::Entropy
78                | ReadMode::Cognitive
79                | ReadMode::Mdl
80                | ReadMode::Task
81                | ReadMode::Reference
82                | ReadMode::Auto
83                | ReadMode::Density(_),
84            ) => ReadView::Compressed,
85        }
86    }
87}
88
89#[derive(Debug, Default)]
90struct BounceStats {
91    total_reads: u64,
92    bounces: u64,
93    wasted_tokens: usize,
94}
95
96/// A detected compressed→full re-read that is not yet booked as a bounce.
97///
98/// Reading a file compressed for orientation, then in full, then editing it is
99/// the normal edit flow (map to orient, full to edit), not a compression
100/// failure. A bounce therefore stays provisional for `EDIT_FORCE_WINDOW` seq
101/// ticks: an edit of the path inside that window cancels it, otherwise it is
102/// committed (ledger entry, path memory, quality signal, scent).
103#[derive(Debug, Clone)]
104struct PendingBounce {
105    path: String,
106    /// Seq tick of the full read that triggered the detection.
107    seq: u64,
108    wasted: usize,
109    origin: Option<crate::core::context_store::task_signals::Origin>,
110    /// Wall-clock detection time, compared against host edits other
111    /// processes report through the edit beacon.
112    detected_unix_ms: u64,
113    /// The request project at detection. The bounce is booked later (after
114    /// the edit window, or by the shutdown flush) outside that request.
115    project_root: Option<String>,
116    /// The read mode of the compressed view the full read corrected.
117    mode: String,
118}
119
120#[derive(Debug, Default)]
121pub struct BounceTracker {
122    recent_reads: HashMap<String, Vec<ReadEvent>>,
123    per_extension: HashMap<String, BounceStats>,
124    recently_edited: HashMap<String, u64>,
125    pending: Vec<PendingBounce>,
126    seq_counter: u64,
127    total_bounces: u64,
128    total_wasted_tokens: usize,
129    /// When true (only for the process-global tracker), detected bounces are appended to
130    /// the persistent savings ledger so a fresh `gain` process sees historical bounce.
131    /// Local trackers in unit tests leave this `false` to avoid touching the real ledger.
132    persist: bool,
133}
134
135/// Upper bound on provisional bounces kept in memory; the oldest is committed
136/// early when a burst exceeds it, so the queue can never grow without limit.
137const MAX_PENDING_BOUNCES: usize = 256;
138
139fn extension_of(path: &str) -> String {
140    path.rsplit('.')
141        .next()
142        .map(|e| format!(".{}", e.to_ascii_lowercase()))
143        .unwrap_or_default()
144}
145
146impl BounceTracker {
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    pub fn next_seq(&mut self) -> u64 {
152        self.seq_counter += 1;
153        self.commit_due();
154        self.seq_counter
155    }
156
157    pub fn set_seq(&mut self, seq: u64) {
158        self.seq_counter = seq;
159        self.commit_due();
160    }
161
162    pub fn record_read(
163        &mut self,
164        path: &str,
165        mode: &str,
166        tokens_sent: usize,
167        original_tokens: usize,
168    ) {
169        let norm = crate::core::pathutil::normalize_tool_path(path);
170        self.commit_due();
171        let seq = self.seq_counter;
172        let view = ReadView::of_mode(mode);
173        let compressed = view == ReadView::Compressed;
174
175        if view == ReadView::Full {
176            self.detect_bounce(&norm, path, seq);
177        }
178        if self.persist && compressed {
179            // K10: a compressed view is a truncated output of its read mode.
180            {
181                use crate::core::hive_signals as signals;
182                signals::truncated(&signals::read_mode_filter(mode));
183            }
184            // Keep the long-term majority rule honest: compressed reads that
185            // did not bounce dilute historical bounces (#496). Full and
186            // partial reads — the bounce re-read itself included — are not
187            // compression attempts and stay out of the denominator.
188            crate::core::path_mode_memory::record_compressed_read_if_tracked(&norm);
189        }
190
191        let current = if self.persist {
192            crate::core::context_store::task_signals::current_origin()
193        } else {
194            None
195        };
196        if let Some(current) = &current {
197            // Bounces are seen only by the process that served the read: a
198            // task served by several processes is not fully observed.
199            crate::core::context_store::task_signals::observe(current);
200        }
201        let origin = current.filter(|_| compressed);
202        self.push_event(
203            norm,
204            ReadEvent {
205                _mode: mode.to_string(),
206                tokens_sent,
207                _original_tokens: original_tokens,
208                seq,
209                view,
210                mtime: if compressed { file_mtime(path) } else { None },
211                origin,
212            },
213        );
214
215        let ext = extension_of(path);
216        if !ext.is_empty() {
217            let stats = self.per_extension.entry(ext).or_default();
218            stats.total_reads += 1;
219        }
220
221        self.prune_stale_paths();
222    }
223
224    fn push_event(&mut self, norm: String, event: ReadEvent) {
225        let events = self.recent_reads.entry(norm).or_default();
226        events.push(event);
227        if events.len() > 10 {
228            events.drain(..events.len() - 10);
229        }
230    }
231
232    /// Detect the correction a full read completes: only a full read whose
233    /// immediately preceding read of the path was a compressed view within
234    /// `BOUNCE_WINDOW`. A partial or full read in between ends the chain.
235    fn detect_bounce(&mut self, norm_path: &str, fs_path: &str, full_seq: u64) {
236        // A full re-read the system itself forced after an edit is not a
237        // compression failure (GL #622): `should_force_full` returns `full` for
238        // EDIT_FORCE_WINDOW ticks post-edit, so the agent had no choice. Counting
239        // it as a bounce would penalize the compression arm for the edit and
240        // inflate the per-extension bounce rate until `should_force_full` pins the
241        // whole extension to `full` — a self-reinforcing loss of compression.
242        if let Some(&edit_seq) = self.recently_edited.get(norm_path)
243            && full_seq.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
244        {
245            return;
246        }
247
248        let Some(events) = self.recent_reads.get(norm_path) else {
249            return;
250        };
251
252        if let Some(ev) = events.iter().next_back()
253            && ev.view == ReadView::Compressed
254            && full_seq.saturating_sub(ev.seq) <= BOUNCE_WINDOW
255            // The file changed since the view was served: the full re-read
256            // fetches new content after an edit. A host's native Edit/Write
257            // in another process may reach neither `record_edit` nor the
258            // edit beacon in time; the moved mtime is direct evidence of it.
259            && (ev.mtime.is_none() || file_mtime(fs_path) == ev.mtime)
260        {
261            // Provisional until EDIT_FORCE_WINDOW passes without an edit of
262            // this path (see `PendingBounce`).
263            let pending = PendingBounce {
264                path: norm_path.to_string(),
265                seq: full_seq,
266                wasted: ev.tokens_sent,
267                origin: ev.origin.clone(),
268                detected_unix_ms: now_unix_ms(),
269                project_root: crate::core::scent_field::current_project(),
270                mode: ev._mode.clone(),
271            };
272            self.pending.push(pending);
273            if self.pending.len() > MAX_PENDING_BOUNCES {
274                let oldest = self.pending.remove(0);
275                self.commit(oldest);
276            }
277        }
278    }
279
280    /// Commit every provisional bounce whose edit window has passed. Called
281    /// whenever the seq clock moves or a read/edit is recorded.
282    fn commit_due(&mut self) {
283        if self.pending.is_empty() {
284            return;
285        }
286        let now = self.seq_counter;
287        let (due, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
288            .into_iter()
289            .partition(|p| now.saturating_sub(p.seq) > EDIT_FORCE_WINDOW);
290        self.pending = keep;
291        for pending in due {
292            self.commit(pending);
293        }
294    }
295
296    /// Commit every provisional bounce now, regardless of its window. Used at
297    /// process shutdown so a bounce detected shortly before exit is not lost.
298    pub fn flush_pending(&mut self) {
299        for pending in std::mem::take(&mut self.pending) {
300            self.commit(pending);
301        }
302    }
303
304    /// Provisional bounces still waiting for their edit window to pass.
305    pub fn pending_bounces(&self) -> usize {
306        self.pending.len()
307    }
308
309    /// Book one bounce: counters, per-extension stats and, for the
310    /// process-global tracker, the ledger entry, path memory, quality signal
311    /// and scent. A host edit another process reported for the path after the
312    /// detection (edit beacon) cancels it instead.
313    fn commit(&mut self, pending: PendingBounce) {
314        if self.persist && host_edited_since(&pending.path, pending.detected_unix_ms) {
315            return;
316        }
317        let PendingBounce {
318            path: norm_path,
319            wasted,
320            origin,
321            project_root,
322            mode,
323            ..
324        } = pending;
325        self.total_bounces += 1;
326        self.total_wasted_tokens += wasted;
327
328        let ext = extension_of(&norm_path);
329        if !ext.is_empty() {
330            let stats = self.per_extension.entry(ext).or_default();
331            stats.bounces += 1;
332            stats.wasted_tokens += wasted;
333        }
334
335        if self.persist {
336            if let Some(origin) = origin {
337                // Off this tracker's lock: recording waits on a file lock.
338                std::thread::spawn(move || {
339                    crate::core::context_store::task_signals::record(
340                        &origin,
341                        crate::core::context_store::task_signals::Signal::Bounce,
342                    );
343                });
344            }
345            crate::core::savings_ledger::record_bounce_event(wasted);
346            // K10: the compressed view was fetched back in full.
347            {
348                use crate::core::hive_signals as signals;
349                signals::refetched(&signals::read_mode_filter(&mode));
350            }
351            // Long-term per-path memory (#496): remember which exact
352            // files keep bouncing so auto-mode learns across restarts.
353            crate::core::path_mode_memory::record_bounce(&norm_path);
354            // Stigmergy (#540): a bounce marks this path as Stuck so
355            // other agents see friction here. Background: lock may block.
356            // The project was resolved at detection, inside the request.
357            if let Some(project_root) = project_root {
358                std::thread::spawn(move || {
359                    crate::core::scent_field::deposit_for(
360                        &project_root,
361                        crate::core::scent_field::scent_agent_id(),
362                        crate::core::scent_field::ScentKind::Stuck,
363                        &norm_path,
364                        0.5,
365                    );
366                });
367            }
368        }
369    }
370
371    /// A shell read of `path` (`cat`, `head`, `sed -n`, …). A full read can
372    /// complete a correction of a preceding compressed view; a partial one
373    /// never does. Either way it becomes the path's newest read, so a second
374    /// `cat` cannot book the same correction twice.
375    pub fn record_shell_read(&mut self, path: &str, view: ReadView) {
376        let norm = crate::core::pathutil::normalize_tool_path(path);
377        self.commit_due();
378        let seq = self.seq_counter;
379        if view == ReadView::Full {
380            self.detect_bounce(&norm, path, seq);
381        }
382        self.push_event(
383            norm,
384            ReadEvent {
385                _mode: "shell".to_string(),
386                tokens_sent: 0,
387                _original_tokens: 0,
388                seq,
389                // A shell read is never the compressed view of a correction.
390                view: if view == ReadView::Compressed {
391                    ReadView::Partial
392                } else {
393                    view
394                },
395                mtime: None,
396                origin: None,
397            },
398        );
399        self.prune_stale_paths();
400    }
401
402    /// Records an explicit archive/context expansion as a quality bounce.
403    ///
404    /// Unlike a full file re-read, `ctx_expand` may address a content handle
405    /// rather than a path, so it cannot always be inferred by `detect_bounce`.
406    /// Benchmark replay uses this method to keep the quality proxy on the same
407    /// tracker as ordinary compressed-then-full bounces.
408    pub fn record_expansion(&mut self, source: Option<&str>, wasted_tokens: usize) {
409        self.total_bounces = self.total_bounces.saturating_add(1);
410        self.total_wasted_tokens = self.total_wasted_tokens.saturating_add(wasted_tokens);
411
412        if let Some(path) = source {
413            let ext = extension_of(path);
414            if !ext.is_empty() {
415                let stats = self.per_extension.entry(ext).or_default();
416                stats.bounces = stats.bounces.saturating_add(1);
417                stats.wasted_tokens = stats.wasted_tokens.saturating_add(wasted_tokens);
418            }
419        }
420    }
421
422    /// The newest read of `path` this process still remembers: whether it
423    /// was compressed and the scope and task it ran under, so a later event
424    /// is charged to the read that caused it. `None`: no read is known here
425    /// (another process, or before a restart). A partial read (line window)
426    /// is precise, not lossy, so it reports `false` like a full read.
427    pub fn last_read(
428        &self,
429        path: &str,
430    ) -> Option<(
431        bool,
432        Option<crate::core::context_store::task_signals::Origin>,
433    )> {
434        let norm = crate::core::pathutil::normalize_tool_path(path);
435        let event = self.recent_reads.get(&norm)?.last()?;
436        Some((event.view == ReadView::Compressed, event.origin.clone()))
437    }
438
439    /// An edit of `path`. Besides exempting the full re-reads it forces, it
440    /// cancels any provisional bounce of the path still inside its edit
441    /// window: the compressed→full re-read was the read-before-edit, not a
442    /// compression failure.
443    pub fn record_edit(&mut self, path: &str) {
444        let norm = crate::core::pathutil::normalize_tool_path(path);
445        self.commit_due();
446        self.pending.retain(|p| p.path != norm);
447        self.recently_edited.insert(norm, self.seq_counter);
448        self.prune_stale_paths();
449    }
450
451    /// Evict outer-map entries whose newest seq is older than the detection windows —
452    /// they can no longer affect bounce detection or `should_force_full`. Bounds the
453    /// `recent_reads` / `recently_edited` maps on a long-lived process.
454    fn prune_stale_paths(&mut self) {
455        let seq = self.seq_counter;
456        self.recent_reads.retain(|_, events| {
457            events
458                .last()
459                .is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
460        });
461        self.recently_edited
462            .retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
463    }
464
465    pub fn should_force_full(&self, path: &str) -> bool {
466        let norm = crate::core::pathutil::normalize_tool_path(path);
467
468        if let Some(&edit_seq) = self.recently_edited.get(&norm)
469            && self.seq_counter.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
470        {
471            return true;
472        }
473
474        let ext = extension_of(path);
475        if !ext.is_empty()
476            && let Some(stats) = self.per_extension.get(&ext)
477            && stats.total_reads >= 3
478        {
479            let rate = stats.bounces as f64 / stats.total_reads as f64;
480            if rate >= BOUNCE_RATE_THRESHOLD {
481                return true;
482            }
483        }
484
485        false
486    }
487
488    pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
489        let ext = extension_of(path);
490        self.per_extension.get(&ext).and_then(|s| {
491            if s.total_reads >= 3 {
492                Some(s.bounces as f64 / s.total_reads as f64)
493            } else {
494                None
495            }
496        })
497    }
498
499    pub fn total_bounces(&self) -> u64 {
500        self.total_bounces
501    }
502
503    pub fn total_wasted_tokens(&self) -> usize {
504        self.total_wasted_tokens
505    }
506
507    pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
508        raw_savings as isize - self.total_wasted_tokens as isize
509    }
510
511    pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
512        let mut exts: Vec<_> = self
513            .per_extension
514            .iter()
515            .filter(|(_, s)| s.total_reads > 0)
516            .collect();
517        exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
518        exts.iter()
519            .take(10)
520            .map(|(ext, stats)| {
521                let rate = if stats.total_reads > 0 {
522                    stats.bounces as f64 / stats.total_reads as f64
523                } else {
524                    0.0
525                };
526                serde_json::json!({
527                    "ext": ext,
528                    "reads": stats.total_reads,
529                    "bounces": stats.bounces,
530                    "wasted_tokens": stats.wasted_tokens,
531                    "rate": (rate * 1000.0).round() / 1000.0,
532                })
533            })
534            .collect()
535    }
536
537    pub fn format_summary(&self) -> String {
538        if self.total_bounces == 0 {
539            return "Bounces: 0".to_string();
540        }
541        let mut lines = vec![format!(
542            "Bounces: {} ({} wasted tokens)",
543            self.total_bounces, self.total_wasted_tokens
544        )];
545        let mut exts: Vec<_> = self
546            .per_extension
547            .iter()
548            .filter(|(_, s)| s.bounces > 0)
549            .collect();
550        exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
551        for (ext, stats) in exts.iter().take(5) {
552            let rate = if stats.total_reads > 0 {
553                stats.bounces as f64 / stats.total_reads as f64 * 100.0
554            } else {
555                0.0
556            };
557            lines.push(format!(
558                "  {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
559                stats.bounces, stats.total_reads, stats.wasted_tokens,
560            ));
561        }
562        lines.join("\n")
563    }
564}
565
566static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
567
568pub fn global() -> &'static Mutex<BounceTracker> {
569    GLOBAL_TRACKER.get_or_init(|| {
570        // Seed from the persistent ledger so every process (including a fresh `gain`)
571        // accounts for historical bounce, then mark this tracker as the persisting one.
572        let summary = crate::core::savings_ledger::summary();
573        let mut bt = BounceTracker::new();
574        bt.total_wasted_tokens = summary.bounce_tokens as usize;
575        bt.total_bounces = summary.bounce_events as u64;
576        bt.persist = true;
577        Mutex::new(bt)
578    })
579}
580
581/// Process shutdown: commit the global tracker's provisional bounces. A no-op
582/// when this process never created the tracker (no read was served).
583pub fn flush_pending() {
584    if let Some(tracker) = GLOBAL_TRACKER.get()
585        && let Ok(mut bt) = tracker.lock()
586    {
587        bt.flush_pending();
588    }
589}
590
591fn now_unix_ms() -> u64 {
592    std::time::SystemTime::now()
593        .duration_since(std::time::UNIX_EPOCH)
594        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
595}
596
597// ── Edit beacon ─────────────────────────────────────────────────────────────
598//
599// Native host edits (Claude Code `Edit`/`Write`, Cursor `StrReplace`, …) are
600// observed by short-lived hook processes, not by the MCP server that holds the
601// in-memory tracker. They append `<unix_ms>\t<normalized path>` to a small
602// append-only file; a provisional bounce consults it once, at commit time, and
603// is cancelled when its path was edited after the detection. Lines are written
604// with one `O_APPEND` write each, so concurrent hook processes do not
605// interleave; the file rotates to `.1` above `EDIT_BEACON_MAX_BYTES`, and the
606// reader scans both generations.
607
608const EDIT_BEACON_FILE: &str = "edit_beacon.log";
609const EDIT_BEACON_MAX_BYTES: u64 = 128 * 1024;
610
611fn edit_beacon_path() -> Option<std::path::PathBuf> {
612    crate::core::data_dir::lean_ctx_data_dir()
613        .ok()
614        .map(|dir| dir.join(EDIT_BEACON_FILE))
615}
616
617fn rotated_beacon_path(path: &std::path::Path) -> std::path::PathBuf {
618    path.with_extension("log.1")
619}
620
621/// Hook processes: report a native host edit of `path` (best-effort).
622pub fn note_host_edit(path: &str) {
623    let Some(beacon) = edit_beacon_path() else {
624        return;
625    };
626    append_edit_beacon(&beacon, path, now_unix_ms());
627}
628
629/// Hook processes: report the files a native shell command rewrites
630/// (`sed -i`, `>`, `tee`, `cp`/`mv`, `apply_patch` bodies), relative paths
631/// resolved against `cwd`. Best-effort, like [`note_host_edit`].
632pub fn note_host_shell_edits(command: &str, cwd: &str) {
633    for path in crate::core::shell_file_effects::analyze(command).edits {
634        note_host_edit(&resolve_against(cwd, &path));
635    }
636}
637
638/// Hook processes: report the files of a Codex `apply_patch` patch body.
639pub fn note_host_patch_edits(patch: &str, cwd: &str) {
640    for path in crate::core::shell_file_effects::apply_patch_targets(patch) {
641        note_host_edit(&resolve_against(cwd, &path));
642    }
643}
644
645/// `path` made absolute against `cwd` (the hook payload's), falling back to
646/// the hook process's own working directory.
647fn resolve_against(cwd: &str, path: &str) -> String {
648    if std::path::Path::new(path).is_absolute() {
649        return path.to_string();
650    }
651    let base = if cwd.trim().is_empty() {
652        std::env::current_dir().ok()
653    } else {
654        Some(std::path::PathBuf::from(cwd))
655    };
656    base.map_or_else(
657        || path.to_string(),
658        |dir| dir.join(path).to_string_lossy().into_owned(),
659    )
660}
661
662fn append_edit_beacon(beacon: &std::path::Path, path: &str, unix_ms: u64) {
663    use std::io::Write;
664    let norm = crate::core::pathutil::normalize_tool_path(path);
665    if norm.is_empty() || norm.contains('\n') {
666        return;
667    }
668    if std::fs::metadata(beacon).is_ok_and(|m| m.len() > EDIT_BEACON_MAX_BYTES) {
669        let _ = std::fs::rename(beacon, rotated_beacon_path(beacon));
670    }
671    if let Some(parent) = beacon.parent() {
672        let _ = std::fs::create_dir_all(parent);
673    }
674    if let Ok(mut file) = std::fs::OpenOptions::new()
675        .create(true)
676        .append(true)
677        .open(beacon)
678    {
679        let _ = file.write_all(format!("{unix_ms}\t{norm}\n").as_bytes());
680    }
681}
682
683fn host_edited_since(norm_path: &str, since_unix_ms: u64) -> bool {
684    edit_beacon_path().is_some_and(|beacon| beacon_has_edit(&beacon, norm_path, since_unix_ms))
685}
686
687fn beacon_has_edit(beacon: &std::path::Path, norm_path: &str, since_unix_ms: u64) -> bool {
688    use std::io::Read;
689    [rotated_beacon_path(beacon), beacon.to_path_buf()]
690        .iter()
691        .any(|file| {
692            let Ok(handle) = std::fs::File::open(file) else {
693                return false;
694            };
695            let mut raw = String::new();
696            // Bounded: rotation keeps each generation near EDIT_BEACON_MAX_BYTES.
697            if handle
698                .take(EDIT_BEACON_MAX_BYTES * 2)
699                .read_to_string(&mut raw)
700                .is_err()
701            {
702                return false;
703            }
704            raw.lines().any(|line| {
705                line.split_once('\t').is_some_and(|(ts, path)| {
706                    path == norm_path && ts.parse::<u64>().is_ok_and(|ts| ts >= since_unix_ms)
707                })
708            })
709        })
710}
711
712#[cfg(test)]
713pub mod tests {
714    use super::*;
715
716    #[test]
717    fn no_bounce_when_first_read_is_full() {
718        let mut bt = BounceTracker::new();
719        bt.seq_counter = 1;
720        bt.record_read("src/main.rs", "full", 500, 500);
721        assert_eq!(bt.total_bounces(), 0);
722        assert_eq!(bt.total_wasted_tokens(), 0);
723    }
724
725    #[test]
726    fn bounce_detected_on_compressed_then_full() {
727        let mut bt = BounceTracker::new();
728        bt.seq_counter = 1;
729        bt.record_read("src/main.rs", "map", 50, 500);
730        bt.seq_counter = 2;
731        bt.record_read("src/main.rs", "full", 500, 500);
732        assert_eq!(
733            bt.pending_bounces(),
734            1,
735            "provisional until the edit window passes"
736        );
737        bt.flush_pending();
738        assert_eq!(bt.total_bounces(), 1);
739        assert_eq!(bt.total_wasted_tokens(), 50);
740    }
741
742    #[test]
743    fn full_reread_of_externally_edited_file_is_not_a_bounce() {
744        // A host's native Edit/Write runs in another process and never calls
745        // `record_edit`; the moved mtime is what exempts the re-read.
746        let dir = tempfile::tempdir().unwrap();
747        let file = dir.path().join("edited.rs");
748        std::fs::write(&file, "fn a() {}\n").unwrap();
749        let path = file.to_str().unwrap();
750        let old = std::time::SystemTime::now() - std::time::Duration::from_mins(2);
751        std::fs::File::options()
752            .write(true)
753            .open(&file)
754            .unwrap()
755            .set_times(std::fs::FileTimes::new().set_modified(old))
756            .unwrap();
757
758        let mut bt = BounceTracker::new();
759        bt.seq_counter = 1;
760        bt.record_read(path, "map", 40, 500);
761        std::fs::write(&file, "fn a() { changed() }\n").unwrap();
762        bt.seq_counter = 2;
763        bt.record_read(path, "full", 500, 500);
764        bt.flush_pending();
765        assert_eq!(bt.total_bounces(), 0, "re-read after an edit is no bounce");
766
767        // Unchanged file: the same pattern is still a real bounce.
768        bt.seq_counter = 10;
769        bt.record_read(path, "map", 40, 500);
770        bt.seq_counter = 11;
771        bt.record_read(path, "full", 500, 500);
772        bt.flush_pending();
773        assert_eq!(bt.total_bounces(), 1);
774    }
775
776    #[test]
777    fn no_bounce_outside_window() {
778        let mut bt = BounceTracker::new();
779        bt.seq_counter = 1;
780        bt.record_read("src/main.rs", "map", 50, 500);
781        bt.seq_counter = 10;
782        bt.record_read("src/main.rs", "full", 500, 500);
783        assert_eq!(bt.total_bounces(), 0);
784    }
785
786    #[test]
787    fn shell_access_triggers_bounce() {
788        let mut bt = BounceTracker::new();
789        bt.seq_counter = 1;
790        bt.record_read("config.yml", "signatures", 30, 400);
791        bt.seq_counter = 3;
792        bt.record_shell_read("config.yml", ReadView::Full);
793        // A second `cat` does not book the same correction again.
794        bt.seq_counter = 4;
795        bt.record_shell_read("config.yml", ReadView::Full);
796        bt.flush_pending();
797        assert_eq!(bt.total_bounces(), 1);
798        assert_eq!(bt.total_wasted_tokens(), 30);
799    }
800
801    #[test]
802    fn read_view_classification() {
803        for mode in ["full", "full-compact", "raw", "anchored"] {
804            assert_eq!(ReadView::of_mode(mode), ReadView::Full, "{mode}");
805        }
806        for mode in [
807            "lines:10-40",
808            "lines:5,10-20",
809            "lines:-30",
810            "anchored:10-20",
811            "diff",
812            "bogus",
813        ] {
814            assert_eq!(ReadView::of_mode(mode), ReadView::Partial, "{mode}");
815        }
816        for mode in [
817            "map",
818            "signatures",
819            "aggressive",
820            "entropy",
821            "task",
822            "reference",
823            "cognitive",
824            "mdl",
825            "auto",
826            "density:0.40",
827        ] {
828            assert_eq!(ReadView::of_mode(mode), ReadView::Compressed, "{mode}");
829        }
830    }
831
832    /// Reading a file window by window, then in full, is no correction:
833    /// windows are precise reads, not lossy views.
834    #[test]
835    fn lines_then_full_is_not_a_bounce() {
836        let mut bt = BounceTracker::new();
837        bt.next_seq();
838        bt.record_read("docs/guide.md", "lines:1-80", 300, 2000);
839        bt.next_seq();
840        bt.record_read("docs/guide.md", "lines:81-160", 300, 2000);
841        bt.next_seq();
842        bt.record_read("docs/guide.md", "full", 2000, 2000);
843        bt.next_seq();
844        bt.record_shell_read("docs/guide.md", ReadView::Full);
845        bt.flush_pending();
846        assert_eq!(bt.total_bounces(), 0);
847    }
848
849    #[test]
850    fn map_then_head_is_not_a_bounce() {
851        let mut bt = BounceTracker::new();
852        bt.next_seq();
853        bt.record_read("src/a.rs", "map", 40, 500);
854        bt.next_seq();
855        bt.record_shell_read("src/a.rs", ReadView::Partial);
856        assert_eq!(bt.pending_bounces(), 0);
857        // The window ended the chain: a later full read is not the map's fault.
858        bt.next_seq();
859        bt.record_shell_read("src/a.rs", ReadView::Full);
860        bt.flush_pending();
861        assert_eq!(bt.total_bounces(), 0);
862    }
863
864    #[test]
865    fn map_then_lines_window_is_not_a_bounce() {
866        let mut bt = BounceTracker::new();
867        bt.next_seq();
868        bt.record_read("src/a.rs", "map", 40, 500);
869        bt.next_seq();
870        bt.record_read("src/a.rs", "lines:20-60", 120, 500);
871        bt.flush_pending();
872        assert_eq!(bt.total_bounces(), 0);
873    }
874
875    #[test]
876    fn map_then_cat_is_a_bounce_unless_edited() {
877        let mut bt = BounceTracker::new();
878        bt.next_seq();
879        bt.record_read("src/a.rs", "map", 40, 500);
880        bt.next_seq();
881        bt.record_shell_read("src/a.rs", ReadView::Full);
882        assert_eq!(bt.pending_bounces(), 1);
883        bt.flush_pending();
884        assert_eq!(bt.total_bounces(), 1);
885        assert_eq!(bt.total_wasted_tokens(), 40);
886
887        let mut edited = BounceTracker::new();
888        edited.next_seq();
889        edited.record_read("src/b.rs", "map", 40, 500);
890        edited.next_seq();
891        edited.record_shell_read("src/b.rs", ReadView::Full);
892        edited.next_seq();
893        // e.g. `sed -i` through ctx_shell
894        edited.record_edit("src/b.rs");
895        edited.flush_pending();
896        assert_eq!(edited.total_bounces(), 0);
897    }
898
899    /// `anchored` is verbatim: it never starts a correction, and as full
900    /// content it completes one (cancelled by the `ctx_patch` that follows).
901    #[test]
902    fn anchored_is_full_content() {
903        let mut bt = BounceTracker::new();
904        bt.next_seq();
905        bt.record_read("src/a.rs", "anchored", 500, 500);
906        bt.next_seq();
907        bt.record_read("src/a.rs", "full", 500, 500);
908        assert_eq!(bt.pending_bounces(), 0, "anchored→full is no correction");
909
910        bt.next_seq();
911        bt.record_read("src/a.rs", "map", 40, 500);
912        bt.next_seq();
913        bt.record_read("src/a.rs", "anchored", 500, 500);
914        assert_eq!(bt.pending_bounces(), 1);
915        bt.next_seq();
916        bt.record_edit("src/a.rs");
917        bt.flush_pending();
918        assert_eq!(bt.total_bounces(), 0);
919    }
920
921    #[test]
922    fn diff_never_completes_a_correction() {
923        let mut bt = BounceTracker::new();
924        bt.next_seq();
925        bt.record_read("src/a.rs", "map", 40, 500);
926        bt.next_seq();
927        bt.record_read("src/a.rs", "diff", 20, 500);
928        bt.flush_pending();
929        assert_eq!(bt.total_bounces(), 0);
930    }
931
932    #[test]
933    fn partial_read_is_not_charged_as_compressed() {
934        let mut bt = BounceTracker::new();
935        bt.next_seq();
936        bt.record_read("src/a.rs", "lines:1-20", 100, 500);
937        assert_eq!(bt.last_read("src/a.rs").map(|(c, _)| c), Some(false));
938        bt.next_seq();
939        bt.record_read("src/a.rs", "map", 40, 500);
940        assert_eq!(bt.last_read("src/a.rs").map(|(c, _)| c), Some(true));
941    }
942
943    #[test]
944    fn explicit_expansion_tracks_quality_and_waste() {
945        let mut bt = BounceTracker::new();
946        bt.record_expansion(Some("src/main.rs"), 125);
947        assert_eq!(bt.total_bounces(), 1);
948        assert_eq!(bt.total_wasted_tokens(), 125);
949    }
950
951    #[test]
952    fn edit_forced_full_read_is_not_a_bounce() {
953        // GL #622: a compressed overview read, then an edit, then the `full`
954        // re-read that `should_force_full` mandates must NOT register as a bounce
955        // — the edit forced it, the compression did not fail.
956        let mut bt = BounceTracker::new();
957        bt.seq_counter = 1;
958        bt.record_read("src/lib.rs", "map", 40, 500);
959        bt.seq_counter = 2;
960        bt.record_edit("src/lib.rs");
961        bt.seq_counter = 4;
962        bt.record_read("src/lib.rs", "full", 500, 500);
963        assert_eq!(
964            bt.total_bounces(),
965            0,
966            "edit-forced full read must not count as a compression bounce"
967        );
968    }
969
970    #[test]
971    fn full_read_without_edit_still_bounces() {
972        // The guard is scoped to edit-forced reads only: an unprompted full
973        // re-read after a compressed read is still a real bounce.
974        let mut bt = BounceTracker::new();
975        bt.seq_counter = 1;
976        bt.record_read("src/lib.rs", "map", 40, 500);
977        bt.seq_counter = 3;
978        bt.record_read("src/lib.rs", "full", 500, 500);
979        bt.flush_pending();
980        assert_eq!(bt.total_bounces(), 1);
981    }
982
983    #[test]
984    fn should_force_full_after_edit() {
985        let mut bt = BounceTracker::new();
986        bt.seq_counter = 5;
987        bt.record_edit("src/lib.rs");
988        bt.seq_counter = 8;
989        assert!(bt.should_force_full("src/lib.rs"));
990        bt.seq_counter = 20;
991        assert!(!bt.should_force_full("src/lib.rs"));
992    }
993
994    #[test]
995    fn should_force_full_by_extension_bounce_rate() {
996        let mut bt = BounceTracker::new();
997        for i in 1..=6 {
998            bt.seq_counter = i * 2 - 1;
999            bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
1000            bt.seq_counter = i * 2;
1001            bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
1002        }
1003        bt.flush_pending();
1004        assert!(bt.should_force_full("new.yml"));
1005    }
1006
1007    #[test]
1008    fn adjusted_savings_subtracts_waste() {
1009        let mut bt = BounceTracker::new();
1010        bt.seq_counter = 1;
1011        bt.record_read("a.rs", "map", 50, 500);
1012        bt.seq_counter = 2;
1013        bt.record_read("a.rs", "full", 500, 500);
1014        bt.flush_pending();
1015        assert_eq!(bt.adjusted_savings(1000), 950);
1016    }
1017
1018    #[test]
1019    fn bounce_rate_for_extension_below_minimum() {
1020        let bt = BounceTracker::new();
1021        assert!(bt.bounce_rate_for_extension("test.rs").is_none());
1022    }
1023
1024    #[test]
1025    fn format_summary_empty() {
1026        let bt = BounceTracker::new();
1027        assert_eq!(bt.format_summary(), "Bounces: 0");
1028    }
1029
1030    #[test]
1031    fn map_full_edit_is_not_a_bounce() {
1032        // Read-before-edit: orient compressed, read full, edit. The full read
1033        // was the edit's preparation, not a compression failure.
1034        let mut bt = BounceTracker::new();
1035        bt.next_seq();
1036        bt.record_read("src/edit_me.rs", "map", 40, 500);
1037        bt.next_seq();
1038        bt.record_read("src/edit_me.rs", "full", 500, 500);
1039        assert_eq!(bt.pending_bounces(), 1);
1040        for _ in 0..3 {
1041            bt.next_seq();
1042        }
1043        bt.record_edit("src/edit_me.rs");
1044        assert_eq!(
1045            bt.pending_bounces(),
1046            0,
1047            "the edit cancels the provisional bounce"
1048        );
1049        for _ in 0..(EDIT_FORCE_WINDOW + 5) {
1050            bt.next_seq();
1051        }
1052        bt.flush_pending();
1053        assert_eq!(bt.total_bounces(), 0);
1054        assert_eq!(bt.total_wasted_tokens(), 0);
1055    }
1056
1057    #[test]
1058    fn map_full_without_edit_commits_when_window_passes() {
1059        let mut bt = BounceTracker::new();
1060        bt.next_seq();
1061        bt.record_read("src/only_read.rs", "map", 40, 500);
1062        bt.next_seq();
1063        bt.record_read("src/only_read.rs", "full", 500, 500);
1064        for _ in 0..EDIT_FORCE_WINDOW {
1065            bt.next_seq();
1066        }
1067        assert_eq!(bt.total_bounces(), 0, "still inside the edit window");
1068        bt.next_seq();
1069        assert_eq!(bt.pending_bounces(), 0);
1070        assert_eq!(bt.total_bounces(), 1, "committed once the window passed");
1071        assert_eq!(bt.total_wasted_tokens(), 40);
1072        // An edit after the window no longer changes the booked bounce.
1073        bt.record_edit("src/only_read.rs");
1074        assert_eq!(bt.total_bounces(), 1);
1075    }
1076
1077    #[test]
1078    fn edit_of_another_path_keeps_the_bounce() {
1079        let mut bt = BounceTracker::new();
1080        bt.next_seq();
1081        bt.record_read("src/a.rs", "map", 40, 500);
1082        bt.next_seq();
1083        bt.record_read("src/a.rs", "full", 500, 500);
1084        bt.record_edit("src/b.rs");
1085        bt.flush_pending();
1086        assert_eq!(bt.total_bounces(), 1);
1087    }
1088
1089    #[test]
1090    fn flush_pending_commits_at_shutdown() {
1091        let mut bt = BounceTracker::new();
1092        bt.next_seq();
1093        bt.record_read("src/late.rs", "signatures", 25, 300);
1094        bt.next_seq();
1095        bt.record_read("src/late.rs", "full", 300, 300);
1096        assert_eq!(bt.total_bounces(), 0);
1097        bt.flush_pending();
1098        assert_eq!(bt.pending_bounces(), 0);
1099        assert_eq!(bt.total_bounces(), 1);
1100        assert_eq!(bt.total_wasted_tokens(), 25);
1101    }
1102
1103    #[test]
1104    fn pending_queue_is_bounded() {
1105        let mut bt = BounceTracker::new();
1106        for i in 0..(MAX_PENDING_BOUNCES + 4) {
1107            let path = format!("src/burst{i}.rs");
1108            bt.record_read(&path, "map", 10, 100);
1109            bt.record_read(&path, "full", 100, 100);
1110        }
1111        assert_eq!(bt.pending_bounces(), MAX_PENDING_BOUNCES);
1112        assert_eq!(bt.total_bounces(), 4, "overflow commits the oldest early");
1113    }
1114
1115    #[test]
1116    fn edit_beacon_reports_host_edits_after_detection() {
1117        let dir = tempfile::tempdir().unwrap();
1118        let beacon = dir.path().join(EDIT_BEACON_FILE);
1119        let file = dir.path().join("edited.rs");
1120        std::fs::write(&file, "fn main() {}\n").unwrap();
1121        let path = file.to_string_lossy().to_string();
1122        let norm = crate::core::pathutil::normalize_tool_path(&path);
1123
1124        assert!(!beacon_has_edit(&beacon, &norm, 0), "no beacon yet");
1125        append_edit_beacon(&beacon, &path, 2_000);
1126        assert!(
1127            beacon_has_edit(&beacon, &norm, 1_500),
1128            "edit after detection"
1129        );
1130        assert!(
1131            !beacon_has_edit(&beacon, &norm, 2_500),
1132            "edit before detection"
1133        );
1134        assert!(!beacon_has_edit(&beacon, "/other/path.rs", 0));
1135    }
1136
1137    #[test]
1138    fn edit_beacon_rotation_keeps_the_previous_generation_readable() {
1139        let dir = tempfile::tempdir().unwrap();
1140        let beacon = dir.path().join(EDIT_BEACON_FILE);
1141        append_edit_beacon(&beacon, "/rotated/first.rs", 10);
1142        let filler = "x".repeat(usize::try_from(EDIT_BEACON_MAX_BYTES).unwrap() + 1);
1143        std::fs::OpenOptions::new()
1144            .append(true)
1145            .open(&beacon)
1146            .and_then(|mut f| std::io::Write::write_all(&mut f, filler.as_bytes()))
1147            .unwrap();
1148        append_edit_beacon(&beacon, "/rotated/second.rs", 20);
1149        assert!(rotated_beacon_path(&beacon).exists());
1150        assert!(beacon_has_edit(&beacon, "/rotated/first.rs", 0));
1151        assert!(beacon_has_edit(&beacon, "/rotated/second.rs", 0));
1152    }
1153}