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 {
162        (a, b)
163    } else {
164        (b, a)
165    }
166}
167
168/// Compute a fast hash for a file path.
169pub fn path_hash(path: &str) -> u64 {
170    use std::hash::{Hash, Hasher};
171    let mut h = std::collections::hash_map::DefaultHasher::new();
172    path.hash(&mut h);
173    h.finish()
174}
175
176// ─── Boltzmann-Temperature Eviction ───────────────────────────────────────────
177
178/// Compute the "energy" (value) of a cache entry for Boltzmann eviction.
179/// Higher energy = more valuable = less likely to be evicted.
180pub struct EntryEnergy {
181    pub read_count: u32,
182    pub recency_secs: f64,
183    pub association_strength: f32,
184    pub token_size: usize,
185    pub graph_centrality: f32,
186}
187
188impl EntryEnergy {
189    /// Calculate the energy value E for this entry.
190    /// Combines multiple signals into a single scalar.
191    pub fn compute(&self) -> f64 {
192        // Recency contributes with log-decay (recent = high energy)
193        let recency_score = 1.0 / (1.0 + self.recency_secs / 60.0);
194
195        // Read frequency (diminishing returns via sqrt)
196        let freq_score = (self.read_count as f64).sqrt();
197
198        // Association boost (normalized)
199        let assoc_score = (self.association_strength as f64).min(5.0);
200
201        // Size penalty (large entries cost more to keep)
202        let size_penalty = 1.0 / (1.0 + (self.token_size as f64 / 5000.0));
203
204        // Graph centrality bonus
205        let centrality_score = self.graph_centrality as f64;
206
207        // Weighted combination
208        recency_score * 3.0
209            + freq_score * 2.0
210            + assoc_score * 1.5
211            + size_penalty * 1.0
212            + centrality_score * 1.0
213    }
214}
215
216/// Boltzmann eviction decision.
217/// Returns the indices to evict from a list of energy scores, given a temperature T.
218///
219/// Temperature T = normalized memory pressure:
220/// - T ≈ 0: almost deterministic (only lowest-energy entries evicted)
221/// - T ≈ 1: stochastic (prevents pathological thrashing)
222pub fn boltzmann_select_evictions(
223    energies: &[f64],
224    num_to_evict: usize,
225    temperature: f64,
226) -> Vec<usize> {
227    if energies.is_empty() || num_to_evict == 0 {
228        return Vec::new();
229    }
230
231    let n = energies.len().min(num_to_evict);
232    let t = temperature.max(0.01); // avoid division by zero
233
234    // Compute eviction probabilities: P(evict_i) ∝ exp(-E_i / T)
235    let max_e = energies.iter().copied().fold(f64::MIN, f64::max);
236    let probs: Vec<f64> = energies
237        .iter()
238        .map(|&e| {
239            let normalized = (e - max_e) / t.max(0.01);
240            (-normalized).exp() // lower energy → higher eviction probability
241        })
242        .collect();
243
244    // Sort by eviction probability (highest first = lowest energy first)
245    let mut indexed: Vec<(usize, f64)> = probs.into_iter().enumerate().collect();
246    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
247
248    // At low temperature, this is nearly deterministic (sorted by energy).
249    // At high temperature, the probabilities flatten out.
250    indexed.into_iter().take(n).map(|(idx, _)| idx).collect()
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn co_access_strengthens_pairs() {
259        let mut matrix = CoAccessMatrix::new();
260        let a = path_hash("src/main.rs");
261        let b = path_hash("src/lib.rs");
262        let c = path_hash("src/config.rs");
263
264        // Simulate burst: A, B, C accessed together
265        matrix.record_access(a);
266        matrix.record_access(b);
267        matrix.record_access(c);
268        matrix.end_burst();
269
270        // A should have association with B
271        assert!(matrix.association_strength(a, &[b]) > 0.0);
272        // And with C
273        assert!(matrix.association_strength(a, &[c]) > 0.0);
274    }
275
276    #[test]
277    fn unrelated_files_have_zero_association() {
278        let matrix = CoAccessMatrix::new();
279        let a = path_hash("src/main.rs");
280        let b = path_hash("src/lib.rs");
281        assert_eq!(matrix.association_strength(a, &[b]), 0.0);
282    }
283
284    #[test]
285    fn boltzmann_low_temp_is_deterministic() {
286        let energies = vec![10.0, 1.0, 5.0, 0.5, 8.0];
287        let evictions = boltzmann_select_evictions(&energies, 2, 0.01);
288        // Should evict lowest-energy entries: idx 3 (0.5) and idx 1 (1.0)
289        assert!(evictions.contains(&3));
290        assert!(evictions.contains(&1));
291    }
292
293    #[test]
294    fn boltzmann_high_temp_still_picks_n() {
295        let energies = vec![10.0, 1.0, 5.0, 0.5, 8.0];
296        let evictions = boltzmann_select_evictions(&energies, 2, 100.0);
297        assert_eq!(evictions.len(), 2);
298    }
299
300    #[test]
301    fn entry_energy_compute_is_sane() {
302        let high_value = EntryEnergy {
303            read_count: 10,
304            recency_secs: 5.0,
305            association_strength: 3.0,
306            token_size: 500,
307            graph_centrality: 0.8,
308        };
309        let low_value = EntryEnergy {
310            read_count: 1,
311            recency_secs: 3600.0,
312            association_strength: 0.0,
313            token_size: 50000,
314            graph_centrality: 0.0,
315        };
316        assert!(high_value.compute() > low_value.compute());
317    }
318
319    #[test]
320    fn normalized_key_is_symmetric() {
321        assert_eq!(normalized_key(42, 99), normalized_key(99, 42));
322    }
323}