Skip to main content

ipfrs_semantic/
multimodal_index.rs

1//! Unified multimodal index for cross-modal similarity search.
2//!
3//! This module implements `MultiModalIndex`, a production-grade index supporting
4//! text, image, audio, video, and structured data embeddings with cross-modal
5//! projection and flexible fusion strategies.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11
12// ---------------------------------------------------------------------------
13// Modality
14// ---------------------------------------------------------------------------
15
16/// Supported content modalities.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum Modality {
19    /// Text embeddings (e.g., from BERT, GPT).
20    Text,
21    /// Image embeddings (e.g., from ResNet, CLIP).
22    Image,
23    /// Audio embeddings (e.g., from Wav2Vec, CLAP).
24    Audio,
25    /// Video embeddings (e.g., from VideoMAE).
26    Video,
27    /// Structured/tabular data embeddings.
28    Structured,
29}
30
31// ---------------------------------------------------------------------------
32// ModalityEmbedding
33// ---------------------------------------------------------------------------
34
35/// An embedding vector associated with a specific modality.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct ModalityEmbedding {
38    /// The modality this embedding belongs to.
39    pub modality: Modality,
40    /// Raw embedding vector (f64 for numeric precision).
41    pub embedding: Vec<f64>,
42    /// Declared dimensionality — must equal `embedding.len()`.
43    pub dims: usize,
44}
45
46impl ModalityEmbedding {
47    /// Create a new `ModalityEmbedding`, recording `dims` from the vector length.
48    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// ---------------------------------------------------------------------------
59// MultiModalDocument
60// ---------------------------------------------------------------------------
61
62/// A document containing one or more modality embeddings.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct MultiModalDocument {
65    /// Unique document identifier.
66    pub id: String,
67    /// Per-modality embeddings (at least one required).
68    pub modalities: HashMap<Modality, ModalityEmbedding>,
69    /// Arbitrary string metadata for post-filtering.
70    pub metadata: HashMap<String, String>,
71    /// Unix-epoch timestamp of indexing (seconds).
72    pub indexed_at: u64,
73}
74
75impl MultiModalDocument {
76    /// Construct a new document.  Returns `Err(MmiError::EmptyDocument)` if `modalities` is empty.
77    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// ---------------------------------------------------------------------------
96// CrossModalQuery
97// ---------------------------------------------------------------------------
98
99/// Query for cross-modal similarity search.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct CrossModalQuery {
102    /// The modality the query embedding originates from.
103    pub query_modality: Modality,
104    /// Query vector.
105    pub query_embedding: Vec<f64>,
106    /// Which document modalities to search against.
107    pub target_modalities: Vec<Modality>,
108    /// Maximum number of results to return.
109    pub top_k: usize,
110    /// Minimum acceptable combined score (0.0 – 1.0).
111    pub min_similarity: f64,
112    /// Metadata key-value filters (all must match).
113    pub filters: HashMap<String, String>,
114}
115
116impl CrossModalQuery {
117    /// Construct a query with no filters and default similarity threshold.
118    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// ---------------------------------------------------------------------------
136// CrossModalResult
137// ---------------------------------------------------------------------------
138
139/// A single search result with per-modality scores.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct CrossModalResult {
142    /// Document identifier.
143    pub doc_id: String,
144    /// Score for each matched modality.
145    pub scores: HashMap<Modality, f64>,
146    /// Fused combined score used for ranking.
147    pub combined_score: f64,
148    /// 1-based rank in the result list.
149    pub rank: usize,
150}
151
152// ---------------------------------------------------------------------------
153// FusionStrategy
154// ---------------------------------------------------------------------------
155
156/// Strategy for combining per-modality scores into a single combined score.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub enum FusionStrategy {
159    /// Take the maximum score across all matched modalities.
160    MaxScore,
161    /// Take the unweighted mean of all matched modality scores.
162    MeanScore,
163    /// Weighted mean; missing modalities are excluded and weights renormalized.
164    WeightedFusion {
165        /// Per-modality weights (need not sum to 1; normalized internally).
166        weights: HashMap<Modality, f64>,
167    },
168    /// Use Text score if available; otherwise fall back to `MaxScore`.
169    TextPrimary,
170}
171
172impl FusionStrategy {
173    /// Fuse a map of modality → score into a single scalar.
174    /// Returns `None` if `scores` is empty.
175    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                    // Fall back to mean when no weights match
197                    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// ---------------------------------------------------------------------------
215// MultiModalIndexConfig
216// ---------------------------------------------------------------------------
217
218/// Configuration for `MultiModalIndex`.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct MultiModalIndexConfig {
221    /// Strategy for fusing per-modality scores.
222    pub fusion_strategy: FusionStrategy,
223    /// Target dimensionality for cross-modal projection output.
224    pub cross_modal_dims: usize,
225    /// If `true`, normalize all query and document embeddings to unit length
226    /// before computing similarities.
227    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// ---------------------------------------------------------------------------
241// MmiError
242// ---------------------------------------------------------------------------
243
244/// Error type for `MultiModalIndex` operations.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub enum MmiError {
247    /// A document with the given ID already exists.
248    DocumentAlreadyExists(String),
249    /// No document with the given ID exists.
250    DocumentNotFound(String),
251    /// A document must contain at least one modality embedding.
252    EmptyDocument,
253    /// Cross-modal projection matrix dimensions are incompatible.
254    ProjectionDimsMismatch,
255    /// The query targeted modalities not found in any document.
256    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// ---------------------------------------------------------------------------
282// MmiStats
283// ---------------------------------------------------------------------------
284
285/// Aggregate statistics for a `MultiModalIndex`.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct MmiStats {
288    /// Total number of indexed documents.
289    pub doc_count: usize,
290    /// Number of documents that contain each modality.
291    pub modality_counts: HashMap<Modality, usize>,
292    /// Average number of modalities per document.
293    pub avg_modalities_per_doc: f64,
294    /// Cumulative number of `search` calls since index creation.
295    pub total_searches: u64,
296}
297
298// ---------------------------------------------------------------------------
299// MultiModalIndex
300// ---------------------------------------------------------------------------
301
302/// A unified index for multimodal content supporting cross-modal similarity search.
303///
304/// # Overview
305///
306/// `MultiModalIndex` stores `MultiModalDocument` entries — each carrying one or
307/// more modality embeddings — and provides:
308///
309/// * **Same-modality search** via `same_modality_search`
310/// * **Cross-modal search** via `search` (projects query into each target modality
311///   space when a projection matrix is registered)
312/// * **Configurable score fusion** via `FusionStrategy`
313/// * **Metadata filtering** on result sets
314pub struct MultiModalIndex {
315    /// Index configuration (fusion strategy, dims, normalization).
316    pub config: MultiModalIndexConfig,
317    /// Indexed documents keyed by their string ID.
318    pub documents: HashMap<String, MultiModalDocument>,
319    /// Learned cross-modal projection matrices.
320    /// Key `(from, to)` maps query space `from` into document space `to`.
321    pub projection_matrices: HashMap<(Modality, Modality), Vec<Vec<f64>>>,
322    /// Atomic counter incremented on every `search` call.
323    total_searches: Arc<AtomicU64>,
324}
325
326impl MultiModalIndex {
327    /// Create a new empty `MultiModalIndex` with the given configuration.
328    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    // -----------------------------------------------------------------------
338    // Document management
339    // -----------------------------------------------------------------------
340
341    /// Index a new document.
342    ///
343    /// Returns `Err(MmiError::DocumentAlreadyExists)` if a document with the
344    /// same ID is already present.
345    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    /// Remove a document by ID.  Returns `true` if the document existed.
354    pub fn remove_document(&mut self, doc_id: &str) -> bool {
355        self.documents.remove(doc_id).is_some()
356    }
357
358    /// Total number of indexed documents.
359    pub fn doc_count(&self) -> usize {
360        self.documents.len()
361    }
362
363    /// Count the number of documents that contain each modality.
364    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    // -----------------------------------------------------------------------
375    // Projection management
376    // -----------------------------------------------------------------------
377
378    /// Register a cross-modal projection matrix from modality `from` to `to`.
379    ///
380    /// `matrix[i][j]` is the weight from input dimension `j` to output dimension `i`.
381    /// So `matrix` must be `cross_modal_dims × from_dims`.
382    ///
383    /// Returns `Err(MmiError::ProjectionDimsMismatch)` if the matrix has zero
384    /// rows or inconsistent column counts.
385    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    // -----------------------------------------------------------------------
408    // Math helpers
409    // -----------------------------------------------------------------------
410
411    /// Compute cosine similarity between two equal-length vectors.
412    /// Returns 0.0 for zero-norm vectors.
413    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    /// Project an embedding vector through a matrix.
427    ///
428    /// `result[i] = Σ_j matrix[i][j] * embedding[j]`
429    ///
430    /// Output length equals the number of rows in `matrix`.
431    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    /// L2-normalize a vector.  Returns the zero vector if the norm is zero.
444    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    // -----------------------------------------------------------------------
453    // Search internals
454    // -----------------------------------------------------------------------
455
456    /// Prepare the query embedding: optionally normalize it.
457    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    /// Prepare a document embedding: optionally normalize it.
466    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    /// Check whether all metadata filters in the query match the document.
475    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    /// Compute per-modality scores for one document against a cross-modal query.
486    ///
487    /// For each `target_modality` in `query.target_modalities`:
488    /// - If the document has that modality AND `query_modality == target_modality`:
489    ///   compare directly using cosine similarity.
490    /// - If `query_modality != target_modality`:
491    ///   apply projection `(query_modality → target_modality)` if available;
492    ///   otherwise skip.
493    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                // Same-modality: direct cosine similarity.
510                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                // Cross-modal: project query into target space if we have a matrix.
516                if let Some(matrix) = self
517                    .projection_matrices
518                    .get(&(query.query_modality, target))
519                {
520                    let projected = Self::project(prepared_query, matrix);
521                    // Only score if projected dims match document dims.
522                    if projected.len() == prepared_doc.len() {
523                        let sim = Self::cosine_similarity(&projected, &prepared_doc);
524                        scores.insert(target, sim);
525                    }
526                }
527                // No projection available → skip this modality for this document.
528            }
529        }
530
531        scores
532    }
533
534    // -----------------------------------------------------------------------
535    // Public search API
536    // -----------------------------------------------------------------------
537
538    /// Search the index using the given cross-modal query.
539    ///
540    /// For each document:
541    /// 1. Compute per-modality cosine similarities (with optional cross-modal projection).
542    /// 2. Fuse scores using `config.fusion_strategy`.
543    /// 3. Filter by `query.min_similarity` and metadata filters.
544    /// 4. Return the top-`query.top_k` results sorted by combined score descending,
545    ///    with 1-based ranks assigned.
546    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, // assigned below
569                })
570            })
571            .collect();
572
573        // Sort descending by combined_score; use doc_id as tiebreaker for determinism.
574        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        // Assign 1-based ranks.
584        for (i, result) in results.iter_mut().enumerate() {
585            result.rank = i + 1;
586        }
587
588        results
589    }
590
591    /// Convenience search restricted to `query.query_modality` only, with no
592    /// cross-modal projection.
593    ///
594    /// This is equivalent to calling `search` with
595    /// `target_modalities = [query_modality]` and is more efficient because it
596    /// skips the projection lookup entirely.
597    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    // -----------------------------------------------------------------------
645    // Stats
646    // -----------------------------------------------------------------------
647
648    /// Return aggregate statistics for this index.
649    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// ---------------------------------------------------------------------------
668// Tests
669// ---------------------------------------------------------------------------
670
671#[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    // -----------------------------------------------------------------------
681    // Helpers
682    // -----------------------------------------------------------------------
683
684    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    // -----------------------------------------------------------------------
727    // Modality tests
728    // -----------------------------------------------------------------------
729
730    #[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    // -----------------------------------------------------------------------
759    // ModalityEmbedding tests
760    // -----------------------------------------------------------------------
761
762    #[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    // -----------------------------------------------------------------------
771    // MultiModalDocument tests
772    // -----------------------------------------------------------------------
773
774    #[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    // -----------------------------------------------------------------------
796    // Index creation and basic management
797    // -----------------------------------------------------------------------
798
799    #[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")); // idempotent second call
838    }
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    // -----------------------------------------------------------------------
860    // Math helper tests
861    // -----------------------------------------------------------------------
862
863    #[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        // 2×2 identity matrix
920        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        // 3-output × 2-input matrix
929        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    // -----------------------------------------------------------------------
937    // Projection matrix management
938    // -----------------------------------------------------------------------
939
940    #[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        // Second row has different length
962        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    // -----------------------------------------------------------------------
980    // Same-modality search
981    // -----------------------------------------------------------------------
982
983    #[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            // All embeddings have positive similarity to [1,0,...] but diminishing
1010            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        // Ranks should be 1, 2, 3 in order
1021        assert_eq!(results[0].rank, 1);
1022        assert_eq!(results[1].rank, 2);
1023        assert_eq!(results[2].rank, 3);
1024        // Scores should be non-increasing
1025        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        // d1 is very similar, d2 is orthogonal
1033        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    // -----------------------------------------------------------------------
1048    // Cross-modal search
1049    // -----------------------------------------------------------------------
1050
1051    #[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        // Document has an Image embedding (dim=4)
1069        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        // Add a Text→Image projection (identity in 4D)
1074        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        // Query from Text, targeting Image
1084        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        // No projection registered → Text→Image cross-modal should return nothing
1098        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        // MeanScore fusion: when two modalities score differently and both are
1106        // in the same modality space (same query_modality), the mean is taken.
1107        // Here both Text embeddings (dim=2) score against the Text query: d1=1.0, d2=0.0.
1108        // The combined score for d1 is mean(1.0) = 1.0.
1109        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        // d1 has score 1.0, d2 has score 0.0
1122        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        // Verify mean fusion: combined = mean of per-modality scores (just one here)
1127        assert!((r1.combined_score - r1.scores[&Modality::Text]).abs() < 1e-10);
1128    }
1129
1130    // -----------------------------------------------------------------------
1131    // FusionStrategy tests
1132    // -----------------------------------------------------------------------
1133
1134    #[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        // 3/4 * 1.0 + 1/4 * 0.0 = 0.75
1167        assert!((fused - 0.75).abs() < 1e-10);
1168    }
1169
1170    #[test]
1171    fn test_fusion_weighted_missing_modality_uses_mean_fallback() {
1172        // Weights only cover Audio, but scores only have Text/Image → weight_total=0 → mean fallback
1173        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    // -----------------------------------------------------------------------
1211    // Metadata filter tests
1212    // -----------------------------------------------------------------------
1213
1214    #[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    // -----------------------------------------------------------------------
1259    // Stats tests
1260    // -----------------------------------------------------------------------
1261
1262    #[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        // doc1: 2 modalities
1292        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        // doc2: 1 modality
1298        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        // avg = (2 + 1) / 2 = 1.5
1305        assert!((stats.avg_modalities_per_doc - 1.5).abs() < 1e-10);
1306    }
1307
1308    // -----------------------------------------------------------------------
1309    // Edge-case and robustness tests
1310    // -----------------------------------------------------------------------
1311
1312    #[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        // Document has 3D text embedding, query has 2D
1324        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        // Image modality is in the same document but different dim check doesn't interfere
1352    }
1353
1354    #[test]
1355    fn test_search_multimodal_fusion_mean_cross_modal() {
1356        // Cross-modal mean: Text→Text (sim=1.0) and Text→Image via projection (sim=0.0).
1357        // Combined = mean(1.0, 0.0) = 0.5.
1358        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        // 2×2 identity projection: Text space → Image space
1366        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        // Text sim = 1.0, Image sim = cosine(unit(2,0), unit(2,1)) = 0.0 → mean = 0.5
1379        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; // slight variation
1389            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        // Unnormalized query; cosine_similarity still computes correctly
1440        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        // MaxScore of (1.0, 0.0) = 1.0
1469        assert!((results[0].combined_score - 1.0).abs() < 1e-10);
1470    }
1471}