chie_core/
geo_selection.rs

1//! Geographic-aware peer selection for optimal content delivery.
2//!
3//! This module provides geographic intelligence for peer selection, including:
4//! - Haversine distance calculation between geographic coordinates
5//! - Region-based peer grouping
6//! - Geographic diversity scoring
7//! - Proximity-based peer ranking
8//!
9//! # Example
10//!
11//! ```rust
12//! use chie_core::geo_selection::{GeoLocation, GeoPeer, GeoSelector, GeoConfig};
13//!
14//! # fn example() {
15//! let config = GeoConfig::default();
16//! let mut selector = GeoSelector::new(config);
17//!
18//! // Add peers with geographic locations
19//! selector.add_peer(GeoPeer {
20//!     peer_id: "peer1".to_string(),
21//!     location: GeoLocation::new(37.7749, -122.4194), // San Francisco
22//!     region: "us-west".to_string(),
23//!     latency_ms: 50.0,
24//!     bandwidth_mbps: 100.0,
25//! });
26//!
27//! selector.add_peer(GeoPeer {
28//!     peer_id: "peer2".to_string(),
29//!     location: GeoLocation::new(40.7128, -74.0060), // New York
30//!     region: "us-east".to_string(),
31//!     latency_ms: 120.0,
32//!     bandwidth_mbps: 100.0,
33//! });
34//!
35//! // Find nearest peer to a location
36//! let target = GeoLocation::new(37.3382, -121.8863); // San Jose
37//! let nearest = selector.find_nearest(&target, 5);
38//! # }
39//! ```
40
41use serde::{Deserialize, Serialize};
42use std::collections::HashMap;
43
44/// Earth's radius in kilometers.
45const EARTH_RADIUS_KM: f64 = 6371.0;
46
47/// Geographic location with latitude and longitude.
48#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
49pub struct GeoLocation {
50    /// Latitude in degrees (-90 to 90).
51    pub latitude: f64,
52    /// Longitude in degrees (-180 to 180).
53    pub longitude: f64,
54}
55
56impl GeoLocation {
57    /// Create a new geographic location.
58    ///
59    /// # Panics
60    ///
61    /// Panics if latitude is not in range [-90, 90] or longitude is not in range [-180, 180].
62    #[must_use]
63    pub fn new(latitude: f64, longitude: f64) -> Self {
64        assert!(
65            (-90.0..=90.0).contains(&latitude),
66            "Latitude must be between -90 and 90 degrees"
67        );
68        assert!(
69            (-180.0..=180.0).contains(&longitude),
70            "Longitude must be between -180 and 180 degrees"
71        );
72
73        Self {
74            latitude,
75            longitude,
76        }
77    }
78
79    /// Calculate the great-circle distance to another location using the Haversine formula.
80    ///
81    /// Returns the distance in kilometers.
82    #[must_use]
83    #[inline]
84    pub fn distance_to(&self, other: &GeoLocation) -> f64 {
85        haversine_distance(self, other)
86    }
87
88    /// Check if this location is within a certain radius of another location.
89    #[must_use]
90    #[inline]
91    pub fn is_within(&self, other: &GeoLocation, radius_km: f64) -> bool {
92        self.distance_to(other) <= radius_km
93    }
94
95    /// Get the bearing (direction) to another location in degrees (0-360).
96    #[must_use]
97    #[inline]
98    pub fn bearing_to(&self, other: &GeoLocation) -> f64 {
99        let lat1 = self.latitude.to_radians();
100        let lat2 = other.latitude.to_radians();
101        let delta_lon = (other.longitude - self.longitude).to_radians();
102
103        let y = delta_lon.sin() * lat2.cos();
104        let x = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * delta_lon.cos();
105
106        let bearing = y.atan2(x).to_degrees();
107        (bearing + 360.0) % 360.0
108    }
109}
110
111/// Calculate the Haversine distance between two geographic locations.
112///
113/// Returns the distance in kilometers.
114#[must_use]
115pub fn haversine_distance(loc1: &GeoLocation, loc2: &GeoLocation) -> f64 {
116    let lat1 = loc1.latitude.to_radians();
117    let lat2 = loc2.latitude.to_radians();
118    let delta_lat = (loc2.latitude - loc1.latitude).to_radians();
119    let delta_lon = (loc2.longitude - loc1.longitude).to_radians();
120
121    let a =
122        (delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lon / 2.0).sin().powi(2);
123    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
124
125    EARTH_RADIUS_KM * c
126}
127
128/// A peer with geographic location information.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct GeoPeer {
131    /// Peer identifier.
132    pub peer_id: String,
133    /// Geographic location of the peer.
134    pub location: GeoLocation,
135    /// Region identifier (e.g., "us-west", "eu-central").
136    pub region: String,
137    /// Average latency in milliseconds.
138    pub latency_ms: f64,
139    /// Available bandwidth in Mbps.
140    pub bandwidth_mbps: f64,
141}
142
143impl GeoPeer {
144    /// Calculate distance to a target location.
145    #[must_use]
146    #[inline]
147    pub fn distance_to(&self, target: &GeoLocation) -> f64 {
148        self.location.distance_to(target)
149    }
150
151    /// Calculate a geographic score (lower distance and latency = higher score).
152    #[must_use]
153    #[inline]
154    pub fn geo_score(&self, target: &GeoLocation) -> f64 {
155        let distance = self.distance_to(target);
156        let distance_score = 1.0 / (1.0 + distance / 1000.0); // Normalize by 1000km
157        let latency_score = 1.0 / (1.0 + self.latency_ms / 100.0); // Normalize by 100ms
158
159        // Weighted combination
160        0.6 * distance_score + 0.4 * latency_score
161    }
162}
163
164/// Configuration for geographic peer selection.
165#[derive(Debug, Clone)]
166pub struct GeoConfig {
167    /// Prefer peers within this radius (km).
168    pub preferred_radius_km: f64,
169    /// Maximum acceptable distance (km).
170    pub max_distance_km: f64,
171    /// Whether to enable region-based grouping.
172    pub enable_region_grouping: bool,
173    /// Minimum number of peers to select from different regions (for diversity).
174    pub min_region_diversity: usize,
175    /// Weight for distance vs latency (0.0 = all latency, 1.0 = all distance).
176    pub distance_weight: f64,
177}
178
179impl Default for GeoConfig {
180    fn default() -> Self {
181        Self {
182            preferred_radius_km: 500.0, // 500 km preferred
183            max_distance_km: 10000.0,   // 10,000 km max
184            enable_region_grouping: true,
185            min_region_diversity: 2, // At least 2 different regions
186            distance_weight: 0.6,    // 60% distance, 40% latency
187        }
188    }
189}
190
191/// Geographic peer selector.
192pub struct GeoSelector {
193    /// Configuration.
194    config: GeoConfig,
195    /// Map of peer ID to peer info.
196    peers: HashMap<String, GeoPeer>,
197    /// Map of region to peer IDs.
198    regions: HashMap<String, Vec<String>>,
199}
200
201impl GeoSelector {
202    /// Create a new geographic peer selector.
203    #[must_use]
204    pub fn new(config: GeoConfig) -> Self {
205        Self {
206            config,
207            peers: HashMap::new(),
208            regions: HashMap::new(),
209        }
210    }
211
212    /// Add a peer to the selector.
213    pub fn add_peer(&mut self, peer: GeoPeer) {
214        // Update region mapping
215        self.regions
216            .entry(peer.region.clone())
217            .or_default()
218            .push(peer.peer_id.clone());
219
220        // Add peer
221        self.peers.insert(peer.peer_id.clone(), peer);
222    }
223
224    /// Remove a peer from the selector.
225    pub fn remove_peer(&mut self, peer_id: &str) -> Option<GeoPeer> {
226        if let Some(peer) = self.peers.remove(peer_id) {
227            // Remove from region mapping
228            if let Some(region_peers) = self.regions.get_mut(&peer.region) {
229                region_peers.retain(|id| id != peer_id);
230                if region_peers.is_empty() {
231                    self.regions.remove(&peer.region);
232                }
233            }
234            Some(peer)
235        } else {
236            None
237        }
238    }
239
240    /// Find the N nearest peers to a target location.
241    #[must_use]
242    #[inline]
243    pub fn find_nearest(&self, target: &GeoLocation, n: usize) -> Vec<GeoPeer> {
244        let mut peers_with_distance: Vec<(GeoPeer, f64)> = self
245            .peers
246            .values()
247            .map(|peer| {
248                let distance = peer.distance_to(target);
249                (peer.clone(), distance)
250            })
251            .collect();
252
253        // Sort by distance
254        peers_with_distance.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
255
256        // Take top N
257        peers_with_distance
258            .into_iter()
259            .take(n)
260            .map(|(peer, _)| peer)
261            .collect()
262    }
263
264    /// Find peers within a specific radius.
265    #[must_use]
266    #[inline]
267    pub fn find_within_radius(&self, target: &GeoLocation, radius_km: f64) -> Vec<GeoPeer> {
268        self.peers
269            .values()
270            .filter(|peer| peer.distance_to(target) <= radius_km)
271            .cloned()
272            .collect()
273    }
274
275    /// Select best peers based on geographic score.
276    #[must_use]
277    #[inline]
278    pub fn select_best(&self, target: &GeoLocation, n: usize) -> Vec<GeoPeer> {
279        let mut peers_with_score: Vec<(GeoPeer, f64)> = self
280            .peers
281            .values()
282            .map(|peer| {
283                let score = peer.geo_score(target);
284                (peer.clone(), score)
285            })
286            .collect();
287
288        // Sort by score (descending)
289        peers_with_score.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
290
291        peers_with_score
292            .into_iter()
293            .take(n)
294            .map(|(peer, _)| peer)
295            .collect()
296    }
297
298    /// Select peers with geographic diversity (from different regions).
299    #[must_use]
300    #[inline]
301    pub fn select_diverse(&self, target: &GeoLocation, n: usize) -> Vec<GeoPeer> {
302        if !self.config.enable_region_grouping {
303            return self.select_best(target, n);
304        }
305
306        let mut selected = Vec::new();
307        let mut used_regions = std::collections::HashSet::new();
308
309        // Get all peers sorted by score
310        let mut peers_with_score: Vec<(GeoPeer, f64)> = self
311            .peers
312            .values()
313            .map(|peer| {
314                let score = peer.geo_score(target);
315                (peer.clone(), score)
316            })
317            .collect();
318
319        peers_with_score.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
320
321        // First, select best peer from each region
322        for (peer, _) in &peers_with_score {
323            if !used_regions.contains(&peer.region) {
324                selected.push(peer.clone());
325                used_regions.insert(peer.region.clone());
326
327                if selected.len() >= n {
328                    return selected;
329                }
330            }
331        }
332
333        // Then fill remaining slots with best peers
334        for (peer, _) in peers_with_score {
335            if selected.iter().any(|p| p.peer_id == peer.peer_id) {
336                continue;
337            }
338            selected.push(peer);
339            if selected.len() >= n {
340                break;
341            }
342        }
343
344        selected
345    }
346
347    /// Get peers grouped by region.
348    #[must_use]
349    #[inline]
350    pub fn get_peers_by_region(&self, region: &str) -> Vec<GeoPeer> {
351        if let Some(peer_ids) = self.regions.get(region) {
352            peer_ids
353                .iter()
354                .filter_map(|id| self.peers.get(id))
355                .cloned()
356                .collect()
357        } else {
358            Vec::new()
359        }
360    }
361
362    /// Get all available regions.
363    #[must_use]
364    #[inline]
365    pub fn get_regions(&self) -> Vec<String> {
366        self.regions.keys().cloned().collect()
367    }
368
369    /// Get statistics about geographic distribution.
370    #[must_use]
371    pub fn get_geo_stats(&self) -> GeoStats {
372        let mut region_counts = HashMap::new();
373        for (region, peers) in &self.regions {
374            region_counts.insert(region.clone(), peers.len());
375        }
376
377        let total_peers = self.peers.len();
378        let total_regions = self.regions.len();
379
380        GeoStats {
381            total_peers,
382            total_regions,
383            peers_per_region: region_counts,
384            avg_peers_per_region: if total_regions > 0 {
385                total_peers as f64 / total_regions as f64
386            } else {
387                0.0
388            },
389        }
390    }
391
392    /// Get peer count.
393    #[must_use]
394    #[inline]
395    pub fn peer_count(&self) -> usize {
396        self.peers.len()
397    }
398
399    /// Get a peer by ID.
400    #[must_use]
401    #[inline]
402    pub fn get_peer(&self, peer_id: &str) -> Option<&GeoPeer> {
403        self.peers.get(peer_id)
404    }
405}
406
407/// Geographic distribution statistics.
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct GeoStats {
410    /// Total number of peers.
411    pub total_peers: usize,
412    /// Total number of regions.
413    pub total_regions: usize,
414    /// Number of peers per region.
415    pub peers_per_region: HashMap<String, usize>,
416    /// Average peers per region.
417    pub avg_peers_per_region: f64,
418}
419
420/// Calculate the midpoint between two locations.
421#[must_use]
422pub fn midpoint(loc1: &GeoLocation, loc2: &GeoLocation) -> GeoLocation {
423    let lat1 = loc1.latitude.to_radians();
424    let lon1 = loc1.longitude.to_radians();
425    let lat2 = loc2.latitude.to_radians();
426    let lon2 = loc2.longitude.to_radians();
427
428    let bx = lat2.cos() * (lon2 - lon1).cos();
429    let by = lat2.cos() * (lon2 - lon1).sin();
430
431    let lat3 = (lat1.sin() + lat2.sin()).atan2(((lat1.cos() + bx).powi(2) + by.powi(2)).sqrt());
432    let lon3 = lon1 + by.atan2(lat1.cos() + bx);
433
434    GeoLocation {
435        latitude: lat3.to_degrees(),
436        longitude: lon3.to_degrees(),
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_geo_location_creation() {
446        let loc = GeoLocation::new(37.7749, -122.4194);
447        assert_eq!(loc.latitude, 37.7749);
448        assert_eq!(loc.longitude, -122.4194);
449    }
450
451    #[test]
452    #[should_panic]
453    fn test_invalid_latitude() {
454        let _ = GeoLocation::new(91.0, 0.0);
455    }
456
457    #[test]
458    fn test_haversine_distance() {
459        // San Francisco to New York
460        let sf = GeoLocation::new(37.7749, -122.4194);
461        let ny = GeoLocation::new(40.7128, -74.0060);
462
463        let distance = sf.distance_to(&ny);
464        // Distance should be approximately 4130 km
465        assert!((distance - 4130.0).abs() < 50.0);
466    }
467
468    #[test]
469    fn test_same_location_distance() {
470        let loc = GeoLocation::new(0.0, 0.0);
471        assert_eq!(loc.distance_to(&loc), 0.0);
472    }
473
474    #[test]
475    fn test_is_within_radius() {
476        let loc1 = GeoLocation::new(37.7749, -122.4194);
477        let loc2 = GeoLocation::new(37.3382, -121.8863);
478
479        // These locations are about 70km apart
480        assert!(loc1.is_within(&loc2, 100.0));
481        assert!(!loc1.is_within(&loc2, 50.0));
482    }
483
484    #[test]
485    fn test_geo_selector() {
486        let config = GeoConfig::default();
487        let mut selector = GeoSelector::new(config);
488
489        let peer1 = GeoPeer {
490            peer_id: "peer1".to_string(),
491            location: GeoLocation::new(37.7749, -122.4194),
492            region: "us-west".to_string(),
493            latency_ms: 50.0,
494            bandwidth_mbps: 100.0,
495        };
496
497        let peer2 = GeoPeer {
498            peer_id: "peer2".to_string(),
499            location: GeoLocation::new(40.7128, -74.0060),
500            region: "us-east".to_string(),
501            latency_ms: 120.0,
502            bandwidth_mbps: 100.0,
503        };
504
505        selector.add_peer(peer1);
506        selector.add_peer(peer2);
507
508        assert_eq!(selector.peer_count(), 2);
509
510        // Find nearest to San Jose (closer to SF)
511        let target = GeoLocation::new(37.3382, -121.8863);
512        let nearest = selector.find_nearest(&target, 1);
513
514        assert_eq!(nearest.len(), 1);
515        assert_eq!(nearest[0].peer_id, "peer1");
516    }
517
518    #[test]
519    fn test_region_grouping() {
520        let config = GeoConfig::default();
521        let mut selector = GeoSelector::new(config);
522
523        selector.add_peer(GeoPeer {
524            peer_id: "peer1".to_string(),
525            location: GeoLocation::new(37.7749, -122.4194),
526            region: "us-west".to_string(),
527            latency_ms: 50.0,
528            bandwidth_mbps: 100.0,
529        });
530
531        let region_peers = selector.get_peers_by_region("us-west");
532        assert_eq!(region_peers.len(), 1);
533
534        let regions = selector.get_regions();
535        assert!(regions.contains(&"us-west".to_string()));
536    }
537
538    #[test]
539    fn test_midpoint() {
540        let loc1 = GeoLocation::new(0.0, 0.0);
541        let loc2 = GeoLocation::new(0.0, 10.0);
542
543        let mid = midpoint(&loc1, &loc2);
544        assert!((mid.latitude - 0.0).abs() < 0.01);
545        assert!((mid.longitude - 5.0).abs() < 0.01);
546    }
547
548    #[test]
549    fn test_bearing() {
550        let loc1 = GeoLocation::new(0.0, 0.0);
551        let loc2 = GeoLocation::new(1.0, 0.0);
552
553        let bearing = loc1.bearing_to(&loc2);
554        // Should be approximately 0 degrees (north)
555        assert!((bearing - 0.0).abs() < 1.0 || (bearing - 360.0).abs() < 1.0);
556    }
557}