Skip to main content

mecha10_behavior_patterns/
ensemble.rs

1//! Ensemble pattern for multi-model fusion
2//!
3//! This module implements ensemble learning patterns where multiple AI models
4//! can be combined to make more robust decisions.
5
6use async_trait::async_trait;
7use mecha10_behavior_runtime::{BehaviorNode, BoxedBehavior, NodeStatus};
8use mecha10_core::Context;
9use serde::{Deserialize, Serialize};
10use tracing::{debug, info};
11
12/// A weighted model in an ensemble.
13#[derive(Debug)]
14pub struct WeightedModel {
15    /// The behavior/model
16    pub behavior: BoxedBehavior,
17    /// Weight for voting (only used in WeightedVote strategy)
18    pub weight: f32,
19    /// Optional name for debugging
20    pub name: Option<String>,
21}
22
23impl WeightedModel {
24    mecha10_core::new_with_defaults!(
25        /// Create a new weighted model.
26        pub fn new(behavior: BoxedBehavior, weight: f32) -> Self {
27            name: None
28        }
29    );
30
31    mecha10_core::builder_setter!(
32        /// Set the name of this model.
33        into_some, with_name, name, impl Into<String>
34    );
35}
36
37/// Strategy for combining results from multiple models.
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EnsembleStrategy {
41    /// Weighted voting: success if weighted sum of successes > threshold (default 0.5)
42    WeightedVote,
43
44    /// Conservative: all models must succeed
45    Conservative,
46
47    /// Optimistic: any model can succeed
48    Optimistic,
49
50    /// Majority: more than half must succeed
51    Majority,
52}
53
54/// Ensemble node for multi-model fusion.
55///
56/// This node runs multiple behaviors/models in parallel and combines their results
57/// using a configurable strategy. This is useful for:
58/// - Combining multiple AI models for robust predictions
59/// - Redundancy and fault tolerance
60/// - Multi-modal sensor fusion
61///
62/// # Strategies
63///
64/// - **WeightedVote**: Models vote with weights, success if total > threshold
65/// - **Conservative**: All models must succeed (high precision, low recall)
66/// - **Optimistic**: Any model can succeed (high recall, low precision)
67/// - **Majority**: More than half must succeed (balanced)
68///
69/// # Example
70///
71/// ```rust
72/// use mecha10_behavior_patterns::prelude::*;
73///
74/// # #[derive(Debug)]
75/// # struct YoloV8;
76/// # #[async_trait]
77/// # impl BehaviorNode for YoloV8 {
78/// #     async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
79/// #         Ok(NodeStatus::Success)
80/// #     }
81/// # }
82/// # #[derive(Debug)]
83/// # struct YoloV10;
84/// # #[async_trait]
85/// # impl BehaviorNode for YoloV10 {
86/// #     async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
87/// #         Ok(NodeStatus::Success)
88/// #     }
89/// # }
90/// # #[derive(Debug)]
91/// # struct CustomDetector;
92/// # #[async_trait]
93/// # impl BehaviorNode for CustomDetector {
94/// #     async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
95/// #         Ok(NodeStatus::Success)
96/// #     }
97/// # }
98///
99/// let ensemble = EnsembleNode::new(EnsembleStrategy::WeightedVote)
100///     .add_model(Box::new(YoloV8), 0.4)
101///     .add_model(Box::new(YoloV10), 0.3)
102///     .add_model(Box::new(CustomDetector), 0.3);
103/// ```
104#[derive(Debug)]
105pub struct EnsembleNode {
106    models: Vec<WeightedModel>,
107    strategy: EnsembleStrategy,
108    threshold: f32,
109    model_statuses: Vec<NodeStatus>,
110}
111
112impl EnsembleNode {
113    /// Create a new ensemble node with the given strategy.
114    pub fn new(strategy: EnsembleStrategy) -> Self {
115        Self {
116            models: Vec::new(),
117            strategy,
118            threshold: 0.5,
119            model_statuses: Vec::new(),
120        }
121    }
122
123    /// Add a model with a weight.
124    pub fn add_model(mut self, behavior: BoxedBehavior, weight: f32) -> Self {
125        self.models.push(WeightedModel::new(behavior, weight));
126        self.model_statuses.push(NodeStatus::Running);
127        self
128    }
129
130    /// Set the threshold for weighted voting (default 0.5).
131    pub fn with_threshold(mut self, threshold: f32) -> Self {
132        self.threshold = threshold;
133        self
134    }
135}
136
137#[async_trait]
138impl BehaviorNode for EnsembleNode {
139    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
140        if self.models.is_empty() {
141            return Ok(NodeStatus::Failure);
142        }
143
144        for (i, model) in self.models.iter_mut().enumerate() {
145            if self.model_statuses[i].is_running() {
146                let status = model.behavior.tick(ctx).await?;
147                self.model_statuses[i] = status;
148
149                let model_name = model.name.as_deref().unwrap_or("unnamed");
150                debug!("Ensemble: model '{}' status: {}", model_name, status);
151            }
152        }
153
154        let overall_status = match self.strategy {
155            EnsembleStrategy::Conservative => self.conservative_combine(),
156            EnsembleStrategy::Optimistic => self.optimistic_combine(),
157            EnsembleStrategy::Majority => self.majority_combine(),
158            EnsembleStrategy::WeightedVote => self.weighted_vote_combine(),
159        };
160
161        info!("Ensemble ({:?}): overall status = {}", self.strategy, overall_status);
162        Ok(overall_status)
163    }
164
165    mecha10_behavior_runtime::reset_child_statuses_field!(model_statuses);
166
167    fn name(&self) -> &str {
168        "ensemble"
169    }
170
171    mecha10_behavior_runtime::children_mut_via_wrapped_behavior!(models, model);
172}
173
174impl EnsembleNode {
175    /// Shared shape behind `conservative_combine`/`optimistic_combine`: both pick an overall
176    /// status if *any* model already reports `veto`, otherwise fall back to `Running` while any
177    /// model is still in flight, otherwise settle on `otherwise` - they only differ in which
178    /// status acts as the veto vs. the settled fallback (cargo-dupes flagged the two near-
179    /// identical bodies as duplicates of each other).
180    fn combine_if_any(&self, veto: NodeStatus, otherwise: NodeStatus) -> NodeStatus {
181        let vetoed = self.model_statuses.contains(&veto);
182        let running = self.model_statuses.iter().any(|s| s.is_running());
183
184        if vetoed {
185            veto
186        } else if running {
187            NodeStatus::Running
188        } else {
189            otherwise
190        }
191    }
192
193    fn conservative_combine(&self) -> NodeStatus {
194        self.combine_if_any(NodeStatus::Failure, NodeStatus::Success)
195    }
196
197    fn optimistic_combine(&self) -> NodeStatus {
198        self.combine_if_any(NodeStatus::Success, NodeStatus::Failure)
199    }
200
201    fn majority_combine(&self) -> NodeStatus {
202        let successes = self.model_statuses.iter().filter(|s| s.is_success()).count();
203        let failures = self.model_statuses.iter().filter(|s| s.is_failure()).count();
204        let running = self.model_statuses.iter().filter(|s| s.is_running()).count();
205
206        let _total_decided = successes + failures;
207        let majority_threshold = self.models.len().div_ceil(2);
208
209        if successes >= majority_threshold {
210            NodeStatus::Success
211        } else if failures >= majority_threshold {
212            NodeStatus::Failure
213        } else if running > 0 {
214            NodeStatus::Running
215        } else {
216            NodeStatus::Failure
217        }
218    }
219
220    fn weighted_vote_combine(&self) -> NodeStatus {
221        let mut success_weight = 0.0;
222        let mut _failure_weight = 0.0;
223        let mut total_weight = 0.0;
224        let mut any_running = false;
225
226        for (model, status) in self.models.iter().zip(&self.model_statuses) {
227            total_weight += model.weight;
228            match status {
229                NodeStatus::Success => success_weight += model.weight,
230                NodeStatus::Failure => _failure_weight += model.weight,
231                NodeStatus::Running => any_running = true,
232            }
233        }
234
235        if any_running {
236            return NodeStatus::Running;
237        }
238
239        let success_ratio = if total_weight > 0.0 {
240            success_weight / total_weight
241        } else {
242            0.0
243        };
244
245        if success_ratio >= self.threshold {
246            NodeStatus::Success
247        } else {
248            NodeStatus::Failure
249        }
250    }
251}
252
253/// Configuration for an ensemble model (for JSON deserialization).
254#[allow(dead_code)]
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct EnsembleModelConfig {
257    /// Node type to instantiate
258    pub node: String,
259    /// Weight for this model
260    pub weight: f32,
261    /// Optional name for this model
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub name: Option<String>,
264    /// Configuration for the node
265    #[serde(default)]
266    pub config: serde_json::Value,
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    // These exercise `combine_if_any` (the shared helper behind `conservative_combine` and
274    // `optimistic_combine`) directly against `model_statuses`, without needing a real `Context`
275    // or Redis - the `tests/ensemble_tests.rs` integration tests cover the full tick loop but
276    // are ignored in environments without Redis, so unit coverage here is what actually runs.
277    fn node_with_statuses(strategy: EnsembleStrategy, statuses: Vec<NodeStatus>) -> EnsembleNode {
278        EnsembleNode {
279            models: Vec::new(),
280            strategy,
281            threshold: 0.5,
282            model_statuses: statuses,
283        }
284    }
285
286    #[test]
287    fn conservative_combine_fails_if_any_model_failed() {
288        let node = node_with_statuses(
289            EnsembleStrategy::Conservative,
290            vec![NodeStatus::Success, NodeStatus::Failure, NodeStatus::Success],
291        );
292        assert_eq!(node.conservative_combine(), NodeStatus::Failure);
293    }
294
295    #[test]
296    fn conservative_combine_runs_while_any_model_is_running() {
297        let node = node_with_statuses(
298            EnsembleStrategy::Conservative,
299            vec![NodeStatus::Success, NodeStatus::Running],
300        );
301        assert_eq!(node.conservative_combine(), NodeStatus::Running);
302    }
303
304    #[test]
305    fn conservative_combine_succeeds_if_all_models_succeeded() {
306        let node = node_with_statuses(
307            EnsembleStrategy::Conservative,
308            vec![NodeStatus::Success, NodeStatus::Success],
309        );
310        assert_eq!(node.conservative_combine(), NodeStatus::Success);
311    }
312
313    #[test]
314    fn optimistic_combine_succeeds_if_any_model_succeeded() {
315        let node = node_with_statuses(
316            EnsembleStrategy::Optimistic,
317            vec![NodeStatus::Failure, NodeStatus::Success, NodeStatus::Failure],
318        );
319        assert_eq!(node.optimistic_combine(), NodeStatus::Success);
320    }
321
322    #[test]
323    fn optimistic_combine_runs_while_any_model_is_running() {
324        let node = node_with_statuses(
325            EnsembleStrategy::Optimistic,
326            vec![NodeStatus::Failure, NodeStatus::Running],
327        );
328        assert_eq!(node.optimistic_combine(), NodeStatus::Running);
329    }
330
331    #[test]
332    fn optimistic_combine_fails_if_all_models_failed() {
333        let node = node_with_statuses(
334            EnsembleStrategy::Optimistic,
335            vec![NodeStatus::Failure, NodeStatus::Failure],
336        );
337        assert_eq!(node.optimistic_combine(), NodeStatus::Failure);
338    }
339}