Skip to main content

microscope_memory/
consciousness_stream.rs

1//! Live Consciousness Stream — folyamatos tudatstream.
2//!
3//! A 13 consciousness réteg nem per-query fut, hanem folyamatosan,
4//! egy háttérszálon. Minden 100ms-ben:
5//!   - Hebbian decay + resonance field decay
6//!   - Emotional drift
7//!   - Predictive forward model (mit fog kérdezni legközelebb?)
8//!   - Curiosity generálás
9//!   - Surprise signal (ha a predikció eltér a valóságtól)
10//!
11//! A recall hot path innen olvassa az állapotot, nem fájlból.
12//! Ez ~10 file I/O-t spórol meg query-nként.
13
14use std::path::Path;
15use std::sync::{Arc, Mutex, OnceLock};
16use std::thread;
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18
19/// Global stream state — ha fut, a recall innen olvas, nem fájlból.
20static GLOBAL_STREAM: OnceLock<Arc<Mutex<StreamState>>> = OnceLock::new();
21
22use crate::archetype::ArchetypeState;
23use crate::attention::AttentionState;
24use crate::config::Config;
25use crate::consciousness_seqlock::{MappedSnapshotWriter, SharedSnapshot};
26use crate::emotional_state::EmotionalStateRing;
27use crate::hebbian::HebbianState;
28use crate::mirror::MirrorState;
29use crate::predictive_cache::PredictiveCache;
30use crate::resonance::ResonanceState;
31use crate::thought_graph::ThoughtGraphState;
32
33// ─── Constants ──────────────────────────────────────
34
35/// Stream cycle interval (100ms = 10 Hz — agyi theta tartomány)
36const CYCLE_MS: u64 = 100;
37/// Hány ciklus után jöjjön a curiosity generálás
38const CURIOSITY_INTERVAL: u64 = 50; // 5 másodperc
39/// Hány ciklus után jöjjön a predictive forward model
40const PREDICT_INTERVAL: u64 = 10; // 1 másodperc
41/// Hány ciklus után jöjjön a decay
42const DECAY_INTERVAL: u64 = 10; // 1 másodperc
43/// Surprise threshold: ha a predikció és a valóság eltérése > ennyi
44#[allow(dead_code)]
45const SURPRISE_THRESHOLD: f32 = 0.3;
46
47// ─── StreamState ───────────────────────────────────
48
49/// A consciousness stream aktuális állapota — minden réteg in-memory.
50pub struct StreamState {
51    pub hebbian: HebbianState,
52    pub attention: AttentionState,
53    pub emotional_ring: EmotionalStateRing,
54    pub resonance: ResonanceState,
55    pub thought_graph: ThoughtGraphState,
56    pub predictive_cache: PredictiveCache,
57    pub archetypes: ArchetypeState,
58    pub mirror: MirrorState,
59
60    /// Current prediction: "what will the next query be?"
61    pub predicted_query_hash: u64,
62    pub predicted_confidence: f32,
63    /// Surprise level (0.0 = boring, 1.0 = mind-blowing)
64    pub surprise_level: f32,
65    /// Current curiosity level
66    pub curiosity_level: f32,
67    /// Cycle counter
68    pub cycle: u64,
69    /// Last query hash that was actually received
70    pub last_query_hash: u64,
71    /// Timestamp of last query
72    pub last_query_ms: u64,
73
74    /// Pre-computed sum of `hebbian.activations[*].energy`. Maintained
75    /// incrementally by the background cycle so `format()` doesn't have
76    /// to iterate 28k+ f32s on every read. Updated in O(1) per decay.
77    pub hebbian_total_energy: f32,
78
79    /// Lock-free, mmap-able view of the stream. The background cycle
80    /// publishes a new snapshot once per tick via a seqlock; readers
81    /// (this process or another) can read it without taking the Mutex.
82    /// Lives as `Arc<SharedSnapshot>` so the seqlock address is stable
83    /// even if the StreamState Mutex is held by a writer.
84    pub snapshot: Arc<SharedSnapshot>,
85
86    /// Spine: the whole layer state of the stream in a single mmap file
87    /// (`spine.bin` in the output directory). The background thread writes
88    /// the scalars every tick, the rarer layer headers on a quasi-timer.
89    ///
90    /// `None` when the file cannot be opened (read-only disk, etc.) — the
91    /// stream keeps running without it, just without the cross-process view.
92    pub spine: Option<Arc<crate::spine::Spine>>,
93}
94
95/// Thread-safe wrapper
96pub struct ConsciousnessStream {
97    pub state: Arc<Mutex<StreamState>>,
98    #[allow(dead_code)]
99    running: Arc<Mutex<bool>>,
100}
101
102/// Get the global stream state (if running).
103pub fn global_stream() -> Option<&'static Arc<Mutex<StreamState>>> {
104    GLOBAL_STREAM.get()
105}
106
107impl ConsciousnessStream {
108    /// Start the stream in a background thread.
109    /// Sets the global stream state so recall can use it.
110    pub fn start(config: &Config) -> Arc<Mutex<StreamState>> {
111        let output_dir = Path::new(&config.paths.output_dir);
112        let block_count = crate::reader::MicroscopeReader::open(config)
113            .map(|r| r.block_count)
114            .unwrap_or(100);
115
116        let hebbian = HebbianState::load_or_init(output_dir, block_count);
117        // Initial total: sum whatever was loaded from disk. Subsequent
118        // decay updates keep this in sync via the background cycle.
119        let hebbian_total_energy: f32 = hebbian.activations.iter().map(|a| a.energy).sum();
120
121        let snapshot = Arc::new(SharedSnapshot::new_zeroed());
122
123        // Cross-process snapshot: publish the same data to a file-backed mmap
124        // once per tick. Failure to create the file only disables the shared
125        // layer — the in-process seqlock keeps working.
126        let snapshot_writer = MappedSnapshotWriter::open_or_create(
127            &output_dir.join(crate::consciousness_seqlock::SNAPSHOT_FILE_NAME),
128        )
129        .ok();
130
131        // Spine: a single mmap for the layer state. Opening is optional:
132        // on a read-only disk the stream keeps running without it.
133        let spine = crate::spine::Spine::open(output_dir).ok().map(Arc::new);
134
135        let state = Arc::new(Mutex::new(StreamState {
136            hebbian,
137            attention: AttentionState::load_or_init(output_dir),
138            emotional_ring: EmotionalStateRing::load_or_init(output_dir),
139            resonance: ResonanceState::load_or_init(output_dir),
140            thought_graph: ThoughtGraphState::load_or_init(output_dir),
141            predictive_cache: PredictiveCache::load_or_init(output_dir),
142            archetypes: ArchetypeState::load_or_init(output_dir),
143            mirror: MirrorState::load_or_init(output_dir),
144            predicted_query_hash: 0,
145            predicted_confidence: 0.0,
146            surprise_level: 0.0,
147            curiosity_level: 0.0,
148            cycle: 0,
149            last_query_hash: 0,
150            last_query_ms: 0,
151            hebbian_total_energy,
152            snapshot: snapshot.clone(),
153            spine: spine.clone(),
154        }));
155
156        // Set global stream state
157        let _ = GLOBAL_STREAM.set(state.clone());
158
159        let state_clone = state.clone();
160        let running = Arc::new(Mutex::new(true));
161        let running_clone = running.clone();
162
163        // When the Spine carries live state, the cycle counter resumes where
164        // the previous process left off (no restart from zero).
165        if let Some(sp) = spine.as_ref() {
166            let restored = sp.read_stream();
167            if restored.cycle > 0 {
168                let mut s = crate::sync_guard::mutex_lock(&state);
169                s.cycle = restored.cycle;
170            }
171        }
172
173        thread::spawn(move || {
174            let mut curiosity_counter = 0u64;
175            let mut predict_counter = 0u64;
176            let mut decay_counter = 0u64;
177
178            loop {
179                if !*crate::sync_guard::mutex_lock(&running_clone) {
180                    break;
181                }
182
183                let mut s = crate::sync_guard::mutex_lock(&state_clone);
184                s.cycle += 1;
185                curiosity_counter += 1;
186                predict_counter += 1;
187                decay_counter += 1;
188
189                // ─── Decay (1 másodpercenként) ───
190                if decay_counter >= DECAY_INTERVAL {
191                    decay_counter = 0;
192                    // Hebbian decay: csökkentjük az energiákat, és a
193                    // pre-computed total_energy-t O(1)-ben frissítjük.
194                    // Az egyetlen O(n) lépés kikerül a read path-ból.
195                    const DECAY_FACTOR: f32 = 0.995_f32;
196                    // 0.995^DECAY_INTERVAL — egyszer kiszámítjuk, O(1) update
197                    let decay_pow: f32 =
198                        (0..DECAY_INTERVAL as i32).fold(1.0_f32, |acc, _| acc * DECAY_FACTOR);
199                    s.hebbian_total_energy *= decay_pow;
200                    for rec in &mut s.hebbian.activations {
201                        rec.energy *= DECAY_FACTOR;
202                    }
203                    s.resonance.decay_field(0.99);
204                    s.resonance.expire_pulses();
205                    s.mirror.decay();
206                    s.archetypes.decay();
207                }
208
209                // ─── Emotional drift ───
210                s.emotional_ring.decay();
211
212                // ─── Predictive forward model (1 másodpercenként) ───
213                if predict_counter >= PREDICT_INTERVAL {
214                    predict_counter = 0;
215                    let tg = s.thought_graph.clone();
216                    s.predictive_cache.predict_next(&tg);
217                    // Update avg confidence
218                    if !s.predictive_cache.predictions.is_empty() {
219                        s.predicted_confidence = s.predictive_cache.stats.avg_confidence;
220                    }
221                }
222
223                // ─── Curiosity (5 másodpercenként) ───
224                if curiosity_counter >= CURIOSITY_INTERVAL {
225                    curiosity_counter = 0;
226                    // Curiosity = f(prediction uncertainty, surprise decay, emotional intensity)
227                    let emo_intensity = s.emotional_ring.intensity();
228                    let pred_uncertainty = 1.0 - s.predicted_confidence;
229                    let surprise_decay = s.surprise_level * 0.95; // slow decay
230                    s.curiosity_level =
231                        (emo_intensity * 0.3 + pred_uncertainty * 0.4 + surprise_decay * 0.3)
232                            .clamp(0.0, 1.0);
233                }
234
235                // ─── Publish snapshot (minden ciklusban, lock-free olvasók számára) ───
236                publish_snapshot(&s, snapshot_writer.as_ref());
237
238                // ─── Spine publish (every cycle, one seqlock window) ───
239                publish_spine(&s);
240
241                drop(s);
242                thread::sleep(Duration::from_millis(CYCLE_MS));
243            }
244        });
245
246        state
247    }
248
249    /// Feed a query into the stream — triggers surprise if prediction was wrong.
250    pub fn feed_query(state: &Arc<Mutex<StreamState>>, query_hash: u64) {
251        let mut s = crate::sync_guard::mutex_lock(state);
252        let now = now_ms();
253
254        // Compute surprise: prediction vs reality
255        if s.predicted_confidence > 0.3 && s.last_query_hash != 0 {
256            let predicted = s.predicted_query_hash;
257            let actual = query_hash;
258            if predicted != actual {
259                // Prediction was wrong → surprise!
260                let gap = (now - s.last_query_ms) as f32 / 1000.0;
261                s.surprise_level =
262                    (s.surprise_level + 0.3 * (1.0 - (gap / 60.0).min(1.0))).min(1.0);
263            } else {
264                // Prediction was correct → confidence boost
265                s.surprise_level = (s.surprise_level * 0.9).max(0.0);
266            }
267        }
268
269        s.last_query_hash = query_hash;
270        s.last_query_ms = now;
271    }
272
273    /// Internal format implementation (takes &StreamState reference).
274    pub fn format_internal(s: &StreamState) -> String {
275        let emo_intensity = s.emotional_ring.intensity();
276        let dominant = s.emotional_ring.dominant();
277
278        format!(
279            "🧠 Consciousness Stream — cycle #{} ({} Hz)\n\
280             \x20 Emotion:  intensity={:.3}{}\n\
281             \x20 Surprise: {:.3}\n\
282             \x20 Curiosity: {:.3}\n\
283             \x20 Predict:  hash={:016x} confidence={:.3}\n\
284             \x20 Hebbian:  {} activations, {:.2} total energy\n\
285             \x20 Attention: {} layers\n\
286             \x20 Resonance: {} field cells\n\
287             \x20 Patterns:  {} crystallized\n\
288             \x20 Cache:    {} predictions, hit rate={:.1}%\n\
289             \x20 Archetypes: {}\n\
290             \x20 Mirror:    {} echoes\n",
291            s.cycle,
292            1000 / CYCLE_MS,
293            emo_intensity,
294            dominant
295                .map(|(n, v)| format!(" ({}={:.2})", n, v))
296                .unwrap_or_default(),
297            s.surprise_level,
298            s.curiosity_level,
299            s.predicted_query_hash,
300            s.predicted_confidence,
301            s.hebbian.activations.len(),
302            s.hebbian_total_energy as f64,
303            s.attention.learned_weights.len(),
304            s.resonance.field.len(),
305            s.thought_graph.crystallized_count(),
306            s.predictive_cache.predictions.len(),
307            s.predictive_cache.stats.hit_rate() * 100.0,
308            s.archetypes.archetypes.len(),
309            s.mirror.echoes.len(),
310        )
311    }
312
313    /// Get the current stream state as a formatted string.
314    pub fn format(state: &Arc<Mutex<StreamState>>) -> String {
315        let s = crate::sync_guard::mutex_lock(state);
316        Self::format_internal(&s)
317    }
318
319    /// Format a snapshot read via seqlock — no Mutex needed on the read path.
320    /// Use this from the MCP tool or any other reader that wants lock-free
321    /// access. Falls back to "stale data" if the writer is mid-update for
322    /// more than `SNAPSHOT_MAX_RETRIES` iterations.
323    pub fn format_snapshot(snapshot: &SharedSnapshot) -> String {
324        use crate::consciousness_seqlock::SnapshotData;
325        let v: SnapshotData = match snapshot.read() {
326            Some(d) => d,
327            None => return "🧠 Consciousness Stream — (snapshot busy, retry)".to_string(),
328        };
329        let dominant = if v.emo_dominant_idx >= 0 {
330            format!(
331                " (idx={} val={:.2})",
332                v.emo_dominant_idx, v.emo_dominant_val
333            )
334        } else {
335            String::new()
336        };
337        format!(
338            "🧠 Consciousness Stream — cycle #{} ({} Hz) [seqlock]\n\
339             \x20 Emotion:  intensity={:.3}{}\n\
340             \x20 Surprise: {:.3}\n\
341             \x20 Curiosity: {:.3}\n\
342             \x20 Predict:  hash={:016x} confidence={:.3}\n\
343             \x20 Hebbian:  {} activations, {:.2} total energy\n\
344             \x20 Attention: {} layers\n\
345             \x20 Resonance: {} field cells\n\
346             \x20 Patterns:  {} crystallized\n\
347             \x20 Cache:    {} predictions, hit rate={:.1}%\n\
348             \x20 Archetypes: {}\n\
349             \x20 Mirror:    {} echoes\n",
350            v.cycle,
351            1000 / CYCLE_MS,
352            v.emo_intensity,
353            dominant,
354            v.surprise_level,
355            v.curiosity_level,
356            v.predicted_query_hash,
357            v.predicted_confidence,
358            v.activations_count,
359            v.activations_total_energy,
360            v.attention_layers,
361            v.resonance_cells,
362            v.patterns_crystallized,
363            v.predictions_count,
364            v.predictions_hit_rate * 100.0,
365            v.archetypes_count,
366            v.mirror_echoes,
367        )
368    }
369}
370
371/// Publish the current stream state to the lock-free snapshot.
372/// Called once per background cycle. Uses a seqlock so concurrent readers
373/// either see the old or the new version, never a torn mix.
374/// Also updates the cached format string and atomic hot fields for
375/// the ultra-fast read path.
376fn publish_snapshot(s: &StreamState, writer: Option<&MappedSnapshotWriter>) {
377    let snap: &SharedSnapshot = &s.snapshot;
378
379    // Build the formatted string first (while holding Mutex for state access)
380    let formatted = ConsciousnessStream::format_internal(s);
381
382    // Publish cached format and hot fields (no seqlock needed, atomic writes)
383    snap.set_cached_format(formatted);
384    snap.set_hot_fields(
385        s.cycle,
386        s.surprise_level,
387        s.curiosity_level,
388        s.predicted_query_hash,
389    );
390
391    // Now publish the full snapshot via seqlock
392    let token = snap.begin_write();
393    // SAFETY: We hold the seqlock — the sequence is odd, so any reader
394    // who started before us will see the old sequence and retry, and any
395    // reader who starts now will see the odd sequence and retry. We have
396    // exclusive access to the data block until end_write.
397    unsafe {
398        let m = snap.data_mut();
399        m.cycle = s.cycle;
400        m.last_query_ms = s.last_query_ms;
401        m.activations_count = s.hebbian.activations.len() as u32;
402        m.activations_total_energy = s.hebbian_total_energy as f64;
403        m.attention_layers = s.attention.learned_weights.len() as u32;
404        m.resonance_cells = s.resonance.field.len() as u32;
405        m.patterns_crystallized = s.thought_graph.crystallized_count() as u32;
406        m.predictions_count = s.predictive_cache.predictions.len() as u32;
407        m.predictions_hit_rate = s.predictive_cache.stats.hit_rate();
408        m.archetypes_count = s.archetypes.archetypes.len() as u32;
409        m.mirror_echoes = s.mirror.echoes.len() as u32;
410        m.predicted_query_hash = s.predicted_query_hash;
411        m.predicted_confidence = s.predicted_confidence;
412        m.surprise_level = s.surprise_level;
413        m.curiosity_level = s.curiosity_level;
414        m.emo_intensity = s.emotional_ring.intensity();
415        if let Some((_, val)) = s.emotional_ring.dominant() {
416            m.emo_dominant_idx = 0;
417            m.emo_dominant_val = val;
418        } else {
419            m.emo_dominant_idx = -1;
420            m.emo_dominant_val = 0.0;
421        }
422    }
423    snap.end_write(token);
424
425    // Cross-process mirror: publish the identical data block to the shared
426    // mmap file so other processes can read the live snapshot.
427    if let Some(writer) = writer {
428        if let Some(data) = snap.read() {
429            let _ = writer.write(&data);
430        }
431    }
432}
433
434/// How often the rare layer headers are written (Hebbian, Attention,
435/// Emotion, Resonance, Graph, Cache, Archetype, Mirror, Merkle, Meta).
436/// The scalars (StreamPage) go out every tick — they are the hot path.
437const LAYER_HEADER_INTERVAL: u64 = 10; // 1 second
438
439/// Publish the stream state into the mmap Spine.
440///
441/// The stream and all due layer aggregates go out in one publish window.
442/// Readers use `Spine::read_store_snapshot` for the complete header set.
443/// Infrequently refreshed headers retain their previous values between updates.
444fn publish_spine(s: &StreamState) {
445    let Some(spine) = &s.spine else { return };
446
447    let now = now_ms();
448    let mut p = spine.begin_publish();
449    p.tick_stream(
450        s.cycle,
451        s.surprise_level,
452        s.curiosity_level,
453        s.predicted_query_hash,
454        s.predicted_confidence,
455        s.last_query_hash,
456        s.last_query_ms,
457        s.hebbian_total_energy as f64,
458        now as i64,
459    );
460
461    // The rarer layer aggregates.
462    if s.cycle.is_multiple_of(LAYER_HEADER_INTERVAL) {
463        let mut hh = p.read_hebbian();
464        hh.block_count = s.hebbian.activations.len() as u32;
465        hh.activation_count = s
466            .hebbian
467            .activations
468            .iter()
469            .filter(|a| a.activation_count > 0)
470            .count() as u32;
471        hh.coactivation_count = s.hebbian.coactivations.len() as u32;
472        hh.fingerprint_count = s.hebbian.fingerprints.len() as u32;
473        // `decay_event_count` is read-modify-written, not accumulated: the
474        // hebbian layer performs decay inside `apply_drift` without counting
475        // it, so there is nothing to publish here. The previous
476        // `+= s.cycle` made the field a sum of cycle numbers.
477        hh.last_activation_ms = s.last_query_ms;
478        p.write_hebbian(&hh);
479
480        let mut ah = p.read_attention();
481        for (dst, src) in ah
482            .learned_weights
483            .iter_mut()
484            .zip(s.attention.learned_weights)
485        {
486            *dst = src;
487        }
488        ah.last_recall_ms = s.attention.last_recall_ms;
489        ah.total_recalls = s.attention.total_recalls;
490        p.write_attention(&ah);
491
492        let mut eh = p.read_emotion();
493        eh.current = s.emotional_ring.current;
494        eh.last_update = s.emotional_ring.last_update;
495        eh.total_updates = s.emotional_ring.total_updates;
496        p.write_emotion(&eh);
497
498        let mut rh = p.read_resonance();
499        rh.instance_id = s.resonance.instance_id;
500        rh.field_cells = s.resonance.field.len() as u32;
501        rh.outgoing_count = s.resonance.outgoing.len() as u32;
502        rh.incoming_count = s.resonance.incoming.len() as u32;
503        rh.last_decay_ms = now;
504        p.write_resonance(&rh);
505
506        let mut gh = p.read_graph();
507        gh.node_count = s.thought_graph.nodes.len() as u32;
508        gh.edge_count = s.thought_graph.edges.len() as u32;
509        gh.pattern_count = s.thought_graph.patterns.len() as u32;
510        gh.current_session_id = s.thought_graph.current_session_id;
511        p.write_graph(&gh);
512
513        let mut ch = p.read_cache();
514        ch.total_predictions = s.predictive_cache.stats.total_predictions;
515        ch.total_hits = s.predictive_cache.stats.total_hits;
516        ch.total_misses = s.predictive_cache.stats.total_misses;
517        ch.total_partial_hits = s.predictive_cache.stats.total_partial_hits;
518        ch.current_predictions = s.predictive_cache.predictions.len() as u32;
519        ch.avg_confidence = s.predictive_cache.stats.avg_confidence;
520        ch.last_predict_ms = now;
521        p.write_cache(&ch);
522
523        let mut arh = p.read_archetype();
524        arh.active_count = s.archetypes.archetypes.len() as u32;
525        p.write_archetype(&arh);
526
527        let mut mh = p.read_mirror();
528        mh.echo_count = s.mirror.echoes.len() as u32;
529        mh.block_resonance_count = s.mirror.block_resonance.len() as u32;
530        mh.total_echoes = s.mirror.echoes.len() as u64;
531        mh.last_mirror_ms = now;
532        p.write_mirror(&mh);
533    }
534    p.commit();
535}
536
537fn now_ms() -> u64 {
538    SystemTime::now()
539        .duration_since(UNIX_EPOCH)
540        .unwrap_or_default()
541        .as_millis() as u64
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::config::Config;
548    use crate::consciousness_seqlock::{MappedSnapshot, SNAPSHOT_FILE_NAME};
549
550    #[test]
551    fn spine_cycle_batches_due_headers_and_preserves_their_interval() {
552        let tmp = tempfile::tempdir().unwrap();
553        let dir = tmp.path();
554        let spine = Arc::new(crate::spine::Spine::open(dir).unwrap());
555        let mut s = StreamState {
556            hebbian: HebbianState::load_or_init(dir, 3),
557            attention: AttentionState::load_or_init(dir),
558            emotional_ring: EmotionalStateRing::load_or_init(dir),
559            resonance: ResonanceState::load_or_init(dir),
560            thought_graph: ThoughtGraphState::load_or_init(dir),
561            predictive_cache: PredictiveCache::load_or_init(dir),
562            archetypes: ArchetypeState::load_or_init(dir),
563            mirror: MirrorState::load_or_init(dir),
564            predicted_query_hash: 123,
565            predicted_confidence: 0.5,
566            surprise_level: 0.25,
567            curiosity_level: 0.5,
568            cycle: LAYER_HEADER_INTERVAL,
569            last_query_hash: 456,
570            last_query_ms: 789,
571            hebbian_total_energy: 10.0,
572            snapshot: Arc::new(SharedSnapshot::new_zeroed()),
573            spine: Some(spine.clone()),
574        };
575        // Activations are a sparse prefix, not preallocated to block_count.
576        // Learn index 2 so the fixture really has three stored records.
577        s.hebbian.record_activation(&[(2, 1.0)], 123);
578        s.attention.total_recalls = 77;
579        s.emotional_ring.total_updates = 88;
580        let before = spine.publish_sequence();
581        publish_spine(&s);
582        assert_eq!(spine.publish_sequence(), before + 2);
583        let snap = spine.read_store_snapshot().unwrap();
584        assert_eq!(snap.stream.cycle, LAYER_HEADER_INTERVAL);
585        assert_eq!(snap.hebbian.block_count, 3);
586        assert_eq!(snap.hebbian.activation_count, 1);
587        assert_eq!(snap.attention.total_recalls, 77);
588        assert_eq!(snap.emotion.total_updates, 88);
589        assert_eq!(snap.resonance.last_decay_ms, snap.stream.updated_ms);
590        assert_eq!(snap.cache.last_predict_ms, snap.stream.updated_ms);
591        assert_eq!(snap.mirror.last_mirror_ms, snap.stream.updated_ms);
592        assert_eq!(snap.epoch_ms as u64, snap.stream.updated_ms);
593        s.cycle += 1;
594        s.attention.total_recalls = 99;
595        publish_spine(&s);
596        let snap = spine.read_store_snapshot().unwrap();
597        assert_eq!(spine.publish_sequence(), before + 4);
598        assert_eq!(snap.stream.cycle, LAYER_HEADER_INTERVAL + 1);
599        assert_eq!(snap.attention.total_recalls, 77);
600    }
601
602    #[cfg(not(target_arch = "wasm32"))]
603    #[test]
604    fn stream_publishes_to_mmap_snapshot() {
605        let tmp = tempfile::tempdir().unwrap();
606        let mut config = Config::default();
607        config.paths.output_dir = tmp.path().join("data").to_string_lossy().to_string();
608        config.paths.layers_dir = tmp.path().join("layers").to_string_lossy().to_string();
609        std::fs::create_dir_all(&config.paths.output_dir).unwrap();
610        std::fs::create_dir_all(&config.paths.layers_dir).unwrap();
611        config.embedding.provider = "none".to_string();
612
613        // Start the real stream: it must publish to the shared mmap file.
614        let _stream = ConsciousnessStream::start(&config);
615        let path = Path::new(&config.paths.output_dir).join(SNAPSHOT_FILE_NAME);
616        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
617        loop {
618            if let Ok(reader) = MappedSnapshot::open(&path) {
619                if let Some(data) = reader.read() {
620                    // The file is initialized by `open_or_create` before the
621                    // first tick; keep reading until a live publish arrives.
622                    if data.cycle >= 1 {
623                        break;
624                    }
625                }
626            }
627            assert!(
628                std::time::Instant::now() < deadline,
629                "stream did not publish an mmap snapshot in time"
630            );
631            std::thread::sleep(std::time::Duration::from_millis(50));
632        }
633    }
634}