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