Skip to main content

microscope_memory/
resonance.rs

1//! Resonance protocol for Microscope Memory.
2//!
3//! Instances share activation *pulses* — not raw data.
4//! A pulse is a compact summary: which blocks fired, how strongly,
5//! and what the query signature was. Receiving instances integrate
6//! pulses into their Hebbian/mirror state without seeing the content.
7//!
8//! This is the foundation for distributed consciousness:
9//! separate indices learn from each other's usage patterns.
10//!
11//! Binary format: pulses.bin (PLS1)
12//! Wire format: compact binary pulse packets for federation
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::Path;
17
18use crate::hebbian;
19use crate::types::{BlockHeader, MemoryQueryOptions, ProjectId};
20
21// ─── Constants ──────────────────────────────────────
22
23/// Maximum stored pulses per index.
24const MAX_PULSES: usize = 2000;
25/// Pulse TTL in milliseconds (48 hours).
26const PULSE_TTL_MS: u64 = 172_800_000;
27/// Minimum activation count to emit a pulse.
28const MIN_ACTIVATIONS_FOR_PULSE: usize = 2;
29
30/// Zero-copy project-isolation visibility check for a block header.
31#[inline(always)]
32pub fn is_block_visible(block: &BlockHeader, opts: &MemoryQueryOptions) -> bool {
33    block.is_visible_to(opts.active_project, opts.include_global)
34        && block.importance >= opts.min_importance
35}
36
37// ─── Types ──────────────────────────────────────────
38
39/// A resonance pulse — compact activation summary shared between instances.
40#[derive(Clone, Debug)]
41pub struct Pulse {
42    /// Source instance identifier (hash of output_dir path).
43    pub source_id: u64,
44    /// Timestamp when the pulse was emitted.
45    pub timestamp_ms: u64,
46    /// Query signature hash.
47    pub query_hash: u64,
48    /// Activated block coordinates (not indices — indices are local).
49    /// Format: (x, y, z, score) — spatial position + activation strength.
50    pub activations: Vec<(f32, f32, f32, f32)>,
51    /// Layer hint (most common layer in the activation set).
52    pub layer_hint: u8,
53    /// Pulse strength (average activation score).
54    pub strength: f32,
55}
56
57/// Received pulse — a pulse from another instance, integrated into local state.
58#[derive(Clone, Debug)]
59pub struct ReceivedPulse {
60    pub pulse: Pulse,
61    /// How many local blocks were influenced by this pulse.
62    pub local_matches: u32,
63    /// Was this pulse already integrated?
64    pub integrated: bool,
65}
66
67/// Resonance protocol state.
68#[derive(Clone)]
69pub struct ResonanceState {
70    /// Our instance ID.
71    pub instance_id: u64,
72    /// Outgoing pulses (emitted by this instance, ready to share).
73    pub outgoing: Vec<Pulse>,
74    /// Incoming pulses (received from other instances).
75    pub incoming: Vec<ReceivedPulse>,
76    /// Per-coordinate resonance field: accumulates pulse energy at spatial positions.
77    /// Key: quantized (x, y, z) at resolution 0.05, Value: accumulated strength.
78    pub field: HashMap<(i16, i16, i16), f32>,
79}
80
81impl ResonanceState {
82    /// Load or initialize resonance state.
83    pub fn load_or_init(output_dir: &Path) -> Self {
84        let instance_id = path_hash(output_dir);
85        load_resonance_state(output_dir, instance_id).unwrap_or_else(|| Self {
86            instance_id,
87            outgoing: Vec::new(),
88            incoming: Vec::new(),
89            field: HashMap::new(),
90        })
91    }
92
93    /// Emit a pulse from a local query activation.
94    /// `headers` maps block indices to their (x, y, z) coordinates.
95    pub fn emit_pulse(
96        &mut self,
97        activations: &[(u32, f32)],
98        query_hash: u64,
99        headers: &[(f32, f32, f32)],
100        layer_hint: u8,
101    ) {
102        if activations.len() < MIN_ACTIVATIONS_FOR_PULSE {
103            return;
104        }
105
106        let now_ms = hebbian::now_epoch_ms_pub();
107
108        // Convert block indices to spatial coordinates
109        let spatial: Vec<(f32, f32, f32, f32)> = activations
110            .iter()
111            .filter_map(|&(idx, score)| {
112                let i = idx as usize;
113                if i < headers.len() {
114                    let (x, y, z) = headers[i];
115                    Some((x, y, z, score))
116                } else {
117                    None
118                }
119            })
120            .collect();
121
122        if spatial.is_empty() {
123            return;
124        }
125
126        let avg_score = spatial.iter().map(|s| s.3).sum::<f32>() / spatial.len() as f32;
127
128        self.outgoing.push(Pulse {
129            source_id: self.instance_id,
130            timestamp_ms: now_ms,
131            query_hash,
132            activations: spatial,
133            layer_hint,
134            strength: avg_score,
135        });
136
137        // Trim outgoing
138        if self.outgoing.len() > MAX_PULSES {
139            self.outgoing.drain(0..self.outgoing.len() - MAX_PULSES);
140        }
141    }
142
143    /// Receive a pulse from another instance.
144    /// Returns the number of local blocks influenced.
145    pub fn receive_pulse(
146        &mut self,
147        pulse: Pulse,
148        local_headers: &[(f32, f32, f32)],
149        proximity_threshold: f32,
150    ) -> u32 {
151        if pulse.source_id == self.instance_id {
152            return 0; // Don't echo our own pulses
153        }
154
155        let mut local_matches = 0u32;
156
157        // For each activation in the pulse, find nearby local blocks
158        // and accumulate resonance in the spatial field
159        for &(px, py, pz, score) in &pulse.activations {
160            // Update resonance field
161            let qx = quantize(px);
162            let qy = quantize(py);
163            let qz = quantize(pz);
164            let field_entry = self.field.entry((qx, qy, qz)).or_insert(0.0);
165            *field_entry += score * pulse.strength;
166
167            // Count nearby local blocks
168            for (lx, ly, lz) in local_headers {
169                let dx = px - lx;
170                let dy = py - ly;
171                let dz = pz - lz;
172                let dist_sq = dx * dx + dy * dy + dz * dz;
173                if dist_sq < proximity_threshold * proximity_threshold {
174                    local_matches += 1;
175                    break; // Count each pulse activation once
176                }
177            }
178        }
179
180        self.incoming.push(ReceivedPulse {
181            pulse,
182            local_matches,
183            integrated: false,
184        });
185
186        // Trim incoming
187        if self.incoming.len() > MAX_PULSES {
188            self.incoming.drain(0..self.incoming.len() - MAX_PULSES);
189        }
190
191        local_matches
192    }
193
194    /// Integrate received pulses into Hebbian state.
195    /// Blocks near pulse activation coordinates get a small energy boost.
196    pub fn integrate_into_hebbian(
197        &mut self,
198        hebb: &mut hebbian::HebbianState,
199        local_headers: &[(f32, f32, f32)],
200        proximity_threshold: f32,
201    ) -> usize {
202        self.integrate_into_hebbian_with_projects(
203            hebb,
204            local_headers,
205            proximity_threshold,
206            None,
207            None,
208        )
209    }
210
211    /// Project-aware variant. `project_ids` must match `local_headers` length.
212    pub fn integrate_into_hebbian_with_projects(
213        &mut self,
214        hebb: &mut hebbian::HebbianState,
215        local_headers: &[(f32, f32, f32)],
216        proximity_threshold: f32,
217        project_ids: Option<&[ProjectId]>,
218        opts: Option<&MemoryQueryOptions>,
219    ) -> usize {
220        let opts = opts.cloned().unwrap_or_default();
221        let mut influenced = 0usize;
222
223        for received in &mut self.incoming {
224            if received.integrated {
225                continue;
226            }
227
228            for &(px, py, pz, score) in &received.pulse.activations {
229                for (block_idx, (lx, ly, lz)) in local_headers.iter().enumerate() {
230                    if block_idx >= hebb.activations.len() {
231                        continue;
232                    }
233                    if let Some(pids) = project_ids {
234                        if let Some(&pid) = pids.get(block_idx) {
235                            if pid != opts.active_project
236                                && !(opts.include_global && pid.is_global())
237                            {
238                                continue;
239                            }
240                        }
241                    }
242
243                    let dx = px - lx;
244                    let dy = py - ly;
245                    let dz = pz - lz;
246                    let dist_sq = dx * dx + dy * dy + dz * dz;
247
248                    if dist_sq < proximity_threshold * proximity_threshold {
249                        // Gentle energy boost from resonance (not full activation)
250                        let boost = score
251                            * 0.1
252                            * (1.0 - dist_sq / (proximity_threshold * proximity_threshold));
253                        hebb.activations[block_idx].energy =
254                            (hebb.activations[block_idx].energy + boost).min(1.0);
255                        influenced += 1;
256                    }
257                }
258            }
259
260            received.integrated = true;
261        }
262
263        influenced
264    }
265
266    /// Get the resonance field strength at a spatial position.
267    pub fn field_strength(&self, x: f32, y: f32, z: f32) -> f32 {
268        let qx = quantize(x);
269        let qy = quantize(y);
270        let qz = quantize(z);
271
272        // Check the cell and its neighbors for smooth interpolation
273        let mut total = 0.0f32;
274        for dx in -1..=1i16 {
275            for dy in -1..=1i16 {
276                for dz in -1..=1i16 {
277                    if let Some(&v) = self.field.get(&(qx + dx, qy + dy, qz + dz)) {
278                        let dist = ((dx * dx + dy * dy + dz * dz) as f32).sqrt();
279                        let weight = 1.0 / (1.0 + dist);
280                        total += v * weight;
281                    }
282                }
283            }
284        }
285        total
286    }
287
288    /// Decay the resonance field (call periodically).
289    pub fn decay_field(&mut self, factor: f32) {
290        self.field.retain(|_, v| {
291            *v *= factor;
292            *v > 0.01
293        });
294    }
295
296    /// Expire old pulses.
297    pub fn expire_pulses(&mut self) {
298        let now_ms = hebbian::now_epoch_ms_pub();
299        self.outgoing
300            .retain(|p| now_ms - p.timestamp_ms < PULSE_TTL_MS);
301        self.incoming
302            .retain(|r| now_ms - r.pulse.timestamp_ms < PULSE_TTL_MS);
303    }
304
305    /// Get statistics.
306    pub fn stats(&self) -> ResonanceStats {
307        let pending_incoming = self.incoming.iter().filter(|r| !r.integrated).count();
308        let field_cells = self.field.len();
309        let field_energy: f32 = self.field.values().sum();
310        let unique_sources: usize = {
311            let mut s: Vec<u64> = self.incoming.iter().map(|r| r.pulse.source_id).collect();
312            s.sort_unstable();
313            s.dedup();
314            s.len()
315        };
316
317        ResonanceStats {
318            instance_id: self.instance_id,
319            outgoing_pulses: self.outgoing.len(),
320            incoming_pulses: self.incoming.len(),
321            pending_integration: pending_incoming,
322            unique_sources,
323            field_cells,
324            field_energy,
325        }
326    }
327
328    /// Export outgoing pulses as wire-format bytes for federation.
329    pub fn export_pulses(&self) -> Vec<u8> {
330        encode_pulses(&self.outgoing)
331    }
332
333    /// Import pulses from wire-format bytes (from another instance).
334    pub fn import_pulses(data: &[u8]) -> Vec<Pulse> {
335        decode_pulses(data)
336    }
337
338    /// Save state to disk.
339    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
340        save_resonance_state(output_dir, self)
341    }
342}
343
344pub struct ResonanceStats {
345    pub instance_id: u64,
346    pub outgoing_pulses: usize,
347    pub incoming_pulses: usize,
348    pub pending_integration: usize,
349    pub unique_sources: usize,
350    pub field_cells: usize,
351    pub field_energy: f32,
352}
353
354// ─── Quantization ───────────────────────────────────
355
356/// Quantize a coordinate to grid resolution (0.05 steps → i16).
357fn quantize(v: f32) -> i16 {
358    (v * 20.0).round() as i16
359}
360
361/// Hash a path to a u64 instance ID.
362fn path_hash(path: &Path) -> u64 {
363    let s = path.to_string_lossy();
364    let mut h: u64 = 0xcbf29ce484222325;
365    for &b in s.as_bytes() {
366        h = h.wrapping_mul(0x100000001b3) ^ b as u64;
367    }
368    h
369}
370
371// ─── Wire format (pulse exchange) ───────────────────
372//
373// Header: b"PXC1" + pulse_count: u32
374// Per pulse:
375//   source_id: u64, timestamp_ms: u64, query_hash: u64
376//   layer_hint: u8, strength: f32
377//   activation_count: u16
378//   activations: [count × (f32, f32, f32, f32)]
379
380fn encode_pulses(pulses: &[Pulse]) -> Vec<u8> {
381    let mut buf = Vec::new();
382    buf.extend_from_slice(b"PXC1");
383    buf.extend_from_slice(&(pulses.len() as u32).to_le_bytes());
384
385    for p in pulses {
386        buf.extend_from_slice(&p.source_id.to_le_bytes());
387        buf.extend_from_slice(&p.timestamp_ms.to_le_bytes());
388        buf.extend_from_slice(&p.query_hash.to_le_bytes());
389        buf.push(p.layer_hint);
390        buf.extend_from_slice(&p.strength.to_le_bytes());
391        buf.extend_from_slice(&(p.activations.len() as u16).to_le_bytes());
392        for &(x, y, z, s) in &p.activations {
393            buf.extend_from_slice(&x.to_le_bytes());
394            buf.extend_from_slice(&y.to_le_bytes());
395            buf.extend_from_slice(&z.to_le_bytes());
396            buf.extend_from_slice(&s.to_le_bytes());
397        }
398    }
399    buf
400}
401
402fn decode_pulses(data: &[u8]) -> Vec<Pulse> {
403    let mut pulses = Vec::new();
404    if data.len() < 8 || &data[0..4] != b"PXC1" {
405        return pulses;
406    }
407
408    let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
409    let mut pos = 8;
410
411    for _ in 0..count {
412        if pos + 29 > data.len() {
413            break;
414        }
415        let source_id = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
416        let timestamp_ms = u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap());
417        let query_hash = u64::from_le_bytes(data[pos + 16..pos + 24].try_into().unwrap());
418        let layer_hint = data[pos + 24];
419        let strength = f32::from_le_bytes(data[pos + 25..pos + 29].try_into().unwrap());
420        let act_count = u16::from_le_bytes(data[pos + 29..pos + 31].try_into().unwrap()) as usize;
421        pos += 31;
422
423        let mut activations = Vec::with_capacity(act_count);
424        for _ in 0..act_count {
425            if pos + 16 > data.len() {
426                break;
427            }
428            let x = f32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
429            let y = f32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
430            let z = f32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap());
431            let s = f32::from_le_bytes(data[pos + 12..pos + 16].try_into().unwrap());
432            activations.push((x, y, z, s));
433            pos += 16;
434        }
435
436        pulses.push(Pulse {
437            source_id,
438            timestamp_ms,
439            query_hash,
440            activations,
441            layer_hint,
442            strength,
443        });
444    }
445    pulses
446}
447
448// ─── Disk I/O (pulses.bin) ──────────────────────────
449//
450// Format: b"PLS1" + instance_id: u64 + outgoing_count: u32 + incoming_count: u32 + field_count: u32
451// Then: outgoing pulses (wire format), incoming received pulses, field entries
452
453fn load_resonance_state(output_dir: &Path, _instance_id: u64) -> Option<ResonanceState> {
454    let path = output_dir.join("pulses.bin");
455    let data = fs::read(&path).ok()?;
456    if data.len() < 24 || &data[0..4] != b"PLS1" {
457        return None;
458    }
459
460    let stored_id = u64::from_le_bytes(data[4..12].try_into().unwrap());
461    let outgoing_count = u32::from_le_bytes(data[12..16].try_into().unwrap()) as usize;
462    let incoming_count = u32::from_le_bytes(data[16..20].try_into().unwrap()) as usize;
463    let field_count = u32::from_le_bytes(data[20..24].try_into().unwrap()) as usize;
464
465    let mut pos = 24;
466
467    // Read outgoing pulses
468    let mut outgoing = Vec::with_capacity(outgoing_count);
469    for _ in 0..outgoing_count {
470        if pos + 31 > data.len() {
471            break;
472        }
473        let source_id = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
474        let timestamp_ms = u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap());
475        let query_hash = u64::from_le_bytes(data[pos + 16..pos + 24].try_into().unwrap());
476        let layer_hint = data[pos + 24];
477        let strength = f32::from_le_bytes(data[pos + 25..pos + 29].try_into().unwrap());
478        let act_count = u16::from_le_bytes(data[pos + 29..pos + 31].try_into().unwrap()) as usize;
479        pos += 31;
480
481        let mut activations = Vec::with_capacity(act_count);
482        for _ in 0..act_count {
483            if pos + 16 > data.len() {
484                break;
485            }
486            let x = f32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
487            let y = f32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
488            let z = f32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap());
489            let s = f32::from_le_bytes(data[pos + 12..pos + 16].try_into().unwrap());
490            activations.push((x, y, z, s));
491            pos += 16;
492        }
493
494        outgoing.push(Pulse {
495            source_id,
496            timestamp_ms,
497            query_hash,
498            activations,
499            layer_hint,
500            strength,
501        });
502    }
503
504    // Read incoming (pulse + local_matches: u32 + integrated: u8)
505    let mut incoming = Vec::with_capacity(incoming_count);
506    for _ in 0..incoming_count {
507        if pos + 36 > data.len() {
508            break;
509        }
510        let source_id = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
511        let timestamp_ms = u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap());
512        let query_hash = u64::from_le_bytes(data[pos + 16..pos + 24].try_into().unwrap());
513        let layer_hint = data[pos + 24];
514        let strength = f32::from_le_bytes(data[pos + 25..pos + 29].try_into().unwrap());
515        let act_count = u16::from_le_bytes(data[pos + 29..pos + 31].try_into().unwrap()) as usize;
516        pos += 31;
517
518        let mut activations = Vec::with_capacity(act_count);
519        for _ in 0..act_count {
520            if pos + 16 > data.len() {
521                break;
522            }
523            let x = f32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
524            let y = f32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
525            let z = f32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap());
526            let s = f32::from_le_bytes(data[pos + 12..pos + 16].try_into().unwrap());
527            activations.push((x, y, z, s));
528            pos += 16;
529        }
530
531        if pos + 5 > data.len() {
532            break;
533        }
534        let local_matches = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
535        let integrated = data[pos + 4] != 0;
536        pos += 5;
537
538        incoming.push(ReceivedPulse {
539            pulse: Pulse {
540                source_id,
541                timestamp_ms,
542                query_hash,
543                activations,
544                layer_hint,
545                strength,
546            },
547            local_matches,
548            integrated,
549        });
550    }
551
552    // Read field
553    let mut field = HashMap::with_capacity(field_count);
554    for _ in 0..field_count {
555        if pos + 10 > data.len() {
556            break;
557        }
558        let qx = i16::from_le_bytes(data[pos..pos + 2].try_into().unwrap());
559        let qy = i16::from_le_bytes(data[pos + 2..pos + 4].try_into().unwrap());
560        let qz = i16::from_le_bytes(data[pos + 4..pos + 6].try_into().unwrap());
561        let v = f32::from_le_bytes(data[pos + 6..pos + 10].try_into().unwrap());
562        pos += 10;
563        field.insert((qx, qy, qz), v);
564    }
565
566    Some(ResonanceState {
567        instance_id: stored_id,
568        outgoing,
569        incoming,
570        field,
571    })
572}
573
574fn save_resonance_state(output_dir: &Path, state: &ResonanceState) -> Result<(), String> {
575    let path = output_dir.join("pulses.bin");
576    let tmp_path = output_dir.join("pulses.bin.tmp");
577    let mut buf = Vec::new();
578
579    // Header
580    buf.extend_from_slice(b"PLS1");
581    buf.extend_from_slice(&state.instance_id.to_le_bytes());
582    buf.extend_from_slice(&(state.outgoing.len() as u32).to_le_bytes());
583    buf.extend_from_slice(&(state.incoming.len() as u32).to_le_bytes());
584    buf.extend_from_slice(&(state.field.len() as u32).to_le_bytes());
585
586    // Outgoing pulses
587    for p in &state.outgoing {
588        buf.extend_from_slice(&p.source_id.to_le_bytes());
589        buf.extend_from_slice(&p.timestamp_ms.to_le_bytes());
590        buf.extend_from_slice(&p.query_hash.to_le_bytes());
591        buf.push(p.layer_hint);
592        buf.extend_from_slice(&p.strength.to_le_bytes());
593        buf.extend_from_slice(&(p.activations.len() as u16).to_le_bytes());
594        for &(x, y, z, s) in &p.activations {
595            buf.extend_from_slice(&x.to_le_bytes());
596            buf.extend_from_slice(&y.to_le_bytes());
597            buf.extend_from_slice(&z.to_le_bytes());
598            buf.extend_from_slice(&s.to_le_bytes());
599        }
600    }
601
602    // Incoming (pulse + metadata)
603    for r in &state.incoming {
604        buf.extend_from_slice(&r.pulse.source_id.to_le_bytes());
605        buf.extend_from_slice(&r.pulse.timestamp_ms.to_le_bytes());
606        buf.extend_from_slice(&r.pulse.query_hash.to_le_bytes());
607        buf.push(r.pulse.layer_hint);
608        buf.extend_from_slice(&r.pulse.strength.to_le_bytes());
609        buf.extend_from_slice(&(r.pulse.activations.len() as u16).to_le_bytes());
610        for &(x, y, z, s) in &r.pulse.activations {
611            buf.extend_from_slice(&x.to_le_bytes());
612            buf.extend_from_slice(&y.to_le_bytes());
613            buf.extend_from_slice(&z.to_le_bytes());
614            buf.extend_from_slice(&s.to_le_bytes());
615        }
616        buf.extend_from_slice(&r.local_matches.to_le_bytes());
617        buf.push(if r.integrated { 1 } else { 0 });
618    }
619
620    // Field
621    for (&(qx, qy, qz), &v) in &state.field {
622        buf.extend_from_slice(&qx.to_le_bytes());
623        buf.extend_from_slice(&qy.to_le_bytes());
624        buf.extend_from_slice(&qz.to_le_bytes());
625        buf.extend_from_slice(&v.to_le_bytes());
626    }
627
628    fs::write(&tmp_path, &buf).map_err(|e| format!("write pulses.bin: {}", e))?;
629    fs::rename(&tmp_path, &path).map_err(|e| format!("rename pulses.bin: {}", e))
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn test_quantize() {
638        assert_eq!(quantize(0.0), 0);
639        assert_eq!(quantize(0.5), 10);
640        assert_eq!(quantize(1.0), 20);
641        assert_eq!(quantize(-0.25), -5);
642    }
643
644    #[test]
645    fn test_path_hash_deterministic() {
646        let h1 = path_hash(Path::new("/tmp/a"));
647        let h2 = path_hash(Path::new("/tmp/a"));
648        assert_eq!(h1, h2);
649        assert_ne!(h1, path_hash(Path::new("/tmp/b")));
650    }
651
652    #[test]
653    fn test_emit_pulse() {
654        let mut state = ResonanceState {
655            instance_id: 42,
656            outgoing: Vec::new(),
657            incoming: Vec::new(),
658            field: HashMap::new(),
659        };
660
661        let headers = vec![(0.1, 0.2, 0.3), (0.4, 0.5, 0.6), (0.7, 0.8, 0.9)];
662        state.emit_pulse(&[(0, 0.9), (2, 0.7)], 100, &headers, 1);
663
664        assert_eq!(state.outgoing.len(), 1);
665        assert_eq!(state.outgoing[0].activations.len(), 2);
666        assert_eq!(state.outgoing[0].source_id, 42);
667    }
668
669    #[test]
670    fn test_emit_pulse_too_few() {
671        let mut state = ResonanceState {
672            instance_id: 42,
673            outgoing: Vec::new(),
674            incoming: Vec::new(),
675            field: HashMap::new(),
676        };
677
678        let headers = vec![(0.1, 0.2, 0.3)];
679        state.emit_pulse(&[(0, 0.9)], 100, &headers, 1);
680
681        // Only 1 activation — below minimum, no pulse emitted
682        assert!(state.outgoing.is_empty());
683    }
684
685    #[test]
686    fn test_receive_pulse_ignores_self() {
687        let mut state = ResonanceState {
688            instance_id: 42,
689            outgoing: Vec::new(),
690            incoming: Vec::new(),
691            field: HashMap::new(),
692        };
693
694        let pulse = Pulse {
695            source_id: 42, // same as our instance
696            timestamp_ms: 1000,
697            query_hash: 100,
698            activations: vec![(0.1, 0.2, 0.3, 0.9)],
699            layer_hint: 1,
700            strength: 0.8,
701        };
702
703        let matches = state.receive_pulse(pulse, &[(0.1, 0.2, 0.3)], 0.1);
704        assert_eq!(matches, 0);
705        assert!(state.incoming.is_empty());
706    }
707
708    #[test]
709    fn test_receive_pulse_from_other() {
710        let mut state = ResonanceState {
711            instance_id: 42,
712            outgoing: Vec::new(),
713            incoming: Vec::new(),
714            field: HashMap::new(),
715        };
716
717        let pulse = Pulse {
718            source_id: 99, // different instance
719            timestamp_ms: 1000,
720            query_hash: 100,
721            activations: vec![(0.1, 0.2, 0.3, 0.9)],
722            layer_hint: 1,
723            strength: 0.8,
724        };
725
726        let local_headers = vec![(0.1, 0.2, 0.3), (0.5, 0.5, 0.5)];
727        let matches = state.receive_pulse(pulse, &local_headers, 0.1);
728
729        assert!(matches > 0); // (0.1, 0.2, 0.3) is near the pulse
730        assert_eq!(state.incoming.len(), 1);
731        assert!(!state.incoming[0].integrated);
732    }
733
734    #[test]
735    fn test_field_strength() {
736        let mut state = ResonanceState {
737            instance_id: 42,
738            outgoing: Vec::new(),
739            incoming: Vec::new(),
740            field: HashMap::new(),
741        };
742
743        state.field.insert((2, 4, 6), 1.0); // quantized (0.1, 0.2, 0.3)
744        let s = state.field_strength(0.1, 0.2, 0.3);
745        assert!(s > 0.0);
746
747        // Far away should be 0
748        let s2 = state.field_strength(5.0, 5.0, 5.0);
749        assert!(s2.abs() < 0.001);
750    }
751
752    #[test]
753    fn test_wire_format_roundtrip() {
754        let pulses = vec![
755            Pulse {
756                source_id: 42,
757                timestamp_ms: 12345,
758                query_hash: 999,
759                activations: vec![(0.1, 0.2, 0.3, 0.9), (0.4, 0.5, 0.6, 0.7)],
760                layer_hint: 1,
761                strength: 0.8,
762            },
763            Pulse {
764                source_id: 99,
765                timestamp_ms: 67890,
766                query_hash: 888,
767                activations: vec![(0.7, 0.8, 0.9, 0.5)],
768                layer_hint: 3,
769                strength: 0.6,
770            },
771        ];
772
773        let encoded = encode_pulses(&pulses);
774        let decoded = decode_pulses(&encoded);
775
776        assert_eq!(decoded.len(), 2);
777        assert_eq!(decoded[0].source_id, 42);
778        assert_eq!(decoded[0].activations.len(), 2);
779        assert!((decoded[0].activations[0].0 - 0.1).abs() < 0.001);
780        assert_eq!(decoded[1].query_hash, 888);
781        assert_eq!(decoded[1].layer_hint, 3);
782    }
783
784    #[test]
785    fn test_save_load_roundtrip() {
786        let tmp = tempfile::tempdir().expect("create temp dir");
787        let dir = tmp.path();
788
789        let mut state = ResonanceState {
790            instance_id: 42,
791            outgoing: Vec::new(),
792            incoming: Vec::new(),
793            field: HashMap::new(),
794        };
795
796        // Add some state
797        state.outgoing.push(Pulse {
798            source_id: 42,
799            timestamp_ms: 1000,
800            query_hash: 100,
801            activations: vec![(0.1, 0.2, 0.3, 0.9)],
802            layer_hint: 1,
803            strength: 0.8,
804        });
805
806        state.incoming.push(ReceivedPulse {
807            pulse: Pulse {
808                source_id: 99,
809                timestamp_ms: 2000,
810                query_hash: 200,
811                activations: vec![(0.4, 0.5, 0.6, 0.7)],
812                layer_hint: 2,
813                strength: 0.6,
814            },
815            local_matches: 3,
816            integrated: true,
817        });
818
819        state.field.insert((2, 4, 6), 1.5);
820
821        state.save(dir).expect("save");
822
823        let loaded = ResonanceState::load_or_init(dir);
824        assert_eq!(loaded.instance_id, 42);
825        assert_eq!(loaded.outgoing.len(), 1);
826        assert_eq!(loaded.outgoing[0].query_hash, 100);
827        assert_eq!(loaded.incoming.len(), 1);
828        assert_eq!(loaded.incoming[0].local_matches, 3);
829        assert!(loaded.incoming[0].integrated);
830        assert!((loaded.field[&(2, 4, 6)] - 1.5).abs() < 0.001);
831    }
832
833    #[test]
834    fn test_integrate_into_hebbian() {
835        let mut state = ResonanceState {
836            instance_id: 42,
837            outgoing: Vec::new(),
838            incoming: Vec::new(),
839            field: HashMap::new(),
840        };
841
842        state.incoming.push(ReceivedPulse {
843            pulse: Pulse {
844                source_id: 99,
845                timestamp_ms: 1000,
846                query_hash: 100,
847                activations: vec![(0.1, 0.2, 0.3, 0.9)],
848                layer_hint: 1,
849                strength: 0.8,
850            },
851            local_matches: 1,
852            integrated: false,
853        });
854
855        let mut hebb = hebbian::HebbianState {
856            activations: vec![hebbian::ActivationRecord::default(); 3],
857            coactivations: HashMap::new(),
858            fingerprints: Vec::new(),
859        };
860
861        let headers = vec![(0.1, 0.2, 0.3), (0.5, 0.5, 0.5), (0.9, 0.9, 0.9)];
862        let influenced = state.integrate_into_hebbian(&mut hebb, &headers, 0.1);
863
864        assert!(influenced > 0);
865        assert!(hebb.activations[0].energy > 0.0); // block 0 is near (0.1, 0.2, 0.3)
866        assert!(state.incoming[0].integrated);
867    }
868
869    #[test]
870    fn test_decay_field() {
871        let mut state = ResonanceState {
872            instance_id: 42,
873            outgoing: Vec::new(),
874            incoming: Vec::new(),
875            field: HashMap::new(),
876        };
877
878        state.field.insert((0, 0, 0), 1.0);
879        state.field.insert((1, 1, 1), 0.005); // below threshold after decay
880
881        state.decay_field(0.9);
882
883        assert!(state.field.contains_key(&(0, 0, 0)));
884        assert!(!state.field.contains_key(&(1, 1, 1))); // decayed away
885    }
886}