use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
const BOUNCE_WINDOW: u64 = 5;
const BOUNCE_RATE_THRESHOLD: f64 = 0.30;
const EDIT_FORCE_WINDOW: u64 = 10;
const TRACKED_PATH_TTL_SEQ: u64 = 64;
#[derive(Debug, Clone)]
struct ReadEvent {
_mode: String,
tokens_sent: usize,
_original_tokens: usize,
seq: u64,
view: ReadView,
mtime: Option<std::time::SystemTime>,
origin: Option<crate::core::context_store::task_signals::Origin>,
}
fn file_mtime(path: &str) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadView {
Compressed,
Full,
Partial,
}
impl ReadView {
#[must_use]
pub fn of_mode(mode: &str) -> Self {
use crate::core::read_mode::ReadMode;
match mode.parse::<ReadMode>() {
Ok(
ReadMode::Full | ReadMode::FullCompact | ReadMode::Raw | ReadMode::Anchored(None),
) => ReadView::Full,
Ok(
ReadMode::Anchored(Some(_))
| ReadMode::Lines(_)
| ReadMode::LinesMulti(_)
| ReadMode::LinesTail(_)
| ReadMode::Diff,
)
| Err(_) => ReadView::Partial,
Ok(
ReadMode::Signatures
| ReadMode::Map
| ReadMode::Aggressive
| ReadMode::Entropy
| ReadMode::Cognitive
| ReadMode::Mdl
| ReadMode::Task
| ReadMode::Reference
| ReadMode::Auto
| ReadMode::Density(_),
) => ReadView::Compressed,
}
}
}
#[derive(Debug, Default)]
struct BounceStats {
total_reads: u64,
bounces: u64,
wasted_tokens: usize,
}
#[derive(Debug, Clone)]
struct PendingBounce {
path: String,
seq: u64,
wasted: usize,
origin: Option<crate::core::context_store::task_signals::Origin>,
detected_unix_ms: u64,
project_root: Option<String>,
mode: String,
}
#[derive(Debug, Default)]
pub struct BounceTracker {
recent_reads: HashMap<String, Vec<ReadEvent>>,
per_extension: HashMap<String, BounceStats>,
recently_edited: HashMap<String, u64>,
pending: Vec<PendingBounce>,
seq_counter: u64,
total_bounces: u64,
total_wasted_tokens: usize,
persist: bool,
}
const MAX_PENDING_BOUNCES: usize = 256;
fn extension_of(path: &str) -> String {
path.rsplit('.')
.next()
.map(|e| format!(".{}", e.to_ascii_lowercase()))
.unwrap_or_default()
}
impl BounceTracker {
pub fn new() -> Self {
Self::default()
}
pub fn next_seq(&mut self) -> u64 {
self.seq_counter += 1;
self.commit_due();
self.seq_counter
}
pub fn set_seq(&mut self, seq: u64) {
self.seq_counter = seq;
self.commit_due();
}
pub fn record_read(
&mut self,
path: &str,
mode: &str,
tokens_sent: usize,
original_tokens: usize,
) {
let norm = crate::core::pathutil::normalize_tool_path(path);
self.commit_due();
let seq = self.seq_counter;
let view = ReadView::of_mode(mode);
let compressed = view == ReadView::Compressed;
if view == ReadView::Full {
self.detect_bounce(&norm, path, seq);
}
if self.persist && compressed {
{
use crate::core::hive_signals as signals;
signals::truncated(&signals::read_mode_filter(mode));
}
crate::core::path_mode_memory::record_compressed_read_if_tracked(&norm);
}
let current = if self.persist {
crate::core::context_store::task_signals::current_origin()
} else {
None
};
if let Some(current) = ¤t {
crate::core::context_store::task_signals::observe(current);
}
let origin = current.filter(|_| compressed);
self.push_event(
norm,
ReadEvent {
_mode: mode.to_string(),
tokens_sent,
_original_tokens: original_tokens,
seq,
view,
mtime: if compressed { file_mtime(path) } else { None },
origin,
},
);
let ext = extension_of(path);
if !ext.is_empty() {
let stats = self.per_extension.entry(ext).or_default();
stats.total_reads += 1;
}
self.prune_stale_paths();
}
fn push_event(&mut self, norm: String, event: ReadEvent) {
let events = self.recent_reads.entry(norm).or_default();
events.push(event);
if events.len() > 10 {
events.drain(..events.len() - 10);
}
}
fn detect_bounce(&mut self, norm_path: &str, fs_path: &str, full_seq: u64) {
if let Some(&edit_seq) = self.recently_edited.get(norm_path)
&& full_seq.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
{
return;
}
let Some(events) = self.recent_reads.get(norm_path) else {
return;
};
if let Some(ev) = events.iter().next_back()
&& ev.view == ReadView::Compressed
&& full_seq.saturating_sub(ev.seq) <= BOUNCE_WINDOW
&& (ev.mtime.is_none() || file_mtime(fs_path) == ev.mtime)
{
let pending = PendingBounce {
path: norm_path.to_string(),
seq: full_seq,
wasted: ev.tokens_sent,
origin: ev.origin.clone(),
detected_unix_ms: now_unix_ms(),
project_root: crate::core::scent_field::current_project(),
mode: ev._mode.clone(),
};
self.pending.push(pending);
if self.pending.len() > MAX_PENDING_BOUNCES {
let oldest = self.pending.remove(0);
self.commit(oldest);
}
}
}
fn commit_due(&mut self) {
if self.pending.is_empty() {
return;
}
let now = self.seq_counter;
let (due, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
.into_iter()
.partition(|p| now.saturating_sub(p.seq) > EDIT_FORCE_WINDOW);
self.pending = keep;
for pending in due {
self.commit(pending);
}
}
pub fn flush_pending(&mut self) {
for pending in std::mem::take(&mut self.pending) {
self.commit(pending);
}
}
pub fn pending_bounces(&self) -> usize {
self.pending.len()
}
fn commit(&mut self, pending: PendingBounce) {
if self.persist && host_edited_since(&pending.path, pending.detected_unix_ms) {
return;
}
let PendingBounce {
path: norm_path,
wasted,
origin,
project_root,
mode,
..
} = pending;
self.total_bounces += 1;
self.total_wasted_tokens += wasted;
let ext = extension_of(&norm_path);
if !ext.is_empty() {
let stats = self.per_extension.entry(ext).or_default();
stats.bounces += 1;
stats.wasted_tokens += wasted;
}
if self.persist {
if let Some(origin) = origin {
std::thread::spawn(move || {
crate::core::context_store::task_signals::record(
&origin,
crate::core::context_store::task_signals::Signal::Bounce,
);
});
}
crate::core::savings_ledger::record_bounce_event(wasted);
{
use crate::core::hive_signals as signals;
signals::refetched(&signals::read_mode_filter(&mode));
}
crate::core::path_mode_memory::record_bounce(&norm_path);
if let Some(project_root) = project_root {
std::thread::spawn(move || {
crate::core::scent_field::deposit_for(
&project_root,
crate::core::scent_field::scent_agent_id(),
crate::core::scent_field::ScentKind::Stuck,
&norm_path,
0.5,
);
});
}
}
}
pub fn record_shell_read(&mut self, path: &str, view: ReadView) {
let norm = crate::core::pathutil::normalize_tool_path(path);
self.commit_due();
let seq = self.seq_counter;
if view == ReadView::Full {
self.detect_bounce(&norm, path, seq);
}
self.push_event(
norm,
ReadEvent {
_mode: "shell".to_string(),
tokens_sent: 0,
_original_tokens: 0,
seq,
view: if view == ReadView::Compressed {
ReadView::Partial
} else {
view
},
mtime: None,
origin: None,
},
);
self.prune_stale_paths();
}
pub fn record_expansion(&mut self, source: Option<&str>, wasted_tokens: usize) {
self.total_bounces = self.total_bounces.saturating_add(1);
self.total_wasted_tokens = self.total_wasted_tokens.saturating_add(wasted_tokens);
if let Some(path) = source {
let ext = extension_of(path);
if !ext.is_empty() {
let stats = self.per_extension.entry(ext).or_default();
stats.bounces = stats.bounces.saturating_add(1);
stats.wasted_tokens = stats.wasted_tokens.saturating_add(wasted_tokens);
}
}
}
pub fn last_read(
&self,
path: &str,
) -> Option<(
bool,
Option<crate::core::context_store::task_signals::Origin>,
)> {
let norm = crate::core::pathutil::normalize_tool_path(path);
let event = self.recent_reads.get(&norm)?.last()?;
Some((event.view == ReadView::Compressed, event.origin.clone()))
}
pub fn record_edit(&mut self, path: &str) {
let norm = crate::core::pathutil::normalize_tool_path(path);
self.commit_due();
self.pending.retain(|p| p.path != norm);
self.recently_edited.insert(norm, self.seq_counter);
self.prune_stale_paths();
}
fn prune_stale_paths(&mut self) {
let seq = self.seq_counter;
self.recent_reads.retain(|_, events| {
events
.last()
.is_some_and(|e| seq.saturating_sub(e.seq) <= TRACKED_PATH_TTL_SEQ)
});
self.recently_edited
.retain(|_, &mut edit_seq| seq.saturating_sub(edit_seq) <= TRACKED_PATH_TTL_SEQ);
}
pub fn should_force_full(&self, path: &str) -> bool {
let norm = crate::core::pathutil::normalize_tool_path(path);
if let Some(&edit_seq) = self.recently_edited.get(&norm)
&& self.seq_counter.saturating_sub(edit_seq) <= EDIT_FORCE_WINDOW
{
return true;
}
let ext = extension_of(path);
if !ext.is_empty()
&& let Some(stats) = self.per_extension.get(&ext)
&& stats.total_reads >= 3
{
let rate = stats.bounces as f64 / stats.total_reads as f64;
if rate >= BOUNCE_RATE_THRESHOLD {
return true;
}
}
false
}
pub fn bounce_rate_for_extension(&self, path: &str) -> Option<f64> {
let ext = extension_of(path);
self.per_extension.get(&ext).and_then(|s| {
if s.total_reads >= 3 {
Some(s.bounces as f64 / s.total_reads as f64)
} else {
None
}
})
}
pub fn total_bounces(&self) -> u64 {
self.total_bounces
}
pub fn total_wasted_tokens(&self) -> usize {
self.total_wasted_tokens
}
pub fn adjusted_savings(&self, raw_savings: usize) -> isize {
raw_savings as isize - self.total_wasted_tokens as isize
}
pub fn per_extension_json(&self) -> Vec<serde_json::Value> {
let mut exts: Vec<_> = self
.per_extension
.iter()
.filter(|(_, s)| s.total_reads > 0)
.collect();
exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
exts.iter()
.take(10)
.map(|(ext, stats)| {
let rate = if stats.total_reads > 0 {
stats.bounces as f64 / stats.total_reads as f64
} else {
0.0
};
serde_json::json!({
"ext": ext,
"reads": stats.total_reads,
"bounces": stats.bounces,
"wasted_tokens": stats.wasted_tokens,
"rate": (rate * 1000.0).round() / 1000.0,
})
})
.collect()
}
pub fn format_summary(&self) -> String {
if self.total_bounces == 0 {
return "Bounces: 0".to_string();
}
let mut lines = vec![format!(
"Bounces: {} ({} wasted tokens)",
self.total_bounces, self.total_wasted_tokens
)];
let mut exts: Vec<_> = self
.per_extension
.iter()
.filter(|(_, s)| s.bounces > 0)
.collect();
exts.sort_by_key(|a| std::cmp::Reverse(a.1.bounces));
for (ext, stats) in exts.iter().take(5) {
let rate = if stats.total_reads > 0 {
stats.bounces as f64 / stats.total_reads as f64 * 100.0
} else {
0.0
};
lines.push(format!(
" {ext}: {}/{} reads bounced ({rate:.0}%), {} tok wasted",
stats.bounces, stats.total_reads, stats.wasted_tokens,
));
}
lines.join("\n")
}
}
static GLOBAL_TRACKER: OnceLock<Mutex<BounceTracker>> = OnceLock::new();
pub fn global() -> &'static Mutex<BounceTracker> {
GLOBAL_TRACKER.get_or_init(|| {
let summary = crate::core::savings_ledger::summary();
let mut bt = BounceTracker::new();
bt.total_wasted_tokens = summary.bounce_tokens as usize;
bt.total_bounces = summary.bounce_events as u64;
bt.persist = true;
Mutex::new(bt)
})
}
pub fn flush_pending() {
if let Some(tracker) = GLOBAL_TRACKER.get()
&& let Ok(mut bt) = tracker.lock()
{
bt.flush_pending();
}
}
fn now_unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
const EDIT_BEACON_FILE: &str = "edit_beacon.log";
const EDIT_BEACON_MAX_BYTES: u64 = 128 * 1024;
fn edit_beacon_path() -> Option<std::path::PathBuf> {
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|dir| dir.join(EDIT_BEACON_FILE))
}
fn rotated_beacon_path(path: &std::path::Path) -> std::path::PathBuf {
path.with_extension("log.1")
}
pub fn note_host_edit(path: &str) {
let Some(beacon) = edit_beacon_path() else {
return;
};
append_edit_beacon(&beacon, path, now_unix_ms());
}
pub fn note_host_shell_edits(command: &str, cwd: &str) {
for path in crate::core::shell_file_effects::analyze(command).edits {
note_host_edit(&resolve_against(cwd, &path));
}
}
pub fn note_host_patch_edits(patch: &str, cwd: &str) {
for path in crate::core::shell_file_effects::apply_patch_targets(patch) {
note_host_edit(&resolve_against(cwd, &path));
}
}
fn resolve_against(cwd: &str, path: &str) -> String {
if std::path::Path::new(path).is_absolute() {
return path.to_string();
}
let base = if cwd.trim().is_empty() {
std::env::current_dir().ok()
} else {
Some(std::path::PathBuf::from(cwd))
};
base.map_or_else(
|| path.to_string(),
|dir| dir.join(path).to_string_lossy().into_owned(),
)
}
fn append_edit_beacon(beacon: &std::path::Path, path: &str, unix_ms: u64) {
use std::io::Write;
let norm = crate::core::pathutil::normalize_tool_path(path);
if norm.is_empty() || norm.contains('\n') {
return;
}
if std::fs::metadata(beacon).is_ok_and(|m| m.len() > EDIT_BEACON_MAX_BYTES) {
let _ = std::fs::rename(beacon, rotated_beacon_path(beacon));
}
if let Some(parent) = beacon.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(beacon)
{
let _ = file.write_all(format!("{unix_ms}\t{norm}\n").as_bytes());
}
}
fn host_edited_since(norm_path: &str, since_unix_ms: u64) -> bool {
edit_beacon_path().is_some_and(|beacon| beacon_has_edit(&beacon, norm_path, since_unix_ms))
}
fn beacon_has_edit(beacon: &std::path::Path, norm_path: &str, since_unix_ms: u64) -> bool {
use std::io::Read;
[rotated_beacon_path(beacon), beacon.to_path_buf()]
.iter()
.any(|file| {
let Ok(handle) = std::fs::File::open(file) else {
return false;
};
let mut raw = String::new();
if handle
.take(EDIT_BEACON_MAX_BYTES * 2)
.read_to_string(&mut raw)
.is_err()
{
return false;
}
raw.lines().any(|line| {
line.split_once('\t').is_some_and(|(ts, path)| {
path == norm_path && ts.parse::<u64>().is_ok_and(|ts| ts >= since_unix_ms)
})
})
})
}
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn no_bounce_when_first_read_is_full() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("src/main.rs", "full", 500, 500);
assert_eq!(bt.total_bounces(), 0);
assert_eq!(bt.total_wasted_tokens(), 0);
}
#[test]
fn bounce_detected_on_compressed_then_full() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("src/main.rs", "map", 50, 500);
bt.seq_counter = 2;
bt.record_read("src/main.rs", "full", 500, 500);
assert_eq!(
bt.pending_bounces(),
1,
"provisional until the edit window passes"
);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 1);
assert_eq!(bt.total_wasted_tokens(), 50);
}
#[test]
fn full_reread_of_externally_edited_file_is_not_a_bounce() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("edited.rs");
std::fs::write(&file, "fn a() {}\n").unwrap();
let path = file.to_str().unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_mins(2);
std::fs::File::options()
.write(true)
.open(&file)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(old))
.unwrap();
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read(path, "map", 40, 500);
std::fs::write(&file, "fn a() { changed() }\n").unwrap();
bt.seq_counter = 2;
bt.record_read(path, "full", 500, 500);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0, "re-read after an edit is no bounce");
bt.seq_counter = 10;
bt.record_read(path, "map", 40, 500);
bt.seq_counter = 11;
bt.record_read(path, "full", 500, 500);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 1);
}
#[test]
fn no_bounce_outside_window() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("src/main.rs", "map", 50, 500);
bt.seq_counter = 10;
bt.record_read("src/main.rs", "full", 500, 500);
assert_eq!(bt.total_bounces(), 0);
}
#[test]
fn shell_access_triggers_bounce() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("config.yml", "signatures", 30, 400);
bt.seq_counter = 3;
bt.record_shell_read("config.yml", ReadView::Full);
bt.seq_counter = 4;
bt.record_shell_read("config.yml", ReadView::Full);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 1);
assert_eq!(bt.total_wasted_tokens(), 30);
}
#[test]
fn read_view_classification() {
for mode in ["full", "full-compact", "raw", "anchored"] {
assert_eq!(ReadView::of_mode(mode), ReadView::Full, "{mode}");
}
for mode in [
"lines:10-40",
"lines:5,10-20",
"lines:-30",
"anchored:10-20",
"diff",
"bogus",
] {
assert_eq!(ReadView::of_mode(mode), ReadView::Partial, "{mode}");
}
for mode in [
"map",
"signatures",
"aggressive",
"entropy",
"task",
"reference",
"cognitive",
"mdl",
"auto",
"density:0.40",
] {
assert_eq!(ReadView::of_mode(mode), ReadView::Compressed, "{mode}");
}
}
#[test]
fn lines_then_full_is_not_a_bounce() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("docs/guide.md", "lines:1-80", 300, 2000);
bt.next_seq();
bt.record_read("docs/guide.md", "lines:81-160", 300, 2000);
bt.next_seq();
bt.record_read("docs/guide.md", "full", 2000, 2000);
bt.next_seq();
bt.record_shell_read("docs/guide.md", ReadView::Full);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0);
}
#[test]
fn map_then_head_is_not_a_bounce() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
bt.next_seq();
bt.record_shell_read("src/a.rs", ReadView::Partial);
assert_eq!(bt.pending_bounces(), 0);
bt.next_seq();
bt.record_shell_read("src/a.rs", ReadView::Full);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0);
}
#[test]
fn map_then_lines_window_is_not_a_bounce() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
bt.next_seq();
bt.record_read("src/a.rs", "lines:20-60", 120, 500);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0);
}
#[test]
fn map_then_cat_is_a_bounce_unless_edited() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
bt.next_seq();
bt.record_shell_read("src/a.rs", ReadView::Full);
assert_eq!(bt.pending_bounces(), 1);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 1);
assert_eq!(bt.total_wasted_tokens(), 40);
let mut edited = BounceTracker::new();
edited.next_seq();
edited.record_read("src/b.rs", "map", 40, 500);
edited.next_seq();
edited.record_shell_read("src/b.rs", ReadView::Full);
edited.next_seq();
edited.record_edit("src/b.rs");
edited.flush_pending();
assert_eq!(edited.total_bounces(), 0);
}
#[test]
fn anchored_is_full_content() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "anchored", 500, 500);
bt.next_seq();
bt.record_read("src/a.rs", "full", 500, 500);
assert_eq!(bt.pending_bounces(), 0, "anchored→full is no correction");
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
bt.next_seq();
bt.record_read("src/a.rs", "anchored", 500, 500);
assert_eq!(bt.pending_bounces(), 1);
bt.next_seq();
bt.record_edit("src/a.rs");
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0);
}
#[test]
fn diff_never_completes_a_correction() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
bt.next_seq();
bt.record_read("src/a.rs", "diff", 20, 500);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0);
}
#[test]
fn partial_read_is_not_charged_as_compressed() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "lines:1-20", 100, 500);
assert_eq!(bt.last_read("src/a.rs").map(|(c, _)| c), Some(false));
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
assert_eq!(bt.last_read("src/a.rs").map(|(c, _)| c), Some(true));
}
#[test]
fn explicit_expansion_tracks_quality_and_waste() {
let mut bt = BounceTracker::new();
bt.record_expansion(Some("src/main.rs"), 125);
assert_eq!(bt.total_bounces(), 1);
assert_eq!(bt.total_wasted_tokens(), 125);
}
#[test]
fn edit_forced_full_read_is_not_a_bounce() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("src/lib.rs", "map", 40, 500);
bt.seq_counter = 2;
bt.record_edit("src/lib.rs");
bt.seq_counter = 4;
bt.record_read("src/lib.rs", "full", 500, 500);
assert_eq!(
bt.total_bounces(),
0,
"edit-forced full read must not count as a compression bounce"
);
}
#[test]
fn full_read_without_edit_still_bounces() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("src/lib.rs", "map", 40, 500);
bt.seq_counter = 3;
bt.record_read("src/lib.rs", "full", 500, 500);
bt.flush_pending();
assert_eq!(bt.total_bounces(), 1);
}
#[test]
fn should_force_full_after_edit() {
let mut bt = BounceTracker::new();
bt.seq_counter = 5;
bt.record_edit("src/lib.rs");
bt.seq_counter = 8;
assert!(bt.should_force_full("src/lib.rs"));
bt.seq_counter = 20;
assert!(!bt.should_force_full("src/lib.rs"));
}
#[test]
fn should_force_full_by_extension_bounce_rate() {
let mut bt = BounceTracker::new();
for i in 1..=6 {
bt.seq_counter = i * 2 - 1;
bt.record_read(&format!("f{i}.yml"), "map", 30, 400);
bt.seq_counter = i * 2;
bt.record_read(&format!("f{i}.yml"), "full", 400, 400);
}
bt.flush_pending();
assert!(bt.should_force_full("new.yml"));
}
#[test]
fn adjusted_savings_subtracts_waste() {
let mut bt = BounceTracker::new();
bt.seq_counter = 1;
bt.record_read("a.rs", "map", 50, 500);
bt.seq_counter = 2;
bt.record_read("a.rs", "full", 500, 500);
bt.flush_pending();
assert_eq!(bt.adjusted_savings(1000), 950);
}
#[test]
fn bounce_rate_for_extension_below_minimum() {
let bt = BounceTracker::new();
assert!(bt.bounce_rate_for_extension("test.rs").is_none());
}
#[test]
fn format_summary_empty() {
let bt = BounceTracker::new();
assert_eq!(bt.format_summary(), "Bounces: 0");
}
#[test]
fn map_full_edit_is_not_a_bounce() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/edit_me.rs", "map", 40, 500);
bt.next_seq();
bt.record_read("src/edit_me.rs", "full", 500, 500);
assert_eq!(bt.pending_bounces(), 1);
for _ in 0..3 {
bt.next_seq();
}
bt.record_edit("src/edit_me.rs");
assert_eq!(
bt.pending_bounces(),
0,
"the edit cancels the provisional bounce"
);
for _ in 0..(EDIT_FORCE_WINDOW + 5) {
bt.next_seq();
}
bt.flush_pending();
assert_eq!(bt.total_bounces(), 0);
assert_eq!(bt.total_wasted_tokens(), 0);
}
#[test]
fn map_full_without_edit_commits_when_window_passes() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/only_read.rs", "map", 40, 500);
bt.next_seq();
bt.record_read("src/only_read.rs", "full", 500, 500);
for _ in 0..EDIT_FORCE_WINDOW {
bt.next_seq();
}
assert_eq!(bt.total_bounces(), 0, "still inside the edit window");
bt.next_seq();
assert_eq!(bt.pending_bounces(), 0);
assert_eq!(bt.total_bounces(), 1, "committed once the window passed");
assert_eq!(bt.total_wasted_tokens(), 40);
bt.record_edit("src/only_read.rs");
assert_eq!(bt.total_bounces(), 1);
}
#[test]
fn edit_of_another_path_keeps_the_bounce() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/a.rs", "map", 40, 500);
bt.next_seq();
bt.record_read("src/a.rs", "full", 500, 500);
bt.record_edit("src/b.rs");
bt.flush_pending();
assert_eq!(bt.total_bounces(), 1);
}
#[test]
fn flush_pending_commits_at_shutdown() {
let mut bt = BounceTracker::new();
bt.next_seq();
bt.record_read("src/late.rs", "signatures", 25, 300);
bt.next_seq();
bt.record_read("src/late.rs", "full", 300, 300);
assert_eq!(bt.total_bounces(), 0);
bt.flush_pending();
assert_eq!(bt.pending_bounces(), 0);
assert_eq!(bt.total_bounces(), 1);
assert_eq!(bt.total_wasted_tokens(), 25);
}
#[test]
fn pending_queue_is_bounded() {
let mut bt = BounceTracker::new();
for i in 0..(MAX_PENDING_BOUNCES + 4) {
let path = format!("src/burst{i}.rs");
bt.record_read(&path, "map", 10, 100);
bt.record_read(&path, "full", 100, 100);
}
assert_eq!(bt.pending_bounces(), MAX_PENDING_BOUNCES);
assert_eq!(bt.total_bounces(), 4, "overflow commits the oldest early");
}
#[test]
fn edit_beacon_reports_host_edits_after_detection() {
let dir = tempfile::tempdir().unwrap();
let beacon = dir.path().join(EDIT_BEACON_FILE);
let file = dir.path().join("edited.rs");
std::fs::write(&file, "fn main() {}\n").unwrap();
let path = file.to_string_lossy().to_string();
let norm = crate::core::pathutil::normalize_tool_path(&path);
assert!(!beacon_has_edit(&beacon, &norm, 0), "no beacon yet");
append_edit_beacon(&beacon, &path, 2_000);
assert!(
beacon_has_edit(&beacon, &norm, 1_500),
"edit after detection"
);
assert!(
!beacon_has_edit(&beacon, &norm, 2_500),
"edit before detection"
);
assert!(!beacon_has_edit(&beacon, "/other/path.rs", 0));
}
#[test]
fn edit_beacon_rotation_keeps_the_previous_generation_readable() {
let dir = tempfile::tempdir().unwrap();
let beacon = dir.path().join(EDIT_BEACON_FILE);
append_edit_beacon(&beacon, "/rotated/first.rs", 10);
let filler = "x".repeat(usize::try_from(EDIT_BEACON_MAX_BYTES).unwrap() + 1);
std::fs::OpenOptions::new()
.append(true)
.open(&beacon)
.and_then(|mut f| std::io::Write::write_all(&mut f, filler.as_bytes()))
.unwrap();
append_edit_beacon(&beacon, "/rotated/second.rs", 20);
assert!(rotated_beacon_path(&beacon).exists());
assert!(beacon_has_edit(&beacon, "/rotated/first.rs", 0));
assert!(beacon_has_edit(&beacon, "/rotated/second.rs", 0));
}
}