Skip to main content

lean_ctx/core/
heatmap.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7const HEATMAP_FLUSH_EVERY: usize = 25;
8const HEATMAP_MAX_ENTRIES: usize = 10_000;
9
10static HEATMAP_BUFFER: Mutex<Option<HeatMap>> = Mutex::new(None);
11static HEATMAP_CALLS: AtomicUsize = AtomicUsize::new(0);
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct HeatEntry {
15    pub path: String,
16    pub access_count: u32,
17    pub last_access: String,
18    pub total_tokens_saved: u64,
19    pub total_original_tokens: u64,
20    pub avg_compression_ratio: f32,
21    /// Per-agent access counts — the stigmergic pheromone field.  When multiple
22    /// agents access the same file, downstream consumers can identify shared
23    /// context (co-access patterns) and compute credit for useful preloads.
24    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
25    pub agent_accesses: HashMap<String, u32>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct HeatMap {
30    pub entries: HashMap<String, HeatEntry>,
31    #[serde(skip)]
32    dirty: bool,
33}
34
35impl HeatMap {
36    pub fn load() -> Self {
37        let mut guard = HEATMAP_BUFFER
38            .lock()
39            .unwrap_or_else(std::sync::PoisonError::into_inner);
40        if let Some(ref hm) = *guard {
41            return hm.clone();
42        }
43        let hm = load_from_disk();
44        *guard = Some(hm.clone());
45        hm
46    }
47
48    pub fn record_access(&mut self, file_path: &str, original_tokens: usize, saved_tokens: usize) {
49        self.record_access_with_agent(file_path, original_tokens, saved_tokens, None);
50    }
51
52    /// Record a file access with an optional agent identifier (stigmergic trace).
53    pub fn record_access_with_agent(
54        &mut self,
55        file_path: &str,
56        original_tokens: usize,
57        saved_tokens: usize,
58        agent_id: Option<&str>,
59    ) {
60        let now = chrono::Utc::now().to_rfc3339();
61        let entry = self
62            .entries
63            .entry(file_path.to_string())
64            .or_insert_with(|| HeatEntry {
65                path: file_path.to_string(),
66                access_count: 0,
67                last_access: now.clone(),
68                total_tokens_saved: 0,
69                total_original_tokens: 0,
70                avg_compression_ratio: 0.0,
71                agent_accesses: HashMap::new(),
72            });
73        entry.access_count += 1;
74        entry.last_access = now;
75        entry.total_tokens_saved += saved_tokens as u64;
76        entry.total_original_tokens += original_tokens as u64;
77        if entry.total_original_tokens > 0 {
78            entry.avg_compression_ratio = 1.0
79                - (entry.total_original_tokens - entry.total_tokens_saved) as f32
80                    / entry.total_original_tokens as f32;
81        }
82        if let Some(aid) = agent_id
83            && !aid.is_empty()
84        {
85            *entry.agent_accesses.entry(aid.to_string()).or_insert(0) += 1;
86        }
87        self.dirty = true;
88    }
89
90    pub fn save(&self) -> std::io::Result<()> {
91        if !self.dirty && !self.entries.is_empty() {
92            return Ok(());
93        }
94        save_to_disk(self)?;
95        let mut guard = HEATMAP_BUFFER
96            .lock()
97            .unwrap_or_else(std::sync::PoisonError::into_inner);
98        *guard = Some(self.clone());
99        Ok(())
100    }
101
102    pub fn top_files(&self, limit: usize) -> Vec<&HeatEntry> {
103        let mut sorted: Vec<&HeatEntry> = self.entries.values().collect();
104        sorted.sort_by_key(|x| std::cmp::Reverse(x.access_count));
105        sorted.truncate(limit);
106        sorted
107    }
108
109    /// Mean original (pre-compression) token size of a recorded file access.
110    /// `None` when nothing has been recorded yet — callers must NOT substitute a
111    /// guessed constant (this backs the ghost report's redundant-read estimate).
112    pub fn avg_original_tokens_per_access(&self) -> Option<u64> {
113        let mut total_original: u64 = 0;
114        let mut total_accesses: u64 = 0;
115        for e in self.entries.values() {
116            total_original = total_original.saturating_add(e.total_original_tokens);
117            total_accesses = total_accesses.saturating_add(u64::from(e.access_count));
118        }
119        (total_accesses > 0).then(|| total_original / total_accesses)
120    }
121
122    /// Compute stigmergic context credit: which agents' file-access traces
123    /// benefited other agents? An agent A gets credit for a file F when A
124    /// accessed F before (or alongside) agent B, because A's trace effectively
125    /// pointed B to useful context. The credit for each (agent_A, file) pair is
126    /// proportional to how many *other* agents also accessed that file.
127    /// Returns `Vec<(agent_id, total_credit)>` sorted descending.
128    pub fn context_credit(&self) -> Vec<(String, f64)> {
129        let mut credit: HashMap<String, f64> = HashMap::new();
130        for entry in self.entries.values() {
131            let n_agents = entry.agent_accesses.len();
132            if n_agents < 2 {
133                continue;
134            }
135            // Shapley-inspired: each agent that accessed a shared file gets
136            // credit = (n_other_agents) / n_agents. The more agents a file
137            // served, the more each contributor is credited.
138            let share = (n_agents - 1) as f64 / n_agents as f64;
139            for agent in entry.agent_accesses.keys() {
140                *credit.entry(agent.clone()).or_insert(0.0) += share;
141            }
142        }
143        let mut sorted: Vec<(String, f64)> = credit.into_iter().collect();
144        sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
145        sorted
146    }
147
148    pub fn directory_summary(&self) -> Vec<(String, u32, u64)> {
149        let mut dirs: HashMap<String, (u32, u64)> = HashMap::new();
150        for entry in self.entries.values() {
151            let dir = std::path::Path::new(&entry.path)
152                .parent()
153                .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
154            let stat = dirs.entry(dir).or_insert((0, 0));
155            stat.0 += entry.access_count;
156            stat.1 += entry.total_tokens_saved;
157        }
158        let mut result: Vec<(String, u32, u64)> = dirs
159            .into_iter()
160            .map(|(dir, (count, saved))| (dir, count, saved))
161            .collect();
162        result.sort_by_key(|x| std::cmp::Reverse(x.1));
163        result
164    }
165
166    pub fn cold_files(&self, all_files: &[String], limit: usize) -> Vec<String> {
167        let hot: std::collections::HashSet<&str> = self
168            .entries
169            .keys()
170            .map(std::string::String::as_str)
171            .collect();
172        let mut cold: Vec<String> = all_files
173            .iter()
174            .filter(|f| !hot.contains(f.as_str()))
175            .cloned()
176            .collect();
177        cold.truncate(limit);
178        cold
179    }
180
181    fn storage_path() -> PathBuf {
182        crate::core::paths::state_dir()
183            .unwrap_or_else(|_| PathBuf::from("."))
184            .join("heatmap.json")
185    }
186}
187
188fn load_from_disk() -> HeatMap {
189    let path = HeatMap::storage_path();
190    match std::fs::read_to_string(&path) {
191        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
192        Err(_) => HeatMap::default(),
193    }
194}
195
196fn save_to_disk(hm: &HeatMap) -> std::io::Result<()> {
197    let path = HeatMap::storage_path();
198    if let Some(parent) = path.parent() {
199        std::fs::create_dir_all(parent)?;
200    }
201    let json = serde_json::to_string_pretty(hm)?;
202    let tmp = path.with_extension("json.tmp");
203    std::fs::write(&tmp, &json)?;
204    std::fs::rename(&tmp, &path)
205}
206
207pub fn record_file_access(file_path: &str, original_tokens: usize, saved_tokens: usize) {
208    // Attribute every read to the current agent identity so the per-agent
209    // pheromone field (stigmergic trace) is populated in production, not just
210    // when callers explicitly pass an id.
211    let agent = crate::core::agent_identity::current_agent_id();
212    record_file_access_with_agent(file_path, original_tokens, saved_tokens, Some(agent));
213}
214
215/// Like [`record_file_access`] but attaches an agent identifier so the heatmap
216/// builds a per-agent pheromone field (stigmergic trace for multi-agent routing).
217pub fn record_file_access_with_agent(
218    file_path: &str,
219    original_tokens: usize,
220    saved_tokens: usize,
221    agent_id: Option<&str>,
222) {
223    // NOTE (#685): the verified savings ledger is recorded by the *callers*
224    // (ctx_read / ctx_multi_read / tool_lifecycle), NOT here. The heatmap counts
225    // in o200k for its file-pressure view, but the ledger must denominate in the
226    // active model's tokenizer family — and only the callers hold the source text
227    // needed to re-tokenize. Recording here would force one shared (o200k) count
228    // onto both, defeating model-correct savings.
229    let file_path = std::fs::canonicalize(file_path).map_or_else(
230        |_| file_path.to_string(),
231        |p| p.to_string_lossy().into_owned(),
232    );
233    let file_path = file_path.as_str();
234
235    let mut guard = HEATMAP_BUFFER
236        .lock()
237        .unwrap_or_else(std::sync::PoisonError::into_inner);
238    let hm = guard.get_or_insert_with(load_from_disk);
239    hm.record_access_with_agent(file_path, original_tokens, saved_tokens, agent_id);
240
241    // Enforce bounded retention.
242    if hm.entries.len() > HEATMAP_MAX_ENTRIES {
243        let mut items: Vec<(String, u32)> = hm
244            .entries
245            .values()
246            .map(|e| (e.path.clone(), e.access_count))
247            .collect();
248        items.sort_by_key(|x| x.1);
249        let drop_n = hm.entries.len().saturating_sub(HEATMAP_MAX_ENTRIES);
250        for (path, _) in items.into_iter().take(drop_n) {
251            hm.entries.remove(&path);
252        }
253    }
254
255    let n = HEATMAP_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
256    if n.is_multiple_of(HEATMAP_FLUSH_EVERY) && save_to_disk(hm).is_ok() {
257        hm.dirty = false;
258    }
259}
260
261pub fn flush() {
262    let guard = HEATMAP_BUFFER
263        .lock()
264        .unwrap_or_else(std::sync::PoisonError::into_inner);
265    if let Some(ref hm) = *guard
266        && hm.dirty
267    {
268        let _ = save_to_disk(hm);
269    }
270}
271
272/// Cheap read-only lookup against the in-process heatmap buffer:
273/// `(access_count, avg_compression_ratio)` for a file, if tracked.
274/// Paths are canonicalized the same way `record_file_access` stores them.
275pub fn entry_stats(file_path: &str) -> Option<(u32, f32)> {
276    let canonical = std::fs::canonicalize(file_path).map_or_else(
277        |_| file_path.to_string(),
278        |p| p.to_string_lossy().into_owned(),
279    );
280    let mut guard = HEATMAP_BUFFER
281        .lock()
282        .unwrap_or_else(std::sync::PoisonError::into_inner);
283    let hm = guard.get_or_insert_with(load_from_disk);
284    hm.entries
285        .get(&canonical)
286        .map(|e| (e.access_count, e.avg_compression_ratio))
287}
288
289pub fn reset() {
290    let mut guard = HEATMAP_BUFFER
291        .lock()
292        .unwrap_or_else(std::sync::PoisonError::into_inner);
293    *guard = Some(HeatMap::default());
294    if let Some(hm) = guard.as_ref() {
295        let _ = save_to_disk(hm);
296    }
297}
298
299pub fn format_heatmap_status(heatmap: &HeatMap, limit: usize) -> String {
300    let top = heatmap.top_files(limit);
301    if top.is_empty() {
302        return "No file access data recorded yet.".to_string();
303    }
304    let mut lines = vec![format!(
305        "File Access Heat Map ({} tracked files):",
306        heatmap.entries.len()
307    )];
308    lines.push(String::new());
309    for (i, entry) in top.iter().enumerate() {
310        let short = short_path(&entry.path);
311        let heat = heat_indicator(entry.access_count);
312        lines.push(format!(
313            "  {heat} #{} {} — {} accesses, {:.0}% compression, {} tok saved",
314            i + 1,
315            short,
316            entry.access_count,
317            entry.avg_compression_ratio * 100.0,
318            entry.total_tokens_saved
319        ));
320    }
321    lines.join("\n")
322}
323
324pub fn format_directory_summary(heatmap: &HeatMap) -> String {
325    let dirs = heatmap.directory_summary();
326    if dirs.is_empty() {
327        return "No directory data.".to_string();
328    }
329    let mut lines = vec!["Directory Heat Map:".to_string(), String::new()];
330    for (dir, count, saved) in dirs.iter().take(15) {
331        let heat = heat_indicator(*count);
332        lines.push(format!(
333            "  {heat} {dir}/ — {count} accesses, {saved} tok saved"
334        ));
335    }
336    lines.join("\n")
337}
338
339fn heat_indicator(count: u32) -> &'static str {
340    match count {
341        0 => "  ",
342        1..=3 => "▁▁",
343        4..=8 => "▃▃",
344        9..=15 => "▅▅",
345        16..=30 => "▇▇",
346        _ => "██",
347    }
348}
349
350fn short_path(path: &str) -> &str {
351    let parts: Vec<&str> = path.rsplitn(3, '/').collect();
352    if parts.len() >= 2 {
353        let start = path.len() - parts[0].len() - parts[1].len() - 1;
354        &path[start..]
355    } else {
356        path
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn record_and_query() {
366        let mut hm = HeatMap::default();
367        hm.record_access("src/main.rs", 100, 80);
368        hm.record_access("src/main.rs", 100, 90);
369        hm.record_access("src/lib.rs", 200, 50);
370
371        assert_eq!(hm.entries.len(), 2);
372        assert_eq!(hm.entries["src/main.rs"].access_count, 2);
373        assert_eq!(hm.entries["src/lib.rs"].total_tokens_saved, 50);
374    }
375
376    #[test]
377    fn avg_original_tokens_per_access_is_measured_not_guessed() {
378        let mut hm = HeatMap::default();
379        assert_eq!(
380            hm.avg_original_tokens_per_access(),
381            None,
382            "no data must yield None, never a fallback constant"
383        );
384        hm.record_access("a.rs", 100, 40);
385        hm.record_access("a.rs", 100, 40);
386        hm.record_access("b.rs", 400, 100);
387        // total original = 600 over 3 accesses => mean 200.
388        assert_eq!(hm.avg_original_tokens_per_access(), Some(200));
389    }
390
391    #[test]
392    fn top_files_sorted() {
393        let mut hm = HeatMap::default();
394        hm.record_access("a.rs", 100, 50);
395        hm.record_access("b.rs", 100, 50);
396        hm.record_access("b.rs", 100, 50);
397        hm.record_access("c.rs", 100, 50);
398        hm.record_access("c.rs", 100, 50);
399        hm.record_access("c.rs", 100, 50);
400
401        let top = hm.top_files(2);
402        assert_eq!(top.len(), 2);
403        assert_eq!(top[0].path, "c.rs");
404        assert_eq!(top[1].path, "b.rs");
405    }
406
407    #[test]
408    fn directory_summary_works() {
409        let mut hm = HeatMap::default();
410        hm.record_access("src/a.rs", 100, 50);
411        hm.record_access("src/b.rs", 100, 50);
412        hm.record_access("tests/t.rs", 200, 100);
413
414        let dirs = hm.directory_summary();
415        assert!(dirs.len() >= 2);
416    }
417
418    #[test]
419    fn cold_files_detection() {
420        let mut hm = HeatMap::default();
421        hm.record_access("src/a.rs", 100, 50);
422
423        let all = vec![
424            "src/a.rs".to_string(),
425            "src/b.rs".to_string(),
426            "src/c.rs".to_string(),
427        ];
428        let cold = hm.cold_files(&all, 10);
429        assert_eq!(cold.len(), 2);
430        assert!(cold.contains(&"src/b.rs".to_string()));
431    }
432
433    #[test]
434    fn heat_indicators() {
435        assert_eq!(heat_indicator(0), "  ");
436        assert_eq!(heat_indicator(1), "▁▁");
437        assert_eq!(heat_indicator(10), "▅▅");
438        assert_eq!(heat_indicator(50), "██");
439    }
440
441    #[test]
442    fn compression_ratio() {
443        let mut hm = HeatMap::default();
444        hm.record_access("a.rs", 1000, 800);
445        let entry = &hm.entries["a.rs"];
446        assert!((entry.avg_compression_ratio - 0.8).abs() < 0.01);
447    }
448
449    #[test]
450    fn agent_scoped_access_and_context_credit() {
451        let mut hm = HeatMap::default();
452        hm.record_access_with_agent("shared.rs", 100, 50, Some("agent-a"));
453        hm.record_access_with_agent("shared.rs", 100, 60, Some("agent-b"));
454        hm.record_access_with_agent("only-a.rs", 100, 70, Some("agent-a"));
455
456        let entry = &hm.entries["shared.rs"];
457        assert_eq!(entry.agent_accesses.len(), 2);
458        assert_eq!(entry.agent_accesses["agent-a"], 1);
459        assert_eq!(entry.agent_accesses["agent-b"], 1);
460
461        let credit = hm.context_credit();
462        assert!(!credit.is_empty());
463        // Both agents get credit for the shared file; only-a.rs contributes
464        // no credit (single-agent access).
465        let a_credit = credit.iter().find(|(id, _)| id == "agent-a").unwrap().1;
466        let b_credit = credit.iter().find(|(id, _)| id == "agent-b").unwrap().1;
467        assert!(a_credit > 0.0);
468        assert!((a_credit - b_credit).abs() < 1e-9);
469    }
470}