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 {
162 (a, b)
163 } else {
164 (b, a)
165 }
166}
167
168pub 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
176pub 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 pub fn compute(&self) -> f64 {
192 let recency_score = 1.0 / (1.0 + self.recency_secs / 60.0);
194
195 let freq_score = (self.read_count as f64).sqrt();
197
198 let assoc_score = (self.association_strength as f64).min(5.0);
200
201 let size_penalty = 1.0 / (1.0 + (self.token_size as f64 / 5000.0));
203
204 let centrality_score = self.graph_centrality as f64;
206
207 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
216pub 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); 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() })
242 .collect();
243
244 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 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 matrix.record_access(a);
266 matrix.record_access(b);
267 matrix.record_access(c);
268 matrix.end_burst();
269
270 assert!(matrix.association_strength(a, &[b]) > 0.0);
272 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 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}