Skip to main content

offline_protocol_router/
dors.rs

1//! DORS (Dynamic Offline Relay Switch) transport selection.
2//!
3//! This module implements the intelligent transport selection algorithm
4//! that automatically chooses and switches between Internet, BLE Mesh, and Wi-Fi Direct
5//! based on real-time network conditions.
6
7use chrono::{DateTime, Duration, Utc};
8use offline_protocol_core::Message;
9use offline_protocol_transport::{TransportMetrics, TransportType};
10use std::collections::{HashMap, VecDeque};
11use tracing::debug;
12
13/// Configuration for DORS transport selection.
14#[derive(Debug, Clone)]
15pub struct DorsConfig {
16    /// Minimum score improvement required to switch transports (hysteresis).
17    pub switch_hysteresis: f32,
18
19    /// Cooldown period after switching (seconds).
20    pub switch_cooldown_secs: u64,
21
22    /// Number of retry failures before escalating from BLE to Wi-Fi Direct.
23    pub ble_to_wifi_retry_threshold: u32,
24
25    /// Minimum BLE success rate (0.0–1.0) below which we escalate to WiFi (quality degradation).
26    pub min_success_rate_before_escalation: f32,
27
28    /// Minimum BLE success-ratio samples (observation volume) before evaluating low-success-rate
29    /// escalation. Prevents a single early failure from triggering escalation on sparse data.
30    pub min_ble_samples_before_success_rate_escalation: usize,
31
32    /// RSSI threshold for switching from BLE to Wi-Fi Direct (dBm).
33    pub rssi_switch_threshold: i16,
34
35    /// Queue depth threshold for detecting congestion.
36    pub congestion_queue_threshold: usize,
37
38    /// Duration for checking stability before switching (seconds).
39    pub stability_window_secs: u64,
40
41    /// Duration that RSSI must remain below the threshold before escalating (seconds).
42    pub poor_signal_duration_secs: u64,
43
44    /// TTL threshold that indicates messages are nearing exhaustion.
45    pub ttl_escalation_threshold: u8,
46
47    /// Whether to prefer online/Internet transport first.
48    pub prefer_online: bool,
49
50    /// Duration that queue congestion must persist before escalating (seconds).
51    pub congestion_duration_secs: u64,
52
53    /// Duration to keep TTL escalation signal active after detection (seconds).
54    pub ttl_escalation_hold_secs: u64,
55
56    /// Number of historical samples to retain per transport for smoothing metrics.
57    pub history_window_size: usize,
58
59    /// Ratio of `congestion_queue_threshold` that indicates recovery (0.0-1.0).
60    pub queue_recovery_ratio: f32,
61
62    /// Battery percentage considered low for energy-aware decisions.
63    pub low_battery_threshold: u8,
64
65    /// Minimum battery percentage required to escalate to high-power transports when not charging.
66    pub relay_min_battery_level: u8,
67
68    /// Target number of relay connections before considering the relay saturated.
69    pub relay_optimal_connection_count: u8,
70}
71
72impl Default for DorsConfig {
73    fn default() -> Self {
74        Self {
75            switch_hysteresis: 15.0,
76            switch_cooldown_secs: 20,
77            ble_to_wifi_retry_threshold: 2,
78            min_success_rate_before_escalation: 0.30,
79            min_ble_samples_before_success_rate_escalation: 5,
80            rssi_switch_threshold: -85,
81            congestion_queue_threshold: 50,
82            stability_window_secs: 8,
83            poor_signal_duration_secs: 10,
84            ttl_escalation_threshold: 2,
85            prefer_online: false,
86            congestion_duration_secs: 10,
87            ttl_escalation_hold_secs: 20,
88            history_window_size: 10,
89            queue_recovery_ratio: 0.5,
90            low_battery_threshold: 20,
91            relay_min_battery_level: 30,
92            relay_optimal_connection_count: 4,
93        }
94    }
95}
96
97/// Scores within this delta of the *best* score are treated as a tie for selection,
98/// and the tie-break order (Internet > WiFiDirect > BLE > Reticulum) is then used.
99const TIE_EPSILON: f32 = 0.01;
100
101/// No-RSSI-data default for radio transports (not transport-specific, just means
102/// "we don't know the signal yet").
103const SIGNAL_SCORE_NO_DATA: f32 = 50.0;
104
105/// Demotion subtracted from the Internet transport's score to make it a strict
106/// *fallback* (only chosen when no mesh transport can reach the peer). Applied
107/// only when `prefer_online == false` and the message is not internet-preferred.
108/// Large enough to push Internet below every possible mesh score (max ≈ 125),
109/// so it ranks last while remaining the sole candidate for genuinely remote
110/// peers where no mesh transport is available. See `calculate_transport_score`.
111const INTERNET_FALLBACK_DEMOTION: f32 = 10_000.0;
112
113/// Recovers the real 0–125 quality score from a (possibly demoted) routing
114/// `total` for telemetry / display.
115///
116/// `INTERNET_FALLBACK_DEMOTION` is a pure *ranking* device: it pushes the
117/// Internet transport below every mesh score so selection only escalates to it
118/// as a last resort. That sentinel must never leak to app-facing telemetry
119/// (events, routing decisions, the UniFFI boundary) — a dashboard reading a
120/// "score" of ≈ -9900 for a remote peer is nonsense. Selection keeps the raw
121/// (negative) total internally; consumers that *display* a score call this.
122///
123/// The demotion is detectable by sign: a non-demoted total is always `>= 0`
124/// (it is `.max(0.0)`'d in `calculate_transport_score`), and a demoted Internet
125/// total is `real - INTERNET_FALLBACK_DEMOTION` with `real ∈ [0, ~125]`, so
126/// adding the demotion back recovers the real score exactly. Idempotent — safe
127/// to apply to an already-sanitized (non-negative) value.
128pub fn display_routing_score(raw_total: f32) -> f32 {
129    if raw_total < 0.0 {
130        (raw_total + INTERNET_FALLBACK_DEMOTION).max(0.0)
131    } else {
132        raw_total
133    }
134}
135
136/// Tie-break priority for transport selection when scores are equal or within TIE_EPSILON.
137/// Lower value = preferred. Order: Internet, WiFiDirect, BLE, Reticulum.
138///
139/// Kept as a standalone function (rather than going through [`TransportScoringProfile`])
140/// because tie-breaking is called per-element inside `min_by_key` / `sort_by`, and
141/// constructing a full profile struct just to read a `u8` would be wasteful.
142/// The values here **must** match each profile's `tie_break_priority` field.
143fn tie_break_priority(transport_type: TransportType) -> u8 {
144    match transport_type {
145        TransportType::Internet => 0,
146        TransportType::WiFiDirect => 1,
147        TransportType::BLE => 2,
148        TransportType::Reticulum => 3,
149        TransportType::Nostr => 4,
150    }
151}
152
153/// Weights for each DORS scoring dimension.
154#[derive(Debug, Clone)]
155struct ScoringWeights {
156    signal: f32,
157    proximity: f32,
158    bandwidth: f32,
159    congestion: f32,
160    energy: f32,
161    reliability: f32,
162    load: f32,
163}
164
165impl ScoringWeights {
166    /// Sum of all weights. Should be 1.0 for a correctly defined profile.
167    fn sum(&self) -> f32 {
168        self.signal
169            + self.proximity
170            + self.bandwidth
171            + self.congestion
172            + self.energy
173            + self.reliability
174            + self.load
175    }
176}
177
178/// Per-transport scoring configuration for DORS.
179///
180/// Centralises all transport-specific weights, baselines, and thresholds so that
181/// adding a new transport only requires defining a single profile in
182/// [`TransportSelector::scoring_profile`].
183#[derive(Debug, Clone)]
184struct TransportScoringProfile {
185    /// Weights for each scoring dimension.
186    weights: ScoringWeights,
187    /// Base score added to the weighted sum (e.g., Internet gets a baseline boost).
188    base_score: f32,
189    /// Bonus applied when message has `transport_preference = "internet"`.
190    media_bonus: f32,
191    /// Penalty applied when message has `transport_preference = "internet"`.
192    media_penalty: f32,
193    /// Maximum bandwidth in bytes/sec for normalization.
194    bandwidth_max_bps: u64,
195    /// Default bandwidth score when `bandwidth_bps` is not reported.
196    bandwidth_default: f32,
197    /// Base energy efficiency score (0–100).
198    energy_baseline: f32,
199    /// Whether this transport is high-power (penalised when battery is low).
200    is_high_power: bool,
201    /// Energy adjustment when this node is actively relaying on this transport.
202    active_relay_energy_adjustment: f32,
203    /// Whether this transport reports RSSI (radio-based transports).
204    has_signal: bool,
205    /// Default signal score returned when the transport does not use RSSI.
206    default_signal_score: f32,
207    /// Tie-break priority (lower = preferred when scores are equal).
208    tie_break_priority: u8,
209}
210
211/// Reason DORS decided to escalate from BLE to Wi‑Fi (for observability).
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum EscalationTriggerReason {
214    /// BLE retry failures reached threshold.
215    RetryThreshold,
216    /// Poor BLE signal for sustained duration.
217    PoorSignal,
218    /// BLE congestion / queue depth exceeded.
219    Congestion,
220    /// Message TTL near exhaustion.
221    LowTtl,
222    /// BLE success rate dropped below configured minimum (quality degradation).
223    LowSuccessRate,
224}
225
226/// Result of evaluating all BLE→WiFi escalation conditions.
227struct EscalationEvaluation {
228    /// The first triggered reason in priority order, or `None` if no condition is active.
229    trigger: Option<EscalationTriggerReason>,
230    /// Whether battery level is too low to permit escalation to a high-power transport.
231    battery_too_low: bool,
232}
233
234/// Score breakdown for transport selection.
235///
236/// `#[non_exhaustive]` so a future scoring factor can be added without a
237/// breaking-change bump. External callers that need to construct a value
238/// (benchmarks, tests, tooling) build a [`TransportScoreFactors`] literal
239/// and pass it to [`Self::from_factors`] — the factors struct uses named
240/// fields so adding a factor in the future does not silently misorder
241/// existing call sites.
242#[non_exhaustive]
243#[derive(Debug, Clone)]
244pub struct TransportScore {
245    /// Signal strength score (0-100).
246    pub signal: f32,
247    /// Proximity/hop distance score (0-100).
248    pub proximity: f32,
249    /// Available bandwidth score (0-100).
250    pub bandwidth: f32,
251    /// Congestion score (0-100, higher is less congested).
252    pub congestion: f32,
253    /// Energy efficiency score (0-100).
254    pub energy: f32,
255    /// Reliability score (0-100).
256    pub reliability: f32,
257    /// Load score (0-100, higher = more capacity available).
258    pub load: f32,
259    /// Total weighted score.
260    pub total: f32,
261}
262
263/// Named-field factors accepted by [`TransportScore::from_factors`].
264///
265/// Using named fields prevents the silent-misorder bug that an N-arg
266/// positional constructor would invite (every factor is `f32`).
267#[derive(Debug, Clone, Default)]
268pub struct TransportScoreFactors {
269    /// Signal strength score (0-100).
270    pub signal: f32,
271    /// Proximity/hop distance score (0-100).
272    pub proximity: f32,
273    /// Available bandwidth score (0-100).
274    pub bandwidth: f32,
275    /// Congestion score (0-100, higher is less congested).
276    pub congestion: f32,
277    /// Energy efficiency score (0-100).
278    pub energy: f32,
279    /// Reliability score (0-100).
280    pub reliability: f32,
281    /// Load score (0-100, higher = more capacity available).
282    pub load: f32,
283    /// Total weighted score.
284    pub total: f32,
285}
286
287impl TransportScore {
288    /// Constructs a score from explicit per-factor values.
289    ///
290    /// Intended for callers outside this crate (benchmarks, tooling). The
291    /// per-factor weighting is the selector's responsibility; this
292    /// constructor performs no validation or recomputation of `total`.
293    pub fn from_factors(factors: TransportScoreFactors) -> Self {
294        Self {
295            signal: factors.signal,
296            proximity: factors.proximity,
297            bandwidth: factors.bandwidth,
298            congestion: factors.congestion,
299            energy: factors.energy,
300            reliability: factors.reliability,
301            load: factors.load,
302            total: factors.total,
303        }
304    }
305}
306
307#[derive(Default, Debug)]
308struct TransportHistory {
309    queue_depths: VecDeque<usize>,
310    congestion_levels: VecDeque<f32>,
311    rssi_samples: VecDeque<i16>,
312    success_ratios: VecDeque<f32>,
313    latency_samples: VecDeque<u32>,
314}
315
316impl TransportHistory {
317    fn push_queue_depth(&mut self, depth: usize, limit: usize) {
318        self.queue_depths.push_back(depth);
319        truncate_deque(&mut self.queue_depths, limit);
320    }
321
322    fn push_congestion(&mut self, level: f32, limit: usize) {
323        self.congestion_levels.push_back(level);
324        truncate_deque(&mut self.congestion_levels, limit);
325    }
326
327    fn push_rssi(&mut self, rssi: i16, limit: usize) {
328        self.rssi_samples.push_back(rssi);
329        truncate_deque(&mut self.rssi_samples, limit);
330    }
331
332    fn push_success_ratio(&mut self, ratio: f32, limit: usize) {
333        self.success_ratios.push_back(ratio.clamp(0.0, 1.0));
334        truncate_deque(&mut self.success_ratios, limit);
335    }
336
337    fn push_latency(&mut self, latency: u32, limit: usize) {
338        self.latency_samples.push_back(latency);
339        truncate_deque(&mut self.latency_samples, limit);
340    }
341
342    fn average_queue_depth(&self) -> Option<f32> {
343        average_usize(&self.queue_depths)
344    }
345
346    fn average_congestion(&self) -> Option<f32> {
347        average_f32(&self.congestion_levels)
348    }
349
350    fn average_rssi(&self) -> Option<f32> {
351        if self.rssi_samples.is_empty() {
352            None
353        } else {
354            Some(
355                self.rssi_samples.iter().map(|v| *v as f32).sum::<f32>()
356                    / self.rssi_samples.len() as f32,
357            )
358        }
359    }
360
361    fn average_success_ratio(&self) -> Option<f32> {
362        average_f32(&self.success_ratios)
363    }
364
365    /// Number of success-ratio samples in history (observation volume for escalation gating).
366    fn success_ratio_sample_count(&self) -> usize {
367        self.success_ratios.len()
368    }
369}
370
371fn truncate_deque<T>(deque: &mut VecDeque<T>, limit: usize) {
372    let limit = limit.max(1);
373    while deque.len() > limit {
374        deque.pop_front();
375    }
376}
377
378fn average_usize(samples: &VecDeque<usize>) -> Option<f32> {
379    if samples.is_empty() {
380        return None;
381    }
382    Some(samples.iter().sum::<usize>() as f32 / samples.len() as f32)
383}
384
385fn average_f32(samples: &VecDeque<f32>) -> Option<f32> {
386    if samples.is_empty() {
387        return None;
388    }
389    Some(samples.iter().sum::<f32>() / samples.len() as f32)
390}
391
392/// Transport selector for DORS.
393///
394/// This struct implements the intelligent transport selection algorithm
395/// that chooses between Internet, BLE, and Wi-Fi Direct based on network conditions.
396pub struct TransportSelector {
397    config: DorsConfig,
398    current_transport: Option<TransportType>,
399    last_switch_time: Option<DateTime<Utc>>,
400    transport_scores_history: HashMap<TransportType, VecDeque<(DateTime<Utc>, f32)>>,
401    transport_history: HashMap<TransportType, TransportHistory>,
402    retry_counts: HashMap<TransportType, u32>,
403    last_metrics: HashMap<TransportType, (TransportMetrics, DateTime<Utc>)>,
404    ble_poor_signal_since: Option<DateTime<Utc>>,
405    ble_high_congestion_since: Option<DateTime<Utc>>,
406    low_ttl_detected_at: Option<DateTime<Utc>>,
407}
408
409impl TransportSelector {
410    /// Creates a new transport selector with default configuration.
411    pub fn new() -> Self {
412        Self::with_config(DorsConfig::default())
413    }
414
415    /// Creates a new transport selector with custom configuration.
416    pub fn with_config(config: DorsConfig) -> Self {
417        Self {
418            config,
419            current_transport: None,
420            last_switch_time: None,
421            transport_scores_history: HashMap::new(),
422            transport_history: HashMap::new(),
423            retry_counts: HashMap::new(),
424            last_metrics: HashMap::new(),
425            ble_poor_signal_since: None,
426            ble_high_congestion_since: None,
427            low_ttl_detected_at: None,
428        }
429    }
430
431    /// Returns the scoring profile for a transport type.
432    ///
433    /// All transport-specific scoring configuration lives here. To add a new
434    /// transport, define its profile and everything else (weighted scoring,
435    /// bandwidth normalisation, energy baseline, tie-break) is handled
436    /// generically.
437    fn scoring_profile(&self, transport_type: TransportType) -> TransportScoringProfile {
438        let profile = match transport_type {
439            TransportType::Internet => TransportScoringProfile {
440                weights: ScoringWeights {
441                    signal: 0.0,
442                    proximity: 0.0,
443                    bandwidth: 0.35,
444                    congestion: 0.15,
445                    energy: 0.10,
446                    reliability: 0.30,
447                    load: 0.10,
448                },
449                base_score: if self.config.prefer_online {
450                    25.0
451                } else {
452                    10.0
453                },
454                media_bonus: 50.0,
455                media_penalty: 0.0,
456                bandwidth_max_bps: 10_000_000,
457                bandwidth_default: if self.config.prefer_online {
458                    70.0
459                } else {
460                    50.0
461                },
462                energy_baseline: 60.0,
463                is_high_power: true,
464                active_relay_energy_adjustment: 0.0,
465                has_signal: false,
466                default_signal_score: 60.0,
467                tie_break_priority: 0,
468            },
469            TransportType::BLE => TransportScoringProfile {
470                weights: ScoringWeights {
471                    signal: 0.30,
472                    proximity: 0.15,
473                    bandwidth: 0.0,
474                    congestion: 0.15,
475                    energy: 0.30,
476                    reliability: 0.05,
477                    load: 0.05,
478                },
479                base_score: 0.0,
480                media_bonus: 0.0,
481                media_penalty: 40.0,
482                bandwidth_max_bps: 150_000,
483                bandwidth_default: 40.0,
484                energy_baseline: 90.0,
485                is_high_power: false,
486                active_relay_energy_adjustment: 5.0,
487                has_signal: true,
488                default_signal_score: 60.0,
489                tie_break_priority: 2,
490            },
491            TransportType::WiFiDirect => TransportScoringProfile {
492                weights: ScoringWeights {
493                    signal: 0.0,
494                    proximity: 0.20,
495                    bandwidth: 0.35,
496                    congestion: 0.20,
497                    energy: 0.0,
498                    reliability: 0.15,
499                    load: 0.10,
500                },
501                base_score: 0.0,
502                media_bonus: 0.0,
503                media_penalty: 0.0,
504                bandwidth_max_bps: 2_000_000,
505                bandwidth_default: 90.0,
506                energy_baseline: 40.0,
507                is_high_power: true,
508                active_relay_energy_adjustment: -10.0,
509                has_signal: true,
510                default_signal_score: 60.0,
511                tie_break_priority: 1,
512            },
513            TransportType::Reticulum => TransportScoringProfile {
514                weights: ScoringWeights {
515                    signal: 0.05,
516                    proximity: 0.20,
517                    bandwidth: 0.05,
518                    congestion: 0.15,
519                    energy: 0.25,
520                    reliability: 0.30,
521                    load: 0.0,
522                },
523                base_score: 0.0,
524                media_bonus: 0.0,
525                media_penalty: 40.0,
526                bandwidth_max_bps: 2_700, // LoRa peak ~2.7 KB/s (SF7/BW500kHz)
527                bandwidth_default: 20.0,  // Conservative default
528                energy_baseline: 75.0,    // Between BLE (90) and Internet (60)
529                is_high_power: false,
530                active_relay_energy_adjustment: 0.0,
531                has_signal: true, // Radio interfaces report RSSI
532                default_signal_score: 60.0,
533                tie_break_priority: 3, // Lowest: resilience fallback
534            },
535            TransportType::Nostr => TransportScoringProfile {
536                weights: ScoringWeights {
537                    signal: 0.0,
538                    proximity: 0.0,
539                    bandwidth: 0.20,
540                    congestion: 0.20,
541                    energy: 0.15,
542                    reliability: 0.35,
543                    load: 0.10,
544                },
545                base_score: 5.0,   // Modest base, below Internet
546                media_bonus: 30.0, // Can handle media via relays
547                media_penalty: 0.0,
548                bandwidth_max_bps: 1_000_000, // Relay-limited, ~1 MB/s practical
549                bandwidth_default: 40.0,      // Conservative default
550                energy_baseline: 55.0,        // Similar to Internet (WebSocket)
551                is_high_power: true,          // Uses Internet radio
552                active_relay_energy_adjustment: 0.0,
553                has_signal: false, // No RSSI
554                default_signal_score: 50.0,
555                tie_break_priority: 4, // Censorship-resistant fallback
556            },
557        };
558
559        debug_assert!(
560            (profile.weights.sum() - 1.0).abs() < 1e-6,
561            "Scoring weights for {:?} must sum to 1.0, got {}",
562            transport_type,
563            profile.weights.sum(),
564        );
565        debug_assert_eq!(
566            profile.tie_break_priority,
567            tie_break_priority(transport_type),
568            "tie_break_priority in profile for {:?} must match standalone tie_break_priority()",
569            transport_type,
570        );
571
572        profile
573    }
574
575    /// Selects the best transport for sending a message.
576    ///
577    /// # Arguments
578    ///
579    /// * `message` - The message to send
580    /// * `available_transports` - Available transport types and their metrics
581    ///
582    /// # Returns
583    ///
584    /// Returns the recommended transport type.
585    pub fn select_transport(
586        &mut self,
587        message: &Message,
588        available_transports: &HashMap<TransportType, TransportMetrics>,
589    ) -> Option<TransportType> {
590        // If no transports available, return None
591        if available_transports.is_empty() {
592            return None;
593        }
594
595        // Calculate scores for all available transports
596        let mut scored_transports: Vec<(TransportType, TransportScore)> = Vec::new();
597
598        for (transport_type, metrics) in available_transports.iter() {
599            self.record_metrics(*transport_type, metrics);
600            self.update_history(*transport_type, metrics);
601
602            if *transport_type == TransportType::BLE {
603                self.update_ble_conditions(message, metrics);
604            }
605
606            let score = self.calculate_transport_score(message, *transport_type, metrics);
607            // INVARIANT: `score.total` recorded here may be the *negative*
608            // fallback-demotion sentinel for a demoted Internet transport
609            // (prefer_online=false), not a real 0–125 quality score. The
610            // stability-window consumers (`is_stable_better` / `get_scores_in_window`)
611            // tolerate this by sign today (it makes "leave Internet for mesh"
612            // trivially allowed, which matches intent). Any FUTURE consumer that
613            // *aggregates* this history across transports (e.g. a global average)
614            // must first map entries through `display_routing_score`, or the
615            // sentinel will skew the result.
616            self.record_score(*transport_type, score.total);
617            scored_transports.push((*transport_type, score));
618        }
619
620        // Sort by total score (descending). Tie-break is applied *after* sorting using
621        // a transitive rule ("within TIE_EPSILON of best"), so we avoid epsilon-based
622        // comparators inside sort_by (which can violate transitivity).
623        scored_transports.sort_by(|a, b| {
624            b.1.total
625                .partial_cmp(&a.1.total)
626                .unwrap_or(std::cmp::Ordering::Equal)
627        });
628
629        // Determine the best finite score (ignoring NaNs), then apply deterministic
630        // tie-break among any transports that are within TIE_EPSILON of that best score.
631        // If all scores are non-finite, fall back to tie-break order alone.
632        let (best_transport, best_score) = if let Some((best_t, best_s)) = scored_transports
633            .iter()
634            .filter(|(_, s)| s.total.is_finite())
635            .max_by(|a, b| {
636                a.1.total
637                    .partial_cmp(&b.1.total)
638                    .unwrap_or(std::cmp::Ordering::Equal)
639            }) {
640            let best_total = best_s.total;
641            scored_transports
642                .iter()
643                .filter(|(_, s)| s.total.is_finite() && best_total - s.total <= TIE_EPSILON)
644                .min_by_key(|(t, _)| tie_break_priority(*t))
645                .map(|(t, s)| (*t, s.total))
646                // In the unlikely event the filter yields nothing, fall back to the
647                // original best candidate.
648                .unwrap_or((*best_t, best_total))
649        } else {
650            // No finite scores; pick purely by tie-break priority.
651            scored_transports
652                .iter()
653                .min_by_key(|(t, _)| tie_break_priority(*t))
654                .map(|(t, s)| (*t, s.total))?
655        };
656
657        // Log all scored transports for observability.
658        // Guard allocation behind level check to avoid Vec<String> on every call.
659        if tracing::enabled!(tracing::Level::DEBUG) {
660            // These are the RAW totals, intentionally NOT passed through
661            // `display_routing_score`: a negative Internet total here is the
662            // fallback-demotion rank (prefer_online=false), and showing it raw is
663            // exactly what explains why a lower-quality mesh transport outranks it.
664            // Sanitizing here would hide the demotion and make selection look wrong.
665            let scores_summary: Vec<_> = scored_transports
666                .iter()
667                .map(|(t, s)| format!("{:?}={:.1}", t, s.total))
668                .collect();
669            debug!(
670                scores = %scores_summary.join(", "),
671                best = ?best_transport,
672                previous = ?self.current_transport,
673                prefer_online = self.config.prefer_online,
674                "DORS scored transports"
675            );
676        }
677
678        if let Some(current) = self.current_transport {
679            // If current transport is no longer available, switch immediately
680            // regardless of cooldown or hysteresis (e.g. Internet disconnected).
681            let current_still_available = available_transports.contains_key(&current);
682
683            if current_still_available {
684                if current == best_transport {
685                    return Some(current);
686                }
687
688                if let Some(current_score) = scored_transports
689                    .iter()
690                    .find(|(t, _)| *t == current)
691                    .map(|(_, s)| s.total)
692                {
693                    // A negative current total is the Internet fallback-demotion
694                    // sentinel (prefer_online=false; see `score_transport`). Anti-flap
695                    // (cooldown/hysteresis/stability) must NOT pin the demoted fallback
696                    // over an available, real-scored mesh transport. The fallback only
697                    // became `current` because a *prior* send found the mesh peer
698                    // unreachable and escalated to Internet — which also re-armed the
699                    // switch cooldown (TransportManager::send → set_current_transport).
700                    // The instant the mesh peer is back in the available set (its
701                    // non-negative score outranks the sentinel) we must switch to it, or
702                    // the next send rides the relay through the whole cooldown and an
703                    // off-relay nearby peer is silently bypassed.
704                    let current_is_demoted_fallback = current_score < 0.0 && best_score >= 0.0;
705                    if current_is_demoted_fallback {
706                        debug!(
707                            current = ?current,
708                            current_score = current_score,
709                            candidate = ?best_transport,
710                            candidate_score = best_score,
711                            "DORS: leaving demoted Internet fallback for an available mesh transport (bypassing cooldown)"
712                        );
713                    } else if !self.should_switch(
714                        current,
715                        current_score,
716                        best_transport,
717                        best_score,
718                    ) {
719                        debug!(
720                            current = ?current,
721                            current_score = current_score,
722                            candidate = ?best_transport,
723                            candidate_score = best_score,
724                            "DORS: switch blocked by hysteresis/cooldown/stability"
725                        );
726                        return Some(current);
727                    }
728                }
729            } else {
730                debug!(
731                    lost = ?current,
732                    fallback = ?best_transport,
733                    "DORS: current transport unavailable, switching immediately"
734                );
735            }
736        }
737
738        // Track switch
739        debug!(
740            from = ?self.current_transport,
741            to = ?best_transport,
742            score = best_score,
743            "DORS: transport switched"
744        );
745        self.last_switch_time = Some(Utc::now());
746        self.current_transport = Some(best_transport);
747
748        Some(best_transport)
749    }
750
751    /// Scores all available transports and returns them ranked by score (descending).
752    ///
753    /// Unlike `select_transport`, this is read-only: it does not record metrics,
754    /// update history, or mutate selector state. It is used by
755    /// `TransportManager::send()` to build a fallback list when the primary
756    /// transport's send fails — at that point `select_transport` has already
757    /// recorded the state for this scoring cycle.
758    ///
759    /// **Important**: This method reads `transport_history` for reliability and
760    /// load scores but does not update it. If called independently of a prior
761    /// `select_transport` in the same cycle, the scores may reflect stale
762    /// history. Always call `select_transport` first in any given scoring
763    /// cycle.
764    pub fn score_and_rank(
765        &self,
766        message: &Message,
767        available_transports: &HashMap<TransportType, TransportMetrics>,
768    ) -> Vec<(TransportType, f32)> {
769        // Single-allocation path: the non-diagnostic send() hot path calls
770        // this on every send, so we project scoring to totals directly
771        // rather than going through `score_and_rank_detailed` (which
772        // allocates a `Vec<(TransportType, TransportScore)>` that would
773        // then be mapped to totals — two vecs where one suffices).
774        let mut scored: Vec<(TransportType, f32)> = available_transports
775            .iter()
776            .map(|(transport_type, metrics)| {
777                let score = self.calculate_transport_score(message, *transport_type, metrics);
778                (*transport_type, score.total)
779            })
780            .collect();
781
782        // Contract: ranked by score (descending).
783        //
784        // Determinism: when scores are exactly equal (rare with floats, but
785        // can happen in tests or after rounding), apply the same priority
786        // tie-break used by selection: Internet > WiFiDirect > BLE. See
787        // `score_and_rank_detailed` — the two functions must agree on
788        // ordering, and a regression test (`test_score_and_rank_detailed_\
789        // matches_score_and_rank_order_and_totals`) pins that invariant.
790        scored.sort_by(|a, b| {
791            let ord = b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal);
792            if ord == std::cmp::Ordering::Equal {
793                tie_break_priority(a.0).cmp(&tie_break_priority(b.0))
794            } else {
795                ord
796            }
797        });
798
799        scored
800    }
801
802    /// Diagnostic variant of [`Self::score_and_rank`] that returns the full
803    /// seven-factor [`TransportScore`] per transport rather than just the
804    /// total. Used by telemetry consumers that opted into `routing_diagnostic`.
805    ///
806    /// Sorted descending by `total`; ties resolve via the same priority
807    /// (Internet > WiFiDirect > BLE) used by selection. Identical read-only
808    /// guarantees as `score_and_rank`.
809    pub fn score_and_rank_detailed(
810        &self,
811        message: &Message,
812        available_transports: &HashMap<TransportType, TransportMetrics>,
813    ) -> Vec<(TransportType, TransportScore)> {
814        let mut scored: Vec<(TransportType, TransportScore)> = available_transports
815            .iter()
816            .map(|(transport_type, metrics)| {
817                let score = self.calculate_transport_score(message, *transport_type, metrics);
818                (*transport_type, score)
819            })
820            .collect();
821        scored.sort_by(|a, b| {
822            let ord =
823                b.1.total
824                    .partial_cmp(&a.1.total)
825                    .unwrap_or(std::cmp::Ordering::Equal);
826            if ord == std::cmp::Ordering::Equal {
827                tie_break_priority(a.0).cmp(&tie_break_priority(b.0))
828            } else {
829                ord
830            }
831        });
832        scored
833    }
834
835    /// Calculates the transport score based on multiple factors.
836    fn calculate_transport_score(
837        &self,
838        message: &Message,
839        transport_type: TransportType,
840        metrics: &TransportMetrics,
841    ) -> TransportScore {
842        let profile = self.scoring_profile(transport_type);
843
844        let signal_score = self.calculate_signal_score(&profile, transport_type, metrics);
845        let proximity_score = self.calculate_proximity_score(message);
846        let bandwidth_score = self.calculate_bandwidth_score(&profile, metrics);
847        let congestion_score = self.calculate_congestion_score(transport_type, metrics);
848        let energy_score = self.calculate_energy_score(&profile, metrics);
849        let reliability_score = self.calculate_reliability_score(transport_type, metrics);
850        let load_score = self.calculate_load_score(transport_type, metrics);
851
852        // Per-message transport preference: when the message metadata contains
853        // `transport_preference = "internet"`, strongly boost Internet and
854        // penalise BLE (too slow for large media payloads).
855        let prefers_internet = message
856            .metadata
857            .get("transport_preference")
858            .map(|v| v == "internet")
859            .unwrap_or(false);
860
861        // Weighted combination based on DORS specification.
862        //
863        // Score ranges (each sub-score is 0–100):
864        //   BLE / WiFi Direct : 0–100 (pure weighted sum, weights sum to 1.0)
865        //   Internet           : baseline + 0–100
866        //     baseline = 10 (default) or 25 (prefer_online)
867        //     → max ≈ 110 or 125
868        //
869        // The Internet baseline intentionally exceeds the 0–100 range of
870        // offline transports so that the gap comfortably exceeds the default
871        // switch hysteresis (15.0). Increasing hysteresis beyond ~20 may
872        // prevent DORS from switching *to* Internet even when prefer_online
873        // is set.
874        //
875        // When the message requests internet preference (media/file chunks),
876        // the profile's media_bonus is applied (Internet: +50) and
877        // media_penalty is subtracted (BLE: -40) to make Internet the
878        // overwhelming favourite while still allowing WiFi Direct as fallback.
879        let media_adjust = if prefers_internet {
880            profile.media_bonus - profile.media_penalty
881        } else {
882            0.0
883        };
884
885        let w = &profile.weights;
886        let mut total = (profile.base_score
887            + media_adjust
888            + (signal_score * w.signal)
889            + (proximity_score * w.proximity)
890            + (bandwidth_score * w.bandwidth)
891            + (congestion_score * w.congestion)
892            + (energy_score * w.energy)
893            + (reliability_score * w.reliability)
894            + (load_score * w.load))
895            .max(0.0);
896
897        // Internet-as-fallback. When the caller is not preferring online and the
898        // message is not internet-preferred (media/file chunks), demote Internet
899        // far below every mesh transport so DORS only escalates to it when a mesh
900        // send reports the peer unreachable (the fallback loop in
901        // TransportManager::send). This keeps a nearby peer on BLE/Wi-Fi-Direct —
902        // which an app-level relay bridge cannot reach when that peer is off the
903        // relay — while still leaving Internet as the sole candidate for a
904        // genuinely remote peer (no mesh transport available at all). The score
905        // goes negative here on purpose; selection only cares about relative
906        // order, and Internet must rank below mesh's 0–100 band. With
907        // `prefer_online == true` the historical internet-first behaviour is
908        // unchanged.
909        if transport_type == TransportType::Internet
910            && !self.config.prefer_online
911            && !prefers_internet
912        {
913            total -= INTERNET_FALLBACK_DEMOTION;
914        }
915
916        TransportScore {
917            signal: signal_score,
918            proximity: proximity_score,
919            bandwidth: bandwidth_score,
920            congestion: congestion_score,
921            energy: energy_score,
922            reliability: reliability_score,
923            load: load_score,
924            total,
925        }
926    }
927
928    /// Determines whether the selector should switch from `current_transport` to `candidate_transport`.
929    fn should_switch(
930        &self,
931        current_transport: TransportType,
932        current_score: f32,
933        candidate_transport: TransportType,
934        candidate_score: f32,
935    ) -> bool {
936        if candidate_transport == current_transport {
937            return false;
938        }
939
940        // Emergency bypass: if current transport has critical degradation, switch
941        // immediately to any reasonably scored candidate. `candidate_score` is the
942        // argmax score, so whenever Internet is fallback-demoted it is a
943        // non-negative mesh score (the negative sentinel can only be the argmax
944        // when Internet is the sole transport, and then `should_switch` is not
945        // reached), so the `> 10.0` floor never sees the sentinel.
946        //
947        // IMPORTANT behavioral note for prefer_online=false: when the ONLY
948        // alternative to a critically degraded mesh link is the demoted Internet,
949        // `best_transport` is still that mesh link (demoted Internet ranks below
950        // it), so `select_transport` returns early on `current == best_transport`
951        // BEFORE reaching this emergency check. Proactive mesh→Internet escalation
952        // is therefore intentionally delegated to the `send()` fallback loop (which
953        // escalates only when a mesh send returns a hard error), NOT driven from
954        // here. Consequence: a mesh link that is degraded yet still returns Ok from
955        // send() is not proactively abandoned for Internet — by design, to keep the
956        // offline-first preference. Do not read this bypass as a general mesh→
957        // Internet safety net under the default config.
958        if self.is_emergency_switch_needed(current_transport) {
959            return candidate_score > 10.0; // Any reasonably scored candidate is acceptable
960        }
961
962        if candidate_score <= current_score {
963            return false;
964        }
965
966        let improvement = candidate_score - current_score;
967        if improvement < self.config.switch_hysteresis {
968            return false;
969        }
970
971        if !self.is_past_cooldown() {
972            return false;
973        }
974
975        self.is_stable_better(
976            candidate_transport,
977            current_transport,
978            self.config.stability_window_secs,
979            self.config.switch_hysteresis / 2.0,
980        )
981    }
982
983    /// Checks if the current transport has critical degradation requiring emergency switch.
984    /// This bypasses normal hysteresis and cooldown for:
985    /// - Transport unavailable (status != Available)
986    /// - Success rate below 30%
987    /// - Consecutive failures exceeding threshold
988    fn is_emergency_switch_needed(&self, transport: TransportType) -> bool {
989        // Check retry failure count
990        let retry_failures = self.retry_counts.get(&transport).copied().unwrap_or(0);
991        if retry_failures >= self.config.ble_to_wifi_retry_threshold {
992            return true;
993        }
994
995        // Check historical success rate (quality degradation), gated on observation volume
996        let min_rate = self
997            .config
998            .min_success_rate_before_escalation
999            .clamp(0.0, 1.0);
1000        let min_samples = self.config.min_ble_samples_before_success_rate_escalation;
1001        if let Some(history) = self.transport_history.get(&transport) {
1002            if history.success_ratio_sample_count() >= min_samples {
1003                if let Some(success_rate) = history.average_success_ratio() {
1004                    if success_rate < min_rate {
1005                        return true;
1006                    }
1007                }
1008            }
1009        }
1010
1011        // Check for prolonged poor signal (BLE only — ble_poor_signal_since
1012        // is only tracked for BLE in update_ble_conditions).
1013        if transport == TransportType::BLE {
1014            if let Some((metrics, _)) = self.last_metrics.get(&transport) {
1015                if let Some(rssi) = metrics.rssi {
1016                    if rssi < -90 {
1017                        let poor_duration = self
1018                            .ble_poor_signal_since
1019                            .map(|since| Utc::now().signed_duration_since(since).num_seconds());
1020                        if poor_duration.unwrap_or(0)
1021                            >= self.config.poor_signal_duration_secs as i64 * 2
1022                        {
1023                            return true;
1024                        }
1025                    }
1026                }
1027            }
1028        }
1029
1030        false
1031    }
1032
1033    /// Calculates signal strength score from RSSI.
1034    fn calculate_signal_score(
1035        &self,
1036        profile: &TransportScoringProfile,
1037        transport_type: TransportType,
1038        metrics: &TransportMetrics,
1039    ) -> f32 {
1040        if !profile.has_signal {
1041            return profile.default_signal_score;
1042        }
1043
1044        let rssi_value = metrics.rssi.map(|r| r as f32).or_else(|| {
1045            self.transport_history
1046                .get(&transport_type)
1047                .and_then(|history| history.average_rssi())
1048        });
1049
1050        let rssi_value = match rssi_value {
1051            Some(value) => value,
1052            None => return SIGNAL_SCORE_NO_DATA,
1053        };
1054
1055        if rssi_value >= -50.0 {
1056            100.0
1057        } else if rssi_value >= -70.0 {
1058            70.0 + ((rssi_value + 70.0) * 30.0 / 20.0)
1059        } else if rssi_value >= -85.0 {
1060            40.0 + ((rssi_value + 85.0) * 30.0 / 15.0)
1061        } else {
1062            ((rssi_value + 100.0).max(0.0) * 40.0 / 15.0).max(0.0)
1063        }
1064    }
1065
1066    /// Calculates proximity score from hop count.
1067    fn calculate_proximity_score(&self, message: &Message) -> f32 {
1068        let hop_count = message.hop_count.value();
1069        let ttl = message.ttl.value();
1070
1071        // Lower hop count = higher score
1072        // If message just started (hop_count = 0), give high score
1073        if hop_count == 0 || ttl == 0 {
1074            100.0
1075        } else {
1076            // Score decreases as hop count increases
1077            let ratio = (ttl as f32 - hop_count as f32) / ttl as f32;
1078            (ratio * 100.0).max(0.0)
1079        }
1080    }
1081
1082    /// Calculates bandwidth score (0–100).
1083    ///
1084    /// When bandwidth is measured/estimated (`bandwidth_bps`), it is normalised
1085    /// against the profile's `bandwidth_max_bps`. When unknown, the profile's
1086    /// `bandwidth_default` is returned (which may be config-dependent, e.g.
1087    /// Internet's default is higher when `prefer_online` is set).
1088    fn calculate_bandwidth_score(
1089        &self,
1090        profile: &TransportScoringProfile,
1091        metrics: &TransportMetrics,
1092    ) -> f32 {
1093        if let Some(bandwidth) = metrics.bandwidth_bps {
1094            (bandwidth as f32 / profile.bandwidth_max_bps as f32 * 100.0).min(100.0)
1095        } else {
1096            profile.bandwidth_default
1097        }
1098    }
1099
1100    /// Calculates congestion score (higher = less congested).
1101    fn calculate_congestion_score(
1102        &self,
1103        transport_type: TransportType,
1104        metrics: &TransportMetrics,
1105    ) -> f32 {
1106        let avg_congestion = self
1107            .transport_history
1108            .get(&transport_type)
1109            .and_then(|history| history.average_congestion())
1110            .unwrap_or(metrics.congestion);
1111        let congestion_level = avg_congestion.clamp(0.0, 1.0);
1112
1113        // Invert: less congestion = higher score
1114        let base_score = (1.0 - congestion_level) * 100.0;
1115
1116        // Factor in queue depth
1117        let threshold = self.config.congestion_queue_threshold.max(1) as f32;
1118        let avg_queue = self
1119            .transport_history
1120            .get(&transport_type)
1121            .and_then(|history| history.average_queue_depth())
1122            .unwrap_or(metrics.queue_depth as f32);
1123        let queue_pressure = (avg_queue / threshold).clamp(0.0, 1.0);
1124        let queue_penalty = (queue_pressure.min(1.0) * 30.0).max(0.0);
1125
1126        (base_score - queue_penalty).max(0.0)
1127    }
1128
1129    /// Calculates energy efficiency score.
1130    fn calculate_energy_score(
1131        &self,
1132        profile: &TransportScoringProfile,
1133        metrics: &TransportMetrics,
1134    ) -> f32 {
1135        let mut base = profile.energy_baseline;
1136
1137        if let Some(cost) = metrics.energy_cost {
1138            // Penalise transports that advertise higher energy cost.
1139            let penalty = (cost * 100.0).clamp(0.0, 40.0);
1140            base = (base - penalty).max(0.0);
1141        }
1142
1143        if let Some(battery) = metrics.battery_level {
1144            let battery = battery.min(100);
1145            let battery_ratio = battery as f32 / 100.0;
1146            let low_threshold = self.config.low_battery_threshold.max(1);
1147
1148            if !metrics.is_charging {
1149                if battery <= low_threshold {
1150                    if profile.is_high_power {
1151                        // Strongly discourage high-power transports when battery is critically low.
1152                        let deficit = (low_threshold - battery) as f32 / low_threshold as f32;
1153                        base = (base * (1.0 - deficit)).max(0.0);
1154                    } else {
1155                        // Low-power transports become more attractive when the battery is low.
1156                        base = (base + (20.0 * (1.0 - battery_ratio))).clamp(0.0, 100.0);
1157                    }
1158                } else if profile.is_high_power {
1159                    // Slight penalty based on remaining battery.
1160                    let penalty = (1.0 - battery_ratio).max(0.0) * 15.0;
1161                    base = (base - penalty).max(0.0);
1162                }
1163            } else {
1164                // Being on charge makes energy-intensive transports more acceptable.
1165                base = (base + 10.0).min(100.0);
1166            }
1167        } else if metrics.is_charging {
1168            base = (base + 5.0).min(100.0);
1169        }
1170
1171        if metrics.is_active_relay && profile.active_relay_energy_adjustment != 0.0 {
1172            // Active relays pay an additional cost; bias towards low-energy transports.
1173            base = (base + profile.active_relay_energy_adjustment).clamp(0.0, 100.0);
1174        }
1175
1176        base.clamp(0.0, 100.0)
1177    }
1178
1179    /// Calculates reliability score based on historical success ratios.
1180    fn calculate_reliability_score(
1181        &self,
1182        transport_type: TransportType,
1183        metrics: &TransportMetrics,
1184    ) -> f32 {
1185        let ratio = metrics
1186            .effective_delivery_ratio()
1187            .or_else(|| {
1188                self.transport_history
1189                    .get(&transport_type)
1190                    .and_then(|history| history.average_success_ratio())
1191            })
1192            .unwrap_or(0.85)
1193            .clamp(0.0, 1.0);
1194
1195        (ratio * 100.0).clamp(0.0, 100.0)
1196    }
1197
1198    /// Calculates load score (higher means more capacity available).
1199    fn calculate_load_score(
1200        &self,
1201        transport_type: TransportType,
1202        metrics: &TransportMetrics,
1203    ) -> f32 {
1204        let threshold = self.config.congestion_queue_threshold.max(1) as f32;
1205        let avg_queue = self
1206            .transport_history
1207            .get(&transport_type)
1208            .and_then(|history| history.average_queue_depth())
1209            .unwrap_or(metrics.queue_depth as f32);
1210
1211        let utilisation = (avg_queue / threshold).clamp(0.0, 1.5);
1212        let mut score = ((1.0 - utilisation).clamp(0.0, 1.0)) * 100.0;
1213
1214        if let Some(drop) = metrics.effective_drop_ratio() {
1215            let drop_penalty = (drop * 100.0).clamp(0.0, 40.0);
1216            score = (score - drop_penalty).max(0.0);
1217        }
1218
1219        if metrics.is_active_relay {
1220            let optimal = self.config.relay_optimal_connection_count.max(1) as f32;
1221            let connections = metrics.relay_connection_count as f32;
1222            if connections > optimal {
1223                let overload = ((connections - optimal) / optimal).clamp(0.0, 1.5);
1224                score = (score * (1.0 - (0.4 * overload))).max(0.0);
1225            } else {
1226                score = (score + 5.0).min(100.0);
1227            }
1228        }
1229
1230        score.clamp(0.0, 100.0)
1231    }
1232
1233    /// Records the latest metrics for the transport.
1234    fn record_metrics(&mut self, transport_type: TransportType, metrics: &TransportMetrics) {
1235        self.last_metrics
1236            .insert(transport_type, (metrics.clone(), Utc::now()));
1237    }
1238
1239    fn history_window(&self) -> usize {
1240        self.config.history_window_size.max(1)
1241    }
1242
1243    fn update_history(&mut self, transport_type: TransportType, metrics: &TransportMetrics) {
1244        let window = self.history_window();
1245        let history = self.transport_history.entry(transport_type).or_default();
1246
1247        history.push_queue_depth(metrics.queue_depth, window);
1248        history.push_congestion(metrics.congestion.clamp(0.0, 1.0), window);
1249
1250        if let Some(rssi) = metrics.rssi {
1251            history.push_rssi(rssi, window);
1252        }
1253
1254        if let Some(latency) = metrics.latency_ms {
1255            history.push_latency(latency, window);
1256        }
1257
1258        let total = metrics.success_count + metrics.failure_count;
1259        if total > 0 {
1260            let success_ratio = metrics.success_count as f32 / total as f32;
1261            history.push_success_ratio(success_ratio, window);
1262        }
1263    }
1264
1265    /// Updates BLE-specific escalation signals.
1266    fn update_ble_conditions(&mut self, message: &Message, metrics: &TransportMetrics) {
1267        let now = Utc::now();
1268
1269        if let Some(rssi) = metrics.rssi {
1270            if rssi <= self.config.rssi_switch_threshold {
1271                if self.ble_poor_signal_since.is_none() {
1272                    self.ble_poor_signal_since = Some(now);
1273                }
1274            } else {
1275                self.ble_poor_signal_since = None;
1276            }
1277        }
1278
1279        let congestion_threshold = self.config.congestion_queue_threshold;
1280        let average_queue = self
1281            .transport_history
1282            .get(&TransportType::BLE)
1283            .and_then(|history| history.average_queue_depth());
1284
1285        let queue_high = if congestion_threshold == 0 {
1286            metrics.queue_depth > 0
1287        } else {
1288            metrics.queue_depth >= congestion_threshold
1289                || average_queue
1290                    .map(|avg| avg >= congestion_threshold as f32)
1291                    .unwrap_or(false)
1292        };
1293
1294        if queue_high {
1295            if self.ble_high_congestion_since.is_none() {
1296                self.ble_high_congestion_since = Some(now);
1297            }
1298        } else {
1299            let recovery_ratio = self.config.queue_recovery_ratio.clamp(0.0, 1.0);
1300            let recovery_threshold = if congestion_threshold == 0 {
1301                0
1302            } else {
1303                (congestion_threshold as f32 * recovery_ratio)
1304                    .ceil()
1305                    .max(1.0) as usize
1306            };
1307
1308            let recovered = if congestion_threshold == 0 {
1309                metrics.queue_depth == 0
1310            } else {
1311                metrics.queue_depth <= recovery_threshold
1312                    && average_queue
1313                        .map(|avg| avg <= recovery_threshold as f32)
1314                        .unwrap_or(true)
1315            };
1316
1317            if recovered {
1318                self.ble_high_congestion_since = None;
1319            }
1320        }
1321
1322        if message.ttl.value() <= self.config.ttl_escalation_threshold {
1323            self.low_ttl_detected_at = Some(now);
1324        } else if message.ttl.value() > self.config.ttl_escalation_threshold.saturating_add(1) {
1325            // Reset once we are comfortably above the threshold.
1326            self.low_ttl_detected_at = None;
1327        }
1328    }
1329
1330    /// Checks if the cooldown period has passed since last switch.
1331    fn is_past_cooldown(&self) -> bool {
1332        if let Some(last_switch) = self.last_switch_time {
1333            let elapsed = Utc::now().signed_duration_since(last_switch);
1334            elapsed.num_seconds() >= self.config.switch_cooldown_secs as i64
1335        } else {
1336            true // No previous switch, cooldown doesn't apply
1337        }
1338    }
1339
1340    /// Checks if a transport has been consistently better for the stability window.
1341    fn is_stable_better(
1342        &self,
1343        new_transport: TransportType,
1344        current_transport: TransportType,
1345        window_secs: u64,
1346        required_improvement: f32,
1347    ) -> bool {
1348        let now = Utc::now();
1349        let window_start = now - chrono::Duration::seconds(window_secs as i64);
1350
1351        // Get scores for both transports within the window
1352        let new_scores = self.get_scores_in_window(new_transport, window_start);
1353        let current_scores = self.get_scores_in_window(current_transport, window_start);
1354
1355        // If we don't have enough history, allow the switch
1356        if new_scores.is_empty() || current_scores.is_empty() {
1357            return true;
1358        }
1359
1360        // Calculate average scores
1361        let new_avg: f32 = new_scores.iter().sum::<f32>() / new_scores.len() as f32;
1362        let current_avg: f32 = current_scores.iter().sum::<f32>() / current_scores.len() as f32;
1363
1364        // New transport must be consistently better
1365        (new_avg - current_avg) >= required_improvement
1366    }
1367
1368    /// Gets transport scores within a time window.
1369    fn get_scores_in_window(
1370        &self,
1371        transport_type: TransportType,
1372        window_start: DateTime<Utc>,
1373    ) -> Vec<f32> {
1374        self.transport_scores_history
1375            .get(&transport_type)
1376            .map(|history| {
1377                history
1378                    .iter()
1379                    .filter(|(time, _)| *time >= window_start)
1380                    .map(|(_, score)| *score)
1381                    .collect()
1382            })
1383            .unwrap_or_default()
1384    }
1385
1386    /// Records a transport score for history tracking.
1387    fn record_score(&mut self, transport_type: TransportType, score: f32) {
1388        let now = Utc::now();
1389        let limit = self.history_window().max(10);
1390        let history = self
1391            .transport_scores_history
1392            .entry(transport_type)
1393            .or_default();
1394
1395        history.push_back((now, score));
1396
1397        while history.len() > limit {
1398            history.pop_front();
1399        }
1400    }
1401
1402    /// Records a retry failure for a transport.
1403    /// Uses saturating addition to prevent overflow.
1404    pub fn record_retry_failure(&mut self, transport_type: TransportType) {
1405        let count = self.retry_counts.entry(transport_type).or_insert(0);
1406        *count = count.saturating_add(1);
1407    }
1408
1409    /// Resets retry count for a transport (e.g., after successful delivery).
1410    pub fn reset_retry_count(&mut self, transport_type: TransportType) {
1411        self.retry_counts.insert(transport_type, 0);
1412    }
1413
1414    /// Evaluates all BLE→WiFi escalation conditions in a single pass.
1415    ///
1416    /// Returns the first triggered reason (priority order: retry > signal >
1417    /// congestion > TTL > success-rate) plus whether the battery is too low
1418    /// to permit escalation. All public escalation methods delegate here.
1419    ///
1420    /// **Degradation-based escalation rules** (all config-driven, deterministic):
1421    /// - **Retry threshold**: BLE send failures ≥ `ble_to_wifi_retry_threshold`
1422    /// - **Poor signal**: BLE RSSI ≤ `rssi_switch_threshold` for ≥ `poor_signal_duration_secs`
1423    /// - **Congestion**: BLE queue depth ≥ `congestion_queue_threshold` for ≥ `congestion_duration_secs`
1424    /// - **Low TTL**: Message TTL ≤ `ttl_escalation_threshold` (held for `ttl_escalation_hold_secs`)
1425    /// - **Low success rate**: BLE success-ratio samples ≥ `min_ble_samples_before_success_rate_escalation`
1426    ///   AND average success ratio < `min_success_rate_before_escalation`
1427    fn evaluate_escalation_conditions(&self) -> EscalationEvaluation {
1428        let now = Utc::now();
1429
1430        let retry_failure = self
1431            .retry_counts
1432            .get(&TransportType::BLE)
1433            .map(|count| *count >= self.config.ble_to_wifi_retry_threshold)
1434            .unwrap_or(false);
1435
1436        let poor_signal = self
1437            .ble_poor_signal_since
1438            .map(|since| {
1439                now.signed_duration_since(since)
1440                    >= Duration::seconds(self.config.poor_signal_duration_secs as i64)
1441            })
1442            .unwrap_or(false);
1443
1444        let high_congestion_active = self
1445            .ble_high_congestion_since
1446            .map(|since| {
1447                now.signed_duration_since(since)
1448                    >= Duration::seconds(self.config.congestion_duration_secs as i64)
1449            })
1450            .unwrap_or(false);
1451
1452        let load_exceeded = self
1453            .transport_history
1454            .get(&TransportType::BLE)
1455            .and_then(|history| history.average_queue_depth())
1456            .map(|avg| {
1457                let threshold = self.config.congestion_queue_threshold.max(1) as f32;
1458                avg >= threshold
1459            })
1460            .unwrap_or(false);
1461
1462        let high_congestion = high_congestion_active && load_exceeded;
1463
1464        let low_ttl_recent = self
1465            .low_ttl_detected_at
1466            .map(|since| {
1467                now.signed_duration_since(since)
1468                    <= Duration::seconds(self.config.ttl_escalation_hold_secs as i64)
1469            })
1470            .unwrap_or(false);
1471
1472        let min_success_rate = self
1473            .config
1474            .min_success_rate_before_escalation
1475            .clamp(0.0, 1.0);
1476        let min_samples = self.config.min_ble_samples_before_success_rate_escalation;
1477        let low_success_rate = self
1478            .transport_history
1479            .get(&TransportType::BLE)
1480            .filter(|h| h.success_ratio_sample_count() >= min_samples)
1481            .and_then(|h| h.average_success_ratio())
1482            .map(|rate| rate < min_success_rate)
1483            .unwrap_or(false);
1484
1485        let battery_too_low = self
1486            .last_metrics
1487            .get(&TransportType::BLE)
1488            .and_then(|(metrics, _)| {
1489                metrics
1490                    .battery_level
1491                    .map(|level| (level, metrics.is_charging))
1492            })
1493            .map(|(level, charging)| level < self.config.relay_min_battery_level && !charging)
1494            .unwrap_or(false);
1495
1496        let trigger = if retry_failure {
1497            Some(EscalationTriggerReason::RetryThreshold)
1498        } else if poor_signal {
1499            Some(EscalationTriggerReason::PoorSignal)
1500        } else if high_congestion {
1501            Some(EscalationTriggerReason::Congestion)
1502        } else if low_ttl_recent {
1503            Some(EscalationTriggerReason::LowTtl)
1504        } else if low_success_rate {
1505            Some(EscalationTriggerReason::LowSuccessRate)
1506        } else {
1507            None
1508        };
1509
1510        EscalationEvaluation {
1511            trigger,
1512            battery_too_low,
1513        }
1514    }
1515
1516    /// Returns which condition triggered escalation, if any.
1517    ///
1518    /// Returns `None` when battery is too low (escalation suppressed) or
1519    /// when no escalation condition is active.
1520    pub fn escalation_trigger_reason(&self) -> Option<EscalationTriggerReason> {
1521        let eval = self.evaluate_escalation_conditions();
1522        if eval.battery_too_low {
1523            return None;
1524        }
1525        eval.trigger
1526    }
1527
1528    /// Checks if escalation from BLE to Wi-Fi Direct is needed.
1529    pub fn should_escalate_to_wifi(&self) -> bool {
1530        let eval = self.evaluate_escalation_conditions();
1531        if eval.battery_too_low {
1532            return false;
1533        }
1534        eval.trigger.is_some()
1535    }
1536
1537    /// Checks if WiFi escalation is appropriate for a message with given priority.
1538    ///
1539    /// For Critical priority messages, battery constraints are bypassed since
1540    /// message delivery is more important than battery preservation.
1541    pub fn should_escalate_to_wifi_for_priority(
1542        &self,
1543        priority: offline_protocol_core::MessagePriority,
1544    ) -> bool {
1545        use offline_protocol_core::MessagePriority;
1546
1547        let eval = self.evaluate_escalation_conditions();
1548
1549        match priority {
1550            MessagePriority::Critical => eval.trigger.is_some(),
1551            MessagePriority::High => !eval.battery_too_low && eval.trigger.is_some(),
1552            MessagePriority::Medium | MessagePriority::Low => {
1553                if eval.battery_too_low {
1554                    return false;
1555                }
1556                let severe_retry = self
1557                    .retry_counts
1558                    .get(&TransportType::BLE)
1559                    .map(|count| *count >= self.config.ble_to_wifi_retry_threshold * 2)
1560                    .unwrap_or(false);
1561                severe_retry && eval.trigger.is_some()
1562            }
1563        }
1564    }
1565
1566    /// Replaces the configuration while preserving accumulated state (history,
1567    /// retry counts, signal tracking, score history).
1568    pub fn update_config(&mut self, config: DorsConfig) {
1569        self.config = config;
1570    }
1571
1572    /// Returns the configuration scoring actually runs against.
1573    ///
1574    /// This selector — not `ProtocolConfig::dors`, which is read once at
1575    /// construction — is the live consumer, so this is the honest place to
1576    /// confirm a runtime update landed.
1577    pub fn config(&self) -> &DorsConfig {
1578        &self.config
1579    }
1580
1581    /// Gets the current transport.
1582    pub fn current_transport(&self) -> Option<TransportType> {
1583        self.current_transport
1584    }
1585
1586    /// Updates the current transport tracked by the selector.
1587    ///
1588    /// Used by `TransportManager` when a fallback transport succeeds so that
1589    /// subsequent `select_transport` calls apply hysteresis against the
1590    /// transport that actually carried the last message. Also resets the
1591    /// switch cooldown timer so the next selection uses a full cooldown
1592    /// window measured from this fallback, not from the original primary
1593    /// selection.
1594    ///
1595    /// The caller is responsible for ensuring `transport` is currently
1596    /// available. Setting a transport that has since become unavailable is
1597    /// safe — `select_transport` handles the `!current_still_available`
1598    /// case — but may cause one wasted hysteresis evaluation.
1599    pub fn set_current_transport(&mut self, transport: TransportType) {
1600        debug!(
1601            previous = ?self.current_transport,
1602            new = ?transport,
1603            "DORS: current transport overridden by fallback"
1604        );
1605        self.current_transport = Some(transport);
1606        self.last_switch_time = Some(Utc::now());
1607    }
1608}
1609
1610impl Default for TransportSelector {
1611    fn default() -> Self {
1612        Self::new()
1613    }
1614}
1615
1616#[cfg(test)]
1617impl TransportSelector {
1618    /// Returns the transport that would win with these scores using the same tie-break as `select_transport`.
1619    /// Use to verify tie-break order (Internet > WiFiDirect > BLE) when scores are forced equal.
1620    pub fn select_from_scores_for_test(
1621        &self,
1622        scores: &[(TransportType, f32)],
1623    ) -> Option<TransportType> {
1624        if scores.is_empty() {
1625            return None;
1626        }
1627
1628        // Find best finite score (ignoring NaNs). If none, fall back to tie-break only.
1629        if let Some(best) = scores
1630            .iter()
1631            .map(|(_, s)| *s)
1632            .filter(|s| s.is_finite())
1633            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1634        {
1635            // Apply same tie semantics as select_transport: consider any score within
1636            // TIE_EPSILON of the best to be a tie, then apply tie-break priority.
1637            scores
1638                .iter()
1639                .filter(|(_, s)| s.is_finite() && best - *s <= TIE_EPSILON)
1640                .min_by_key(|(t, _)| tie_break_priority(*t))
1641                .map(|(t, _)| *t)
1642        } else {
1643            // No finite scores; choose purely by tie-break priority.
1644            scores
1645                .iter()
1646                .min_by_key(|(t, _)| tie_break_priority(*t))
1647                .map(|(t, _)| *t)
1648        }
1649    }
1650
1651    /// Sets last switch time for testing cooldown. With cooldown_secs > 0, setting this to
1652    /// Utc::now() makes the next select_transport consider us still in cooldown.
1653    pub fn set_last_switch_time_for_test(&mut self, t: Option<DateTime<Utc>>) {
1654        self.last_switch_time = t;
1655    }
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660    use super::*;
1661    use offline_protocol_core::{AppId, UserId, TTL};
1662
1663    fn create_test_message() -> Message {
1664        Message::new(
1665            UserId::new("alice").unwrap(),
1666            UserId::new("bob").unwrap(),
1667            AppId::new("test").unwrap(),
1668            "Test message",
1669        )
1670    }
1671
1672    /// Builds a message with the given hop_count and ttl for proximity score tests.
1673    fn create_test_message_with_hops(hop_count: u8, ttl: u8) -> Message {
1674        let ttl_val = TTL::new(ttl).unwrap();
1675        let mut msg = Message::builder(
1676            UserId::new("alice").unwrap(),
1677            UserId::new("bob").unwrap(),
1678            AppId::new("test").unwrap(),
1679        )
1680        .ttl(ttl_val)
1681        .build();
1682        for _ in 0..hop_count {
1683            msg.increment_hop().unwrap();
1684        }
1685        msg
1686    }
1687
1688    fn create_test_metrics(
1689        rssi: Option<i16>,
1690        congestion: f32,
1691        queue_depth: usize,
1692    ) -> TransportMetrics {
1693        TransportMetrics {
1694            rssi,
1695            latency_ms: Some(10),
1696            bandwidth_bps: Some(150_000),
1697            congestion,
1698            queue_depth,
1699            success_count: 10,
1700            failure_count: 0,
1701            battery_level: Some(80),
1702            is_charging: false,
1703            relay_connection_count: 3,
1704            is_active_relay: true,
1705            delivery_ratio: Some(0.9),
1706            drop_rate: Some(0.1),
1707            average_hop_count: Some(2.0),
1708            energy_cost: Some(0.2),
1709        }
1710    }
1711
1712    #[test]
1713    fn test_transport_selection_prefers_best_score() {
1714        let mut selector = TransportSelector::new();
1715        let message = create_test_message();
1716
1717        let mut transports = HashMap::new();
1718        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1719        transports.insert(
1720            TransportType::WiFiDirect,
1721            create_test_metrics(Some(-55), 0.1, 5),
1722        );
1723
1724        let selected = selector.select_transport(&message, &transports);
1725        assert!(selected.is_some());
1726    }
1727
1728    /// Given prefer_online and Internet available with good metrics, selection must be Internet.
1729    #[test]
1730    fn test_selection_selects_internet_when_prefer_online_and_good_metrics() {
1731        let config = DorsConfig {
1732            prefer_online: true,
1733            ..Default::default()
1734        };
1735        let mut selector = TransportSelector::with_config(config);
1736        let message = create_test_message();
1737
1738        let mut transports = HashMap::new();
1739        let mut internet_metrics = create_test_metrics(None, 0.0, 0);
1740        internet_metrics.bandwidth_bps = None; // use default (70 when prefer_online), not BLE-style 150k
1741        transports.insert(TransportType::Internet, internet_metrics);
1742        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1743        transports.insert(
1744            TransportType::WiFiDirect,
1745            create_test_metrics(Some(-55), 0.1, 5),
1746        );
1747
1748        let selected = selector.select_transport(&message, &transports).unwrap();
1749        assert_eq!(
1750            selected,
1751            TransportType::Internet,
1752            "With prefer_online=true and Internet available, selection must be Internet"
1753        );
1754    }
1755
1756    /// Given BLE with good signal and low congestion, and WiFiDirect with poor signal and high congestion, selection must be BLE.
1757    #[test]
1758    fn test_selection_selects_ble_when_good_signal_and_wifi_worse() {
1759        let mut selector = TransportSelector::new();
1760        let message = create_test_message();
1761
1762        let mut transports = HashMap::new();
1763        transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.1, 5));
1764        let mut wifi_metrics = create_test_metrics(Some(-88), 0.9, 60);
1765        wifi_metrics.congestion = 0.9;
1766        wifi_metrics.queue_depth = 60;
1767        transports.insert(TransportType::WiFiDirect, wifi_metrics);
1768
1769        let selected = selector.select_transport(&message, &transports).unwrap();
1770        assert_eq!(
1771            selected,
1772            TransportType::BLE,
1773            "With BLE good (rssi=-55, low congestion) and WiFi poor (rssi=-88, high congestion), selection must be BLE"
1774        );
1775    }
1776
1777    /// Given WiFiDirect with good signal and low congestion, and BLE with poor signal and high congestion, selection must be WiFiDirect.
1778    #[test]
1779    fn test_selection_selects_wifi_direct_when_good_metrics_and_ble_poor() {
1780        let mut selector = TransportSelector::new();
1781        let message = create_test_message();
1782
1783        let mut transports = HashMap::new();
1784        let mut ble_metrics = create_test_metrics(Some(-90), 0.85, 50);
1785        ble_metrics.congestion = 0.85;
1786        ble_metrics.queue_depth = 50;
1787        transports.insert(TransportType::BLE, ble_metrics);
1788        transports.insert(
1789            TransportType::WiFiDirect,
1790            create_test_metrics(Some(-50), 0.1, 5),
1791        );
1792
1793        let selected = selector.select_transport(&message, &transports).unwrap();
1794        assert_eq!(
1795            selected,
1796            TransportType::WiFiDirect,
1797            "With WiFiDirect good (rssi=-50, low congestion) and BLE poor (rssi=-90, high congestion), selection must be WiFiDirect"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_online_first_mode() {
1803        let config = DorsConfig {
1804            prefer_online: true,
1805            ..Default::default()
1806        };
1807        let mut selector = TransportSelector::with_config(config);
1808        let message = create_test_message();
1809
1810        let mut transports = HashMap::new();
1811        let mut internet_metrics = create_test_metrics(None, 0.0, 0);
1812        internet_metrics.bandwidth_bps = None;
1813        transports.insert(TransportType::Internet, internet_metrics);
1814        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1815
1816        let selected = selector.select_transport(&message, &transports).unwrap();
1817        assert_eq!(selected, TransportType::Internet);
1818    }
1819
1820    /// Internet is a *fallback* transport when prefer_online is false: a mesh
1821    /// transport wins when available, Internet is only chosen when no mesh
1822    /// transport is available (a genuinely remote peer), and an internet-
1823    /// preferred (media) message keeps Internet on top.
1824    #[test]
1825    fn test_internet_is_fallback_when_not_prefer_online() {
1826        // Default config => prefer_online = false.
1827        let message = create_test_message();
1828
1829        let mut both = HashMap::new();
1830        both.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
1831        both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 5));
1832
1833        // score_and_rank (drives the fallback order): BLE first, Internet last
1834        // but still present as a fallback candidate.
1835        let mut selector = TransportSelector::new();
1836        let ranked = selector.score_and_rank(&message, &both);
1837        assert_eq!(
1838            ranked[0].0,
1839            TransportType::BLE,
1840            "mesh must outrank Internet when prefer_online = false (ranked: {ranked:?})",
1841        );
1842        assert!(
1843            ranked.iter().any(|(t, _)| *t == TransportType::Internet),
1844            "Internet must remain a candidate for fallback, just ranked last",
1845        );
1846        assert_eq!(
1847            selector.select_transport(&message, &both),
1848            Some(TransportType::BLE),
1849            "primary selection must be mesh, not Internet",
1850        );
1851
1852        // Only Internet available (remote peer): Internet must still be selected.
1853        let mut internet_only = HashMap::new();
1854        internet_only.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
1855        let mut remote_selector = TransportSelector::new();
1856        assert_eq!(
1857            remote_selector.select_transport(&message, &internet_only),
1858            Some(TransportType::Internet),
1859            "Internet must be selected when it is the only available transport",
1860        );
1861
1862        // Media message (transport_preference = internet): Internet not demoted.
1863        let mut media = create_test_message();
1864        media
1865            .metadata
1866            .insert("transport_preference".to_string(), "internet".to_string());
1867        let mut media_selector = TransportSelector::new();
1868        assert_eq!(
1869            media_selector.select_transport(&media, &both),
1870            Some(TransportType::Internet),
1871            "an internet-preferred (media) message must keep Internet on top",
1872        );
1873    }
1874
1875    /// `display_routing_score` must recover the real 0–125 quality score from the
1876    /// negative fallback-demotion sentinel (so telemetry never leaks ≈ -9900),
1877    /// be the identity for normal mesh scores, and be idempotent.
1878    #[test]
1879    fn test_display_routing_score_reverses_fallback_demotion() {
1880        // A demoted Internet total (real score 42 minus the demotion) recovers to 42.
1881        let demoted = 42.0 - INTERNET_FALLBACK_DEMOTION;
1882        assert!(
1883            demoted < 0.0,
1884            "precondition: demotion makes the total negative"
1885        );
1886        assert!((display_routing_score(demoted) - 42.0).abs() < 1e-3);
1887
1888        // Normal mesh scores pass through untouched.
1889        assert_eq!(display_routing_score(0.0), 0.0);
1890        assert_eq!(display_routing_score(73.5), 73.5);
1891        assert_eq!(display_routing_score(125.0), 125.0);
1892
1893        // Idempotent: applying it to an already-sanitized value is a no-op.
1894        let once = display_routing_score(demoted);
1895        assert_eq!(display_routing_score(once), once);
1896
1897        // And the live demotion path: an Internet-only selection still scores
1898        // negative INTERNALLY, but display_routing_score sanitizes it to >= 0.
1899        let selector = TransportSelector::new();
1900        let message = create_test_message();
1901        let metrics = create_test_metrics(None, 0.0, 0);
1902        let raw = selector
1903            .calculate_transport_score(&message, TransportType::Internet, &metrics)
1904            .total;
1905        assert!(
1906            raw < 0.0,
1907            "Internet is demoted negative under default config"
1908        );
1909        assert!(display_routing_score(raw) >= 0.0);
1910    }
1911
1912    #[test]
1913    fn test_hysteresis_prevents_switching() {
1914        let mut selector = TransportSelector::new();
1915        let message = create_test_message();
1916
1917        let mut transports = HashMap::new();
1918        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1919        transports.insert(
1920            TransportType::WiFiDirect,
1921            create_test_metrics(Some(-55), 0.1, 5),
1922        );
1923
1924        // First selection
1925        let first = selector.select_transport(&message, &transports).unwrap();
1926
1927        // Slightly modify metrics (not enough to overcome hysteresis)
1928        transports.insert(TransportType::BLE, create_test_metrics(Some(-61), 0.2, 10));
1929
1930        // Should stay with same transport due to hysteresis
1931        let second = selector.select_transport(&message, &transports).unwrap();
1932        assert_eq!(first, second);
1933    }
1934
1935    /// Anti-flap: switch only when score delta beats hysteresis threshold.
1936    /// When improvement is below threshold, must stay on current transport.
1937    #[test]
1938    fn test_anti_flap_hysteresis_score_delta_below_threshold() {
1939        let config = DorsConfig {
1940            switch_hysteresis: 20.0,
1941            switch_cooldown_secs: 0,
1942            stability_window_secs: 0,
1943            ..Default::default()
1944        };
1945        let mut selector = TransportSelector::with_config(config);
1946        let message = create_test_message();
1947
1948        let mut transports = HashMap::new();
1949        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1950        transports.insert(
1951            TransportType::WiFiDirect,
1952            create_test_metrics(Some(-55), 0.1, 5),
1953        );
1954
1955        let first = selector.select_transport(&message, &transports).unwrap();
1956        // Slightly favor WiFi (e.g. better RSSI) but not by 20 points — delta below hysteresis.
1957        transports.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.25, 12));
1958        transports.insert(
1959            TransportType::WiFiDirect,
1960            create_test_metrics(Some(-58), 0.12, 6),
1961        );
1962
1963        let second = selector.select_transport(&message, &transports).unwrap();
1964        assert_eq!(
1965            first, second,
1966            "switch must be blocked when score delta is below hysteresis threshold"
1967        );
1968    }
1969
1970    /// Anti-flap: cooldown blocks immediate re-switch after a transport change.
1971    #[test]
1972    fn test_anti_flap_cooldown_blocks_immediate_reswitch() {
1973        let config = DorsConfig {
1974            switch_hysteresis: 5.0,
1975            switch_cooldown_secs: 1,
1976            stability_window_secs: 0,
1977            ..Default::default()
1978        };
1979        let mut selector = TransportSelector::with_config(config);
1980        let message = create_test_message();
1981
1982        let mut transports = HashMap::new();
1983        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1984        transports.insert(
1985            TransportType::WiFiDirect,
1986            create_test_metrics(Some(-55), 0.1, 5),
1987        );
1988
1989        let first = selector.select_transport(&message, &transports).unwrap();
1990        // Simulate that we just switched (cooldown starts now)
1991        selector.set_last_switch_time_for_test(Some(Utc::now()));
1992        // Make WiFi clearly better so hysteresis would allow switch
1993        transports.insert(TransportType::BLE, create_test_metrics(Some(-88), 0.5, 40));
1994        transports.insert(
1995            TransportType::WiFiDirect,
1996            create_test_metrics(Some(-50), 0.05, 3),
1997        );
1998
1999        let second = selector.select_transport(&message, &transports).unwrap();
2000        assert_eq!(
2001            first, second,
2002            "cooldown must block immediate re-switch; should stay on {:?}",
2003            first
2004        );
2005    }
2006
2007    /// Regression (Android↔iOS off-relay stall): the demoted Internet fallback
2008    /// must never be retained over an available mesh transport, even while the
2009    /// switch cooldown is active. The cold-start path pins `current = Internet`
2010    /// (BLE had no peers, the send escalated to Internet via the fallback loop,
2011    /// which re-armed the cooldown); the instant a BLE peer is discovered the next
2012    /// selection must switch to BLE rather than riding the relay through the whole
2013    /// cooldown — which silently bypasses a nearby peer that is off the relay.
2014    /// Pre-fix this returned Internet for `switch_cooldown_secs`.
2015    #[test]
2016    fn test_demoted_internet_fallback_yields_to_mesh_despite_cooldown() {
2017        // Default config => prefer_online = false (Internet demoted) with a
2018        // non-zero switch cooldown, so absent the bypass the cooldown pins Internet.
2019        let message = create_test_message();
2020        let mut selector = TransportSelector::new();
2021        assert!(
2022            selector.config.switch_cooldown_secs > 0,
2023            "test depends on a non-zero default cooldown to prove the bypass",
2024        );
2025
2026        // Cold start: only Internet is up (BLE has no peers yet). Internet is
2027        // selected and becomes `current`, arming the cooldown timer.
2028        let mut internet_only = HashMap::new();
2029        internet_only.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2030        assert_eq!(
2031            selector.select_transport(&message, &internet_only),
2032            Some(TransportType::Internet),
2033            "cold start with only Internet available must select Internet",
2034        );
2035
2036        // A BLE peer is discovered a moment later; the cooldown is still active.
2037        let mut both = HashMap::new();
2038        both.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2039        both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 5));
2040        assert_eq!(
2041            selector.select_transport(&message, &both),
2042            Some(TransportType::BLE),
2043            "a freshly-available mesh transport must win immediately over the \
2044             demoted Internet fallback, bypassing the switch cooldown",
2045        );
2046    }
2047
2048    /// Anti-flap: stability window requires sustained superiority before switching.
2049    /// Candidate briefly better (no history of sustained lead) must not switch.
2050    #[test]
2051    fn test_anti_flap_stability_window_blocks_switch_without_sustained_superiority() {
2052        let config = DorsConfig {
2053            switch_hysteresis: 10.0,
2054            switch_cooldown_secs: 0,
2055            stability_window_secs: 8,
2056            history_window_size: 20,
2057            ..Default::default()
2058        };
2059        let mut selector = TransportSelector::with_config(config);
2060        let message = create_test_message();
2061
2062        let mut transports = HashMap::new();
2063        transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.1, 5));
2064        transports.insert(
2065            TransportType::WiFiDirect,
2066            create_test_metrics(Some(-75), 0.3, 25),
2067        );
2068
2069        // Build history: many selects with BLE winning (BLE consistently better)
2070        for _ in 0..15 {
2071            selector.select_transport(&message, &transports).unwrap();
2072        }
2073        assert_eq!(selector.current_transport(), Some(TransportType::BLE));
2074
2075        // One round where WiFi is much better (would beat hysteresis)
2076        transports.insert(TransportType::BLE, create_test_metrics(Some(-90), 0.6, 50));
2077        transports.insert(
2078            TransportType::WiFiDirect,
2079            create_test_metrics(Some(-52), 0.08, 4),
2080        );
2081
2082        let after = selector.select_transport(&message, &transports).unwrap();
2083        assert_eq!(
2084            after,
2085            TransportType::BLE,
2086            "stability window must block switch when candidate was not consistently better in window"
2087        );
2088    }
2089
2090    /// Anti-flap: noisy metric changes around threshold must not cause frequent switching.
2091    #[test]
2092    fn test_anti_flap_noisy_metrics_switch_suppression() {
2093        let mut selector = TransportSelector::new();
2094        let message = create_test_message();
2095
2096        let mut transports = HashMap::new();
2097        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2098        transports.insert(
2099            TransportType::WiFiDirect,
2100            create_test_metrics(Some(-62), 0.15, 8),
2101        );
2102
2103        let mut switch_count = 0u32;
2104        let mut prev = None::<TransportType>;
2105
2106        // Alternate metrics so scores move around (BLE vs WiFi close); then one big swing
2107        for step in 0..20 {
2108            match step % 3 {
2109                0 => {
2110                    transports.insert(TransportType::BLE, create_test_metrics(Some(-58), 0.18, 8));
2111                    transports.insert(
2112                        TransportType::WiFiDirect,
2113                        create_test_metrics(Some(-64), 0.2, 12),
2114                    );
2115                }
2116                1 => {
2117                    transports.insert(TransportType::BLE, create_test_metrics(Some(-64), 0.22, 14));
2118                    transports.insert(
2119                        TransportType::WiFiDirect,
2120                        create_test_metrics(Some(-59), 0.14, 6),
2121                    );
2122                }
2123                _ => {
2124                    transports.insert(TransportType::BLE, create_test_metrics(Some(-61), 0.2, 10));
2125                    transports.insert(
2126                        TransportType::WiFiDirect,
2127                        create_test_metrics(Some(-62), 0.16, 9),
2128                    );
2129                }
2130            }
2131            let selected = selector.select_transport(&message, &transports).unwrap();
2132            if prev.map(|p| p != selected).unwrap_or(false) {
2133                switch_count += 1;
2134            }
2135            prev = Some(selected);
2136        }
2137
2138        assert!(
2139            switch_count <= 1,
2140            "noisy metrics must not cause flapping; switch_count should be at most 1, got {}",
2141            switch_count
2142        );
2143    }
2144
2145    #[test]
2146    fn test_retry_escalation() {
2147        let mut selector = TransportSelector::new();
2148
2149        assert!(!selector.should_escalate_to_wifi());
2150
2151        selector.record_retry_failure(TransportType::BLE);
2152        assert!(!selector.should_escalate_to_wifi());
2153
2154        selector.record_retry_failure(TransportType::BLE);
2155        assert!(selector.should_escalate_to_wifi());
2156
2157        selector.reset_retry_count(TransportType::BLE);
2158        assert!(!selector.should_escalate_to_wifi());
2159    }
2160
2161    #[test]
2162    fn test_poor_signal_escalation() {
2163        let config = DorsConfig {
2164            rssi_switch_threshold: -80,
2165            poor_signal_duration_secs: 0, // Immediate for test
2166            ..Default::default()
2167        };
2168
2169        let mut selector = TransportSelector::with_config(config);
2170        let message = create_test_message();
2171
2172        let mut transports = HashMap::new();
2173        transports.insert(TransportType::BLE, create_test_metrics(Some(-90), 0.1, 5));
2174
2175        selector.select_transport(&message, &transports);
2176        assert!(selector.should_escalate_to_wifi());
2177    }
2178
2179    #[test]
2180    fn test_congestion_escalation() {
2181        let config = DorsConfig {
2182            congestion_queue_threshold: 10,
2183            congestion_duration_secs: 0,
2184            ..Default::default()
2185        };
2186
2187        let mut selector = TransportSelector::with_config(config);
2188        let message = create_test_message();
2189
2190        let mut transports = HashMap::new();
2191        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.9, 20));
2192
2193        selector.select_transport(&message, &transports);
2194        assert!(selector.should_escalate_to_wifi());
2195    }
2196
2197    /// Degradation-based escalation: when BLE success rate drops below configured minimum
2198    /// after sufficient observation volume, escalation is triggered (deterministic, config-driven).
2199    #[test]
2200    fn test_low_success_rate_escalation() {
2201        let config = DorsConfig {
2202            min_success_rate_before_escalation: 0.30,
2203            min_ble_samples_before_success_rate_escalation: 1, // one sample enough for test
2204            ..Default::default()
2205        };
2206        let mut selector = TransportSelector::with_config(config);
2207        let message = create_test_message();
2208
2209        let mut ble_metrics = create_test_metrics(Some(-60), 0.1, 5);
2210        ble_metrics.success_count = 2;
2211        ble_metrics.failure_count = 8;
2212
2213        let mut transports = HashMap::new();
2214        transports.insert(TransportType::BLE, ble_metrics);
2215
2216        selector.select_transport(&message, &transports);
2217        assert!(
2218            selector.should_escalate_to_wifi(),
2219            "BLE success rate 0.2 < 0.30 with sample floor met should trigger escalation"
2220        );
2221        assert_eq!(
2222            selector.escalation_trigger_reason(),
2223            Some(EscalationTriggerReason::LowSuccessRate)
2224        );
2225    }
2226
2227    /// Sparse data must not trigger low-success-rate escalation: single sample below threshold
2228    /// is gated by min_ble_samples_before_success_rate_escalation (default 5).
2229    #[test]
2230    fn test_low_success_rate_escalation_gated_by_sample_floor() {
2231        let config = DorsConfig {
2232            min_success_rate_before_escalation: 0.30,
2233            min_ble_samples_before_success_rate_escalation: 5, // require 5 samples
2234            ..Default::default()
2235        };
2236        let mut selector = TransportSelector::with_config(config);
2237        let message = create_test_message();
2238
2239        // One observation with very low success (0.2) — below threshold but only 1 sample
2240        let mut ble_metrics = create_test_metrics(Some(-60), 0.1, 5);
2241        ble_metrics.success_count = 2;
2242        ble_metrics.failure_count = 8;
2243
2244        let mut transports = HashMap::new();
2245        transports.insert(TransportType::BLE, ble_metrics);
2246
2247        selector.select_transport(&message, &transports);
2248        assert!(
2249            !selector.should_escalate_to_wifi(),
2250            "single sample below rate threshold must not trigger escalation when floor is 5"
2251        );
2252        assert_ne!(
2253            selector.escalation_trigger_reason(),
2254            Some(EscalationTriggerReason::LowSuccessRate)
2255        );
2256
2257        // After 5 select_transport calls we have 5 samples; then low rate should trigger
2258        for _ in 0..4 {
2259            selector.select_transport(&message, &transports);
2260        }
2261        assert!(
2262            selector.should_escalate_to_wifi(),
2263            "after 5 samples, low success rate should trigger escalation"
2264        );
2265        assert_eq!(
2266            selector.escalation_trigger_reason(),
2267            Some(EscalationTriggerReason::LowSuccessRate)
2268        );
2269    }
2270
2271    #[test]
2272    fn test_low_battery_blocks_escalation() {
2273        let mut selector = TransportSelector::new();
2274        let message = create_test_message();
2275
2276        let mut ble_metrics = create_test_metrics(Some(-90), 0.9, 25);
2277        ble_metrics.battery_level = Some(10);
2278        ble_metrics.is_charging = false;
2279
2280        let mut transports = HashMap::new();
2281        transports.insert(TransportType::BLE, ble_metrics);
2282        transports.insert(
2283            TransportType::WiFiDirect,
2284            create_test_metrics(Some(-60), 0.2, 5),
2285        );
2286
2287        selector.select_transport(&message, &transports);
2288        selector.record_retry_failure(TransportType::BLE);
2289        selector.record_retry_failure(TransportType::BLE);
2290
2291        assert!(!selector.should_escalate_to_wifi());
2292    }
2293
2294    #[test]
2295    fn test_signal_score_calculation() {
2296        let selector = TransportSelector::new();
2297        let profile = selector.scoring_profile(TransportType::BLE);
2298
2299        let excellent = create_test_metrics(Some(-40), 0.0, 0);
2300        let score = selector.calculate_signal_score(&profile, TransportType::BLE, &excellent);
2301        assert_eq!(score, 100.0);
2302
2303        let poor = create_test_metrics(Some(-90), 0.0, 0);
2304        let score = selector.calculate_signal_score(&profile, TransportType::BLE, &poor);
2305        assert!(score < 40.0);
2306    }
2307
2308    #[test]
2309    fn test_congestion_score() {
2310        let selector = TransportSelector::new();
2311
2312        let low_congestion = create_test_metrics(Some(-60), 0.1, 5);
2313        let score = selector.calculate_congestion_score(TransportType::BLE, &low_congestion);
2314        assert!(score > 80.0);
2315
2316        let high_congestion = create_test_metrics(Some(-60), 0.9, 60);
2317        let score = selector.calculate_congestion_score(TransportType::BLE, &high_congestion);
2318        assert!(score < 20.0);
2319    }
2320
2321    #[test]
2322    fn test_load_score_discourages_overloaded_transport() {
2323        let mut selector = TransportSelector::new();
2324        let message = create_test_message();
2325
2326        let mut transports = HashMap::new();
2327        transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.2, 5));
2328
2329        let mut congested = create_test_metrics(Some(-55), 0.9, 90);
2330        congested.queue_depth = 90;
2331        congested.congestion = 0.9;
2332        transports.insert(TransportType::WiFiDirect, congested);
2333
2334        let selected = selector.select_transport(&message, &transports).unwrap();
2335        assert_eq!(selected, TransportType::BLE);
2336    }
2337
2338    #[test]
2339    fn test_each_transport_gets_numeric_score_from_same_scoring_function() {
2340        let selector = TransportSelector::new();
2341        let message = create_test_message();
2342        let mut transports = HashMap::new();
2343        transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2344        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2345        transports.insert(
2346            TransportType::WiFiDirect,
2347            create_test_metrics(Some(-55), 0.1, 5),
2348        );
2349
2350        let ranked = selector.score_and_rank(&message, &transports);
2351        assert_eq!(
2352            ranked.len(),
2353            3,
2354            "Each transport gets one score from the same scoring path"
2355        );
2356        for (tt, score) in &ranked {
2357            assert!(score.is_finite(), "Score for {:?} must be finite", tt);
2358            // Internet is demoted to a negative fallback sentinel under the
2359            // default (prefer_online = false) config; mesh transports stay >= 0.
2360            if *tt != TransportType::Internet {
2361                assert!(*score >= 0.0, "Score for {:?} must be non-negative", tt);
2362            }
2363        }
2364        let scores: Vec<f32> = ranked.iter().map(|(_, s)| *s).collect();
2365        let sorted: Vec<f32> = {
2366            let mut s = scores.clone();
2367            s.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
2368            s
2369        };
2370        assert_eq!(scores, sorted, "score_and_rank returns descending by score");
2371    }
2372
2373    #[test]
2374    fn test_score_and_rank_detailed_matches_score_and_rank_order_and_totals() {
2375        // Contract: detailed and total variants agree on ordering and on the
2376        // `total` field. The detailed variant simply adds the per-factor
2377        // breakdown without changing ranking semantics.
2378        let selector = TransportSelector::new();
2379        let message = create_test_message();
2380        let mut transports = HashMap::new();
2381        transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2382        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2383        transports.insert(
2384            TransportType::WiFiDirect,
2385            create_test_metrics(Some(-55), 0.1, 5),
2386        );
2387
2388        let totals = selector.score_and_rank(&message, &transports);
2389        let detailed = selector.score_and_rank_detailed(&message, &transports);
2390
2391        assert_eq!(totals.len(), detailed.len());
2392        for ((t_total, score_total), (t_det, score_det)) in totals.iter().zip(detailed.iter()) {
2393            assert_eq!(t_total, t_det, "ordering must match between variants");
2394            assert!(
2395                (*score_total - score_det.total).abs() < f32::EPSILON,
2396                "totals must match: {} vs {}",
2397                score_total,
2398                score_det.total,
2399            );
2400            for (label, v) in [
2401                ("signal", score_det.signal),
2402                ("proximity", score_det.proximity),
2403                ("bandwidth", score_det.bandwidth),
2404                ("congestion", score_det.congestion),
2405                ("energy", score_det.energy),
2406                ("reliability", score_det.reliability),
2407                ("load", score_det.load),
2408            ] {
2409                assert!(
2410                    v.is_finite(),
2411                    "{label} sub-score for {t_det:?} must be finite",
2412                );
2413            }
2414        }
2415    }
2416
2417    #[test]
2418    fn test_missing_metrics_handled_safely_with_defaults() {
2419        let selector = TransportSelector::new();
2420        let message = create_test_message();
2421        let mut transports = HashMap::new();
2422        transports.insert(TransportType::Internet, TransportMetrics::default());
2423        transports.insert(TransportType::BLE, TransportMetrics::default());
2424        transports.insert(TransportType::WiFiDirect, TransportMetrics::default());
2425
2426        let ranked = selector.score_and_rank(&message, &transports);
2427        assert_eq!(ranked.len(), 3);
2428        for (tt, score) in &ranked {
2429            assert!(
2430                score.is_finite(),
2431                "Missing metrics must not produce NaN for {:?}",
2432                tt
2433            );
2434            // Internet is demoted to a negative fallback sentinel under the
2435            // default config; mesh transports stay >= 0.
2436            if *tt != TransportType::Internet {
2437                assert!(
2438                    !score.is_nan() && *score >= 0.0,
2439                    "Missing metrics must yield safe default score for {:?}",
2440                    tt
2441                );
2442            }
2443        }
2444    }
2445
2446    #[test]
2447    fn test_score_calculation_normal_inputs_ble_better_signal_higher_score() {
2448        let selector = TransportSelector::new();
2449        let profile = selector.scoring_profile(TransportType::BLE);
2450        let excellent = create_test_metrics(Some(-45), 0.1, 5);
2451        let poor = create_test_metrics(Some(-88), 0.1, 5);
2452
2453        let excellent_signal =
2454            selector.calculate_signal_score(&profile, TransportType::BLE, &excellent);
2455        let poor_signal = selector.calculate_signal_score(&profile, TransportType::BLE, &poor);
2456        assert!(excellent_signal > poor_signal);
2457        assert!(excellent_signal >= 90.0);
2458        assert!(poor_signal < 50.0);
2459    }
2460
2461    #[test]
2462    fn test_score_calculation_normal_inputs_low_congestion_higher_score() {
2463        let selector = TransportSelector::new();
2464        let low = create_test_metrics(Some(-60), 0.1, 5);
2465        let high = create_test_metrics(Some(-60), 0.95, 80);
2466
2467        let low_cong = selector.calculate_congestion_score(TransportType::BLE, &low);
2468        let high_cong = selector.calculate_congestion_score(TransportType::BLE, &high);
2469        assert!(low_cong > high_cong);
2470    }
2471
2472    #[test]
2473    fn test_score_calculation_edge_no_rssi_uses_default() {
2474        let selector = TransportSelector::new();
2475        let profile = selector.scoring_profile(TransportType::BLE);
2476        let mut metrics = TransportMetrics::default();
2477        metrics.rssi = None;
2478
2479        let score = selector.calculate_signal_score(&profile, TransportType::BLE, &metrics);
2480        assert!(score.is_finite());
2481        assert!(score >= 0.0 && score <= 100.0);
2482    }
2483
2484    #[test]
2485    fn test_score_calculation_edge_no_delivery_ratio_uses_default() {
2486        let selector = TransportSelector::new();
2487        let metrics = TransportMetrics::default();
2488        assert_eq!(metrics.delivery_ratio, None);
2489        assert_eq!(metrics.success_count, 0);
2490        assert_eq!(metrics.failure_count, 0);
2491
2492        let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
2493        assert!(score.is_finite());
2494        assert!(score >= 0.0 && score <= 100.0);
2495    }
2496
2497    #[test]
2498    fn test_score_calculation_edge_zero_bandwidth_uses_type_default() {
2499        let selector = TransportSelector::new();
2500        let metrics = TransportMetrics::default();
2501        assert_eq!(metrics.bandwidth_bps, None);
2502
2503        let ble_bw = selector
2504            .calculate_bandwidth_score(&selector.scoring_profile(TransportType::BLE), &metrics);
2505        let wifi_bw = selector.calculate_bandwidth_score(
2506            &selector.scoring_profile(TransportType::WiFiDirect),
2507            &metrics,
2508        );
2509        let internet_bw = selector.calculate_bandwidth_score(
2510            &selector.scoring_profile(TransportType::Internet),
2511            &metrics,
2512        );
2513        assert!(ble_bw.is_finite() && ble_bw >= 0.0);
2514        assert!(wifi_bw.is_finite() && wifi_bw >= 0.0);
2515        assert!(internet_bw.is_finite() && internet_bw >= 0.0);
2516    }
2517
2518    #[test]
2519    fn test_internet_bandwidth_uses_measured_when_available() {
2520        let selector = TransportSelector::new();
2521        let profile = selector.scoring_profile(TransportType::Internet);
2522        // 10 Mbps => score 100
2523        let mut m10 = TransportMetrics::default();
2524        m10.bandwidth_bps = Some(10_000_000);
2525        assert!((selector.calculate_bandwidth_score(&profile, &m10) - 100.0).abs() < 0.01);
2526        // 1 Mbps => score 10
2527        m10.bandwidth_bps = Some(1_000_000);
2528        assert!((selector.calculate_bandwidth_score(&profile, &m10) - 10.0).abs() < 0.01);
2529        // Above 10 Mbps clamped to 100
2530        m10.bandwidth_bps = Some(100_000_000);
2531        assert!((selector.calculate_bandwidth_score(&profile, &m10) - 100.0).abs() < 0.01);
2532    }
2533
2534    #[test]
2535    fn test_internet_bandwidth_default_respects_prefer_online() {
2536        let metrics = TransportMetrics::default();
2537        assert_eq!(metrics.bandwidth_bps, None);
2538        let prefer = TransportSelector::with_config(DorsConfig {
2539            prefer_online: true,
2540            ..Default::default()
2541        });
2542        let no_prefer = TransportSelector::with_config(DorsConfig {
2543            prefer_online: false,
2544            ..Default::default()
2545        });
2546        assert_eq!(
2547            prefer.calculate_bandwidth_score(
2548                &prefer.scoring_profile(TransportType::Internet),
2549                &metrics
2550            ),
2551            70.0
2552        );
2553        assert_eq!(
2554            no_prefer.calculate_bandwidth_score(
2555                &no_prefer.scoring_profile(TransportType::Internet),
2556                &metrics
2557            ),
2558            50.0
2559        );
2560    }
2561
2562    #[test]
2563    fn test_score_calculation_edge_extreme_congestion_clamped() {
2564        let selector = TransportSelector::new();
2565        let mut metrics = TransportMetrics::default();
2566        metrics.congestion = 2.0;
2567        metrics.queue_depth = 1000;
2568
2569        let score = selector.calculate_congestion_score(TransportType::BLE, &metrics);
2570        assert!(score.is_finite());
2571        assert!(score >= 0.0);
2572    }
2573
2574    #[test]
2575    fn test_score_calculation_edge_negative_rssi_handled() {
2576        let selector = TransportSelector::new();
2577        let profile = selector.scoring_profile(TransportType::BLE);
2578        let metrics = create_test_metrics(Some(-100), 0.0, 0);
2579        let score = selector.calculate_signal_score(&profile, TransportType::BLE, &metrics);
2580        assert!(score.is_finite());
2581        assert!(score >= 0.0 && score <= 100.0);
2582    }
2583
2584    #[test]
2585    fn test_total_score_aggregates_subscores_consistently() {
2586        let selector = TransportSelector::new();
2587        let message = create_test_message();
2588        let metrics = create_test_metrics(Some(-60), 0.2, 10);
2589
2590        let score_ble = selector.calculate_transport_score(&message, TransportType::BLE, &metrics);
2591        let score_wifi =
2592            selector.calculate_transport_score(&message, TransportType::WiFiDirect, &metrics);
2593
2594        assert!(score_ble.total.is_finite() && score_ble.total >= 0.0);
2595        assert!(score_wifi.total.is_finite() && score_wifi.total >= 0.0);
2596        assert!(score_ble.signal >= 0.0 && score_ble.signal <= 100.0);
2597        assert!(score_ble.congestion >= 0.0 && score_ble.congestion <= 100.0);
2598    }
2599
2600    // ---------- Normal inputs: score calculation ----------
2601
2602    #[test]
2603    fn test_score_calculation_normal_proximity_hop_zero_gives_max() {
2604        let selector = TransportSelector::new();
2605        let message = create_test_message(); // hop_count 0
2606        let score = selector.calculate_proximity_score(&message);
2607        assert!((score - 100.0).abs() < 0.01);
2608    }
2609
2610    #[test]
2611    fn test_score_calculation_normal_proximity_mid_hops() {
2612        let selector = TransportSelector::new();
2613        let message = create_test_message_with_hops(2, 8); // (8-2)/8 * 100 = 75
2614        let score = selector.calculate_proximity_score(&message);
2615        assert!((score - 75.0).abs() < 0.01);
2616    }
2617
2618    #[test]
2619    fn test_score_calculation_normal_energy_battery_mid() {
2620        let selector = TransportSelector::new();
2621        let metrics = create_test_metrics(Some(-60), 0.2, 10); // battery 80
2622        let ble = selector
2623            .calculate_energy_score(&selector.scoring_profile(TransportType::BLE), &metrics);
2624        let wifi = selector.calculate_energy_score(
2625            &selector.scoring_profile(TransportType::WiFiDirect),
2626            &metrics,
2627        );
2628        assert!(ble.is_finite() && ble >= 0.0 && ble <= 100.0);
2629        assert!(wifi.is_finite() && wifi >= 0.0 && wifi <= 100.0);
2630        assert!(
2631            ble > wifi,
2632            "BLE should score higher on energy than WiFi at same battery"
2633        );
2634    }
2635
2636    #[test]
2637    fn test_score_calculation_normal_reliability_from_delivery_ratio() {
2638        let selector = TransportSelector::new();
2639        let mut metrics = TransportMetrics::default();
2640        metrics.delivery_ratio = Some(0.9);
2641        let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
2642        assert!((score - 90.0).abs() < 0.01);
2643    }
2644
2645    #[test]
2646    fn test_score_calculation_normal_reliability_from_success_failure() {
2647        let selector = TransportSelector::new();
2648        let mut metrics = TransportMetrics::default();
2649        metrics.success_count = 9;
2650        metrics.failure_count = 1;
2651        let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
2652        assert!((score - 90.0).abs() < 0.01);
2653    }
2654
2655    #[test]
2656    fn test_score_calculation_normal_load_low_queue() {
2657        let selector = TransportSelector::new();
2658        let mut metrics = TransportMetrics::default();
2659        metrics.queue_depth = 5;
2660        let score = selector.calculate_load_score(TransportType::BLE, &metrics);
2661        assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
2662        assert!(
2663            score > 50.0,
2664            "low queue should yield relatively high load score"
2665        );
2666    }
2667
2668    #[test]
2669    fn test_score_calculation_normal_internet_total_finite() {
2670        let selector = TransportSelector::new();
2671        let message = create_test_message();
2672        let mut metrics = create_test_metrics(None, 0.0, 0);
2673        metrics.bandwidth_bps = Some(5_000_000); // 5 Mbps
2674        let score = selector.calculate_transport_score(&message, TransportType::Internet, &metrics);
2675        // total is finite; under the default (prefer_online = false) config the
2676        // Internet total is demoted negative (fallback), so we assert finiteness
2677        // only. The sub-scores below are unaffected by the demotion.
2678        assert!(score.total.is_finite());
2679        assert!(score.bandwidth >= 0.0 && score.bandwidth <= 100.0);
2680        assert!(score.reliability >= 0.0 && score.reliability <= 100.0);
2681    }
2682
2683    #[test]
2684    fn test_score_calculation_normal_signal_rssi_boundaries() {
2685        let selector = TransportSelector::new();
2686        let profile = selector.scoring_profile(TransportType::BLE);
2687        let mut m50 = TransportMetrics::default();
2688        m50.rssi = Some(-50);
2689        assert!(
2690            (selector.calculate_signal_score(&profile, TransportType::BLE, &m50) - 100.0).abs()
2691                < 0.01
2692        );
2693        let mut m70 = TransportMetrics::default();
2694        m70.rssi = Some(-70);
2695        let s70 = selector.calculate_signal_score(&profile, TransportType::BLE, &m70);
2696        assert!(s70 >= 70.0 && s70 <= 100.0);
2697    }
2698
2699    // ---------- Edge inputs: score calculation ----------
2700
2701    #[test]
2702    fn test_score_calculation_edge_proximity_hop_near_ttl() {
2703        let selector = TransportSelector::new();
2704        let message = create_test_message_with_hops(7, 8); // (8-7)/8 * 100 = 12.5
2705        let score = selector.calculate_proximity_score(&message);
2706        assert!(score >= 10.0 && score <= 20.0);
2707    }
2708
2709    #[test]
2710    fn test_score_calculation_edge_energy_no_battery() {
2711        let selector = TransportSelector::new();
2712        let mut metrics = TransportMetrics::default();
2713        metrics.battery_level = None;
2714        metrics.is_charging = false;
2715        let score = selector
2716            .calculate_energy_score(&selector.scoring_profile(TransportType::BLE), &metrics);
2717        assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
2718    }
2719
2720    #[test]
2721    fn test_score_calculation_edge_reliability_zero_and_one() {
2722        let selector = TransportSelector::new();
2723        let mut m0 = TransportMetrics::default();
2724        m0.delivery_ratio = Some(0.0);
2725        assert!((selector.calculate_reliability_score(TransportType::BLE, &m0)).abs() < 0.01);
2726        let mut m1 = TransportMetrics::default();
2727        m1.delivery_ratio = Some(1.0);
2728        assert!(
2729            (selector.calculate_reliability_score(TransportType::BLE, &m1) - 100.0).abs() < 0.01
2730        );
2731    }
2732
2733    #[test]
2734    fn test_score_calculation_edge_load_zero_queue() {
2735        let selector = TransportSelector::new();
2736        let mut metrics = TransportMetrics::default();
2737        metrics.queue_depth = 0;
2738        let score = selector.calculate_load_score(TransportType::BLE, &metrics);
2739        assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
2740        assert!(score >= 90.0, "zero queue should give very high load score");
2741    }
2742
2743    #[test]
2744    fn test_score_calculation_edge_load_queue_above_threshold() {
2745        let selector = TransportSelector::new();
2746        let mut metrics = TransportMetrics::default();
2747        metrics.queue_depth = 200; // above default threshold 50
2748        let score = selector.calculate_load_score(TransportType::BLE, &metrics);
2749        assert!(score.is_finite() && score >= 0.0);
2750        assert!(
2751            score < 50.0,
2752            "queue above threshold should reduce load score"
2753        );
2754    }
2755
2756    #[test]
2757    fn test_score_calculation_edge_total_all_metrics_default() {
2758        let selector = TransportSelector::new();
2759        let message = create_test_message();
2760        let metrics = TransportMetrics::default();
2761        for transport_type in [
2762            TransportType::BLE,
2763            TransportType::WiFiDirect,
2764            TransportType::Internet,
2765            TransportType::Reticulum,
2766        ] {
2767            let score = selector.calculate_transport_score(&message, transport_type, &metrics);
2768            assert!(
2769                score.total.is_finite(),
2770                "total must be finite for {:?}",
2771                transport_type
2772            );
2773            // Internet is demoted to a negative fallback sentinel under the
2774            // default config; mesh transports stay >= 0.
2775            if transport_type != TransportType::Internet {
2776                assert!(
2777                    score.total >= 0.0,
2778                    "total must be non-negative for {:?}",
2779                    transport_type
2780                );
2781            }
2782            assert!(score.bandwidth >= 0.0 && score.bandwidth <= 100.0);
2783            assert!(score.congestion >= 0.0 && score.congestion <= 100.0);
2784        }
2785    }
2786
2787    #[test]
2788    fn test_score_calculation_edge_congestion_zero_and_one() {
2789        let selector = TransportSelector::new();
2790        let mut m0 = TransportMetrics::default();
2791        m0.congestion = 0.0;
2792        m0.queue_depth = 0;
2793        let low = selector.calculate_congestion_score(TransportType::BLE, &m0);
2794        let mut m1 = TransportMetrics::default();
2795        m1.congestion = 1.0;
2796        m1.queue_depth = 100;
2797        let high_cong = selector.calculate_congestion_score(TransportType::BLE, &m1);
2798        assert!(low > high_cong);
2799        assert!(low >= 70.0);
2800        assert!(high_cong <= 50.0);
2801    }
2802
2803    #[test]
2804    fn test_unavailable_transport_bypasses_cooldown() {
2805        let config = DorsConfig {
2806            prefer_online: true,
2807            switch_cooldown_secs: 60, // Long cooldown
2808            ..Default::default()
2809        };
2810        let mut selector = TransportSelector::with_config(config);
2811        let message = create_test_message();
2812
2813        // First: select Internet while it's available
2814        let mut transports_with_internet = HashMap::new();
2815        let mut internet_metrics = create_test_metrics(None, 0.0, 0);
2816        internet_metrics.bandwidth_bps = None;
2817        transports_with_internet.insert(TransportType::Internet, internet_metrics);
2818        transports_with_internet
2819            .insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2820
2821        let selected = selector
2822            .select_transport(&message, &transports_with_internet)
2823            .unwrap();
2824        assert_eq!(selected, TransportType::Internet);
2825
2826        // Now Internet disconnects (removed from available transports).
2827        // Despite the 60s cooldown, DORS must switch to BLE immediately.
2828        let mut transports_without_internet = HashMap::new();
2829        transports_without_internet
2830            .insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2831
2832        let selected = selector
2833            .select_transport(&message, &transports_without_internet)
2834            .unwrap();
2835        assert_eq!(
2836            selected,
2837            TransportType::BLE,
2838            "DORS must fall back to BLE when Internet is no longer available"
2839        );
2840    }
2841
2842    #[test]
2843    fn test_selection_chooses_highest_scored_transport_when_no_current() {
2844        let mut selector = TransportSelector::new();
2845        let message = create_test_message();
2846
2847        let mut transports = HashMap::new();
2848        transports.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
2849        transports.insert(
2850            TransportType::WiFiDirect,
2851            create_test_metrics(Some(-55), 0.05, 2),
2852        );
2853        transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2854
2855        let ranked = selector.score_and_rank(&message, &transports);
2856        assert_eq!(ranked.len(), 3);
2857        let (best_type, best_score) = ranked[0];
2858        let (_second_type, second_score) = ranked[1];
2859        assert!(
2860            best_score >= second_score,
2861            "score_and_rank returns descending order"
2862        );
2863
2864        let selected = selector.select_transport(&message, &transports).unwrap();
2865        assert_eq!(
2866				selected, best_type,
2867				"Selection must choose the transport with the highest policy-compliant score when there is no current transport"
2868		);
2869    }
2870
2871    #[test]
2872    fn test_no_transport_viable_returns_none_typed_fallback() {
2873        let mut selector = TransportSelector::new();
2874        let message = create_test_message();
2875        let transports: HashMap<TransportType, TransportMetrics> = HashMap::new();
2876
2877        let result = selector.select_transport(&message, &transports);
2878        assert!(
2879				result.is_none(),
2880				"When no transport is viable (empty available_transports), must return None as typed fallback"
2881		);
2882    }
2883
2884    #[test]
2885    fn test_single_transport_returns_that_transport() {
2886        let mut selector = TransportSelector::new();
2887        let message = create_test_message();
2888        let mut transports = HashMap::new();
2889        transports.insert(
2890            TransportType::Internet,
2891            create_test_metrics(Some(-60), 0.2, 10),
2892        );
2893
2894        let selected = selector.select_transport(&message, &transports);
2895        assert_eq!(selected, Some(TransportType::Internet));
2896    }
2897
2898    /// When scores are equal or within a small gap (TIE_EPSILON), selection still returns
2899    /// one of the available transports with finite, non-negative scores. Tie-break order
2900    /// (Internet > WiFiDirect > BLE) is applied when scores are effectively equal.
2901    #[test]
2902    fn test_tie_case_returns_some_transport_with_finite_scores() {
2903        let mut selector = TransportSelector::new();
2904        let message = create_test_message();
2905        let mut transports = HashMap::new();
2906        let mut ble = create_test_metrics(Some(-68), 0.15, 8);
2907        ble.bandwidth_bps = Some(120_000);
2908        let mut wifi = create_test_metrics(Some(-65), 0.15, 8);
2909        wifi.bandwidth_bps = Some(1_900_000);
2910        transports.insert(TransportType::BLE, ble);
2911        transports.insert(TransportType::WiFiDirect, wifi);
2912
2913        let ranked = selector.score_and_rank(&message, &transports);
2914        assert_eq!(ranked.len(), 2);
2915        let selected = selector.select_transport(&message, &transports);
2916        assert!(selected.is_some());
2917        let sel = selected.unwrap();
2918        assert!(
2919            sel == TransportType::BLE || sel == TransportType::WiFiDirect,
2920            "On tie or near-tie, selection must return one of the available transports"
2921        );
2922        assert!(
2923            ranked.iter().all(|(_, s)| s.is_finite() && *s >= 0.0),
2924            "All scores must be finite and non-negative"
2925        );
2926    }
2927
2928    /// With forced equal scores, the winner must follow the tie-break rule: Internet > WiFiDirect > BLE > Reticulum.
2929    /// Uses a test-only helper to inject equal scores so we don't rely on runtime metrics to tie.
2930    #[test]
2931    fn test_tie_break_order_when_scores_forced_equal() {
2932        let selector = TransportSelector::new();
2933
2934        // Two transports with identical score: WiFiDirect and BLE. Tie-break prefers WiFiDirect over BLE.
2935        let scores_two = [
2936            (TransportType::WiFiDirect, 50.0),
2937            (TransportType::BLE, 50.0),
2938        ];
2939        assert_eq!(
2940            selector.select_from_scores_for_test(&scores_two),
2941            Some(TransportType::WiFiDirect),
2942            "Tie-break: WiFiDirect > BLE"
2943        );
2944
2945        // All three with identical score: Internet must win.
2946        let scores_three = [
2947            (TransportType::BLE, 50.0),
2948            (TransportType::WiFiDirect, 50.0),
2949            (TransportType::Internet, 50.0),
2950        ];
2951        assert_eq!(
2952            selector.select_from_scores_for_test(&scores_three),
2953            Some(TransportType::Internet),
2954            "Tie-break: Internet > WiFiDirect > BLE"
2955        );
2956
2957        // Order in input should not matter.
2958        let scores_reversed = [
2959            (TransportType::Internet, 50.0),
2960            (TransportType::WiFiDirect, 50.0),
2961            (TransportType::BLE, 50.0),
2962        ];
2963        assert_eq!(
2964            selector.select_from_scores_for_test(&scores_reversed),
2965            Some(TransportType::Internet)
2966        );
2967    }
2968
2969    #[test]
2970    fn test_policy_compliant_when_hysteresis_blocks_returns_current() {
2971        let mut selector = TransportSelector::new();
2972        let message = create_test_message();
2973
2974        let mut transports = HashMap::new();
2975        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2976        transports.insert(
2977            TransportType::WiFiDirect,
2978            create_test_metrics(Some(-55), 0.1, 5),
2979        );
2980
2981        let first = selector.select_transport(&message, &transports).unwrap();
2982        let current = first;
2983
2984        transports.insert(TransportType::BLE, create_test_metrics(Some(-62), 0.2, 10));
2985        transports.insert(
2986            TransportType::WiFiDirect,
2987            create_test_metrics(Some(-56), 0.1, 5),
2988        );
2989
2990        let second = selector.select_transport(&message, &transports).unwrap();
2991        assert_eq!(
2992				second, current,
2993				"When hysteresis blocks a switch, selection must return current transport (policy-compliant)"
2994		);
2995    }
2996
2997    #[test]
2998    fn test_dors_when_wifi_direct_removed_from_available_selects_ble() {
2999        let mut selector = TransportSelector::new();
3000        let message = create_test_message();
3001
3002        let mut with_wifi = HashMap::new();
3003        with_wifi.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3004        with_wifi.insert(
3005            TransportType::WiFiDirect,
3006            create_test_metrics(Some(-50), 0.1, 5),
3007        );
3008
3009        let selected_with_wifi = selector.select_transport(&message, &with_wifi).unwrap();
3010        assert!(
3011            matches!(
3012                selected_with_wifi,
3013                TransportType::BLE | TransportType::WiFiDirect
3014            ),
3015            "DORS may pick BLE or WiFi when both available"
3016        );
3017
3018        let mut without_wifi = HashMap::new();
3019        without_wifi.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3020        without_wifi.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
3021
3022        let selected_without_wifi = selector.select_transport(&message, &without_wifi).unwrap();
3023        assert!(
3024            selected_without_wifi != TransportType::WiFiDirect,
3025            "WiFi Direct must not be selected when it is not in available_transports"
3026        );
3027    }
3028
3029    #[test]
3030    fn test_dors_ios_background_wifi_suspended_fallback_to_ble_or_internet() {
3031        let mut selector = TransportSelector::new();
3032        let message = create_test_message();
3033
3034        let mut transports_foreground = HashMap::new();
3035        transports_foreground.insert(TransportType::BLE, create_test_metrics(Some(-65), 0.2, 8));
3036        transports_foreground.insert(
3037            TransportType::WiFiDirect,
3038            create_test_metrics(Some(-55), 0.1, 3),
3039        );
3040
3041        let _ = selector.select_transport(&message, &transports_foreground);
3042
3043        let mut transports_background = HashMap::new();
3044        transports_background.insert(TransportType::BLE, create_test_metrics(Some(-65), 0.2, 8));
3045        transports_background.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
3046
3047        let selected = selector
3048            .select_transport(&message, &transports_background)
3049            .unwrap();
3050        assert_ne!(
3051            selected,
3052            TransportType::WiFiDirect,
3053            "WiFi Direct must not be selected when removed from available_transports (iOS background)"
3054        );
3055        assert!(
3056            selected == TransportType::BLE || selected == TransportType::Internet,
3057            "DORS must reroute through BLE or Internet when WiFi Direct is suspended by OS"
3058        );
3059    }
3060
3061    #[test]
3062    fn test_dors_wifi_direct_removed_then_only_ble_available_selects_ble() {
3063        let mut selector = TransportSelector::new();
3064        let message = create_test_message();
3065
3066        let mut with_both = HashMap::new();
3067        with_both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3068        with_both.insert(
3069            TransportType::WiFiDirect,
3070            create_test_metrics(Some(-50), 0.05, 2),
3071        );
3072        let _ = selector.select_transport(&message, &with_both);
3073
3074        let mut only_ble = HashMap::new();
3075        only_ble.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3076
3077        let selected = selector.select_transport(&message, &only_ble).unwrap();
3078        assert_eq!(
3079            selected,
3080            TransportType::BLE,
3081            "When WiFi Direct is removed from available_transports (e.g. iOS background), DORS must select BLE so traffic is rerouted through BLE"
3082        );
3083    }
3084
3085    #[test]
3086    fn test_dors_current_transport_wifi_then_removed_switches_immediately() {
3087        let mut selector = TransportSelector::new();
3088        let message = create_test_message();
3089
3090        let mut with_wifi = HashMap::new();
3091        with_wifi.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
3092        with_wifi.insert(
3093            TransportType::WiFiDirect,
3094            create_test_metrics(Some(-55), 0.1, 2),
3095        );
3096        let _first = selector.select_transport(&message, &with_wifi).unwrap();
3097
3098        let mut without_wifi = HashMap::new();
3099        without_wifi.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
3100
3101        let second = selector.select_transport(&message, &without_wifi).unwrap();
3102        assert_eq!(
3103            second,
3104            TransportType::BLE,
3105            "DORS must switch immediately when current transport (e.g. WiFi Direct) is no longer available, without waiting for cooldown"
3106        );
3107        assert_ne!(
3108            second,
3109            TransportType::WiFiDirect,
3110            "WiFi Direct must not be returned when it was removed from available_transports"
3111        );
3112    }
3113
3114    /// Regression test: verifies that the scoring-profile refactor does not change
3115    /// the computed scores for the three original transports (BLE, WiFi Direct,
3116    /// Internet) under fixed, representative metrics.
3117    #[test]
3118    fn test_scoring_profile_regression_existing_transports() {
3119        let selector = TransportSelector::new();
3120        let message = create_test_message();
3121
3122        // Representative metrics: RSSI -60, congestion 0.15, queue_depth 5, battery 75%.
3123        let metrics = create_test_metrics(Some(-60), 0.15, 5);
3124
3125        let ble = selector.calculate_transport_score(&message, TransportType::BLE, &metrics);
3126        let wifi =
3127            selector.calculate_transport_score(&message, TransportType::WiFiDirect, &metrics);
3128        let inet = selector.calculate_transport_score(&message, TransportType::Internet, &metrics);
3129
3130        // Verify all scores are finite and non-negative.
3131        for (label, score) in [("BLE", &ble), ("WiFiDirect", &wifi), ("Internet", &inet)] {
3132            assert!(
3133                score.total.is_finite(),
3134                "{label} score must be finite, got {}",
3135                score.total,
3136            );
3137            // Internet is demoted to a negative fallback sentinel under the
3138            // default config; mesh transports stay >= 0.
3139            if label != "Internet" {
3140                assert!(
3141                    score.total >= 0.0,
3142                    "{label} score must be non-negative, got {}",
3143                    score.total,
3144                );
3145            }
3146        }
3147
3148        // With good RSSI (-60) and 75% battery, BLE's energy-efficiency weighting
3149        // makes it score higher than Internet (which has a baseline boost but
3150        // lower energy score). This is the expected DORS behavior.
3151        assert!(
3152            ble.total > inet.total,
3153            "BLE ({:.1}) should score higher than Internet ({:.1}) with good signal and 75% battery",
3154            ble.total,
3155            inet.total,
3156        );
3157
3158        // All sub-scores must be in [0, 100].
3159        for (label, score) in [("BLE", &ble), ("WiFiDirect", &wifi), ("Internet", &inet)] {
3160            assert!(
3161                score.signal >= 0.0 && score.signal <= 100.0,
3162                "{label} signal"
3163            );
3164            assert!(
3165                score.bandwidth >= 0.0 && score.bandwidth <= 100.0,
3166                "{label} bandwidth"
3167            );
3168            assert!(
3169                score.energy >= 0.0 && score.energy <= 100.0,
3170                "{label} energy"
3171            );
3172            assert!(
3173                score.reliability >= 0.0 && score.reliability <= 100.0,
3174                "{label} reliability"
3175            );
3176        }
3177    }
3178
3179    #[test]
3180    fn test_reticulum_scores_as_resilience_fallback() {
3181        let mut selector = TransportSelector::new();
3182        let message = create_test_message();
3183
3184        // All four transports available with decent metrics.
3185        let mut transports = HashMap::new();
3186        transports.insert(TransportType::Internet, create_test_metrics(None, 0.1, 2));
3187        transports.insert(
3188            TransportType::WiFiDirect,
3189            create_test_metrics(Some(-55), 0.1, 3),
3190        );
3191        transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 2));
3192        transports.insert(
3193            TransportType::Reticulum,
3194            create_test_metrics(Some(-65), 0.1, 2),
3195        );
3196
3197        let selected = selector.select_transport(&message, &transports).unwrap();
3198        assert_ne!(
3199            selected,
3200            TransportType::Reticulum,
3201            "Reticulum should not win when higher-bandwidth transports are available"
3202        );
3203    }
3204
3205    #[test]
3206    fn test_reticulum_wins_when_only_available() {
3207        let mut selector = TransportSelector::new();
3208        let message = create_test_message();
3209
3210        let mut transports = HashMap::new();
3211        transports.insert(
3212            TransportType::Reticulum,
3213            create_test_metrics(Some(-60), 0.1, 2),
3214        );
3215
3216        let selected = selector.select_transport(&message, &transports).unwrap();
3217        assert_eq!(
3218            selected,
3219            TransportType::Reticulum,
3220            "Reticulum must be selected when it is the only available transport"
3221        );
3222    }
3223
3224    #[test]
3225    fn test_reticulum_tie_break_is_lowest_priority() {
3226        let selector = TransportSelector::new();
3227
3228        let scores = [(TransportType::Reticulum, 50.0), (TransportType::BLE, 50.0)];
3229        assert_eq!(
3230            selector.select_from_scores_for_test(&scores),
3231            Some(TransportType::BLE),
3232            "Tie-break: BLE > Reticulum"
3233        );
3234
3235        let all = [
3236            (TransportType::Internet, 50.0),
3237            (TransportType::WiFiDirect, 50.0),
3238            (TransportType::BLE, 50.0),
3239            (TransportType::Reticulum, 50.0),
3240        ];
3241        assert_eq!(
3242            selector.select_from_scores_for_test(&all),
3243            Some(TransportType::Internet),
3244            "Tie-break: Internet > WiFiDirect > BLE > Reticulum"
3245        );
3246    }
3247
3248    #[test]
3249    fn test_reticulum_scoring_profile_produces_valid_scores() {
3250        let selector = TransportSelector::new();
3251        let message = create_test_message();
3252        let metrics = create_test_metrics(Some(-70), 0.2, 5);
3253
3254        let score =
3255            selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3256        assert!(
3257            score.total.is_finite() && score.total >= 0.0,
3258            "Reticulum score must be finite and non-negative"
3259        );
3260        assert!(score.signal.is_finite() && score.signal >= 0.0);
3261        assert!(score.bandwidth.is_finite() && score.bandwidth >= 0.0);
3262        assert!(score.energy.is_finite() && score.energy >= 0.0);
3263        assert!(score.reliability.is_finite() && score.reliability >= 0.0);
3264    }
3265
3266    #[test]
3267    fn test_reticulum_penalised_for_media_preference() {
3268        let selector = TransportSelector::new();
3269        let mut message = create_test_message();
3270        let metrics = create_test_metrics(Some(-65), 0.1, 2);
3271
3272        let score_normal =
3273            selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3274
3275        message
3276            .metadata
3277            .insert("transport_preference".to_string(), "internet".to_string());
3278        let score_media =
3279            selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3280
3281        assert!(
3282            score_media.total < score_normal.total,
3283            "Reticulum should be penalised when message prefers internet (media). normal={:.1}, media={:.1}",
3284            score_normal.total,
3285            score_media.total
3286        );
3287    }
3288
3289    /// Verifies that `prefer_online` does not give Reticulum an unintended boost.
3290    #[test]
3291    fn test_reticulum_not_boosted_by_prefer_online() {
3292        let selector_prefer = TransportSelector::with_config(DorsConfig {
3293            prefer_online: true,
3294            ..Default::default()
3295        });
3296        let selector_default = TransportSelector::new();
3297        let message = create_test_message();
3298        let metrics = create_test_metrics(Some(-65), 0.1, 2);
3299
3300        let score_prefer =
3301            selector_prefer.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3302        let score_default = selector_default.calculate_transport_score(
3303            &message,
3304            TransportType::Reticulum,
3305            &metrics,
3306        );
3307
3308        assert!(
3309            (score_prefer.total - score_default.total).abs() < 0.01,
3310            "prefer_online must not change Reticulum score. prefer={:.1}, default={:.1}",
3311            score_prefer.total,
3312            score_default.total,
3313        );
3314    }
3315}