Skip to main content

offline_protocol_router/
relay.rs

1//! Relay management for promotion and demotion logic.
2
3use serde::{Deserialize, Serialize};
4
5/// Battery level below which a device stops doing anything for other people,
6/// however willing its configuration is.
7///
8/// This is the hard floor beneath the soft [`RelayConfig::min_battery_for_relay`]:
9/// a charging device, or one configured [`RelayPriority::Always`], is excused
10/// the soft minimum but never this. It is public because the protocol crate
11/// applies the same floor to message forwarding — a device must not keep
12/// carrying traffic at a level that would have stripped it of the relay role,
13/// and two copies of the number would eventually disagree.
14pub const CRITICAL_RELAY_BATTERY_LEVEL: u8 = 15;
15
16/// Configuration for relay behavior.
17#[derive(Debug, Clone)]
18pub struct RelayConfig {
19    /// Minimum number of connections to become a relay.
20    pub relay_threshold: usize,
21
22    /// Minimum battery level to act as relay (percentage).
23    pub min_battery_for_relay: u8,
24
25    /// Whether this device allows acting as a relay.
26    pub allow_relay: bool,
27
28    /// Priority for relay selection (higher = more preferred).
29    pub relay_priority: RelayPriority,
30}
31
32impl Default for RelayConfig {
33    fn default() -> Self {
34        Self {
35            relay_threshold: 3,
36            min_battery_for_relay: 30,
37            allow_relay: true,
38            relay_priority: RelayPriority::Auto,
39        }
40    }
41}
42
43/// Relay priority levels.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum RelayPriority {
47    /// Never act as relay.
48    Never,
49    /// Automatically decide based on conditions.
50    Auto,
51    /// Always try to act as relay (if conditions met).
52    Always,
53}
54
55/// Relay role of a device.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum RelayRole {
59    /// Regular device (not a relay).
60    Regular,
61    /// Acting as a relay.
62    Relay,
63}
64
65/// Reason a device was demoted from relay role.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum RelayDemotionReason {
68    /// Active connection count fell below the relay threshold.
69    LowConnections,
70    /// Battery dropped below the level required to keep relaying.
71    LowBattery {
72        /// Minimum battery level required to remain a relay.
73        min_required: u8,
74    },
75    /// The configuration forbids relaying (`allow_relay` is `false` or the
76    /// priority is [`RelayPriority::Never`]), so the role is surrendered
77    /// regardless of connections or battery.
78    RelayDisallowed,
79}
80
81/// A relay-role transition produced by [`RelayManager::evaluate_transition`].
82///
83/// `None` from `evaluate_transition` means the role did not change; a `Some`
84/// value is emitted exactly once, at the tick the transition happens.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum RelayTransition {
87    /// The device became a relay.
88    Promoted {
89        /// Active connection count at the time of promotion.
90        connection_count: usize,
91        /// Battery level at the time of promotion.
92        battery_level: u8,
93    },
94    /// The device stopped being a relay.
95    Demoted(RelayDemotionReason),
96}
97
98/// Information about a potential relay.
99#[derive(Debug, Clone)]
100pub struct RelayInfo {
101    /// Number of connections this device has.
102    pub connection_count: usize,
103
104    /// Battery level (0-100).
105    pub battery_level: u8,
106
107    /// Whether the device is charging.
108    pub is_charging: bool,
109
110    /// Current relay role.
111    pub role: RelayRole,
112
113    /// Link quality to this relay (0-100).
114    pub link_quality: u8,
115
116    /// Current queue depth at this relay.
117    pub queue_depth: usize,
118
119    /// Congestion level at this relay (0.0-1.0).
120    pub congestion_level: f32,
121}
122
123/// Relay manager for promotion and demotion logic.
124pub struct RelayManager {
125    config: RelayConfig,
126    current_role: RelayRole,
127}
128
129impl RelayManager {
130    /// Creates a new relay manager with default configuration.
131    pub fn new() -> Self {
132        Self::with_config(RelayConfig::default())
133    }
134
135    /// Creates a new relay manager with custom configuration.
136    pub fn with_config(config: RelayConfig) -> Self {
137        Self {
138            config,
139            current_role: RelayRole::Regular,
140        }
141    }
142
143    /// Checks if this device should be promoted to relay role.
144    ///
145    /// # Arguments
146    ///
147    /// * `connection_count` - Number of active connections
148    /// * `battery_level` - Current battery level (0-100)
149    /// * `is_charging` - Whether the device is charging
150    ///
151    /// # Returns
152    ///
153    /// Returns `true` if the device should become a relay.
154    pub fn should_promote_to_relay(
155        &self,
156        connection_count: usize,
157        battery_level: u8,
158        is_charging: bool,
159    ) -> bool {
160        // Config-level opt-outs apply regardless of priority mode — the
161        // charging shortcut below must not override an explicit "don't relay".
162        if self.relaying_disallowed() {
163            return false;
164        }
165
166        // Must have enough connections
167        if connection_count < self.config.relay_threshold {
168            return false;
169        }
170
171        // Check battery constraints
172        if battery_level < 15 {
173            // Critical battery - never become relay
174            return false;
175        }
176
177        if battery_level < self.config.min_battery_for_relay && !is_charging {
178            // Low battery and not charging - only if Always priority
179            return matches!(self.config.relay_priority, RelayPriority::Always);
180        }
181
182        // If charging, always prefer to be relay (helps network)
183        if is_charging {
184            return true;
185        }
186
187        // Auto mode: become relay if conditions are good
188        matches!(
189            self.config.relay_priority,
190            RelayPriority::Auto | RelayPriority::Always
191        )
192    }
193
194    /// Checks if this device should be demoted from relay role.
195    ///
196    /// # Arguments
197    ///
198    /// * `connection_count` - Number of active connections
199    /// * `battery_level` - Current battery level (0-100)
200    /// * `is_charging` - Whether the device is charging
201    ///
202    /// # Returns
203    ///
204    /// Returns `true` if the device should stop being a relay.
205    pub fn should_demote_from_relay(
206        &self,
207        connection_count: usize,
208        battery_level: u8,
209        is_charging: bool,
210    ) -> bool {
211        // A device whose config forbids relaying must surrender the role,
212        // however it acquired it (external `set_role`, config change).
213        if self.relaying_disallowed() {
214            return true;
215        }
216
217        // If priority is Always, stay as relay unless critical battery
218        if matches!(self.config.relay_priority, RelayPriority::Always) {
219            return battery_level < CRITICAL_RELAY_BATTERY_LEVEL;
220        }
221
222        // Demote if connections drop below threshold
223        if connection_count < self.config.relay_threshold {
224            return true;
225        }
226
227        // Demote if battery is too low. A charging device is held to the same
228        // relaxed floor as `should_promote_to_relay` (which favors a charging
229        // device even below `min_battery_for_relay`): only a *critical* level
230        // forces demotion. Without this symmetry a charging device sitting just
231        // below the soft minimum would oscillate promote → demote every tick.
232        if battery_level < self.demotion_battery_floor(is_charging) {
233            return true;
234        }
235
236        false
237    }
238
239    /// Whether the configuration forbids acting as a relay at all.
240    ///
241    /// Mirrors the message-forwarding gate in the protocol crate: relaying is
242    /// off when `allow_relay` is `false` or the priority is `Never`.
243    fn relaying_disallowed(&self) -> bool {
244        !self.config.allow_relay || matches!(self.config.relay_priority, RelayPriority::Never)
245    }
246
247    /// Battery level below which a relay must demote, given charging state.
248    ///
249    /// Mirrors the battery branches of [`should_demote_from_relay`] so the
250    /// transition classifier and the demotion decision never disagree.
251    fn demotion_battery_floor(&self, is_charging: bool) -> u8 {
252        if matches!(self.config.relay_priority, RelayPriority::Always) || is_charging {
253            CRITICAL_RELAY_BATTERY_LEVEL
254        } else {
255            self.config.min_battery_for_relay
256        }
257    }
258
259    /// Evaluates whether the relay role should change and, if so, applies the
260    /// change and returns the transition that occurred.
261    ///
262    /// This is the single entry point that mutates [`RelayRole`]: it compares
263    /// the promote/demote decision against the current role and only returns
264    /// `Some` when the role actually flips, so callers can emit a role-change
265    /// event exactly once per transition. A stable role returns `None`.
266    pub fn evaluate_transition(
267        &mut self,
268        connection_count: usize,
269        battery_level: u8,
270        is_charging: bool,
271    ) -> Option<RelayTransition> {
272        match self.current_role {
273            RelayRole::Regular => {
274                if self.should_promote_to_relay(connection_count, battery_level, is_charging) {
275                    self.set_role(RelayRole::Relay);
276                    Some(RelayTransition::Promoted {
277                        connection_count,
278                        battery_level,
279                    })
280                } else {
281                    None
282                }
283            }
284            RelayRole::Relay => {
285                if self.should_demote_from_relay(connection_count, battery_level, is_charging) {
286                    self.set_role(RelayRole::Regular);
287                    // A config-level opt-out outranks resource state: without
288                    // it a healthy-but-disallowed relay would be misreported
289                    // as connection-starved. Below that, battery takes
290                    // precedence: a relay that is both connection-starved and
291                    // battery-starved is reported as a battery demotion,
292                    // which is the signal the analytics panel tracks
293                    // distinctly.
294                    let floor = self.demotion_battery_floor(is_charging);
295                    let reason = if self.relaying_disallowed() {
296                        RelayDemotionReason::RelayDisallowed
297                    } else if battery_level < floor {
298                        RelayDemotionReason::LowBattery {
299                            min_required: floor,
300                        }
301                    } else {
302                        RelayDemotionReason::LowConnections
303                    };
304                    Some(RelayTransition::Demoted(reason))
305                } else {
306                    None
307                }
308            }
309        }
310    }
311
312    /// Calculates a score for a potential relay.
313    ///
314    /// Higher score = better relay candidate.
315    pub fn calculate_relay_score(&self, relay: &RelayInfo) -> f32 {
316        let mut score = 0.0;
317
318        // Connection count factor (0-30 points)
319        let connection_score = (relay.connection_count as f32 / 10.0 * 30.0).min(30.0);
320        score += connection_score;
321
322        // Battery level factor (0-20 points)
323        let battery_score = relay.battery_level as f32 / 100.0 * 20.0;
324        score += battery_score;
325
326        // Charging bonus (20 points)
327        if relay.is_charging {
328            score += 20.0;
329        }
330
331        // Link quality factor (0-20 points)
332        let link_score = relay.link_quality as f32 / 100.0 * 20.0;
333        score += link_score;
334
335        // Congestion penalty (0-15 points deduction)
336        let congestion_penalty = relay.congestion_level * 15.0;
337        score -= congestion_penalty;
338
339        // Queue depth penalty (0-15 points deduction)
340        let queue_penalty = (relay.queue_depth as f32 / 50.0 * 15.0).min(15.0);
341        score -= queue_penalty;
342
343        score.max(0.0)
344    }
345
346    /// Selects the best relays from a list of candidates.
347    ///
348    /// # Arguments
349    ///
350    /// * `candidates` - List of potential relays
351    /// * `count` - Number of relays to select
352    /// * `max_congestion_level` - Congestion level (0.0-1.0) at or above
353    ///   which a candidate is considered overloaded and excluded; pass the
354    ///   caller's path policy (e.g. `PathConfig::max_congestion_level`)
355    ///
356    /// # Returns
357    ///
358    /// Returns up to `count` relay candidates, sorted by score (best first).
359    pub fn select_best_relays(
360        &self,
361        mut candidates: Vec<RelayInfo>,
362        count: usize,
363        max_congestion_level: f32,
364    ) -> Vec<RelayInfo> {
365        // Filter out overloaded relays
366        candidates.retain(|relay| relay.congestion_level < max_congestion_level);
367
368        // Calculate scores and sort
369        candidates.sort_by(|a, b| {
370            let score_a = self.calculate_relay_score(a);
371            let score_b = self.calculate_relay_score(b);
372            score_b
373                .partial_cmp(&score_a)
374                .unwrap_or(std::cmp::Ordering::Equal)
375        });
376
377        // Take top N
378        candidates.into_iter().take(count).collect()
379    }
380
381    /// Gets the current relay role.
382    pub fn current_role(&self) -> RelayRole {
383        self.current_role
384    }
385
386    /// Sets the current relay role.
387    pub fn set_role(&mut self, role: RelayRole) {
388        self.current_role = role;
389    }
390
391    /// Gets the relay configuration.
392    pub fn config(&self) -> &RelayConfig {
393        &self.config
394    }
395}
396
397impl Default for RelayManager {
398    fn default() -> Self {
399        Self::new()
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_relay_promotion_basic() {
409        let manager = RelayManager::new();
410
411        // Good conditions: many connections, good battery
412        assert!(manager.should_promote_to_relay(5, 80, false));
413
414        // Too few connections
415        assert!(!manager.should_promote_to_relay(2, 80, false));
416
417        // Critical battery
418        assert!(!manager.should_promote_to_relay(5, 10, false));
419    }
420
421    #[test]
422    fn test_relay_promotion_charging_preferred() {
423        let manager = RelayManager::new();
424
425        // Charging device is preferred even with lower battery
426        assert!(manager.should_promote_to_relay(3, 25, true));
427    }
428
429    #[test]
430    fn test_relay_demotion() {
431        let manager = RelayManager::new();
432
433        // Should demote if connections drop
434        assert!(manager.should_demote_from_relay(2, 80, false));
435
436        // Should demote if battery too low
437        assert!(manager.should_demote_from_relay(5, 25, false));
438
439        // Should stay as relay with good conditions
440        assert!(!manager.should_demote_from_relay(5, 80, false));
441    }
442
443    #[test]
444    fn test_should_demote_charging_exempt_from_soft_floor() {
445        let manager = RelayManager::new(); // min_battery_for_relay = 30
446
447        // Not charging, battery below the soft minimum -> demote.
448        assert!(manager.should_demote_from_relay(5, 25, false));
449
450        // Charging at the same level -> stay (mirrors promotion); only a
451        // critical battery level forces demotion while charging.
452        assert!(!manager.should_demote_from_relay(5, 25, true));
453        assert!(manager.should_demote_from_relay(5, 10, true));
454    }
455
456    #[test]
457    fn test_evaluate_transition_promote_then_idempotent() {
458        let mut manager = RelayManager::new();
459        assert_eq!(manager.current_role(), RelayRole::Regular);
460
461        // Good conditions promote, returning the transition exactly once.
462        assert_eq!(
463            manager.evaluate_transition(5, 80, false),
464            Some(RelayTransition::Promoted {
465                connection_count: 5,
466                battery_level: 80,
467            })
468        );
469        assert_eq!(manager.current_role(), RelayRole::Relay);
470
471        // Stable conditions on the next tick produce no transition.
472        assert_eq!(manager.evaluate_transition(5, 80, false), None);
473        assert_eq!(manager.current_role(), RelayRole::Relay);
474    }
475
476    #[test]
477    fn test_evaluate_transition_no_promote_stays_regular() {
478        let mut manager = RelayManager::new();
479        // Too few connections: no promotion, role unchanged.
480        assert_eq!(manager.evaluate_transition(1, 80, false), None);
481        assert_eq!(manager.current_role(), RelayRole::Regular);
482    }
483
484    #[test]
485    fn test_evaluate_transition_demote_low_connections() {
486        let mut manager = RelayManager::new();
487        manager.set_role(RelayRole::Relay);
488
489        // Battery healthy, connections collapsed -> connection demotion.
490        assert_eq!(
491            manager.evaluate_transition(1, 80, false),
492            Some(RelayTransition::Demoted(
493                RelayDemotionReason::LowConnections
494            ))
495        );
496        assert_eq!(manager.current_role(), RelayRole::Regular);
497    }
498
499    #[test]
500    fn test_evaluate_transition_demote_low_battery() {
501        let mut manager = RelayManager::new(); // min_battery_for_relay = 30
502        manager.set_role(RelayRole::Relay);
503
504        // Enough connections, battery below minimum -> battery demotion,
505        // reporting the soft floor as the required level.
506        assert_eq!(
507            manager.evaluate_transition(5, 25, false),
508            Some(RelayTransition::Demoted(RelayDemotionReason::LowBattery {
509                min_required: 30,
510            }))
511        );
512        assert_eq!(manager.current_role(), RelayRole::Regular);
513    }
514
515    #[test]
516    fn test_evaluate_transition_battery_precedence_over_connections() {
517        let mut manager = RelayManager::new();
518        manager.set_role(RelayRole::Relay);
519
520        // Both connection-starved and battery-starved: classified as battery.
521        assert_eq!(
522            manager.evaluate_transition(1, 10, false),
523            Some(RelayTransition::Demoted(RelayDemotionReason::LowBattery {
524                min_required: 30,
525            }))
526        );
527    }
528
529    #[test]
530    fn test_evaluate_transition_always_critical_battery_floor() {
531        let config = RelayConfig {
532            relay_priority: RelayPriority::Always,
533            ..Default::default()
534        };
535        let mut manager = RelayManager::with_config(config);
536        manager.set_role(RelayRole::Relay);
537
538        // Always priority demotes only at critical battery, reporting 15.
539        assert_eq!(
540            manager.evaluate_transition(1, 10, false),
541            Some(RelayTransition::Demoted(RelayDemotionReason::LowBattery {
542                min_required: 15,
543            }))
544        );
545    }
546
547    #[test]
548    fn test_evaluate_transition_no_flap_while_charging() {
549        let mut manager = RelayManager::new(); // min_battery_for_relay = 30
550
551        // Charging device just below the soft minimum: promotes, then holds
552        // (no promote → demote oscillation that would emit fake churn).
553        assert!(matches!(
554            manager.evaluate_transition(5, 25, true),
555            Some(RelayTransition::Promoted { .. })
556        ));
557        assert_eq!(manager.evaluate_transition(5, 25, true), None);
558        assert_eq!(manager.current_role(), RelayRole::Relay);
559    }
560
561    #[test]
562    fn test_relay_scoring() {
563        let manager = RelayManager::new();
564
565        let good_relay = RelayInfo {
566            connection_count: 8,
567            battery_level: 80,
568            is_charging: true,
569            role: RelayRole::Relay,
570            link_quality: 90,
571            queue_depth: 5,
572            congestion_level: 0.1,
573        };
574
575        let poor_relay = RelayInfo {
576            connection_count: 3,
577            battery_level: 25,
578            is_charging: false,
579            role: RelayRole::Regular,
580            link_quality: 40,
581            queue_depth: 60,
582            congestion_level: 0.8,
583        };
584
585        let good_score = manager.calculate_relay_score(&good_relay);
586        let poor_score = manager.calculate_relay_score(&poor_relay);
587
588        assert!(good_score > poor_score);
589        assert!(good_score > 70.0); // Good relay should score high
590    }
591
592    #[test]
593    fn test_select_best_relays() {
594        let manager = RelayManager::new();
595
596        let relays = vec![
597            RelayInfo {
598                connection_count: 8,
599                battery_level: 80,
600                is_charging: true,
601                role: RelayRole::Relay,
602                link_quality: 90,
603                queue_depth: 5,
604                congestion_level: 0.1,
605            },
606            RelayInfo {
607                connection_count: 5,
608                battery_level: 60,
609                is_charging: false,
610                role: RelayRole::Relay,
611                link_quality: 70,
612                queue_depth: 15,
613                congestion_level: 0.3,
614            },
615            RelayInfo {
616                connection_count: 3,
617                battery_level: 40,
618                is_charging: false,
619                role: RelayRole::Regular,
620                link_quality: 50,
621                queue_depth: 40,
622                congestion_level: 0.6,
623            },
624        ];
625
626        let best = manager.select_best_relays(relays, 2, 0.7);
627        assert_eq!(best.len(), 2);
628
629        // First relay should be the best one
630        assert_eq!(best[0].connection_count, 8);
631    }
632
633    #[test]
634    fn test_select_best_relays_honors_congestion_threshold() {
635        let manager = RelayManager::new();
636        let candidate = |congestion_level: f32| RelayInfo {
637            connection_count: 5,
638            battery_level: 60,
639            is_charging: false,
640            role: RelayRole::Relay,
641            link_quality: 70,
642            queue_depth: 10,
643            congestion_level,
644        };
645
646        // A 0.6-congestion candidate passes the default-style 0.7 threshold
647        // but must be excluded under a stricter 0.5 policy.
648        let best = manager.select_best_relays(vec![candidate(0.6)], 1, 0.7);
649        assert_eq!(best.len(), 1);
650
651        let best = manager.select_best_relays(vec![candidate(0.6)], 1, 0.5);
652        assert!(best.is_empty());
653    }
654
655    #[test]
656    fn test_relay_priority_never() {
657        let config = RelayConfig {
658            relay_priority: RelayPriority::Never,
659            ..Default::default()
660        };
661        let manager = RelayManager::with_config(config);
662
663        // Should never promote, even with perfect conditions
664        assert!(!manager.should_promote_to_relay(10, 100, true));
665
666        // And must surrender the role if it somehow holds it.
667        assert!(manager.should_demote_from_relay(10, 100, true));
668    }
669
670    #[test]
671    fn test_allow_relay_false_blocks_auto_promotion() {
672        let config = RelayConfig {
673            allow_relay: false,
674            ..Default::default() // Auto priority
675        };
676        let manager = RelayManager::with_config(config);
677
678        // Perfect conditions, including the charging shortcut that used to
679        // bypass the opt-out entirely in Auto mode.
680        assert!(!manager.should_promote_to_relay(10, 100, true));
681        assert!(!manager.should_promote_to_relay(10, 100, false));
682    }
683
684    #[test]
685    fn test_allow_relay_false_forces_demotion() {
686        let config = RelayConfig {
687            allow_relay: false,
688            ..Default::default()
689        };
690        let manager = RelayManager::with_config(config);
691
692        // Healthy connections and battery are irrelevant: the config says no.
693        assert!(manager.should_demote_from_relay(10, 100, false));
694    }
695
696    #[test]
697    fn test_allow_relay_false_blocks_always_priority() {
698        let config = RelayConfig {
699            allow_relay: false,
700            relay_priority: RelayPriority::Always,
701            ..Default::default()
702        };
703        let manager = RelayManager::with_config(config);
704
705        assert!(!manager.should_promote_to_relay(10, 100, true));
706        assert!(manager.should_demote_from_relay(10, 100, true));
707    }
708
709    #[test]
710    fn test_evaluate_transition_demote_relay_disallowed() {
711        let config = RelayConfig {
712            allow_relay: false,
713            ..Default::default()
714        };
715        let mut manager = RelayManager::with_config(config);
716        manager.set_role(RelayRole::Relay);
717
718        // Healthy stats: the demotion must be classified as disallowed, not
719        // misreported as connection- or battery-starvation.
720        assert_eq!(
721            manager.evaluate_transition(10, 100, false),
722            Some(RelayTransition::Demoted(
723                RelayDemotionReason::RelayDisallowed
724            ))
725        );
726        assert_eq!(manager.current_role(), RelayRole::Regular);
727    }
728
729    #[test]
730    fn test_relay_priority_always() {
731        let config = RelayConfig {
732            relay_priority: RelayPriority::Always,
733            ..Default::default()
734        };
735        let manager = RelayManager::with_config(config);
736
737        // Should promote even with lower battery (if not critical)
738        assert!(manager.should_promote_to_relay(3, 20, false));
739
740        // But not with critical battery
741        assert!(!manager.should_promote_to_relay(3, 10, false));
742    }
743}