use crate::engine::{EngineConfig, InferenceEngine};
use crate::error::{InferenceError, InferenceResult};
use scirs2_core::ndarray::{Array1, Array2, Axis};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ModalityType {
Audio,
Video,
Sensor,
Text,
Custom(&'static str),
}
impl ModalityType {
pub fn name(&self) -> &str {
match self {
ModalityType::Audio => "audio",
ModalityType::Video => "video",
ModalityType::Sensor => "sensor",
ModalityType::Text => "text",
ModalityType::Custom(name) => name,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FusionStrategy {
#[default]
EarlyFusion,
LateFusion,
WeightedFusion,
MaxPooling,
CrossAttention,
Hierarchical,
}
pub type ModalityPreprocessor =
Arc<dyn Fn(&Array1<f32>) -> InferenceResult<Array1<f32>> + Send + Sync>;
#[derive(Clone)]
pub struct ModalityConfig {
pub modality_type: ModalityType,
pub input_dim: usize,
pub preprocessor: Option<ModalityPreprocessor>,
pub fusion_weight: f32,
}
impl ModalityConfig {
pub fn new(modality_type: ModalityType, input_dim: usize) -> Self {
Self {
modality_type,
input_dim,
preprocessor: None,
fusion_weight: 1.0,
}
}
pub fn preprocessor(mut self, preprocessor: ModalityPreprocessor) -> Self {
self.preprocessor = Some(preprocessor);
self
}
pub fn fusion_weight(mut self, weight: f32) -> Self {
self.fusion_weight = weight;
self
}
}
pub struct MultiModalPipeline {
engine: InferenceEngine,
modalities: HashMap<ModalityType, ModalityConfig>,
fusion_strategy: FusionStrategy,
#[allow(dead_code)]
total_input_dim: usize,
}
impl MultiModalPipeline {
pub fn builder() -> MultiModalPipelineBuilder {
MultiModalPipelineBuilder::new()
}
pub fn forward(
&mut self,
inputs: &[(ModalityType, Array1<f32>)],
) -> InferenceResult<Array1<f32>> {
for (modality, input) in inputs {
let config = self.modalities.get(modality).ok_or_else(|| {
InferenceError::PipelineConfig(format!("Unknown modality: {:?}", modality))
})?;
if input.len() != config.input_dim {
return Err(InferenceError::DimensionMismatch {
expected: config.input_dim,
got: input.len(),
});
}
}
let mut preprocessed: HashMap<ModalityType, Array1<f32>> = HashMap::new();
for (modality, input) in inputs {
let config = &self.modalities[modality];
let processed = if let Some(ref preprocessor) = config.preprocessor {
preprocessor(input)?
} else {
input.clone()
};
preprocessed.insert(*modality, processed);
}
let fused = self.fuse(&preprocessed)?;
self.engine.step(&fused)
}
fn fuse(
&mut self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
match self.fusion_strategy {
FusionStrategy::EarlyFusion => self.early_fusion(inputs),
FusionStrategy::LateFusion => self.late_fusion(inputs),
FusionStrategy::WeightedFusion => self.weighted_fusion(inputs),
FusionStrategy::MaxPooling => self.max_pooling_fusion(inputs),
FusionStrategy::CrossAttention => self.cross_attention_fusion(inputs),
FusionStrategy::Hierarchical => self.hierarchical_fusion(inputs),
}
}
fn early_fusion(
&self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
let mut result = Vec::new();
let mut sorted_modalities: Vec<_> = inputs.keys().collect();
sorted_modalities.sort_by_key(|m| m.name());
for modality in sorted_modalities {
let input = &inputs[modality];
let slice = input.as_slice().ok_or_else(|| {
InferenceError::ForwardError(
"Array data not contiguous in early fusion".to_string(),
)
})?;
result.extend_from_slice(slice);
}
Ok(Array1::from_vec(result))
}
fn late_fusion(
&mut self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
if inputs.is_empty() {
return Err(InferenceError::PipelineConfig(
"No modalities to fuse".into(),
));
}
let mut by_dim: std::collections::HashMap<usize, Vec<Array1<f32>>> =
std::collections::HashMap::new();
for input in inputs.values() {
by_dim.entry(input.len()).or_default().push(input.clone());
}
let mut result = Vec::new();
let mut dims: Vec<_> = by_dim.keys().cloned().collect();
dims.sort();
for dim in dims {
let arrays = &by_dim[&dim];
let mut averaged = Array1::zeros(dim);
for arr in arrays {
averaged += arr;
}
averaged /= arrays.len() as f32;
let slice = averaged.as_slice().ok_or_else(|| {
InferenceError::ForwardError("Array data not contiguous in late fusion".to_string())
})?;
result.extend_from_slice(slice);
}
Ok(Array1::from_vec(result))
}
fn weighted_fusion(
&self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
let mut result = Vec::new();
let mut total_weight = 0.0;
for (modality, input) in inputs {
let config = &self.modalities[modality];
let weight = config.fusion_weight;
total_weight += weight;
let weighted = input.mapv(|x| x * weight);
let slice = weighted.as_slice().ok_or_else(|| {
InferenceError::ForwardError(
"Array data not contiguous in weighted fusion".to_string(),
)
})?;
result.extend_from_slice(slice);
}
let normalized: Vec<f32> = result.iter().map(|x| x / total_weight).collect();
Ok(Array1::from_vec(normalized))
}
fn max_pooling_fusion(
&self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
if inputs.is_empty() {
return Err(InferenceError::PipelineConfig(
"No modalities to fuse".into(),
));
}
let mut by_dim: std::collections::HashMap<usize, Vec<Array1<f32>>> =
std::collections::HashMap::new();
for input in inputs.values() {
by_dim.entry(input.len()).or_default().push(input.clone());
}
let mut result = Vec::new();
let mut dims: Vec<_> = by_dim.keys().cloned().collect();
dims.sort();
for dim in dims {
let arrays = &by_dim[&dim];
if arrays.len() == 1 {
let slice = arrays[0].as_slice().ok_or_else(|| {
InferenceError::ForwardError(
"Array data not contiguous in max pooling".to_string(),
)
})?;
result.extend_from_slice(slice);
} else {
let nrows = arrays.len();
let ncols = dim;
let mut stacked = Array2::zeros((nrows, ncols));
for (i, arr) in arrays.iter().enumerate() {
for (j, &val) in arr.iter().enumerate() {
stacked[[i, j]] = val;
}
}
let pooled = stacked.map_axis(Axis(0), |col| {
col.iter().cloned().fold(f32::NEG_INFINITY, f32::max)
});
let slice = pooled.as_slice().ok_or_else(|| {
InferenceError::ForwardError(
"Array data not contiguous in max pooling result".to_string(),
)
})?;
result.extend_from_slice(slice);
}
}
Ok(Array1::from_vec(result))
}
fn cross_attention_fusion(
&self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
if inputs.is_empty() {
return Err(InferenceError::PipelineConfig(
"No modalities to fuse".into(),
));
}
if inputs.len() == 1 {
let single_input = inputs.values().next().ok_or_else(|| {
InferenceError::ForwardError("No input found in hierarchical fusion".to_string())
})?;
return Ok(single_input.clone());
}
let mut by_dim: std::collections::HashMap<usize, Vec<Array1<f32>>> =
std::collections::HashMap::new();
for input in inputs.values() {
by_dim.entry(input.len()).or_default().push(input.clone());
}
let mut result = Vec::new();
let mut dims: Vec<_> = by_dim.keys().cloned().collect();
dims.sort();
for dim in dims {
let modalities = &by_dim[&dim];
let n = modalities.len();
if n == 1 {
let slice = modalities[0].as_slice().ok_or_else(|| {
InferenceError::ForwardError(
"Array data not contiguous in hierarchical fusion".to_string(),
)
})?;
result.extend_from_slice(slice);
} else {
let mut attention_weights = vec![0.0; n];
for i in 0..n {
for j in 0..n {
if i != j {
let dot_product: f32 = modalities[i]
.iter()
.zip(modalities[j].iter())
.map(|(a, b)| a * b)
.sum();
attention_weights[i] += dot_product.abs();
}
}
}
let total: f32 = attention_weights.iter().sum();
if total > 0.0 {
for weight in &mut attention_weights {
*weight /= total;
}
} else {
let uniform = 1.0 / n as f32;
attention_weights.fill(uniform);
}
let mut weighted_result = Array1::zeros(dim);
for (i, modality) in modalities.iter().enumerate() {
weighted_result += &(modality * attention_weights[i]);
}
let slice = weighted_result.as_slice().ok_or_else(|| {
InferenceError::ForwardError(
"Array data not contiguous in cross-attention result".to_string(),
)
})?;
result.extend_from_slice(slice);
}
}
Ok(Array1::from_vec(result))
}
fn hierarchical_fusion(
&mut self,
inputs: &HashMap<ModalityType, Array1<f32>>,
) -> InferenceResult<Array1<f32>> {
let early = self.early_fusion(inputs)?;
let weighted = self.weighted_fusion(inputs)?;
if early.len() != weighted.len() {
return Err(InferenceError::PipelineConfig(format!(
"Fusion dimension mismatch: early={}, weighted={}",
early.len(),
weighted.len()
)));
}
let result = (early + weighted) / 2.0;
Ok(result)
}
pub fn reset(&mut self) {
self.engine.reset();
}
pub fn fusion_strategy(&self) -> FusionStrategy {
self.fusion_strategy
}
pub fn modalities(&self) -> &HashMap<ModalityType, ModalityConfig> {
&self.modalities
}
pub fn engine(&self) -> &InferenceEngine {
&self.engine
}
pub fn engine_mut(&mut self) -> &mut InferenceEngine {
&mut self.engine
}
}
pub struct MultiModalPipelineBuilder {
engine_config: Option<EngineConfig>,
modalities: HashMap<ModalityType, ModalityConfig>,
fusion_strategy: FusionStrategy,
}
impl MultiModalPipelineBuilder {
pub fn new() -> Self {
Self {
engine_config: None,
modalities: HashMap::new(),
fusion_strategy: FusionStrategy::default(),
}
}
pub fn engine_config(mut self, config: EngineConfig) -> Self {
self.engine_config = Some(config);
self
}
pub fn add_modality(mut self, config: ModalityConfig) -> Self {
self.modalities.insert(config.modality_type, config);
self
}
pub fn modality(mut self, modality_type: ModalityType, input_dim: usize) -> Self {
let config = ModalityConfig::new(modality_type, input_dim);
self.modalities.insert(modality_type, config);
self
}
pub fn fusion_strategy(mut self, strategy: FusionStrategy) -> Self {
self.fusion_strategy = strategy;
self
}
pub fn build(self) -> InferenceResult<MultiModalPipeline> {
if self.modalities.is_empty() {
return Err(InferenceError::PipelineConfig(
"At least one modality must be configured".into(),
));
}
let total_input_dim: usize = self.modalities.values().map(|c| c.input_dim).sum();
let engine_config = self
.engine_config
.ok_or_else(|| InferenceError::PipelineConfig("engine_config not set".into()))?;
let engine = InferenceEngine::new(engine_config);
Ok(MultiModalPipeline {
engine,
modalities: self.modalities,
fusion_strategy: self.fusion_strategy,
total_input_dim,
})
}
}
impl Default for MultiModalPipelineBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use kizzasi_model::s4::{S4Config, S4D};
fn create_test_model(input_dim: usize, _output_dim: usize) -> Box<S4D> {
let config = S4Config::new()
.input_dim(input_dim)
.hidden_dim(32)
.state_dim(16)
.num_layers(1)
.diagonal(true);
Box::new(S4D::new(config).unwrap())
}
#[test]
fn test_modality_type_name() {
assert_eq!(ModalityType::Audio.name(), "audio");
assert_eq!(ModalityType::Video.name(), "video");
assert_eq!(ModalityType::Sensor.name(), "sensor");
assert_eq!(ModalityType::Text.name(), "text");
assert_eq!(ModalityType::Custom("xyz").name(), "xyz");
}
#[test]
fn test_multimodal_builder() {
let engine_config = EngineConfig::new(6, 10);
let pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 3)
.modality(ModalityType::Video, 3)
.fusion_strategy(FusionStrategy::EarlyFusion)
.build();
assert!(pipeline.is_ok());
let p = pipeline.unwrap();
assert_eq!(p.modalities().len(), 2);
assert_eq!(p.total_input_dim, 6);
}
#[test]
fn test_multimodal_no_modalities() {
let engine_config = EngineConfig::new(3, 10);
let result = MultiModalPipeline::builder()
.engine_config(engine_config)
.build();
assert!(result.is_err());
}
#[test]
fn test_early_fusion() {
let engine_config = EngineConfig::new(6, 6);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 3)
.modality(ModalityType::Video, 3)
.fusion_strategy(FusionStrategy::EarlyFusion)
.build()
.unwrap();
pipeline.engine_mut().set_model(create_test_model(6, 6));
let audio = Array1::from_vec(vec![0.1, 0.2, 0.3]);
let video = Array1::from_vec(vec![0.4, 0.5, 0.6]);
let result =
pipeline.forward(&[(ModalityType::Audio, audio), (ModalityType::Video, video)]);
result.unwrap(); }
#[test]
fn test_weighted_fusion() {
let engine_config = EngineConfig::new(4, 4);
let audio_config = ModalityConfig::new(ModalityType::Audio, 2).fusion_weight(2.0);
let video_config = ModalityConfig::new(ModalityType::Video, 2).fusion_weight(1.0);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.add_modality(audio_config)
.add_modality(video_config)
.fusion_strategy(FusionStrategy::WeightedFusion)
.build()
.unwrap();
pipeline.engine_mut().set_model(create_test_model(4, 4));
let audio = Array1::from_vec(vec![0.3, 0.6]);
let video = Array1::from_vec(vec![0.1, 0.2]);
let result =
pipeline.forward(&[(ModalityType::Audio, audio), (ModalityType::Video, video)]);
assert!(result.is_ok());
}
#[test]
fn test_dimension_mismatch() {
let engine_config = EngineConfig::new(6, 10);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 3)
.modality(ModalityType::Video, 3)
.build()
.unwrap();
let audio = Array1::from_vec(vec![0.1, 0.2]); let video = Array1::from_vec(vec![0.4, 0.5, 0.6]);
let result =
pipeline.forward(&[(ModalityType::Audio, audio), (ModalityType::Video, video)]);
assert!(result.is_err());
}
#[test]
fn test_unknown_modality() {
let engine_config = EngineConfig::new(3, 10);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 3)
.build()
.unwrap();
let video = Array1::from_vec(vec![0.4, 0.5, 0.6]);
let result = pipeline.forward(&[(ModalityType::Video, video)]);
assert!(result.is_err());
}
#[test]
fn test_max_pooling_fusion() {
let engine_config = EngineConfig::new(3, 3);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 3)
.modality(ModalityType::Video, 3)
.fusion_strategy(FusionStrategy::MaxPooling)
.build()
.unwrap();
pipeline.engine_mut().set_model(create_test_model(3, 3));
let audio = Array1::from_vec(vec![0.1, 0.9, 0.3]);
let video = Array1::from_vec(vec![0.8, 0.2, 0.6]);
let result =
pipeline.forward(&[(ModalityType::Audio, audio), (ModalityType::Video, video)]);
assert!(result.is_ok());
}
#[test]
fn test_cross_attention_fusion() {
let engine_config = EngineConfig::new(3, 3);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 3)
.modality(ModalityType::Sensor, 3)
.fusion_strategy(FusionStrategy::CrossAttention)
.build()
.unwrap();
pipeline.engine_mut().set_model(create_test_model(3, 3));
let audio = Array1::from_vec(vec![0.1, 0.2, 0.3]);
let sensor = Array1::from_vec(vec![0.4, 0.5, 0.6]);
let result =
pipeline.forward(&[(ModalityType::Audio, audio), (ModalityType::Sensor, sensor)]);
assert!(result.is_ok());
}
#[test]
fn test_hierarchical_fusion() {
let engine_config = EngineConfig::new(4, 4);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.modality(ModalityType::Audio, 2)
.modality(ModalityType::Text, 2)
.fusion_strategy(FusionStrategy::Hierarchical)
.build()
.unwrap();
pipeline.engine_mut().set_model(create_test_model(4, 4));
let audio = Array1::from_vec(vec![0.1, 0.2]);
let text = Array1::from_vec(vec![0.3, 0.4]);
let result = pipeline.forward(&[(ModalityType::Audio, audio), (ModalityType::Text, text)]);
result.unwrap(); }
#[test]
fn test_modality_preprocessor() {
let engine_config = EngineConfig::new(3, 3);
let preprocessor: ModalityPreprocessor = Arc::new(|input| Ok(input.mapv(|x| x * 2.0)));
let config = ModalityConfig::new(ModalityType::Audio, 3).preprocessor(preprocessor);
let mut pipeline = MultiModalPipeline::builder()
.engine_config(engine_config)
.add_modality(config)
.build()
.unwrap();
pipeline.engine_mut().set_model(create_test_model(3, 3));
let audio = Array1::from_vec(vec![0.1, 0.2, 0.3]);
let result = pipeline.forward(&[(ModalityType::Audio, audio)]);
assert!(result.is_ok());
}
}