use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Modality {
Text,
Image,
Audio,
Video,
Structured,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModalityEmbedding {
pub modality: Modality,
pub embedding: Vec<f64>,
pub dims: usize,
}
impl ModalityEmbedding {
pub fn new(modality: Modality, embedding: Vec<f64>) -> Self {
let dims = embedding.len();
Self {
modality,
embedding,
dims,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MultiModalDocument {
pub id: String,
pub modalities: HashMap<Modality, ModalityEmbedding>,
pub metadata: HashMap<String, String>,
pub indexed_at: u64,
}
impl MultiModalDocument {
pub fn new(
id: impl Into<String>,
modalities: HashMap<Modality, ModalityEmbedding>,
metadata: HashMap<String, String>,
indexed_at: u64,
) -> Result<Self, MmiError> {
if modalities.is_empty() {
return Err(MmiError::EmptyDocument);
}
Ok(Self {
id: id.into(),
modalities,
metadata,
indexed_at,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossModalQuery {
pub query_modality: Modality,
pub query_embedding: Vec<f64>,
pub target_modalities: Vec<Modality>,
pub top_k: usize,
pub min_similarity: f64,
pub filters: HashMap<String, String>,
}
impl CrossModalQuery {
pub fn new(
query_modality: Modality,
query_embedding: Vec<f64>,
target_modalities: Vec<Modality>,
top_k: usize,
) -> Self {
Self {
query_modality,
query_embedding,
target_modalities,
top_k,
min_similarity: 0.0,
filters: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossModalResult {
pub doc_id: String,
pub scores: HashMap<Modality, f64>,
pub combined_score: f64,
pub rank: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FusionStrategy {
MaxScore,
MeanScore,
WeightedFusion {
weights: HashMap<Modality, f64>,
},
TextPrimary,
}
impl FusionStrategy {
pub fn fuse(&self, scores: &HashMap<Modality, f64>) -> Option<f64> {
if scores.is_empty() {
return None;
}
match self {
FusionStrategy::MaxScore => scores.values().copied().reduce(f64::max),
FusionStrategy::MeanScore => {
let sum: f64 = scores.values().sum();
Some(sum / scores.len() as f64)
}
FusionStrategy::WeightedFusion { weights } => {
let mut weighted_sum = 0.0_f64;
let mut weight_total = 0.0_f64;
for (modality, &score) in scores {
let w = weights.get(modality).copied().unwrap_or(0.0);
if w > 0.0 {
weighted_sum += score * w;
weight_total += w;
}
}
if weight_total == 0.0 {
let sum: f64 = scores.values().sum();
Some(sum / scores.len() as f64)
} else {
Some(weighted_sum / weight_total)
}
}
FusionStrategy::TextPrimary => {
if let Some(&text_score) = scores.get(&Modality::Text) {
Some(text_score)
} else {
scores.values().copied().reduce(f64::max)
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiModalIndexConfig {
pub fusion_strategy: FusionStrategy,
pub cross_modal_dims: usize,
pub normalize_embeddings: bool,
}
impl Default for MultiModalIndexConfig {
fn default() -> Self {
Self {
fusion_strategy: FusionStrategy::MeanScore,
cross_modal_dims: 256,
normalize_embeddings: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MmiError {
DocumentAlreadyExists(String),
DocumentNotFound(String),
EmptyDocument,
ProjectionDimsMismatch,
NoMatchingModality,
}
impl std::fmt::Display for MmiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MmiError::DocumentAlreadyExists(id) => {
write!(f, "Document already exists: {id}")
}
MmiError::DocumentNotFound(id) => {
write!(f, "Document not found: {id}")
}
MmiError::EmptyDocument => write!(f, "Document has no modality embeddings"),
MmiError::ProjectionDimsMismatch => {
write!(f, "Projection matrix dimensions do not match")
}
MmiError::NoMatchingModality => {
write!(f, "No matching modality found in any document")
}
}
}
}
impl std::error::Error for MmiError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MmiStats {
pub doc_count: usize,
pub modality_counts: HashMap<Modality, usize>,
pub avg_modalities_per_doc: f64,
pub total_searches: u64,
}
pub struct MultiModalIndex {
pub config: MultiModalIndexConfig,
pub documents: HashMap<String, MultiModalDocument>,
pub projection_matrices: HashMap<(Modality, Modality), Vec<Vec<f64>>>,
total_searches: Arc<AtomicU64>,
}
impl MultiModalIndex {
pub fn new(config: MultiModalIndexConfig) -> Self {
Self {
config,
documents: HashMap::new(),
projection_matrices: HashMap::new(),
total_searches: Arc::new(AtomicU64::new(0)),
}
}
pub fn add_document(&mut self, doc: MultiModalDocument) -> Result<(), MmiError> {
if self.documents.contains_key(&doc.id) {
return Err(MmiError::DocumentAlreadyExists(doc.id.clone()));
}
self.documents.insert(doc.id.clone(), doc);
Ok(())
}
pub fn remove_document(&mut self, doc_id: &str) -> bool {
self.documents.remove(doc_id).is_some()
}
pub fn doc_count(&self) -> usize {
self.documents.len()
}
pub fn modality_coverage(&self) -> HashMap<Modality, usize> {
let mut counts: HashMap<Modality, usize> = HashMap::new();
for doc in self.documents.values() {
for modality in doc.modalities.keys() {
*counts.entry(*modality).or_insert(0) += 1;
}
}
counts
}
pub fn add_projection(
&mut self,
from: Modality,
to: Modality,
matrix: Vec<Vec<f64>>,
) -> Result<(), MmiError> {
if matrix.is_empty() {
return Err(MmiError::ProjectionDimsMismatch);
}
let expected_cols = matrix[0].len();
if expected_cols == 0 {
return Err(MmiError::ProjectionDimsMismatch);
}
for row in &matrix {
if row.len() != expected_cols {
return Err(MmiError::ProjectionDimsMismatch);
}
}
self.projection_matrices.insert((from, to), matrix);
Ok(())
}
pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
pub fn project(embedding: &[f64], matrix: &[Vec<f64>]) -> Vec<f64> {
matrix
.iter()
.map(|row| {
row.iter()
.zip(embedding.iter())
.map(|(w, x)| w * x)
.sum::<f64>()
})
.collect()
}
pub fn l2_normalize(v: &[f64]) -> Vec<f64> {
let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm == 0.0 {
return v.to_vec();
}
v.iter().map(|x| x / norm).collect()
}
fn prepare_query(&self, embedding: &[f64]) -> Vec<f64> {
if self.config.normalize_embeddings {
Self::l2_normalize(embedding)
} else {
embedding.to_vec()
}
}
fn prepare_doc_embedding(&self, embedding: &[f64]) -> Vec<f64> {
if self.config.normalize_embeddings {
Self::l2_normalize(embedding)
} else {
embedding.to_vec()
}
}
fn matches_filters(doc: &MultiModalDocument, filters: &HashMap<String, String>) -> bool {
for (key, value) in filters {
match doc.metadata.get(key) {
Some(doc_val) if doc_val == value => {}
_ => return false,
}
}
true
}
fn score_document(
&self,
query: &CrossModalQuery,
prepared_query: &[f64],
doc: &MultiModalDocument,
) -> HashMap<Modality, f64> {
let mut scores: HashMap<Modality, f64> = HashMap::new();
for &target in &query.target_modalities {
let Some(doc_emb) = doc.modalities.get(&target) else {
continue;
};
let prepared_doc = self.prepare_doc_embedding(&doc_emb.embedding);
if query.query_modality == target {
if prepared_query.len() == prepared_doc.len() {
let sim = Self::cosine_similarity(prepared_query, &prepared_doc);
scores.insert(target, sim);
}
} else {
if let Some(matrix) = self
.projection_matrices
.get(&(query.query_modality, target))
{
let projected = Self::project(prepared_query, matrix);
if projected.len() == prepared_doc.len() {
let sim = Self::cosine_similarity(&projected, &prepared_doc);
scores.insert(target, sim);
}
}
}
}
scores
}
pub fn search(&self, query: &CrossModalQuery) -> Vec<CrossModalResult> {
self.total_searches.fetch_add(1, Ordering::Relaxed);
let prepared_query = self.prepare_query(&query.query_embedding);
let mut results: Vec<CrossModalResult> = self
.documents
.values()
.filter(|doc| Self::matches_filters(doc, &query.filters))
.filter_map(|doc| {
let scores = self.score_document(query, &prepared_query, doc);
if scores.is_empty() {
return None;
}
let combined_score = self.config.fusion_strategy.fuse(&scores)?;
if combined_score < query.min_similarity {
return None;
}
Some(CrossModalResult {
doc_id: doc.id.clone(),
scores,
combined_score,
rank: 0, })
})
.collect();
results.sort_by(|a, b| {
b.combined_score
.partial_cmp(&a.combined_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.doc_id.cmp(&b.doc_id))
});
results.truncate(query.top_k);
for (i, result) in results.iter_mut().enumerate() {
result.rank = i + 1;
}
results
}
pub fn same_modality_search(&self, query: &CrossModalQuery) -> Vec<CrossModalResult> {
self.total_searches.fetch_add(1, Ordering::Relaxed);
let prepared_query = self.prepare_query(&query.query_embedding);
let target = query.query_modality;
let mut results: Vec<CrossModalResult> = self
.documents
.values()
.filter(|doc| Self::matches_filters(doc, &query.filters))
.filter_map(|doc| {
let doc_emb = doc.modalities.get(&target)?;
let prepared_doc = self.prepare_doc_embedding(&doc_emb.embedding);
if prepared_query.len() != prepared_doc.len() {
return None;
}
let sim = Self::cosine_similarity(&prepared_query, &prepared_doc);
if sim < query.min_similarity {
return None;
}
let mut scores = HashMap::new();
scores.insert(target, sim);
Some(CrossModalResult {
doc_id: doc.id.clone(),
scores,
combined_score: sim,
rank: 0,
})
})
.collect();
results.sort_by(|a, b| {
b.combined_score
.partial_cmp(&a.combined_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.doc_id.cmp(&b.doc_id))
});
results.truncate(query.top_k);
for (i, result) in results.iter_mut().enumerate() {
result.rank = i + 1;
}
results
}
pub fn stats(&self) -> MmiStats {
let doc_count = self.documents.len();
let modality_counts = self.modality_coverage();
let total_mod: usize = self.documents.values().map(|d| d.modalities.len()).sum();
let avg_modalities_per_doc = if doc_count == 0 {
0.0
} else {
total_mod as f64 / doc_count as f64
};
MmiStats {
doc_count,
modality_counts,
avg_modalities_per_doc,
total_searches: self.total_searches.load(Ordering::Relaxed),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::multimodal_index::{
CrossModalQuery, FusionStrategy, MmiError, MmiStats, Modality, ModalityEmbedding,
MultiModalDocument, MultiModalIndex, MultiModalIndexConfig,
};
fn make_config(strategy: FusionStrategy) -> MultiModalIndexConfig {
MultiModalIndexConfig {
fusion_strategy: strategy,
cross_modal_dims: 4,
normalize_embeddings: true,
}
}
fn make_doc(id: &str, modalities: HashMap<Modality, ModalityEmbedding>) -> MultiModalDocument {
MultiModalDocument::new(id, modalities, HashMap::new(), 0).expect("doc creation failed")
}
fn make_doc_with_meta(
id: &str,
modalities: HashMap<Modality, ModalityEmbedding>,
meta: HashMap<String, String>,
) -> MultiModalDocument {
MultiModalDocument::new(id, modalities, meta, 42).expect("doc creation failed")
}
fn text_emb(v: Vec<f64>) -> ModalityEmbedding {
ModalityEmbedding::new(Modality::Text, v)
}
fn image_emb(v: Vec<f64>) -> ModalityEmbedding {
ModalityEmbedding::new(Modality::Image, v)
}
fn audio_emb(v: Vec<f64>) -> ModalityEmbedding {
ModalityEmbedding::new(Modality::Audio, v)
}
fn unit(n: usize, pos: usize) -> Vec<f64> {
let mut v = vec![0.0; n];
v[pos] = 1.0;
v
}
fn query_same(modality: Modality, embedding: Vec<f64>, k: usize) -> CrossModalQuery {
CrossModalQuery::new(modality, embedding, vec![modality], k)
}
#[test]
fn test_modality_hash_eq() {
let mut map = HashMap::new();
map.insert(Modality::Text, 1_usize);
map.insert(Modality::Image, 2_usize);
assert_eq!(map[&Modality::Text], 1);
assert_eq!(map[&Modality::Image], 2);
assert_ne!(Modality::Text, Modality::Image);
}
#[test]
fn test_all_modalities_distinct() {
let all = [
Modality::Text,
Modality::Image,
Modality::Audio,
Modality::Video,
Modality::Structured,
];
for i in 0..all.len() {
for j in 0..all.len() {
if i != j {
assert_ne!(all[i], all[j], "modalities {i} and {j} should differ");
}
}
}
}
#[test]
fn test_modality_embedding_dims() {
let emb = ModalityEmbedding::new(Modality::Audio, vec![1.0, 2.0, 3.0]);
assert_eq!(emb.dims, 3);
assert_eq!(emb.embedding.len(), 3);
assert_eq!(emb.modality, Modality::Audio);
}
#[test]
fn test_empty_document_rejected() {
let result = MultiModalDocument::new("id", HashMap::new(), HashMap::new(), 0);
assert_eq!(result, Err(MmiError::EmptyDocument));
}
#[test]
fn test_document_creation_with_metadata() {
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(vec![0.5; 4]));
let mut meta = HashMap::new();
meta.insert("author".to_string(), "alice".to_string());
let doc = MultiModalDocument::new("doc1", mods, meta, 1000).expect("should succeed");
assert_eq!(doc.id, "doc1");
assert_eq!(doc.indexed_at, 1000);
assert_eq!(
doc.metadata.get("author").map(String::as_str),
Some("alice")
);
}
#[test]
fn test_new_index_is_empty() {
let idx = MultiModalIndex::new(MultiModalIndexConfig::default());
assert_eq!(idx.doc_count(), 0);
assert!(idx.modality_coverage().is_empty());
}
#[test]
fn test_add_document_success() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(vec![1.0, 0.0, 0.0]));
let doc = make_doc("a", mods);
idx.add_document(doc).expect("should succeed");
assert_eq!(idx.doc_count(), 1);
}
#[test]
fn test_add_duplicate_document_error() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(vec![1.0, 0.0, 0.0]));
let doc1 = make_doc("dup", mods.clone());
let doc2 = make_doc("dup", mods);
idx.add_document(doc1).expect("first insert ok");
let err = idx.add_document(doc2).expect_err("second should fail");
assert!(matches!(err, MmiError::DocumentAlreadyExists(ref id) if id == "dup"));
}
#[test]
fn test_remove_document() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(vec![1.0, 0.0]));
let doc = make_doc("rem", mods);
idx.add_document(doc).expect("ok");
assert!(idx.remove_document("rem"));
assert_eq!(idx.doc_count(), 0);
assert!(!idx.remove_document("rem")); }
#[test]
fn test_modality_coverage() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let mut mods1 = HashMap::new();
mods1.insert(Modality::Text, text_emb(vec![1.0, 0.0]));
mods1.insert(Modality::Image, image_emb(vec![0.0, 1.0]));
idx.add_document(make_doc("d1", mods1)).expect("ok");
let mut mods2 = HashMap::new();
mods2.insert(Modality::Text, text_emb(vec![0.5, 0.5]));
idx.add_document(make_doc("d2", mods2)).expect("ok");
let cov = idx.modality_coverage();
assert_eq!(cov[&Modality::Text], 2);
assert_eq!(cov[&Modality::Image], 1);
assert!(!cov.contains_key(&Modality::Audio));
}
#[test]
fn test_cosine_similarity_identical() {
let v = vec![1.0, 2.0, 3.0];
let sim = MultiModalIndex::cosine_similarity(&v, &v);
assert!((sim - 1.0).abs() < 1e-10);
}
#[test]
fn test_cosine_similarity_orthogonal() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![0.0, 1.0, 0.0];
let sim = MultiModalIndex::cosine_similarity(&a, &b);
assert!(sim.abs() < 1e-10);
}
#[test]
fn test_cosine_similarity_opposite() {
let a = vec![1.0, 0.0];
let b = vec![-1.0, 0.0];
let sim = MultiModalIndex::cosine_similarity(&a, &b);
assert!((sim - (-1.0)).abs() < 1e-10);
}
#[test]
fn test_cosine_similarity_zero_vector() {
let a = vec![0.0, 0.0, 0.0];
let b = vec![1.0, 2.0, 3.0];
let sim = MultiModalIndex::cosine_similarity(&a, &b);
assert_eq!(sim, 0.0);
}
#[test]
fn test_cosine_similarity_mismatched_len() {
let a = vec![1.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
let sim = MultiModalIndex::cosine_similarity(&a, &b);
assert_eq!(sim, 0.0);
}
#[test]
fn test_l2_normalize() {
let v = vec![3.0, 4.0];
let n = MultiModalIndex::l2_normalize(&v);
assert!((n[0] - 0.6).abs() < 1e-10);
assert!((n[1] - 0.8).abs() < 1e-10);
}
#[test]
fn test_l2_normalize_zero_vec() {
let v = vec![0.0, 0.0];
let n = MultiModalIndex::l2_normalize(&v);
assert_eq!(n, vec![0.0, 0.0]);
}
#[test]
fn test_project_identity() {
let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let emb = vec![3.0, 7.0];
let out = MultiModalIndex::project(&emb, &matrix);
assert_eq!(out, vec![3.0, 7.0]);
}
#[test]
fn test_project_dimensions() {
let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
let emb = vec![2.0, 3.0];
let out = MultiModalIndex::project(&emb, &matrix);
assert_eq!(out.len(), 3);
assert!((out[2] - 5.0).abs() < 1e-10);
}
#[test]
fn test_add_projection_success() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
assert!(idx
.add_projection(Modality::Text, Modality::Image, matrix)
.is_ok());
}
#[test]
fn test_add_projection_empty_matrix() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let err = idx
.add_projection(Modality::Text, Modality::Image, vec![])
.expect_err("empty should fail");
assert_eq!(err, MmiError::ProjectionDimsMismatch);
}
#[test]
fn test_add_projection_ragged_matrix() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let matrix = vec![vec![1.0, 0.0], vec![0.0]];
let err = idx
.add_projection(Modality::Text, Modality::Image, matrix)
.expect_err("ragged should fail");
assert_eq!(err, MmiError::ProjectionDimsMismatch);
}
#[test]
fn test_add_projection_zero_cols() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let matrix = vec![vec![]];
let err = idx
.add_projection(Modality::Text, Modality::Image, matrix)
.expect_err("zero cols should fail");
assert_eq!(err, MmiError::ProjectionDimsMismatch);
}
#[test]
fn test_same_modality_search_basic() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods1 = HashMap::new();
mods1.insert(Modality::Text, text_emb(unit(3, 0)));
let mut mods2 = HashMap::new();
mods2.insert(Modality::Text, text_emb(unit(3, 1)));
let mut mods3 = HashMap::new();
mods3.insert(Modality::Text, text_emb(unit(3, 2)));
idx.add_document(make_doc("a", mods1)).expect("ok");
idx.add_document(make_doc("b", mods2)).expect("ok");
idx.add_document(make_doc("c", mods3)).expect("ok");
let q = query_same(Modality::Text, unit(3, 0), 1);
let results = idx.same_modality_search(&q);
assert_eq!(results.len(), 1);
assert_eq!(results[0].doc_id, "a");
assert!((results[0].combined_score - 1.0).abs() < 1e-10);
assert_eq!(results[0].rank, 1);
}
#[test]
fn test_same_modality_search_top_k() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
for i in 0..10_usize {
let mut mods = HashMap::new();
let mut v = vec![0.0; 4];
v[0] = 1.0 - i as f64 * 0.05;
v[1] = (i as f64 * 0.05).max(0.0);
mods.insert(Modality::Text, text_emb(v));
idx.add_document(make_doc(&i.to_string(), mods))
.expect("ok");
}
let q = query_same(Modality::Text, unit(4, 0), 3);
let results = idx.same_modality_search(&q);
assert_eq!(results.len(), 3);
assert_eq!(results[0].rank, 1);
assert_eq!(results[1].rank, 2);
assert_eq!(results[2].rank, 3);
assert!(results[0].combined_score >= results[1].combined_score);
assert!(results[1].combined_score >= results[2].combined_score);
}
#[test]
fn test_same_modality_search_min_similarity_filter() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods1 = HashMap::new();
mods1.insert(Modality::Text, text_emb(unit(3, 0)));
let mut mods2 = HashMap::new();
mods2.insert(Modality::Text, text_emb(unit(3, 1)));
idx.add_document(make_doc("d1", mods1)).expect("ok");
idx.add_document(make_doc("d2", mods2)).expect("ok");
let mut q = query_same(Modality::Text, unit(3, 0), 10);
q.min_similarity = 0.5;
let results = idx.same_modality_search(&q);
assert_eq!(results.len(), 1);
assert_eq!(results[0].doc_id, "d1");
}
#[test]
fn test_search_same_modality_in_search() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(unit(3, 0)));
idx.add_document(make_doc("t1", mods)).expect("ok");
let q = CrossModalQuery::new(Modality::Text, unit(3, 0), vec![Modality::Text], 5);
let results = idx.search(&q);
assert_eq!(results.len(), 1);
assert!((results[0].combined_score - 1.0).abs() < 1e-10);
}
#[test]
fn test_search_cross_modal_with_projection() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Image, image_emb(unit(4, 0)));
idx.add_document(make_doc("img1", mods)).expect("ok");
let identity = vec![
vec![1.0, 0.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0, 0.0],
vec![0.0, 0.0, 1.0, 0.0],
vec![0.0, 0.0, 0.0, 1.0],
];
idx.add_projection(Modality::Text, Modality::Image, identity)
.expect("projection ok");
let q = CrossModalQuery::new(Modality::Text, unit(4, 0), vec![Modality::Image], 5);
let results = idx.search(&q);
assert_eq!(results.len(), 1);
assert!((results[0].combined_score - 1.0).abs() < 1e-10);
}
#[test]
fn test_search_no_projection_skips_cross_modal() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Image, image_emb(unit(4, 0)));
idx.add_document(make_doc("img1", mods)).expect("ok");
let q = CrossModalQuery::new(Modality::Text, unit(4, 0), vec![Modality::Image], 5);
let results = idx.search(&q);
assert!(results.is_empty());
}
#[test]
fn test_search_multimodal_fusion_mean() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods1 = HashMap::new();
mods1.insert(Modality::Text, text_emb(unit(2, 0)));
let mut mods2 = HashMap::new();
mods2.insert(Modality::Text, text_emb(unit(2, 1)));
idx.add_document(make_doc("d1", mods1)).expect("ok");
idx.add_document(make_doc("d2", mods2)).expect("ok");
let q = CrossModalQuery::new(Modality::Text, unit(2, 0), vec![Modality::Text], 5);
let results = idx.search(&q);
assert_eq!(results.len(), 2);
let r1 = results.iter().find(|r| r.doc_id == "d1").expect("d1");
let r2 = results.iter().find(|r| r.doc_id == "d2").expect("d2");
assert!((r1.combined_score - 1.0).abs() < 1e-10);
assert!(r2.combined_score.abs() < 1e-10);
assert!((r1.combined_score - r1.scores[&Modality::Text]).abs() < 1e-10);
}
#[test]
fn test_fusion_max_score() {
let strategy = FusionStrategy::MaxScore;
let mut scores = HashMap::new();
scores.insert(Modality::Text, 0.3);
scores.insert(Modality::Image, 0.8);
scores.insert(Modality::Audio, 0.5);
let fused = strategy.fuse(&scores).expect("some");
assert!((fused - 0.8).abs() < 1e-10);
}
#[test]
fn test_fusion_mean_score() {
let strategy = FusionStrategy::MeanScore;
let mut scores = HashMap::new();
scores.insert(Modality::Text, 0.6);
scores.insert(Modality::Image, 0.4);
let fused = strategy.fuse(&scores).expect("some");
assert!((fused - 0.5).abs() < 1e-10);
}
#[test]
fn test_fusion_weighted() {
let mut weights = HashMap::new();
weights.insert(Modality::Text, 3.0);
weights.insert(Modality::Image, 1.0);
let strategy = FusionStrategy::WeightedFusion { weights };
let mut scores = HashMap::new();
scores.insert(Modality::Text, 1.0);
scores.insert(Modality::Image, 0.0);
let fused = strategy.fuse(&scores).expect("some");
assert!((fused - 0.75).abs() < 1e-10);
}
#[test]
fn test_fusion_weighted_missing_modality_uses_mean_fallback() {
let mut weights = HashMap::new();
weights.insert(Modality::Audio, 2.0);
let strategy = FusionStrategy::WeightedFusion { weights };
let mut scores = HashMap::new();
scores.insert(Modality::Text, 0.4);
scores.insert(Modality::Image, 0.6);
let fused = strategy.fuse(&scores).expect("some");
assert!((fused - 0.5).abs() < 1e-10);
}
#[test]
fn test_fusion_text_primary_uses_text_when_present() {
let strategy = FusionStrategy::TextPrimary;
let mut scores = HashMap::new();
scores.insert(Modality::Text, 0.7);
scores.insert(Modality::Image, 0.9);
let fused = strategy.fuse(&scores).expect("some");
assert!((fused - 0.7).abs() < 1e-10);
}
#[test]
fn test_fusion_text_primary_falls_back_to_max() {
let strategy = FusionStrategy::TextPrimary;
let mut scores = HashMap::new();
scores.insert(Modality::Image, 0.6);
scores.insert(Modality::Audio, 0.9);
let fused = strategy.fuse(&scores).expect("some");
assert!((fused - 0.9).abs() < 1e-10);
}
#[test]
fn test_fusion_empty_scores_returns_none() {
let strategy = FusionStrategy::MeanScore;
assert!(strategy.fuse(&HashMap::new()).is_none());
}
#[test]
fn test_metadata_filter_match() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut meta1 = HashMap::new();
meta1.insert("type".to_string(), "news".to_string());
let mut mods1 = HashMap::new();
mods1.insert(Modality::Text, text_emb(unit(3, 0)));
idx.add_document(make_doc_with_meta("n1", mods1, meta1))
.expect("ok");
let mut meta2 = HashMap::new();
meta2.insert("type".to_string(), "blog".to_string());
let mut mods2 = HashMap::new();
mods2.insert(Modality::Text, text_emb(unit(3, 0)));
idx.add_document(make_doc_with_meta("b1", mods2, meta2))
.expect("ok");
let mut filters = HashMap::new();
filters.insert("type".to_string(), "news".to_string());
let mut q = query_same(Modality::Text, unit(3, 0), 10);
q.filters = filters;
let results = idx.same_modality_search(&q);
assert_eq!(results.len(), 1);
assert_eq!(results[0].doc_id, "n1");
}
#[test]
fn test_metadata_filter_no_match_returns_empty() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(unit(3, 0)));
idx.add_document(make_doc("d1", mods)).expect("ok");
let mut filters = HashMap::new();
filters.insert("nonexistent".to_string(), "value".to_string());
let mut q = query_same(Modality::Text, unit(3, 0), 10);
q.filters = filters;
let results = idx.search(&q);
assert!(results.is_empty());
}
#[test]
fn test_stats_empty_index() {
let idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let stats = idx.stats();
assert_eq!(stats.doc_count, 0);
assert_eq!(stats.total_searches, 0);
assert_eq!(stats.avg_modalities_per_doc, 0.0);
}
#[test]
fn test_stats_search_counter_increments() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(unit(3, 0)));
idx.add_document(make_doc("x", mods)).expect("ok");
let q = query_same(Modality::Text, unit(3, 0), 5);
idx.search(&q);
idx.search(&q);
idx.same_modality_search(&q);
let stats = idx.stats();
assert_eq!(stats.total_searches, 3);
}
#[test]
fn test_stats_avg_modalities_per_doc() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let mut mods1 = HashMap::new();
mods1.insert(Modality::Text, text_emb(vec![1.0]));
mods1.insert(Modality::Image, image_emb(vec![1.0]));
idx.add_document(make_doc("d1", mods1)).expect("ok");
let mut mods2 = HashMap::new();
mods2.insert(Modality::Audio, audio_emb(vec![1.0]));
idx.add_document(make_doc("d2", mods2)).expect("ok");
let stats = idx.stats();
assert_eq!(stats.doc_count, 2);
assert!((stats.avg_modalities_per_doc - 1.5).abs() < 1e-10);
}
#[test]
fn test_search_empty_index() {
let idx = MultiModalIndex::new(MultiModalIndexConfig::default());
let q = query_same(Modality::Text, unit(3, 0), 5);
let results = idx.search(&q);
assert!(results.is_empty());
}
#[test]
fn test_search_dim_mismatch_skips_document() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(unit(3, 0)));
idx.add_document(make_doc("dim_mismatch", mods))
.expect("ok");
let q = query_same(Modality::Text, unit(2, 0), 5);
let results = idx.same_modality_search(&q);
assert!(results.is_empty());
}
#[test]
fn test_search_scores_contain_matched_modalities() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(unit(3, 0)));
mods.insert(Modality::Image, image_emb(unit(3, 1)));
idx.add_document(make_doc("multi", mods)).expect("ok");
let q = CrossModalQuery::new(
Modality::Text,
unit(3, 0),
vec![Modality::Text, Modality::Image],
5,
);
let results = idx.search(&q);
assert_eq!(results.len(), 1);
assert!(results[0].scores.contains_key(&Modality::Text));
}
#[test]
fn test_search_multimodal_fusion_mean_cross_modal() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(unit(2, 0)));
mods.insert(Modality::Image, image_emb(unit(2, 1)));
idx.add_document(make_doc("mm", mods)).expect("ok");
let identity = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
idx.add_projection(Modality::Text, Modality::Image, identity)
.expect("ok");
let q = CrossModalQuery::new(
Modality::Text,
unit(2, 0),
vec![Modality::Text, Modality::Image],
5,
);
let results = idx.search(&q);
assert_eq!(results.len(), 1);
assert!((results[0].combined_score - 0.5).abs() < 1e-10);
}
#[test]
fn test_cross_modal_result_rank_assignment() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MeanScore));
for i in 0..5_usize {
let mut mods = HashMap::new();
let mut v = unit(4, 0);
v[1] = i as f64 * 0.01; mods.insert(Modality::Text, text_emb(v));
idx.add_document(make_doc(&format!("r{i}"), mods))
.expect("ok");
}
let q = query_same(Modality::Text, unit(4, 0), 5);
let results = idx.search(&q);
for (i, r) in results.iter().enumerate() {
assert_eq!(r.rank, i + 1);
}
}
#[test]
fn test_mmi_error_display() {
let e1 = MmiError::DocumentAlreadyExists("id1".into());
let e2 = MmiError::DocumentNotFound("id2".into());
let e3 = MmiError::EmptyDocument;
let e4 = MmiError::ProjectionDimsMismatch;
let e5 = MmiError::NoMatchingModality;
assert!(e1.to_string().contains("id1"));
assert!(e2.to_string().contains("id2"));
assert!(!e3.to_string().is_empty());
assert!(!e4.to_string().is_empty());
assert!(!e5.to_string().is_empty());
}
#[test]
fn test_mmi_stats_modality_counts() {
let mut idx = MultiModalIndex::new(MultiModalIndexConfig::default());
for i in 0..3_usize {
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(vec![i as f64]));
idx.add_document(make_doc(&i.to_string(), mods))
.expect("ok");
}
let stats: MmiStats = idx.stats();
assert_eq!(*stats.modality_counts.get(&Modality::Text).unwrap_or(&0), 3);
}
#[test]
fn test_normalization_flag_off() {
let config = MultiModalIndexConfig {
fusion_strategy: FusionStrategy::MeanScore,
cross_modal_dims: 4,
normalize_embeddings: false,
};
let mut idx = MultiModalIndex::new(config);
let mut mods = HashMap::new();
mods.insert(Modality::Text, text_emb(vec![2.0, 0.0, 0.0]));
idx.add_document(make_doc("nn", mods)).expect("ok");
let q = query_same(Modality::Text, vec![2.0, 0.0, 0.0], 5);
let results = idx.search(&q);
assert_eq!(results.len(), 1);
assert!((results[0].combined_score - 1.0).abs() < 1e-10);
}
#[test]
fn test_structured_and_video_modalities() {
let mut idx = MultiModalIndex::new(make_config(FusionStrategy::MaxScore));
let mut mods = HashMap::new();
mods.insert(
Modality::Structured,
ModalityEmbedding::new(Modality::Structured, unit(4, 0)),
);
mods.insert(
Modality::Video,
ModalityEmbedding::new(Modality::Video, unit(4, 1)),
);
idx.add_document(make_doc("sv", mods)).expect("ok");
let q = CrossModalQuery::new(
Modality::Structured,
unit(4, 0),
vec![Modality::Structured, Modality::Video],
5,
);
let results = idx.search(&q);
assert_eq!(results.len(), 1);
assert!((results[0].combined_score - 1.0).abs() < 1e-10);
}
}