mecha10_behavior_patterns/
ensemble.rs1use 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#[derive(Debug)]
14pub struct WeightedModel {
15 pub behavior: BoxedBehavior,
17 pub weight: f32,
19 pub name: Option<String>,
21}
22
23impl WeightedModel {
24 mecha10_core::new_with_defaults!(
25 pub fn new(behavior: BoxedBehavior, weight: f32) -> Self {
27 name: None
28 }
29 );
30
31 mecha10_core::builder_setter!(
32 into_some, with_name, name, impl Into<String>
34 );
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EnsembleStrategy {
41 WeightedVote,
43
44 Conservative,
46
47 Optimistic,
49
50 Majority,
52}
53
54#[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 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 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 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 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#[allow(dead_code)]
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct EnsembleModelConfig {
257 pub node: String,
259 pub weight: f32,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub name: Option<String>,
264 #[serde(default)]
266 pub config: serde_json::Value,
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 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}