Skip to main content

lean_ctx/core/
heatmap.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicUsize, Ordering};
6use std::time::{Duration, Instant};
7
8const HEATMAP_FLUSH_EVERY: usize = 25;
9const HEATMAP_MAX_ENTRIES: usize = 10_000;
10/// The heat map is rewritten whole on every save; a long-lived process saves
11/// it at most this often (and once more when it exits, [`flush_now`]).
12const HEATMAP_SAVE_EVERY: Duration = Duration::from_secs(30);
13
14static HEATMAP_BUFFER: Mutex<Option<HeatMap>> = Mutex::new(None);
15static HEATMAP_CALLS: AtomicUsize = AtomicUsize::new(0);
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HeatEntry {
19    pub path: String,
20    pub access_count: u32,
21    pub last_access: String,
22    pub total_tokens_saved: u64,
23    pub total_original_tokens: u64,
24    pub avg_compression_ratio: f32,
25    /// Per-agent access counts — the stigmergic pheromone field.  When multiple
26    /// agents access the same file, downstream consumers can identify shared
27    /// context (co-access patterns) and compute credit for useful preloads.
28    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
29    pub agent_accesses: HashMap<String, u32>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct HeatMap {
34    pub entries: HashMap<String, HeatEntry>,
35    #[serde(skip)]
36    dirty: bool,
37    /// When and where this process last saved the map: a save to another
38    /// file (another data dir) is never put off.
39    #[serde(skip)]
40    last_save: Option<(Instant, PathBuf)>,
41}
42
43impl HeatMap {
44    pub fn load() -> Self {
45        let mut guard = HEATMAP_BUFFER
46            .lock()
47            .unwrap_or_else(std::sync::PoisonError::into_inner);
48        if let Some(ref hm) = *guard {
49            return hm.clone();
50        }
51        let hm = load_from_disk();
52        *guard = Some(hm.clone());
53        hm
54    }
55
56    pub fn record_access(&mut self, file_path: &str, original_tokens: usize, saved_tokens: usize) {
57        self.record_access_with_agent(file_path, original_tokens, saved_tokens, None);
58    }
59
60    /// Record a file access with an optional agent identifier (stigmergic trace).
61    pub fn record_access_with_agent(
62        &mut self,
63        file_path: &str,
64        original_tokens: usize,
65        saved_tokens: usize,
66        agent_id: Option<&str>,
67    ) {
68        let now = chrono::Utc::now().to_rfc3339();
69        let entry = self
70            .entries
71            .entry(file_path.to_string())
72            .or_insert_with(|| HeatEntry {
73                path: file_path.to_string(),
74                access_count: 0,
75                last_access: now.clone(),
76                total_tokens_saved: 0,
77                total_original_tokens: 0,
78                avg_compression_ratio: 0.0,
79                agent_accesses: HashMap::new(),
80            });
81        entry.access_count += 1;
82        entry.last_access = now;
83        entry.total_tokens_saved += saved_tokens as u64;
84        entry.total_original_tokens += original_tokens as u64;
85        if entry.total_original_tokens > 0 {
86            entry.avg_compression_ratio = 1.0
87                - (entry.total_original_tokens - entry.total_tokens_saved) as f32
88                    / entry.total_original_tokens as f32;
89        }
90        if let Some(aid) = agent_id
91            && !aid.is_empty()
92        {
93            *entry.agent_accesses.entry(aid.to_string()).or_insert(0) += 1;
94        }
95        self.dirty = true;
96    }
97
98    pub fn save(&self) -> std::io::Result<()> {
99        if !self.dirty && !self.entries.is_empty() {
100            return Ok(());
101        }
102        save_to_disk(self)?;
103        let mut saved = self.clone();
104        saved.dirty = false;
105        saved.last_save = Some((Instant::now(), Self::storage_path()));
106        let mut guard = HEATMAP_BUFFER
107            .lock()
108            .unwrap_or_else(std::sync::PoisonError::into_inner);
109        *guard = Some(saved);
110        Ok(())
111    }
112
113    pub fn top_files(&self, limit: usize) -> Vec<&HeatEntry> {
114        let mut sorted: Vec<&HeatEntry> = self.entries.values().collect();
115        sorted.sort_by_key(|x| std::cmp::Reverse(x.access_count));
116        sorted.truncate(limit);
117        sorted
118    }
119
120    /// Mean original (pre-compression) token size of a recorded file access.
121    /// `None` when nothing has been recorded yet — callers must NOT substitute a
122    /// guessed constant (this backs the ghost report's redundant-read estimate).
123    pub fn avg_original_tokens_per_access(&self) -> Option<u64> {
124        let mut total_original: u64 = 0;
125        let mut total_accesses: u64 = 0;
126        for e in self.entries.values() {
127            total_original = total_original.saturating_add(e.total_original_tokens);
128            total_accesses = total_accesses.saturating_add(u64::from(e.access_count));
129        }
130        (total_accesses > 0).then(|| total_original / total_accesses)
131    }
132
133    /// Compute stigmergic context credit: which agents' file-access traces
134    /// benefited other agents? An agent A gets credit for a file F when A
135    /// accessed F before (or alongside) agent B, because A's trace effectively
136    /// pointed B to useful context. The credit for each (agent_A, file) pair is
137    /// proportional to how many *other* agents also accessed that file.
138    /// Returns `Vec<(agent_id, total_credit)>` sorted descending.
139    pub fn context_credit(&self) -> Vec<(String, f64)> {
140        let mut credit: HashMap<String, f64> = HashMap::new();
141        for entry in self.entries.values() {
142            let n_agents = entry.agent_accesses.len();
143            if n_agents < 2 {
144                continue;
145            }
146            // Shapley-inspired: each agent that accessed a shared file gets
147            // credit = (n_other_agents) / n_agents. The more agents a file
148            // served, the more each contributor is credited.
149            let share = (n_agents - 1) as f64 / n_agents as f64;
150            for agent in entry.agent_accesses.keys() {
151                *credit.entry(agent.clone()).or_insert(0.0) += share;
152            }
153        }
154        let mut sorted: Vec<(String, f64)> = credit.into_iter().collect();
155        sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
156        sorted
157    }
158
159    pub fn directory_summary(&self) -> Vec<(String, u32, u64)> {
160        let mut dirs: HashMap<String, (u32, u64)> = HashMap::new();
161        for entry in self.entries.values() {
162            let dir = std::path::Path::new(&entry.path)
163                .parent()
164                .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
165            let stat = dirs.entry(dir).or_insert((0, 0));
166            stat.0 += entry.access_count;
167            stat.1 += entry.total_tokens_saved;
168        }
169        let mut result: Vec<(String, u32, u64)> = dirs
170            .into_iter()
171            .map(|(dir, (count, saved))| (dir, count, saved))
172            .collect();
173        result.sort_by_key(|x| std::cmp::Reverse(x.1));
174        result
175    }
176
177    pub fn cold_files(&self, all_files: &[String], limit: usize) -> Vec<String> {
178        let hot: std::collections::HashSet<&str> = self
179            .entries
180            .keys()
181            .map(std::string::String::as_str)
182            .collect();
183        let mut cold: Vec<String> = all_files
184            .iter()
185            .filter(|f| !hot.contains(f.as_str()))
186            .cloned()
187            .collect();
188        cold.truncate(limit);
189        cold
190    }
191
192    fn storage_path() -> PathBuf {
193        crate::core::paths::state_dir()
194            .unwrap_or_else(|_| PathBuf::from("."))
195            .join("heatmap.json")
196    }
197}
198
199fn load_from_disk() -> HeatMap {
200    let path = HeatMap::storage_path();
201    match std::fs::read_to_string(&path) {
202        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
203        Err(_) => HeatMap::default(),
204    }
205}
206
207fn save_to_disk(hm: &HeatMap) -> std::io::Result<()> {
208    save_to_path(hm, &HeatMap::storage_path())
209}
210
211/// Write the map to `path` atomically (temp file + rename), as compact JSON:
212/// the file is rewritten whole, so every byte of indentation is written
213/// again on every save.
214fn save_to_path(hm: &HeatMap, path: &Path) -> std::io::Result<()> {
215    if let Some(parent) = path.parent() {
216        std::fs::create_dir_all(parent)?;
217    }
218    let json = serde_json::to_vec(hm)?;
219    let tmp = path.with_extension("json.tmp");
220    std::fs::write(&tmp, &json)?;
221    std::fs::rename(&tmp, path)
222}
223
224/// Save `hm` to `path` when it changed since its last save and, unless
225/// `force`, that save to the same file lies [`HEATMAP_SAVE_EVERY`] back.
226/// Returns whether it wrote. A failed write leaves the map dirty.
227fn persist(hm: &mut HeatMap, path: &Path, force: bool, now: Instant) -> bool {
228    if !hm.dirty {
229        return false;
230    }
231    let recent = hm.last_save.as_ref().is_some_and(|(at, saved_to)| {
232        saved_to == path && now.saturating_duration_since(*at) < HEATMAP_SAVE_EVERY
233    });
234    if recent && !force {
235        return false;
236    }
237    if save_to_path(hm, path).is_err() {
238        return false;
239    }
240    hm.dirty = false;
241    hm.last_save = Some((now, path.to_path_buf()));
242    true
243}
244
245pub fn record_file_access(file_path: &str, original_tokens: usize, saved_tokens: usize) {
246    // Attribute every read to the current agent identity so the per-agent
247    // pheromone field (stigmergic trace) is populated in production, not just
248    // when callers explicitly pass an id.
249    let agent = crate::core::agent_identity::current_agent_id();
250    record_file_access_with_agent(file_path, original_tokens, saved_tokens, Some(agent));
251}
252
253/// Like [`record_file_access`] but attaches an agent identifier so the heatmap
254/// builds a per-agent pheromone field (stigmergic trace for multi-agent routing).
255pub fn record_file_access_with_agent(
256    file_path: &str,
257    original_tokens: usize,
258    saved_tokens: usize,
259    agent_id: Option<&str>,
260) {
261    // NOTE (#685): the verified savings ledger is recorded by the *callers*
262    // (ctx_read / ctx_multi_read / tool_lifecycle), NOT here. The heatmap counts
263    // in o200k for its file-pressure view, but the ledger must denominate in the
264    // active model's tokenizer family — and only the callers hold the source text
265    // needed to re-tokenize. Recording here would force one shared (o200k) count
266    // onto both, defeating model-correct savings.
267    let file_path = std::fs::canonicalize(file_path).map_or_else(
268        |_| file_path.to_string(),
269        |p| p.to_string_lossy().into_owned(),
270    );
271    let file_path = file_path.as_str();
272
273    let mut guard = HEATMAP_BUFFER
274        .lock()
275        .unwrap_or_else(std::sync::PoisonError::into_inner);
276    let hm = guard.get_or_insert_with(load_from_disk);
277    hm.record_access_with_agent(file_path, original_tokens, saved_tokens, agent_id);
278
279    // Enforce bounded retention.
280    if hm.entries.len() > HEATMAP_MAX_ENTRIES {
281        let mut items: Vec<(String, u32)> = hm
282            .entries
283            .values()
284            .map(|e| (e.path.clone(), e.access_count))
285            .collect();
286        items.sort_by_key(|x| x.1);
287        let drop_n = hm.entries.len().saturating_sub(HEATMAP_MAX_ENTRIES);
288        for (path, _) in items.into_iter().take(drop_n) {
289            hm.entries.remove(&path);
290        }
291    }
292
293    let n = HEATMAP_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
294    if n.is_multiple_of(HEATMAP_FLUSH_EVERY) {
295        persist(hm, &HeatMap::storage_path(), false, Instant::now());
296    }
297}
298
299/// Save the buffered map when it changed and its last save lies
300/// `HEATMAP_SAVE_EVERY` back. Runs after every tool call
301/// (`tool_lifecycle::flush_all`): before, it rewrote the whole file on every
302/// call once anything had changed, because it never cleared `dirty`.
303pub fn flush() {
304    flush_with(false);
305}
306
307/// Save the buffered map now when it changed: for a process about to exit.
308pub fn flush_now() {
309    flush_with(true);
310}
311
312fn flush_with(force: bool) {
313    let mut guard = HEATMAP_BUFFER
314        .lock()
315        .unwrap_or_else(std::sync::PoisonError::into_inner);
316    if let Some(hm) = guard.as_mut() {
317        persist(hm, &HeatMap::storage_path(), force, Instant::now());
318    }
319}
320
321pub fn reset() {
322    let mut guard = HEATMAP_BUFFER
323        .lock()
324        .unwrap_or_else(std::sync::PoisonError::into_inner);
325    let hm = guard.insert(HeatMap {
326        dirty: true,
327        ..HeatMap::default()
328    });
329    persist(hm, &HeatMap::storage_path(), true, Instant::now());
330}
331
332pub fn format_heatmap_status(heatmap: &HeatMap, limit: usize) -> String {
333    let top = heatmap.top_files(limit);
334    if top.is_empty() {
335        return "No file access data recorded yet.".to_string();
336    }
337    let mut lines = vec![format!(
338        "File Access Heat Map ({} tracked files):",
339        heatmap.entries.len()
340    )];
341    lines.push(String::new());
342    for (i, entry) in top.iter().enumerate() {
343        let short = short_path(&entry.path);
344        let heat = heat_indicator(entry.access_count);
345        lines.push(format!(
346            "  {heat} #{} {} — {} accesses, {:.0}% compression, {} tok saved",
347            i + 1,
348            short,
349            entry.access_count,
350            entry.avg_compression_ratio * 100.0,
351            entry.total_tokens_saved
352        ));
353    }
354    lines.join("\n")
355}
356
357pub fn format_directory_summary(heatmap: &HeatMap) -> String {
358    let dirs = heatmap.directory_summary();
359    if dirs.is_empty() {
360        return "No directory data.".to_string();
361    }
362    let mut lines = vec!["Directory Heat Map:".to_string(), String::new()];
363    for (dir, count, saved) in dirs.iter().take(15) {
364        let heat = heat_indicator(*count);
365        lines.push(format!(
366            "  {heat} {dir}/ — {count} accesses, {saved} tok saved"
367        ));
368    }
369    lines.join("\n")
370}
371
372fn heat_indicator(count: u32) -> &'static str {
373    match count {
374        0 => "  ",
375        1..=3 => "▁▁",
376        4..=8 => "▃▃",
377        9..=15 => "▅▅",
378        16..=30 => "▇▇",
379        _ => "██",
380    }
381}
382
383fn short_path(path: &str) -> &str {
384    let parts: Vec<&str> = path.rsplitn(3, '/').collect();
385    if parts.len() >= 2 {
386        let start = path.len() - parts[0].len() - parts[1].len() - 1;
387        &path[start..]
388    } else {
389        path
390    }
391}
392
393#[cfg(test)]
394pub mod tests {
395    use super::*;
396
397    #[test]
398    fn record_and_query() {
399        let mut hm = HeatMap::default();
400        hm.record_access("src/main.rs", 100, 80);
401        hm.record_access("src/main.rs", 100, 90);
402        hm.record_access("src/lib.rs", 200, 50);
403
404        assert_eq!(hm.entries.len(), 2);
405        assert_eq!(hm.entries["src/main.rs"].access_count, 2);
406        assert_eq!(hm.entries["src/lib.rs"].total_tokens_saved, 50);
407    }
408
409    #[test]
410    fn avg_original_tokens_per_access_is_measured_not_guessed() {
411        let mut hm = HeatMap::default();
412        assert_eq!(
413            hm.avg_original_tokens_per_access(),
414            None,
415            "no data must yield None, never a fallback constant"
416        );
417        hm.record_access("a.rs", 100, 40);
418        hm.record_access("a.rs", 100, 40);
419        hm.record_access("b.rs", 400, 100);
420        // total original = 600 over 3 accesses => mean 200.
421        assert_eq!(hm.avg_original_tokens_per_access(), Some(200));
422    }
423
424    #[test]
425    fn top_files_sorted() {
426        let mut hm = HeatMap::default();
427        hm.record_access("a.rs", 100, 50);
428        hm.record_access("b.rs", 100, 50);
429        hm.record_access("b.rs", 100, 50);
430        hm.record_access("c.rs", 100, 50);
431        hm.record_access("c.rs", 100, 50);
432        hm.record_access("c.rs", 100, 50);
433
434        let top = hm.top_files(2);
435        assert_eq!(top.len(), 2);
436        assert_eq!(top[0].path, "c.rs");
437        assert_eq!(top[1].path, "b.rs");
438    }
439
440    #[test]
441    fn directory_summary_works() {
442        let mut hm = HeatMap::default();
443        hm.record_access("src/a.rs", 100, 50);
444        hm.record_access("src/b.rs", 100, 50);
445        hm.record_access("tests/t.rs", 200, 100);
446
447        let dirs = hm.directory_summary();
448        assert!(dirs.len() >= 2);
449    }
450
451    #[test]
452    fn cold_files_detection() {
453        let mut hm = HeatMap::default();
454        hm.record_access("src/a.rs", 100, 50);
455
456        let all = vec![
457            "src/a.rs".to_string(),
458            "src/b.rs".to_string(),
459            "src/c.rs".to_string(),
460        ];
461        let cold = hm.cold_files(&all, 10);
462        assert_eq!(cold.len(), 2);
463        assert!(cold.contains(&"src/b.rs".to_string()));
464    }
465
466    #[test]
467    fn heat_indicators() {
468        assert_eq!(heat_indicator(0), "  ");
469        assert_eq!(heat_indicator(1), "▁▁");
470        assert_eq!(heat_indicator(10), "▅▅");
471        assert_eq!(heat_indicator(50), "██");
472    }
473
474    #[test]
475    fn compression_ratio() {
476        let mut hm = HeatMap::default();
477        hm.record_access("a.rs", 1000, 800);
478        let entry = &hm.entries["a.rs"];
479        assert!((entry.avg_compression_ratio - 0.8).abs() < 0.01);
480    }
481
482    #[test]
483    fn agent_scoped_access_and_context_credit() {
484        let mut hm = HeatMap::default();
485        hm.record_access_with_agent("shared.rs", 100, 50, Some("agent-a"));
486        hm.record_access_with_agent("shared.rs", 100, 60, Some("agent-b"));
487        hm.record_access_with_agent("only-a.rs", 100, 70, Some("agent-a"));
488
489        let entry = &hm.entries["shared.rs"];
490        assert_eq!(entry.agent_accesses.len(), 2);
491        assert_eq!(entry.agent_accesses["agent-a"], 1);
492        assert_eq!(entry.agent_accesses["agent-b"], 1);
493
494        let credit = hm.context_credit();
495        assert!(!credit.is_empty(), "`credit` is empty");
496        // Both agents get credit for the shared file; only-a.rs contributes
497        // no credit (single-agent access).
498        let a_credit = credit.iter().find(|(id, _)| id == "agent-a").unwrap().1;
499        let b_credit = credit.iter().find(|(id, _)| id == "agent-b").unwrap().1;
500        assert!(a_credit > 0.0);
501        assert!((a_credit - b_credit).abs() < 1e-9);
502    }
503
504    /// The identity of the file at `path`: an atomic rewrite (temp file +
505    /// rename) gives it a new inode.
506    fn file_identity(path: &Path) -> (u64, std::time::SystemTime) {
507        let meta = std::fs::metadata(path).expect("heatmap.json written");
508        #[cfg(unix)]
509        let ino = {
510            use std::os::unix::fs::MetadataExt;
511            meta.ino()
512        };
513        #[cfg(not(unix))]
514        let ino = 0;
515        (ino, meta.modified().unwrap())
516    }
517
518    /// Write amplification (Mac, 11.11.): `flush` runs after every tool call
519    /// and never cleared `dirty`, so after the first access each flush
520    /// rewrote the whole (pretty-printed, ~2 MB) file. A flush with nothing
521    /// new must not write.
522    #[test]
523    fn a_second_flush_without_changes_does_not_rewrite_the_heatmap() {
524        let dir = crate::core::data_dir::isolated_data_dir();
525        let file = dir.path().join("hot.rs");
526        std::fs::write(&file, "fn hot() {}\n").unwrap();
527        record_file_access_with_agent(&file.to_string_lossy(), 100, 40, None);
528        flush();
529        let heatmap = crate::core::paths::state_dir()
530            .unwrap()
531            .join("heatmap.json");
532        let first = file_identity(&heatmap);
533        std::thread::sleep(Duration::from_millis(20));
534        flush();
535        flush();
536        assert_eq!(
537            file_identity(&heatmap),
538            first,
539            "a flush without changes rewrote heatmap.json"
540        );
541        let text = std::fs::read_to_string(&heatmap).unwrap();
542        assert!(!text.contains('\n'), "heatmap.json is not compact");
543    }
544
545    #[test]
546    fn persist_writes_only_changes_at_most_every_interval_unless_forced() {
547        let dir = tempfile::tempdir().unwrap();
548        let path = dir.path().join("heatmap.json");
549        let mut hm = HeatMap::default();
550        let t0 = Instant::now();
551        assert!(!persist(&mut hm, &path, false, t0), "nothing to save");
552
553        hm.record_access("a.rs", 100, 50);
554        assert!(persist(&mut hm, &path, false, t0), "first save");
555        assert!(!persist(&mut hm, &path, false, t0), "unchanged: no write");
556        assert!(
557            !persist(&mut hm, &path, true, t0),
558            "unchanged: not even forced"
559        );
560
561        hm.record_access("a.rs", 100, 50);
562        let soon = t0 + Duration::from_secs(5);
563        assert!(!persist(&mut hm, &path, false, soon), "within the interval");
564        assert!(hm.dirty, "a put-off save stays pending");
565        assert!(persist(&mut hm, &path, false, t0 + HEATMAP_SAVE_EVERY));
566
567        hm.record_access("b.rs", 100, 50);
568        let later = t0 + HEATMAP_SAVE_EVERY + Duration::from_secs(1);
569        assert!(persist(&mut hm, &path, true, later), "forced at exit");
570
571        // Another file (data dir switched) is never put off.
572        hm.record_access("c.rs", 100, 50);
573        let other = dir.path().join("other").join("heatmap.json");
574        assert!(persist(&mut hm, &other, false, later));
575
576        let loaded: HeatMap =
577            serde_json::from_str(&std::fs::read_to_string(&other).unwrap()).unwrap();
578        assert_eq!(loaded.entries.len(), 3);
579        assert!(!dir.path().join("heatmap.json.tmp").exists());
580    }
581
582    /// Writes for a burst of tool calls: one per [`HEATMAP_SAVE_EVERY`],
583    /// not one per call.
584    #[test]
585    fn a_burst_of_tool_calls_writes_once_per_interval() {
586        let dir = tempfile::tempdir().unwrap();
587        let path = dir.path().join("heatmap.json");
588        let mut hm = HeatMap::default();
589        let t0 = Instant::now();
590        let mut writes = 0;
591        // 100 tool calls, one read each, over 50 seconds.
592        for call in 0..100u64 {
593            hm.record_access(&format!("f{}.rs", call % 7), 100, 50);
594            let at = t0 + Duration::from_millis(call * 500);
595            writes += usize::from(persist(&mut hm, &path, false, at));
596        }
597        assert_eq!(writes, 2, "{writes} writes for 100 calls in 50 s");
598        assert!(persist(&mut hm, &path, true, t0 + Duration::from_secs(50)));
599    }
600}