Skip to main content

peat_protocol/composition/
constraint.rs

1//! Constraint-based composition rules
2//!
3//! This module implements composition rules for constraint-based capabilities -
4//! capabilities where team performance is limited by individual constraints.
5//!
6//! Examples:
7//! - Team Speed: Limited by slowest member
8//! - Communication Range: Depends on mesh topology
9//! - Mission Duration: Limited by shortest endurance
10
11use crate::composition::rules::{CompositionContext, CompositionResult, CompositionRule};
12use crate::models::capability::{Capability, CapabilityType};
13use crate::models::CapabilityExt;
14use crate::Result;
15use async_trait::async_trait;
16use serde_json::{json, Value};
17
18/// Rule for determining team speed constraint
19///
20/// Team moves at the speed of the slowest member. This is critical for
21/// coordinated operations where the team must stay together.
22pub struct TeamSpeedConstraintRule {
23    /// Minimum number of platforms for team movement
24    min_platforms: usize,
25}
26
27impl TeamSpeedConstraintRule {
28    /// Create a new team speed constraint rule
29    pub fn new(min_platforms: usize) -> Self {
30        Self { min_platforms }
31    }
32}
33
34impl Default for TeamSpeedConstraintRule {
35    fn default() -> Self {
36        Self::new(2)
37    }
38}
39
40#[async_trait]
41impl CompositionRule for TeamSpeedConstraintRule {
42    fn name(&self) -> &str {
43        "team_speed_constraint"
44    }
45
46    fn description(&self) -> &str {
47        "Determines team movement speed based on slowest member constraint"
48    }
49
50    fn applies_to(&self, capabilities: &[Capability]) -> bool {
51        let mobility_count = capabilities
52            .iter()
53            .filter(|c| {
54                c.get_capability_type() == CapabilityType::Mobility
55                    && serde_json::from_str::<Value>(&c.metadata_json)
56                        .ok()
57                        .and_then(|v| v.get("max_speed").cloned())
58                        .is_some()
59            })
60            .count();
61
62        mobility_count >= self.min_platforms
63    }
64
65    async fn compose(
66        &self,
67        capabilities: &[Capability],
68        _context: &CompositionContext,
69    ) -> Result<CompositionResult> {
70        let mobility_caps: Vec<&Capability> = capabilities
71            .iter()
72            .filter(|c| {
73                c.get_capability_type() == CapabilityType::Mobility
74                    && serde_json::from_str::<Value>(&c.metadata_json)
75                        .ok()
76                        .and_then(|v| v.get("max_speed").cloned())
77                        .is_some()
78            })
79            .collect();
80
81        if mobility_caps.len() < self.min_platforms {
82            return Ok(CompositionResult::new(vec![], 0.0));
83        }
84
85        // Find minimum speed (slowest member)
86        let speeds: Vec<f64> = mobility_caps
87            .iter()
88            .filter_map(|c| {
89                serde_json::from_str::<Value>(&c.metadata_json)
90                    .ok()
91                    .and_then(|v| v.get("max_speed").and_then(|s| s.as_f64()))
92            })
93            .collect();
94
95        let team_speed = speeds.iter().cloned().fold(f64::INFINITY, f64::min);
96
97        // Find slowest member
98        let slowest = mobility_caps
99            .iter()
100            .min_by(|a, b| {
101                let speed_a = serde_json::from_str::<Value>(&a.metadata_json)
102                    .ok()
103                    .and_then(|v| v.get("max_speed").and_then(|s| s.as_f64()))
104                    .unwrap_or(0.0);
105                let speed_b = serde_json::from_str::<Value>(&b.metadata_json)
106                    .ok()
107                    .and_then(|v| v.get("max_speed").and_then(|s| s.as_f64()))
108                    .unwrap_or(0.0);
109                speed_a.partial_cmp(&speed_b).unwrap()
110            })
111            .unwrap();
112
113        // Confidence is based on slowest member's confidence
114        let team_confidence = slowest.confidence;
115
116        let mut composed = Capability::new(
117            format!("constraint_team_speed_{}", uuid::Uuid::new_v4()),
118            "Team Speed".to_string(),
119            CapabilityType::Emergent,
120            team_confidence,
121        );
122        composed.metadata_json = serde_json::to_string(&json!({
123            "composition_type": "constraint",
124            "pattern": "team_speed",
125            "team_speed": team_speed,
126            "platform_count": mobility_caps.len(),
127            "limiting_platform": slowest.id,
128            "individual_speeds": speeds,
129            "description": "Team movement speed constrained by slowest member"
130        }))
131        .unwrap_or_default();
132
133        let contributor_ids: Vec<String> = mobility_caps.iter().map(|c| c.id.clone()).collect();
134
135        Ok(CompositionResult::new(vec![composed], team_confidence)
136            .with_contributors(contributor_ids))
137    }
138}
139
140/// Rule for determining effective communication range
141///
142/// Communication range depends on whether the team has mesh networking.
143/// - With mesh: Range is maximum (relay through intermediaries)
144/// - Without mesh: Range is minimum (all must reach all)
145pub struct CommunicationRangeConstraintRule {
146    /// Minimum number of nodes for communication
147    min_nodes: usize,
148    /// Whether mesh networking is available
149    has_mesh: bool,
150}
151
152impl CommunicationRangeConstraintRule {
153    /// Create a new communication range constraint rule
154    pub fn new(min_nodes: usize, has_mesh: bool) -> Self {
155        Self {
156            min_nodes,
157            has_mesh,
158        }
159    }
160}
161
162impl Default for CommunicationRangeConstraintRule {
163    fn default() -> Self {
164        Self::new(2, false) // Default: no mesh, direct comms only
165    }
166}
167
168#[async_trait]
169impl CompositionRule for CommunicationRangeConstraintRule {
170    fn name(&self) -> &str {
171        "communication_range_constraint"
172    }
173
174    fn description(&self) -> &str {
175        "Determines effective communication range based on mesh capability"
176    }
177
178    fn applies_to(&self, capabilities: &[Capability]) -> bool {
179        let comm_count = capabilities
180            .iter()
181            .filter(|c| {
182                c.get_capability_type() == CapabilityType::Communication
183                    && serde_json::from_str::<Value>(&c.metadata_json)
184                        .ok()
185                        .and_then(|v| v.get("range").cloned())
186                        .is_some()
187            })
188            .count();
189
190        comm_count >= self.min_nodes
191    }
192
193    async fn compose(
194        &self,
195        capabilities: &[Capability],
196        _context: &CompositionContext,
197    ) -> Result<CompositionResult> {
198        let comm_caps: Vec<&Capability> = capabilities
199            .iter()
200            .filter(|c| {
201                c.get_capability_type() == CapabilityType::Communication
202                    && serde_json::from_str::<Value>(&c.metadata_json)
203                        .ok()
204                        .and_then(|v| v.get("range").cloned())
205                        .is_some()
206            })
207            .collect();
208
209        if comm_caps.len() < self.min_nodes {
210            return Ok(CompositionResult::new(vec![], 0.0));
211        }
212
213        // Get communication ranges
214        let ranges: Vec<f64> = comm_caps
215            .iter()
216            .filter_map(|c| {
217                serde_json::from_str::<Value>(&c.metadata_json)
218                    .ok()
219                    .and_then(|v| v.get("range").and_then(|r| r.as_f64()))
220            })
221            .collect();
222
223        // Determine effective range based on mesh capability
224        let (effective_range, limiting_factor) = if self.has_mesh {
225            // With mesh: can relay through intermediaries, use max range
226            let max_range = ranges.iter().cloned().fold(0.0, f64::max);
227            (max_range, "mesh_enabled".to_string())
228        } else {
229            // Without mesh: all must reach all, use min range
230            let min_range = ranges.iter().cloned().fold(f64::INFINITY, f64::min);
231            (min_range, "direct_comms_only".to_string())
232        };
233
234        // Find limiting node
235        let limiting_node = if self.has_mesh {
236            comm_caps
237                .iter()
238                .max_by(|a, b| {
239                    let range_a = serde_json::from_str::<Value>(&a.metadata_json)
240                        .ok()
241                        .and_then(|v| v.get("range").and_then(|r| r.as_f64()))
242                        .unwrap_or(0.0);
243                    let range_b = serde_json::from_str::<Value>(&b.metadata_json)
244                        .ok()
245                        .and_then(|v| v.get("range").and_then(|r| r.as_f64()))
246                        .unwrap_or(0.0);
247                    range_a.partial_cmp(&range_b).unwrap()
248                })
249                .unwrap()
250        } else {
251            comm_caps
252                .iter()
253                .min_by(|a, b| {
254                    let range_a = serde_json::from_str::<Value>(&a.metadata_json)
255                        .ok()
256                        .and_then(|v| v.get("range").and_then(|r| r.as_f64()))
257                        .unwrap_or(0.0);
258                    let range_b = serde_json::from_str::<Value>(&b.metadata_json)
259                        .ok()
260                        .and_then(|v| v.get("range").and_then(|r| r.as_f64()))
261                        .unwrap_or(0.0);
262                    range_a.partial_cmp(&range_b).unwrap()
263                })
264                .unwrap()
265        };
266
267        // Average confidence across communication capabilities
268        let avg_confidence: f32 =
269            comm_caps.iter().map(|c| c.confidence).sum::<f32>() / comm_caps.len() as f32;
270
271        let mut composed = Capability::new(
272            format!("constraint_comm_range_{}", uuid::Uuid::new_v4()),
273            "Team Communication Range".to_string(),
274            CapabilityType::Emergent,
275            avg_confidence,
276        );
277        composed.metadata_json = serde_json::to_string(&json!({
278            "composition_type": "constraint",
279            "pattern": "communication_range",
280            "effective_range": effective_range,
281            "mesh_enabled": self.has_mesh,
282            "limiting_factor": limiting_factor,
283            "limiting_node": limiting_node.id,
284            "node_count": comm_caps.len(),
285            "individual_ranges": ranges,
286            "description": if self.has_mesh {
287                "Extended range through mesh networking"
288            } else {
289                "Range constrained by weakest link"
290            }
291        }))
292        .unwrap_or_default();
293
294        let contributor_ids: Vec<String> = comm_caps.iter().map(|c| c.id.clone()).collect();
295
296        Ok(CompositionResult::new(vec![composed], avg_confidence)
297            .with_contributors(contributor_ids))
298    }
299}
300
301/// Rule for determining mission duration constraint
302///
303/// Mission duration is limited by the platform with shortest endurance.
304/// Critical for planning operations that require the entire team.
305pub struct MissionDurationConstraintRule {
306    /// Minimum number of platforms
307    min_platforms: usize,
308}
309
310impl MissionDurationConstraintRule {
311    /// Create a new mission duration constraint rule
312    pub fn new(min_platforms: usize) -> Self {
313        Self { min_platforms }
314    }
315}
316
317impl Default for MissionDurationConstraintRule {
318    fn default() -> Self {
319        Self::new(2)
320    }
321}
322
323#[async_trait]
324impl CompositionRule for MissionDurationConstraintRule {
325    fn name(&self) -> &str {
326        "mission_duration_constraint"
327    }
328
329    fn description(&self) -> &str {
330        "Determines maximum mission duration based on shortest platform endurance"
331    }
332
333    fn applies_to(&self, capabilities: &[Capability]) -> bool {
334        let platforms_with_endurance = capabilities
335            .iter()
336            .filter(|c| {
337                serde_json::from_str::<Value>(&c.metadata_json)
338                    .ok()
339                    .and_then(|v| v.get("endurance_minutes").cloned())
340                    .is_some()
341            })
342            .count();
343
344        platforms_with_endurance >= self.min_platforms
345    }
346
347    async fn compose(
348        &self,
349        capabilities: &[Capability],
350        _context: &CompositionContext,
351    ) -> Result<CompositionResult> {
352        let platforms: Vec<&Capability> = capabilities
353            .iter()
354            .filter(|c| {
355                serde_json::from_str::<Value>(&c.metadata_json)
356                    .ok()
357                    .and_then(|v| v.get("endurance_minutes").cloned())
358                    .is_some()
359            })
360            .collect();
361
362        if platforms.len() < self.min_platforms {
363            return Ok(CompositionResult::new(vec![], 0.0));
364        }
365
366        // Get endurance values
367        let endurances: Vec<f64> = platforms
368            .iter()
369            .filter_map(|c| {
370                serde_json::from_str::<Value>(&c.metadata_json)
371                    .ok()
372                    .and_then(|v| v.get("endurance_minutes").and_then(|e| e.as_f64()))
373            })
374            .collect();
375
376        // Mission duration is limited by shortest endurance
377        let mission_duration = endurances.iter().cloned().fold(f64::INFINITY, f64::min);
378
379        // Find limiting platform
380        let limiting_platform = platforms
381            .iter()
382            .min_by(|a, b| {
383                let endurance_a = serde_json::from_str::<Value>(&a.metadata_json)
384                    .ok()
385                    .and_then(|v| v.get("endurance_minutes").and_then(|e| e.as_f64()))
386                    .unwrap_or(0.0);
387                let endurance_b = serde_json::from_str::<Value>(&b.metadata_json)
388                    .ok()
389                    .and_then(|v| v.get("endurance_minutes").and_then(|e| e.as_f64()))
390                    .unwrap_or(0.0);
391                endurance_a.partial_cmp(&endurance_b).unwrap()
392            })
393            .unwrap();
394
395        // Confidence based on limiting platform
396        let mission_confidence = limiting_platform.confidence;
397
398        let mut composed = Capability::new(
399            format!("constraint_mission_duration_{}", uuid::Uuid::new_v4()),
400            "Team Mission Duration".to_string(),
401            CapabilityType::Emergent,
402            mission_confidence,
403        );
404        composed.metadata_json = serde_json::to_string(&json!({
405            "composition_type": "constraint",
406            "pattern": "mission_duration",
407            "mission_duration_minutes": mission_duration,
408            "platform_count": platforms.len(),
409            "limiting_platform": limiting_platform.id,
410            "individual_endurances": endurances,
411            "description": "Mission duration constrained by shortest endurance"
412        }))
413        .unwrap_or_default();
414
415        let contributor_ids: Vec<String> = platforms.iter().map(|c| c.id.clone()).collect();
416
417        Ok(CompositionResult::new(vec![composed], mission_confidence)
418            .with_contributors(contributor_ids))
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use serde_json::json;
426
427    #[tokio::test]
428    async fn test_team_speed_constraint() {
429        let rule = TeamSpeedConstraintRule::default();
430
431        let mut fast_platform = Capability::new(
432            "fast1".to_string(),
433            "Fast Drone".to_string(),
434            CapabilityType::Mobility,
435            0.9,
436        );
437        fast_platform.metadata_json =
438            serde_json::to_string(&json!({"max_speed": 20.0})).unwrap_or_default();
439
440        let mut slow_platform = Capability::new(
441            "slow1".to_string(),
442            "Slow Ground Vehicle".to_string(),
443            CapabilityType::Mobility,
444            0.85,
445        );
446        slow_platform.metadata_json =
447            serde_json::to_string(&json!({"max_speed": 5.0})).unwrap_or_default();
448
449        let caps = vec![fast_platform, slow_platform];
450        let context = CompositionContext::new(vec!["node1".to_string(), "node2".to_string()]);
451
452        assert!(rule.applies_to(&caps));
453
454        let result = rule.compose(&caps, &context).await.unwrap();
455        assert!(result.has_compositions());
456
457        let composed = &result.composed_capabilities[0];
458        assert_eq!(composed.name, "Team Speed");
459        // Team speed should be limited by slowest member (5.0 m/s)
460        let metadata: Value = serde_json::from_str(&composed.metadata_json).unwrap();
461        assert_eq!(metadata["team_speed"].as_f64().unwrap(), 5.0);
462        assert_eq!(metadata["limiting_platform"].as_str().unwrap(), "slow1");
463        // Confidence should match slowest member
464        assert_eq!(composed.confidence, 0.85);
465    }
466
467    #[tokio::test]
468    async fn test_communication_range_without_mesh() {
469        let rule = CommunicationRangeConstraintRule::new(2, false); // No mesh
470
471        let mut long_range = Capability::new(
472            "comm1".to_string(),
473            "Long Range Radio".to_string(),
474            CapabilityType::Communication,
475            0.9,
476        );
477        long_range.metadata_json =
478            serde_json::to_string(&json!({"range": 1000.0})).unwrap_or_default();
479
480        let mut short_range = Capability::new(
481            "comm2".to_string(),
482            "Short Range Radio".to_string(),
483            CapabilityType::Communication,
484            0.85,
485        );
486        short_range.metadata_json =
487            serde_json::to_string(&json!({"range": 200.0})).unwrap_or_default();
488
489        let caps = vec![long_range, short_range];
490        let context = CompositionContext::new(vec!["node1".to_string(), "node2".to_string()]);
491
492        let result = rule.compose(&caps, &context).await.unwrap();
493        assert!(result.has_compositions());
494
495        let composed = &result.composed_capabilities[0];
496        // Without mesh, range is limited by weakest link (200m)
497        let metadata: Value = serde_json::from_str(&composed.metadata_json).unwrap();
498        assert_eq!(metadata["effective_range"].as_f64().unwrap(), 200.0);
499        assert!(!metadata["mesh_enabled"].as_bool().unwrap());
500        assert_eq!(metadata["limiting_node"].as_str().unwrap(), "comm2");
501    }
502
503    #[tokio::test]
504    async fn test_communication_range_with_mesh() {
505        let rule = CommunicationRangeConstraintRule::new(2, true); // With mesh
506
507        let mut long_range = Capability::new(
508            "comm1".to_string(),
509            "Long Range Radio".to_string(),
510            CapabilityType::Communication,
511            0.9,
512        );
513        long_range.metadata_json =
514            serde_json::to_string(&json!({"range": 1000.0})).unwrap_or_default();
515
516        let mut short_range = Capability::new(
517            "comm2".to_string(),
518            "Short Range Radio".to_string(),
519            CapabilityType::Communication,
520            0.85,
521        );
522        short_range.metadata_json =
523            serde_json::to_string(&json!({"range": 200.0})).unwrap_or_default();
524
525        let caps = vec![long_range, short_range];
526        let context = CompositionContext::new(vec!["node1".to_string(), "node2".to_string()]);
527
528        let result = rule.compose(&caps, &context).await.unwrap();
529        assert!(result.has_compositions());
530
531        let composed = &result.composed_capabilities[0];
532        // With mesh, can use maximum range (1000m)
533        let metadata: Value = serde_json::from_str(&composed.metadata_json).unwrap();
534        assert_eq!(metadata["effective_range"].as_f64().unwrap(), 1000.0);
535        assert!(metadata["mesh_enabled"].as_bool().unwrap());
536        assert_eq!(metadata["limiting_node"].as_str().unwrap(), "comm1");
537    }
538
539    #[tokio::test]
540    async fn test_mission_duration_constraint() {
541        let rule = MissionDurationConstraintRule::default();
542
543        let mut long_endurance = Capability::new(
544            "platform1".to_string(),
545            "Fixed-Wing UAV".to_string(),
546            CapabilityType::Mobility,
547            0.95,
548        );
549        long_endurance.metadata_json =
550            serde_json::to_string(&json!({"endurance_minutes": 120.0})).unwrap_or_default(); // 2 hours
551
552        let mut short_endurance = Capability::new(
553            "platform2".to_string(),
554            "Quadcopter".to_string(),
555            CapabilityType::Mobility,
556            0.8,
557        );
558        short_endurance.metadata_json =
559            serde_json::to_string(&json!({"endurance_minutes": 25.0})).unwrap_or_default(); // 25 minutes
560
561        let caps = vec![long_endurance, short_endurance];
562        let context = CompositionContext::new(vec!["node1".to_string(), "node2".to_string()]);
563
564        assert!(rule.applies_to(&caps));
565
566        let result = rule.compose(&caps, &context).await.unwrap();
567        assert!(result.has_compositions());
568
569        let composed = &result.composed_capabilities[0];
570        assert_eq!(composed.name, "Team Mission Duration");
571        // Mission duration limited by shortest endurance (25 minutes)
572        let metadata: Value = serde_json::from_str(&composed.metadata_json).unwrap();
573        assert_eq!(metadata["mission_duration_minutes"].as_f64().unwrap(), 25.0);
574        assert_eq!(metadata["limiting_platform"].as_str().unwrap(), "platform2");
575        // Confidence matches limiting platform
576        assert_eq!(composed.confidence, 0.8);
577    }
578
579    #[tokio::test]
580    async fn test_constraint_rules_dont_apply_insufficient_platforms() {
581        let speed_rule = TeamSpeedConstraintRule::default();
582        let comm_rule = CommunicationRangeConstraintRule::default();
583        let duration_rule = MissionDurationConstraintRule::default();
584
585        // Single platform
586        let mut single_platform = Capability::new(
587            "platform1".to_string(),
588            "Solo Platform".to_string(),
589            CapabilityType::Mobility,
590            0.9,
591        );
592        single_platform.metadata_json =
593            serde_json::to_string(&json!({"max_speed": 10.0, "endurance_minutes": 60.0}))
594                .unwrap_or_default();
595
596        let caps = vec![single_platform];
597
598        // All rules require at least 2 platforms
599        assert!(!speed_rule.applies_to(&caps));
600        assert!(!comm_rule.applies_to(&caps));
601        assert!(!duration_rule.applies_to(&caps));
602    }
603
604    #[tokio::test]
605    async fn test_team_speed_with_three_platforms() {
606        let rule = TeamSpeedConstraintRule::default();
607
608        let platforms: Vec<Capability> = vec![
609            ("fast", 25.0, 0.95),
610            ("medium", 15.0, 0.9),
611            ("slow", 8.0, 0.85),
612        ]
613        .into_iter()
614        .map(|(name, speed, confidence)| {
615            let mut cap = Capability::new(
616                format!("platform_{}", name),
617                name.to_string(),
618                CapabilityType::Mobility,
619                confidence,
620            );
621            cap.metadata_json =
622                serde_json::to_string(&json!({"max_speed": speed})).unwrap_or_default();
623            cap
624        })
625        .collect();
626
627        let context = CompositionContext::new(vec![
628            "node1".to_string(),
629            "node2".to_string(),
630            "node3".to_string(),
631        ]);
632
633        let result = rule.compose(&platforms, &context).await.unwrap();
634        assert!(result.has_compositions());
635
636        let composed = &result.composed_capabilities[0];
637        // Team speed constrained by slowest (8.0)
638        let metadata: Value = serde_json::from_str(&composed.metadata_json).unwrap();
639        assert_eq!(metadata["team_speed"].as_f64().unwrap(), 8.0);
640        assert_eq!(metadata["platform_count"].as_u64().unwrap(), 3);
641    }
642
643    #[tokio::test]
644    async fn test_constraint_metadata_accuracy() {
645        let rule = TeamSpeedConstraintRule::default();
646
647        let mut platform1 = Capability::new(
648            "p1".to_string(),
649            "Platform 1".to_string(),
650            CapabilityType::Mobility,
651            0.9,
652        );
653        platform1.metadata_json =
654            serde_json::to_string(&json!({"max_speed": 12.5})).unwrap_or_default();
655
656        let mut platform2 = Capability::new(
657            "p2".to_string(),
658            "Platform 2".to_string(),
659            CapabilityType::Mobility,
660            0.85,
661        );
662        platform2.metadata_json =
663            serde_json::to_string(&json!({"max_speed": 18.3})).unwrap_or_default();
664
665        let caps = vec![platform1, platform2];
666        let context = CompositionContext::new(vec!["node1".to_string(), "node2".to_string()]);
667
668        let result = rule.compose(&caps, &context).await.unwrap();
669        let composed = &result.composed_capabilities[0];
670
671        // Check that all individual speeds are recorded
672        let metadata: Value = serde_json::from_str(&composed.metadata_json).unwrap();
673        let individual_speeds = metadata["individual_speeds"].as_array().unwrap();
674        assert_eq!(individual_speeds.len(), 2);
675        assert!(individual_speeds.contains(&json!(12.5)));
676        assert!(individual_speeds.contains(&json!(18.3)));
677    }
678}