Skip to main content

lean_ctx/core/
hebbian_cache.rs

1//! Hebbian Co-Access Cache with Boltzmann-Temperature Eviction.
2//!
3//! Scientific basis:
4//! - Hebb (1949): "Neurons that fire together wire together" — files accessed together
5//!   strengthen their association, making co-accessed files resistant to eviction.
6//! - Boltzmann distribution (Statistical Physics): P(evict) = exp(-E/kT) where E is the
7//!   "value" of a cache entry and T is the memory pressure. Low T = deterministic (only
8//!   lowest-value entries evicted), High T = stochastic (prevents thrashing).
9
10use std::collections::HashMap;
11use std::time::{Duration, Instant};
12
13/// Maximum number of co-access pairs tracked (prevents unbounded growth).
14const MAX_ASSOCIATIONS: usize = 10_000;
15/// Decay half-life in seconds for Hebbian weights.
16const DECAY_HALF_LIFE_SECS: f64 = 300.0;
17/// Minimum weight before pruning.
18const PRUNE_THRESHOLD: f32 = 0.01;
19
20/// Tracks co-access patterns between files (Hebbian learning).
21pub struct CoAccessMatrix {
22    /// Sparse co-access weights: (path_hash_a, path_hash_b) → weight
23    weights: HashMap<(u64, u64), f32>,
24    /// When each pair was last strengthened
25    timestamps: HashMap<(u64, u64), Instant>,
26    /// Current access burst (files read in the same tool-call window)
27    current_burst: Vec<u64>,
28    burst_start: Instant,
29    /// How long a burst stays open. Real tool calls read their files within a
30    /// few ms, so 500ms in production. Injectable so tests can widen it and stop
31    /// depending on two `store()` calls landing in the same real-time window —
32    /// a scheduling-jitter flake once tests run in parallel.
33    burst_window: Duration,
34}
35
36impl Default for CoAccessMatrix {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl CoAccessMatrix {
43    pub fn new() -> Self {
44        Self {
45            weights: HashMap::with_capacity(256),
46            timestamps: HashMap::with_capacity(256),
47            current_burst: Vec::with_capacity(8),
48            burst_start: Instant::now(),
49            burst_window: Duration::from_millis(500),
50        }
51    }
52
53    /// Override the burst window. Test-only: production always uses the 500ms
54    /// default set in `new`.
55    #[cfg(test)]
56    pub fn set_burst_window(&mut self, window: Duration) {
57        self.burst_window = window;
58        self.burst_start = Instant::now();
59    }
60
61    /// Record a file access. If within the burst window (500ms), strengthens
62    /// associations with other files in the same burst.
63    pub fn record_access(&mut self, path_hash: u64) {
64        let now = Instant::now();
65
66        if now.duration_since(self.burst_start) > self.burst_window {
67            self.flush_burst();
68            self.burst_start = now;
69        }
70
71        self.current_burst.push(path_hash);
72    }
73
74    /// Flush current burst: strengthen all pairwise associations.
75    fn flush_burst(&mut self) {
76        if self.current_burst.len() < 2 {
77            self.current_burst.clear();
78            return;
79        }
80
81        let now = Instant::now();
82        let burst = std::mem::take(&mut self.current_burst);
83
84        for i in 0..burst.len() {
85            for j in (i + 1)..burst.len() {
86                let key = normalized_key(burst[i], burst[j]);
87                let w = self.weights.entry(key).or_insert(0.0);
88                *w += 1.0;
89                self.timestamps.insert(key, now);
90            }
91        }
92
93        if self.weights.len() > MAX_ASSOCIATIONS {
94            self.prune();
95        }
96    }
97
98    /// Get the association strength of a file with all currently active files.
99    /// Applies exponential decay based on elapsed time.
100    pub fn association_strength(&self, path_hash: u64, active_hashes: &[u64]) -> f32 {
101        let now = Instant::now();
102        let mut total = 0.0f32;
103
104        for &active in active_hashes {
105            let key = normalized_key(path_hash, active);
106            if let Some(&weight) = self.weights.get(&key) {
107                let elapsed = self.timestamps.get(&key).map_or(DECAY_HALF_LIFE_SECS, |t| {
108                    now.duration_since(*t).as_secs_f64()
109                });
110                let decay = (-elapsed * (2.0f64.ln()) / DECAY_HALF_LIFE_SECS).exp();
111                total += weight * decay as f32;
112            }
113        }
114
115        total
116    }
117
118    /// Remove weak associations to keep memory bounded.
119    fn prune(&mut self) {
120        let now = Instant::now();
121        self.weights.retain(|key, weight| {
122            let elapsed = self
123                .timestamps
124                .get(key)
125                .map_or(DECAY_HALF_LIFE_SECS * 2.0, |t| {
126                    now.duration_since(*t).as_secs_f64()
127                });
128            let decay = (-elapsed * (2.0f64.ln()) / DECAY_HALF_LIFE_SECS).exp();
129            let effective = *weight * decay as f32;
130            if effective < PRUNE_THRESHOLD {
131                self.timestamps.remove(key);
132                false
133            } else {
134                true
135            }
136        });
137
138        // Hard cap: a high-churn burst of strong, fresh associations can leave the map
139        // above MAX_ASSOCIATIONS after threshold-pruning. Drop the lowest-effective-weight
140        // pairs to enforce the cap (keeping both maps key-synced). Tradeoff: discards the
141        // weakest learned co-access pairs, which are re-learned if seen again.
142        if self.weights.len() > MAX_ASSOCIATIONS {
143            let mut scored: Vec<((u64, u64), f32)> = self
144                .weights
145                .iter()
146                .map(|(&key, &weight)| {
147                    let elapsed = self
148                        .timestamps
149                        .get(&key)
150                        .map_or(DECAY_HALF_LIFE_SECS * 2.0, |t| {
151                            now.duration_since(*t).as_secs_f64()
152                        });
153                    let decay = (-elapsed * (2.0f64.ln()) / DECAY_HALF_LIFE_SECS).exp();
154                    (key, weight * decay as f32)
155                })
156                .collect();
157            scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
158            let to_drop = self.weights.len() - MAX_ASSOCIATIONS;
159            for (key, _) in scored.into_iter().take(to_drop) {
160                self.weights.remove(&key);
161                self.timestamps.remove(&key);
162            }
163        }
164    }
165
166    /// Force flush any pending burst (call at end of tool-call processing).
167    pub fn end_burst(&mut self) {
168        self.flush_burst();
169    }
170}
171
172/// Normalize key so (a,b) == (b,a).
173fn normalized_key(a: u64, b: u64) -> (u64, u64) {
174    if a <= b { (a, b) } else { (b, a) }
175}
176
177/// Compute a fast hash for a file path.
178pub fn path_hash(path: &str) -> u64 {
179    use std::hash::{Hash, Hasher};
180    let mut h = std::collections::hash_map::DefaultHasher::new();
181    path.hash(&mut h);
182    h.finish()
183}
184
185// ─── Boltzmann-Temperature Eviction ───────────────────────────────────────────
186
187/// Compute the "energy" (value) of a cache entry for Boltzmann eviction.
188/// Higher energy = more valuable = less likely to be evicted.
189pub struct EntryEnergy {
190    pub read_count: u32,
191    pub recency_secs: f64,
192    pub association_strength: f32,
193    pub token_size: usize,
194    pub graph_centrality: f32,
195}
196
197impl EntryEnergy {
198    /// Calculate the energy value E for this entry.
199    /// Combines multiple signals into a single scalar.
200    pub fn compute(&self) -> f64 {
201        // Recency contributes with log-decay (recent = high energy)
202        let recency_score = 1.0 / (1.0 + self.recency_secs / 60.0);
203
204        // Read frequency (diminishing returns via sqrt)
205        let freq_score = (self.read_count as f64).sqrt();
206
207        // Association boost (normalized)
208        let assoc_score = (self.association_strength as f64).min(5.0);
209
210        // Size penalty (large entries cost more to keep)
211        let size_penalty = 1.0 / (1.0 + (self.token_size as f64 / 5000.0));
212
213        // Graph centrality bonus
214        let centrality_score = self.graph_centrality as f64;
215
216        // Weighted combination
217        recency_score * 3.0
218            + freq_score * 2.0
219            + assoc_score * 1.5
220            + size_penalty * 1.0
221            + centrality_score * 1.0
222    }
223}
224
225/// Boltzmann eviction decision.
226/// Returns the indices to evict from a list of energy scores, given a temperature T.
227///
228/// Temperature T = normalized memory pressure:
229/// - T ≈ 0: almost deterministic (only lowest-energy entries evicted)
230/// - T ≈ 1: stochastic (prevents pathological thrashing)
231pub fn boltzmann_select_evictions(
232    energies: &[f64],
233    num_to_evict: usize,
234    temperature: f64,
235) -> Vec<usize> {
236    if energies.is_empty() || num_to_evict == 0 {
237        return Vec::new();
238    }
239
240    let n = energies.len().min(num_to_evict);
241    let t = temperature.max(0.01); // avoid division by zero
242
243    // Compute eviction probabilities: P(evict_i) ∝ exp(-E_i / T)
244    let max_e = energies.iter().copied().fold(f64::MIN, f64::max);
245    let probs: Vec<f64> = energies
246        .iter()
247        .map(|&e| {
248            let normalized = (e - max_e) / t.max(0.01);
249            (-normalized).exp() // lower energy → higher eviction probability
250        })
251        .collect();
252
253    // Sort by eviction probability (highest first = lowest energy first)
254    let mut indexed: Vec<(usize, f64)> = probs.into_iter().enumerate().collect();
255    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
256
257    // At low temperature, this is nearly deterministic (sorted by energy).
258    // At high temperature, the probabilities flatten out.
259    indexed.into_iter().take(n).map(|(idx, _)| idx).collect()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn co_access_strengthens_pairs() {
268        let mut matrix = CoAccessMatrix::new();
269        let a = path_hash("src/main.rs");
270        let b = path_hash("src/lib.rs");
271        let c = path_hash("src/config.rs");
272
273        // Simulate burst: A, B, C accessed together
274        matrix.record_access(a);
275        matrix.record_access(b);
276        matrix.record_access(c);
277        matrix.end_burst();
278
279        // A should have association with B
280        assert!(matrix.association_strength(a, &[b]) > 0.0);
281        // And with C
282        assert!(matrix.association_strength(a, &[c]) > 0.0);
283    }
284
285    #[test]
286    fn unrelated_files_have_zero_association() {
287        let matrix = CoAccessMatrix::new();
288        let a = path_hash("src/main.rs");
289        let b = path_hash("src/lib.rs");
290        assert_eq!(matrix.association_strength(a, &[b]), 0.0);
291    }
292
293    #[test]
294    fn boltzmann_low_temp_is_deterministic() {
295        let energies = vec![10.0, 1.0, 5.0, 0.5, 8.0];
296        let evictions = boltzmann_select_evictions(&energies, 2, 0.01);
297        // Should evict lowest-energy entries: idx 3 (0.5) and idx 1 (1.0)
298        assert!(evictions.contains(&3));
299        assert!(evictions.contains(&1));
300    }
301
302    #[test]
303    fn boltzmann_high_temp_still_picks_n() {
304        let energies = vec![10.0, 1.0, 5.0, 0.5, 8.0];
305        let evictions = boltzmann_select_evictions(&energies, 2, 100.0);
306        assert_eq!(evictions.len(), 2);
307    }
308
309    #[test]
310    fn entry_energy_compute_is_sane() {
311        let high_value = EntryEnergy {
312            read_count: 10,
313            recency_secs: 5.0,
314            association_strength: 3.0,
315            token_size: 500,
316            graph_centrality: 0.8,
317        };
318        let low_value = EntryEnergy {
319            read_count: 1,
320            recency_secs: 3600.0,
321            association_strength: 0.0,
322            token_size: 50000,
323            graph_centrality: 0.0,
324        };
325        assert!(high_value.compute() > low_value.compute());
326    }
327
328    #[test]
329    fn normalized_key_is_symmetric() {
330        assert_eq!(normalized_key(42, 99), normalized_key(99, 42));
331    }
332}