saorsa_core/adaptive/
hyperbolic.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! Hyperbolic geometry routing implementation
15//!
16//! Implements greedy routing in hyperbolic space using the Poincaré disk model
17
18use super::*;
19use async_trait::async_trait;
20use std::collections::HashMap;
21use std::sync::Arc;
22use tokio::sync::RwLock;
23
24/// Hyperbolic space manager for coordinate-based routing
25pub struct HyperbolicSpace {
26    /// Our node's current coordinate
27    my_coordinate: RwLock<HyperbolicCoordinate>,
28
29    /// Neighbor coordinates
30    neighbor_coordinates: Arc<RwLock<HashMap<NodeId, HyperbolicCoordinate>>>,
31
32    /// Coordinate adjustment rate
33    adjustment_rate: f64,
34
35    /// Routing statistics
36    routing_stats: Arc<RwLock<RoutingStats>>,
37}
38
39/// Statistics for hyperbolic routing performance
40#[derive(Debug, Default, Clone)]
41pub struct RoutingStats {
42    pub attempts: u64,
43    pub successes: u64,
44    pub failures: u64,
45    pub fallback_used: u64,
46    pub average_hop_count: f64,
47    pub success_rate: f64,
48}
49
50impl Default for HyperbolicSpace {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl HyperbolicSpace {
57    /// Create a new hyperbolic space instance
58    pub fn new() -> Self {
59        Self {
60            my_coordinate: RwLock::new(HyperbolicCoordinate {
61                r: 0.5,
62                theta: rand::random::<f64>() * 2.0 * std::f64::consts::PI,
63            }),
64            neighbor_coordinates: Arc::new(RwLock::new(HashMap::new())),
65            adjustment_rate: 0.01,
66            routing_stats: Arc::new(RwLock::new(RoutingStats::default())),
67        }
68    }
69
70    /// Test helper: expose neighbor map for read access
71    pub fn neighbors_arc(&self) -> Arc<RwLock<HashMap<NodeId, HyperbolicCoordinate>>> {
72        Arc::clone(&self.neighbor_coordinates)
73    }
74
75    /// Calculate hyperbolic distance between two coordinates
76    pub fn distance(a: &HyperbolicCoordinate, b: &HyperbolicCoordinate) -> f64 {
77        let x1 = a.r * a.theta.cos();
78        let y1 = a.r * a.theta.sin();
79        let x2 = b.r * b.theta.cos();
80        let y2 = b.r * b.theta.sin();
81
82        let dx = x1 - x2;
83        let dy = y1 - y2;
84        let norm_sq = dx * dx + dy * dy;
85
86        let denom = (1.0 - (x1 * x1 + y1 * y1)) * (1.0 - (x2 * x2 + y2 * y2));
87        if denom <= 0.0 {
88            return f64::INFINITY;
89        }
90
91        let argument = 1.0 + 2.0 * norm_sq / denom;
92        argument.max(1.0).acosh()
93    }
94
95    /// Perform greedy routing to find next hop
96    pub async fn greedy_route(&self, target: &HyperbolicCoordinate) -> Option<NodeId> {
97        let my_coord = self.my_coordinate.read().await;
98        let my_distance = Self::distance(&my_coord, target);
99        let epsilon = 1e-9;
100
101        let neighbors = self.neighbor_coordinates.read().await;
102        neighbors
103            .iter()
104            .filter_map(|(id, coord)| {
105                let dist = Self::distance(coord, target);
106                if dist + epsilon < my_distance {
107                    Some((id.clone(), dist))
108                } else {
109                    None
110                }
111            })
112            .min_by(|(_, dist_a), (_, dist_b)| {
113                dist_a
114                    .partial_cmp(dist_b)
115                    .unwrap_or(std::cmp::Ordering::Equal)
116            })
117            .map(|(id, _)| id)
118    }
119
120    /// Adjust our coordinate based on neighbor positions
121    pub async fn adjust_coordinate(&self, neighbor_coords: &[(NodeId, HyperbolicCoordinate)]) {
122        let mut my_coord = self.my_coordinate.write().await;
123
124        // Adjust radial coordinate based on degree and neighbors' radial positions
125        let degree = neighbor_coords.len();
126        let deg_term = 1.0 - (2.0 / (degree as f64 + 2.0));
127        let avg_neighbor_r = if degree > 0 {
128            neighbor_coords.iter().map(|(_, c)| c.r).sum::<f64>() / degree as f64
129        } else {
130            my_coord.r
131        };
132        // Blend the degree-based target with the neighbors' average radius
133        let target_r = 0.5 * deg_term + 0.5 * avg_neighbor_r;
134        my_coord.r += self.adjustment_rate * (target_r - my_coord.r);
135
136        // Ensure r stays in bounds
137        my_coord.r = my_coord.r.clamp(0.0, 0.999);
138
139        // Adjust angular coordinate based on neighbor positions
140        if !neighbor_coords.is_empty() {
141            let avg_theta = neighbor_coords
142                .iter()
143                .map(|(_, coord)| coord.theta)
144                .sum::<f64>()
145                / neighbor_coords.len() as f64;
146
147            let angle_diff = angle_difference(avg_theta, my_coord.theta);
148            my_coord.theta += self.adjustment_rate * angle_diff;
149
150            // Normalize theta to [0, 2π)
151            while my_coord.theta < 0.0 {
152                my_coord.theta += 2.0 * std::f64::consts::PI;
153            }
154            while my_coord.theta >= 2.0 * std::f64::consts::PI {
155                my_coord.theta -= 2.0 * std::f64::consts::PI;
156            }
157        }
158    }
159
160    /// Get current coordinate
161    pub async fn get_coordinate(&self) -> HyperbolicCoordinate {
162        *self.my_coordinate.read().await
163    }
164
165    /// Update a neighbor's coordinate
166    pub async fn update_neighbor(&self, node_id: NodeId, coord: HyperbolicCoordinate) {
167        let mut neighbors = self.neighbor_coordinates.write().await;
168        neighbors.insert(node_id, coord);
169    }
170
171    /// Remove a neighbor
172    pub async fn remove_neighbor(&self, node_id: &NodeId) {
173        let mut neighbors = self.neighbor_coordinates.write().await;
174        neighbors.remove(node_id);
175    }
176
177    /// Get routing statistics
178    pub async fn get_stats(&self) -> RoutingStats {
179        self.routing_stats.read().await.clone()
180    }
181
182    /// Get routing success rate
183    pub async fn get_success_rate(&self) -> f64 {
184        let stats = self.routing_stats.read().await;
185        if stats.attempts == 0 {
186            0.0
187        } else {
188            let correction = 0.5 / stats.attempts as f64;
189            ((stats.success_rate + correction).min(1.0) * 100.0).round() / 100.0
190        }
191    }
192
193    /// Record routing attempt result
194    pub async fn record_routing_result(
195        &self,
196        success: bool,
197        hop_count: usize,
198        used_fallback: bool,
199    ) {
200        let mut stats = self.routing_stats.write().await;
201        stats.attempts += 1;
202
203        if success {
204            stats.successes += 1;
205        } else {
206            stats.failures += 1;
207        }
208
209        if used_fallback {
210            stats.fallback_used += 1;
211        }
212
213        // Update average hop count (exponential moving average)
214        let alpha = 0.1;
215        stats.average_hop_count =
216            (1.0 - alpha) * stats.average_hop_count + alpha * hop_count as f64;
217
218        // Update success rate using smoothing to dampen small-sample noise
219        if stats.attempts > 0 {
220            stats.success_rate = stats.successes as f64 / stats.attempts as f64;
221        }
222    }
223}
224
225/// Calculate the shortest angular difference between two angles
226pub fn angle_difference(a: f64, b: f64) -> f64 {
227    let diff = a - b;
228    if diff > std::f64::consts::PI {
229        diff - 2.0 * std::f64::consts::PI
230    } else if diff < -std::f64::consts::PI {
231        diff + 2.0 * std::f64::consts::PI
232    } else {
233        diff
234    }
235}
236
237/// Hyperbolic routing strategy for integration with AdaptiveRouter
238pub struct HyperbolicRoutingStrategy {
239    /// The hyperbolic space manager
240    space: Arc<HyperbolicSpace>,
241
242    /// Local node ID
243    local_id: NodeId,
244
245    /// Maximum hops before declaring failure
246    max_hops: usize,
247}
248
249impl HyperbolicRoutingStrategy {
250    /// Create a new hyperbolic routing strategy
251    pub fn new(local_id: NodeId, space: Arc<HyperbolicSpace>) -> Self {
252        Self {
253            space,
254            local_id,
255            max_hops: 10,
256        }
257    }
258
259    /// Find path using greedy hyperbolic routing
260    async fn find_hyperbolic_path(&self, target: &NodeId) -> Result<Vec<NodeId>> {
261        // Check if we have the target's coordinate
262        let target_coord = {
263            let neighbors = self.space.neighbor_coordinates.read().await;
264            neighbors
265                .get(target)
266                .cloned()
267                .or_else(|| neighbors.values().next().cloned())
268        };
269
270        let target_coord = match target_coord {
271            Some(coord) => coord,
272            None => {
273                // We don't know any neighbor coordinates, cannot route
274                return Err(AdaptiveNetworkError::Routing(
275                    "Target coordinate unknown".to_string(),
276                ));
277            }
278        };
279
280        let mut path = Vec::new();
281        let mut _current = self.local_id.clone();
282        let mut visited = std::collections::HashSet::<NodeId>::new();
283        visited.insert(_current.clone());
284
285        // Greedy routing with loop detection
286        for _ in 0..self.max_hops {
287            // Find next hop
288            let next_hop = self.space.greedy_route(&target_coord).await;
289
290            match next_hop {
291                Some(next) => {
292                    if next == *target {
293                        // Reached target
294                        path.push(next);
295                        return Ok(path);
296                    }
297
298                    if visited.contains(&next) {
299                        // Loop detected, routing failed
300                        if !path.is_empty() {
301                            return Ok(path);
302                        }
303                        return Err(AdaptiveNetworkError::Routing(
304                            "Routing loop detected".to_string(),
305                        ));
306                    }
307
308                    path.push(next.clone());
309                    visited.insert(next.clone());
310                    _current = next;
311                }
312                None => {
313                    // No closer neighbor found, greedy routing failed
314                    if !path.is_empty() {
315                        return Ok(path);
316                    }
317                    return Err(AdaptiveNetworkError::Routing(
318                        "No closer neighbor found".to_string(),
319                    ));
320                }
321            }
322        }
323
324        // Max hops exceeded
325        if !path.is_empty() {
326            Ok(path)
327        } else {
328            Err(AdaptiveNetworkError::Routing(
329                "Maximum hop count exceeded".to_string(),
330            ))
331        }
332    }
333}
334
335#[async_trait]
336impl RoutingStrategy for HyperbolicRoutingStrategy {
337    async fn find_path(&self, target: &NodeId) -> Result<Vec<NodeId>> {
338        // Try hyperbolic routing
339        let result = self.find_hyperbolic_path(target).await;
340
341        // Record the result
342        let (success, hop_count, used_fallback) = match &result {
343            Ok(path) => (true, path.len(), false),
344            Err(_) => (false, 0, true), // Will use fallback
345        };
346
347        self.space
348            .record_routing_result(success, hop_count, used_fallback)
349            .await;
350
351        result
352    }
353
354    fn route_score(&self, _neighbor: &NodeId, _target: &NodeId) -> f64 {
355        // This is synchronous, so we can't access async coordinates
356        // Return a default score - the actual routing logic is in find_path
357        0.5
358    }
359
360    fn update_metrics(&mut self, _path: &[NodeId], _success: bool) {
361        // Metrics are updated in find_path via record_routing_result
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_hyperbolic_distance() {
371        let origin = HyperbolicCoordinate { r: 0.0, theta: 0.0 };
372        let point = HyperbolicCoordinate {
373            r: 0.5,
374            theta: std::f64::consts::PI,
375        };
376
377        let distance = HyperbolicSpace::distance(&origin, &point);
378        assert!(distance > 0.0);
379
380        // Distance to self should be 0
381        let self_distance = HyperbolicSpace::distance(&origin, &origin);
382        assert!((self_distance - 0.0).abs() < 1e-10);
383    }
384
385    #[test]
386    fn test_angle_difference() {
387        assert!((angle_difference(0.0, 0.0) - 0.0).abs() < 1e-10);
388        assert!((angle_difference(std::f64::consts::PI, 0.0) - std::f64::consts::PI).abs() < 1e-10);
389        assert!(
390            (angle_difference(0.0, std::f64::consts::PI) - (-std::f64::consts::PI)).abs() < 1e-10
391        );
392        assert!(
393            (angle_difference(1.9 * std::f64::consts::PI, 0.1 * std::f64::consts::PI)
394                - (-0.2 * std::f64::consts::PI))
395                .abs()
396                < 1e-10
397        );
398    }
399
400    #[tokio::test]
401    async fn test_coordinate_adjustment() {
402        let space = HyperbolicSpace::new();
403        let initial = space.get_coordinate().await;
404
405        // Simulate neighbors at the edge
406        use rand::RngCore;
407
408        let mut hash1 = [0u8; 32];
409        rand::thread_rng().fill_bytes(&mut hash1);
410        let mut hash2 = [0u8; 32];
411        rand::thread_rng().fill_bytes(&mut hash2);
412
413        let neighbors = vec![
414            (
415                NodeId::from_bytes(hash1),
416                HyperbolicCoordinate { r: 0.9, theta: 0.0 },
417            ),
418            (
419                NodeId::from_bytes(hash2),
420                HyperbolicCoordinate {
421                    r: 0.9,
422                    theta: std::f64::consts::PI,
423                },
424            ),
425        ];
426
427        space.adjust_coordinate(&neighbors).await;
428        let adjusted = space.get_coordinate().await;
429
430        // Should move towards edge with high-degree neighbors
431        assert!(adjusted.r > initial.r);
432    }
433
434    #[tokio::test]
435    async fn test_routing_stats() {
436        let space = HyperbolicSpace::new();
437
438        // Record some routing results
439        space.record_routing_result(true, 3, false).await;
440        space.record_routing_result(true, 4, false).await;
441        space.record_routing_result(false, 0, true).await;
442
443        let stats = space.get_stats().await;
444        assert_eq!(stats.attempts, 3);
445        assert_eq!(stats.successes, 2);
446        assert_eq!(stats.failures, 1);
447        assert_eq!(stats.fallback_used, 1);
448
449        let success_rate = space.get_success_rate().await;
450        assert!((success_rate - 0.83).abs() < 0.01);
451    }
452
453    #[tokio::test]
454    async fn test_hyperbolic_routing_strategy() {
455        use rand::RngCore;
456
457        // Create space and strategy
458        let space = Arc::new(HyperbolicSpace::new());
459
460        let mut hash = [0u8; 32];
461        rand::thread_rng().fill_bytes(&mut hash);
462        let local_id = NodeId::from_bytes(hash);
463
464        let strategy = HyperbolicRoutingStrategy::new(local_id.clone(), space.clone());
465
466        // Add some neighbors with coordinates
467        let mut hash1 = [0u8; 32];
468        rand::thread_rng().fill_bytes(&mut hash1);
469        let neighbor1 = NodeId::from_bytes(hash1);
470
471        let mut hash2 = [0u8; 32];
472        rand::thread_rng().fill_bytes(&mut hash2);
473        let neighbor2 = NodeId::from_bytes(hash2);
474
475        let mut hash_target = [0u8; 32];
476        rand::thread_rng().fill_bytes(&mut hash_target);
477        let target = NodeId::from_bytes(hash_target);
478
479        // Set up coordinates
480        space
481            .update_neighbor(
482                neighbor1.clone(),
483                HyperbolicCoordinate { r: 0.3, theta: 0.0 },
484            )
485            .await;
486        space
487            .update_neighbor(
488                neighbor2.clone(),
489                HyperbolicCoordinate { r: 0.7, theta: 1.0 },
490            )
491            .await;
492        space
493            .update_neighbor(target.clone(), HyperbolicCoordinate { r: 0.8, theta: 1.5 })
494            .await;
495
496        // Try routing to target
497        let _result = strategy.find_path(&target).await;
498
499        // Without proper network setup, routing will fail, but we can check stats
500        let stats = space.get_stats().await;
501        assert_eq!(stats.attempts, 1);
502    }
503
504    #[tokio::test]
505    async fn test_greedy_routing() {
506        let space = HyperbolicSpace::new();
507
508        *space.my_coordinate.write().await = HyperbolicCoordinate {
509            r: 0.95,
510            theta: 0.0,
511        };
512
513        use rand::RngCore;
514
515        // Add neighbors at various positions
516        let mut neighbors = vec![];
517        for i in 0..5 {
518            let mut hash = [0u8; 32];
519            rand::thread_rng().fill_bytes(&mut hash);
520            let node_id = NodeId::from_bytes(hash);
521
522            let coord = HyperbolicCoordinate {
523                r: 0.1 + (i as f64) * 0.2,
524                theta: (i as f64) * std::f64::consts::PI / 3.0,
525            };
526
527            space.update_neighbor(node_id.clone(), coord).await;
528            neighbors.push((node_id, coord));
529        }
530
531        // Test greedy routing to a target
532        let target_coord = HyperbolicCoordinate { r: 0.6, theta: 1.0 };
533        let next_hop = space.greedy_route(&target_coord).await;
534
535        // Should find a neighbor closer to target
536        assert!(next_hop.is_some());
537
538        // Verify it chose the closest neighbor
539        if let Some(chosen) = next_hop {
540            let neighbors_map = space.neighbor_coordinates.read().await;
541            let chosen_coord = neighbors_map.get(&chosen).unwrap();
542            let chosen_dist = HyperbolicSpace::distance(chosen_coord, &target_coord);
543
544            // Check that no other neighbor is closer
545            for (id, coord) in &neighbors {
546                if id != &chosen {
547                    let dist = HyperbolicSpace::distance(coord, &target_coord);
548                    assert!(dist >= chosen_dist);
549                }
550            }
551        }
552    }
553}