Skip to main content

ipfrs_tensorlogic/
model_ensemble.rs

1//! ModelEnsemble — Multi-model ensemble aggregator for distributed inference.
2//!
3//! Supports voting, averaging, and stacking strategies with per-member tracking,
4//! weight normalization, and statistical disagreement measurement.
5
6use std::collections::HashMap;
7use thiserror::Error;
8
9// ---------------------------------------------------------------------------
10// Error type
11// ---------------------------------------------------------------------------
12
13/// Errors that can arise during ensemble aggregation or configuration.
14#[derive(Debug, Error, PartialEq)]
15pub enum EnsembleError {
16    /// Not enough model predictions to satisfy `min_models`.
17    #[error("insufficient models: needed {needed}, got {got}")]
18    InsufficientModels { needed: usize, got: usize },
19
20    /// A required model was not found (e.g. `require_all` mode).
21    #[error("missing model: {0}")]
22    MissingModel(String),
23
24    /// A prediction's `outputs` vector is empty.
25    #[error("empty outputs in one or more predictions")]
26    EmptyOutputs,
27
28    /// The number of weights does not match the number of models.
29    #[error("weight count mismatch: expected {expected}, got {got}")]
30    WeightCountMismatch { expected: usize, got: usize },
31}
32
33// ---------------------------------------------------------------------------
34// Strategy
35// ---------------------------------------------------------------------------
36
37/// Aggregation strategy used by the ensemble.
38#[derive(Debug, Clone, PartialEq)]
39pub enum EnsembleStrategy {
40    /// Classification: argmax vote; ties broken by lowest class index.
41    MajorityVote,
42
43    /// Classification: each model's vote is multiplied by its weight.
44    /// Weights are normalised internally; they need not sum to 1.
45    WeightedVote { weights: Vec<f64> },
46
47    /// Regression: arithmetic mean of `outputs[0]` across all models.
48    MeanAveraging,
49
50    /// Regression: weighted mean of `outputs[0]`.
51    WeightedAveraging { weights: Vec<f64> },
52
53    /// Linear combination of per-model outputs using `meta_weights`.
54    /// Each model's full `outputs` vector contributes to the final vector.
55    Stacking { meta_weights: Vec<f64> },
56}
57
58// ---------------------------------------------------------------------------
59// Data types
60// ---------------------------------------------------------------------------
61
62/// A single model's prediction package.
63#[derive(Debug, Clone)]
64pub struct ModelPrediction {
65    /// Unique identifier for the model that produced this prediction.
66    pub model_id: String,
67    /// For classification: `outputs[i]` = probability of class *i*.
68    /// For regression: `outputs[0]` = predicted value.
69    pub outputs: Vec<f64>,
70    /// Confidence score in `[0, 1]`.
71    pub confidence: f64,
72    /// Wall-clock inference time in milliseconds.
73    pub latency_ms: u64,
74}
75
76/// The aggregated output of the ensemble.
77#[derive(Debug, Clone)]
78pub struct EnsembleResult {
79    /// Final aggregated outputs (class probabilities or regression value).
80    pub final_outputs: Vec<f64>,
81    /// Human-readable name of the strategy that was applied.
82    pub strategy_used: String,
83    /// Number of models whose predictions were included.
84    pub participating_models: usize,
85    /// Mean confidence across participating models.
86    pub avg_confidence: f64,
87    /// Mean latency in milliseconds across participating models.
88    pub avg_latency_ms: f64,
89    /// Disagreement metric:
90    /// * Classification → `1 - max_vote_fraction`
91    /// * Regression     → standard deviation of `outputs[0]` across models
92    pub disagreement: f64,
93}
94
95/// Metadata for a single member of the ensemble.
96#[derive(Debug, Clone)]
97pub struct ModelMember {
98    /// Identifier matching `ModelPrediction::model_id`.
99    pub model_id: String,
100    /// Default weight used when the strategy is weight-based and no
101    /// per-call weight list is provided.
102    pub weight: f64,
103    /// Whether this member participates in aggregation.
104    pub enabled: bool,
105    /// Total number of times `record_call` was invoked for this member.
106    pub call_count: u64,
107    /// Number of failed calls recorded via `record_call(_, false)`.
108    pub error_count: u64,
109}
110
111/// Configuration for `ModelEnsemble`.
112#[derive(Debug, Clone)]
113pub struct EnsembleConfig {
114    /// Strategy used to aggregate predictions.
115    pub strategy: EnsembleStrategy,
116    /// Minimum number of active predictions required; defaults to 1.
117    pub min_models: usize,
118    /// Maximum allowed wall-clock time (informational; not enforced here).
119    pub timeout_ms: u64,
120    /// If `true`, fail when any registered member has no matching prediction.
121    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/// Aggregate statistics over all ensemble members.
136#[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    /// Mean error rate across members that have been called at least once.
143    pub avg_member_error_rate: f64,
144}
145
146// ---------------------------------------------------------------------------
147// ModelEnsemble
148// ---------------------------------------------------------------------------
149
150/// Multi-model ensemble aggregator supporting voting, averaging, and stacking.
151#[derive(Debug)]
152pub struct ModelEnsemble {
153    pub config: EnsembleConfig,
154    pub members: Vec<ModelMember>,
155}
156
157impl ModelEnsemble {
158    // -----------------------------------------------------------------------
159    // Construction
160    // -----------------------------------------------------------------------
161
162    /// Create a new ensemble with the given configuration.
163    pub fn new(config: EnsembleConfig) -> Self {
164        Self {
165            config,
166            members: Vec::new(),
167        }
168    }
169
170    /// Add a model member. Returns `&mut Self` for builder-style chaining.
171    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    // -----------------------------------------------------------------------
183    // Member management
184    // -----------------------------------------------------------------------
185
186    /// Enable the member with the given id. Returns `false` if not found.
187    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    /// Disable the member with the given id. Returns `false` if not found.
198    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    /// Record a call result for a member (updates `call_count` / `error_count`).
209    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    /// Immutable references to all members.
219    pub fn member_stats(&self) -> Vec<&ModelMember> {
220        self.members.iter().collect()
221    }
222
223    /// Aggregate statistics over all members.
224    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    // -----------------------------------------------------------------------
253    // Core aggregation
254    // -----------------------------------------------------------------------
255
256    /// Aggregate a slice of model predictions according to the configured strategy.
257    ///
258    /// Steps:
259    /// 1. Filter out predictions whose `model_id` maps to a disabled member.
260    /// 2. Optionally check that every enabled member contributed (`require_all`).
261    /// 3. Validate the count against `min_models`.
262    /// 4. Apply the strategy.
263    pub fn aggregate(
264        &self,
265        predictions: &[ModelPrediction],
266    ) -> Result<EnsembleResult, EnsembleError> {
267        // Build fast lookup: model_id → enabled status and weight.
268        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        // Filter to predictions from enabled members (unknown model ids are
275        // treated as enabled with weight 1.0 — they are not registered members).
276        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        // require_all: every enabled member must have a matching prediction.
286        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        // Validate minimum model count.
297        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        // Validate that no prediction has empty outputs.
306        for p in &active {
307            if p.outputs.is_empty() {
308                return Err(EnsembleError::EmptyOutputs);
309            }
310        }
311
312        // Compute per-prediction weights using member registry.
313        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        // Shared statistics.
319        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        // Dispatch to strategy implementation.
323        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    // -----------------------------------------------------------------------
355    // Strategy implementations
356    // -----------------------------------------------------------------------
357
358    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        // Disagreement: 1 - fraction of votes held by the majority class.
379        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        // Resolve effective weights: strategy_weights override member_weights
401        // when the lengths match; otherwise fall back to member_weights.
402        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        // Disagreement: 1 - max element in final_outputs (max weighted vote share).
424        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        // Effective per-model stacking weights (meta_weights if lengths match,
519        // otherwise fall back to normalised member weights).
520        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        // Determine output dimensionality from the first prediction.
532        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        // Disagreement: std dev across participating models of their scalar
542        // outputs (use first element for a consistent scalar).
543        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    // -----------------------------------------------------------------------
557    // Utility helpers
558    // -----------------------------------------------------------------------
559
560    /// Return the index of the maximum element (argmax). Ties: lowest index.
561    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    /// Numerically stable softmax (subtract max before exp).
575    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    /// Divide each weight by the total sum. If the sum is approximately 0,
590    /// return a uniform distribution.
591    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    /// Population standard deviation of a slice.
604    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// ---------------------------------------------------------------------------
616// Tests
617// ---------------------------------------------------------------------------
618
619#[cfg(test)]
620mod tests {
621    use crate::model_ensemble::{
622        EnsembleConfig, EnsembleError, EnsembleStrategy, ModelEnsemble, ModelPrediction,
623    };
624
625    // -------
626    // Helpers
627    // -------
628
629    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    // -----------------------------------------------------------------------
649    // top_class
650    // -----------------------------------------------------------------------
651
652    #[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        // Tie between index 0 and 2 → lowest wins (index 0).
660        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    // -----------------------------------------------------------------------
674    // softmax
675    // -----------------------------------------------------------------------
676
677    #[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        // Would overflow without the max-subtraction trick.
687        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    // -----------------------------------------------------------------------
713    // normalize_weights
714    // -----------------------------------------------------------------------
715
716    #[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    // -----------------------------------------------------------------------
744    // MajorityVote
745    // -----------------------------------------------------------------------
746
747    #[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        // Two votes for class 0, one for class 1.
762        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        // Tie → equal vote shares, final_outputs = [0.5, 0.5].
780        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        // Unanimous → disagreement = 0.
795        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    // -----------------------------------------------------------------------
814    // WeightedVote
815    // -----------------------------------------------------------------------
816
817    #[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), // weight 3
827            pred("b", vec![0.2, 0.8], 0.7, 10), // weight 1
828        ];
829
830        let res = e.aggregate(&preds).expect("aggregate");
831        // Normalised weights: [0.75, 0.25]
832        // final_outputs[0] = 0.75*0.8 + 0.25*0.2 = 0.65
833        // final_outputs[1] = 0.75*0.2 + 0.25*0.8 = 0.35
834        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], // only 1 weight for 2 models
843        };
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    // -----------------------------------------------------------------------
857    // MeanAveraging
858    // -----------------------------------------------------------------------
859
860    #[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        // std_dev([1,3]) = 1.0
893        assert!((res.disagreement - 1.0).abs() < 1e-12);
894    }
895
896    // -----------------------------------------------------------------------
897    // WeightedAveraging
898    // -----------------------------------------------------------------------
899
900    #[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        // Normalised weights: [0.25, 0.75]
912        // 0.25*0.0 + 0.75*4.0 = 3.0
913        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        // Empty strategy weights → fall back to member weights.
920        let strategy = EnsembleStrategy::WeightedAveraging { weights: vec![] };
921        let mut e = basic_ensemble(strategy);
922        // member weights: a=1, b=3
923        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        // normalised [0.25, 0.75] → 3.0
929        assert!((res.final_outputs[0] - 3.0).abs() < 1e-12);
930    }
931
932    // -----------------------------------------------------------------------
933    // Stacking
934    // -----------------------------------------------------------------------
935
936    #[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        // Normalised weights both 0.5.
951        // final[0] = 0.5*2 + 0.5*6 = 4, final[1] = 0.5*4 + 0.5*8 = 6
952        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], // 3 weights but 2 models
961        };
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    // -----------------------------------------------------------------------
972    // Error paths
973    // -----------------------------------------------------------------------
974
975    #[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        // Only model "a" sends a prediction.
1017        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    // -----------------------------------------------------------------------
1023    // Member management
1024    // -----------------------------------------------------------------------
1025
1026    #[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        // "a" is disabled → only "b" participates.
1037        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        // Should silently do nothing.
1075        e.record_call("ghost", true);
1076    }
1077
1078    // -----------------------------------------------------------------------
1079    // Stats
1080    // -----------------------------------------------------------------------
1081
1082    #[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); // a: 1 call, 0 errors
1102        e.record_call("b", false); // b: 1 call, 1 error
1103
1104        let s = e.stats();
1105        assert_eq!(s.total_calls, 2);
1106        assert_eq!(s.total_errors, 1);
1107        // avg of [0.0, 1.0] = 0.5
1108        assert!((s.avg_member_error_rate - 0.5).abs() < 1e-12);
1109    }
1110
1111    // -----------------------------------------------------------------------
1112    // Unknown model ids (not registered members)
1113    // -----------------------------------------------------------------------
1114
1115    #[test]
1116    fn test_unregistered_model_participates_with_default_weight() {
1117        // No members registered; any prediction gets through.
1118        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    // -----------------------------------------------------------------------
1125    // member_stats
1126    // -----------------------------------------------------------------------
1127
1128    #[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    // -----------------------------------------------------------------------
1139    // EnsembleConfig defaults
1140    // -----------------------------------------------------------------------
1141
1142    #[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    // -----------------------------------------------------------------------
1151    // Softmax edge cases
1152    // -----------------------------------------------------------------------
1153
1154    #[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        // -1.0 should be the max → largest probability.
1166        assert!(out[0] > out[1]);
1167        assert!(out[1] > out[2]);
1168    }
1169
1170    // -----------------------------------------------------------------------
1171    // Disagrement edge cases
1172    // -----------------------------------------------------------------------
1173
1174    #[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        // variance = ((1-2)^2 + (2-2)^2 + (3-2)^2) / 3 = 2/3
1190        // std_dev = sqrt(2/3) ≈ 0.8165
1191        let expected_std = (2.0_f64 / 3.0).sqrt();
1192        assert!((res.disagreement - expected_std).abs() < 1e-12);
1193    }
1194
1195    // -----------------------------------------------------------------------
1196    // Weighted vote using member weights (empty strategy weights)
1197    // -----------------------------------------------------------------------
1198
1199    #[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        // normed weights: [0.25, 0.75]
1212        // final[0] = 0.25*1 + 0.75*0 = 0.25
1213        // final[1] = 0.25*0 + 0.75*1 = 0.75
1214        assert!((res.final_outputs[0] - 0.25).abs() < 1e-12);
1215        assert!((res.final_outputs[1] - 0.75).abs() < 1e-12);
1216    }
1217}