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;
10const 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 #[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 #[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 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 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 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 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
211fn 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
224fn 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 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
253pub 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 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 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
299pub fn flush() {
304 flush_with(false);
305}
306
307pub 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 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 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 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 #[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 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 #[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 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}