Skip to main content

microscope_memory/
hebbian.rs

1//! Hebbian learning layer for Microscope Memory.
2//!
3//! "Neurons that fire together wire together."
4//!
5//! Tracks block activations and co-activations from queries.
6//! Over time, frequently co-activated blocks drift their coordinates closer.
7//! Energy decays exponentially — recently active blocks are "hot".
8//!
9//! Binary formats:
10//!   activations.bin — per-block activation state (HEB1)
11//!   coactivations.bin — sparse co-activation pairs (COA1)
12//!   fingerprints.bin — activation fingerprints for mirror neurons (FPR1)
13
14use std::collections::HashMap;
15use std::fs;
16use std::io::Write;
17use std::path::Path;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20// ─── Constants ──────────────────────────────────────
21
22const ACTIVATION_RECORD_BYTES: usize = 32; // manual serialization, not sizeof
23const COACTIVATION_RECORD_BYTES: usize = 20; // manual serialization, not sizeof
24const ENERGY_HALF_LIFE_MS: f64 = 86_400_000.0; // 24 hours
25const DRIFT_RATE: f32 = 0.01; // how fast coordinates move per Hebbian step
26const DRIFT_MAX: f32 = 0.1; // maximum drift from original position
27
28// ─── Activation state per block ─────────────────────
29
30/// Per-block activation record: 32 bytes, stored in activations.bin.
31#[repr(C)]
32#[derive(Clone, Copy, Debug)]
33pub struct ActivationRecord {
34    pub activation_count: u32,
35    pub last_activated_ms: u64,
36    pub drift_x: f32,
37    pub drift_y: f32,
38    pub drift_z: f32,
39    pub energy: f32,
40    pub _pad: u32,
41}
42
43impl Default for ActivationRecord {
44    fn default() -> Self {
45        Self {
46            activation_count: 0,
47            last_activated_ms: 0,
48            drift_x: 0.0,
49            drift_y: 0.0,
50            drift_z: 0.0,
51            energy: 0.0,
52            _pad: 0,
53        }
54    }
55}
56
57/// Co-activation pair: 20 bytes, stored in coactivations.bin.
58#[repr(C)]
59#[derive(Clone, Copy, Debug)]
60pub struct CoactivationPair {
61    pub block_a: u32,
62    pub block_b: u32,
63    pub count: u32,
64    pub last_ts_ms: u64,
65}
66
67/// Activation fingerprint — snapshot of a query's activation pattern.
68/// Used for mirror neuron resonance (future).
69#[derive(Clone, Debug)]
70pub struct ActivationFingerprint {
71    pub timestamp_ms: u64,
72    pub query_hash: u64,
73    pub activations: Vec<(u32, f32)>, // (block_idx, score)
74}
75
76// ─── HebbianState ───────────────────────────────────
77
78/// In-memory Hebbian state, loaded from binary files.
79pub struct HebbianState {
80    pub activations: Vec<ActivationRecord>,
81    pub coactivations: HashMap<(u32, u32), CoactivationPair>,
82    pub fingerprints: Vec<ActivationFingerprint>,
83}
84
85impl HebbianState {
86    /// Load or initialize Hebbian state for a given block count.
87    pub fn load_or_init(output_dir: &Path, block_count: usize) -> Self {
88        let activations = load_activations(output_dir, block_count);
89        let coactivations = load_coactivations(output_dir);
90        let fingerprints = load_fingerprints(output_dir);
91
92        Self {
93            activations,
94            coactivations,
95            fingerprints,
96        }
97    }
98
99    /// Record that a set of blocks were activated together by a query.
100    /// This is the core Hebbian learning signal.
101    pub fn record_activation(&mut self, results: &[(u32, f32)], query_hash: u64) {
102        let now_ms = now_epoch_ms();
103
104        // Update per-block activation records
105        for &(block_idx, _score) in results {
106            let idx = block_idx as usize;
107            if idx < self.activations.len() {
108                let rec = &mut self.activations[idx];
109                rec.activation_count += 1;
110                rec.last_activated_ms = now_ms;
111                rec.energy = 1.0; // fresh activation = max energy
112            }
113        }
114
115        // Record co-activations for all pairs
116        for i in 0..results.len() {
117            for j in (i + 1)..results.len() {
118                let a = results[i].0.min(results[j].0);
119                let b = results[i].0.max(results[j].0);
120                let pair = self
121                    .coactivations
122                    .entry((a, b))
123                    .or_insert(CoactivationPair {
124                        block_a: a,
125                        block_b: b,
126                        count: 0,
127                        last_ts_ms: 0,
128                    });
129                pair.count += 1;
130                pair.last_ts_ms = now_ms;
131            }
132        }
133
134        // Store activation fingerprint (for mirror neurons)
135        self.fingerprints.push(ActivationFingerprint {
136            timestamp_ms: now_ms,
137            query_hash,
138            activations: results.to_vec(),
139        });
140
141        // Keep fingerprints bounded (last 1000)
142        if self.fingerprints.len() > 1000 {
143            self.fingerprints.drain(0..self.fingerprints.len() - 1000);
144        }
145    }
146
147    /// Apply Hebbian drift: co-activated blocks pull each other's coordinates closer.
148    /// Call this during rebuild or periodically.
149    pub fn apply_drift(&mut self, headers: &[(f32, f32, f32)]) {
150        let now_ms = now_epoch_ms();
151
152        // First: decay all energies
153        for rec in &mut self.activations {
154            if rec.energy > 0.0 && rec.last_activated_ms > 0 {
155                let elapsed_ms = (now_ms - rec.last_activated_ms) as f64;
156                rec.energy *= (-(elapsed_ms / ENERGY_HALF_LIFE_MS) * std::f64::consts::LN_2) as f32;
157                rec.energy = rec.energy.exp();
158            }
159        }
160
161        // Apply Hebbian drift for co-activated pairs
162        for pair in self.coactivations.values() {
163            let a = pair.block_a as usize;
164            let b = pair.block_b as usize;
165
166            if a >= headers.len() || b >= headers.len() {
167                continue;
168            }
169
170            // Strength proportional to co-activation count, capped
171            let strength = (pair.count as f32).ln().min(5.0) * DRIFT_RATE;
172            if strength < 0.001 {
173                continue;
174            }
175
176            let (ax, ay, az) = headers[a];
177            let (bx, by, bz) = headers[b];
178
179            // Vector from A to B
180            let dx = bx + self.activations[b].drift_x - (ax + self.activations[a].drift_x);
181            let dy = by + self.activations[b].drift_y - (ay + self.activations[a].drift_y);
182            let dz = bz + self.activations[b].drift_z - (az + self.activations[a].drift_z);
183
184            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
185            if dist < 0.001 {
186                continue;
187            }
188
189            // Move A toward B, and B toward A
190            let nx = dx / dist * strength;
191            let ny = dy / dist * strength;
192            let nz = dz / dist * strength;
193
194            self.activations[a].drift_x = clamp_drift(self.activations[a].drift_x + nx);
195            self.activations[a].drift_y = clamp_drift(self.activations[a].drift_y + ny);
196            self.activations[a].drift_z = clamp_drift(self.activations[a].drift_z + nz);
197
198            self.activations[b].drift_x = clamp_drift(self.activations[b].drift_x - nx);
199            self.activations[b].drift_y = clamp_drift(self.activations[b].drift_y - ny);
200            self.activations[b].drift_z = clamp_drift(self.activations[b].drift_z - nz);
201        }
202    }
203
204    /// Get effective coordinates for a block (original + Hebbian drift).
205    pub fn effective_coords(&self, block_idx: usize, original: (f32, f32, f32)) -> (f32, f32, f32) {
206        if block_idx < self.activations.len() {
207            let rec = &self.activations[block_idx];
208            (
209                original.0 + rec.drift_x,
210                original.1 + rec.drift_y,
211                original.2 + rec.drift_z,
212            )
213        } else {
214            original
215        }
216    }
217
218    /// Get the energy (heat) of a block. 1.0 = just activated, decays toward 0.
219    pub fn energy(&self, block_idx: usize) -> f32 {
220        if block_idx < self.activations.len() {
221            let rec = &self.activations[block_idx];
222            if rec.energy > 0.0 && rec.last_activated_ms > 0 {
223                let elapsed_ms = (now_epoch_ms() - rec.last_activated_ms) as f64;
224                let decay = (-(elapsed_ms / ENERGY_HALF_LIFE_MS) * std::f64::consts::LN_2).exp();
225                decay as f32
226            } else {
227                0.0
228            }
229        } else {
230            0.0
231        }
232    }
233
234    /// Save all Hebbian state to binary files.
235    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
236        save_activations(output_dir, &self.activations)?;
237        save_coactivations(output_dir, &self.coactivations)?;
238        save_fingerprints(output_dir, &self.fingerprints)?;
239        Ok(())
240    }
241
242    /// Get statistics about the Hebbian state.
243    pub fn stats(&self) -> HebbianStats {
244        let active_blocks = self
245            .activations
246            .iter()
247            .filter(|r| r.activation_count > 0)
248            .count();
249        let total_activations: u64 = self
250            .activations
251            .iter()
252            .map(|r| r.activation_count as u64)
253            .sum();
254        let hot_blocks = self
255            .activations
256            .iter()
257            .enumerate()
258            .filter(|(i, _)| self.energy(*i) > 0.1)
259            .count();
260        let drifted_blocks = self
261            .activations
262            .iter()
263            .filter(|r| {
264                r.drift_x.abs() > 0.001 || r.drift_y.abs() > 0.001 || r.drift_z.abs() > 0.001
265            })
266            .count();
267
268        HebbianStats {
269            block_count: self.activations.len(),
270            active_blocks,
271            total_activations,
272            hot_blocks,
273            coactivation_pairs: self.coactivations.len(),
274            fingerprint_count: self.fingerprints.len(),
275            drifted_blocks,
276        }
277    }
278
279    /// Get the latest activation fingerprint (for mirror neuron sharing).
280    pub fn latest_fingerprint(&self) -> Option<&ActivationFingerprint> {
281        self.fingerprints.last()
282    }
283
284    /// Get top-N most activated blocks.
285    pub fn hottest_blocks(&self, n: usize) -> Vec<(usize, f32)> {
286        let mut blocks: Vec<(usize, f32)> = (0..self.activations.len())
287            .map(|i| (i, self.energy(i)))
288            .filter(|(_, e)| *e > 0.01)
289            .collect();
290        blocks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
291        blocks.truncate(n);
292        blocks
293    }
294
295    /// Get top-N strongest co-activation pairs.
296    pub fn strongest_pairs(&self, n: usize) -> Vec<&CoactivationPair> {
297        let mut pairs: Vec<&CoactivationPair> = self.coactivations.values().collect();
298        pairs.sort_by(|a, b| b.count.cmp(&a.count));
299        pairs.truncate(n);
300        pairs
301    }
302}
303
304pub struct HebbianStats {
305    pub block_count: usize,
306    pub active_blocks: usize,
307    pub total_activations: u64,
308    pub hot_blocks: usize,
309    pub coactivation_pairs: usize,
310    pub fingerprint_count: usize,
311    pub drifted_blocks: usize,
312}
313
314// ─── Binary I/O ─────────────────────────────────────
315
316fn read_u32(b: &[u8], off: usize) -> u32 {
317    u32::from_le_bytes(b[off..off + 4].try_into().unwrap())
318}
319fn read_u64(b: &[u8], off: usize) -> u64 {
320    u64::from_le_bytes(b[off..off + 8].try_into().unwrap())
321}
322fn read_f32(b: &[u8], off: usize) -> f32 {
323    f32::from_le_bytes(b[off..off + 4].try_into().unwrap())
324}
325
326fn load_activations(output_dir: &Path, block_count: usize) -> Vec<ActivationRecord> {
327    let path = output_dir.join("activations.bin");
328    if let Ok(data) = fs::read(&path) {
329        if data.len() >= 8 && &data[0..4] == b"HEB1" {
330            let stored_count = read_u32(&data, 4) as usize;
331            let expected_size = 8 + stored_count * ACTIVATION_RECORD_BYTES;
332            if data.len() >= expected_size {
333                let mut records = Vec::with_capacity(block_count.max(stored_count));
334                for i in 0..stored_count {
335                    let off = 8 + i * ACTIVATION_RECORD_BYTES;
336                    records.push(ActivationRecord {
337                        activation_count: read_u32(&data, off),
338                        last_activated_ms: read_u64(&data, off + 4),
339                        drift_x: read_f32(&data, off + 12),
340                        drift_y: read_f32(&data, off + 16),
341                        drift_z: read_f32(&data, off + 20),
342                        energy: read_f32(&data, off + 24),
343                        _pad: read_u32(&data, off + 28),
344                    });
345                }
346                records.resize(block_count.max(stored_count), ActivationRecord::default());
347                return records;
348            }
349        }
350    }
351    vec![ActivationRecord::default(); block_count]
352}
353
354fn save_activations(output_dir: &Path, records: &[ActivationRecord]) -> Result<(), String> {
355    let path = output_dir.join("activations.bin");
356    let mut buf = Vec::with_capacity(8 + records.len() * ACTIVATION_RECORD_BYTES);
357    buf.extend_from_slice(b"HEB1");
358    buf.extend_from_slice(&(records.len() as u32).to_le_bytes());
359    for rec in records {
360        buf.extend_from_slice(&rec.activation_count.to_le_bytes());
361        buf.extend_from_slice(&rec.last_activated_ms.to_le_bytes());
362        buf.extend_from_slice(&rec.drift_x.to_le_bytes());
363        buf.extend_from_slice(&rec.drift_y.to_le_bytes());
364        buf.extend_from_slice(&rec.drift_z.to_le_bytes());
365        buf.extend_from_slice(&rec.energy.to_le_bytes());
366        buf.extend_from_slice(&rec._pad.to_le_bytes());
367    }
368    fs::write(&path, &buf).map_err(|e| format!("write activations.bin: {}", e))
369}
370
371fn load_coactivations(output_dir: &Path) -> HashMap<(u32, u32), CoactivationPair> {
372    let path = output_dir.join("coactivations.bin");
373    let mut map = HashMap::new();
374    if let Ok(data) = fs::read(&path) {
375        if data.len() >= 8 && &data[0..4] == b"COA1" {
376            let pair_count = read_u32(&data, 4) as usize;
377            for i in 0..pair_count {
378                let off = 8 + i * COACTIVATION_RECORD_BYTES;
379                if off + COACTIVATION_RECORD_BYTES > data.len() {
380                    break;
381                }
382                let pair = CoactivationPair {
383                    block_a: read_u32(&data, off),
384                    block_b: read_u32(&data, off + 4),
385                    count: read_u32(&data, off + 8),
386                    last_ts_ms: read_u64(&data, off + 12),
387                };
388                map.insert((pair.block_a, pair.block_b), pair);
389            }
390        }
391    }
392    map
393}
394
395fn save_coactivations(
396    output_dir: &Path,
397    pairs: &HashMap<(u32, u32), CoactivationPair>,
398) -> Result<(), String> {
399    let path = output_dir.join("coactivations.bin");
400    let mut buf = Vec::with_capacity(8 + pairs.len() * COACTIVATION_RECORD_BYTES);
401    buf.extend_from_slice(b"COA1");
402    buf.extend_from_slice(&(pairs.len() as u32).to_le_bytes());
403    for pair in pairs.values() {
404        buf.extend_from_slice(&pair.block_a.to_le_bytes());
405        buf.extend_from_slice(&pair.block_b.to_le_bytes());
406        buf.extend_from_slice(&pair.count.to_le_bytes());
407        buf.extend_from_slice(&pair.last_ts_ms.to_le_bytes());
408    }
409    fs::write(&path, &buf).map_err(|e| format!("write coactivations.bin: {}", e))
410}
411
412fn load_fingerprints(output_dir: &Path) -> Vec<ActivationFingerprint> {
413    let path = output_dir.join("fingerprints.bin");
414    let mut fingerprints = Vec::new();
415    if let Ok(data) = fs::read(&path) {
416        if data.len() >= 8 && &data[0..4] == b"FPR1" {
417            let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
418            let mut pos = 8;
419            for _ in 0..count {
420                if pos + 18 > data.len() {
421                    break;
422                }
423                let timestamp_ms = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
424                let query_hash = u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap());
425                let activated_count =
426                    u16::from_le_bytes(data[pos + 16..pos + 18].try_into().unwrap()) as usize;
427                pos += 18;
428
429                let mut activations = Vec::with_capacity(activated_count);
430                for _ in 0..activated_count {
431                    if pos + 8 > data.len() {
432                        break;
433                    }
434                    let block_idx = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
435                    let score = f32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
436                    activations.push((block_idx, score));
437                    pos += 8;
438                }
439
440                fingerprints.push(ActivationFingerprint {
441                    timestamp_ms,
442                    query_hash,
443                    activations,
444                });
445            }
446        }
447    }
448    fingerprints
449}
450
451fn save_fingerprints(
452    output_dir: &Path,
453    fingerprints: &[ActivationFingerprint],
454) -> Result<(), String> {
455    let path = output_dir.join("fingerprints.bin");
456    let mut file =
457        fs::File::create(&path).map_err(|e| format!("create fingerprints.bin: {}", e))?;
458    file.write_all(b"FPR1")
459        .map_err(|e| format!("write magic: {}", e))?;
460    file.write_all(&(fingerprints.len() as u32).to_le_bytes())
461        .map_err(|e| format!("write count: {}", e))?;
462    for fp in fingerprints {
463        file.write_all(&fp.timestamp_ms.to_le_bytes())
464            .map_err(|e| format!("write ts: {}", e))?;
465        file.write_all(&fp.query_hash.to_le_bytes())
466            .map_err(|e| format!("write hash: {}", e))?;
467        file.write_all(&(fp.activations.len() as u16).to_le_bytes())
468            .map_err(|e| format!("write count: {}", e))?;
469        for &(block_idx, score) in &fp.activations {
470            file.write_all(&block_idx.to_le_bytes())
471                .map_err(|e| format!("write idx: {}", e))?;
472            file.write_all(&score.to_le_bytes())
473                .map_err(|e| format!("write score: {}", e))?;
474        }
475    }
476    Ok(())
477}
478
479// ─── Utilities ──────────────────────────────────────
480
481fn now_epoch_ms() -> u64 {
482    SystemTime::now()
483        .duration_since(UNIX_EPOCH)
484        .unwrap_or_default()
485        .as_millis() as u64
486}
487
488/// Public accessor for mirror neuron module.
489pub fn now_epoch_ms_pub() -> u64 {
490    now_epoch_ms()
491}
492
493fn clamp_drift(v: f32) -> f32 {
494    v.clamp(-DRIFT_MAX, DRIFT_MAX)
495}
496
497/// Hash a query string to u64 (for fingerprint tracking).
498pub fn query_hash(query: &str) -> u64 {
499    let mut h: u64 = 0xcbf29ce484222325;
500    for &b in query.as_bytes() {
501        h = h.wrapping_mul(0x100000001b3) ^ b as u64;
502    }
503    h
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_serialization_sizes() {
512        // Manual serialization sizes (not struct sizes — repr(C) may add padding)
513        assert_eq!(ACTIVATION_RECORD_BYTES, 32); // 4+8+4+4+4+4+4
514        assert_eq!(COACTIVATION_RECORD_BYTES, 20); // 4+4+4+8
515    }
516
517    #[test]
518    fn test_record_activation() {
519        let mut state = HebbianState {
520            activations: vec![ActivationRecord::default(); 10],
521            coactivations: HashMap::new(),
522            fingerprints: Vec::new(),
523        };
524
525        state.record_activation(&[(0, 0.5), (3, 0.3), (7, 0.1)], 12345);
526
527        assert_eq!(state.activations[0].activation_count, 1);
528        assert_eq!(state.activations[3].activation_count, 1);
529        assert_eq!(state.activations[7].activation_count, 1);
530        assert_eq!(state.activations[1].activation_count, 0);
531
532        // 3 pairs: (0,3), (0,7), (3,7)
533        assert_eq!(state.coactivations.len(), 3);
534        assert!(state.coactivations.contains_key(&(0, 3)));
535        assert!(state.coactivations.contains_key(&(0, 7)));
536        assert!(state.coactivations.contains_key(&(3, 7)));
537
538        // Fingerprint stored
539        assert_eq!(state.fingerprints.len(), 1);
540        assert_eq!(state.fingerprints[0].query_hash, 12345);
541        assert_eq!(state.fingerprints[0].activations.len(), 3);
542    }
543
544    #[test]
545    fn test_repeated_coactivation() {
546        let mut state = HebbianState {
547            activations: vec![ActivationRecord::default(); 5],
548            coactivations: HashMap::new(),
549            fingerprints: Vec::new(),
550        };
551
552        state.record_activation(&[(1, 0.5), (2, 0.3)], 100);
553        state.record_activation(&[(1, 0.4), (2, 0.6)], 200);
554        state.record_activation(&[(1, 0.3), (2, 0.2)], 300);
555
556        assert_eq!(state.activations[1].activation_count, 3);
557        assert_eq!(state.coactivations[&(1, 2)].count, 3);
558    }
559
560    #[test]
561    fn test_drift_application() {
562        let mut state = HebbianState {
563            activations: vec![ActivationRecord::default(); 3],
564            coactivations: HashMap::new(),
565            fingerprints: Vec::new(),
566        };
567
568        // Simulate strong co-activation between blocks 0 and 2
569        for _ in 0..20 {
570            state.record_activation(&[(0, 0.5), (2, 0.5)], 42);
571        }
572
573        let headers = vec![(0.0, 0.0, 0.0), (0.5, 0.5, 0.5), (1.0, 1.0, 1.0)];
574        state.apply_drift(&headers);
575
576        // Block 0 should drift toward (1,1,1) and block 2 toward (0,0,0)
577        assert!(state.activations[0].drift_x > 0.0);
578        assert!(state.activations[0].drift_y > 0.0);
579        assert!(state.activations[0].drift_z > 0.0);
580        assert!(state.activations[2].drift_x < 0.0);
581        assert!(state.activations[2].drift_y < 0.0);
582        assert!(state.activations[2].drift_z < 0.0);
583    }
584
585    #[test]
586    fn test_effective_coords() {
587        let mut state = HebbianState {
588            activations: vec![ActivationRecord::default(); 2],
589            coactivations: HashMap::new(),
590            fingerprints: Vec::new(),
591        };
592
593        state.activations[0].drift_x = 0.05;
594        state.activations[0].drift_y = -0.03;
595        state.activations[0].drift_z = 0.01;
596
597        let (x, y, z) = state.effective_coords(0, (0.2, 0.3, 0.4));
598        assert!((x - 0.25).abs() < 0.001);
599        assert!((y - 0.27).abs() < 0.001);
600        assert!((z - 0.41).abs() < 0.001);
601    }
602
603    #[test]
604    fn test_save_load_roundtrip() {
605        let tmp = tempfile::tempdir().expect("create temp dir");
606        let dir = tmp.path();
607
608        let mut state = HebbianState {
609            activations: vec![ActivationRecord::default(); 5],
610            coactivations: HashMap::new(),
611            fingerprints: Vec::new(),
612        };
613
614        state.record_activation(&[(0, 0.5), (2, 0.3), (4, 0.1)], 999);
615        state.record_activation(&[(1, 0.8), (3, 0.2)], 888);
616
617        state.save(dir).expect("save");
618
619        let loaded = HebbianState::load_or_init(dir, 5);
620        assert_eq!(loaded.activations[0].activation_count, 1);
621        assert_eq!(loaded.activations[1].activation_count, 1);
622        assert_eq!(loaded.coactivations.len(), 4); // (0,2), (0,4), (2,4), (1,3)
623        assert_eq!(loaded.fingerprints.len(), 2);
624        assert_eq!(loaded.fingerprints[0].query_hash, 999);
625        assert_eq!(loaded.fingerprints[1].query_hash, 888);
626    }
627
628    #[test]
629    fn test_clamp_drift() {
630        assert_eq!(clamp_drift(0.05), 0.05);
631        assert_eq!(clamp_drift(0.2), DRIFT_MAX);
632        assert_eq!(clamp_drift(-0.2), -DRIFT_MAX);
633    }
634
635    #[test]
636    fn test_query_hash_deterministic() {
637        assert_eq!(query_hash("hello"), query_hash("hello"));
638        assert_ne!(query_hash("hello"), query_hash("world"));
639    }
640
641    #[test]
642    fn test_hottest_blocks() {
643        let mut state = HebbianState {
644            activations: vec![ActivationRecord::default(); 5],
645            coactivations: HashMap::new(),
646            fingerprints: Vec::new(),
647        };
648
649        state.record_activation(&[(0, 1.0), (2, 0.5)], 1);
650
651        let hot = state.hottest_blocks(10);
652        assert!(!hot.is_empty());
653        // Block 0 and 2 should be hot
654        assert!(hot.iter().any(|(idx, _)| *idx == 0));
655        assert!(hot.iter().any(|(idx, _)| *idx == 2));
656    }
657
658    #[test]
659    fn test_stats() {
660        let mut state = HebbianState {
661            activations: vec![ActivationRecord::default(); 10],
662            coactivations: HashMap::new(),
663            fingerprints: Vec::new(),
664        };
665
666        state.record_activation(&[(0, 1.0), (5, 0.5)], 42);
667
668        let stats = state.stats();
669        assert_eq!(stats.block_count, 10);
670        assert_eq!(stats.active_blocks, 2);
671        assert_eq!(stats.total_activations, 2);
672        assert_eq!(stats.coactivation_pairs, 1);
673        assert_eq!(stats.fingerprint_count, 1);
674    }
675}