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 #[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 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 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 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 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 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
215pub 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 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 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
272pub 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 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 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}