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