1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum Modality {
19 Text,
21 Image,
23 Audio,
25 Video,
27 Structured,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct ModalityEmbedding {
38 pub modality: Modality,
40 pub embedding: Vec<f64>,
42 pub dims: usize,
44}
45
46impl ModalityEmbedding {
47 pub fn new(modality: Modality, embedding: Vec<f64>) -> Self {
49 let dims = embedding.len();
50 Self {
51 modality,
52 embedding,
53 dims,
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct MultiModalDocument {
65 pub id: String,
67 pub modalities: HashMap<Modality, ModalityEmbedding>,
69 pub metadata: HashMap<String, String>,
71 pub indexed_at: u64,
73}
74
75impl MultiModalDocument {
76 pub fn new(
78 id: impl Into<String>,
79 modalities: HashMap<Modality, ModalityEmbedding>,
80 metadata: HashMap<String, String>,
81 indexed_at: u64,
82 ) -> Result<Self, MmiError> {
83 if modalities.is_empty() {
84 return Err(MmiError::EmptyDocument);
85 }
86 Ok(Self {
87 id: id.into(),
88 modalities,
89 metadata,
90 indexed_at,
91 })
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct CrossModalQuery {
102 pub query_modality: Modality,
104 pub query_embedding: Vec<f64>,
106 pub target_modalities: Vec<Modality>,
108 pub top_k: usize,
110 pub min_similarity: f64,
112 pub filters: HashMap<String, String>,
114}
115
116impl CrossModalQuery {
117 pub fn new(
119 query_modality: Modality,
120 query_embedding: Vec<f64>,
121 target_modalities: Vec<Modality>,
122 top_k: usize,
123 ) -> Self {
124 Self {
125 query_modality,
126 query_embedding,
127 target_modalities,
128 top_k,
129 min_similarity: 0.0,
130 filters: HashMap::new(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct CrossModalResult {
142 pub doc_id: String,
144 pub scores: HashMap<Modality, f64>,
146 pub combined_score: f64,
148 pub rank: usize,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
158pub enum FusionStrategy {
159 MaxScore,
161 MeanScore,
163 WeightedFusion {
165 weights: HashMap<Modality, f64>,
167 },
168 TextPrimary,
170}
171
172impl FusionStrategy {
173 pub fn fuse(&self, scores: &HashMap<Modality, f64>) -> Option<f64> {
176 if scores.is_empty() {
177 return None;
178 }
179 match self {
180 FusionStrategy::MaxScore => scores.values().copied().reduce(f64::max),
181 FusionStrategy::MeanScore => {
182 let sum: f64 = scores.values().sum();
183 Some(sum / scores.len() as f64)
184 }
185 FusionStrategy::WeightedFusion { weights } => {
186 let mut weighted_sum = 0.0_f64;
187 let mut weight_total = 0.0_f64;
188 for (modality, &score) in scores {
189 let w = weights.get(modality).copied().unwrap_or(0.0);
190 if w > 0.0 {
191 weighted_sum += score * w;
192 weight_total += w;
193 }
194 }
195 if weight_total == 0.0 {
196 let sum: f64 = scores.values().sum();
198 Some(sum / scores.len() as f64)
199 } else {
200 Some(weighted_sum / weight_total)
201 }
202 }
203 FusionStrategy::TextPrimary => {
204 if let Some(&text_score) = scores.get(&Modality::Text) {
205 Some(text_score)
206 } else {
207 scores.values().copied().reduce(f64::max)
208 }
209 }
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct MultiModalIndexConfig {
221 pub fusion_strategy: FusionStrategy,
223 pub cross_modal_dims: usize,
225 pub normalize_embeddings: bool,
228}
229
230impl Default for MultiModalIndexConfig {
231 fn default() -> Self {
232 Self {
233 fusion_strategy: FusionStrategy::MeanScore,
234 cross_modal_dims: 256,
235 normalize_embeddings: true,
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub enum MmiError {
247 DocumentAlreadyExists(String),
249 DocumentNotFound(String),
251 EmptyDocument,
253 ProjectionDimsMismatch,
255 NoMatchingModality,
257}
258
259impl std::fmt::Display for MmiError {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 match self {
262 MmiError::DocumentAlreadyExists(id) => {
263 write!(f, "Document already exists: {id}")
264 }
265 MmiError::DocumentNotFound(id) => {
266 write!(f, "Document not found: {id}")
267 }
268 MmiError::EmptyDocument => write!(f, "Document has no modality embeddings"),
269 MmiError::ProjectionDimsMismatch => {
270 write!(f, "Projection matrix dimensions do not match")
271 }
272 MmiError::NoMatchingModality => {
273 write!(f, "No matching modality found in any document")
274 }
275 }
276 }
277}
278
279impl std::error::Error for MmiError {}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct MmiStats {
288 pub doc_count: usize,
290 pub modality_counts: HashMap<Modality, usize>,
292 pub avg_modalities_per_doc: f64,
294 pub total_searches: u64,
296}
297
298pub struct MultiModalIndex {
315 pub config: MultiModalIndexConfig,
317 pub documents: HashMap<String, MultiModalDocument>,
319 pub projection_matrices: HashMap<(Modality, Modality), Vec<Vec<f64>>>,
322 total_searches: Arc<AtomicU64>,
324}
325
326impl MultiModalIndex {
327 pub fn new(config: MultiModalIndexConfig) -> Self {
329 Self {
330 config,
331 documents: HashMap::new(),
332 projection_matrices: HashMap::new(),
333 total_searches: Arc::new(AtomicU64::new(0)),
334 }
335 }
336
337 pub fn add_document(&mut self, doc: MultiModalDocument) -> Result<(), MmiError> {
346 if self.documents.contains_key(&doc.id) {
347 return Err(MmiError::DocumentAlreadyExists(doc.id.clone()));
348 }
349 self.documents.insert(doc.id.clone(), doc);
350 Ok(())
351 }
352
353 pub fn remove_document(&mut self, doc_id: &str) -> bool {
355 self.documents.remove(doc_id).is_some()
356 }
357
358 pub fn doc_count(&self) -> usize {
360 self.documents.len()
361 }
362
363 pub fn modality_coverage(&self) -> HashMap<Modality, usize> {
365 let mut counts: HashMap<Modality, usize> = HashMap::new();
366 for doc in self.documents.values() {
367 for modality in doc.modalities.keys() {
368 *counts.entry(*modality).or_insert(0) += 1;
369 }
370 }
371 counts
372 }
373
374 pub fn add_projection(
386 &mut self,
387 from: Modality,
388 to: Modality,
389 matrix: Vec<Vec<f64>>,
390 ) -> Result<(), MmiError> {
391 if matrix.is_empty() {
392 return Err(MmiError::ProjectionDimsMismatch);
393 }
394 let expected_cols = matrix[0].len();
395 if expected_cols == 0 {
396 return Err(MmiError::ProjectionDimsMismatch);
397 }
398 for row in &matrix {
399 if row.len() != expected_cols {
400 return Err(MmiError::ProjectionDimsMismatch);
401 }
402 }
403 self.projection_matrices.insert((from, to), matrix);
404 Ok(())
405 }
406
407 pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
414 if a.len() != b.len() || a.is_empty() {
415 return 0.0;
416 }
417 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
418 let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
419 let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
420 if norm_a == 0.0 || norm_b == 0.0 {
421 return 0.0;
422 }
423 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
424 }
425
426 pub fn project(embedding: &[f64], matrix: &[Vec<f64>]) -> Vec<f64> {
432 matrix
433 .iter()
434 .map(|row| {
435 row.iter()
436 .zip(embedding.iter())
437 .map(|(w, x)| w * x)
438 .sum::<f64>()
439 })
440 .collect()
441 }
442
443 pub fn l2_normalize(v: &[f64]) -> Vec<f64> {
445 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
446 if norm == 0.0 {
447 return v.to_vec();
448 }
449 v.iter().map(|x| x / norm).collect()
450 }
451
452 fn prepare_query(&self, embedding: &[f64]) -> Vec<f64> {
458 if self.config.normalize_embeddings {
459 Self::l2_normalize(embedding)
460 } else {
461 embedding.to_vec()
462 }
463 }
464
465 fn prepare_doc_embedding(&self, embedding: &[f64]) -> Vec<f64> {
467 if self.config.normalize_embeddings {
468 Self::l2_normalize(embedding)
469 } else {
470 embedding.to_vec()
471 }
472 }
473
474 fn matches_filters(doc: &MultiModalDocument, filters: &HashMap<String, String>) -> bool {
476 for (key, value) in filters {
477 match doc.metadata.get(key) {
478 Some(doc_val) if doc_val == value => {}
479 _ => return false,
480 }
481 }
482 true
483 }
484
485 fn score_document(
494 &self,
495 query: &CrossModalQuery,
496 prepared_query: &[f64],
497 doc: &MultiModalDocument,
498 ) -> HashMap<Modality, f64> {
499 let mut scores: HashMap<Modality, f64> = HashMap::new();
500
501 for &target in &query.target_modalities {
502 let Some(doc_emb) = doc.modalities.get(&target) else {
503 continue;
504 };
505
506 let prepared_doc = self.prepare_doc_embedding(&doc_emb.embedding);
507
508 if query.query_modality == target {
509 if prepared_query.len() == prepared_doc.len() {
511 let sim = Self::cosine_similarity(prepared_query, &prepared_doc);
512 scores.insert(target, sim);
513 }
514 } else {
515 if let Some(matrix) = self
517 .projection_matrices
518 .get(&(query.query_modality, target))
519 {
520 let projected = Self::project(prepared_query, matrix);
521 if projected.len() == prepared_doc.len() {
523 let sim = Self::cosine_similarity(&projected, &prepared_doc);
524 scores.insert(target, sim);
525 }
526 }
527 }
529 }
530
531 scores
532 }
533
534 pub fn search(&self, query: &CrossModalQuery) -> Vec<CrossModalResult> {
547 self.total_searches.fetch_add(1, Ordering::Relaxed);
548
549 let prepared_query = self.prepare_query(&query.query_embedding);
550
551 let mut results: Vec<CrossModalResult> = self
552 .documents
553 .values()
554 .filter(|doc| Self::matches_filters(doc, &query.filters))
555 .filter_map(|doc| {
556 let scores = self.score_document(query, &prepared_query, doc);
557 if scores.is_empty() {
558 return None;
559 }
560 let combined_score = self.config.fusion_strategy.fuse(&scores)?;
561 if combined_score < query.min_similarity {
562 return None;
563 }
564 Some(CrossModalResult {
565 doc_id: doc.id.clone(),
566 scores,
567 combined_score,
568 rank: 0, })
570 })
571 .collect();
572
573 results.sort_by(|a, b| {
575 b.combined_score
576 .partial_cmp(&a.combined_score)
577 .unwrap_or(std::cmp::Ordering::Equal)
578 .then_with(|| a.doc_id.cmp(&b.doc_id))
579 });
580
581 results.truncate(query.top_k);
582
583 for (i, result) in results.iter_mut().enumerate() {
585 result.rank = i + 1;
586 }
587
588 results
589 }
590
591 pub fn same_modality_search(&self, query: &CrossModalQuery) -> Vec<CrossModalResult> {
598 self.total_searches.fetch_add(1, Ordering::Relaxed);
599
600 let prepared_query = self.prepare_query(&query.query_embedding);
601 let target = query.query_modality;
602
603 let mut results: Vec<CrossModalResult> = self
604 .documents
605 .values()
606 .filter(|doc| Self::matches_filters(doc, &query.filters))
607 .filter_map(|doc| {
608 let doc_emb = doc.modalities.get(&target)?;
609 let prepared_doc = self.prepare_doc_embedding(&doc_emb.embedding);
610 if prepared_query.len() != prepared_doc.len() {
611 return None;
612 }
613 let sim = Self::cosine_similarity(&prepared_query, &prepared_doc);
614 if sim < query.min_similarity {
615 return None;
616 }
617 let mut scores = HashMap::new();
618 scores.insert(target, sim);
619 Some(CrossModalResult {
620 doc_id: doc.id.clone(),
621 scores,
622 combined_score: sim,
623 rank: 0,
624 })
625 })
626 .collect();
627
628 results.sort_by(|a, b| {
629 b.combined_score
630 .partial_cmp(&a.combined_score)
631 .unwrap_or(std::cmp::Ordering::Equal)
632 .then_with(|| a.doc_id.cmp(&b.doc_id))
633 });
634
635 results.truncate(query.top_k);
636
637 for (i, result) in results.iter_mut().enumerate() {
638 result.rank = i + 1;
639 }
640
641 results
642 }
643
644 pub fn stats(&self) -> MmiStats {
650 let doc_count = self.documents.len();
651 let modality_counts = self.modality_coverage();
652 let total_mod: usize = self.documents.values().map(|d| d.modalities.len()).sum();
653 let avg_modalities_per_doc = if doc_count == 0 {
654 0.0
655 } else {
656 total_mod as f64 / doc_count as f64
657 };
658 MmiStats {
659 doc_count,
660 modality_counts,
661 avg_modalities_per_doc,
662 total_searches: self.total_searches.load(Ordering::Relaxed),
663 }
664 }
665}
666
667#[cfg(test)]
672mod tests {
673 use std::collections::HashMap;
674
675 use crate::multimodal_index::{
676 CrossModalQuery, FusionStrategy, MmiError, MmiStats, Modality, ModalityEmbedding,
677 MultiModalDocument, MultiModalIndex, MultiModalIndexConfig,
678 };
679
680 fn make_config(strategy: FusionStrategy) -> MultiModalIndexConfig {
685 MultiModalIndexConfig {
686 fusion_strategy: strategy,
687 cross_modal_dims: 4,
688 normalize_embeddings: true,
689 }
690 }
691
692 fn make_doc(id: &str, modalities: HashMap<Modality, ModalityEmbedding>) -> MultiModalDocument {
693 MultiModalDocument::new(id, modalities, HashMap::new(), 0).expect("doc creation failed")
694 }
695
696 fn make_doc_with_meta(
697 id: &str,
698 modalities: HashMap<Modality, ModalityEmbedding>,
699 meta: HashMap<String, String>,
700 ) -> MultiModalDocument {
701 MultiModalDocument::new(id, modalities, meta, 42).expect("doc creation failed")
702 }
703
704 fn text_emb(v: Vec<f64>) -> ModalityEmbedding {
705 ModalityEmbedding::new(Modality::Text, v)
706 }
707
708 fn image_emb(v: Vec<f64>) -> ModalityEmbedding {
709 ModalityEmbedding::new(Modality::Image, v)
710 }
711
712 fn audio_emb(v: Vec<f64>) -> ModalityEmbedding {
713 ModalityEmbedding::new(Modality::Audio, v)
714 }
715
716 fn unit(n: usize, pos: usize) -> Vec<f64> {
717 let mut v = vec![0.0; n];
718 v[pos] = 1.0;
719 v
720 }
721
722 fn query_same(modality: Modality, embedding: Vec<f64>, k: usize) -> CrossModalQuery {
723 CrossModalQuery::new(modality, embedding, vec![modality], k)
724 }
725
726 #[test]
731 fn test_modality_hash_eq() {
732 let mut map = HashMap::new();
733 map.insert(Modality::Text, 1_usize);
734 map.insert(Modality::Image, 2_usize);
735 assert_eq!(map[&Modality::Text], 1);
736 assert_eq!(map[&Modality::Image], 2);
737 assert_ne!(Modality::Text, Modality::Image);
738 }
739
740 #[test]
741 fn test_all_modalities_distinct() {
742 let all = [
743 Modality::Text,
744 Modality::Image,
745 Modality::Audio,
746 Modality::Video,
747 Modality::Structured,
748 ];
749 for i in 0..all.len() {
750 for j in 0..all.len() {
751 if i != j {
752 assert_ne!(all[i], all[j], "modalities {i} and {j} should differ");
753 }
754 }
755 }
756 }
757
758 #[test]
763 fn test_modality_embedding_dims() {
764 let emb = ModalityEmbedding::new(Modality::Audio, vec![1.0, 2.0, 3.0]);
765 assert_eq!(emb.dims, 3);
766 assert_eq!(emb.embedding.len(), 3);
767 assert_eq!(emb.modality, Modality::Audio);
768 }
769
770 #[test]
775 fn test_empty_document_rejected() {
776 let result = MultiModalDocument::new("id", HashMap::new(), HashMap::new(), 0);
777 assert_eq!(result, Err(MmiError::EmptyDocument));
778 }
779
780 #[test]
781 fn test_document_creation_with_metadata() {
782 let mut mods = HashMap::new();
783 mods.insert(Modality::Text, text_emb(vec![0.5; 4]));
784 let mut meta = HashMap::new();
785 meta.insert("author".to_string(), "alice".to_string());
786 let doc = MultiModalDocument::new("doc1", mods, meta, 1000).expect("should succeed");
787 assert_eq!(doc.id, "doc1");
788 assert_eq!(doc.indexed_at, 1000);
789 assert_eq!(
790 doc.metadata.get("author").map(String::as_str),
791 Some("alice")
792 );
793 }
794
795 #[test]
800 fn test_new_index_is_empty() {
801 let idx = MultiModalIndex::new(MultiModalIndexConfig::default());
802 assert_eq!(idx.doc_count(), 0);
803 assert!(idx.modality_coverage().is_empty());
804 }
805
806 #[test]
807 fn test_add_document_success() {
808 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
809 let mut mods = HashMap::new();
810 mods.insert(Modality::Text, text_emb(vec![1.0, 0.0, 0.0]));
811 let doc = make_doc("a", mods);
812 idx.add_document(doc).expect("should succeed");
813 assert_eq!(idx.doc_count(), 1);
814 }
815
816 #[test]
817 fn test_add_duplicate_document_error() {
818 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
819 let mut mods = HashMap::new();
820 mods.insert(Modality::Text, text_emb(vec![1.0, 0.0, 0.0]));
821 let doc1 = make_doc("dup", mods.clone());
822 let doc2 = make_doc("dup", mods);
823 idx.add_document(doc1).expect("first insert ok");
824 let err = idx.add_document(doc2).expect_err("second should fail");
825 assert!(matches!(err, MmiError::DocumentAlreadyExists(ref id) if id == "dup"));
826 }
827
828 #[test]
829 fn test_remove_document() {
830 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
831 let mut mods = HashMap::new();
832 mods.insert(Modality::Text, text_emb(vec![1.0, 0.0]));
833 let doc = make_doc("rem", mods);
834 idx.add_document(doc).expect("ok");
835 assert!(idx.remove_document("rem"));
836 assert_eq!(idx.doc_count(), 0);
837 assert!(!idx.remove_document("rem")); }
839
840 #[test]
841 fn test_modality_coverage() {
842 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
843
844 let mut mods1 = HashMap::new();
845 mods1.insert(Modality::Text, text_emb(vec![1.0, 0.0]));
846 mods1.insert(Modality::Image, image_emb(vec![0.0, 1.0]));
847 idx.add_document(make_doc("d1", mods1)).expect("ok");
848
849 let mut mods2 = HashMap::new();
850 mods2.insert(Modality::Text, text_emb(vec![0.5, 0.5]));
851 idx.add_document(make_doc("d2", mods2)).expect("ok");
852
853 let cov = idx.modality_coverage();
854 assert_eq!(cov[&Modality::Text], 2);
855 assert_eq!(cov[&Modality::Image], 1);
856 assert!(!cov.contains_key(&Modality::Audio));
857 }
858
859 #[test]
864 fn test_cosine_similarity_identical() {
865 let v = vec![1.0, 2.0, 3.0];
866 let sim = MultiModalIndex::cosine_similarity(&v, &v);
867 assert!((sim - 1.0).abs() < 1e-10);
868 }
869
870 #[test]
871 fn test_cosine_similarity_orthogonal() {
872 let a = vec![1.0, 0.0, 0.0];
873 let b = vec![0.0, 1.0, 0.0];
874 let sim = MultiModalIndex::cosine_similarity(&a, &b);
875 assert!(sim.abs() < 1e-10);
876 }
877
878 #[test]
879 fn test_cosine_similarity_opposite() {
880 let a = vec![1.0, 0.0];
881 let b = vec![-1.0, 0.0];
882 let sim = MultiModalIndex::cosine_similarity(&a, &b);
883 assert!((sim - (-1.0)).abs() < 1e-10);
884 }
885
886 #[test]
887 fn test_cosine_similarity_zero_vector() {
888 let a = vec![0.0, 0.0, 0.0];
889 let b = vec![1.0, 2.0, 3.0];
890 let sim = MultiModalIndex::cosine_similarity(&a, &b);
891 assert_eq!(sim, 0.0);
892 }
893
894 #[test]
895 fn test_cosine_similarity_mismatched_len() {
896 let a = vec![1.0, 0.0];
897 let b = vec![1.0, 0.0, 0.0];
898 let sim = MultiModalIndex::cosine_similarity(&a, &b);
899 assert_eq!(sim, 0.0);
900 }
901
902 #[test]
903 fn test_l2_normalize() {
904 let v = vec![3.0, 4.0];
905 let n = MultiModalIndex::l2_normalize(&v);
906 assert!((n[0] - 0.6).abs() < 1e-10);
907 assert!((n[1] - 0.8).abs() < 1e-10);
908 }
909
910 #[test]
911 fn test_l2_normalize_zero_vec() {
912 let v = vec![0.0, 0.0];
913 let n = MultiModalIndex::l2_normalize(&v);
914 assert_eq!(n, vec![0.0, 0.0]);
915 }
916
917 #[test]
918 fn test_project_identity() {
919 let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
921 let emb = vec![3.0, 7.0];
922 let out = MultiModalIndex::project(&emb, &matrix);
923 assert_eq!(out, vec![3.0, 7.0]);
924 }
925
926 #[test]
927 fn test_project_dimensions() {
928 let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
930 let emb = vec![2.0, 3.0];
931 let out = MultiModalIndex::project(&emb, &matrix);
932 assert_eq!(out.len(), 3);
933 assert!((out[2] - 5.0).abs() < 1e-10);
934 }
935
936 #[test]
941 fn test_add_projection_success() {
942 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
943 let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
944 assert!(idx
945 .add_projection(Modality::Text, Modality::Image, matrix)
946 .is_ok());
947 }
948
949 #[test]
950 fn test_add_projection_empty_matrix() {
951 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
952 let err = idx
953 .add_projection(Modality::Text, Modality::Image, vec![])
954 .expect_err("empty should fail");
955 assert_eq!(err, MmiError::ProjectionDimsMismatch);
956 }
957
958 #[test]
959 fn test_add_projection_ragged_matrix() {
960 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
961 let matrix = vec![vec![1.0, 0.0], vec![0.0]];
963 let err = idx
964 .add_projection(Modality::Text, Modality::Image, matrix)
965 .expect_err("ragged should fail");
966 assert_eq!(err, MmiError::ProjectionDimsMismatch);
967 }
968
969 #[test]
970 fn test_add_projection_zero_cols() {
971 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
972 let matrix = vec![vec![]];
973 let err = idx
974 .add_projection(Modality::Text, Modality::Image, matrix)
975 .expect_err("zero cols should fail");
976 assert_eq!(err, MmiError::ProjectionDimsMismatch);
977 }
978
979 #[test]
984 fn test_same_modality_search_basic() {
985 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
986 let mut mods1 = HashMap::new();
987 mods1.insert(Modality::Text, text_emb(unit(3, 0)));
988 let mut mods2 = HashMap::new();
989 mods2.insert(Modality::Text, text_emb(unit(3, 1)));
990 let mut mods3 = HashMap::new();
991 mods3.insert(Modality::Text, text_emb(unit(3, 2)));
992 idx.add_document(make_doc("a", mods1)).expect("ok");
993 idx.add_document(make_doc("b", mods2)).expect("ok");
994 idx.add_document(make_doc("c", mods3)).expect("ok");
995
996 let q = query_same(Modality::Text, unit(3, 0), 1);
997 let results = idx.same_modality_search(&q);
998 assert_eq!(results.len(), 1);
999 assert_eq!(results[0].doc_id, "a");
1000 assert!((results[0].combined_score - 1.0).abs() < 1e-10);
1001 assert_eq!(results[0].rank, 1);
1002 }
1003
1004 #[test]
1005 fn test_same_modality_search_top_k() {
1006 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1007 for i in 0..10_usize {
1008 let mut mods = HashMap::new();
1009 let mut v = vec![0.0; 4];
1011 v[0] = 1.0 - i as f64 * 0.05;
1012 v[1] = (i as f64 * 0.05).max(0.0);
1013 mods.insert(Modality::Text, text_emb(v));
1014 idx.add_document(make_doc(&i.to_string(), mods))
1015 .expect("ok");
1016 }
1017 let q = query_same(Modality::Text, unit(4, 0), 3);
1018 let results = idx.same_modality_search(&q);
1019 assert_eq!(results.len(), 3);
1020 assert_eq!(results[0].rank, 1);
1022 assert_eq!(results[1].rank, 2);
1023 assert_eq!(results[2].rank, 3);
1024 assert!(results[0].combined_score >= results[1].combined_score);
1026 assert!(results[1].combined_score >= results[2].combined_score);
1027 }
1028
1029 #[test]
1030 fn test_same_modality_search_min_similarity_filter() {
1031 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1032 let mut mods1 = HashMap::new();
1034 mods1.insert(Modality::Text, text_emb(unit(3, 0)));
1035 let mut mods2 = HashMap::new();
1036 mods2.insert(Modality::Text, text_emb(unit(3, 1)));
1037 idx.add_document(make_doc("d1", mods1)).expect("ok");
1038 idx.add_document(make_doc("d2", mods2)).expect("ok");
1039
1040 let mut q = query_same(Modality::Text, unit(3, 0), 10);
1041 q.min_similarity = 0.5;
1042 let results = idx.same_modality_search(&q);
1043 assert_eq!(results.len(), 1);
1044 assert_eq!(results[0].doc_id, "d1");
1045 }
1046
1047 #[test]
1052 fn test_search_same_modality_in_search() {
1053 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1054 let mut mods = HashMap::new();
1055 mods.insert(Modality::Text, text_emb(unit(3, 0)));
1056 idx.add_document(make_doc("t1", mods)).expect("ok");
1057
1058 let q = CrossModalQuery::new(Modality::Text, unit(3, 0), vec![Modality::Text], 5);
1059 let results = idx.search(&q);
1060 assert_eq!(results.len(), 1);
1061 assert!((results[0].combined_score - 1.0).abs() < 1e-10);
1062 }
1063
1064 #[test]
1065 fn test_search_cross_modal_with_projection() {
1066 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1067
1068 let mut mods = HashMap::new();
1070 mods.insert(Modality::Image, image_emb(unit(4, 0)));
1071 idx.add_document(make_doc("img1", mods)).expect("ok");
1072
1073 let identity = vec![
1075 vec![1.0, 0.0, 0.0, 0.0],
1076 vec![0.0, 1.0, 0.0, 0.0],
1077 vec![0.0, 0.0, 1.0, 0.0],
1078 vec![0.0, 0.0, 0.0, 1.0],
1079 ];
1080 idx.add_projection(Modality::Text, Modality::Image, identity)
1081 .expect("projection ok");
1082
1083 let q = CrossModalQuery::new(Modality::Text, unit(4, 0), vec![Modality::Image], 5);
1085 let results = idx.search(&q);
1086 assert_eq!(results.len(), 1);
1087 assert!((results[0].combined_score - 1.0).abs() < 1e-10);
1088 }
1089
1090 #[test]
1091 fn test_search_no_projection_skips_cross_modal() {
1092 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1093 let mut mods = HashMap::new();
1094 mods.insert(Modality::Image, image_emb(unit(4, 0)));
1095 idx.add_document(make_doc("img1", mods)).expect("ok");
1096
1097 let q = CrossModalQuery::new(Modality::Text, unit(4, 0), vec![Modality::Image], 5);
1099 let results = idx.search(&q);
1100 assert!(results.is_empty());
1101 }
1102
1103 #[test]
1104 fn test_search_multimodal_fusion_mean() {
1105 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1110
1111 let mut mods1 = HashMap::new();
1112 mods1.insert(Modality::Text, text_emb(unit(2, 0)));
1113 let mut mods2 = HashMap::new();
1114 mods2.insert(Modality::Text, text_emb(unit(2, 1)));
1115 idx.add_document(make_doc("d1", mods1)).expect("ok");
1116 idx.add_document(make_doc("d2", mods2)).expect("ok");
1117
1118 let q = CrossModalQuery::new(Modality::Text, unit(2, 0), vec![Modality::Text], 5);
1119 let results = idx.search(&q);
1120 assert_eq!(results.len(), 2);
1121 let r1 = results.iter().find(|r| r.doc_id == "d1").expect("d1");
1123 let r2 = results.iter().find(|r| r.doc_id == "d2").expect("d2");
1124 assert!((r1.combined_score - 1.0).abs() < 1e-10);
1125 assert!(r2.combined_score.abs() < 1e-10);
1126 assert!((r1.combined_score - r1.scores[&Modality::Text]).abs() < 1e-10);
1128 }
1129
1130 #[test]
1135 fn test_fusion_max_score() {
1136 let strategy = FusionStrategy::MaxScore;
1137 let mut scores = HashMap::new();
1138 scores.insert(Modality::Text, 0.3);
1139 scores.insert(Modality::Image, 0.8);
1140 scores.insert(Modality::Audio, 0.5);
1141 let fused = strategy.fuse(&scores).expect("some");
1142 assert!((fused - 0.8).abs() < 1e-10);
1143 }
1144
1145 #[test]
1146 fn test_fusion_mean_score() {
1147 let strategy = FusionStrategy::MeanScore;
1148 let mut scores = HashMap::new();
1149 scores.insert(Modality::Text, 0.6);
1150 scores.insert(Modality::Image, 0.4);
1151 let fused = strategy.fuse(&scores).expect("some");
1152 assert!((fused - 0.5).abs() < 1e-10);
1153 }
1154
1155 #[test]
1156 fn test_fusion_weighted() {
1157 let mut weights = HashMap::new();
1158 weights.insert(Modality::Text, 3.0);
1159 weights.insert(Modality::Image, 1.0);
1160 let strategy = FusionStrategy::WeightedFusion { weights };
1161
1162 let mut scores = HashMap::new();
1163 scores.insert(Modality::Text, 1.0);
1164 scores.insert(Modality::Image, 0.0);
1165 let fused = strategy.fuse(&scores).expect("some");
1166 assert!((fused - 0.75).abs() < 1e-10);
1168 }
1169
1170 #[test]
1171 fn test_fusion_weighted_missing_modality_uses_mean_fallback() {
1172 let mut weights = HashMap::new();
1174 weights.insert(Modality::Audio, 2.0);
1175 let strategy = FusionStrategy::WeightedFusion { weights };
1176
1177 let mut scores = HashMap::new();
1178 scores.insert(Modality::Text, 0.4);
1179 scores.insert(Modality::Image, 0.6);
1180 let fused = strategy.fuse(&scores).expect("some");
1181 assert!((fused - 0.5).abs() < 1e-10);
1182 }
1183
1184 #[test]
1185 fn test_fusion_text_primary_uses_text_when_present() {
1186 let strategy = FusionStrategy::TextPrimary;
1187 let mut scores = HashMap::new();
1188 scores.insert(Modality::Text, 0.7);
1189 scores.insert(Modality::Image, 0.9);
1190 let fused = strategy.fuse(&scores).expect("some");
1191 assert!((fused - 0.7).abs() < 1e-10);
1192 }
1193
1194 #[test]
1195 fn test_fusion_text_primary_falls_back_to_max() {
1196 let strategy = FusionStrategy::TextPrimary;
1197 let mut scores = HashMap::new();
1198 scores.insert(Modality::Image, 0.6);
1199 scores.insert(Modality::Audio, 0.9);
1200 let fused = strategy.fuse(&scores).expect("some");
1201 assert!((fused - 0.9).abs() < 1e-10);
1202 }
1203
1204 #[test]
1205 fn test_fusion_empty_scores_returns_none() {
1206 let strategy = FusionStrategy::MeanScore;
1207 assert!(strategy.fuse(&HashMap::new()).is_none());
1208 }
1209
1210 #[test]
1215 fn test_metadata_filter_match() {
1216 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1217
1218 let mut meta1 = HashMap::new();
1219 meta1.insert("type".to_string(), "news".to_string());
1220 let mut mods1 = HashMap::new();
1221 mods1.insert(Modality::Text, text_emb(unit(3, 0)));
1222 idx.add_document(make_doc_with_meta("n1", mods1, meta1))
1223 .expect("ok");
1224
1225 let mut meta2 = HashMap::new();
1226 meta2.insert("type".to_string(), "blog".to_string());
1227 let mut mods2 = HashMap::new();
1228 mods2.insert(Modality::Text, text_emb(unit(3, 0)));
1229 idx.add_document(make_doc_with_meta("b1", mods2, meta2))
1230 .expect("ok");
1231
1232 let mut filters = HashMap::new();
1233 filters.insert("type".to_string(), "news".to_string());
1234 let mut q = query_same(Modality::Text, unit(3, 0), 10);
1235 q.filters = filters;
1236
1237 let results = idx.same_modality_search(&q);
1238 assert_eq!(results.len(), 1);
1239 assert_eq!(results[0].doc_id, "n1");
1240 }
1241
1242 #[test]
1243 fn test_metadata_filter_no_match_returns_empty() {
1244 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1245 let mut mods = HashMap::new();
1246 mods.insert(Modality::Text, text_emb(unit(3, 0)));
1247 idx.add_document(make_doc("d1", mods)).expect("ok");
1248
1249 let mut filters = HashMap::new();
1250 filters.insert("nonexistent".to_string(), "value".to_string());
1251 let mut q = query_same(Modality::Text, unit(3, 0), 10);
1252 q.filters = filters;
1253
1254 let results = idx.search(&q);
1255 assert!(results.is_empty());
1256 }
1257
1258 #[test]
1263 fn test_stats_empty_index() {
1264 let idx = MultiModalIndex::new(MultiModalIndexConfig::default());
1265 let stats = idx.stats();
1266 assert_eq!(stats.doc_count, 0);
1267 assert_eq!(stats.total_searches, 0);
1268 assert_eq!(stats.avg_modalities_per_doc, 0.0);
1269 }
1270
1271 #[test]
1272 fn test_stats_search_counter_increments() {
1273 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1274 let mut mods = HashMap::new();
1275 mods.insert(Modality::Text, text_emb(unit(3, 0)));
1276 idx.add_document(make_doc("x", mods)).expect("ok");
1277
1278 let q = query_same(Modality::Text, unit(3, 0), 5);
1279 idx.search(&q);
1280 idx.search(&q);
1281 idx.same_modality_search(&q);
1282
1283 let stats = idx.stats();
1284 assert_eq!(stats.total_searches, 3);
1285 }
1286
1287 #[test]
1288 fn test_stats_avg_modalities_per_doc() {
1289 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
1290
1291 let mut mods1 = HashMap::new();
1293 mods1.insert(Modality::Text, text_emb(vec![1.0]));
1294 mods1.insert(Modality::Image, image_emb(vec![1.0]));
1295 idx.add_document(make_doc("d1", mods1)).expect("ok");
1296
1297 let mut mods2 = HashMap::new();
1299 mods2.insert(Modality::Audio, audio_emb(vec![1.0]));
1300 idx.add_document(make_doc("d2", mods2)).expect("ok");
1301
1302 let stats = idx.stats();
1303 assert_eq!(stats.doc_count, 2);
1304 assert!((stats.avg_modalities_per_doc - 1.5).abs() < 1e-10);
1306 }
1307
1308 #[test]
1313 fn test_search_empty_index() {
1314 let idx = MultiModalIndex::new(MultiModalIndexConfig::default());
1315 let q = query_same(Modality::Text, unit(3, 0), 5);
1316 let results = idx.search(&q);
1317 assert!(results.is_empty());
1318 }
1319
1320 #[test]
1321 fn test_search_dim_mismatch_skips_document() {
1322 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1323 let mut mods = HashMap::new();
1325 mods.insert(Modality::Text, text_emb(unit(3, 0)));
1326 idx.add_document(make_doc("dim_mismatch", mods))
1327 .expect("ok");
1328
1329 let q = query_same(Modality::Text, unit(2, 0), 5);
1330 let results = idx.same_modality_search(&q);
1331 assert!(results.is_empty());
1332 }
1333
1334 #[test]
1335 fn test_search_scores_contain_matched_modalities() {
1336 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1337 let mut mods = HashMap::new();
1338 mods.insert(Modality::Text, text_emb(unit(3, 0)));
1339 mods.insert(Modality::Image, image_emb(unit(3, 1)));
1340 idx.add_document(make_doc("multi", mods)).expect("ok");
1341
1342 let q = CrossModalQuery::new(
1343 Modality::Text,
1344 unit(3, 0),
1345 vec![Modality::Text, Modality::Image],
1346 5,
1347 );
1348 let results = idx.search(&q);
1349 assert_eq!(results.len(), 1);
1350 assert!(results[0].scores.contains_key(&Modality::Text));
1351 }
1353
1354 #[test]
1355 fn test_search_multimodal_fusion_mean_cross_modal() {
1356 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1359
1360 let mut mods = HashMap::new();
1361 mods.insert(Modality::Text, text_emb(unit(2, 0)));
1362 mods.insert(Modality::Image, image_emb(unit(2, 1)));
1363 idx.add_document(make_doc("mm", mods)).expect("ok");
1364
1365 let identity = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1367 idx.add_projection(Modality::Text, Modality::Image, identity)
1368 .expect("ok");
1369
1370 let q = CrossModalQuery::new(
1371 Modality::Text,
1372 unit(2, 0),
1373 vec![Modality::Text, Modality::Image],
1374 5,
1375 );
1376 let results = idx.search(&q);
1377 assert_eq!(results.len(), 1);
1378 assert!((results[0].combined_score - 0.5).abs() < 1e-10);
1380 }
1381
1382 #[test]
1383 fn test_cross_modal_result_rank_assignment() {
1384 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
1385 for i in 0..5_usize {
1386 let mut mods = HashMap::new();
1387 let mut v = unit(4, 0);
1388 v[1] = i as f64 * 0.01; mods.insert(Modality::Text, text_emb(v));
1390 idx.add_document(make_doc(&format!("r{i}"), mods))
1391 .expect("ok");
1392 }
1393 let q = query_same(Modality::Text, unit(4, 0), 5);
1394 let results = idx.search(&q);
1395 for (i, r) in results.iter().enumerate() {
1396 assert_eq!(r.rank, i + 1);
1397 }
1398 }
1399
1400 #[test]
1401 fn test_mmi_error_display() {
1402 let e1 = MmiError::DocumentAlreadyExists("id1".into());
1403 let e2 = MmiError::DocumentNotFound("id2".into());
1404 let e3 = MmiError::EmptyDocument;
1405 let e4 = MmiError::ProjectionDimsMismatch;
1406 let e5 = MmiError::NoMatchingModality;
1407 assert!(e1.to_string().contains("id1"));
1408 assert!(e2.to_string().contains("id2"));
1409 assert!(!e3.to_string().is_empty());
1410 assert!(!e4.to_string().is_empty());
1411 assert!(!e5.to_string().is_empty());
1412 }
1413
1414 #[test]
1415 fn test_mmi_stats_modality_counts() {
1416 let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
1417 for i in 0..3_usize {
1418 let mut mods = HashMap::new();
1419 mods.insert(Modality::Text, text_emb(vec![i as f64]));
1420 idx.add_document(make_doc(&i.to_string(), mods))
1421 .expect("ok");
1422 }
1423 let stats: MmiStats = idx.stats();
1424 assert_eq!(*stats.modality_counts.get(&Modality::Text).unwrap_or(&0), 3);
1425 }
1426
1427 #[test]
1428 fn test_normalization_flag_off() {
1429 let config = MultiModalIndexConfig {
1430 fusion_strategy: FusionStrategy::MeanScore,
1431 cross_modal_dims: 4,
1432 normalize_embeddings: false,
1433 };
1434 let mut idx = MultiModalIndex::new(config);
1435 let mut mods = HashMap::new();
1436 mods.insert(Modality::Text, text_emb(vec![2.0, 0.0, 0.0]));
1437 idx.add_document(make_doc("nn", mods)).expect("ok");
1438
1439 let q = query_same(Modality::Text, vec![2.0, 0.0, 0.0], 5);
1441 let results = idx.search(&q);
1442 assert_eq!(results.len(), 1);
1443 assert!((results[0].combined_score - 1.0).abs() < 1e-10);
1444 }
1445
1446 #[test]
1447 fn test_structured_and_video_modalities() {
1448 let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MaxScore));
1449 let mut mods = HashMap::new();
1450 mods.insert(
1451 Modality::Structured,
1452 ModalityEmbedding::new(Modality::Structured, unit(4, 0)),
1453 );
1454 mods.insert(
1455 Modality::Video,
1456 ModalityEmbedding::new(Modality::Video, unit(4, 1)),
1457 );
1458 idx.add_document(make_doc("sv", mods)).expect("ok");
1459
1460 let q = CrossModalQuery::new(
1461 Modality::Structured,
1462 unit(4, 0),
1463 vec![Modality::Structured, Modality::Video],
1464 5,
1465 );
1466 let results = idx.search(&q);
1467 assert_eq!(results.len(), 1);
1468 assert!((results[0].combined_score - 1.0).abs() < 1e-10);
1470 }
1471}