Skip to main content

murmuration_routing/
router.rs

1/// Routing logic for mesh messages
2/// Implements UCB1 (Upper Confidence Bound) adaptive routing.
3/// Reference: Auer et al., "Finite-time Analysis of the Multiarmed Bandit Problem", 2002.
4///
5/// UCB1 state is persisted to sled under the key "ucb1_state" so that learned peer
6/// quality survives node restarts.
7use crate::error::{RoutingError, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::{Arc, OnceLock};
11use std::time::{Duration, Instant};
12use std::fmt;
13use tokio::sync::RwLock;
14use tracing::{debug, warn};
15
16/// UCB1 exploration constant (standard value = 2.0).
17///
18/// Read once from `MURMURATION_UCB1_C` if set, else 2.0. The environment override
19/// exists only so the routing benchmark can sweep the exploration constant to
20/// show the results are not an artefact of one lucky value; in normal operation
21/// the variable is unset and this is exactly the textbook constant.
22fn ucb1_c() -> f64 {
23    static C: OnceLock<f64> = OnceLock::new();
24    *C.get_or_init(|| {
25        std::env::var("MURMURATION_UCB1_C")
26            .ok()
27            .and_then(|v| v.parse().ok())
28            .unwrap_or(2.0)
29    })
30}
31/// Number of selections required before switching from heuristic warm-up to pure UCB1.
32const UCB1_MIN_SAMPLES: u64 = 5;
33
34/// Q-routing learning rate (Boyan & Littman, 1994).
35const Q_ALPHA: f64 = 0.15;
36/// Optimistic initialisation for unseen (destination, neighbour) pairs: they look
37/// maximally good so each neighbour is tried at least once before estimates settle.
38const Q_INIT: f64 = 1.0;
39
40/// Murmuration address format: mur://<node_id>
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub struct MurmurationAddress {
43    pub node_id: String,
44}
45
46impl MurmurationAddress {
47    /// Parse from string format: mur://<node_id>
48    pub fn from_string(addr: &str) -> Result<Self> {
49        if let Some(stripped) = addr.strip_prefix("mur://") {
50            Ok(Self {
51                node_id: stripped.to_string(),
52            })
53        } else {
54            Err(RoutingError::Protocol(format!(
55                "Invalid Murmuration address format: {}",
56                addr
57            )))
58        }
59    }
60}
61
62impl fmt::Display for MurmurationAddress {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(f, "mur://{}", self.node_id)
65    }
66}
67
68/// Mesh message for routing
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MeshMessage {
71    pub from: String,
72    pub to: Option<String>, // None = broadcast
73    pub data: Vec<u8>,
74    pub message_id: String,
75    pub ttl: u8,
76    pub path: Vec<String>, // Route path for loop detection
77}
78
79impl MeshMessage {
80    /// Create a new mesh message
81    pub fn new(from: String, to: Option<String>, data: Vec<u8>) -> Self {
82        Self {
83            from,
84            to,
85            data,
86            message_id: uuid::Uuid::new_v4().to_string(),
87            ttl: 10, // Default TTL
88            path: Vec::new(),
89        }
90    }
91
92    // Conversion to/from the node's wire `Message` lives in the parent crate
93    // (see `core/src/ai/adapter.rs`), keeping this crate free of the protocol type.
94}
95
96/// Persistence backend for the router's learned UCB1 state.
97///
98/// The crate stays storage-agnostic: it hands the serialized state to `save` and
99/// asks for it back via `load`. The node supplies a sled-backed implementation;
100/// tests and the benchmark use none.
101pub trait RouterStore: Send + Sync {
102    /// Return previously-saved state bytes, if any.
103    fn load(&self) -> Option<Vec<u8>>;
104    /// Persist state bytes (best-effort; errors are the implementation's to log).
105    fn save(&self, bytes: &[u8]);
106}
107
108/// Router for mesh message routing.
109/// Peer selection uses UCB1 (multi-armed bandit) once sufficient samples exist;
110/// falls back to a heuristic score during cold-start.
111/// UCB1 state is persisted via an optional [`RouterStore`] so learned topology
112/// survives restarts.
113pub struct Router {
114    our_node_id: String,
115    seen_messages: Arc<RwLock<HashMap<String, Instant>>>, // message_id -> timestamp
116    message_cache: Arc<RwLock<HashMap<String, MeshMessage>>>, // Cache for deduplication
117    route_history: Arc<RwLock<HashMap<String, RouteStats>>>, // peer_id -> heuristic stats
118    ucb_state: Arc<RwLock<UcbState>>,                     // UCB1 bandit state
119    q_state: Arc<RwLock<QRoutingState>>,                  // Q-routing estimates
120    /// Optional persistence backend for UCB1 state across restarts.
121    store: Option<Arc<dyn RouterStore>>,
122}
123
124/// Q-routing state (Boyan & Littman, 1994).
125///
126/// `q[(dest, neighbour)]` estimates the probability that a message for `dest`
127/// handed to `neighbour` eventually arrives. Unlike the UCB1 bandit, updates
128/// bootstrap from the *neighbour's* advertised estimate rather than a terminal
129/// reward — that is what carries destination information backwards through the
130/// mesh, and it is why the routing benchmark shows Q-routing exceed the
131/// destination-agnostic ceiling under concentrated traffic (see `results/`).
132#[derive(Debug, Default)]
133struct QRoutingState {
134    q: HashMap<(String, String), f64>,
135}
136
137impl QRoutingState {
138    fn get(&self, dest: &str, peer: &str) -> f64 {
139        *self
140            .q
141            .get(&(dest.to_string(), peer.to_string()))
142            .unwrap_or(&Q_INIT)
143    }
144
145    /// The value this node advertises to others for `dest`: the best estimate
146    /// over the given neighbours. A node with no neighbours advertises 0.
147    fn best_over(&self, dest: &str, neighbours: &[String]) -> f64 {
148        neighbours
149            .iter()
150            .map(|p| self.get(dest, p))
151            .fold(0.0_f64, f64::max)
152    }
153
154    fn update(&mut self, dest: &str, peer: &str, target: f64) {
155        let cur = self.get(dest, peer);
156        self.q.insert(
157            (dest.to_string(), peer.to_string()),
158            (1.0 - Q_ALPHA) * cur + Q_ALPHA * target,
159        );
160    }
161}
162
163/// Statistics for a route (peer)
164#[derive(Debug, Clone)]
165pub struct RouteStats {
166    success_count: u32,
167    failure_count: u32,
168    total_latency: Duration,
169    sample_count: u32,
170    last_updated: Instant,
171}
172
173/// Per-peer UCB1 state.
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175struct UcbPeerStats {
176    /// n_i: number of times this peer was selected for routing.
177    selections: u64,
178    /// μ_i: running average reward (incremental update).
179    avg_reward: f64,
180}
181
182/// Global UCB1 bandit state shared across all routing decisions.
183/// Serializable so it can be persisted to sled and survive restarts.
184///
185/// # Destination conditioning
186///
187/// `peers` is keyed by peer alone, which makes it a *destination-agnostic*
188/// estimate: it answers "is this neighbour generally reliable?", not "is this
189/// neighbour a good step toward D?". Those are different questions, and only the
190/// second one is routing. Benchmarking showed the agnostic form plateaus far
191/// below an oracle that conditions on the destination, so `by_dest` keeps a
192/// separate bandit per destination; see `get_best_forward_peers_toward`.
193///
194/// Both are retained: the agnostic estimate is still the right prior for peer
195/// health, and keeping it preserves the on-disk format written by earlier
196/// versions.
197#[derive(Debug, Default, Serialize, Deserialize)]
198struct UcbState {
199    /// N: total routing selections across all peers.
200    total_selections: u64,
201    peers: HashMap<String, UcbPeerStats>,
202    /// destination node_id → bandit conditioned on that destination.
203    #[serde(default)]
204    by_dest: HashMap<String, DestBandit>,
205}
206
207/// A UCB1 bandit scoped to a single destination.
208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209struct DestBandit {
210    total_selections: u64,
211    peers: HashMap<String, UcbPeerStats>,
212}
213
214impl DestBandit {
215    fn ucb1_score(&self, peer_id: &str) -> f64 {
216        match self.peers.get(peer_id) {
217            None => f64::INFINITY,
218            Some(s) if s.selections == 0 => f64::INFINITY,
219            Some(s) => {
220                let exploration = if self.total_selections > 0 {
221                    (ucb1_c() * (self.total_selections as f64).ln() / s.selections as f64).sqrt()
222                } else {
223                    0.0
224                };
225                s.avg_reward + exploration
226            }
227        }
228    }
229
230    fn record_reward(&mut self, peer_id: &str, reward: f64) {
231        self.total_selections += 1;
232        let s = self.peers.entry(peer_id.to_string()).or_default();
233        s.selections += 1;
234        s.avg_reward += (reward - s.avg_reward) / s.selections as f64;
235    }
236
237    fn selections(&self, peer_id: &str) -> u64 {
238        self.peers.get(peer_id).map_or(0, |s| s.selections)
239    }
240}
241
242impl UcbState {
243    /// UCB1 score for peer_i: μ_i + sqrt(C * ln(N) / n_i).
244    /// Caller must ensure selections >= UCB1_MIN_SAMPLES before calling.
245    fn ucb1_score(&self, peer_id: &str) -> f64 {
246        match self.peers.get(peer_id) {
247            None => f64::INFINITY,
248            Some(s) if s.selections == 0 => f64::INFINITY,
249            Some(s) => {
250                let exploration = if self.total_selections > 0 {
251                    (ucb1_c() * (self.total_selections as f64).ln() / s.selections as f64).sqrt()
252                } else {
253                    0.0
254                };
255                s.avg_reward + exploration
256            }
257        }
258    }
259
260    /// Record routing outcome for peer_i using incremental average update.
261    fn record_reward(&mut self, peer_id: &str, reward: f64) {
262        self.total_selections += 1;
263        let s = self.peers.entry(peer_id.to_string()).or_default();
264        s.selections += 1;
265        // Incremental mean: μ ← μ + (r - μ) / n
266        s.avg_reward += (reward - s.avg_reward) / s.selections as f64;
267    }
268
269    /// Return how many times peer has been selected (0 if unknown).
270    fn selections(&self, peer_id: &str) -> u64 {
271        self.peers.get(peer_id).map_or(0, |s| s.selections)
272    }
273}
274
275impl Router {
276    /// Create a new router (no persistence).
277    pub fn new(our_node_id: String) -> Self {
278        Self {
279            our_node_id,
280            seen_messages: Arc::new(RwLock::new(HashMap::new())),
281            message_cache: Arc::new(RwLock::new(HashMap::new())),
282            route_history: Arc::new(RwLock::new(HashMap::new())),
283            ucb_state: Arc::new(RwLock::new(UcbState::default())),
284            q_state: Arc::new(RwLock::new(QRoutingState::default())),
285            store: None,
286        }
287    }
288
289    /// Create a router backed by `store` for UCB1 state persistence.
290    /// Previously learned peer quality is loaded immediately.
291    pub fn with_store(our_node_id: String, store: Arc<dyn RouterStore>) -> Self {
292        let initial_state = store
293            .load()
294            .and_then(|bytes| serde_json::from_slice::<UcbState>(&bytes).ok())
295            .unwrap_or_default();
296        Self {
297            our_node_id,
298            seen_messages: Arc::new(RwLock::new(HashMap::new())),
299            message_cache: Arc::new(RwLock::new(HashMap::new())),
300            route_history: Arc::new(RwLock::new(HashMap::new())),
301            ucb_state: Arc::new(RwLock::new(initial_state)),
302            q_state: Arc::new(RwLock::new(QRoutingState::default())),
303            store: Some(store),
304        }
305    }
306
307    /// Persist the current UCB1 state via the store (best-effort).
308    async fn persist_ucb_state(&self) {
309        if let Some(store) = &self.store {
310            let state = self.ucb_state.read().await;
311            match serde_json::to_vec(&*state) {
312                Ok(bytes) => store.save(&bytes),
313                Err(e) => warn!("Failed to serialize UCB1 state: {}", e),
314            }
315        }
316    }
317
318    /// Check if message should be processed (deduplication and TTL check)
319    pub async fn should_process(&self, message: &MeshMessage) -> bool {
320        // Check TTL
321        if message.ttl == 0 {
322            debug!("Message {} dropped: TTL expired", message.message_id);
323            return false;
324        }
325
326        // Check if we've seen this message recently (within 60 seconds)
327        let seen = self.seen_messages.read().await;
328        if let Some(timestamp) = seen.get(&message.message_id) {
329            if timestamp.elapsed() < Duration::from_secs(60) {
330                debug!("Message {} dropped: already seen", message.message_id);
331                return false;
332            }
333        }
334        drop(seen);
335
336        // Check if we're in the path (loop detection)
337        if message.path.contains(&self.our_node_id) {
338            debug!("Message {} dropped: loop detected", message.message_id);
339            return false;
340        }
341
342        true
343    }
344
345    /// Mark message as seen
346    pub async fn mark_seen(&self, message_id: &str) {
347        let mut seen = self.seen_messages.write().await;
348        seen.insert(message_id.to_string(), Instant::now());
349
350        // Cleanup old entries (older than 5 minutes)
351        seen.retain(|_, timestamp| timestamp.elapsed() < Duration::from_secs(300));
352    }
353
354    /// Check if message is for us
355    pub fn is_for_us(&self, message: &MeshMessage) -> bool {
356        match &message.to {
357            None => true, // Broadcast
358            Some(to) => to == &self.our_node_id,
359        }
360    }
361
362    /// Prepare message for forwarding (decrement TTL, add to path)
363    pub fn prepare_for_forwarding(&self, message: &MeshMessage) -> MeshMessage {
364        let mut forward_msg = message.clone();
365        forward_msg.ttl = forward_msg.ttl.saturating_sub(1);
366        forward_msg.path.push(self.our_node_id.clone());
367        forward_msg
368    }
369
370    /// Calculate routing score for a peer based on metrics (higher is better)
371    /// Uses adaptive learning: score = α*old_score + β*new_score
372    pub fn calculate_peer_score(
373        peer_metrics: &crate::peer::PeerMetrics,
374        route_stats: Option<&RouteStats>,
375    ) -> f64 {
376        // Latency score: lower latency = higher score (normalize to 0-1, assuming max 1s latency)
377        let latency_score = peer_metrics
378            .latency
379            .map(|lat| {
380                let lat_secs = lat.as_secs_f64();
381                (1.0 - (lat_secs.min(1.0))).max(0.0)
382            })
383            .unwrap_or(0.5); // Default score if no latency data
384
385        // Uptime score: longer uptime = higher score (normalize to 1 hour)
386        let uptime_score = (peer_metrics.uptime.as_secs_f64() / 3600.0).min(1.0);
387
388        // Reliability score: based on ping success rate
389        let reliability = peer_metrics.reliability_score() as f64;
390
391        // Route success rate from history
392        let route_success_rate = if let Some(stats) = route_stats {
393            let total = stats.success_count + stats.failure_count;
394            if total > 0 {
395                stats.success_count as f64 / total as f64
396            } else {
397                0.5
398            }
399        } else {
400            0.5 // Default if no history
401        };
402
403        // Base score: 30% latency, 15% uptime, 30% reliability, 25% route success
404        let base_score = 0.3 * latency_score
405            + 0.15 * uptime_score
406            + 0.3 * reliability
407            + 0.25 * route_success_rate;
408
409        // Adaptive learning: if we have previous score, blend it
410        if let Some(stats) = route_stats {
411            if stats.sample_count > 0 {
412                let avg_latency = if stats.sample_count > 0 {
413                    stats.total_latency.as_secs_f64() / stats.sample_count as f64
414                } else {
415                    0.0
416                };
417                let historical_score = (1.0 - (avg_latency.min(1.0))).max(0.0);
418
419                // Exponential moving average: α=0.7 (old), β=0.3 (new)
420                const ALPHA: f64 = 0.7;
421                const BETA: f64 = 0.3;
422                return ALPHA * historical_score + BETA * base_score;
423            }
424        }
425
426        base_score
427    }
428
429    /// Get list of peers to forward to (flooding: all except sender)
430    pub fn get_forward_peers(&self, message: &MeshMessage, all_peers: &[String]) -> Vec<String> {
431        all_peers
432            .iter()
433            .filter(|peer_id| {
434                // Don't forward to sender
435                **peer_id != message.from &&
436                // Don't forward to nodes already in path (loop prevention)
437                !message.path.contains(peer_id)
438            })
439            .cloned()
440            .collect()
441    }
442
443    /// Get best peers to forward to using UCB1 adaptive routing.
444    ///
445    /// Peer selection strategy:
446    /// - **Warm-up** (selections < UCB1_MIN_SAMPLES): heuristic score + exploration bonus.
447    ///   Unvisited peers receive the highest bonus, ensuring all peers are tried first.
448    /// - **Exploitation** (selections >= UCB1_MIN_SAMPLES): pure UCB1 score.
449    ///
450    /// Returns peers sorted by score (best first), limited to top `max_peers`.
451    pub async fn get_best_forward_peers(
452        &self,
453        message: &MeshMessage,
454        peer_infos: &[crate::peer::PeerInfo],
455        max_peers: usize,
456    ) -> Vec<String> {
457        let route_history = self.route_history.read().await;
458        let ucb = self.ucb_state.read().await;
459
460        let mut scored_peers: Vec<(String, f64)> = peer_infos
461            .iter()
462            .filter(|peer| {
463                peer.node_id != message.from
464                    && !message.path.contains(&peer.node_id)
465                    && peer.is_connected()
466            })
467            .map(|peer| {
468                let n_i = ucb.selections(&peer.node_id);
469                let score = if n_i < UCB1_MIN_SAMPLES {
470                    // Cold-start: heuristic score + exploration bonus.
471                    // Unvisited peers (n_i == 0) get +1.0 so they are always tried first.
472                    let heuristic =
473                        Self::calculate_peer_score(&peer.metrics, route_history.get(&peer.node_id));
474                    let bonus = if n_i == 0 { 1.0 } else { 0.5 };
475                    heuristic + bonus
476                } else {
477                    ucb.ucb1_score(&peer.node_id)
478                };
479                (peer.node_id.clone(), score)
480            })
481            .collect();
482
483        scored_peers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
484        scored_peers
485            .into_iter()
486            .take(max_peers)
487            .map(|(peer_id, _)| peer_id)
488            .collect()
489    }
490
491    /// Destination-conditioned peer selection.
492    ///
493    /// Identical to [`Self::get_best_forward_peers`] except that the bandit state
494    /// consulted is the one scoped to `dest`. A neighbour that is an excellent step
495    /// toward one destination is often a poor step toward another, so scoring peers
496    /// with a single destination-agnostic estimate discards the signal that actually
497    /// determines routing quality.
498    ///
499    /// Warm-up behaviour is unchanged: until a peer has `UCB1_MIN_SAMPLES`
500    /// observations *for this destination*, the heuristic score plus an exploration
501    /// bonus is used, so unvisited peers are still tried first.
502    pub async fn get_best_forward_peers_toward(
503        &self,
504        message: &MeshMessage,
505        peer_infos: &[crate::peer::PeerInfo],
506        max_peers: usize,
507        dest: &str,
508    ) -> Vec<String> {
509        let route_history = self.route_history.read().await;
510        let ucb = self.ucb_state.read().await;
511        let empty = DestBandit::default();
512        let bandit = ucb.by_dest.get(dest).unwrap_or(&empty);
513
514        let mut scored_peers: Vec<(String, f64)> = peer_infos
515            .iter()
516            .filter(|peer| {
517                peer.node_id != message.from
518                    && !message.path.contains(&peer.node_id)
519                    && peer.is_connected()
520            })
521            .map(|peer| {
522                let n_i = bandit.selections(&peer.node_id);
523                let score = if n_i < UCB1_MIN_SAMPLES {
524                    let heuristic =
525                        Self::calculate_peer_score(&peer.metrics, route_history.get(&peer.node_id));
526                    let bonus = if n_i == 0 { 1.0 } else { 0.5 };
527                    heuristic + bonus
528                } else {
529                    bandit.ucb1_score(&peer.node_id)
530                };
531                (peer.node_id.clone(), score)
532            })
533            .collect();
534
535        scored_peers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
536        scored_peers
537            .into_iter()
538            .take(max_peers)
539            .map(|(peer_id, _)| peer_id)
540            .collect()
541    }
542
543    /// Record a delivery outcome against the bandit scoped to `dest`.
544    ///
545    /// `success` carries the observed hop latency; `None` records a failure.
546    /// Reward matches the destination-agnostic path: `clamp(1 - 2*latency, 0.5, 1.0)`
547    /// on success, `0.0` on failure.
548    pub async fn record_route_outcome_toward(
549        &self,
550        dest: &str,
551        peer_id: &str,
552        success: Option<Duration>,
553    ) {
554        let reward = match success {
555            Some(latency) => (1.0 - 2.0 * latency.as_secs_f64()).clamp(0.5, 1.0),
556            None => 0.0,
557        };
558        {
559            let mut state = self.ucb_state.write().await;
560            state
561                .by_dest
562                .entry(dest.to_string())
563                .or_default()
564                .record_reward(peer_id, reward);
565        }
566        self.persist_ucb_state().await;
567    }
568
569    // ─── Q-routing (value bootstrapping) ─────────────────────────────────────
570    //
571    // These three methods are the node-facing surface of the Q-routing scheme
572    // validated in `results/`. The protocol carries the advertised value between
573    // hops via `Message::RoutingEstimate`; see `docs/Q_ROUTING.md` for the wire
574    // exchange and the (still to be validated on a live multi-node network)
575    // integration plan.
576
577    /// Choose next hops for `dest` by Q-value, best first, limited to `max_peers`.
578    ///
579    /// Optimistic initialisation (`Q_INIT = 1.0`) means any neighbour never yet
580    /// tried for `dest` outscores explored ones, so every neighbour is attempted
581    /// at least once before the estimates take over — the same warm-up the
582    /// benchmark uses. Connected peers only; sender and nodes already on the path
583    /// are excluded for loop-freedom.
584    pub async fn q_select_toward(
585        &self,
586        message: &MeshMessage,
587        peer_infos: &[crate::peer::PeerInfo],
588        max_peers: usize,
589        dest: &str,
590    ) -> Vec<String> {
591        let q = self.q_state.read().await;
592        let mut scored: Vec<(String, f64)> = peer_infos
593            .iter()
594            .filter(|p| {
595                p.node_id != message.from
596                    && !message.path.contains(&p.node_id)
597                    && p.is_connected()
598            })
599            .map(|p| (p.node_id.clone(), q.get(dest, &p.node_id)))
600            .collect();
601        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
602        scored
603            .into_iter()
604            .take(max_peers)
605            .map(|(id, _)| id)
606            .collect()
607    }
608
609    /// The value this node advertises to an upstream neighbour for `dest`:
610    /// `max` over its own neighbours' Q-estimates. This is the quantity a
611    /// downstream node bootstraps from. `neighbours` is the caller's current
612    /// connected-peer id list.
613    pub async fn q_advertised_value(&self, dest: &str, neighbours: &[String]) -> f64 {
614        self.q_state.read().await.best_over(dest, neighbours)
615    }
616
617    /// Update the Q-estimate for hop `peer` toward `dest` after an outcome.
618    ///
619    /// `downstream_value` is the estimate the neighbour advertised (its
620    /// `q_advertised_value` for `dest`). On success the bootstrap target is that
621    /// value; on failure it is 0. This is the delivery-probability form of the
622    /// Boyan–Littman update.
623    pub async fn q_record(
624        &self,
625        dest: &str,
626        peer: &str,
627        delivered: bool,
628        downstream_value: f64,
629    ) {
630        let target = if delivered { downstream_value } else { 0.0 };
631        self.q_state.write().await.update(dest, peer, target);
632    }
633
634    /// Record successful route (for adaptive learning).
635    /// Updates both the heuristic history and the UCB1 bandit state.
636    /// Reward is latency-weighted: r = clamp(1 - 2*latency_secs, 0.5, 1.0).
637    pub async fn record_route_success(&self, peer_id: &str, latency: Duration) {
638        let mut history = self.route_history.write().await;
639        let stats = history
640            .entry(peer_id.to_string())
641            .or_insert_with(|| RouteStats {
642                success_count: 0,
643                failure_count: 0,
644                total_latency: Duration::ZERO,
645                sample_count: 0,
646                last_updated: Instant::now(),
647            });
648
649        stats.success_count += 1;
650        stats.total_latency += latency;
651        stats.sample_count += 1;
652        stats.last_updated = Instant::now();
653        drop(history);
654
655        // UCB1: reward decreases with latency; clamped to [0.5, 1.0] for successful delivery.
656        let reward = (1.0 - 2.0 * latency.as_secs_f64()).clamp(0.5, 1.0);
657        self.ucb_state.write().await.record_reward(peer_id, reward);
658        self.persist_ucb_state().await;
659    }
660
661    /// Record failed route (for adaptive learning).
662    /// Updates both the heuristic history and the UCB1 bandit state (reward = 0).
663    pub async fn record_route_failure(&self, peer_id: &str) {
664        let mut history = self.route_history.write().await;
665        let stats = history
666            .entry(peer_id.to_string())
667            .or_insert_with(|| RouteStats {
668                success_count: 0,
669                failure_count: 0,
670                total_latency: Duration::ZERO,
671                sample_count: 0,
672                last_updated: Instant::now(),
673            });
674
675        stats.failure_count += 1;
676        stats.last_updated = Instant::now();
677        drop(history);
678
679        // UCB1: failure → reward = 0.
680        self.ucb_state.write().await.record_reward(peer_id, 0.0);
681        self.persist_ucb_state().await;
682    }
683
684    /// Cleanup old cache entries
685    pub async fn cleanup_cache(&self) {
686        let mut cache = self.message_cache.write().await;
687        cache.retain(|_, _msg| {
688            // Keep messages that are less than 5 minutes old
689            true // For now, keep all cached messages
690        });
691    }
692}
693
694impl Clone for Router {
695    fn clone(&self) -> Self {
696        Self {
697            our_node_id: self.our_node_id.clone(),
698            seen_messages: self.seen_messages.clone(),
699            message_cache: self.message_cache.clone(),
700            route_history: self.route_history.clone(),
701            ucb_state: self.ucb_state.clone(),
702            q_state: self.q_state.clone(),
703            store: self.store.clone(),
704        }
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn test_murmuration_address_parse() {
714        let addr = MurmurationAddress::from_string("mur://node123").unwrap();
715        assert_eq!(addr.node_id, "node123");
716
717        let invalid = MurmurationAddress::from_string("invalid");
718        assert!(invalid.is_err());
719    }
720
721    #[test]
722    fn test_murmuration_address_to_string() {
723        let addr = MurmurationAddress {
724            node_id: "node123".to_string(),
725        };
726        assert_eq!(addr.to_string(), "mur://node123");
727    }
728
729    #[tokio::test]
730    async fn test_router_should_process() {
731        let router = Router::new("our-node".to_string());
732        let message = MeshMessage::new("peer1".to_string(), None, b"test".to_vec());
733
734        // New message should be processed
735        assert!(router.should_process(&message).await);
736
737        // Mark as seen
738        router.mark_seen(&message.message_id).await;
739
740        // Should not process again immediately
741        assert!(!router.should_process(&message).await);
742    }
743
744    #[tokio::test]
745    async fn test_router_is_for_us() {
746        let router = Router::new("our-node".to_string());
747
748        // Broadcast message
749        let broadcast = MeshMessage::new("peer1".to_string(), None, b"test".to_vec());
750        assert!(router.is_for_us(&broadcast));
751
752        // Directed to us
753        let directed = MeshMessage::new(
754            "peer1".to_string(),
755            Some("our-node".to_string()),
756            b"test".to_vec(),
757        );
758        assert!(router.is_for_us(&directed));
759
760        // Directed to someone else
761        let other = MeshMessage::new(
762            "peer1".to_string(),
763            Some("other-node".to_string()),
764            b"test".to_vec(),
765        );
766        assert!(!router.is_for_us(&other));
767    }
768
769    #[tokio::test]
770    async fn test_router_prepare_for_forwarding() {
771        let router = Router::new("our-node".to_string());
772        let message = MeshMessage::new("peer1".to_string(), None, b"test".to_vec());
773        let original_ttl = message.ttl;
774
775        let forwarded = router.prepare_for_forwarding(&message);
776
777        assert_eq!(forwarded.ttl, original_ttl - 1);
778        assert!(forwarded.path.contains(&"our-node".to_string()));
779    }
780
781    #[tokio::test]
782    async fn test_router_get_forward_peers() {
783        let router = Router::new("our-node".to_string());
784        let message = MeshMessage::new("peer1".to_string(), None, b"test".to_vec());
785        let all_peers = vec![
786            "peer1".to_string(),
787            "peer2".to_string(),
788            "peer3".to_string(),
789        ];
790
791        let forward_peers = router.get_forward_peers(&message, &all_peers);
792
793        // Should not include sender (peer1) or our node
794        assert!(!forward_peers.contains(&"peer1".to_string()));
795        assert!(forward_peers.contains(&"peer2".to_string()));
796        assert!(forward_peers.contains(&"peer3".to_string()));
797    }
798
799    #[tokio::test]
800    async fn test_router_loop_detection() {
801        let router = Router::new("our-node".to_string());
802        let mut message = MeshMessage::new("peer1".to_string(), None, b"test".to_vec());
803        message.path.push("our-node".to_string());
804
805        // Should not process if we're in the path
806        assert!(!router.should_process(&message).await);
807    }
808
809    // ─── Q-routing ───────────────────────────────────────────────────────────
810
811    use crate::peer::{ConnectionState, PeerInfo};
812    use std::net::SocketAddr;
813
814    fn connected_peer(id: &str) -> PeerInfo {
815        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
816        let mut p = PeerInfo::new(id.to_string(), addr);
817        p.state = ConnectionState::Connected;
818        p
819    }
820
821    #[tokio::test]
822    async fn test_q_optimistic_init() {
823        let router = Router::new("u".to_string());
824        // Nothing learned yet: every (dest, peer) reads the optimistic Q_INIT,
825        // so the advertised value is 1.0 as long as there is a neighbour.
826        let v = router.q_advertised_value("dst", &["a".to_string()]).await;
827        assert_eq!(v, Q_INIT);
828        // No neighbours → advertises 0 (nothing reachable).
829        assert_eq!(router.q_advertised_value("dst", &[]).await, 0.0);
830    }
831
832    #[tokio::test]
833    async fn test_q_record_bootstraps_downstream_value() {
834        let router = Router::new("u".to_string());
835        // Success bootstraps toward the neighbour's advertised value (0.8).
836        router.q_record("dst", "a", true, 0.8).await;
837        // Q ← (1-α)·1.0 + α·0.8 = 0.85·1 + 0.15·0.8 = 0.97
838        let q_a = router.q_advertised_value("dst", &["a".to_string()]).await;
839        assert!((q_a - 0.97).abs() < 1e-9, "got {q_a}");
840
841        // A failure pulls the estimate down toward 0.
842        for _ in 0..20 {
843            router.q_record("dst", "b", false, 0.0).await;
844        }
845        let q_b = router
846            .q_state
847            .read()
848            .await
849            .get("dst", "b");
850        assert!(q_b < 0.1, "failures should drive Q→0, got {q_b}");
851    }
852
853    #[tokio::test]
854    async fn test_q_select_prefers_higher_value() {
855        let router = Router::new("u".to_string());
856        // Make peer "good" clearly better and "bad" clearly worse for dst.
857        for _ in 0..30 {
858            router.q_record("dst", "good", true, 1.0).await;
859            router.q_record("dst", "bad", false, 0.0).await;
860        }
861        let msg = MeshMessage::new("src".to_string(), Some("dst".to_string()), vec![]);
862        let peers = vec![connected_peer("good"), connected_peer("bad")];
863        let picked = router.q_select_toward(&msg, &peers, 1, "dst").await;
864        assert_eq!(picked, vec!["good".to_string()]);
865    }
866
867    #[tokio::test]
868    async fn test_q_select_excludes_sender_and_path() {
869        let router = Router::new("u".to_string());
870        let mut msg = MeshMessage::new("sender".to_string(), Some("dst".to_string()), vec![]);
871        msg.path.push("visited".to_string());
872        let peers = vec![
873            connected_peer("sender"),
874            connected_peer("visited"),
875            connected_peer("fresh"),
876        ];
877        let picked = router.q_select_toward(&msg, &peers, 3, "dst").await;
878        assert_eq!(picked, vec!["fresh".to_string()]);
879    }
880}