lean_ctx/core/
hebbian_cache.rs1use std::collections::HashMap;
11use std::time::Instant;
12
13const MAX_ASSOCIATIONS: usize = 10_000;
15const DECAY_HALF_LIFE_SECS: f64 = 300.0;
17const PRUNE_THRESHOLD: f32 = 0.01;
19
20pub struct CoAccessMatrix {
22 weights: HashMap<(u64, u64), f32>,
24 timestamps: HashMap<(u64, u64), Instant>,
26 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 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 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 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 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 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 pub fn end_burst(&mut self) {
155 self.flush_burst();
156 }
157}
158
159fn normalized_key(a: u64, b: u64) -> (u64, u64) {
161 if a <= b { (a, b) } else { (b, a) }
162}
163
164pub 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
172pub 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 pub fn compute(&self) -> f64 {
188 let recency_score = 1.0 / (1.0 + self.recency_secs / 60.0);
190
191 let freq_score = (self.read_count as f64).sqrt();
193
194 let assoc_score = (self.association_strength as f64).min(5.0);
196
197 let size_penalty = 1.0 / (1.0 + (self.token_size as f64 / 5000.0));
199
200 let centrality_score = self.graph_centrality as f64;
202
203 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
212pub 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); 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() })
238 .collect();
239
240 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 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 matrix.record_access(a);
262 matrix.record_access(b);
263 matrix.record_access(c);
264 matrix.end_burst();
265
266 assert!(matrix.association_strength(a, &[b]) > 0.0);
268 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 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}