1use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
11pub struct RouteCost {
12 pub peer_id: String,
14 pub latency_ms: f64,
16 pub bandwidth_bps: u64,
18 pub reliability: f64,
20}
21
22impl RouteCost {
23 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 pub fn weighted_score(&self) -> f64 {
35 self.reliability / self.fetch_cost(65536).max(1e-9)
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct RouteEntry {
42 pub cid: String,
44 pub candidates: Vec<RouteCost>,
46 pub cached_at_secs: u64,
48 pub hit_count: u64,
50}
51
52impl RouteEntry {
53 pub fn best_peer(&self) -> Option<&RouteCost> {
56 self.candidates.first()
57 }
58
59 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#[derive(Debug, Clone, Default)]
67pub struct RoutingStats {
68 pub cache_hits: u64,
70 pub cache_misses: u64,
72 pub stale_routes: u64,
74 pub total_routes: usize,
76}
77
78impl RoutingStats {
79 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#[derive(Debug, Clone)]
94pub struct OptimizerConfig {
95 pub route_ttl_secs: u64,
97 pub max_candidates_per_cid: usize,
99 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
113pub struct ContentRoutingOptimizer {
115 routes: HashMap<String, RouteEntry>,
117 config: OptimizerConfig,
119 stats: RoutingStats,
121}
122
123impl ContentRoutingOptimizer {
124 pub fn new(config: OptimizerConfig) -> Self {
126 Self {
127 routes: HashMap::new(),
128 config,
129 stats: RoutingStats::default(),
130 }
131 }
132
133 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 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 let entry = self.routes.get_mut(cid)?;
188 self.stats.cache_hits += 1;
189 entry.hit_count += 1;
190
191 self.routes.get(cid).and_then(|e| e.best_peer())
193 }
194
195 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 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 pub fn stats(&self) -> &RoutingStats {
233 &self.stats
234 }
235
236 pub fn route_count(&self) -> usize {
238 self.routes.len()
239 }
240}
241
242#[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 #[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 #[test]
279 fn test_fetch_cost_calculation() {
280 let peer = make_peer("p1", 10.0, 1_000_000, 1.0); 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[test]
457 fn test_update_peer_cost_resorts_candidates() {
458 let mut opt = default_optimizer();
459 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 opt.update_peer_cost("p_poor", 1.0, 1.0);
469
470 let entry = opt.routes.get("cid1").expect("entry must exist");
472 assert_eq!(entry.candidates[0].peer_id, "p_poor");
473 }
474
475 #[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)); }
486
487 #[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)); }
498
499 #[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 #[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 #[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 #[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}