Skip to main content

peat_mesh/topology/
selection.rs

1//! Peer selection algorithm for topology formation
2//!
3//! This module implements the logic for evaluating and selecting peers
4//! based on node profiles, resources, geographic proximity, and hierarchy levels.
5
6use crate::beacon::{GeoPosition, GeographicBeacon, HierarchyLevel, NodeMobility};
7
8/// Candidate peer with selection score
9#[derive(Debug, Clone)]
10pub struct PeerCandidate {
11    pub beacon: GeographicBeacon,
12    pub score: f64,
13}
14
15/// Peer selection configuration
16#[derive(Debug, Clone)]
17pub struct SelectionConfig {
18    /// Weight for mobility factor (0.0-1.0)
19    pub mobility_weight: f64,
20    /// Weight for resource availability (0.0-1.0)
21    pub resource_weight: f64,
22    /// Weight for battery level (0.0-1.0)
23    pub battery_weight: f64,
24    /// Weight for geographic proximity (0.0-1.0)
25    pub proximity_weight: f64,
26    /// Maximum distance in meters (None = unlimited)
27    pub max_distance_meters: Option<f64>,
28    /// Maximum number of linked peers per node (None = unlimited)
29    pub max_children_per_parent: Option<usize>,
30}
31
32impl Default for SelectionConfig {
33    fn default() -> Self {
34        Self {
35            mobility_weight: 0.3,
36            resource_weight: 0.3,
37            battery_weight: 0.2,
38            proximity_weight: 0.2,
39            max_distance_meters: Some(10_000.0), // 10km default
40            max_children_per_parent: Some(10),   // 10 children max
41        }
42    }
43}
44
45impl SelectionConfig {
46    /// Tactical configuration for short-range operations
47    pub fn tactical() -> Self {
48        Self {
49            mobility_weight: 0.4,
50            resource_weight: 0.25,
51            battery_weight: 0.15,
52            proximity_weight: 0.2,
53            max_distance_meters: Some(2_000.0), // 2km
54            max_children_per_parent: Some(5),
55        }
56    }
57
58    /// Distributed configuration for wide-area operations
59    pub fn distributed() -> Self {
60        Self {
61            mobility_weight: 0.2,
62            resource_weight: 0.4,
63            battery_weight: 0.2,
64            proximity_weight: 0.2,
65            max_distance_meters: None, // Unlimited
66            max_children_per_parent: Some(15),
67        }
68    }
69}
70
71/// Peer selection algorithm
72pub struct PeerSelector {
73    config: SelectionConfig,
74    own_position: GeoPosition,
75    own_level: HierarchyLevel,
76}
77
78impl PeerSelector {
79    /// Create a new peer selector
80    pub fn new(
81        config: SelectionConfig,
82        own_position: GeoPosition,
83        own_level: HierarchyLevel,
84    ) -> Self {
85        Self {
86            config,
87            own_position,
88            own_level,
89        }
90    }
91
92    /// Select the best peer from nearby beacons
93    ///
94    /// Returns None if no suitable peer found
95    pub fn select_peer(&self, candidates: &[GeographicBeacon]) -> Option<PeerCandidate> {
96        let mut scored: Vec<PeerCandidate> = candidates
97            .iter()
98            .filter(|beacon| self.is_valid_peer(beacon))
99            .map(|beacon| PeerCandidate {
100                beacon: beacon.clone(),
101                score: self.score_candidate(beacon),
102            })
103            .collect();
104
105        // Sort by score (highest first)
106        scored.sort_by(|a, b| {
107            b.score
108                .partial_cmp(&a.score)
109                .unwrap_or(std::cmp::Ordering::Equal)
110        });
111
112        scored.into_iter().next()
113    }
114
115    /// Check if a beacon is a valid peer candidate
116    fn is_valid_peer(&self, beacon: &GeographicBeacon) -> bool {
117        // Must be at least one level higher in hierarchy
118        if !beacon.hierarchy_level.can_be_parent_of(&self.own_level) {
119            return false;
120        }
121
122        // Check distance constraints
123        if let Some(max_dist) = self.config.max_distance_meters {
124            let distance = self.own_position.distance_to(&beacon.position);
125            if distance > max_dist {
126                return false;
127            }
128        }
129
130        // Check if node can accept linked peers
131        if !beacon.can_parent {
132            return false;
133        }
134
135        true
136    }
137
138    /// Score a candidate peer (higher is better)
139    fn score_candidate(&self, beacon: &GeographicBeacon) -> f64 {
140        let mut score = 0.0;
141
142        // Mobility score: Static nodes preferred
143        if let Some(mobility) = beacon.mobility {
144            score += self.mobility_score(&mobility) * self.config.mobility_weight;
145        } else {
146            // Default score if mobility not specified
147            score += 0.5 * self.config.mobility_weight;
148        }
149
150        // Resource and battery scores
151        if let Some(ref resources) = beacon.resources {
152            score += self.resource_score(resources) * self.config.resource_weight;
153            score += self.battery_score(resources) * self.config.battery_weight;
154        } else {
155            // Default scores if no resources specified
156            score += 0.5 * self.config.resource_weight;
157            score += 0.5 * self.config.battery_weight;
158        }
159
160        // Proximity score: Closer is better
161        score += self.proximity_score(beacon) * self.config.proximity_weight;
162
163        score
164    }
165
166    /// Score based on mobility (0.0-1.0)
167    /// Static = 1.0, SemiMobile = 0.6, Mobile = 0.3
168    fn mobility_score(&self, mobility: &NodeMobility) -> f64 {
169        match mobility {
170            NodeMobility::Static => 1.0,
171            NodeMobility::SemiMobile => 0.6,
172            NodeMobility::Mobile => 0.3,
173        }
174    }
175
176    /// Score based on resource availability (0.0-1.0)
177    fn resource_score(&self, resources: &crate::beacon::NodeResources) -> f64 {
178        // CPU utilization (lower is better)
179        let cpu_score = 1.0 - (resources.cpu_usage_percent as f64 / 100.0);
180
181        // Memory utilization (lower is better)
182        let mem_score = 1.0 - (resources.memory_usage_percent as f64 / 100.0);
183
184        // Bandwidth availability (higher is better, normalize to 0-1)
185        let bandwidth_score = (resources.bandwidth_mbps as f64).min(100.0) / 100.0;
186
187        // Average of all resource scores
188        (cpu_score + mem_score + bandwidth_score) / 3.0
189    }
190
191    /// Score based on battery level (0.0-1.0)
192    fn battery_score(&self, resources: &crate::beacon::NodeResources) -> f64 {
193        if let Some(battery) = resources.battery_percent {
194            battery as f64 / 100.0
195        } else {
196            1.0 // Assume AC powered = best
197        }
198    }
199
200    /// Score based on proximity (0.0-1.0)
201    /// Uses exponential decay with distance
202    fn proximity_score(&self, beacon: &GeographicBeacon) -> f64 {
203        let distance = self.own_position.distance_to(&beacon.position);
204
205        // Exponential decay: score = e^(-distance / scale)
206        // Scale factor = max_distance / 3 (so score ≈ 0.05 at max distance)
207        let scale = self
208            .config
209            .max_distance_meters
210            .unwrap_or(10_000.0)
211            .max(1000.0)
212            / 3.0;
213
214        (-distance / scale).exp()
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_select_best_peer_prefers_static_nodes() {
224        let selector = PeerSelector::new(
225            SelectionConfig::default(),
226            GeoPosition::new(37.7749, -122.4194),
227            HierarchyLevel::Squad,
228        );
229
230        let static_peer = create_test_beacon(
231            "static",
232            GeoPosition::new(37.7750, -122.4195),
233            HierarchyLevel::Platoon,
234            NodeMobility::Static,
235            50, // 50% resource usage
236        );
237
238        let mobile_peer = create_test_beacon(
239            "mobile",
240            GeoPosition::new(37.7751, -122.4196),
241            HierarchyLevel::Platoon,
242            NodeMobility::Mobile,
243            30, // 30% resource usage (better resources)
244        );
245
246        let result = selector.select_peer(&[static_peer, mobile_peer]);
247
248        assert!(result.is_some());
249        let winner = result.unwrap();
250        assert_eq!(winner.beacon.node_id, "static");
251    }
252
253    #[test]
254    fn test_select_peer_respects_hierarchy() {
255        let selector = PeerSelector::new(
256            SelectionConfig::default(),
257            GeoPosition::new(37.7749, -122.4194),
258            HierarchyLevel::Squad,
259        );
260
261        // Platoon can be selected peer of Squad  (Platoon > Squad in hierarchy)
262        let valid_peer = create_test_beacon(
263            "valid",
264            GeoPosition::new(37.7750, -122.4195),
265            HierarchyLevel::Platoon,
266            NodeMobility::Static,
267            50,
268        );
269
270        // Platform cannot be selected peer of Squad (Platform < Squad in hierarchy)
271        let invalid_peer = create_test_beacon(
272            "invalid",
273            GeoPosition::new(37.7751, -122.4196),
274            HierarchyLevel::Platform,
275            NodeMobility::Static,
276            30,
277        );
278
279        let result = selector.select_peer(&[valid_peer.clone(), invalid_peer]);
280
281        assert!(result.is_some());
282        let winner = result.unwrap();
283        assert_eq!(winner.beacon.node_id, "valid");
284    }
285
286    #[test]
287    fn test_select_peer_prefers_closer_nodes() {
288        let selector = PeerSelector::new(
289            SelectionConfig {
290                proximity_weight: 0.9, // Heavy weight on proximity
291                mobility_weight: 0.1,
292                resource_weight: 0.0,
293                battery_weight: 0.0,
294                ..Default::default()
295            },
296            GeoPosition::new(37.7749, -122.4194),
297            HierarchyLevel::Squad,
298        );
299
300        let nearby = create_test_beacon(
301            "nearby",
302            GeoPosition::new(37.7750, -122.4195), // ~100m away
303            HierarchyLevel::Platoon,
304            NodeMobility::Mobile,
305            70,
306        );
307
308        let far = create_test_beacon(
309            "far",
310            GeoPosition::new(37.8000, -122.4400), // ~2.5km away
311            HierarchyLevel::Platoon,
312            NodeMobility::Static,
313            30,
314        );
315
316        let result = selector.select_peer(&[nearby, far]);
317
318        assert!(result.is_some());
319        let winner = result.unwrap();
320        assert_eq!(winner.beacon.node_id, "nearby");
321    }
322
323    #[test]
324    fn test_select_peer_respects_distance_limit() {
325        let selector = PeerSelector::new(
326            SelectionConfig {
327                max_distance_meters: Some(1_000.0), // 1km limit
328                ..Default::default()
329            },
330            GeoPosition::new(37.7749, -122.4194),
331            HierarchyLevel::Squad,
332        );
333
334        let too_far = create_test_beacon(
335            "far",
336            GeoPosition::new(37.8000, -122.4400), // ~2.5km away
337            HierarchyLevel::Platoon,
338            NodeMobility::Static,
339            30,
340        );
341
342        let result = selector.select_peer(&[too_far]);
343        assert!(result.is_none());
344    }
345
346    #[test]
347    fn test_select_peer_prefers_better_resources() {
348        let selector = PeerSelector::new(
349            SelectionConfig {
350                resource_weight: 0.9, // Heavy weight on resources
351                mobility_weight: 0.1,
352                proximity_weight: 0.0,
353                battery_weight: 0.0,
354                ..Default::default()
355            },
356            GeoPosition::new(37.7749, -122.4194),
357            HierarchyLevel::Squad,
358        );
359
360        let low_resources = create_test_beacon(
361            "low",
362            GeoPosition::new(37.7750, -122.4195),
363            HierarchyLevel::Platoon,
364            NodeMobility::Static,
365            90, // 90% resource usage
366        );
367
368        let high_resources = create_test_beacon(
369            "high",
370            GeoPosition::new(37.7751, -122.4196),
371            HierarchyLevel::Platoon,
372            NodeMobility::Static,
373            20, // 20% resource usage
374        );
375
376        let result = selector.select_peer(&[low_resources, high_resources]);
377
378        assert!(result.is_some());
379        let winner = result.unwrap();
380        assert_eq!(winner.beacon.node_id, "high");
381    }
382
383    fn create_test_beacon(
384        node_id: &str,
385        position: GeoPosition,
386        level: HierarchyLevel,
387        mobility: NodeMobility,
388        resource_usage: u8,
389    ) -> GeographicBeacon {
390        let resources = crate::beacon::NodeResources {
391            cpu_cores: 4,
392            memory_mb: 8192,
393            bandwidth_mbps: 100,
394            cpu_usage_percent: resource_usage,
395            memory_usage_percent: resource_usage,
396            battery_percent: Some(80),
397        };
398
399        let mut beacon = GeographicBeacon::new(node_id.to_string(), position, level);
400        beacon.mobility = Some(mobility);
401        beacon.resources = Some(resources);
402        beacon.can_parent = true;
403        beacon.parent_priority = 100;
404        beacon
405    }
406
407    #[test]
408    fn test_tactical_config() {
409        let config = SelectionConfig::tactical();
410        assert_eq!(config.mobility_weight, 0.4);
411        assert_eq!(config.resource_weight, 0.25);
412        assert_eq!(config.battery_weight, 0.15);
413        assert_eq!(config.proximity_weight, 0.2);
414        assert_eq!(config.max_distance_meters, Some(2_000.0));
415        assert_eq!(config.max_children_per_parent, Some(5));
416    }
417
418    #[test]
419    fn test_distributed_config() {
420        let config = SelectionConfig::distributed();
421        assert_eq!(config.mobility_weight, 0.2);
422        assert_eq!(config.resource_weight, 0.4);
423        assert_eq!(config.battery_weight, 0.2);
424        assert_eq!(config.proximity_weight, 0.2);
425        assert!(config.max_distance_meters.is_none());
426        assert_eq!(config.max_children_per_parent, Some(15));
427    }
428
429    #[test]
430    fn test_empty_candidates_returns_none() {
431        let selector = PeerSelector::new(
432            SelectionConfig::default(),
433            GeoPosition::new(37.7749, -122.4194),
434            HierarchyLevel::Squad,
435        );
436        assert!(selector.select_peer(&[]).is_none());
437    }
438
439    #[test]
440    fn test_can_parent_false_filtered() {
441        let selector = PeerSelector::new(
442            SelectionConfig::default(),
443            GeoPosition::new(37.7749, -122.4194),
444            HierarchyLevel::Squad,
445        );
446
447        let mut beacon = create_test_beacon(
448            "no-parent",
449            GeoPosition::new(37.7750, -122.4195),
450            HierarchyLevel::Platoon,
451            NodeMobility::Static,
452            30,
453        );
454        beacon.can_parent = false;
455
456        assert!(selector.select_peer(&[beacon]).is_none());
457    }
458
459    #[test]
460    fn test_no_mobility_beacon_scoring() {
461        let selector = PeerSelector::new(
462            SelectionConfig::default(),
463            GeoPosition::new(37.7749, -122.4194),
464            HierarchyLevel::Squad,
465        );
466
467        let mut beacon = create_test_beacon(
468            "no-mob",
469            GeoPosition::new(37.7750, -122.4195),
470            HierarchyLevel::Platoon,
471            NodeMobility::Static,
472            30,
473        );
474        beacon.mobility = None;
475
476        let result = selector.select_peer(&[beacon]);
477        assert!(result.is_some());
478        // Should still score (default 0.5 * mobility_weight)
479        assert!(result.unwrap().score > 0.0);
480    }
481
482    #[test]
483    fn test_no_resources_beacon_scoring() {
484        let selector = PeerSelector::new(
485            SelectionConfig::default(),
486            GeoPosition::new(37.7749, -122.4194),
487            HierarchyLevel::Squad,
488        );
489
490        let mut beacon = GeographicBeacon::new(
491            "no-res".to_string(),
492            GeoPosition::new(37.7750, -122.4195),
493            HierarchyLevel::Platoon,
494        );
495        beacon.mobility = Some(NodeMobility::Static);
496        beacon.resources = None;
497        beacon.can_parent = true;
498
499        let result = selector.select_peer(&[beacon]);
500        assert!(result.is_some());
501        assert!(result.unwrap().score > 0.0);
502    }
503
504    #[test]
505    fn test_semi_mobile_scoring() {
506        let selector = PeerSelector::new(
507            SelectionConfig {
508                mobility_weight: 1.0,
509                resource_weight: 0.0,
510                battery_weight: 0.0,
511                proximity_weight: 0.0,
512                ..Default::default()
513            },
514            GeoPosition::new(37.7749, -122.4194),
515            HierarchyLevel::Squad,
516        );
517
518        let semi = create_test_beacon(
519            "semi",
520            GeoPosition::new(37.7750, -122.4195),
521            HierarchyLevel::Platoon,
522            NodeMobility::SemiMobile,
523            50,
524        );
525
526        let result = selector.select_peer(&[semi]).unwrap();
527        // SemiMobile score = 0.6 * 1.0 = 0.6
528        assert!((result.score - 0.6).abs() < 0.01);
529    }
530
531    #[test]
532    fn test_unlimited_distance_config() {
533        let selector = PeerSelector::new(
534            SelectionConfig {
535                max_distance_meters: None,
536                ..Default::default()
537            },
538            GeoPosition::new(37.7749, -122.4194),
539            HierarchyLevel::Squad,
540        );
541
542        // Very far away beacon should still be valid
543        let far = create_test_beacon(
544            "far",
545            GeoPosition::new(40.7128, -74.0060), // New York
546            HierarchyLevel::Platoon,
547            NodeMobility::Static,
548            30,
549        );
550
551        assert!(selector.select_peer(&[far]).is_some());
552    }
553
554    #[test]
555    fn test_battery_none_ac_powered() {
556        let selector = PeerSelector::new(
557            SelectionConfig {
558                battery_weight: 1.0,
559                mobility_weight: 0.0,
560                resource_weight: 0.0,
561                proximity_weight: 0.0,
562                ..Default::default()
563            },
564            GeoPosition::new(37.7749, -122.4194),
565            HierarchyLevel::Squad,
566        );
567
568        let mut beacon = create_test_beacon(
569            "ac",
570            GeoPosition::new(37.7750, -122.4195),
571            HierarchyLevel::Platoon,
572            NodeMobility::Static,
573            50,
574        );
575        // Set battery_percent to None (AC powered = 1.0 score)
576        beacon.resources.as_mut().unwrap().battery_percent = None;
577
578        let result = selector.select_peer(&[beacon]).unwrap();
579        // Battery score = 1.0 * 1.0 = 1.0
580        assert!((result.score - 1.0).abs() < 0.01);
581    }
582
583    #[test]
584    fn test_default_config() {
585        let config = SelectionConfig::default();
586        assert_eq!(config.mobility_weight, 0.3);
587        assert_eq!(config.resource_weight, 0.3);
588        assert_eq!(config.battery_weight, 0.2);
589        assert_eq!(config.proximity_weight, 0.2);
590        assert_eq!(config.max_distance_meters, Some(10_000.0));
591        assert_eq!(config.max_children_per_parent, Some(10));
592    }
593
594    #[test]
595    fn test_config_debug_clone() {
596        let config = SelectionConfig::default();
597        let cloned = config.clone();
598        assert_eq!(cloned.mobility_weight, config.mobility_weight);
599        let _ = format!("{:?}", config);
600    }
601
602    #[test]
603    fn test_peer_candidate_debug_clone() {
604        let beacon = create_test_beacon(
605            "test",
606            GeoPosition::new(37.7749, -122.4194),
607            HierarchyLevel::Platoon,
608            NodeMobility::Static,
609            50,
610        );
611        let candidate = PeerCandidate {
612            beacon,
613            score: 0.75,
614        };
615        let cloned = candidate.clone();
616        assert_eq!(cloned.score, 0.75);
617        let _ = format!("{:?}", candidate);
618    }
619}