Skip to main content

bvr/analysis/
cache.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5use sha2::{Digest, Sha256};
6
7use crate::model::Issue;
8
9use super::graph::{AnalysisConfig, GraphMetrics, IssueGraph};
10
11/// Default time-to-live for cached metrics.
12const DEFAULT_TTL_SECS: u64 = 300; // 5 minutes
13
14/// A cached set of graph metrics with expiry tracking.
15#[derive(Clone)]
16struct CacheEntry {
17    metrics: GraphMetrics,
18    inserted_at: Instant,
19    ttl: Duration,
20}
21
22impl CacheEntry {
23    fn is_expired(&self) -> bool {
24        self.inserted_at.elapsed() > self.ttl
25    }
26}
27
28/// In-memory cache for graph metrics, keyed by a SHA-256 hash of the graph structure.
29///
30/// The cache avoids recomputing expensive metrics (PageRank, betweenness, HITS, etc.)
31/// when the underlying issue graph has not changed. This is especially valuable for
32/// TUI sessions where multiple views read the same metrics repeatedly.
33pub struct MetricsCache {
34    entries: Mutex<HashMap<[u8; 32], CacheEntry>>,
35    ttl: Duration,
36    hits: Mutex<u64>,
37    misses: Mutex<u64>,
38}
39
40/// Cache hit/miss statistics.
41#[derive(Debug, Clone, Copy)]
42pub struct CacheStats {
43    pub hits: u64,
44    pub misses: u64,
45    pub entries: usize,
46}
47
48impl MetricsCache {
49    /// Create a new cache with default TTL (5 minutes).
50    #[must_use]
51    pub fn new() -> Self {
52        Self {
53            entries: Mutex::new(HashMap::new()),
54            ttl: Duration::from_secs(DEFAULT_TTL_SECS),
55            hits: Mutex::new(0),
56            misses: Mutex::new(0),
57        }
58    }
59
60    /// Create a cache with a custom TTL.
61    #[must_use]
62    pub fn with_ttl(ttl: Duration) -> Self {
63        Self {
64            entries: Mutex::new(HashMap::new()),
65            ttl,
66            hits: Mutex::new(0),
67            misses: Mutex::new(0),
68        }
69    }
70
71    /// Compute (or retrieve cached) metrics for the given issues and config.
72    ///
73    /// The cache key is a SHA-256 hash of the graph structure (sorted node IDs,
74    /// edges, and issue statuses) plus the analysis config.
75    pub fn get_or_compute(&self, issues: &[Issue], config: &AnalysisConfig) -> GraphMetrics {
76        let key = compute_cache_key(issues, config);
77
78        // Check cache.
79        {
80            let entries = self
81                .entries
82                .lock()
83                .unwrap_or_else(std::sync::PoisonError::into_inner);
84            if let Some(entry) = entries.get(&key) {
85                if !entry.is_expired() {
86                    *self
87                        .hits
88                        .lock()
89                        .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
90                    return entry.metrics.clone();
91                }
92            }
93        }
94
95        // Cache miss — compute metrics.
96        *self
97            .misses
98            .lock()
99            .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
100        let graph = IssueGraph::build(issues);
101        let metrics = graph.compute_metrics_with_config(config);
102
103        // Store in cache.
104        {
105            let mut entries = self
106                .entries
107                .lock()
108                .unwrap_or_else(std::sync::PoisonError::into_inner);
109            // Evict expired entries on insert to prevent unbounded growth.
110            entries.retain(|_, entry| !entry.is_expired());
111            entries.insert(
112                key,
113                CacheEntry {
114                    metrics: metrics.clone(),
115                    inserted_at: Instant::now(),
116                    ttl: self.ttl,
117                },
118            );
119        }
120
121        metrics
122    }
123
124    /// Invalidate all cached entries.
125    pub fn clear(&self) {
126        self.entries
127            .lock()
128            .unwrap_or_else(std::sync::PoisonError::into_inner)
129            .clear();
130    }
131
132    /// Return cache statistics.
133    #[must_use]
134    pub fn stats(&self) -> CacheStats {
135        let entries = self
136            .entries
137            .lock()
138            .unwrap_or_else(std::sync::PoisonError::into_inner);
139        let hits = *self
140            .hits
141            .lock()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        let misses = *self
144            .misses
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner);
147        CacheStats {
148            hits,
149            misses,
150            entries: entries.len(),
151        }
152    }
153}
154
155impl Default for MetricsCache {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// Compute a SHA-256 cache key from the graph structure.
162///
163/// The key incorporates:
164/// - Sorted issue IDs
165/// - Issue statuses (since open/closed affects metrics like actionable sets)
166/// - Dependency edges (sorted)
167/// - AnalysisConfig toggles (since they affect which metrics are computed)
168fn compute_cache_key(issues: &[Issue], config: &AnalysisConfig) -> [u8; 32] {
169    let mut hasher = Sha256::new();
170
171    // Sort issues by ID for determinism.
172    let mut sorted_ids: Vec<(&str, &str)> = issues
173        .iter()
174        .map(|i| (i.id.as_str(), i.status.as_str()))
175        .collect();
176    sorted_ids.sort();
177
178    for (id, status) in &sorted_ids {
179        hasher.update(id.as_bytes());
180        hasher.update(b"\0");
181        hasher.update(status.as_bytes());
182        hasher.update(b"\n");
183    }
184
185    // Include edges.
186    let mut edges: Vec<(&str, &str)> = Vec::new();
187    for issue in issues {
188        for dep in &issue.dependencies {
189            if dep.is_blocking() {
190                edges.push((issue.id.as_str(), dep.depends_on_id.as_str()));
191            }
192        }
193    }
194    edges.sort();
195    for (from, to) in &edges {
196        hasher.update(from.as_bytes());
197        hasher.update(b"->");
198        hasher.update(to.as_bytes());
199        hasher.update(b"\n");
200    }
201
202    // Include config toggles that affect metric computation.
203    hasher.update(if config.enable_pagerank {
204        b"pr:1"
205    } else {
206        b"pr:0"
207    });
208    hasher.update(if config.enable_betweenness {
209        b"bt:1"
210    } else {
211        b"bt:0"
212    });
213    hasher.update(if config.enable_eigenvector {
214        b"ev:1"
215    } else {
216        b"ev:0"
217    });
218    hasher.update(if config.enable_hits { b"hi:1" } else { b"hi:0" });
219    hasher.update(if config.enable_k_core {
220        b"kc:1"
221    } else {
222        b"kc:0"
223    });
224    hasher.update(if config.enable_cycles {
225        b"cy:1"
226    } else {
227        b"cy:0"
228    });
229    hasher.update(if config.enable_critical_path {
230        b"cp:1"
231    } else {
232        b"cp:0"
233    });
234    hasher.update(if config.enable_articulation {
235        b"ap:1"
236    } else {
237        b"ap:0"
238    });
239    hasher.update(if config.enable_slack {
240        b"sl:1"
241    } else {
242        b"sl:0"
243    });
244
245    let result = hasher.finalize();
246    let mut key = [0u8; 32];
247    key.copy_from_slice(&result);
248    key
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::model::{Dependency, Issue};
255    use std::time::Duration;
256
257    fn make_issue(id: &str, status: &str) -> Issue {
258        Issue {
259            id: id.to_string(),
260            title: format!("Issue {id}"),
261            status: status.to_string(),
262            issue_type: "task".to_string(),
263            priority: 2,
264            ..Issue::default()
265        }
266    }
267
268    fn make_blocked(id: &str, depends_on: &str) -> Issue {
269        Issue {
270            id: id.to_string(),
271            title: format!("Issue {id}"),
272            status: "blocked".to_string(),
273            issue_type: "task".to_string(),
274            priority: 2,
275            dependencies: vec![Dependency {
276                issue_id: id.to_string(),
277                depends_on_id: depends_on.to_string(),
278                dep_type: "blocks".to_string(),
279                ..Dependency::default()
280            }],
281            ..Issue::default()
282        }
283    }
284
285    #[test]
286    fn cache_hit_returns_same_metrics() {
287        let cache = MetricsCache::new();
288        let issues = vec![make_issue("A", "open"), make_blocked("B", "A")];
289        let config = AnalysisConfig::default();
290
291        let first = cache.get_or_compute(&issues, &config);
292        let second = cache.get_or_compute(&issues, &config);
293
294        // Same PageRank values.
295        assert_eq!(first.pagerank.get("A"), second.pagerank.get("A"));
296        assert_eq!(first.pagerank.get("B"), second.pagerank.get("B"));
297
298        let stats = cache.stats();
299        assert_eq!(stats.misses, 1);
300        assert_eq!(stats.hits, 1);
301        assert_eq!(stats.entries, 1);
302    }
303
304    #[test]
305    fn cache_miss_on_changed_graph() {
306        let cache = MetricsCache::new();
307        let config = AnalysisConfig::default();
308
309        let issues_v1 = vec![make_issue("A", "open")];
310        cache.get_or_compute(&issues_v1, &config);
311
312        let issues_v2 = vec![make_issue("A", "open"), make_issue("B", "open")];
313        cache.get_or_compute(&issues_v2, &config);
314
315        let stats = cache.stats();
316        assert_eq!(stats.misses, 2);
317        assert_eq!(stats.hits, 0);
318    }
319
320    #[test]
321    fn cache_miss_on_status_change() {
322        let cache = MetricsCache::new();
323        let config = AnalysisConfig::default();
324
325        let issues_open = vec![make_issue("A", "open")];
326        cache.get_or_compute(&issues_open, &config);
327
328        let issues_closed = vec![make_issue("A", "closed")];
329        cache.get_or_compute(&issues_closed, &config);
330
331        let stats = cache.stats();
332        assert_eq!(stats.misses, 2, "status change should invalidate cache");
333    }
334
335    #[test]
336    fn cache_ttl_expiry() {
337        let cache = MetricsCache::with_ttl(Duration::from_millis(1));
338        let issues = vec![make_issue("A", "open")];
339        let config = AnalysisConfig::default();
340
341        cache.get_or_compute(&issues, &config);
342        // Sleep past the TTL.
343        std::thread::sleep(Duration::from_millis(10));
344        cache.get_or_compute(&issues, &config);
345
346        let stats = cache.stats();
347        assert_eq!(stats.misses, 2, "expired entry should cause a miss");
348    }
349
350    #[test]
351    fn cache_clear() {
352        let cache = MetricsCache::new();
353        let issues = vec![make_issue("A", "open")];
354        let config = AnalysisConfig::default();
355
356        cache.get_or_compute(&issues, &config);
357        assert_eq!(cache.stats().entries, 1);
358
359        cache.clear();
360        assert_eq!(cache.stats().entries, 0);
361
362        // Should recompute.
363        cache.get_or_compute(&issues, &config);
364        assert_eq!(cache.stats().misses, 2);
365    }
366
367    #[test]
368    fn cache_key_deterministic() {
369        let issues = vec![make_issue("A", "open"), make_blocked("B", "A")];
370        let config = AnalysisConfig::default();
371
372        let key1 = compute_cache_key(&issues, &config);
373        let key2 = compute_cache_key(&issues, &config);
374        assert_eq!(key1, key2);
375    }
376
377    #[test]
378    fn cache_key_differs_for_different_config() {
379        let issues = vec![make_issue("A", "open")];
380        let config1 = AnalysisConfig::default();
381        let mut config2 = AnalysisConfig::default();
382        config2.enable_pagerank = false;
383
384        let key1 = compute_cache_key(&issues, &config1);
385        let key2 = compute_cache_key(&issues, &config2);
386        assert_ne!(key1, key2);
387    }
388
389    #[test]
390    fn cache_empty_issues() {
391        let cache = MetricsCache::new();
392        let issues: Vec<Issue> = vec![];
393        let config = AnalysisConfig::default();
394
395        let metrics = cache.get_or_compute(&issues, &config);
396        assert!(metrics.pagerank.is_empty());
397        assert_eq!(cache.stats().misses, 1);
398    }
399
400    #[test]
401    fn cache_default_impl() {
402        let cache = MetricsCache::default();
403        let stats = cache.stats();
404        assert_eq!(stats.hits, 0);
405        assert_eq!(stats.misses, 0);
406        assert_eq!(stats.entries, 0);
407    }
408}