Skip to main content

ipfrs_network/
content_routing_optimizer.rs

1//! Content Routing Optimizer
2//!
3//! Optimizes content routing decisions by maintaining a cost model for fetching
4//! blocks from different peers, caching routing decisions, and detecting when
5//! cached routes have become stale.
6
7use std::collections::HashMap;
8
9/// Cost model for fetching content from a specific peer.
10#[derive(Debug, Clone)]
11pub struct RouteCost {
12    /// Peer identifier
13    pub peer_id: String,
14    /// Round-trip latency in milliseconds
15    pub latency_ms: f64,
16    /// Available bandwidth in bytes per second
17    pub bandwidth_bps: u64,
18    /// Peer reliability score in range [0.0, 1.0]
19    pub reliability: f64,
20}
21
22impl RouteCost {
23    /// Estimated fetch cost in milliseconds for the given payload size.
24    ///
25    /// Formula: `(size_bytes / bandwidth_bps) * 1000 + latency_ms`
26    pub fn fetch_cost(&self, size_bytes: u64) -> f64 {
27        let transfer_ms = size_bytes as f64 / (self.bandwidth_bps.max(1) as f64) * 1000.0;
28        transfer_ms + self.latency_ms
29    }
30
31    /// Weighted quality score — higher is better.
32    ///
33    /// Formula: `reliability / fetch_cost(65536).max(1e-9)`
34    pub fn weighted_score(&self) -> f64 {
35        self.reliability / self.fetch_cost(65536).max(1e-9)
36    }
37}
38
39/// A cached routing entry for a single CID.
40#[derive(Debug, Clone)]
41pub struct RouteEntry {
42    /// Content identifier this entry is for
43    pub cid: String,
44    /// Candidate peers sorted by `weighted_score` descending
45    pub candidates: Vec<RouteCost>,
46    /// Unix timestamp (seconds) at which this entry was last updated
47    pub cached_at_secs: u64,
48    /// Number of times this entry has been served from cache
49    pub hit_count: u64,
50}
51
52impl RouteEntry {
53    /// Returns a reference to the best (lowest-cost, highest-reliability) peer,
54    /// or `None` if there are no candidates.
55    pub fn best_peer(&self) -> Option<&RouteCost> {
56        self.candidates.first()
57    }
58
59    /// Returns `true` when the entry has exceeded its time-to-live.
60    pub fn is_stale(&self, ttl_secs: u64, now_secs: u64) -> bool {
61        now_secs.saturating_sub(self.cached_at_secs) >= ttl_secs
62    }
63}
64
65/// Aggregate statistics for the routing optimizer.
66#[derive(Debug, Clone, Default)]
67pub struct RoutingStats {
68    /// Number of lookups that were served from cache
69    pub cache_hits: u64,
70    /// Number of lookups that were not in cache (or were stale)
71    pub cache_misses: u64,
72    /// Cumulative number of stale entries that have been evicted
73    pub stale_routes: u64,
74    /// Current number of entries held in the cache
75    pub total_routes: usize,
76}
77
78impl RoutingStats {
79    /// Fraction of total lookups that were cache hits.
80    ///
81    /// Returns `0.0` when no lookups have been performed yet.
82    pub fn hit_rate(&self) -> f64 {
83        let total = self.cache_hits + self.cache_misses;
84        if total == 0 {
85            0.0
86        } else {
87            self.cache_hits as f64 / total as f64
88        }
89    }
90}
91
92/// Configuration knobs for the [`ContentRoutingOptimizer`].
93#[derive(Debug, Clone)]
94pub struct OptimizerConfig {
95    /// Maximum age of a cached route before it is considered stale (seconds)
96    pub route_ttl_secs: u64,
97    /// Maximum number of candidate peers stored per CID
98    pub max_candidates_per_cid: usize,
99    /// Minimum reliability score for a peer to be admitted as a candidate
100    pub min_reliability: f64,
101}
102
103impl Default for OptimizerConfig {
104    fn default() -> Self {
105        Self {
106            route_ttl_secs: 300,
107            max_candidates_per_cid: 5,
108            min_reliability: 0.3,
109        }
110    }
111}
112
113/// Content routing optimizer that caches and manages peer routing decisions.
114pub struct ContentRoutingOptimizer {
115    /// Routing table keyed by CID string
116    routes: HashMap<String, RouteEntry>,
117    /// Optimizer configuration
118    config: OptimizerConfig,
119    /// Running statistics
120    stats: RoutingStats,
121}
122
123impl ContentRoutingOptimizer {
124    /// Creates a new optimizer with the supplied configuration.
125    pub fn new(config: OptimizerConfig) -> Self {
126        Self {
127            routes: HashMap::new(),
128            config,
129            stats: RoutingStats::default(),
130        }
131    }
132
133    /// Adds (or replaces) a routing entry for `cid`.
134    ///
135    /// Candidates that do not meet the `min_reliability` threshold are
136    /// discarded before insertion. The survivors are sorted by
137    /// [`RouteCost::weighted_score`] descending and truncated to
138    /// `max_candidates_per_cid`.
139    pub fn add_route(&mut self, cid: String, candidates: Vec<RouteCost>, now_secs: u64) {
140        let mut filtered: Vec<RouteCost> = candidates
141            .into_iter()
142            .filter(|c| c.reliability >= self.config.min_reliability)
143            .collect();
144
145        filtered.sort_by(|a, b| {
146            b.weighted_score()
147                .partial_cmp(&a.weighted_score())
148                .unwrap_or(std::cmp::Ordering::Equal)
149        });
150
151        filtered.truncate(self.config.max_candidates_per_cid);
152
153        let entry = RouteEntry {
154            cid: cid.clone(),
155            candidates: filtered,
156            cached_at_secs: now_secs,
157            hit_count: 0,
158        };
159
160        self.routes.insert(cid, entry);
161        self.stats.total_routes = self.routes.len();
162    }
163
164    /// Returns the best peer for `cid`, updating cache statistics.
165    ///
166    /// Returns `None` when:
167    /// - The entry does not exist (cache miss).
168    /// - The entry is stale — the entry is removed and counted as a stale
169    ///   eviction plus a cache miss.
170    pub fn best_route(&mut self, cid: &str, now_secs: u64) -> Option<&RouteCost> {
171        let ttl = self.config.route_ttl_secs;
172
173        if let Some(entry) = self.routes.get(cid) {
174            if entry.is_stale(ttl, now_secs) {
175                self.routes.remove(cid);
176                self.stats.stale_routes += 1;
177                self.stats.cache_misses += 1;
178                self.stats.total_routes = self.routes.len();
179                return None;
180            }
181        } else {
182            self.stats.cache_misses += 1;
183            return None;
184        }
185
186        // Entry is present and fresh — record hit.
187        let entry = self.routes.get_mut(cid)?;
188        self.stats.cache_hits += 1;
189        entry.hit_count += 1;
190
191        // Re-borrow immutably to return a reference with the right lifetime.
192        self.routes.get(cid).and_then(|e| e.best_peer())
193    }
194
195    /// Evicts all stale entries from the cache.
196    ///
197    /// Returns the number of entries that were removed.
198    pub fn evict_stale(&mut self, now_secs: u64) -> usize {
199        let ttl = self.config.route_ttl_secs;
200        let before = self.routes.len();
201        self.routes
202            .retain(|_, entry| !entry.is_stale(ttl, now_secs));
203        let removed = before - self.routes.len();
204        self.stats.stale_routes += removed as u64;
205        self.stats.total_routes = self.routes.len();
206        removed
207    }
208
209    /// Updates the latency and reliability for `peer_id` across **all**
210    /// cached routing entries, then re-sorts each affected entry's candidates.
211    pub fn update_peer_cost(&mut self, peer_id: &str, new_latency_ms: f64, new_reliability: f64) {
212        for entry in self.routes.values_mut() {
213            let mut changed = false;
214            for candidate in &mut entry.candidates {
215                if candidate.peer_id == peer_id {
216                    candidate.latency_ms = new_latency_ms;
217                    candidate.reliability = new_reliability;
218                    changed = true;
219                }
220            }
221            if changed {
222                entry.candidates.sort_by(|a, b| {
223                    b.weighted_score()
224                        .partial_cmp(&a.weighted_score())
225                        .unwrap_or(std::cmp::Ordering::Equal)
226                });
227            }
228        }
229    }
230
231    /// Returns a reference to the current routing statistics.
232    pub fn stats(&self) -> &RoutingStats {
233        &self.stats
234    }
235
236    /// Returns the number of entries currently held in the route cache.
237    pub fn route_count(&self) -> usize {
238        self.routes.len()
239    }
240}
241
242// ─────────────────────────────────────────────────────────────────────────────
243// Tests
244// ─────────────────────────────────────────────────────────────────────────────
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn make_peer(
251        peer_id: &str,
252        latency_ms: f64,
253        bandwidth_bps: u64,
254        reliability: f64,
255    ) -> RouteCost {
256        RouteCost {
257            peer_id: peer_id.to_string(),
258            latency_ms,
259            bandwidth_bps,
260            reliability,
261        }
262    }
263
264    fn default_optimizer() -> ContentRoutingOptimizer {
265        ContentRoutingOptimizer::new(OptimizerConfig::default())
266    }
267
268    // ── 1. new() starts empty ─────────────────────────────────────────────────
269    #[test]
270    fn test_new_empty() {
271        let opt = default_optimizer();
272        assert_eq!(opt.route_count(), 0);
273        assert_eq!(opt.stats().cache_hits, 0);
274        assert_eq!(opt.stats().cache_misses, 0);
275    }
276
277    // ── 2. fetch_cost calculation ─────────────────────────────────────────────
278    #[test]
279    fn test_fetch_cost_calculation() {
280        let peer = make_peer("p1", 10.0, 1_000_000, 1.0); // 1 MB/s
281                                                          // 65536 bytes @ 1 MB/s = 65.536 ms + 10 ms = 75.536 ms
282        let expected = 65536.0 / 1_000_000.0 * 1000.0 + 10.0;
283        assert!((peer.fetch_cost(65536) - expected).abs() < 1e-9);
284    }
285
286    // ── 3. weighted_score higher for reliable/fast peer ──────────────────────
287    #[test]
288    fn test_weighted_score_fast_beats_slow() {
289        let fast = make_peer("fast", 5.0, 10_000_000, 0.9);
290        let slow = make_peer("slow", 200.0, 100_000, 0.9);
291        assert!(fast.weighted_score() > slow.weighted_score());
292    }
293
294    // ── 4. add_route: filters by min_reliability ─────────────────────────────
295    #[test]
296    fn test_add_route_filters_by_min_reliability() {
297        let mut opt = ContentRoutingOptimizer::new(OptimizerConfig {
298            min_reliability: 0.5,
299            ..Default::default()
300        });
301        let candidates = vec![
302            make_peer("good", 10.0, 1_000_000, 0.8),
303            make_peer("bad", 10.0, 1_000_000, 0.2),
304        ];
305        opt.add_route("cid1".to_string(), candidates, 1000);
306        let entry = opt.routes.get("cid1").expect("entry must exist");
307        assert_eq!(entry.candidates.len(), 1);
308        assert_eq!(entry.candidates[0].peer_id, "good");
309    }
310
311    // ── 5. add_route: sorts by weighted_score descending ─────────────────────
312    #[test]
313    fn test_add_route_sorts_by_weighted_score() {
314        let mut opt = default_optimizer();
315        let candidates = vec![
316            make_peer("slow", 500.0, 100_000, 0.9),
317            make_peer("fast", 5.0, 10_000_000, 0.9),
318        ];
319        opt.add_route("cid1".to_string(), candidates, 1000);
320        let entry = opt.routes.get("cid1").expect("entry must exist");
321        assert_eq!(entry.candidates[0].peer_id, "fast");
322    }
323
324    // ── 6. add_route: truncates to max_candidates ─────────────────────────────
325    #[test]
326    fn test_add_route_truncates_to_max_candidates() {
327        let mut opt = ContentRoutingOptimizer::new(OptimizerConfig {
328            max_candidates_per_cid: 3,
329            ..Default::default()
330        });
331        let candidates: Vec<RouteCost> = (0..10)
332            .map(|i| make_peer(&format!("p{i}"), 10.0, 1_000_000, 0.9))
333            .collect();
334        opt.add_route("cid1".to_string(), candidates, 1000);
335        assert_eq!(opt.routes["cid1"].candidates.len(), 3);
336    }
337
338    // ── 7. best_route: cache hit increments stats ─────────────────────────────
339    #[test]
340    fn test_best_route_cache_hit_increments_stats() {
341        let mut opt = default_optimizer();
342        opt.add_route(
343            "cid1".to_string(),
344            vec![make_peer("p1", 10.0, 1_000_000, 0.9)],
345            1000,
346        );
347        let result = opt.best_route("cid1", 1000);
348        assert!(result.is_some());
349        assert_eq!(opt.stats().cache_hits, 1);
350        assert_eq!(opt.stats().cache_misses, 0);
351        assert_eq!(opt.routes["cid1"].hit_count, 1);
352    }
353
354    // ── 8. best_route: stale entry is removed ────────────────────────────────
355    #[test]
356    fn test_best_route_stale_entry_removed() {
357        let mut opt = ContentRoutingOptimizer::new(OptimizerConfig {
358            route_ttl_secs: 60,
359            ..Default::default()
360        });
361        opt.add_route(
362            "cid1".to_string(),
363            vec![make_peer("p1", 10.0, 1_000_000, 0.9)],
364            0,
365        );
366        // Query 120 seconds later — beyond TTL
367        let result = opt.best_route("cid1", 120);
368        assert!(result.is_none());
369        assert_eq!(opt.stats().stale_routes, 1);
370        assert_eq!(opt.stats().cache_misses, 1);
371        assert_eq!(opt.route_count(), 0);
372    }
373
374    // ── 9. best_route: not found increments misses ───────────────────────────
375    #[test]
376    fn test_best_route_not_found_increments_misses() {
377        let mut opt = default_optimizer();
378        let result = opt.best_route("nonexistent", 1000);
379        assert!(result.is_none());
380        assert_eq!(opt.stats().cache_misses, 1);
381        assert_eq!(opt.stats().cache_hits, 0);
382    }
383
384    // ── 10. evict_stale removes expired entries ───────────────────────────────
385    #[test]
386    fn test_evict_stale_removes_expired_entries() {
387        let mut opt = ContentRoutingOptimizer::new(OptimizerConfig {
388            route_ttl_secs: 60,
389            ..Default::default()
390        });
391        opt.add_route(
392            "fresh".to_string(),
393            vec![make_peer("p1", 5.0, 1_000_000, 0.9)],
394            100,
395        );
396        opt.add_route(
397            "stale".to_string(),
398            vec![make_peer("p2", 5.0, 1_000_000, 0.9)],
399            0,
400        );
401
402        let removed = opt.evict_stale(120);
403        assert_eq!(removed, 1);
404        assert!(opt.routes.contains_key("fresh"));
405        assert!(!opt.routes.contains_key("stale"));
406    }
407
408    // ── 11. evict_stale returns correct count ─────────────────────────────────
409    #[test]
410    fn test_evict_stale_returns_count() {
411        let mut opt = ContentRoutingOptimizer::new(OptimizerConfig {
412            route_ttl_secs: 60,
413            ..Default::default()
414        });
415        for i in 0..5_u64 {
416            opt.add_route(
417                format!("cid{i}"),
418                vec![make_peer("p", 5.0, 1_000_000, 0.9)],
419                0,
420            );
421        }
422        let removed = opt.evict_stale(120);
423        assert_eq!(removed, 5);
424        assert_eq!(opt.route_count(), 0);
425    }
426
427    // ── 12. update_peer_cost updates all affected routes ─────────────────────
428    #[test]
429    fn test_update_peer_cost_updates_all_routes() {
430        let mut opt = default_optimizer();
431        opt.add_route(
432            "cid1".to_string(),
433            vec![make_peer("target", 100.0, 500_000, 0.8)],
434            0,
435        );
436        opt.add_route(
437            "cid2".to_string(),
438            vec![make_peer("target", 100.0, 500_000, 0.8)],
439            0,
440        );
441
442        opt.update_peer_cost("target", 10.0, 0.95);
443
444        for entry in opt.routes.values() {
445            let c = entry
446                .candidates
447                .iter()
448                .find(|c| c.peer_id == "target")
449                .expect("should exist");
450            assert!((c.latency_ms - 10.0).abs() < 1e-9);
451            assert!((c.reliability - 0.95).abs() < 1e-9);
452        }
453    }
454
455    // ── 13. update_peer_cost re-sorts candidates ──────────────────────────────
456    #[test]
457    fn test_update_peer_cost_resorts_candidates() {
458        let mut opt = default_optimizer();
459        // Same bandwidth so latency/reliability dominate the score.
460        // p_poor starts with high latency (above min_reliability floor); p_good is currently first.
461        let candidates = vec![
462            make_peer("p_poor", 100.0, 10_000_000, 0.4),
463            make_peer("p_good", 5.0, 10_000_000, 0.9),
464        ];
465        opt.add_route("cid1".to_string(), candidates, 0);
466
467        // p_good is first; now dramatically boost p_poor: latency=1ms, reliability=1.0
468        opt.update_peer_cost("p_poor", 1.0, 1.0);
469
470        // After update the re-sort should place p_poor first
471        let entry = opt.routes.get("cid1").expect("entry must exist");
472        assert_eq!(entry.candidates[0].peer_id, "p_poor");
473    }
474
475    // ── 14. is_stale: fresh entry ─────────────────────────────────────────────
476    #[test]
477    fn test_is_stale_fresh() {
478        let entry = RouteEntry {
479            cid: "c".to_string(),
480            candidates: vec![],
481            cached_at_secs: 1000,
482            hit_count: 0,
483        };
484        assert!(!entry.is_stale(300, 1200)); // 200s elapsed < 300s TTL
485    }
486
487    // ── 15. is_stale: stale entry ─────────────────────────────────────────────
488    #[test]
489    fn test_is_stale_stale() {
490        let entry = RouteEntry {
491            cid: "c".to_string(),
492            candidates: vec![],
493            cached_at_secs: 1000,
494            hit_count: 0,
495        };
496        assert!(entry.is_stale(300, 1400)); // 400s elapsed >= 300s TTL
497    }
498
499    // ── 16. hit_rate calculation ──────────────────────────────────────────────
500    #[test]
501    fn test_hit_rate_calculation() {
502        let stats = RoutingStats {
503            cache_hits: 3,
504            cache_misses: 1,
505            ..Default::default()
506        };
507        assert!((stats.hit_rate() - 0.75).abs() < 1e-9);
508    }
509
510    // ── 16b. hit_rate zero when no lookups ───────────────────────────────────
511    #[test]
512    fn test_hit_rate_zero_no_lookups() {
513        let stats = RoutingStats::default();
514        assert_eq!(stats.hit_rate(), 0.0);
515    }
516
517    // ── 17. best_peer returns first candidate ─────────────────────────────────
518    #[test]
519    fn test_best_peer_first_candidate() {
520        let entry = RouteEntry {
521            cid: "c".to_string(),
522            candidates: vec![
523                make_peer("p_best", 5.0, 10_000_000, 0.9),
524                make_peer("p_second", 50.0, 1_000_000, 0.8),
525            ],
526            cached_at_secs: 0,
527            hit_count: 0,
528        };
529        assert_eq!(
530            entry.best_peer().map(|p| p.peer_id.as_str()),
531            Some("p_best")
532        );
533    }
534
535    // ── 18. route_count is correct ────────────────────────────────────────────
536    #[test]
537    fn test_route_count_correct() {
538        let mut opt = default_optimizer();
539        assert_eq!(opt.route_count(), 0);
540        opt.add_route(
541            "c1".to_string(),
542            vec![make_peer("p", 5.0, 1_000_000, 0.9)],
543            0,
544        );
545        assert_eq!(opt.route_count(), 1);
546        opt.add_route(
547            "c2".to_string(),
548            vec![make_peer("p", 5.0, 1_000_000, 0.9)],
549            0,
550        );
551        assert_eq!(opt.route_count(), 2);
552    }
553}