1use std::collections::HashMap;
7use thiserror::Error;
8
9#[derive(Debug, Error, PartialEq)]
15pub enum EnsembleError {
16 #[error("insufficient models: needed {needed}, got {got}")]
18 InsufficientModels { needed: usize, got: usize },
19
20 #[error("missing model: {0}")]
22 MissingModel(String),
23
24 #[error("empty outputs in one or more predictions")]
26 EmptyOutputs,
27
28 #[error("weight count mismatch: expected {expected}, got {got}")]
30 WeightCountMismatch { expected: usize, got: usize },
31}
32
33#[derive(Debug, Clone, PartialEq)]
39pub enum EnsembleStrategy {
40 MajorityVote,
42
43 WeightedVote { weights: Vec<f64> },
46
47 MeanAveraging,
49
50 WeightedAveraging { weights: Vec<f64> },
52
53 Stacking { meta_weights: Vec<f64> },
56}
57
58#[derive(Debug, Clone)]
64pub struct ModelPrediction {
65 pub model_id: String,
67 pub outputs: Vec<f64>,
70 pub confidence: f64,
72 pub latency_ms: u64,
74}
75
76#[derive(Debug, Clone)]
78pub struct EnsembleResult {
79 pub final_outputs: Vec<f64>,
81 pub strategy_used: String,
83 pub participating_models: usize,
85 pub avg_confidence: f64,
87 pub avg_latency_ms: f64,
89 pub disagreement: f64,
93}
94
95#[derive(Debug, Clone)]
97pub struct ModelMember {
98 pub model_id: String,
100 pub weight: f64,
103 pub enabled: bool,
105 pub call_count: u64,
107 pub error_count: u64,
109}
110
111#[derive(Debug, Clone)]
113pub struct EnsembleConfig {
114 pub strategy: EnsembleStrategy,
116 pub min_models: usize,
118 pub timeout_ms: u64,
120 pub require_all: bool,
122}
123
124impl Default for EnsembleConfig {
125 fn default() -> Self {
126 Self {
127 strategy: EnsembleStrategy::MeanAveraging,
128 min_models: 1,
129 timeout_ms: 5_000,
130 require_all: false,
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct EnsembleStats {
138 pub total_members: usize,
139 pub enabled_members: usize,
140 pub total_calls: u64,
141 pub total_errors: u64,
142 pub avg_member_error_rate: f64,
144}
145
146#[derive(Debug)]
152pub struct ModelEnsemble {
153 pub config: EnsembleConfig,
154 pub members: Vec<ModelMember>,
155}
156
157impl ModelEnsemble {
158 pub fn new(config: EnsembleConfig) -> Self {
164 Self {
165 config,
166 members: Vec::new(),
167 }
168 }
169
170 pub fn add_member(&mut self, model_id: String, weight: f64) -> &mut Self {
172 self.members.push(ModelMember {
173 model_id,
174 weight,
175 enabled: true,
176 call_count: 0,
177 error_count: 0,
178 });
179 self
180 }
181
182 pub fn enable_member(&mut self, model_id: &str) -> bool {
188 match self.members.iter_mut().find(|m| m.model_id == model_id) {
189 Some(m) => {
190 m.enabled = true;
191 true
192 }
193 None => false,
194 }
195 }
196
197 pub fn disable_member(&mut self, model_id: &str) -> bool {
199 match self.members.iter_mut().find(|m| m.model_id == model_id) {
200 Some(m) => {
201 m.enabled = false;
202 true
203 }
204 None => false,
205 }
206 }
207
208 pub fn record_call(&mut self, model_id: &str, success: bool) {
210 if let Some(m) = self.members.iter_mut().find(|m| m.model_id == model_id) {
211 m.call_count += 1;
212 if !success {
213 m.error_count += 1;
214 }
215 }
216 }
217
218 pub fn member_stats(&self) -> Vec<&ModelMember> {
220 self.members.iter().collect()
221 }
222
223 pub fn stats(&self) -> EnsembleStats {
225 let total_members = self.members.len();
226 let enabled_members = self.members.iter().filter(|m| m.enabled).count();
227 let total_calls: u64 = self.members.iter().map(|m| m.call_count).sum();
228 let total_errors: u64 = self.members.iter().map(|m| m.error_count).sum();
229
230 let rates: Vec<f64> = self
231 .members
232 .iter()
233 .filter(|m| m.call_count > 0)
234 .map(|m| m.error_count as f64 / m.call_count as f64)
235 .collect();
236
237 let avg_member_error_rate = if rates.is_empty() {
238 0.0
239 } else {
240 rates.iter().sum::<f64>() / rates.len() as f64
241 };
242
243 EnsembleStats {
244 total_members,
245 enabled_members,
246 total_calls,
247 total_errors,
248 avg_member_error_rate,
249 }
250 }
251
252 pub fn aggregate(
264 &self,
265 predictions: &[ModelPrediction],
266 ) -> Result<EnsembleResult, EnsembleError> {
267 let member_map: HashMap<&str, (bool, f64)> = self
269 .members
270 .iter()
271 .map(|m| (m.model_id.as_str(), (m.enabled, m.weight)))
272 .collect();
273
274 let active: Vec<&ModelPrediction> = predictions
277 .iter()
278 .filter(|p| {
279 member_map
280 .get(p.model_id.as_str())
281 .is_none_or(|(enabled, _)| *enabled)
282 })
283 .collect();
284
285 if self.config.require_all {
287 let active_ids: std::collections::HashSet<&str> =
288 active.iter().map(|p| p.model_id.as_str()).collect();
289 for member in self.members.iter().filter(|m| m.enabled) {
290 if !active_ids.contains(member.model_id.as_str()) {
291 return Err(EnsembleError::MissingModel(member.model_id.clone()));
292 }
293 }
294 }
295
296 let n = active.len();
298 if n < self.config.min_models {
299 return Err(EnsembleError::InsufficientModels {
300 needed: self.config.min_models,
301 got: n,
302 });
303 }
304
305 for p in &active {
307 if p.outputs.is_empty() {
308 return Err(EnsembleError::EmptyOutputs);
309 }
310 }
311
312 let pred_weights: Vec<f64> = active
314 .iter()
315 .map(|p| member_map.get(p.model_id.as_str()).map_or(1.0, |(_, w)| *w))
316 .collect();
317
318 let avg_confidence = active.iter().map(|p| p.confidence).sum::<f64>() / n as f64;
320 let avg_latency_ms = active.iter().map(|p| p.latency_ms as f64).sum::<f64>() / n as f64;
321
322 match &self.config.strategy {
324 EnsembleStrategy::MajorityVote => {
325 self.majority_vote(&active, avg_confidence, avg_latency_ms)
326 }
327 EnsembleStrategy::WeightedVote { weights } => self.weighted_vote(
328 &active,
329 weights,
330 &pred_weights,
331 avg_confidence,
332 avg_latency_ms,
333 ),
334 EnsembleStrategy::MeanAveraging => {
335 self.mean_averaging(&active, avg_confidence, avg_latency_ms)
336 }
337 EnsembleStrategy::WeightedAveraging { weights } => self.weighted_averaging(
338 &active,
339 weights,
340 &pred_weights,
341 avg_confidence,
342 avg_latency_ms,
343 ),
344 EnsembleStrategy::Stacking { meta_weights } => self.stacking(
345 &active,
346 meta_weights,
347 &pred_weights,
348 avg_confidence,
349 avg_latency_ms,
350 ),
351 }
352 }
353
354 fn majority_vote(
359 &self,
360 active: &[&ModelPrediction],
361 avg_confidence: f64,
362 avg_latency_ms: f64,
363 ) -> Result<EnsembleResult, EnsembleError> {
364 let n_classes = active[0].outputs.len();
365 let mut vote_counts = vec![0u64; n_classes];
366
367 for pred in active {
368 let cls = Self::top_class(&pred.outputs);
369 vote_counts[cls] += 1;
370 }
371
372 let total_votes = active.len() as f64;
373 let final_outputs: Vec<f64> = vote_counts
374 .iter()
375 .map(|&c| c as f64 / total_votes)
376 .collect();
377
378 let max_votes = vote_counts.iter().copied().max().unwrap_or(0);
380 let disagreement = 1.0 - (max_votes as f64 / total_votes);
381
382 Ok(EnsembleResult {
383 final_outputs,
384 strategy_used: "MajorityVote".to_string(),
385 participating_models: active.len(),
386 avg_confidence,
387 avg_latency_ms,
388 disagreement,
389 })
390 }
391
392 fn weighted_vote(
393 &self,
394 active: &[&ModelPrediction],
395 strategy_weights: &[f64],
396 member_weights: &[f64],
397 avg_confidence: f64,
398 avg_latency_ms: f64,
399 ) -> Result<EnsembleResult, EnsembleError> {
400 let effective: Vec<f64> = if strategy_weights.len() == active.len() {
403 strategy_weights.to_vec()
404 } else if !strategy_weights.is_empty() {
405 return Err(EnsembleError::WeightCountMismatch {
406 expected: active.len(),
407 got: strategy_weights.len(),
408 });
409 } else {
410 member_weights.to_vec()
411 };
412
413 let normed = Self::normalize_weights(&effective);
414 let n_classes = active[0].outputs.len();
415 let mut final_outputs = vec![0.0f64; n_classes];
416
417 for (pred, &w) in active.iter().zip(normed.iter()) {
418 for (i, &v) in pred.outputs.iter().enumerate().take(n_classes) {
419 final_outputs[i] += w * v;
420 }
421 }
422
423 let max_val = final_outputs
425 .iter()
426 .copied()
427 .fold(f64::NEG_INFINITY, f64::max);
428 let disagreement = (1.0 - max_val).max(0.0);
429
430 Ok(EnsembleResult {
431 final_outputs,
432 strategy_used: "WeightedVote".to_string(),
433 participating_models: active.len(),
434 avg_confidence,
435 avg_latency_ms,
436 disagreement,
437 })
438 }
439
440 fn mean_averaging(
441 &self,
442 active: &[&ModelPrediction],
443 avg_confidence: f64,
444 avg_latency_ms: f64,
445 ) -> Result<EnsembleResult, EnsembleError> {
446 let n = active.len() as f64;
447 let mean_val: f64 = active.iter().map(|p| p.outputs[0]).sum::<f64>() / n;
448 let disagreement = Self::std_dev(
449 active
450 .iter()
451 .map(|p| p.outputs[0])
452 .collect::<Vec<_>>()
453 .as_slice(),
454 );
455
456 Ok(EnsembleResult {
457 final_outputs: vec![mean_val],
458 strategy_used: "MeanAveraging".to_string(),
459 participating_models: active.len(),
460 avg_confidence,
461 avg_latency_ms,
462 disagreement,
463 })
464 }
465
466 fn weighted_averaging(
467 &self,
468 active: &[&ModelPrediction],
469 strategy_weights: &[f64],
470 member_weights: &[f64],
471 avg_confidence: f64,
472 avg_latency_ms: f64,
473 ) -> Result<EnsembleResult, EnsembleError> {
474 let effective: Vec<f64> = if strategy_weights.len() == active.len() {
475 strategy_weights.to_vec()
476 } else if !strategy_weights.is_empty() {
477 return Err(EnsembleError::WeightCountMismatch {
478 expected: active.len(),
479 got: strategy_weights.len(),
480 });
481 } else {
482 member_weights.to_vec()
483 };
484
485 let normed = Self::normalize_weights(&effective);
486 let weighted_val: f64 = active
487 .iter()
488 .zip(normed.iter())
489 .map(|(p, &w)| p.outputs[0] * w)
490 .sum();
491
492 let disagreement = Self::std_dev(
493 active
494 .iter()
495 .map(|p| p.outputs[0])
496 .collect::<Vec<_>>()
497 .as_slice(),
498 );
499
500 Ok(EnsembleResult {
501 final_outputs: vec![weighted_val],
502 strategy_used: "WeightedAveraging".to_string(),
503 participating_models: active.len(),
504 avg_confidence,
505 avg_latency_ms,
506 disagreement,
507 })
508 }
509
510 fn stacking(
511 &self,
512 active: &[&ModelPrediction],
513 meta_weights: &[f64],
514 member_weights: &[f64],
515 avg_confidence: f64,
516 avg_latency_ms: f64,
517 ) -> Result<EnsembleResult, EnsembleError> {
518 let effective: Vec<f64> = if meta_weights.len() == active.len() {
521 Self::normalize_weights(meta_weights)
522 } else if !meta_weights.is_empty() {
523 return Err(EnsembleError::WeightCountMismatch {
524 expected: active.len(),
525 got: meta_weights.len(),
526 });
527 } else {
528 Self::normalize_weights(member_weights)
529 };
530
531 let out_dim = active[0].outputs.len();
533 let mut final_outputs = vec![0.0f64; out_dim];
534
535 for (pred, &w) in active.iter().zip(effective.iter()) {
536 for (i, &v) in pred.outputs.iter().enumerate().take(out_dim) {
537 final_outputs[i] += w * v;
538 }
539 }
540
541 let scalars: Vec<f64> = active.iter().map(|p| p.outputs[0]).collect();
544 let disagreement = Self::std_dev(&scalars);
545
546 Ok(EnsembleResult {
547 final_outputs,
548 strategy_used: "Stacking".to_string(),
549 participating_models: active.len(),
550 avg_confidence,
551 avg_latency_ms,
552 disagreement,
553 })
554 }
555
556 pub fn top_class(outputs: &[f64]) -> usize {
562 outputs.iter().enumerate().fold(
563 0usize,
564 |best, (i, &v)| {
565 if v > outputs[best] {
566 i
567 } else {
568 best
569 }
570 },
571 )
572 }
573
574 pub fn softmax(logits: &[f64]) -> Vec<f64> {
576 if logits.is_empty() {
577 return Vec::new();
578 }
579 let max = logits.iter().copied().fold(f64::NEG_INFINITY, f64::max);
580 let exps: Vec<f64> = logits.iter().map(|&x| (x - max).exp()).collect();
581 let sum: f64 = exps.iter().sum();
582 if sum == 0.0 {
583 vec![1.0 / logits.len() as f64; logits.len()]
584 } else {
585 exps.iter().map(|&e| e / sum).collect()
586 }
587 }
588
589 pub fn normalize_weights(weights: &[f64]) -> Vec<f64> {
592 if weights.is_empty() {
593 return Vec::new();
594 }
595 let sum: f64 = weights.iter().sum();
596 if sum.abs() < f64::EPSILON {
597 vec![1.0 / weights.len() as f64; weights.len()]
598 } else {
599 weights.iter().map(|&w| w / sum).collect()
600 }
601 }
602
603 fn std_dev(values: &[f64]) -> f64 {
605 let n = values.len();
606 if n <= 1 {
607 return 0.0;
608 }
609 let mean = values.iter().sum::<f64>() / n as f64;
610 let variance = values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n as f64;
611 variance.sqrt()
612 }
613}
614
615#[cfg(test)]
620mod tests {
621 use crate::model_ensemble::{
622 EnsembleConfig, EnsembleError, EnsembleStrategy, ModelEnsemble, ModelPrediction,
623 };
624
625 fn pred(id: &str, outputs: Vec<f64>, confidence: f64, latency_ms: u64) -> ModelPrediction {
630 ModelPrediction {
631 model_id: id.to_string(),
632 outputs,
633 confidence,
634 latency_ms,
635 }
636 }
637
638 fn basic_ensemble(strategy: EnsembleStrategy) -> ModelEnsemble {
639 let cfg = EnsembleConfig {
640 strategy,
641 min_models: 1,
642 timeout_ms: 1_000,
643 require_all: false,
644 };
645 ModelEnsemble::new(cfg)
646 }
647
648 #[test]
653 fn test_top_class_simple() {
654 assert_eq!(ModelEnsemble::top_class(&[0.1, 0.8, 0.1]), 1);
655 }
656
657 #[test]
658 fn test_top_class_first_wins_tie() {
659 assert_eq!(ModelEnsemble::top_class(&[0.5, 0.0, 0.5]), 0);
661 }
662
663 #[test]
664 fn test_top_class_single_element() {
665 assert_eq!(ModelEnsemble::top_class(&[42.0]), 0);
666 }
667
668 #[test]
669 fn test_top_class_all_equal() {
670 assert_eq!(ModelEnsemble::top_class(&[1.0, 1.0, 1.0]), 0);
671 }
672
673 #[test]
678 fn test_softmax_sums_to_one() {
679 let out = ModelEnsemble::softmax(&[1.0, 2.0, 3.0]);
680 let sum: f64 = out.iter().sum();
681 assert!((sum - 1.0).abs() < 1e-12);
682 }
683
684 #[test]
685 fn test_softmax_numerically_stable_large_inputs() {
686 let out = ModelEnsemble::softmax(&[1000.0, 1001.0, 1002.0]);
688 let sum: f64 = out.iter().sum();
689 assert!((sum - 1.0).abs() < 1e-12);
690 }
691
692 #[test]
693 fn test_softmax_empty() {
694 assert!(ModelEnsemble::softmax(&[]).is_empty());
695 }
696
697 #[test]
698 fn test_softmax_uniform_on_equal_inputs() {
699 let out = ModelEnsemble::softmax(&[0.0, 0.0, 0.0]);
700 for v in &out {
701 assert!((v - 1.0 / 3.0).abs() < 1e-12);
702 }
703 }
704
705 #[test]
706 fn test_softmax_argmax_preserved() {
707 let logits = &[0.5, 3.0, 1.0];
708 let out = ModelEnsemble::softmax(logits);
709 assert_eq!(ModelEnsemble::top_class(&out), 1);
710 }
711
712 #[test]
717 fn test_normalize_weights_basic() {
718 let w = ModelEnsemble::normalize_weights(&[1.0, 3.0]);
719 assert!((w[0] - 0.25).abs() < 1e-12);
720 assert!((w[1] - 0.75).abs() < 1e-12);
721 }
722
723 #[test]
724 fn test_normalize_weights_already_normed() {
725 let w = ModelEnsemble::normalize_weights(&[0.4, 0.6]);
726 assert!((w[0] - 0.4).abs() < 1e-12);
727 assert!((w[1] - 0.6).abs() < 1e-12);
728 }
729
730 #[test]
731 fn test_normalize_weights_zero_sum_gives_uniform() {
732 let w = ModelEnsemble::normalize_weights(&[0.0, 0.0, 0.0]);
733 for v in &w {
734 assert!((v - 1.0 / 3.0).abs() < 1e-12);
735 }
736 }
737
738 #[test]
739 fn test_normalize_weights_empty() {
740 assert!(ModelEnsemble::normalize_weights(&[]).is_empty());
741 }
742
743 #[test]
748 fn test_majority_vote_clear_winner() {
749 let mut e = basic_ensemble(EnsembleStrategy::MajorityVote);
750 e.add_member("a".into(), 1.0)
751 .add_member("b".into(), 1.0)
752 .add_member("c".into(), 1.0);
753
754 let preds = vec![
755 pred("a", vec![0.9, 0.1], 0.9, 10),
756 pred("b", vec![0.8, 0.2], 0.8, 12),
757 pred("c", vec![0.1, 0.9], 0.7, 8),
758 ];
759
760 let res = e.aggregate(&preds).expect("aggregate");
761 assert!((res.final_outputs[0] - 2.0 / 3.0).abs() < 1e-12);
763 assert!((res.final_outputs[1] - 1.0 / 3.0).abs() < 1e-12);
764 assert_eq!(res.strategy_used, "MajorityVote");
765 assert_eq!(res.participating_models, 3);
766 }
767
768 #[test]
769 fn test_majority_vote_tie_lowest_class_wins() {
770 let mut e = basic_ensemble(EnsembleStrategy::MajorityVote);
771 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
772
773 let preds = vec![
774 pred("a", vec![0.9, 0.1], 0.9, 10),
775 pred("b", vec![0.1, 0.9], 0.9, 10),
776 ];
777
778 let res = e.aggregate(&preds).expect("aggregate");
779 assert!((res.final_outputs[0] - 0.5).abs() < 1e-12);
781 }
782
783 #[test]
784 fn test_majority_vote_disagreement_unanimous() {
785 let mut e = basic_ensemble(EnsembleStrategy::MajorityVote);
786 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
787
788 let preds = vec![
789 pred("a", vec![1.0, 0.0], 1.0, 5),
790 pred("b", vec![1.0, 0.0], 1.0, 5),
791 ];
792
793 let res = e.aggregate(&preds).expect("aggregate");
794 assert!(res.disagreement.abs() < 1e-12);
796 }
797
798 #[test]
799 fn test_majority_vote_avg_stats() {
800 let mut e = basic_ensemble(EnsembleStrategy::MajorityVote);
801 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
802
803 let preds = vec![
804 pred("a", vec![1.0, 0.0], 0.6, 10),
805 pred("b", vec![1.0, 0.0], 0.8, 20),
806 ];
807
808 let res = e.aggregate(&preds).expect("aggregate");
809 assert!((res.avg_confidence - 0.7).abs() < 1e-12);
810 assert!((res.avg_latency_ms - 15.0).abs() < 1e-12);
811 }
812
813 #[test]
818 fn test_weighted_vote_basic() {
819 let strategy = EnsembleStrategy::WeightedVote {
820 weights: vec![3.0, 1.0],
821 };
822 let mut e = basic_ensemble(strategy);
823 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
824
825 let preds = vec![
826 pred("a", vec![0.8, 0.2], 0.9, 10), pred("b", vec![0.2, 0.8], 0.7, 10), ];
829
830 let res = e.aggregate(&preds).expect("aggregate");
831 assert!((res.final_outputs[0] - 0.65).abs() < 1e-12);
835 assert!((res.final_outputs[1] - 0.35).abs() < 1e-12);
836 assert_eq!(res.strategy_used, "WeightedVote");
837 }
838
839 #[test]
840 fn test_weighted_vote_mismatch_error() {
841 let strategy = EnsembleStrategy::WeightedVote {
842 weights: vec![1.0], };
844 let mut e = basic_ensemble(strategy);
845 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
846
847 let preds = vec![
848 pred("a", vec![1.0, 0.0], 0.9, 10),
849 pred("b", vec![0.0, 1.0], 0.8, 10),
850 ];
851
852 let err = e.aggregate(&preds).expect_err("should fail");
853 assert!(matches!(err, EnsembleError::WeightCountMismatch { .. }));
854 }
855
856 #[test]
861 fn test_mean_averaging_basic() {
862 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
863 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
864
865 let preds = vec![pred("a", vec![2.0], 0.8, 5), pred("b", vec![4.0], 0.6, 15)];
866
867 let res = e.aggregate(&preds).expect("aggregate");
868 assert!((res.final_outputs[0] - 3.0).abs() < 1e-12);
869 assert_eq!(res.strategy_used, "MeanAveraging");
870 }
871
872 #[test]
873 fn test_mean_averaging_single_model() {
874 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
875 e.add_member("a".into(), 1.0);
876
877 let preds = vec![pred("a", vec![7.5], 1.0, 1)];
878
879 let res = e.aggregate(&preds).expect("aggregate");
880 assert!((res.final_outputs[0] - 7.5).abs() < 1e-12);
881 assert!(res.disagreement.abs() < 1e-12);
882 }
883
884 #[test]
885 fn test_mean_averaging_disagreement_nonzero() {
886 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
887 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
888
889 let preds = vec![pred("a", vec![1.0], 0.9, 5), pred("b", vec![3.0], 0.9, 5)];
890
891 let res = e.aggregate(&preds).expect("aggregate");
892 assert!((res.disagreement - 1.0).abs() < 1e-12);
894 }
895
896 #[test]
901 fn test_weighted_averaging_basic() {
902 let strategy = EnsembleStrategy::WeightedAveraging {
903 weights: vec![1.0, 3.0],
904 };
905 let mut e = basic_ensemble(strategy);
906 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
907
908 let preds = vec![pred("a", vec![0.0], 0.9, 10), pred("b", vec![4.0], 0.9, 10)];
909
910 let res = e.aggregate(&preds).expect("aggregate");
911 assert!((res.final_outputs[0] - 3.0).abs() < 1e-12);
914 assert_eq!(res.strategy_used, "WeightedAveraging");
915 }
916
917 #[test]
918 fn test_weighted_averaging_fallback_to_member_weights() {
919 let strategy = EnsembleStrategy::WeightedAveraging { weights: vec![] };
921 let mut e = basic_ensemble(strategy);
922 e.add_member("a".into(), 1.0).add_member("b".into(), 3.0);
924
925 let preds = vec![pred("a", vec![0.0], 0.9, 10), pred("b", vec![4.0], 0.9, 10)];
926
927 let res = e.aggregate(&preds).expect("aggregate");
928 assert!((res.final_outputs[0] - 3.0).abs() < 1e-12);
930 }
931
932 #[test]
937 fn test_stacking_basic() {
938 let strategy = EnsembleStrategy::Stacking {
939 meta_weights: vec![0.5, 0.5],
940 };
941 let mut e = basic_ensemble(strategy);
942 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
943
944 let preds = vec![
945 pred("a", vec![2.0, 4.0], 0.9, 10),
946 pred("b", vec![6.0, 8.0], 0.8, 10),
947 ];
948
949 let res = e.aggregate(&preds).expect("aggregate");
950 assert!((res.final_outputs[0] - 4.0).abs() < 1e-12);
953 assert!((res.final_outputs[1] - 6.0).abs() < 1e-12);
954 assert_eq!(res.strategy_used, "Stacking");
955 }
956
957 #[test]
958 fn test_stacking_mismatch_error() {
959 let strategy = EnsembleStrategy::Stacking {
960 meta_weights: vec![1.0, 2.0, 3.0], };
962 let mut e = basic_ensemble(strategy);
963 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
964
965 let preds = vec![pred("a", vec![1.0], 0.9, 5), pred("b", vec![2.0], 0.9, 5)];
966
967 let err = e.aggregate(&preds).expect_err("should fail");
968 assert!(matches!(err, EnsembleError::WeightCountMismatch { .. }));
969 }
970
971 #[test]
976 fn test_insufficient_models_error() {
977 let cfg = EnsembleConfig {
978 strategy: EnsembleStrategy::MeanAveraging,
979 min_models: 3,
980 timeout_ms: 1_000,
981 require_all: false,
982 };
983 let e = ModelEnsemble::new(cfg);
984 let preds = vec![pred("a", vec![1.0], 0.9, 5)];
985 let err = e.aggregate(&preds).expect_err("should fail");
986 assert_eq!(err, EnsembleError::InsufficientModels { needed: 3, got: 1 });
987 }
988
989 #[test]
990 fn test_empty_outputs_error() {
991 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
992 e.add_member("a".into(), 1.0);
993
994 let preds = vec![ModelPrediction {
995 model_id: "a".into(),
996 outputs: vec![],
997 confidence: 0.9,
998 latency_ms: 5,
999 }];
1000
1001 let err = e.aggregate(&preds).expect_err("should fail");
1002 assert_eq!(err, EnsembleError::EmptyOutputs);
1003 }
1004
1005 #[test]
1006 fn test_require_all_missing_member_error() {
1007 let cfg = EnsembleConfig {
1008 strategy: EnsembleStrategy::MeanAveraging,
1009 min_models: 1,
1010 timeout_ms: 1_000,
1011 require_all: true,
1012 };
1013 let mut e = ModelEnsemble::new(cfg);
1014 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
1015
1016 let preds = vec![pred("a", vec![1.0], 0.9, 5)];
1018 let err = e.aggregate(&preds).expect_err("should fail");
1019 assert!(matches!(err, EnsembleError::MissingModel(_)));
1020 }
1021
1022 #[test]
1027 fn test_enable_disable_member() {
1028 let mut e = basic_ensemble(EnsembleStrategy::MajorityVote);
1029 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
1030
1031 assert!(e.disable_member("a"));
1032 let preds = vec![
1033 pred("a", vec![0.0, 1.0], 0.9, 5),
1034 pred("b", vec![1.0, 0.0], 0.9, 5),
1035 ];
1036 let res = e.aggregate(&preds).expect("aggregate");
1038 assert_eq!(res.participating_models, 1);
1039
1040 assert!(e.enable_member("a"));
1041 let res2 = e.aggregate(&preds).expect("aggregate after re-enable");
1042 assert_eq!(res2.participating_models, 2);
1043 }
1044
1045 #[test]
1046 fn test_enable_nonexistent_returns_false() {
1047 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1048 assert!(!e.enable_member("ghost"));
1049 }
1050
1051 #[test]
1052 fn test_disable_nonexistent_returns_false() {
1053 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1054 assert!(!e.disable_member("ghost"));
1055 }
1056
1057 #[test]
1058 fn test_record_call_updates_counts() {
1059 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1060 e.add_member("a".into(), 1.0);
1061
1062 e.record_call("a", true);
1063 e.record_call("a", false);
1064 e.record_call("a", true);
1065
1066 let m = &e.members[0];
1067 assert_eq!(m.call_count, 3);
1068 assert_eq!(m.error_count, 1);
1069 }
1070
1071 #[test]
1072 fn test_record_call_unknown_model_no_panic() {
1073 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1074 e.record_call("ghost", true);
1076 }
1077
1078 #[test]
1083 fn test_stats_no_calls() {
1084 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1085 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
1086 e.disable_member("b");
1087
1088 let s = e.stats();
1089 assert_eq!(s.total_members, 2);
1090 assert_eq!(s.enabled_members, 1);
1091 assert_eq!(s.total_calls, 0);
1092 assert_eq!(s.total_errors, 0);
1093 assert!((s.avg_member_error_rate).abs() < 1e-12);
1094 }
1095
1096 #[test]
1097 fn test_stats_with_calls() {
1098 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1099 e.add_member("a".into(), 1.0).add_member("b".into(), 1.0);
1100
1101 e.record_call("a", true); e.record_call("b", false); let s = e.stats();
1105 assert_eq!(s.total_calls, 2);
1106 assert_eq!(s.total_errors, 1);
1107 assert!((s.avg_member_error_rate - 0.5).abs() < 1e-12);
1109 }
1110
1111 #[test]
1116 fn test_unregistered_model_participates_with_default_weight() {
1117 let e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1119 let preds = vec![pred("unknown", vec![5.0], 0.9, 10)];
1120 let res = e.aggregate(&preds).expect("aggregate");
1121 assert!((res.final_outputs[0] - 5.0).abs() < 1e-12);
1122 }
1123
1124 #[test]
1129 fn test_member_stats_returns_all() {
1130 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1131 e.add_member("a".into(), 2.0).add_member("b".into(), 3.0);
1132 let stats = e.member_stats();
1133 assert_eq!(stats.len(), 2);
1134 assert_eq!(stats[0].model_id, "a");
1135 assert_eq!(stats[1].model_id, "b");
1136 }
1137
1138 #[test]
1143 fn test_default_config() {
1144 let cfg = EnsembleConfig::default();
1145 assert_eq!(cfg.min_models, 1);
1146 assert_eq!(cfg.timeout_ms, 5_000);
1147 assert!(!cfg.require_all);
1148 }
1149
1150 #[test]
1155 fn test_softmax_single_element() {
1156 let out = ModelEnsemble::softmax(&[42.0]);
1157 assert!((out[0] - 1.0).abs() < 1e-12);
1158 }
1159
1160 #[test]
1161 fn test_softmax_negative_inputs() {
1162 let out = ModelEnsemble::softmax(&[-1.0, -2.0, -3.0]);
1163 let sum: f64 = out.iter().sum();
1164 assert!((sum - 1.0).abs() < 1e-12);
1165 assert!(out[0] > out[1]);
1167 assert!(out[1] > out[2]);
1168 }
1169
1170 #[test]
1175 fn test_mean_averaging_three_models_disagrement() {
1176 let mut e = basic_ensemble(EnsembleStrategy::MeanAveraging);
1177 e.add_member("a".into(), 1.0)
1178 .add_member("b".into(), 1.0)
1179 .add_member("c".into(), 1.0);
1180
1181 let preds = vec![
1182 pred("a", vec![1.0], 0.9, 5),
1183 pred("b", vec![2.0], 0.9, 5),
1184 pred("c", vec![3.0], 0.9, 5),
1185 ];
1186
1187 let res = e.aggregate(&preds).expect("aggregate");
1188 assert!((res.final_outputs[0] - 2.0).abs() < 1e-12);
1189 let expected_std = (2.0_f64 / 3.0).sqrt();
1192 assert!((res.disagreement - expected_std).abs() < 1e-12);
1193 }
1194
1195 #[test]
1200 fn test_weighted_vote_fallback_to_member_weights() {
1201 let strategy = EnsembleStrategy::WeightedVote { weights: vec![] };
1202 let mut e = basic_ensemble(strategy);
1203 e.add_member("a".into(), 1.0).add_member("b".into(), 3.0);
1204
1205 let preds = vec![
1206 pred("a", vec![1.0, 0.0], 0.9, 10),
1207 pred("b", vec![0.0, 1.0], 0.8, 10),
1208 ];
1209
1210 let res = e.aggregate(&preds).expect("aggregate");
1211 assert!((res.final_outputs[0] - 0.25).abs() < 1e-12);
1215 assert!((res.final_outputs[1] - 0.75).abs() < 1e-12);
1216 }
1217}