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