Skip to main content

microscope_memory/
multimodal.rs

1//! Multi-Modal Memory for Microscope Memory.
2//!
3//! Extends beyond text to store and recall images (perceptual hashes),
4//! audio (spectral fingerprints), and structured data (typed key-value pairs)
5//! within the same spatial coordinate framework.
6//!
7//! Binary format: modalities.bin (MOD1)
8//!
9//! Modalities are stored as a sidecar index — the core BlockHeader (32B mmap'd)
10//! is unchanged. Each entry maps a block_idx to its modality metadata.
11
12use std::collections::HashMap;
13use std::fs;
14use std::path::Path;
15
16// ─── Constants ──────────────────────────────────────
17
18const IMAGE_PAYLOAD_BYTES: usize = 32; // 2+2+8+12+4+4
19const AUDIO_PAYLOAD_BYTES: usize = 32; // 4+2+16+4+4+2
20
21// ─── Modality types ─────────────────────────────────
22
23#[derive(Clone, Debug, PartialEq)]
24pub enum Modality {
25    Text,
26    Image(ImageMeta),
27    Audio(AudioMeta),
28    Structured(StructuredMeta),
29}
30
31impl Modality {
32    pub fn tag(&self) -> u8 {
33        match self {
34            Modality::Text => 0,
35            Modality::Image(_) => 1,
36            Modality::Audio(_) => 2,
37            Modality::Structured(_) => 3,
38        }
39    }
40
41    pub fn name(&self) -> &'static str {
42        match self {
43            Modality::Text => "text",
44            Modality::Image(_) => "image",
45            Modality::Audio(_) => "audio",
46            Modality::Structured(_) => "structured",
47        }
48    }
49}
50
51#[derive(Clone, Debug, PartialEq)]
52pub struct ImageMeta {
53    pub width: u16,
54    pub height: u16,
55    pub phash: [u8; 8],
56    pub color_histogram: [u8; 12],
57    pub content_hash: u32,
58}
59
60#[derive(Clone, Debug, PartialEq)]
61pub struct AudioMeta {
62    pub duration_ms: u32,
63    pub sample_rate: u16,
64    pub spectral_fingerprint: [u8; 16],
65    pub peak_freq: f32,
66    pub bpm_estimate: f32,
67}
68
69#[derive(Clone, Debug, PartialEq)]
70pub struct StructuredMeta {
71    pub fields: Vec<(String, FieldValue)>,
72}
73
74#[derive(Clone, Debug, PartialEq)]
75pub enum FieldValue {
76    Str(String),
77    Int(i64),
78    Float(f64),
79    Bool(bool),
80}
81
82impl FieldValue {
83    pub fn type_tag(&self) -> u8 {
84        match self {
85            FieldValue::Str(_) => 0,
86            FieldValue::Int(_) => 1,
87            FieldValue::Float(_) => 2,
88            FieldValue::Bool(_) => 3,
89        }
90    }
91}
92
93// ─── ModalityIndex ──────────────────────────────────
94
95/// Sidecar index mapping block indices to modality metadata.
96pub struct ModalityIndex {
97    pub entries: HashMap<u32, Modality>,
98}
99
100pub struct ModalityStats {
101    pub total_entries: usize,
102    pub text_count: usize,
103    pub image_count: usize,
104    pub audio_count: usize,
105    pub structured_count: usize,
106}
107
108impl ModalityIndex {
109    pub fn load_or_init(output_dir: &Path) -> Self {
110        let path = output_dir.join("modalities.bin");
111        if let Ok(data) = fs::read(&path) {
112            if data.len() >= 8 && &data[0..4] == b"MOD1" {
113                let entry_count = read_u32(&data, 4) as usize;
114                let mut entries = HashMap::new();
115                let mut pos = 8;
116
117                for _ in 0..entry_count {
118                    if pos + 7 > data.len() {
119                        break;
120                    }
121                    let block_idx = read_u32(&data, pos);
122                    let modality_tag = data[pos + 4];
123                    let payload_len = read_u16(&data, pos + 5) as usize;
124                    pos += 7;
125
126                    if pos + payload_len > data.len() {
127                        break;
128                    }
129
130                    let modality = match modality_tag {
131                        0 => Modality::Text,
132                        1 if payload_len >= IMAGE_PAYLOAD_BYTES => {
133                            if let Some(m) = decode_image_meta(&data[pos..pos + payload_len]) {
134                                Modality::Image(m)
135                            } else {
136                                pos += payload_len;
137                                continue;
138                            }
139                        }
140                        2 if payload_len >= AUDIO_PAYLOAD_BYTES => {
141                            if let Some(m) = decode_audio_meta(&data[pos..pos + payload_len]) {
142                                Modality::Audio(m)
143                            } else {
144                                pos += payload_len;
145                                continue;
146                            }
147                        }
148                        3 => {
149                            if let Some(m) = decode_structured_meta(&data[pos..pos + payload_len]) {
150                                Modality::Structured(m)
151                            } else {
152                                pos += payload_len;
153                                continue;
154                            }
155                        }
156                        _ => {
157                            pos += payload_len;
158                            continue;
159                        }
160                    };
161
162                    entries.insert(block_idx, modality);
163                    pos += payload_len;
164                }
165
166                return Self { entries };
167            }
168        }
169        Self {
170            entries: HashMap::new(),
171        }
172    }
173
174    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
175        let path = output_dir.join("modalities.bin");
176        let mut buf = Vec::new();
177        buf.extend_from_slice(b"MOD1");
178        buf.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
179
180        for (&block_idx, modality) in &self.entries {
181            buf.extend_from_slice(&block_idx.to_le_bytes());
182            buf.push(modality.tag());
183
184            match modality {
185                Modality::Text => {
186                    buf.extend_from_slice(&0u16.to_le_bytes());
187                }
188                Modality::Image(m) => {
189                    buf.extend_from_slice(&(IMAGE_PAYLOAD_BYTES as u16).to_le_bytes());
190                    encode_image_meta(m, &mut buf);
191                }
192                Modality::Audio(m) => {
193                    buf.extend_from_slice(&(AUDIO_PAYLOAD_BYTES as u16).to_le_bytes());
194                    encode_audio_meta(m, &mut buf);
195                }
196                Modality::Structured(m) => {
197                    let payload = encode_structured_meta(m);
198                    buf.extend_from_slice(&(payload.len() as u16).to_le_bytes());
199                    buf.extend_from_slice(&payload);
200                }
201            }
202        }
203
204        let tmp_path = output_dir.join("modalities.bin.tmp");
205        fs::write(&tmp_path, &buf).map_err(|e| format!("write modalities.bin: {}", e))?;
206        fs::rename(&tmp_path, &path).map_err(|e| format!("rename modalities.bin: {}", e))
207    }
208
209    /// Register a block's modality.
210    pub fn register(&mut self, block_idx: u32, modality: Modality) {
211        self.entries.insert(block_idx, modality);
212    }
213
214    /// Get modality for a block.
215    pub fn get(&self, block_idx: u32) -> Option<&Modality> {
216        self.entries.get(&block_idx)
217    }
218
219    /// Search by perceptual hash similarity (hamming distance).
220    /// Returns Vec of (block_idx, hamming_distance), sorted by distance.
221    pub fn search_image_similar(&self, phash: &[u8; 8], max_distance: u32) -> Vec<(u32, u32)> {
222        let mut results: Vec<(u32, u32)> = self
223            .entries
224            .iter()
225            .filter_map(|(&idx, m)| {
226                if let Modality::Image(img) = m {
227                    let dist = hamming_distance(&img.phash, phash);
228                    if dist <= max_distance {
229                        Some((idx, dist))
230                    } else {
231                        None
232                    }
233                } else {
234                    None
235                }
236            })
237            .collect();
238        results.sort_by_key(|&(_, d)| d);
239        results
240    }
241
242    /// Search by spectral fingerprint similarity.
243    /// Returns Vec of (block_idx, similarity_score), sorted by score descending.
244    pub fn search_audio_similar(&self, fingerprint: &[u8; 16], threshold: f32) -> Vec<(u32, f32)> {
245        let mut results: Vec<(u32, f32)> = self
246            .entries
247            .iter()
248            .filter_map(|(&idx, m)| {
249                if let Modality::Audio(aud) = m {
250                    let sim = spectral_similarity(&aud.spectral_fingerprint, fingerprint);
251                    if sim >= threshold {
252                        Some((idx, sim))
253                    } else {
254                        None
255                    }
256                } else {
257                    None
258                }
259            })
260            .collect();
261        results.sort_by(|a, b| b.1.total_cmp(&a.1));
262        results
263    }
264
265    /// Search structured data by field name and value.
266    pub fn search_structured(&self, field: &str, value: &FieldValue) -> Vec<u32> {
267        self.entries
268            .iter()
269            .filter_map(|(&idx, m)| {
270                if let Modality::Structured(s) = m {
271                    if s.fields.iter().any(|(k, v)| k == field && v == value) {
272                        Some(idx)
273                    } else {
274                        None
275                    }
276                } else {
277                    None
278                }
279            })
280            .collect()
281    }
282
283    /// Compute spatial coordinates for a modality.
284    /// Images: from phash, Audio: from spectral features, Structured: from field hashing.
285    pub fn modality_coords(modality: &Modality) -> (f32, f32, f32) {
286        match modality {
287            Modality::Text => (0.0, 0.0, 0.0), // handled by normal content_coords
288            Modality::Image(m) => {
289                // Derive from phash bytes
290                let x = (m.phash[0] as f32 + m.phash[1] as f32) / 510.0 * 0.25;
291                let y = (m.phash[2] as f32 + m.phash[3] as f32) / 510.0 * 0.25;
292                let z = (m.phash[4] as f32 + m.phash[5] as f32) / 510.0 * 0.25;
293                // Offset into associative region
294                (0.3 + x, 0.0 + y, 0.0 + z)
295            }
296            Modality::Audio(m) => {
297                // Derive from spectral features
298                let x = (m.peak_freq / 20000.0).clamp(0.0, 1.0) * 0.25;
299                let y = (m.bpm_estimate / 300.0).clamp(0.0, 1.0) * 0.25;
300                let z = (m.duration_ms as f32 / 600_000.0).clamp(0.0, 1.0) * 0.25;
301                // Offset into echo_cache region
302                (0.0 + x, 0.3 + y, 0.3 + z)
303            }
304            Modality::Structured(m) => {
305                // Hash field names for coordinates
306                let mut h: u64 = 0xcbf29ce484222325;
307                for (k, _) in &m.fields {
308                    for &b in k.as_bytes() {
309                        h = h.wrapping_mul(0x100000001b3) ^ b as u64;
310                    }
311                }
312                let x = ((h & 0xFFFF) as f32 / 65535.0) * 0.25;
313                let y = (((h >> 16) & 0xFFFF) as f32 / 65535.0) * 0.25;
314                let z = (((h >> 32) & 0xFFFF) as f32 / 65535.0) * 0.25;
315                // Offset into rust_state region
316                (0.15 + x, 0.0 + y, 0.15 + z)
317            }
318        }
319    }
320
321    pub fn stats(&self) -> ModalityStats {
322        let mut text = 0;
323        let mut image = 0;
324        let mut audio = 0;
325        let mut structured = 0;
326        for m in self.entries.values() {
327            match m {
328                Modality::Text => text += 1,
329                Modality::Image(_) => image += 1,
330                Modality::Audio(_) => audio += 1,
331                Modality::Structured(_) => structured += 1,
332            }
333        }
334        ModalityStats {
335            total_entries: self.entries.len(),
336            text_count: text,
337            image_count: image,
338            audio_count: audio,
339            structured_count: structured,
340        }
341    }
342}
343
344// ─── Perceptual hashing ─────────────────────────────
345
346/// Compute perceptual hash (dHash-like) from grayscale pixel data.
347/// Expects row-major grayscale pixels.
348pub fn compute_phash(pixels: &[u8], width: u32, height: u32) -> [u8; 8] {
349    // Downsample to 9x8 grid and compute differences
350    let mut hash = [0u8; 8];
351    if width < 2 || height < 2 || pixels.len() < (width * height) as usize {
352        return hash;
353    }
354
355    let mut bit_idx = 0;
356    for row in 0..8u32 {
357        let src_y = (row * height / 8).min(height - 1);
358        for col in 0..8u32 {
359            let src_x1 = (col * width / 8).min(width - 1);
360            let src_x2 = ((col + 1) * width / 8).min(width - 1);
361            let p1 = pixels[(src_y * width + src_x1) as usize];
362            let p2 = pixels[(src_y * width + src_x2) as usize];
363            if p1 > p2 {
364                hash[bit_idx / 8] |= 1 << (bit_idx % 8);
365            }
366            bit_idx += 1;
367        }
368    }
369    hash
370}
371
372/// Simple spectral fingerprint from audio samples.
373/// Divides into 16 frequency bands and computes energy per band.
374pub fn compute_spectral_fingerprint(samples: &[f32], _sample_rate: u32) -> [u8; 16] {
375    let mut fingerprint = [0u8; 16];
376    if samples.is_empty() {
377        return fingerprint;
378    }
379
380    let band_size = (samples.len() / 16).max(1);
381    for (i, fp_byte) in fingerprint.iter_mut().enumerate() {
382        let start = i * band_size;
383        let end = ((i + 1) * band_size).min(samples.len());
384        let energy: f32 =
385            samples[start..end].iter().map(|s| s * s).sum::<f32>() / (end - start) as f32;
386        *fp_byte = (energy.sqrt() * 255.0).clamp(0.0, 255.0) as u8;
387    }
388    fingerprint
389}
390
391/// Hamming distance between two byte arrays.
392pub fn hamming_distance(a: &[u8], b: &[u8]) -> u32 {
393    a.iter()
394        .zip(b.iter())
395        .map(|(x, y)| (x ^ y).count_ones())
396        .sum()
397}
398
399/// Spectral similarity: normalized dot product of fingerprints.
400fn spectral_similarity(a: &[u8; 16], b: &[u8; 16]) -> f32 {
401    let dot: u32 = a
402        .iter()
403        .zip(b.iter())
404        .map(|(&x, &y)| x as u32 * y as u32)
405        .sum();
406    let mag_a: f32 = a
407        .iter()
408        .map(|&x| (x as f32) * (x as f32))
409        .sum::<f32>()
410        .sqrt();
411    let mag_b: f32 = b
412        .iter()
413        .map(|&x| (x as f32) * (x as f32))
414        .sum::<f32>()
415        .sqrt();
416    if mag_a < 0.001 || mag_b < 0.001 {
417        return 0.0;
418    }
419    dot as f32 / (mag_a * mag_b)
420}
421
422// ─── Binary encoding helpers ────────────────────────
423
424fn encode_image_meta(m: &ImageMeta, buf: &mut Vec<u8>) {
425    buf.extend_from_slice(&m.width.to_le_bytes());
426    buf.extend_from_slice(&m.height.to_le_bytes());
427    buf.extend_from_slice(&m.phash);
428    buf.extend_from_slice(&m.color_histogram);
429    buf.extend_from_slice(&m.content_hash.to_le_bytes());
430    // pad to IMAGE_PAYLOAD_BYTES
431    let written = 2 + 2 + 8 + 12 + 4; // 28
432    for _ in written..IMAGE_PAYLOAD_BYTES {
433        buf.push(0);
434    }
435}
436
437fn decode_image_meta(data: &[u8]) -> Option<ImageMeta> {
438    // Self-defensive: 28 bytes (width 2 + height 2 + phash 8 + histogram 12 + hash 4).
439    if data.len() < 28 {
440        return None;
441    }
442    let width = u16::from_le_bytes(data[0..2].try_into().unwrap());
443    let height = u16::from_le_bytes(data[2..4].try_into().unwrap());
444    let mut phash = [0u8; 8];
445    phash.copy_from_slice(&data[4..12]);
446    let mut color_histogram = [0u8; 12];
447    color_histogram.copy_from_slice(&data[12..24]);
448    let content_hash = u32::from_le_bytes(data[24..28].try_into().unwrap());
449    Some(ImageMeta {
450        width,
451        height,
452        phash,
453        color_histogram,
454        content_hash,
455    })
456}
457
458fn encode_audio_meta(m: &AudioMeta, buf: &mut Vec<u8>) {
459    buf.extend_from_slice(&m.duration_ms.to_le_bytes());
460    buf.extend_from_slice(&m.sample_rate.to_le_bytes());
461    buf.extend_from_slice(&m.spectral_fingerprint);
462    buf.extend_from_slice(&m.peak_freq.to_le_bytes());
463    buf.extend_from_slice(&m.bpm_estimate.to_le_bytes());
464    // pad to AUDIO_PAYLOAD_BYTES
465    let written = 4 + 2 + 16 + 4 + 4; // 30
466    for _ in written..AUDIO_PAYLOAD_BYTES {
467        buf.push(0);
468    }
469}
470
471fn decode_audio_meta(data: &[u8]) -> Option<AudioMeta> {
472    // Self-defensive: 30 bytes (duration 4 + sample_rate 2 + fingerprint 16 + 2 floats).
473    if data.len() < 30 {
474        return None;
475    }
476    let duration_ms = u32::from_le_bytes(data[0..4].try_into().unwrap());
477    let sample_rate = u16::from_le_bytes(data[4..6].try_into().unwrap());
478    let mut spectral_fingerprint = [0u8; 16];
479    spectral_fingerprint.copy_from_slice(&data[6..22]);
480    let peak_freq = f32::from_le_bytes(data[22..26].try_into().unwrap());
481    let bpm_estimate = f32::from_le_bytes(data[26..30].try_into().unwrap());
482    Some(AudioMeta {
483        duration_ms,
484        sample_rate,
485        spectral_fingerprint,
486        peak_freq,
487        bpm_estimate,
488    })
489}
490
491fn encode_structured_meta(m: &StructuredMeta) -> Vec<u8> {
492    let mut buf = Vec::new();
493    buf.push(m.fields.len() as u8);
494    for (key, val) in &m.fields {
495        let key_bytes = key.as_bytes();
496        buf.push(key_bytes.len().min(255) as u8);
497        buf.extend_from_slice(&key_bytes[..key_bytes.len().min(255)]);
498        buf.push(val.type_tag());
499        match val {
500            FieldValue::Str(s) => {
501                let vb = s.as_bytes();
502                buf.push(vb.len().min(255) as u8);
503                buf.extend_from_slice(&vb[..vb.len().min(255)]);
504            }
505            FieldValue::Int(i) => {
506                buf.push(8);
507                buf.extend_from_slice(&i.to_le_bytes());
508            }
509            FieldValue::Float(f) => {
510                buf.push(8);
511                buf.extend_from_slice(&f.to_le_bytes());
512            }
513            FieldValue::Bool(b) => {
514                buf.push(1);
515                buf.push(if *b { 1 } else { 0 });
516            }
517        }
518    }
519    buf
520}
521
522fn decode_structured_meta(data: &[u8]) -> Option<StructuredMeta> {
523    if data.is_empty() {
524        return None;
525    }
526    let field_count = data[0] as usize;
527    let mut pos = 1;
528    let mut fields = Vec::new();
529
530    for _ in 0..field_count {
531        if pos >= data.len() {
532            break;
533        }
534        let key_len = data[pos] as usize;
535        pos += 1;
536        if pos + key_len >= data.len() {
537            break;
538        }
539        let key = String::from_utf8_lossy(&data[pos..pos + key_len]).to_string();
540        pos += key_len;
541
542        if pos + 2 > data.len() {
543            break;
544        }
545        let type_tag = data[pos];
546        let val_len = data[pos + 1] as usize;
547        pos += 2;
548
549        if pos + val_len > data.len() {
550            break;
551        }
552
553        let value = match type_tag {
554            0 => FieldValue::Str(String::from_utf8_lossy(&data[pos..pos + val_len]).to_string()),
555            1 if val_len == 8 => {
556                FieldValue::Int(i64::from_le_bytes(data[pos..pos + 8].try_into().unwrap()))
557            }
558            2 if val_len == 8 => {
559                FieldValue::Float(f64::from_le_bytes(data[pos..pos + 8].try_into().unwrap()))
560            }
561            3 if val_len >= 1 => FieldValue::Bool(data[pos] != 0),
562            _ => {
563                pos += val_len;
564                continue;
565            }
566        };
567
568        fields.push((key, value));
569        pos += val_len;
570    }
571
572    Some(StructuredMeta { fields })
573}
574
575fn read_u32(b: &[u8], off: usize) -> u32 {
576    u32::from_le_bytes(b[off..off + 4].try_into().unwrap())
577}
578fn read_u16(b: &[u8], off: usize) -> u16 {
579    u16::from_le_bytes(b[off..off + 2].try_into().unwrap())
580}
581
582// ─── Tests ──────────────────────────────────────────
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn test_phash_deterministic() {
590        let pixels = vec![128u8; 64 * 64];
591        let h1 = compute_phash(&pixels, 64, 64);
592        let h2 = compute_phash(&pixels, 64, 64);
593        assert_eq!(h1, h2);
594    }
595
596    #[test]
597    fn test_phash_different_images() {
598        // Ascending gradient — many left<right transitions
599        let mut pixels1 = vec![0u8; 64 * 64];
600        for (i, p) in pixels1.iter_mut().enumerate() {
601            *p = ((i % 64) * 4) as u8; // 0..252 across each row
602        }
603        // Descending gradient — many left>right transitions
604        let mut pixels2 = vec![0u8; 64 * 64];
605        for (i, p) in pixels2.iter_mut().enumerate() {
606            *p = (255 - (i % 64) * 4) as u8;
607        }
608        let h1 = compute_phash(&pixels1, 64, 64);
609        let h2 = compute_phash(&pixels2, 64, 64);
610        assert_ne!(h1, h2);
611    }
612
613    #[test]
614    fn test_hamming_distance() {
615        let a = [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00];
616        let b = [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00];
617        assert_eq!(hamming_distance(&a, &b), 0);
618
619        let c = [0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF];
620        assert_eq!(hamming_distance(&a, &c), 64); // all bits differ
621    }
622
623    #[test]
624    fn test_spectral_fingerprint_deterministic() {
625        let samples: Vec<f32> = (0..1000).map(|i| (i as f32 * 0.01).sin()).collect();
626        let fp1 = compute_spectral_fingerprint(&samples, 44100);
627        let fp2 = compute_spectral_fingerprint(&samples, 44100);
628        assert_eq!(fp1, fp2);
629    }
630
631    #[test]
632    fn test_modality_coords() {
633        let text_coords = ModalityIndex::modality_coords(&Modality::Text);
634        assert_eq!(text_coords, (0.0, 0.0, 0.0));
635
636        let img = Modality::Image(ImageMeta {
637            width: 100,
638            height: 100,
639            phash: [128, 64, 32, 16, 8, 4, 2, 1],
640            color_histogram: [0; 12],
641            content_hash: 42,
642        });
643        let img_coords = ModalityIndex::modality_coords(&img);
644        // Should be in associative region (0.3+, ...)
645        assert!(img_coords.0 >= 0.3);
646
647        let aud = Modality::Audio(AudioMeta {
648            duration_ms: 30000,
649            sample_rate: 44100,
650            spectral_fingerprint: [0; 16],
651            peak_freq: 440.0,
652            bpm_estimate: 120.0,
653        });
654        let aud_coords = ModalityIndex::modality_coords(&aud);
655        // Should be in echo_cache region
656        assert!(aud_coords.1 >= 0.3);
657    }
658
659    #[test]
660    fn test_image_meta_roundtrip() {
661        let meta = ImageMeta {
662            width: 1920,
663            height: 1080,
664            phash: [1, 2, 3, 4, 5, 6, 7, 8],
665            color_histogram: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120],
666            content_hash: 0xDEADBEEF,
667        };
668        let mut buf = Vec::new();
669        encode_image_meta(&meta, &mut buf);
670        let decoded = decode_image_meta(&buf).unwrap();
671        assert_eq!(meta, decoded);
672    }
673
674    #[test]
675    fn test_audio_meta_roundtrip() {
676        let meta = AudioMeta {
677            duration_ms: 180000,
678            sample_rate: 44100,
679            spectral_fingerprint: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
680            peak_freq: 440.0,
681            bpm_estimate: 120.5,
682        };
683        let mut buf = Vec::new();
684        encode_audio_meta(&meta, &mut buf);
685        let decoded = decode_audio_meta(&buf).unwrap();
686        assert_eq!(meta, decoded);
687    }
688
689    #[test]
690    fn test_structured_meta_roundtrip() {
691        let meta = StructuredMeta {
692            fields: vec![
693                ("name".to_string(), FieldValue::Str("test".to_string())),
694                ("count".to_string(), FieldValue::Int(42)),
695                ("ratio".to_string(), FieldValue::Float(3.125)),
696                ("active".to_string(), FieldValue::Bool(true)),
697            ],
698        };
699        let encoded = encode_structured_meta(&meta);
700        let decoded = decode_structured_meta(&encoded).unwrap();
701        assert_eq!(meta, decoded);
702    }
703
704    #[test]
705    fn test_search_image_similar() {
706        let mut index = ModalityIndex {
707            entries: HashMap::new(),
708        };
709        index.register(
710            0,
711            Modality::Image(ImageMeta {
712                width: 100,
713                height: 100,
714                phash: [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
715                color_histogram: [0; 12],
716                content_hash: 1,
717            }),
718        );
719        index.register(
720            1,
721            Modality::Image(ImageMeta {
722                width: 100,
723                height: 100,
724                phash: [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE], // 1 bit diff
725                color_histogram: [0; 12],
726                content_hash: 2,
727            }),
728        );
729        index.register(
730            2,
731            Modality::Image(ImageMeta {
732                width: 100,
733                height: 100,
734                phash: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // max diff
735                color_histogram: [0; 12],
736                content_hash: 3,
737            }),
738        );
739
740        let target = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
741        let results = index.search_image_similar(&target, 5);
742        assert_eq!(results.len(), 2); // blocks 0 and 1
743        assert_eq!(results[0].1, 0); // exact match first
744        assert_eq!(results[1].1, 1); // 1 bit diff second
745    }
746
747    #[test]
748    fn test_search_structured_by_field() {
749        let mut index = ModalityIndex {
750            entries: HashMap::new(),
751        };
752        index.register(
753            0,
754            Modality::Structured(StructuredMeta {
755                fields: vec![("type".to_string(), FieldValue::Str("report".to_string()))],
756            }),
757        );
758        index.register(
759            1,
760            Modality::Structured(StructuredMeta {
761                fields: vec![("type".to_string(), FieldValue::Str("note".to_string()))],
762            }),
763        );
764
765        let results = index.search_structured("type", &FieldValue::Str("report".to_string()));
766        assert_eq!(results.len(), 1);
767        assert_eq!(results[0], 0);
768    }
769
770    #[test]
771    fn test_modality_index_save_load() {
772        let tmp = tempfile::tempdir().expect("tempdir");
773        let mut index = ModalityIndex {
774            entries: HashMap::new(),
775        };
776
777        index.register(
778            0,
779            Modality::Image(ImageMeta {
780                width: 640,
781                height: 480,
782                phash: [1, 2, 3, 4, 5, 6, 7, 8],
783                color_histogram: [10; 12],
784                content_hash: 42,
785            }),
786        );
787        index.register(
788            1,
789            Modality::Audio(AudioMeta {
790                duration_ms: 60000,
791                sample_rate: 44100,
792                spectral_fingerprint: [5; 16],
793                peak_freq: 440.0,
794                bpm_estimate: 120.0,
795            }),
796        );
797        index.register(
798            2,
799            Modality::Structured(StructuredMeta {
800                fields: vec![("key".to_string(), FieldValue::Int(99))],
801            }),
802        );
803
804        index.save(tmp.path()).unwrap();
805        let loaded = ModalityIndex::load_or_init(tmp.path());
806        assert_eq!(loaded.entries.len(), 3);
807        assert!(matches!(loaded.get(0), Some(Modality::Image(_))));
808        assert!(matches!(loaded.get(1), Some(Modality::Audio(_))));
809        assert!(matches!(loaded.get(2), Some(Modality::Structured(_))));
810    }
811}