lean_ctx/core/
hebbian_cache.rs1use std::collections::HashMap;
11use std::time::{Duration, 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 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 #[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 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 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 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 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 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 pub fn end_burst(&mut self) {
168 self.flush_burst();
169 }
170}
171
172fn normalized_key(a: u64, b: u64) -> (u64, u64) {
174 if a <= b { (a, b) } else { (b, a) }
175}
176
177pub 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
185pub 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 pub fn compute(&self) -> f64 {
201 let recency_score = 1.0 / (1.0 + self.recency_secs / 60.0);
203
204 let freq_score = (self.read_count as f64).sqrt();
206
207 let assoc_score = (self.association_strength as f64).min(5.0);
209
210 let size_penalty = 1.0 / (1.0 + (self.token_size as f64 / 5000.0));
212
213 let centrality_score = self.graph_centrality as f64;
215
216 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
225pub 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); 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() })
251 .collect();
252
253 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 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 matrix.record_access(a);
275 matrix.record_access(b);
276 matrix.record_access(c);
277 matrix.end_burst();
278
279 assert!(matrix.association_strength(a, &[b]) > 0.0);
281 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 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}