1use std::collections::HashMap;
13use std::fs;
14use std::path::Path;
15
16const IMAGE_PAYLOAD_BYTES: usize = 32; const AUDIO_PAYLOAD_BYTES: usize = 32; #[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
93pub 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 pub fn register(&mut self, block_idx: u32, modality: Modality) {
211 self.entries.insert(block_idx, modality);
212 }
213
214 pub fn get(&self, block_idx: u32) -> Option<&Modality> {
216 self.entries.get(&block_idx)
217 }
218
219 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 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 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 pub fn modality_coords(modality: &Modality) -> (f32, f32, f32) {
286 match modality {
287 Modality::Text => (0.0, 0.0, 0.0), Modality::Image(m) => {
289 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 (0.3 + x, 0.0 + y, 0.0 + z)
295 }
296 Modality::Audio(m) => {
297 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 (0.0 + x, 0.3 + y, 0.3 + z)
303 }
304 Modality::Structured(m) => {
305 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 (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
344pub fn compute_phash(pixels: &[u8], width: u32, height: u32) -> [u8; 8] {
349 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
372pub 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
391pub 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
399fn 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
422fn 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 let written = 2 + 2 + 8 + 12 + 4; for _ in written..IMAGE_PAYLOAD_BYTES {
433 buf.push(0);
434 }
435}
436
437fn decode_image_meta(data: &[u8]) -> Option<ImageMeta> {
438 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 let written = 4 + 2 + 16 + 4 + 4; for _ in written..AUDIO_PAYLOAD_BYTES {
467 buf.push(0);
468 }
469}
470
471fn decode_audio_meta(data: &[u8]) -> Option<AudioMeta> {
472 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#[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 let mut pixels1 = vec![0u8; 64 * 64];
600 for (i, p) in pixels1.iter_mut().enumerate() {
601 *p = ((i % 64) * 4) as u8; }
603 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); }
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 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 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], 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], 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); assert_eq!(results[0].1, 0); assert_eq!(results[1].1, 1); }
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}