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 (HEB2, sparse: only records
11//!     that differ from the default are stored, plus block_count)
12//!   activations_delta.bin — append-only, CRC-checked journal of the records a
13//!     recall touched (AEM2); folded into the base on a full save or at
14//!     JOURNAL_MAX_RECORDS
15//!   coactivations.bin — sparse co-activation pairs (COA1)
16//!   fingerprints.bin — activation fingerprints for mirror neurons (FPR1)
17
18use std::collections::HashMap;
19use std::fs;
20use std::io::Write;
21use std::path::Path;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24// ─── Constants ──────────────────────────────────────
25
26const ACTIVATION_RECORD_BYTES: usize = 32; // manual serialization, not sizeof
27/// Upper bound on how far `record_activation` will extend the activation
28/// vector. It exists only to reject the `u32::MAX` "dropped block" sentinel
29/// the retention remap uses -- not to police real block indices, which cannot
30/// reach it. See the call site.
31const ACTIVATION_GROWTH_LIMIT: usize = 4_000_000;
32const COACTIVATION_RECORD_BYTES: usize = 20; // manual serialization, not sizeof
33const ENERGY_HALF_LIFE_MS: f64 = 86_400_000.0; // 24 hours
34const DRIFT_RATE: f32 = 0.01; // how fast coordinates move per Hebbian step
35const DRIFT_MAX: f32 = 0.1; // maximum drift from original position
36
37// ─── Activation state per block ─────────────────────
38
39/// Per-block activation record: 32 bytes, stored in activations.bin.
40#[repr(C)]
41#[derive(Clone, Copy, Debug)]
42pub struct ActivationRecord {
43    pub activation_count: u32,
44    pub last_activated_ms: u64,
45    pub drift_x: f32,
46    pub drift_y: f32,
47    pub drift_z: f32,
48    pub energy: f32,
49    pub _pad: u32,
50}
51
52impl Default for ActivationRecord {
53    fn default() -> Self {
54        Self {
55            activation_count: 0,
56            last_activated_ms: 0,
57            drift_x: 0.0,
58            drift_y: 0.0,
59            drift_z: 0.0,
60            energy: 0.0,
61            _pad: 0,
62        }
63    }
64}
65
66/// Co-activation pair: 20 bytes, stored in coactivations.bin.
67#[repr(C)]
68#[derive(Clone, Copy, Debug)]
69pub struct CoactivationPair {
70    pub block_a: u32,
71    pub block_b: u32,
72    pub count: u32,
73    pub last_ts_ms: u64,
74}
75
76/// Activation fingerprint — snapshot of a query's activation pattern.
77/// Used for mirror neuron resonance (future).
78#[derive(Clone, Debug)]
79pub struct ActivationFingerprint {
80    pub timestamp_ms: u64,
81    pub query_hash: u64,
82    pub activations: Vec<(u32, f32)>, // (block_idx, score)
83}
84
85// ─── HebbianState ───────────────────────────────────
86
87/// In-memory Hebbian state, loaded from binary files.
88#[derive(Clone)]
89pub struct HebbianState {
90    pub activations: Vec<ActivationRecord>,
91    pub coactivations: HashMap<(u32, u32), CoactivationPair>,
92    pub fingerprints: Vec<ActivationFingerprint>,
93}
94
95impl HebbianState {
96    /// Load or initialize Hebbian state for a given block count.
97    pub fn load_or_init(output_dir: &Path, block_count: usize) -> Self {
98        let activations = load_activations(output_dir, block_count);
99        let coactivations = load_coactivations(output_dir);
100        let fingerprints = load_fingerprints(output_dir);
101
102        Self {
103            activations,
104            coactivations,
105            fingerprints,
106        }
107    }
108
109    /// Record that a set of blocks were activated together by a query.
110    /// This is the core Hebbian learning signal.
111    pub fn record_activation(&mut self, results: &[(u32, f32)], query_hash: u64) {
112        let now_ms = now_epoch_ms();
113
114        // Update per-block activation records with saturation protection
115        for &(block_idx, _score) in results {
116            let idx = block_idx as usize;
117            if idx < self.activations.len() {
118                let rec = &mut self.activations[idx];
119                rec.activation_count = rec.activation_count.saturating_add(1);
120                rec.last_activated_ms = now_ms;
121                rec.energy = 1.0; // fresh activation = max energy (already saturated)
122            } else if idx < ACTIVATION_GROWTH_LIMIT {
123                // Grow rather than drop. The vector is a sparse prefix, so an
124                // index past its end is an untouched block, not an invalid one,
125                // and skipping it would lose that block's very first
126                // activation -- silently, with no error anywhere.
127                //
128                // The limit rejects the `u32::MAX` "dropped block" sentinel the
129                // retention remap uses, which here would ask for a 128 GB
130                // allocation. It is four times the largest index this project has
131                // shipped (967,587), so no real block index can reach it.
132                self.activations
133                    .resize(idx + 1, ActivationRecord::default());
134                let rec = &mut self.activations[idx];
135                rec.activation_count = 1;
136                rec.last_activated_ms = now_ms;
137                rec.energy = 1.0;
138            }
139        }
140
141        // Record co-activations for all pairs
142        for i in 0..results.len() {
143            for j in (i + 1)..results.len() {
144                let a = results[i].0.min(results[j].0);
145                let b = results[i].0.max(results[j].0);
146                let pair = self
147                    .coactivations
148                    .entry((a, b))
149                    .or_insert(CoactivationPair {
150                        block_a: a,
151                        block_b: b,
152                        count: 0,
153                        last_ts_ms: 0,
154                    });
155                pair.count = pair.count.saturating_add(1);
156                pair.last_ts_ms = now_ms;
157            }
158        }
159
160        // Store activation fingerprint (for mirror neurons)
161        self.fingerprints.push(ActivationFingerprint {
162            timestamp_ms: now_ms,
163            query_hash,
164            activations: results.to_vec(),
165        });
166
167        // Keep fingerprints bounded (last 1000)
168        if self.fingerprints.len() > 1000 {
169            self.fingerprints.drain(0..self.fingerprints.len() - 1000);
170        }
171    }
172
173    /// Apply Hebbian drift: co-activated blocks pull each other's coordinates closer.
174    /// Call this during rebuild or periodically.
175    pub fn apply_drift(&mut self, headers: &[(f32, f32, f32)]) {
176        let now_ms = now_epoch_ms();
177
178        // First: decay all energies (exponential half-life decay)
179        // Correct formula: energy *= exp(-elapsed * ln(2) / half_life)
180        // The previous version multiplied energy by a negative factor then took exp(),
181        // which collapsed all energies toward 0 regardless of their initial value.
182        for rec in &mut self.activations {
183            if rec.energy > 0.0 && rec.last_activated_ms > 0 {
184                let elapsed_ms = (now_ms - rec.last_activated_ms) as f64;
185                if elapsed_ms < 0.0 {
186                    // Clock skew or future timestamp — skip to avoid panics
187                    continue;
188                }
189                let decay =
190                    (-(elapsed_ms / ENERGY_HALF_LIFE_MS) * std::f64::consts::LN_2).exp() as f32;
191                rec.energy *= decay;
192            }
193        }
194
195        // Apply Hebbian drift for co-activated pairs
196        for pair in self.coactivations.values() {
197            let a = pair.block_a as usize;
198            let b = pair.block_b as usize;
199
200            if a >= headers.len() || b >= headers.len() {
201                continue;
202            }
203
204            // Strength proportional to co-activation count, capped
205            let strength = (pair.count as f32).ln().min(5.0) * DRIFT_RATE;
206            if strength < 0.001 {
207                continue;
208            }
209
210            let (ax, ay, az) = headers[a];
211            let (bx, by, bz) = headers[b];
212
213            // Vector from A to B
214            let dx = bx + self.activations[b].drift_x - (ax + self.activations[a].drift_x);
215            let dy = by + self.activations[b].drift_y - (ay + self.activations[a].drift_y);
216            let dz = bz + self.activations[b].drift_z - (az + self.activations[a].drift_z);
217
218            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
219            if dist < 0.001 {
220                continue;
221            }
222
223            // Move A toward B, and B toward A
224            let nx = dx / dist * strength;
225            let ny = dy / dist * strength;
226            let nz = dz / dist * strength;
227
228            self.activations[a].drift_x = clamp_drift(self.activations[a].drift_x + nx);
229            self.activations[a].drift_y = clamp_drift(self.activations[a].drift_y + ny);
230            self.activations[a].drift_z = clamp_drift(self.activations[a].drift_z + nz);
231
232            self.activations[b].drift_x = clamp_drift(self.activations[b].drift_x - nx);
233            self.activations[b].drift_y = clamp_drift(self.activations[b].drift_y - ny);
234            self.activations[b].drift_z = clamp_drift(self.activations[b].drift_z - nz);
235        }
236    }
237
238    /// Get effective coordinates for a block (original + Hebbian drift).
239    pub fn effective_coords(&self, block_idx: usize, original: (f32, f32, f32)) -> (f32, f32, f32) {
240        if block_idx < self.activations.len() {
241            let rec = &self.activations[block_idx];
242            (
243                original.0 + rec.drift_x,
244                original.1 + rec.drift_y,
245                original.2 + rec.drift_z,
246            )
247        } else {
248            original
249        }
250    }
251
252    /// Get the energy (heat) of a block. 1.0 = just activated, decays toward 0.
253    pub fn energy(&self, block_idx: usize) -> f32 {
254        if block_idx < self.activations.len() {
255            let rec = &self.activations[block_idx];
256            if rec.energy > 0.0 && rec.last_activated_ms > 0 {
257                let elapsed_ms = (now_epoch_ms() - rec.last_activated_ms) as f64;
258                let decay = (-(elapsed_ms / ENERGY_HALF_LIFE_MS) * std::f64::consts::LN_2).exp();
259                decay as f32
260            } else {
261                0.0
262            }
263        } else {
264            0.0
265        }
266    }
267
268    /// Block indices whose decayed energy is at or above `threshold`, ascending.
269    ///
270    /// The energy lives in memory, so the candidate set can be built before any
271    /// block header is touched. A consumer that would otherwise scan all
272    /// 699,110 headers to find the handful of hot blocks reads only those
273    /// headers. The result is exactly `{i : energy(i) >= threshold}`, in
274    /// ascending order, so a full-scan formulation and this one produce
275    /// bit-identical sums.
276    pub fn hot_indices(&self, threshold: f32) -> Vec<usize> {
277        let mut out = Vec::new();
278        for (i, rec) in self.activations.iter().enumerate() {
279            // energy() is 0.0 in exactly these two cases; skip the exp() for
280            // them, which is nearly every record on a fresh index.
281            if rec.energy <= 0.0 || rec.last_activated_ms == 0 {
282                continue;
283            }
284            if self.energy(i) >= threshold {
285                out.push(i);
286            }
287        }
288        out
289    }
290
291    /// Save all Hebbian state to binary files. Writes the activation base in
292    /// full, which is the correct thing after a rebuild or a remap.
293    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
294        save_activations(output_dir, &self.activations)?;
295        save_coactivations(output_dir, &self.coactivations)?;
296        save_fingerprints(output_dir, &self.fingerprints)?;
297        Ok(())
298    }
299
300    /// Save after a recall: journal only the blocks that were actually
301    /// activated, and rewrite the base only when the journal has reached its
302    /// bound or the base is missing.
303    ///
304    /// The caller passes the dirty indices because it is the one that knows
305    /// them -- recall activates exactly the top-k blocks it returned. Nothing is
306    /// deferred to process exit on purpose: a CLI invocation is a whole
307    /// process, so "write it later" would lose the learning entirely.
308    pub fn save_dirty(&self, output_dir: &Path, dirty: &[u32]) -> Result<(), String> {
309        let base_missing = !output_dir.join("activations.bin").exists();
310        if base_missing || delta_journal_records(output_dir) >= JOURNAL_MAX_RECORDS {
311            save_activations(output_dir, &self.activations)?;
312        } else {
313            append_activation_deltas(output_dir, &self.activations, dirty)?;
314        }
315        save_coactivations(output_dir, &self.coactivations)?;
316        save_fingerprints(output_dir, &self.fingerprints)?;
317        Ok(())
318    }
319
320    /// Get statistics about the Hebbian state.
321    pub fn stats(&self) -> HebbianStats {
322        let active_blocks = self
323            .activations
324            .iter()
325            .filter(|r| r.activation_count > 0)
326            .count();
327        let total_activations: u64 = self
328            .activations
329            .iter()
330            .map(|r| r.activation_count as u64)
331            .sum();
332        let hot_blocks = self
333            .activations
334            .iter()
335            .enumerate()
336            .filter(|(i, _)| self.energy(*i) > 0.1)
337            .count();
338        let drifted_blocks = self
339            .activations
340            .iter()
341            .filter(|r| {
342                r.drift_x.abs() > 0.001 || r.drift_y.abs() > 0.001 || r.drift_z.abs() > 0.001
343            })
344            .count();
345
346        HebbianStats {
347            block_count: self.activations.len(),
348            active_blocks,
349            total_activations,
350            hot_blocks,
351            coactivation_pairs: self.coactivations.len(),
352            fingerprint_count: self.fingerprints.len(),
353            drifted_blocks,
354        }
355    }
356
357    /// Get the latest activation fingerprint (for mirror neuron sharing).
358    pub fn latest_fingerprint(&self) -> Option<&ActivationFingerprint> {
359        self.fingerprints.last()
360    }
361
362    /// Get top-N most activated blocks.
363    pub fn hottest_blocks(&self, n: usize) -> Vec<(usize, f32)> {
364        let mut blocks: Vec<(usize, f32)> = (0..self.activations.len())
365            .map(|i| (i, self.energy(i)))
366            .filter(|(_, e)| *e > 0.01)
367            .collect();
368        blocks.sort_by(|a, b| b.1.total_cmp(&a.1));
369        blocks.truncate(n);
370        blocks
371    }
372
373    /// Get top-N strongest co-activation pairs.
374    pub fn strongest_pairs(&self, n: usize) -> Vec<&CoactivationPair> {
375        let mut pairs: Vec<&CoactivationPair> = self.coactivations.values().collect();
376        pairs.sort_by_key(|b| std::cmp::Reverse(b.count));
377        pairs.truncate(n);
378        pairs
379    }
380
381    /// Remap all block indexes after a rebuild reorders blocks.
382    ///
383    /// `old_to_new[old_idx] = new_idx`. Blocks that were not in the old
384    /// layout get `u32::MAX` and are dropped.
385    pub fn remap_indexes(&mut self, old_to_new: &[u32]) {
386        let old_len = self.activations.len();
387        // u32::MAX marks "dropped" — it must not size the target vector, or a
388        // single unmatched block would allocate a 2^32-element array.
389        let new_len = old_to_new
390            .iter()
391            .copied()
392            .filter(|&m| m != u32::MAX)
393            .max()
394            .map_or(0, |m| m as usize + 1);
395
396        // 1. Remap activations vector
397        let mut new_activations = vec![ActivationRecord::default(); new_len];
398        for (old_idx, &new_idx) in old_to_new
399            .iter()
400            .enumerate()
401            .take(old_len.min(old_to_new.len()))
402        {
403            if new_idx != u32::MAX && (new_idx as usize) < new_len {
404                new_activations[new_idx as usize] = self.activations[old_idx];
405            }
406        }
407        self.activations = new_activations;
408
409        // 2. Remap coactivation pair keys
410        let mut new_coactivations = HashMap::new();
411        for ((a, b), mut pair) in self.coactivations.drain() {
412            let na = if (a as usize) < old_to_new.len() {
413                old_to_new[a as usize]
414            } else {
415                u32::MAX
416            };
417            let nb = if (b as usize) < old_to_new.len() {
418                old_to_new[b as usize]
419            } else {
420                u32::MAX
421            };
422            if na != u32::MAX && nb != u32::MAX && na != nb {
423                let key = (na.min(nb), na.max(nb));
424                pair.block_a = key.0;
425                pair.block_b = key.1;
426                new_coactivations
427                    .entry(key)
428                    .and_modify(|e: &mut CoactivationPair| {
429                        e.count = e.count.saturating_add(pair.count);
430                    })
431                    .or_insert(pair);
432            }
433        }
434        self.coactivations = new_coactivations;
435
436        // 3. Remap fingerprint block indexes
437        for fp in &mut self.fingerprints {
438            for (idx, _) in &mut fp.activations {
439                if (*idx as usize) < old_to_new.len() {
440                    *idx = old_to_new[*idx as usize];
441                } else {
442                    *idx = u32::MAX;
443                }
444            }
445            fp.activations.retain(|(idx, _)| *idx != u32::MAX);
446        }
447    }
448}
449
450pub struct HebbianStats {
451    pub block_count: usize,
452    pub active_blocks: usize,
453    pub total_activations: u64,
454    pub hot_blocks: usize,
455    pub coactivation_pairs: usize,
456    pub fingerprint_count: usize,
457    pub drifted_blocks: usize,
458}
459
460// ─── Binary I/O ─────────────────────────────────────
461
462fn read_u32(b: &[u8], off: usize) -> u32 {
463    u32::from_le_bytes(b[off..off + 4].try_into().unwrap())
464}
465fn read_u64(b: &[u8], off: usize) -> u64 {
466    u64::from_le_bytes(b[off..off + 8].try_into().unwrap())
467}
468fn read_f32(b: &[u8], off: usize) -> f32 {
469    f32::from_le_bytes(b[off..off + 4].try_into().unwrap())
470}
471
472/// CRC32 (IEEE, bitwise). Validates delta-journal records: a torn tail must be
473/// detected and dropped, never applied.
474fn crc32(bytes: &[u8]) -> u32 {
475    let mut crc: u32 = 0xFFFF_FFFF;
476    for &b in bytes {
477        crc ^= b as u32;
478        for _ in 0..8 {
479            let mask = (crc & 1).wrapping_neg();
480            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
481        }
482    }
483    !crc
484}
485
486/// Journal record: 4 (index) + 32 (record) + 4 (crc).
487const DELTA_RECORD_BYTES: usize = 4 + ACTIVATION_RECORD_BYTES + 4;
488const DELTA_MAGIC: &[u8; 4] = b"AEM2";
489const DELTA_FILE: &str = "activations_delta.bin";
490/// Compact the base once the journal holds this many records (~160 KB), so the
491/// load path stays bounded however long the process keeps learning.
492const JOURNAL_MAX_RECORDS: usize = 4096;
493
494fn decode_activation_record(data: &[u8], off: usize) -> ActivationRecord {
495    ActivationRecord {
496        activation_count: read_u32(data, off),
497        last_activated_ms: read_u64(data, off + 4),
498        drift_x: read_f32(data, off + 12),
499        drift_y: read_f32(data, off + 16),
500        drift_z: read_f32(data, off + 20),
501        energy: read_f32(data, off + 24),
502        _pad: read_u32(data, off + 28),
503    }
504}
505
506fn encode_activation_record(buf: &mut Vec<u8>, rec: &ActivationRecord) {
507    buf.extend_from_slice(&rec.activation_count.to_le_bytes());
508    buf.extend_from_slice(&rec.last_activated_ms.to_le_bytes());
509    buf.extend_from_slice(&rec.drift_x.to_le_bytes());
510    buf.extend_from_slice(&rec.drift_y.to_le_bytes());
511    buf.extend_from_slice(&rec.drift_z.to_le_bytes());
512    buf.extend_from_slice(&rec.energy.to_le_bytes());
513    buf.extend_from_slice(&rec._pad.to_le_bytes());
514}
515
516/// True when a record carries no information, i.e. it equals what a freshly
517/// resized vector already holds. On a clean 699k-block index that is all of
518/// them, and none of them is ever written.
519fn is_default_record(rec: &ActivationRecord) -> bool {
520    rec.activation_count == 0
521        && rec.last_activated_ms == 0
522        && rec.drift_x == 0.0
523        && rec.drift_y == 0.0
524        && rec.drift_z == 0.0
525        && rec.energy == 0.0
526        && rec._pad == 0
527}
528
529/// Apply the append-only delta journal on top of a loaded base.
530///
531/// A crash can leave a partial record. Records are CRC-checked and the first
532/// bad one ends the scan: a torn tail is dropped, never applied, and the file
533/// is truncated to the last good record so the next append lands on a valid
534/// boundary.
535fn apply_activation_deltas(
536    output_dir: &Path,
537    records: &mut Vec<ActivationRecord>,
538    block_count: usize,
539) {
540    let path = output_dir.join(DELTA_FILE);
541    let data = match fs::read(&path) {
542        Ok(d) => d,
543        Err(_) => return,
544    };
545    if data.len() < 8 || &data[0..4] != DELTA_MAGIC {
546        // Too short, or not ours (a future format): ignore rather than
547        // misinterpret. Never delete a file we do not understand.
548        return;
549    }
550    let mut pos = 8usize;
551    while pos + DELTA_RECORD_BYTES <= data.len() {
552        let body = &data[pos..pos + 4 + ACTIVATION_RECORD_BYTES];
553        if crc32(body) != read_u32(&data, pos + 4 + ACTIVATION_RECORD_BYTES) {
554            break;
555        }
556        let idx = read_u32(&data, pos) as usize;
557        if idx >= block_count {
558            // The record names a block this corpus no longer has. Stop: a
559            // rebuild re-creates the file, and resurrecting a block the index
560            // does not have would be wrong.
561            break;
562        }
563        if idx >= records.len() {
564            // Legitimate, and past the base file's last stored index. Grow.
565            // Bounding this by `records.len()` instead of `block_count` -- which
566            // is what the old corpus-sized vector made indistinguishable --
567            // truncates the whole journal at the first record not yet folded
568            // into the base, silently losing every record after it.
569            records.resize(idx + 1, ActivationRecord::default());
570        }
571        records[idx] = decode_activation_record(&data, pos + 4);
572        pos += DELTA_RECORD_BYTES;
573    }
574    if pos != data.len() {
575        // Trim the unusable tail so the next append cannot be misread as part
576        // of a torn record.
577        if let Ok(f) = fs::OpenOptions::new().write(true).open(&path) {
578            let _ = f.set_len(pos as u64);
579        }
580    }
581}
582
583fn load_activations(output_dir: &Path, block_count: usize) -> Vec<ActivationRecord> {
584    let path = output_dir.join("activations.bin");
585    // Sized from the stored records, not from `block_count`. Allocating the
586    // full block count zeroed 30,962,784 bytes on every recall to hold the 197
587    // records in a 7,104-byte file: 6.8 ms of a 37.4 ms query, scaling with the
588    // corpus rather than with what had actually been learned.
589    //
590    // The vector is a sparse prefix: `len()` is the highest stored index plus
591    // one, and an index at or past it has never been activated. For the
592    // evaluation index the highest stored index is 12,625, so 0.4 MB replaces
593    // 31 MB.
594    let mut records: Vec<ActivationRecord> = Vec::new();
595    if let Ok(data) = fs::read(&path) {
596        if data.len() >= 12 && &data[0..4] == b"HEB2" {
597            // [HEB2][u32 block_count][u32 stored][(u32 idx, record)...]
598            let _stored = read_u32(&data, 4) as usize;
599            let count = read_u32(&data, 8) as usize;
600            let stride = 4 + ACTIVATION_RECORD_BYTES;
601            // Size from the record indices only.
602            //
603            // The field at offset 4 is named `stored` here but it is the
604            // corpus block_count, not a record count -- the header layout is
605            // [HEB2][u32 block_count][u32 count]. Folding it into `needed` is
606            // what made a first attempt at this change a silent no-op: the
607            // vector stayed at 967,587 entries and the load time did not move
608            // at all.
609            let mut needed = 0usize;
610            for i in 0..count {
611                let off = 12 + i * stride;
612                if off + stride > data.len() {
613                    break;
614                }
615                let idx = read_u32(&data, off) as usize;
616                if idx < block_count {
617                    needed = needed.max(idx + 1);
618                }
619            }
620            records.resize(needed, ActivationRecord::default());
621            for i in 0..count {
622                let off = 12 + i * stride;
623                if off + stride > data.len() {
624                    break;
625                }
626                let idx = read_u32(&data, off) as usize;
627                if idx < records.len() {
628                    records[idx] = decode_activation_record(&data, off + 4);
629                }
630            }
631        } else if data.len() >= 8 && &data[0..4] == b"HEB1" {
632            // Legacy dense layout, still written by older builds. Read it, and
633            // the next save converts the file to the sparse form.
634            let stored_count = read_u32(&data, 4) as usize;
635            let expected_size = 8 + stored_count * ACTIVATION_RECORD_BYTES;
636            if data.len() >= expected_size {
637                records.clear();
638                records.reserve(block_count.max(stored_count));
639                for i in 0..stored_count {
640                    records.push(decode_activation_record(
641                        &data,
642                        8 + i * ACTIVATION_RECORD_BYTES,
643                    ));
644                }
645                records.resize(block_count.max(stored_count), ActivationRecord::default());
646            }
647        }
648    }
649    apply_activation_deltas(output_dir, &mut records, block_count);
650    records
651}
652
653/// Write the sparse base atomically and retire the journal: after this a load
654/// needs the base only. This is the checkpoint a rebuild or a remap relies on.
655fn save_activations(output_dir: &Path, records: &[ActivationRecord]) -> Result<(), String> {
656    let path = output_dir.join("activations.bin");
657    let idxs: Vec<usize> = records
658        .iter()
659        .enumerate()
660        .filter(|(_, rec)| !is_default_record(rec))
661        .map(|(i, _)| i)
662        .collect();
663    let mut buf = Vec::with_capacity(12 + idxs.len() * (4 + ACTIVATION_RECORD_BYTES));
664    buf.extend_from_slice(b"HEB2");
665    buf.extend_from_slice(&(records.len() as u32).to_le_bytes());
666    buf.extend_from_slice(&(idxs.len() as u32).to_le_bytes());
667    for &i in &idxs {
668        buf.extend_from_slice(&(i as u32).to_le_bytes());
669        encode_activation_record(&mut buf, &records[i]);
670    }
671    let tmp_path = output_dir.join("activations.bin.tmp");
672    fs::write(&tmp_path, &buf).map_err(|e| format!("write activations.bin: {}", e))?;
673    fs::rename(&tmp_path, &path).map_err(|e| format!("rename activations.bin: {}", e))?;
674    // The base now contains every delta that was journalled.
675    let _ = fs::remove_file(output_dir.join(DELTA_FILE));
676    Ok(())
677}
678
679/// How many records the journal currently holds.
680fn delta_journal_records(output_dir: &Path) -> usize {
681    match fs::metadata(output_dir.join(DELTA_FILE)) {
682        Ok(m) if m.len() > 8 => ((m.len() - 8) / DELTA_RECORD_BYTES as u64) as usize,
683        _ => 0,
684    }
685}
686
687/// Append the given block indices' records to the journal.
688///
689/// The whole batch goes out with a single append, so a concurrent reader sees
690/// either none of it or all of it. Records that still hold the default value
691/// are skipped: a block that was decayed back to nothing costs nothing to keep.
692fn append_activation_deltas(
693    output_dir: &Path,
694    records: &[ActivationRecord],
695    dirty: &[u32],
696) -> Result<(), String> {
697    let path = output_dir.join(DELTA_FILE);
698    let existing = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
699    let mut buf: Vec<u8> = Vec::with_capacity(8 + dirty.len() * DELTA_RECORD_BYTES);
700    if existing == 0 {
701        buf.extend_from_slice(DELTA_MAGIC);
702        buf.extend_from_slice(&0u32.to_le_bytes()); // record count, advisory
703    }
704    let mut written = 0usize;
705    for &i in dirty {
706        let idx = i as usize;
707        if idx >= records.len() {
708            continue;
709        }
710        let rec = &records[idx];
711        if is_default_record(rec) {
712            continue;
713        }
714        let start = buf.len();
715        buf.extend_from_slice(&i.to_le_bytes());
716        encode_activation_record(&mut buf, rec);
717        let crc = crc32(&buf[start..]);
718        buf.extend_from_slice(&crc.to_le_bytes());
719        written += 1;
720    }
721    if written == 0 && existing > 0 {
722        return Ok(());
723    }
724    let mut f = fs::OpenOptions::new()
725        .create(true)
726        .append(true)
727        .open(&path)
728        .map_err(|e| format!("open activations journal: {}", e))?;
729    f.write_all(&buf)
730        .map_err(|e| format!("write activations journal: {}", e))?;
731    f.flush()
732        .map_err(|e| format!("flush activations journal: {}", e))?;
733    Ok(())
734}
735
736fn load_coactivations(output_dir: &Path) -> HashMap<(u32, u32), CoactivationPair> {
737    let path = output_dir.join("coactivations.bin");
738    let mut map = HashMap::new();
739    if let Ok(data) = fs::read(&path) {
740        if data.len() >= 8 && &data[0..4] == b"COA1" {
741            let pair_count = read_u32(&data, 4) as usize;
742            for i in 0..pair_count {
743                let off = 8 + i * COACTIVATION_RECORD_BYTES;
744                if off + COACTIVATION_RECORD_BYTES > data.len() {
745                    break;
746                }
747                let pair = CoactivationPair {
748                    block_a: read_u32(&data, off),
749                    block_b: read_u32(&data, off + 4),
750                    count: read_u32(&data, off + 8),
751                    last_ts_ms: read_u64(&data, off + 12),
752                };
753                map.insert((pair.block_a, pair.block_b), pair);
754            }
755        }
756    }
757    map
758}
759
760fn save_coactivations(
761    output_dir: &Path,
762    pairs: &HashMap<(u32, u32), CoactivationPair>,
763) -> Result<(), String> {
764    let path = output_dir.join("coactivations.bin");
765    let mut buf = Vec::with_capacity(8 + pairs.len() * COACTIVATION_RECORD_BYTES);
766    buf.extend_from_slice(b"COA1");
767    buf.extend_from_slice(&(pairs.len() as u32).to_le_bytes());
768    for pair in pairs.values() {
769        buf.extend_from_slice(&pair.block_a.to_le_bytes());
770        buf.extend_from_slice(&pair.block_b.to_le_bytes());
771        buf.extend_from_slice(&pair.count.to_le_bytes());
772        buf.extend_from_slice(&pair.last_ts_ms.to_le_bytes());
773    }
774    let tmp_path = output_dir.join("coactivations.bin.tmp");
775    fs::write(&tmp_path, &buf).map_err(|e| format!("write coactivations.bin: {}", e))?;
776    fs::rename(&tmp_path, &path).map_err(|e| format!("rename coactivations.bin: {}", e))
777}
778
779fn load_fingerprints(output_dir: &Path) -> Vec<ActivationFingerprint> {
780    let path = output_dir.join("fingerprints.bin");
781    let mut fingerprints = Vec::new();
782    if let Ok(data) = fs::read(&path) {
783        if data.len() >= 8 && &data[0..4] == b"FPR1" {
784            let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
785            let mut pos = 8;
786            for _ in 0..count {
787                if pos + 18 > data.len() {
788                    break;
789                }
790                let timestamp_ms = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
791                let query_hash = u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap());
792                let activated_count =
793                    u16::from_le_bytes(data[pos + 16..pos + 18].try_into().unwrap()) as usize;
794                pos += 18;
795
796                let mut activations = Vec::with_capacity(activated_count);
797                for _ in 0..activated_count {
798                    if pos + 8 > data.len() {
799                        break;
800                    }
801                    let block_idx = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
802                    let score = f32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
803                    activations.push((block_idx, score));
804                    pos += 8;
805                }
806
807                fingerprints.push(ActivationFingerprint {
808                    timestamp_ms,
809                    query_hash,
810                    activations,
811                });
812            }
813        }
814    }
815    fingerprints
816}
817
818fn save_fingerprints(
819    output_dir: &Path,
820    fingerprints: &[ActivationFingerprint],
821) -> Result<(), String> {
822    let path = output_dir.join("fingerprints.bin");
823    let mut buf: Vec<u8> = Vec::with_capacity(8 + fingerprints.len() * 32);
824    buf.extend_from_slice(b"FPR1");
825    buf.extend_from_slice(&(fingerprints.len() as u32).to_le_bytes());
826    for fp in fingerprints {
827        buf.extend_from_slice(&fp.timestamp_ms.to_le_bytes());
828        buf.extend_from_slice(&fp.query_hash.to_le_bytes());
829        buf.extend_from_slice(&(fp.activations.len() as u16).to_le_bytes());
830        for &(block_idx, score) in &fp.activations {
831            buf.extend_from_slice(&block_idx.to_le_bytes());
832            buf.extend_from_slice(&score.to_le_bytes());
833        }
834    }
835    // Atomic, like every other state file here. `File::create` truncated in
836    // place, so a crash mid-write left a half-written mirror-neuron history
837    // that the next load would silently accept as the whole history.
838    let tmp_path = output_dir.join("fingerprints.bin.tmp");
839    fs::write(&tmp_path, &buf).map_err(|e| format!("write fingerprints.bin: {}", e))?;
840    fs::rename(&tmp_path, &path).map_err(|e| format!("rename fingerprints.bin: {}", e))
841}
842
843// ─── Utilities ──────────────────────────────────────
844
845fn now_epoch_ms() -> u64 {
846    SystemTime::now()
847        .duration_since(UNIX_EPOCH)
848        .unwrap_or_default()
849        .as_millis() as u64
850}
851
852/// Public accessor for mirror neuron module.
853pub fn now_epoch_ms_pub() -> u64 {
854    now_epoch_ms()
855}
856
857fn clamp_drift(v: f32) -> f32 {
858    v.clamp(-DRIFT_MAX, DRIFT_MAX)
859}
860
861/// Hash a query string to u64 (for fingerprint tracking).
862pub fn query_hash(query: &str) -> u64 {
863    let mut h: u64 = 0xcbf29ce484222325;
864    for &b in query.as_bytes() {
865        h = h.wrapping_mul(0x100000001b3) ^ b as u64;
866    }
867    h
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use std::path::PathBuf;
874
875    fn tmp_dir(name: &str) -> PathBuf {
876        let d = std::env::temp_dir().join(name);
877        let _ = fs::remove_dir_all(&d);
878        fs::create_dir_all(&d).unwrap();
879        d
880    }
881
882    /// The activation vector used to be allocated to the full block count on
883    /// every load: 30,962,784 bytes for the 967,587-block evaluation index, to
884    /// hold the 197 records in a 7,104-byte file. It is now a sparse prefix,
885    /// which changes what a missing entry means and is easy to get wrong.
886    ///
887    /// The trap this pins: `ActivationRecord::default()` is all zeros, so
888    /// "absent" and "present but untouched" are indistinguishable by value --
889    /// but not by `Option`, and the vector is read with two fallbacks:
890    ///
891    ///   commands/recall.rs:402  .get(idx).map(|a| a.energy).unwrap_or(0.0)
892    ///   dream.rs:588            .get(i).map(|r| r.energy).unwrap_or(0.0)
893    ///
894    /// Both must stay at 0.0. recall.rs used 0.5, which was unreachable while
895    /// the vector was corpus-sized; under the sparse prefix it would have given
896    /// every never-recalled block a neutral salience, ranking the whole
897    /// untouched corpus above blocks that were activated and then decayed.
898    #[test]
899    fn untouched_activation_is_absent_and_reads_as_zero() {
900        let dir = tmp_dir("hebb_absent_vs_zero");
901        let mut hebb = HebbianState::load_or_init(&dir, 4);
902
903        // A fresh index stores nothing, so nothing is allocated per block.
904        assert!(
905            hebb.activations.is_empty(),
906            "a fresh index must not allocate a record per block, got {}",
907            hebb.activations.len()
908        );
909
910        // Index 3 was never activated: absent, not a zeroed record.
911        let rec = hebb.activations.get(3);
912        assert!(
913            rec.is_none(),
914            "an unstored index must read as None under the sparse prefix"
915        );
916        assert_eq!(rec.map(|a| a.energy).unwrap_or(0.0), 0.0);
917        assert_eq!(rec.map(|r| r.energy).unwrap_or(0.0), 0.0);
918
919        // A stored record is still Some, with its energy intact.
920        hebb.activations.resize(4, ActivationRecord::default());
921        hebb.activations[3].energy = 0.4;
922        assert_eq!(hebb.activations.get(3).map(|a| a.energy), Some(0.4));
923    }
924
925    /// Activating a block for the first time must create its record, not drop
926    /// the activation because the index is past the end of a sparse vector.
927    /// This is what the growth branch in `record_activation` exists to prevent,
928    /// and the failure would be silent: no error, just a block that never learns.
929    #[test]
930    fn first_activation_of_a_high_index_grows_the_vector() {
931        let dir = tmp_dir("hebb_first_activation");
932        let mut hebb = HebbianState::load_or_init(&dir, 10_000);
933        assert!(hebb.activations.is_empty(), "a fresh index starts empty");
934
935        hebb.record_activation(&[(9_000, 0.9)], 0xdead_beef);
936
937        assert!(
938            hebb.activations.len() > 9_000,
939            "the record must be created, len is {}",
940            hebb.activations.len()
941        );
942        assert_eq!(hebb.activations[9_000].activation_count, 1);
943        assert_eq!(hebb.activations[9_000].energy, 1.0);
944    }
945
946    /// A record stored at a high index must survive a load, and the vector must
947    /// still cover it. Guards the other half: sizing the vector to the number of
948    /// stored records would drop index 9,000 in a corpus of 10,000.
949    #[test]
950    fn high_index_record_round_trips() {
951        let dir = tmp_dir("hebb_high_index");
952        let mut hebb = HebbianState::load_or_init(&dir, 10_000);
953        // Three activations, so the round trip has a count to preserve.
954        for _ in 0..3 {
955            hebb.record_activation(&[(9_000, 0.77)], 1);
956        }
957        hebb.activations[9_000].energy = 0.77;
958        hebb.save(&dir).unwrap();
959
960        let back = HebbianState::load_or_init(&dir, 10_000);
961        assert_eq!(back.activations[9_000].energy, 0.77);
962        assert_eq!(back.activations[9_000].activation_count, 3);
963        // Untouched neighbours are still addressable.
964        assert_eq!(back.activations[8_999].energy, 0.0);
965    }
966
967    #[test]
968    fn sparse_base_stores_only_learned_records() {
969        let dir = tmp_dir("mscope_hebb_sparse");
970        let n = 1000;
971        let mut recs = vec![ActivationRecord::default(); n];
972        recs[7].activation_count = 3;
973        recs[7].energy = 1.0;
974        recs[900].activation_count = 1;
975        recs[900].drift_x = 0.05;
976        save_activations(&dir, &recs).unwrap();
977
978        let file = fs::read(dir.join("activations.bin")).unwrap();
979        assert_eq!(&file[0..4], b"HEB2");
980        assert!(
981            file.len() < 200,
982            "sparse base should be a few records, got {} bytes",
983            file.len()
984        );
985        assert!(file.len() < 8 + n * ACTIVATION_RECORD_BYTES);
986
987        let back = load_activations(&dir, n);
988        // Sized to the highest stored index, not to the block count: 901
989        // entries cover this 1,000-block corpus, which is the whole point of
990        // the change. The base file was already sparse on disk; now memory
991        // matches it.
992        assert_eq!(back.len(), 901, "sized to the highest stored index, not n");
993        assert_eq!(back[7].activation_count, 3);
994        assert_eq!(back[7].energy, 1.0);
995        assert_eq!(back[900].drift_x, 0.05);
996        assert!(is_default_record(&back[11]));
997        // `back[901]` no longer exists, and that is the change: the vector
998        // stops at the highest stored index rather than at the block count.
999        let _ = fs::remove_dir_all(&dir);
1000    }
1001
1002    #[test]
1003    fn legacy_dense_base_still_loads() {
1004        let dir = tmp_dir("mscope_hebb_legacy");
1005        let n = 64;
1006        let mut buf = Vec::new();
1007        buf.extend_from_slice(b"HEB1");
1008        buf.extend_from_slice(&(n as u32).to_le_bytes());
1009        for i in 0..n {
1010            let mut rec = ActivationRecord::default();
1011            if i == 5 {
1012                rec.activation_count = 9;
1013                rec.energy = 0.75;
1014            }
1015            encode_activation_record(&mut buf, &rec);
1016        }
1017        fs::write(dir.join("activations.bin"), &buf).unwrap();
1018
1019        let back = load_activations(&dir, n);
1020        assert_eq!(back.len(), n);
1021        assert_eq!(back[5].activation_count, 9);
1022        assert_eq!(back[5].energy, 0.75);
1023        assert!(is_default_record(&back[6]));
1024        let _ = fs::remove_dir_all(&dir);
1025    }
1026
1027    #[test]
1028    fn journaled_update_is_visible_to_the_next_process() {
1029        // The second load must see the first process's learning. This is why the
1030        // write is a journal rather than a buffer flushed at exit: a CLI
1031        // invocation is a whole process and there is no later.
1032        let dir = tmp_dir("mscope_hebb_journal");
1033        let n = 500;
1034        let mut first = HebbianState::load_or_init(&dir, n);
1035        first.record_activation(&[(3, 0.9)], 1);
1036        first.save_dirty(&dir, &[3]).unwrap(); // bootstraps the base
1037
1038        let mut second = HebbianState::load_or_init(&dir, n);
1039        assert_eq!(second.activations[3].activation_count, 1);
1040        second.record_activation(&[(3, 0.8), (4, 0.4)], 2);
1041        second.save_dirty(&dir, &[3, 4]).unwrap(); // journals
1042        assert!(
1043            dir.join(DELTA_FILE).exists(),
1044            "an incremental save must not rewrite the whole base"
1045        );
1046
1047        let third = HebbianState::load_or_init(&dir, n);
1048        assert_eq!(third.activations[3].activation_count, 2);
1049        assert_eq!(third.activations[4].activation_count, 1);
1050        let _ = fs::remove_dir_all(&dir);
1051    }
1052
1053    #[test]
1054    fn torn_journal_tail_is_dropped_and_the_file_is_truncated() {
1055        let dir = tmp_dir("mscope_hebb_torn");
1056        let n = 200;
1057        let mut first = HebbianState::load_or_init(&dir, n);
1058        first.record_activation(&[(1, 1.0)], 1);
1059        first.save_dirty(&dir, &[1]).unwrap();
1060
1061        let mut second = HebbianState::load_or_init(&dir, n);
1062        second.record_activation(&[(2, 1.0)], 2);
1063        second.save_dirty(&dir, &[2]).unwrap();
1064
1065        // A crash mid-append leaves a partial record at the tail.
1066        let jp = dir.join(DELTA_FILE);
1067        let mut data = fs::read(&jp).unwrap();
1068        let good_len = data.len();
1069        data.extend_from_slice(&[0x01, 0x02, 0x03]);
1070        fs::write(&jp, &data).unwrap();
1071
1072        let loaded = HebbianState::load_or_init(&dir, n);
1073        assert_eq!(loaded.activations[1].activation_count, 1);
1074        assert_eq!(
1075            loaded.activations[2].activation_count, 1,
1076            "the record before the tear must be applied"
1077        );
1078        assert_eq!(
1079            fs::metadata(&jp).unwrap().len(),
1080            good_len as u64,
1081            "the unusable tail is truncated so the next append is readable"
1082        );
1083
1084        // A later append must still be seen: the tear cost one record, not the
1085        // journal.
1086        let mut third = loaded.clone();
1087        third.record_activation(&[(3, 1.0)], 3);
1088        third.save_dirty(&dir, &[3]).unwrap();
1089        let after = HebbianState::load_or_init(&dir, n);
1090        assert_eq!(after.activations[3].activation_count, 1);
1091        assert_eq!(after.activations[2].activation_count, 1);
1092        let _ = fs::remove_dir_all(&dir);
1093    }
1094
1095    #[test]
1096    fn journal_is_folded_into_the_base_at_the_threshold() {
1097        let dir = tmp_dir("mscope_hebb_compact");
1098        let n = 64;
1099        // Pre-fill the journal past its bound with valid records.
1100        let mut buf = Vec::new();
1101        buf.extend_from_slice(DELTA_MAGIC);
1102        buf.extend_from_slice(&0u32.to_le_bytes());
1103        for i in 0..JOURNAL_MAX_RECORDS {
1104            let rec = ActivationRecord {
1105                activation_count: 1,
1106                energy: 1.0,
1107                ..Default::default()
1108            };
1109            let start = buf.len();
1110            buf.extend_from_slice(&((i % n) as u32).to_le_bytes());
1111            encode_activation_record(&mut buf, &rec);
1112            let crc = crc32(&buf[start..]);
1113            buf.extend_from_slice(&crc.to_le_bytes());
1114        }
1115        fs::write(dir.join(DELTA_FILE), &buf).unwrap();
1116        assert!(delta_journal_records(&dir) >= JOURNAL_MAX_RECORDS);
1117
1118        // A load folds the journal into memory; the next save must checkpoint.
1119        let state = HebbianState::load_or_init(&dir, n);
1120        state.save_dirty(&dir, &[1]).unwrap();
1121        assert_eq!(
1122            delta_journal_records(&dir),
1123            0,
1124            "the journal is retired when the base is rewritten"
1125        );
1126        let data = fs::read(dir.join("activations.bin")).unwrap();
1127        assert_eq!(&data[0..4], b"HEB2");
1128        let back = load_activations(&dir, n);
1129        // The journal is last-write-wins per index, so 4096 records over 64
1130        // indices must leave exactly those 64 at their latest value -- not 4096
1131        // activations. Compaction must not lose an index.
1132        let learned = back.iter().filter(|r| r.activation_count > 0).count();
1133        assert_eq!(learned, n, "every journalled index must survive compaction");
1134        assert!(back.iter().all(|r| r.activation_count == 1));
1135        let _ = fs::remove_dir_all(&dir);
1136    }
1137
1138    #[test]
1139    fn remap_keeps_activations_across_a_rebuild() {
1140        let dir = tmp_dir("mscope_hebb_remap");
1141        let n = 10;
1142        let mut state = HebbianState::load_or_init(&dir, n);
1143        state.record_activation(&[(2, 1.0), (7, 1.0)], 42);
1144        state.save(&dir).unwrap(); // a rebuild checkpoints in full
1145
1146        // Reverse the layout, as a rebuild that reorders blocks would.
1147        let map: Vec<u32> = (0..n).map(|i| (n - 1 - i) as u32).collect();
1148        let mut state = HebbianState::load_or_init(&dir, n);
1149        state.remap_indexes(&map);
1150        state.save(&dir).unwrap();
1151
1152        let back = HebbianState::load_or_init(&dir, n);
1153        assert_eq!(back.activations[n - 1 - 2].activation_count, 1);
1154        assert_eq!(back.activations[n - 1 - 7].activation_count, 1);
1155        let _ = fs::remove_dir_all(&dir);
1156    }
1157
1158    #[test]
1159    fn test_serialization_sizes() {
1160        // Manual serialization sizes (not struct sizes — repr(C) may add padding)
1161        assert_eq!(ACTIVATION_RECORD_BYTES, 32); // 4+8+4+4+4+4+4
1162        assert_eq!(COACTIVATION_RECORD_BYTES, 20); // 4+4+4+8
1163    }
1164
1165    #[test]
1166    fn test_record_activation() {
1167        let mut state = HebbianState {
1168            activations: vec![ActivationRecord::default(); 10],
1169            coactivations: HashMap::new(),
1170            fingerprints: Vec::new(),
1171        };
1172
1173        state.record_activation(&[(0, 0.5), (3, 0.3), (7, 0.1)], 12345);
1174
1175        assert_eq!(state.activations[0].activation_count, 1);
1176        assert_eq!(state.activations[3].activation_count, 1);
1177        assert_eq!(state.activations[7].activation_count, 1);
1178        assert_eq!(state.activations[1].activation_count, 0);
1179
1180        // 3 pairs: (0,3), (0,7), (3,7)
1181        assert_eq!(state.coactivations.len(), 3);
1182        assert!(state.coactivations.contains_key(&(0, 3)));
1183        assert!(state.coactivations.contains_key(&(0, 7)));
1184        assert!(state.coactivations.contains_key(&(3, 7)));
1185
1186        // Fingerprint stored
1187        assert_eq!(state.fingerprints.len(), 1);
1188        assert_eq!(state.fingerprints[0].query_hash, 12345);
1189        assert_eq!(state.fingerprints[0].activations.len(), 3);
1190    }
1191
1192    #[test]
1193    fn test_repeated_coactivation() {
1194        let mut state = HebbianState {
1195            activations: vec![ActivationRecord::default(); 5],
1196            coactivations: HashMap::new(),
1197            fingerprints: Vec::new(),
1198        };
1199
1200        state.record_activation(&[(1, 0.5), (2, 0.3)], 100);
1201        state.record_activation(&[(1, 0.4), (2, 0.6)], 200);
1202        state.record_activation(&[(1, 0.3), (2, 0.2)], 300);
1203
1204        assert_eq!(state.activations[1].activation_count, 3);
1205        assert_eq!(state.coactivations[&(1, 2)].count, 3);
1206    }
1207
1208    #[test]
1209    fn test_drift_application() {
1210        let mut state = HebbianState {
1211            activations: vec![ActivationRecord::default(); 3],
1212            coactivations: HashMap::new(),
1213            fingerprints: Vec::new(),
1214        };
1215
1216        // Simulate strong co-activation between blocks 0 and 2
1217        for _ in 0..20 {
1218            state.record_activation(&[(0, 0.5), (2, 0.5)], 42);
1219        }
1220
1221        let headers = vec![(0.0, 0.0, 0.0), (0.5, 0.5, 0.5), (1.0, 1.0, 1.0)];
1222        state.apply_drift(&headers);
1223
1224        // Block 0 should drift toward (1,1,1) and block 2 toward (0,0,0)
1225        assert!(state.activations[0].drift_x > 0.0);
1226        assert!(state.activations[0].drift_y > 0.0);
1227        assert!(state.activations[0].drift_z > 0.0);
1228        assert!(state.activations[2].drift_x < 0.0);
1229        assert!(state.activations[2].drift_y < 0.0);
1230        assert!(state.activations[2].drift_z < 0.0);
1231    }
1232
1233    #[test]
1234    fn test_effective_coords() {
1235        let mut state = HebbianState {
1236            activations: vec![ActivationRecord::default(); 2],
1237            coactivations: HashMap::new(),
1238            fingerprints: Vec::new(),
1239        };
1240
1241        state.activations[0].drift_x = 0.05;
1242        state.activations[0].drift_y = -0.03;
1243        state.activations[0].drift_z = 0.01;
1244
1245        let (x, y, z) = state.effective_coords(0, (0.2, 0.3, 0.4));
1246        assert!((x - 0.25).abs() < 0.001);
1247        assert!((y - 0.27).abs() < 0.001);
1248        assert!((z - 0.41).abs() < 0.001);
1249    }
1250
1251    #[test]
1252    fn test_save_load_roundtrip() {
1253        let tmp = tempfile::tempdir().expect("create temp dir");
1254        let dir = tmp.path();
1255
1256        let mut state = HebbianState {
1257            activations: vec![ActivationRecord::default(); 5],
1258            coactivations: HashMap::new(),
1259            fingerprints: Vec::new(),
1260        };
1261
1262        state.record_activation(&[(0, 0.5), (2, 0.3), (4, 0.1)], 999);
1263        state.record_activation(&[(1, 0.8), (3, 0.2)], 888);
1264
1265        state.save(dir).expect("save");
1266
1267        let loaded = HebbianState::load_or_init(dir, 5);
1268        assert_eq!(loaded.activations[0].activation_count, 1);
1269        assert_eq!(loaded.activations[1].activation_count, 1);
1270        assert_eq!(loaded.coactivations.len(), 4); // (0,2), (0,4), (2,4), (1,3)
1271        assert_eq!(loaded.fingerprints.len(), 2);
1272        assert_eq!(loaded.fingerprints[0].query_hash, 999);
1273        assert_eq!(loaded.fingerprints[1].query_hash, 888);
1274    }
1275
1276    #[test]
1277    fn test_clamp_drift() {
1278        assert_eq!(clamp_drift(0.05), 0.05);
1279        assert_eq!(clamp_drift(0.2), DRIFT_MAX);
1280        assert_eq!(clamp_drift(-0.2), -DRIFT_MAX);
1281    }
1282
1283    #[test]
1284    fn test_query_hash_deterministic() {
1285        assert_eq!(query_hash("hello"), query_hash("hello"));
1286        assert_ne!(query_hash("hello"), query_hash("world"));
1287    }
1288
1289    #[test]
1290    fn test_hottest_blocks() {
1291        let mut state = HebbianState {
1292            activations: vec![ActivationRecord::default(); 5],
1293            coactivations: HashMap::new(),
1294            fingerprints: Vec::new(),
1295        };
1296
1297        state.record_activation(&[(0, 1.0), (2, 0.5)], 1);
1298
1299        let hot = state.hottest_blocks(10);
1300        assert!(!hot.is_empty());
1301        // Block 0 and 2 should be hot
1302        assert!(hot.iter().any(|(idx, _)| *idx == 0));
1303        assert!(hot.iter().any(|(idx, _)| *idx == 2));
1304    }
1305
1306    #[test]
1307    fn test_stats() {
1308        let mut state = HebbianState {
1309            activations: vec![ActivationRecord::default(); 10],
1310            coactivations: HashMap::new(),
1311            fingerprints: Vec::new(),
1312        };
1313
1314        state.record_activation(&[(0, 1.0), (5, 0.5)], 42);
1315
1316        let stats = state.stats();
1317        assert_eq!(stats.block_count, 10);
1318        assert_eq!(stats.active_blocks, 2);
1319        assert_eq!(stats.total_activations, 2);
1320        assert_eq!(stats.coactivation_pairs, 1);
1321        assert_eq!(stats.fingerprint_count, 1);
1322    }
1323}
1324// ── MM-001: remap_indexes tests ────────────────────
1325
1326#[test]
1327fn mm001_remap_preserves_activation_energy() {
1328    let mut hebb = HebbianState {
1329        activations: vec![ActivationRecord::default(); 5],
1330        coactivations: HashMap::new(),
1331        fingerprints: Vec::new(),
1332    };
1333    // Block 2 has high energy
1334    hebb.activations[2].energy = 0.95;
1335    hebb.activations[2].activation_count = 42;
1336
1337    // Rebuild maps: old[0]->new[3], old[1]->new[0], old[2]->new[4], old[3]->new[1], old[4]->new[2]
1338    let old_to_new = vec![3u32, 0, 4, 1, 2];
1339    hebb.remap_indexes(&old_to_new);
1340
1341    // Block 2's energy should now be at index 4
1342    assert_eq!(hebb.activations[4].energy, 0.95);
1343    assert_eq!(hebb.activations[4].activation_count, 42);
1344    // Old index 2 should now be empty (default)
1345    assert_eq!(hebb.activations[2].energy, 0.0);
1346}
1347
1348#[test]
1349fn mm001_remap_coactivations_merge_duplicates() {
1350    let mut hebb = HebbianState {
1351        activations: vec![ActivationRecord::default(); 4],
1352        coactivations: HashMap::new(),
1353        fingerprints: Vec::new(),
1354    };
1355    // Two pairs that will merge after remap
1356    hebb.coactivations.insert(
1357        (0, 1),
1358        CoactivationPair {
1359            block_a: 0,
1360            block_b: 1,
1361            count: 3,
1362            last_ts_ms: 100,
1363        },
1364    );
1365    hebb.coactivations.insert(
1366        (2, 3),
1367        CoactivationPair {
1368            block_a: 2,
1369            block_b: 3,
1370            count: 5,
1371            last_ts_ms: 200,
1372        },
1373    );
1374
1375    // old[0]->new[0], old[1]->new[2], old[2]->new[0], old[3]->new[2]
1376    // Both pairs map to (0, 2) — should merge counts
1377    let old_to_new = vec![0u32, 2, 0, 2];
1378    hebb.remap_indexes(&old_to_new);
1379
1380    assert_eq!(hebb.coactivations.len(), 1, "pairs should merge");
1381    let merged = hebb.coactivations.values().next().unwrap();
1382    assert_eq!(merged.count, 8, "counts should be summed");
1383}
1384
1385#[test]
1386fn mm001_remap_drops_out_of_range() {
1387    let mut hebb = HebbianState {
1388        activations: vec![ActivationRecord::default(); 3],
1389        coactivations: HashMap::new(),
1390        fingerprints: Vec::new(),
1391    };
1392    hebb.activations[1].energy = 0.5;
1393
1394    // Remap to a smaller array — old index 2 is out of range
1395    let old_to_new = vec![0u32, 1];
1396    hebb.remap_indexes(&old_to_new);
1397
1398    assert_eq!(hebb.activations.len(), 2);
1399    assert_eq!(hebb.activations[1].energy, 0.5);
1400}
1401
1402#[test]
1403fn mm001_remap_ignores_drop_markers_when_sizing() {
1404    let mut hebb = HebbianState {
1405        activations: vec![ActivationRecord::default(); 3],
1406        coactivations: HashMap::new(),
1407        fingerprints: Vec::new(),
1408    };
1409    hebb.activations[1].energy = 0.5;
1410
1411    // A dropped block (u32::MAX) must NOT size the target vector: the old
1412    // code computed new_len = max(map) + 1 and tried to allocate a
1413    // 2^32-element array whenever any block was dropped.
1414    let old_to_new = vec![0u32, 1, u32::MAX];
1415    hebb.remap_indexes(&old_to_new);
1416
1417    assert_eq!(hebb.activations.len(), 2);
1418    assert_eq!(hebb.activations[1].energy, 0.5);
1419}