Skip to main content

microscope_memory/
dream.rs

1//! Dream Consolidation for Microscope Memory.
2//!
3//! An offline process that replays the day's recall patterns during idle time,
4//! strengthening important pathways and pruning weak ones — analogous to how
5//! biological sleep consolidates memories.
6//!
7//! Binary format: dream_log.bin (DRM1)
8
9use std::fs;
10use std::path::Path;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::hebbian::{ActivationRecord, HebbianState};
14use crate::predictive_cache::PredictiveCache;
15use crate::resonance::ResonanceState;
16use crate::thought_graph::ThoughtGraphState;
17use crate::{emotional_similarity, load_emotion_lookup};
18
19// ─── Constants ──────────────────────────────────────
20
21/// Replay window: consider fingerprints from the last 24h.
22const REPLAY_WINDOW_MS: u64 = 86_400_000;
23
24/// Co-activation pairs seen only this many times AND older than PRUNE_AGE are pruned.
25const COACTIVATION_PRUNE_THRESHOLD: u32 = 1;
26
27/// Prune age: 48h.
28const PRUNE_AGE_MS: u64 = 172_800_000;
29
30/// Activation records with energy below this are pruned (zeroed).
31const ACTIVATION_PRUNE_ENERGY: f32 = 0.001;
32
33/// Dream replay gives partial energy (lighter than real activation).
34const REPLAY_ENERGY: f32 = 0.3;
35
36/// Co-activation pairs seen in 3+ replayed fingerprints get strengthened.
37const STRENGTHEN_MIN_APPEARANCES: usize = 3;
38
39/// Multiplier for strengthened co-activation pairs.
40const STRENGTHEN_MULTIPLIER: f32 = 1.5;
41
42/// Resonance field decay factor during dream.
43const FIELD_DREAM_DECAY: f32 = 0.8;
44
45// ─── Types ──────────────────────────────────────────
46
47/// Record of a single dream consolidation cycle.
48#[derive(Clone, Debug)]
49pub struct DreamCycle {
50    pub timestamp_ms: u64,
51    pub duration_ms: u32,
52    pub replayed_fingerprints: u32,
53    pub strengthened_pairs: u32,
54    pub pruned_pairs: u32,
55    pub pruned_activations: u32,
56    pub consolidated_patterns: u32,
57    pub forgotten_blocks: u32,
58    pub energy_before: f32,
59    pub energy_after: f32,
60}
61
62/// Persistent dream consolidation log.
63pub struct DreamState {
64    pub cycles: Vec<DreamCycle>,
65    pub last_dream_ms: u64,
66}
67
68pub struct DreamStats {
69    pub total_cycles: usize,
70    pub last_dream_ms: u64,
71    pub total_pruned_pairs: u64,
72    pub total_pruned_activations: u64,
73    pub total_strengthened: u64,
74    pub total_replayed: u64,
75    pub total_forgotten_blocks: u64,
76}
77
78// ─── DreamState I/O ─────────────────────────────────
79
80const CYCLE_BYTES: usize = 44; // 8+4+4+4+4+4+4+4+4+4
81
82impl DreamState {
83    pub fn load_or_init(output_dir: &Path) -> Self {
84        let path = output_dir.join("dream_log.bin");
85        if let Ok(data) = fs::read(&path) {
86            if data.len() >= 16 && &data[0..4] == b"DRM1" {
87                let cycle_count = read_u32(&data, 4) as usize;
88                let last_dream_ms = read_u64(&data, 8);
89                let mut cycles = Vec::with_capacity(cycle_count);
90                for i in 0..cycle_count {
91                    let off = 16 + i * CYCLE_BYTES;
92                    if off + CYCLE_BYTES > data.len() {
93                        break;
94                    }
95                    cycles.push(DreamCycle {
96                        timestamp_ms: read_u64(&data, off),
97                        duration_ms: read_u32(&data, off + 8),
98                        replayed_fingerprints: read_u32(&data, off + 12),
99                        strengthened_pairs: read_u32(&data, off + 16),
100                        pruned_pairs: read_u32(&data, off + 20),
101                        pruned_activations: read_u32(&data, off + 24),
102                        consolidated_patterns: read_u32(&data, off + 28),
103                        forgotten_blocks: read_u32(&data, off + 32),
104                        energy_before: read_f32(&data, off + 36),
105                        energy_after: read_f32(&data, off + 40),
106                    });
107                }
108                return Self {
109                    cycles,
110                    last_dream_ms,
111                };
112            }
113        }
114        Self {
115            cycles: Vec::new(),
116            last_dream_ms: 0,
117        }
118    }
119
120    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
121        let path = output_dir.join("dream_log.bin");
122        let mut buf = Vec::with_capacity(16 + self.cycles.len() * CYCLE_BYTES);
123        buf.extend_from_slice(b"DRM1");
124        buf.extend_from_slice(&(self.cycles.len() as u32).to_le_bytes());
125        buf.extend_from_slice(&self.last_dream_ms.to_le_bytes());
126        for c in &self.cycles {
127            buf.extend_from_slice(&c.timestamp_ms.to_le_bytes());
128            buf.extend_from_slice(&c.duration_ms.to_le_bytes());
129            buf.extend_from_slice(&c.replayed_fingerprints.to_le_bytes());
130            buf.extend_from_slice(&c.strengthened_pairs.to_le_bytes());
131            buf.extend_from_slice(&c.pruned_pairs.to_le_bytes());
132            buf.extend_from_slice(&c.pruned_activations.to_le_bytes());
133            buf.extend_from_slice(&c.consolidated_patterns.to_le_bytes());
134            buf.extend_from_slice(&c.forgotten_blocks.to_le_bytes());
135            buf.extend_from_slice(&c.energy_before.to_le_bytes());
136            buf.extend_from_slice(&c.energy_after.to_le_bytes());
137        }
138        let tmp_path = output_dir.join("dream_log.bin.tmp");
139        fs::write(&tmp_path, &buf).map_err(|e| format!("write dream_log.bin: {}", e))?;
140        fs::rename(&tmp_path, &path).map_err(|e| format!("rename dream_log.bin: {}", e))
141    }
142
143    pub fn stats(&self) -> DreamStats {
144        DreamStats {
145            total_cycles: self.cycles.len(),
146            last_dream_ms: self.last_dream_ms,
147            total_pruned_pairs: self.cycles.iter().map(|c| c.pruned_pairs as u64).sum(),
148            total_pruned_activations: self
149                .cycles
150                .iter()
151                .map(|c| c.pruned_activations as u64)
152                .sum(),
153            total_forgotten_blocks: self.cycles.iter().map(|c| c.forgotten_blocks as u64).sum(),
154            total_strengthened: self
155                .cycles
156                .iter()
157                .map(|c| c.strengthened_pairs as u64)
158                .sum(),
159            total_replayed: self
160                .cycles
161                .iter()
162                .map(|c| c.replayed_fingerprints as u64)
163                .sum(),
164        }
165    }
166}
167
168// ─── Dream Consolidation ─────────────────────────────
169
170/// Run a full dream consolidation cycle.
171/// 1. Replay recent fingerprints (partial energy boost)
172/// 2. Strengthen co-activation pairs appearing in 3+ replayed fingerprints
173/// 3. Prune weak co-activation pairs (count=1, older than 48h)
174/// 4. Prune cold activation records (zero energy, zero count)
175/// 5. Detect thought patterns across recent sessions
176/// 6. Decay resonance field
177/// 7. Clean up expired predictive cache entries
178pub fn dream_consolidate(
179    output_dir: &Path,
180    block_count: usize,
181    max_blocks: usize,
182    protect_min_importance: u8,
183) -> Result<DreamCycle, String> {
184    let t0 = now_ms();
185
186    let mut hebb = HebbianState::load_or_init(output_dir, block_count);
187    let mut thought_graph = ThoughtGraphState::load_or_init(output_dir);
188    let mut pred_cache = PredictiveCache::load_or_init(output_dir);
189    let mut resonance = ResonanceState::load_or_init(output_dir);
190
191    // Measure energy before
192    let energy_before: f32 = hebb.activations.iter().map(|r| r.energy).sum();
193
194    // Step 1: Replay recent fingerprints
195    let cutoff = t0.saturating_sub(REPLAY_WINDOW_MS);
196    let recent_fps: Vec<_> = hebb
197        .fingerprints
198        .iter()
199        .filter(|fp| fp.timestamp_ms >= cutoff)
200        .cloned()
201        .collect();
202    let replayed_count = recent_fps.len() as u32;
203
204    // Count how many fingerprints each co-activation pair appears in
205    let mut pair_appearances: std::collections::HashMap<(u32, u32), usize> =
206        std::collections::HashMap::new();
207
208    for fp in &recent_fps {
209        // Replay: partial energy boost
210        for &(block_idx, _score) in &fp.activations {
211            let idx = block_idx as usize;
212            // Grow, or the replay silently skips every block that has never
213            // been activated. The guard used to be `idx < activations.len()`,
214            // which the corpus-sized vector made always true; under a sparse
215            // prefix it drops exactly the blocks a replay exists to strengthen.
216            if idx >= hebb.activations.len() {
217                if idx >= 4_000_000 {
218                    continue; // u32::MAX dropped-block sentinel
219                }
220                hebb.activations
221                    .resize(idx + 1, ActivationRecord::default());
222            }
223            let rec = &mut hebb.activations[idx];
224            // Boost energy, but lighter than real activation
225            rec.energy = (rec.energy + REPLAY_ENERGY).min(1.0);
226        }
227
228        // Track co-activation pair appearances
229        for i in 0..fp.activations.len() {
230            for j in (i + 1)..fp.activations.len() {
231                let a = fp.activations[i].0.min(fp.activations[j].0);
232                let b = fp.activations[i].0.max(fp.activations[j].0);
233                *pair_appearances.entry((a, b)).or_insert(0) += 1;
234            }
235        }
236    }
237
238    // Step 2: Strengthen frequently co-appearing pairs with emotional coherence boost
239    let emotion_lookup = load_emotion_lookup(output_dir);
240    let mut strengthened = 0u32;
241    let mut coherence_boosted = 0u32;
242    let mut _emotion_pruned = 0u32;
243
244    for ((a, b), appearances) in &pair_appearances {
245        if *appearances >= STRENGTHEN_MIN_APPEARANCES {
246            // Check emotional coherence for extra boost
247            let coherence_mult = emotion_lookup
248                .as_ref()
249                .and_then(|lookup| {
250                    lookup(*a as usize).and_then(|ea| {
251                        lookup(*b as usize).map(|eb| {
252                            let sim = emotional_similarity(&ea, &eb);
253                            if sim > 0.4 {
254                                coherence_boosted += 1;
255                                1.0 + sim * 0.5 // up to 1.5x extra boost
256                            } else {
257                                1.0
258                            }
259                        })
260                    })
261                })
262                .unwrap_or(1.0);
263
264            if let Some(pair) = hebb.coactivations.get_mut(&(*a, *b)) {
265                pair.count = (pair.count as f32 * STRENGTHEN_MULTIPLIER * coherence_mult) as u32;
266                strengthened += 1;
267            }
268        } else if let Some(ref lookup) = emotion_lookup {
269            // Emotionally incoherent pairs get extra pruning pressure
270            if let (Some(ea), Some(eb)) = (lookup(*a as usize), lookup(*b as usize)) {
271                let sim = emotional_similarity(&ea, &eb);
272                if sim < 0.1 {
273                    if let Some(pair) = hebb.coactivations.get_mut(&(*a, *b)) {
274                        if pair.count > 1 {
275                            pair.count /= 2;
276                            _emotion_pruned += 1;
277                        }
278                    }
279                }
280            }
281        }
282    }
283
284    // Step 3: Prune weak co-activation pairs
285    let mut pruned_pairs = 0u32;
286    hebb.coactivations.retain(|_, pair| {
287        if pair.count <= COACTIVATION_PRUNE_THRESHOLD && pair.last_ts_ms + PRUNE_AGE_MS < t0 {
288            pruned_pairs += 1;
289            false
290        } else {
291            true
292        }
293    });
294
295    // Step 4: Prune cold activations
296    let mut pruned_activations = 0u32;
297    for rec in &mut hebb.activations {
298        if rec.energy < ACTIVATION_PRUNE_ENERGY && rec.activation_count == 0 {
299            *rec = crate::hebbian::ActivationRecord::default();
300            pruned_activations += 1;
301        }
302    }
303
304    // Step 5: Pattern detection
305    let patterns_before = thought_graph.crystallized_count();
306    thought_graph.detect_patterns();
307    let consolidated_patterns = (thought_graph.crystallized_count() - patterns_before) as u32;
308
309    // Step 6: Decay resonance field
310    resonance.decay_field(FIELD_DREAM_DECAY);
311    resonance.expire_pulses();
312
313    // Step 7: Predictive cache cleanup — remove predictions with very low confidence
314    pred_cache.dream_cleanup();
315
316    // Step 8: Forget old internal thoughts (autonomous mode outputs)
317    let mut forgotten = forget_old_thoughts(output_dir, block_count)?;
318
319    // Step 8b: Size-bounded eviction — drop lowest-scoring blocks when the
320    // index exceeds max_blocks. Blocks with importance >= protect_min_importance
321    // are never evicted.
322    let evicted = evict_over_capacity(output_dir, max_blocks, protect_min_importance)?;
323    if evicted > 0 {
324        println!(
325            "  [EVICT] {} blokk eltávolítva (plafon: {}, maradt: {})",
326            evicted,
327            max_blocks,
328            block_count.saturating_sub(evicted as usize)
329        );
330    }
331    forgotten += evicted;
332
333    // Measure energy after
334    let energy_after: f32 = hebb.activations.iter().map(|r| r.energy).sum();
335
336    // Save everything
337    hebb.save(output_dir)
338        .map_err(|e| format!("save hebbian: {}", e))?;
339    thought_graph
340        .save(output_dir)
341        .map_err(|e| format!("save thought_graph: {}", e))?;
342    pred_cache
343        .save(output_dir)
344        .map_err(|e| format!("save predictive_cache: {}", e))?;
345    resonance
346        .save(output_dir)
347        .map_err(|e| format!("save resonance: {}", e))?;
348
349    let duration_ms = (now_ms() - t0) as u32;
350
351    Ok(DreamCycle {
352        timestamp_ms: t0,
353        duration_ms,
354        replayed_fingerprints: replayed_count,
355        strengthened_pairs: strengthened,
356        pruned_pairs,
357        pruned_activations,
358        consolidated_patterns,
359        forgotten_blocks: forgotten,
360        energy_before,
361        energy_after,
362    })
363}
364
365// ─── Binary helpers ─────────────────────────────────
366
367/// Read `data_offset` and `data_len` for block `idx` out of `microscope.bin`.
368///
369/// BlockHeader is `#[repr(C, packed)]`, so there is no padding: after depth (16)
370/// and layer_id (17), data_offset occupies 18..22 and data_len 22..24. Those are
371/// the same offsets the rest of the format already relies on -- layer_id at 17
372/// and importance at 48 are read by byte index elsewhere in this file.
373///
374/// The reason this helper exists: data.bin is a packed, variable-length file.
375/// build.rs advances it with `stream_position()` and records the real span in
376/// the header, so there is no BLOCK_DATA_SIZE grid to compute an offset from. On
377/// the 5,639,685-block SciFact index the mean block is 5.6 bytes, so deriving
378/// `idx * BLOCK_DATA_SIZE` was wrong by two orders of magnitude and read the
379/// wrong bytes for every block after the first.
380fn block_data_span(headers: &[u8], idx: usize) -> Option<(usize, usize)> {
381    let off = idx * crate::HEADER_SIZE;
382    if off + crate::HEADER_SIZE > headers.len() {
383        return None;
384    }
385    let start = u32::from_le_bytes([
386        headers[off + 18],
387        headers[off + 19],
388        headers[off + 20],
389        headers[off + 21],
390    ]) as usize;
391    let len = u16::from_le_bytes([headers[off + 22], headers[off + 23]]) as usize;
392    Some((start, len))
393}
394
395fn read_u32(b: &[u8], off: usize) -> u32 {
396    u32::from_le_bytes(b[off..off + 4].try_into().unwrap())
397}
398fn read_u64(b: &[u8], off: usize) -> u64 {
399    u64::from_le_bytes(b[off..off + 8].try_into().unwrap())
400}
401fn read_f32(b: &[u8], off: usize) -> f32 {
402    f32::from_le_bytes(b[off..off + 4].try_into().unwrap())
403}
404
405fn now_ms() -> u64 {
406    SystemTime::now()
407        .duration_since(UNIX_EPOCH)
408        .unwrap_or_default()
409        .as_millis() as u64
410}
411
412// ─── Tests ──────────────────────────────────────────
413
414// ─── Forgetting ─────────────────────────────────────────
415/// Forget old internal thoughts (autonomous mode outputs).
416/// Only targets internal layers: short_term(2), associative(3), reflections(6), session(11).
417/// Never touches: identity(0), long_term(1), emotional(4), relational(5),
418/// crypto_chain(7), echo_cache(8), rust_state(9), code(10).
419/// Blocks older than FORGET_AGE_MS with importance < 5 are removed.
420#[allow(dead_code)]
421const FORGET_AGE_MS: u64 = 86_400_000; // 24 hours
422const FORGET_INTERNAL_LAYERS: &[u8] = &[2, 3, 6, 11];
423const FORGET_MIN_IMPORTANCE: u8 = 5;
424
425pub fn forget_old_thoughts(output_dir: &Path, _block_count: usize) -> Result<u32, String> {
426    use crate::{BLOCK_DATA_SIZE, DEPTH_ENTRY_SIZE, HEADER_SIZE, META_HEADER_SIZE};
427    use std::fs;
428
429    let hdr_path = output_dir.join("microscope.bin");
430    let dat_path = output_dir.join("data.bin");
431    let meta_path = output_dir.join("meta.bin");
432
433    if !hdr_path.exists() || !dat_path.exists() || !meta_path.exists() {
434        return Ok(0); // Nothing to do if files don't exist
435    }
436
437    let headers = fs::read(&hdr_path).map_err(|e| format!("read microscope.bin: {}", e))?;
438    let data = fs::read(&dat_path).map_err(|e| format!("read data.bin: {}", e))?;
439    let meta = fs::read(&meta_path).map_err(|e| format!("read meta.bin: {}", e))?;
440
441    let actual_blocks = headers.len() / HEADER_SIZE;
442    if actual_blocks == 0 {
443        return Ok(0);
444    }
445
446    let _t0 = now_ms();
447    let mut keep_indices: Vec<usize> = Vec::with_capacity(actual_blocks);
448    let mut forgotten = 0u32;
449
450    for i in 0..actual_blocks {
451        let off = i * HEADER_SIZE;
452        if off + HEADER_SIZE > headers.len() {
453            break;
454        }
455
456        // Read layer_id (byte 17 in MSC4 header: depth(16) then layer_id(17))
457        let layer_id = headers[off + 17];
458        // Read importance (byte 48 in MSC4 header: after project_id)
459        let importance = headers[off + 48];
460
461        // Check if this is an internal thought that should be forgotten
462        if FORGET_INTERNAL_LAYERS.contains(&layer_id) && importance < FORGET_MIN_IMPORTANCE {
463            // We don't have a direct timestamp in the header, so we estimate
464            // based on block position: older blocks have lower indices in their depth range.
465            // For simplicity, we forget based on layer + importance only.
466            // Old internal thoughts with low importance are always forgotten.
467            forgotten += 1;
468            continue; // Skip this block
469        }
470
471        keep_indices.push(i);
472    }
473
474    if forgotten == 0 {
475        return Ok(0);
476    }
477
478    // Rewrite microscope.bin with only kept headers
479    let mut new_headers = Vec::with_capacity(keep_indices.len() * HEADER_SIZE);
480    let mut new_data = Vec::with_capacity(keep_indices.len() * BLOCK_DATA_SIZE);
481
482    for &idx in &keep_indices {
483        let hdr_off = idx * HEADER_SIZE;
484        new_headers.extend_from_slice(&headers[hdr_off..hdr_off + HEADER_SIZE]);
485
486        // data.bin is a packed, variable-length file: build.rs advances it with
487        // stream_position() and stores the real span in the header's data_offset
488        // and data_len. Addressing it as `idx * BLOCK_DATA_SIZE` reads from the
489        // wrong place for every block past the first, and silently drops the tail
490        // of the last one. On the 5,639,685-block SciFact index the average block
491        // is 5.6 bytes, so the grid assumption was wrong by two orders of
492        // magnitude and compaction corrupted the corpus.
493        if let Some((data_start, data_len)) = block_data_span(&headers, idx) {
494            let data_end = data_start.saturating_add(data_len).min(data.len());
495            if data_start < data_end {
496                new_data.extend_from_slice(&data[data_start..data_end]);
497            }
498        }
499    }
500
501    let hdr_tmp = output_dir.join("microscope.bin.tmp");
502    let dat_tmp = output_dir.join("data.bin.tmp");
503    fs::write(&hdr_tmp, &new_headers).map_err(|e| format!("write microscope.bin: {}", e))?;
504    fs::write(&dat_tmp, &new_data).map_err(|e| format!("write data.bin: {}", e))?;
505    fs::rename(&hdr_tmp, &hdr_path).map_err(|e| format!("rename microscope.bin: {}", e))?;
506    fs::rename(&dat_tmp, &dat_path).map_err(|e| format!("rename data.bin: {}", e))?;
507
508    // Rebuild meta.bin with new block count and depth ranges
509    let n = keep_indices.len();
510    let mut new_meta = Vec::with_capacity(META_HEADER_SIZE + 9 * DEPTH_ENTRY_SIZE);
511
512    // Copy original magic and version (first 8 bytes)
513    if meta.len() >= 8 {
514        new_meta.extend_from_slice(&meta[..8]);
515    } else {
516        new_meta.extend_from_slice(b"MSC4");
517    }
518    // Write new block count (u32 at offset 8)
519    new_meta.extend_from_slice(&(n as u32).to_le_bytes());
520
521    // Compute depth ranges from kept headers
522    let mut depth_counts = [0u32; 9];
523    for &idx in &keep_indices {
524        let off = idx * HEADER_SIZE;
525        let depth = headers[off + 14]; // depth is at byte 14
526        if (depth as usize) < 9 {
527            depth_counts[depth as usize] += 1;
528        }
529    }
530
531    let mut running_start = 0u32;
532    for &count in &depth_counts[..9] {
533        new_meta.extend_from_slice(&running_start.to_le_bytes());
534        new_meta.extend_from_slice(&count.to_le_bytes());
535        running_start += count;
536    }
537
538    // Copy remaining meta data (merkle root, etc.) if available
539    let meta_tail_start = META_HEADER_SIZE + 9 * DEPTH_ENTRY_SIZE;
540    if meta_tail_start < meta.len() {
541        new_meta.extend_from_slice(&meta[meta_tail_start..]);
542    }
543
544    let meta_tmp = output_dir.join("meta.bin.tmp");
545    fs::write(&meta_tmp, &new_meta).map_err(|e| format!("write meta.bin: {}", e))?;
546    fs::rename(&meta_tmp, &meta_path).map_err(|e| format!("rename meta.bin: {}", e))?;
547
548    println!(
549        "  [FORGET] {} belső gondolat elfelejtve ({} blokk maradt)",
550        forgotten, n
551    );
552
553    Ok(forgotten)
554}
555
556/// Evict the lowest-scoring blocks when the index exceeds `max_blocks`.
557///
558/// Approved policy: eviction score = importance × 10 + recall energy − age
559/// penalty, where age is the block position normalized to [0, 1]. Blocks with
560/// importance >= `protect_min_importance` are never evicted; if the cap cannot
561/// be reached without touching protected memory, nothing is evicted. The
562/// embeddings index is rewritten together with the block index so semantic
563/// rows stay aligned.
564pub fn evict_over_capacity(
565    output_dir: &Path,
566    max_blocks: usize,
567    protect_min_importance: u8,
568) -> Result<u32, String> {
569    use crate::{BLOCK_DATA_SIZE, DEPTH_ENTRY_SIZE, HEADER_SIZE, META_HEADER_SIZE};
570
571    let hdr_path = output_dir.join("microscope.bin");
572    let dat_path = output_dir.join("data.bin");
573    let meta_path = output_dir.join("meta.bin");
574    if !hdr_path.exists() || !dat_path.exists() || !meta_path.exists() {
575        return Ok(0);
576    }
577    let headers = fs::read(&hdr_path).map_err(|e| format!("read microscope.bin: {e}"))?;
578    let data = fs::read(&dat_path).map_err(|e| format!("read data.bin: {e}"))?;
579    let meta = fs::read(&meta_path).map_err(|e| format!("read meta.bin: {e}"))?;
580
581    let actual_blocks = headers.len() / HEADER_SIZE;
582    if actual_blocks == 0 || max_blocks == 0 || actual_blocks <= max_blocks {
583        return Ok(0);
584    }
585    let target = actual_blocks - max_blocks;
586
587    // Recall signal: per-block energy from the Hebbian state.
588    let hebb = crate::hebbian::HebbianState::load_or_init(output_dir, actual_blocks);
589
590    let mut scored: Vec<(usize, f32)> = Vec::with_capacity(actual_blocks);
591    for i in 0..actual_blocks {
592        let off = i * HEADER_SIZE;
593        let importance = headers[off + 48]; // byte 48: importance (MSC4 layout)
594        if importance >= protect_min_importance {
595            continue; // protected core memory is never evicted
596        }
597        let energy = hebb.activations.get(i).map(|r| r.energy).unwrap_or(0.0);
598        let age = i as f32 / actual_blocks as f32;
599        scored.push((i, importance as f32 * 10.0 + energy - age * 5.0));
600    }
601    if scored.len() <= target {
602        // Reaching the cap would require evicting protected memory.
603        return Ok(0);
604    }
605
606    scored.sort_by(|a, b| a.1.total_cmp(&b.1)); // lowest score first
607    let victims: std::collections::HashSet<usize> =
608        scored.iter().take(target).map(|(idx, _)| *idx).collect();
609    let keep_indices: Vec<usize> = (0..actual_blocks)
610        .filter(|i| !victims.contains(i))
611        .collect();
612
613    // Rewrite the binary index, keeping only the surviving blocks.
614    let mut new_headers = Vec::with_capacity(keep_indices.len() * HEADER_SIZE);
615    let mut new_data = Vec::with_capacity(keep_indices.len() * BLOCK_DATA_SIZE);
616    for &idx in &keep_indices {
617        let hdr_off = idx * HEADER_SIZE;
618        new_headers.extend_from_slice(&headers[hdr_off..hdr_off + HEADER_SIZE]);
619        // Packed, variable-length data.bin: see block_data_span.
620        if let Some((data_start, data_len)) = block_data_span(&headers, idx) {
621            let data_end = data_start.saturating_add(data_len).min(data.len());
622            if data_start < data_end {
623                new_data.extend_from_slice(&data[data_start..data_end]);
624            }
625        }
626    }
627    let hdr_tmp = output_dir.join("microscope.bin.tmp");
628    let dat_tmp = output_dir.join("data.bin.tmp");
629    fs::write(&hdr_tmp, &new_headers).map_err(|e| format!("write microscope.bin: {e}"))?;
630    fs::write(&dat_tmp, &new_data).map_err(|e| format!("write data.bin: {e}"))?;
631    fs::rename(&hdr_tmp, &hdr_path).map_err(|e| format!("rename microscope.bin: {e}"))?;
632    fs::rename(&dat_tmp, &dat_path).map_err(|e| format!("rename data.bin: {e}"))?;
633
634    // Rebuild meta.bin with the new block count and depth ranges.
635    let n = keep_indices.len();
636    let mut new_meta = Vec::with_capacity(META_HEADER_SIZE + 9 * DEPTH_ENTRY_SIZE);
637    if meta.len() >= 8 {
638        new_meta.extend_from_slice(&meta[..8]);
639    } else {
640        new_meta.extend_from_slice(b"MSC4\x04\0\0\0");
641    }
642    new_meta.extend_from_slice(&(n as u32).to_le_bytes());
643    let mut depth_counts = [0u32; 9];
644    for &idx in &keep_indices {
645        let off = idx * HEADER_SIZE;
646        let depth = headers[off + 14]; // byte 14: depth
647        if (depth as usize) < 9 {
648            depth_counts[depth as usize] += 1;
649        }
650    }
651    let mut running_start = 0u32;
652    for &count in &depth_counts[..9] {
653        new_meta.extend_from_slice(&running_start.to_le_bytes());
654        new_meta.extend_from_slice(&count.to_le_bytes());
655        running_start += count;
656    }
657    let meta_tail_start = META_HEADER_SIZE + 9 * DEPTH_ENTRY_SIZE;
658    if meta_tail_start < meta.len() {
659        new_meta.extend_from_slice(&meta[meta_tail_start..]);
660    }
661    let meta_tmp = output_dir.join("meta.bin.tmp");
662    fs::write(&meta_tmp, &new_meta).map_err(|e| format!("write meta.bin: {e}"))?;
663    fs::rename(&meta_tmp, &meta_path).map_err(|e| format!("rename meta.bin: {e}"))?;
664
665    trim_embeddings(output_dir, &keep_indices)?;
666
667    Ok(target as u32)
668}
669
670/// Drop the evicted rows from embeddings.bin so semantic rows stay aligned
671/// with the compacted block index. A mismatched or missing file is left alone
672/// (the next embedding rebuild regenerates it).
673fn trim_embeddings(output_dir: &Path, keep_indices: &[usize]) -> Result<(), String> {
674    let emb_path = output_dir.join("embeddings.bin");
675    if !emb_path.exists() {
676        return Ok(());
677    }
678    let emb = fs::read(&emb_path).map_err(|e| format!("read embeddings.bin: {e}"))?;
679    if emb.len() < 12 {
680        return Ok(());
681    }
682    let block_count = u32::from_le_bytes(emb[0..4].try_into().unwrap()) as usize;
683    let dim = u32::from_le_bytes(emb[4..8].try_into().unwrap()) as usize;
684    let expected = 12 + block_count * dim * 4;
685    if dim == 0 || block_count == 0 || emb.len() < expected {
686        return Ok(());
687    }
688    let mut out = Vec::with_capacity(12 + keep_indices.len() * dim * 4);
689    out.extend_from_slice(&(keep_indices.len() as u32).to_le_bytes());
690    out.extend_from_slice(&(dim as u32).to_le_bytes());
691    out.extend_from_slice(&emb[8..12]); // max_depth
692    for &idx in keep_indices {
693        let off = 12 + idx * dim * 4;
694        out.extend_from_slice(&emb[off..off + dim * 4]);
695    }
696    let tmp = output_dir.join("embeddings.bin.tmp");
697    fs::write(&tmp, &out).map_err(|e| format!("write embeddings.bin: {e}"))?;
698    fs::rename(&tmp, &emb_path).map_err(|e| format!("rename embeddings.bin: {e}"))?;
699    Ok(())
700}
701
702/// Automatic importance promotion for frequently recalled blocks.
703///
704/// Blocks whose Hebbian recall energy is at least `promote_energy` and which
705/// have been activated at least once get their importance bumped by one,
706/// capped at `protect_min_importance` (the eviction protection floor). The
707/// bump is written into the binary headers and mirrored back into the layer
708/// source entries (via the `(imp=N)` marker) so it survives rebuilds.
709pub fn promote_recalled_blocks(
710    output_dir: &Path,
711    layers_dir: &Path,
712    block_count: usize,
713    promote_energy: f32,
714    protect_min_importance: u8,
715) -> Result<u32, String> {
716    use crate::HEADER_SIZE;
717
718    let hdr_path = output_dir.join("microscope.bin");
719    if !hdr_path.exists() {
720        return Ok(0);
721    }
722    let headers = fs::read(&hdr_path).map_err(|e| format!("read microscope.bin: {e}"))?;
723    let actual = headers.len() / HEADER_SIZE;
724    if actual == 0 || actual != block_count {
725        return Ok(0);
726    }
727    let hebb = crate::hebbian::HebbianState::load_or_init(output_dir, actual);
728
729    let mut bumps: Vec<(usize, u8)> = Vec::new(); // (block index, new importance)
730
731    // Load the evidence ledger for the epistemic gate (C4).
732    let epistemic_gate = output_dir.exists();
733    let ledger = if epistemic_gate {
734        crate::epistemic::EvidenceLedger::load_or_init(output_dir)
735    } else {
736        crate::epistemic::EvidenceLedger {
737            records: std::collections::HashMap::new(),
738        }
739    };
740
741    for i in 0..actual {
742        let imp = headers[i * HEADER_SIZE + 48]; // byte 48: importance (MSC4)
743        if imp >= protect_min_importance {
744            continue; // already at or above the protection floor
745        }
746        let rec = match hebb.activations.get(i) {
747            Some(r) => r,
748            None => continue,
749        };
750        if rec.energy >= promote_energy && rec.activation_count > 0 {
751            // Epistemic gate (C4): Inference/Hypothesis without evidence cannot promote.
752            let flags = headers[i * HEADER_SIZE + 49]; // byte 48+1: flags
753            let class = crate::epistemic::class_of_flags(flags);
754            let block_hash = {
755                // Read the block text from data.bin to compute its content hash.
756                let data_path = output_dir.join("data.bin");
757                if let Ok(data) = fs::read(&data_path) {
758                    // Packed, variable-length data.bin: use the header's own
759                    // span, and the header's data_len rather than scanning for
760                    // a NUL that the packed writer never writes.
761                    if let Some((start, len)) = block_data_span(&headers, i) {
762                        let end = start.saturating_add(len).min(data.len());
763                        if start < end {
764                            let block = &data[start..end];
765                            crate::epistemic::content_hash(String::from_utf8_lossy(block).trim())
766                        } else {
767                            0
768                        }
769                    } else {
770                        0
771                    }
772                } else {
773                    0
774                }
775            };
776            if let Err(_reason) = crate::epistemic::check_promotion_gate(&ledger, class, block_hash)
777            {
778                // Gate blocked: log but do not promote.
779                continue;
780            }
781            bumps.push((i, imp.saturating_add(1).min(protect_min_importance)));
782        }
783    }
784    if bumps.is_empty() {
785        return Ok(0);
786    }
787
788    // Rewrite headers with the bumped importance values (atomic tmp+rename).
789    let mut new_headers = headers.clone();
790    for &(idx, new_imp) in &bumps {
791        new_headers[idx * HEADER_SIZE + 48] = new_imp; // byte 48: importance (MSC4)
792    }
793    let hdr_tmp = output_dir.join("microscope.bin.tmp");
794    fs::write(&hdr_tmp, &new_headers).map_err(|e| format!("write microscope.bin: {e}"))?;
795    fs::rename(&hdr_tmp, &hdr_path).map_err(|e| format!("rename microscope.bin: {e}"))?;
796
797    // Mirror the bumps into the layer source entries so rebuilds keep them.
798    let dat_path = output_dir.join("data.bin");
799    let data = fs::read(&dat_path).map_err(|e| format!("read data.bin: {e}"))?;
800    let mut updated_files: std::collections::HashMap<std::path::PathBuf, String> =
801        std::collections::HashMap::new();
802    for &(idx, new_imp) in &bumps {
803        // Packed, variable-length data.bin: see block_data_span.
804        let Some((start, len)) = block_data_span(&headers, idx) else {
805            continue;
806        };
807        let end = start.saturating_add(len).min(data.len());
808        if start >= end {
809            continue;
810        }
811        let block = &data[start..end];
812        let block_text = String::from_utf8_lossy(block).trim().to_string();
813        if block_text.len() < 8 {
814            continue;
815        }
816        if let Ok(entries) = fs::read_dir(layers_dir) {
817            for file in entries.filter_map(|e| e.ok()) {
818                let path = file.path();
819                if path.extension().and_then(|e| e.to_str()) != Some("txt") {
820                    continue;
821                }
822                let content = updated_files
823                    .entry(path.clone())
824                    .or_insert_with(|| fs::read_to_string(&path).unwrap_or_default());
825                if let Some(next) = bump_entry_by_text_hash(content, &block_text, new_imp) {
826                    *content = next;
827                    break;
828                }
829            }
830        }
831    }
832    for (path, content) in &updated_files {
833        let tmp = path.with_extension("txt.tmp");
834        fs::write(&tmp, content).map_err(|e| format!("write layer file: {e}"))?;
835        fs::rename(&tmp, path).map_err(|e| format!("rename layer file: {e}"))?;
836    }
837
838    Ok(bumps.len() as u32)
839}
840
841/// Replace the `(imp=N)` marker of the layer entry whose text hash exactly
842/// matches the promoted block's text.
843///
844/// Matching is exact hash equality (FNV-1a over the marker-stripped, trimmed
845/// text), never substring matching, so overlapping or duplicated entries cannot
846/// be modified by mistake. At most one entry changes: the first exact match.
847fn bump_entry_by_text_hash(content: &str, block_text: &str, new_imp: u8) -> Option<String> {
848    let needle_hash = crate::fingerprint::fnv1a_hash(block_text.trim().as_bytes());
849    let mut entries: Vec<String> = content.split("\n\n").map(|s| s.to_string()).collect();
850    for entry in &mut entries {
851        let (stripped, _imp) = crate::reader::parse_imp_marker(entry);
852        if crate::fingerprint::fnv1a_hash(stripped.trim().as_bytes()) == needle_hash
853            && entry.starts_with("(imp=")
854        {
855            if let Some(end) = entry.find(')') {
856                *entry = format!("(imp={}){}", new_imp, &entry[end + 1..]);
857                return Some(entries.join("\n\n"));
858            }
859        }
860    }
861    None
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use crate::archetype::ArchetypeState;
868    use crate::hebbian::{ActivationFingerprint, ActivationRecord, CoactivationPair};
869    use crate::predictive_cache::PredictiveCache;
870    use crate::resonance::ResonanceState;
871    use crate::thought_graph::ThoughtGraphState;
872    use std::collections::HashMap;
873
874    fn make_hebb(block_count: usize) -> HebbianState {
875        HebbianState {
876            activations: vec![ActivationRecord::default(); block_count],
877            coactivations: HashMap::new(),
878            fingerprints: Vec::new(),
879        }
880    }
881
882    #[test]
883    fn test_dream_log_roundtrip() {
884        let tmp = tempfile::tempdir().expect("tempdir");
885        let state = DreamState {
886            cycles: vec![
887                DreamCycle {
888                    timestamp_ms: 1000,
889                    duration_ms: 50,
890                    replayed_fingerprints: 10,
891                    strengthened_pairs: 3,
892                    pruned_pairs: 5,
893                    pruned_activations: 2,
894                    consolidated_patterns: 1,
895                    forgotten_blocks: 0,
896                    energy_before: 10.5,
897                    energy_after: 8.2,
898                },
899                DreamCycle {
900                    timestamp_ms: 2000,
901                    duration_ms: 30,
902                    replayed_fingerprints: 8,
903                    strengthened_pairs: 2,
904                    pruned_pairs: 3,
905                    pruned_activations: 1,
906                    consolidated_patterns: 0,
907                    forgotten_blocks: 0,
908                    energy_before: 8.2,
909                    energy_after: 7.0,
910                },
911            ],
912            last_dream_ms: 2000,
913        };
914        state.save(tmp.path()).unwrap();
915        let loaded = DreamState::load_or_init(tmp.path());
916        assert_eq!(loaded.cycles.len(), 2);
917        assert_eq!(loaded.last_dream_ms, 2000);
918        assert_eq!(loaded.cycles[0].replayed_fingerprints, 10);
919        assert_eq!(loaded.cycles[1].pruned_pairs, 3);
920    }
921
922    #[test]
923    fn eviction_respects_importance_protection() {
924        use crate::{BLOCK_DATA_SIZE, HEADER_SIZE};
925        use std::fs;
926
927        let tmp = tempfile::tempdir().expect("tempdir");
928        let dir = tmp.path();
929        let n = 10usize;
930        let mut headers = Vec::new();
931        let mut data = Vec::new();
932        for i in 0..n {
933            let mut h = vec![0u8; HEADER_SIZE];
934            h[17] = 1; // layer_id (byte 17 in MSC4)
935            h[48] = if i < 2 { 8 } else { 5 }; // importance (byte 48 in MSC4)
936            h[16] = (i % 9) as u8; // depth (byte 16 in MSC4)
937            headers.extend_from_slice(&h);
938            data.extend_from_slice(&[b'a'; BLOCK_DATA_SIZE]);
939        }
940        let mut meta = Vec::new();
941        meta.extend_from_slice(b"MSC4\x04\0\0\0");
942        meta.extend_from_slice(&(n as u32).to_le_bytes());
943        for _ in 0..9 {
944            meta.extend_from_slice(&0u32.to_le_bytes());
945            meta.extend_from_slice(&0u32.to_le_bytes());
946        }
947        fs::write(dir.join("microscope.bin"), &headers).unwrap();
948        fs::write(dir.join("data.bin"), &data).unwrap();
949        fs::write(dir.join("meta.bin"), &meta).unwrap();
950
951        let evicted = evict_over_capacity(dir, 4, 8).unwrap();
952        assert_eq!(evicted, 6);
953
954        let new_headers = fs::read(dir.join("microscope.bin")).unwrap();
955        assert_eq!(new_headers.len() / HEADER_SIZE, 4);
956        let mut protected_survivors = 0;
957        let mut min_imp = u8::MAX;
958        for i in 0..4 {
959            let imp = new_headers[i * HEADER_SIZE + 48]; // byte 48: importance (MSC4)
960            if imp >= 8 {
961                protected_survivors += 1;
962            }
963            min_imp = min_imp.min(imp);
964        }
965        assert_eq!(protected_survivors, 2, "both protected blocks survive");
966        assert!(min_imp >= 5, "no protected block is evicted");
967    }
968
969    #[test]
970    fn eviction_is_noop_below_cap_or_without_candidates() {
971        use crate::{BLOCK_DATA_SIZE, HEADER_SIZE};
972        use std::fs;
973
974        let tmp = tempfile::tempdir().expect("tempdir");
975        let dir = tmp.path();
976        let n = 6usize;
977        let mut headers = Vec::new();
978        let mut data = Vec::new();
979        for _ in 0..n {
980            let mut h = vec![0u8; HEADER_SIZE];
981            h[48] = 8; // everything protected (byte 48 in MSC4)
982            headers.extend_from_slice(&h);
983            data.extend_from_slice(&[b'a'; BLOCK_DATA_SIZE]);
984        }
985        fs::write(dir.join("microscope.bin"), &headers).unwrap();
986        fs::write(dir.join("data.bin"), &data).unwrap();
987        fs::write(dir.join("meta.bin"), b"MSC4\x04\0\0\0").unwrap();
988
989        // Below the cap: no-op.
990        assert_eq!(evict_over_capacity(dir, 100, 8).unwrap(), 0);
991        // Cap cannot be reached without evicting protected memory: no-op.
992        assert_eq!(evict_over_capacity(dir, 2, 8).unwrap(), 0);
993        let after = fs::read(dir.join("microscope.bin")).unwrap();
994        assert_eq!(after.len() / HEADER_SIZE, n);
995    }
996
997    #[test]
998    fn promotion_bumps_recalled_blocks_and_mirrors_layer_source() {
999        use crate::hebbian::{ActivationRecord, HebbianState};
1000        use crate::{BLOCK_DATA_SIZE, HEADER_SIZE};
1001        use std::fs;
1002
1003        let tmp = tempfile::tempdir().expect("tempdir");
1004        let dir = tmp.path();
1005        let layer_dir = dir.join("layers");
1006        fs::create_dir_all(&layer_dir).unwrap();
1007        let n = 4usize;
1008
1009        let mut headers = Vec::new();
1010        let mut data = Vec::new();
1011        // A packed, variable-length layout the real build writes: build.rs
1012        // advances data.bin with stream_position() and records the span in the
1013        // header. This fixture used to pad every block out to BLOCK_DATA_SIZE
1014        // with NULs and leave data_offset/data_len at zero, which is the grid
1015        // layout that dream.rs assumed and data.bin never actually had -- so the
1016        // test passed against a format the build does not produce.
1017        for i in 0..n {
1018            let text = format!("emlék szöveg blokk {}", i);
1019            let bytes = text.as_bytes();
1020            let mut h = vec![0u8; HEADER_SIZE];
1021            h[17] = 0; // layer_id (byte 17 in MSC4)
1022            h[48] = 5; // importance (byte 48 in MSC4)
1023            h[18..22].copy_from_slice(&(data.len() as u32).to_le_bytes());
1024            h[22..24].copy_from_slice(&(bytes.len() as u16).to_le_bytes());
1025            headers.extend_from_slice(&h);
1026            // Deliberately pad each block out to BLOCK_DATA_SIZE, so the fixture
1027            // is the grid layout and the offsets in the header are the only
1028            // thing pointing at the real text. Any code that derives the offset
1029            // as `idx * BLOCK_DATA_SIZE` reads the wrong bytes here and the
1030            // promotion does not mirror.
1031            let mut block = bytes.to_vec();
1032            block.resize(BLOCK_DATA_SIZE, 0);
1033            data.extend_from_slice(&block);
1034        }
1035        fs::write(dir.join("microscope.bin"), &headers).unwrap();
1036        fs::write(dir.join("data.bin"), &data).unwrap();
1037        fs::write(dir.join("meta.bin"), b"MSC4\x04\0\0\0").unwrap();
1038        fs::write(
1039            layer_dir.join("long_term.txt"),
1040            "(imp=5) emlék szöveg blokk 0\n\n(imp=5) emlék szöveg blokk 1\n\n\
1041             (imp=5) emlék szöveg blokk 2\n\n(imp=5) emlék szöveg blokk 3",
1042        )
1043        .unwrap();
1044
1045        let mut hebb = HebbianState {
1046            activations: vec![ActivationRecord::default(); n],
1047            coactivations: HashMap::new(),
1048            fingerprints: Vec::new(),
1049        };
1050        hebb.activations[1].energy = 0.8;
1051        hebb.activations[1].activation_count = 3;
1052        hebb.activations[2].energy = 0.9; // high energy but never activated
1053        hebb.save(dir).unwrap();
1054
1055        let promoted = promote_recalled_blocks(dir, &layer_dir, n, 0.35, 8).unwrap();
1056        assert_eq!(promoted, 1);
1057
1058        let new_headers = fs::read(dir.join("microscope.bin")).unwrap();
1059        assert_eq!(new_headers[HEADER_SIZE + 48], 6);
1060        assert_eq!(
1061            new_headers[2 * HEADER_SIZE + 48],
1062            5,
1063            "no activation -> no promotion"
1064        );
1065        assert_eq!(new_headers[48], 5);
1066
1067        let layer = fs::read_to_string(layer_dir.join("long_term.txt")).unwrap();
1068        assert!(
1069            layer.contains("(imp=6) emlék szöveg blokk 1"),
1070            "layer source must mirror the promotion"
1071        );
1072        assert!(layer.contains("(imp=5) emlék szöveg blokk 2"));
1073    }
1074
1075    #[test]
1076    fn bump_entry_matches_exact_text_not_substring() {
1077        let content = "(imp=5) fontos dolog\n\n(imp=5) ez fontos dolog";
1078        let result = bump_entry_by_text_hash(content, "fontos dolog", 7).unwrap();
1079        assert!(result.starts_with("(imp=7) fontos dolog"));
1080        assert!(
1081            result.contains("\n\n(imp=5) ez fontos dolog"),
1082            "overlapping entry must stay untouched"
1083        );
1084    }
1085
1086    #[test]
1087    fn bump_entry_changes_at_most_one_entry() {
1088        let content = "(imp=5) ugyanaz\n\n(imp=5) ugyanaz";
1089        let result = bump_entry_by_text_hash(content, "ugyanaz", 7).unwrap();
1090        assert_eq!(result.matches("(imp=7) ugyanaz").count(), 1);
1091        assert_eq!(result.matches("(imp=5) ugyanaz").count(), 1);
1092    }
1093
1094    #[test]
1095    fn test_dream_strengthens_repeated_coactivations() {
1096        let tmp = tempfile::tempdir().expect("tempdir");
1097        let mut hebb = make_hebb(10);
1098
1099        // Insert a co-activation pair
1100        hebb.coactivations.insert(
1101            (0, 1),
1102            CoactivationPair {
1103                block_a: 0,
1104                block_b: 1,
1105                count: 5,
1106                last_ts_ms: now_ms(),
1107            },
1108        );
1109
1110        // Add 3 fingerprints that co-activate blocks 0 and 1
1111        let now = now_ms();
1112        for i in 0..3 {
1113            hebb.fingerprints.push(ActivationFingerprint {
1114                timestamp_ms: now - i * 1000,
1115                query_hash: 100 + i,
1116                activations: vec![(0, 0.5), (1, 0.3)],
1117            });
1118        }
1119
1120        hebb.save(tmp.path()).unwrap();
1121
1122        // Also need thought_graph, pred_cache, resonance, archetypes
1123        let tg = ThoughtGraphState::load_or_init(tmp.path());
1124        tg.save(tmp.path()).unwrap();
1125        let pc = PredictiveCache::load_or_init(tmp.path());
1126        pc.save(tmp.path()).unwrap();
1127        let res = ResonanceState::load_or_init(tmp.path());
1128        res.save(tmp.path()).unwrap();
1129        let arc = ArchetypeState::load_or_init(tmp.path());
1130        arc.save(tmp.path()).unwrap();
1131
1132        let cycle = dream_consolidate(tmp.path(), 10, 0, 8).unwrap();
1133        assert!(cycle.strengthened_pairs > 0);
1134
1135        // Verify the pair was strengthened
1136        let hebb2 = HebbianState::load_or_init(tmp.path(), 10);
1137        let pair = hebb2.coactivations.get(&(0, 1)).unwrap();
1138        assert!(pair.count > 5); // was 5, should be 5 * 1.5 = 7
1139    }
1140
1141    #[test]
1142    fn test_dream_prunes_weak_pairs() {
1143        let tmp = tempfile::tempdir().expect("tempdir");
1144        let mut hebb = make_hebb(5);
1145
1146        // Old, weak pair
1147        hebb.coactivations.insert(
1148            (0, 1),
1149            CoactivationPair {
1150                block_a: 0,
1151                block_b: 1,
1152                count: 1,
1153                last_ts_ms: 1000, // very old
1154            },
1155        );
1156        // Recent, strong pair
1157        hebb.coactivations.insert(
1158            (2, 3),
1159            CoactivationPair {
1160                block_a: 2,
1161                block_b: 3,
1162                count: 10,
1163                last_ts_ms: now_ms(),
1164            },
1165        );
1166
1167        hebb.save(tmp.path()).unwrap();
1168        ThoughtGraphState::load_or_init(tmp.path())
1169            .save(tmp.path())
1170            .unwrap();
1171        PredictiveCache::load_or_init(tmp.path())
1172            .save(tmp.path())
1173            .unwrap();
1174        ResonanceState::load_or_init(tmp.path())
1175            .save(tmp.path())
1176            .unwrap();
1177        ArchetypeState::load_or_init(tmp.path())
1178            .save(tmp.path())
1179            .unwrap();
1180
1181        let cycle = dream_consolidate(tmp.path(), 5, 0, 8).unwrap();
1182        assert_eq!(cycle.pruned_pairs, 1);
1183
1184        let hebb2 = HebbianState::load_or_init(tmp.path(), 5);
1185        assert!(!hebb2.coactivations.contains_key(&(0, 1))); // pruned
1186        assert!(hebb2.coactivations.contains_key(&(2, 3))); // kept
1187    }
1188
1189    #[test]
1190    fn test_dream_replays_fingerprints() {
1191        let tmp = tempfile::tempdir().expect("tempdir");
1192        let mut hebb = make_hebb(5);
1193
1194        // Block 0 has zero energy
1195        assert_eq!(hebb.activations[0].energy, 0.0);
1196
1197        // Add a recent fingerprint activating block 0
1198        hebb.fingerprints.push(ActivationFingerprint {
1199            timestamp_ms: now_ms() - 1000,
1200            query_hash: 42,
1201            activations: vec![(0, 0.5)],
1202        });
1203
1204        hebb.save(tmp.path()).unwrap();
1205        ThoughtGraphState::load_or_init(tmp.path())
1206            .save(tmp.path())
1207            .unwrap();
1208        PredictiveCache::load_or_init(tmp.path())
1209            .save(tmp.path())
1210            .unwrap();
1211        ResonanceState::load_or_init(tmp.path())
1212            .save(tmp.path())
1213            .unwrap();
1214        ArchetypeState::load_or_init(tmp.path())
1215            .save(tmp.path())
1216            .unwrap();
1217
1218        let cycle = dream_consolidate(tmp.path(), 5, 0, 8).unwrap();
1219        assert_eq!(cycle.replayed_fingerprints, 1);
1220
1221        let hebb2 = HebbianState::load_or_init(tmp.path(), 5);
1222        assert!(hebb2.activations[0].energy >= REPLAY_ENERGY - 0.01);
1223    }
1224
1225    #[test]
1226    fn test_dream_no_fingerprints() {
1227        let tmp = tempfile::tempdir().expect("tempdir");
1228        let hebb = make_hebb(5);
1229        hebb.save(tmp.path()).unwrap();
1230        ThoughtGraphState::load_or_init(tmp.path())
1231            .save(tmp.path())
1232            .unwrap();
1233        PredictiveCache::load_or_init(tmp.path())
1234            .save(tmp.path())
1235            .unwrap();
1236        ResonanceState::load_or_init(tmp.path())
1237            .save(tmp.path())
1238            .unwrap();
1239        ArchetypeState::load_or_init(tmp.path())
1240            .save(tmp.path())
1241            .unwrap();
1242
1243        let cycle = dream_consolidate(tmp.path(), 5, 0, 8).unwrap();
1244        assert_eq!(cycle.replayed_fingerprints, 0);
1245        assert_eq!(cycle.strengthened_pairs, 0);
1246        assert_eq!(cycle.pruned_pairs, 0);
1247    }
1248
1249    #[test]
1250    fn test_dream_stats() {
1251        let state = DreamState {
1252            cycles: vec![
1253                DreamCycle {
1254                    timestamp_ms: 1000,
1255                    duration_ms: 50,
1256                    replayed_fingerprints: 10,
1257                    strengthened_pairs: 3,
1258                    pruned_pairs: 5,
1259                    pruned_activations: 2,
1260                    consolidated_patterns: 1,
1261                    forgotten_blocks: 0,
1262                    energy_before: 10.0,
1263                    energy_after: 8.0,
1264                },
1265                DreamCycle {
1266                    timestamp_ms: 2000,
1267                    duration_ms: 30,
1268                    replayed_fingerprints: 8,
1269                    strengthened_pairs: 2,
1270                    pruned_pairs: 3,
1271                    pruned_activations: 1,
1272                    consolidated_patterns: 0,
1273                    forgotten_blocks: 0,
1274                    energy_before: 8.0,
1275                    energy_after: 7.0,
1276                },
1277            ],
1278            last_dream_ms: 2000,
1279        };
1280        let stats = state.stats();
1281        assert_eq!(stats.total_cycles, 2);
1282        assert_eq!(stats.total_pruned_pairs, 8);
1283        assert_eq!(stats.total_strengthened, 5);
1284        assert_eq!(stats.total_replayed, 18);
1285    }
1286}