Skip to main content

peat_protocol/models/
role.rs

1//! Cell role model and scoring
2//!
3//! Defines tactical roles that nodes can fill within a squad, with scoring
4//! algorithms that consider both platform capabilities and human operator specialties.
5
6use crate::models::{
7    CapabilityExt, CapabilityType, NodeConfig, NodeConfigExt, NodeState, NodeStateExt, Operator,
8};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Tactical role that a platform can fill within a squad
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum CellRole {
15    /// Cell leader - elected leader, coordinates squad operations
16    Leader,
17    /// Primary sensor/scout - long-range detection and reconnaissance
18    Sensor,
19    /// Compute node - processes sensor data, runs analysis
20    Compute,
21    /// Communications relay - extends network range
22    Relay,
23    /// Strike platform - primary weapons capability
24    Strike,
25    /// Support platform - logistics, medical, maintenance
26    Support,
27    /// General follower - no specialized role
28    Follower,
29}
30
31impl CellRole {
32    /// Get all assignable roles (excludes Leader, which is elected)
33    pub fn assignable_roles() -> Vec<Self> {
34        vec![
35            Self::Sensor,
36            Self::Compute,
37            Self::Relay,
38            Self::Strike,
39            Self::Support,
40            Self::Follower,
41        ]
42    }
43
44    /// Get human-readable description of role
45    pub fn description(&self) -> &'static str {
46        match self {
47            Self::Leader => "Cell leader - coordinates operations and makes tactical decisions",
48            Self::Sensor => "Sensor/scout - provides long-range detection and reconnaissance",
49            Self::Compute => "Compute node - processes sensor data and runs analysis algorithms",
50            Self::Relay => "Communications relay - extends network range and connectivity",
51            Self::Strike => "Strike platform - engages targets with weapons systems",
52            Self::Support => {
53                "Support platform - provides logistics, medical, or maintenance support"
54            }
55            Self::Follower => "General squad member - performs assigned tasks",
56        }
57    }
58
59    /// Get required capabilities for this role
60    pub fn required_capabilities(&self) -> Vec<CapabilityType> {
61        match self {
62            Self::Leader => vec![CapabilityType::Communication],
63            Self::Sensor => vec![CapabilityType::Sensor],
64            Self::Compute => vec![CapabilityType::Compute],
65            Self::Relay => vec![CapabilityType::Communication],
66            Self::Strike => vec![CapabilityType::Payload],
67            Self::Support => vec![],
68            Self::Follower => vec![],
69        }
70    }
71
72    /// Get preferred capabilities for this role (not required but improve scoring)
73    pub fn preferred_capabilities(&self) -> Vec<CapabilityType> {
74        match self {
75            Self::Leader => vec![CapabilityType::Compute, CapabilityType::Sensor],
76            Self::Sensor => vec![CapabilityType::Communication],
77            Self::Compute => vec![CapabilityType::Communication],
78            Self::Relay => vec![CapabilityType::Sensor],
79            Self::Strike => vec![CapabilityType::Sensor, CapabilityType::Compute],
80            Self::Support => vec![CapabilityType::Mobility],
81            Self::Follower => vec![],
82        }
83    }
84
85    /// Get relevant MOS codes for this role (Military Occupational Specialty)
86    pub fn relevant_mos(&self) -> Vec<&'static str> {
87        match self {
88            Self::Leader => vec!["11B", "11C", "19D"], // Infantry, Indirect Fire, Cavalry Scout
89            Self::Sensor => vec!["19D", "35M", "35N"], // Cavalry Scout, Human Intel, Signals Intel
90            Self::Compute => vec!["35F", "35N", "17C"], // Intel Analyst, Signals Intel, Cyber
91            Self::Relay => vec!["25U", "25B", "25Q"], // Signal Support, IT Specialist, Multichannel
92            Self::Strike => vec!["11B", "11C", "19K"], // Infantry, Indirect Fire, Armor
93            Self::Support => vec!["68W", "88M", "91B"], // Medic, Transport, Mechanic
94            Self::Follower => vec![],
95        }
96    }
97}
98
99/// Role assignment for a platform
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct RoleAssignment {
102    /// Node ID
103    pub platform_id: String,
104    /// Assigned role
105    pub role: CellRole,
106    /// Score for this assignment (0.0-1.0)
107    pub score: f64,
108    /// Whether this is the platform's primary role choice
109    pub is_primary_choice: bool,
110}
111
112impl RoleAssignment {
113    /// Create a new role assignment
114    pub fn new(platform_id: String, role: CellRole, score: f64, is_primary_choice: bool) -> Self {
115        Self {
116            platform_id,
117            role,
118            score,
119            is_primary_choice,
120        }
121    }
122}
123
124/// Role scorer - calculates how well a platform fits a role
125pub struct RoleScorer;
126
127impl RoleScorer {
128    /// Score a platform for a specific role
129    ///
130    /// Scoring considers:
131    /// - Node capabilities (required and preferred)
132    /// - Human operator MOS (if present)
133    /// - Node health and readiness
134    ///
135    /// Returns score 0.0-1.0, or None if platform cannot fill role
136    pub fn score_platform_for_role(
137        config: &NodeConfig,
138        state: &NodeState,
139        role: CellRole,
140    ) -> Option<f64> {
141        let operator = config.get_primary_operator();
142        let mut score = 0.0;
143        let mut weight_sum = 0.0;
144
145        // Check required capabilities (blocking)
146        for required_cap_type in role.required_capabilities() {
147            let has_required = config
148                .capabilities
149                .iter()
150                .any(|c| c.get_capability_type() == required_cap_type);
151
152            if !has_required {
153                return None; // Cannot fill this role
154            }
155        }
156
157        // Score required capabilities (30% weight)
158        let required_score = Self::score_required_capabilities(config, &role);
159        score += required_score * 0.3;
160        weight_sum += 0.3;
161
162        // Score preferred capabilities (20% weight)
163        let preferred_score = Self::score_preferred_capabilities(config, &role);
164        score += preferred_score * 0.2;
165        weight_sum += 0.2;
166
167        // Score operator MOS match (30% weight if operator present)
168        if let Some(op) = operator {
169            let mos_score = Self::score_operator_mos(op, &role);
170            score += mos_score * 0.3;
171            weight_sum += 0.3;
172        }
173
174        // Score platform health (20% weight)
175        let health_score = Self::score_platform_health(state);
176        score += health_score * 0.2;
177        weight_sum += 0.2;
178
179        // Normalize if we didn't use all weights (no operator case)
180        if weight_sum < 1.0 {
181            score /= weight_sum;
182        }
183
184        Some(score.clamp(0.0, 1.0))
185    }
186
187    /// Score required capabilities
188    fn score_required_capabilities(config: &NodeConfig, role: &CellRole) -> f64 {
189        let required = role.required_capabilities();
190        if required.is_empty() {
191            return 1.0;
192        }
193
194        let mut total_score = 0.0;
195        for req_type in &required {
196            let best_capability = config
197                .capabilities
198                .iter()
199                .filter(|c| c.get_capability_type() == *req_type)
200                .max_by(|a, b| {
201                    a.confidence
202                        .partial_cmp(&b.confidence)
203                        .unwrap_or(std::cmp::Ordering::Equal)
204                });
205
206            if let Some(cap) = best_capability {
207                total_score += cap.confidence as f64;
208            }
209        }
210
211        total_score / required.len() as f64
212    }
213
214    /// Score preferred capabilities
215    fn score_preferred_capabilities(config: &NodeConfig, role: &CellRole) -> f64 {
216        let preferred = role.preferred_capabilities();
217        if preferred.is_empty() {
218            return 1.0;
219        }
220
221        let mut total_score = 0.0;
222        let mut count = 0;
223
224        for pref_type in preferred {
225            if let Some(best_cap) = config
226                .capabilities
227                .iter()
228                .filter(|c| c.get_capability_type() == pref_type)
229                .max_by(|a, b| {
230                    a.confidence
231                        .partial_cmp(&b.confidence)
232                        .unwrap_or(std::cmp::Ordering::Equal)
233                })
234            {
235                total_score += best_cap.confidence as f64;
236                count += 1;
237            }
238        }
239
240        if count > 0 {
241            total_score / count as f64
242        } else {
243            0.5 // Neutral score if no preferred capabilities
244        }
245    }
246
247    /// Score operator MOS match
248    fn score_operator_mos(operator: &Operator, role: &CellRole) -> f64 {
249        let relevant_mos = role.relevant_mos();
250        if relevant_mos.is_empty() {
251            return 0.5; // Neutral score for roles with no MOS preference
252        }
253
254        if relevant_mos.contains(&operator.mos.as_str()) {
255            0.9 // High score for matching MOS
256        } else {
257            0.3 // Low score for non-matching MOS
258        }
259    }
260
261    /// Score platform health
262    fn score_platform_health(state: &NodeState) -> f64 {
263        match state.get_health() {
264            crate::models::HealthStatus::Nominal => 1.0,
265            crate::models::HealthStatus::Degraded => 0.6,
266            crate::models::HealthStatus::Critical => 0.3,
267            crate::models::HealthStatus::Failed => 0.0,
268            crate::models::HealthStatus::Unspecified => 0.5,
269        }
270    }
271
272    /// Get all role scores for a platform
273    pub fn score_all_roles(config: &NodeConfig, state: &NodeState) -> HashMap<CellRole, f64> {
274        let mut scores = HashMap::new();
275
276        for role in CellRole::assignable_roles() {
277            if let Some(score) = Self::score_platform_for_role(config, state, role) {
278                scores.insert(role, score);
279            }
280        }
281
282        scores
283    }
284
285    /// Get the best role for a platform
286    pub fn best_role_for_platform(
287        config: &NodeConfig,
288        state: &NodeState,
289    ) -> Option<(CellRole, f64)> {
290        Self::score_all_roles(config, state)
291            .into_iter()
292            .max_by(|(_, score_a), (_, score_b)| {
293                score_a
294                    .partial_cmp(score_b)
295                    .unwrap_or(std::cmp::Ordering::Equal)
296            })
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::models::{
304        AuthorityLevel, BindingType, Capability, HumanMachinePair, HumanMachinePairExt, NodeConfig,
305        NodeConfigExt, NodeStateExt, OperatorExt, OperatorRank,
306    };
307
308    fn create_test_platform_with_capabilities(caps: Vec<Capability>) -> (NodeConfig, NodeState) {
309        let mut config = NodeConfig::new("test_platform".to_string());
310        for cap in caps {
311            config.add_capability(cap);
312        }
313        let state = NodeState::new((0.0, 0.0, 0.0));
314        (config, state)
315    }
316
317    fn create_test_operator(mos: &str, rank: OperatorRank) -> Operator {
318        Operator::new(
319            "op_1".to_string(),
320            "Test Operator".to_string(),
321            rank,
322            AuthorityLevel::Commander,
323            mos.to_string(),
324        )
325    }
326
327    #[test]
328    fn test_role_required_capabilities() {
329        assert_eq!(
330            CellRole::Sensor.required_capabilities(),
331            vec![CapabilityType::Sensor]
332        );
333        assert_eq!(
334            CellRole::Strike.required_capabilities(),
335            vec![CapabilityType::Payload]
336        );
337        assert!(CellRole::Follower.required_capabilities().is_empty());
338    }
339
340    #[test]
341    fn test_role_relevant_mos() {
342        let sensor_mos = CellRole::Sensor.relevant_mos();
343        assert!(sensor_mos.contains(&"19D")); // Cavalry Scout
344
345        let relay_mos = CellRole::Relay.relevant_mos();
346        assert!(relay_mos.contains(&"25U")); // Signal Support
347    }
348
349    #[test]
350    fn test_score_platform_without_required_capability() {
351        // Node without sensing capability cannot be sensor
352        let (config, state) = create_test_platform_with_capabilities(vec![Capability::new(
353            "cpu_1".to_string(),
354            "CPU".to_string(),
355            CapabilityType::Compute,
356            0.8,
357        )]);
358
359        let score = RoleScorer::score_platform_for_role(&config, &state, CellRole::Sensor);
360        assert!(score.is_none());
361    }
362
363    #[test]
364    fn test_score_platform_with_required_capability() {
365        // Node with sensing capability can be sensor
366        let (config, state) = create_test_platform_with_capabilities(vec![Capability::new(
367            "radar_1".to_string(),
368            "Radar".to_string(),
369            CapabilityType::Sensor,
370            0.9,
371        )]);
372
373        let score = RoleScorer::score_platform_for_role(&config, &state, CellRole::Sensor);
374        assert!(score.is_some());
375        assert!(score.unwrap() > 0.5);
376    }
377
378    #[test]
379    fn test_score_with_operator_mos_match() {
380        let (mut config, state) = create_test_platform_with_capabilities(vec![Capability::new(
381            "camera_1".to_string(),
382            "Camera".to_string(),
383            CapabilityType::Sensor,
384            0.8,
385        )]);
386
387        let operator = create_test_operator("19D", OperatorRank::E5); // Cavalry Scout
388        let binding = crate::models::HumanMachinePair::new(
389            vec![operator],
390            vec![config.id.clone()],
391            crate::models::BindingType::OneToOne,
392        );
393        config.set_operator_binding(Some(binding));
394
395        let score_with_match =
396            RoleScorer::score_platform_for_role(&config, &state, CellRole::Sensor).unwrap();
397
398        // Create platform without operator for comparison
399        let (config_no_op, state_no_op) =
400            create_test_platform_with_capabilities(vec![Capability::new(
401                "camera_2".to_string(),
402                "Camera".to_string(),
403                CapabilityType::Sensor,
404                0.8,
405            )]);
406
407        let score_without_operator =
408            RoleScorer::score_platform_for_role(&config_no_op, &state_no_op, CellRole::Sensor)
409                .unwrap();
410
411        // Score with matching MOS should be higher
412        assert!(score_with_match > score_without_operator);
413    }
414
415    #[test]
416    fn test_score_with_operator_mos_mismatch() {
417        let (mut config, state) = create_test_platform_with_capabilities(vec![Capability::new(
418            "camera_1".to_string(),
419            "Camera".to_string(),
420            CapabilityType::Sensor,
421            0.8,
422        )]);
423
424        let operator = create_test_operator("68W", OperatorRank::E4); // Medic (not sensor MOS)
425        let binding = crate::models::HumanMachinePair::new(
426            vec![operator],
427            vec![config.id.clone()],
428            crate::models::BindingType::OneToOne,
429        );
430        config.set_operator_binding(Some(binding));
431
432        let score_with_mismatch =
433            RoleScorer::score_platform_for_role(&config, &state, CellRole::Sensor).unwrap();
434
435        // Score should still be valid but not boosted
436        assert!(score_with_mismatch > 0.0);
437        assert!(score_with_mismatch < 1.0);
438    }
439
440    #[test]
441    fn test_score_all_roles() {
442        let (config, state) = create_test_platform_with_capabilities(vec![
443            Capability::new(
444                "camera_1".to_string(),
445                "Camera".to_string(),
446                CapabilityType::Sensor,
447                0.9,
448            ),
449            Capability::new(
450                "radio_1".to_string(),
451                "Radio".to_string(),
452                CapabilityType::Communication,
453                0.7,
454            ),
455        ]);
456
457        let scores = RoleScorer::score_all_roles(&config, &state);
458
459        // Should have scores for roles it can fill
460        assert!(scores.contains_key(&CellRole::Sensor));
461        assert!(scores.contains_key(&CellRole::Relay));
462        assert!(scores.contains_key(&CellRole::Follower));
463
464        // Should not have scores for roles requiring capabilities it doesn't have
465        assert!(!scores.contains_key(&CellRole::Strike));
466        assert!(!scores.contains_key(&CellRole::Compute));
467    }
468
469    #[test]
470    fn test_best_role_for_platform() {
471        let mut config = NodeConfig::new("test_platform".to_string());
472        config.add_capability(Capability::new(
473            "radar_1".to_string(),
474            "Radar".to_string(),
475            CapabilityType::Sensor,
476            0.95,
477        ));
478        config.add_capability(Capability::new(
479            "radio_1".to_string(),
480            "Radio".to_string(),
481            CapabilityType::Communication,
482            0.5,
483        ));
484
485        // Add operator with Sensor-relevant MOS to boost Sensor score
486        let operator = create_test_operator("19D", OperatorRank::E4); // Cavalry Scout
487        let platform_id = config.id.clone();
488        config.operator_binding = Some(HumanMachinePair::new(
489            vec![operator],
490            vec![platform_id],
491            BindingType::OneToOne,
492        ));
493
494        let state = NodeState::new((0.0, 0.0, 0.0));
495
496        let (best_role, score) = RoleScorer::best_role_for_platform(&config, &state).unwrap();
497
498        // Best role should be Sensor due to high sensing capability + matching MOS
499        assert_eq!(best_role, CellRole::Sensor);
500        assert!(score > 0.5);
501    }
502
503    #[test]
504    fn test_role_assignment_creation() {
505        let assignment = RoleAssignment::new("node_1".to_string(), CellRole::Sensor, 0.85, true);
506
507        assert_eq!(assignment.platform_id, "node_1");
508        assert_eq!(assignment.role, CellRole::Sensor);
509        assert_eq!(assignment.score, 0.85);
510        assert!(assignment.is_primary_choice);
511    }
512
513    #[test]
514    fn test_assignable_roles() {
515        let roles = CellRole::assignable_roles();
516
517        // Leader is not assignable (it's elected)
518        assert!(!roles.contains(&CellRole::Leader));
519
520        // All other roles should be assignable
521        assert!(roles.contains(&CellRole::Sensor));
522        assert!(roles.contains(&CellRole::Compute));
523        assert!(roles.contains(&CellRole::Relay));
524        assert!(roles.contains(&CellRole::Strike));
525        assert!(roles.contains(&CellRole::Support));
526        assert!(roles.contains(&CellRole::Follower));
527    }
528
529    #[test]
530    fn test_degraded_platform_role_scoring() {
531        // Edge case: Degraded platform should have lower score than nominal
532        let (config_nominal, state_nominal) =
533            create_test_platform_with_capabilities(vec![Capability::new(
534                "sensor_1".to_string(),
535                "Sensor".to_string(),
536                CapabilityType::Sensor,
537                0.9,
538            )]);
539
540        let (config_degraded, mut state_degraded) =
541            create_test_platform_with_capabilities(vec![Capability::new(
542                "sensor_2".to_string(),
543                "Sensor".to_string(),
544                CapabilityType::Sensor,
545                0.9,
546            )]);
547        state_degraded.update_health(crate::models::HealthStatus::Degraded);
548
549        let score_nominal =
550            RoleScorer::score_platform_for_role(&config_nominal, &state_nominal, CellRole::Sensor)
551                .unwrap();
552        let score_degraded = RoleScorer::score_platform_for_role(
553            &config_degraded,
554            &state_degraded,
555            CellRole::Sensor,
556        )
557        .unwrap();
558
559        // Degraded platform should score lower
560        assert!(score_degraded < score_nominal);
561        // But should still be viable (>0.4)
562        assert!(score_degraded > 0.4);
563    }
564
565    #[test]
566    fn test_critical_platform_role_scoring() {
567        // Edge case: Critical health platform
568        let (config, mut state) = create_test_platform_with_capabilities(vec![Capability::new(
569            "sensor_1".to_string(),
570            "Sensor".to_string(),
571            CapabilityType::Sensor,
572            0.9,
573        )]);
574        state.update_health(crate::models::HealthStatus::Critical);
575
576        let score = RoleScorer::score_platform_for_role(&config, &state, CellRole::Sensor).unwrap();
577
578        // Critical health (0.3) weighs 20%, so contributes 0.06
579        // High capability (0.9) weighs 30%, so contributes 0.27
580        // Total score should be around 0.5-0.6 range
581        assert!(score > 0.0);
582        assert!(score < 0.7); // Less than nominal but still viable
583    }
584
585    #[test]
586    fn test_failed_platform_role_scoring() {
587        // Edge case: Failed platform
588        let (config, mut state) = create_test_platform_with_capabilities(vec![Capability::new(
589            "sensor_1".to_string(),
590            "Sensor".to_string(),
591            CapabilityType::Sensor,
592            0.9,
593        )]);
594        state.update_health(crate::models::HealthStatus::Failed);
595
596        let score = RoleScorer::score_platform_for_role(&config, &state, CellRole::Sensor).unwrap();
597
598        // Failed health (0.0) weighs 20%, so contributes 0.0
599        // High capability (0.9) weighs 30%, so contributes 0.27
600        // Total score should be around 0.4-0.5 (capability only)
601        assert!(score > 0.2);
602        assert!(score < 0.6); // Significantly reduced but not zero
603    }
604}